This commit is contained in:
Chris Chen
2026-06-20 17:51:33 -07:00
parent f55807fa7d
commit 3558c67fd7
55 changed files with 3140 additions and 85 deletions
@@ -12,6 +12,7 @@ import { MemberApiService } from '../../../members/services/member-api.service';
import { MemberListItemDto, memberDisplayName } from '../../../members/models/member.model';
import {
MinistryDto, ExpenseCategoryGroupDto, ExpenseSubCategoryDto, ExpenseType, CreateExpenseRequest,
ExpenseListItemDto,
} from '../../models/expense.model';
export interface ExpenseFormResult { request: CreateExpenseRequest; receipt: File | null; }
@@ -29,6 +30,8 @@ export class ExpenseFormDialogComponent implements OnInit {
@Input() mode: 'vendor' | 'reimbursement' = 'reimbursement';
@Input() allowMemberPick = false;
@Input() title = 'New Expense';
/** When set, the dialog prefills from this row for editing instead of starting blank. */
@Input() expense: ExpenseListItemDto | null = null;
@Output() save = new EventEmitter<ExpenseFormResult>();
@Output() cancel = new EventEmitter<void>();
@@ -59,7 +62,30 @@ export class ExpenseFormDialogComponent implements OnInit {
ngOnInit(): void {
this.ministryApi.getAll().subscribe(m => (this.ministries = m));
this.catApi.getAll(false).subscribe(g => (this.groups = g));
this.catApi.getAll(false).subscribe(groups => {
this.groups = groups;
// Populate the sub-category list for the prefilled group so its value displays on edit.
if (this.expense) {
this.subs = this.groups.find(group => group.id === this.expense!.categoryGroupId)?.subCategories ?? [];
}
});
if (this.expense) this.prefill(this.expense);
}
private prefill(expense: ExpenseListItemDto): void {
// expenseDate is a "yyyy-MM-dd" string; build a local Date to avoid a timezone day-shift.
const [year, month, day] = expense.expenseDate.split('-').map(Number);
this.form = {
ministryId: expense.ministryId,
categoryGroupId: expense.categoryGroupId,
subCategoryId: expense.subCategoryId,
amount: expense.amount,
description: expense.description,
vendorName: expense.vendorName ?? '',
checkNumber: expense.checkNumber ?? '',
memberId: expense.memberId,
expenseDate: new Date(year, month - 1, day),
};
}
onGroupChange(groupId: number | null): void {
@@ -11,21 +11,18 @@
<h3>Groups / 組別</h3>
<button kendoButton themeColor="primary" (click)="openNewGroup()">+ New Group</button>
</div>
<kendo-grid [data]="groups" [loading]="loading">
<div class="hint-text-sm">Click a row to view its subcategories · right-click for actions</div>
<kendo-grid class="clickable-rows" [data]="groups" [loading]="loading"
[rowClass]="groupRowClass"
(cellClick)="onGroupCellClick($event)">
<kendo-grid-column field="sortOrder" title="#" [width]="50"></kendo-grid-column>
<kendo-grid-column field="name_en" title="Name (EN)"></kendo-grid-column>
<kendo-grid-column field="name_zh" title="名稱 (中)"></kendo-grid-column>
<kendo-grid-column field="isActive" title="Active" [width]="70">
<ng-template kendoGridCellTemplate let-g>{{ g.isActive ? 'Yes' : 'No' }}</ng-template>
</kendo-grid-column>
<kendo-grid-column title="Actions" [width]="180">
<ng-template kendoGridCellTemplate let-g>
<button kendoButton fillMode="flat" (click)="selectGroup(g)" [themeColor]="selectedGroup?.id === g.id ? 'primary' : 'base'">Select</button>
<button kendoButton fillMode="flat" (click)="openEditGroup(g)">Edit</button>
<button kendoButton fillMode="flat" *ngIf="g.isActive" (click)="deactivateGroup(g)">Deactivate</button>
</ng-template>
</kendo-grid-column>
</kendo-grid>
<kendo-contextmenu #groupMenu [items]="groupMenuItems" (select)="onGroupMenuSelect($event)"></kendo-contextmenu>
</div>
<!-- Right: Subcategories of selected group -->
@@ -35,20 +32,17 @@
<button kendoButton themeColor="primary" [disabled]="!selectedGroup" (click)="openNewSub()">+ New Subcategory</button>
</div>
<div *ngIf="!selectedGroup" class="hint-text">Select a group on the left to view its subcategories.</div>
<kendo-grid *ngIf="selectedGroup" [data]="subCategories" [loading]="loading">
<div *ngIf="selectedGroup" class="hint-text-sm">Right-click a row for actions</div>
<kendo-grid *ngIf="selectedGroup" [data]="subCategories" [loading]="loading"
(cellClick)="onSubCellClick($event)">
<kendo-grid-column field="sortOrder" title="#" [width]="50"></kendo-grid-column>
<kendo-grid-column field="name_en" title="Name (EN)"></kendo-grid-column>
<kendo-grid-column field="name_zh" title="名稱 (中)"></kendo-grid-column>
<kendo-grid-column field="isActive" title="Active" [width]="70">
<ng-template kendoGridCellTemplate let-s>{{ s.isActive ? 'Yes' : 'No' }}</ng-template>
</kendo-grid-column>
<kendo-grid-column title="Actions" [width]="150">
<ng-template kendoGridCellTemplate let-s>
<button kendoButton fillMode="flat" (click)="openEditSub(s)">Edit</button>
<button kendoButton fillMode="flat" *ngIf="s.isActive" (click)="deactivateSub(s)">Deactivate</button>
</ng-template>
</kendo-grid-column>
</kendo-grid>
<kendo-contextmenu #subMenu [items]="subMenuItems" (select)="onSubMenuSelect($event)"></kendo-contextmenu>
</div>
</div>
@@ -28,3 +28,18 @@
color: #888;
font-style: italic;
}
.hint-text-sm {
margin-bottom: 0.5rem;
font-size: 0.8rem;
color: #999;
}
// Group grid: rows are clickable to select.
.clickable-rows ::ng-deep .k-grid-content tr {
cursor: pointer;
}
::ng-deep .k-grid .k-table-row.selected-row > td {
background-color: rgba(0, 105, 217, 0.12);
}
@@ -1,17 +1,18 @@
import { Component, OnInit } from '@angular/core';
import { Component, OnInit, ViewChild } from '@angular/core';
import { CommonModule } from '@angular/common';
import { FormsModule } from '@angular/forms';
import { GridModule } from '@progress/kendo-angular-grid';
import { GridModule, CellClickEvent, RowClassArgs } 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 { ContextMenuModule, ContextMenuComponent, ContextMenuSelectEvent } from '@progress/kendo-angular-menu';
import { ExpenseCategoryApiService } from '../../services/expense-category-api.service';
import { ExpenseCategoryGroupDto, ExpenseSubCategoryDto } from '../../models/expense.model';
@Component({
selector: 'app-expense-categories-page',
standalone: true,
imports: [CommonModule, FormsModule, GridModule, ButtonsModule, DialogsModule, InputsModule],
imports: [CommonModule, FormsModule, GridModule, ButtonsModule, DialogsModule, InputsModule, ContextMenuModule],
templateUrl: './expense-categories-page.component.html',
styleUrls: ['./expense-categories-page.component.scss'],
})
@@ -20,6 +21,13 @@ export class ExpenseCategoriesPageComponent implements OnInit {
selectedGroup: ExpenseCategoryGroupDto | null = null;
loading = false;
@ViewChild('groupMenu') groupMenu!: ContextMenuComponent;
@ViewChild('subMenu') subMenu!: ContextMenuComponent;
groupMenuItems: { text: string }[] = [];
subMenuItems: { text: string }[] = [];
private contextGroup: ExpenseCategoryGroupDto | null = null;
private contextSub: ExpenseSubCategoryDto | null = null;
groupDialogOpen = false;
editingGroupId: number | null = null;
groupForm = { name_en: '', name_zh: '', sortOrder: 0, isActive: true };
@@ -47,6 +55,50 @@ export class ExpenseCategoriesPageComponent implements OnInit {
selectGroup(g: ExpenseCategoryGroupDto): void { this.selectedGroup = g; }
get subCategories(): ExpenseSubCategoryDto[] { return this.selectedGroup?.subCategories ?? []; }
// Highlight the currently selected group row.
groupRowClass = (context: RowClassArgs): Record<string, boolean> => {
return { 'selected-row': this.selectedGroup?.id === context.dataItem.id };
};
// Left-click selects the group (reveals its subcategories); right-click opens the actions menu.
onGroupCellClick(event: CellClickEvent): void {
if (event.type === 'contextmenu') {
event.originalEvent.preventDefault();
this.contextGroup = event.dataItem;
this.groupMenuItems = this.buildMenuItems(event.dataItem.isActive);
this.groupMenu.show({ left: event.originalEvent.pageX, top: event.originalEvent.pageY });
} else {
this.selectGroup(event.dataItem);
}
}
onGroupMenuSelect(event: ContextMenuSelectEvent): void {
if (!this.contextGroup) return;
if (event.item.text === 'Edit') this.openEditGroup(this.contextGroup);
else if (event.item.text === 'Deactivate') this.deactivateGroup(this.contextGroup);
}
// Subcategory rows have no selection behaviour; only the right-click actions menu.
onSubCellClick(event: CellClickEvent): void {
if (event.type !== 'contextmenu') return;
event.originalEvent.preventDefault();
this.contextSub = event.dataItem;
this.subMenuItems = this.buildMenuItems(event.dataItem.isActive);
this.subMenu.show({ left: event.originalEvent.pageX, top: event.originalEvent.pageY });
}
onSubMenuSelect(event: ContextMenuSelectEvent): void {
if (!this.contextSub) return;
if (event.item.text === 'Edit') this.openEditSub(this.contextSub);
else if (event.item.text === 'Deactivate') this.deactivateSub(this.contextSub);
}
private buildMenuItems(isActive: boolean): { text: string }[] {
const items: { text: string }[] = [{ text: 'Edit' }];
if (isActive) items.push({ text: 'Deactivate' });
return items;
}
openNewGroup(): void {
this.editingGroupId = null;
this.groupForm = { name_en: '', name_zh: '', sortOrder: this.groups.length + 1, isActive: true };
@@ -7,33 +7,22 @@
<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 / check #"
[(ngModel)]="filter.search"
<kendo-textbox placeholder="Search description / vendor / member / check #" [(ngModel)]="filter.search"
(keydown.enter)="applyFilter()">
</kendo-textbox>
</label>
<label class="flex flex-col gap-1">
Ministry
<kendo-dropdownlist
[data]="ministries"
textField="label"
valueField="id"
[valuePrimitive]="true"
[(ngModel)]="filter.ministryId"
[defaultItem]="{ id: null, label: 'All Ministries/全部事工' }">
<kendo-dropdownlist [data]="ministries" textField="label" valueField="id" [valuePrimitive]="true"
[(ngModel)]="filter.ministryId" [defaultItem]="{ id: null, label: 'All Ministries/全部事工' }">
</kendo-dropdownlist>
</label>
<label class="flex flex-col gap-1">
Status
<kendo-dropdownlist
[data]="statuses"
textField="label"
valueField="value"
[valuePrimitive]="true"
[(ngModel)]="filter.status"
[defaultItem]="{ value: null, label: 'All Status/全部狀態' }">
<kendo-dropdownlist [data]="statuses" textField="label" valueField="value" [valuePrimitive]="true"
[(ngModel)]="filter.status" [defaultItem]="{ value: null, label: 'All Status/全部狀態' }">
</kendo-dropdownlist>
</label>
@@ -46,28 +35,23 @@
</div>
<!-- Main grid -->
<kendo-grid
[data]="{ data: rows, total: total }"
[loading]="loading"
[pageable]="true"
[skip]="skip"
[pageSize]="pageSize"
(pageChange)="onPageChange($event)">
<kendo-grid [data]="{ data: rows, total: total }" [loading]="loading" [pageable]="true" [skip]="skip"
[pageSize]="pageSize" (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="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 field="ministryName" title="Ministry" [width]="280"></kendo-grid-column>
<kendo-grid-column title="Category" [width]="180">
<kendo-grid-column title="Category" [width]="360">
<ng-template kendoGridCellTemplate let-dataItem>
{{ dataItem.categoryGroupName }} / {{ dataItem.subCategoryName }}
</ng-template>
</kendo-grid-column>
<kendo-grid-column field="description" title="Description"></kendo-grid-column>
<kendo-grid-column title="Payee" [width]="150">
<ng-template kendoGridCellTemplate let-dataItem>
{{ dataItem.vendorName || dataItem.memberName || '—' }}
@@ -88,7 +72,7 @@
</ng-template>
</kendo-grid-column>
<kendo-grid-column title="Actions" [width]="240">
<kendo-grid-column title="Actions" [width]="100">
<ng-template kendoGridCellTemplate let-dataItem>
<ng-container *ngIf="canApproveOrReject(dataItem)">
<button kendoButton themeColor="success" fillMode="flat" (click)="approve(dataItem)">Approve</button>
@@ -96,30 +80,21 @@
</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>
<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)"
<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 *ngIf="reimbDialogOpen" mode="reimbursement" [allowMemberPick]="true"
title="Reimbursement (on behalf)" (save)="onReimbSave($event)" (cancel)="reimbDialogOpen = false">
</app-expense-form-dialog>
<!-- Mark Paid dialog -->
@@ -154,4 +129,4 @@
</kendo-dialog-actions>
</kendo-dialog>
</div>
</div>
@@ -46,7 +46,7 @@ export class ExpensesPageComponent implements OnInit {
rejectRow: ExpenseListItemDto | null = null;
rejectNotes = '';
constructor(private api: ExpenseApiService, private ministryApi: MinistryApiService) {}
constructor(private api: ExpenseApiService, private ministryApi: MinistryApiService) { }
ngOnInit(): void {
this.ministryApi.getAll().subscribe(m => (this.ministries = m));
@@ -124,7 +124,11 @@ export class ExpensesPageComponent implements OnInit {
}
canApproveOrReject(row: ExpenseListItemDto): boolean { return row.status === 'PendingApproval'; }
canPay(row: ExpenseListItemDto): boolean { return row.status === 'Approved'; }
canPay(row: ExpenseListItemDto): boolean {
return false;
// row.status === 'Approved';
//should be pay by disbursement
}
statusClass(status: string): string {
return ({
@@ -22,6 +22,7 @@
<kendo-grid-column title="Actions" [width]="200">
<ng-template kendoGridCellTemplate let-dataItem>
<ng-container *ngIf="canEdit(dataItem)">
<button kendoButton fillMode="flat" (click)="openEdit(dataItem)">Edit</button>
<button kendoButton themeColor="primary" fillMode="flat" (click)="submit(dataItem)">Submit</button>
<button kendoButton fillMode="flat" (click)="remove(dataItem)">Delete</button>
</ng-container>
@@ -34,8 +35,9 @@
<app-expense-form-dialog
*ngIf="dialogOpen"
mode="reimbursement"
title="New Reimbursement"
[expense]="editRow"
[title]="editRow ? 'Edit Reimbursement' : 'New Reimbursement'"
(save)="onSave($event)"
(cancel)="dialogOpen=false">
(cancel)="closeDialog()">
</app-expense-form-dialog>
</div>
@@ -18,6 +18,7 @@ export class MyReimbursementsPageComponent implements OnInit {
rows: ExpenseListItemDto[] = [];
loading = false;
dialogOpen = false;
editRow: ExpenseListItemDto | null = null;
constructor(private api: ExpenseApiService) {}
@@ -31,14 +32,23 @@ export class MyReimbursementsPageComponent implements OnInit {
});
}
openNew(): void { this.dialogOpen = true; }
openNew(): void { this.editRow = null; this.dialogOpen = true; }
openEdit(row: ExpenseListItemDto): void { this.editRow = row; this.dialogOpen = true; }
closeDialog(): void { this.dialogOpen = false; this.editRow = null; }
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(); });
if (this.editRow) {
const id = this.editRow.id;
this.api.update(id, result.request).pipe(
switchMap(() => result.receipt ? this.api.uploadReceipt(id, result.receipt) : of(void 0)),
).subscribe(() => { this.closeDialog(); this.load(); });
} else {
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.closeDialog(); this.load(); });
}
}
submit(row: ExpenseListItemDto): void { this.api.submit(row.id).subscribe(() => this.load()); }