import React, { useMemo } from 'react'; import { Link } from 'react-router-dom'; import { formatNumber } from '../../lib/utils'; import type { ResourceTimelineLane, ResourceTimelineItem } from '../../hooks/useResourceTimeline'; const HOUR = 60; const LANE_HEIGHT = 46; const LABEL_WIDTH = 168; function hhmm(timestamp: number): string { const d = new Date(timestamp * 1000); return `${String(d.getHours()).padStart(2, '0')}:${String(d.getMinutes()).padStart(2, '0')}`; } /** ساعتِ خط‌کش با ارقام فارسی — بقیهٔ پنل هم فارسی است. */ function hourLabel(minute: number): string { return String(Math.floor(minute / 60)).padStart(2, '0').replace(/\d/g, (d) => '۰۱۲۳۴۵۶۷۸۹'[Number(d)]); } function minuteOfDay(timestamp: number, dayStart: number): number { return Math.round((timestamp - dayStart) / 60); } /** * بازهٔ ساعتی که نشان داده می‌شود: از شیفت‌ها و اشغال‌های همان روز درمی‌آید، نه ۰ تا ۲۴. * * نمایش کل شبانه‌روز یعنی کلینیکی که ۱۶ تا ۲۱ کار می‌کند، پنج‌ششمِ عرض صفحه‌اش خالی * بماند و بلوک‌ها آن‌قدر باریک شوند که خوانده نشوند. */ function windowOf(lanes: ResourceTimelineLane[], dayStart: number): { from: number; to: number } { let from = 24 * HOUR; let to = 0; lanes.forEach((lane) => { lane.shifts.forEach((s) => { from = Math.min(from, s.start_minute); to = Math.max(to, s.end_minute); }); lane.items.forEach((i) => { from = Math.min(from, minuteOfDay(i.starts_at, dayStart)); to = Math.max(to, minuteOfDay(i.ends_at, dayStart)); }); }); if (from >= to) return { from: 8 * HOUR, to: 20 * HOUR }; // به ساعت گرد می‌شود تا خط‌کش بالای نمودار عدد کامل نشان دهد. return { from: Math.floor(from / HOUR) * HOUR, to: Math.ceil(to / HOUR) * HOUR }; } /** * تایم‌لاین منبع‌محورِ یک روز — یک ردیف به‌ازای هر منبع. * * نمای پزشک‌محور نمی‌گوید چرا ساعتی پر است؛ در مدل منبع‌محور یک نوبت می‌تواند هم‌زمان * اتاق و دستگاه را بگیرد و همان است که ظرفیت را تمام می‌کند. اینجا هر بلوک یک ردیفِ * اشغال است، پس یک نوبتِ چندبخشی روی چند ردیف دیده می‌شود. */ export default function ResourceTimeline({ lanes, dayStart, loading, error }: { lanes: ResourceTimelineLane[]; dayStart: number; loading: boolean; error: string | null; }) { const { from, to } = useMemo(() => windowOf(lanes, dayStart), [lanes, dayStart]); const span = to - from; const hours = useMemo( () => Array.from({ length: Math.floor(span / HOUR) + 1 }, (_, i) => from + i * HOUR), [from, span], ); if (loading) { return
در حال بارگذاری...
; } if (error) { return
{error}
; } if (lanes.length === 0) { return (
هنوز منبعی تعریف نشده است.
تنظیمات ← منابع
); } return (
{/* خط‌کش ساعت */}
{hours.map((m, i) => ( {hourLabel(m)} ))}
{lanes.map((lane) => (
{lane.name} {lane.type_name} {lane.capacity > 1 && <> · ظرفیت {formatNumber(lane.capacity)}}
{/* شیفت کاری: پس‌زمینهٔ روشن‌تر، تا «تعطیل» از «آزاد» فرق کند */} {lane.shifts.map((s, i) => (
))} {hours.map((m) => (
))} {lane.items.map((item) => ( ))} {lane.shifts.length === 0 && lane.items.length === 0 && ( این روز شیفتی ندارد )}
))}
); } /** * «چقدر از این ردیف هنوز جا دارد» — با ظرفیت حساب شده، نه پر/خالی. * * منبع ظرفیت‌۳ که دو نوبت هم‌زمان دارد هنوز آزاد است؛ بدون این عدد، کاربر از روی * بلوک‌های تودرتو باید حدس بزند. */ function LaneLoad({ lane }: { lane: ResourceTimelineLane }) { if (lane.shift_minutes === 0) { return بدون شیفت; } const full = lane.free_minutes === 0; return ( {full ? 'تکمیل' : `${formatNumber(lane.free_minutes)} دقیقه آزاد`} ); } function Block({ item, dayStart, from, span }: { item: ResourceTimelineItem; dayStart: number; from: number; span: number; }) { const start = minuteOfDay(item.starts_at, dayStart); const end = minuteOfDay(item.ends_at, dayStart); const hold = item.status === 'hold'; const label = [item.patient_name, item.segment_name].filter(Boolean).join(' · '); const title = `${hhmm(item.starts_at)} تا ${hhmm(item.ends_at)}${label ? ` — ${label}` : ''}`; const body = ( {label || hhmm(item.starts_at)} ); const style: React.CSSProperties = { position: 'absolute', top: 8, height: 30, right: `${((start - from) / span) * 100}%`, width: `${Math.max(((end - start) / span) * 100, 1.2)}%`, borderRadius: 6, textDecoration: 'none', // رزرو موقت هنوز نوبت نیست: توپر نشان دادنش یعنی کاربر آن را قطعی بخواند. background: hold ? 'var(--warning-bg)' : 'var(--primary)', border: hold ? '1px dashed var(--warning)' : 'none', }; if (!item.appointment_uuid) { return
{body}
; } return ( {body} ); }