The staff field started empty even when the service's treatment protocol already named who may perform it — a decision made once in the service settings and then asked again on every booking. The modal now reads that protocol and preselects its first staff member, but only until the user touches the field; otherwise a manual choice would be wiped on the next service change. A protocol with no staff leaves it empty and does not block submission. Renames the four user-facing 'اپراتور' strings to 'پرسنل', matching the record in /admin/staff that they all refer to. ResourcesPage keeps the word: there it names a kind of bookable resource (doctor, operator, room, device), not a ClinicStaff row. Drops a test whose premise the default invalidated; the two new ones cover both sides — protocol with staff sends staff_uuid, protocol without staff sends none. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
564 lines
27 KiB
TypeScript
564 lines
27 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,
|
|
treatmentSessionUuid = null,
|
|
}: {
|
|
slot: BookingSlot;
|
|
onClose: () => void;
|
|
onSuccess: () => void;
|
|
serviceMode?: boolean;
|
|
services?: BookingService[];
|
|
date?: string;
|
|
/** محل نوبت — بدون آن backend نوبت را به مطب شخصی نسبت میدهد. */
|
|
clinicUuid?: string | null;
|
|
resource?: BookingResource | null;
|
|
/**
|
|
* رزروِ صریحِ یک جلسهٔ درمان. بیمار میتواند چند دورهٔ باز داشته باشد، پس بدون این،
|
|
* اتصال از روی سرویس حدس زده میشود و سرویسِ اشتباه یک پروندهٔ موازی میسازد.
|
|
*/
|
|
treatmentSessionUuid?: string | 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 [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 }]
|
|
: []),
|
|
...(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) : ''); setStaffTouched(true); }}
|
|
placeholder={protocolQ.isLoading ? 'در حال خواندن پرسنل سرویس…' : 'در صف مشترک پرسنل بماند'}
|
|
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>
|
|
);
|
|
}
|