Files
clinicpro/assets/admin/pages/ResourceDetailPage.tsx
T
hamedandClaude Opus 5 4380e64a5d fix(settings): every settings page renders the settings shell
Resources, branches, price lists, holidays and the new categories page sat
in the settings menu but rendered bare, so clicking one made the settings
sidebar disappear — the subscription page was the only one that kept it.

Eleven pages now wrap in SettingsLayout with the key of the menu entry they
belong to, and the four resource pages (list, types, skills, pools) share
one menu entry plus a sub-nav between them, rather than four entries that
would make the menu a third longer without making anything clearer.

.seg accepts `a` as well as `button`, and treats `active` as an alias of
`on`. Both were needed: cross-page tabs must be real links, and the pages
already using `active` (service detail, clinic appointment settings) had no
visible highlight at all.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-02 13:34:49 +03:30

209 lines
8.3 KiB
TypeScript

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 SettingsLayout from '../components/layout/SettingsLayout';
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 || !resource) {
return (
<SettingsLayout active="resources">
<div style={{ padding: 24, color: 'var(--text-3)', fontSize: 13 }}>
{loading ? 'در حال بارگذاری...' : 'منبع یافت نشد.'}
</div>
</SettingsLayout>
);
}
return (
<SettingsLayout active="resources">
<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>
</SettingsLayout>
);
}
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>
);
}