A "سرویسها" action on each resource row opens a modal listing what that resource performs, with its own duration and price. It follows the skills modal exactly — same PUT-replaces-everything contract, same components, no new page and no new route. Leaving a cell empty means inherit, so the effective value is shown as the placeholder along with where it came from: "40 — service default", "1,800,000 toman — branch". Without that the user cannot tell an unset field from a zero, which is the one thing this screen has to communicate. Two backend adjustments came out of wiring it up: - the offering filter in findEligible is now scoped to the requirement's resource type. Registering lasers for a service was making the room in the same plan ineligible and breaking the whole booking — "who performs this" is about the performing role, not about rooms and support resources. The seeder caught this immediately. - the scenario seeder now creates offerings and resource categories, so the demo data exercises this model instead of leaving every resource empty. Three vitest tests: inherited value with its source, saving an override, and clearing back to inheritance. Verified in the browser at 1440 dark, 1440 compact and 390 mobile — the last with no horizontal scroll. Panel suite 648 green across 98 files, tsc clean, encore build succeeds. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
272 lines
11 KiB
TypeScript
272 lines
11 KiB
TypeScript
import React, { useMemo, useState } from 'react';
|
|
import { Link } from 'react-router-dom';
|
|
import { PlusIcon } from '@heroicons/react/24/outline';
|
|
import PageHeader from '../components/ui/PageHeader';
|
|
import DataTable, { type Column } from '../components/ui/DataTable';
|
|
import ResourceBlocksModal from '../components/ResourceBlocksModal';
|
|
import ConfirmDialog from '../components/ui/ConfirmDialog';
|
|
import SearchableSelect from '../components/ui/SearchableSelect';
|
|
import { ActiveBadge } from '../components/ui/StatusBadge';
|
|
import { useUrlState } from '../hooks/useUrlState';
|
|
import { usePermissions } from '../hooks/usePermissions';
|
|
import { useBranches } from '../hooks/useBranches';
|
|
import { useResourceServices, useResources, useResourceTypes, useSkills } from '../hooks/useResources';
|
|
import ResourceFormModal from '../components/resources/ResourceFormModal';
|
|
import ResourceSkillsModal from '../components/resources/ResourceSkillsModal';
|
|
import ResourceServicesModal from '../components/resources/ResourceServicesModal';
|
|
import { useAllServiceItems } from '../hooks/useServiceCatalog';
|
|
import type { ClinicResource } from '../types';
|
|
|
|
/** برچسب فارسی پلِ هر منبع؛ `null` یعنی تجهیزاتی که پشتش موجودیت دیگری نیست. */
|
|
const SUBJECT_LABEL: Record<string, string> = {
|
|
doctor: 'پزشک',
|
|
staff: 'پرسنل',
|
|
room: 'اتاق',
|
|
};
|
|
|
|
/**
|
|
* منابع — هر چیزی که ممکن است اشغال باشد.
|
|
*
|
|
* فیلترها (شعبه/نوع/مهارت/وضعیت) در URL مینشینند تا «بازگشت» و رفرش همان نما را
|
|
* بدهند، و همانها مستقیم به سرور میروند: فیلتر کردن سمت کلاینت با فهرستی که خودش
|
|
* فیلترشده آمده، دوبارهکاری و منبع اختلاف است.
|
|
*/
|
|
export default function ResourcesPage() {
|
|
const [urlState, setUrlState] = useUrlState({
|
|
search: '', address: '', type: '', skill: '', status: '',
|
|
});
|
|
|
|
const { branches } = useBranches();
|
|
const { types } = useResourceTypes();
|
|
const { skills } = useSkills();
|
|
const { can } = usePermissions();
|
|
const canUpdate = can('appointment_settings', 'update');
|
|
|
|
const { resources, loading, create, update, remove, setSkills } = useResources({
|
|
address_uuid: urlState.address || undefined,
|
|
type_uuid: urlState.type || undefined,
|
|
skill_uuid: urlState.skill || undefined,
|
|
active: urlState.status || undefined,
|
|
});
|
|
|
|
const [editing, setEditing] = useState<{ open: boolean; resource: ClinicResource | null }>({ open: false, resource: null });
|
|
const [skillsFor, setSkillsFor] = useState<ClinicResource | null>(null);
|
|
const [servicesFor, setServicesFor] = useState<ClinicResource | null>(null);
|
|
const [blocksFor, setBlocksFor] = useState<ClinicResource | null>(null);
|
|
const [toDelete, setToDelete] = useState<ClinicResource | null>(null);
|
|
|
|
const { offerings, save: saveServices } = useResourceServices(servicesFor?.uuid);
|
|
const { items: serviceOptions } = useAllServiceItems();
|
|
|
|
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: 'address_name', header: 'شعبه', render: (r) => <span style={{ fontSize: 13 }}>{r.address_name || '—'}</span> },
|
|
{
|
|
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="هر چیزی که ممکن است اشغال باشد: پزشک، اپراتور، اتاق، دستگاه. ظرفیت یعنی تعداد بیمار همزمان."
|
|
backTo="/admin/settings-menu"
|
|
action={
|
|
canUpdate ? (
|
|
<button type="button" className="btn primary" onClick={() => setEditing({ open: true, resource: null })}>
|
|
<PlusIcon style={{ width: 16 }} /> افزودن منبع
|
|
</button>
|
|
) : undefined
|
|
}
|
|
/>
|
|
|
|
<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: 180 }}>
|
|
<SearchableSelect
|
|
options={branches.map((b) => ({ value: b.uuid, label: b.name || 'بدون نام' }))}
|
|
value={urlState.address || null}
|
|
onChange={(v) => setUrlState({ address: v ? String(v) : '' })}
|
|
placeholder="همهٔ شعبهها"
|
|
isClearable
|
|
height={36}
|
|
/>
|
|
</div>
|
|
<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={
|
|
canUpdate
|
|
? (r) => (
|
|
<div style={{ display: 'flex', gap: 6 }}>
|
|
<button type="button" className="btn secondary sm" onClick={() => setEditing({ open: true, resource: r })}>
|
|
ویرایش
|
|
</button>
|
|
<button type="button" className="btn secondary sm" onClick={() => setSkillsFor(r)}>
|
|
مهارتها
|
|
</button>
|
|
<button type="button" className="btn secondary sm" onClick={() => setServicesFor(r)}>
|
|
سرویسها
|
|
</button>
|
|
<Link className="btn secondary sm" to={`/admin/resources/${r.uuid}/calendar`}>
|
|
تقویم
|
|
</Link>
|
|
{/* مسدودسازی موردی از تقویم جداست: آن الگوی کاری را عوض میکند،
|
|
این یک بازهٔ مشخص را میبندد. */}
|
|
<button type="button" className="btn secondary sm" onClick={() => setBlocksFor(r)}>
|
|
مسدودسازی
|
|
</button>
|
|
<button type="button" className="btn secondary sm" onClick={() => setToDelete(r)}>
|
|
حذف
|
|
</button>
|
|
</div>
|
|
)
|
|
: undefined
|
|
}
|
|
/>
|
|
|
|
<ResourceBlocksModal
|
|
resourceUuid={blocksFor?.uuid ?? null}
|
|
resourceName={blocksFor?.name ?? ''}
|
|
onClose={() => setBlocksFor(null)}
|
|
/>
|
|
|
|
<ResourceFormModal
|
|
open={editing.open}
|
|
resource={editing.resource}
|
|
branches={branches}
|
|
types={types}
|
|
saving={create.isPending || update.isPending}
|
|
onClose={() => setEditing({ open: false, resource: null })}
|
|
onSave={(payload) => {
|
|
const opts = { onSuccess: () => setEditing({ open: false, resource: null }) };
|
|
if (editing.resource) update.mutate({ uuid: editing.resource.uuid, d: payload }, opts);
|
|
else create.mutate(payload, opts);
|
|
}}
|
|
/>
|
|
|
|
<ResourceSkillsModal
|
|
resource={skillsFor}
|
|
skills={skills}
|
|
saving={setSkills.isPending}
|
|
onClose={() => setSkillsFor(null)}
|
|
onSave={(lines) =>
|
|
skillsFor &&
|
|
setSkills.mutate({ uuid: skillsFor.uuid, skills: lines }, { onSuccess: () => setSkillsFor(null) })
|
|
}
|
|
/>
|
|
|
|
<ResourceServicesModal
|
|
resource={servicesFor}
|
|
offerings={offerings}
|
|
services={serviceOptions}
|
|
saving={saveServices.isPending}
|
|
onClose={() => setServicesFor(null)}
|
|
onSave={(lines) =>
|
|
servicesFor &&
|
|
saveServices.mutate(
|
|
{ uuid: servicesFor.uuid, services: lines },
|
|
{ onSuccess: () => setServicesFor(null) },
|
|
)
|
|
}
|
|
/>
|
|
|
|
<ConfirmDialog
|
|
open={!!toDelete}
|
|
title="حذف منبع"
|
|
message={`آیا از حذف «${toDelete?.name}» مطمئن هستید؟`}
|
|
confirmLabel="حذف"
|
|
loading={remove.isPending}
|
|
onConfirm={() => toDelete && remove.mutate(toDelete.uuid, { onSuccess: () => setToDelete(null) })}
|
|
onCancel={() => setToDelete(null)}
|
|
/>
|
|
</div>
|
|
);
|
|
}
|