Files
clinicpro/assets/admin/pages/ResourcePoolsPage.tsx
T
hamedandClaude Opus 5 4380e64a5d fix(settings): every settings page renders the settings shell
Resources, branches, price lists, holidays and the new categories page sat
in the settings menu but rendered bare, so clicking one made the settings
sidebar disappear — the subscription page was the only one that kept it.

Eleven pages now wrap in SettingsLayout with the key of the menu entry they
belong to, and the four resource pages (list, types, skills, pools) share
one menu entry plus a sub-nav between them, rather than four entries that
would make the menu a third longer without making anything clearer.

.seg accepts `a` as well as `button`, and treats `active` as an alias of
`on`. Both were needed: cross-page tabs must be real links, and the pages
already using `active` (service detail, clinic appointment settings) had no
visible highlight at all.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-02 13:34:49 +03:30

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 { useBranches } from '../hooks/useBranches';
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 { 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 (
<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}
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>
</SettingsLayout>
);
}
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>
);
}