feat: Implement resource booking functionality

- Add service timeline builder for appointments to manage available slots.
- Create a hook to fetch resource booking services with effective durations.
- Develop ResourceBookingSlotController to handle API requests for resource booking slots.
- Implement ResourceBookingSlotService to calculate available time slots based on resource occupancy and service durations.
- Add tests for resource appointment creation and booking slot functionality to ensure correct behavior and edge cases.
This commit is contained in:
hamed
2026-08-03 14:34:23 +03:30
parent 981261ed3a
commit 4f69bc9044
21 changed files with 2045 additions and 514 deletions
@@ -1,34 +1,53 @@
import React from 'react';
import { PlusIcon } from '@heroicons/react/24/outline';
import { formatNumber } from '../../lib/utils';
import StatusBadge from '../ui/StatusBadge';
import { useQuery } from '@tanstack/react-query';
import { api } from '../../lib/api';
import type { ApiResponse } from '../../lib/api';
import TurnsTimeline from './TurnsTimeline';
import { buildServiceTimeline } from './serviceTimeline';
import type { TimelineSlot } from './types';
import type { Appointment, ClinicResource } from '../../types';
function hhmm(ts: number): string {
const d = new Date(ts * 1000);
return `${String(d.getHours()).padStart(2, '0')}:${String(d.getMinutes()).padStart(2, '0')}`;
interface DaySlotsData {
windows: { start: number; end: number; start_time: string; end_time: string }[];
empty_reason: string | null;
}
/**
* نمای روزِ یک منبع.
* نمای روزِ یک منبع — همان تایم‌لاینِ نوبت‌دهی سرویسیِ پزشک، با تقویم خودِ منبع.
*
* عمداً تایم‌لاین اسلاتی نیست: منبع برنامهٔ هفتگیِ اسلات‌شده ندارد، تقویمش از مدتِ
* سرویس‌ها و اشغالِ واقعی ساخته می‌شود. نشان دادن شبکهٔ اسلات برای منبع یعنی وعدهٔ
* زمان‌هایی که موتور رزرو اصلاً نمی‌شناسد.
*
* پس فقط دو چیز: آنچه امروز روی این منبع رزرو شده، و یک راه برای افزودن نوبتِ سرویسی.
* منبع اسلاتِ ثابت ندارد: بازهٔ کاری از شیفت خودش می‌آید و طول هر نوبت از سرویس‌هایش،
* پس ردیف‌ها «نوبت‌های رزروشده + بازه‌های خالیِ بینشان»اند. کامپوننت تایم‌لاین یکی
* است تا کارت، وضعیت و عملیاتِ نوبت در هر دو نما یک چیز باشند.
*/
export default function ResourceDayPanel({
resource, appointments, loading, canCreate, onBook, onView,
resource, date, appointments, loading, canCreate, queryKey, onBook, onView,
}: {
resource: ClinicResource;
/** روزِ نمایش، ISO `Y-m-d`. */
date: string;
appointments: Appointment[];
loading: boolean;
canCreate: boolean;
onBook: () => void;
queryKey: unknown[];
onBook: (slot: TimelineSlot | null) => void;
onView: (appointment: Appointment) => void;
}) {
const rows = [...appointments].sort((a, b) => a.slot_start - b.slot_start);
const dayQuery = useQuery<ApiResponse<DaySlotsData>>({
queryKey: ['resource-day-slots', resource.uuid, date],
queryFn: () => api.get(`/api/v1/resource/${resource.uuid}/day-slots?date=${date}`),
});
const data = dayQuery.data?.data;
const windows = data?.windows ?? [];
const slots = React.useMemo(
() => buildServiceTimeline(windows, appointments),
[windows, appointments],
);
const workingRange = windows.length > 0
? { start: windows[0].start_time, end: windows[windows.length - 1].end_time }
: null;
return (
<div>
@@ -38,62 +57,23 @@ export default function ResourceDayPanel({
background: 'var(--surface-2)', border: '1px solid var(--border)',
fontSize: 12.5, color: 'var(--text-2)',
}}>
<span>
نوبتدهی این منبع سرویسی است زمان از مدت سرویس و آزادبودن خودِ منبع میآید، نه از اسلات ثابت.
</span>
{resource.supervisor && (
<span style={{ color: 'var(--text-3)', flexShrink: 0 }}>
زیر نظر {resource.supervisor.name}
<span>نوبتدهی سرویسی نوبتها بر اساس مدت سرویس چیده میشوند.</span>
{workingRange && (
<span dir="ltr" style={{ color: 'var(--text-3)', flexShrink: 0 }}>
ساعت کاری: {workingRange.start} - {workingRange.end}
</span>
)}
</div>
{canCreate && (
<button
type="button"
className="btn primary"
onClick={onBook}
style={{ marginBottom: 14 }}
>
<PlusIcon style={{ width: 16 }} /> افزودن نوبت سرویس
</button>
)}
{loading ? (
<div className="skeleton" style={{ height: 90, borderRadius: 'var(--r-sm)' }} />
) : rows.length === 0 ? (
<p style={{ fontSize: 13, color: 'var(--text-3)', margin: 0, lineHeight: 2 }}>
برای «{resource.name}» در این روز نوبتی ثبت نشده است.
</p>
) : (
<div style={{ display: 'grid', gap: 8 }}>
{rows.map((a) => (
<button
key={a.uuid}
type="button"
onClick={() => onView(a)}
style={{
display: 'flex', alignItems: 'center', gap: 12, width: '100%', textAlign: 'right',
padding: '10px 12px', borderRadius: 'var(--r-sm)', cursor: 'pointer',
border: '1px solid var(--border)', background: 'var(--surface)', fontFamily: 'inherit',
}}
>
<span dir="ltr" style={{ fontSize: 13, fontWeight: 700, color: 'var(--text)', flexShrink: 0 }}>
{hhmm(a.slot_start)} {hhmm(a.slot_end)}
</span>
<span style={{ flex: 1, minWidth: 0, fontSize: 13, color: 'var(--text-2)', overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>
{a.patient_name || '—'}
{a.service_item?.name ? ` · ${a.service_item.name}` : ''}
</span>
<StatusBadge type="appointment" value={a.status} />
</button>
))}
</div>
)}
{rows.length > 0 && (
<p className="field-hint">{formatNumber(rows.length)} نوبت روی این منبع در این روز</p>
)}
<TurnsTimeline
slots={canCreate ? slots : slots.filter(s => s.appointment !== null)}
loading={loading || dayQuery.isLoading}
queryKey={queryKey}
onView={onView}
onBook={onBook}
emptyReason={data?.empty_reason ?? null}
errorMessage={dayQuery.isError ? ((dayQuery.error as Error)?.message || 'خطای نامشخص') : null}
/>
</div>
);
}