Files
CRM_Montzar/apps/web/src/views/ActivityDetailView.vue
T

903 lines
22 KiB
Vue
Raw Normal View History

<script setup lang="ts">
import { computed, onMounted, onUnmounted, ref, watch } from 'vue';
import { RouterLink, useRoute, useRouter } from 'vue-router';
import { api } from '@/lib/api';
import {
calendarDaysBetween,
formatClockFa,
formatJalaliDay,
formatJalaliNumeric,
} from '@/lib/jalali';
import UiIcon from '@/components/UiIcon.vue';
import UserAvatar from '@/components/UserAvatar.vue';
import UserMultiSelect from '@/components/UserMultiSelect.vue';
import ActivityCommentSheet from '@/components/activities/ActivityCommentSheet.vue';
import ActivityPostponeSheet from '@/components/activities/ActivityPostponeSheet.vue';
import ActivityFieldsView from '@/components/activities/ActivityFieldsView.vue';
import type {
ActivityChecklistItem,
ActivityDto,
ActivityPriority,
OrgUserDto,
} from '@mento/shared';
import { useAuthStore } from '@/stores/auth';
import { canReassignRecords } from '@/lib/roles';
import { activityTypeView, typeColorStyle, useActivityTypes } from '@/lib/activity-types';
import { brand } from '@mento/shared';
const route = useRoute();
const router = useRouter();
const auth = useAuthStore();
const id = computed(() => (typeof route.params.id === 'string' ? route.params.id : ''));
const canReassign = computed(() => canReassignRecords(auth.user?.role));
const loading = ref(true);
const saving = ref(false);
const error = ref('');
const activity = ref<ActivityDto | null>(null);
const imageUrls = ref<Array<{ id: string; name: string; url: string }>>([]);
const moreOpen = ref(false);
const commentOpen = ref(false);
const postponeOpen = ref(false);
const noteOpen = ref(true);
const users = ref<OrgUserDto[]>([]);
const assignUserIds = ref<string[]>([]);
const assigneeNames = computed(() => {
const a = activity.value;
if (!a) return '—';
const list = a.assignees?.length
? a.assignees.map((u) => u.fullName)
: a.assignee
? [a.assignee.fullName]
: [];
return list.length ? list.join('، ') : '—';
});
const activityTypes = useActivityTypes();
const typeView = computed(() => (activity.value ? activityTypeView(activity.value) : null));
const typeDef = computed(() => (activity.value ? activityTypes.defOf(activity.value) : null));
const fieldsBusy = ref(false);
/** تیک چک‌لیست بدون باز کردن فرم ویرایش */
async function toggleChecklistItem(key: string, itemId: string, done: boolean) {
const a = activity.value;
if (!a || fieldsBusy.value) return;
const list = (a.fields?.[key] as ActivityChecklistItem[] | undefined) ?? [];
const nextFields = {
...(a.fields ?? {}),
[key]: list.map((it) => (it.id === itemId ? { ...it, done } : it)),
};
activity.value = { ...a, fields: nextFields };
fieldsBusy.value = true;
error.value = '';
try {
const { data } = await api.patch<ActivityDto>(`/activities/${a.id}`, { fields: nextFields });
activity.value = { ...data, participantUsers: a.participantUsers };
} catch {
activity.value = a;
error.value = 'ثبت تغییر چک‌لیست ناموفق بود.';
} finally {
fieldsBusy.value = false;
}
}
const priorityLabel: Record<ActivityPriority, string> = {
very_low: 'خیلی کم',
low: 'کم',
medium: 'متوسط',
high: 'زیاد',
very_high: 'خیلی زیاد',
};
const subtitle = computed(() => {
const a = activity.value;
if (!a) return '';
return a.deal?.title || a.company?.name || a.person?.fullName || '';
});
const dueLabel = computed(() => {
const due = activity.value?.dueAt;
if (!due) return 'بدون موعد';
const d = new Date(due);
const days = calendarDaysBetween(d, new Date());
const clock = formatClockFa(d);
if (days === 0) return `امروز - ${clock}`;
if (days === 1) return `دیروز - ${clock}`;
if (days === -1) return `فردا - ${clock}`;
return `${formatJalaliDay(d)} - ${clock}`;
});
const createdLabel = computed(() => {
const raw = activity.value?.createdAt;
if (!raw) return '—';
const d = new Date(raw);
return `${formatJalaliDay(d)} - ${formatClockFa(d)}`;
});
const isDone = computed(() => activity.value?.status === 'done');
const statusText = computed(() => {
if (!activity.value) return '—';
if (activity.value.status === 'done') return 'انجام شد';
if (activity.value.status === 'canceled') return 'لغو شده';
return 'انجام نشده';
});
async function load() {
if (!id.value) return;
loading.value = true;
error.value = '';
moreOpen.value = false;
try {
const { data } = await api.get<ActivityDto>(`/activities/${id.value}`);
activity.value = data;
await loadActivityImages(data);
assignUserIds.value = data.assignees?.length
? data.assignees.map((u) => u.id)
: data.assignee?.id
? [data.assignee.id]
: [];
} catch {
error.value = 'بارگذاری جزئیات فعالیت ناموفق بود.';
activity.value = null;
} finally {
loading.value = false;
}
}
function clearImageUrls() {
for (const image of imageUrls.value) URL.revokeObjectURL(image.url);
imageUrls.value = [];
}
async function loadActivityImages(row: ActivityDto) {
clearImageUrls();
const next: Array<{ id: string; name: string; url: string }> = [];
for (const image of row.images ?? []) {
try {
const { data } = await api.get<Blob>(`/activities/${row.id}/images/${image.id}`, {
responseType: 'blob',
});
next.push({ id: image.id, name: image.fileName, url: URL.createObjectURL(data) });
} catch {
/* عکس در دسترس نیست */
}
}
imageUrls.value = next;
}
onUnmounted(clearImageUrls);
async function loadUsers() {
if (!canReassign.value) return;
try {
const { data } = await api.get<OrgUserDto[]>('/users');
users.value = data;
} catch {
users.value = [];
}
}
async function complete() {
if (!id.value || isDone.value) return;
saving.value = true;
error.value = '';
try {
await api.patch(`/activities/${id.value}/complete`);
await router.push({ name: 'activities' });
} catch {
error.value = 'ثبت انجام‌شدن ناموفق بود.';
} finally {
saving.value = false;
}
}
async function reopen() {
if (!id.value || !activity.value || activity.value.status === 'open') return;
saving.value = true;
error.value = '';
try {
await api.patch(`/activities/${id.value}`, { status: 'open' });
await load();
} catch {
error.value = 'بازگشایی کار ناموفق بود.';
} finally {
saving.value = false;
}
}
async function reassign() {
if (!id.value || !canReassign.value) return;
if (!assignUserIds.value.length) {
error.value = 'حداقل یک مسئول لازم است.';
return;
}
saving.value = true;
error.value = '';
try {
await api.patch(`/activities/${id.value}`, {
assigneeIds: [...assignUserIds.value],
assigneeId: assignUserIds.value[0] || null,
});
await load();
} catch {
error.value = 'ارجاع کار ناموفق بود.';
} finally {
saving.value = false;
}
}
async function postponeTo(iso: string) {
if (!id.value || !activity.value || isDone.value) return;
saving.value = true;
error.value = '';
try {
await api.patch(`/activities/${id.value}`, { dueAt: iso });
postponeOpen.value = false;
await load();
} catch {
error.value = 'تعویق فعالیت ناموفق بود.';
} finally {
saving.value = false;
}
}
async function submitComment(text: string) {
const body = text.trim();
if (!id.value || !activity.value || !body) return;
saving.value = true;
error.value = '';
try {
const prev = activity.value.description?.trim();
const now = new Date();
const author = auth.user?.fullName?.trim();
const stamp = `[${formatJalaliNumeric(now)} ساعت ${formatClockFa(now)}${author ? ` — ${author}` : ''}]`;
const entry = `${stamp}\n${body}`;
const next = prev ? `${prev}\n\n${entry}` : entry;
await api.patch(`/activities/${id.value}`, { description: next });
commentOpen.value = false;
await load();
} catch {
error.value = 'ثبت نظر ناموفق بود.';
} finally {
saving.value = false;
}
}
async function remove() {
moreOpen.value = false;
if (!id.value || !confirm('این فعالیت حذف شود؟')) return;
try {
await api.delete(`/activities/${id.value}`);
await router.push({ name: 'activities' });
} catch {
error.value = 'حذف ناموفق بود.';
}
}
const routinesEnabled = computed(() => {
const mods = auth.user?.enabledModules;
return !mods || mods.includes('routines');
});
const converting = ref(false);
/** پیگیری → Task در «کارهای روتین» (تکراری نمی‌سازد) */
async function convertToTask() {
if (!id.value || converting.value) return;
converting.value = true;
error.value = '';
try {
const { data } = await api.post<{ id: string }>(`/routine-tasks/from-activity/${id.value}`);
moreOpen.value = false;
void router.push({ path: '/routines', query: { task: data.id } });
} catch (e) {
const msg = (e as { response?: { data?: { message?: string | string[] } } }).response?.data?.message;
error.value = Array.isArray(msg) ? msg.join('، ') : (msg ?? 'تبدیل به Task ممکن نشد.');
} finally {
converting.value = false;
}
}
watch(id, () => {
commentOpen.value = false;
postponeOpen.value = false;
void load();
});
onMounted(() => {
void load();
void loadUsers();
void activityTypes.load();
});
</script>
<template>
<section class="page" @click="moreOpen = false">
<header class="nav">
<RouterLink class="icon-btn back" :to="{ name: 'activities' }" aria-label="بازگشت">
<UiIcon name="chevron" :size="18" />
</RouterLink>
<span class="brand">{{ brand.name }}</span>
<div class="more" @click.stop>
<button class="icon-btn" type="button" aria-label="منوی بیشتر" @click="moreOpen = !moreOpen">
<UiIcon name="dots-v" :size="18" />
</button>
<div v-if="moreOpen && activity" class="menu">
<RouterLink :to="{ name: 'activity-edit', params: { id } }">ویرایش</RouterLink>
<button v-if="routinesEnabled" type="button" :disabled="converting" @click="convertToTask">
تبدیل به Task
</button>
<button type="button" class="danger" @click="remove">حذف</button>
</div>
</div>
</header>
<h1>جزئیات فعالیت</h1>
<p v-if="loading" class="muted">در حال بارگذاری…</p>
<p v-else-if="error && !activity" class="error">{{ error }}</p>
<template v-else-if="activity">
<article class="card">
<header class="hero">
<div>
<h2>
{{ activity.title }}
<UiIcon name="star" :size="16" class="star" />
</h2>
<p v-if="subtitle" class="sub">
<UiIcon name="building" :size="14" />
{{ subtitle }}
</p>
<div v-if="activity.tags?.length" class="tags">
<RouterLink
v-for="t in activity.tags"
:key="t"
class="tag"
:to="{ name: 'activities', query: { q: t, filter: 'all' } }"
>
#{{ t }}
</RouterLink>
</div>
</div>
<span class="type-ico" :style="typeColorStyle(typeView!.color)">
<UiIcon :name="typeView!.icon" :size="22" />
</span>
</header>
<dl>
<div>
<dt>
<UiIcon :name="typeView!.icon" :size="15" />
نوع فعالیت
</dt>
<dd>{{ typeView!.name }}</dd>
</div>
<div>
<dt>
<UiIcon name="calendar" :size="15" />
تاریخ و زمان
</dt>
<dd>{{ dueLabel }}</dd>
</div>
<div class="assignees-row">
<dt>
<UiIcon name="user" :size="15" />
مسئولان
</dt>
<dd class="person">
<UserMultiSelect
v-if="canReassign"
v-model="assignUserIds"
:users="users"
:disabled="saving"
:min="1"
@change="reassign"
/>
<template v-else>
{{ assigneeNames }}
</template>
</dd>
</div>
<div>
<dt>
<UiIcon name="flag" :size="15" class="flag" :class="activity.priority || 'medium'" />
اولویت
</dt>
<dd class="priority" :class="activity.priority || 'medium'">
{{ priorityLabel[activity.priority || 'medium'] }}
</dd>
</div>
<div>
<dt>وضعیت</dt>
<dd>
<span class="status" :class="{ done: isDone }">{{ statusText }}</span>
</dd>
</div>
<div>
<dt>
<UiIcon name="user" :size="15" />
ایجاد شده توسط
</dt>
<dd class="person">
<UserAvatar v-if="activity.createdBy" :name="activity.createdBy.fullName" :user-id="activity.createdBy.id" :size="28" />
{{ activity.createdBy?.fullName || '—' }}
</dd>
</div>
<div v-if="activity.lead">
<dt>
<UiIcon name="target" :size="15" />
لید
</dt>
<dd>
<RouterLink
class="lead-link"
:to="{ name: 'lead-detail', params: { id: activity.lead.id } }"
>
{{ activity.lead.title }}
</RouterLink>
</dd>
</div>
<div>
<dt>
<UiIcon name="calendar" :size="15" />
تاریخ ایجاد
</dt>
<dd>{{ createdLabel }}</dd>
</div>
</dl>
</article>
<article v-if="typeDef?.fields.length" class="card type-card">
<h3>
<span class="type-ico sm" :style="typeColorStyle(typeView!.color)">
<UiIcon :name="typeView!.icon" :size="14" />
</span>
اطلاعات {{ typeView!.name }}
</h3>
<ActivityFieldsView
:fields="typeDef.fields"
:values="activity.fields ?? {}"
:participant-users="activity.participantUsers"
:busy="fieldsBusy"
editable
@toggle-item="toggleChecklistItem"
/>
</article>
<article class="card note">
<button class="note-head" type="button" @click="noteOpen = !noteOpen">
<strong>
<UiIcon name="document" :size="16" />
یادداشت
</strong>
<UiIcon name="chevron" :size="16" :class="{ open: noteOpen }" />
</button>
<div v-show="noteOpen">
<p v-if="activity.description?.trim()">{{ activity.description.trim() }}</p>
<p v-else-if="!imageUrls.length" class="empty">یادداشتی ثبت نشده.</p>
<div v-if="imageUrls.length" class="note-photos">
<a v-for="image in imageUrls" :key="image.id" :href="image.url" target="_blank" rel="noopener">
<img :src="image.url" :alt="image.name" />
</a>
</div>
</div>
</article>
<p v-if="error" class="error">{{ error }}</p>
<ActivityCommentSheet
v-if="commentOpen"
:activity="activity"
:saving="saving"
@close="commentOpen = false"
@submit="submitComment"
/>
<ActivityPostponeSheet
v-if="postponeOpen"
:due-at="activity.dueAt"
:saving="saving"
@close="postponeOpen = false"
@submit="postponeTo"
/>
</template>
<footer v-if="activity && !commentOpen && !postponeOpen" class="dock">
<button
v-if="isDone || activity.status === 'canceled'"
class="act primary"
type="button"
:disabled="saving"
@click="reopen"
>
<UiIcon name="clock" :size="16" />
بازگشایی
</button>
<button
v-else
class="act primary"
type="button"
:disabled="saving || isDone"
@click="complete"
>
<UiIcon name="check" :size="16" />
انجام شد
</button>
<button class="act ghost" type="button" :disabled="saving || isDone" @click="postponeOpen = true">
<UiIcon name="refresh" :size="16" />
تعویق
</button>
<button class="act ghost" type="button" :disabled="saving" @click="commentOpen = true">
<UiIcon name="chat" :size="16" />
ثبت نظر
</button>
</footer>
</section>
</template>
<style scoped>
.page {
width: 100%;
max-width: 100%;
min-width: 0;
margin: 0;
display: grid;
gap: 0.9rem;
padding-bottom: 5.5rem;
}
.nav {
display: flex;
align-items: center;
justify-content: space-between;
position: relative;
z-index: 4;
}
.brand {
font-weight: 600;
color: #0f766e;
letter-spacing: -0.04em;
}
.icon-btn {
width: 38px;
height: 38px;
border: 0;
background: #fff;
border-radius: var(--radius);
display: grid;
place-items: center;
color: #334155;
box-shadow: 0 4px 12px rgba(15, 23, 42, 0.05);
}
.back :deep(.ico) {
transform: scaleX(-1);
}
.more {
position: relative;
}
.menu {
position: absolute;
top: calc(100% + 6px);
inset-inline-end: 0;
min-width: 10rem;
background: #fff;
border-radius: var(--radius);
box-shadow: 0 16px 40px rgba(15, 23, 42, 0.14);
padding: 0.35rem;
display: grid;
z-index: 6;
}
.menu a,
.menu button {
display: block;
width: 100%;
text-align: start;
border: 0;
background: none;
padding: 0.62rem 0.75rem;
border-radius: var(--radius);
font: inherit;
cursor: pointer;
color: #0f172a;
}
.menu .danger {
color: #b91c1c;
}
h1 {
margin: 0.1rem 0 0;
font-size: 1.35rem;
}
.muted,
.error {
margin: 0;
}
.muted {
color: #64748b;
}
.error {
color: #b91c1c;
}
.card {
background: #fff;
border-radius: var(--radius);
box-shadow: 0 8px 24px rgba(15, 23, 42, 0.05);
padding: 1rem 1.05rem;
}
.hero {
display: flex;
justify-content: space-between;
gap: 0.8rem;
align-items: flex-start;
padding-bottom: 0.85rem;
}
.hero h2 {
margin: 0;
font-size: 1.05rem;
display: flex;
align-items: flex-start;
gap: 0.35rem;
}
.star {
color: #cbd5e1;
margin-top: 0.15rem;
}
.sub {
margin: 0.4rem 0 0;
color: #64748b;
font-size: 0.84rem;
display: flex;
align-items: center;
gap: 0.3rem;
}
.type-ico {
width: 48px;
height: 48px;
border-radius: 999px;
display: grid;
place-items: center;
flex-shrink: 0;
}
.tags {
display: flex;
flex-wrap: wrap;
gap: 0.3rem;
margin-top: 0.5rem;
}
.tag {
background: #f0fdfa;
color: #0f766e;
border-radius: 999px;
padding: 0.15rem 0.55rem;
font-size: 0.76rem;
font-weight: 600;
text-decoration: none;
}
.type-ico.sm {
width: 28px;
height: 28px;
}
.type-card h3 {
margin: 0 0 0.4rem;
display: flex;
align-items: center;
gap: 0.45rem;
font-size: 0.95rem;
color: #0f172a;
}
dl {
margin: 0;
}
dl > div {
display: flex;
justify-content: space-between;
gap: 0.8rem;
align-items: center;
padding: 0.78rem 0;
border-top: 1px solid #f1f5f9;
}
dt,
dd {
margin: 0;
}
dt {
display: flex;
align-items: center;
gap: 0.4rem;
color: #64748b;
font-size: 0.86rem;
}
dd {
font-weight: 700;
text-align: start;
}
.lead-link {
color: var(--accent, #0f766e);
font-weight: 600;
text-decoration: none;
}
.lead-link:hover {
text-decoration: underline;
}
.person {
display: flex;
align-items: center;
flex-wrap: wrap;
gap: 0.45rem;
}
.assignees-row {
align-items: flex-start;
flex-wrap: wrap;
}
.assignees-row dt {
padding-top: 0.3rem;
flex-shrink: 0;
}
.assignees-row dd {
flex: 1 1 16rem;
min-width: 0;
font-weight: 500;
}
.flag.low,
.priority.low {
color: #64748b;
}
.flag.medium,
.priority.medium {
color: #ea580c;
}
.flag.high,
.priority.high {
color: #dc2626;
}
.status {
display: inline-flex;
border-radius: 999px;
padding: 0.22rem 0.65rem;
background: #ecfdf5;
color: #0f766e;
font-size: 0.8rem;
}
.status.done {
background: #dcfce7;
color: #166534;
}
.note-head {
width: 100%;
display: flex;
justify-content: space-between;
align-items: center;
border: 0;
background: none;
padding: 0;
font: inherit;
color: #0f766e;
cursor: pointer;
}
.note-head :deep(.ico:last-child) {
transform: rotate(90deg);
color: #94a3b8;
}
.note-head :deep(.ico.open) {
transform: rotate(-90deg);
}
.note p {
margin: 0.75rem 0 0;
color: #334155;
line-height: 1.9;
font-size: 0.9rem;
white-space: pre-line;
}
.note p.empty {
color: #94a3b8;
}
.note-photos {
display: flex;
flex-wrap: wrap;
gap: 0.5rem;
margin-top: 0.75rem;
}
.note-photos a {
width: 5.5rem;
height: 5.5rem;
}
.note-photos img {
width: 100%;
height: 100%;
object-fit: cover;
border-radius: 0.6rem;
border: 1px solid #e2e8f0;
}
.dock {
position: fixed;
inset-inline: 0.85rem;
bottom: calc(0.55rem + var(--mobile-tabbar-h));
display: grid;
grid-template-columns: 1.15fr 1fr 1fr;
gap: 0.5rem;
z-index: 8;
}
.act {
display: inline-flex;
align-items: center;
justify-content: center;
gap: 0.3rem;
min-height: var(--control-h);
height: var(--control-h);
border-radius: var(--radius);
border: 1px solid #99f6e4;
background: #fff;
color: #0f766e;
font: inherit;
font-weight: 700;
cursor: pointer;
}
.act.primary {
background: #14b8a6;
border-color: #14b8a6;
color: #fff;
}
.act:disabled {
opacity: 0.45;
cursor: not-allowed;
}
@media (min-width: 900px) {
.page {
padding-bottom: 0;
}
.dock {
position: sticky;
inset-inline: auto;
bottom: 0;
}
}
</style>