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
+431 -296
View File
@@ -16,16 +16,6 @@ import {
RESERVATION_STATUS_LABELS,
RESERVATION_STATUSES,
} from '@mento/shared';
import {
UiButton,
UiEmptyState,
UiField,
UiInput,
UiPageHeader,
UiModal,
UiTextarea,
} from '@/components/ui';
import UiIcon from '@/components/UiIcon.vue';
import JalaliDateInput from '@/components/JalaliDateInput.vue';
import {
formatClockFa,
@@ -35,6 +25,35 @@ import {
} from '@/lib/jalali';
import { canManageSettings } from '@/lib/roles';
import { useAuthStore } from '@/stores/auth';
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,
InputNumber as AInputNumber,
List as AList,
Modal,
Modal as AModal,
Row as ARow,
Select as ASelect,
Space as ASpace,
Spin as ASpin,
TabPane as ATabPane,
Tabs as ATabs,
Textarea as ATextarea,
Typography,
} from 'ant-design-vue';
import { PlusOutlined, ReloadOutlined } from '@ant-design/icons-vue';
const { Title: ATypographyTitle, Text: ATypographyText, Paragraph: ATypographyParagraph } =
Typography;
const AListItem = AList.Item;
const AListItemMeta = AList.Item.Meta;
type TabKey = 'appointments' | 'services';
@@ -73,6 +92,46 @@ const serviceEnd = ref(17);
const dayNames = ['یکشنبه', 'دوشنبه', 'سه‌شنبه', 'چهارشنبه', 'پنجشنبه', 'جمعه', 'شنبه'];
const statusFilterOptions = computed(() => [
{ value: '', label: 'همه' },
...RESERVATION_STATUSES.map((s) => ({
value: s,
label: RESERVATION_STATUS_LABELS[s],
})),
]);
const statusOptions = computed(() =>
RESERVATION_STATUSES.map((s) => ({
value: s,
label: RESERVATION_STATUS_LABELS[s],
})),
);
const activeServiceOptions = computed(() =>
services.value
.filter((x) => x.isActive)
.map((s) => ({
value: s.id,
label: `${s.name} (${toFaDigits(s.durationMinutes)} دقیقه)`,
})),
);
const personOptions = computed(() =>
people.value.map((p) => ({ value: p.id, label: p.fullName })),
);
const userOptions = computed(() => [
{ value: '', label: '—' },
...users.value.map((u) => ({ value: u.id, label: u.fullName })),
]);
const slotOptions = computed(() =>
slots.value.map((sl) => ({
value: sl.startsAt,
label: slotLabel(sl.startsAt),
})),
);
async function load() {
loading.value = true;
error.value = '';
@@ -176,14 +235,22 @@ async function setStatus(id: string, status: ReservationStatus) {
}
}
async function removeAppointment(id: string) {
if (!confirm('این نوبت حذف شود؟')) return;
try {
await api.delete(`/reservations/${id}`);
await load();
} catch {
error.value = 'حذف نوبت ناموفق بود.';
}
function removeAppointment(id: string) {
Modal.confirm({
title: 'حذف نوبت',
content: 'این نوبت حذف شود؟',
okText: 'حذف',
okType: 'danger',
cancelText: 'انصراف',
onOk: async () => {
try {
await api.delete(`/reservations/${id}`);
await load();
} catch {
error.value = 'حذف نوبت ناموفق بود.';
}
},
});
}
function openServiceCreate() {
@@ -239,308 +306,376 @@ async function saveService() {
}
}
async function removeService(id: string) {
if (!confirm('این خدمت حذف شود؟')) return;
try {
await api.delete(`/reservations/services/${id}`);
await load();
} catch (e) {
error.value = axios.isAxiosError(e)
? String((e.response?.data as { message?: string })?.message ?? 'حذف ناموفق بود.')
: 'حذف ناموفق بود.';
}
function removeService(id: string) {
Modal.confirm({
title: 'حذف خدمت',
content: 'این خدمت حذف شود؟',
okText: 'حذف',
okType: 'danger',
cancelText: 'انصراف',
onOk: async () => {
try {
await api.delete(`/reservations/services/${id}`);
await load();
} catch (e) {
error.value = axios.isAxiosError(e)
? String((e.response?.data as { message?: string })?.message ?? 'حذف ناموفق بود.')
: 'حذف ناموفق بود.';
}
},
});
}
function slotLabel(iso: string) {
return formatClockFa(new Date(iso));
}
function appointmentDescription(a: ReservationAppointmentDto) {
const parts = [
formatJalaliNumeric(new Date(a.startsAt)),
`${formatClockFa(new Date(a.startsAt))} تا ${formatClockFa(new Date(a.endsAt))}`,
RESERVATION_STATUS_LABELS[a.status],
];
if (a.assigneeName) parts.push(a.assigneeName);
return parts.join(' · ');
}
function serviceDescription(s: ReservationServiceDto) {
return [
`مدت ${toFaDigits(s.durationMinutes)} دقیقه`,
`ساعت ${toFaDigits(s.dayStartHour)} تا ${toFaDigits(s.dayEndHour)}`,
s.isActive ? 'فعال' : 'غیرفعال',
].join(' · ');
}
function onTabChange(key: string | number) {
tab.value = String(key) as TabKey;
}
onMounted(() => {
void load();
});
</script>
<template>
<div class="page">
<UiPageHeader
title="رزرو و نوبت‌دهی"
description="تعریف خدمت، انتخاب بازه آزاد و ثبت نوبت برای مشتری"
>
<template #actions>
<UiButton variant="secondary" :disabled="loading" @click="load">
<UiIcon name="refresh" />
<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-space wrap :size="8">
<a-button :disabled="loading" @click="load">
<template #icon><ReloadOutlined /></template>
بروزرسانی
</UiButton>
<UiButton v-if="tab === 'appointments'" @click="openCreate">
</a-button>
<a-button v-if="tab === 'appointments'" type="primary" @click="openCreate">
<template #icon><PlusOutlined /></template>
نوبت جدید
</UiButton>
<UiButton
</a-button>
<a-button
v-else-if="canEditServices"
type="primary"
@click="openServiceCreate"
>
<template #icon><PlusOutlined /></template>
خدمت جدید
</UiButton>
</template>
</UiPageHeader>
</a-button>
</a-space>
</a-flex>
<nav class="tabs">
<button type="button" :class="{ on: tab === 'appointments' }" @click="tab = 'appointments'">
نوبت‌ها
</button>
<button type="button" :class="{ on: tab === 'services' }" @click="tab = 'services'">
خدمات
</button>
</nav>
<a-card :bordered="false">
<a-tabs :active-key="tab" @change="onTabChange">
<a-tab-pane key="appointments" tab="نوبت‌ها" />
<a-tab-pane key="services" tab="خدمات" />
</a-tabs>
<p v-if="error" class="error">{{ error }}</p>
<p v-if="ok" class="ok">{{ ok }}</p>
<template v-if="tab === 'appointments'">
<div class="filters">
<label>
وضعیت
<select v-model="statusFilter" @change="load">
<option value="">همه</option>
<option v-for="s in RESERVATION_STATUSES" :key="s" :value="s">
{{ RESERVATION_STATUS_LABELS[s] }}
</option>
</select>
</label>
</div>
<UiEmptyState
v-if="!loading && !appointments.length"
title="نوبتی نیست"
description="اول خدمت تعریف کنید، بعد نوبت بسازید."
<a-alert
v-if="error"
type="error"
show-icon
:message="error"
closable
:style="{ marginBottom: '12px' }"
@close="error = ''"
/>
<div v-else class="list">
<article v-for="a in appointments" :key="a.id" class="item">
<div>
<strong>{{ a.serviceName }} — {{ a.personName }}</strong>
<p class="muted">
{{ formatJalaliNumeric(new Date(a.startsAt)) }}
· {{ formatClockFa(new Date(a.startsAt)) }} تا {{ formatClockFa(new Date(a.endsAt)) }}
· {{ RESERVATION_STATUS_LABELS[a.status] }}
<template v-if="a.assigneeName"> · {{ a.assigneeName }}</template>
</p>
<p v-if="a.notes" class="muted">{{ a.notes }}</p>
</div>
<div class="actions">
<UiButton
v-if="a.status === 'pending'"
variant="secondary"
@click="setStatus(a.id, 'confirmed')"
>
تأیید
</UiButton>
<UiButton
v-if="a.status === 'confirmed' || a.status === 'pending'"
variant="secondary"
@click="setStatus(a.id, 'done')"
>
انجام شد
</UiButton>
<UiButton
v-if="a.status !== 'canceled' && a.status !== 'done'"
variant="secondary"
@click="setStatus(a.id, 'canceled')"
>
لغو
</UiButton>
<UiButton variant="secondary" @click="removeAppointment(a.id)">حذف</UiButton>
</div>
</article>
</div>
</template>
<template v-else>
<UiEmptyState
v-if="!loading && !services.length"
title="خدمتی نیست"
description="مثلاً مشاوره ۳۰ دقیقه‌ای تعریف کنید."
<a-alert
v-if="ok"
type="success"
show-icon
:message="ok"
closable
:style="{ marginBottom: '12px' }"
@close="ok = ''"
/>
<div v-else class="list">
<article v-for="s in services" :key="s.id" class="item">
<div>
<strong>{{ s.name }}</strong>
<p class="muted">
مدت {{ toFaDigits(s.durationMinutes) }} دقیقه
· ساعت {{ toFaDigits(s.dayStartHour) }} تا {{ toFaDigits(s.dayEndHour) }}
· {{ s.isActive ? 'فعال' : 'غیرفعال' }}
</p>
<p v-if="s.description" class="muted">{{ s.description }}</p>
</div>
<div v-if="canEditServices" class="actions">
<UiButton variant="secondary" @click="openServiceEdit(s)">ویرایش</UiButton>
<UiButton variant="secondary" @click="removeService(s.id)">حذف</UiButton>
</div>
</article>
</div>
</template>
<UiModal :open="formOpen" title="نوبت جدید" @close="formOpen = false">
<div class="form-grid">
<UiField label="خدمت">
<select v-model="formServiceId">
<option disabled value="">انتخاب کنید</option>
<option
v-for="s in services.filter((x) => x.isActive)"
:key="s.id"
:value="s.id"
>
{{ s.name }} ({{ toFaDigits(s.durationMinutes) }} دقیقه)
</option>
</select>
</UiField>
<UiField label="مشتری">
<select v-model="formPersonId">
<option disabled value="">انتخاب کنید</option>
<option v-for="p in people" :key="p.id" :value="p.id">
{{ p.fullName }}
</option>
</select>
</UiField>
<UiField label="کارشناس">
<select v-model="formAssigneeId">
<option value="">—</option>
<option v-for="u in users" :key="u.id" :value="u.id">
{{ u.fullName }}
</option>
</select>
</UiField>
<UiField label="تاریخ">
<JalaliDateInput v-model="formDate" />
</UiField>
<UiField label="ساعت آزاد">
<select v-model="formStartsAt">
<option disabled value="">انتخاب کنید</option>
<option v-for="sl in slots" :key="sl.startsAt" :value="sl.startsAt">
{{ slotLabel(sl.startsAt) }}
</option>
</select>
<p v-if="formServiceId && formDate && !slots.length" class="muted">
برای این تاریخ اسلات آزادی نیست.
</p>
</UiField>
<UiField label="وضعیت">
<select v-model="formStatus">
<option v-for="s in RESERVATION_STATUSES" :key="s" :value="s">
{{ RESERVATION_STATUS_LABELS[s] }}
</option>
</select>
</UiField>
<UiField label="یادداشت">
<UiTextarea v-model="formNotes" :rows="2" />
</UiField>
</div>
<template #footer>
<UiButton variant="secondary" @click="formOpen = false">انصراف</UiButton>
<UiButton :loading="saving" @click="saveAppointment">ثبت نوبت</UiButton>
</template>
</UiModal>
<a-spin :spinning="loading">
<template v-if="tab === 'appointments'">
<a-form layout="vertical">
<a-row :gutter="[16, 0]">
<a-col :xs="24" :sm="12" :md="8" :lg="6">
<a-form-item label="وضعیت">
<a-select
v-model:value="statusFilter"
:options="statusFilterOptions"
style="width: 100%"
@change="load"
/>
</a-form-item>
</a-col>
</a-row>
</a-form>
<UiModal
:open="serviceFormOpen"
<a-empty
v-if="!loading && !appointments.length"
description="نوبتی نیست — اول خدمت تعریف کنید، بعد نوبت بسازید."
/>
<a-list v-else :data-source="appointments" item-layout="horizontal">
<template #renderItem="{ item }">
<a-list-item>
<template #actions>
<a-space wrap :size="8">
<a-button
v-if="(item as ReservationAppointmentDto).status === 'pending'"
@click="setStatus((item as ReservationAppointmentDto).id, 'confirmed')"
>
تأیید
</a-button>
<a-button
v-if="
(item as ReservationAppointmentDto).status === 'confirmed' ||
(item as ReservationAppointmentDto).status === 'pending'
"
@click="setStatus((item as ReservationAppointmentDto).id, 'done')"
>
انجام شد
</a-button>
<a-button
v-if="
(item as ReservationAppointmentDto).status !== 'canceled' &&
(item as ReservationAppointmentDto).status !== 'done'
"
@click="setStatus((item as ReservationAppointmentDto).id, 'canceled')"
>
لغو
</a-button>
<a-button
danger
@click="removeAppointment((item as ReservationAppointmentDto).id)"
>
حذف
</a-button>
</a-space>
</template>
<a-list-item-meta>
<template #title>
{{ (item as ReservationAppointmentDto).serviceName }} —
{{ (item as ReservationAppointmentDto).personName }}
</template>
<template #description>
<a-flex vertical :gap="4">
<a-typography-text type="secondary">
{{ appointmentDescription(item as ReservationAppointmentDto) }}
</a-typography-text>
<a-typography-text
v-if="(item as ReservationAppointmentDto).notes"
type="secondary"
>
{{ (item as ReservationAppointmentDto).notes }}
</a-typography-text>
</a-flex>
</template>
</a-list-item-meta>
</a-list-item>
</template>
</a-list>
</template>
<template v-else>
<a-empty
v-if="!loading && !services.length"
description="خدمتی نیست — مثلاً مشاوره ۳۰ دقیقه‌ای تعریف کنید."
/>
<a-list v-else :data-source="services" item-layout="horizontal">
<template #renderItem="{ item }">
<a-list-item>
<template v-if="canEditServices" #actions>
<a-space wrap :size="8">
<a-button @click="openServiceEdit(item as ReservationServiceDto)">
ویرایش
</a-button>
<a-button danger @click="removeService((item as ReservationServiceDto).id)">
حذف
</a-button>
</a-space>
</template>
<a-list-item-meta>
<template #title>
{{ (item as ReservationServiceDto).name }}
</template>
<template #description>
<a-flex vertical :gap="4">
<a-typography-text type="secondary">
{{ serviceDescription(item as ReservationServiceDto) }}
</a-typography-text>
<a-typography-text
v-if="(item as ReservationServiceDto).description"
type="secondary"
>
{{ (item as ReservationServiceDto).description }}
</a-typography-text>
</a-flex>
</template>
</a-list-item-meta>
</a-list-item>
</template>
</a-list>
</template>
</a-spin>
</a-card>
<a-modal v-model:open="formOpen" title="نوبت جدید" destroy-on-close :footer="null" width="640px">
<a-form layout="vertical">
<a-row :gutter="[16, 0]">
<a-col :xs="24" :md="12">
<a-form-item label="خدمت" required>
<a-select
v-model:value="formServiceId"
:options="activeServiceOptions"
placeholder="انتخاب کنید"
style="width: 100%"
/>
</a-form-item>
</a-col>
<a-col :xs="24" :md="12">
<a-form-item label="مشتری" required>
<a-select
v-model:value="formPersonId"
:options="personOptions"
show-search
option-filter-prop="label"
placeholder="انتخاب کنید"
style="width: 100%"
/>
</a-form-item>
</a-col>
<a-col :xs="24" :md="12">
<a-form-item label="کارشناس">
<a-select
v-model:value="formAssigneeId"
:options="userOptions"
show-search
option-filter-prop="label"
style="width: 100%"
/>
</a-form-item>
</a-col>
<a-col :xs="24" :md="12">
<a-form-item label="تاریخ" required>
<JalaliDateInput v-model="formDate" />
</a-form-item>
</a-col>
<a-col :xs="24" :md="12">
<a-form-item label="ساعت آزاد" required>
<a-select
v-model:value="formStartsAt"
:options="slotOptions"
placeholder="انتخاب کنید"
style="width: 100%"
/>
<a-typography-text
v-if="formServiceId && formDate && !slots.length"
type="secondary"
>
برای این تاریخ اسلات آزادی نیست.
</a-typography-text>
</a-form-item>
</a-col>
<a-col :xs="24" :md="12">
<a-form-item label="وضعیت">
<a-select
v-model:value="formStatus"
:options="statusOptions"
style="width: 100%"
/>
</a-form-item>
</a-col>
<a-col :span="24">
<a-form-item label="یادداشت">
<a-textarea v-model:value="formNotes" :rows="2" style="width: 100%" />
</a-form-item>
</a-col>
</a-row>
</a-form>
<a-flex justify="flex-end" :gap="8">
<a-button :disabled="saving" @click="formOpen = false">انصراف</a-button>
<a-button type="primary" :loading="saving" @click="saveAppointment">ثبت نوبت</a-button>
</a-flex>
</a-modal>
<a-modal
v-model:open="serviceFormOpen"
:title="serviceEditId ? 'ویرایش خدمت' : 'خدمت جدید'"
@close="serviceFormOpen = false"
destroy-on-close
:footer="null"
width="560px"
>
<div class="form-grid">
<UiField label="نام">
<UiInput v-model="serviceName" placeholder="مشاوره حضوری" />
</UiField>
<UiField label="مدت (دقیقه)">
<UiInput v-model.number="serviceDuration" type="number" />
</UiField>
<UiField label="شروع روز (ساعت)">
<UiInput v-model.number="serviceStart" type="number" />
</UiField>
<UiField label="پایان روز (ساعت)">
<UiInput v-model.number="serviceEnd" type="number" />
</UiField>
<UiField label="توضیح">
<UiTextarea v-model="serviceDesc" :rows="2" />
</UiField>
</div>
<p class="muted">روزهای کاری پیش‌فرض: همه روزهای هفته ({{ dayNames.join('، ') }})</p>
<template #footer>
<UiButton variant="secondary" @click="serviceFormOpen = false">انصراف</UiButton>
<UiButton :loading="saving" @click="saveService">ذخیره</UiButton>
</template>
</UiModal>
</div>
<a-form layout="vertical">
<a-row :gutter="[16, 0]">
<a-col :span="24">
<a-form-item label="نام" required>
<a-input
v-model:value="serviceName"
placeholder="مشاوره حضوری"
style="width: 100%"
/>
</a-form-item>
</a-col>
<a-col :xs="24" :md="8">
<a-form-item label="مدت (دقیقه)">
<a-input-number
v-model:value="serviceDuration"
:min="1"
style="width: 100%"
/>
</a-form-item>
</a-col>
<a-col :xs="24" :md="8">
<a-form-item label="شروع روز (ساعت)">
<a-input-number
v-model:value="serviceStart"
:min="0"
:max="23"
style="width: 100%"
/>
</a-form-item>
</a-col>
<a-col :xs="24" :md="8">
<a-form-item label="پایان روز (ساعت)">
<a-input-number
v-model:value="serviceEnd"
:min="0"
:max="24"
style="width: 100%"
/>
</a-form-item>
</a-col>
<a-col :span="24">
<a-form-item label="توضیح">
<a-textarea v-model:value="serviceDesc" :rows="2" style="width: 100%" />
</a-form-item>
</a-col>
</a-row>
</a-form>
<a-typography-paragraph type="secondary">
روزهای کاری پیش‌فرض: همه روزهای هفته ({{ dayNames.join('، ') }})
</a-typography-paragraph>
<a-flex justify="flex-end" :gap="8">
<a-button :disabled="saving" @click="serviceFormOpen = false">انصراف</a-button>
<a-button type="primary" :loading="saving" @click="saveService">ذخیره</a-button>
</a-flex>
</a-modal>
</a-flex>
</template>
<style scoped>
.page {
display: grid;
gap: 1rem;
}
.tabs {
display: flex;
gap: 0.35rem;
}
.tabs button {
border: 1px solid #e2e8f0;
background: #fff;
border-radius: 999px;
padding: 0.4rem 0.9rem;
font: inherit;
cursor: pointer;
}
.tabs button.on {
background: #f0fdfa;
border-color: #99f6e4;
color: #0f766e;
font-weight: 700;
}
.filters select,
.form-grid select {
font: inherit;
border: 1px solid #e2e8f0;
border-radius: 8px;
padding: 0.45rem 0.6rem;
width: 100%;
background: #fff;
}
.list {
display: grid;
gap: 0.75rem;
}
.item {
display: flex;
justify-content: space-between;
gap: 0.75rem;
padding: 0.85rem;
border: 1px solid #eef2f7;
border-radius: var(--radius);
background: #fff;
}
.actions {
display: flex;
gap: 0.35rem;
flex-wrap: wrap;
align-items: start;
}
.form-grid {
display: grid;
gap: 0.75rem;
}
.muted {
color: #64748b;
font-size: 0.85rem;
margin: 0.25rem 0 0;
}
.error {
color: #b91c1c;
font-weight: 600;
}
.ok {
color: #0f766e;
font-weight: 600;
}
@media (max-width: 800px) {
.item {
flex-direction: column;
}
}
</style>