Files
CRM_Montzar/apps/web/src/components/EntityTimeline.vue
T

310 lines
8.2 KiB
Vue
Raw Normal View History

<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,
Spin as ASpin,
Tag as ATag,
Textarea as ATextarea,
Typography,
} from 'ant-design-vue';
const ASpaceCompact = ASpace.Compact;
const AListItem = AList.Item;
const AListItemMeta = AList.Item.Meta;
import { api } from '@/lib/api';
2026-10-13 18:04:53 +03:30
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) {
2026-10-13 18:04:53 +03:30
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' }"
/>
<a-spin :spinning="loading">
<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' }"
/>
</a-spin>
</a-card>
</template>