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
This commit is contained in:
s.esmaeili
2026-09-12 01:33:07 +03:30
parent 9a991fc1f1
commit 1ae87c97b7
87 changed files with 7844 additions and 5718 deletions
+351 -313
View File
@@ -5,6 +5,17 @@ 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,
@@ -24,6 +35,7 @@ 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);
@@ -33,8 +45,8 @@ const error = ref('');
const number = ref('');
const subject = ref('');
const description = ref('');
const status = ref<TicketStatus>('open');
const priority = ref<TicketPriority>('normal');
const status = ref<string>('open');
const priority = ref<string>('normal');
const personId = ref('');
const companyId = ref('');
const assigneeId = ref('');
@@ -138,8 +150,8 @@ async function load() {
number.value = data.number;
subject.value = data.subject;
description.value = data.description ?? '';
status.value = data.status as TicketStatus;
priority.value = data.priority as TicketPriority;
status.value = data.status;
priority.value = data.priority;
personId.value = data.person?.id ?? '';
companyId.value = data.company?.id ?? '';
assigneeId.value = data.assignee?.id ?? '';
@@ -168,8 +180,8 @@ function payload(): CreateTicketRequest {
return {
subject: subject.value.trim(),
description: description.value || undefined,
status: status.value,
priority: priority.value,
status: status.value as TicketStatus,
priority: priority.value as TicketPriority,
personId: personId.value || null,
companyId: companyId.value || null,
assigneeId: assigneeId.value || null,
@@ -266,21 +278,49 @@ async function remove() {
await router.push({ name: 'tickets' });
}
function cancel() {
void router.push({ name: 'tickets' });
}
onMounted(load);
</script>
<template>
<section class="page">
<header class="page-head">
<div class="head-copy">
<p class="crumb">
<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>{{ isNew ? 'تیکت جدید' : number || 'تیکت' }}</strong>
</p>
<h1>{{ isNew ? 'تیکت جدید' : number || 'تیکت' }}</h1>
</div>
</header>
<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>
@@ -290,137 +330,163 @@ onMounted(load);
class="sla-box"
:class="{ bad: ticket.slaBreached }"
>
<template v-if="ticket.slaBreached">مهلت پاسخ اول گذشته است.</template>
<template v-else-if="ticket.firstRespondedAt">
پاسخ اول ثبت شد ({{ formatWhen(ticket.firstRespondedAt) }}).
</template>
<template v-else>
مهلت پاسخ اول: {{ formatWhen(ticket.slaDueAt) }}
</template>
<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="form" @submit.prevent="save">
<div class="grid">
<div class="field wide">
<label>موضوع</label>
<input v-model="subject" required maxlength="200" />
</div>
<div class="field">
<label>وضعیت</label>
<select v-model="status">
<option
v-for="(label, key) in statusLabel"
:key="key"
:value="key"
>
{{ label }}
</option>
</select>
</div>
<div class="field">
<label>اولویت</label>
<select v-model="priority">
<option
v-for="(label, key) in priorityLabel"
:key="key"
:value="key"
>
{{ label }}
</option>
</select>
</div>
<div class="field">
<label>شخص</label>
<select v-model="personId">
<option value="">بدون شخص</option>
<option v-for="p in people" :key="p.id" :value="p.id">
{{ p.fullName }}
</option>
</select>
</div>
<div class="field">
<label>شرکت</label>
<select v-model="companyId">
<option value="">بدون شرکت</option>
<option v-for="c in companies" :key="c.id" :value="c.id">
{{ c.name }}
</option>
</select>
</div>
<div v-if="canAssignOthers" class="field">
<label>مسئول</label>
<select v-model="assigneeId">
<option value="">بدون مسئول</option>
<option v-for="u in users" :key="u.id" :value="u.id">
{{ u.fullName }}
</option>
</select>
</div>
<div class="field wide">
<label>{{ isNew ? 'شرح اولیه' : 'شرح' }}</label>
<textarea v-model="description" rows="4" />
</div>
</div>
<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>
<div v-if="isNew && faqHints.length" class="faq-hints">
<p class="faq-title">شاید این FAQ کمک کند:</p>
<details v-for="f in faqHints" :key="f.id">
<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>
</div>
</UiCard>
<p v-if="error" class="error">{{ error }}</p>
<div class="form-actions">
<button class="btn" type="submit" :disabled="saving">
{{ saving ? 'در حال ذخیره…' : 'ذخیره' }}
</button>
<button
v-if="!isNew"
class="btn btn-danger"
type="button"
:disabled="saving"
@click="remove"
>
حذف
</button>
</div>
<p v-if="error" class="error-msg">{{ error }}</p>
</form>
<section v-if="showCsat" class="csat">
<h2>رضایت مشتری (CSAT)</h2>
<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>
<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>
<textarea
v-model="csatComment"
rows="2"
placeholder="نظر اختیاری…"
/>
<button
class="btn"
type="button"
:disabled="csatSaving || csatScore == null"
@click="saveCsat"
>
{{ csatSaving ? '…' : ticket?.csatAt ? 'به‌روزرسانی رضایت' : 'ثبت رضایت' }}
</button>
<p v-if="ticket?.csatAt" class="muted">
آخرین ثبت: {{ formatWhen(ticket.csatAt) }}
</p>
</section>
<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>
<section v-if="!isNew && ticket" class="thread">
<h2>گفتگو</h2>
<div v-if="ticket.messages.length" class="messages">
<article
v-for="msg in ticket.messages"
@@ -431,261 +497,233 @@ onMounted(load);
<header>
<strong>{{ msg.author?.fullName || 'سیستم' }}</strong>
<span class="muted">{{ formatWhen(msg.createdAt) }}</span>
<span v-if="msg.isInternal" class="tag">داخلی</span>
<UiBadge v-if="msg.isInternal" variant="warning" size="sm">داخلی</UiBadge>
</header>
<p>{{ msg.body }}</p>
</article>
</div>
<p v-else class="muted">هنوز پیامی نیست.</p>
<p v-else class="muted empty-messages">هنوز پیامی ثبت نشده است.</p>
<form class="reply" @submit.prevent="sendReply">
<textarea
v-model="reply"
rows="3"
placeholder="پاسخ یا یادداشت…"
required
/>
<label class="check">
<input v-model="replyInternal" type="checkbox" />
یادداشت داخلی (فقط تیم)
</label>
<button class="btn" type="submit" :disabled="messaging">
{{ messaging ? '…' : 'ارسال' }}
</button>
</form>
</section>
<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>
.page {
display: grid;
gap: 1rem;
max-width: 760px;
min-width: 0;
}
.page-head {
display: flex;
justify-content: space-between;
gap: 1rem;
}
.head-copy {
min-width: 0;
}
.crumb {
margin: 0 0 0.25rem;
display: flex;
align-items: center;
gap: 0.35rem;
color: #94a3b8;
font-size: 0.82rem;
gap: var(--sp-2);
color: var(--color-text-muted);
font-size: var(--fs-xs);
}
.crumb a {
color: #0f766e;
color: var(--color-primary-dark);
font-weight: 700;
text-decoration: none;
}
h1 {
margin: 0;
font-size: clamp(1.25rem, 2.5vw, 1.55rem);
font-weight: 800;
}
h2 {
margin: 0 0 0.75rem;
font-size: 1.05rem;
}
.form,
.thread,
.csat {
display: grid;
gap: 0.85rem;
}
.form,
.thread,
.csat {
background: #fff;
border-radius: 18px;
padding: 1rem;
box-shadow: 0 8px 22px rgba(15, 23, 42, 0.04);
}
.sla-box {
padding: 0.75rem 1rem;
border-radius: 18px;
border: 1px solid #99f6e4;
background: #f0fdfa;
font-size: 0.95rem;
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, #b42318 40%, var(--line));
background: color-mix(in srgb, #b42318 8%, #fff);
color: #b42318;
border-color: color-mix(in srgb, var(--color-danger) 40%, var(--color-border));
background: var(--color-danger-surface);
color: var(--color-danger);
}
.grid {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 0.75rem;
}
.field {
display: grid;
gap: 0.35rem;
}
.field.wide {
grid-column: 1 / -1;
}
label {
color: var(--muted);
font-size: 0.9rem;
}
input,
select,
textarea {
border: 1px solid var(--line);
border-radius: var(--radius);
min-height: var(--control-h);
padding: 0 0.85rem;
font: inherit;
background: #fff;
width: 100%;
box-sizing: border-box;
}
textarea {
min-height: 5.5rem;
padding: 0.65rem 0.85rem;
}
.faq-hints {
display: grid;
gap: 0.45rem;
padding: 0.75rem;
border: 1px dashed var(--line);
border-radius: var(--radius);
.faq-hints-card {
border: 1px dashed var(--color-border);
background: var(--color-surface-sunken);
}
.faq-title {
margin: 0;
margin: 0 0 var(--sp-2);
font-weight: 700;
font-size: 0.9rem;
}
.faq-hints details {
font-size: 0.9rem;
}
.faq-hints p {
margin: 0.4rem 0 0;
color: var(--muted);
white-space: pre-wrap;
}
.form-actions {
font-size: var(--fs-sm);
display: flex;
gap: 0.6rem;
flex-wrap: wrap;
align-items: center;
gap: var(--sp-2);
color: var(--color-text);
}
.csat,
.thread {
background: var(--surface);
border: 1px solid var(--line);
border-radius: var(--radius);
padding: 1rem;
.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: 0.4rem;
gap: var(--sp-2);
}
.score {
width: 2.4rem;
height: 2.4rem;
border-radius: 10px;
border: 1px solid var(--line);
background: #fff;
font: inherit;
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(--accent);
background: color-mix(in srgb, var(--accent) 12%, #fff);
font-weight: 700;
border-color: var(--color-primary);
background: var(--color-primary);
color: #fff;
font-weight: 600;
}
.messages {
display: grid;
gap: 0.65rem;
gap: var(--sp-3);
}
.msg {
border: 1px solid var(--line);
border-radius: 12px;
padding: 0.75rem 0.9rem;
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: color-mix(in srgb, var(--accent) 6%, #fff);
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: 0.5rem;
gap: var(--sp-2);
align-items: center;
margin-bottom: 0.35rem;
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);
}
.tag {
font-size: 0.75rem;
color: var(--accent);
font-weight: 700;
.empty-messages {
font-size: var(--fs-sm);
padding: var(--sp-4) 0;
text-align: center;
}
.reply {
.reply-form {
display: grid;
gap: 0.55rem;
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: 0.4rem;
color: inherit;
gap: var(--sp-2);
font-size: var(--fs-sm);
color: var(--color-text-muted);
cursor: pointer;
}
.muted {
color: var(--muted);
}
.error {
color: #b42318;
}
@media (max-width: 640px) {
.grid {
grid-template-columns: 1fr;
}
color: var(--color-text-muted);
}
</style>