feat(expense-snapshot): load/save snapshot in vendor payment form
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
+29
@@ -5,6 +5,19 @@
|
|||||||
|
|
||||||
<div class="flex-1 min-w-0 grid grid-cols-1 md:grid-cols-2 gap-x-4 gap-y-3">
|
<div class="flex-1 min-w-0 grid grid-cols-1 md:grid-cols-2 gap-x-4 gap-y-3">
|
||||||
|
|
||||||
|
<!-- Snapshot tools (vendor mode): quick-load a saved template, or save the current form -->
|
||||||
|
<div *ngIf="showSnapshotTools" class="md:col-span-2 flex flex-wrap items-end gap-2 rounded border border-gray-200 bg-gray-50 p-2">
|
||||||
|
<label class="flex flex-1 min-w-[14rem] flex-col gap-1">範本 / Load from snapshot
|
||||||
|
<kendo-dropdownlist [data]="snapshots" textField="name" valueField="id" [valuePrimitive]="true"
|
||||||
|
[(ngModel)]="selectedSnapshotId" (valueChange)="applySnapshot($event)"
|
||||||
|
[defaultItem]="{ id: null, name: '-- 選擇範本 / Select a saved snapshot --' }">
|
||||||
|
</kendo-dropdownlist>
|
||||||
|
</label>
|
||||||
|
<button kendoButton fillMode="outline" themeColor="primary" type="button"
|
||||||
|
[disabled]="!isValid" (click)="openSnapshotPrompt()"
|
||||||
|
title="Save the current form as a reusable snapshot / 儲存為範本">💾 存為範本 / Save as snapshot</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
<!-- Continuous entry: keep member/ministry/category/date after each save (on-behalf reimbursement only) -->
|
<!-- 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">
|
<label *ngIf="showContinueEntry" class="flex items-center gap-2 md:col-span-2">
|
||||||
<kendo-switch [(ngModel)]="continueEntry"></kendo-switch>
|
<kendo-switch [(ngModel)]="continueEntry"></kendo-switch>
|
||||||
@@ -210,4 +223,20 @@
|
|||||||
<button kendoButton (click)="cancel.emit()">Cancel</button>
|
<button kendoButton (click)="cancel.emit()">Cancel</button>
|
||||||
<button kendoButton themeColor="primary" [disabled]="!isValid" (click)="emitSave()">Save</button>
|
<button kendoButton themeColor="primary" [disabled]="!isValid" (click)="emitSave()">Save</button>
|
||||||
</kendo-dialog-actions>
|
</kendo-dialog-actions>
|
||||||
|
</kendo-dialog>
|
||||||
|
|
||||||
|
<!-- Save-as-snapshot name prompt -->
|
||||||
|
<kendo-dialog *ngIf="showSnapshotNamePrompt" title="存為範本 / Save as Snapshot" [width]="420" [maxWidth]="'95vw'"
|
||||||
|
(close)="cancelSnapshotPrompt()">
|
||||||
|
<div class="flex flex-col gap-2 p-2">
|
||||||
|
<label class="flex flex-col gap-1">名稱 / Name
|
||||||
|
<kendo-textbox [(ngModel)]="snapshotName" placeholder="e.g. Monthly Rent — Landlord X"></kendo-textbox>
|
||||||
|
</label>
|
||||||
|
<p class="text-xs text-gray-500">費用日期不會存入範本 / The Expense Date is not saved in a snapshot.</p>
|
||||||
|
</div>
|
||||||
|
<kendo-dialog-actions>
|
||||||
|
<button kendoButton (click)="cancelSnapshotPrompt()">Cancel</button>
|
||||||
|
<button kendoButton themeColor="primary" [disabled]="!snapshotName.trim() || snapshotSaving"
|
||||||
|
(click)="saveSnapshot()">{{ snapshotSaving ? '儲存中… / Saving…' : 'Save' }}</button>
|
||||||
|
</kendo-dialog-actions>
|
||||||
</kendo-dialog>
|
</kendo-dialog>
|
||||||
+82
@@ -10,6 +10,8 @@ import { DateInputsModule } from '@progress/kendo-angular-dateinputs';
|
|||||||
import { MinistryApiService } from '../../services/ministry-api.service';
|
import { MinistryApiService } from '../../services/ministry-api.service';
|
||||||
import { ExpenseCategoryApiService } from '../../services/expense-category-api.service';
|
import { ExpenseCategoryApiService } from '../../services/expense-category-api.service';
|
||||||
import { ExpenseApiService } from '../../services/expense-api.service';
|
import { ExpenseApiService } from '../../services/expense-api.service';
|
||||||
|
import { ExpenseSnapshotApiService } from '../../services/expense-snapshot-api.service';
|
||||||
|
import { ExpenseSnapshotDto, CreateExpenseSnapshotRequest } from '../../models/expense-snapshot.model';
|
||||||
import { ExpenseAiService } from '../../services/expense-ai.service';
|
import { ExpenseAiService } from '../../services/expense-ai.service';
|
||||||
import { MemberApiService } from '../../../members/services/member-api.service';
|
import { MemberApiService } from '../../../members/services/member-api.service';
|
||||||
import { MemberListItemDto, memberDisplayName } from '../../../members/models/member.model';
|
import { MemberListItemDto, memberDisplayName } from '../../../members/models/member.model';
|
||||||
@@ -64,6 +66,19 @@ export class ExpenseFormDialogComponent implements OnInit, OnDestroy {
|
|||||||
ministries: MinistryDto[] = [];
|
ministries: MinistryDto[] = [];
|
||||||
groups: ExpenseCategoryGroupDto[] = [];
|
groups: ExpenseCategoryGroupDto[] = [];
|
||||||
|
|
||||||
|
/** Saved snapshots (vendor mode only) for the "Load from snapshot" picker. */
|
||||||
|
snapshots: ExpenseSnapshotDto[] = [];
|
||||||
|
/** Picker binding; reset to null after each apply so the same snapshot can be re-picked. */
|
||||||
|
selectedSnapshotId: number | null = null;
|
||||||
|
|
||||||
|
/** "Save as snapshot" name-prompt state. */
|
||||||
|
showSnapshotNamePrompt = false;
|
||||||
|
snapshotName = '';
|
||||||
|
snapshotSaving = false;
|
||||||
|
|
||||||
|
/** Snapshot tools (load/save) are a vendor-payment feature only. */
|
||||||
|
get showSnapshotTools(): boolean { return this.mode === 'vendor'; }
|
||||||
|
|
||||||
memberResults: MemberOption[] = [];
|
memberResults: MemberOption[] = [];
|
||||||
|
|
||||||
/** Continuous-entry toggle: keep member/ministry/category/date and the dialog open after each save. */
|
/** Continuous-entry toggle: keep member/ministry/category/date and the dialog open after each save. */
|
||||||
@@ -116,12 +131,14 @@ export class ExpenseFormDialogComponent implements OnInit, OnDestroy {
|
|||||||
private catApi: ExpenseCategoryApiService,
|
private catApi: ExpenseCategoryApiService,
|
||||||
private memberApi: MemberApiService,
|
private memberApi: MemberApiService,
|
||||||
private expenseApi: ExpenseApiService,
|
private expenseApi: ExpenseApiService,
|
||||||
|
private snapshotApi: ExpenseSnapshotApiService,
|
||||||
private aiApi: ExpenseAiService,
|
private aiApi: ExpenseAiService,
|
||||||
private sanitizer: DomSanitizer,
|
private sanitizer: DomSanitizer,
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
ngOnInit(): void {
|
ngOnInit(): void {
|
||||||
this.ministryApi.getAll().subscribe(m => (this.ministries = m));
|
this.ministryApi.getAll().subscribe(m => (this.ministries = m));
|
||||||
|
if (this.showSnapshotTools) this.loadSnapshots();
|
||||||
this.catApi.getAll(false).subscribe(groups => {
|
this.catApi.getAll(false).subscribe(groups => {
|
||||||
this.groups = groups;
|
this.groups = groups;
|
||||||
// Populate each line's sub-category list once the catalog is loaded (edit mode).
|
// Populate each line's sub-category list once the catalog is loaded (edit mode).
|
||||||
@@ -315,6 +332,71 @@ export class ExpenseFormDialogComponent implements OnInit, OnDestroy {
|
|||||||
this.receiptPdfUrl = null;
|
this.receiptPdfUrl = null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private loadSnapshots(): void {
|
||||||
|
this.snapshotApi.getAll().subscribe(list => (this.snapshots = list));
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Apply a saved snapshot: prefill header + lines, but keep today's Expense Date. */
|
||||||
|
applySnapshot(snapshotId: number | null): void {
|
||||||
|
if (snapshotId == null) return;
|
||||||
|
this.snapshotApi.getById(snapshotId).subscribe(s => {
|
||||||
|
this.form.ministryId = s.ministryId;
|
||||||
|
this.form.description = s.description;
|
||||||
|
this.form.vendorName = s.vendorName ?? '';
|
||||||
|
this.form.checkNumber = s.checkNumber ?? '';
|
||||||
|
// Expense Date is intentionally NOT taken from the snapshot — leave it as-is (today).
|
||||||
|
this.lines = s.lines.map(line => ({
|
||||||
|
categoryGroupId: line.categoryGroupId,
|
||||||
|
subCategoryId: line.subCategoryId,
|
||||||
|
amount: line.amount,
|
||||||
|
description: line.description ?? '',
|
||||||
|
functionalClass: line.functionalClass,
|
||||||
|
subs: this.groups.find(g => g.id === line.categoryGroupId)?.subCategories ?? [],
|
||||||
|
}));
|
||||||
|
if (this.lines.length === 0) this.lines = [this.emptyLine()];
|
||||||
|
this.selectedSnapshotId = null;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Open the name prompt for saving the current form as a snapshot (requires a valid form). */
|
||||||
|
openSnapshotPrompt(): void {
|
||||||
|
if (!this.isValid) return;
|
||||||
|
this.snapshotName = '';
|
||||||
|
this.showSnapshotNamePrompt = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
cancelSnapshotPrompt(): void { this.showSnapshotNamePrompt = false; }
|
||||||
|
|
||||||
|
/** Save the current header + lines as a named snapshot (Expense Date is not stored). */
|
||||||
|
saveSnapshot(): void {
|
||||||
|
const name = this.snapshotName.trim();
|
||||||
|
if (!name || this.snapshotSaving) return;
|
||||||
|
const request: CreateExpenseSnapshotRequest = {
|
||||||
|
name,
|
||||||
|
ministryId: this.form.ministryId!,
|
||||||
|
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.form.vendorName || null,
|
||||||
|
checkNumber: this.form.checkNumber || null,
|
||||||
|
notes: null,
|
||||||
|
};
|
||||||
|
this.snapshotSaving = true;
|
||||||
|
this.snapshotApi.create(request).subscribe({
|
||||||
|
next: () => {
|
||||||
|
this.snapshotSaving = false;
|
||||||
|
this.showSnapshotNamePrompt = false;
|
||||||
|
this.loadSnapshots();
|
||||||
|
},
|
||||||
|
error: () => { this.snapshotSaving = false; },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
get isValid(): boolean {
|
get isValid(): boolean {
|
||||||
return !!this.form.ministryId
|
return !!this.form.ministryId
|
||||||
&& this.form.description.trim().length > 0
|
&& this.form.description.trim().length > 0
|
||||||
|
|||||||
Reference in New Issue
Block a user