- Implemented BlogBodySanitizer to clean HTML content before saving articles, ensuring security against XSS attacks. - Added tests for BlogBodySanitizer to verify that unsafe tags and attributes are stripped from the content. - Introduced ApiLeastPrivilegeTest to ensure that unauthorized users cannot access sensitive API routes, maintaining strict access control.
259 lines
10 KiB
TypeScript
259 lines
10 KiB
TypeScript
import React, { useState } from 'react';
|
|
import { useParams } from 'react-router';
|
|
import { PencilIcon } from '@heroicons/react/24/outline';
|
|
import PageHeader from '../components/ui/PageHeader';
|
|
import ConfirmDialog from '../components/ui/ConfirmDialog';
|
|
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 ResourceBlocksPanel from '../components/resources/ResourceBlocksPanel';
|
|
import { useUrlState } from '../hooks/useUrlState';
|
|
import { usePermissions } from '../hooks/usePermissions';
|
|
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: 'blocks', 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 { types } = useResourceTypes();
|
|
const { skills } = useSkills();
|
|
const { update, setSkills } = useResources();
|
|
const { offerings, save: saveServices } = useResourceServices(resourceUuid);
|
|
const { items: serviceOptions } = useAllServiceItems();
|
|
const { can } = usePermissions();
|
|
const canUpdate = can('resources', 'update');
|
|
|
|
const [editOpen, setEditOpen] = useState(false);
|
|
const [confirmDeactivate, setConfirmDeactivate] = useState(false);
|
|
|
|
// حالتهای بارگذاری و نبودِ منبع هم داخل پوستهٔ تنظیمات میمانند، وگرنه منوی کناری
|
|
// یک لحظه میپرد و دوباره برمیگردد.
|
|
if (loading || !resource) {
|
|
return (
|
|
<div style={{ padding: 24, color: 'var(--text-3)', fontSize: 13 }}>
|
|
{loading ? 'در حال بارگذاری...' : 'منبع یافت نشد.'}
|
|
</div>
|
|
);
|
|
}
|
|
|
|
return (
|
|
<div className="fade-in">
|
|
<PageHeader
|
|
title={resource.name}
|
|
description={`${resource.type_name}${resource.subject_kind ? ` · ${SUBJECT_LABEL[resource.subject_kind]}` : ' · تجهیزات'}`}
|
|
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" role="group" aria-label="تنظیمات منبع" style={{ marginBottom: 'var(--gap)', overflowX: 'auto', flexWrap: 'nowrap' }}>
|
|
{TABS.map((t) => (
|
|
<button
|
|
key={t.id}
|
|
className={tab === t.id ? 'active' : ''}
|
|
aria-pressed={tab === t.id}
|
|
style={{ whiteSpace: 'nowrap' }}
|
|
onClick={() => setUrlState({ tab: t.id })}
|
|
>
|
|
{t.label}
|
|
</button>
|
|
))}
|
|
</div>
|
|
|
|
{tab === 'info' && (
|
|
<InfoTab
|
|
resource={resource}
|
|
canUpdate={canUpdate}
|
|
toggling={update.isPending}
|
|
// فعال کردن بیخطر است و مستقیم انجام میشود؛ غیرفعال کردن منبع را از
|
|
// نوبتدهی بیرون میبرد، پس تأیید میگیرد.
|
|
onToggleActive={() => {
|
|
if (resource.active) setConfirmDeactivate(true);
|
|
else update.mutate({ uuid: resource.uuid, d: { active: true } });
|
|
}}
|
|
/>
|
|
)}
|
|
|
|
{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 === 'blocks' && <ResourceBlocksPanel resourceUuid={resourceUuid} canUpdate={canUpdate} />}
|
|
|
|
<ConfirmDialog
|
|
open={confirmDeactivate}
|
|
title="غیرفعال کردن منبع"
|
|
message={`«${resource.name}» پس از غیرفعال شدن در جستجوی وقت و رزرو نوبت ظاهر نمیشود. نوبتهای ثبتشده سرِ جایشان میمانند.`}
|
|
confirmLabel="غیرفعال کن"
|
|
danger
|
|
loading={update.isPending}
|
|
onConfirm={() =>
|
|
update.mutate(
|
|
{ uuid: resource.uuid, d: { active: false } },
|
|
{ onSuccess: () => setConfirmDeactivate(false) },
|
|
)
|
|
}
|
|
onCancel={() => setConfirmDeactivate(false)}
|
|
/>
|
|
|
|
<ResourceFormModal
|
|
open={editOpen}
|
|
resource={resource}
|
|
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, canUpdate, toggling, onToggleActive }: {
|
|
resource: ClinicResource;
|
|
canUpdate: boolean;
|
|
toggling: boolean;
|
|
onToggleActive: () => void;
|
|
}) {
|
|
const attributes = Object.entries(resource.attributes ?? {});
|
|
|
|
return (
|
|
<div style={{ display: 'grid', gap: 'var(--gap)' }}>
|
|
<div className="card card-pad">
|
|
<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>
|
|
{attributes.map(([key, value]) => (
|
|
<Row key={key} label={key}>{String(value)}</Row>
|
|
))}
|
|
</div>
|
|
|
|
{canUpdate && (
|
|
<div className="card card-pad">
|
|
<h2 style={{ fontSize: 16, fontWeight: 700, color: 'var(--text)', marginBottom: 4 }}>
|
|
وضعیت منبع
|
|
</h2>
|
|
<p className="field-hint" style={{ marginTop: 0, marginBottom: 14 }}>
|
|
منبع غیرفعال در نوبتدهی پیشنهاد نمیشود. برای بستن فقط یک بازهٔ مشخص، از تب
|
|
«غیرفعالسازی موقت» استفاده کنید.
|
|
</p>
|
|
|
|
<div className="toggle-row">
|
|
<div>
|
|
<div className="tr-title">{resource.active ? 'این منبع فعال است' : 'این منبع غیرفعال است'}</div>
|
|
<div className="tr-desc">
|
|
{resource.active
|
|
? 'در جستجوی وقت و رزرو نوبت در دسترس است.'
|
|
: 'در جستجوی وقت و رزرو نوبت ظاهر نمیشود.'}
|
|
</div>
|
|
</div>
|
|
<button
|
|
type="button"
|
|
className={resource.active ? 'btn danger' : 'btn primary'}
|
|
disabled={toggling}
|
|
onClick={onToggleActive}
|
|
>
|
|
{resource.active ? 'غیرفعال کردن' : 'فعال کردن'}
|
|
</button>
|
|
</div>
|
|
</div>
|
|
)}
|
|
</div>
|
|
);
|
|
}
|