Files
clinicpro/.claude/prompt/insurance-pricing-tabs-redesign.md

16 KiB
Raw Permalink Blame History

بازطراحی صفحه بیمه و قیمت‌گذاری — تفکیک نوع بیمه با 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) تا انتقال چیزی را نشکند.