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 =