From fec5ece2559336f2b43e14a6904fabecf73a139f Mon Sep 17 00:00:00 2001 From: Mahdi Date: Thu, 12 Nov 2026 16:27:32 +0330 Subject: [PATCH] Improve deal board activity cues with relative dates and a quick activity list popup. EOF Co-authored-by: Cursor --- apps/api/src/pipelines/pipelines.service.ts | 116 +++++++- apps/web/src/components/pipeline/DealCard.vue | 17 +- .../pipeline/DealQuickActivityPopover.vue | 267 +++++++++++++++--- .../src/components/pipeline/PipelineList.vue | 29 +- apps/web/src/lib/jalali.ts | 11 + apps/web/src/lib/pipeline-board.ts | 18 +- apps/web/src/views/PipelineBoardView.vue | 6 +- packages/shared/src/index.ts | 21 ++ 8 files changed, 431 insertions(+), 54 deletions(-) diff --git a/apps/api/src/pipelines/pipelines.service.ts b/apps/api/src/pipelines/pipelines.service.ts index e7eac1c..84c55e4 100644 --- a/apps/api/src/pipelines/pipelines.service.ts +++ b/apps/api/src/pipelines/pipelines.service.ts @@ -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(); 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 + > { + 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(); + 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 => { + if (item.status === 'done') return 'done'; + if (item.dueAt && item.dueAt.getTime() < now) return 'overdue'; + return 'upcoming'; + }; + const rank = (cue: Exclude) => + 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, diff --git a/apps/web/src/components/pipeline/DealCard.vue b/apps/web/src/components/pipeline/DealCard.vue index 0387ac4..7c1dd57 100644 --- a/apps/web/src/components/pipeline/DealCard.vue +++ b/apps/web/src/components/pipeline/DealCard.vue @@ -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 = { very_high: { label: 'خیلی زیاد', color: '#dc2626' }, high: { label: 'زیاد', color: '#ea580c' }, @@ -236,10 +243,12 @@ const cardStyle = computed(() => ({ +{{ extraAssignees }} - - - {{ deal.date }} - + + + + {{ deal.date }} + + ('create'); +const listExpanded = ref(false); const saving = ref(false); const error = ref(''); const title = ref(''); const type = ref('task'); const dueDate = ref(''); const dueTime = ref(''); +const localCue = ref(null); +const localSummaries = ref(null); + +const summaries = computed( + () => localSummaries.value ?? props.deal.activitySummaries ?? [], +); + +const activityCue = computed( + () => 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) { + 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( `.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('/activities', body); + const { data } = await api.post('/activities', body); message.success('فعالیت ثبت شد'); - close(); + const dueMs = new Date(dueAt).getTime(); + const cue: Exclude = + 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() {