diff --git a/assets/admin/components/layout/Sidebar.tsx b/assets/admin/components/layout/Sidebar.tsx index a3ad08b6..918a1bde 100644 --- a/assets/admin/components/layout/Sidebar.tsx +++ b/assets/admin/components/layout/Sidebar.tsx @@ -10,7 +10,6 @@ import { ChevronDownIcon, ClipboardDocumentCheckIcon, ClipboardDocumentListIcon, - SparklesIcon, Cog6ToothIcon, CreditCardIcon, CubeIcon, @@ -23,6 +22,7 @@ import { LockClosedIcon, PlusIcon, ShieldCheckIcon, + SparklesIcon, StarIcon, TagIcon, UserCircleIcon, @@ -32,9 +32,9 @@ import { } from "@heroicons/react/24/outline"; import { useState } from "react"; import { NavLink, useLocation, useNavigate } from "react-router-dom"; -import { useSubscription } from "../../hooks/useSubscription"; import { usePermissions } from "../../hooks/usePermissions"; import { useSecretaryEarnings } from "../../hooks/useSecretaryEarnings"; +import { useSubscription } from "../../hooks/useSubscription"; import { useAuthStore } from "../../stores/authStore"; import { useUiStore } from "../../stores/uiStore"; @@ -62,7 +62,11 @@ const APPOINTMENTS_CHILDREN: SubItem[] = [ */ const APPOINTMENTS_CHILDREN_WITH_RESERVE: SubItem[] = [ ...APPOINTMENTS_CHILDREN, - { to: "/admin/appointments/reserve", label: "رزرو نوبت", icon: ArchiveBoxIcon }, + { + to: "/admin/appointments/reserve", + label: "رزرو نوبت", + icon: ArchiveBoxIcon, + }, ]; function buildSections( @@ -94,25 +98,55 @@ function buildSections( }); } if (can("payments", "view")) { - items.push({ to: "/admin/my-payments", icon: CreditCardIcon, label: "پرداخت‌ها" }); + items.push({ + to: "/admin/my-payments", + icon: CreditCardIcon, + label: "پرداخت‌ها", + }); } if (can("insurances", "view")) { items.push( - { to: "/admin/insurance-pricing", icon: ShieldCheckIcon, label: "قیمت‌گذاری بیمه", feature: "insurance" }, - { to: "/admin/claims", icon: DocumentTextIcon, label: "مطالبات بیمه", feature: "insurance" }, + { + to: "/admin/insurance-pricing", + icon: ShieldCheckIcon, + label: "قیمت‌گذاری بیمه", + feature: "insurance", + }, + { + to: "/admin/claims", + icon: DocumentTextIcon, + label: "مطالبات بیمه", + feature: "insurance", + }, ); } if (can("services", "view")) { - items.push({ to: "/admin/clinic-services", icon: WrenchScrewdriverIcon, label: "سرویس ها" }); + items.push({ + to: "/admin/clinic-services", + icon: WrenchScrewdriverIcon, + label: "سرویس ها", + }); } if (can("appointments", "view")) { - items.push({ to: "/admin/treatment-cases", icon: ClipboardDocumentListIcon, label: "درمان‌های چندجلسه‌ای" }); + items.push({ + to: "/admin/treatment-cases", + icon: ClipboardDocumentListIcon, + label: "دوره‌های درمان", + }); } if (can("appointment_settings", "view")) { - items.push({ to: "/admin/resources", icon: CubeIcon, label: "منابع" }); + items.push({ + to: "/admin/resources", + icon: CubeIcon, + label: "منابع", + }); } if (can("inventory", "view")) { - items.push({ to: "/admin/inventory", icon: ArchiveBoxIcon, label: "انبارداری" }); + items.push({ + to: "/admin/inventory", + icon: ArchiveBoxIcon, + label: "انبارداری", + }); } // زیرمنوهای «تنظیمات» (services/tags/staff/discounts/sms/appointment_settings) @@ -121,12 +155,24 @@ function buildSections( return [ { label: "عمومی", - items: [{ to: "/admin/dashboard", icon: ChartBarIcon, label: "داشبورد" }], + items: [ + { + to: "/admin/dashboard", + icon: ChartBarIcon, + label: "داشبورد", + }, + ], }, { label: "مدیریت", items }, { label: "تنظیمات", - items: [{ to: "/admin/account-settings", icon: Cog6ToothIcon, label: "تنظیمات" }], + items: [ + { + to: "/admin/account-settings", + icon: Cog6ToothIcon, + label: "تنظیمات", + }, + ], }, ]; } @@ -323,7 +369,7 @@ function buildSections( { to: "/admin/treatment-cases", icon: ClipboardDocumentListIcon, - label: "درمان‌های چندجلسه‌ای", + label: "دوره‌های درمان", }, { to: "/admin/resources", @@ -408,7 +454,7 @@ function buildSections( { to: "/admin/treatment-cases", icon: ClipboardDocumentListIcon, - label: "درمان‌های چندجلسه‌ای", + label: "دوره‌های درمان", }, { to: "/admin/resources", @@ -486,7 +532,7 @@ function buildSections( items.push({ to: "/admin/treatment-cases", icon: ClipboardDocumentListIcon, - label: "درمان‌های چندجلسه‌ای", + label: "دوره‌های درمان", }); } if (can("appointment_settings", "view")) { @@ -525,7 +571,13 @@ function buildSections( return [ { label: "عمومی", - items: [{ to: "/admin/dashboard", icon: ChartBarIcon, label: "داشبورد" }], + items: [ + { + to: "/admin/dashboard", + icon: ChartBarIcon, + label: "داشبورد", + }, + ], }, { label: "مدیریت", items }, { @@ -549,7 +601,13 @@ function buildSections( return [ { label: "عمومی", - items: [{ to: "/admin/dashboard", icon: ChartBarIcon, label: "داشبورد" }], + items: [ + { + to: "/admin/dashboard", + icon: ChartBarIcon, + label: "داشبورد", + }, + ], }, { label: "مدیریت", @@ -799,7 +857,9 @@ export default function Sidebar({ mobileOpen: _m, onMobileClose: _c }: Props) { const navigate = useNavigate(); const { can } = usePermissions(); - const { summary: secretaryEarnings } = useSecretaryEarnings(primaryRole === "secretary"); + const { summary: secretaryEarnings } = useSecretaryEarnings( + primaryRole === "secretary", + ); const sections = buildSections( primaryRole, dbUuid, diff --git a/assets/admin/pages/TreatmentCasesPage.tsx b/assets/admin/pages/TreatmentCasesPage.tsx index d21fa502..eb00417d 100644 --- a/assets/admin/pages/TreatmentCasesPage.tsx +++ b/assets/admin/pages/TreatmentCasesPage.tsx @@ -1,29 +1,40 @@ -import { useEffect, useState } from 'react'; -import { Link } from 'react-router-dom'; -import { useQuery } from '@tanstack/react-query'; -import { MagnifyingGlassIcon, PencilSquareIcon, XMarkIcon } from '@heroicons/react/24/outline'; -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, formatDateTime, formatNumber } from '../lib/utils'; -import { useUrlState } from '../hooks/useUrlState'; -import PersianDateInput from '../components/ui/PersianDateInput'; -import TreatmentCaseEditModal from '../components/TreatmentCaseEditModal'; -import { PatientsGridView, PatientsCategoryView } from '../components/icons/FilesToolbarIcons'; -import type { TreatmentCaseSummary, StaffTreatmentSession, SlotSuggestionResponse } from '../types'; +import { + MagnifyingGlassIcon, + PencilSquareIcon, + XMarkIcon, +} from "@heroicons/react/24/outline"; +import { useQuery } from "@tanstack/react-query"; +import { useEffect, useState } from "react"; +import { Link } from "react-router-dom"; +import TreatmentCaseEditModal from "../components/TreatmentCaseEditModal"; +import { + PatientsCategoryView, + PatientsGridView, +} from "../components/icons/FilesToolbarIcons"; +import PageHeader from "../components/ui/PageHeader"; +import PersianDateInput from "../components/ui/PersianDateInput"; +import StatusBadge from "../components/ui/StatusBadge"; +import { useUrlState } from "../hooks/useUrlState"; +import type { ApiResponse } from "../lib/api"; +import { api } from "../lib/api"; +import { formatDate, formatDateTime, formatNumber } from "../lib/utils"; +import type { + SlotSuggestionResponse, + StaffTreatmentSession, + TreatmentCaseSummary, +} from "../types"; const TABS = [ - { id: 'cases', label: 'پرونده‌های درمان' }, - { id: 'unbooked', label: 'جلسات بدون نوبت' }, + { id: "cases", label: "پرونده‌های درمان" }, + { id: "unbooked", label: "جلسات بدون نوبت" }, ] as const; -type TabId = typeof TABS[number]['id']; +type TabId = (typeof TABS)[number]["id"]; -const CASE_STATUS_LABEL: Record = { - active: 'در جریان', - completed: 'تمام شده', - abandoned: 'رها شده', +const CASE_STATUS_LABEL: Record = { + active: "در جریان", + completed: "تمام شده", + abandoned: "رها شده", }; /** @@ -33,399 +44,738 @@ const CASE_STATUS_LABEL: Record = { * را جواب می‌دهند — «کدام بیمار در چه مرحله‌ای است و چه کاری مانده». */ export default function TreatmentCasesPage() { - const [urlState, setUrlState] = useUrlState({ tab: 'cases', status: '', q: '', from: '', to: '', view: 'table' }); - const tab = (TABS.some((t) => t.id === urlState.tab) ? urlState.tab : 'cases') as TabId; + const [urlState, setUrlState] = useUrlState({ + tab: "cases", + status: "", + q: "", + from: "", + to: "", + view: "table", + }); + const tab = ( + TABS.some((t) => t.id === urlState.tab) ? urlState.tab : "cases" + ) as TabId; - return ( - <> - + return ( + <> + -
- {TABS.map((t) => ( - - ))} -
+
+ {TABS.map((t) => ( + + ))} +
- {tab === 'cases' - ? setUrlState({ status: s })} - search={urlState.q} - onSearch={(q) => setUrlState({ q })} - from={urlState.from} - onFrom={(from) => setUrlState({ from })} - to={urlState.to} - onTo={(to) => setUrlState({ to })} - view={urlState.view === 'card' ? 'card' : 'table'} - onView={(v) => setUrlState({ view: v })} - /> - : } - - ); + {tab === "cases" ? ( + setUrlState({ status: s })} + search={urlState.q} + onSearch={(q) => setUrlState({ q })} + from={urlState.from} + onFrom={(from) => setUrlState({ from })} + to={urlState.to} + onTo={(to) => setUrlState({ to })} + view={urlState.view === "card" ? "card" : "table"} + onView={(v) => setUrlState({ view: v })} + /> + ) : ( + + )} + + ); } const STATUS_FILTERS = [ - ['', 'همه'], - ['active', 'در جریان'], - ['completed', 'تمام شده'], - ['abandoned', 'رها شده'], + ["", "همه"], + ["active", "در جریان"], + ["completed", "تمام شده"], + ["abandoned", "رها شده"], ] as const; -function CasesTab({ status, onStatus, search, onSearch, from, onFrom, to, onTo, view, onView }: { - status: string; - onStatus: (s: string) => void; - search: string; - onSearch: (s: string) => void; - /** بازهٔ تاریخِ باز شدن پرونده، `YYYY-MM-DD` میلادی. خالی = بدون کران. */ - from: string; - onFrom: (s: string) => void; - to: string; - onTo: (s: string) => void; - /** جدولی یا کارتی — همان الگوی صفحهٔ پرونده‌ها، در URL تا «بازگشت» نما را نپراند. */ - view: 'table' | 'card'; - onView: (v: 'table' | 'card') => void; +function CasesTab({ + status, + onStatus, + search, + onSearch, + from, + onFrom, + to, + onTo, + view, + onView, +}: { + status: string; + onStatus: (s: string) => void; + search: string; + onSearch: (s: string) => void; + /** بازهٔ تاریخِ باز شدن پرونده، `YYYY-MM-DD` میلادی. خالی = بدون کران. */ + from: string; + onFrom: (s: string) => void; + to: string; + onTo: (s: string) => void; + /** جدولی یا کارتی — همان الگوی صفحهٔ پرونده‌ها، در URL تا «بازگشت» نما را نپراند. */ + view: "table" | "card"; + onView: (v: "table" | "card") => void; }) { - // فیلد جستجو محلی می‌ماند و فقط مقدار نهایی به URL می‌رود؛ وگرنه هر حرف یک ورودی - // تاریخچه می‌سازد و «بازگشت» بی‌معنی می‌شود. - const [term, setTerm] = useState(search); - useEffect(() => setTerm(search), [search]); - useEffect(() => { - const t = setTimeout(() => { if (term !== search) onSearch(term); }, 350); - return () => clearTimeout(t); - }, [term]); + // فیلد جستجو محلی می‌ماند و فقط مقدار نهایی به URL می‌رود؛ وگرنه هر حرف یک ورودی + // تاریخچه می‌سازد و «بازگشت» بی‌معنی می‌شود. + const [term, setTerm] = useState(search); + useEffect(() => setTerm(search), [search]); + useEffect(() => { + const t = setTimeout(() => { + if (term !== search) onSearch(term); + }, 350); + return () => clearTimeout(t); + }, [term]); - const [editing, setEditing] = useState(null); + const [editing, setEditing] = useState(null); - const { data, isLoading, isError, refetch } = useQuery({ - queryKey: ['treatment-cases', status, search, from, to], - queryFn: () => { - const qs = new URLSearchParams(); - if (status) qs.set('status', status); - if (search) qs.set('q', search); - if (from) qs.set('from', from); - if (to) qs.set('to', to); - const suffix = qs.toString(); + const { data, isLoading, isError, refetch } = useQuery({ + queryKey: ["treatment-cases", status, search, from, to], + queryFn: () => { + const qs = new URLSearchParams(); + if (status) qs.set("status", status); + if (search) qs.set("q", search); + if (from) qs.set("from", from); + if (to) qs.set("to", to); + const suffix = qs.toString(); - return api.get>( - `/api/v1/treatment-cases${suffix ? `?${suffix}` : ''}`, - ); - }, - staleTime: 30_000, - }); + return api.get>( + `/api/v1/treatment-cases${suffix ? `?${suffix}` : ""}`, + ); + }, + staleTime: 30_000, + }); - const cases = data?.data ?? []; + const cases = data?.data ?? []; - return ( - <> -
-
- - setTerm(e.target.value)} - placeholder="نام بیمار، پرسنل، موبایل، کد ملی، شمارهٔ پرونده یا سرویس" - aria-label="جستجوی پرونده" - /> - {term !== '' && ( - - )} -
- -
- {([['table', 'نمایش جدولی', PatientsGridView], ['card', 'نمایش کارتی', PatientsCategoryView]] as const).map( - ([v, label, Icon]) => ( - - ), - )} -
- -
- {STATUS_FILTERS.map(([v, label]) => ( - - ))} -
+
+ + setTerm(e.target.value)} + placeholder="نام بیمار، پرسنل، موبایل، کد ملی، شمارهٔ پرونده یا سرویس" + aria-label="جستجوی پرونده" + /> + {term !== "" && ( + + )} +
- {/* بازه روی تاریخِ باز شدن پرونده است — همان چیزی که در کارت زیر «شروع» می‌آید. */} -
- شروع - {/* دو تاریخ و «تا»ی بینشان یک واحدند: اگر جدا بشکنند، «تا» از فیلدش +
+ {( + [ + ["table", "نمایش جدولی", PatientsGridView], + ["card", "نمایش کارتی", PatientsCategoryView], + ] as const + ).map(([v, label, Icon]) => ( + + ))} +
+ +
+ {STATUS_FILTERS.map(([v, label]) => ( + + ))} +
+ + {/* بازه روی تاریخِ باز شدن پرونده است — همان چیزی که در کارت زیر «شروع» می‌آید. */} +
+ + شروع + + {/* دو تاریخ و «تا»ی بینشان یک واحدند: اگر جدا بشکنند، «تا» از فیلدش می‌افتد و معلوم نیست کران بالا کدام است. */} -
-
- +
+
+ +
+ + تا + +
+ +
+
+ {(from !== "" || to !== "") && ( + + )} +
- تا -
- -
-
- {(from !== '' || to !== '') && ( - - )} -
-
- {from !== '' && to !== '' && from > to && ( -
- «از تاریخ» بعد از «تا تاریخ» است، پس هیچ پرونده‌ای در این بازه نمی‌افتد. -
- )} + {from !== "" && to !== "" && from > to && ( +
+ «از تاریخ» بعد از «تا تاریخ» است، پس هیچ پرونده‌ای در این + بازه نمی‌افتد. +
+ )} - {isLoading ? ( -
- {[0, 1].map((i) =>
)} -
- ) : isError ? ( - /* خطای سرور نباید «پرونده‌ای یافت نشد» خوانده شود — آن یعنی جستجو نتیجه نداشت. */ -
- خواندن پرونده‌ها ناموفق بود. - -
- ) : cases.length === 0 ? ( -
- {search - ? `برای «${search}» پرونده‌ای پیدا نشد.` - : (from !== '' || to !== '') - ? 'در این بازهٔ تاریخ پرونده‌ای باز نشده است.' - : 'پرونده‌ای یافت نشد. پرونده وقتی ساخته می‌شود که نوبتِ سرویسی با «طول درمان» قطعی شود.'} -
- ) : view === 'table' ? ( - - ) : ( -
- {cases.map((c) => setEditing(c.uuid)} />)} -
- )} + {isLoading ? ( +
+ {[0, 1].map((i) => ( +
+ ))} +
+ ) : isError ? ( + /* خطای سرور نباید «پرونده‌ای یافت نشد» خوانده شود — آن یعنی جستجو نتیجه نداشت. */ +
+ + خواندن پرونده‌ها ناموفق بود. + + +
+ ) : cases.length === 0 ? ( +
+ {search + ? `برای «${search}» پرونده‌ای پیدا نشد.` + : from !== "" || to !== "" + ? "در این بازهٔ تاریخ پرونده‌ای باز نشده است." + : "پرونده‌ای یافت نشد. پرونده وقتی ساخته می‌شود که نوبتِ سرویسی با «طول درمان» قطعی شود."} +
+ ) : view === "table" ? ( + + ) : ( +
+ {cases.map((c) => ( + setEditing(c.uuid)} + /> + ))} +
+ )} - {editing !== null && ( - setEditing(null)} /> - )} - - ); + {editing !== null && ( + setEditing(null)} + /> + )} + + ); } /** * انجام‌دهنده از جلسات می‌آید (سابقه) و اختصاص‌یافته برنامه است؛ تا وقتی جلسه‌ای * انجام نشده، همان برنامه را نشان می‌دهیم. هر دو نما یک قاعده دارند. */ -function operatorOf(c: TreatmentCaseSummary): { text: string; planned: boolean } | null { - if (c.performed_by.length > 0) { - return { text: c.performed_by.map((s) => s.name).join('، '), planned: false }; - } +function operatorOf( + c: TreatmentCaseSummary, +): { text: string; planned: boolean } | null { + if (c.performed_by.length > 0) { + return { + text: c.performed_by.map((s) => s.name).join("، "), + planned: false, + }; + } - if (c.assigned_staff.length > 0) { - return { text: c.assigned_staff.map((s) => s.name).join('، '), planned: true }; - } + if (c.assigned_staff.length > 0) { + return { + text: c.assigned_staff.map((s) => s.name).join("، "), + planned: true, + }; + } - return null; + return null; } -const TABLE_HEADS = ['ردیف', 'بیمار', 'سرویس', 'پرسنل', 'وضعیت', 'جلسات', 'شروع', 'عملیات']; +const TABLE_HEADS = [ + "ردیف", + "بیمار", + "سرویس", + "پرسنل", + "وضعیت", + "جلسات", + "شروع", + "عملیات", +]; -function CasesTable({ cases, onEdit }: { cases: TreatmentCaseSummary[]; onEdit: (uuid: string) => void }) { - return ( -
- - - - {TABLE_HEADS.map((h) => ( - - ))} - - - - {cases.map((c, i) => { - const operator = operatorOf(c); +function CasesTable({ + cases, + onEdit, +}: { + cases: TreatmentCaseSummary[]; + onEdit: (uuid: string) => void; +}) { + return ( +
+
- {h} -
+ + + {TABLE_HEADS.map((h) => ( + + ))} + + + + {cases.map((c, i) => { + const operator = operatorOf(c); - return ( - - - - - - - - - + + + + + + + + + ); + })} + +
+ {h} +
{formatNumber(i + 1)} -
{c.patient.name || 'بیمار بدون نام'}
-
{c.patient.mobile}
-
{c.service.name} - {operator === null ? '—' : operator.planned ? `${operator.text} (هنوز انجام نشده)` : operator.text} - - - {CASE_STATUS_LABEL[c.status]} - - - {formatNumber(c.completed_sessions)} از {formatNumber(c.total_sessions)} - {formatDateTime(c.opened_at)} - + {formatNumber(i + 1)} + +
+ {c.patient.name || "بیمار بدون نام"} +
+
+ {c.patient.mobile} +
+
+ {c.service.name} + + {operator === null + ? "—" + : operator.planned + ? `${operator.text} (هنوز انجام نشده)` + : operator.text} + + + + {CASE_STATUS_LABEL[c.status]} + + + {formatNumber(c.completed_sessions)} از{" "} + {formatNumber(c.total_sessions)} + + {formatDateTime(c.opened_at)} + + +
+
+ ); +} + +function CaseCard({ + item: c, + onEdit, +}: { + item: TreatmentCaseSummary; + onEdit: () => void; +}) { + const percent = + c.total_sessions > 0 + ? Math.round((c.completed_sessions / c.total_sessions) * 100) + : 0; + const operator = operatorOf(c); + + return ( +
+
+ {/* بیمار سرتیتر است نه سرویس: دو پروندهٔ یک سرویس فقط با نام بیمار از هم جدا می‌شوند. */} + + {c.patient.name || "بیمار بدون نام"} + + + + {CASE_STATUS_LABEL[c.status]} + + + {formatNumber(c.completed_sessions)} از{" "} + {formatNumber(c.total_sessions)} جلسه + + - - - ); - })} - - -
- ); -} + className="btn secondary sm" + style={{ marginInlineStart: "auto" }} + onClick={onEdit} + > + + ویرایش + +
-function CaseCard({ item: c, onEdit }: { item: TreatmentCaseSummary; onEdit: () => void }) { - const percent = c.total_sessions > 0 - ? Math.round((c.completed_sessions / c.total_sessions) * 100) - : 0; - const operator = operatorOf(c); +
+ {c.service.name} + {c.patient.mobile} + {/* ساعت هم لازم است: چند پروندهٔ یک روز فقط با ساعت از هم جدا می‌شوند. */} + شروع: {formatDateTime(c.opened_at)} + {c.supervisor && پزشک ناظر: {c.supervisor.name}} + {operator !== null && + (operator.planned ? ( + + پرسنل: {operator.text} (هنوز انجام نشده) + + ) : ( + انجام‌دهنده: {operator.text} + ))} + {c.areas.length > 0 && ( + نواحی: {c.areas.map((a) => a.name).join("، ")} + )} +
- return ( -
-
- {/* بیمار سرتیتر است نه سرویس: دو پروندهٔ یک سرویس فقط با نام بیمار از هم جدا می‌شوند. */} - {c.patient.name || 'بیمار بدون نام'} - - {CASE_STATUS_LABEL[c.status]} - - - {formatNumber(c.completed_sessions)} از {formatNumber(c.total_sessions)} جلسه - - -
- -
- {c.service.name} - {c.patient.mobile} - {/* ساعت هم لازم است: چند پروندهٔ یک روز فقط با ساعت از هم جدا می‌شوند. */} - شروع: {formatDateTime(c.opened_at)} - {c.supervisor && پزشک ناظر: {c.supervisor.name}} - {operator !== null && ( - operator.planned - ? پرسنل: {operator.text} (هنوز انجام نشده) - : انجام‌دهنده: {operator.text} - )} - {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 { 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 ?? []; + const sessions = data?.data ?? []; - return ( - <> -

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

+ return ( + <> +

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

- {isLoading ? ( -
در حال بارگذاری...
- ) : sessions.length === 0 ? ( -
- جلسه‌ای در انتظار رزرو نیست. -
- ) : ( -
- {sessions.map((s) => ( -
-
- {s.service_name} - - - جلسهٔ {s.session_number} از {s.total_sessions} - -
+ {isLoading ? ( +
+ در حال بارگذاری... +
+ ) : sessions.length === 0 ? ( +
+ جلسه‌ای در انتظار رزرو نیست. +
+ ) : ( +
+ {sessions.map((s) => ( +
+
+ + {s.service_name} + + + + جلسهٔ {s.session_number} از{" "} + {s.total_sessions} + +
- {s.due_at !== null && ( - - سررسید: {formatDate(s.due_at)} - - )} + {s.due_at !== null && ( + + سررسید: {formatDate(s.due_at)} + + )} - -
- ))} -
- )} - - ); + +
+ ))} +
+ )} + + ); } /** @@ -435,71 +785,105 @@ function UnbookedTab() { * می‌کند. خودکار رزرو کردن یعنی سیستم به‌جای بیمار تصمیم بگیرد و بعد او نیاید. */ function SlotSuggestions({ sessionUuid }: { sessionUuid: string }) { - const [open, setOpen] = useState(false); + const [open, setOpen] = useState(false); - const { data, isLoading, isError } = useQuery({ - queryKey: ['session-slot-suggestions', sessionUuid], - queryFn: () => api.get>( - `/api/v1/treatment-session/${sessionUuid}/slot-suggestions?days=14`, - ), - enabled: open, - staleTime: 60_000, - }); + const { data, isLoading, isError } = useQuery({ + queryKey: ["session-slot-suggestions", sessionUuid], + queryFn: () => + api.get>( + `/api/v1/treatment-session/${sessionUuid}/slot-suggestions?days=14`, + ), + enabled: open, + staleTime: 60_000, + }); - if (!open) { - return ( -
- - {/* لینک باید جلسه را ببرد، وگرنه منشی بیمار و سرویس را دستی می‌زند و اتصال + if (!open) { + return ( +
+ + {/* لینک باید جلسه را ببرد، وگرنه منشی بیمار و سرویس را دستی می‌زند و اتصال به حدسِ سرویس سپرده می‌شود. */} - - ثبت نوبت این جلسه - -
- ); - } + + ثبت نوبت این جلسه + +
+ ); + } - const days = data?.data?.days ?? []; + const days = data?.data?.days ?? []; - return ( -
- {isLoading && در حال جست‌وجوی وقت...} + return ( +
+ {isLoading && ( + + در حال جست‌وجوی وقت... + + )} - {isError && ( - - دستگاهی برای پیشنهاد وقت مشخص نیست — این جلسه هنوز روی هیچ دستگاهی انجام نشده. - - )} + {isError && ( + + دستگاهی برای پیشنهاد وقت مشخص نیست — این جلسه هنوز روی هیچ + دستگاهی انجام نشده. + + )} - {!isLoading && !isError && days.length === 0 && ( - - در دو هفتهٔ آینده وقت آزادی روی این دستگاه نیست. - - )} + {!isLoading && !isError && days.length === 0 && ( + + در دو هفتهٔ آینده وقت آزادی روی این دستگاه نیست. + + )} - {days.slice(0, 3).map((day) => ( -
- - {formatDate(Math.floor(new Date(day.date).getTime() / 1000))} - - {day.slots.slice(0, 6).map((slot) => ( - ( +
+ + {formatDate( + Math.floor(new Date(day.date).getTime() / 1000), + )} + + {day.slots.slice(0, 6).map((slot) => ( + + {slot.start_time} + + ))} +
+ ))} + +
- ))} - - -
- ); + ); }