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:
@@ -0,0 +1,89 @@
|
|||||||
|
# بازطراحی UI/UX مودال «ثبت مراجعه جدید»
|
||||||
|
|
||||||
|
## پروژه
|
||||||
|
|
||||||
|
`clinicpro` (admin frontend)
|
||||||
|
|
||||||
|
## زمینه
|
||||||
|
|
||||||
|
مودال «ثبت مراجعه جدید» در صفحهی پرونده بیمار، UX ضعیفی دارد: فیلدها بدون گروهبندی، label و input بدون فاصلهی منطقی، بخش خدمات و خلاصهی قیمت بیسازمان، و ارتباط بین «قیمت ویزیت / تخفیف بیمه پایه / تخفیف مکمل» با بیمهی واقعی بیمار مشخص نیست. (تصویر فعلی توسط کاربر ارائه شده.)
|
||||||
|
|
||||||
|
## مشکل / هدف
|
||||||
|
|
||||||
|
بازطراحی مودال با اصول UI/UX حرفهای:
|
||||||
|
|
||||||
|
- گروهبندی منطقی: «بیمه و مبلغ» / «خدمات» / «پرداخت و یادداشت» / «خلاصه»
|
||||||
|
- انتخاب بیمهی پایه و مکمل بهصورت select (از بیمههای entity)، و **پرکردن خودکار قیمت ویزیت و تخفیف/سهم** بر اساس قیمتگذاری بیمهی پروفایل (خروجی پرامپت `insurance-visit-pricing.md`)
|
||||||
|
- نمایش زندهی محاسبه: آزاد → با بیمه پایه → با بیمه مکمل → مبلغ نهایی
|
||||||
|
- واحد پول یکدست (تومان یا ریال — یکی، نه مخلوط؛ الان «ریال» در ورودی و «تومان» در خلاصه مخلوط است)
|
||||||
|
- فاصلهگذاری، عنوان بخشها، حالتهای loading/disabled درست
|
||||||
|
|
||||||
|
## فایلهای مرتبط
|
||||||
|
|
||||||
|
| فایل | نقش |
|
||||||
|
|------|-----|
|
||||||
|
| `assets/admin/pages/MyPatientsPage.tsx` | مودال `sessionModal` (حدود خط ۸۰۹ به بعد) و `handleSubmitSession` |
|
||||||
|
| `assets/admin/components/ui/Modal.tsx` | کامپوننت مودال (props: `size` = sm/md/lg/xl) |
|
||||||
|
| `assets/admin/components/ui/SearchableSelect.tsx` | select قابلجستجو (برای بیمه/خدمات) |
|
||||||
|
| `assets/admin/lib/utils.ts` | `formatRial`, `formatNumber` |
|
||||||
|
| `src/Patient/Controller/PatientController.php` | endpoint `POST /api/v1/patient/{uuid}/session` |
|
||||||
|
| `src/Patient/Service/PatientService.php` | محاسبهی نهایی سمت سرور |
|
||||||
|
|
||||||
|
## وضعیت فعلی
|
||||||
|
|
||||||
|
- اسکیمای فرم:
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
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),
|
||||||
|
payment_method: z.enum(["cash", "card", "insurance", "online", "pending"]),
|
||||||
|
notes: z.string().optional(),
|
||||||
|
});
|
||||||
|
```
|
||||||
|
|
||||||
|
- محاسبهی قیمت سمت کلاینت:
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
function calcFinalPrice(visitPrice, baseDiscount, suppDiscount, servicesTotal) {
|
||||||
|
const afterBase = visitPrice * (1 - baseDiscount / 100);
|
||||||
|
const afterSupp = afterBase * (1 - suppDiscount / 100);
|
||||||
|
return Math.round(afterSupp) + servicesTotal;
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
- خلاصهی پایین مودال «جمع خدمات» و «مبلغ نهایی» را با «تومان» نشان میدهد ولی ورودیها «ریال» هستند — ناهماهنگ.
|
||||||
|
- backend (`PatientService::createSession`) همین فیلدها + `insurance_base_id` / `insurance_supplementary_id` را میپذیرد (موجودیت `PatientSession` این دو ستون را دارد) اما فرم فعلی آنها را ارسال نمیکند.
|
||||||
|
|
||||||
|
## وظایف
|
||||||
|
|
||||||
|
### ۱. ساختار بصری مودال
|
||||||
|
|
||||||
|
مودال را به بخشهای واضح با عنوان تقسیم کن (میتوانی `size="lg"` بدهی):
|
||||||
|
|
||||||
|
1. **بیمه و مبلغ ویزیت**: select بیمهی پایه + select بیمهی مکمل + قیمت ویزیت + تخفیف/سهم
|
||||||
|
2. **خدمات**: انتخاب بخش/سرویس + chipهای انتخابشده (الگوی فعلی، فقط منظمتر)
|
||||||
|
3. **پرداخت و یادداشت**: روش پرداخت + یادداشت
|
||||||
|
4. **خلاصه**: آزاد / با بیمه پایه / با بیمه مکمل / جمع خدمات / مبلغ نهایی
|
||||||
|
|
||||||
|
### ۲. اتصال بیمه به قیمتگذاری پروفایل
|
||||||
|
|
||||||
|
- بیمههای entity و قیمتگذاری را از endpoint پرامپت `insurance-visit-pricing.md` (`GET /api/v1/insurance-pricing`) بگیر.
|
||||||
|
- با انتخاب بیمهی پایه/مکمل، قیمت ویزیت آزاد و سهم بیمار خودکار پر شود (کاربر بتواند دستی override کند).
|
||||||
|
- `insurance_base_id` و `insurance_supplementary_id` را در body ارسال کن (backend از قبل پشتیبانی میکند).
|
||||||
|
|
||||||
|
### ۳. واحد پول یکدست
|
||||||
|
|
||||||
|
همهجا یک واحد. توصیه: نمایش به تومان با `formatRial` اگر این util تومان میدهد را بررسی کن؛ ورودی و خلاصه باید یکی باشند.
|
||||||
|
|
||||||
|
### ۴. تست
|
||||||
|
|
||||||
|
`ddev exec npx tsc --noEmit` و `ddev exec yarn dev`؛ بدون خطا.
|
||||||
|
|
||||||
|
## نکات مهم
|
||||||
|
|
||||||
|
- وابسته به `insurance-visit-pricing.md`؛ اگر آن endpoint هنوز نیست، حداقل ساختار UI و select بیمهها از `GET /api/v1/insurances` را پیاده کن و auto-fill را با TODO علامت بزن.
|
||||||
|
- فرم با React Hook Form + Zod؛ server state با TanStack Query.
|
||||||
|
- از کامپوننتهای `ui/` موجود استفاده کن؛ کتابخانهی CSS جدید اضافه نکن؛ RTL.
|
||||||
|
- این تغییر صرفاً frontend است؛ تغییر API لازم نیست (مگر اینکه فیلد بیمهی session در پاسخ کم باشد).
|
||||||
@@ -40,11 +40,24 @@ const sessionSchema = z.object({
|
|||||||
visit_price_rials: z.coerce.number().min(0),
|
visit_price_rials: z.coerce.number().min(0),
|
||||||
base_insurance_discount_percent: z.coerce.number().min(0).max(100),
|
base_insurance_discount_percent: z.coerce.number().min(0).max(100),
|
||||||
supplementary_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"]),
|
payment_method: z.enum(["cash", "card", "insurance", "online", "pending"]),
|
||||||
notes: z.string().optional(),
|
notes: z.string().optional(),
|
||||||
});
|
});
|
||||||
type SessionFormData = z.infer<typeof sessionSchema>;
|
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> = {
|
const PAYMENT_LABELS: Record<string, string> = {
|
||||||
cash: "نقدی",
|
cash: "نقدی",
|
||||||
card: "کارت",
|
card: "کارت",
|
||||||
@@ -116,6 +129,8 @@ function MyPatientsPageInner() {
|
|||||||
>([]);
|
>([]);
|
||||||
const [sectionUuid, setSectionUuid] = useState("");
|
const [sectionUuid, setSectionUuid] = useState("");
|
||||||
const [itemUuid, setItemUuid] = useState("");
|
const [itemUuid, setItemUuid] = useState("");
|
||||||
|
const [baseInsuranceId, setBaseInsuranceId] = useState("");
|
||||||
|
const [suppInsuranceId, setSuppInsuranceId] = useState("");
|
||||||
|
|
||||||
const [createRecordOpen, setCreateRecordOpen] = useState(false);
|
const [createRecordOpen, setCreateRecordOpen] = useState(false);
|
||||||
const [searchMobile, setSearchMobile] = useState("");
|
const [searchMobile, setSearchMobile] = useState("");
|
||||||
@@ -174,6 +189,12 @@ function MyPatientsPageInner() {
|
|||||||
enabled: !!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 records = recordsData?.data ?? EMPTY_RECORDS;
|
||||||
const sessions = sessionsData?.data ?? EMPTY_SESSIONS;
|
const sessions = sessionsData?.data ?? EMPTY_SESSIONS;
|
||||||
const totalRec = recordsData?.meta?.totalRecords ?? 0;
|
const totalRec = recordsData?.meta?.totalRecords ?? 0;
|
||||||
@@ -192,6 +213,41 @@ function MyPatientsPageInner() {
|
|||||||
label: `${i.name} — ${formatRial(i.price_rials)}`,
|
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({
|
const createSessionMut = useMutation({
|
||||||
mutationFn: (body: object) =>
|
mutationFn: (body: object) =>
|
||||||
api.post(`/api/v1/patient/${selectedRecord!.uuid}/session`, body),
|
api.post(`/api/v1/patient/${selectedRecord!.uuid}/session`, body),
|
||||||
@@ -202,6 +258,8 @@ function MyPatientsPageInner() {
|
|||||||
setSessionModal(false);
|
setSessionModal(false);
|
||||||
form.reset();
|
form.reset();
|
||||||
setSelectedServices([]);
|
setSelectedServices([]);
|
||||||
|
setBaseInsuranceId("");
|
||||||
|
setSuppInsuranceId("");
|
||||||
toast.success("مراجعه ثبت شد");
|
toast.success("مراجعه ثبت شد");
|
||||||
},
|
},
|
||||||
onError: (e: any) => toast.error(e.message),
|
onError: (e: any) => toast.error(e.message),
|
||||||
@@ -650,6 +708,8 @@ function MyPatientsPageInner() {
|
|||||||
onClick={() => {
|
onClick={() => {
|
||||||
form.reset();
|
form.reset();
|
||||||
setSelectedServices([]);
|
setSelectedServices([]);
|
||||||
|
setBaseInsuranceId("");
|
||||||
|
setSuppInsuranceId("");
|
||||||
setSessionModal(true);
|
setSessionModal(true);
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
@@ -777,6 +837,8 @@ function MyPatientsPageInner() {
|
|||||||
onClick={() => {
|
onClick={() => {
|
||||||
form.reset();
|
form.reset();
|
||||||
setSelectedServices([]);
|
setSelectedServices([]);
|
||||||
|
setBaseInsuranceId("");
|
||||||
|
setSuppInsuranceId("");
|
||||||
setSessionModal(true);
|
setSessionModal(true);
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
@@ -809,6 +871,7 @@ function MyPatientsPageInner() {
|
|||||||
open={sessionModal}
|
open={sessionModal}
|
||||||
onClose={() => setSessionModal(false)}
|
onClose={() => setSessionModal(false)}
|
||||||
title="ثبت مراجعه جدید"
|
title="ثبت مراجعه جدید"
|
||||||
|
size="lg"
|
||||||
>
|
>
|
||||||
<form onSubmit={handleSubmitSession}>
|
<form onSubmit={handleSubmitSession}>
|
||||||
<div
|
<div
|
||||||
@@ -818,6 +881,41 @@ function MyPatientsPageInner() {
|
|||||||
gap: 14,
|
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
|
<div
|
||||||
style={{
|
style={{
|
||||||
display: "grid",
|
display: "grid",
|
||||||
@@ -826,7 +924,7 @@ function MyPatientsPageInner() {
|
|||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<div className="field">
|
<div className="field">
|
||||||
<label>قیمت ویزیت (ریال)</label>
|
<label>قیمت ویزیت (تومان)</label>
|
||||||
<input
|
<input
|
||||||
{...form.register("visit_price_rials")}
|
{...form.register("visit_price_rials")}
|
||||||
type="number"
|
type="number"
|
||||||
@@ -859,6 +957,7 @@ function MyPatientsPageInner() {
|
|||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
<div style={{ fontWeight: 700, fontSize: 13.5, color: "var(--text-2)", borderTop: "1px solid var(--border)", paddingTop: 12 }}>پرداخت و یادداشت</div>
|
||||||
<div className="field">
|
<div className="field">
|
||||||
<label>روش پرداخت</label>
|
<label>روش پرداخت</label>
|
||||||
<select {...form.register("payment_method")}>
|
<select {...form.register("payment_method")}>
|
||||||
@@ -1004,29 +1103,42 @@ function MyPatientsPageInner() {
|
|||||||
border: "1px solid oklch(0.88 0.05 256)",
|
border: "1px solid oklch(0.88 0.05 256)",
|
||||||
borderRadius: 10,
|
borderRadius: 10,
|
||||||
padding: 14,
|
padding: 14,
|
||||||
|
display: "flex",
|
||||||
|
flexDirection: "column",
|
||||||
|
gap: 6,
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<div
|
{(() => {
|
||||||
style={{
|
const visit = Number(watchVisit);
|
||||||
display: "flex",
|
const afterBase = Math.round(visit * (1 - Number(watchBase) / 100));
|
||||||
justifyContent: "space-between",
|
const afterSupp = Math.round(afterBase * (1 - Number(watchSupp) / 100));
|
||||||
marginBottom: 6,
|
const row = (label: string, val: number, muted = true) => (
|
||||||
fontSize: 13,
|
<div style={{ display: "flex", justifyContent: "space-between", fontSize: 13, color: muted ? "var(--text-3)" : "var(--text)" }}>
|
||||||
color: "var(--text-3)",
|
<span>{label}</span>
|
||||||
}}
|
<span>{formatRial(val)}</span>
|
||||||
>
|
</div>
|
||||||
<span>جمع خدمات</span>
|
);
|
||||||
<span>{formatRial(servicesTotal)}</span>
|
return (
|
||||||
</div>
|
<>
|
||||||
|
{row("ویزیت آزاد", visit)}
|
||||||
|
{Number(watchBase) > 0 && row("پس از بیمه پایه", afterBase)}
|
||||||
|
{Number(watchSupp) > 0 && row("پس از بیمه تکمیلی", afterSupp)}
|
||||||
|
{servicesTotal > 0 && row("جمع خدمات", servicesTotal)}
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
})()}
|
||||||
<div
|
<div
|
||||||
style={{
|
style={{
|
||||||
display: "flex",
|
display: "flex",
|
||||||
justifyContent: "space-between",
|
justifyContent: "space-between",
|
||||||
fontWeight: 700,
|
fontWeight: 700,
|
||||||
fontSize: 15,
|
fontSize: 15,
|
||||||
|
borderTop: "1px solid oklch(0.88 0.05 256)",
|
||||||
|
paddingTop: 8,
|
||||||
|
marginTop: 2,
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<span>مبلغ نهایی</span>
|
<span>مبلغ نهایی (سهم بیمار)</span>
|
||||||
<span style={{ color: "var(--primary)" }}>
|
<span style={{ color: "var(--primary)" }}>
|
||||||
{formatRial(finalPrice)}
|
{formatRial(finalPrice)}
|
||||||
</span>
|
</span>
|
||||||
|
|||||||
Reference in New Issue
Block a user