feat(insurance): redesign insurance management page to match Figma
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 <noreply@anthropic.com>
This commit is contained in:
@@ -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);
|
||||
|
||||
|
||||
@@ -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,
|
||||
];
|
||||
|
||||
@@ -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]);
|
||||
|
||||
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user