Files
clinicpro/assets/admin/pages/AppointmentCreatePage.tsx
T
hamedandClaude Opus 4.8 4642506c0f 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>
2026-07-16 10:10:47 +03:30

457 lines
24 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 { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
import { useNavigate, useSearchParams } from 'react-router-dom';
import { PlusIcon, ChevronRightIcon, MagnifyingGlassIcon } from '@heroicons/react/24/outline';
import { toast } from 'sonner';
import { api } from '../lib/api';
import type { ApiResponse } from '../lib/api';
import { useAuthStore } from '../stores/authStore';
import PersianDateInput from '../components/ui/PersianDateInput';
import PriceInput from '../components/ui/PriceInput';
import DigitInput from '../components/ui/DigitInput';
import SearchableSelect from '../components/ui/SearchableSelect';
import { WalletChargeLink } from '../components/AppointmentActions';
import { useDoctorBookingServices } from '../hooks/useDoctorBookingServices';
import ServiceSlotPicker from '../components/appointments/ServiceSlotPicker';
import { tehranWallClockToUnix } from '../lib/utils';
/**
* افزودن نوبت — صفحهٔ کامل (بازسازی `CreateTurn.jsx` طرح tauri). از همان endpointهای
* موجود استفاده می‌کند: `POST /api/v1/my/appointment` (یا admin) + در صورت نیاز
* `PATCH .../status`. هیچ endpoint جدیدی ساخته نشده است.
*/
interface Option { uuid: string; name?: string; full_name?: string }
interface PatientRow { uuid: string; user_name?: string; user_mobile?: string; user_national_code?: string }
const toEpoch = (isoDate: string, time: string) => tehranWallClockToUnix(isoDate, time);
const addMinutes = (time: string, min: number) => {
const [h, m] = time.split(':').map(Number);
const t = h * 60 + m + min;
return `${String(Math.floor(t / 60) % 24).padStart(2, '0')}:${String(t % 60).padStart(2, '0')}`;
};
const label = { fontSize: 12.5, color: 'var(--text-3)', display: 'block' } as const;
const sectionTitle: React.CSSProperties = { fontSize: 18, fontWeight: 700, color: 'var(--text)', margin: '22px 0 14px' };
export default function AppointmentCreatePage() {
const navigate = useNavigate();
const [params] = useSearchParams();
const qc = useQueryClient();
const primaryRole = useAuthStore(s => s.primaryRole);
const dbUuid = useAuthStore(s => s.dbUuid);
const isDoctor = primaryRole === 'doctor';
const today = new Date().toISOString().slice(0, 10);
// ── پزشک
const [doctorUuid, setDoctorUuid] = useState(isDoctor && dbUuid ? dbUuid : (params.get('doctor') ?? ''));
const clinicDoctorsQuery = useQuery<ApiResponse<{ data: { uuid: string; name: string }[] }>>({
queryKey: ['clinic-doctors', dbUuid],
queryFn: () => api.get(`/api/v1/clinic/doctor-list/${dbUuid}`),
enabled: !isDoctor && !!dbUuid,
});
const doctorOptions = (clinicDoctorsQuery.data?.data?.data ?? []).map(d => ({ value: d.uuid, label: d.name }));
// ── بیمار: جستجوی رکورد موجود یا ورود شخص جدید
const [patientSearch, setPatientSearch] = useState('');
const [picked, setPicked] = useState<PatientRow | null>(null);
const [newPatient, setNewPatient] = useState(false); // فرم «مراجعه کننده جدید» فعال است؟
const [name, setName] = useState('');
const [mobile, setMobile] = useState('');
const [nationalCode, setNationalCode] = useState('');
const patientsQ = useQuery<ApiResponse<PatientRow[]>>({
queryKey: ['create-patients', patientSearch],
queryFn: () => api.get(`/api/v1/patients?search=${encodeURIComponent(patientSearch)}&limit=10`),
enabled: patientSearch.trim().length >= 2,
});
const patients = useMemo(() => patientsQ.data?.data ?? [], [patientsQ.data]);
// ── مشخصات سرویس
const [sectionUuid, setSectionUuid] = 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],
queryFn: () => api.get(`/api/v1/service-items/${sectionUuid}`),
enabled: !!sectionUuid,
});
const staffQ = useQuery<ApiResponse<Option[]>>({ queryKey: ['staff-list'], queryFn: () => api.get('/api/v1/staff') });
// ── روش نوبت‌دهی پزشک (سرویسی/اسلاتی)
const { bookingMode, services } = useDoctorBookingServices(doctorUuid);
const serviceMode = bookingMode === 'service';
const [servicePick, setServicePick] = useState<{ serviceUuids: string[]; slot: { start: number; end: number } | null }>({ serviceUuids: [], slot: null });
// ── زمان نوبت
const [date, setDate] = useState(params.get('date') || today);
const [duration, setDuration] = useState(40);
const [start, setStart] = useState('15:00');
const [end, setEnd] = useState(addMinutes('15:00', 40));
useEffect(() => { setEnd(addMinutes(start, duration)); }, [start, duration]);
// ── بیعانه / وضعیت / توضیحات
const [depositRequired, setDepositRequired] = useState(false);
const [depositRials, setDepositRials] = useState(0);
const [status, setStatus] = useState('pending');
const [note, setNote] = useState('');
const effectiveName = picked?.user_name || name.trim();
const effectiveMobile = picked?.user_mobile || mobile.trim();
const effectiveNationalCode = (picked?.user_national_code || nationalCode).replace(/\D/g, '');
const timingValid = serviceMode
? (servicePick.serviceUuids.length > 0 && !!servicePick.slot)
: (!!start && !!end);
const valid = !!doctorUuid && !!date && effectiveName.length >= 2 && effectiveMobile.length >= 10
&& effectiveNationalCode.length === 10 && timingValid;
const create = useMutation({
mutationFn: async () => {
const createEndpoint = primaryRole === 'admin' ? '/api/v1/admin/appointment' : '/api/v1/my/appointment';
const payload: Record<string, unknown> = {
doctor_uuid: doctorUuid,
slot_start: serviceMode ? servicePick.slot!.start : toEpoch(date, start),
slot_end: serviceMode ? servicePick.slot!.end : toEpoch(date, end),
patient_name: effectiveName,
patient_mobile: effectiveMobile,
patient_national_code: effectiveNationalCode,
...(serviceMode
? { service_item_uuids: servicePick.serviceUuids, duration_from_services: true }
: {
...(sectionUuid ? { service_section_uuid: sectionUuid } : {}),
...(serviceItemUuids.length ? { service_item_uuids: serviceItemUuids } : {}),
}),
...(staffUuid ? { staff_uuid: staffUuid } : {}),
...(depositRequired ? { deposit_required: true, deposit_amount_rials: depositRials } : {}),
...(note.trim() ? { note: note.trim() } : {}),
};
const res: any = await api.post(createEndpoint, payload);
if (status !== 'pending' && res?.data?.uuid) {
await api.patch(`/api/v1/appointment/${res.data.uuid}/status`, { status, version: 1 });
}
return res;
},
onSuccess: () => {
qc.invalidateQueries({ queryKey: ['appointments'] });
toast.success('نوبت با موفقیت ثبت شد');
// به همان روزِ نوبت برگرد، نه امروز.
navigate(`/admin/appointments?date=${date}`);
},
onError: (e: any) => toast.error(e.message || 'خطا در ثبت نوبت'),
});
return (
<div style={{ padding: '20px 24px', maxWidth: 1080, margin: '0 auto' }}>
{/* بردکرامب */}
<div style={{ display: 'flex', alignItems: 'center', gap: 8, marginBottom: 18 }}>
<button onClick={() => navigate(-1)} className="btn sm" style={{ display: 'flex', alignItems: 'center', gap: 4 }}>
<ChevronRightIcon style={{ width: 15, height: 15 }} /> بازگشت
</button>
<span style={{ fontSize: 13, color: 'var(--text-3)' }}>نوبت ها</span>
<span style={{ color: 'var(--text-3)' }}></span>
<span style={{ fontSize: 15, fontWeight: 700, color: 'var(--text)' }}>ثبت نوبت جدید</span>
</div>
<div style={{ background: 'var(--surface)', border: '1px solid var(--border-2)', borderRadius: 'var(--r-sm)', padding: 28 }}>
{/* پزشک — فقط برای admin/clinic */}
{!isDoctor && (
<>
<div style={{ ...sectionTitle, marginTop: 0 }}>پزشک:</div>
<label style={label}>انتخاب پزشک</label>
<div style={{ margin: '6px 0 4px', maxWidth: 400 }}>
<SearchableSelect
options={doctorOptions}
value={doctorUuid || null}
onChange={(v) => setDoctorUuid(v ? String(v) : '')}
placeholder="انتخاب پزشک..."
isClearable
height={44}
/>
</div>
</>
)}
{/* مراجعه کننده */}
<div style={{ ...sectionTitle, marginTop: isDoctor ? 0 : 18 }}>اطلاعات مراجعه کننده :</div>
{/* حالت جستجوی مراجعه‌کنندهٔ موجود */}
{!newPatient && (
<>
<label style={label}>انتخاب مراجعه کننده</label>
<div style={{ display: 'flex', flexWrap: 'wrap', alignItems: 'center', gap: 12, margin: '6px 0 8px' }}>
<div className="field" style={{ width: 400, maxWidth: '100%' }}>
<MagnifyingGlassIcon style={{ width: 18, height: 18, color: 'var(--text-3)', flexShrink: 0 }} />
<input value={picked ? `${picked.user_name ?? ''}${picked.user_mobile ?? ''}` : patientSearch}
onChange={e => { setPicked(null); setPatientSearch(e.target.value); }}
placeholder="جستجوی نام، شماره تماس، شماره پرونده..." />
</div>
<button type="button" className="btn outline"
onClick={() => { setPicked(null); setPatientSearch(''); setNewPatient(true); }}
style={{ height: 44, minWidth: 161, gap: 6 }}>
<PlusIcon style={{ width: 16, height: 16 }} /> مراجعه کننده جدید
</button>
</div>
{!picked && patients.length > 0 && (
<div style={{ border: '1px solid var(--border)', borderRadius: 'var(--r-sm)', marginBottom: 10, overflow: 'hidden', maxWidth: 400 }}>
{patients.map(p => (
<button key={p.uuid}
onClick={() => { setPicked(p); setNationalCode((p.user_national_code ?? '').replace(/\D/g, '').slice(0, 10)); }}
style={{
display: 'block', width: '100%', padding: '8px 10px', fontSize: 13, textAlign: 'right',
background: 'transparent', border: 'none', cursor: 'pointer', fontFamily: 'inherit',
}}>
{p.user_name} <span style={{ color: 'var(--text-3)', direction: 'ltr' }}>{p.user_mobile}</span>
</button>
))}
</div>
)}
{/* کارت بیمارِ انتخاب‌شده + کد ملی (برای ثبت نوبت الزامی است) */}
{picked && (
<div style={{ maxWidth: 400, border: '1px solid var(--border-2)', borderRadius: 'var(--r-sm)', padding: 12, marginBottom: 8 }}>
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', marginBottom: 10 }}>
<span style={{ fontSize: 13.5, fontWeight: 600 }}>
{picked.user_name} <span style={{ color: 'var(--text-3)', direction: 'ltr', fontWeight: 400 }}>{picked.user_mobile}</span>
</span>
<button type="button" className="btn sm ghost"
onClick={() => { setPicked(null); setNationalCode(''); }}>تغییر</button>
</div>
{/^\d{10}$/.test((picked.user_national_code ?? '').replace(/\D/g, '')) ? (
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', fontSize: 13 }}>
<span style={{ color: 'var(--text-3)' }}>کد ملی</span>
<span dir="ltr" style={{ fontWeight: 600 }}>{nationalCode}</span>
</div>
) : (
<>
<label style={label}>کد ملی (ثبت‌نشده وارد کنید)</label>
<div className="field" style={{ marginTop: 6, height: 44 }}>
<DigitInput value={nationalCode} onChange={setNationalCode} maxDigits={10}
placeholder="کد ملی مراجعه کننده" />
</div>
</>
)}
</div>
)}
</>
)}
{/* حالت ثبت مراجعه‌کنندهٔ جدید */}
{newPatient && (
<>
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', margin: '2px 0 6px' }}>
<span style={{ fontSize: 13, fontWeight: 600, color: 'var(--text-2)' }}>مراجعه کننده جدید</span>
<button type="button" className="btn sm ghost"
onClick={() => { setName(''); setMobile(''); setNationalCode(''); setNewPatient(false); }}>
انتخاب از لیست موجود
</button>
</div>
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(3, 1fr)', gap: 16, marginTop: 4 }}>
<div>
<label style={label}>نام و نام خانوادگی مراجعه کننده</label>
<div className="field" style={{ marginTop: 6, height: 44 }}>
<input value={name} onChange={e => setName(e.target.value)} placeholder="نام و نام خانوادگی مراجعه کننده" />
</div>
</div>
<div>
<label style={label}>شماره تماس</label>
<div className="field" style={{ marginTop: 6, height: 44 }}>
<DigitInput value={mobile} onChange={setMobile} maxDigits={11} placeholder="شماره تماس مراجعه کننده" />
</div>
</div>
<div>
<label style={label}>کد ملی</label>
<div className="field" style={{ marginTop: 6, height: 44 }}>
<DigitInput value={nationalCode} onChange={setNationalCode} maxDigits={10}
placeholder="کد ملی مراجعه کننده" />
</div>
</div>
</div>
</>
)}
{/* مشخصات سرویس */}
<div style={sectionTitle}>مشخصات سرویس</div>
{!serviceMode ? (
<>
<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>
{/* سرویس‌ها — چند انتخابی، بر اساس بخشِ انتخاب‌شده */}
<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 style={{ maxWidth: 400, marginBottom: 10 }}>
<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 style={sectionTitle}>زمان نوبت</div>
{serviceMode ? (
<>
<label style={label}>انتخاب تاریخ</label>
<div style={{ margin: '6px 0 10px', maxWidth: 400 }}><PersianDateInput value={date} onChange={setDate} /></div>
<div style={{ marginBottom: 12 }}>
{doctorUuid ? (
<ServiceSlotPicker
doctorUuid={doctorUuid}
date={date}
services={services}
onSelect={setServicePick}
/>
) : (
<div style={{ fontSize: 12.5, color: 'var(--text-3)' }}>ابتدا پزشک را انتخاب کنید.</div>
)}
</div>
</>
) : (
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(4, 1fr)', gap: 16, marginBottom: 12 }}>
<div>
<label style={label}>انتخاب تاریخ</label>
<div style={{ marginTop: 6 }}><PersianDateInput value={date} onChange={setDate} /></div>
</div>
<div>
<label style={label}>زمان پیش فرض (دقیقه)</label>
<div className="field" style={{ marginTop: 6, height: 44 }}>
<input aria-label="زمان پیش فرض" type="number" min={5} value={duration} onChange={e => setDuration(Math.max(5, Number(e.target.value) || 0))} dir="ltr" />
</div>
</div>
<div>
<label style={label}>ساعت شروع</label>
<div className="field" style={{ marginTop: 6, height: 44 }}><input aria-label="ساعت شروع" type="time" value={start} onChange={e => setStart(e.target.value)} dir="ltr" /></div>
</div>
<div>
<label style={label}>ساعت پایان</label>
<div className="field" style={{ marginTop: 6, height: 44 }}><input aria-label="ساعت پایان" type="time" value={end} onChange={e => setEnd(e.target.value)} dir="ltr" /></div>
</div>
</div>
)}
{/* بیعانه */}
<div style={sectionTitle}>بیعانه</div>
<label style={{ display: 'inline-flex', alignItems: 'center', gap: 10, fontSize: 14, cursor: 'pointer' }}>
<span style={{
position: 'relative', width: 42, height: 22, borderRadius: 999, flexShrink: 0,
background: depositRequired ? 'var(--primary)' : '#c4c4c4', transition: 'background .2s',
}}>
<input type="checkbox" checked={depositRequired} onChange={e => setDepositRequired(e.target.checked)}
style={{ position: 'absolute', inset: 0, width: '100%', height: '100%', margin: 0, opacity: 0, cursor: 'pointer' }} />
<span style={{
position: 'absolute', top: 2, insetInlineStart: depositRequired ? 22 : 2, width: 18, height: 18,
borderRadius: 999, background: '#fff', transition: 'inset-inline-start .2s', boxShadow: '0 1px 2px rgba(0,0,0,.2)',
}} />
</span>
بیعانه مورد نیاز است.
</label>
{depositRequired && (
<div style={{ display: 'flex', flexWrap: 'wrap', alignItems: 'flex-end', gap: 12, margin: '12px 0' }}>
<div style={{ width: 300, maxWidth: '100%' }}>
<label style={label}>مبلغ بیعانه (تومان)</label>
<div className="field" style={{ marginTop: 6, height: 44 }}><PriceInput value={depositRials} onChange={setDepositRials} /></div>
</div>
<WalletChargeLink mobile={effectiveMobile} />
</div>
)}
<div style={{ maxWidth: 500 }}>
<label style={label}>انتخاب وضعیت</label>
<div style={{ margin: '6px 0 12px' }}>
<SearchableSelect
options={[{ value: 'pending', label: 'ثبت شده' }, { value: 'confirmed', label: 'قطعی شده' }]}
value={status || null}
onChange={v => setStatus(v ? String(v) : '')}
placeholder="انتخاب وضعیت"
height={44}
/>
</div>
</div>
<label style={label}>توضیحات</label>
<div className="field" style={{ height: 'auto', margin: '6px 0 16px' }}>
<textarea value={note} onChange={e => setNote(e.target.value)} rows={4} placeholder="توضیحات..."
style={{ width: '100%', border: 'none', background: 'transparent', fontFamily: 'inherit', resize: 'vertical' }} />
</div>
<div style={{ display: 'flex', justifyContent: 'flex-end', paddingTop: 4 }}>
<button className="btn primary lg" disabled={!valid || create.isPending} onClick={() => create.mutate()}>
{create.isPending ? '...' : 'ثبت اطلاعات'}
</button>
</div>
</div>
</div>
);
}