From aa77f2051a3b281dfe4e3d7ae686a21966bfc985 Mon Sep 17 00:00:00 2001 From: Chris Chen Date: Fri, 29 May 2026 18:53:39 -0700 Subject: [PATCH] feat(expense): add finance expenses overview + review page --- .../expenses-page.component.html | 149 ++++++++++++++++++ .../expenses-page.component.scss | 46 ++++++ .../expenses-page/expenses-page.component.ts | 137 ++++++++++++++++ 3 files changed, 332 insertions(+) create mode 100644 APP/src/app/features/expense/pages/expenses-page/expenses-page.component.html create mode 100644 APP/src/app/features/expense/pages/expenses-page/expenses-page.component.scss create mode 100644 APP/src/app/features/expense/pages/expenses-page/expenses-page.component.ts diff --git a/APP/src/app/features/expense/pages/expenses-page/expenses-page.component.html b/APP/src/app/features/expense/pages/expenses-page/expenses-page.component.html new file mode 100644 index 0000000..639263b --- /dev/null +++ b/APP/src/app/features/expense/pages/expenses-page/expenses-page.component.html @@ -0,0 +1,149 @@ +
+ + + +
+ + + + + + + + +
+ + +
+
+ + + + + + + + + + + + + + + {{ dataItem.categoryGroupName }} / {{ dataItem.subCategoryName }} + + + + + + {{ dataItem.vendorName || dataItem.memberName || '—' }} + + + + + + + + {{ dataItem.status }} + + + + + + + + + + + + + + + + + + + + + + + + + + +
+ + +
+ + + + +
+ + + +
+ +
+ + + + +
+ +
diff --git a/APP/src/app/features/expense/pages/expenses-page/expenses-page.component.scss b/APP/src/app/features/expense/pages/expenses-page/expenses-page.component.scss new file mode 100644 index 0000000..cba4215 --- /dev/null +++ b/APP/src/app/features/expense/pages/expenses-page/expenses-page.component.scss @@ -0,0 +1,46 @@ +// Status badge pill styles +%badge-base { + display: inline-block; + padding: 2px 10px; + border-radius: 9999px; + font-size: 0.75rem; + font-weight: 600; + white-space: nowrap; +} + +.badge-draft { + @extend %badge-base; + background-color: #e5e7eb; + color: #374151; +} + +.badge-pending { + @extend %badge-base; + background-color: #fef3c7; + color: #92400e; +} + +.badge-approved { + @extend %badge-base; + background-color: #dbeafe; + color: #1e40af; +} + +.badge-paid { + @extend %badge-base; + background-color: #d1fae5; + color: #065f46; +} + +.badge-rejected { + @extend %badge-base; + background-color: #fee2e2; + color: #991b1b; +} + +.receipt-link { + font-size: 0.8rem; + margin-left: 4px; + color: #1d4ed8; + text-decoration: underline; +} diff --git a/APP/src/app/features/expense/pages/expenses-page/expenses-page.component.ts b/APP/src/app/features/expense/pages/expenses-page/expenses-page.component.ts new file mode 100644 index 0000000..6ffb827 --- /dev/null +++ b/APP/src/app/features/expense/pages/expenses-page/expenses-page.component.ts @@ -0,0 +1,137 @@ +import { Component, OnInit } from '@angular/core'; +import { CommonModule } from '@angular/common'; +import { FormsModule } from '@angular/forms'; +import { GridModule, PageChangeEvent } from '@progress/kendo-angular-grid'; +import { ButtonsModule } from '@progress/kendo-angular-buttons'; +import { DropDownsModule } from '@progress/kendo-angular-dropdowns'; +import { InputsModule } from '@progress/kendo-angular-inputs'; +import { DialogsModule } from '@progress/kendo-angular-dialog'; +import { DateInputsModule } from '@progress/kendo-angular-dateinputs'; +import { ExpenseApiService, ExpenseQuery } from '../../services/expense-api.service'; +import { MinistryApiService } from '../../services/ministry-api.service'; +import { ExpenseFormDialogComponent, ExpenseFormResult } from '../../components/expense-form-dialog/expense-form-dialog.component'; +import { ExpenseListItemDto, MinistryDto, ExpenseStatus } from '../../models/expense.model'; +import { switchMap, of } from 'rxjs'; + +@Component({ + selector: 'app-expenses-page', + standalone: true, + imports: [ + CommonModule, FormsModule, GridModule, ButtonsModule, DropDownsModule, + InputsModule, DialogsModule, DateInputsModule, ExpenseFormDialogComponent, + ], + templateUrl: './expenses-page.component.html', + styleUrls: ['./expenses-page.component.scss'], +}) +export class ExpensesPageComponent implements OnInit { + rows: ExpenseListItemDto[] = []; + total = 0; + page = 1; + pageSize = 20; + loading = false; + + ministries: MinistryDto[] = []; + readonly statuses: ExpenseStatus[] = ['Draft', 'PendingApproval', 'Approved', 'Paid', 'Rejected']; + + filter: ExpenseQuery = {}; + + vendorDialogOpen = false; + reimbDialogOpen = false; + + payRow: ExpenseListItemDto | null = null; + payCheckNumber = ''; + payDate = new Date(); + + rejectRow: ExpenseListItemDto | null = null; + rejectNotes = ''; + + constructor(private api: ExpenseApiService, private ministryApi: MinistryApiService) {} + + ngOnInit(): void { + this.ministryApi.getAll().subscribe(m => (this.ministries = m)); + this.load(); + } + + load(): void { + this.loading = true; + this.api.getPaged({ ...this.filter, page: this.page, pageSize: this.pageSize }).subscribe({ + next: r => { this.rows = r.items; this.total = r.totalCount; this.loading = false; }, + error: () => { this.loading = false; }, + }); + } + + get skip(): number { return (this.page - 1) * this.pageSize; } + + applyFilter(): void { this.page = 1; this.load(); } + + onPageChange(e: PageChangeEvent): void { + this.page = Math.floor(e.skip / this.pageSize) + 1; + this.load(); + } + + onVendorSave(result: ExpenseFormResult): void { + this.api.create(result.request).subscribe(() => { this.vendorDialogOpen = false; this.load(); }); + } + + onReimbSave(result: ExpenseFormResult): void { + this.api.create(result.request).pipe( + switchMap(c => result.receipt + ? this.api.uploadReceipt(c.id, result.receipt).pipe(switchMap(() => of(c))) + : of(c)), + ).subscribe(() => { this.reimbDialogOpen = false; this.load(); }); + } + + approve(row: ExpenseListItemDto): void { + this.api.approve(row.id).subscribe(() => this.load()); + } + + openReject(row: ExpenseListItemDto): void { + this.rejectRow = row; + this.rejectNotes = ''; + } + + confirmReject(): void { + if (!this.rejectRow) return; + this.api.reject(this.rejectRow.id, { reviewNotes: this.rejectNotes || null }).subscribe(() => { + this.rejectRow = null; + this.load(); + }); + } + + openPay(row: ExpenseListItemDto): void { + this.payRow = row; + this.payCheckNumber = ''; + this.payDate = new Date(); + } + + confirmPay(): void { + if (!this.payRow) return; + const d = this.payDate; + const paidAt = `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, '0')}-${String(d.getDate()).padStart(2, '0')}`; + this.api.pay(this.payRow.id, { checkNumber: this.payCheckNumber || null, paidAt }).subscribe(() => { + this.payRow = null; + this.load(); + }); + } + + openReceipt(id: number): void { + this.api.downloadReceipt(id).subscribe(blob => { + const url = URL.createObjectURL(blob); + window.open(url, '_blank'); + setTimeout(() => URL.revokeObjectURL(url), 60_000); + }); + } + + canApproveOrReject(row: ExpenseListItemDto): boolean { return row.status === 'PendingApproval'; } + canPay(row: ExpenseListItemDto): boolean { return row.status === 'Approved'; } + + statusClass(status: string): string { + return ({ + Draft: 'badge-draft', + PendingApproval: 'badge-pending', + Approved: 'badge-approved', + Paid: 'badge-paid', + Rejected: 'badge-rejected', + } as Record)[status] ?? ''; + } +}