Show related activities on deal detail, prefill dealId on activity create, and add a side-panel quick create from pipeline cards. Co-authored-by: Cursor <cursoragent@cursor.com>
394 lines
12 KiB
Vue
394 lines
12 KiB
Vue
<script setup lang="ts">
|
||
import { computed, ref, watch } from 'vue';
|
||
import { useRouter } from 'vue-router';
|
||
import {
|
||
Alert as AAlert,
|
||
Avatar as AAvatar,
|
||
Button as AButton,
|
||
Card as ACard,
|
||
Collapse as ACollapse,
|
||
CollapsePanel as ACollapsePanel,
|
||
Divider as ADivider,
|
||
Empty as AEmpty,
|
||
Flex as AFlex,
|
||
Space as ASpace,
|
||
Spin as ASpin,
|
||
Tag as ATag,
|
||
Typography,
|
||
theme,
|
||
} from 'ant-design-vue';
|
||
import {
|
||
CalendarOutlined,
|
||
CheckCircleOutlined,
|
||
ClockCircleOutlined,
|
||
LeftOutlined,
|
||
PhoneOutlined,
|
||
PlusOutlined,
|
||
ScheduleOutlined,
|
||
TeamOutlined,
|
||
WarningOutlined,
|
||
} from '@ant-design/icons-vue';
|
||
import type { ActivityDto, ActivityStatus, ActivityType, PaginatedResponse } from '@mento/shared';
|
||
import { ACTIVITY_TYPE_LABELS } from '@mento/shared';
|
||
import { api } from '@/lib/api';
|
||
import {
|
||
calendarDaysBetween,
|
||
formatClockFa,
|
||
formatJalaliDay,
|
||
startOfLocalDay,
|
||
toFaDigits,
|
||
} from '@/lib/jalali';
|
||
|
||
const { Text: ATypographyText, Title: ATypographyTitle, Paragraph: ATypographyParagraph } =
|
||
Typography;
|
||
const { token } = theme.useToken();
|
||
|
||
const STATUS_LABELS: Record<ActivityStatus, string> = {
|
||
open: 'باز',
|
||
done: 'انجامشده',
|
||
canceled: 'لغو شده',
|
||
};
|
||
|
||
const TYPE_ICONS: Record<ActivityType, typeof PhoneOutlined> = {
|
||
call: PhoneOutlined,
|
||
meeting: TeamOutlined,
|
||
task: CheckCircleOutlined,
|
||
note: ScheduleOutlined,
|
||
};
|
||
|
||
type DueTone = 'overdue' | 'today' | 'soon' | 'later' | 'none';
|
||
|
||
const props = withDefaults(
|
||
defineProps<{
|
||
dealId: string;
|
||
personId?: string | null;
|
||
companyId?: string | null;
|
||
dealTitle?: string;
|
||
}>(),
|
||
{
|
||
personId: null,
|
||
companyId: null,
|
||
dealTitle: '',
|
||
},
|
||
);
|
||
|
||
const router = useRouter();
|
||
|
||
const items = ref<ActivityDto[]>([]);
|
||
const loading = ref(false);
|
||
const error = ref('');
|
||
const closedKeys = ref<string[]>([]);
|
||
|
||
function sortByDue(a: ActivityDto, b: ActivityDto) {
|
||
const ta = a.dueAt ? new Date(a.dueAt).getTime() : Number.POSITIVE_INFINITY;
|
||
const tb = b.dueAt ? new Date(b.dueAt).getTime() : Number.POSITIVE_INFINITY;
|
||
if (Number.isNaN(ta) && Number.isNaN(tb)) return 0;
|
||
if (Number.isNaN(ta)) return 1;
|
||
if (Number.isNaN(tb)) return -1;
|
||
return ta - tb;
|
||
}
|
||
|
||
const planned = computed(() =>
|
||
items.value.filter((a) => a.status === 'open').slice().sort(sortByDue),
|
||
);
|
||
const closed = computed(() =>
|
||
items.value
|
||
.filter((a) => a.status !== 'open')
|
||
.slice()
|
||
.sort((a, b) => {
|
||
const ta = a.completedAt || a.updatedAt || a.dueAt || '';
|
||
const tb = b.completedAt || b.updatedAt || b.dueAt || '';
|
||
return tb.localeCompare(ta);
|
||
}),
|
||
);
|
||
|
||
function dueTone(dueAt: string | null): DueTone {
|
||
if (!dueAt) return 'none';
|
||
const due = new Date(dueAt);
|
||
if (Number.isNaN(due.getTime())) return 'none';
|
||
const days = calendarDaysBetween(startOfLocalDay(), startOfLocalDay(due));
|
||
if (days < 0) return 'overdue';
|
||
if (days === 0) return 'today';
|
||
if (days <= 2) return 'soon';
|
||
return 'later';
|
||
}
|
||
|
||
function dueLabel(dueAt: string | null) {
|
||
if (!dueAt) return 'بدون موعد';
|
||
const d = new Date(dueAt);
|
||
if (Number.isNaN(d.getTime())) return 'بدون موعد';
|
||
const tone = dueTone(dueAt);
|
||
if (tone === 'overdue') {
|
||
const days = Math.abs(calendarDaysBetween(startOfLocalDay(), startOfLocalDay(d)));
|
||
return days === 1 ? 'دیروز موعد داشت' : `${toFaDigits(days)} روز تأخیر`;
|
||
}
|
||
if (tone === 'today') return `امروز · ${formatClockFa(d)}`;
|
||
return `${formatJalaliDay(d)} · ${formatClockFa(d)}`;
|
||
}
|
||
|
||
function dueTagColor(tone: DueTone) {
|
||
if (tone === 'overdue') return 'error';
|
||
if (tone === 'today') return 'warning';
|
||
if (tone === 'soon') return 'processing';
|
||
return 'default';
|
||
}
|
||
|
||
function rowStyle(tone: DueTone, closedRow = false) {
|
||
const base = {
|
||
width: '100%',
|
||
padding: `${token.value.paddingSM}px ${token.value.padding}px`,
|
||
borderRadius: `${token.value.borderRadiusLG}px`,
|
||
border: `1px solid ${token.value.colorBorderSecondary}`,
|
||
background: token.value.colorBgContainer,
|
||
cursor: 'pointer' as const,
|
||
opacity: closedRow ? 0.78 : 1,
|
||
};
|
||
if (closedRow) {
|
||
return { ...base, background: token.value.colorFillAlter };
|
||
}
|
||
if (tone === 'overdue') {
|
||
return {
|
||
...base,
|
||
borderColor: token.value.colorErrorBorder,
|
||
background: token.value.colorErrorBg,
|
||
};
|
||
}
|
||
if (tone === 'today') {
|
||
return {
|
||
...base,
|
||
borderColor: token.value.colorWarningBorder,
|
||
background: token.value.colorWarningBg,
|
||
};
|
||
}
|
||
return base;
|
||
}
|
||
|
||
function avatarStyle(tone: DueTone) {
|
||
if (tone === 'overdue') {
|
||
return { backgroundColor: token.value.colorErrorBg, color: token.value.colorError };
|
||
}
|
||
if (tone === 'today') {
|
||
return { backgroundColor: token.value.colorWarningBg, color: token.value.colorWarning };
|
||
}
|
||
return {
|
||
backgroundColor: token.value.colorPrimaryBg,
|
||
color: token.value.colorPrimary,
|
||
};
|
||
}
|
||
|
||
function typeLabel(item: ActivityDto) {
|
||
const type = item.type as ActivityType;
|
||
return item.typeDef?.name || ACTIVITY_TYPE_LABELS[type];
|
||
}
|
||
|
||
function statusLabel(status: ActivityDto['status']) {
|
||
return STATUS_LABELS[status as ActivityStatus];
|
||
}
|
||
|
||
function typeIcon(type: ActivityDto['type']) {
|
||
return TYPE_ICONS[type as ActivityType];
|
||
}
|
||
|
||
function newActivityQuery() {
|
||
const query: Record<string, string> = { dealId: props.dealId };
|
||
if (props.personId) query.personId = props.personId;
|
||
if (props.companyId) query.companyId = props.companyId;
|
||
if (props.dealTitle.trim()) query.title = `پیگیری: ${props.dealTitle.trim()}`;
|
||
return query;
|
||
}
|
||
|
||
function goCreate() {
|
||
void router.push({ name: 'activity-new', query: newActivityQuery() });
|
||
}
|
||
|
||
function goDetail(id: string) {
|
||
void router.push({ name: 'activity-detail', params: { id } });
|
||
}
|
||
|
||
async function load() {
|
||
if (!props.dealId) return;
|
||
loading.value = true;
|
||
error.value = '';
|
||
try {
|
||
const { data } = await api.get<PaginatedResponse<ActivityDto>>('/activities', {
|
||
params: { filter: 'all', dealId: props.dealId, limit: 50 },
|
||
});
|
||
items.value = data.items ?? [];
|
||
} catch {
|
||
error.value = 'بارگذاری فعالیتهای معامله ممکن نشد.';
|
||
items.value = [];
|
||
} finally {
|
||
loading.value = false;
|
||
}
|
||
}
|
||
|
||
watch(
|
||
() => props.dealId,
|
||
() => {
|
||
void load();
|
||
},
|
||
{ immediate: true },
|
||
);
|
||
</script>
|
||
|
||
<template>
|
||
<a-card :bordered="false">
|
||
<template #title>
|
||
<a-space :size="8">
|
||
<CalendarOutlined :style="{ color: token.colorPrimary }" />
|
||
<span>فعالیتهای برنامهریزیشده</span>
|
||
<a-tag v-if="planned.length" color="processing" :bordered="false">
|
||
{{ toFaDigits(planned.length) }} باز
|
||
</a-tag>
|
||
</a-space>
|
||
</template>
|
||
<template #extra>
|
||
<a-button type="primary" @click="goCreate">
|
||
<template #icon><PlusOutlined /></template>
|
||
افزودن فعالیت
|
||
</a-button>
|
||
</template>
|
||
|
||
<a-alert
|
||
v-if="error"
|
||
type="error"
|
||
show-icon
|
||
:message="error"
|
||
:style="{ marginBottom: `${token.margin}px` }"
|
||
/>
|
||
|
||
<a-spin :spinning="loading">
|
||
<a-empty
|
||
v-if="!loading && !planned.length"
|
||
:image="AEmpty.PRESENTED_IMAGE_SIMPLE"
|
||
:style="{ paddingBlock: `${token.paddingLG}px` }"
|
||
>
|
||
<template #description>
|
||
<a-flex vertical align="center" :gap="token.marginXS">
|
||
<a-typography-title :level="5" :style="{ margin: 0 }">
|
||
هنوز فعالیتی برای این معامله نیست
|
||
</a-typography-title>
|
||
<a-typography-paragraph
|
||
type="secondary"
|
||
:style="{ margin: 0, maxWidth: '28rem', textAlign: 'center' }"
|
||
>
|
||
یک تماس، جلسه یا پیگیری زمانبندی کن تا معامله از دست نرود.
|
||
</a-typography-paragraph>
|
||
</a-flex>
|
||
</template>
|
||
<a-button type="primary" @click="goCreate">
|
||
<template #icon><PlusOutlined /></template>
|
||
افزودن اولین فعالیت
|
||
</a-button>
|
||
</a-empty>
|
||
|
||
<a-flex v-else-if="planned.length" vertical :gap="token.marginSM">
|
||
<a-flex
|
||
v-for="item in planned"
|
||
:key="item.id"
|
||
align="center"
|
||
justify="space-between"
|
||
:gap="token.marginSM"
|
||
wrap="wrap"
|
||
:style="rowStyle(dueTone(item.dueAt))"
|
||
role="button"
|
||
tabindex="0"
|
||
@click="goDetail(item.id)"
|
||
@keydown.enter="goDetail(item.id)"
|
||
>
|
||
<a-flex align="center" :gap="token.marginSM" :style="{ minWidth: 0, flex: '1 1 220px' }">
|
||
<a-avatar :size="40" :style="avatarStyle(dueTone(item.dueAt))">
|
||
<template #icon>
|
||
<WarningOutlined v-if="dueTone(item.dueAt) === 'overdue'" />
|
||
<ClockCircleOutlined v-else-if="dueTone(item.dueAt) === 'today'" />
|
||
<component :is="typeIcon(item.type)" v-else />
|
||
</template>
|
||
</a-avatar>
|
||
<a-flex vertical :gap="4" :style="{ minWidth: 0 }">
|
||
<a-typography-text strong :ellipsis="{ tooltip: item.title }">
|
||
{{ item.title }}
|
||
</a-typography-text>
|
||
<a-space :size="8" wrap>
|
||
<a-tag :bordered="false">{{ typeLabel(item) }}</a-tag>
|
||
<a-tag :color="dueTagColor(dueTone(item.dueAt))" :bordered="false">
|
||
{{ dueLabel(item.dueAt) }}
|
||
</a-tag>
|
||
<a-typography-text v-if="item.assignee?.fullName" type="secondary">
|
||
{{ item.assignee.fullName }}
|
||
</a-typography-text>
|
||
</a-space>
|
||
</a-flex>
|
||
</a-flex>
|
||
<LeftOutlined :style="{ color: token.colorTextQuaternary, flexShrink: 0 }" />
|
||
</a-flex>
|
||
</a-flex>
|
||
|
||
<template v-if="closed.length">
|
||
<a-divider v-if="planned.length" :style="{ margin: `${token.marginMD}px 0` }" />
|
||
<a-collapse
|
||
v-model:activeKey="closedKeys"
|
||
ghost
|
||
:bordered="false"
|
||
:style="{
|
||
background: 'transparent',
|
||
marginTop: planned.length ? 0 : `${token.marginSM}px`,
|
||
}"
|
||
>
|
||
<a-collapse-panel key="closed">
|
||
<template #header>
|
||
<a-typography-text type="secondary">
|
||
{{ toFaDigits(closed.length) }} فعالیت انجامشده یا لغو شده
|
||
</a-typography-text>
|
||
</template>
|
||
<a-flex vertical :gap="token.marginSM">
|
||
<a-flex
|
||
v-for="item in closed"
|
||
:key="item.id"
|
||
align="center"
|
||
justify="space-between"
|
||
:gap="token.marginSM"
|
||
wrap="wrap"
|
||
:style="rowStyle('none', true)"
|
||
role="button"
|
||
tabindex="0"
|
||
@click="goDetail(item.id)"
|
||
@keydown.enter="goDetail(item.id)"
|
||
>
|
||
<a-flex
|
||
align="center"
|
||
:gap="token.marginSM"
|
||
:style="{ minWidth: 0, flex: '1 1 220px' }"
|
||
>
|
||
<a-avatar
|
||
:size="40"
|
||
:style="{
|
||
backgroundColor: token.colorFillSecondary,
|
||
color: token.colorTextSecondary,
|
||
}"
|
||
>
|
||
<template #icon>
|
||
<component :is="typeIcon(item.type)" />
|
||
</template>
|
||
</a-avatar>
|
||
<a-flex vertical :gap="4" :style="{ minWidth: 0 }">
|
||
<a-typography-text :ellipsis="{ tooltip: item.title }">
|
||
{{ item.title }}
|
||
</a-typography-text>
|
||
<a-space :size="8" wrap>
|
||
<a-tag :bordered="false">{{ statusLabel(item.status) }}</a-tag>
|
||
<a-typography-text type="secondary">
|
||
{{ dueLabel(item.dueAt) }}
|
||
</a-typography-text>
|
||
</a-space>
|
||
</a-flex>
|
||
</a-flex>
|
||
<LeftOutlined :style="{ color: token.colorTextQuaternary, flexShrink: 0 }" />
|
||
</a-flex>
|
||
</a-flex>
|
||
</a-collapse-panel>
|
||
</a-collapse>
|
||
</template>
|
||
</a-spin>
|
||
</a-card>
|
||
</template>
|