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:
@@ -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,
|
||||
|
||||
@@ -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' }">
|
||||
|
||||
@@ -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 '—';
|
||||
|
||||
@@ -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,
|
||||
),
|
||||
|
||||
@@ -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'] {
|
||||
|
||||
@@ -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";
|
||||
|
||||
|
||||
Reference in New Issue
Block a user