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, NotFoundException,
} from '@nestjs/common'; } from '@nestjs/common';
import type { import type {
ActivityStatus,
ActivityType,
DealActivityCue,
DealActivitySummaryDto,
DealDto, DealDto,
MergePipelineResultDto, MergePipelineResultDto,
PipelineBoardDto, PipelineBoardDto,
@@ -374,12 +378,23 @@ export class PipelinesService {
}); });
const rateMap = await loadOrgRates(this.prisma, organizationId); const rateMap = await loadOrgRates(this.prisma, organizationId);
const activityHints = await this.loadDealActivityHints(
organizationId,
deals.map((deal) => deal.id),
);
const byStage = new Map<string, DealDto[]>(); const byStage = new Map<string, DealDto[]>();
for (const stage of resolved.stages) byStage.set(stage.id, []); for (const stage of resolved.stages) byStage.set(stage.id, []);
for (const deal of deals) { for (const deal of deals) {
const list = byStage.get(deal.stageId); 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 { 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 { toDealDto(row: DealRow): DealDto {
return { return {
id: row.id, id: row.id,
+13 -4
View File
@@ -19,6 +19,7 @@ import {
theme, theme,
} from 'ant-design-vue'; } from 'ant-design-vue';
import DealQuickActivityPopover from '@/components/pipeline/DealQuickActivityPopover.vue'; import DealQuickActivityPopover from '@/components/pipeline/DealQuickActivityPopover.vue';
import { formatJalaliNumeric } from '@/lib/jalali';
import { userAvatarSrc } from '@/lib/userAvatars'; import { userAvatarSrc } from '@/lib/userAvatars';
import type { ActivityPriority } from '@mento/shared'; import type { ActivityPriority } from '@mento/shared';
import type { PipelineAccent, PipelineDeal, PipelineUiCopy } from '@/lib/pipeline-board'; 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), Math.max(0, (props.deal.assigneeNames?.length ?? 0) - 1),
); );
const showTrash = computed(() => props.selectMode && props.selected); 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 }> = { const PRIORITY_FLAG: Record<ActivityPriority, { label: string; color: string }> = {
very_high: { label: 'خیلی زیاد', color: '#dc2626' }, very_high: { label: 'خیلی زیاد', color: '#dc2626' },
high: { label: 'زیاد', color: '#ea580c' }, high: { label: 'زیاد', color: '#ea580c' },
@@ -236,10 +243,12 @@ const cardStyle = computed(() => ({
<a-tag v-if="extraAssignees > 0" :bordered="false" :title="assigneeTitle"> <a-tag v-if="extraAssignees > 0" :bordered="false" :title="assigneeTitle">
+{{ extraAssignees }} +{{ extraAssignees }}
</a-tag> </a-tag>
<a-typography-text type="secondary" :style="{ fontSize: `${token.fontSizeSM}px` }"> <a-tooltip :title="dateHint">
<CalendarOutlined :style="{ marginInlineEnd: '4px' }" /> <a-typography-text type="secondary" :style="{ fontSize: `${token.fontSizeSM}px` }">
{{ deal.date }} <CalendarOutlined :style="{ marginInlineEnd: '4px' }" />
</a-typography-text> {{ deal.date }}
</a-typography-text>
</a-tooltip>
</a-space> </a-space>
<a-space :size="4" align="center" :style="{ flexShrink: 0 }"> <a-space :size="4" align="center" :style="{ flexShrink: 0 }">
<a-typography-text <a-typography-text
@@ -3,7 +3,9 @@ import { computed, nextTick, onBeforeUnmount, onMounted, ref, watch } from 'vue'
import { useRouter } from 'vue-router'; import { useRouter } from 'vue-router';
import { import {
Alert as AAlert, Alert as AAlert,
Badge as ABadge,
Button as AButton, Button as AButton,
Card as ACard,
Col as ACol, Col as ACol,
Divider as ADivider, Divider as ADivider,
Flex as AFlex, Flex as AFlex,
@@ -17,15 +19,32 @@ import {
message, message,
theme, theme,
} from 'ant-design-vue'; } from 'ant-design-vue';
import { CalendarOutlined, CloseOutlined, FormOutlined } from '@ant-design/icons-vue'; import {
import type { ActivityDto, ActivityType, CreateActivityRequest } from '@mento/shared'; 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 { ACTIVITY_TYPE_LABELS } from '@mento/shared';
import JalaliDateInput from '@/components/JalaliDateInput.vue'; import JalaliDateInput from '@/components/JalaliDateInput.vue';
import { api } from '@/lib/api'; 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'; import type { PipelineDeal } from '@/lib/pipeline-board';
const { Title: ATypographyTitle, Link: ATypographyLink } = Typography; const { Title: ATypographyTitle, Link: ATypographyLink, Text: ATypographyText } =
Typography;
const props = defineProps<{ const props = defineProps<{
deal: PipelineDeal; deal: PipelineDeal;
@@ -39,6 +58,8 @@ const emit = defineEmits<{
const router = useRouter(); const router = useRouter();
const { token } = theme.useToken(); const { token } = theme.useToken();
type PanelMode = 'list' | 'create';
const open = ref(false); const open = ref(false);
const rendered = ref(false); const rendered = ref(false);
const placed = 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 hideTimer = 0;
let reveal = false; let reveal = false;
const VIEW_PAD = 16; const VIEW_PAD = 16;
const PREVIEW_LIMIT = 3;
const mode = ref<PanelMode>('create');
const listExpanded = ref(false);
const saving = ref(false); const saving = ref(false);
const error = ref(''); const error = ref('');
const title = ref(''); const title = ref('');
const type = ref<ActivityType>('task'); const type = ref<ActivityType>('task');
const dueDate = ref(''); const dueDate = ref('');
const dueTime = 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(() => const typeOptions = computed(() =>
(Object.entries(ACTIVITY_TYPE_LABELS) as Array<[ActivityType, string]>) (Object.entries(ACTIVITY_TYPE_LABELS) as Array<[ActivityType, string]>)
@@ -66,6 +125,18 @@ const fieldItemStyle = computed(() => ({
width: '100%', 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() { function resetForm() {
title.value = `پیگیری: ${props.deal.title}`; title.value = `پیگیری: ${props.deal.title}`;
type.value = 'task'; type.value = 'task';
@@ -74,6 +145,20 @@ function resetForm() {
error.value = ''; 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() { function cardEl() {
return document.querySelector<HTMLElement>( return document.querySelector<HTMLElement>(
`.ant-card[data-card-id="${CSS.escape(props.deal.id)}"]`, `.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 room = scroller.scrollHeight - scroller.clientHeight - scroller.scrollTop;
const delta = Math.min(overflow, Math.max(0, room)); const delta = Math.min(overflow, Math.max(0, room));
if (delta <= 8) return; 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 }); scroller.scrollBy({ top: delta, behavior: motion });
} }
@@ -171,6 +258,23 @@ function close() {
setOpen(false); 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() { function onViewport() {
if (open.value) measure(); if (open.value) measure();
} }
@@ -205,6 +309,8 @@ watch(open, async (isOpen) => {
return; return;
} }
window.clearTimeout(hideTimer); window.clearTimeout(hideTimer);
listExpanded.value = false;
mode.value = summaries.value.length ? 'list' : 'create';
resetForm(); resetForm();
rendered.value = true; rendered.value = true;
reveal = true; reveal = true;
@@ -280,9 +386,31 @@ async function submit() {
}; };
saving.value = true; saving.value = true;
try { try {
await api.post<ActivityDto>('/activities', body); const { data } = await api.post<ActivityDto>('/activities', body);
message.success('فعالیت ثبت شد'); 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 { } catch {
error.value = 'ثبت فعالیت ناموفق بود.'; error.value = 'ثبت فعالیت ناموفق بود.';
} finally { } finally {
@@ -292,25 +420,31 @@ async function submit() {
</script> </script>
<template> <template>
<a-tooltip title="افزودن فعالیت"> <a-tooltip :title="cueMeta.title">
<a-button <a-badge
type="text" :count="openCount"
:disabled="disabled" :offset="[-2, 2]"
aria-label="افزودن فعالیت سریع" :number-style="{ fontSize: '10px', minWidth: '16px', height: '16px', lineHeight: '16px' }"
: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
> >
<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> </a-tooltip>
<Teleport to="body"> <Teleport to="body">
@@ -319,17 +453,71 @@ async function submit() {
ref="panelEl" ref="panelEl"
role="dialog" role="dialog"
dir="rtl" dir="rtl"
:aria-label="`ایجاد فعالیت سریع برای ${deal.title}`" :aria-label="`${panelTitle} برای ${deal.title}`"
:style="panelStyle" :style="panelStyle"
> >
<a-flex justify="space-between" align="center" :style="{ marginBottom: `${token.marginSM}px` }"> <a-flex justify="space-between" align="center" :style="{ marginBottom: `${token.marginSM}px` }">
<a-typography-title :level="5" :style="{ margin: 0 }">ایجاد فعالیت سریع</a-typography-title> <a-typography-title :level="5" :style="{ margin: 0 }">{{ panelTitle }}</a-typography-title>
<a-button type="text" aria-label="بستن" @click="close"> <a-space :size="4">
<template #icon><CloseOutlined /></template> <a-button
</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>
<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-alert v-if="error" type="error" show-icon :message="error" />
<a-form-item label="عنوان" :style="fieldItemStyle"> <a-form-item label="عنوان" :style="fieldItemStyle">
@@ -361,12 +549,19 @@ async function submit() {
<a-divider :style="{ margin: `${token.marginXS}px 0` }" /> <a-divider :style="{ margin: `${token.marginXS}px 0` }" />
<a-flex justify="space-between" align="center" wrap="wrap" :gap="8"> <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-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-button type="primary" :loading="saving" @click="submit">ذخیره</a-button>
</a-space> </a-space>
</a-flex> </a-flex>
@@ -15,11 +15,13 @@ import {
Space as ASpace, Space as ASpace,
Table as ATable, Table as ATable,
Tag as ATag, Tag as ATag,
Tooltip as ATooltip,
Typography, Typography,
theme, theme,
} from 'ant-design-vue'; } from 'ant-design-vue';
import type { TableColumnsType } from 'ant-design-vue'; import type { TableColumnsType } from 'ant-design-vue';
import TableColumnsMenu from '@/components/TableColumnsMenu.vue'; import TableColumnsMenu from '@/components/TableColumnsMenu.vue';
import { formatJalaliNumeric } from '@/lib/jalali';
import type { import type {
PipelineAccent, PipelineAccent,
PipelineDeal, PipelineDeal,
@@ -82,6 +84,13 @@ function money(deal: PipelineDeal) {
return formatCurrencyParts(deal.amount ?? 0, deal.currencyUnit ?? 'ریال'); 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 hideAmount = computed(() => Boolean(props.copy.hideAmount));
const tableKey = computed(() => (hideAmount.value ? 'pipeline.leads' : 'pipeline.deals')); const tableKey = computed(() => (hideAmount.value ? 'pipeline.leads' : 'pipeline.deals'));
@@ -213,10 +222,12 @@ onUnmounted(() => {
</span> </span>
</template> </template>
<template v-else-if="column.key === 'date'"> <template v-else-if="column.key === 'date'">
<a-space :size="6"> <a-tooltip :title="dateHint(record.deal)">
<CalendarOutlined /> <a-space :size="6">
<span>{{ record.deal.date }}</span> <CalendarOutlined />
</a-space> <span>{{ record.deal.date }}</span>
</a-space>
</a-tooltip>
</template> </template>
<template v-else-if="column.key === 'actions'"> <template v-else-if="column.key === 'actions'">
<span :data-card-id="record.deal.id"> <span :data-card-id="record.deal.id">
@@ -334,10 +345,12 @@ onUnmounted(() => {
{{ row.stageTitle }} {{ row.stageTitle }}
</a-tag> </a-tag>
<a-flex justify="space-between" align="center"> <a-flex justify="space-between" align="center">
<a-space :size="6"> <a-tooltip :title="dateHint(row.deal)">
<CalendarOutlined /> <a-space :size="6">
<a-typography-text type="secondary">{{ row.deal.date }}</a-typography-text> <CalendarOutlined />
</a-space> <a-typography-text type="secondary">{{ row.deal.date }}</a-typography-text>
</a-space>
</a-tooltip>
<span v-if="!hideAmount" dir="ltr"> <span v-if="!hideAmount" dir="ltr">
<a-typography-text strong>{{ money(row.deal).number }}</a-typography-text> <a-typography-text strong>{{ money(row.deal).number }}</a-typography-text>
<a-typography-text type="secondary" :style="{ marginInlineStart: '6px' }"> <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)} روز پیش`; 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 { export function formatNextSlot(iso: string | null | undefined): string {
if (!iso) return '—'; if (!iso) return '—';
+17 -1
View File
@@ -1,6 +1,10 @@
import { currencySuffix } from '@/lib/money'; import { currencySuffix } from '@/lib/money';
import { matchesSearch } from '@/lib/search'; 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'; export type PipelineAccent = 'green' | 'orange' | 'purple' | 'blue' | 'rose' | 'slate';
@@ -10,6 +14,8 @@ export type PipelineDeal = {
description: string; description: string;
amount: number | null; amount: number | null;
date: string; date: string;
/** ISO برای tooltip تاریخ مطلق */
dateIso?: string;
initials: string; initials: string;
/** شناسه مرحله فعلی — هنگام جابه‌جایی به‌روز می‌شود */ /** شناسه مرحله فعلی — هنگام جابه‌جایی به‌روز می‌شود */
stageId?: string; stageId?: string;
@@ -26,6 +32,10 @@ export type PipelineDeal = {
searchFields?: Array<string | null | undefined>; searchFields?: Array<string | null | undefined>;
/** اولویت فعالیت؛ فقط روی کارت فعالیت نشان داده می‌شود */ /** اولویت فعالیت؛ فقط روی کارت فعالیت نشان داده می‌شود */
priority?: ActivityPriority; priority?: ActivityPriority;
/** نشانگر وضعیت فعالیت معامله روی دکمهٔ تقویم */
activityCue?: DealActivityCue;
/** خلاصه فعالیت‌ها برای پاپ‌آپ دکمهٔ تقویم */
activitySummaries?: DealActivitySummaryDto[];
}; };
export type PipelineUiCopy = { export type PipelineUiCopy = {
@@ -190,6 +200,8 @@ export function mapBoardToStages(
expectedCloseAt: string | null; expectedCloseAt: string | null;
createdAt: string; createdAt: string;
promotedFromPriorAt: string | null; promotedFromPriorAt: string | null;
activityCue?: DealActivityCue;
activitySummaries?: DealActivitySummaryDto[];
person: { id?: string; fullName: string } | null; person: { id?: string; fullName: string } | null;
company: { id?: string; name: string } | null; company: { id?: string; name: string } | null;
owner?: { id: string; fullName: string } | null; owner?: { id: string; fullName: string } | null;
@@ -234,6 +246,9 @@ export function mapBoardToStages(
description, description,
amount: deal.amount ?? 0, amount: deal.amount ?? 0,
date: formatDate(dateIso), date: formatDate(dateIso),
dateIso,
activityCue: deal.activityCue ?? 'none',
activitySummaries: deal.activitySummaries ?? [],
initials: initialsFromTitle( initials: initialsFromTitle(
deal.company?.name || deal.title, deal.company?.name || deal.title,
), ),
@@ -301,6 +316,7 @@ export function mapProjectBoardToStages(
'', '',
amount: null, amount: null,
date: formatDate(card.createdAt), date: formatDate(card.createdAt),
dateIso: card.createdAt,
initials: initialsFromTitle( initials: initialsFromTitle(
card.company?.name || card.person?.fullName || card.title, 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 { useRoute, useRouter } from 'vue-router';
import axios from 'axios'; import axios from 'axios';
import { api } from '@/lib/api'; 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 PipelineToolbar from '@/components/pipeline/PipelineToolbar.vue';
import type { PipelineViewMode } from '@/components/pipeline/PipelineToolbar.vue'; import type { PipelineViewMode } from '@/components/pipeline/PipelineToolbar.vue';
import PipelineBoard from '@/components/pipeline/PipelineBoard.vue'; import PipelineBoard from '@/components/pipeline/PipelineBoard.vue';
@@ -165,9 +165,7 @@ function applyDealsSavedFilters(filters: Record<string, unknown>) {
} }
function formatDealDate(iso: string) { function formatDealDate(iso: string) {
const d = new Date(iso); return formatRelativeDayLabel(iso);
if (Number.isNaN(d.getTime())) return iso;
return formatJalaliNumeric(d);
} }
function findAccent(dealId: string): PipelineStage['color'] { function findAccent(dealId: string): PipelineStage['color'] {
+21
View File
@@ -1623,6 +1623,13 @@ export interface DealDto {
lineItems: DealLineItemDto[]; lineItems: DealLineItemDto[];
createdAt: string; createdAt: string;
updatedAt: string; updatedAt: string;
/**
* فقط در برد کانبان پر می‌شود:
* none=بدون فعالیت، overdue=عقب‌افتاده، upcoming=آینده، done=انجام‌شده
*/
activityCue?: DealActivityCue;
/** لیست مرتب‌شدهٔ فعالیت‌های باز/انجام‌شده برای پاپ‌آپ کارت */
activitySummaries?: DealActivitySummaryDto[];
} }
export interface PipelineBoardStageDto extends PipelineStageDto { export interface PipelineBoardStageDto extends PipelineStageDto {
@@ -1849,6 +1856,20 @@ export interface MoveDealRequest {
export type ActivityType = "call" | "meeting" | "task" | "note"; export type ActivityType = "call" | "meeting" | "task" | "note";
export type ActivityStatus = "open" | "done" | "canceled"; 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 ActivityPriority = "very_low" | "low" | "medium" | "high" | "very_high";
export type ActivityFilter = "today" | "overdue" | "upcoming" | "done" | "all"; export type ActivityFilter = "today" | "overdue" | "upcoming" | "done" | "all";