WIP
This commit is contained in:
+52
-13
@@ -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>
|
||||
|
||||
+61
-4
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user