feat: support multiple services per appointment (checkbox selection)

Appointments could only reference a single service (ManyToOne). Add an
appointment_service_items join table (ManyToMany) so an appointment can
carry several services; the first stays the primary service_item for
backward compatibility, and toArray now also returns service_items[].

Both create endpoints (my/appointment, admin/appointment) accept
service_item_uuids[] and attach all of them. A new duration_from_services
flag gates the slot_end recompute: service-booking mode sends it true
(slot_end = start + Σ durations); slot mode omits it so the manual end
time is preserved. The admin endpoint previously ignored services entirely.

Frontend: in slot mode the single service dropdown becomes a checkbox list
filtered by the selected section (multi-select); service mode sends the
duration flag. Migration + backend/entity tests + docs updated.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
hamed
2026-07-16 10:10:47 +03:30
co-authored by Claude Opus 4.8
parent d76fd73529
commit 4642506c0f
9 changed files with 315 additions and 65 deletions
+68 -45
View File
@@ -69,8 +69,10 @@ export default function AppointmentCreatePage() {
// ── مشخصات سرویس
const [sectionUuid, setSectionUuid] = useState('');
const [itemUuid, setItemUuid] = useState('');
const [serviceItemUuids, setServiceItemUuids] = useState<string[]>([]); // چند سرویس در حالت اسلاتی
const [staffUuid, setStaffUuid] = useState('');
const toggleServiceItem = (uuid: string) =>
setServiceItemUuids(prev => prev.includes(uuid) ? prev.filter(u => u !== uuid) : [...prev, uuid]);
const sectionsQ = useQuery<ApiResponse<Option[]>>({ queryKey: ['service-sections'], queryFn: () => api.get('/api/v1/service-sections') });
const itemsQ = useQuery<ApiResponse<Option[]>>({
queryKey: ['service-items', sectionUuid],
@@ -117,10 +119,10 @@ export default function AppointmentCreatePage() {
patient_mobile: effectiveMobile,
patient_national_code: effectiveNationalCode,
...(serviceMode
? { service_item_uuids: servicePick.serviceUuids }
? { service_item_uuids: servicePick.serviceUuids, duration_from_services: true }
: {
...(sectionUuid ? { service_section_uuid: sectionUuid } : {}),
...(itemUuid ? { service_item_uuid: itemUuid } : {}),
...(serviceItemUuids.length ? { service_item_uuids: serviceItemUuids } : {}),
}),
...(staffUuid ? { staff_uuid: staffUuid } : {}),
...(depositRequired ? { deposit_required: true, deposit_amount_rials: depositRials } : {}),
@@ -273,51 +275,72 @@ export default function AppointmentCreatePage() {
{/* مشخصات سرویس */}
<div style={sectionTitle}>مشخصات سرویس</div>
{!serviceMode ? (
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(3, 1fr)', gap: 16, marginBottom: 10 }}>
<div>
<label style={label}>بخش</label>
<div style={{ marginTop: 6 }}>
<SearchableSelect
options={(sectionsQ.data?.data ?? []).map(o => ({ value: o.uuid, label: o.name ?? '' }))}
value={sectionUuid || null}
onChange={v => { setSectionUuid(v ? String(v) : ''); setItemUuid(''); }}
placeholder="انتخاب بخش"
isLoading={sectionsQ.isLoading}
isClearable
height={44}
/>
<>
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(2, 1fr)', gap: 16, marginBottom: 10 }}>
<div>
<label style={label}>بخش</label>
<div style={{ marginTop: 6 }}>
<SearchableSelect
options={(sectionsQ.data?.data ?? []).map(o => ({ value: o.uuid, label: o.name ?? '' }))}
value={sectionUuid || null}
onChange={v => { setSectionUuid(v ? String(v) : ''); setServiceItemUuids([]); }}
placeholder="انتخاب بخش"
isLoading={sectionsQ.isLoading}
isClearable
height={44}
/>
</div>
</div>
<div>
<label style={label}>پرسنل</label>
<div style={{ marginTop: 6 }}>
<SearchableSelect
options={(staffQ.data?.data ?? []).map(o => ({ value: o.uuid, label: o.full_name ?? '' }))}
value={staffUuid || null}
onChange={v => setStaffUuid(v ? String(v) : '')}
placeholder="انتخاب پرسنل"
isLoading={staffQ.isLoading}
isClearable
height={44}
/>
</div>
</div>
</div>
<div>
<label style={label}>سرویس</label>
<div style={{ marginTop: 6 }}>
<SearchableSelect
options={(itemsQ.data?.data ?? []).map(o => ({ value: o.uuid, label: o.name ?? '' }))}
value={itemUuid || null}
onChange={v => setItemUuid(v ? String(v) : '')}
placeholder="انتخاب سرویس"
isDisabled={!sectionUuid}
isLoading={itemsQ.isLoading}
isClearable
height={44}
/>
{/* سرویس‌ها — چند انتخابی، بر اساس بخشِ انتخاب‌شده */}
<label style={label}>سرویس (یک یا چند)</label>
{!sectionUuid ? (
<div style={{ fontSize: 12.5, color: 'var(--text-3)', margin: '6px 0 10px' }}>ابتدا بخش را انتخاب کنید.</div>
) : itemsQ.isLoading ? (
<div style={{ fontSize: 12.5, color: 'var(--text-3)', margin: '6px 0 10px' }}>در حال بارگذاری...</div>
) : (itemsQ.data?.data ?? []).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' }}>
{(itemsQ.data?.data ?? []).map(o => {
const active = serviceItemUuids.includes(o.uuid);
return (
<button key={o.uuid} type="button" onClick={() => toggleServiceItem(o.uuid)}
style={{
display: 'flex', alignItems: 'center', 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={{
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>
{o.name}
</button>
);
})}
</div>
</div>
<div>
<label style={label}>پرسنل</label>
<div style={{ marginTop: 6 }}>
<SearchableSelect
options={(staffQ.data?.data ?? []).map(o => ({ value: o.uuid, label: o.full_name ?? '' }))}
value={staffUuid || null}
onChange={v => setStaffUuid(v ? String(v) : '')}
placeholder="انتخاب پرسنل"
isLoading={staffQ.isLoading}
isClearable
height={44}
/>
</div>
</div>
</div>
)}
</>
) : (
<div style={{ maxWidth: 400, marginBottom: 10 }}>
<label style={label}>پرسنل</label>