@@ -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(); });
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user