Files
CRM_Montzar/apps/web/src/components/pipeline/PipelineColumn.vue
T
MahdiandCursor f13a1249af feat(activities): edit detail in place and tighten the board peek
Let people change activity fields and their own notes with photos from the detail page, open full detail from the card title, and toggle the side peek with a chevron.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-11-10 21:31:21 +03:30

364 lines
11 KiB
Vue

<script setup lang="ts">
import { computed, nextTick, ref } from 'vue';
import { HolderOutlined, MoreOutlined } from '@ant-design/icons-vue';
import {
Button as AButton,
Dropdown as ADropdown,
Input as AInput,
Menu as AMenu,
Tag as ATag,
Typography,
theme,
} from 'ant-design-vue';
import AddDealButton from './AddDealButton.vue';
import DealCard from './DealCard.vue';
import type { PipelineDeal, PipelineStage, PipelineUiCopy } from '@/lib/pipeline-board';
import { DEAL_BOARD_COPY, PIPELINE_ACCENT, dragScrollColumn, pointerDropIndex, stopDragScroll } from '@/lib/pipeline-board';
const props = withDefaults(
defineProps<{
stage: PipelineStage;
draggingId: string | null;
dropActive: boolean;
canManage?: boolean;
isFirst?: boolean;
isLast?: boolean;
copy?: PipelineUiCopy;
selectMode?: boolean;
selectedIds?: Set<string>;
peekId?: string | null;
previewId?: string | null;
}>(),
{ copy: () => DEAL_BOARD_COPY, selectedIds: () => new Set(), peekId: null, previewId: null },
);
const emit = defineEmits<{
dragStart: [deal: PipelineDeal, event: DragEvent];
dragEnd: [];
dragOver: [stageId: string, event: DragEvent];
drop: [stageId: string, event: DragEvent, index?: number];
addDeal: [stageId: string];
openDeal: [dealId: string];
detailDeal: [dealId: string];
menuDeal: [dealId: string];
toggleSelect: [dealId: string];
bulkDelete: [];
rename: [stageId: string, title: string];
delete: [stageId: string];
move: [stageId: string, dir: -1 | 1];
columnDragStart: [stageId: string, event: DragEvent];
columnDragEnd: [];
loadMore: [stageId: string];
}>();
const { Text: ATypographyText } = Typography;
const { token } = theme.useToken();
const bodyEl = ref<HTMLElement | null>(null);
const insertAt = ref<number | null>(null);
const renaming = ref(false);
const draft = ref('');
const titleInput = ref<{ focus: () => void; select?: () => void } | null>(null);
const menuOpen = ref(false);
const accent = computed(() => PIPELINE_ACCENT[props.stage.color]);
const columnStyle = computed(() => ({
boxSizing: 'border-box' as const,
display: 'flex',
flexDirection: 'column' as const,
gap: '12px',
flex: '0 0 310px',
width: '310px',
alignSelf: 'stretch' as const,
minHeight: 0,
padding: 0,
borderRadius: `${token.value.borderRadiusLG}px`,
border: `1px solid ${
props.dropActive ? token.value.colorPrimary : token.value.colorBorderSecondary
}`,
background: props.dropActive
? token.value.colorPrimaryBg
: token.value.colorFillAlter,
boxShadow: props.dropActive
? `0 0 0 2px ${token.value.colorPrimaryBg}`
: undefined,
overflow: 'hidden' as const,
}));
const headStyle = computed(() => ({
boxSizing: 'border-box' as const,
flexShrink: 0,
display: 'flex',
alignItems: 'center',
gap: '8px',
padding: '12px',
borderBottom: `1px solid ${token.value.colorBorderSecondary}`,
background: token.value.colorBgContainer,
minWidth: 0,
}));
const bodyStyle = computed(() => ({
boxSizing: 'border-box' as const,
flex: '1 1 auto',
height: 0,
minHeight: 0,
overflowY: 'auto' as const,
display: 'flex',
flexDirection: 'column' as const,
gap: '12px',
padding: '12px',
margin: 0,
}));
function trackDrag(event: DragEvent, index: number | null) {
event.preventDefault();
dragScrollColumn(bodyEl.value, event.clientY);
if (index != null) insertAt.value = index;
}
function clearDragUi() {
insertAt.value = null;
stopDragScroll();
}
function onCardDragOver(stageId: string, index: number, event: DragEvent) {
event.stopPropagation();
trackDrag(event, pointerDropIndex(index, event));
emit('dragOver', stageId, event);
}
function onColumnDrop(event: DragEvent) {
const at = insertAt.value ?? undefined;
clearDragUi();
emit('drop', props.stage.id, event, at);
}
function onCardDrop(stageId: string, index: number, event: DragEvent) {
event.preventDefault();
event.stopPropagation();
const at = insertAt.value ?? pointerDropIndex(index, event);
clearDragUi();
emit('drop', stageId, event, at);
}
function slotStyle(active: boolean, collapseGap = false) {
return {
height: active ? '36px' : '0px',
marginTop: !active && collapseGap ? '-12px' : '0',
borderRadius: `${token.value.borderRadius}px`,
background: active ? token.value.colorPrimaryBg : 'transparent',
outline: active ? `1px dashed ${token.value.colorPrimary}` : 'none',
outlineOffset: '-1px',
transition: 'height 180ms ease, margin-top 180ms ease, background 180ms ease',
overflow: 'hidden',
flexShrink: 0,
};
}
function startRename() {
if (!props.canManage) return;
draft.value = props.stage.title;
renaming.value = true;
menuOpen.value = false;
void nextTick(() => {
const el = titleInput.value as unknown as HTMLInputElement | null;
el?.focus?.();
el?.select?.();
});
}
function commitRename() {
if (!renaming.value) return;
const title = draft.value.trim();
renaming.value = false;
if (!title || title === props.stage.title) return;
emit('rename', props.stage.id, title);
}
function cancelRename() {
renaming.value = false;
draft.value = props.stage.title;
}
function onColumnDragStart(event: DragEvent) {
event.dataTransfer?.setData('text/plain', `stage:${props.stage.id}`);
event.dataTransfer!.effectAllowed = 'move';
emit('columnDragStart', props.stage.id, event);
}
const menuItems = computed(() => [
{ key: 'add', label: 'افزودن' },
{ key: 'rename', label: 'تغییر نام' },
{ key: 'right', label: 'انتقال به راست', disabled: props.isFirst },
{ key: 'left', label: 'انتقال به چپ', disabled: props.isLast },
{ key: 'delete', label: 'حذف ستون', danger: true },
]);
const itemCountLabel = computed(() =>
new Intl.NumberFormat('fa-IR').format(props.stage.total ?? props.stage.deals.length),
);
function onMenuClick({ key }: { key: string | number }) {
menuOpen.value = false;
if (key === 'add') emit('addDeal', props.stage.id);
else if (key === 'rename') startRename();
else if (key === 'right') emit('move', props.stage.id, -1);
else if (key === 'left') emit('move', props.stage.id, 1);
else if (key === 'delete') emit('delete', props.stage.id);
}
</script>
<template>
<section
:style="columnStyle"
@dragover.prevent="trackDrag($event, null); emit('dragOver', stage.id, $event)"
@drop.prevent="onColumnDrop"
>
<div :style="headStyle">
<span
v-if="canManage"
draggable="true"
title="کشیدن برای جابه‌جایی"
aria-label="جابه‌جایی ستون"
:style="{
display: 'inline-flex',
alignItems: 'center',
justifyContent: 'center',
width: '28px',
height: '28px',
flexShrink: 0,
borderRadius: `${token.borderRadiusSM}px`,
color: token.colorTextTertiary,
cursor: 'grab',
background: token.colorFillQuaternary,
}"
@dragstart="onColumnDragStart"
@dragend="emit('columnDragEnd')"
>
<HolderOutlined />
</span>
<span
:style="{
width: '8px',
height: '8px',
borderRadius: '50%',
background: accent.solid,
display: 'inline-block',
flexShrink: 0,
}"
aria-hidden="true"
/>
<a-input
v-if="renaming"
ref="titleInput"
v-model:value="draft"
:maxlength="80"
aria-label="نام ستون"
:style="{ flex: 1, minWidth: 0 }"
@pressEnter="commitRename"
@keydown.esc.prevent="cancelRename"
@blur="commitRename"
/>
<a-typography-text
v-else
ellipsis
:style="{
flex: 1,
minWidth: 0,
margin: 0,
fontSize: `${token.fontSizeSM}px`,
fontWeight: 600,
cursor: canManage ? 'text' : 'default',
}"
:title="canManage ? 'برای تغییر نام کلیک کنید' : stage.title"
@click="startRename"
>
{{ stage.title }}
</a-typography-text>
<a-tag v-if="stage.badge && !renaming" :bordered="false">
{{ stage.badge }}
</a-tag>
<a-dropdown
v-if="canManage"
v-model:open="menuOpen"
:trigger="['click']"
placement="bottomLeft"
>
<a-button type="text" aria-label="گزینه‌های ستون" @click.stop>
<template #icon><MoreOutlined /></template>
</a-button>
<template #overlay>
<a-menu :items="menuItems" @click="onMenuClick" />
</template>
</a-dropdown>
</div>
<div ref="bodyEl" role="list" :aria-label="stage.title" :style="bodyStyle">
<div
v-for="(deal, index) in stage.deals"
:key="deal.id"
@dragover="onCardDragOver(stage.id, index, $event)"
@drop="onCardDrop(stage.id, index, $event)"
>
<div
:style="slotStyle(dropActive && insertAt === index)"
@dragover.stop.prevent="trackDrag($event, index); emit('dragOver', stage.id, $event)"
@drop.stop.prevent="clearDragUi(); emit('drop', stage.id, $event, index)"
/>
<DealCard
:deal="deal"
:accent="stage.color"
:dragging="draggingId === deal.id"
:copy="copy"
:select-mode="selectMode"
:selected="selectedIds.has(deal.id)"
:peeking="peekId === deal.id"
:previewing="previewId === deal.id"
@drag-start="(d, e) => emit('dragStart', d, e)"
@drag-end="clearDragUi(); emit('dragEnd')"
@open="emit('openDeal', $event)"
@detail="emit('detailDeal', $event)"
@menu="emit('menuDeal', $event)"
@toggle-select="emit('toggleSelect', $event)"
@bulk-delete="emit('bulkDelete')"
/>
</div>
<div
:style="slotStyle(dropActive && insertAt === stage.deals.length, true)"
@dragover.stop.prevent="trackDrag($event, stage.deals.length); emit('dragOver', stage.id, $event)"
@drop.stop.prevent="clearDragUi(); emit('drop', stage.id, $event, stage.deals.length)"
/>
<a-typography-text
v-if="!stage.deals.length"
type="secondary"
:style="{ textAlign: 'center', display: 'block', margin: 'auto', padding: '16px' }"
>
{{ copy.empty }}
</a-typography-text>
<a-button
v-if="stage.hasMore"
type="dashed"
block
:loading="stage.loadingMore"
@click="emit('loadMore', stage.id)"
>
{{ stage.loadingMore ? 'در حال بارگذاری…' : 'نمایش بیشتر' }}
</a-button>
</div>
<div :style="{ padding: '0 12px 12px', flexShrink: 0 }">
<AddDealButton
:accent="stage.color"
:label="copy.addItem"
:count="itemCountLabel"
@add="emit('addDeal', stage.id)"
/>
</div>
</section>
</template>