feat(resources): one tabbed page per resource

In the resource-first model a resource is the unit of capacity, so its
working hours, holidays, services, skills and categories belong to it — not
scattered across a list page's modals plus a separate calendar page.

/admin/resources/{uuid} now carries six tabs and the active tab lives in the
query string, so back and refresh land on the same view. The old
/calendar URL redirects to ?tab=hours instead of 404ing.

The skills and services modal bodies became panels the tab renders directly;
the modals are now thin wrappers, so the list page keeps working unchanged
and there is still one implementation of each editor.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
hamed
2026-08-02 13:06:06 +03:30
co-authored by Claude Opus 5
parent 03d8d7d68a
commit e08876f7b4
15 changed files with 1126 additions and 705 deletions
-383
View File
@@ -1,383 +0,0 @@
import React, { useEffect, useMemo, useState } from 'react';
import { useParams } from 'react-router-dom';
import { PlusIcon, TrashIcon } from '@heroicons/react/24/outline';
import PageHeader from '../components/ui/PageHeader';
import ConfirmDialog from '../components/ui/ConfirmDialog';
import SearchableSelect from '../components/ui/SearchableSelect';
import PersianDateInput from '../components/ui/PersianDateInput';
import { usePermissions } from '../hooks/usePermissions';
import { useResources } from '../hooks/useResources';
import {
useResourceAvailability, useResourceCalendar, useResourceExceptions,
} from '../hooks/useResourceCalendar';
import { formatDate } from '../lib/utils';
import type { ResourceException } from '../types';
/** ۰ = شنبه — همان قرارداد بک‌اند و ساعت کاری شعبه. */
const DAY_LABELS = ['شنبه', 'یکشنبه', 'دوشنبه', 'سه‌شنبه', 'چهارشنبه', 'پنجشنبه', 'جمعه'];
const MINUTES_IN_DAY = 1440;
/** چرا یک روز خالی است — بدون ترجمه، پاسخ خام سرور به کاربر نشان داده می‌شد. */
const REASON_LABELS: Record<string, string> = {
national_holiday: 'تعطیل رسمی',
tenant_holiday: 'تعطیلی این محیط',
no_shift: 'شیفتی تعریف نشده',
branch_closed: 'شعبه این روز بسته است',
outside_branch_hours: 'شیفت بیرون از ساعت کاری شعبه',
exception: 'مرخصی یا سرویس',
resource_inactive: 'منبع غیرفعال است',
branch_inactive: 'شعبه غیرفعال است',
};
const EXCEPTION_TYPES = [
{ value: 'leave', label: 'مرخصی' },
{ value: 'absence', label: 'غیبت' },
{ value: 'maintenance', label: 'سرویس دوره‌ای' },
{ value: 'closure', label: 'تعطیلی موردی' },
];
type Draft = { start: string; end: string; endOfDay: boolean };
function toTime(minute: number): string {
return `${String(Math.floor(minute / 60)).padStart(2, '0')}:${String(minute % 60).padStart(2, '0')}`;
}
function toMinutes(time: string): number | null {
const m = /^(\d{1,2}):(\d{2})$/.exec(time.trim());
if (!m) return null;
const minutes = Number(m[1]) * 60 + Number(m[2]);
return minutes >= 0 && minutes <= MINUTES_IN_DAY ? minutes : null;
}
/** نیمه‌شبِ امروز به‌صورت timestamp ثانیه‌ای. */
function todayMidnight(): number {
const d = new Date();
d.setHours(0, 0, 0, 0);
return Math.floor(d.getTime() / 1000);
}
/**
* تقویم یک منبع: شیفت هفتگی، استثناها، و پیش‌نمایش ساعت آزاد.
*
* پیش‌نمایش عمداً «ساعت آزاد» نامیده نشده بلکه «خام» است: نوبت‌های ثبت‌شده در آن
* کسر نشده‌اند و اشتباه گرفتنش با «وقت قابل رزرو» به بیش‌رزروی می‌انجامد.
*/
export default function ResourceCalendarPage() {
const { resourceUuid } = useParams<{ resourceUuid: string }>();
const { calendar, loading, save } = useResourceCalendar(resourceUuid);
const { exceptions, create, remove } = useResourceExceptions(resourceUuid);
const { resources } = useResources();
const { can } = usePermissions();
const canUpdate = can('appointment_settings', 'update');
const resource = resources.find((r) => r.uuid === resourceUuid);
const [draft, setDraft] = useState<Record<number, Draft[]>>({});
const [error, setError] = useState<string | null>(null);
const [toDelete, setToDelete] = useState<ResourceException | null>(null);
const previewFrom = todayMidnight();
const previewTo = previewFrom + 13 * 86400;
const { availability } = useResourceAvailability(resourceUuid, previewFrom, previewTo);
useEffect(() => {
if (!calendar) return;
const next: Record<number, Draft[]> = {};
DAY_LABELS.forEach((_, day) => {
next[day] = (calendar.days[String(day)] ?? []).map((r) => ({
start: toTime(r.start_minute),
end: r.end_minute === MINUTES_IN_DAY ? '23:59' : toTime(r.end_minute),
endOfDay: r.end_minute === MINUTES_IN_DAY,
}));
});
setDraft(next);
}, [calendar]);
const totalShifts = useMemo(
() => Object.values(draft).reduce((sum, rows) => sum + rows.length, 0),
[draft],
);
const editRange = (day: number, index: number, patch: Partial<Draft>) =>
setDraft((d) => ({ ...d, [day]: (d[day] ?? []).map((r, i) => (i === index ? { ...r, ...patch } : r)) }));
const submit = () => {
const days: Record<string, { start_minute: number; end_minute: number }[]> = {};
for (const [dayKey, rows] of Object.entries(draft)) {
const parsed: { start_minute: number; end_minute: number }[] = [];
for (const row of rows) {
const start = toMinutes(row.start);
const end = row.endOfDay ? MINUTES_IN_DAY : toMinutes(row.end);
if (start === null || end === null) {
setError(`ساعت روز ${DAY_LABELS[Number(dayKey)]} را به شکل ۰۹:۰۰ وارد کنید`);
return;
}
if (end <= start) {
setError(`در روز ${DAY_LABELS[Number(dayKey)]} پایان شیفت باید بعد از شروع آن باشد`);
return;
}
parsed.push({ start_minute: start, end_minute: end });
}
days[dayKey] = parsed;
}
setError(null);
save.mutate(days);
};
return (
<div className="fade-in">
<PageHeader
title={`تقویم ${resource?.name ?? 'منبع'}`}
description="شیفت هفتگی منبع. ساعت واقعی از تقاطع این شیفت‌ها با ساعت کاری شعبه به‌دست می‌آید و تعطیلات و مرخصی از آن کسر می‌شود."
backTo="/admin/resources"
breadcrumbs={[{ label: 'منابع', to: '/admin/resources' }, { label: 'تقویم' }]}
action={
canUpdate ? (
<button type="button" className="btn primary" disabled={save.isPending} onClick={submit}>
{save.isPending ? 'در حال ذخیره...' : 'ذخیرهٔ شیفت‌ها'}
</button>
) : undefined
}
/>
{error && (
<div
className="card"
style={{ padding: '12px 16px', marginBottom: 16, color: 'var(--danger)', background: 'var(--danger-bg)', fontSize: 13 }}
>
{error}
</div>
)}
<div style={{ display: 'grid', gap: 16, gridTemplateColumns: 'repeat(auto-fit, minmax(320px, 1fr))' }}>
<section style={{ display: 'grid', gap: 12 }}>
<h2 className="section-title" style={{ margin: 0 }}>
شیفت هفتگی {totalShifts > 0 && <span style={{ fontSize: 12, color: 'var(--text-3)' }}>({totalShifts} شیفت)</span>}
</h2>
{loading ? (
<div style={{ padding: 16, color: 'var(--text-3)', fontSize: 13 }}>در حال بارگذاری...</div>
) : (
DAY_LABELS.map((label, day) => {
const rows = draft[day] ?? [];
return (
<div key={day} className="card" style={{ padding: 14 }}>
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', gap: 10, marginBottom: rows.length ? 10 : 0 }}>
<div style={{ display: 'flex', alignItems: 'center', gap: 8 }}>
<span style={{ fontWeight: 700, fontSize: 14 }}>{label}</span>
{rows.length === 0 && <span style={{ fontSize: 12, color: 'var(--text-3)' }}>بدون شیفت</span>}
</div>
{canUpdate && (
<button
type="button"
className="btn secondary sm"
onClick={() => setDraft((d) => ({ ...d, [day]: [...(d[day] ?? []), { start: '09:00', end: '17:00', endOfDay: false }] }))}
>
<PlusIcon style={{ width: 15 }} /> شیفت
</button>
)}
</div>
<div style={{ display: 'grid', gap: 8 }}>
{rows.map((row, index) => (
<div key={index} style={{ display: 'flex', alignItems: 'center', gap: 8, flexWrap: 'wrap' }}>
<input
type="time"
className="field"
value={row.start}
disabled={!canUpdate}
onChange={(e) => editRange(day, index, { start: e.target.value })}
style={{ width: 116 }}
/>
<span style={{ fontSize: 12, color: 'var(--text-3)' }}>تا</span>
{row.endOfDay ? (
<span className="field" style={{ width: 116, color: 'var(--text-2)' }}>۲۴:۰۰</span>
) : (
<input
type="time"
className="field"
value={row.end}
disabled={!canUpdate}
onChange={(e) => editRange(day, index, { end: e.target.value })}
style={{ width: 116 }}
/>
)}
<label style={{ display: 'flex', alignItems: 'center', gap: 5, fontSize: 12, color: 'var(--text-3)' }}>
<input
type="checkbox"
checked={row.endOfDay}
disabled={!canUpdate}
onChange={(e) => editRange(day, index, { endOfDay: e.target.checked })}
/>
تا پایان روز
</label>
{canUpdate && (
<button
type="button"
className="btn secondary sm"
onClick={() => setDraft((d) => ({ ...d, [day]: (d[day] ?? []).filter((_, i) => i !== index) }))}
aria-label="حذف شیفت"
>
<TrashIcon style={{ width: 15 }} />
</button>
)}
</div>
))}
</div>
</div>
);
})
)}
</section>
<section style={{ display: 'grid', gap: 12, alignContent: 'start' }}>
<ExceptionsCard
exceptions={exceptions}
canUpdate={canUpdate}
saving={create.isPending}
onCreate={(payload) => create.mutate(payload)}
onDelete={setToDelete}
/>
<div className="card" style={{ padding: 14 }}>
<h2 className="section-title" style={{ margin: '0 0 4px' }}>پیشنمایش دو هفته</h2>
<p style={{ fontSize: 12, color: 'var(--text-3)', margin: '0 0 10px' }}>
ساعت <strong>خام</strong> نوبتهای ثبتشده هنوز از آن کسر نشدهاند.
</p>
<div style={{ display: 'grid', gap: 6 }}>
{(availability?.days ?? []).map((day) => (
<div
key={day.date}
style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', gap: 8, fontSize: 13 }}
>
<span style={{ color: 'var(--text-2)' }}>
{DAY_LABELS[day.day_of_week]} · {formatDate(day.date * 1000)}
</span>
{day.intervals.length === 0 ? (
<span style={{ fontSize: 12, color: 'var(--text-3)' }}>
{day.reasons.map((r) => REASON_LABELS[r] ?? r).join('، ') || '—'}
</span>
) : (
<span style={{ fontWeight: 600 }}>{day.total_minutes} دقیقه</span>
)}
</div>
))}
</div>
</div>
</section>
</div>
<ConfirmDialog
open={!!toDelete}
title="حذف استثنا"
message={`آیا از حذف «${toDelete?.type_label}» مطمئن هستید؟`}
confirmLabel="حذف"
loading={remove.isPending}
onConfirm={() => toDelete && remove.mutate(toDelete.uuid, { onSuccess: () => setToDelete(null) })}
onCancel={() => setToDelete(null)}
/>
</div>
);
}
function ExceptionsCard({
exceptions, canUpdate, saving, onCreate, onDelete,
}: {
exceptions: ResourceException[];
canUpdate: boolean;
saving: boolean;
onCreate: (payload: { type: string; starts_at: number; ends_at: number; reason?: string | null }) => void;
onDelete: (e: ResourceException) => void;
}) {
const [type, setType] = useState<string>('leave');
const [startDate, setStartDate] = useState('');
const [endDate, setEndDate] = useState('');
const [reason, setReason] = useState('');
const toTimestamp = (value: string): number | null => {
if (value === '') return null;
const ms = new Date(`${value}T00:00:00`).getTime();
return Number.isNaN(ms) ? null : Math.floor(ms / 1000);
};
const start = toTimestamp(startDate);
const end = toTimestamp(endDate);
// پایان روزِ انتخاب‌شده، نه آغازش: مرخصیِ «تا سه‌شنبه» شامل خودِ سه‌شنبه است.
const endExclusive = end === null ? null : end + 86400;
const invalid = start === null || endExclusive === null || endExclusive <= start;
return (
<div className="card" style={{ padding: 14 }}>
<h2 className="section-title" style={{ margin: '0 0 10px' }}>مرخصی و سرویس</h2>
{exceptions.length === 0 ? (
<p style={{ fontSize: 13, color: 'var(--text-3)', margin: '0 0 10px' }}>استثنایی ثبت نشده است.</p>
) : (
<div style={{ display: 'grid', gap: 8, marginBottom: 12 }}>
{exceptions.map((e) => (
<div key={e.uuid} style={{ display: 'flex', alignItems: 'center', gap: 8, fontSize: 13 }}>
<span className="badge amber" style={{ fontSize: 11 }}>{e.type_label}</span>
<span style={{ flex: 1, color: 'var(--text-2)' }}>
{formatDate(e.starts_at * 1000)} تا {formatDate(e.ends_at * 1000)}
{e.reason ? ` · ${e.reason}` : ''}
</span>
{canUpdate && (
<button type="button" className="btn secondary sm" onClick={() => onDelete(e)} aria-label="حذف استثنا">
</button>
)}
</div>
))}
</div>
)}
{canUpdate && (
<div style={{ display: 'grid', gap: 8 }}>
<SearchableSelect
options={EXCEPTION_TYPES}
value={type}
onChange={(v) => setType(v ? String(v) : 'leave')}
placeholder="نوع استثنا"
height={36}
/>
{/* تقویم شمسی، نه `input type=date` میلادی: اپراتور تاریخ را شمسی می‌گوید و
ترجمهٔ ذهنی همان‌جایی است که استثنا یک روز جابه‌جا ثبت می‌شود. */}
<div style={{ display: 'flex', gap: 8 }}>
<div style={{ flex: 1 }}>
<PersianDateInput value={startDate} onChange={setStartDate} placeholder="از تاریخ" />
</div>
<div style={{ flex: 1 }}>
<PersianDateInput value={endDate} onChange={setEndDate} placeholder="تا تاریخ" />
</div>
</div>
<input className="field" value={reason} onChange={(e) => setReason(e.target.value)} placeholder="توضیح (اختیاری)" />
<button
type="button"
className="btn secondary"
disabled={saving || invalid}
onClick={() => {
onCreate({
type,
starts_at: start!,
ends_at: endExclusive!,
reason: reason.trim() === '' ? null : reason.trim(),
});
setStartDate('');
setEndDate('');
setReason('');
}}
>
{saving ? 'در حال ثبت...' : 'ثبت استثنا'}
</button>
</div>
)}
</div>
);
}
@@ -1,5 +1,6 @@
import { describe, it, expect, beforeEach, vi } from 'vitest';
import { screen, waitFor } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import { renderWithProviders } from '../test/utils';
vi.mock('../lib/api', () => ({
@@ -11,15 +12,38 @@ vi.mock('../hooks/usePermissions', () => ({ usePermissions: () => ({ can: () =>
import { Routes, Route } from 'react-router-dom';
import { api } from '../lib/api';
import ResourceCalendarPage from './ResourceCalendarPage';
import ResourceDetailPage from './ResourceDetailPage';
const get = api.get as ReturnType<typeof vi.fn>;
const resource = {
uuid: 'r1',
name: 'لیزر دایود',
address_uuid: 'a1',
address_name: 'شعبهٔ مرکزی',
type_uuid: 't1',
type_code: 'device',
type_name: 'دستگاه لیزر',
capacity: 1,
setup_minutes: 5,
cleanup_minutes: 10,
attributes: {},
subject_kind: null,
subject_uuid: null,
skills: [{ skill_uuid: 's1', skill_name: 'کار با لیزر', level: 4 }],
categories: [{ uuid: 'c-hand', name: 'دست' }],
active: true,
created_at: 0,
updated_at: 0,
upcoming_appointments: 0,
};
const emptyDays = (): Record<string, unknown[]> =>
Object.fromEntries(Array.from({ length: 7 }, (_, d) => [String(d), [] as unknown[]]));
function mockApi(days: Record<string, unknown[]>, availabilityDays: unknown[]) {
function mockApi(days: Record<string, unknown[]> = emptyDays(), availabilityDays: unknown[] = []) {
get.mockImplementation((path: string) => {
if (path === '/api/v1/resource/r1') return Promise.resolve({ success: true, data: resource });
if (path.endsWith('/calendar')) {
return Promise.resolve({
success: true,
@@ -32,38 +56,52 @@ function mockApi(days: Record<string, unknown[]>, availabilityDays: unknown[]) {
data: { resource_uuid: 'r1', timezone: 'Asia/Tehran', days: availabilityDays },
});
}
if (path.endsWith('/exceptions')) return Promise.resolve({ success: true, data: [] });
if (path.startsWith('/api/v1/resources')) return Promise.resolve({ success: true, data: [] });
if (path.includes('/service-categories/tree')) {
return Promise.resolve({
success: true,
data: [{ uuid: 'c-hand', name: 'دست', sort_order: 0, active: true, children: [] },
{ uuid: 'c-foot', name: 'پا', sort_order: 1, active: true, children: [] }],
});
}
return Promise.resolve({ success: true, data: [] });
});
}
function renderPage() {
function renderPage(route = '/admin/resources/r1') {
return renderWithProviders(
<Routes>
<Route path="/admin/resources/:resourceUuid/calendar" element={<ResourceCalendarPage />} />
<Route path="/admin/resources/:resourceUuid" element={<ResourceDetailPage />} />
</Routes>,
{ route: '/admin/resources/r1/calendar' },
{ route },
);
}
describe('ResourceCalendarPage', () => {
describe('ResourceDetailPage', () => {
beforeEach(() => vi.clearAllMocks());
it('renders seven days and marks shiftless ones', async () => {
mockApi(emptyDays(), []);
it('اطلاعات منبع را در تب پیش‌فرض نشان می‌دهد', async () => {
mockApi();
renderPage();
await waitFor(() => expect(screen.getByText('شعبهٔ مرکزی')).toBeInTheDocument());
expect(screen.getByText('ظرفیت هم‌زمان')).toBeInTheDocument();
expect(screen.getByText('کار با لیزر · 4')).toBeInTheDocument();
});
it('تب از URL خوانده می‌شود تا بازگشت و رفرش همان نما را بدهد', async () => {
mockApi();
renderPage('/admin/resources/r1?tab=hours');
await waitFor(() => expect(screen.getByText('شنبه')).toBeInTheDocument());
expect(screen.getByText('جمعه')).toBeInTheDocument();
expect(screen.getAllByText('بدون شیفت')).toHaveLength(7);
});
it('shows stored shifts as times', async () => {
it('شیفت ذخیره‌شده را به‌صورت ساعت نشان می‌دهد', async () => {
const days = emptyDays();
days['0'] = [{ sequence: 0, start_minute: 540, end_minute: 1020, start_time: '09:00', end_time: '17:00', active: true }];
mockApi(days, []);
renderPage();
mockApi(days);
renderPage('/admin/resources/r1?tab=hours');
await waitFor(() => expect(screen.getByDisplayValue('09:00')).toBeInTheDocument());
expect(screen.getByDisplayValue('17:00')).toBeInTheDocument();
@@ -73,13 +111,13 @@ describe('ResourceCalendarPage', () => {
* دلیلِ خالی بودن روز باید فارسی نشان داده شود؛ نشان دادن کلید خام سرور
* («outside_branch_hours») به کاربر یعنی پیام بیمعنا.
*/
it('translates every empty-day reason into Persian', async () => {
it('دلیل خالی بودن روز را فارسی می‌کند', async () => {
mockApi(emptyDays(), [
{ date: 1785529800, day_of_week: 0, intervals: [], total_minutes: 0, reasons: ['national_holiday'] },
{ date: 1785616200, day_of_week: 1, intervals: [], total_minutes: 0, reasons: ['outside_branch_hours'] },
{ date: 1785702600, day_of_week: 2, intervals: [], total_minutes: 0, reasons: ['exception'] },
]);
renderPage();
renderPage('/admin/resources/r1?tab=exceptions');
await waitFor(() => expect(screen.getByText('تعطیل رسمی')).toBeInTheDocument());
expect(screen.getByText('شیفت بیرون از ساعت کاری شعبه')).toBeInTheDocument();
@@ -87,20 +125,24 @@ describe('ResourceCalendarPage', () => {
expect(screen.queryByText('outside_branch_hours')).not.toBeInTheDocument();
});
it('shows free minutes for a day that has availability', async () => {
mockApi(emptyDays(), [
{ date: 1785529800, day_of_week: 0, intervals: [{ start: 1, end: 2 }], total_minutes: 480, reasons: [] },
]);
renderPage();
await waitFor(() => expect(screen.getByText('480 دقیقه')).toBeInTheDocument());
});
/** پیش‌نمایش نباید «وقت قابل رزرو» خوانده شود — نوبت‌ها هنوز کسر نشده‌اند. */
it('warns that the preview is raw availability', async () => {
mockApi(emptyDays(), []);
renderPage();
it('پیش‌نمایش را خام معرفی می‌کند', async () => {
mockApi();
renderPage('/admin/resources/r1?tab=exceptions');
await waitFor(() => expect(screen.getByText(/نوبت‌های ثبت‌شده هنوز از آن کسر نشده‌اند/)).toBeInTheDocument());
});
it('تب دسته‌بندی فقط انتخاب می‌دهد، نه ساخت', async () => {
mockApi();
const user = userEvent.setup();
renderPage();
await waitFor(() => expect(screen.getByText('شعبهٔ مرکزی')).toBeInTheDocument());
await user.click(screen.getByRole('button', { name: 'دسته‌بندی‌ها' }));
await waitFor(() => expect(screen.getByText(/فقط انتخاب می‌شود/)).toBeInTheDocument());
expect(screen.queryByRole('button', { name: /افزودن دسته‌بندی جدید/ })).not.toBeInTheDocument();
expect(screen.getByText('تنظیمات ← دسته‌بندی‌ها')).toHaveAttribute('href', '/admin/service-categories');
});
});
+196
View File
@@ -0,0 +1,196 @@
import React, { useState } from 'react';
import { useParams } from 'react-router-dom';
import { PencilIcon } from '@heroicons/react/24/outline';
import PageHeader from '../components/ui/PageHeader';
import { ActiveBadge } from '../components/ui/StatusBadge';
import ResourceFormModal from '../components/resources/ResourceFormModal';
import ResourceWorkingHoursPanel from '../components/resources/ResourceWorkingHoursPanel';
import ResourceExceptionsPanel from '../components/resources/ResourceExceptionsPanel';
import ResourceServicesPanel from '../components/resources/ResourceServicesPanel';
import ResourceSkillsPanel from '../components/resources/ResourceSkillsPanel';
import ResourceCategoriesPanel from '../components/resources/ResourceCategoriesPanel';
import { useUrlState } from '../hooks/useUrlState';
import { usePermissions } from '../hooks/usePermissions';
import { useBranches } from '../hooks/useBranches';
import { useResourceDetail, useResourceServices, useResources, useResourceTypes, useSkills } from '../hooks/useResources';
import { useAllServiceItems } from '../hooks/useServiceCatalog';
import type { ClinicResource } from '../types';
const TABS = [
{ id: 'info', label: 'اطلاعات' },
{ id: 'hours', label: 'ساعات کاری' },
{ id: 'exceptions', label: 'تعطیلات و استثنا' },
{ id: 'services', label: 'سرویس‌ها' },
{ id: 'skills', label: 'مهارت‌ها' },
{ id: 'categories', label: 'دسته‌بندی‌ها' },
] as const;
type TabId = typeof TABS[number]['id'];
const SUBJECT_LABEL: Record<string, string> = {
doctor: 'پزشک',
staff: 'پرسنل',
room: 'اتاق',
};
/**
* یک منبع و همهٔ تنظیمات مستقلش، در یک صفحهٔ تب‌بندی‌شده — همان ساختار صفحهٔ
* «مدیریت نوبت‌دهی» کلینیک.
*
* منبع در مدل Resource-First واحدِ ظرفیت است، پس ساعت کاری و تعطیلات را خودش دارد نه
* فقط پزشکِ پشتش؛ تقویم منبع درون برنامهٔ هفتگی تنگ‌تر می‌شود، آن را گشاد نمی‌کند.
*/
export default function ResourceDetailPage() {
const { resourceUuid } = useParams<{ resourceUuid: string }>();
const [urlState, setUrlState] = useUrlState({ tab: 'info' });
const tab = (TABS.some((t) => t.id === urlState.tab) ? urlState.tab : 'info') as TabId;
const { resource, loading } = useResourceDetail(resourceUuid);
const { branches } = useBranches();
const { types } = useResourceTypes();
const { skills } = useSkills();
const { update, setSkills, setCategories } = useResources();
const { offerings, save: saveServices } = useResourceServices(resourceUuid);
const { items: serviceOptions } = useAllServiceItems();
const { can } = usePermissions();
const canUpdate = can('appointment_settings', 'update');
const [editOpen, setEditOpen] = useState(false);
if (loading) return <div style={{ padding: 24, color: 'var(--text-3)', fontSize: 13 }}>در حال بارگذاری...</div>;
if (!resource) return <div style={{ padding: 24, color: 'var(--text-3)', fontSize: 13 }}>منبع یافت نشد.</div>;
return (
<div className="fade-in">
<PageHeader
title={resource.name}
description={`${resource.type_name}${resource.subject_kind ? ` · ${SUBJECT_LABEL[resource.subject_kind]}` : ' · تجهیزات'}${resource.address_name ? ` · ${resource.address_name}` : ''}`}
backTo="/admin/resources"
breadcrumbs={[{ label: 'منابع', to: '/admin/resources' }, { label: resource.name }]}
action={
canUpdate ? (
<button type="button" className="btn primary sm" onClick={() => setEditOpen(true)}>
<PencilIcon style={{ width: 15 }} /> ویرایش
</button>
) : undefined
}
/>
<div style={{ display: 'flex', alignItems: 'center', gap: 8, marginBottom: 'var(--gap)' }}>
<ActiveBadge active={resource.active} />
</div>
<div className="seg" style={{ marginBottom: 'var(--gap)', overflowX: 'auto', flexWrap: 'nowrap' }}>
{TABS.map((t) => (
<button
key={t.id}
className={tab === t.id ? 'active' : ''}
style={{ whiteSpace: 'nowrap' }}
onClick={() => setUrlState({ tab: t.id })}
>
{t.label}
</button>
))}
</div>
{tab === 'info' && <InfoTab resource={resource} />}
{tab === 'hours' && <ResourceWorkingHoursPanel resourceUuid={resourceUuid} canUpdate={canUpdate} />}
{tab === 'exceptions' && <ResourceExceptionsPanel resourceUuid={resourceUuid} canUpdate={canUpdate} />}
{tab === 'services' && (
<div className="card card-pad">
<ResourceServicesPanel
resource={resource}
offerings={offerings}
services={serviceOptions}
saving={saveServices.isPending}
onSave={(lines) => saveServices.mutate({ uuid: resource.uuid, services: lines })}
/>
</div>
)}
{tab === 'skills' && (
<div className="card card-pad">
<ResourceSkillsPanel
resource={resource}
skills={skills}
saving={setSkills.isPending}
onSave={(lines) => setSkills.mutate({ uuid: resource.uuid, skills: lines })}
/>
</div>
)}
{tab === 'categories' && (
<ResourceCategoriesPanel
resource={resource}
canUpdate={canUpdate}
saving={setCategories.isPending}
onSave={(categoryUuids) => setCategories.mutate({ uuid: resource.uuid, categoryUuids })}
/>
)}
<ResourceFormModal
open={editOpen}
resource={resource}
branches={branches}
types={types}
saving={update.isPending}
onClose={() => setEditOpen(false)}
onSave={(payload) =>
update.mutate({ uuid: resource.uuid, d: payload }, { onSuccess: () => setEditOpen(false) })
}
/>
</div>
);
}
function Row({ label, children }: { label: string; children: React.ReactNode }) {
return (
<div style={{
display: 'flex', alignItems: 'center', justifyContent: 'space-between',
gap: 12, padding: '11px 0', borderBottom: '1px solid var(--border)',
}}>
<span style={{ fontSize: 12.5, color: 'var(--text-3)' }}>{label}</span>
<span style={{ fontSize: 13.5, color: 'var(--text-2)', textAlign: 'left', minWidth: 0 }}>{children}</span>
</div>
);
}
function InfoTab({ resource }: { resource: ClinicResource }) {
const attributes = Object.entries(resource.attributes ?? {});
return (
<div className="card card-pad">
<Row label="شعبه">{resource.address_name || '—'}</Row>
<Row label="نوع منبع">{resource.type_name}</Row>
<Row label="ظرفیت هم‌زمان">{resource.capacity} نفر</Row>
<Row label="آماده‌سازی / تمیزکاری">
{resource.setup_minutes} / {resource.cleanup_minutes} دقیقه
</Row>
<Row label="مهارت‌ها">
{resource.skills.length === 0 ? '—' : (
<span style={{ display: 'inline-flex', gap: 4, flexWrap: 'wrap' }}>
{resource.skills.map((s) => (
<span key={s.skill_uuid} className="badge blue" style={{ fontSize: 11 }}>
{s.skill_name} · {s.level}
</span>
))}
</span>
)}
</Row>
<Row label="دسته‌بندی‌ها">
{(resource.categories ?? []).length === 0 ? '—' : (
<span style={{ display: 'inline-flex', gap: 4, flexWrap: 'wrap' }}>
{(resource.categories ?? []).map((c) => (
<span key={c.uuid} className="badge gray" style={{ fontSize: 11 }}>{c.name}</span>
))}
</span>
)}
</Row>
{attributes.map(([key, value]) => (
<Row key={key} label={key}>{String(value)}</Row>
))}
</div>
);
}
+2 -2
View File
@@ -194,8 +194,8 @@ export default function ResourcesPage() {
<button type="button" className="btn secondary sm" onClick={() => setServicesFor(r)}>
سرویسها
</button>
<Link className="btn secondary sm" to={`/admin/resources/${r.uuid}/calendar`}>
تقویم
<Link className="btn secondary sm" to={`/admin/resources/${r.uuid}`}>
تنظیمات
</Link>
{/* مسدودسازی موردی از تقویم جداست: آن الگوی کاری را عوض می‌کند،
این یک بازهٔ مشخص را می‌بندد. */}