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:
hamed
2026-08-06 21:51:21 +03:30
co-authored by Claude Opus 5
parent d8aabe5d0a
commit c7fdb92df4
7 changed files with 540 additions and 1 deletions
+6
View File
@@ -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>
</>
);
}
+173
View File
@@ -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>
)}
</>
);
}
+11
View File
@@ -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 =