The appointments page only ever showed one doctor's row, but in the resource-first model a single appointment can hold a room and a device at the same time, and that — not the doctor's schedule — is what runs the capacity out. An hour could look free on the doctor's lane while the only alexandrite laser was already taken. A third view, "منابع", draws one lane per resource for the selected day. Blocks come from resource_occupancy rather than the appointment: that range includes the device's setup and cleanup minutes and is the same range the availability engine treats as busy. A multi-segment appointment therefore shows up on every resource it holds, and each block links to the appointment it belongs to. GET /api/v1/resources/timeline keeps a fixed query count — one for occupancy, one for shifts, one for the patient names — instead of one per resource. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
208 lines
7.8 KiB
TypeScript
208 lines
7.8 KiB
TypeScript
import React, { useMemo } from 'react';
|
|
import { Link } from 'react-router-dom';
|
|
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 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 <div style={{ padding: 40, textAlign: 'center', color: 'var(--text-3)' }}>در حال بارگذاری...</div>;
|
|
}
|
|
|
|
if (error) {
|
|
return <div style={{ padding: 40, textAlign: 'center', color: 'var(--danger)' }}>{error}</div>;
|
|
}
|
|
|
|
if (lanes.length === 0) {
|
|
return (
|
|
<div style={{ padding: 40, textAlign: 'center', color: 'var(--text-3)', fontSize: 13, lineHeight: 2 }}>
|
|
هنوز منبعی تعریف نشده است.
|
|
<br />
|
|
<Link to="/admin/resources" style={{ color: 'var(--primary)' }}>تنظیمات ← منابع</Link>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
return (
|
|
<div style={{ overflowX: 'auto' }}>
|
|
<div style={{ minWidth: 720 }}>
|
|
{/* خطکش ساعت */}
|
|
<div style={{ display: 'flex', alignItems: 'flex-end', marginBottom: 6 }}>
|
|
<div style={{ width: LABEL_WIDTH, flexShrink: 0 }} />
|
|
<div style={{ position: 'relative', flex: 1, height: 18 }}>
|
|
{hours.map((m) => (
|
|
<span
|
|
key={m}
|
|
dir="ltr"
|
|
style={{
|
|
position: 'absolute', right: `${((m - from) / span) * 100}%`,
|
|
transform: 'translateX(50%)', fontSize: 11, color: 'var(--text-3)',
|
|
}}
|
|
>
|
|
{String(Math.floor(m / 60)).padStart(2, '0')}
|
|
</span>
|
|
))}
|
|
</div>
|
|
</div>
|
|
|
|
{lanes.map((lane) => (
|
|
<div key={lane.uuid} style={{ display: 'flex', alignItems: 'center', marginBottom: 6 }}>
|
|
<div style={{ width: LABEL_WIDTH, flexShrink: 0, paddingLeft: 10, minWidth: 0 }}>
|
|
<Link
|
|
to={`/admin/resources/${lane.uuid}`}
|
|
style={{ fontSize: 13, fontWeight: 600, color: 'var(--text)', textDecoration: 'none', display: 'block' }}
|
|
>
|
|
{lane.name}
|
|
</Link>
|
|
<span style={{ fontSize: 11, color: 'var(--text-3)' }}>{lane.type_name}</span>
|
|
</div>
|
|
|
|
<div style={{
|
|
position: 'relative', flex: 1, height: LANE_HEIGHT,
|
|
background: 'var(--surface-2)', borderRadius: 'var(--r-sm)', overflow: 'hidden',
|
|
}}>
|
|
{/* شیفت کاری: پسزمینهٔ روشنتر، تا «تعطیل» از «آزاد» فرق کند */}
|
|
{lane.shifts.map((s, i) => (
|
|
<div
|
|
key={i}
|
|
style={{
|
|
position: 'absolute', top: 0, bottom: 0,
|
|
right: `${((s.start_minute - from) / span) * 100}%`,
|
|
width: `${((s.end_minute - s.start_minute) / span) * 100}%`,
|
|
background: 'var(--surface)',
|
|
}}
|
|
/>
|
|
))}
|
|
|
|
{hours.map((m) => (
|
|
<div
|
|
key={m}
|
|
style={{
|
|
position: 'absolute', top: 0, bottom: 0, right: `${((m - from) / span) * 100}%`,
|
|
width: 1, background: 'var(--border)',
|
|
}}
|
|
/>
|
|
))}
|
|
|
|
{lane.items.map((item) => (
|
|
<Block key={item.uuid} item={item} dayStart={dayStart} from={from} span={span} />
|
|
))}
|
|
|
|
{lane.shifts.length === 0 && lane.items.length === 0 && (
|
|
<span style={{
|
|
position: 'absolute', inset: 0, display: 'flex', alignItems: 'center', justifyContent: 'center',
|
|
fontSize: 11.5, color: 'var(--text-3)',
|
|
}}>
|
|
این روز شیفتی ندارد
|
|
</span>
|
|
)}
|
|
</div>
|
|
</div>
|
|
))}
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
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 = (
|
|
<span style={{
|
|
display: 'block', overflow: 'hidden', whiteSpace: 'nowrap', textOverflow: 'ellipsis',
|
|
fontSize: 11.5, fontWeight: 600, lineHeight: '30px', padding: '0 6px',
|
|
color: hold ? 'var(--warning)' : 'var(--on-primary)',
|
|
}}>
|
|
{label || hhmm(item.starts_at)}
|
|
</span>
|
|
);
|
|
|
|
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 <div title={title} style={style}>{body}</div>;
|
|
}
|
|
|
|
return (
|
|
<Link to={`/admin/appointments/${item.appointment_uuid}`} title={title} style={style}>
|
|
{body}
|
|
</Link>
|
|
);
|
|
}
|