feat: redesign new-visit modal with insurance selects and live breakdown

Sectioned layout (insurance & price / services / payment / summary),
base+supplementary insurance selects auto-filling visit price and
discount from insurance-pricing, live cost breakdown, unified تومان unit.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
hamed
2026-06-23 15:15:12 +03:30
co-authored by Claude Opus 4.8
parent 89191eee57
commit 59fb590d65
2 changed files with 215 additions and 14 deletions
+126 -14
View File
@@ -40,11 +40,24 @@ 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: "کارت",
@@ -116,6 +129,8 @@ function MyPatientsPageInner() {
>([]);
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("");
@@ -174,6 +189,12 @@ function MyPatientsPageInner() {
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;
@@ -192,6 +213,41 @@ function MyPatientsPageInner() {
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),
@@ -202,6 +258,8 @@ function MyPatientsPageInner() {
setSessionModal(false);
form.reset();
setSelectedServices([]);
setBaseInsuranceId("");
setSuppInsuranceId("");
toast.success("مراجعه ثبت شد");
},
onError: (e: any) => toast.error(e.message),
@@ -650,6 +708,8 @@ function MyPatientsPageInner() {
onClick={() => {
form.reset();
setSelectedServices([]);
setBaseInsuranceId("");
setSuppInsuranceId("");
setSessionModal(true);
}}
>
@@ -777,6 +837,8 @@ function MyPatientsPageInner() {
onClick={() => {
form.reset();
setSelectedServices([]);
setBaseInsuranceId("");
setSuppInsuranceId("");
setSessionModal(true);
}}
>
@@ -809,6 +871,7 @@ function MyPatientsPageInner() {
open={sessionModal}
onClose={() => setSessionModal(false)}
title="ثبت مراجعه جدید"
size="lg"
>
<form onSubmit={handleSubmitSession}>
<div
@@ -818,6 +881,41 @@ function MyPatientsPageInner() {
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",
@@ -826,7 +924,7 @@ function MyPatientsPageInner() {
}}
>
<div className="field">
<label>قیمت ویزیت (ریال)</label>
<label>قیمت ویزیت (تومان)</label>
<input
{...form.register("visit_price_rials")}
type="number"
@@ -859,6 +957,7 @@ function MyPatientsPageInner() {
/>
</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")}>
@@ -1004,29 +1103,42 @@ function MyPatientsPageInner() {
border: "1px solid oklch(0.88 0.05 256)",
borderRadius: 10,
padding: 14,
display: "flex",
flexDirection: "column",
gap: 6,
}}
>
<div
style={{
display: "flex",
justifyContent: "space-between",
marginBottom: 6,
fontSize: 13,
color: "var(--text-3)",
}}
>
<span>جمع خدمات</span>
<span>{formatRial(servicesTotal)}</span>
</div>
{(() => {
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>مبلغ نهایی (سهم بیمار)</span>
<span style={{ color: "var(--primary)" }}>
{formatRial(finalPrice)}
</span>