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>
364 lines
11 KiB
Vue
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>
|