Files
CRM_Montzar/apps/web/src/components/EntityTimeline.vue
T
MahdiandCursor 6c6e32e099 feat(web): adopt vuesax icons and shared skeleton loading
Replace the inline SVG icon set with the vuesax linear pack, add skeleton primitives for loading states, and keep page scroll inside the app shell.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-11-05 14:46:35 +03:30

312 lines
8.3 KiB
Vue
Raw Blame History

This file contains invisible Unicode characters
This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<script setup lang="ts">
import { computed, onMounted, ref, watch } from 'vue';
import { RouterLink } from 'vue-router';
import {
Alert as AAlert,
Button as AButton,
Card as ACard,
Empty as AEmpty,
Flex as AFlex,
Input as AInput,
List as AList,
Space as ASpace,
Tag as ATag,
Textarea as ATextarea,
Typography,
} from 'ant-design-vue';
import { CrmSkeleton, CrmSkeletonList } from '@/components/skeleton';
const ASpaceCompact = ASpace.Compact;
const AListItem = AList.Item;
const AListItemMeta = AList.Item.Meta;
import { api } from '@/lib/api';
import { formatJalaliDateTime } from '@/lib/jalali';
import VoiceToTextButton from '@/components/VoiceToTextButton.vue';
import { appendSpokenText } from '@/lib/spoken-text';
import type {
ActivityDto,
NoteDto,
PaginatedResponse,
} from '@mento/shared';
const { Title: ATypographyTitle, Text: ATypographyText, Paragraph: ATypographyParagraph } =
Typography;
const props = defineProps<{
entityType: 'person' | 'company' | 'deal';
entityId: string;
}>();
type TimelineKind = 'note' | 'activity';
type TimelineItem = {
key: string;
kind: TimelineKind;
id: string;
title: string;
body?: string | null;
meta: string;
at: string;
href: string;
done?: boolean;
};
const loading = ref(false);
const saving = ref(false);
const error = ref('');
const items = ref<TimelineItem[]>([]);
const noteDraft = ref('');
const activityDraft = ref('');
const entityParams = computed(() => {
if (props.entityType === 'person') return { personId: props.entityId };
if (props.entityType === 'company') return { companyId: props.entityId };
return { dealId: props.entityId };
});
const typeLabel: Record<ActivityDto['type'], string> = {
call: 'تماس',
meeting: 'جلسه',
task: 'وظیفه',
note: 'یادداشت',
};
function noteTypeLabel(value?: string | null) {
const map: Record<string, string> = {
general: 'یادداشت',
call_result: 'نتیجه تماس',
meeting: 'جلسه',
important: 'نکته مهم',
warning: 'هشدار',
next_action: 'کار بعدی',
};
return map[value || 'general'] ?? 'یادداشت';
}
function formatTime(iso: string) {
const d = new Date(iso);
if (Number.isNaN(d.getTime())) return iso;
return formatJalaliDateTime(d);
}
async function load() {
if (!props.entityId) {
items.value = [];
return;
}
loading.value = true;
error.value = '';
try {
const [notesRes, activitiesRes] = await Promise.all([
api.get<PaginatedResponse<NoteDto>>('/notes', {
params: { ...entityParams.value, limit: 40 },
}),
api.get<PaginatedResponse<ActivityDto>>('/activities', {
params: {
filter: 'all',
...entityParams.value,
limit: 40,
},
}),
]);
const notes: TimelineItem[] = notesRes.data.items.map((n) => ({
key: `note-${n.id}`,
kind: 'note',
id: n.id,
title: n.title?.trim() || 'یادداشت',
body: n.body,
meta:
noteTypeLabel(n.noteType) +
(n.pinned ? ' · پین' : '') +
(n.attachments?.length ? ` · ${n.attachments.length} پیوست` : ''),
at: n.updatedAt || n.createdAt,
href: '/notes',
}));
const activities: TimelineItem[] = activitiesRes.data.items.map((a) => ({
key: `activity-${a.id}`,
kind: 'activity',
id: a.id,
title: a.title,
body: a.description,
meta: `${typeLabel[a.type] ?? a.type}${a.status === 'done' ? ' · انجام‌شده' : ''}`,
at: a.dueAt || a.createdAt,
href: `/activities/${a.id}`,
done: a.status === 'done',
}));
items.value = [...notes, ...activities].sort(
(a, b) => new Date(b.at).getTime() - new Date(a.at).getTime(),
);
} catch {
error.value = 'بارگذاری تایم‌لاین ناموفق بود.';
items.value = [];
} finally {
loading.value = false;
}
}
async function addNote() {
const body = noteDraft.value.trim();
if (!body || saving.value) return;
saving.value = true;
error.value = '';
try {
await api.post('/notes', {
body,
...entityParams.value,
});
noteDraft.value = '';
await load();
} catch {
error.value = 'ثبت یادداشت ناموفق بود.';
} finally {
saving.value = false;
}
}
async function addActivity() {
const title = activityDraft.value.trim();
if (!title || saving.value) return;
saving.value = true;
error.value = '';
try {
await api.post('/activities', {
title,
type: 'task',
...entityParams.value,
});
activityDraft.value = '';
await load();
} catch {
error.value = 'ثبت فعالیت ناموفق بود.';
} finally {
saving.value = false;
}
}
async function completeActivity(item: TimelineItem) {
if (item.kind !== 'activity' || item.done) return;
try {
await api.patch(`/activities/${item.id}/complete`);
await load();
} catch {
error.value = 'اتمام فعالیت ناموفق بود.';
}
}
watch(
() => [props.entityType, props.entityId],
() => {
void load();
},
);
onMounted(load);
</script>
<template>
<a-card :bordered="false" :style="{ marginTop: '16px' }">
<a-typography-title :level="5" :style="{ margin: 0 }">تایم‌لاین</a-typography-title>
<a-typography-text type="secondary">
یادداشت‌ها و فعالیت‌های مرتبط با این پرونده
</a-typography-text>
<a-flex vertical :gap="12" :style="{ marginTop: '16px' }">
<a-textarea
v-model:value="noteDraft"
:rows="2"
placeholder="یادداشت سریع…"
@keydown.ctrl.enter.prevent="addNote"
/>
<a-flex justify="space-between" align="center" wrap="wrap" :gap="8">
<VoiceToTextButton
@transcript="(text) => (noteDraft = appendSpokenText(noteDraft, text))"
/>
<a-button
type="primary"
:disabled="saving || !noteDraft.trim()"
:loading="saving"
@click="addNote"
>
ثبت یادداشت
</a-button>
</a-flex>
<a-space-compact block style="width: 100%">
<a-input
v-model:value="activityDraft"
placeholder="عنوان فعالیت / پیگیری…"
style="width: 100%"
@pressEnter="addActivity"
/>
<a-button
:disabled="saving || !activityDraft.trim()"
@click="addActivity"
>
افزودن فعالیت
</a-button>
</a-space-compact>
</a-flex>
<a-alert
v-if="error"
type="error"
show-icon
:message="error"
:style="{ marginTop: '12px' }"
/>
<CrmSkeleton :loading="loading">
<CrmSkeletonList :rows="5">
<a-list
v-if="items.length"
item-layout="horizontal"
:data-source="items"
:style="{ marginTop: '16px' }"
>
<template #renderItem="{ item }">
<a-list-item>
<a-list-item-meta>
<template #title>
<a-space :size="8">
<a-tag :bordered="false">{{ item.meta }}</a-tag>
<span>{{ item.title }}</span>
</a-space>
</template>
<template #description>
<a-typography-paragraph
v-if="item.body"
type="secondary"
:style="{ marginBottom: '4px' }"
>
{{ item.body }}
</a-typography-paragraph>
<a-typography-text type="secondary">{{ formatTime(item.at) }}</a-typography-text>
</template>
</a-list-item-meta>
<template #actions>
<RouterLink :to="item.href">باز کردن</RouterLink>
<a-button
v-if="item.kind === 'activity' && !item.done"
type="link"
@click="completeActivity(item)"
>
انجام شد
</a-button>
</template>
</a-list-item>
</template>
</a-list>
<a-empty
v-else-if="!loading"
description="هنوز چیزی در تایم‌لاین نیست. یک یادداشت یا فعالیت ثبت کنید."
:style="{ marginTop: '16px' }"
/>
</CrmSkeletonList>
</CrmSkeleton>
</a-card>
</template>