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:
+143
@@ -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>
|
||||
+25
@@ -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;
|
||||
}
|
||||
+103
@@ -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());
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user