Files
CRM_Montzar/apps/web/src/views/TicketsView.vue
T

358 lines
12 KiB
Vue
Raw Normal View History

<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';
2026-10-13 18:04:53 +03:30
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 '';
2026-10-13 18:04:53 +03:30
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>