Files
CRM_Montzar/apps/web/src/views/ReservationsView.vue
T
Mahdi 26792f9a37 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.
2026-11-03 16:51:51 +03:30

682 lines
22 KiB
Vue
Raw Blame History

This file contains invisible Unicode characters
This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<script setup lang="ts">
import { computed, onMounted, ref, watch } from 'vue';
import axios from 'axios';
import { api } from '@/lib/api';
import type {
OrgUserDto,
PaginatedResponse,
PersonDto,
ReservationAppointmentDto,
ReservationListResponse,
ReservationServiceDto,
ReservationSlotDto,
ReservationStatus,
} from '@mento/shared';
import {
RESERVATION_STATUS_LABELS,
RESERVATION_STATUSES,
} from '@mento/shared';
import JalaliDateInput from '@/components/JalaliDateInput.vue';
import {
formatClockFa,
formatJalaliNumeric,
formatLocalDateInput,
toFaDigits,
} 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';
const auth = useAuthStore();
const canEditServices = computed(() => canManageSettings(auth.user?.role));
const tab = ref<TabKey>('appointments');
const loading = ref(false);
const saving = ref(false);
const error = ref('');
const ok = ref('');
const appointments = ref<ReservationAppointmentDto[]>([]);
const services = ref<ReservationServiceDto[]>([]);
const people = ref<PersonDto[]>([]);
const users = ref<OrgUserDto[]>([]);
const slots = ref<ReservationSlotDto[]>([]);
const statusFilter = ref('');
const formOpen = ref(false);
const formServiceId = ref('');
const formPersonId = ref('');
const formAssigneeId = ref('');
const formDate = ref(formatLocalDateInput(new Date()));
const formStartsAt = ref('');
const formStatus = ref<ReservationStatus>('pending');
const formNotes = ref('');
const serviceFormOpen = ref(false);
const serviceEditId = ref<string | null>(null);
const serviceName = ref('');
const serviceDesc = ref('');
const serviceDuration = ref(30);
const serviceStart = ref(9);
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 = '';
try {
const [aptRes, svcRes, peopleRes, usersRes] = await Promise.all([
api.get<ReservationListResponse>('/reservations', {
params: {
limit: 50,
...(statusFilter.value ? { status: statusFilter.value } : {}),
},
}),
api.get<ReservationServiceDto[]>('/reservations/services'),
api.get<PaginatedResponse<PersonDto>>('/people', {
params: { page: 1, limit: 100 },
}),
api.get<OrgUserDto[]>('/users'),
]);
appointments.value = aptRes.data.items;
services.value = svcRes.data;
people.value = peopleRes.data.items;
users.value = usersRes.data.filter((u) => u.isActive !== false);
} catch (e) {
if (axios.isAxiosError(e) && e.response?.status === 403) {
error.value = 'ماژول رزرو و نوبت‌دهی برای این محیط فعال نیست.';
} else {
error.value = 'بارگذاری ناموفق بود.';
}
} finally {
loading.value = false;
}
}
async function loadSlots() {
slots.value = [];
formStartsAt.value = '';
if (!formServiceId.value || !formDate.value) return;
try {
const { data } = await api.get<ReservationSlotDto[]>('/reservations/slots', {
params: {
serviceId: formServiceId.value,
date: new Date(formDate.value + 'T12:00:00').toISOString(),
...(formAssigneeId.value ? { assigneeId: formAssigneeId.value } : {}),
},
});
slots.value = data;
} catch {
slots.value = [];
}
}
watch([formServiceId, formDate, formAssigneeId], () => {
if (formOpen.value) void loadSlots();
});
function openCreate() {
formServiceId.value = services.value.find((s) => s.isActive)?.id ?? '';
formPersonId.value = '';
formAssigneeId.value = auth.user?.id ?? '';
formDate.value = formatLocalDateInput(new Date());
formStartsAt.value = '';
formStatus.value = 'pending';
formNotes.value = '';
formOpen.value = true;
void loadSlots();
}
async function saveAppointment() {
if (!formServiceId.value || !formPersonId.value || !formStartsAt.value) {
error.value = 'خدمت، مشتری و ساعت نوبت را انتخاب کنید.';
return;
}
saving.value = true;
error.value = '';
try {
await api.post('/reservations', {
serviceId: formServiceId.value,
personId: formPersonId.value,
assigneeId: formAssigneeId.value || null,
startsAt: formStartsAt.value,
status: formStatus.value,
notes: formNotes.value.trim() || null,
});
formOpen.value = false;
ok.value = 'نوبت ثبت شد.';
await load();
} catch (e) {
error.value = axios.isAxiosError(e)
? String((e.response?.data as { message?: string })?.message ?? 'ثبت نوبت ناموفق بود.')
: 'ثبت نوبت ناموفق بود.';
} finally {
saving.value = false;
}
}
async function setStatus(id: string, status: ReservationStatus) {
try {
await api.patch(`/reservations/${id}`, { status });
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() {
serviceEditId.value = null;
serviceName.value = '';
serviceDesc.value = '';
serviceDuration.value = 30;
serviceStart.value = 9;
serviceEnd.value = 17;
serviceFormOpen.value = true;
}
function openServiceEdit(s: ReservationServiceDto) {
serviceEditId.value = s.id;
serviceName.value = s.name;
serviceDesc.value = s.description;
serviceDuration.value = s.durationMinutes;
serviceStart.value = s.dayStartHour;
serviceEnd.value = s.dayEndHour;
serviceFormOpen.value = true;
}
async function saveService() {
if (!serviceName.value.trim()) {
error.value = 'نام خدمت را وارد کنید.';
return;
}
saving.value = true;
try {
const payload = {
name: serviceName.value.trim(),
description: serviceDesc.value.trim(),
durationMinutes: Number(serviceDuration.value),
dayStartHour: Number(serviceStart.value),
dayEndHour: Number(serviceEnd.value),
workDays: [0, 1, 2, 3, 4, 5, 6],
isActive: true,
};
if (serviceEditId.value) {
await api.patch(`/reservations/services/${serviceEditId.value}`, payload);
} else {
await api.post('/reservations/services', payload);
}
serviceFormOpen.value = false;
ok.value = 'خدمت ذخیره شد.';
await load();
} catch (e) {
error.value = axios.isAxiosError(e)
? String((e.response?.data as { message?: string })?.message ?? 'ذخیره خدمت ناموفق بود.')
: 'ذخیره خدمت ناموفق بود.';
} finally {
saving.value = false;
}
}
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>
<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>
بروزرسانی
</a-button>
<a-button v-if="tab === 'appointments'" type="primary" @click="openCreate">
<template #icon><PlusOutlined /></template>
نوبت جدید
</a-button>
<a-button
v-else-if="canEditServices"
type="primary"
@click="openServiceCreate"
>
<template #icon><PlusOutlined /></template>
خدمت جدید
</a-button>
</a-space>
</a-flex>
<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>
<a-alert
v-if="error"
type="error"
show-icon
:message="error"
closable
:style="{ marginBottom: '12px' }"
@close="error = ''"
/>
<a-alert
v-if="ok"
type="success"
show-icon
:message="ok"
closable
:style="{ marginBottom: '12px' }"
@close="ok = ''"
/>
<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>
<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 ? 'ویرایش خدمت' : 'خدمت جدید'"
destroy-on-close
:footer="null"
width="560px"
>
<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>