- Updated date handling in BlogSeoFields and ScheduleSection to use Tehran timezone utilities for consistency. - Introduced `toTehranClockTime`, `tehranWallClockToUnix`, and `todayIso` functions for accurate date representation. - Modified various components to utilize these new utilities, ensuring that date strings are correctly formatted and timestamps are accurately converted. - Enhanced API documentation to clarify the handling of date fields, emphasizing the importance of server-local midnight. - Added tests to verify that date overrides and holidays maintain the correct day without shifting due to timezone discrepancies.
917 lines
44 KiB
TypeScript
917 lines
44 KiB
TypeScript
import React, { useEffect, useRef, useState } from 'react';
|
|
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
|
|
import { useNavigate, useSearchParams } from 'react-router-dom';
|
|
import {
|
|
PlusIcon, ChevronRightIcon, ChevronLeftIcon, ChevronDownIcon, CalendarDaysIcon,
|
|
AdjustmentsHorizontalIcon, ClockIcon, MagnifyingGlassIcon, CheckCircleIcon,
|
|
} from '@heroicons/react/24/outline';
|
|
import { toast } from 'sonner';
|
|
import { api } from '../lib/api';
|
|
import type { PaginatedResponse, ApiResponse } from '../lib/api';
|
|
import type { Appointment } from '../types';
|
|
import { formatDate, toGregorianDate, todayIso, formatTime, digitsOnly, sanitizeMobileInput, rialToToman, tomanToRial, formatRial } from '../lib/utils';
|
|
import PriceInput from '../components/ui/PriceInput';
|
|
import Modal from '../components/ui/Modal';
|
|
import { useAuthStore } from '../stores/authStore';
|
|
import { useClinicContext } from '../hooks/useClinicContext';
|
|
import Pagination from '../components/ui/Pagination';
|
|
import AppointmentFiltersModal, { applyAppointmentFilters, EMPTY_FILTERS } from '../components/AppointmentFiltersModal';
|
|
import type { AppointmentFilters } from '../components/AppointmentFiltersModal';
|
|
import PersianCalendar from '../components/ui/PersianCalendar';
|
|
import SearchableSelect from '../components/ui/SearchableSelect';
|
|
// اجزای طرح نوبتهای tauri
|
|
import TurnsStatInfo from '../components/appointments/TurnsStatInfo';
|
|
import TurnsViewToggle from '../components/appointments/TurnsViewToggle';
|
|
import type { TurnsViewMode } from '../components/appointments/TurnsViewToggle';
|
|
import DoctorTabs from '../components/appointments/DoctorTabs';
|
|
import TurnsTimeline from '../components/appointments/TurnsTimeline';
|
|
import TurnsTable from '../components/appointments/TurnsTable';
|
|
import { usePermissions } from '../hooks/usePermissions';
|
|
import ServiceSlotPicker from '../components/appointments/ServiceSlotPicker';
|
|
import { useDoctorBookingServices } from '../hooks/useDoctorBookingServices';
|
|
import { CANCELLED_STATUSES } from '../components/appointments/turnStatus';
|
|
import type { TimelineSlot } from '../components/appointments/types';
|
|
|
|
const EMPTY_ARR: Appointment[] = [];
|
|
|
|
// ─────────────────────────────────────────────────────────────────────────────
|
|
// Date Navigator (طرح tauri — ناوبری روزانه)
|
|
// ─────────────────────────────────────────────────────────────────────────────
|
|
|
|
const WEEK_DAYS_FA = ['یکشنبه', 'دوشنبه', 'سهشنبه', 'چهارشنبه', 'پنجشنبه', 'جمعه', 'شنبه'];
|
|
|
|
function getPersianWeekDay(gregorianDate: string): string {
|
|
return WEEK_DAYS_FA[new Date(gregorianDate + 'T12:00:00').getDay()];
|
|
}
|
|
|
|
const navBtnSx: React.CSSProperties = {
|
|
background: 'var(--surface)', border: '1px solid var(--border)',
|
|
borderRadius: 'var(--r-sm)', height: 36, width: 36,
|
|
display: 'flex', alignItems: 'center', justifyContent: 'center',
|
|
cursor: 'pointer', color: 'var(--text-2)',
|
|
};
|
|
|
|
function DateNavigator({ date, onChange }: { date: string; onChange: (d: string) => void }) {
|
|
const [showCal, setShowCal] = useState(false);
|
|
const ref = useRef<HTMLDivElement>(null);
|
|
|
|
const weekDay = getPersianWeekDay(date);
|
|
const isFriday = new Date(date + 'T12:00:00').getDay() === 5;
|
|
|
|
function addDays(n: number) {
|
|
const d = new Date(date + 'T12:00:00');
|
|
d.setDate(d.getDate() + n);
|
|
onChange(toGregorianDate(d));
|
|
}
|
|
|
|
return (
|
|
<div ref={ref} style={{ display: 'flex', alignItems: 'center', gap: 2, position: 'relative' }}>
|
|
<button className="btn sm" style={navBtnSx} onClick={() => addDays(1)}>
|
|
<ChevronRightIcon style={{ width: 15, height: 15 }} />
|
|
</button>
|
|
<div style={{
|
|
padding: '0 14px', height: 44,
|
|
display: 'flex', flexDirection: 'column', alignItems: 'center', justifyContent: 'center',
|
|
background: 'var(--surface)', border: '1px solid var(--border)',
|
|
borderRadius: 'var(--r-sm)', minWidth: 130, gap: 1,
|
|
}}>
|
|
<span style={{ fontSize: 13, fontWeight: 700, lineHeight: 1.2, color: isFriday ? 'var(--danger)' : 'var(--text)' }}>
|
|
{weekDay}
|
|
</span>
|
|
<span style={{ fontSize: 11, color: 'var(--text-2)', lineHeight: 1.2 }}>
|
|
{formatDate(date)}
|
|
</span>
|
|
</div>
|
|
<button className="btn sm" style={navBtnSx} onClick={() => addDays(-1)}>
|
|
<ChevronLeftIcon style={{ width: 15, height: 15 }} />
|
|
</button>
|
|
<button className="btn sm" style={navBtnSx} onClick={() => setShowCal(c => !c)}>
|
|
<CalendarDaysIcon style={{ width: 15, height: 15 }} />
|
|
</button>
|
|
{showCal && (
|
|
<PersianCalendar value={date} onChange={v => { onChange(v); setShowCal(false); }} onClose={() => setShowCal(false)} />
|
|
)}
|
|
</div>
|
|
);
|
|
}
|
|
|
|
// ─────────────────────────────────────────────────────────────────────────────
|
|
// Quick booking modal (کلیک روی اسلات خالیِ زمانبندی)
|
|
// ─────────────────────────────────────────────────────────────────────────────
|
|
|
|
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 function NewAppointmentModal({
|
|
slot, onClose, onSuccess, serviceMode = false, services = [], date, clinicUuid = null,
|
|
}: {
|
|
slot: BookingSlot;
|
|
onClose: () => void;
|
|
onSuccess: () => void;
|
|
serviceMode?: boolean;
|
|
services?: import('../hooks/useDoctorBookingServices').BookingService[];
|
|
date?: string;
|
|
/** محل نوبت — بدون آن backend نوبت را به مطب شخصی نسبت میدهد. */
|
|
clinicUuid?: 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<{ serviceUuids: string[]; slot: { start: number; end: number } | null }>({ serviceUuids: [], slot: null });
|
|
|
|
const role = useAuthStore(s => s.primaryRole);
|
|
const createEndpoint = role === 'admin' ? '/api/v1/admin/appointment' : '/api/v1/my/appointment';
|
|
|
|
// هزینه ویزیت — الزامی بودن از تنظیمات «الزامی کردن هزینه ویزیت». فیلد 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)}`),
|
|
retry: false,
|
|
});
|
|
const selfPricingQ = useQuery<Pricing>({
|
|
queryKey: ['insurance-pricing'],
|
|
queryFn: () => api.get('/api/v1/insurance-pricing'),
|
|
enabled: doctorPricingQ.isError,
|
|
});
|
|
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 = !serviceMode || (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, {
|
|
doctor_uuid: slot.doctor_uuid,
|
|
slot_start: serviceMode ? pick.slot!.start : slot.start,
|
|
slot_end: serviceMode ? pick.slot!.end : slot.end,
|
|
patient_mobile: mobile,
|
|
patient_name: effectiveName,
|
|
patient_national_code: effectiveNationalCode,
|
|
...(clinicUuid ? { clinic_uuid: clinicUuid } : {}),
|
|
...(serviceMode ? { service_item_uuids: pick.serviceUuids } : {}),
|
|
...(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 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: 10, marginBottom: 18,
|
|
padding: '10px 14px', borderRadius: 'var(--r-sm)', background: 'var(--primary-soft)',
|
|
}}>
|
|
<ClockIcon style={{ width: 18, height: 18, color: 'var(--primary-700)', flexShrink: 0 }} />
|
|
<span style={{ fontSize: 13.5, color: 'var(--text)' }}>
|
|
{serviceMode ? slot.doctor_name : `${slot.start_time} تا ${slot.end_time}`}
|
|
</span>
|
|
{!serviceMode && (
|
|
<span style={{ fontSize: 12.5, color: 'var(--text-2)', marginInlineStart: 'auto' }}>
|
|
{slot.doctor_name}
|
|
</span>
|
|
)}
|
|
</div>
|
|
|
|
{serviceMode && date && (
|
|
<div style={{ marginBottom: 18 }}>
|
|
<ServiceSlotPicker
|
|
doctorUuid={slot.doctor_uuid}
|
|
date={date}
|
|
services={services}
|
|
onSelect={setPick}
|
|
clinicUuidOverride={clinicUuid}
|
|
/>
|
|
</div>
|
|
)}
|
|
|
|
<div className="field-block" style={{ marginBottom: 14 }}>
|
|
<label>جستجوی بیمار <span className="req">*</span></label>
|
|
{/* انتخاب معیار جستجو: موبایل یا کد ملی */}
|
|
<div style={{ display: 'flex', gap: 6, marginBottom: 8 }}>
|
|
{(['national', 'mobile'] as const).map(mode => (
|
|
<button
|
|
key={mode}
|
|
type="button"
|
|
className={`btn sm ${searchBy === mode ? 'primary' : 'ghost'}`}
|
|
onClick={() => onSwitchSearchBy(mode)}
|
|
style={{ flex: 1 }}
|
|
>
|
|
{mode === 'mobile' ? 'شماره موبایل' : 'کد ملی'}
|
|
</button>
|
|
))}
|
|
</div>
|
|
<div style={{ display: 'flex', gap: 8 }}>
|
|
<div className="field" style={{ flex: 1 }}>
|
|
{searchBy === 'mobile' ? (
|
|
<input
|
|
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
|
|
/>
|
|
) : (
|
|
<input
|
|
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
|
|
/>
|
|
)}
|
|
</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>نام و نام خانوادگی بیمار <span className="req">*</span></label>
|
|
<div className="field">
|
|
<input
|
|
type="text"
|
|
value={patientName}
|
|
onChange={e => setPatientName(e.target.value)}
|
|
placeholder="مثال: علی محمدی"
|
|
/>
|
|
</div>
|
|
</div>
|
|
{/* در جستجو با کد ملی، موبایل هنوز نامعلوم است و برای ثبت لازم میشود. */}
|
|
{searchBy === 'national' && (
|
|
<div className="field-block" style={{ marginBottom: 14 }}>
|
|
<label>شماره موبایل بیمار <span className="req">*</span></label>
|
|
<div className="field">
|
|
<input
|
|
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>کد ملی بیمار <span className="req">*</span></label>
|
|
<div className="field">
|
|
<input
|
|
type="text"
|
|
inputMode="numeric"
|
|
lang="en"
|
|
maxLength={10}
|
|
value={nationalCode}
|
|
onChange={e => setNationalCode(digitsOnly(e.target.value, 10))}
|
|
placeholder="کد ملی ۱۰ رقمی"
|
|
style={{ direction: 'ltr' }}
|
|
/>
|
|
</div>
|
|
</div>
|
|
)}
|
|
</>
|
|
)}
|
|
|
|
<div className="field-block">
|
|
{requireVisit ? (
|
|
<label>
|
|
هزینه ویزیت (تومان)<span className="req"> *</span>
|
|
</label>
|
|
) : (
|
|
// سرِ کلپس — با کلیک باز/بسته میشود (فقط وقتی اختیاری است).
|
|
<button
|
|
type="button"
|
|
onClick={() => setVisitPriceOpen(o => !o)}
|
|
aria-expanded={visitPriceExpanded}
|
|
style={{
|
|
display: 'flex', alignItems: 'center', gap: 6, width: '100%',
|
|
background: 'none', border: 'none', cursor: 'pointer', font: 'inherit',
|
|
padding: 0, color: 'var(--text)',
|
|
}}
|
|
>
|
|
<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>
|
|
</Modal>
|
|
);
|
|
}
|
|
|
|
// ─────────────────────────────────────────────────────────────────────────────
|
|
// Main Page — «نوبت ها» (طرح tauri)
|
|
// ─────────────────────────────────────────────────────────────────────────────
|
|
|
|
interface TodayStats { total: number; completed: number; waiting: number; cancelled: number; }
|
|
|
|
export default function AppointmentsPage() {
|
|
const navigate = useNavigate();
|
|
const primaryRole = useAuthStore(s => s.primaryRole);
|
|
const dbUuid = useAuthStore(s => s.dbUuid);
|
|
// در محیط کلینیک، dbUuid شناسهٔ کلینیک است نه پزشک؛ uuid پزشک فقط از doctorUuid میآید.
|
|
const doctorUuid = useAuthStore(s => s.doctorUuid);
|
|
const clinicUuid = useClinicContext();
|
|
const scope = useAuthStore(s => s.context?.scope);
|
|
const isAdmin = primaryRole === 'admin';
|
|
const isClinic = primaryRole === 'clinic';
|
|
const isDoctor = primaryRole === 'doctor';
|
|
const isRepresentation = primaryRole === 'representation';
|
|
// منشی در هر دو ساختار باید تایملاین ببیند: کلینیک چندپزشکه (تب پزشکانِ
|
|
// تخصیصیافته) و پزشک مستقل (همان یک پزشک). لیست در هر دو حالت از اندپوینتِ
|
|
// احرازشدهٔ /my/clinic-doctors میآید که خودش هر دو سناریو را resolve میکند.
|
|
const isSecretary = primaryRole === 'secretary';
|
|
const isClinicScopedSecretary = isSecretary && scope === 'clinic';
|
|
// مجوزهای منشی روی نوبتها؛ برای owner/پزشک همیشه true.
|
|
const { can } = usePermissions();
|
|
const canCreateAppt = can('appointments', 'create');
|
|
const canManageAppt = can('appointments', 'update_status');
|
|
const canCancelAppt = can('appointments', 'cancel');
|
|
|
|
const [params] = useSearchParams();
|
|
const today = todayIso();
|
|
// پس از ویرایش/ثبت، صفحه با ?date=... باز میشود تا همان روز نمایش داده شود.
|
|
const [selectedDate, setSelectedDate] = useState(params.get('date') || today);
|
|
const [viewMode, setViewMode] = useState<TurnsViewMode>('timeline');
|
|
const [selectedDoctorUuid, setSelectedDoctorUuid] = useState<string>(isDoctor ? (doctorUuid ?? '') : '');
|
|
const [bookingSlot, setBookingSlot] = useState<BookingSlot | null>(null);
|
|
const [filtersOpen, setFiltersOpen] = useState(false);
|
|
const [filters, setFilters] = useState<AppointmentFilters>(EMPTY_FILTERS);
|
|
const qc = useQueryClient();
|
|
|
|
// ── Today stats
|
|
const statsQuery = useQuery<ApiResponse<TodayStats>>({
|
|
queryKey: ['appt-today-stats', selectedDate, isAdmin],
|
|
queryFn: () => api.get(
|
|
isAdmin
|
|
? `/api/v1/admin/appointments/today-stats?date=${selectedDate}`
|
|
: `/api/v1/my/appointments/today-stats?date=${selectedDate}`
|
|
),
|
|
});
|
|
const stats = statsQuery.data?.data ?? { total: 0, completed: 0, waiting: 0, cancelled: 0 };
|
|
|
|
// ── Appointments query
|
|
const apptEndpoint = isAdmin
|
|
? '/api/v1/admin/appointments'
|
|
: isRepresentation
|
|
? '/api/v1/representation/appointments'
|
|
: '/api/v1/my/appointments';
|
|
const apptQueryKey = ['appointments', apptEndpoint, selectedDate, selectedDoctorUuid];
|
|
const apptParams = new URLSearchParams({ date: selectedDate, limit: '500' });
|
|
if (selectedDoctorUuid) apptParams.set('doctor_uuid', selectedDoctorUuid);
|
|
|
|
const apptQuery = useQuery<PaginatedResponse<Appointment>>({
|
|
queryKey: apptQueryKey,
|
|
queryFn: () => api.get(`${apptEndpoint}?${apptParams}`),
|
|
});
|
|
const appointments: Appointment[] = apptQuery.data?.data ?? EMPTY_ARR;
|
|
const filteredAppointments = applyAppointmentFilters(appointments, filters);
|
|
const filtersActive = filters !== EMPTY_FILTERS && JSON.stringify(filters) !== JSON.stringify(EMPTY_FILTERS);
|
|
|
|
// Table pagination — client-side (full day stays loaded for timeline + doctor tabs).
|
|
const TABLE_PAGE_SIZE = 20;
|
|
const [tablePage, setTablePage] = useState(1);
|
|
useEffect(() => { setTablePage(1); }, [selectedDate, selectedDoctorUuid, filters]);
|
|
const pagedAppointments = filteredAppointments.slice((tablePage - 1) * TABLE_PAGE_SIZE, tablePage * TABLE_PAGE_SIZE);
|
|
|
|
// ── Clinic doctors (authoritative list for tabs)
|
|
const clinicDoctorsQuery = useQuery<ApiResponse<{ data: { uuid: string; name: string }[] }>>({
|
|
queryKey: ['clinic-doctors', dbUuid, isSecretary],
|
|
// منشی از اندپوینتِ احرازشده میگیرد تا فقط پزشکانِ تخصیصیافتهاش بیایند —
|
|
// چه کلینیک چندپزشکه و چه پزشک مستقل؛ کلینیک/ادمین از لیستِ عمومیِ کلینیک.
|
|
queryFn: () => api.get(
|
|
isSecretary
|
|
? '/api/v1/my/clinic-doctors'
|
|
: `/api/v1/clinic/doctor-list/${dbUuid}`,
|
|
),
|
|
enabled: isSecretary || (isClinic && !!dbUuid),
|
|
});
|
|
const clinicDoctorsList = clinicDoctorsQuery.data?.data?.data ?? [];
|
|
|
|
const doctors = React.useMemo(() => {
|
|
const map = new Map<string, string>();
|
|
clinicDoctorsList.forEach(d => map.set(d.uuid, d.name));
|
|
appointments.forEach(a => {
|
|
if (a.doctor_uuid && a.doctor_name && !map.has(a.doctor_uuid)) {
|
|
map.set(a.doctor_uuid, a.doctor_name);
|
|
}
|
|
});
|
|
return Array.from(map.entries()).map(([uuid, name]) => ({ uuid, name }));
|
|
}, [appointments, clinicDoctorsList]);
|
|
|
|
// سرویسهای موجود در نوبتهای امروز (برای فیلتر «سرویس مورد نظر...»).
|
|
const serviceOptions = React.useMemo(() => {
|
|
const map = new Map<string, string>();
|
|
appointments.forEach(a => {
|
|
if (a.service_item?.uuid && a.service_item?.name) map.set(a.service_item.uuid, a.service_item.name);
|
|
});
|
|
return Array.from(map.entries()).map(([uuid, name]) => ({ uuid, name }));
|
|
}, [appointments]);
|
|
|
|
// نوع پروفایل: کلینیک چندپزشکه (تب دکترها + مدیریت چند پزشک) در برابر پزشک مستقل.
|
|
// نقش clinic/admin = چندپزشکه؛ نقش doctor (حتی مهمانِ کلینیک) = مستقل، فقط برنامهٔ خودش.
|
|
const isMultiDoctorClinic = isClinic || isAdmin || isSecretary;
|
|
const showDoctorTabs = isMultiDoctorClinic && doctors.length >= 1;
|
|
const showDoctorCol = isAdmin && !selectedDoctorUuid;
|
|
|
|
// کلینیک و منشی: اولین پزشکِ در دسترس پیشفرض انتخاب میشود تا تایملاین خالی نماند.
|
|
// برای منشیِ پزشک مستقل این تنها راه انتخاب است (تبِ پزشک هم یک گزینه بیشتر ندارد).
|
|
useEffect(() => {
|
|
if ((isClinic || isSecretary) && !selectedDoctorUuid && doctors.length > 0) {
|
|
setSelectedDoctorUuid(doctors[0].uuid);
|
|
}
|
|
}, [isClinic, isSecretary, selectedDoctorUuid, doctors]);
|
|
|
|
// ── محل نوبتدهی برای ادمین
|
|
// ادمین context کلینیکی ندارد (useClinicContext → null)؛ بدون clinic_uuid فقط برنامهٔ
|
|
// مطب شخصی خوانده میشود. محل از booking-locations همان پزشک انتخاب میشود.
|
|
const adminLocationsQuery = useQuery<ApiResponse<any>>({
|
|
queryKey: ['booking-locations', selectedDoctorUuid],
|
|
queryFn: () => api.get(`/api/v1/appointment-booking-locations/${selectedDoctorUuid}?management=1`),
|
|
enabled: isAdmin && !!selectedDoctorUuid,
|
|
});
|
|
const adminLocations: any[] = (adminLocationsQuery.data?.data as any)?.booking_locations ?? EMPTY_ARR;
|
|
const [adminLocKey, setAdminLocKey] = useState<string | null>(null);
|
|
useEffect(() => { setAdminLocKey(null); }, [selectedDoctorUuid]);
|
|
const locKey = (l: any) => l.clinic_uuid ?? 'personal';
|
|
// پیشفرض = اولین آیتم؛ backend بر اساس زودترین نوبت آزاد مرتب کرده است.
|
|
const adminLocation = adminLocations.find(l => locKey(l) === adminLocKey) ?? adminLocations[0] ?? null;
|
|
const effectiveClinicUuid: string | null = isAdmin ? (adminLocation?.clinic_uuid ?? null) : clinicUuid;
|
|
|
|
// ── Slots query (timeline)
|
|
// effectiveClinicUuid باید در کلید کش باشد، وگرنه برنامهٔ یک محیط برای محیط دیگر نشان داده میشود.
|
|
const slotsQueryKey = ['appt-slots', selectedDoctorUuid, selectedDate, effectiveClinicUuid];
|
|
const slotsQuery = useQuery<ApiResponse<any>>({
|
|
queryKey: slotsQueryKey,
|
|
queryFn: () => api.get(
|
|
`/api/v1/appointment-slots?doctor_uuid=${selectedDoctorUuid}&date=${selectedDate}&management=1` +
|
|
(effectiveClinicUuid ? `&clinic_uuid=${encodeURIComponent(effectiveClinicUuid)}` : '')
|
|
),
|
|
enabled: viewMode === 'timeline' && !!selectedDoctorUuid,
|
|
});
|
|
|
|
// ── روش نوبتدهی پزشکِ انتخابشده (سرویسی/اسلاتی)
|
|
const { bookingMode, services } = useDoctorBookingServices(
|
|
selectedDoctorUuid,
|
|
isAdmin ? (adminLocation?.clinic_uuid ?? null) : undefined,
|
|
);
|
|
const serviceMode = bookingMode === 'service';
|
|
|
|
// بازهٔ کاری پزشک در این روز (برای هدرِ تایملاینِ سرویسی).
|
|
const workingRange = React.useMemo(() => {
|
|
const rawSessions: any[] = (slotsQuery.data?.data as any)?.sessions ?? [];
|
|
if (!rawSessions.length) return null;
|
|
const starts = rawSessions.map(s => s.start_time).filter(Boolean).sort();
|
|
const ends = rawSessions.map(s => s.end_time).filter(Boolean).sort();
|
|
if (!starts.length || !ends.length) return null;
|
|
return { start: starts[0], end: ends[ends.length - 1] };
|
|
}, [slotsQuery.data]);
|
|
|
|
// ── Merge sessions + appointments → flat timeline slots
|
|
const timelineSlots: TimelineSlot[] = React.useMemo(() => {
|
|
if (viewMode !== 'timeline') return [];
|
|
|
|
const activeByStart = new Map<number, Appointment>();
|
|
const cancelledByStart = new Map<number, Appointment>();
|
|
appointments.forEach(a => {
|
|
const key = typeof a.slot_start === 'number' ? a.slot_start : parseInt(String(a.slot_start), 10);
|
|
if (CANCELLED_STATUSES.has(a.status)) {
|
|
const existing = cancelledByStart.get(key);
|
|
if (!existing || a.created_at > existing.created_at) cancelledByStart.set(key, a);
|
|
} else {
|
|
activeByStart.set(key, a);
|
|
}
|
|
});
|
|
|
|
// حالت سرویسی: اسلات ثابت وجود ندارد — برای هر شیفتِ کاری، نوبتهای رزروشده
|
|
// نمایش داده میشوند و باقیِ زمان بهصورت بازه(های) خالیِ قابلرزرو بین آنها.
|
|
if (serviceMode) {
|
|
const fmt = (ts: number) => formatTime(ts);
|
|
const parseHM = (t: string) => { const [h, m] = (t ?? '00:00').split(':').map(Number); return (h * 3600) + (m * 60); };
|
|
const rawSessions: any[] = (slotsQuery.data?.data as any)?.sessions ?? [];
|
|
const booked = [...activeByStart.values()].sort((a, b) => Number(a.slot_start) - Number(b.slot_start));
|
|
const now = Math.floor(Date.now() / 1000);
|
|
|
|
const out: TimelineSlot[] = [];
|
|
// بازهٔ خالیِ قابلرزرو؛ اگر روی «اکنون» بیفتد به اکنون بریده میشود و بازههای
|
|
// کاملاً گذشته حذف میشوند تا فقط زمان قابلرزرو باقی بماند.
|
|
const pushFree = (start: number, end: number) => {
|
|
const s = start < now ? now : start;
|
|
if (end <= s) return;
|
|
out.push({ start: s, end, start_time: fmt(s), end_time: fmt(end), is_available: true, appointment: null, cancelled_appointment: null });
|
|
};
|
|
|
|
rawSessions.forEach((session: any) => {
|
|
const slots = (session.slots as any[]) ?? [];
|
|
if (!slots.length) return;
|
|
const dayStart = Number(slots[0].start) - parseHM(session.start_time);
|
|
const winStart = dayStart + parseHM(session.start_time);
|
|
const winEnd = dayStart + parseHM(session.end_time);
|
|
|
|
const inWin = booked.filter(a => Number(a.slot_start) >= winStart && Number(a.slot_start) < winEnd);
|
|
let cursor = winStart;
|
|
inWin.forEach(a => {
|
|
const s = Number(a.slot_start), e = Number(a.slot_end);
|
|
if (s > cursor) pushFree(cursor, s);
|
|
out.push({
|
|
start: s, end: e, start_time: fmt(s), end_time: fmt(e),
|
|
is_available: false, appointment: a, cancelled_appointment: null,
|
|
});
|
|
cursor = Math.max(cursor, e);
|
|
});
|
|
if (cursor < winEnd) pushFree(cursor, winEnd);
|
|
});
|
|
return out;
|
|
}
|
|
|
|
const rawSessions: any[] = (slotsQuery.data?.data as any)?.sessions ?? [];
|
|
const out: TimelineSlot[] = [];
|
|
rawSessions.forEach((session: any) => {
|
|
(session.slots as any[]).forEach((s: any) => {
|
|
const slotStart = typeof s.start === 'number' ? s.start : parseInt(String(s.start), 10);
|
|
const slotEnd = typeof s.end === 'number' ? s.end : parseInt(String(s.end), 10);
|
|
out.push({
|
|
start: slotStart,
|
|
end: slotEnd,
|
|
start_time: s.start_time ?? formatTime(slotStart),
|
|
end_time: s.end_time ?? formatTime(slotEnd),
|
|
is_available: s.is_available as boolean,
|
|
appointment: activeByStart.get(slotStart) ?? null,
|
|
cancelled_appointment: cancelledByStart.get(slotStart) ?? null,
|
|
});
|
|
});
|
|
});
|
|
return out;
|
|
}, [viewMode, slotsQuery.data, appointments, serviceMode]);
|
|
|
|
// ── Slot click → quick booking modal
|
|
function handleSlotClick(slot: TimelineSlot) {
|
|
if (isRepresentation || !canCreateAppt) return;
|
|
const doctorName = doctors.find(d => d.uuid === selectedDoctorUuid)?.name ?? '';
|
|
setBookingSlot({
|
|
start: slot.start,
|
|
end: slot.end,
|
|
start_time: slot.start_time,
|
|
end_time: slot.end_time,
|
|
doctor_uuid: selectedDoctorUuid,
|
|
doctor_name: doctorName,
|
|
});
|
|
}
|
|
|
|
function openDetail(a: Appointment) {
|
|
navigate(`/admin/appointments/${a.uuid}`);
|
|
}
|
|
|
|
return (
|
|
<div style={{ padding: '20px 24px' }}>
|
|
<div style={{ maxWidth: 1050, margin: '0 auto' }}>
|
|
{/* عنوان */}
|
|
<h1 style={{ fontSize: 20, fontWeight: 700, color: 'var(--text)', marginBottom: 16 }}>نوبت ها</h1>
|
|
|
|
{/* نوار آمار */}
|
|
<TurnsStatInfo stats={stats} />
|
|
|
|
{/* نوار ابزار (بیرونِ کارت، مطابق طرح) */}
|
|
<div style={{
|
|
display: 'flex', alignItems: 'center', gap: 10, flexWrap: 'wrap', marginBottom: 16,
|
|
}}>
|
|
{/* سمت راست: تاریخ + سرویس + سوییچ نما (مطابق طرح) */}
|
|
<DateNavigator date={selectedDate} onChange={setSelectedDate} />
|
|
|
|
<ServiceFilterSelect
|
|
value={filters.itemUuid}
|
|
options={serviceOptions}
|
|
onChange={(v) => setFilters(f => ({ ...f, itemUuid: v }))}
|
|
/>
|
|
|
|
{isAdmin && adminLocations.length > 1 && (
|
|
<div style={{ minWidth: 220 }}>
|
|
<SearchableSelect
|
|
options={adminLocations.map((l: any) => ({
|
|
value: locKey(l),
|
|
label: l.type === 'personal' ? `مطب شخصی${l.title ? ` — ${l.title}` : ''}` : l.title,
|
|
}))}
|
|
value={adminLocation ? locKey(adminLocation) : null}
|
|
onChange={v => setAdminLocKey(v ? String(v) : null)}
|
|
placeholder="محل نوبتدهی..."
|
|
height={44}
|
|
/>
|
|
</div>
|
|
)}
|
|
|
|
<TurnsViewToggle viewMode={viewMode} onChange={setViewMode} />
|
|
|
|
<div style={{ flex: 1 }} />
|
|
|
|
{/* سمت چپ: فیلتر + افزودن نوبت */}
|
|
<button
|
|
aria-label="فیلترها"
|
|
className="btn sm"
|
|
onClick={() => setFiltersOpen(true)}
|
|
style={{
|
|
border: `1px solid ${filtersActive ? 'var(--primary)' : 'var(--border)'}`,
|
|
color: filtersActive ? 'var(--primary)' : 'var(--text-2)',
|
|
background: 'var(--surface)',
|
|
}}
|
|
>
|
|
<AdjustmentsHorizontalIcon style={{ width: 16 }} />
|
|
</button>
|
|
|
|
{!isRepresentation && canCreateAppt && (
|
|
<button
|
|
className="btn primary sm"
|
|
onClick={() => {
|
|
const q = selectedDoctorUuid ? `?doctor=${selectedDoctorUuid}&date=${selectedDate}` : `?date=${selectedDate}`;
|
|
navigate(`/admin/appointments/new${q}`);
|
|
}}
|
|
style={{ display: 'flex', alignItems: 'center', gap: 5 }}
|
|
>
|
|
<PlusIcon style={{ width: 15, height: 15 }} />
|
|
افزودن نوبت
|
|
</button>
|
|
)}
|
|
</div>
|
|
|
|
{/* کارت اصلی — تب دکترها (هدر) + محتوا */}
|
|
<div style={{
|
|
background: 'var(--surface)', border: '1px solid var(--border)',
|
|
borderRadius: 'var(--r)', overflow: 'hidden',
|
|
}}>
|
|
{showDoctorTabs && (
|
|
<DoctorTabs doctors={doctors} selected={selectedDoctorUuid} onSelect={setSelectedDoctorUuid} showAll={isAdmin} />
|
|
)}
|
|
<div style={{ padding: 16 }}>
|
|
{viewMode === 'table' ? (
|
|
<>
|
|
<TurnsTable
|
|
items={pagedAppointments}
|
|
loading={apptQuery.isLoading}
|
|
queryKey={apptQueryKey}
|
|
showDoctor={showDoctorCol}
|
|
canManage={canManageAppt}
|
|
canCancel={canCancelAppt}
|
|
/>
|
|
{filteredAppointments.length > TABLE_PAGE_SIZE && (
|
|
<div style={{ marginTop: 14 }}>
|
|
<Pagination page={tablePage} total={filteredAppointments.length} limit={TABLE_PAGE_SIZE} onPageChange={setTablePage} />
|
|
</div>
|
|
)}
|
|
</>
|
|
) : (
|
|
<>
|
|
{!selectedDoctorUuid ? (
|
|
<div style={{ padding: 40, textAlign: 'center', color: 'var(--text-3)' }}>
|
|
برای نمایش زمانبندی، ابتدا یک پزشک انتخاب کنید
|
|
</div>
|
|
) : (
|
|
<>
|
|
{serviceMode && (
|
|
<div style={{
|
|
display: 'flex', alignItems: 'center', justifyContent: 'space-between', gap: 8,
|
|
padding: '8px 12px', marginBottom: 12, borderRadius: 'var(--r-sm)',
|
|
background: 'var(--surface-2)', border: '1px solid var(--border)', fontSize: 12.5, color: 'var(--text-2)',
|
|
}}>
|
|
<span>نوبتدهی سرویسی — نوبتها بر اساس مدت سرویس چیده میشوند.</span>
|
|
{workingRange && (
|
|
<span dir="ltr" style={{ color: 'var(--text-3)' }}>ساعت کاری: {workingRange.start} - {workingRange.end}</span>
|
|
)}
|
|
</div>
|
|
)}
|
|
<TurnsTimeline
|
|
slots={timelineSlots}
|
|
loading={apptQuery.isLoading || slotsQuery.isLoading}
|
|
queryKey={apptQueryKey}
|
|
onView={openDetail}
|
|
onBook={handleSlotClick}
|
|
emptyReason={(slotsQuery.data?.data as any)?.empty_reason ?? null}
|
|
errorMessage={slotsQuery.isError ? ((slotsQuery.error as Error)?.message || 'خطای نامشخص') : null}
|
|
/>
|
|
</>
|
|
)}
|
|
</>
|
|
)}
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
{/* مودال ثبت سریع نوبت */}
|
|
{bookingSlot && (
|
|
<NewAppointmentModal
|
|
slot={bookingSlot}
|
|
serviceMode={serviceMode}
|
|
services={services}
|
|
date={selectedDate}
|
|
clinicUuid={effectiveClinicUuid}
|
|
onClose={() => setBookingSlot(null)}
|
|
onSuccess={() => {
|
|
qc.invalidateQueries({ queryKey: apptQueryKey });
|
|
qc.invalidateQueries({ queryKey: slotsQueryKey });
|
|
qc.invalidateQueries({ queryKey: ['appt-today-stats', selectedDate] });
|
|
}}
|
|
/>
|
|
)}
|
|
|
|
{/* مودال فیلترها */}
|
|
{filtersOpen && (
|
|
<AppointmentFiltersModal value={filters} onApply={setFilters} onClose={() => setFiltersOpen(false)} />
|
|
)}
|
|
</div>
|
|
);
|
|
}
|
|
|
|
/** فیلتر سرویس نوار ابزار — ردیفهای بارگذاریشدهٔ روز را بر اساس سرویس فیلتر میکند. */
|
|
function ServiceFilterSelect({ value, options, onChange }: {
|
|
value: string; options: { uuid: string; name: string }[]; onChange: (v: string) => void;
|
|
}) {
|
|
return (
|
|
<div style={{ minWidth: 280 }}>
|
|
<SearchableSelect
|
|
options={options.map(s => ({ value: s.uuid, label: s.name }))}
|
|
value={value || null}
|
|
onChange={v => onChange(v ? String(v) : '')}
|
|
placeholder="سرویس مورد نظر را انتخاب کنید..."
|
|
isClearable
|
|
height={44}
|
|
/>
|
|
</div>
|
|
);
|
|
}
|