Refactor code structure for improved readability and maintainability
This commit is contained in:
@@ -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,
|
||||
|
||||
@@ -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<TreatmentCaseSummary['status'], string> = {
|
||||
active: 'در جریان',
|
||||
completed: 'تمام شده',
|
||||
abandoned: 'رها شده',
|
||||
const CASE_STATUS_LABEL: Record<TreatmentCaseSummary["status"], string> = {
|
||||
active: "در جریان",
|
||||
completed: "تمام شده",
|
||||
abandoned: "رها شده",
|
||||
};
|
||||
|
||||
/**
|
||||
@@ -33,19 +44,28 @@ const CASE_STATUS_LABEL: Record<TreatmentCaseSummary['status'], string> = {
|
||||
* را جواب میدهند — «کدام بیمار در چه مرحلهای است و چه کاری مانده».
|
||||
*/
|
||||
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 (
|
||||
<>
|
||||
<PageHeader title="درمانهای چندجلسهای" />
|
||||
<PageHeader title="دورههای درمان" />
|
||||
|
||||
<div className="tabs" style={{ marginBottom: 16 }}>
|
||||
{TABS.map((t) => (
|
||||
<button
|
||||
key={t.id}
|
||||
type="button"
|
||||
className={tab === t.id ? 'active' : ''}
|
||||
className={tab === t.id ? "active" : ""}
|
||||
onClick={() => setUrlState({ tab: t.id })}
|
||||
>
|
||||
{t.label}
|
||||
@@ -53,8 +73,8 @@ export default function TreatmentCasesPage() {
|
||||
))}
|
||||
</div>
|
||||
|
||||
{tab === 'cases'
|
||||
? <CasesTab
|
||||
{tab === "cases" ? (
|
||||
<CasesTab
|
||||
status={urlState.status}
|
||||
onStatus={(s) => setUrlState({ status: s })}
|
||||
search={urlState.q}
|
||||
@@ -63,22 +83,35 @@ export default function TreatmentCasesPage() {
|
||||
onFrom={(from) => setUrlState({ from })}
|
||||
to={urlState.to}
|
||||
onTo={(to) => setUrlState({ to })}
|
||||
view={urlState.view === 'card' ? 'card' : 'table'}
|
||||
view={urlState.view === "card" ? "card" : "table"}
|
||||
onView={(v) => setUrlState({ view: v })}
|
||||
/>
|
||||
: <UnbookedTab />}
|
||||
) : (
|
||||
<UnbookedTab />
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
const STATUS_FILTERS = [
|
||||
['', 'همه'],
|
||||
['active', 'در جریان'],
|
||||
['completed', 'تمام شده'],
|
||||
['abandoned', 'رها شده'],
|
||||
["", "همه"],
|
||||
["active", "در جریان"],
|
||||
["completed", "تمام شده"],
|
||||
["abandoned", "رها شده"],
|
||||
] 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;
|
||||
onStatus: (s: string) => void;
|
||||
search: string;
|
||||
@@ -89,32 +122,34 @@ function CasesTab({ status, onStatus, search, onSearch, from, onFrom, to, onTo,
|
||||
to: string;
|
||||
onTo: (s: string) => void;
|
||||
/** جدولی یا کارتی — همان الگوی صفحهٔ پروندهها، در URL تا «بازگشت» نما را نپراند. */
|
||||
view: 'table' | 'card';
|
||||
onView: (v: 'table' | 'card') => void;
|
||||
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);
|
||||
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],
|
||||
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);
|
||||
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}` : ''}`,
|
||||
`/api/v1/treatment-cases${suffix ? `?${suffix}` : ""}`,
|
||||
);
|
||||
},
|
||||
staleTime: 30_000,
|
||||
@@ -124,28 +159,60 @@ function CasesTab({ status, onStatus, search, onSearch, from, onFrom, to, onTo,
|
||||
|
||||
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)' }} />
|
||||
<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('')}>
|
||||
{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]) => (
|
||||
<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"
|
||||
@@ -153,14 +220,26 @@ function CasesTab({ status, onStatus, search, onSearch, from, onFrom, to, onTo,
|
||||
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',
|
||||
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)'} />
|
||||
<Icon
|
||||
color={
|
||||
view === v
|
||||
? "var(--primary)"
|
||||
: "var(--text-2)"
|
||||
}
|
||||
/>
|
||||
</button>
|
||||
),
|
||||
)}
|
||||
))}
|
||||
</div>
|
||||
|
||||
<div className="seg">
|
||||
@@ -168,7 +247,7 @@ function CasesTab({ status, onStatus, search, onSearch, from, onFrom, to, onTo,
|
||||
<button
|
||||
key={v}
|
||||
type="button"
|
||||
className={status === v ? 'on' : ''}
|
||||
className={status === v ? "on" : ""}
|
||||
aria-pressed={status === v}
|
||||
onClick={() => onStatus(v)}
|
||||
>
|
||||
@@ -178,27 +257,64 @@ function CasesTab({ status, onStatus, search, onSearch, from, onFrom, to, onTo,
|
||||
</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: 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={{
|
||||
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="از تاریخ" />
|
||||
<PersianDateInput
|
||||
value={from}
|
||||
onChange={onFrom}
|
||||
ariaLabel="شروع از تاریخ"
|
||||
placeholder="از تاریخ"
|
||||
/>
|
||||
</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 }}>
|
||||
<PersianDateInput value={to} onChange={onTo} ariaLabel="شروع تا تاریخ" placeholder="تا تاریخ" />
|
||||
<PersianDateInput
|
||||
value={to}
|
||||
onChange={onTo}
|
||||
ariaLabel="شروع تا تاریخ"
|
||||
placeholder="تا تاریخ"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
{(from !== '' || to !== '') && (
|
||||
{(from !== "" || to !== "") && (
|
||||
<button
|
||||
type="button"
|
||||
className="btn ghost sm"
|
||||
onClick={() => { onFrom(''); onTo(''); }}
|
||||
onClick={() => {
|
||||
onFrom("");
|
||||
onTo("");
|
||||
}}
|
||||
>
|
||||
پاک کردن بازه
|
||||
</button>
|
||||
@@ -206,40 +322,77 @@ function CasesTab({ status, onStatus, search, onSearch, from, onFrom, to, onTo,
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{from !== '' && to !== '' && from > to && (
|
||||
<div className="card card-pad" style={{ marginBottom: 12, fontSize: 12.5, color: 'var(--danger)' }}>
|
||||
«از تاریخ» بعد از «تا تاریخ» است، پس هیچ پروندهای در این بازه نمیافتد.
|
||||
{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 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
|
||||
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 }}>
|
||||
<div
|
||||
className="card card-pad"
|
||||
style={{
|
||||
fontSize: 13,
|
||||
color: "var(--text-3)",
|
||||
lineHeight: 1.9,
|
||||
}}
|
||||
>
|
||||
{search
|
||||
? `برای «${search}» پروندهای پیدا نشد.`
|
||||
: (from !== '' || to !== '')
|
||||
? 'در این بازهٔ تاریخ پروندهای باز نشده است.'
|
||||
: 'پروندهای یافت نشد. پرونده وقتی ساخته میشود که نوبتِ سرویسی با «طول درمان» قطعی شود.'}
|
||||
: from !== "" || to !== ""
|
||||
? "در این بازهٔ تاریخ پروندهای باز نشده است."
|
||||
: "پروندهای یافت نشد. پرونده وقتی ساخته میشود که نوبتِ سرویسی با «طول درمان» قطعی شود."}
|
||||
</div>
|
||||
) : view === 'table' ? (
|
||||
) : 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 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)} />
|
||||
<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) {
|
||||
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) {
|
||||
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;
|
||||
}
|
||||
|
||||
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 (
|
||||
<div style={{
|
||||
background: 'var(--surface)', border: '1px solid var(--border)',
|
||||
borderRadius: 'var(--r-lg)', overflow: 'auto',
|
||||
}}>
|
||||
<table style={{ width: '100%', borderCollapse: 'collapse', minWidth: 900 }}>
|
||||
<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 }}>
|
||||
<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' }}>
|
||||
<th
|
||||
key={h}
|
||||
style={{
|
||||
padding: "12px 14px",
|
||||
textAlign: "center",
|
||||
fontWeight: 600,
|
||||
whiteSpace: "nowrap",
|
||||
}}
|
||||
>
|
||||
{h}
|
||||
</th>
|
||||
))}
|
||||
@@ -284,34 +484,89 @@ function CasesTable({ cases, onEdit }: { cases: TreatmentCaseSummary[]; onEdit:
|
||||
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>
|
||||
<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' }}>{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 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' }}>
|
||||
<span className={`badge ${c.status === 'active' ? 'blue' : c.status === 'completed' ? 'green' : 'gray'}`}>
|
||||
<span className="bdot" />{CASE_STATUS_LABEL[c.status]}
|
||||
<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
|
||||
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' }}>
|
||||
<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 ?? ''}`}
|
||||
aria-label={`ویرایش پروندهٔ ${c.patient.name ?? ""}`}
|
||||
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>
|
||||
</td>
|
||||
</tr>
|
||||
@@ -323,41 +578,79 @@ function CasesTable({ cases, onEdit }: { cases: TreatmentCaseSummary[]; onEdit:
|
||||
);
|
||||
}
|
||||
|
||||
function CaseCard({ item: c, onEdit }: { item: TreatmentCaseSummary; onEdit: () => void }) {
|
||||
const percent = c.total_sessions > 0
|
||||
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' }}>
|
||||
<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]}
|
||||
<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 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}>
|
||||
<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)' }}>
|
||||
<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 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>
|
||||
{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>
|
||||
)}
|
||||
{c.areas.length > 0 && <span>نواحی: {c.areas.map((a) => a.name).join('، ')}</span>}
|
||||
</div>
|
||||
|
||||
{/* `<progress>` نیتیو ظاهر مرورگر را میگیرد و با توکنهای تم نمیخواند. */}
|
||||
@@ -367,13 +660,25 @@ function CaseCard({ item: c, onEdit }: { item: TreatmentCaseSummary; onEdit: ()
|
||||
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' }}
|
||||
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
|
||||
style={{
|
||||
width: `${percent}%`,
|
||||
height: "100%",
|
||||
borderRadius: 999,
|
||||
background:
|
||||
c.status === "completed"
|
||||
? "var(--success)"
|
||||
: "var(--primary)",
|
||||
transition: "width .3s var(--ease)",
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
@@ -381,8 +686,11 @@ function CaseCard({ item: c, onEdit }: { item: TreatmentCaseSummary; onEdit: ()
|
||||
|
||||
function UnbookedTab() {
|
||||
const { data, isLoading } = useQuery({
|
||||
queryKey: ['treatment-sessions-unbooked'],
|
||||
queryFn: () => api.get<ApiResponse<StaffTreatmentSession[]>>('/api/v1/treatment-sessions/unbooked?within_days=14'),
|
||||
queryKey: ["treatment-sessions-unbooked"],
|
||||
queryFn: () =>
|
||||
api.get<ApiResponse<StaffTreatmentSession[]>>(
|
||||
"/api/v1/treatment-sessions/unbooked?within_days=14",
|
||||
),
|
||||
staleTime: 30_000,
|
||||
});
|
||||
|
||||
@@ -390,31 +698,73 @@ function UnbookedTab() {
|
||||
|
||||
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>
|
||||
|
||||
{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 ? (
|
||||
<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 style={{ display: 'grid', gap: 12 }}>
|
||||
<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}
|
||||
<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)' }}>
|
||||
<span
|
||||
style={{
|
||||
fontSize: 12.5,
|
||||
color: "var(--warning)",
|
||||
}}
|
||||
>
|
||||
سررسید: {formatDate(s.due_at)}
|
||||
</span>
|
||||
)}
|
||||
@@ -438,8 +788,9 @@ 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>>(
|
||||
queryKey: ["session-slot-suggestions", sessionUuid],
|
||||
queryFn: () =>
|
||||
api.get<ApiResponse<SlotSuggestionResponse>>(
|
||||
`/api/v1/treatment-session/${sessionUuid}/slot-suggestions?days=14`,
|
||||
),
|
||||
enabled: open,
|
||||
@@ -448,13 +799,20 @@ function SlotSuggestions({ sessionUuid }: { sessionUuid: string }) {
|
||||
|
||||
if (!open) {
|
||||
return (
|
||||
<div style={{ display: 'flex', gap: 8, flexWrap: 'wrap' }}>
|
||||
<button type="button" className="btn secondary sm" onClick={() => setOpen(true)}>
|
||||
<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
|
||||
to={`/admin/appointments/new?session=${sessionUuid}`}
|
||||
className="btn primary sm"
|
||||
>
|
||||
ثبت نوبت این جلسه
|
||||
</Link>
|
||||
</div>
|
||||
@@ -464,25 +822,46 @@ function SlotSuggestions({ sessionUuid }: { sessionUuid: string }) {
|
||||
const days = data?.data?.days ?? [];
|
||||
|
||||
return (
|
||||
<div style={{ display: 'grid', gap: 8 }}>
|
||||
{isLoading && <span style={{ fontSize: 12.5, color: 'var(--text-3)' }}>در حال جستوجوی وقت...</span>}
|
||||
<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 style={{ fontSize: 12.5, color: "var(--text-3)" }}>
|
||||
دستگاهی برای پیشنهاد وقت مشخص نیست — این جلسه هنوز روی هیچ
|
||||
دستگاهی انجام نشده.
|
||||
</span>
|
||||
)}
|
||||
|
||||
{!isLoading && !isError && days.length === 0 && (
|
||||
<span style={{ fontSize: 12.5, color: 'var(--text-3)' }}>
|
||||
<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))}
|
||||
<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
|
||||
@@ -497,7 +876,12 @@ function SlotSuggestions({ sessionUuid }: { sessionUuid: string }) {
|
||||
</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>
|
||||
</div>
|
||||
|
||||
Reference in New Issue
Block a user