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>
323 lines
13 KiB
TypeScript
323 lines
13 KiB
TypeScript
import React, { useEffect, useMemo, useState } from 'react';
|
|
import { PlusIcon } from '@heroicons/react/24/outline';
|
|
import PageHeader from '../components/ui/PageHeader';
|
|
import DataTable, { type Column } from '../components/ui/DataTable';
|
|
import Modal from '../components/ui/Modal';
|
|
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 { useResourcePools, useResources, useResourceTypes } from '../hooks/useResources';
|
|
import type { ResourcePool } from '../types';
|
|
import SettingsLayout from '../components/layout/SettingsLayout';
|
|
import ResourcesSubNav from '../components/resources/ResourcesSubNav';
|
|
|
|
/**
|
|
* استخر منابع — گروهی از منابع که جایگزین کامل یکدیگرند.
|
|
*
|
|
* اعضا باید همشعبه و همنوعِ خودِ استخر باشند؛ سرور این را اجبار میکند و فرم هم
|
|
* فهرست انتخاب را به همانها محدود میکند تا کاربر به خطای ۴۲۲ نخورد.
|
|
*/
|
|
export default function ResourcePoolsPage() {
|
|
const { pools, loading, create, update, remove, setMembers } = useResourcePools();
|
|
const { addresses } = useAddresses();
|
|
const { types } = useResourceTypes();
|
|
const { can } = usePermissions();
|
|
const canUpdate = can('appointment_settings', 'update');
|
|
|
|
const [urlState, setUrlState] = useUrlState({ search: '' });
|
|
const [creating, setCreating] = useState(false);
|
|
const [membersFor, setMembersFor] = useState<ResourcePool | null>(null);
|
|
const [toDelete, setToDelete] = useState<ResourcePool | null>(null);
|
|
|
|
const rows = useMemo(() => {
|
|
const q = urlState.search.trim();
|
|
return q === '' ? pools : pools.filter((p) => `${p.name} ${p.type_name}`.includes(q));
|
|
}, [pools, urlState.search]);
|
|
|
|
const columns: Column<ResourcePool>[] = [
|
|
{
|
|
key: 'name',
|
|
header: 'استخر',
|
|
render: (p) => (
|
|
<div style={{ display: 'flex', flexDirection: 'column', gap: 2 }}>
|
|
<span style={{ fontWeight: 600 }}>{p.name}</span>
|
|
<span style={{ fontSize: 12, color: 'var(--text-3)' }}>{p.type_name}</span>
|
|
</div>
|
|
),
|
|
},
|
|
{ key: 'address_name', header: 'شعبه', render: (p) => <span style={{ fontSize: 13 }}>{p.address_name || '—'}</span> },
|
|
{
|
|
key: 'members',
|
|
header: 'اعضا',
|
|
render: (p) =>
|
|
p.members.length === 0 ? (
|
|
<span style={{ fontSize: 12, color: 'var(--text-3)' }}>بدون عضو</span>
|
|
) : (
|
|
<div style={{ display: 'flex', gap: 4, flexWrap: 'wrap' }}>
|
|
{p.members.map((m) => (
|
|
<span key={m.resource_uuid} className="badge blue" style={{ fontSize: 11 }}>{m.resource_name}</span>
|
|
))}
|
|
</div>
|
|
),
|
|
},
|
|
{ key: 'active', header: 'وضعیت', render: (p) => <ActiveBadge active={p.active} /> },
|
|
];
|
|
|
|
return (
|
|
<SettingsLayout active="resources">
|
|
<div className="fade-in">
|
|
<PageHeader
|
|
title="استخر منابع"
|
|
description="منابعی که جایگزین کامل یکدیگرند — مثل «لیزرهای آلکساندرایت». همهٔ اعضا باید در یک شعبه و از یک نوع باشند."
|
|
backTo="/admin/resources"
|
|
breadcrumbs={[{ label: 'منابع', to: '/admin/resources' }, { label: 'استخر منابع' }]}
|
|
action={
|
|
canUpdate ? (
|
|
<button type="button" className="btn primary" onClick={() => setCreating(true)}>
|
|
<PlusIcon style={{ width: 16 }} /> افزودن استخر
|
|
</button>
|
|
) : undefined
|
|
}
|
|
/>
|
|
|
|
<ResourcesSubNav />
|
|
|
|
<DataTable
|
|
columns={columns}
|
|
data={rows}
|
|
loading={loading}
|
|
searchValue={urlState.search}
|
|
onSearchChange={(v) => setUrlState({ search: v })}
|
|
searchPlaceholder="جستجو در استخرها..."
|
|
emptyMessage="هنوز استخری تعریف نشده است"
|
|
actions={
|
|
canUpdate
|
|
? (p) => (
|
|
<div style={{ display: 'flex', gap: 6 }}>
|
|
<button type="button" className="btn secondary sm" onClick={() => setMembersFor(p)}>
|
|
اعضا
|
|
</button>
|
|
<button
|
|
type="button"
|
|
className="btn secondary sm"
|
|
onClick={() => update.mutate({ uuid: p.uuid, d: { active: !p.active } })}
|
|
>
|
|
{p.active ? 'غیرفعال کردن' : 'فعال کردن'}
|
|
</button>
|
|
<button type="button" className="btn secondary sm" onClick={() => setToDelete(p)}>
|
|
حذف
|
|
</button>
|
|
</div>
|
|
)
|
|
: undefined
|
|
}
|
|
/>
|
|
|
|
<CreatePoolModal
|
|
open={creating}
|
|
addresses={addresses}
|
|
types={types}
|
|
saving={create.isPending}
|
|
onClose={() => setCreating(false)}
|
|
onSave={(payload) => create.mutate(payload, { onSuccess: () => setCreating(false) })}
|
|
/>
|
|
|
|
<PoolMembersModal
|
|
pool={membersFor}
|
|
saving={setMembers.isPending}
|
|
onClose={() => setMembersFor(null)}
|
|
onSave={(members) =>
|
|
membersFor &&
|
|
setMembers.mutate({ uuid: membersFor.uuid, members }, { onSuccess: () => setMembersFor(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>
|
|
);
|
|
}
|
|
|
|
function CreatePoolModal({
|
|
open, addresses, types, saving, onClose, onSave,
|
|
}: {
|
|
open: boolean;
|
|
addresses: ReturnType<typeof useAddresses>['addresses'];
|
|
types: ReturnType<typeof useResourceTypes>['types'];
|
|
saving: boolean;
|
|
onClose: () => void;
|
|
onSave: (payload: { address_uuid: string; type_uuid: string; name: string }) => void;
|
|
}) {
|
|
const [name, setName] = useState('');
|
|
const [addressUuid, setAddressUuid] = useState<string | null>(null);
|
|
const [typeUuid, setTypeUuid] = useState<string | null>(null);
|
|
|
|
useEffect(() => {
|
|
if (!open) return;
|
|
setName('');
|
|
setAddressUuid(null);
|
|
setTypeUuid(null);
|
|
}, [open]);
|
|
|
|
const invalid = name.trim() === '' || !addressUuid || !typeUuid;
|
|
|
|
return (
|
|
<Modal open={open} onClose={onClose} title="افزودن استخر منابع">
|
|
<div style={{ display: 'grid', gap: 14 }}>
|
|
<div style={{ display: 'grid', gap: 6 }}>
|
|
<label style={{ fontSize: 12, color: 'var(--text-2)' }}>نام استخر</label>
|
|
<input className="field" value={name} onChange={(e) => setName(e.target.value)} placeholder="لیزرهای آلکساندرایت" />
|
|
</div>
|
|
|
|
<div style={{ display: 'grid', gap: 6 }}>
|
|
<label style={{ fontSize: 12, color: 'var(--text-2)' }}>شعبه</label>
|
|
<SearchableSelect
|
|
options={addresses.map((b) => ({ value: b.uuid, label: b.name || 'بدون نام' }))}
|
|
value={addressUuid}
|
|
onChange={(v) => setAddressUuid(v ? String(v) : null)}
|
|
placeholder="شعبه را انتخاب کنید"
|
|
height={38}
|
|
/>
|
|
</div>
|
|
|
|
<div style={{ display: 'grid', gap: 6 }}>
|
|
<label style={{ fontSize: 12, color: 'var(--text-2)' }}>نوع منبع</label>
|
|
<SearchableSelect
|
|
options={types.map((t) => ({ value: t.uuid, label: t.name }))}
|
|
value={typeUuid}
|
|
onChange={(v) => setTypeUuid(v ? String(v) : null)}
|
|
placeholder="نوع را انتخاب کنید"
|
|
height={38}
|
|
/>
|
|
</div>
|
|
|
|
<p style={{ fontSize: 12, color: 'var(--text-3)', margin: 0 }}>
|
|
شعبه و نوع پس از ساخت تغییر نمیکنند — اعضا باید با هر دو بخوانند.
|
|
</p>
|
|
|
|
<div style={{ display: 'flex', justifyContent: 'flex-end', gap: 8 }}>
|
|
<button type="button" className="btn secondary" onClick={onClose}>انصراف</button>
|
|
<button
|
|
type="button"
|
|
className="btn primary"
|
|
disabled={saving || invalid}
|
|
onClick={() => onSave({ address_uuid: addressUuid!, type_uuid: typeUuid!, name: name.trim() })}
|
|
>
|
|
{saving ? 'در حال ذخیره...' : 'ذخیره'}
|
|
</button>
|
|
</div>
|
|
</div>
|
|
</Modal>
|
|
);
|
|
}
|
|
|
|
function PoolMembersModal({
|
|
pool, saving, onClose, onSave,
|
|
}: {
|
|
pool: ResourcePool | null;
|
|
saving: boolean;
|
|
onClose: () => void;
|
|
onSave: (members: { resource_uuid: string; priority: number }[]) => void;
|
|
}) {
|
|
const [chosen, setChosen] = useState<string[]>([]);
|
|
|
|
// فهرست انتخاب به همان شعبه و نوعِ استخر محدود است — همان قاعدهای که سرور با ۴۲۲
|
|
// اجبار میکند، پس کاربر اصلاً به آن خطا نمیخورد.
|
|
const { resources } = useResources(
|
|
pool ? { address_uuid: pool.address_uuid, type_uuid: pool.type_uuid } : {},
|
|
);
|
|
|
|
useEffect(() => {
|
|
if (!pool) return;
|
|
setChosen(pool.members.map((m) => m.resource_uuid));
|
|
}, [pool]);
|
|
|
|
const nameOf = (uuid: string) => resources.find((r) => r.uuid === uuid)?.name
|
|
?? pool?.members.find((m) => m.resource_uuid === uuid)?.resource_name
|
|
?? uuid;
|
|
|
|
const available = resources.filter((r) => !chosen.includes(r.uuid));
|
|
|
|
return (
|
|
<Modal open={pool !== null} onClose={onClose} title={`اعضای ${pool?.name ?? 'استخر'}`}>
|
|
<div style={{ display: 'grid', gap: 14 }}>
|
|
{chosen.length === 0 ? (
|
|
<p style={{ fontSize: 13, color: 'var(--text-3)', margin: 0 }}>
|
|
این استخر عضوی ندارد. استخر بدون عضو معتبر است، ولی در انتخاب منبع «هیچ منبعی» دیده میشود.
|
|
</p>
|
|
) : (
|
|
<div style={{ display: 'grid', gap: 8 }}>
|
|
{chosen.map((uuid, index) => (
|
|
<div key={uuid} style={{ display: 'flex', alignItems: 'center', gap: 10 }}>
|
|
<span style={{ fontSize: 12, color: 'var(--text-3)', width: 24 }}>{index + 1}</span>
|
|
<span style={{ flex: 1, fontSize: 13, fontWeight: 600 }}>{nameOf(uuid)}</span>
|
|
<button
|
|
type="button"
|
|
className="btn secondary sm"
|
|
disabled={index === 0}
|
|
onClick={() => setChosen((c) => {
|
|
const next = [...c];
|
|
[next[index - 1], next[index]] = [next[index], next[index - 1]];
|
|
return next;
|
|
})}
|
|
aria-label={`بالا بردن ${nameOf(uuid)}`}
|
|
>
|
|
↑
|
|
</button>
|
|
<button
|
|
type="button"
|
|
className="btn secondary sm"
|
|
onClick={() => setChosen((c) => c.filter((x) => x !== uuid))}
|
|
aria-label={`حذف ${nameOf(uuid)}`}
|
|
>
|
|
✕
|
|
</button>
|
|
</div>
|
|
))}
|
|
</div>
|
|
)}
|
|
|
|
{available.length > 0 && (
|
|
<div style={{ display: 'grid', gap: 6 }}>
|
|
<label style={{ fontSize: 12, color: 'var(--text-2)' }}>افزودن عضو</label>
|
|
<SearchableSelect
|
|
options={available.map((r) => ({ value: r.uuid, label: r.name }))}
|
|
value={null}
|
|
onChange={(v) => v && setChosen((c) => [...c, String(v)])}
|
|
placeholder="یک منبع انتخاب کنید"
|
|
height={38}
|
|
/>
|
|
</div>
|
|
)}
|
|
|
|
<p style={{ fontSize: 12, color: 'var(--text-3)', margin: 0 }}>
|
|
ترتیب همان اولویت انتخاب است. ذخیره کل فهرست را جایگزین میکند.
|
|
</p>
|
|
|
|
<div style={{ display: 'flex', justifyContent: 'flex-end', gap: 8 }}>
|
|
<button type="button" className="btn secondary" onClick={onClose}>انصراف</button>
|
|
<button
|
|
type="button"
|
|
className="btn primary"
|
|
disabled={saving}
|
|
onClick={() => onSave(chosen.map((uuid, index) => ({ resource_uuid: uuid, priority: index })))}
|
|
>
|
|
{saving ? 'در حال ذخیره...' : 'ذخیره'}
|
|
</button>
|
|
</div>
|
|
</div>
|
|
</Modal>
|
|
);
|
|
}
|