- Updated DoctorDetailPage, MySecretariesPage, RecordNumberSettingsPage, RepresentationsPage, ResourcePoolsPage, ResourceTypesPage, SecretariesPage, SecretaryDetailPage, SettingsPage, SkillsPage, SmsWalletPage, and TagsSettingsPage to use the new Switch component instead of native checkboxes. - Enhanced accessibility by ensuring the Switch component uses appropriate roles and labels. - Added tests for the new Switch component to ensure functionality and accessibility compliance. - Updated styles to accommodate the new Switch component design.
300 lines
12 KiB
TypeScript
300 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 { useResourcePools, useResources, useResourceTypes } from '../hooks/useResources';
|
|
import type { ResourcePool } from '../types';
|
|
import ResourcesSubNav from '../components/resources/ResourcesSubNav';
|
|
|
|
/**
|
|
* استخر منابع — گروهی از منابع که جایگزین کامل یکدیگرند.
|
|
*
|
|
* اعضا باید همنوعِ خودِ استخر باشند؛ سرور این را اجبار میکند و فرم هم
|
|
* فهرست انتخاب را به همانها محدود میکند تا کاربر به خطای ۴۲۲ نخورد.
|
|
*/
|
|
export default function ResourcePoolsPage() {
|
|
const { pools, loading, create, update, remove, setMembers } = useResourcePools();
|
|
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: '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
|
|
}
|
|
/>
|
|
|
|
<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 danger sm" onClick={() => setToDelete(p)}>
|
|
حذف
|
|
</button>
|
|
</div>
|
|
)
|
|
: undefined
|
|
}
|
|
/>
|
|
|
|
<CreatePoolModal
|
|
open={creating}
|
|
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, types, saving, onClose, onSave,
|
|
}: {
|
|
open: boolean;
|
|
types: ReturnType<typeof useResourceTypes>['types'];
|
|
saving: boolean;
|
|
onClose: () => void;
|
|
onSave: (payload: { type_uuid: string; name: string }) => void;
|
|
}) {
|
|
const [name, setName] = useState('');
|
|
const [typeUuid, setTypeUuid] = useState<string | null>(null);
|
|
|
|
useEffect(() => {
|
|
if (!open) return;
|
|
setName('');
|
|
setTypeUuid(null);
|
|
}, [open]);
|
|
|
|
const invalid = name.trim() === '' || !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={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({ 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 ? { 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>
|
|
);
|
|
}
|