From 91ea1b02aa53097c1ea1e9904d6683315b47c977 Mon Sep 17 00:00:00 2001 From: hamed <15238-genius.ha@users.noreply.drupalcode.org> Date: Mon, 13 Jul 2026 23:55:13 +0330 Subject: [PATCH] =?UTF-8?q?feat(appointments):=20rich=20create=20drawer=20?= =?UTF-8?q?(=D8=A7=D8=B6=D8=A7=D9=81=D9=87=20=DA=A9=D8=B1=D8=AF=D9=86=20?= =?UTF-8?q?=D9=86=D9=88=D8=A8=D8=AA=20=D8=AC=D8=AF=DB=8C=D8=AF)=20?= =?UTF-8?q?=E2=80=94=20phase=20C1?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit NewAppointmentDrawer implements add.pdf: patient search-or-new (patient-list search), بخش/سرویس/پرسنل selects (service-sections, service-items, staff), Jalali date + default-duration with auto end-time, deposit toggle + PriceInput, status pick (applied via the status endpoint after create) and notes. The toolbar «نوبت جدید» button now opens it instead of only hinting at slot click; the slot-click quick modal stays. isReserve mode (day-level, no time fields) is reused by the upcoming reserve list page. Co-Authored-By: Claude Fable 5 --- .../components/NewAppointmentDrawer.test.tsx | 100 ++++++++ .../admin/components/NewAppointmentDrawer.tsx | 238 ++++++++++++++++++ assets/admin/pages/AppointmentsPage.tsx | 15 +- 3 files changed, 351 insertions(+), 2 deletions(-) create mode 100644 assets/admin/components/NewAppointmentDrawer.test.tsx create mode 100644 assets/admin/components/NewAppointmentDrawer.tsx diff --git a/assets/admin/components/NewAppointmentDrawer.test.tsx b/assets/admin/components/NewAppointmentDrawer.test.tsx new file mode 100644 index 00000000..5b8a1b83 --- /dev/null +++ b/assets/admin/components/NewAppointmentDrawer.test.tsx @@ -0,0 +1,100 @@ +import { describe, it, expect, beforeEach, vi } from 'vitest'; +import { screen, fireEvent, waitFor } from '@testing-library/react'; +import { renderWithProviders } from '../test/utils'; + +vi.mock('../lib/api', () => ({ + api: { get: vi.fn(), post: vi.fn(), patch: vi.fn(), put: vi.fn(), delete: vi.fn() }, + ApiError: class extends Error {}, +})); + +import { api } from '../lib/api'; +import NewAppointmentDrawer from './NewAppointmentDrawer'; + +const get = api.get as ReturnType; +const post = api.post as ReturnType; + +beforeEach(() => { + get.mockReset(); post.mockReset(); + get.mockImplementation((url: string) => { + if (url === '/api/v1/service-sections') return Promise.resolve({ success: true, data: [{ uuid: 'sec1', name: 'زیبایی' }] }); + if (url.startsWith('/api/v1/service-items/sec1')) return Promise.resolve({ success: true, data: [{ uuid: 'it1', name: 'لیزر توتال' }] }); + if (url === '/api/v1/staff') return Promise.resolve({ success: true, data: [{ uuid: 'st1', full_name: 'سحر ایمانی' }] }); + if (url.startsWith('/api/v1/patient?search=')) return Promise.resolve({ success: true, data: [{ uuid: 'rec1', user_name: 'ساغر صابری', user_mobile: '09356619438' }] }); + return Promise.resolve({ success: true, data: [] }); + }); + post.mockResolvedValue({ success: true, data: { uuid: 'new1' } }); +}); + +function renderDrawer() { + return renderWithProviders( + {}} />, + ); +} + +describe('NewAppointmentDrawer (اضافه کردن نوبت جدید)', () => { + it('renders the Figma sections and disables submit until valid', async () => { + renderDrawer(); + expect(screen.getByText('اطلاعات مراجعه کننده:')).toBeInTheDocument(); + expect(screen.getByText('مشخصات سرویس:')).toBeInTheDocument(); + expect(screen.getByText('زمان نوبت:')).toBeInTheDocument(); + expect(screen.getByText('بیعانه:')).toBeInTheDocument(); + expect(screen.getByRole('button', { name: 'ثبت نوبت' })).toBeDisabled(); + }); + + it('end time follows start + default duration', () => { + renderDrawer(); + fireEvent.change(screen.getByLabelText('ساعت شروع'), { target: { value: '09:00' } }); + fireEvent.change(screen.getByLabelText('زمان پیش فرض'), { target: { value: '45' } }); + expect((screen.getByLabelText('ساعت پایان') as HTMLInputElement).value).toBe('09:45'); + }); + + it('posts the extended payload for a new patient with service specs', async () => { + renderDrawer(); + fireEvent.change(screen.getByPlaceholderText('نام و نام خانوادگی مراجعه کننده را وارد نمایید'), { target: { value: 'مریم خلیلی' } }); + fireEvent.change(screen.getByPlaceholderText('شماره تماس مراجعه کننده را وارد نمایید'), { target: { value: '09136549874' } }); + await screen.findByRole('option', { name: 'زیبایی' }); + fireEvent.change(screen.getByLabelText('بخش'), { target: { value: 'sec1' } }); + await screen.findByRole('option', { name: 'لیزر توتال' }); + fireEvent.change(screen.getByLabelText('سرویس'), { target: { value: 'it1' } }); + await screen.findByRole('option', { name: 'سحر ایمانی' }); + fireEvent.change(screen.getByLabelText('پرسنل'), { target: { value: 'st1' } }); + + fireEvent.click(screen.getByRole('button', { name: 'ثبت نوبت' })); + await waitFor(() => expect(post).toHaveBeenCalledWith('/api/v1/my/appointment', expect.objectContaining({ + doctor_uuid: 'd1', + patient_name: 'مریم خلیلی', + patient_mobile: '09136549874', + service_section_uuid: 'sec1', + service_item_uuid: 'it1', + staff_uuid: 'st1', + is_reserve: false, + }))); + }); + + it('picks an existing patient from the search results', async () => { + renderDrawer(); + fireEvent.change(screen.getByPlaceholderText('جستجوی نام، شماره تماس، شماره پرونده...'), { target: { value: 'ساغر' } }); + fireEvent.click(await screen.findByText('ساغر صابری')); + fireEvent.click(screen.getByRole('button', { name: 'ثبت نوبت' })); + await waitFor(() => expect(post).toHaveBeenCalledWith('/api/v1/my/appointment', expect.objectContaining({ + patient_name: 'ساغر صابری', patient_mobile: '09356619438', + }))); + }); + + it('reserve mode hides time fields and posts a day-level entry', async () => { + renderWithProviders( + {}} isReserve />, + ); + expect(screen.getByText('اضافه کردن نوبت رزرو')).toBeInTheDocument(); + expect(screen.queryByLabelText('ساعت شروع')).toBeNull(); + + fireEvent.change(screen.getByPlaceholderText('نام و نام خانوادگی مراجعه کننده را وارد نمایید'), { target: { value: 'مریم خلیلی' } }); + fireEvent.change(screen.getByPlaceholderText('شماره تماس مراجعه کننده را وارد نمایید'), { target: { value: '09136549874' } }); + fireEvent.click(screen.getByRole('button', { name: 'ثبت نوبت' })); + + const day = Math.floor(new Date('2026-08-01T00:00').getTime() / 1000); + await waitFor(() => expect(post).toHaveBeenCalledWith('/api/v1/my/appointment', expect.objectContaining({ + is_reserve: true, slot_start: day, slot_end: day, + }))); + }); +}); diff --git a/assets/admin/components/NewAppointmentDrawer.tsx b/assets/admin/components/NewAppointmentDrawer.tsx new file mode 100644 index 00000000..bcd15974 --- /dev/null +++ b/assets/admin/components/NewAppointmentDrawer.tsx @@ -0,0 +1,238 @@ +import { useEffect, useMemo, useState } from 'react'; +import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'; +import { PlusIcon } from '@heroicons/react/24/outline'; +import { toast } from 'sonner'; +import { api } from '../lib/api'; +import type { ApiResponse } from '../lib/api'; +import Modal from './ui/Modal'; +import PersianDateInput from './ui/PersianDateInput'; +import PriceInput from './ui/PriceInput'; + +interface Option { uuid: string; name?: string; full_name?: string } +interface PatientRow { uuid: string; user_name?: string; user_mobile?: string } + +const toEpoch = (isoDate: string, time: string) => + Math.floor(new Date(`${isoDate}T${time || '00:00'}`).getTime() / 1000); +const addMinutes = (time: string, min: number) => { + const [h, m] = time.split(':').map(Number); + const t = h * 60 + m + min; + return `${String(Math.floor(t / 60) % 24).padStart(2, '0')}:${String(t % 60).padStart(2, '0')}`; +}; + +/** + * اضافه کردن نوبت جدید (Figma add.pdf) — rich create form: patient + * search-or-new, بخش/سرویس/پرسنل, date + default-duration + start/end time, + * deposit toggle, status and notes. POSTs the extended /my/appointment. + */ +export default function NewAppointmentDrawer({ doctorUuid, defaultDate, queryKey, onClose, isReserve = false }: { + doctorUuid: string; + /** ISO Y-m-d — the currently viewed day. */ + defaultDate: string; + queryKey: unknown[]; + onClose: () => void; + /** true → «اضافه کردن نوبت رزرو» (day-level entry, no time fields). */ + isReserve?: boolean; +}) { + const qc = useQueryClient(); + + // ── patient: pick an existing record or enter a new person ──────────────── + const [patientSearch, setPatientSearch] = useState(''); + const [pickedPatient, setPickedPatient] = useState(null); + const [name, setName] = useState(''); + const [mobile, setMobile] = useState(''); + + const patientsQ = useQuery>({ + queryKey: ['drawer-patients', patientSearch], + queryFn: () => api.get(`/api/v1/patient?search=${encodeURIComponent(patientSearch)}&limit=10`), + enabled: patientSearch.trim().length >= 2, + }); + + // ── service specs ────────────────────────────────────────────────────────── + const [sectionUuid, setSectionUuid] = useState(''); + const [itemUuid, setItemUuid] = useState(''); + const [staffUuid, setStaffUuid] = useState(''); + + const sectionsQ = useQuery>({ + queryKey: ['service-sections'], queryFn: () => api.get('/api/v1/service-sections'), + }); + const itemsQ = useQuery>({ + queryKey: ['service-items', sectionUuid], + queryFn: () => api.get(`/api/v1/service-items/${sectionUuid}`), + enabled: !!sectionUuid, + }); + const staffQ = useQuery>({ + queryKey: ['staff-list'], queryFn: () => api.get('/api/v1/staff'), + }); + + // ── timing ───────────────────────────────────────────────────────────────── + const [date, setDate] = useState(defaultDate); + const [duration, setDuration] = useState(40); + const [start, setStart] = useState('15:00'); + const [end, setEnd] = useState(addMinutes('15:00', 40)); + useEffect(() => { setEnd(addMinutes(start, duration)); }, [start, duration]); + + // ── deposit / status / notes ─────────────────────────────────────────────── + const [depositRequired, setDepositRequired] = useState(false); + const [depositRials, setDepositRials] = useState(0); + const [status, setStatus] = useState('pending'); + const [note, setNote] = useState(''); + + const effectiveName = pickedPatient?.user_name || name.trim(); + const effectiveMobile = pickedPatient?.user_mobile || mobile.trim(); + const valid = !!doctorUuid && !!date && effectiveName.length >= 2 && effectiveMobile.length >= 10 && (isReserve || (!!start && !!end)); + + const create = useMutation({ + mutationFn: async () => { + const payload: Record = { + doctor_uuid: doctorUuid, + slot_start: isReserve ? toEpoch(date, '00:00') : toEpoch(date, start), + slot_end: isReserve ? toEpoch(date, '00:00') : toEpoch(date, end), + patient_name: effectiveName, + patient_mobile: effectiveMobile, + is_reserve: isReserve, + ...(sectionUuid ? { service_section_uuid: sectionUuid } : {}), + ...(itemUuid ? { service_item_uuid: itemUuid } : {}), + ...(staffUuid ? { staff_uuid: staffUuid } : {}), + ...(depositRequired ? { deposit_required: true, deposit_amount_rials: depositRials } : {}), + ...(note.trim() ? { note: note.trim() } : {}), + }; + const res: any = await api.post('/api/v1/my/appointment', payload); + // POST creates a pending booking; apply the picked status afterwards. + if (status !== 'pending' && res?.data?.uuid) { + await api.patch(`/api/v1/appointment/${res.data.uuid}/status`, { status, version: 1 }); + } + return res; + }, + onSuccess: () => { + qc.invalidateQueries({ queryKey }); + toast.success(isReserve ? 'نوبت رزرو ثبت شد' : 'نوبت با موفقیت ثبت شد'); + onClose(); + }, + onError: (e: any) => toast.error(e.message || 'خطا در ثبت نوبت'), + }); + + const label = { fontSize: 12.5, color: 'var(--text-3)' } as const; + const sel = { width: '100%', height: 38, borderRadius: 'var(--r-sm)', border: '1px solid var(--border)', background: 'var(--surface)', fontSize: 13, fontFamily: 'inherit', padding: '0 10px' } as const; + const patients = useMemo(() => patientsQ.data?.data ?? [], [patientsQ.data]); + + return ( + +
+
اطلاعات مراجعه کننده:
+ +
+ { setPickedPatient(null); setPatientSearch(e.target.value); }} + placeholder="جستجوی نام، شماره تماس، شماره پرونده..." /> +
+ {!pickedPatient && patients.length > 0 && ( +
+ {patients.map(p => ( + + ))} +
+ )} + {pickedPatient === null && ( + <> +
+ + مراجعه کننده جدید + +
+ +
+ setName(e.target.value)} placeholder="نام و نام خانوادگی مراجعه کننده را وارد نمایید" /> +
+ +
+ setMobile(e.target.value)} placeholder="شماره تماس مراجعه کننده را وارد نمایید" dir="ltr" /> +
+ + )} + +
مشخصات سرویس:
+
+
+ + +
+
+ + +
+
+ + + +
زمان نوبت:
+ +
+ +
+ setDuration(Math.max(5, Number(e.target.value) || 0))} dir="ltr" /> +
+ {!isReserve && ( +
+
+ +
setStart(e.target.value)} dir="ltr" />
+
+
+ +
setEnd(e.target.value)} dir="ltr" />
+
+
+ )} + + {!isReserve && ( + <> +
بیعانه:
+
+ +
+ {depositRequired && ( +
+ +
+ +
+
+ )} + + )} + + + + +
+