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>
200 lines
8.0 KiB
TypeScript
200 lines
8.0 KiB
TypeScript
import React, { useMemo, useState } from 'react';
|
|
import { useParams } from 'react-router-dom';
|
|
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 { useBranchRooms, useBranches } from '../hooks/useBranches';
|
|
import type { Room, RoomPayload } from '../types';
|
|
import SettingsLayout from '../components/layout/SettingsLayout';
|
|
|
|
/** اتاقهای یک شعبه. ظرفیت = چند بیمار همزمان، نه چند اتاق. */
|
|
export default function BranchRoomsPage() {
|
|
const { branchUuid } = useParams<{ branchUuid: string }>();
|
|
const { rooms, loading, create, update, remove } = useBranchRooms(branchUuid);
|
|
const { branches } = useBranches();
|
|
const { can } = usePermissions();
|
|
const canUpdate = can('appointment_settings', 'update');
|
|
|
|
const branch = branches.find((b) => b.uuid === branchUuid);
|
|
|
|
const [urlState, setUrlState] = useUrlState({ search: '' });
|
|
const [editing, setEditing] = useState<{ open: boolean; room: Room | null }>({ open: false, room: null });
|
|
const [toDelete, setToDelete] = useState<Room | null>(null);
|
|
|
|
const rows = useMemo(() => {
|
|
const q = urlState.search.trim();
|
|
return q === '' ? rooms : rooms.filter((r) => `${r.name} ${r.room_type ?? ''}`.includes(q));
|
|
}, [rooms, urlState.search]);
|
|
|
|
const columns: Column<Room>[] = [
|
|
{ key: 'name', header: 'نام اتاق', render: (r) => <span style={{ fontWeight: 600 }}>{r.name}</span> },
|
|
{ key: 'room_type', header: 'نوع', render: (r) => <span style={{ fontSize: 13 }}>{r.room_type || '—'}</span> },
|
|
{
|
|
key: 'capacity',
|
|
header: 'ظرفیت همزمان',
|
|
render: (r) => <span style={{ fontSize: 13 }}>{r.capacity} نفر</span>,
|
|
},
|
|
{ key: 'floor', header: 'طبقه', render: (r) => <span style={{ fontSize: 13 }}>{r.floor || '—'}</span> },
|
|
{ key: 'active', header: 'وضعیت', render: (r) => <ActiveBadge active={r.active} /> },
|
|
];
|
|
|
|
const save = (payload: RoomPayload) => {
|
|
const opts = { onSuccess: () => setEditing({ open: false, room: null }) };
|
|
if (editing.room) update.mutate({ uuid: editing.room.uuid, d: payload }, opts);
|
|
else create.mutate(payload, opts);
|
|
};
|
|
|
|
return (
|
|
<SettingsLayout active="branches">
|
|
<div className="fade-in">
|
|
<PageHeader
|
|
title={`اتاقهای ${branch?.name ?? 'شعبه'}`}
|
|
description="ظرفیت هر اتاق تعداد بیمارِ همزمان است — اتاق تزریق سهتخته یک اتاق با ظرفیت ۳ است، نه سه اتاق."
|
|
backTo="/admin/branches"
|
|
breadcrumbs={[{ label: 'شعبهها', to: '/admin/branches' }, { label: 'اتاقها' }]}
|
|
action={
|
|
canUpdate ? (
|
|
<button type="button" className="btn primary" onClick={() => setEditing({ open: true, room: 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
|
|
? (r) => (
|
|
<div style={{ display: 'flex', gap: 6 }}>
|
|
<button type="button" className="btn secondary sm" onClick={() => setEditing({ open: true, room: r })}>
|
|
ویرایش
|
|
</button>
|
|
<button type="button" className="btn secondary sm" onClick={() => setToDelete(r)}>
|
|
حذف
|
|
</button>
|
|
</div>
|
|
)
|
|
: undefined
|
|
}
|
|
/>
|
|
|
|
<RoomModal
|
|
open={editing.open}
|
|
room={editing.room}
|
|
saving={create.isPending || update.isPending}
|
|
onClose={() => setEditing({ open: false, room: null })}
|
|
onSave={save}
|
|
/>
|
|
|
|
<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 RoomModal({
|
|
open, room, saving, onClose, onSave,
|
|
}: {
|
|
open: boolean;
|
|
room: Room | null;
|
|
saving: boolean;
|
|
onClose: () => void;
|
|
onSave: (payload: RoomPayload) => void;
|
|
}) {
|
|
const [name, setName] = useState('');
|
|
const [roomType, setRoomType] = useState('');
|
|
const [capacity, setCapacity] = useState('1');
|
|
const [floor, setFloor] = useState('');
|
|
const [active, setActive] = useState(true);
|
|
|
|
// فرم با هر بازشدن از روی اتاقِ هدف بازنشانی میشود؛ key در والد باعث remount
|
|
// نمیشود چون Modal همیشه mounted است.
|
|
React.useEffect(() => {
|
|
if (!open) return;
|
|
setName(room?.name ?? '');
|
|
setRoomType(room?.room_type ?? '');
|
|
setCapacity(String(room?.capacity ?? 1));
|
|
setFloor(room?.floor ?? '');
|
|
setActive(room?.active ?? true);
|
|
}, [open, room]);
|
|
|
|
const parsedCapacity = Number(capacity);
|
|
const invalid = name.trim() === '' || !Number.isFinite(parsedCapacity) || parsedCapacity < 1;
|
|
|
|
return (
|
|
<Modal open={open} onClose={onClose} title={room ? 'ویرایش اتاق' : 'افزودن اتاق'}>
|
|
<div style={{ display: 'grid', gap: 14 }}>
|
|
<Field label="نام اتاق">
|
|
<input className="field" value={name} onChange={(e) => setName(e.target.value)} placeholder="اتاق تزریق" />
|
|
</Field>
|
|
<Field label="نوع اتاق (اختیاری)">
|
|
<input className="field" value={roomType} onChange={(e) => setRoomType(e.target.value)} placeholder="تزریقات" />
|
|
</Field>
|
|
<Field label="ظرفیت همزمان">
|
|
<input
|
|
className="field"
|
|
type="number"
|
|
min={1}
|
|
value={capacity}
|
|
onChange={(e) => setCapacity(e.target.value)}
|
|
/>
|
|
</Field>
|
|
<Field label="طبقه (اختیاری)">
|
|
<input className="field" value={floor} onChange={(e) => setFloor(e.target.value)} placeholder="۲" />
|
|
</Field>
|
|
<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={() => onSave({
|
|
name: name.trim(),
|
|
room_type: roomType.trim() === '' ? null : roomType.trim(),
|
|
capacity: parsedCapacity,
|
|
floor: floor.trim() === '' ? null : floor.trim(),
|
|
active,
|
|
})}
|
|
>
|
|
{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>
|
|
);
|
|
}
|