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>
|