@@ -30,20 +30,24 @@
|
|||||||
</div>
|
</div>
|
||||||
</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"
|
<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="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="ministryName" title="Ministry" [width]="280"></kendo-grid-column>
|
<kendo-grid-column title="Ministry / Category" [width]="240">
|
||||||
|
|
||||||
<kendo-grid-column title="Category" [width]="360">
|
|
||||||
<ng-template kendoGridCellTemplate let-dataItem>
|
<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>
|
</ng-template>
|
||||||
</kendo-grid-column>
|
</kendo-grid-column>
|
||||||
|
|
||||||
@@ -81,19 +85,75 @@
|
|||||||
</ng-template>
|
</ng-template>
|
||||||
</kendo-grid-column>
|
</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-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 -->
|
<!-- 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)"
|
||||||
|
|||||||
@@ -1,12 +1,13 @@
|
|||||||
import { Component, OnInit } from '@angular/core';
|
import { Component, OnInit, ViewChild } from '@angular/core';
|
||||||
import { CommonModule } from '@angular/common';
|
import { CommonModule } from '@angular/common';
|
||||||
import { FormsModule } from '@angular/forms';
|
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 { ButtonsModule } from '@progress/kendo-angular-buttons';
|
||||||
import { DropDownsModule } from '@progress/kendo-angular-dropdowns';
|
import { DropDownsModule } from '@progress/kendo-angular-dropdowns';
|
||||||
import { InputsModule } from '@progress/kendo-angular-inputs';
|
import { InputsModule } from '@progress/kendo-angular-inputs';
|
||||||
import { DialogsModule } from '@progress/kendo-angular-dialog';
|
import { DialogsModule } from '@progress/kendo-angular-dialog';
|
||||||
import { DateInputsModule } from '@progress/kendo-angular-dateinputs';
|
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 { EXPENSE_STATUS_OPTIONS } from '../../../../shared/i18n/option-lists';
|
||||||
import { ExpenseApiService, ExpenseQuery } from '../../services/expense-api.service';
|
import { ExpenseApiService, ExpenseQuery } from '../../services/expense-api.service';
|
||||||
import { MinistryApiService } from '../../services/ministry-api.service';
|
import { MinistryApiService } from '../../services/ministry-api.service';
|
||||||
@@ -20,8 +21,8 @@ import { switchMap, of } from 'rxjs';
|
|||||||
standalone: true,
|
standalone: true,
|
||||||
imports: [
|
imports: [
|
||||||
CommonModule, FormsModule, GridModule, ButtonsModule, DropDownsModule,
|
CommonModule, FormsModule, GridModule, ButtonsModule, DropDownsModule,
|
||||||
InputsModule, DialogsModule, DateInputsModule, ExpenseFormDialogComponent,
|
InputsModule, DialogsModule, DateInputsModule, ContextMenuModule,
|
||||||
ExpenseReviewDialogComponent,
|
ExpenseFormDialogComponent, ExpenseReviewDialogComponent,
|
||||||
],
|
],
|
||||||
templateUrl: './expenses-page.component.html',
|
templateUrl: './expenses-page.component.html',
|
||||||
styleUrls: ['./expenses-page.component.scss'],
|
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. */
|
/** Row whose detail+receipt are open in the review dialog for an approve/reject decision. */
|
||||||
reviewRow: ExpenseListItemDto | null = null;
|
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. */
|
/** Transient confirmation pill, used so the user gets feedback during continuous entry. */
|
||||||
toast: string | null = null;
|
toast: string | null = null;
|
||||||
private toastTimer?: ReturnType<typeof setTimeout>;
|
private toastTimer?: ReturnType<typeof setTimeout>;
|
||||||
@@ -79,6 +85,52 @@ export class ExpensesPageComponent implements OnInit {
|
|||||||
this.load();
|
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 {
|
onVendorSave(result: ExpenseFormResult): void {
|
||||||
this.api.create(result.request).subscribe(() => { this.vendorDialogOpen = false; this.load(); });
|
this.api.create(result.request).subscribe(() => { this.vendorDialogOpen = false; this.load(); });
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user