- Integrated TourButton component into SettingsMenuPage, SkillsPage, SmsWalletPage, StaffPage, StaffSessionDetailPage, StaffTreatmentSessionsPage, SubscriptionPage, TagsSettingsPage, TreatmentCasesPage to enhance user onboarding experience. - Created new tour definitions for appointments, clinics, staff management, financial management, and patient management, ensuring comprehensive guidance for users navigating the admin panel. - Updated documentation to reflect the addition of tours and their implementation details.
890 lines
34 KiB
TypeScript
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";
|
|
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="دورههای درمان" tourId="treatment-cases" />
|
|
|
|
<div className="tabs" data-tour="treatment-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>
|
|
);
|
|
}
|