Files
clinicpro/assets/admin/pages/AdminPracticeDomainsPage.tsx
T
hamedandClaude Opus 5 c7fdb92df4 feat(admin): practice domain settings, treatment case list and platform domain CRUD
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>
2026-08-06 21:51:21 +03:30

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>
);
}