update detail.
ci-cd-vm / ci-cd (push) Successful in 4m27s

This commit is contained in:
Chris Chen
2026-06-25 09:33:49 -07:00
parent 609ce6a439
commit 8bdb942a49
23 changed files with 698 additions and 271 deletions
@@ -1,128 +1,147 @@
<kendo-dialog [title]="title" (close)="cancel.emit()" [width]="560" [maxWidth]="'95vw'" [maxHeight]="'90vh'">
<div class="grid grid-cols-1 md:grid-cols-2 gap-x-4 gap-y-3">
<kendo-dialog [title]="title" (close)="cancel.emit()" [width]="showReceiptPanel ? 1200 : 760" [maxWidth]="'95vw'" [maxHeight]="'90vh'">
<!-- Two columns on desktop: form on the left, receipt preview on the right. Stacks on mobile. -->
<div class="flex flex-col gap-4 md:flex-row">
<!-- Continuous entry: keep member/ministry/category/date after each save (on-behalf reimbursement only) -->
<label *ngIf="showContinueEntry" class="flex items-center gap-2 md:col-span-2">
<kendo-switch [(ngModel)]="continueEntry"></kendo-switch>
<span>連續登打 / Continuous Entry</span>
</label>
<div class="flex-1 min-w-0 grid grid-cols-1 md:grid-cols-2 gap-x-4 gap-y-3">
<!-- Member picker (finance creating on behalf of a member) -->
<label *ngIf="allowMemberPick" class="flex flex-col gap-1 md:col-span-2">Member
<kendo-dropdownlist
[data]="memberResults"
textField="displayName"
valueField="id"
[valuePrimitive]="true"
[filterable]="true"
(filterChange)="onMemberFilter($event)"
[(ngModel)]="form.memberId"
<!-- Continuous entry: keep member/ministry/category/date after each save (on-behalf reimbursement only) -->
<label *ngIf="showContinueEntry" class="flex items-center gap-2 md:col-span-2">
<kendo-switch [(ngModel)]="continueEntry"></kendo-switch>
<span>連續登打 / Continuous Entry</span>
</label>
<!-- Description -->
<label class="flex flex-col gap-1 md:col-span-2">Description
<kendo-textbox [(ngModel)]="form.description" placeholder="Brief description of expense"></kendo-textbox>
</label>
<!-- Member picker (finance creating on behalf of a member) -->
<label *ngIf="allowMemberPick" class="flex flex-col gap-1 md:col-span-2">Member
<kendo-dropdownlist [data]="memberResults" textField="displayName" valueField="id" [valuePrimitive]="true"
[filterable]="true" (filterChange)="onMemberFilter($event)" [(ngModel)]="form.memberId"
placeholder="Search member by name">
</kendo-dropdownlist>
</label>
<!-- Ministry -->
<label class="flex flex-col gap-1">Ministry
<kendo-dropdownlist
[data]="ministries"
textField="label"
valueField="id"
[valuePrimitive]="true"
[(ngModel)]="form.ministryId"
[defaultItem]="{ id: null, label: '-- Select ministry --/請選擇事工' }">
</kendo-dropdownlist>
</label>
<!-- Category Group -->
<label class="flex flex-col gap-1">Category Group
<kendo-dropdownlist
[data]="groups"
textField="label"
valueField="id"
[valuePrimitive]="true"
[(ngModel)]="form.categoryGroupId"
(valueChange)="onGroupChange($event)"
[defaultItem]="{ id: null, label: '-- Select group --/請選擇大類' }">
</kendo-dropdownlist>
</label>
<!-- Sub-Category -->
<label class="flex flex-col gap-1">Sub-Category
<kendo-dropdownlist
[data]="subs"
textField="label"
valueField="id"
[valuePrimitive]="true"
[(ngModel)]="form.subCategoryId"
[defaultItem]="{ id: null, label: '-- Select sub-category --/請選擇子項' }"
[disabled]="!form.categoryGroupId">
</kendo-dropdownlist>
</label>
<!-- Functional Class override -->
<label class="flex flex-col gap-1">
<span>Functional Class / 功能別</span>
<kendo-dropdownlist
[data]="functionalClassOptions"
textField="label"
valueField="value"
[valuePrimitive]="true"
[defaultItem]="{ value: null, label: '(Inherit ministry / 沿用事工)' }"
[(ngModel)]="form.functionalClass">
</kendo-dropdownlist>
</label>
<!-- Amount -->
<label class="flex flex-col gap-1">Amount
<kendo-numerictextbox
[(ngModel)]="form.amount"
[min]="0"
[format]="'c2'">
</kendo-numerictextbox>
</label>
<!-- Expense Date -->
<label class="flex flex-col gap-1">Expense Date
<kendo-datepicker [(ngModel)]="form.expenseDate"></kendo-datepicker>
</label>
<!-- Description -->
<label class="flex flex-col gap-1 md:col-span-2">Description
<kendo-textbox [(ngModel)]="form.description" placeholder="Brief description of expense"></kendo-textbox>
</label>
<!-- Vendor mode: vendor name + check number -->
<ng-container *ngIf="mode === 'vendor'">
<label class="flex flex-col gap-1">Vendor Name
<kendo-textbox [(ngModel)]="form.vendorName" placeholder="Payee / vendor name"></kendo-textbox>
</kendo-dropdownlist>
</label>
<label class="flex flex-col gap-1">Check #
<kendo-textbox [(ngModel)]="form.checkNumber" placeholder="Check number (optional)"></kendo-textbox>
</label>
</ng-container>
<!-- Reimbursement mode: receipt file input -->
<ng-container *ngIf="mode === 'reimbursement'">
<label class="flex flex-col gap-1 md:col-span-2">Receipt (optional)
<!--
<!-- Ministry -->
<label class="flex flex-col gap-1">Ministry
<kendo-dropdownlist [data]="ministries" textField="label" valueField="id" [valuePrimitive]="true"
[(ngModel)]="form.ministryId" [defaultItem]="{ id: null, label: '-- Select ministry --/請選擇事工' }">
</kendo-dropdownlist>
</label>
<!-- Expense Date -->
<label class="flex flex-col gap-1">Expense Date
<kendo-datepicker [(ngModel)]="form.expenseDate"></kendo-datepicker>
</label>
<!-- Category lines: one invoice can span several categories -->
<div class="md:col-span-2 flex flex-col gap-2">
<div class="flex items-center justify-between">
<span class="font-semibold">明細 / Line Items</span>
<span class="text-sm text-gray-600">合計 / Total: {{ totalAmount | currency }}</span>
</div>
<div *ngFor="let line of lines; let i = index" class="flex flex-col gap-3 rounded border border-gray-200 p-3">
<!-- Line header: number + remove -->
<div class="flex items-center justify-between">
<span class="text-sm font-medium text-gray-600">明細 {{ i + 1 }} / Item {{ i + 1 }}</span>
<button kendoButton fillMode="flat" themeColor="error" size="small" [disabled]="lines.length === 1"
(click)="removeLine(i)" title="Remove line / 刪除此列">✕ 刪除</button>
</div>
<!-- Row 1: Category Group + Sub-Category -->
<div class="grid grid-cols-1 md:grid-cols-2 gap-3">
<label class="flex flex-col gap-1">Category Group
<kendo-dropdownlist [data]="groups" textField="label" valueField="id" [valuePrimitive]="true"
[(ngModel)]="line.categoryGroupId" (valueChange)="onLineGroupChange(line, $event)"
[defaultItem]="{ id: null, label: '-- Select group --/請選擇大類' }">
</kendo-dropdownlist>
</label>
<label class="flex flex-col gap-1">Sub-Category
<kendo-dropdownlist [data]="line.subs" textField="label" valueField="id" [valuePrimitive]="true"
[(ngModel)]="line.subCategoryId" [defaultItem]="{ id: null, label: '-- Select sub-category --/請選擇子項' }"
[disabled]="!line.categoryGroupId">
</kendo-dropdownlist>
</label>
</div>
<!-- Row 2: Description (optional) + Amount -->
<div class="grid grid-cols-1 md:grid-cols-2 gap-3">
<label class="flex flex-col gap-1">Description / 說明 <span class="text-gray-400">(Optional)</span>
<kendo-textbox [(ngModel)]="line.description" placeholder="e.g. 點心、文具…"></kendo-textbox>
</label>
<label class="flex flex-col gap-1">Amount
<kendo-numerictextbox [(ngModel)]="line.amount" [min]="0" [format]="'c2'"></kendo-numerictextbox>
</label>
</div>
</div>
<div>
<button kendoButton fillMode="outline" (click)="addLine()">+ 新增一列 / Add Line</button>
</div>
</div>
<!-- Vendor mode: vendor name + check number -->
<ng-container *ngIf="mode === 'vendor'">
<label class="flex flex-col gap-1">Vendor Name
<kendo-textbox [(ngModel)]="form.vendorName" placeholder="Payee / vendor name"></kendo-textbox>
</label>
<label class="flex flex-col gap-1">Check #
<kendo-textbox [(ngModel)]="form.checkNumber" placeholder="Check number (optional)"></kendo-textbox>
</label>
</ng-container>
<!-- Reimbursement mode: receipt file input -->
<ng-container *ngIf="mode === 'reimbursement'">
<label class="flex flex-col gap-1 md:col-span-2">Receipt (optional)
<!--
Stop the native 'cancel' DOM event (fired when the OS file picker is dismissed)
from bubbling up to the host, where it would collide with this component's
@Output() cancel and wrongly close the dialog. See Angular issues #50556 / #13997.
-->
<input
#receiptInput
type="file"
accept="image/*,application/pdf"
(change)="onFileSelected($event)"
<input #receiptInput type="file" accept="image/*,application/pdf" (change)="onFileSelected($event)"
(cancel)="$event.stopPropagation()"
class="block w-full text-sm text-gray-700 file:mr-3 file:py-1 file:px-3 file:rounded file:border-0 file:text-sm file:bg-gray-100 hover:file:bg-gray-200" />
</label>
</ng-container>
</label>
</ng-container>
</div>
<!-- /left form column -->
<!-- Right: receipt preview (shown once a file is selected or an existing receipt is loaded) -->
<div *ngIf="showReceiptPanel" class="md:w-[34rem] md:shrink-0 md:border-l md:pl-4 flex flex-col gap-2">
<div class="flex items-center justify-between">
<span class="font-semibold">收據預覽 / Receipt</span>
<!-- Zoom controls (image only; PDF uses the browser viewer's own zoom) -->
<div *ngIf="receiptImageUrl" class="flex items-center gap-1">
<button kendoButton size="small" fillMode="flat" (click)="zoomOut()"
[disabled]="receiptZoom <= minZoom" title="縮小 / Zoom out"></button>
<span class="w-12 text-center text-sm tabular-nums">{{ receiptZoom * 100 | number:'1.0-0' }}%</span>
<button kendoButton size="small" fillMode="flat" (click)="zoomIn()"
[disabled]="receiptZoom >= maxZoom" title="放大 / Zoom in"></button>
<button kendoButton size="small" fillMode="flat" (click)="resetZoom()" title="重設 / Reset"></button>
</div>
</div>
<!-- Scrollable so a zoomed-in image can be panned for comparison -->
<div *ngIf="receiptImageUrl" class="overflow-auto rounded border border-gray-200 bg-gray-50"
style="max-height: 72vh;">
<img [src]="receiptImageUrl" alt="Receipt preview" [style.width.%]="receiptZoom * 100"
class="block max-w-none" />
</div>
<iframe *ngIf="receiptPdfUrl" [src]="receiptPdfUrl" title="Receipt PDF"
class="w-full rounded border border-gray-200" style="height: 72vh;"></iframe>
</div>
</div>
<!-- /two-column body -->
<kendo-dialog-actions>
<button kendoButton (click)="cancel.emit()">Cancel</button>
<button kendoButton themeColor="primary" [disabled]="!isValid" (click)="emitSave()">Save</button>
</kendo-dialog-actions>
</kendo-dialog>
</kendo-dialog>
@@ -1,6 +1,7 @@
import { Component, ElementRef, EventEmitter, Input, OnInit, Output, ViewChild } from '@angular/core';
import { Component, ElementRef, EventEmitter, Input, OnDestroy, OnInit, Output, ViewChild } from '@angular/core';
import { CommonModule } from '@angular/common';
import { FormsModule } from '@angular/forms';
import { DomSanitizer, SafeResourceUrl } from '@angular/platform-browser';
import { InputsModule } from '@progress/kendo-angular-inputs';
import { ButtonsModule } from '@progress/kendo-angular-buttons';
import { DialogsModule } from '@progress/kendo-angular-dialog';
@@ -8,11 +9,12 @@ import { DropDownsModule } from '@progress/kendo-angular-dropdowns';
import { DateInputsModule } from '@progress/kendo-angular-dateinputs';
import { MinistryApiService } from '../../services/ministry-api.service';
import { ExpenseCategoryApiService } from '../../services/expense-category-api.service';
import { ExpenseApiService } from '../../services/expense-api.service';
import { MemberApiService } from '../../../members/services/member-api.service';
import { MemberListItemDto, memberDisplayName } from '../../../members/models/member.model';
import {
MinistryDto, ExpenseCategoryGroupDto, ExpenseSubCategoryDto, ExpenseType, CreateExpenseRequest,
ExpenseListItemDto, FunctionalClass,
ExpenseDto, FunctionalClass,
} from '../../models/expense.model';
export interface ExpenseFormResult {
@@ -25,18 +27,30 @@ export interface ExpenseFormResult {
/** Flattened member item with a single displayName field for the dropdown. */
interface MemberOption { id: number; displayName: string; }
/** One editable category line. `subs` holds the sub-category list for this row's chosen group. */
interface ExpenseLineForm {
categoryGroupId: number | null;
subCategoryId: number | null;
amount: number;
description: string;
/** Functional class is no longer exposed in the form (too complex for volunteers); it stays
* null = inherit the ministry default. Kept here so existing overrides survive an edit. */
functionalClass: FunctionalClass | null;
subs: ExpenseSubCategoryDto[];
}
@Component({
selector: 'app-expense-form-dialog',
standalone: true,
imports: [CommonModule, FormsModule, InputsModule, ButtonsModule, DialogsModule, DropDownsModule, DateInputsModule],
templateUrl: './expense-form-dialog.component.html',
})
export class ExpenseFormDialogComponent implements OnInit {
export class ExpenseFormDialogComponent implements OnInit, OnDestroy {
@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;
/** When set, the dialog prefills from this expense (with its lines) for editing. */
@Input() expense: ExpenseDto | null = null;
@Output() save = new EventEmitter<ExpenseFormResult>();
@Output() cancel = new EventEmitter<void>();
@@ -45,19 +59,12 @@ export class ExpenseFormDialogComponent implements OnInit {
ministries: MinistryDto[] = [];
groups: ExpenseCategoryGroupDto[] = [];
subs: ExpenseSubCategoryDto[] = [];
memberResults: MemberOption[] = [];
/** Continuous-entry toggle: keep member/ministry/category/date and the dialog open after each save. */
continueEntry = false;
readonly functionalClassOptions: { value: FunctionalClass; label: string }[] = [
{ value: 'Program', label: 'Program / 事工服務' },
{ value: 'ManagementGeneral', label: 'Management & General / 管理' },
{ value: 'Fundraising', label: 'Fundraising / 募款' },
];
/** The on-behalf reimbursement create flow is the only place continuous entry applies. */
get showContinueEntry(): boolean {
return this.mode === 'reimbursement' && this.allowMemberPick && !this.expense;
@@ -65,56 +72,108 @@ export class ExpenseFormDialogComponent implements OnInit {
form = {
ministryId: null as number | null,
categoryGroupId: null as number | null,
subCategoryId: null as number | null,
amount: 0,
description: '',
vendorName: '',
checkNumber: '',
memberId: null as number | null,
expenseDate: new Date(),
functionalClass: null as FunctionalClass | null,
};
/** At least one line always; "+ Add line" appends, each line is independently removable down to one. */
lines: ExpenseLineForm[] = [this.emptyLine()];
receipt: File | null = null;
// ── Receipt preview (right panel) ────────────────────────────────────────
/** Blob URL for an image receipt, bound directly to <img [src]>. */
receiptImageUrl: string | null = null;
/** Sanitized blob URL for a PDF receipt, bound to <iframe [src]>. */
receiptPdfUrl: SafeResourceUrl | null = null;
/** Raw object URL kept so it can be revoked. */
private receiptObjectUrl: string | null = null;
/** Image zoom factor (1 = fit panel width); lets volunteers blow up a receipt to compare. */
receiptZoom = 1;
readonly minZoom = 0.5;
readonly maxZoom = 5;
get showReceiptPanel(): boolean { return !!(this.receiptImageUrl || this.receiptPdfUrl); }
zoomIn(): void { this.receiptZoom = Math.min(this.maxZoom, +(this.receiptZoom + 0.25).toFixed(2)); }
zoomOut(): void { this.receiptZoom = Math.max(this.minZoom, +(this.receiptZoom - 0.25).toFixed(2)); }
resetZoom(): void { this.receiptZoom = 1; }
constructor(
private ministryApi: MinistryApiService,
private catApi: ExpenseCategoryApiService,
private memberApi: MemberApiService,
private expenseApi: ExpenseApiService,
private sanitizer: DomSanitizer,
) {}
ngOnInit(): void {
this.ministryApi.getAll().subscribe(m => (this.ministries = m));
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 ?? [];
}
// Populate each line's sub-category list once the catalog is loaded (edit mode).
if (this.expense) this.hydrateLineSubs();
});
if (this.expense) this.prefill(this.expense);
if (this.expense) {
this.prefill(this.expense);
// Edit mode: load the existing receipt into the preview panel.
if (this.expense.hasReceipt) {
this.expenseApi.downloadReceipt(this.expense.id)
.subscribe(blob => this.setPreview(blob, blob.type));
}
}
}
private prefill(expense: ExpenseListItemDto): void {
ngOnDestroy(): void { this.clearPreview(); }
private emptyLine(): ExpenseLineForm {
return { categoryGroupId: null, subCategoryId: null, amount: 0, description: '', functionalClass: null, subs: [] };
}
private prefill(expense: ExpenseDto): 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),
functionalClass: expense.functionalClass ?? null,
};
this.lines = (expense.lines ?? []).map(l => ({
categoryGroupId: l.categoryGroupId,
subCategoryId: l.subCategoryId,
amount: l.amount,
description: l.description ?? '',
functionalClass: l.functionalClass,
subs: [],
}));
if (this.lines.length === 0) this.lines = [this.emptyLine()];
}
onGroupChange(groupId: number | null): void {
this.form.subCategoryId = null;
this.subs = this.groups.find(g => g.id === groupId)?.subCategories ?? [];
/** Fill each line's sub-category list from its chosen group (used after the catalog loads on edit). */
private hydrateLineSubs(): void {
for (const line of this.lines) {
line.subs = this.groups.find(g => g.id === line.categoryGroupId)?.subCategories ?? [];
}
}
onLineGroupChange(line: ExpenseLineForm, groupId: number | null): void {
line.subCategoryId = null;
line.subs = this.groups.find(g => g.id === groupId)?.subCategories ?? [];
}
addLine(): void { this.lines.push(this.emptyLine()); }
removeLine(index: number): void {
if (this.lines.length > 1) this.lines.splice(index, 1);
}
get totalAmount(): number {
return this.lines.reduce((sum, l) => sum + (l.amount || 0), 0);
}
onMemberFilter(term: string): void {
@@ -130,12 +189,34 @@ export class ExpenseFormDialogComponent implements OnInit {
onFileSelected(event: Event): void {
const input = event.target as HTMLInputElement;
this.receipt = input.files?.[0] ?? null;
const file = input.files?.[0] ?? null;
this.receipt = file;
if (file) this.setPreview(file, file.type);
}
/** Show a newly-selected file or a fetched existing receipt in the right-hand preview panel. */
private setPreview(blob: Blob, contentType: string): void {
this.clearPreview();
this.receiptZoom = 1;
this.receiptObjectUrl = URL.createObjectURL(blob);
if (contentType.startsWith('image/')) {
this.receiptImageUrl = this.receiptObjectUrl;
} else {
this.receiptPdfUrl = this.sanitizer.bypassSecurityTrustResourceUrl(this.receiptObjectUrl);
}
}
private clearPreview(): void {
if (this.receiptObjectUrl) { URL.revokeObjectURL(this.receiptObjectUrl); this.receiptObjectUrl = null; }
this.receiptImageUrl = null;
this.receiptPdfUrl = null;
}
get isValid(): boolean {
return !!this.form.ministryId && !!this.form.categoryGroupId && !!this.form.subCategoryId
&& this.form.amount > 0 && this.form.description.trim().length > 0;
return !!this.form.ministryId
&& this.form.description.trim().length > 0
&& this.lines.length > 0
&& this.lines.every(l => !!l.categoryGroupId && !!l.subCategoryId && l.amount > 0);
}
emitSave(): void {
@@ -145,16 +226,19 @@ export class ExpenseFormDialogComponent implements OnInit {
const request: CreateExpenseRequest = {
type: (this.mode === 'vendor' ? 'VendorPayment' : 'StaffReimbursement') as ExpenseType,
ministryId: this.form.ministryId!,
categoryGroupId: this.form.categoryGroupId!,
subCategoryId: this.form.subCategoryId!,
amount: this.form.amount,
lines: this.lines.map(l => ({
categoryGroupId: l.categoryGroupId!,
subCategoryId: l.subCategoryId!,
amount: l.amount,
functionalClass: l.functionalClass,
description: l.description.trim() || null,
})),
description: this.form.description.trim(),
vendorName: this.mode === 'vendor' ? (this.form.vendorName || null) : null,
memberId: this.allowMemberPick ? this.form.memberId : null,
checkNumber: this.mode === 'vendor' ? (this.form.checkNumber || null) : null,
expenseDate,
notes: null,
functionalClass: this.form.functionalClass,
};
// The request and receipt are snapshotted here, so resetting the form right
// after emitting is safe even though the parent saves asynchronously.
@@ -163,14 +247,14 @@ export class ExpenseFormDialogComponent implements OnInit {
}
/**
* Clear only the per-entry fields, keeping Member, Ministry, Category Group,
* Sub-Category and Expense Date (plus the loaded sub-category list) so the
* user can immediately log the next reimbursement.
* Clear only the per-entry fields, keeping Member, Ministry and Expense Date so the
* user can immediately log the next reimbursement. Lines reset to a single blank row.
*/
private resetForNext(): void {
this.form.amount = 0;
this.lines = [this.emptyLine()];
this.form.description = '';
this.receipt = null;
this.clearPreview();
if (this.receiptInput) this.receiptInput.nativeElement.value = '';
}
}
@@ -15,21 +15,31 @@ export interface UpdateExpenseGroupRequest extends CreateExpenseGroupRequest { i
export interface CreateExpenseSubCategoryRequest { groupId: number; name_en: string; name_zh: string | null; sortOrder: number; form990LineId: number | null; }
export interface UpdateExpenseSubCategoryRequest extends CreateExpenseSubCategoryRequest { isActive: boolean; }
export interface ExpenseLineItemDto {
id: number; categoryGroupId: number; categoryGroupName: string;
subCategoryId: number; subCategoryName: string;
functionalClass: FunctionalClass | null; amount: number; description: string | null;
}
export interface ExpenseListItemDto {
id: number; type: ExpenseType; status: ExpenseStatus; amount: number; description: string;
ministryId: number; ministryName: string; categoryGroupId: number; categoryGroupName: string;
subCategoryId: number; subCategoryName: string; vendorName: string | null;
ministryId: number; ministryName: string; lineCount: number; primaryCategoryName: string;
vendorName: string | null;
memberId: number | null; memberName: string | null; expenseDate: string; hasReceipt: boolean;
checkNumber: string | null; functionalClass: FunctionalClass | null;
checkNumber: string | null;
}
export interface ExpenseDto extends ExpenseListItemDto {
notes: string | null; reviewNotes: string | null;
submittedBy: string | null; submittedAt: string | null; reviewedAt: string | null; paidAt: string | null;
lines: ExpenseLineItemDto[];
}
export interface ExpenseLineInput {
categoryGroupId: number; subCategoryId: number; amount: number;
functionalClass: FunctionalClass | null; description: string | null;
}
export interface CreateExpenseRequest {
type: ExpenseType; ministryId: number; categoryGroupId: number; subCategoryId: number;
amount: number; description: string; vendorName: string | null; memberId: number | null;
checkNumber: string | null; expenseDate: string; notes: string | null; functionalClass: FunctionalClass | null;
type: ExpenseType; ministryId: number; lines: ExpenseLineInput[];
description: string; vendorName: string | null; memberId: number | null;
checkNumber: string | null; expenseDate: string; notes: string | null;
}
export type UpdateExpenseRequest = CreateExpenseRequest;
export interface RejectExpenseRequest { reviewNotes: string | null; }
@@ -43,7 +43,7 @@
<kendo-grid-column title="Category" [width]="360">
<ng-template kendoGridCellTemplate let-dataItem>
{{ dataItem.categoryGroupName }} / {{ dataItem.subCategoryName }}
{{ dataItem.primaryCategoryName }}<span *ngIf="dataItem.lineCount > 1" class="text-gray-500"> +{{ dataItem.lineCount - 1 }}</span>
</ng-template>
</kendo-grid-column>
@@ -11,7 +11,7 @@ import { EXPENSE_STATUS_OPTIONS } from '../../../../shared/i18n/option-lists';
import { ExpenseApiService, ExpenseQuery } from '../../services/expense-api.service';
import { MinistryApiService } from '../../services/ministry-api.service';
import { ExpenseFormDialogComponent, ExpenseFormResult } from '../../components/expense-form-dialog/expense-form-dialog.component';
import { ExpenseListItemDto, MinistryDto } from '../../models/expense.model';
import { ExpenseDto, ExpenseListItemDto, MinistryDto } from '../../models/expense.model';
import { switchMap, of } from 'rxjs';
@Component({
@@ -39,7 +39,7 @@ export class ExpensesPageComponent implements OnInit {
vendorDialogOpen = false;
reimbDialogOpen = false;
editRow: ExpenseListItemDto | null = null;
editRow: ExpenseDto | null = null;
editMode: 'vendor' | 'reimbursement' = 'reimbursement';
payRow: ExpenseListItemDto | null = null;
@@ -95,8 +95,9 @@ export class ExpensesPageComponent implements OnInit {
}
openEdit(row: ExpenseListItemDto): void {
this.editRow = row;
// Fetch the full expense (with its lines) before opening the dialog for editing.
this.editMode = row.type === 'VendorPayment' ? 'vendor' : 'reimbursement';
this.api.getById(row.id).subscribe(dto => (this.editRow = dto));
}
closeEdit(): void { this.editRow = null; }
@@ -11,7 +11,7 @@
<kendo-grid-column field="ministryName" title="Ministry" [width]="140"></kendo-grid-column>
<kendo-grid-column title="Category">
<ng-template kendoGridCellTemplate let-dataItem>
{{ dataItem.categoryGroupName }} / {{ dataItem.subCategoryName }}
{{ dataItem.primaryCategoryName }}<span *ngIf="dataItem.lineCount > 1" class="text-gray-500"> +{{ dataItem.lineCount - 1 }}</span>
</ng-template>
</kendo-grid-column>
<kendo-grid-column field="amount" title="Amount" [width]="110" format="c2"></kendo-grid-column>
@@ -52,7 +52,7 @@
</div>
<div>
<dt>Category</dt>
<dd>{{ row.categoryGroupName }} / {{ row.subCategoryName }}</dd>
<dd>{{ row.primaryCategoryName }}<span *ngIf="row.lineCount > 1"> +{{ row.lineCount - 1 }}</span></dd>
</div>
</dl>
@@ -4,7 +4,7 @@ import { GridModule } from '@progress/kendo-angular-grid';
import { ButtonsModule } from '@progress/kendo-angular-buttons';
import { ExpenseApiService } from '../../services/expense-api.service';
import { ExpenseFormDialogComponent, ExpenseFormResult } from '../../components/expense-form-dialog/expense-form-dialog.component';
import { ExpenseListItemDto } from '../../models/expense.model';
import { ExpenseDto, ExpenseListItemDto } from '../../models/expense.model';
import { switchMap, of } from 'rxjs';
import { PageHeaderActionsDirective } from '../../../../shared/directives/page-header-actions.directive';
@@ -19,7 +19,7 @@ export class MyReimbursementsPageComponent implements OnInit {
rows: ExpenseListItemDto[] = [];
loading = false;
dialogOpen = false;
editRow: ExpenseListItemDto | null = null;
editRow: ExpenseDto | null = null;
constructor(private api: ExpenseApiService) {}
@@ -34,7 +34,10 @@ export class MyReimbursementsPageComponent implements OnInit {
}
openNew(): void { this.editRow = null; this.dialogOpen = true; }
openEdit(row: ExpenseListItemDto): void { this.editRow = row; this.dialogOpen = true; }
openEdit(row: ExpenseListItemDto): void {
// Fetch the full expense (with its lines) before opening the dialog for editing.
this.api.getById(row.id).subscribe(dto => { this.editRow = dto; this.dialogOpen = true; });
}
closeDialog(): void { this.dialogOpen = false; this.editRow = null; }
onSave(result: ExpenseFormResult): void {