feat(expense-categories): AI 建議 for group/sub name + 990 line
ci-cd-vm / ci-cd (push) Successful in 2m25s
ci-cd-vm / ci-cd (push) Successful in 2m25s
Add an AI assist button to the Edit/New Group (大項) and Subcategory (小項) dialogs: the user enters a Chinese name, and the model refines the Chinese, translates it to English, and suggests the matching IRS Form 990 Part IX line. Suggestions surface in a confirm card; Apply fills the Chinese name, English name, and 990 line fields. Backend mirrors the existing expense-classification AI family but over the Form 990 line catalog: IExpenseCategoryAiService + base (catalog load, prompt, id validation) + Claude/Gemini providers + factory that picks the provider from ChurchProfile.AiProvider. New write-gated POST api/expense-categories/ai-suggest endpoint; sub-category requests pass the parent group + its 990 line to bias the choice. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
+1
-1
@@ -106,7 +106,7 @@
|
||||
|
||||
<!-- 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>
|
||||
<label class="flex flex-col gap-1"><span>Description / 說明 <span class="text-gray-400">(Optional)</span></span>
|
||||
<kendo-textbox [(ngModel)]="line.description" placeholder="e.g. 點心、文具…"></kendo-textbox>
|
||||
</label>
|
||||
|
||||
|
||||
@@ -44,6 +44,23 @@ export interface ExpenseAiSuggestion {
|
||||
confidence: number;
|
||||
}
|
||||
|
||||
/** Request to AI-assist defining an expense category (大項/小項). */
|
||||
export interface ExpenseCategoryAiRequest {
|
||||
name_zh: string;
|
||||
name_en?: string | null;
|
||||
level: 'group' | 'sub';
|
||||
parentGroupName?: string | null;
|
||||
parentForm990LineId?: number | null;
|
||||
}
|
||||
/** AI suggestion for a category: refined Chinese name, English translation, and a Form 990 line. */
|
||||
export interface CategoryAiSuggestion {
|
||||
chineseName: string | null;
|
||||
englishName: string | null;
|
||||
form990LineId: number | null;
|
||||
form990LineLabel: string | null;
|
||||
confidence: number;
|
||||
}
|
||||
|
||||
export interface ExpenseLineInput {
|
||||
categoryGroupId: number; subCategoryId: number; amount: number;
|
||||
functionalClass: FunctionalClass | null; description: string | null;
|
||||
|
||||
+68
-8
@@ -53,10 +53,40 @@
|
||||
Name (EN) *
|
||||
<kendo-textbox [(ngModel)]="groupForm.name_en"></kendo-textbox>
|
||||
</label>
|
||||
<label class="flex flex-col gap-1">
|
||||
名稱 (中)
|
||||
<kendo-textbox [(ngModel)]="groupForm.name_zh"></kendo-textbox>
|
||||
</label>
|
||||
<!-- Chinese name with AI assist: refine 中文 + translate to English + suggest a 990 line -->
|
||||
<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">名稱 (中)
|
||||
<kendo-textbox [(ngModel)]="groupForm.name_zh" placeholder="可輸入中文,AI 幫忙翻譯"></kendo-textbox>
|
||||
</label>
|
||||
<button kendoButton fillMode="outline" themeColor="primary" type="button"
|
||||
[disabled]="(!groupForm.name_zh.trim() && !groupForm.name_en.trim()) || groupAiLoading"
|
||||
(click)="requestGroupAiSuggest()"
|
||||
title="翻譯英文並建議 990 Line / Translate + suggest 990 line">
|
||||
{{ groupAiLoading ? '思考中… / Thinking…' : '✨ AI 建議' }}
|
||||
</button>
|
||||
</div>
|
||||
<div *ngIf="groupAiSuggestion" 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="groupAiSuggestion.englishName" class="flex gap-2">
|
||||
<span class="text-gray-500 shrink-0">English:</span>
|
||||
<span class="font-medium">{{ groupAiSuggestion.englishName }}</span>
|
||||
</div>
|
||||
<div *ngIf="groupAiSuggestion.chineseName" class="flex gap-2">
|
||||
<span class="text-gray-500 shrink-0">中文:</span>
|
||||
<span class="font-medium">{{ groupAiSuggestion.chineseName }}</span>
|
||||
</div>
|
||||
<div *ngIf="groupAiSuggestion.form990LineLabel" class="flex gap-2">
|
||||
<span class="text-gray-500 shrink-0">990 Line:</span>
|
||||
<span class="font-medium">{{ groupAiSuggestion.form990LineLabel }}</span>
|
||||
</div>
|
||||
<div class="text-xs text-gray-500">信心 / Confidence: {{ groupAiSuggestion.confidence * 100 | number:'1.0-0' }}%</div>
|
||||
<div class="flex gap-2">
|
||||
<button kendoButton themeColor="primary" size="small" type="button" (click)="applyGroupAiSuggestion()">套用 / Apply</button>
|
||||
<button kendoButton fillMode="flat" size="small" type="button" (click)="dismissGroupAiSuggestion()">忽略 / Dismiss</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<label class="flex flex-col gap-1">
|
||||
Sort order
|
||||
<kendo-numerictextbox [(ngModel)]="groupForm.sortOrder" [format]="'n0'" [decimals]="0" [min]="0"></kendo-numerictextbox>
|
||||
@@ -90,10 +120,40 @@
|
||||
Name (EN) *
|
||||
<kendo-textbox [(ngModel)]="subForm.name_en"></kendo-textbox>
|
||||
</label>
|
||||
<label class="flex flex-col gap-1">
|
||||
名稱 (中)
|
||||
<kendo-textbox [(ngModel)]="subForm.name_zh"></kendo-textbox>
|
||||
</label>
|
||||
<!-- Chinese name with AI assist: refine 中文 + translate to English + suggest a 990 line (biased by parent group) -->
|
||||
<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">名稱 (中)
|
||||
<kendo-textbox [(ngModel)]="subForm.name_zh" placeholder="可輸入中文,AI 幫忙翻譯"></kendo-textbox>
|
||||
</label>
|
||||
<button kendoButton fillMode="outline" themeColor="primary" type="button"
|
||||
[disabled]="(!subForm.name_zh.trim() && !subForm.name_en.trim()) || subAiLoading"
|
||||
(click)="requestSubAiSuggest()"
|
||||
title="翻譯英文並建議 990 Line / Translate + suggest 990 line">
|
||||
{{ subAiLoading ? '思考中… / Thinking…' : '✨ AI 建議' }}
|
||||
</button>
|
||||
</div>
|
||||
<div *ngIf="subAiSuggestion" 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="subAiSuggestion.englishName" class="flex gap-2">
|
||||
<span class="text-gray-500 shrink-0">English:</span>
|
||||
<span class="font-medium">{{ subAiSuggestion.englishName }}</span>
|
||||
</div>
|
||||
<div *ngIf="subAiSuggestion.chineseName" class="flex gap-2">
|
||||
<span class="text-gray-500 shrink-0">中文:</span>
|
||||
<span class="font-medium">{{ subAiSuggestion.chineseName }}</span>
|
||||
</div>
|
||||
<div *ngIf="subAiSuggestion.form990LineLabel" class="flex gap-2">
|
||||
<span class="text-gray-500 shrink-0">990 Line:</span>
|
||||
<span class="font-medium">{{ subAiSuggestion.form990LineLabel }}</span>
|
||||
</div>
|
||||
<div class="text-xs text-gray-500">信心 / Confidence: {{ subAiSuggestion.confidence * 100 | number:'1.0-0' }}%</div>
|
||||
<div class="flex gap-2">
|
||||
<button kendoButton themeColor="primary" size="small" type="button" (click)="applySubAiSuggestion()">套用 / Apply</button>
|
||||
<button kendoButton fillMode="flat" size="small" type="button" (click)="dismissSubAiSuggestion()">忽略 / Dismiss</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<label class="flex flex-col gap-1">
|
||||
Sort order
|
||||
<kendo-numerictextbox [(ngModel)]="subForm.sortOrder" [format]="'n0'" [decimals]="0" [min]="0"></kendo-numerictextbox>
|
||||
|
||||
+57
-1
@@ -8,7 +8,7 @@ import { InputsModule } from '@progress/kendo-angular-inputs';
|
||||
import { DropDownsModule } from '@progress/kendo-angular-dropdowns';
|
||||
import { ContextMenuModule, ContextMenuComponent, ContextMenuSelectEvent } from '@progress/kendo-angular-menu';
|
||||
import { ExpenseCategoryApiService } from '../../services/expense-category-api.service';
|
||||
import { ExpenseCategoryGroupDto, ExpenseSubCategoryDto } from '../../models/expense.model';
|
||||
import { ExpenseCategoryGroupDto, ExpenseSubCategoryDto, CategoryAiSuggestion } from '../../models/expense.model';
|
||||
import { Form990ExpenseLineDto } from '../../../finance-report/models/form990-report.model';
|
||||
|
||||
@Component({
|
||||
@@ -34,10 +34,14 @@ export class ExpenseCategoriesPageComponent implements OnInit {
|
||||
groupDialogOpen = false;
|
||||
editingGroupId: number | null = null;
|
||||
groupForm = { name_en: '', name_zh: '', sortOrder: 0, isActive: true, form990LineId: null as number | null };
|
||||
groupAiLoading = false;
|
||||
groupAiSuggestion: CategoryAiSuggestion | null = null;
|
||||
|
||||
subDialogOpen = false;
|
||||
editingSubId: number | null = null;
|
||||
subForm = { name_en: '', name_zh: '', sortOrder: 0, isActive: true, form990LineId: null as number | null };
|
||||
subAiLoading = false;
|
||||
subAiSuggestion: CategoryAiSuggestion | null = null;
|
||||
|
||||
constructor(private api: ExpenseCategoryApiService) {}
|
||||
|
||||
@@ -108,13 +112,36 @@ export class ExpenseCategoriesPageComponent implements OnInit {
|
||||
openNewGroup(): void {
|
||||
this.editingGroupId = null;
|
||||
this.groupForm = { name_en: '', name_zh: '', sortOrder: this.groups.length + 1, isActive: true, form990LineId: null };
|
||||
this.resetGroupAi();
|
||||
this.groupDialogOpen = true;
|
||||
}
|
||||
openEditGroup(g: ExpenseCategoryGroupDto): void {
|
||||
this.editingGroupId = g.id;
|
||||
this.groupForm = { name_en: g.name_en, name_zh: g.name_zh ?? '', sortOrder: g.sortOrder, isActive: g.isActive, form990LineId: g.form990LineId };
|
||||
this.resetGroupAi();
|
||||
this.groupDialogOpen = true;
|
||||
}
|
||||
|
||||
// ── Group AI assist: refine the Chinese name, translate to English, suggest a Form 990 line ──
|
||||
private resetGroupAi(): void { this.groupAiLoading = false; this.groupAiSuggestion = null; }
|
||||
requestGroupAiSuggest(): void {
|
||||
if (this.groupAiLoading) return;
|
||||
this.groupAiLoading = true;
|
||||
this.groupAiSuggestion = null;
|
||||
this.api.aiSuggest({ name_zh: this.groupForm.name_zh.trim(), name_en: this.groupForm.name_en.trim() || null, level: 'group' }).subscribe({
|
||||
next: s => { this.groupAiSuggestion = s; this.groupAiLoading = false; },
|
||||
error: () => { this.groupAiLoading = false; },
|
||||
});
|
||||
}
|
||||
applyGroupAiSuggestion(): void {
|
||||
const s = this.groupAiSuggestion;
|
||||
if (!s) return;
|
||||
if (s.chineseName) this.groupForm.name_zh = s.chineseName;
|
||||
if (s.englishName) this.groupForm.name_en = s.englishName;
|
||||
if (s.form990LineId != null) this.groupForm.form990LineId = s.form990LineId;
|
||||
this.groupAiSuggestion = null;
|
||||
}
|
||||
dismissGroupAiSuggestion(): void { this.groupAiSuggestion = null; }
|
||||
saveGroup(): void {
|
||||
const body = { name_en: this.groupForm.name_en, name_zh: this.groupForm.name_zh || null, sortOrder: this.groupForm.sortOrder, form990LineId: this.groupForm.form990LineId };
|
||||
const done = () => { this.groupDialogOpen = false; this.load(); };
|
||||
@@ -130,13 +157,42 @@ export class ExpenseCategoriesPageComponent implements OnInit {
|
||||
if (!this.selectedGroup) return;
|
||||
this.editingSubId = null;
|
||||
this.subForm = { name_en: '', name_zh: '', sortOrder: this.subCategories.length + 1, isActive: true, form990LineId: null };
|
||||
this.resetSubAi();
|
||||
this.subDialogOpen = true;
|
||||
}
|
||||
openEditSub(s: ExpenseSubCategoryDto): void {
|
||||
this.editingSubId = s.id;
|
||||
this.subForm = { name_en: s.name_en, name_zh: s.name_zh ?? '', sortOrder: s.sortOrder, isActive: s.isActive, form990LineId: s.form990LineId };
|
||||
this.resetSubAi();
|
||||
this.subDialogOpen = true;
|
||||
}
|
||||
|
||||
// ── Subcategory AI assist: same as group, biased by the selected parent group + its 990 line ──
|
||||
private resetSubAi(): void { this.subAiLoading = false; this.subAiSuggestion = null; }
|
||||
requestSubAiSuggest(): void {
|
||||
if (this.subAiLoading || !this.selectedGroup) return;
|
||||
this.subAiLoading = true;
|
||||
this.subAiSuggestion = null;
|
||||
this.api.aiSuggest({
|
||||
name_zh: this.subForm.name_zh.trim(),
|
||||
name_en: this.subForm.name_en.trim() || null,
|
||||
level: 'sub',
|
||||
parentGroupName: this.selectedGroup.label ?? this.selectedGroup.name_en,
|
||||
parentForm990LineId: this.selectedGroup.form990LineId,
|
||||
}).subscribe({
|
||||
next: s => { this.subAiSuggestion = s; this.subAiLoading = false; },
|
||||
error: () => { this.subAiLoading = false; },
|
||||
});
|
||||
}
|
||||
applySubAiSuggestion(): void {
|
||||
const s = this.subAiSuggestion;
|
||||
if (!s) return;
|
||||
if (s.chineseName) this.subForm.name_zh = s.chineseName;
|
||||
if (s.englishName) this.subForm.name_en = s.englishName;
|
||||
if (s.form990LineId != null) this.subForm.form990LineId = s.form990LineId;
|
||||
this.subAiSuggestion = null;
|
||||
}
|
||||
dismissSubAiSuggestion(): void { this.subAiSuggestion = null; }
|
||||
saveSub(): void {
|
||||
if (!this.selectedGroup) return;
|
||||
const body = { groupId: this.selectedGroup.id, name_en: this.subForm.name_en, name_zh: this.subForm.name_zh || null, sortOrder: this.subForm.sortOrder, form990LineId: this.subForm.form990LineId };
|
||||
|
||||
@@ -6,6 +6,7 @@ import { ApiConfigService } from '../../../core/services/api-config.service';
|
||||
import {
|
||||
ExpenseCategoryGroupDto, CreateExpenseGroupRequest, UpdateExpenseGroupRequest,
|
||||
CreateExpenseSubCategoryRequest, UpdateExpenseSubCategoryRequest,
|
||||
ExpenseCategoryAiRequest, CategoryAiSuggestion,
|
||||
} from '../models/expense.model';
|
||||
import { Form990ExpenseLineDto } from '../../finance-report/models/form990-report.model';
|
||||
|
||||
@@ -30,6 +31,9 @@ export class ExpenseCategoryApiService {
|
||||
createSub(r: CreateExpenseSubCategoryRequest): Observable<{ id: number }> { return this.http.post<{ id: number }>(`${this.endpoint}/subcategories`, r); }
|
||||
updateSub(id: number, r: UpdateExpenseSubCategoryRequest): Observable<void> { return this.http.put<void>(`${this.endpoint}/subcategories/${id}`, r); }
|
||||
deactivateSub(id: number): Observable<void> { return this.http.delete<void>(`${this.endpoint}/subcategories/${id}`); }
|
||||
aiSuggest(req: ExpenseCategoryAiRequest): Observable<CategoryAiSuggestion> {
|
||||
return this.http.post<CategoryAiSuggestion>(`${this.endpoint}/ai-suggest`, req);
|
||||
}
|
||||
getForm990Lines(): Observable<Form990ExpenseLineDto[]> {
|
||||
return this.http.get<Form990ExpenseLineDto[]>(this.apiConfig.getApiUrl('form990-report') + '/lines')
|
||||
.pipe(map(rows => rows.map(r => ({ ...r, label: `${r.lineCode} — ${r.name_en}${r.name_zh ? ' / ' + r.name_zh : ''}` }))));
|
||||
|
||||
Reference in New Issue
Block a user