1 Commits

Author SHA1 Message Date
Daniel Krähenbühl d924ae0c30 feat: salary section — Salarium iframe, Swiss net pay calculator
- Salary nav group in sidebar with flyout and expanded dropdown
- Salarium page (/salarium): embedded BFS Salarium iframe for official
  Swiss salary comparisons (no public API available, iframe only)
- Pay calculator (/lohn-analyse): Swiss net salary calculator with all
  mandatory deductions: AHV/IV/EO (5.3%), ALV (1.1%), BVK (0.5275%),
  cantonal FAK rates, income tax estimate; built with Angular signals
- Salary development page (/lohn-entwicklung): placeholder (planned)
- i18n: all keys in DE/EN/FR/IT
2026-05-25 22:46:30 +02:00
35 changed files with 940 additions and 2685 deletions
+1 -52
View File
@@ -5,58 +5,7 @@ All notable changes to this project will be documented in this file.
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), 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
-1
View File
@@ -104,7 +104,6 @@ EMAIL_HOST_USER = os.environ.get('EMAIL_HOST_USER', '')
EMAIL_HOST_PASSWORD = os.environ.get('EMAIL_HOST_PASSWORD', '') EMAIL_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,
-18
View File
@@ -14,11 +14,6 @@ from finance.views import (
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()
@@ -57,17 +52,4 @@ urlpatterns = [
path('api/notifications/', NotificationsView.as_view()), path('api/notifications/', NotificationsView.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)
@@ -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),
),
]
@@ -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')},
},
),
]
@@ -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),
),
]
-142
View File
@@ -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)
@@ -225,144 +224,3 @@ class BackupCode(models.Model):
def __str__(self): def __str__(self):
return f"{self.user} backup {'used' if self.used else 'active'}" return f"{self.user} backup {'used' if self.used else 'active'}"
# ── FinancialYear ─────────────────────────────────────────────────────────────
class Household(models.Model):
name = models.CharField(max_length=100)
created_by = models.ForeignKey(
settings.AUTH_USER_MODEL,
on_delete=models.PROTECT,
related_name='created_households',
)
created_at = models.DateTimeField(auto_now_add=True)
def __str__(self):
return self.name
class HouseholdMembership(models.Model):
STATUS_CHOICES = [
('pending', 'Pending'),
('active', 'Active'),
('left', 'Left'),
]
ROLE_CHOICES = [
('member', 'Member'),
('admin', 'Admin'),
]
household = models.ForeignKey(Household, on_delete=models.CASCADE, related_name='memberships')
user = models.ForeignKey(
settings.AUTH_USER_MODEL,
on_delete=models.CASCADE,
related_name='household_memberships',
)
invited_by = models.ForeignKey(
settings.AUTH_USER_MODEL,
on_delete=models.SET_NULL,
null=True,
related_name='sent_invitations',
)
status = models.CharField(max_length=10, choices=STATUS_CHOICES, default='pending')
role = models.CharField(max_length=10, choices=ROLE_CHOICES, default='member')
effective_from_year = models.PositiveSmallIntegerField(null=True, blank=True)
effective_until_year = models.PositiveSmallIntegerField(null=True, blank=True)
created_at = models.DateTimeField(auto_now_add=True)
class Meta:
unique_together = ['household', 'user']
def __str__(self):
return f"{self.user} in {self.household} ({self.status})"
class PendingHouseholdInvite(models.Model):
household = models.ForeignKey(Household, on_delete=models.CASCADE, related_name='pending_invites')
invited_email = models.EmailField()
invited_by = models.ForeignKey(
settings.AUTH_USER_MODEL,
on_delete=models.SET_NULL,
null=True,
related_name='sent_pending_invitations',
)
effective_from_year = models.PositiveSmallIntegerField(null=True, blank=True)
created_at = models.DateTimeField(auto_now_add=True)
class Meta:
unique_together = ['household', 'invited_email']
def __str__(self):
return f"Pending invite for {self.invited_email} to {self.household}"
class FinancialYear(models.Model):
user = models.ForeignKey(
settings.AUTH_USER_MODEL,
on_delete=models.CASCADE,
related_name='financial_years',
null=True, blank=True,
)
household = models.ForeignKey(
Household,
on_delete=models.CASCADE,
related_name='financial_years',
null=True, blank=True,
)
year = models.PositiveSmallIntegerField()
is_active = models.BooleanField(default=True)
notes = models.TextField(blank=True, default='')
created_at = models.DateTimeField(auto_now_add=True)
class Meta:
constraints = [
models.CheckConstraint(
condition=(
models.Q(user__isnull=False, household__isnull=True) |
models.Q(user__isnull=True, household__isnull=False)
),
name='financial_year_owner_exclusive',
),
models.UniqueConstraint(
fields=['user', 'year'],
condition=models.Q(user__isnull=False),
name='unique_personal_financial_year',
),
models.UniqueConstraint(
fields=['household', 'year'],
condition=models.Q(household__isnull=False),
name='unique_household_financial_year',
),
]
def __str__(self):
owner = self.user or self.household
return f"{owner}{self.year}"
class YearlyIncome(models.Model):
financial_year = models.ForeignKey(FinancialYear, on_delete=models.CASCADE, related_name='incomes')
member = models.ForeignKey(
settings.AUTH_USER_MODEL,
on_delete=models.SET_NULL,
null=True,
related_name='yearly_incomes',
)
name = models.CharField(max_length=100)
amount = models.DecimalField(max_digits=12, decimal_places=2)
active = models.BooleanField(default=True)
notes = models.TextField(blank=True, default='')
def __str__(self):
return f"{self.name}: CHF {self.amount} ({self.financial_year.year})"
class YearlyBudgetItem(models.Model):
financial_year = models.ForeignKey(FinancialYear, on_delete=models.CASCADE, related_name='budget_items')
name = models.CharField(max_length=100)
amount = models.DecimalField(max_digits=12, decimal_places=2)
active = models.BooleanField(default=True)
notes = models.TextField(blank=True, default='')
def __str__(self):
return f"{self.name}: CHF {self.amount} ({self.financial_year.year})"
+2 -93
View File
@@ -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
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,76 +56,6 @@ class DeadlineSerializer(serializers.ModelSerializer):
exclude = ['user'] exclude = ['user']
class HouseholdMembershipSerializer(serializers.ModelSerializer):
user_email = serializers.EmailField(source='user.email', read_only=True)
invited_by_email = serializers.EmailField(source='invited_by.email', read_only=True)
class Meta:
model = HouseholdMembership
fields = ['id', 'user', 'user_email', 'invited_by_email', 'status', 'role',
'effective_from_year', 'effective_until_year', 'created_at']
read_only_fields = ['id', 'user', 'user_email', 'invited_by_email', 'created_at']
class PendingHouseholdInviteSerializer(serializers.ModelSerializer):
invited_by_email = serializers.EmailField(source='invited_by.email', read_only=True)
class Meta:
model = PendingHouseholdInvite
fields = ['id', 'invited_email', 'invited_by_email', 'effective_from_year', 'created_at']
read_only_fields = fields
class HouseholdSerializer(serializers.ModelSerializer):
memberships = HouseholdMembershipSerializer(many=True, read_only=True)
pending_invites = PendingHouseholdInviteSerializer(many=True, read_only=True)
created_by_email = serializers.EmailField(source='created_by.email', read_only=True)
class Meta:
model = Household
fields = ['id', 'name', 'created_by_email', 'memberships', 'pending_invites', 'created_at']
read_only_fields = ['id', 'created_by_email', 'memberships', 'pending_invites', 'created_at']
class YearlyIncomeSerializer(serializers.ModelSerializer):
member_email = serializers.EmailField(source='member.email', read_only=True)
class Meta:
model = YearlyIncome
fields = ['id', 'member', 'member_email', 'name', 'amount', 'active', 'notes']
read_only_fields = ['id', 'member_email']
class YearlyBudgetItemSerializer(serializers.ModelSerializer):
class Meta:
model = YearlyBudgetItem
fields = ['id', 'name', 'amount', 'active', 'notes']
read_only_fields = ['id']
class FinancialYearSerializer(serializers.ModelSerializer):
incomes = YearlyIncomeSerializer(many=True, read_only=True)
budget_items = YearlyBudgetItemSerializer(many=True, read_only=True)
total_income = serializers.SerializerMethodField()
total_fixed_costs = serializers.SerializerMethodField()
owner_type = serializers.SerializerMethodField()
class Meta:
model = FinancialYear
fields = ['id', 'year', 'is_active', 'notes', 'owner_type', 'household_id',
'total_income', 'total_fixed_costs', 'incomes', 'budget_items', 'created_at']
read_only_fields = ['id', 'created_at', 'owner_type', 'household_id', 'total_income', 'total_fixed_costs']
def get_total_income(self, obj):
return sum(i.amount for i in obj.incomes.filter(active=True))
def get_total_fixed_costs(self, obj):
return sum(b.amount for b in obj.budget_items.filter(active=True))
def get_owner_type(self, obj):
return 'household' if obj.household_id else 'personal'
class RegisterSerializer(serializers.Serializer): class RegisterSerializer(serializers.Serializer):
email = serializers.EmailField() email = serializers.EmailField()
password = serializers.CharField(min_length=8, write_only=True) password = serializers.CharField(min_length=8, write_only=True)
@@ -145,21 +67,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
+1 -460
View File
@@ -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,16 @@ 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
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,
FinancialYearSerializer, YearlyIncomeSerializer, YearlyBudgetItemSerializer,
) )
@@ -1005,454 +997,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
+6 -2
View File
@@ -14,7 +14,9 @@ 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 { Salarium } from './salary/salarium/salarium';
import { SalaryAnalyse } from './salary/analyse/analyse';
import { SalaryEntwicklung } from './salary/entwicklung/entwicklung';
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 +37,9 @@ 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: 'salarium', component: Salarium },
{ path: 'lohn-analyse', component: SalaryAnalyse },
{ path: 'lohn-entwicklung', component: SalaryEntwicklung },
], ],
}, },
{ path: '**', redirectTo: 'dashboard' }, { path: '**', redirectTo: 'dashboard' },
+14 -26
View File
@@ -2,7 +2,6 @@ import { Component, OnInit, OnDestroy, AfterViewInit, signal } from '@angular/co
import { CommonModule } from '@angular/common'; import { 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);
}
}
+58 -20
View File
@@ -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,64 @@
} }
</li> </li>
<!-- Lohn -->
<li class="relative">
@if (sidebarService.collapsed()) {
<!-- Collapsed: icon button opens flyout -->
<button (click)="sidebarService.toggleFlyout('salary')"
[class]="sidebarService.openFlyout() === 'salary' ? '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" aria-hidden="true" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24">
<path fill="currentColor" d="M4 19v2c0 .5523.44772 1 1 1h14c.5523 0 1-.4477 1-1v-2H4Z"/>
<path fill="currentColor" fill-rule="evenodd" d="M9 3c0-.55228.44772-1 1-1h8c.5523 0 1 .44772 1 1v3c0 .55228-.4477 1-1 1h-2v1h2c.5096 0 .9376.38314.9939.88957L19.8951 17H4.10498l.90116-8.11043C5.06241 8.38314 5.49047 8 6.00002 8H12V7h-2c-.55228 0-1-.44772-1-1V3Zm1.01 8H8.00002v2.01H10.01V11Zm.99 0h2.01v2.01H11V11Zm5.01 0H14v2.01h2.01V11Zm-8.00998 3H10.01v2.01H8.00002V14ZM13.01 14H11v2.01h2.01V14Zm.99 0h2.01v2.01H14V14ZM11 4h6v1h-6V4Z" clip-rule="evenodd"/>
</svg>
@if (sidebarService.openFlyout() !== 'salary') {
<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.salary' | translate }}
</span>
}
</button>
<!-- Flyout -->
@if (sidebarService.openFlyout() === 'salary') {
<div class="absolute left-full top-0 ml-2 z-50 w-44 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.salary' | translate }}</p>
<a routerLink="/salarium" (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.salarium' | translate }}
</a>
<a routerLink="/lohn-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.salary_analyse' | translate }}
</a>
<a routerLink="/lohn-entwicklung" (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.salary_entwicklung' | translate }}
</a>
</div>
}
} @else {
<!-- Expanded: Angular-controlled dropdown -->
<button type="button" (click)="sidebarService.toggleSalary()"
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" aria-hidden="true" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24">
<path fill="currentColor" d="M4 19v2c0 .5523.44772 1 1 1h14c.5523 0 1-.4477 1-1v-2H4Z"/>
<path fill="currentColor" fill-rule="evenodd" d="M9 3c0-.55228.44772-1 1-1h8c.5523 0 1 .44772 1 1v3c0 .55228-.4477 1-1 1h-2v1h2c.5096 0 .9376.38314.9939.88957L19.8951 17H4.10498l.90116-8.11043C5.06241 8.38314 5.49047 8 6.00002 8H12V7h-2c-.55228 0-1-.44772-1-1V3Zm1.01 8H8.00002v2.01H10.01V11Zm.99 0h2.01v2.01H11V11Zm5.01 0H14v2.01h2.01V11Zm-8.00998 3H10.01v2.01H8.00002V14ZM13.01 14H11v2.01h2.01V14Zm.99 0h2.01v2.01H14V14ZM11 4h6v1h-6V4Z" clip-rule="evenodd"/>
</svg>
<span class="flex-1 ml-3 text-left whitespace-nowrap">{{ 'sidebar.salary' | translate }}</span>
<svg [class.rotate-180]="sidebarService.salaryOpen()" 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.salaryOpen()) {
<ul class="py-2 space-y-2">
<li><a routerLink="/salarium" 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.salarium' | translate }}</a></li>
<li><a routerLink="/lohn-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.salary_analyse' | translate }}</a></li>
<li><a routerLink="/lohn-entwicklung" 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.salary_entwicklung' | 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) -->
@@ -0,0 +1,434 @@
<div class="p-4 sm:p-6 lg:p-8">
<!-- Header -->
<div class="mb-6">
<h1 class="text-2xl font-bold text-gray-900 dark:text-white">{{ 'lohn_analyse.title' | translate }}</h1>
<p class="mt-1 text-sm text-gray-500 dark:text-gray-400">{{ 'lohn_analyse.subtitle' | translate }}</p>
</div>
<div class="grid grid-cols-1 lg:grid-cols-[360px_1fr] gap-6 items-start">
<!-- ── Eingaben ────────────────────────────────────────────────────────── -->
<div class="space-y-4">
<!-- Grundlagen -->
<div class="rounded-lg bg-white border border-gray-200 dark:bg-gray-800 dark:border-gray-700 p-5">
<h2 class="text-base font-semibold text-gray-900 dark:text-white mb-4">
{{ 'lohn_analyse.section_basics' | translate }}
</h2>
<div class="space-y-4">
<!-- Monatslohn -->
<div>
<label class="block mb-1.5 text-xs font-medium text-gray-700 dark:text-gray-300">
{{ 'lohn_analyse.label_monatslohn' | translate }}
</label>
<div class="relative">
<span class="absolute inset-y-0 left-0 flex items-center pl-3 text-sm text-gray-500 dark:text-gray-400 pointer-events-none">CHF</span>
<input type="number" min="0" step="100"
[ngModel]="monatslohn()"
(ngModelChange)="setMonatslohn($event)"
class="block w-full rounded-lg border border-gray-300 bg-gray-50 pl-12 pr-3 py-2.5 text-sm text-gray-900 placeholder-gray-400
focus:border-violet-600 focus:outline-none focus:ring-2 focus:ring-violet-300
dark:border-gray-600 dark:bg-gray-700 dark:text-white dark:focus:border-violet-500 dark:focus:ring-violet-500" />
</div>
</div>
<!-- Anzahl Monate -->
<div>
<label class="block mb-1.5 text-xs font-medium text-gray-700 dark:text-gray-300">
{{ 'lohn_analyse.label_monate' | translate }}
</label>
<div class="flex gap-2">
<button (click)="setAnzahlMonate('12')"
[class]="anzahlMonate() === 12
? 'flex-1 rounded-lg bg-violet-700 px-3 py-2 text-sm font-medium text-white dark:bg-violet-600'
: 'flex-1 rounded-lg border border-gray-200 bg-white px-3 py-2 text-sm font-medium text-gray-700 hover:bg-gray-50 dark:border-gray-600 dark:bg-gray-700 dark:text-gray-300 dark:hover:bg-gray-600'">
12 {{ 'lohn_analyse.monate' | translate }}
</button>
<button (click)="setAnzahlMonate('13')"
[class]="anzahlMonate() === 13
? 'flex-1 rounded-lg bg-violet-700 px-3 py-2 text-sm font-medium text-white dark:bg-violet-600'
: 'flex-1 rounded-lg border border-gray-200 bg-white px-3 py-2 text-sm font-medium text-gray-700 hover:bg-gray-50 dark:border-gray-600 dark:bg-gray-700 dark:text-gray-300 dark:hover:bg-gray-600'">
13 {{ 'lohn_analyse.monate' | translate }}
</button>
</div>
</div>
<!-- Kanton -->
<div>
<label class="block mb-1.5 text-xs font-medium text-gray-700 dark:text-gray-300">
{{ 'lohn_analyse.label_kanton' | translate }}
</label>
<div class="relative">
<select [ngModel]="kanton()" (ngModelChange)="setKanton($event)"
class="block w-full rounded-lg border border-gray-300 bg-gray-50 px-3 py-2.5 text-sm text-gray-900
focus:border-violet-600 focus:outline-none focus:ring-2 focus:ring-violet-300
dark:border-gray-600 dark:bg-gray-700 dark:text-white dark:focus:border-violet-500 dark:focus:ring-violet-500 appearance-none pr-10">
@for (c of cantons; track c) {
<option [value]="c">{{ ('canton_names.' + c) | translate }} ({{ c }})</option>
}
</select>
<span class="pointer-events-none absolute inset-y-0 right-0 flex items-center pr-3">
<svg class="w-4 h-4 text-gray-400" fill="none" viewBox="0 0 24 24">
<path stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="m19 9-7 7-7-7"/>
</svg>
</span>
</div>
<p class="mt-1.5 text-xs text-gray-500 dark:text-gray-400">
{{ 'lohn_analyse.fak_rate_hint' | translate }} <span class="font-medium text-violet-700 dark:text-violet-400">{{ pct(result().fakRate) }}</span>
</p>
</div>
<!-- Abrechnungsverfahren -->
<div>
<label class="block mb-1.5 text-xs font-medium text-gray-700 dark:text-gray-300">
{{ 'lohn_analyse.label_verfahren' | translate }}
</label>
<select [ngModel]="verfahren()" (ngModelChange)="setVerfahren($event)"
class="block w-full rounded-lg border border-gray-300 bg-gray-50 px-3 py-2.5 text-sm text-gray-900
focus:border-violet-600 focus:outline-none focus:ring-2 focus:ring-violet-300
dark:border-gray-600 dark:bg-gray-700 dark:text-white dark:focus:border-violet-500 dark:focus:ring-violet-500 appearance-none">
<option value="ordentlich">{{ 'lohn_analyse.verfahren_ordentlich' | translate }}</option>
<option value="vereinfacht">{{ 'lohn_analyse.verfahren_vereinfacht' | translate }}</option>
<option value="quellensteuer">{{ 'lohn_analyse.verfahren_quellensteuer' | translate }}</option>
</select>
@if (verfahren() !== 'ordentlich') {
<p class="mt-1.5 flex items-center gap-1.5 text-xs text-amber-600 dark:text-amber-400">
<svg class="w-3.5 h-3.5 flex-shrink-0" fill="none" viewBox="0 0 24 24">
<path stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 9v4m0 4h.01M10.29 3.86 1.82 18a2 2 0 0 0 1.71 3h16.94a2 2 0 0 0 1.71-3L13.71 3.86a2 2 0 0 0-3.42 0Z"/>
</svg>
{{ 'lohn_analyse.verfahren_hint' | translate }}
</p>
}
</div>
</div>
</div>
<!-- Optionale Sätze -->
<div class="rounded-lg bg-white border border-gray-200 dark:bg-gray-800 dark:border-gray-700">
<button (click)="showOptional.set(!showOptional())"
class="flex w-full items-center justify-between px-5 py-3.5 text-sm font-medium text-gray-900 dark:text-white">
<span>{{ 'lohn_analyse.section_optional' | translate }}</span>
<svg [class.rotate-180]="showOptional()" class="w-4 h-4 text-gray-400 transition-transform duration-200" fill="none" viewBox="0 0 24 24">
<path stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="m19 9-7 7-7-7"/>
</svg>
</button>
@if (showOptional()) {
<div class="border-t border-gray-100 dark:border-gray-700 px-5 pb-5 pt-4 space-y-4">
<p class="text-xs text-gray-500 dark:text-gray-400">{{ 'lohn_analyse.optional_hint' | translate }}</p>
<!-- AG -->
<p class="text-xs font-semibold text-gray-500 dark:text-gray-400 uppercase tracking-wider">
{{ 'lohn_analyse.beitraege_ag' | translate }}
</p>
<div class="grid grid-cols-2 gap-3">
<div>
<label class="block mb-1.5 text-xs font-medium text-gray-700 dark:text-gray-300">
{{ 'lohn_analyse.ktv' | translate }} (%)
</label>
<input type="number" min="0" max="10" step="0.05"
[ngModel]="agKtvPct()"
(ngModelChange)="setAgKtvPct($event)"
placeholder="0.00"
class="block w-full rounded-lg border border-gray-300 bg-gray-50 px-3 py-2.5 text-sm text-gray-900
focus:border-violet-600 focus:outline-none focus:ring-2 focus:ring-violet-300
dark:border-gray-600 dark:bg-gray-700 dark:text-white dark:focus:border-violet-500 dark:focus:ring-violet-500" />
</div>
<div>
<label class="block mb-1.5 text-xs font-medium text-gray-700 dark:text-gray-300">
{{ 'lohn_analyse.bu' | translate }} (%)
</label>
<input type="number" min="0" max="10" step="0.05"
[ngModel]="agBuPct()"
(ngModelChange)="setAgBuPct($event)"
placeholder="0.00"
class="block w-full rounded-lg border border-gray-300 bg-gray-50 px-3 py-2.5 text-sm text-gray-900
focus:border-violet-600 focus:outline-none focus:ring-2 focus:ring-violet-300
dark:border-gray-600 dark:bg-gray-700 dark:text-white dark:focus:border-violet-500 dark:focus:ring-violet-500" />
</div>
</div>
<!-- AN -->
<p class="text-xs font-semibold text-gray-500 dark:text-gray-400 uppercase tracking-wider">
{{ 'lohn_analyse.abzuege_an' | translate }}
</p>
<div class="grid grid-cols-2 gap-3">
<div>
<label class="block mb-1.5 text-xs font-medium text-gray-700 dark:text-gray-300">
{{ 'lohn_analyse.ktv' | translate }} (%)
</label>
<input type="number" min="0" max="10" step="0.05"
[ngModel]="anKtvPct()"
(ngModelChange)="setAnKtvPct($event)"
placeholder="0.00"
class="block w-full rounded-lg border border-gray-300 bg-gray-50 px-3 py-2.5 text-sm text-gray-900
focus:border-violet-600 focus:outline-none focus:ring-2 focus:ring-violet-300
dark:border-gray-600 dark:bg-gray-700 dark:text-white dark:focus:border-violet-500 dark:focus:ring-violet-500" />
</div>
<div>
<label class="block mb-1.5 text-xs font-medium text-gray-700 dark:text-gray-300">
{{ 'lohn_analyse.nbu' | translate }} (%)
</label>
<input type="number" min="0" max="10" step="0.05"
[ngModel]="anNbuPct()"
(ngModelChange)="setAnNbuPct($event)"
placeholder="0.00"
class="block w-full rounded-lg border border-gray-300 bg-gray-50 px-3 py-2.5 text-sm text-gray-900
focus:border-violet-600 focus:outline-none focus:ring-2 focus:ring-violet-300
dark:border-gray-600 dark:bg-gray-700 dark:text-white dark:focus:border-violet-500 dark:focus:ring-violet-500" />
</div>
</div>
</div>
}
</div>
</div><!-- end inputs -->
<!-- ── Ergebnisse ─────────────────────────────────────────────────────── -->
<div class="space-y-4">
<!-- KPI-Zusammenfassung -->
<div class="grid grid-cols-1 sm:grid-cols-3 gap-4">
<!-- Bruttolohn -->
<div class="rounded-lg bg-white border border-gray-200 dark:bg-gray-800 dark:border-gray-700 p-4">
<p class="text-xs font-medium text-gray-500 dark:text-gray-400 mb-1">
{{ 'lohn_analyse.bruttolohn' | translate }}
</p>
<p class="text-xl font-bold text-gray-900 dark:text-white">
CHF {{ result().grossMonthly | number:'1.2-2' }}
</p>
<p class="text-xs text-gray-400 dark:text-gray-500 mt-0.5">
CHF {{ result().grossAnnual | number:'1.2-2' }} / {{ 'lohn_analyse.jahr' | translate }}
</p>
</div>
<!-- Totalaufwand AG -->
<div class="rounded-lg bg-amber-50 border border-amber-200 dark:bg-amber-900/20 dark:border-amber-800 p-4">
<p class="text-xs font-medium text-amber-700 dark:text-amber-400 mb-1">
{{ 'lohn_analyse.totalaufwand_ag' | translate }}
</p>
<p class="text-xl font-bold text-amber-700 dark:text-amber-400">
CHF {{ result().totalCostMonthly | number:'1.2-2' }}
</p>
<p class="text-xs text-amber-600 dark:text-amber-500 mt-0.5">
CHF {{ result().totalCostAnnual | number:'1.2-2' }} / {{ 'lohn_analyse.jahr' | translate }}
</p>
</div>
<!-- Nettolohn AN -->
<div class="rounded-lg bg-emerald-50 border border-emerald-200 dark:bg-emerald-900/20 dark:border-emerald-800 p-4">
<p class="text-xs font-medium text-emerald-700 dark:text-emerald-400 mb-1">
{{ 'lohn_analyse.nettolohn' | translate }}
</p>
<p class="text-xl font-bold text-emerald-700 dark:text-emerald-400">
CHF {{ result().netMonthly | number:'1.2-2' }}
</p>
<p class="text-xs text-emerald-600 dark:text-emerald-500 mt-0.5">
CHF {{ result().netAnnual | number:'1.2-2' }} / {{ 'lohn_analyse.jahr' | translate }}
</p>
</div>
</div>
<!-- Aufschlüsselung Arbeitgeber -->
<div class="rounded-lg bg-white border border-gray-200 dark:bg-gray-800 dark:border-gray-700 overflow-hidden">
<div class="px-5 py-3.5 border-b border-gray-100 dark:border-gray-700">
<h2 class="text-base font-semibold text-gray-900 dark:text-white">
{{ 'lohn_analyse.section_ag' | translate }}
</h2>
</div>
<div class="overflow-x-auto">
<table class="w-full text-sm">
<thead>
<tr class="text-xs font-medium text-gray-500 dark:text-gray-400 uppercase tracking-wider">
<th class="px-5 py-3 text-left">{{ 'lohn_analyse.col_position' | translate }}</th>
<th class="px-4 py-3 text-right">{{ 'lohn_analyse.col_satz' | translate }}</th>
<th class="px-4 py-3 text-right">{{ 'lohn_analyse.col_monat' | translate }}</th>
<th class="px-5 py-3 text-right">{{ 'lohn_analyse.col_jahr' | translate }}</th>
</tr>
</thead>
<tbody class="divide-y divide-gray-100 dark:divide-gray-700">
<!-- Bruttolohn -->
<tr class="bg-gray-50 dark:bg-gray-700/30">
<td class="px-5 py-3 font-medium text-gray-900 dark:text-white">{{ 'lohn_analyse.bruttolohn' | translate }}</td>
<td class="px-4 py-3 text-right text-gray-400 dark:text-gray-500"></td>
<td class="px-4 py-3 text-right font-medium text-gray-900 dark:text-white">{{ result().grossMonthly | number:'1.2-2' }}</td>
<td class="px-5 py-3 text-right font-medium text-gray-900 dark:text-white">{{ result().grossAnnual | number:'1.2-2' }}</td>
</tr>
<!-- AHV/IV/EO -->
<tr>
<td class="px-5 py-3 text-gray-700 dark:text-gray-300">{{ 'lohn_analyse.ahv_iv_eo' | translate }}</td>
<td class="px-4 py-3 text-right text-gray-500 dark:text-gray-400">{{ pct(0.053) }}</td>
<td class="px-4 py-3 text-right text-gray-700 dark:text-gray-300">{{ result().agAhv | number:'1.2-2' }}</td>
<td class="px-5 py-3 text-right text-gray-700 dark:text-gray-300">{{ result().agAhv * anzahlMonate() | number:'1.2-2' }}</td>
</tr>
<!-- ALV -->
<tr>
<td class="px-5 py-3 text-gray-700 dark:text-gray-300">{{ 'lohn_analyse.alv' | translate }}</td>
<td class="px-4 py-3 text-right text-gray-500 dark:text-gray-400">{{ pct(0.011) }}</td>
<td class="px-4 py-3 text-right text-gray-700 dark:text-gray-300">{{ result().agAlv | number:'1.2-2' }}</td>
<td class="px-5 py-3 text-right text-gray-700 dark:text-gray-300">{{ result().agAlv * anzahlMonate() | number:'1.2-2' }}</td>
</tr>
<!-- FAK -->
<tr>
<td class="px-5 py-3 text-gray-700 dark:text-gray-300">
{{ 'lohn_analyse.fak' | translate }}
<span class="ml-1.5 text-xs text-gray-400 dark:text-gray-500">({{ kanton() }})</span>
</td>
<td class="px-4 py-3 text-right text-gray-500 dark:text-gray-400">{{ pct(result().fakRate) }}</td>
<td class="px-4 py-3 text-right text-gray-700 dark:text-gray-300">{{ result().agFak | number:'1.2-2' }}</td>
<td class="px-5 py-3 text-right text-gray-700 dark:text-gray-300">{{ result().agFak * anzahlMonate() | number:'1.2-2' }}</td>
</tr>
<!-- VK -->
<tr>
<td class="px-5 py-3 text-gray-700 dark:text-gray-300">{{ 'lohn_analyse.vk' | translate }}</td>
<td class="px-4 py-3 text-right text-gray-500 dark:text-gray-400">{{ pct(0.005275) }}</td>
<td class="px-4 py-3 text-right text-gray-700 dark:text-gray-300">{{ result().agVk | number:'1.2-2' }}</td>
<td class="px-5 py-3 text-right text-gray-700 dark:text-gray-300">{{ result().agVk * anzahlMonate() | number:'1.2-2' }}</td>
</tr>
<!-- KTV AG (optional) -->
@if (agKtvPct() > 0) {
<tr>
<td class="px-5 py-3 text-gray-700 dark:text-gray-300">{{ 'lohn_analyse.ktv' | translate }}</td>
<td class="px-4 py-3 text-right text-gray-500 dark:text-gray-400">{{ pct(agKtvPct() / 100) }}</td>
<td class="px-4 py-3 text-right text-gray-700 dark:text-gray-300">{{ result().agKtvAmt | number:'1.2-2' }}</td>
<td class="px-5 py-3 text-right text-gray-700 dark:text-gray-300">{{ result().agKtvAmt * anzahlMonate() | number:'1.2-2' }}</td>
</tr>
}
<!-- BU AG (optional) -->
@if (agBuPct() > 0) {
<tr>
<td class="px-5 py-3 text-gray-700 dark:text-gray-300">{{ 'lohn_analyse.bu' | translate }}</td>
<td class="px-4 py-3 text-right text-gray-500 dark:text-gray-400">{{ pct(agBuPct() / 100) }}</td>
<td class="px-4 py-3 text-right text-gray-700 dark:text-gray-300">{{ result().agBuAmt | number:'1.2-2' }}</td>
<td class="px-5 py-3 text-right text-gray-700 dark:text-gray-300">{{ result().agBuAmt * anzahlMonate() | number:'1.2-2' }}</td>
</tr>
}
<!-- Total Beiträge AG -->
<tr class="bg-amber-50 dark:bg-amber-900/10">
<td class="px-5 py-3 font-medium text-amber-800 dark:text-amber-300">{{ 'lohn_analyse.total_beitraege_ag' | translate }}</td>
<td class="px-4 py-3 text-right text-amber-700 dark:text-amber-400">{{ pct(result().agTotalRate) }}</td>
<td class="px-4 py-3 text-right font-medium text-amber-800 dark:text-amber-300">{{ result().agTotal | number:'1.2-2' }}</td>
<td class="px-5 py-3 text-right font-medium text-amber-800 dark:text-amber-300">{{ result().agTotal * anzahlMonate() | number:'1.2-2' }}</td>
</tr>
<!-- Totalaufwand -->
<tr class="bg-amber-100 dark:bg-amber-900/20 border-t-2 border-amber-200 dark:border-amber-700">
<td class="px-5 py-3.5 font-semibold text-amber-900 dark:text-amber-200">{{ 'lohn_analyse.totalaufwand_ag' | translate }}</td>
<td class="px-4 py-3.5 text-right text-amber-700 dark:text-amber-400"></td>
<td class="px-4 py-3.5 text-right font-semibold text-amber-900 dark:text-amber-200">{{ result().totalCostMonthly | number:'1.2-2' }}</td>
<td class="px-5 py-3.5 text-right font-semibold text-amber-900 dark:text-amber-200">{{ result().totalCostAnnual | number:'1.2-2' }}</td>
</tr>
</tbody>
</table>
</div>
</div>
<!-- Aufschlüsselung Arbeitnehmer -->
<div class="rounded-lg bg-white border border-gray-200 dark:bg-gray-800 dark:border-gray-700 overflow-hidden">
<div class="px-5 py-3.5 border-b border-gray-100 dark:border-gray-700">
<h2 class="text-base font-semibold text-gray-900 dark:text-white">
{{ 'lohn_analyse.section_an' | translate }}
</h2>
</div>
<div class="overflow-x-auto">
<table class="w-full text-sm">
<thead>
<tr class="text-xs font-medium text-gray-500 dark:text-gray-400 uppercase tracking-wider">
<th class="px-5 py-3 text-left">{{ 'lohn_analyse.col_position' | translate }}</th>
<th class="px-4 py-3 text-right">{{ 'lohn_analyse.col_satz' | translate }}</th>
<th class="px-4 py-3 text-right">{{ 'lohn_analyse.col_monat' | translate }}</th>
<th class="px-5 py-3 text-right">{{ 'lohn_analyse.col_jahr' | translate }}</th>
</tr>
</thead>
<tbody class="divide-y divide-gray-100 dark:divide-gray-700">
<!-- Bruttolohn -->
<tr class="bg-gray-50 dark:bg-gray-700/30">
<td class="px-5 py-3 font-medium text-gray-900 dark:text-white">{{ 'lohn_analyse.bruttolohn' | translate }}</td>
<td class="px-4 py-3 text-right text-gray-400 dark:text-gray-500"></td>
<td class="px-4 py-3 text-right font-medium text-gray-900 dark:text-white">{{ result().grossMonthly | number:'1.2-2' }}</td>
<td class="px-5 py-3 text-right font-medium text-gray-900 dark:text-white">{{ result().grossAnnual | number:'1.2-2' }}</td>
</tr>
<!-- AHV/IV/EO -->
<tr>
<td class="px-5 py-3 text-gray-700 dark:text-gray-300">{{ 'lohn_analyse.ahv_iv_eo' | translate }}</td>
<td class="px-4 py-3 text-right text-gray-500 dark:text-gray-400">{{ pct(0.053) }}</td>
<td class="px-4 py-3 text-right text-red-600 dark:text-red-400">{{ result().anAhv | number:'1.2-2' }}</td>
<td class="px-5 py-3 text-right text-red-600 dark:text-red-400">{{ result().anAhv * anzahlMonate() | number:'1.2-2' }}</td>
</tr>
<!-- ALV -->
<tr>
<td class="px-5 py-3 text-gray-700 dark:text-gray-300">{{ 'lohn_analyse.alv' | translate }}</td>
<td class="px-4 py-3 text-right text-gray-500 dark:text-gray-400">{{ pct(0.011) }}</td>
<td class="px-4 py-3 text-right text-red-600 dark:text-red-400">{{ result().anAlv | number:'1.2-2' }}</td>
<td class="px-5 py-3 text-right text-red-600 dark:text-red-400">{{ result().anAlv * anzahlMonate() | number:'1.2-2' }}</td>
</tr>
<!-- KTV AN (optional) -->
@if (anKtvPct() > 0) {
<tr>
<td class="px-5 py-3 text-gray-700 dark:text-gray-300">{{ 'lohn_analyse.ktv' | translate }}</td>
<td class="px-4 py-3 text-right text-gray-500 dark:text-gray-400">{{ pct(anKtvPct() / 100) }}</td>
<td class="px-4 py-3 text-right text-red-600 dark:text-red-400">{{ result().anKtvAmt | number:'1.2-2' }}</td>
<td class="px-5 py-3 text-right text-red-600 dark:text-red-400">{{ result().anKtvAmt * anzahlMonate() | number:'1.2-2' }}</td>
</tr>
}
<!-- NBU AN (optional) -->
@if (anNbuPct() > 0) {
<tr>
<td class="px-5 py-3 text-gray-700 dark:text-gray-300">{{ 'lohn_analyse.nbu' | translate }}</td>
<td class="px-4 py-3 text-right text-gray-500 dark:text-gray-400">{{ pct(anNbuPct() / 100) }}</td>
<td class="px-4 py-3 text-right text-red-600 dark:text-red-400">{{ result().anNbuAmt | number:'1.2-2' }}</td>
<td class="px-5 py-3 text-right text-red-600 dark:text-red-400">{{ result().anNbuAmt * anzahlMonate() | number:'1.2-2' }}</td>
</tr>
}
<!-- Total Abzüge -->
<tr class="bg-red-50 dark:bg-red-900/10">
<td class="px-5 py-3 font-medium text-red-800 dark:text-red-300">{{ 'lohn_analyse.total_abzuege_an' | translate }}</td>
<td class="px-4 py-3 text-right text-red-700 dark:text-red-400">{{ pct(result().anTotalRate) }}</td>
<td class="px-4 py-3 text-right font-medium text-red-800 dark:text-red-300">{{ result().anTotal | number:'1.2-2' }}</td>
<td class="px-5 py-3 text-right font-medium text-red-800 dark:text-red-300">{{ result().anTotal * anzahlMonate() | number:'1.2-2' }}</td>
</tr>
<!-- Nettolohn -->
<tr class="bg-emerald-50 dark:bg-emerald-900/20 border-t-2 border-emerald-200 dark:border-emerald-700">
<td class="px-5 py-3.5 font-semibold text-emerald-900 dark:text-emerald-200">{{ 'lohn_analyse.nettolohn' | translate }}</td>
<td class="px-4 py-3.5 text-right text-emerald-700 dark:text-emerald-400"></td>
<td class="px-4 py-3.5 text-right font-semibold text-emerald-900 dark:text-emerald-200">{{ result().netMonthly | number:'1.2-2' }}</td>
<td class="px-5 py-3.5 text-right font-semibold text-emerald-900 dark:text-emerald-200">{{ result().netAnnual | number:'1.2-2' }}</td>
</tr>
</tbody>
</table>
</div>
</div>
<!-- Quellenhinweis -->
<p class="text-xs text-gray-400 dark:text-gray-500">
{{ 'lohn_analyse.source_hint' | translate }}
</p>
</div><!-- end results -->
</div>
</div>
+102
View File
@@ -0,0 +1,102 @@
import { Component, computed, signal } from '@angular/core';
import { CommonModule } from '@angular/common';
import { FormsModule } from '@angular/forms';
import { TranslateModule } from '@ngx-translate/core';
export const FAK_RATES: Record<string, number> = {
AG: 0.0145, AI: 0.016, AR: 0.016, BE: 0.015, BL: 0.013,
BS: 0.0165, FR: 0.0227, GE: 0.0222, GL: 0.014, GR: 0.015,
JU: 0.0275, LU: 0.0135, NE: 0.018, NW: 0.015, OW: 0.014,
SG: 0.018, SH: 0.013, SO: 0.0125, SZ: 0.013, TG: 0.014,
TI: 0.016, UR: 0.017, VD: 0.0237, VS: 0.025, ZG: 0.0135,
ZH: 0.01025,
};
export const CANTONS = Object.keys(FAK_RATES).sort();
const AHV_RATE = 0.053;
const ALV_RATE = 0.011;
const VK_RATE = 0.005275;
@Component({
selector: 'app-salary-analyse',
standalone: true,
imports: [CommonModule, FormsModule, TranslateModule],
templateUrl: './analyse.html',
})
export class SalaryAnalyse {
readonly cantons = CANTONS;
readonly fakRates = FAK_RATES;
// ── Inputs ────────────────────────────────────────────────────────────────
monatslohn = signal(5000);
anzahlMonate = signal(12);
kanton = signal('ZH');
verfahren = signal('ordentlich');
agKtvPct = signal(0);
agBuPct = signal(0);
anKtvPct = signal(0);
anNbuPct = signal(0);
showOptional = signal(false);
// ── Computed ───────────────────────────────────────────────────────────────
result = computed(() => {
const gross = Math.max(0, this.monatslohn());
const monate = Math.min(13, Math.max(1, this.anzahlMonate()));
const fak = FAK_RATES[this.kanton()] ?? 0;
const agKtv = (this.agKtvPct() ?? 0) / 100;
const agBu = (this.agBuPct() ?? 0) / 100;
const anKtv = (this.anKtvPct() ?? 0) / 100;
const anNbu = (this.anNbuPct() ?? 0) / 100;
// Arbeitgeber
const agAhv = gross * AHV_RATE;
const agAlv = gross * ALV_RATE;
const agFak = gross * fak;
const agVk = gross * VK_RATE;
const agKtvAmt = gross * agKtv;
const agBuAmt = gross * agBu;
const agTotal = agAhv + agAlv + agFak + agVk + agKtvAmt + agBuAmt;
const agTotalRate = AHV_RATE + ALV_RATE + fak + VK_RATE + agKtv + agBu;
// Arbeitnehmer
const anAhv = gross * AHV_RATE;
const anAlv = gross * ALV_RATE;
const anKtvAmt = gross * anKtv;
const anNbuAmt = gross * anNbu;
const anTotal = anAhv + anAlv + anKtvAmt + anNbuAmt;
const anTotalRate = AHV_RATE + ALV_RATE + anKtv + anNbu;
return {
grossMonthly: gross,
grossAnnual: gross * monate,
fakRate: fak,
agAhv, agAlv, agFak, agVk, agKtvAmt, agBuAmt,
agTotal, agTotalRate,
totalCostMonthly: gross + agTotal,
totalCostAnnual: (gross + agTotal) * monate,
anAhv, anAlv, anKtvAmt, anNbuAmt,
anTotal, anTotalRate,
netMonthly: gross - anTotal,
netAnnual: (gross - anTotal) * monate,
};
});
// ── Helpers ────────────────────────────────────────────────────────────────
pct(rate: number): string {
return (rate * 100).toFixed(rate % 0.001 === 0 ? 1 : 3).replace(/\.?0+$/, '') + ' %';
}
setMonatslohn(v: string) { this.monatslohn.set(+v || 0); }
setAnzahlMonate(v: string) { this.anzahlMonate.set(Math.min(13, Math.max(1, +v || 12))); }
setKanton(v: string) { this.kanton.set(v); }
setVerfahren(v: string) { this.verfahren.set(v); }
setAgKtvPct(v: string) { this.agKtvPct.set(+v || 0); }
setAgBuPct(v: string) { this.agBuPct.set(+v || 0); }
setAnKtvPct(v: string) { this.anKtvPct.set(+v || 0); }
setAnNbuPct(v: string) { this.anNbuPct.set(+v || 0); }
}
@@ -0,0 +1,17 @@
<div class="p-4 sm:p-6 lg:p-8">
<div class="mb-6">
<h1 class="text-2xl font-bold text-gray-900 dark:text-white">{{ 'salary_entwicklung.title' | translate }}</h1>
<p class="mt-1 text-sm text-gray-500 dark:text-gray-400">{{ 'salary_entwicklung.subtitle' | translate }}</p>
</div>
<!-- Placeholder -->
<div class="flex flex-col items-center justify-center py-20 text-center">
<div class="w-16 h-16 rounded-full bg-violet-100 dark:bg-violet-900/30 flex items-center justify-center mb-4">
<svg class="w-8 h-8 text-violet-600 dark:text-violet-400" fill="currentColor" viewBox="0 0 20 20">
<path fill-rule="evenodd" d="M12 7a1 1 0 110-2h5a1 1 0 011 1v5a1 1 0 11-2 0V8.414l-4.293 4.293a1 1 0 01-1.414 0L8 10.414l-4.293 4.293a1 1 0 01-1.414-1.414l5-5a1 1 0 011.414 0L11 10.586 14.586 7H12z" clip-rule="evenodd"/>
</svg>
</div>
<h2 class="text-lg font-semibold text-gray-900 dark:text-white mb-2">{{ 'salary_entwicklung.coming_soon_title' | translate }}</h2>
<p class="text-sm text-gray-500 dark:text-gray-400 max-w-sm">{{ 'salary_entwicklung.coming_soon_text' | translate }}</p>
</div>
</div>
@@ -0,0 +1,10 @@
import { Component } from '@angular/core';
import { TranslateModule } from '@ngx-translate/core';
@Component({
selector: 'app-salary-entwicklung',
standalone: true,
imports: [TranslateModule],
templateUrl: './entwicklung.html',
})
export class SalaryEntwicklung {}
@@ -0,0 +1,29 @@
<div class="flex flex-col h-[calc(100vh-57px)]">
<!-- Header -->
<div class="flex items-center justify-between px-4 sm:px-6 py-3 border-b border-gray-200 dark:border-gray-700 bg-white dark:bg-gray-800 flex-shrink-0">
<div>
<h1 class="text-lg font-semibold text-gray-900 dark:text-white">{{ 'salarium.title' | translate }}</h1>
<p class="text-xs text-gray-500 dark:text-gray-400">{{ 'salarium.subtitle' | translate }}</p>
</div>
<a [href]="externalUrl" target="_blank" rel="noopener noreferrer"
class="inline-flex items-center gap-1.5 rounded-lg border border-gray-200 bg-white px-3 py-1.5 text-xs font-medium text-gray-700
hover:bg-gray-50 dark:border-gray-600 dark:bg-gray-700 dark:text-gray-300 dark:hover:bg-gray-600 transition-colors">
<svg class="w-3.5 h-3.5" 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>
{{ 'salarium.open_external' | translate }}
</a>
</div>
<!-- iFrame -->
<iframe
[src]="safeUrl"
class="flex-1 w-full border-0"
title="Salarium BFS Lohnrechner"
loading="lazy"
sandbox="allow-scripts allow-same-origin allow-forms allow-popups allow-popups-to-escape-sandbox">
</iframe>
</div>
@@ -0,0 +1,16 @@
import { Component, inject } from '@angular/core';
import { DomSanitizer, SafeResourceUrl } from '@angular/platform-browser';
import { TranslateModule } from '@ngx-translate/core';
@Component({
selector: 'app-salarium',
standalone: true,
imports: [TranslateModule],
templateUrl: './salarium.html',
})
export class Salarium {
private sanitizer = inject(DomSanitizer);
readonly externalUrl = 'https://www.salarium.bfs.admin.ch/';
readonly safeUrl: SafeResourceUrl = this.sanitizer.bypassSecurityTrustResourceUrl(this.externalUrl);
}
-4
View File
@@ -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}/`);
} }
-146
View File
@@ -1,146 +0,0 @@
import { Injectable } from '@angular/core';
import { HttpClient } from '@angular/common/http';
import { Observable } from 'rxjs';
export interface YearlyIncome {
id: number;
member: number | null;
member_email: string;
name: string;
amount: number;
active: boolean;
notes: string;
}
export interface YearlyBudgetItem {
id: number;
name: string;
amount: number;
active: boolean;
notes: string;
}
export interface FinancialYear {
id: number;
year: number;
is_active: boolean;
notes: string;
owner_type: 'personal' | 'household';
household_id: number | null;
total_income: number;
total_fixed_costs: number;
incomes: YearlyIncome[];
budget_items: YearlyBudgetItem[];
created_at: string;
}
export interface HouseholdMembership {
id: number;
user: number;
user_email: string;
invited_by_email: string;
status: 'pending' | 'active' | 'left';
role: 'member' | 'admin';
effective_from_year: number | null;
effective_until_year: number | null;
created_at: string;
}
export interface PendingInvite {
id: number;
invited_email: string;
invited_by_email: string;
effective_from_year: number | null;
created_at: string;
}
export interface Household {
id: number;
name: string;
created_by_email: string;
memberships: HouseholdMembership[];
pending_invites: PendingInvite[];
created_at: string;
}
@Injectable({ providedIn: 'root' })
export class FinancialYearService {
private base = '/api/financial-years';
constructor(private http: HttpClient) {}
list(): Observable<FinancialYear[]> {
return this.http.get<FinancialYear[]>(`${this.base}/`);
}
get(year: number): Observable<FinancialYear> {
return this.http.get<FinancialYear>(`${this.base}/${year}/`);
}
create(data: { year: number; notes?: string; household_id?: number }): Observable<FinancialYear> {
return this.http.post<FinancialYear>(`${this.base}/`, data);
}
update(year: number, data: Partial<{ is_active: boolean; notes: string }>): Observable<FinancialYear> {
return this.http.patch<FinancialYear>(`${this.base}/${year}/`, data);
}
copyFrom(year: number, sourceYear: number): Observable<any> {
return this.http.post(`${this.base}/${year}/copy-from/${sourceYear}/`, {});
}
// Incomes
createIncome(year: number, data: { name: string; amount: number; active: boolean; notes: string; member: number | null }): Observable<YearlyIncome> {
return this.http.post<YearlyIncome>(`${this.base}/${year}/incomes/`, data);
}
updateIncome(year: number, id: number, data: { name: string; amount: number; active: boolean; notes: string }): Observable<YearlyIncome> {
return this.http.patch<YearlyIncome>(`${this.base}/${year}/incomes/${id}/`, data);
}
deleteIncome(year: number, id: number): Observable<void> {
return this.http.delete<void>(`${this.base}/${year}/incomes/${id}/`);
}
// Budget Items
createBudgetItem(year: number, data: { name: string; amount: number; active: boolean; notes: string }): Observable<YearlyBudgetItem> {
return this.http.post<YearlyBudgetItem>(`${this.base}/${year}/budget-items/`, data);
}
updateBudgetItem(year: number, id: number, data: { name: string; amount: number; active: boolean; notes: string }): Observable<YearlyBudgetItem> {
return this.http.patch<YearlyBudgetItem>(`${this.base}/${year}/budget-items/${id}/`, data);
}
deleteBudgetItem(year: number, id: number): Observable<void> {
return this.http.delete<void>(`${this.base}/${year}/budget-items/${id}/`);
}
// Households
getHouseholds(): Observable<Household[]> {
return this.http.get<Household[]>('/api/households/');
}
createHousehold(name: string): Observable<Household> {
return this.http.post<Household>('/api/households/', { name });
}
inviteMember(pk: number, email: string): Observable<any> {
return this.http.post(`/api/households/${pk}/invite/`, { email });
}
acceptInvitation(pk: number): Observable<any> {
return this.http.post(`/api/households/${pk}/accept/`, {});
}
leaveHousehold(pk: number): Observable<any> {
return this.http.post(`/api/households/${pk}/leave/`, {});
}
setMemberRole(pk: number, membershipId: number, role: 'member' | 'admin'): Observable<HouseholdMembership> {
return this.http.post<HouseholdMembership>(`/api/households/${pk}/members/${membershipId}/set-role/`, { role });
}
getHouseholdRevenueAccounts(pk: number): Observable<any[]> {
return this.http.get<any[]>(`/api/households/${pk}/revenue-accounts/`);
}
}
+5
View File
@@ -9,6 +9,7 @@ export class SidebarService {
mobileOpen = signal(false); mobileOpen = signal(false);
budgetsOpen = signal(false); budgetsOpen = signal(false);
accountsOpen = signal(false); accountsOpen = signal(false);
salaryOpen = 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);
} }
toggleSalary() {
this.salaryOpen.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);
} }
+61 -70
View File
@@ -142,10 +142,69 @@
"fixed_costs": "Fixkosten", "fixed_costs": "Fixkosten",
"expenses": "Ausgaben", "expenses": "Ausgaben",
"calendar": "Kalender", "calendar": "Kalender",
"financial_year": "Jahresplanung",
"accounts": "Konten", "accounts": "Konten",
"revenue_accounts": "Einnahmekonten", "revenue_accounts": "Einnahmekonten",
"transactions": "Transaktionen" "transactions": "Transaktionen",
"salary": "Lohn",
"salarium": "Salarium",
"salary_analyse": "Analyse",
"salary_entwicklung": "Entwicklung"
},
"salarium": {
"title": "Salarium",
"subtitle": "Statistischer Lohnrechner des Bundesamts für Statistik (BFS).",
"open_external": "Im neuen Tab öffnen"
},
"salary_analyse": {
"title": "Lohnanalyse",
"subtitle": "Vergleiche deinen aktuellen Lohn mit dem Marktdurchschnitt.",
"coming_soon_title": "Lohnanalyse in Entwicklung",
"coming_soon_text": "Hier kannst du deinen Lohn mit Branchen- und Regionaldaten vergleichen."
},
"salary_entwicklung": {
"title": "Lohnentwicklung",
"subtitle": "Verfolge deine Lohnentwicklung über die Jahre.",
"coming_soon_title": "Lohnentwicklung in Entwicklung",
"coming_soon_text": "Visualisiere deine Lohnhistorie und erkenne Trends über die Zeit."
},
"lohn_analyse": {
"title": "Lohnanalyse",
"subtitle": "Berechne Brutto- und Nettolohn sowie den Totalaufwand des Arbeitgebers nach Schweizer Recht.",
"section_basics": "Grundlagen",
"label_monatslohn": "Monatslohn",
"label_monate": "Anzahl Monate",
"monate": "Monate",
"label_kanton": "Kanton",
"fak_rate_hint": "FAK-Beitragssatz:",
"label_verfahren": "Abrechnungsverfahren",
"verfahren_ordentlich": "Ordentliches Verfahren",
"verfahren_vereinfacht": "Vereinfachtes Verfahren",
"verfahren_quellensteuer": "Ordentliches mit Quellensteuer",
"verfahren_hint": "Dieses Verfahren wird noch nicht vollständig berechnet — es werden die Standardsätze verwendet.",
"section_optional": "Weitere Sätze (KTV / BU / NBU)",
"optional_hint": "KTV- und Berufsunfall-Sätze sind betriebsabhängig. Leer lassen wenn unbekannt.",
"beitraege_ag": "Arbeitgeber",
"abzuege_an": "Arbeitnehmer",
"ktv": "KTV",
"bu": "BU",
"nbu": "NBU",
"section_ag": "Kosten Arbeitgeber",
"section_an": "Abzüge Arbeitnehmer",
"bruttolohn": "Bruttolohn",
"ahv_iv_eo": "AHV / IV / EO",
"alv": "ALV",
"fak": "FAK",
"vk": "Verwaltungskosten (VK)",
"total_beitraege_ag": "Total Beiträge Arbeitgeber",
"totalaufwand_ag": "Totalaufwand Arbeitgeber",
"total_abzuege_an": "Total Abzüge Arbeitnehmer",
"nettolohn": "Nettolohn",
"col_position": "Position",
"col_satz": "Satz",
"col_monat": "/ Monat",
"col_jahr": "/ Jahr",
"jahr": "Jahr",
"source_hint": "Quelle: Lohnbudget Monatslohn, Bundesamt für Sozialversicherungen (BSV). Gilt für das ordentliche Abrechnungsverfahren."
}, },
"dashboard": { "dashboard": {
"title": "Dashboard", "title": "Dashboard",
@@ -346,74 +405,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",
+61 -70
View File
@@ -142,10 +142,69 @@
"fixed_costs": "Fixed Costs", "fixed_costs": "Fixed Costs",
"expenses": "Expenses", "expenses": "Expenses",
"calendar": "Calendar", "calendar": "Calendar",
"financial_year": "Annual Planning",
"accounts": "Accounts", "accounts": "Accounts",
"revenue_accounts": "Revenue Accounts", "revenue_accounts": "Revenue Accounts",
"transactions": "Transactions" "transactions": "Transactions",
"salary": "Salary",
"salarium": "Salarium",
"salary_analyse": "Analysis",
"salary_entwicklung": "Development"
},
"salarium": {
"title": "Salarium",
"subtitle": "Statistical salary calculator by the Federal Statistical Office (FSO).",
"open_external": "Open in new tab"
},
"salary_analyse": {
"title": "Salary Analysis",
"subtitle": "Compare your current salary with the market average.",
"coming_soon_title": "Salary Analysis in Development",
"coming_soon_text": "Here you will be able to compare your salary with industry and regional benchmarks."
},
"salary_entwicklung": {
"title": "Salary Development",
"subtitle": "Track your salary development over the years.",
"coming_soon_title": "Salary Development in Development",
"coming_soon_text": "Visualize your salary history and identify trends over time."
},
"lohn_analyse": {
"title": "Salary Analysis",
"subtitle": "Calculate gross and net salary as well as the total employer cost under Swiss law.",
"section_basics": "Basics",
"label_monatslohn": "Monthly Salary",
"label_monate": "Number of Months",
"monate": "Months",
"label_kanton": "Canton",
"fak_rate_hint": "FAK contribution rate:",
"label_verfahren": "Billing Method",
"verfahren_ordentlich": "Standard Method",
"verfahren_vereinfacht": "Simplified Method",
"verfahren_quellensteuer": "Standard with Withholding Tax",
"verfahren_hint": "This method is not yet fully calculated — standard rates are used.",
"section_optional": "Additional Rates (KTV / BU / NBU)",
"optional_hint": "KTV and accident insurance rates depend on the employer. Leave empty if unknown.",
"beitraege_ag": "Employer",
"abzuege_an": "Employee",
"ktv": "KTV",
"bu": "BU",
"nbu": "NBU",
"section_ag": "Employer Costs",
"section_an": "Employee Deductions",
"bruttolohn": "Gross Salary",
"ahv_iv_eo": "AHV / IV / EO",
"alv": "ALV",
"fak": "FAK",
"vk": "Admin Costs (VK)",
"total_beitraege_ag": "Total Employer Contributions",
"totalaufwand_ag": "Total Employer Cost",
"total_abzuege_an": "Total Employee Deductions",
"nettolohn": "Net Salary",
"col_position": "Position",
"col_satz": "Rate",
"col_monat": "/ Month",
"col_jahr": "/ Year",
"jahr": "Year",
"source_hint": "Source: Monthly Salary Budget, Federal Social Insurance Office (FSIO). Applies to the standard billing method."
}, },
"dashboard": { "dashboard": {
"title": "Dashboard", "title": "Dashboard",
@@ -346,74 +405,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",
+61 -70
View File
@@ -142,10 +142,69 @@
"fixed_costs": "Charges fixes", "fixed_costs": "Charges fixes",
"expenses": "Dépenses", "expenses": "Dépenses",
"calendar": "Calendrier", "calendar": "Calendrier",
"financial_year": "Planification annuelle",
"accounts": "Comptes", "accounts": "Comptes",
"revenue_accounts": "Comptes de revenus", "revenue_accounts": "Comptes de revenus",
"transactions": "Transactions" "transactions": "Transactions",
"salary": "Salaire",
"salarium": "Salarium",
"salary_analyse": "Analyse",
"salary_entwicklung": "Évolution"
},
"salarium": {
"title": "Salarium",
"subtitle": "Calculateur de salaire statistique de l'Office fédéral de la statistique (OFS).",
"open_external": "Ouvrir dans un nouvel onglet"
},
"salary_analyse": {
"title": "Analyse salariale",
"subtitle": "Comparez votre salaire actuel avec la moyenne du marché.",
"coming_soon_title": "Analyse salariale en développement",
"coming_soon_text": "Vous pourrez ici comparer votre salaire avec les données sectorielles et régionales."
},
"salary_entwicklung": {
"title": "Évolution salariale",
"subtitle": "Suivez l'évolution de votre salaire au fil des années.",
"coming_soon_title": "Évolution salariale en développement",
"coming_soon_text": "Visualisez votre historique salarial et identifiez les tendances au fil du temps."
},
"lohn_analyse": {
"title": "Analyse salariale",
"subtitle": "Calculez le salaire brut, net et le coût total de l'employeur selon le droit suisse.",
"section_basics": "Données de base",
"label_monatslohn": "Salaire mensuel",
"label_monate": "Nombre de mois",
"monate": "mois",
"label_kanton": "Canton",
"fak_rate_hint": "Taux de cotisation CAF :",
"label_verfahren": "Procédure de décompte",
"verfahren_ordentlich": "Procédure ordinaire",
"verfahren_vereinfacht": "Procédure simplifiée",
"verfahren_quellensteuer": "Procédure ordinaire avec impôt à la source",
"verfahren_hint": "Cette procédure n'est pas encore entièrement calculée — les taux standard sont utilisés.",
"section_optional": "Taux supplémentaires (IJM / AA)",
"optional_hint": "Les taux IJM et accidents dépendent de l'employeur. Laisser vide si inconnu.",
"beitraege_ag": "Employeur",
"abzuege_an": "Employé",
"ktv": "IJM",
"bu": "AA pro.",
"nbu": "AA non-pro.",
"section_ag": "Coûts employeur",
"section_an": "Déductions employé",
"bruttolohn": "Salaire brut",
"ahv_iv_eo": "AVS / AI / APG",
"alv": "AC",
"fak": "CAF",
"vk": "Frais admin. (VK)",
"total_beitraege_ag": "Total cotisations employeur",
"totalaufwand_ag": "Coût total employeur",
"total_abzuege_an": "Total déductions employé",
"nettolohn": "Salaire net",
"col_position": "Position",
"col_satz": "Taux",
"col_monat": "/ Mois",
"col_jahr": "/ An",
"jahr": "An",
"source_hint": "Source : Budget salaire mensuel, Office fédéral des assurances sociales (OFAS). Valable pour la procédure de décompte ordinaire."
}, },
"dashboard": { "dashboard": {
"title": "Tableau de bord", "title": "Tableau de bord",
@@ -346,74 +405,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",
+61 -70
View File
@@ -142,10 +142,69 @@
"fixed_costs": "Costi fissi", "fixed_costs": "Costi fissi",
"expenses": "Spese", "expenses": "Spese",
"calendar": "Calendario", "calendar": "Calendario",
"financial_year": "Pianificazione annuale",
"accounts": "Conti", "accounts": "Conti",
"revenue_accounts": "Conti entrate", "revenue_accounts": "Conti entrate",
"transactions": "Transazioni" "transactions": "Transazioni",
"salary": "Stipendio",
"salarium": "Salarium",
"salary_analyse": "Analisi",
"salary_entwicklung": "Sviluppo"
},
"salarium": {
"title": "Salarium",
"subtitle": "Calcolatore statistico degli stipendi dell'Ufficio federale di statistica (UST).",
"open_external": "Apri in una nuova scheda"
},
"salary_analyse": {
"title": "Analisi stipendio",
"subtitle": "Confronta il tuo stipendio attuale con la media di mercato.",
"coming_soon_title": "Analisi stipendio in sviluppo",
"coming_soon_text": "Qui potrai confrontare il tuo stipendio con i dati del settore e della regione."
},
"salary_entwicklung": {
"title": "Sviluppo stipendio",
"subtitle": "Monitora lo sviluppo del tuo stipendio nel tempo.",
"coming_soon_title": "Sviluppo stipendio in sviluppo",
"coming_soon_text": "Visualizza la tua storia salariale e identifica le tendenze nel tempo."
},
"lohn_analyse": {
"title": "Analisi stipendio",
"subtitle": "Calcola lo stipendio lordo, netto e il costo totale del datore di lavoro secondo il diritto svizzero.",
"section_basics": "Dati di base",
"label_monatslohn": "Stipendio mensile",
"label_monate": "Numero di mesi",
"monate": "mesi",
"label_kanton": "Cantone",
"fak_rate_hint": "Aliquota CAF:",
"label_verfahren": "Procedura di conteggio",
"verfahren_ordentlich": "Procedura ordinaria",
"verfahren_vereinfacht": "Procedura semplificata",
"verfahren_quellensteuer": "Procedura ordinaria con imposta alla fonte",
"verfahren_hint": "Questa procedura non è ancora completamente calcolata — vengono utilizzate le aliquote standard.",
"section_optional": "Aliquote aggiuntive (IJM / LAINF)",
"optional_hint": "Le aliquote IJM e infortuni dipendono dal datore di lavoro. Lasciare vuoto se sconosciuto.",
"beitraege_ag": "Datore di lavoro",
"abzuege_an": "Lavoratore",
"ktv": "IJM",
"bu": "LAINF pro.",
"nbu": "LAINF non-pro.",
"section_ag": "Costi datore di lavoro",
"section_an": "Deduzioni lavoratore",
"bruttolohn": "Stipendio lordo",
"ahv_iv_eo": "AVS / AI / IPG",
"alv": "AD",
"fak": "CAF",
"vk": "Spese amm. (VK)",
"total_beitraege_ag": "Totale contributi datore di lavoro",
"totalaufwand_ag": "Costo totale datore di lavoro",
"total_abzuege_an": "Totale deduzioni lavoratore",
"nettolohn": "Stipendio netto",
"col_position": "Posizione",
"col_satz": "Aliquota",
"col_monat": "/ Mese",
"col_jahr": "/ Anno",
"jahr": "Anno",
"source_hint": "Fonte: Budget stipendio mensile, Ufficio federale delle assicurazioni sociali (UFAS). Valido per la procedura di conteggio ordinaria."
}, },
"dashboard": { "dashboard": {
"title": "Dashboard", "title": "Dashboard",
@@ -346,74 +405,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",