1 Commits

Author SHA1 Message Date
Daniel Krähenbühl c03d2a97ab feat: insurance section — overview, documents, analysis, KVG premium comparison
- Insurance overview page (/insurance): current policies table with type,
  provider, premium, franchise, coverage, and document links
- Documents page: upload and manage insurance documents
- Analysis page: coverage gap analysis per insurance type
- Priminfo integration (/insurance/priminfo): KVG premium comparison by
  insurer, model (TAR/HMO/etc.), franchise level, and accident coverage
  via embedded Priminfo iframe (no public API available)
- Backend: Insurance, PraemienEntry, PraemienPolice models with migrations
- Sidebar: insurance nav group with flyout and dropdown
- i18n: all keys in DE/EN/FR/IT
2026-05-25 22:46:31 +02:00
39 changed files with 2453 additions and 2703 deletions
+1 -52
View File
@@ -5,58 +5,7 @@ All notable changes to this project will be documented in this file.
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
## [Unreleased]
### Added
- Financial Year: Income tab zeigt neu Revenue Accounts (Typ «Einnahmequelle») statt YearlyIncome-Einträge — Monatsgehalt × Monate = Jahreseinkommen; Toggle-Button pro Konto für 12 oder 13 Monatslöhne; Gesamtjahreseinkommen-Summe am Tab-Ende
- Account-Model: `salary_months` Feld (IntegerField, default 12, choices 12/13, Migration 0021); `patchAccount()` in ApiService
- Financial Year: Summary-Cards überarbeitet — (1) Jahreseinkommen aus Revenue Accounts, (2) Fixkosten/Monat × 12 = Jahresbetrag aus `/budgets`, (3) tatsächliche Ausgaben des gewählten Jahres aus `/expenses` (ersetzt «Verfügbar»)
- Financial Year: Haushalt-Finanzjahr erstellbar — Modal «Neues Jahr starten» zeigt Radio-Buttons «Persönlich» / Haushalt-Name wenn User aktive Haushaltsmitgliedschaft hat; Backend akzeptiert optionales `household_id` bei `POST /api/financial-years/`
- Financial Year: Haushalt-Modus Einnahmen-Tab zeigt Revenue Accounts aller aktiven Haushaltsmitglieder (neuer Endpoint `GET /api/households/<pk>/revenue-accounts/`); Partner-Accounts mit E-Mail-Hinweis
- `household_id` in FinancialYear-Serializer-Response
- Haushalt Einladungsflow für nicht-registrierte Benutzer: `PendingHouseholdInvite` Model (Migration 0022) speichert E-Mail ohne User-FK; nach Registrierung wird `HouseholdMembership` automatisch angelegt und `PendingHouseholdInvite` gelöscht
- Einladungs-E-Mail via `household_invite` Template (HTML + Plaintext) mit variablem CTA-Label; bestehende User erhalten «Einladung annehmen» → `/financial-year`, neue User «Konto erstellen & beitreten» → `/register`
- Frontend zeigt ausstehende Einladungen an nicht-registrierte Adressen mit Badge «Nicht registriert» in der Haushaltsliste
- `FRONTEND_URL` Setting (default `http://localhost:4200`; Prod: `https://www.armarium.ch` in `.env`)
- ProfileSerializer: `get_email()` gibt `user.email` zurück wenn Profile-Email leer — verhindert dass `myMembership()` für neue User keine Treffer findet
### Fixed
- Dashboard: `totalExpenses()` filterte nicht nach ausgewähltem Jahr — alle Ausgaben wurden summiert
- Dashboard: `totalIncome()` und `totalFixedCosts()` lasen aus FinancialYear statt aus Revenue Accounts / `/budgets` — inkonsistent mit Dateneingabe-Workflow des Users
- Financial Year: `updateIncome()` und `updateBudgetItem()` verwendeten `PUT` statt `PATCH` → 405 Method Not Allowed
- Financial Year: `reloadCurrentYear()` löste `NG0100 ExpressionChangedAfterItHasBeenCheckedError` aus — Signal-Updates in `setTimeout()` verschoben
- Financial Year: `PATCH /incomes/<id>/` und `/budget-items/<id>/` gaben 403 zurück wenn `is_active=False` auf FinancialYear — `is_active`-Check aus 5 Backend-Views entfernt
- Backend: `Profile.email_verified` und verwandte Felder existierten in DB aber nicht im Model → `IntegrityError` beim Login neuer User; Felder ins Model aufgenommen (Migration 0023, fake-applied); DB-Defaults via `ALTER COLUMN ... SET DEFAULT` gesetzt
### Added
- Feature: Jahresplanung (`/financial-year`) — neue Seite mit Jahres-Dropdown, 3 Summary-Cards (Einnahmen, Fixkosten, Verfügbar + Sparquote), Tabs Einnahmen/Fixkosten, Inline-Formular für CRUD; Button "Neues Jahr starten" — max. 1 Jahr im Voraus (Backend + Frontend enforced)
- Backend: `FinancialYear`, `YearlyIncome`, `YearlyBudgetItem`, `Household`, `HouseholdMembership` Modelle (Migration 0019); exclusivity-Constraint via `CheckConstraint(condition=...)` (Django 6.0.4), partielle Unique-Constraints für persönliche und Haushalt-Jahre
- Backend: `FinancialYearListCreateView`, `FinancialYearDetailView`, `FinancialYearCopyView`, `YearlyIncomeListCreateView/DetailView`, `YearlyBudgetItemListCreateView/DetailView`, `HouseholdListCreateView`, `HouseholdInviteView`, `HouseholdAcceptView`, `HouseholdLeaveView`, `HouseholdSetRoleView`
- Backend: `GET/POST /api/financial-years/`, `GET/PATCH/DELETE /api/financial-years/<year>/`, `POST /api/financial-years/<year>/copy-from/<source>/`, nested Endpunkte für Incomes und Budget Items; `GET/POST /api/households/`, Invite/Accept/Leave/SetRole
- Backend: Jahr-Erstellungs-Begrenzer — max. `current_calendar_year + 1`; plus "nur nächstes Jahr nach dem Maximum" Constraint
- Backend: `role` Feld auf `HouseholdMembership` (`member` | `admin`, Migration 0020); Gründer erhält automatisch `role='admin'`; Einladungen erlaubt für Gründer und aktive Admins; Rollenvergabe nur durch Gründer via `POST /api/households/<pk>/members/<id>/set-role/`
- Dashboard: `totalIncome()` und `totalFixedCosts()` lesen nun aus `FinancialYearService.list()` für das gewählte Jahr (statt alte Account/Budget-Daten); Jahres-Dropdown zeigt echte FinancialYear-Jahre; Donut-Chart zeigt `YearlyBudgetItem` des gewählten Jahres; Jahrwechsel re-rendert beide Charts
- Backend: Django Management Command `migrate_to_financial_year` — migriert bestehende Revenue-Accounts → `YearlyIncome` und Budgets → `YearlyBudgetItem` für Jahr 2026; idempotent, `--dry-run` Flag verfügbar
- Frontend: `FinancialYearService` (`services/financial-year.ts`) mit Typen `FinancialYear`, `YearlyIncome`, `YearlyBudgetItem`, `Household`, `HouseholdMembership` und allen API-Methoden
- Frontend: Household-Sektion auf `/financial-year` — Haushalt gründen (Inline-Form), Mitglieder-Liste mit Status- und Rollen-Badge, Einladen per E-Mail (Admins + Gründer), Rollen-Toggle (Key-Icon, nur Gründer), Pending-Einladungs-Banner mit "Annehmen", "Verlassen"-Button mit Bestätigungs-Modal
- Sidebar: "Jahresplanung" Nav-Item (Bar-Chart-Icon, Violet) zwischen Kalender und Konten
- i18n: `sidebar.financial_year`, `financial_year.*` Schlüssel (DE/EN/FR/IT)
- Dashboard: Einnahmen vs. Ausgaben — Flowbite-Redesign mit Icon-Header (Violet), 3 Serien (Einnahmen/Fixkosten/Variable Ausgaben), gerundete Balken, kein Grid/Y-Axis, custom Tooltip mit ausgeschriebenem Monatsnamen in Landessprache, Jahres-Dropdown im Footer
- Dashboard: Fixkostenaufschlüsselung — Pie Chart (war: Donut) mit %-Datenlabels direkt auf Segmenten; Toggle-Button (Violet) wechselt zur Listenansicht mit Name, CHF-Betrag und %; Violet-Farbpalette
- Dashboard: Sparquote — Violet-Marker auf Progress-Bar an der Zielposition; Settings-Toggle (Badge-Icon, Violet) öffnet Einstellungsansicht mit Zahlenfeld, Live-Marker-Preview und Speichern/Abbrechen; Ziel persisted im Profil (`savings_rate_goal`, Default 20%)
- Backend: `savings_rate_goal` Feld auf `Profile`-Modell (Migration 0018)
- i18n: `dashboard.view_report`, `dashboard.goal_hint` in DE/EN/FR/IT; `dashboard.goal` von "Ziel: 20%" zu "Sparziel" geändert
- Security: Cloudflare Turnstile CAPTCHA on login and register — `TurnstileComponent` (Angular, polls until script loaded, auto-reset on error); backend verifies token via `_verify_turnstile()` using urllib (no extra dependency); `DEBUG=True` and `localhost` bypass for local development; Submit button disabled until widget resolves
- Infrastructure: Brevo SMTP configured for transactional email (`smtp-relay.brevo.com:587`, TLS); domain `armarium.ch` verified with SPF/DKIM; account activation pending (requested via contact@brevo.com)
- i18n: `auth.errors.captcha_failed` key in DE/EN/FR/IT
- Docs: `design-system.md` — Brand design reference with colors, typography (desktop/mobile), icons, component patterns and Tailwind classes
### Changed
- `.env.example`: added `TURNSTILE_SECRET_KEY` and Brevo `EMAIL_*` variables
---
## [1.1.0] - 2026-05-17
## [1.1.0] - 2026-05-19
### Added
- Auth: E-Mail-Verifikation bei Registrierung — Token (SHA-256-Hash in DB, 24h gültig) wird per Mail versendet; `/verify-email?token=` Frontend-Route löst automatisch `POST /api/auth/verify-email/` aus
-1
View File
@@ -104,7 +104,6 @@ EMAIL_HOST_USER = os.environ.get('EMAIL_HOST_USER', '')
EMAIL_HOST_PASSWORD = os.environ.get('EMAIL_HOST_PASSWORD', '')
EMAIL_USE_TLS = os.environ.get('EMAIL_USE_TLS', 'True') == 'True'
DEFAULT_FROM_EMAIL = os.environ.get('DEFAULT_FROM_EMAIL', 'noreply@armarium.ch')
FRONTEND_URL = os.environ.get('FRONTEND_URL', 'http://localhost:4200')
LOGGING = {
'version': 1,
+4 -19
View File
@@ -7,18 +7,13 @@ 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,
SessionListView, SessionRevokeView, SessionRevokeAllView,
DataExportView, NotificationPrefsView,
VerifyEmailView, PasswordResetRequestView, PasswordResetConfirmView,
FinancialYearListCreateView, FinancialYearDetailView, FinancialYearCopyView,
YearlyIncomeListCreateView, YearlyIncomeDetailView,
YearlyBudgetItemListCreateView, YearlyBudgetItemDetailView,
HouseholdListCreateView, HouseholdInviteView, HouseholdAcceptView, HouseholdLeaveView,
HouseholdSetRoleView, HouseholdRevenueAccountsView,
)
router = DefaultRouter()
@@ -27,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('/')+ '/'
@@ -55,19 +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()),
path('api/financial-years/', FinancialYearListCreateView.as_view()),
path('api/financial-years/<int:year>/', FinancialYearDetailView.as_view()),
path('api/financial-years/<int:year>/copy-from/<int:source_year>/', FinancialYearCopyView.as_view()),
path('api/financial-years/<int:year>/incomes/', YearlyIncomeListCreateView.as_view()),
path('api/financial-years/<int:year>/incomes/<int:pk>/', YearlyIncomeDetailView.as_view()),
path('api/financial-years/<int:year>/budget-items/', YearlyBudgetItemListCreateView.as_view()),
path('api/financial-years/<int:year>/budget-items/<int:pk>/', YearlyBudgetItemDetailView.as_view()),
path('api/households/', HouseholdListCreateView.as_view()),
path('api/households/<int:pk>/invite/', HouseholdInviteView.as_view()),
path('api/households/<int:pk>/accept/', HouseholdAcceptView.as_view()),
path('api/households/<int:pk>/leave/', HouseholdLeaveView.as_view()),
path('api/households/<int:pk>/members/<int:membership_id>/set-role/', HouseholdSetRoleView.as_view()),
path('api/households/<int:pk>/revenue-accounts/', HouseholdRevenueAccountsView.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.'))
@@ -1,85 +0,0 @@
from django.core.management.base import BaseCommand
from django.contrib.auth import get_user_model
from finance.models import Account, Budget, FinancialYear, YearlyIncome, YearlyBudgetItem
User = get_user_model()
TARGET_YEAR = 2026
class Command(BaseCommand):
help = 'Migrate existing revenue accounts and budgets into FinancialYear 2026. Idempotent — safe to run multiple times.'
def add_arguments(self, parser):
parser.add_argument(
'--dry-run',
action='store_true',
help='Preview what would be created without writing to the database.',
)
def handle(self, *args, **options):
dry_run = options['dry_run']
if dry_run:
self.stdout.write(self.style.WARNING('DRY RUN — no changes will be saved.\n'))
total_incomes = 0
total_budgets = 0
for user in User.objects.all():
revenue_accounts = Account.objects.filter(user=user, account_type='revenue', active=True)
budgets = Budget.objects.filter(account__user=user, active=True)
if not revenue_accounts.exists() and not budgets.exists():
continue
self.stdout.write(f'\nUser: {user.email}')
if dry_run:
fy = FinancialYear.objects.filter(user=user, year=TARGET_YEAR).first()
if fy:
self.stdout.write(f' FinancialYear {TARGET_YEAR} already exists (id={fy.pk})')
else:
self.stdout.write(f' Would create FinancialYear {TARGET_YEAR}')
else:
fy, fy_created = FinancialYear.objects.get_or_create(user=user, year=TARGET_YEAR)
if fy_created:
self.stdout.write(f' Created FinancialYear {TARGET_YEAR} (id={fy.pk})')
else:
self.stdout.write(f' FinancialYear {TARGET_YEAR} exists (id={fy.pk})')
for account in revenue_accounts:
label = f'YearlyIncome "{account.name}" CHF {account.balance}'
if dry_run:
exists = fy and YearlyIncome.objects.filter(financial_year=fy, name=account.name).exists()
self.stdout.write(f' {"SKIP (exists)" if exists else "Would create"}: {label}')
else:
_, created = YearlyIncome.objects.get_or_create(
financial_year=fy,
name=account.name,
defaults={'amount': account.balance, 'member': user, 'active': True},
)
self.stdout.write(f' {"Created" if created else "Skipped (exists)"}: {label}')
if created:
total_incomes += 1
for budget in budgets:
label = f'YearlyBudgetItem "{budget.name}" CHF {budget.amount}'
if dry_run:
exists = fy and YearlyBudgetItem.objects.filter(financial_year=fy, name=budget.name).exists()
self.stdout.write(f' {"SKIP (exists)" if exists else "Would create"}: {label}')
else:
_, created = YearlyBudgetItem.objects.get_or_create(
financial_year=fy,
name=budget.name,
defaults={'amount': budget.amount, 'active': budget.active},
)
self.stdout.write(f' {"Created" if created else "Skipped (exists)"}: {label}')
if created:
total_budgets += 1
if not dry_run:
self.stdout.write(self.style.SUCCESS(
f'\nDone. Created {total_incomes} income(s) and {total_budgets} budget item(s).'
))
else:
self.stdout.write(self.style.WARNING('\nDry run complete. Re-run without --dry-run to apply.'))
@@ -1,89 +0,0 @@
# Generated by Django 6.0.4 on 2026-05-18 20:16
import django.db.models.deletion
from django.conf import settings
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('finance', '0018_profile_savings_rate_goal'),
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
]
operations = [
migrations.CreateModel(
name='Household',
fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('name', models.CharField(max_length=100)),
('created_at', models.DateTimeField(auto_now_add=True)),
('created_by', models.ForeignKey(on_delete=django.db.models.deletion.PROTECT, related_name='created_households', to=settings.AUTH_USER_MODEL)),
],
),
migrations.CreateModel(
name='FinancialYear',
fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('year', models.PositiveSmallIntegerField()),
('is_active', models.BooleanField(default=True)),
('notes', models.TextField(blank=True, default='')),
('created_at', models.DateTimeField(auto_now_add=True)),
('user', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, related_name='financial_years', to=settings.AUTH_USER_MODEL)),
('household', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, related_name='financial_years', to='finance.household')),
],
),
migrations.CreateModel(
name='HouseholdMembership',
fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('status', models.CharField(choices=[('pending', 'Pending'), ('active', 'Active'), ('left', 'Left')], default='pending', max_length=10)),
('effective_from_year', models.PositiveSmallIntegerField(blank=True, null=True)),
('effective_until_year', models.PositiveSmallIntegerField(blank=True, null=True)),
('created_at', models.DateTimeField(auto_now_add=True)),
('household', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='memberships', to='finance.household')),
('invited_by', models.ForeignKey(null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='sent_invitations', to=settings.AUTH_USER_MODEL)),
('user', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='household_memberships', to=settings.AUTH_USER_MODEL)),
],
),
migrations.CreateModel(
name='YearlyBudgetItem',
fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('name', models.CharField(max_length=100)),
('amount', models.DecimalField(decimal_places=2, max_digits=12)),
('active', models.BooleanField(default=True)),
('notes', models.TextField(blank=True, default='')),
('financial_year', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='budget_items', to='finance.financialyear')),
],
),
migrations.CreateModel(
name='YearlyIncome',
fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('name', models.CharField(max_length=100)),
('amount', models.DecimalField(decimal_places=2, max_digits=12)),
('active', models.BooleanField(default=True)),
('notes', models.TextField(blank=True, default='')),
('financial_year', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='incomes', to='finance.financialyear')),
('member', models.ForeignKey(null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='yearly_incomes', to=settings.AUTH_USER_MODEL)),
],
),
migrations.AddConstraint(
model_name='financialyear',
constraint=models.CheckConstraint(condition=models.Q(models.Q(('household__isnull', True), ('user__isnull', False)), models.Q(('household__isnull', False), ('user__isnull', True)), _connector='OR'), name='financial_year_owner_exclusive'),
),
migrations.AddConstraint(
model_name='financialyear',
constraint=models.UniqueConstraint(condition=models.Q(('user__isnull', False)), fields=('user', 'year'), name='unique_personal_financial_year'),
),
migrations.AddConstraint(
model_name='financialyear',
constraint=models.UniqueConstraint(condition=models.Q(('household__isnull', False)), fields=('household', 'year'), name='unique_household_financial_year'),
),
migrations.AlterUniqueTogether(
name='householdmembership',
unique_together={('household', 'user')},
),
]
@@ -1,18 +0,0 @@
# Generated by Django 6.0.4 on 2026-05-19 07:24
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('finance', '0019_financial_year'),
]
operations = [
migrations.AddField(
model_name='householdmembership',
name='role',
field=models.CharField(choices=[('member', 'Member'), ('admin', 'Admin')], default='member', max_length=10),
),
]
@@ -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'],
},
),
]
@@ -1,18 +0,0 @@
# Generated by Django 6.0.4 on 2026-05-21 18:37
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('finance', '0020_household_membership_role'),
]
operations = [
migrations.AddField(
model_name='account',
name='salary_months',
field=models.IntegerField(choices=[(12, 12), (13, 13)], default=12),
),
]
@@ -1,30 +0,0 @@
# Generated by Django 6.0.4 on 2026-05-21 19:49
import django.db.models.deletion
from django.conf import settings
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('finance', '0021_add_salary_months_to_account'),
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
]
operations = [
migrations.CreateModel(
name='PendingHouseholdInvite',
fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('invited_email', models.EmailField(max_length=254)),
('effective_from_year', models.PositiveSmallIntegerField(blank=True, null=True)),
('created_at', models.DateTimeField(auto_now_add=True)),
('household', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='pending_invites', to='finance.household')),
('invited_by', models.ForeignKey(null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='sent_pending_invitations', to=settings.AUTH_USER_MODEL)),
],
options={
'unique_together': {('household', 'invited_email')},
},
),
]
@@ -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')},
},
),
]
@@ -1,38 +0,0 @@
# Generated by Django 6.0.4 on 2026-05-21 20:05
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('finance', '0022_add_pending_household_invite'),
]
operations = [
migrations.AddField(
model_name='profile',
name='email_verified',
field=models.BooleanField(default=False),
),
migrations.AddField(
model_name='profile',
name='email_verify_token',
field=models.CharField(blank=True, default='', max_length=64),
),
migrations.AddField(
model_name='profile',
name='email_verify_token_expires',
field=models.DateTimeField(blank=True, null=True),
),
migrations.AddField(
model_name='profile',
name='password_reset_token_expires',
field=models.DateTimeField(blank=True, null=True),
),
migrations.AddField(
model_name='profile',
name='password_reset_token_hash',
field=models.CharField(blank=True, default='', max_length=64),
),
]
+93 -127
View File
@@ -18,7 +18,6 @@ class Account(models.Model):
name = models.CharField(max_length=100)
account_type = models.CharField(max_length=20, choices=ACCOUNT_TYPES, default='asset')
balance = models.DecimalField(max_digits=12, decimal_places=2, default=0.00)
salary_months = models.IntegerField(default=12, choices=[(12, 12), (13, 13)])
active = models.BooleanField(default=True)
created_at = models.DateTimeField(auto_now_add=True)
@@ -227,142 +226,109 @@ class BackupCode(models.Model):
return f"{self.user} backup {'used' if self.used else 'active'}"
# ── FinancialYear ─────────────────────────────────────────────────────────────
class Household(models.Model):
name = models.CharField(max_length=100)
created_by = models.ForeignKey(
settings.AUTH_USER_MODEL,
on_delete=models.PROTECT,
related_name='created_households',
)
created_at = models.DateTimeField(auto_now_add=True)
def __str__(self):
return self.name
class HouseholdMembership(models.Model):
STATUS_CHOICES = [
('pending', 'Pending'),
('active', 'Active'),
('left', 'Left'),
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'),
]
ROLE_CHOICES = [
('member', 'Member'),
('admin', 'Admin'),
PERIOD_CHOICES = [
('monthly', 'Monatlich'),
('quarterly', 'Vierteljährlich'),
('semi_annual', 'Halbjährlich'),
('annual', 'Jährlich'),
]
household = models.ForeignKey(Household, on_delete=models.CASCADE, related_name='memberships')
user = models.ForeignKey(
settings.AUTH_USER_MODEL,
on_delete=models.CASCADE,
related_name='household_memberships',
related_name='insurances',
)
invited_by = models.ForeignKey(
settings.AUTH_USER_MODEL,
on_delete=models.SET_NULL,
null=True,
related_name='sent_invitations',
)
status = models.CharField(max_length=10, choices=STATUS_CHOICES, default='pending')
role = models.CharField(max_length=10, choices=ROLE_CHOICES, default='member')
effective_from_year = models.PositiveSmallIntegerField(null=True, blank=True)
effective_until_year = models.PositiveSmallIntegerField(null=True, blank=True)
created_at = models.DateTimeField(auto_now_add=True)
class Meta:
unique_together = ['household', 'user']
def __str__(self):
return f"{self.user} in {self.household} ({self.status})"
class PendingHouseholdInvite(models.Model):
household = models.ForeignKey(Household, on_delete=models.CASCADE, related_name='pending_invites')
invited_email = models.EmailField()
invited_by = models.ForeignKey(
settings.AUTH_USER_MODEL,
on_delete=models.SET_NULL,
null=True,
related_name='sent_pending_invitations',
)
effective_from_year = models.PositiveSmallIntegerField(null=True, blank=True)
created_at = models.DateTimeField(auto_now_add=True)
class Meta:
unique_together = ['household', 'invited_email']
def __str__(self):
return f"Pending invite for {self.invited_email} to {self.household}"
class FinancialYear(models.Model):
user = models.ForeignKey(
settings.AUTH_USER_MODEL,
on_delete=models.CASCADE,
related_name='financial_years',
null=True, blank=True,
)
household = models.ForeignKey(
Household,
on_delete=models.CASCADE,
related_name='financial_years',
null=True, blank=True,
)
year = models.PositiveSmallIntegerField()
is_active = models.BooleanField(default=True)
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:
constraints = [
models.CheckConstraint(
condition=(
models.Q(user__isnull=False, household__isnull=True) |
models.Q(user__isnull=True, household__isnull=False)
),
name='financial_year_owner_exclusive',
),
models.UniqueConstraint(
fields=['user', 'year'],
condition=models.Q(user__isnull=False),
name='unique_personal_financial_year',
),
models.UniqueConstraint(
fields=['household', 'year'],
condition=models.Q(household__isnull=False),
name='unique_household_financial_year',
),
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):
owner = self.user or self.household
return f"{owner} {self.year}"
class YearlyIncome(models.Model):
financial_year = models.ForeignKey(FinancialYear, on_delete=models.CASCADE, related_name='incomes')
member = models.ForeignKey(
settings.AUTH_USER_MODEL,
on_delete=models.SET_NULL,
null=True,
related_name='yearly_incomes',
)
name = models.CharField(max_length=100)
amount = models.DecimalField(max_digits=12, decimal_places=2)
active = models.BooleanField(default=True)
notes = models.TextField(blank=True, default='')
def __str__(self):
return f"{self.name}: CHF {self.amount} ({self.financial_year.year})"
class YearlyBudgetItem(models.Model):
financial_year = models.ForeignKey(FinancialYear, on_delete=models.CASCADE, related_name='budget_items')
name = models.CharField(max_length=100)
amount = models.DecimalField(max_digits=12, decimal_places=2)
active = models.BooleanField(default=True)
notes = models.TextField(blank=True, default='')
def __str__(self):
return f"{self.name}: CHF {self.amount} ({self.financial_year.year})"
return (f"V{self.versicherer_id} {self.kanton} R{self.region} "
f"{self.altersklasse} {self.tariftyp} {self.franchisestufe}{self.praemie} CHF")
+5 -90
View File
@@ -1,10 +1,6 @@
from rest_framework import serializers
from django.contrib.auth import get_user_model
from .models import (
Account, Transaction, Budget, Expense, Profile, Deadline,
Household, HouseholdMembership, PendingHouseholdInvite,
FinancialYear, YearlyIncome, YearlyBudgetItem,
)
from .models import Account, Transaction, Budget, Expense, Profile, Deadline, Insurance
User = get_user_model()
@@ -48,10 +44,6 @@ class ExpenseSerializer(serializers.ModelSerializer):
class ProfileSerializer(serializers.ModelSerializer):
totp_enabled = serializers.BooleanField(read_only=True)
email = serializers.SerializerMethodField()
def get_email(self, obj):
return obj.email or (obj.user.email if obj.user else '')
class Meta:
model = Profile
@@ -64,74 +56,10 @@ class DeadlineSerializer(serializers.ModelSerializer):
exclude = ['user']
class HouseholdMembershipSerializer(serializers.ModelSerializer):
user_email = serializers.EmailField(source='user.email', read_only=True)
invited_by_email = serializers.EmailField(source='invited_by.email', read_only=True)
class InsuranceSerializer(serializers.ModelSerializer):
class Meta:
model = HouseholdMembership
fields = ['id', 'user', 'user_email', 'invited_by_email', 'status', 'role',
'effective_from_year', 'effective_until_year', 'created_at']
read_only_fields = ['id', 'user', 'user_email', 'invited_by_email', 'created_at']
class PendingHouseholdInviteSerializer(serializers.ModelSerializer):
invited_by_email = serializers.EmailField(source='invited_by.email', read_only=True)
class Meta:
model = PendingHouseholdInvite
fields = ['id', 'invited_email', 'invited_by_email', 'effective_from_year', 'created_at']
read_only_fields = fields
class HouseholdSerializer(serializers.ModelSerializer):
memberships = HouseholdMembershipSerializer(many=True, read_only=True)
pending_invites = PendingHouseholdInviteSerializer(many=True, read_only=True)
created_by_email = serializers.EmailField(source='created_by.email', read_only=True)
class Meta:
model = Household
fields = ['id', 'name', 'created_by_email', 'memberships', 'pending_invites', 'created_at']
read_only_fields = ['id', 'created_by_email', 'memberships', 'pending_invites', 'created_at']
class YearlyIncomeSerializer(serializers.ModelSerializer):
member_email = serializers.EmailField(source='member.email', read_only=True)
class Meta:
model = YearlyIncome
fields = ['id', 'member', 'member_email', 'name', 'amount', 'active', 'notes']
read_only_fields = ['id', 'member_email']
class YearlyBudgetItemSerializer(serializers.ModelSerializer):
class Meta:
model = YearlyBudgetItem
fields = ['id', 'name', 'amount', 'active', 'notes']
read_only_fields = ['id']
class FinancialYearSerializer(serializers.ModelSerializer):
incomes = YearlyIncomeSerializer(many=True, read_only=True)
budget_items = YearlyBudgetItemSerializer(many=True, read_only=True)
total_income = serializers.SerializerMethodField()
total_fixed_costs = serializers.SerializerMethodField()
owner_type = serializers.SerializerMethodField()
class Meta:
model = FinancialYear
fields = ['id', 'year', 'is_active', 'notes', 'owner_type', 'household_id',
'total_income', 'total_fixed_costs', 'incomes', 'budget_items', 'created_at']
read_only_fields = ['id', 'created_at', 'owner_type', 'household_id', 'total_income', 'total_fixed_costs']
def get_total_income(self, obj):
return sum(i.amount for i in obj.incomes.filter(active=True))
def get_total_fixed_costs(self, obj):
return sum(b.amount for b in obj.budget_items.filter(active=True))
def get_owner_type(self, obj):
return 'household' if obj.household_id else 'personal'
model = Insurance
exclude = ['user']
class RegisterSerializer(serializers.Serializer):
@@ -145,21 +73,8 @@ class RegisterSerializer(serializers.Serializer):
def create(self, validated_data):
email = validated_data['email']
user = User.objects.create_user(
return User.objects.create_user(
username=email,
email=email,
password=validated_data['password'],
)
from .models import PendingHouseholdInvite, HouseholdMembership
for invite in PendingHouseholdInvite.objects.filter(invited_email__iexact=email):
HouseholdMembership.objects.get_or_create(
household=invite.household,
user=user,
defaults={
'invited_by': invite.invited_by,
'status': 'pending',
'effective_from_year': invite.effective_from_year,
},
)
invite.delete()
return user
+168 -460
View File
@@ -1,5 +1,4 @@
import base64
import datetime
import hmac
import hashlib
import json
@@ -16,23 +15,17 @@ from django.contrib.auth import get_user_model, authenticate
from django.http import HttpResponse
from icalendar import Calendar as iCalendar, Event as iCalEvent
from django.db import models
from rest_framework import viewsets, views, status
from rest_framework.response import Response
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 django.db import transaction as db_transaction
from .models import (
Account, Transaction, Budget, Expense, Profile, Deadline, ReadEvent, BackupCode, UserSession,
Household, HouseholdMembership, FinancialYear, YearlyIncome, YearlyBudgetItem,
)
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,
HouseholdSerializer, HouseholdMembershipSerializer,
FinancialYearSerializer, YearlyIncomeSerializer, YearlyBudgetItemSerializer,
InsuranceSerializer,
)
@@ -119,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)
@@ -1005,454 +1164,3 @@ class PasswordResetConfirmView(views.APIView):
for session in UserSession.objects.filter(user=user):
_blacklist_session(session)
return Response({'detail': 'Password updated.'})
# ── FinancialYear Helpers ─────────────────────────────────────────────────────
def _get_user_financial_year(user, year):
"""Return the FinancialYear for a given year accessible to this user."""
# Personal year
fy = FinancialYear.objects.filter(user=user, year=year).first()
if fy:
return fy
# Household year where user is an active member for this year
memberships = HouseholdMembership.objects.filter(
user=user,
status='active',
effective_from_year__lte=year,
).filter(
models.Q(effective_until_year__isnull=True) | models.Q(effective_until_year__gt=year)
)
household_ids = memberships.values_list('household_id', flat=True)
return FinancialYear.objects.filter(household_id__in=household_ids, year=year).first()
def _all_user_financial_years(user):
"""Return all FinancialYears accessible to this user."""
personal = FinancialYear.objects.filter(user=user)
memberships = HouseholdMembership.objects.filter(user=user, status='active')
household_ids = memberships.values_list('household_id', flat=True)
household = FinancialYear.objects.filter(household_id__in=household_ids)
return (personal | household).distinct().order_by('-year')
def _max_year_for_user(user):
"""Return the highest year the user currently has access to."""
years = _all_user_financial_years(user).values_list('year', flat=True)
return max(years) if years else None
# ── FinancialYear Views ───────────────────────────────────────────────────────
class FinancialYearListCreateView(views.APIView):
def get(self, request):
qs = _all_user_financial_years(request.user)
return Response(FinancialYearSerializer(qs, many=True).data)
def post(self, request):
year = request.data.get('year')
if not year:
return Response({'year': 'This field is required.'}, status=400)
try:
year = int(year)
except (TypeError, ValueError):
return Response({'year': 'Must be an integer.'}, status=400)
current_year = datetime.date.today().year
if year > current_year + 1:
return Response(
{'year': f'You can only create years up to {current_year + 1}.'},
status=400,
)
max_year = _max_year_for_user(request.user)
if max_year is not None and year != max_year + 1:
return Response(
{'year': f'You can only create the next year ({max_year + 1}).'},
status=400,
)
household_id = request.data.get('household_id')
if household_id:
household = Household.objects.filter(
id=household_id,
memberships__user=request.user,
memberships__status='active',
).first()
if not household:
return Response({'detail': 'Household not found or not a member.'}, status=404)
if FinancialYear.objects.filter(household=household, year=year).exists():
return Response({'year': 'This year already exists for this household.'}, status=400)
FinancialYear.objects.filter(household=household, is_active=True).update(is_active=False)
fy = FinancialYear.objects.create(household=household, year=year, is_active=True)
else:
if FinancialYear.objects.filter(user=request.user, year=year).exists():
return Response({'year': 'This year already exists.'}, status=400)
FinancialYear.objects.filter(user=request.user, is_active=True).update(is_active=False)
fy = FinancialYear.objects.create(user=request.user, year=year, is_active=True)
return Response(FinancialYearSerializer(fy).data, status=201)
class FinancialYearDetailView(views.APIView):
def _get_or_404(self, request, year):
fy = _get_user_financial_year(request.user, year)
if not fy:
return None
return fy
def get(self, request, year):
fy = self._get_or_404(request, year)
if not fy:
return Response({'detail': 'Not found.'}, status=404)
return Response(FinancialYearSerializer(fy).data)
def patch(self, request, year):
fy = self._get_or_404(request, year)
if not fy:
return Response({'detail': 'Not found.'}, status=404)
serializer = FinancialYearSerializer(fy, data=request.data, partial=True)
if serializer.is_valid():
serializer.save()
return Response(serializer.data)
return Response(serializer.errors, status=400)
def delete(self, request, year):
fy = self._get_or_404(request, year)
if not fy:
return Response({'detail': 'Not found.'}, status=404)
if not fy.is_active:
return Response({'detail': 'Archived years cannot be deleted.'}, status=400)
fy.delete()
return Response(status=204)
class FinancialYearCopyView(views.APIView):
def post(self, request, year, source_year):
source = _get_user_financial_year(request.user, source_year)
if not source:
return Response({'detail': f'Source year {source_year} not found.'}, status=404)
target = _get_user_financial_year(request.user, year)
if not target:
return Response({'detail': f'Target year {year} not found.'}, status=404)
if not target.is_active:
return Response({'detail': 'Target year is archived.'}, status=400)
with db_transaction.atomic():
incomes_copied = 0
for income in source.incomes.all():
YearlyIncome.objects.create(
financial_year=target,
member=income.member,
name=income.name,
amount=income.amount,
active=income.active,
notes=income.notes,
)
incomes_copied += 1
items_copied = 0
for item in source.budget_items.all():
YearlyBudgetItem.objects.create(
financial_year=target,
name=item.name,
amount=item.amount,
active=item.active,
notes=item.notes,
)
items_copied += 1
return Response({
'year': year,
'source_year': source_year,
'incomes_copied': incomes_copied,
'budget_items_copied': items_copied,
})
# ── YearlyIncome Views ────────────────────────────────────────────────────────
class YearlyIncomeListCreateView(views.APIView):
def _get_year_or_404(self, request, year):
fy = _get_user_financial_year(request.user, year)
return fy
def get(self, request, year):
fy = self._get_year_or_404(request, year)
if not fy:
return Response({'detail': 'Not found.'}, status=404)
return Response(YearlyIncomeSerializer(fy.incomes.all(), many=True).data)
def post(self, request, year):
fy = self._get_year_or_404(request, year)
if not fy:
return Response({'detail': 'Not found.'}, status=404)
if not fy.is_active:
return Response({'detail': 'Archived years are read-only.'}, status=403)
serializer = YearlyIncomeSerializer(data=request.data)
if serializer.is_valid():
serializer.save(financial_year=fy, member=request.user)
return Response(serializer.data, status=201)
return Response(serializer.errors, status=400)
class YearlyIncomeDetailView(views.APIView):
def _get_income_or_404(self, request, year, pk):
fy = _get_user_financial_year(request.user, year)
if not fy:
return None, None
income = fy.incomes.filter(pk=pk).first()
return fy, income
def patch(self, request, year, pk):
fy, income = self._get_income_or_404(request, year, pk)
if not income:
return Response({'detail': 'Not found.'}, status=404)
serializer = YearlyIncomeSerializer(income, data=request.data, partial=True)
if serializer.is_valid():
serializer.save()
return Response(serializer.data)
return Response(serializer.errors, status=400)
def delete(self, request, year, pk):
fy, income = self._get_income_or_404(request, year, pk)
if not income:
return Response({'detail': 'Not found.'}, status=404)
income.delete()
return Response(status=204)
# ── YearlyBudgetItem Views ────────────────────────────────────────────────────
class YearlyBudgetItemListCreateView(views.APIView):
def _get_year_or_404(self, request, year):
return _get_user_financial_year(request.user, year)
def get(self, request, year):
fy = self._get_year_or_404(request, year)
if not fy:
return Response({'detail': 'Not found.'}, status=404)
return Response(YearlyBudgetItemSerializer(fy.budget_items.all(), many=True).data)
def post(self, request, year):
fy = self._get_year_or_404(request, year)
if not fy:
return Response({'detail': 'Not found.'}, status=404)
serializer = YearlyBudgetItemSerializer(data=request.data)
if serializer.is_valid():
serializer.save(financial_year=fy)
return Response(serializer.data, status=201)
return Response(serializer.errors, status=400)
class YearlyBudgetItemDetailView(views.APIView):
def _get_item_or_404(self, request, year, pk):
fy = _get_user_financial_year(request.user, year)
if not fy:
return None, None
item = fy.budget_items.filter(pk=pk).first()
return fy, item
def patch(self, request, year, pk):
fy, item = self._get_item_or_404(request, year, pk)
if not item:
return Response({'detail': 'Not found.'}, status=404)
serializer = YearlyBudgetItemSerializer(item, data=request.data, partial=True)
if serializer.is_valid():
serializer.save()
return Response(serializer.data)
return Response(serializer.errors, status=400)
def delete(self, request, year, pk):
fy, item = self._get_item_or_404(request, year, pk)
if not item:
return Response({'detail': 'Not found.'}, status=404)
item.delete()
return Response(status=204)
# ── Household Views ───────────────────────────────────────────────────────────
class HouseholdListCreateView(views.APIView):
def get(self, request):
memberships = HouseholdMembership.objects.filter(user=request.user, status__in=['active', 'pending'])
household_ids = memberships.values_list('household_id', flat=True)
created = Household.objects.filter(created_by=request.user)
qs = (Household.objects.filter(id__in=household_ids) | created).distinct()
return Response(HouseholdSerializer(qs, many=True).data)
def post(self, request):
serializer = HouseholdSerializer(data=request.data)
if serializer.is_valid():
household = serializer.save(created_by=request.user)
# Creator is automatically an active member
next_year = (datetime.date.today().year + 1)
HouseholdMembership.objects.create(
household=household,
user=request.user,
invited_by=request.user,
status='active',
role='admin',
effective_from_year=next_year,
)
return Response(HouseholdSerializer(household).data, status=201)
return Response(serializer.errors, status=400)
class HouseholdInviteView(views.APIView):
def post(self, request, pk):
household = Household.objects.filter(pk=pk).first()
if not household:
return Response({'detail': 'Not found.'}, status=404)
# Only founder or active admins can invite
is_founder = household.created_by == request.user
is_admin = HouseholdMembership.objects.filter(
household=household, user=request.user, status='active', role='admin'
).exists()
if not (is_founder or is_admin):
return Response({'detail': 'Only admins can invite members.'}, status=403)
email = request.data.get('email', '').strip().lower()
User = get_user_model()
invitee = User.objects.filter(email__iexact=email).first()
from django.conf import settings
from .email import send_email
from .models import PendingHouseholdInvite
next_year = datetime.date.today().year + 1
inviter_name = request.user.get_full_name() or request.user.email
if not invitee:
if PendingHouseholdInvite.objects.filter(household=household, invited_email__iexact=email).exists():
return Response({'detail': 'Invitation already sent to this email.'}, status=400)
PendingHouseholdInvite.objects.create(
household=household,
invited_by=request.user,
invited_email=email,
effective_from_year=next_year,
)
register_url = f"{settings.FRONTEND_URL}/register"
send_email(
template_name='household_invite',
subject=f'Einladung zum Haushalt «{household.name}»',
context={
'invitee_name': email,
'inviter_name': inviter_name,
'household_name': household.name,
'accept_url': register_url,
'cta_label': 'Konto erstellen & beitreten',
},
to=email,
)
return Response({'detail': f'Registration invitation sent to {email}.'})
if invitee == request.user:
return Response({'detail': 'You cannot invite yourself.'}, status=400)
if HouseholdMembership.objects.filter(household=household, user=invitee, status__in=['pending', 'active']).exists():
return Response({'detail': 'User is already a member or has a pending invitation.'}, status=400)
HouseholdMembership.objects.create(
household=household,
user=invitee,
invited_by=request.user,
status='pending',
effective_from_year=next_year,
)
invitee_name = invitee.get_full_name() or invitee.email
send_email(
template_name='household_invite',
subject=f'Einladung zum Haushalt «{household.name}»',
context={
'invitee_name': invitee_name,
'inviter_name': inviter_name,
'household_name': household.name,
'accept_url': f"{settings.FRONTEND_URL}/financial-year",
'cta_label': 'Einladung annehmen',
},
to=invitee.email,
)
return Response({'detail': f'Invitation sent to {email} for year {next_year}.'})
class HouseholdAcceptView(views.APIView):
def post(self, request, pk):
membership = HouseholdMembership.objects.filter(
household_id=pk, user=request.user, status='pending'
).first()
if not membership:
return Response({'detail': 'No pending invitation found.'}, status=404)
membership.status = 'active'
membership.save(update_fields=['status'])
return Response({'detail': 'Invitation accepted.'})
class HouseholdLeaveView(views.APIView):
def post(self, request, pk):
membership = HouseholdMembership.objects.filter(
household_id=pk, user=request.user, status='active'
).first()
if not membership:
return Response({'detail': 'You are not an active member of this household.'}, status=404)
next_year = datetime.date.today().year + 1
membership.status = 'left'
membership.effective_until_year = next_year
membership.save(update_fields=['status', 'effective_until_year'])
return Response({'detail': f'You will leave this household at the end of {next_year - 1}.'})
class HouseholdSetRoleView(views.APIView):
def post(self, request, pk, membership_id):
# Only the founder can assign roles
household = Household.objects.filter(pk=pk, created_by=request.user).first()
if not household:
return Response({'detail': 'Not found or not owner.'}, status=404)
membership = HouseholdMembership.objects.filter(
pk=membership_id, household=household, status='active'
).first()
if not membership:
return Response({'detail': 'Active membership not found.'}, status=404)
if membership.user == request.user:
return Response({'detail': 'Cannot change your own role.'}, status=400)
role = request.data.get('role')
if role not in ['member', 'admin']:
return Response({'detail': 'Role must be "member" or "admin".'}, status=400)
membership.role = role
membership.save(update_fields=['role'])
return Response(HouseholdMembershipSerializer(membership).data)
class HouseholdRevenueAccountsView(views.APIView):
def get(self, request, pk):
membership = HouseholdMembership.objects.filter(
household_id=pk, user=request.user, status='active'
).first()
if not membership:
return Response({'detail': 'Not a member of this household.'}, status=403)
member_users = HouseholdMembership.objects.filter(
household_id=pk, status='active'
).values_list('user_id', flat=True)
accounts = Account.objects.filter(
user_id__in=member_users, account_type='revenue', active=True
).select_related('user')
data = [
{
'id': a.id,
'name': a.name,
'balance': str(a.balance),
'salary_months': a.salary_months,
'owner_email': a.user.email,
'is_mine': a.user_id == request.user.id,
}
for a in accounts
]
return Response(data)
@@ -1,34 +0,0 @@
{% extends "emails/base.html" %}
{% block subject %}Armarium Einladung zum Haushalt{% endblock %}
{% block body %}
<p style="margin:0 0 20px;font-size:15px;color:#374151;line-height:1.6;">Hallo {{ invitee_name }},</p>
<p style="margin:0 0 20px;font-size:15px;color:#374151;line-height:1.6;">
<strong>{{ inviter_name }}</strong> hat dich eingeladen, dem Haushalt
<strong>{{ household_name }}</strong> auf Armarium beizutreten.
</p>
<table role="presentation" width="100%" cellpadding="0" cellspacing="0" style="margin:0 0 28px;">
<tr>
<td align="center">
<a href="{{ accept_url }}"
style="display:inline-block;background-color:#7c3aed;color:#ffffff;font-size:15px;font-weight:600;text-decoration:none;padding:14px 32px;border-radius:8px;letter-spacing:0.1px;">
{{ cta_label }}
</a>
</td>
</tr>
</table>
<p style="margin:0 0 8px;font-size:13px;color:#6b7280;line-height:1.6;">
Falls der Button nicht funktioniert, kopiere diesen Link in deinen Browser:
</p>
<p style="margin:0 0 24px;font-size:13px;color:#7c3aed;line-height:1.6;word-break:break-all;">
{{ accept_url }}
</p>
<p style="margin:0;font-size:15px;color:#374151;line-height:1.6;">
Das Armarium-Team
</p>
{% endblock %}
@@ -1,10 +0,0 @@
Hallo {{ invitee_name }},
{{ inviter_name }} hat dich eingeladen, dem Haushalt "{{ household_name }}" auf Armarium beizutreten.
{{ cta_label }}:
{{ accept_url }}
Falls du diese Einladung nicht erwartet hast, kannst du sie ignorieren.
Das Armarium-Team
+8 -2
View File
@@ -14,7 +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 { FinancialYearComponent } from './financial-year/financial-year';
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 },
@@ -35,7 +38,10 @@ export const routes: Routes = [
{ path: 'profile', component: Profile },
{ path: 'settings', component: Settings },
{ path: 'calendar', component: Calendar },
{ path: 'financial-year', component: FinancialYearComponent },
{ path: 'insurance', component: InsuranceOverview },
{ path: 'insurance-documents', component: InsuranceDocuments },
{ path: 'insurance-analyse', component: InsuranceAnalyse },
{ path: 'insurance-priminfo', component: Priminfo },
],
},
{ path: '**', redirectTo: 'dashboard' },
+14 -26
View File
@@ -2,7 +2,6 @@ import { Component, OnInit, OnDestroy, AfterViewInit, signal } from '@angular/co
import { CommonModule } from '@angular/common';
import { TranslateModule, TranslateService } from '@ngx-translate/core';
import { ApiService } from '../services/api';
import { FinancialYearService, FinancialYear } from '../services/financial-year';
import ApexCharts from 'apexcharts';
import { Subscription } from 'rxjs';
@@ -14,11 +13,10 @@ import { Subscription } from 'rxjs';
styleUrl: './dashboard.css',
})
export class Dashboard implements OnInit, AfterViewInit, OnDestroy {
financialYears = signal<FinancialYear[]>([]);
accounts = signal<any[]>([]);
budgets = signal<any[]>([]);
expenses = signal<any[]>([]);
transactions = signal<any[]>([]);
budgets = signal<any[]>([]);
accounts = signal<any[]>([]);
donutExpanded = signal(false);
selectedYear = signal(new Date().getFullYear());
yearDropdownOpen = signal(false);
@@ -34,18 +32,17 @@ export class Dashboard implements OnInit, AfterViewInit, OnDestroy {
private barChart?: ApexCharts;
private donutChart?: ApexCharts;
private dataLoaded = 0;
private readonly totalRequests = 5;
private readonly totalRequests = 4;
private timeInterval?: ReturnType<typeof setInterval>;
private langSub?: Subscription;
constructor(private api: ApiService, private fy: FinancialYearService, private translate: TranslateService) {}
constructor(private api: ApiService, private translate: TranslateService) {}
ngOnInit(): void {
this.fy.list().subscribe({ next: (d) => { this.financialYears.set(d); this.onDataLoaded(); } });
this.api.getAccounts().subscribe({ next: (d) => { this.accounts.set(d); this.onDataLoaded(); } });
this.api.getBudgets().subscribe({ next: (d) => { this.budgets.set(d); this.onDataLoaded(); } });
this.api.getExpenses().subscribe({ next: (d) => { this.expenses.set(d); this.onDataLoaded(); } });
this.api.getTransactions().subscribe({ next: (d) => { this.transactions.set(d); this.onDataLoaded(); } });
this.api.getBudgets().subscribe({ next: (d) => { this.budgets.set(d); this.onDataLoaded(); } });
this.api.getAccounts().subscribe({ next: (d) => { this.accounts.set(d); this.onDataLoaded(); } });
this.api.getProfile().subscribe({
next: (p) => {
@@ -103,10 +100,6 @@ export class Dashboard implements OnInit, AfterViewInit, OnDestroy {
this.dateTimeDisplay.set(`${weekday}, ${date} | ${time}`);
}
private financialYearFor(year: number): FinancialYear | undefined {
return this.financialYears().find((fy) => fy.year === year);
}
// KPIs
totalIncome(): number {
return this.accounts()
@@ -121,10 +114,7 @@ export class Dashboard implements OnInit, AfterViewInit, OnDestroy {
}
totalExpenses(): number {
const year = this.selectedYear();
return this.expenses()
.filter((e) => new Date(e.date).getFullYear() === year)
.reduce((sum, e) => sum + parseFloat(e.amount), 0);
return this.expenses().reduce((sum, e) => sum + parseFloat(e.amount), 0);
}
balance(): number {
@@ -172,23 +162,21 @@ export class Dashboard implements OnInit, AfterViewInit, OnDestroy {
this.selectedYear.set(year);
this.yearDropdownOpen.set(false);
this.renderBarChart();
this.renderDonutChart();
}
availableYears(): number[] {
const years = new Set<number>([new Date().getFullYear()]);
this.financialYears().forEach((fy) => years.add(fy.year));
this.expenses().forEach((e) => years.add(new Date(e.date).getFullYear()));
this.expenses().forEach(e => years.add(new Date(e.date).getFullYear()));
return Array.from(years).sort((a, b) => b - a);
}
donutItems(): { name: string; amount: number; pct: string; color: string }[] {
const items = this.budgets().filter((b) => b.active);
const total = items.reduce((sum, b) => sum + +b.amount, 0);
return items.map((b, i) => ({
const active = this.budgets().filter((b) => b.active);
const total = active.reduce((sum, b) => sum + parseFloat(b.amount), 0);
return active.map((b, i) => ({
name: b.name,
amount: +b.amount,
pct: total > 0 ? ((+b.amount / total) * 100).toFixed(1) : '0',
amount: parseFloat(b.amount),
pct: total > 0 ? ((parseFloat(b.amount) / total) * 100).toFixed(1) : '0',
color: this.donutColors[i % this.donutColors.length],
}));
}
@@ -316,7 +304,7 @@ export class Dashboard implements OnInit, AfterViewInit, OnDestroy {
const active = this.budgets().filter((b) => b.active);
const labels = active.map((b) => b.name);
const series = active.map((b) => +b.amount);
const series = active.map((b) => parseFloat(b.amount));
if (series.length === 0) return;
@@ -1,671 +0,0 @@
<!-- Backdrop for year dropdown -->
@if (yearDropdownOpen()) {
<div class="fixed inset-0 z-20" (click)="yearDropdownOpen.set(false)"></div>
}
<div class="p-4 sm:p-6 max-w-4xl mx-auto space-y-5">
<!-- Page header -->
<div class="flex flex-col sm:flex-row sm:items-center sm:justify-between gap-3">
<div>
<h1 class="text-xl font-semibold text-gray-900 dark:text-white">
{{ 'financial_year.title' | translate }}
</h1>
@if (currentFY()) {
<p class="text-sm text-gray-500 dark:text-gray-400 mt-0.5">
{{ currentFY()!.owner_type === 'household'
? ('financial_year.owner_household' | translate)
: ('financial_year.owner_personal' | translate) }}
</p>
}
</div>
<div class="flex items-center gap-2">
<!-- Year dropdown -->
@if (years().length > 0) {
<div class="relative z-30">
<button type="button" (click)="yearDropdownOpen.set(!yearDropdownOpen())"
class="flex items-center gap-2 px-3 py-2 text-sm font-medium text-gray-700 dark:text-gray-200 bg-white dark:bg-gray-800 border border-gray-300 dark:border-gray-600 rounded-lg hover:bg-gray-50 dark:hover:bg-gray-700">
<span>{{ selectedYear() }}</span>
<svg class="w-4 h-4 transition-transform" [class.rotate-180]="yearDropdownOpen()" 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"/>
</svg>
</button>
@if (yearDropdownOpen()) {
<div class="absolute right-0 top-full mt-1 w-28 bg-white dark:bg-gray-800 border border-gray-200 dark:border-gray-700 rounded-lg shadow-lg py-1">
@for (y of years(); track y.year) {
<button type="button" (click)="selectYear(y.year)"
class="w-full flex items-center justify-between px-3 py-1.5 text-sm hover:bg-gray-100 dark:hover:bg-gray-700"
[class.text-violet-700]="y.year === selectedYear()"
[class.dark:text-violet-400]="y.year === selectedYear()"
[class.font-semibold]="y.year === selectedYear()"
[class.text-gray-700]="y.year !== selectedYear()"
[class.dark:text-gray-200]="y.year !== selectedYear()">
{{ y.year }}
@if (y.is_active) {
<span class="w-1.5 h-1.5 rounded-full bg-violet-500"></span>
}
</button>
}
</div>
}
</div>
}
<!-- New year button -->
@if (canCreateNewYear()) {
<button type="button" (click)="openNewYearModal()"
class="flex items-center gap-1.5 px-3 py-2 text-sm font-medium text-white bg-violet-700 rounded-lg hover:bg-violet-800 focus:ring-2 focus:ring-violet-300 dark:focus:ring-violet-800">
<svg class="w-4 h-4" fill="currentColor" viewBox="0 0 20 20">
<path fill-rule="evenodd" d="M10 3a1 1 0 011 1v5h5a1 1 0 110 2h-5v5a1 1 0 11-2 0v-5H4a1 1 0 110-2h5V4a1 1 0 011-1z" clip-rule="evenodd"/>
</svg>
{{ 'financial_year.new_year' | translate }}
</button>
}
</div>
</div>
<!-- Loading spinner -->
@if (loading()) {
<div class="flex justify-center py-16">
<svg class="animate-spin w-6 h-6 text-violet-600" 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>
</div>
}
<!-- Empty state: no years at all -->
@if (!loading() && years().length === 0) {
<div class="text-center py-16">
<svg class="mx-auto w-12 h-12 text-gray-300 dark:text-gray-600 mb-4" fill="currentColor" viewBox="0 0 20 20">
<path d="M2 10a1 1 0 011-1h2a1 1 0 011 1v5a1 1 0 01-1 1H3a1 1 0 01-1-1v-5zM8 6a1 1 0 011-1h2a1 1 0 011 1v9a1 1 0 01-1 1H9a1 1 0 01-1-1V6zM14 4a1 1 0 011-1h2a1 1 0 011 1v11a1 1 0 01-1 1h-2a1 1 0 01-1-1V4z"/>
</svg>
<p class="text-gray-500 dark:text-gray-400 mb-4">{{ 'financial_year.no_years' | translate }}</p>
<button type="button" (click)="openNewYearModal()"
class="px-4 py-2 text-sm font-medium text-white bg-violet-700 rounded-lg hover:bg-violet-800">
{{ 'financial_year.start_first_year' | translate }}
</button>
</div>
}
<!-- Main content -->
@if (!loading() && currentFY()) {
<!-- Summary cards -->
<div class="grid grid-cols-1 sm:grid-cols-3 gap-4">
<div class="bg-white dark:bg-gray-800 rounded-xl border border-gray-200 dark:border-gray-700 p-4">
<p class="text-xs font-medium text-gray-500 dark:text-gray-400 uppercase tracking-wide">
{{ 'financial_year.total_income' | translate }}
</p>
<p class="mt-1.5 text-2xl font-bold text-gray-900 dark:text-white">
CHF {{ formatChf(totalAnnualIncome()) }}
</p>
<p class="text-xs text-gray-400 dark:text-gray-500 mt-0.5">
CHF {{ formatChf(totalAnnualIncome() / 12) }} / {{ 'financial_year.per_month' | translate }}
</p>
</div>
<div class="bg-white dark:bg-gray-800 rounded-xl border border-gray-200 dark:border-gray-700 p-4">
<p class="text-xs font-medium text-gray-500 dark:text-gray-400 uppercase tracking-wide">
{{ 'financial_year.total_fixed_costs' | translate }}
</p>
<p class="mt-1.5 text-2xl font-bold text-gray-900 dark:text-white">
CHF {{ formatChf(totalAnnualBudget()) }}
</p>
<p class="text-xs text-gray-400 dark:text-gray-500 mt-0.5">
CHF {{ formatChf(totalMonthlyBudget()) }} / {{ 'financial_year.per_month' | translate }}
</p>
</div>
<div class="bg-white dark:bg-gray-800 rounded-xl border border-gray-200 dark:border-gray-700 p-4">
<p class="text-xs font-medium text-gray-500 dark:text-gray-400 uppercase tracking-wide">
{{ 'financial_year.total_expenses_year' | translate }} {{ selectedYear() }}
</p>
<p class="mt-1.5 text-2xl font-bold text-gray-900 dark:text-white">
CHF {{ formatChf(totalYearExpenses()) }}
</p>
<p class="text-xs text-gray-400 dark:text-gray-500 mt-0.5">
Ø CHF {{ formatChf(avgMonthlyExpenses()) }} / {{ 'financial_year.per_month' | translate }}
</p>
</div>
</div>
<!-- Tabs + item list -->
<div class="bg-white dark:bg-gray-800 rounded-xl border border-gray-200 dark:border-gray-700 overflow-hidden">
<!-- Tab navigation -->
<div class="border-b border-gray-200 dark:border-gray-700 px-4">
<nav class="flex gap-0 -mb-px">
<button type="button" (click)="selectTab('incomes')"
class="px-4 py-3 text-sm font-medium border-b-2 transition-colors"
[class.border-violet-700]="activeTab() === 'incomes'"
[class.text-violet-700]="activeTab() === 'incomes'"
[class.dark:text-violet-400]="activeTab() === 'incomes'"
[class.border-transparent]="activeTab() !== 'incomes'"
[class.text-gray-500]="activeTab() !== 'incomes'"
[class.dark:text-gray-400]="activeTab() !== 'incomes'"
[class.hover:text-gray-700]="activeTab() !== 'incomes'"
[class.dark:hover:text-gray-200]="activeTab() !== 'incomes'">
{{ 'financial_year.tab_incomes' | translate }}
<span class="ml-1.5 text-xs bg-gray-100 dark:bg-gray-700 text-gray-600 dark:text-gray-300 rounded-full px-1.5 py-0.5">
{{ revenueAccounts().length }}
</span>
</button>
<button type="button" (click)="selectTab('budget_items')"
class="px-4 py-3 text-sm font-medium border-b-2 transition-colors"
[class.border-violet-700]="activeTab() === 'budget_items'"
[class.text-violet-700]="activeTab() === 'budget_items'"
[class.dark:text-violet-400]="activeTab() === 'budget_items'"
[class.border-transparent]="activeTab() !== 'budget_items'"
[class.text-gray-500]="activeTab() !== 'budget_items'"
[class.dark:text-gray-400]="activeTab() !== 'budget_items'"
[class.hover:text-gray-700]="activeTab() !== 'budget_items'"
[class.dark:hover:text-gray-200]="activeTab() !== 'budget_items'">
{{ 'financial_year.tab_budget_items' | translate }}
<span class="ml-1.5 text-xs bg-gray-100 dark:bg-gray-700 text-gray-600 dark:text-gray-300 rounded-full px-1.5 py-0.5">
{{ budgetItems().length }}
</span>
</button>
</nav>
</div>
<!-- Item list: Incomes (from Revenue Accounts) -->
@if (activeTab() === 'incomes') {
@if (revenueAccounts().length === 0) {
<p class="text-sm text-gray-400 dark:text-gray-500 text-center py-10">
{{ 'financial_year.no_revenue_accounts' | translate }}
</p>
}
@for (account of revenueAccounts(); track account.id) {
<div class="flex items-center gap-3 px-4 py-3 hover:bg-gray-50 dark:hover:bg-gray-700/40 border-b border-gray-100 dark:border-gray-700/50 last:border-b-0">
<div class="flex-1 min-w-0">
<p class="text-sm font-medium text-gray-900 dark:text-white truncate">{{ account.name }}</p>
<p class="text-xs text-gray-400 dark:text-gray-500">
CHF {{ formatChf(account.balance) }}/Mt.
@if (account.owner_email && !account.is_mine) {
· <span class="italic">{{ account.owner_email }}</span>
}
</p>
</div>
<div class="text-right shrink-0">
<p class="text-sm font-semibold text-gray-900 dark:text-white">
CHF {{ formatChf(account.balance * (account.salary_months ?? 12)) }}
</p>
<p class="text-xs text-gray-400 dark:text-gray-500">
{{ 'financial_year.annual_label' | translate }}
</p>
</div>
<button type="button" (click)="toggleSalaryMonths(account)"
title="{{ 'financial_year.toggle_salary_months' | translate }}"
class="shrink-0 min-w-[2.5rem] text-xs font-semibold rounded-full px-2.5 py-1 transition-colors"
[class.bg-violet-100]="(account.salary_months ?? 12) === 13"
[class.text-violet-700]="(account.salary_months ?? 12) === 13"
[class.dark:bg-violet-900/30]="(account.salary_months ?? 12) === 13"
[class.dark:text-violet-400]="(account.salary_months ?? 12) === 13"
[class.bg-gray-100]="(account.salary_months ?? 12) === 12"
[class.text-gray-600]="(account.salary_months ?? 12) === 12"
[class.dark:bg-gray-700]="(account.salary_months ?? 12) === 12"
[class.dark:text-gray-300]="(account.salary_months ?? 12) === 12">
{{ account.salary_months ?? 12 }} Mt.
</button>
</div>
}
@if (revenueAccounts().length > 0) {
<div class="flex items-center justify-between px-4 py-3 bg-gray-50 dark:bg-gray-700/30 border-t border-gray-200 dark:border-gray-700">
<span class="text-sm font-medium text-gray-700 dark:text-gray-300">{{ 'financial_year.total_annual_income' | translate }}</span>
<span class="text-sm font-bold text-gray-900 dark:text-white">CHF {{ formatChf(totalAnnualIncome()) }}</span>
</div>
}
}
<!-- Item list: Budget items -->
@if (activeTab() === 'budget_items') {
@if (budgetItems().length === 0 && !showForm()) {
<p class="text-sm text-gray-400 dark:text-gray-500 text-center py-10">
{{ 'financial_year.no_budget_items' | translate }}
</p>
}
@for (item of budgetItems(); track item.id) {
<div class="flex items-center gap-3 px-4 py-3 hover:bg-gray-50 dark:hover:bg-gray-700/40 border-b border-gray-100 dark:border-gray-700/50 last:border-b-0">
<div class="flex-1 min-w-0">
<p class="text-sm font-medium text-gray-900 dark:text-white truncate">{{ item.name }}</p>
@if (item.notes) {
<p class="text-xs text-gray-400 dark:text-gray-500 truncate">{{ item.notes }}</p>
}
</div>
<div class="text-right shrink-0">
<p class="text-sm font-semibold text-gray-900 dark:text-white">CHF {{ formatChf(item.amount) }}</p>
<p class="text-xs text-gray-400 dark:text-gray-500">CHF {{ formatChf(perMonth(item.amount)) }}/Mt.</p>
</div>
@if (!item.active) {
<span class="shrink-0 text-xs font-medium bg-gray-100 dark:bg-gray-700 text-gray-500 dark:text-gray-400 rounded-full px-2 py-0.5">Inaktiv</span>
}
<div class="flex items-center gap-0.5 shrink-0">
<button type="button" (click)="openEditForm(item)"
title="{{ 'common.edit' | translate }}"
class="p-1.5 text-gray-400 hover:text-violet-700 dark:hover:text-violet-400 rounded-md hover:bg-violet-50 dark:hover:bg-violet-900/20 transition-colors">
<svg class="w-4 h-4" aria-hidden="true" xmlns="http://www.w3.org/2000/svg" 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.844 6.844L8 14l.713-3.565 6.844-6.844a2.015 2.015 0 0 1 2.852 0Z"/>
</svg>
</button>
<button type="button" (click)="confirmDelete(item.id)"
title="{{ 'common.delete' | translate }}"
class="p-1.5 text-gray-400 hover:text-red-600 dark:hover:text-red-400 rounded-md hover:bg-red-50 dark:hover:bg-red-900/20 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>
}
}
<!-- Inline add/edit form (budget_items tab only) -->
@if (showForm() && activeTab() !== 'incomes') {
<div class="px-4 py-4 bg-gray-50 dark:bg-gray-700/30 border-t border-gray-200 dark:border-gray-700">
<h3 class="text-sm font-semibold text-gray-900 dark:text-white mb-3">
{{ editingId ? ('common.edit' | translate) : ('common.add' | translate) }}
</h3>
<div class="grid grid-cols-1 sm:grid-cols-2 gap-3">
<div>
<label class="block text-xs font-medium text-gray-700 dark:text-gray-300 mb-1">
{{ 'financial_year.label_name' | translate }}
</label>
<input type="text" [(ngModel)]="formName"
[placeholder]="activeTab() === 'incomes' ? 'z.B. Lohn' : 'z.B. Miete'"
class="w-full px-3 py-2 text-sm bg-white dark:bg-gray-800 border border-gray-300 dark:border-gray-600 rounded-lg focus:ring-2 focus:ring-violet-500 focus:border-violet-500 text-gray-900 dark:text-white placeholder-gray-400">
</div>
<div>
<label class="block text-xs font-medium text-gray-700 dark:text-gray-300 mb-1">
{{ 'financial_year.label_amount' | translate }}
</label>
<input type="number" [(ngModel)]="formAmount" min="0" step="100"
class="w-full px-3 py-2 text-sm bg-white dark:bg-gray-800 border border-gray-300 dark:border-gray-600 rounded-lg focus:ring-2 focus:ring-violet-500 focus:border-violet-500 text-gray-900 dark:text-white">
</div>
<div>
<label class="block text-xs font-medium text-gray-700 dark:text-gray-300 mb-1">
{{ 'financial_year.label_notes' | translate }}
<span class="text-gray-400 font-normal">({{ 'common.optional' | translate }})</span>
</label>
<input type="text" [(ngModel)]="formNotes"
class="w-full px-3 py-2 text-sm bg-white dark:bg-gray-800 border border-gray-300 dark:border-gray-600 rounded-lg focus:ring-2 focus:ring-violet-500 focus:border-violet-500 text-gray-900 dark:text-white placeholder-gray-400">
</div>
<div class="flex items-end pb-0.5">
<label class="flex items-center gap-2 cursor-pointer">
<input type="checkbox" [(ngModel)]="formActive"
class="w-4 h-4 rounded border-gray-300 dark:border-gray-600 text-violet-600 focus:ring-violet-500 bg-white dark:bg-gray-700">
<span class="text-sm text-gray-700 dark:text-gray-300">
{{ 'financial_year.label_active' | translate }}
</span>
</label>
</div>
</div>
@if (formError) {
<p class="mt-2 text-xs text-red-600 dark:text-red-400">
{{ 'financial_year.error_' + formError | translate }}
</p>
}
<div class="flex items-center gap-2 mt-3">
<button type="button" (click)="saveForm()" [disabled]="formSaving"
class="px-4 py-2 text-sm font-medium text-white bg-violet-700 rounded-lg hover:bg-violet-800 disabled:opacity-60 disabled:cursor-not-allowed">
{{ formSaving ? '…' : ('common.save' | translate) }}
</button>
<button type="button" (click)="closeForm()"
class="px-4 py-2 text-sm font-medium text-gray-700 dark:text-gray-200 bg-white dark:bg-gray-800 border border-gray-300 dark:border-gray-600 rounded-lg hover:bg-gray-50 dark:hover:bg-gray-700">
{{ 'common.cancel' | translate }}
</button>
</div>
</div>
}
<!-- Footer add button (budget_items tab only) -->
@if (!showForm() && activeTab() !== 'incomes') {
<div class="px-4 py-3 border-t border-gray-100 dark:border-gray-700/50">
<button type="button" (click)="openAddForm()"
class="flex items-center gap-1.5 text-sm font-medium text-violet-700 dark:text-violet-400 hover:text-violet-900 dark:hover:text-violet-300 transition-colors">
<svg class="w-4 h-4" fill="currentColor" viewBox="0 0 20 20">
<path fill-rule="evenodd" d="M10 3a1 1 0 011 1v5h5a1 1 0 110 2h-5v5a1 1 0 11-2 0v-5H4a1 1 0 110-2h5V4a1 1 0 011-1z" clip-rule="evenodd"/>
</svg>
{{ activeTab() === 'incomes'
? ('financial_year.add_income' | translate)
: ('financial_year.add_budget_item' | translate) }}
</button>
</div>
}
</div>
}
<!-- Household Section -->
<div class="bg-white dark:bg-gray-800 rounded-xl border border-gray-200 dark:border-gray-700 overflow-hidden">
<!-- Header -->
<div class="flex items-center gap-2 px-4 py-3 border-b border-gray-200 dark:border-gray-700">
<svg class="w-5 h-5 text-violet-700 dark:text-violet-400 flex-shrink-0" fill="currentColor" viewBox="0 0 20 20">
<path d="M10.707 2.293a1 1 0 00-1.414 0l-7 7a1 1 0 001.414 1.414L4 10.414V17a1 1 0 001 1h4a1 1 0 001-1v-3h2v3a1 1 0 001 1h4a1 1 0 001-1v-6.586l.293.293a1 1 0 001.414-1.414l-7-7z"/>
</svg>
<h2 class="text-sm font-semibold text-gray-900 dark:text-white">{{ 'household.title' | translate }}</h2>
</div>
<!-- No household at all -->
@if (households().length === 0) {
<div class="px-4 py-8 text-center">
<p class="text-sm text-gray-500 dark:text-gray-400 mb-1">{{ 'household.none' | translate }}</p>
<p class="text-xs text-gray-400 dark:text-gray-500 mb-4">{{ 'household.none_hint' | translate }}</p>
@if (!showCreateHouseholdForm()) {
<button type="button" (click)="showCreateHouseholdForm.set(true)"
class="px-4 py-2 text-sm font-medium text-white bg-violet-700 rounded-lg hover:bg-violet-800">
{{ 'household.create' | translate }}
</button>
}
@if (showCreateHouseholdForm()) {
<div class="mt-4 max-w-sm mx-auto text-left">
<label class="block text-xs font-medium text-gray-700 dark:text-gray-300 mb-1">
{{ 'household.label_name' | translate }}
</label>
<input type="text" [(ngModel)]="householdName"
[placeholder]="'household.placeholder_name' | translate"
class="w-full px-3 py-2 text-sm bg-white dark:bg-gray-800 border border-gray-300 dark:border-gray-600 rounded-lg focus:ring-2 focus:ring-violet-500 focus:border-violet-500 text-gray-900 dark:text-white placeholder-gray-400">
@if (householdError) {
<p class="mt-1 text-xs text-red-600 dark:text-red-400">
{{ 'household.error_' + householdError | translate }}
</p>
}
<div class="flex gap-2 mt-2">
<button type="button" (click)="createHousehold()" [disabled]="householdSaving"
class="px-4 py-2 text-sm font-medium text-white bg-violet-700 rounded-lg hover:bg-violet-800 disabled:opacity-60">
{{ householdSaving ? '…' : ('common.create' | translate) }}
</button>
<button type="button" (click)="showCreateHouseholdForm.set(false); householdName = ''; householdError = ''"
class="px-4 py-2 text-sm font-medium text-gray-700 dark:text-gray-200 bg-white dark:bg-gray-800 border border-gray-300 dark:border-gray-600 rounded-lg hover:bg-gray-50 dark:hover:bg-gray-700">
{{ 'common.cancel' | translate }}
</button>
</div>
</div>
}
</div>
}
<!-- Household list -->
@for (h of households(); track h.id) {
@let myM = myMembership(h);
@let amFounder = isFounder(h);
@let isPending = myM?.status === 'pending';
<div class="px-4 py-4" [class.border-b]="!$last" [class.border-gray-100]="!$last" [class.dark:border-gray-700]="!$last">
<!-- Household header row -->
<div class="flex items-center justify-between mb-3">
<div>
<h3 class="text-sm font-semibold text-gray-900 dark:text-white">{{ h.name }}</h3>
<p class="text-xs text-gray-400 dark:text-gray-500 mt-0.5">
{{ 'household.created_by' | translate }}: {{ h.created_by_email }}
</p>
</div>
<!-- Invite button (founder or admin, only if not pending) -->
@if (!isPending && canInvite(h) && inviteHouseholdId() !== h.id) {
<button type="button" (click)="openInviteForm(h.id)"
class="flex items-center gap-1.5 px-3 py-1.5 text-xs font-medium text-violet-700 dark:text-violet-400 border border-violet-300 dark:border-violet-700 rounded-lg hover:bg-violet-50 dark:hover:bg-violet-900/20">
<svg class="w-3.5 h-3.5" fill="currentColor" viewBox="0 0 20 20">
<path d="M8 9a3 3 0 100-6 3 3 0 000 6zM8 11a6 6 0 016 6H2a6 6 0 016-6zM16 7a1 1 0 10-2 0v1h-1a1 1 0 100 2h1v1a1 1 0 102 0v-1h1a1 1 0 100-2h-1V7z"/>
</svg>
{{ 'household.invite' | translate }}
</button>
}
</div>
<!-- Pending invitation banner -->
@if (isPending) {
<div class="flex items-center justify-between bg-violet-50 dark:bg-violet-900/20 border border-violet-200 dark:border-violet-800 rounded-lg px-3 py-2.5 mb-3">
<div>
<p class="text-xs font-medium text-violet-800 dark:text-violet-300">{{ 'household.pending_invitation' | translate }}</p>
<p class="text-xs text-violet-600 dark:text-violet-400 mt-0.5">
{{ 'household.pending_from' | translate }}: {{ myM?.invited_by_email }}
· {{ 'household.effective_from' | translate }} {{ myM?.effective_from_year }}
</p>
</div>
<button type="button" (click)="acceptInvitation(h.id)"
class="ml-3 px-3 py-1.5 text-xs font-medium text-white bg-violet-700 rounded-lg hover:bg-violet-800 shrink-0">
{{ 'household.accept' | translate }}
</button>
</div>
}
<!-- Member list (show only for non-pending) -->
@if (!isPending) {
<div class="divide-y divide-gray-100 dark:divide-gray-700 border border-gray-200 dark:border-gray-700 rounded-lg overflow-hidden">
@for (m of activeMembers(h); track m.id) {
<div class="flex items-center gap-3 px-3 py-2.5">
<!-- Email -->
<p class="flex-1 text-sm text-gray-900 dark:text-white truncate min-w-0">
{{ m.user_email }}
@if (m.user_email === userEmail) {
<span class="ml-1.5 text-xs text-gray-400">({{ 'household.you' | translate }})</span>
}
@if (m.user_email === h.created_by_email) {
<span class="ml-1.5 text-xs text-gray-400">({{ 'household.founder' | translate }})</span>
}
</p>
<!-- Status badge -->
<span class="shrink-0 text-xs font-medium rounded-full px-2 py-0.5"
[class.bg-green-100]="m.status === 'active'"
[class.text-green-700]="m.status === 'active'"
[class.dark:bg-green-900/30]="m.status === 'active'"
[class.dark:text-green-400]="m.status === 'active'"
[class.bg-yellow-100]="m.status === 'pending'"
[class.text-yellow-700]="m.status === 'pending'"
[class.dark:bg-yellow-900/30]="m.status === 'pending'"
[class.dark:text-yellow-400]="m.status === 'pending'">
{{ 'household.status_' + m.status | translate }}
</span>
<!-- Role badge -->
<span class="shrink-0 text-xs font-medium rounded-full px-2 py-0.5"
[class.bg-violet-100]="m.role === 'admin'"
[class.text-violet-700]="m.role === 'admin'"
[class.dark:bg-violet-900/30]="m.role === 'admin'"
[class.dark:text-violet-400]="m.role === 'admin'"
[class.bg-gray-100]="m.role === 'member'"
[class.text-gray-500]="m.role === 'member'"
[class.dark:bg-gray-700]="m.role === 'member'"
[class.dark:text-gray-400]="m.role === 'member'">
{{ 'household.role_' + m.role | translate }}
</span>
<!-- Role toggle (only founder, not for self, only for active members) -->
@if (amFounder && m.user_email !== userEmail && m.status === 'active') {
<button type="button" (click)="toggleMemberRole(h.id, m)"
class="shrink-0 p-1.5 text-gray-400 hover:text-violet-700 dark:hover:text-violet-400 rounded-md hover:bg-violet-50 dark:hover:bg-violet-900/20 transition-colors"
[title]="m.role === 'admin' ? ('household.remove_admin' | translate) : ('household.make_admin' | translate)">
<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="M15.75 5.25a3 3 0 0 1 3 3m3 0a6 6 0 0 1-7.029 5.912c-.563-.097-1.159.026-1.563.43L10.5 17.25H8.25v2.25H6v2.25H2.25v-2.818c0-.597.237-1.17.659-1.591l6.499-6.499c.404-.404.527-1 .43-1.563A6 6 0 0 1 21.75 8.25Z"/>
</svg>
</button>
}
</div>
}
</div>
}
<!-- Pending invites (no account yet) -->
@if (!isPending && (h.pending_invites?.length ?? 0) > 0) {
<div class="mt-3 divide-y divide-gray-100 dark:divide-gray-700 border border-gray-200 dark:border-gray-700 rounded-lg overflow-hidden">
@for (pi of h.pending_invites; track pi.id) {
<div class="flex items-center gap-3 px-3 py-2.5">
<p class="flex-1 text-sm text-gray-400 dark:text-gray-500 truncate min-w-0 italic">
{{ pi.invited_email }}
</p>
<span class="shrink-0 text-xs font-medium bg-gray-100 dark:bg-gray-700 text-gray-500 dark:text-gray-400 rounded-full px-2 py-0.5">
{{ 'household.status_unregistered' | translate }}
</span>
</div>
}
</div>
}
<!-- Invite form -->
@if (inviteHouseholdId() === h.id) {
<div class="mt-3 p-3 bg-gray-50 dark:bg-gray-700/30 rounded-lg border border-gray-200 dark:border-gray-700">
<label class="block text-xs font-medium text-gray-700 dark:text-gray-300 mb-1">
{{ 'household.invite_email' | translate }}
</label>
<div class="flex gap-2">
<input type="email" [(ngModel)]="inviteEmail"
[placeholder]="'household.invite_placeholder' | translate"
class="flex-1 min-w-0 px-3 py-2 text-sm bg-white dark:bg-gray-800 border border-gray-300 dark:border-gray-600 rounded-lg focus:ring-2 focus:ring-violet-500 focus:border-violet-500 text-gray-900 dark:text-white placeholder-gray-400">
<button type="button" (click)="sendInvite(h.id)" [disabled]="inviteSaving"
class="px-3 py-2 text-sm font-medium text-white bg-violet-700 rounded-lg hover:bg-violet-800 disabled:opacity-60 shrink-0">
{{ inviteSaving ? '…' : ('household.send' | translate) }}
</button>
<button type="button" (click)="inviteHouseholdId.set(null)"
class="px-3 py-2 text-sm font-medium text-gray-700 dark:text-gray-200 bg-white dark:bg-gray-800 border border-gray-300 dark:border-gray-600 rounded-lg hover:bg-gray-50 dark:hover:bg-gray-700 shrink-0">
{{ 'common.cancel' | translate }}
</button>
</div>
@if (inviteError) {
<p class="mt-1.5 text-xs text-red-600 dark:text-red-400">
{{ 'household.error_' + inviteError | translate }}
</p>
}
</div>
}
<!-- Leave button (active members who are not the founder) -->
@if (!amFounder && myM?.status === 'active') {
<div class="mt-3 flex justify-end">
<button type="button" (click)="openLeaveModal(h.id)"
class="text-xs font-medium text-red-600 dark:text-red-400 hover:text-red-800 dark:hover:text-red-300">
{{ 'household.leave' | translate }}
</button>
</div>
}
</div>
}
</div>
</div>
<!-- New Year Modal -->
@if (showNewYearModal()) {
<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-xl shadow-xl p-6 w-full max-w-sm">
<h2 class="text-lg font-semibold text-gray-900 dark:text-white mb-2">
{{ 'financial_year.confirm_new_year' | translate: { year: nextYear() } }}
</h2>
<!-- Haushalt-Auswahl -->
@if (activeHouseholds().length > 0) {
<div class="mb-4">
<label class="block text-xs font-medium text-gray-700 dark:text-gray-300 mb-1.5">
{{ 'financial_year.new_year_owner' | translate }}
</label>
<div class="flex flex-col gap-1.5">
<label class="flex items-center gap-2 cursor-pointer">
<input type="radio" name="newYearOwner" [value]="null" [checked]="newYearHouseholdId() === null" (change)="newYearHouseholdId.set(null)"
class="text-violet-600 focus:ring-violet-500">
<span class="text-sm text-gray-700 dark:text-gray-300">{{ 'financial_year.owner_personal' | translate }}</span>
</label>
@for (h of activeHouseholds(); track h.id) {
<label class="flex items-center gap-2 cursor-pointer">
<input type="radio" name="newYearOwner" [value]="h.id" [checked]="newYearHouseholdId() === h.id" (change)="newYearHouseholdId.set(h.id)"
class="text-violet-600 focus:ring-violet-500">
<span class="text-sm text-gray-700 dark:text-gray-300">{{ h.name }}</span>
</label>
}
</div>
</div>
}
@if (years().length > 0) {
<p class="text-sm text-gray-500 dark:text-gray-400 mb-5">
{{ 'financial_year.confirm_copy' | translate: { source: selectedYear() } }}
</p>
<div class="flex flex-col gap-2">
<button type="button" (click)="createYear(true)"
class="w-full px-4 py-2.5 text-sm font-medium text-white bg-violet-700 rounded-lg hover:bg-violet-800">
{{ 'financial_year.copy_yes' | translate }}
</button>
<button type="button" (click)="createYear(false)"
class="w-full px-4 py-2.5 text-sm font-medium text-gray-700 dark:text-gray-200 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">
{{ 'financial_year.copy_no' | translate }}
</button>
<button type="button" (click)="showNewYearModal.set(false)"
class="w-full px-4 py-2 text-sm text-gray-400 hover:text-gray-600 dark:hover:text-gray-300">
{{ 'common.cancel' | translate }}
</button>
</div>
} @else {
<p class="text-sm text-gray-500 dark:text-gray-400 mb-5">
{{ 'financial_year.first_year_hint' | translate: { year: nextYear() } }}
</p>
<div class="flex flex-col gap-2">
<button type="button" (click)="createYear(false)"
class="w-full px-4 py-2.5 text-sm font-medium text-white bg-violet-700 rounded-lg hover:bg-violet-800">
{{ 'financial_year.create_year' | translate }}
</button>
<button type="button" (click)="showNewYearModal.set(false)"
class="w-full px-4 py-2 text-sm text-gray-400 hover:text-gray-600 dark:hover:text-gray-300">
{{ 'common.cancel' | translate }}
</button>
</div>
}
</div>
</div>
}
<!-- Leave Household Modal -->
@if (showLeaveModal()) {
<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-xl shadow-xl p-6 w-full max-w-sm">
<h2 class="text-lg font-semibold text-gray-900 dark:text-white mb-2">
{{ 'household.leave_confirm_title' | translate }}
</h2>
<p class="text-sm text-gray-500 dark:text-gray-400 mb-5">
{{ 'household.leave_confirm_text' | translate }}
</p>
<div class="flex gap-3">
<button type="button" (click)="showLeaveModal.set(false)"
class="flex-1 px-4 py-2 text-sm font-medium text-gray-700 dark:text-gray-200 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">
{{ 'common.cancel' | translate }}
</button>
<button type="button" (click)="confirmLeave()"
class="flex-1 px-4 py-2 text-sm font-medium text-white bg-red-600 rounded-lg hover:bg-red-700">
{{ 'household.leave' | translate }}
</button>
</div>
</div>
</div>
}
<!-- Delete Confirm Modal -->
@if (showDeleteModal()) {
<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-xl shadow-xl p-6 w-full max-w-sm">
<h2 class="text-lg 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-5">
{{ 'common.delete_confirm_text' | translate }}
</p>
<div class="flex gap-3">
<button type="button" (click)="showDeleteModal.set(false)"
class="flex-1 px-4 py-2 text-sm font-medium text-gray-700 dark:text-gray-200 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">
{{ 'common.cancel' | translate }}
</button>
<button type="button" (click)="executeDelete()"
class="flex-1 px-4 py-2 text-sm font-medium text-white bg-red-600 rounded-lg hover:bg-red-700">
{{ 'common.delete' | translate }}
</button>
</div>
</div>
</div>
}
@@ -1,447 +0,0 @@
import { Component, OnInit, signal, computed } from '@angular/core';
import { CommonModule } from '@angular/common';
import { FormsModule } from '@angular/forms';
import { TranslateModule } from '@ngx-translate/core';
import { FinancialYearService, FinancialYear, YearlyIncome, YearlyBudgetItem, Household, HouseholdMembership } from '../services/financial-year';
import { ApiService } from '../services/api';
type Tab = 'incomes' | 'budget_items';
@Component({
selector: 'app-financial-year',
standalone: true,
imports: [CommonModule, FormsModule, TranslateModule],
templateUrl: './financial-year.html',
styleUrl: './financial-year.css',
})
export class FinancialYearComponent implements OnInit {
years = signal<FinancialYear[]>([]);
currentFY = signal<FinancialYear | null>(null);
selectedYear = signal<number>(new Date().getFullYear());
activeTab = signal<Tab>('incomes');
loading = signal(true);
yearDropdownOpen = signal(false);
// New year modal
showNewYearModal = signal(false);
newYearHouseholdId = signal<number | null>(null);
// Add/edit form
showForm = signal(false);
editingId: number | null = null;
formName = '';
formAmount = 0;
formNotes = '';
formActive = true;
formError = '';
formSaving = false;
// Delete modal
showDeleteModal = signal(false);
deleteId: number | null = null;
// Revenue accounts (income tab)
revenueAccounts = signal<any[]>([]);
// Budgets (fixed costs)
budgets = signal<any[]>([]);
// Expenses (actual spending)
expenses = signal<any[]>([]);
// Household state
households = signal<Household[]>([]);
userEmail = '';
// Create household form
showCreateHouseholdForm = signal(false);
householdName = '';
householdSaving = false;
householdError = '';
// Invite form
inviteHouseholdId = signal<number | null>(null);
inviteEmail = '';
inviteError = '';
inviteSaving = false;
// Leave confirm modal
showLeaveModal = signal(false);
leaveHouseholdId: number | null = null;
constructor(private fyService: FinancialYearService, private api: ApiService) {}
ngOnInit(): void {
this.loadAll();
this.loadHouseholds();
this.loadRevenueAccounts();
this.loadBudgets();
this.api.getExpenses().subscribe({ next: (d) => this.expenses.set(d) });
this.api.getProfile().subscribe({ next: (p) => { this.userEmail = p.email || ''; } });
}
// --- Computed ---
nextYear = computed(() => {
const ys = this.years();
if (ys.length === 0) return new Date().getFullYear();
return Math.max(...ys.map(y => y.year)) + 1;
});
canCreateNewYear = computed(() => {
const next = this.nextYear();
const maxAllowed = new Date().getFullYear() + 1;
return next <= maxAllowed && !this.years().some(y => y.year === next);
});
totalIncome = computed(() => Number(this.currentFY()?.total_income ?? 0));
totalFixedCosts = computed(() => Number(this.currentFY()?.total_fixed_costs ?? 0));
disposable = computed(() => this.totalIncome() - this.totalFixedCosts());
savingsRate = computed(() => {
const i = this.totalIncome();
return i > 0 ? Math.round((this.disposable() / i) * 100) : 0;
});
incomes = computed(() => this.currentFY()?.incomes ?? []);
budgetItems = computed(() => this.currentFY()?.budget_items ?? []);
totalAnnualIncome = computed(() =>
this.revenueAccounts().reduce((sum, a) => sum + parseFloat(a.balance) * (a.salary_months ?? 12), 0)
);
totalMonthlyBudget = computed(() =>
this.budgets().filter((b) => b.active).reduce((sum, b) => sum + parseFloat(b.amount), 0)
);
totalAnnualBudget = computed(() => this.totalMonthlyBudget() * 12);
totalYearExpenses = computed(() =>
this.expenses()
.filter((e) => new Date(e.date).getFullYear() === this.selectedYear())
.reduce((sum, e) => sum + parseFloat(e.amount), 0)
);
avgMonthlyExpenses = computed(() => {
const now = new Date();
const year = this.selectedYear();
const months = year < now.getFullYear() ? 12 : now.getMonth() + 1;
return months > 0 ? this.totalYearExpenses() / months : 0;
});
// --- Data loading ---
private loadAll(): void {
this.loading.set(true);
this.fyService.list().subscribe({
next: (ys) => {
// ys is ordered by -year (descending from backend)
this.years.set(ys);
if (ys.length > 0) {
const target =
ys.find(y => y.year === this.selectedYear()) ??
ys.find(y => y.is_active) ??
ys[0];
this.selectedYear.set(target.year);
this.currentFY.set(target);
}
this.loading.set(false);
},
error: () => this.loading.set(false),
});
}
private reloadCurrentYear(): void {
const year = this.selectedYear();
this.fyService.get(year).subscribe({
next: (fy) => {
setTimeout(() => {
this.currentFY.set(fy);
// Keep the years list in sync for totals shown in the sidebar/year selector
this.years.update(ys => ys.map(y => (y.year === year ? { ...y, ...fy } : y)));
});
},
});
}
// --- Year selection ---
selectYear(year: number): void {
this.yearDropdownOpen.set(false);
this.selectedYear.set(year);
this.closeForm();
const cached = this.years().find(y => y.year === year);
if (cached) {
this.currentFY.set(cached);
this.loadRevenueAccounts();
}
}
// --- Year creation ---
openNewYearModal(): void {
this.newYearHouseholdId.set(null);
this.showNewYearModal.set(true);
}
activeHouseholds(): Household[] {
return this.households().filter(h =>
h.memberships.some(m => m.user_email === this.userEmail && m.status === 'active')
);
}
createYear(copy: boolean): void {
const newYear = this.nextYear();
const sourceYear = copy ? Math.max(...this.years().map(y => y.year)) : null;
const householdId = this.newYearHouseholdId();
const payload: { year: number; household_id?: number } = { year: newYear };
if (householdId) payload.household_id = householdId;
this.fyService.create(payload).subscribe({
next: () => {
if (sourceYear !== null) {
this.fyService.copyFrom(newYear, sourceYear).subscribe({
next: () => {
this.showNewYearModal.set(false);
this.selectedYear.set(newYear);
this.loadAll();
},
});
} else {
this.showNewYearModal.set(false);
this.selectedYear.set(newYear);
this.loadAll();
}
},
});
}
// --- Tab ---
selectTab(tab: Tab): void {
this.activeTab.set(tab);
this.closeForm();
}
// --- Form ---
openAddForm(): void {
this.editingId = null;
this.formName = '';
this.formAmount = 0;
this.formNotes = '';
this.formActive = true;
this.formError = '';
this.formSaving = false;
this.showForm.set(true);
}
openEditForm(item: YearlyIncome | YearlyBudgetItem): void {
this.editingId = item.id;
this.formName = item.name;
this.formAmount = Number(item.amount);
this.formNotes = item.notes;
this.formActive = item.active;
this.formError = '';
this.formSaving = false;
this.showForm.set(true);
}
closeForm(): void {
this.showForm.set(false);
this.editingId = null;
}
saveForm(): void {
const name = this.formName.trim();
if (!name) { this.formError = 'name_required'; return; }
const amount = this.formAmount;
if (!amount || amount <= 0) { this.formError = 'amount_invalid'; return; }
const year = this.selectedYear();
const data = { name, amount, notes: this.formNotes, active: this.formActive };
const id = this.editingId;
const tab = this.activeTab();
this.formSaving = true;
let obs;
if (tab === 'incomes') {
obs = id
? this.fyService.updateIncome(year, id, data)
: this.fyService.createIncome(year, { ...data, member: null });
} else {
obs = id
? this.fyService.updateBudgetItem(year, id, data)
: this.fyService.createBudgetItem(year, data);
}
obs.subscribe({
next: () => {
this.formSaving = false;
this.closeForm();
this.reloadCurrentYear();
},
error: () => {
this.formSaving = false;
this.formError = 'save_failed';
},
});
}
// --- Delete ---
confirmDelete(id: number): void {
this.deleteId = id;
this.showDeleteModal.set(true);
}
executeDelete(): void {
const id = this.deleteId;
if (!id) return;
const year = this.selectedYear();
const tab = this.activeTab();
const obs = tab === 'incomes'
? this.fyService.deleteIncome(year, id)
: this.fyService.deleteBudgetItem(year, id);
obs.subscribe({
next: () => {
this.showDeleteModal.set(false);
this.reloadCurrentYear();
},
});
}
// --- Household helpers ---
myMembership(h: Household): HouseholdMembership | undefined {
return h.memberships.find(m => m.user_email === this.userEmail);
}
isFounder(h: Household): boolean {
return h.created_by_email === this.userEmail;
}
canInvite(h: Household): boolean {
const m = this.myMembership(h);
return this.isFounder(h) || (m?.status === 'active' && m?.role === 'admin');
}
activeMembers(h: Household): HouseholdMembership[] {
return h.memberships.filter(m => m.status !== 'left');
}
// --- Household CRUD ---
private loadBudgets(): void {
this.api.getBudgets().subscribe({ next: (bs) => this.budgets.set(bs) });
}
private loadRevenueAccounts(): void {
const fy = this.currentFY();
if (fy?.owner_type === 'household' && fy.household_id) {
this.fyService.getHouseholdRevenueAccounts(fy.household_id).subscribe({
next: (accounts) => this.revenueAccounts.set(accounts),
});
} else {
this.api.getAccounts().subscribe({
next: (accounts) => this.revenueAccounts.set(accounts.filter((a) => a.account_type === 'revenue')),
});
}
}
toggleSalaryMonths(account: any): void {
const newMonths = account.salary_months === 13 ? 12 : 13;
this.api.patchAccount(account.id, { salary_months: newMonths }).subscribe({
next: (updated) => {
this.revenueAccounts.update((accounts) =>
accounts.map((a) => (a.id === account.id ? { ...a, salary_months: updated.salary_months } : a))
);
},
});
}
private loadHouseholds(): void {
this.fyService.getHouseholds().subscribe({ next: (hs) => this.households.set(hs) });
}
createHousehold(): void {
const name = this.householdName.trim();
if (!name) { this.householdError = 'name_required'; return; }
this.householdSaving = true;
this.fyService.createHousehold(name).subscribe({
next: () => {
this.householdSaving = false;
this.showCreateHouseholdForm.set(false);
this.householdName = '';
this.householdError = '';
this.loadHouseholds();
},
error: () => { this.householdSaving = false; this.householdError = 'failed'; },
});
}
openInviteForm(householdId: number): void {
this.inviteHouseholdId.set(householdId);
this.inviteEmail = '';
this.inviteError = '';
this.inviteSaving = false;
}
sendInvite(pk: number): void {
const email = this.inviteEmail.trim();
if (!email) { this.inviteError = 'email_required'; return; }
this.inviteSaving = true;
this.fyService.inviteMember(pk, email).subscribe({
next: () => {
this.inviteSaving = false;
this.inviteHouseholdId.set(null);
this.inviteEmail = '';
this.loadHouseholds();
},
error: (err) => {
this.inviteSaving = false;
const detail = err?.error?.detail ?? '';
if (detail.includes('email')) this.inviteError = 'not_found';
else if (detail.includes('already')) this.inviteError = 'already_member';
else this.inviteError = 'failed';
},
});
}
acceptInvitation(pk: number): void {
this.fyService.acceptInvitation(pk).subscribe({
next: () => this.loadHouseholds(),
});
}
openLeaveModal(householdId: number): void {
this.leaveHouseholdId = householdId;
this.showLeaveModal.set(true);
}
confirmLeave(): void {
if (!this.leaveHouseholdId) return;
this.fyService.leaveHousehold(this.leaveHouseholdId).subscribe({
next: () => { this.showLeaveModal.set(false); this.loadHouseholds(); },
});
}
toggleMemberRole(pk: number, membership: HouseholdMembership): void {
const newRole = membership.role === 'admin' ? 'member' : 'admin';
this.fyService.setMemberRole(pk, membership.id, newRole).subscribe({
next: () => this.loadHouseholds(),
});
}
// --- Formatting ---
formatChf(val: number): string {
return new Intl.NumberFormat('de-CH', { minimumFractionDigits: 0, maximumFractionDigits: 0 }).format(val);
}
perMonth(val: number): number {
return Math.round(Number(val) / 12);
}
}
@@ -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');
}
}
+61 -20
View File
@@ -106,26 +106,6 @@
</a>
</li>
<!-- Financial Year -->
<li>
<a routerLink="/financial-year" routerLinkActive="!bg-violet-50 !text-violet-700 dark:!bg-violet-900/20 dark:!text-violet-300"
(click)="sidebarService.closeMobile()"
[class]="sidebarService.collapsed() ? 'justify-center relative' : ''"
class="flex items-center p-2 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 20 20">
<path d="M2 10a1 1 0 011-1h2a1 1 0 011 1v5a1 1 0 01-1 1H3a1 1 0 01-1-1v-5zM8 6a1 1 0 011-1h2a1 1 0 011 1v9a1 1 0 01-1 1H9a1 1 0 01-1-1V6zM14 4a1 1 0 011-1h2a1 1 0 011 1v11a1 1 0 01-1 1h-2a1 1 0 01-1-1V4z"/>
</svg>
@if (!sidebarService.collapsed()) {
<span class="ml-3 whitespace-nowrap">{{ 'sidebar.financial_year' | translate }}</span>
}
@if (sidebarService.collapsed()) {
<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.financial_year' | translate }}
</span>
}
</a>
</li>
<!-- Accounts -->
<li class="relative">
@if (sidebarService.collapsed()) {
@@ -177,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 -4
View File
@@ -23,10 +23,6 @@ export class ApiService {
return this.http.put(`${this.baseUrl}/accounts/${id}/`, account);
}
patchAccount(id: number, data: Partial<{name: string, balance: number, account_type: string, salary_months: number}>): Observable<any> {
return this.http.patch(`${this.baseUrl}/accounts/${id}/`, data);
}
deleteAccount(id: number): Observable<any> {
return this.http.delete(`${this.baseUrl}/accounts/${id}/`);
}
@@ -198,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}/`);
}
}
-146
View File
@@ -1,146 +0,0 @@
import { Injectable } from '@angular/core';
import { HttpClient } from '@angular/common/http';
import { Observable } from 'rxjs';
export interface YearlyIncome {
id: number;
member: number | null;
member_email: string;
name: string;
amount: number;
active: boolean;
notes: string;
}
export interface YearlyBudgetItem {
id: number;
name: string;
amount: number;
active: boolean;
notes: string;
}
export interface FinancialYear {
id: number;
year: number;
is_active: boolean;
notes: string;
owner_type: 'personal' | 'household';
household_id: number | null;
total_income: number;
total_fixed_costs: number;
incomes: YearlyIncome[];
budget_items: YearlyBudgetItem[];
created_at: string;
}
export interface HouseholdMembership {
id: number;
user: number;
user_email: string;
invited_by_email: string;
status: 'pending' | 'active' | 'left';
role: 'member' | 'admin';
effective_from_year: number | null;
effective_until_year: number | null;
created_at: string;
}
export interface PendingInvite {
id: number;
invited_email: string;
invited_by_email: string;
effective_from_year: number | null;
created_at: string;
}
export interface Household {
id: number;
name: string;
created_by_email: string;
memberships: HouseholdMembership[];
pending_invites: PendingInvite[];
created_at: string;
}
@Injectable({ providedIn: 'root' })
export class FinancialYearService {
private base = '/api/financial-years';
constructor(private http: HttpClient) {}
list(): Observable<FinancialYear[]> {
return this.http.get<FinancialYear[]>(`${this.base}/`);
}
get(year: number): Observable<FinancialYear> {
return this.http.get<FinancialYear>(`${this.base}/${year}/`);
}
create(data: { year: number; notes?: string; household_id?: number }): Observable<FinancialYear> {
return this.http.post<FinancialYear>(`${this.base}/`, data);
}
update(year: number, data: Partial<{ is_active: boolean; notes: string }>): Observable<FinancialYear> {
return this.http.patch<FinancialYear>(`${this.base}/${year}/`, data);
}
copyFrom(year: number, sourceYear: number): Observable<any> {
return this.http.post(`${this.base}/${year}/copy-from/${sourceYear}/`, {});
}
// Incomes
createIncome(year: number, data: { name: string; amount: number; active: boolean; notes: string; member: number | null }): Observable<YearlyIncome> {
return this.http.post<YearlyIncome>(`${this.base}/${year}/incomes/`, data);
}
updateIncome(year: number, id: number, data: { name: string; amount: number; active: boolean; notes: string }): Observable<YearlyIncome> {
return this.http.patch<YearlyIncome>(`${this.base}/${year}/incomes/${id}/`, data);
}
deleteIncome(year: number, id: number): Observable<void> {
return this.http.delete<void>(`${this.base}/${year}/incomes/${id}/`);
}
// Budget Items
createBudgetItem(year: number, data: { name: string; amount: number; active: boolean; notes: string }): Observable<YearlyBudgetItem> {
return this.http.post<YearlyBudgetItem>(`${this.base}/${year}/budget-items/`, data);
}
updateBudgetItem(year: number, id: number, data: { name: string; amount: number; active: boolean; notes: string }): Observable<YearlyBudgetItem> {
return this.http.patch<YearlyBudgetItem>(`${this.base}/${year}/budget-items/${id}/`, data);
}
deleteBudgetItem(year: number, id: number): Observable<void> {
return this.http.delete<void>(`${this.base}/${year}/budget-items/${id}/`);
}
// Households
getHouseholds(): Observable<Household[]> {
return this.http.get<Household[]>('/api/households/');
}
createHousehold(name: string): Observable<Household> {
return this.http.post<Household>('/api/households/', { name });
}
inviteMember(pk: number, email: string): Observable<any> {
return this.http.post(`/api/households/${pk}/invite/`, { email });
}
acceptInvitation(pk: number): Observable<any> {
return this.http.post(`/api/households/${pk}/accept/`, {});
}
leaveHousehold(pk: number): Observable<any> {
return this.http.post(`/api/households/${pk}/leave/`, {});
}
setMemberRole(pk: number, membershipId: number, role: 'member' | 'admin'): Observable<HouseholdMembership> {
return this.http.post<HouseholdMembership>(`/api/households/${pk}/members/${membershipId}/set-role/`, { role });
}
getHouseholdRevenueAccounts(pk: number): Observable<any[]> {
return this.http.get<any[]>(`/api/households/${pk}/revenue-accounts/`);
}
}
+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 -79
View File
@@ -136,17 +136,6 @@
"transactions": "Transaktionen",
"deadlines": "Termine"
},
"sidebar": {
"dashboard": "Dashboard",
"budgets": "Budgets",
"fixed_costs": "Fixkosten",
"expenses": "Ausgaben",
"calendar": "Kalender",
"financial_year": "Jahresplanung",
"accounts": "Konten",
"revenue_accounts": "Einnahmekonten",
"transactions": "Transaktionen"
},
"dashboard": {
"title": "Dashboard",
"subtitle": "Finanzübersicht",
@@ -346,74 +335,6 @@
"ZG": "Zug",
"ZH": "Zürich"
},
"household": {
"title": "Haushalt",
"none": "Du bist noch in keinem Haushalt.",
"none_hint": "Gründe einen gemeinsamen Haushalt oder warte auf eine Einladung.",
"create": "Haushalt gründen",
"create_title": "Neuer Haushalt",
"label_name": "Name des Haushalts",
"placeholder_name": "z.B. Familie Müller",
"created_by": "Gegründet von",
"members": "Mitglieder",
"invite": "Einladen",
"invite_email": "E-Mail-Adresse",
"invite_placeholder": "user@beispiel.ch",
"send": "Einladung senden",
"status_active": "Aktiv",
"status_pending": "Ausstehend",
"status_left": "Ausgetreten",
"role_admin": "Admin",
"role_member": "Mitglied",
"make_admin": "Admin machen",
"remove_admin": "Admin entfernen",
"pending_invitation": "Einladung erhalten",
"pending_from": "Eingeladen von",
"effective_from": "Ab Jahr",
"accept": "Annehmen",
"leave": "Haushalt verlassen",
"leave_confirm_title": "Haushalt verlassen?",
"leave_confirm_text": "Du verlässt den Haushalt per Ende des laufenden Jahres. Vergangene Haushaltsjahre bleiben für dich lesbar.",
"you": "du",
"founder": "Gründer",
"error_name_required": "Name ist erforderlich.",
"error_failed": "Vorgang fehlgeschlagen.",
"error_email_required": "E-Mail-Adresse ist erforderlich.",
"error_not_found": "Kein Benutzer mit dieser E-Mail gefunden.",
"error_already_member": "Benutzer ist bereits Mitglied oder hat eine ausstehende Einladung."
},
"financial_year": {
"title": "Jahresplanung",
"owner_personal": "Persönlich",
"owner_household": "Haushalt",
"new_year": "Neues Jahr starten",
"no_years": "Noch kein Finanzjahr erstellt.",
"start_first_year": "Erstes Jahr starten",
"tab_incomes": "Einnahmen",
"tab_budget_items": "Fixkosten",
"add_income": "Einnahme hinzufügen",
"add_budget_item": "Fixkosten hinzufügen",
"label_name": "Bezeichnung",
"label_amount": "Betrag (CHF / Jahr)",
"label_notes": "Notizen",
"label_active": "Aktiv",
"total_income": "Gesamteinnahmen",
"total_fixed_costs": "Fixkosten total",
"disposable": "Verfügbar",
"savings_rate": "Sparquote",
"per_month": "Monat",
"no_incomes": "Noch keine Einnahmen erfasst.",
"no_budget_items": "Noch keine Fixkosten erfasst.",
"confirm_new_year": "Jahr {{ year }} starten?",
"confirm_copy": "Soll das neue Jahr mit den Daten aus {{ source }} vorausgefüllt werden?",
"first_year_hint": "Das Finanzjahr {{ year }} wird neu erstellt.",
"create_year": "Jahr erstellen",
"copy_yes": "Ja, Daten übernehmen",
"copy_no": "Leer starten",
"error_name_required": "Bezeichnung ist erforderlich.",
"error_amount_invalid": "Bitte einen gültigen Betrag eingeben.",
"error_save_failed": "Speichern fehlgeschlagen."
},
"profile": {
"title": "Profil",
"subtitle": "Persönliche Informationen und Einstellungen verwalten",
@@ -469,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 -79
View File
@@ -136,17 +136,6 @@
"transactions": "Transactions",
"deadlines": "Deadlines"
},
"sidebar": {
"dashboard": "Dashboard",
"budgets": "Budgets",
"fixed_costs": "Fixed Costs",
"expenses": "Expenses",
"calendar": "Calendar",
"financial_year": "Annual Planning",
"accounts": "Accounts",
"revenue_accounts": "Revenue Accounts",
"transactions": "Transactions"
},
"dashboard": {
"title": "Dashboard",
"subtitle": "Financial overview",
@@ -346,74 +335,6 @@
"ZG": "Zug",
"ZH": "Zuerich"
},
"household": {
"title": "Household",
"none": "You are not part of any household yet.",
"none_hint": "Create a shared household or wait for an invitation.",
"create": "Create Household",
"create_title": "New Household",
"label_name": "Household Name",
"placeholder_name": "e.g. Smith Family",
"created_by": "Created by",
"members": "Members",
"invite": "Invite",
"invite_email": "Email Address",
"invite_placeholder": "user@example.com",
"send": "Send Invitation",
"status_active": "Active",
"status_pending": "Pending",
"status_left": "Left",
"role_admin": "Admin",
"role_member": "Member",
"make_admin": "Make admin",
"remove_admin": "Remove admin",
"pending_invitation": "Invitation received",
"pending_from": "Invited by",
"effective_from": "From year",
"accept": "Accept",
"leave": "Leave Household",
"leave_confirm_title": "Leave Household?",
"leave_confirm_text": "You will leave the household at the end of the current year. Past household years remain readable for you.",
"you": "you",
"founder": "Founder",
"error_name_required": "Name is required.",
"error_failed": "Operation failed.",
"error_email_required": "Email address is required.",
"error_not_found": "No user found with this email.",
"error_already_member": "User is already a member or has a pending invitation."
},
"financial_year": {
"title": "Annual Planning",
"owner_personal": "Personal",
"owner_household": "Household",
"new_year": "Start New Year",
"no_years": "No financial year created yet.",
"start_first_year": "Start First Year",
"tab_incomes": "Income",
"tab_budget_items": "Fixed Costs",
"add_income": "Add Income",
"add_budget_item": "Add Fixed Cost",
"label_name": "Name",
"label_amount": "Amount (CHF / Year)",
"label_notes": "Notes",
"label_active": "Active",
"total_income": "Total Income",
"total_fixed_costs": "Total Fixed Costs",
"disposable": "Disposable",
"savings_rate": "Savings Rate",
"per_month": "Month",
"no_incomes": "No income entries yet.",
"no_budget_items": "No fixed cost entries yet.",
"confirm_new_year": "Start Year {{ year }}?",
"confirm_copy": "Should the new year be pre-filled with data from {{ source }}?",
"first_year_hint": "Financial year {{ year }} will be created.",
"create_year": "Create Year",
"copy_yes": "Yes, copy data",
"copy_no": "Start empty",
"error_name_required": "Name is required.",
"error_amount_invalid": "Please enter a valid amount.",
"error_save_failed": "Saving failed."
},
"profile": {
"title": "Profile",
"subtitle": "Manage your personal information and settings",
@@ -469,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 -79
View File
@@ -136,17 +136,6 @@
"transactions": "Transactions",
"deadlines": "Échéances"
},
"sidebar": {
"dashboard": "Tableau de bord",
"budgets": "Budgets",
"fixed_costs": "Charges fixes",
"expenses": "Dépenses",
"calendar": "Calendrier",
"financial_year": "Planification annuelle",
"accounts": "Comptes",
"revenue_accounts": "Comptes de revenus",
"transactions": "Transactions"
},
"dashboard": {
"title": "Tableau de bord",
"subtitle": "Aperçu financier",
@@ -346,74 +335,6 @@
"ZG": "Zoug",
"ZH": "Zurich"
},
"household": {
"title": "Ménage",
"none": "Vous ne faites encore partie d'aucun ménage.",
"none_hint": "Créez un ménage commun ou attendez une invitation.",
"create": "Créer un ménage",
"create_title": "Nouveau ménage",
"label_name": "Nom du ménage",
"placeholder_name": "p.ex. Famille Müller",
"created_by": "Créé par",
"members": "Membres",
"invite": "Inviter",
"invite_email": "Adresse e-mail",
"invite_placeholder": "user@exemple.ch",
"send": "Envoyer l'invitation",
"status_active": "Actif",
"status_pending": "En attente",
"status_left": "Parti",
"role_admin": "Admin",
"role_member": "Membre",
"make_admin": "Rendre admin",
"remove_admin": "Retirer admin",
"pending_invitation": "Invitation reçue",
"pending_from": "Invité par",
"effective_from": "Dès l'année",
"accept": "Accepter",
"leave": "Quitter le ménage",
"leave_confirm_title": "Quitter le ménage ?",
"leave_confirm_text": "Vous quitterez le ménage à la fin de l'année en cours. Les années passées resteront lisibles.",
"you": "vous",
"founder": "Fondateur",
"error_name_required": "Le nom est requis.",
"error_failed": "Opération échouée.",
"error_email_required": "L'adresse e-mail est requise.",
"error_not_found": "Aucun utilisateur trouvé avec cet e-mail.",
"error_already_member": "L'utilisateur est déjà membre ou a une invitation en attente."
},
"financial_year": {
"title": "Planification annuelle",
"owner_personal": "Personnel",
"owner_household": "Ménage",
"new_year": "Démarrer une nouvelle année",
"no_years": "Aucune année financière créée.",
"start_first_year": "Démarrer la première année",
"tab_incomes": "Revenus",
"tab_budget_items": "Charges fixes",
"add_income": "Ajouter un revenu",
"add_budget_item": "Ajouter une charge fixe",
"label_name": "Désignation",
"label_amount": "Montant (CHF / an)",
"label_notes": "Notes",
"label_active": "Actif",
"total_income": "Revenus totaux",
"total_fixed_costs": "Charges fixes totales",
"disposable": "Disponible",
"savings_rate": "Taux d'épargne",
"per_month": "mois",
"no_incomes": "Aucun revenu enregistré.",
"no_budget_items": "Aucune charge fixe enregistrée.",
"confirm_new_year": "Démarrer l'année {{ year }} ?",
"confirm_copy": "Reprendre les données de {{ source }} pour la nouvelle année ?",
"first_year_hint": "L'année financière {{ year }} sera créée.",
"create_year": "Créer l'année",
"copy_yes": "Oui, reprendre les données",
"copy_no": "Démarrer vide",
"error_name_required": "La désignation est requise.",
"error_amount_invalid": "Veuillez saisir un montant valide.",
"error_save_failed": "Échec de l'enregistrement."
},
"profile": {
"title": "Profil",
"subtitle": "Gérer vos informations personnelles et paramètres",
@@ -469,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 -79
View File
@@ -136,17 +136,6 @@
"transactions": "Transazioni",
"deadlines": "Scadenze"
},
"sidebar": {
"dashboard": "Dashboard",
"budgets": "Budget",
"fixed_costs": "Costi fissi",
"expenses": "Spese",
"calendar": "Calendario",
"financial_year": "Pianificazione annuale",
"accounts": "Conti",
"revenue_accounts": "Conti entrate",
"transactions": "Transazioni"
},
"dashboard": {
"title": "Dashboard",
"subtitle": "Panoramica finanziaria",
@@ -346,74 +335,6 @@
"ZG": "Zugo",
"ZH": "Zurigo"
},
"household": {
"title": "Nucleo familiare",
"none": "Non fai ancora parte di nessun nucleo familiare.",
"none_hint": "Crea un nucleo familiare condiviso o attendi un invito.",
"create": "Crea nucleo familiare",
"create_title": "Nuovo nucleo familiare",
"label_name": "Nome del nucleo",
"placeholder_name": "es. Famiglia Müller",
"created_by": "Creato da",
"members": "Membri",
"invite": "Invita",
"invite_email": "Indirizzo e-mail",
"invite_placeholder": "user@esempio.ch",
"send": "Invia invito",
"status_active": "Attivo",
"status_pending": "In attesa",
"status_left": "Uscito",
"role_admin": "Admin",
"role_member": "Membro",
"make_admin": "Rendi admin",
"remove_admin": "Rimuovi admin",
"pending_invitation": "Invito ricevuto",
"pending_from": "Invitato da",
"effective_from": "Dall'anno",
"accept": "Accetta",
"leave": "Lascia il nucleo",
"leave_confirm_title": "Lasciare il nucleo?",
"leave_confirm_text": "Lascerai il nucleo alla fine dell'anno in corso. Gli anni passati rimarranno leggibili.",
"you": "tu",
"founder": "Fondatore",
"error_name_required": "Il nome è obbligatorio.",
"error_failed": "Operazione non riuscita.",
"error_email_required": "L'indirizzo e-mail è obbligatorio.",
"error_not_found": "Nessun utente trovato con questa e-mail.",
"error_already_member": "L'utente è già membro o ha un invito in sospeso."
},
"financial_year": {
"title": "Pianificazione annuale",
"owner_personal": "Personale",
"owner_household": "Nucleo familiare",
"new_year": "Avvia nuovo anno",
"no_years": "Nessun anno finanziario creato.",
"start_first_year": "Avvia il primo anno",
"tab_incomes": "Entrate",
"tab_budget_items": "Costi fissi",
"add_income": "Aggiungi entrata",
"add_budget_item": "Aggiungi costo fisso",
"label_name": "Denominazione",
"label_amount": "Importo (CHF / anno)",
"label_notes": "Note",
"label_active": "Attivo",
"total_income": "Entrate totali",
"total_fixed_costs": "Costi fissi totali",
"disposable": "Disponibile",
"savings_rate": "Tasso di risparmio",
"per_month": "mese",
"no_incomes": "Nessuna entrata registrata.",
"no_budget_items": "Nessun costo fisso registrato.",
"confirm_new_year": "Avviare l'anno {{ year }}?",
"confirm_copy": "Vuoi pre-compilare il nuovo anno con i dati di {{ source }}?",
"first_year_hint": "L'anno finanziario {{ year }} verrà creato.",
"create_year": "Crea anno",
"copy_yes": "Sì, copia i dati",
"copy_no": "Inizia vuoto",
"error_name_required": "La denominazione è obbligatoria.",
"error_amount_invalid": "Inserisci un importo valido.",
"error_save_failed": "Salvataggio non riuscito."
},
"profile": {
"title": "Profilo",
"subtitle": "Gestisci le tue informazioni personali e impostazioni",
@@ -469,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)"
}
}