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:
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,53 @@
|
||||
import { useQuery } from '@tanstack/react-query';
|
||||
import { api, type ApiResponse } from '../lib/api';
|
||||
|
||||
export type ResourceTimelineItem = {
|
||||
uuid: string;
|
||||
starts_at: number;
|
||||
ends_at: number;
|
||||
status: 'booked' | 'hold';
|
||||
segment_name: string | null;
|
||||
appointment_id: number | null;
|
||||
patient_name: string | null;
|
||||
appointment_uuid: string | null;
|
||||
appointment_status: string | null;
|
||||
};
|
||||
|
||||
export type ResourceTimelineLane = {
|
||||
uuid: string;
|
||||
name: string;
|
||||
type_name: string;
|
||||
address_name: string | null;
|
||||
capacity: number;
|
||||
shifts: { start_minute: number; end_minute: number }[];
|
||||
items: ResourceTimelineItem[];
|
||||
};
|
||||
|
||||
export type ResourceTimelineDay = {
|
||||
date: number;
|
||||
day_of_week: number;
|
||||
resources: ResourceTimelineLane[];
|
||||
};
|
||||
|
||||
/**
|
||||
* «کدام منبع در این روز کِی گرفته است» — ورودی نمای منبعمحورِ صفحهٔ نوبتها.
|
||||
*
|
||||
* بازهها از جدول اشغال میآیند نه از خودِ نوبت: آمادهسازی و تمیزکاری دستگاه هم
|
||||
* درونشان است و همان بازهای است که موتور جستجو اشغال میبیند.
|
||||
*/
|
||||
export function useResourceTimeline(date: string, addressUuid?: string) {
|
||||
const params = new URLSearchParams({ date });
|
||||
if (addressUuid) params.set('address_uuid', addressUuid);
|
||||
|
||||
const query = useQuery({
|
||||
queryKey: ['resource-timeline', date, addressUuid ?? null],
|
||||
queryFn: () => api.get<ApiResponse<ResourceTimelineDay>>(`/api/v1/resources/timeline?${params}`),
|
||||
enabled: !!date,
|
||||
});
|
||||
|
||||
return {
|
||||
day: query.data?.data,
|
||||
loading: query.isLoading,
|
||||
error: query.isError ? ((query.error as Error)?.message || 'خطای نامشخص') : null,
|
||||
};
|
||||
}
|
||||
@@ -23,6 +23,8 @@ import SearchableSelect from '../components/ui/SearchableSelect';
|
||||
import TurnsStatInfo from '../components/appointments/TurnsStatInfo';
|
||||
import TurnsViewToggle from '../components/appointments/TurnsViewToggle';
|
||||
import type { TurnsViewMode } from '../components/appointments/TurnsViewToggle';
|
||||
import ResourceTimeline from '../components/appointments/ResourceTimeline';
|
||||
import { useResourceTimeline } from '../hooks/useResourceTimeline';
|
||||
import DoctorTabs from '../components/appointments/DoctorTabs';
|
||||
import TurnsTimeline from '../components/appointments/TurnsTimeline';
|
||||
import TurnsTable from '../components/appointments/TurnsTable';
|
||||
@@ -616,6 +618,14 @@ 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 : '');
|
||||
|
||||
// ── Slots query (timeline)
|
||||
// effectiveClinicUuid باید در کلید کش باشد، وگرنه برنامهٔ یک محیط برای محیط دیگر نشان داده میشود.
|
||||
const slotsQueryKey = ['appt-slots', selectedDoctorUuid, selectedDate, effectiveClinicUuid];
|
||||
@@ -831,7 +841,14 @@ export default function AppointmentsPage() {
|
||||
<DoctorTabs doctors={doctors} selected={selectedDoctorUuid} onSelect={setSelectedDoctorUuid} showAll={isAdmin} />
|
||||
)}
|
||||
<div style={{ padding: 16 }}>
|
||||
{viewMode === 'table' ? (
|
||||
{viewMode === 'resources' ? (
|
||||
<ResourceTimeline
|
||||
lanes={resourceDay?.resources ?? []}
|
||||
dayStart={resourceDay?.date ?? 0}
|
||||
loading={resourceTimelineLoading}
|
||||
error={resourceTimelineError}
|
||||
/>
|
||||
) : viewMode === 'table' ? (
|
||||
<>
|
||||
<TurnsTable
|
||||
items={pagedAppointments}
|
||||
|
||||
@@ -335,6 +335,59 @@
|
||||
> **اتمی است.** اعتبارسنجی کل فهرست پیش از هر حذفی انجام میشود، پس یک ردیف نامعتبر
|
||||
> در انتهای فهرست، مهارتهای درستِ قبلی را پاک نمیکند و بعد ۴۲۲ برگرداند.
|
||||
|
||||
### `GET /api/v1/resources/timeline`
|
||||
|
||||
مجوز: `appointment_settings.view`. پشتِ نمای **منابع** در صفحهٔ نوبتها
|
||||
(`/admin/appointments`) است.
|
||||
|
||||
| پارامتر | پیشفرض | توضیح |
|
||||
|---|---|---|
|
||||
| `date` | امروز | `YYYY-MM-DD` — هر قالب دیگری ۴۲۲ |
|
||||
| `address_uuid` | همهٔ شعبهها | فقط منابع همان شعبه |
|
||||
|
||||
فقط منابع **فعال** برمیگردند و منبعِ بیشیفت هم در فهرست میماند تا ردیفش در تایملاین
|
||||
دیده شود.
|
||||
|
||||
بازهها از `resource_occupancy` میآیند نه از خودِ نوبت: بازهٔ اشغال، آمادهسازی و
|
||||
تمیزکاری منبع را هم در بر دارد و همان بازهای است که موتور جستجو اشغال میبیند. ردیف
|
||||
`released` نمیآید؛ آن تاریخچه است. یک نوبتِ چندبخشی روی چند منبع، چند ردیف دارد —
|
||||
همان چیزی که نمای پزشکمحور نشان نمیدهد.
|
||||
|
||||
خروجی واقعی (سناریوی ۲، ۲۰۲۶-۰۸-۰۵):
|
||||
|
||||
```jsonc
|
||||
{
|
||||
"success": true,
|
||||
"data": {
|
||||
"date": 1785875400, // نیمهشب همان روز
|
||||
"day_of_week": 4, // ۰ = شنبه
|
||||
"resources": [
|
||||
{
|
||||
"uuid": "…", "name": "اتاق لیزر ۱", "type_name": "اتاق درمان",
|
||||
"address_name": "درمانگاه سلامت", "capacity": 1,
|
||||
"shifts": [{ "start_minute": 480, "end_minute": 1260 }],
|
||||
"items": [
|
||||
{
|
||||
"uuid": "6176e73b-df27-4cdf-815c-36f9bfbd68ca",
|
||||
"starts_at": 1785931200, "ends_at": 1785931500,
|
||||
"status": "booked", "segment_name": "بیحسی موضعی",
|
||||
"appointment_id": 47, "patient_name": "زهرا احمدی",
|
||||
"appointment_uuid": "17086c41-6cc4-4539-bb5b-4c94e8f373c6",
|
||||
"appointment_status": "pending"
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
`shifts` فقط شیفتهای همان روزِ هفته است. **۴۲۲:** قالب `date` غلط
|
||||
(`{"code":"ERR_VALIDATION_002","message":"تاریخ باید به شکل YYYY-MM-DD باشد","field":"date"}`).
|
||||
|
||||
> تعداد کوئری ثابت است: یک کوئری اشغال، یک کوئری شیفت، یک کوئری نوبت — نه یکی بهازای
|
||||
> هر منبع.
|
||||
|
||||
### `PUT /api/v1/resource/{uuid}/categories`
|
||||
|
||||
مجوز: `appointment_settings.update`.
|
||||
|
||||
@@ -132,4 +132,58 @@ class ResourceOccupancyRepository extends ServiceEntityRepository
|
||||
{
|
||||
return $this->findBy(['appointmentId' => $appointmentId]);
|
||||
}
|
||||
|
||||
/**
|
||||
* ردیفهای اشغالِ یک روز برای تایملاین منابع — **یک کوئری برای همهٔ منابع**.
|
||||
*
|
||||
* برخلاف `busyByResource()` که فقط بازه میخواهد، اینجا شناسهٔ نوبت و نام بخش هم
|
||||
* لازم است تا هر بلوک بگوید مالِ کدام بیمار و کدام مرحله است. آرایه برمیگردد نه
|
||||
* entity، چون هیچکدام از این ردیفها قرار نیست تغییر کند.
|
||||
*
|
||||
* @param int[] $resourceIds
|
||||
* @return array<int, list<array{uuid: string, starts_at: int, ends_at: int, status: string, segment_name: ?string, appointment_id: ?int}>>
|
||||
*/
|
||||
public function dayByResource(array $resourceIds, int $from, int $to): array
|
||||
{
|
||||
if ($resourceIds === []) {
|
||||
return [];
|
||||
}
|
||||
|
||||
$rows = $this->createQueryBuilder('o')
|
||||
->select(
|
||||
'IDENTITY(o.resource) AS resource_id',
|
||||
'o.uuid AS uuid',
|
||||
'o.startsAt AS starts_at',
|
||||
'o.endsAt AS ends_at',
|
||||
'o.status AS status',
|
||||
'o.segmentName AS segment_name',
|
||||
'o.appointmentId AS appointment_id',
|
||||
)
|
||||
->where('o.resource IN (:ids)')
|
||||
->andWhere('o.startsAt < :to')
|
||||
->andWhere('o.endsAt > :from')
|
||||
// ردیف آزادشده تاریخچه است؛ در تایملاینِ «الان چه چیزی گرفته است» جا ندارد.
|
||||
->andWhere('o.status IN (:blocking)')
|
||||
->setParameter('blocking', ResourceOccupancy::BLOCKING_STATUSES)
|
||||
->setParameter('ids', $resourceIds)
|
||||
->setParameter('from', $from)
|
||||
->setParameter('to', $to)
|
||||
->orderBy('o.startsAt', 'ASC')
|
||||
->getQuery()
|
||||
->getArrayResult();
|
||||
|
||||
$byResource = [];
|
||||
foreach ($rows as $row) {
|
||||
$byResource[(int) $row['resource_id']][] = [
|
||||
'uuid' => (string) $row['uuid'],
|
||||
'starts_at' => (int) $row['starts_at'],
|
||||
'ends_at' => (int) $row['ends_at'],
|
||||
'status' => (string) $row['status'],
|
||||
'segment_name' => $row['segment_name'] !== null ? (string) $row['segment_name'] : null,
|
||||
'appointment_id' => $row['appointment_id'] !== null ? (int) $row['appointment_id'] : null,
|
||||
];
|
||||
}
|
||||
|
||||
return $byResource;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -31,6 +31,8 @@ class ResourceController extends BaseController
|
||||
private readonly SkillAssignmentService $skills,
|
||||
private readonly ResourceOccupancyRepository $occupancy,
|
||||
private readonly \App\Resource\Service\ResourceServiceAssignmentService $serviceOfferings,
|
||||
private readonly \App\Resource\Repository\ResourceCalendarRepository $calendars,
|
||||
private readonly \App\Appointment\Repository\AppointmentRepository $appointments,
|
||||
) {}
|
||||
|
||||
#[Route('/api/v1/resources', name: 'resource_list', methods: ['GET'])]
|
||||
@@ -177,6 +179,135 @@ class ResourceController extends BaseController
|
||||
return $this->success($resource->toArray());
|
||||
}
|
||||
|
||||
/**
|
||||
* تایملاین یک روز به تفکیک منبع — «کدام دستگاه/اتاق/پزشک کِی گرفته است».
|
||||
*
|
||||
* نمای نوبتها تا امروز فقط ردیف پزشک را نشان میداد؛ در مدل منبعمحور، یک نوبت
|
||||
* میتواند همزمان اتاق و دستگاه را بگیرد و همان چیزی است که ظرفیت را تمام میکند.
|
||||
*
|
||||
* ردیف اشغال از `resource_occupancy` میآید، نه از خودِ نوبت: بازهٔ آن شاملِ
|
||||
* آمادهسازی و تمیزکاری منبع هم هست و همان بازهای است که موتور جستجو میبیند.
|
||||
*/
|
||||
#[Route('/api/v1/resources/timeline', name: 'resource_timeline', methods: ['GET'])]
|
||||
public function timeline(#[CurrentUser] User $user, Request $request): JsonResponse
|
||||
{
|
||||
$this->denyUnlessGranted($user, 'view');
|
||||
|
||||
[$entityType, $entityId] = $this->context->pair($user);
|
||||
|
||||
$dateParam = $request->query->get('date');
|
||||
$from = $this->dayStart(is_string($dateParam) ? $dateParam : null);
|
||||
|
||||
if ($from === null) {
|
||||
return $this->error(ErrorCodes::ERR_VALIDATION_002, 'تاریخ باید به شکل YYYY-MM-DD باشد', 422, 'date');
|
||||
}
|
||||
|
||||
$to = $from + 86400;
|
||||
$addressUuid = $request->query->get('address_uuid');
|
||||
|
||||
$resources = $this->resources->findForPair($entityType, $entityId, [
|
||||
'address' => is_string($addressUuid) && $addressUuid !== '' ? $this->context->address($user, $addressUuid) : null,
|
||||
'type' => null,
|
||||
'active' => true,
|
||||
'skillUuid' => null,
|
||||
]);
|
||||
|
||||
$ids = array_map(static fn (ClinicResource $r): int => (int) $r->getId(), $resources);
|
||||
|
||||
$occupancy = $this->occupancy->dayByResource($ids, $from, $to);
|
||||
$shifts = $this->calendars->findForResources($ids);
|
||||
$dayOfWeek = $this->dayOfWeek($from);
|
||||
$patients = $this->patientLabels($occupancy);
|
||||
|
||||
return $this->success([
|
||||
'date' => $from,
|
||||
'day_of_week' => $dayOfWeek,
|
||||
'resources' => array_map(function (ClinicResource $r) use ($occupancy, $shifts, $dayOfWeek, $patients): array {
|
||||
$id = (int) $r->getId();
|
||||
|
||||
return [
|
||||
'uuid' => $r->getUuid(),
|
||||
'name' => $r->getName(),
|
||||
'type_name' => $r->getType()->getName(),
|
||||
'address_name' => $r->getAddress()->getName(),
|
||||
'capacity' => $r->getCapacity(),
|
||||
'shifts' => array_values(array_map(
|
||||
static fn (\App\Resource\Entity\ResourceCalendar $c): array => [
|
||||
'start_minute' => $c->getStartMinute(),
|
||||
'end_minute' => $c->getEndMinute(),
|
||||
],
|
||||
array_filter(
|
||||
$shifts[$id] ?? [],
|
||||
static fn (\App\Resource\Entity\ResourceCalendar $c): bool => $c->getDayOfWeek() === $dayOfWeek,
|
||||
),
|
||||
)),
|
||||
'items' => array_map(
|
||||
static fn (array $row): array => $row + ($patients[$row['appointment_id']] ?? [
|
||||
'patient_name' => null,
|
||||
'appointment_uuid' => null,
|
||||
'appointment_status' => null,
|
||||
]),
|
||||
$occupancy[$id] ?? [],
|
||||
),
|
||||
];
|
||||
}, $resources),
|
||||
]);
|
||||
}
|
||||
|
||||
/** نیمهشبِ روز خواستهشده؛ بدون پارامتر یعنی امروز. `null` یعنی قالب تاریخ غلط بود. */
|
||||
private function dayStart(?string $date): ?int
|
||||
{
|
||||
if ($date === null || $date === '') {
|
||||
return strtotime('today midnight') ?: null;
|
||||
}
|
||||
|
||||
if (preg_match('/^\d{4}-\d{2}-\d{2}$/', $date) !== 1) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return strtotime($date . ' midnight') ?: null;
|
||||
}
|
||||
|
||||
/** ۰ = شنبه — همان قرارداد تقویم منبع و ساعت کاری شعبه. */
|
||||
private function dayOfWeek(int $timestamp): int
|
||||
{
|
||||
// date('w') یکشنبه را ۰ میگیرد؛ شنبه باید ۰ شود.
|
||||
return (int) ((((int) date('w', $timestamp)) + 1) % 7);
|
||||
}
|
||||
|
||||
/**
|
||||
* نام بیمار و وضعیت نوبتِ هر ردیف اشغال — با **یک** کوئری برای کل روز.
|
||||
*
|
||||
* @param array<int, list<array<string, mixed>>> $occupancy
|
||||
* @return array<int, array{patient_name: ?string, appointment_uuid: string, appointment_status: string}>
|
||||
*/
|
||||
private function patientLabels(array $occupancy): array
|
||||
{
|
||||
$ids = [];
|
||||
foreach ($occupancy as $rows) {
|
||||
foreach ($rows as $row) {
|
||||
if ($row['appointment_id'] !== null) {
|
||||
$ids[(int) $row['appointment_id']] = true;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if ($ids === []) {
|
||||
return [];
|
||||
}
|
||||
|
||||
$labels = [];
|
||||
foreach ($this->appointments->findBy(['id' => array_keys($ids)]) as $appointment) {
|
||||
$labels[(int) $appointment->getId()] = [
|
||||
'patient_name' => $appointment->getPatientName(),
|
||||
'appointment_uuid' => $appointment->getUuid(),
|
||||
'appointment_status' => $appointment->getStatus(),
|
||||
];
|
||||
}
|
||||
|
||||
return $labels;
|
||||
}
|
||||
|
||||
/** جایگزینی کامل مهارتهای منبع: مهارتی که در بدنه نیست، برداشته میشود. */
|
||||
#[Route('/api/v1/resource/{uuid}/skills', name: 'resource_skills_replace', methods: ['PUT'])]
|
||||
public function replaceSkills(#[CurrentUser] User $user, string $uuid, Request $request): JsonResponse
|
||||
|
||||
@@ -0,0 +1,161 @@
|
||||
<?php
|
||||
|
||||
namespace App\Tests\Resource;
|
||||
|
||||
use App\Appointment\Availability\Entity\ResourceOccupancy;
|
||||
use App\Auth\Entity\User;
|
||||
use App\Clinic\Entity\Clinic;
|
||||
use App\Doctor\Entity\DoctorAddress;
|
||||
use App\Resource\Entity\ClinicResource;
|
||||
use App\Resource\Entity\ResourceCalendar;
|
||||
use App\Resource\Entity\ResourceType;
|
||||
use App\Tests\ApiTestCase;
|
||||
|
||||
/**
|
||||
* تایملاین منابع در صفحهٔ نوبتها: «کدام دستگاه/اتاق کِی گرفته است».
|
||||
*
|
||||
* ردیفها از `resource_occupancy` میآیند نه از خودِ نوبت، چون بازهٔ اشغال شاملِ
|
||||
* آمادهسازی و تمیزکاری هم هست و همان چیزی است که ظرفیت را تمام میکند.
|
||||
*/
|
||||
class ResourceTimelineEndpointTest extends ApiTestCase
|
||||
{
|
||||
/** @return array{0: User, 1: ClinicResource} */
|
||||
private function clinicWithResource(): array
|
||||
{
|
||||
$user = $this->createUser(['ROLE_USER', 'ROLE_CLINIC']);
|
||||
$clinic = new Clinic($user);
|
||||
$clinic->setName('کلینیک تایملاین');
|
||||
$this->em->persist($clinic);
|
||||
$this->em->flush();
|
||||
|
||||
$address = DoctorAddress::forClinic($clinic->getId());
|
||||
$address->setName('شعبهٔ مرکزی');
|
||||
$this->em->persist($address);
|
||||
$this->em->flush();
|
||||
|
||||
$type = new ResourceType('clinic', (int) $clinic->getId(), 'device', 'دستگاه لیزر');
|
||||
$this->em->persist($type);
|
||||
$this->em->flush();
|
||||
|
||||
$resource = new ClinicResource($address, $type, 'لیزر دایود');
|
||||
$this->em->persist($resource);
|
||||
$this->em->flush();
|
||||
|
||||
return [$user, $resource];
|
||||
}
|
||||
|
||||
private function occupy(ClinicResource $resource, int $start, int $end, string $segment): void
|
||||
{
|
||||
$occupancy = new ResourceOccupancy($resource, $start, $end);
|
||||
$occupancy->setSegmentName($segment);
|
||||
$this->em->persist($occupancy);
|
||||
$this->em->flush();
|
||||
}
|
||||
|
||||
private function shift(ClinicResource $resource, int $day, int $from, int $to): void
|
||||
{
|
||||
$this->em->persist(new ResourceCalendar($resource, $day, $from, $to));
|
||||
$this->em->flush();
|
||||
}
|
||||
|
||||
/** ۰ = شنبه، همان قرارداد تقویم منبع. */
|
||||
private function dayOfWeek(int $timestamp): int
|
||||
{
|
||||
return (int) (((int) date('w', $timestamp) + 1) % 7);
|
||||
}
|
||||
|
||||
// ── ✅ موفق ──────────────────────────────────────────────────────────────
|
||||
|
||||
public function testTheDayShowsEachResourceWithItsOccupiedRanges(): void
|
||||
{
|
||||
[$user, $resource] = $this->clinicWithResource();
|
||||
|
||||
$midnight = (int) strtotime('today midnight');
|
||||
$this->shift($resource, $this->dayOfWeek($midnight), 540, 1020);
|
||||
$this->occupy($resource, $midnight + 10 * 3600, $midnight + 11 * 3600, 'لیزر');
|
||||
|
||||
$body = $this->authJson('GET', '/api/v1/resources/timeline?date=' . date('Y-m-d', $midnight), $user);
|
||||
|
||||
self::assertSame(200, $this->responseCode(), json_encode($body, JSON_UNESCAPED_UNICODE));
|
||||
self::assertSame($midnight, $body['data']['date']);
|
||||
|
||||
$row = $body['data']['resources'][0];
|
||||
self::assertSame('لیزر دایود', $row['name']);
|
||||
self::assertSame('دستگاه لیزر', $row['type_name']);
|
||||
self::assertSame([['start_minute' => 540, 'end_minute' => 1020]], $row['shifts']);
|
||||
|
||||
self::assertCount(1, $row['items']);
|
||||
self::assertSame('لیزر', $row['items'][0]['segment_name']);
|
||||
self::assertSame($midnight + 10 * 3600, $row['items'][0]['starts_at']);
|
||||
}
|
||||
|
||||
public function testOnlyTheShiftsOfThatWeekdayComeBack(): void
|
||||
{
|
||||
[$user, $resource] = $this->clinicWithResource();
|
||||
|
||||
$midnight = (int) strtotime('today midnight');
|
||||
$today = $this->dayOfWeek($midnight);
|
||||
|
||||
$this->shift($resource, $today, 540, 1020);
|
||||
// شیفت روز دیگر نباید در تایملاین امروز ظاهر شود.
|
||||
$this->shift($resource, ($today + 3) % 7, 60, 120);
|
||||
|
||||
$body = $this->authJson('GET', '/api/v1/resources/timeline?date=' . date('Y-m-d', $midnight), $user);
|
||||
|
||||
self::assertSame([['start_minute' => 540, 'end_minute' => 1020]], $body['data']['resources'][0]['shifts']);
|
||||
}
|
||||
|
||||
// ── ⚠️ مرزی ─────────────────────────────────────────────────────────────
|
||||
|
||||
public function testARestDayReturnsTheResourceWithNothingOnIt(): void
|
||||
{
|
||||
[$user, $resource] = $this->clinicWithResource();
|
||||
|
||||
$midnight = (int) strtotime('today midnight');
|
||||
$this->occupy($resource, $midnight - 5 * 86400, $midnight - 5 * 86400 + 3600, 'هفتهٔ پیش');
|
||||
|
||||
$body = $this->authJson('GET', '/api/v1/resources/timeline?date=' . date('Y-m-d', $midnight), $user);
|
||||
|
||||
// منبع میماند تا ردیفش در تایملاین دیده شود؛ فقط خالی است.
|
||||
self::assertCount(1, $body['data']['resources']);
|
||||
self::assertSame([], $body['data']['resources'][0]['items']);
|
||||
self::assertSame([], $body['data']['resources'][0]['shifts']);
|
||||
}
|
||||
|
||||
public function testWithoutADateItFallsBackToToday(): void
|
||||
{
|
||||
[$user] = $this->clinicWithResource();
|
||||
|
||||
$body = $this->authJson('GET', '/api/v1/resources/timeline', $user);
|
||||
|
||||
self::assertSame(200, $this->responseCode());
|
||||
self::assertSame((int) strtotime('today midnight'), $body['data']['date']);
|
||||
}
|
||||
|
||||
// ── ❌ خطا ───────────────────────────────────────────────────────────────
|
||||
|
||||
public function testAMalformedDateIsRefused(): void
|
||||
{
|
||||
[$user] = $this->clinicWithResource();
|
||||
|
||||
$this->authJson('GET', '/api/v1/resources/timeline?date=05-08-2026', $user);
|
||||
|
||||
self::assertSame(422, $this->responseCode());
|
||||
}
|
||||
|
||||
public function testAnotherEnvironmentDoesNotSeeTheseResources(): void
|
||||
{
|
||||
[, $resource] = $this->clinicWithResource();
|
||||
|
||||
$midnight = (int) strtotime('today midnight');
|
||||
$this->occupy($resource, $midnight + 3600, $midnight + 7200, 'لیزر');
|
||||
|
||||
[$stranger] = $this->clinicWithResource();
|
||||
|
||||
$body = $this->authJson('GET', '/api/v1/resources/timeline?date=' . date('Y-m-d', $midnight), $stranger);
|
||||
|
||||
// منبعِ خودش را میبیند، نه منبع کلینیک دیگر.
|
||||
self::assertCount(1, $body['data']['resources']);
|
||||
self::assertSame([], $body['data']['resources'][0]['items']);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user