Compare commits
1 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| c03d2a97ab |
+1
-52
@@ -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/),
|
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).
|
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
|
||||||
|
|
||||||
## [Unreleased]
|
## [1.1.0] - 2026-05-19
|
||||||
|
|
||||||
### 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
|
|
||||||
|
|
||||||
### Added
|
### 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
|
- 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
|
||||||
|
|||||||
@@ -104,7 +104,6 @@ EMAIL_HOST_USER = os.environ.get('EMAIL_HOST_USER', '')
|
|||||||
EMAIL_HOST_PASSWORD = os.environ.get('EMAIL_HOST_PASSWORD', '')
|
EMAIL_HOST_PASSWORD = os.environ.get('EMAIL_HOST_PASSWORD', '')
|
||||||
EMAIL_USE_TLS = os.environ.get('EMAIL_USE_TLS', 'True') == 'True'
|
EMAIL_USE_TLS = os.environ.get('EMAIL_USE_TLS', 'True') == 'True'
|
||||||
DEFAULT_FROM_EMAIL = os.environ.get('DEFAULT_FROM_EMAIL', 'noreply@armarium.ch')
|
DEFAULT_FROM_EMAIL = os.environ.get('DEFAULT_FROM_EMAIL', 'noreply@armarium.ch')
|
||||||
FRONTEND_URL = os.environ.get('FRONTEND_URL', 'http://localhost:4200')
|
|
||||||
|
|
||||||
LOGGING = {
|
LOGGING = {
|
||||||
'version': 1,
|
'version': 1,
|
||||||
|
|||||||
+4
-19
@@ -7,18 +7,13 @@ from rest_framework.routers import DefaultRouter
|
|||||||
from rest_framework_simplejwt.views import TokenRefreshView
|
from rest_framework_simplejwt.views import TokenRefreshView
|
||||||
from finance.views import (
|
from finance.views import (
|
||||||
AccountViewSet, TransactionViewSet, BudgetViewSet,
|
AccountViewSet, TransactionViewSet, BudgetViewSet,
|
||||||
ExpenseViewSet, DeadlineViewSet, ProfileView, RegisterView, LogoutView, ChangePasswordView,
|
ExpenseViewSet, DeadlineViewSet, InsuranceViewSet, PraemienView, PraemienVergleichView, ProfileView, RegisterView, LogoutView, ChangePasswordView,
|
||||||
ICalUrlView, ICalFeedView, NotificationsView, SearchView,
|
ICalUrlView, ICalFeedView, NotificationsView, SearchView,
|
||||||
LoginView, TwoFactorLoginView, TwoFactorSetupView, TwoFactorEnableView, TwoFactorDisableView,
|
LoginView, TwoFactorLoginView, TwoFactorSetupView, TwoFactorEnableView, TwoFactorDisableView,
|
||||||
TwoFactorRecoverRequestView, TwoFactorRecoverConfirmView,
|
TwoFactorRecoverRequestView, TwoFactorRecoverConfirmView,
|
||||||
SessionListView, SessionRevokeView, SessionRevokeAllView,
|
SessionListView, SessionRevokeView, SessionRevokeAllView,
|
||||||
DataExportView, NotificationPrefsView,
|
DataExportView, NotificationPrefsView,
|
||||||
VerifyEmailView, PasswordResetRequestView, PasswordResetConfirmView,
|
VerifyEmailView, PasswordResetRequestView, PasswordResetConfirmView,
|
||||||
FinancialYearListCreateView, FinancialYearDetailView, FinancialYearCopyView,
|
|
||||||
YearlyIncomeListCreateView, YearlyIncomeDetailView,
|
|
||||||
YearlyBudgetItemListCreateView, YearlyBudgetItemDetailView,
|
|
||||||
HouseholdListCreateView, HouseholdInviteView, HouseholdAcceptView, HouseholdLeaveView,
|
|
||||||
HouseholdSetRoleView, HouseholdRevenueAccountsView,
|
|
||||||
)
|
)
|
||||||
|
|
||||||
router = DefaultRouter()
|
router = DefaultRouter()
|
||||||
@@ -27,6 +22,7 @@ router.register(r'transactions', TransactionViewSet, basename='transaction')
|
|||||||
router.register(r'budgets', BudgetViewSet, basename='budget')
|
router.register(r'budgets', BudgetViewSet, basename='budget')
|
||||||
router.register(r'expenses', ExpenseViewSet, basename='expense')
|
router.register(r'expenses', ExpenseViewSet, basename='expense')
|
||||||
router.register(r'deadlines', DeadlineViewSet, basename='deadline')
|
router.register(r'deadlines', DeadlineViewSet, basename='deadline')
|
||||||
|
router.register(r'insurances', InsuranceViewSet, basename='insurance')
|
||||||
|
|
||||||
_admin_url = os.environ.get('ADMIN_URL', 'manage/').strip('/')+ '/'
|
_admin_url = os.environ.get('ADMIN_URL', 'manage/').strip('/')+ '/'
|
||||||
|
|
||||||
@@ -55,19 +51,8 @@ urlpatterns = [
|
|||||||
path('api/notifications/prefs/', NotificationPrefsView.as_view()),
|
path('api/notifications/prefs/', NotificationPrefsView.as_view()),
|
||||||
path('api/search/', SearchView.as_view()),
|
path('api/search/', SearchView.as_view()),
|
||||||
path('api/notifications/', NotificationsView.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-url/', ICalUrlView.as_view()),
|
||||||
path('api/calendar/ical/<int:user_id>/<str:token>/', ICalFeedView.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)
|
] + 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
@@ -18,7 +18,6 @@ class Account(models.Model):
|
|||||||
name = models.CharField(max_length=100)
|
name = models.CharField(max_length=100)
|
||||||
account_type = models.CharField(max_length=20, choices=ACCOUNT_TYPES, default='asset')
|
account_type = models.CharField(max_length=20, choices=ACCOUNT_TYPES, default='asset')
|
||||||
balance = models.DecimalField(max_digits=12, decimal_places=2, default=0.00)
|
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)
|
active = models.BooleanField(default=True)
|
||||||
created_at = models.DateTimeField(auto_now_add=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'}"
|
return f"{self.user} – backup {'used' if self.used else 'active'}"
|
||||||
|
|
||||||
|
|
||||||
# ── FinancialYear ─────────────────────────────────────────────────────────────
|
class Insurance(models.Model):
|
||||||
|
INSURANCE_TYPES = [
|
||||||
class Household(models.Model):
|
('kvg', 'Krankenkasse Grundversicherung (KVG)'),
|
||||||
name = models.CharField(max_length=100)
|
('kk_zusatz', 'KK-Zusatzversicherung'),
|
||||||
created_by = models.ForeignKey(
|
('nbu', 'Nicht-Berufsunfallversicherung (NBU)'),
|
||||||
settings.AUTH_USER_MODEL,
|
('haftpflicht', 'Privathaftpflicht'),
|
||||||
on_delete=models.PROTECT,
|
('hausrat', 'Hausrat'),
|
||||||
related_name='created_households',
|
('mfz', 'MFZ-Haftpflicht'),
|
||||||
)
|
('rechtsschutz', 'Rechtsschutz'),
|
||||||
created_at = models.DateTimeField(auto_now_add=True)
|
('saule_3a', 'Säule 3a'),
|
||||||
|
('leben', 'Lebensversicherung'),
|
||||||
def __str__(self):
|
('reise', 'Reiseversicherung'),
|
||||||
return self.name
|
('other', 'Sonstiges'),
|
||||||
|
|
||||||
|
|
||||||
class HouseholdMembership(models.Model):
|
|
||||||
STATUS_CHOICES = [
|
|
||||||
('pending', 'Pending'),
|
|
||||||
('active', 'Active'),
|
|
||||||
('left', 'Left'),
|
|
||||||
]
|
]
|
||||||
ROLE_CHOICES = [
|
|
||||||
('member', 'Member'),
|
PERIOD_CHOICES = [
|
||||||
('admin', 'Admin'),
|
('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(
|
user = models.ForeignKey(
|
||||||
settings.AUTH_USER_MODEL,
|
settings.AUTH_USER_MODEL,
|
||||||
on_delete=models.CASCADE,
|
on_delete=models.CASCADE,
|
||||||
related_name='household_memberships',
|
related_name='insurances',
|
||||||
)
|
)
|
||||||
invited_by = models.ForeignKey(
|
insurance_type = models.CharField(max_length=30, choices=INSURANCE_TYPES)
|
||||||
settings.AUTH_USER_MODEL,
|
insurer = models.CharField(max_length=200)
|
||||||
on_delete=models.SET_NULL,
|
policy_number = models.CharField(max_length=100, blank=True, default='')
|
||||||
null=True,
|
premium = models.DecimalField(max_digits=10, decimal_places=2)
|
||||||
related_name='sent_invitations',
|
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)
|
||||||
status = models.CharField(max_length=10, choices=STATUS_CHOICES, default='pending')
|
deductible = models.DecimalField(max_digits=10, decimal_places=2, null=True, blank=True)
|
||||||
role = models.CharField(max_length=10, choices=ROLE_CHOICES, default='member')
|
valid_from = models.DateField(null=True, blank=True)
|
||||||
effective_from_year = models.PositiveSmallIntegerField(null=True, blank=True)
|
valid_until = models.DateField(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)
|
|
||||||
notes = models.TextField(blank=True, default='')
|
notes = models.TextField(blank=True, default='')
|
||||||
created_at = models.DateTimeField(auto_now_add=True)
|
created_at = models.DateTimeField(auto_now_add=True)
|
||||||
|
|
||||||
class Meta:
|
class Meta:
|
||||||
constraints = [
|
ordering = ['insurance_type']
|
||||||
models.CheckConstraint(
|
|
||||||
condition=(
|
def __str__(self):
|
||||||
models.Q(user__isnull=False, household__isnull=True) |
|
return f"{self.get_insurance_type_display()} – {self.insurer}"
|
||||||
models.Q(user__isnull=True, household__isnull=False)
|
|
||||||
),
|
|
||||||
name='financial_year_owner_exclusive',
|
class PraemienEntry(models.Model):
|
||||||
),
|
"""
|
||||||
models.UniqueConstraint(
|
Swiss health insurance average premium data from BAG / Priminfo.
|
||||||
fields=['user', 'year'],
|
Populated via management command: python manage.py import_praemien [year]
|
||||||
condition=models.Q(user__isnull=False),
|
Source: https://www.priminfo.admin.ch/downloads/praemienregionen_{year}.xlsx
|
||||||
name='unique_personal_financial_year',
|
"""
|
||||||
),
|
plz = models.CharField(max_length=10, db_index=True)
|
||||||
models.UniqueConstraint(
|
ort = models.CharField(max_length=200)
|
||||||
fields=['household', 'year'],
|
kanton = models.CharField(max_length=2)
|
||||||
condition=models.Q(household__isnull=False),
|
region = models.PositiveSmallIntegerField() # Prämienregion 0, 1, 2, or 3
|
||||||
name='unique_household_financial_year',
|
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):
|
def __str__(self):
|
||||||
owner = self.user or self.household
|
return (f"V{self.versicherer_id} {self.kanton} R{self.region} "
|
||||||
return f"{owner} — {self.year}"
|
f"{self.altersklasse} {self.tariftyp} {self.franchisestufe} → {self.praemie} CHF")
|
||||||
|
|
||||||
|
|
||||||
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})"
|
|
||||||
@@ -1,10 +1,6 @@
|
|||||||
from rest_framework import serializers
|
from rest_framework import serializers
|
||||||
from django.contrib.auth import get_user_model
|
from django.contrib.auth import get_user_model
|
||||||
from .models import (
|
from .models import Account, Transaction, Budget, Expense, Profile, Deadline, Insurance
|
||||||
Account, Transaction, Budget, Expense, Profile, Deadline,
|
|
||||||
Household, HouseholdMembership, PendingHouseholdInvite,
|
|
||||||
FinancialYear, YearlyIncome, YearlyBudgetItem,
|
|
||||||
)
|
|
||||||
|
|
||||||
User = get_user_model()
|
User = get_user_model()
|
||||||
|
|
||||||
@@ -48,10 +44,6 @@ class ExpenseSerializer(serializers.ModelSerializer):
|
|||||||
|
|
||||||
class ProfileSerializer(serializers.ModelSerializer):
|
class ProfileSerializer(serializers.ModelSerializer):
|
||||||
totp_enabled = serializers.BooleanField(read_only=True)
|
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:
|
class Meta:
|
||||||
model = Profile
|
model = Profile
|
||||||
@@ -64,74 +56,10 @@ class DeadlineSerializer(serializers.ModelSerializer):
|
|||||||
exclude = ['user']
|
exclude = ['user']
|
||||||
|
|
||||||
|
|
||||||
class HouseholdMembershipSerializer(serializers.ModelSerializer):
|
class InsuranceSerializer(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 Meta:
|
class Meta:
|
||||||
model = HouseholdMembership
|
model = Insurance
|
||||||
fields = ['id', 'user', 'user_email', 'invited_by_email', 'status', 'role',
|
exclude = ['user']
|
||||||
'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'
|
|
||||||
|
|
||||||
|
|
||||||
class RegisterSerializer(serializers.Serializer):
|
class RegisterSerializer(serializers.Serializer):
|
||||||
@@ -145,21 +73,8 @@ class RegisterSerializer(serializers.Serializer):
|
|||||||
|
|
||||||
def create(self, validated_data):
|
def create(self, validated_data):
|
||||||
email = validated_data['email']
|
email = validated_data['email']
|
||||||
user = User.objects.create_user(
|
return User.objects.create_user(
|
||||||
username=email,
|
username=email,
|
||||||
email=email,
|
email=email,
|
||||||
password=validated_data['password'],
|
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
@@ -1,5 +1,4 @@
|
|||||||
import base64
|
import base64
|
||||||
import datetime
|
|
||||||
import hmac
|
import hmac
|
||||||
import hashlib
|
import hashlib
|
||||||
import json
|
import json
|
||||||
@@ -16,23 +15,17 @@ from django.contrib.auth import get_user_model, authenticate
|
|||||||
from django.http import HttpResponse
|
from django.http import HttpResponse
|
||||||
from icalendar import Calendar as iCalendar, Event as iCalEvent
|
from icalendar import Calendar as iCalendar, Event as iCalEvent
|
||||||
|
|
||||||
from django.db import models
|
|
||||||
from rest_framework import viewsets, views, status
|
from rest_framework import viewsets, views, status
|
||||||
from rest_framework.response import Response
|
from rest_framework.response import Response
|
||||||
from rest_framework.permissions import AllowAny, IsAuthenticated
|
from rest_framework.permissions import AllowAny, IsAuthenticated
|
||||||
from rest_framework.throttling import AnonRateThrottle
|
from rest_framework.throttling import AnonRateThrottle
|
||||||
from rest_framework_simplejwt.tokens import RefreshToken
|
from rest_framework_simplejwt.tokens import RefreshToken
|
||||||
from rest_framework_simplejwt.exceptions import TokenError
|
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, Insurance, PraemienEntry, PraemienPolice
|
||||||
from .models import (
|
|
||||||
Account, Transaction, Budget, Expense, Profile, Deadline, ReadEvent, BackupCode, UserSession,
|
|
||||||
Household, HouseholdMembership, FinancialYear, YearlyIncome, YearlyBudgetItem,
|
|
||||||
)
|
|
||||||
from .serializers import (
|
from .serializers import (
|
||||||
AccountSerializer, TransactionSerializer, BudgetSerializer,
|
AccountSerializer, TransactionSerializer, BudgetSerializer,
|
||||||
ExpenseSerializer, ProfileSerializer, DeadlineSerializer, RegisterSerializer,
|
ExpenseSerializer, ProfileSerializer, DeadlineSerializer, RegisterSerializer,
|
||||||
HouseholdSerializer, HouseholdMembershipSerializer,
|
InsuranceSerializer,
|
||||||
FinancialYearSerializer, YearlyIncomeSerializer, YearlyBudgetItemSerializer,
|
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
@@ -119,6 +112,172 @@ class DeadlineViewSet(viewsets.ModelViewSet):
|
|||||||
serializer.save(user=self.request.user)
|
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):
|
class ProfileView(views.APIView):
|
||||||
def get(self, request):
|
def get(self, request):
|
||||||
profile, _ = Profile.objects.get_or_create(user=request.user)
|
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):
|
for session in UserSession.objects.filter(user=user):
|
||||||
_blacklist_session(session)
|
_blacklist_session(session)
|
||||||
return Response({'detail': 'Password updated.'})
|
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
|
|
||||||
@@ -14,7 +14,10 @@ import { ExpenseList } from './expenses/expense-list/expense-list';
|
|||||||
import { Profile } from './profile/profile';
|
import { Profile } from './profile/profile';
|
||||||
import { Settings } from './settings/settings';
|
import { Settings } from './settings/settings';
|
||||||
import { Calendar } from './calendar/calendar';
|
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 = [
|
export const routes: Routes = [
|
||||||
{ path: 'login', component: Login },
|
{ path: 'login', component: Login },
|
||||||
{ path: 'register', component: Register },
|
{ path: 'register', component: Register },
|
||||||
@@ -35,7 +38,10 @@ export const routes: Routes = [
|
|||||||
{ path: 'profile', component: Profile },
|
{ path: 'profile', component: Profile },
|
||||||
{ path: 'settings', component: Settings },
|
{ path: 'settings', component: Settings },
|
||||||
{ path: 'calendar', component: Calendar },
|
{ 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' },
|
{ path: '**', redirectTo: 'dashboard' },
|
||||||
|
|||||||
@@ -2,7 +2,6 @@ import { Component, OnInit, OnDestroy, AfterViewInit, signal } from '@angular/co
|
|||||||
import { CommonModule } from '@angular/common';
|
import { CommonModule } from '@angular/common';
|
||||||
import { TranslateModule, TranslateService } from '@ngx-translate/core';
|
import { TranslateModule, TranslateService } from '@ngx-translate/core';
|
||||||
import { ApiService } from '../services/api';
|
import { ApiService } from '../services/api';
|
||||||
import { FinancialYearService, FinancialYear } from '../services/financial-year';
|
|
||||||
import ApexCharts from 'apexcharts';
|
import ApexCharts from 'apexcharts';
|
||||||
import { Subscription } from 'rxjs';
|
import { Subscription } from 'rxjs';
|
||||||
|
|
||||||
@@ -14,11 +13,10 @@ import { Subscription } from 'rxjs';
|
|||||||
styleUrl: './dashboard.css',
|
styleUrl: './dashboard.css',
|
||||||
})
|
})
|
||||||
export class Dashboard implements OnInit, AfterViewInit, OnDestroy {
|
export class Dashboard implements OnInit, AfterViewInit, OnDestroy {
|
||||||
financialYears = signal<FinancialYear[]>([]);
|
accounts = signal<any[]>([]);
|
||||||
|
budgets = signal<any[]>([]);
|
||||||
expenses = signal<any[]>([]);
|
expenses = signal<any[]>([]);
|
||||||
transactions = signal<any[]>([]);
|
transactions = signal<any[]>([]);
|
||||||
budgets = signal<any[]>([]);
|
|
||||||
accounts = signal<any[]>([]);
|
|
||||||
donutExpanded = signal(false);
|
donutExpanded = signal(false);
|
||||||
selectedYear = signal(new Date().getFullYear());
|
selectedYear = signal(new Date().getFullYear());
|
||||||
yearDropdownOpen = signal(false);
|
yearDropdownOpen = signal(false);
|
||||||
@@ -34,18 +32,17 @@ export class Dashboard implements OnInit, AfterViewInit, OnDestroy {
|
|||||||
private barChart?: ApexCharts;
|
private barChart?: ApexCharts;
|
||||||
private donutChart?: ApexCharts;
|
private donutChart?: ApexCharts;
|
||||||
private dataLoaded = 0;
|
private dataLoaded = 0;
|
||||||
private readonly totalRequests = 5;
|
private readonly totalRequests = 4;
|
||||||
private timeInterval?: ReturnType<typeof setInterval>;
|
private timeInterval?: ReturnType<typeof setInterval>;
|
||||||
private langSub?: Subscription;
|
private langSub?: Subscription;
|
||||||
|
|
||||||
constructor(private api: ApiService, private fy: FinancialYearService, private translate: TranslateService) {}
|
constructor(private api: ApiService, private translate: TranslateService) {}
|
||||||
|
|
||||||
ngOnInit(): void {
|
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.getExpenses().subscribe({ next: (d) => { this.expenses.set(d); this.onDataLoaded(); } });
|
||||||
this.api.getTransactions().subscribe({ next: (d) => { this.transactions.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({
|
this.api.getProfile().subscribe({
|
||||||
next: (p) => {
|
next: (p) => {
|
||||||
@@ -103,10 +100,6 @@ export class Dashboard implements OnInit, AfterViewInit, OnDestroy {
|
|||||||
this.dateTimeDisplay.set(`${weekday}, ${date} | ${time}`);
|
this.dateTimeDisplay.set(`${weekday}, ${date} | ${time}`);
|
||||||
}
|
}
|
||||||
|
|
||||||
private financialYearFor(year: number): FinancialYear | undefined {
|
|
||||||
return this.financialYears().find((fy) => fy.year === year);
|
|
||||||
}
|
|
||||||
|
|
||||||
// KPIs
|
// KPIs
|
||||||
totalIncome(): number {
|
totalIncome(): number {
|
||||||
return this.accounts()
|
return this.accounts()
|
||||||
@@ -121,10 +114,7 @@ export class Dashboard implements OnInit, AfterViewInit, OnDestroy {
|
|||||||
}
|
}
|
||||||
|
|
||||||
totalExpenses(): number {
|
totalExpenses(): number {
|
||||||
const year = this.selectedYear();
|
return this.expenses().reduce((sum, e) => sum + parseFloat(e.amount), 0);
|
||||||
return this.expenses()
|
|
||||||
.filter((e) => new Date(e.date).getFullYear() === year)
|
|
||||||
.reduce((sum, e) => sum + parseFloat(e.amount), 0);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
balance(): number {
|
balance(): number {
|
||||||
@@ -172,23 +162,21 @@ export class Dashboard implements OnInit, AfterViewInit, OnDestroy {
|
|||||||
this.selectedYear.set(year);
|
this.selectedYear.set(year);
|
||||||
this.yearDropdownOpen.set(false);
|
this.yearDropdownOpen.set(false);
|
||||||
this.renderBarChart();
|
this.renderBarChart();
|
||||||
this.renderDonutChart();
|
|
||||||
}
|
}
|
||||||
|
|
||||||
availableYears(): number[] {
|
availableYears(): number[] {
|
||||||
const years = new Set<number>([new Date().getFullYear()]);
|
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);
|
return Array.from(years).sort((a, b) => b - a);
|
||||||
}
|
}
|
||||||
|
|
||||||
donutItems(): { name: string; amount: number; pct: string; color: string }[] {
|
donutItems(): { name: string; amount: number; pct: string; color: string }[] {
|
||||||
const items = this.budgets().filter((b) => b.active);
|
const active = this.budgets().filter((b) => b.active);
|
||||||
const total = items.reduce((sum, b) => sum + +b.amount, 0);
|
const total = active.reduce((sum, b) => sum + parseFloat(b.amount), 0);
|
||||||
return items.map((b, i) => ({
|
return active.map((b, i) => ({
|
||||||
name: b.name,
|
name: b.name,
|
||||||
amount: +b.amount,
|
amount: parseFloat(b.amount),
|
||||||
pct: total > 0 ? ((+b.amount / total) * 100).toFixed(1) : '0',
|
pct: total > 0 ? ((parseFloat(b.amount) / total) * 100).toFixed(1) : '0',
|
||||||
color: this.donutColors[i % this.donutColors.length],
|
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 active = this.budgets().filter((b) => b.active);
|
||||||
const labels = active.map((b) => b.name);
|
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;
|
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');
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -106,26 +106,6 @@
|
|||||||
</a>
|
</a>
|
||||||
</li>
|
</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 -->
|
<!-- Accounts -->
|
||||||
<li class="relative">
|
<li class="relative">
|
||||||
@if (sidebarService.collapsed()) {
|
@if (sidebarService.collapsed()) {
|
||||||
@@ -177,6 +157,67 @@
|
|||||||
}
|
}
|
||||||
</li>
|
</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>
|
</ul>
|
||||||
|
|
||||||
<!-- Mobile: Notifications, Theme, Profile, Logout (hidden on desktop — those are in the navbar) -->
|
<!-- Mobile: Notifications, Theme, Profile, Logout (hidden on desktop — those are in the navbar) -->
|
||||||
|
|||||||
@@ -23,10 +23,6 @@ export class ApiService {
|
|||||||
return this.http.put(`${this.baseUrl}/accounts/${id}/`, account);
|
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> {
|
deleteAccount(id: number): Observable<any> {
|
||||||
return this.http.delete(`${this.baseUrl}/accounts/${id}/`);
|
return this.http.delete(`${this.baseUrl}/accounts/${id}/`);
|
||||||
}
|
}
|
||||||
@@ -198,4 +194,43 @@ export class ApiService {
|
|||||||
confirmPasswordReset(token: string, password: string): Observable<any> {
|
confirmPasswordReset(token: string, password: string): Observable<any> {
|
||||||
return this.http.post(`${this.baseUrl}/auth/password-reset/confirm/`, { token, password });
|
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}/`);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
@@ -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/`);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -9,6 +9,7 @@ export class SidebarService {
|
|||||||
mobileOpen = signal(false);
|
mobileOpen = signal(false);
|
||||||
budgetsOpen = signal(false);
|
budgetsOpen = signal(false);
|
||||||
accountsOpen = signal(false);
|
accountsOpen = signal(false);
|
||||||
|
insuranceOpen = signal(false);
|
||||||
|
|
||||||
toggle() {
|
toggle() {
|
||||||
this.collapsed.update(v => !v);
|
this.collapsed.update(v => !v);
|
||||||
@@ -31,6 +32,10 @@ export class SidebarService {
|
|||||||
this.accountsOpen.update(v => !v);
|
this.accountsOpen.update(v => !v);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
toggleInsurance() {
|
||||||
|
this.insuranceOpen.update(v => !v);
|
||||||
|
}
|
||||||
|
|
||||||
toggleFlyout(name: string) {
|
toggleFlyout(name: string) {
|
||||||
this.openFlyout.update(current => current === name ? null : name);
|
this.openFlyout.update(current => current === name ? null : name);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -136,17 +136,6 @@
|
|||||||
"transactions": "Transaktionen",
|
"transactions": "Transaktionen",
|
||||||
"deadlines": "Termine"
|
"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": {
|
"dashboard": {
|
||||||
"title": "Dashboard",
|
"title": "Dashboard",
|
||||||
"subtitle": "Finanzübersicht",
|
"subtitle": "Finanzübersicht",
|
||||||
@@ -346,74 +335,6 @@
|
|||||||
"ZG": "Zug",
|
"ZG": "Zug",
|
||||||
"ZH": "Zürich"
|
"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": {
|
"profile": {
|
||||||
"title": "Profil",
|
"title": "Profil",
|
||||||
"subtitle": "Persönliche Informationen und Einstellungen verwalten",
|
"subtitle": "Persönliche Informationen und Einstellungen verwalten",
|
||||||
@@ -469,5 +390,134 @@
|
|||||||
"password_too_short": "Passwort muss mindestens 8 Zeichen lang sein.",
|
"password_too_short": "Passwort muss mindestens 8 Zeichen lang sein.",
|
||||||
"password_failed": "Passwort konnte nicht aktualisiert werden."
|
"password_failed": "Passwort konnte nicht aktualisiert werden."
|
||||||
}
|
}
|
||||||
|
},
|
||||||
|
"sidebar": {
|
||||||
|
"dashboard": "Dashboard",
|
||||||
|
"budgets": "Budget",
|
||||||
|
"fixed_costs": "Fixkosten",
|
||||||
|
"expenses": "Ausgaben",
|
||||||
|
"calendar": "Kalender",
|
||||||
|
"accounts": "Konten",
|
||||||
|
"revenue_accounts": "Einnahmenkonten",
|
||||||
|
"transactions": "Transaktionen",
|
||||||
|
"insurance": "Versicherungen",
|
||||||
|
"insurance_overview": "Übersicht",
|
||||||
|
"insurance_documents": "Dokumente",
|
||||||
|
"insurance_analyse": "Analyse",
|
||||||
|
"insurance_priminfo": "Priminfo"
|
||||||
|
},
|
||||||
|
"insurance": {
|
||||||
|
"title": "Versicherungen",
|
||||||
|
"subtitle": "Deine aktuelle Versicherungssituation — Ist-Situation",
|
||||||
|
"add": "Versicherung hinzufügen",
|
||||||
|
"create_title": "Versicherung erfassen",
|
||||||
|
"edit_title": "Versicherung bearbeiten",
|
||||||
|
"list_title": "Deine Versicherungen",
|
||||||
|
"no_entries": "Noch keine Versicherungen erfasst.",
|
||||||
|
"loading": "Laden...",
|
||||||
|
"kpi_monthly": "Monatliche Prämien total",
|
||||||
|
"kpi_count": "Policen",
|
||||||
|
"kpi_covered": "Abgedeckte Typen",
|
||||||
|
"checklist_title": "Empfohlene Mindestabdeckung",
|
||||||
|
"checklist_hint": "Diese vier Versicherungen gelten in der Schweiz als Mindeststandard für jede Person.",
|
||||||
|
"label_type": "Versicherungsart",
|
||||||
|
"label_insurer": "Versicherer",
|
||||||
|
"label_policy_number": "Policennummer",
|
||||||
|
"label_premium": "Prämie (CHF)",
|
||||||
|
"label_period": "Zahlungsrhythmus",
|
||||||
|
"label_coverage": "Deckungssumme (CHF)",
|
||||||
|
"label_deductible": "Franchise (CHF)",
|
||||||
|
"label_valid_from": "Gültig ab",
|
||||||
|
"label_valid_until": "Gültig bis",
|
||||||
|
"label_notes": "Notizen",
|
||||||
|
"placeholder_insurer": "z.B. Helsana, AXA, CSS",
|
||||||
|
"placeholder_policy_number": "z.B. 12345678",
|
||||||
|
"month_short": "Mt.",
|
||||||
|
"types": {
|
||||||
|
"kvg": "Krankenkasse (KVG)",
|
||||||
|
"kk_zusatz": "KK-Zusatzversicherung",
|
||||||
|
"nbu": "Unfallversicherung (NBU)",
|
||||||
|
"haftpflicht": "Privathaftpflicht",
|
||||||
|
"hausrat": "Hausrat",
|
||||||
|
"mfz": "MFZ-Haftpflicht",
|
||||||
|
"rechtsschutz": "Rechtsschutz",
|
||||||
|
"saule_3a": "Säule 3a",
|
||||||
|
"leben": "Lebensversicherung",
|
||||||
|
"reise": "Reiseversicherung",
|
||||||
|
"other": "Sonstiges"
|
||||||
|
},
|
||||||
|
"period": {
|
||||||
|
"monthly": "Monatlich",
|
||||||
|
"quarterly": "Vierteljährlich",
|
||||||
|
"semi_annual": "Halbjährlich",
|
||||||
|
"annual": "Jährlich"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"insurance_docs": {
|
||||||
|
"title": "Versicherungsdokumente",
|
||||||
|
"subtitle": "Lade deine Policen hoch und lass sie von der KI analysieren",
|
||||||
|
"coming_soon_title": "PDF-Upload & KI-Analyse — Demnächst",
|
||||||
|
"coming_soon_text": "Lade deine Versicherungspolicen als PDF hoch. Die KI (Claude) extrahiert automatisch die wichtigsten Informationen."
|
||||||
|
},
|
||||||
|
"insurance_analyse": {
|
||||||
|
"title": "Versicherungsanalyse",
|
||||||
|
"subtitle": "Soll/Kann — Was brauchst du wirklich?",
|
||||||
|
"coming_soon_title": "Deckungsanalyse — Demnächst",
|
||||||
|
"coming_soon_text": "Vergleiche deine aktuelle Versicherungssituation mit Schweizer Empfehlungen und erkenne Lücken.",
|
||||||
|
"tag_soll": "Soll-Situation",
|
||||||
|
"tag_gaps": "Deckungslücken",
|
||||||
|
"tag_recommendations": "Empfehlungen"
|
||||||
|
},
|
||||||
|
"priminfo": {
|
||||||
|
"title": "Priminfo — KVG-Prämienrechner",
|
||||||
|
"subtitle": "Durchschnittliche Monatsprämien nach PLZ, basierend auf BAG-Daten",
|
||||||
|
"plz_label": "Postleitzahl eingeben",
|
||||||
|
"plz_placeholder": "z.B. 8001",
|
||||||
|
"plz_hint": "4-stellige Schweizer Postleitzahl",
|
||||||
|
"search": "Suchen",
|
||||||
|
"region_label": "Prämienregion",
|
||||||
|
"col_child": "Kinder",
|
||||||
|
"col_young": "Junge Erwachsene",
|
||||||
|
"col_adult": "Erwachsene",
|
||||||
|
"month": "Monat",
|
||||||
|
"disclaimer": "Ø-Monatsprämien {{ year }} (alle Versicherer, alle Modelle). Quelle: BAG / Priminfo.",
|
||||||
|
"multi_ort_hint": "Diese PLZ liegt in mehreren Gemeinden oder Regionen.",
|
||||||
|
"error_not_found": "Keine Daten für diese PLZ gefunden.",
|
||||||
|
"no_results": "Keine Resultate.",
|
||||||
|
"cta_title": "Weitere Details auf priminfo.admin.ch",
|
||||||
|
"cta_text": "Jahreskosten, historische Prämienverläufe und Zusatzversicherungen auf der offiziellen BAG-Seite.",
|
||||||
|
"cta_btn": "Jetzt auf Priminfo vergleichen",
|
||||||
|
"info_title": "Was zeigt dieser Rechner?",
|
||||||
|
"info_1": "Die Prämien sind kantonale Durchschnittswerte über alle Versicherer und alle Versicherungsmodelle (Standard, Hausarzt, HMO, Telemed).",
|
||||||
|
"info_2": "Die Prämienregion (1, 2 oder 3) bestimmt die Prämienhöhe innerhalb eines Kantons — Region 1 ist meist am teuersten.",
|
||||||
|
"info_3": "Für den genauen Prämienvergleich nach Versicherer und Franchise empfehlen wir den offiziellen Rechner auf priminfo.admin.ch.",
|
||||||
|
"source": "Quelle: BAG — ",
|
||||||
|
"vergleich_card_title": "Versicherer vergleichen",
|
||||||
|
"vergleich_card_subtitle": "Granulare Prämien nach Versicherer, Modell und Franchise",
|
||||||
|
"geburtsjahr_label": "Geburtsjahr",
|
||||||
|
"geburtsjahr_placeholder": "z.B. 1990",
|
||||||
|
"modell_label": "Versicherungsmodell",
|
||||||
|
"modell_base": "Standard",
|
||||||
|
"modell_ham": "Hausarzt (HÄM)",
|
||||||
|
"modell_hmo": "HMO",
|
||||||
|
"modell_div": "Andere Modelle",
|
||||||
|
"franchise_label": "Franchise",
|
||||||
|
"unfall_label": "Unfalldeckung",
|
||||||
|
"unfall_ohn": "Ohne Unfall",
|
||||||
|
"unfall_mit": "Mit Unfall",
|
||||||
|
"unfall_note": "Angestellte können die Unfalldeckung weglassen, wenn der Arbeitgeber eine NBU-Versicherung abschliesst.",
|
||||||
|
"vergleich_btn": "Versicherer vergleichen",
|
||||||
|
"col_rank": "#",
|
||||||
|
"col_insurer": "Versicherer",
|
||||||
|
"col_model": "Modell",
|
||||||
|
"col_franchise": "Franchise",
|
||||||
|
"col_premium": "Prämie/Mt.",
|
||||||
|
"cheapest_badge": "Günstigste",
|
||||||
|
"vergleich_data_year": "Prämien {{ year }}",
|
||||||
|
"vergleich_no_results": "Keine Versicherer für diese Kombination gefunden. Andere Modell- oder Franchisewahl versuchen.",
|
||||||
|
"vergleich_hint": "Versicherer, sortiert nach Prämie aufsteigend.",
|
||||||
|
"age_child": "Kind (≤ 18 J.)",
|
||||||
|
"age_young": "Junger Erw. (19–25 J.)",
|
||||||
|
"age_adult": "Erwachsener (≥ 26 J.)"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -136,17 +136,6 @@
|
|||||||
"transactions": "Transactions",
|
"transactions": "Transactions",
|
||||||
"deadlines": "Deadlines"
|
"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": {
|
"dashboard": {
|
||||||
"title": "Dashboard",
|
"title": "Dashboard",
|
||||||
"subtitle": "Financial overview",
|
"subtitle": "Financial overview",
|
||||||
@@ -346,74 +335,6 @@
|
|||||||
"ZG": "Zug",
|
"ZG": "Zug",
|
||||||
"ZH": "Zuerich"
|
"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": {
|
"profile": {
|
||||||
"title": "Profile",
|
"title": "Profile",
|
||||||
"subtitle": "Manage your personal information and settings",
|
"subtitle": "Manage your personal information and settings",
|
||||||
@@ -469,5 +390,134 @@
|
|||||||
"password_too_short": "Password must be at least 8 characters.",
|
"password_too_short": "Password must be at least 8 characters.",
|
||||||
"password_failed": "Failed to update password."
|
"password_failed": "Failed to update password."
|
||||||
}
|
}
|
||||||
|
},
|
||||||
|
"sidebar": {
|
||||||
|
"dashboard": "Dashboard",
|
||||||
|
"budgets": "Budgets",
|
||||||
|
"fixed_costs": "Fixed Costs",
|
||||||
|
"expenses": "Expenses",
|
||||||
|
"calendar": "Calendar",
|
||||||
|
"accounts": "Accounts",
|
||||||
|
"revenue_accounts": "Revenue Accounts",
|
||||||
|
"transactions": "Transactions",
|
||||||
|
"insurance": "Insurances",
|
||||||
|
"insurance_overview": "Overview",
|
||||||
|
"insurance_documents": "Documents",
|
||||||
|
"insurance_analyse": "Analysis",
|
||||||
|
"insurance_priminfo": "Priminfo"
|
||||||
|
},
|
||||||
|
"insurance": {
|
||||||
|
"title": "Insurances",
|
||||||
|
"subtitle": "Your current insurance coverage — Ist-Situation",
|
||||||
|
"add": "Add Insurance",
|
||||||
|
"create_title": "Add Insurance",
|
||||||
|
"edit_title": "Edit Insurance",
|
||||||
|
"list_title": "Your Insurances",
|
||||||
|
"no_entries": "No insurances recorded yet.",
|
||||||
|
"loading": "Loading...",
|
||||||
|
"kpi_monthly": "Monthly Total",
|
||||||
|
"kpi_count": "Policies",
|
||||||
|
"kpi_covered": "Types Covered",
|
||||||
|
"checklist_title": "Recommended Coverage",
|
||||||
|
"checklist_hint": "These four insurances are the Swiss minimum recommended for every person.",
|
||||||
|
"label_type": "Insurance Type",
|
||||||
|
"label_insurer": "Insurer",
|
||||||
|
"label_policy_number": "Policy Number",
|
||||||
|
"label_premium": "Premium (CHF)",
|
||||||
|
"label_period": "Period",
|
||||||
|
"label_coverage": "Coverage (CHF)",
|
||||||
|
"label_deductible": "Deductible (CHF)",
|
||||||
|
"label_valid_from": "Valid From",
|
||||||
|
"label_valid_until": "Valid Until",
|
||||||
|
"label_notes": "Notes",
|
||||||
|
"placeholder_insurer": "e.g. Helsana, AXA, CSS",
|
||||||
|
"placeholder_policy_number": "e.g. 12345678",
|
||||||
|
"month_short": "mo",
|
||||||
|
"types": {
|
||||||
|
"kvg": "Health Insurance (KVG)",
|
||||||
|
"kk_zusatz": "Supplemental Health",
|
||||||
|
"nbu": "Accident (NBU)",
|
||||||
|
"haftpflicht": "Private Liability",
|
||||||
|
"hausrat": "Household Contents",
|
||||||
|
"mfz": "Vehicle Liability",
|
||||||
|
"rechtsschutz": "Legal Protection",
|
||||||
|
"saule_3a": "Pillar 3a",
|
||||||
|
"leben": "Life Insurance",
|
||||||
|
"reise": "Travel Insurance",
|
||||||
|
"other": "Other"
|
||||||
|
},
|
||||||
|
"period": {
|
||||||
|
"monthly": "Monthly",
|
||||||
|
"quarterly": "Quarterly",
|
||||||
|
"semi_annual": "Semi-annual",
|
||||||
|
"annual": "Annual"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"insurance_docs": {
|
||||||
|
"title": "Insurance Documents",
|
||||||
|
"subtitle": "Upload and analyse your insurance policies with AI",
|
||||||
|
"coming_soon_title": "PDF Upload & AI Analysis — Coming Soon",
|
||||||
|
"coming_soon_text": "Upload your insurance policies as PDF. The AI (Claude) will extract the most important information automatically."
|
||||||
|
},
|
||||||
|
"insurance_analyse": {
|
||||||
|
"title": "Insurance Analysis",
|
||||||
|
"subtitle": "Soll/Kann — What coverage do you need?",
|
||||||
|
"coming_soon_title": "Coverage Analysis — Coming Soon",
|
||||||
|
"coming_soon_text": "Compare your current coverage with Swiss recommendations and identify gaps.",
|
||||||
|
"tag_soll": "Target Coverage",
|
||||||
|
"tag_gaps": "Coverage Gaps",
|
||||||
|
"tag_recommendations": "Recommendations"
|
||||||
|
},
|
||||||
|
"priminfo": {
|
||||||
|
"title": "Priminfo — KVG Premium Calculator",
|
||||||
|
"subtitle": "Average monthly premiums by postal code, based on FOPH data",
|
||||||
|
"plz_label": "Enter postal code",
|
||||||
|
"plz_placeholder": "e.g. 8001",
|
||||||
|
"plz_hint": "4-digit Swiss postal code",
|
||||||
|
"search": "Search",
|
||||||
|
"region_label": "Premium Region",
|
||||||
|
"col_child": "Children",
|
||||||
|
"col_young": "Young Adults",
|
||||||
|
"col_adult": "Adults",
|
||||||
|
"month": "month",
|
||||||
|
"disclaimer": "Avg. monthly premiums {{ year }} (all insurers, all models). Source: FOPH / Priminfo.",
|
||||||
|
"multi_ort_hint": "This postal code covers multiple municipalities or regions.",
|
||||||
|
"error_not_found": "No data found for this postal code.",
|
||||||
|
"no_results": "No results.",
|
||||||
|
"cta_title": "More details at priminfo.admin.ch",
|
||||||
|
"cta_text": "Annual costs, premium history and supplemental insurance on the official FOPH website.",
|
||||||
|
"cta_btn": "Compare on Priminfo",
|
||||||
|
"info_title": "What does this calculator show?",
|
||||||
|
"info_1": "Premiums are cantonal averages across all insurers and all insurance models (standard, family doctor, HMO, telemedicine).",
|
||||||
|
"info_2": "The premium region (1, 2 or 3) determines the premium level within a canton — region 1 is usually the most expensive.",
|
||||||
|
"info_3": "For a precise comparison by insurer and deductible, we recommend the official calculator at priminfo.admin.ch.",
|
||||||
|
"source": "Source: FOPH — ",
|
||||||
|
"vergleich_card_title": "Compare Insurers",
|
||||||
|
"vergleich_card_subtitle": "Granular premiums by insurer, model and deductible",
|
||||||
|
"geburtsjahr_label": "Year of Birth",
|
||||||
|
"geburtsjahr_placeholder": "e.g. 1990",
|
||||||
|
"modell_label": "Insurance Model",
|
||||||
|
"modell_base": "Standard",
|
||||||
|
"modell_ham": "GP Model (HÄM)",
|
||||||
|
"modell_hmo": "HMO",
|
||||||
|
"modell_div": "Other Models",
|
||||||
|
"franchise_label": "Deductible",
|
||||||
|
"unfall_label": "Accident Coverage",
|
||||||
|
"unfall_ohn": "Without Accident",
|
||||||
|
"unfall_mit": "With Accident",
|
||||||
|
"unfall_note": "Employees can exclude accident coverage if their employer has a non-occupational accident insurance (NBIA).",
|
||||||
|
"vergleich_btn": "Compare Insurers",
|
||||||
|
"col_rank": "#",
|
||||||
|
"col_insurer": "Insurer",
|
||||||
|
"col_model": "Model",
|
||||||
|
"col_franchise": "Deductible",
|
||||||
|
"col_premium": "Premium/Mo.",
|
||||||
|
"cheapest_badge": "Cheapest",
|
||||||
|
"vergleich_data_year": "Premiums {{ year }}",
|
||||||
|
"vergleich_no_results": "No insurers found for this combination. Try a different model or deductible.",
|
||||||
|
"vergleich_hint": "Insurers sorted by premium ascending.",
|
||||||
|
"age_child": "Child (≤ 18 yrs)",
|
||||||
|
"age_young": "Young Adult (19–25 yrs)",
|
||||||
|
"age_adult": "Adult (≥ 26 yrs)"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -136,17 +136,6 @@
|
|||||||
"transactions": "Transactions",
|
"transactions": "Transactions",
|
||||||
"deadlines": "Échéances"
|
"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": {
|
"dashboard": {
|
||||||
"title": "Tableau de bord",
|
"title": "Tableau de bord",
|
||||||
"subtitle": "Aperçu financier",
|
"subtitle": "Aperçu financier",
|
||||||
@@ -346,74 +335,6 @@
|
|||||||
"ZG": "Zoug",
|
"ZG": "Zoug",
|
||||||
"ZH": "Zurich"
|
"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": {
|
"profile": {
|
||||||
"title": "Profil",
|
"title": "Profil",
|
||||||
"subtitle": "Gérer vos informations personnelles et paramètres",
|
"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_too_short": "Le mot de passe doit comporter au moins 8 caractères.",
|
||||||
"password_failed": "Échec de la mise à jour du mot de passe."
|
"password_failed": "Échec de la mise à jour du mot de passe."
|
||||||
}
|
}
|
||||||
|
},
|
||||||
|
"sidebar": {
|
||||||
|
"dashboard": "Tableau de bord",
|
||||||
|
"budgets": "Budgets",
|
||||||
|
"fixed_costs": "Charges fixes",
|
||||||
|
"expenses": "Dépenses",
|
||||||
|
"calendar": "Calendrier",
|
||||||
|
"accounts": "Comptes",
|
||||||
|
"revenue_accounts": "Comptes de revenus",
|
||||||
|
"transactions": "Transactions",
|
||||||
|
"insurance": "Assurances",
|
||||||
|
"insurance_overview": "Aperçu",
|
||||||
|
"insurance_documents": "Documents",
|
||||||
|
"insurance_analyse": "Analyse",
|
||||||
|
"insurance_priminfo": "Priminfo"
|
||||||
|
},
|
||||||
|
"insurance": {
|
||||||
|
"title": "Assurances",
|
||||||
|
"subtitle": "Votre couverture d'assurance actuelle — Situation actuelle",
|
||||||
|
"add": "Ajouter une assurance",
|
||||||
|
"create_title": "Nouvelle assurance",
|
||||||
|
"edit_title": "Modifier l'assurance",
|
||||||
|
"list_title": "Vos assurances",
|
||||||
|
"no_entries": "Aucune assurance enregistrée.",
|
||||||
|
"loading": "Chargement...",
|
||||||
|
"kpi_monthly": "Total mensuel",
|
||||||
|
"kpi_count": "Polices",
|
||||||
|
"kpi_covered": "Types couverts",
|
||||||
|
"checklist_title": "Couverture minimale recommandée",
|
||||||
|
"checklist_hint": "Ces quatre assurances constituent le minimum recommandé en Suisse pour chaque personne.",
|
||||||
|
"label_type": "Type d'assurance",
|
||||||
|
"label_insurer": "Assureur",
|
||||||
|
"label_policy_number": "Numéro de police",
|
||||||
|
"label_premium": "Prime (CHF)",
|
||||||
|
"label_period": "Fréquence de paiement",
|
||||||
|
"label_coverage": "Montant assuré (CHF)",
|
||||||
|
"label_deductible": "Franchise (CHF)",
|
||||||
|
"label_valid_from": "Valable dès",
|
||||||
|
"label_valid_until": "Valable jusqu'au",
|
||||||
|
"label_notes": "Remarques",
|
||||||
|
"placeholder_insurer": "ex. Helsana, AXA, CSS",
|
||||||
|
"placeholder_policy_number": "ex. 12345678",
|
||||||
|
"month_short": "mois",
|
||||||
|
"types": {
|
||||||
|
"kvg": "Assurance maladie (LAMal)",
|
||||||
|
"kk_zusatz": "Assurance complémentaire",
|
||||||
|
"nbu": "Accident (LAA)",
|
||||||
|
"haftpflicht": "Responsabilité civile",
|
||||||
|
"hausrat": "Ménage",
|
||||||
|
"mfz": "RC véhicule",
|
||||||
|
"rechtsschutz": "Protection juridique",
|
||||||
|
"saule_3a": "Pilier 3a",
|
||||||
|
"leben": "Assurance vie",
|
||||||
|
"reise": "Assurance voyage",
|
||||||
|
"other": "Autre"
|
||||||
|
},
|
||||||
|
"period": {
|
||||||
|
"monthly": "Mensuel",
|
||||||
|
"quarterly": "Trimestriel",
|
||||||
|
"semi_annual": "Semestriel",
|
||||||
|
"annual": "Annuel"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"insurance_docs": {
|
||||||
|
"title": "Documents d'assurance",
|
||||||
|
"subtitle": "Téléchargez vos polices et faites-les analyser par l'IA",
|
||||||
|
"coming_soon_title": "Upload PDF & analyse IA — Bientôt disponible",
|
||||||
|
"coming_soon_text": "Téléchargez vos polices d'assurance en PDF. L'IA (Claude) extrait automatiquement les informations les plus importantes."
|
||||||
|
},
|
||||||
|
"insurance_analyse": {
|
||||||
|
"title": "Analyse des assurances",
|
||||||
|
"subtitle": "Cible — Quelle couverture vous faut-il ?",
|
||||||
|
"coming_soon_title": "Analyse de couverture — Bientôt disponible",
|
||||||
|
"coming_soon_text": "Comparez votre situation actuelle avec les recommandations suisses et identifiez les lacunes.",
|
||||||
|
"tag_soll": "Situation cible",
|
||||||
|
"tag_gaps": "Lacunes",
|
||||||
|
"tag_recommendations": "Recommandations"
|
||||||
|
},
|
||||||
|
"priminfo": {
|
||||||
|
"title": "Priminfo — Calculateur de primes LAMal",
|
||||||
|
"subtitle": "Primes mensuelles moyennes par NPA, basées sur les données de l'OFSP",
|
||||||
|
"plz_label": "Entrez votre NPA",
|
||||||
|
"plz_placeholder": "ex. 8001",
|
||||||
|
"plz_hint": "Code postal suisse à 4 chiffres",
|
||||||
|
"search": "Rechercher",
|
||||||
|
"region_label": "Région de primes",
|
||||||
|
"col_child": "Enfants",
|
||||||
|
"col_young": "Jeunes adultes",
|
||||||
|
"col_adult": "Adultes",
|
||||||
|
"month": "mois",
|
||||||
|
"disclaimer": "Primes mensuelles moyennes {{ year }} (tous assureurs, tous modèles). Source : OFSP / Priminfo.",
|
||||||
|
"multi_ort_hint": "Ce NPA couvre plusieurs communes ou régions.",
|
||||||
|
"error_not_found": "Aucune donnée trouvée pour ce NPA.",
|
||||||
|
"no_results": "Aucun résultat.",
|
||||||
|
"cta_title": "Plus de détails sur priminfo.admin.ch",
|
||||||
|
"cta_text": "Coûts annuels, historique des primes et assurances complémentaires sur le site officiel de l'OFSP.",
|
||||||
|
"cta_btn": "Comparer sur Priminfo",
|
||||||
|
"info_title": "Que montre ce calculateur ?",
|
||||||
|
"info_1": "Les primes sont des moyennes cantonales calculées sur l'ensemble des assureurs et des modèles d'assurance (standard, médecin de famille, HMO, télémédecine).",
|
||||||
|
"info_2": "La région de primes (1, 2 ou 3) détermine le niveau des primes au sein d'un canton — la région 1 est généralement la plus chère.",
|
||||||
|
"info_3": "Pour une comparaison précise par assureur et par franchise, nous recommandons le calculateur officiel sur priminfo.admin.ch.",
|
||||||
|
"source": "Source : OFSP — ",
|
||||||
|
"vergleich_card_title": "Comparer les assureurs",
|
||||||
|
"vergleich_card_subtitle": "Primes détaillées par assureur, modèle et franchise",
|
||||||
|
"geburtsjahr_label": "Année de naissance",
|
||||||
|
"geburtsjahr_placeholder": "p.ex. 1990",
|
||||||
|
"modell_label": "Modèle d'assurance",
|
||||||
|
"modell_base": "Standard",
|
||||||
|
"modell_ham": "Médecin de famille",
|
||||||
|
"modell_hmo": "HMO",
|
||||||
|
"modell_div": "Autres modèles",
|
||||||
|
"franchise_label": "Franchise",
|
||||||
|
"unfall_label": "Couverture accidents",
|
||||||
|
"unfall_ohn": "Sans accidents",
|
||||||
|
"unfall_mit": "Avec accidents",
|
||||||
|
"unfall_note": "Les salariés peuvent exclure la couverture accidents si l'employeur a souscrit une assurance LAA.",
|
||||||
|
"vergleich_btn": "Comparer les assureurs",
|
||||||
|
"col_rank": "#",
|
||||||
|
"col_insurer": "Assureur",
|
||||||
|
"col_model": "Modèle",
|
||||||
|
"col_franchise": "Franchise",
|
||||||
|
"col_premium": "Prime/mois",
|
||||||
|
"cheapest_badge": "La moins chère",
|
||||||
|
"vergleich_data_year": "Primes {{ year }}",
|
||||||
|
"vergleich_no_results": "Aucun assureur trouvé pour cette combinaison. Essayez un autre modèle ou une autre franchise.",
|
||||||
|
"vergleich_hint": "Assureurs triés par prime croissante.",
|
||||||
|
"age_child": "Enfant (≤ 18 ans)",
|
||||||
|
"age_young": "Jeune adulte (19–25 ans)",
|
||||||
|
"age_adult": "Adulte (≥ 26 ans)"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -136,17 +136,6 @@
|
|||||||
"transactions": "Transazioni",
|
"transactions": "Transazioni",
|
||||||
"deadlines": "Scadenze"
|
"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": {
|
"dashboard": {
|
||||||
"title": "Dashboard",
|
"title": "Dashboard",
|
||||||
"subtitle": "Panoramica finanziaria",
|
"subtitle": "Panoramica finanziaria",
|
||||||
@@ -346,74 +335,6 @@
|
|||||||
"ZG": "Zugo",
|
"ZG": "Zugo",
|
||||||
"ZH": "Zurigo"
|
"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": {
|
"profile": {
|
||||||
"title": "Profilo",
|
"title": "Profilo",
|
||||||
"subtitle": "Gestisci le tue informazioni personali e impostazioni",
|
"subtitle": "Gestisci le tue informazioni personali e impostazioni",
|
||||||
@@ -469,5 +390,134 @@
|
|||||||
"password_too_short": "La password deve contenere almeno 8 caratteri.",
|
"password_too_short": "La password deve contenere almeno 8 caratteri.",
|
||||||
"password_failed": "Aggiornamento password fallito."
|
"password_failed": "Aggiornamento password fallito."
|
||||||
}
|
}
|
||||||
|
},
|
||||||
|
"sidebar": {
|
||||||
|
"dashboard": "Dashboard",
|
||||||
|
"budgets": "Budget",
|
||||||
|
"fixed_costs": "Costi fissi",
|
||||||
|
"expenses": "Spese",
|
||||||
|
"calendar": "Calendario",
|
||||||
|
"accounts": "Conti",
|
||||||
|
"revenue_accounts": "Conti entrate",
|
||||||
|
"transactions": "Transazioni",
|
||||||
|
"insurance": "Assicurazioni",
|
||||||
|
"insurance_overview": "Panoramica",
|
||||||
|
"insurance_documents": "Documenti",
|
||||||
|
"insurance_analyse": "Analisi",
|
||||||
|
"insurance_priminfo": "Priminfo"
|
||||||
|
},
|
||||||
|
"insurance": {
|
||||||
|
"title": "Assicurazioni",
|
||||||
|
"subtitle": "La tua copertura assicurativa attuale — Situazione attuale",
|
||||||
|
"add": "Aggiungi assicurazione",
|
||||||
|
"create_title": "Nuova assicurazione",
|
||||||
|
"edit_title": "Modifica assicurazione",
|
||||||
|
"list_title": "Le tue assicurazioni",
|
||||||
|
"no_entries": "Nessuna assicurazione registrata.",
|
||||||
|
"loading": "Caricamento...",
|
||||||
|
"kpi_monthly": "Totale mensile",
|
||||||
|
"kpi_count": "Polizze",
|
||||||
|
"kpi_covered": "Tipi coperti",
|
||||||
|
"checklist_title": "Copertura minima raccomandata",
|
||||||
|
"checklist_hint": "Queste quattro assicurazioni sono il minimo raccomandato in Svizzera per ogni persona.",
|
||||||
|
"label_type": "Tipo di assicurazione",
|
||||||
|
"label_insurer": "Assicuratore",
|
||||||
|
"label_policy_number": "Numero polizza",
|
||||||
|
"label_premium": "Premio (CHF)",
|
||||||
|
"label_period": "Frequenza di pagamento",
|
||||||
|
"label_coverage": "Somma assicurata (CHF)",
|
||||||
|
"label_deductible": "Franchigia (CHF)",
|
||||||
|
"label_valid_from": "Valido dal",
|
||||||
|
"label_valid_until": "Valido fino al",
|
||||||
|
"label_notes": "Note",
|
||||||
|
"placeholder_insurer": "es. Helsana, AXA, CSS",
|
||||||
|
"placeholder_policy_number": "es. 12345678",
|
||||||
|
"month_short": "mese",
|
||||||
|
"types": {
|
||||||
|
"kvg": "Cassa malati (LAMal)",
|
||||||
|
"kk_zusatz": "Assicurazione complementare",
|
||||||
|
"nbu": "Infortuni (LAINF)",
|
||||||
|
"haftpflicht": "Responsabilità civile privata",
|
||||||
|
"hausrat": "Economia domestica",
|
||||||
|
"mfz": "RC veicoli",
|
||||||
|
"rechtsschutz": "Tutela legale",
|
||||||
|
"saule_3a": "Pilastro 3a",
|
||||||
|
"leben": "Assicurazione vita",
|
||||||
|
"reise": "Assicurazione viaggio",
|
||||||
|
"other": "Altro"
|
||||||
|
},
|
||||||
|
"period": {
|
||||||
|
"monthly": "Mensile",
|
||||||
|
"quarterly": "Trimestrale",
|
||||||
|
"semi_annual": "Semestrale",
|
||||||
|
"annual": "Annuale"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"insurance_docs": {
|
||||||
|
"title": "Documenti assicurativi",
|
||||||
|
"subtitle": "Carica le tue polizze e falle analizzare dall'IA",
|
||||||
|
"coming_soon_title": "Upload PDF & analisi IA — In arrivo",
|
||||||
|
"coming_soon_text": "Carica le tue polizze assicurative in PDF. L'IA (Claude) estrae automaticamente le informazioni più importanti."
|
||||||
|
},
|
||||||
|
"insurance_analyse": {
|
||||||
|
"title": "Analisi assicurativa",
|
||||||
|
"subtitle": "Obiettivo — Di quale copertura hai bisogno?",
|
||||||
|
"coming_soon_title": "Analisi della copertura — In arrivo",
|
||||||
|
"coming_soon_text": "Confronta la tua situazione attuale con le raccomandazioni svizzere e identifica le lacune.",
|
||||||
|
"tag_soll": "Situazione obiettivo",
|
||||||
|
"tag_gaps": "Lacune di copertura",
|
||||||
|
"tag_recommendations": "Raccomandazioni"
|
||||||
|
},
|
||||||
|
"priminfo": {
|
||||||
|
"title": "Priminfo — Calcolatore premi LAMal",
|
||||||
|
"subtitle": "Premi mensili medi per CAP, basati sui dati dell'UFSP",
|
||||||
|
"plz_label": "Inserisci il CAP",
|
||||||
|
"plz_placeholder": "es. 8001",
|
||||||
|
"plz_hint": "Codice postale svizzero a 4 cifre",
|
||||||
|
"search": "Cerca",
|
||||||
|
"region_label": "Regione tariffale",
|
||||||
|
"col_child": "Bambini",
|
||||||
|
"col_young": "Giovani adulti",
|
||||||
|
"col_adult": "Adulti",
|
||||||
|
"month": "mese",
|
||||||
|
"disclaimer": "Premi mensili medi {{ year }} (tutti gli assicuratori, tutti i modelli). Fonte: UFSP / Priminfo.",
|
||||||
|
"multi_ort_hint": "Questo CAP comprende più comuni o regioni.",
|
||||||
|
"error_not_found": "Nessun dato trovato per questo CAP.",
|
||||||
|
"no_results": "Nessun risultato.",
|
||||||
|
"cta_title": "Ulteriori dettagli su priminfo.admin.ch",
|
||||||
|
"cta_text": "Costi annuali, storico dei premi e assicurazioni complementari sul sito ufficiale dell'UFSP.",
|
||||||
|
"cta_btn": "Confronta su Priminfo",
|
||||||
|
"info_title": "Cosa mostra questo calcolatore?",
|
||||||
|
"info_1": "I premi sono medie cantonali calcolate su tutti gli assicuratori e tutti i modelli assicurativi (standard, medico di famiglia, HMO, telemedicina).",
|
||||||
|
"info_2": "La regione tariffale (1, 2 o 3) determina il livello dei premi all'interno di un cantone — la regione 1 è generalmente la più cara.",
|
||||||
|
"info_3": "Per un confronto preciso per assicuratore e franchigia, consigliamo il calcolatore ufficiale su priminfo.admin.ch.",
|
||||||
|
"source": "Fonte: UFSP — ",
|
||||||
|
"vergleich_card_title": "Confronta assicuratori",
|
||||||
|
"vergleich_card_subtitle": "Premi dettagliati per assicuratore, modello e franchigia",
|
||||||
|
"geburtsjahr_label": "Anno di nascita",
|
||||||
|
"geburtsjahr_placeholder": "es. 1990",
|
||||||
|
"modell_label": "Modello assicurativo",
|
||||||
|
"modell_base": "Standard",
|
||||||
|
"modell_ham": "Medico di base",
|
||||||
|
"modell_hmo": "HMO",
|
||||||
|
"modell_div": "Altri modelli",
|
||||||
|
"franchise_label": "Franchigia",
|
||||||
|
"unfall_label": "Copertura infortuni",
|
||||||
|
"unfall_ohn": "Senza infortuni",
|
||||||
|
"unfall_mit": "Con infortuni",
|
||||||
|
"unfall_note": "I dipendenti possono escludere la copertura infortuni se il datore di lavoro ha stipulato un'assicurazione AINF.",
|
||||||
|
"vergleich_btn": "Confronta assicuratori",
|
||||||
|
"col_rank": "#",
|
||||||
|
"col_insurer": "Assicuratore",
|
||||||
|
"col_model": "Modello",
|
||||||
|
"col_franchise": "Franchigia",
|
||||||
|
"col_premium": "Premio/mese",
|
||||||
|
"cheapest_badge": "Più economico",
|
||||||
|
"vergleich_data_year": "Premi {{ year }}",
|
||||||
|
"vergleich_no_results": "Nessun assicuratore trovato per questa combinazione. Provare un altro modello o franchigia.",
|
||||||
|
"vergleich_hint": "Assicuratori ordinati per premio crescente.",
|
||||||
|
"age_child": "Bambino (≤ 18 anni)",
|
||||||
|
"age_young": "Giovane adulto (19–25 anni)",
|
||||||
|
"age_adult": "Adulto (≥ 26 anni)"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user