Files
CRM_Montzar/apps/web/src/components/pipeline/PipelineColumn.vue
T
MahdiandCursor 4abab55a6b feat(web): migrate CRM screens to Ant Design Vue with central theme
Adopt ant-design-vue and a shared antdTheme for leads, pipeline, invoices, and shared form controls under strict single-page governance.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-10-18 19:39:59 +03:30

318 lines
8.9 KiB
Vue

<script setup lang="ts">
import { computed, nextTick, ref } from 'vue';
import {
HolderOutlined,
MoreOutlined,
PlusOutlined,
} 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, toFaItemCount } 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>;
}>(),
{ copy: () => DEAL_BOARD_COPY, selectedIds: () => new Set() },
);
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];
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 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 280px',
width: '280px',
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 onCardDragOver(stageId: string, event: DragEvent) {
event.preventDefault();
event.stopPropagation();
emit('dragOver', stageId, event);
}
function onCardDrop(stageId: string, index: number, event: DragEvent) {
event.preventDefault();
event.stopPropagation();
emit('drop', stageId, event, index);
}
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: 'rename', label: 'تغییر نام' },
{ key: 'right', label: 'انتقال به راست', disabled: props.isFirst },
{ key: 'left', label: 'انتقال به چپ', disabled: props.isLast },
{ key: 'delete', label: 'حذف ستون', danger: true },
]);
function onMenuClick({ key }: { key: string | number }) {
menuOpen.value = false;
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="emit('dragOver', stage.id, $event)"
@drop.prevent="emit('drop', stage.id, $event)"
>
<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
strong
ellipsis
:style="{
flex: 1,
minWidth: 0,
margin: 0,
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-typography-text type="secondary" :style="{ whiteSpace: 'nowrap', flexShrink: 0 }">
{{ toFaItemCount(stage.total ?? stage.deals.length, copy.itemNoun) }}
</a-typography-text>
<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>
<a-button
type="text"
:aria-label="copy.addItemAria"
:style="{ color: accent.solid }"
@click="emit('addDeal', stage.id)"
>
<template #icon><PlusOutlined /></template>
</a-button>
</div>
<div role="list" :aria-label="stage.title" :style="bodyStyle">
<div
v-for="(deal, index) in stage.deals"
:key="deal.id"
@dragover="onCardDragOver(stage.id, $event)"
@drop="onCardDrop(stage.id, index, $event)"
>
<DealCard
:deal="deal"
:accent="stage.color"
:dragging="draggingId === deal.id"
:copy="copy"
:select-mode="selectMode"
:selected="selectedIds.has(deal.id)"
@drag-start="(d, e) => emit('dragStart', d, e)"
@drag-end="emit('dragEnd')"
@open="emit('openDeal', $event)"
@menu="emit('menuDeal', $event)"
@toggle-select="emit('toggleSelect', $event)"
@bulk-delete="emit('bulkDelete')"
/>
</div>
<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" @add="emit('addDeal', stage.id)" />
</div>
</section>
</template>