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>
This commit is contained in:
hamed
2026-08-02 16:14:04 +03:30
co-authored by Claude Opus 5
parent b8e8580867
commit 5e87bbc18b
12 changed files with 455 additions and 46 deletions
@@ -16,6 +16,9 @@ function lane(overrides: Partial<ResourceTimelineLane> = {}): ResourceTimelineLa
address_name: 'شعبهٔ مرکزی',
capacity: 1,
shifts: [{ start_minute: 9 * 60, end_minute: 17 * 60 }],
shift_minutes: 480,
busy_minutes: 60,
free_minutes: 420,
items: [
{
uuid: 'o-1',
@@ -96,4 +99,34 @@ describe('ResourceTimeline', () => {
expect(screen.getByText('خطای داخلی سرور')).toBeInTheDocument();
});
/** ظرفیت و وقت آزاد باید روی خودِ ردیف باشد، نه حدسِ کاربر از روی بلوک‌ها. */
it('وقت آزاد هر ردیف را نشان می‌دهد', () => {
renderWithProviders(
<ResourceTimeline lanes={[lane()]} dayStart={DAY_START} loading={false} error={null} />,
);
expect(screen.getByText('۴۲۰ دقیقه آزاد')).toBeInTheDocument();
});
it('ردیفِ پرشده «تکمیل» می‌گوید، نه صفر دقیقه', () => {
renderWithProviders(
<ResourceTimeline
lanes={[lane({ busy_minutes: 480, free_minutes: 0 })]}
dayStart={DAY_START}
loading={false}
error={null}
/>,
);
expect(screen.getByText('تکمیل')).toBeInTheDocument();
});
it('ظرفیت بیش از یک را کنار نوع منبع می‌آورد', () => {
renderWithProviders(
<ResourceTimeline lanes={[lane({ capacity: 3 })]} dayStart={DAY_START} loading={false} error={null} />,
);
expect(screen.getByText(/ظرفیت ۳/)).toBeInTheDocument();
});
});
@@ -1,5 +1,6 @@
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;
@@ -11,6 +12,11 @@ function hhmm(timestamp: number): string {
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);
}
@@ -88,16 +94,21 @@ export default function ResourceTimeline({ lanes, dayStart, loading, error }: {
<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) => (
{hours.map((m, i) => (
<span
key={m}
dir="ltr"
style={{
position: 'absolute', right: `${((m - from) / span) * 100}%`,
transform: 'translateX(50%)', fontSize: 11, color: 'var(--text-3)',
// برچسبِ دو سرِ خط‌کش وسط‌چین نمی‌شود، وگرنه نصفش بیرون قاب می‌افتد
// و «۲۱» به «۲» تبدیل می‌شود.
transform: i === 0 ? 'translateX(0)'
: i === hours.length - 1 ? 'translateX(100%)'
: 'translateX(50%)',
fontSize: 11, color: 'var(--text-3)',
}}
>
{String(Math.floor(m / 60)).padStart(2, '0')}
{hourLabel(m)}
</span>
))}
</div>
@@ -112,7 +123,11 @@ export default function ResourceTimeline({ lanes, dayStart, loading, error }: {
>
{lane.name}
</Link>
<span style={{ fontSize: 11, color: 'var(--text-3)' }}>{lane.type_name}</span>
<span style={{ fontSize: 11, color: 'var(--text-3)' }}>
{lane.type_name}
{lane.capacity > 1 && <> · ظرفیت {formatNumber(lane.capacity)}</>}
</span>
<LaneLoad lane={lane} />
</div>
<div style={{
@@ -162,6 +177,26 @@ export default function ResourceTimeline({ lanes, dayStart, loading, error }: {
);
}
/**
* «چقدر از این ردیف هنوز جا دارد» — با ظرفیت حساب شده، نه پر/خالی.
*
* منبع ظرفیت‌۳ که دو نوبت هم‌زمان دارد هنوز آزاد است؛ بدون این عدد، کاربر از روی
* بلوک‌های تودرتو باید حدس بزند.
*/
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;
@@ -2,15 +2,15 @@
* سوییچ کشویی نمای نوبت‌ها — بازسازی `TurnsViewModeToggle.jsx` طرح tauri
* (پس‌زمینهٔ لغزنده، متن فعال #5559ce).
*
* نمای «منابع» بعداً اضافه شد چون در مدل منبع‌محور، پرشدن یک ساعت را دستگاه و اتاق
* تعیین می‌کنند نه فقط برنامهٔ پزشک.
* منابع نمای جدا ندارند: ردیفشان درون همان نمای «زمانبندی» می‌آید، چون پرشدن یک ساعت
* را دستگاه و اتاق تعیین می‌کنند نه فقط برنامهٔ پزشک — و دیدنشان جدا از هم یعنی کاربر
* باید دو نما را با چشم تطبیق دهد.
*/
export type TurnsViewMode = 'table' | 'timeline' | 'resources';
export type TurnsViewMode = 'table' | 'timeline';
const MODES: { id: TurnsViewMode; label: string }[] = [
{ id: 'table', label: 'نمایش جدولی' },
{ id: 'timeline', label: 'زمانبندی' },
{ id: 'resources', label: 'منابع' },
];
export default function TurnsViewToggle({
@@ -27,7 +27,7 @@ export default function TurnsViewToggle({
role="tablist"
aria-label="نمای نوبت‌ها"
style={{
position: 'relative', width: 272, height: 44, display: 'flex', overflow: 'hidden',
position: 'relative', width: 193, height: 44, display: 'flex', overflow: 'hidden',
border: '1px solid var(--border)', borderRadius: 'var(--r-sm)', background: 'var(--surface)',
}}
>
+9 -3
View File
@@ -20,6 +20,10 @@ export type ResourceTimelineLane = {
address_name: string | null;
capacity: number;
shifts: { start_minute: number; end_minute: number }[];
/** دقیقهٔ شیفت، دقیقهٔ پرشده (ظرفیت‌آگاه) و باقی‌ماندهٔ آزاد. */
shift_minutes: number;
busy_minutes: number;
free_minutes: number;
items: ResourceTimelineItem[];
};
@@ -35,12 +39,14 @@ export type ResourceTimelineDay = {
* بازه‌ها از جدول اشغال می‌آیند نه از خودِ نوبت: آماده‌سازی و تمیزکاری دستگاه هم
* درونشان است و همان بازه‌ای است که موتور جستجو اشغال می‌بیند.
*/
export function useResourceTimeline(date: string, addressUuid?: string) {
export function useResourceTimeline(date: string, options: { addressUuid?: string; onlyBookable?: boolean } = {}) {
const params = new URLSearchParams({ date });
if (addressUuid) params.set('address_uuid', addressUuid);
if (options.addressUuid) params.set('address_uuid', options.addressUuid);
// منبعی که به هیچ سرویسی وصل نیست ردیفِ همیشه‌خالی است؛ در تایم‌لاین نوبت‌ها نمی‌آید.
if (options.onlyBookable) params.set('only_bookable', '1');
const query = useQuery({
queryKey: ['resource-timeline', date, addressUuid ?? null],
queryKey: ['resource-timeline', date, options.addressUuid ?? null, options.onlyBookable ?? false],
queryFn: () => api.get<ApiResponse<ResourceTimelineDay>>(`/api/v1/resources/timeline?${params}`),
enabled: !!date,
});
+16 -11
View File
@@ -618,13 +618,13 @@ export default function AppointmentsPage() {
const adminLocation = adminLocations.find(l => locKey(l) === adminLocKey) ?? adminLocations[0] ?? null;
const effectiveClinicUuid: string | null = isAdmin ? (adminLocation?.clinic_uuid ?? null) : clinicUuid;
// نمای منبع‌محور فقط وقتی درخواست می‌فرستد که انتخاب شده باشد؛ در نمای جدولی
// یک کوئری اضافه به‌ازای هر تغییر روز، بی‌مصرف است.
// ردیف منابع بخشی از همان نمای زمانبندی است: در مدل منبع‌محور، پرشدن یک ساعت را
// دستگاه و اتاق تعیین می‌کنند نه فقط برنامهٔ پزشک. در نمای جدولی کوئری نمی‌رود.
const {
day: resourceDay,
loading: resourceTimelineLoading,
error: resourceTimelineError,
} = useResourceTimeline(viewMode === 'resources' ? selectedDate : '');
} = useResourceTimeline(viewMode === 'timeline' ? selectedDate : '', { onlyBookable: true });
// ── Slots query (timeline)
// effectiveClinicUuid باید در کلید کش باشد، وگرنه برنامهٔ یک محیط برای محیط دیگر نشان داده می‌شود.
@@ -841,14 +841,7 @@ export default function AppointmentsPage() {
<DoctorTabs doctors={doctors} selected={selectedDoctorUuid} onSelect={setSelectedDoctorUuid} showAll={isAdmin} />
)}
<div style={{ padding: 16 }}>
{viewMode === 'resources' ? (
<ResourceTimeline
lanes={resourceDay?.resources ?? []}
dayStart={resourceDay?.date ?? 0}
loading={resourceTimelineLoading}
error={resourceTimelineError}
/>
) : viewMode === 'table' ? (
{viewMode === 'table' ? (
<>
<TurnsTable
items={pagedAppointments}
@@ -895,6 +888,18 @@ export default function AppointmentsPage() {
/>
</>
)}
{/* منابعِ قابل رزرو، زیر همان روز — یک نوبت می‌تواند هم‌زمان اتاق و
دستگاه را بگیرد و آن است که ظرفیت را تمام می‌کند. */}
<div style={{ marginTop: 'var(--gap)', paddingTop: 'var(--gap)', borderTop: '1px solid var(--border)' }}>
<h2 className="section-title" style={{ margin: '0 0 12px', fontSize: 15 }}>منابع</h2>
<ResourceTimeline
lanes={resourceDay?.resources ?? []}
dayStart={resourceDay?.date ?? 0}
loading={resourceTimelineLoading}
error={resourceTimelineError}
/>
</div>
</>
)}
</div>