feat(expense): add member self-service My Reimbursements page
Standalone Angular component (Kendo Grid + ExpenseFormDialog) that lets any logged-in user list, create, submit, and delete their own draft reimbursements, with optional receipt upload. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
+41
@@ -0,0 +1,41 @@
|
|||||||
|
<div class="page">
|
||||||
|
<header class="page-header">
|
||||||
|
<h2>My Reimbursements</h2>
|
||||||
|
<button kendoButton themeColor="primary" (click)="openNew()">+ New Reimbursement</button>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<kendo-grid [data]="rows" [loading]="loading">
|
||||||
|
<kendo-grid-column field="expenseDate" title="Date" [width]="110"></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">
|
||||||
|
<ng-template kendoGridCellTemplate let-dataItem>
|
||||||
|
{{ dataItem.categoryGroupName }} / {{ dataItem.subCategoryName }}
|
||||||
|
</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]="200">
|
||||||
|
<ng-template kendoGridCellTemplate let-dataItem>
|
||||||
|
<ng-container *ngIf="canEdit(dataItem)">
|
||||||
|
<button kendoButton themeColor="primary" fillMode="flat" (click)="submit(dataItem)">Submit</button>
|
||||||
|
<button kendoButton fillMode="flat"
|
||||||
|
(click)="confirm('Delete this reimbursement?') && remove(dataItem)">Delete</button>
|
||||||
|
</ng-container>
|
||||||
|
<a *ngIf="dataItem.hasReceipt" [href]="receiptUrl(dataItem.id)" target="_blank" class="receipt-link">Receipt</a>
|
||||||
|
</ng-template>
|
||||||
|
</kendo-grid-column>
|
||||||
|
</kendo-grid>
|
||||||
|
|
||||||
|
<app-expense-form-dialog
|
||||||
|
*ngIf="dialogOpen"
|
||||||
|
mode="reimbursement"
|
||||||
|
title="New Reimbursement"
|
||||||
|
(save)="onSave($event)"
|
||||||
|
(cancel)="dialogOpen=false">
|
||||||
|
</app-expense-form-dialog>
|
||||||
|
</div>
|
||||||
+46
@@ -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;
|
||||||
|
}
|
||||||
+52
@@ -0,0 +1,52 @@
|
|||||||
|
import { Component, OnInit } from '@angular/core';
|
||||||
|
import { CommonModule } from '@angular/common';
|
||||||
|
import { GridModule } from '@progress/kendo-angular-grid';
|
||||||
|
import { ButtonsModule } from '@progress/kendo-angular-buttons';
|
||||||
|
import { ExpenseApiService } from '../../services/expense-api.service';
|
||||||
|
import { ExpenseFormDialogComponent, ExpenseFormResult } from '../../components/expense-form-dialog/expense-form-dialog.component';
|
||||||
|
import { ExpenseListItemDto } from '../../models/expense.model';
|
||||||
|
import { switchMap, of } from 'rxjs';
|
||||||
|
|
||||||
|
@Component({
|
||||||
|
selector: 'app-my-reimbursements-page',
|
||||||
|
standalone: true,
|
||||||
|
imports: [CommonModule, GridModule, ButtonsModule, ExpenseFormDialogComponent],
|
||||||
|
templateUrl: './my-reimbursements-page.component.html',
|
||||||
|
styleUrls: ['./my-reimbursements-page.component.scss'],
|
||||||
|
})
|
||||||
|
export class MyReimbursementsPageComponent implements OnInit {
|
||||||
|
rows: ExpenseListItemDto[] = [];
|
||||||
|
loading = false;
|
||||||
|
dialogOpen = false;
|
||||||
|
|
||||||
|
constructor(private api: ExpenseApiService) {}
|
||||||
|
|
||||||
|
ngOnInit(): void { this.load(); }
|
||||||
|
|
||||||
|
load(): void {
|
||||||
|
this.loading = true;
|
||||||
|
this.api.getMine().subscribe({
|
||||||
|
next: r => { this.rows = r.items; this.loading = false; },
|
||||||
|
error: () => { this.loading = false; },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
openNew(): void { this.dialogOpen = true; }
|
||||||
|
|
||||||
|
onSave(result: ExpenseFormResult): void {
|
||||||
|
this.api.create(result.request).pipe(
|
||||||
|
switchMap(created => result.receipt
|
||||||
|
? this.api.uploadReceipt(created.id, result.receipt).pipe(switchMap(() => of(created)))
|
||||||
|
: of(created)),
|
||||||
|
).subscribe(() => { this.dialogOpen = false; this.load(); });
|
||||||
|
}
|
||||||
|
|
||||||
|
submit(row: ExpenseListItemDto): void { this.api.submit(row.id).subscribe(() => this.load()); }
|
||||||
|
remove(row: ExpenseListItemDto): void { this.api.delete(row.id).subscribe(() => this.load()); }
|
||||||
|
|
||||||
|
canEdit(row: ExpenseListItemDto): boolean { return row.status === 'Draft'; }
|
||||||
|
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] ?? '';
|
||||||
|
}
|
||||||
|
receiptUrl(id: number): string { return this.api.receiptUrl(id); }
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user