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>
224 lines
12 KiB
TypeScript
224 lines
12 KiB
TypeScript
import { useEffect, useMemo, useState } from 'react';
|
||
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';
|
||
|
||
interface ServiceSlot { start: number; end: number; start_time: string }
|
||
export interface PickedService { uuid: string; name: string; section: string; duration: number }
|
||
export interface ServicePick { serviceUuids: string[]; durations: Record<string, number>; slot: ServiceSlot | null }
|
||
|
||
/**
|
||
* انتخاب سرویس بر اساس بخش (بخش → سرویس، انباشته از چند بخش) + زمانهای خالیِ پیشنهادی
|
||
* برای نوبتدهی سرویسی. مدتِ هر سرویس در پنل قابل ویرایش است (فقط برای همین نوبت؛ پیشفرضِ
|
||
* سرویس تغییر نمیکند). مدت کل = مجموع مدتها؛ زمانها از `appointment-service-slots`
|
||
* با اعمال همان override محاسبه میشوند. انتخاب را از طریق onSelect بالا میفرستد.
|
||
*/
|
||
export default function ServiceSlotPicker({
|
||
doctorUuid, date, services, onSelect, editableDuration = true,
|
||
}: {
|
||
doctorUuid: string;
|
||
date: string;
|
||
services: BookingService[];
|
||
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);
|
||
|
||
// بخشهای یکتا از روی سرویسهای bookable (بدون endpoint اضافه — همه یکجا آمدهاند).
|
||
const sections = useMemo(() => {
|
||
const map = new Map<string, { uuid: string; name: string }>();
|
||
services.forEach(s => { if (s.service_section) map.set(s.service_section.uuid, s.service_section); });
|
||
return [...map.values()];
|
||
}, [services]);
|
||
const sectionServices = useMemo(
|
||
() => services.filter(s => s.service_section?.uuid === sectionUuid),
|
||
[services, sectionUuid],
|
||
);
|
||
|
||
// تعویض پزشک ⇒ لیست سرویسها عوض میشود؛ انتخابها ریست شوند.
|
||
useEffect(() => { setSelected([]); setSectionUuid(''); }, [doctorUuid]);
|
||
useEffect(() => { setPickedSlot(null); }, [selected, date, doctorUuid]);
|
||
|
||
const serviceUuids = useMemo(() => selected.map(s => s.uuid), [selected]);
|
||
const durations = useMemo(
|
||
() => Object.fromEntries(selected.map(s => [s.uuid, s.duration])) as Record<string, number>,
|
||
[selected],
|
||
);
|
||
|
||
useEffect(() => { onSelect({ serviceUuids, durations, slot: pickedSlot }); }, [serviceUuids, durations, pickedSlot]);
|
||
|
||
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, clinicUuid],
|
||
queryFn: () => api.get(
|
||
`/api/v1/appointment-service-slots?doctor_uuid=${doctorUuid}&date=${date}`
|
||
+ serviceUuids.map(u => `&service_item_uuids[]=${encodeURIComponent(u)}`).join('')
|
||
+ durationsQs
|
||
+ (clinicUuid ? `&clinic_uuid=${encodeURIComponent(clinicUuid)}` : ''),
|
||
),
|
||
enabled: !!doctorUuid && !!date && serviceUuids.length > 0,
|
||
});
|
||
const startTimes: ServiceSlot[] = (slotsQ.data?.data as any)?.start_times ?? [];
|
||
const totalMinutes = (slotsQ.data?.data as any)?.total_duration_minutes as number | undefined;
|
||
|
||
const label = { fontSize: 12.5, color: 'var(--text-3)', display: 'block' } as const;
|
||
|
||
const toggle = (s: BookingService) =>
|
||
setSelected(prev => prev.some(p => p.uuid === s.uuid)
|
||
? prev.filter(p => p.uuid !== s.uuid)
|
||
: [...prev, { uuid: s.uuid, name: s.name, section: s.service_section.name, duration: s.duration_minutes ?? 0 }]);
|
||
const remove = (uuid: string) => setSelected(prev => prev.filter(p => p.uuid !== uuid));
|
||
const setDuration = (uuid: string, minutes: number) =>
|
||
setSelected(prev => prev.map(p => p.uuid === uuid ? { ...p, duration: minutes } : p));
|
||
|
||
if (services.length === 0) {
|
||
return (
|
||
<div style={{ fontSize: 12.5, color: 'var(--danger)', margin: '6px 0' }}>
|
||
سرویسی با «نمایش در نوبتدهی» برای این پزشک تعریف نشده است.
|
||
</div>
|
||
);
|
||
}
|
||
|
||
return (
|
||
<div>
|
||
{/* انتخاب بخش */}
|
||
<label style={label}>بخش</label>
|
||
<div style={{ margin: '6px 0 10px', maxWidth: 400 }}>
|
||
<SearchableSelect
|
||
inputId="service-mode-section-select"
|
||
options={sections.map(s => ({ value: s.uuid, label: s.name }))}
|
||
value={sectionUuid || null}
|
||
onChange={v => setSectionUuid(v ? String(v) : '')}
|
||
placeholder="ابتدا بخش را انتخاب کنید"
|
||
isClearable
|
||
height={44}
|
||
/>
|
||
</div>
|
||
|
||
{/* سرویسهای بخشِ انتخابشده — چند انتخابی */}
|
||
{sectionUuid && (
|
||
<>
|
||
<label style={label}>سرویسهای این بخش (یک یا چند)</label>
|
||
{sectionServices.length === 0 ? (
|
||
<div style={{ fontSize: 12.5, color: 'var(--text-3)', margin: '6px 0 10px' }}>سرویسی در این بخش تعریف نشده است.</div>
|
||
) : (
|
||
<div style={{ display: 'flex', flexDirection: 'column', gap: 6, margin: '6px 0 12px' }}>
|
||
{sectionServices.map(s => {
|
||
const active = selected.some(p => p.uuid === s.uuid);
|
||
return (
|
||
<button key={s.uuid} type="button" onClick={() => toggle(s)}
|
||
style={{
|
||
display: 'flex', alignItems: 'center', justifyContent: 'space-between', gap: 8,
|
||
padding: '9px 12px', borderRadius: 'var(--r-sm)', cursor: 'pointer', textAlign: 'right',
|
||
fontFamily: 'inherit', fontSize: 13,
|
||
border: active ? '1px solid var(--primary)' : '1px solid var(--border)',
|
||
background: active ? 'var(--primary-soft)' : 'var(--surface)',
|
||
}}>
|
||
<span style={{ display: 'inline-flex', alignItems: 'center', gap: 8 }}>
|
||
<span style={{
|
||
width: 16, height: 16, borderRadius: 4, display: 'grid', placeItems: 'center', flexShrink: 0,
|
||
border: active ? '1px solid var(--primary)' : '1px solid var(--border-2)',
|
||
background: active ? 'var(--primary)' : 'transparent',
|
||
}}>
|
||
{active && <span style={{ width: 8, height: 8, background: '#fff', borderRadius: 2 }} />}
|
||
</span>
|
||
{s.name}
|
||
</span>
|
||
{s.duration_minutes ? <span style={{ color: 'var(--text-3)', fontSize: 12 }}>{s.duration_minutes} دقیقه</span> : null}
|
||
</button>
|
||
);
|
||
})}
|
||
</div>
|
||
)}
|
||
</>
|
||
)}
|
||
|
||
{/* لیستِ انباشتهٔ سرویسهای انتخابشده (از هر بخش) — «بخش → سرویس» + مدت قابلویرایش + حذف */}
|
||
{selected.length > 0 && (
|
||
<div style={{ margin: '4px 0 12px' }}>
|
||
<label style={label}>سرویسهای انتخابشده ({selected.length})</label>
|
||
<div style={{ display: 'flex', flexDirection: 'column', gap: 8, marginTop: 6 }}>
|
||
{selected.map(s => (
|
||
<div key={s.uuid} style={{
|
||
display: 'flex', alignItems: 'center', gap: 10,
|
||
padding: '8px 10px', borderRadius: 'var(--r-sm)', fontSize: 13,
|
||
background: 'var(--primary-soft)', border: '1px solid var(--primary)',
|
||
}}>
|
||
<span style={{
|
||
flex: 1, minWidth: 0, color: 'var(--primary-700)', fontWeight: 600,
|
||
whiteSpace: 'nowrap', overflow: 'hidden', textOverflow: 'ellipsis',
|
||
}}>
|
||
<span style={{ color: 'var(--text-3)', fontWeight: 400 }}>{s.section}</span>
|
||
{' ← '}{s.name}
|
||
</span>
|
||
{editableDuration ? (
|
||
<span style={{
|
||
display: 'inline-flex', alignItems: 'center', gap: 4, flexShrink: 0,
|
||
height: 32, padding: '0 8px', borderRadius: 'var(--r-sm)',
|
||
background: 'var(--surface)', border: '1px solid var(--border-2)',
|
||
}}>
|
||
<DigitInput
|
||
aria-label={`مدت ${s.name}`}
|
||
value={String(s.duration || '')}
|
||
onChange={v => setDuration(s.uuid, Number(v) || 0)}
|
||
maxDigits={3}
|
||
style={{ width: 34, border: 'none', outline: 'none', background: 'transparent', textAlign: 'center', fontFamily: 'inherit', fontSize: 13, color: 'var(--text)' }}
|
||
/>
|
||
<span style={{ color: 'var(--text-3)', fontSize: 11.5 }}>دقیقه</span>
|
||
</span>
|
||
) : (
|
||
<span style={{ flexShrink: 0, color: 'var(--text-3)', fontSize: 12 }}>{s.duration} دقیقه</span>
|
||
)}
|
||
<button type="button" aria-label={`حذف ${s.name}`} onClick={() => remove(s.uuid)}
|
||
style={{
|
||
display: 'grid', placeItems: 'center', width: 18, height: 18, borderRadius: 999, flexShrink: 0,
|
||
border: 'none', cursor: 'pointer', background: 'var(--primary)', color: '#fff',
|
||
fontSize: 13, lineHeight: 1, fontFamily: 'inherit',
|
||
}}>×</button>
|
||
</div>
|
||
))}
|
||
</div>
|
||
</div>
|
||
)}
|
||
|
||
{/* زمانهای خالی پیشنهادی */}
|
||
{selected.length > 0 && (
|
||
<>
|
||
<label style={label}>زمانهای خالی پیشنهادی{totalMinutes != null ? ` (مدت کل: ${totalMinutes} دقیقه)` : ''}</label>
|
||
{slotsQ.isLoading ? (
|
||
<div style={{ fontSize: 12.5, color: 'var(--text-3)', margin: '6px 0' }}>در حال محاسبه...</div>
|
||
) : startTimes.length === 0 ? (
|
||
<div style={{ fontSize: 12.5, color: 'var(--danger)', margin: '6px 0' }}>
|
||
برای این سرویس در این روز زمان خالی کافی نیست؛ روز دیگری انتخاب کنید.
|
||
</div>
|
||
) : (
|
||
<div style={{ display: 'flex', flexWrap: 'wrap', gap: 6, margin: '6px 0 4px' }}>
|
||
{startTimes.map(s => {
|
||
const active = pickedSlot?.start === s.start;
|
||
return (
|
||
<button key={s.start} type="button" dir="ltr"
|
||
onClick={() => setPickedSlot({ start: s.start, end: s.end, start_time: s.start_time })}
|
||
style={{
|
||
fontSize: 13, padding: '6px 12px', borderRadius: 'var(--r-sm)', cursor: 'pointer', fontFamily: 'inherit',
|
||
border: active ? '1px solid var(--primary)' : '1px solid var(--border)',
|
||
background: active ? 'var(--primary)' : 'var(--surface)', color: active ? '#fff' : 'var(--text)',
|
||
}}>
|
||
{s.start_time}
|
||
</button>
|
||
);
|
||
})}
|
||
</div>
|
||
)}
|
||
</>
|
||
)}
|
||
</div>
|
||
);
|
||
}
|