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