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>
This commit is contained in:
@@ -62,6 +62,9 @@ import StaffPage from './pages/StaffPage';
|
||||
import StaffMyServicesPage from './pages/StaffMyServicesPage';
|
||||
import StaffTreatmentSessionsPage from './pages/StaffTreatmentSessionsPage';
|
||||
import StaffSessionDetailPage from './pages/StaffSessionDetailPage';
|
||||
import TreatmentCasesPage from './pages/TreatmentCasesPage';
|
||||
import PracticeDomainSettingsPage from './pages/PracticeDomainSettingsPage';
|
||||
import AdminPracticeDomainsPage from './pages/AdminPracticeDomainsPage';
|
||||
import SubscriptionPage from './pages/SubscriptionPage';
|
||||
import DiscountsPage from './pages/DiscountsPage';
|
||||
import ClinicServicesPage from './pages/ClinicServicesPage';
|
||||
@@ -296,6 +299,9 @@ export default function App() {
|
||||
<Route path="clinic-services" element={<RoleRoute roles={['doctor', 'clinic', 'secretary']} blockClinicScope permission={['services', 'view']}><ClinicServicesPage /></RoleRoute>} />
|
||||
<Route path="clinic-services/:uuid" element={<RoleRoute roles={['doctor', 'clinic', 'secretary']} blockClinicScope permission={['services', 'view']}><ServiceDetailPage /></RoleRoute>} />
|
||||
<Route path="inventory" element={<RoleRoute roles={['doctor', 'clinic', 'secretary']} blockClinicScope permission={['inventory', 'view']}><InventoryPage /></RoleRoute>} />
|
||||
<Route path="treatment-cases" element={<RoleRoute roles={['doctor', 'clinic', 'secretary']} permission={['appointments', 'view']}><TreatmentCasesPage /></RoleRoute>} />
|
||||
<Route path="settings/practice-domain" element={<RoleRoute roles={['clinic']} permission={['appointment_settings', 'view']}><PracticeDomainSettingsPage /></RoleRoute>} />
|
||||
<Route path="practice-domains" element={<RoleRoute roles={['admin']}><AdminPracticeDomainsPage /></RoleRoute>} />
|
||||
<Route path="service-categories" element={<RoleRoute roles={['doctor', 'clinic', 'secretary']} blockClinicScope permission={['appointment_settings', 'view']}><CatalogCategoriesPage /></RoleRoute>} />
|
||||
<Route path="resource-booking" element={<RoleRoute roles={['doctor', 'clinic', 'secretary']} blockClinicScope permission={['appointments', 'create']}><ResourceBookingPage /></RoleRoute>} />
|
||||
<Route path="resources" element={<RoleRoute roles={['doctor', 'clinic', 'secretary']} blockClinicScope permission={['appointment_settings', 'view']}><ResourcesPage /></RoleRoute>} />
|
||||
|
||||
@@ -9,6 +9,8 @@ import {
|
||||
ChatBubbleLeftEllipsisIcon,
|
||||
ChevronDownIcon,
|
||||
ClipboardDocumentCheckIcon,
|
||||
ClipboardDocumentListIcon,
|
||||
SparklesIcon,
|
||||
Cog6ToothIcon,
|
||||
CreditCardIcon,
|
||||
CubeIcon,
|
||||
@@ -103,6 +105,9 @@ function buildSections(
|
||||
if (can("services", "view")) {
|
||||
items.push({ to: "/admin/clinic-services", icon: WrenchScrewdriverIcon, label: "سرویس ها" });
|
||||
}
|
||||
if (can("appointments", "view")) {
|
||||
items.push({ to: "/admin/treatment-cases", icon: ClipboardDocumentListIcon, label: "درمانهای چندجلسهای" });
|
||||
}
|
||||
if (can("appointment_settings", "view")) {
|
||||
items.push({ to: "/admin/resources", icon: CubeIcon, label: "منابع" });
|
||||
}
|
||||
@@ -244,6 +249,11 @@ function buildSections(
|
||||
icon: CalendarDaysIcon,
|
||||
label: "تعطیلات رسمی",
|
||||
},
|
||||
{
|
||||
to: "/admin/practice-domains",
|
||||
icon: SparklesIcon,
|
||||
label: "حوزههای فعالیت",
|
||||
},
|
||||
{
|
||||
to: "/admin/settings",
|
||||
icon: Cog6ToothIcon,
|
||||
@@ -310,6 +320,11 @@ function buildSections(
|
||||
icon: WrenchScrewdriverIcon,
|
||||
label: "سرویس ها",
|
||||
},
|
||||
{
|
||||
to: "/admin/treatment-cases",
|
||||
icon: ClipboardDocumentListIcon,
|
||||
label: "درمانهای چندجلسهای",
|
||||
},
|
||||
{
|
||||
to: "/admin/resources",
|
||||
icon: CubeIcon,
|
||||
@@ -390,6 +405,11 @@ function buildSections(
|
||||
icon: WrenchScrewdriverIcon,
|
||||
label: "سرویس ها",
|
||||
},
|
||||
{
|
||||
to: "/admin/treatment-cases",
|
||||
icon: ClipboardDocumentListIcon,
|
||||
label: "درمانهای چندجلسهای",
|
||||
},
|
||||
{
|
||||
to: "/admin/resources",
|
||||
icon: CubeIcon,
|
||||
@@ -462,6 +482,13 @@ function buildSections(
|
||||
label: "سرویس ها",
|
||||
});
|
||||
}
|
||||
if (can("appointments", "view")) {
|
||||
items.push({
|
||||
to: "/admin/treatment-cases",
|
||||
icon: ClipboardDocumentListIcon,
|
||||
label: "درمانهای چندجلسهای",
|
||||
});
|
||||
}
|
||||
if (can("appointment_settings", "view")) {
|
||||
items.push({
|
||||
to: "/admin/resources",
|
||||
@@ -527,6 +554,11 @@ function buildSections(
|
||||
{
|
||||
label: "مدیریت",
|
||||
items: [
|
||||
{
|
||||
to: "/admin/my-sessions",
|
||||
icon: ClipboardDocumentListIcon,
|
||||
label: "جلسات امروز من",
|
||||
},
|
||||
{
|
||||
to: "/admin/my-services",
|
||||
icon: WrenchScrewdriverIcon,
|
||||
|
||||
@@ -3,7 +3,7 @@ import {
|
||||
CreditCardIcon, UserIcon, CalendarDaysIcon, BuildingOffice2Icon,
|
||||
BanknotesIcon, UsersIcon, ShieldCheckIcon,
|
||||
TagIcon, ChatBubbleLeftRightIcon, UserCircleIcon, UserPlusIcon, ReceiptPercentIcon,
|
||||
RectangleStackIcon, HashtagIcon,
|
||||
RectangleStackIcon, HashtagIcon, SparklesIcon,
|
||||
} from '@heroicons/react/24/outline';
|
||||
|
||||
/**
|
||||
@@ -36,6 +36,7 @@ export const SETTINGS_MENU: SettingsMenuItem[] = [
|
||||
{ key: 'clinic-doctors', label: 'پزشکان کلینیک', icon: BuildingOffice2Icon, to: '/admin/settings/clinic-doctors', roles: ['clinic'], perm: ['clinic_doctors', 'view'] },
|
||||
// «منابع» به سایدبار اصلی («مدیریت») منتقل شد — کارِ روزمره است، نه تنظیمات.
|
||||
// گِیتش آنجا همین است: `appointment_settings.view` در هر چهار نقشی که اینجا میدیدندش.
|
||||
{ key: 'practice-domain', label: 'حوزهٔ فعالیت', icon: SparklesIcon, to: '/admin/settings/practice-domain', roles: ['clinic'], perm: ['appointment_settings', 'view'] },
|
||||
{ key: 'service-categories', label: 'دستهبندیها', icon: RectangleStackIcon, to: '/admin/service-categories', roles: ['doctor', 'clinic'], perm: ['appointment_settings', 'view'] },
|
||||
{ key: 'holidays', label: 'تعطیلات رسمی', icon: CalendarDaysIcon, to: '/admin/holidays', roles: ['doctor', 'clinic'], perm: ['appointment_settings', 'view'] },
|
||||
{ key: 'payment', label: 'مدیریت پرداخت', icon: BanknotesIcon, to: '/admin/my-financial', perm: ['payments', 'view'] },
|
||||
|
||||
@@ -0,0 +1,215 @@
|
||||
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>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,101 @@
|
||||
import { useEffect, useState } from 'react';
|
||||
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
|
||||
import { toast } from 'sonner';
|
||||
import { api, ApiError, type ApiResponse } from '../lib/api';
|
||||
import PageHeader from '../components/ui/PageHeader';
|
||||
import SearchableSelect from '../components/ui/SearchableSelect';
|
||||
import { useAuthStore } from '../stores/authStore';
|
||||
import type { PracticeDomain } from '../types';
|
||||
|
||||
/**
|
||||
* حوزهٔ فعالیت کلینیک.
|
||||
*
|
||||
* تخصصِ پزشکی نیست — آن برچسبی است برای سایت عمومی. این یک کلید پیکربندی است که
|
||||
* تعیین میکند کدام workflow درمان صدا زده شود، پس کدش پایدار میماند و فقط ادمین
|
||||
* پلتفرم میتواند حوزهٔ تازه بسازد.
|
||||
*/
|
||||
export default function PracticeDomainSettingsPage() {
|
||||
const qc = useQueryClient();
|
||||
// محیط جاری با `db_uuid` شناخته میشود؛ همان uuid کلینیک است.
|
||||
const clinicUuid = useAuthStore((s) => s.context?.db_uuid ?? null);
|
||||
|
||||
const { data: domainsData, isLoading } = useQuery({
|
||||
queryKey: ['practice-domains'],
|
||||
queryFn: () => api.get<ApiResponse<PracticeDomain[]>>('/api/v1/practice-domains'),
|
||||
staleTime: 300_000,
|
||||
});
|
||||
|
||||
const { data: clinicData } = useQuery({
|
||||
queryKey: ['clinic-practice-domain', clinicUuid],
|
||||
queryFn: () => api.get<ApiResponse<{ data: { practice_domain: PracticeDomain | null } }>>(
|
||||
`/api/v1/clinic/${clinicUuid}`,
|
||||
),
|
||||
enabled: !!clinicUuid,
|
||||
});
|
||||
|
||||
const current = clinicData?.data?.data?.practice_domain ?? null;
|
||||
const [selected, setSelected] = useState<string | null>(null);
|
||||
|
||||
useEffect(() => setSelected(current?.uuid ?? null), [current]);
|
||||
|
||||
const save = useMutation({
|
||||
mutationFn: (uuid: string | null) => api.patch<ApiResponse<unknown>>(
|
||||
`/api/v1/clinic/${clinicUuid}`,
|
||||
{ practice_domain_uuid: uuid ?? '' },
|
||||
),
|
||||
onSuccess: () => {
|
||||
toast.success('حوزهٔ فعالیت ذخیره شد');
|
||||
qc.invalidateQueries({ queryKey: ['clinic-practice-domain', clinicUuid] });
|
||||
},
|
||||
onError: (e) => toast.error(e instanceof ApiError ? e.message : 'ذخیرهٔ حوزهٔ فعالیت ناموفق بود'),
|
||||
});
|
||||
|
||||
const domains = domainsData?.data ?? [];
|
||||
const options = domains.map((d) => ({ value: d.uuid, label: d.name }));
|
||||
const chosen = domains.find((d) => d.uuid === selected) ?? null;
|
||||
|
||||
return (
|
||||
<>
|
||||
<PageHeader title="حوزهٔ فعالیت" backTo="/admin/settings" />
|
||||
|
||||
<div className="card card-pad" style={{ display: 'grid', gap: 14, maxWidth: 560 }}>
|
||||
<p style={{ margin: 0, fontSize: 12.5, color: 'var(--text-3)', lineHeight: 1.9 }}>
|
||||
حوزهٔ فعالیت تعیین میکند سیستم چه فرآیند درمانی برای کلینیک شما اجرا کند. این با
|
||||
«تخصص» فرق دارد: تخصص برچسبی است که در سایت عمومی دیده میشود، این یک تنظیم است.
|
||||
انتخابنکردنش خطا نیست و کلینیک مثل امروز کار میکند.
|
||||
</p>
|
||||
|
||||
{isLoading ? (
|
||||
<span style={{ fontSize: 13, color: 'var(--text-3)' }}>در حال بارگذاری...</span>
|
||||
) : (
|
||||
<>
|
||||
<SearchableSelect
|
||||
options={options}
|
||||
value={selected}
|
||||
onChange={(v) => setSelected(v === null ? null : String(v))}
|
||||
placeholder="حوزهای انتخاب نشده"
|
||||
isClearable
|
||||
ariaLabel="حوزهٔ فعالیت کلینیک"
|
||||
/>
|
||||
|
||||
{chosen && !chosen.has_workflow && (
|
||||
<span style={{ fontSize: 12.5, color: 'var(--warning)' }}>
|
||||
برای این حوزه هنوز فرآیند اختصاصی تعریف نشده است؛ رفتار پیشفرض اعمال میشود.
|
||||
</span>
|
||||
)}
|
||||
|
||||
<button
|
||||
type="button"
|
||||
className="btn primary"
|
||||
disabled={save.isPending || !clinicUuid}
|
||||
onClick={() => save.mutate(selected)}
|
||||
style={{ justifySelf: 'start' }}
|
||||
>
|
||||
{save.isPending ? 'در حال ذخیره...' : 'ذخیره'}
|
||||
</button>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,173 @@
|
||||
import { Link } from 'react-router-dom';
|
||||
import { useQuery } from '@tanstack/react-query';
|
||||
import { api } from '../lib/api';
|
||||
import type { ApiResponse } from '../lib/api';
|
||||
import PageHeader from '../components/ui/PageHeader';
|
||||
import StatusBadge from '../components/ui/StatusBadge';
|
||||
import { formatDate } from '../lib/utils';
|
||||
import { useUrlState } from '../hooks/useUrlState';
|
||||
import type { TreatmentCaseSummary, StaffTreatmentSession } from '../types';
|
||||
|
||||
const TABS = [
|
||||
{ id: 'cases', label: 'پروندههای درمان' },
|
||||
{ id: 'unbooked', label: 'جلسات بدون نوبت' },
|
||||
] as const;
|
||||
|
||||
type TabId = typeof TABS[number]['id'];
|
||||
|
||||
const CASE_STATUS_LABEL: Record<TreatmentCaseSummary['status'], string> = {
|
||||
active: 'در جریان',
|
||||
completed: 'تمام شده',
|
||||
abandoned: 'رها شده',
|
||||
};
|
||||
|
||||
/**
|
||||
* پروندههای درمان و کارِ باقیماندهٔ منشی.
|
||||
*
|
||||
* صفِ «جلسات بدون نوبت» عمداً کنار فهرست پروندههاست نه صفحهٔ جدا: هر دو یک سؤال
|
||||
* را جواب میدهند — «کدام بیمار در چه مرحلهای است و چه کاری مانده».
|
||||
*/
|
||||
export default function TreatmentCasesPage() {
|
||||
const [urlState, setUrlState] = useUrlState({ tab: 'cases', status: '' });
|
||||
const tab = (TABS.some((t) => t.id === urlState.tab) ? urlState.tab : 'cases') as TabId;
|
||||
|
||||
return (
|
||||
<>
|
||||
<PageHeader title="درمانهای چندجلسهای" />
|
||||
|
||||
<div className="tabs" style={{ marginBottom: 16 }}>
|
||||
{TABS.map((t) => (
|
||||
<button
|
||||
key={t.id}
|
||||
type="button"
|
||||
className={tab === t.id ? 'active' : ''}
|
||||
onClick={() => setUrlState({ tab: t.id })}
|
||||
>
|
||||
{t.label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{tab === 'cases'
|
||||
? <CasesTab status={urlState.status} onStatus={(s) => setUrlState({ status: s })} />
|
||||
: <UnbookedTab />}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
function CasesTab({ status, onStatus }: { status: string; onStatus: (s: string) => void }) {
|
||||
const { data, isLoading } = useQuery({
|
||||
queryKey: ['treatment-cases', status],
|
||||
queryFn: () => api.get<ApiResponse<TreatmentCaseSummary[]>>(
|
||||
`/api/v1/treatment-cases${status ? `?status=${status}` : ''}`,
|
||||
),
|
||||
staleTime: 30_000,
|
||||
});
|
||||
|
||||
const cases = data?.data ?? [];
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="toolbar" style={{ marginBottom: 12, display: 'flex', gap: 8, flexWrap: 'wrap' }}>
|
||||
{[['', 'همه'], ['active', 'در جریان'], ['completed', 'تمام شده'], ['abandoned', 'رها شده']].map(([v, label]) => (
|
||||
<button
|
||||
key={v}
|
||||
type="button"
|
||||
className={`btn ${status === v ? 'primary' : 'secondary'} sm`}
|
||||
onClick={() => onStatus(v)}
|
||||
>
|
||||
{label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{isLoading ? (
|
||||
<div className="card card-pad" style={{ fontSize: 13, color: 'var(--text-3)' }}>در حال بارگذاری...</div>
|
||||
) : cases.length === 0 ? (
|
||||
<div className="card card-pad" style={{ fontSize: 13, color: 'var(--text-3)' }}>
|
||||
پروندهای یافت نشد. پرونده وقتی ساخته میشود که نوبتِ سرویسی با «طول درمان» قطعی شود.
|
||||
</div>
|
||||
) : (
|
||||
<div style={{ display: 'grid', gap: 12 }}>
|
||||
{cases.map((c) => (
|
||||
<div key={c.uuid} className="card card-pad" style={{ display: 'grid', gap: 8 }}>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 10, flexWrap: 'wrap' }}>
|
||||
<strong style={{ fontSize: 14 }}>{c.service.name}</strong>
|
||||
<span className={`badge ${c.status === 'active' ? 'blue' : c.status === 'completed' ? 'green' : 'gray'}`}>
|
||||
<span className="bdot" />{CASE_STATUS_LABEL[c.status]}
|
||||
</span>
|
||||
<span style={{ fontSize: 12.5, color: 'var(--text-3)' }}>
|
||||
{c.completed_sessions} از {c.total_sessions} جلسه
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div style={{ display: 'flex', gap: 16, flexWrap: 'wrap', fontSize: 12.5, color: 'var(--text-2)' }}>
|
||||
<span>شروع: {formatDate(c.opened_at)}</span>
|
||||
{c.supervisor && <span>پزشک ناظر: {c.supervisor.name}</span>}
|
||||
{c.areas.length > 0 && <span>نواحی: {c.areas.map((a) => a.name).join('، ')}</span>}
|
||||
</div>
|
||||
|
||||
<progress
|
||||
value={c.completed_sessions}
|
||||
max={c.total_sessions}
|
||||
aria-label={`پیشرفت دوره: ${c.completed_sessions} از ${c.total_sessions}`}
|
||||
style={{ width: '100%', height: 6 }}
|
||||
/>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
function UnbookedTab() {
|
||||
const { data, isLoading } = useQuery({
|
||||
queryKey: ['treatment-sessions-unbooked'],
|
||||
queryFn: () => api.get<ApiResponse<StaffTreatmentSession[]>>('/api/v1/treatment-sessions/unbooked?within_days=14'),
|
||||
staleTime: 30_000,
|
||||
});
|
||||
|
||||
const sessions = data?.data ?? [];
|
||||
|
||||
return (
|
||||
<>
|
||||
<p style={{ margin: '0 0 12px', fontSize: 12.5, color: 'var(--text-3)', lineHeight: 1.9 }}>
|
||||
جلساتی که سررسیدشان رسیده و هنوز نوبت نگرفتهاند. رزرو عمداً خودکار نیست — وقتِ مناسب را
|
||||
باید با خود بیمار هماهنگ کرد.
|
||||
</p>
|
||||
|
||||
{isLoading ? (
|
||||
<div className="card card-pad" style={{ fontSize: 13, color: 'var(--text-3)' }}>در حال بارگذاری...</div>
|
||||
) : sessions.length === 0 ? (
|
||||
<div className="card card-pad" style={{ fontSize: 13, color: 'var(--text-3)' }}>
|
||||
جلسهای در انتظار رزرو نیست.
|
||||
</div>
|
||||
) : (
|
||||
<div style={{ display: 'grid', gap: 12 }}>
|
||||
{sessions.map((s) => (
|
||||
<div key={s.uuid} className="card card-pad" style={{ display: 'grid', gap: 8 }}>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 10, flexWrap: 'wrap' }}>
|
||||
<strong style={{ fontSize: 14 }}>{s.service_name}</strong>
|
||||
<StatusBadge type="treatment-session" value={s.status} />
|
||||
<span style={{ fontSize: 12.5, color: 'var(--text-3)' }}>
|
||||
جلسهٔ {s.session_number} از {s.total_sessions}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{s.due_at !== null && (
|
||||
<span style={{ fontSize: 12.5, color: 'var(--warning)' }}>
|
||||
سررسید: {formatDate(s.due_at)}
|
||||
</span>
|
||||
)}
|
||||
|
||||
<Link to="/admin/appointments/new" className="btn primary sm" style={{ justifySelf: 'start' }}>
|
||||
ثبت نوبت این جلسه
|
||||
</Link>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -1270,6 +1270,17 @@ export interface CatalogCategory {
|
||||
children?: CatalogCategory[];
|
||||
}
|
||||
|
||||
export interface PracticeDomain {
|
||||
uuid: string;
|
||||
/** لنگرِ پیادهسازی workflow؛ بعد از ساخت تغییر نمیکند. */
|
||||
code: string;
|
||||
name: string;
|
||||
sort_order: number;
|
||||
active: boolean;
|
||||
/** آیا پیادهسازی اختصاصی دارد یا رفتار پیشفرض میگیرد. */
|
||||
has_workflow?: boolean;
|
||||
}
|
||||
|
||||
// ── Treatment ────────────────────────────────────────────────────────────────
|
||||
|
||||
export type TreatmentSessionStatus =
|
||||
|
||||
Reference in New Issue
Block a user