Files
armarium-suite/frontend/src/app/insurance/overview/overview.html
T
Daniel Krähenbühl c03d2a97ab feat: insurance section — overview, documents, analysis, KVG premium comparison
- Insurance overview page (/insurance): current policies table with type,
  provider, premium, franchise, coverage, and document links
- Documents page: upload and manage insurance documents
- Analysis page: coverage gap analysis per insurance type
- Priminfo integration (/insurance/priminfo): KVG premium comparison by
  insurer, model (TAR/HMO/etc.), franchise level, and accident coverage
  via embedded Priminfo iframe (no public API available)
- Backend: Insurance, PraemienEntry, PraemienPolice models with migrations
- Sidebar: insurance nav group with flyout and dropdown
- i18n: all keys in DE/EN/FR/IT
2026-05-25 22:46:31 +02:00

303 lines
18 KiB
HTML

<div class="p-4 sm:p-6 space-y-6">
<!-- Header -->
<div class="flex items-center justify-between">
<div>
<h1 class="text-2xl font-bold text-gray-900 dark:text-white">{{ 'insurance.title' | translate }}</h1>
<p class="text-sm text-gray-500 dark:text-gray-400 mt-0.5">{{ 'insurance.subtitle' | translate }}</p>
</div>
<button (click)="openCreate()"
class="flex items-center gap-2 px-4 py-2 bg-violet-600 hover:bg-violet-700 text-white text-sm font-medium rounded-lg transition-colors">
<svg class="w-4 h-4" fill="none" viewBox="0 0 24 24">
<path stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M5 12h14M12 5v14"/>
</svg>
{{ 'insurance.add' | translate }}
</button>
</div>
<!-- KPI Cards -->
<div class="grid grid-cols-1 sm:grid-cols-3 gap-4">
<!-- Total monthly -->
<div class="bg-white dark:bg-gray-800 rounded-xl border border-gray-200 dark:border-gray-700 p-5">
<p class="text-xs font-medium text-gray-500 dark:text-gray-400 uppercase tracking-wide">{{ 'insurance.kpi_monthly' | translate }}</p>
<p class="mt-2 text-2xl font-bold text-amber-600 dark:text-amber-400">{{ totalMonthly() | number:'1.2-2' }} <span class="text-sm font-normal text-gray-400">CHF</span></p>
</div>
<!-- Count -->
<div class="bg-white dark:bg-gray-800 rounded-xl border border-gray-200 dark:border-gray-700 p-5">
<p class="text-xs font-medium text-gray-500 dark:text-gray-400 uppercase tracking-wide">{{ 'insurance.kpi_count' | translate }}</p>
<p class="mt-2 text-2xl font-bold text-gray-900 dark:text-white">{{ insurances().length }}</p>
</div>
<!-- Coverage status -->
<div class="bg-white dark:bg-gray-800 rounded-xl border border-gray-200 dark:border-gray-700 p-5">
<p class="text-xs font-medium text-gray-500 dark:text-gray-400 uppercase tracking-wide">{{ 'insurance.kpi_covered' | translate }}</p>
<p class="mt-2 text-2xl font-bold text-emerald-600 dark:text-emerald-400">
{{ coveredTypes().size }} / {{ insuranceTypes.length }}
</p>
</div>
</div>
<!-- Coverage Checklist -->
<div class="bg-white dark:bg-gray-800 rounded-xl border border-gray-200 dark:border-gray-700 p-5">
<h2 class="text-sm font-semibold text-gray-700 dark:text-gray-300 mb-4">{{ 'insurance.checklist_title' | translate }}</h2>
<div class="grid grid-cols-2 sm:grid-cols-4 gap-3">
@for (type of checklist; track type) {
<div class="flex items-center gap-2 p-2.5 rounded-lg"
[class]="coveredTypes().has(type)
? 'bg-emerald-50 dark:bg-emerald-900/20 border border-emerald-200 dark:border-emerald-800'
: 'bg-amber-50 dark:bg-amber-900/20 border border-amber-200 dark:border-amber-800'">
@if (coveredTypes().has(type)) {
<svg class="w-4 h-4 text-emerald-600 dark:text-emerald-400 flex-shrink-0" fill="currentColor" viewBox="0 0 20 20">
<path fill-rule="evenodd" d="M10 18a8 8 0 100-16 8 8 0 000 16zm3.707-9.293a1 1 0 00-1.414-1.414L9 10.586 7.707 9.293a1 1 0 00-1.414 1.414l2 2a1 1 0 001.414 0l4-4z" clip-rule="evenodd"/>
</svg>
} @else {
<svg class="w-4 h-4 text-amber-500 dark:text-amber-400 flex-shrink-0" fill="currentColor" viewBox="0 0 20 20">
<path fill-rule="evenodd" d="M8.257 3.099c.765-1.36 2.722-1.36 3.486 0l5.58 9.92c.75 1.334-.213 2.98-1.742 2.98H4.42c-1.53 0-2.493-1.646-1.743-2.98l5.58-9.92zM11 13a1 1 0 11-2 0 1 1 0 012 0zm-1-8a1 1 0 00-1 1v3a1 1 0 002 0V6a1 1 0 00-1-1z" clip-rule="evenodd"/>
</svg>
}
<span class="text-xs font-medium"
[class]="coveredTypes().has(type)
? 'text-emerald-700 dark:text-emerald-300'
: 'text-amber-700 dark:text-amber-300'">
{{ ('insurance.types.' + type) | translate }}
</span>
</div>
}
</div>
<p class="mt-3 text-xs text-gray-400 dark:text-gray-500">{{ 'insurance.checklist_hint' | translate }}</p>
</div>
<!-- Insurance List -->
<div class="bg-white dark:bg-gray-800 rounded-xl border border-gray-200 dark:border-gray-700 overflow-hidden">
<div class="px-5 py-4 border-b border-gray-100 dark:border-gray-700">
<h2 class="text-sm font-semibold text-gray-700 dark:text-gray-300">{{ 'insurance.list_title' | translate }}</h2>
</div>
@if (loading()) {
<div class="py-12 text-center text-sm text-gray-400">{{ 'insurance.loading' | translate }}</div>
} @else if (insurances().length === 0) {
<div class="py-12 text-center">
<svg class="mx-auto w-10 h-10 text-gray-300 dark:text-gray-600 mb-3" fill="currentColor" viewBox="0 0 20 20">
<path fill-rule="evenodd" d="M10 1.944A11.954 11.954 0 012.166 5C2.056 5.649 2 6.319 2 7c0 5.225 3.34 9.67 8 11.317C14.66 16.67 18 12.225 18 7c0-.682-.057-1.35-.166-2.001A11.954 11.954 0 0110 1.944z" clip-rule="evenodd"/>
</svg>
<p class="text-sm text-gray-500 dark:text-gray-400">{{ 'insurance.no_entries' | translate }}</p>
</div>
} @else {
<div class="divide-y divide-gray-100 dark:divide-gray-700">
@for (ins of insurances(); track ins.id) {
<div class="flex items-center justify-between px-5 py-4 hover:bg-gray-50 dark:hover:bg-gray-700/50 transition-colors">
<div class="flex items-center gap-4 min-w-0">
<!-- Type badge -->
<span class="shrink-0 px-2.5 py-1 rounded-full text-xs font-medium bg-violet-100 dark:bg-violet-900/30 text-violet-700 dark:text-violet-300">
{{ ('insurance.types.' + ins.insurance_type) | translate }}
</span>
<div class="min-w-0">
<p class="text-sm font-semibold text-gray-900 dark:text-white truncate">{{ ins.insurer }}</p>
@if (ins.policy_number) {
<p class="text-xs text-gray-400 dark:text-gray-500">{{ ins.policy_number }}</p>
}
</div>
</div>
<div class="flex items-center gap-6 shrink-0 ml-4">
<!-- Premium -->
<div class="text-right hidden sm:block">
<p class="text-sm font-semibold text-amber-600 dark:text-amber-400">{{ ins.premium | number:'1.2-2' }} CHF</p>
<p class="text-xs text-gray-400">{{ ('insurance.period.' + ins.premium_period) | translate }}</p>
</div>
<!-- Monthly equivalent -->
<div class="text-right">
<p class="text-xs text-gray-500 dark:text-gray-400">{{ monthlyEquivalent(ins) | number:'1.2-2' }} CHF/{{ 'insurance.month_short' | translate }}</p>
</div>
<!-- Actions -->
<div class="flex items-center gap-1">
<button (click)="openEdit(ins)"
class="p-1.5 text-gray-400 hover:text-violet-600 dark:hover:text-violet-400 rounded-lg hover:bg-gray-100 dark:hover:bg-gray-700 transition-colors">
<svg class="w-4 h-4" fill="none" viewBox="0 0 24 24">
<path stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="m14.304 4.844 2.852 2.852M7 7H4a1 1 0 0 0-1 1v10a1 1 0 0 0 1 1h11a1 1 0 0 0 1-1v-4.5m2.409-9.91a2.017 2.017 0 0 1 0 2.853l-6.845 6.845L8 14l.713-3.564 6.844-6.846a2.015 2.015 0 0 1 2.852 0Z"/>
</svg>
</button>
<button (click)="confirmDelete(ins)"
class="p-1.5 text-gray-400 hover:text-red-600 dark:hover:text-red-400 rounded-lg hover:bg-gray-100 dark:hover:bg-gray-700 transition-colors">
<svg class="w-4 h-4" fill="none" viewBox="0 0 24 24">
<path stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M5 7h14m-9 3v8m4-8v8M10 3h4a1 1 0 0 1 1 1v3H9V4a1 1 0 0 1 1-1ZM6 7h12v13a1 1 0 0 1-1 1H7a1 1 0 0 1-1-1V7Z"/>
</svg>
</button>
</div>
</div>
</div>
}
</div>
}
</div>
</div>
<!-- Create / Edit Modal -->
@if (showModal()) {
<div class="fixed inset-0 z-50 flex items-center justify-center bg-black/50 backdrop-blur-sm p-4">
<div class="bg-white dark:bg-gray-800 rounded-2xl shadow-xl w-full max-w-lg max-h-[90vh] overflow-y-auto">
<div class="flex items-center justify-between px-6 py-4 border-b border-gray-100 dark:border-gray-700">
<h2 class="text-base font-semibold text-gray-900 dark:text-white">
{{ (editTarget() ? 'insurance.edit_title' : 'insurance.create_title') | translate }}
</h2>
<button (click)="closeModal()" class="text-gray-400 hover:text-gray-600 dark:hover:text-gray-300">
<svg class="w-5 h-5" fill="none" viewBox="0 0 24 24">
<path stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 18 17.94 6M18 18 6.06 6"/>
</svg>
</button>
</div>
<div class="px-6 py-5 space-y-4">
<!-- Type -->
<div>
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1.5">{{ 'insurance.label_type' | translate }}</label>
<select
[ngModel]="form().insurance_type"
(ngModelChange)="setField('insurance_type', $event)"
class="block w-full rounded-lg border border-gray-300 dark:border-gray-600 bg-white dark:bg-gray-700 text-sm text-gray-900 dark:text-white px-3 py-2.5 focus:ring-2 focus:ring-violet-500 focus:border-violet-500">
@for (type of insuranceTypes; track type) {
<option [value]="type">{{ ('insurance.types.' + type) | translate }}</option>
}
</select>
</div>
<!-- Insurer -->
<div>
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1.5">{{ 'insurance.label_insurer' | translate }}</label>
<input type="text"
[ngModel]="form().insurer"
(ngModelChange)="setField('insurer', $event)"
[placeholder]="'insurance.placeholder_insurer' | translate"
class="block w-full rounded-lg border border-gray-300 dark:border-gray-600 bg-white dark:bg-gray-700 text-sm text-gray-900 dark:text-white px-3 py-2.5 focus:ring-2 focus:ring-violet-500 focus:border-violet-500" />
</div>
<!-- Policy number -->
<div>
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1.5">
{{ 'insurance.label_policy_number' | translate }}
<span class="text-gray-400 font-normal">({{ 'common.optional' | translate }})</span>
</label>
<input type="text"
[ngModel]="form().policy_number"
(ngModelChange)="setField('policy_number', $event)"
[placeholder]="'insurance.placeholder_policy_number' | translate"
class="block w-full rounded-lg border border-gray-300 dark:border-gray-600 bg-white dark:bg-gray-700 text-sm text-gray-900 dark:text-white px-3 py-2.5 focus:ring-2 focus:ring-violet-500 focus:border-violet-500" />
</div>
<!-- Premium + Period -->
<div class="grid grid-cols-2 gap-3">
<div>
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1.5">{{ 'insurance.label_premium' | translate }}</label>
<input type="number" min="0" step="0.01"
[ngModel]="form().premium"
(ngModelChange)="setField('premium', $event)"
class="block w-full rounded-lg border border-gray-300 dark:border-gray-600 bg-white dark:bg-gray-700 text-sm text-gray-900 dark:text-white px-3 py-2.5 focus:ring-2 focus:ring-violet-500 focus:border-violet-500" />
</div>
<div>
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1.5">{{ 'insurance.label_period' | translate }}</label>
<select
[ngModel]="form().premium_period"
(ngModelChange)="setField('premium_period', $event)"
class="block w-full rounded-lg border border-gray-300 dark:border-gray-600 bg-white dark:bg-gray-700 text-sm text-gray-900 dark:text-white px-3 py-2.5 focus:ring-2 focus:ring-violet-500 focus:border-violet-500">
@for (p of periodChoices; track p) {
<option [value]="p">{{ ('insurance.period.' + p) | translate }}</option>
}
</select>
</div>
</div>
<!-- Coverage amount + Deductible -->
<div class="grid grid-cols-2 gap-3">
<div>
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1.5">
{{ 'insurance.label_coverage' | translate }}
<span class="text-gray-400 font-normal">({{ 'common.optional' | translate }})</span>
</label>
<input type="number" min="0" step="100"
[ngModel]="form().coverage_amount"
(ngModelChange)="setField('coverage_amount', $event || null)"
class="block w-full rounded-lg border border-gray-300 dark:border-gray-600 bg-white dark:bg-gray-700 text-sm text-gray-900 dark:text-white px-3 py-2.5 focus:ring-2 focus:ring-violet-500 focus:border-violet-500" />
</div>
<div>
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1.5">
{{ 'insurance.label_deductible' | translate }}
<span class="text-gray-400 font-normal">({{ 'common.optional' | translate }})</span>
</label>
<input type="number" min="0" step="50"
[ngModel]="form().deductible"
(ngModelChange)="setField('deductible', $event || null)"
class="block w-full rounded-lg border border-gray-300 dark:border-gray-600 bg-white dark:bg-gray-700 text-sm text-gray-900 dark:text-white px-3 py-2.5 focus:ring-2 focus:ring-violet-500 focus:border-violet-500" />
</div>
</div>
<!-- Valid from / until -->
<div class="grid grid-cols-2 gap-3">
<div>
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1.5">
{{ 'insurance.label_valid_from' | translate }}
<span class="text-gray-400 font-normal">({{ 'common.optional' | translate }})</span>
</label>
<input type="date"
[ngModel]="form().valid_from"
(ngModelChange)="setField('valid_from', $event || null)"
class="block w-full rounded-lg border border-gray-300 dark:border-gray-600 bg-white dark:bg-gray-700 text-sm text-gray-900 dark:text-white px-3 py-2.5 focus:ring-2 focus:ring-violet-500 focus:border-violet-500" />
</div>
<div>
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1.5">
{{ 'insurance.label_valid_until' | translate }}
<span class="text-gray-400 font-normal">({{ 'common.optional' | translate }})</span>
</label>
<input type="date"
[ngModel]="form().valid_until"
(ngModelChange)="setField('valid_until', $event || null)"
class="block w-full rounded-lg border border-gray-300 dark:border-gray-600 bg-white dark:bg-gray-700 text-sm text-gray-900 dark:text-white px-3 py-2.5 focus:ring-2 focus:ring-violet-500 focus:border-violet-500" />
</div>
</div>
<!-- Notes -->
<div>
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1.5">
{{ 'insurance.label_notes' | translate }}
<span class="text-gray-400 font-normal">({{ 'common.optional' | translate }})</span>
</label>
<textarea rows="2"
[ngModel]="form().notes"
(ngModelChange)="setField('notes', $event)"
class="block w-full rounded-lg border border-gray-300 dark:border-gray-600 bg-white dark:bg-gray-700 text-sm text-gray-900 dark:text-white px-3 py-2.5 focus:ring-2 focus:ring-violet-500 focus:border-violet-500 resize-none"></textarea>
</div>
</div>
<!-- Footer -->
<div class="px-6 py-4 border-t border-gray-100 dark:border-gray-700 flex justify-end gap-3">
<button (click)="closeModal()"
class="px-4 py-2 text-sm font-medium text-gray-700 dark:text-gray-300 bg-white dark:bg-gray-700 border border-gray-300 dark:border-gray-600 rounded-lg hover:bg-gray-50 dark:hover:bg-gray-600 transition-colors">
{{ 'common.cancel' | translate }}
</button>
<button (click)="save()" [disabled]="saving()"
class="px-4 py-2 text-sm font-medium text-white bg-violet-600 hover:bg-violet-700 disabled:opacity-60 rounded-lg transition-colors">
{{ saving() ? ('common.save' | translate) + '...' : ('common.save_changes' | translate) }}
</button>
</div>
</div>
</div>
}
<!-- Delete confirm -->
@if (deleteTarget()) {
<div class="fixed inset-0 z-50 flex items-center justify-center bg-black/50 backdrop-blur-sm p-4">
<div class="bg-white dark:bg-gray-800 rounded-2xl shadow-xl w-full max-w-sm p-6">
<h2 class="text-base font-semibold text-gray-900 dark:text-white mb-2">{{ 'common.delete_confirm_title' | translate }}</h2>
<p class="text-sm text-gray-500 dark:text-gray-400 mb-6">{{ 'common.delete_confirm_text' | translate }}</p>
<div class="flex justify-end gap-3">
<button (click)="cancelDelete()"
class="px-4 py-2 text-sm font-medium text-gray-700 dark:text-gray-300 bg-white dark:bg-gray-700 border border-gray-300 dark:border-gray-600 rounded-lg hover:bg-gray-50 dark:hover:bg-gray-600 transition-colors">
{{ 'common.cancel' | translate }}
</button>
<button (click)="executeDelete()"
class="px-4 py-2 text-sm font-medium text-white bg-red-600 hover:bg-red-700 rounded-lg transition-colors">
{{ 'common.delete' | translate }}
</button>
</div>
</div>
</div>
}