Files
CRM_Montzar/apps/web/src/views/TicketFormView.vue
T
MahdiandCursor 534dae45b2 feat(roles): let each section decide who may assign work to others
Section access now only creates a record for yourself. Assigning it to a colleague requires a separate permission on that section.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-11-03 13:15:21 +03:30

726 lines
19 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.
This file contains Unicode characters that might be confused with other characters. 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, useRoute, useRouter } from 'vue-router';
import axios from 'axios';
import { api } from '@/lib/api';
import { formatJalaliDateTime } from '@/lib/jalali';
import { canAssignToOthers } from '@/lib/assign-access';
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(() => canAssignToOthers(auth.user, 'tickets'));
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) {
const d = new Date(iso);
if (Number.isNaN(d.getTime())) return iso;
return formatJalaliDateTime(d);
}
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>