From c7fdb92df4b40ed9fd8be142f565e06a4541fbf7 Mon Sep 17 00:00:00 2001 From: hamed <15238-genius.ha@users.noreply.drupalcode.org> Date: Thu, 6 Aug 2026 21:51:21 +0330 Subject: [PATCH] feat(admin): practice domain settings, treatment case list and platform domain CRUD MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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) --- assets/admin/App.tsx | 6 + assets/admin/components/layout/Sidebar.tsx | 32 +++ .../admin/components/layout/settingsMenu.ts | 3 +- .../admin/pages/AdminPracticeDomainsPage.tsx | 215 ++++++++++++++++++ .../pages/PracticeDomainSettingsPage.tsx | 101 ++++++++ assets/admin/pages/TreatmentCasesPage.tsx | 173 ++++++++++++++ assets/admin/types/index.ts | 11 + 7 files changed, 540 insertions(+), 1 deletion(-) create mode 100644 assets/admin/pages/AdminPracticeDomainsPage.tsx create mode 100644 assets/admin/pages/PracticeDomainSettingsPage.tsx create mode 100644 assets/admin/pages/TreatmentCasesPage.tsx diff --git a/assets/admin/App.tsx b/assets/admin/App.tsx index 8b60bcf7..572117a4 100644 --- a/assets/admin/App.tsx +++ b/assets/admin/App.tsx @@ -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() { } /> } /> } /> + } /> + } /> + } /> } /> } /> } /> diff --git a/assets/admin/components/layout/Sidebar.tsx b/assets/admin/components/layout/Sidebar.tsx index c7d7cb92..83673c24 100644 --- a/assets/admin/components/layout/Sidebar.tsx +++ b/assets/admin/components/layout/Sidebar.tsx @@ -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, diff --git a/assets/admin/components/layout/settingsMenu.ts b/assets/admin/components/layout/settingsMenu.ts index 4ef2ad4e..d2cdec60 100644 --- a/assets/admin/components/layout/settingsMenu.ts +++ b/assets/admin/components/layout/settingsMenu.ts @@ -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'] }, diff --git a/assets/admin/pages/AdminPracticeDomainsPage.tsx b/assets/admin/pages/AdminPracticeDomainsPage.tsx new file mode 100644 index 00000000..f35edd28 --- /dev/null +++ b/assets/admin/pages/AdminPracticeDomainsPage.tsx @@ -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>('/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>('/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>(`/api/v1/practice-domain/${uuid}`, d), + onSuccess: () => { toast.success('حوزهٔ فعالیت به‌روزرسانی شد'); invalidate(); }, + onError: (e) => fail(e, 'به‌روزرسانی ناموفق بود'), + }); + + const columns: Column[] = [ + { key: 'name', header: 'نام', render: (d) => {d.name} }, + { key: 'code', header: 'کد', render: (d) => {d.code} }, + { + key: 'has_workflow', + header: 'فرآیند اختصاصی', + render: (d) => ( + + {d.has_workflow ? 'دارد' : 'پیش‌فرض'} + + ), + }, + { + key: 'active', + header: 'وضعیت', + render: (d) => ( + + {d.active ? 'فعال' : 'غیرفعال'} + + ), + }, + { + key: 'actions', + header: '', + render: (d) => ( + + ), + }, + ]; + + return ( + <> + setEditing({ open: true, domain: null })}> + افزودن حوزه + + } + /> + + + + 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 ( + + + + + } + > +
{ + e.preventDefault(); + if (!saving && !invalid) onSave({ code: code.trim(), name: name.trim(), sortOrder, active }); + }} + > + + setCode(e.target.value)} + style={isEdit ? { opacity: 0.6, cursor: 'not-allowed' } : undefined} + /> + + {isEdit + ? 'کد پس از ساخت تغییر نمی‌کند؛ فرآیند درمان روی همین کد سوار می‌شود.' + : 'فرآیند درمان به این کد گره می‌خورد، پس بعداً قابل تغییر نیست.'} + + + + + setName(e.target.value)} + /> + + + + setSortOrder(Number(e.target.value))} + /> + + + {isEdit && ( + + )} + +
+ ); +} diff --git a/assets/admin/pages/PracticeDomainSettingsPage.tsx b/assets/admin/pages/PracticeDomainSettingsPage.tsx new file mode 100644 index 00000000..4b019d21 --- /dev/null +++ b/assets/admin/pages/PracticeDomainSettingsPage.tsx @@ -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>('/api/v1/practice-domains'), + staleTime: 300_000, + }); + + const { data: clinicData } = useQuery({ + queryKey: ['clinic-practice-domain', clinicUuid], + queryFn: () => api.get>( + `/api/v1/clinic/${clinicUuid}`, + ), + enabled: !!clinicUuid, + }); + + const current = clinicData?.data?.data?.practice_domain ?? null; + const [selected, setSelected] = useState(null); + + useEffect(() => setSelected(current?.uuid ?? null), [current]); + + const save = useMutation({ + mutationFn: (uuid: string | null) => api.patch>( + `/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 ( + <> + + +
+

+ حوزهٔ فعالیت تعیین می‌کند سیستم چه فرآیند درمانی برای کلینیک شما اجرا کند. این با + «تخصص» فرق دارد: تخصص برچسبی است که در سایت عمومی دیده می‌شود، این یک تنظیم است. + انتخاب‌نکردنش خطا نیست و کلینیک مثل امروز کار می‌کند. +

+ + {isLoading ? ( + در حال بارگذاری... + ) : ( + <> + setSelected(v === null ? null : String(v))} + placeholder="حوزه‌ای انتخاب نشده" + isClearable + ariaLabel="حوزهٔ فعالیت کلینیک" + /> + + {chosen && !chosen.has_workflow && ( + + برای این حوزه هنوز فرآیند اختصاصی تعریف نشده است؛ رفتار پیش‌فرض اعمال می‌شود. + + )} + + + + )} +
+ + ); +} diff --git a/assets/admin/pages/TreatmentCasesPage.tsx b/assets/admin/pages/TreatmentCasesPage.tsx new file mode 100644 index 00000000..48522ed9 --- /dev/null +++ b/assets/admin/pages/TreatmentCasesPage.tsx @@ -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 = { + 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 ( + <> + + +
+ {TABS.map((t) => ( + + ))} +
+ + {tab === 'cases' + ? setUrlState({ status: s })} /> + : } + + ); +} + +function CasesTab({ status, onStatus }: { status: string; onStatus: (s: string) => void }) { + const { data, isLoading } = useQuery({ + queryKey: ['treatment-cases', status], + queryFn: () => api.get>( + `/api/v1/treatment-cases${status ? `?status=${status}` : ''}`, + ), + staleTime: 30_000, + }); + + const cases = data?.data ?? []; + + return ( + <> +
+ {[['', 'همه'], ['active', 'در جریان'], ['completed', 'تمام شده'], ['abandoned', 'رها شده']].map(([v, label]) => ( + + ))} +
+ + {isLoading ? ( +
در حال بارگذاری...
+ ) : cases.length === 0 ? ( +
+ پرونده‌ای یافت نشد. پرونده وقتی ساخته می‌شود که نوبتِ سرویسی با «طول درمان» قطعی شود. +
+ ) : ( +
+ {cases.map((c) => ( +
+
+ {c.service.name} + + {CASE_STATUS_LABEL[c.status]} + + + {c.completed_sessions} از {c.total_sessions} جلسه + +
+ +
+ شروع: {formatDate(c.opened_at)} + {c.supervisor && پزشک ناظر: {c.supervisor.name}} + {c.areas.length > 0 && نواحی: {c.areas.map((a) => a.name).join('، ')}} +
+ + +
+ ))} +
+ )} + + ); +} + +function UnbookedTab() { + const { data, isLoading } = useQuery({ + queryKey: ['treatment-sessions-unbooked'], + queryFn: () => api.get>('/api/v1/treatment-sessions/unbooked?within_days=14'), + staleTime: 30_000, + }); + + const sessions = data?.data ?? []; + + return ( + <> +

+ جلساتی که سررسیدشان رسیده و هنوز نوبت نگرفته‌اند. رزرو عمداً خودکار نیست — وقتِ مناسب را + باید با خود بیمار هماهنگ کرد. +

+ + {isLoading ? ( +
در حال بارگذاری...
+ ) : sessions.length === 0 ? ( +
+ جلسه‌ای در انتظار رزرو نیست. +
+ ) : ( +
+ {sessions.map((s) => ( +
+
+ {s.service_name} + + + جلسهٔ {s.session_number} از {s.total_sessions} + +
+ + {s.due_at !== null && ( + + سررسید: {formatDate(s.due_at)} + + )} + + + ثبت نوبت این جلسه + +
+ ))} +
+ )} + + ); +} diff --git a/assets/admin/types/index.ts b/assets/admin/types/index.ts index 0d84a1cd..1bad0d27 100644 --- a/assets/admin/types/index.ts +++ b/assets/admin/types/index.ts @@ -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 =