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

730 lines
19 KiB
Vue
Raw Normal View History

<script setup lang="ts">
import { computed, onMounted, ref, watch } from 'vue';
import { RouterLink, useRoute, useRouter } from 'vue-router';
import axios from 'axios';
import { api } from '@/lib/api';
import { canViewAllRecords } from '@/lib/roles';
import { useAuthStore } from '@/stores/auth';
import {
UiBadge,
UiButton,
UiCard,
UiField,
UiInput,
UiPageHeader,
UiSelect,
UiTextarea,
} from '@/components/ui';
import UiIcon from '@/components/UiIcon.vue';
import type {
CompanyDto,
CreateTicketRequest,
FaqArticleDto,
OrgUserDto,
PaginatedResponse,
PersonDto,
TicketDto,
TicketPriority,
TicketStatus,
} from '@mento/shared';
const route = useRoute();
const router = useRouter();
const auth = useAuthStore();
const id = computed(() =>
typeof route.params.id === 'string' ? route.params.id : '',
);
const isNew = computed(() => route.name === 'ticket-new');
const pageTitle = computed(() => (isNew.value ? 'تیکت جدید' : number.value || 'تیکت'));
const loading = ref(false);
const saving = ref(false);
const messaging = ref(false);
const csatSaving = ref(false);
const error = ref('');
const number = ref('');
const subject = ref('');
const description = ref('');
const status = ref<string>('open');
const priority = ref<string>('normal');
const personId = ref('');
const companyId = ref('');
const assigneeId = ref('');
const people = ref<PersonDto[]>([]);
const companies = ref<CompanyDto[]>([]);
const users = ref<OrgUserDto[]>([]);
const ticket = ref<TicketDto | null>(null);
const reply = ref('');
const replyInternal = ref(false);
const faqHints = ref<FaqArticleDto[]>([]);
const csatScore = ref<number | null>(null);
const csatComment = ref('');
const canAssignOthers = computed(() => canViewAllRecords(auth.user?.role));
const showCsat = computed(() => {
if (!ticket.value || isNew.value) return false;
const s = status.value;
return s === 'resolved' || s === 'closed';
});
const statusLabel: Record<TicketStatus, string> = {
open: 'باز',
in_progress: 'در حال رسیدگی',
waiting: 'در انتظار',
resolved: 'حل‌شده',
closed: 'بسته',
};
const priorityLabel: Record<TicketPriority, string> = {
low: 'کم',
normal: 'عادی',
high: 'بالا',
urgent: 'فوری',
};
function formatWhen(iso: string) {
try {
return new Intl.DateTimeFormat('fa-IR', {
dateStyle: 'medium',
timeStyle: 'short',
}).format(new Date(iso));
} catch {
return iso;
}
}
async function loadFaqHints() {
if (!isNew.value) {
faqHints.value = [];
return;
}
const q = subject.value.trim();
if (q.length < 2) {
faqHints.value = [];
return;
}
try {
const { data } = await api.get<FaqArticleDto[]>('/faq', {
params: { q, publishedOnly: true },
});
faqHints.value = data.slice(0, 5);
} catch {
faqHints.value = [];
}
}
let faqTimer: ReturnType<typeof setTimeout> | null = null;
watch(subject, () => {
if (!isNew.value) return;
if (faqTimer) clearTimeout(faqTimer);
faqTimer = setTimeout(() => void loadFaqHints(), 350);
});
async function load() {
loading.value = true;
error.value = '';
try {
const [peopleRes, companiesRes] = await Promise.all([
api.get<PaginatedResponse<PersonDto>>('/people', {
params: { limit: 100 },
}),
api.get<PaginatedResponse<CompanyDto>>('/companies', {
params: { limit: 100 },
}),
]);
people.value = peopleRes.data.items;
companies.value = companiesRes.data.items;
if (canAssignOthers.value) {
try {
users.value = (await api.get<OrgUserDto[]>('/users')).data;
} catch {
users.value = [];
}
}
if (!isNew.value && id.value) {
const { data } = await api.get<TicketDto>(`/tickets/${id.value}`);
ticket.value = data;
number.value = data.number;
subject.value = data.subject;
description.value = data.description ?? '';
status.value = data.status;
priority.value = data.priority;
personId.value = data.person?.id ?? '';
companyId.value = data.company?.id ?? '';
assigneeId.value = data.assignee?.id ?? '';
csatScore.value = data.csatScore;
csatComment.value = data.csatComment ?? '';
} else {
ticket.value = null;
assigneeId.value = auth.user?.id ?? '';
csatScore.value = null;
csatComment.value = '';
if (typeof route.query.personId === 'string' && route.query.personId) {
personId.value = route.query.personId;
const person = people.value.find((p) => p.id === personId.value);
if (person?.company?.id) companyId.value = person.company.id;
}
void loadFaqHints();
}
} catch {
error.value = 'بارگذاری تیکت ناموفق بود.';
} finally {
loading.value = false;
}
}
function payload(): CreateTicketRequest {
return {
subject: subject.value.trim(),
description: description.value || undefined,
status: status.value as TicketStatus,
priority: priority.value as TicketPriority,
personId: personId.value || null,
companyId: companyId.value || null,
assigneeId: assigneeId.value || null,
};
}
async function save() {
if (!subject.value.trim()) {
error.value = 'موضوع را وارد کنید.';
return;
}
saving.value = true;
error.value = '';
try {
if (isNew.value) {
const { data } = await api.post<TicketDto>('/tickets', payload());
await router.replace({ name: 'ticket-detail', params: { id: data.id } });
} else {
await api.patch(`/tickets/${id.value}`, payload());
await load();
}
} catch (e) {
if (axios.isAxiosError(e)) {
const msg = (e.response?.data as { message?: string | string[] })?.message;
error.value = Array.isArray(msg)
? msg.join('، ')
: (msg ?? 'ذخیره ناموفق بود');
} else {
error.value = 'ذخیره ناموفق بود';
}
} finally {
saving.value = false;
}
}
async function saveCsat() {
if (!id.value || csatScore.value == null) {
error.value = 'امتیاز رضایت را انتخاب کنید.';
return;
}
csatSaving.value = true;
error.value = '';
try {
const { data } = await api.patch<TicketDto>(`/tickets/${id.value}`, {
csatScore: csatScore.value,
csatComment: csatComment.value.trim() || null,
});
ticket.value = data;
csatScore.value = data.csatScore;
csatComment.value = data.csatComment ?? '';
} catch (e) {
if (axios.isAxiosError(e)) {
const msg = (e.response?.data as { message?: string | string[] })?.message;
error.value = Array.isArray(msg)
? msg.join('، ')
: (msg ?? 'ثبت رضایت ناموفق بود');
} else {
error.value = 'ثبت رضایت ناموفق بود';
}
} finally {
csatSaving.value = false;
}
}
async function sendReply() {
if (!id.value || !reply.value.trim()) return;
messaging.value = true;
error.value = '';
try {
const { data } = await api.post<TicketDto>(`/tickets/${id.value}/messages`, {
body: reply.value.trim(),
isInternal: replyInternal.value,
});
ticket.value = data;
reply.value = '';
replyInternal.value = false;
} catch (e) {
if (axios.isAxiosError(e)) {
const msg = (e.response?.data as { message?: string | string[] })?.message;
error.value = Array.isArray(msg)
? msg.join('، ')
: (msg ?? 'ارسال پیام ناموفق بود');
} else {
error.value = 'ارسال پیام ناموفق بود';
}
} finally {
messaging.value = false;
}
}
async function remove() {
if (!id.value || !confirm('این تیکت حذف شود؟')) return;
await api.delete(`/tickets/${id.value}`);
await router.push({ name: 'tickets' });
}
function cancel() {
void router.push({ name: 'tickets' });
}
onMounted(load);
</script>
<template>
<section class="crm-page narrow">
<UiPageHeader
:title="pageTitle"
:back-to="{ name: 'tickets' }"
>
<template #lead>
<span class="crumb">
<RouterLink :to="{ name: 'tickets' }">تیکت‌ها</RouterLink>
<span>›</span>
<strong>{{ pageTitle }}</strong>
</span>
</template>
<template #actions>
<UiButton
variant="primary"
:disabled="saving"
:loading="saving"
@click="save"
>
<UiIcon name="check" :size="16" />
{{ saving ? 'در حال ذخیره…' : 'ذخیره' }}
</UiButton>
<UiButton variant="ghost" :disabled="saving" @click="cancel">
انصراف
</UiButton>
<UiButton
v-if="!isNew"
variant="danger"
:disabled="saving"
@click="remove"
>
حذف
</UiButton>
</template>
</UiPageHeader>
<p v-if="loading" class="muted">در حال بارگذاری…</p>
<template v-else>
<aside
v-if="!isNew && ticket?.slaDueAt"
class="sla-box"
:class="{ bad: ticket.slaBreached }"
>
<UiIcon name="clock" :size="18" />
<div>
<template v-if="ticket.slaBreached">
مهلت پاسخ اول گذشته است.
</template>
<template v-else-if="ticket.firstRespondedAt">
پاسخ اول ثبت شد ({{ formatWhen(ticket.firstRespondedAt) }}).
</template>
<template v-else>
مهلت پاسخ اول: {{ formatWhen(ticket.slaDueAt) }}
</template>
</div>
</aside>
<form class="crm-form" @submit.prevent="save">
<UiCard>
<div class="crm-form-grid-2">
<div class="crm-col-span-full">
<UiField label="موضوع" required for-id="subject">
<UiInput
id="subject"
v-model="subject"
required
maxlength="200"
placeholder="موضوع تیکت را وارد کنید…"
/>
</UiField>
</div>
<UiField label="وضعیت" for-id="status">
<UiSelect id="status" v-model="status">
<option
v-for="(label, key) in statusLabel"
:key="key"
:value="key"
>
{{ label }}
</option>
</UiSelect>
</UiField>
<UiField label="اولویت" for-id="priority">
<UiSelect id="priority" v-model="priority">
<option
v-for="(label, key) in priorityLabel"
:key="key"
:value="key"
>
{{ label }}
</option>
</UiSelect>
</UiField>
<UiField label="شخص مرتبط" for-id="personId">
<UiSelect id="personId" v-model="personId">
<option value="">بدون شخص</option>
<option v-for="p in people" :key="p.id" :value="p.id">
{{ p.fullName }}
</option>
</UiSelect>
</UiField>
<UiField label="شرکت مرتبط" for-id="companyId">
<UiSelect id="companyId" v-model="companyId">
<option value="">بدون شرکت</option>
<option v-for="c in companies" :key="c.id" :value="c.id">
{{ c.name }}
</option>
</UiSelect>
</UiField>
<UiField v-if="canAssignOthers" label="مسئول" for-id="assigneeId">
<UiSelect id="assigneeId" v-model="assigneeId">
<option value="">بدون مسئول</option>
<option v-for="u in users" :key="u.id" :value="u.id">
{{ u.fullName }}
</option>
</UiSelect>
</UiField>
<div class="crm-col-span-full">
<UiField :label="isNew ? 'شرح اولیه' : 'شرح'" for-id="description">
<UiTextarea
id="description"
v-model="description"
:rows="4"
placeholder="جزئیات و توضیحات تیکت را بنویسید…"
/>
</UiField>
</div>
</div>
</UiCard>
<UiCard v-if="isNew && faqHints.length" class="faq-hints-card" padding="sm">
<p class="faq-title">
<UiIcon name="lightbulb" :size="16" />
شاید این مقالات راهنما (FAQ) کمک کند:
</p>
<details v-for="f in faqHints" :key="f.id" class="faq-item">
<summary>{{ f.question }}</summary>
<p>{{ f.answer }}</p>
</details>
</UiCard>
<p v-if="error" class="error-msg">{{ error }}</p>
</form>
<UiCard v-if="showCsat" class="csat-card">
<template #header>
<div class="csat-header">
<h3>رضایت مشتری (CSAT)</h3>
<span v-if="ticket?.csatAt" class="muted">
آخرین ثبت: {{ formatWhen(ticket.csatAt) }}
</span>
</div>
</template>
<div class="csat-body">
<div class="csat-scores">
<button
v-for="n in 5"
:key="n"
type="button"
class="score"
:class="{ active: csatScore === n }"
@click="csatScore = n"
>
{{ n }}
</button>
</div>
<UiTextarea
v-model="csatComment"
:rows="2"
placeholder="نظر یا بازخورد مشتری (اختیاری)…"
/>
</div>
<template #footer>
<UiButton
variant="secondary"
:disabled="csatSaving || csatScore == null"
:loading="csatSaving"
@click="saveCsat"
>
{{ ticket?.csatAt ? 'به‌روزرسانی رضایت' : 'ثبت رضایت' }}
</UiButton>
</template>
</UiCard>
<UiCard v-if="!isNew && ticket" class="thread-card">
<template #header>
<div class="thread-header">
<h3>گفتگو و پیام‌ها</h3>
<UiBadge variant="neutral" size="sm">
{{ ticket.messages.length }} پیام
</UiBadge>
</div>
</template>
<div v-if="ticket.messages.length" class="messages">
<article
v-for="msg in ticket.messages"
:key="msg.id"
class="msg"
:class="{ internal: msg.isInternal }"
>
<header>
<strong>{{ msg.author?.fullName || 'سیستم' }}</strong>
<span class="muted">{{ formatWhen(msg.createdAt) }}</span>
<UiBadge v-if="msg.isInternal" variant="warning" size="sm">داخلی</UiBadge>
</header>
<p>{{ msg.body }}</p>
</article>
</div>
<p v-else class="muted empty-messages">هنوز پیامی ثبت نشده است.</p>
<template #footer>
<form class="reply-form" @submit.prevent="sendReply">
<UiTextarea
v-model="reply"
:rows="3"
placeholder="پاسخ به تیکت یا یادداشت جدید…"
required
/>
<div class="reply-actions">
<label class="check">
<input v-model="replyInternal" type="checkbox" />
<span>یادداشت داخلی (فقط همکاران)</span>
</label>
<UiButton
variant="primary"
type="submit"
:disabled="messaging || !reply.trim()"
:loading="messaging"
>
ارسال پیام
</UiButton>
</div>
</form>
</template>
</UiCard>
</template>
</section>
</template>
<style scoped>
.crumb {
display: flex;
align-items: center;
gap: var(--sp-2);
color: var(--color-text-muted);
font-size: var(--fs-xs);
}
.crumb a {
color: var(--color-primary-dark);
font-weight: 700;
text-decoration: none;
}
.sla-box {
display: flex;
align-items: center;
gap: var(--sp-3);
padding: var(--sp-3) var(--sp-4);
border-radius: var(--radius-lg);
border: 1px solid var(--color-primary-light);
background: var(--color-primary-surface);
color: var(--color-primary-dark);
font-size: var(--fs-sm);
font-weight: 600;
}
.sla-box.bad {
border-color: color-mix(in srgb, var(--color-danger) 40%, var(--color-border));
background: var(--color-danger-surface);
color: var(--color-danger);
}
.faq-hints-card {
border: 1px dashed var(--color-border);
background: var(--color-surface-sunken);
}
.faq-title {
margin: 0 0 var(--sp-2);
font-weight: 700;
font-size: var(--fs-sm);
display: flex;
align-items: center;
gap: var(--sp-2);
color: var(--color-text);
}
.faq-item {
font-size: var(--fs-sm);
margin-top: var(--sp-2);
}
.faq-item summary {
cursor: pointer;
font-weight: 600;
color: var(--color-text-emphasis);
}
.faq-item p {
margin: var(--sp-2) 0 0;
color: var(--color-text-muted);
white-space: pre-wrap;
line-height: 1.6;
}
.error-msg {
color: var(--color-danger);
font-size: var(--fs-sm);
font-weight: 600;
}
.csat-card,
.thread-card {
margin-top: var(--sp-4);
}
.csat-header,
.thread-header {
display: flex;
align-items: center;
justify-content: space-between;
width: 100%;
}
.csat-header h3,
.thread-header h3 {
margin: 0;
font-size: var(--fs-base);
font-weight: 700;
}
.csat-body {
display: grid;
gap: var(--sp-3);
}
.csat-scores {
display: flex;
gap: var(--sp-2);
}
.score {
width: 2.6rem;
height: 2.6rem;
border-radius: var(--radius-md);
border: 1px solid var(--color-border);
background: var(--color-surface);
color: var(--color-text);
font-weight: 600;
font-size: var(--fs-base);
cursor: pointer;
transition: all var(--transition-fast);
}
.score:hover {
border-color: var(--color-primary);
background: var(--color-primary-surface);
}
.score.active {
border-color: var(--color-primary);
background: var(--color-primary);
color: #fff;
font-weight: 600;
}
.messages {
display: grid;
gap: var(--sp-3);
}
.msg {
border: 1px solid var(--color-border);
border-radius: var(--radius-lg);
padding: var(--sp-3) var(--sp-4);
background: var(--color-surface);
}
.msg.internal {
background: var(--color-warning-surface);
border-color: color-mix(in srgb, var(--color-warning) 30%, var(--color-border));
}
.msg header {
display: flex;
flex-wrap: wrap;
gap: var(--sp-2);
align-items: center;
margin-bottom: var(--sp-2);
}
.msg p {
margin: 0;
white-space: pre-wrap;
line-height: 1.6;
font-size: var(--fs-sm);
color: var(--color-text);
}
.empty-messages {
font-size: var(--fs-sm);
padding: var(--sp-4) 0;
text-align: center;
}
.reply-form {
display: grid;
gap: var(--sp-3);
width: 100%;
}
.reply-actions {
display: flex;
align-items: center;
justify-content: space-between;
flex-wrap: wrap;
gap: var(--sp-3);
}
.check {
display: flex;
align-items: center;
gap: var(--sp-2);
font-size: var(--fs-sm);
color: var(--color-text-muted);
cursor: pointer;
}
.muted {
color: var(--color-text-muted);
}
</style>