Files
clinicpro/assets/admin/components/appointments/ServiceSlotPicker.tsx
T
hamedandClaude Opus 4.8 e00fe997f9 feat: section-based service picker + per-appointment duration override (service mode)
Service-booking mode now selects services by section like slot mode:
appointment-booking-services returns service_section per item; ServiceSlotPicker
groups by section (SearchableSelect), accumulates picks across sections into a
removable 'section -> service' chip list.

Secretaries can override a service's duration for a single appointment without
changing the service default: appointment-service-slots accepts durations[uuid]
and both create endpoints accept service_durations; the override drives total
duration and slot_end. Online (patient) booking is unaffected — it never sends
overrides. Backend + frontend tests and docs updated.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-16 10:41:15 +03:30

216 lines
11 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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 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 [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],
queryFn: () => api.get(
`/api/v1/appointment-service-slots?doctor_uuid=${doctorUuid}&date=${date}`
+ serviceUuids.map(u => `&service_item_uuids[]=${encodeURIComponent(u)}`).join('')
+ durationsQs,
),
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, flexWrap: 'wrap',
padding: '8px 10px', borderRadius: 'var(--r-sm)', fontSize: 13,
background: 'var(--primary-soft)', border: '1px solid var(--primary)',
}}>
<span style={{ flex: 1, minWidth: 120, color: 'var(--primary-700)', fontWeight: 600 }}>
<span style={{ color: 'var(--text-3)', fontWeight: 400 }}>{s.section}</span>
{' ← '}{s.name}
</span>
<span style={{ display: 'inline-flex', alignItems: 'center', gap: 6 }}>
{editableDuration ? (
<span className="field" style={{ height: 34, width: 92, padding: '0 8px' }}>
<DigitInput
aria-label={`مدت ${s.name}`}
value={String(s.duration || '')}
onChange={v => setDuration(s.uuid, Number(v) || 0)}
maxDigits={3}
placeholder="دقیقه"
/>
<span style={{ color: 'var(--text-3)', fontSize: 11.5 }}>دقیقه</span>
</span>
) : (
<span style={{ 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,
border: 'none', cursor: 'pointer', background: 'var(--primary)', color: '#fff',
fontSize: 13, lineHeight: 1, fontFamily: 'inherit',
}}>×</button>
</span>
</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>
);
}