Files
CRM_Montzar/apps/web/src/components/pipeline/DealCard.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

268 lines
8.9 KiB
Vue

<script setup lang="ts">
import { computed } 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 { 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 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: props.dragging ? 0.4 : 1,
transform: props.dragging ? 'scale(0.98)' : 'none',
transition: 'opacity 180ms ease, transform 180ms ease, box-shadow 180ms ease',
margin: 0,
position: props.peeking ? 'relative' : undefined,
zIndex: props.peeking ? 2 : undefined,
background: props.selected || props.peeking ? token.value.colorPrimaryBg : token.value.colorBgContainer,
borderColor: props.selected || props.peeking ? token.value.colorPrimary : token.value.colorBorderSecondary,
boxShadow: props.peeking
? `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-typography-text
v-if="showAmount"
strong
dir="ltr"
:style="{ margin: 0, whiteSpace: 'nowrap', flexShrink: 0 }"
>
{{ 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>
</a-flex>
</a-flex>
</a-card>
</template>