feat(expense): add monthly reconciliation statement page

Implements Task 16 — MonthlyStatementPageComponent with Kendo Grid list
(year filter), create/edit dialog (server-computed totals preview), and
finalize action that locks the statement.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
Chris Chen
2026-05-29 18:56:14 -07:00
parent aa77f2051a
commit f5ff03260b
3 changed files with 271 additions and 0 deletions
@@ -0,0 +1,143 @@
<div class="page">
<header class="page-header">
<h2>Monthly Statements</h2>
</header>
<!-- Filter toolbar -->
<div class="flex flex-wrap gap-3 items-end mb-4">
<label class="flex flex-col gap-1">
Year
<kendo-numerictextbox
[(ngModel)]="yearFilter"
[format]="'####'"
[decimals]="0"
[spinners]="false"
placeholder="All years">
</kendo-numerictextbox>
</label>
<button kendoButton (click)="load()">Apply</button>
<div class="ml-auto">
<button kendoButton themeColor="primary" (click)="openNew()">+ New Statement</button>
</div>
</div>
<!-- Main grid -->
<kendo-grid [data]="rows" [loading]="loading">
<kendo-grid-column field="year" title="Year" [width]="80"></kendo-grid-column>
<kendo-grid-column field="month" title="Month" [width]="80"></kendo-grid-column>
<kendo-grid-column field="openingBalance" title="Opening" [width]="120" format="c2"></kendo-grid-column>
<kendo-grid-column field="totalGiving" title="Giving" [width]="120" format="c2"></kendo-grid-column>
<kendo-grid-column field="totalOtherIncome" title="Other Income" [width]="130" format="c2"></kendo-grid-column>
<kendo-grid-column field="totalExpenses" title="Expenses" [width]="120" format="c2"></kendo-grid-column>
<kendo-grid-column field="calculatedClosingBalance" title="Calc. Closing" [width]="130" format="c2"></kendo-grid-column>
<kendo-grid-column field="bankStatementBalance" title="Bank" [width]="120" format="c2"></kendo-grid-column>
<kendo-grid-column title="Difference" [width]="120">
<ng-template kendoGridCellTemplate let-dataItem>
<span [class.text-red-600]="dataItem.difference !== 0">
{{ dataItem.difference | currency:'USD':'symbol':'1.2-2' }}
</span>
</ng-template>
</kendo-grid-column>
<kendo-grid-column title="Finalized" [width]="100">
<ng-template kendoGridCellTemplate let-dataItem>
<span [class]="dataItem.isFinalized ? 'badge badge-yes' : 'badge badge-no'">
{{ dataItem.isFinalized ? 'Yes' : 'No' }}
</span>
</ng-template>
</kendo-grid-column>
<kendo-grid-column title="Actions" [width]="180">
<ng-template kendoGridCellTemplate let-dataItem>
<button *ngIf="!dataItem.isFinalized" kendoButton fillMode="flat" (click)="openEdit(dataItem)">Edit</button>
<button *ngIf="!dataItem.isFinalized" kendoButton themeColor="warning" fillMode="flat" (click)="finalize(dataItem)">Finalize</button>
</ng-template>
</kendo-grid-column>
</kendo-grid>
<!-- Create / Edit dialog -->
<kendo-dialog *ngIf="dialogOpen" title="Monthly Statement" [width]="560" (close)="dialogOpen = false">
<div class="grid grid-cols-1 md:grid-cols-2 gap-3 p-2">
<label class="flex flex-col gap-1">
Year
<kendo-numerictextbox
[(ngModel)]="form.year"
[format]="'n0'"
[decimals]="0"
[min]="2000"
[disabled]="!!editing">
</kendo-numerictextbox>
</label>
<label class="flex flex-col gap-1">
Month
<kendo-numerictextbox
[(ngModel)]="form.month"
[format]="'n0'"
[decimals]="0"
[min]="1"
[max]="12"
[disabled]="!!editing">
</kendo-numerictextbox>
</label>
<label class="flex flex-col gap-1">
Opening Balance
<kendo-numerictextbox
[(ngModel)]="form.openingBalance"
[format]="'c2'"
[decimals]="2">
</kendo-numerictextbox>
</label>
<label class="flex flex-col gap-1">
Other Income
<kendo-numerictextbox
[(ngModel)]="form.totalOtherIncome"
[format]="'c2'"
[decimals]="2">
</kendo-numerictextbox>
</label>
<label class="flex flex-col gap-1">
Bank Statement Balance
<kendo-numerictextbox
[(ngModel)]="form.bankStatementBalance"
[format]="'c2'"
[decimals]="2">
</kendo-numerictextbox>
</label>
<label class="flex flex-col gap-1 md:col-span-2">
Notes
<kendo-textbox [(ngModel)]="form.notes" placeholder="Optional"></kendo-textbox>
</label>
<!-- Computed summary (shown when editing an existing record or after save) -->
<div *ngIf="preview" class="md:col-span-2 bg-gray-50 rounded p-3 grid grid-cols-2 gap-2 text-sm">
<div class="font-semibold col-span-2 mb-1">Computed Summary</div>
<span class="text-gray-600">Total Giving</span>
<span>{{ preview.totalGiving | currency:'USD':'symbol':'1.2-2' }}</span>
<span class="text-gray-600">Total Expenses</span>
<span>{{ preview.totalExpenses | currency:'USD':'symbol':'1.2-2' }}</span>
<span class="text-gray-600">Calc. Closing</span>
<span>{{ preview.calculatedClosingBalance | currency:'USD':'symbol':'1.2-2' }}</span>
<span class="text-gray-600">Difference</span>
<span [class.text-red-600]="preview.difference !== 0">
{{ preview.difference | currency:'USD':'symbol':'1.2-2' }}
</span>
</div>
</div>
<kendo-dialog-actions>
<button kendoButton (click)="dialogOpen = false">Cancel</button>
<button kendoButton themeColor="primary" (click)="save()">Save</button>
</kendo-dialog-actions>
</kendo-dialog>
</div>
@@ -0,0 +1,25 @@
// Tailwind handles most layout; minimal local overrides only.
.badge {
display: inline-block;
padding: 2px 8px;
border-radius: 4px;
font-size: 0.75rem;
font-weight: 600;
line-height: 1.4;
}
.badge-yes {
background: #dcfce7;
color: #166534;
}
.badge-no {
background: #f1f5f9;
color: #475569;
}
// Ensure Tailwind text-red-600 works even with preflight off
.text-red-600 {
color: #dc2626;
}
@@ -0,0 +1,103 @@
import { Component, OnInit } from '@angular/core';
import { CommonModule } from '@angular/common';
import { FormsModule } from '@angular/forms';
import { GridModule } from '@progress/kendo-angular-grid';
import { ButtonsModule } from '@progress/kendo-angular-buttons';
import { DialogsModule } from '@progress/kendo-angular-dialog';
import { InputsModule } from '@progress/kendo-angular-inputs';
import { MonthlyStatementApiService } from '../../services/monthly-statement-api.service';
import { MonthlyStatementDto } from '../../models/expense.model';
@Component({
selector: 'app-monthly-statement-page',
standalone: true,
imports: [CommonModule, FormsModule, GridModule, ButtonsModule, DialogsModule, InputsModule],
templateUrl: './monthly-statement-page.component.html',
styleUrls: ['./monthly-statement-page.component.scss'],
})
export class MonthlyStatementPageComponent implements OnInit {
rows: MonthlyStatementDto[] = [];
loading = false;
yearFilter: number | null = null;
dialogOpen = false;
editing: MonthlyStatementDto | null = null;
form = {
year: new Date().getFullYear(),
month: new Date().getMonth() + 1,
openingBalance: 0,
totalOtherIncome: 0,
bankStatementBalance: 0,
notes: '',
};
preview: MonthlyStatementDto | null = null;
constructor(private api: MonthlyStatementApiService) {}
ngOnInit(): void { this.load(); }
load(): void {
this.loading = true;
this.api.getAll(this.yearFilter ?? undefined).subscribe({
next: r => { this.rows = r; this.loading = false; },
error: () => { this.loading = false; },
});
}
openNew(): void {
this.editing = null;
this.preview = null;
this.form = {
year: new Date().getFullYear(),
month: new Date().getMonth() + 1,
openingBalance: 0,
totalOtherIncome: 0,
bankStatementBalance: 0,
notes: '',
};
this.dialogOpen = true;
}
openEdit(row: MonthlyStatementDto): void {
this.editing = row;
this.preview = row;
this.form = {
year: row.year,
month: row.month,
openingBalance: row.openingBalance,
totalOtherIncome: row.totalOtherIncome,
bankStatementBalance: row.bankStatementBalance,
notes: row.notes ?? '',
};
this.dialogOpen = true;
}
save(): void {
const done = () => { this.dialogOpen = false; this.load(); };
if (this.editing == null) {
this.api.create({
year: this.form.year,
month: this.form.month,
openingBalance: this.form.openingBalance,
totalOtherIncome: this.form.totalOtherIncome,
bankStatementBalance: this.form.bankStatementBalance,
notes: this.form.notes || null,
}).subscribe({
next: created => this.api.getById(created.id).subscribe(s => { this.preview = s; done(); }),
error: err => alert(err?.error?.message ?? 'Create failed (a statement for that month may already exist).'),
});
} else {
this.api.update(this.editing.id, {
openingBalance: this.form.openingBalance,
totalOtherIncome: this.form.totalOtherIncome,
bankStatementBalance: this.form.bankStatementBalance,
notes: this.form.notes || null,
}).subscribe(done);
}
}
finalize(row: MonthlyStatementDto): void {
if (!confirm(`Finalize ${row.year}-${String(row.month).padStart(2, '0')}? This locks the statement.`)) return;
this.api.finalize(row.id).subscribe(() => this.load());
}
}