From d7cb9ea5a34711edb2aa890cb24cb072f973947a Mon Sep 17 00:00:00 2001 From: hamed <15238-genius.ha@users.noreply.drupalcode.org> Date: Wed, 15 Jul 2026 13:23:38 +0330 Subject: [PATCH] feat(insurance): redesign insurance management page to match Figma MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Rebuild the /admin/insurance-pricing contracts UI to the Figma "مدیریت بیمه" design and inject the coverage/franchise/ceiling fields the design omitted. Backend: - Add contract-level `kind` column to TenantInsurance (basic|supplementary), defaulting to the catalog type; migration Version20260715093358. - POST/PATCH /billing/tenant-insurances now accept effective_from, effective_to, kind; PATCH also toggles is_active without clobbering the user-set effective_to (unlike DELETE/deactivate). - List returns the latest version of every insurance (active + inactive) via TenantInsuranceRepository::findLatestByTenant, for the فعال/غیرفعال toggle. Frontend: - New InsuranceModal (ui/Modal + SearchableSelect + PersianDateInput) with the seven fields; submit "ثبت بیمه". - TenantInsuranceContracts rebuilt: header + search box, desktop table (ردیف/نام/کد/نوع/وضعیت/عملیات) and mobile cards, status toggle -> PATCH. - utils: isoToUnix/unixToIso helpers for contract dates. Tests: TenantInsuranceContractApiTest (create/edit/toggle/list, 5 cases), InsuranceModal + TenantInsuranceContracts vitest suites, docs/api updated. Co-Authored-By: Claude Opus 4.8 --- .../admin/components/InsuranceModal.test.tsx | 79 +++++ assets/admin/components/InsuranceModal.tsx | 176 ++++++++++ .../TenantInsuranceContracts.test.tsx | 83 +++++ .../components/TenantInsuranceContracts.tsx | 315 +++++++++--------- assets/admin/lib/utils.ts | 11 + docs/api/insurance.md | 12 +- migrations/Version20260715093358.php | 31 ++ .../Controller/InsuranceController.php | 22 +- src/Insurance/Entity/TenantInsurance.php | 8 + .../Repository/TenantInsuranceRepository.php | 27 ++ .../Service/TenantInsuranceService.php | 13 +- .../TenantInsuranceContractApiTest.php | 131 ++++++++ 12 files changed, 741 insertions(+), 167 deletions(-) create mode 100644 assets/admin/components/InsuranceModal.test.tsx create mode 100644 assets/admin/components/InsuranceModal.tsx create mode 100644 assets/admin/components/TenantInsuranceContracts.test.tsx create mode 100644 migrations/Version20260715093358.php create mode 100644 tests/Insurance/TenantInsuranceContractApiTest.php diff --git a/assets/admin/components/InsuranceModal.test.tsx b/assets/admin/components/InsuranceModal.test.tsx new file mode 100644 index 00000000..ca3c342b --- /dev/null +++ b/assets/admin/components/InsuranceModal.test.tsx @@ -0,0 +1,79 @@ +import { describe, it, expect, vi } from 'vitest'; +import { screen, fireEvent } from '@testing-library/react'; +import { renderWithProviders } from '../test/utils'; +import InsuranceModal, { + buildInsurancePayload, contractToForm, EMPTY_FORM, type Contract, type InsuranceOption, +} from './InsuranceModal'; + +const mkContract = (over: Partial = {}): Contract => ({ + uuid: 'c-1', insurance_id: 3, insurance_name: 'بیمه ایران', insurance_kind: 'basic', + version: 1, is_active: true, coverage_percent: 70, franchise_rials: 500_000, + annual_ceiling_rials: 20_000_000, kind: 'basic', effective_from: 1_700_000_000, + effective_to: null, ...over, +}); + +const options: InsuranceOption[] = [ + { insurance_id: 3, insurance_name: 'بیمه ایران', type: 'basic' }, + { insurance_id: 5, insurance_name: 'بیمه آسیا', type: 'supplementary' }, +]; + +describe('buildInsurancePayload', () => { + it('converts toman → rials, percent, and Y-m-d → unix', () => { + const payload = buildInsurancePayload({ + ...EMPTY_FORM, insuranceId: '3', kind: 'supplementary', + coverage: '80', franchise: '50000', ceiling: '2000000', + effectiveFrom: '2024-01-01', effectiveTo: '2025-01-01', + }); + expect(payload.insurance_id).toBe(3); + expect(payload.kind).toBe('supplementary'); + expect(payload.coverage_percent).toBe(80); + expect(payload.franchise_rials).toBe(500_000); // 50000 toman × 10 + expect(payload.annual_ceiling_rials).toBe(20_000_000); + expect(typeof payload.effective_from).toBe('number'); + expect(payload.effective_to).toBeGreaterThan(payload.effective_from!); + }); + + it('empty ceiling → null (بی‌نهایت), empty dates → null', () => { + const payload = buildInsurancePayload({ ...EMPTY_FORM, insuranceId: '3', coverage: '50' }); + expect(payload.annual_ceiling_rials).toBeNull(); + expect(payload.effective_from).toBeNull(); + expect(payload.effective_to).toBeNull(); + }); +}); + +describe('contractToForm', () => { + it('maps rials → toman and uses contract kind', () => { + const form = contractToForm(mkContract({ franchise_rials: 300_000, kind: 'supplementary' })); + expect(form.franchise).toBe('30000'); + expect(form.kind).toBe('supplementary'); + expect(form.coverage).toBe('70'); + }); +}); + +describe('InsuranceModal', () => { + it('renders all seven fields in add mode', () => { + renderWithProviders( + {}} onSubmit={() => {}} />, + ); + expect(screen.getByText('افزودن بیمه')).toBeInTheDocument(); + expect(screen.getByText('نام بیمه')).toBeInTheDocument(); + expect(screen.getByText('نوع بیمه')).toBeInTheDocument(); + expect(screen.getByText('تاریخ شروع قرارداد')).toBeInTheDocument(); + expect(screen.getByText('تاریخ پایان قرارداد')).toBeInTheDocument(); + expect(screen.getByText('درصد پوشش')).toBeInTheDocument(); + expect(screen.getByText('فرانشیز (تومان)')).toBeInTheDocument(); + expect(screen.getByText('سقف تعهد (تومان)')).toBeInTheDocument(); + expect(screen.getByText('ثبت بیمه')).toBeInTheDocument(); + }); + + it('submits the built payload for an edited contract', () => { + const onSubmit = vi.fn(); + renderWithProviders( + {}} onSubmit={onSubmit} />, + ); + fireEvent.click(screen.getByText('ثبت بیمه')); + expect(onSubmit).toHaveBeenCalledWith(expect.objectContaining({ + insurance_id: 3, coverage_percent: 70, franchise_rials: 500_000, kind: 'basic', + })); + }); +}); diff --git a/assets/admin/components/InsuranceModal.tsx b/assets/admin/components/InsuranceModal.tsx new file mode 100644 index 00000000..5c2baa87 --- /dev/null +++ b/assets/admin/components/InsuranceModal.tsx @@ -0,0 +1,176 @@ +import { useEffect, useState } from 'react'; +import Modal from './ui/Modal'; +import SearchableSelect from './ui/SearchableSelect'; +import PersianDateInput from './ui/PersianDateInput'; +import { isoToUnix, rialToToman, tomanToRial, unixToIso } from '../lib/utils'; + +export interface InsuranceOption { + insurance_id: number; + insurance_name: string; + type: string; +} + +export interface Contract { + uuid: string; + insurance_id: number; + insurance_name: string | null; + insurance_kind: string | null; + version: number; + is_active: boolean; + coverage_percent: number; + franchise_rials: number; + annual_ceiling_rials: number | null; + kind: string | null; + effective_from: number; + effective_to: number | null; +} + +export interface InsuranceFormValues { + insuranceId: string; + kind: string; + effectiveFrom: string; // Y-m-d + effectiveTo: string; // Y-m-d + coverage: string; + franchise: string; // toman + ceiling: string; // toman +} + +export const KIND_LABEL: Record = { + basic: 'پایه', + supplementary: 'تکمیلی', +}; + +export const EMPTY_FORM: InsuranceFormValues = { + insuranceId: '', kind: 'basic', effectiveFrom: '', effectiveTo: '', + coverage: '', franchise: '', ceiling: '', +}; + +/** Map a contract to editable form values (rials → toman, unix → Y-m-d). */ +export function contractToForm(c: Contract): InsuranceFormValues { + return { + insuranceId: String(c.insurance_id), + kind: c.kind ?? c.insurance_kind ?? 'basic', + effectiveFrom: unixToIso(c.effective_from), + effectiveTo: unixToIso(c.effective_to), + coverage: String(c.coverage_percent ?? ''), + franchise: c.franchise_rials != null ? String(rialToToman(c.franchise_rials)) : '', + ceiling: c.annual_ceiling_rials != null ? String(rialToToman(c.annual_ceiling_rials)) : '', + }; +} + +/** Build the API payload from form values (toman → rials, Y-m-d → unix). */ +export function buildInsurancePayload(v: InsuranceFormValues) { + return { + insurance_id: Number(v.insuranceId), + kind: v.kind || null, + coverage_percent: Number(v.coverage) || 0, + franchise_rials: tomanToRial(Number(v.franchise) || 0), + annual_ceiling_rials: v.ceiling === '' ? null : tomanToRial(Number(v.ceiling)), + effective_from: isoToUnix(v.effectiveFrom), + effective_to: isoToUnix(v.effectiveTo), + }; +} + +interface Props { + open: boolean; + editContract: Contract | null; + /** Insurance catalog options; in edit mode all are shown, in add mode only the available ones. */ + options: InsuranceOption[]; + onClose: () => void; + onSubmit: (payload: ReturnType) => void; + isPending?: boolean; +} + +/** + * Add/edit insurance contract modal (افزودن/ویرایش بیمه). Presentational: owns form + * state, emits the built payload via onSubmit. Fields mirror the Figma "افزودن بیمه" + * modal plus the injected coverage/franchise/ceiling controls. + */ +export default function InsuranceModal({ open, editContract, options, onClose, onSubmit, isPending }: Props) { + const [form, setForm] = useState(EMPTY_FORM); + + useEffect(() => { + if (!open) return; + setForm(editContract ? contractToForm(editContract) : EMPTY_FORM); + }, [open, editContract]); + + const set = (patch: Partial) => setForm((f) => ({ ...f, ...patch })); + const isEdit = editContract !== null; + + const submit = () => { + if (!form.insuranceId) return; + onSubmit(buildInsurancePayload(form)); + }; + + const field = { display: 'flex', flexDirection: 'column' as const, gap: 6 }; + const label = { fontSize: 12, fontWeight: 600, color: 'var(--text-2)' }; + + return ( + + + + + } + > +
+
+ + ({ value: String(i.insurance_id), label: i.insurance_name }))} + value={form.insuranceId} + onChange={(v) => set({ insuranceId: v ? String(v) : '' })} + isDisabled={isEdit} + placeholder="انتخاب کنید..." + /> +
+ +
+ + +
+ +
+
+ + set({ effectiveFrom: v })} placeholder="انتخاب" /> +
+
+ + set({ effectiveTo: v })} placeholder="انتخاب" /> +
+
+ +
+
+ + set({ coverage: e.target.value })} /> +
+
+ + set({ franchise: e.target.value })} /> +
+
+ + set({ ceiling: e.target.value })} /> +
+
+
+
+ ); +} diff --git a/assets/admin/components/TenantInsuranceContracts.test.tsx b/assets/admin/components/TenantInsuranceContracts.test.tsx new file mode 100644 index 00000000..ca473765 --- /dev/null +++ b/assets/admin/components/TenantInsuranceContracts.test.tsx @@ -0,0 +1,83 @@ +import { describe, it, expect, beforeEach, vi } from 'vitest'; +import { screen, fireEvent, waitFor } from '@testing-library/react'; +import { renderWithProviders } from '../test/utils'; + +vi.mock('../lib/api', () => ({ + api: { get: vi.fn(), post: vi.fn(), patch: vi.fn(), put: vi.fn(), delete: vi.fn() }, + ApiError: class extends Error {}, +})); +vi.mock('sonner', () => ({ toast: { success: vi.fn(), error: vi.fn() } })); + +import { api } from '../lib/api'; +import TenantInsuranceContracts, { filterInsurances } from './TenantInsuranceContracts'; +import type { Contract } from './InsuranceModal'; + +const get = api.get as ReturnType; +const patch = api.patch as ReturnType; + +const mk = (over: Partial): Contract => ({ + uuid: 'u', insurance_id: 3, insurance_name: 'بیمه ایران', insurance_kind: 'basic', + version: 1, is_active: true, coverage_percent: 70, franchise_rials: 0, + annual_ceiling_rials: null, kind: 'basic', effective_from: 0, effective_to: null, ...over, +}); + +describe('filterInsurances', () => { + const list = [ + mk({ uuid: 'a', insurance_id: 3, insurance_name: 'بیمه ایران' }), + mk({ uuid: 'b', insurance_id: 5, insurance_name: 'بیمه آسیا' }), + ]; + it('filters by name', () => { + expect(filterInsurances(list, 'آسیا')).toHaveLength(1); + expect(filterInsurances(list, 'آسیا')[0].uuid).toBe('b'); + }); + it('filters by code (insurance_id)', () => { + expect(filterInsurances(list, '5')).toHaveLength(1); + }); + it('empty query returns all', () => { + expect(filterInsurances(list, ' ')).toHaveLength(2); + }); +}); + +describe('TenantInsuranceContracts', () => { + beforeEach(() => { + get.mockReset(); + patch.mockReset(); + patch.mockResolvedValue({ success: true, data: { data: {} } }); + 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 }), + ] } }); + } + return Promise.resolve({ success: true, data: { insurances: [] } }); + }); + }); + + // 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 () => { + renderWithProviders(); + expect((await screen.findAllByText('بیمه ایران')).length).toBeGreaterThan(0); + expect(screen.getAllByText('بیمه آسیا').length).toBeGreaterThan(0); + }); + + it('search box filters rows by name', async () => { + renderWithProviders(); + await screen.findAllByText('بیمه ایران'); + fireEvent.change(screen.getByPlaceholderText('جستجو در بیمه ها...'), { target: { value: 'آسیا' } }); + expect(screen.queryByText('بیمه ایران')).not.toBeInTheDocument(); + expect(screen.getAllByText('بیمه آسیا').length).toBeGreaterThan(0); + }); + + it('status toggle PATCHes is_active', async () => { + renderWithProviders(); + await screen.findAllByText('بیمه ایران'); + // The active row's toggle offers to deactivate it. + const toggles = screen.getAllByLabelText('غیرفعال کردن'); + fireEvent.click(toggles[0]); + 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 99476398..fc60b961 100644 --- a/assets/admin/components/TenantInsuranceContracts.tsx +++ b/assets/admin/components/TenantInsuranceContracts.tsx @@ -1,211 +1,202 @@ -import { useState } from 'react'; +import { useMemo, useState, type ReactNode } from 'react'; import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'; -import { TrashIcon, PlusIcon, PencilIcon } from '@heroicons/react/24/outline'; +import { PlusIcon, PencilIcon, MagnifyingGlassIcon } from '@heroicons/react/24/outline'; import { toast } from 'sonner'; import { api } from '../lib/api'; -import { formatRial, rialToToman, tomanToRial } from '../lib/utils'; -import SearchableSelect from './ui/SearchableSelect'; +import { formatRial } from '../lib/utils'; +import InsuranceModal, { Contract, InsuranceOption, KIND_LABEL, buildInsurancePayload } from './InsuranceModal'; -interface Contract { - uuid: string; - insurance_id: number; - insurance_name: string | null; - insurance_kind: string | null; - version: number; - coverage_percent: number; - franchise_rials: number; - annual_ceiling_rials: number | null; +/** Case-insensitive filter over insurance name (and code) — the "جستجو در بیمه ها..." box. */ +export function filterInsurances(list: Contract[], query: string): Contract[] { + const q = query.trim().toLowerCase(); + if (!q) return list; + return list.filter((c) => + (c.insurance_name ?? '').toLowerCase().includes(q) || + String(c.insurance_id).includes(q), + ); } -interface InsuranceOption { - insurance_id: number; - insurance_name: string; - type: string; -} - -const KIND_LABEL: Record = { - basic: 'پایه', - supplementary: 'تکمیلی', -}; - export default function TenantInsuranceContracts() { const qc = useQueryClient(); - const [addOpen, setAddOpen] = useState(false); - const [editUuid, setEditUuid] = useState(null); - const [insuranceId, setInsuranceId] = useState(''); - const [coverage, setCoverage] = useState(''); - const [franchise, setFranchise] = useState(''); - const [ceiling, setCeiling] = useState(''); + const [modalOpen, setModalOpen] = useState(false); + const [editContract, setEditContract] = useState(null); + const [search, setSearch] = useState(''); - const contractsQuery = useQuery<{ data: Contract[] }>({ + const contractsQuery = useQuery({ queryKey: ['tenant-insurances'], queryFn: () => api.get('/api/v1/billing/tenant-insurances'), }); - const pricingQuery = useQuery<{ data: { insurances: InsuranceOption[] } }>({ + const pricingQuery = useQuery({ queryKey: ['insurance-pricing'], queryFn: () => api.get('/api/v1/insurance-pricing'), }); - const contracts = (contractsQuery.data as any)?.data?.data ?? []; + 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: Contract) => c.insurance_id)); + const activeIds = new Set(contracts.map((c) => c.insurance_id)); const available = allInsurances.filter((i) => !activeIds.has(i.insurance_id)); - const resetForm = () => { - setInsuranceId(''); - setCoverage(''); - setFranchise(''); - setCeiling(''); - setAddOpen(false); - setEditUuid(null); - }; + const rows = useMemo(() => filterInsurances(contracts, search), [contracts, search]); - const openEdit = (c: Contract) => { - setEditUuid(c.uuid); - setAddOpen(false); - setInsuranceId(String(c.insurance_id)); - setCoverage(String(c.coverage_percent ?? '')); - setFranchise(c.franchise_rials != null ? String(rialToToman(c.franchise_rials)) : ''); - setCeiling(c.annual_ceiling_rials != null ? String(rialToToman(c.annual_ceiling_rials)) : ''); - }; + const invalidate = () => qc.invalidateQueries({ queryKey: ['tenant-insurances'] }); - const editMut = useMutation({ - mutationFn: () => - api.patch(`/api/v1/billing/tenant-insurances/${editUuid}`, { - coverage_percent: Number(coverage) || 0, - franchise_rials: tomanToRial(Number(franchise) || 0), - annual_ceiling_rials: ceiling === '' ? null : tomanToRial(Number(ceiling)), - }), + const saveMut = useMutation({ + mutationFn: (payload: ReturnType) => + editContract + ? api.patch(`/api/v1/billing/tenant-insurances/${editContract.uuid}`, payload) + : api.post('/api/v1/billing/tenant-insurances', payload), onSuccess: () => { - toast.success('قرارداد بیمه ویرایش شد'); - resetForm(); - qc.invalidateQueries({ queryKey: ['tenant-insurances'] }); + toast.success(editContract ? 'قرارداد بیمه ویرایش شد' : 'قرارداد بیمه فعال شد'); + closeModal(); + invalidate(); }, onError: (e: Error) => toast.error(e.message), }); - const addMut = useMutation({ - mutationFn: () => - api.post('/api/v1/billing/tenant-insurances', { - insurance_id: Number(insuranceId), - coverage_percent: Number(coverage) || 0, - franchise_rials: tomanToRial(Number(franchise) || 0), - annual_ceiling_rials: ceiling === '' ? null : tomanToRial(Number(ceiling)), - }), - onSuccess: () => { - toast.success('قرارداد بیمه فعال شد'); - resetForm(); - qc.invalidateQueries({ queryKey: ['tenant-insurances'] }); - }, + const toggleMut = useMutation({ + mutationFn: (c: Contract) => + api.patch(`/api/v1/billing/tenant-insurances/${c.uuid}`, { is_active: !c.is_active }), + onSuccess: () => invalidate(), onError: (e: Error) => toast.error(e.message), }); - const delMut = useMutation({ - mutationFn: (uuid: string) => api.delete(`/api/v1/billing/tenant-insurances/${uuid}`), - onSuccess: () => { - toast.success('قرارداد غیرفعال شد'); - qc.invalidateQueries({ queryKey: ['tenant-insurances'] }); - }, - onError: (e: Error) => toast.error(e.message), - }); + const openAdd = () => { setEditContract(null); setModalOpen(true); }; + const openEdit = (c: Contract) => { setEditContract(c); setModalOpen(true); }; + const closeModal = () => { setModalOpen(false); setEditContract(null); }; return ( -
-
-
-

قراردادهای بیمه

-

- بیمه‌هایی که با آن‌ها قرارداد دارید. درصد پوشش، فرانشیز و سقف تعهد هر بیمه را تعیین کنید. -

-
- {!addOpen && available.length > 0 && ( - - )} +
+
+

مدیریت بیمه

+
- {(addOpen || editUuid) && ( -
-
- -
- ({ value: String(i.insurance_id), label: `${i.insurance_name} (${KIND_LABEL[i.type] ?? i.type})` }))} - value={insuranceId} - onChange={(v) => setInsuranceId(v ? String(v) : '')} - isDisabled={!!editUuid} - placeholder="انتخاب..." - /> -
-
-
- - setCoverage(e.target.value)} /> -
-
- - setFranchise(e.target.value)} /> -
-
- - setCeiling(e.target.value)} /> -
-
- - -
-
- )} +
+ + setSearch(e.target.value)} + /> +
{contractsQuery.isLoading ? (
در حال بارگذاری...
- ) : contracts.length === 0 ? ( -
- هنوز با هیچ بیمه‌ای قرارداد فعال ندارید. + ) : rows.length === 0 ? ( +
+ {search ? 'بیمه‌ای یافت نشد.' : 'هنوز با هیچ بیمه‌ای قرارداد ندارید.'}
) : ( -
- {(['basic', 'supplementary'] as const).map((kind) => { - const group = contracts.filter((c: Contract) => c.insurance_kind === kind); - if (group.length === 0) return null; - return ( -
-
- بیمه {KIND_LABEL[kind]} -
-
- {group.map((c: Contract) => ( -
-
-
{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)}`} -
+ <> + {/* 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} />} />
- ); - })} -
+ ))} +
+ )} + + saveMut.mutate(payload)} + isPending={saveMut.isPending} + />
); } + +function Row({ label, value }: { label: string; value: ReactNode }) { + return ( +
+ {label}: + {value} +
+ ); +} + +function StatusToggle({ contract, onToggle, disabled }: { contract: Contract; onToggle: () => void; disabled?: boolean }) { + return ( + + ); +} diff --git a/assets/admin/lib/utils.ts b/assets/admin/lib/utils.ts index 390ffd1a..152095c4 100644 --- a/assets/admin/lib/utils.ts +++ b/assets/admin/lib/utils.ts @@ -46,6 +46,17 @@ export function toGregorianDate(d: Date): string { return `${y}-${m}-${day}`; } +// API stores contract dates as Unix seconds; the date input speaks Y-m-d strings. +export function isoToUnix(iso: string): number | null { + const d = toDate(iso); + return d && !isNaN(d.getTime()) ? Math.floor(d.getTime() / 1000) : null; +} + +export function unixToIso(ts: number | null | undefined): string { + if (ts == null) return ''; + return toGregorianDate(new Date(ts * 1000)); +} + export function maskMobile(mobile: string): string { if (mobile.length < 7) return mobile; return mobile.slice(0, 4) + '***' + mobile.slice(-3); diff --git a/docs/api/insurance.md b/docs/api/insurance.md index 66efb03b..14f6d6bf 100644 --- a/docs/api/insurance.md +++ b/docs/api/insurance.md @@ -353,7 +353,7 @@ entity جاری از `#[CurrentUser]` resolve می‌شود: نقش `ROLE_DOCTOR قرارداد یک پزشک/کلینیک با یک بیمه: درصد پوشش، فرانشیز، سقف تعهد سالانه، نسخه‌بندی و وضعیت فعال. مبنای محاسبه‌ی سهم در سیستم صورتحساب (`docs/architecture/insurance-billing-system.md`). tenant از `#[CurrentUser]` (`ROLE_DOCTOR`→doctor، `ROLE_CLINIC`→clinic). جدول `tenant_insurances`. ### GET `/api/v1/billing/tenant-insurances` -لیست قراردادهای فعال tenant جاری. +لیست قراردادهای tenant جاری — **آخرین نسخهٔ هر بیمه، فعال یا غیرفعال** (برای toggle فعال/غیرفعال در UI مدیریت بیمه). `insurance_kind` = `kind` قرارداد در صورت تعیین، وگرنه نوع بیمه از کاتالوگ. **Permission:** `AUTH` (doctor/clinic) @@ -372,6 +372,7 @@ entity جاری از `#[CurrentUser]` resolve می‌شود: نقش `ROLE_DOCTOR "coverage_percent": 70, "franchise_rials": 0, "annual_ceiling_rials": null, + "kind": "basic", "effective_from": 1718900000, "effective_to": null } @@ -390,12 +391,19 @@ entity جاری از `#[CurrentUser]` resolve می‌شود: نقش `ROLE_DOCTOR | `coverage_percent` | float | درصد پوشش (۰–۱۰۰) | | `franchise_rials` | int | فرانشیز ثابت سهم بیمار | | `annual_ceiling_rials` | int \| null | سقف تعهد (null = بی‌نهایت) | +| `kind` | string \| null | نوع بیمه قرارداد (`basic`/`supplementary`); خالی → پیش‌فرض نوع کاتالوگ | +| `effective_from` | int \| null | تاریخ شروع قرارداد (Unix)؛ null → اکنون | +| `effective_to` | int \| null | تاریخ پایان قرارداد (Unix)؛ null → نامحدود | پاسخ `201`: `{ success, data: { …contract } }`. خطاها: `404 ERR_NOT_FOUND_001` بیمه یافت نشد · `422 ERR_VALIDATION_001` insurance_id الزامی · `403 ERR_FORBIDDEN_001` پروفایل یافت نشد. ### PATCH `/api/v1/billing/tenant-insurances/{uuid}` -ویرایش `coverage_percent` / `franchise_rials` / `annual_ceiling_rials`. فقط قرارداد متعلق به tenant جاری. +ویرایش فیلدهای قرارداد (همه اختیاری، فقط کلیدهای موجود اعمال می‌شوند). فقط قرارداد متعلق به tenant جاری. + +**Body:** `coverage_percent` · `franchise_rials` · `annual_ceiling_rials` · `kind` · `effective_from` · `effective_to` · `is_active`. + +- `is_active` (bool): toggle فعال/غیرفعال. برخلاف `DELETE`، مقدار `effective_to`ِ تعیین‌شدهٔ کاربر را دست‌نخورده نگه می‌دارد (برای reactivate). ### DELETE `/api/v1/billing/tenant-insurances/{uuid}` غیرفعال‌سازی نرم (soft) — `is_active=false` و `effective_to=now`. داده حذف نمی‌شود. diff --git a/migrations/Version20260715093358.php b/migrations/Version20260715093358.php new file mode 100644 index 00000000..38df2da6 --- /dev/null +++ b/migrations/Version20260715093358.php @@ -0,0 +1,31 @@ +addSql('ALTER TABLE tenant_insurances ADD kind VARCHAR(20) DEFAULT NULL'); + } + + public function down(Schema $schema): void + { + // this down() migration is auto-generated, please modify it to your needs + $this->addSql('ALTER TABLE tenant_insurances DROP kind'); + } +} diff --git a/src/Insurance/Controller/InsuranceController.php b/src/Insurance/Controller/InsuranceController.php index b86dd653..0e7b039d 100644 --- a/src/Insurance/Controller/InsuranceController.php +++ b/src/Insurance/Controller/InsuranceController.php @@ -312,7 +312,7 @@ class InsuranceController extends BaseController return $this->error(ErrorCodes::ERR_FORBIDDEN_001, 'پروفایل یافت نشد', 403); } - $contracts = $this->tenantInsuranceRepo->findActiveByTenant($entityType, $entityId); + $contracts = $this->tenantInsuranceRepo->findLatestByTenant($entityType, $entityId); $byId = []; foreach ($this->insuranceRepo->findActive(null) as $ins) { @@ -322,7 +322,8 @@ class InsuranceController extends BaseController $data = array_map(function (TenantInsurance $c) use ($byId) { $row = $c->toArray(); $row['insurance_name'] = $byId[$c->getInsuranceId()]['name'] ?? null; - $row['insurance_kind'] = $byId[$c->getInsuranceId()]['type'] ?? null; + // Contract-level kind wins over the catalog type when the tenant categorised it. + $row['insurance_kind'] = $c->getKind() ?? ($byId[$c->getInsuranceId()]['type'] ?? null); return $row; }, $contracts); @@ -352,6 +353,9 @@ class InsuranceController extends BaseController (int) ($data['franchise_rials'] ?? 0), isset($data['annual_ceiling_rials']) && $data['annual_ceiling_rials'] !== null ? (int) $data['annual_ceiling_rials'] : null, + isset($data['effective_from']) && $data['effective_from'] !== null ? (int) $data['effective_from'] : null, + isset($data['effective_to']) && $data['effective_to'] !== null ? (int) $data['effective_to'] : null, + isset($data['kind']) && $data['kind'] !== '' ? (string) $data['kind'] : null, ); return $this->success(['data' => $contract->toArray()], 201); @@ -377,6 +381,20 @@ class InsuranceController extends BaseController if (array_key_exists('annual_ceiling_rials', $data)) { $contract->setAnnualCeilingRials($data['annual_ceiling_rials'] !== null ? (int) $data['annual_ceiling_rials'] : null); } + if (array_key_exists('kind', $data)) { + $contract->setKind($data['kind'] !== '' && $data['kind'] !== null ? (string) $data['kind'] : null); + } + if (array_key_exists('effective_from', $data) && $data['effective_from'] !== null) { + $contract->setEffectiveFrom((int) $data['effective_from']); + } + if (array_key_exists('effective_to', $data)) { + $contract->setEffectiveTo($data['effective_to'] !== null ? (int) $data['effective_to'] : null); + } + // Status toggle (فعال/غیرفعال) is set here directly so it does not clobber the + // user-chosen effective_to the way the DELETE/deactivate path does. + if (array_key_exists('is_active', $data)) { + $contract->setActive((bool) $data['is_active']); + } $this->tenantInsuranceRepo->save($contract); diff --git a/src/Insurance/Entity/TenantInsurance.php b/src/Insurance/Entity/TenantInsurance.php index 64646fcb..d63eaae5 100644 --- a/src/Insurance/Entity/TenantInsurance.php +++ b/src/Insurance/Entity/TenantInsurance.php @@ -47,6 +47,10 @@ class TenantInsurance #[ORM\Column(name: 'annual_ceiling_rials', type: 'integer', nullable: true)] private ?int $annualCeilingRials = null; + /** Contract-level insurance kind ('basic'|'supplementary'); overrides the catalog type when set. */ + #[ORM\Column(name: 'kind', type: 'string', length: 20, nullable: true)] + private ?string $kind = null; + #[ORM\Column(name: 'effective_from', type: 'integer')] private int $effectiveFrom; @@ -81,6 +85,7 @@ class TenantInsurance public function getCoveragePercent(): float { return (float) $this->coveragePercent; } public function getFranchiseRials(): int { return $this->franchiseRials; } public function getAnnualCeilingRials(): ?int { return $this->annualCeilingRials; } + public function getKind(): ?string { return $this->kind; } public function getEffectiveFrom(): int { return $this->effectiveFrom; } public function getEffectiveTo(): ?int { return $this->effectiveTo; } @@ -88,6 +93,8 @@ class TenantInsurance public function setCoveragePercent(float $v): self { $this->coveragePercent = (string) $v; $this->updatedAt = time(); return $this; } public function setFranchiseRials(int $v): self { $this->franchiseRials = $v; $this->updatedAt = time(); return $this; } public function setAnnualCeilingRials(?int $v): self { $this->annualCeilingRials = $v; $this->updatedAt = time(); return $this; } + public function setKind(?string $v): self { $this->kind = $v; $this->updatedAt = time(); return $this; } + public function setEffectiveFrom(int $v): self { $this->effectiveFrom = $v; $this->updatedAt = time(); return $this; } public function setEffectiveTo(?int $v): self { $this->effectiveTo = $v; $this->updatedAt = time(); return $this; } public function toArray(): array @@ -102,6 +109,7 @@ class TenantInsurance 'coverage_percent' => (float) $this->coveragePercent, 'franchise_rials' => $this->franchiseRials, 'annual_ceiling_rials' => $this->annualCeilingRials, + 'kind' => $this->kind, 'effective_from' => $this->effectiveFrom, 'effective_to' => $this->effectiveTo, ]; diff --git a/src/Insurance/Repository/TenantInsuranceRepository.php b/src/Insurance/Repository/TenantInsuranceRepository.php index 30ba6e5c..d5dd7650 100644 --- a/src/Insurance/Repository/TenantInsuranceRepository.php +++ b/src/Insurance/Repository/TenantInsuranceRepository.php @@ -27,6 +27,33 @@ class TenantInsuranceRepository extends ServiceEntityRepository ->getResult(); } + /** + * Latest version of every insurance the tenant has a contract with, active or not. + * The management UI shows one row per insurance with a فعال/غیرفعال toggle, so both + * states must be returned; older versions are collapsed to the newest. + * + * @return TenantInsurance[] + */ + public function findLatestByTenant(string $entityType, int $entityId): array + { + $rows = $this->createQueryBuilder('t') + ->where('t.entityType = :type') + ->andWhere('t.entityId = :id') + ->setParameter('type', $entityType) + ->setParameter('id', $entityId) + ->orderBy('t.insuranceId', 'ASC') + ->addOrderBy('t.version', 'DESC') + ->getQuery() + ->getResult(); + + $latest = []; + foreach ($rows as $row) { + $latest[$row->getInsuranceId()] ??= $row; + } + + return array_values($latest); + } + public function findByUuid(string $uuid): ?TenantInsurance { return $this->findOneBy(['uuid' => $uuid]); diff --git a/src/Insurance/Service/TenantInsuranceService.php b/src/Insurance/Service/TenantInsuranceService.php index a303cc77..e2152701 100644 --- a/src/Insurance/Service/TenantInsuranceService.php +++ b/src/Insurance/Service/TenantInsuranceService.php @@ -31,8 +31,12 @@ class TenantInsuranceService float $coveragePercent, int $franchiseRials = 0, ?int $annualCeilingRials = null, + ?int $effectiveFrom = null, + ?int $effectiveTo = null, + ?string $kind = null, ): TenantInsurance { - if ($this->insuranceRepo->find($insuranceId) === null) { + $insurance = $this->insuranceRepo->find($insuranceId); + if ($insurance === null) { throw new AppException(ErrorCodes::ERR_NOT_FOUND_001, 'بیمه یافت نشد', 404); } @@ -45,8 +49,15 @@ class TenantInsuranceService $contract->setCoveragePercent($coveragePercent) ->setFranchiseRials($franchiseRials) ->setAnnualCeilingRials($annualCeilingRials) + // kind defaults to the catalog type; caller may override to categorise the contract. + ->setKind($kind ?? $insurance->getType()->value) + ->setEffectiveTo($effectiveTo) ->setActive(true); + if ($effectiveFrom !== null) { + $contract->setEffectiveFrom($effectiveFrom); + } + $this->repo->save($contract); return $contract; diff --git a/tests/Insurance/TenantInsuranceContractApiTest.php b/tests/Insurance/TenantInsuranceContractApiTest.php new file mode 100644 index 00000000..7435cab2 --- /dev/null +++ b/tests/Insurance/TenantInsuranceContractApiTest.php @@ -0,0 +1,131 @@ +createUser(['ROLE_DOCTOR']); + $doctor = new Doctor($owner, 'دکتر تست بیمه'); + $this->em->persist($doctor); + + $insurance = new Insurance('بیمه ایران ' . random_int(1000, 9999), $type); + $this->em->persist($insurance); + $this->em->flush(); + + return [$owner, $insurance]; + } + + public function testCreateContractPersistsDatesAndKind(): void + { + [$owner, $insurance] = $this->makeDoctorAndInsurance(InsuranceType::Basic); + + $body = $this->authJson('POST', '/api/v1/billing/tenant-insurances', $owner, [ + 'insurance_id' => $insurance->getId(), + 'coverage_percent' => 70, + 'franchise_rials' => 500_000, + 'annual_ceiling_rials' => 20_000_000, + 'effective_from' => 1_700_000_000, + 'effective_to' => 1_800_000_000, + 'kind' => 'supplementary', + ]); + + $this->assertSame(201, $this->responseCode()); + $row = $body['data']['data']; + $this->assertEquals(70.0, $row['coverage_percent']); + $this->assertSame(500_000, $row['franchise_rials']); + $this->assertSame(20_000_000, $row['annual_ceiling_rials']); + $this->assertSame(1_700_000_000, $row['effective_from']); + $this->assertSame(1_800_000_000, $row['effective_to']); + $this->assertSame('supplementary', $row['kind']); + $this->assertTrue($row['is_active']); + } + + public function testKindDefaultsToCatalogTypeWhenOmitted(): void + { + [$owner, $insurance] = $this->makeDoctorAndInsurance(InsuranceType::Basic); + + $body = $this->authJson('POST', '/api/v1/billing/tenant-insurances', $owner, [ + 'insurance_id' => $insurance->getId(), + 'coverage_percent' => 50, + ]); + + $this->assertSame(201, $this->responseCode()); + $this->assertSame('basic', $body['data']['data']['kind']); + } + + public function testEditUpdatesDatesKindAndAmounts(): void + { + [$owner, $insurance] = $this->makeDoctorAndInsurance(); + $created = $this->authJson('POST', '/api/v1/billing/tenant-insurances', $owner, [ + 'insurance_id' => $insurance->getId(), + 'coverage_percent' => 40, + ]); + $uuid = $created['data']['data']['uuid']; + + $body = $this->authJson('PATCH', "/api/v1/billing/tenant-insurances/$uuid", $owner, [ + 'coverage_percent' => 90, + 'franchise_rials' => 123_000, + 'annual_ceiling_rials' => null, + 'effective_to' => 1_900_000_000, + 'kind' => 'supplementary', + ]); + + $this->assertSame(200, $this->responseCode()); + $this->assertEquals(90.0, $body['data']['data']['coverage_percent']); + $this->assertSame(123_000, $body['data']['data']['franchise_rials']); + $this->assertNull($body['data']['data']['annual_ceiling_rials']); + $this->assertSame(1_900_000_000, $body['data']['data']['effective_to']); + $this->assertSame('supplementary', $body['data']['data']['kind']); + } + + public function testToggleIsActiveDoesNotClobberEffectiveTo(): void + { + [$owner, $insurance] = $this->makeDoctorAndInsurance(); + $created = $this->authJson('POST', '/api/v1/billing/tenant-insurances', $owner, [ + 'insurance_id' => $insurance->getId(), + 'coverage_percent' => 60, + 'effective_to' => 1_850_000_000, + ]); + $uuid = $created['data']['data']['uuid']; + + $off = $this->authJson('PATCH', "/api/v1/billing/tenant-insurances/$uuid", $owner, ['is_active' => false]); + $this->assertFalse($off['data']['data']['is_active']); + // Deactivating via the toggle must keep the user-set effective_to intact. + $this->assertSame(1_850_000_000, $off['data']['data']['effective_to']); + + $on = $this->authJson('PATCH', "/api/v1/billing/tenant-insurances/$uuid", $owner, ['is_active' => true]); + $this->assertTrue($on['data']['data']['is_active']); + } + + public function testListReturnsInactiveContracts(): void + { + [$owner, $insurance] = $this->makeDoctorAndInsurance(); + $created = $this->authJson('POST', '/api/v1/billing/tenant-insurances', $owner, [ + 'insurance_id' => $insurance->getId(), + 'coverage_percent' => 30, + ]); + $uuid = $created['data']['data']['uuid']; + $this->authJson('PATCH', "/api/v1/billing/tenant-insurances/$uuid", $owner, ['is_active' => false]); + + $body = $this->authJson('GET', '/api/v1/billing/tenant-insurances', $owner); + $this->assertSame(200, $this->responseCode()); + + $rows = $body['data']['data'] ?? $body['data']; + $found = array_filter($rows, fn ($r) => $r['uuid'] === $uuid); + $this->assertCount(1, $found, 'inactive contract must still appear in the list'); + $this->assertFalse(array_values($found)[0]['is_active']); + } +}