feat: migrate CRM screens to Ant Design Vue and expand home dashboard

Preserve the local Ant UI work across views/components and add the new home dashboard chart panels before merging latest main.
This commit is contained in:
2026-11-03 16:51:51 +03:30
parent 631a3267d1
commit 26792f9a37
80 changed files with 19982 additions and 24020 deletions
+238 -588
View File
@@ -1,22 +1,51 @@
<script setup lang="ts">
import { computed, onMounted, ref, watch } from 'vue';
import TableColumnsMenu from '@/components/TableColumnsMenu.vue';
import { RouterLink, useRoute, useRouter } from 'vue-router';
import { useRouter, useRoute } from 'vue-router';
import axios from 'axios';
import { api } from '@/lib/api';
import { formatJalaliDateTime, toFaDigits } from '@/lib/jalali';
import {
UiButton,
UiPageHeader,
UiEmptyState,
} from '@/components/ui';
import UiIcon from '@/components/UiIcon.vue';
import { registerTableColumns, useTableColumns } from '@/lib/tableColumns';
import type {
PaginatedResponse,
TicketDto,
TicketPriority,
TicketStatus,
} from '@mento/shared';
import {
Alert as AAlert,
Button as AButton,
Card as ACard,
Col as ACol,
Empty as AEmpty,
Flex as AFlex,
Form as AForm,
FormItem as AFormItem,
Input as AInput,
Row as ARow,
Select as ASelect,
Space as ASpace,
Spin as ASpin,
Table as ATable,
Tag as ATag,
Typography,
} from 'ant-design-vue';
import type { TableColumnsType } from 'ant-design-vue';
import { FilterOutlined, PlusOutlined, SearchOutlined } from '@ant-design/icons-vue';
const { Title: ATypographyTitle, Text: ATypographyText, Link: ATypographyLink } = Typography;
const TICKET_COL_LABELS = [
'موضوع',
'شماره',
'وضعیت',
'اولویت',
'مشتری / مسئول',
'SLA',
] as const;
registerTableColumns('tickets', [...TICKET_COL_LABELS]);
const tableCols = useTableColumns(() => 'tickets');
const route = useRoute();
const router = useRouter();
@@ -32,7 +61,6 @@ const status = ref<TicketStatus | 'all'>(
: 'all',
);
const slaOnly = ref(route.query.sla === 'overdue');
const filterOpen = ref(false);
const statusLabel: Record<TicketStatus, string> = {
open: 'باز',
@@ -49,11 +77,28 @@ const priorityLabel: Record<TicketPriority, string> = {
urgent: 'فوری',
};
const statusOptions = [
{ value: 'all', label: 'همه وضعیت‌ها' },
...Object.entries(statusLabel).map(([value, label]) => ({ value, label })),
];
const items = computed(() => data.value?.items ?? []);
const listEmpty = computed(() => !loading.value && !items.value.length);
const statusFilterLabel = computed(() =>
status.value === 'all' ? 'همه وضعیت‌ها' : statusLabel[status.value],
);
function statusTagColor(s: TicketStatus | string) {
if (s === 'open') return 'cyan';
if (s === 'in_progress') return 'orange';
if (s === 'waiting') return 'gold';
if (s === 'resolved' || s === 'closed') return 'success';
return 'default';
}
function priorityTagColor(p: TicketPriority | string) {
if (p === 'low') return 'default';
if (p === 'normal') return 'cyan';
if (p === 'high' || p === 'urgent') return 'error';
return 'default';
}
function formatSla(iso: string | null) {
if (!iso) return '';
@@ -62,10 +107,41 @@ function formatSla(iso: string | null) {
return formatJalaliDateTime(d);
}
function personAssigneeText(ticket: TicketDto) {
const parts: string[] = [];
if (ticket.person) parts.push(ticket.person.fullName);
if (ticket.assignee) parts.push(ticket.assignee.fullName);
return parts.length ? parts.join(' · ') : '—';
}
function slaText(ticket: TicketDto) {
if (ticket.slaBreached) return { text: 'تأخیر SLA', color: 'error' as const };
if (ticket.firstRespondedAt) return { text: 'پاسخ داده شد', color: 'success' as const };
if (ticket.slaDueAt) return { text: `مهلت ${formatSla(ticket.slaDueAt)}`, color: 'processing' as const };
return { text: '—', color: 'default' as const };
}
const columns = computed<TableColumnsType<TicketDto>>(() => {
const all: TableColumnsType<TicketDto> = [
{ title: 'موضوع', key: 'موضوع' },
{ title: 'شماره', key: 'شماره', dataIndex: 'number' },
{ title: 'وضعیت', key: 'وضعیت' },
{ title: 'اولویت', key: 'اولویت' },
{ title: 'مشتری / مسئول', key: 'مشتری / مسئول' },
{ title: 'SLA', key: 'SLA' },
];
return all.filter((col) => {
const key = String(col.key ?? '');
if (!TICKET_COL_LABELS.includes(key as (typeof TICKET_COL_LABELS)[number])) {
return true;
}
return tableCols.isVisible(key);
});
});
async function load() {
loading.value = true;
error.value = '';
filterOpen.value = false;
try {
const { data: res } = await api.get<PaginatedResponse<TicketDto>>('/tickets', {
params: {
@@ -98,9 +174,8 @@ function onSearch() {
});
}
function setStatus(next: TicketStatus | 'all') {
status.value = next;
filterOpen.value = false;
function onStatusChange(value: unknown) {
status.value = (value == null ? 'all' : String(value)) as TicketStatus | 'all';
onSearch();
}
@@ -109,6 +184,14 @@ function toggleSla() {
onSearch();
}
function goNew() {
void router.push({ name: 'ticket-new' });
}
function goDetail(id: string) {
void router.push({ name: 'ticket-detail', params: { id } });
}
watch(
() => [route.query.q, route.query.status, route.query.sla],
() => {
@@ -127,581 +210,148 @@ onMounted(load);
</script>
<template>
<section class="page" @click="filterOpen = false">
<UiPageHeader
title="تیکت‌ها"
lead="درخواست‌های پشتیبانی را ثبت و پیگیری کنید."
>
<template #actions>
<RouterLink :to="{ name: 'ticket-new' }">
<UiButton variant="primary">
<UiIcon name="plus" :size="16" />
تیکت جدید
</UiButton>
</RouterLink>
</template>
</UiPageHeader>
<a-flex vertical :gap="16">
<a-flex justify="space-between" align="flex-start" wrap="wrap" :gap="12">
<a-flex vertical :gap="4">
<a-typography-title :level="3" :style="{ margin: 0 }">تیکت‌ها</a-typography-title>
<a-typography-text type="secondary">
درخواست‌های پشتیبانی را ثبت و پیگیری کنید.
</a-typography-text>
</a-flex>
<a-button type="primary" @click="goNew">
<template #icon><PlusOutlined /></template>
تیکت جدید
</a-button>
</a-flex>
<div class="tools">
<form class="search" @submit.prevent="onSearch">
<UiIcon name="search" :size="18" />
<input v-model="q" type="search" placeholder="جستجو در موضوع یا شماره…" />
</form>
<button
class="btn ghost sla-btn"
type="button"
:class="{ on: slaOnly }"
@click="toggleSla"
>
فقط SLA
</button>
<div class="filter-wrap" @click.stop>
<button class="btn ghost filter-btn" type="button" @click="filterOpen = !filterOpen">
<UiIcon name="filter" :size="16" />
<span class="btn-full">{{ statusFilterLabel }}</span>
<span class="btn-short">وضعیت</span>
<UiIcon name="chevron" :size="14" class="chev" />
</button>
<div v-if="filterOpen" class="popover">
<button type="button" :class="{ on: status === 'all' }" @click="setStatus('all')">
همه وضعیت‌ها
</button>
<button
v-for="(label, key) in statusLabel"
:key="key"
type="button"
:class="{ on: status === key }"
@click="setStatus(key as TicketStatus)"
>
{{ label }}
</button>
</div>
</div>
</div>
<a-card :bordered="false">
<a-form layout="vertical" @finish="onSearch">
<a-row :gutter="[16, 0]" align="bottom">
<a-col :xs="24" :md="12" :lg="10">
<a-form-item label="جستجو">
<a-input
v-model:value="q"
allow-clear
placeholder="جستجو در موضوع یا شماره…"
style="width: 100%"
@pressEnter="onSearch"
>
<template #prefix><SearchOutlined /></template>
</a-input>
</a-form-item>
</a-col>
<a-col :xs="24" :sm="12" :md="8" :lg="6">
<a-form-item label="وضعیت">
<a-select
:value="status"
:options="statusOptions"
style="width: 100%"
@update:value="onStatusChange"
>
<template #suffixIcon><FilterOutlined /></template>
</a-select>
</a-form-item>
</a-col>
<a-col :xs="24" :sm="12" :md="8" :lg="8">
<a-form-item label=" ">
<a-space :size="8" wrap>
<a-button :type="slaOnly ? 'primary' : 'default'" @click="toggleSla">
فقط SLA
</a-button>
<a-button type="primary" html-type="submit">اعمال</a-button>
</a-space>
</a-form-item>
</a-col>
</a-row>
</a-form>
</a-card>
<p v-if="error" class="error">{{ error }}</p>
<p v-else-if="loading" class="muted">در حال بارگذاری…</p>
<a-alert v-if="error" type="error" show-icon :message="error" />
<UiEmptyState
v-else-if="listEmpty"
title="هنوز تیکتی ثبت نشده است"
description="اولین درخواست پشتیبانی را ثبت کنید تا پیگیری‌ها اینجا جمع شوند."
>
<template #action>
<RouterLink :to="{ name: 'ticket-new' }">
<UiButton variant="primary">
<UiIcon name="plus" :size="16" />
تیکت جدید
</UiButton>
</RouterLink>
</template>
</UiEmptyState>
<template v-else>
<div class="list-head">
<strong>{{ toFaDigits(items.length) }} تیکت</strong>
</div>
<div class="desktop-only">
<div class="tc-row">
<TableColumnsMenu table-key="tickets" align="end" />
</div>
</div>
<div class="table-wrap desktop-only">
<table v-table-cols="'tickets'" class="data-table">
<thead>
<tr>
<th>موضوع</th>
<th>شماره</th>
<th>وضعیت</th>
<th>اولویت</th>
<th>مشتری / مسئول</th>
<th>SLA</th>
</tr>
</thead>
<tbody>
<tr v-for="ticket in items" :key="ticket.id" :class="{ breached: ticket.slaBreached }">
<td>
<RouterLink class="title" :to="{ name: 'ticket-detail', params: { id: ticket.id } }">
{{ ticket.subject }}
</RouterLink>
</td>
<td class="muted">{{ ticket.number }}</td>
<td>
<span class="badge" :class="ticket.status">
{{ statusLabel[ticket.status as TicketStatus] || ticket.status }}
</span>
</td>
<td>
<span class="prio" :class="ticket.priority">
{{ priorityLabel[ticket.priority as TicketPriority] || ticket.priority }}
</span>
</td>
<td class="muted">
<span v-if="ticket.person">{{ ticket.person.fullName }}</span>
<span v-if="ticket.person && ticket.assignee"> · </span>
<span v-if="ticket.assignee">{{ ticket.assignee.fullName }}</span>
<span v-if="!ticket.person && !ticket.assignee">—</span>
</td>
<td>
<span v-if="ticket.slaBreached" class="sla bad">تأخیر SLA</span>
<span v-else-if="ticket.firstRespondedAt" class="sla ok">پاسخ داده شد</span>
<span v-else-if="ticket.slaDueAt" class="sla">مهلت {{ formatSla(ticket.slaDueAt) }}</span>
<span v-else class="muted">—</span>
</td>
</tr>
</tbody>
</table>
</div>
<div class="cards mobile-only">
<RouterLink
v-for="ticket in items"
:key="ticket.id"
class="m-card"
:class="{ breached: ticket.slaBreached }"
:to="{ name: 'ticket-detail', params: { id: ticket.id } }"
<a-spin :spinning="loading">
<a-card v-if="listEmpty" :bordered="false">
<a-empty
description="هنوز تیکتی ثبت نشده است. اولین درخواست پشتیبانی را ثبت کنید تا پیگیری‌ها اینجا جمع شوند."
>
<div class="m-top">
<strong>{{ ticket.subject }}</strong>
<span class="badge" :class="ticket.status">
{{ statusLabel[ticket.status as TicketStatus] || ticket.status }}
</span>
</div>
<p class="muted">
{{ ticket.number }} ·
{{ priorityLabel[ticket.priority as TicketPriority] || ticket.priority }}
</p>
<p v-if="ticket.person || ticket.assignee" class="meta">
<span v-if="ticket.person">{{ ticket.person.fullName }}</span>
<span v-if="ticket.assignee"> · {{ ticket.assignee.fullName }}</span>
</p>
<div class="m-foot">
<span v-if="ticket.slaBreached" class="sla bad">تأخیر SLA</span>
<span v-else-if="ticket.firstRespondedAt" class="sla ok">پاسخ داده شد</span>
<span v-else-if="ticket.slaDueAt" class="sla">مهلت {{ formatSla(ticket.slaDueAt) }}</span>
</div>
</RouterLink>
</div>
</template>
</section>
<a-button type="primary" @click="goNew">
<template #icon><PlusOutlined /></template>
تیکت جدید
</a-button>
</a-empty>
</a-card>
<a-card v-else-if="!error" :bordered="false">
<a-flex justify="space-between" align="center" :gap="12" :style="{ marginBottom: '12px' }">
<a-typography-text strong>
{{ toFaDigits(items.length) }} تیکت
</a-typography-text>
<TableColumnsMenu table-key="tickets" align="end" />
</a-flex>
<a-table
:columns="columns"
:data-source="items"
:pagination="false"
row-key="id"
:scroll="{ x: 720 }"
:custom-row="(record: TicketDto) => ({
style: record.slaBreached
? { background: 'var(--ant-color-error-bg)' }
: undefined,
onClick: () => goDetail(record.id),
})"
>
<template #bodyCell="{ column, record }">
<template v-if="column.key === 'موضوع'">
<a-typography-link @click.stop="goDetail((record as TicketDto).id)">
{{ (record as TicketDto).subject }}
</a-typography-link>
</template>
<template v-else-if="column.key === 'شماره'">
<a-typography-text type="secondary">
{{ (record as TicketDto).number }}
</a-typography-text>
</template>
<template v-else-if="column.key === 'وضعیت'">
<a-tag
:color="statusTagColor((record as TicketDto).status)"
:bordered="false"
>
{{
statusLabel[(record as TicketDto).status as TicketStatus] ||
(record as TicketDto).status
}}
</a-tag>
</template>
<template v-else-if="column.key === 'اولویت'">
<a-tag
:color="priorityTagColor((record as TicketDto).priority)"
:bordered="false"
>
{{
priorityLabel[(record as TicketDto).priority as TicketPriority] ||
(record as TicketDto).priority
}}
</a-tag>
</template>
<template v-else-if="column.key === 'مشتری / مسئول'">
<a-typography-text type="secondary">
{{ personAssigneeText(record as TicketDto) }}
</a-typography-text>
</template>
<template v-else-if="column.key === 'SLA'">
<a-tag
v-if="slaText(record as TicketDto).text !== '—'"
:color="slaText(record as TicketDto).color"
:bordered="false"
>
{{ slaText(record as TicketDto).text }}
</a-tag>
<a-typography-text v-else type="secondary">—</a-typography-text>
</template>
</template>
</a-table>
</a-card>
</a-spin>
</a-flex>
</template>
<style scoped>
.page {
display: grid;
gap: 1rem;
min-width: 0;
overflow-x: clip;
}
.page-head {
display: flex;
justify-content: space-between;
gap: 1rem;
align-items: flex-start;
}
.head-copy {
min-width: 0;
}
h1 {
margin: 0 0 0.3rem;
font-size: clamp(1.45rem, 2.5vw, 1.85rem);
font-weight: 600;
}
.lead,
.muted,
.error,
.meta {
margin: 0;
}
.lead,
.muted,
.meta {
color: #64748b;
}
.error {
color: #b91c1c;
}
.add-btn {
width: var(--control-h);
height: var(--control-h);
border-radius: var(--radius);
background: #14b8a6;
color: #fff;
display: grid;
place-items: center;
text-decoration: none;
font-size: 1.6rem;
font-weight: 700;
box-shadow: 0 8px 18px rgba(20, 184, 166, 0.28);
flex-shrink: 0;
}
.tools {
display: flex;
gap: 0.55rem;
align-items: center;
}
.search {
flex: 1 1 200px;
min-width: 180px;
max-width: 400px;
height: var(--control-h);
min-height: var(--control-h);
display: flex;
align-items: center;
gap: 0.5rem;
background: #fff;
border: 1px solid var(--line);
border-radius: var(--radius);
padding: 0 0.75rem;
color: var(--muted);
box-sizing: border-box;
transition: border-color var(--transition-fast), box-shadow var(--transition-fast);
}
.search:focus-within {
border-color: var(--accent);
box-shadow: 0 0 0 2px var(--accent-soft);
}
.search input {
flex: 1;
min-width: 0;
border: 0;
outline: none;
background: none;
padding: 0;
font: inherit;
font-size: var(--fs-sm);
color: var(--ink);
}
.filter-wrap {
position: relative;
flex-shrink: 0;
}
.btn {
display: inline-flex;
align-items: center;
justify-content: center;
gap: 0.35rem;
height: var(--control-h);
min-height: var(--control-h);
padding: 0 0.85rem;
border-radius: var(--radius);
font-weight: 600;
font-size: var(--fs-sm);
border: 1px solid transparent;
cursor: pointer;
font: inherit;
text-decoration: none;
box-sizing: border-box;
transition: background var(--transition-fast), border-color var(--transition-fast);
}
.btn.primary {
background: var(--accent);
color: #fff;
}
.btn.ghost {
background: #fff;
color: var(--ink-secondary);
border-color: var(--line);
}
.btn.ghost:hover {
background: var(--surface-subtle);
border-color: var(--line-strong);
}
.btn.ghost.on {
background: var(--accent-subtle);
border-color: var(--accent);
color: var(--accent-deep);
}
.btn.wide {
width: min(100%, 320px);
}
.sla-btn {
flex-shrink: 0;
white-space: nowrap;
}
.filter-btn .chev {
transform: rotate(90deg);
}
.btn-short {
display: none;
}
.popover {
position: absolute;
top: calc(100% + 6px);
inset-inline-end: 0;
min-width: 11rem;
background: #fff;
border-radius: var(--radius);
box-shadow: 0 16px 40px rgba(15, 23, 42, 0.14);
padding: 0.35rem;
display: grid;
z-index: 8;
}
.popover button {
display: block;
width: 100%;
text-align: start;
border: 0;
background: none;
padding: 0.6rem 0.7rem;
border-radius: var(--radius);
font: inherit;
cursor: pointer;
color: #0f172a;
}
.popover button:hover,
.popover button.on {
background: #f0fdfa;
color: #0f766e;
}
.empty-card {
background: #fff;
border-radius: var(--radius);
box-shadow: 0 10px 28px rgba(15, 23, 42, 0.05);
padding: 2.4rem 1.2rem 2rem;
display: grid;
justify-items: center;
text-align: center;
gap: 0.7rem;
}
.art svg {
width: 148px;
height: auto;
display: block;
}
.empty-card h2 {
margin: 0.2rem 0 0;
font-size: 1.15rem;
}
.empty-card > p {
max-width: 22rem;
color: #94a3b8;
line-height: 1.8;
}
.list-head {
color: #0f766e;
}
.table-wrap {
background: #fff;
border-radius: var(--radius);
box-shadow: 0 8px 24px rgba(15, 23, 42, 0.05);
overflow: auto;
}
.data-table {
width: 100%;
border-collapse: collapse;
min-width: 720px;
}
.data-table th,
.data-table td {
padding: 0.9rem 1rem;
text-align: start;
border-bottom: 1px solid #f1f5f9;
}
.data-table th {
font-size: 0.78rem;
color: #94a3b8;
font-weight: 700;
background: #f8fafc;
}
.data-table tr.breached td {
background: color-mix(in srgb, #fef2f2 55%, #fff);
}
.title {
color: #0f172a;
font-weight: 700;
text-decoration: none;
}
.title:hover {
color: #0f766e;
}
.badge,
.prio {
display: inline-flex;
align-items: center;
padding: 0.25rem 0.55rem;
border-radius: 999px;
font-size: 0.75rem;
font-weight: 700;
background: #f1f5f9;
color: #475569;
white-space: nowrap;
}
.badge.open,
.prio.normal {
background: #ecfeff;
color: #0e7490;
}
.badge.in_progress {
background: #fff7ed;
color: #c2410c;
}
.badge.waiting {
background: #fefce8;
color: #a16207;
}
.badge.resolved,
.badge.closed {
background: #ecfdf5;
color: #047857;
}
.prio.low {
background: #f1f5f9;
color: #64748b;
}
.prio.high,
.prio.urgent {
background: #fef2f2;
color: #b91c1c;
}
.sla {
font-size: 0.8rem;
font-weight: 700;
color: #0f766e;
}
.sla.bad {
color: #b91c1c;
}
.sla.ok {
color: #047857;
}
.desktop-only {
display: block;
}
.mobile-only {
display: none;
}
.cards {
display: grid;
gap: 0.65rem;
}
.m-card {
background: #fff;
border-radius: var(--radius);
padding: 0.95rem 1rem;
box-shadow: 0 6px 18px rgba(15, 23, 42, 0.05);
text-decoration: none;
color: inherit;
display: grid;
gap: 0.35rem;
min-width: 0;
}
.m-card.breached {
box-shadow: 0 0 0 1px color-mix(in srgb, #b91c1c 25%, transparent),
0 6px 18px rgba(15, 23, 42, 0.05);
}
.m-top {
display: flex;
justify-content: space-between;
gap: 0.6rem;
align-items: flex-start;
}
.m-top strong {
min-width: 0;
overflow-wrap: anywhere;
}
.m-foot {
margin-top: 0.25rem;
}
@media (max-width: 900px) {
.page-head {
align-items: center;
}
.tools {
flex-wrap: wrap;
}
.search {
flex: 1 1 100%;
}
.btn-full {
display: none;
}
.btn-short {
display: inline;
}
.desktop-only {
display: none;
}
.mobile-only {
display: grid;
}
}
@media (max-width: 520px) {
.add-btn {
width: var(--control-h);
height: var(--control-h);
}
.sla-btn {
font-size: 0.85rem;
padding-inline: 0.7rem;
}
.m-card {
padding: 0.85rem;
}
}
</style>