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>
243 lines
9.3 KiB
TypeScript
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>
|
|
);
|
|
}
|