diff --git a/.claude/prompt/insurance-pricing-tabs-redesign.md b/.claude/prompt/insurance-pricing-tabs-redesign.md new file mode 100644 index 00000000..b4db823f --- /dev/null +++ b/.claude/prompt/insurance-pricing-tabs-redesign.md @@ -0,0 +1,235 @@ +# بازطراحی صفحه بیمه و قیمت‌گذاری — تفکیک نوع بیمه با 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](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](assets/admin/pages/InsurancePricingPage.tsx) | صفحه‌ی بیمه و قیمت‌گذاری | حذف `` و توضیح مربوطه | +| [assets/admin/pages/AppointmentSettingsPage.tsx](assets/admin/pages/AppointmentSettingsPage.tsx) | صفحه‌ی تنظیمات نوبت‌دهی | افزودن `` | +| [assets/admin/components/FreeVisitPrice.tsx](assets/admin/components/FreeVisitPrice.tsx) | کارت قیمت ویزیت آزاد | بدون تغییر (فقط جابه‌جا می‌شود) | +| [assets/admin/components/TenantInsuranceContracts.tsx](assets/admin/components/TenantInsuranceContracts.tsx) | جدول مدیریت بیمه | بازنویسی: Tab پایه/تکمیلی + ردیف Expandable + خلاصه‌ی خوانا | +| [assets/admin/components/InsuranceModal.tsx](assets/admin/components/InsuranceModal.tsx) | مودال افزودن/ویرایش بیمه | حذف `select` نوع بیمه؛ `kind` از prop می‌آید | + +## وضعیت فعلی + +### `InsurancePricingPage.tsx` + +```tsx + + + +``` + +### `AppointmentSettingsPage.tsx` (بخش render) + +```tsx +) : ( + +)} +``` + +### `InsuranceModal.tsx` — انتخاب دستی نوع (حذف شود) + +```tsx +
+ + +
+``` + +### `TenantInsuranceContracts.tsx` — یک جدول مسطح، خلاصه فقط در زیرنویس نام + +```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)); +... + + {c.insurance_name ?? `#${c.insurance_id}`} +
+ پوشش {c.coverage_percent}٪ + {c.franchise_rials > 0 && ` · فرانشیز ${formatRial(c.franchise_rials)}`} + {c.annual_ceiling_rials != null && ` · سقف ${formatRial(c.annual_ceiling_rials)}`} +
+ +``` + +## وظایف + +### ۱. انتقال «قیمت ویزیت آزاد» به تنظیمات نوبت‌دهی + +**۱.۱ حذف از `InsurancePricingPage.tsx`:** خط `import 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` همیشه نمایش داده شود: + +```tsx +return ( + +
+

مدیریت نوبت دهی

+ + + + {!uuid ? ( +
این بخش فقط برای پزشک در دسترس است.
+ ) : isLoading ? ( + ... + ) : ( + + )} +
+
+); +``` + +### ۲. حذف انتخاب دستی نوع بیمه از `InsuranceModal` + +نوع بیمه دیگر دستی انتخاب نمی‌شود؛ از Tab فعال می‌آید. + +**۲.۱** بلوک ` set({ kind: e.target.value })}> - - - - -
diff --git a/assets/admin/components/TenantInsuranceContracts.test.tsx b/assets/admin/components/TenantInsuranceContracts.test.tsx index ca473765..db32c38f 100644 --- a/assets/admin/components/TenantInsuranceContracts.test.tsx +++ b/assets/admin/components/TenantInsuranceContracts.test.tsx @@ -9,7 +9,7 @@ vi.mock('../lib/api', () => ({ vi.mock('sonner', () => ({ toast: { success: vi.fn(), error: vi.fn() } })); import { api } from '../lib/api'; -import TenantInsuranceContracts, { filterInsurances } from './TenantInsuranceContracts'; +import TenantInsuranceContracts, { filterInsurances, contractSummary } from './TenantInsuranceContracts'; import type { Contract } from './InsuranceModal'; const get = api.get as ReturnType; @@ -38,6 +38,20 @@ describe('filterInsurances', () => { }); }); +describe('contractSummary', () => { + it('shows coverage, franchise and ceiling inline', () => { + const s = contractSummary(mk({ coverage_percent: 90, franchise_rials: 500_000, annual_ceiling_rials: 20_000_000 })); + expect(s).toContain('پوشش'); + expect(s).toContain('فرانشیز'); + expect(s).toContain('سقف پوشش'); + }); + it('omits franchise when zero and marks unlimited ceiling', () => { + const s = contractSummary(mk({ franchise_rials: 0, annual_ceiling_rials: null })); + expect(s).not.toContain('فرانشیز'); + expect(s).toContain('سقف پوشش نامحدود'); + }); +}); + describe('TenantInsuranceContracts', () => { beforeEach(() => { get.mockReset(); @@ -46,8 +60,9 @@ describe('TenantInsuranceContracts', () => { get.mockImplementation((path: string) => { if (path.includes('tenant-insurances')) { return Promise.resolve({ success: true, data: { data: [ - mk({ uuid: 'u1', insurance_id: 3, insurance_name: 'بیمه ایران', is_active: true }), - mk({ uuid: 'u2', insurance_id: 5, insurance_name: 'بیمه آسیا', is_active: false }), + mk({ uuid: 'u1', insurance_id: 3, insurance_name: 'بیمه ایران', insurance_kind: 'basic', is_active: true }), + mk({ uuid: 'u2', insurance_id: 5, insurance_name: 'بیمه آسیا', insurance_kind: 'basic', is_active: false }), + mk({ uuid: 'u3', insurance_id: 7, insurance_name: 'بیمه دانا', insurance_kind: 'supplementary', is_active: true }), ] } }); } return Promise.resolve({ success: true, data: { insurances: [] } }); @@ -56,10 +71,42 @@ describe('TenantInsuranceContracts', () => { // Desktop table and mobile cards both render in jsdom (CSS `hidden`/`md:` is inert), // so each row's text appears twice — assertions use *AllBy* accordingly. - it('lists active and inactive contracts', async () => { + it('lists only the active tab (basic) contracts', async () => { renderWithProviders(); expect((await screen.findAllByText('بیمه ایران')).length).toBeGreaterThan(0); expect(screen.getAllByText('بیمه آسیا').length).toBeGreaterThan(0); + // Supplementary contract is hidden under the other tab. + expect(screen.queryByText('بیمه دانا')).not.toBeInTheDocument(); + }); + + it('switching to the supplementary tab filters by kind', async () => { + renderWithProviders(); + await screen.findAllByText('بیمه ایران'); + fireEvent.click(screen.getByRole('tab', { name: /بیمه تکمیلی/ })); + expect((await screen.findAllByText('بیمه دانا')).length).toBeGreaterThan(0); + expect(screen.queryByText('بیمه ایران')).not.toBeInTheDocument(); + }); + + it('classifies by catalog type, overriding a stale contract kind', async () => { + // آسیا is stored on the contract as basic (legacy manual pick) but the catalog + // marks it supplementary — catalog type wins, so it must leave the basic tab. + get.mockImplementation((path: string) => { + if (path.includes('tenant-insurances')) { + return Promise.resolve({ success: true, data: { data: [ + mk({ uuid: 'u1', insurance_id: 3, insurance_name: 'بیمه ایران', insurance_kind: 'basic' }), + mk({ uuid: 'u2', insurance_id: 5, insurance_name: 'بیمه آسیا', insurance_kind: 'basic' }), + ] } }); + } + return Promise.resolve({ success: true, data: { insurances: [ + { insurance_id: 3, insurance_name: 'بیمه ایران', type: 'basic' }, + { insurance_id: 5, insurance_name: 'بیمه آسیا', type: 'supplementary' }, + ] } }); + }); + renderWithProviders(); + await screen.findByText('بیمه ایران'); + expect(screen.queryByText('بیمه آسیا')).not.toBeInTheDocument(); + fireEvent.click(screen.getByRole('tab', { name: /بیمه تکمیلی/ })); + expect(await screen.findByText('بیمه آسیا')).toBeInTheDocument(); }); it('search box filters rows by name', async () => { @@ -70,12 +117,22 @@ describe('TenantInsuranceContracts', () => { expect(screen.getAllByText('بیمه آسیا').length).toBeGreaterThan(0); }); - it('status toggle PATCHes is_active', async () => { + it('clicking a row expands its detail, clicking again collapses', async () => { + renderWithProviders(); + const names = await screen.findAllByText('بیمه ایران'); + expect(screen.queryByText('نسخه قرارداد')).not.toBeInTheDocument(); + fireEvent.click(names[0]); + expect(screen.getAllByText('نسخه قرارداد').length).toBeGreaterThan(0); + fireEvent.click(names[0]); + expect(screen.queryByText('نسخه قرارداد')).not.toBeInTheDocument(); + }); + + it('clicking the status toggle does not expand the row (stopPropagation)', async () => { renderWithProviders(); await screen.findAllByText('بیمه ایران'); - // The active row's toggle offers to deactivate it. const toggles = screen.getAllByLabelText('غیرفعال کردن'); fireEvent.click(toggles[0]); + expect(screen.queryByText('نسخه قرارداد')).not.toBeInTheDocument(); await waitFor(() => expect(patch).toHaveBeenCalledWith('/api/v1/billing/tenant-insurances/u1', { is_active: false }), ); diff --git a/assets/admin/components/TenantInsuranceContracts.tsx b/assets/admin/components/TenantInsuranceContracts.tsx index fc60b961..37ac030e 100644 --- a/assets/admin/components/TenantInsuranceContracts.tsx +++ b/assets/admin/components/TenantInsuranceContracts.tsx @@ -1,11 +1,22 @@ import { useMemo, useState, type ReactNode } from 'react'; import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'; -import { PlusIcon, PencilIcon, MagnifyingGlassIcon } from '@heroicons/react/24/outline'; +import { PlusIcon, PencilIcon, MagnifyingGlassIcon, ChevronDownIcon } from '@heroicons/react/24/outline'; import { toast } from 'sonner'; import { api } from '../lib/api'; -import { formatRial } from '../lib/utils'; +import { formatRial, formatNumber, formatDate } from '../lib/utils'; import InsuranceModal, { Contract, InsuranceOption, KIND_LABEL, buildInsurancePayload } from './InsuranceModal'; +type Kind = 'basic' | 'supplementary'; + +const KINDS: { key: Kind; label: string; addLabel: string; emptyLabel: string }[] = [ + { key: 'basic', label: 'بیمه پایه', addLabel: 'افزودن بیمه پایه', emptyLabel: 'هنوز بیمه‌ی پایه‌ای اضافه نکرده‌اید.' }, + { key: 'supplementary', label: 'بیمه تکمیلی', addLabel: 'افزودن بیمه تکمیلی', emptyLabel: 'هنوز بیمه‌ی تکمیلی‌ای اضافه نکرده‌اید.' }, +]; + +/** Contract's effective kind, falling back to 'basic' for legacy rows with no kind/type. */ +const contractKind = (c: Contract): Kind => + (c.insurance_kind === 'supplementary' ? 'supplementary' : 'basic'); + /** Case-insensitive filter over insurance name (and code) — the "جستجو در بیمه ها..." box. */ export function filterInsurances(list: Contract[], query: string): Contract[] { const q = query.trim().toLowerCase(); @@ -16,11 +27,21 @@ export function filterInsurances(list: Contract[], query: string): Contract[] { ); } +/** One-line readable summary shown on the collapsed row: پوشش ۹۰٪ · فرانشیز … · سقف پوشش … */ +export function contractSummary(c: Contract): string { + const parts = [`پوشش ${formatNumber(c.coverage_percent)}٪`]; + if (c.franchise_rials > 0) parts.push(`فرانشیز ${formatRial(c.franchise_rials)}`); + parts.push(c.annual_ceiling_rials != null ? `سقف پوشش ${formatRial(c.annual_ceiling_rials)}` : 'سقف پوشش نامحدود'); + return parts.join(' · '); +} + export default function TenantInsuranceContracts() { const qc = useQueryClient(); + const [tab, setTab] = useState('basic'); const [modalOpen, setModalOpen] = useState(false); const [editContract, setEditContract] = useState(null); const [search, setSearch] = useState(''); + const [expanded, setExpanded] = useState(null); const contractsQuery = useQuery({ queryKey: ['tenant-insurances'], @@ -35,11 +56,28 @@ export default function TenantInsuranceContracts() { 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)); + // Add-mode options: only the active tab's kind, excluding already-contracted insurances. + const available = allInsurances.filter((i) => i.type === tab).filter((i) => !activeIds.has(i.insurance_id)); - const rows = useMemo(() => filterInsurances(contracts, search), [contracts, search]); + // The insurance's real type comes from the catalog, not the contract's stored kind + // (legacy contracts carry a stale manually-picked kind). Catalog type is authoritative. + const catalogTypeById = useMemo(() => { + const m: Record = {}; + for (const i of allInsurances) m[i.insurance_id] = i.type === 'supplementary' ? 'supplementary' : 'basic'; + return m; + }, [allInsurances]); + const kindOf = (c: Contract): Kind => + catalogTypeById[c.insurance_id] ?? contractKind(c); + + const byKind = useMemo(() => contracts.filter((c) => kindOf(c) === tab), [contracts, tab, catalogTypeById]); + const rows = useMemo(() => filterInsurances(byKind, search), [byKind, search]); + const counts = useMemo(() => ({ + basic: contracts.filter((c) => kindOf(c) === 'basic').length, + supplementary: contracts.filter((c) => kindOf(c) === 'supplementary').length, + }), [contracts, catalogTypeById]); const invalidate = () => qc.invalidateQueries({ queryKey: ['tenant-insurances'] }); + const toggleRow = (uuid: string) => setExpanded((p) => (p === uuid ? null : uuid)); const saveMut = useMutation({ mutationFn: (payload: ReturnType) => @@ -65,15 +103,46 @@ export default function TenantInsuranceContracts() { const openEdit = (c: Contract) => { setEditContract(c); setModalOpen(true); }; const closeModal = () => { setModalOpen(false); setEditContract(null); }; + const activeKind = KINDS.find((k) => k.key === tab)!; + return (

مدیریت بیمه

+
+ {KINDS.map((k) => { + const active = k.key === tab; + return ( + + ); + })} +
+
در حال بارگذاری...
) : rows.length === 0 ? (
- {search ? 'بیمه‌ای یافت نشد.' : 'هنوز با هیچ بیمه‌ای قرارداد ندارید.'} + {search ? 'بیمه‌ای یافت نشد.' : activeKind.emptyLabel}
) : ( - <> - {/* Desktop table */} -
- - - - - - - - - - - - - {rows.map((c, i) => ( - - - - - - - - - ))} - -
ردیفنام بیمهکدنوع بیمهوضعیتعملیات
{i + 1} - {c.insurance_name ?? `#${c.insurance_id}`} -
- پوشش {c.coverage_percent}٪ - {c.franchise_rials > 0 && ` · فرانشیز ${formatRial(c.franchise_rials)}`} - {c.annual_ceiling_rials != null && ` · سقف ${formatRial(c.annual_ceiling_rials)}`} -
-
{c.insurance_id}{KIND_LABEL[c.insurance_kind ?? ''] ?? c.insurance_kind ?? '—'} - toggleMut.mutate(c)} disabled={toggleMut.isPending} /> - - -
-
- - {/* Mobile cards */} -
- {rows.map((c) => ( -
-
-
{c.insurance_name ?? `#${c.insurance_id}`}
- -
- {c.insurance_id}} /> - - toggleMut.mutate(c)} disabled={toggleMut.isPending} />} /> -
- ))} -
- +
+ {rows.map((c) => ( + toggleRow(c.uuid)} + onEdit={() => openEdit(c)} + onToggleStatus={() => toggleMut.mutate(c)} + statusPending={toggleMut.isPending} + /> + ))} +
)} saveMut.mutate(payload)} isPending={saveMut.isPending} @@ -165,16 +189,74 @@ export default function TenantInsuranceContracts() { ); } -function Row({ label, value }: { label: string; value: ReactNode }) { +interface RowProps { + contract: Contract; + open: boolean; + onToggleRow: () => void; + onEdit: () => void; + onToggleStatus: () => void; + statusPending?: boolean; +} + +function ContractCard({ contract: c, open, onToggleRow, onEdit, onToggleStatus, statusPending }: RowProps) { + const stop = (fn: () => void) => (e: React.MouseEvent) => { e.stopPropagation(); fn(); }; return ( -
- {label}: - {value} +
+
+
+ +
{c.insurance_name ?? `#${c.insurance_id}`}
+
+ +
+
{contractSummary(c)}
+
{})}> + +
+ {open &&
}
); } -function StatusToggle({ contract, onToggle, disabled }: { contract: Contract; onToggle: () => void; disabled?: boolean }) { +/** Expanded full detail of a contract (all fields), shown when its card is open. */ +function ContractDetails({ contract: c }: { contract: Contract }) { + return ( +
+ + 0 ? formatRial(c.franchise_rials) : '—'} /> + + + + {formatNumber(c.version)}} /> + {c.insurance_id}} /> + + {c.is_active ? 'فعال' : 'غیرفعال'} + + } + /> +
+ ); +} + +function DetailCell({ label, value }: { label: string; value: ReactNode }) { + return ( +
+ {label} + {value} +
+ ); +} + +function StatusToggle({ contract, onToggle, disabled }: { contract: Contract; onToggle: (e: React.MouseEvent) => void; disabled?: boolean }) { return (