1635 lines
68 KiB
TypeScript
1635 lines
68 KiB
TypeScript
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>
|
||
);
|
||
}
|