- Replaced raw input fields for pricing with PriceInput component across various forms and modals to ensure consistent formatting and accessibility. - Updated tests to reflect changes in pricing input handling, ensuring values are displayed in toman with proper formatting. - Enhanced accessibility by adding aria-labels and aria-invalid attributes to PriceInput components. - Adjusted UI elements to improve layout and user experience, particularly in forms related to service items and scheduling. - Changed labels from "نمایش در نوبتدهی" to "نمایش در نوبتدهی آنلاین" for clarity.
263 lines
15 KiB
TypeScript
263 lines
15 KiB
TypeScript
import { useEffect, useMemo, useRef, useState } from 'react';
|
|
import { useQuery } from '@tanstack/react-query';
|
|
import { api } from '../../lib/api';
|
|
import type { ApiResponse } from '../../lib/api';
|
|
import type { BookingService } from '../../hooks/useDoctorBookingServices';
|
|
import { useClinicContext } from '../../hooks/useClinicContext';
|
|
import { XMarkIcon } from '@heroicons/react/24/outline';
|
|
import SearchableSelect from '../ui/SearchableSelect';
|
|
import DigitInput from '../ui/DigitInput';
|
|
|
|
interface ServiceSlot { start: number; end: number; start_time: string }
|
|
export interface PickedService { uuid: string; name: string; section: string; duration: number }
|
|
export interface ServicePick { serviceUuids: string[]; durations: Record<string, number>; slot: ServiceSlot | null }
|
|
|
|
/**
|
|
* انتخاب سرویس بر اساس بخش (بخش → سرویس، انباشته از چند بخش) + زمانهای خالیِ پیشنهادی
|
|
* برای نوبتدهی سرویسی. مدتِ هر سرویس در پنل قابل ویرایش است (فقط برای همین نوبت؛ پیشفرضِ
|
|
* سرویس تغییر نمیکند). مدت کل = مجموع مدتها؛ زمانها از `appointment-service-slots`
|
|
* با اعمال همان override محاسبه میشوند. انتخاب را از طریق onSelect بالا میفرستد.
|
|
*/
|
|
export default function ServiceSlotPicker({
|
|
doctorUuid, resourceUuid, date, services, onSelect, editableDuration = true, clinicUuidOverride,
|
|
excludeAppointmentUuid, initialSelection,
|
|
}: {
|
|
doctorUuid: string;
|
|
/**
|
|
* نوبتدهی روی یک منبع: زمانها از تقویم خودِ منبع میآیند، نه از برنامهٔ پزشک.
|
|
*
|
|
* فرمِ انتخاب سرویس در هر دو حالت یکی است (بخش → سرویس → مدت → زمان)، پس فقط
|
|
* منبعِ زمان عوض میشود نه کامپوننت — دو نسخه یعنی دو رفتار.
|
|
*/
|
|
resourceUuid?: string;
|
|
date: string;
|
|
services: BookingService[];
|
|
onSelect: (v: ServicePick) => void;
|
|
editableDuration?: boolean;
|
|
/** undefined = context محیط جاری؛ مقدار صریح (شامل null) = محل انتخابشده خارج از context */
|
|
clinicUuidOverride?: string | null;
|
|
/**
|
|
* ویرایش نوبت: بازهٔ خودِ همین نوبت اشغال حساب نشود، وگرنه زمان فعلیاش در فهرست
|
|
* نمیآید و کاربر نمیتواند «همان ساعت، سرویس متفاوت» را ثبت کند.
|
|
*/
|
|
excludeAppointmentUuid?: string;
|
|
/** سرویسهای از قبل انتخابشده (ویرایش نوبت موجود). فقط یک بار هیدریت میشود. */
|
|
initialSelection?: PickedService[];
|
|
}) {
|
|
const contextClinicUuid = useClinicContext();
|
|
const clinicUuid = clinicUuidOverride === undefined ? contextClinicUuid : clinicUuidOverride;
|
|
const [sectionUuid, setSectionUuid] = useState('');
|
|
const [selected, setSelected] = useState<PickedService[]>(initialSelection ?? []);
|
|
const [pickedSlot, setPickedSlot] = useState<ServiceSlot | null>(null);
|
|
|
|
// بخشهای یکتا از روی سرویسهای bookable (بدون endpoint اضافه — همه یکجا آمدهاند).
|
|
const sections = useMemo(() => {
|
|
const map = new Map<string, { uuid: string; name: string }>();
|
|
services.forEach(s => { if (s.service_section) map.set(s.service_section.uuid, s.service_section); });
|
|
return [...map.values()];
|
|
}, [services]);
|
|
const sectionServices = useMemo(
|
|
() => services.filter(s => s.service_section?.uuid === sectionUuid),
|
|
[services, sectionUuid],
|
|
);
|
|
|
|
// تعویض پزشک ⇒ لیست سرویسها عوض میشود؛ انتخابها ریست شوند. اجرای نخست معاف است،
|
|
// وگرنه initialSelection (ویرایش نوبت موجود) همان لحظه پاک میشد.
|
|
const mounted = useRef(false);
|
|
useEffect(() => {
|
|
if (!mounted.current) { mounted.current = true; return; }
|
|
setSelected([]); setSectionUuid('');
|
|
}, [doctorUuid, resourceUuid]);
|
|
useEffect(() => { setPickedSlot(null); }, [selected, date, doctorUuid, resourceUuid]);
|
|
|
|
const serviceUuids = useMemo(() => selected.map(s => s.uuid), [selected]);
|
|
const durations = useMemo(
|
|
() => Object.fromEntries(selected.map(s => [s.uuid, s.duration])) as Record<string, number>,
|
|
[selected],
|
|
);
|
|
|
|
useEffect(() => { onSelect({ serviceUuids, durations, slot: pickedSlot }); }, [serviceUuids, durations, pickedSlot]);
|
|
|
|
const durationsQs = selected.map(s => `&durations[${encodeURIComponent(s.uuid)}]=${s.duration}`).join('');
|
|
const servicesQs = serviceUuids.map(u => `&service_item_uuids[]=${encodeURIComponent(u)}`).join('');
|
|
const slotsQ = useQuery<ApiResponse<any>>({
|
|
queryKey: ['service-slots-picker', resourceUuid ?? doctorUuid, date, serviceUuids, durations, clinicUuid, excludeAppointmentUuid],
|
|
queryFn: () => api.get(
|
|
resourceUuid
|
|
? `/api/v1/resource/${resourceUuid}/service-slots?date=${date}` + servicesQs + durationsQs
|
|
: `/api/v1/appointment-service-slots?doctor_uuid=${doctorUuid}&date=${date}&management=1`
|
|
+ servicesQs
|
|
+ durationsQs
|
|
+ (clinicUuid ? `&clinic_uuid=${encodeURIComponent(clinicUuid)}` : '')
|
|
+ (excludeAppointmentUuid ? `&exclude_appointment_uuid=${encodeURIComponent(excludeAppointmentUuid)}` : ''),
|
|
),
|
|
enabled: (!!resourceUuid || !!doctorUuid) && !!date && serviceUuids.length > 0,
|
|
});
|
|
const startTimes: ServiceSlot[] = (slotsQ.data?.data as any)?.start_times ?? [];
|
|
const totalMinutes = (slotsQ.data?.data as any)?.total_duration_minutes as number | undefined;
|
|
|
|
const label = { fontSize: 12.5, color: 'var(--text-3)', display: 'block' } as const;
|
|
|
|
const toggle = (s: BookingService) =>
|
|
setSelected(prev => prev.some(p => p.uuid === s.uuid)
|
|
? prev.filter(p => p.uuid !== s.uuid)
|
|
: [...prev, { uuid: s.uuid, name: s.name, section: s.service_section.name, duration: s.duration_minutes ?? 0 }]);
|
|
const remove = (uuid: string) => setSelected(prev => prev.filter(p => p.uuid !== uuid));
|
|
const setDuration = (uuid: string, minutes: number) =>
|
|
setSelected(prev => prev.map(p => p.uuid === uuid ? { ...p, duration: minutes } : p));
|
|
|
|
if (services.length === 0) {
|
|
return (
|
|
<div style={{ fontSize: 12.5, color: 'var(--danger)', margin: '6px 0' }}>
|
|
{resourceUuid
|
|
? 'برای این منبع سرویسی تعریف نشده است — از تب «سرویسها»ی همین منبع اضافه کنید.'
|
|
: 'سرویسی با «نمایش در نوبتدهی آنلاین» برای این پزشک تعریف نشده است.'}
|
|
</div>
|
|
);
|
|
}
|
|
|
|
return (
|
|
<div>
|
|
{/* انتخاب بخش */}
|
|
<label style={label} htmlFor="service-mode-section-select">بخش</label>
|
|
<div style={{ margin: '6px 0 10px', maxWidth: 400 }}>
|
|
<SearchableSelect
|
|
inputId="service-mode-section-select"
|
|
options={sections.map(s => ({ value: s.uuid, label: s.name }))}
|
|
value={sectionUuid || null}
|
|
onChange={v => setSectionUuid(v ? String(v) : '')}
|
|
placeholder="ابتدا بخش را انتخاب کنید"
|
|
isClearable
|
|
height={44}
|
|
/>
|
|
</div>
|
|
|
|
{/* سرویسهای بخشِ انتخابشده — چند انتخابی */}
|
|
{sectionUuid && (
|
|
<>
|
|
<label style={label}>سرویسهای این بخش (یک یا چند)</label>
|
|
{sectionServices.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' }}>
|
|
{sectionServices.map(s => {
|
|
const active = selected.some(p => p.uuid === s.uuid);
|
|
return (
|
|
<button key={s.uuid} type="button" onClick={() => toggle(s)}
|
|
role="checkbox" aria-checked={active}
|
|
style={{
|
|
display: 'flex', alignItems: 'center', justifyContent: 'space-between', gap: 8,
|
|
minHeight: 40, 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={{ display: 'inline-flex', alignItems: 'center', gap: 8 }}>
|
|
<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: 'var(--surface)', borderRadius: 2 }} />}
|
|
</span>
|
|
{s.name}
|
|
</span>
|
|
{s.duration_minutes ? <span style={{ color: 'var(--text-3)', fontSize: 12 }}>{s.duration_minutes} دقیقه</span> : null}
|
|
</button>
|
|
);
|
|
})}
|
|
</div>
|
|
)}
|
|
</>
|
|
)}
|
|
|
|
{/* لیستِ انباشتهٔ سرویسهای انتخابشده (از هر بخش) — «بخش → سرویس» + مدت قابلویرایش + حذف */}
|
|
{selected.length > 0 && (
|
|
<div style={{ margin: '4px 0 12px' }}>
|
|
<label style={label}>سرویسهای انتخابشده ({selected.length})</label>
|
|
<div style={{ display: 'flex', flexDirection: 'column', gap: 8, marginTop: 6 }}>
|
|
{selected.map(s => (
|
|
<div key={s.uuid} style={{
|
|
display: 'flex', alignItems: 'center', gap: 10,
|
|
padding: '8px 10px', borderRadius: 'var(--r-sm)', fontSize: 13,
|
|
background: 'var(--primary-soft)', border: '1px solid var(--primary)',
|
|
}}>
|
|
<span style={{
|
|
flex: 1, minWidth: 0, color: 'var(--primary-700)', fontWeight: 600,
|
|
whiteSpace: 'nowrap', overflow: 'hidden', textOverflow: 'ellipsis',
|
|
}}>
|
|
<span style={{ color: 'var(--text-3)', fontWeight: 400 }}>{s.section}</span>
|
|
{' ← '}{s.name}
|
|
</span>
|
|
{editableDuration ? (
|
|
<span style={{
|
|
display: 'inline-flex', alignItems: 'center', gap: 4, flexShrink: 0,
|
|
height: 36, padding: '0 8px', borderRadius: 'var(--r-sm)',
|
|
background: 'var(--surface)', border: '1px solid var(--border-2)',
|
|
}}>
|
|
<DigitInput
|
|
aria-label={`مدت ${s.name}`}
|
|
value={String(s.duration || '')}
|
|
onChange={v => setDuration(s.uuid, Number(v) || 0)}
|
|
maxDigits={3}
|
|
style={{ width: 34, border: 'none', outline: 'none', background: 'transparent', textAlign: 'center', fontFamily: 'inherit', fontSize: 13, color: 'var(--text)' }}
|
|
/>
|
|
<span style={{ color: 'var(--text-3)', fontSize: 11.5 }}>دقیقه</span>
|
|
</span>
|
|
) : (
|
|
<span style={{ flexShrink: 0, color: 'var(--text-3)', fontSize: 12 }}>{s.duration} دقیقه</span>
|
|
)}
|
|
{/* هدف کلیک ۳۲px است نه ۱۸px — این دکمه انتخابِ کاربر را پاک میکند و
|
|
خطا زدنش روی موبایل یعنی حذف ناخواستهٔ سرویس. */}
|
|
<button type="button" className="mini-btn" aria-label={`حذف ${s.name}`} onClick={() => remove(s.uuid)}
|
|
style={{ flexShrink: 0, color: 'var(--primary-700)' }}>
|
|
<XMarkIcon style={{ width: 16, height: 16 }} />
|
|
</button>
|
|
</div>
|
|
))}
|
|
</div>
|
|
</div>
|
|
)}
|
|
|
|
{/* زمانهای خالی پیشنهادی */}
|
|
{selected.length > 0 && (
|
|
<>
|
|
<label style={label}>زمانهای خالی پیشنهادی{totalMinutes != null ? ` (مدت کل: ${totalMinutes} دقیقه)` : ''}</label>
|
|
{slotsQ.isLoading ? (
|
|
<div style={{ fontSize: 12.5, color: 'var(--text-3)', margin: '6px 0' }}>در حال محاسبه...</div>
|
|
) : slotsQ.isError ? (
|
|
/* بدون این شاخه، خطای سرور به «زمان خالی نیست» ترجمه میشد — یعنی کاربر
|
|
روز درست را کنار میگذاشت، چون پاسخ دروغ بود. */
|
|
<div style={{ display: 'flex', alignItems: 'center', gap: 8, margin: '6px 0' }}>
|
|
<span style={{ fontSize: 12.5, color: 'var(--danger)' }}>خواندن زمانهای خالی ناموفق بود.</span>
|
|
<button type="button" className="btn ghost sm" onClick={() => slotsQ.refetch()}>تلاش دوباره</button>
|
|
</div>
|
|
) : startTimes.length === 0 ? (
|
|
<div style={{ fontSize: 12.5, color: 'var(--danger)', margin: '6px 0' }}>
|
|
برای این سرویس در این روز زمان خالی کافی نیست؛ روز دیگری انتخاب کنید.
|
|
</div>
|
|
) : (
|
|
<div style={{ display: 'flex', flexWrap: 'wrap', gap: 6, margin: '6px 0 4px' }}>
|
|
{startTimes.map(s => {
|
|
const active = pickedSlot?.start === s.start;
|
|
return (
|
|
<button key={s.start} type="button" dir="ltr"
|
|
aria-pressed={active}
|
|
onClick={() => setPickedSlot({ start: s.start, end: s.end, start_time: s.start_time })}
|
|
style={{
|
|
fontSize: 13, minHeight: 36, padding: '6px 12px', borderRadius: 'var(--r-sm)', cursor: 'pointer', fontFamily: 'inherit',
|
|
border: active ? '1px solid var(--primary)' : '1px solid var(--border)',
|
|
background: active ? 'var(--primary)' : 'var(--surface)', color: active ? 'var(--on-primary)' : 'var(--text)',
|
|
}}>
|
|
{s.start_time}
|
|
</button>
|
|
);
|
|
})}
|
|
</div>
|
|
)}
|
|
</>
|
|
)}
|
|
</div>
|
|
);
|
|
}
|