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:
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user