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:
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user