feat(expense): add finance expenses overview + review page
This commit is contained in:
@@ -0,0 +1,149 @@
|
|||||||
|
<div class="page">
|
||||||
|
<header class="page-header">
|
||||||
|
<h2>Expenses</h2>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<!-- Filter toolbar -->
|
||||||
|
<div class="flex flex-wrap gap-3 items-end mb-4">
|
||||||
|
<label class="flex flex-col gap-1">
|
||||||
|
Search
|
||||||
|
<kendo-textbox placeholder="Search description / vendor / member"
|
||||||
|
[(ngModel)]="filter.search"
|
||||||
|
(keydown.enter)="applyFilter()">
|
||||||
|
</kendo-textbox>
|
||||||
|
</label>
|
||||||
|
|
||||||
|
<label class="flex flex-col gap-1">
|
||||||
|
Ministry
|
||||||
|
<kendo-dropdownlist
|
||||||
|
[data]="ministries"
|
||||||
|
textField="name_en"
|
||||||
|
valueField="id"
|
||||||
|
[valuePrimitive]="true"
|
||||||
|
[(ngModel)]="filter.ministryId"
|
||||||
|
[defaultItem]="{ id: null, name_en: 'All Ministries' }">
|
||||||
|
</kendo-dropdownlist>
|
||||||
|
</label>
|
||||||
|
|
||||||
|
<label class="flex flex-col gap-1">
|
||||||
|
Status
|
||||||
|
<kendo-dropdownlist
|
||||||
|
[data]="statuses"
|
||||||
|
[(ngModel)]="filter.status"
|
||||||
|
[defaultItem]="null">
|
||||||
|
</kendo-dropdownlist>
|
||||||
|
</label>
|
||||||
|
|
||||||
|
<button kendoButton (click)="applyFilter()">Apply</button>
|
||||||
|
|
||||||
|
<div class="ml-auto flex gap-2">
|
||||||
|
<button kendoButton themeColor="primary" (click)="vendorDialogOpen = true">+ Vendor Payment</button>
|
||||||
|
<button kendoButton themeColor="primary" (click)="reimbDialogOpen = true">+ Reimbursement</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Main grid -->
|
||||||
|
<kendo-grid
|
||||||
|
[data]="rows"
|
||||||
|
[loading]="loading"
|
||||||
|
[pageable]="true"
|
||||||
|
[skip]="skip"
|
||||||
|
[pageSize]="pageSize"
|
||||||
|
[total]="total"
|
||||||
|
(pageChange)="onPageChange($event)">
|
||||||
|
|
||||||
|
<kendo-grid-column field="expenseDate" title="Date" [width]="110"></kendo-grid-column>
|
||||||
|
|
||||||
|
<kendo-grid-column field="type" title="Type" [width]="140"></kendo-grid-column>
|
||||||
|
|
||||||
|
<kendo-grid-column field="description" title="Description"></kendo-grid-column>
|
||||||
|
|
||||||
|
<kendo-grid-column field="ministryName" title="Ministry" [width]="140"></kendo-grid-column>
|
||||||
|
|
||||||
|
<kendo-grid-column title="Category" [width]="180">
|
||||||
|
<ng-template kendoGridCellTemplate let-dataItem>
|
||||||
|
{{ dataItem.categoryGroupName }} / {{ dataItem.subCategoryName }}
|
||||||
|
</ng-template>
|
||||||
|
</kendo-grid-column>
|
||||||
|
|
||||||
|
<kendo-grid-column title="Payee" [width]="150">
|
||||||
|
<ng-template kendoGridCellTemplate let-dataItem>
|
||||||
|
{{ dataItem.vendorName || dataItem.memberName || '—' }}
|
||||||
|
</ng-template>
|
||||||
|
</kendo-grid-column>
|
||||||
|
|
||||||
|
<kendo-grid-column field="amount" title="Amount" [width]="110" format="c2"></kendo-grid-column>
|
||||||
|
|
||||||
|
<kendo-grid-column title="Status" [width]="140">
|
||||||
|
<ng-template kendoGridCellTemplate let-dataItem>
|
||||||
|
<span [class]="statusClass(dataItem.status)">{{ dataItem.status }}</span>
|
||||||
|
</ng-template>
|
||||||
|
</kendo-grid-column>
|
||||||
|
|
||||||
|
<kendo-grid-column title="Actions" [width]="240">
|
||||||
|
<ng-template kendoGridCellTemplate let-dataItem>
|
||||||
|
<ng-container *ngIf="canApproveOrReject(dataItem)">
|
||||||
|
<button kendoButton themeColor="success" fillMode="flat" (click)="approve(dataItem)">Approve</button>
|
||||||
|
<button kendoButton themeColor="error" fillMode="flat" (click)="openReject(dataItem)">Reject</button>
|
||||||
|
</ng-container>
|
||||||
|
<button *ngIf="canPay(dataItem)" kendoButton themeColor="primary" fillMode="flat"
|
||||||
|
(click)="openPay(dataItem)">Pay</button>
|
||||||
|
<button *ngIf="dataItem.hasReceipt" kendoButton fillMode="flat"
|
||||||
|
(click)="openReceipt(dataItem.id)" class="receipt-link">Receipt</button>
|
||||||
|
</ng-template>
|
||||||
|
</kendo-grid-column>
|
||||||
|
|
||||||
|
</kendo-grid>
|
||||||
|
|
||||||
|
<!-- Vendor Payment dialog -->
|
||||||
|
<app-expense-form-dialog
|
||||||
|
*ngIf="vendorDialogOpen"
|
||||||
|
mode="vendor"
|
||||||
|
title="Vendor Payment"
|
||||||
|
(save)="onVendorSave($event)"
|
||||||
|
(cancel)="vendorDialogOpen = false">
|
||||||
|
</app-expense-form-dialog>
|
||||||
|
|
||||||
|
<!-- Reimbursement (on behalf) dialog -->
|
||||||
|
<app-expense-form-dialog
|
||||||
|
*ngIf="reimbDialogOpen"
|
||||||
|
mode="reimbursement"
|
||||||
|
[allowMemberPick]="true"
|
||||||
|
title="Reimbursement (on behalf)"
|
||||||
|
(save)="onReimbSave($event)"
|
||||||
|
(cancel)="reimbDialogOpen = false">
|
||||||
|
</app-expense-form-dialog>
|
||||||
|
|
||||||
|
<!-- Mark Paid dialog -->
|
||||||
|
<kendo-dialog *ngIf="payRow" title="Mark Paid" [width]="400" (close)="payRow = null">
|
||||||
|
<div class="grid grid-cols-1 gap-3 p-2">
|
||||||
|
<label class="flex flex-col gap-1">
|
||||||
|
Check #
|
||||||
|
<kendo-textbox [(ngModel)]="payCheckNumber" placeholder="Optional"></kendo-textbox>
|
||||||
|
</label>
|
||||||
|
<label class="flex flex-col gap-1">
|
||||||
|
Payment Date
|
||||||
|
<kendo-datepicker [(ngModel)]="payDate"></kendo-datepicker>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
<kendo-dialog-actions>
|
||||||
|
<button kendoButton (click)="payRow = null">Cancel</button>
|
||||||
|
<button kendoButton themeColor="primary" (click)="confirmPay()">Confirm</button>
|
||||||
|
</kendo-dialog-actions>
|
||||||
|
</kendo-dialog>
|
||||||
|
|
||||||
|
<!-- Reject dialog -->
|
||||||
|
<kendo-dialog *ngIf="rejectRow" title="Reject Expense" [width]="400" (close)="rejectRow = null">
|
||||||
|
<div class="grid grid-cols-1 gap-3 p-2">
|
||||||
|
<label class="flex flex-col gap-1">
|
||||||
|
Review Notes
|
||||||
|
<kendo-textbox [(ngModel)]="rejectNotes" placeholder="Optional notes for submitter"></kendo-textbox>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
<kendo-dialog-actions>
|
||||||
|
<button kendoButton (click)="rejectRow = null">Cancel</button>
|
||||||
|
<button kendoButton themeColor="error" (click)="confirmReject()">Reject</button>
|
||||||
|
</kendo-dialog-actions>
|
||||||
|
</kendo-dialog>
|
||||||
|
|
||||||
|
</div>
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
@@ -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<string, string>)[status] ?? '';
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user