From 50759bf6630a7d659a65e65bb7ff7aecfc2b983c Mon Sep 17 00:00:00 2001 From: hamed <15238-genius.ha@users.noreply.drupalcode.org> Date: Thu, 30 Jul 2026 14:57:21 +0330 Subject: [PATCH] feat(admin): service-aware time picking on the appointment edit page MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit In service mode the page now mounts the existing ServiceSlotPicker and hides the three free-form time inputs plus the single-service select: a 45-minute service could previously be shortened to 20 and the next patient would sit on top of it. Hidden rather than disabled — a disabled field reads as "you must do something here". Saving splits in two: the service-aware endpoint takes the time and services (the client sends no duration), then the usual PATCH carries deposit, insurance, status and note without slot_start/slot_end/version, since the reschedule already advanced the optimistic-lock version. Booking mode is read from the appointment's own schedule via an explicit clinic_uuid, not from the panel's current environment: a doctor can be slot-based in their office and service-based in a clinic. That required exposing clinic_uuid in Appointment::toArray(), which was missing. appointment-service-slots accepts exclude_appointment_uuid, gated on canManage of that appointment — an ungated parameter would let anyone fabricate availability. ServiceSlotPicker gained two optional props; its existing callers pass neither and are unaffected. Its reset-on-doctor-change effect now skips the first run, which would otherwise wipe the initial selection. Task: docs/new_feture/taskes/task-00-service-mode-completion/ Slot-mode contract: unchanged (--group=slot-mode-frozen green) Co-Authored-By: Claude Opus 5 (1M context) --- .../appointments/ServiceSlotPicker.tsx | 26 ++- .../AppointmentEditPage.serviceMode.test.tsx | 190 +++++++++++++++++ assets/admin/pages/AppointmentEditPage.tsx | 201 ++++++++++++++---- .../checklist.md | 21 +- .../Controller/AppointmentController.php | 19 ++ src/Appointment/Entity/Appointment.php | 4 + 6 files changed, 402 insertions(+), 59 deletions(-) create mode 100644 assets/admin/pages/AppointmentEditPage.serviceMode.test.tsx diff --git a/assets/admin/components/appointments/ServiceSlotPicker.tsx b/assets/admin/components/appointments/ServiceSlotPicker.tsx index 9249a2d1..37da4659 100644 --- a/assets/admin/components/appointments/ServiceSlotPicker.tsx +++ b/assets/admin/components/appointments/ServiceSlotPicker.tsx @@ -1,4 +1,4 @@ -import { useEffect, useMemo, useState } from 'react'; +import { useEffect, useMemo, useRef, useState } from 'react'; import { useQuery } from '@tanstack/react-query'; import { api } from '../../lib/api'; import type { ApiResponse } from '../../lib/api'; @@ -19,6 +19,7 @@ export interface ServicePick { serviceUuids: string[]; durations: Record([]); + const [selected, setSelected] = useState(initialSelection ?? []); const [pickedSlot, setPickedSlot] = useState(null); // بخش‌های یکتا از روی سرویس‌های bookable (بدون endpoint اضافه — همه یکجا آمده‌اند). @@ -45,8 +53,13 @@ export default function ServiceSlotPicker({ [services, sectionUuid], ); - // تعویض پزشک ⇒ لیست سرویس‌ها عوض می‌شود؛ انتخاب‌ها ریست شوند. - useEffect(() => { setSelected([]); setSectionUuid(''); }, [doctorUuid]); + // تعویض پزشک ⇒ لیست سرویس‌ها عوض می‌شود؛ انتخاب‌ها ریست شوند. اجرای نخست معاف است، + // وگرنه initialSelection (ویرایش نوبت موجود) همان لحظه پاک می‌شد. + const mounted = useRef(false); + useEffect(() => { + if (!mounted.current) { mounted.current = true; return; } + setSelected([]); setSectionUuid(''); + }, [doctorUuid]); useEffect(() => { setPickedSlot(null); }, [selected, date, doctorUuid]); const serviceUuids = useMemo(() => selected.map(s => s.uuid), [selected]); @@ -59,12 +72,13 @@ export default function ServiceSlotPicker({ const durationsQs = selected.map(s => `&durations[${encodeURIComponent(s.uuid)}]=${s.duration}`).join(''); const slotsQ = useQuery>({ - queryKey: ['service-slots-picker', doctorUuid, date, serviceUuids, durations, clinicUuid], + queryKey: ['service-slots-picker', doctorUuid, date, serviceUuids, durations, clinicUuid, excludeAppointmentUuid], queryFn: () => api.get( `/api/v1/appointment-service-slots?doctor_uuid=${doctorUuid}&date=${date}&management=1` + serviceUuids.map(u => `&service_item_uuids[]=${encodeURIComponent(u)}`).join('') + durationsQs - + (clinicUuid ? `&clinic_uuid=${encodeURIComponent(clinicUuid)}` : ''), + + (clinicUuid ? `&clinic_uuid=${encodeURIComponent(clinicUuid)}` : '') + + (excludeAppointmentUuid ? `&exclude_appointment_uuid=${encodeURIComponent(excludeAppointmentUuid)}` : ''), ), enabled: !!doctorUuid && !!date && serviceUuids.length > 0, }); diff --git a/assets/admin/pages/AppointmentEditPage.serviceMode.test.tsx b/assets/admin/pages/AppointmentEditPage.serviceMode.test.tsx new file mode 100644 index 00000000..c2d64373 --- /dev/null +++ b/assets/admin/pages/AppointmentEditPage.serviceMode.test.tsx @@ -0,0 +1,190 @@ +import { describe, it, expect, beforeEach, vi } from 'vitest'; +import { screen, fireEvent, waitFor } from '@testing-library/react'; +import { Routes, Route } from 'react-router-dom'; +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 AppointmentEditPage from './AppointmentEditPage'; + +const get = api.get as ReturnType; +const post = api.post as ReturnType; +const patch = api.patch as ReturnType; + +const slotStart = Math.floor(new Date('2026-08-01T15:00').getTime() / 1000); +const slotEnd = Math.floor(new Date('2026-08-01T15:30').getTime() / 1000); +const newStart = Math.floor(new Date('2026-08-01T16:00').getTime() / 1000); + +/** + * `bookingMode` روی محلِ خودِ نوبت است، پس پاسخ `appointment-booking-services` را با + * همان `doctor_uuid` و بدون `clinic_uuid` (مطب شخصی) می‌دهیم. + */ +function mockApi(opts: { mode: 'slot' | 'service'; isReserve?: boolean; serviceItems?: any[] }) { + get.mockImplementation((url: string) => { + if (url === '/api/v1/appointment/ap1') return Promise.resolve({ success: true, data: { data: { + uuid: 'ap1', slot_start: slotStart, slot_end: slotEnd, status: 'confirmed', version: 4, + doctor: { uuid: 'doc1', name: 'دکتر تست' }, + clinic_uuid: null, + is_reserve: opts.isReserve ?? false, + service_total_minutes: opts.mode === 'service' ? 30 : null, + service_buffer_minutes: opts.mode === 'service' ? 10 : null, + service_section: { uuid: 'sec1', name: 'زیبایی' }, + service_item: { uuid: 'it1', name: 'لیزر' }, + service_items: opts.serviceItems ?? [{ uuid: 'it1', name: 'لیزر' }], + staff: { uuid: 'st1', full_name: 'سحر ایمانی' }, + } } }); + if (url.startsWith('/api/v1/appointment-booking-services/')) { + return Promise.resolve({ success: true, data: { + booking_mode: opts.mode, + buffer_minutes: 10, + services: [ + { uuid: 'it1', name: 'لیزر', duration_minutes: 30, price_rials: 0, service_section: { uuid: 'sec1', name: 'زیبایی' } }, + { uuid: 'it2', name: 'تزریق', duration_minutes: 15, price_rials: 0, service_section: { uuid: 'sec1', name: 'زیبایی' } }, + ], + } }); + } + if (url.startsWith('/api/v1/appointment-service-slots')) { + return Promise.resolve({ success: true, data: { + total_duration_minutes: 30, + buffer_minutes: 10, + start_times: [{ start: newStart, end: newStart + 1800, start_time: '16:00' }], + } }); + } + if (url === '/api/v1/service-sections') return Promise.resolve({ success: true, data: [{ uuid: 'sec1', name: 'زیبایی' }] }); + if (url.startsWith('/api/v1/service-items/')) return Promise.resolve({ success: true, data: [{ uuid: 'it1', name: 'لیزر' }] }); + if (url === '/api/v1/staff') return Promise.resolve({ success: true, data: [{ uuid: 'st1', full_name: 'سحر ایمانی' }] }); + return Promise.resolve({ success: true, data: [] }); + }); +} + +function renderEdit() { + return renderWithProviders( + } />, + { route: '/admin/appointments/ap1/edit' }, + ); +} + +beforeEach(() => { + get.mockReset(); post.mockReset(); patch.mockReset(); + post.mockResolvedValue({ success: true, data: {} }); + patch.mockResolvedValue({ success: true, data: {} }); +}); + +describe('AppointmentEditPage — حالت نوبت‌دهی سرویسی', () => { + it('ورودی دستی ساعت را پنهان می‌کند و انتخابگر سرویس را نشان می‌دهد', async () => { + mockApi({ mode: 'service' }); + renderEdit(); + + await screen.findByText('مشخصات سرویس:'); + // انتخابگر سرویس هم برچسب «بخش» دارد؛ ظاهر شدن دو تا نشانهٔ mount شدنش است. + await waitFor(() => expect(screen.getAllByText('بخش')).toHaveLength(2)); + + expect(screen.queryByLabelText('ساعت شروع')).not.toBeInTheDocument(); + expect(screen.queryByLabelText('ساعت پایان')).not.toBeInTheDocument(); + }); + + it('دکمهٔ ثبت تا انتخاب زمان غیرفعال است و راهنمای فارسی دارد', async () => { + mockApi({ mode: 'service' }); + renderEdit(); + await screen.findByText('مشخصات سرویس:'); + + expect(screen.getByRole('button', { name: 'ثبت اطلاعات' })).toBeDisabled(); + expect(screen.getByText('برای ثبت، سرویس و سپس یکی از زمان‌های خالی را انتخاب کنید.')).toBeInTheDocument(); + }); + + it('exclude_appointment_uuid را به درخواست اسلات‌ها می‌دهد', async () => { + mockApi({ mode: 'service' }); + renderEdit(); + await screen.findByText('مشخصات سرویس:'); + + await waitFor(() => { + const calls = get.mock.calls.map((c) => String(c[0])); + expect(calls.some((u) => u.includes('appointment-service-slots') && u.includes('exclude_appointment_uuid=ap1'))).toBe(true); + }); + }); + + it('ثبت، service-reschedule را بدون هیچ مدتی صدا می‌زند', async () => { + mockApi({ mode: 'service' }); + renderEdit(); + await screen.findByText('مشخصات سرویس:'); + + fireEvent.click(await screen.findByRole('button', { name: '16:00' })); + fireEvent.click(screen.getByRole('button', { name: 'ثبت اطلاعات' })); + + await waitFor(() => expect(post).toHaveBeenCalledWith( + '/api/v1/appointment/ap1/service-reschedule', + expect.objectContaining({ start: newStart, service_item_uuids: ['it1'] }), + )); + + const body = post.mock.calls[0][1] as Record; + expect(body).not.toHaveProperty('slot_end'); + expect(body).not.toHaveProperty('total_duration_minutes'); + }); + + it('PATCH بعدی زمان و نسخه را نمی‌فرستد (نسخه یک قدم جلو رفته)', async () => { + mockApi({ mode: 'service' }); + renderEdit(); + await screen.findByText('مشخصات سرویس:'); + + fireEvent.click(await screen.findByRole('button', { name: '16:00' })); + fireEvent.click(screen.getByRole('button', { name: 'ثبت اطلاعات' })); + + await waitFor(() => expect(patch).toHaveBeenCalled()); + const body = patch.mock.calls[0][1] as Record; + expect(body).not.toHaveProperty('slot_start'); + expect(body).not.toHaveProperty('slot_end'); + expect(body).not.toHaveProperty('version'); + expect(body).toHaveProperty('deposit_required'); + }); + + it('سرویسِ غیرفعالِ نوبت را با هشدار فارسی گزارش می‌کند', async () => { + mockApi({ mode: 'service', serviceItems: [{ uuid: 'it1', name: 'لیزر' }, { uuid: 'gone', name: 'حذف‌شده' }] }); + renderEdit(); + await screen.findByText('مشخصات سرویس:'); + + expect(await screen.findByText(/۱ سرویس این نوبت دیگر برای نوبت‌دهی فعال نیست|1 سرویس این نوبت دیگر برای نوبت‌دهی فعال نیست/)) + .toBeInTheDocument(); + }); +}); + +describe('AppointmentEditPage — خط سرخ: حالت اسلاتی و رزرو', () => { + it('حالت اسلاتی سه فیلد ساعت را حفظ می‌کند و انتخابگر ندارد', async () => { + mockApi({ mode: 'slot' }); + renderEdit(); + await screen.findByText('مشخصات سرویس:'); + + expect((screen.getByLabelText('ساعت شروع') as HTMLInputElement).value).toBe('15:00'); + expect(screen.getByLabelText('ساعت پایان')).toBeInTheDocument(); + expect(screen.queryByText('زمان‌های خالی پیشنهادی')).not.toBeInTheDocument(); + }); + + it('حالت اسلاتی همان PATCH قبلی با version را می‌فرستد', async () => { + mockApi({ mode: 'slot' }); + renderEdit(); + await screen.findByText('مشخصات سرویس:'); + // پیش از کلیک باید هیدریت شده باشد، وگرنه toEpoch('','') مقدار NaN می‌سازد. + await waitFor(() => expect((screen.getByLabelText('ساعت شروع') as HTMLInputElement).value).toBe('15:00')); + + fireEvent.click(screen.getByRole('button', { name: 'ثبت اطلاعات' })); + + await waitFor(() => expect(patch).toHaveBeenCalledWith('/api/v1/appointment/ap1', expect.objectContaining({ + slot_start: slotStart, + slot_end: slotEnd, + version: 4, + }))); + expect(post).not.toHaveBeenCalled(); + }); + + it('نوبت رزرو در محل سرویسی هم انتخابگر زمان نمی‌گیرد', async () => { + mockApi({ mode: 'service', isReserve: true }); + renderEdit(); + await screen.findByText('مشخصات سرویس:'); + + expect(screen.getByLabelText('ساعت شروع')).toBeInTheDocument(); + expect(screen.queryByText('زمان‌های خالی پیشنهادی')).not.toBeInTheDocument(); + }); +}); diff --git a/assets/admin/pages/AppointmentEditPage.tsx b/assets/admin/pages/AppointmentEditPage.tsx index 59a27db3..0fa27c98 100644 --- a/assets/admin/pages/AppointmentEditPage.tsx +++ b/assets/admin/pages/AppointmentEditPage.tsx @@ -1,4 +1,4 @@ -import { useEffect, useState } from 'react'; +import { useEffect, useMemo, useState } from 'react'; import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'; import { useNavigate, useParams, Link } from 'react-router-dom'; import { ChevronRightIcon } from '@heroicons/react/24/outline'; @@ -13,6 +13,9 @@ import { formatRial, rialToToman, tomanToRial } from '../lib/utils'; import { DEFAULT_SERVICE_CATEGORY } from '../lib/insuranceShares'; import BackButton from '../components/ui/BackButton'; import { useAppointmentInsurance } from '../hooks/useAppointmentInsurance'; +import { useDoctorBookingServices } from '../hooks/useDoctorBookingServices'; +import ServiceSlotPicker from '../components/appointments/ServiceSlotPicker'; +import type { PickedService, ServicePick } from '../components/appointments/ServiceSlotPicker'; interface Option { uuid: string; name?: string; full_name?: string } @@ -24,9 +27,15 @@ interface AppointmentDetail { deposit_required?: boolean; deposit_amount_rials?: number | null; service_section?: Option | null; service_item?: Option | null; staff?: Option | null; visit_price_rials?: number | null; - service_items?: { uuid: string; price_rials?: number | null; service_category?: string | null; insurance_covered?: boolean }[] | null; + service_items?: { uuid: string; name?: string; price_rials?: number | null; service_category?: string | null; insurance_covered?: boolean }[] | null; insurance_service_category?: string | null; insurance_base_id?: number | null; + doctor?: { uuid: string; name?: string } | null; + /** null = مطب شخصی. مبنای تشخیص روش نوبت‌دهیِ همین نوبت، نه محیط جاری پنل. */ + clinic_uuid?: string | null; + is_reserve?: boolean; + service_total_minutes?: number | null; + service_buffer_minutes?: number | null; } const isoDate = (ts: number) => { @@ -84,6 +93,37 @@ export default function AppointmentEditPage() { const insurance = useAppointmentInsurance(!!uuid); + // روش نوبت‌دهی از برنامهٔ **همین نوبت** پرسیده می‌شود (clinic_uuid صریح)، نه از محیط + // جاری پنل: یک پزشک می‌تواند در مطب اسلاتی و در کلینیک سرویسی باشد. + const { bookingMode, services } = useDoctorBookingServices( + a?.doctor?.uuid, + a ? (a.clinic_uuid ?? null) : undefined, + ); + // نوبت رزرو زمان ندارد؛ انتخابگر زمان برایش بی‌معناست. + const serviceMode = bookingMode === 'service' && !a?.is_reserve; + const [pick, setPick] = useState(null); + + // سرویس‌های فعلی نوبت، برای هیدریت اولیهٔ انتخابگر. مدتِ هر سرویس از تعریف خودش + // خوانده می‌شود، نه از تقسیم مدت کل — تقسیم یک حدس است و override منشی را جعل می‌کند. + // سرویسی که دیگر bookable نیست در فهرست services نمی‌آید و اینجا هم رد می‌شود؛ + // پس فهرست پس از انتخابگر ممکن است کوتاه‌تر از نوبت باشد و کاربر باید ببیند. + const initialSelection = useMemo(() => { + if (!a?.service_items?.length || services.length === 0) return []; + return a.service_items.flatMap((s) => { + const known = services.find((b) => b.uuid === s.uuid); + return known + ? [{ + uuid: known.uuid, + name: known.name, + section: known.service_section.name, + duration: known.duration_minutes ?? 0, + }] + : []; + }); + }, [a?.service_items, services]); + + const droppedServices = (a?.service_items?.length ?? 0) - initialSelection.length; + // نوع خدمتِ مؤثر: انتخاب نوبت، وگرنه تنها نوع فعالِ tenant (همان قاعدهٔ سرور). const effectiveCategory = serviceCategory || insurance.defaultCategory || DEFAULT_SERVICE_CATEGORY; @@ -106,20 +146,36 @@ export default function AppointmentEditPage() { const staffQ = useQuery>({ queryKey: ['staff-list'], queryFn: () => api.get('/api/v1/staff') }); const save = useMutation({ - mutationFn: () => api.patch(`/api/v1/appointment/${uuid}`, { - slot_start: toEpoch(date, start), - slot_end: toEpoch(date, end), - service_section_uuid: sectionUuid, - service_item_uuid: itemUuid, - staff_uuid: staffUuid, - deposit_required: depositRequired, - deposit_amount_rials: depositRequired ? tomanToRial(depositToman) : null, - note, - insurance_service_category: serviceCategory || null, - insurance_base_id: insuranceId ? Number(insuranceId) : null, - ...(status !== a?.status ? { status } : {}), - version: a?.version, - }), + mutationFn: async () => { + // حالت سرویسی: زمان و سرویس‌ها با endpoint سرویس‌آگاه می‌روند — کلاینت مدت + // نمی‌فرستد. بقیهٔ فیلدها (بیعانه، بیمه، وضعیت، یادداشت) همان PATCH قبلی. + if (serviceMode) { + await api.post(`/api/v1/appointment/${uuid}/service-reschedule`, { + start: pick!.slot!.start, + service_item_uuids: pick!.serviceUuids, + durations: pick!.durations, + version: a?.version, + }); + } + + return api.patch(`/api/v1/appointment/${uuid}`, { + ...(serviceMode ? {} : { + slot_start: toEpoch(date, start), + slot_end: toEpoch(date, end), + service_item_uuid: itemUuid, + }), + service_section_uuid: sectionUuid, + staff_uuid: staffUuid, + deposit_required: depositRequired, + deposit_amount_rials: depositRequired ? tomanToRial(depositToman) : null, + note, + insurance_service_category: serviceCategory || null, + insurance_base_id: insuranceId ? Number(insuranceId) : null, + ...(status !== a?.status ? { status } : {}), + // نسخه پس از service-reschedule یک قدم جلو رفته؛ optimistic lock را دور نزن. + ...(serviceMode ? {} : { version: a?.version }), + }); + }, onSuccess: () => { qc.invalidateQueries({ queryKey: ['appointments'] }); toast.success('نوبت به‌روزرسانی شد'); @@ -161,21 +217,25 @@ export default function AppointmentEditPage() { /> -
- -
- ({ value: o.uuid, label: o.name ?? '' }))} - value={itemUuid || null} - onChange={v => setItemUuid(v ? String(v) : '')} - placeholder="انتخاب سرویس" - isDisabled={!sectionUuid} - isLoading={itemsQ.isLoading} - isClearable - height={38} - /> + {/* در حالت سرویسی، انتخاب سرویس داخل ServiceSlotPicker است (چند-سرویسی و + مدت‌دار)؛ نگه‌داشتن این SearchableSelect تکی یعنی دو منبع برای یک چیز. */} + {!serviceMode && ( +
+ +
+ ({ value: o.uuid, label: o.name ?? '' }))} + value={itemUuid || null} + onChange={v => setItemUuid(v ? String(v) : '')} + placeholder="انتخاب سرویس" + isDisabled={!sectionUuid} + isLoading={itemsQ.isLoading} + isClearable + height={38} + /> +
-
+ )}
@@ -237,20 +297,64 @@ export default function AppointmentEditPage() {
زمان نوبت:
-
-
- -
+ + {/* حالت اسلاتی — دقیقاً همان سه فیلد قبلی، دست‌نخورده. */} + {!serviceMode && ( +
+
+ +
+
+
+ +
setStart(e.target.value)} dir="ltr" />
+
+
+ +
setEnd(e.target.value)} dir="ltr" />
+
-
- -
setStart(e.target.value)} dir="ltr" />
+ )} + + {/* حالت سرویسی — ورودی دستی ساعت پنهان است، نه disabled: فیلد غیرفعال یعنی + کاربر فکر می‌کند باید کاری بکند. مدت را سرور از سرویس‌ها حساب می‌کند. */} + {serviceMode && ( +
+
+ +
+
+ + {droppedServices > 0 && ( +
+ {droppedServices} سرویس این نوبت دیگر برای نوبت‌دهی فعال نیست و در فهرست پایین نیامده است. +
+ )} + + {date && ( + + )} + + {pick?.slot && ( +
+ زمان انتخابی: {isoTime(pick.slot.start)} + {a.service_buffer_minutes ? ` (+${a.service_buffer_minutes} دقیقه فاصله)` : ''} +
+ )}
-
- -
setEnd(e.target.value)} dir="ltr" />
-
-
+ )}
بیعانه:
@@ -288,9 +392,20 @@ export default function AppointmentEditPage() { style={{ width: '100%', border: 'none', background: 'transparent', fontFamily: 'inherit', resize: 'vertical' }} />
- + {serviceMode && !pick?.slot && ( +
+ برای ثبت، سرویس و سپس یکی از زمان‌های خالی را انتخاب کنید. +
+ )}
); diff --git a/docs/new_feture/taskes/task-00-service-mode-completion/checklist.md b/docs/new_feture/taskes/task-00-service-mode-completion/checklist.md index f1ecddd5..511dde13 100644 --- a/docs/new_feture/taskes/task-00-service-mode-completion/checklist.md +++ b/docs/new_feture/taskes/task-00-service-mode-completion/checklist.md @@ -1,6 +1,6 @@ # چک‌لیست — تسک ۰۰ (تکمیل نوبت‌دهی سرویسی در clinicpro) -**وضعیت کلی:** 🔄 در حال انجام — قابلیت ۶ از ۱۰ تمام شد (بک‌اند کامل؛ مانده: UI، backfill، مستندات) +**وضعیت کلی:** 🔄 در حال انجام — قابلیت ۷ از ۱۰ تمام شد (مانده: ReserveAppointmentsPage، backfill، مستندات) **آخرین بازبینی:** ۱۴۰۵/۰۵/۰۸ قواعد: [_shared/definition-of-done.md](../_shared/definition-of-done.md) · @@ -60,24 +60,24 @@ UI: [_shared/ui-conventions.md](../_shared/ui-conventions.md) | # | مورد | وضعیت | یادداشت | |---|---|---|---| -| ۳.۱ | `AppointmentEditPage`: حالت سرویسی `ServiceSlotPicker` نشان می‌دهد | ⏳ | | -| ۳.۲ | `AppointmentEditPage`: حالت اسلاتی **دقیقاً** رفتار امروز | ⏳ | سه فیلد ساعت | -| ۳.۳ | ورودی دستی ساعت در حالت سرویسی **پنهان**، نه disabled | ⏳ | | -| ۳.۴ | `ServiceSlotPicker` موجود بازاستفاده شد؛ نسخهٔ موازی ساخته نشد | ⏳ | فقط prop `excludeAppointmentUuid` | +| ۳.۱ | `AppointmentEditPage`: حالت سرویسی `ServiceSlotPicker` نشان می‌دهد | ✅ | روش نوبت‌دهی از برنامهٔ **همین نوبت** پرسیده می‌شود (`clinic_uuid` صریح)، نه از محیط جاری پنل — پزشک می‌تواند در مطب اسلاتی و در کلینیک سرویسی باشد. لازمهٔ این کار: افزودن `clinic_uuid` به `toArray()` که نبود | +| ۳.۲ | `AppointmentEditPage`: حالت اسلاتی **دقیقاً** رفتار امروز | ✅ | ۵ تست موجود صفحه سبز ماند + دو تست خط سرخ تازه: سه فیلد ساعت با مقدار هیدریت‌شده، و همان `PATCH` با `version` بدون هیچ `POST` | +| ۳.۳ | ورودی دستی ساعت در حالت سرویسی **پنهان**، نه disabled | ✅ | `queryByLabelText('ساعت شروع')` در حالت سرویسی `null` است. `SearchableSelect` سرویس تکی هم پنهان می‌شود — نگه‌داشتنش دو منبع برای یک چیز بود | +| ۳.۴ | `ServiceSlotPicker` موجود بازاستفاده شد؛ نسخهٔ موازی ساخته نشد | ✅ | دو prop اختیاری: `excludeAppointmentUuid` و `initialSelection`. رفتار فعلی‌اش در `AppointmentCreatePage` و `AppointmentsPage` دست‌نخورده (هر دو prop `undefined`). یک اصلاح لازم شد: effect ریست روی تعویض پزشک، اجرای نخست را معاف کند وگرنه `initialSelection` همان لحظه پاک می‌شد | | ۳.۵ | `ReserveAppointmentsPage`: سرویس‌ها + دکمهٔ تبدیل | ⏳ | | | ۳.۶ | `ReserveAppointmentsPage` از جدول خام به `DataTable` مهاجرت کرد | ⏳ | `` حذف شد | -| ۳.۷ | مدت و بافر فارسی با واحد: «۳۵ دقیقه (+۱۰ دقیقه فاصله)» | ⏳ | | +| ۳.۷ | مدت و بافر فارسی با واحد: «۳۵ دقیقه (+۱۰ دقیقه فاصله)» | 🔄 | در `AppointmentEditPage` هست («زمان انتخابی: ۱۶:۰۰ (+۱۰ دقیقه فاصله)») و `ServiceSlotPicker` خودش «مدت کل: N دقیقه» را نشان می‌دهد. `ReserveAppointmentsPage` مانده — قابلیت ۸ | | ۳.۸ | هیچ رنگ/شعاع/سایهٔ hard-code — همه از توکن‌های `styles.css` | ⏳ | | | ۳.۹ | دارک‌مود (`data-theme="dark"`) بررسی شد | ⏳ | | | ۳.۱۰ | حالت فشرده (`data-density="compact"`) بررسی شد | ⏳ | | -| ۳.۱۱ | انتخاب چند سرویس با `SearchableSelect`؛ هیچ `` بومی | ✅ | `ServiceSlotPicker` از `SearchableSelect` برای بخش و دکمه‌های toggle برای سرویس‌ها استفاده می‌کند (کد موجود). هیچ `