feat(appointments): a resource-first view on the timeline

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>
This commit is contained in:
hamed
2026-08-02 14:14:10 +03:30
co-authored by Claude Opus 5
parent eeb9ae851a
commit 444ebc897a
9 changed files with 824 additions and 36 deletions
@@ -0,0 +1,99 @@
import { describe, it, expect } from 'vitest';
import { screen } from '@testing-library/react';
import { renderWithProviders } from '../../test/utils';
import ResourceTimeline from './ResourceTimeline';
import type { ResourceTimelineLane } from '../../hooks/useResourceTimeline';
/** نیمه‌شبِ یک روز ثابت، تا موقعیت بلوک‌ها به ساعت اجرای تست وابسته نباشد. */
const DAY_START = Math.floor(new Date(2026, 7, 5, 0, 0, 0).getTime() / 1000);
const at = (hour: number, minute = 0) => DAY_START + hour * 3600 + minute * 60;
function lane(overrides: Partial<ResourceTimelineLane> = {}): ResourceTimelineLane {
return {
uuid: 'r-1',
name: 'لیزر دایود',
type_name: 'دستگاه لیزر',
address_name: 'شعبهٔ مرکزی',
capacity: 1,
shifts: [{ start_minute: 9 * 60, end_minute: 17 * 60 }],
items: [
{
uuid: 'o-1',
starts_at: at(10),
ends_at: at(11),
status: 'booked',
segment_name: 'لیزر',
appointment_id: 47,
patient_name: 'زهرا احمدی',
appointment_uuid: 'appt-1',
appointment_status: 'confirmed',
},
],
...overrides,
};
}
describe('ResourceTimeline', () => {
it('یک ردیف به‌ازای هر منبع با نام بیمار و بخش نشان می‌دهد', () => {
renderWithProviders(
<ResourceTimeline lanes={[lane()]} dayStart={DAY_START} loading={false} error={null} />,
);
expect(screen.getByText('لیزر دایود')).toBeInTheDocument();
expect(screen.getByText('دستگاه لیزر')).toBeInTheDocument();
expect(screen.getByText('زهرا احمدی · لیزر')).toBeInTheDocument();
});
it('بلوک به جزئیات همان نوبت لینک می‌دهد', () => {
renderWithProviders(
<ResourceTimeline lanes={[lane()]} dayStart={DAY_START} loading={false} error={null} />,
);
expect(screen.getByText('زهرا احمدی · لیزر').closest('a'))
.toHaveAttribute('href', '/admin/appointments/appt-1');
});
/**
* یک نوبت می‌تواند هم‌زمان اتاق و دستگاه را بگیرد؛ همین است که ظرفیت را تمام می‌کند و
* نمای پزشک‌محور نشانش نمی‌دهد.
*/
it('یک نوبت روی چند منبع، روی هر ردیف دیده می‌شود', () => {
const room = lane({
uuid: 'r-2',
name: 'اتاق لیزر ۱',
type_name: 'اتاق',
items: [{ ...lane().items[0], uuid: 'o-2', segment_name: 'بی‌حسی موضعی' }],
});
renderWithProviders(
<ResourceTimeline lanes={[lane(), room]} dayStart={DAY_START} loading={false} error={null} />,
);
expect(screen.getByText('زهرا احمدی · لیزر')).toBeInTheDocument();
expect(screen.getByText('زهرا احمدی · بی‌حسی موضعی')).toBeInTheDocument();
});
it('روزِ بدون شیفت و بدون نوبت را صریح می‌گوید', () => {
renderWithProviders(
<ResourceTimeline lanes={[lane({ shifts: [], items: [] })]} dayStart={DAY_START} loading={false} error={null} />,
);
expect(screen.getByText('این روز شیفتی ندارد')).toBeInTheDocument();
});
it('بدون هیچ منبعی به صفحهٔ تعریف منبع راه می‌دهد', () => {
renderWithProviders(
<ResourceTimeline lanes={[]} dayStart={DAY_START} loading={false} error={null} />,
);
expect(screen.getByText('تنظیمات ← منابع').closest('a')).toHaveAttribute('href', '/admin/resources');
});
it('خطای سرور را نشان می‌دهد، نه تایم‌لاین خالی', () => {
renderWithProviders(
<ResourceTimeline lanes={[]} dayStart={DAY_START} loading={false} error="خطای داخلی سرور" />,
);
expect(screen.getByText('خطای داخلی سرور')).toBeInTheDocument();
});
});
@@ -0,0 +1,207 @@
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>
);
}
@@ -1,8 +1,17 @@
/**
* سوییچ کشویی «نمایش جدولی / زمانبندی» — بازسازی `TurnsViewModeToggle.jsx` طرح
* tauri (کادر ۱۹۳×۴۸، پس‌زمینهٔ لغزنده، متن فعال #5559ce).
* سوییچ کشویی نمای نوبت‌ها — بازسازی `TurnsViewModeToggle.jsx` طرح tauri
* (پس‌زمینهٔ لغزنده، متن فعال #5559ce).
*
* نمای «منابع» بعداً اضافه شد چون در مدل منبع‌محور، پرشدن یک ساعت را دستگاه و اتاق
* تعیین می‌کنند نه فقط برنامهٔ پزشک.
*/
export type TurnsViewMode = 'table' | 'timeline';
export type TurnsViewMode = 'table' | 'timeline' | 'resources';
const MODES: { id: TurnsViewMode; label: string }[] = [
{ id: 'table', label: 'نمایش جدولی' },
{ id: 'timeline', label: 'زمانبندی' },
{ id: 'resources', label: 'منابع' },
];
export default function TurnsViewToggle({
viewMode, onChange,
@@ -10,42 +19,46 @@ export default function TurnsViewToggle({
viewMode: TurnsViewMode;
onChange: (m: TurnsViewMode) => void;
}) {
const activeText = 'var(--primary)';
const idleText = 'var(--text-3)';
const index = Math.max(MODES.findIndex((m) => m.id === viewMode), 0);
const width = 100 / MODES.length;
return (
<div style={{
position: 'relative', width: 193, height: 44, display: 'flex', overflow: 'hidden',
border: '1px solid var(--border)', borderRadius: 'var(--r-sm)', background: 'var(--surface)',
}}>
{/* پس‌زمینهٔ لغزنده */}
<div
role="tablist"
aria-label="نمای نوبت‌ها"
style={{
position: 'relative', width: 272, height: 44, display: 'flex', overflow: 'hidden',
border: '1px solid var(--border)', borderRadius: 'var(--r-sm)', background: 'var(--surface)',
}}
>
{/* پس‌زمینهٔ لغزنده — RTL، پس اولین حالت سمت راست است */}
<div style={{
position: 'absolute', top: 0, right: 0, height: '100%', width: '50%',
position: 'absolute', top: 0, right: 0, height: '100%', width: `${width}%`,
background: 'var(--primary-soft)', transition: 'transform .3s var(--ease)',
transform: viewMode === 'table' ? 'translateX(100%)' : 'translateX(0%)',
transform: `translateX(${index * -100}%)`,
}} />
<button
type="button"
onClick={() => onChange('table')}
style={{
position: 'relative', zIndex: 1, width: '50%', height: '100%', border: 'none',
background: 'transparent', cursor: 'pointer', fontFamily: 'inherit',
fontSize: 13, fontWeight: 500, color: viewMode === 'table' ? activeText : idleText,
}}
>
نمایش جدولی
</button>
<div style={{ position: 'relative', zIndex: 1, width: 1, height: '100%', background: 'var(--border)' }} />
<button
type="button"
onClick={() => onChange('timeline')}
style={{
position: 'relative', zIndex: 1, width: '50%', height: '100%', border: 'none',
background: 'transparent', cursor: 'pointer', fontFamily: 'inherit',
fontSize: 13, fontWeight: 500, color: viewMode === 'timeline' ? activeText : idleText,
}}
>
زمانبندی
</button>
{MODES.map((m, i) => (
<div key={m.id} style={{ display: 'contents' }}>
{i > 0 && (
<div style={{ position: 'relative', zIndex: 1, width: 1, height: '100%', background: 'var(--border)' }} />
)}
<button
type="button"
role="tab"
aria-selected={viewMode === m.id}
onClick={() => onChange(m.id)}
style={{
position: 'relative', zIndex: 1, flex: 1, height: '100%', border: 'none',
background: 'transparent', cursor: 'pointer', fontFamily: 'inherit',
fontSize: 13, fontWeight: 500,
color: viewMode === m.id ? 'var(--primary)' : 'var(--text-3)',
}}
>
{m.label}
</button>
</div>
))}
</div>
);
}