From c03d2a97abcdf00bc5950dd78c924c059a3feae9 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Daniel=20Kr=C3=A4henb=C3=BChl?= Date: Mon, 25 May 2026 22:05:37 +0200 Subject: [PATCH] =?UTF-8?q?feat:=20insurance=20section=20=E2=80=94=20overv?= =?UTF-8?q?iew,=20documents,=20analysis,=20KVG=20premium=20comparison?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 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 --- backend/core/urls.py | 5 +- backend/finance/management/__init__.py | 0 .../finance/management/commands/__init__.py | 0 .../management/commands/import_praemien.py | 309 ++++++++++++++ .../migrations/0021_add_insurance_model.py | 37 ++ .../migrations/0022_add_praemien_entry.py | 34 ++ .../migrations/0023_add_praemien_police.py | 34 ++ backend/finance/models.py | 110 ++++- backend/finance/serializers.py | 8 +- backend/finance/views.py | 169 +++++++- frontend/src/app/app.routes.ts | 8 + .../src/app/insurance/analyse/analyse.html | 24 ++ frontend/src/app/insurance/analyse/analyse.ts | 10 + .../app/insurance/documents/documents.html | 25 ++ .../src/app/insurance/documents/documents.ts | 10 + .../src/app/insurance/overview/overview.html | 302 ++++++++++++++ .../src/app/insurance/overview/overview.ts | 163 ++++++++ .../src/app/insurance/priminfo/priminfo.html | 382 ++++++++++++++++++ .../src/app/insurance/priminfo/priminfo.ts | 209 ++++++++++ frontend/src/app/layout/sidebar/sidebar.html | 61 +++ frontend/src/app/services/api.ts | 39 ++ frontend/src/app/services/sidebar.ts | 5 + frontend/src/assets/i18n/de.json | 139 ++++++- frontend/src/assets/i18n/en.json | 139 ++++++- frontend/src/assets/i18n/fr.json | 139 ++++++- frontend/src/assets/i18n/it.json | 139 ++++++- 26 files changed, 2456 insertions(+), 44 deletions(-) create mode 100644 backend/finance/management/__init__.py create mode 100644 backend/finance/management/commands/__init__.py create mode 100644 backend/finance/management/commands/import_praemien.py create mode 100644 backend/finance/migrations/0021_add_insurance_model.py create mode 100644 backend/finance/migrations/0022_add_praemien_entry.py create mode 100644 backend/finance/migrations/0023_add_praemien_police.py create mode 100644 frontend/src/app/insurance/analyse/analyse.html create mode 100644 frontend/src/app/insurance/analyse/analyse.ts create mode 100644 frontend/src/app/insurance/documents/documents.html create mode 100644 frontend/src/app/insurance/documents/documents.ts create mode 100644 frontend/src/app/insurance/overview/overview.html create mode 100644 frontend/src/app/insurance/overview/overview.ts create mode 100644 frontend/src/app/insurance/priminfo/priminfo.html create mode 100644 frontend/src/app/insurance/priminfo/priminfo.ts diff --git a/backend/core/urls.py b/backend/core/urls.py index a79c45a..060292a 100644 --- a/backend/core/urls.py +++ b/backend/core/urls.py @@ -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///', ICalFeedView.as_view()), ] + static(settings.MEDIA_URL, document_root=settings.MEDIA_ROOT) diff --git a/backend/finance/management/__init__.py b/backend/finance/management/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/backend/finance/management/commands/__init__.py b/backend/finance/management/commands/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/backend/finance/management/commands/import_praemien.py b/backend/finance/management/commands/import_praemien.py new file mode 100644 index 0000000..283430e --- /dev/null +++ b/backend/finance/management/commands/import_praemien.py @@ -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.')) diff --git a/backend/finance/migrations/0021_add_insurance_model.py b/backend/finance/migrations/0021_add_insurance_model.py new file mode 100644 index 0000000..78a25b9 --- /dev/null +++ b/backend/finance/migrations/0021_add_insurance_model.py @@ -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'], + }, + ), + ] diff --git a/backend/finance/migrations/0022_add_praemien_entry.py b/backend/finance/migrations/0022_add_praemien_entry.py new file mode 100644 index 0000000..64f017f --- /dev/null +++ b/backend/finance/migrations/0022_add_praemien_entry.py @@ -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')}, + }, + ), + ] diff --git a/backend/finance/migrations/0023_add_praemien_police.py b/backend/finance/migrations/0023_add_praemien_police.py new file mode 100644 index 0000000..f69ce25 --- /dev/null +++ b/backend/finance/migrations/0023_add_praemien_police.py @@ -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')}, + }, + ), + ] diff --git a/backend/finance/models.py b/backend/finance/models.py index 52652bf..8aa8d1b 100644 --- a/backend/finance/models.py +++ b/backend/finance/models.py @@ -223,4 +223,112 @@ class BackupCode(models.Model): indexes = [models.Index(fields=['user', 'used'])] def __str__(self): - return f"{self.user} – backup {'used' if self.used else 'active'}" \ No newline at end of file + 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") \ No newline at end of file diff --git a/backend/finance/serializers.py b/backend/finance/serializers.py index 2768263..cb18f94 100644 --- a/backend/finance/serializers.py +++ b/backend/finance/serializers.py @@ -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) diff --git a/backend/finance/views.py b/backend/finance/views.py index 0974c98..a5776aa 100644 --- a/backend/finance/views.py +++ b/backend/finance/views.py @@ -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) diff --git a/frontend/src/app/app.routes.ts b/frontend/src/app/app.routes.ts index fb9ce80..a6cd142 100644 --- a/frontend/src/app/app.routes.ts +++ b/frontend/src/app/app.routes.ts @@ -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' }, diff --git a/frontend/src/app/insurance/analyse/analyse.html b/frontend/src/app/insurance/analyse/analyse.html new file mode 100644 index 0000000..4b9437b --- /dev/null +++ b/frontend/src/app/insurance/analyse/analyse.html @@ -0,0 +1,24 @@ +
+ +
+

{{ 'insurance_analyse.title' | translate }}

+

{{ 'insurance_analyse.subtitle' | translate }}

+
+ + +
+
+ + + +
+

{{ 'insurance_analyse.coming_soon_title' | translate }}

+

{{ 'insurance_analyse.coming_soon_text' | translate }}

+
+ {{ 'insurance_analyse.tag_soll' | translate }} + {{ 'insurance_analyse.tag_gaps' | translate }} + {{ 'insurance_analyse.tag_recommendations' | translate }} +
+
+ +
diff --git a/frontend/src/app/insurance/analyse/analyse.ts b/frontend/src/app/insurance/analyse/analyse.ts new file mode 100644 index 0000000..583e653 --- /dev/null +++ b/frontend/src/app/insurance/analyse/analyse.ts @@ -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 {} diff --git a/frontend/src/app/insurance/documents/documents.html b/frontend/src/app/insurance/documents/documents.html new file mode 100644 index 0000000..6530546 --- /dev/null +++ b/frontend/src/app/insurance/documents/documents.html @@ -0,0 +1,25 @@ +
+ +
+

{{ 'insurance_docs.title' | translate }}

+

{{ 'insurance_docs.subtitle' | translate }}

+
+ + +
+
+ + + + +
+

{{ 'insurance_docs.coming_soon_title' | translate }}

+

{{ 'insurance_docs.coming_soon_text' | translate }}

+
+ PDF Upload + AI / IDP + Claude API +
+
+ +
diff --git a/frontend/src/app/insurance/documents/documents.ts b/frontend/src/app/insurance/documents/documents.ts new file mode 100644 index 0000000..93d2b2e --- /dev/null +++ b/frontend/src/app/insurance/documents/documents.ts @@ -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 {} diff --git a/frontend/src/app/insurance/overview/overview.html b/frontend/src/app/insurance/overview/overview.html new file mode 100644 index 0000000..3c062e3 --- /dev/null +++ b/frontend/src/app/insurance/overview/overview.html @@ -0,0 +1,302 @@ +
+ + +
+
+

{{ 'insurance.title' | translate }}

+

{{ 'insurance.subtitle' | translate }}

+
+ +
+ + +
+ +
+

{{ 'insurance.kpi_monthly' | translate }}

+

{{ totalMonthly() | number:'1.2-2' }} CHF

+
+ +
+

{{ 'insurance.kpi_count' | translate }}

+

{{ insurances().length }}

+
+ +
+

{{ 'insurance.kpi_covered' | translate }}

+

+ {{ coveredTypes().size }} / {{ insuranceTypes.length }} +

+
+
+ + +
+

{{ 'insurance.checklist_title' | translate }}

+
+ @for (type of checklist; track type) { +
+ @if (coveredTypes().has(type)) { + + + + } @else { + + + + } + + {{ ('insurance.types.' + type) | translate }} + +
+ } +
+

{{ 'insurance.checklist_hint' | translate }}

+
+ + +
+
+

{{ 'insurance.list_title' | translate }}

+
+ + @if (loading()) { +
{{ 'insurance.loading' | translate }}
+ } @else if (insurances().length === 0) { +
+ + + +

{{ 'insurance.no_entries' | translate }}

+
+ } @else { +
+ @for (ins of insurances(); track ins.id) { +
+
+ + + {{ ('insurance.types.' + ins.insurance_type) | translate }} + +
+

{{ ins.insurer }}

+ @if (ins.policy_number) { +

{{ ins.policy_number }}

+ } +
+
+
+ + + +
+

{{ monthlyEquivalent(ins) | number:'1.2-2' }} CHF/{{ 'insurance.month_short' | translate }}

+
+ +
+ + +
+
+
+ } +
+ } +
+ +
+ + +@if (showModal()) { +
+
+
+

+ {{ (editTarget() ? 'insurance.edit_title' : 'insurance.create_title') | translate }} +

+ +
+ +
+ +
+ + +
+ + +
+ + +
+ + +
+ + +
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+ + +
+
+ + +
+ + +
+
+
+} + + +@if (deleteTarget()) { +
+
+

{{ 'common.delete_confirm_title' | translate }}

+

{{ 'common.delete_confirm_text' | translate }}

+
+ + +
+
+
+} diff --git a/frontend/src/app/insurance/overview/overview.ts b/frontend/src/app/insurance/overview/overview.ts new file mode 100644 index 0000000..61fb25c --- /dev/null +++ b/frontend/src/app/insurance/overview/overview.ts @@ -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 = { + 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([]); + loading = signal(true); + saving = signal(false); + showModal = signal(false); + deleteTarget = signal(null); + editTarget = signal(null); + + form = signal({ + 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(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; + } +} diff --git a/frontend/src/app/insurance/priminfo/priminfo.html b/frontend/src/app/insurance/priminfo/priminfo.html new file mode 100644 index 0000000..d19d5bd --- /dev/null +++ b/frontend/src/app/insurance/priminfo/priminfo.html @@ -0,0 +1,382 @@ +
+ + +
+
+

{{ 'priminfo.title' | translate }}

+

{{ 'priminfo.subtitle' | translate }}

+
+ +
+ + +
+ +
+ + +
+

{{ 'priminfo.plz_hint' | translate }}

+
+ + + @if (error()) { +
+ + + +

{{ 'priminfo.error_not_found' | translate }}

+
+ } + + + @if (results().length > 0) { +
+
+
+ + + +
+
+

+ {{ results()[0].plz }} {{ results()[0].ort }} +

+

+ {{ results()[0].kanton }} · {{ results()[0].bezirk }} +

+
+
+ + {{ 'priminfo.region_label' | translate }} {{ results()[0].region }} + +
+
+ + @if (results().length > 1) { +
+

{{ 'priminfo.multi_ort_hint' | translate }}

+
+ } + + @for (entry of uniqueRegions(); track entry.region + entry.kanton) { +
+ @if (uniqueRegions().length > 1) { +

+ {{ entry.gemeinde }} — {{ 'priminfo.region_label' | translate }} {{ entry.region }} +

+ } +
+
+

{{ 'priminfo.col_child' | translate }}

+

{{ entry.avg_child | number:'1.2-2' }}

+

CHF/{{ 'priminfo.month' | translate }}

+
+
+

{{ 'priminfo.col_young' | translate }}

+

{{ entry.avg_young_adult | number:'1.2-2' }}

+

CHF/{{ 'priminfo.month' | translate }}

+
+
+

{{ 'priminfo.col_adult' | translate }}

+

{{ entry.avg_adult | number:'1.2-2' }}

+

CHF/{{ 'priminfo.month' | translate }}

+
+
+
+ } + +

+ {{ 'priminfo.disclaimer' | translate : { year: dataYear() } }} +

+
+ } @else if (searched() && !loading() && !error()) { +
{{ 'priminfo.no_results' | translate }}
+ } + + +
+ + +
+
+ + + +
+
+

{{ 'priminfo.vergleich_card_title' | translate }}

+

{{ 'priminfo.vergleich_card_subtitle' | translate }}

+
+
+ + +
+ + +
+ +
+ + @if (ageClass()) { + + @if (ageClass() === 'AKL-KIN') { {{ 'priminfo.age_child' | translate }} } + @else if (ageClass() === 'AKL-JUG') { {{ 'priminfo.age_young' | translate }} } + @else { {{ 'priminfo.age_adult' | translate }} } + + } +
+
+ + +
+ + +
+ + +
+ + +
+ + +
+ +
+ + +
+
+
+ + +

+ + + + {{ 'priminfo.unfall_note' | translate }} +

+ + +
+ + @if (!canVergleich() && !vergleichLoading()) { +

{{ 'priminfo.plz_hint' | translate }} + {{ 'priminfo.geburtsjahr_label' | translate | lowercase }}

+ } +
+
+ + + @if (vergleichError()) { +
+ + + +

{{ 'priminfo.error_not_found' | translate }}

+
+ } + + + @if (vergleichResults().length > 0) { +
+ + +
+
+ + + + {{ vergleichMeta()?.ort }} · {{ 'priminfo.region_label' | translate }} {{ vergleichMeta()?.region }} +
+
+ + {{ 'priminfo.vergleich_data_year' | translate : { year: vergleichMeta()?.data_year } }} + + + {{ vergleichResults().length }} {{ 'priminfo.vergleich_hint' | translate }} + +
+
+ + +
+ + + + + + + + + + + + @for (r of vergleichResults(); track r.versicherer_id; let i = $index) { + + + + + + + + } + +
{{ 'priminfo.col_rank' | translate }}{{ 'priminfo.col_insurer' | translate }}{{ 'priminfo.col_premium' | translate }}
{{ i + 1 }} +
+ + {{ r.versicherer_name }} + + @if (isCheapest(r.praemie)) { + + + + + {{ 'priminfo.cheapest_badge' | translate }} + + } +
+
+ + {{ r.praemie | number:'1.2-2' }} + + CHF +
+
+ +
+ } @else if (vergleichSearched() && !vergleichLoading() && !vergleichError()) { +
+

{{ 'priminfo.vergleich_no_results' | translate }}

+
+ } + + +
+
+

{{ 'priminfo.cta_title' | translate }}

+

{{ 'priminfo.cta_text' | translate }}

+
+ +
+ + +
+

{{ 'priminfo.info_title' | translate }}

+
+

{{ 'priminfo.info_1' | translate }}

+

{{ 'priminfo.info_2' | translate }}

+

{{ 'priminfo.info_3' | translate }}

+
+

+ {{ 'priminfo.source' | translate }} + priminfo.admin.ch +

+
+ +
diff --git a/frontend/src/app/insurance/priminfo/priminfo.ts b/frontend/src/app/insurance/priminfo/priminfo.ts new file mode 100644 index 0000000..195c676 --- /dev/null +++ b/frontend/src/app/insurance/priminfo/priminfo.ts @@ -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([]); + dataYear = signal(null); + error = signal(null); + searched = signal(false); + + // === Versicherer-Vergleich === + geburtsjahrInput = signal(''); + tariftyp = signal('TAR-BASE'); + franchisestufe = signal('FRAST1'); + unfall = signal('OHN-UNF'); + vergleichResults = signal([]); + vergleichLoading = signal(false); + vergleichError = signal(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(() => { + return this.ageClass() === 'AKL-ERW' ? FRANCHISE_ERW : FRANCHISE_KIN_JUG; + }); + + // Computed: deduplicate Ø-results by kanton+region + uniqueRegions = computed(() => { + const seen = new Set(); + 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'); + } +} diff --git a/frontend/src/app/layout/sidebar/sidebar.html b/frontend/src/app/layout/sidebar/sidebar.html index a064177..d2561a0 100644 --- a/frontend/src/app/layout/sidebar/sidebar.html +++ b/frontend/src/app/layout/sidebar/sidebar.html @@ -157,6 +157,67 @@ } + +
  • + @if (sidebarService.collapsed()) { + + + + @if (sidebarService.openFlyout() === 'insurance') { + + } + } @else { + + + @if (sidebarService.insuranceOpen()) { + + } + } +
  • + diff --git a/frontend/src/app/services/api.ts b/frontend/src/app/services/api.ts index 7b56356..ee00db3 100644 --- a/frontend/src/app/services/api.ts +++ b/frontend/src/app/services/api.ts @@ -194,4 +194,43 @@ export class ApiService { confirmPasswordReset(token: string, password: string): Observable { return this.http.post(`${this.baseUrl}/auth/password-reset/confirm/`, { token, password }); } + + // Praemien + getPraemienByPlz(plz: string): Observable { + return this.http.get(`${this.baseUrl}/praemien/?plz=${encodeURIComponent(plz)}`); + } + + getPraemienVergleich(params: { + plz: string; + geburtsjahr: number; + tariftyp: string; + franchisestufe: string; + unfall: string; + }): Observable { + 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 { + return this.http.get(`${this.baseUrl}/insurances/`); + } + + createInsurance(insurance: any): Observable { + return this.http.post(`${this.baseUrl}/insurances/`, insurance); + } + + updateInsurance(id: number, insurance: any): Observable { + return this.http.put(`${this.baseUrl}/insurances/${id}/`, insurance); + } + + deleteInsurance(id: number): Observable { + return this.http.delete(`${this.baseUrl}/insurances/${id}/`); + } } \ No newline at end of file diff --git a/frontend/src/app/services/sidebar.ts b/frontend/src/app/services/sidebar.ts index e5b01fc..502453f 100644 --- a/frontend/src/app/services/sidebar.ts +++ b/frontend/src/app/services/sidebar.ts @@ -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); } diff --git a/frontend/src/assets/i18n/de.json b/frontend/src/assets/i18n/de.json index e574d6f..ac880c8 100644 --- a/frontend/src/assets/i18n/de.json +++ b/frontend/src/assets/i18n/de.json @@ -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. (19–25 J.)", + "age_adult": "Erwachsener (≥ 26 J.)" } } diff --git a/frontend/src/assets/i18n/en.json b/frontend/src/assets/i18n/en.json index e844ffa..e3c03b7 100644 --- a/frontend/src/assets/i18n/en.json +++ b/frontend/src/assets/i18n/en.json @@ -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 (19–25 yrs)", + "age_adult": "Adult (≥ 26 yrs)" } } diff --git a/frontend/src/assets/i18n/fr.json b/frontend/src/assets/i18n/fr.json index 949a23f..f687613 100644 --- a/frontend/src/assets/i18n/fr.json +++ b/frontend/src/assets/i18n/fr.json @@ -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 (19–25 ans)", + "age_adult": "Adulte (≥ 26 ans)" } } diff --git a/frontend/src/assets/i18n/it.json b/frontend/src/assets/i18n/it.json index 2774c62..b100bf1 100644 --- a/frontend/src/assets/i18n/it.json +++ b/frontend/src/assets/i18n/it.json @@ -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 (19–25 anni)", + "age_adult": "Adulto (≥ 26 anni)" } }