Files
clinicpro/assets/admin/pages/MyPatientsPage.tsx
T
hamedandClaude Opus 4.8 3cf27aa8e4 style(admin): patient record cards match Figma patients-card
Restyle PatientCard to the Figma patient card: overflow menu (edit/view),
name + orange user avatar, dividers, file number + phone rows, tags row.
Avatar now a solid accent circle with user icon.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-13 09:33:31 +03:30

1951 lines
87 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,
CheckCircleIcon,
ChevronRightIcon,
FolderOpenIcon,
MagnifyingGlassIcon,
PencilIcon,
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-dom";
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 type { ApiResponse, PaginatedResponse } from "../lib/api";
import { api } from "../lib/api";
import {
formatDate,
formatDateTime,
formatNumber,
formatRial,
} from "../lib/utils";
import type {
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 PAYMENT_LABELS: Record<string, string> = {
cash: "نقدی",
card: "کارت",
insurance: "بیمه",
online: "آنلاین",
pending: "در انتظار",
};
const PAYMENT_BADGE: Record<string, string> = {
cash: "green",
card: "blue",
insurance: "purple",
online: "blue",
pending: "amber",
};
const EMPTY_RECORDS: PatientRecord[] = [];
const EMPTY_SESSIONS: PatientSession[] = [];
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();
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" | "services" | "sessions">("info");
const [invoiceUuid, setInvoiceUuid] = useState<string | null>(null);
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 EMPTY_EDIT = {
name: "", family: "", national_code: "", gender: "", blood_type: "",
marital_status: "", job: "", home_phone: "", work_phone: "", address: "",
basic_insurance_id: "", supplementary_insurance_id: "",
};
const [editOpen, setEditOpen] = useState(false);
const [editForm, setEditForm] = useState<Record<string, string>>(EMPTY_EDIT);
const setEditField = (k: string, v: string) => setEditForm((f) => ({ ...f, [k]: v }));
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;
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: 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,
});
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 totalRec = recordsData?.meta?.totalRecords ?? 0;
const totalSes = sessionsData?.meta?.totalRecords ?? 0;
const selectedPatientName = getPatientName(selectedRecord);
const selectedPatientPhone = getPatientPhone(selectedRecord);
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 }));
// درصد تخفیف معادلِ سهم بیمار بر اساس قیمت آزاد. share=null یعنی پوشش ندارد (۰٪).
const shareToDiscountPercent = (insuranceId: string): number => {
if (!insuranceId || freeVisitPrice <= 0) return 0;
const ins = pricing?.insurances.find((i) => String(i.insurance_id) === insuranceId);
if (!ins || ins.patient_share_rials == null) return 0;
const covered = Math.max(0, freeVisitPrice - ins.patient_share_rials);
return Math.round((covered / freeVisitPrice) * 1000) / 10;
};
const applyBaseInsurance = (insuranceId: string) => {
setBaseInsuranceId(insuranceId);
form.setValue("insurance_base_id", insuranceId ? Number(insuranceId) : undefined);
if (insuranceId && freeVisitPrice > 0) {
form.setValue("visit_price_rials", freeVisitPrice);
form.setValue("base_insurance_discount_percent", shareToDiscountPercent(insuranceId));
}
};
const applySuppInsurance = (insuranceId: string) => {
setSuppInsuranceId(insuranceId);
form.setValue("insurance_supplementary_id", insuranceId ? Number(insuranceId) : undefined);
if (insuranceId) {
form.setValue("supplementary_discount_percent", shareToDiscountPercent(insuranceId));
}
};
const openEditInfo = () => {
const p = patientProfile;
setEditForm({
name: p?.name ?? "",
family: p?.family ?? "",
national_code: p?.national_code ?? "",
gender: p?.gender ?? "",
blood_type: p?.blood_type ?? "",
marital_status: p?.marital_status ?? "",
job: p?.job ?? "",
home_phone: p?.home_phone ?? "",
work_phone: p?.work_phone ?? "",
address: p?.address ?? "",
basic_insurance_id: p?.basic_insurance_id != null ? String(p.basic_insurance_id) : "",
supplementary_insurance_id: p?.supplementary_insurance_id != null ? String(p.supplementary_insurance_id) : "",
});
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 submitEditInfo = () => {
if (!editForm.name.trim()) {
toast.error("نام بیمار الزامی است");
return;
}
updatePatientMut.mutate({
name: editForm.name.trim(),
family: editForm.family.trim() || null,
national_code: editForm.national_code.trim(),
gender: editForm.gender || null,
blood_type: editForm.blood_type.trim() || null,
marital_status: editForm.marital_status || null,
job: editForm.job.trim() || null,
home_phone: editForm.home_phone.trim() || null,
work_phone: editForm.work_phone.trim() || null,
address: editForm.address.trim() || null,
basic_insurance_id: editForm.basic_insurance_id ? Number(editForm.basic_insurance_id) : null,
supplementary_insurance_id: editForm.supplementary_insurance_id ? Number(editForm.supplementary_insurance_id) : null,
});
};
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={
<button
className="btn primary sm"
onClick={() => setCreateRecordOpen(true)}
>
<UserPlusIcon style={{ width: 16 }} />
پرونده جدید
</button>
}
/>
<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: "#fff",
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>
<button
className="btn primary sm"
onClick={() => navigate(`/admin/my-patients/${selectedRecord.uuid}/session/new`)}
>
<PlusIcon style={{ width: 15 }} /> مراجعه جدید
</button>
</div>
}
/>
{/* بنر اطلاعات بیمار */}
<div
style={{
background:
"linear-gradient(135deg, oklch(0.52 0.22 256), oklch(0.40 0.18 256))",
borderRadius: "var(--r)",
padding: "16px 20px",
marginBottom: 16,
display: "flex",
alignItems: "center",
gap: 16,
color: "#fff",
}}
>
<div
style={{
width: 48,
height: 48,
borderRadius: "50%",
background: "rgba(255,255,255,0.2)",
display: "flex",
alignItems: "center",
justifyContent: "center",
fontSize: 20,
fontWeight: 700,
flexShrink: 0,
}}
>
{(selectedPatientName === "—"
? "?"
: selectedPatientName
).charAt(0)}
</div>
<div style={{ flex: 1 }}>
<div style={{ fontWeight: 700, fontSize: 17 }}>
{selectedPatientName}
</div>
<div
style={{
fontSize: 13,
opacity: 0.85,
marginTop: 2,
display: "flex",
alignItems: "center",
gap: 6,
}}
>
<PhoneIcon style={{ width: 13 }} />
<span dir="ltr">{selectedPatientPhone}</span>
</div>
</div>
<div
style={{
textAlign: "center",
background: "rgba(255,255,255,0.15)",
borderRadius: 10,
padding: "8px 16px",
}}
>
<div style={{ fontSize: 22, fontWeight: 800 }}>
{totalSes}
</div>
<div style={{ fontSize: 12, opacity: 0.85 }}>مراجعه</div>
</div>
</div>
<div className="cp-tabs" style={{ display: "flex", gap: 4, borderBottom: "1px solid var(--border)", marginBottom: 16 }}>
{([["info", "اطلاعات پرونده"], ["services", "سرویس‌ها"], ["sessions", `تاریخچه مراجعات (${totalSes})`]] as const).map(([key, label]) => (
<button
key={key}
onClick={() => setDetailTab(key)}
style={{
padding: "10px 16px", fontSize: 13.5, fontWeight: 600, cursor: "pointer",
background: "none", border: "none",
color: detailTab === key ? "var(--primary)" : "var(--text-2)",
borderBottom: `2px solid ${detailTab === key ? "var(--primary)" : "transparent"}`,
marginBottom: -1,
}}
>
{label}
</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>
<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.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.job],
["موبایل", patientProfile.mobile],
["تلفن منزل", patientProfile.home_phone],
["بیمه پایه", patientProfile.basic_insurance_name],
["بیمه تکمیلی", patientProfile.supplementary_insurance_name],
["آدرس", patientProfile.address],
] 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)}
footer={
<>
<button className="cp-btn-ghost" onClick={() => setEditOpen(false)}>انصراف</button>
<button className="cp-btn-primary" onClick={submitEditInfo} disabled={updatePatientMut.isPending}>
{updatePatientMut.isPending ? "در حال ذخیره…" : "ذخیره"}
</button>
</>
}
>
<div style={{ display: "grid", gridTemplateColumns: "repeat(auto-fill, minmax(200px, 1fr))", gap: 12 }}>
<div>
<label className="cp-label">نام</label>
<input className="cp-input" value={editForm.name} onChange={(e) => setEditField("name", e.target.value)} />
</div>
<div>
<label className="cp-label">نام خانوادگی</label>
<input className="cp-input" value={editForm.family} onChange={(e) => setEditField("family", e.target.value)} />
</div>
<div>
<label className="cp-label">کد ملی</label>
<input className="cp-input" dir="ltr" inputMode="numeric" maxLength={10} value={editForm.national_code} onChange={(e) => setEditField("national_code", e.target.value.replace(/\D/g, ""))} />
</div>
<div>
<label className="cp-label">جنسیت</label>
<select className="cp-select" value={editForm.gender} onChange={(e) => setEditField("gender", e.target.value)}>
<option value=""></option>
<option value="male">مرد</option>
<option value="female">زن</option>
</select>
</div>
<div>
<label className="cp-label">گروه خونی</label>
<input className="cp-input" value={editForm.blood_type} onChange={(e) => setEditField("blood_type", e.target.value)} />
</div>
<div>
<label className="cp-label">وضعیت تأهل</label>
<select className="cp-select" value={editForm.marital_status} onChange={(e) => setEditField("marital_status", e.target.value)}>
<option value=""></option>
<option value="single">مجرد</option>
<option value="married">متأهل</option>
</select>
</div>
<div>
<label className="cp-label">شغل</label>
<input className="cp-input" value={editForm.job} onChange={(e) => setEditField("job", e.target.value)} />
</div>
<div>
<label className="cp-label">تلفن منزل</label>
<input className="cp-input" dir="ltr" value={editForm.home_phone} onChange={(e) => setEditField("home_phone", e.target.value)} />
</div>
<div>
<label className="cp-label">تلفن محل کار</label>
<input className="cp-input" dir="ltr" value={editForm.work_phone} onChange={(e) => setEditField("work_phone", e.target.value)} />
</div>
<div>
<label className="cp-label">بیمه پایه</label>
<select className="cp-select" value={editForm.basic_insurance_id} onChange={(e) => setEditField("basic_insurance_id", e.target.value)}>
<option value=""></option>
{baseInsuranceOptions.map((o) => <option key={o.value} value={o.value}>{o.label}</option>)}
</select>
</div>
<div>
<label className="cp-label">بیمه تکمیلی</label>
<select className="cp-select" value={editForm.supplementary_insurance_id} onChange={(e) => setEditField("supplementary_insurance_id", e.target.value)}>
<option value=""></option>
{suppInsuranceOptions.map((o) => <option key={o.value} value={o.value}>{o.label}</option>)}
</select>
</div>
</div>
<div style={{ marginTop: 12 }}>
<label className="cp-label">آدرس</label>
<textarea className="cp-textarea" rows={2} value={editForm.address} onChange={(e) => setEditField("address", e.target.value)} />
</div>
<div style={{ marginTop: 12, fontSize: 12, color: "var(--text-3)" }}>
شماره موبایل ({patientProfile?.mobile}) شناسه‌ی حساب بیمار است و از این‌جا قابل تغییر نیست.
</div>
</Modal>
{detailTab === "services" && (
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 style={{ display: "grid", gridTemplateColumns: "repeat(auto-fill, minmax(280px, 1fr))", gap: 16 }}>
{sessions.map((s) => {
const svcNames = (s.services ?? []).map((x) => x.service_name).join("، ") || "ویزیت";
const debt = s.patient_debt_rials ?? 0;
return (
<div key={s.uuid} className="card" style={{ padding: 16, display: "flex", flexDirection: "column", gap: 8 }}>
<div style={{ display: "flex", justifyContent: "space-between", alignItems: "center", gap: 8 }}>
<div style={{ fontWeight: 700, fontSize: 14, lineHeight: 1.5 }}>{svcNames}</div>
{s.is_paid
? <span className="badge green"><span className="bdot" />تسویه شده</span>
: <span className="badge amber"><span className="bdot" />تسویه نشده</span>}
</div>
<div className="cp-info-row"><span className="cp-info-label">انجام‌دهنده</span><span className="cp-info-value">{s.doctor_name || "—"}</span></div>
<div className="cp-info-row"><span className="cp-info-label">تاریخ</span><span className="cp-info-value">{formatDate(s.created_at)}</span></div>
<div className="cp-info-row"><span className="cp-info-label">هزینه</span><span className="cp-info-value">{formatRial(s.final_price_rials)}</span></div>
<div className="cp-info-row" style={{ borderBottom: "none" }}>
<span className="cp-info-label">مانده بدهی</span>
<span className="cp-info-value" style={{ color: debt > 0 ? "var(--danger)" : "var(--success)" }}>
{debt > 0 ? formatRial(debt) : "ندارد"}
</span>
</div>
{s.is_paid ? (
s.invoice_uuid ? (
<button className="cp-btn-secondary" style={{ width: "100%", marginTop: 4 }} onClick={() => setInvoiceUuid(s.invoice_uuid!)}>مشاهده فاکتور</button>
) : (
<button className="cp-btn-secondary" style={{ width: "100%", marginTop: 4 }} disabled>پرداخت شده</button>
)
) : (
<button
className="cp-btn-primary"
style={{ width: "100%", marginTop: 4 }}
disabled={settleSessionMut.isPending}
onClick={() => settleSessionMut.mutate(s.uuid)}
>
تکمیل پرداخت
</button>
)}
</div>
);
})}
</div>
)
)}
<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>
{detailTab === "sessions" && (
<div className="card">
<div
style={{
fontWeight: 600,
padding: "14px 16px 10px",
borderBottom: "1px solid var(--border)",
fontSize: 14,
}}
>
تاریخچه مراجعات
</div>
{sessionsLoading ? (
<div
style={{
padding: 32,
textAlign: "center",
color: "var(--text-3)",
}}
>
در حال بارگذاری...
</div>
) : sessions.length === 0 ? (
<div
style={{
padding: "48px 24px",
textAlign: "center",
color: "var(--text-3)",
}}
>
<FolderOpenIcon
style={{
width: 40,
margin: "0 auto 12px",
display: "block",
opacity: 0.4,
}}
/>
<div
style={{
fontWeight: 600,
marginBottom: 6,
color: "var(--text-2)",
}}
>
مراجعه‌ای ثبت نشده است
</div>
<button
className="btn primary sm"
style={{ marginTop: 12 }}
onClick={() => navigate(`/admin/my-patients/${selectedRecord.uuid}/session/new`)}
>
<PlusIcon style={{ width: 14 }} /> ثبت اولین مراجعه
</button>
</div>
) : (
sessions.map((s) => (
<SessionRow
key={s.uuid}
session={s}
onEdit={setEditSession}
/>
))
)}
{totalSes > 20 && (
<div style={{ padding: "0 16px 12px" }}>
<Pagination
page={sessionPage}
total={totalSes}
limit={20}
onPageChange={setSessionPage}
/>
</div>
)}
</div>
)}
{/* 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>
<select
className="input"
value={baseInsuranceId}
onChange={(e) => applyBaseInsurance(e.target.value)}
>
<option value="">بدون بیمه پایه</option>
{baseInsuranceOptions.map((o) => (
<option key={o.value} value={o.value}>{o.label}</option>
))}
</select>
</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>
<select
className="input"
value={suppInsuranceId}
onChange={(e) => applySuppInsurance(e.target.value)}
>
<option value="">بدون بیمه تکمیلی</option>
{suppInsuranceOptions.map((o) => (
<option key={o.value} value={o.value}>{o.label}</option>
))}
</select>
</div>
</div>
<div
style={{
display: "grid",
gridTemplateColumns: "1fr 1fr 1fr",
gap: 12,
}}
>
<div className="field">
<label>قیمت ویزیت (تومان)</label>
<input
{...form.register("visit_price_rials")}
type="number"
min={0}
dir="ltr"
/>
</div>
<div className="field">
<label>تخفیف بیمه پایه (%)</label>
<input
{...form.register(
"base_insurance_discount_percent",
)}
type="number"
min={0}
max={100}
dir="ltr"
/>
</div>
<div className="field">
<label>تخفیف تکمیلی (%)</label>
<input
{...form.register(
"supplementary_discount_percent",
)}
type="number"
min={0}
max={100}
dir="ltr"
/>
</div>
</div>
<div style={{ fontWeight: 700, fontSize: 13.5, color: "var(--text-2)", borderTop: "1px solid var(--border)", paddingTop: 12 }}>پرداخت و یادداشت</div>
<div className="field">
<label>روش پرداخت</label>
<select {...form.register("payment_method")}>
{Object.entries(PAYMENT_LABELS).map(
([v, l]) => (
<option key={v} value={v}>
{l}
</option>
),
)}
</select>
</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: "#fff", 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 SessionRow({
session,
onEdit,
}: {
session: PatientSession;
onEdit: (s: PatientSession) => void;
}) {
const [open, setOpen] = useState(false);
const badgeColor = PAYMENT_BADGE[session.payment_method] ?? "gray";
return (
<div
style={{
borderBottom: "1px solid var(--border)",
transition: "background 0.1s",
}}
>
<div
style={{
display: "flex",
justifyContent: "space-between",
alignItems: "center",
padding: "12px 16px",
cursor: "pointer",
}}
onClick={() => setOpen(!open)}
>
<div style={{ display: "flex", gap: 14, alignItems: "center" }}>
<div
style={{
width: 8,
height: 8,
borderRadius: "50%",
background: open
? "var(--primary)"
: "var(--border)",
transition: "background 0.15s",
flexShrink: 0,
}}
/>
<span style={{ fontSize: 13, color: "var(--text-3)" }}>
{formatDateTime(session.created_at)}
</span>
<span className={`badge ${badgeColor}`}>
{PAYMENT_LABELS[session.payment_method] ??
session.payment_method}
</span>
<b style={{ fontSize: 14, color: "var(--primary)" }}>
{formatRial(session.final_price_rials)}
</b>
</div>
<button
className="btn sm"
onClick={(e) => {
e.stopPropagation();
onEdit(session);
}}
title="ویرایش مراجعه"
>
<PencilIcon style={{ width: 14 }} />
</button>
</div>
{open && (
<div style={{ padding: "0 16px 14px" }}>
<div
style={{
background: "var(--surface)",
border: "1px solid var(--border)",
borderRadius: 10,
padding: 14,
display: "grid",
gridTemplateColumns: "1fr 1fr 1fr",
gap: 12,
fontSize: 13,
}}
>
<div>
<div
style={{
color: "var(--text-3)",
marginBottom: 4,
fontSize: 12,
}}
>
قیمت ویزیت
</div>
<div style={{ fontWeight: 600 }}>
{formatRial(session.visit_price_rials)}
</div>
</div>
<div>
<div
style={{
color: "var(--text-3)",
marginBottom: 4,
fontSize: 12,
}}
>
تخفیف بیمه پایه
</div>
<div style={{ fontWeight: 600 }}>
{formatNumber(
parseFloat(
session.base_insurance_discount_percent,
),
)}
٪
</div>
</div>
<div>
<div
style={{
color: "var(--text-3)",
marginBottom: 4,
fontSize: 12,
}}
>
جمع خدمات
</div>
<div style={{ fontWeight: 600 }}>
{formatRial(session.services_total_rials)}
</div>
</div>
{session.notes && (
<div
style={{
gridColumn: "1 / -1",
borderTop: "1px solid var(--border)",
paddingTop: 10,
marginTop: 2,
}}
>
<div
style={{
color: "var(--text-3)",
marginBottom: 4,
fontSize: 12,
}}
>
یادداشت
</div>
<div style={{ lineHeight: 1.6 }}>
{session.notes}
</div>
</div>
)}
</div>
</div>
)}
</div>
);
}
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">
<label>روش پرداخت</label>
<select
value={method}
onChange={(e) => setMethod(e.target.value)}
>
{Object.entries(PAYMENT_LABELS).map(([v, l]) => (
<option key={v} value={v}>
{l}
</option>
))}
</select>
</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>
);
}