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