mirror of https://github.com/ghostfolio/ghostfolio
9 changed files with 1051 additions and 12 deletions
@ -0,0 +1,28 @@ |
|||||
|
import { IsArray, IsInt, IsOptional, Min, ValidateNested } from 'class-validator'; |
||||
|
import { Type } from 'class-transformer'; |
||||
|
|
||||
|
import { |
||||
|
K1ExtractedFieldDto, |
||||
|
K1UnmappedItemDto |
||||
|
} from '@ghostfolio/common/dtos'; |
||||
|
|
||||
|
/** |
||||
|
* DTO for verifying K-1 import session. |
||||
|
* Re-exports shared VerifyK1ImportDto for route-level validation. |
||||
|
*/ |
||||
|
export class VerifyK1Dto { |
||||
|
@IsInt() |
||||
|
@Min(1900) |
||||
|
taxYear: number; |
||||
|
|
||||
|
@IsArray() |
||||
|
@ValidateNested({ each: true }) |
||||
|
@Type(() => K1ExtractedFieldDto) |
||||
|
fields: K1ExtractedFieldDto[]; |
||||
|
|
||||
|
@IsOptional() |
||||
|
@IsArray() |
||||
|
@ValidateNested({ each: true }) |
||||
|
@Type(() => K1UnmappedItemDto) |
||||
|
unmappedItems?: K1UnmappedItemDto[]; |
||||
|
} |
||||
@ -1,8 +1,98 @@ |
|||||
import { Injectable } from '@nestjs/common'; |
import { PrismaService } from '@ghostfolio/api/services/prisma/prisma.service'; |
||||
|
import type { K1AggregationResult } from '@ghostfolio/common/interfaces'; |
||||
|
|
||||
|
import { HttpException, Injectable, Logger } from '@nestjs/common'; |
||||
|
import { StatusCodes, getReasonPhrase } from 'http-status-codes'; |
||||
|
|
||||
|
import { CellMappingService } from '../cell-mapping/cell-mapping.service'; |
||||
|
|
||||
/** |
/** |
||||
* Service for computing dynamic aggregation totals |
* Service for computing dynamic aggregation totals |
||||
* from CellAggregationRule records. Implemented in Phase 4 (US2). |
* from CellAggregationRule records. |
||||
|
* FR-034, FR-039: Computed dynamically, only rules persisted. |
||||
*/ |
*/ |
||||
@Injectable() |
@Injectable() |
||||
export class K1AggregationService {} |
export class K1AggregationService { |
||||
|
private readonly logger = new Logger(K1AggregationService.name); |
||||
|
|
||||
|
public constructor( |
||||
|
private readonly prismaService: PrismaService, |
||||
|
private readonly cellMappingService: CellMappingService |
||||
|
) {} |
||||
|
|
||||
|
/** |
||||
|
* Compute aggregation results for a set of extracted/verified fields. |
||||
|
* Used during verification (live recalculation on cell edit) and |
||||
|
* after confirmation. |
||||
|
*/ |
||||
|
public async computeFromFields( |
||||
|
fields: Array<{ boxNumber: string; numericValue: number | null }>, |
||||
|
partnershipId?: string |
||||
|
): Promise<K1AggregationResult[]> { |
||||
|
const rules = |
||||
|
await this.cellMappingService.getAggregationRules(partnershipId); |
||||
|
|
||||
|
return rules.map((rule) => { |
||||
|
const sourceCells = (rule.sourceCells as string[]) || []; |
||||
|
const breakdown: Record<string, number> = {}; |
||||
|
let computedValue = 0; |
||||
|
|
||||
|
for (const boxNumber of sourceCells) { |
||||
|
const field = fields.find((f) => f.boxNumber === boxNumber); |
||||
|
const value = field?.numericValue ?? 0; |
||||
|
breakdown[boxNumber] = value; |
||||
|
|
||||
|
if (rule.operation === 'SUM') { |
||||
|
computedValue += value; |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
return { |
||||
|
ruleId: rule.id, |
||||
|
name: rule.name, |
||||
|
operation: rule.operation, |
||||
|
sourceCells, |
||||
|
computedValue: Math.round(computedValue * 100) / 100, |
||||
|
breakdown |
||||
|
}; |
||||
|
}); |
||||
|
} |
||||
|
|
||||
|
/** |
||||
|
* Compute aggregation results for a KDocument (stored box values). |
||||
|
* GET /aggregation-rules/compute |
||||
|
*/ |
||||
|
public async computeForKDocument( |
||||
|
kDocumentId: string, |
||||
|
partnershipId?: string |
||||
|
): Promise<K1AggregationResult[]> { |
||||
|
const kDocument = await this.prismaService.kDocument.findUnique({ |
||||
|
where: { id: kDocumentId } |
||||
|
}); |
||||
|
|
||||
|
if (!kDocument) { |
||||
|
throw new HttpException( |
||||
|
getReasonPhrase(StatusCodes.NOT_FOUND), |
||||
|
StatusCodes.NOT_FOUND |
||||
|
); |
||||
|
} |
||||
|
|
||||
|
// Extract box values from the KDocument data
|
||||
|
const data = (kDocument.data as any) || {}; |
||||
|
const fields: Array<{ boxNumber: string; numericValue: number | null }> = |
||||
|
[]; |
||||
|
|
||||
|
// kDocument.data stores box values as { "1": 50000, "9a": -1200, ... }
|
||||
|
for (const [boxNumber, value] of Object.entries(data)) { |
||||
|
fields.push({ |
||||
|
boxNumber, |
||||
|
numericValue: typeof value === 'number' ? value : null |
||||
|
}); |
||||
|
} |
||||
|
|
||||
|
return this.computeFromFields( |
||||
|
fields, |
||||
|
partnershipId || kDocument.partnershipId |
||||
|
); |
||||
|
} |
||||
|
} |
||||
|
|||||
@ -0,0 +1,413 @@ |
|||||
|
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[] = []; |
||||
|
|
||||
|
private partnershipId: 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; |
||||
|
this.partnershipId = session.partnershipId; |
||||
|
|
||||
|
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; |
||||
|
} |
||||
|
} |
||||
@ -0,0 +1,246 @@ |
|||||
|
<div class="container"> |
||||
|
<div class="row"> |
||||
|
<div class="col"> |
||||
|
<h1 class="d-none d-sm-block h3 mb-4 text-center"> |
||||
|
Verify K-1 Extraction ({{ taxYear }}) |
||||
|
</h1> |
||||
|
|
||||
|
@if (error) { |
||||
|
<div class="alert alert-danger mb-3"> |
||||
|
{{ error }} |
||||
|
</div> |
||||
|
} |
||||
|
|
||||
|
@if (isLoading) { |
||||
|
<mat-progress-bar mode="indeterminate"></mat-progress-bar> |
||||
|
} @else { |
||||
|
<!-- Extracted Fields Table --> |
||||
|
<section class="fields-section mb-4"> |
||||
|
<h2 class="h5 mb-3">Extracted Values</h2> |
||||
|
|
||||
|
<div class="table-responsive"> |
||||
|
<table mat-table [dataSource]="fields" class="w-100"> |
||||
|
<!-- Box Number Column --> |
||||
|
<ng-container matColumnDef="boxNumber"> |
||||
|
<th mat-header-cell *matHeaderCellDef>Box</th> |
||||
|
<td mat-cell *matCellDef="let field"> |
||||
|
<strong>{{ field.boxNumber }}</strong> |
||||
|
</td> |
||||
|
</ng-container> |
||||
|
|
||||
|
<!-- Label Column --> |
||||
|
<ng-container matColumnDef="label"> |
||||
|
<th mat-header-cell *matHeaderCellDef>Label</th> |
||||
|
<td mat-cell *matCellDef="let field"> |
||||
|
@if (field.isEditing) { |
||||
|
<mat-form-field class="compact-field"> |
||||
|
<input matInput [(ngModel)]="field.editLabel" /> |
||||
|
</mat-form-field> |
||||
|
} @else { |
||||
|
<span>{{ field.customLabel || field.label }}</span> |
||||
|
@if (field.customLabel) { |
||||
|
<small class="text-muted d-block"> |
||||
|
(original: {{ field.label }}) |
||||
|
</small> |
||||
|
} |
||||
|
} |
||||
|
</td> |
||||
|
</ng-container> |
||||
|
|
||||
|
<!-- Raw Value Column --> |
||||
|
<ng-container matColumnDef="rawValue"> |
||||
|
<th mat-header-cell *matHeaderCellDef>Value</th> |
||||
|
<td mat-cell *matCellDef="let field"> |
||||
|
@if (field.isEditing) { |
||||
|
<mat-form-field class="compact-field"> |
||||
|
<input matInput [(ngModel)]="field.editValue" /> |
||||
|
</mat-form-field> |
||||
|
} @else { |
||||
|
<span [class.user-edited]="field.isUserEdited"> |
||||
|
{{ field.rawValue }} |
||||
|
</span> |
||||
|
} |
||||
|
</td> |
||||
|
</ng-container> |
||||
|
|
||||
|
<!-- Numeric Value Column --> |
||||
|
<ng-container matColumnDef="numericValue"> |
||||
|
<th mat-header-cell *matHeaderCellDef>Parsed</th> |
||||
|
<td mat-cell *matCellDef="let field"> |
||||
|
@if (field.numericValue !== null && field.numericValue !== undefined) { |
||||
|
{{ field.numericValue | number:'1.2-2' }} |
||||
|
} @else { |
||||
|
<span class="text-muted">—</span> |
||||
|
} |
||||
|
</td> |
||||
|
</ng-container> |
||||
|
|
||||
|
<!-- Confidence Column --> |
||||
|
<ng-container matColumnDef="confidence"> |
||||
|
<th mat-header-cell *matHeaderCellDef>Confidence</th> |
||||
|
<td mat-cell *matCellDef="let field"> |
||||
|
<span |
||||
|
class="confidence-badge" |
||||
|
[ngClass]="getConfidenceClass(field.confidenceLevel)" |
||||
|
[matTooltip]="(field.confidence * 100).toFixed(0) + '%'"> |
||||
|
{{ field.confidenceLevel }} |
||||
|
</span> |
||||
|
</td> |
||||
|
</ng-container> |
||||
|
|
||||
|
<!-- Reviewed Column --> |
||||
|
<ng-container matColumnDef="reviewed"> |
||||
|
<th mat-header-cell *matHeaderCellDef>Reviewed</th> |
||||
|
<td mat-cell *matCellDef="let field"> |
||||
|
<mat-checkbox |
||||
|
[checked]="field.isReviewed" |
||||
|
[disabled]="field.confidenceLevel === 'HIGH'" |
||||
|
(change)="toggleReviewed(field)"> |
||||
|
</mat-checkbox> |
||||
|
</td> |
||||
|
</ng-container> |
||||
|
|
||||
|
<!-- Actions Column --> |
||||
|
<ng-container matColumnDef="actions"> |
||||
|
<th mat-header-cell *matHeaderCellDef></th> |
||||
|
<td mat-cell *matCellDef="let field"> |
||||
|
@if (field.isEditing) { |
||||
|
<button mat-icon-button color="primary" (click)="saveEdit(field)" |
||||
|
matTooltip="Save"> |
||||
|
<mat-icon>check</mat-icon> |
||||
|
</button> |
||||
|
<button mat-icon-button (click)="cancelEdit(field)" |
||||
|
matTooltip="Cancel"> |
||||
|
<mat-icon>close</mat-icon> |
||||
|
</button> |
||||
|
} @else { |
||||
|
<button mat-icon-button (click)="startEditing(field)" |
||||
|
matTooltip="Edit"> |
||||
|
<mat-icon>edit</mat-icon> |
||||
|
</button> |
||||
|
} |
||||
|
</td> |
||||
|
</ng-container> |
||||
|
|
||||
|
<tr mat-header-row *matHeaderRowDef="displayedColumns"></tr> |
||||
|
<tr mat-row *matRowDef="let row; columns: displayedColumns" |
||||
|
[ngClass]="{ |
||||
|
'row-low': row.confidenceLevel === 'LOW' && !row.isReviewed, |
||||
|
'row-medium': row.confidenceLevel === 'MEDIUM' && !row.isReviewed |
||||
|
}"> |
||||
|
</tr> |
||||
|
</table> |
||||
|
</div> |
||||
|
</section> |
||||
|
|
||||
|
<!-- Unmapped Items Section (FR-037, FR-038) --> |
||||
|
@if (unmappedItems.length > 0) { |
||||
|
<section class="unmapped-section mb-4"> |
||||
|
<h2 class="h5 mb-3"> |
||||
|
Unmapped Items |
||||
|
<small class="text-muted">({{ unmappedItems.length }} items)</small> |
||||
|
</h2> |
||||
|
|
||||
|
<div class="unmapped-list"> |
||||
|
@for (item of unmappedItems; track item.rawLabel) { |
||||
|
<div class="unmapped-item p-3 mb-2" [ngClass]="{ |
||||
|
'resolved': item.resolution !== null |
||||
|
}"> |
||||
|
<div class="d-flex justify-content-between align-items-start"> |
||||
|
<div> |
||||
|
<strong>{{ item.rawLabel }}</strong> |
||||
|
<span class="ms-2">{{ item.rawValue }}</span> |
||||
|
@if (item.numericValue !== null) { |
||||
|
<small class="text-muted ms-1"> |
||||
|
({{ item.numericValue | number:'1.2-2' }}) |
||||
|
</small> |
||||
|
} |
||||
|
<small class="text-muted d-block">Page {{ item.pageNumber }}</small> |
||||
|
</div> |
||||
|
<div class="unmapped-actions d-flex align-items-center gap-2"> |
||||
|
@if (item.resolution === null) { |
||||
|
<mat-form-field class="compact-field"> |
||||
|
<mat-label>Assign to box</mat-label> |
||||
|
<mat-select (selectionChange)="assignUnmappedItem(item, $event.value)"> |
||||
|
@for (box of availableBoxNumbers; track box) { |
||||
|
<mat-option [value]="box">Box {{ box }}</mat-option> |
||||
|
} |
||||
|
</mat-select> |
||||
|
</mat-form-field> |
||||
|
<button mat-icon-button color="warn" |
||||
|
matTooltip="Discard" |
||||
|
(click)="discardUnmappedItem(item)"> |
||||
|
<mat-icon>delete</mat-icon> |
||||
|
</button> |
||||
|
} @else { |
||||
|
<span class="resolution-badge"> |
||||
|
@if (item.resolution === 'assigned') { |
||||
|
Assigned to Box {{ item.assignedBoxNumber }} |
||||
|
} @else { |
||||
|
Discarded |
||||
|
} |
||||
|
</span> |
||||
|
} |
||||
|
</div> |
||||
|
</div> |
||||
|
</div> |
||||
|
} |
||||
|
</div> |
||||
|
</section> |
||||
|
} |
||||
|
|
||||
|
<!-- Aggregation Summary (FR-033, FR-034) --> |
||||
|
@if (aggregations.length > 0) { |
||||
|
<section class="aggregation-section mb-4"> |
||||
|
<h2 class="h5 mb-3">Aggregation Summary</h2> |
||||
|
|
||||
|
<div class="aggregation-list"> |
||||
|
@for (agg of aggregations; track agg.ruleId) { |
||||
|
<div class="aggregation-row d-flex justify-content-between p-2"> |
||||
|
<div> |
||||
|
<strong>{{ agg.name }}</strong> |
||||
|
<small class="text-muted ms-2"> |
||||
|
({{ agg.operation }} of |
||||
|
@for (box of agg.sourceCells; track box; let last = $last) { |
||||
|
Box {{ box }}@if (!last) {, } |
||||
|
}) |
||||
|
</small> |
||||
|
</div> |
||||
|
<div class="aggregation-value"> |
||||
|
<strong>{{ agg.computedValue | number:'1.2-2' }}</strong> |
||||
|
</div> |
||||
|
</div> |
||||
|
} |
||||
|
</div> |
||||
|
</section> |
||||
|
} |
||||
|
|
||||
|
<!-- Review Status Banner (FR-035) --> |
||||
|
@if (!canConfirm) { |
||||
|
<div class="alert alert-warning mb-3"> |
||||
|
<strong>Review Required:</strong> Please review all medium/low-confidence |
||||
|
fields and resolve all unmapped items before submitting. |
||||
|
</div> |
||||
|
} |
||||
|
|
||||
|
<!-- Action Buttons --> |
||||
|
<div class="actions d-flex justify-content-between mt-4"> |
||||
|
<button mat-stroked-button color="warn" (click)="cancelImport()"> |
||||
|
Cancel Import |
||||
|
</button> |
||||
|
<button |
||||
|
mat-flat-button |
||||
|
color="primary" |
||||
|
[disabled]="!canConfirm || isSaving" |
||||
|
(click)="submitVerification()"> |
||||
|
@if (isSaving) { |
||||
|
Saving... |
||||
|
} @else { |
||||
|
Confirm & Continue |
||||
|
} |
||||
|
</button> |
||||
|
</div> |
||||
|
} |
||||
|
</div> |
||||
|
</div> |
||||
|
</div> |
||||
@ -0,0 +1,116 @@ |
|||||
|
:host { |
||||
|
display: block; |
||||
|
} |
||||
|
|
||||
|
.fields-section { |
||||
|
.table-responsive { |
||||
|
overflow-x: auto; |
||||
|
} |
||||
|
|
||||
|
.compact-field { |
||||
|
width: 160px; |
||||
|
|
||||
|
.mat-mdc-form-field-infix { |
||||
|
padding: 4px 0; |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
.user-edited { |
||||
|
font-style: italic; |
||||
|
color: var(--primary-color, #1976d2); |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
// Confidence badge styles |
||||
|
.confidence-badge { |
||||
|
display: inline-block; |
||||
|
padding: 2px 8px; |
||||
|
border-radius: 4px; |
||||
|
font-size: 0.75rem; |
||||
|
font-weight: 600; |
||||
|
text-transform: uppercase; |
||||
|
} |
||||
|
|
||||
|
.confidence-high { |
||||
|
background-color: rgba(76, 175, 80, 0.15); |
||||
|
color: #2e7d32; |
||||
|
} |
||||
|
|
||||
|
.confidence-medium { |
||||
|
background-color: rgba(255, 193, 7, 0.15); |
||||
|
color: #f57f17; |
||||
|
} |
||||
|
|
||||
|
.confidence-low { |
||||
|
background-color: rgba(244, 67, 54, 0.15); |
||||
|
color: #c62828; |
||||
|
} |
||||
|
|
||||
|
// Row highlighting for unreviewed medium/low |
||||
|
.row-low { |
||||
|
background-color: rgba(244, 67, 54, 0.05) !important; |
||||
|
} |
||||
|
|
||||
|
.row-medium { |
||||
|
background-color: rgba(255, 193, 7, 0.05) !important; |
||||
|
} |
||||
|
|
||||
|
// Unmapped items |
||||
|
.unmapped-section { |
||||
|
.unmapped-item { |
||||
|
border: 1px solid var(--border-color, #e0e0e0); |
||||
|
border-radius: 8px; |
||||
|
transition: background-color 0.2s ease; |
||||
|
|
||||
|
&.resolved { |
||||
|
opacity: 0.7; |
||||
|
background-color: rgba(76, 175, 80, 0.05); |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
.resolution-badge { |
||||
|
font-size: 0.85rem; |
||||
|
color: var(--text-muted, #666); |
||||
|
font-style: italic; |
||||
|
} |
||||
|
|
||||
|
.compact-field { |
||||
|
width: 140px; |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
// Aggregation summary |
||||
|
.aggregation-section { |
||||
|
.aggregation-row { |
||||
|
border-bottom: 1px solid var(--border-color, #e0e0e0); |
||||
|
|
||||
|
&:last-child { |
||||
|
border-bottom: none; |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
.aggregation-value { |
||||
|
font-size: 1.1rem; |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
// Alerts |
||||
|
.alert-danger { |
||||
|
background-color: rgba(244, 67, 54, 0.1); |
||||
|
border: 1px solid rgba(244, 67, 54, 0.3); |
||||
|
border-radius: 4px; |
||||
|
color: #f44336; |
||||
|
padding: 12px 16px; |
||||
|
} |
||||
|
|
||||
|
.alert-warning { |
||||
|
background-color: rgba(255, 193, 7, 0.1); |
||||
|
border: 1px solid rgba(255, 193, 7, 0.3); |
||||
|
border-radius: 4px; |
||||
|
color: #e65100; |
||||
|
padding: 12px 16px; |
||||
|
} |
||||
|
|
||||
|
.actions { |
||||
|
padding-bottom: 2rem; |
||||
|
} |
||||
Loading…
Reference in new issue