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:
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user