Files
clinicpro/assets/admin/components/appointments/ResourceTimeline.tsx
T
hamedandClaude Opus 5 5e87bbc18b feat(appointments): resources share the doctors' timeline
Three views become two. The resource lanes were a separate tab, which meant
reading a doctor's free hour on one screen and the laser's on another and
matching them by eye — while in the resource-first model it is the device and
the room that decide whether that hour is really free. They now sit under the
same "زمانبندی" view, below the doctor's slots.

Each lane says how much of its shift is still free, and that number respects
capacity: a minute counts as busy only once the overlapping bookings reach
the resource's capacity, so a three-bed room with two appointments is still
open. Treating it otherwise would silently turn every multi-capacity resource
into a single-capacity one. ResourceFreeTimeCalculator does the sweep and
carries nine cases of its own.

only_bookable=1 keeps resources with no service offering out of the view;
they could only ever render an empty lane. On the seeded clinic that is five
resources down to two.

Two ruler defects the screenshot caught: hours rendered in Latin digits, and
the last label was half-clipped by the container so 21 read as 2.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-02 16:14:04 +03:30

243 lines
9.3 KiB
TypeScript

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 <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, i) => (
<span
key={m}
dir="ltr"
style={{
position: 'absolute', right: `${((m - from) / span) * 100}%`,
// برچسبِ دو سرِ خط‌کش وسط‌چین نمی‌شود، وگرنه نصفش بیرون قاب می‌افتد
// و «۲۱» به «۲» تبدیل می‌شود.
transform: i === 0 ? 'translateX(0)'
: i === hours.length - 1 ? 'translateX(100%)'
: 'translateX(50%)',
fontSize: 11, color: 'var(--text-3)',
}}
>
{hourLabel(m)}
</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}
{lane.capacity > 1 && <> · ظرفیت {formatNumber(lane.capacity)}</>}
</span>
<LaneLoad lane={lane} />
</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 LaneLoad({ lane }: { lane: ResourceTimelineLane }) {
if (lane.shift_minutes === 0) {
return <span style={{ fontSize: 11, color: 'var(--text-3)' }}>بدون شیفت</span>;
}
const full = lane.free_minutes === 0;
return (
<span style={{ fontSize: 11, color: full ? 'var(--danger)' : 'var(--success)' }}>
{full ? 'تکمیل' : `${formatNumber(lane.free_minutes)} دقیقه آزاد`}
</span>
);
}
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>
);
}