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">
|
||||
|
||||
<!-- 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) -->
|
||||
<label *ngIf="showContinueEntry" class="flex items-center gap-2 md:col-span-2">
|
||||
<kendo-switch [(ngModel)]="continueEntry"></kendo-switch>
|
||||
@@ -210,4 +223,20 @@
|
||||
<button kendoButton (click)="cancel.emit()">Cancel</button>
|
||||
<button kendoButton themeColor="primary" [disabled]="!isValid" (click)="emitSave()">Save</button>
|
||||
</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>
|
||||
+82
@@ -10,6 +10,8 @@ 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 { ExpenseSnapshotApiService } from '../../services/expense-snapshot-api.service';
|
||||
import { ExpenseSnapshotDto, CreateExpenseSnapshotRequest } from '../../models/expense-snapshot.model';
|
||||
import { ExpenseAiService } from '../../services/expense-ai.service';
|
||||
import { MemberApiService } from '../../../members/services/member-api.service';
|
||||
import { MemberListItemDto, memberDisplayName } from '../../../members/models/member.model';
|
||||
@@ -64,6 +66,19 @@ export class ExpenseFormDialogComponent implements OnInit, OnDestroy {
|
||||
ministries: MinistryDto[] = [];
|
||||
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[] = [];
|
||||
|
||||
/** 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 memberApi: MemberApiService,
|
||||
private expenseApi: ExpenseApiService,
|
||||
private snapshotApi: ExpenseSnapshotApiService,
|
||||
private aiApi: ExpenseAiService,
|
||||
private sanitizer: DomSanitizer,
|
||||
) {}
|
||||
|
||||
ngOnInit(): void {
|
||||
this.ministryApi.getAll().subscribe(m => (this.ministries = m));
|
||||
if (this.showSnapshotTools) this.loadSnapshots();
|
||||
this.catApi.getAll(false).subscribe(groups => {
|
||||
this.groups = groups;
|
||||
// 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;
|
||||
}
|
||||
|
||||
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 {
|
||||
return !!this.form.ministryId
|
||||
&& this.form.description.trim().length > 0
|
||||
|
||||
Reference in New Issue
Block a user