Files
clinicpro/assets/admin/pages/MyPatientsPage.tsx
T

1635 lines
68 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 {
CheckCircleIcon,
ChevronRightIcon,
FolderOpenIcon,
IdentificationIcon,
MagnifyingGlassIcon,
PencilIcon,
PhoneIcon,
PlusIcon,
UserPlusIcon,
UsersIcon,
} 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 { 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 [selectedRecord, setSelectedRecord] = useState<PatientRecord | null>(
null,
);
const [page, setPage] = useState(1);
const [search, setSearch] = useState("");
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 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 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 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>
</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={{
display: "grid",
gridTemplateColumns:
"repeat(auto-fill, minmax(280px, 1fr))",
gap: 14,
}}
>
{records.map((r) => (
<PatientCard
key={r.uuid}
record={r}
onOpen={() => {
setSelectedRecord(r);
setSessionPage(1);
}}
/>
))}
</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={() => {
form.reset();
setSelectedServices([]);
setBaseInsuranceId("");
setSuppInsuranceId("");
setSessionModal(true);
}}
>
<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>
{patientProfile && (
<div className="card" style={{ padding: 16, marginBottom: 16 }}>
<div style={{ fontWeight: 600, fontSize: 14, marginBottom: 12 }}>اطلاعات بیمار</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>
)}
<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={() => {
form.reset();
setSelectedServices([]);
setBaseInsuranceId("");
setSuppInsuranceId("");
setSessionModal(true);
}}
>
<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 = 28) {
return (
<div
style={{
width: size, height: size, borderRadius: "50%", flexShrink: 0,
background: "linear-gradient(145deg, #f8945a, #f0682a)",
color: "#fff", display: "flex", alignItems: "center",
justifyContent: "center", fontWeight: 700, fontSize: size * 0.42,
}}
>
{(name === "—" ? "؟" : name).charAt(0)}
</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 infoRow = (label: string, value: React.ReactNode) => (
<div style={{ display: "flex", justifyContent: "space-between", alignItems: "center", fontSize: 13 }}>
<span style={{ color: "var(--text-3)" }}>{label}:</span>
<span dir="ltr" style={{ color: "var(--text-2)" }}>{value}</span>
</div>
);
return (
<div
className="card"
style={{ padding: 16, display: "flex", flexDirection: "column", gap: 12, cursor: "pointer" }}
onClick={onOpen}
>
<div style={{ display: "flex", alignItems: "center", justifyContent: "space-between" }}>
<span className="mini-btn" style={{ pointerEvents: "none" }}>···</span>
<div style={{ display: "flex", alignItems: "center", gap: 8 }}>
<b style={{ fontSize: 14 }}>{name}</b>
{patientAvatar(name)}
</div>
</div>
{infoRow("شماره پرونده", fileNumber(record))}
{infoRow("موبایل", phone)}
<div style={{ display: "flex", justifyContent: "space-between", alignItems: "center", fontSize: 13 }}>
<span style={{ color: "var(--text-3)" }}>برچسب‌ها:</span>
<span style={{ color: "var(--text-3)" }}></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>
);
}