Files
clinicpro/assets/admin/pages/AppointmentCreatePage.tsx
T
hamedandClaude Opus 5 c7a3b88b32 feat(treatment): bind an appointment to a chosen session, and free it on cancel
Two holes in how a course's later appointments were made.

The link from the unbooked queue carried nothing — `/admin/appointments/new`
with no parameters — so the secretary retyped the patient and the service, and
which case the appointment joined was inferred from the service they happened to
pick. A patient with two open courses had no way to say which one they meant,
and picking the wrong service silently opened a third case. (The suggestion link
did pass slot_start and resource_uuid, but the create page never read either.)

POST /api/v1/my/appointment now takes an optional treatment_session_uuid.
SessionBookingLink validates it — same tenant, still unbooked, case open, same
patient — and reserves that session. Confirm-time attachment steps aside when
the appointment already holds a session. The booking form states in words which
session, which course and which patient it is about to book, read from a new
GET /api/v1/treatment-session/{uuid}.

Nothing ever detached a session from its appointment, so a cancelled booking
left the session `booked` forever, and since findNextUnbooked requires
"has no appointment", it could never return to the queue. Cancellation and
no-show now release it back to `planned`. A finished session is history and is
left alone.

The system still never books the next appointment by itself — it only suggests.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-07 15:44:22 +03:30

713 lines
39 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 { useResources } from '../hooks/useResources';
import { useResourceBookingServices } from '../hooks/useResourceBookingServices';
import { useClinicContext } from '../hooks/useClinicContext';
import ServiceSlotPicker from '../components/appointments/ServiceSlotPicker';
import SlotPicker, { type PickedSlot } from '../components/appointments/SlotPicker';
import { tehranWallClockToUnix, rialToToman, tomanToRial } from '../lib/utils';
import BackButton from '../components/ui/BackButton';
import { digitsOnly, todayIso, formatNumber } from '../lib/utils';
import Switch from '../components/ui/Switch';
/**
* افزودن نوبت — صفحهٔ کامل (بازسازی `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 = todayIso();
// ── پزشک
// در محیط کلینیک، `dbUuid` شناسهٔ کلینیک است نه پزشک؛ uuid پزشک فقط از `doctorUuid`
// می‌آید. بدون این، پزشکِ مهمانِ کلینیک uuid کلینیک را به‌عنوان پزشک می‌فرستاد.
const ownDoctorUuid = useAuthStore(s => s.doctorUuid);
const [doctorUuid, setDoctorUuid] = useState(
isDoctor ? (ownDoctorUuid ?? dbUuid ?? '') : (params.get('doctor') ?? ''),
);
// hydrate شدنِ استور بعد از رندر اول، پزشک را خالی می‌گذاشت.
useEffect(() => {
if (isDoctor && !doctorUuid && ownDoctorUuid) setDoctorUuid(ownDoctorUuid);
}, [isDoctor, doctorUuid, ownDoctorUuid]);
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 clinicUuid = useClinicContext();
const { resources: bookableResources } = useResources({ active: '1' });
const supervisedResources = useMemo(
() => (doctorUuid ? bookableResources.filter(r => r.supervisor?.uuid === doctorUuid) : []),
[bookableResources, doctorUuid],
);
const [resourceUuid, setResourceUuid] = useState('');
// تعویض پزشک ⇒ منبعِ پزشک قبلی دیگر زیر نظرِ او نیست.
useEffect(() => { setResourceUuid(''); }, [doctorUuid]);
const activeResource = supervisedResources.find(r => r.uuid === resourceUuid) ?? null;
const resourceMode = activeResource !== null;
const { services: resourceServices } = useResourceBookingServices(activeResource?.uuid);
// ── بیمار: جستجوی رکورد موجود یا ورود شخص جدید
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 fromRecordUuid = params.get('record') ?? '';
/**
* «ثبت نوبت این جلسه» — بیمار می‌تواند چند دورهٔ باز داشته باشد، پس فرم باید بگوید
* این نوبت به کدام جلسه می‌چسبد و همان را به سرور بفرستد. بدونش اتصال به حدسِ
* سرویس سپرده می‌شود و سرویسِ اشتباه یک پروندهٔ موازی می‌سازد.
*/
const targetSessionUuid = params.get('session') ?? '';
const targetSessionQ = useQuery<ApiResponse<{
uuid: string;
session_number: number;
total_sessions: number;
case_uuid: string;
service: { uuid: string; name: string };
patient: { record_uuid: string; name: string | null };
}>>({
queryKey: ['create-appt-session', targetSessionUuid],
queryFn: () => api.get(`/api/v1/treatment-session/${targetSessionUuid}`),
enabled: !!targetSessionUuid,
});
const targetSession = targetSessionQ.data?.data;
const recordQ = useQuery<ApiResponse<PatientRow & { user_name?: string }>>({
queryKey: ['create-appt-record', fromRecordUuid],
queryFn: () => api.get(`/api/v1/patient/${fromRecordUuid}`),
enabled: !!fromRecordUuid,
});
useEffect(() => {
const rec: any = (recordQ.data?.data as any)?.data ?? recordQ.data?.data;
if (!rec) return;
setPicked({
uuid: rec.uuid,
user_name: rec.user_name,
user_mobile: rec.user_mobile,
user_national_code: rec.user_national_code ?? rec.profile?.national_code,
});
setNationalCode(String(rec.user_national_code ?? rec.profile?.national_code ?? '').replace(/\D/g, '').slice(0, 10));
}, [recordQ.data]);
// ── مشخصات سرویس
const [sectionUuid, setSectionUuid] = useState(''); // بخشِ در حال مرور (برای دیدن سرویس‌هایش)
// سرویس‌های انتخاب‌شده — انباشته از چند بخش؛ هر کدام قابل حذف. نام را نگه می‌داریم تا در chip نشان دهیم.
const [selectedServices, setSelectedServices] = useState<{ uuid: string; name: string }[]>([]);
const [staffUuid, setStaffUuid] = useState('');
const toggleServiceItem = (uuid: string, name: string) =>
setSelectedServices(prev => prev.some(s => s.uuid === uuid)
? prev.filter(s => s.uuid !== uuid)
: [...prev, { uuid, name }]);
const removeService = (uuid: string) =>
setSelectedServices(prev => prev.filter(s => s.uuid !== 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 = resourceMode || bookingMode === 'service';
const [servicePick, setServicePick] = useState<{ serviceUuids: string[]; durations: Record<string, number>; slot: { start: number; end: number } | null }>({ serviceUuids: [], durations: {}, slot: null });
// ── زمان نوبت
const [date, setDate] = useState(params.get('date') || today);
// حالت اسلاتی: انتخاب از اسلات‌های واقعیِ برنامهٔ پزشک. ورود دستیِ ساعت فقط وقتی
// باقی می‌ماند که پزشک برای آن روز اصلاً برنامه‌ای نداشته باشد (نوبت خارج از برنامه).
const [slotPick, setSlotPick] = useState<PickedSlot | null>(null);
const [manualTime, setManualTime] = useState(false);
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]);
// تعویض پزشک/تاریخ ⇒ اسلات انتخاب‌شده دیگر معتبر نیست.
useEffect(() => { setSlotPick(null); }, [doctorUuid, date]);
// ── بیعانه / وضعیت / توضیحات
const [depositRequired, setDepositRequired] = useState(false);
const [depositToman, setDepositToman] = useState(0);
const [note, setNote] = useState('');
// ── هزینه ویزیت — الزامی بودن از تنظیمات «الزامی کردن هزینه ویزیت» (کاربر بدون
// پروفایل doctor/clinic از این endpoint 403 می‌گیرد → فلگ false و فیلد اختیاری می‌ماند)
const pricingQ = useQuery<{ data: { free_visit_price_rials: number; require_visit_price: boolean } }>({
queryKey: ['insurance-pricing'],
queryFn: () => api.get('/api/v1/insurance-pricing'),
});
const freeVisit = (pricingQ.data as any)?.data?.free_visit_price_rials ?? 0;
const requireVisit = (pricingQ.data as any)?.data?.require_visit_price ?? false;
// فیلد UI تومان است؛ API ریالی (visit_price_rials).
const [visitPriceToman, setVisitPriceToman] = useState(0);
const [visitPriceTouched, setVisitPriceTouched] = useState(false);
useEffect(() => {
if (!visitPriceTouched && freeVisit > 0) setVisitPriceToman(rialToToman(freeVisit));
}, [freeVisit, visitPriceTouched]);
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)
: (manualTime ? (!!start && !!end) : !!slotPick);
const valid = !!doctorUuid && !!date && effectiveName.length >= 2 && effectiveMobile.length >= 10
&& effectiveNationalCode.length === 10 && timingValid && (!requireVisit || visitPriceToman > 0);
const create = useMutation({
mutationFn: async () => {
// اندپوینت ادمین `resource_uuid` نمی‌شناسد؛ نوبتِ منبع همیشه از مسیر پنل می‌رود.
const createEndpoint = primaryRole === 'admin' && !resourceMode
? '/api/v1/admin/appointment'
: '/api/v1/my/appointment';
const slotStart = serviceMode ? servicePick.slot!.start : (slotPick ? slotPick.start : toEpoch(date, start));
const slotEnd = serviceMode ? servicePick.slot!.end : (slotPick ? slotPick.end : toEpoch(date, end));
const payload: Record<string, unknown> = {
doctor_uuid: doctorUuid,
slot_start: slotStart,
slot_end: slotEnd,
patient_name: effectiveName,
patient_mobile: effectiveMobile,
patient_national_code: effectiveNationalCode,
// محل نوبت‌دهی: بدون `clinic_uuid` سرور نوبت را به مطب شخصیِ پزشک می‌نشاند —
// و در حالت منبع، منبعِ کلینیک اصلاً «متعلق به این محیط» شناخته نمی‌شود (۴۲۲).
...(clinicUuid ? { clinic_uuid: clinicUuid } : {}),
...(resourceMode ? { resource_uuid: activeResource!.uuid } : {}),
...(targetSessionUuid ? { treatment_session_uuid: targetSessionUuid } : {}),
...(serviceMode
? { service_item_uuids: servicePick.serviceUuids, duration_from_services: true, service_durations: servicePick.durations }
: {
...(sectionUuid ? { service_section_uuid: sectionUuid } : {}),
...(selectedServices.length ? { service_item_uuids: selectedServices.map(s => s.uuid) } : {}),
}),
// پرسنل فقط در حالت پزشک معنا دارد؛ در حالت منبع، خودِ منبع مجریِ نوبت است.
...(staffUuid && !resourceMode ? { staff_uuid: staffUuid } : {}),
...(depositRequired ? { deposit_required: true, deposit_amount_rials: tomanToRial(depositToman) } : {}),
...(visitPriceToman > 0 ? { visit_price_rials: tomanToRial(visitPriceToman) } : {}),
...(note.trim() ? { note: note.trim() } : {}),
};
// سرور نوبت را «ثبت‌شده» متولد می‌کند؛ این صفحه اما نوبتِ **قطعی** می‌سازد، پس
// بلافاصله همان endpoint قطعی‌کردن صدا زده می‌شود (بدون پرداخت — پرداخت از
// مودال «قطعی کردن» یا صفحهٔ پرداخت انجام می‌شود).
const created: any = await api.post(createEndpoint, payload);
const uuid = created?.data?.uuid ?? created?.data?.data?.uuid;
if (!uuid) return created;
try {
await api.post(`/api/v1/appointment/${uuid}/confirm`, { payments: [] });
} catch (e: any) {
// نوبت ثبت شده و اسلات را گرفته؛ فقط قطعی نشده. کاربر باید بداند.
toast.error(e?.message || 'نوبت ثبت شد ولی قطعی نشد؛ از لیست نوبت‌ها قطعی کنید');
}
return created;
},
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 }}>
<BackButton fallback="/admin/appointments" />
<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>
{/* فرم باید صریح بگوید این نوبت برای کدام دوره است؛ بیمارِ چنددوره‌ای بدون این،
اتصال را به حدسِ سرویس می‌سپارد. */}
{targetSessionUuid !== '' && (
<div style={{
marginBottom: 16, padding: '12px 16px', borderRadius: 'var(--r-sm)',
background: 'var(--primary-soft)', display: 'flex', gap: 14, flexWrap: 'wrap', fontSize: 13,
}}>
{targetSessionQ.isLoading ? (
<span style={{ color: 'var(--text-3)' }}>در حال خواندن جلسهٔ درمان…</span>
) : targetSession ? (
<>
<span>
<span style={{ color: 'var(--text-3)' }}>این نوبت برای: </span>
<b>جلسهٔ {formatNumber(targetSession.session_number)} از {formatNumber(targetSession.total_sessions)}</b>
</span>
<span>
<span style={{ color: 'var(--text-3)' }}>دوره: </span>
<b>{targetSession.service.name}</b>
</span>
<span>
<span style={{ color: 'var(--text-3)' }}>بیمار: </span>
<b>{targetSession.patient.name || 'بدون نام'}</b>
</span>
</>
) : (
<span style={{ color: 'var(--danger)' }}>
جلسهٔ درمان یافت نشد نوبت بدون اتصال به دوره ثبت می‌شود.
</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>
</>
)}
{/* هدف نوبت — فقط وقتی این پزشک منبعی زیر نظر دارد */}
{supervisedResources.length > 0 && (
<>
<div style={{ ...sectionTitle, marginTop: isDoctor ? 0 : 18 }}>هدف نوبت:</div>
<div style={{ display: 'flex', flexWrap: 'wrap', gap: 8, margin: '6px 0 2px' }}>
{[{ uuid: '', name: 'خودِ پزشک' }, ...supervisedResources.map(r => ({ uuid: r.uuid, name: r.name }))].map(t => {
const active = resourceUuid === t.uuid;
return (
<button key={t.uuid || 'doctor'} type="button" onClick={() => setResourceUuid(t.uuid)}
style={{
fontSize: 13, padding: '8px 14px', borderRadius: 'var(--r-pill)', cursor: 'pointer',
fontFamily: 'inherit', minHeight: 36,
border: active ? '1px solid var(--primary)' : '1px solid var(--border)',
background: active ? 'var(--primary)' : 'var(--surface)',
color: active ? 'var(--on-primary)' : 'var(--text)',
}}>
{t.name}
</button>
);
})}
</div>
<div style={{ fontSize: 12.5, color: 'var(--text-3)', marginTop: 6 }}>
{resourceMode
? 'زمان‌ها از تقویم خودِ منبع می‌آیند و سرویس‌ها همان‌هایی‌اند که روی این منبع فعال‌اند.'
: 'زمان‌ها از برنامهٔ هفتگی پزشک می‌آیند.'}
</div>
</>
)}
{/* مراجعه کننده */}
<div style={{ ...sectionTitle, marginTop: isDoctor && supervisedResources.length === 0 ? 0 : 18 }}>اطلاعات مراجعه کننده :</div>
{/* آمده از پروندهٔ بیمار: مراجعه‌کننده معلوم است، جستجو معنا ندارد. */}
{fromRecordUuid ? (
<div style={{ maxWidth: 400, border: '1px solid var(--border-2)', borderRadius: 'var(--r-sm)', padding: 12, marginBottom: 8 }}>
<div style={{ fontSize: 13.5, fontWeight: 600, marginBottom: 6 }}>
{picked?.user_name ?? 'در حال بارگذاری پرونده...'}
<span style={{ color: 'var(--text-3)', direction: 'ltr', fontWeight: 400 }}> {picked?.user_mobile ?? ''}</span>
</div>
{picked && !/^\d{10}$/.test(effectiveNationalCode) ? (
<>
<label style={label}>کد ملی (در پرونده ثبت نشده وارد کنید)</label>
<div className="field" style={{ marginTop: 6, height: 44 }}>
<DigitInput value={nationalCode} onChange={setNationalCode} maxDigits={10}
placeholder="کد ملی مراجعه کننده" />
</div>
</>
) : (
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', fontSize: 13 }}>
<span style={{ color: 'var(--text-3)' }}>کد ملی</span>
<span dir="ltr" style={{ fontWeight: 600 }}>{effectiveNationalCode}</span>
</div>
)}
</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>
</>
)}
</>
)}
{/* مشخصات سرویس — در حالت منبع، سرویس‌ها داخل انتخابگر زمانِ همان منبع انتخاب
می‌شوند (فقط سرویس‌هایی که روی آن منبع فعال‌اند)، پس این بخش جا ندارد. */}
{!resourceMode && <div style={sectionTitle}>مشخصات سرویس</div>}
{resourceMode ? null : !serviceMode ? (
<>
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(2, 1fr)', gap: 16, marginBottom: 10 }}>
<div>
<label style={label}>بخش</label>
<div style={{ marginTop: 6 }}>
<SearchableSelect
inputId="appt-section-select"
options={(sectionsQ.data?.data ?? []).map(o => ({ value: o.uuid, label: o.name ?? '' }))}
value={sectionUuid || null}
onChange={v => setSectionUuid(v ? String(v) : '')}
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>
{/* سرویس‌های بخشِ انتخاب‌شده — چند انتخابی، به لیست انباشته اضافه می‌شوند */}
{sectionUuid && (
<>
<label style={label}>سرویس‌های این بخش (یک یا چند)</label>
{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 = selectedServices.some(s => s.uuid === o.uuid);
return (
<button key={o.uuid} type="button" onClick={() => toggleServiceItem(o.uuid, o.name ?? '')}
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: 'var(--surface)', borderRadius: 2 }} />}
</span>
{o.name}
</button>
);
})}
</div>
)}
</>
)}
{/* لیستِ انباشتهٔ سرویس‌های انتخاب‌شده (از هر بخش) — قابل حذف */}
{selectedServices.length > 0 && (
<div style={{ margin: '4px 0 12px' }}>
<label style={label}>سرویس‌های انتخاب‌شده ({selectedServices.length})</label>
<div style={{ display: 'flex', flexWrap: 'wrap', gap: 8, marginTop: 6 }}>
{selectedServices.map(s => (
<span key={s.uuid} style={{
display: 'inline-flex', alignItems: 'center', gap: 6, padding: '5px 6px 5px 10px',
borderRadius: 'var(--r-pill)', fontSize: 12.5, background: 'var(--primary-soft)',
color: 'var(--primary-700)', border: '1px solid var(--primary)',
}}>
{s.name}
<button type="button" aria-label={`حذف ${s.name}`} onClick={() => removeService(s.uuid)}
style={{
display: 'grid', placeItems: 'center', width: 16, height: 16, borderRadius: 999,
border: 'none', cursor: 'pointer', background: 'var(--primary)', color: 'var(--on-primary)',
fontSize: 12, lineHeight: 1, fontFamily: 'inherit',
}}>×</button>
</span>
))}
</div>
</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}>زمان نوبت{resourceMode ? ` — ${activeResource!.name}` : ''}</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}
resourceUuid={activeResource?.uuid}
date={date}
services={resourceMode ? resourceServices : services}
onSelect={setServicePick}
/>
) : (
<div style={{ fontSize: 12.5, color: 'var(--text-3)' }}>ابتدا پزشک را انتخاب کنید.</div>
)}
</div>
</>
) : (
<>
<div style={{ maxWidth: 400, marginBottom: 12 }}>
<label style={label}>انتخاب تاریخ</label>
<div style={{ marginTop: 6 }}><PersianDateInput value={date} onChange={setDate} /></div>
</div>
{!manualTime ? (
<div style={{ marginBottom: 12 }}>
<label style={label}>انتخاب زمان از برنامهٔ پزشک</label>
<div style={{ marginTop: 6 }}>
{doctorUuid ? (
<SlotPicker doctorUuid={doctorUuid} date={date} value={slotPick} onSelect={setSlotPick} />
) : (
<div style={{ fontSize: 12.5, color: 'var(--text-3)' }}>ابتدا پزشک را انتخاب کنید.</div>
)}
</div>
<button type="button" className="btn sm ghost" style={{ marginTop: 8 }}
onClick={() => { setManualTime(true); setSlotPick(null); }}>
ثبت خارج از برنامه (ورود دستی ساعت)
</button>
</div>
) : (
<>
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(3, 1fr)', gap: 16, marginBottom: 8 }}>
<div>
<label style={label}>زمان پیش فرض (دقیقه)</label>
<div className="field" style={{ marginTop: 6, height: 44 }}>
<input aria-label="زمان پیش فرض" type="text" inputMode="numeric" value={duration} onChange={e => setDuration(Math.max(5, Number(digitsOnly(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>
<button type="button" className="btn sm ghost" style={{ marginBottom: 12 }}
onClick={() => setManualTime(false)}>
بازگشت به انتخاب از برنامهٔ پزشک
</button>
</>
)}
</>
)}
{/* بیعانه */}
<div style={sectionTitle}>بیعانه</div>
<Switch
inline
checked={depositRequired}
onChange={setDepositRequired}
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={depositToman} onChange={setDepositToman} /></div>
</div>
<WalletChargeLink mobile={effectiveMobile} />
</div>
)}
{/* هزینه ویزیت */}
<div style={sectionTitle}>هزینه ویزیت</div>
<div style={{ width: 300, maxWidth: '100%', marginBottom: 12 }}>
<label style={label}>
هزینه ویزیت (تومان){requireVisit && <span style={{ color: 'var(--danger)' }}> *</span>}
</label>
<div className="field" style={{ marginTop: 6, height: 44 }}>
<PriceInput value={visitPriceToman} onChange={(v) => { setVisitPriceToman(v); setVisitPriceTouched(true); }} />
</div>
{requireVisit && visitPriceToman <= 0 && (
<span style={{ fontSize: 12, color: 'var(--danger)', display: 'block', marginTop: 4 }}>هزینه ویزیت الزامی است</span>
)}
</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>
);
}