Files
clinicpro/assets/admin/pages/MyPatientsPage.tsx
T
hamed 6876135a53 feat: add BlogBodySanitizer for HTML sanitization on article save
- Implemented BlogBodySanitizer to clean HTML content before saving articles, ensuring security against XSS attacks.
- Added tests for BlogBodySanitizer to verify that unsafe tags and attributes are stripped from the content.
- Introduced ApiLeastPrivilegeTest to ensure that unauthorized users cannot access sensitive API routes, maintaining strict access control.
2026-08-07 21:13:38 +03:30

2112 lines
98 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import {
Bars3Icon,
BanknotesIcon,
BellIcon,
CalendarDaysIcon,
ChatBubbleLeftEllipsisIcon,
ChatBubbleLeftRightIcon,
CheckCircleIcon,
ChevronDownIcon,
ChevronRightIcon,
ClipboardDocumentCheckIcon,
ClipboardDocumentListIcon,
ClockIcon,
CreditCardIcon,
DocumentTextIcon,
FolderOpenIcon,
FunnelIcon,
MagnifyingGlassIcon,
PaperClipIcon,
PencilIcon,
PhoneArrowUpRightIcon,
PhoneIcon,
PlusIcon,
Squares2X2Icon,
UserPlusIcon,
UserIcon,
UsersIcon,
EllipsisHorizontalIcon,
EyeIcon,
} from "@heroicons/react/24/outline";
import { zodResolver } from "@hookform/resolvers/zod";
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import React, { useCallback, useRef, useState } from "react";
import { useForm } from "react-hook-form";
import { useNavigate } from "react-router";
import { toast } from "sonner";
import { z } from "zod";
import FeatureGate from "../components/ui/FeatureGate";
import Modal from "../components/ui/Modal";
import PageHeader from "../components/ui/PageHeader";
import Pagination from "../components/ui/Pagination";
import SearchableSelect from "../components/ui/SearchableSelect";
import PatientRecordInfoForm from "../components/PatientRecordInfoForm";
import { usePermissions } from "../hooks/usePermissions";
import {
GENDER_OPTS,
MARITAL_OPTS,
EDUCATION_OPTS,
REFERRAL_OPTS,
profileToFormValues,
formValuesToPayload,
} from "../lib/patientForm";
import type { ApiResponse, PaginatedResponse } from "../lib/api";
import { api } from "../lib/api";
import { useIssueInvoice } from "../hooks/useIssueInvoice";
import { numericField } from "../lib/forms";
import {
formatDate,
formatDateTime,
formatNumber,
formatRial,
} from "../lib/utils";
import type {
PatientAppointment,
PatientRecord,
PatientSession,
ServiceItem,
ServiceSection,
} from "../types";
const sessionSchema = z.object({
visit_price_rials: z.coerce.number().min(0),
base_insurance_discount_percent: z.coerce.number().min(0).max(100),
supplementary_discount_percent: z.coerce.number().min(0).max(100),
insurance_base_id: z.coerce.number().optional(),
insurance_supplementary_id: z.coerce.number().optional(),
payment_method: z.enum(["cash", "card", "insurance", "online", "pending"]),
notes: z.string().optional(),
});
type SessionFormData = z.infer<typeof sessionSchema>;
interface PricingInsurance {
insurance_id: number;
insurance_name: string;
type: string;
/** فقط «سهم بیمار ثابت» مدل قدیمی؛ ورودی هیچ محاسبه‌ای نیست. */
patient_share_rials: number | null;
}
interface InsurancePricing {
free_visit_price_rials: number;
insurances: PricingInsurance[];
}
/** ویزیت خدمتِ سرپایی است، پس درصد پوشش همین نوع خدمت خوانده می‌شود. */
const VISIT_SERVICE_CATEGORY = "outpatient";
const PAYMENT_LABELS: Record<string, string> = {
cash: "نقدی",
card: "کارت",
insurance: "بیمه",
online: "آنلاین",
pending: "در انتظار",
};
const APPT_STATUS_LABELS: Record<string, string> = {
pending: "در انتظار",
confirmed: "تایید شده",
completed: "انجام شده",
cancelled_by_doctor: "لغو توسط پزشک",
cancelled_by_user: "لغو توسط بیمار",
no_show: "عدم مراجعه",
expired: "منقضی",
};
const APPT_STATUS_BADGE: Record<string, string> = {
pending: "amber",
confirmed: "blue",
completed: "green",
cancelled_by_doctor: "red",
cancelled_by_user: "red",
no_show: "gray",
expired: "gray",
};
const APPT_ACTIVE_STATUSES = ["pending", "confirmed"];
const EMPTY_RECORDS: PatientRecord[] = [];
const EMPTY_SESSIONS: PatientSession[] = [];
const PATIENT_TABS = [
{ key: "visits", label: "مراجعات", icon: ClipboardDocumentCheckIcon },
{ key: "info", label: "اطلاعات پرونده", icon: DocumentTextIcon },
{ key: "appointments", label: "نوبت ها", icon: CalendarDaysIcon },
{ key: "payments", label: "پرداخت ها", icon: CreditCardIcon },
{ key: "wallet", label: "کیف پول", icon: BanknotesIcon },
{ key: "messages", label: "پیام ها", icon: ChatBubbleLeftRightIcon },
{ key: "callcenter", label: "کال سنتر", icon: PhoneArrowUpRightIcon },
{ key: "attach", label: "ضمیمه", icon: PaperClipIcon },
{ key: "records", label: "پرونده پزشکی", icon: ClipboardDocumentListIcon },
] as const;
const PLACEHOLDER_TABS: Record<string, string> = {
records: "پرونده پزشکی",
attach: "ضمیمه",
callcenter: "کال سنتر",
messages: "پیام‌ها",
wallet: "کیف پول",
};
function BannerInfoRow({
icon,
label,
value,
}: {
icon: React.ReactNode;
label: string;
value: React.ReactNode;
}) {
return (
<div style={{ display: "flex", alignItems: "center", gap: 8 }}>
<div
style={{
width: 36, height: 36, borderRadius: "50%", flexShrink: 0,
background: "rgba(255,216,197,0.6)", display: "grid", placeItems: "center",
}}
>
{icon}
</div>
<span style={{ fontSize: 12, color: "var(--text)" }}>{label}</span>
<span style={{ fontSize: 14, fontWeight: 500, color: "var(--text)" }}>{value}</span>
</div>
);
}
function getPatientName(record?: PatientRecord | null) {
return record?.user_name || "—";
}
function getPatientPhone(record?: PatientRecord | null) {
return record?.user_mobile || "—";
}
function getPatientNationalCode(record?: PatientRecord | null) {
return record?.user_national_code || null;
}
function calcFinalPrice(
visitPrice: number,
baseDiscount: number,
suppDiscount: number,
servicesTotal: number,
) {
const afterBase = visitPrice * (1 - baseDiscount / 100);
const afterSupp = afterBase * (1 - suppDiscount / 100);
return Math.round(afterSupp) + servicesTotal;
}
function MyPatientsPageInner() {
const qc = useQueryClient();
const navigate = useNavigate();
// مجوزهای منشی؛ برای owner/پزشک همیشه true (usePermissions بدون context آزاد است).
const { can } = usePermissions();
const canCreate = can("patients", "create");
const canUpdate = can("patients", "update");
const [selectedRecord, setSelectedRecord] = useState<PatientRecord | null>(
null,
);
const [page, setPage] = useState(1);
const [search, setSearch] = useState("");
const [viewMode, setViewMode] = useState<"grid" | "list">("grid");
const [detailTab, setDetailTab] = useState<
| "info"
| "visits"
| "appointments"
| "payments"
| "records"
| "attach"
| "callcenter"
| "messages"
| "wallet"
>("visits");
const [expandedVisit, setExpandedVisit] = useState<string | null>(null);
const [invoiceUuid, setInvoiceUuid] = useState<string | null>(null);
// «مشاهده فاکتور»: نبودِ invoice_uuid یعنی اول صادر شود (idempotent create + finalize).
const issueInvoiceMut = useIssueInvoice(selectedRecord?.uuid);
const viewInvoice = (s: PatientSession) => {
if (s.invoice_uuid) { setExpandedVisit(null); setInvoiceUuid(s.invoice_uuid); return; }
issueInvoiceMut.mutate(s.uuid, {
onSuccess: (iv) => { setExpandedVisit(null); setInvoiceUuid(iv); },
onError: () => toast.error("صدور فاکتور ناموفق بود"),
});
};
const [sessionPage, setSessionPage] = useState(1);
const [sessionModal, setSessionModal] = useState(false);
const [editSession, setEditSession] = useState<PatientSession | null>(null);
const form = useForm<SessionFormData>({
resolver: zodResolver(sessionSchema),
defaultValues: {
visit_price_rials: 0,
base_insurance_discount_percent: 0,
supplementary_discount_percent: 0,
payment_method: "cash",
},
});
const watchVisit = form.watch("visit_price_rials") ?? 0;
const watchBase = form.watch("base_insurance_discount_percent") ?? 0;
const watchSupp = form.watch("supplementary_discount_percent") ?? 0;
const [selectedServices, setSelectedServices] = useState<
{ service_item_uuid: string; name: string; price_rials: number }[]
>([]);
const [sectionUuid, setSectionUuid] = useState("");
const [itemUuid, setItemUuid] = useState("");
const [baseInsuranceId, setBaseInsuranceId] = useState("");
const [suppInsuranceId, setSuppInsuranceId] = useState("");
const [createRecordOpen, setCreateRecordOpen] = useState(false);
const [searchMobile, setSearchMobile] = useState("");
const [foundUser, setFoundUser] = useState<{
uuid: string;
name: string | null;
mobile: string;
national_code?: string | null;
} | null>(null);
const [recordNationalCode, setRecordNationalCode] = useState("");
const [searchError, setSearchError] = useState("");
const [notFound, setNotFound] = useState(false);
const [newPatientName, setNewPatientName] = useState("");
const mobileInputRef = useRef<HTMLInputElement>(null);
const [editOpen, setEditOpen] = useState(false);
const servicesTotal = selectedServices.reduce(
(sum, s) => sum + s.price_rials,
0,
);
const finalPrice = calcFinalPrice(
Number(watchVisit),
Number(watchBase),
Number(watchSupp),
servicesTotal,
);
const limit = 20;
const { data: recordsData, isLoading } = useQuery<
PaginatedResponse<PatientRecord>
>({
queryKey: ["patients", page, search],
queryFn: () =>
api.get(
`/api/v1/patients?page=${page}&limit=${limit}&search=${encodeURIComponent(search)}`,
),
});
const { data: recordDetail } = useQuery<ApiResponse<PatientRecord>>({
queryKey: ["patient-detail", selectedRecord?.uuid],
queryFn: () => api.get(`/api/v1/patient/${selectedRecord!.uuid}`),
enabled: !!selectedRecord,
});
const patientProfile = (recordDetail?.data as PatientRecord | undefined)?.profile ?? null;
// استان/شهر برای فرم «اطلاعات پرونده» (منبع: دامنهٔ Location؛ شهر وابسته به استان)
const [editProvinceId, setEditProvinceId] = useState<number | null>(null);
const provincesQ = useQuery({
queryKey: ["provinces"],
queryFn: () => api.get<any>("/api/v1/provinces"),
staleTime: 600_000,
});
const citiesQ = useQuery({
queryKey: ["cities", editProvinceId],
queryFn: () =>
api.get<any>(`/api/v1/cities${editProvinceId ? `?province_id=${editProvinceId}` : ""}`),
staleTime: 300_000,
});
const locOpts = (raw: any) =>
(raw?.data?.data ?? raw?.data ?? []).map((x: any) => ({ value: Number(x.id), label: x.name }));
const { data: sessionsData, isLoading: sessionsLoading } = useQuery<
PaginatedResponse<PatientSession>
>({
queryKey: ["patient-sessions", selectedRecord?.uuid, sessionPage],
queryFn: () =>
api.get(
`/api/v1/patient/${selectedRecord!.uuid}/sessions?page=${sessionPage}&limit=20`,
),
enabled: !!selectedRecord,
});
const { data: appointmentsData, isLoading: appointmentsLoading } = useQuery<
ApiResponse<PatientAppointment[]>
>({
queryKey: ["patient-appointments", selectedRecord?.uuid],
queryFn: () => api.get(`/api/v1/patient/${selectedRecord!.uuid}/appointments`),
enabled: !!selectedRecord,
});
const { data: sectionsData } = useQuery<ApiResponse<ServiceSection[]>>({
queryKey: ["service-sections"],
queryFn: () => api.get("/api/v1/service-sections"),
});
const { data: itemsData } = useQuery<ApiResponse<ServiceItem[]>>({
queryKey: ["service-items-for-session", sectionUuid],
queryFn: () => api.get(`/api/v1/service-items/${sectionUuid}`),
enabled: !!sectionUuid,
});
const { data: pricingData } = useQuery<ApiResponse<InsurancePricing>>({
queryKey: ["insurance-pricing"],
queryFn: () => api.get("/api/v1/insurance-pricing"),
enabled: !!selectedRecord,
});
// قراردادهای بیمهٔ همین tenant — منبع درصد پوشش (نه سهم بیمار ثابت).
const { data: contractsData } = useQuery<ApiResponse<any>>({
queryKey: ["tenant-insurances"],
queryFn: () => api.get("/api/v1/billing/tenant-insurances"),
enabled: !!selectedRecord,
});
const { data: invoiceData } = useQuery<ApiResponse<any>>({
queryKey: ["invoice", invoiceUuid],
queryFn: () => api.get(`/api/v1/billing/invoices/${invoiceUuid}`),
enabled: !!invoiceUuid,
});
const invoice = (invoiceData?.data as any)?.data ?? (invoiceData?.data as any) ?? null;
const records = recordsData?.data ?? EMPTY_RECORDS;
const sessions = sessionsData?.data ?? EMPTY_SESSIONS;
const appointments = appointmentsData?.data ?? [];
const totalRec = recordsData?.meta?.totalRecords ?? 0;
const totalSes = sessionsData?.meta?.totalRecords ?? 0;
const selectedPatientName = getPatientName(selectedRecord);
const selectedPatientPhone = getPatientPhone(selectedRecord);
// نوبت بعدی: نزدیک‌ترین نوبت آینده که لغو نشده
const nowSec = Math.floor(Date.now() / 1000);
const nextAppointment = appointments
.filter((a) => a.starts_at >= nowSec && APPT_ACTIVE_STATUSES.includes(a.status))
.sort((a, b) => a.starts_at - b.starts_at)[0];
// برای بنر: پرونده «تکمیل نشده» اگر مراجعه‌ی تسویه‌نشده دارد
const hasUnpaid = sessions.some((s) => s.is_paid === false || (s.patient_debt_rials ?? 0) > 0);
const sectionOptions = (sectionsData?.data ?? []).map((s) => ({
value: s.uuid,
label: s.name,
}));
const itemOptions = (itemsData?.data ?? [])
.filter((i) => i.active)
.map((i) => ({
value: i.uuid,
label: `${i.name}${formatRial(i.price_rials)}`,
}));
const pricing = (pricingData?.data as InsurancePricing | undefined) ?? undefined;
const freeVisitPrice = pricing?.free_visit_price_rials ?? 0;
const baseInsuranceOptions = (pricing?.insurances ?? [])
.filter((i) => i.type === "basic")
.map((i) => ({ value: String(i.insurance_id), label: i.insurance_name }));
const suppInsuranceOptions = (pricing?.insurances ?? [])
.filter((i) => i.type === "supplementary")
.map((i) => ({ value: String(i.insurance_id), label: i.insurance_name }));
/**
* درصد پوشش ویزیت از قرارداد فعال همان بیمه (سرپایی). قرارداد نبود → ۰٪.
* `patient_share_rials` دیگر ورودی محاسبه نیست.
*/
const contractVisitPercent = (insuranceId: string): number => {
if (!insuranceId) return 0;
const contracts = (contractsData?.data as any)?.data ?? [];
const contract = contracts.find(
(c: any) => String(c.insurance_id) === insuranceId && c.is_active,
);
return Number(contract?.category_coverages?.[VISIT_SERVICE_CATEGORY] ?? contract?.coverage_percent ?? 0);
};
const applyBaseInsurance = (insuranceId: string) => {
setBaseInsuranceId(insuranceId);
form.setValue("insurance_base_id", insuranceId ? Number(insuranceId) : undefined);
if (insuranceId) {
if (freeVisitPrice > 0) form.setValue("visit_price_rials", freeVisitPrice);
form.setValue("base_insurance_discount_percent", contractVisitPercent(insuranceId));
}
};
const applySuppInsurance = (insuranceId: string) => {
setSuppInsuranceId(insuranceId);
form.setValue("insurance_supplementary_id", insuranceId ? Number(insuranceId) : undefined);
if (insuranceId) {
form.setValue("supplementary_discount_percent", contractVisitPercent(insuranceId));
}
};
const openEditInfo = () => {
setEditProvinceId(patientProfile?.province_id ?? null);
setEditOpen(true);
};
const updatePatientMut = useMutation({
mutationFn: (body: object) =>
api.patch(`/api/v1/patient/${selectedRecord!.uuid}`, body),
onSuccess: () => {
qc.invalidateQueries({ queryKey: ["patient-detail", selectedRecord?.uuid] });
qc.invalidateQueries({ queryKey: ["patients"] });
setEditOpen(false);
toast.success("اطلاعات بیمار به‌روزرسانی شد");
},
onError: (e: any) => {
toast.error(e?.message || "خطا در به‌روزرسانی اطلاعات بیمار");
},
});
const settleSessionMut = useMutation({
mutationFn: (uuid: string) =>
api.patch(`/api/v1/session/${uuid}`, { payment_method: "cash" }),
onSuccess: () => {
qc.invalidateQueries({ queryKey: ["patient-sessions", selectedRecord?.uuid] });
toast.success("پرداخت ثبت شد");
},
onError: (e: any) => toast.error(e?.message || "خطا در ثبت پرداخت"),
});
const createSessionMut = useMutation({
mutationFn: (body: object) =>
api.post(`/api/v1/patient/${selectedRecord!.uuid}/session`, body),
onSuccess: () => {
qc.invalidateQueries({
queryKey: ["patient-sessions", selectedRecord?.uuid],
});
setSessionModal(false);
form.reset();
setSelectedServices([]);
setBaseInsuranceId("");
setSuppInsuranceId("");
toast.success("مراجعه ثبت شد");
},
onError: (e: any) => toast.error(e.message),
});
const updateSessionMut = useMutation({
mutationFn: ({ uuid, body }: { uuid: string; body: object }) =>
api.patch(`/api/v1/session/${uuid}`, body),
onSuccess: () => {
qc.invalidateQueries({
queryKey: ["patient-sessions", selectedRecord?.uuid],
});
setEditSession(null);
toast.success("مراجعه ویرایش شد");
},
onError: (e: any) => toast.error(e.message),
});
const searchUserMut = useMutation({
mutationFn: (mobile: string) =>
api.get(
`/api/v1/patient/search-user?mobile=${encodeURIComponent(mobile)}`,
),
onSuccess: (res: any) => {
setFoundUser(res?.data);
setRecordNationalCode(res?.data?.national_code ?? "");
setNotFound(false);
setSearchError("");
},
onError: () => {
setFoundUser(null);
setNotFound(true);
setSearchError("");
},
});
const createRecordMut = useMutation({
mutationFn: (body: Record<string, unknown>) =>
api.post("/api/v1/patient", body),
onSuccess: () => {
qc.invalidateQueries({ queryKey: ["patients"] });
setCreateRecordOpen(false);
setSearchMobile("");
setFoundUser(null);
setRecordNationalCode("");
setNotFound(false);
setNewPatientName("");
setSearchError("");
toast.success("پرونده بیمار ایجاد شد");
},
onError: (e: any) => toast.error(e.message),
});
const handleSearchMobile = () => {
const digits = searchMobile.replace(/\D/g, "");
if (!/^09\d{9}$/.test(digits)) {
setSearchError("شماره موبایل معتبر نیست");
return;
}
setSearchError("");
setFoundUser(null);
setNotFound(false);
searchUserMut.mutate(digits);
};
const handleCreateRecord = () => {
if (foundUser) {
createRecordMut.mutate({
user_uuid: foundUser.uuid,
national_code: recordNationalCode || undefined,
});
} else if (notFound) {
createRecordMut.mutate({
mobile: searchMobile.replace(/\D/g, ""),
name: newPatientName.trim(),
national_code: recordNationalCode || undefined,
});
}
};
const handleCreateRecordClose = () => {
setCreateRecordOpen(false);
setSearchMobile("");
setFoundUser(null);
setRecordNationalCode("");
setNotFound(false);
setNewPatientName("");
setSearchError("");
};
const handleAddService = () => {
if (!itemUuid) return;
const found = itemsData?.data?.find((i) => i.uuid === itemUuid);
if (
!found ||
selectedServices.some((s) => s.service_item_uuid === found.uuid)
)
return;
setSelectedServices((p) => [
...p,
{
service_item_uuid: found.uuid,
name: found.name,
price_rials: found.price_rials,
},
]);
setItemUuid("");
};
const handleSubmitSession = form.handleSubmit((d) => {
createSessionMut.mutate({
...d,
services: selectedServices.map((s) => ({
service_item_uuid: s.service_item_uuid,
})),
});
});
const handleSearch = useCallback((v: string) => {
setSearch(v);
setPage(1);
}, []);
if (!selectedRecord) {
return (
<>
<PageHeader
title="پرونده بیماران"
description="مراجعه‌کنندگان ثبت‌شده شما"
action={
canCreate ? (
<button
className="btn primary sm"
onClick={() => setCreateRecordOpen(true)}
>
<UserPlusIcon style={{ width: 16 }} />
پرونده جدید
</button>
) : undefined
}
/>
<div style={{ marginBottom: 16 }}>
<div
style={{
background: "var(--surface)",
border: "1px solid var(--border)",
borderRadius: "var(--r)",
padding: "10px 14px",
display: "flex",
alignItems: "center",
gap: 10,
}}
>
<MagnifyingGlassIcon
style={{
width: 18,
color: "var(--text-3)",
flexShrink: 0,
}}
/>
<input
style={{
border: "none",
outline: "none",
background: "transparent",
flex: 1,
fontSize: 14,
}}
value={search}
onChange={(e) => handleSearch(e.target.value)}
placeholder="جستجو بر اساس نام، شماره موبایل یا کد ملی..."
/>
<div style={{ display: "flex", gap: 4, flexShrink: 0 }}>
<button
className={`mini-btn${viewMode === "grid" ? " active" : ""}`}
style={viewMode === "grid" ? { background: "var(--primary-soft)", color: "var(--primary)" } : undefined}
onClick={() => setViewMode("grid")}
title="نمای کارتی"
>
<Squares2X2Icon style={{ width: 16 }} />
</button>
<button
className={`mini-btn${viewMode === "list" ? " active" : ""}`}
style={viewMode === "list" ? { background: "var(--primary-soft)", color: "var(--primary)" } : undefined}
onClick={() => setViewMode("list")}
title="نمای لیستی"
>
<Bars3Icon style={{ width: 16 }} />
</button>
</div>
</div>
</div>
{records.length === 0 && !isLoading ? (
<div
className="card"
style={{
textAlign: "center",
padding: "60px 24px",
color: "var(--text-3)",
}}
>
<UsersIcon
style={{
width: 48,
margin: "0 auto 16px",
display: "block",
opacity: 0.4,
}}
/>
<div
style={{
fontWeight: 600,
fontSize: 15,
marginBottom: 8,
color: "var(--text-2)",
}}
>
{search
? "بیماری یافت نشد"
: "هنوز بیماری ثبت نشده است"}
</div>
<div style={{ fontSize: 13 }}>
{search
? "عبارت جستجو را تغییر دهید"
: "پس از ثبت نوبت، پرونده بیمار به طور خودکار ایجاد می‌شود"}
</div>
</div>
) : (
<>
<div
style={
viewMode === "grid"
? { display: "grid", gridTemplateColumns: "repeat(auto-fill, minmax(280px, 1fr))", gap: 14 }
: { display: "flex", flexDirection: "column", gap: 10 }
}
>
{records.map((r) => {
const open = () => { setSelectedRecord(r); setSessionPage(1); };
return viewMode === "grid"
? <PatientCard key={r.uuid} record={r} onOpen={open} />
: <PatientRow key={r.uuid} record={r} onOpen={open} />;
})}
</div>
<div style={{ marginTop: 16 }}>
<Pagination
page={page}
total={totalRec}
limit={limit}
onPageChange={setPage}
/>
</div>
</>
)}
{/* Modal ایجاد پرونده دستی */}
<Modal
open={createRecordOpen}
onClose={handleCreateRecordClose}
title="ایجاد پرونده بیمار"
size="sm"
footer={
<>
<button
className="btn ghost sm"
onClick={handleCreateRecordClose}
>
انصراف
</button>
<button
className="btn primary sm"
disabled={
createRecordMut.isPending ||
(!foundUser &&
!(notFound && newPatientName.trim()))
}
onClick={handleCreateRecord}
>
{createRecordMut.isPending
? "در حال ایجاد..."
: "ایجاد پرونده"}
</button>
</>
}
>
<div
style={{
display: "flex",
alignItems: "flex-start",
gap: 10,
padding: "11px 13px",
marginBottom: 18,
background: "var(--primary-subtle)",
border: "1px solid oklch(0.88 0.05 256)",
borderRadius: "var(--r)",
fontSize: 12.5,
color: "var(--text-2)",
lineHeight: 1.7,
}}
>
<UserPlusIcon
style={{
width: 18,
color: "var(--primary)",
flexShrink: 0,
marginTop: 1,
}}
/>
<span>
بیمار باید قبلاً در سیستم ثبت‌نام کرده باشد. شماره
موبایل او را وارد و جستجو کنید.
</span>
</div>
<div>
<label className="field-label">
شماره موبایل بیمار
</label>
<div
style={{
display: "flex",
alignItems: "center",
gap: 6,
background: "var(--surface)",
border: `1px solid ${searchError ? "var(--danger)" : "var(--border)"}`,
borderRadius: "var(--r-sm)",
padding: "5px 5px 5px 12px",
transition: "border-color .15s",
}}
>
<PhoneIcon
style={{
width: 16,
color: "var(--text-3)",
flexShrink: 0,
}}
/>
<input
ref={mobileInputRef}
value={searchMobile}
placeholder="09123456789"
dir="ltr"
inputMode="numeric"
maxLength={11}
autoFocus
style={{
flex: 1,
border: "none",
outline: "none",
background: "transparent",
fontSize: 14,
textAlign: "left",
letterSpacing: "0.5px",
}}
onChange={(e) => {
const digits = e.target.value.replace(
/\D/g,
"",
);
setSearchMobile(digits);
setFoundUser(null);
setSearchError("");
}}
onKeyDown={(e) => {
if (e.key === "Enter") handleSearchMobile();
}}
/>
<button
className="btn primary sm"
onClick={handleSearchMobile}
disabled={
searchUserMut.isPending ||
searchMobile.length < 11
}
style={{
flexShrink: 0,
display: "flex",
alignItems: "center",
gap: 5,
}}
>
<MagnifyingGlassIcon style={{ width: 14 }} />
{searchUserMut.isPending ? "جستجو..." : "جستجو"}
</button>
</div>
{searchError && (
<span
style={{
display: "block",
marginTop: 6,
fontSize: 12,
color: "var(--danger)",
}}
>
{searchError}
</span>
)}
</div>
{foundUser && (
<div
style={{
marginTop: 16,
display: "flex",
alignItems: "center",
gap: 12,
padding: "14px",
background:
"var(--success-subtle, oklch(0.97 0.03 150))",
border: "1px solid oklch(0.86 0.08 150)",
borderRadius: "var(--r)",
}}
>
<div
style={{
width: 40,
height: 40,
borderRadius: "50%",
flexShrink: 0,
background:
"linear-gradient(145deg, oklch(0.68 0.15 150), oklch(0.52 0.16 150))",
display: "flex",
alignItems: "center",
justifyContent: "center",
color: "var(--on-primary)",
fontWeight: 700,
fontSize: 16,
}}
>
{(foundUser.name ?? "؟").charAt(0)}
</div>
<div style={{ flex: 1, minWidth: 0 }}>
<div style={{ fontWeight: 600, fontSize: 14 }}>
{foundUser.name ?? "بدون نام"}
</div>
<div
style={{
fontSize: 12.5,
color: "var(--text-3)",
direction: "ltr",
textAlign: "right",
marginTop: 2,
}}
>
{foundUser.mobile}
</div>
</div>
<span
style={{
display: "flex",
alignItems: "center",
gap: 4,
fontSize: 12,
fontWeight: 600,
color: "var(--success, #16a34a)",
flexShrink: 0,
}}
>
<CheckCircleIcon style={{ width: 18 }} />
یافت شد
</span>
</div>
)}
{notFound && (
<div
style={{
marginTop: 16,
padding: 14,
border: "1px solid var(--border)",
borderRadius: "var(--r)",
background: "var(--surface)",
display: "flex",
flexDirection: "column",
gap: 12,
}}
>
<div
style={{
fontSize: 12.5,
color: "var(--text-3)",
lineHeight: 1.7,
}}
>
کاربری با این شماره یافت نشد. برای ثبت بیمار جدید،
نام را وارد کنید.
</div>
<div
style={{
display: "flex",
flexDirection: "column",
gap: 5,
}}
>
<label
style={{ fontSize: 12.5, fontWeight: 600 }}
>
نام و نام خانوادگی *
</label>
<input
className="input"
value={newPatientName}
onChange={(e) =>
setNewPatientName(e.target.value)
}
placeholder="مثال: محمد محمدی"
autoFocus
/>
</div>
<div
style={{
display: "flex",
flexDirection: "column",
gap: 5,
}}
>
<label
style={{ fontSize: 12.5, fontWeight: 600 }}
>
کد ملی (اختیاری)
</label>
<input
className="input"
dir="ltr"
inputMode="numeric"
maxLength={10}
value={recordNationalCode}
onChange={(e) =>
setRecordNationalCode(
e.target.value.replace(/\D/g, ""),
)
}
placeholder="۱۰ رقم"
/>
</div>
</div>
)}
</Modal>
</>
);
}
return (
<>
<PageHeader
title={selectedPatientName}
description={`تلفن: ${selectedPatientPhone} — پرونده از ${formatDate(selectedRecord.created_at)}`}
action={
<div style={{ display: "flex", gap: 8 }}>
<button
className="btn sm"
onClick={() => setSelectedRecord(null)}
>
<ChevronRightIcon style={{ width: 15 }} /> بازگشت
</button>
{canUpdate && (
<button
className="btn primary sm"
onClick={() => navigate(`/admin/my-patients/${selectedRecord.uuid}/session/new`)}
>
<PlusIcon style={{ width: 15 }} /> مراجعه جدید
</button>
)}
</div>
}
/>
{/* بنر اطلاعات بیمار — مطابق فیگما */}
<div
style={{
background: "var(--surface)",
border: "1px solid var(--border)",
borderRadius: 8,
padding: "20px 24px",
marginBottom: 16,
display: "flex",
alignItems: "center",
justifyContent: "space-between",
gap: 16,
flexWrap: "wrap",
}}
>
{/* راست: نام، شماره پرونده، برچسب‌ها */}
<div style={{ display: "flex", flexDirection: "column", alignItems: "flex-end", gap: 12 }}>
<div style={{ display: "flex", alignItems: "center", gap: 8 }}>
<span style={{ fontWeight: 700, fontSize: 20, color: "var(--text)" }}>
{selectedPatientName}
</span>
{hasUnpaid ? (
<span style={{ background: "rgba(255,192,81,0.15)", color: "var(--warning)", fontSize: 14, fontWeight: 500, padding: "4px 12px", borderRadius: 8 }}>
تکمیل نشده
</span>
) : (
<span style={{ background: "rgba(60,154,79,0.15)", color: "var(--success)", fontSize: 14, fontWeight: 500, padding: "4px 12px", borderRadius: 8 }}>
تکمیل شده
</span>
)}
</div>
<div style={{ fontSize: 16, color: "var(--text-2)" }} dir="ltr">
شماره پرونده: {fileNumber(selectedRecord)}
</div>
<div style={{ display: "flex", alignItems: "center", gap: 8, fontSize: 15, color: "var(--text-2)" }}>
<span style={{ display: "inline-flex", alignItems: "center", gap: 4, color: "var(--text-3)", fontSize: 13 }}>
افزودن <PlusIcon style={{ width: 15 }} />
</span>
<span>برچسب ها:</span>
</div>
</div>
{/* وسط: شماره تماس و تاریخ تشکیل پرونده */}
<div style={{ display: "flex", flexDirection: "column", gap: 20 }}>
<BannerInfoRow
icon={<PhoneArrowUpRightIcon style={{ width: 20, color: "var(--accent)" }} />}
label="شماره تماس:"
value={<span dir="ltr">{selectedPatientPhone}</span>}
/>
<BannerInfoRow
icon={<CalendarDaysIcon style={{ width: 20, color: "var(--accent)" }} />}
label="تاریخ تشکیل پرونده:"
value={formatDate(selectedRecord.created_at)}
/>
</div>
{/* چپ: نوبت بعدی و دکمه یادداشت */}
<div style={{ display: "flex", flexDirection: "column", alignItems: "flex-start", gap: 16 }}>
<div style={{ display: "flex", alignItems: "center", gap: 4, fontSize: 12, color: "var(--text)" }}>
<BellIcon style={{ width: 22, color: "var(--text-2)" }} />
<span>
نوبت بعدی:{" "}
<span style={{ fontSize: 14, fontWeight: 600 }}>
{nextAppointment ? formatDate(nextAppointment.starts_at) : "—"}
</span>
</span>
</div>
{canUpdate && (
<button
onClick={() => toast.info("امکان یادداشت به‌زودی اضافه می‌شود")}
style={{
background: "var(--accent)", color: "var(--on-primary)", border: "none", cursor: "pointer",
display: "inline-flex", alignItems: "center", gap: 6,
padding: "8px 16px", borderRadius: 12, fontSize: 15, fontWeight: 500,
}}
>
یادداشت
<ChatBubbleLeftEllipsisIcon style={{ width: 20 }} />
</button>
)}
</div>
</div>
<div
className="cp-tabs"
style={{
display: "flex",
gap: 8,
borderBottom: "1px solid var(--border)",
marginBottom: 16,
overflowX: "auto",
}}
>
{PATIENT_TABS.map(({ key, label, icon: Icon }) => {
const active = detailTab === key;
return (
<button
key={key}
onClick={() => setDetailTab(key)}
style={{
display: "flex", alignItems: "center", gap: 6, whiteSpace: "nowrap",
padding: "10px 14px", fontSize: 13.5, fontWeight: 600, cursor: "pointer",
background: "none", border: "none",
color: active ? "var(--primary)" : "var(--text-2)",
borderBottom: `2px solid ${active ? "var(--primary)" : "transparent"}`,
marginBottom: -1,
}}
>
{label}
<Icon style={{ width: 18 }} />
</button>
);
})}
</div>
{detailTab === "info" && patientProfile && (
<div className="card" style={{ padding: 16, marginBottom: 16 }}>
<div style={{ display: "flex", alignItems: "center", justifyContent: "space-between", marginBottom: 12 }}>
<div style={{ fontWeight: 600, fontSize: 14 }}>اطلاعات بیمار</div>
{canUpdate && (
<button className="cp-btn-secondary" style={{ height: 34, padding: "0 12px", fontSize: 13 }} onClick={openEditInfo}>
<PencilIcon style={{ width: 15, height: 15 }} /> ویرایش
</button>
)}
</div>
<div style={{ display: "grid", gridTemplateColumns: "repeat(auto-fill, minmax(180px, 1fr))", gap: 12 }}>
{([
["نام کامل", patientProfile.full_name],
["نام پدر", patientProfile.fathers_name],
["کد ملی", patientProfile.national_code],
["جنسیت", patientProfile.gender === "male" ? "مرد" : patientProfile.gender === "female" ? "زن" : patientProfile.gender],
["تاریخ تولد", patientProfile.date_of_birth ? formatDate(patientProfile.date_of_birth) : null],
["گروه خونی", patientProfile.blood_type],
["وضعیت تأهل", patientProfile.marital_status],
["مقطع تحصیلی", patientProfile.education],
["رشته تحصیلی", patientProfile.field_of_study],
["شغل", patientProfile.job],
["موبایل", patientProfile.mobile],
["تلفن منزل", patientProfile.home_phone],
["بیمه پایه", patientProfile.basic_insurance_name],
["بیمه تکمیلی", patientProfile.supplementary_insurance_name],
["کد پستی", patientProfile.postal_code],
["نحوه آشنایی", patientProfile.referral_source],
["آدرس", patientProfile.address],
["توضیحات", patientProfile.description],
] as [string, string | null | undefined][]).map(([label, value]) => (
<div key={label}>
<div style={{ fontSize: 11.5, color: "var(--text-3)", marginBottom: 3 }}>{label}</div>
<div style={{ fontSize: 13, fontWeight: 500 }} dir={label === "موبایل" || label === "کد ملی" || label === "تلفن منزل" ? "ltr" : undefined}>
{value ? value : <span style={{ color: "var(--text-3)", fontWeight: 400 }}>ثبت نشده</span>}
</div>
</div>
))}
</div>
</div>
)}
<Modal
open={editOpen}
title="ویرایش اطلاعات پرونده"
size="lg"
onClose={() => setEditOpen(false)}
>
<PatientRecordInfoForm
defaultValues={profileToFormValues(patientProfile)}
recordNumber={selectedRecord?.uuid}
options={{
gender: GENDER_OPTS,
marital: MARITAL_OPTS,
education: EDUCATION_OPTS,
referral: REFERRAL_OPTS,
insurance: baseInsuranceOptions,
supplementary: suppInsuranceOptions,
province: locOpts(provincesQ.data),
city: locOpts(citiesQ.data),
}}
onSubmit={(v) => updatePatientMut.mutate(formValuesToPayload(v))}
isSubmitting={updatePatientMut.isPending}
onProvinceChange={setEditProvinceId}
/>
</Modal>
{detailTab === "visits" && (
sessionsLoading ? (
<div style={{ padding: 32, textAlign: "center", color: "var(--text-3)" }}>در حال بارگذاری…</div>
) : (
<>
<div style={{ display: "flex", gap: 8, marginBottom: 16 }}>
{canUpdate && (
<button
className="cp-btn-primary"
onClick={() => navigate(`/admin/my-patients/${selectedRecord.uuid}/session/new`)}
>
<PlusIcon style={{ width: 16 }} /> مراجعه جدید
</button>
)}
<button className="cp-btn-secondary" style={{ padding: "0 12px" }} title="فیلتر">
<FunnelIcon style={{ width: 18 }} />
</button>
</div>
{sessions.length === 0 ? (
<div className="card" style={{ padding: 32, textAlign: "center", color: "var(--text-3)" }}>
هنوز مراجعه‌ای برای این بیمار ثبت نشده است.
</div>
) : (
<>
<div style={{ display: "grid", gridTemplateColumns: "repeat(auto-fill, minmax(280px, 1fr))", gap: 16 }}>
{sessions.map((s) => (
<VisitSummaryCard
key={s.uuid}
session={s}
onOpen={() => setExpandedVisit(s.uuid)}
/>
))}
</div>
{totalSes > 20 && (
<div style={{ marginTop: 16 }}>
<Pagination page={sessionPage} total={totalSes} limit={20} onPageChange={setSessionPage} />
</div>
)}
</>
)}
</>
)
)}
{detailTab === "payments" && (
sessionsLoading ? (
<div style={{ padding: 32, textAlign: "center", color: "var(--text-3)" }}>در حال بارگذاری…</div>
) : sessions.length === 0 ? (
<div className="card" style={{ padding: 32, textAlign: "center", color: "var(--text-3)" }}>
پرداختی برای این بیمار ثبت نشده است.
</div>
) : (
<div className="card" style={{ overflowX: "auto" }}>
<table style={{ width: "100%", borderCollapse: "collapse", fontSize: 13.5 }}>
<thead>
<tr style={{ borderBottom: "1px solid var(--border)", color: "var(--text-3)", fontSize: 12 }}>
<th style={{ textAlign: "right", padding: "12px 16px", fontWeight: 600 }}>تاریخ</th>
<th style={{ textAlign: "right", padding: "12px 16px", fontWeight: 600 }}>شرح</th>
<th style={{ textAlign: "right", padding: "12px 16px", fontWeight: 600 }}>مبلغ</th>
<th style={{ textAlign: "right", padding: "12px 16px", fontWeight: 600 }}>مانده بدهی</th>
<th style={{ textAlign: "right", padding: "12px 16px", fontWeight: 600 }}>وضعیت</th>
<th style={{ textAlign: "left", padding: "12px 16px", fontWeight: 600 }}></th>
</tr>
</thead>
<tbody>
{sessions.map((s) => {
const debt = s.patient_debt_rials ?? 0;
const desc = (s.services ?? []).map((x) => x.service_name).join("، ") || "ویزیت";
return (
<tr key={s.uuid} style={{ borderBottom: "1px solid var(--border)" }}>
<td style={{ padding: "12px 16px" }}>{formatDate(s.created_at)}</td>
<td style={{ padding: "12px 16px", color: "var(--text-2)" }}>{desc}</td>
<td style={{ padding: "12px 16px", fontWeight: 600 }}>{formatRial(s.final_price_rials)}</td>
<td style={{ padding: "12px 16px", color: debt > 0 ? "var(--danger)" : "var(--success)" }}>
{debt > 0 ? formatRial(debt) : "ندارد"}
</td>
<td style={{ padding: "12px 16px" }}>
{s.is_paid
? <span className="badge green"><span className="bdot" />پرداخت شده</span>
: <span className="badge amber"><span className="bdot" />در انتظار</span>}
</td>
<td style={{ padding: "12px 16px", textAlign: "left" }}>
{s.is_paid ? (
s.invoice_uuid && (
<button className="cp-btn-secondary" style={{ height: 32, padding: "0 12px" }} onClick={() => setInvoiceUuid(s.invoice_uuid!)}>
مشاهده فاکتور
</button>
)
) : (
canUpdate && (
<button className="cp-btn-primary" style={{ height: 32, padding: "0 12px" }} disabled={settleSessionMut.isPending} onClick={() => settleSessionMut.mutate(s.uuid)}>
تکمیل پرداخت
</button>
)
)}
</td>
</tr>
);
})}
</tbody>
</table>
</div>
)
)}
{detailTab === "appointments" && (
appointmentsLoading ? (
<div style={{ padding: 32, textAlign: "center", color: "var(--text-3)" }}>در حال بارگذاری…</div>
) : appointments.length === 0 ? (
<div className="card" style={{ padding: 32, textAlign: "center", color: "var(--text-3)" }}>
نوبتی برای این بیمار ثبت نشده است.
</div>
) : (
<div className="card" style={{ overflowX: "auto" }}>
<table style={{ width: "100%", borderCollapse: "collapse", fontSize: 13.5 }}>
<thead>
<tr style={{ borderBottom: "1px solid var(--border)", color: "var(--text-3)", fontSize: 12 }}>
<th style={{ textAlign: "right", padding: "12px 16px", fontWeight: 600 }}>تاریخ و ساعت</th>
<th style={{ textAlign: "right", padding: "12px 16px", fontWeight: 600 }}>پزشک</th>
<th style={{ textAlign: "right", padding: "12px 16px", fontWeight: 600 }}>خدمت</th>
<th style={{ textAlign: "right", padding: "12px 16px", fontWeight: 600 }}>مبلغ</th>
<th style={{ textAlign: "right", padding: "12px 16px", fontWeight: 600 }}>وضعیت</th>
</tr>
</thead>
<tbody>
{appointments.map((a) => (
<tr key={a.uuid} style={{ borderBottom: "1px solid var(--border)" }}>
<td style={{ padding: "12px 16px" }}>{formatDateTime(a.starts_at)}</td>
<td style={{ padding: "12px 16px", color: "var(--text-2)" }}>{a.doctor_name || "—"}</td>
<td style={{ padding: "12px 16px", color: "var(--text-2)" }}>{a.service_name || "—"}</td>
<td style={{ padding: "12px 16px" }}>{a.price_rials != null ? formatRial(a.price_rials) : "—"}</td>
<td style={{ padding: "12px 16px" }}>
<span className={`badge ${APPT_STATUS_BADGE[a.status] ?? "gray"}`}>
{APPT_STATUS_LABELS[a.status] ?? a.status}
</span>
</td>
</tr>
))}
</tbody>
</table>
</div>
)
)}
{PLACEHOLDER_TABS[detailTab] && (
<div className="card" style={{ padding: "60px 24px", textAlign: "center", color: "var(--text-3)" }}>
<div style={{ fontWeight: 600, fontSize: 15, marginBottom: 8, color: "var(--text-2)" }}>
{PLACEHOLDER_TABS[detailTab]}
</div>
<div style={{ fontSize: 13 }}>این بخش به‌زودی اضافه می‌شود.</div>
</div>
)}
<VisitDetailModal
session={sessions.find((s) => s.uuid === expandedVisit) ?? null}
onClose={() => setExpandedVisit(null)}
settling={settleSessionMut.isPending}
onSettle={(uuid) => settleSessionMut.mutate(uuid)}
onViewInvoice={viewInvoice}
issuing={issueInvoiceMut.isPending}
onEdit={(s) => { setExpandedVisit(null); setEditSession(s); }}
canUpdate={canUpdate}
/>
<Modal open={!!invoiceUuid} title="فاکتور" size="md" onClose={() => setInvoiceUuid(null)}>
{!invoice ? (
<div style={{ padding: 24, textAlign: "center", color: "var(--text-3)" }}>در حال بارگذاری…</div>
) : (
<div style={{ display: "flex", flexDirection: "column", gap: 4 }}>
<div className="cp-info-row"><span className="cp-info-label">وضعیت</span><span className="cp-info-value">{invoice.status}</span></div>
<div className="cp-info-row"><span className="cp-info-label">مبلغ کل</span><span className="cp-info-value">{formatRial(invoice.total_rials ?? 0)}</span></div>
<div className="cp-info-row"><span className="cp-info-label">سهم بیمه پایه</span><span className="cp-info-value">{formatRial(invoice.base_insurance_rials ?? 0)}</span></div>
<div className="cp-info-row"><span className="cp-info-label">سهم بیمه تکمیلی</span><span className="cp-info-value">{formatRial(invoice.supplementary_rials ?? 0)}</span></div>
<div className="cp-info-row" style={{ borderBottom: "none" }}><span className="cp-info-label">سهم بیمار</span><span className="cp-info-value">{formatRial(invoice.patient_rials ?? 0)}</span></div>
{Array.isArray(invoice.items) && invoice.items.length > 0 && (
<div style={{ marginTop: 12 }}>
<div className="cp-section-title">اقلام</div>
{invoice.items.map((it: any, i: number) => (
<div key={i} className="cp-info-row">
<span className="cp-info-label">{it.title}</span>
<span className="cp-info-value">{formatRial(it.total_rials ?? 0)}</span>
</div>
))}
</div>
)}
</div>
)}
</Modal>
{/* Modal مراجعه جدید */}
<Modal
open={sessionModal}
onClose={() => setSessionModal(false)}
title="ثبت مراجعه جدید"
size="lg"
>
<form onSubmit={handleSubmitSession}>
<div
style={{
display: "flex",
flexDirection: "column",
gap: 14,
}}
>
<div style={{ fontWeight: 700, fontSize: 13.5, color: "var(--text-2)" }}>بیمه و مبلغ ویزیت</div>
<div
style={{
display: "grid",
gridTemplateColumns: "1fr 1fr",
gap: 12,
}}
>
<div className="field" style={{ flexDirection: "column", alignItems: "stretch", height: "auto", gap: 5, padding: 0, border: "none", background: "none" }}>
<label style={{ fontSize: 12.5, fontWeight: 600 }}>بیمه پایه</label>
<SearchableSelect
options={baseInsuranceOptions}
value={baseInsuranceId || null}
onChange={(v) => applyBaseInsurance(v ? String(v) : "")}
placeholder="بدون بیمه پایه"
isClearable
height={38}
/>
</div>
<div className="field" style={{ flexDirection: "column", alignItems: "stretch", height: "auto", gap: 5, padding: 0, border: "none", background: "none" }}>
<label style={{ fontSize: 12.5, fontWeight: 600 }}>بیمه تکمیلی</label>
<SearchableSelect
options={suppInsuranceOptions}
value={suppInsuranceId || null}
onChange={(v) => applySuppInsurance(v ? String(v) : "")}
placeholder="بدون بیمه تکمیلی"
isClearable
height={38}
/>
</div>
</div>
<div
style={{
display: "grid",
gridTemplateColumns: "1fr 1fr 1fr",
gap: 12,
}}
>
<div className="field">
<label>قیمت ویزیت (تومان)</label>
<input
{...numericField(form.register("visit_price_rials"))}
/>
</div>
<div className="field">
<label>تخفیف بیمه پایه (%)</label>
<input
{...numericField(form.register(
"base_insurance_discount_percent",
), 3)}
/>
</div>
<div className="field">
<label>تخفیف تکمیلی (%)</label>
<input
{...numericField(form.register(
"supplementary_discount_percent",
), 3)}
/>
</div>
</div>
<div style={{ fontWeight: 700, fontSize: 13.5, color: "var(--text-2)", borderTop: "1px solid var(--border)", paddingTop: 12 }}>پرداخت و یادداشت</div>
<div className="field" style={{ flexDirection: "column", alignItems: "stretch", height: "auto", gap: 5, padding: 0, border: "none", background: "none" }}>
<label>روش پرداخت</label>
<SearchableSelect
options={Object.entries(PAYMENT_LABELS).map(([v, l]) => ({ value: v, label: l }))}
value={form.watch("payment_method")}
onChange={(v) => form.setValue("payment_method", v as "cash" | "card" | "insurance" | "online" | "pending", { shouldDirty: true })}
height={38}
/>
</div>
<div className="field">
<label>یادداشت</label>
<textarea
{...form.register("notes")}
rows={2}
placeholder="یادداشت پزشک..."
/>
</div>
<div
style={{
borderTop: "1px solid var(--border)",
paddingTop: 12,
}}
>
<div
style={{
fontWeight: 600,
fontSize: 13,
marginBottom: 10,
}}
>
افزودن خدمات
</div>
<div
style={{
display: "flex",
gap: 8,
marginBottom: 10,
}}
>
<div style={{ flex: 1 }}>
<SearchableSelect
options={sectionOptions}
value={sectionUuid}
onChange={(v) => {
setSectionUuid(v ? String(v) : "");
setItemUuid("");
}}
placeholder="انتخاب بخش..."
/>
</div>
<div style={{ flex: 1 }}>
<SearchableSelect
options={itemOptions}
value={itemUuid}
onChange={(v) =>
setItemUuid(v ? String(v) : "")
}
placeholder="انتخاب سرویس..."
isDisabled={!sectionUuid}
/>
</div>
<button
type="button"
className="btn primary sm"
onClick={handleAddService}
disabled={!itemUuid}
>
<PlusIcon style={{ width: 14 }} />
</button>
</div>
{selectedServices.length > 0 && (
<div
style={{
display: "flex",
flexWrap: "wrap",
gap: 6,
marginBottom: 10,
}}
>
{selectedServices.map((svc) => (
<div
key={svc.service_item_uuid}
style={{
display: "inline-flex",
alignItems: "center",
gap: 8,
background:
"var(--primary-subtle)",
border: "1px solid oklch(0.85 0.06 256)",
borderRadius: 20,
padding: "4px 10px",
fontSize: 12.5,
}}
>
<span style={{ fontWeight: 500 }}>
{svc.name}
</span>
<span
style={{
color: "var(--primary)",
fontWeight: 600,
}}
>
{formatRial(svc.price_rials)}
</span>
<button
type="button"
style={{
background: "none",
border: "none",
cursor: "pointer",
color: "var(--danger)",
padding: 0,
fontSize: 14,
lineHeight: 1,
}}
onClick={() =>
setSelectedServices((p) =>
p.filter(
(s) =>
s.service_item_uuid !==
svc.service_item_uuid,
),
)
}
>
×
</button>
</div>
))}
</div>
)}
</div>
<div
style={{
background: "var(--primary-subtle)",
border: "1px solid oklch(0.88 0.05 256)",
borderRadius: 10,
padding: 14,
display: "flex",
flexDirection: "column",
gap: 6,
}}
>
{(() => {
const visit = Number(watchVisit);
const afterBase = Math.round(visit * (1 - Number(watchBase) / 100));
const afterSupp = Math.round(afterBase * (1 - Number(watchSupp) / 100));
const row = (label: string, val: number, muted = true) => (
<div style={{ display: "flex", justifyContent: "space-between", fontSize: 13, color: muted ? "var(--text-3)" : "var(--text)" }}>
<span>{label}</span>
<span>{formatRial(val)}</span>
</div>
);
return (
<>
{row("ویزیت آزاد", visit)}
{Number(watchBase) > 0 && row("پس از بیمه پایه", afterBase)}
{Number(watchSupp) > 0 && row("پس از بیمه تکمیلی", afterSupp)}
{servicesTotal > 0 && row("جمع خدمات", servicesTotal)}
</>
);
})()}
<div
style={{
display: "flex",
justifyContent: "space-between",
fontWeight: 700,
fontSize: 15,
borderTop: "1px solid oklch(0.88 0.05 256)",
paddingTop: 8,
marginTop: 2,
}}
>
<span>مبلغ نهایی (سهم بیمار)</span>
<span style={{ color: "var(--primary)" }}>
{formatRial(finalPrice)}
</span>
</div>
</div>
<div style={{ display: "flex", gap: 8 }}>
<button
type="submit"
className="btn primary"
disabled={createSessionMut.isPending}
>
{createSessionMut.isPending
? "در حال ذخیره..."
: "ثبت مراجعه"}
</button>
<button
type="button"
className="btn"
onClick={() => setSessionModal(false)}
>
انصراف
</button>
</div>
</div>
</form>
</Modal>
<EditSessionModal
session={editSession}
onClose={() => setEditSession(null)}
onSave={(uuid, body) => updateSessionMut.mutate({ uuid, body })}
loading={updateSessionMut.isPending}
/>
</>
);
}
function patientAvatar(_name: string, size = 32) {
return (
<div
style={{
width: size, height: size, borderRadius: "50%", flexShrink: 0,
background: "var(--accent, #F17732)",
color: "var(--on-primary)", display: "grid", placeItems: "center",
}}
>
<UserIcon style={{ width: size * 0.6, height: size * 0.6 }} />
</div>
);
}
function fileNumber(record: PatientRecord): string {
return `P-${record.uuid.slice(0, 8).toUpperCase()}`;
}
function PatientCard({
record,
onOpen,
}: {
record: PatientRecord;
onOpen: () => void;
}) {
const name = getPatientName(record);
const phone = getPatientPhone(record);
const [menu, setMenu] = React.useState(false);
const infoRow = (label: string, value: React.ReactNode) => (
<div style={{ display: "flex", justifyContent: "space-between", alignItems: "center", fontSize: 14, padding: "2px 0" }}>
<span style={{ color: "var(--text-3)" }}>{label}:</span>
<span dir="ltr" style={{ color: "var(--text-2)", fontWeight: 500 }}>{value}</span>
</div>
);
const menuItemStyle: React.CSSProperties = {
display: "flex", alignItems: "center", justifyContent: "flex-end", gap: 8, width: "100%",
padding: "8px 10px", borderRadius: 8, border: "none", cursor: "pointer",
background: "transparent", color: "var(--text-2)", fontSize: 13, fontFamily: "inherit", whiteSpace: "nowrap",
};
return (
<div
style={{
position: "relative", cursor: "pointer",
background: "var(--surface)", border: "1px solid var(--border)", borderRadius: 8,
boxShadow: "0 1px 24.8px rgba(204,204,204,0.18)", padding: 12,
}}
onClick={onOpen}
>
<div style={{ display: "flex", alignItems: "flex-start", justifyContent: "space-between", gap: 8 }}>
<button
className="btn sm ghost"
style={{ padding: 4 }}
onClick={(e) => { e.stopPropagation(); setMenu((v) => !v); }}
title="عملیات"
>
<EllipsisHorizontalIcon style={{ width: 20 }} />
</button>
<div style={{ display: "flex", alignItems: "flex-start", gap: 8 }}>
<b style={{ fontSize: 16, color: "var(--text)", textAlign: "right" }}>{name}</b>
{patientAvatar(name, 32)}
</div>
</div>
{menu && (
<>
<div style={{ position: "fixed", inset: 0, zIndex: 40 }} onClick={(e) => { e.stopPropagation(); setMenu(false); }} />
<div
style={{
position: "absolute", top: 38, left: 8, zIndex: 41,
background: "var(--surface)", border: "1px solid var(--border)",
borderRadius: "var(--r-sm)", boxShadow: "var(--shadow)", padding: 6, minWidth: 150,
display: "flex", flexDirection: "column", gap: 2,
}}
onClick={(e) => e.stopPropagation()}
>
<button style={menuItemStyle} onClick={() => { setMenu(false); onOpen(); }}>ویرایش <PencilIcon style={{ width: 15 }} /></button>
<button style={menuItemStyle} onClick={() => { setMenu(false); onOpen(); }}>مشاهده <EyeIcon style={{ width: 15 }} /></button>
</div>
</>
)}
<div style={{ height: 1, background: "var(--border)", margin: "12px 0" }} />
{infoRow("شماره پرونده", fileNumber(record))}
{infoRow("تلفن", phone)}
<div style={{ height: 1, background: "var(--border)", margin: "12px 0" }} />
<div style={{ display: "flex", justifyContent: "space-between", alignItems: "center", fontSize: 14 }}>
<span style={{ color: "var(--text-3)" }}>برچسب‌ها:</span>
<span style={{ display: "inline-flex", alignItems: "center", gap: 4, color: "var(--text-3)", fontSize: 13 }}>
افزودن <PlusIcon style={{ width: 15 }} />
</span>
</div>
</div>
);
}
function PatientRow({
record,
onOpen,
}: {
record: PatientRecord;
onOpen: () => void;
}) {
const name = getPatientName(record);
return (
<div
className="card"
style={{
padding: "12px 16px", display: "flex", alignItems: "center", gap: 14,
cursor: "pointer",
}}
onClick={onOpen}
>
{patientAvatar(name, 38)}
<b style={{ fontSize: 14, minWidth: 140 }}>{name}</b>
<span style={{ fontSize: 13, color: "var(--text-3)" }} dir="ltr">{fileNumber(record)}</span>
<span style={{ fontSize: 13, color: "var(--text-2)", flex: 1 }} dir="ltr">{getPatientPhone(record)}</span>
<button
className="btn primary sm"
onClick={(e) => { e.stopPropagation(); onOpen(); }}
>
<FolderOpenIcon style={{ width: 14 }} /> مشاهده
</button>
</div>
);
}
// کارت خلاصه هر مراجعه در تب «مراجعات»: تاریخ + خلاصه سرویس‌ها؛ کلیک → جزئیات سرویس‌های انجام‌شده.
function VisitSummaryCard({
session,
onOpen,
}: {
session: PatientSession;
onOpen: () => void;
}) {
const services = session.services ?? [];
const summary = services.length
? services.map((s) => s.service_name).join(" - ")
: "ویزیت";
const debt = session.patient_debt_rials ?? 0;
const paid = !!session.is_paid;
return (
<div
onClick={onOpen}
style={{
background: "var(--surface)",
border: "1px solid var(--border)",
borderRadius: 8,
boxShadow: "0 1px 24.8px rgba(204,204,204,0.18)",
padding: 12,
cursor: "pointer",
display: "flex",
flexDirection: "column",
}}
>
{/* سرِ کارت: تاریخ + خلاصه + وضعیت */}
<div style={{ display: "flex", justifyContent: "space-between", alignItems: "flex-start", gap: 8 }}>
<div style={{ display: "flex", flexDirection: "column", alignItems: "flex-end", gap: 6, textAlign: "right", minWidth: 0 }}>
<span style={{ fontWeight: 700, fontSize: 14, color: "var(--text)" }}>{formatDate(session.created_at)}</span>
<span style={{ fontSize: 12, color: "var(--text-2)", lineHeight: 1.6 }}>{summary}</span>
</div>
<div
style={{
width: 36, height: 36, borderRadius: 8, flexShrink: 0,
display: "grid", placeItems: "center",
background: paid ? "rgba(60,154,79,0.15)" : "rgba(241,119,50,0.15)",
}}
>
{paid
? <CheckCircleIcon style={{ width: 20, color: "var(--success)" }} />
: <ClockIcon style={{ width: 20, color: "var(--accent)" }} />}
</div>
</div>
<div style={{ height: 1, background: "var(--border)", margin: "12px 0" }} />
<div style={{ display: "flex", justifyContent: "space-between", alignItems: "center", gap: 8 }}>
<span style={{ color: "var(--text-3)", fontSize: 12 }}>مبلغ</span>
<span style={{ fontSize: 14, fontWeight: 700, color: "var(--primary)" }}>{formatRial(session.final_price_rials)}</span>
</div>
<div style={{ display: "flex", justifyContent: "space-between", alignItems: "center", gap: 8, marginTop: 6 }}>
<span style={{ color: "var(--text-3)", fontSize: 12 }}>مانده بدهی</span>
<span style={{ fontSize: 13, fontWeight: 500, color: debt > 0 ? "var(--danger)" : "var(--success)" }}>
{debt > 0 ? formatRial(debt) : "ندارد"}
</span>
</div>
<div style={{ display: "flex", alignItems: "center", justifyContent: "center", gap: 4, marginTop: 12, color: "var(--primary)", fontSize: 13, fontWeight: 600 }}>
مشاهده سرویس‌ها
<ChevronDownIcon style={{ width: 15 }} />
</div>
</div>
);
}
// جزئیات یک مراجعه: سرویس‌های انجام‌شده + خلاصه مالی + پرداخت/ویرایش.
function VisitDetailModal({
session,
onClose,
settling,
issuing,
onSettle,
onViewInvoice,
onEdit,
canUpdate,
}: {
session: PatientSession | null;
onClose: () => void;
settling: boolean;
issuing?: boolean;
onSettle: (uuid: string) => void;
/** کل session پاس می‌شود؛ نبودِ invoice_uuid یعنی caller باید فاکتور را صادر کند. */
onViewInvoice: (session: PatientSession) => void;
onEdit: (s: PatientSession) => void;
canUpdate: boolean;
}) {
if (!session) return null;
const services = session.services ?? [];
const assistant = services.find((s) => s.staff_name)?.staff_name || null;
const debt = session.patient_debt_rials ?? 0;
const paid = !!session.is_paid;
const baseDiscount = parseFloat(session.base_insurance_discount_percent) || 0;
const suppDiscount = parseFloat(session.supplementary_discount_percent) || 0;
const metaRow = (label: string, value: React.ReactNode, danger = false) => (
<div style={{ display: "flex", justifyContent: "space-between", alignItems: "center", gap: 8 }}>
<span style={{ color: "var(--text-3)", fontSize: 12 }}>{label}</span>
<span style={{ color: danger ? "var(--danger)" : "var(--text-2)", fontSize: 14, fontWeight: 500 }}>{value}</span>
</div>
);
return (
<Modal
open={!!session}
onClose={onClose}
title={`مراجعه ${formatDate(session.created_at)}`}
size="md"
footer={
<>
{canUpdate && (
<button className="cp-btn-ghost" onClick={() => onEdit(session)}>
<PencilIcon style={{ width: 15 }} /> ویرایش
</button>
)}
{paid ? (
<button className="cp-btn-secondary" disabled={issuing} onClick={() => onViewInvoice(session)}>
{issuing ? 'در حال صدور…' : 'مشاهده فاکتور'}
</button>
) : (
canUpdate && (
<button className="cp-btn-primary" disabled={settling} onClick={() => onSettle(session.uuid)}>تکمیل پرداخت</button>
)
)}
</>
}
>
<div style={{ display: "flex", flexDirection: "column", gap: 12 }}>
<div style={{ display: "flex", flexDirection: "column", gap: 10 }}>
{metaRow("انجام دهنده:", session.doctor_name || "—")}
{metaRow("دستیار:", assistant || "—")}
{metaRow("تاریخ:", formatDateTime(session.created_at))}
{metaRow(
"وضعیت پرداخت:",
paid
? <span className="badge green"><span className="bdot" />پرداخت شده</span>
: <span className="badge amber"><span className="bdot" />در انتظار</span>,
)}
{session.notes && (
<div style={{ fontSize: 12, color: "var(--text-3)", lineHeight: 1.6 }}>
توضیحات: <span style={{ color: "var(--text-2)", fontWeight: 500 }}>{session.notes}</span>
</div>
)}
</div>
<div style={{ height: 1, background: "var(--border)" }} />
{/* سرویس‌های انجام‌شده */}
<div>
<div style={{ fontWeight: 600, fontSize: 13.5, marginBottom: 8 }}>سرویس‌های انجام‌شده</div>
{services.length === 0 ? (
<div style={{ fontSize: 13, color: "var(--text-3)" }}>فقط ویزیت سرویس اضافه‌ای ثبت نشده است.</div>
) : (
<div style={{ display: "flex", flexDirection: "column", gap: 8 }}>
{services.map((sv) => (
<div
key={sv.uuid}
style={{
display: "flex", justifyContent: "space-between", alignItems: "center", gap: 8,
border: "1px solid var(--border)", borderRadius: 8, padding: "10px 12px",
}}
>
<div style={{ minWidth: 0 }}>
<div style={{ fontSize: 13.5, fontWeight: 600, color: "var(--text)" }}>{sv.service_name}</div>
<div style={{ fontSize: 12, color: "var(--text-3)", marginTop: 2 }}>
{sv.staff_name ? `${sv.staff_name} · ` : ""}تعداد: {formatNumber(sv.quantity)}
</div>
</div>
<div style={{ fontSize: 13.5, fontWeight: 600, color: "var(--text-2)", flexShrink: 0 }}>
{formatRial(sv.line_total_rials)}
</div>
</div>
))}
</div>
)}
</div>
<div style={{ height: 1, background: "var(--border)" }} />
{/* خلاصه مالی */}
<div style={{ display: "flex", flexDirection: "column", gap: 8 }}>
{metaRow("قیمت ویزیت:", formatRial(session.visit_price_rials))}
{baseDiscount > 0 && metaRow("تخفیف بیمه پایه:", `${formatNumber(baseDiscount)}٪`)}
{suppDiscount > 0 && metaRow("تخفیف بیمه تکمیلی:", `${formatNumber(suppDiscount)}٪`)}
{session.services_total_rials > 0 && metaRow("جمع خدمات:", formatRial(session.services_total_rials))}
<div style={{ display: "flex", justifyContent: "space-between", alignItems: "center", gap: 8, borderTop: "1px solid var(--border)", paddingTop: 8, fontWeight: 700, fontSize: 15 }}>
<span>مبلغ نهایی</span>
<span style={{ color: "var(--primary)" }}>{formatRial(session.final_price_rials)}</span>
</div>
{metaRow("مانده بدهی:", debt > 0 ? formatRial(debt) : "ندارد", debt > 0)}
</div>
</div>
</Modal>
);
}
function EditSessionModal({
session,
onClose,
onSave,
loading,
}: {
session: PatientSession | null;
onClose: () => void;
onSave: (uuid: string, body: object) => void;
loading: boolean;
}) {
const [notes, setNotes] = useState("");
const [method, setMethod] = useState("cash");
React.useEffect(() => {
if (session) {
setNotes(session.notes ?? "");
setMethod(session.payment_method);
}
}, [session]);
return (
<Modal open={!!session} onClose={onClose} title="ویرایش مراجعه">
<div style={{ display: "flex", flexDirection: "column", gap: 12 }}>
<div className="field" style={{ flexDirection: "column", alignItems: "stretch", height: "auto", gap: 5, padding: 0, border: "none", background: "none" }}>
<label>روش پرداخت</label>
<SearchableSelect
options={Object.entries(PAYMENT_LABELS).map(([v, l]) => ({ value: v, label: l }))}
value={method}
onChange={(v) => setMethod(v ? String(v) : "cash")}
height={38}
/>
</div>
<div className="field">
<label>یادداشت</label>
<textarea
value={notes}
onChange={(e) => setNotes(e.target.value)}
rows={3}
/>
</div>
<div style={{ display: "flex", gap: 8 }}>
<button
className="btn primary"
disabled={loading}
onClick={() =>
session &&
onSave(session.uuid, {
notes,
payment_method: method,
})
}
>
{loading ? "در حال ذخیره..." : "ذخیره"}
</button>
<button className="btn" onClick={onClose}>
انصراف
</button>
</div>
</div>
</Modal>
);
}
export default function MyPatientsPage() {
return (
<FeatureGate feature="patient_records">
<MyPatientsPageInner />
</FeatureGate>
);
}