fix(booking): carry the clinic context through the panel and drop phantom locations

Two faults, one root: the per-context booking work updated ScheduleSection but
left the rest of the panel calling slot endpoints without clinic_uuid. Absent
clinic_uuid means the personal practice, so the panel asked about a schedule the
doctor barely uses and got nothing back.

- useClinicContext() resolves the current environment once and is used by the
  appointments page, useDoctorBookingServices, ServiceSlotPicker and both
  queries in NewAppointmentDrawer (a fifth call site a sweep turned up). It
  returns null in a doctor's personal environment so the mirror-image bug — a
  doctor seeing the clinic's schedule at their own practice — cannot appear.
  clinicUuid is part of every query key; without it the cache leaks across
  environments.
- appointment-slots returns empty_reason (no_schedule | holiday | day_off |
  outside_window). TurnsTimeline rendered «این روز تعطیل است» for any empty day,
  which is what the bug report actually saw; it now says which of the four it is.
- booking-locations lists a location only when the context has an address and an
  active shift points at it. The dev data had three "personal" schedules whose
  shifts referenced the clinic's address, so the public site advertised a
  personal practice that could never be booked.
- ?date= adds available_on_date per location, validated as a real calendar date.
- MyAppointmentsController and AdminApiController resolved the appointment
  address with no context and could store the wrong one. Both now go through the
  new BookingContextResolver, which also replaces AppointmentController's private
  copy of the same membership check.
- app:schedule:audit-locations reports shifts pointing at a missing or foreign
  address; --fix deactivates them rather than deleting.

Verified against the reported doctor: same date, no clinic_uuid -> 0 sessions,
with it -> 1 session; a full week matches the configured Sat/Tue/Wed/Thu.

Suite: 417 tests, 2 failures — both pre-existing and unrelated.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
hamed
2026-07-18 14:35:31 +03:30
co-authored by Claude Opus 4.8
parent 8d31ebb3cb
commit 7a0654f8ba
15 changed files with 906 additions and 42 deletions
@@ -4,6 +4,7 @@ import { PlusIcon } from '@heroicons/react/24/outline';
import { toast } from 'sonner';
import { api } from '../lib/api';
import type { ApiResponse } from '../lib/api';
import { useClinicContext } from '../hooks/useClinicContext';
import Modal from './ui/Modal';
import PersianDateInput from './ui/PersianDateInput';
import PriceInput from './ui/PriceInput';
@@ -36,6 +37,7 @@ export default function NewAppointmentDrawer({ doctorUuid, defaultDate, queryKey
isReserve?: boolean;
}) {
const qc = useQueryClient();
const clinicUuid = useClinicContext();
// ── patient: pick an existing record or enter a new person ────────────────
const [patientSearch, setPatientSearch] = useState('');
@@ -57,8 +59,11 @@ export default function NewAppointmentDrawer({ doctorUuid, defaultDate, queryKey
// روش نوبت‌دهی پزشک: در حالت «سرویس» زمان از مدت سرویس محاسبه و پیشنهاد می‌شود.
const scheduleQ = useQuery<ApiResponse<any>>({
queryKey: ['drawer-schedule', doctorUuid],
queryFn: () => api.get(`/api/v1/appointment-settings/weekly-schedule/${doctorUuid}`),
queryKey: ['drawer-schedule', doctorUuid, clinicUuid],
queryFn: () => api.get(
`/api/v1/appointment-settings/weekly-schedule/${doctorUuid}`
+ (clinicUuid ? `?clinic_uuid=${encodeURIComponent(clinicUuid)}` : '')
),
enabled: !!doctorUuid,
});
const bookingMode: 'slot' | 'service' =
@@ -92,10 +97,11 @@ export default function NewAppointmentDrawer({ doctorUuid, defaultDate, queryKey
useEffect(() => { setPickedSlot(null); }, [serviceUuids, date]);
const svcSlotsQ = useQuery<ApiResponse<any>>({
queryKey: ['drawer-service-slots', doctorUuid, date, serviceUuids],
queryKey: ['drawer-service-slots', doctorUuid, date, serviceUuids, clinicUuid],
queryFn: () => api.get(
`/api/v1/appointment-service-slots?doctor_uuid=${doctorUuid}&date=${date}`
+ serviceUuids.map(u => `&service_item_uuids[]=${encodeURIComponent(u)}`).join('')
+ (clinicUuid ? `&clinic_uuid=${encodeURIComponent(clinicUuid)}` : '')
),
enabled: serviceMode && !!date && serviceUuids.length > 0,
});
@@ -3,6 +3,7 @@ import { useQuery } from '@tanstack/react-query';
import { api } from '../../lib/api';
import type { ApiResponse } from '../../lib/api';
import type { BookingService } from '../../hooks/useDoctorBookingServices';
import { useClinicContext } from '../../hooks/useClinicContext';
import SearchableSelect from '../ui/SearchableSelect';
import DigitInput from '../ui/DigitInput';
@@ -25,6 +26,7 @@ export default function ServiceSlotPicker({
onSelect: (v: ServicePick) => void;
editableDuration?: boolean;
}) {
const clinicUuid = useClinicContext();
const [sectionUuid, setSectionUuid] = useState('');
const [selected, setSelected] = useState<PickedService[]>([]);
const [pickedSlot, setPickedSlot] = useState<ServiceSlot | null>(null);
@@ -54,11 +56,12 @@ export default function ServiceSlotPicker({
const durationsQs = selected.map(s => `&durations[${encodeURIComponent(s.uuid)}]=${s.duration}`).join('');
const slotsQ = useQuery<ApiResponse<any>>({
queryKey: ['service-slots-picker', doctorUuid, date, serviceUuids, durations],
queryKey: ['service-slots-picker', doctorUuid, date, serviceUuids, durations, clinicUuid],
queryFn: () => api.get(
`/api/v1/appointment-service-slots?doctor_uuid=${doctorUuid}&date=${date}`
+ serviceUuids.map(u => `&service_item_uuids[]=${encodeURIComponent(u)}`).join('')
+ durationsQs,
+ durationsQs
+ (clinicUuid ? `&clinic_uuid=${encodeURIComponent(clinicUuid)}` : ''),
),
enabled: !!doctorUuid && !!date && serviceUuids.length > 0,
});
@@ -142,10 +142,22 @@ function OccupiedCard({
);
}
/**
* چرا این روز اسلاتی ندارد — از `empty_reason` پاسخ appointment-slots.
* خالی‌بودن لزوماً تعطیلی نیست.
*/
const EMPTY_REASON_TEXT: Record<string, { title: string; hint: string }> = {
no_schedule: { title: 'برنامهٔ نوبت‌دهی ثبت نشده', hint: 'برای این محل هنوز برنامهٔ کاری تعریف نشده است' },
holiday: { title: 'این روز تعطیل است', hint: 'در تقویم تعطیلات، این روز برای پزشک تعطیل ثبت شده' },
day_off: { title: 'این روز شیفت کاری ندارد', hint: 'در برنامهٔ هفتگی، برای این روز شیفتی تعریف نشده است' },
outside_window: { title: 'خارج از بازهٔ نوبت‌دهی', hint: 'این تاریخ از بازهٔ مجاز رزرو گذشته یا نوبت‌دهی آنلاین خاموش است' },
};
export default function TurnsTimeline({
slots, loading, queryKey, onView, onBook,
slots, loading, queryKey, onView, onBook, emptyReason,
}: {
slots: TimelineSlot[];
emptyReason?: string | null;
loading: boolean;
queryKey: unknown[];
onView: (a: Appointment) => void;
@@ -163,12 +175,15 @@ export default function TurnsTimeline({
}, [activeIndex]);
if (loading) return <div style={{ padding: 40, textAlign: 'center', color: 'var(--text-3)' }}>در حال بارگذاری...</div>;
if (!slots.length) return (
<div style={{ padding: 40, textAlign: 'center' }}>
<div style={{ fontWeight: 700, fontSize: 15, color: 'var(--text)' }}>این روز تعطیل است</div>
<div style={{ fontSize: 12, color: 'var(--text-3)', marginTop: 4 }}>هیچ برنامه زمانبندی برای این روز تنظیم نشده است</div>
</div>
);
if (!slots.length) {
const reason = EMPTY_REASON_TEXT[emptyReason ?? ''] ?? EMPTY_REASON_TEXT.day_off;
return (
<div style={{ padding: 40, textAlign: 'center' }}>
<div style={{ fontWeight: 700, fontSize: 15, color: 'var(--text)' }}>{reason.title}</div>
<div style={{ fontSize: 12, color: 'var(--text-3)', marginTop: 4 }}>{reason.hint}</div>
</div>
);
}
return (
<div style={{ display: 'flex', flexDirection: 'column', gap: 16, alignItems: 'center', padding: '4px 0' }}>
+28
View File
@@ -0,0 +1,28 @@
import { useMemo } from 'react';
import { useAuthStore } from '../stores/authStore';
/**
* uuid کلینیکِ محیط جاری، یا null وقتی کاربر در محیط شخصی خودش است.
*
* تنظیمات نوبت‌دهی per-context است و نبودِ clinic_uuid در درخواست یعنی «مطب شخصی»،
* نه «هر برنامه‌ای که پیدا شد». پس این تفکیک باید دقیق باشد: پزشکی که هم مطب شخصی
* دارد هم عضو کلینیک است، در محیط شخصی نباید برنامهٔ کلینیک را ببیند و برعکس.
*
* fallback به availableContexts فقط برای مالک کلینیک است — قبل از اولین
* switch-context، هنوز context پر نشده ولی نقش کاربر تکلیف را روشن می‌کند.
*/
export function useClinicContext(): string | null {
const dbUuid = useAuthStore(s => s.dbUuid);
const context = useAuthStore(s => s.context);
const primaryRole = useAuthStore(s => s.primaryRole);
const availableContexts = useAuthStore(s => s.availableContexts);
return useMemo(() => {
if (context?.type === 'clinic') return dbUuid;
if (context?.type === 'doctor') return null;
return primaryRole === 'clinic'
? availableContexts.find(c => c.type === 'clinic')?.db_uuid ?? dbUuid
: null;
}, [context, dbUuid, primaryRole, availableContexts]);
}
@@ -1,6 +1,7 @@
import { useQuery } from '@tanstack/react-query';
import { api } from '../lib/api';
import type { ApiResponse } from '../lib/api';
import { useClinicContext } from './useClinicContext';
export interface BookingService {
uuid: string;
@@ -21,9 +22,14 @@ interface BookingServicesData {
* `appointment-booking-services`. برای سرویس‌محور کردن فرم‌های ثبت نوبت پنل.
*/
export function useDoctorBookingServices(doctorUuid: string | null | undefined) {
const clinicUuid = useClinicContext();
const q = useQuery<ApiResponse<BookingServicesData>>({
queryKey: ['booking-services', doctorUuid],
queryFn: () => api.get(`/api/v1/appointment-booking-services/${doctorUuid}`),
queryKey: ['booking-services', doctorUuid, clinicUuid],
queryFn: () => api.get(
`/api/v1/appointment-booking-services/${doctorUuid}`
+ (clinicUuid ? `?clinic_uuid=${encodeURIComponent(clinicUuid)}` : '')
),
enabled: !!doctorUuid,
});
+9 -2
View File
@@ -12,6 +12,7 @@ import type { Appointment } from '../types';
import { formatDate, toGregorianDate, formatTime, digitsOnly, sanitizeMobileInput, rialToToman, tomanToRial } from '../lib/utils';
import PriceInput from '../components/ui/PriceInput';
import { useAuthStore } from '../stores/authStore';
import { useClinicContext } from '../hooks/useClinicContext';
import Pagination from '../components/ui/Pagination';
import AppointmentFiltersModal, { applyAppointmentFilters, EMPTY_FILTERS } from '../components/AppointmentFiltersModal';
import type { AppointmentFilters } from '../components/AppointmentFiltersModal';
@@ -327,6 +328,7 @@ export default function AppointmentsPage() {
const navigate = useNavigate();
const primaryRole = useAuthStore(s => s.primaryRole);
const dbUuid = useAuthStore(s => s.dbUuid);
const clinicUuid = useClinicContext();
const isAdmin = primaryRole === 'admin';
const isClinic = primaryRole === 'clinic';
const isDoctor = primaryRole === 'doctor';
@@ -420,10 +422,14 @@ export default function AppointmentsPage() {
}, [isClinic, selectedDoctorUuid, doctors]);
// ── Slots query (timeline)
const slotsQueryKey = ['appt-slots', selectedDoctorUuid, selectedDate];
// clinicUuid باید در کلید کش باشد، وگرنه برنامهٔ یک محیط برای محیط دیگر نشان داده می‌شود.
const slotsQueryKey = ['appt-slots', selectedDoctorUuid, selectedDate, clinicUuid];
const slotsQuery = useQuery<ApiResponse<any>>({
queryKey: slotsQueryKey,
queryFn: () => api.get(`/api/v1/appointment-slots?doctor_uuid=${selectedDoctorUuid}&date=${selectedDate}`),
queryFn: () => api.get(
`/api/v1/appointment-slots?doctor_uuid=${selectedDoctorUuid}&date=${selectedDate}` +
(clinicUuid ? `&clinic_uuid=${encodeURIComponent(clinicUuid)}` : '')
),
enabled: viewMode === 'timeline' && !!selectedDoctorUuid,
});
@@ -640,6 +646,7 @@ export default function AppointmentsPage() {
queryKey={apptQueryKey}
onView={openDetail}
onBook={handleSlotClick}
emptyReason={(slotsQuery.data?.data as any)?.empty_reason ?? null}
/>
</>
)}