mirror of https://github.com/ghostfolio/ghostfolio
12 changed files with 660 additions and 19 deletions
@ -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<string, any>; |
|||
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); |
|||
} |
|||
} |
|||
@ -0,0 +1,102 @@ |
|||
<div class="container"> |
|||
<div class="d-flex align-items-center mb-3"> |
|||
<button mat-icon-button (click)="goBack()"> |
|||
<mat-icon>arrow_back</mat-icon> |
|||
</button> |
|||
<h1 class="h3 mb-0 ms-2">K-Document Detail</h1> |
|||
</div> |
|||
|
|||
@if (error) { |
|||
<div class="alert alert-danger">{{ error }}</div> |
|||
} |
|||
|
|||
@if (kDocument) { |
|||
<!-- Summary Card --> |
|||
<mat-card class="mb-4"> |
|||
<mat-card-header> |
|||
<mat-card-title>{{ kDocument.partnershipName || kDocument.partnershipId }}</mat-card-title> |
|||
<mat-card-subtitle> |
|||
{{ kDocument.type }} — Tax Year {{ kDocument.taxYear }} |
|||
</mat-card-subtitle> |
|||
</mat-card-header> |
|||
<mat-card-content> |
|||
<div class="detail-row"> |
|||
<span class="label">Filing Status:</span> |
|||
<mat-chip-set> |
|||
<mat-chip [class.chip-draft]="kDocument.filingStatus === 'DRAFT'" |
|||
[class.chip-estimated]="kDocument.filingStatus === 'ESTIMATED'" |
|||
[class.chip-final]="kDocument.filingStatus === 'FINAL'"> |
|||
{{ kDocument.filingStatus }} |
|||
</mat-chip> |
|||
</mat-chip-set> |
|||
</div> |
|||
<div class="detail-row"> |
|||
<span class="label">Created:</span> |
|||
<span>{{ kDocument.createdAt | date:'medium' }}</span> |
|||
</div> |
|||
<div class="detail-row"> |
|||
<span class="label">Updated:</span> |
|||
<span>{{ kDocument.updatedAt | date:'medium' }}</span> |
|||
</div> |
|||
</mat-card-content> |
|||
</mat-card> |
|||
|
|||
<!-- Aggregation Summary (FR-036) --> |
|||
@if (aggregations.length > 0) { |
|||
<h2 class="h5 mb-3">Aggregation Summary</h2> |
|||
<div class="aggregation-cards mb-4"> |
|||
@for (agg of aggregations; track agg.name) { |
|||
<mat-card class="aggregation-card"> |
|||
<mat-card-header> |
|||
<mat-card-title>{{ agg.name }}</mat-card-title> |
|||
</mat-card-header> |
|||
<mat-card-content> |
|||
<div class="aggregation-value"> |
|||
{{ agg.value | currency:'USD':'symbol':'1.2-2' }} |
|||
</div> |
|||
@if (agg.breakdown && agg.breakdown.length > 0) { |
|||
<div class="breakdown"> |
|||
@for (item of agg.breakdown; track item.boxNumber) { |
|||
<div class="breakdown-row"> |
|||
<span class="box-label">Box {{ item.boxNumber }}:</span> |
|||
<span class="box-value">{{ item.value | currency:'USD':'symbol':'1.2-2' }}</span> |
|||
</div> |
|||
} |
|||
</div> |
|||
} |
|||
</mat-card-content> |
|||
</mat-card> |
|||
} |
|||
</div> |
|||
} |
|||
|
|||
<!-- Raw Box Values --> |
|||
<h2 class="h5 mb-3">Box Values</h2> |
|||
@if (boxData.length > 0) { |
|||
<table mat-table [dataSource]="boxData" class="w-100 box-table"> |
|||
<ng-container matColumnDef="boxNumber"> |
|||
<th mat-header-cell *matHeaderCellDef>Box #</th> |
|||
<td mat-cell *matCellDef="let row">{{ row.boxNumber }}</td> |
|||
</ng-container> |
|||
|
|||
<ng-container matColumnDef="value"> |
|||
<th mat-header-cell *matHeaderCellDef>Value</th> |
|||
<td mat-cell *matCellDef="let row"> |
|||
@if (row.value !== null) { |
|||
{{ row.value | currency:'USD':'symbol':'1.2-2' }} |
|||
} @else { |
|||
<span class="text-muted">—</span> |
|||
} |
|||
</td> |
|||
</ng-container> |
|||
|
|||
<tr mat-header-row *matHeaderRowDef="boxColumns"></tr> |
|||
<tr mat-row *matRowDef="let row; columns: boxColumns;"></tr> |
|||
</table> |
|||
} @else { |
|||
<p class="text-muted">No box values available.</p> |
|||
} |
|||
} @else if (!error) { |
|||
<p>Loading...</p> |
|||
} |
|||
</div> |
|||
@ -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; |
|||
} |
|||
Loading…
Reference in new issue