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
This commit is contained in:
Daniel Krähenbühl
2026-05-25 22:05:37 +02:00
parent 1a7ef09805
commit c03d2a97ab
26 changed files with 2456 additions and 44 deletions
+8
View File
@@ -14,6 +14,10 @@ import { ExpenseList } from './expenses/expense-list/expense-list';
import { Profile } from './profile/profile';
import { Settings } from './settings/settings';
import { Calendar } from './calendar/calendar';
import { InsuranceOverview } from './insurance/overview/overview';
import { InsuranceDocuments } from './insurance/documents/documents';
import { InsuranceAnalyse } from './insurance/analyse/analyse';
import { Priminfo } from './insurance/priminfo/priminfo';
export const routes: Routes = [
{ path: 'login', component: Login },
{ path: 'register', component: Register },
@@ -34,6 +38,10 @@ export const routes: Routes = [
{ path: 'profile', component: Profile },
{ path: 'settings', component: Settings },
{ path: 'calendar', component: Calendar },
{ path: 'insurance', component: InsuranceOverview },
{ path: 'insurance-documents', component: InsuranceDocuments },
{ path: 'insurance-analyse', component: InsuranceAnalyse },
{ path: 'insurance-priminfo', component: Priminfo },
],
},
{ path: '**', redirectTo: 'dashboard' },
@@ -0,0 +1,24 @@
<div class="p-4 sm:p-6 space-y-6">
<div>
<h1 class="text-2xl font-bold text-gray-900 dark:text-white">{{ 'insurance_analyse.title' | translate }}</h1>
<p class="text-sm text-gray-500 dark:text-gray-400 mt-0.5">{{ 'insurance_analyse.subtitle' | translate }}</p>
</div>
<!-- Coming soon card -->
<div class="bg-white dark:bg-gray-800 rounded-xl border border-dashed border-gray-300 dark:border-gray-600 p-12 text-center">
<div class="mx-auto w-14 h-14 rounded-full bg-emerald-100 dark:bg-emerald-900/30 flex items-center justify-center mb-4">
<svg class="w-7 h-7 text-emerald-600 dark:text-emerald-400" fill="none" viewBox="0 0 24 24">
<path stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M10 3v4a1 1 0 0 1-1 1H5m4 8h6m-6-4h6m4-8v16a1 1 0 0 1-1 1H6a1 1 0 0 1-1-1V7.914a1 1 0 0 1 .293-.707l3.914-3.914A1 1 0 0 1 9.914 3H18a1 1 0 0 1 1 1Z"/>
</svg>
</div>
<h2 class="text-base font-semibold text-gray-900 dark:text-white mb-2">{{ 'insurance_analyse.coming_soon_title' | translate }}</h2>
<p class="text-sm text-gray-500 dark:text-gray-400 max-w-sm mx-auto">{{ 'insurance_analyse.coming_soon_text' | translate }}</p>
<div class="mt-6 flex flex-wrap justify-center gap-2">
<span class="px-3 py-1 text-xs font-medium rounded-full bg-emerald-100 dark:bg-emerald-900/30 text-emerald-700 dark:text-emerald-300">{{ 'insurance_analyse.tag_soll' | translate }}</span>
<span class="px-3 py-1 text-xs font-medium rounded-full bg-emerald-100 dark:bg-emerald-900/30 text-emerald-700 dark:text-emerald-300">{{ 'insurance_analyse.tag_gaps' | translate }}</span>
<span class="px-3 py-1 text-xs font-medium rounded-full bg-emerald-100 dark:bg-emerald-900/30 text-emerald-700 dark:text-emerald-300">{{ 'insurance_analyse.tag_recommendations' | translate }}</span>
</div>
</div>
</div>
@@ -0,0 +1,10 @@
import { Component } from '@angular/core';
import { TranslateModule } from '@ngx-translate/core';
@Component({
selector: 'app-insurance-analyse',
standalone: true,
imports: [TranslateModule],
templateUrl: './analyse.html',
})
export class InsuranceAnalyse {}
@@ -0,0 +1,25 @@
<div class="p-4 sm:p-6 space-y-6">
<div>
<h1 class="text-2xl font-bold text-gray-900 dark:text-white">{{ 'insurance_docs.title' | translate }}</h1>
<p class="text-sm text-gray-500 dark:text-gray-400 mt-0.5">{{ 'insurance_docs.subtitle' | translate }}</p>
</div>
<!-- Coming soon card -->
<div class="bg-white dark:bg-gray-800 rounded-xl border border-dashed border-gray-300 dark:border-gray-600 p-12 text-center">
<div class="mx-auto w-14 h-14 rounded-full bg-violet-100 dark:bg-violet-900/30 flex items-center justify-center mb-4">
<!-- Sparkles / AI icon -->
<svg class="w-7 h-7 text-violet-600 dark:text-violet-400" fill="none" viewBox="0 0 24 24">
<path stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9.143 4 7 2m-3 5L2 9m7-3a5 5 0 0 1 5 5m3-7 2-2m-2 7 2 2M5 12a5 5 0 0 0 5 5m0 0 2 2m-2-2-2 2m2-2V9"/>
</svg>
</div>
<h2 class="text-base font-semibold text-gray-900 dark:text-white mb-2">{{ 'insurance_docs.coming_soon_title' | translate }}</h2>
<p class="text-sm text-gray-500 dark:text-gray-400 max-w-sm mx-auto">{{ 'insurance_docs.coming_soon_text' | translate }}</p>
<div class="mt-6 flex flex-wrap justify-center gap-2">
<span class="px-3 py-1 text-xs font-medium rounded-full bg-violet-100 dark:bg-violet-900/30 text-violet-700 dark:text-violet-300">PDF Upload</span>
<span class="px-3 py-1 text-xs font-medium rounded-full bg-violet-100 dark:bg-violet-900/30 text-violet-700 dark:text-violet-300">AI / IDP</span>
<span class="px-3 py-1 text-xs font-medium rounded-full bg-violet-100 dark:bg-violet-900/30 text-violet-700 dark:text-violet-300">Claude API</span>
</div>
</div>
</div>
@@ -0,0 +1,10 @@
import { Component } from '@angular/core';
import { TranslateModule } from '@ngx-translate/core';
@Component({
selector: 'app-insurance-documents',
standalone: true,
imports: [TranslateModule],
templateUrl: './documents.html',
})
export class InsuranceDocuments {}
@@ -0,0 +1,302 @@
<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>
}
@@ -0,0 +1,163 @@
import { Component, OnInit, signal, computed } from '@angular/core';
import { CommonModule } from '@angular/common';
import { FormsModule } from '@angular/forms';
import { TranslateModule, TranslateService } from '@ngx-translate/core';
import { ApiService } from '../../services/api';
export interface Insurance {
id?: number;
insurance_type: string;
insurer: string;
policy_number: string;
premium: number;
premium_period: string;
coverage_amount: number | null;
deductible: number | null;
valid_from: string | null;
valid_until: string | null;
notes: string;
}
export const INSURANCE_TYPES = [
'kvg', 'kk_zusatz', 'nbu', 'haftpflicht', 'hausrat',
'mfz', 'rechtsschutz', 'saule_3a', 'leben', 'reise', 'other',
];
export const PERIOD_CHOICES = ['monthly', 'quarterly', 'semi_annual', 'annual'];
// Monthly premium factor for each period
const PERIOD_TO_MONTHLY: Record<string, number> = {
monthly: 1,
quarterly: 1 / 3,
semi_annual: 1 / 6,
annual: 1 / 12,
};
// Swiss recommended coverage checklist
export const SWISS_COVERAGE_CHECKLIST = [
'kvg', 'haftpflicht', 'hausrat', 'nbu',
];
@Component({
selector: 'app-insurance-overview',
standalone: true,
imports: [CommonModule, FormsModule, TranslateModule],
templateUrl: './overview.html',
})
export class InsuranceOverview implements OnInit {
insurances = signal<Insurance[]>([]);
loading = signal(true);
saving = signal(false);
showModal = signal(false);
deleteTarget = signal<Insurance | null>(null);
editTarget = signal<Insurance | null>(null);
form = signal<Insurance>({
insurance_type: 'kvg',
insurer: '',
policy_number: '',
premium: 0,
premium_period: 'monthly',
coverage_amount: null,
deductible: null,
valid_from: null,
valid_until: null,
notes: '',
});
readonly insuranceTypes = INSURANCE_TYPES;
readonly periodChoices = PERIOD_CHOICES;
readonly checklist = SWISS_COVERAGE_CHECKLIST;
// KPI: total monthly premium across all insurances
totalMonthly = computed(() =>
this.insurances().reduce((sum, ins) => {
const factor = PERIOD_TO_MONTHLY[ins.premium_period] ?? 1;
return sum + (Number(ins.premium) * factor);
}, 0)
);
// Which checklist items are covered
coveredTypes = computed(() => new Set(this.insurances().map(i => i.insurance_type)));
constructor(private api: ApiService) {}
ngOnInit() {
this.load();
}
load() {
this.loading.set(true);
this.api.getInsurances().subscribe({
next: (data) => { this.insurances.set(data); this.loading.set(false); },
error: () => this.loading.set(false),
});
}
openCreate() {
this.editTarget.set(null);
this.form.set({
insurance_type: 'kvg',
insurer: '',
policy_number: '',
premium: 0,
premium_period: 'monthly',
coverage_amount: null,
deductible: null,
valid_from: null,
valid_until: null,
notes: '',
});
this.showModal.set(true);
}
openEdit(ins: Insurance) {
this.editTarget.set(ins);
this.form.set({ ...ins });
this.showModal.set(true);
}
closeModal() {
this.showModal.set(false);
this.editTarget.set(null);
}
save() {
const data = this.form();
this.saving.set(true);
const target = this.editTarget();
const req = target?.id
? this.api.updateInsurance(target.id, data)
: this.api.createInsurance(data);
req.subscribe({
next: () => { this.saving.set(false); this.closeModal(); this.load(); },
error: () => this.saving.set(false),
});
}
confirmDelete(ins: Insurance) {
this.deleteTarget.set(ins);
}
cancelDelete() {
this.deleteTarget.set(null);
}
executeDelete() {
const target = this.deleteTarget();
if (!target?.id) return;
this.api.deleteInsurance(target.id).subscribe({
next: () => { this.deleteTarget.set(null); this.load(); },
});
}
// Form helpers — signal-form pattern
setField<K extends keyof Insurance>(key: K, value: Insurance[K]) {
this.form.update(f => ({ ...f, [key]: value }));
}
monthlyEquivalent(ins: Insurance): number {
const factor = PERIOD_TO_MONTHLY[ins.premium_period] ?? 1;
return Number(ins.premium) * factor;
}
}
@@ -0,0 +1,382 @@
<div class="p-4 sm:p-6 space-y-6">
<!-- Header -->
<div class="flex items-start justify-between gap-4">
<div>
<h1 class="text-2xl font-bold text-gray-900 dark:text-white">{{ 'priminfo.title' | translate }}</h1>
<p class="text-sm text-gray-500 dark:text-gray-400 mt-0.5">{{ 'priminfo.subtitle' | translate }}</p>
</div>
<button (click)="openPriminfo()"
class="shrink-0 flex items-center gap-2 px-3 py-2 text-sm font-medium text-violet-700 dark:text-violet-300 bg-violet-50 dark:bg-violet-900/20 border border-violet-200 dark:border-violet-800 rounded-lg hover:bg-violet-100 dark:hover:bg-violet-900/40 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="M18 14v4.833A1.166 1.166 0 0 1 16.833 20H5.167A1.167 1.167 0 0 1 4 18.833V7.167A1.166 1.166 0 0 1 5.167 6h4.618m4.447-2H20v5.768m-7.889 2.121 7.778-7.778"/>
</svg>
priminfo.admin.ch
</button>
</div>
<!-- ── Section 1: PLZ → Ø-Prämien ───────────────────────────────── -->
<div class="bg-white dark:bg-gray-800 rounded-xl border border-gray-200 dark:border-gray-700 p-5">
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-3">
{{ 'priminfo.plz_label' | translate }}
</label>
<div class="flex gap-3">
<input
type="text"
inputmode="numeric"
maxlength="4"
[ngModel]="plzInput()"
(ngModelChange)="plzInput.set($event)"
(keydown)="onKeydown($event)"
[placeholder]="'priminfo.plz_placeholder' | translate"
class="block w-40 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 font-mono tracking-widest" />
<button (click)="search()" [disabled]="loading() || plzInput().trim().length < 4"
class="flex items-center gap-2 px-4 py-2.5 bg-violet-600 hover:bg-violet-700 disabled:opacity-50 text-white text-sm font-medium rounded-lg transition-colors">
@if (loading()) {
<svg class="w-4 h-4 animate-spin" fill="none" viewBox="0 0 24 24">
<circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4"></circle>
<path class="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4z"></path>
</svg>
} @else {
<svg class="w-4 h-4" fill="none" viewBox="0 0 24 24">
<path stroke="currentColor" stroke-linecap="round" stroke-width="2" d="m21 21-3.5-3.5M17 11A6 6 0 1 1 5 11a6 6 0 0 1 12 0Z"/>
</svg>
}
{{ 'priminfo.search' | translate }}
</button>
</div>
<p class="mt-2 text-xs text-gray-400 dark:text-gray-500">{{ 'priminfo.plz_hint' | translate }}</p>
</div>
<!-- Error (PLZ search) -->
@if (error()) {
<div class="bg-amber-50 dark:bg-amber-900/20 border border-amber-200 dark:border-amber-800 rounded-xl p-4 flex items-start gap-3">
<svg class="w-5 h-5 text-amber-500 flex-shrink-0 mt-0.5" 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>
<p class="text-sm text-amber-700 dark:text-amber-300">{{ 'priminfo.error_not_found' | translate }}</p>
</div>
}
<!-- Ø-Prämien Results -->
@if (results().length > 0) {
<div class="bg-white dark:bg-gray-800 rounded-xl border border-gray-200 dark:border-gray-700 p-5">
<div class="flex items-center gap-3 mb-4">
<div class="w-8 h-8 rounded-full bg-violet-100 dark:bg-violet-900/30 flex items-center justify-center">
<svg class="w-4 h-4 text-violet-600 dark:text-violet-400" fill="currentColor" viewBox="0 0 20 20">
<path fill-rule="evenodd" d="M5.05 4.05a7 7 0 119.9 9.9L10 18.9l-4.95-4.95a7 7 0 010-9.9zM10 11a2 2 0 100-4 2 2 0 000 4z" clip-rule="evenodd"/>
</svg>
</div>
<div>
<p class="text-sm font-semibold text-gray-900 dark:text-white">
{{ results()[0].plz }} {{ results()[0].ort }}
</p>
<p class="text-xs text-gray-500 dark:text-gray-400">
{{ results()[0].kanton }} · {{ results()[0].bezirk }}
</p>
</div>
<div class="ml-auto">
<span class="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">
{{ 'priminfo.region_label' | translate }} {{ results()[0].region }}
</span>
</div>
</div>
@if (results().length > 1) {
<div class="mb-4 px-3 py-2 bg-amber-50 dark:bg-amber-900/20 rounded-lg border border-amber-200 dark:border-amber-800">
<p class="text-xs text-amber-700 dark:text-amber-300">{{ 'priminfo.multi_ort_hint' | translate }}</p>
</div>
}
@for (entry of uniqueRegions(); track entry.region + entry.kanton) {
<div class="mb-4 last:mb-0">
@if (uniqueRegions().length > 1) {
<p class="text-xs font-semibold text-gray-500 dark:text-gray-400 mb-2 uppercase tracking-wide">
{{ entry.gemeinde }} — {{ 'priminfo.region_label' | translate }} {{ entry.region }}
</p>
}
<div class="grid grid-cols-3 gap-3">
<div class="bg-gray-50 dark:bg-gray-700/50 rounded-lg p-3 text-center">
<p class="text-xs text-gray-500 dark:text-gray-400 mb-1">{{ 'priminfo.col_child' | translate }}</p>
<p class="text-lg font-bold text-emerald-600 dark:text-emerald-400">{{ entry.avg_child | number:'1.2-2' }}</p>
<p class="text-xs text-gray-400">CHF/{{ 'priminfo.month' | translate }}</p>
</div>
<div class="bg-gray-50 dark:bg-gray-700/50 rounded-lg p-3 text-center">
<p class="text-xs text-gray-500 dark:text-gray-400 mb-1">{{ 'priminfo.col_young' | translate }}</p>
<p class="text-lg font-bold text-amber-600 dark:text-amber-400">{{ entry.avg_young_adult | number:'1.2-2' }}</p>
<p class="text-xs text-gray-400">CHF/{{ 'priminfo.month' | translate }}</p>
</div>
<div class="bg-gray-50 dark:bg-gray-700/50 rounded-lg p-3 text-center">
<p class="text-xs text-gray-500 dark:text-gray-400 mb-1">{{ 'priminfo.col_adult' | translate }}</p>
<p class="text-lg font-bold text-violet-600 dark:text-violet-400">{{ entry.avg_adult | number:'1.2-2' }}</p>
<p class="text-xs text-gray-400">CHF/{{ 'priminfo.month' | translate }}</p>
</div>
</div>
</div>
}
<p class="mt-4 text-xs text-gray-400 dark:text-gray-500">
{{ 'priminfo.disclaimer' | translate : { year: dataYear() } }}
</p>
</div>
} @else if (searched() && !loading() && !error()) {
<div class="text-center py-10 text-sm text-gray-400">{{ 'priminfo.no_results' | translate }}</div>
}
<!-- ── Section 2: Versicherer-Vergleich ─────────────────────────── -->
<div class="bg-white dark:bg-gray-800 rounded-xl border border-emerald-200 dark:border-emerald-800 p-5">
<!-- Card header -->
<div class="flex items-center gap-3 mb-5">
<div class="w-8 h-8 rounded-full bg-emerald-100 dark:bg-emerald-900/30 flex items-center justify-center">
<svg class="w-4 h-4 text-emerald-600 dark:text-emerald-400" fill="currentColor" viewBox="0 0 24 24">
<path fill-rule="evenodd" d="M11.644 3.066a1 1 0 0 1 .712 0l7 2.666A1 1 0 0 1 20 6.68a17.694 17.694 0 0 1-2.023 7.98 17.406 17.406 0 0 1-5.402 6.158 1 1 0 0 1-1.15 0 17.405 17.405 0 0 1-5.403-6.157A17.695 17.695 0 0 1 4 6.68a1 1 0 0 1 .644-.949l7-2.666Zm4.014 7.187a1 1 0 0 0-1.316-1.506l-3.296 2.884-.839-.838a1 1 0 0 0-1.414 1.414l1.5 1.5a1 1 0 0 0 1.366.046l4-3.5Z" clip-rule="evenodd"/>
</svg>
</div>
<div>
<h2 class="text-sm font-semibold text-gray-900 dark:text-white">{{ 'priminfo.vergleich_card_title' | translate }}</h2>
<p class="text-xs text-gray-500 dark:text-gray-400">{{ 'priminfo.vergleich_card_subtitle' | translate }}</p>
</div>
</div>
<!-- Filter grid -->
<div class="grid grid-cols-1 sm:grid-cols-2 gap-4">
<!-- Geburtsjahr -->
<div>
<label class="block text-xs font-medium text-gray-600 dark:text-gray-400 mb-1.5">
{{ 'priminfo.geburtsjahr_label' | translate }}
</label>
<div class="flex items-center gap-2">
<input
type="text"
inputmode="numeric"
maxlength="4"
[ngModel]="geburtsjahrInput()"
(ngModelChange)="onGeburtsjahrChange($event)"
[placeholder]="'priminfo.geburtsjahr_placeholder' | translate"
class="block w-28 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 focus:ring-2 focus:ring-emerald-500 focus:border-emerald-500 font-mono tracking-widest" />
@if (ageClass()) {
<span class="inline-flex items-center px-2 py-0.5 rounded text-xs font-medium"
[class]="ageClass() === 'AKL-KIN' ? 'bg-sky-100 text-sky-700 dark:bg-sky-900/30 dark:text-sky-300' :
ageClass() === 'AKL-JUG' ? 'bg-amber-100 text-amber-700 dark:bg-amber-900/30 dark:text-amber-300' :
'bg-emerald-100 text-emerald-700 dark:bg-emerald-900/30 dark:text-emerald-300'">
@if (ageClass() === 'AKL-KIN') { {{ 'priminfo.age_child' | translate }} }
@else if (ageClass() === 'AKL-JUG') { {{ 'priminfo.age_young' | translate }} }
@else { {{ 'priminfo.age_adult' | translate }} }
</span>
}
</div>
</div>
<!-- Versicherungsmodell -->
<div>
<label class="block text-xs font-medium text-gray-600 dark:text-gray-400 mb-1.5">
{{ 'priminfo.modell_label' | translate }}
</label>
<select
[ngModel]="tariftyp()"
(ngModelChange)="tariftyp.set($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 focus:ring-2 focus:ring-emerald-500 focus:border-emerald-500">
<option value="TAR-BASE">{{ 'priminfo.modell_base' | translate }}</option>
<option value="TAR-HAM">{{ 'priminfo.modell_ham' | translate }}</option>
<option value="TAR-HMO">{{ 'priminfo.modell_hmo' | translate }}</option>
<option value="TAR-DIV">{{ 'priminfo.modell_div' | translate }}</option>
</select>
</div>
<!-- Franchise -->
<div>
<label class="block text-xs font-medium text-gray-600 dark:text-gray-400 mb-1.5">
{{ 'priminfo.franchise_label' | translate }}
</label>
<select
[ngModel]="franchisestufe()"
(ngModelChange)="franchisestufe.set($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 focus:ring-2 focus:ring-emerald-500 focus:border-emerald-500">
@for (opt of franchiseOptions(); track opt.code) {
<option [value]="opt.code">{{ opt.label }}</option>
}
</select>
</div>
<!-- Unfalldeckung -->
<div>
<label class="block text-xs font-medium text-gray-600 dark:text-gray-400 mb-1.5">
{{ 'priminfo.unfall_label' | translate }}
</label>
<div class="flex rounded-lg border border-gray-300 dark:border-gray-600 overflow-hidden">
<button type="button"
(click)="unfall.set('OHN-UNF')"
[class]="unfall() === 'OHN-UNF'
? 'flex-1 px-3 py-2 text-xs font-medium bg-emerald-600 text-white transition-colors'
: 'flex-1 px-3 py-2 text-xs font-medium bg-white dark:bg-gray-700 text-gray-700 dark:text-gray-300 hover:bg-gray-50 dark:hover:bg-gray-600 transition-colors'">
{{ 'priminfo.unfall_ohn' | translate }}
</button>
<button type="button"
(click)="unfall.set('MIT-UNF')"
[class]="unfall() === 'MIT-UNF'
? 'flex-1 px-3 py-2 text-xs font-medium bg-emerald-600 text-white border-l border-gray-300 dark:border-gray-600 transition-colors'
: 'flex-1 px-3 py-2 text-xs font-medium bg-white dark:bg-gray-700 text-gray-700 dark:text-gray-300 border-l border-gray-300 dark:border-gray-600 hover:bg-gray-50 dark:hover:bg-gray-600 transition-colors'">
{{ 'priminfo.unfall_mit' | translate }}
</button>
</div>
</div>
</div>
<!-- Unfall note -->
<p class="mt-3 text-xs text-gray-400 dark:text-gray-500 flex items-start gap-1.5">
<svg class="w-3.5 h-3.5 mt-0.5 flex-shrink-0 text-gray-400" fill="currentColor" viewBox="0 0 20 20">
<path fill-rule="evenodd" d="M18 10a8 8 0 11-16 0 8 8 0 0116 0zm-7-4a1 1 0 11-2 0 1 1 0 012 0zM9 9a1 1 0 000 2v3a1 1 0 001 1h1a1 1 0 100-2v-3a1 1 0 00-1-1H9z" clip-rule="evenodd"/>
</svg>
{{ 'priminfo.unfall_note' | translate }}
</p>
<!-- Vergleichen button -->
<div class="mt-4 flex items-center gap-3">
<button (click)="searchVergleich()"
[disabled]="!canVergleich() || vergleichLoading()"
class="flex items-center gap-2 px-4 py-2.5 bg-emerald-600 hover:bg-emerald-700 disabled:opacity-50 disabled:cursor-not-allowed text-white text-sm font-medium rounded-lg transition-colors">
@if (vergleichLoading()) {
<svg class="w-4 h-4 animate-spin" fill="none" viewBox="0 0 24 24">
<circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4"></circle>
<path class="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4z"></path>
</svg>
} @else {
<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="M3 7h13m0 0a3 3 0 1 1 6 0 3 3 0 0 1-6 0Zm6 7H4m0 0a3 3 0 1 0-6 0 3 3 0 0 0 6 0Z"/>
</svg>
}
{{ 'priminfo.vergleich_btn' | translate }}
</button>
@if (!canVergleich() && !vergleichLoading()) {
<p class="text-xs text-gray-400 dark:text-gray-500">{{ 'priminfo.plz_hint' | translate }} + {{ 'priminfo.geburtsjahr_label' | translate | lowercase }}</p>
}
</div>
</div>
<!-- Vergleich Error -->
@if (vergleichError()) {
<div class="bg-amber-50 dark:bg-amber-900/20 border border-amber-200 dark:border-amber-800 rounded-xl p-4 flex items-start gap-3">
<svg class="w-5 h-5 text-amber-500 flex-shrink-0 mt-0.5" 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>
<p class="text-sm text-amber-700 dark:text-amber-300">{{ 'priminfo.error_not_found' | translate }}</p>
</div>
}
<!-- Vergleich Results Table -->
@if (vergleichResults().length > 0) {
<div class="bg-white dark:bg-gray-800 rounded-xl border border-emerald-200 dark:border-emerald-800 overflow-hidden">
<!-- Table header bar -->
<div class="px-5 py-3 border-b border-emerald-100 dark:border-emerald-900 bg-emerald-50 dark:bg-emerald-900/20 flex items-center justify-between gap-3 flex-wrap">
<div class="flex items-center gap-2 text-sm font-semibold text-emerald-900 dark:text-emerald-200">
<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="M3 7h13m0 0a3 3 0 1 1 6 0 3 3 0 0 1-6 0Zm6 7H4m0 0a3 3 0 1 0-6 0 3 3 0 0 0 6 0Z"/>
</svg>
{{ vergleichMeta()?.ort }} · {{ 'priminfo.region_label' | translate }} {{ vergleichMeta()?.region }}
</div>
<div class="flex items-center gap-2 flex-wrap">
<span class="px-2 py-0.5 rounded text-xs font-medium bg-emerald-100 dark:bg-emerald-900/40 text-emerald-700 dark:text-emerald-300">
{{ 'priminfo.vergleich_data_year' | translate : { year: vergleichMeta()?.data_year } }}
</span>
<span class="text-xs text-emerald-700 dark:text-emerald-300">
{{ vergleichResults().length }} {{ 'priminfo.vergleich_hint' | translate }}
</span>
</div>
</div>
<!-- Scrollable table -->
<div class="overflow-x-auto">
<table class="w-full text-sm">
<thead>
<tr class="border-b border-gray-100 dark:border-gray-700">
<th class="px-4 py-3 text-left text-xs font-semibold text-gray-500 dark:text-gray-400 w-8">{{ 'priminfo.col_rank' | translate }}</th>
<th class="px-4 py-3 text-left text-xs font-semibold text-gray-500 dark:text-gray-400">{{ 'priminfo.col_insurer' | translate }}</th>
<th class="px-4 py-3 text-left text-xs font-semibold text-gray-500 dark:text-gray-400 hidden sm:table-cell">{{ 'priminfo.col_model' | translate }}</th>
<th class="px-4 py-3 text-right text-xs font-semibold text-gray-500 dark:text-gray-400 hidden sm:table-cell">{{ 'priminfo.col_franchise' | translate }}</th>
<th class="px-4 py-3 text-right text-xs font-semibold text-gray-500 dark:text-gray-400">{{ 'priminfo.col_premium' | translate }}</th>
</tr>
</thead>
<tbody class="divide-y divide-gray-50 dark:divide-gray-700/50">
@for (r of vergleichResults(); track r.versicherer_id; let i = $index) {
<tr [class]="isCheapest(r.praemie)
? 'bg-emerald-50 dark:bg-emerald-900/10'
: 'hover:bg-gray-50 dark:hover:bg-gray-700/30 transition-colors'">
<td class="px-4 py-3 text-xs text-gray-400 dark:text-gray-500 font-mono">{{ i + 1 }}</td>
<td class="px-4 py-3">
<div class="flex items-center gap-2 flex-wrap">
<span [class]="isCheapest(r.praemie)
? 'font-semibold text-emerald-700 dark:text-emerald-300'
: 'text-gray-800 dark:text-gray-200'">
{{ r.versicherer_name }}
</span>
@if (isCheapest(r.praemie)) {
<span class="inline-flex items-center gap-1 px-1.5 py-0.5 rounded text-xs font-medium bg-emerald-100 dark:bg-emerald-900/40 text-emerald-700 dark:text-emerald-300">
<svg class="w-3 h-3" 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>
{{ 'priminfo.cheapest_badge' | translate }}
</span>
}
</div>
</td>
<td class="px-4 py-3 text-xs text-gray-500 dark:text-gray-400 hidden sm:table-cell max-w-[180px] truncate">
{{ r.tarifbezeichnung }}
</td>
<td class="px-4 py-3 text-xs text-gray-500 dark:text-gray-400 text-right hidden sm:table-cell">
CHF {{ r.franchise_chf }}
</td>
<td class="px-4 py-3 text-right">
<span [class]="isCheapest(r.praemie)
? 'text-base font-bold text-emerald-700 dark:text-emerald-300'
: 'text-sm font-semibold text-gray-800 dark:text-gray-200'">
{{ r.praemie | number:'1.2-2' }}
</span>
<span class="text-xs text-gray-400 dark:text-gray-500"> CHF</span>
</td>
</tr>
}
</tbody>
</table>
</div>
</div>
} @else if (vergleichSearched() && !vergleichLoading() && !vergleichError()) {
<div class="bg-white dark:bg-gray-800 rounded-xl border border-emerald-200 dark:border-emerald-800 p-8 text-center">
<p class="text-sm text-gray-400 dark:text-gray-500">{{ 'priminfo.vergleich_no_results' | translate }}</p>
</div>
}
<!-- CTA: open official Priminfo -->
<div class="bg-violet-50 dark:bg-violet-900/20 border border-violet-200 dark:border-violet-800 rounded-xl p-5 flex items-center justify-between gap-4">
<div>
<p class="text-sm font-semibold text-violet-900 dark:text-violet-200">{{ 'priminfo.cta_title' | translate }}</p>
<p class="text-xs text-violet-700 dark:text-violet-300 mt-0.5">{{ 'priminfo.cta_text' | translate }}</p>
</div>
<button (click)="openPriminfo()"
class="shrink-0 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="M18 14v4.833A1.166 1.166 0 0 1 16.833 20H5.167A1.167 1.167 0 0 1 4 18.833V7.167A1.166 1.166 0 0 1 5.167 6h4.618m4.447-2H20v5.768m-7.889 2.121 7.778-7.778"/>
</svg>
{{ 'priminfo.cta_btn' | translate }}
</button>
</div>
<!-- Info box -->
<div class="bg-gray-50 dark:bg-gray-700/40 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-3">{{ 'priminfo.info_title' | translate }}</h2>
<div class="space-y-2 text-xs text-gray-500 dark:text-gray-400">
<p>{{ 'priminfo.info_1' | translate }}</p>
<p>{{ 'priminfo.info_2' | translate }}</p>
<p>{{ 'priminfo.info_3' | translate }}</p>
</div>
<p class="mt-3 text-xs text-gray-400 dark:text-gray-500">
{{ 'priminfo.source' | translate }}
<a href="https://www.priminfo.admin.ch" target="_blank" rel="noopener noreferrer"
class="text-violet-600 dark:text-violet-400 hover:underline">priminfo.admin.ch</a>
</p>
</div>
</div>
@@ -0,0 +1,209 @@
import { Component, signal, computed } from '@angular/core';
import { CommonModule } from '@angular/common';
import { FormsModule } from '@angular/forms';
import { TranslateModule } from '@ngx-translate/core';
import { ApiService } from '../../services/api';
export interface PraemienResult {
plz: string;
ort: string;
kanton: string;
region: number;
gemeinde: string;
bezirk: string;
avg_adult: number;
avg_young_adult: number;
avg_child: number;
data_year: number;
}
export interface VergleichResult {
versicherer_id: number;
versicherer_name: string;
tarifbezeichnung: string;
franchise_chf: number;
praemie: string;
}
interface FranchiseOption {
code: string;
chf: number;
label: string;
}
const FRANCHISE_ERW: FranchiseOption[] = [
{ code: 'FRAST1', chf: 300, label: "CHF 300" },
{ code: 'FRAST2', chf: 500, label: "CHF 500" },
{ code: 'FRAST3', chf: 1000, label: "CHF 1'000" },
{ code: 'FRAST4', chf: 1500, label: "CHF 1'500" },
{ code: 'FRAST5', chf: 2000, label: "CHF 2'000" },
{ code: 'FRAST6', chf: 2500, label: "CHF 2'500" },
];
const FRANCHISE_KIN_JUG: FranchiseOption[] = [
{ code: 'FRAST1', chf: 0, label: "CHF 0" },
{ code: 'FRAST2', chf: 100, label: "CHF 100" },
{ code: 'FRAST3', chf: 200, label: "CHF 200" },
{ code: 'FRAST4', chf: 300, label: "CHF 300" },
{ code: 'FRAST5', chf: 400, label: "CHF 400" },
{ code: 'FRAST6', chf: 500, label: "CHF 500" },
{ code: 'FRAST7', chf: 600, label: "CHF 600" },
];
@Component({
selector: 'app-priminfo',
standalone: true,
imports: [CommonModule, FormsModule, TranslateModule],
templateUrl: './priminfo.html',
})
export class Priminfo {
// === Ø-Prämien (PLZ overview) ===
plzInput = signal('');
loading = signal(false);
results = signal<PraemienResult[]>([]);
dataYear = signal<number | null>(null);
error = signal<string | null>(null);
searched = signal(false);
// === Versicherer-Vergleich ===
geburtsjahrInput = signal('');
tariftyp = signal('TAR-BASE');
franchisestufe = signal('FRAST1');
unfall = signal('OHN-UNF');
vergleichResults = signal<VergleichResult[]>([]);
vergleichLoading = signal(false);
vergleichError = signal<string | null>(null);
vergleichSearched = signal(false);
vergleichMeta = signal<{
kanton: string; region: number; ort: string; altersklasse: string; data_year: number;
} | null>(null);
readonly primInfoUrl = 'https://www.priminfo.admin.ch/de/praemien';
// Computed: age class from Geburtsjahr input
ageClass = computed<'AKL-KIN' | 'AKL-JUG' | 'AKL-ERW' | null>(() => {
const jg = parseInt(this.geburtsjahrInput(), 10);
const currentYear = new Date().getFullYear();
if (!jg || jg < 1900 || jg > currentYear) return null;
const age = currentYear - jg;
if (age <= 18) return 'AKL-KIN';
if (age <= 25) return 'AKL-JUG';
return 'AKL-ERW';
});
// Computed: franchise options depend on age class
franchiseOptions = computed<FranchiseOption[]>(() => {
return this.ageClass() === 'AKL-ERW' ? FRANCHISE_ERW : FRANCHISE_KIN_JUG;
});
// Computed: deduplicate Ø-results by kanton+region
uniqueRegions = computed(() => {
const seen = new Set<string>();
return this.results().filter(r => {
const key = `${r.kanton}-${r.region}`;
if (seen.has(key)) return false;
seen.add(key);
return true;
});
});
// Computed: vergleich search requirements met?
canVergleich = computed(() => {
const plz = this.plzInput().trim();
const jg = parseInt(this.geburtsjahrInput(), 10);
const currentYear = new Date().getFullYear();
return /^\d{4}$/.test(plz) && jg >= 1900 && jg <= currentYear;
});
// Computed: minimum premium across results (for cheapest highlight)
cheapestPraemie = computed(() => {
const r = this.vergleichResults();
if (!r.length) return null;
return Math.min(...r.map(x => Number(x.praemie)));
});
constructor(private api: ApiService) {}
// === Ø-Prämien search ===
search() {
const plz = this.plzInput().trim();
if (!plz || plz.length < 4) return;
this.loading.set(true);
this.error.set(null);
this.results.set([]);
this.searched.set(true);
this.api.getPraemienByPlz(plz).subscribe({
next: (data) => {
this.results.set(data.results || []);
this.dataYear.set(data.data_year || null);
this.loading.set(false);
},
error: (err) => {
const msg = err?.error?.error || 'priminfo.error_not_found';
this.error.set(msg);
this.loading.set(false);
},
});
}
onKeydown(event: KeyboardEvent) {
if (event.key === 'Enter') this.search();
}
// Reset franchise to first valid option when age class changes
onGeburtsjahrChange(val: string) {
this.geburtsjahrInput.set(val);
const opts = this.franchiseOptions();
if (!opts.find(o => o.code === this.franchisestufe())) {
this.franchisestufe.set(opts[0].code);
}
}
// === Versicherer-Vergleich search ===
searchVergleich() {
if (!this.canVergleich()) return;
const plz = this.plzInput().trim();
const jg = parseInt(this.geburtsjahrInput(), 10);
this.vergleichLoading.set(true);
this.vergleichError.set(null);
this.vergleichResults.set([]);
this.vergleichSearched.set(true);
this.api.getPraemienVergleich({
plz,
geburtsjahr: jg,
tariftyp: this.tariftyp(),
franchisestufe: this.franchisestufe(),
unfall: this.unfall(),
}).subscribe({
next: (data) => {
this.vergleichResults.set(data.results || []);
this.vergleichMeta.set({
kanton: data.kanton,
region: data.region,
ort: data.ort,
altersklasse: data.altersklasse,
data_year: data.data_year,
});
this.vergleichLoading.set(false);
},
error: (err) => {
const msg = err?.error?.error || 'priminfo.error_not_found';
this.vergleichError.set(msg);
this.vergleichLoading.set(false);
},
});
}
isCheapest(praemie: string): boolean {
const min = this.cheapestPraemie();
return min !== null && Number(praemie) === min;
}
openPriminfo() {
window.open(this.primInfoUrl, '_blank', 'noopener,noreferrer');
}
}
@@ -157,6 +157,67 @@
}
</li>
<!-- Versicherungen -->
<li class="relative">
@if (sidebarService.collapsed()) {
<!-- Collapsed: icon button opens flyout -->
<button (click)="sidebarService.toggleFlyout('insurance')"
[class]="sidebarService.openFlyout() === 'insurance' ? 'bg-gray-100 dark:bg-gray-700' : ''"
class="relative flex items-center justify-center p-2 w-full text-sm font-normal text-gray-900 rounded-lg dark:text-white hover:bg-gray-100 dark:hover:bg-gray-700 group">
<svg class="w-6 h-6 flex-shrink-0 text-gray-400 group-hover:text-gray-900 dark:group-hover:text-white" fill="currentColor" viewBox="0 0 24 24">
<path fill-rule="evenodd" d="M11.644 3.066a1 1 0 0 1 .712 0l7 2.666A1 1 0 0 1 20 6.68a17.694 17.694 0 0 1-2.023 7.98 17.406 17.406 0 0 1-5.402 6.158 1 1 0 0 1-1.15 0 17.405 17.405 0 0 1-5.403-6.157A17.695 17.695 0 0 1 4 6.68a1 1 0 0 1 .644-.949l7-2.666Zm4.014 7.187a1 1 0 0 0-1.316-1.506l-3.296 2.884-.839-.838a1 1 0 0 0-1.414 1.414l1.5 1.5a1 1 0 0 0 1.366.046l4-3.5Z" clip-rule="evenodd"/>
</svg>
@if (sidebarService.openFlyout() !== 'insurance') {
<span class="pointer-events-none absolute left-full ml-3 top-1/2 -translate-y-1/2 px-2 py-1 text-xs font-medium text-white bg-gray-900 dark:bg-gray-700 rounded whitespace-nowrap opacity-0 group-hover:opacity-100 transition-opacity duration-150 z-50">
{{ 'sidebar.insurance' | translate }}
</span>
}
</button>
<!-- Flyout -->
@if (sidebarService.openFlyout() === 'insurance') {
<div class="absolute left-full top-0 ml-2 z-50 w-48 bg-white dark:bg-gray-800 border border-gray-200 dark:border-gray-700 rounded-lg shadow-lg py-1">
<p class="px-3 py-2 text-xs font-semibold text-gray-400 dark:text-gray-500 uppercase tracking-wider">{{ 'sidebar.insurance' | translate }}</p>
<a routerLink="/insurance" (click)="sidebarService.closeFlyout(); sidebarService.closeMobile()"
class="flex items-center px-3 py-2 text-sm text-gray-700 dark:text-gray-200 hover:bg-gray-100 dark:hover:bg-gray-700 rounded-md mx-1">
{{ 'sidebar.insurance_overview' | translate }}
</a>
<a routerLink="/insurance-documents" (click)="sidebarService.closeFlyout(); sidebarService.closeMobile()"
class="flex items-center px-3 py-2 text-sm text-gray-700 dark:text-gray-200 hover:bg-gray-100 dark:hover:bg-gray-700 rounded-md mx-1">
{{ 'sidebar.insurance_documents' | translate }}
</a>
<a routerLink="/insurance-analyse" (click)="sidebarService.closeFlyout(); sidebarService.closeMobile()"
class="flex items-center px-3 py-2 text-sm text-gray-700 dark:text-gray-200 hover:bg-gray-100 dark:hover:bg-gray-700 rounded-md mx-1">
{{ 'sidebar.insurance_analyse' | translate }}
</a>
<a routerLink="/insurance-priminfo" (click)="sidebarService.closeFlyout(); sidebarService.closeMobile()"
class="flex items-center px-3 py-2 text-sm text-gray-700 dark:text-gray-200 hover:bg-gray-100 dark:hover:bg-gray-700 rounded-md mx-1">
{{ 'sidebar.insurance_priminfo' | translate }}
</a>
</div>
}
} @else {
<!-- Expanded: Angular-controlled dropdown -->
<button type="button" (click)="sidebarService.toggleInsurance()"
class="flex items-center p-2 w-full text-sm font-normal text-gray-900 rounded-lg dark:text-white hover:bg-gray-100 dark:hover:bg-gray-700 group">
<svg class="w-6 h-6 flex-shrink-0 text-gray-400 group-hover:text-gray-900 dark:group-hover:text-white" fill="currentColor" viewBox="0 0 24 24">
<path fill-rule="evenodd" d="M11.644 3.066a1 1 0 0 1 .712 0l7 2.666A1 1 0 0 1 20 6.68a17.694 17.694 0 0 1-2.023 7.98 17.406 17.406 0 0 1-5.402 6.158 1 1 0 0 1-1.15 0 17.405 17.405 0 0 1-5.403-6.157A17.695 17.695 0 0 1 4 6.68a1 1 0 0 1 .644-.949l7-2.666Zm4.014 7.187a1 1 0 0 0-1.316-1.506l-3.296 2.884-.839-.838a1 1 0 0 0-1.414 1.414l1.5 1.5a1 1 0 0 0 1.366.046l4-3.5Z" clip-rule="evenodd"/>
</svg>
<span class="flex-1 ml-3 text-left whitespace-nowrap">{{ 'sidebar.insurance' | translate }}</span>
<svg [class.rotate-180]="sidebarService.insuranceOpen()" class="w-4 h-4 transition-transform duration-200" fill="currentColor" viewBox="0 0 20 20">
<path fill-rule="evenodd" d="M5.293 7.293a1 1 0 011.414 0L10 10.586l3.293-3.293a1 1 0 111.414 1.414l-4 4a1 1 0 01-1.414 0l-4-4a1 1 0 010-1.414z" clip-rule="evenodd"></path>
</svg>
</button>
@if (sidebarService.insuranceOpen()) {
<ul class="py-2 space-y-2">
<li><a routerLink="/insurance" routerLinkActive="!bg-violet-50 !text-violet-700 dark:!bg-violet-900/20 dark:!text-violet-300" (click)="sidebarService.closeMobile()" class="flex items-center p-2 pl-11 w-full text-sm font-normal text-gray-900 rounded-lg dark:text-white hover:bg-gray-100 dark:hover:bg-gray-700">{{ 'sidebar.insurance_overview' | translate }}</a></li>
<li><a routerLink="/insurance-documents" routerLinkActive="!bg-violet-50 !text-violet-700 dark:!bg-violet-900/20 dark:!text-violet-300" (click)="sidebarService.closeMobile()" class="flex items-center p-2 pl-11 w-full text-sm font-normal text-gray-900 rounded-lg dark:text-white hover:bg-gray-100 dark:hover:bg-gray-700">{{ 'sidebar.insurance_documents' | translate }}</a></li>
<li><a routerLink="/insurance-analyse" routerLinkActive="!bg-violet-50 !text-violet-700 dark:!bg-violet-900/20 dark:!text-violet-300" (click)="sidebarService.closeMobile()" class="flex items-center p-2 pl-11 w-full text-sm font-normal text-gray-900 rounded-lg dark:text-white hover:bg-gray-100 dark:hover:bg-gray-700">{{ 'sidebar.insurance_analyse' | translate }}</a></li>
<li><a routerLink="/insurance-priminfo" routerLinkActive="!bg-violet-50 !text-violet-700 dark:!bg-violet-900/20 dark:!text-violet-300" (click)="sidebarService.closeMobile()" class="flex items-center p-2 pl-11 w-full text-sm font-normal text-gray-900 rounded-lg dark:text-white hover:bg-gray-100 dark:hover:bg-gray-700">{{ 'sidebar.insurance_priminfo' | translate }}</a></li>
</ul>
}
}
</li>
</ul>
<!-- Mobile: Notifications, Theme, Profile, Logout (hidden on desktop — those are in the navbar) -->
+39
View File
@@ -194,4 +194,43 @@ export class ApiService {
confirmPasswordReset(token: string, password: string): Observable<any> {
return this.http.post(`${this.baseUrl}/auth/password-reset/confirm/`, { token, password });
}
// Praemien
getPraemienByPlz(plz: string): Observable<any> {
return this.http.get(`${this.baseUrl}/praemien/?plz=${encodeURIComponent(plz)}`);
}
getPraemienVergleich(params: {
plz: string;
geburtsjahr: number;
tariftyp: string;
franchisestufe: string;
unfall: string;
}): Observable<any> {
const p = new URLSearchParams({
plz: params.plz,
geburtsjahr: String(params.geburtsjahr),
tariftyp: params.tariftyp,
franchisestufe: params.franchisestufe,
unfall: params.unfall,
});
return this.http.get(`${this.baseUrl}/praemien/vergleich/?${p}`);
}
// Insurances
getInsurances(): Observable<any[]> {
return this.http.get<any[]>(`${this.baseUrl}/insurances/`);
}
createInsurance(insurance: any): Observable<any> {
return this.http.post(`${this.baseUrl}/insurances/`, insurance);
}
updateInsurance(id: number, insurance: any): Observable<any> {
return this.http.put(`${this.baseUrl}/insurances/${id}/`, insurance);
}
deleteInsurance(id: number): Observable<any> {
return this.http.delete(`${this.baseUrl}/insurances/${id}/`);
}
}
+5
View File
@@ -9,6 +9,7 @@ export class SidebarService {
mobileOpen = signal(false);
budgetsOpen = signal(false);
accountsOpen = signal(false);
insuranceOpen = signal(false);
toggle() {
this.collapsed.update(v => !v);
@@ -31,6 +32,10 @@ export class SidebarService {
this.accountsOpen.update(v => !v);
}
toggleInsurance() {
this.insuranceOpen.update(v => !v);
}
toggleFlyout(name: string) {
this.openFlyout.update(current => current === name ? null : name);
}
+129 -10
View File
@@ -136,16 +136,6 @@
"transactions": "Transaktionen",
"deadlines": "Termine"
},
"sidebar": {
"dashboard": "Dashboard",
"budgets": "Budgets",
"fixed_costs": "Fixkosten",
"expenses": "Ausgaben",
"calendar": "Kalender",
"accounts": "Konten",
"revenue_accounts": "Einnahmekonten",
"transactions": "Transaktionen"
},
"dashboard": {
"title": "Dashboard",
"subtitle": "Finanzübersicht",
@@ -400,5 +390,134 @@
"password_too_short": "Passwort muss mindestens 8 Zeichen lang sein.",
"password_failed": "Passwort konnte nicht aktualisiert werden."
}
},
"sidebar": {
"dashboard": "Dashboard",
"budgets": "Budget",
"fixed_costs": "Fixkosten",
"expenses": "Ausgaben",
"calendar": "Kalender",
"accounts": "Konten",
"revenue_accounts": "Einnahmenkonten",
"transactions": "Transaktionen",
"insurance": "Versicherungen",
"insurance_overview": "Übersicht",
"insurance_documents": "Dokumente",
"insurance_analyse": "Analyse",
"insurance_priminfo": "Priminfo"
},
"insurance": {
"title": "Versicherungen",
"subtitle": "Deine aktuelle Versicherungssituation — Ist-Situation",
"add": "Versicherung hinzufügen",
"create_title": "Versicherung erfassen",
"edit_title": "Versicherung bearbeiten",
"list_title": "Deine Versicherungen",
"no_entries": "Noch keine Versicherungen erfasst.",
"loading": "Laden...",
"kpi_monthly": "Monatliche Prämien total",
"kpi_count": "Policen",
"kpi_covered": "Abgedeckte Typen",
"checklist_title": "Empfohlene Mindestabdeckung",
"checklist_hint": "Diese vier Versicherungen gelten in der Schweiz als Mindeststandard für jede Person.",
"label_type": "Versicherungsart",
"label_insurer": "Versicherer",
"label_policy_number": "Policennummer",
"label_premium": "Prämie (CHF)",
"label_period": "Zahlungsrhythmus",
"label_coverage": "Deckungssumme (CHF)",
"label_deductible": "Franchise (CHF)",
"label_valid_from": "Gültig ab",
"label_valid_until": "Gültig bis",
"label_notes": "Notizen",
"placeholder_insurer": "z.B. Helsana, AXA, CSS",
"placeholder_policy_number": "z.B. 12345678",
"month_short": "Mt.",
"types": {
"kvg": "Krankenkasse (KVG)",
"kk_zusatz": "KK-Zusatzversicherung",
"nbu": "Unfallversicherung (NBU)",
"haftpflicht": "Privathaftpflicht",
"hausrat": "Hausrat",
"mfz": "MFZ-Haftpflicht",
"rechtsschutz": "Rechtsschutz",
"saule_3a": "Säule 3a",
"leben": "Lebensversicherung",
"reise": "Reiseversicherung",
"other": "Sonstiges"
},
"period": {
"monthly": "Monatlich",
"quarterly": "Vierteljährlich",
"semi_annual": "Halbjährlich",
"annual": "Jährlich"
}
},
"insurance_docs": {
"title": "Versicherungsdokumente",
"subtitle": "Lade deine Policen hoch und lass sie von der KI analysieren",
"coming_soon_title": "PDF-Upload & KI-Analyse — Demnächst",
"coming_soon_text": "Lade deine Versicherungspolicen als PDF hoch. Die KI (Claude) extrahiert automatisch die wichtigsten Informationen."
},
"insurance_analyse": {
"title": "Versicherungsanalyse",
"subtitle": "Soll/Kann — Was brauchst du wirklich?",
"coming_soon_title": "Deckungsanalyse — Demnächst",
"coming_soon_text": "Vergleiche deine aktuelle Versicherungssituation mit Schweizer Empfehlungen und erkenne Lücken.",
"tag_soll": "Soll-Situation",
"tag_gaps": "Deckungslücken",
"tag_recommendations": "Empfehlungen"
},
"priminfo": {
"title": "Priminfo — KVG-Prämienrechner",
"subtitle": "Durchschnittliche Monatsprämien nach PLZ, basierend auf BAG-Daten",
"plz_label": "Postleitzahl eingeben",
"plz_placeholder": "z.B. 8001",
"plz_hint": "4-stellige Schweizer Postleitzahl",
"search": "Suchen",
"region_label": "Prämienregion",
"col_child": "Kinder",
"col_young": "Junge Erwachsene",
"col_adult": "Erwachsene",
"month": "Monat",
"disclaimer": "Ø-Monatsprämien {{ year }} (alle Versicherer, alle Modelle). Quelle: BAG / Priminfo.",
"multi_ort_hint": "Diese PLZ liegt in mehreren Gemeinden oder Regionen.",
"error_not_found": "Keine Daten für diese PLZ gefunden.",
"no_results": "Keine Resultate.",
"cta_title": "Weitere Details auf priminfo.admin.ch",
"cta_text": "Jahreskosten, historische Prämienverläufe und Zusatzversicherungen auf der offiziellen BAG-Seite.",
"cta_btn": "Jetzt auf Priminfo vergleichen",
"info_title": "Was zeigt dieser Rechner?",
"info_1": "Die Prämien sind kantonale Durchschnittswerte über alle Versicherer und alle Versicherungsmodelle (Standard, Hausarzt, HMO, Telemed).",
"info_2": "Die Prämienregion (1, 2 oder 3) bestimmt die Prämienhöhe innerhalb eines Kantons — Region 1 ist meist am teuersten.",
"info_3": "Für den genauen Prämienvergleich nach Versicherer und Franchise empfehlen wir den offiziellen Rechner auf priminfo.admin.ch.",
"source": "Quelle: BAG — ",
"vergleich_card_title": "Versicherer vergleichen",
"vergleich_card_subtitle": "Granulare Prämien nach Versicherer, Modell und Franchise",
"geburtsjahr_label": "Geburtsjahr",
"geburtsjahr_placeholder": "z.B. 1990",
"modell_label": "Versicherungsmodell",
"modell_base": "Standard",
"modell_ham": "Hausarzt (HÄM)",
"modell_hmo": "HMO",
"modell_div": "Andere Modelle",
"franchise_label": "Franchise",
"unfall_label": "Unfalldeckung",
"unfall_ohn": "Ohne Unfall",
"unfall_mit": "Mit Unfall",
"unfall_note": "Angestellte können die Unfalldeckung weglassen, wenn der Arbeitgeber eine NBU-Versicherung abschliesst.",
"vergleich_btn": "Versicherer vergleichen",
"col_rank": "#",
"col_insurer": "Versicherer",
"col_model": "Modell",
"col_franchise": "Franchise",
"col_premium": "Prämie/Mt.",
"cheapest_badge": "Günstigste",
"vergleich_data_year": "Prämien {{ year }}",
"vergleich_no_results": "Keine Versicherer für diese Kombination gefunden. Andere Modell- oder Franchisewahl versuchen.",
"vergleich_hint": "Versicherer, sortiert nach Prämie aufsteigend.",
"age_child": "Kind (≤ 18 J.)",
"age_young": "Junger Erw. (1925 J.)",
"age_adult": "Erwachsener (≥ 26 J.)"
}
}
+129 -10
View File
@@ -136,16 +136,6 @@
"transactions": "Transactions",
"deadlines": "Deadlines"
},
"sidebar": {
"dashboard": "Dashboard",
"budgets": "Budgets",
"fixed_costs": "Fixed Costs",
"expenses": "Expenses",
"calendar": "Calendar",
"accounts": "Accounts",
"revenue_accounts": "Revenue Accounts",
"transactions": "Transactions"
},
"dashboard": {
"title": "Dashboard",
"subtitle": "Financial overview",
@@ -400,5 +390,134 @@
"password_too_short": "Password must be at least 8 characters.",
"password_failed": "Failed to update password."
}
},
"sidebar": {
"dashboard": "Dashboard",
"budgets": "Budgets",
"fixed_costs": "Fixed Costs",
"expenses": "Expenses",
"calendar": "Calendar",
"accounts": "Accounts",
"revenue_accounts": "Revenue Accounts",
"transactions": "Transactions",
"insurance": "Insurances",
"insurance_overview": "Overview",
"insurance_documents": "Documents",
"insurance_analyse": "Analysis",
"insurance_priminfo": "Priminfo"
},
"insurance": {
"title": "Insurances",
"subtitle": "Your current insurance coverage — Ist-Situation",
"add": "Add Insurance",
"create_title": "Add Insurance",
"edit_title": "Edit Insurance",
"list_title": "Your Insurances",
"no_entries": "No insurances recorded yet.",
"loading": "Loading...",
"kpi_monthly": "Monthly Total",
"kpi_count": "Policies",
"kpi_covered": "Types Covered",
"checklist_title": "Recommended Coverage",
"checklist_hint": "These four insurances are the Swiss minimum recommended for every person.",
"label_type": "Insurance Type",
"label_insurer": "Insurer",
"label_policy_number": "Policy Number",
"label_premium": "Premium (CHF)",
"label_period": "Period",
"label_coverage": "Coverage (CHF)",
"label_deductible": "Deductible (CHF)",
"label_valid_from": "Valid From",
"label_valid_until": "Valid Until",
"label_notes": "Notes",
"placeholder_insurer": "e.g. Helsana, AXA, CSS",
"placeholder_policy_number": "e.g. 12345678",
"month_short": "mo",
"types": {
"kvg": "Health Insurance (KVG)",
"kk_zusatz": "Supplemental Health",
"nbu": "Accident (NBU)",
"haftpflicht": "Private Liability",
"hausrat": "Household Contents",
"mfz": "Vehicle Liability",
"rechtsschutz": "Legal Protection",
"saule_3a": "Pillar 3a",
"leben": "Life Insurance",
"reise": "Travel Insurance",
"other": "Other"
},
"period": {
"monthly": "Monthly",
"quarterly": "Quarterly",
"semi_annual": "Semi-annual",
"annual": "Annual"
}
},
"insurance_docs": {
"title": "Insurance Documents",
"subtitle": "Upload and analyse your insurance policies with AI",
"coming_soon_title": "PDF Upload & AI Analysis — Coming Soon",
"coming_soon_text": "Upload your insurance policies as PDF. The AI (Claude) will extract the most important information automatically."
},
"insurance_analyse": {
"title": "Insurance Analysis",
"subtitle": "Soll/Kann — What coverage do you need?",
"coming_soon_title": "Coverage Analysis — Coming Soon",
"coming_soon_text": "Compare your current coverage with Swiss recommendations and identify gaps.",
"tag_soll": "Target Coverage",
"tag_gaps": "Coverage Gaps",
"tag_recommendations": "Recommendations"
},
"priminfo": {
"title": "Priminfo — KVG Premium Calculator",
"subtitle": "Average monthly premiums by postal code, based on FOPH data",
"plz_label": "Enter postal code",
"plz_placeholder": "e.g. 8001",
"plz_hint": "4-digit Swiss postal code",
"search": "Search",
"region_label": "Premium Region",
"col_child": "Children",
"col_young": "Young Adults",
"col_adult": "Adults",
"month": "month",
"disclaimer": "Avg. monthly premiums {{ year }} (all insurers, all models). Source: FOPH / Priminfo.",
"multi_ort_hint": "This postal code covers multiple municipalities or regions.",
"error_not_found": "No data found for this postal code.",
"no_results": "No results.",
"cta_title": "More details at priminfo.admin.ch",
"cta_text": "Annual costs, premium history and supplemental insurance on the official FOPH website.",
"cta_btn": "Compare on Priminfo",
"info_title": "What does this calculator show?",
"info_1": "Premiums are cantonal averages across all insurers and all insurance models (standard, family doctor, HMO, telemedicine).",
"info_2": "The premium region (1, 2 or 3) determines the premium level within a canton — region 1 is usually the most expensive.",
"info_3": "For a precise comparison by insurer and deductible, we recommend the official calculator at priminfo.admin.ch.",
"source": "Source: FOPH — ",
"vergleich_card_title": "Compare Insurers",
"vergleich_card_subtitle": "Granular premiums by insurer, model and deductible",
"geburtsjahr_label": "Year of Birth",
"geburtsjahr_placeholder": "e.g. 1990",
"modell_label": "Insurance Model",
"modell_base": "Standard",
"modell_ham": "GP Model (HÄM)",
"modell_hmo": "HMO",
"modell_div": "Other Models",
"franchise_label": "Deductible",
"unfall_label": "Accident Coverage",
"unfall_ohn": "Without Accident",
"unfall_mit": "With Accident",
"unfall_note": "Employees can exclude accident coverage if their employer has a non-occupational accident insurance (NBIA).",
"vergleich_btn": "Compare Insurers",
"col_rank": "#",
"col_insurer": "Insurer",
"col_model": "Model",
"col_franchise": "Deductible",
"col_premium": "Premium/Mo.",
"cheapest_badge": "Cheapest",
"vergleich_data_year": "Premiums {{ year }}",
"vergleich_no_results": "No insurers found for this combination. Try a different model or deductible.",
"vergleich_hint": "Insurers sorted by premium ascending.",
"age_child": "Child (≤ 18 yrs)",
"age_young": "Young Adult (1925 yrs)",
"age_adult": "Adult (≥ 26 yrs)"
}
}
+129 -10
View File
@@ -136,16 +136,6 @@
"transactions": "Transactions",
"deadlines": "Échéances"
},
"sidebar": {
"dashboard": "Tableau de bord",
"budgets": "Budgets",
"fixed_costs": "Charges fixes",
"expenses": "Dépenses",
"calendar": "Calendrier",
"accounts": "Comptes",
"revenue_accounts": "Comptes de revenus",
"transactions": "Transactions"
},
"dashboard": {
"title": "Tableau de bord",
"subtitle": "Aperçu financier",
@@ -400,5 +390,134 @@
"password_too_short": "Le mot de passe doit comporter au moins 8 caractères.",
"password_failed": "Échec de la mise à jour du mot de passe."
}
},
"sidebar": {
"dashboard": "Tableau de bord",
"budgets": "Budgets",
"fixed_costs": "Charges fixes",
"expenses": "Dépenses",
"calendar": "Calendrier",
"accounts": "Comptes",
"revenue_accounts": "Comptes de revenus",
"transactions": "Transactions",
"insurance": "Assurances",
"insurance_overview": "Aperçu",
"insurance_documents": "Documents",
"insurance_analyse": "Analyse",
"insurance_priminfo": "Priminfo"
},
"insurance": {
"title": "Assurances",
"subtitle": "Votre couverture d'assurance actuelle — Situation actuelle",
"add": "Ajouter une assurance",
"create_title": "Nouvelle assurance",
"edit_title": "Modifier l'assurance",
"list_title": "Vos assurances",
"no_entries": "Aucune assurance enregistrée.",
"loading": "Chargement...",
"kpi_monthly": "Total mensuel",
"kpi_count": "Polices",
"kpi_covered": "Types couverts",
"checklist_title": "Couverture minimale recommandée",
"checklist_hint": "Ces quatre assurances constituent le minimum recommandé en Suisse pour chaque personne.",
"label_type": "Type d'assurance",
"label_insurer": "Assureur",
"label_policy_number": "Numéro de police",
"label_premium": "Prime (CHF)",
"label_period": "Fréquence de paiement",
"label_coverage": "Montant assuré (CHF)",
"label_deductible": "Franchise (CHF)",
"label_valid_from": "Valable dès",
"label_valid_until": "Valable jusqu'au",
"label_notes": "Remarques",
"placeholder_insurer": "ex. Helsana, AXA, CSS",
"placeholder_policy_number": "ex. 12345678",
"month_short": "mois",
"types": {
"kvg": "Assurance maladie (LAMal)",
"kk_zusatz": "Assurance complémentaire",
"nbu": "Accident (LAA)",
"haftpflicht": "Responsabilité civile",
"hausrat": "Ménage",
"mfz": "RC véhicule",
"rechtsschutz": "Protection juridique",
"saule_3a": "Pilier 3a",
"leben": "Assurance vie",
"reise": "Assurance voyage",
"other": "Autre"
},
"period": {
"monthly": "Mensuel",
"quarterly": "Trimestriel",
"semi_annual": "Semestriel",
"annual": "Annuel"
}
},
"insurance_docs": {
"title": "Documents d'assurance",
"subtitle": "Téléchargez vos polices et faites-les analyser par l'IA",
"coming_soon_title": "Upload PDF & analyse IA — Bientôt disponible",
"coming_soon_text": "Téléchargez vos polices d'assurance en PDF. L'IA (Claude) extrait automatiquement les informations les plus importantes."
},
"insurance_analyse": {
"title": "Analyse des assurances",
"subtitle": "Cible — Quelle couverture vous faut-il ?",
"coming_soon_title": "Analyse de couverture — Bientôt disponible",
"coming_soon_text": "Comparez votre situation actuelle avec les recommandations suisses et identifiez les lacunes.",
"tag_soll": "Situation cible",
"tag_gaps": "Lacunes",
"tag_recommendations": "Recommandations"
},
"priminfo": {
"title": "Priminfo — Calculateur de primes LAMal",
"subtitle": "Primes mensuelles moyennes par NPA, basées sur les données de l'OFSP",
"plz_label": "Entrez votre NPA",
"plz_placeholder": "ex. 8001",
"plz_hint": "Code postal suisse à 4 chiffres",
"search": "Rechercher",
"region_label": "Région de primes",
"col_child": "Enfants",
"col_young": "Jeunes adultes",
"col_adult": "Adultes",
"month": "mois",
"disclaimer": "Primes mensuelles moyennes {{ year }} (tous assureurs, tous modèles). Source : OFSP / Priminfo.",
"multi_ort_hint": "Ce NPA couvre plusieurs communes ou régions.",
"error_not_found": "Aucune donnée trouvée pour ce NPA.",
"no_results": "Aucun résultat.",
"cta_title": "Plus de détails sur priminfo.admin.ch",
"cta_text": "Coûts annuels, historique des primes et assurances complémentaires sur le site officiel de l'OFSP.",
"cta_btn": "Comparer sur Priminfo",
"info_title": "Que montre ce calculateur ?",
"info_1": "Les primes sont des moyennes cantonales calculées sur l'ensemble des assureurs et des modèles d'assurance (standard, médecin de famille, HMO, télémédecine).",
"info_2": "La région de primes (1, 2 ou 3) détermine le niveau des primes au sein d'un canton — la région 1 est généralement la plus chère.",
"info_3": "Pour une comparaison précise par assureur et par franchise, nous recommandons le calculateur officiel sur priminfo.admin.ch.",
"source": "Source : OFSP — ",
"vergleich_card_title": "Comparer les assureurs",
"vergleich_card_subtitle": "Primes détaillées par assureur, modèle et franchise",
"geburtsjahr_label": "Année de naissance",
"geburtsjahr_placeholder": "p.ex. 1990",
"modell_label": "Modèle d'assurance",
"modell_base": "Standard",
"modell_ham": "Médecin de famille",
"modell_hmo": "HMO",
"modell_div": "Autres modèles",
"franchise_label": "Franchise",
"unfall_label": "Couverture accidents",
"unfall_ohn": "Sans accidents",
"unfall_mit": "Avec accidents",
"unfall_note": "Les salariés peuvent exclure la couverture accidents si l'employeur a souscrit une assurance LAA.",
"vergleich_btn": "Comparer les assureurs",
"col_rank": "#",
"col_insurer": "Assureur",
"col_model": "Modèle",
"col_franchise": "Franchise",
"col_premium": "Prime/mois",
"cheapest_badge": "La moins chère",
"vergleich_data_year": "Primes {{ year }}",
"vergleich_no_results": "Aucun assureur trouvé pour cette combinaison. Essayez un autre modèle ou une autre franchise.",
"vergleich_hint": "Assureurs triés par prime croissante.",
"age_child": "Enfant (≤ 18 ans)",
"age_young": "Jeune adulte (1925 ans)",
"age_adult": "Adulte (≥ 26 ans)"
}
}
+129 -10
View File
@@ -136,16 +136,6 @@
"transactions": "Transazioni",
"deadlines": "Scadenze"
},
"sidebar": {
"dashboard": "Dashboard",
"budgets": "Budget",
"fixed_costs": "Costi fissi",
"expenses": "Spese",
"calendar": "Calendario",
"accounts": "Conti",
"revenue_accounts": "Conti entrate",
"transactions": "Transazioni"
},
"dashboard": {
"title": "Dashboard",
"subtitle": "Panoramica finanziaria",
@@ -400,5 +390,134 @@
"password_too_short": "La password deve contenere almeno 8 caratteri.",
"password_failed": "Aggiornamento password fallito."
}
},
"sidebar": {
"dashboard": "Dashboard",
"budgets": "Budget",
"fixed_costs": "Costi fissi",
"expenses": "Spese",
"calendar": "Calendario",
"accounts": "Conti",
"revenue_accounts": "Conti entrate",
"transactions": "Transazioni",
"insurance": "Assicurazioni",
"insurance_overview": "Panoramica",
"insurance_documents": "Documenti",
"insurance_analyse": "Analisi",
"insurance_priminfo": "Priminfo"
},
"insurance": {
"title": "Assicurazioni",
"subtitle": "La tua copertura assicurativa attuale — Situazione attuale",
"add": "Aggiungi assicurazione",
"create_title": "Nuova assicurazione",
"edit_title": "Modifica assicurazione",
"list_title": "Le tue assicurazioni",
"no_entries": "Nessuna assicurazione registrata.",
"loading": "Caricamento...",
"kpi_monthly": "Totale mensile",
"kpi_count": "Polizze",
"kpi_covered": "Tipi coperti",
"checklist_title": "Copertura minima raccomandata",
"checklist_hint": "Queste quattro assicurazioni sono il minimo raccomandato in Svizzera per ogni persona.",
"label_type": "Tipo di assicurazione",
"label_insurer": "Assicuratore",
"label_policy_number": "Numero polizza",
"label_premium": "Premio (CHF)",
"label_period": "Frequenza di pagamento",
"label_coverage": "Somma assicurata (CHF)",
"label_deductible": "Franchigia (CHF)",
"label_valid_from": "Valido dal",
"label_valid_until": "Valido fino al",
"label_notes": "Note",
"placeholder_insurer": "es. Helsana, AXA, CSS",
"placeholder_policy_number": "es. 12345678",
"month_short": "mese",
"types": {
"kvg": "Cassa malati (LAMal)",
"kk_zusatz": "Assicurazione complementare",
"nbu": "Infortuni (LAINF)",
"haftpflicht": "Responsabilità civile privata",
"hausrat": "Economia domestica",
"mfz": "RC veicoli",
"rechtsschutz": "Tutela legale",
"saule_3a": "Pilastro 3a",
"leben": "Assicurazione vita",
"reise": "Assicurazione viaggio",
"other": "Altro"
},
"period": {
"monthly": "Mensile",
"quarterly": "Trimestrale",
"semi_annual": "Semestrale",
"annual": "Annuale"
}
},
"insurance_docs": {
"title": "Documenti assicurativi",
"subtitle": "Carica le tue polizze e falle analizzare dall'IA",
"coming_soon_title": "Upload PDF & analisi IA — In arrivo",
"coming_soon_text": "Carica le tue polizze assicurative in PDF. L'IA (Claude) estrae automaticamente le informazioni più importanti."
},
"insurance_analyse": {
"title": "Analisi assicurativa",
"subtitle": "Obiettivo — Di quale copertura hai bisogno?",
"coming_soon_title": "Analisi della copertura — In arrivo",
"coming_soon_text": "Confronta la tua situazione attuale con le raccomandazioni svizzere e identifica le lacune.",
"tag_soll": "Situazione obiettivo",
"tag_gaps": "Lacune di copertura",
"tag_recommendations": "Raccomandazioni"
},
"priminfo": {
"title": "Priminfo — Calcolatore premi LAMal",
"subtitle": "Premi mensili medi per CAP, basati sui dati dell'UFSP",
"plz_label": "Inserisci il CAP",
"plz_placeholder": "es. 8001",
"plz_hint": "Codice postale svizzero a 4 cifre",
"search": "Cerca",
"region_label": "Regione tariffale",
"col_child": "Bambini",
"col_young": "Giovani adulti",
"col_adult": "Adulti",
"month": "mese",
"disclaimer": "Premi mensili medi {{ year }} (tutti gli assicuratori, tutti i modelli). Fonte: UFSP / Priminfo.",
"multi_ort_hint": "Questo CAP comprende più comuni o regioni.",
"error_not_found": "Nessun dato trovato per questo CAP.",
"no_results": "Nessun risultato.",
"cta_title": "Ulteriori dettagli su priminfo.admin.ch",
"cta_text": "Costi annuali, storico dei premi e assicurazioni complementari sul sito ufficiale dell'UFSP.",
"cta_btn": "Confronta su Priminfo",
"info_title": "Cosa mostra questo calcolatore?",
"info_1": "I premi sono medie cantonali calcolate su tutti gli assicuratori e tutti i modelli assicurativi (standard, medico di famiglia, HMO, telemedicina).",
"info_2": "La regione tariffale (1, 2 o 3) determina il livello dei premi all'interno di un cantone — la regione 1 è generalmente la più cara.",
"info_3": "Per un confronto preciso per assicuratore e franchigia, consigliamo il calcolatore ufficiale su priminfo.admin.ch.",
"source": "Fonte: UFSP — ",
"vergleich_card_title": "Confronta assicuratori",
"vergleich_card_subtitle": "Premi dettagliati per assicuratore, modello e franchigia",
"geburtsjahr_label": "Anno di nascita",
"geburtsjahr_placeholder": "es. 1990",
"modell_label": "Modello assicurativo",
"modell_base": "Standard",
"modell_ham": "Medico di base",
"modell_hmo": "HMO",
"modell_div": "Altri modelli",
"franchise_label": "Franchigia",
"unfall_label": "Copertura infortuni",
"unfall_ohn": "Senza infortuni",
"unfall_mit": "Con infortuni",
"unfall_note": "I dipendenti possono escludere la copertura infortuni se il datore di lavoro ha stipulato un'assicurazione AINF.",
"vergleich_btn": "Confronta assicuratori",
"col_rank": "#",
"col_insurer": "Assicuratore",
"col_model": "Modello",
"col_franchise": "Franchigia",
"col_premium": "Premio/mese",
"cheapest_badge": "Più economico",
"vergleich_data_year": "Premi {{ year }}",
"vergleich_no_results": "Nessun assicuratore trovato per questa combinazione. Provare un altro modello o franchigia.",
"vergleich_hint": "Assicuratori ordinati per premio crescente.",
"age_child": "Bambino (≤ 18 anni)",
"age_young": "Giovane adulto (1925 anni)",
"age_adult": "Adulto (≥ 26 anni)"
}
}