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
28 changed files with 2681 additions and 19 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/), The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
## [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 ### Added
- Auth: E-Mail-Verifikation bei Registrierung — Token (SHA-256-Hash in DB, 24h gültig) wird per Mail versendet; `/verify-email?token=` Frontend-Route löst automatisch `POST /api/auth/verify-email/` aus - Auth: E-Mail-Verifikation bei Registrierung — Token (SHA-256-Hash in DB, 24h gültig) wird per Mail versendet; `/verify-email?token=` Frontend-Route löst automatisch `POST /api/auth/verify-email/` aus
+1
View File
@@ -104,6 +104,7 @@ EMAIL_HOST_USER = os.environ.get('EMAIL_HOST_USER', '')
EMAIL_HOST_PASSWORD = os.environ.get('EMAIL_HOST_PASSWORD', '') EMAIL_HOST_PASSWORD = os.environ.get('EMAIL_HOST_PASSWORD', '')
EMAIL_USE_TLS = os.environ.get('EMAIL_USE_TLS', 'True') == 'True' EMAIL_USE_TLS = os.environ.get('EMAIL_USE_TLS', 'True') == 'True'
DEFAULT_FROM_EMAIL = os.environ.get('DEFAULT_FROM_EMAIL', 'noreply@armarium.ch') DEFAULT_FROM_EMAIL = os.environ.get('DEFAULT_FROM_EMAIL', 'noreply@armarium.ch')
FRONTEND_URL = os.environ.get('FRONTEND_URL', 'http://localhost:4200')
LOGGING = { LOGGING = {
'version': 1, 'version': 1,
+18
View File
@@ -14,6 +14,11 @@ from finance.views import (
SessionListView, SessionRevokeView, SessionRevokeAllView, SessionListView, SessionRevokeView, SessionRevokeAllView,
DataExportView, NotificationPrefsView, DataExportView, NotificationPrefsView,
VerifyEmailView, PasswordResetRequestView, PasswordResetConfirmView, VerifyEmailView, PasswordResetRequestView, PasswordResetConfirmView,
FinancialYearListCreateView, FinancialYearDetailView, FinancialYearCopyView,
YearlyIncomeListCreateView, YearlyIncomeDetailView,
YearlyBudgetItemListCreateView, YearlyBudgetItemDetailView,
HouseholdListCreateView, HouseholdInviteView, HouseholdAcceptView, HouseholdLeaveView,
HouseholdSetRoleView, HouseholdRevenueAccountsView,
) )
router = DefaultRouter() router = DefaultRouter()
@@ -52,4 +57,17 @@ urlpatterns = [
path('api/notifications/', NotificationsView.as_view()), path('api/notifications/', NotificationsView.as_view()),
path('api/calendar/ical-url/', ICalUrlView.as_view()), path('api/calendar/ical-url/', ICalUrlView.as_view()),
path('api/calendar/ical/<int:user_id>/<str:token>/', ICalFeedView.as_view()), path('api/calendar/ical/<int:user_id>/<str:token>/', ICalFeedView.as_view()),
path('api/financial-years/', FinancialYearListCreateView.as_view()),
path('api/financial-years/<int:year>/', FinancialYearDetailView.as_view()),
path('api/financial-years/<int:year>/copy-from/<int:source_year>/', FinancialYearCopyView.as_view()),
path('api/financial-years/<int:year>/incomes/', YearlyIncomeListCreateView.as_view()),
path('api/financial-years/<int:year>/incomes/<int:pk>/', YearlyIncomeDetailView.as_view()),
path('api/financial-years/<int:year>/budget-items/', YearlyBudgetItemListCreateView.as_view()),
path('api/financial-years/<int:year>/budget-items/<int:pk>/', YearlyBudgetItemDetailView.as_view()),
path('api/households/', HouseholdListCreateView.as_view()),
path('api/households/<int:pk>/invite/', HouseholdInviteView.as_view()),
path('api/households/<int:pk>/accept/', HouseholdAcceptView.as_view()),
path('api/households/<int:pk>/leave/', HouseholdLeaveView.as_view()),
path('api/households/<int:pk>/members/<int:membership_id>/set-role/', HouseholdSetRoleView.as_view()),
path('api/households/<int:pk>/revenue-accounts/', HouseholdRevenueAccountsView.as_view()),
] + static(settings.MEDIA_URL, document_root=settings.MEDIA_ROOT) ] + static(settings.MEDIA_URL, document_root=settings.MEDIA_ROOT)
@@ -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) name = models.CharField(max_length=100)
account_type = models.CharField(max_length=20, choices=ACCOUNT_TYPES, default='asset') account_type = models.CharField(max_length=20, choices=ACCOUNT_TYPES, default='asset')
balance = models.DecimalField(max_digits=12, decimal_places=2, default=0.00) balance = models.DecimalField(max_digits=12, decimal_places=2, default=0.00)
salary_months = models.IntegerField(default=12, choices=[(12, 12), (13, 13)])
active = models.BooleanField(default=True) active = models.BooleanField(default=True)
created_at = models.DateTimeField(auto_now_add=True) created_at = models.DateTimeField(auto_now_add=True)
@@ -224,3 +225,144 @@ class BackupCode(models.Model):
def __str__(self): def __str__(self):
return f"{self.user} backup {'used' if self.used else 'active'}" return f"{self.user} backup {'used' if self.used else 'active'}"
# ── FinancialYear ─────────────────────────────────────────────────────────────
class Household(models.Model):
name = models.CharField(max_length=100)
created_by = models.ForeignKey(
settings.AUTH_USER_MODEL,
on_delete=models.PROTECT,
related_name='created_households',
)
created_at = models.DateTimeField(auto_now_add=True)
def __str__(self):
return self.name
class HouseholdMembership(models.Model):
STATUS_CHOICES = [
('pending', 'Pending'),
('active', 'Active'),
('left', 'Left'),
]
ROLE_CHOICES = [
('member', 'Member'),
('admin', 'Admin'),
]
household = models.ForeignKey(Household, on_delete=models.CASCADE, related_name='memberships')
user = models.ForeignKey(
settings.AUTH_USER_MODEL,
on_delete=models.CASCADE,
related_name='household_memberships',
)
invited_by = models.ForeignKey(
settings.AUTH_USER_MODEL,
on_delete=models.SET_NULL,
null=True,
related_name='sent_invitations',
)
status = models.CharField(max_length=10, choices=STATUS_CHOICES, default='pending')
role = models.CharField(max_length=10, choices=ROLE_CHOICES, default='member')
effective_from_year = models.PositiveSmallIntegerField(null=True, blank=True)
effective_until_year = models.PositiveSmallIntegerField(null=True, blank=True)
created_at = models.DateTimeField(auto_now_add=True)
class Meta:
unique_together = ['household', 'user']
def __str__(self):
return f"{self.user} in {self.household} ({self.status})"
class PendingHouseholdInvite(models.Model):
household = models.ForeignKey(Household, on_delete=models.CASCADE, related_name='pending_invites')
invited_email = models.EmailField()
invited_by = models.ForeignKey(
settings.AUTH_USER_MODEL,
on_delete=models.SET_NULL,
null=True,
related_name='sent_pending_invitations',
)
effective_from_year = models.PositiveSmallIntegerField(null=True, blank=True)
created_at = models.DateTimeField(auto_now_add=True)
class Meta:
unique_together = ['household', 'invited_email']
def __str__(self):
return f"Pending invite for {self.invited_email} to {self.household}"
class FinancialYear(models.Model):
user = models.ForeignKey(
settings.AUTH_USER_MODEL,
on_delete=models.CASCADE,
related_name='financial_years',
null=True, blank=True,
)
household = models.ForeignKey(
Household,
on_delete=models.CASCADE,
related_name='financial_years',
null=True, blank=True,
)
year = models.PositiveSmallIntegerField()
is_active = models.BooleanField(default=True)
notes = models.TextField(blank=True, default='')
created_at = models.DateTimeField(auto_now_add=True)
class Meta:
constraints = [
models.CheckConstraint(
condition=(
models.Q(user__isnull=False, household__isnull=True) |
models.Q(user__isnull=True, household__isnull=False)
),
name='financial_year_owner_exclusive',
),
models.UniqueConstraint(
fields=['user', 'year'],
condition=models.Q(user__isnull=False),
name='unique_personal_financial_year',
),
models.UniqueConstraint(
fields=['household', 'year'],
condition=models.Q(household__isnull=False),
name='unique_household_financial_year',
),
]
def __str__(self):
owner = self.user or self.household
return f"{owner}{self.year}"
class YearlyIncome(models.Model):
financial_year = models.ForeignKey(FinancialYear, on_delete=models.CASCADE, related_name='incomes')
member = models.ForeignKey(
settings.AUTH_USER_MODEL,
on_delete=models.SET_NULL,
null=True,
related_name='yearly_incomes',
)
name = models.CharField(max_length=100)
amount = models.DecimalField(max_digits=12, decimal_places=2)
active = models.BooleanField(default=True)
notes = models.TextField(blank=True, default='')
def __str__(self):
return f"{self.name}: CHF {self.amount} ({self.financial_year.year})"
class YearlyBudgetItem(models.Model):
financial_year = models.ForeignKey(FinancialYear, on_delete=models.CASCADE, related_name='budget_items')
name = models.CharField(max_length=100)
amount = models.DecimalField(max_digits=12, decimal_places=2)
active = models.BooleanField(default=True)
notes = models.TextField(blank=True, default='')
def __str__(self):
return f"{self.name}: CHF {self.amount} ({self.financial_year.year})"
+93 -2
View File
@@ -1,6 +1,10 @@
from rest_framework import serializers from rest_framework import serializers
from django.contrib.auth import get_user_model from django.contrib.auth import get_user_model
from .models import 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() User = get_user_model()
@@ -44,6 +48,10 @@ class ExpenseSerializer(serializers.ModelSerializer):
class ProfileSerializer(serializers.ModelSerializer): class ProfileSerializer(serializers.ModelSerializer):
totp_enabled = serializers.BooleanField(read_only=True) totp_enabled = serializers.BooleanField(read_only=True)
email = serializers.SerializerMethodField()
def get_email(self, obj):
return obj.email or (obj.user.email if obj.user else '')
class Meta: class Meta:
model = Profile model = Profile
@@ -56,6 +64,76 @@ class DeadlineSerializer(serializers.ModelSerializer):
exclude = ['user'] exclude = ['user']
class HouseholdMembershipSerializer(serializers.ModelSerializer):
user_email = serializers.EmailField(source='user.email', read_only=True)
invited_by_email = serializers.EmailField(source='invited_by.email', read_only=True)
class Meta:
model = HouseholdMembership
fields = ['id', 'user', 'user_email', 'invited_by_email', 'status', 'role',
'effective_from_year', 'effective_until_year', 'created_at']
read_only_fields = ['id', 'user', 'user_email', 'invited_by_email', 'created_at']
class PendingHouseholdInviteSerializer(serializers.ModelSerializer):
invited_by_email = serializers.EmailField(source='invited_by.email', read_only=True)
class Meta:
model = PendingHouseholdInvite
fields = ['id', 'invited_email', 'invited_by_email', 'effective_from_year', 'created_at']
read_only_fields = fields
class HouseholdSerializer(serializers.ModelSerializer):
memberships = HouseholdMembershipSerializer(many=True, read_only=True)
pending_invites = PendingHouseholdInviteSerializer(many=True, read_only=True)
created_by_email = serializers.EmailField(source='created_by.email', read_only=True)
class Meta:
model = Household
fields = ['id', 'name', 'created_by_email', 'memberships', 'pending_invites', 'created_at']
read_only_fields = ['id', 'created_by_email', 'memberships', 'pending_invites', 'created_at']
class YearlyIncomeSerializer(serializers.ModelSerializer):
member_email = serializers.EmailField(source='member.email', read_only=True)
class Meta:
model = YearlyIncome
fields = ['id', 'member', 'member_email', 'name', 'amount', 'active', 'notes']
read_only_fields = ['id', 'member_email']
class YearlyBudgetItemSerializer(serializers.ModelSerializer):
class Meta:
model = YearlyBudgetItem
fields = ['id', 'name', 'amount', 'active', 'notes']
read_only_fields = ['id']
class FinancialYearSerializer(serializers.ModelSerializer):
incomes = YearlyIncomeSerializer(many=True, read_only=True)
budget_items = YearlyBudgetItemSerializer(many=True, read_only=True)
total_income = serializers.SerializerMethodField()
total_fixed_costs = serializers.SerializerMethodField()
owner_type = serializers.SerializerMethodField()
class Meta:
model = FinancialYear
fields = ['id', 'year', 'is_active', 'notes', 'owner_type', 'household_id',
'total_income', 'total_fixed_costs', 'incomes', 'budget_items', 'created_at']
read_only_fields = ['id', 'created_at', 'owner_type', 'household_id', 'total_income', 'total_fixed_costs']
def get_total_income(self, obj):
return sum(i.amount for i in obj.incomes.filter(active=True))
def get_total_fixed_costs(self, obj):
return sum(b.amount for b in obj.budget_items.filter(active=True))
def get_owner_type(self, obj):
return 'household' if obj.household_id else 'personal'
class RegisterSerializer(serializers.Serializer): class RegisterSerializer(serializers.Serializer):
email = serializers.EmailField() email = serializers.EmailField()
password = serializers.CharField(min_length=8, write_only=True) password = serializers.CharField(min_length=8, write_only=True)
@@ -67,8 +145,21 @@ class RegisterSerializer(serializers.Serializer):
def create(self, validated_data): def create(self, validated_data):
email = validated_data['email'] email = validated_data['email']
return User.objects.create_user( user = User.objects.create_user(
username=email, username=email,
email=email, email=email,
password=validated_data['password'], password=validated_data['password'],
) )
from .models import PendingHouseholdInvite, HouseholdMembership
for invite in PendingHouseholdInvite.objects.filter(invited_email__iexact=email):
HouseholdMembership.objects.get_or_create(
household=invite.household,
user=user,
defaults={
'invited_by': invite.invited_by,
'status': 'pending',
'effective_from_year': invite.effective_from_year,
},
)
invite.delete()
return user
+460 -1
View File
@@ -1,4 +1,5 @@
import base64 import base64
import datetime
import hmac import hmac
import hashlib import hashlib
import json import json
@@ -15,16 +16,23 @@ from django.contrib.auth import get_user_model, authenticate
from django.http import HttpResponse from django.http import HttpResponse
from icalendar import Calendar as iCalendar, Event as iCalEvent from icalendar import Calendar as iCalendar, Event as iCalEvent
from django.db import models
from rest_framework import viewsets, views, status from rest_framework import viewsets, views, status
from rest_framework.response import Response from rest_framework.response import Response
from rest_framework.permissions import AllowAny, IsAuthenticated from rest_framework.permissions import AllowAny, IsAuthenticated
from rest_framework.throttling import AnonRateThrottle from rest_framework.throttling import AnonRateThrottle
from rest_framework_simplejwt.tokens import RefreshToken from rest_framework_simplejwt.tokens import RefreshToken
from rest_framework_simplejwt.exceptions import TokenError from rest_framework_simplejwt.exceptions import TokenError
from .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 ( from .serializers import (
AccountSerializer, TransactionSerializer, BudgetSerializer, AccountSerializer, TransactionSerializer, BudgetSerializer,
ExpenseSerializer, ProfileSerializer, DeadlineSerializer, RegisterSerializer, 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): for session in UserSession.objects.filter(user=user):
_blacklist_session(session) _blacklist_session(session)
return Response({'detail': 'Password updated.'}) return Response({'detail': 'Password updated.'})
# ── FinancialYear Helpers ─────────────────────────────────────────────────────
def _get_user_financial_year(user, year):
"""Return the FinancialYear for a given year accessible to this user."""
# Personal year
fy = FinancialYear.objects.filter(user=user, year=year).first()
if fy:
return fy
# Household year where user is an active member for this year
memberships = HouseholdMembership.objects.filter(
user=user,
status='active',
effective_from_year__lte=year,
).filter(
models.Q(effective_until_year__isnull=True) | models.Q(effective_until_year__gt=year)
)
household_ids = memberships.values_list('household_id', flat=True)
return FinancialYear.objects.filter(household_id__in=household_ids, year=year).first()
def _all_user_financial_years(user):
"""Return all FinancialYears accessible to this user."""
personal = FinancialYear.objects.filter(user=user)
memberships = HouseholdMembership.objects.filter(user=user, status='active')
household_ids = memberships.values_list('household_id', flat=True)
household = FinancialYear.objects.filter(household_id__in=household_ids)
return (personal | household).distinct().order_by('-year')
def _max_year_for_user(user):
"""Return the highest year the user currently has access to."""
years = _all_user_financial_years(user).values_list('year', flat=True)
return max(years) if years else None
# ── FinancialYear Views ───────────────────────────────────────────────────────
class FinancialYearListCreateView(views.APIView):
def get(self, request):
qs = _all_user_financial_years(request.user)
return Response(FinancialYearSerializer(qs, many=True).data)
def post(self, request):
year = request.data.get('year')
if not year:
return Response({'year': 'This field is required.'}, status=400)
try:
year = int(year)
except (TypeError, ValueError):
return Response({'year': 'Must be an integer.'}, status=400)
current_year = datetime.date.today().year
if year > current_year + 1:
return Response(
{'year': f'You can only create years up to {current_year + 1}.'},
status=400,
)
max_year = _max_year_for_user(request.user)
if max_year is not None and year != max_year + 1:
return Response(
{'year': f'You can only create the next year ({max_year + 1}).'},
status=400,
)
household_id = request.data.get('household_id')
if household_id:
household = Household.objects.filter(
id=household_id,
memberships__user=request.user,
memberships__status='active',
).first()
if not household:
return Response({'detail': 'Household not found or not a member.'}, status=404)
if FinancialYear.objects.filter(household=household, year=year).exists():
return Response({'year': 'This year already exists for this household.'}, status=400)
FinancialYear.objects.filter(household=household, is_active=True).update(is_active=False)
fy = FinancialYear.objects.create(household=household, year=year, is_active=True)
else:
if FinancialYear.objects.filter(user=request.user, year=year).exists():
return Response({'year': 'This year already exists.'}, status=400)
FinancialYear.objects.filter(user=request.user, is_active=True).update(is_active=False)
fy = FinancialYear.objects.create(user=request.user, year=year, is_active=True)
return Response(FinancialYearSerializer(fy).data, status=201)
class FinancialYearDetailView(views.APIView):
def _get_or_404(self, request, year):
fy = _get_user_financial_year(request.user, year)
if not fy:
return None
return fy
def get(self, request, year):
fy = self._get_or_404(request, year)
if not fy:
return Response({'detail': 'Not found.'}, status=404)
return Response(FinancialYearSerializer(fy).data)
def patch(self, request, year):
fy = self._get_or_404(request, year)
if not fy:
return Response({'detail': 'Not found.'}, status=404)
serializer = FinancialYearSerializer(fy, data=request.data, partial=True)
if serializer.is_valid():
serializer.save()
return Response(serializer.data)
return Response(serializer.errors, status=400)
def delete(self, request, year):
fy = self._get_or_404(request, year)
if not fy:
return Response({'detail': 'Not found.'}, status=404)
if not fy.is_active:
return Response({'detail': 'Archived years cannot be deleted.'}, status=400)
fy.delete()
return Response(status=204)
class FinancialYearCopyView(views.APIView):
def post(self, request, year, source_year):
source = _get_user_financial_year(request.user, source_year)
if not source:
return Response({'detail': f'Source year {source_year} not found.'}, status=404)
target = _get_user_financial_year(request.user, year)
if not target:
return Response({'detail': f'Target year {year} not found.'}, status=404)
if not target.is_active:
return Response({'detail': 'Target year is archived.'}, status=400)
with db_transaction.atomic():
incomes_copied = 0
for income in source.incomes.all():
YearlyIncome.objects.create(
financial_year=target,
member=income.member,
name=income.name,
amount=income.amount,
active=income.active,
notes=income.notes,
)
incomes_copied += 1
items_copied = 0
for item in source.budget_items.all():
YearlyBudgetItem.objects.create(
financial_year=target,
name=item.name,
amount=item.amount,
active=item.active,
notes=item.notes,
)
items_copied += 1
return Response({
'year': year,
'source_year': source_year,
'incomes_copied': incomes_copied,
'budget_items_copied': items_copied,
})
# ── YearlyIncome Views ────────────────────────────────────────────────────────
class YearlyIncomeListCreateView(views.APIView):
def _get_year_or_404(self, request, year):
fy = _get_user_financial_year(request.user, year)
return fy
def get(self, request, year):
fy = self._get_year_or_404(request, year)
if not fy:
return Response({'detail': 'Not found.'}, status=404)
return Response(YearlyIncomeSerializer(fy.incomes.all(), many=True).data)
def post(self, request, year):
fy = self._get_year_or_404(request, year)
if not fy:
return Response({'detail': 'Not found.'}, status=404)
if not fy.is_active:
return Response({'detail': 'Archived years are read-only.'}, status=403)
serializer = YearlyIncomeSerializer(data=request.data)
if serializer.is_valid():
serializer.save(financial_year=fy, member=request.user)
return Response(serializer.data, status=201)
return Response(serializer.errors, status=400)
class YearlyIncomeDetailView(views.APIView):
def _get_income_or_404(self, request, year, pk):
fy = _get_user_financial_year(request.user, year)
if not fy:
return None, None
income = fy.incomes.filter(pk=pk).first()
return fy, income
def patch(self, request, year, pk):
fy, income = self._get_income_or_404(request, year, pk)
if not income:
return Response({'detail': 'Not found.'}, status=404)
serializer = YearlyIncomeSerializer(income, data=request.data, partial=True)
if serializer.is_valid():
serializer.save()
return Response(serializer.data)
return Response(serializer.errors, status=400)
def delete(self, request, year, pk):
fy, income = self._get_income_or_404(request, year, pk)
if not income:
return Response({'detail': 'Not found.'}, status=404)
income.delete()
return Response(status=204)
# ── YearlyBudgetItem Views ────────────────────────────────────────────────────
class YearlyBudgetItemListCreateView(views.APIView):
def _get_year_or_404(self, request, year):
return _get_user_financial_year(request.user, year)
def get(self, request, year):
fy = self._get_year_or_404(request, year)
if not fy:
return Response({'detail': 'Not found.'}, status=404)
return Response(YearlyBudgetItemSerializer(fy.budget_items.all(), many=True).data)
def post(self, request, year):
fy = self._get_year_or_404(request, year)
if not fy:
return Response({'detail': 'Not found.'}, status=404)
serializer = YearlyBudgetItemSerializer(data=request.data)
if serializer.is_valid():
serializer.save(financial_year=fy)
return Response(serializer.data, status=201)
return Response(serializer.errors, status=400)
class YearlyBudgetItemDetailView(views.APIView):
def _get_item_or_404(self, request, year, pk):
fy = _get_user_financial_year(request.user, year)
if not fy:
return None, None
item = fy.budget_items.filter(pk=pk).first()
return fy, item
def patch(self, request, year, pk):
fy, item = self._get_item_or_404(request, year, pk)
if not item:
return Response({'detail': 'Not found.'}, status=404)
serializer = YearlyBudgetItemSerializer(item, data=request.data, partial=True)
if serializer.is_valid():
serializer.save()
return Response(serializer.data)
return Response(serializer.errors, status=400)
def delete(self, request, year, pk):
fy, item = self._get_item_or_404(request, year, pk)
if not item:
return Response({'detail': 'Not found.'}, status=404)
item.delete()
return Response(status=204)
# ── Household Views ───────────────────────────────────────────────────────────
class HouseholdListCreateView(views.APIView):
def get(self, request):
memberships = HouseholdMembership.objects.filter(user=request.user, status__in=['active', 'pending'])
household_ids = memberships.values_list('household_id', flat=True)
created = Household.objects.filter(created_by=request.user)
qs = (Household.objects.filter(id__in=household_ids) | created).distinct()
return Response(HouseholdSerializer(qs, many=True).data)
def post(self, request):
serializer = HouseholdSerializer(data=request.data)
if serializer.is_valid():
household = serializer.save(created_by=request.user)
# Creator is automatically an active member
next_year = (datetime.date.today().year + 1)
HouseholdMembership.objects.create(
household=household,
user=request.user,
invited_by=request.user,
status='active',
role='admin',
effective_from_year=next_year,
)
return Response(HouseholdSerializer(household).data, status=201)
return Response(serializer.errors, status=400)
class HouseholdInviteView(views.APIView):
def post(self, request, pk):
household = Household.objects.filter(pk=pk).first()
if not household:
return Response({'detail': 'Not found.'}, status=404)
# Only founder or active admins can invite
is_founder = household.created_by == request.user
is_admin = HouseholdMembership.objects.filter(
household=household, user=request.user, status='active', role='admin'
).exists()
if not (is_founder or is_admin):
return Response({'detail': 'Only admins can invite members.'}, status=403)
email = request.data.get('email', '').strip().lower()
User = get_user_model()
invitee = User.objects.filter(email__iexact=email).first()
from django.conf import settings
from .email import send_email
from .models import PendingHouseholdInvite
next_year = datetime.date.today().year + 1
inviter_name = request.user.get_full_name() or request.user.email
if not invitee:
if PendingHouseholdInvite.objects.filter(household=household, invited_email__iexact=email).exists():
return Response({'detail': 'Invitation already sent to this email.'}, status=400)
PendingHouseholdInvite.objects.create(
household=household,
invited_by=request.user,
invited_email=email,
effective_from_year=next_year,
)
register_url = f"{settings.FRONTEND_URL}/register"
send_email(
template_name='household_invite',
subject=f'Einladung zum Haushalt «{household.name}»',
context={
'invitee_name': email,
'inviter_name': inviter_name,
'household_name': household.name,
'accept_url': register_url,
'cta_label': 'Konto erstellen & beitreten',
},
to=email,
)
return Response({'detail': f'Registration invitation sent to {email}.'})
if invitee == request.user:
return Response({'detail': 'You cannot invite yourself.'}, status=400)
if HouseholdMembership.objects.filter(household=household, user=invitee, status__in=['pending', 'active']).exists():
return Response({'detail': 'User is already a member or has a pending invitation.'}, status=400)
HouseholdMembership.objects.create(
household=household,
user=invitee,
invited_by=request.user,
status='pending',
effective_from_year=next_year,
)
invitee_name = invitee.get_full_name() or invitee.email
send_email(
template_name='household_invite',
subject=f'Einladung zum Haushalt «{household.name}»',
context={
'invitee_name': invitee_name,
'inviter_name': inviter_name,
'household_name': household.name,
'accept_url': f"{settings.FRONTEND_URL}/financial-year",
'cta_label': 'Einladung annehmen',
},
to=invitee.email,
)
return Response({'detail': f'Invitation sent to {email} for year {next_year}.'})
class HouseholdAcceptView(views.APIView):
def post(self, request, pk):
membership = HouseholdMembership.objects.filter(
household_id=pk, user=request.user, status='pending'
).first()
if not membership:
return Response({'detail': 'No pending invitation found.'}, status=404)
membership.status = 'active'
membership.save(update_fields=['status'])
return Response({'detail': 'Invitation accepted.'})
class HouseholdLeaveView(views.APIView):
def post(self, request, pk):
membership = HouseholdMembership.objects.filter(
household_id=pk, user=request.user, status='active'
).first()
if not membership:
return Response({'detail': 'You are not an active member of this household.'}, status=404)
next_year = datetime.date.today().year + 1
membership.status = 'left'
membership.effective_until_year = next_year
membership.save(update_fields=['status', 'effective_until_year'])
return Response({'detail': f'You will leave this household at the end of {next_year - 1}.'})
class HouseholdSetRoleView(views.APIView):
def post(self, request, pk, membership_id):
# Only the founder can assign roles
household = Household.objects.filter(pk=pk, created_by=request.user).first()
if not household:
return Response({'detail': 'Not found or not owner.'}, status=404)
membership = HouseholdMembership.objects.filter(
pk=membership_id, household=household, status='active'
).first()
if not membership:
return Response({'detail': 'Active membership not found.'}, status=404)
if membership.user == request.user:
return Response({'detail': 'Cannot change your own role.'}, status=400)
role = request.data.get('role')
if role not in ['member', 'admin']:
return Response({'detail': 'Role must be "member" or "admin".'}, status=400)
membership.role = role
membership.save(update_fields=['role'])
return Response(HouseholdMembershipSerializer(membership).data)
class HouseholdRevenueAccountsView(views.APIView):
def get(self, request, pk):
membership = HouseholdMembership.objects.filter(
household_id=pk, user=request.user, status='active'
).first()
if not membership:
return Response({'detail': 'Not a member of this household.'}, status=403)
member_users = HouseholdMembership.objects.filter(
household_id=pk, status='active'
).values_list('user_id', flat=True)
accounts = Account.objects.filter(
user_id__in=member_users, account_type='revenue', active=True
).select_related('user')
data = [
{
'id': a.id,
'name': a.name,
'balance': str(a.balance),
'salary_months': a.salary_months,
'owner_email': a.user.email,
'is_mine': a.user_id == request.user.id,
}
for a in accounts
]
return Response(data)
@@ -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
View File
@@ -14,6 +14,7 @@ import { ExpenseList } from './expenses/expense-list/expense-list';
import { Profile } from './profile/profile'; import { Profile } from './profile/profile';
import { Settings } from './settings/settings'; import { Settings } from './settings/settings';
import { Calendar } from './calendar/calendar'; import { Calendar } from './calendar/calendar';
import { FinancialYearComponent } from './financial-year/financial-year';
export const routes: Routes = [ export const routes: Routes = [
{ path: 'login', component: Login }, { path: 'login', component: Login },
{ path: 'register', component: Register }, { path: 'register', component: Register },
@@ -34,6 +35,7 @@ export const routes: Routes = [
{ path: 'profile', component: Profile }, { path: 'profile', component: Profile },
{ path: 'settings', component: Settings }, { path: 'settings', component: Settings },
{ path: 'calendar', component: Calendar }, { path: 'calendar', component: Calendar },
{ path: 'financial-year', component: FinancialYearComponent },
], ],
}, },
{ path: '**', redirectTo: 'dashboard' }, { 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 { CommonModule } from '@angular/common';
import { TranslateModule, TranslateService } from '@ngx-translate/core'; import { TranslateModule, TranslateService } from '@ngx-translate/core';
import { ApiService } from '../services/api'; import { ApiService } from '../services/api';
import { FinancialYearService, FinancialYear } from '../services/financial-year';
import ApexCharts from 'apexcharts'; import ApexCharts from 'apexcharts';
import { Subscription } from 'rxjs'; import { Subscription } from 'rxjs';
@@ -13,10 +14,11 @@ import { Subscription } from 'rxjs';
styleUrl: './dashboard.css', styleUrl: './dashboard.css',
}) })
export class Dashboard implements OnInit, AfterViewInit, OnDestroy { export class Dashboard implements OnInit, AfterViewInit, OnDestroy {
accounts = signal<any[]>([]); financialYears = signal<FinancialYear[]>([]);
budgets = signal<any[]>([]);
expenses = signal<any[]>([]); expenses = signal<any[]>([]);
transactions = signal<any[]>([]); transactions = signal<any[]>([]);
budgets = signal<any[]>([]);
accounts = signal<any[]>([]);
donutExpanded = signal(false); donutExpanded = signal(false);
selectedYear = signal(new Date().getFullYear()); selectedYear = signal(new Date().getFullYear());
yearDropdownOpen = signal(false); yearDropdownOpen = signal(false);
@@ -32,17 +34,18 @@ export class Dashboard implements OnInit, AfterViewInit, OnDestroy {
private barChart?: ApexCharts; private barChart?: ApexCharts;
private donutChart?: ApexCharts; private donutChart?: ApexCharts;
private dataLoaded = 0; private dataLoaded = 0;
private readonly totalRequests = 4; private readonly totalRequests = 5;
private timeInterval?: ReturnType<typeof setInterval>; private timeInterval?: ReturnType<typeof setInterval>;
private langSub?: Subscription; private langSub?: Subscription;
constructor(private api: ApiService, private translate: TranslateService) {} constructor(private api: ApiService, private fy: FinancialYearService, private translate: TranslateService) {}
ngOnInit(): void { ngOnInit(): void {
this.api.getAccounts().subscribe({ next: (d) => { this.accounts.set(d); this.onDataLoaded(); } }); this.fy.list().subscribe({ next: (d) => { this.financialYears.set(d); this.onDataLoaded(); } });
this.api.getBudgets().subscribe({ next: (d) => { this.budgets.set(d); this.onDataLoaded(); } });
this.api.getExpenses().subscribe({ next: (d) => { this.expenses.set(d); this.onDataLoaded(); } }); this.api.getExpenses().subscribe({ next: (d) => { this.expenses.set(d); this.onDataLoaded(); } });
this.api.getTransactions().subscribe({ next: (d) => { this.transactions.set(d); this.onDataLoaded(); } }); this.api.getTransactions().subscribe({ next: (d) => { this.transactions.set(d); this.onDataLoaded(); } });
this.api.getBudgets().subscribe({ next: (d) => { this.budgets.set(d); this.onDataLoaded(); } });
this.api.getAccounts().subscribe({ next: (d) => { this.accounts.set(d); this.onDataLoaded(); } });
this.api.getProfile().subscribe({ this.api.getProfile().subscribe({
next: (p) => { next: (p) => {
@@ -100,6 +103,10 @@ export class Dashboard implements OnInit, AfterViewInit, OnDestroy {
this.dateTimeDisplay.set(`${weekday}, ${date} | ${time}`); this.dateTimeDisplay.set(`${weekday}, ${date} | ${time}`);
} }
private financialYearFor(year: number): FinancialYear | undefined {
return this.financialYears().find((fy) => fy.year === year);
}
// KPIs // KPIs
totalIncome(): number { totalIncome(): number {
return this.accounts() return this.accounts()
@@ -114,7 +121,10 @@ export class Dashboard implements OnInit, AfterViewInit, OnDestroy {
} }
totalExpenses(): number { 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 { balance(): number {
@@ -162,21 +172,23 @@ export class Dashboard implements OnInit, AfterViewInit, OnDestroy {
this.selectedYear.set(year); this.selectedYear.set(year);
this.yearDropdownOpen.set(false); this.yearDropdownOpen.set(false);
this.renderBarChart(); this.renderBarChart();
this.renderDonutChart();
} }
availableYears(): number[] { availableYears(): number[] {
const years = new Set<number>([new Date().getFullYear()]); const years = new Set<number>([new Date().getFullYear()]);
this.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); return Array.from(years).sort((a, b) => b - a);
} }
donutItems(): { name: string; amount: number; pct: string; color: string }[] { donutItems(): { name: string; amount: number; pct: string; color: string }[] {
const active = this.budgets().filter((b) => b.active); const items = this.budgets().filter((b) => b.active);
const total = active.reduce((sum, b) => sum + parseFloat(b.amount), 0); const total = items.reduce((sum, b) => sum + +b.amount, 0);
return active.map((b, i) => ({ return items.map((b, i) => ({
name: b.name, name: b.name,
amount: parseFloat(b.amount), amount: +b.amount,
pct: total > 0 ? ((parseFloat(b.amount) / total) * 100).toFixed(1) : '0', pct: total > 0 ? ((+b.amount / total) * 100).toFixed(1) : '0',
color: this.donutColors[i % this.donutColors.length], 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 active = this.budgets().filter((b) => b.active);
const labels = active.map((b) => b.name); const labels = active.map((b) => b.name);
const series = active.map((b) => parseFloat(b.amount)); const series = active.map((b) => +b.amount);
if (series.length === 0) return; 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);
}
}
@@ -106,6 +106,26 @@
</a> </a>
</li> </li>
<!-- Financial Year -->
<li>
<a routerLink="/financial-year" routerLinkActive="!bg-violet-50 !text-violet-700 dark:!bg-violet-900/20 dark:!text-violet-300"
(click)="sidebarService.closeMobile()"
[class]="sidebarService.collapsed() ? 'justify-center relative' : ''"
class="flex items-center p-2 text-sm font-normal text-gray-900 rounded-lg dark:text-white hover:bg-gray-100 dark:hover:bg-gray-700 group">
<svg class="w-6 h-6 flex-shrink-0 text-gray-400 group-hover:text-gray-900 dark:group-hover:text-white" fill="currentColor" viewBox="0 0 20 20">
<path d="M2 10a1 1 0 011-1h2a1 1 0 011 1v5a1 1 0 01-1 1H3a1 1 0 01-1-1v-5zM8 6a1 1 0 011-1h2a1 1 0 011 1v9a1 1 0 01-1 1H9a1 1 0 01-1-1V6zM14 4a1 1 0 011-1h2a1 1 0 011 1v11a1 1 0 01-1 1h-2a1 1 0 01-1-1V4z"/>
</svg>
@if (!sidebarService.collapsed()) {
<span class="ml-3 whitespace-nowrap">{{ 'sidebar.financial_year' | translate }}</span>
}
@if (sidebarService.collapsed()) {
<span class="pointer-events-none absolute left-full ml-3 top-1/2 -translate-y-1/2 px-2 py-1 text-xs font-medium text-white bg-gray-900 dark:bg-gray-700 rounded whitespace-nowrap opacity-0 group-hover:opacity-100 transition-opacity duration-150 z-50">
{{ 'sidebar.financial_year' | translate }}
</span>
}
</a>
</li>
<!-- Accounts --> <!-- Accounts -->
<li class="relative"> <li class="relative">
@if (sidebarService.collapsed()) { @if (sidebarService.collapsed()) {
+4
View File
@@ -23,6 +23,10 @@ export class ApiService {
return this.http.put(`${this.baseUrl}/accounts/${id}/`, account); return this.http.put(`${this.baseUrl}/accounts/${id}/`, account);
} }
patchAccount(id: number, data: Partial<{name: string, balance: number, account_type: string, salary_months: number}>): Observable<any> {
return this.http.patch(`${this.baseUrl}/accounts/${id}/`, data);
}
deleteAccount(id: number): Observable<any> { deleteAccount(id: number): Observable<any> {
return this.http.delete(`${this.baseUrl}/accounts/${id}/`); return this.http.delete(`${this.baseUrl}/accounts/${id}/`);
} }
+146
View File
@@ -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/`);
}
}
+69
View File
@@ -142,6 +142,7 @@
"fixed_costs": "Fixkosten", "fixed_costs": "Fixkosten",
"expenses": "Ausgaben", "expenses": "Ausgaben",
"calendar": "Kalender", "calendar": "Kalender",
"financial_year": "Jahresplanung",
"accounts": "Konten", "accounts": "Konten",
"revenue_accounts": "Einnahmekonten", "revenue_accounts": "Einnahmekonten",
"transactions": "Transaktionen" "transactions": "Transaktionen"
@@ -345,6 +346,74 @@
"ZG": "Zug", "ZG": "Zug",
"ZH": "Zürich" "ZH": "Zürich"
}, },
"household": {
"title": "Haushalt",
"none": "Du bist noch in keinem Haushalt.",
"none_hint": "Gründe einen gemeinsamen Haushalt oder warte auf eine Einladung.",
"create": "Haushalt gründen",
"create_title": "Neuer Haushalt",
"label_name": "Name des Haushalts",
"placeholder_name": "z.B. Familie Müller",
"created_by": "Gegründet von",
"members": "Mitglieder",
"invite": "Einladen",
"invite_email": "E-Mail-Adresse",
"invite_placeholder": "user@beispiel.ch",
"send": "Einladung senden",
"status_active": "Aktiv",
"status_pending": "Ausstehend",
"status_left": "Ausgetreten",
"role_admin": "Admin",
"role_member": "Mitglied",
"make_admin": "Admin machen",
"remove_admin": "Admin entfernen",
"pending_invitation": "Einladung erhalten",
"pending_from": "Eingeladen von",
"effective_from": "Ab Jahr",
"accept": "Annehmen",
"leave": "Haushalt verlassen",
"leave_confirm_title": "Haushalt verlassen?",
"leave_confirm_text": "Du verlässt den Haushalt per Ende des laufenden Jahres. Vergangene Haushaltsjahre bleiben für dich lesbar.",
"you": "du",
"founder": "Gründer",
"error_name_required": "Name ist erforderlich.",
"error_failed": "Vorgang fehlgeschlagen.",
"error_email_required": "E-Mail-Adresse ist erforderlich.",
"error_not_found": "Kein Benutzer mit dieser E-Mail gefunden.",
"error_already_member": "Benutzer ist bereits Mitglied oder hat eine ausstehende Einladung."
},
"financial_year": {
"title": "Jahresplanung",
"owner_personal": "Persönlich",
"owner_household": "Haushalt",
"new_year": "Neues Jahr starten",
"no_years": "Noch kein Finanzjahr erstellt.",
"start_first_year": "Erstes Jahr starten",
"tab_incomes": "Einnahmen",
"tab_budget_items": "Fixkosten",
"add_income": "Einnahme hinzufügen",
"add_budget_item": "Fixkosten hinzufügen",
"label_name": "Bezeichnung",
"label_amount": "Betrag (CHF / Jahr)",
"label_notes": "Notizen",
"label_active": "Aktiv",
"total_income": "Gesamteinnahmen",
"total_fixed_costs": "Fixkosten total",
"disposable": "Verfügbar",
"savings_rate": "Sparquote",
"per_month": "Monat",
"no_incomes": "Noch keine Einnahmen erfasst.",
"no_budget_items": "Noch keine Fixkosten erfasst.",
"confirm_new_year": "Jahr {{ year }} starten?",
"confirm_copy": "Soll das neue Jahr mit den Daten aus {{ source }} vorausgefüllt werden?",
"first_year_hint": "Das Finanzjahr {{ year }} wird neu erstellt.",
"create_year": "Jahr erstellen",
"copy_yes": "Ja, Daten übernehmen",
"copy_no": "Leer starten",
"error_name_required": "Bezeichnung ist erforderlich.",
"error_amount_invalid": "Bitte einen gültigen Betrag eingeben.",
"error_save_failed": "Speichern fehlgeschlagen."
},
"profile": { "profile": {
"title": "Profil", "title": "Profil",
"subtitle": "Persönliche Informationen und Einstellungen verwalten", "subtitle": "Persönliche Informationen und Einstellungen verwalten",
+69
View File
@@ -142,6 +142,7 @@
"fixed_costs": "Fixed Costs", "fixed_costs": "Fixed Costs",
"expenses": "Expenses", "expenses": "Expenses",
"calendar": "Calendar", "calendar": "Calendar",
"financial_year": "Annual Planning",
"accounts": "Accounts", "accounts": "Accounts",
"revenue_accounts": "Revenue Accounts", "revenue_accounts": "Revenue Accounts",
"transactions": "Transactions" "transactions": "Transactions"
@@ -345,6 +346,74 @@
"ZG": "Zug", "ZG": "Zug",
"ZH": "Zuerich" "ZH": "Zuerich"
}, },
"household": {
"title": "Household",
"none": "You are not part of any household yet.",
"none_hint": "Create a shared household or wait for an invitation.",
"create": "Create Household",
"create_title": "New Household",
"label_name": "Household Name",
"placeholder_name": "e.g. Smith Family",
"created_by": "Created by",
"members": "Members",
"invite": "Invite",
"invite_email": "Email Address",
"invite_placeholder": "user@example.com",
"send": "Send Invitation",
"status_active": "Active",
"status_pending": "Pending",
"status_left": "Left",
"role_admin": "Admin",
"role_member": "Member",
"make_admin": "Make admin",
"remove_admin": "Remove admin",
"pending_invitation": "Invitation received",
"pending_from": "Invited by",
"effective_from": "From year",
"accept": "Accept",
"leave": "Leave Household",
"leave_confirm_title": "Leave Household?",
"leave_confirm_text": "You will leave the household at the end of the current year. Past household years remain readable for you.",
"you": "you",
"founder": "Founder",
"error_name_required": "Name is required.",
"error_failed": "Operation failed.",
"error_email_required": "Email address is required.",
"error_not_found": "No user found with this email.",
"error_already_member": "User is already a member or has a pending invitation."
},
"financial_year": {
"title": "Annual Planning",
"owner_personal": "Personal",
"owner_household": "Household",
"new_year": "Start New Year",
"no_years": "No financial year created yet.",
"start_first_year": "Start First Year",
"tab_incomes": "Income",
"tab_budget_items": "Fixed Costs",
"add_income": "Add Income",
"add_budget_item": "Add Fixed Cost",
"label_name": "Name",
"label_amount": "Amount (CHF / Year)",
"label_notes": "Notes",
"label_active": "Active",
"total_income": "Total Income",
"total_fixed_costs": "Total Fixed Costs",
"disposable": "Disposable",
"savings_rate": "Savings Rate",
"per_month": "Month",
"no_incomes": "No income entries yet.",
"no_budget_items": "No fixed cost entries yet.",
"confirm_new_year": "Start Year {{ year }}?",
"confirm_copy": "Should the new year be pre-filled with data from {{ source }}?",
"first_year_hint": "Financial year {{ year }} will be created.",
"create_year": "Create Year",
"copy_yes": "Yes, copy data",
"copy_no": "Start empty",
"error_name_required": "Name is required.",
"error_amount_invalid": "Please enter a valid amount.",
"error_save_failed": "Saving failed."
},
"profile": { "profile": {
"title": "Profile", "title": "Profile",
"subtitle": "Manage your personal information and settings", "subtitle": "Manage your personal information and settings",
+69
View File
@@ -142,6 +142,7 @@
"fixed_costs": "Charges fixes", "fixed_costs": "Charges fixes",
"expenses": "Dépenses", "expenses": "Dépenses",
"calendar": "Calendrier", "calendar": "Calendrier",
"financial_year": "Planification annuelle",
"accounts": "Comptes", "accounts": "Comptes",
"revenue_accounts": "Comptes de revenus", "revenue_accounts": "Comptes de revenus",
"transactions": "Transactions" "transactions": "Transactions"
@@ -345,6 +346,74 @@
"ZG": "Zoug", "ZG": "Zoug",
"ZH": "Zurich" "ZH": "Zurich"
}, },
"household": {
"title": "Ménage",
"none": "Vous ne faites encore partie d'aucun ménage.",
"none_hint": "Créez un ménage commun ou attendez une invitation.",
"create": "Créer un ménage",
"create_title": "Nouveau ménage",
"label_name": "Nom du ménage",
"placeholder_name": "p.ex. Famille Müller",
"created_by": "Créé par",
"members": "Membres",
"invite": "Inviter",
"invite_email": "Adresse e-mail",
"invite_placeholder": "user@exemple.ch",
"send": "Envoyer l'invitation",
"status_active": "Actif",
"status_pending": "En attente",
"status_left": "Parti",
"role_admin": "Admin",
"role_member": "Membre",
"make_admin": "Rendre admin",
"remove_admin": "Retirer admin",
"pending_invitation": "Invitation reçue",
"pending_from": "Invité par",
"effective_from": "Dès l'année",
"accept": "Accepter",
"leave": "Quitter le ménage",
"leave_confirm_title": "Quitter le ménage ?",
"leave_confirm_text": "Vous quitterez le ménage à la fin de l'année en cours. Les années passées resteront lisibles.",
"you": "vous",
"founder": "Fondateur",
"error_name_required": "Le nom est requis.",
"error_failed": "Opération échouée.",
"error_email_required": "L'adresse e-mail est requise.",
"error_not_found": "Aucun utilisateur trouvé avec cet e-mail.",
"error_already_member": "L'utilisateur est déjà membre ou a une invitation en attente."
},
"financial_year": {
"title": "Planification annuelle",
"owner_personal": "Personnel",
"owner_household": "Ménage",
"new_year": "Démarrer une nouvelle année",
"no_years": "Aucune année financière créée.",
"start_first_year": "Démarrer la première année",
"tab_incomes": "Revenus",
"tab_budget_items": "Charges fixes",
"add_income": "Ajouter un revenu",
"add_budget_item": "Ajouter une charge fixe",
"label_name": "Désignation",
"label_amount": "Montant (CHF / an)",
"label_notes": "Notes",
"label_active": "Actif",
"total_income": "Revenus totaux",
"total_fixed_costs": "Charges fixes totales",
"disposable": "Disponible",
"savings_rate": "Taux d'épargne",
"per_month": "mois",
"no_incomes": "Aucun revenu enregistré.",
"no_budget_items": "Aucune charge fixe enregistrée.",
"confirm_new_year": "Démarrer l'année {{ year }} ?",
"confirm_copy": "Reprendre les données de {{ source }} pour la nouvelle année ?",
"first_year_hint": "L'année financière {{ year }} sera créée.",
"create_year": "Créer l'année",
"copy_yes": "Oui, reprendre les données",
"copy_no": "Démarrer vide",
"error_name_required": "La désignation est requise.",
"error_amount_invalid": "Veuillez saisir un montant valide.",
"error_save_failed": "Échec de l'enregistrement."
},
"profile": { "profile": {
"title": "Profil", "title": "Profil",
"subtitle": "Gérer vos informations personnelles et paramètres", "subtitle": "Gérer vos informations personnelles et paramètres",
+69
View File
@@ -142,6 +142,7 @@
"fixed_costs": "Costi fissi", "fixed_costs": "Costi fissi",
"expenses": "Spese", "expenses": "Spese",
"calendar": "Calendario", "calendar": "Calendario",
"financial_year": "Pianificazione annuale",
"accounts": "Conti", "accounts": "Conti",
"revenue_accounts": "Conti entrate", "revenue_accounts": "Conti entrate",
"transactions": "Transazioni" "transactions": "Transazioni"
@@ -345,6 +346,74 @@
"ZG": "Zugo", "ZG": "Zugo",
"ZH": "Zurigo" "ZH": "Zurigo"
}, },
"household": {
"title": "Nucleo familiare",
"none": "Non fai ancora parte di nessun nucleo familiare.",
"none_hint": "Crea un nucleo familiare condiviso o attendi un invito.",
"create": "Crea nucleo familiare",
"create_title": "Nuovo nucleo familiare",
"label_name": "Nome del nucleo",
"placeholder_name": "es. Famiglia Müller",
"created_by": "Creato da",
"members": "Membri",
"invite": "Invita",
"invite_email": "Indirizzo e-mail",
"invite_placeholder": "user@esempio.ch",
"send": "Invia invito",
"status_active": "Attivo",
"status_pending": "In attesa",
"status_left": "Uscito",
"role_admin": "Admin",
"role_member": "Membro",
"make_admin": "Rendi admin",
"remove_admin": "Rimuovi admin",
"pending_invitation": "Invito ricevuto",
"pending_from": "Invitato da",
"effective_from": "Dall'anno",
"accept": "Accetta",
"leave": "Lascia il nucleo",
"leave_confirm_title": "Lasciare il nucleo?",
"leave_confirm_text": "Lascerai il nucleo alla fine dell'anno in corso. Gli anni passati rimarranno leggibili.",
"you": "tu",
"founder": "Fondatore",
"error_name_required": "Il nome è obbligatorio.",
"error_failed": "Operazione non riuscita.",
"error_email_required": "L'indirizzo e-mail è obbligatorio.",
"error_not_found": "Nessun utente trovato con questa e-mail.",
"error_already_member": "L'utente è già membro o ha un invito in sospeso."
},
"financial_year": {
"title": "Pianificazione annuale",
"owner_personal": "Personale",
"owner_household": "Nucleo familiare",
"new_year": "Avvia nuovo anno",
"no_years": "Nessun anno finanziario creato.",
"start_first_year": "Avvia il primo anno",
"tab_incomes": "Entrate",
"tab_budget_items": "Costi fissi",
"add_income": "Aggiungi entrata",
"add_budget_item": "Aggiungi costo fisso",
"label_name": "Denominazione",
"label_amount": "Importo (CHF / anno)",
"label_notes": "Note",
"label_active": "Attivo",
"total_income": "Entrate totali",
"total_fixed_costs": "Costi fissi totali",
"disposable": "Disponibile",
"savings_rate": "Tasso di risparmio",
"per_month": "mese",
"no_incomes": "Nessuna entrata registrata.",
"no_budget_items": "Nessun costo fisso registrato.",
"confirm_new_year": "Avviare l'anno {{ year }}?",
"confirm_copy": "Vuoi pre-compilare il nuovo anno con i dati di {{ source }}?",
"first_year_hint": "L'anno finanziario {{ year }} verrà creato.",
"create_year": "Crea anno",
"copy_yes": "Sì, copia i dati",
"copy_no": "Inizia vuoto",
"error_name_required": "La denominazione è obbligatoria.",
"error_amount_invalid": "Inserisci un importo valido.",
"error_save_failed": "Salvataggio non riuscito."
},
"profile": { "profile": {
"title": "Profilo", "title": "Profilo",
"subtitle": "Gestisci le tue informazioni personali e impostazioni", "subtitle": "Gestisci le tue informazioni personali e impostazioni",