693 lines
33 KiB
TypeScript
693 lines
33 KiB
TypeScript
import React, { 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 Stepper from '../ui/Stepper';
|
|
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;
|
|
}
|
|
|
|
type StepKey = 'service' | 'patient' | 'confirm';
|
|
|
|
const STEP_TITLES: Record<StepKey, string> = {
|
|
service: 'خدمت و زمان',
|
|
patient: 'بیمار',
|
|
confirm: 'تأیید و ثبت',
|
|
};
|
|
|
|
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 [stepIdx, setStepIdx] = useState(0);
|
|
|
|
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 stepKeys: StepKey[] = [
|
|
...(pickerMode && activeDate ? (['service'] as StepKey[]) : []),
|
|
...(patient === null ? (['patient'] as StepKey[]) : []),
|
|
'confirm',
|
|
];
|
|
const currentStep = stepKeys[Math.min(stepIdx, stepKeys.length - 1)];
|
|
const isLastStep = currentStep === 'confirm';
|
|
|
|
// اولین چیزی که جلوی رفتن به مرحلهٔ بعد (یا ثبت) را گرفته. دکمهٔ خاکستریِ
|
|
// بیتوضیح یعنی کاربر باید حدس بزند چه چیزی کم است.
|
|
const stepBlockReason: Record<StepKey, string | null> = {
|
|
service: !serviceTimingValid
|
|
? (pick.serviceUuids.length === 0 ? 'یک سرویس انتخاب کنید' : 'ساعت شروع را انتخاب کنید')
|
|
: null,
|
|
patient: lookup === null
|
|
? 'ابتدا بیمار را جستجو کنید'
|
|
: needsDetails && !detailsValid
|
|
? 'مشخصات بیمار را کامل کنید'
|
|
: !mobileValid
|
|
? 'شماره موبایل بیمار معتبر نیست'
|
|
: null,
|
|
confirm: !visitPriceValid ? 'هزینه ویزیت الزامی است' : null,
|
|
};
|
|
const blockReason = stepBlockReason[currentStep];
|
|
|
|
// خلاصهٔ مرحلهٔ آخر: همان چیزی که ثبت میشود، پیش از ثبت.
|
|
const pickedServiceNames = pick.serviceUuids
|
|
.map(uuid => services.find(sv => sv.uuid === uuid)?.name ?? uuid);
|
|
const staffName = (staffQ.data?.data ?? []).find(st => st.uuid === staffUuid)?.full_name ?? null;
|
|
const summaryRows: { label: string; value: string }[] = [
|
|
...targetFacts.map(f => ({ label: f.label, value: f.value })),
|
|
...(pickerMode && activeDate ? [{ label: 'تاریخ', value: formatDate(activeDate) }] : []),
|
|
...(pickerMode && pick.slot ? [{ label: 'ساعت', value: pick.slot.start_time }] : []),
|
|
...(pickedServiceNames.length > 0 ? [{ label: 'سرویس', value: pickedServiceNames.join('، ') }] : []),
|
|
...(staffName ? [{ label: 'پرسنل', value: staffName }] : []),
|
|
{ label: 'بیمار', value: effectiveName || (patient?.name ?? '—') },
|
|
{ label: 'موبایل', value: mobile || (patient?.mobile ?? '—') },
|
|
];
|
|
|
|
const priceHint = pricingLoading
|
|
? 'در حال خواندن تعرفهٔ پزشک…'
|
|
: freeVisit > 0
|
|
? `تعرفهٔ نوبتدهی ${slot.doctor_name}: ${formatRial(freeVisit)} — در صورت نیاز تغییر دهید`
|
|
: 'برای این پزشک تعرفهای ثبت نشده — در صورت نیاز مبلغ را وارد کنید';
|
|
|
|
return (
|
|
<Modal
|
|
open
|
|
title="ثبت نوبت"
|
|
// هر بار یک مرحله دیده میشود، پس ستون تکی کافی است؛ `md` عرضِ راحتِ
|
|
// انتخابگر سرویس و اسلات است بدون فضای خالی.
|
|
size="md"
|
|
onClose={onClose}
|
|
footer={
|
|
<>
|
|
{/* «قبلی» جای «انصراف» را نمیگیرد: بستنِ فرم همیشه باید یک کلیک باشد. */}
|
|
<button className="btn ghost" onClick={onClose}>انصراف</button>
|
|
{stepIdx > 0 && (
|
|
<button className="btn secondary" onClick={() => setStepIdx(i => i - 1)}>
|
|
مرحلهٔ قبل
|
|
</button>
|
|
)}
|
|
{isLastStep ? (
|
|
<button
|
|
className="btn primary"
|
|
onClick={() => mutation.mutate()}
|
|
disabled={!isValid || mutation.isPending}
|
|
>
|
|
{mutation.isPending ? 'در حال ثبت…' : 'ثبت نوبت'}
|
|
</button>
|
|
) : (
|
|
<button
|
|
className="btn primary"
|
|
onClick={() => setStepIdx(i => i + 1)}
|
|
disabled={blockReason !== null}
|
|
>
|
|
مرحلهٔ بعد
|
|
</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>
|
|
|
|
{stepKeys.length > 1 && (
|
|
<Stepper
|
|
steps={stepKeys.map(key => ({ key, title: STEP_TITLES[key] }))}
|
|
current={currentStep}
|
|
ariaLabel="مراحل ثبت نوبت"
|
|
/>
|
|
)}
|
|
|
|
{currentStep === 'service' && (
|
|
<Step title={STEP_TITLES.service}>
|
|
<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>
|
|
)}
|
|
|
|
{currentStep === 'patient' && (
|
|
<Step title={STEP_TITLES.patient}>
|
|
{/* بیمارِ از پیش معلوم: فقط تأیید میشود، جستجو لازم نیست. */}
|
|
{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>
|
|
)}
|
|
|
|
{currentStep === 'confirm' && (
|
|
<Step title={STEP_TITLES.confirm}>
|
|
{/* خلاصهٔ همان چیزی که ثبت میشود — تنها جایی که کاربر پیش از ثبت، انتخاب
|
|
سرویس و زمان و بیمار را با هم میبیند. */}
|
|
<dl style={{
|
|
display: 'grid', gridTemplateColumns: 'auto 1fr', gap: '8px 14px', margin: '0 0 18px',
|
|
padding: '12px 14px', borderRadius: 'var(--r-sm)', background: 'var(--surface-2)',
|
|
fontSize: 13,
|
|
}}>
|
|
{summaryRows.map(row => (
|
|
<React.Fragment key={row.label}>
|
|
<dt style={{ color: 'var(--text-3)' }}>{row.label}</dt>
|
|
<dd style={{ margin: 0, color: 'var(--text)', fontWeight: 600, minWidth: 0, overflowWrap: 'anywhere' }}>
|
|
{row.value}
|
|
</dd>
|
|
</React.Fragment>
|
|
))}
|
|
</dl>
|
|
|
|
{/* هزینه ویزیت در حالت اختیاری یک کلپسِ بسته است. */}
|
|
<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>
|
|
</Step>
|
|
)}
|
|
|
|
{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 }} />
|
|
{isLastStep ? 'برای ثبت نوبت' : 'برای مرحلهٔ بعد'}: {blockReason}
|
|
</div>
|
|
)}
|
|
</Modal>
|
|
);
|
|
}
|
|
|
|
/** بدنهٔ یک مرحله — عنوانش در `Stepper` هم هست، اینجا سرِ همان بخش است. */
|
|
function Step({ title, children }: { title: string; children: ReactNode }) {
|
|
return (
|
|
<section style={{ marginBottom: 18 }}>
|
|
<h3 style={{
|
|
margin: '0 0 10px', fontSize: 13.5, fontWeight: 700, color: 'var(--text)',
|
|
}}>
|
|
{title}
|
|
</h3>
|
|
{children}
|
|
</section>
|
|
);
|
|
}
|
|
|