Branches and rooms are not part of the resource-first product: a room is a
resource like any other, and the only thing the branch pages still managed —
opening hours — duplicated the resource's own shift.
What could not go is the address. Every appointment carries address_id (75 of
75 rows), the public booking site reads /clinic-pro/doctor-address/{id}, and a
resource derives its tenant pair from the address it belongs to. So
DoctorAddress stays as an invisible anchor with no page and no menu entry, and
GET /api/v1/addresses replaces GET /api/v1/branches for the forms that still
need to say "where".
BranchResolver was likewise not a branch feature. doctor_addresses is a global
table, so TenantFilter does not cover it and eight callers across booking,
availability, pricing and the catalog went through this resolver to avoid
leaking another clinic's address. It moved to Doctor\Service\AddressResolver
rather than dying with the domain.
The availability engine loses one layer: a resource's real hours were the
branch hours intersected with its shift, and are now the shift alone. That is
the single behavioural change, and the three tests that asserted the old
contract are replaced by one that states the new one.
Rooms already had a resource row each; the migration drops only the bridge
back to `rooms`, and drops it before the table — that foreign key is ON DELETE
CASCADE and the other order would take the resources, and their appointments,
with it.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
278 lines
11 KiB
TypeScript
278 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 { useAddresses } from '../hooks/useAddresses';
|
|
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';
|
|
import SettingsLayout from '../components/layout/SettingsLayout';
|
|
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: '', address: '', type: '', skill: '', status: '',
|
|
});
|
|
|
|
const { addresses } = useAddresses();
|
|
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 (
|
|
<SettingsLayout active="resources">
|
|
<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
|
|
}
|
|
/>
|
|
|
|
<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: 180 }}>
|
|
<SearchableSelect
|
|
options={addresses.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}`}>
|
|
تنظیمات
|
|
</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}
|
|
addresses={addresses}
|
|
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>
|
|
</SettingsLayout>
|
|
);
|
|
}
|