Files
clinicpro/assets/admin/pages/ResourcesPage.tsx
T
hamedandClaude Opus 5 0127b463a6 feat(resource): ad-hoc blocking, 409 recovery, and the rest of the flake
Ad-hoc resource blocking
- "The laser is being serviced this afternoon" is a specific range, not a change
  to the resource's working pattern. It stays separate from calendar exceptions
  and the modal says which is which — merging them means either an afternoon's
  closure lives in the calendar forever, or a change to working hours vanishes
  with one click
- Blocking a range that already holds an appointment is refused with 409 rather
  than silently taking capacity back; the appointment is still there and someone
  has to decide about it first
- Deleting an occupancy that belongs to an appointment is refused too, otherwise
  a patient's booking would quietly lose its resource with no record

409 on hold now recovers
Saying "someone just took it" is not enough — the operator would have to search
again by hand. The page drops the stale selection and refetches, so alternatives
are on screen immediately.

Flake, second half
The earlier fix only covered createUser's retry path. Any test that trips a
unique constraint closes the EntityManager, and the next test inherits the same
closed instance from the container. setUp now resets the registry when it finds
a closed manager, so a test's starting state no longer depends on how the
previous one failed.

Three consecutive full runs green: 1340 tests.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-31 20:50:43 +03:30

248 lines
9.9 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 { useResources, useResourceTypes, useSkills } from '../hooks/useResources';
import ResourceFormModal from '../components/resources/ResourceFormModal';
import ResourceSkillsModal from '../components/resources/ResourceSkillsModal';
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 [blocksFor, setBlocksFor] = useState<ClinicResource | null>(null);
const [toDelete, setToDelete] = useState<ClinicResource | null>(null);
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>
<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) })
}
/>
<ConfirmDialog
open={!!toDelete}
title="حذف منبع"
message={`آیا از حذف «${toDelete?.name}» مطمئن هستید؟`}
confirmLabel="حذف"
loading={remove.isPending}
onConfirm={() => toDelete && remove.mutate(toDelete.uuid, { onSuccess: () => setToDelete(null) })}
onCancel={() => setToDelete(null)}
/>
</div>
);
}