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:
@@ -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: [] });
|
||||
});
|
||||
|
||||
@@ -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 });
|
||||
|
||||
Reference in New Issue
Block a user