Completes the panel side. A clinic picks its practice domain in settings, where the copy says plainly that this is not the specialty label the public site shows; picking nothing stays valid and changes nothing. Cases and the unbooked queue share one page rather than two, because they answer the same question — which patient is where in their course and what is still owed. The queue explains why booking is not automatic instead of leaving the reader to wonder. Platform admins get domain CRUD with a column showing whether a domain has a dedicated workflow or falls back to the default, so the gap is visible rather than guessed at; the code field is locked after creation because workflows bind to it. Also puts the staff session screens in the sidebar — they were reachable only by typing the URL. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
216 lines
8.1 KiB
TypeScript
216 lines
8.1 KiB
TypeScript
import { useEffect, useState } from 'react';
|
|
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
|
|
import { toast } from 'sonner';
|
|
import { PlusIcon } from '@heroicons/react/24/outline';
|
|
import { api, ApiError, type ApiResponse } from '../lib/api';
|
|
import PageHeader from '../components/ui/PageHeader';
|
|
import Modal from '../components/ui/Modal';
|
|
import Field from '../components/ui/Field';
|
|
import Input from '../components/ui/Input';
|
|
import Switch from '../components/ui/Switch';
|
|
import DataTable, { type Column } from '../components/ui/DataTable';
|
|
import type { PracticeDomain } from '../types';
|
|
|
|
const CODE_PATTERN = /^[a-z0-9_]{1,40}$/;
|
|
|
|
/**
|
|
* حوزههای فعالیت — فقط ادمین پلتفرم.
|
|
*
|
|
* ساختنش عمداً دست مدیر کلینیک نیست: هر حوزه به یک پیادهسازی `TreatmentWorkflow`
|
|
* گره خورده، و حوزهای که کسی برایش کد ننوشته بیصدا رفتار پیشفرض میگیرد. ستون
|
|
* «فرآیند اختصاصی» همین را نشان میدهد تا حدس زده نشود.
|
|
*/
|
|
export default function AdminPracticeDomainsPage() {
|
|
const qc = useQueryClient();
|
|
const [editing, setEditing] = useState<{ open: boolean; domain: PracticeDomain | null }>({ open: false, domain: null });
|
|
|
|
const { data, isLoading } = useQuery({
|
|
queryKey: ['practice-domains'],
|
|
queryFn: () => api.get<ApiResponse<PracticeDomain[]>>('/api/v1/practice-domains'),
|
|
});
|
|
|
|
const invalidate = () => qc.invalidateQueries({ queryKey: ['practice-domains'] });
|
|
const fail = (e: unknown, fallback: string) => toast.error(e instanceof ApiError ? e.message : fallback);
|
|
|
|
const create = useMutation({
|
|
mutationFn: (d: { code: string; name: string; sort_order: number }) =>
|
|
api.post<ApiResponse<PracticeDomain>>('/api/v1/practice-domains', d),
|
|
onSuccess: () => { toast.success('حوزهٔ فعالیت افزوده شد'); invalidate(); },
|
|
onError: (e) => fail(e, 'افزودن حوزه ناموفق بود'),
|
|
});
|
|
|
|
const update = useMutation({
|
|
mutationFn: ({ uuid, d }: { uuid: string; d: { name: string; sort_order: number; active: boolean } }) =>
|
|
api.patch<ApiResponse<PracticeDomain>>(`/api/v1/practice-domain/${uuid}`, d),
|
|
onSuccess: () => { toast.success('حوزهٔ فعالیت بهروزرسانی شد'); invalidate(); },
|
|
onError: (e) => fail(e, 'بهروزرسانی ناموفق بود'),
|
|
});
|
|
|
|
const columns: Column<PracticeDomain>[] = [
|
|
{ key: 'name', header: 'نام', render: (d) => <span style={{ fontWeight: 600 }}>{d.name}</span> },
|
|
{ key: 'code', header: 'کد', render: (d) => <span dir="ltr" style={{ fontSize: 12.5 }}>{d.code}</span> },
|
|
{
|
|
key: 'has_workflow',
|
|
header: 'فرآیند اختصاصی',
|
|
render: (d) => (
|
|
<span className={`badge ${d.has_workflow ? 'green' : 'gray'}`}>
|
|
<span className="bdot" />{d.has_workflow ? 'دارد' : 'پیشفرض'}
|
|
</span>
|
|
),
|
|
},
|
|
{
|
|
key: 'active',
|
|
header: 'وضعیت',
|
|
render: (d) => (
|
|
<span className={`badge ${d.active ? 'green' : 'gray'}`}>
|
|
<span className="bdot" />{d.active ? 'فعال' : 'غیرفعال'}
|
|
</span>
|
|
),
|
|
},
|
|
{
|
|
key: 'actions',
|
|
header: '',
|
|
render: (d) => (
|
|
<button type="button" className="btn secondary sm" onClick={() => setEditing({ open: true, domain: d })}>
|
|
ویرایش
|
|
</button>
|
|
),
|
|
},
|
|
];
|
|
|
|
return (
|
|
<>
|
|
<PageHeader
|
|
title="حوزههای فعالیت"
|
|
action={
|
|
<button type="button" className="btn primary" onClick={() => setEditing({ open: true, domain: null })}>
|
|
<PlusIcon style={{ width: 16, height: 16 }} /> افزودن حوزه
|
|
</button>
|
|
}
|
|
/>
|
|
|
|
<DataTable columns={columns} data={data?.data ?? []} loading={isLoading} emptyMessage="حوزهای تعریف نشده است" />
|
|
|
|
<DomainModal
|
|
open={editing.open}
|
|
domain={editing.domain}
|
|
saving={create.isPending || update.isPending}
|
|
onClose={() => setEditing({ open: false, domain: null })}
|
|
onSave={(payload) => {
|
|
const opts = { onSuccess: () => setEditing({ open: false, domain: null }) };
|
|
if (editing.domain) {
|
|
update.mutate({
|
|
uuid: editing.domain.uuid,
|
|
d: { name: payload.name, sort_order: payload.sortOrder, active: payload.active },
|
|
}, opts);
|
|
} else {
|
|
create.mutate({ code: payload.code, name: payload.name, sort_order: payload.sortOrder }, opts);
|
|
}
|
|
}}
|
|
/>
|
|
</>
|
|
);
|
|
}
|
|
|
|
function DomainModal({ open, domain, saving, onClose, onSave }: {
|
|
open: boolean;
|
|
domain: PracticeDomain | null;
|
|
saving: boolean;
|
|
onClose: () => void;
|
|
onSave: (p: { code: string; name: string; sortOrder: number; active: boolean }) => void;
|
|
}) {
|
|
const [code, setCode] = useState('');
|
|
const [name, setName] = useState('');
|
|
const [sortOrder, setSortOrder] = useState(0);
|
|
const [active, setActive] = useState(true);
|
|
|
|
useEffect(() => {
|
|
if (!open) return;
|
|
setCode(domain?.code ?? '');
|
|
setName(domain?.name ?? '');
|
|
setSortOrder(domain?.sort_order ?? 0);
|
|
setActive(domain?.active ?? true);
|
|
}, [open, domain]);
|
|
|
|
const isEdit = domain !== null;
|
|
const codeValid = CODE_PATTERN.test(code);
|
|
const invalid = name.trim() === '' || (!isEdit && !codeValid);
|
|
|
|
return (
|
|
<Modal
|
|
open={open}
|
|
onClose={onClose}
|
|
title={isEdit ? 'ویرایش حوزهٔ فعالیت' : 'افزودن حوزهٔ فعالیت'}
|
|
footer={
|
|
<div className="row-actions">
|
|
<button type="button" className="btn secondary" onClick={onClose}>انصراف</button>
|
|
<button type="submit" form="practice-domain-form" className="btn primary" disabled={saving || invalid}>
|
|
{saving ? 'در حال ذخیره...' : 'ذخیره'}
|
|
</button>
|
|
</div>
|
|
}
|
|
>
|
|
<form
|
|
id="practice-domain-form"
|
|
style={{ display: 'grid', gap: 16 }}
|
|
onSubmit={(e) => {
|
|
e.preventDefault();
|
|
if (!saving && !invalid) onSave({ code: code.trim(), name: name.trim(), sortOrder, active });
|
|
}}
|
|
>
|
|
<Field
|
|
label="کد (انگلیسی)"
|
|
htmlFor="practice-domain-code"
|
|
error={!isEdit && code !== '' && !codeValid ? 'فقط حروف کوچک انگلیسی، عدد و زیرخط.' : undefined}
|
|
>
|
|
<Input
|
|
id="practice-domain-code"
|
|
value={code}
|
|
disabled={isEdit}
|
|
autoFocus={!isEdit}
|
|
dir="ltr"
|
|
placeholder="beauty"
|
|
hasError={!isEdit && code !== '' && !codeValid}
|
|
onChange={(e) => setCode(e.target.value)}
|
|
style={isEdit ? { opacity: 0.6, cursor: 'not-allowed' } : undefined}
|
|
/>
|
|
<span className="field-hint">
|
|
{isEdit
|
|
? 'کد پس از ساخت تغییر نمیکند؛ فرآیند درمان روی همین کد سوار میشود.'
|
|
: 'فرآیند درمان به این کد گره میخورد، پس بعداً قابل تغییر نیست.'}
|
|
</span>
|
|
</Field>
|
|
|
|
<Field label="نام نمایشی" htmlFor="practice-domain-name">
|
|
<Input
|
|
id="practice-domain-name"
|
|
value={name}
|
|
autoFocus={isEdit}
|
|
placeholder="کلینیک زیبایی"
|
|
onChange={(e) => setName(e.target.value)}
|
|
/>
|
|
</Field>
|
|
|
|
<Field label="ترتیب نمایش" htmlFor="practice-domain-sort">
|
|
<Input
|
|
id="practice-domain-sort"
|
|
type="number"
|
|
value={String(sortOrder)}
|
|
onChange={(e) => setSortOrder(Number(e.target.value))}
|
|
/>
|
|
</Field>
|
|
|
|
{isEdit && (
|
|
<Switch
|
|
id="practice-domain-active"
|
|
checked={active}
|
|
onChange={setActive}
|
|
label="فعال است"
|
|
hint="حوزهٔ غیرفعال به مدیران کلینیک پیشنهاد نمیشود."
|
|
/>
|
|
)}
|
|
</form>
|
|
</Modal>
|
|
);
|
|
}
|