Refactor code structure for improved readability and maintainability

This commit is contained in:
hamed
2026-08-07 17:16:10 +03:30
parent 01100b90ad
commit 0360a5e46f
2 changed files with 887 additions and 443 deletions
+78 -18
View File
@@ -10,7 +10,6 @@ import {
ChevronDownIcon, ChevronDownIcon,
ClipboardDocumentCheckIcon, ClipboardDocumentCheckIcon,
ClipboardDocumentListIcon, ClipboardDocumentListIcon,
SparklesIcon,
Cog6ToothIcon, Cog6ToothIcon,
CreditCardIcon, CreditCardIcon,
CubeIcon, CubeIcon,
@@ -23,6 +22,7 @@ import {
LockClosedIcon, LockClosedIcon,
PlusIcon, PlusIcon,
ShieldCheckIcon, ShieldCheckIcon,
SparklesIcon,
StarIcon, StarIcon,
TagIcon, TagIcon,
UserCircleIcon, UserCircleIcon,
@@ -32,9 +32,9 @@ import {
} from "@heroicons/react/24/outline"; } from "@heroicons/react/24/outline";
import { useState } from "react"; import { useState } from "react";
import { NavLink, useLocation, useNavigate } from "react-router-dom"; import { NavLink, useLocation, useNavigate } from "react-router-dom";
import { useSubscription } from "../../hooks/useSubscription";
import { usePermissions } from "../../hooks/usePermissions"; import { usePermissions } from "../../hooks/usePermissions";
import { useSecretaryEarnings } from "../../hooks/useSecretaryEarnings"; import { useSecretaryEarnings } from "../../hooks/useSecretaryEarnings";
import { useSubscription } from "../../hooks/useSubscription";
import { useAuthStore } from "../../stores/authStore"; import { useAuthStore } from "../../stores/authStore";
import { useUiStore } from "../../stores/uiStore"; import { useUiStore } from "../../stores/uiStore";
@@ -62,7 +62,11 @@ const APPOINTMENTS_CHILDREN: SubItem[] = [
*/ */
const APPOINTMENTS_CHILDREN_WITH_RESERVE: SubItem[] = [ const APPOINTMENTS_CHILDREN_WITH_RESERVE: SubItem[] = [
...APPOINTMENTS_CHILDREN, ...APPOINTMENTS_CHILDREN,
{ to: "/admin/appointments/reserve", label: "رزرو نوبت", icon: ArchiveBoxIcon }, {
to: "/admin/appointments/reserve",
label: "رزرو نوبت",
icon: ArchiveBoxIcon,
},
]; ];
function buildSections( function buildSections(
@@ -94,25 +98,55 @@ function buildSections(
}); });
} }
if (can("payments", "view")) { 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")) { if (can("insurances", "view")) {
items.push( 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")) { 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")) { 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")) { 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")) { 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) // زیرمنوهای «تنظیمات» (services/tags/staff/discounts/sms/appointment_settings)
@@ -121,12 +155,24 @@ function buildSections(
return [ return [
{ {
label: "عمومی", label: "عمومی",
items: [{ to: "/admin/dashboard", icon: ChartBarIcon, label: "داشبورد" }], items: [
{
to: "/admin/dashboard",
icon: ChartBarIcon,
label: "داشبورد",
},
],
}, },
{ label: "مدیریت", items }, { label: "مدیریت", items },
{ {
label: "تنظیمات", 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", to: "/admin/treatment-cases",
icon: ClipboardDocumentListIcon, icon: ClipboardDocumentListIcon,
label: رمان‌های چندجلسه‌ای", label: وره‌های درمان",
}, },
{ {
to: "/admin/resources", to: "/admin/resources",
@@ -408,7 +454,7 @@ function buildSections(
{ {
to: "/admin/treatment-cases", to: "/admin/treatment-cases",
icon: ClipboardDocumentListIcon, icon: ClipboardDocumentListIcon,
label: رمان‌های چندجلسه‌ای", label: وره‌های درمان",
}, },
{ {
to: "/admin/resources", to: "/admin/resources",
@@ -486,7 +532,7 @@ function buildSections(
items.push({ items.push({
to: "/admin/treatment-cases", to: "/admin/treatment-cases",
icon: ClipboardDocumentListIcon, icon: ClipboardDocumentListIcon,
label: رمان‌های چندجلسه‌ای", label: وره‌های درمان",
}); });
} }
if (can("appointment_settings", "view")) { if (can("appointment_settings", "view")) {
@@ -525,7 +571,13 @@ function buildSections(
return [ return [
{ {
label: "عمومی", label: "عمومی",
items: [{ to: "/admin/dashboard", icon: ChartBarIcon, label: "داشبورد" }], items: [
{
to: "/admin/dashboard",
icon: ChartBarIcon,
label: "داشبورد",
},
],
}, },
{ label: "مدیریت", items }, { label: "مدیریت", items },
{ {
@@ -549,7 +601,13 @@ function buildSections(
return [ return [
{ {
label: "عمومی", label: "عمومی",
items: [{ to: "/admin/dashboard", icon: ChartBarIcon, label: "داشبورد" }], items: [
{
to: "/admin/dashboard",
icon: ChartBarIcon,
label: "داشبورد",
},
],
}, },
{ {
label: "مدیریت", label: "مدیریت",
@@ -799,7 +857,9 @@ export default function Sidebar({ mobileOpen: _m, onMobileClose: _c }: Props) {
const navigate = useNavigate(); const navigate = useNavigate();
const { can } = usePermissions(); const { can } = usePermissions();
const { summary: secretaryEarnings } = useSecretaryEarnings(primaryRole === "secretary"); const { summary: secretaryEarnings } = useSecretaryEarnings(
primaryRole === "secretary",
);
const sections = buildSections( const sections = buildSections(
primaryRole, primaryRole,
dbUuid, dbUuid,
+553 -169
View File
@@ -1,29 +1,40 @@
import { useEffect, useState } from 'react'; import {
import { Link } from 'react-router-dom'; MagnifyingGlassIcon,
import { useQuery } from '@tanstack/react-query'; PencilSquareIcon,
import { MagnifyingGlassIcon, PencilSquareIcon, XMarkIcon } from '@heroicons/react/24/outline'; XMarkIcon,
import { api } from '../lib/api'; } from "@heroicons/react/24/outline";
import type { ApiResponse } from '../lib/api'; import { useQuery } from "@tanstack/react-query";
import PageHeader from '../components/ui/PageHeader'; import { useEffect, useState } from "react";
import StatusBadge from '../components/ui/StatusBadge'; import { Link } from "react-router-dom";
import { formatDate, formatDateTime, formatNumber } from '../lib/utils'; import TreatmentCaseEditModal from "../components/TreatmentCaseEditModal";
import { useUrlState } from '../hooks/useUrlState'; import {
import PersianDateInput from '../components/ui/PersianDateInput'; PatientsCategoryView,
import TreatmentCaseEditModal from '../components/TreatmentCaseEditModal'; PatientsGridView,
import { PatientsGridView, PatientsCategoryView } from '../components/icons/FilesToolbarIcons'; } from "../components/icons/FilesToolbarIcons";
import type { TreatmentCaseSummary, StaffTreatmentSession, SlotSuggestionResponse } from '../types'; 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 = [ const TABS = [
{ id: 'cases', label: 'پرونده‌های درمان' }, { id: "cases", label: "پرونده‌های درمان" },
{ id: 'unbooked', label: 'جلسات بدون نوبت' }, { id: "unbooked", label: "جلسات بدون نوبت" },
] as const; ] as const;
type TabId = typeof TABS[number]['id']; type TabId = (typeof TABS)[number]["id"];
const CASE_STATUS_LABEL: Record<TreatmentCaseSummary['status'], string> = { const CASE_STATUS_LABEL: Record<TreatmentCaseSummary["status"], string> = {
active: 'در جریان', active: "در جریان",
completed: 'تمام شده', completed: "تمام شده",
abandoned: 'رها شده', abandoned: "رها شده",
}; };
/** /**
@@ -33,19 +44,28 @@ const CASE_STATUS_LABEL: Record<TreatmentCaseSummary['status'], string> = {
* را جواب می‌دهند — «کدام بیمار در چه مرحله‌ای است و چه کاری مانده». * را جواب می‌دهند — «کدام بیمار در چه مرحله‌ای است و چه کاری مانده».
*/ */
export default function TreatmentCasesPage() { export default function TreatmentCasesPage() {
const [urlState, setUrlState] = useUrlState({ tab: 'cases', status: '', q: '', from: '', to: '', view: 'table' }); const [urlState, setUrlState] = useUrlState({
const tab = (TABS.some((t) => t.id === urlState.tab) ? urlState.tab : 'cases') as TabId; tab: "cases",
status: "",
q: "",
from: "",
to: "",
view: "table",
});
const tab = (
TABS.some((t) => t.id === urlState.tab) ? urlState.tab : "cases"
) as TabId;
return ( return (
<> <>
<PageHeader title=رمان‌های چندجلسه‌ای" /> <PageHeader title=وره‌های درمان" />
<div className="tabs" style={{ marginBottom: 16 }}> <div className="tabs" style={{ marginBottom: 16 }}>
{TABS.map((t) => ( {TABS.map((t) => (
<button <button
key={t.id} key={t.id}
type="button" type="button"
className={tab === t.id ? 'active' : ''} className={tab === t.id ? "active" : ""}
onClick={() => setUrlState({ tab: t.id })} onClick={() => setUrlState({ tab: t.id })}
> >
{t.label} {t.label}
@@ -53,8 +73,8 @@ export default function TreatmentCasesPage() {
))} ))}
</div> </div>
{tab === 'cases' {tab === "cases" ? (
? <CasesTab <CasesTab
status={urlState.status} status={urlState.status}
onStatus={(s) => setUrlState({ status: s })} onStatus={(s) => setUrlState({ status: s })}
search={urlState.q} search={urlState.q}
@@ -63,22 +83,35 @@ export default function TreatmentCasesPage() {
onFrom={(from) => setUrlState({ from })} onFrom={(from) => setUrlState({ from })}
to={urlState.to} to={urlState.to}
onTo={(to) => setUrlState({ to })} onTo={(to) => setUrlState({ to })}
view={urlState.view === 'card' ? 'card' : 'table'} view={urlState.view === "card" ? "card" : "table"}
onView={(v) => setUrlState({ view: v })} onView={(v) => setUrlState({ view: v })}
/> />
: <UnbookedTab />} ) : (
<UnbookedTab />
)}
</> </>
); );
} }
const STATUS_FILTERS = [ const STATUS_FILTERS = [
['', 'همه'], ["", "همه"],
['active', 'در جریان'], ["active", "در جریان"],
['completed', 'تمام شده'], ["completed", "تمام شده"],
['abandoned', 'رها شده'], ["abandoned", "رها شده"],
] as const; ] as const;
function CasesTab({ status, onStatus, search, onSearch, from, onFrom, to, onTo, view, onView }: { function CasesTab({
status,
onStatus,
search,
onSearch,
from,
onFrom,
to,
onTo,
view,
onView,
}: {
status: string; status: string;
onStatus: (s: string) => void; onStatus: (s: string) => void;
search: string; search: string;
@@ -89,32 +122,34 @@ function CasesTab({ status, onStatus, search, onSearch, from, onFrom, to, onTo,
to: string; to: string;
onTo: (s: string) => void; onTo: (s: string) => void;
/** جدولی یا کارتی — همان الگوی صفحهٔ پرونده‌ها، در URL تا «بازگشت» نما را نپراند. */ /** جدولی یا کارتی — همان الگوی صفحهٔ پرونده‌ها، در URL تا «بازگشت» نما را نپراند. */
view: 'table' | 'card'; view: "table" | "card";
onView: (v: 'table' | 'card') => void; onView: (v: "table" | "card") => void;
}) { }) {
// فیلد جستجو محلی می‌ماند و فقط مقدار نهایی به URL می‌رود؛ وگرنه هر حرف یک ورودی // فیلد جستجو محلی می‌ماند و فقط مقدار نهایی به URL می‌رود؛ وگرنه هر حرف یک ورودی
// تاریخچه می‌سازد و «بازگشت» بی‌معنی می‌شود. // تاریخچه می‌سازد و «بازگشت» بی‌معنی می‌شود.
const [term, setTerm] = useState(search); const [term, setTerm] = useState(search);
useEffect(() => setTerm(search), [search]); useEffect(() => setTerm(search), [search]);
useEffect(() => { useEffect(() => {
const t = setTimeout(() => { if (term !== search) onSearch(term); }, 350); const t = setTimeout(() => {
if (term !== search) onSearch(term);
}, 350);
return () => clearTimeout(t); return () => clearTimeout(t);
}, [term]); }, [term]);
const [editing, setEditing] = useState<string | null>(null); const [editing, setEditing] = useState<string | null>(null);
const { data, isLoading, isError, refetch } = useQuery({ const { data, isLoading, isError, refetch } = useQuery({
queryKey: ['treatment-cases', status, search, from, to], queryKey: ["treatment-cases", status, search, from, to],
queryFn: () => { queryFn: () => {
const qs = new URLSearchParams(); const qs = new URLSearchParams();
if (status) qs.set('status', status); if (status) qs.set("status", status);
if (search) qs.set('q', search); if (search) qs.set("q", search);
if (from) qs.set('from', from); if (from) qs.set("from", from);
if (to) qs.set('to', to); if (to) qs.set("to", to);
const suffix = qs.toString(); const suffix = qs.toString();
return api.get<ApiResponse<TreatmentCaseSummary[]>>( return api.get<ApiResponse<TreatmentCaseSummary[]>>(
`/api/v1/treatment-cases${suffix ? `?${suffix}` : ''}`, `/api/v1/treatment-cases${suffix ? `?${suffix}` : ""}`,
); );
}, },
staleTime: 30_000, staleTime: 30_000,
@@ -124,28 +159,60 @@ function CasesTab({ status, onStatus, search, onSearch, from, onFrom, to, onTo,
return ( return (
<> <>
<div style={{ display: 'flex', gap: 10, flexWrap: 'wrap', alignItems: 'center', marginBottom: 14 }}> <div
<div className="field" style={{ flex: '1 1 260px', maxWidth: 380 }}> style={{
<MagnifyingGlassIcon style={{ width: 16, height: 16, flexShrink: 0, color: 'var(--text-3)' }} /> display: "flex",
gap: 10,
flexWrap: "wrap",
alignItems: "center",
marginBottom: 14,
}}
>
<div
className="field"
style={{ flex: "1 1 260px", maxWidth: 380 }}
>
<MagnifyingGlassIcon
style={{
width: 16,
height: 16,
flexShrink: 0,
color: "var(--text-3)",
}}
/>
<input <input
value={term} value={term}
onChange={(e) => setTerm(e.target.value)} onChange={(e) => setTerm(e.target.value)}
placeholder="نام بیمار، پرسنل، موبایل، کد ملی، شمارهٔ پرونده یا سرویس" placeholder="نام بیمار، پرسنل، موبایل، کد ملی، شمارهٔ پرونده یا سرویس"
aria-label="جستجوی پرونده" aria-label="جستجوی پرونده"
/> />
{term !== '' && ( {term !== "" && (
<button type="button" className="mini-btn" aria-label="پاک کردن جستجو" onClick={() => setTerm('')}> <button
type="button"
className="mini-btn"
aria-label="پاک کردن جستجو"
onClick={() => setTerm("")}
>
<XMarkIcon style={{ width: 15, height: 15 }} /> <XMarkIcon style={{ width: 15, height: 15 }} />
</button> </button>
)} )}
</div> </div>
<div style={{ <div
display: 'flex', flexShrink: 0, overflow: 'hidden', style={{
border: '1px solid var(--border-2)', borderRadius: 4, display: "flex",
}}> flexShrink: 0,
{([['table', 'نمایش جدولی', PatientsGridView], ['card', 'نمایش کارتی', PatientsCategoryView]] as const).map( overflow: "hidden",
([v, label, Icon]) => ( border: "1px solid var(--border-2)",
borderRadius: 4,
}}
>
{(
[
["table", "نمایش جدولی", PatientsGridView],
["card", "نمایش کارتی", PatientsCategoryView],
] as const
).map(([v, label, Icon]) => (
<button <button
key={v} key={v}
type="button" type="button"
@@ -153,14 +220,26 @@ function CasesTab({ status, onStatus, search, onSearch, from, onFrom, to, onTo,
aria-pressed={view === v} aria-pressed={view === v}
onClick={() => onView(v)} onClick={() => onView(v)}
style={{ style={{
padding: 8, border: 'none', cursor: 'pointer', display: 'grid', placeItems: 'center', padding: 8,
background: view === v ? 'var(--primary-soft)' : 'transparent', border: "none",
cursor: "pointer",
display: "grid",
placeItems: "center",
background:
view === v
? "var(--primary-soft)"
: "transparent",
}} }}
> >
<Icon color={view === v ? 'var(--primary)' : 'var(--text-2)'} /> <Icon
color={
view === v
? "var(--primary)"
: "var(--text-2)"
}
/>
</button> </button>
), ))}
)}
</div> </div>
<div className="seg"> <div className="seg">
@@ -168,7 +247,7 @@ function CasesTab({ status, onStatus, search, onSearch, from, onFrom, to, onTo,
<button <button
key={v} key={v}
type="button" type="button"
className={status === v ? 'on' : ''} className={status === v ? "on" : ""}
aria-pressed={status === v} aria-pressed={status === v}
onClick={() => onStatus(v)} onClick={() => onStatus(v)}
> >
@@ -178,27 +257,64 @@ function CasesTab({ status, onStatus, search, onSearch, from, onFrom, to, onTo,
</div> </div>
{/* بازه روی تاریخِ باز شدن پرونده است — همان چیزی که در کارت زیر «شروع» می‌آید. */} {/* بازه روی تاریخِ باز شدن پرونده است — همان چیزی که در کارت زیر «شروع» می‌آید. */}
<div style={{ display: 'flex', alignItems: 'center', gap: 8, flexWrap: 'wrap' }}> <div
<span style={{ fontSize: 12.5, color: 'var(--text-3)' }}>شروع</span> style={{
display: "flex",
alignItems: "center",
gap: 8,
flexWrap: "wrap",
}}
>
<span style={{ fontSize: 12.5, color: "var(--text-3)" }}>
شروع
</span>
{/* دو تاریخ و «تا»ی بینشان یک واحدند: اگر جدا بشکنند، «تا» از فیلدش {/* دو تاریخ و «تا»ی بینشان یک واحدند: اگر جدا بشکنند، «تا» از فیلدش
می‌افتد و معلوم نیست کران بالا کدام است. */} می‌افتد و معلوم نیست کران بالا کدام است. */}
<div style={{ <div
display: 'flex', alignItems: 'center', gap: 6, flexWrap: 'nowrap', style={{
flex: '1 1 300px', minWidth: 260, maxWidth: 340, display: "flex",
}}> alignItems: "center",
gap: 6,
flexWrap: "nowrap",
flex: "1 1 300px",
minWidth: 260,
maxWidth: 340,
}}
>
<div style={{ flex: 1, minWidth: 0 }}> <div style={{ flex: 1, minWidth: 0 }}>
<PersianDateInput value={from} onChange={onFrom} ariaLabel="شروع از تاریخ" placeholder="از تاریخ" /> <PersianDateInput
value={from}
onChange={onFrom}
ariaLabel="شروع از تاریخ"
placeholder="از تاریخ"
/>
</div> </div>
<span style={{ fontSize: 12.5, color: 'var(--text-3)', flexShrink: 0 }}>تا</span> <span
style={{
fontSize: 12.5,
color: "var(--text-3)",
flexShrink: 0,
}}
>
تا
</span>
<div style={{ flex: 1, minWidth: 0 }}> <div style={{ flex: 1, minWidth: 0 }}>
<PersianDateInput value={to} onChange={onTo} ariaLabel="شروع تا تاریخ" placeholder="تا تاریخ" /> <PersianDateInput
value={to}
onChange={onTo}
ariaLabel="شروع تا تاریخ"
placeholder="تا تاریخ"
/>
</div> </div>
</div> </div>
{(from !== '' || to !== '') && ( {(from !== "" || to !== "") && (
<button <button
type="button" type="button"
className="btn ghost sm" className="btn ghost sm"
onClick={() => { onFrom(''); onTo(''); }} onClick={() => {
onFrom("");
onTo("");
}}
> >
پاک کردن بازه پاک کردن بازه
</button> </button>
@@ -206,40 +322,77 @@ function CasesTab({ status, onStatus, search, onSearch, from, onFrom, to, onTo,
</div> </div>
</div> </div>
{from !== '' && to !== '' && from > to && ( {from !== "" && to !== "" && from > to && (
<div className="card card-pad" style={{ marginBottom: 12, fontSize: 12.5, color: 'var(--danger)' }}> <div
«از تاریخ» بعد از «تا تاریخ» است، پس هیچ پروندهای در این بازه نمیافتد. className="card card-pad"
style={{
marginBottom: 12,
fontSize: 12.5,
color: "var(--danger)",
}}
>
«از تاریخ» بعد از «تا تاریخ» است، پس هیچ پروندهای در این
بازه نمیافتد.
</div> </div>
)} )}
{isLoading ? ( {isLoading ? (
<div style={{ display: 'grid', gap: 12 }}> <div style={{ display: "grid", gap: 12 }}>
{[0, 1].map((i) => <div key={i} className="card" style={{ height: 96 }} />)} {[0, 1].map((i) => (
<div key={i} className="card" style={{ height: 96 }} />
))}
</div> </div>
) : isError ? ( ) : isError ? (
/* خطای سرور نباید «پرونده‌ای یافت نشد» خوانده شود — آن یعنی جستجو نتیجه نداشت. */ /* خطای سرور نباید «پرونده‌ای یافت نشد» خوانده شود — آن یعنی جستجو نتیجه نداشت. */
<div className="card card-pad" style={{ display: 'grid', gap: 10, justifyItems: 'start' }}> <div
<span style={{ fontSize: 13, color: 'var(--danger)' }}>خواندن پروندهها ناموفق بود.</span> className="card card-pad"
<button type="button" className="btn secondary sm" onClick={() => refetch()}>تلاش دوباره</button> style={{ display: "grid", gap: 10, justifyItems: "start" }}
>
<span style={{ fontSize: 13, color: "var(--danger)" }}>
خواندن پروندهها ناموفق بود.
</span>
<button
type="button"
className="btn secondary sm"
onClick={() => refetch()}
>
تلاش دوباره
</button>
</div> </div>
) : cases.length === 0 ? ( ) : cases.length === 0 ? (
<div className="card card-pad" style={{ fontSize: 13, color: 'var(--text-3)', lineHeight: 1.9 }}> <div
className="card card-pad"
style={{
fontSize: 13,
color: "var(--text-3)",
lineHeight: 1.9,
}}
>
{search {search
? `برای «${search}» پرونده‌ای پیدا نشد.` ? `برای «${search}» پرونده‌ای پیدا نشد.`
: (from !== '' || to !== '') : from !== "" || to !== ""
? 'در این بازهٔ تاریخ پرونده‌ای باز نشده است.' ? "در این بازهٔ تاریخ پرونده‌ای باز نشده است."
: 'پرونده‌ای یافت نشد. پرونده وقتی ساخته می‌شود که نوبتِ سرویسی با «طول درمان» قطعی شود.'} : "پرونده‌ای یافت نشد. پرونده وقتی ساخته می‌شود که نوبتِ سرویسی با «طول درمان» قطعی شود."}
</div> </div>
) : view === 'table' ? ( ) : view === "table" ? (
<CasesTable cases={cases} onEdit={setEditing} /> <CasesTable cases={cases} onEdit={setEditing} />
) : ( ) : (
<div style={{ display: 'grid', gap: 12 }}> <div style={{ display: "grid", gap: 12 }}>
{cases.map((c) => <CaseCard key={c.uuid} item={c} onEdit={() => setEditing(c.uuid)} />)} {cases.map((c) => (
<CaseCard
key={c.uuid}
item={c}
onEdit={() => setEditing(c.uuid)}
/>
))}
</div> </div>
)} )}
{editing !== null && ( {editing !== null && (
<TreatmentCaseEditModal caseUuid={editing} onClose={() => setEditing(null)} /> <TreatmentCaseEditModal
caseUuid={editing}
onClose={() => setEditing(null)}
/>
)} )}
</> </>
); );
@@ -249,31 +402,78 @@ function CasesTab({ status, onStatus, search, onSearch, from, onFrom, to, onTo,
* انجام‌دهنده از جلسات می‌آید (سابقه) و اختصاص‌یافته برنامه است؛ تا وقتی جلسه‌ای * انجام‌دهنده از جلسات می‌آید (سابقه) و اختصاص‌یافته برنامه است؛ تا وقتی جلسه‌ای
* انجام نشده، همان برنامه را نشان می‌دهیم. هر دو نما یک قاعده دارند. * انجام نشده، همان برنامه را نشان می‌دهیم. هر دو نما یک قاعده دارند.
*/ */
function operatorOf(c: TreatmentCaseSummary): { text: string; planned: boolean } | null { function operatorOf(
c: TreatmentCaseSummary,
): { text: string; planned: boolean } | null {
if (c.performed_by.length > 0) { if (c.performed_by.length > 0) {
return { text: c.performed_by.map((s) => s.name).join('، '), planned: false }; return {
text: c.performed_by.map((s) => s.name).join("، "),
planned: false,
};
} }
if (c.assigned_staff.length > 0) { if (c.assigned_staff.length > 0) {
return { text: c.assigned_staff.map((s) => s.name).join('، '), planned: true }; 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 }) { function CasesTable({
cases,
onEdit,
}: {
cases: TreatmentCaseSummary[];
onEdit: (uuid: string) => void;
}) {
return ( return (
<div style={{ <div
background: 'var(--surface)', border: '1px solid var(--border)', style={{
borderRadius: 'var(--r-lg)', overflow: 'auto', background: "var(--surface)",
}}> border: "1px solid var(--border)",
<table style={{ width: '100%', borderCollapse: 'collapse', minWidth: 900 }}> borderRadius: "var(--r-lg)",
overflow: "auto",
}}
>
<table
style={{
width: "100%",
borderCollapse: "collapse",
minWidth: 900,
}}
>
<thead> <thead>
<tr style={{ background: 'var(--surface-2)', color: 'var(--text-2)', fontSize: 13 }}> <tr
style={{
background: "var(--surface-2)",
color: "var(--text-2)",
fontSize: 13,
}}
>
{TABLE_HEADS.map((h) => ( {TABLE_HEADS.map((h) => (
<th key={h} style={{ padding: '12px 14px', textAlign: 'center', fontWeight: 600, whiteSpace: 'nowrap' }}> <th
key={h}
style={{
padding: "12px 14px",
textAlign: "center",
fontWeight: 600,
whiteSpace: "nowrap",
}}
>
{h} {h}
</th> </th>
))} ))}
@@ -284,34 +484,89 @@ function CasesTable({ cases, onEdit }: { cases: TreatmentCaseSummary[]; onEdit:
const operator = operatorOf(c); const operator = operatorOf(c);
return ( return (
<tr key={c.uuid} style={{ borderTop: '1px solid var(--border)', fontSize: 13.5, textAlign: 'center' }}> <tr
<td style={{ padding: '12px 14px', color: 'var(--text-3)' }}>{formatNumber(i + 1)}</td> key={c.uuid}
<td style={{ padding: '12px 14px' }}> style={{
<div style={{ fontWeight: 600 }}>{c.patient.name || 'بیمار بدون نام'}</div> borderTop: "1px solid var(--border)",
<div style={{ fontSize: 12, color: 'var(--text-3)', direction: 'ltr' }}>{c.patient.mobile}</div> fontSize: 13.5,
textAlign: "center",
}}
>
<td
style={{
padding: "12px 14px",
color: "var(--text-3)",
}}
>
{formatNumber(i + 1)}
</td> </td>
<td style={{ padding: '12px 14px' }}>{c.service.name}</td> <td style={{ padding: "12px 14px" }}>
<td style={{ padding: '12px 14px', color: operator?.planned ? 'var(--text-3)' : 'var(--text)' }}> <div style={{ fontWeight: 600 }}>
{operator === null ? '—' : operator.planned ? `${operator.text} (هنوز انجام نشده)` : operator.text} {c.patient.name || "بیمار بدون نام"}
</div>
<div
style={{
fontSize: 12,
color: "var(--text-3)",
direction: "ltr",
}}
>
{c.patient.mobile}
</div>
</td> </td>
<td style={{ padding: '12px 14px' }}> <td style={{ padding: "12px 14px" }}>
<span className={`badge ${c.status === 'active' ? 'blue' : c.status === 'completed' ? 'green' : 'gray'}`}> {c.service.name}
<span className="bdot" />{CASE_STATUS_LABEL[c.status]} </td>
<td
style={{
padding: "12px 14px",
color: operator?.planned
? "var(--text-3)"
: "var(--text)",
}}
>
{operator === null
? "—"
: operator.planned
? `${operator.text} (هنوز انجام نشده)`
: operator.text}
</td>
<td style={{ padding: "12px 14px" }}>
<span
className={`badge ${c.status === "active" ? "blue" : c.status === "completed" ? "green" : "gray"}`}
>
<span className="bdot" />
{CASE_STATUS_LABEL[c.status]}
</span> </span>
</td> </td>
<td style={{ padding: '12px 14px', whiteSpace: 'nowrap' }}> <td
{formatNumber(c.completed_sessions)} از {formatNumber(c.total_sessions)} style={{
padding: "12px 14px",
whiteSpace: "nowrap",
}}
>
{formatNumber(c.completed_sessions)} از{" "}
{formatNumber(c.total_sessions)}
</td> </td>
<td style={{ padding: '12px 14px', whiteSpace: 'nowrap' }}>{formatDateTime(c.opened_at)}</td> <td
<td style={{ padding: '12px 14px' }}> style={{
padding: "12px 14px",
whiteSpace: "nowrap",
}}
>
{formatDateTime(c.opened_at)}
</td>
<td style={{ padding: "12px 14px" }}>
<button <button
type="button" type="button"
className="mini-btn" className="mini-btn"
aria-label={`ویرایش پروندهٔ ${c.patient.name ?? ''}`} aria-label={`ویرایش پروندهٔ ${c.patient.name ?? ""}`}
onClick={() => onEdit(c.uuid)} onClick={() => onEdit(c.uuid)}
style={{ color: 'var(--accent)' }} style={{ color: "var(--accent)" }}
> >
<PencilSquareIcon style={{ width: 18, height: 18 }} /> <PencilSquareIcon
style={{ width: 18, height: 18 }}
/>
</button> </button>
</td> </td>
</tr> </tr>
@@ -323,41 +578,79 @@ function CasesTable({ cases, onEdit }: { cases: TreatmentCaseSummary[]; onEdit:
); );
} }
function CaseCard({ item: c, onEdit }: { item: TreatmentCaseSummary; onEdit: () => void }) { function CaseCard({
const percent = c.total_sessions > 0 item: c,
onEdit,
}: {
item: TreatmentCaseSummary;
onEdit: () => void;
}) {
const percent =
c.total_sessions > 0
? Math.round((c.completed_sessions / c.total_sessions) * 100) ? Math.round((c.completed_sessions / c.total_sessions) * 100)
: 0; : 0;
const operator = operatorOf(c); const operator = operatorOf(c);
return ( return (
<div className="card card-pad" style={{ display: 'grid', gap: 10 }}> <div className="card card-pad" style={{ display: "grid", gap: 10 }}>
<div style={{ display: 'flex', alignItems: 'center', gap: 10, flexWrap: 'wrap' }}> <div
style={{
display: "flex",
alignItems: "center",
gap: 10,
flexWrap: "wrap",
}}
>
{/* بیمار سرتیتر است نه سرویس: دو پروندهٔ یک سرویس فقط با نام بیمار از هم جدا می‌شوند. */} {/* بیمار سرتیتر است نه سرویس: دو پروندهٔ یک سرویس فقط با نام بیمار از هم جدا می‌شوند. */}
<strong style={{ fontSize: 14 }}>{c.patient.name || 'بیمار بدون نام'}</strong> <strong style={{ fontSize: 14 }}>
<span className={`badge ${c.status === 'active' ? 'blue' : c.status === 'completed' ? 'green' : 'gray'}`}> {c.patient.name || "بیمار بدون نام"}
<span className="bdot" />{CASE_STATUS_LABEL[c.status]} </strong>
<span
className={`badge ${c.status === "active" ? "blue" : c.status === "completed" ? "green" : "gray"}`}
>
<span className="bdot" />
{CASE_STATUS_LABEL[c.status]}
</span> </span>
<span style={{ fontSize: 12.5, color: 'var(--text-3)' }}> <span style={{ fontSize: 12.5, color: "var(--text-3)" }}>
{formatNumber(c.completed_sessions)} از {formatNumber(c.total_sessions)} جلسه {formatNumber(c.completed_sessions)} از{" "}
{formatNumber(c.total_sessions)} جلسه
</span> </span>
<button type="button" className="btn secondary sm" style={{ marginInlineStart: 'auto' }} onClick={onEdit}> <button
type="button"
className="btn secondary sm"
style={{ marginInlineStart: "auto" }}
onClick={onEdit}
>
<PencilSquareIcon style={{ width: 15, height: 15 }} /> <PencilSquareIcon style={{ width: 15, height: 15 }} />
ویرایش ویرایش
</button> </button>
</div> </div>
<div style={{ display: 'flex', gap: 16, flexWrap: 'wrap', fontSize: 12.5, color: 'var(--text-2)' }}> <div
style={{
display: "flex",
gap: 16,
flexWrap: "wrap",
fontSize: 12.5,
color: "var(--text-2)",
}}
>
<span>{c.service.name}</span> <span>{c.service.name}</span>
<span style={{ direction: 'ltr' }}>{c.patient.mobile}</span> <span style={{ direction: "ltr" }}>{c.patient.mobile}</span>
{/* ساعت هم لازم است: چند پروندهٔ یک روز فقط با ساعت از هم جدا می‌شوند. */} {/* ساعت هم لازم است: چند پروندهٔ یک روز فقط با ساعت از هم جدا می‌شوند. */}
<span>شروع: {formatDateTime(c.opened_at)}</span> <span>شروع: {formatDateTime(c.opened_at)}</span>
{c.supervisor && <span>پزشک ناظر: {c.supervisor.name}</span>} {c.supervisor && <span>پزشک ناظر: {c.supervisor.name}</span>}
{operator !== null && ( {operator !== null &&
operator.planned (operator.planned ? (
? <span style={{ color: 'var(--text-3)' }}>پرسنل: {operator.text} (هنوز انجام نشده)</span> <span style={{ color: "var(--text-3)" }}>
: <span>انجامدهنده: {operator.text}</span> پرسنل: {operator.text} (هنوز انجام نشده)
</span>
) : (
<span>انجامدهنده: {operator.text}</span>
))}
{c.areas.length > 0 && (
<span>نواحی: {c.areas.map((a) => a.name).join("، ")}</span>
)} )}
{c.areas.length > 0 && <span>نواحی: {c.areas.map((a) => a.name).join('، ')}</span>}
</div> </div>
{/* `<progress>` نیتیو ظاهر مرورگر را می‌گیرد و با توکن‌های تم نمی‌خواند. */} {/* `<progress>` نیتیو ظاهر مرورگر را می‌گیرد و با توکن‌های تم نمی‌خواند. */}
@@ -367,13 +660,25 @@ function CaseCard({ item: c, onEdit }: { item: TreatmentCaseSummary; onEdit: ()
aria-valuemin={0} aria-valuemin={0}
aria-valuemax={c.total_sessions} aria-valuemax={c.total_sessions}
aria-label={`پیشرفت دوره: ${c.completed_sessions} از ${c.total_sessions}`} aria-label={`پیشرفت دوره: ${c.completed_sessions} از ${c.total_sessions}`}
style={{ height: 6, borderRadius: 999, background: 'var(--surface-3)', overflow: 'hidden' }} style={{
height: 6,
borderRadius: 999,
background: "var(--surface-3)",
overflow: "hidden",
}}
> >
<div style={{ <div
width: `${percent}%`, height: '100%', borderRadius: 999, style={{
background: c.status === 'completed' ? 'var(--success)' : 'var(--primary)', width: `${percent}%`,
transition: 'width .3s var(--ease)', height: "100%",
}} /> borderRadius: 999,
background:
c.status === "completed"
? "var(--success)"
: "var(--primary)",
transition: "width .3s var(--ease)",
}}
/>
</div> </div>
</div> </div>
); );
@@ -381,8 +686,11 @@ function CaseCard({ item: c, onEdit }: { item: TreatmentCaseSummary; onEdit: ()
function UnbookedTab() { function UnbookedTab() {
const { data, isLoading } = useQuery({ const { data, isLoading } = useQuery({
queryKey: ['treatment-sessions-unbooked'], queryKey: ["treatment-sessions-unbooked"],
queryFn: () => api.get<ApiResponse<StaffTreatmentSession[]>>('/api/v1/treatment-sessions/unbooked?within_days=14'), queryFn: () =>
api.get<ApiResponse<StaffTreatmentSession[]>>(
"/api/v1/treatment-sessions/unbooked?within_days=14",
),
staleTime: 30_000, staleTime: 30_000,
}); });
@@ -390,31 +698,73 @@ function UnbookedTab() {
return ( return (
<> <>
<p style={{ margin: '0 0 12px', fontSize: 12.5, color: 'var(--text-3)', lineHeight: 1.9 }}> <p
جلساتی که سررسیدشان رسیده و هنوز نوبت نگرفتهاند. رزرو عمداً خودکار نیست وقتِ مناسب را style={{
باید با خود بیمار هماهنگ کرد. margin: "0 0 12px",
fontSize: 12.5,
color: "var(--text-3)",
lineHeight: 1.9,
}}
>
جلساتی که سررسیدشان رسیده و هنوز نوبت نگرفتهاند. رزرو عمداً
خودکار نیست وقتِ مناسب را باید با خود بیمار هماهنگ کرد.
</p> </p>
{isLoading ? ( {isLoading ? (
<div className="card card-pad" style={{ fontSize: 13, color: 'var(--text-3)' }}>در حال بارگذاری...</div> <div
className="card card-pad"
style={{ fontSize: 13, color: "var(--text-3)" }}
>
در حال بارگذاری...
</div>
) : sessions.length === 0 ? ( ) : sessions.length === 0 ? (
<div className="card card-pad" style={{ fontSize: 13, color: 'var(--text-3)' }}> <div
className="card card-pad"
style={{ fontSize: 13, color: "var(--text-3)" }}
>
جلسهای در انتظار رزرو نیست. جلسهای در انتظار رزرو نیست.
</div> </div>
) : ( ) : (
<div style={{ display: 'grid', gap: 12 }}> <div style={{ display: "grid", gap: 12 }}>
{sessions.map((s) => ( {sessions.map((s) => (
<div key={s.uuid} className="card card-pad" style={{ display: 'grid', gap: 8 }}> <div
<div style={{ display: 'flex', alignItems: 'center', gap: 10, flexWrap: 'wrap' }}> key={s.uuid}
<strong style={{ fontSize: 14 }}>{s.service_name}</strong> className="card card-pad"
<StatusBadge type="treatment-session" value={s.status} /> style={{ display: "grid", gap: 8 }}
<span style={{ fontSize: 12.5, color: 'var(--text-3)' }}> >
جلسهٔ {s.session_number} از {s.total_sessions} <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> </span>
</div> </div>
{s.due_at !== null && ( {s.due_at !== null && (
<span style={{ fontSize: 12.5, color: 'var(--warning)' }}> <span
style={{
fontSize: 12.5,
color: "var(--warning)",
}}
>
سررسید: {formatDate(s.due_at)} سررسید: {formatDate(s.due_at)}
</span> </span>
)} )}
@@ -438,8 +788,9 @@ function SlotSuggestions({ sessionUuid }: { sessionUuid: string }) {
const [open, setOpen] = useState(false); const [open, setOpen] = useState(false);
const { data, isLoading, isError } = useQuery({ const { data, isLoading, isError } = useQuery({
queryKey: ['session-slot-suggestions', sessionUuid], queryKey: ["session-slot-suggestions", sessionUuid],
queryFn: () => api.get<ApiResponse<SlotSuggestionResponse>>( queryFn: () =>
api.get<ApiResponse<SlotSuggestionResponse>>(
`/api/v1/treatment-session/${sessionUuid}/slot-suggestions?days=14`, `/api/v1/treatment-session/${sessionUuid}/slot-suggestions?days=14`,
), ),
enabled: open, enabled: open,
@@ -448,13 +799,20 @@ function SlotSuggestions({ sessionUuid }: { sessionUuid: string }) {
if (!open) { if (!open) {
return ( return (
<div style={{ display: 'flex', gap: 8, flexWrap: 'wrap' }}> <div style={{ display: "flex", gap: 8, flexWrap: "wrap" }}>
<button type="button" className="btn secondary sm" onClick={() => setOpen(true)}> <button
type="button"
className="btn secondary sm"
onClick={() => setOpen(true)}
>
پیشنهاد وقت پیشنهاد وقت
</button> </button>
{/* لینک باید جلسه را ببرد، وگرنه منشی بیمار و سرویس را دستی می‌زند و اتصال {/* لینک باید جلسه را ببرد، وگرنه منشی بیمار و سرویس را دستی می‌زند و اتصال
به حدسِ سرویس سپرده می‌شود. */} به حدسِ سرویس سپرده می‌شود. */}
<Link to={`/admin/appointments/new?session=${sessionUuid}`} className="btn primary sm"> <Link
to={`/admin/appointments/new?session=${sessionUuid}`}
className="btn primary sm"
>
ثبت نوبت این جلسه ثبت نوبت این جلسه
</Link> </Link>
</div> </div>
@@ -464,25 +822,46 @@ function SlotSuggestions({ sessionUuid }: { sessionUuid: string }) {
const days = data?.data?.days ?? []; const days = data?.data?.days ?? [];
return ( return (
<div style={{ display: 'grid', gap: 8 }}> <div style={{ display: "grid", gap: 8 }}>
{isLoading && <span style={{ fontSize: 12.5, color: 'var(--text-3)' }}>در حال جستوجوی وقت...</span>} {isLoading && (
<span style={{ fontSize: 12.5, color: "var(--text-3)" }}>
در حال جستوجوی وقت...
</span>
)}
{isError && ( {isError && (
<span style={{ fontSize: 12.5, color: 'var(--text-3)' }}> <span style={{ fontSize: 12.5, color: "var(--text-3)" }}>
دستگاهی برای پیشنهاد وقت مشخص نیست این جلسه هنوز روی هیچ دستگاهی انجام نشده. دستگاهی برای پیشنهاد وقت مشخص نیست این جلسه هنوز روی هیچ
دستگاهی انجام نشده.
</span> </span>
)} )}
{!isLoading && !isError && days.length === 0 && ( {!isLoading && !isError && days.length === 0 && (
<span style={{ fontSize: 12.5, color: 'var(--text-3)' }}> <span style={{ fontSize: 12.5, color: "var(--text-3)" }}>
در دو هفتهٔ آینده وقت آزادی روی این دستگاه نیست. در دو هفتهٔ آینده وقت آزادی روی این دستگاه نیست.
</span> </span>
)} )}
{days.slice(0, 3).map((day) => ( {days.slice(0, 3).map((day) => (
<div key={day.date} style={{ display: 'flex', gap: 6, flexWrap: 'wrap', alignItems: 'center' }}> <div
<span style={{ fontSize: 12.5, minWidth: 96, color: 'var(--text-2)' }}> key={day.date}
{formatDate(Math.floor(new Date(day.date).getTime() / 1000))} style={{
display: "flex",
gap: 6,
flexWrap: "wrap",
alignItems: "center",
}}
>
<span
style={{
fontSize: 12.5,
minWidth: 96,
color: "var(--text-2)",
}}
>
{formatDate(
Math.floor(new Date(day.date).getTime() / 1000),
)}
</span> </span>
{day.slots.slice(0, 6).map((slot) => ( {day.slots.slice(0, 6).map((slot) => (
<Link <Link
@@ -497,7 +876,12 @@ function SlotSuggestions({ sessionUuid }: { sessionUuid: string }) {
</div> </div>
))} ))}
<button type="button" className="btn ghost sm" onClick={() => setOpen(false)} style={{ justifySelf: 'start' }}> <button
type="button"
className="btn ghost sm"
onClick={() => setOpen(false)}
style={{ justifySelf: "start" }}
>
بستن بستن
</button> </button>
</div> </div>