mirror of https://github.com/ghostfolio/ghostfolio
You can not select more than 25 topics
Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
410 lines
11 KiB
410 lines
11 KiB
import { K1ImportDataService } from '@ghostfolio/client/services/k1-import-data.service';
|
|
import type {
|
|
K1AggregationResult,
|
|
K1ExtractedField,
|
|
K1UnmappedItem
|
|
} from '@ghostfolio/common/interfaces';
|
|
|
|
import { CommonModule } from '@angular/common';
|
|
import {
|
|
ChangeDetectionStrategy,
|
|
ChangeDetectorRef,
|
|
Component,
|
|
DestroyRef,
|
|
OnInit
|
|
} from '@angular/core';
|
|
import { takeUntilDestroyed } from '@angular/core/rxjs-interop';
|
|
import { FormsModule } from '@angular/forms';
|
|
import { MatButtonModule } from '@angular/material/button';
|
|
import { MatCheckboxModule } from '@angular/material/checkbox';
|
|
import { MatFormFieldModule } from '@angular/material/form-field';
|
|
import { MatIconModule } from '@angular/material/icon';
|
|
import { MatInputModule } from '@angular/material/input';
|
|
import { MatProgressBarModule } from '@angular/material/progress-bar';
|
|
import { MatSelectModule } from '@angular/material/select';
|
|
import { MatTableModule } from '@angular/material/table';
|
|
import { MatTooltipModule } from '@angular/material/tooltip';
|
|
import { ActivatedRoute, Router } from '@angular/router';
|
|
import { addIcons } from 'ionicons';
|
|
import {
|
|
checkmarkCircleOutline,
|
|
alertCircleOutline,
|
|
closeCircleOutline,
|
|
trashOutline
|
|
} from 'ionicons/icons';
|
|
|
|
interface EditableField extends K1ExtractedField {
|
|
isEditing: boolean;
|
|
editValue: string;
|
|
editLabel: string;
|
|
}
|
|
|
|
interface EditableUnmappedItem extends K1UnmappedItem {
|
|
resolution: 'assigned' | 'discarded' | null;
|
|
assignedBoxNumber: string | null;
|
|
}
|
|
|
|
@Component({
|
|
changeDetection: ChangeDetectionStrategy.OnPush,
|
|
host: { class: 'page' },
|
|
imports: [
|
|
CommonModule,
|
|
FormsModule,
|
|
MatButtonModule,
|
|
MatCheckboxModule,
|
|
MatFormFieldModule,
|
|
MatIconModule,
|
|
MatInputModule,
|
|
MatProgressBarModule,
|
|
MatSelectModule,
|
|
MatTableModule,
|
|
MatTooltipModule
|
|
],
|
|
selector: 'gf-k1-verification',
|
|
styleUrls: ['./k1-verification.scss'],
|
|
templateUrl: './k1-verification.html'
|
|
})
|
|
export class K1VerificationComponent implements OnInit {
|
|
public aggregations: K1AggregationResult[] = [];
|
|
public canConfirm = false;
|
|
public error: string | null = null;
|
|
public fields: EditableField[] = [];
|
|
public isLoading = true;
|
|
public isSaving = false;
|
|
public sessionId: string;
|
|
public taxYear: number;
|
|
public unmappedItems: EditableUnmappedItem[] = [];
|
|
|
|
// Column definitions for the fields table
|
|
public displayedColumns = [
|
|
'boxNumber',
|
|
'label',
|
|
'rawValue',
|
|
'numericValue',
|
|
'confidence',
|
|
'reviewed',
|
|
'actions'
|
|
];
|
|
|
|
// Available box numbers for assigning unmapped items
|
|
public availableBoxNumbers: string[] = [];
|
|
|
|
public constructor(
|
|
private readonly activatedRoute: ActivatedRoute,
|
|
private readonly changeDetectorRef: ChangeDetectorRef,
|
|
private readonly destroyRef: DestroyRef,
|
|
private readonly k1ImportDataService: K1ImportDataService,
|
|
private readonly router: Router
|
|
) {
|
|
addIcons({
|
|
checkmarkCircleOutline,
|
|
alertCircleOutline,
|
|
closeCircleOutline,
|
|
trashOutline
|
|
});
|
|
}
|
|
|
|
public ngOnInit(): void {
|
|
this.sessionId = this.activatedRoute.snapshot.params['id'];
|
|
this.loadSession();
|
|
}
|
|
|
|
/**
|
|
* Get confidence badge CSS class.
|
|
*/
|
|
public getConfidenceClass(level: string): string {
|
|
switch (level) {
|
|
case 'HIGH':
|
|
return 'confidence-high';
|
|
case 'MEDIUM':
|
|
return 'confidence-medium';
|
|
case 'LOW':
|
|
return 'confidence-low';
|
|
default:
|
|
return '';
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Toggle inline editing for a field.
|
|
*/
|
|
public startEditing(field: EditableField): void {
|
|
field.isEditing = true;
|
|
field.editValue = field.rawValue;
|
|
field.editLabel = field.customLabel || field.label;
|
|
this.changeDetectorRef.markForCheck();
|
|
}
|
|
|
|
/**
|
|
* Save edits to a field.
|
|
*/
|
|
public saveEdit(field: EditableField): void {
|
|
field.rawValue = field.editValue;
|
|
field.customLabel =
|
|
field.editLabel !== field.label ? field.editLabel : null;
|
|
field.isUserEdited = true;
|
|
field.isReviewed = true;
|
|
field.isEditing = false;
|
|
|
|
// Try to parse numeric value
|
|
const cleaned = field.editValue
|
|
.replace(/[$,]/g, '')
|
|
.replace(/\(([^)]+)\)/, '-$1')
|
|
.trim();
|
|
const parsed = parseFloat(cleaned);
|
|
field.numericValue = isNaN(parsed) ? null : parsed;
|
|
|
|
this.recalculateAggregations();
|
|
this.checkConfirmability();
|
|
this.changeDetectorRef.markForCheck();
|
|
}
|
|
|
|
/**
|
|
* Cancel editing.
|
|
*/
|
|
public cancelEdit(field: EditableField): void {
|
|
field.isEditing = false;
|
|
this.changeDetectorRef.markForCheck();
|
|
}
|
|
|
|
/**
|
|
* Toggle reviewed flag for a field.
|
|
*/
|
|
public toggleReviewed(field: EditableField): void {
|
|
field.isReviewed = !field.isReviewed;
|
|
this.checkConfirmability();
|
|
this.changeDetectorRef.markForCheck();
|
|
}
|
|
|
|
/**
|
|
* Assign an unmapped item to an existing box number.
|
|
*/
|
|
public assignUnmappedItem(
|
|
item: EditableUnmappedItem,
|
|
boxNumber: string
|
|
): void {
|
|
item.resolution = 'assigned';
|
|
item.assignedBoxNumber = boxNumber;
|
|
this.checkConfirmability();
|
|
this.changeDetectorRef.markForCheck();
|
|
}
|
|
|
|
/**
|
|
* Discard an unmapped item.
|
|
*/
|
|
public discardUnmappedItem(item: EditableUnmappedItem): void {
|
|
item.resolution = 'discarded';
|
|
item.assignedBoxNumber = null;
|
|
this.checkConfirmability();
|
|
this.changeDetectorRef.markForCheck();
|
|
}
|
|
|
|
/**
|
|
* Submit verified data.
|
|
*/
|
|
public submitVerification(): void {
|
|
if (!this.canConfirm) {
|
|
return;
|
|
}
|
|
|
|
this.isSaving = true;
|
|
this.error = null;
|
|
this.changeDetectorRef.markForCheck();
|
|
|
|
const data = {
|
|
taxYear: this.taxYear,
|
|
fields: this.fields.map((f) => ({
|
|
boxNumber: f.boxNumber,
|
|
label: f.label,
|
|
customLabel: f.customLabel,
|
|
rawValue: f.rawValue,
|
|
numericValue: f.numericValue,
|
|
confidence: f.confidence,
|
|
confidenceLevel: f.confidenceLevel,
|
|
isUserEdited: f.isUserEdited,
|
|
isReviewed: f.isReviewed
|
|
})),
|
|
unmappedItems: this.unmappedItems.map((item) => ({
|
|
rawLabel: item.rawLabel,
|
|
rawValue: item.rawValue,
|
|
numericValue: item.numericValue,
|
|
confidence: item.confidence,
|
|
pageNumber: item.pageNumber,
|
|
resolution: item.resolution,
|
|
assignedBoxNumber: item.assignedBoxNumber
|
|
}))
|
|
};
|
|
|
|
this.k1ImportDataService
|
|
.verifyImportSession(this.sessionId, data as any)
|
|
.pipe(takeUntilDestroyed(this.destroyRef))
|
|
.subscribe({
|
|
next: () => {
|
|
this.isSaving = false;
|
|
// Navigate to confirmation step (Phase 5)
|
|
this.router.navigate(['/k1-import', this.sessionId, 'confirm']);
|
|
},
|
|
error: (err) => {
|
|
this.isSaving = false;
|
|
this.error =
|
|
err?.error?.message || err?.message || 'Verification failed.';
|
|
this.changeDetectorRef.markForCheck();
|
|
}
|
|
});
|
|
}
|
|
|
|
/**
|
|
* Cancel and go back to import page.
|
|
*/
|
|
public cancelImport(): void {
|
|
this.k1ImportDataService
|
|
.cancelImportSession(this.sessionId)
|
|
.pipe(takeUntilDestroyed(this.destroyRef))
|
|
.subscribe({
|
|
next: () => {
|
|
this.router.navigate(['/k1-import']);
|
|
},
|
|
error: (err) => {
|
|
this.error =
|
|
err?.error?.message || err?.message || 'Cancel failed.';
|
|
this.changeDetectorRef.markForCheck();
|
|
}
|
|
});
|
|
}
|
|
|
|
/**
|
|
* Load session data and populate fields.
|
|
*/
|
|
private loadSession(): void {
|
|
this.k1ImportDataService
|
|
.fetchImportSession(this.sessionId)
|
|
.pipe(takeUntilDestroyed(this.destroyRef))
|
|
.subscribe({
|
|
next: (session: any) => {
|
|
if (
|
|
session.status !== 'EXTRACTED' &&
|
|
session.status !== 'VERIFIED'
|
|
) {
|
|
this.error = `Session is in ${session.status} status. Cannot verify.`;
|
|
this.isLoading = false;
|
|
this.changeDetectorRef.markForCheck();
|
|
return;
|
|
}
|
|
|
|
this.taxYear = session.taxYear;
|
|
|
|
const extraction = session.rawExtraction || session.verifiedData;
|
|
if (extraction) {
|
|
this.fields = (extraction.fields || []).map(
|
|
(f: K1ExtractedField) => ({
|
|
...f,
|
|
isEditing: false,
|
|
editValue: f.rawValue,
|
|
editLabel: f.customLabel || f.label
|
|
})
|
|
);
|
|
|
|
this.unmappedItems = (extraction.unmappedItems || []).map(
|
|
(item: K1UnmappedItem) => ({
|
|
...item,
|
|
resolution: item.resolution || null,
|
|
assignedBoxNumber: item.assignedBoxNumber || null
|
|
})
|
|
);
|
|
|
|
// Build available box numbers from fields
|
|
this.availableBoxNumbers = this.fields.map((f) => f.boxNumber);
|
|
}
|
|
|
|
this.recalculateAggregations();
|
|
this.checkConfirmability();
|
|
this.isLoading = false;
|
|
this.changeDetectorRef.markForCheck();
|
|
},
|
|
error: (err) => {
|
|
this.error =
|
|
err?.error?.message || err?.message || 'Failed to load session.';
|
|
this.isLoading = false;
|
|
this.changeDetectorRef.markForCheck();
|
|
}
|
|
});
|
|
}
|
|
|
|
/**
|
|
* Recalculate aggregation summaries from current field values.
|
|
* FR-034: Auto-recalculate when cell values change.
|
|
*/
|
|
private recalculateAggregations(): void {
|
|
// Use the data service to compute aggregations from current fields
|
|
// For now, compute client-side from the predefined rules
|
|
// The full server-side computation will be used when a KDocument exists
|
|
const fieldMap: Record<string, number> = {};
|
|
for (const f of this.fields) {
|
|
if (f.numericValue !== null && f.numericValue !== undefined) {
|
|
fieldMap[f.boxNumber] = f.numericValue;
|
|
}
|
|
}
|
|
|
|
// Client-side aggregation matching the default rules
|
|
this.aggregations = [
|
|
{
|
|
ruleId: 'client-1',
|
|
name: 'Total Ordinary Income',
|
|
operation: 'SUM',
|
|
sourceCells: ['1'],
|
|
computedValue: fieldMap['1'] ?? 0,
|
|
breakdown: { '1': fieldMap['1'] ?? 0 }
|
|
},
|
|
{
|
|
ruleId: 'client-2',
|
|
name: 'Total Capital Gains',
|
|
operation: 'SUM',
|
|
sourceCells: ['8', '9a', '9b', '9c', '10'],
|
|
computedValue: ['8', '9a', '9b', '9c', '10'].reduce(
|
|
(sum, box) => sum + (fieldMap[box] ?? 0),
|
|
0
|
|
),
|
|
breakdown: Object.fromEntries(
|
|
['8', '9a', '9b', '9c', '10'].map((box) => [
|
|
box,
|
|
fieldMap[box] ?? 0
|
|
])
|
|
)
|
|
},
|
|
{
|
|
ruleId: 'client-3',
|
|
name: 'Total Deductions',
|
|
operation: 'SUM',
|
|
sourceCells: ['12', '13'],
|
|
computedValue: (fieldMap['12'] ?? 0) + (fieldMap['13'] ?? 0),
|
|
breakdown: {
|
|
'12': fieldMap['12'] ?? 0,
|
|
'13': fieldMap['13'] ?? 0
|
|
}
|
|
}
|
|
];
|
|
}
|
|
|
|
/**
|
|
* FR-035: Check if all medium/low-confidence fields are reviewed
|
|
* AND all unmapped items are resolved.
|
|
*/
|
|
private checkConfirmability(): void {
|
|
// All medium/low fields must be reviewed
|
|
const allFieldsReviewed = this.fields.every(
|
|
(f) =>
|
|
f.confidenceLevel === 'HIGH' ||
|
|
f.isReviewed
|
|
);
|
|
|
|
// All unmapped items must be resolved
|
|
const allUnmappedResolved =
|
|
this.unmappedItems.length === 0 ||
|
|
this.unmappedItems.every(
|
|
(item) =>
|
|
item.resolution === 'assigned' || item.resolution === 'discarded'
|
|
);
|
|
|
|
this.canConfirm = allFieldsReviewed && allUnmappedResolved;
|
|
}
|
|
}
|
|
|