The booking modal presented one flat scroll of fields whose order did not
match the order of the decisions behind them, and gave no reason when the
submit button stayed grey.
- Group the form into numbered steps (service+time, patient) so the order of
decisions is visible. The optional visit-price collapse stays unnumbered —
numbering an optional step reads as required.
- Show the first blocking condition above the footer instead of leaving a
disabled button unexplained.
- Label the header chip's facts ("device:", "supervising doctor:") and add the
appointment's Jalali date, which the modal never displayed at all.
- Replace the hand-rolled primary/ghost button pair with the design system's
`.seg` + `.on`, and announce state via aria-pressed.
- Move autoFocus off the patient search in picker mode; the first decision is
the section select above it.
- Give every input an id and its label an htmlFor.
- Surface a distinct error state for the slot query. A failed request used to
fall through to "not enough free time", which sent users to another day for
no reason.
- Raise the service remove button (18px), the duration pill and the time chips
to at least the 32px hit target; mark service rows role=checkbox.
- Modal close button gets an accessible name; `.field` controls stretch to the
full 40px box so the whole frame is clickable.
Runtime probe on the open modal goes from 4 unnamed icon controls, 1 unlabelled
field and 2 sub-32px controls to clean, across light/dark/compact/mobile.
The redesign-page probe now names the offending elements instead of only
counting them.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
263 lines
14 KiB
TypeScript
263 lines
14 KiB
TypeScript
import { useEffect, useMemo, useRef, 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 { XMarkIcon } from '@heroicons/react/24/outline';
|
|
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, resourceUuid, date, services, onSelect, editableDuration = true, clinicUuidOverride,
|
|
excludeAppointmentUuid, initialSelection,
|
|
}: {
|
|
doctorUuid: string;
|
|
/**
|
|
* نوبتدهی روی یک منبع: زمانها از تقویم خودِ منبع میآیند، نه از برنامهٔ پزشک.
|
|
*
|
|
* فرمِ انتخاب سرویس در هر دو حالت یکی است (بخش → سرویس → مدت → زمان)، پس فقط
|
|
* منبعِ زمان عوض میشود نه کامپوننت — دو نسخه یعنی دو رفتار.
|
|
*/
|
|
resourceUuid?: string;
|
|
date: string;
|
|
services: BookingService[];
|
|
onSelect: (v: ServicePick) => void;
|
|
editableDuration?: boolean;
|
|
/** undefined = context محیط جاری؛ مقدار صریح (شامل null) = محل انتخابشده خارج از context */
|
|
clinicUuidOverride?: string | null;
|
|
/**
|
|
* ویرایش نوبت: بازهٔ خودِ همین نوبت اشغال حساب نشود، وگرنه زمان فعلیاش در فهرست
|
|
* نمیآید و کاربر نمیتواند «همان ساعت، سرویس متفاوت» را ثبت کند.
|
|
*/
|
|
excludeAppointmentUuid?: string;
|
|
/** سرویسهای از قبل انتخابشده (ویرایش نوبت موجود). فقط یک بار هیدریت میشود. */
|
|
initialSelection?: PickedService[];
|
|
}) {
|
|
const contextClinicUuid = useClinicContext();
|
|
const clinicUuid = clinicUuidOverride === undefined ? contextClinicUuid : clinicUuidOverride;
|
|
const [sectionUuid, setSectionUuid] = useState('');
|
|
const [selected, setSelected] = useState<PickedService[]>(initialSelection ?? []);
|
|
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],
|
|
);
|
|
|
|
// تعویض پزشک ⇒ لیست سرویسها عوض میشود؛ انتخابها ریست شوند. اجرای نخست معاف است،
|
|
// وگرنه initialSelection (ویرایش نوبت موجود) همان لحظه پاک میشد.
|
|
const mounted = useRef(false);
|
|
useEffect(() => {
|
|
if (!mounted.current) { mounted.current = true; return; }
|
|
setSelected([]); setSectionUuid('');
|
|
}, [doctorUuid, resourceUuid]);
|
|
useEffect(() => { setPickedSlot(null); }, [selected, date, doctorUuid, resourceUuid]);
|
|
|
|
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 servicesQs = serviceUuids.map(u => `&service_item_uuids[]=${encodeURIComponent(u)}`).join('');
|
|
const slotsQ = useQuery<ApiResponse<any>>({
|
|
queryKey: ['service-slots-picker', resourceUuid ?? doctorUuid, date, serviceUuids, durations, clinicUuid, excludeAppointmentUuid],
|
|
queryFn: () => api.get(
|
|
resourceUuid
|
|
? `/api/v1/resource/${resourceUuid}/service-slots?date=${date}` + servicesQs + durationsQs
|
|
: `/api/v1/appointment-service-slots?doctor_uuid=${doctorUuid}&date=${date}&management=1`
|
|
+ servicesQs
|
|
+ durationsQs
|
|
+ (clinicUuid ? `&clinic_uuid=${encodeURIComponent(clinicUuid)}` : '')
|
|
+ (excludeAppointmentUuid ? `&exclude_appointment_uuid=${encodeURIComponent(excludeAppointmentUuid)}` : ''),
|
|
),
|
|
enabled: (!!resourceUuid || !!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' }}>
|
|
{resourceUuid
|
|
? 'برای این منبع سرویسی تعریف نشده است — از تب «سرویسها»ی همین منبع اضافه کنید.'
|
|
: 'سرویسی با «نمایش در نوبتدهی» برای این پزشک تعریف نشده است.'}
|
|
</div>
|
|
);
|
|
}
|
|
|
|
return (
|
|
<div>
|
|
{/* انتخاب بخش */}
|
|
<label style={label} htmlFor="service-mode-section-select">بخش</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)}
|
|
role="checkbox" aria-checked={active}
|
|
style={{
|
|
display: 'flex', alignItems: 'center', justifyContent: 'space-between', gap: 8,
|
|
minHeight: 40, 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: 'var(--surface)', 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: 36, 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>
|
|
)}
|
|
{/* هدف کلیک ۳۲px است نه ۱۸px — این دکمه انتخابِ کاربر را پاک میکند و
|
|
خطا زدنش روی موبایل یعنی حذف ناخواستهٔ سرویس. */}
|
|
<button type="button" className="mini-btn" aria-label={`حذف ${s.name}`} onClick={() => remove(s.uuid)}
|
|
style={{ flexShrink: 0, color: 'var(--primary-700)' }}>
|
|
<XMarkIcon style={{ width: 16, height: 16 }} />
|
|
</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>
|
|
) : slotsQ.isError ? (
|
|
/* بدون این شاخه، خطای سرور به «زمان خالی نیست» ترجمه میشد — یعنی کاربر
|
|
روز درست را کنار میگذاشت، چون پاسخ دروغ بود. */
|
|
<div style={{ display: 'flex', alignItems: 'center', gap: 8, margin: '6px 0' }}>
|
|
<span style={{ fontSize: 12.5, color: 'var(--danger)' }}>خواندن زمانهای خالی ناموفق بود.</span>
|
|
<button type="button" className="btn ghost sm" onClick={() => slotsQ.refetch()}>تلاش دوباره</button>
|
|
</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"
|
|
aria-pressed={active}
|
|
onClick={() => setPickedSlot({ start: s.start, end: s.end, start_time: s.start_time })}
|
|
style={{
|
|
fontSize: 13, minHeight: 36, 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 ? 'var(--on-primary)' : 'var(--text)',
|
|
}}>
|
|
{s.start_time}
|
|
</button>
|
|
);
|
|
})}
|
|
</div>
|
|
)}
|
|
</>
|
|
)}
|
|
</div>
|
|
);
|
|
}
|