update view.
ci-cd-vm / ci-cd (push) Successful in 1m59s

This commit is contained in:
Chris Chen
2026-06-25 21:55:16 -07:00
parent d987ddea0e
commit 773d38d838
2 changed files with 134 additions and 22 deletions
@@ -30,20 +30,24 @@
</div>
</div>
<!-- Main grid -->
<!-- Desktop grid -->
<div class="hidden md:block">
<div class="hint-text-sm mb-2">Right-click a row for actions / 右鍵顯示動作</div>
<kendo-grid [data]="{ data: rows, total: total }" [loading]="loading" [pageable]="true" [skip]="skip"
[pageSize]="pageSize" (pageChange)="onPageChange($event)">
[pageSize]="pageSize" (pageChange)="onPageChange($event)" (cellClick)="onCellClick($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="ministryName" title="Ministry" [width]="280"></kendo-grid-column>
<kendo-grid-column title="Category" [width]="360">
<kendo-grid-column title="Ministry / Category" [width]="240">
<ng-template kendoGridCellTemplate let-dataItem>
{{ dataItem.primaryCategoryName }}<span *ngIf="dataItem.lineCount > 1" class="text-gray-500"> +{{ dataItem.lineCount - 1 }}</span>
<div>{{ dataItem.ministryName }}</div>
<div class="text-gray-500 text-xs">
{{ dataItem.primaryCategoryName }}<span *ngIf="dataItem.lineCount > 1"> +{{ dataItem.lineCount - 1 }}</span>
</div>
</ng-template>
</kendo-grid-column>
@@ -81,19 +85,75 @@
</ng-template>
</kendo-grid-column>
<kendo-grid-column title="Actions" [width]="160">
<ng-template kendoGridCellTemplate let-dataItem>
<button *ngIf="canEdit(dataItem)" kendoButton fillMode="flat" (click)="openEdit(dataItem)">Edit</button>
<button *ngIf="canApproveOrReject(dataItem)" kendoButton themeColor="primary" fillMode="flat"
(click)="openReview(dataItem)">Review</button>
<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>
<kendo-contextmenu #rowMenu [items]="rowMenuItems" (select)="onRowMenuSelect($event)"></kendo-contextmenu>
</div>
<!-- Mobile cards -->
<div class="md:hidden flex flex-col gap-3">
<div *ngFor="let dataItem of rows" class="rounded border p-3 flex flex-col gap-2">
<div class="flex justify-between items-start gap-2">
<div class="text-sm text-gray-500">{{ dataItem.expenseDate }}</div>
<div class="font-semibold">{{ dataItem.amount | currency }}</div>
</div>
<div>
<div class="font-medium">{{ dataItem.ministryName }}</div>
<div class="text-gray-500 text-xs">
{{ dataItem.primaryCategoryName }}<span *ngIf="dataItem.lineCount > 1"> +{{ dataItem.lineCount - 1 }}</span>
</div>
</div>
<div *ngIf="dataItem.description" class="text-sm">{{ dataItem.description }}</div>
<div class="text-sm flex justify-between gap-2">
<span class="text-gray-500">Payee / 收款人</span>
<span class="text-right">
<ng-container *ngIf="dataItem.vendorName; else mobileMemberPayee">{{ dataItem.vendorName }}</ng-container>
<ng-template #mobileMemberPayee>
<ng-container *ngIf="dataItem.memberName; else mobileDash">
<span *ngIf="dataItem.memberNickName">{{ dataItem.memberNickName }} </span>
<span [class.text-gray-500]="dataItem.memberNickName">{{ dataItem.memberName }}</span>
</ng-container>
<ng-template #mobileDash></ng-template>
</ng-template>
</span>
</div>
<div *ngIf="dataItem.status === 'Paid' && dataItem.checkNumber" class="text-sm flex justify-between gap-2">
<span class="text-gray-500">Check # / 支票號</span>
<span>{{ dataItem.checkNumber }}</span>
</div>
<div>
<span [class]="statusClass(dataItem.status)">{{ dataItem.status }}</span>
<div *ngIf="dataItem.reviewedByName && (dataItem.status === 'Approved' || dataItem.status === 'Paid')"
class="review-meta">✓ Approved by {{ dataItem.reviewedByName }}<br>{{ dataItem.reviewedAt | date:'yyyy-MM-dd HH:mm' }}</div>
<div *ngIf="dataItem.reviewedByName && dataItem.status === 'Rejected'" class="review-meta review-meta-reject">
✗ Rejected by {{ dataItem.reviewedByName }}<br>{{ dataItem.reviewedAt | date:'yyyy-MM-dd HH:mm' }}
<div *ngIf="dataItem.reviewNotes" class="review-reason">{{ dataItem.reviewNotes }}</div>
</div>
</div>
<div class="flex flex-wrap gap-2 pt-1">
<button *ngIf="canEdit(dataItem)" kendoButton size="small" (click)="openEdit(dataItem)">Edit</button>
<button *ngIf="canApproveOrReject(dataItem)" kendoButton size="small" themeColor="primary"
(click)="openReview(dataItem)">Review</button>
<button *ngIf="canPay(dataItem)" kendoButton size="small" themeColor="primary"
(click)="openPay(dataItem)">Pay</button>
<button *ngIf="dataItem.hasReceipt" kendoButton size="small" fillMode="outline"
(click)="openReceipt(dataItem.id)">Receipt</button>
</div>
</div>
<div *ngIf="!loading && rows.length === 0" class="text-center text-gray-500 py-6">No expenses / 無支出資料</div>
<div *ngIf="rows.length > 0" class="flex items-center justify-between gap-2 pt-1">
<button kendoButton size="small" [disabled]="page <= 1" (click)="prevPage()"> Prev</button>
<span class="text-sm text-gray-500">{{ page }} / {{ totalPages }}</span>
<button kendoButton size="small" [disabled]="page >= totalPages" (click)="nextPage()">Next </button>
</div>
</div>
<!-- Vendor Payment dialog -->
<app-expense-form-dialog *ngIf="vendorDialogOpen" mode="vendor" title="Vendor Payment" (save)="onVendorSave($event)"
@@ -1,12 +1,13 @@
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, PageChangeEvent } from '@progress/kendo-angular-grid';
import { GridModule, PageChangeEvent, CellClickEvent } 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 { ContextMenuModule, ContextMenuComponent, ContextMenuSelectEvent } from '@progress/kendo-angular-menu';
import { EXPENSE_STATUS_OPTIONS } from '../../../../shared/i18n/option-lists';
import { ExpenseApiService, ExpenseQuery } from '../../services/expense-api.service';
import { MinistryApiService } from '../../services/ministry-api.service';
@@ -20,8 +21,8 @@ import { switchMap, of } from 'rxjs';
standalone: true,
imports: [
CommonModule, FormsModule, GridModule, ButtonsModule, DropDownsModule,
InputsModule, DialogsModule, DateInputsModule, ExpenseFormDialogComponent,
ExpenseReviewDialogComponent,
InputsModule, DialogsModule, DateInputsModule, ContextMenuModule,
ExpenseFormDialogComponent, ExpenseReviewDialogComponent,
],
templateUrl: './expenses-page.component.html',
styleUrls: ['./expenses-page.component.scss'],
@@ -51,6 +52,11 @@ export class ExpensesPageComponent implements OnInit {
/** Row whose detail+receipt are open in the review dialog for an approve/reject decision. */
reviewRow: ExpenseListItemDto | null = null;
/** Right-click row-action menu: items are rebuilt per row from what that row currently allows. */
@ViewChild('rowMenu') rowMenu!: ContextMenuComponent;
rowMenuItems: { text: string }[] = [];
private contextRow: ExpenseListItemDto | null = null;
/** Transient confirmation pill, used so the user gets feedback during continuous entry. */
toast: string | null = null;
private toastTimer?: ReturnType<typeof setTimeout>;
@@ -79,6 +85,52 @@ export class ExpensesPageComponent implements OnInit {
this.load();
}
// ── Mobile pager (the Kendo grid pager is desktop-only) ───────────────────────
get totalPages(): number { return Math.max(1, Math.ceil(this.total / this.pageSize)); }
prevPage(): void {
if (this.page <= 1) return;
this.page--;
this.load();
}
nextPage(): void {
if (this.page >= this.totalPages) return;
this.page++;
this.load();
}
// ── Row interaction: right-click opens the per-row action menu ────────────────
onCellClick(event: CellClickEvent): void {
if (event.type !== 'contextmenu') return;
event.originalEvent.preventDefault();
const items = this.buildMenuItems(event.dataItem);
if (items.length === 0) return;
this.contextRow = event.dataItem;
this.rowMenuItems = items;
this.rowMenu.show({ left: event.originalEvent.pageX, top: event.originalEvent.pageY });
}
onRowMenuSelect(event: ContextMenuSelectEvent): void {
const row = this.contextRow;
if (!row) return;
switch (event.item.text) {
case 'Edit': this.openEdit(row); break;
case 'Review': this.openReview(row); break;
case 'Pay': this.openPay(row); break;
case 'Receipt': this.openReceipt(row.id); break;
}
}
private buildMenuItems(row: ExpenseListItemDto): { text: string }[] {
const items: { text: string }[] = [];
if (this.canEdit(row)) items.push({ text: 'Edit' });
if (this.canApproveOrReject(row)) items.push({ text: 'Review' });
if (this.canPay(row)) items.push({ text: 'Pay' });
if (row.hasReceipt) items.push({ text: 'Receipt' });
return items;
}
onVendorSave(result: ExpenseFormResult): void {
this.api.create(result.request).subscribe(() => { this.vendorDialogOpen = false; this.load(); });
}