This commit is contained in:
Chris Chen
2026-06-25 12:38:13 -07:00
parent a89e936f4d
commit bdccb79029
11 changed files with 416 additions and 103 deletions
@@ -1,4 +1,5 @@
<kendo-dialog [title]="title" (close)="cancel.emit()" [width]="showReceiptPanel ? 1200 : 760" [maxWidth]="'95vw'" [maxHeight]="'90vh'">
<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">
@@ -14,7 +15,8 @@
<div class="flex flex-col gap-1 md:col-span-2">
<div class="flex items-end justify-between gap-2">
<label class="flex flex-1 flex-col gap-1">Description
<kendo-textbox [(ngModel)]="form.description" placeholder="Brief description of expense / 費用說明(可輸入中文)"></kendo-textbox>
<kendo-textbox [(ngModel)]="form.description"
placeholder="Brief description of expense / 費用說明(可輸入中文)"></kendo-textbox>
</label>
<button kendoButton fillMode="outline" themeColor="primary" type="button"
[disabled]="!form.description.trim() || aiLoading" (click)="requestAiAssist()"
@@ -26,18 +28,22 @@
<!-- Suggestion card: the "suggest & confirm" step — user applies or dismisses -->
<div *ngIf="hasAiSuggestion" class="rounded border border-blue-200 bg-blue-50 p-3 flex flex-col gap-2 text-sm">
<div class="font-semibold text-blue-800">AI 建議 / Suggestion</div>
<div *ngIf="aiSuggestion?.englishDescription" class="flex gap-2">
<span class="text-gray-500 shrink-0">English:</span>
<span class="font-medium">{{ aiSuggestion?.englishDescription }}</span>
<div *ngIf="aiSuggestedDescription" class="flex gap-2">
<span class="text-gray-500 shrink-0">說明 / Description:</span>
<span class="font-medium">{{ aiSuggestedDescription }}</span>
</div>
<div *ngIf="aiSuggestion?.groupLabel" class="flex gap-2">
<span class="text-gray-500 shrink-0">分類 / Category:</span>
<span class="font-medium">{{ aiSuggestion?.groupLabel }}<span *ngIf="aiSuggestion?.subLabel"> → {{ aiSuggestion?.subLabel }}</span></span>
<span class="font-medium">{{ aiSuggestion?.groupLabel }}<span *ngIf="aiSuggestion?.subLabel"> → {{
aiSuggestion?.subLabel }}</span></span>
</div>
<div class="text-xs text-gray-500">信心 / Confidence: {{ (aiSuggestion?.confidence ?? 0) * 100 | number:'1.0-0' }}%</div>
<div class="text-xs text-gray-500">信心 / Confidence: {{ (aiSuggestion?.confidence ?? 0) * 100 | number:'1.0-0'
}}%</div>
<div class="flex gap-2">
<button kendoButton themeColor="primary" size="small" type="button" (click)="applyAiSuggestion()">套用 / Apply</button>
<button kendoButton fillMode="flat" size="small" type="button" (click)="dismissAiSuggestion()">忽略 / Dismiss</button>
<button kendoButton themeColor="primary" size="small" type="button" (click)="applyAiSuggestion()">套用 /
Apply</button>
<button kendoButton fillMode="flat" size="small" type="button" (click)="dismissAiSuggestion()">忽略 /
Dismiss</button>
</div>
</div>
</div>
@@ -72,6 +78,11 @@
<!-- 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="outline" themeColor="primary" size="small" type="button"
[disabled]="!line.description.trim() || line.aiLoading" (click)="requestLineAiAssist(line)"
title="Translate this line and suggest a category / 翻譯並建議此列分類">
{{ line.aiLoading ? '思考中… / Thinking…' : '✨ AI 建議此列' }}
</button>
<button kendoButton fillMode="flat" themeColor="error" size="small" [disabled]="lines.length === 1"
(click)="removeLine(i)" title="Remove line / 刪除此列">✕ 刪除</button>
</div>
@@ -103,6 +114,34 @@
<kendo-numerictextbox [(ngModel)]="line.amount" [min]="0" [format]="'c2'"></kendo-numerictextbox>
</label>
</div>
<!-- Per-line AI assist: translate this line's note + suggest its category from its own amount -->
<div>
</div>
<!-- Per-line suggestion card: the "suggest & confirm" step for this line -->
<div *ngIf="hasLineSuggestion(line)"
class="rounded border border-blue-200 bg-blue-50 p-3 flex flex-col gap-2 text-sm">
<div class="font-semibold text-blue-800">AI 建議 / Suggestion</div>
<div *ngIf="lineSuggestedDescription(line)" class="flex gap-2">
<span class="text-gray-500 shrink-0">說明 / Description:</span>
<span class="font-medium">{{ lineSuggestedDescription(line) }}</span>
</div>
<div *ngIf="line.aiSuggestion?.groupLabel" class="flex gap-2">
<span class="text-gray-500 shrink-0">分類 / Category:</span>
<span class="font-medium">{{ line.aiSuggestion?.groupLabel }}<span *ngIf="line.aiSuggestion?.subLabel">
{{ line.aiSuggestion?.subLabel }}</span></span>
</div>
<div class="text-xs text-gray-500">信心 / Confidence: {{ (line.aiSuggestion?.confidence ?? 0) * 100 |
number:'1.0-0' }}%</div>
<div class="flex gap-2">
<button kendoButton themeColor="primary" size="small" type="button"
(click)="applyLineAiSuggestion(line)">套用 / Apply</button>
<button kendoButton fillMode="flat" size="small" type="button" (click)="dismissLineAiSuggestion(line)">忽略
/ Dismiss</button>
</div>
</div>
</div>
<div>
@@ -144,11 +183,11 @@
<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>
<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)="zoomIn()" [disabled]="receiptZoom >= maxZoom"
title="放大 / Zoom in"></button>
<button kendoButton size="small" fillMode="flat" (click)="resetZoom()" title="重設 / Reset"></button>
</div>
</div>
@@ -38,6 +38,9 @@ interface ExpenseLineForm {
* null = inherit the ministry default. Kept here so existing overrides survive an edit. */
functionalClass: FunctionalClass | null;
subs: ExpenseSubCategoryDto[];
/** Per-line AI assist state (suggest & confirm), independent of the header assist. */
aiLoading?: boolean;
aiSuggestion?: ExpenseAiSuggestion | null;
}
@Component({
@@ -189,17 +192,31 @@ export class ExpenseFormDialogComponent implements OnInit, OnDestroy {
/** True once a suggestion offers at least a translation or a category to apply. */
get hasAiSuggestion(): boolean {
const s = this.aiSuggestion;
return !!s && (!!s.englishDescription || s.groupId != null);
return !!s && (!!this.aiSuggestedDescription || s.groupId != null);
}
/** Combine a suggestion's two halves into the "English / 中文" string that Apply writes. */
private combineDescription(suggestion: ExpenseAiSuggestion | null | undefined): string {
if (!suggestion) return '';
const en = suggestion.englishDescription?.trim() ?? '';
const zh = suggestion.chineseDescription?.trim() ?? '';
if (en && zh) return `${en} / ${zh}`;
return en || zh;
}
/** The description that the header Apply will write. */
get aiSuggestedDescription(): string {
return this.combineDescription(this.aiSuggestion);
}
/**
* Apply the suggestion: replace the description with the English translation and set the first
* line's category/sub. Most expenses are single-line; multi-line users adjust the rest by hand.
* Apply the suggestion: set the description to "English / 中文" and set the first line's
* category/sub. Most expenses are single-line; multi-line users adjust the rest by hand.
*/
applyAiSuggestion(): void {
const suggestion = this.aiSuggestion;
if (!suggestion) return;
if (suggestion.englishDescription) this.form.description = suggestion.englishDescription;
if (this.aiSuggestedDescription) this.form.description = this.aiSuggestedDescription;
if (suggestion.groupId != null) {
const firstLine = this.lines[0];
firstLine.categoryGroupId = suggestion.groupId;
@@ -212,6 +229,46 @@ export class ExpenseFormDialogComponent implements OnInit, OnDestroy {
dismissAiSuggestion(): void { this.aiSuggestion = null; }
// ── Per-line AI assist ──────────────────────────────────────────────────────
/** Ask the AI to translate this line's own note and suggest its category, using the line's amount. */
requestLineAiAssist(line: ExpenseLineForm): void {
const text = line.description.trim();
if (!text || line.aiLoading) return;
line.aiLoading = true;
line.aiSuggestion = null;
this.aiApi.assist(text, line.amount).subscribe({
next: suggestion => { line.aiSuggestion = suggestion; line.aiLoading = false; },
error: () => { line.aiLoading = false; },
});
}
/** The description that this line's Apply will write: "English / 中文". */
lineSuggestedDescription(line: ExpenseLineForm): string {
return this.combineDescription(line.aiSuggestion);
}
/** True once a line suggestion offers a translation or a category to apply. */
hasLineSuggestion(line: ExpenseLineForm): boolean {
return !!line.aiSuggestion && (!!this.lineSuggestedDescription(line) || line.aiSuggestion.groupId != null);
}
/** Apply this line's suggestion to itself: set its description (bilingual) and category/sub. */
applyLineAiSuggestion(line: ExpenseLineForm): void {
const suggestion = line.aiSuggestion;
if (!suggestion) return;
const description = this.lineSuggestedDescription(line);
if (description) line.description = description;
if (suggestion.groupId != null) {
line.categoryGroupId = suggestion.groupId;
// Populate the sub-category list for the chosen group, then select the suggested sub.
this.onLineGroupChange(line, suggestion.groupId);
if (suggestion.subCategoryId != null) line.subCategoryId = suggestion.subCategoryId;
}
line.aiSuggestion = null;
}
dismissLineAiSuggestion(line: ExpenseLineForm): void { line.aiSuggestion = null; }
addLine(): void { this.lines.push(this.emptyLine()); }
removeLine(index: number): void {
@@ -36,6 +36,7 @@ export interface ExpenseDto extends ExpenseListItemDto {
/** AI assist suggestion: English translation + a proposed major/sub category (null when unclassified). */
export interface ExpenseAiSuggestion {
englishDescription: string | null;
chineseDescription: string | null;
groupId: number | null;
subCategoryId: number | null;
groupLabel: string | null;