Files
clinicpro/assets/admin/pages/TreatmentCasesPage.tsx
T

890 lines
34 KiB
TypeScript

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: "جلسات بدون نوبت" },
] as const;
type TabId = (typeof TABS)[number]["id"];
const CASE_STATUS_LABEL: Record<TreatmentCaseSummary["status"], string> = {
active: "در جریان",
completed: "تمام شده",
abandoned: "رها شده",
};
/**
* پرونده‌های درمان و کارِ باقی‌ماندهٔ منشی.
*
* صفِ «جلسات بدون نوبت» عمداً کنار فهرست پرونده‌هاست نه صفحهٔ جدا: هر دو یک سؤال
* را جواب می‌دهند — «کدام بیمار در چه مرحله‌ای است و چه کاری مانده».
*/
export default function TreatmentCasesPage() {
const [urlState, setUrlState] = useUrlState({
tab: "cases",
status: "",
q: "",
from: "",
to: "",
view: "table",
});
const tab = (
TABS.some((t) => t.id === urlState.tab) ? urlState.tab : "cases"
) as TabId;
return (
<>
<PageHeader title="دوره‌های درمان" />
<div className="tabs" style={{ marginBottom: 16 }}>
{TABS.map((t) => (
<button
key={t.id}
type="button"
className={tab === t.id ? "active" : ""}
onClick={() => setUrlState({ tab: t.id })}
>
{t.label}
</button>
))}
</div>
{tab === "cases" ? (
<CasesTab
status={urlState.status}
onStatus={(s) => setUrlState({ status: s })}
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 })}
/>
) : (
<UnbookedTab />
)}
</>
);
}
const STATUS_FILTERS = [
["", "همه"],
["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;
}) {
// فیلد جستجو محلی می‌ماند و فقط مقدار نهایی به 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<string | null>(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();
return api.get<ApiResponse<TreatmentCaseSummary[]>>(
`/api/v1/treatment-cases${suffix ? `?${suffix}` : ""}`,
);
},
staleTime: 30_000,
});
const cases = data?.data ?? [];
return (
<>
<div
style={{
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
value={term}
onChange={(e) => setTerm(e.target.value)}
placeholder="نام بیمار، پرسنل، موبایل، کد ملی، شمارهٔ پرونده یا سرویس"
aria-label="جستجوی پرونده"
/>
{term !== "" && (
<button
type="button"
className="mini-btn"
aria-label="پاک کردن جستجو"
onClick={() => setTerm("")}
>
<XMarkIcon style={{ width: 15, height: 15 }} />
</button>
)}
</div>
<div
style={{
display: "flex",
flexShrink: 0,
overflow: "hidden",
border: "1px solid var(--border-2)",
borderRadius: 4,
}}
>
{(
[
["table", "نمایش جدولی", PatientsGridView],
["card", "نمایش کارتی", PatientsCategoryView],
] as const
).map(([v, label, Icon]) => (
<button
key={v}
type="button"
aria-label={label}
aria-pressed={view === v}
onClick={() => onView(v)}
style={{
padding: 8,
border: "none",
cursor: "pointer",
display: "grid",
placeItems: "center",
background:
view === v
? "var(--primary-soft)"
: "transparent",
}}
>
<Icon
color={
view === v
? "var(--primary)"
: "var(--text-2)"
}
/>
</button>
))}
</div>
<div className="seg">
{STATUS_FILTERS.map(([v, label]) => (
<button
key={v}
type="button"
className={status === v ? "on" : ""}
aria-pressed={status === v}
onClick={() => onStatus(v)}
>
{label}
</button>
))}
</div>
{/* بازه روی تاریخِ باز شدن پرونده است — همان چیزی که در کارت زیر «شروع» می‌آید. */}
<div
style={{
display: "flex",
alignItems: "center",
gap: 8,
flexWrap: "wrap",
}}
>
<span style={{ fontSize: 12.5, color: "var(--text-3)" }}>
شروع
</span>
{/* دو تاریخ و «تا»ی بینشان یک واحدند: اگر جدا بشکنند، «تا» از فیلدش
می‌افتد و معلوم نیست کران بالا کدام است. */}
<div
style={{
display: "flex",
alignItems: "center",
gap: 6,
flexWrap: "nowrap",
flex: "1 1 300px",
minWidth: 260,
maxWidth: 340,
}}
>
<div style={{ flex: 1, minWidth: 0 }}>
<PersianDateInput
value={from}
onChange={onFrom}
ariaLabel="شروع از تاریخ"
placeholder="از تاریخ"
/>
</div>
<span
style={{
fontSize: 12.5,
color: "var(--text-3)",
flexShrink: 0,
}}
>
تا
</span>
<div style={{ flex: 1, minWidth: 0 }}>
<PersianDateInput
value={to}
onChange={onTo}
ariaLabel="شروع تا تاریخ"
placeholder="تا تاریخ"
/>
</div>
</div>
{(from !== "" || to !== "") && (
<button
type="button"
className="btn ghost sm"
onClick={() => {
onFrom("");
onTo("");
}}
>
پاک کردن بازه
</button>
)}
</div>
</div>
{from !== "" && to !== "" && from > to && (
<div
className="card card-pad"
style={{
marginBottom: 12,
fontSize: 12.5,
color: "var(--danger)",
}}
>
«از تاریخ» بعد از «تا تاریخ» است، پس هیچ پرونده‌ای در این
بازه نمی‌افتد.
</div>
)}
{isLoading ? (
<div style={{ display: "grid", gap: 12 }}>
{[0, 1].map((i) => (
<div key={i} className="card" style={{ height: 96 }} />
))}
</div>
) : isError ? (
/* خطای سرور نباید «پرونده‌ای یافت نشد» خوانده شود — آن یعنی جستجو نتیجه نداشت. */
<div
className="card card-pad"
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>
) : cases.length === 0 ? (
<div
className="card card-pad"
style={{
fontSize: 13,
color: "var(--text-3)",
lineHeight: 1.9,
}}
>
{search
? `برای «${search}» پرونده‌ای پیدا نشد.`
: from !== "" || to !== ""
? "در این بازهٔ تاریخ پرونده‌ای باز نشده است."
: "پرونده‌ای یافت نشد. پرونده وقتی ساخته می‌شود که نوبتِ سرویسی با «طول درمان» قطعی شود."}
</div>
) : view === "table" ? (
<CasesTable cases={cases} onEdit={setEditing} />
) : (
<div style={{ display: "grid", gap: 12 }}>
{cases.map((c) => (
<CaseCard
key={c.uuid}
item={c}
onEdit={() => setEditing(c.uuid)}
/>
))}
</div>
)}
{editing !== null && (
<TreatmentCaseEditModal
caseUuid={editing}
onClose={() => 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,
};
}
if (c.assigned_staff.length > 0) {
return {
text: c.assigned_staff.map((s) => s.name).join("، "),
planned: true,
};
}
return null;
}
const TABLE_HEADS = [
"ردیف",
"بیمار",
"سرویس",
"پرسنل",
"وضعیت",
"جلسات",
"شروع",
"عملیات",
];
function CasesTable({
cases,
onEdit,
}: {
cases: TreatmentCaseSummary[];
onEdit: (uuid: string) => void;
}) {
return (
<div
style={{
background: "var(--surface)",
border: "1px solid var(--border)",
borderRadius: "var(--r-lg)",
overflow: "auto",
}}
>
<table
style={{
width: "100%",
borderCollapse: "collapse",
minWidth: 900,
}}
>
<thead>
<tr
style={{
background: "var(--surface-2)",
color: "var(--text-2)",
fontSize: 13,
}}
>
{TABLE_HEADS.map((h) => (
<th
key={h}
style={{
padding: "12px 14px",
textAlign: "center",
fontWeight: 600,
whiteSpace: "nowrap",
}}
>
{h}
</th>
))}
</tr>
</thead>
<tbody>
{cases.map((c, i) => {
const operator = operatorOf(c);
return (
<tr
key={c.uuid}
style={{
borderTop: "1px solid var(--border)",
fontSize: 13.5,
textAlign: "center",
}}
>
<td
style={{
padding: "12px 14px",
color: "var(--text-3)",
}}
>
{formatNumber(i + 1)}
</td>
<td style={{ padding: "12px 14px" }}>
<div style={{ fontWeight: 600 }}>
{c.patient.name || "بیمار بدون نام"}
</div>
<div
style={{
fontSize: 12,
color: "var(--text-3)",
direction: "ltr",
}}
>
{c.patient.mobile}
</div>
</td>
<td style={{ padding: "12px 14px" }}>
{c.service.name}
</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>
</td>
<td
style={{
padding: "12px 14px",
whiteSpace: "nowrap",
}}
>
{formatNumber(c.completed_sessions)} از{" "}
{formatNumber(c.total_sessions)}
</td>
<td
style={{
padding: "12px 14px",
whiteSpace: "nowrap",
}}
>
{formatDateTime(c.opened_at)}
</td>
<td style={{ padding: "12px 14px" }}>
<button
type="button"
className="mini-btn"
aria-label={`ویرایش پروندهٔ ${c.patient.name ?? ""}`}
onClick={() => onEdit(c.uuid)}
style={{ color: "var(--accent)" }}
>
<PencilSquareIcon
style={{ width: 18, height: 18 }}
/>
</button>
</td>
</tr>
);
})}
</tbody>
</table>
</div>
);
}
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 (
<div className="card card-pad" style={{ display: "grid", gap: 10 }}>
<div
style={{
display: "flex",
alignItems: "center",
gap: 10,
flexWrap: "wrap",
}}
>
{/* بیمار سرتیتر است نه سرویس: دو پروندهٔ یک سرویس فقط با نام بیمار از هم جدا می‌شوند. */}
<strong style={{ fontSize: 14 }}>
{c.patient.name || "بیمار بدون نام"}
</strong>
<span
className={`badge ${c.status === "active" ? "blue" : c.status === "completed" ? "green" : "gray"}`}
>
<span className="bdot" />
{CASE_STATUS_LABEL[c.status]}
</span>
<span style={{ fontSize: 12.5, color: "var(--text-3)" }}>
{formatNumber(c.completed_sessions)} از{" "}
{formatNumber(c.total_sessions)} جلسه
</span>
<button
type="button"
className="btn secondary sm"
style={{ marginInlineStart: "auto" }}
onClick={onEdit}
>
<PencilSquareIcon style={{ width: 15, height: 15 }} />
ویرایش
</button>
</div>
<div
style={{
display: "flex",
gap: 16,
flexWrap: "wrap",
fontSize: 12.5,
color: "var(--text-2)",
}}
>
<span>{c.service.name}</span>
<span style={{ direction: "ltr" }}>{c.patient.mobile}</span>
{/* ساعت هم لازم است: چند پروندهٔ یک روز فقط با ساعت از هم جدا می‌شوند. */}
<span>شروع: {formatDateTime(c.opened_at)}</span>
{c.supervisor && <span>پزشک ناظر: {c.supervisor.name}</span>}
{operator !== null &&
(operator.planned ? (
<span style={{ color: "var(--text-3)" }}>
پرسنل: {operator.text} (هنوز انجام نشده)
</span>
) : (
<span>انجام‌دهنده: {operator.text}</span>
))}
{c.areas.length > 0 && (
<span>نواحی: {c.areas.map((a) => a.name).join("، ")}</span>
)}
</div>
{/* `<progress>` نیتیو ظاهر مرورگر را می‌گیرد و با توکن‌های تم نمی‌خواند. */}
<div
role="progressbar"
aria-valuenow={c.completed_sessions}
aria-valuemin={0}
aria-valuemax={c.total_sessions}
aria-label={`پیشرفت دوره: ${c.completed_sessions} از ${c.total_sessions}`}
style={{
height: 6,
borderRadius: 999,
background: "var(--surface-3)",
overflow: "hidden",
}}
>
<div
style={{
width: `${percent}%`,
height: "100%",
borderRadius: 999,
background:
c.status === "completed"
? "var(--success)"
: "var(--primary)",
transition: "width .3s var(--ease)",
}}
/>
</div>
</div>
);
}
function UnbookedTab() {
const { data, isLoading } = useQuery({
queryKey: ["treatment-sessions-unbooked"],
queryFn: () =>
api.get<ApiResponse<StaffTreatmentSession[]>>(
"/api/v1/treatment-sessions/unbooked?within_days=14",
),
staleTime: 30_000,
});
const sessions = data?.data ?? [];
return (
<>
<p
style={{
margin: "0 0 12px",
fontSize: 12.5,
color: "var(--text-3)",
lineHeight: 1.9,
}}
>
جلساتی که سررسیدشان رسیده و هنوز نوبت نگرفته‌اند. رزرو عمداً
خودکار نیست وقتِ مناسب را باید با خود بیمار هماهنگ کرد.
</p>
{isLoading ? (
<div
className="card card-pad"
style={{ fontSize: 13, color: "var(--text-3)" }}
>
در حال بارگذاری...
</div>
) : sessions.length === 0 ? (
<div
className="card card-pad"
style={{ fontSize: 13, color: "var(--text-3)" }}
>
جلسه‌ای در انتظار رزرو نیست.
</div>
) : (
<div style={{ display: "grid", gap: 12 }}>
{sessions.map((s) => (
<div
key={s.uuid}
className="card card-pad"
style={{ display: "grid", gap: 8 }}
>
<div
style={{
display: "flex",
alignItems: "center",
gap: 10,
flexWrap: "wrap",
}}
>
<strong style={{ fontSize: 14 }}>
{s.service_name}
</strong>
<StatusBadge
type="treatment-session"
value={s.status}
/>
<span
style={{
fontSize: 12.5,
color: "var(--text-3)",
}}
>
جلسهٔ {s.session_number} از{" "}
{s.total_sessions}
</span>
</div>
{s.due_at !== null && (
<span
style={{
fontSize: 12.5,
color: "var(--warning)",
}}
>
سررسید: {formatDate(s.due_at)}
</span>
)}
<SlotSuggestions sessionUuid={s.uuid} />
</div>
))}
</div>
)}
</>
);
}
/**
* وقت‌های آزادِ همان دستگاهی که جلسهٔ قبلی رویش انجام شد.
*
* پیشنهاد است نه رزرو: منشی با بیمار هماهنگ می‌کند و بعد از فرم عادی نوبت ثبتش
* می‌کند. خودکار رزرو کردن یعنی سیستم به‌جای بیمار تصمیم بگیرد و بعد او نیاید.
*/
function SlotSuggestions({ sessionUuid }: { sessionUuid: string }) {
const [open, setOpen] = useState(false);
const { data, isLoading, isError } = useQuery({
queryKey: ["session-slot-suggestions", sessionUuid],
queryFn: () =>
api.get<ApiResponse<SlotSuggestionResponse>>(
`/api/v1/treatment-session/${sessionUuid}/slot-suggestions?days=14`,
),
enabled: open,
staleTime: 60_000,
});
if (!open) {
return (
<div style={{ display: "flex", gap: 8, flexWrap: "wrap" }}>
<button
type="button"
className="btn secondary sm"
onClick={() => setOpen(true)}
>
پیشنهاد وقت
</button>
{/* لینک باید جلسه را ببرد، وگرنه منشی بیمار و سرویس را دستی می‌زند و اتصال
به حدسِ سرویس سپرده می‌شود. */}
<Link
to={`/admin/appointments/new?session=${sessionUuid}`}
className="btn primary sm"
>
ثبت نوبت این جلسه
</Link>
</div>
);
}
const days = data?.data?.days ?? [];
return (
<div style={{ display: "grid", gap: 8 }}>
{isLoading && (
<span style={{ fontSize: 12.5, color: "var(--text-3)" }}>
در حال جست‌وجوی وقت...
</span>
)}
{isError && (
<span style={{ fontSize: 12.5, color: "var(--text-3)" }}>
دستگاهی برای پیشنهاد وقت مشخص نیست این جلسه هنوز روی هیچ
دستگاهی انجام نشده.
</span>
)}
{!isLoading && !isError && days.length === 0 && (
<span style={{ fontSize: 12.5, color: "var(--text-3)" }}>
در دو هفتهٔ آینده وقت آزادی روی این دستگاه نیست.
</span>
)}
{days.slice(0, 3).map((day) => (
<div
key={day.date}
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>
{day.slots.slice(0, 6).map((slot) => (
<Link
key={slot.start}
to={`/admin/appointments/new?session=${sessionUuid}&date=${day.date}`}
className="btn secondary sm"
title={formatDateTime(slot.start)}
>
{slot.start_time}
</Link>
))}
</div>
))}
<button
type="button"
className="btn ghost sm"
onClick={() => setOpen(false)}
style={{ justifySelf: "start" }}
>
بستن
</button>
</div>
);
}