Users typing on a Persian keyboard produced two distinct failures. Fields with type="number" silently returned an empty string — the browser rejects Persian digits, so the value was lost and saved as empty or zero. Text fields passed the Persian characters straight through to the database, where a mobile stored as ۰۹۱۲… never matches 09… again. The secretary form hit the second case with no validation at all. Frontend: - Adds digitsOnly() and the national-code schemas to lib/utils, plus lib/forms with numericField()/latinDigitsField() wrappers for React Hook Form fields. - Converts every type="number" input to type="text" inputMode="numeric" with digit normalization; none remain. Fields that legitimately carry non-digits (sheba, landline) only get the digits translated, keeping IR and separators. - Points the patient national-code and mobile schemas at the shared normalizing schemas, which accept Persian input instead of rejecting it. - Drops two duplicate local digit converters in favour of the shared helper. Backend: - Adds NumericFieldNormalizerSubscriber, translating digits in whitelisted numeric keys of JSON request bodies under /api/v1/ before controllers run, so nobat724_front and clinic-pro-tauri are covered too. Translation only — no characters are stripped, non-string values and other keys are untouched. Three component tests asserted on role="spinbutton" and numeric input values; both are properties of type="number", so they were updated to match the new text inputs. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
520 lines
28 KiB
TypeScript
520 lines
28 KiB
TypeScript
import { useEffect, useMemo, useState } from 'react';
|
||
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
|
||
import { useNavigate, useSearchParams } from 'react-router-dom';
|
||
import { PlusIcon, ChevronRightIcon, MagnifyingGlassIcon } from '@heroicons/react/24/outline';
|
||
import { toast } from 'sonner';
|
||
import { api } from '../lib/api';
|
||
import type { ApiResponse } from '../lib/api';
|
||
import { useAuthStore } from '../stores/authStore';
|
||
import PersianDateInput from '../components/ui/PersianDateInput';
|
||
import PriceInput from '../components/ui/PriceInput';
|
||
import DigitInput from '../components/ui/DigitInput';
|
||
import SearchableSelect from '../components/ui/SearchableSelect';
|
||
import { WalletChargeLink } from '../components/AppointmentActions';
|
||
import { useDoctorBookingServices } from '../hooks/useDoctorBookingServices';
|
||
import ServiceSlotPicker from '../components/appointments/ServiceSlotPicker';
|
||
import { tehranWallClockToUnix, rialToToman, tomanToRial } from '../lib/utils';
|
||
import { digitsOnly } from '../lib/utils';
|
||
|
||
/**
|
||
* افزودن نوبت — صفحهٔ کامل (بازسازی `CreateTurn.jsx` طرح tauri). از همان endpointهای
|
||
* موجود استفاده میکند: `POST /api/v1/my/appointment` (یا admin) + در صورت نیاز
|
||
* `PATCH .../status`. هیچ endpoint جدیدی ساخته نشده است.
|
||
*/
|
||
|
||
interface Option { uuid: string; name?: string; full_name?: string }
|
||
interface PatientRow { uuid: string; user_name?: string; user_mobile?: string; user_national_code?: string }
|
||
|
||
const toEpoch = (isoDate: string, time: string) => tehranWallClockToUnix(isoDate, time);
|
||
const addMinutes = (time: string, min: number) => {
|
||
const [h, m] = time.split(':').map(Number);
|
||
const t = h * 60 + m + min;
|
||
return `${String(Math.floor(t / 60) % 24).padStart(2, '0')}:${String(t % 60).padStart(2, '0')}`;
|
||
};
|
||
|
||
const label = { fontSize: 12.5, color: 'var(--text-3)', display: 'block' } as const;
|
||
const sectionTitle: React.CSSProperties = { fontSize: 18, fontWeight: 700, color: 'var(--text)', margin: '22px 0 14px' };
|
||
|
||
export default function AppointmentCreatePage() {
|
||
const navigate = useNavigate();
|
||
const [params] = useSearchParams();
|
||
const qc = useQueryClient();
|
||
const primaryRole = useAuthStore(s => s.primaryRole);
|
||
const dbUuid = useAuthStore(s => s.dbUuid);
|
||
const isDoctor = primaryRole === 'doctor';
|
||
|
||
const today = new Date().toISOString().slice(0, 10);
|
||
|
||
// ── پزشک
|
||
const [doctorUuid, setDoctorUuid] = useState(isDoctor && dbUuid ? dbUuid : (params.get('doctor') ?? ''));
|
||
const clinicDoctorsQuery = useQuery<ApiResponse<{ data: { uuid: string; name: string }[] }>>({
|
||
queryKey: ['clinic-doctors', dbUuid],
|
||
queryFn: () => api.get(`/api/v1/clinic/doctor-list/${dbUuid}`),
|
||
enabled: !isDoctor && !!dbUuid,
|
||
});
|
||
const doctorOptions = (clinicDoctorsQuery.data?.data?.data ?? []).map(d => ({ value: d.uuid, label: d.name }));
|
||
|
||
// ── بیمار: جستجوی رکورد موجود یا ورود شخص جدید
|
||
const [patientSearch, setPatientSearch] = useState('');
|
||
const [picked, setPicked] = useState<PatientRow | null>(null);
|
||
const [newPatient, setNewPatient] = useState(false); // فرم «مراجعه کننده جدید» فعال است؟
|
||
const [name, setName] = useState('');
|
||
const [mobile, setMobile] = useState('');
|
||
const [nationalCode, setNationalCode] = useState('');
|
||
const patientsQ = useQuery<ApiResponse<PatientRow[]>>({
|
||
queryKey: ['create-patients', patientSearch],
|
||
queryFn: () => api.get(`/api/v1/patients?search=${encodeURIComponent(patientSearch)}&limit=10`),
|
||
enabled: patientSearch.trim().length >= 2,
|
||
});
|
||
const patients = useMemo(() => patientsQ.data?.data ?? [], [patientsQ.data]);
|
||
|
||
// ── مشخصات سرویس
|
||
const [sectionUuid, setSectionUuid] = useState(''); // بخشِ در حال مرور (برای دیدن سرویسهایش)
|
||
// سرویسهای انتخابشده — انباشته از چند بخش؛ هر کدام قابل حذف. نام را نگه میداریم تا در chip نشان دهیم.
|
||
const [selectedServices, setSelectedServices] = useState<{ uuid: string; name: string }[]>([]);
|
||
const [staffUuid, setStaffUuid] = useState('');
|
||
const toggleServiceItem = (uuid: string, name: string) =>
|
||
setSelectedServices(prev => prev.some(s => s.uuid === uuid)
|
||
? prev.filter(s => s.uuid !== uuid)
|
||
: [...prev, { uuid, name }]);
|
||
const removeService = (uuid: string) =>
|
||
setSelectedServices(prev => prev.filter(s => s.uuid !== uuid));
|
||
const sectionsQ = useQuery<ApiResponse<Option[]>>({ queryKey: ['service-sections'], queryFn: () => api.get('/api/v1/service-sections') });
|
||
const itemsQ = useQuery<ApiResponse<Option[]>>({
|
||
queryKey: ['service-items', sectionUuid],
|
||
queryFn: () => api.get(`/api/v1/service-items/${sectionUuid}`),
|
||
enabled: !!sectionUuid,
|
||
});
|
||
const staffQ = useQuery<ApiResponse<Option[]>>({ queryKey: ['staff-list'], queryFn: () => api.get('/api/v1/staff') });
|
||
|
||
// ── روش نوبتدهی پزشک (سرویسی/اسلاتی)
|
||
const { bookingMode, services } = useDoctorBookingServices(doctorUuid);
|
||
const serviceMode = bookingMode === 'service';
|
||
const [servicePick, setServicePick] = useState<{ serviceUuids: string[]; durations: Record<string, number>; slot: { start: number; end: number } | null }>({ serviceUuids: [], durations: {}, slot: null });
|
||
|
||
// ── زمان نوبت
|
||
const [date, setDate] = useState(params.get('date') || today);
|
||
const [duration, setDuration] = useState(40);
|
||
const [start, setStart] = useState('15:00');
|
||
const [end, setEnd] = useState(addMinutes('15:00', 40));
|
||
useEffect(() => { setEnd(addMinutes(start, duration)); }, [start, duration]);
|
||
|
||
// ── بیعانه / وضعیت / توضیحات
|
||
const [depositRequired, setDepositRequired] = useState(false);
|
||
const [depositToman, setDepositToman] = useState(0);
|
||
const [status, setStatus] = useState('pending');
|
||
const [note, setNote] = useState('');
|
||
|
||
// ── هزینه ویزیت — الزامی بودن از تنظیمات «الزامی کردن هزینه ویزیت» (کاربر بدون
|
||
// پروفایل doctor/clinic از این endpoint 403 میگیرد → فلگ false و فیلد اختیاری میماند)
|
||
const pricingQ = useQuery<{ data: { free_visit_price_rials: number; require_visit_price: boolean } }>({
|
||
queryKey: ['insurance-pricing'],
|
||
queryFn: () => api.get('/api/v1/insurance-pricing'),
|
||
});
|
||
const freeVisit = (pricingQ.data as any)?.data?.free_visit_price_rials ?? 0;
|
||
const requireVisit = (pricingQ.data as any)?.data?.require_visit_price ?? false;
|
||
// فیلد UI تومان است؛ API ریالی (visit_price_rials).
|
||
const [visitPriceToman, setVisitPriceToman] = useState(0);
|
||
const [visitPriceTouched, setVisitPriceTouched] = useState(false);
|
||
useEffect(() => {
|
||
if (!visitPriceTouched && freeVisit > 0) setVisitPriceToman(rialToToman(freeVisit));
|
||
}, [freeVisit, visitPriceTouched]);
|
||
|
||
const effectiveName = picked?.user_name || name.trim();
|
||
const effectiveMobile = picked?.user_mobile || mobile.trim();
|
||
const effectiveNationalCode = (picked?.user_national_code || nationalCode).replace(/\D/g, '');
|
||
const timingValid = serviceMode
|
||
? (servicePick.serviceUuids.length > 0 && !!servicePick.slot)
|
||
: (!!start && !!end);
|
||
const valid = !!doctorUuid && !!date && effectiveName.length >= 2 && effectiveMobile.length >= 10
|
||
&& effectiveNationalCode.length === 10 && timingValid && (!requireVisit || visitPriceToman > 0);
|
||
|
||
const create = useMutation({
|
||
mutationFn: async () => {
|
||
const createEndpoint = primaryRole === 'admin' ? '/api/v1/admin/appointment' : '/api/v1/my/appointment';
|
||
const payload: Record<string, unknown> = {
|
||
doctor_uuid: doctorUuid,
|
||
slot_start: serviceMode ? servicePick.slot!.start : toEpoch(date, start),
|
||
slot_end: serviceMode ? servicePick.slot!.end : toEpoch(date, end),
|
||
patient_name: effectiveName,
|
||
patient_mobile: effectiveMobile,
|
||
patient_national_code: effectiveNationalCode,
|
||
...(serviceMode
|
||
? { service_item_uuids: servicePick.serviceUuids, duration_from_services: true, service_durations: servicePick.durations }
|
||
: {
|
||
...(sectionUuid ? { service_section_uuid: sectionUuid } : {}),
|
||
...(selectedServices.length ? { service_item_uuids: selectedServices.map(s => s.uuid) } : {}),
|
||
}),
|
||
...(staffUuid ? { staff_uuid: staffUuid } : {}),
|
||
...(depositRequired ? { deposit_required: true, deposit_amount_rials: tomanToRial(depositToman) } : {}),
|
||
...(visitPriceToman > 0 ? { visit_price_rials: tomanToRial(visitPriceToman) } : {}),
|
||
...(note.trim() ? { note: note.trim() } : {}),
|
||
};
|
||
const res: any = await api.post(createEndpoint, payload);
|
||
if (status !== 'pending' && res?.data?.uuid) {
|
||
await api.patch(`/api/v1/appointment/${res.data.uuid}/status`, { status, version: 1 });
|
||
}
|
||
return res;
|
||
},
|
||
onSuccess: () => {
|
||
qc.invalidateQueries({ queryKey: ['appointments'] });
|
||
toast.success('نوبت با موفقیت ثبت شد');
|
||
// به همان روزِ نوبت برگرد، نه امروز.
|
||
navigate(`/admin/appointments?date=${date}`);
|
||
},
|
||
onError: (e: any) => toast.error(e.message || 'خطا در ثبت نوبت'),
|
||
});
|
||
|
||
return (
|
||
<div style={{ padding: '20px 24px', maxWidth: 1080, margin: '0 auto' }}>
|
||
{/* بردکرامب */}
|
||
<div style={{ display: 'flex', alignItems: 'center', gap: 8, marginBottom: 18 }}>
|
||
<button onClick={() => navigate(-1)} className="btn sm" style={{ display: 'flex', alignItems: 'center', gap: 4 }}>
|
||
<ChevronRightIcon style={{ width: 15, height: 15 }} /> بازگشت
|
||
</button>
|
||
<span style={{ fontSize: 13, color: 'var(--text-3)' }}>نوبت ها</span>
|
||
<span style={{ color: 'var(--text-3)' }}>›</span>
|
||
<span style={{ fontSize: 15, fontWeight: 700, color: 'var(--text)' }}>ثبت نوبت جدید</span>
|
||
</div>
|
||
|
||
<div style={{ background: 'var(--surface)', border: '1px solid var(--border-2)', borderRadius: 'var(--r-sm)', padding: 28 }}>
|
||
{/* پزشک — فقط برای admin/clinic */}
|
||
{!isDoctor && (
|
||
<>
|
||
<div style={{ ...sectionTitle, marginTop: 0 }}>پزشک:</div>
|
||
<label style={label}>انتخاب پزشک</label>
|
||
<div style={{ margin: '6px 0 4px', maxWidth: 400 }}>
|
||
<SearchableSelect
|
||
options={doctorOptions}
|
||
value={doctorUuid || null}
|
||
onChange={(v) => setDoctorUuid(v ? String(v) : '')}
|
||
placeholder="انتخاب پزشک..."
|
||
isClearable
|
||
height={44}
|
||
/>
|
||
</div>
|
||
</>
|
||
)}
|
||
|
||
{/* مراجعه کننده */}
|
||
<div style={{ ...sectionTitle, marginTop: isDoctor ? 0 : 18 }}>اطلاعات مراجعه کننده :</div>
|
||
|
||
{/* حالت جستجوی مراجعهکنندهٔ موجود */}
|
||
{!newPatient && (
|
||
<>
|
||
<label style={label}>انتخاب مراجعه کننده</label>
|
||
<div style={{ display: 'flex', flexWrap: 'wrap', alignItems: 'center', gap: 12, margin: '6px 0 8px' }}>
|
||
<div className="field" style={{ width: 400, maxWidth: '100%' }}>
|
||
<MagnifyingGlassIcon style={{ width: 18, height: 18, color: 'var(--text-3)', flexShrink: 0 }} />
|
||
<input value={picked ? `${picked.user_name ?? ''} — ${picked.user_mobile ?? ''}` : patientSearch}
|
||
onChange={e => { setPicked(null); setPatientSearch(e.target.value); }}
|
||
placeholder="جستجوی نام، شماره تماس، شماره پرونده..." />
|
||
</div>
|
||
<button type="button" className="btn outline"
|
||
onClick={() => { setPicked(null); setPatientSearch(''); setNewPatient(true); }}
|
||
style={{ height: 44, minWidth: 161, gap: 6 }}>
|
||
<PlusIcon style={{ width: 16, height: 16 }} /> مراجعه کننده جدید
|
||
</button>
|
||
</div>
|
||
{!picked && patients.length > 0 && (
|
||
<div style={{ border: '1px solid var(--border)', borderRadius: 'var(--r-sm)', marginBottom: 10, overflow: 'hidden', maxWidth: 400 }}>
|
||
{patients.map(p => (
|
||
<button key={p.uuid}
|
||
onClick={() => { setPicked(p); setNationalCode((p.user_national_code ?? '').replace(/\D/g, '').slice(0, 10)); }}
|
||
style={{
|
||
display: 'block', width: '100%', padding: '8px 10px', fontSize: 13, textAlign: 'right',
|
||
background: 'transparent', border: 'none', cursor: 'pointer', fontFamily: 'inherit',
|
||
}}>
|
||
{p.user_name} <span style={{ color: 'var(--text-3)', direction: 'ltr' }}>{p.user_mobile}</span>
|
||
</button>
|
||
))}
|
||
</div>
|
||
)}
|
||
|
||
{/* کارت بیمارِ انتخابشده + کد ملی (برای ثبت نوبت الزامی است) */}
|
||
{picked && (
|
||
<div style={{ maxWidth: 400, border: '1px solid var(--border-2)', borderRadius: 'var(--r-sm)', padding: 12, marginBottom: 8 }}>
|
||
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', marginBottom: 10 }}>
|
||
<span style={{ fontSize: 13.5, fontWeight: 600 }}>
|
||
{picked.user_name} <span style={{ color: 'var(--text-3)', direction: 'ltr', fontWeight: 400 }}>{picked.user_mobile}</span>
|
||
</span>
|
||
<button type="button" className="btn sm ghost"
|
||
onClick={() => { setPicked(null); setNationalCode(''); }}>تغییر</button>
|
||
</div>
|
||
{/^\d{10}$/.test((picked.user_national_code ?? '').replace(/\D/g, '')) ? (
|
||
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', fontSize: 13 }}>
|
||
<span style={{ color: 'var(--text-3)' }}>کد ملی</span>
|
||
<span dir="ltr" style={{ fontWeight: 600 }}>{nationalCode}</span>
|
||
</div>
|
||
) : (
|
||
<>
|
||
<label style={label}>کد ملی (ثبتنشده — وارد کنید)</label>
|
||
<div className="field" style={{ marginTop: 6, height: 44 }}>
|
||
<DigitInput value={nationalCode} onChange={setNationalCode} maxDigits={10}
|
||
placeholder="کد ملی مراجعه کننده" />
|
||
</div>
|
||
</>
|
||
)}
|
||
</div>
|
||
)}
|
||
</>
|
||
)}
|
||
|
||
{/* حالت ثبت مراجعهکنندهٔ جدید */}
|
||
{newPatient && (
|
||
<>
|
||
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', margin: '2px 0 6px' }}>
|
||
<span style={{ fontSize: 13, fontWeight: 600, color: 'var(--text-2)' }}>مراجعه کننده جدید</span>
|
||
<button type="button" className="btn sm ghost"
|
||
onClick={() => { setName(''); setMobile(''); setNationalCode(''); setNewPatient(false); }}>
|
||
انتخاب از لیست موجود
|
||
</button>
|
||
</div>
|
||
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(3, 1fr)', gap: 16, marginTop: 4 }}>
|
||
<div>
|
||
<label style={label}>نام و نام خانوادگی مراجعه کننده</label>
|
||
<div className="field" style={{ marginTop: 6, height: 44 }}>
|
||
<input value={name} onChange={e => setName(e.target.value)} placeholder="نام و نام خانوادگی مراجعه کننده" />
|
||
</div>
|
||
</div>
|
||
<div>
|
||
<label style={label}>شماره تماس</label>
|
||
<div className="field" style={{ marginTop: 6, height: 44 }}>
|
||
<DigitInput value={mobile} onChange={setMobile} maxDigits={11} placeholder="شماره تماس مراجعه کننده" />
|
||
</div>
|
||
</div>
|
||
<div>
|
||
<label style={label}>کد ملی</label>
|
||
<div className="field" style={{ marginTop: 6, height: 44 }}>
|
||
<DigitInput value={nationalCode} onChange={setNationalCode} maxDigits={10}
|
||
placeholder="کد ملی مراجعه کننده" />
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</>
|
||
)}
|
||
|
||
{/* مشخصات سرویس */}
|
||
<div style={sectionTitle}>مشخصات سرویس</div>
|
||
{!serviceMode ? (
|
||
<>
|
||
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(2, 1fr)', gap: 16, marginBottom: 10 }}>
|
||
<div>
|
||
<label style={label}>بخش</label>
|
||
<div style={{ marginTop: 6 }}>
|
||
<SearchableSelect
|
||
inputId="appt-section-select"
|
||
options={(sectionsQ.data?.data ?? []).map(o => ({ value: o.uuid, label: o.name ?? '' }))}
|
||
value={sectionUuid || null}
|
||
onChange={v => setSectionUuid(v ? String(v) : '')}
|
||
placeholder="ابتدا بخش را انتخاب کنید"
|
||
isLoading={sectionsQ.isLoading}
|
||
isClearable
|
||
height={44}
|
||
/>
|
||
</div>
|
||
</div>
|
||
<div>
|
||
<label style={label}>پرسنل</label>
|
||
<div style={{ marginTop: 6 }}>
|
||
<SearchableSelect
|
||
options={(staffQ.data?.data ?? []).map(o => ({ value: o.uuid, label: o.full_name ?? '' }))}
|
||
value={staffUuid || null}
|
||
onChange={v => setStaffUuid(v ? String(v) : '')}
|
||
placeholder="انتخاب پرسنل"
|
||
isLoading={staffQ.isLoading}
|
||
isClearable
|
||
height={44}
|
||
/>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
{/* سرویسهای بخشِ انتخابشده — چند انتخابی، به لیست انباشته اضافه میشوند */}
|
||
{sectionUuid && (
|
||
<>
|
||
<label style={label}>سرویسهای این بخش (یک یا چند)</label>
|
||
{itemsQ.isLoading ? (
|
||
<div style={{ fontSize: 12.5, color: 'var(--text-3)', margin: '6px 0 10px' }}>در حال بارگذاری...</div>
|
||
) : (itemsQ.data?.data ?? []).length === 0 ? (
|
||
<div style={{ fontSize: 12.5, color: 'var(--text-3)', margin: '6px 0 10px' }}>سرویسی در این بخش تعریف نشده است.</div>
|
||
) : (
|
||
<div style={{ display: 'flex', flexDirection: 'column', gap: 6, margin: '6px 0 12px' }}>
|
||
{(itemsQ.data?.data ?? []).map(o => {
|
||
const active = selectedServices.some(s => s.uuid === o.uuid);
|
||
return (
|
||
<button key={o.uuid} type="button" onClick={() => toggleServiceItem(o.uuid, o.name ?? '')}
|
||
style={{
|
||
display: 'flex', alignItems: 'center', gap: 8, padding: '9px 12px', borderRadius: 'var(--r-sm)',
|
||
cursor: 'pointer', textAlign: 'right', fontFamily: 'inherit', fontSize: 13,
|
||
border: active ? '1px solid var(--primary)' : '1px solid var(--border)',
|
||
background: active ? 'var(--primary-soft)' : 'var(--surface)',
|
||
}}>
|
||
<span style={{
|
||
width: 16, height: 16, borderRadius: 4, display: 'grid', placeItems: 'center', flexShrink: 0,
|
||
border: active ? '1px solid var(--primary)' : '1px solid var(--border-2)',
|
||
background: active ? 'var(--primary)' : 'transparent',
|
||
}}>
|
||
{active && <span style={{ width: 8, height: 8, background: '#fff', borderRadius: 2 }} />}
|
||
</span>
|
||
{o.name}
|
||
</button>
|
||
);
|
||
})}
|
||
</div>
|
||
)}
|
||
</>
|
||
)}
|
||
|
||
{/* لیستِ انباشتهٔ سرویسهای انتخابشده (از هر بخش) — قابل حذف */}
|
||
{selectedServices.length > 0 && (
|
||
<div style={{ margin: '4px 0 12px' }}>
|
||
<label style={label}>سرویسهای انتخابشده ({selectedServices.length})</label>
|
||
<div style={{ display: 'flex', flexWrap: 'wrap', gap: 8, marginTop: 6 }}>
|
||
{selectedServices.map(s => (
|
||
<span key={s.uuid} style={{
|
||
display: 'inline-flex', alignItems: 'center', gap: 6, padding: '5px 6px 5px 10px',
|
||
borderRadius: 'var(--r-pill)', fontSize: 12.5, background: 'var(--primary-soft)',
|
||
color: 'var(--primary-700)', border: '1px solid var(--primary)',
|
||
}}>
|
||
{s.name}
|
||
<button type="button" aria-label={`حذف ${s.name}`} onClick={() => removeService(s.uuid)}
|
||
style={{
|
||
display: 'grid', placeItems: 'center', width: 16, height: 16, borderRadius: 999,
|
||
border: 'none', cursor: 'pointer', background: 'var(--primary)', color: '#fff',
|
||
fontSize: 12, lineHeight: 1, fontFamily: 'inherit',
|
||
}}>×</button>
|
||
</span>
|
||
))}
|
||
</div>
|
||
</div>
|
||
)}
|
||
</>
|
||
) : (
|
||
<div style={{ maxWidth: 400, marginBottom: 10 }}>
|
||
<label style={label}>پرسنل</label>
|
||
<div style={{ marginTop: 6 }}>
|
||
<SearchableSelect
|
||
options={(staffQ.data?.data ?? []).map(o => ({ value: o.uuid, label: o.full_name ?? '' }))}
|
||
value={staffUuid || null}
|
||
onChange={v => setStaffUuid(v ? String(v) : '')}
|
||
placeholder="انتخاب پرسنل"
|
||
isLoading={staffQ.isLoading}
|
||
isClearable
|
||
height={44}
|
||
/>
|
||
</div>
|
||
</div>
|
||
)}
|
||
|
||
{/* زمان نوبت */}
|
||
<div style={sectionTitle}>زمان نوبت</div>
|
||
{serviceMode ? (
|
||
<>
|
||
<label style={label}>انتخاب تاریخ</label>
|
||
<div style={{ margin: '6px 0 10px', maxWidth: 400 }}><PersianDateInput value={date} onChange={setDate} /></div>
|
||
<div style={{ marginBottom: 12 }}>
|
||
{doctorUuid ? (
|
||
<ServiceSlotPicker
|
||
doctorUuid={doctorUuid}
|
||
date={date}
|
||
services={services}
|
||
onSelect={setServicePick}
|
||
/>
|
||
) : (
|
||
<div style={{ fontSize: 12.5, color: 'var(--text-3)' }}>ابتدا پزشک را انتخاب کنید.</div>
|
||
)}
|
||
</div>
|
||
</>
|
||
) : (
|
||
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(4, 1fr)', gap: 16, marginBottom: 12 }}>
|
||
<div>
|
||
<label style={label}>انتخاب تاریخ</label>
|
||
<div style={{ marginTop: 6 }}><PersianDateInput value={date} onChange={setDate} /></div>
|
||
</div>
|
||
<div>
|
||
<label style={label}>زمان پیش فرض (دقیقه)</label>
|
||
<div className="field" style={{ marginTop: 6, height: 44 }}>
|
||
<input aria-label="زمان پیش فرض" type="text" inputMode="numeric" value={duration} onChange={e => setDuration(Math.max(5, Number(digitsOnly(e.target.value)) || 0))} dir="ltr" />
|
||
</div>
|
||
</div>
|
||
<div>
|
||
<label style={label}>ساعت شروع</label>
|
||
<div className="field" style={{ marginTop: 6, height: 44 }}><input aria-label="ساعت شروع" type="time" value={start} onChange={e => setStart(e.target.value)} dir="ltr" /></div>
|
||
</div>
|
||
<div>
|
||
<label style={label}>ساعت پایان</label>
|
||
<div className="field" style={{ marginTop: 6, height: 44 }}><input aria-label="ساعت پایان" type="time" value={end} onChange={e => setEnd(e.target.value)} dir="ltr" /></div>
|
||
</div>
|
||
</div>
|
||
)}
|
||
|
||
{/* بیعانه */}
|
||
<div style={sectionTitle}>بیعانه</div>
|
||
<label style={{ display: 'inline-flex', alignItems: 'center', gap: 10, fontSize: 14, cursor: 'pointer' }}>
|
||
<span style={{
|
||
position: 'relative', width: 42, height: 22, borderRadius: 999, flexShrink: 0,
|
||
background: depositRequired ? 'var(--primary)' : '#c4c4c4', transition: 'background .2s',
|
||
}}>
|
||
<input type="checkbox" checked={depositRequired} onChange={e => setDepositRequired(e.target.checked)}
|
||
style={{ position: 'absolute', inset: 0, width: '100%', height: '100%', margin: 0, opacity: 0, cursor: 'pointer' }} />
|
||
<span style={{
|
||
position: 'absolute', top: 2, insetInlineStart: depositRequired ? 22 : 2, width: 18, height: 18,
|
||
borderRadius: 999, background: '#fff', transition: 'inset-inline-start .2s', boxShadow: '0 1px 2px rgba(0,0,0,.2)',
|
||
}} />
|
||
</span>
|
||
بیعانه مورد نیاز است.
|
||
</label>
|
||
{depositRequired && (
|
||
<div style={{ display: 'flex', flexWrap: 'wrap', alignItems: 'flex-end', gap: 12, margin: '12px 0' }}>
|
||
<div style={{ width: 300, maxWidth: '100%' }}>
|
||
<label style={label}>مبلغ بیعانه (تومان)</label>
|
||
<div className="field" style={{ marginTop: 6, height: 44 }}><PriceInput value={depositToman} onChange={setDepositToman} /></div>
|
||
</div>
|
||
<WalletChargeLink mobile={effectiveMobile} />
|
||
</div>
|
||
)}
|
||
|
||
{/* هزینه ویزیت */}
|
||
<div style={sectionTitle}>هزینه ویزیت</div>
|
||
<div style={{ width: 300, maxWidth: '100%', marginBottom: 12 }}>
|
||
<label style={label}>
|
||
هزینه ویزیت (تومان){requireVisit && <span style={{ color: 'var(--danger)' }}> *</span>}
|
||
</label>
|
||
<div className="field" style={{ marginTop: 6, height: 44 }}>
|
||
<PriceInput value={visitPriceToman} onChange={(v) => { setVisitPriceToman(v); setVisitPriceTouched(true); }} />
|
||
</div>
|
||
{requireVisit && visitPriceToman <= 0 && (
|
||
<span style={{ fontSize: 12, color: 'var(--danger)', display: 'block', marginTop: 4 }}>هزینه ویزیت الزامی است</span>
|
||
)}
|
||
</div>
|
||
|
||
<div style={{ maxWidth: 500 }}>
|
||
<label style={label}>انتخاب وضعیت</label>
|
||
<div style={{ margin: '6px 0 12px' }}>
|
||
<SearchableSelect
|
||
options={[{ value: 'pending', label: 'ثبت شده' }, { value: 'confirmed', label: 'قطعی شده' }]}
|
||
value={status || null}
|
||
onChange={v => setStatus(v ? String(v) : '')}
|
||
placeholder="انتخاب وضعیت"
|
||
height={44}
|
||
/>
|
||
</div>
|
||
</div>
|
||
|
||
<label style={label}>توضیحات</label>
|
||
<div className="field" style={{ height: 'auto', margin: '6px 0 16px' }}>
|
||
<textarea value={note} onChange={e => setNote(e.target.value)} rows={4} placeholder="توضیحات..."
|
||
style={{ width: '100%', border: 'none', background: 'transparent', fontFamily: 'inherit', resize: 'vertical' }} />
|
||
</div>
|
||
|
||
<div style={{ display: 'flex', justifyContent: 'flex-end', paddingTop: 4 }}>
|
||
<button className="btn primary lg" disabled={!valid || create.isPending} onClick={() => create.mutate()}>
|
||
{create.isPending ? '...' : 'ثبت اطلاعات'}
|
||
</button>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
);
|
||
}
|