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 = { 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 ( <>
{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 })} /> ) : ( )} ); } 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(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>( `/api/v1/treatment-cases${suffix ? `?${suffix}` : ""}`, ); }, staleTime: 30_000, }); 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]) => ( ))}
{/* بازه روی تاریخِ باز شدن پرونده است — همان چیزی که در کارت زیر «شروع» می‌آید. */}
شروع {/* دو تاریخ و «تا»ی بینشان یک واحدند: اگر جدا بشکنند، «تا» از فیلدش می‌افتد و معلوم نیست کران بالا کدام است. */}
تا
{(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)} /> ))}
)} {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, }; } 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 (
{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)}
); } 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)} جلسه
{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 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)} )}
))}
)} ); } /** * وقت‌های آزادِ همان دستگاهی که جلسهٔ قبلی رویش انجام شد. * * پیشنهاد است نه رزرو: منشی با بیمار هماهنگ می‌کند و بعد از فرم عادی نوبت ثبتش * می‌کند. خودکار رزرو کردن یعنی سیستم به‌جای بیمار تصمیم بگیرد و بعد او نیاید. */ function SlotSuggestions({ sessionUuid }: { sessionUuid: string }) { 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, }); if (!open) { return (
{/* لینک باید جلسه را ببرد، وگرنه منشی بیمار و سرویس را دستی می‌زند و اتصال به حدسِ سرویس سپرده می‌شود. */} ثبت نوبت این جلسه
); } const days = data?.data?.days ?? []; return (
{isLoading && ( در حال جست‌وجوی وقت... )} {isError && ( دستگاهی برای پیشنهاد وقت مشخص نیست — این جلسه هنوز روی هیچ دستگاهی انجام نشده. )} {!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) => ( {slot.start_time} ))}
))}
); }