feat(resource): admin UI for resources, types, skills and pools, plus real API docs

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>
This commit is contained in:
hamed
2026-07-30 17:51:38 +03:30
co-authored by Claude Opus 5
parent 964c09cc00
commit 04d3222559
19 changed files with 2028 additions and 66 deletions
+8
View File
@@ -77,6 +77,10 @@ import InventoryPage from './pages/InventoryPage';
import BranchesPage from './pages/BranchesPage';
import BranchWorkingHoursPage from './pages/BranchWorkingHoursPage';
import BranchRoomsPage from './pages/BranchRoomsPage';
import ResourcesPage from './pages/ResourcesPage';
import ResourceTypesPage from './pages/ResourceTypesPage';
import SkillsPage from './pages/SkillsPage';
import ResourcePoolsPage from './pages/ResourcePoolsPage';
import PatientRecordFormPage from './pages/PatientRecordFormPage';
import PatientDetailPage from './pages/PatientDetailPage';
import PaymentSuccessPage from './pages/PaymentSuccessPage';
@@ -281,6 +285,10 @@ export default function App() {
<Route path="branches" element={<RoleRoute roles={['doctor', 'clinic', 'secretary']} blockClinicScope permission={['appointment_settings', 'view']}><BranchesPage /></RoleRoute>} />
<Route path="branches/:branchUuid/working-hours" element={<RoleRoute roles={['doctor', 'clinic', 'secretary']} blockClinicScope permission={['appointment_settings', 'view']}><BranchWorkingHoursPage /></RoleRoute>} />
<Route path="branches/:branchUuid/rooms" element={<RoleRoute roles={['doctor', 'clinic', 'secretary']} blockClinicScope permission={['appointment_settings', 'view']}><BranchRoomsPage /></RoleRoute>} />
<Route path="resources" element={<RoleRoute roles={['doctor', 'clinic', 'secretary']} blockClinicScope permission={['appointment_settings', 'view']}><ResourcesPage /></RoleRoute>} />
<Route path="resources/types" element={<RoleRoute roles={['doctor', 'clinic', 'secretary']} blockClinicScope permission={['appointment_settings', 'view']}><ResourceTypesPage /></RoleRoute>} />
<Route path="resources/skills" element={<RoleRoute roles={['doctor', 'clinic', 'secretary']} blockClinicScope permission={['appointment_settings', 'view']}><SkillsPage /></RoleRoute>} />
<Route path="resources/pools" element={<RoleRoute roles={['doctor', 'clinic', 'secretary']} blockClinicScope permission={['appointment_settings', 'view']}><ResourcePoolsPage /></RoleRoute>} />
<Route path="sms-wallet" element={<RoleRoute roles={['doctor', 'clinic', 'secretary']} blockClinicScope permission={['sms', 'view']}><SmsWalletPage /></RoleRoute>} />
<Route path="my-secretaries" element={<RoleRoute roles={['doctor', 'clinic']} blockClinicScope><MySecretariesPage /></RoleRoute>} />
<Route path="admin-subscription" element={<RoleRoute roles={['admin']}><AdminSubscriptionPage /></RoleRoute>} />
@@ -2,7 +2,7 @@ import React from 'react';
import {
CreditCardIcon, UserIcon, CalendarDaysIcon, BuildingOffice2Icon,
BanknotesIcon, UsersIcon, ShieldCheckIcon,
TagIcon, ChatBubbleLeftRightIcon, UserCircleIcon, UserPlusIcon, ReceiptPercentIcon, MapPinIcon,
TagIcon, ChatBubbleLeftRightIcon, UserCircleIcon, UserPlusIcon, ReceiptPercentIcon, MapPinIcon, CubeIcon,
} from '@heroicons/react/24/outline';
import PurchaseSubscriptionSidebar from './PurchaseSubscriptionSidebar';
@@ -30,6 +30,7 @@ export const SETTINGS_MENU: SettingsMenuItem[] = [
{ key: 'appointment', label: 'مدیریت نوبت دهی', icon: CalendarDaysIcon, to: '/admin/settings/appointment-settings', roles: ['clinic'], perm: ['appointment_settings', 'view'] },
{ key: 'clinic-doctors', label: 'پزشکان کلینیک', icon: BuildingOffice2Icon, to: '/admin/settings/clinic-doctors', roles: ['clinic'], perm: ['clinic_doctors', 'view'] },
{ key: 'branches', label: 'شعبه‌ها و اتاق‌ها', icon: MapPinIcon, to: '/admin/branches', roles: ['doctor', 'clinic'], perm: ['appointment_settings', 'view'] },
{ key: 'resources', label: 'منابع', icon: CubeIcon, to: '/admin/resources', roles: ['doctor', 'clinic'], perm: ['appointment_settings', 'view'] },
{ key: 'payment', label: 'مدیریت پرداخت', icon: BanknotesIcon, to: '/admin/my-financial', perm: ['payments', 'view'] },
{ key: 'secretary', label: 'مدیریت منشی', icon: UsersIcon, to: '/admin/my-secretaries' },
{ key: 'staff', label: 'پرسنل', icon: UserPlusIcon, to: '/admin/staff', perm: ['staff', 'view'] },
@@ -0,0 +1,205 @@
import React, { useEffect, useState } from 'react';
import Modal from '../ui/Modal';
import SearchableSelect from '../ui/SearchableSelect';
import type { Branch, ClinicResource, ResourcePayload, ResourceType } from '../../types';
/** کلیدهای شناخته‌شدهٔ ویژگی — قرارداد است نه اجبار؛ سرور هر کلید snake_case را می‌پذیرد. */
const KNOWN_ATTRIBUTES = ['gender', 'device_model', 'floor', 'brand'];
type AttributeRow = { key: string; value: string };
interface Props {
open: boolean;
resource: ClinicResource | null;
branches: Branch[];
types: ResourceType[];
saving: boolean;
onClose: () => void;
onSave: (payload: ResourcePayload) => void;
}
export default function ResourceFormModal({
open, resource, branches, types, saving, onClose, onSave,
}: Props) {
const [name, setName] = useState('');
const [addressUuid, setAddressUuid] = useState<string | null>(null);
const [typeUuid, setTypeUuid] = useState<string | null>(null);
const [capacity, setCapacity] = useState('1');
const [setupMinutes, setSetupMinutes] = useState('0');
const [cleanupMinutes, setCleanupMinutes] = useState('0');
const [active, setActive] = useState(true);
const [attributes, setAttributes] = useState<AttributeRow[]>([]);
useEffect(() => {
if (!open) return;
setName(resource?.name ?? '');
setAddressUuid(resource?.address_uuid ?? null);
setTypeUuid(resource?.type_uuid ?? null);
setCapacity(String(resource?.capacity ?? 1));
setSetupMinutes(String(resource?.setup_minutes ?? 0));
setCleanupMinutes(String(resource?.cleanup_minutes ?? 0));
setActive(resource?.active ?? true);
setAttributes(
Object.entries(resource?.attributes ?? {}).map(([key, value]) => ({ key, value: String(value) })),
);
}, [open, resource]);
const isEdit = resource !== null;
const parsedCapacity = Number(capacity);
const invalid =
name.trim() === '' ||
(!isEdit && (!addressUuid || !typeUuid)) ||
!Number.isFinite(parsedCapacity) ||
parsedCapacity < 1;
const submit = () => {
const attrs: Record<string, string> = {};
attributes.forEach(({ key, value }) => {
if (key.trim() !== '') attrs[key.trim()] = value;
});
const payload: ResourcePayload = {
name: name.trim(),
capacity: parsedCapacity,
setup_minutes: Number(setupMinutes) || 0,
cleanup_minutes: Number(cleanupMinutes) || 0,
attributes: attrs,
active,
};
// شعبه و نوع فقط هنگام ساخت فرستاده می‌شوند؛ جفت محیطِ منبع از آدرس مشتق شده و
// جابه‌جا کردنش یعنی همان منبع در محیط دیگری ظاهر شود.
if (!isEdit) {
payload.address_uuid = addressUuid!;
payload.type_uuid = typeUuid!;
}
onSave(payload);
};
return (
<Modal open={open} onClose={onClose} title={isEdit ? 'ویرایش منبع' : 'افزودن منبع'} size="lg">
<div style={{ display: 'grid', gap: 14 }}>
<Field label="نام منبع">
<input className="field" value={name} onChange={(e) => setName(e.target.value)} placeholder="لیزر آلکساندرایت ۱" />
</Field>
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(auto-fit, minmax(200px, 1fr))', gap: 12 }}>
<Field label="شعبه">
<SearchableSelect
options={branches.map((b) => ({ value: b.uuid, label: b.name || 'بدون نام' }))}
value={addressUuid}
onChange={(v) => setAddressUuid(v ? String(v) : null)}
placeholder="شعبه را انتخاب کنید"
isDisabled={isEdit}
height={38}
/>
</Field>
<Field label="نوع منبع">
<SearchableSelect
options={types.map((t) => ({ value: t.uuid, label: t.name }))}
value={typeUuid}
onChange={(v) => setTypeUuid(v ? String(v) : null)}
placeholder="نوع را انتخاب کنید"
isDisabled={isEdit}
height={38}
/>
</Field>
</div>
{isEdit && (
<p style={{ fontSize: 12, color: 'var(--text-3)', margin: 0 }}>
شعبه و نوع منبع پس از ساخت تغییر نمیکنند؛ برای جابهجایی، منبع تازه بسازید.
</p>
)}
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(auto-fit, minmax(160px, 1fr))', gap: 12 }}>
<Field label="ظرفیت هم‌زمان">
<input className="field" type="number" min={1} value={capacity} onChange={(e) => setCapacity(e.target.value)} />
</Field>
<Field label="آماده‌سازی (دقیقه)">
<input className="field" type="number" min={0} value={setupMinutes} onChange={(e) => setSetupMinutes(e.target.value)} />
</Field>
<Field label="تمیزکاری (دقیقه)">
<input className="field" type="number" min={0} value={cleanupMinutes} onChange={(e) => setCleanupMinutes(e.target.value)} />
</Field>
</div>
<p style={{ fontSize: 12, color: 'var(--text-3)', margin: 0 }}>
ظرفیت یعنی چند بیمار همزمان اتاق تزریق سهتخته یک منبع با ظرفیت ۳ است، نه سه منبع.
آمادهسازی و تمیزکاری جزو نوبت بیمار نیستند ولی منبع را اشغال میکنند.
</p>
<div style={{ display: 'grid', gap: 8 }}>
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between' }}>
<label style={{ fontSize: 12, color: 'var(--text-2)' }}>ویژگیها (اختیاری)</label>
<button
type="button"
className="btn secondary sm"
onClick={() => setAttributes((a) => [...a, { key: '', value: '' }])}
>
افزودن ویژگی
</button>
</div>
{attributes.map((row, index) => (
<div key={index} style={{ display: 'flex', gap: 8, alignItems: 'center' }}>
<input
className="field"
list="resource-attribute-keys"
value={row.key}
placeholder="gender"
onChange={(e) =>
setAttributes((a) => a.map((r, i) => (i === index ? { ...r, key: e.target.value } : r)))
}
style={{ flex: 1 }}
/>
<input
className="field"
value={row.value}
placeholder="female"
onChange={(e) =>
setAttributes((a) => a.map((r, i) => (i === index ? { ...r, value: e.target.value } : r)))
}
style={{ flex: 1 }}
/>
<button
type="button"
className="btn secondary sm"
onClick={() => setAttributes((a) => a.filter((_, i) => i !== index))}
aria-label="حذف ویژگی"
>
</button>
</div>
))}
<datalist id="resource-attribute-keys">
{KNOWN_ATTRIBUTES.map((k) => <option key={k} value={k} />)}
</datalist>
</div>
<label style={{ display: 'flex', alignItems: 'center', gap: 8, fontSize: 13 }}>
<input type="checkbox" checked={active} onChange={(e) => setActive(e.target.checked)} />
منبع فعال است
</label>
<div style={{ display: 'flex', justifyContent: 'flex-end', gap: 8, marginTop: 4 }}>
<button type="button" className="btn secondary" onClick={onClose}>انصراف</button>
<button type="button" className="btn primary" disabled={saving || invalid} onClick={submit}>
{saving ? 'در حال ذخیره...' : 'ذخیره'}
</button>
</div>
</div>
</Modal>
);
}
function Field({ label, children }: { label: string; children: React.ReactNode }) {
return (
<div style={{ display: 'grid', gap: 6 }}>
<label style={{ fontSize: 12, color: 'var(--text-2)' }}>{label}</label>
{children}
</div>
);
}
@@ -0,0 +1,104 @@
import React, { useEffect, useState } from 'react';
import Modal from '../ui/Modal';
import SearchableSelect from '../ui/SearchableSelect';
import type { ClinicResource, Skill } from '../../types';
type Line = { skill_uuid: string; level: number };
interface Props {
resource: ClinicResource | null;
skills: Skill[];
saving: boolean;
onClose: () => void;
onSave: (lines: Line[]) => void;
}
/**
* مهارت‌های یک منبع. ذخیره یک PUT است و **جایگزینی کامل**: مهارتی که اینجا نباشد،
* از منبع برداشته می‌شود.
*/
export default function ResourceSkillsModal({ resource, skills, saving, onClose, onSave }: Props) {
const [lines, setLines] = useState<Line[]>([]);
useEffect(() => {
if (!resource) return;
setLines(resource.skills.map((s) => ({ skill_uuid: s.skill_uuid, level: s.level })));
}, [resource]);
const chosen = new Set(lines.map((l) => l.skill_uuid));
const available = skills.filter((s) => !chosen.has(s.uuid));
const nameOf = (uuid: string) => skills.find((s) => s.uuid === uuid)?.name ?? uuid;
return (
<Modal
open={resource !== null}
onClose={onClose}
title={`مهارت‌های ${resource?.name ?? 'منبع'}`}
>
<div style={{ display: 'grid', gap: 14 }}>
{skills.length === 0 && (
<p style={{ fontSize: 13, color: 'var(--text-3)', margin: 0 }}>
هنوز هیچ مهارتی تعریف نشده است. اول از صفحهٔ «مهارتها» یکی بسازید.
</p>
)}
{lines.length === 0 ? (
<p style={{ fontSize: 13, color: 'var(--text-3)', margin: 0 }}>این منبع هیچ مهارتی ندارد.</p>
) : (
<div style={{ display: 'grid', gap: 8 }}>
{lines.map((line, index) => (
<div key={line.skill_uuid} style={{ display: 'flex', alignItems: 'center', gap: 10 }}>
<span style={{ flex: 1, fontSize: 13, fontWeight: 600 }}>{nameOf(line.skill_uuid)}</span>
<label style={{ fontSize: 12, color: 'var(--text-3)' }}>سطح</label>
<div style={{ width: 92 }}>
<SearchableSelect
options={[1, 2, 3, 4, 5].map((lv) => ({ value: String(lv), label: String(lv) }))}
value={String(line.level)}
onChange={(v) =>
setLines((l) => l.map((x, i) => (i === index ? { ...x, level: Number(v) || 1 } : x)))
}
placeholder="سطح"
height={36}
/>
</div>
<button
type="button"
className="btn secondary sm"
onClick={() => setLines((l) => l.filter((_, i) => i !== index))}
aria-label={`حذف ${nameOf(line.skill_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((s) => ({ value: s.uuid, label: s.name }))}
value={null}
onChange={(v) => v && setLines((l) => [...l, { skill_uuid: String(v), level: 1 }])}
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(lines)}>
{saving ? 'در حال ذخیره...' : 'ذخیره'}
</button>
</div>
</div>
</Modal>
);
}
+182
View File
@@ -0,0 +1,182 @@
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
import { toast } from 'sonner';
import { api, ApiError, type ApiResponse } from '../lib/api';
import type {
ClinicResource, ResourcePool, ResourcePayload, ResourceType, Skill,
} from '../types';
/**
* منابع: هر چیزی که ممکن است اشغال باشد. هر منبع مال یک شعبه است، و شعبه همان
* آدرس محل نوبت‌دهی است — پس فیلترها با `address_uuid` کار می‌کنند نه `branch_id`.
*/
const RESOURCES_KEY = 'resources';
const TYPES_KEY = ['resource-types'];
const SKILLS_KEY = ['skills'];
const POOLS_KEY = ['resource-pools'];
function fail(e: unknown, fallback: string) {
toast.error(e instanceof ApiError ? e.message : fallback);
}
export type ResourceFilters = {
address_uuid?: string;
type_uuid?: string;
skill_uuid?: string;
active?: string;
};
function toQuery(filters: ResourceFilters): string {
const params = new URLSearchParams();
Object.entries(filters).forEach(([k, v]) => {
if (v) params.set(k, v);
});
const qs = params.toString();
return qs === '' ? '' : `?${qs}`;
}
export function useResources(filters: ResourceFilters = {}) {
const qc = useQueryClient();
const invalidate = () => qc.invalidateQueries({ queryKey: [RESOURCES_KEY] });
const query = useQuery({
queryKey: [RESOURCES_KEY, filters],
queryFn: () => api.get<ApiResponse<ClinicResource[]>>(`/api/v1/resources${toQuery(filters)}`),
});
const create = useMutation({
mutationFn: (d: ResourcePayload) => api.post<ApiResponse<ClinicResource>>('/api/v1/resource', d),
onSuccess: () => { toast.success('منبع افزوده شد'); invalidate(); },
onError: (e) => fail(e, 'افزودن منبع ناموفق بود'),
});
const update = useMutation({
mutationFn: ({ uuid, d }: { uuid: string; d: ResourcePayload }) =>
api.patch<ApiResponse<ClinicResource>>(`/api/v1/resource/${uuid}`, d),
onSuccess: () => { toast.success('منبع به‌روزرسانی شد'); invalidate(); },
onError: (e) => fail(e, 'به‌روزرسانی منبع ناموفق بود'),
});
const remove = useMutation({
mutationFn: (uuid: string) => api.delete<ApiResponse<null>>(`/api/v1/resource/${uuid}`),
onSuccess: () => { toast.success('منبع حذف شد'); invalidate(); },
onError: (e) => fail(e, 'حذف منبع ناموفق بود'),
});
/** جایگزینی کامل: مهارتی که در بدنه نیست، برداشته می‌شود. */
const setSkills = useMutation({
mutationFn: ({ uuid, skills }: { uuid: string; skills: { skill_uuid: string; level: number }[] }) =>
api.put<ApiResponse<ClinicResource>>(`/api/v1/resource/${uuid}/skills`, { skills }),
onSuccess: () => { toast.success('مهارت‌ها ذخیره شد'); invalidate(); },
onError: (e) => fail(e, 'ذخیرهٔ مهارت‌ها ناموفق بود'),
});
return {
resources: query.data?.data ?? [],
loading: query.isLoading,
create, update, remove, setSkills,
};
}
export function useResourceTypes() {
const qc = useQueryClient();
const invalidate = () => qc.invalidateQueries({ queryKey: TYPES_KEY });
const query = useQuery({
queryKey: TYPES_KEY,
queryFn: () => api.get<ApiResponse<ResourceType[]>>('/api/v1/resource-types'),
});
const create = useMutation({
mutationFn: (d: { code: string; name: string }) =>
api.post<ApiResponse<ResourceType>>('/api/v1/resource-types', d),
onSuccess: () => { toast.success('نوع منبع افزوده شد'); invalidate(); },
onError: (e) => fail(e, 'افزودن نوع منبع ناموفق بود'),
});
const update = useMutation({
mutationFn: ({ uuid, d }: { uuid: string; d: { name?: string; active?: boolean } }) =>
api.patch<ApiResponse<ResourceType>>(`/api/v1/resource-type/${uuid}`, d),
onSuccess: () => { toast.success('نوع منبع به‌روزرسانی شد'); invalidate(); },
onError: (e) => fail(e, 'به‌روزرسانی ناموفق بود'),
});
const remove = useMutation({
mutationFn: (uuid: string) => api.delete<ApiResponse<null>>(`/api/v1/resource-type/${uuid}`),
onSuccess: () => { toast.success('نوع منبع حذف شد'); invalidate(); },
onError: (e) => fail(e, 'حذف نوع منبع ناموفق بود'),
});
return { types: query.data?.data ?? [], loading: query.isLoading, create, update, remove };
}
export function useSkills() {
const qc = useQueryClient();
const invalidate = () => qc.invalidateQueries({ queryKey: SKILLS_KEY });
const query = useQuery({
queryKey: SKILLS_KEY,
queryFn: () => api.get<ApiResponse<Skill[]>>('/api/v1/skills'),
});
const create = useMutation({
mutationFn: (d: { name: string }) => api.post<ApiResponse<Skill>>('/api/v1/skills', d),
onSuccess: () => { toast.success('مهارت افزوده شد'); invalidate(); },
onError: (e) => fail(e, 'افزودن مهارت ناموفق بود'),
});
const update = useMutation({
mutationFn: ({ uuid, d }: { uuid: string; d: { name?: string; active?: boolean } }) =>
api.patch<ApiResponse<Skill>>(`/api/v1/skill/${uuid}`, d),
onSuccess: () => { toast.success('مهارت به‌روزرسانی شد'); invalidate(); },
onError: (e) => fail(e, 'به‌روزرسانی ناموفق بود'),
});
const remove = useMutation({
mutationFn: (uuid: string) => api.delete<ApiResponse<null>>(`/api/v1/skill/${uuid}`),
onSuccess: () => { toast.success('مهارت حذف شد'); invalidate(); },
// پیام سرور دقیق است («به N منبع داده شده»)، پس همان نشان داده می‌شود.
onError: (e) => fail(e, 'حذف مهارت ناموفق بود'),
});
return { skills: query.data?.data ?? [], loading: query.isLoading, create, update, remove };
}
export function useResourcePools() {
const qc = useQueryClient();
const invalidate = () => qc.invalidateQueries({ queryKey: POOLS_KEY });
const query = useQuery({
queryKey: POOLS_KEY,
queryFn: () => api.get<ApiResponse<ResourcePool[]>>('/api/v1/resource-pools'),
});
const create = useMutation({
mutationFn: (d: { address_uuid: string; type_uuid: string; name: string }) =>
api.post<ApiResponse<ResourcePool>>('/api/v1/resource-pools', d),
onSuccess: () => { toast.success('استخر افزوده شد'); invalidate(); },
onError: (e) => fail(e, 'افزودن استخر ناموفق بود'),
});
const update = useMutation({
mutationFn: ({ uuid, d }: { uuid: string; d: { name?: string; active?: boolean } }) =>
api.patch<ApiResponse<ResourcePool>>(`/api/v1/resource-pool/${uuid}`, d),
onSuccess: () => { toast.success('استخر به‌روزرسانی شد'); invalidate(); },
onError: (e) => fail(e, 'به‌روزرسانی ناموفق بود'),
});
const remove = useMutation({
mutationFn: (uuid: string) => api.delete<ApiResponse<null>>(`/api/v1/resource-pool/${uuid}`),
onSuccess: () => { toast.success('استخر حذف شد'); invalidate(); },
onError: (e) => fail(e, 'حذف استخر ناموفق بود'),
});
/** جایگزینی کامل اعضا؛ سرور هم‌شعبه و هم‌نوع بودن را اجبار می‌کند. */
const setMembers = useMutation({
mutationFn: ({ uuid, members }: { uuid: string; members: { resource_uuid: string; priority: number }[] }) =>
api.put<ApiResponse<ResourcePool>>(`/api/v1/resource-pool/${uuid}/members`, { members }),
onSuccess: () => { toast.success('اعضای استخر ذخیره شد'); invalidate(); },
onError: (e) => fail(e, 'ذخیرهٔ اعضا ناموفق بود'),
});
return { pools: query.data?.data ?? [], loading: query.isLoading, create, update, remove, setMembers };
}
+316
View File
@@ -0,0 +1,316 @@
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>
);
}
+188
View File
@@ -0,0 +1,188 @@
import React, { 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 { ActiveBadge } from '../components/ui/StatusBadge';
import { useUrlState } from '../hooks/useUrlState';
import { usePermissions } from '../hooks/usePermissions';
import { useResourceTypes } from '../hooks/useResources';
import type { ResourceType } from '../types';
/** نوع منبع — کلینیک خودش تعریفش می‌کند؛ سه نوع سیستمی را backfill می‌سازد. */
export default function ResourceTypesPage() {
const { types, loading, create, update, remove } = useResourceTypes();
const { can } = usePermissions();
const canUpdate = can('appointment_settings', 'update');
const [urlState, setUrlState] = useUrlState({ search: '' });
const [editing, setEditing] = useState<{ open: boolean; type: ResourceType | null }>({ open: false, type: null });
const [toDelete, setToDelete] = useState<ResourceType | null>(null);
const rows = useMemo(() => {
const q = urlState.search.trim();
return q === '' ? types : types.filter((t) => `${t.name} ${t.code}`.includes(q));
}, [types, urlState.search]);
const columns: Column<ResourceType>[] = [
{
key: 'name',
header: 'نوع منبع',
render: (t) => (
<div style={{ display: 'flex', alignItems: 'center', gap: 8 }}>
<span style={{ fontWeight: 600 }}>{t.name}</span>
{t.is_system && <span className="badge gray" style={{ fontSize: 11 }}>سیستمی</span>}
</div>
),
},
{ key: 'code', header: 'کد', render: (t) => <code style={{ fontSize: 12, direction: 'ltr' }}>{t.code}</code> },
{ key: 'resources_count', header: 'تعداد منبع', render: (t) => <span style={{ fontSize: 13 }}>{t.resources_count ?? 0}</span> },
{ key: 'active', header: 'وضعیت', render: (t) => <ActiveBadge active={t.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={() => setEditing({ open: true, type: null })}>
<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
? (t) => (
<div style={{ display: 'flex', gap: 6 }}>
<button type="button" className="btn secondary sm" onClick={() => setEditing({ open: true, type: t })}>
ویرایش
</button>
<button
type="button"
className="btn secondary sm"
disabled={t.is_system}
title={t.is_system ? 'نوع سیستمی حذف نمی‌شود' : undefined}
onClick={() => setToDelete(t)}
>
حذف
</button>
</div>
)
: undefined
}
/>
<TypeModal
open={editing.open}
type={editing.type}
saving={create.isPending || update.isPending}
onClose={() => setEditing({ open: false, type: null })}
onSave={(payload) => {
const opts = { onSuccess: () => setEditing({ open: false, type: null }) };
if (editing.type) update.mutate({ uuid: editing.type.uuid, d: { name: payload.name, active: payload.active } }, opts);
else create.mutate({ code: payload.code, name: payload.name }, opts);
}}
/>
<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 TypeModal({
open, type, saving, onClose, onSave,
}: {
open: boolean;
type: ResourceType | null;
saving: boolean;
onClose: () => void;
onSave: (payload: { code: string; name: string; active: boolean }) => void;
}) {
const [code, setCode] = useState('');
const [name, setName] = useState('');
const [active, setActive] = useState(true);
React.useEffect(() => {
if (!open) return;
setCode(type?.code ?? '');
setName(type?.name ?? '');
setActive(type?.active ?? true);
}, [open, type]);
const isEdit = type !== null;
const codeValid = /^[a-z0-9_]{1,40}$/.test(code);
const invalid = name.trim() === '' || (!isEdit && !codeValid);
return (
<Modal open={open} onClose={onClose} title={isEdit ? 'ویرایش نوع منبع' : 'افزودن نوع منبع'}>
<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={code}
disabled={isEdit}
onChange={(e) => setCode(e.target.value)}
placeholder="laser_device"
style={{ direction: 'ltr' }}
/>
{!isEdit && code !== '' && !codeValid && (
<span style={{ fontSize: 12, color: 'var(--danger)' }}>
فقط حروف کوچک انگلیسی، عدد و زیرخط.
</span>
)}
{isEdit && (
<span style={{ fontSize: 12, color: 'var(--text-3)' }}>
کد پس از ساخت تغییر نمیکند؛ منابع موجود با همین کد پیدا میشوند.
</span>
)}
</div>
<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>
<label style={{ display: 'flex', alignItems: 'center', gap: 8, fontSize: 13 }}>
<input type="checkbox" checked={active} onChange={(e) => setActive(e.target.checked)} />
فعال است
</label>
<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({ code: code.trim(), name: name.trim(), active })}
>
{saving ? 'در حال ذخیره...' : 'ذخیره'}
</button>
</div>
</div>
</Modal>
);
}
+110
View File
@@ -0,0 +1,110 @@
import { describe, it, expect, beforeEach, vi } from 'vitest';
import { screen, fireEvent, waitFor } from '@testing-library/react';
import { renderWithProviders } from '../test/utils';
vi.mock('../lib/api', () => ({
api: { get: vi.fn(), post: vi.fn(), patch: vi.fn(), put: vi.fn(), delete: vi.fn() },
ApiError: class extends Error {},
}));
vi.mock('sonner', () => ({ toast: { success: vi.fn(), error: vi.fn() } }));
vi.mock('../hooks/usePermissions', () => ({
usePermissions: () => ({ can: () => true }),
}));
import { api } from '../lib/api';
import ResourcesPage from './ResourcesPage';
const get = api.get as ReturnType<typeof vi.fn>;
const post = api.post as ReturnType<typeof vi.fn>;
const branch = {
id: '1', uuid: 'b1', type: 'clinic', clinic_id: 3, clinic_name: 'کلینیک',
name: 'شعبهٔ مرکزی', map: { latitude: null, longitude: null }, address: null,
telephone: null, active: true, timezone: 'Asia/Tehran', city: null, province: null,
};
const laserType = { uuid: 't1', code: 'laser', name: 'دستگاه لیزر', is_system: false, active: true, resources_count: 1, created_at: 0, updated_at: 0 };
const skill = { uuid: 's1', name: 'آلکساندرایت', active: true, resources_count: 1, created_at: 0, updated_at: 0 };
const resource = {
uuid: 'r1', name: 'لیزر ۱', address_uuid: 'b1', address_name: 'شعبهٔ مرکزی',
type_uuid: 't1', type_code: 'laser', type_name: 'دستگاه لیزر',
capacity: 3, setup_minutes: 5, cleanup_minutes: 10, attributes: {},
subject_kind: null, subject_uuid: null,
skills: [{ skill_uuid: 's1', skill_name: 'آلکساندرایت', level: 4 }],
active: true, created_at: 0, updated_at: 0,
};
function mockApi() {
get.mockImplementation((path: string) => {
if (path.startsWith('/api/v1/branches')) return Promise.resolve({ success: true, data: [branch] });
if (path.startsWith('/api/v1/resource-types')) return Promise.resolve({ success: true, data: [laserType] });
if (path.startsWith('/api/v1/skills')) return Promise.resolve({ success: true, data: [skill] });
if (path.startsWith('/api/v1/resources')) return Promise.resolve({ success: true, data: [resource] });
return Promise.resolve({ success: true, data: [] });
});
post.mockResolvedValue({ success: true, data: resource });
}
describe('ResourcesPage', () => {
beforeEach(() => vi.clearAllMocks());
it('shows capacity as concurrency and both buffer minutes', async () => {
mockApi();
renderWithProviders(<ResourcesPage />, { route: '/admin/resources' });
await waitFor(() => expect(screen.getByText('لیزر ۱')).toBeInTheDocument());
expect(screen.getByText('3 نفر')).toBeInTheDocument();
expect(screen.getByText('5 / 10 دقیقه')).toBeInTheDocument();
});
it('labels a resource with no bridge as equipment', async () => {
mockApi();
renderWithProviders(<ResourcesPage />, { route: '/admin/resources' });
await waitFor(() => expect(screen.getByText(/تجهیزات/)).toBeInTheDocument());
});
it('renders each skill with its level', async () => {
mockApi();
renderWithProviders(<ResourcesPage />, { route: '/admin/resources' });
await waitFor(() => expect(screen.getByText('آلکساندرایت · 4')).toBeInTheDocument());
});
/**
* فیلترها از URL خوانده می‌شوند و مستقیم به سرور می‌روند — فیلتر دوبارهٔ سمت
* کلاینت روی فهرستی که خودش فیلترشده آمده، منبع اختلاف است.
*/
it('passes the URL filters straight to the server', async () => {
mockApi();
renderWithProviders(<ResourcesPage />, {
route: '/admin/resources?address=b1&type=t1&skill=s1&status=1',
});
await waitFor(() => expect(get).toHaveBeenCalled());
const called = get.mock.calls.map((c) => String(c[0]));
const listCall = called.find((p) => p.startsWith('/api/v1/resources'));
expect(listCall).toContain('address_uuid=b1');
expect(listCall).toContain('type_uuid=t1');
expect(listCall).toContain('skill_uuid=s1');
expect(listCall).toContain('active=1');
});
it('sends address and type only when creating, never when editing', async () => {
mockApi();
renderWithProviders(<ResourcesPage />, { route: '/admin/resources' });
await waitFor(() => expect(screen.getByText('لیزر ۱')).toBeInTheDocument());
fireEvent.click(screen.getByText('افزودن منبع'));
fireEvent.change(screen.getByPlaceholderText('لیزر آلکساندرایت ۱'), { target: { value: 'لیزر تازه' } });
// شعبه و نوع هنوز انتخاب نشده‌اند → ذخیره غیرفعال است.
expect(screen.getByText('ذخیره').closest('button')).toBeDisabled();
});
});
+230
View File
@@ -0,0 +1,230 @@
import React, { 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 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 { useResources, useResourceTypes, useSkills } from '../hooks/useResources';
import ResourceFormModal from '../components/resources/ResourceFormModal';
import ResourceSkillsModal from '../components/resources/ResourceSkillsModal';
import type { ClinicResource } from '../types';
/** برچسب فارسی پلِ هر منبع؛ `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 { branches } = useBranches();
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 [toDelete, setToDelete] = useState<ClinicResource | null>(null);
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 (
<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
}
/>
<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={branches.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={() => setToDelete(r)}>
حذف
</button>
</div>
)
: undefined
}
/>
<ResourceFormModal
open={editing.open}
resource={editing.resource}
branches={branches}
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) })
}
/>
<ConfirmDialog
open={!!toDelete}
title="حذف منبع"
message={`آیا از حذف «${toDelete?.name}» مطمئن هستید؟`}
confirmLabel="حذف"
loading={remove.isPending}
onConfirm={() => toDelete && remove.mutate(toDelete.uuid, { onSuccess: () => setToDelete(null) })}
onCancel={() => setToDelete(null)}
/>
</div>
);
}
+157
View File
@@ -0,0 +1,157 @@
import React, { 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 { ActiveBadge } from '../components/ui/StatusBadge';
import { useUrlState } from '../hooks/useUrlState';
import { usePermissions } from '../hooks/usePermissions';
import { useSkills } from '../hooks/useResources';
import type { Skill } from '../types';
/**
* مهارت‌ها — «کدام اپراتور مجاز است با کدام دستگاه کار کند» یک اطلاعات است، نه یک
* قانون. با «عنوان شغلی» پرسنل قاطی نشود: آن متن آزاد و فقط برای نمایش است.
*/
export default function SkillsPage() {
const { skills, loading, create, update, remove } = useSkills();
const { can } = usePermissions();
const canUpdate = can('appointment_settings', 'update');
const [urlState, setUrlState] = useUrlState({ search: '' });
const [editing, setEditing] = useState<{ open: boolean; skill: Skill | null }>({ open: false, skill: null });
const [toDelete, setToDelete] = useState<Skill | null>(null);
const rows = useMemo(() => {
const q = urlState.search.trim();
return q === '' ? skills : skills.filter((s) => s.name.includes(q));
}, [skills, urlState.search]);
const columns: Column<Skill>[] = [
{ key: 'name', header: 'مهارت', render: (s) => <span style={{ fontWeight: 600 }}>{s.name}</span> },
{
key: 'resources_count',
header: 'روی چند منبع',
render: (s) => <span style={{ fontSize: 13 }}>{s.resources_count ?? 0}</span>,
},
{ key: 'active', header: 'وضعیت', render: (s) => <ActiveBadge active={s.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={() => setEditing({ open: true, skill: null })}>
<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
? (s) => (
<div style={{ display: 'flex', gap: 6 }}>
<button type="button" className="btn secondary sm" onClick={() => setEditing({ open: true, skill: s })}>
ویرایش
</button>
<button
type="button"
className="btn secondary sm"
disabled={(s.resources_count ?? 0) > 0}
title={(s.resources_count ?? 0) > 0 ? 'اول از منابع برداشته شود' : undefined}
onClick={() => setToDelete(s)}
>
حذف
</button>
</div>
)
: undefined
}
/>
<SkillModal
open={editing.open}
skill={editing.skill}
saving={create.isPending || update.isPending}
onClose={() => setEditing({ open: false, skill: null })}
onSave={({ name, active }) => {
const opts = { onSuccess: () => setEditing({ open: false, skill: null }) };
if (editing.skill) update.mutate({ uuid: editing.skill.uuid, d: { name, active } }, opts);
else create.mutate({ name }, opts);
}}
/>
<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 SkillModal({
open, skill, saving, onClose, onSave,
}: {
open: boolean;
skill: Skill | null;
saving: boolean;
onClose: () => void;
onSave: (payload: { name: string; active: boolean }) => void;
}) {
const [name, setName] = useState('');
const [active, setActive] = useState(true);
React.useEffect(() => {
if (!open) return;
setName(skill?.name ?? '');
setActive(skill?.active ?? true);
}, [open, skill]);
return (
<Modal open={open} onClose={onClose} title={skill ? 'ویرایش مهارت' : 'افزودن مهارت'}>
<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>
<label style={{ display: 'flex', alignItems: 'center', gap: 8, fontSize: 13 }}>
<input type="checkbox" checked={active} onChange={(e) => setActive(e.target.checked)} />
فعال است
</label>
<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 || name.trim() === ''}
onClick={() => onSave({ name: name.trim(), active })}
>
{saving ? 'در حال ذخیره...' : 'ذخیره'}
</button>
</div>
</div>
</Modal>
);
}