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

388 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 { api } from '@/lib/api';
import type {
ActivityDto,
NoteDto,
PaginatedResponse,
} from '@mento/shared';
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) {
try {
return new Intl.DateTimeFormat('fa-IR', {
dateStyle: 'medium',
timeStyle: 'short',
}).format(new Date(iso));
} catch {
return iso;
}
}
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>
<section class="timeline">
<header class="head">
<h3>تایم‌لاین</h3>
<p class="muted">یادداشت‌ها و فعالیت‌های مرتبط با این پرونده</p>
</header>
<div class="composers">
<div class="composer">
<textarea
v-model="noteDraft"
rows="2"
placeholder="یادداشت سریع…"
@keydown.ctrl.enter.prevent="addNote"
/>
<button class="btn" type="button" :disabled="saving || !noteDraft.trim()" @click="addNote">
ثبت یادداشت
</button>
</div>
<div class="composer">
<input
v-model="activityDraft"
type="text"
placeholder="عنوان فعالیت / پیگیری…"
@keydown.enter.prevent="addActivity"
/>
<button
class="btn btn-ghost"
type="button"
:disabled="saving || !activityDraft.trim()"
@click="addActivity"
>
افزودن فعالیت
</button>
</div>
</div>
<p v-if="error" class="error">{{ error }}</p>
<p v-if="loading" class="muted">در حال بارگذاری…</p>
<ul v-else-if="items.length" class="list">
<li v-for="item in items" :key="item.key" :class="['item', item.kind, { done: item.done }]">
<div class="item-main">
<span class="badge">{{ item.meta }}</span>
<strong>{{ item.title }}</strong>
<p v-if="item.body" class="body">{{ item.body }}</p>
<time>{{ formatTime(item.at) }}</time>
</div>
<div class="item-actions">
<RouterLink :to="item.href">باز کردن</RouterLink>
<button
v-if="item.kind === 'activity' && !item.done"
class="linkish"
type="button"
@click="completeActivity(item)"
>
انجام شد
</button>
</div>
</li>
</ul>
<p v-else class="muted">هنوز چیزی در تایم‌لاین نیست. یک یادداشت یا فعالیت ثبت کنید.</p>
</section>
</template>
<style scoped>
.timeline {
margin-top: 1.25rem;
padding-top: 1.1rem;
border-top: 1px solid var(--line);
display: grid;
gap: 0.9rem;
}
.head h3 {
margin: 0 0 0.25rem;
font-size: 1.05rem;
}
.head .muted {
margin: 0;
}
.composers {
display: grid;
gap: 0.75rem;
}
.composer {
display: grid;
gap: 0.5rem;
}
.composer textarea,
.composer input {
width: 100%;
border: 1px solid var(--line);
border-radius: 12px;
padding: 0.7rem 0.85rem;
background: #fff;
}
.list {
list-style: none;
margin: 0;
padding: 0;
display: grid;
gap: 0.55rem;
}
.item {
display: flex;
justify-content: space-between;
gap: 0.75rem;
align-items: flex-start;
border: 1px solid var(--line);
border-radius: 12px;
padding: 0.75rem 0.85rem;
background: #fbfcfb;
}
@media (max-width: 640px) {
.item {
flex-direction: column;
}
.item-actions {
justify-items: start;
flex-direction: row;
display: flex;
gap: 0.75rem;
}
}
.item.note {
border-right: 3px solid var(--accent);
}
.item.activity {
border-right: 3px solid #b7791f;
}
.item.done {
opacity: 0.72;
}
.item-main {
display: grid;
gap: 0.2rem;
min-width: 0;
}
.badge {
font-size: 0.75rem;
color: var(--muted);
}
.body {
margin: 0;
color: var(--muted);
font-size: 0.9rem;
white-space: pre-wrap;
overflow-wrap: anywhere;
}
time {
font-size: 0.78rem;
color: var(--muted);
}
.item-actions {
display: grid;
gap: 0.35rem;
justify-items: end;
flex-shrink: 0;
}
.item-actions a,
.linkish {
border: 0;
background: transparent;
color: var(--accent);
cursor: pointer;
font: inherit;
font-size: 0.85rem;
padding: 0;
}
.error {
color: var(--danger);
margin: 0;
}
.muted {
color: var(--muted);
margin: 0;
}
</style>