Files
clinicpro/assets/admin/components/appointments/NewAppointmentModal.tsx
T
hamedandClaude Opus 5 251b45d807 feat(admin): let the secretary pre-assign an operator when booking
The backend already accepted staff_uuid on POST /api/v1/my/appointment; only
the booking modal never sent it, so the pre-assignment half of the operator
model had no way in.

The field sits in step 1 next to the section select and is optional by design:
left empty, the session stays in the shared queue that every allowed operator
sees. Its placeholder says so rather than leaving the blank state unexplained.

The section select is no longer the only combobox in the modal, so the tests
target it by its label instead of by role.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-07 12:39:35 +03:30

532 lines
25 KiB
TypeScript

import { useEffect, useState } from 'react';
import type { ReactNode } from 'react';
import { useMutation, useQuery } from '@tanstack/react-query';
import { ChevronDownIcon, ClockIcon, MagnifyingGlassIcon, CheckCircleIcon, CpuChipIcon, ExclamationCircleIcon } from '@heroicons/react/24/outline';
import { toast } from 'sonner';
import { api } from '../../lib/api';
import type { ApiResponse } from '../../lib/api';
import { digitsOnly, sanitizeMobileInput, rialToToman, tomanToRial, formatRial, formatDate } from '../../lib/utils';
import { useAuthStore } from '../../stores/authStore';
import Modal from '../ui/Modal';
import PriceInput from '../ui/PriceInput';
import SearchableSelect from '../ui/SearchableSelect';
import ServiceSlotPicker from './ServiceSlotPicker';
import type { ServicePick } from './ServiceSlotPicker';
import type { BookingService } from '../../hooks/useDoctorBookingServices';
export interface BookingSlot {
start: number;
end: number;
start_time: string;
end_time: string;
doctor_uuid: string;
doctor_name: string;
}
interface PatientLookup { found: boolean; name?: string | null; mobile?: string; national_code?: string | null }
/** منبعِ هدفِ نوبت — دستگاه/اتاق/پرسنلی که نوبت رویش می‌نشیند. */
export interface BookingResource {
uuid: string;
name: string;
}
/**
* ثبت نوبت از روی یک اسلات/منبع: جستجوی بیمار، انتخاب سرویس‌ها و زمان، هزینهٔ ویزیت.
*
* سه حالت دارد و هر سه یک فرم‌اند — سه مودال یعنی سه رفتار:
* - اسلاتی: زمان همان اسلاتِ کلیک‌شده است.
* - سرویسی (پزشک): زمان از `ServiceSlotPicker` و برنامهٔ هفتگیِ پزشک می‌آید.
* - منبع: همان انتخابگر، ولی زمان‌ها از تقویم خودِ منبع و ثبت با `resource_uuid`.
*/
export default function NewAppointmentModal({
slot, onClose, onSuccess, serviceMode = false, services = [], date, clinicUuid = null, resource = null,
}: {
slot: BookingSlot;
onClose: () => void;
onSuccess: () => void;
serviceMode?: boolean;
services?: BookingService[];
date?: string;
/** محل نوبت — بدون آن backend نوبت را به مطب شخصی نسبت می‌دهد. */
clinicUuid?: string | null;
resource?: BookingResource | null;
}) {
const [mobile, setMobile] = useState('');
const [lookup, setLookup] = useState<PatientLookup | null>(null);
const [patientName, setPatientName] = useState('');
const [nationalCode, setNationalCode] = useState('');
// معیار جستجوی بیمار: کد ملی (پیش‌فرض) یا موبایل.
const [searchBy, setSearchBy] = useState<'mobile' | 'national'>('national');
const [pick, setPick] = useState<ServicePick>({ serviceUuids: [], durations: {}, slot: null });
// اپراتور اختیاری است: خالی گذاشتنش جلسه را در صفِ مشترکِ پرسنلِ مجاز می‌گذارد،
// پر کردنش آن را از قبل به یک نفر می‌دهد.
const [staffUuid, setStaffUuid] = useState('');
const role = useAuthStore(s => s.primaryRole);
// نوبتِ منبع فقط مسیر پنل را دارد؛ اندپوینت ادمین `resource_uuid` نمی‌شناسد.
const createEndpoint = role === 'admin' && resource === null
? '/api/v1/admin/appointment'
: '/api/v1/my/appointment';
// منبع تقویم خودش را دارد، پس نوبتش همیشه سرویسی است — اسلات ثابتی وجود ندارد
// که بشود رویش نشست.
const pickerMode = serviceMode || resource !== null;
// هزینه ویزیت — الزامی بودن از تنظیمات «الزامی کردن هزینه ویزیت». فیلد UI تومان،
// API ریالی (visit_price_rials). بدون این مقدار، وقتی فلگ فعال است backend خطای ۴۲۲ می‌دهد.
//
// قیمت باید از تنظیمات نوبت‌دهیِ *پزشکِ همین اسلات* بیاید، نه از entity کاربر جاری؛
// منشی/کلینیک قیمت خودشان را ندارند و فیلد صفر می‌ماند. اگر دسترسی به تنظیمات آن
// پزشک نبود (۴۰۳)، به تنظیمات خودِ کاربر برمی‌گردیم تا فلگ الزامی‌بودن از دست نرود.
type Pricing = { data: { free_visit_price_rials: number; require_visit_price: boolean } };
const doctorPricingQ = useQuery<Pricing>({
queryKey: ['insurance-pricing', slot.doctor_uuid],
queryFn: () => api.get(`/api/v1/insurance-pricing?doctor_uuid=${encodeURIComponent(slot.doctor_uuid)}`),
enabled: !!slot.doctor_uuid,
retry: false,
});
const staffQ = useQuery<ApiResponse<{ uuid: string; full_name: string }[]>>({
queryKey: ['staff-list'],
queryFn: () => api.get('/api/v1/staff'),
enabled: pickerMode,
staleTime: 60_000,
});
const selfPricingQ = useQuery<Pricing>({
queryKey: ['insurance-pricing'],
queryFn: () => api.get('/api/v1/insurance-pricing'),
enabled: doctorPricingQ.isError || !slot.doctor_uuid,
});
const pricing = (doctorPricingQ.data ?? selfPricingQ.data) as any;
const freeVisit = pricing?.data?.free_visit_price_rials ?? 0;
const requireVisit = pricing?.data?.require_visit_price ?? false;
const pricingLoading = doctorPricingQ.isLoading || selfPricingQ.isLoading;
const [visitPriceToman, setVisitPriceToman] = useState(0);
const [visitPriceTouched, setVisitPriceTouched] = useState(false);
useEffect(() => {
if (!visitPriceTouched && freeVisit > 0) setVisitPriceToman(rialToToman(freeVisit));
}, [freeVisit, visitPriceTouched]);
// هزینه ویزیت اختیاری داخل کلپسِ بسته می‌نشیند؛ وقتی الزامی است کلپس همیشه باز است.
const [visitPriceOpen, setVisitPriceOpen] = useState(false);
const visitPriceExpanded = requireVisit || visitPriceOpen;
const mobileValid = /^09\d{9}$/.test(mobile);
const nationalCodeValid = /^\d{10}$/.test(nationalCode);
// اعتبار کلید جستجو بسته به معیار انتخاب‌شده.
const searchValid = searchBy === 'mobile' ? mobileValid : nationalCodeValid;
const serviceTimingValid = !pickerMode || (pick.serviceUuids.length > 0 && !!pick.slot);
// یک بیمارِ یافت‌شده که کد ملی دارد، بدون فرم اضافی قابل استفاده است.
const foundWithNationalCode = !!lookup?.found && !!lookup.national_code;
const needsDetails = lookup !== null && !foundWithNationalCode; // یافت‌نشده، یا یافت‌شده بدون کد ملی
const effectiveName = foundWithNationalCode ? (lookup?.name ?? '') : patientName.trim();
const effectiveNationalCode = foundWithNationalCode ? (lookup?.national_code ?? '') : nationalCode;
const detailsValid = effectiveName.length >= 2 && effectiveNationalCode.length === 10;
const visitPriceValid = !requireVisit || visitPriceToman > 0;
const isValid = mobileValid && (foundWithNationalCode || (needsDetails && detailsValid)) && serviceTimingValid && visitPriceValid;
const search = useMutation({
mutationFn: () => {
const q = searchBy === 'mobile'
? `mobile=${encodeURIComponent(mobile)}`
: `national_code=${encodeURIComponent(nationalCode)}`;
return api.get(`/api/v1/my/appointment/patient-lookup?${q}`);
},
onSuccess: (res: any) => {
const data: PatientLookup = res?.data ?? { found: false };
setLookup(data);
setPatientName(data.found ? (data.name ?? '') : '');
// موبایل و کد ملیِ بیمارِ یافت‌شده را پر می‌کنیم تا ثبت مستقل از معیار جستجو کار کند.
if (data.found) {
if (data.mobile) setMobile(data.mobile);
setNationalCode(data.national_code ?? '');
} else if (searchBy === 'mobile') {
setNationalCode('');
}
},
onError: (e: any) => toast.error(e?.response?.data?.errors?.[0]?.message ?? e?.message ?? 'خطا در جستجو'),
});
const mutation = useMutation({
mutationFn: () => api.post(createEndpoint, {
...(slot.doctor_uuid ? { doctor_uuid: slot.doctor_uuid } : {}),
slot_start: pickerMode ? pick.slot!.start : slot.start,
slot_end: pickerMode ? pick.slot!.end : slot.end,
patient_mobile: mobile,
patient_name: effectiveName,
patient_national_code: effectiveNationalCode,
...(clinicUuid ? { clinic_uuid: clinicUuid } : {}),
...(staffUuid ? { staff_uuid: staffUuid } : {}),
...(pickerMode ? { service_item_uuids: pick.serviceUuids } : {}),
// منبع: مدت را سرور از سرویس‌های همین منبع می‌سازد، پس ساعت پایان حدس نیست.
...(resource ? {
resource_uuid: resource.uuid,
duration_from_services: true,
service_durations: pick.durations,
} : {}),
...(visitPriceToman > 0 ? { visit_price_rials: tomanToRial(visitPriceToman) } : {}),
}),
onSuccess: () => {
toast.success('نوبت با موفقیت ثبت شد');
onSuccess();
onClose();
},
onError: (e: any) => {
const msg = e?.response?.data?.errors?.[0]?.message ?? e?.message ?? 'خطا در ثبت نوبت';
toast.error(msg);
},
});
// تغییر کلید جستجو نتیجه‌ی جستجوی قبلی را باطل می‌کند تا کاربر دوباره جستجو کند.
function invalidateLookup() {
if (lookup !== null) { setLookup(null); setPatientName(''); }
}
function onMobileChange(v: string) {
// ارقام فارسی/عربی → انگلیسی، فقط رقم، حداکثر ۱۱ رقم (کیبورد فارسی هم پذیرفته می‌شود).
setMobile(sanitizeMobileInput(v));
if (lookup !== null) { setLookup(null); setPatientName(''); if (searchBy === 'mobile') setNationalCode(''); }
}
function onNationalSearchChange(v: string) {
setNationalCode(digitsOnly(v, 10));
invalidateLookup();
}
// جابه‌جایی معیار جستجو همه‌چیز را از نو شروع می‌کند.
function onSwitchSearchBy(mode: 'mobile' | 'national') {
setSearchBy(mode);
setLookup(null); setPatientName('');
setMobile(''); setNationalCode('');
}
// هدفِ نوبت به‌صورت «برچسب: مقدار» — دو اسمِ لخت کنار هم معلوم نمی‌کند کدام دستگاه
// است و کدام پزشک. تاریخ هم اینجاست چون تنها جای مودال بود که اصلاً دیده نمی‌شد.
const targetFacts: { label: string; value: string }[] = [
resource
? { label: 'دستگاه', value: resource.name }
: pickerMode
? { label: 'پزشک', value: slot.doctor_name }
: { label: 'ساعت', value: `${slot.start_time} تا ${slot.end_time}` },
...((resource || !pickerMode) && slot.doctor_name
? [{ label: resource ? 'پزشک ناظر' : 'پزشک', value: slot.doctor_name }]
: []),
...(date ? [{ label: 'تاریخ', value: formatDate(date) }] : []),
];
// اولین چیزی که جلوی ثبت را گرفته، به ترتیبِ همان مراحلِ فرم. دکمهٔ خاکستریِ
// بی‌توضیح یعنی کاربر باید حدس بزند چه چیزی کم است.
const blockReason = !serviceTimingValid
? (pick.serviceUuids.length === 0 ? 'یک سرویس انتخاب کنید' : 'ساعت شروع را انتخاب کنید')
: lookup === null
? 'ابتدا بیمار را جستجو کنید'
: needsDetails && !detailsValid
? 'مشخصات بیمار را کامل کنید'
: !mobileValid
? 'شماره موبایل بیمار معتبر نیست'
: !visitPriceValid
? 'هزینه ویزیت الزامی است'
: null;
const priceHint = pricingLoading
? 'در حال خواندن تعرفهٔ پزشک…'
: freeVisit > 0
? `تعرفهٔ نوبت‌دهی ${slot.doctor_name}: ${formatRial(freeVisit)} — در صورت نیاز تغییر دهید`
: 'برای این پزشک تعرفه‌ای ثبت نشده — در صورت نیاز مبلغ را وارد کنید';
return (
<Modal
open
title="ثبت نوبت"
size="sm"
onClose={onClose}
footer={
<>
<button className="btn ghost" onClick={onClose}>انصراف</button>
<button
className="btn primary"
onClick={() => mutation.mutate()}
disabled={!isValid || mutation.isPending}
>
{mutation.isPending ? 'در حال ثبت…' : 'ثبت نوبت'}
</button>
</>
}
>
{/* هدف نوبت: منبع، یا اسلات/پزشک */}
<div style={{
display: 'flex', alignItems: 'center', gap: 14, flexWrap: 'wrap', marginBottom: 18,
padding: '10px 14px', borderRadius: 'var(--r-sm)', background: 'var(--primary-soft)',
}}>
{resource
? <CpuChipIcon style={{ width: 18, height: 18, color: 'var(--primary-700)', flexShrink: 0 }} />
: <ClockIcon style={{ width: 18, height: 18, color: 'var(--primary-700)', flexShrink: 0 }} />}
{targetFacts.map(f => (
<span key={f.label} style={{ fontSize: 13 }}>
<span style={{ color: 'var(--text-3)' }}>{f.label}: </span>
<span style={{ color: 'var(--text)', fontWeight: 600 }}>{f.value}</span>
</span>
))}
</div>
{pickerMode && date && (
<Step n={1} title="خدمت و زمان">
<ServiceSlotPicker
doctorUuid={slot.doctor_uuid}
resourceUuid={resource?.uuid}
date={date}
services={services}
onSelect={setPick}
clinicUuidOverride={clinicUuid}
/>
{/* اختیاری به‌عمد: پیش‌فرضِ سیستم صفِ مشترک است و منشی فقط وقتی دخالت
می‌کند که بیمار اپراتور مشخصی خواسته باشد. */}
<div className="field-block" style={{ marginTop: 12 }}>
<label htmlFor="appt-operator">اپراتور <span className="opt">(اختیاری)</span></label>
<SearchableSelect
inputId="appt-operator"
options={(staffQ.data?.data ?? []).map(s => ({ value: s.uuid, label: s.full_name }))}
value={staffUuid || null}
onChange={v => setStaffUuid(v ? String(v) : '')}
placeholder="در صف مشترک پرسنل بماند"
isLoading={staffQ.isLoading}
isClearable
height={40}
/>
</div>
</Step>
)}
<Step n={pickerMode ? 2 : null} title="بیمار">
<div className="field-block" style={{ marginBottom: 14 }}>
<label htmlFor="appt-patient-search">جستجوی بیمار <span className="req">*</span></label>
{/* انتخاب معیار جستجو: موبایل یا کد ملی */}
<div className="seg" style={{ display: 'flex', marginBottom: 8 }}>
{(['national', 'mobile'] as const).map(mode => (
<button
key={mode}
type="button"
className={searchBy === mode ? 'on' : ''}
aria-pressed={searchBy === mode}
onClick={() => onSwitchSearchBy(mode)}
style={{ flex: 1, justifyContent: 'center' }}
>
{mode === 'mobile' ? 'شماره موبایل' : 'کد ملی'}
</button>
))}
</div>
<div style={{ display: 'flex', gap: 8 }}>
<div className="field" style={{ flex: 1 }}>
{searchBy === 'mobile' ? (
<input
id="appt-patient-search"
type="tel"
inputMode="numeric"
maxLength={11}
value={mobile}
onChange={e => onMobileChange(e.target.value)}
onKeyDown={e => { if (e.key === 'Enter' && searchValid && !search.isPending) search.mutate(); }}
placeholder="مثال: 09123456789"
style={{ direction: 'ltr' }}
autoFocus={!pickerMode}
/>
) : (
<input
id="appt-patient-search"
type="text"
inputMode="numeric"
lang="en"
maxLength={10}
value={nationalCode}
onChange={e => onNationalSearchChange(e.target.value)}
onKeyDown={e => { if (e.key === 'Enter' && searchValid && !search.isPending) search.mutate(); }}
placeholder="کد ملی ۱۰ رقمی"
style={{ direction: 'ltr' }}
// در حالت انتخابگر، اولین تصمیم «بخش» است نه بیمار؛ فوکوسِ خودکار
// اینجا کاربر را از مرحلهٔ یک رد می‌کرد.
autoFocus={!pickerMode}
/>
)}
</div>
<button
className="btn soft"
onClick={() => search.mutate()}
disabled={!searchValid || search.isPending}
style={{ whiteSpace: 'nowrap' }}
>
<MagnifyingGlassIcon style={{ width: 16, height: 16 }} />
{search.isPending ? '...' : 'جستجو'}
</button>
</div>
</div>
{foundWithNationalCode && (
<div style={{
marginBottom: 16, padding: '10px 14px', borderRadius: 'var(--r-sm)',
background: 'var(--success-bg)', fontSize: 13,
display: 'flex', alignItems: 'center', gap: 10,
}}>
<CheckCircleIcon style={{ width: 20, height: 20, color: 'var(--success)', flexShrink: 0 }} />
<div style={{ minWidth: 0 }}>
<div style={{ fontWeight: 700, color: 'var(--success)', fontSize: 12 }}>بیمار یافت شد</div>
<div style={{ fontWeight: 700, color: 'var(--text)' }}>{lookup?.name}</div>
<div style={{ color: 'var(--text-2)', fontSize: 12 }}>کد ملی: {lookup?.national_code}</div>
</div>
</div>
)}
{needsDetails && (
<>
<div style={{
fontSize: 12.5, color: 'var(--text-2)', marginBottom: 12,
padding: '9px 12px', borderRadius: 'var(--r-sm)', background: 'var(--warning-bg)',
}}>
{lookup?.found ? 'برای این بیمار کد ملی ثبت نشده — لطفاً تکمیل کنید:' : 'بیماری با این مشخصات یافت نشد — بیمار جدید:'}
</div>
<div className="field-block" style={{ marginBottom: 14 }}>
<label htmlFor="appt-patient-name">نام و نام خانوادگی بیمار <span className="req">*</span></label>
<div className="field">
<input
id="appt-patient-name"
type="text"
value={patientName}
onChange={e => setPatientName(e.target.value)}
placeholder="مثال: علی محمدی"
/>
</div>
</div>
{/* در جستجو با کد ملی، موبایل هنوز نامعلوم است و برای ثبت لازم می‌شود. */}
{searchBy === 'national' && (
<div className="field-block" style={{ marginBottom: 14 }}>
<label htmlFor="appt-patient-mobile">شماره موبایل بیمار <span className="req">*</span></label>
<div className="field">
<input
id="appt-patient-mobile"
type="tel"
inputMode="numeric"
maxLength={11}
value={mobile}
onChange={e => setMobile(sanitizeMobileInput(e.target.value))}
placeholder="مثال: 09123456789"
style={{ direction: 'ltr' }}
/>
</div>
</div>
)}
{/* در جستجو با کد ملی، همان مقدار کلیدِ جستجو استفاده می‌شود و فیلد تکراری لازم نیست. */}
{searchBy === 'mobile' && (
<div className="field-block" style={{ marginBottom: 14 }}>
<label htmlFor="appt-patient-national">کد ملی بیمار <span className="req">*</span></label>
<div className="field">
<input
id="appt-patient-national"
type="text"
inputMode="numeric"
lang="en"
maxLength={10}
value={nationalCode}
onChange={e => setNationalCode(digitsOnly(e.target.value, 10))}
placeholder="کد ملی ۱۰ رقمی"
style={{ direction: 'ltr' }}
/>
</div>
</div>
)}
</>
)}
</Step>
{/* هزینه ویزیت مرحلهٔ شماره‌دار نیست: در حالت اختیاری یک کلپسِ بسته است و
شماره دادن به آن، کاری اختیاری را اجباری نشان می‌داد. */}
<div className="field-block">
{requireVisit ? (
<label>
هزینه ویزیت (تومان)<span className="req"> *</span>
</label>
) : (
// سرِ کلپس — با کلیک باز/بسته می‌شود (فقط وقتی اختیاری است).
<button
type="button"
onClick={() => setVisitPriceOpen(o => !o)}
aria-expanded={visitPriceExpanded}
style={{
// سرِ کلپس تمام عرض و ۳۶px است: با padding صفر ارتفاعش اندازهٔ یک خط متن
// می‌شد و روی موبایل عملاً قابل زدن نبود.
display: 'flex', alignItems: 'center', gap: 6, width: '100%', minHeight: 36,
background: 'none', border: 'none', cursor: 'pointer', font: 'inherit',
padding: 0, color: 'var(--text)', textAlign: 'start',
}}
>
<span>هزینه ویزیت (تومان) <span className="opt">(اختیاری)</span></span>
<ChevronDownIcon
style={{
width: 15, height: 15, marginInlineStart: 'auto', flexShrink: 0,
transition: 'transform .2s var(--ease)',
transform: visitPriceExpanded ? 'rotate(180deg)' : 'none',
}}
/>
</button>
)}
{visitPriceExpanded && (
<>
<div
className="field"
style={requireVisit && visitPriceToman <= 0 ? { borderColor: 'var(--danger)' } : undefined}
>
<PriceInput
value={visitPriceToman}
onChange={(v) => { setVisitPriceToman(v); setVisitPriceTouched(true); }}
suffix="تومان"
/>
</div>
{requireVisit && visitPriceToman <= 0
? <span className="field-err">هزینه ویزیت الزامی است</span>
: <span className="field-hint">{priceHint}</span>}
{freeVisit > 0 && visitPriceToman !== rialToToman(freeVisit) && (
<button
type="button"
className="btn ghost sm"
style={{ marginTop: 8, alignSelf: 'flex-start' }}
onClick={() => { setVisitPriceToman(rialToToman(freeVisit)); setVisitPriceTouched(true); }}
>
استفاده از تعرفهٔ پزشک
</button>
)}
</>
)}
</div>
{blockReason && (
<div style={{
display: 'flex', alignItems: 'center', gap: 6, marginTop: 16,
fontSize: 12.5, color: 'var(--text-3)',
}}>
<ExclamationCircleIcon style={{ width: 15, height: 15, flexShrink: 0 }} />
برای ثبت نوبت: {blockReason}
</div>
)}
</Modal>
);
}
/** مرحلهٔ شماره‌دار فرم — ترتیب تصمیم‌ها را دیدنی می‌کند، نه فقط ترتیب فیلدها. */
function Step({ n, title, children }: { n: number | null; title: string; children: ReactNode }) {
return (
<section style={{ marginBottom: 18 }}>
<h3 style={{
display: 'flex', alignItems: 'center', gap: 8, margin: '0 0 10px',
fontSize: 13.5, fontWeight: 700, color: 'var(--text)',
}}>
{/* حالت اسلاتی فقط یک مرحله دارد؛ شمارهٔ «۱» تنها، نویز است نه راهنما. */}
{n !== null && (
<span style={{
display: 'grid', placeItems: 'center', width: 20, height: 20, borderRadius: 999,
background: 'var(--primary)', color: 'var(--on-primary)', fontSize: 11.5, flexShrink: 0,
}}>{n}</span>
)}
{title}
</h3>
{children}
</section>
);
}