WIP
This commit is contained in:
+27
-1
@@ -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 {
|
||||
|
||||
+9
-15
@@ -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>
|
||||
|
||||
+15
@@ -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);
|
||||
}
|
||||
|
||||
+55
-3
@@ -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 ({
|
||||
|
||||
+4
-2
@@ -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>
|
||||
|
||||
+16
-6
@@ -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()); }
|
||||
|
||||
Reference in New Issue
Block a user