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
+4 -1
View File
@@ -7,7 +7,7 @@ from rest_framework.routers import DefaultRouter
from rest_framework_simplejwt.views import TokenRefreshView
from finance.views import (
AccountViewSet, TransactionViewSet, BudgetViewSet,
ExpenseViewSet, DeadlineViewSet, ProfileView, RegisterView, LogoutView, ChangePasswordView,
ExpenseViewSet, DeadlineViewSet, InsuranceViewSet, PraemienView, PraemienVergleichView, ProfileView, RegisterView, LogoutView, ChangePasswordView,
ICalUrlView, ICalFeedView, NotificationsView, SearchView,
LoginView, TwoFactorLoginView, TwoFactorSetupView, TwoFactorEnableView, TwoFactorDisableView,
TwoFactorRecoverRequestView, TwoFactorRecoverConfirmView,
@@ -22,6 +22,7 @@ router.register(r'transactions', TransactionViewSet, basename='transaction')
router.register(r'budgets', BudgetViewSet, basename='budget')
router.register(r'expenses', ExpenseViewSet, basename='expense')
router.register(r'deadlines', DeadlineViewSet, basename='deadline')
router.register(r'insurances', InsuranceViewSet, basename='insurance')
_admin_url = os.environ.get('ADMIN_URL', 'manage/').strip('/')+ '/'
@@ -50,6 +51,8 @@ urlpatterns = [
path('api/notifications/prefs/', NotificationPrefsView.as_view()),
path('api/search/', SearchView.as_view()),
path('api/notifications/', NotificationsView.as_view()),
path('api/praemien/', PraemienView.as_view()),
path('api/praemien/vergleich/', PraemienVergleichView.as_view()),
path('api/calendar/ical-url/', ICalUrlView.as_view()),
path('api/calendar/ical/<int:user_id>/<str:token>/', ICalFeedView.as_view()),
] + static(settings.MEDIA_URL, document_root=settings.MEDIA_ROOT)
@@ -0,0 +1,309 @@
"""
Management command: import_praemien
Imports Swiss KVG premium data from two BAG sources:
1. praemienregionen_{year}.xlsx (Priminfo)
PLZ → BFS-Nr, Gemeinde, Kanton, Prämienregion (0-3) + Ø-Monatsrämien
→ PraemienEntry model
2. Prämien_CH.csv (opendata.bagnet.ch / opendata.swiss)
Full per-insurer, per-model, per-franchise granular premiums
→ PraemienPolice model
Usage:
python manage.py import_praemien # import both, latest year
python manage.py import_praemien --year 2025
python manage.py import_praemien --skip-policen # only PLZ/region data
python manage.py import_praemien --skip-regionen # only granular policen data
"""
import csv
import io
import urllib.request
import zipfile
import xml.etree.ElementTree as ET
from decimal import Decimal, InvalidOperation
from django.core.management.base import BaseCommand, CommandError
from finance.models import PraemienEntry, PraemienPolice
NS = {'x': 'http://schemas.openxmlformats.org/spreadsheetml/2006/main'}
REGIONEN_URL = 'https://www.priminfo.admin.ch/downloads/praemienregionen_{year}.xlsx'
POLICEN_URL = 'https://opendata.bagnet.ch/?r=/download&path=L1ByYWVtaWVuL1Byw6RtaWVuX0NILmNzdg%3D%3D'
LATEST_YEAR = 2025 # praemienregionen: update each September when BAG publishes new file
POLICEN_YEAR = 2026 # Prämien_CH.csv always contains the upcoming business year
# BAG insurer ID → display name (stable, regulated by BAG)
INSURER_NAMES: dict[int, str] = {
8: 'Helsana AG',
32: 'KPT/CPT',
134: 'CSS Versicherung AG',
194: 'Concordia',
246: 'Groupe Mutuel',
290: 'Sanitas Krankenversicherung',
312: 'SWICA Krankenversicherung',
343: 'Visana AG',
360: 'Atupri Krankenkasse',
376: 'Kolping Krankenkasse',
455: 'EGK-Gesundheitskasse',
509: 'Galenos AG',
780: 'Luzerner Hinterland Krankenkasse (LHK)',
820: 'Krankenkasse Steffisburg',
881: 'sodalis gesundheitsgruppe',
923: 'Vivao Sympany AG',
941: 'Birchmeier Krankenkasse',
966: 'Krankenkasse Wädenswil',
1040: 'ÖKK',
1113: 'Agrisano Krankenkasse',
1318: 'Mutuel Assurance',
1322: 'Provita Gesundheitsversicherung AG',
1384: 'Sanagate AG',
1386: 'Aquilana Versicherungen',
1401: 'Easy Sana Assurance Maladie SA',
1479: 'Caisse-maladie Philos',
1507: 'Scheidegg Krankenkasse',
1509: 'Sana24 AG',
1535: 'rhenusana',
1542: 'Caisse-maladie de la Vallée SA',
1555: 'KLuG Krankenkasse',
1560: 'Krankenkasse Institut Ingenbohl',
1562: 'Sumiswalder Krankenkasse',
1568: 'avanto health AG',
}
# Franchise code → CHF value
FRANCHISE_CHF: dict[str, int] = {
'FRA-0': 0,
'FRA-100': 100,
'FRA-200': 200,
'FRA-300': 300,
'FRA-400': 400,
'FRA-500': 500,
'FRA-600': 600,
'FRA-1000': 1000,
'FRA-1500': 1500,
'FRA-2000': 2000,
'FRA-2500': 2500,
}
# ─────────────────────────────────────────────────────────
# XLSX helpers (for praemienregionen)
# ─────────────────────────────────────────────────────────
def _cell_value(cell):
is_el = cell.find('x:is/x:t', NS)
if is_el is not None:
return (is_el.text or '').strip().replace('\n', ' ')
v_el = cell.find('x:v', NS)
if v_el is not None and v_el.text:
return v_el.text.strip()
return None
def _parse_rows(ws):
for row in ws.findall('.//x:row', NS):
yield [_cell_value(c) for c in row.findall('x:c', NS)]
def _parse_regionen_xlsx(data: bytes, year: int) -> list[dict]:
zf = zipfile.ZipFile(io.BytesIO(data))
rels_xml = ET.fromstring(zf.read('xl/_rels/workbook.xml.rels'))
wb_xml = ET.fromstring(zf.read('xl/workbook.xml'))
wb_ns = {'x': 'http://schemas.openxmlformats.org/spreadsheetml/2006/main'}
rid_to_path = {
r.get('Id'): r.get('Target').lstrip('/')
for r in rels_xml if 'worksheet' in r.get('Type', '')
}
rid_to_name = {
s.get('{http://schemas.openxmlformats.org/officeDocument/2006/relationships}id'): s.get('name')
for s in wb_xml.findall('.//x:sheet', wb_ns)
}
name_to_path = {rid_to_name[rid]: path for rid, path in rid_to_path.items() if rid in rid_to_name}
# D_PRIM: BFS-Nr → avg premiums
ws_dprim = ET.fromstring(zf.read(name_to_path['D_PRIM']))
premiums = {}
header_found = False
for row_vals in _parse_rows(ws_dprim):
if not header_found:
flat = ' '.join(str(v) for v in row_vals if v)
if 'BFS-Nr' in flat or 'No OFS' in flat:
header_found = True
continue
if not row_vals or not row_vals[0]:
continue
try:
bfs_nr = int(row_vals[0])
region = int(row_vals[3]) if row_vals[3] is not None else 0
avg_child = Decimal(str(row_vals[4]).replace("'", '')) if row_vals[4] else Decimal('0')
avg_young = Decimal(str(row_vals[5]).replace("'", '')) if row_vals[5] else Decimal('0')
avg_adult = Decimal(str(row_vals[6]).replace("'", '')) if row_vals[6] else Decimal('0')
premiums[bfs_nr] = (region, avg_child, avg_young, avg_adult)
except (ValueError, InvalidOperation, IndexError):
continue
# B_NPA: PLZ → BFS-Nr (PLZ at index 1, flag column at index 0)
ws_bnpa = ET.fromstring(zf.read(name_to_path['B_NPA']))
entries = []
header_found = False
for row_vals in _parse_rows(ws_bnpa):
if not header_found:
flat = ' '.join(str(v) for v in row_vals if v)
if 'PLZ' in flat and 'BFS' in flat:
header_found = True
continue
if len(row_vals) < 6:
continue
plz_raw = str(row_vals[1] or '').replace("'", '').strip()
if not plz_raw.isdigit():
continue
try:
plz = plz_raw.zfill(4)
ort = str(row_vals[2] or '').replace("'", '').strip()
kanton = str(row_vals[3] or '').replace("'", '').strip()
bfs_nr_raw = row_vals[5]
if bfs_nr_raw is None:
continue
bfs_nr = int(str(bfs_nr_raw).replace("'", '').strip())
gemeinde = str(row_vals[6] or '').replace("'", '').strip() if len(row_vals) > 6 else ''
bezirk = str(row_vals[7] or '').replace("'", '').strip() if len(row_vals) > 7 else ''
if bfs_nr not in premiums:
continue
region, avg_child, avg_young, avg_adult = premiums[bfs_nr]
entries.append({
'plz': plz, 'ort': ort, 'kanton': kanton, 'region': region,
'bfs_nr': bfs_nr, 'gemeinde': gemeinde, 'bezirk': bezirk,
'avg_adult': avg_adult, 'avg_young_adult': avg_young,
'avg_child': avg_child, 'data_year': year,
})
except (ValueError, InvalidOperation, IndexError):
continue
return entries
# ─────────────────────────────────────────────────────────
# CSV helpers (for Prämien_CH.csv)
# ─────────────────────────────────────────────────────────
def _parse_policen_csv(data: bytes, data_year: int) -> list[dict]:
"""Parse Prämien_CH.csv → list of PraemienPolice dicts."""
text = data.decode('utf-8-sig')
reader = csv.DictReader(io.StringIO(text))
entries = []
for row in reader:
try:
versicherer_id = int(row['Versicherer'])
kanton = row['Kanton'].strip()
if kanton not in ('AG','AI','AR','BE','BL','BS','FR','GE','GL','GR',
'JU','LU','NE','NW','OW','SG','SH','SO','SZ','TG',
'TI','UR','VD','VS','ZG','ZH'):
continue # skip EU/EFTA rows
region_code = row['Region'].strip() # PR-REG CH0 … CH3
try:
region = int(region_code.split('CH')[1])
except (IndexError, ValueError):
continue
altersklasse = row['Altersklasse'].strip()
unfalleinschluss = row['Unfalleinschluss'].strip()
tariftyp = row['Tariftyp'].strip()
tarifbezeichnung = row['Tarifbezeichnung'].strip()
franchisestufe = row['Franchisestufe'].strip()
franchise_code = row['Franchise'].strip()
franchise_chf = FRANCHISE_CHF.get(franchise_code, 0)
praemie = Decimal(row['Prämie'].strip())
entries.append({
'versicherer_id': versicherer_id,
'kanton': kanton,
'region': region,
'altersklasse': altersklasse,
'unfalleinschluss': unfalleinschluss,
'tariftyp': tariftyp,
'tarifbezeichnung': tarifbezeichnung,
'franchisestufe': franchisestufe,
'franchise_chf': franchise_chf,
'praemie': praemie,
'data_year': data_year,
})
except (ValueError, InvalidOperation, KeyError):
continue
return entries
class Command(BaseCommand):
help = 'Import Swiss KVG premium data from BAG/Priminfo (PLZ regions + granular policen)'
def add_arguments(self, parser):
parser.add_argument('--year', type=int, default=LATEST_YEAR,
help='Year for praemienregionen XLSX (default: %(default)s)')
parser.add_argument('--policen-year', type=int, default=POLICEN_YEAR,
help='Business year in Prämien_CH.csv (default: %(default)s)')
parser.add_argument('--skip-regionen', action='store_true',
help='Skip PLZ/region import (praemienregionen XLSX)')
parser.add_argument('--skip-policen', action='store_true',
help='Skip granular policen import (Prämien_CH.csv)')
def handle(self, *args, **options):
year = options['year']
policen_year = options['policen_year']
# ── 1. PLZ / Prämienregionen ──────────────────────────────────────
if not options['skip_regionen']:
url = REGIONEN_URL.format(year=year)
self.stdout.write(f'[1/2] Downloading praemienregionen {year}: {url}')
try:
with urllib.request.urlopen(url, timeout=30) as resp:
data = resp.read()
except Exception as e:
raise CommandError(f'Download failed: {e}')
self.stdout.write(f' Parsing XLSX ({len(data):,} bytes)…')
entries = _parse_regionen_xlsx(data, year)
self.stdout.write(f' Parsed {len(entries):,} PLZ entries.')
if not entries:
raise CommandError('No PLZ data parsed — check XLSX structure.')
deleted, _ = PraemienEntry.objects.filter(data_year=year).delete()
self.stdout.write(f' Cleared {deleted} old entries.')
objs = [PraemienEntry(**e) for e in entries]
for i in range(0, len(objs), 1000):
PraemienEntry.objects.bulk_create(objs[i:i+1000], ignore_conflicts=True)
self.stdout.write(self.style.SUCCESS(f'{len(objs):,} PLZ entries imported.'))
# ── 2. Granular Prämien_CH.csv ────────────────────────────────────
if not options['skip_policen']:
self.stdout.write(f'[2/2] Downloading Prämien_CH.csv (business year {policen_year})…')
try:
with urllib.request.urlopen(POLICEN_URL, timeout=120) as resp:
data = resp.read()
except Exception as e:
raise CommandError(f'Download failed: {e}')
self.stdout.write(f' Parsing CSV ({len(data):,} bytes)…')
entries = _parse_policen_csv(data, policen_year)
self.stdout.write(f' Parsed {len(entries):,} policen rows.')
if not entries:
raise CommandError('No policen data parsed — check CSV structure.')
deleted, _ = PraemienPolice.objects.filter(data_year=policen_year).delete()
self.stdout.write(f' Cleared {deleted} old entries.')
objs = [PraemienPolice(**e) for e in entries]
created = 0
for i in range(0, len(objs), 2000):
PraemienPolice.objects.bulk_create(objs[i:i+2000], ignore_conflicts=True)
created += min(2000, len(objs) - i)
self.stdout.write(f' {created:,} / {len(objs):,}', ending='\r')
self.stdout.write('')
self.stdout.write(self.style.SUCCESS(f'{len(objs):,} policen rows imported.'))
self.stdout.write(self.style.SUCCESS('Done.'))
@@ -0,0 +1,37 @@
# Generated by Django 6.0.4 on 2026-05-24 10:31
import django.db.models.deletion
from django.conf import settings
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('finance', '0020_email_verify_token_expiry'),
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
]
operations = [
migrations.CreateModel(
name='Insurance',
fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('insurance_type', models.CharField(choices=[('kvg', 'Krankenkasse Grundversicherung (KVG)'), ('kk_zusatz', 'KK-Zusatzversicherung'), ('nbu', 'Nicht-Berufsunfallversicherung (NBU)'), ('haftpflicht', 'Privathaftpflicht'), ('hausrat', 'Hausrat'), ('mfz', 'MFZ-Haftpflicht'), ('rechtsschutz', 'Rechtsschutz'), ('saule_3a', 'Säule 3a'), ('leben', 'Lebensversicherung'), ('reise', 'Reiseversicherung'), ('other', 'Sonstiges')], max_length=30)),
('insurer', models.CharField(max_length=200)),
('policy_number', models.CharField(blank=True, default='', max_length=100)),
('premium', models.DecimalField(decimal_places=2, max_digits=10)),
('premium_period', models.CharField(choices=[('monthly', 'Monatlich'), ('quarterly', 'Vierteljährlich'), ('semi_annual', 'Halbjährlich'), ('annual', 'Jährlich')], default='monthly', max_length=20)),
('coverage_amount', models.DecimalField(blank=True, decimal_places=2, max_digits=12, null=True)),
('deductible', models.DecimalField(blank=True, decimal_places=2, max_digits=10, null=True)),
('valid_from', models.DateField(blank=True, null=True)),
('valid_until', models.DateField(blank=True, null=True)),
('notes', models.TextField(blank=True, default='')),
('created_at', models.DateTimeField(auto_now_add=True)),
('user', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='insurances', to=settings.AUTH_USER_MODEL)),
],
options={
'ordering': ['insurance_type'],
},
),
]
@@ -0,0 +1,34 @@
# Generated by Django 6.0.4 on 2026-05-24 11:14
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('finance', '0021_add_insurance_model'),
]
operations = [
migrations.CreateModel(
name='PraemienEntry',
fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('plz', models.CharField(db_index=True, max_length=10)),
('ort', models.CharField(max_length=200)),
('kanton', models.CharField(max_length=2)),
('region', models.PositiveSmallIntegerField()),
('bfs_nr', models.PositiveIntegerField(db_index=True)),
('gemeinde', models.CharField(max_length=200)),
('bezirk', models.CharField(blank=True, default='', max_length=200)),
('avg_adult', models.DecimalField(decimal_places=2, max_digits=8)),
('avg_young_adult', models.DecimalField(decimal_places=2, max_digits=8)),
('avg_child', models.DecimalField(decimal_places=2, max_digits=8)),
('data_year', models.PositiveSmallIntegerField(db_index=True)),
],
options={
'ordering': ['kanton', 'ort'],
'unique_together': {('plz', 'ort', 'data_year')},
},
),
]
@@ -0,0 +1,34 @@
# Generated by Django 6.0.4 on 2026-05-24 11:38
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('finance', '0022_add_praemien_entry'),
]
operations = [
migrations.CreateModel(
name='PraemienPolice',
fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('versicherer_id', models.PositiveIntegerField(db_index=True)),
('kanton', models.CharField(max_length=2)),
('region', models.PositiveSmallIntegerField()),
('altersklasse', models.CharField(max_length=10)),
('unfalleinschluss', models.CharField(max_length=10)),
('tariftyp', models.CharField(max_length=10)),
('tarifbezeichnung', models.CharField(max_length=200)),
('franchisestufe', models.CharField(max_length=10)),
('franchise_chf', models.PositiveSmallIntegerField()),
('praemie', models.DecimalField(decimal_places=2, max_digits=8)),
('data_year', models.PositiveSmallIntegerField(db_index=True)),
],
options={
'indexes': [models.Index(fields=['kanton', 'region', 'altersklasse', 'unfalleinschluss', 'tariftyp', 'franchisestufe', 'data_year'], name='finance_pra_kanton_e430cb_idx')],
'unique_together': {('versicherer_id', 'kanton', 'region', 'altersklasse', 'unfalleinschluss', 'tariftyp', 'franchisestufe', 'data_year')},
},
),
]
+108
View File
@@ -224,3 +224,111 @@ class BackupCode(models.Model):
def __str__(self):
return f"{self.user} backup {'used' if self.used else 'active'}"
class Insurance(models.Model):
INSURANCE_TYPES = [
('kvg', 'Krankenkasse Grundversicherung (KVG)'),
('kk_zusatz', 'KK-Zusatzversicherung'),
('nbu', 'Nicht-Berufsunfallversicherung (NBU)'),
('haftpflicht', 'Privathaftpflicht'),
('hausrat', 'Hausrat'),
('mfz', 'MFZ-Haftpflicht'),
('rechtsschutz', 'Rechtsschutz'),
('saule_3a', 'Säule 3a'),
('leben', 'Lebensversicherung'),
('reise', 'Reiseversicherung'),
('other', 'Sonstiges'),
]
PERIOD_CHOICES = [
('monthly', 'Monatlich'),
('quarterly', 'Vierteljährlich'),
('semi_annual', 'Halbjährlich'),
('annual', 'Jährlich'),
]
user = models.ForeignKey(
settings.AUTH_USER_MODEL,
on_delete=models.CASCADE,
related_name='insurances',
)
insurance_type = models.CharField(max_length=30, choices=INSURANCE_TYPES)
insurer = models.CharField(max_length=200)
policy_number = models.CharField(max_length=100, blank=True, default='')
premium = models.DecimalField(max_digits=10, decimal_places=2)
premium_period = models.CharField(max_length=20, choices=PERIOD_CHOICES, default='monthly')
coverage_amount = models.DecimalField(max_digits=12, decimal_places=2, null=True, blank=True)
deductible = models.DecimalField(max_digits=10, decimal_places=2, null=True, blank=True)
valid_from = models.DateField(null=True, blank=True)
valid_until = models.DateField(null=True, blank=True)
notes = models.TextField(blank=True, default='')
created_at = models.DateTimeField(auto_now_add=True)
class Meta:
ordering = ['insurance_type']
def __str__(self):
return f"{self.get_insurance_type_display()} {self.insurer}"
class PraemienEntry(models.Model):
"""
Swiss health insurance average premium data from BAG / Priminfo.
Populated via management command: python manage.py import_praemien [year]
Source: https://www.priminfo.admin.ch/downloads/praemienregionen_{year}.xlsx
"""
plz = models.CharField(max_length=10, db_index=True)
ort = models.CharField(max_length=200)
kanton = models.CharField(max_length=2)
region = models.PositiveSmallIntegerField() # Prämienregion 0, 1, 2, or 3
bfs_nr = models.PositiveIntegerField(db_index=True)
gemeinde = models.CharField(max_length=200)
bezirk = models.CharField(max_length=200, blank=True, default='')
avg_adult = models.DecimalField(max_digits=8, decimal_places=2)
avg_young_adult = models.DecimalField(max_digits=8, decimal_places=2)
avg_child = models.DecimalField(max_digits=8, decimal_places=2)
data_year = models.PositiveSmallIntegerField(db_index=True)
class Meta:
unique_together = ['plz', 'ort', 'data_year']
ordering = ['kanton', 'ort']
def __str__(self):
return f"{self.plz} {self.ort} ({self.kanton}) Region {self.region} {self.data_year}"
class PraemienPolice(models.Model):
"""
Granular KVG premium data per insurer, canton, region, age class, model, franchise.
Populated via management command: python manage.py import_praemien [year]
Source: https://opendata.bagnet.ch (Prämien_CH.csv)
~217k rows for a full year.
"""
versicherer_id = models.PositiveIntegerField(db_index=True)
kanton = models.CharField(max_length=2)
region = models.PositiveSmallIntegerField() # 0, 1, 2, 3
altersklasse = models.CharField(max_length=10) # AKL-ERW / AKL-JUG / AKL-KIN
unfalleinschluss = models.CharField(max_length=10) # MIT-UNF / OHN-UNF
tariftyp = models.CharField(max_length=10) # TAR-BASE / TAR-HAM / TAR-HMO / TAR-DIV
tarifbezeichnung = models.CharField(max_length=200)
franchisestufe = models.CharField(max_length=10) # FRAST1 … FRAST7
franchise_chf = models.PositiveSmallIntegerField() # e.g. 300, 500, 1000 …
praemie = models.DecimalField(max_digits=8, decimal_places=2)
data_year = models.PositiveSmallIntegerField(db_index=True)
class Meta:
unique_together = [
'versicherer_id', 'kanton', 'region', 'altersklasse',
'unfalleinschluss', 'tariftyp', 'franchisestufe', 'data_year',
]
indexes = [
models.Index(fields=[
'kanton', 'region', 'altersklasse',
'unfalleinschluss', 'tariftyp', 'franchisestufe', 'data_year',
]),
]
def __str__(self):
return (f"V{self.versicherer_id} {self.kanton} R{self.region} "
f"{self.altersklasse} {self.tariftyp} {self.franchisestufe}{self.praemie} CHF")
+7 -1
View File
@@ -1,6 +1,6 @@
from rest_framework import serializers
from django.contrib.auth import get_user_model
from .models import Account, Transaction, Budget, Expense, Profile, Deadline
from .models import Account, Transaction, Budget, Expense, Profile, Deadline, Insurance
User = get_user_model()
@@ -56,6 +56,12 @@ class DeadlineSerializer(serializers.ModelSerializer):
exclude = ['user']
class InsuranceSerializer(serializers.ModelSerializer):
class Meta:
model = Insurance
exclude = ['user']
class RegisterSerializer(serializers.Serializer):
email = serializers.EmailField()
password = serializers.CharField(min_length=8, write_only=True)
+168 -1
View File
@@ -21,10 +21,11 @@ from rest_framework.permissions import AllowAny, IsAuthenticated
from rest_framework.throttling import AnonRateThrottle
from rest_framework_simplejwt.tokens import RefreshToken
from rest_framework_simplejwt.exceptions import TokenError
from .models import Account, Transaction, Budget, Expense, Profile, Deadline, ReadEvent, BackupCode, UserSession
from .models import Account, Transaction, Budget, Expense, Profile, Deadline, ReadEvent, BackupCode, UserSession, Insurance, PraemienEntry, PraemienPolice
from .serializers import (
AccountSerializer, TransactionSerializer, BudgetSerializer,
ExpenseSerializer, ProfileSerializer, DeadlineSerializer, RegisterSerializer,
InsuranceSerializer,
)
@@ -111,6 +112,172 @@ class DeadlineViewSet(viewsets.ModelViewSet):
serializer.save(user=self.request.user)
class InsuranceViewSet(viewsets.ModelViewSet):
serializer_class = InsuranceSerializer
def get_queryset(self):
return Insurance.objects.filter(user=self.request.user)
def perform_create(self, serializer):
serializer.save(user=self.request.user)
INSURER_NAMES: dict[int, str] = {
8: 'Helsana AG',
32: 'KPT/CPT',
134: 'CSS Versicherung AG',
194: 'Concordia',
246: 'Groupe Mutuel',
290: 'Sanitas Krankenversicherung',
312: 'SWICA Krankenversicherung',
343: 'Visana AG',
360: 'Atupri Krankenkasse',
376: 'Kolping Krankenkasse',
455: 'EGK-Gesundheitskasse',
509: 'Galenos AG',
780: 'Luzerner Hinterland Krankenkasse (LHK)',
820: 'Krankenkasse Steffisburg',
881: 'sodalis gesundheitsgruppe',
923: 'Vivao Sympany AG',
941: 'Birchmeier Krankenkasse',
966: 'Krankenkasse Wädenswil',
1040: 'ÖKK',
1113: 'Agrisano Krankenkasse',
1318: 'Mutuel Assurance',
1322: 'Provita Gesundheitsversicherung AG',
1384: 'Sanagate AG',
1386: 'Aquilana Versicherungen',
1401: 'Easy Sana Assurance Maladie SA',
1479: 'Caisse-maladie Philos',
1507: 'Scheidegg Krankenkasse',
1509: 'Sana24 AG',
1535: 'rhenusana',
1542: 'Caisse-maladie de la Vallée SA',
1555: 'KLuG Krankenkasse',
1560: 'Krankenkasse Institut Ingenbohl',
1562: 'Sumiswalder Krankenkasse',
1568: 'avanto health AG',
}
def _age_class(geburtsjahr: int) -> str:
from datetime import date
age = date.today().year - geburtsjahr
if age <= 18:
return 'AKL-KIN'
if age <= 25:
return 'AKL-JUG'
return 'AKL-ERW'
class PraemienView(views.APIView):
"""
GET /api/praemien/?plz=8001
→ Ø-Prämien aus D_PRIM (alle Versicherer, alle Modelle), 3 Alterskategorien.
GET /api/praemien/vergleich/?plz=8001&geburtsjahr=1990&tariftyp=TAR-BASE&franchisestufe=FRAST1&unfall=OHN-UNF
→ Granularer Prämienvergleich pro Versicherer aus Prämien_CH.csv.
"""
permission_classes = [IsAuthenticated]
def get(self, request):
plz = request.query_params.get('plz', '').strip().zfill(4)
if not plz or not plz.isdigit() or len(plz) != 4:
return Response({'error': 'Invalid PLZ'}, status=status.HTTP_400_BAD_REQUEST)
latest_year = (PraemienEntry.objects
.values_list('data_year', flat=True)
.order_by('-data_year').first())
if not latest_year:
return Response({'error': 'No data imported yet.'}, status=status.HTTP_404_NOT_FOUND)
entries = list(PraemienEntry.objects.filter(plz=plz, data_year=latest_year).values(
'plz', 'ort', 'kanton', 'region', 'gemeinde', 'bezirk',
'avg_adult', 'avg_young_adult', 'avg_child', 'data_year',
))
if not entries:
return Response({'error': f'No data for PLZ {plz}'}, status=status.HTTP_404_NOT_FOUND)
return Response({'data_year': latest_year, 'results': entries})
class PraemienVergleichView(views.APIView):
"""
GET /api/praemien/vergleich/?plz=8001&geburtsjahr=1990&tariftyp=TAR-BASE&franchisestufe=FRAST1&unfall=OHN-UNF
Returns all available insurers for the given filters, sorted by premium ascending.
"""
permission_classes = [IsAuthenticated]
def get(self, request):
plz = request.query_params.get('plz', '').strip().zfill(4)
geburtsjahr_raw = request.query_params.get('geburtsjahr', '').strip()
tariftyp = request.query_params.get('tariftyp', 'TAR-BASE').strip()
franchisestufe = request.query_params.get('franchisestufe', 'FRAST1').strip()
unfall = request.query_params.get('unfall', 'OHN-UNF').strip()
if not plz or not plz.isdigit() or len(plz) != 4:
return Response({'error': 'Invalid PLZ'}, status=status.HTTP_400_BAD_REQUEST)
if not geburtsjahr_raw.isdigit():
return Response({'error': 'Invalid geburtsjahr'}, status=status.HTTP_400_BAD_REQUEST)
geburtsjahr = int(geburtsjahr_raw)
altersklasse = _age_class(geburtsjahr)
# Resolve PLZ → Kanton + Region
entry = (PraemienEntry.objects
.filter(plz=plz)
.order_by('-data_year')
.values('kanton', 'region', 'ort', 'gemeinde', 'data_year')
.first())
if not entry:
return Response({'error': f'PLZ {plz} not found'}, status=status.HTTP_404_NOT_FOUND)
kanton = entry['kanton']
region = entry['region']
# Latest policen year
policen_year = (PraemienPolice.objects
.values_list('data_year', flat=True)
.order_by('-data_year').first())
if not policen_year:
return Response({'error': 'No policen data imported yet.'}, status=status.HTTP_404_NOT_FOUND)
policen = list(PraemienPolice.objects.filter(
kanton=kanton,
region=region,
altersklasse=altersklasse,
unfalleinschluss=unfall,
tariftyp=tariftyp,
franchisestufe=franchisestufe,
data_year=policen_year,
).values('versicherer_id', 'tarifbezeichnung', 'franchise_chf', 'praemie')
.order_by('praemie'))
results = []
for p in policen:
vid = p['versicherer_id']
results.append({
'versicherer_id': vid,
'versicherer_name': INSURER_NAMES.get(vid, f'Versicherer {vid}'),
'tarifbezeichnung': p['tarifbezeichnung'],
'franchise_chf': p['franchise_chf'],
'praemie': str(p['praemie']),
})
return Response({
'kanton': kanton,
'region': region,
'ort': entry['ort'],
'gemeinde': entry['gemeinde'],
'altersklasse': altersklasse,
'tariftyp': tariftyp,
'franchisestufe': franchisestufe,
'unfall': unfall,
'data_year': policen_year,
'results': results,
})
class ProfileView(views.APIView):
def get(self, request):
profile, _ = Profile.objects.get_or_create(user=request.user)
+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)"
}
}