The modal took its date from the caller and had no way to change it. Opened from a session card, that meant the projected date or nothing: if the device had no free time that day the user had to close a form they had already filled with a patient and a service, go elsewhere, and start again. The date is now a field in step one, seeded from the prop and reset when the prop changes. ServiceSlotPicker already clears the picked slot when its date changes, so the times reload for the new day on their own. The header chip drops the date whenever that field is on screen; showing the same value twice, one editable and one not, invites the reader to trust the wrong one. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
610 lines
30 KiB
TypeScript
610 lines
30 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 PersianDateInput from '../ui/PersianDateInput';
|
|
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,
|
|
treatmentSessionUuid = null, patient = null,
|
|
}: {
|
|
slot: BookingSlot;
|
|
onClose: () => void;
|
|
onSuccess: () => void;
|
|
serviceMode?: boolean;
|
|
services?: BookingService[];
|
|
date?: string;
|
|
/** محل نوبت — بدون آن backend نوبت را به مطب شخصی نسبت میدهد. */
|
|
clinicUuid?: string | null;
|
|
resource?: BookingResource | null;
|
|
/**
|
|
* رزروِ صریحِ یک جلسهٔ درمان. بیمار میتواند چند دورهٔ باز داشته باشد، پس بدون این،
|
|
* اتصال از روی سرویس حدس زده میشود و سرویسِ اشتباه یک پروندهٔ موازی میسازد.
|
|
*/
|
|
treatmentSessionUuid?: string | null;
|
|
/**
|
|
* بیمارِ از پیش معلوم — وقتی مودال از پروندهٔ خودِ بیمار باز میشود.
|
|
*
|
|
* مرحلهٔ جستجو رد میشود: کسی که پروندهٔ بیمار را باز کرده نباید همان بیمار را
|
|
* دوباره با کد ملی پیدا کند.
|
|
*/
|
|
patient?: { name: string | null; mobile: string; national_code: string | null } | null;
|
|
}) {
|
|
const [mobile, setMobile] = useState(patient?.mobile ?? '');
|
|
const [lookup, setLookup] = useState<PatientLookup | null>(
|
|
patient === null ? null : { found: true, name: patient.name, mobile: patient.mobile, national_code: patient.national_code },
|
|
);
|
|
const [patientName, setPatientName] = useState(patient?.name ?? '');
|
|
const [nationalCode, setNationalCode] = useState(patient?.national_code ?? '');
|
|
// معیار جستجوی بیمار: کد ملی (پیشفرض) یا موبایل.
|
|
const [searchBy, setSearchBy] = useState<'mobile' | 'national'>('national');
|
|
const [pick, setPick] = useState<ServicePick>({ serviceUuids: [], durations: {}, slot: null });
|
|
// اپراتور اختیاری است: خالی گذاشتنش جلسه را در صفِ مشترکِ پرسنلِ مجاز میگذارد،
|
|
// پر کردنش آن را از قبل به یک نفر میدهد.
|
|
const [staffUuid, setStaffUuid] = useState('');
|
|
/**
|
|
* تاریخ داخل خودِ مودال قابل ویرایش است.
|
|
*
|
|
* prop فقط نقطهٔ شروع را میدهد — تاریخِ کارتِ جلسه یا روزِ انتخابشدهٔ صفحه. ولی
|
|
* وقت پیشنهادی همیشه در دسترس نیست و کاربر باید بتواند همانجا روز دیگری بگیرد،
|
|
* بدون بستن فرمی که بیمار و سرویسش را پر کرده.
|
|
*/
|
|
const [activeDate, setActiveDate] = useState(date ?? '');
|
|
useEffect(() => { setActiveDate(date ?? ''); }, [date]);
|
|
// پیشفرضِ پروتکل فقط تا وقتی اعمال میشود که کاربر دست نزده باشد؛ وگرنه انتخاب
|
|
// دستیِ منشی با هر تغییرِ سرویس پاک میشد.
|
|
const [staffTouched, setStaffTouched] = useState(false);
|
|
|
|
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 primaryService = pick.serviceUuids[0] ?? '';
|
|
const protocolQ = useQuery<ApiResponse<{ staff: { uuid: string; name: string }[] } | null>>({
|
|
queryKey: ['service-protocol', primaryService],
|
|
queryFn: () => api.get(`/api/v1/service-item/${primaryService}/treatment-protocol`),
|
|
enabled: primaryService !== '',
|
|
staleTime: 60_000,
|
|
});
|
|
|
|
const protocolStaff = protocolQ.data?.data?.staff ?? [];
|
|
|
|
useEffect(() => {
|
|
if (staffTouched || protocolStaff.length === 0) return;
|
|
|
|
setStaffUuid(protocolStaff[0].uuid);
|
|
}, [protocolStaff, staffTouched]);
|
|
|
|
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 } : {}),
|
|
...(treatmentSessionUuid ? { treatment_session_uuid: treatmentSessionUuid } : {}),
|
|
...(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 }]
|
|
: []),
|
|
/* در حالت انتخابگر، خودِ فیلدِ تاریخ همین را میگوید و تکرارش در هدر نویز است. */
|
|
...(activeDate && !pickerMode ? [{ label: 'تاریخ', value: formatDate(activeDate) }] : []),
|
|
];
|
|
|
|
// اولین چیزی که جلوی ثبت را گرفته، به ترتیبِ همان مراحلِ فرم. دکمهٔ خاکستریِ
|
|
// بیتوضیح یعنی کاربر باید حدس بزند چه چیزی کم است.
|
|
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 && activeDate && (
|
|
<Step n={1} title="خدمت و زمان">
|
|
<div className="field-block" style={{ marginBottom: 12, maxWidth: 220 }}>
|
|
<label htmlFor="appt-date">تاریخ نوبت</label>
|
|
<PersianDateInput
|
|
value={activeDate}
|
|
onChange={setActiveDate}
|
|
ariaLabel="تاریخ نوبت"
|
|
/>
|
|
</div>
|
|
|
|
<ServiceSlotPicker
|
|
doctorUuid={slot.doctor_uuid}
|
|
resourceUuid={resource?.uuid}
|
|
date={activeDate}
|
|
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) : ''); setStaffTouched(true); }}
|
|
placeholder={protocolQ.isLoading ? 'در حال خواندن پرسنل سرویس…' : 'در صف مشترک پرسنل بماند'}
|
|
isLoading={staffQ.isLoading}
|
|
isClearable
|
|
height={40}
|
|
/>
|
|
</div>
|
|
</Step>
|
|
)}
|
|
|
|
<Step n={pickerMode ? 2 : null} title="بیمار">
|
|
{/* بیمارِ از پیش معلوم: فقط تأیید میشود، جستجو لازم نیست. */}
|
|
{patient !== null && (
|
|
<div style={{
|
|
marginBottom: 14, padding: '10px 14px', borderRadius: 'var(--r-sm)',
|
|
background: 'var(--success-bg)', display: 'flex', alignItems: 'center', gap: 10, fontSize: 13,
|
|
}}>
|
|
<CheckCircleIcon style={{ width: 20, height: 20, color: 'var(--success)', flexShrink: 0 }} />
|
|
<div style={{ minWidth: 0 }}>
|
|
<div style={{ fontWeight: 700, color: 'var(--text)' }}>{patient.name || 'بدون نام'}</div>
|
|
<div style={{ color: 'var(--text-2)', fontSize: 12, direction: 'ltr', textAlign: 'start' }}>
|
|
{patient.mobile}{patient.national_code ? ` · ${patient.national_code}` : ''}
|
|
</div>
|
|
</div>
|
|
</div>
|
|
)}
|
|
|
|
<div className="field-block" style={{ marginBottom: 14, display: patient === null ? undefined : 'none' }}>
|
|
<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>
|
|
|
|
{/* وقتی بیمار از قبل معلوم است، کارت بالا همین را میگوید؛ دو بار گفتنش نویز است. */}
|
|
{patient === null && 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>
|
|
);
|
|
}
|