Four pages on the existing design system: a resources list whose branch/type/skill/
status filters live in the URL and go straight to the server, and three supporting
pages for types, skills and pools. Filtering client-side over a list the server had
already filtered would have been a second source of truth, so the page does neither.
The pool members dialog only offers resources from the pool's own branch and type —
the same rule the server enforces with 422, applied early so the user never reaches
the error. Skill assignment and pool membership are both full replacements, and both
say so in the dialog, because a partial-looking save that silently drops rows is
worse than an explicit one.
Wiring that was missing: deactivating a staff member through
PATCH /api/v1/staff/{uuid}/toggle now closes their resource too. Without it an
inactive operator would still have shown up in availability search. It is an explicit
call rather than a Doctrine lifecycle callback, since callbacks do not fire for
getArrayResult() — which is how every admin list is built — and that asymmetry is
its own bug. The reverse does not hold: closing a resource does not deactivate the
person, who may be purely administrative.
docs/api/resource.md documents all sixteen endpoints with responses captured from
real curl runs against ddev, including the 422 bodies for person-capacity and
non-scalar attributes. staff.md gains a "relationship to resources" section stating
that job_title is not a skill. tenancy.md contrasts these aggregate children —
whose roots do carry a tenant pair — with the branch_working_hours case from task 01,
where the root was global and the classification was wrong.
Also fixed a pre-existing flaky test: NumericFieldNormalizerTest guarded its random
mobile against collision on the never-reset db_test but not its random national code,
so a full-suite run could fail with 422 and close the EntityManager, taking an
unrelated test down with it. Both are now guarded, and the assertion prints the
server's response instead of a bare "422 is not 201".
Verified: phpunit 1119 tests / 3113 assertions green; slot-mode frozen contract green;
phpstan 14 errors before and after, none in touched files; tsc clean; vitest 88 files
/ 617 tests green.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
317 lines
12 KiB
TypeScript
317 lines
12 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 { useBranches } from '../hooks/useBranches';
|
|
import { useResourcePools, useResources, useResourceTypes } from '../hooks/useResources';
|
|
import type { ResourcePool } from '../types';
|
|
|
|
/**
|
|
* استخر منابع — گروهی از منابع که جایگزین کامل یکدیگرند.
|
|
*
|
|
* اعضا باید همشعبه و همنوعِ خودِ استخر باشند؛ سرور این را اجبار میکند و فرم هم
|
|
* فهرست انتخاب را به همانها محدود میکند تا کاربر به خطای ۴۲۲ نخورد.
|
|
*/
|
|
export default function ResourcePoolsPage() {
|
|
const { pools, loading, create, update, remove, setMembers } = useResourcePools();
|
|
const { branches } = useBranches();
|
|
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 (
|
|
<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
|
|
}
|
|
/>
|
|
|
|
<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}
|
|
branches={branches}
|
|
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>
|
|
);
|
|
}
|
|
|
|
function CreatePoolModal({
|
|
open, branches, types, saving, onClose, onSave,
|
|
}: {
|
|
open: boolean;
|
|
branches: ReturnType<typeof useBranches>['branches'];
|
|
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={branches.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>
|
|
);
|
|
}
|