Reset hung move queues, defer drag visuals past dragstart, and prefer card drop identity so HTML5 DnD no longer dies mid-drag. Co-authored-by: Cursor <cursoragent@cursor.com>
286 lines
9.5 KiB
Vue
286 lines
9.5 KiB
Vue
<script setup lang="ts">
|
|
import { computed, ref } from 'vue';
|
|
import {
|
|
CalendarOutlined,
|
|
CheckOutlined,
|
|
DeleteOutlined,
|
|
FlagFilled,
|
|
MoreOutlined,
|
|
} from '@ant-design/icons-vue';
|
|
import {
|
|
Avatar as AAvatar,
|
|
Button as AButton,
|
|
Card as ACard,
|
|
Flex as AFlex,
|
|
Space as ASpace,
|
|
Tag as ATag,
|
|
Tooltip as ATooltip,
|
|
Typography,
|
|
theme,
|
|
} from 'ant-design-vue';
|
|
import DealQuickActivityPopover from '@/components/pipeline/DealQuickActivityPopover.vue';
|
|
import { userAvatarSrc } from '@/lib/userAvatars';
|
|
import type { ActivityPriority } from '@mento/shared';
|
|
import type { PipelineAccent, PipelineDeal, PipelineUiCopy } from '@/lib/pipeline-board';
|
|
import { DEAL_BOARD_COPY, formatCurrencyParts } from '@/lib/pipeline-board';
|
|
|
|
const props = withDefaults(
|
|
defineProps<{
|
|
deal: PipelineDeal;
|
|
accent: PipelineAccent;
|
|
dragging?: boolean;
|
|
copy?: PipelineUiCopy;
|
|
selectMode?: boolean;
|
|
selected?: boolean;
|
|
peeking?: boolean;
|
|
/** پنل کناری همین کارت باز است؛ فلش را برمیگرداند. جدا از هایلایتِ بعد از بستن. */
|
|
previewing?: boolean;
|
|
}>(),
|
|
{ copy: () => DEAL_BOARD_COPY },
|
|
);
|
|
|
|
const emit = defineEmits<{
|
|
dragStart: [deal: PipelineDeal, event: DragEvent];
|
|
dragEnd: [];
|
|
open: [dealId: string];
|
|
detail: [dealId: string];
|
|
menu: [dealId: string];
|
|
toggleSelect: [dealId: string];
|
|
bulkDelete: [];
|
|
}>();
|
|
|
|
const { Text: ATypographyText } = Typography;
|
|
const { token } = theme.useToken();
|
|
|
|
const activityOpen = ref(false);
|
|
const highlighted = computed(
|
|
() => props.selected || props.peeking || activityOpen.value,
|
|
);
|
|
|
|
const showAmount = computed(
|
|
() => !props.copy.hideAmount && props.deal.amount != null,
|
|
);
|
|
const money = computed(() =>
|
|
formatCurrencyParts(props.deal.amount ?? 0, props.deal.currencyUnit ?? 'ریال'),
|
|
);
|
|
const assigneeTitle = computed(() =>
|
|
(props.deal.assigneeNames ?? []).filter(Boolean).join('، ') || 'مسئول',
|
|
);
|
|
const avatarSrc = computed(() =>
|
|
userAvatarSrc(props.deal.ownerId || props.deal.assigneeIds?.[0] || null),
|
|
);
|
|
const extraAssignees = computed(() =>
|
|
Math.max(0, (props.deal.assigneeNames?.length ?? 0) - 1),
|
|
);
|
|
const showTrash = computed(() => props.selectMode && props.selected);
|
|
const PRIORITY_FLAG: Record<ActivityPriority, { label: string; color: string }> = {
|
|
very_high: { label: 'خیلی زیاد', color: '#dc2626' },
|
|
high: { label: 'زیاد', color: '#ea580c' },
|
|
medium: { label: 'متوسط', color: '#eab308' },
|
|
low: { label: 'کم', color: '#16a34a' },
|
|
very_low: { label: 'خیلی کم', color: '#94a3b8' },
|
|
};
|
|
const priorityFlag = computed(() =>
|
|
props.deal.priority ? PRIORITY_FLAG[props.deal.priority] : null,
|
|
);
|
|
|
|
function onTitleClick() {
|
|
if (props.selectMode) {
|
|
emit('toggleSelect', props.deal.id);
|
|
return;
|
|
}
|
|
if (props.copy.quickView) emit('detail', props.deal.id);
|
|
else emit('open', props.deal.id);
|
|
}
|
|
|
|
const chevronStyle = computed(() => ({
|
|
display: 'inline-block',
|
|
width: '0.42rem',
|
|
height: '0.42rem',
|
|
borderRight: '1.6px solid currentColor',
|
|
borderBottom: '1.6px solid currentColor',
|
|
transform: props.previewing ? 'rotate(-45deg)' : 'rotate(135deg)',
|
|
transition: 'transform 0.15s ease',
|
|
}));
|
|
|
|
const cardStyle = computed(() => ({
|
|
cursor: props.selectMode ? 'pointer' : 'grab',
|
|
// تغییر opacity/transform وسط dragstart درگ HTML5 را در کروم لغو میکند؛ فقط سایه
|
|
opacity: props.dragging ? 0.55 : 1,
|
|
transition: 'opacity 120ms ease, box-shadow 120ms ease',
|
|
margin: 0,
|
|
position: highlighted.value ? 'relative' : undefined,
|
|
zIndex: highlighted.value ? 2 : undefined,
|
|
background: highlighted.value ? token.value.colorPrimaryBg : token.value.colorBgContainer,
|
|
borderColor: highlighted.value ? token.value.colorPrimary : token.value.colorBorderSecondary,
|
|
boxShadow: highlighted.value
|
|
? `0 0 0 2px ${token.value.colorPrimary}`
|
|
: props.dragging
|
|
? token.value.boxShadow
|
|
: token.value.boxShadowTertiary,
|
|
}));
|
|
</script>
|
|
|
|
<template>
|
|
<a-card
|
|
hoverable
|
|
:bordered="true"
|
|
:body-style="{ padding: '12px 14px', margin: 0 }"
|
|
:style="cardStyle"
|
|
:data-card-id="deal.id"
|
|
:draggable="!selectMode"
|
|
role="listitem"
|
|
:aria-grabbed="dragging ? 'true' : 'false'"
|
|
@dragstart="!selectMode && emit('dragStart', deal, $event)"
|
|
@dragend="emit('dragEnd')"
|
|
@dblclick="!selectMode && emit('open', deal.id)"
|
|
@click="selectMode && emit('toggleSelect', deal.id)"
|
|
>
|
|
<a-flex vertical :gap="10" :style="{ width: '100%', minWidth: 0 }">
|
|
<a-flex :gap="10" align="flex-start" :style="{ minWidth: 0 }">
|
|
<a-button
|
|
v-if="selectMode"
|
|
type="text"
|
|
shape="circle"
|
|
:aria-label="selected ? 'لغو انتخاب' : 'انتخاب معامله'"
|
|
:aria-pressed="selected"
|
|
:style="{
|
|
width: '28px',
|
|
height: '28px',
|
|
minWidth: '28px',
|
|
color: selected ? token.colorTextLightSolid : token.colorTextTertiary,
|
|
border: `2px solid ${selected ? token.colorPrimary : token.colorBorder}`,
|
|
background: selected ? token.colorPrimary : token.colorBgContainer,
|
|
}"
|
|
@click.stop="emit('toggleSelect', deal.id)"
|
|
>
|
|
<template #icon>
|
|
<CheckOutlined v-if="selected" />
|
|
</template>
|
|
</a-button>
|
|
<div :style="{ flex: 1, minWidth: 0 }">
|
|
<a-flex align="center" :gap="6" :style="{ minWidth: 0 }">
|
|
<a-tooltip v-if="copy.quickView && priorityFlag" :title="`اولویت: ${priorityFlag.label}`">
|
|
<FlagFilled :style="{ color: priorityFlag.color, fontSize: '14px', flexShrink: 0 }" />
|
|
</a-tooltip>
|
|
<a-typography-text
|
|
ellipsis
|
|
:style="{
|
|
flex: 1,
|
|
minWidth: 0,
|
|
display: 'block',
|
|
margin: 0,
|
|
lineHeight: 1.35,
|
|
cursor: 'pointer',
|
|
fontSize: `${token.fontSizeSM}px`,
|
|
fontWeight: 600,
|
|
}"
|
|
:title="deal.title"
|
|
@click.stop="onTitleClick"
|
|
@dblclick.stop
|
|
>
|
|
{{ deal.title }}
|
|
</a-typography-text>
|
|
</a-flex>
|
|
<a-typography-text
|
|
v-if="deal.description"
|
|
type="secondary"
|
|
ellipsis
|
|
:style="{
|
|
display: 'block',
|
|
margin: 0,
|
|
marginTop: '2px',
|
|
fontSize: `${token.fontSizeSM}px`,
|
|
}"
|
|
:title="deal.description"
|
|
>
|
|
{{ deal.description }}
|
|
</a-typography-text>
|
|
</div>
|
|
|
|
<span :data-card-id="deal.id">
|
|
<a-button
|
|
v-if="showTrash"
|
|
class="menu"
|
|
type="text"
|
|
danger
|
|
aria-label="حذف انتخابشدهها"
|
|
@click.stop="emit('bulkDelete')"
|
|
>
|
|
<template #icon><DeleteOutlined /></template>
|
|
</a-button>
|
|
<a-button
|
|
v-else
|
|
class="menu"
|
|
type="text"
|
|
:aria-label="copy.itemMenuAria"
|
|
@click.stop="emit('menu', deal.id)"
|
|
>
|
|
<template #icon><MoreOutlined /></template>
|
|
</a-button>
|
|
</span>
|
|
</a-flex>
|
|
|
|
<a-flex
|
|
justify="space-between"
|
|
align="center"
|
|
:gap="8"
|
|
:style="{
|
|
minWidth: 0,
|
|
paddingTop: '8px',
|
|
borderTop: `1px solid ${token.colorSplit}`,
|
|
}"
|
|
>
|
|
<a-space :size="8" align="center" :style="{ minWidth: 0 }">
|
|
<a-avatar :src="avatarSrc" :size="24" :title="assigneeTitle" />
|
|
<a-tag v-if="extraAssignees > 0" :bordered="false" :title="assigneeTitle">
|
|
+{{ extraAssignees }}
|
|
</a-tag>
|
|
<a-typography-text type="secondary" :style="{ fontSize: `${token.fontSizeSM}px` }">
|
|
<CalendarOutlined :style="{ marginInlineEnd: '4px' }" />
|
|
{{ deal.date }}
|
|
</a-typography-text>
|
|
</a-space>
|
|
<a-space :size="4" align="center" :style="{ flexShrink: 0 }">
|
|
<a-typography-text
|
|
v-if="showAmount"
|
|
strong
|
|
dir="ltr"
|
|
:style="{ margin: 0, whiteSpace: 'nowrap' }"
|
|
>
|
|
{{ money.number }}
|
|
<a-typography-text type="secondary" :style="{ fontSize: `${token.fontSizeSM}px` }">
|
|
{{ money.unit }}
|
|
</a-typography-text>
|
|
</a-typography-text>
|
|
<a-button
|
|
v-if="copy.quickView && !selectMode"
|
|
type="text"
|
|
:aria-label="previewing ? 'بستن پیشنمایش' : 'پیشنمایش فعالیت'"
|
|
:aria-expanded="previewing"
|
|
:style="{ color: token.colorTextSecondary }"
|
|
@click.stop="emit('open', deal.id)"
|
|
>
|
|
<template #icon><i class="chev" aria-hidden="true" :style="chevronStyle" /></template>
|
|
</a-button>
|
|
<a-tooltip v-else-if="priorityFlag" :title="`اولویت: ${priorityFlag.label}`">
|
|
<span :style="{ display: 'inline-flex', flexShrink: 0, lineHeight: 1 }">
|
|
<FlagFilled :style="{ color: priorityFlag.color, fontSize: '16px' }" />
|
|
</span>
|
|
</a-tooltip>
|
|
<span
|
|
v-if="copy.quickActivity && !selectMode"
|
|
@click.stop
|
|
@mousedown.stop
|
|
>
|
|
<DealQuickActivityPopover
|
|
:deal="deal"
|
|
@update:open="activityOpen = $event"
|
|
/>
|
|
</span>
|
|
</a-space>
|
|
</a-flex>
|
|
</a-flex>
|
|
</a-card>
|
|
</template>
|