Files
CRM_Montzar/apps/web/src/views/TicketFormView.vue
T
s.esmaeili 1ae87c97b7 feat(ui): standardize design system, modals, sidebar, and form inputs
- Implement unified design system tokens, 10px radius, and IRANSans typography
- Modernize sidebar with Gemini-style collapsed expander, square 1:1 items, and elevated profile flyout
- Standardize all modal dialogs with 16px padding, fixed header/footer, 16px medium title, and sticky bottom actions
- Refactor DealFormView and CustomFieldsSection with standard UiField, UiInput, and UiSelect components
- Add draggable bottom-left QuickCreate floating action button
2026-09-12 01:33:07 +03:30

730 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 { 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>