Files
clinicpro/assets/admin/pages/ResourcesPage.tsx
T
hamed 6876135a53 feat: add BlogBodySanitizer for HTML sanitization on article save
- 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.
2026-08-07 21:13:38 +03:30

200 lines
8.0 KiB
TypeScript

import React, { useMemo, useState } from 'react';
import { Link } from 'react-router';
import { PlusIcon } from '@heroicons/react/24/outline';
import PageHeader from '../components/ui/PageHeader';
import DataTable, { type Column } from '../components/ui/DataTable';
import SearchableSelect from '../components/ui/SearchableSelect';
import { ActiveBadge } from '../components/ui/StatusBadge';
import { formatNumber } from '../lib/utils';
import { useUrlState } from '../hooks/useUrlState';
import { usePermissions } from '../hooks/usePermissions';
import { useResourceQuota, useResources, useResourceTypes, useSkills } from '../hooks/useResources';
import ResourceFormModal from '../components/resources/ResourceFormModal';
import type { ClinicResource } from '../types';
import ResourcesSubNav from '../components/resources/ResourcesSubNav';
/** برچسب فارسی پلِ هر منبع؛ `null` یعنی تجهیزاتی که پشتش موجودیت دیگری نیست. */
const SUBJECT_LABEL: Record<string, string> = {
doctor: 'پزشک',
staff: 'پرسنل',
room: 'اتاق',
};
/**
* منابع — هر چیزی که ممکن است اشغال باشد.
*
* فیلترها (شعبه/نوع/مهارت/وضعیت) در URL می‌نشینند تا «بازگشت» و رفرش همان نما را
* بدهند، و همان‌ها مستقیم به سرور می‌روند: فیلتر کردن سمت کلاینت با فهرستی که خودش
* فیلترشده آمده، دوباره‌کاری و منبع اختلاف است.
*/
export default function ResourcesPage() {
const [urlState, setUrlState] = useUrlState({
search: '', type: '', skill: '', status: '',
});
const { types } = useResourceTypes();
const { skills } = useSkills();
const { can } = usePermissions();
const canUpdate = can('resources', 'update');
const { resources, loading, create } = useResources({
type_uuid: urlState.type || undefined,
skill_uuid: urlState.skill || undefined,
active: urlState.status || undefined,
});
// ساخت تنها کاری است که به منبعِ موجود گره نمی‌خورد، پس تنها مودالی است که می‌ماند.
const [createOpen, setCreateOpen] = useState(false);
const quota = useResourceQuota();
const rows = useMemo(() => {
const q = urlState.search.trim();
return q === '' ? resources : resources.filter((r) => `${r.name} ${r.type_name}`.includes(q));
}, [resources, urlState.search]);
const columns: Column<ClinicResource>[] = [
{
key: 'name',
header: 'منبع',
render: (r) => (
<div style={{ display: 'flex', flexDirection: 'column', gap: 2 }}>
<span style={{ fontWeight: 600 }}>{r.name}</span>
<span style={{ fontSize: 12, color: 'var(--text-3)' }}>
{r.type_name}
{r.subject_kind ? ` · ${SUBJECT_LABEL[r.subject_kind]}` : ' · تجهیزات'}
</span>
</div>
),
},
{
key: 'capacity',
header: 'ظرفیت هم‌زمان',
render: (r) => <span style={{ fontSize: 13 }}>{r.capacity} نفر</span>,
},
{
key: 'buffers',
header: 'آماده‌سازی / تمیزکاری',
render: (r) => (
<span style={{ fontSize: 13, color: 'var(--text-2)' }}>
{r.setup_minutes} / {r.cleanup_minutes} دقیقه
</span>
),
},
{
key: 'skills',
header: 'مهارت‌ها',
render: (r) =>
r.skills.length === 0 ? (
<span style={{ fontSize: 12, color: 'var(--text-3)' }}></span>
) : (
<div style={{ display: 'flex', gap: 4, flexWrap: 'wrap' }}>
{r.skills.map((s) => (
<span key={s.skill_uuid} className="badge blue" style={{ fontSize: 11 }}>
{s.skill_name} · {s.level}
</span>
))}
</div>
),
},
{ key: 'active', header: 'وضعیت', render: (r) => <ActiveBadge active={r.active} /> },
];
return (
<div className="fade-in">
<PageHeader
title="منابع"
description="هر چیزی که ممکن است اشغال باشد: پزشک، اپراتور، اتاق، دستگاه. ظرفیت یعنی تعداد بیمار هم‌زمان."
/* سقف پلن پیش از باز شدن فرم گفته می‌شود، نه بعد از پر کردنش: خطای ۴۲۲ ته کار
همان اطلاعات را دیرتر و گران‌تر می‌داد. تصمیم نهایی همچنان با سرور است. */
action={
canUpdate ? (
<div style={{ display: 'flex', alignItems: 'center', gap: 10, flexWrap: 'wrap' }}>
{!quota.unlimited && (
<span style={{ fontSize: 12.5, color: 'var(--text-3)' }}>
{formatNumber(quota.used)} از {formatNumber(quota.limit)} منبع پلن فعلی
</span>
)}
{quota.atLimit ? (
<Link to="/admin/subscription" className="btn primary" style={{ textDecoration: 'none' }}>
ارتقای پنل
</Link>
) : (
<button type="button" className="btn primary" onClick={() => setCreateOpen(true)}>
<PlusIcon style={{ width: 16 }} /> افزودن منبع
</button>
)}
</div>
) : undefined
}
/>
<ResourcesSubNav />
<DataTable
columns={columns}
data={rows}
loading={loading}
searchValue={urlState.search}
onSearchChange={(v) => setUrlState({ search: v })}
searchPlaceholder="جستجو در منابع..."
emptyMessage="هیچ منبعی با این فیلترها یافت نشد"
headerExtra={
<div style={{ display: 'flex', gap: 8, flexWrap: 'wrap', marginRight: 'auto' }}>
<div style={{ minWidth: 170 }}>
<SearchableSelect
options={types.map((t) => ({ value: t.uuid, label: t.name }))}
value={urlState.type || null}
onChange={(v) => setUrlState({ type: v ? String(v) : '' })}
placeholder="همهٔ نوع‌ها"
isClearable
height={36}
/>
</div>
<div style={{ minWidth: 170 }}>
<SearchableSelect
options={skills.map((s) => ({ value: s.uuid, label: s.name }))}
value={urlState.skill || null}
onChange={(v) => setUrlState({ skill: v ? String(v) : '' })}
placeholder="همهٔ مهارت‌ها"
isClearable
height={36}
/>
</div>
<div style={{ minWidth: 140 }}>
<SearchableSelect
options={[
{ value: '1', label: 'فعال' },
{ value: '0', label: 'غیرفعال' },
]}
value={urlState.status || null}
onChange={(v) => setUrlState({ status: v ? String(v) : '' })}
placeholder="همهٔ وضعیت‌ها"
isClearable
height={36}
/>
</div>
</div>
}
/* یک اقدام، نه شش‌تا: ویرایش/مهارت‌ها/سرویس‌ها/مسدودسازی/حذف همگی تب‌های
خودِ منبع‌اند. شش دکمه در هر ردیف، جدول را از لبه بیرون می‌زد و همان کار
را دو جای مختلف (مودالِ ردیف و تبِ منبع) قابل انجام می‌کرد. */
actions={(r) => (
<Link className="btn secondary sm" to={`/admin/resources/${r.uuid}`}>
مدیریت
</Link>
)}
/>
<ResourceFormModal
open={createOpen}
resource={null}
types={types}
saving={create.isPending}
onClose={() => setCreateOpen(false)}
onSave={(payload) => create.mutate(payload, { onSuccess: () => setCreateOpen(false) })}
/>
</div>
);
}