16 KiB
بازطراحی صفحه بیمه و قیمتگذاری — تفکیک نوع بیمه با Tab + ردیف Expandable + انتقال ویزیت آزاد
پروژه
clinicpro (پنل ادمین React — فقط frontend؛ backend موجود کافی است)
زمینه
مسیر /admin/insurance-pricing امروز سه بخش را در یک صفحه نشان میدهد: «قیمت ویزیت آزاد» (FreeVisitPrice)، و «مدیریت بیمه» (TenantInsuranceContracts) که همهی قراردادهای بیمهی پایه و تکمیلی را در یک جدول مسطح فهرست میکند. مدیر مطب هنگام افزودن بیمه باید نوع بیمه را دستی از یک select انتخاب کند و اطلاعات کلیدی هر بیمه (پوشش، فرانشیز، سقف) فقط بهصورت یک زیرنویس کمرنگ در ستون نام دیده میشود. جزئیات کامل قرارداد جایی نمایش داده نمیشود.
هدف: تجربهی مدیریت بیمه را برای یک مدیر حرفهای مطب/کلینیک سریع و خوانا کنیم — تفکیک پایه/تکمیلی با Tab، حذف انتخاب دستی نوع، نمایش خلاصهی خوانا در ردیف، و ردیفهای Expandable برای جزئیات کامل. همچنین «قیمت ویزیت آزاد» به صفحهی «تنظیمات نوبتدهی» منتقل شود.
backend — نیازی به تغییر نیست (اول گشتم)
طبق قاعدهی «اول بگرد، بعد بساز» endpointهای موجود کافیاند؛ هیچ تغییر backend لازم نیست:
GET /api/v1/insurance-pricing(در src/Insurance/Controller/InsuranceController.php خط ۲۲۰) هر بیمهی فعال را با فیلدtype('basic'|'supplementary') وfree_visit_price_rialsبرمیگرداند.GET /api/v1/billing/tenant-insurances(خط ۳۰۶) برای هر قراردادinsurance_kind(kindقرارداد یا در نبودشtypeکاتالوگ) و همهی فیلدهای پوشش/فرانشیز/سقف/تاریخ را برمیگرداند.POST /api/v1/billing/tenant-insurances(خط ۳۳۳) فیلدkindرا در payload میپذیرد و ذخیره میکند.PUT /api/v1/insurance-pricing(خط ۲۵۸) با{ free_visit_price_rials }قیمت ویزیت آزاد را ذخیره میکند.
پس فیلتر بر اساس نوع بیمه کاملاً سمت frontend انجام میشود (روی دادههای موجود همین دو endpoint).
فایلهای مرتبط
| فایل | نقش | تغییر |
|---|---|---|
| assets/admin/pages/InsurancePricingPage.tsx | صفحهی بیمه و قیمتگذاری | حذف <FreeVisitPrice/> و توضیح مربوطه |
| assets/admin/pages/AppointmentSettingsPage.tsx | صفحهی تنظیمات نوبتدهی | افزودن <FreeVisitPrice/> |
| assets/admin/components/FreeVisitPrice.tsx | کارت قیمت ویزیت آزاد | بدون تغییر (فقط جابهجا میشود) |
| assets/admin/components/TenantInsuranceContracts.tsx | جدول مدیریت بیمه | بازنویسی: Tab پایه/تکمیلی + ردیف Expandable + خلاصهی خوانا |
| assets/admin/components/InsuranceModal.tsx | مودال افزودن/ویرایش بیمه | حذف select نوع بیمه؛ kind از prop میآید |
وضعیت فعلی
InsurancePricingPage.tsx
<PageHeader
title="بیمه و قیمتگذاری"
description="قیمت ویزیت آزاد و قراردادهای بیمه (پایه و تکمیلی)"
/>
<FreeVisitPrice />
<TenantInsuranceContracts />
AppointmentSettingsPage.tsx (بخش render)
) : (
<WeeklyScheduleTab doctorUuid={uuid} addresses={addresses} />
)}
InsuranceModal.tsx — انتخاب دستی نوع (حذف شود)
<div style={field}>
<label style={label}>نوع بیمه</label>
<select className="input" value={form.kind} onChange={(e) => set({ kind: e.target.value })}>
<option value="basic">پایه</option>
<option value="supplementary">تکمیلی</option>
</select>
</div>
TenantInsuranceContracts.tsx — یک جدول مسطح، خلاصه فقط در زیرنویس نام
const contracts: Contract[] = (contractsQuery.data as any)?.data?.data ?? [];
const allInsurances: InsuranceOption[] = (pricingQuery.data as any)?.data?.insurances ?? [];
const activeIds = new Set(contracts.map((c) => c.insurance_id));
const available = allInsurances.filter((i) => !activeIds.has(i.insurance_id));
...
<td style={{ padding: '12px', fontWeight: 600 }}>
{c.insurance_name ?? `#${c.insurance_id}`}
<div style={{ fontSize: 11, color: 'var(--text-3)', ... }}>
پوشش {c.coverage_percent}٪
{c.franchise_rials > 0 && ` · فرانشیز ${formatRial(c.franchise_rials)}`}
{c.annual_ceiling_rials != null && ` · سقف ${formatRial(c.annual_ceiling_rials)}`}
</div>
</td>
وظایف
۱. انتقال «قیمت ویزیت آزاد» به تنظیمات نوبتدهی
۱.۱ حذف از InsurancePricingPage.tsx: خط import FreeVisitPrice ... و <FreeVisitPrice /> را بردار. توضیح PageHeader را به «قراردادهای بیمه پایه و تکمیلی» تغییر بده (دیگر ویزیت آزاد اینجا نیست).
۱.۲ افزودن به AppointmentSettingsPage.tsx: import FreeVisitPrice from '../components/FreeVisitPrice'; و کارت را بالای WeeklyScheduleTab رندر کن.
نکتهی مهم — گاردِ «فقط پزشک»: FreeVisitPrice از GET/PUT /api/v1/insurance-pricing استفاده میکند که entity را از روی نقش کاربر (ROLE_DOCTOR یا ROLE_CLINIC) resolve میکند (خط ۴۸ کنترلر)، پس هم برای پزشک و هم کلینیک کار میکند — اما AppointmentSettingsPage وقتی uuid پزشک نباشد کل محتوا را با پیام «این بخش فقط برای پزشک در دسترس است» جایگزین میکند. کارت قیمت ویزیت آزاد را بیرون از شرط !uuid و قبل از آن قرار بده تا مستقل از داشتن doctorUuid همیشه نمایش داده شود:
return (
<SettingsLayout active="appointment">
<div className="fade-in">
<h1 className="section-title" style={{ marginBottom: 16 }}>مدیریت نوبت دهی</h1>
<FreeVisitPrice />
{!uuid ? (
<div className="card" ...>این بخش فقط برای پزشک در دسترس است.</div>
) : isLoading ? (
...
) : (
<WeeklyScheduleTab doctorUuid={uuid} addresses={addresses} />
)}
</div>
</SettingsLayout>
);
۲. حذف انتخاب دستی نوع بیمه از InsuranceModal
نوع بیمه دیگر دستی انتخاب نمیشود؛ از Tab فعال میآید.
۲.۱ بلوک <select> نوع بیمه را کامل حذف کن.
۲.۲ یک prop جدید kind: 'basic' | 'supplementary' به Props اضافه کن (نوع بیمهی جاری بر اساس Tab). در حالت افزودن، EMPTY_FORM.kind را با این prop مقداردهی کن؛ در حالت ویرایش، kind قرارداد حفظ میشود:
interface Props {
open: boolean;
editContract: Contract | null;
options: InsuranceOption[];
kind: string; // نوع بیمهی Tab فعال — برای رکورد جدید
onClose: () => void;
onSubmit: (payload: ReturnType<typeof buildInsurancePayload>) => void;
isPending?: boolean;
}
useEffect(() => {
if (!open) return;
setForm(editContract ? contractToForm(editContract) : { ...EMPTY_FORM, kind });
}, [open, editContract, kind]);
buildInsurancePayload بدون تغییر میماند (همان kind را در payload میگذارد). یک نشانگر فقطخواندنی از نوع بیمه در مودال نشان بده تا کاربر بداند در کدام دسته اضافه میکند (مثلاً یک chip کوچک با KIND_LABEL[kind] نزدیک عنوان یا فیلد نام)، اما قابل تغییر نباشد.
۳. Tab پایه/تکمیلی در TenantInsuranceContracts
۳.۱ یک state برای Tab فعال:
type Kind = 'basic' | 'supplementary';
const [tab, setTab] = useState<Kind>('basic');
۳.۲ نوار Tab بالای جدول (بعد از هدر «مدیریت بیمه»). از توکنهای موجود استفاده کن (--primary, --border, --text-3) — الگوی Tab را از یک کامپوننت موجود که Tab دارد (مثلاً tabهای صفحهی جزئیات) همراستا کن. هر Tab تعداد قراردادهای همان نوع را بهصورت badge نشان دهد:
const KINDS: { key: Kind; label: string }[] = [
{ key: 'basic', label: 'بیمه پایه' },
{ key: 'supplementary', label: 'بیمه تکمیلی' },
];
۳.۳ فیلتر قراردادها بر اساس Tab (روی insurance_kind) — سپس جستجو روی همان زیرمجموعه اعمال شود:
const byKind = contracts.filter((c) => (c.insurance_kind ?? 'basic') === tab);
const rows = useMemo(() => filterInsurances(byKind, search), [byKind, search]);
۳.۴ فیلتر لیست انتخاب هنگام افزودن بر اساس Tab (روی type کاتالوگ). فقط بیمههای همان نوع که هنوز قرارداد ندارند:
const available = allInsurances
.filter((i) => i.type === tab)
.filter((i) => !activeIds.has(i.insurance_id));
توجه: InsuranceOption.type از پیش در interface هست (assets/admin/components/InsuranceModal.tsx خط ۱۰) و از pricingQuery میآید (data.insurances[].type).
۳.۵ مودال را با kind={tab} صدا بزن و در حالت افزودن options={available} (که حالا بر اساس Tab فیلتر شده):
<InsuranceModal
open={modalOpen}
editContract={editContract}
options={editContract ? allInsurances : available}
kind={tab}
onClose={closeModal}
onSubmit={(payload) => saveMut.mutate(payload)}
isPending={saveMut.isPending}
/>
دکمهی «افزودن بیمه» برچسبش را بر اساس Tab دقیقتر کن: «افزودن بیمه پایه» / «افزودن بیمه تکمیلی».
۴. خلاصهی خوانا + ردیف Expandable
ستون «نوع بیمه» در جدول دیگر لازم نیست (با Tab مشخص است) — حذفش کن و جایش خلاصهی اطلاعات کلیدی را مستقیماً در ردیف نشان بده.
۴.۱ خلاصهی ردیف (سطر جمعشده): بهجای زیرنویس کمرنگ، اطلاعات کلیدی را در یک ستون خوانا با جداکنندهی · نشان بده:
پوشش ۹۰٪ · فرانشیز ۵۰٬۰۰۰ تومان · سقف پوشش ۲٬۰۰۰٬۰۰۰ تومان
از formatRial (assets/admin/lib/utils.ts) برای مبالغ و ارقام فارسی استفاده کن. اگر annual_ceiling_rials == null بهجای سقف «سقف پوشش نامحدود» نشان بده.
۴.۲ Expandable Row: هر ردیف با کلیک باز/بسته شود. state:
const [expanded, setExpanded] = useState<string | null>(null); // contract.uuid
const toggleRow = (uuid: string) => setExpanded((p) => (p === uuid ? null : uuid));
- کل ردیف
clickableباشد (cursor: pointer) و یک آیکون chevron (ChevronDownIcon/ChevronUpIconاز@heroicons/react/24/outline) وضعیت باز/بسته را نشان دهد. - کلیک روی دکمههای عملیات (ویرایش) و روی سوییچ وضعیت نباید ردیف را toggle کند → در
onClickآنهاe.stopPropagation(). - ردیف جزئیات (
<tr>دوم با<td colSpan>) وقتیexpanded === c.uuidرندر شود و همهی فیلدهای کامل قرارداد را نشان دهد:- درصد پوشش، فرانشیز، سقف تعهد سالانه
- تاریخ شروع و پایان قرارداد (
effective_from/effective_to) باformatDateشمسی (assets/admin/lib/utils.ts)؛ اگرeffective_to == null→ «بدون تاریخ پایان» - نسخهی قرارداد (
version) و کد بیمه (insurance_id) - وضعیت فعال/غیرفعال
- انیمیشن باز/بستهشدن نرم باشد (از
--easeاستفاده کن یا یک transition ساده روی ارتفاع/opacity).
۴.۳ نسخهی موبایل (md:hidden): همان الگوی Expandable روی کارتها — کارت جمعشده خلاصه را نشان دهد و با کلیک جزئیات کامل باز شود.
۴.۴ Empty state هر Tab: اگر قراردادی برای Tab فعال نبود، پیام مناسب همان نوع: «هنوز بیمهی پایهای اضافه نکردهاید.» / «هنوز بیمهی تکمیلیای اضافه نکردهاید.» (بهجای پیام عمومی فعلی).
نکات مهم
- بدون تغییر backend و بدون migration. فقط frontend. اگر حین کار حس کردی endpoint کم دارد، اول دوباره بگرد — احتمالاً داده در همان
insurance-pricing/tenant-insurancesهست. - SOLID / تکمسئولیتی (قاعده ۱): ردیف Expandable و کارت موبایل را به کامپوننتهای کوچک جدا کن (مثلاً
ContractRow,ContractCard,ContractDetails) تاTenantInsuranceContractsمتورم نشود.StatusToggleوRowفعلی را نگهدار/بازاستفاده کن. - توکنهای طراحی: هیچ رنگ hex هاردکد نکن؛ از
var(--...)استفاده کن (--primary,--surface-2,--border,--text-2/3,--success,--r,--ease). منبع توکنهاassets/admin/styles.cssاست. - RTL و فارسی: همهی رشتهها فارسی، اعداد و مبالغ فارسی از طریق
formatRial/formatNumber/formatDate. ازinsetInlineStart/paddingInlineStart(نه left/right) مثل کد فعلی. insurance_kindممکن استnullباشد (قراردادهای قدیمی کهkindنداشتند وtypeکاتالوگشان هم null بوده) — در فیلتر Tab آن را به'basic'fallback بده تا گم نشود.- حالت ویرایش: نوع بیمه در ویرایش تغییر نمیکند؛ مودال در ویرایش
kindقرارداد را حفظ میکند و لیست کاملallInsurancesرا میدهد (چونinsuranceIdقفل/isDisabledاست). - تستها (قاعده ۴): فایل تست موجود
assets/admin/components/TenantInsuranceContracts.test.tsxوInsuranceModal.test.tsxرا بهروزرسانی/گسترش بده — سناریوها: (الف) فیلتر Tab قراردادها را درست جدا میکند، (ب) لیست انتخاب افزودن فقط بیمههای همان نوع را دارد، (ج) کلیک روی ردیف جزئیات را باز/بسته میکند، (د) کلیک روی دکمهی ویرایش/سوییچ ردیف را toggle نمیکند (stopPropagation)، (ه)AppointmentSettingsPage.test.tsx:FreeVisitPriceرندر میشود حتی وقتیuuidنیست. تستها را باyarn testسبز کن. - type check:
npx tsc --noEmit --project tsconfig.jsonبدون خطا. - مصرفکنندهی دیگر
FreeVisitPrice: مطمئن شو جایی جزInsurancePricingPageآن را import نمیکند (grep) تا انتقال چیزی را نشکند.