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 |
|||
* from CellAggregationRule records. Implemented in Phase 4 (US2). |
|||
* from CellAggregationRule records. |
|||
* FR-034, FR-039: Computed dynamically, only rules persisted. |
|||
*/ |
|||
@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