Improve deal board activity cues with relative dates and a quick activity list popup.

EOF

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
2026-11-12 16:27:32 +03:30
co-authored by Cursor
parent f4f2311ae4
commit fec5ece255
8 changed files with 431 additions and 54 deletions
+115 -1
View File
@@ -4,6 +4,10 @@ import {
NotFoundException,
} from '@nestjs/common';
import type {
ActivityStatus,
ActivityType,
DealActivityCue,
DealActivitySummaryDto,
DealDto,
MergePipelineResultDto,
PipelineBoardDto,
@@ -374,12 +378,23 @@ export class PipelinesService {
});
const rateMap = await loadOrgRates(this.prisma, organizationId);
const activityHints = await this.loadDealActivityHints(
organizationId,
deals.map((deal) => deal.id),
);
const byStage = new Map<string, DealDto[]>();
for (const stage of resolved.stages) byStage.set(stage.id, []);
for (const deal of deals) {
const list = byStage.get(deal.stageId);
if (list) list.push(this.toDealDto(deal));
if (list) {
const hint = activityHints.get(deal.id);
list.push({
...this.toDealDto(deal),
activityCue: hint?.cue ?? 'none',
activitySummaries: hint?.summaries ?? [],
});
}
}
return {
@@ -597,6 +612,105 @@ export class PipelinesService {
};
}
private async loadDealActivityHints(
organizationId: string,
dealIds: string[],
): Promise<
Map<string, { cue: DealActivityCue; summaries: DealActivitySummaryDto[] }>
> {
const hints = new Map<
string,
{ cue: DealActivityCue; summaries: DealActivitySummaryDto[] }
>();
if (!dealIds.length) return hints;
const rows = await this.prisma.activity.findMany({
where: {
organizationId,
dealId: { in: dealIds },
status: { in: ['open', 'done'] },
},
select: {
id: true,
dealId: true,
title: true,
type: true,
status: true,
dueAt: true,
completedAt: true,
},
});
type Row = {
id: string;
title: string;
type: string;
status: 'open' | 'done';
dueAt: Date | null;
completedAt: Date | null;
};
const byDeal = new Map<string, Row[]>();
for (const row of rows) {
if (!row.dealId) continue;
const list = byDeal.get(row.dealId) ?? [];
list.push({
id: row.id,
title: row.title,
type: row.type,
status: row.status === 'done' ? 'done' : 'open',
dueAt: row.dueAt,
completedAt: row.completedAt,
});
byDeal.set(row.dealId, list);
}
const now = Date.now();
const cueOf = (item: Row): Exclude<DealActivityCue, 'none'> => {
if (item.status === 'done') return 'done';
if (item.dueAt && item.dueAt.getTime() < now) return 'overdue';
return 'upcoming';
};
const rank = (cue: Exclude<DealActivityCue, 'none'>) =>
cue === 'overdue' ? 0 : cue === 'upcoming' ? 1 : 2;
for (const dealId of dealIds) {
const list = byDeal.get(dealId) ?? [];
const summaries = list
.map((item) => {
const cue = cueOf(item);
return {
id: item.id,
title: item.title,
type: item.type as ActivityType,
status: item.status as ActivityStatus,
dueAt: item.dueAt?.toISOString() ?? null,
cue,
_due: item.dueAt?.getTime() ?? Number.POSITIVE_INFINITY,
_done: item.completedAt?.getTime() ?? 0,
};
})
.sort((a, b) => {
const byRank = rank(a.cue) - rank(b.cue);
if (byRank) return byRank;
if (a.cue === 'done' && b.cue === 'done') return b._done - a._done;
return a._due - b._due;
})
.map(({ _due: _d, _done: _c, ...summary }) => summary);
const open = list.filter((item) => item.status === 'open');
let cue: DealActivityCue = 'none';
if (open.some((item) => item.dueAt && item.dueAt.getTime() < now)) {
cue = 'overdue';
} else if (open.length) {
cue = 'upcoming';
} else if (list.some((item) => item.status === 'done')) {
cue = 'done';
}
hints.set(dealId, { cue, summaries });
}
return hints;
}
toDealDto(row: DealRow): DealDto {
return {
id: row.id,
+13 -4
View File
@@ -19,6 +19,7 @@ import {
theme,
} from 'ant-design-vue';
import DealQuickActivityPopover from '@/components/pipeline/DealQuickActivityPopover.vue';
import { formatJalaliNumeric } from '@/lib/jalali';
import { userAvatarSrc } from '@/lib/userAvatars';
import type { ActivityPriority } from '@mento/shared';
import type { PipelineAccent, PipelineDeal, PipelineUiCopy } from '@/lib/pipeline-board';
@@ -73,6 +74,12 @@ const extraAssignees = computed(() =>
Math.max(0, (props.deal.assigneeNames?.length ?? 0) - 1),
);
const showTrash = computed(() => props.selectMode && props.selected);
const dateHint = computed(() => {
if (!props.deal.dateIso) return props.deal.date;
const d = new Date(props.deal.dateIso);
if (Number.isNaN(d.getTime())) return props.deal.date;
return formatJalaliNumeric(d);
});
const PRIORITY_FLAG: Record<ActivityPriority, { label: string; color: string }> = {
very_high: { label: 'خیلی زیاد', color: '#dc2626' },
high: { label: 'زیاد', color: '#ea580c' },
@@ -236,10 +243,12 @@ const cardStyle = computed(() => ({
<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-tooltip :title="dateHint">
<a-typography-text type="secondary" :style="{ fontSize: `${token.fontSizeSM}px` }">
<CalendarOutlined :style="{ marginInlineEnd: '4px' }" />
{{ deal.date }}
</a-typography-text>
</a-tooltip>
</a-space>
<a-space :size="4" align="center" :style="{ flexShrink: 0 }">
<a-typography-text
@@ -3,7 +3,9 @@ import { computed, nextTick, onBeforeUnmount, onMounted, ref, watch } from 'vue'
import { useRouter } from 'vue-router';
import {
Alert as AAlert,
Badge as ABadge,
Button as AButton,
Card as ACard,
Col as ACol,
Divider as ADivider,
Flex as AFlex,
@@ -17,15 +19,32 @@ import {
message,
theme,
} from 'ant-design-vue';
import { CalendarOutlined, CloseOutlined, FormOutlined } from '@ant-design/icons-vue';
import type { ActivityDto, ActivityType, CreateActivityRequest } from '@mento/shared';
import {
CalendarOutlined,
CloseOutlined,
FormOutlined,
PlusOutlined,
} from '@ant-design/icons-vue';
import type {
ActivityDto,
ActivityType,
CreateActivityRequest,
DealActivityCue,
DealActivitySummaryDto,
} from '@mento/shared';
import { ACTIVITY_TYPE_LABELS } from '@mento/shared';
import JalaliDateInput from '@/components/JalaliDateInput.vue';
import { api } from '@/lib/api';
import { formatLocalDateInput } from '@/lib/jalali';
import {
formatLocalDateInput,
formatRelativeDayLabel,
formatClockFa,
toFaDigits,
} from '@/lib/jalali';
import type { PipelineDeal } from '@/lib/pipeline-board';
const { Title: ATypographyTitle, Link: ATypographyLink } = Typography;
const { Title: ATypographyTitle, Link: ATypographyLink, Text: ATypographyText } =
Typography;
const props = defineProps<{
deal: PipelineDeal;
@@ -39,6 +58,8 @@ const emit = defineEmits<{
const router = useRouter();
const { token } = theme.useToken();
type PanelMode = 'list' | 'create';
const open = ref(false);
const rendered = ref(false);
const placed = ref(false);
@@ -47,13 +68,51 @@ const frame = ref({ left: 12, top: 12, width: 360, maxHeight: 480, shift: 88 });
let hideTimer = 0;
let reveal = false;
const VIEW_PAD = 16;
const PREVIEW_LIMIT = 3;
const mode = ref<PanelMode>('create');
const listExpanded = ref(false);
const saving = ref(false);
const error = ref('');
const title = ref('');
const type = ref<ActivityType>('task');
const dueDate = ref('');
const dueTime = ref('');
const localCue = ref<DealActivityCue | null>(null);
const localSummaries = ref<DealActivitySummaryDto[] | null>(null);
const summaries = computed(
() => localSummaries.value ?? props.deal.activitySummaries ?? [],
);
const activityCue = computed<DealActivityCue>(
() => localCue.value ?? props.deal.activityCue ?? 'none',
);
const openCount = computed(
() => summaries.value.filter((item) => item.status === 'open').length,
);
const visibleSummaries = computed(() =>
listExpanded.value ? summaries.value : summaries.value.slice(0, PREVIEW_LIMIT),
);
const hiddenCount = computed(() =>
Math.max(0, summaries.value.length - PREVIEW_LIMIT),
);
const cueMeta = computed(() => {
switch (activityCue.value) {
case 'overdue':
return { color: token.value.colorError, title: 'فعالیت عقب‌افتاده' };
case 'done':
return { color: token.value.colorSuccess, title: 'فعالیت انجام‌شده' };
case 'none':
return { color: token.value.colorWarning, title: 'بدون فعالیت — افزودن' };
default:
return { color: token.value.colorTextSecondary, title: 'فعالیت‌های معامله' };
}
});
const typeOptions = computed(() =>
(Object.entries(ACTIVITY_TYPE_LABELS) as Array<[ActivityType, string]>)
@@ -66,6 +125,18 @@ const fieldItemStyle = computed(() => ({
width: '100%',
}));
const panelTitle = computed(() =>
mode.value === 'list' ? 'فعالیت‌های معامله' : 'ایجاد فعالیت سریع',
);
watch(
() => [props.deal.activityCue, props.deal.activitySummaries] as const,
() => {
localCue.value = null;
localSummaries.value = null;
},
);
function resetForm() {
title.value = `پیگیری: ${props.deal.title}`;
type.value = 'task';
@@ -74,6 +145,20 @@ function resetForm() {
error.value = '';
}
function cueDotColor(cue: Exclude<DealActivityCue, 'none'>) {
if (cue === 'overdue') return token.value.colorError;
if (cue === 'done') return token.value.colorSuccess;
return token.value.colorTextSecondary;
}
function activityWhen(item: DealActivitySummaryDto) {
if (!item.dueAt) return 'بدون موعد';
const d = new Date(item.dueAt);
if (Number.isNaN(d.getTime())) return '—';
const day = formatRelativeDayLabel(item.dueAt);
return `${day} · ${formatClockFa(d)}`;
}
function cardEl() {
return document.querySelector<HTMLElement>(
`.ant-card[data-card-id="${CSS.escape(props.deal.id)}"]`,
@@ -104,7 +189,9 @@ function liftColumn(el: HTMLElement, cardTop: number, height: number, vh: number
const room = scroller.scrollHeight - scroller.clientHeight - scroller.scrollTop;
const delta = Math.min(overflow, Math.max(0, room));
if (delta <= 8) return;
const motion = window.matchMedia('(prefers-reduced-motion: reduce)').matches ? 'auto' : 'smooth';
const motion = window.matchMedia('(prefers-reduced-motion: reduce)').matches
? 'auto'
: 'smooth';
scroller.scrollBy({ top: delta, behavior: motion });
}
@@ -171,6 +258,23 @@ function close() {
setOpen(false);
}
function openCreate() {
mode.value = 'create';
resetForm();
void nextTick().then(() => measure());
}
function backToList() {
mode.value = 'list';
error.value = '';
void nextTick().then(() => measure());
}
function goActivity(id: string) {
close();
void router.push({ name: 'activity-detail', params: { id } });
}
function onViewport() {
if (open.value) measure();
}
@@ -205,6 +309,8 @@ watch(open, async (isOpen) => {
return;
}
window.clearTimeout(hideTimer);
listExpanded.value = false;
mode.value = summaries.value.length ? 'list' : 'create';
resetForm();
rendered.value = true;
reveal = true;
@@ -280,9 +386,31 @@ async function submit() {
};
saving.value = true;
try {
await api.post<ActivityDto>('/activities', body);
const { data } = await api.post<ActivityDto>('/activities', body);
message.success('فعالیت ثبت شد');
close();
const dueMs = new Date(dueAt).getTime();
const cue: Exclude<DealActivityCue, 'none'> =
Number.isFinite(dueMs) && dueMs < Date.now() ? 'overdue' : 'upcoming';
const created: DealActivitySummaryDto = {
id: data.id,
title: data.title,
type: data.type,
status: data.status === 'done' ? 'done' : 'open',
dueAt: data.dueAt,
cue,
};
const next = [created, ...summaries.value.filter((item) => item.id !== created.id)];
localSummaries.value = next;
localCue.value =
next.some((item) => item.cue === 'overdue')
? 'overdue'
: next.some((item) => item.status === 'open')
? 'upcoming'
: next.some((item) => item.cue === 'done')
? 'done'
: 'none';
mode.value = 'list';
void nextTick().then(() => measure());
} catch {
error.value = 'ثبت فعالیت ناموفق بود.';
} finally {
@@ -292,25 +420,31 @@ async function submit() {
</script>
<template>
<a-tooltip title="افزودن فعالیت">
<a-button
type="text"
:disabled="disabled"
aria-label="افزودن فعالیت سریع"
:aria-expanded="open"
:style="{
color: open ? token.colorPrimary : token.colorTextSecondary,
width: '28px',
minWidth: '28px',
height: '28px',
padding: 0,
}"
@click.stop="toggleOpen"
@mousedown.stop
@dragstart.stop.prevent
<a-tooltip :title="cueMeta.title">
<a-badge
:count="openCount"
:offset="[-2, 2]"
:number-style="{ fontSize: '10px', minWidth: '16px', height: '16px', lineHeight: '16px' }"
>
<template #icon><CalendarOutlined /></template>
</a-button>
<a-button
type="text"
:disabled="disabled"
aria-label="فعالیت‌های معامله"
:aria-expanded="open"
:style="{
color: open ? token.colorPrimary : cueMeta.color,
width: '28px',
minWidth: '28px',
height: '28px',
padding: 0,
}"
@click.stop="toggleOpen"
@mousedown.stop
@dragstart.stop.prevent
>
<template #icon><CalendarOutlined /></template>
</a-button>
</a-badge>
</a-tooltip>
<Teleport to="body">
@@ -319,17 +453,71 @@ async function submit() {
ref="panelEl"
role="dialog"
dir="rtl"
:aria-label="`ایجاد فعالیت سریع برای ${deal.title}`"
:aria-label="`${panelTitle} برای ${deal.title}`"
:style="panelStyle"
>
<a-flex justify="space-between" align="center" :style="{ marginBottom: `${token.marginSM}px` }">
<a-typography-title :level="5" :style="{ margin: 0 }">ایجاد فعالیت سریع</a-typography-title>
<a-button type="text" aria-label="بستن" @click="close">
<template #icon><CloseOutlined /></template>
</a-button>
<a-typography-title :level="5" :style="{ margin: 0 }">{{ panelTitle }}</a-typography-title>
<a-space :size="4">
<a-button
v-if="mode === 'list'"
type="text"
aria-label="فعالیت جدید"
@click="openCreate"
>
<template #icon><PlusOutlined /></template>
</a-button>
<a-button type="text" aria-label="بستن" @click="close">
<template #icon><CloseOutlined /></template>
</a-button>
</a-space>
</a-flex>
<a-flex vertical :gap="token.marginSM">
<template v-if="mode === 'list'">
<a-flex vertical :gap="8">
<a-card
v-for="item in visibleSummaries"
:key="item.id"
hoverable
:bordered="true"
:body-style="{ padding: '10px 12px' }"
@click="goActivity(item.id)"
>
<a-flex align="flex-start" :gap="10">
<span
aria-hidden="true"
:style="{
width: '8px',
height: '8px',
marginTop: '6px',
borderRadius: '999px',
background: cueDotColor(item.cue),
flexShrink: 0,
}"
/>
<div :style="{ minWidth: 0, flex: 1 }">
<a-typography-text strong :style="{ display: 'block' }">
{{ item.title }}
</a-typography-text>
<a-typography-text type="secondary" :style="{ fontSize: `${token.fontSizeSM}px` }">
{{ ACTIVITY_TYPE_LABELS[item.type] || item.type }}
·
{{ activityWhen(item) }}
</a-typography-text>
</div>
</a-flex>
</a-card>
<a-typography-link
v-if="!listExpanded && hiddenCount > 0"
@click="listExpanded = true"
>
مشاهده همه ({{ toFaDigits(summaries.length) }})
</a-typography-link>
</a-flex>
</template>
<a-flex v-else vertical :gap="token.marginSM">
<a-alert v-if="error" type="error" show-icon :message="error" />
<a-form-item label="عنوان" :style="fieldItemStyle">
@@ -361,12 +549,19 @@ async function submit() {
<a-divider :style="{ margin: `${token.marginXS}px 0` }" />
<a-flex justify="space-between" align="center" wrap="wrap" :gap="8">
<a-typography-link @click="goFullForm">
<FormOutlined />
فرم کامل
</a-typography-link>
<a-space :size="8">
<a-button :disabled="saving" @click="close">انصراف</a-button>
<a-typography-link v-if="summaries.length" @click="backToList">
بازگشت به لیست
</a-typography-link>
<a-typography-link @click="goFullForm">
<FormOutlined />
فرم کامل
</a-typography-link>
</a-space>
<a-space :size="8">
<a-button :disabled="saving" @click="summaries.length ? backToList() : close()">
انصراف
</a-button>
<a-button type="primary" :loading="saving" @click="submit">ذخیره</a-button>
</a-space>
</a-flex>
@@ -15,11 +15,13 @@ import {
Space as ASpace,
Table as ATable,
Tag as ATag,
Tooltip as ATooltip,
Typography,
theme,
} from 'ant-design-vue';
import type { TableColumnsType } from 'ant-design-vue';
import TableColumnsMenu from '@/components/TableColumnsMenu.vue';
import { formatJalaliNumeric } from '@/lib/jalali';
import type {
PipelineAccent,
PipelineDeal,
@@ -82,6 +84,13 @@ function money(deal: PipelineDeal) {
return formatCurrencyParts(deal.amount ?? 0, deal.currencyUnit ?? 'ریال');
}
function dateHint(deal: PipelineDeal) {
if (!deal.dateIso) return deal.date;
const d = new Date(deal.dateIso);
if (Number.isNaN(d.getTime())) return deal.date;
return formatJalaliNumeric(d);
}
const hideAmount = computed(() => Boolean(props.copy.hideAmount));
const tableKey = computed(() => (hideAmount.value ? 'pipeline.leads' : 'pipeline.deals'));
@@ -213,10 +222,12 @@ onUnmounted(() => {
</span>
</template>
<template v-else-if="column.key === 'date'">
<a-space :size="6">
<CalendarOutlined />
<span>{{ record.deal.date }}</span>
</a-space>
<a-tooltip :title="dateHint(record.deal)">
<a-space :size="6">
<CalendarOutlined />
<span>{{ record.deal.date }}</span>
</a-space>
</a-tooltip>
</template>
<template v-else-if="column.key === 'actions'">
<span :data-card-id="record.deal.id">
@@ -334,10 +345,12 @@ onUnmounted(() => {
{{ row.stageTitle }}
</a-tag>
<a-flex justify="space-between" align="center">
<a-space :size="6">
<CalendarOutlined />
<a-typography-text type="secondary">{{ row.deal.date }}</a-typography-text>
</a-space>
<a-tooltip :title="dateHint(row.deal)">
<a-space :size="6">
<CalendarOutlined />
<a-typography-text type="secondary">{{ row.deal.date }}</a-typography-text>
</a-space>
</a-tooltip>
<span v-if="!hideAmount" dir="ltr">
<a-typography-text strong>{{ money(row.deal).number }}</a-typography-text>
<a-typography-text type="secondary" :style="{ marginInlineStart: '6px' }">
+11
View File
@@ -226,6 +226,17 @@ export function formatRelativeContact(iso: string | null | undefined): string {
return `${toFaDigits(days)} روز پیش`;
}
/** تاریخ نسبی تقویمی برای کارت‌ها: امروز / ۳ روز پیش / ۵ روز مانده */
export function formatRelativeDayLabel(iso: string | null | undefined): string {
if (!iso) return '—';
const d = new Date(iso);
if (Number.isNaN(d.getTime())) return '—';
const daysAhead = calendarDaysBetween(new Date(), d);
if (daysAhead === 0) return 'امروز';
if (daysAhead > 0) return `${toFaDigits(daysAhead)} روز مانده`;
return `${toFaDigits(-daysAhead)} روز پیش`;
}
/** فعالیت بعدی: امروز ۱۶:۳۰ / فردا ۱۰:۰۰ / ۲۴ مرداد ۱۱:۲۰ */
export function formatNextSlot(iso: string | null | undefined): string {
if (!iso) return '—';
+17 -1
View File
@@ -1,6 +1,10 @@
import { currencySuffix } from '@/lib/money';
import { matchesSearch } from '@/lib/search';
import type { ActivityPriority } from '@mento/shared';
import type {
ActivityPriority,
DealActivityCue,
DealActivitySummaryDto,
} from '@mento/shared';
export type PipelineAccent = 'green' | 'orange' | 'purple' | 'blue' | 'rose' | 'slate';
@@ -10,6 +14,8 @@ export type PipelineDeal = {
description: string;
amount: number | null;
date: string;
/** ISO برای tooltip تاریخ مطلق */
dateIso?: string;
initials: string;
/** شناسه مرحله فعلی — هنگام جابه‌جایی به‌روز می‌شود */
stageId?: string;
@@ -26,6 +32,10 @@ export type PipelineDeal = {
searchFields?: Array<string | null | undefined>;
/** اولویت فعالیت؛ فقط روی کارت فعالیت نشان داده می‌شود */
priority?: ActivityPriority;
/** نشانگر وضعیت فعالیت معامله روی دکمهٔ تقویم */
activityCue?: DealActivityCue;
/** خلاصه فعالیت‌ها برای پاپ‌آپ دکمهٔ تقویم */
activitySummaries?: DealActivitySummaryDto[];
};
export type PipelineUiCopy = {
@@ -190,6 +200,8 @@ export function mapBoardToStages(
expectedCloseAt: string | null;
createdAt: string;
promotedFromPriorAt: string | null;
activityCue?: DealActivityCue;
activitySummaries?: DealActivitySummaryDto[];
person: { id?: string; fullName: string } | null;
company: { id?: string; name: string } | null;
owner?: { id: string; fullName: string } | null;
@@ -234,6 +246,9 @@ export function mapBoardToStages(
description,
amount: deal.amount ?? 0,
date: formatDate(dateIso),
dateIso,
activityCue: deal.activityCue ?? 'none',
activitySummaries: deal.activitySummaries ?? [],
initials: initialsFromTitle(
deal.company?.name || deal.title,
),
@@ -301,6 +316,7 @@ export function mapProjectBoardToStages(
'',
amount: null,
date: formatDate(card.createdAt),
dateIso: card.createdAt,
initials: initialsFromTitle(
card.company?.name || card.person?.fullName || card.title,
),
+2 -4
View File
@@ -3,7 +3,7 @@ import { computed, nextTick, onBeforeUnmount, onMounted, ref, watch } from 'vue'
import { useRoute, useRouter } from 'vue-router';
import axios from 'axios';
import { api } from '@/lib/api';
import { formatJalaliNumeric, toFaDigits } from '@/lib/jalali';
import { formatRelativeDayLabel, toFaDigits } from '@/lib/jalali';
import PipelineToolbar from '@/components/pipeline/PipelineToolbar.vue';
import type { PipelineViewMode } from '@/components/pipeline/PipelineToolbar.vue';
import PipelineBoard from '@/components/pipeline/PipelineBoard.vue';
@@ -165,9 +165,7 @@ function applyDealsSavedFilters(filters: Record<string, unknown>) {
}
function formatDealDate(iso: string) {
const d = new Date(iso);
if (Number.isNaN(d.getTime())) return iso;
return formatJalaliNumeric(d);
return formatRelativeDayLabel(iso);
}
function findAccent(dealId: string): PipelineStage['color'] {
+21
View File
@@ -1623,6 +1623,13 @@ export interface DealDto {
lineItems: DealLineItemDto[];
createdAt: string;
updatedAt: string;
/**
* فقط در برد کانبان پر می‌شود:
* none=بدون فعالیت، overdue=عقب‌افتاده، upcoming=آینده، done=انجام‌شده
*/
activityCue?: DealActivityCue;
/** لیست مرتب‌شدهٔ فعالیت‌های باز/انجام‌شده برای پاپ‌آپ کارت */
activitySummaries?: DealActivitySummaryDto[];
}
export interface PipelineBoardStageDto extends PipelineStageDto {
@@ -1849,6 +1856,20 @@ export interface MoveDealRequest {
export type ActivityType = "call" | "meeting" | "task" | "note";
export type ActivityStatus = "open" | "done" | "canceled";
/** وضعیت فعالیت مرتبط با معامله برای نشانگر کارت کانبان */
export type DealActivityCue = "none" | "upcoming" | "overdue" | "done";
/** خلاصهٔ کوتاه فعالیت روی کارت معامله */
export interface DealActivitySummaryDto {
id: string;
title: string;
type: ActivityType;
status: ActivityStatus;
dueAt: string | null;
/** وضعیت نسبی همین آیتم برای رنگ نقطه */
cue: Exclude<DealActivityCue, "none">;
}
export type ActivityPriority = "very_low" | "low" | "medium" | "high" | "very_high";
export type ActivityFilter = "today" | "overdue" | "upcoming" | "done" | "all";