From 59fb590d65e9486ce2929c8f4eef64f003c7ac9e Mon Sep 17 00:00:00 2001 From: hamed <15238-genius.ha@users.noreply.drupalcode.org> Date: Tue, 23 Jun 2026 15:15:12 +0330 Subject: [PATCH] feat: redesign new-visit modal with insurance selects and live breakdown MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- .claude/prompt/new-visit-modal-ux.md | 89 ++++++++++++++++ assets/admin/pages/MyPatientsPage.tsx | 140 +++++++++++++++++++++++--- 2 files changed, 215 insertions(+), 14 deletions(-) create mode 100644 .claude/prompt/new-visit-modal-ux.md diff --git a/.claude/prompt/new-visit-modal-ux.md b/.claude/prompt/new-visit-modal-ux.md new file mode 100644 index 00000000..8a6c9349 --- /dev/null +++ b/.claude/prompt/new-visit-modal-ux.md @@ -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 در پاسخ کم باشد). diff --git a/assets/admin/pages/MyPatientsPage.tsx b/assets/admin/pages/MyPatientsPage.tsx index 7843e2b1..b4ed5d7f 100644 --- a/assets/admin/pages/MyPatientsPage.tsx +++ b/assets/admin/pages/MyPatientsPage.tsx @@ -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; +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 = { 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>({ + 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" >
+
بیمه و مبلغ ویزیت
+
+
+ + +
+
+ + +
+
- +
+
پرداخت و یادداشت