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:
@@ -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)',
|
||||
}}
|
||||
>
|
||||
|
||||
@@ -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,
|
||||
});
|
||||
|
||||
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user