feat(appointment): implement service mode functionality with service slot selection and validation

This commit is contained in:
hamed
2026-07-16 00:02:36 +03:30
parent ac4a430564
commit 4715649fd8
5 changed files with 339 additions and 49 deletions
+91 -15
View File
@@ -22,6 +22,8 @@ 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 ServiceSlotPicker from '../components/appointments/ServiceSlotPicker';
import { useDoctorBookingServices } from '../hooks/useDoctorBookingServices';
import { CANCELLED_STATUSES } from '../components/appointments/turnStatus';
import type { TimelineSlot } from '../components/appointments/types';
@@ -97,17 +99,26 @@ interface BookingSlot { start: number; end: number; start_time: string; end_time
interface PatientLookup { found: boolean; name?: string | null; mobile?: string; national_code?: string | null }
export function NewAppointmentModal({
slot, onClose, onSuccess,
}: { slot: BookingSlot; onClose: () => void; onSuccess: () => void }) {
slot, onClose, onSuccess, serviceMode = false, services = [], date,
}: {
slot: BookingSlot;
onClose: () => void;
onSuccess: () => void;
serviceMode?: boolean;
services?: import('../hooks/useDoctorBookingServices').BookingService[];
date?: string;
}) {
const [mobile, setMobile] = useState('');
const [lookup, setLookup] = useState<PatientLookup | null>(null);
const [patientName, setPatientName] = useState('');
const [nationalCode, setNationalCode] = useState('');
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';
const mobileValid = /^09\d{9}$/.test(mobile);
const serviceTimingValid = !serviceMode || (pick.serviceUuids.length > 0 && !!pick.slot);
// یک بیمارِ یافت‌شده که کد ملی دارد، بدون فرم اضافی قابل استفاده است.
const foundWithNationalCode = !!lookup?.found && !!lookup.national_code;
const needsDetails = lookup !== null && !foundWithNationalCode; // یافت‌نشده، یا یافت‌شده بدون کد ملی
@@ -115,7 +126,7 @@ export function NewAppointmentModal({
const effectiveName = foundWithNationalCode ? (lookup?.name ?? '') : patientName.trim();
const effectiveNationalCode = foundWithNationalCode ? (lookup?.national_code ?? '') : nationalCode;
const detailsValid = effectiveName.length >= 2 && effectiveNationalCode.length === 10;
const isValid = mobileValid && (foundWithNationalCode || (needsDetails && detailsValid));
const isValid = mobileValid && (foundWithNationalCode || (needsDetails && detailsValid)) && serviceTimingValid;
const search = useMutation({
mutationFn: () => api.get(`/api/v1/my/appointment/patient-lookup?mobile=${encodeURIComponent(mobile)}`),
@@ -131,11 +142,12 @@ export function NewAppointmentModal({
const mutation = useMutation({
mutationFn: () => api.post(createEndpoint, {
doctor_uuid: slot.doctor_uuid,
slot_start: slot.start,
slot_end: slot.end,
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,
...(serviceMode ? { service_item_uuids: pick.serviceUuids } : {}),
}),
onSuccess: () => {
toast.success('نوبت با موفقیت ثبت شد');
@@ -180,9 +192,22 @@ export function NewAppointmentModal({
}} onClick={e => e.stopPropagation()}>
<div style={{ fontWeight: 700, fontSize: 16, marginBottom: 4 }}>ثبت نوبت</div>
<div style={{ fontSize: 13, color: 'var(--text-2)', marginBottom: 16 }}>
{slot.start_time} تا {slot.end_time} {slot.doctor_name}
{serviceMode
? slot.doctor_name
: `${slot.start_time} تا ${slot.end_time}${slot.doctor_name}`}
</div>
{serviceMode && date && (
<div style={{ marginBottom: 16 }}>
<ServiceSlotPicker
doctorUuid={slot.doctor_uuid}
date={date}
services={services}
onSelect={setPick}
/>
</div>
)}
<div style={{ marginBottom: 12 }}>
<label style={labelSx}>شماره موبایل بیمار *</label>
<div style={{ display: 'flex', gap: 8 }}>
@@ -374,10 +399,23 @@ export default function AppointmentsPage() {
enabled: viewMode === 'timeline' && !!selectedDoctorUuid,
});
// ── روش نوبت‌دهی پزشکِ انتخاب‌شده (سرویسی/اسلاتی)
const { bookingMode, services } = useDoctorBookingServices(selectedDoctorUuid);
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 rawSessions: any[] = (slotsQuery.data?.data as any)?.sessions ?? [];
const activeByStart = new Map<number, Appointment>();
const cancelledByStart = new Map<number, Appointment>();
@@ -391,6 +429,27 @@ export default function AppointmentsPage() {
}
});
// حالت سرویسی: اسلات ثابت وجود ندارد — تایم‌لاین از خودِ نوبت‌های رزروشده
// (با مدت واقعی) ساخته می‌شود، مرتب بر اساس زمان شروع.
if (serviceMode) {
const fmt = (ts: number) => new Date(ts * 1000).toLocaleTimeString('fa-IR', { hour: '2-digit', minute: '2-digit' });
return [...activeByStart.values()]
.sort((a, b) => Number(a.slot_start) - Number(b.slot_start))
.map(a => {
const start = Number(a.slot_start);
const end = Number(a.slot_end);
return {
start, end,
start_time: fmt(start),
end_time: fmt(end),
is_available: false,
appointment: a,
cancelled_appointment: null,
} as TimelineSlot;
});
}
const rawSessions: any[] = (slotsQuery.data?.data as any)?.sessions ?? [];
const out: TimelineSlot[] = [];
rawSessions.forEach((session: any) => {
(session.slots as any[]).forEach((s: any) => {
@@ -408,7 +467,7 @@ export default function AppointmentsPage() {
});
});
return out;
}, [viewMode, slotsQuery.data, appointments]);
}, [viewMode, slotsQuery.data, appointments, serviceMode]);
// ── Slot click → quick booking modal
function handleSlotClick(slot: TimelineSlot) {
@@ -513,13 +572,27 @@ export default function AppointmentsPage() {
برای نمایش زمانبندی، ابتدا یک پزشک انتخاب کنید
</div>
) : (
<TurnsTimeline
slots={timelineSlots}
loading={apptQuery.isLoading || slotsQuery.isLoading}
queryKey={apptQueryKey}
onView={openDetail}
onBook={handleSlotClick}
/>
<>
{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}
/>
</>
)}
</>
)}
@@ -531,6 +604,9 @@ export default function AppointmentsPage() {
{bookingSlot && (
<NewAppointmentModal
slot={bookingSlot}
serviceMode={serviceMode}
services={services}
date={selectedDate}
onClose={() => setBookingSlot(null)}
onSuccess={() => {
qc.invalidateQueries({ queryKey: apptQueryKey });