Preserve the local Ant UI work across views/components and add the new home dashboard chart panels before merging latest main.
358 lines
12 KiB
Vue
358 lines
12 KiB
Vue
<script setup lang="ts">
|
||
import { computed, onMounted, ref, watch } from 'vue';
|
||
import TableColumnsMenu from '@/components/TableColumnsMenu.vue';
|
||
import { useRouter, useRoute } from 'vue-router';
|
||
import axios from 'axios';
|
||
import { api } from '@/lib/api';
|
||
import { formatJalaliDateTime, toFaDigits } from '@/lib/jalali';
|
||
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();
|
||
|
||
const loading = ref(false);
|
||
const error = ref('');
|
||
const data = ref<PaginatedResponse<TicketDto> | null>(null);
|
||
const q = ref(typeof route.query.q === 'string' ? route.query.q : '');
|
||
const status = ref<TicketStatus | 'all'>(
|
||
typeof route.query.status === 'string' &&
|
||
['open', 'in_progress', 'waiting', 'resolved', 'closed'].includes(route.query.status)
|
||
? (route.query.status as TicketStatus)
|
||
: 'all',
|
||
);
|
||
const slaOnly = ref(route.query.sla === 'overdue');
|
||
|
||
const statusLabel: Record<TicketStatus, string> = {
|
||
open: 'باز',
|
||
in_progress: 'در حال رسیدگی',
|
||
waiting: 'در انتظار',
|
||
resolved: 'حلشده',
|
||
closed: 'بسته',
|
||
};
|
||
|
||
const priorityLabel: Record<TicketPriority, string> = {
|
||
low: 'کم',
|
||
normal: 'عادی',
|
||
high: 'بالا',
|
||
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);
|
||
|
||
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 '';
|
||
const d = new Date(iso);
|
||
if (Number.isNaN(d.getTime())) return iso;
|
||
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 = '';
|
||
try {
|
||
const { data: res } = await api.get<PaginatedResponse<TicketDto>>('/tickets', {
|
||
params: {
|
||
q: q.value || undefined,
|
||
status: status.value === 'all' ? undefined : status.value,
|
||
sla: slaOnly.value ? 'overdue' : undefined,
|
||
limit: 50,
|
||
},
|
||
});
|
||
data.value = res;
|
||
} catch (e) {
|
||
if (axios.isAxiosError(e) && !e.response) {
|
||
error.value = 'اتصال به API برقرار نشد.';
|
||
} else {
|
||
error.value = 'بارگذاری تیکتها ناموفق بود.';
|
||
}
|
||
} finally {
|
||
loading.value = false;
|
||
}
|
||
}
|
||
|
||
function onSearch() {
|
||
void router.replace({
|
||
name: 'tickets',
|
||
query: {
|
||
q: q.value.trim() || undefined,
|
||
status: status.value === 'all' ? undefined : status.value,
|
||
sla: slaOnly.value ? 'overdue' : undefined,
|
||
},
|
||
});
|
||
}
|
||
|
||
function onStatusChange(value: unknown) {
|
||
status.value = (value == null ? 'all' : String(value)) as TicketStatus | 'all';
|
||
onSearch();
|
||
}
|
||
|
||
function toggleSla() {
|
||
slaOnly.value = !slaOnly.value;
|
||
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],
|
||
() => {
|
||
q.value = typeof route.query.q === 'string' ? route.query.q : '';
|
||
status.value =
|
||
typeof route.query.status === 'string' &&
|
||
['open', 'in_progress', 'waiting', 'resolved', 'closed'].includes(route.query.status)
|
||
? (route.query.status as TicketStatus)
|
||
: 'all';
|
||
slaOnly.value = route.query.sla === 'overdue';
|
||
void load();
|
||
},
|
||
);
|
||
|
||
onMounted(load);
|
||
</script>
|
||
|
||
<template>
|
||
<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>
|
||
|
||
<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>
|
||
|
||
<a-alert v-if="error" type="error" show-icon :message="error" />
|
||
|
||
<a-spin :spinning="loading">
|
||
<a-card v-if="listEmpty" :bordered="false">
|
||
<a-empty
|
||
description="هنوز تیکتی ثبت نشده است. اولین درخواست پشتیبانی را ثبت کنید تا پیگیریها اینجا جمع شوند."
|
||
>
|
||
<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>
|