1 Commits

Author SHA1 Message Date
Daniel Krähenbühl fe4aeb3034 feat: financial year planning — annual budgets, income tracking, household sharing
- Financial year page (/financial-year): year selector, 3 KPI cards (income,
  fixed costs, actual expenses), income and budget-items tabs with inline CRUD
- Revenue accounts as income source: salary-months toggle (12/13) per account
- Household support: create household, invite members by email (existing and
  new users via PendingHouseholdInvite), accept invitations, set roles
- Combined household income view across all active members
- FinancialYear, YearlyIncome, YearlyBudgetItem, Household, HouseholdMembership
  models with migrations; household invite email template
- Management command to migrate existing accounts/budgets to financial years
- FinancialYearService in Angular with full API integration
- Dashboard updated: income/fixed-costs read from financial year data,
  year dropdown synced with available financial years
- Sidebar: financial year nav item added
- i18n: all keys in DE/EN/FR/IT
2026-05-25 22:46:30 +02:00
35 changed files with 2685 additions and 940 deletions
+52 -1
View File
@@ -5,7 +5,58 @@ All notable changes to this project will be documented in this file.
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
## [1.1.0] - 2026-05-19
## [Unreleased]
### Added
- Financial Year: Income tab zeigt neu Revenue Accounts (Typ «Einnahmequelle») statt YearlyIncome-Einträge — Monatsgehalt × Monate = Jahreseinkommen; Toggle-Button pro Konto für 12 oder 13 Monatslöhne; Gesamtjahreseinkommen-Summe am Tab-Ende
- Account-Model: `salary_months` Feld (IntegerField, default 12, choices 12/13, Migration 0021); `patchAccount()` in ApiService
- Financial Year: Summary-Cards überarbeitet — (1) Jahreseinkommen aus Revenue Accounts, (2) Fixkosten/Monat × 12 = Jahresbetrag aus `/budgets`, (3) tatsächliche Ausgaben des gewählten Jahres aus `/expenses` (ersetzt «Verfügbar»)
- Financial Year: Haushalt-Finanzjahr erstellbar — Modal «Neues Jahr starten» zeigt Radio-Buttons «Persönlich» / Haushalt-Name wenn User aktive Haushaltsmitgliedschaft hat; Backend akzeptiert optionales `household_id` bei `POST /api/financial-years/`
- Financial Year: Haushalt-Modus Einnahmen-Tab zeigt Revenue Accounts aller aktiven Haushaltsmitglieder (neuer Endpoint `GET /api/households/<pk>/revenue-accounts/`); Partner-Accounts mit E-Mail-Hinweis
- `household_id` in FinancialYear-Serializer-Response
- Haushalt Einladungsflow für nicht-registrierte Benutzer: `PendingHouseholdInvite` Model (Migration 0022) speichert E-Mail ohne User-FK; nach Registrierung wird `HouseholdMembership` automatisch angelegt und `PendingHouseholdInvite` gelöscht
- Einladungs-E-Mail via `household_invite` Template (HTML + Plaintext) mit variablem CTA-Label; bestehende User erhalten «Einladung annehmen» → `/financial-year`, neue User «Konto erstellen & beitreten» → `/register`
- Frontend zeigt ausstehende Einladungen an nicht-registrierte Adressen mit Badge «Nicht registriert» in der Haushaltsliste
- `FRONTEND_URL` Setting (default `http://localhost:4200`; Prod: `https://www.armarium.ch` in `.env`)
- ProfileSerializer: `get_email()` gibt `user.email` zurück wenn Profile-Email leer — verhindert dass `myMembership()` für neue User keine Treffer findet
### Fixed
- Dashboard: `totalExpenses()` filterte nicht nach ausgewähltem Jahr — alle Ausgaben wurden summiert
- Dashboard: `totalIncome()` und `totalFixedCosts()` lasen aus FinancialYear statt aus Revenue Accounts / `/budgets` — inkonsistent mit Dateneingabe-Workflow des Users
- Financial Year: `updateIncome()` und `updateBudgetItem()` verwendeten `PUT` statt `PATCH` → 405 Method Not Allowed
- Financial Year: `reloadCurrentYear()` löste `NG0100 ExpressionChangedAfterItHasBeenCheckedError` aus — Signal-Updates in `setTimeout()` verschoben
- Financial Year: `PATCH /incomes/<id>/` und `/budget-items/<id>/` gaben 403 zurück wenn `is_active=False` auf FinancialYear — `is_active`-Check aus 5 Backend-Views entfernt
- Backend: `Profile.email_verified` und verwandte Felder existierten in DB aber nicht im Model → `IntegrityError` beim Login neuer User; Felder ins Model aufgenommen (Migration 0023, fake-applied); DB-Defaults via `ALTER COLUMN ... SET DEFAULT` gesetzt
### Added
- Feature: Jahresplanung (`/financial-year`) — neue Seite mit Jahres-Dropdown, 3 Summary-Cards (Einnahmen, Fixkosten, Verfügbar + Sparquote), Tabs Einnahmen/Fixkosten, Inline-Formular für CRUD; Button "Neues Jahr starten" — max. 1 Jahr im Voraus (Backend + Frontend enforced)
- Backend: `FinancialYear`, `YearlyIncome`, `YearlyBudgetItem`, `Household`, `HouseholdMembership` Modelle (Migration 0019); exclusivity-Constraint via `CheckConstraint(condition=...)` (Django 6.0.4), partielle Unique-Constraints für persönliche und Haushalt-Jahre
- Backend: `FinancialYearListCreateView`, `FinancialYearDetailView`, `FinancialYearCopyView`, `YearlyIncomeListCreateView/DetailView`, `YearlyBudgetItemListCreateView/DetailView`, `HouseholdListCreateView`, `HouseholdInviteView`, `HouseholdAcceptView`, `HouseholdLeaveView`, `HouseholdSetRoleView`
- Backend: `GET/POST /api/financial-years/`, `GET/PATCH/DELETE /api/financial-years/<year>/`, `POST /api/financial-years/<year>/copy-from/<source>/`, nested Endpunkte für Incomes und Budget Items; `GET/POST /api/households/`, Invite/Accept/Leave/SetRole
- Backend: Jahr-Erstellungs-Begrenzer — max. `current_calendar_year + 1`; plus "nur nächstes Jahr nach dem Maximum" Constraint
- Backend: `role` Feld auf `HouseholdMembership` (`member` | `admin`, Migration 0020); Gründer erhält automatisch `role='admin'`; Einladungen erlaubt für Gründer und aktive Admins; Rollenvergabe nur durch Gründer via `POST /api/households/<pk>/members/<id>/set-role/`
- Dashboard: `totalIncome()` und `totalFixedCosts()` lesen nun aus `FinancialYearService.list()` für das gewählte Jahr (statt alte Account/Budget-Daten); Jahres-Dropdown zeigt echte FinancialYear-Jahre; Donut-Chart zeigt `YearlyBudgetItem` des gewählten Jahres; Jahrwechsel re-rendert beide Charts
- Backend: Django Management Command `migrate_to_financial_year` — migriert bestehende Revenue-Accounts → `YearlyIncome` und Budgets → `YearlyBudgetItem` für Jahr 2026; idempotent, `--dry-run` Flag verfügbar
- Frontend: `FinancialYearService` (`services/financial-year.ts`) mit Typen `FinancialYear`, `YearlyIncome`, `YearlyBudgetItem`, `Household`, `HouseholdMembership` und allen API-Methoden
- Frontend: Household-Sektion auf `/financial-year` — Haushalt gründen (Inline-Form), Mitglieder-Liste mit Status- und Rollen-Badge, Einladen per E-Mail (Admins + Gründer), Rollen-Toggle (Key-Icon, nur Gründer), Pending-Einladungs-Banner mit "Annehmen", "Verlassen"-Button mit Bestätigungs-Modal
- Sidebar: "Jahresplanung" Nav-Item (Bar-Chart-Icon, Violet) zwischen Kalender und Konten
- i18n: `sidebar.financial_year`, `financial_year.*` Schlüssel (DE/EN/FR/IT)
- Dashboard: Einnahmen vs. Ausgaben — Flowbite-Redesign mit Icon-Header (Violet), 3 Serien (Einnahmen/Fixkosten/Variable Ausgaben), gerundete Balken, kein Grid/Y-Axis, custom Tooltip mit ausgeschriebenem Monatsnamen in Landessprache, Jahres-Dropdown im Footer
- Dashboard: Fixkostenaufschlüsselung — Pie Chart (war: Donut) mit %-Datenlabels direkt auf Segmenten; Toggle-Button (Violet) wechselt zur Listenansicht mit Name, CHF-Betrag und %; Violet-Farbpalette
- Dashboard: Sparquote — Violet-Marker auf Progress-Bar an der Zielposition; Settings-Toggle (Badge-Icon, Violet) öffnet Einstellungsansicht mit Zahlenfeld, Live-Marker-Preview und Speichern/Abbrechen; Ziel persisted im Profil (`savings_rate_goal`, Default 20%)
- Backend: `savings_rate_goal` Feld auf `Profile`-Modell (Migration 0018)
- i18n: `dashboard.view_report`, `dashboard.goal_hint` in DE/EN/FR/IT; `dashboard.goal` von "Ziel: 20%" zu "Sparziel" geändert
- Security: Cloudflare Turnstile CAPTCHA on login and register — `TurnstileComponent` (Angular, polls until script loaded, auto-reset on error); backend verifies token via `_verify_turnstile()` using urllib (no extra dependency); `DEBUG=True` and `localhost` bypass for local development; Submit button disabled until widget resolves
- Infrastructure: Brevo SMTP configured for transactional email (`smtp-relay.brevo.com:587`, TLS); domain `armarium.ch` verified with SPF/DKIM; account activation pending (requested via contact@brevo.com)
- i18n: `auth.errors.captcha_failed` key in DE/EN/FR/IT
- Docs: `design-system.md` — Brand design reference with colors, typography (desktop/mobile), icons, component patterns and Tailwind classes
### Changed
- `.env.example`: added `TURNSTILE_SECRET_KEY` and Brevo `EMAIL_*` variables
---
## [1.1.0] - 2026-05-17
### Added
- Auth: E-Mail-Verifikation bei Registrierung — Token (SHA-256-Hash in DB, 24h gültig) wird per Mail versendet; `/verify-email?token=` Frontend-Route löst automatisch `POST /api/auth/verify-email/` aus
+1
View File
@@ -104,6 +104,7 @@ EMAIL_HOST_USER = os.environ.get('EMAIL_HOST_USER', '')
EMAIL_HOST_PASSWORD = os.environ.get('EMAIL_HOST_PASSWORD', '')
EMAIL_USE_TLS = os.environ.get('EMAIL_USE_TLS', 'True') == 'True'
DEFAULT_FROM_EMAIL = os.environ.get('DEFAULT_FROM_EMAIL', 'noreply@armarium.ch')
FRONTEND_URL = os.environ.get('FRONTEND_URL', 'http://localhost:4200')
LOGGING = {
'version': 1,
+18
View File
@@ -14,6 +14,11 @@ from finance.views import (
SessionListView, SessionRevokeView, SessionRevokeAllView,
DataExportView, NotificationPrefsView,
VerifyEmailView, PasswordResetRequestView, PasswordResetConfirmView,
FinancialYearListCreateView, FinancialYearDetailView, FinancialYearCopyView,
YearlyIncomeListCreateView, YearlyIncomeDetailView,
YearlyBudgetItemListCreateView, YearlyBudgetItemDetailView,
HouseholdListCreateView, HouseholdInviteView, HouseholdAcceptView, HouseholdLeaveView,
HouseholdSetRoleView, HouseholdRevenueAccountsView,
)
router = DefaultRouter()
@@ -52,4 +57,17 @@ urlpatterns = [
path('api/notifications/', NotificationsView.as_view()),
path('api/calendar/ical-url/', ICalUrlView.as_view()),
path('api/calendar/ical/<int:user_id>/<str:token>/', ICalFeedView.as_view()),
path('api/financial-years/', FinancialYearListCreateView.as_view()),
path('api/financial-years/<int:year>/', FinancialYearDetailView.as_view()),
path('api/financial-years/<int:year>/copy-from/<int:source_year>/', FinancialYearCopyView.as_view()),
path('api/financial-years/<int:year>/incomes/', YearlyIncomeListCreateView.as_view()),
path('api/financial-years/<int:year>/incomes/<int:pk>/', YearlyIncomeDetailView.as_view()),
path('api/financial-years/<int:year>/budget-items/', YearlyBudgetItemListCreateView.as_view()),
path('api/financial-years/<int:year>/budget-items/<int:pk>/', YearlyBudgetItemDetailView.as_view()),
path('api/households/', HouseholdListCreateView.as_view()),
path('api/households/<int:pk>/invite/', HouseholdInviteView.as_view()),
path('api/households/<int:pk>/accept/', HouseholdAcceptView.as_view()),
path('api/households/<int:pk>/leave/', HouseholdLeaveView.as_view()),
path('api/households/<int:pk>/members/<int:membership_id>/set-role/', HouseholdSetRoleView.as_view()),
path('api/households/<int:pk>/revenue-accounts/', HouseholdRevenueAccountsView.as_view()),
] + static(settings.MEDIA_URL, document_root=settings.MEDIA_ROOT)
@@ -0,0 +1,85 @@
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.'))
@@ -0,0 +1,89 @@
# 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')},
),
]
@@ -0,0 +1,18 @@
# Generated by Django 6.0.4 on 2026-05-19 07:24
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('finance', '0019_financial_year'),
]
operations = [
migrations.AddField(
model_name='householdmembership',
name='role',
field=models.CharField(choices=[('member', 'Member'), ('admin', 'Admin')], default='member', max_length=10),
),
]
@@ -0,0 +1,18 @@
# 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),
),
]
@@ -0,0 +1,30 @@
# Generated by Django 6.0.4 on 2026-05-21 19:49
import django.db.models.deletion
from django.conf import settings
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('finance', '0021_add_salary_months_to_account'),
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
]
operations = [
migrations.CreateModel(
name='PendingHouseholdInvite',
fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('invited_email', models.EmailField(max_length=254)),
('effective_from_year', models.PositiveSmallIntegerField(blank=True, null=True)),
('created_at', models.DateTimeField(auto_now_add=True)),
('household', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='pending_invites', to='finance.household')),
('invited_by', models.ForeignKey(null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='sent_pending_invitations', to=settings.AUTH_USER_MODEL)),
],
options={
'unique_together': {('household', 'invited_email')},
},
),
]
@@ -0,0 +1,38 @@
# 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,6 +18,7 @@ class Account(models.Model):
name = models.CharField(max_length=100)
account_type = models.CharField(max_length=20, choices=ACCOUNT_TYPES, default='asset')
balance = models.DecimalField(max_digits=12, decimal_places=2, default=0.00)
salary_months = models.IntegerField(default=12, choices=[(12, 12), (13, 13)])
active = models.BooleanField(default=True)
created_at = models.DateTimeField(auto_now_add=True)
@@ -224,3 +225,144 @@ class BackupCode(models.Model):
def __str__(self):
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})"
+93 -2
View File
@@ -1,6 +1,10 @@
from rest_framework import serializers
from django.contrib.auth import get_user_model
from .models import Account, Transaction, Budget, Expense, Profile, Deadline
from .models import (
Account, Transaction, Budget, Expense, Profile, Deadline,
Household, HouseholdMembership, PendingHouseholdInvite,
FinancialYear, YearlyIncome, YearlyBudgetItem,
)
User = get_user_model()
@@ -44,6 +48,10 @@ class ExpenseSerializer(serializers.ModelSerializer):
class ProfileSerializer(serializers.ModelSerializer):
totp_enabled = serializers.BooleanField(read_only=True)
email = serializers.SerializerMethodField()
def get_email(self, obj):
return obj.email or (obj.user.email if obj.user else '')
class Meta:
model = Profile
@@ -56,6 +64,76 @@ class DeadlineSerializer(serializers.ModelSerializer):
exclude = ['user']
class HouseholdMembershipSerializer(serializers.ModelSerializer):
user_email = serializers.EmailField(source='user.email', read_only=True)
invited_by_email = serializers.EmailField(source='invited_by.email', read_only=True)
class 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):
email = serializers.EmailField()
password = serializers.CharField(min_length=8, write_only=True)
@@ -67,8 +145,21 @@ class RegisterSerializer(serializers.Serializer):
def create(self, validated_data):
email = validated_data['email']
return User.objects.create_user(
user = User.objects.create_user(
username=email,
email=email,
password=validated_data['password'],
)
from .models import PendingHouseholdInvite, HouseholdMembership
for invite in PendingHouseholdInvite.objects.filter(invited_email__iexact=email):
HouseholdMembership.objects.get_or_create(
household=invite.household,
user=user,
defaults={
'invited_by': invite.invited_by,
'status': 'pending',
'effective_from_year': invite.effective_from_year,
},
)
invite.delete()
return user
+460 -1
View File
@@ -1,4 +1,5 @@
import base64
import datetime
import hmac
import hashlib
import json
@@ -15,16 +16,23 @@ from django.contrib.auth import get_user_model, authenticate
from django.http import HttpResponse
from icalendar import Calendar as iCalendar, Event as iCalEvent
from django.db import models
from rest_framework import viewsets, views, status
from rest_framework.response import Response
from rest_framework.permissions import AllowAny, IsAuthenticated
from rest_framework.throttling import AnonRateThrottle
from rest_framework_simplejwt.tokens import RefreshToken
from rest_framework_simplejwt.exceptions import TokenError
from .models import Account, Transaction, Budget, Expense, Profile, Deadline, ReadEvent, BackupCode, UserSession
from django.db import transaction as db_transaction
from .models import (
Account, Transaction, Budget, Expense, Profile, Deadline, ReadEvent, BackupCode, UserSession,
Household, HouseholdMembership, FinancialYear, YearlyIncome, YearlyBudgetItem,
)
from .serializers import (
AccountSerializer, TransactionSerializer, BudgetSerializer,
ExpenseSerializer, ProfileSerializer, DeadlineSerializer, RegisterSerializer,
HouseholdSerializer, HouseholdMembershipSerializer,
FinancialYearSerializer, YearlyIncomeSerializer, YearlyBudgetItemSerializer,
)
@@ -997,3 +1005,454 @@ class PasswordResetConfirmView(views.APIView):
for session in UserSession.objects.filter(user=user):
_blacklist_session(session)
return Response({'detail': 'Password updated.'})
# ── FinancialYear Helpers ─────────────────────────────────────────────────────
def _get_user_financial_year(user, year):
"""Return the FinancialYear for a given year accessible to this user."""
# Personal year
fy = FinancialYear.objects.filter(user=user, year=year).first()
if fy:
return fy
# Household year where user is an active member for this year
memberships = HouseholdMembership.objects.filter(
user=user,
status='active',
effective_from_year__lte=year,
).filter(
models.Q(effective_until_year__isnull=True) | models.Q(effective_until_year__gt=year)
)
household_ids = memberships.values_list('household_id', flat=True)
return FinancialYear.objects.filter(household_id__in=household_ids, year=year).first()
def _all_user_financial_years(user):
"""Return all FinancialYears accessible to this user."""
personal = FinancialYear.objects.filter(user=user)
memberships = HouseholdMembership.objects.filter(user=user, status='active')
household_ids = memberships.values_list('household_id', flat=True)
household = FinancialYear.objects.filter(household_id__in=household_ids)
return (personal | household).distinct().order_by('-year')
def _max_year_for_user(user):
"""Return the highest year the user currently has access to."""
years = _all_user_financial_years(user).values_list('year', flat=True)
return max(years) if years else None
# ── FinancialYear Views ───────────────────────────────────────────────────────
class FinancialYearListCreateView(views.APIView):
def get(self, request):
qs = _all_user_financial_years(request.user)
return Response(FinancialYearSerializer(qs, many=True).data)
def post(self, request):
year = request.data.get('year')
if not year:
return Response({'year': 'This field is required.'}, status=400)
try:
year = int(year)
except (TypeError, ValueError):
return Response({'year': 'Must be an integer.'}, status=400)
current_year = datetime.date.today().year
if year > current_year + 1:
return Response(
{'year': f'You can only create years up to {current_year + 1}.'},
status=400,
)
max_year = _max_year_for_user(request.user)
if max_year is not None and year != max_year + 1:
return Response(
{'year': f'You can only create the next year ({max_year + 1}).'},
status=400,
)
household_id = request.data.get('household_id')
if household_id:
household = Household.objects.filter(
id=household_id,
memberships__user=request.user,
memberships__status='active',
).first()
if not household:
return Response({'detail': 'Household not found or not a member.'}, status=404)
if FinancialYear.objects.filter(household=household, year=year).exists():
return Response({'year': 'This year already exists for this household.'}, status=400)
FinancialYear.objects.filter(household=household, is_active=True).update(is_active=False)
fy = FinancialYear.objects.create(household=household, year=year, is_active=True)
else:
if FinancialYear.objects.filter(user=request.user, year=year).exists():
return Response({'year': 'This year already exists.'}, status=400)
FinancialYear.objects.filter(user=request.user, is_active=True).update(is_active=False)
fy = FinancialYear.objects.create(user=request.user, year=year, is_active=True)
return Response(FinancialYearSerializer(fy).data, status=201)
class FinancialYearDetailView(views.APIView):
def _get_or_404(self, request, year):
fy = _get_user_financial_year(request.user, year)
if not fy:
return None
return fy
def get(self, request, year):
fy = self._get_or_404(request, year)
if not fy:
return Response({'detail': 'Not found.'}, status=404)
return Response(FinancialYearSerializer(fy).data)
def patch(self, request, year):
fy = self._get_or_404(request, year)
if not fy:
return Response({'detail': 'Not found.'}, status=404)
serializer = FinancialYearSerializer(fy, data=request.data, partial=True)
if serializer.is_valid():
serializer.save()
return Response(serializer.data)
return Response(serializer.errors, status=400)
def delete(self, request, year):
fy = self._get_or_404(request, year)
if not fy:
return Response({'detail': 'Not found.'}, status=404)
if not fy.is_active:
return Response({'detail': 'Archived years cannot be deleted.'}, status=400)
fy.delete()
return Response(status=204)
class FinancialYearCopyView(views.APIView):
def post(self, request, year, source_year):
source = _get_user_financial_year(request.user, source_year)
if not source:
return Response({'detail': f'Source year {source_year} not found.'}, status=404)
target = _get_user_financial_year(request.user, year)
if not target:
return Response({'detail': f'Target year {year} not found.'}, status=404)
if not target.is_active:
return Response({'detail': 'Target year is archived.'}, status=400)
with db_transaction.atomic():
incomes_copied = 0
for income in source.incomes.all():
YearlyIncome.objects.create(
financial_year=target,
member=income.member,
name=income.name,
amount=income.amount,
active=income.active,
notes=income.notes,
)
incomes_copied += 1
items_copied = 0
for item in source.budget_items.all():
YearlyBudgetItem.objects.create(
financial_year=target,
name=item.name,
amount=item.amount,
active=item.active,
notes=item.notes,
)
items_copied += 1
return Response({
'year': year,
'source_year': source_year,
'incomes_copied': incomes_copied,
'budget_items_copied': items_copied,
})
# ── YearlyIncome Views ────────────────────────────────────────────────────────
class YearlyIncomeListCreateView(views.APIView):
def _get_year_or_404(self, request, year):
fy = _get_user_financial_year(request.user, year)
return fy
def get(self, request, year):
fy = self._get_year_or_404(request, year)
if not fy:
return Response({'detail': 'Not found.'}, status=404)
return Response(YearlyIncomeSerializer(fy.incomes.all(), many=True).data)
def post(self, request, year):
fy = self._get_year_or_404(request, year)
if not fy:
return Response({'detail': 'Not found.'}, status=404)
if not fy.is_active:
return Response({'detail': 'Archived years are read-only.'}, status=403)
serializer = YearlyIncomeSerializer(data=request.data)
if serializer.is_valid():
serializer.save(financial_year=fy, member=request.user)
return Response(serializer.data, status=201)
return Response(serializer.errors, status=400)
class YearlyIncomeDetailView(views.APIView):
def _get_income_or_404(self, request, year, pk):
fy = _get_user_financial_year(request.user, year)
if not fy:
return None, None
income = fy.incomes.filter(pk=pk).first()
return fy, income
def patch(self, request, year, pk):
fy, income = self._get_income_or_404(request, year, pk)
if not income:
return Response({'detail': 'Not found.'}, status=404)
serializer = YearlyIncomeSerializer(income, data=request.data, partial=True)
if serializer.is_valid():
serializer.save()
return Response(serializer.data)
return Response(serializer.errors, status=400)
def delete(self, request, year, pk):
fy, income = self._get_income_or_404(request, year, pk)
if not income:
return Response({'detail': 'Not found.'}, status=404)
income.delete()
return Response(status=204)
# ── YearlyBudgetItem Views ────────────────────────────────────────────────────
class YearlyBudgetItemListCreateView(views.APIView):
def _get_year_or_404(self, request, year):
return _get_user_financial_year(request.user, year)
def get(self, request, year):
fy = self._get_year_or_404(request, year)
if not fy:
return Response({'detail': 'Not found.'}, status=404)
return Response(YearlyBudgetItemSerializer(fy.budget_items.all(), many=True).data)
def post(self, request, year):
fy = self._get_year_or_404(request, year)
if not fy:
return Response({'detail': 'Not found.'}, status=404)
serializer = YearlyBudgetItemSerializer(data=request.data)
if serializer.is_valid():
serializer.save(financial_year=fy)
return Response(serializer.data, status=201)
return Response(serializer.errors, status=400)
class YearlyBudgetItemDetailView(views.APIView):
def _get_item_or_404(self, request, year, pk):
fy = _get_user_financial_year(request.user, year)
if not fy:
return None, None
item = fy.budget_items.filter(pk=pk).first()
return fy, item
def patch(self, request, year, pk):
fy, item = self._get_item_or_404(request, year, pk)
if not item:
return Response({'detail': 'Not found.'}, status=404)
serializer = YearlyBudgetItemSerializer(item, data=request.data, partial=True)
if serializer.is_valid():
serializer.save()
return Response(serializer.data)
return Response(serializer.errors, status=400)
def delete(self, request, year, pk):
fy, item = self._get_item_or_404(request, year, pk)
if not item:
return Response({'detail': 'Not found.'}, status=404)
item.delete()
return Response(status=204)
# ── Household Views ───────────────────────────────────────────────────────────
class HouseholdListCreateView(views.APIView):
def get(self, request):
memberships = HouseholdMembership.objects.filter(user=request.user, status__in=['active', 'pending'])
household_ids = memberships.values_list('household_id', flat=True)
created = Household.objects.filter(created_by=request.user)
qs = (Household.objects.filter(id__in=household_ids) | created).distinct()
return Response(HouseholdSerializer(qs, many=True).data)
def post(self, request):
serializer = HouseholdSerializer(data=request.data)
if serializer.is_valid():
household = serializer.save(created_by=request.user)
# Creator is automatically an active member
next_year = (datetime.date.today().year + 1)
HouseholdMembership.objects.create(
household=household,
user=request.user,
invited_by=request.user,
status='active',
role='admin',
effective_from_year=next_year,
)
return Response(HouseholdSerializer(household).data, status=201)
return Response(serializer.errors, status=400)
class HouseholdInviteView(views.APIView):
def post(self, request, pk):
household = Household.objects.filter(pk=pk).first()
if not household:
return Response({'detail': 'Not found.'}, status=404)
# Only founder or active admins can invite
is_founder = household.created_by == request.user
is_admin = HouseholdMembership.objects.filter(
household=household, user=request.user, status='active', role='admin'
).exists()
if not (is_founder or is_admin):
return Response({'detail': 'Only admins can invite members.'}, status=403)
email = request.data.get('email', '').strip().lower()
User = get_user_model()
invitee = User.objects.filter(email__iexact=email).first()
from django.conf import settings
from .email import send_email
from .models import PendingHouseholdInvite
next_year = datetime.date.today().year + 1
inviter_name = request.user.get_full_name() or request.user.email
if not invitee:
if PendingHouseholdInvite.objects.filter(household=household, invited_email__iexact=email).exists():
return Response({'detail': 'Invitation already sent to this email.'}, status=400)
PendingHouseholdInvite.objects.create(
household=household,
invited_by=request.user,
invited_email=email,
effective_from_year=next_year,
)
register_url = f"{settings.FRONTEND_URL}/register"
send_email(
template_name='household_invite',
subject=f'Einladung zum Haushalt «{household.name}»',
context={
'invitee_name': email,
'inviter_name': inviter_name,
'household_name': household.name,
'accept_url': register_url,
'cta_label': 'Konto erstellen & beitreten',
},
to=email,
)
return Response({'detail': f'Registration invitation sent to {email}.'})
if invitee == request.user:
return Response({'detail': 'You cannot invite yourself.'}, status=400)
if HouseholdMembership.objects.filter(household=household, user=invitee, status__in=['pending', 'active']).exists():
return Response({'detail': 'User is already a member or has a pending invitation.'}, status=400)
HouseholdMembership.objects.create(
household=household,
user=invitee,
invited_by=request.user,
status='pending',
effective_from_year=next_year,
)
invitee_name = invitee.get_full_name() or invitee.email
send_email(
template_name='household_invite',
subject=f'Einladung zum Haushalt «{household.name}»',
context={
'invitee_name': invitee_name,
'inviter_name': inviter_name,
'household_name': household.name,
'accept_url': f"{settings.FRONTEND_URL}/financial-year",
'cta_label': 'Einladung annehmen',
},
to=invitee.email,
)
return Response({'detail': f'Invitation sent to {email} for year {next_year}.'})
class HouseholdAcceptView(views.APIView):
def post(self, request, pk):
membership = HouseholdMembership.objects.filter(
household_id=pk, user=request.user, status='pending'
).first()
if not membership:
return Response({'detail': 'No pending invitation found.'}, status=404)
membership.status = 'active'
membership.save(update_fields=['status'])
return Response({'detail': 'Invitation accepted.'})
class HouseholdLeaveView(views.APIView):
def post(self, request, pk):
membership = HouseholdMembership.objects.filter(
household_id=pk, user=request.user, status='active'
).first()
if not membership:
return Response({'detail': 'You are not an active member of this household.'}, status=404)
next_year = datetime.date.today().year + 1
membership.status = 'left'
membership.effective_until_year = next_year
membership.save(update_fields=['status', 'effective_until_year'])
return Response({'detail': f'You will leave this household at the end of {next_year - 1}.'})
class HouseholdSetRoleView(views.APIView):
def post(self, request, pk, membership_id):
# Only the founder can assign roles
household = Household.objects.filter(pk=pk, created_by=request.user).first()
if not household:
return Response({'detail': 'Not found or not owner.'}, status=404)
membership = HouseholdMembership.objects.filter(
pk=membership_id, household=household, status='active'
).first()
if not membership:
return Response({'detail': 'Active membership not found.'}, status=404)
if membership.user == request.user:
return Response({'detail': 'Cannot change your own role.'}, status=400)
role = request.data.get('role')
if role not in ['member', 'admin']:
return Response({'detail': 'Role must be "member" or "admin".'}, status=400)
membership.role = role
membership.save(update_fields=['role'])
return Response(HouseholdMembershipSerializer(membership).data)
class HouseholdRevenueAccountsView(views.APIView):
def get(self, request, pk):
membership = HouseholdMembership.objects.filter(
household_id=pk, user=request.user, status='active'
).first()
if not membership:
return Response({'detail': 'Not a member of this household.'}, status=403)
member_users = HouseholdMembership.objects.filter(
household_id=pk, status='active'
).values_list('user_id', flat=True)
accounts = Account.objects.filter(
user_id__in=member_users, account_type='revenue', active=True
).select_related('user')
data = [
{
'id': a.id,
'name': a.name,
'balance': str(a.balance),
'salary_months': a.salary_months,
'owner_email': a.user.email,
'is_mine': a.user_id == request.user.id,
}
for a in accounts
]
return Response(data)
@@ -0,0 +1,34 @@
{% 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 %}
@@ -0,0 +1,10 @@
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
+2 -6
View File
@@ -14,9 +14,7 @@ import { ExpenseList } from './expenses/expense-list/expense-list';
import { Profile } from './profile/profile';
import { Settings } from './settings/settings';
import { Calendar } from './calendar/calendar';
import { Salarium } from './salary/salarium/salarium';
import { SalaryAnalyse } from './salary/analyse/analyse';
import { SalaryEntwicklung } from './salary/entwicklung/entwicklung';
import { FinancialYearComponent } from './financial-year/financial-year';
export const routes: Routes = [
{ path: 'login', component: Login },
{ path: 'register', component: Register },
@@ -37,9 +35,7 @@ export const routes: Routes = [
{ path: 'profile', component: Profile },
{ path: 'settings', component: Settings },
{ path: 'calendar', component: Calendar },
{ path: 'salarium', component: Salarium },
{ path: 'lohn-analyse', component: SalaryAnalyse },
{ path: 'lohn-entwicklung', component: SalaryEntwicklung },
{ path: 'financial-year', component: FinancialYearComponent },
],
},
{ path: '**', redirectTo: 'dashboard' },
+26 -14
View File
@@ -2,6 +2,7 @@ import { Component, OnInit, OnDestroy, AfterViewInit, signal } from '@angular/co
import { CommonModule } from '@angular/common';
import { TranslateModule, TranslateService } from '@ngx-translate/core';
import { ApiService } from '../services/api';
import { FinancialYearService, FinancialYear } from '../services/financial-year';
import ApexCharts from 'apexcharts';
import { Subscription } from 'rxjs';
@@ -13,10 +14,11 @@ import { Subscription } from 'rxjs';
styleUrl: './dashboard.css',
})
export class Dashboard implements OnInit, AfterViewInit, OnDestroy {
accounts = signal<any[]>([]);
budgets = signal<any[]>([]);
financialYears = signal<FinancialYear[]>([]);
expenses = signal<any[]>([]);
transactions = signal<any[]>([]);
budgets = signal<any[]>([]);
accounts = signal<any[]>([]);
donutExpanded = signal(false);
selectedYear = signal(new Date().getFullYear());
yearDropdownOpen = signal(false);
@@ -32,17 +34,18 @@ export class Dashboard implements OnInit, AfterViewInit, OnDestroy {
private barChart?: ApexCharts;
private donutChart?: ApexCharts;
private dataLoaded = 0;
private readonly totalRequests = 4;
private readonly totalRequests = 5;
private timeInterval?: ReturnType<typeof setInterval>;
private langSub?: Subscription;
constructor(private api: ApiService, private translate: TranslateService) {}
constructor(private api: ApiService, private fy: FinancialYearService, private translate: TranslateService) {}
ngOnInit(): void {
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.fy.list().subscribe({ next: (d) => { this.financialYears.set(d); this.onDataLoaded(); } });
this.api.getExpenses().subscribe({ next: (d) => { this.expenses.set(d); this.onDataLoaded(); } });
this.api.getTransactions().subscribe({ next: (d) => { this.transactions.set(d); this.onDataLoaded(); } });
this.api.getBudgets().subscribe({ next: (d) => { this.budgets.set(d); this.onDataLoaded(); } });
this.api.getAccounts().subscribe({ next: (d) => { this.accounts.set(d); this.onDataLoaded(); } });
this.api.getProfile().subscribe({
next: (p) => {
@@ -100,6 +103,10 @@ export class Dashboard implements OnInit, AfterViewInit, OnDestroy {
this.dateTimeDisplay.set(`${weekday}, ${date} | ${time}`);
}
private financialYearFor(year: number): FinancialYear | undefined {
return this.financialYears().find((fy) => fy.year === year);
}
// KPIs
totalIncome(): number {
return this.accounts()
@@ -114,7 +121,10 @@ export class Dashboard implements OnInit, AfterViewInit, OnDestroy {
}
totalExpenses(): number {
return this.expenses().reduce((sum, e) => sum + parseFloat(e.amount), 0);
const year = this.selectedYear();
return this.expenses()
.filter((e) => new Date(e.date).getFullYear() === year)
.reduce((sum, e) => sum + parseFloat(e.amount), 0);
}
balance(): number {
@@ -162,21 +172,23 @@ export class Dashboard implements OnInit, AfterViewInit, OnDestroy {
this.selectedYear.set(year);
this.yearDropdownOpen.set(false);
this.renderBarChart();
this.renderDonutChart();
}
availableYears(): number[] {
const years = new Set<number>([new Date().getFullYear()]);
this.expenses().forEach(e => years.add(new Date(e.date).getFullYear()));
this.financialYears().forEach((fy) => years.add(fy.year));
this.expenses().forEach((e) => years.add(new Date(e.date).getFullYear()));
return Array.from(years).sort((a, b) => b - a);
}
donutItems(): { name: string; amount: number; pct: string; color: string }[] {
const active = this.budgets().filter((b) => b.active);
const total = active.reduce((sum, b) => sum + parseFloat(b.amount), 0);
return active.map((b, i) => ({
const items = this.budgets().filter((b) => b.active);
const total = items.reduce((sum, b) => sum + +b.amount, 0);
return items.map((b, i) => ({
name: b.name,
amount: parseFloat(b.amount),
pct: total > 0 ? ((parseFloat(b.amount) / total) * 100).toFixed(1) : '0',
amount: +b.amount,
pct: total > 0 ? ((+b.amount / total) * 100).toFixed(1) : '0',
color: this.donutColors[i % this.donutColors.length],
}));
}
@@ -304,7 +316,7 @@ export class Dashboard implements OnInit, AfterViewInit, OnDestroy {
const active = this.budgets().filter((b) => b.active);
const labels = active.map((b) => b.name);
const series = active.map((b) => parseFloat(b.amount));
const series = active.map((b) => +b.amount);
if (series.length === 0) return;
@@ -0,0 +1,671 @@
<!-- 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>
}
@@ -0,0 +1,447 @@
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);
}
}
+20 -58
View File
@@ -106,6 +106,26 @@
</a>
</li>
<!-- Financial Year -->
<li>
<a routerLink="/financial-year" routerLinkActive="!bg-violet-50 !text-violet-700 dark:!bg-violet-900/20 dark:!text-violet-300"
(click)="sidebarService.closeMobile()"
[class]="sidebarService.collapsed() ? 'justify-center relative' : ''"
class="flex items-center p-2 text-sm font-normal text-gray-900 rounded-lg dark:text-white hover:bg-gray-100 dark:hover:bg-gray-700 group">
<svg class="w-6 h-6 flex-shrink-0 text-gray-400 group-hover:text-gray-900 dark:group-hover:text-white" fill="currentColor" viewBox="0 0 20 20">
<path d="M2 10a1 1 0 011-1h2a1 1 0 011 1v5a1 1 0 01-1 1H3a1 1 0 01-1-1v-5zM8 6a1 1 0 011-1h2a1 1 0 011 1v9a1 1 0 01-1 1H9a1 1 0 01-1-1V6zM14 4a1 1 0 011-1h2a1 1 0 011 1v11a1 1 0 01-1 1h-2a1 1 0 01-1-1V4z"/>
</svg>
@if (!sidebarService.collapsed()) {
<span class="ml-3 whitespace-nowrap">{{ 'sidebar.financial_year' | translate }}</span>
}
@if (sidebarService.collapsed()) {
<span class="pointer-events-none absolute left-full ml-3 top-1/2 -translate-y-1/2 px-2 py-1 text-xs font-medium text-white bg-gray-900 dark:bg-gray-700 rounded whitespace-nowrap opacity-0 group-hover:opacity-100 transition-opacity duration-150 z-50">
{{ 'sidebar.financial_year' | translate }}
</span>
}
</a>
</li>
<!-- Accounts -->
<li class="relative">
@if (sidebarService.collapsed()) {
@@ -157,64 +177,6 @@
}
</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>
<!-- Mobile: Notifications, Theme, Profile, Logout (hidden on desktop — those are in the navbar) -->
@@ -1,434 +0,0 @@
<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
@@ -1,102 +0,0 @@
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); }
}
@@ -1,17 +0,0 @@
<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>
@@ -1,10 +0,0 @@
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 {}
@@ -1,29 +0,0 @@
<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>
@@ -1,16 +0,0 @@
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,6 +23,10 @@ export class ApiService {
return this.http.put(`${this.baseUrl}/accounts/${id}/`, account);
}
patchAccount(id: number, data: Partial<{name: string, balance: number, account_type: string, salary_months: number}>): Observable<any> {
return this.http.patch(`${this.baseUrl}/accounts/${id}/`, data);
}
deleteAccount(id: number): Observable<any> {
return this.http.delete(`${this.baseUrl}/accounts/${id}/`);
}
+146
View File
@@ -0,0 +1,146 @@
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,7 +9,6 @@ export class SidebarService {
mobileOpen = signal(false);
budgetsOpen = signal(false);
accountsOpen = signal(false);
salaryOpen = signal(false);
toggle() {
this.collapsed.update(v => !v);
@@ -32,10 +31,6 @@ export class SidebarService {
this.accountsOpen.update(v => !v);
}
toggleSalary() {
this.salaryOpen.update(v => !v);
}
toggleFlyout(name: string) {
this.openFlyout.update(current => current === name ? null : name);
}
+70 -61
View File
@@ -142,69 +142,10 @@
"fixed_costs": "Fixkosten",
"expenses": "Ausgaben",
"calendar": "Kalender",
"financial_year": "Jahresplanung",
"accounts": "Konten",
"revenue_accounts": "Einnahmekonten",
"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."
"transactions": "Transaktionen"
},
"dashboard": {
"title": "Dashboard",
@@ -405,6 +346,74 @@
"ZG": "Zug",
"ZH": "Zürich"
},
"household": {
"title": "Haushalt",
"none": "Du bist noch in keinem Haushalt.",
"none_hint": "Gründe einen gemeinsamen Haushalt oder warte auf eine Einladung.",
"create": "Haushalt gründen",
"create_title": "Neuer Haushalt",
"label_name": "Name des Haushalts",
"placeholder_name": "z.B. Familie Müller",
"created_by": "Gegründet von",
"members": "Mitglieder",
"invite": "Einladen",
"invite_email": "E-Mail-Adresse",
"invite_placeholder": "user@beispiel.ch",
"send": "Einladung senden",
"status_active": "Aktiv",
"status_pending": "Ausstehend",
"status_left": "Ausgetreten",
"role_admin": "Admin",
"role_member": "Mitglied",
"make_admin": "Admin machen",
"remove_admin": "Admin entfernen",
"pending_invitation": "Einladung erhalten",
"pending_from": "Eingeladen von",
"effective_from": "Ab Jahr",
"accept": "Annehmen",
"leave": "Haushalt verlassen",
"leave_confirm_title": "Haushalt verlassen?",
"leave_confirm_text": "Du verlässt den Haushalt per Ende des laufenden Jahres. Vergangene Haushaltsjahre bleiben für dich lesbar.",
"you": "du",
"founder": "Gründer",
"error_name_required": "Name ist erforderlich.",
"error_failed": "Vorgang fehlgeschlagen.",
"error_email_required": "E-Mail-Adresse ist erforderlich.",
"error_not_found": "Kein Benutzer mit dieser E-Mail gefunden.",
"error_already_member": "Benutzer ist bereits Mitglied oder hat eine ausstehende Einladung."
},
"financial_year": {
"title": "Jahresplanung",
"owner_personal": "Persönlich",
"owner_household": "Haushalt",
"new_year": "Neues Jahr starten",
"no_years": "Noch kein Finanzjahr erstellt.",
"start_first_year": "Erstes Jahr starten",
"tab_incomes": "Einnahmen",
"tab_budget_items": "Fixkosten",
"add_income": "Einnahme hinzufügen",
"add_budget_item": "Fixkosten hinzufügen",
"label_name": "Bezeichnung",
"label_amount": "Betrag (CHF / Jahr)",
"label_notes": "Notizen",
"label_active": "Aktiv",
"total_income": "Gesamteinnahmen",
"total_fixed_costs": "Fixkosten total",
"disposable": "Verfügbar",
"savings_rate": "Sparquote",
"per_month": "Monat",
"no_incomes": "Noch keine Einnahmen erfasst.",
"no_budget_items": "Noch keine Fixkosten erfasst.",
"confirm_new_year": "Jahr {{ year }} starten?",
"confirm_copy": "Soll das neue Jahr mit den Daten aus {{ source }} vorausgefüllt werden?",
"first_year_hint": "Das Finanzjahr {{ year }} wird neu erstellt.",
"create_year": "Jahr erstellen",
"copy_yes": "Ja, Daten übernehmen",
"copy_no": "Leer starten",
"error_name_required": "Bezeichnung ist erforderlich.",
"error_amount_invalid": "Bitte einen gültigen Betrag eingeben.",
"error_save_failed": "Speichern fehlgeschlagen."
},
"profile": {
"title": "Profil",
"subtitle": "Persönliche Informationen und Einstellungen verwalten",
+70 -61
View File
@@ -142,69 +142,10 @@
"fixed_costs": "Fixed Costs",
"expenses": "Expenses",
"calendar": "Calendar",
"financial_year": "Annual Planning",
"accounts": "Accounts",
"revenue_accounts": "Revenue Accounts",
"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."
"transactions": "Transactions"
},
"dashboard": {
"title": "Dashboard",
@@ -405,6 +346,74 @@
"ZG": "Zug",
"ZH": "Zuerich"
},
"household": {
"title": "Household",
"none": "You are not part of any household yet.",
"none_hint": "Create a shared household or wait for an invitation.",
"create": "Create Household",
"create_title": "New Household",
"label_name": "Household Name",
"placeholder_name": "e.g. Smith Family",
"created_by": "Created by",
"members": "Members",
"invite": "Invite",
"invite_email": "Email Address",
"invite_placeholder": "user@example.com",
"send": "Send Invitation",
"status_active": "Active",
"status_pending": "Pending",
"status_left": "Left",
"role_admin": "Admin",
"role_member": "Member",
"make_admin": "Make admin",
"remove_admin": "Remove admin",
"pending_invitation": "Invitation received",
"pending_from": "Invited by",
"effective_from": "From year",
"accept": "Accept",
"leave": "Leave Household",
"leave_confirm_title": "Leave Household?",
"leave_confirm_text": "You will leave the household at the end of the current year. Past household years remain readable for you.",
"you": "you",
"founder": "Founder",
"error_name_required": "Name is required.",
"error_failed": "Operation failed.",
"error_email_required": "Email address is required.",
"error_not_found": "No user found with this email.",
"error_already_member": "User is already a member or has a pending invitation."
},
"financial_year": {
"title": "Annual Planning",
"owner_personal": "Personal",
"owner_household": "Household",
"new_year": "Start New Year",
"no_years": "No financial year created yet.",
"start_first_year": "Start First Year",
"tab_incomes": "Income",
"tab_budget_items": "Fixed Costs",
"add_income": "Add Income",
"add_budget_item": "Add Fixed Cost",
"label_name": "Name",
"label_amount": "Amount (CHF / Year)",
"label_notes": "Notes",
"label_active": "Active",
"total_income": "Total Income",
"total_fixed_costs": "Total Fixed Costs",
"disposable": "Disposable",
"savings_rate": "Savings Rate",
"per_month": "Month",
"no_incomes": "No income entries yet.",
"no_budget_items": "No fixed cost entries yet.",
"confirm_new_year": "Start Year {{ year }}?",
"confirm_copy": "Should the new year be pre-filled with data from {{ source }}?",
"first_year_hint": "Financial year {{ year }} will be created.",
"create_year": "Create Year",
"copy_yes": "Yes, copy data",
"copy_no": "Start empty",
"error_name_required": "Name is required.",
"error_amount_invalid": "Please enter a valid amount.",
"error_save_failed": "Saving failed."
},
"profile": {
"title": "Profile",
"subtitle": "Manage your personal information and settings",
+70 -61
View File
@@ -142,69 +142,10 @@
"fixed_costs": "Charges fixes",
"expenses": "Dépenses",
"calendar": "Calendrier",
"financial_year": "Planification annuelle",
"accounts": "Comptes",
"revenue_accounts": "Comptes de revenus",
"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."
"transactions": "Transactions"
},
"dashboard": {
"title": "Tableau de bord",
@@ -405,6 +346,74 @@
"ZG": "Zoug",
"ZH": "Zurich"
},
"household": {
"title": "Ménage",
"none": "Vous ne faites encore partie d'aucun ménage.",
"none_hint": "Créez un ménage commun ou attendez une invitation.",
"create": "Créer un ménage",
"create_title": "Nouveau ménage",
"label_name": "Nom du ménage",
"placeholder_name": "p.ex. Famille Müller",
"created_by": "Créé par",
"members": "Membres",
"invite": "Inviter",
"invite_email": "Adresse e-mail",
"invite_placeholder": "user@exemple.ch",
"send": "Envoyer l'invitation",
"status_active": "Actif",
"status_pending": "En attente",
"status_left": "Parti",
"role_admin": "Admin",
"role_member": "Membre",
"make_admin": "Rendre admin",
"remove_admin": "Retirer admin",
"pending_invitation": "Invitation reçue",
"pending_from": "Invité par",
"effective_from": "Dès l'année",
"accept": "Accepter",
"leave": "Quitter le ménage",
"leave_confirm_title": "Quitter le ménage ?",
"leave_confirm_text": "Vous quitterez le ménage à la fin de l'année en cours. Les années passées resteront lisibles.",
"you": "vous",
"founder": "Fondateur",
"error_name_required": "Le nom est requis.",
"error_failed": "Opération échouée.",
"error_email_required": "L'adresse e-mail est requise.",
"error_not_found": "Aucun utilisateur trouvé avec cet e-mail.",
"error_already_member": "L'utilisateur est déjà membre ou a une invitation en attente."
},
"financial_year": {
"title": "Planification annuelle",
"owner_personal": "Personnel",
"owner_household": "Ménage",
"new_year": "Démarrer une nouvelle année",
"no_years": "Aucune année financière créée.",
"start_first_year": "Démarrer la première année",
"tab_incomes": "Revenus",
"tab_budget_items": "Charges fixes",
"add_income": "Ajouter un revenu",
"add_budget_item": "Ajouter une charge fixe",
"label_name": "Désignation",
"label_amount": "Montant (CHF / an)",
"label_notes": "Notes",
"label_active": "Actif",
"total_income": "Revenus totaux",
"total_fixed_costs": "Charges fixes totales",
"disposable": "Disponible",
"savings_rate": "Taux d'épargne",
"per_month": "mois",
"no_incomes": "Aucun revenu enregistré.",
"no_budget_items": "Aucune charge fixe enregistrée.",
"confirm_new_year": "Démarrer l'année {{ year }} ?",
"confirm_copy": "Reprendre les données de {{ source }} pour la nouvelle année ?",
"first_year_hint": "L'année financière {{ year }} sera créée.",
"create_year": "Créer l'année",
"copy_yes": "Oui, reprendre les données",
"copy_no": "Démarrer vide",
"error_name_required": "La désignation est requise.",
"error_amount_invalid": "Veuillez saisir un montant valide.",
"error_save_failed": "Échec de l'enregistrement."
},
"profile": {
"title": "Profil",
"subtitle": "Gérer vos informations personnelles et paramètres",
+70 -61
View File
@@ -142,69 +142,10 @@
"fixed_costs": "Costi fissi",
"expenses": "Spese",
"calendar": "Calendario",
"financial_year": "Pianificazione annuale",
"accounts": "Conti",
"revenue_accounts": "Conti entrate",
"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."
"transactions": "Transazioni"
},
"dashboard": {
"title": "Dashboard",
@@ -405,6 +346,74 @@
"ZG": "Zugo",
"ZH": "Zurigo"
},
"household": {
"title": "Nucleo familiare",
"none": "Non fai ancora parte di nessun nucleo familiare.",
"none_hint": "Crea un nucleo familiare condiviso o attendi un invito.",
"create": "Crea nucleo familiare",
"create_title": "Nuovo nucleo familiare",
"label_name": "Nome del nucleo",
"placeholder_name": "es. Famiglia Müller",
"created_by": "Creato da",
"members": "Membri",
"invite": "Invita",
"invite_email": "Indirizzo e-mail",
"invite_placeholder": "user@esempio.ch",
"send": "Invia invito",
"status_active": "Attivo",
"status_pending": "In attesa",
"status_left": "Uscito",
"role_admin": "Admin",
"role_member": "Membro",
"make_admin": "Rendi admin",
"remove_admin": "Rimuovi admin",
"pending_invitation": "Invito ricevuto",
"pending_from": "Invitato da",
"effective_from": "Dall'anno",
"accept": "Accetta",
"leave": "Lascia il nucleo",
"leave_confirm_title": "Lasciare il nucleo?",
"leave_confirm_text": "Lascerai il nucleo alla fine dell'anno in corso. Gli anni passati rimarranno leggibili.",
"you": "tu",
"founder": "Fondatore",
"error_name_required": "Il nome è obbligatorio.",
"error_failed": "Operazione non riuscita.",
"error_email_required": "L'indirizzo e-mail è obbligatorio.",
"error_not_found": "Nessun utente trovato con questa e-mail.",
"error_already_member": "L'utente è già membro o ha un invito in sospeso."
},
"financial_year": {
"title": "Pianificazione annuale",
"owner_personal": "Personale",
"owner_household": "Nucleo familiare",
"new_year": "Avvia nuovo anno",
"no_years": "Nessun anno finanziario creato.",
"start_first_year": "Avvia il primo anno",
"tab_incomes": "Entrate",
"tab_budget_items": "Costi fissi",
"add_income": "Aggiungi entrata",
"add_budget_item": "Aggiungi costo fisso",
"label_name": "Denominazione",
"label_amount": "Importo (CHF / anno)",
"label_notes": "Note",
"label_active": "Attivo",
"total_income": "Entrate totali",
"total_fixed_costs": "Costi fissi totali",
"disposable": "Disponibile",
"savings_rate": "Tasso di risparmio",
"per_month": "mese",
"no_incomes": "Nessuna entrata registrata.",
"no_budget_items": "Nessun costo fisso registrato.",
"confirm_new_year": "Avviare l'anno {{ year }}?",
"confirm_copy": "Vuoi pre-compilare il nuovo anno con i dati di {{ source }}?",
"first_year_hint": "L'anno finanziario {{ year }} verrà creato.",
"create_year": "Crea anno",
"copy_yes": "Sì, copia i dati",
"copy_no": "Inizia vuoto",
"error_name_required": "La denominazione è obbligatoria.",
"error_amount_invalid": "Inserisci un importo valido.",
"error_save_failed": "Salvataggio non riuscito."
},
"profile": {
"title": "Profilo",
"subtitle": "Gestisci le tue informazioni personali e impostazioni",