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
This commit is contained in:
@@ -14,6 +14,7 @@ import { ExpenseList } from './expenses/expense-list/expense-list';
|
||||
import { Profile } from './profile/profile';
|
||||
import { Settings } from './settings/settings';
|
||||
import { Calendar } from './calendar/calendar';
|
||||
import { FinancialYearComponent } from './financial-year/financial-year';
|
||||
export const routes: Routes = [
|
||||
{ path: 'login', component: Login },
|
||||
{ path: 'register', component: Register },
|
||||
@@ -34,6 +35,7 @@ export const routes: Routes = [
|
||||
{ path: 'profile', component: Profile },
|
||||
{ path: 'settings', component: Settings },
|
||||
{ path: 'calendar', component: Calendar },
|
||||
{ path: 'financial-year', component: FinancialYearComponent },
|
||||
],
|
||||
},
|
||||
{ path: '**', redirectTo: 'dashboard' },
|
||||
|
||||
@@ -2,6 +2,7 @@ import { Component, OnInit, OnDestroy, AfterViewInit, signal } from '@angular/co
|
||||
import { CommonModule } from '@angular/common';
|
||||
import { TranslateModule, TranslateService } from '@ngx-translate/core';
|
||||
import { ApiService } from '../services/api';
|
||||
import { FinancialYearService, FinancialYear } from '../services/financial-year';
|
||||
import ApexCharts from 'apexcharts';
|
||||
import { Subscription } from 'rxjs';
|
||||
|
||||
@@ -13,10 +14,11 @@ import { Subscription } from 'rxjs';
|
||||
styleUrl: './dashboard.css',
|
||||
})
|
||||
export class Dashboard implements OnInit, AfterViewInit, OnDestroy {
|
||||
accounts = signal<any[]>([]);
|
||||
budgets = signal<any[]>([]);
|
||||
financialYears = signal<FinancialYear[]>([]);
|
||||
expenses = signal<any[]>([]);
|
||||
transactions = signal<any[]>([]);
|
||||
budgets = signal<any[]>([]);
|
||||
accounts = signal<any[]>([]);
|
||||
donutExpanded = signal(false);
|
||||
selectedYear = signal(new Date().getFullYear());
|
||||
yearDropdownOpen = signal(false);
|
||||
@@ -32,17 +34,18 @@ export class Dashboard implements OnInit, AfterViewInit, OnDestroy {
|
||||
private barChart?: ApexCharts;
|
||||
private donutChart?: ApexCharts;
|
||||
private dataLoaded = 0;
|
||||
private readonly totalRequests = 4;
|
||||
private readonly totalRequests = 5;
|
||||
private timeInterval?: ReturnType<typeof setInterval>;
|
||||
private langSub?: Subscription;
|
||||
|
||||
constructor(private api: ApiService, private translate: TranslateService) {}
|
||||
constructor(private api: ApiService, private fy: FinancialYearService, private translate: TranslateService) {}
|
||||
|
||||
ngOnInit(): void {
|
||||
this.api.getAccounts().subscribe({ next: (d) => { this.accounts.set(d); this.onDataLoaded(); } });
|
||||
this.api.getBudgets().subscribe({ next: (d) => { this.budgets.set(d); this.onDataLoaded(); } });
|
||||
this.fy.list().subscribe({ next: (d) => { this.financialYears.set(d); this.onDataLoaded(); } });
|
||||
this.api.getExpenses().subscribe({ next: (d) => { this.expenses.set(d); this.onDataLoaded(); } });
|
||||
this.api.getTransactions().subscribe({ next: (d) => { this.transactions.set(d); this.onDataLoaded(); } });
|
||||
this.api.getBudgets().subscribe({ next: (d) => { this.budgets.set(d); this.onDataLoaded(); } });
|
||||
this.api.getAccounts().subscribe({ next: (d) => { this.accounts.set(d); this.onDataLoaded(); } });
|
||||
|
||||
this.api.getProfile().subscribe({
|
||||
next: (p) => {
|
||||
@@ -100,6 +103,10 @@ export class Dashboard implements OnInit, AfterViewInit, OnDestroy {
|
||||
this.dateTimeDisplay.set(`${weekday}, ${date} | ${time}`);
|
||||
}
|
||||
|
||||
private financialYearFor(year: number): FinancialYear | undefined {
|
||||
return this.financialYears().find((fy) => fy.year === year);
|
||||
}
|
||||
|
||||
// KPIs
|
||||
totalIncome(): number {
|
||||
return this.accounts()
|
||||
@@ -114,7 +121,10 @@ export class Dashboard implements OnInit, AfterViewInit, OnDestroy {
|
||||
}
|
||||
|
||||
totalExpenses(): number {
|
||||
return this.expenses().reduce((sum, e) => sum + parseFloat(e.amount), 0);
|
||||
const year = this.selectedYear();
|
||||
return this.expenses()
|
||||
.filter((e) => new Date(e.date).getFullYear() === year)
|
||||
.reduce((sum, e) => sum + parseFloat(e.amount), 0);
|
||||
}
|
||||
|
||||
balance(): number {
|
||||
@@ -162,21 +172,23 @@ export class Dashboard implements OnInit, AfterViewInit, OnDestroy {
|
||||
this.selectedYear.set(year);
|
||||
this.yearDropdownOpen.set(false);
|
||||
this.renderBarChart();
|
||||
this.renderDonutChart();
|
||||
}
|
||||
|
||||
availableYears(): number[] {
|
||||
const years = new Set<number>([new Date().getFullYear()]);
|
||||
this.expenses().forEach(e => years.add(new Date(e.date).getFullYear()));
|
||||
this.financialYears().forEach((fy) => years.add(fy.year));
|
||||
this.expenses().forEach((e) => years.add(new Date(e.date).getFullYear()));
|
||||
return Array.from(years).sort((a, b) => b - a);
|
||||
}
|
||||
|
||||
donutItems(): { name: string; amount: number; pct: string; color: string }[] {
|
||||
const active = this.budgets().filter((b) => b.active);
|
||||
const total = active.reduce((sum, b) => sum + parseFloat(b.amount), 0);
|
||||
return active.map((b, i) => ({
|
||||
const items = this.budgets().filter((b) => b.active);
|
||||
const total = items.reduce((sum, b) => sum + +b.amount, 0);
|
||||
return items.map((b, i) => ({
|
||||
name: b.name,
|
||||
amount: parseFloat(b.amount),
|
||||
pct: total > 0 ? ((parseFloat(b.amount) / total) * 100).toFixed(1) : '0',
|
||||
amount: +b.amount,
|
||||
pct: total > 0 ? ((+b.amount / total) * 100).toFixed(1) : '0',
|
||||
color: this.donutColors[i % this.donutColors.length],
|
||||
}));
|
||||
}
|
||||
@@ -304,7 +316,7 @@ export class Dashboard implements OnInit, AfterViewInit, OnDestroy {
|
||||
|
||||
const active = this.budgets().filter((b) => b.active);
|
||||
const labels = active.map((b) => b.name);
|
||||
const series = active.map((b) => parseFloat(b.amount));
|
||||
const series = active.map((b) => +b.amount);
|
||||
|
||||
if (series.length === 0) return;
|
||||
|
||||
|
||||
@@ -0,0 +1,671 @@
|
||||
<!-- Backdrop for year dropdown -->
|
||||
@if (yearDropdownOpen()) {
|
||||
<div class="fixed inset-0 z-20" (click)="yearDropdownOpen.set(false)"></div>
|
||||
}
|
||||
|
||||
<div class="p-4 sm:p-6 max-w-4xl mx-auto space-y-5">
|
||||
|
||||
<!-- Page header -->
|
||||
<div class="flex flex-col sm:flex-row sm:items-center sm:justify-between gap-3">
|
||||
<div>
|
||||
<h1 class="text-xl font-semibold text-gray-900 dark:text-white">
|
||||
{{ 'financial_year.title' | translate }}
|
||||
</h1>
|
||||
@if (currentFY()) {
|
||||
<p class="text-sm text-gray-500 dark:text-gray-400 mt-0.5">
|
||||
{{ currentFY()!.owner_type === 'household'
|
||||
? ('financial_year.owner_household' | translate)
|
||||
: ('financial_year.owner_personal' | translate) }}
|
||||
</p>
|
||||
}
|
||||
</div>
|
||||
|
||||
<div class="flex items-center gap-2">
|
||||
|
||||
<!-- Year dropdown -->
|
||||
@if (years().length > 0) {
|
||||
<div class="relative z-30">
|
||||
<button type="button" (click)="yearDropdownOpen.set(!yearDropdownOpen())"
|
||||
class="flex items-center gap-2 px-3 py-2 text-sm font-medium text-gray-700 dark:text-gray-200 bg-white dark:bg-gray-800 border border-gray-300 dark:border-gray-600 rounded-lg hover:bg-gray-50 dark:hover:bg-gray-700">
|
||||
<span>{{ selectedYear() }}</span>
|
||||
<svg class="w-4 h-4 transition-transform" [class.rotate-180]="yearDropdownOpen()" fill="currentColor" viewBox="0 0 20 20">
|
||||
<path fill-rule="evenodd" d="M5.293 7.293a1 1 0 011.414 0L10 10.586l3.293-3.293a1 1 0 111.414 1.414l-4 4a1 1 0 01-1.414 0l-4-4a1 1 0 010-1.414z" clip-rule="evenodd"/>
|
||||
</svg>
|
||||
</button>
|
||||
@if (yearDropdownOpen()) {
|
||||
<div class="absolute right-0 top-full mt-1 w-28 bg-white dark:bg-gray-800 border border-gray-200 dark:border-gray-700 rounded-lg shadow-lg py-1">
|
||||
@for (y of years(); track y.year) {
|
||||
<button type="button" (click)="selectYear(y.year)"
|
||||
class="w-full flex items-center justify-between px-3 py-1.5 text-sm hover:bg-gray-100 dark:hover:bg-gray-700"
|
||||
[class.text-violet-700]="y.year === selectedYear()"
|
||||
[class.dark:text-violet-400]="y.year === selectedYear()"
|
||||
[class.font-semibold]="y.year === selectedYear()"
|
||||
[class.text-gray-700]="y.year !== selectedYear()"
|
||||
[class.dark:text-gray-200]="y.year !== selectedYear()">
|
||||
{{ y.year }}
|
||||
@if (y.is_active) {
|
||||
<span class="w-1.5 h-1.5 rounded-full bg-violet-500"></span>
|
||||
}
|
||||
</button>
|
||||
}
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
}
|
||||
|
||||
<!-- New year button -->
|
||||
@if (canCreateNewYear()) {
|
||||
<button type="button" (click)="openNewYearModal()"
|
||||
class="flex items-center gap-1.5 px-3 py-2 text-sm font-medium text-white bg-violet-700 rounded-lg hover:bg-violet-800 focus:ring-2 focus:ring-violet-300 dark:focus:ring-violet-800">
|
||||
<svg class="w-4 h-4" fill="currentColor" viewBox="0 0 20 20">
|
||||
<path fill-rule="evenodd" d="M10 3a1 1 0 011 1v5h5a1 1 0 110 2h-5v5a1 1 0 11-2 0v-5H4a1 1 0 110-2h5V4a1 1 0 011-1z" clip-rule="evenodd"/>
|
||||
</svg>
|
||||
{{ 'financial_year.new_year' | translate }}
|
||||
</button>
|
||||
}
|
||||
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Loading spinner -->
|
||||
@if (loading()) {
|
||||
<div class="flex justify-center py-16">
|
||||
<svg class="animate-spin w-6 h-6 text-violet-600" fill="none" viewBox="0 0 24 24">
|
||||
<circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4"></circle>
|
||||
<path class="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4z"></path>
|
||||
</svg>
|
||||
</div>
|
||||
}
|
||||
|
||||
<!-- Empty state: no years at all -->
|
||||
@if (!loading() && years().length === 0) {
|
||||
<div class="text-center py-16">
|
||||
<svg class="mx-auto w-12 h-12 text-gray-300 dark:text-gray-600 mb-4" fill="currentColor" viewBox="0 0 20 20">
|
||||
<path d="M2 10a1 1 0 011-1h2a1 1 0 011 1v5a1 1 0 01-1 1H3a1 1 0 01-1-1v-5zM8 6a1 1 0 011-1h2a1 1 0 011 1v9a1 1 0 01-1 1H9a1 1 0 01-1-1V6zM14 4a1 1 0 011-1h2a1 1 0 011 1v11a1 1 0 01-1 1h-2a1 1 0 01-1-1V4z"/>
|
||||
</svg>
|
||||
<p class="text-gray-500 dark:text-gray-400 mb-4">{{ 'financial_year.no_years' | translate }}</p>
|
||||
<button type="button" (click)="openNewYearModal()"
|
||||
class="px-4 py-2 text-sm font-medium text-white bg-violet-700 rounded-lg hover:bg-violet-800">
|
||||
{{ 'financial_year.start_first_year' | translate }}
|
||||
</button>
|
||||
</div>
|
||||
}
|
||||
|
||||
<!-- Main content -->
|
||||
@if (!loading() && currentFY()) {
|
||||
|
||||
<!-- Summary cards -->
|
||||
<div class="grid grid-cols-1 sm:grid-cols-3 gap-4">
|
||||
|
||||
<div class="bg-white dark:bg-gray-800 rounded-xl border border-gray-200 dark:border-gray-700 p-4">
|
||||
<p class="text-xs font-medium text-gray-500 dark:text-gray-400 uppercase tracking-wide">
|
||||
{{ 'financial_year.total_income' | translate }}
|
||||
</p>
|
||||
<p class="mt-1.5 text-2xl font-bold text-gray-900 dark:text-white">
|
||||
CHF {{ formatChf(totalAnnualIncome()) }}
|
||||
</p>
|
||||
<p class="text-xs text-gray-400 dark:text-gray-500 mt-0.5">
|
||||
CHF {{ formatChf(totalAnnualIncome() / 12) }} / {{ 'financial_year.per_month' | translate }}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div class="bg-white dark:bg-gray-800 rounded-xl border border-gray-200 dark:border-gray-700 p-4">
|
||||
<p class="text-xs font-medium text-gray-500 dark:text-gray-400 uppercase tracking-wide">
|
||||
{{ 'financial_year.total_fixed_costs' | translate }}
|
||||
</p>
|
||||
<p class="mt-1.5 text-2xl font-bold text-gray-900 dark:text-white">
|
||||
CHF {{ formatChf(totalAnnualBudget()) }}
|
||||
</p>
|
||||
<p class="text-xs text-gray-400 dark:text-gray-500 mt-0.5">
|
||||
CHF {{ formatChf(totalMonthlyBudget()) }} / {{ 'financial_year.per_month' | translate }}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div class="bg-white dark:bg-gray-800 rounded-xl border border-gray-200 dark:border-gray-700 p-4">
|
||||
<p class="text-xs font-medium text-gray-500 dark:text-gray-400 uppercase tracking-wide">
|
||||
{{ 'financial_year.total_expenses_year' | translate }} {{ selectedYear() }}
|
||||
</p>
|
||||
<p class="mt-1.5 text-2xl font-bold text-gray-900 dark:text-white">
|
||||
CHF {{ formatChf(totalYearExpenses()) }}
|
||||
</p>
|
||||
<p class="text-xs text-gray-400 dark:text-gray-500 mt-0.5">
|
||||
Ø CHF {{ formatChf(avgMonthlyExpenses()) }} / {{ 'financial_year.per_month' | translate }}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
|
||||
<!-- Tabs + item list -->
|
||||
<div class="bg-white dark:bg-gray-800 rounded-xl border border-gray-200 dark:border-gray-700 overflow-hidden">
|
||||
|
||||
<!-- Tab navigation -->
|
||||
<div class="border-b border-gray-200 dark:border-gray-700 px-4">
|
||||
<nav class="flex gap-0 -mb-px">
|
||||
<button type="button" (click)="selectTab('incomes')"
|
||||
class="px-4 py-3 text-sm font-medium border-b-2 transition-colors"
|
||||
[class.border-violet-700]="activeTab() === 'incomes'"
|
||||
[class.text-violet-700]="activeTab() === 'incomes'"
|
||||
[class.dark:text-violet-400]="activeTab() === 'incomes'"
|
||||
[class.border-transparent]="activeTab() !== 'incomes'"
|
||||
[class.text-gray-500]="activeTab() !== 'incomes'"
|
||||
[class.dark:text-gray-400]="activeTab() !== 'incomes'"
|
||||
[class.hover:text-gray-700]="activeTab() !== 'incomes'"
|
||||
[class.dark:hover:text-gray-200]="activeTab() !== 'incomes'">
|
||||
{{ 'financial_year.tab_incomes' | translate }}
|
||||
<span class="ml-1.5 text-xs bg-gray-100 dark:bg-gray-700 text-gray-600 dark:text-gray-300 rounded-full px-1.5 py-0.5">
|
||||
{{ revenueAccounts().length }}
|
||||
</span>
|
||||
</button>
|
||||
<button type="button" (click)="selectTab('budget_items')"
|
||||
class="px-4 py-3 text-sm font-medium border-b-2 transition-colors"
|
||||
[class.border-violet-700]="activeTab() === 'budget_items'"
|
||||
[class.text-violet-700]="activeTab() === 'budget_items'"
|
||||
[class.dark:text-violet-400]="activeTab() === 'budget_items'"
|
||||
[class.border-transparent]="activeTab() !== 'budget_items'"
|
||||
[class.text-gray-500]="activeTab() !== 'budget_items'"
|
||||
[class.dark:text-gray-400]="activeTab() !== 'budget_items'"
|
||||
[class.hover:text-gray-700]="activeTab() !== 'budget_items'"
|
||||
[class.dark:hover:text-gray-200]="activeTab() !== 'budget_items'">
|
||||
{{ 'financial_year.tab_budget_items' | translate }}
|
||||
<span class="ml-1.5 text-xs bg-gray-100 dark:bg-gray-700 text-gray-600 dark:text-gray-300 rounded-full px-1.5 py-0.5">
|
||||
{{ budgetItems().length }}
|
||||
</span>
|
||||
</button>
|
||||
</nav>
|
||||
</div>
|
||||
|
||||
<!-- Item list: Incomes (from Revenue Accounts) -->
|
||||
@if (activeTab() === 'incomes') {
|
||||
@if (revenueAccounts().length === 0) {
|
||||
<p class="text-sm text-gray-400 dark:text-gray-500 text-center py-10">
|
||||
{{ 'financial_year.no_revenue_accounts' | translate }}
|
||||
</p>
|
||||
}
|
||||
@for (account of revenueAccounts(); track account.id) {
|
||||
<div class="flex items-center gap-3 px-4 py-3 hover:bg-gray-50 dark:hover:bg-gray-700/40 border-b border-gray-100 dark:border-gray-700/50 last:border-b-0">
|
||||
<div class="flex-1 min-w-0">
|
||||
<p class="text-sm font-medium text-gray-900 dark:text-white truncate">{{ account.name }}</p>
|
||||
<p class="text-xs text-gray-400 dark:text-gray-500">
|
||||
CHF {{ formatChf(account.balance) }}/Mt.
|
||||
@if (account.owner_email && !account.is_mine) {
|
||||
· <span class="italic">{{ account.owner_email }}</span>
|
||||
}
|
||||
</p>
|
||||
</div>
|
||||
<div class="text-right shrink-0">
|
||||
<p class="text-sm font-semibold text-gray-900 dark:text-white">
|
||||
CHF {{ formatChf(account.balance * (account.salary_months ?? 12)) }}
|
||||
</p>
|
||||
<p class="text-xs text-gray-400 dark:text-gray-500">
|
||||
{{ 'financial_year.annual_label' | translate }}
|
||||
</p>
|
||||
</div>
|
||||
<button type="button" (click)="toggleSalaryMonths(account)"
|
||||
title="{{ 'financial_year.toggle_salary_months' | translate }}"
|
||||
class="shrink-0 min-w-[2.5rem] text-xs font-semibold rounded-full px-2.5 py-1 transition-colors"
|
||||
[class.bg-violet-100]="(account.salary_months ?? 12) === 13"
|
||||
[class.text-violet-700]="(account.salary_months ?? 12) === 13"
|
||||
[class.dark:bg-violet-900/30]="(account.salary_months ?? 12) === 13"
|
||||
[class.dark:text-violet-400]="(account.salary_months ?? 12) === 13"
|
||||
[class.bg-gray-100]="(account.salary_months ?? 12) === 12"
|
||||
[class.text-gray-600]="(account.salary_months ?? 12) === 12"
|
||||
[class.dark:bg-gray-700]="(account.salary_months ?? 12) === 12"
|
||||
[class.dark:text-gray-300]="(account.salary_months ?? 12) === 12">
|
||||
{{ account.salary_months ?? 12 }} Mt.
|
||||
</button>
|
||||
</div>
|
||||
}
|
||||
@if (revenueAccounts().length > 0) {
|
||||
<div class="flex items-center justify-between px-4 py-3 bg-gray-50 dark:bg-gray-700/30 border-t border-gray-200 dark:border-gray-700">
|
||||
<span class="text-sm font-medium text-gray-700 dark:text-gray-300">{{ 'financial_year.total_annual_income' | translate }}</span>
|
||||
<span class="text-sm font-bold text-gray-900 dark:text-white">CHF {{ formatChf(totalAnnualIncome()) }}</span>
|
||||
</div>
|
||||
}
|
||||
}
|
||||
|
||||
<!-- Item list: Budget items -->
|
||||
@if (activeTab() === 'budget_items') {
|
||||
@if (budgetItems().length === 0 && !showForm()) {
|
||||
<p class="text-sm text-gray-400 dark:text-gray-500 text-center py-10">
|
||||
{{ 'financial_year.no_budget_items' | translate }}
|
||||
</p>
|
||||
}
|
||||
@for (item of budgetItems(); track item.id) {
|
||||
<div class="flex items-center gap-3 px-4 py-3 hover:bg-gray-50 dark:hover:bg-gray-700/40 border-b border-gray-100 dark:border-gray-700/50 last:border-b-0">
|
||||
<div class="flex-1 min-w-0">
|
||||
<p class="text-sm font-medium text-gray-900 dark:text-white truncate">{{ item.name }}</p>
|
||||
@if (item.notes) {
|
||||
<p class="text-xs text-gray-400 dark:text-gray-500 truncate">{{ item.notes }}</p>
|
||||
}
|
||||
</div>
|
||||
<div class="text-right shrink-0">
|
||||
<p class="text-sm font-semibold text-gray-900 dark:text-white">CHF {{ formatChf(item.amount) }}</p>
|
||||
<p class="text-xs text-gray-400 dark:text-gray-500">CHF {{ formatChf(perMonth(item.amount)) }}/Mt.</p>
|
||||
</div>
|
||||
@if (!item.active) {
|
||||
<span class="shrink-0 text-xs font-medium bg-gray-100 dark:bg-gray-700 text-gray-500 dark:text-gray-400 rounded-full px-2 py-0.5">Inaktiv</span>
|
||||
}
|
||||
<div class="flex items-center gap-0.5 shrink-0">
|
||||
<button type="button" (click)="openEditForm(item)"
|
||||
title="{{ 'common.edit' | translate }}"
|
||||
class="p-1.5 text-gray-400 hover:text-violet-700 dark:hover:text-violet-400 rounded-md hover:bg-violet-50 dark:hover:bg-violet-900/20 transition-colors">
|
||||
<svg class="w-4 h-4" aria-hidden="true" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24">
|
||||
<path stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="m14.304 4.844 2.852 2.852M7 7H4a1 1 0 0 0-1 1v10a1 1 0 0 0 1 1h11a1 1 0 0 0 1-1v-4.5m2.409-9.91a2.017 2.017 0 0 1 0 2.853l-6.844 6.844L8 14l.713-3.565 6.844-6.844a2.015 2.015 0 0 1 2.852 0Z"/>
|
||||
</svg>
|
||||
</button>
|
||||
<button type="button" (click)="confirmDelete(item.id)"
|
||||
title="{{ 'common.delete' | translate }}"
|
||||
class="p-1.5 text-gray-400 hover:text-red-600 dark:hover:text-red-400 rounded-md hover:bg-red-50 dark:hover:bg-red-900/20 transition-colors">
|
||||
<svg class="w-4 h-4" fill="none" viewBox="0 0 24 24">
|
||||
<path stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M5 7h14m-9 3v8m4-8v8M10 3h4a1 1 0 0 1 1 1v3H9V4a1 1 0 0 1 1-1ZM6 7h12v13a1 1 0 0 1-1 1H7a1 1 0 0 1-1-1V7Z"/>
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
}
|
||||
|
||||
<!-- Inline add/edit form (budget_items tab only) -->
|
||||
@if (showForm() && activeTab() !== 'incomes') {
|
||||
<div class="px-4 py-4 bg-gray-50 dark:bg-gray-700/30 border-t border-gray-200 dark:border-gray-700">
|
||||
<h3 class="text-sm font-semibold text-gray-900 dark:text-white mb-3">
|
||||
{{ editingId ? ('common.edit' | translate) : ('common.add' | translate) }}
|
||||
</h3>
|
||||
<div class="grid grid-cols-1 sm:grid-cols-2 gap-3">
|
||||
<div>
|
||||
<label class="block text-xs font-medium text-gray-700 dark:text-gray-300 mb-1">
|
||||
{{ 'financial_year.label_name' | translate }}
|
||||
</label>
|
||||
<input type="text" [(ngModel)]="formName"
|
||||
[placeholder]="activeTab() === 'incomes' ? 'z.B. Lohn' : 'z.B. Miete'"
|
||||
class="w-full px-3 py-2 text-sm bg-white dark:bg-gray-800 border border-gray-300 dark:border-gray-600 rounded-lg focus:ring-2 focus:ring-violet-500 focus:border-violet-500 text-gray-900 dark:text-white placeholder-gray-400">
|
||||
</div>
|
||||
<div>
|
||||
<label class="block text-xs font-medium text-gray-700 dark:text-gray-300 mb-1">
|
||||
{{ 'financial_year.label_amount' | translate }}
|
||||
</label>
|
||||
<input type="number" [(ngModel)]="formAmount" min="0" step="100"
|
||||
class="w-full px-3 py-2 text-sm bg-white dark:bg-gray-800 border border-gray-300 dark:border-gray-600 rounded-lg focus:ring-2 focus:ring-violet-500 focus:border-violet-500 text-gray-900 dark:text-white">
|
||||
</div>
|
||||
<div>
|
||||
<label class="block text-xs font-medium text-gray-700 dark:text-gray-300 mb-1">
|
||||
{{ 'financial_year.label_notes' | translate }}
|
||||
<span class="text-gray-400 font-normal">({{ 'common.optional' | translate }})</span>
|
||||
</label>
|
||||
<input type="text" [(ngModel)]="formNotes"
|
||||
class="w-full px-3 py-2 text-sm bg-white dark:bg-gray-800 border border-gray-300 dark:border-gray-600 rounded-lg focus:ring-2 focus:ring-violet-500 focus:border-violet-500 text-gray-900 dark:text-white placeholder-gray-400">
|
||||
</div>
|
||||
<div class="flex items-end pb-0.5">
|
||||
<label class="flex items-center gap-2 cursor-pointer">
|
||||
<input type="checkbox" [(ngModel)]="formActive"
|
||||
class="w-4 h-4 rounded border-gray-300 dark:border-gray-600 text-violet-600 focus:ring-violet-500 bg-white dark:bg-gray-700">
|
||||
<span class="text-sm text-gray-700 dark:text-gray-300">
|
||||
{{ 'financial_year.label_active' | translate }}
|
||||
</span>
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
@if (formError) {
|
||||
<p class="mt-2 text-xs text-red-600 dark:text-red-400">
|
||||
{{ 'financial_year.error_' + formError | translate }}
|
||||
</p>
|
||||
}
|
||||
<div class="flex items-center gap-2 mt-3">
|
||||
<button type="button" (click)="saveForm()" [disabled]="formSaving"
|
||||
class="px-4 py-2 text-sm font-medium text-white bg-violet-700 rounded-lg hover:bg-violet-800 disabled:opacity-60 disabled:cursor-not-allowed">
|
||||
{{ formSaving ? '…' : ('common.save' | translate) }}
|
||||
</button>
|
||||
<button type="button" (click)="closeForm()"
|
||||
class="px-4 py-2 text-sm font-medium text-gray-700 dark:text-gray-200 bg-white dark:bg-gray-800 border border-gray-300 dark:border-gray-600 rounded-lg hover:bg-gray-50 dark:hover:bg-gray-700">
|
||||
{{ 'common.cancel' | translate }}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
|
||||
<!-- Footer add button (budget_items tab only) -->
|
||||
@if (!showForm() && activeTab() !== 'incomes') {
|
||||
<div class="px-4 py-3 border-t border-gray-100 dark:border-gray-700/50">
|
||||
<button type="button" (click)="openAddForm()"
|
||||
class="flex items-center gap-1.5 text-sm font-medium text-violet-700 dark:text-violet-400 hover:text-violet-900 dark:hover:text-violet-300 transition-colors">
|
||||
<svg class="w-4 h-4" fill="currentColor" viewBox="0 0 20 20">
|
||||
<path fill-rule="evenodd" d="M10 3a1 1 0 011 1v5h5a1 1 0 110 2h-5v5a1 1 0 11-2 0v-5H4a1 1 0 110-2h5V4a1 1 0 011-1z" clip-rule="evenodd"/>
|
||||
</svg>
|
||||
{{ activeTab() === 'incomes'
|
||||
? ('financial_year.add_income' | translate)
|
||||
: ('financial_year.add_budget_item' | translate) }}
|
||||
</button>
|
||||
</div>
|
||||
}
|
||||
|
||||
</div>
|
||||
}
|
||||
|
||||
<!-- Household Section -->
|
||||
<div class="bg-white dark:bg-gray-800 rounded-xl border border-gray-200 dark:border-gray-700 overflow-hidden">
|
||||
|
||||
<!-- Header -->
|
||||
<div class="flex items-center gap-2 px-4 py-3 border-b border-gray-200 dark:border-gray-700">
|
||||
<svg class="w-5 h-5 text-violet-700 dark:text-violet-400 flex-shrink-0" fill="currentColor" viewBox="0 0 20 20">
|
||||
<path d="M10.707 2.293a1 1 0 00-1.414 0l-7 7a1 1 0 001.414 1.414L4 10.414V17a1 1 0 001 1h4a1 1 0 001-1v-3h2v3a1 1 0 001 1h4a1 1 0 001-1v-6.586l.293.293a1 1 0 001.414-1.414l-7-7z"/>
|
||||
</svg>
|
||||
<h2 class="text-sm font-semibold text-gray-900 dark:text-white">{{ 'household.title' | translate }}</h2>
|
||||
</div>
|
||||
|
||||
<!-- No household at all -->
|
||||
@if (households().length === 0) {
|
||||
<div class="px-4 py-8 text-center">
|
||||
<p class="text-sm text-gray-500 dark:text-gray-400 mb-1">{{ 'household.none' | translate }}</p>
|
||||
<p class="text-xs text-gray-400 dark:text-gray-500 mb-4">{{ 'household.none_hint' | translate }}</p>
|
||||
|
||||
@if (!showCreateHouseholdForm()) {
|
||||
<button type="button" (click)="showCreateHouseholdForm.set(true)"
|
||||
class="px-4 py-2 text-sm font-medium text-white bg-violet-700 rounded-lg hover:bg-violet-800">
|
||||
{{ 'household.create' | translate }}
|
||||
</button>
|
||||
}
|
||||
|
||||
@if (showCreateHouseholdForm()) {
|
||||
<div class="mt-4 max-w-sm mx-auto text-left">
|
||||
<label class="block text-xs font-medium text-gray-700 dark:text-gray-300 mb-1">
|
||||
{{ 'household.label_name' | translate }}
|
||||
</label>
|
||||
<input type="text" [(ngModel)]="householdName"
|
||||
[placeholder]="'household.placeholder_name' | translate"
|
||||
class="w-full px-3 py-2 text-sm bg-white dark:bg-gray-800 border border-gray-300 dark:border-gray-600 rounded-lg focus:ring-2 focus:ring-violet-500 focus:border-violet-500 text-gray-900 dark:text-white placeholder-gray-400">
|
||||
@if (householdError) {
|
||||
<p class="mt-1 text-xs text-red-600 dark:text-red-400">
|
||||
{{ 'household.error_' + householdError | translate }}
|
||||
</p>
|
||||
}
|
||||
<div class="flex gap-2 mt-2">
|
||||
<button type="button" (click)="createHousehold()" [disabled]="householdSaving"
|
||||
class="px-4 py-2 text-sm font-medium text-white bg-violet-700 rounded-lg hover:bg-violet-800 disabled:opacity-60">
|
||||
{{ householdSaving ? '…' : ('common.create' | translate) }}
|
||||
</button>
|
||||
<button type="button" (click)="showCreateHouseholdForm.set(false); householdName = ''; householdError = ''"
|
||||
class="px-4 py-2 text-sm font-medium text-gray-700 dark:text-gray-200 bg-white dark:bg-gray-800 border border-gray-300 dark:border-gray-600 rounded-lg hover:bg-gray-50 dark:hover:bg-gray-700">
|
||||
{{ 'common.cancel' | translate }}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
}
|
||||
|
||||
<!-- Household list -->
|
||||
@for (h of households(); track h.id) {
|
||||
@let myM = myMembership(h);
|
||||
@let amFounder = isFounder(h);
|
||||
@let isPending = myM?.status === 'pending';
|
||||
|
||||
<div class="px-4 py-4" [class.border-b]="!$last" [class.border-gray-100]="!$last" [class.dark:border-gray-700]="!$last">
|
||||
|
||||
<!-- Household header row -->
|
||||
<div class="flex items-center justify-between mb-3">
|
||||
<div>
|
||||
<h3 class="text-sm font-semibold text-gray-900 dark:text-white">{{ h.name }}</h3>
|
||||
<p class="text-xs text-gray-400 dark:text-gray-500 mt-0.5">
|
||||
{{ 'household.created_by' | translate }}: {{ h.created_by_email }}
|
||||
</p>
|
||||
</div>
|
||||
<!-- Invite button (founder or admin, only if not pending) -->
|
||||
@if (!isPending && canInvite(h) && inviteHouseholdId() !== h.id) {
|
||||
<button type="button" (click)="openInviteForm(h.id)"
|
||||
class="flex items-center gap-1.5 px-3 py-1.5 text-xs font-medium text-violet-700 dark:text-violet-400 border border-violet-300 dark:border-violet-700 rounded-lg hover:bg-violet-50 dark:hover:bg-violet-900/20">
|
||||
<svg class="w-3.5 h-3.5" fill="currentColor" viewBox="0 0 20 20">
|
||||
<path d="M8 9a3 3 0 100-6 3 3 0 000 6zM8 11a6 6 0 016 6H2a6 6 0 016-6zM16 7a1 1 0 10-2 0v1h-1a1 1 0 100 2h1v1a1 1 0 102 0v-1h1a1 1 0 100-2h-1V7z"/>
|
||||
</svg>
|
||||
{{ 'household.invite' | translate }}
|
||||
</button>
|
||||
}
|
||||
</div>
|
||||
|
||||
<!-- Pending invitation banner -->
|
||||
@if (isPending) {
|
||||
<div class="flex items-center justify-between bg-violet-50 dark:bg-violet-900/20 border border-violet-200 dark:border-violet-800 rounded-lg px-3 py-2.5 mb-3">
|
||||
<div>
|
||||
<p class="text-xs font-medium text-violet-800 dark:text-violet-300">{{ 'household.pending_invitation' | translate }}</p>
|
||||
<p class="text-xs text-violet-600 dark:text-violet-400 mt-0.5">
|
||||
{{ 'household.pending_from' | translate }}: {{ myM?.invited_by_email }}
|
||||
· {{ 'household.effective_from' | translate }} {{ myM?.effective_from_year }}
|
||||
</p>
|
||||
</div>
|
||||
<button type="button" (click)="acceptInvitation(h.id)"
|
||||
class="ml-3 px-3 py-1.5 text-xs font-medium text-white bg-violet-700 rounded-lg hover:bg-violet-800 shrink-0">
|
||||
{{ 'household.accept' | translate }}
|
||||
</button>
|
||||
</div>
|
||||
}
|
||||
|
||||
<!-- Member list (show only for non-pending) -->
|
||||
@if (!isPending) {
|
||||
<div class="divide-y divide-gray-100 dark:divide-gray-700 border border-gray-200 dark:border-gray-700 rounded-lg overflow-hidden">
|
||||
@for (m of activeMembers(h); track m.id) {
|
||||
<div class="flex items-center gap-3 px-3 py-2.5">
|
||||
<!-- Email -->
|
||||
<p class="flex-1 text-sm text-gray-900 dark:text-white truncate min-w-0">
|
||||
{{ m.user_email }}
|
||||
@if (m.user_email === userEmail) {
|
||||
<span class="ml-1.5 text-xs text-gray-400">({{ 'household.you' | translate }})</span>
|
||||
}
|
||||
@if (m.user_email === h.created_by_email) {
|
||||
<span class="ml-1.5 text-xs text-gray-400">({{ 'household.founder' | translate }})</span>
|
||||
}
|
||||
</p>
|
||||
<!-- Status badge -->
|
||||
<span class="shrink-0 text-xs font-medium rounded-full px-2 py-0.5"
|
||||
[class.bg-green-100]="m.status === 'active'"
|
||||
[class.text-green-700]="m.status === 'active'"
|
||||
[class.dark:bg-green-900/30]="m.status === 'active'"
|
||||
[class.dark:text-green-400]="m.status === 'active'"
|
||||
[class.bg-yellow-100]="m.status === 'pending'"
|
||||
[class.text-yellow-700]="m.status === 'pending'"
|
||||
[class.dark:bg-yellow-900/30]="m.status === 'pending'"
|
||||
[class.dark:text-yellow-400]="m.status === 'pending'">
|
||||
{{ 'household.status_' + m.status | translate }}
|
||||
</span>
|
||||
<!-- Role badge -->
|
||||
<span class="shrink-0 text-xs font-medium rounded-full px-2 py-0.5"
|
||||
[class.bg-violet-100]="m.role === 'admin'"
|
||||
[class.text-violet-700]="m.role === 'admin'"
|
||||
[class.dark:bg-violet-900/30]="m.role === 'admin'"
|
||||
[class.dark:text-violet-400]="m.role === 'admin'"
|
||||
[class.bg-gray-100]="m.role === 'member'"
|
||||
[class.text-gray-500]="m.role === 'member'"
|
||||
[class.dark:bg-gray-700]="m.role === 'member'"
|
||||
[class.dark:text-gray-400]="m.role === 'member'">
|
||||
{{ 'household.role_' + m.role | translate }}
|
||||
</span>
|
||||
<!-- Role toggle (only founder, not for self, only for active members) -->
|
||||
@if (amFounder && m.user_email !== userEmail && m.status === 'active') {
|
||||
<button type="button" (click)="toggleMemberRole(h.id, m)"
|
||||
class="shrink-0 p-1.5 text-gray-400 hover:text-violet-700 dark:hover:text-violet-400 rounded-md hover:bg-violet-50 dark:hover:bg-violet-900/20 transition-colors"
|
||||
[title]="m.role === 'admin' ? ('household.remove_admin' | translate) : ('household.make_admin' | translate)">
|
||||
<svg class="w-4 h-4" fill="none" viewBox="0 0 24 24">
|
||||
<path stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M15.75 5.25a3 3 0 0 1 3 3m3 0a6 6 0 0 1-7.029 5.912c-.563-.097-1.159.026-1.563.43L10.5 17.25H8.25v2.25H6v2.25H2.25v-2.818c0-.597.237-1.17.659-1.591l6.499-6.499c.404-.404.527-1 .43-1.563A6 6 0 0 1 21.75 8.25Z"/>
|
||||
</svg>
|
||||
</button>
|
||||
}
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
}
|
||||
|
||||
<!-- Pending invites (no account yet) -->
|
||||
@if (!isPending && (h.pending_invites?.length ?? 0) > 0) {
|
||||
<div class="mt-3 divide-y divide-gray-100 dark:divide-gray-700 border border-gray-200 dark:border-gray-700 rounded-lg overflow-hidden">
|
||||
@for (pi of h.pending_invites; track pi.id) {
|
||||
<div class="flex items-center gap-3 px-3 py-2.5">
|
||||
<p class="flex-1 text-sm text-gray-400 dark:text-gray-500 truncate min-w-0 italic">
|
||||
{{ pi.invited_email }}
|
||||
</p>
|
||||
<span class="shrink-0 text-xs font-medium bg-gray-100 dark:bg-gray-700 text-gray-500 dark:text-gray-400 rounded-full px-2 py-0.5">
|
||||
{{ 'household.status_unregistered' | translate }}
|
||||
</span>
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
}
|
||||
|
||||
<!-- Invite form -->
|
||||
@if (inviteHouseholdId() === h.id) {
|
||||
<div class="mt-3 p-3 bg-gray-50 dark:bg-gray-700/30 rounded-lg border border-gray-200 dark:border-gray-700">
|
||||
<label class="block text-xs font-medium text-gray-700 dark:text-gray-300 mb-1">
|
||||
{{ 'household.invite_email' | translate }}
|
||||
</label>
|
||||
<div class="flex gap-2">
|
||||
<input type="email" [(ngModel)]="inviteEmail"
|
||||
[placeholder]="'household.invite_placeholder' | translate"
|
||||
class="flex-1 min-w-0 px-3 py-2 text-sm bg-white dark:bg-gray-800 border border-gray-300 dark:border-gray-600 rounded-lg focus:ring-2 focus:ring-violet-500 focus:border-violet-500 text-gray-900 dark:text-white placeholder-gray-400">
|
||||
<button type="button" (click)="sendInvite(h.id)" [disabled]="inviteSaving"
|
||||
class="px-3 py-2 text-sm font-medium text-white bg-violet-700 rounded-lg hover:bg-violet-800 disabled:opacity-60 shrink-0">
|
||||
{{ inviteSaving ? '…' : ('household.send' | translate) }}
|
||||
</button>
|
||||
<button type="button" (click)="inviteHouseholdId.set(null)"
|
||||
class="px-3 py-2 text-sm font-medium text-gray-700 dark:text-gray-200 bg-white dark:bg-gray-800 border border-gray-300 dark:border-gray-600 rounded-lg hover:bg-gray-50 dark:hover:bg-gray-700 shrink-0">
|
||||
{{ 'common.cancel' | translate }}
|
||||
</button>
|
||||
</div>
|
||||
@if (inviteError) {
|
||||
<p class="mt-1.5 text-xs text-red-600 dark:text-red-400">
|
||||
{{ 'household.error_' + inviteError | translate }}
|
||||
</p>
|
||||
}
|
||||
</div>
|
||||
}
|
||||
|
||||
<!-- Leave button (active members who are not the founder) -->
|
||||
@if (!amFounder && myM?.status === 'active') {
|
||||
<div class="mt-3 flex justify-end">
|
||||
<button type="button" (click)="openLeaveModal(h.id)"
|
||||
class="text-xs font-medium text-red-600 dark:text-red-400 hover:text-red-800 dark:hover:text-red-300">
|
||||
{{ 'household.leave' | translate }}
|
||||
</button>
|
||||
</div>
|
||||
}
|
||||
|
||||
</div>
|
||||
}
|
||||
|
||||
</div>
|
||||
|
||||
</div>
|
||||
|
||||
<!-- New Year Modal -->
|
||||
@if (showNewYearModal()) {
|
||||
<div class="fixed inset-0 z-50 flex items-center justify-center bg-black/50 backdrop-blur-sm p-4">
|
||||
<div class="bg-white dark:bg-gray-800 rounded-xl shadow-xl p-6 w-full max-w-sm">
|
||||
|
||||
<h2 class="text-lg font-semibold text-gray-900 dark:text-white mb-2">
|
||||
{{ 'financial_year.confirm_new_year' | translate: { year: nextYear() } }}
|
||||
</h2>
|
||||
|
||||
<!-- Haushalt-Auswahl -->
|
||||
@if (activeHouseholds().length > 0) {
|
||||
<div class="mb-4">
|
||||
<label class="block text-xs font-medium text-gray-700 dark:text-gray-300 mb-1.5">
|
||||
{{ 'financial_year.new_year_owner' | translate }}
|
||||
</label>
|
||||
<div class="flex flex-col gap-1.5">
|
||||
<label class="flex items-center gap-2 cursor-pointer">
|
||||
<input type="radio" name="newYearOwner" [value]="null" [checked]="newYearHouseholdId() === null" (change)="newYearHouseholdId.set(null)"
|
||||
class="text-violet-600 focus:ring-violet-500">
|
||||
<span class="text-sm text-gray-700 dark:text-gray-300">{{ 'financial_year.owner_personal' | translate }}</span>
|
||||
</label>
|
||||
@for (h of activeHouseholds(); track h.id) {
|
||||
<label class="flex items-center gap-2 cursor-pointer">
|
||||
<input type="radio" name="newYearOwner" [value]="h.id" [checked]="newYearHouseholdId() === h.id" (change)="newYearHouseholdId.set(h.id)"
|
||||
class="text-violet-600 focus:ring-violet-500">
|
||||
<span class="text-sm text-gray-700 dark:text-gray-300">{{ h.name }}</span>
|
||||
</label>
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
|
||||
@if (years().length > 0) {
|
||||
<p class="text-sm text-gray-500 dark:text-gray-400 mb-5">
|
||||
{{ 'financial_year.confirm_copy' | translate: { source: selectedYear() } }}
|
||||
</p>
|
||||
<div class="flex flex-col gap-2">
|
||||
<button type="button" (click)="createYear(true)"
|
||||
class="w-full px-4 py-2.5 text-sm font-medium text-white bg-violet-700 rounded-lg hover:bg-violet-800">
|
||||
{{ 'financial_year.copy_yes' | translate }}
|
||||
</button>
|
||||
<button type="button" (click)="createYear(false)"
|
||||
class="w-full px-4 py-2.5 text-sm font-medium text-gray-700 dark:text-gray-200 bg-white dark:bg-gray-700 border border-gray-300 dark:border-gray-600 rounded-lg hover:bg-gray-50 dark:hover:bg-gray-600">
|
||||
{{ 'financial_year.copy_no' | translate }}
|
||||
</button>
|
||||
<button type="button" (click)="showNewYearModal.set(false)"
|
||||
class="w-full px-4 py-2 text-sm text-gray-400 hover:text-gray-600 dark:hover:text-gray-300">
|
||||
{{ 'common.cancel' | translate }}
|
||||
</button>
|
||||
</div>
|
||||
} @else {
|
||||
<p class="text-sm text-gray-500 dark:text-gray-400 mb-5">
|
||||
{{ 'financial_year.first_year_hint' | translate: { year: nextYear() } }}
|
||||
</p>
|
||||
<div class="flex flex-col gap-2">
|
||||
<button type="button" (click)="createYear(false)"
|
||||
class="w-full px-4 py-2.5 text-sm font-medium text-white bg-violet-700 rounded-lg hover:bg-violet-800">
|
||||
{{ 'financial_year.create_year' | translate }}
|
||||
</button>
|
||||
<button type="button" (click)="showNewYearModal.set(false)"
|
||||
class="w-full px-4 py-2 text-sm text-gray-400 hover:text-gray-600 dark:hover:text-gray-300">
|
||||
{{ 'common.cancel' | translate }}
|
||||
</button>
|
||||
</div>
|
||||
}
|
||||
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
|
||||
<!-- Leave Household Modal -->
|
||||
@if (showLeaveModal()) {
|
||||
<div class="fixed inset-0 z-50 flex items-center justify-center bg-black/50 backdrop-blur-sm p-4">
|
||||
<div class="bg-white dark:bg-gray-800 rounded-xl shadow-xl p-6 w-full max-w-sm">
|
||||
<h2 class="text-lg font-semibold text-gray-900 dark:text-white mb-2">
|
||||
{{ 'household.leave_confirm_title' | translate }}
|
||||
</h2>
|
||||
<p class="text-sm text-gray-500 dark:text-gray-400 mb-5">
|
||||
{{ 'household.leave_confirm_text' | translate }}
|
||||
</p>
|
||||
<div class="flex gap-3">
|
||||
<button type="button" (click)="showLeaveModal.set(false)"
|
||||
class="flex-1 px-4 py-2 text-sm font-medium text-gray-700 dark:text-gray-200 bg-white dark:bg-gray-700 border border-gray-300 dark:border-gray-600 rounded-lg hover:bg-gray-50 dark:hover:bg-gray-600">
|
||||
{{ 'common.cancel' | translate }}
|
||||
</button>
|
||||
<button type="button" (click)="confirmLeave()"
|
||||
class="flex-1 px-4 py-2 text-sm font-medium text-white bg-red-600 rounded-lg hover:bg-red-700">
|
||||
{{ 'household.leave' | translate }}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
|
||||
<!-- Delete Confirm Modal -->
|
||||
@if (showDeleteModal()) {
|
||||
<div class="fixed inset-0 z-50 flex items-center justify-center bg-black/50 backdrop-blur-sm p-4">
|
||||
<div class="bg-white dark:bg-gray-800 rounded-xl shadow-xl p-6 w-full max-w-sm">
|
||||
<h2 class="text-lg font-semibold text-gray-900 dark:text-white mb-2">
|
||||
{{ 'common.delete_confirm_title' | translate }}
|
||||
</h2>
|
||||
<p class="text-sm text-gray-500 dark:text-gray-400 mb-5">
|
||||
{{ 'common.delete_confirm_text' | translate }}
|
||||
</p>
|
||||
<div class="flex gap-3">
|
||||
<button type="button" (click)="showDeleteModal.set(false)"
|
||||
class="flex-1 px-4 py-2 text-sm font-medium text-gray-700 dark:text-gray-200 bg-white dark:bg-gray-700 border border-gray-300 dark:border-gray-600 rounded-lg hover:bg-gray-50 dark:hover:bg-gray-600">
|
||||
{{ 'common.cancel' | translate }}
|
||||
</button>
|
||||
<button type="button" (click)="executeDelete()"
|
||||
class="flex-1 px-4 py-2 text-sm font-medium text-white bg-red-600 rounded-lg hover:bg-red-700">
|
||||
{{ 'common.delete' | translate }}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
@@ -0,0 +1,447 @@
|
||||
import { Component, OnInit, signal, computed } from '@angular/core';
|
||||
import { CommonModule } from '@angular/common';
|
||||
import { FormsModule } from '@angular/forms';
|
||||
import { TranslateModule } from '@ngx-translate/core';
|
||||
import { FinancialYearService, FinancialYear, YearlyIncome, YearlyBudgetItem, Household, HouseholdMembership } from '../services/financial-year';
|
||||
import { ApiService } from '../services/api';
|
||||
|
||||
type Tab = 'incomes' | 'budget_items';
|
||||
|
||||
@Component({
|
||||
selector: 'app-financial-year',
|
||||
standalone: true,
|
||||
imports: [CommonModule, FormsModule, TranslateModule],
|
||||
templateUrl: './financial-year.html',
|
||||
styleUrl: './financial-year.css',
|
||||
})
|
||||
export class FinancialYearComponent implements OnInit {
|
||||
years = signal<FinancialYear[]>([]);
|
||||
currentFY = signal<FinancialYear | null>(null);
|
||||
selectedYear = signal<number>(new Date().getFullYear());
|
||||
activeTab = signal<Tab>('incomes');
|
||||
loading = signal(true);
|
||||
yearDropdownOpen = signal(false);
|
||||
|
||||
// New year modal
|
||||
showNewYearModal = signal(false);
|
||||
newYearHouseholdId = signal<number | null>(null);
|
||||
|
||||
// Add/edit form
|
||||
showForm = signal(false);
|
||||
editingId: number | null = null;
|
||||
formName = '';
|
||||
formAmount = 0;
|
||||
formNotes = '';
|
||||
formActive = true;
|
||||
formError = '';
|
||||
formSaving = false;
|
||||
|
||||
// Delete modal
|
||||
showDeleteModal = signal(false);
|
||||
deleteId: number | null = null;
|
||||
|
||||
// Revenue accounts (income tab)
|
||||
revenueAccounts = signal<any[]>([]);
|
||||
|
||||
// Budgets (fixed costs)
|
||||
budgets = signal<any[]>([]);
|
||||
|
||||
// Expenses (actual spending)
|
||||
expenses = signal<any[]>([]);
|
||||
|
||||
// Household state
|
||||
households = signal<Household[]>([]);
|
||||
userEmail = '';
|
||||
|
||||
// Create household form
|
||||
showCreateHouseholdForm = signal(false);
|
||||
householdName = '';
|
||||
householdSaving = false;
|
||||
householdError = '';
|
||||
|
||||
// Invite form
|
||||
inviteHouseholdId = signal<number | null>(null);
|
||||
inviteEmail = '';
|
||||
inviteError = '';
|
||||
inviteSaving = false;
|
||||
|
||||
// Leave confirm modal
|
||||
showLeaveModal = signal(false);
|
||||
leaveHouseholdId: number | null = null;
|
||||
|
||||
constructor(private fyService: FinancialYearService, private api: ApiService) {}
|
||||
|
||||
ngOnInit(): void {
|
||||
this.loadAll();
|
||||
this.loadHouseholds();
|
||||
this.loadRevenueAccounts();
|
||||
this.loadBudgets();
|
||||
this.api.getExpenses().subscribe({ next: (d) => this.expenses.set(d) });
|
||||
this.api.getProfile().subscribe({ next: (p) => { this.userEmail = p.email || ''; } });
|
||||
}
|
||||
|
||||
// --- Computed ---
|
||||
|
||||
nextYear = computed(() => {
|
||||
const ys = this.years();
|
||||
if (ys.length === 0) return new Date().getFullYear();
|
||||
return Math.max(...ys.map(y => y.year)) + 1;
|
||||
});
|
||||
|
||||
canCreateNewYear = computed(() => {
|
||||
const next = this.nextYear();
|
||||
const maxAllowed = new Date().getFullYear() + 1;
|
||||
return next <= maxAllowed && !this.years().some(y => y.year === next);
|
||||
});
|
||||
|
||||
totalIncome = computed(() => Number(this.currentFY()?.total_income ?? 0));
|
||||
totalFixedCosts = computed(() => Number(this.currentFY()?.total_fixed_costs ?? 0));
|
||||
|
||||
disposable = computed(() => this.totalIncome() - this.totalFixedCosts());
|
||||
|
||||
savingsRate = computed(() => {
|
||||
const i = this.totalIncome();
|
||||
return i > 0 ? Math.round((this.disposable() / i) * 100) : 0;
|
||||
});
|
||||
|
||||
incomes = computed(() => this.currentFY()?.incomes ?? []);
|
||||
budgetItems = computed(() => this.currentFY()?.budget_items ?? []);
|
||||
|
||||
totalAnnualIncome = computed(() =>
|
||||
this.revenueAccounts().reduce((sum, a) => sum + parseFloat(a.balance) * (a.salary_months ?? 12), 0)
|
||||
);
|
||||
|
||||
totalMonthlyBudget = computed(() =>
|
||||
this.budgets().filter((b) => b.active).reduce((sum, b) => sum + parseFloat(b.amount), 0)
|
||||
);
|
||||
|
||||
totalAnnualBudget = computed(() => this.totalMonthlyBudget() * 12);
|
||||
|
||||
totalYearExpenses = computed(() =>
|
||||
this.expenses()
|
||||
.filter((e) => new Date(e.date).getFullYear() === this.selectedYear())
|
||||
.reduce((sum, e) => sum + parseFloat(e.amount), 0)
|
||||
);
|
||||
|
||||
avgMonthlyExpenses = computed(() => {
|
||||
const now = new Date();
|
||||
const year = this.selectedYear();
|
||||
const months = year < now.getFullYear() ? 12 : now.getMonth() + 1;
|
||||
return months > 0 ? this.totalYearExpenses() / months : 0;
|
||||
});
|
||||
|
||||
// --- Data loading ---
|
||||
|
||||
private loadAll(): void {
|
||||
this.loading.set(true);
|
||||
this.fyService.list().subscribe({
|
||||
next: (ys) => {
|
||||
// ys is ordered by -year (descending from backend)
|
||||
this.years.set(ys);
|
||||
if (ys.length > 0) {
|
||||
const target =
|
||||
ys.find(y => y.year === this.selectedYear()) ??
|
||||
ys.find(y => y.is_active) ??
|
||||
ys[0];
|
||||
this.selectedYear.set(target.year);
|
||||
this.currentFY.set(target);
|
||||
}
|
||||
this.loading.set(false);
|
||||
},
|
||||
error: () => this.loading.set(false),
|
||||
});
|
||||
}
|
||||
|
||||
private reloadCurrentYear(): void {
|
||||
const year = this.selectedYear();
|
||||
this.fyService.get(year).subscribe({
|
||||
next: (fy) => {
|
||||
setTimeout(() => {
|
||||
this.currentFY.set(fy);
|
||||
// Keep the years list in sync for totals shown in the sidebar/year selector
|
||||
this.years.update(ys => ys.map(y => (y.year === year ? { ...y, ...fy } : y)));
|
||||
});
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
// --- Year selection ---
|
||||
|
||||
selectYear(year: number): void {
|
||||
this.yearDropdownOpen.set(false);
|
||||
this.selectedYear.set(year);
|
||||
this.closeForm();
|
||||
const cached = this.years().find(y => y.year === year);
|
||||
if (cached) {
|
||||
this.currentFY.set(cached);
|
||||
this.loadRevenueAccounts();
|
||||
}
|
||||
}
|
||||
|
||||
// --- Year creation ---
|
||||
|
||||
openNewYearModal(): void {
|
||||
this.newYearHouseholdId.set(null);
|
||||
this.showNewYearModal.set(true);
|
||||
}
|
||||
|
||||
activeHouseholds(): Household[] {
|
||||
return this.households().filter(h =>
|
||||
h.memberships.some(m => m.user_email === this.userEmail && m.status === 'active')
|
||||
);
|
||||
}
|
||||
|
||||
createYear(copy: boolean): void {
|
||||
const newYear = this.nextYear();
|
||||
const sourceYear = copy ? Math.max(...this.years().map(y => y.year)) : null;
|
||||
const householdId = this.newYearHouseholdId();
|
||||
|
||||
const payload: { year: number; household_id?: number } = { year: newYear };
|
||||
if (householdId) payload.household_id = householdId;
|
||||
|
||||
this.fyService.create(payload).subscribe({
|
||||
next: () => {
|
||||
if (sourceYear !== null) {
|
||||
this.fyService.copyFrom(newYear, sourceYear).subscribe({
|
||||
next: () => {
|
||||
this.showNewYearModal.set(false);
|
||||
this.selectedYear.set(newYear);
|
||||
this.loadAll();
|
||||
},
|
||||
});
|
||||
} else {
|
||||
this.showNewYearModal.set(false);
|
||||
this.selectedYear.set(newYear);
|
||||
this.loadAll();
|
||||
}
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
// --- Tab ---
|
||||
|
||||
selectTab(tab: Tab): void {
|
||||
this.activeTab.set(tab);
|
||||
this.closeForm();
|
||||
}
|
||||
|
||||
// --- Form ---
|
||||
|
||||
openAddForm(): void {
|
||||
this.editingId = null;
|
||||
this.formName = '';
|
||||
this.formAmount = 0;
|
||||
this.formNotes = '';
|
||||
this.formActive = true;
|
||||
this.formError = '';
|
||||
this.formSaving = false;
|
||||
this.showForm.set(true);
|
||||
}
|
||||
|
||||
openEditForm(item: YearlyIncome | YearlyBudgetItem): void {
|
||||
this.editingId = item.id;
|
||||
this.formName = item.name;
|
||||
this.formAmount = Number(item.amount);
|
||||
this.formNotes = item.notes;
|
||||
this.formActive = item.active;
|
||||
this.formError = '';
|
||||
this.formSaving = false;
|
||||
this.showForm.set(true);
|
||||
}
|
||||
|
||||
closeForm(): void {
|
||||
this.showForm.set(false);
|
||||
this.editingId = null;
|
||||
}
|
||||
|
||||
saveForm(): void {
|
||||
const name = this.formName.trim();
|
||||
if (!name) { this.formError = 'name_required'; return; }
|
||||
const amount = this.formAmount;
|
||||
if (!amount || amount <= 0) { this.formError = 'amount_invalid'; return; }
|
||||
|
||||
const year = this.selectedYear();
|
||||
const data = { name, amount, notes: this.formNotes, active: this.formActive };
|
||||
const id = this.editingId;
|
||||
const tab = this.activeTab();
|
||||
this.formSaving = true;
|
||||
|
||||
let obs;
|
||||
if (tab === 'incomes') {
|
||||
obs = id
|
||||
? this.fyService.updateIncome(year, id, data)
|
||||
: this.fyService.createIncome(year, { ...data, member: null });
|
||||
} else {
|
||||
obs = id
|
||||
? this.fyService.updateBudgetItem(year, id, data)
|
||||
: this.fyService.createBudgetItem(year, data);
|
||||
}
|
||||
|
||||
obs.subscribe({
|
||||
next: () => {
|
||||
this.formSaving = false;
|
||||
this.closeForm();
|
||||
this.reloadCurrentYear();
|
||||
},
|
||||
error: () => {
|
||||
this.formSaving = false;
|
||||
this.formError = 'save_failed';
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
// --- Delete ---
|
||||
|
||||
confirmDelete(id: number): void {
|
||||
this.deleteId = id;
|
||||
this.showDeleteModal.set(true);
|
||||
}
|
||||
|
||||
executeDelete(): void {
|
||||
const id = this.deleteId;
|
||||
if (!id) return;
|
||||
const year = this.selectedYear();
|
||||
const tab = this.activeTab();
|
||||
const obs = tab === 'incomes'
|
||||
? this.fyService.deleteIncome(year, id)
|
||||
: this.fyService.deleteBudgetItem(year, id);
|
||||
obs.subscribe({
|
||||
next: () => {
|
||||
this.showDeleteModal.set(false);
|
||||
this.reloadCurrentYear();
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
// --- Household helpers ---
|
||||
|
||||
myMembership(h: Household): HouseholdMembership | undefined {
|
||||
return h.memberships.find(m => m.user_email === this.userEmail);
|
||||
}
|
||||
|
||||
isFounder(h: Household): boolean {
|
||||
return h.created_by_email === this.userEmail;
|
||||
}
|
||||
|
||||
canInvite(h: Household): boolean {
|
||||
const m = this.myMembership(h);
|
||||
return this.isFounder(h) || (m?.status === 'active' && m?.role === 'admin');
|
||||
}
|
||||
|
||||
activeMembers(h: Household): HouseholdMembership[] {
|
||||
return h.memberships.filter(m => m.status !== 'left');
|
||||
}
|
||||
|
||||
// --- Household CRUD ---
|
||||
|
||||
private loadBudgets(): void {
|
||||
this.api.getBudgets().subscribe({ next: (bs) => this.budgets.set(bs) });
|
||||
}
|
||||
|
||||
private loadRevenueAccounts(): void {
|
||||
const fy = this.currentFY();
|
||||
if (fy?.owner_type === 'household' && fy.household_id) {
|
||||
this.fyService.getHouseholdRevenueAccounts(fy.household_id).subscribe({
|
||||
next: (accounts) => this.revenueAccounts.set(accounts),
|
||||
});
|
||||
} else {
|
||||
this.api.getAccounts().subscribe({
|
||||
next: (accounts) => this.revenueAccounts.set(accounts.filter((a) => a.account_type === 'revenue')),
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
toggleSalaryMonths(account: any): void {
|
||||
const newMonths = account.salary_months === 13 ? 12 : 13;
|
||||
this.api.patchAccount(account.id, { salary_months: newMonths }).subscribe({
|
||||
next: (updated) => {
|
||||
this.revenueAccounts.update((accounts) =>
|
||||
accounts.map((a) => (a.id === account.id ? { ...a, salary_months: updated.salary_months } : a))
|
||||
);
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
private loadHouseholds(): void {
|
||||
this.fyService.getHouseholds().subscribe({ next: (hs) => this.households.set(hs) });
|
||||
}
|
||||
|
||||
createHousehold(): void {
|
||||
const name = this.householdName.trim();
|
||||
if (!name) { this.householdError = 'name_required'; return; }
|
||||
this.householdSaving = true;
|
||||
this.fyService.createHousehold(name).subscribe({
|
||||
next: () => {
|
||||
this.householdSaving = false;
|
||||
this.showCreateHouseholdForm.set(false);
|
||||
this.householdName = '';
|
||||
this.householdError = '';
|
||||
this.loadHouseholds();
|
||||
},
|
||||
error: () => { this.householdSaving = false; this.householdError = 'failed'; },
|
||||
});
|
||||
}
|
||||
|
||||
openInviteForm(householdId: number): void {
|
||||
this.inviteHouseholdId.set(householdId);
|
||||
this.inviteEmail = '';
|
||||
this.inviteError = '';
|
||||
this.inviteSaving = false;
|
||||
}
|
||||
|
||||
sendInvite(pk: number): void {
|
||||
const email = this.inviteEmail.trim();
|
||||
if (!email) { this.inviteError = 'email_required'; return; }
|
||||
this.inviteSaving = true;
|
||||
this.fyService.inviteMember(pk, email).subscribe({
|
||||
next: () => {
|
||||
this.inviteSaving = false;
|
||||
this.inviteHouseholdId.set(null);
|
||||
this.inviteEmail = '';
|
||||
this.loadHouseholds();
|
||||
},
|
||||
error: (err) => {
|
||||
this.inviteSaving = false;
|
||||
const detail = err?.error?.detail ?? '';
|
||||
if (detail.includes('email')) this.inviteError = 'not_found';
|
||||
else if (detail.includes('already')) this.inviteError = 'already_member';
|
||||
else this.inviteError = 'failed';
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
acceptInvitation(pk: number): void {
|
||||
this.fyService.acceptInvitation(pk).subscribe({
|
||||
next: () => this.loadHouseholds(),
|
||||
});
|
||||
}
|
||||
|
||||
openLeaveModal(householdId: number): void {
|
||||
this.leaveHouseholdId = householdId;
|
||||
this.showLeaveModal.set(true);
|
||||
}
|
||||
|
||||
confirmLeave(): void {
|
||||
if (!this.leaveHouseholdId) return;
|
||||
this.fyService.leaveHousehold(this.leaveHouseholdId).subscribe({
|
||||
next: () => { this.showLeaveModal.set(false); this.loadHouseholds(); },
|
||||
});
|
||||
}
|
||||
|
||||
toggleMemberRole(pk: number, membership: HouseholdMembership): void {
|
||||
const newRole = membership.role === 'admin' ? 'member' : 'admin';
|
||||
this.fyService.setMemberRole(pk, membership.id, newRole).subscribe({
|
||||
next: () => this.loadHouseholds(),
|
||||
});
|
||||
}
|
||||
|
||||
// --- Formatting ---
|
||||
|
||||
formatChf(val: number): string {
|
||||
return new Intl.NumberFormat('de-CH', { minimumFractionDigits: 0, maximumFractionDigits: 0 }).format(val);
|
||||
}
|
||||
|
||||
perMonth(val: number): number {
|
||||
return Math.round(Number(val) / 12);
|
||||
}
|
||||
}
|
||||
@@ -106,6 +106,26 @@
|
||||
</a>
|
||||
</li>
|
||||
|
||||
<!-- Financial Year -->
|
||||
<li>
|
||||
<a routerLink="/financial-year" routerLinkActive="!bg-violet-50 !text-violet-700 dark:!bg-violet-900/20 dark:!text-violet-300"
|
||||
(click)="sidebarService.closeMobile()"
|
||||
[class]="sidebarService.collapsed() ? 'justify-center relative' : ''"
|
||||
class="flex items-center p-2 text-sm font-normal text-gray-900 rounded-lg dark:text-white hover:bg-gray-100 dark:hover:bg-gray-700 group">
|
||||
<svg class="w-6 h-6 flex-shrink-0 text-gray-400 group-hover:text-gray-900 dark:group-hover:text-white" fill="currentColor" viewBox="0 0 20 20">
|
||||
<path d="M2 10a1 1 0 011-1h2a1 1 0 011 1v5a1 1 0 01-1 1H3a1 1 0 01-1-1v-5zM8 6a1 1 0 011-1h2a1 1 0 011 1v9a1 1 0 01-1 1H9a1 1 0 01-1-1V6zM14 4a1 1 0 011-1h2a1 1 0 011 1v11a1 1 0 01-1 1h-2a1 1 0 01-1-1V4z"/>
|
||||
</svg>
|
||||
@if (!sidebarService.collapsed()) {
|
||||
<span class="ml-3 whitespace-nowrap">{{ 'sidebar.financial_year' | translate }}</span>
|
||||
}
|
||||
@if (sidebarService.collapsed()) {
|
||||
<span class="pointer-events-none absolute left-full ml-3 top-1/2 -translate-y-1/2 px-2 py-1 text-xs font-medium text-white bg-gray-900 dark:bg-gray-700 rounded whitespace-nowrap opacity-0 group-hover:opacity-100 transition-opacity duration-150 z-50">
|
||||
{{ 'sidebar.financial_year' | translate }}
|
||||
</span>
|
||||
}
|
||||
</a>
|
||||
</li>
|
||||
|
||||
<!-- Accounts -->
|
||||
<li class="relative">
|
||||
@if (sidebarService.collapsed()) {
|
||||
|
||||
@@ -23,6 +23,10 @@ export class ApiService {
|
||||
return this.http.put(`${this.baseUrl}/accounts/${id}/`, account);
|
||||
}
|
||||
|
||||
patchAccount(id: number, data: Partial<{name: string, balance: number, account_type: string, salary_months: number}>): Observable<any> {
|
||||
return this.http.patch(`${this.baseUrl}/accounts/${id}/`, data);
|
||||
}
|
||||
|
||||
deleteAccount(id: number): Observable<any> {
|
||||
return this.http.delete(`${this.baseUrl}/accounts/${id}/`);
|
||||
}
|
||||
|
||||
@@ -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/`);
|
||||
}
|
||||
}
|
||||
@@ -142,6 +142,7 @@
|
||||
"fixed_costs": "Fixkosten",
|
||||
"expenses": "Ausgaben",
|
||||
"calendar": "Kalender",
|
||||
"financial_year": "Jahresplanung",
|
||||
"accounts": "Konten",
|
||||
"revenue_accounts": "Einnahmekonten",
|
||||
"transactions": "Transaktionen"
|
||||
@@ -345,6 +346,74 @@
|
||||
"ZG": "Zug",
|
||||
"ZH": "Zürich"
|
||||
},
|
||||
"household": {
|
||||
"title": "Haushalt",
|
||||
"none": "Du bist noch in keinem Haushalt.",
|
||||
"none_hint": "Gründe einen gemeinsamen Haushalt oder warte auf eine Einladung.",
|
||||
"create": "Haushalt gründen",
|
||||
"create_title": "Neuer Haushalt",
|
||||
"label_name": "Name des Haushalts",
|
||||
"placeholder_name": "z.B. Familie Müller",
|
||||
"created_by": "Gegründet von",
|
||||
"members": "Mitglieder",
|
||||
"invite": "Einladen",
|
||||
"invite_email": "E-Mail-Adresse",
|
||||
"invite_placeholder": "user@beispiel.ch",
|
||||
"send": "Einladung senden",
|
||||
"status_active": "Aktiv",
|
||||
"status_pending": "Ausstehend",
|
||||
"status_left": "Ausgetreten",
|
||||
"role_admin": "Admin",
|
||||
"role_member": "Mitglied",
|
||||
"make_admin": "Admin machen",
|
||||
"remove_admin": "Admin entfernen",
|
||||
"pending_invitation": "Einladung erhalten",
|
||||
"pending_from": "Eingeladen von",
|
||||
"effective_from": "Ab Jahr",
|
||||
"accept": "Annehmen",
|
||||
"leave": "Haushalt verlassen",
|
||||
"leave_confirm_title": "Haushalt verlassen?",
|
||||
"leave_confirm_text": "Du verlässt den Haushalt per Ende des laufenden Jahres. Vergangene Haushaltsjahre bleiben für dich lesbar.",
|
||||
"you": "du",
|
||||
"founder": "Gründer",
|
||||
"error_name_required": "Name ist erforderlich.",
|
||||
"error_failed": "Vorgang fehlgeschlagen.",
|
||||
"error_email_required": "E-Mail-Adresse ist erforderlich.",
|
||||
"error_not_found": "Kein Benutzer mit dieser E-Mail gefunden.",
|
||||
"error_already_member": "Benutzer ist bereits Mitglied oder hat eine ausstehende Einladung."
|
||||
},
|
||||
"financial_year": {
|
||||
"title": "Jahresplanung",
|
||||
"owner_personal": "Persönlich",
|
||||
"owner_household": "Haushalt",
|
||||
"new_year": "Neues Jahr starten",
|
||||
"no_years": "Noch kein Finanzjahr erstellt.",
|
||||
"start_first_year": "Erstes Jahr starten",
|
||||
"tab_incomes": "Einnahmen",
|
||||
"tab_budget_items": "Fixkosten",
|
||||
"add_income": "Einnahme hinzufügen",
|
||||
"add_budget_item": "Fixkosten hinzufügen",
|
||||
"label_name": "Bezeichnung",
|
||||
"label_amount": "Betrag (CHF / Jahr)",
|
||||
"label_notes": "Notizen",
|
||||
"label_active": "Aktiv",
|
||||
"total_income": "Gesamteinnahmen",
|
||||
"total_fixed_costs": "Fixkosten total",
|
||||
"disposable": "Verfügbar",
|
||||
"savings_rate": "Sparquote",
|
||||
"per_month": "Monat",
|
||||
"no_incomes": "Noch keine Einnahmen erfasst.",
|
||||
"no_budget_items": "Noch keine Fixkosten erfasst.",
|
||||
"confirm_new_year": "Jahr {{ year }} starten?",
|
||||
"confirm_copy": "Soll das neue Jahr mit den Daten aus {{ source }} vorausgefüllt werden?",
|
||||
"first_year_hint": "Das Finanzjahr {{ year }} wird neu erstellt.",
|
||||
"create_year": "Jahr erstellen",
|
||||
"copy_yes": "Ja, Daten übernehmen",
|
||||
"copy_no": "Leer starten",
|
||||
"error_name_required": "Bezeichnung ist erforderlich.",
|
||||
"error_amount_invalid": "Bitte einen gültigen Betrag eingeben.",
|
||||
"error_save_failed": "Speichern fehlgeschlagen."
|
||||
},
|
||||
"profile": {
|
||||
"title": "Profil",
|
||||
"subtitle": "Persönliche Informationen und Einstellungen verwalten",
|
||||
|
||||
@@ -142,6 +142,7 @@
|
||||
"fixed_costs": "Fixed Costs",
|
||||
"expenses": "Expenses",
|
||||
"calendar": "Calendar",
|
||||
"financial_year": "Annual Planning",
|
||||
"accounts": "Accounts",
|
||||
"revenue_accounts": "Revenue Accounts",
|
||||
"transactions": "Transactions"
|
||||
@@ -345,6 +346,74 @@
|
||||
"ZG": "Zug",
|
||||
"ZH": "Zuerich"
|
||||
},
|
||||
"household": {
|
||||
"title": "Household",
|
||||
"none": "You are not part of any household yet.",
|
||||
"none_hint": "Create a shared household or wait for an invitation.",
|
||||
"create": "Create Household",
|
||||
"create_title": "New Household",
|
||||
"label_name": "Household Name",
|
||||
"placeholder_name": "e.g. Smith Family",
|
||||
"created_by": "Created by",
|
||||
"members": "Members",
|
||||
"invite": "Invite",
|
||||
"invite_email": "Email Address",
|
||||
"invite_placeholder": "user@example.com",
|
||||
"send": "Send Invitation",
|
||||
"status_active": "Active",
|
||||
"status_pending": "Pending",
|
||||
"status_left": "Left",
|
||||
"role_admin": "Admin",
|
||||
"role_member": "Member",
|
||||
"make_admin": "Make admin",
|
||||
"remove_admin": "Remove admin",
|
||||
"pending_invitation": "Invitation received",
|
||||
"pending_from": "Invited by",
|
||||
"effective_from": "From year",
|
||||
"accept": "Accept",
|
||||
"leave": "Leave Household",
|
||||
"leave_confirm_title": "Leave Household?",
|
||||
"leave_confirm_text": "You will leave the household at the end of the current year. Past household years remain readable for you.",
|
||||
"you": "you",
|
||||
"founder": "Founder",
|
||||
"error_name_required": "Name is required.",
|
||||
"error_failed": "Operation failed.",
|
||||
"error_email_required": "Email address is required.",
|
||||
"error_not_found": "No user found with this email.",
|
||||
"error_already_member": "User is already a member or has a pending invitation."
|
||||
},
|
||||
"financial_year": {
|
||||
"title": "Annual Planning",
|
||||
"owner_personal": "Personal",
|
||||
"owner_household": "Household",
|
||||
"new_year": "Start New Year",
|
||||
"no_years": "No financial year created yet.",
|
||||
"start_first_year": "Start First Year",
|
||||
"tab_incomes": "Income",
|
||||
"tab_budget_items": "Fixed Costs",
|
||||
"add_income": "Add Income",
|
||||
"add_budget_item": "Add Fixed Cost",
|
||||
"label_name": "Name",
|
||||
"label_amount": "Amount (CHF / Year)",
|
||||
"label_notes": "Notes",
|
||||
"label_active": "Active",
|
||||
"total_income": "Total Income",
|
||||
"total_fixed_costs": "Total Fixed Costs",
|
||||
"disposable": "Disposable",
|
||||
"savings_rate": "Savings Rate",
|
||||
"per_month": "Month",
|
||||
"no_incomes": "No income entries yet.",
|
||||
"no_budget_items": "No fixed cost entries yet.",
|
||||
"confirm_new_year": "Start Year {{ year }}?",
|
||||
"confirm_copy": "Should the new year be pre-filled with data from {{ source }}?",
|
||||
"first_year_hint": "Financial year {{ year }} will be created.",
|
||||
"create_year": "Create Year",
|
||||
"copy_yes": "Yes, copy data",
|
||||
"copy_no": "Start empty",
|
||||
"error_name_required": "Name is required.",
|
||||
"error_amount_invalid": "Please enter a valid amount.",
|
||||
"error_save_failed": "Saving failed."
|
||||
},
|
||||
"profile": {
|
||||
"title": "Profile",
|
||||
"subtitle": "Manage your personal information and settings",
|
||||
|
||||
@@ -142,6 +142,7 @@
|
||||
"fixed_costs": "Charges fixes",
|
||||
"expenses": "Dépenses",
|
||||
"calendar": "Calendrier",
|
||||
"financial_year": "Planification annuelle",
|
||||
"accounts": "Comptes",
|
||||
"revenue_accounts": "Comptes de revenus",
|
||||
"transactions": "Transactions"
|
||||
@@ -345,6 +346,74 @@
|
||||
"ZG": "Zoug",
|
||||
"ZH": "Zurich"
|
||||
},
|
||||
"household": {
|
||||
"title": "Ménage",
|
||||
"none": "Vous ne faites encore partie d'aucun ménage.",
|
||||
"none_hint": "Créez un ménage commun ou attendez une invitation.",
|
||||
"create": "Créer un ménage",
|
||||
"create_title": "Nouveau ménage",
|
||||
"label_name": "Nom du ménage",
|
||||
"placeholder_name": "p.ex. Famille Müller",
|
||||
"created_by": "Créé par",
|
||||
"members": "Membres",
|
||||
"invite": "Inviter",
|
||||
"invite_email": "Adresse e-mail",
|
||||
"invite_placeholder": "user@exemple.ch",
|
||||
"send": "Envoyer l'invitation",
|
||||
"status_active": "Actif",
|
||||
"status_pending": "En attente",
|
||||
"status_left": "Parti",
|
||||
"role_admin": "Admin",
|
||||
"role_member": "Membre",
|
||||
"make_admin": "Rendre admin",
|
||||
"remove_admin": "Retirer admin",
|
||||
"pending_invitation": "Invitation reçue",
|
||||
"pending_from": "Invité par",
|
||||
"effective_from": "Dès l'année",
|
||||
"accept": "Accepter",
|
||||
"leave": "Quitter le ménage",
|
||||
"leave_confirm_title": "Quitter le ménage ?",
|
||||
"leave_confirm_text": "Vous quitterez le ménage à la fin de l'année en cours. Les années passées resteront lisibles.",
|
||||
"you": "vous",
|
||||
"founder": "Fondateur",
|
||||
"error_name_required": "Le nom est requis.",
|
||||
"error_failed": "Opération échouée.",
|
||||
"error_email_required": "L'adresse e-mail est requise.",
|
||||
"error_not_found": "Aucun utilisateur trouvé avec cet e-mail.",
|
||||
"error_already_member": "L'utilisateur est déjà membre ou a une invitation en attente."
|
||||
},
|
||||
"financial_year": {
|
||||
"title": "Planification annuelle",
|
||||
"owner_personal": "Personnel",
|
||||
"owner_household": "Ménage",
|
||||
"new_year": "Démarrer une nouvelle année",
|
||||
"no_years": "Aucune année financière créée.",
|
||||
"start_first_year": "Démarrer la première année",
|
||||
"tab_incomes": "Revenus",
|
||||
"tab_budget_items": "Charges fixes",
|
||||
"add_income": "Ajouter un revenu",
|
||||
"add_budget_item": "Ajouter une charge fixe",
|
||||
"label_name": "Désignation",
|
||||
"label_amount": "Montant (CHF / an)",
|
||||
"label_notes": "Notes",
|
||||
"label_active": "Actif",
|
||||
"total_income": "Revenus totaux",
|
||||
"total_fixed_costs": "Charges fixes totales",
|
||||
"disposable": "Disponible",
|
||||
"savings_rate": "Taux d'épargne",
|
||||
"per_month": "mois",
|
||||
"no_incomes": "Aucun revenu enregistré.",
|
||||
"no_budget_items": "Aucune charge fixe enregistrée.",
|
||||
"confirm_new_year": "Démarrer l'année {{ year }} ?",
|
||||
"confirm_copy": "Reprendre les données de {{ source }} pour la nouvelle année ?",
|
||||
"first_year_hint": "L'année financière {{ year }} sera créée.",
|
||||
"create_year": "Créer l'année",
|
||||
"copy_yes": "Oui, reprendre les données",
|
||||
"copy_no": "Démarrer vide",
|
||||
"error_name_required": "La désignation est requise.",
|
||||
"error_amount_invalid": "Veuillez saisir un montant valide.",
|
||||
"error_save_failed": "Échec de l'enregistrement."
|
||||
},
|
||||
"profile": {
|
||||
"title": "Profil",
|
||||
"subtitle": "Gérer vos informations personnelles et paramètres",
|
||||
|
||||
@@ -142,6 +142,7 @@
|
||||
"fixed_costs": "Costi fissi",
|
||||
"expenses": "Spese",
|
||||
"calendar": "Calendario",
|
||||
"financial_year": "Pianificazione annuale",
|
||||
"accounts": "Conti",
|
||||
"revenue_accounts": "Conti entrate",
|
||||
"transactions": "Transazioni"
|
||||
@@ -345,6 +346,74 @@
|
||||
"ZG": "Zugo",
|
||||
"ZH": "Zurigo"
|
||||
},
|
||||
"household": {
|
||||
"title": "Nucleo familiare",
|
||||
"none": "Non fai ancora parte di nessun nucleo familiare.",
|
||||
"none_hint": "Crea un nucleo familiare condiviso o attendi un invito.",
|
||||
"create": "Crea nucleo familiare",
|
||||
"create_title": "Nuovo nucleo familiare",
|
||||
"label_name": "Nome del nucleo",
|
||||
"placeholder_name": "es. Famiglia Müller",
|
||||
"created_by": "Creato da",
|
||||
"members": "Membri",
|
||||
"invite": "Invita",
|
||||
"invite_email": "Indirizzo e-mail",
|
||||
"invite_placeholder": "user@esempio.ch",
|
||||
"send": "Invia invito",
|
||||
"status_active": "Attivo",
|
||||
"status_pending": "In attesa",
|
||||
"status_left": "Uscito",
|
||||
"role_admin": "Admin",
|
||||
"role_member": "Membro",
|
||||
"make_admin": "Rendi admin",
|
||||
"remove_admin": "Rimuovi admin",
|
||||
"pending_invitation": "Invito ricevuto",
|
||||
"pending_from": "Invitato da",
|
||||
"effective_from": "Dall'anno",
|
||||
"accept": "Accetta",
|
||||
"leave": "Lascia il nucleo",
|
||||
"leave_confirm_title": "Lasciare il nucleo?",
|
||||
"leave_confirm_text": "Lascerai il nucleo alla fine dell'anno in corso. Gli anni passati rimarranno leggibili.",
|
||||
"you": "tu",
|
||||
"founder": "Fondatore",
|
||||
"error_name_required": "Il nome è obbligatorio.",
|
||||
"error_failed": "Operazione non riuscita.",
|
||||
"error_email_required": "L'indirizzo e-mail è obbligatorio.",
|
||||
"error_not_found": "Nessun utente trovato con questa e-mail.",
|
||||
"error_already_member": "L'utente è già membro o ha un invito in sospeso."
|
||||
},
|
||||
"financial_year": {
|
||||
"title": "Pianificazione annuale",
|
||||
"owner_personal": "Personale",
|
||||
"owner_household": "Nucleo familiare",
|
||||
"new_year": "Avvia nuovo anno",
|
||||
"no_years": "Nessun anno finanziario creato.",
|
||||
"start_first_year": "Avvia il primo anno",
|
||||
"tab_incomes": "Entrate",
|
||||
"tab_budget_items": "Costi fissi",
|
||||
"add_income": "Aggiungi entrata",
|
||||
"add_budget_item": "Aggiungi costo fisso",
|
||||
"label_name": "Denominazione",
|
||||
"label_amount": "Importo (CHF / anno)",
|
||||
"label_notes": "Note",
|
||||
"label_active": "Attivo",
|
||||
"total_income": "Entrate totali",
|
||||
"total_fixed_costs": "Costi fissi totali",
|
||||
"disposable": "Disponibile",
|
||||
"savings_rate": "Tasso di risparmio",
|
||||
"per_month": "mese",
|
||||
"no_incomes": "Nessuna entrata registrata.",
|
||||
"no_budget_items": "Nessun costo fisso registrato.",
|
||||
"confirm_new_year": "Avviare l'anno {{ year }}?",
|
||||
"confirm_copy": "Vuoi pre-compilare il nuovo anno con i dati di {{ source }}?",
|
||||
"first_year_hint": "L'anno finanziario {{ year }} verrà creato.",
|
||||
"create_year": "Crea anno",
|
||||
"copy_yes": "Sì, copia i dati",
|
||||
"copy_no": "Inizia vuoto",
|
||||
"error_name_required": "La denominazione è obbligatoria.",
|
||||
"error_amount_invalid": "Inserisci un importo valido.",
|
||||
"error_save_failed": "Salvataggio non riuscito."
|
||||
},
|
||||
"profile": {
|
||||
"title": "Profilo",
|
||||
"subtitle": "Gestisci le tue informazioni personali e impostazioni",
|
||||
|
||||
Reference in New Issue
Block a user