fix(booking): aggregate public booking state across all schedules

The public doctor payload built `active`/`free_turn`/`hours_of_work` from the
personal schedule alone, so a doctor bookable only at a clinic was reported as
"نوبت‌دهی غیرفعال". Aggregate over every schedule instead: any schedule with
online booking on and an active day makes the doctor bookable, and the disabled
label only appears when all of them are off.

Three admin-panel fixes for the same class of bug:

- AppointmentsPage took the selected doctor from `dbUuid`, which is the clinic's
  uuid inside a clinic context — the slots request 404'd. Use `doctorUuid`.
- TurnsTimeline rendered any error or unknown empty_reason as "این روز شیفت کاری
  ندارد". Errors now surface as errors and unknown reasons get a neutral message;
  the day-off wording is reserved for an explicit day_off from the backend.
- Admins have no clinic context, so slots fell back to the personal schedule.
  They now pick a location from `appointment-booking-locations` and that choice
  drives the slot, service and create-appointment requests.

Adds `app:schedule:normalize-format` for legacy rows stored as a bare JSON list
covering only Saturday, which read as day-off for the rest of the week.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
hamed
2026-07-18 16:25:24 +03:30
co-authored by Claude Fable 5
parent 63bf2cdd12
commit 7baa4df3d4
15 changed files with 631 additions and 69 deletions
@@ -18,15 +18,18 @@ export interface ServicePick { serviceUuids: string[]; durations: Record<string,
* با اعمال همان override محاسبه می‌شوند. انتخاب را از طریق onSelect بالا می‌فرستد.
*/
export default function ServiceSlotPicker({
doctorUuid, date, services, onSelect, editableDuration = true,
doctorUuid, date, services, onSelect, editableDuration = true, clinicUuidOverride,
}: {
doctorUuid: string;
date: string;
services: BookingService[];
onSelect: (v: ServicePick) => void;
editableDuration?: boolean;
/** undefined = context محیط جاری؛ مقدار صریح (شامل null) = محل انتخاب‌شده خارج از context */
clinicUuidOverride?: string | null;
}) {
const clinicUuid = useClinicContext();
const contextClinicUuid = useClinicContext();
const clinicUuid = clinicUuidOverride === undefined ? contextClinicUuid : clinicUuidOverride;
const [sectionUuid, setSectionUuid] = useState('');
const [selected, setSelected] = useState<PickedService[]>([]);
const [pickedSlot, setPickedSlot] = useState<ServiceSlot | null>(null);
@@ -40,13 +40,31 @@ describe('TurnsTimeline', () => {
it('renders an empty slot as «افزودن نوبت» and fires onBook on click', () => {
const onBook = vi.fn();
renderWithProviders(<TurnsTimeline slots={[emptySlot]} loading={false} queryKey={['x']} onView={vi.fn()} onBook={onBook} />);
const add = screen.getByText('افزودن نوبت');
const add = screen.getByText('افزودن نوبت سریع');
fireEvent.click(add);
expect(onBook).toHaveBeenCalledWith(emptySlot);
});
it('shows the holiday/empty message when there are no slots', () => {
renderWithProviders(<TurnsTimeline slots={[]} loading={false} queryKey={['x']} onView={vi.fn()} onBook={vi.fn()} />);
it('empty_reason=day_off → «این روز شیفت کاری ندارد»', () => {
renderWithProviders(<TurnsTimeline slots={[]} emptyReason="day_off" loading={false} queryKey={['x']} onView={vi.fn()} onBook={vi.fn()} />);
expect(screen.getByText('این روز شیفت کاری ندارد')).toBeInTheDocument();
});
it('empty_reason=holiday → «این روز تعطیل است»', () => {
renderWithProviders(<TurnsTimeline slots={[]} emptyReason="holiday" loading={false} queryKey={['x']} onView={vi.fn()} onBook={vi.fn()} />);
expect(screen.getByText('این روز تعطیل است')).toBeInTheDocument();
});
it('خطای API هرگز به «شیفت کاری ندارد» ترجمه نمی‌شود', () => {
renderWithProviders(<TurnsTimeline slots={[]} errorMessage="دکتر یافت نشد" loading={false} queryKey={['x']} onView={vi.fn()} onBook={vi.fn()} />);
expect(screen.getByText('خطا در دریافت برنامهٔ این روز')).toBeInTheDocument();
expect(screen.getByText('دکتر یافت نشد')).toBeInTheDocument();
expect(screen.queryByText('این روز شیفت کاری ندارد')).toBeNull();
});
it('دلیل ناشناخته/غایب → پیام خنثی، نه day_off', () => {
renderWithProviders(<TurnsTimeline slots={[]} loading={false} queryKey={['x']} onView={vi.fn()} onBook={vi.fn()} />);
expect(screen.getByText('برنامهٔ این روز در دسترس نیست')).toBeInTheDocument();
expect(screen.queryByText('این روز شیفت کاری ندارد')).toBeNull();
});
});
@@ -154,10 +154,11 @@ const EMPTY_REASON_TEXT: Record<string, { title: string; hint: string }> = {
};
export default function TurnsTimeline({
slots, loading, queryKey, onView, onBook, emptyReason,
slots, loading, queryKey, onView, onBook, emptyReason, errorMessage,
}: {
slots: TimelineSlot[];
emptyReason?: string | null;
errorMessage?: string | null;
loading: boolean;
queryKey: unknown[];
onView: (a: Appointment) => void;
@@ -175,8 +176,19 @@ export default function TurnsTimeline({
}, [activeIndex]);
if (loading) return <div style={{ padding: 40, textAlign: 'center', color: 'var(--text-3)' }}>در حال بارگذاری...</div>;
if (errorMessage) {
return (
<div style={{ padding: 40, textAlign: 'center' }}>
<div style={{ fontWeight: 700, fontSize: 15, color: 'var(--danger)' }}>خطا در دریافت برنامهٔ این روز</div>
<div style={{ fontSize: 12, color: 'var(--text-3)', marginTop: 4 }}>{errorMessage}</div>
</div>
);
}
if (!slots.length) {
const reason = EMPTY_REASON_TEXT[emptyReason ?? ''] ?? EMPTY_REASON_TEXT.day_off;
// «شیفت کاری ندارد» فقط وقتی که backend صریحاً day_off گفته باشد؛
// دلیل ناشناخته/غایب نباید به تعطیلی تفسیر شود.
const reason = EMPTY_REASON_TEXT[emptyReason ?? '']
?? { title: 'برنامهٔ این روز در دسترس نیست', hint: 'اطلاعات برنامهٔ کاری برای این روز دریافت نشد' };
return (
<div style={{ padding: 40, textAlign: 'center' }}>
<div style={{ fontWeight: 700, fontSize: 15, color: 'var(--text)' }}>{reason.title}</div>
@@ -20,9 +20,16 @@ interface BookingServicesData {
/**
* روش نوبت‌دهی و سرویس‌های قابل‌انتخابِ یک پزشک — از endpoint عمومیِ
* `appointment-booking-services`. برای سرویس‌محور کردن فرم‌های ثبت نوبت پنل.
*
* clinicUuidOverride: `undefined` یعنی context محیط جاری؛ مقدار صریح (شامل null =
* مطب شخصی) وقتی که انتخاب محل خارج از context انجام شده (مثلاً ادمین).
*/
export function useDoctorBookingServices(doctorUuid: string | null | undefined) {
const clinicUuid = useClinicContext();
export function useDoctorBookingServices(
doctorUuid: string | null | undefined,
clinicUuidOverride?: string | null,
) {
const contextClinicUuid = useClinicContext();
const clinicUuid = clinicUuidOverride === undefined ? contextClinicUuid : clinicUuidOverride;
const q = useQuery<ApiResponse<BookingServicesData>>({
queryKey: ['booking-services', doctorUuid, clinicUuid],
+3 -3
View File
@@ -15,10 +15,10 @@ const get = api.get as ReturnType<typeof vi.fn>;
beforeEach(() => {
get.mockReset();
useAuthStore.setState({ primaryRole: 'doctor', dbUuid: 'doc1' } as any);
useAuthStore.setState({ primaryRole: 'doctor', dbUuid: 'doc1', doctorUuid: 'doc1' } as any);
get.mockImplementation((url: string) => {
if (url.includes('today-stats')) return Promise.resolve({ success: true, data: { total: 7, completed: 3, waiting: 2, cancelled: 1 } });
if (url.includes('appointment-slots')) return Promise.resolve({ success: true, data: { sessions: [] } });
if (url.includes('appointment-slots')) return Promise.resolve({ success: true, data: { sessions: [], empty_reason: 'holiday' } });
if (url.includes('/my/appointments')) return Promise.resolve({ success: true, data: [], meta: { totalRecords: 0, totalPages: 0, currentPage: 1 } });
return Promise.resolve({ success: true, data: [] });
});
@@ -64,7 +64,7 @@ describe('AppointmentsPage — پروفایل کلینیک چندپزشکه', ()
if (url.includes('/clinic/doctor-list/')) return Promise.resolve({ success: true, data: { data: [
{ uuid: 'd1', name: 'دکتر محمدی' }, { uuid: 'd2', name: 'دکتر رضایی' },
] } });
if (url.includes('appointment-slots')) return Promise.resolve({ success: true, data: { sessions: [] } });
if (url.includes('appointment-slots')) return Promise.resolve({ success: true, data: { sessions: [], empty_reason: 'holiday' } });
if (url.includes('/my/appointments')) return Promise.resolve({ success: true, data: [], meta: { totalRecords: 0, totalPages: 0, currentPage: 1 } });
return Promise.resolve({ success: true, data: [] });
});
+48 -6
View File
@@ -102,7 +102,7 @@ interface BookingSlot { start: number; end: number; start_time: string; end_time
interface PatientLookup { found: boolean; name?: string | null; mobile?: string; national_code?: string | null }
export function NewAppointmentModal({
slot, onClose, onSuccess, serviceMode = false, services = [], date,
slot, onClose, onSuccess, serviceMode = false, services = [], date, clinicUuid = null,
}: {
slot: BookingSlot;
onClose: () => void;
@@ -110,6 +110,8 @@ export function NewAppointmentModal({
serviceMode?: boolean;
services?: import('../hooks/useDoctorBookingServices').BookingService[];
date?: string;
/** محل نوبت — بدون آن backend نوبت را به مطب شخصی نسبت می‌دهد. */
clinicUuid?: string | null;
}) {
const [mobile, setMobile] = useState('');
const [lookup, setLookup] = useState<PatientLookup | null>(null);
@@ -165,6 +167,7 @@ export function NewAppointmentModal({
patient_mobile: mobile,
patient_name: effectiveName,
patient_national_code: effectiveNationalCode,
...(clinicUuid ? { clinic_uuid: clinicUuid } : {}),
...(serviceMode ? { service_item_uuids: pick.serviceUuids } : {}),
...(visitPriceToman > 0 ? { visit_price_rials: tomanToRial(visitPriceToman) } : {}),
}),
@@ -218,6 +221,7 @@ export function NewAppointmentModal({
date={date}
services={services}
onSelect={setPick}
clinicUuidOverride={clinicUuid}
/>
</div>
)}
@@ -328,6 +332,8 @@ export default function AppointmentsPage() {
const navigate = useNavigate();
const primaryRole = useAuthStore(s => s.primaryRole);
const dbUuid = useAuthStore(s => s.dbUuid);
// در محیط کلینیک، dbUuid شناسهٔ کلینیک است نه پزشک؛ uuid پزشک فقط از doctorUuid می‌آید.
const doctorUuid = useAuthStore(s => s.doctorUuid);
const clinicUuid = useClinicContext();
const isAdmin = primaryRole === 'admin';
const isClinic = primaryRole === 'clinic';
@@ -339,7 +345,7 @@ export default function AppointmentsPage() {
// پس از ویرایش/ثبت، صفحه با ?date=... باز می‌شود تا همان روز نمایش داده شود.
const [selectedDate, setSelectedDate] = useState(params.get('date') || today);
const [viewMode, setViewMode] = useState<TurnsViewMode>('timeline');
const [selectedDoctorUuid, setSelectedDoctorUuid] = useState<string>(isDoctor && dbUuid ? dbUuid : '');
const [selectedDoctorUuid, setSelectedDoctorUuid] = useState<string>(isDoctor ? (doctorUuid ?? '') : '');
const [bookingSlot, setBookingSlot] = useState<BookingSlot | null>(null);
const [filtersOpen, setFiltersOpen] = useState(false);
const [filters, setFilters] = useState<AppointmentFilters>(EMPTY_FILTERS);
@@ -421,20 +427,39 @@ export default function AppointmentsPage() {
}
}, [isClinic, selectedDoctorUuid, doctors]);
// ── محل نوبت‌دهی برای ادمین
// ادمین context کلینیکی ندارد (useClinicContext → null)؛ بدون clinic_uuid فقط برنامهٔ
// مطب شخصی خوانده می‌شود. محل از booking-locations همان پزشک انتخاب می‌شود.
const adminLocationsQuery = useQuery<ApiResponse<any>>({
queryKey: ['booking-locations', selectedDoctorUuid],
queryFn: () => api.get(`/api/v1/appointment-booking-locations/${selectedDoctorUuid}`),
enabled: isAdmin && !!selectedDoctorUuid,
});
const adminLocations: any[] = (adminLocationsQuery.data?.data as any)?.booking_locations ?? EMPTY_ARR;
const [adminLocKey, setAdminLocKey] = useState<string | null>(null);
useEffect(() => { setAdminLocKey(null); }, [selectedDoctorUuid]);
const locKey = (l: any) => l.clinic_uuid ?? 'personal';
// پیش‌فرض = اولین آیتم؛ backend بر اساس زودترین نوبت آزاد مرتب کرده است.
const adminLocation = adminLocations.find(l => locKey(l) === adminLocKey) ?? adminLocations[0] ?? null;
const effectiveClinicUuid: string | null = isAdmin ? (adminLocation?.clinic_uuid ?? null) : clinicUuid;
// ── Slots query (timeline)
// clinicUuid باید در کلید کش باشد، وگرنه برنامهٔ یک محیط برای محیط دیگر نشان داده می‌شود.
const slotsQueryKey = ['appt-slots', selectedDoctorUuid, selectedDate, clinicUuid];
// effectiveClinicUuid باید در کلید کش باشد، وگرنه برنامهٔ یک محیط برای محیط دیگر نشان داده می‌شود.
const slotsQueryKey = ['appt-slots', selectedDoctorUuid, selectedDate, effectiveClinicUuid];
const slotsQuery = useQuery<ApiResponse<any>>({
queryKey: slotsQueryKey,
queryFn: () => api.get(
`/api/v1/appointment-slots?doctor_uuid=${selectedDoctorUuid}&date=${selectedDate}` +
(clinicUuid ? `&clinic_uuid=${encodeURIComponent(clinicUuid)}` : '')
(effectiveClinicUuid ? `&clinic_uuid=${encodeURIComponent(effectiveClinicUuid)}` : '')
),
enabled: viewMode === 'timeline' && !!selectedDoctorUuid,
});
// ── روش نوبت‌دهی پزشکِ انتخاب‌شده (سرویسی/اسلاتی)
const { bookingMode, services } = useDoctorBookingServices(selectedDoctorUuid);
const { bookingMode, services } = useDoctorBookingServices(
selectedDoctorUuid,
isAdmin ? (adminLocation?.clinic_uuid ?? null) : undefined,
);
const serviceMode = bookingMode === 'service';
// بازهٔ کاری پزشک در این روز (برای هدرِ تایم‌لاینِ سرویسی).
@@ -564,6 +589,21 @@ export default function AppointmentsPage() {
onChange={(v) => setFilters(f => ({ ...f, itemUuid: v }))}
/>
{isAdmin && adminLocations.length > 1 && (
<div style={{ minWidth: 220 }}>
<SearchableSelect
options={adminLocations.map((l: any) => ({
value: locKey(l),
label: l.type === 'personal' ? `مطب شخصی${l.title ? `${l.title}` : ''}` : l.title,
}))}
value={adminLocation ? locKey(adminLocation) : null}
onChange={v => setAdminLocKey(v ? String(v) : null)}
placeholder="محل نوبت‌دهی..."
height={44}
/>
</div>
)}
<TurnsViewToggle viewMode={viewMode} onChange={setViewMode} />
<div style={{ flex: 1 }} />
@@ -647,6 +687,7 @@ export default function AppointmentsPage() {
onView={openDetail}
onBook={handleSlotClick}
emptyReason={(slotsQuery.data?.data as any)?.empty_reason ?? null}
errorMessage={slotsQuery.isError ? ((slotsQuery.error as Error)?.message || 'خطای نامشخص') : null}
/>
</>
)}
@@ -663,6 +704,7 @@ export default function AppointmentsPage() {
serviceMode={serviceMode}
services={services}
date={selectedDate}
clinicUuid={effectiveClinicUuid}
onClose={() => setBookingSlot(null)}
onSuccess={() => {
qc.invalidateQueries({ queryKey: apptQueryKey });