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

547 lines
16 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 {
UiButton,
UiEmptyState,
UiField,
UiInput,
UiPageHeader,
UiModal,
UiTextarea,
} from '@/components/ui';
import UiIcon from '@/components/UiIcon.vue';
import JalaliDateInput from '@/components/JalaliDateInput.vue';
import {
formatClockFa,
formatJalaliNumeric,
formatLocalDateInput,
toFaDigits,
} from '@/lib/jalali';
import { canManageSettings } from '@/lib/roles';
import { useAuthStore } from '@/stores/auth';
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 = ['یکشنبه', 'دوشنبه', 'سه‌شنبه', 'چهارشنبه', 'پنجشنبه', 'جمعه', 'شنبه'];
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 = 'تغییر وضعیت ناموفق بود.';
}
}
async function removeAppointment(id: string) {
if (!confirm('این نوبت حذف شود؟')) return;
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;
}
}
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 slotLabel(iso: string) {
return formatClockFa(new Date(iso));
}
onMounted(() => {
void load();
});
</script>
<template>
<div class="page">
<UiPageHeader
title="رزرو و نوبت‌دهی"
description="تعریف خدمت، انتخاب بازه آزاد و ثبت نوبت برای مشتری"
>
<template #actions>
<UiButton variant="secondary" :disabled="loading" @click="load">
<UiIcon name="refresh" />
بروزرسانی
</UiButton>
<UiButton v-if="tab === 'appointments'" @click="openCreate">
نوبت جدید
</UiButton>
<UiButton
v-else-if="canEditServices"
@click="openServiceCreate"
>
خدمت جدید
</UiButton>
</template>
</UiPageHeader>
<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>
<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="اول خدمت تعریف کنید، بعد نوبت بسازید."
/>
<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="مثلاً مشاوره ۳۰ دقیقه‌ای تعریف کنید."
/>
<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>
<UiModal
:open="serviceFormOpen"
:title="serviceEditId ? 'ویرایش خدمت' : 'خدمت جدید'"
@close="serviceFormOpen = false"
>
<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>
</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>