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>
318 lines
8.9 KiB
Vue
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>
|