From 063722b8299df35674452a036b0613df7d49acb9 Mon Sep 17 00:00:00 2001 From: Robert Patch Date: Wed, 18 Mar 2026 02:15:21 -0700 Subject: [PATCH] feat(k1-import): Phase 7 US5 - import history, reprocess, audit trail, KDocument detail with aggregation summary --- .../cell-mapping/cell-mapping.controller.ts | 17 +++ .../app/cell-mapping/cell-mapping.service.ts | 43 ++++++ .../src/app/k1-import/k1-import.controller.ts | 31 +++++ .../src/app/k1-import/k1-import.service.ts | 124 ++++++++++++++++++ .../k-document-detail.component.ts | 119 +++++++++++++++++ .../k-document-detail/k-document-detail.html | 102 ++++++++++++++ .../k-document-detail/k-document-detail.scss | 79 +++++++++++ .../k-documents/k-documents-page.routes.ts | 9 ++ .../k1-import/k1-import-page.component.ts | 56 +++++++- .../app/pages/k1-import/k1-import-page.html | 65 ++++++++- prisma/schema.prisma | 24 ++-- specs/004-k1-scan-import/tasks.md | 10 +- 12 files changed, 660 insertions(+), 19 deletions(-) create mode 100644 apps/client/src/app/pages/k-documents/k-document-detail/k-document-detail.component.ts create mode 100644 apps/client/src/app/pages/k-documents/k-document-detail/k-document-detail.html create mode 100644 apps/client/src/app/pages/k-documents/k-document-detail/k-document-detail.scss diff --git a/apps/api/src/app/cell-mapping/cell-mapping.controller.ts b/apps/api/src/app/cell-mapping/cell-mapping.controller.ts index d72846ea3..6a54ae4bb 100644 --- a/apps/api/src/app/cell-mapping/cell-mapping.controller.ts +++ b/apps/api/src/app/cell-mapping/cell-mapping.controller.ts @@ -112,4 +112,21 @@ export class CellMappingController { data.rules ); } + + /** + * GET /api/v1/cell-mapping/aggregation-rules/compute + * Compute aggregation values for a specific KDocument (FR-036). + */ + @HasPermission(permissions.readKDocument) + @Get('aggregation-rules/compute') + @UseGuards(AuthGuard('jwt'), HasPermissionGuard) + public async computeAggregations( + @Query('kDocumentId') kDocumentId: string, + @Query('partnershipId') partnershipId?: string + ) { + return this.cellMappingService.computeAggregations( + kDocumentId, + partnershipId + ); + } } diff --git a/apps/api/src/app/cell-mapping/cell-mapping.service.ts b/apps/api/src/app/cell-mapping/cell-mapping.service.ts index 65d28691c..6b3caf3b6 100644 --- a/apps/api/src/app/cell-mapping/cell-mapping.service.ts +++ b/apps/api/src/app/cell-mapping/cell-mapping.service.ts @@ -265,4 +265,47 @@ export class CellMappingService implements OnModuleInit { return this.getAggregationRules(partnershipId); } + + /** + * Compute aggregation values for a specific KDocument (FR-036). + */ + public async computeAggregations( + kDocumentId: string, + partnershipId?: string + ) { + const kDocument = await this.prismaService.kDocument.findUnique({ + where: { id: kDocumentId } + }); + + if (!kDocument) { + throw new HttpException('KDocument not found', StatusCodes.NOT_FOUND); + } + + const pId = partnershipId || kDocument.partnershipId; + const rules = await this.getAggregationRules(pId); + const data = kDocument.data as Record; + + return rules.map((rule: any) => { + const sourceCells = (rule.sourceCells || []) as string[]; + const breakdown = sourceCells.map((boxNumber: string) => ({ + boxNumber, + value: typeof data[boxNumber] === 'number' ? data[boxNumber] : 0 + })); + + let value = 0; + if (rule.operation === 'SUM') { + value = breakdown.reduce( + (sum: number, item: any) => sum + item.value, + 0 + ); + } + + return { + name: rule.name, + operation: rule.operation, + value, + breakdown + }; + }); + } } diff --git a/apps/api/src/app/k1-import/k1-import.controller.ts b/apps/api/src/app/k1-import/k1-import.controller.ts index f6ddb1c58..fe10d28b3 100644 --- a/apps/api/src/app/k1-import/k1-import.controller.ts +++ b/apps/api/src/app/k1-import/k1-import.controller.ts @@ -12,6 +12,7 @@ import { Param, Post, Put, + Query, UploadedFile, UseGuards, UseInterceptors @@ -53,6 +54,24 @@ export class K1ImportController { }); } + /** + * GET /api/v1/k1-import/history + * Get import history for a partnership. + */ + @HasPermission(permissions.readKDocument) + @Get('history') + @UseGuards(AuthGuard('jwt'), HasPermissionGuard) + public async getImportHistory( + @Query('partnershipId') partnershipId: string, + @Query('taxYear') taxYear?: string + ) { + return this.k1ImportService.getHistory( + this.request.user.id, + partnershipId, + taxYear ? parseInt(taxYear, 10) : undefined + ); + } + /** * GET /api/v1/k1-import/:id * Get the current state of an import session. @@ -90,6 +109,18 @@ export class K1ImportController { return this.k1ImportService.cancel(id, this.request.user.id); } + /** + * POST /api/v1/k1-import/:id/reprocess + * Re-process a previously uploaded K-1 PDF with current cell mapping. + */ + @HasPermission(permissions.updateKDocument) + @Post(':id/reprocess') + @HttpCode(StatusCodes.OK) + @UseGuards(AuthGuard('jwt'), HasPermissionGuard) + public async reprocessImportSession(@Param('id') id: string) { + return this.k1ImportService.reprocess(id, this.request.user.id); + } + /** * POST /api/v1/k1-import/:id/confirm * Confirm verified data and trigger auto-creation of model objects. diff --git a/apps/api/src/app/k1-import/k1-import.service.ts b/apps/api/src/app/k1-import/k1-import.service.ts index 4106cd97b..c8aefeea5 100644 --- a/apps/api/src/app/k1-import/k1-import.service.ts +++ b/apps/api/src/app/k1-import/k1-import.service.ts @@ -389,6 +389,124 @@ export class K1ImportService { return updated; } + /** + * Get import history for a partnership, optionally filtered by tax year. + * FR-022: History of all K-1 import attempts per partnership. + */ + public async getHistory( + userId: string, + partnershipId: string, + taxYear?: number + ) { + const where: any = { userId, partnershipId }; + if (taxYear) { + where.taxYear = taxYear; + } + + const sessions = await this.prismaService.k1ImportSession.findMany({ + where, + orderBy: { createdAt: 'desc' }, + select: { + id: true, + partnershipId: true, + status: true, + taxYear: true, + fileName: true, + extractionMethod: true, + kDocumentId: true, + createdAt: true + } + }); + + return sessions; + } + + /** + * Re-process a previously uploaded K-1 PDF with the current cell mapping. + * FR-023: Creates a new import session using the stored document from the original session. + */ + public async reprocess(sessionId: string, userId: string) { + const originalSession = await this.getSession(sessionId, userId); + + if (!originalSession.documentId) { + throw new HttpException( + 'Original session has no stored document to re-process', + StatusCodes.BAD_REQUEST + ); + } + + // Read the stored file from uploads directory + const document = await this.prismaService.document.findUnique({ + where: { id: originalSession.documentId } + }); + + if (!document) { + throw new HttpException( + 'Stored document not found', + StatusCodes.NOT_FOUND + ); + } + + // Create a new import session in PROCESSING status + const newSession = await this.prismaService.k1ImportSession.create({ + data: { + partnershipId: originalSession.partnershipId, + userId, + status: K1ImportStatus.PROCESSING, + taxYear: originalSession.taxYear, + fileName: originalSession.fileName, + fileSize: originalSession.fileSize, + extractionMethod: 'pending', + documentId: originalSession.documentId + } + }); + + // Read file from disk and run extraction asynchronously + const fs = await import('fs/promises'); + const filePath = (document as any).url || (document as any).filePath; + + if (!filePath) { + throw new HttpException( + 'Cannot determine file path for stored document', + StatusCodes.INTERNAL_SERVER_ERROR + ); + } + + const fileBuffer = await fs.readFile(filePath); + const file = { + buffer: fileBuffer, + originalname: originalSession.fileName, + mimetype: 'application/pdf', + size: originalSession.fileSize + }; + + this.runExtraction( + newSession.id, + file, + originalSession.partnershipId + ).catch((err) => { + this.logger.error( + `Reprocess extraction failed for session ${newSession.id}: ${err.message}`, + err.stack + ); + }); + + this.logger.log( + `Session ${sessionId}: Re-processing started as new session ${newSession.id}` + ); + + return { + id: newSession.id, + partnershipId: newSession.partnershipId, + status: newSession.status, + taxYear: newSession.taxYear, + fileName: newSession.fileName, + fileSize: newSession.fileSize, + extractionMethod: newSession.extractionMethod, + createdAt: newSession.createdAt + }; + } + /** * Confirm verified data and auto-create model objects. * VERIFIED → CONFIRMED transition. @@ -470,11 +588,17 @@ export class K1ImportService { // FR-012: Create or update KDocument let kDocument; if (existingKDocument && data.existingKDocumentAction === 'UPDATE') { + // FR-025: Preserve previous values for audit trail + const previousData = existingKDocument.data; + const previousFilingStatus = existingKDocument.filingStatus; + kDocument = await this.prismaService.kDocument.update({ where: { id: existingKDocument.id }, data: { filingStatus: data.filingStatus, data: kDocumentData as any, + previousData: previousData as any, + previousFilingStatus, documentFileId: session.documentId } }); diff --git a/apps/client/src/app/pages/k-documents/k-document-detail/k-document-detail.component.ts b/apps/client/src/app/pages/k-documents/k-document-detail/k-document-detail.component.ts new file mode 100644 index 000000000..c3bb3d3b7 --- /dev/null +++ b/apps/client/src/app/pages/k-documents/k-document-detail/k-document-detail.component.ts @@ -0,0 +1,119 @@ +import { FamilyOfficeDataService } from '@ghostfolio/client/services/family-office-data.service'; +import { K1ImportDataService } from '@ghostfolio/client/services/k1-import-data.service'; +import { K1AggregationResult } from '@ghostfolio/common/interfaces/k1-import.interface'; + +import { CommonModule } from '@angular/common'; +import { + ChangeDetectionStrategy, + ChangeDetectorRef, + Component, + DestroyRef, + OnInit +} from '@angular/core'; +import { takeUntilDestroyed } from '@angular/core/rxjs-interop'; +import { MatButtonModule } from '@angular/material/button'; +import { MatCardModule } from '@angular/material/card'; +import { MatChipsModule } from '@angular/material/chips'; +import { MatIconModule } from '@angular/material/icon'; +import { MatTableModule } from '@angular/material/table'; +import { ActivatedRoute, Router, RouterModule } from '@angular/router'; + +@Component({ + changeDetection: ChangeDetectionStrategy.OnPush, + host: { class: 'page' }, + imports: [ + CommonModule, + MatButtonModule, + MatCardModule, + MatChipsModule, + MatIconModule, + MatTableModule, + RouterModule + ], + selector: 'gf-k-document-detail', + styleUrls: ['./k-document-detail.scss'], + templateUrl: './k-document-detail.html' +}) +export class KDocumentDetailComponent implements OnInit { + public aggregations: K1AggregationResult[] = []; + public boxColumns = ['boxNumber', 'value']; + public boxData: Array<{ boxNumber: string; value: number | null }> = []; + public error: string | null = null; + public kDocument: any = null; + public kDocumentId: string; + + public constructor( + private readonly activatedRoute: ActivatedRoute, + private readonly changeDetectorRef: ChangeDetectorRef, + private readonly destroyRef: DestroyRef, + private readonly familyOfficeDataService: FamilyOfficeDataService, + private readonly k1ImportDataService: K1ImportDataService, + private readonly router: Router + ) {} + + public ngOnInit(): void { + this.kDocumentId = this.activatedRoute.snapshot.paramMap.get('id') || ''; + + if (this.kDocumentId) { + this.loadKDocument(); + this.loadAggregations(); + } + } + + public goBack(): void { + this.router.navigate(['/k-documents']); + } + + private loadKDocument(): void { + this.familyOfficeDataService + .fetchKDocuments() + .pipe(takeUntilDestroyed(this.destroyRef)) + .subscribe({ + next: (docs) => { + this.kDocument = docs.find((d) => d.id === this.kDocumentId) || null; + + if (this.kDocument?.data) { + const data = this.kDocument.data as Record; + this.boxData = Object.entries(data) + .map(([boxNumber, value]) => ({ + boxNumber, + value: typeof value === 'number' ? value : null + })) + .sort((a, b) => this.compareBoxNumbers(a.boxNumber, b.boxNumber)); + } + + this.changeDetectorRef.markForCheck(); + }, + error: () => { + this.error = 'Failed to load K-Document.'; + this.changeDetectorRef.markForCheck(); + } + }); + } + + private loadAggregations(): void { + this.k1ImportDataService + .computeAggregations({ kDocumentId: this.kDocumentId }) + .pipe(takeUntilDestroyed(this.destroyRef)) + .subscribe({ + next: (aggregations) => { + this.aggregations = aggregations; + this.changeDetectorRef.markForCheck(); + }, + error: () => { + // Aggregations may not be configured yet + this.aggregations = []; + this.changeDetectorRef.markForCheck(); + } + }); + } + + private compareBoxNumbers(a: string, b: string): number { + const numA = parseInt(a.replace(/[^0-9]/g, ''), 10) || 0; + const numB = parseInt(b.replace(/[^0-9]/g, ''), 10) || 0; + if (numA !== numB) { + return numA - numB; + } + return a.localeCompare(b); + } +} diff --git a/apps/client/src/app/pages/k-documents/k-document-detail/k-document-detail.html b/apps/client/src/app/pages/k-documents/k-document-detail/k-document-detail.html new file mode 100644 index 000000000..6ade119bc --- /dev/null +++ b/apps/client/src/app/pages/k-documents/k-document-detail/k-document-detail.html @@ -0,0 +1,102 @@ +
+
+ +

K-Document Detail

+
+ + @if (error) { +
{{ error }}
+ } + + @if (kDocument) { + + + + {{ kDocument.partnershipName || kDocument.partnershipId }} + + {{ kDocument.type }} — Tax Year {{ kDocument.taxYear }} + + + +
+ Filing Status: + + + {{ kDocument.filingStatus }} + + +
+
+ Created: + {{ kDocument.createdAt | date:'medium' }} +
+
+ Updated: + {{ kDocument.updatedAt | date:'medium' }} +
+
+
+ + + @if (aggregations.length > 0) { +

Aggregation Summary

+
+ @for (agg of aggregations; track agg.name) { + + + {{ agg.name }} + + +
+ {{ agg.value | currency:'USD':'symbol':'1.2-2' }} +
+ @if (agg.breakdown && agg.breakdown.length > 0) { +
+ @for (item of agg.breakdown; track item.boxNumber) { +
+ Box {{ item.boxNumber }}: + {{ item.value | currency:'USD':'symbol':'1.2-2' }} +
+ } +
+ } +
+
+ } +
+ } + + +

Box Values

+ @if (boxData.length > 0) { + + + + + + + + + + + + + +
Box #{{ row.boxNumber }}Value + @if (row.value !== null) { + {{ row.value | currency:'USD':'symbol':'1.2-2' }} + } @else { + + } +
+ } @else { +

No box values available.

+ } + } @else if (!error) { +

Loading...

+ } +
diff --git a/apps/client/src/app/pages/k-documents/k-document-detail/k-document-detail.scss b/apps/client/src/app/pages/k-documents/k-document-detail/k-document-detail.scss new file mode 100644 index 000000000..21adbc2ad --- /dev/null +++ b/apps/client/src/app/pages/k-documents/k-document-detail/k-document-detail.scss @@ -0,0 +1,79 @@ +:host { + display: block; +} + +.container { + max-width: 960px; + margin: 0 auto; + padding: 1.5rem; +} + +.detail-row { + display: flex; + align-items: center; + gap: 0.75rem; + margin-bottom: 0.5rem; + + .label { + font-weight: 500; + min-width: 120px; + color: rgba(0, 0, 0, 0.6); + } +} + +// Filing status chips + +.chip-draft { + --mdc-chip-elevated-container-color: #e0e0e0; +} + +.chip-estimated { + --mdc-chip-elevated-container-color: #fff3e0; +} + +.chip-final { + --mdc-chip-elevated-container-color: #e8f5e9; +} + +// Aggregation cards + +.aggregation-cards { + display: grid; + grid-template-columns: repeat(auto-fill, minmax(280px, 1fr)); + gap: 1rem; +} + +.aggregation-card { + .aggregation-value { + font-size: 1.5rem; + font-weight: 600; + margin-bottom: 0.5rem; + } + + .breakdown { + border-top: 1px solid rgba(0, 0, 0, 0.08); + padding-top: 0.5rem; + font-size: 0.875rem; + } + + .breakdown-row { + display: flex; + justify-content: space-between; + padding: 2px 0; + } + + .box-label { + color: rgba(0, 0, 0, 0.6); + font-family: monospace; + } + + .box-value { + font-weight: 500; + } +} + +// Box table + +.box-table { + max-width: 500px; +} diff --git a/apps/client/src/app/pages/k-documents/k-documents-page.routes.ts b/apps/client/src/app/pages/k-documents/k-documents-page.routes.ts index 92b18bed5..fff8e1898 100644 --- a/apps/client/src/app/pages/k-documents/k-documents-page.routes.ts +++ b/apps/client/src/app/pages/k-documents/k-documents-page.routes.ts @@ -10,5 +10,14 @@ export const routes: Routes = [ component: KDocumentsPageComponent, path: '', title: 'K-1 / K-3 Documents' + }, + { + canActivate: [AuthGuard], + loadComponent: () => + import('./k-document-detail/k-document-detail.component').then( + (c) => c.KDocumentDetailComponent + ), + path: ':id', + title: 'K-Document Detail' } ]; diff --git a/apps/client/src/app/pages/k1-import/k1-import-page.component.ts b/apps/client/src/app/pages/k1-import/k1-import-page.component.ts index 8774ab43b..5cb41ee39 100644 --- a/apps/client/src/app/pages/k1-import/k1-import-page.component.ts +++ b/apps/client/src/app/pages/k1-import/k1-import-page.component.ts @@ -16,7 +16,9 @@ import { MatFormFieldModule } from '@angular/material/form-field'; import { MatIconModule } from '@angular/material/icon'; import { MatProgressBarModule } from '@angular/material/progress-bar'; import { MatSelectModule } from '@angular/material/select'; -import { Router } from '@angular/router'; +import { MatTableModule } from '@angular/material/table'; +import { MatTooltipModule } from '@angular/material/tooltip'; +import { Router, RouterModule } from '@angular/router'; import { addIcons } from 'ionicons'; import { cloudUploadOutline, @@ -33,7 +35,10 @@ import { MatFormFieldModule, MatIconModule, MatProgressBarModule, - MatSelectModule + MatSelectModule, + MatTableModule, + MatTooltipModule, + RouterModule ], selector: 'gf-k1-import-page', styleUrls: ['./k1-import-page.scss'], @@ -42,6 +47,8 @@ import { export class K1ImportPageComponent implements OnInit { public error: string | null = null; public extractionStatus: string | null = null; + public historyColumns = ['createdAt', 'fileName', 'taxYear', 'status', 'kDocument', 'actions']; + public importHistory: any[] = []; public isUploading = false; public partnerships: Array<{ id: string; name: string }> = []; public selectedFile: File | null = null; @@ -72,6 +79,51 @@ export class K1ImportPageComponent implements OnInit { this.fetchPartnerships(); } + public onPartnershipChange(): void { + if (this.selectedPartnershipId) { + this.loadImportHistory(); + } + } + + public loadImportHistory(): void { + if (!this.selectedPartnershipId) { + return; + } + + this.k1ImportDataService + .fetchImportHistory({ partnershipId: this.selectedPartnershipId }) + .pipe(takeUntilDestroyed(this.destroyRef)) + .subscribe({ + next: (history) => { + this.importHistory = history; + this.changeDetectorRef.markForCheck(); + }, + error: () => { + this.importHistory = []; + this.changeDetectorRef.markForCheck(); + } + }); + } + + public reprocessSession(sessionId: string): void { + this.k1ImportDataService + .reprocessImportSession(sessionId) + .pipe(takeUntilDestroyed(this.destroyRef)) + .subscribe({ + next: (result) => { + this.sessionId = result.id; + this.extractionStatus = 'Processing...'; + this.changeDetectorRef.markForCheck(); + this.startPolling(result.id); + }, + error: (err) => { + this.error = + err?.error?.message || err?.message || 'Re-processing failed.'; + this.changeDetectorRef.markForCheck(); + } + }); + } + public onFileSelected(event: Event): void { const input = event.target as HTMLInputElement; if (input.files && input.files.length > 0) { diff --git a/apps/client/src/app/pages/k1-import/k1-import-page.html b/apps/client/src/app/pages/k1-import/k1-import-page.html index 863cbf008..e79d71460 100644 --- a/apps/client/src/app/pages/k1-import/k1-import-page.html +++ b/apps/client/src/app/pages/k1-import/k1-import-page.html @@ -15,7 +15,7 @@
Partnership - + @for (p of partnerships; track p.id) { {{ p.name }} } @@ -96,4 +96,67 @@ }
+ + + @if (importHistory.length > 0) { +
+
+

Import History

+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
Date{{ row.createdAt | date:'short' }}File{{ row.fileName }}Tax Year{{ row.taxYear }}Status + + {{ row.status }} + + K-Document + @if (row.kDocumentId) { + View + } @else { + — + } + Actions + +
+
+
+ } diff --git a/prisma/schema.prisma b/prisma/schema.prisma index 47289671e..26c33cf68 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -541,17 +541,19 @@ model Distribution { } model KDocument { - id String @id @default(uuid()) - partnershipId String - partnership Partnership @relation(fields: [partnershipId], onDelete: Cascade, references: [id]) - type KDocumentType - taxYear Int - filingStatus KDocumentStatus @default(DRAFT) - data Json - documentFileId String? - documentFile Document? @relation(fields: [documentFileId], references: [id]) - createdAt DateTime @default(now()) - updatedAt DateTime @updatedAt + id String @id @default(uuid()) + partnershipId String + partnership Partnership @relation(fields: [partnershipId], onDelete: Cascade, references: [id]) + type KDocumentType + taxYear Int + filingStatus KDocumentStatus @default(DRAFT) + data Json + previousData Json? + previousFilingStatus KDocumentStatus? + documentFileId String? + documentFile Document? @relation(fields: [documentFileId], references: [id]) + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt importSession K1ImportSession? diff --git a/specs/004-k1-scan-import/tasks.md b/specs/004-k1-scan-import/tasks.md index cac26386d..20f3cbf66 100644 --- a/specs/004-k1-scan-import/tasks.md +++ b/specs/004-k1-scan-import/tasks.md @@ -142,11 +142,11 @@ ### Implementation for User Story 5 -- [ ] T042 [US5] Implement import history query (filter by partnershipId and optional taxYear, order by createdAt desc) in apps/api/src/app/k1-import/k1-import.service.ts and GET /api/v1/k1-import/history endpoint in k1-import.controller.ts -- [ ] T043 [US5] Implement reprocess endpoint (re-extract stored PDF with current cell mapping, create new session, original session unchanged) in apps/api/src/app/k1-import/k1-import.service.ts and POST /api/v1/k1-import/:id/reprocess in k1-import.controller.ts -- [ ] T044 [US5] Add import history list view (date, filename, status, tax year, link to KDocument) to K1 import page in apps/client/src/app/pages/k1-import/k1-import-page.component.ts -- [ ] T045 [US5] Implement KDocument status transitions (DRAFT → ESTIMATED → FINAL) with previous value preservation for audit trail (FR-024/FR-025) in apps/api/src/app/k1-import/k1-import.service.ts -- [ ] T046 [US5] Extend KDocument detail view with aggregation summary section (display named aggregation totals alongside raw box values per FR-036) in apps/client/src/app/pages/k-document/k-document-detail/ +- [X] T042 [US5] Implement import history query (filter by partnershipId and optional taxYear, order by createdAt desc) in apps/api/src/app/k1-import/k1-import.service.ts and GET /api/v1/k1-import/history endpoint in k1-import.controller.ts +- [X] T043 [US5] Implement reprocess endpoint (re-extract stored PDF with current cell mapping, create new session, original session unchanged) in apps/api/src/app/k1-import/k1-import.service.ts and POST /api/v1/k1-import/:id/reprocess in k1-import.controller.ts +- [X] T044 [US5] Add import history list view (date, filename, status, tax year, link to KDocument) to K1 import page in apps/client/src/app/pages/k1-import/k1-import-page.component.ts +- [X] T045 [US5] Implement KDocument status transitions (DRAFT → ESTIMATED → FINAL) with previous value preservation for audit trail (FR-024/FR-025) in apps/api/src/app/k1-import/k1-import.service.ts +- [X] T046 [US5] Extend KDocument detail view with aggregation summary section (display named aggregation totals alongside raw box values per FR-036) in apps/client/src/app/pages/k-document/k-document-detail/ **Checkpoint**: All user stories should now be independently functional