Browse Source

feat(008): Family Office UI redesign - nav restructure, entity tabs, dashboard management

pull/6701/head
Robert Patch 2 months ago
parent
commit
cfcb86cbe4
  1. 6
      .github/agents/copilot-instructions.md
  2. 62
      apps/api/src/app/admin/dev-seed.service.ts
  3. 32
      apps/api/src/app/k1-box-definition/cell-mapping-legacy.controller.ts
  4. 2
      apps/client/src/app/app.routes.ts
  5. 329
      apps/client/src/app/components/header/header.component.html
  6. 52
      apps/client/src/app/components/header/header.component.ts
  7. 137
      apps/client/src/app/pages/entity-detail/entity-detail-page.html
  8. 285
      apps/client/src/app/pages/family-dashboard/dashboard-page.component.ts
  9. 90
      apps/client/src/app/pages/portfolio/analysis/analysis-page.component.ts
  10. 203
      apps/client/src/app/pages/portfolio/analysis/analysis-page.html
  11. 1
      libs/ui/src/lib/k1-income-summary/index.ts
  12. 160
      libs/ui/src/lib/k1-income-summary/k1-income-summary.component.ts
  13. 1
      libs/ui/src/lib/nav-menu-group/index.ts
  14. 66
      libs/ui/src/lib/nav-menu-group/nav-menu-group.component.ts
  15. 36
      specs/008-fo-ui-redesign/checklists/requirements.md
  16. 82
      specs/008-fo-ui-redesign/contracts/analysis-page.md
  17. 76
      specs/008-fo-ui-redesign/contracts/navigation.md
  18. 143
      specs/008-fo-ui-redesign/data-model.md
  19. 102
      specs/008-fo-ui-redesign/plan.md
  20. 97
      specs/008-fo-ui-redesign/quickstart.md
  21. 147
      specs/008-fo-ui-redesign/research.md
  22. 147
      specs/008-fo-ui-redesign/spec.md
  23. 220
      specs/008-fo-ui-redesign/tasks.md

6
.github/agents/copilot-instructions.md

@ -1,6 +1,6 @@
# portfolio-management Development Guidelines
Auto-generated from all feature plans. Last updated: 2026-03-21
Auto-generated from all feature plans. Last updated: 2026-03-22
## Active Technologies
- TypeScript 5.9.2, Node.js >= 22.18.0 + Angular 21.1.1, NestJS 11.1.14, Angular Material 21.1.1, Prisma 6.19.0, big.js, date-fns 4.1.0 (003-portfolio-performance-views)
@ -11,6 +11,8 @@ Auto-generated from all feature plans. Last updated: 2026-03-21
- PostgreSQL via Prisma ORM (existing K1ImportSession, Document tables) (005-k1-parser-fix)
- TypeScript 5.x (strict mode, `noUnusedLocals`, `noUnusedParameters`) + NestJS 11+ (module-based DI), Prisma ORM 6.x, PostgreSQL 16, Redis (caching), pdfjs-dist (extraction — unaffected by this feature) (006-k1-model-review)
- PostgreSQL via Prisma (Docker dev: port 5434). All schema changes via `prisma migrate dev`. (006-k1-model-review)
- TypeScript 5.9.2, Node.js ≥22.18.0 + NestJS 11.1.14 (API), Angular 21.1.1 + Angular Material 21.1.1 (client), Prisma 6.19.0 (ORM), Nx 22.5.3 (monorepo), chart.js 4.5.1, date-fns 4.1.0, Bull 4.16.5 (queues), Redis (caching), Ionic 8.8.1 (008-fo-ui-redesign)
- PostgreSQL via Prisma ORM, Redis for caching (008-fo-ui-redesign)
- TypeScript 5.9.2, Node.js ≥22.18.0 + NestJS 11.1.14 (API), Angular 21.1.1 + Angular Material 21.1.1 (client), Prisma 6.19.0 (ORM), Nx 22.5.3 (monorepo), big.js (decimal math), date-fns 4.1.0, chart.js 4.5.1, Bull 4.16.5 (job queues), Redis (caching), yahoo-finance2 3.13.2 (001-family-office-transform)
@ -31,9 +33,9 @@ npm test; npm run lint
TypeScript 5.9.2, Node.js ≥22.18.0: Follow standard conventions
## Recent Changes
- 008-fo-ui-redesign: Added TypeScript 5.9.2, Node.js ≥22.18.0 + NestJS 11.1.14 (API), Angular 21.1.1 + Angular Material 21.1.1 (client), Prisma 6.19.0 (ORM), Nx 22.5.3 (monorepo), chart.js 4.5.1, date-fns 4.1.0, Bull 4.16.5 (queues), Redis (caching), Ionic 8.8.1
- 006-k1-model-review: Added TypeScript 5.x (strict mode, `noUnusedLocals`, `noUnusedParameters`) + NestJS 11+ (module-based DI), Prisma ORM 6.x, PostgreSQL 16, Redis (caching), pdfjs-dist (extraction — unaffected by this feature)
- 005-k1-parser-fix: Added TypeScript 5.x (Node.js runtime) + NestJS 11.x, pdfjs-dist 5.4.x (already installed via pdf-parse), pdf-parse 2.4.x (kept for `isDigitalK1` detection)
- 004-k1-scan-import: Added TypeScript 5.9.2, Node.js ≥ 22.18.0 + NestJS 11.x (backend), Angular 21.x (frontend), Prisma 6.x (ORM), pdf-parse (PDF text), @azure/ai-form-recognizer (cloud OCR), tesseract.js (local OCR fallback)
<!-- MANUAL ADDITIONS START -->

62
apps/api/src/app/admin/dev-seed.service.ts

@ -916,6 +916,68 @@ export class DevSeedService {
counts.kDocuments++;
}
// ── B9. Ownerships (Entity → Account links) ─────────────────────
// Realistic mapping: Trust owns the IRA & 401(k), LLC owns
// the brokerage & margin accounts, Individual owns Coinbase.
const [trustEntity, llcEntity, jamesEntity] = foEntities;
const ownershipDefs = [
{
accountId: accountRecords[1].id, // Schwab Traditional IRA
accountUserId: userId,
costBasis: 150000,
effectiveDate: new Date('2020-01-15'),
entityId: trustEntity.id,
ownershipPercent: 100
},
{
accountId: accountRecords[3].id, // Fidelity 401(k)
accountUserId: userId,
costBasis: 250000,
effectiveDate: new Date('2019-06-01'),
entityId: trustEntity.id,
ownershipPercent: 100
},
{
accountId: accountRecords[0].id, // Schwab Individual Brokerage
accountUserId: userId,
costBasis: 35000,
effectiveDate: new Date('2021-03-10'),
entityId: llcEntity.id,
ownershipPercent: 100
},
{
accountId: accountRecords[5].id, // Interactive Brokers Margin
accountUserId: userId,
costBasis: 50000,
effectiveDate: new Date('2022-01-15'),
entityId: llcEntity.id,
ownershipPercent: 100
},
{
accountId: accountRecords[2].id, // Vanguard Roth IRA
accountUserId: userId,
costBasis: 20000,
effectiveDate: new Date('2020-09-01'),
entityId: jamesEntity.id,
ownershipPercent: 100
},
{
accountId: accountRecords[4].id, // Coinbase
accountUserId: userId,
costBasis: 5000,
effectiveDate: new Date('2023-02-14'),
entityId: jamesEntity.id,
ownershipPercent: 100
}
];
counts.ownerships = 0;
for (const def of ownershipDefs) {
await this.prismaService.ownership.create({ data: def });
counts.ownerships++;
}
this.logger.log(`Dummy data populated: ${JSON.stringify(counts)}`);
return { created: counts };

32
apps/api/src/app/k1-box-definition/cell-mapping-legacy.controller.ts

@ -0,0 +1,32 @@
/**
* Legacy redirect controller for /api/v1/cell-mapping/* routes.
*
* The old CellMapping module was replaced by K1BoxDefinition in spec 006.
* This controller ensures any stale PWA bundles or cached clients hitting
* the old /cell-mapping/* paths still get a response (301 redirect to the
* new /k1/box-definitions/* routes).
*/
import { Controller, Get, Query, Redirect } from '@nestjs/common';
@Controller('cell-mapping')
export class CellMappingLegacyController {
@Get()
@Redirect('/api/v1/k1/box-definitions', 301)
public getAll(@Query('section') section?: string) {
if (section) {
return { url: `/api/v1/k1/box-definitions?section=${section}` };
}
}
@Get('aggregation-rules')
@Redirect('/api/v1/k1/box-definitions/aggregation-rules', 301)
public getAggregationRules() {
// Default redirect target already set by decorator
}
@Get('aggregation-rules/compute')
@Redirect('/api/v1/k1/box-definitions/aggregation-rules', 301)
public computeAggregationRules() {
// Legacy compute endpoint → redirect to rules list
}
}

2
apps/client/src/app/app.routes.ts

@ -220,7 +220,7 @@ export const routes: Routes = [
// wildcard, if requested url doesn't match any paths for routes defined
// earlier
path: '**',
redirectTo: 'home',
redirectTo: 'family-office',
pathMatch: 'full'
}
];

329
apps/client/src/app/components/header/header.component.html

@ -13,129 +13,29 @@
</div>
<span class="gf-spacer"></span>
<ul class="align-items-center d-flex list-inline m-0 px-2">
<!-- Dashboard -->
<li class="list-inline-item">
<a
class="d-none d-sm-block"
i18n
mat-flat-button
routerLink="/family-office"
[ngClass]="{
'font-weight-bold':
currentRoute === internalRoutes.home.path ||
currentRoute === internalRoutes.zen.path,
'text-decoration-underline':
currentRoute === internalRoutes.home.path ||
currentRoute === internalRoutes.zen.path
}"
[routerLink]="['/']"
>Overview</a
>
</li>
<li class="list-inline-item">
<a
class="d-none d-sm-block"
i18n
mat-flat-button
[ngClass]="{
'font-weight-bold': currentRoute === internalRoutes.portfolio.path,
'text-decoration-underline':
currentRoute === internalRoutes.portfolio.path
'font-weight-bold': currentRoute === 'family-office',
'text-decoration-underline': currentRoute === 'family-office'
}"
[routerLink]="routerLinkPortfolio"
>Portfolio</a
>Dashboard</a
>
</li>
<li class="list-inline-item">
<a
class="d-none d-sm-block"
i18n
mat-flat-button
[ngClass]="{
'font-weight-bold': currentRoute === internalRoutes.accounts.path,
'text-decoration-underline':
currentRoute === internalRoutes.accounts.path
}"
[routerLink]="routerLinkAccounts"
>Accounts</a
>
</li>
<li class="list-inline-item">
<a
class="d-none d-sm-block"
i18n
mat-flat-button
routerLink="/entities"
[ngClass]="{
'font-weight-bold': currentRoute === 'entities',
'text-decoration-underline': currentRoute === 'entities'
}"
>Entities</a
>
</li>
<li class="list-inline-item">
<a
class="d-none d-sm-block"
i18n
mat-flat-button
routerLink="/partnerships"
[ngClass]="{
'font-weight-bold': currentRoute === 'partnerships',
'text-decoration-underline': currentRoute === 'partnerships'
}"
>Partnerships</a
>
</li>
<li class="list-inline-item">
<a
class="d-none d-sm-block"
i18n
mat-flat-button
routerLink="/distributions"
[ngClass]="{
'font-weight-bold': currentRoute === 'distributions',
'text-decoration-underline': currentRoute === 'distributions'
}"
>Distributions</a
>
</li>
<li class="list-inline-item">
<a
class="d-none d-sm-block"
i18n
mat-flat-button
routerLink="/k-documents"
[ngClass]="{
'font-weight-bold': currentRoute === 'k-documents',
'text-decoration-underline': currentRoute === 'k-documents'
}"
>K-1 Documents</a
>
</li>
<li class="list-inline-item">
<a
class="d-none d-sm-block"
i18n
mat-flat-button
routerLink="/k1-import"
[ngClass]="{
'font-weight-bold': currentRoute === 'k1-import',
'text-decoration-underline': currentRoute === 'k1-import'
}"
>K-1 Import</a
>
</li>
<li class="list-inline-item">
<a
class="d-none d-sm-block"
i18n
mat-flat-button
routerLink="/cell-mapping"
[ngClass]="{
'font-weight-bold': currentRoute === 'cell-mapping',
'text-decoration-underline': currentRoute === 'cell-mapping'
}"
>Cell Mapping</a
>
<!-- Partnerships Group -->
<li class="list-inline-item d-none d-sm-block">
<gf-nav-menu-group
label="Partnerships"
[isActive]="isActiveInGroup(partnershipsRoutes)"
[menuItems]="partnershipsMenuItems"
/>
</li>
<!-- Portfolio Views -->
<li class="list-inline-item">
<a
class="d-none d-sm-block"
@ -149,71 +49,71 @@
>Portfolio Views</a
>
</li>
@if (hasPermissionToAccessAdminControl) {
<!-- K-1 Center Group -->
<li class="list-inline-item d-none d-sm-block">
<gf-nav-menu-group
label="K-1 Center"
[isActive]="isActiveInGroup(k1CenterRoutes)"
[menuItems]="k1CenterMenuItems"
/>
</li>
<!-- Analysis -->
<li class="list-inline-item">
<a
class="d-none d-sm-block"
i18n
mat-flat-button
[ngClass]="{
'font-weight-bold':
currentRoute === internalRoutes.adminControl.path,
'font-weight-bold': currentRoute === internalRoutes.portfolio.path,
'text-decoration-underline':
currentRoute === internalRoutes.adminControl.path
currentRoute === internalRoutes.portfolio.path
}"
[routerLink]="routerLinkAdminControl"
>Admin Control</a
[routerLink]="routerLinkPortfolio"
>Analysis</a
>
</li>
}
<li class="list-inline-item">
<a
class="d-none d-sm-block"
i18n
<!-- Admin Group -->
@if (hasPermissionToAccessAdminControl) {
<li class="list-inline-item d-none d-sm-block">
<button
mat-flat-button
[matMenuTriggerFor]="adminNavMenu"
[ngClass]="{
'font-weight-bold': currentRoute === routeResources,
'text-decoration-underline': currentRoute === routeResources
'font-weight-bold': isActiveInGroup(adminGroupRoutes),
'text-decoration-underline': isActiveInGroup(adminGroupRoutes)
}"
[routerLink]="routerLinkResources"
>
Admin
</button>
<mat-menu #adminNavMenu="matMenu" xPosition="after">
<a i18n mat-menu-item [routerLink]="routerLinkAdminControl"
>Admin Control</a
>
<a i18n mat-menu-item [routerLink]="routerLinkAccounts"
>Accounts</a
>
<a i18n mat-menu-item [routerLink]="routerLinkResources"
>Resources</a
>
</li>
@if (
hasPermissionForSubscription && user?.subscription?.type === 'Basic'
hasPermissionForSubscription &&
user?.subscription?.type === 'Basic'
) {
<li class="list-inline-item">
<a
class="d-none d-sm-block"
mat-flat-button
[ngClass]="{
'font-weight-bold': currentRoute === routePricing,
'text-decoration-underline': currentRoute === routePricing
}"
[routerLink]="routerLinkPricing"
>
<a mat-menu-item [routerLink]="routerLinkPricing">
<span class="align-items-center d-flex">
<span i18n>Pricing</span>
@if (currentRoute !== routePricing && hasPromotion) {
<span class="badge badge-warning ml-1">%</span>
}
</span>
</a>
</li>
}
<li class="list-inline-item">
<a
class="d-none d-sm-block"
i18n
mat-flat-button
[ngClass]="{
'font-weight-bold': currentRoute === routeAbout,
'text-decoration-underline': currentRoute === routeAbout
}"
[routerLink]="routerLinkAbout"
>About</a
>
<hr class="m-0" />
@for (item of legacyMenuItems; track item.routerLink) {
<a mat-menu-item [routerLink]="item.routerLink">{{
item.label
}}</a>
}
</mat-menu>
</li>
}
@if (hasPermissionToAccessAssistant) {
<li class="list-inline-item">
<button
@ -326,38 +226,20 @@
}
<hr class="m-0" />
}
<!-- Dashboard -->
<a
class="d-flex d-sm-none"
i18n
mat-menu-item
[ngClass]="{
'font-weight-bold':
currentRoute === internalRoutes.home.path ||
currentRoute === internalRoutes.zen.path
}"
[routerLink]="['/']"
>Overview</a
>
<a
class="d-flex d-sm-none"
i18n
mat-menu-item
[ngClass]="{
'font-weight-bold': currentRoute === internalRoutes.portfolio.path
}"
[routerLink]="routerLinkPortfolio"
>Portfolio</a
>
<a
class="d-flex d-sm-none"
i18n
mat-menu-item
[ngClass]="{
'font-weight-bold': currentRoute === internalRoutes.accounts.path
}"
[routerLink]="routerLinkAccounts"
>Accounts</a
routerLink="/family-office"
[ngClass]="{ 'font-weight-bold': currentRoute === 'family-office' }"
>Dashboard</a
>
<hr class="d-flex d-sm-none m-0" />
<!-- Partnerships -->
<div class="d-flex d-sm-none px-4 py-1">
<small class="text-muted" i18n>Partnerships</small>
</div>
<a
class="d-flex d-sm-none"
i18n
@ -386,10 +268,25 @@
class="d-flex d-sm-none"
i18n
mat-menu-item
routerLink="/k-documents"
[ngClass]="{ 'font-weight-bold': currentRoute === 'k-documents' }"
>K-1 Documents</a
routerLink="/accounts"
[ngClass]="{ 'font-weight-bold': currentRoute === 'accounts' }"
>Accounts</a
>
<hr class="d-flex d-sm-none m-0" />
<!-- Portfolio Views -->
<a
class="d-flex d-sm-none"
i18n
mat-menu-item
routerLink="/portfolio-views"
[ngClass]="{ 'font-weight-bold': currentRoute === 'portfolio-views' }"
>Portfolio Views</a
>
<hr class="d-flex d-sm-none m-0" />
<!-- K-1 Center -->
<div class="d-flex d-sm-none px-4 py-1">
<small class="text-muted" i18n>K-1 Center</small>
</div>
<a
class="d-flex d-sm-none"
i18n
@ -398,6 +295,14 @@
[ngClass]="{ 'font-weight-bold': currentRoute === 'k1-import' }"
>K-1 Import</a
>
<a
class="d-flex d-sm-none"
i18n
mat-menu-item
routerLink="/k-documents"
[ngClass]="{ 'font-weight-bold': currentRoute === 'k-documents' }"
>K-1 Documents</a
>
<a
class="d-flex d-sm-none"
i18n
@ -406,14 +311,20 @@
[ngClass]="{ 'font-weight-bold': currentRoute === 'cell-mapping' }"
>Cell Mapping</a
>
<hr class="d-flex d-sm-none m-0" />
<!-- Analysis -->
<a
class="d-flex d-sm-none"
i18n
mat-menu-item
routerLink="/portfolio-views"
[ngClass]="{ 'font-weight-bold': currentRoute === 'portfolio-views' }"
>Portfolio Views</a
[ngClass]="{
'font-weight-bold': currentRoute === internalRoutes.portfolio.path
}"
[routerLink]="routerLinkPortfolio"
>Analysis</a
>
<hr class="d-flex d-sm-none m-0" />
<!-- Account Settings -->
<a
i18n
mat-menu-item
@ -423,7 +334,12 @@
[routerLink]="routerLinkAccount"
>My Ghostfolio</a
>
<!-- Admin -->
@if (hasPermissionToAccessAdminControl) {
<hr class="d-flex d-sm-none m-0" />
<div class="d-flex d-sm-none px-4 py-1">
<small class="text-muted" i18n>Admin</small>
</div>
<a
class="d-flex d-sm-none"
i18n
@ -435,8 +351,17 @@
[routerLink]="routerLinkAdminControl"
>Admin Control</a
>
}
<hr class="m-0" />
<a
class="d-flex d-sm-none"
i18n
mat-menu-item
[ngClass]="{
'font-weight-bold':
currentRoute === internalRoutes.accounts.path
}"
[routerLink]="routerLinkAccounts"
>Accounts</a
>
<a
class="d-flex d-sm-none"
i18n
@ -448,30 +373,36 @@
>Resources</a
>
@if (
hasPermissionForSubscription && user?.subscription?.type === 'Basic'
hasPermissionForSubscription &&
user?.subscription?.type === 'Basic'
) {
<a
class="d-flex d-sm-none"
mat-menu-item
[ngClass]="{ 'font-weight-bold': currentRoute === routePricing }"
[ngClass]="{
'font-weight-bold': currentRoute === routePricing
}"
[routerLink]="routerLinkPricing"
>
<span class="align-items-center d-flex">
<span i18n>Pricing</span>
@if (currentRoute !== routePricing && hasPromotion) {
<span class="badge badge-warning ml-1">%</span>
}
</span>
</a>
}
<hr class="d-flex d-sm-none m-0" />
<!-- Legacy -->
<div class="d-flex d-sm-none px-4 py-1">
<small class="text-muted" i18n>Legacy</small>
</div>
@for (item of legacyMenuItems; track item.routerLink) {
<a
class="d-flex d-sm-none"
i18n
mat-menu-item
[ngClass]="{ 'font-weight-bold': currentRoute === routeAbout }"
[routerLink]="routerLinkAbout"
>About Ghostfolio</a
[routerLink]="item.routerLink"
>{{ item.label }}</a
>
}
}
<hr class="d-flex d-sm-none m-0" />
<button i18n mat-menu-item (click)="onSignOut()">Log out</button>
</mat-menu>

52
apps/client/src/app/components/header/header.component.ts

@ -16,6 +16,10 @@ import { DateRange } from '@ghostfolio/common/types';
import { GfAssistantComponent } from '@ghostfolio/ui/assistant/assistant.component';
import { GfLogoComponent } from '@ghostfolio/ui/logo';
import { NotificationService } from '@ghostfolio/ui/notifications';
import {
GfNavMenuGroupComponent,
NavMenuItem
} from '@ghostfolio/ui/nav-menu-group';
import { GfPremiumIndicatorComponent } from '@ghostfolio/ui/premium-indicator';
import { DataService } from '@ghostfolio/ui/services';
@ -59,6 +63,7 @@ import { catchError } from 'rxjs/operators';
CommonModule,
GfAssistantComponent,
GfLogoComponent,
GfNavMenuGroupComponent,
GfPremiumIndicatorComponent,
IonIcon,
MatBadgeModule,
@ -133,6 +138,41 @@ export class GfHeaderComponent implements OnChanges {
public routerLinkRegister = publicRoutes.register.routerLink;
public routerLinkResources = publicRoutes.resources.routerLink;
// Navigation group items per contracts/navigation.md
public partnershipsMenuItems: NavMenuItem[] = [
{ label: 'Entities', routerLink: '/entities' },
{ label: 'Partnerships', routerLink: '/partnerships' },
{ label: 'Distributions', routerLink: '/distributions' },
{ label: 'Accounts', routerLink: '/accounts' }
];
public k1CenterMenuItems: NavMenuItem[] = [
{ label: 'K-1 Import', routerLink: '/k1-import' },
{ label: 'K-1 Documents', routerLink: '/k-documents' },
{ label: 'Cell Mapping', routerLink: '/cell-mapping' }
];
public legacyMenuItems: NavMenuItem[] = [
{ label: 'Overview', routerLink: '/home' },
{ label: 'Holdings', routerLink: '/home/holdings' },
{ label: 'Summary', routerLink: '/home/summary' },
{ label: 'Markets', routerLink: '/home/markets' },
{ label: 'Watchlist', routerLink: '/home/watchlist' },
{ label: 'FIRE Calculator', routerLink: '/portfolio/fire' },
{ label: 'X-Ray', routerLink: '/portfolio/x-ray' }
];
public partnershipsRoutes = [
'entities',
'partnerships',
'distributions',
'accounts'
];
public k1CenterRoutes = ['k1-import', 'k-documents', 'cell-mapping'];
public adminGroupRoutes: string[] = [];
public constructor(
private dataService: DataService,
private destroyRef: DestroyRef,
@ -206,6 +246,14 @@ export class GfHeaderComponent implements OnChanges {
this.info?.globalPermissions,
permissions.createUserAccount
);
this.adminGroupRoutes = [
internalRoutes.adminControl.path,
internalRoutes.accounts.path,
this.routeResources,
this.routePricing,
internalRoutes.home.path
];
}
public closeAssistant() {
@ -262,6 +310,10 @@ export class GfHeaderComponent implements OnChanges {
});
}
public isActiveInGroup(routes: string[]): boolean {
return routes.includes(this.currentRoute);
}
public onLogoClick() {
if (['home', 'zen'].includes(this.currentRoute)) {
this.layoutService.getShouldReloadSubject().next();

137
apps/client/src/app/pages/entity-detail/entity-detail-page.html

@ -15,74 +15,6 @@
</div>
<mat-tab-group>
<!-- Ownerships Tab -->
<mat-tab>
<ng-template mat-tab-label>
<span i18n>Ownerships</span>
<span class="badge bg-secondary ms-2">{{
entity?.ownerships?.length ?? 0
}}</span>
</ng-template>
<div class="py-3">
<table
class="gf-table w-100"
mat-table
[dataSource]="entity?.ownerships ?? []"
>
<ng-container matColumnDef="accountName">
<th *matHeaderCellDef mat-header-cell>Account</th>
<td *matCellDef="let row" mat-cell>{{ row.accountName }}</td>
</ng-container>
<ng-container matColumnDef="ownershipPercent">
<th *matHeaderCellDef class="text-right" mat-header-cell>
Ownership %
</th>
<td *matCellDef="let row" class="text-right" mat-cell>
{{ row.ownershipPercent }}%
</td>
</ng-container>
<ng-container matColumnDef="effectiveDate">
<th
*matHeaderCellDef
class="d-none d-lg-table-cell"
mat-header-cell
>
Effective Date
</th>
<td
*matCellDef="let row"
class="d-none d-lg-table-cell"
mat-cell
>
{{ row.effectiveDate | date }}
</td>
</ng-container>
<tr *matHeaderRowDef="ownershipColumns" mat-header-row></tr>
<tr *matRowDef="let row; columns: ownershipColumns" mat-row></tr>
</table>
@if (entity?.ownerships?.length === 0) {
<p class="p-3 text-center text-muted" i18n>
No account ownerships yet.
</p>
}
@if (hasPermissionToCreate) {
<div class="mt-3">
<button
color="primary"
mat-stroked-button
(click)="onAddOwnership()"
>
<ion-icon class="me-1" name="add-outline" />
Add Ownership
</button>
</div>
}
</div>
</mat-tab>
<!-- Partnership Memberships Tab -->
<mat-tab>
<ng-template mat-tab-label>
@ -250,6 +182,75 @@
}
</div>
</mat-tab>
<!-- Account Ownerships Tab -->
<mat-tab>
<ng-template mat-tab-label>
<span i18n>Accounts</span>
<span class="badge bg-secondary ms-2">{{
entity?.ownerships?.length ?? 0
}}</span>
</ng-template>
<div class="py-3">
<table
class="gf-table w-100"
mat-table
[dataSource]="entity?.ownerships ?? []"
>
<ng-container matColumnDef="accountName">
<th *matHeaderCellDef mat-header-cell>Account</th>
<td *matCellDef="let row" mat-cell>{{ row.accountName }}</td>
</ng-container>
<ng-container matColumnDef="ownershipPercent">
<th *matHeaderCellDef class="text-right" mat-header-cell>
Ownership %
</th>
<td *matCellDef="let row" class="text-right" mat-cell>
{{ row.ownershipPercent }}%
</td>
</ng-container>
<ng-container matColumnDef="effectiveDate">
<th
*matHeaderCellDef
class="d-none d-lg-table-cell"
mat-header-cell
>
Effective Date
</th>
<td
*matCellDef="let row"
class="d-none d-lg-table-cell"
mat-cell
>
{{ row.effectiveDate | date }}
</td>
</ng-container>
<tr *matHeaderRowDef="ownershipColumns" mat-header-row></tr>
<tr *matRowDef="let row; columns: ownershipColumns" mat-row></tr>
</table>
@if (entity?.ownerships?.length === 0) {
<p class="p-3 text-center text-muted" i18n>
No brokerage account links yet. Manage accounts from the
Partnerships menu.
</p>
}
@if (hasPermissionToCreate) {
<div class="mt-3">
<button
color="primary"
mat-stroked-button
(click)="onAddOwnership()"
>
<ion-icon class="me-1" name="add-outline" />
Link Account
</button>
</div>
}
</div>
</mat-tab>
</mat-tab-group>
</div>
</div>

285
apps/client/src/app/pages/family-dashboard/dashboard-page.component.ts

@ -1,5 +1,12 @@
import { FamilyOfficeDataService } from '@ghostfolio/client/services/family-office-data.service';
import type { IFamilyOfficeDashboard } from '@ghostfolio/common/interfaces';
import type {
IActivityDetail,
IFamilyOfficeDashboard,
IPortfolioSummary
} from '@ghostfolio/common/interfaces';
import { GfK1IncomeSummaryComponent } from '@ghostfolio/ui/k1-income-summary';
import { GfPerformanceMetricsComponent } from '@ghostfolio/ui/performance-metrics';
import { AdminService } from '@ghostfolio/ui/services';
import { CommonModule } from '@angular/common';
import {
@ -10,11 +17,13 @@ import {
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 { MatProgressBarModule } from '@angular/material/progress-bar';
import { MatProgressSpinnerModule } from '@angular/material/progress-spinner';
import { MatSnackBar, MatSnackBarModule } from '@angular/material/snack-bar';
import { MatTableModule } from '@angular/material/table';
import { RouterModule } from '@angular/router';
@ -22,11 +31,15 @@ import { RouterModule } from '@angular/router';
changeDetection: ChangeDetectionStrategy.OnPush,
imports: [
CommonModule,
GfK1IncomeSummaryComponent,
GfPerformanceMetricsComponent,
MatButtonModule,
MatCardModule,
MatChipsModule,
MatIconModule,
MatProgressBarModule,
MatProgressSpinnerModule,
MatSnackBarModule,
MatTableModule,
RouterModule
],
@ -166,6 +179,24 @@ import { RouterModule } from '@angular/router';
justify-content: center;
padding: 3rem;
}
.mgmt-section {
margin-top: 2rem;
border-top: 1px solid rgba(0, 0, 0, 0.12);
padding-top: 1.5rem;
}
.mgmt-section h2 {
margin-bottom: 0.75rem;
font-size: 1.1rem;
color: rgba(0, 0, 0, 0.6);
}
.mgmt-buttons {
display: flex;
gap: 1rem;
flex-wrap: wrap;
}
`
],
template: `
@ -201,6 +232,89 @@ import { RouterModule } from '@angular/router';
</div>
</mat-card>
<!-- Onboarding Guide (T020) -->
@if (
dashboard.entitiesCount === 0 && dashboard.partnershipsCount === 0
) {
<mat-card style="margin-bottom: 1.5rem; padding: 1.5rem">
<mat-card-header>
<mat-card-title>
<mat-icon style="vertical-align: middle; margin-right: 0.5rem"
>rocket_launch</mat-icon
>
Get Started
</mat-card-title>
</mat-card-header>
<mat-card-content style="padding-top: 1rem">
<div
style="
display: flex;
flex-direction: column;
gap: 1rem;
"
>
<div style="display: flex; align-items: center; gap: 1rem">
<span
style="
background: #1976d2;
color: white;
border-radius: 50%;
width: 32px;
height: 32px;
display: flex;
align-items: center;
justify-content: center;
font-weight: 600;
"
>1</span
>
<a routerLink="/entities" style="font-size: 1rem"
>Create an Entity</a
>
</div>
<div style="display: flex; align-items: center; gap: 1rem">
<span
style="
background: #1976d2;
color: white;
border-radius: 50%;
width: 32px;
height: 32px;
display: flex;
align-items: center;
justify-content: center;
font-weight: 600;
"
>2</span
>
<a routerLink="/partnerships" style="font-size: 1rem"
>Add a Partnership</a
>
</div>
<div style="display: flex; align-items: center; gap: 1rem">
<span
style="
background: #1976d2;
color: white;
border-radius: 50%;
width: 32px;
height: 32px;
display: flex;
align-items: center;
justify-content: center;
font-weight: 600;
"
>3</span
>
<a routerLink="/k1-import" style="font-size: 1rem"
>Import a K-1</a
>
</div>
</div>
</mat-card-content>
</mat-card>
}
<!-- Allocation Charts -->
<div class="charts-grid">
<!-- By Entity -->
@ -294,6 +408,27 @@ import { RouterModule } from '@angular/router';
</mat-card>
</div>
<!-- Performance Metrics (T018) -->
@if (portfolioSummary?.totals) {
<section style="margin-bottom: 1.5rem">
<h2 style="margin-bottom: 0.75rem">Performance Metrics</h2>
<gf-performance-metrics
[dpi]="portfolioSummary.totals.dpi"
[irr]="portfolioSummary.totals.irr"
[rvpi]="portfolioSummary.totals.rvpi"
[tvpi]="portfolioSummary.totals.tvpi"
/>
</section>
}
<!-- K-1 Income Summary (T019) -->
@if (activityDetail?.rows?.length) {
<section style="margin-bottom: 1.5rem">
<h2 style="margin-bottom: 0.75rem">K-1 Income Summary</h2>
<gf-k1-income-summary [rows]="activityDetail.rows" />
</section>
}
<!-- Bottom row: Recent Distributions + K-1 Status -->
<div class="bottom-grid">
<!-- Recent Distributions -->
@ -385,19 +520,54 @@ import { RouterModule } from '@angular/router';
</mat-card-content>
</mat-card>
</div>
<!-- Data Management -->
<section class="mgmt-section">
<h2>
<mat-icon style="vertical-align: middle; margin-right: 0.25rem; font-size: 1.2rem; height: 1.2rem; width: 1.2rem"
>settings</mat-icon
>
Data Management
</h2>
<div class="mgmt-buttons">
<button
mat-stroked-button
color="primary"
[disabled]="isSeedingOrClearing"
(click)="onPopulateDummyData()"
>
<mat-icon>auto_awesome</mat-icon>
Populate Demo Data
</button>
<button
mat-stroked-button
color="warn"
[disabled]="isSeedingOrClearing"
(click)="onClearDatabase()"
>
<mat-icon>delete_sweep</mat-icon>
Clear All Data
</button>
</div>
</section>
}
`
})
export class DashboardPageComponent implements OnInit {
public activityDetail: IActivityDetail | null = null;
public dashboard: IFamilyOfficeDashboard | null = null;
public distributionColumns = ['partnership', 'amount', 'date', 'type'];
public isLoading = true;
public isSeedingOrClearing = false;
public k1ProgressPercent = 0;
public portfolioSummary: IPortfolioSummary | null = null;
public constructor(
private readonly adminService: AdminService,
private readonly changeDetectorRef: ChangeDetectorRef,
private readonly destroyRef: DestroyRef,
private readonly familyOfficeDataService: FamilyOfficeDataService
private readonly familyOfficeDataService: FamilyOfficeDataService,
private readonly snackBar: MatSnackBar
) {}
public ngOnInit() {
@ -423,5 +593,116 @@ export class DashboardPageComponent implements OnInit {
this.changeDetectorRef.markForCheck();
}
});
this.familyOfficeDataService
.fetchPortfolioSummary()
.pipe(takeUntilDestroyed(this.destroyRef))
.subscribe({
next: (summary) => {
this.portfolioSummary = summary;
this.changeDetectorRef.markForCheck();
}
});
this.familyOfficeDataService
.fetchActivity()
.pipe(takeUntilDestroyed(this.destroyRef))
.subscribe({
next: (activity) => {
this.activityDetail = activity;
this.changeDetectorRef.markForCheck();
}
});
}
public onPopulateDummyData() {
if (
!confirm(
'This will populate the database with demo family office data (entities, partnerships, distributions, K-1 documents, and brokerage accounts with activities). Continue?'
)
) {
return;
}
this.isSeedingOrClearing = true;
this.changeDetectorRef.markForCheck();
this.adminService
.seedFamilyOfficeData()
.pipe(takeUntilDestroyed(this.destroyRef))
.subscribe({
error: (error) => {
this.isSeedingOrClearing = false;
this.snackBar.open(
`Failed to populate data: ${error?.error?.message ?? 'Unknown error'}`,
'Dismiss',
{ duration: 5000 }
);
this.changeDetectorRef.markForCheck();
},
next: (result) => {
this.isSeedingOrClearing = false;
const total = Object.values(result.created).reduce(
(sum, n) => sum + n,
0
);
this.snackBar.open(
`Demo data populated (${total} records created)`,
'OK',
{ duration: 5000 }
);
this.refreshDashboard();
}
});
}
public onClearDatabase() {
if (
!confirm(
'This will permanently delete ALL family office data and portfolio data (entities, partnerships, accounts, activities, etc.). This cannot be undone. Continue?'
)
) {
return;
}
this.isSeedingOrClearing = true;
this.changeDetectorRef.markForCheck();
this.adminService
.clearFamilyOfficeData()
.pipe(takeUntilDestroyed(this.destroyRef))
.subscribe({
error: (error) => {
this.isSeedingOrClearing = false;
this.snackBar.open(
`Failed to clear data: ${error?.error?.message ?? 'Unknown error'}`,
'Dismiss',
{ duration: 5000 }
);
this.changeDetectorRef.markForCheck();
},
next: (result) => {
this.isSeedingOrClearing = false;
const total = Object.values(result.deleted).reduce(
(sum, n) => sum + n,
0
);
this.snackBar.open(
`All data cleared (${total} records removed)`,
'OK',
{ duration: 5000 }
);
this.refreshDashboard();
}
});
}
private refreshDashboard() {
this.isLoading = true;
this.dashboard = null;
this.portfolioSummary = null;
this.activityDetail = null;
this.changeDetectorRef.markForCheck();
this.ngOnInit();
}
}

90
apps/client/src/app/pages/portfolio/analysis/analysis-page.component.ts

@ -1,10 +1,14 @@
import { GfBenchmarkComparatorComponent } from '@ghostfolio/client/components/benchmark-comparator/benchmark-comparator.component';
import { GfInvestmentChartComponent } from '@ghostfolio/client/components/investment-chart/investment-chart.component';
import { FamilyOfficeDataService } from '@ghostfolio/client/services/family-office-data.service';
import { ImpersonationStorageService } from '@ghostfolio/client/services/impersonation-storage.service';
import { UserService } from '@ghostfolio/client/services/user/user.service';
import { NUMERICAL_PRECISION_THRESHOLD_6_FIGURES } from '@ghostfolio/common/config';
import {
HistoricalDataItem,
IActivityDetail,
IAssetClassSummary,
IPortfolioSummary,
InvestmentItem,
PortfolioInvestmentsResponse,
PortfolioPerformance,
@ -15,12 +19,15 @@ import {
import { hasPermission, permissions } from '@ghostfolio/common/permissions';
import type { AiPromptMode, GroupBy } from '@ghostfolio/common/types';
import { translate } from '@ghostfolio/ui/i18n';
import { GfK1IncomeSummaryComponent } from '@ghostfolio/ui/k1-income-summary';
import { GfPerformanceMetricsComponent } from '@ghostfolio/ui/performance-metrics';
import { GfPremiumIndicatorComponent } from '@ghostfolio/ui/premium-indicator';
import { DataService } from '@ghostfolio/ui/services';
import { GfToggleComponent } from '@ghostfolio/ui/toggle';
import { GfValueComponent } from '@ghostfolio/ui/value';
import { Clipboard } from '@angular/cdk/clipboard';
import { CommonModule } from '@angular/common';
import {
ChangeDetectorRef,
Component,
@ -33,6 +40,7 @@ import { MatButtonModule } from '@angular/material/button';
import { MatCardModule } from '@angular/material/card';
import { MatMenuModule, MatMenuTrigger } from '@angular/material/menu';
import { MatProgressSpinnerModule } from '@angular/material/progress-spinner';
import { MatTableModule } from '@angular/material/table';
import { MatSnackBar } from '@angular/material/snack-bar';
import { RouterModule } from '@angular/router';
import { IonIcon } from '@ionic/angular/standalone';
@ -46,8 +54,11 @@ import { NgxSkeletonLoaderModule } from 'ngx-skeleton-loader';
@Component({
imports: [
CommonModule,
GfBenchmarkComparatorComponent,
GfInvestmentChartComponent,
GfK1IncomeSummaryComponent,
GfPerformanceMetricsComponent,
GfPremiumIndicatorComponent,
GfToggleComponent,
GfValueComponent,
@ -56,6 +67,7 @@ import { NgxSkeletonLoaderModule } from 'ngx-skeleton-loader';
MatCardModule,
MatMenuModule,
MatProgressSpinnerModule,
MatTableModule,
NgxSkeletonLoaderModule,
RouterModule
],
@ -66,16 +78,40 @@ import { NgxSkeletonLoaderModule } from 'ngx-skeleton-loader';
export class GfAnalysisPageComponent implements OnInit {
@ViewChild(MatMenuTrigger) actionsMenuButton!: MatMenuTrigger;
public activityDetail: IActivityDetail | null = null;
public assetClassSummary: IAssetClassSummary | null = null;
public benchmark: Partial<SymbolProfile>;
public benchmarkDataItems: HistoricalDataItem[] = [];
public benchmarks: Partial<SymbolProfile>[];
public entityColumns = [
'entityName',
'originalCommitment',
'percentCalled',
'unfundedCommitment',
'paidIn',
'distributions',
'irr',
'tvpi',
'dpi'
];
public assetClassColumns = [
'assetClassLabel',
'originalCommitment',
'paidIn',
'distributions',
'irr',
'tvpi',
'dpi'
];
public bottom3: PortfolioPosition[];
public deviceType: string;
public dividendsByGroup: InvestmentItem[];
public dividendTimelineDataLabel = $localize`Dividend`;
public firstOrderDate: Date;
public hasFamilyOfficeData: boolean = false;
public hasImpersonationId: boolean;
public hasPermissionToReadAiPrompt: boolean;
public isLoadingFamilyOffice: boolean = false;
public investments: InvestmentItem[];
public investmentTimelineDataLabel = $localize`Investment`;
public investmentsByGroup: InvestmentItem[];
@ -95,6 +131,7 @@ export class GfAnalysisPageComponent implements OnInit {
public performanceDataItemsInPercentage: HistoricalDataItem[];
public portfolioEvolutionDataLabel = $localize`Investment`;
public precision = 2;
public portfolioSummary: IPortfolioSummary | null = null;
public streaks: PortfolioInvestmentsResponse['streaks'];
public top3: PortfolioPosition[];
public unitCurrentStreak: string;
@ -107,6 +144,7 @@ export class GfAnalysisPageComponent implements OnInit {
private dataService: DataService,
private destroyRef: DestroyRef,
private deviceService: DeviceDetectorService,
private familyOfficeDataService: FamilyOfficeDataService,
private impersonationStorageService: ImpersonationStorageService,
private snackBar: MatSnackBar,
private userService: UserService
@ -367,8 +405,60 @@ export class GfAnalysisPageComponent implements OnInit {
});
this.fetchDividendsAndInvestments();
this.fetchFamilyOfficeData();
this.changeDetectorRef.markForCheck();
}
private fetchFamilyOfficeData() {
this.isLoadingFamilyOffice = true;
this.familyOfficeDataService
.fetchPortfolioSummary()
.pipe(takeUntilDestroyed(this.destroyRef))
.subscribe({
error: () => {
this.isLoadingFamilyOffice = false;
this.changeDetectorRef.markForCheck();
},
next: (portfolioSummary) => {
this.portfolioSummary = portfolioSummary;
this.hasFamilyOfficeData =
this.hasFamilyOfficeData ||
(portfolioSummary?.entities?.length ?? 0) > 0;
this.isLoadingFamilyOffice = false;
this.changeDetectorRef.markForCheck();
}
});
this.familyOfficeDataService
.fetchAssetClassSummary()
.pipe(takeUntilDestroyed(this.destroyRef))
.subscribe({
error: () => {
this.changeDetectorRef.markForCheck();
},
next: (assetClassSummary) => {
this.assetClassSummary = assetClassSummary;
this.changeDetectorRef.markForCheck();
}
});
this.familyOfficeDataService
.fetchActivity()
.pipe(takeUntilDestroyed(this.destroyRef))
.subscribe({
error: () => {
this.changeDetectorRef.markForCheck();
},
next: (activityDetail) => {
this.activityDetail = activityDetail;
this.hasFamilyOfficeData =
this.hasFamilyOfficeData ||
(activityDetail?.rows?.length ?? 0) > 0;
this.changeDetectorRef.markForCheck();
}
});
}
private updateBenchmarkDataItems() {
this.benchmarkDataItems = [];

203
apps/client/src/app/pages/portfolio/analysis/analysis-page.html

@ -157,6 +157,132 @@
</div>
</div>
<!-- Family Office Performance Metrics (NEW) -->
@if (hasFamilyOfficeData && portfolioSummary?.totals) {
<div class="mb-5 row">
<div class="col-lg">
<gf-performance-metrics
[dpi]="portfolioSummary.totals.dpi"
[irr]="portfolioSummary.totals.irr"
[rvpi]="portfolioSummary.totals.rvpi"
[tvpi]="portfolioSummary.totals.tvpi"
/>
</div>
</div>
}
<!-- Entity Breakdown Table (NEW) -->
@if (portfolioSummary?.entities?.length > 0) {
<div class="mb-5 row">
<div class="col-lg">
<mat-card appearance="outlined">
<mat-card-header>
<mat-card-title i18n>Entity Performance</mat-card-title>
</mat-card-header>
<mat-card-content>
<div class="table-responsive">
<table
class="w-100"
mat-table
[dataSource]="portfolioSummary.entities"
>
<ng-container matColumnDef="entityName">
<th *matHeaderCellDef mat-header-cell i18n>Entity</th>
<td *matCellDef="let row" mat-cell>{{ row.entityName }}</td>
</ng-container>
<ng-container matColumnDef="originalCommitment">
<th *matHeaderCellDef mat-header-cell i18n>Commitment</th>
<td *matCellDef="let row" mat-cell>
{{ row.originalCommitment | number: '1.0-0' }}
</td>
</ng-container>
<ng-container matColumnDef="percentCalled">
<th *matHeaderCellDef mat-header-cell i18n>% Called</th>
<td *matCellDef="let row" mat-cell>
{{
row.percentCalled !== null
? (row.percentCalled | number: '1.1-1') + '%'
: 'N/A'
}}
</td>
</ng-container>
<ng-container matColumnDef="unfundedCommitment">
<th *matHeaderCellDef mat-header-cell i18n>Unfunded</th>
<td *matCellDef="let row" mat-cell>
{{ row.unfundedCommitment | number: '1.0-0' }}
</td>
</ng-container>
<ng-container matColumnDef="paidIn">
<th *matHeaderCellDef mat-header-cell i18n>Paid-In</th>
<td *matCellDef="let row" mat-cell>
{{ row.paidIn | number: '1.0-0' }}
</td>
</ng-container>
<ng-container matColumnDef="distributions">
<th *matHeaderCellDef mat-header-cell i18n>Distributions</th>
<td *matCellDef="let row" mat-cell>
{{ row.distributions | number: '1.0-0' }}
</td>
</ng-container>
<ng-container matColumnDef="irr">
<th *matHeaderCellDef mat-header-cell i18n>IRR</th>
<td *matCellDef="let row" mat-cell>
{{
row.irr !== null
? (row.irr | percent: '1.2-2')
: 'N/A'
}}
</td>
</ng-container>
<ng-container matColumnDef="tvpi">
<th *matHeaderCellDef mat-header-cell i18n>TVPI</th>
<td *matCellDef="let row" mat-cell>
{{ row.tvpi | number: '1.2-2' }}x
</td>
</ng-container>
<ng-container matColumnDef="dpi">
<th *matHeaderCellDef mat-header-cell i18n>DPI</th>
<td *matCellDef="let row" mat-cell>
{{ row.dpi | number: '1.2-2' }}x
</td>
</ng-container>
<tr *matHeaderRowDef="entityColumns" mat-header-row></tr>
<tr *matRowDef="let row; columns: entityColumns" mat-row></tr>
</table>
</div>
</mat-card-content>
</mat-card>
</div>
</div>
}
<!-- K1 Income Summary (NEW) -->
@if (hasFamilyOfficeData && activityDetail?.rows) {
<div class="mb-5 row">
<div class="col-lg">
<gf-k1-income-summary [rows]="activityDetail.rows" />
</div>
</div>
}
<!-- Empty State: No FO data (NEW) -->
@if (!hasFamilyOfficeData && !isLoadingFamilyOffice) {
<div class="mb-5 row">
<div class="col-lg">
<mat-card appearance="outlined">
<mat-card-content class="text-center py-4">
<p i18n>
No K-1 data available. Import a K-1 to get started.
</p>
<a mat-flat-button color="primary" routerLink="/k1-import" i18n
>Import K-1</a
>
</mat-card-content>
</mat-card>
</div>
</div>
}
<div class="mb-5 row">
<div class="col">
<mat-card appearance="outlined">
@ -297,6 +423,83 @@
</div>
</div>
<!-- Asset Class Breakdown (NEW) -->
@if (assetClassSummary?.assetClasses?.length > 0) {
<div class="mb-5 row">
<div class="col-lg">
<mat-card appearance="outlined">
<mat-card-header>
<mat-card-title i18n>Asset Class Breakdown</mat-card-title>
</mat-card-header>
<mat-card-content>
<div class="table-responsive">
<table
class="w-100"
mat-table
[dataSource]="assetClassSummary.assetClasses"
>
<ng-container matColumnDef="assetClassLabel">
<th *matHeaderCellDef mat-header-cell i18n>Asset Class</th>
<td *matCellDef="let row" mat-cell>
{{ row.assetClassLabel }}
</td>
</ng-container>
<ng-container matColumnDef="originalCommitment">
<th *matHeaderCellDef mat-header-cell i18n>Commitment</th>
<td *matCellDef="let row" mat-cell>
{{ row.originalCommitment | number: '1.0-0' }}
</td>
</ng-container>
<ng-container matColumnDef="paidIn">
<th *matHeaderCellDef mat-header-cell i18n>Paid-In</th>
<td *matCellDef="let row" mat-cell>
{{ row.paidIn | number: '1.0-0' }}
</td>
</ng-container>
<ng-container matColumnDef="distributions">
<th *matHeaderCellDef mat-header-cell i18n>Distributions</th>
<td *matCellDef="let row" mat-cell>
{{ row.distributions | number: '1.0-0' }}
</td>
</ng-container>
<ng-container matColumnDef="irr">
<th *matHeaderCellDef mat-header-cell i18n>IRR</th>
<td *matCellDef="let row" mat-cell>
{{
row.irr !== null
? (row.irr | percent: '1.2-2')
: 'N/A'
}}
</td>
</ng-container>
<ng-container matColumnDef="tvpi">
<th *matHeaderCellDef mat-header-cell i18n>TVPI</th>
<td *matCellDef="let row" mat-cell>
{{ row.tvpi | number: '1.2-2' }}x
</td>
</ng-container>
<ng-container matColumnDef="dpi">
<th *matHeaderCellDef mat-header-cell i18n>DPI</th>
<td *matCellDef="let row" mat-cell>
{{ row.dpi | number: '1.2-2' }}x
</td>
</ng-container>
<tr
*matHeaderRowDef="assetClassColumns"
mat-header-row
></tr>
<tr
*matRowDef="let row; columns: assetClassColumns"
mat-row
></tr>
</table>
</div>
</mat-card-content>
</mat-card>
</div>
</div>
}
<div class="mb-5 row">
<div class="col-md-6 mb-3">
<mat-card appearance="outlined" class="h-100">

1
libs/ui/src/lib/k1-income-summary/index.ts

@ -0,0 +1 @@
export * from './k1-income-summary.component';

160
libs/ui/src/lib/k1-income-summary/k1-income-summary.component.ts

@ -0,0 +1,160 @@
import type { IActivityRow } from '@ghostfolio/common/interfaces';
import { CommonModule, CurrencyPipe } from '@angular/common';
import { ChangeDetectionStrategy, Component, Input } from '@angular/core';
import { MatCardModule } from '@angular/material/card';
import { MatIconModule } from '@angular/material/icon';
@Component({
changeDetection: ChangeDetectionStrategy.OnPush,
imports: [CommonModule, CurrencyPipe, MatCardModule, MatIconModule],
selector: 'gf-k1-income-summary',
standalone: true,
styles: [
`
:host {
display: block;
}
.income-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(180px, 1fr));
gap: 1rem;
}
.income-item {
text-align: center;
padding: 1rem 0.5rem;
}
.income-value {
font-size: 24px;
font-weight: 600;
line-height: 1.2;
}
.income-label {
font-size: 13px;
color: rgba(var(--dark-primary-text), 0.6);
margin-top: 4px;
}
.positive {
color: #2e7d32;
}
.negative {
color: #c62828;
}
.neutral {
color: rgba(var(--dark-primary-text), 0.87);
}
.summary-header {
display: flex;
align-items: center;
margin-bottom: 1rem;
}
.summary-header mat-icon {
margin-right: 8px;
color: rgba(var(--dark-primary-text), 0.6);
}
.summary-header h3 {
margin: 0;
font-size: 16px;
font-weight: 500;
}
`
],
template: `
<mat-card appearance="outlined">
<mat-card-content>
<div class="summary-header">
<mat-icon>receipt_long</mat-icon>
<h3>K-1 Income Summary</h3>
</div>
<div class="income-grid">
<div class="income-item">
<div
class="income-value"
[ngClass]="getValueClass(totalOrdinaryIncome)"
>
{{ totalOrdinaryIncome | currency: 'USD' : 'symbol' : '1.0-0' }}
</div>
<div class="income-label">Total Ordinary Income</div>
</div>
<div class="income-item">
<div
class="income-value"
[ngClass]="getValueClass(totalCapitalGains)"
>
{{ totalCapitalGains | currency: 'USD' : 'symbol' : '1.0-0' }}
</div>
<div class="income-label">Total Capital Gains</div>
</div>
<div class="income-item">
<div
class="income-value"
[ngClass]="getValueClass(totalDistributions)"
>
{{ totalDistributions | currency: 'USD' : 'symbol' : '1.0-0' }}
</div>
<div class="income-label">Total Distributions</div>
</div>
<div class="income-item">
<div
class="income-value"
[ngClass]="getValueClass(totalOtherAdjustments)"
>
{{
totalOtherAdjustments | currency: 'USD' : 'symbol' : '1.0-0'
}}
</div>
<div class="income-label">Total Other Adjustments</div>
</div>
</div>
</mat-card-content>
</mat-card>
`
})
export class GfK1IncomeSummaryComponent {
@Input() public rows: IActivityRow[] = [];
public get totalOrdinaryIncome(): number {
return this.rows.reduce(
(sum, row) =>
sum + (row.interest ?? 0) + (row.dividends ?? 0) + (row.remainingK1IncomeDed ?? 0),
0
);
}
public get totalCapitalGains(): number {
return this.rows.reduce((sum, row) => sum + (row.capitalGains ?? 0), 0);
}
public get totalDistributions(): number {
return this.rows.reduce((sum, row) => sum + (row.distributions ?? 0), 0);
}
public get totalOtherAdjustments(): number {
return this.rows.reduce(
(sum, row) => sum + (row.otherAdjustments ?? 0),
0
);
}
public getValueClass(value: number): string {
if (value > 0) {
return 'positive';
}
if (value < 0) {
return 'negative';
}
return 'neutral';
}
}

1
libs/ui/src/lib/nav-menu-group/index.ts

@ -0,0 +1 @@
export * from './nav-menu-group.component';

66
libs/ui/src/lib/nav-menu-group/nav-menu-group.component.ts

@ -0,0 +1,66 @@
import { CommonModule } from '@angular/common';
import {
ChangeDetectionStrategy,
Component,
Input
} from '@angular/core';
import { MatButtonModule } from '@angular/material/button';
import { MatMenuModule } from '@angular/material/menu';
import { RouterModule } from '@angular/router';
export interface NavMenuItem {
label: string;
routerLink: string;
}
@Component({
changeDetection: ChangeDetectionStrategy.OnPush,
imports: [CommonModule, MatButtonModule, MatMenuModule, RouterModule],
selector: 'gf-nav-menu-group',
standalone: true,
styles: [
`
:host {
display: inline-block;
}
.nav-trigger {
text-transform: none;
letter-spacing: normal;
}
.font-weight-bold {
font-weight: 700;
}
.text-decoration-underline {
text-decoration: underline;
}
`
],
template: `
<button
class="nav-trigger"
mat-flat-button
[matMenuTriggerFor]="dropdownMenu"
[ngClass]="{
'font-weight-bold': isActive,
'text-decoration-underline': isActive
}"
>
{{ label }}
</button>
<mat-menu #dropdownMenu="matMenu" xPosition="after">
@for (item of menuItems; track item.routerLink) {
<a mat-menu-item [routerLink]="item.routerLink">
{{ item.label }}
</a>
}
</mat-menu>
`
})
export class GfNavMenuGroupComponent {
@Input() public isActive: boolean = false;
@Input() public label: string = '';
@Input() public menuItems: NavMenuItem[] = [];
}

36
specs/008-fo-ui-redesign/checklists/requirements.md

@ -0,0 +1,36 @@
# Specification Quality Checklist: Family Office UI Redesign
**Purpose**: Validate specification completeness and quality before proceeding to planning
**Created**: 2025-07-15
**Feature**: [spec.md](../spec.md)
## Content Quality
- [x] No implementation details (languages, frameworks, APIs)
- [x] Focused on user value and business needs
- [x] Written for non-technical stakeholders
- [x] All mandatory sections completed
## Requirement Completeness
- [x] No [NEEDS CLARIFICATION] markers remain
- [x] Requirements are testable and unambiguous
- [x] Success criteria are measurable
- [x] Success criteria are technology-agnostic (no implementation details)
- [x] All acceptance scenarios are defined
- [x] Edge cases are identified
- [x] Scope is clearly bounded
- [x] Dependencies and assumptions identified
## Feature Readiness
- [x] All functional requirements have clear acceptance criteria
- [x] User scenarios cover primary flows
- [x] Feature meets measurable outcomes defined in Success Criteria
- [x] No implementation details leak into specification
## Notes
- All items pass validation. Spec is ready for `/speckit.clarify` or `/speckit.plan`.
- Assumptions section documents reasonable defaults for auth model, performance calculator accuracy, and mobile behavior.
- No [NEEDS CLARIFICATION] markers were needed — user clarified navigation structure, legacy page handling, and dashboard content via interactive questions before spec authoring.

82
specs/008-fo-ui-redesign/contracts/analysis-page.md

@ -0,0 +1,82 @@
# Analysis Page Data Contract: Family Office UI Redesign
**Feature**: 008-fo-ui-redesign
**Date**: 2026-03-22
## Overview
The portfolio analysis page (`/portfolio/analysis`) currently displays only traditional Ghostfolio portfolio data. This contract defines what family office data MUST be added to the page.
## Existing Data (Preserved)
These data sources remain unchanged — they power the existing analysis widgets:
| Data | Source | Widget |
|---|---|---|
| Portfolio performance | `DataService.fetchPortfolioPerformance()` | Summary cards, benchmark chart |
| Holdings top/bottom 3 | `DataService.fetchPortfolioHoldings()` | Top 3 / Bottom 3 cards |
| Dividends | `DataService.fetchDividends()` | Dividend timeline chart |
| Investments | `DataService.fetchInvestments()` | Investment timeline chart |
| Benchmark | `DataService.fetchBenchmarkForUser()` | Benchmark comparator |
## New Data (Family Office Integration)
These data sources MUST be added to the analysis page:
### 1. Portfolio Summary Metrics
**Source**: `FamilyOfficeDataService.fetchPortfolioSummary()`
**Returns**: `IPortfolioSummary`
**Display contract**:
- A summary card showing **totals**: IRR, TVPI, DPI, RVPI
- A table showing **per-entity** breakdown: entity name, original commitment, % called, unfunded, paid-in, distributions, IRR, TVPI, DPI
**Empty state**: If response has no entities or totals are all zero, show a card that says "No partnership data available. Import a K-1 to get started." with a link to `/k1-import`.
### 2. Asset Class Breakdown
**Source**: `FamilyOfficeDataService.fetchAssetClassSummary()`
**Returns**: `IAssetClassSummary`
**Display contract**:
- A table showing **per-asset-class** breakdown: asset class label, original commitment, paid-in, distributions, IRR, TVPI, DPI
**Empty state**: Hidden if no data (less critical than entity breakdown).
### 3. K1 Income Summary
**Source**: `FamilyOfficeDataService.fetchActivity()`
**Returns**: `IActivityDetail`
**Display contract**:
- A summary card aggregating across all activity rows for the most recent tax year:
- Total Ordinary Income (interest + dividends + remainingK1IncomeDed)
- Total Capital Gains (capitalGains field)
- Total Distributions (distributions field)
- Total Other Adjustments (otherAdjustments field)
- Each category shows the dollar amount
- Zero values displayed as $0 (not hidden), per spec acceptance scenario 3
**Empty state**: If no activity rows, show "No K-1 income data available. Import a K-1 to get started." with link to `/k1-import`.
## Section Ordering
The analysis page sections MUST appear in this order:
1. **(Existing)** Summary cards (total amount, change, performance %)
2. **(Existing)** Benchmark comparator
3. **NEW**: Family Office Performance Metrics (IRR/TVPI/DPI/RVPI totals + entity table)
4. **NEW**: K1 Income Summary (income categories card)
5. **(Existing)** Performance breakdown (asset/currency/net)
6. **NEW**: Asset Class Breakdown (table)
7. **(Existing)** Top 3 / Bottom 3 holdings
8. **(Existing)** Portfolio evolution chart
9. **(Existing)** Investment timeline chart
10. **(Existing)** Dividend timeline chart
## Conditional Rendering
All new sections (3, 4, 6) MUST be gated behind a check for family office data availability:
- If `IPortfolioSummary.entities` is empty AND `IActivityDetail.rows` is empty → show a single "Import K-1 data" guide card instead of the three empty sections
- If any one section has data, show it; hide only the specific sections with no data (except K1 Income which always shows $0 values)

76
specs/008-fo-ui-redesign/contracts/navigation.md

@ -0,0 +1,76 @@
# Navigation Contract: Family Office UI Redesign
**Feature**: 008-fo-ui-redesign
**Date**: 2026-03-22
## Primary Navigation Structure
The header navigation for authenticated users MUST display exactly these 5 top-level items:
### 1. Dashboard (direct link)
- **Route**: `/family-office`
- **Label**: "Dashboard"
- **Behavior**: Direct navigation (no submenu)
### 2. Partnerships (dropdown menu)
- **Label**: "Partnerships"
- **Sub-items**:
| Label | Route | Description |
|---|---|---|
| Entities | `/entities` | Entity management (trusts, LLCs, etc.) |
| Partnerships | `/partnerships` | Partnership list and details |
| Distributions | `/distributions` | Distribution tracking |
| Portfolio Views | `/portfolio-views` | Configurable performance views |
### 3. K-1 Center (dropdown menu)
- **Label**: "K-1 Center"
- **Sub-items**:
| Label | Route | Description |
|---|---|---|
| K-1 Import | `/k1-import` | Upload and parse K1 documents |
| K-1 Documents | `/k-documents` | Browse parsed K1 documents |
| Cell Mapping | `/cell-mapping` | K1 box definition management |
### 4. Analysis (direct link)
- **Route**: `/portfolio` (existing portfolio page with analysis, activities, allocations tabs)
- **Label**: "Analysis"
- **Behavior**: Direct navigation (no submenu)
### 5. Admin (dropdown menu, conditional)
- **Label**: "Admin"
- **Visibility**: Shows only when `hasPermissionToAccessAdminControl` is true
- **Sub-items**:
| Label | Route | Condition |
|---|---|---|
| Admin Control | (existing admin route) | `hasPermissionToAccessAdminControl` |
| Accounts | (existing accounts route) | Always |
| Resources | `/resources` | Always |
| Pricing | `/pricing` | `hasPermissionForSubscription` |
| Legacy Pages | (submenu or section) | Always |
### Legacy Pages (accessible from Admin > Legacy or via direct URL)
| Label | Route |
|---|---|
| Overview | `/home` |
| Holdings | `/home/holdings` |
| Summary | `/home/summary` |
| Markets | `/home/markets` |
| Watchlist | `/home/watchlist` |
| FIRE Calculator | `/portfolio/fire` |
| X-Ray | `/portfolio/x-ray` |
## Mobile Navigation
The mobile hamburger menu (account dropdown, `d-flex d-sm-none` items) MUST mirror the same 5-group structure with all sub-items expanded flat (since nested mat-menus are awkward on mobile).
## Route Preservation
All existing routes MUST continue to work. No routes are removed or redirected. Only the navigation UI changes — the route table in `app.routes.ts` remains intact.
## Default Route Change
| Current | Target |
|---|---|
| `/**``home` | `/**``family-office` |
The wildcard redirect in `app.routes.ts` changes from `redirectTo: 'home'` to `redirectTo: 'family-office'`.

143
specs/008-fo-ui-redesign/data-model.md

@ -0,0 +1,143 @@
# Data Model: Family Office UI Redesign
**Feature**: 008-fo-ui-redesign
**Date**: 2026-03-22
## Overview
This feature requires **no new database models or schema changes**. All data entities already exist in the Prisma schema from the 001-family-office-transform feature. The "data model" for this feature is the **UI state model** — the shapes of data flowing from existing API endpoints to new/modified Angular components.
## Existing Entities (No Changes)
These Prisma models already exist and power the family office API endpoints:
| Entity | Prisma Model | Key Fields | Used By |
|---|---|---|---|
| Entity | `Entity` | id, name, type (INDIVIDUAL/TRUST/LLC/LP/CORPORATION), taxId | Dashboard allocations, Portfolio Summary |
| Partnership | `Partnership` | id, name, ein, entityId, currentValuation | Dashboard AUM, Performance metrics |
| Distribution | `Distribution` | id, partnershipId, entityId, amount, date, type | Dashboard recent distributions |
| K1 Document | `KDocument` | id, partnershipId, taxYear, filingStatus, normalizedData | K1 filing status, Activity ledger |
| K1 Box Definition | `K1BoxDefinition` | id, formType, boxNumber, description, dataType | K1 parsing, Cell Mapping page |
| Valuation | `Valuation` | id, partnershipId, value, quarter, year | AUM calculation |
| Partner Performance | `PartnerPerformance` | id, partnershipId, irr, tvpi, dpi, rvpi | Performance metrics |
## UI State Models (Data Flow)
These are the TypeScript interfaces already defined in `@ghostfolio/common` that flow from API to UI. No new interfaces are needed.
### Dashboard State
```
IFamilyOfficeDashboard (existing, from GET /family-office/dashboard)
├── totalAum: number
├── currency: string
├── entitiesCount: number
├── partnershipsCount: number
├── allocationByEntity[]: { entityId, entityName, value, percentage }
├── allocationByAssetClass[]: { assetClass, value, percentage }
├── allocationByStructure[]: { structureType, value, percentage }
├── recentDistributions[]: { id, partnershipName, amount, date, type }
└── kDocumentStatus: { taxYear, total, draft, estimated, final }
```
### Portfolio Summary State (currently unused by any component)
```
IPortfolioSummary (existing, from GET /family-office/portfolio-summary)
├── entities[]: IEntityPerformanceRow
│ ├── entityId, entityName
│ ├── originalCommitment, percentCalled, unfundedCommitment
│ ├── paidIn, distributions, residualUsed
│ └── dpi, rvpi, tvpi, irr
├── totals: IPerformanceRow (same fields without entity identifiers)
├── valuationYear: number
└── quarter?: number
```
### Asset Class Summary State (currently unused by any component)
```
IAssetClassSummary (existing, from GET /family-office/asset-class-summary)
├── assetClasses[]: IAssetClassPerformanceRow
│ ├── assetClass, assetClassLabel
│ ├── originalCommitment, percentCalled, unfundedCommitment
│ ├── paidIn, distributions, residualUsed
│ └── dpi, rvpi, tvpi, irr
├── totals: IPerformanceRow
├── valuationYear: number
└── quarter?: number
```
### Activity Ledger State (currently unused by any component)
```
IActivityDetail (existing, from GET /family-office/activity)
├── rows[]: IActivityRow
│ ├── year, entityId, entityName, partnershipId, partnershipName
│ ├── beginningBasis, contributions
│ ├── interest, dividends, capitalGains, remainingK1IncomeDed
│ ├── totalIncome, distributions
│ ├── otherAdjustments, endingTaxBasis
│ ├── endingGLBalance, bookToTaxAdj
│ ├── endingK1CapitalAccount, k1CapitalVsTaxBasisDiff
│ ├── excessDistribution, negativeBasis, deltaEndingBasis
│ └── notes
├── totalCount: number
└── filters: { entities[], partnerships[], years[] }
```
### Report State (currently unused by any component)
```
IFamilyOfficeReport (existing, from GET /family-office/report)
├── reportTitle: string
├── period: { start, end }
├── entity?: { id, name }
├── summary: { totalValueStart, totalValueEnd, periodChange, periodChangePercent, ytdChangePercent }
├── assetAllocation: Record<string, { value, percentage }>
├── partnershipPerformance[]: { partnershipId, partnershipName, periodReturn, irr, tvpi, dpi }
├── distributionSummary: { periodTotal, byType: Record<string, number> }
└── benchmarkComparisons?[]: { id, name, periodReturn, excessReturn?, ytdReturn? }
```
## State Transitions
### Navigation State
```
Current: 11+ flat nav items → Target: 5 grouped items
┌─────────────────────────────────────────────┐
│ Dashboard │ Partnerships ▼ │ K-1 Center ▼ │ Analysis │ Admin ▼ │
│ │ Entities │ K-1 Import │ │ Admin Ctrl │
│ │ Partnerships │ K-1 Documents│ │ Accounts │
│ │ Distributions │ Cell Mapping │ │ Resources │
│ │ Portf. Views │ │ │ Pricing │
│ │ │ │ │ Legacy ▸ │
└─────────────────────────────────────────────┘
```
### Default Route State
```
Current: /** → /home → GfHomeOverviewComponent (stock portfolio overview)
Target: /** → /family-office → Dashboard (FO dashboard with AUM + allocations)
```
### Portfolio Analysis Page State
```
Current data flow:
DataService.fetchPortfolioPerformance() → performance cards
DataService.fetchPortfolioHoldings() → top/bottom 3
DataService.fetchDividends() → dividend chart
DataService.fetchInvestments() → investment chart
Target data flow (additions):
FamilyOfficeDataService.fetchPortfolioSummary() → entity performance table + totals
FamilyOfficeDataService.fetchAssetClassSummary() → asset class breakdown
FamilyOfficeDataService.fetchActivity() → K1 income summary card
```
## Validation Rules
No new validation rules for this feature. All validation is already handled by the existing API endpoints and DTOs.

102
specs/008-fo-ui-redesign/plan.md

@ -0,0 +1,102 @@
# Implementation Plan: Family Office UI Redesign
**Branch**: `008-fo-ui-redesign` | **Date**: 2026-03-22 | **Spec**: [spec.md](spec.md)
**Input**: Feature specification from `/specs/008-fo-ui-redesign/spec.md`
## Summary
Redesign the UI to surface existing family office data (K1 income, partnership performance, distributions) in the portfolio analysis view and restructure navigation around five family office-focused top-level items. The backend APIs and data services already exist — `FamilyOfficeService` computes portfolio summaries, asset class breakdowns, activity ledgers, and periodic reports, and `FamilyOfficeDataService` on the client has HTTP wrappers for all five endpoints. However, **zero Angular components consume the four richest endpoints** (portfolio-summary, asset-class-summary, activity, report). The work is primarily frontend: wire existing client-side data methods into the portfolio analysis page, restructure the header navigation into grouped menus, and enhance the existing family dashboard to be the default landing page.
## Technical Context
**Language/Version**: TypeScript 5.9.2, Node.js ≥22.18.0
**Primary Dependencies**: NestJS 11.1.14 (API), Angular 21.1.1 + Angular Material 21.1.1 (client), Prisma 6.19.0 (ORM), Nx 22.5.3 (monorepo), chart.js 4.5.1, date-fns 4.1.0, Bull 4.16.5 (queues), Redis (caching), Ionic 8.8.1
**Storage**: PostgreSQL via Prisma ORM, Redis for caching
**Testing**: Jest 30.2.0 (via `@nx/jest`), jest-preset-angular 16.0.0 for client tests
**Target Platform**: Linux server (Docker/Railway), web browser (Angular SPA)
**Project Type**: Nx monorepo web application (API + Client + 2 shared libs)
**Performance Goals**: Dashboard load < 5s, portfolio analysis with K1 data < 3s (SC-001, SC-005)
**Constraints**: Preserve all existing Ghostfolio functionality; all legacy URLs must keep working (SC-004); no breaking API changes
**Scale/Scope**: Single family office (1-5 users), ~50 entities/partnerships, ~100 K1 documents, 5 primary nav items
## Constitution Check
_GATE: Must pass before Phase 0 research. Re-check after Phase 1 design._
| # | Constitution Principle | Status | Notes |
|---|---|---|---|
| I | Nx Monorepo Structure | **PASS** | All changes in existing 4 projects (api, client, common, ui). No new Nx projects. |
| II | NestJS Module Pattern | **PASS** | No new backend modules needed. Existing `FamilyOfficeModule` already exposes all required endpoints. Minor additions only if new aggregation endpoints are needed. |
| III | Prisma Data Layer | **PASS** | No schema changes required. All data models already exist. |
| IV | TypeScript Strict Conventions | **PASS** | Standard — no dead code, path aliases. |
| V | Simplicity First / YAGNI | **PASS** | This feature wires existing unused APIs into existing UI pages. No new abstractions. Maximum reuse of `FamilyOfficeDataService` methods already built. |
| VI | Interface-First Design | **PASS** | All interfaces already defined in `@ghostfolio/common` (`IFamilyOfficeDashboard`, `IPortfolioSummary`, `IAssetClassSummary`, `IActivityDetail`, `IFamilyOfficeReport`). |
**Pre-Phase 0 Gate**: All 6 principles PASS. No violations.
**Post-Phase 1 Re-check**:
| # | Constitution Principle | Status | Notes |
|---|---|---|---|
| I | Nx Monorepo Structure | **PASS** | All changes within existing 4 projects. 3 new components in `libs/ui`, modifications to `apps/client` pages and header. No new Nx projects. |
| II | NestJS Module Pattern | **PASS** | No backend changes required. All API endpoints already exist in `FamilyOfficeModule`. |
| III | Prisma Data Layer | **PASS** | No schema changes. No new migrations. |
| IV | TypeScript Strict Conventions | **PASS** | All new code follows strict mode. Path aliases used consistently. |
| V | Simplicity First / YAGNI | **PASS** | Feature wires 4 existing unused API endpoints to UI components. 3 new small UI components (k1-income-summary, nav-menu-group, performance-metrics-card). No new abstractions or architectural layers. Maximum reuse of existing `FamilyOfficeDataService` and `libs/ui` components. |
| VI | Interface-First Design | **PASS** | All interfaces already defined in `@ghostfolio/common` (`IFamilyOfficeDashboard`, `IPortfolioSummary`, `IAssetClassSummary`, `IActivityDetail`, `IFamilyOfficeReport`). No new shared interfaces needed. |
**Post-Phase 1 Gate**: All 6 principles PASS. Design adds 3 new `libs/ui` components and modifies 6 existing files. No violations.
## Project Structure
### Documentation (this feature)
```text
specs/008-fo-ui-redesign/
├── plan.md # This file
├── research.md # Phase 0 output
├── data-model.md # Phase 1 output
├── quickstart.md # Phase 1 output
├── contracts/ # Phase 1 output (navigation contract, analysis data shape)
└── tasks.md # Phase 2 output (/speckit.tasks command - NOT created by /speckit.plan)
```
### Source Code (repository root)
```text
apps/client/src/app/
├── components/
│ └── header/
│ ├── header.component.ts # MODIFIED: Restructure nav to 5 grouped items
│ └── header.component.html # MODIFIED: New nav template with submenus
├── pages/
│ ├── portfolio/
│ │ └── analysis/
│ │ ├── analysis-page.component.ts # MODIFIED: Add FO data integration
│ │ └── analysis-page.component.html # MODIFIED: Add K1 income sections
│ ├── family-dashboard/
│ │ ├── dashboard-page.component.ts # MODIFIED: Add portfolio summary, K1 income cards
│ │ └── dashboard-page.component.html # MODIFIED: Enhanced dashboard layout
│ └── home/
│ └── home-page.routes.ts # MODIFIED: Default redirect to family-dashboard
├── services/
│ └── family-office-data.service.ts # EXISTING: Already has all HTTP methods, no changes
└── app.routes.ts # MODIFIED: Update default route
libs/ui/src/lib/
├── k1-income-summary/ # NEW: Reusable K1 income breakdown card
│ ├── k1-income-summary.component.ts
│ └── k1-income-summary.component.html
├── performance-metrics-card/ # NEW: Reusable IRR/TVPI/DPI/RVPI card
│ ├── performance-metrics-card.component.ts
│ └── performance-metrics-card.component.html
└── nav-menu-group/ # NEW: Grouped nav item with expandable submenu
├── nav-menu-group.component.ts
└── nav-menu-group.component.html
```
**Structure Decision**: Follows the existing Nx monorepo structure. This is primarily a frontend feature — modifying existing Angular pages (portfolio analysis, family dashboard, header) and adding 3 small reusable UI components in `libs/ui`. No new NestJS modules, no Prisma schema changes, no new Nx projects. All within the existing 4-project structure (api, client, common, ui).
## Complexity Tracking
No constitution violations to justify — all 6 principles pass.

97
specs/008-fo-ui-redesign/quickstart.md

@ -0,0 +1,97 @@
# Quickstart: Family Office UI Redesign
**Feature**: 008-fo-ui-redesign
**Branch**: `008-fo-ui-redesign`
## Prerequisites
- Node.js ≥22.18.0
- Docker (for PostgreSQL and Redis)
- At least one parsed K1 document in the database (for testing portfolio analysis integration)
## Setup
```bash
# 1. Start dev infrastructure
docker compose -f docker/docker-compose.dev.yml up -d
# 2. Install dependencies
npm install
# 3. Run database migrations
npx prisma migrate dev
# 4. Seed the database (if fresh)
npx prisma db seed
# 5. Start the API (terminal 1)
npx nx serve api
# 6. Start the client (terminal 2)
npx nx serve client
```
The client runs at http://localhost:4200 and the API at http://localhost:3333.
## Auth Token
Use this token for API testing:
```
3a99343a9f099119cf2c297fe082de12e656e6291cd6a45b4b128f775e0898af4e5141e1c032b1ff64d12efde3c0a31d4c9c1cc1022f64ec9dc4e88dbcc8f318
```
## Verification Workflow
### P1: Portfolio Analysis + K1 Data
1. Navigate to http://localhost:4200/portfolio
2. Verify the analysis page shows family office performance metrics (IRR, TVPI, DPI, RVPI)
3. Verify K1 income summary card shows income categories
4. Verify entity-level breakdown table appears
5. If no K1 data exists, verify the empty state guides to K-1 Import
### P2: Navigation Restructure
1. After login, verify the header shows 5 top-level items: Dashboard, Partnerships, K-1 Center, Analysis, Admin
2. Click "Partnerships" — verify dropdown shows Entities, Partnerships, Distributions, Portfolio Views
3. Click "K-1 Center" — verify dropdown shows K-1 Import, K-1 Documents, Cell Mapping
4. Navigate to a legacy page via URL (e.g., /home/holdings) — verify it still loads
### P3: Dashboard Landing
1. Navigate to http://localhost:4200/ — verify redirect to /family-office
2. Verify dashboard shows AUM, allocations, portfolio metrics, K1 income summary, recent distributions
3. If no data, verify onboarding guide appears
### P4: Legacy Pages
1. Navigate to Admin > Legacy section
2. Verify Overview, Holdings, Summary, Markets, Watchlist, FIRE, X-Ray are all accessible
## API Endpoints for Testing
```bash
# Dashboard data
curl -H "Authorization: Bearer $TOKEN" http://localhost:3333/api/v1/family-office/dashboard
# Portfolio summary (entity-level performance)
curl -H "Authorization: Bearer $TOKEN" http://localhost:3333/api/v1/family-office/portfolio-summary
# Asset class summary
curl -H "Authorization: Bearer $TOKEN" http://localhost:3333/api/v1/family-office/asset-class-summary
# Activity (K1 income ledger)
curl -H "Authorization: Bearer $TOKEN" http://localhost:3333/api/v1/family-office/activity
# Report
curl -H "Authorization: Bearer $TOKEN" "http://localhost:3333/api/v1/family-office/report?period=quarterly&year=2025"
```
## Key Files to Modify
| File | Change |
|---|---|
| `apps/client/src/app/components/header/header.component.html` | Restructure nav to 5 grouped items |
| `apps/client/src/app/components/header/header.component.ts` | Add mat-menu properties for nav groups |
| `apps/client/src/app/pages/portfolio/analysis/analysis-page.component.ts` | Inject FamilyOfficeDataService, fetch K1 data |
| `apps/client/src/app/pages/portfolio/analysis/analysis-page.component.html` | Add FO metrics sections |
| `apps/client/src/app/pages/family-dashboard/dashboard-page.component.ts` | Add portfolio summary + K1 income sections |
| `apps/client/src/app/app.routes.ts` | Change wildcard redirect to `family-office` |
| `libs/ui/src/lib/k1-income-summary/` | NEW: K1 income breakdown card component |
| `libs/ui/src/lib/nav-menu-group/` | NEW: Grouped nav item with mat-menu dropdown |

147
specs/008-fo-ui-redesign/research.md

@ -0,0 +1,147 @@
# Research: Family Office UI Redesign
**Feature**: 008-fo-ui-redesign
**Date**: 2026-03-22
## Research Questions & Findings
### R-001: How does the current navigation work and what needs to change?
**Decision**: Restructure the flat 11+ nav items into 5 grouped top-level items using Angular Material `mat-menu` dropdowns.
**Rationale**: The header component (`header.component.html`, 587 lines) currently renders 11 top-level `<li>` items in a toolbar `<ul>` for desktop, then duplicates them all as `mat-menu-item` entries inside the account dropdown for mobile. There are no nested/grouped menu patterns anywhere in the app today. The mat-menu component (`@angular/material/menu`) is already imported and used for the assistant and account menus, so extending it to navigation groups requires no new dependencies.
**Current navigation items (authenticated desktop)**:
1. Overview → `['/']`
2. Portfolio → `internalRoutes.portfolio`
3. Accounts → `internalRoutes.accounts`
4. Entities → `/entities`
5. Partnerships → `/partnerships`
6. Distributions → `/distributions`
7. K-1 Documents → `/k-documents`
8. K-1 Import → `/k1-import`
9. Cell Mapping → `/cell-mapping`
10. Portfolio Views → `/portfolio-views`
11. Admin Control (conditional)
**Target navigation structure**:
1. **Dashboard**`/family-office` (existing route, currently at bottom of app.routes.ts)
2. **Partnerships** (mat-menu dropdown) → Entities, Partnerships, Distributions, Portfolio Views
3. **K-1 Center** (mat-menu dropdown) → K-1 Import, K-1 Documents, Cell Mapping
4. **Analysis** → existing `/portfolio` (portfolio analysis, activities, allocations)
5. **Admin** (conditional) → Admin Control, Accounts, Resources, Pricing
**Legacy pages** (moved to "More" or accessible only via URL): Overview, Holdings, Summary, Markets, Watchlist, FIRE, X-Ray
**Alternatives considered**:
- Sidebar navigation: Rejected — would require major layout restructuring, inconsistent with Ghostfolio's toolbar approach
- Keep flat list, just reorder: Rejected — 11+ items is too many, doesn't communicate hierarchy
---
### R-002: What family office data is available but not surfaced in portfolio analysis?
**Decision**: The portfolio analysis page must be modified to call `FamilyOfficeDataService` methods that already exist but have zero UI consumers.
**Rationale**: Research reveals a complete parallel data system:
| Available Endpoint | Interface | Client Method | Current UI Consumer |
|---|---|---|---|
| `GET /family-office/dashboard` | `IFamilyOfficeDashboard` | `fetchDashboard()` | Family Dashboard page ONLY |
| `GET /family-office/portfolio-summary` | `IPortfolioSummary` | `fetchPortfolioSummary()` | **NONE** |
| `GET /family-office/asset-class-summary` | `IAssetClassSummary` | `fetchAssetClassSummary()` | **NONE** |
| `GET /family-office/activity` | `IActivityDetail` | `fetchActivity()` | **NONE** |
| `GET /family-office/report` | `IFamilyOfficeReport` | `fetchReport()` | **NONE** |
The four richest endpoints have complete backend implementations in `FamilyOfficeService` (1453 lines), HTTP client wrappers in `FamilyOfficeDataService`, and typed interfaces in `@ghostfolio/common` — but **no Angular component uses them**.
**Key data shapes unused**:
- `IPortfolioSummary`: Entity-level IRR, TVPI, DPI, RVPI, original commitment, unfunded, paid-in, distributions
- `IAssetClassSummary`: Same metrics broken down by asset class
- `IActivityDetail`: Full K1 ledger with basis tracking, income decomposition (interest, dividends, capital gains, deductions), excess distributions
- `IFamilyOfficeReport`: Period performance, partnership-level returns, distribution summaries, benchmark comparisons
**Alternatives considered**:
- Add new API endpoints: Rejected — the data is already computed and exposed, just not consumed
- Modify `PortfolioService.getDetails()` to include K1 data: Rejected — would couple two separate data domains; better to add FO data alongside the existing view
---
### R-003: How should K1 data integrate into the portfolio analysis page?
**Decision**: Add a new section to the analysis page using `FamilyOfficeDataService` alongside the existing `DataService`, displaying family office metrics in new cards below the existing Ghostfolio analysis content.
**Rationale**: The current analysis page (`analysis-page.component.ts`, 296 lines) uses only `DataService` for standard Ghostfolio portfolio data. Rather than replacing this, add a conditional section that appears when family office data exists. This preserves backward compatibility and follows the spec requirement that legacy features remain functional.
**Integration approach**:
1. Inject `FamilyOfficeDataService` into the analysis page component
2. Call `fetchPortfolioSummary()` and `fetchAssetClassSummary()` on init
3. Add new template sections for: K1 income summary card, performance metrics (IRR/TVPI/DPI/RVPI), entity breakdown table, asset class breakdown table
4. Gate the new sections behind a check for non-empty family office data
5. Use existing `libs/ui` components where possible (`performance-metrics/` already exists)
**Alternatives considered**:
- Create a completely separate "Family Office Analysis" page: Rejected — spec says P1 is about the portfolio analysis page showing K1 data, not a new page
- Embed an iframe to the family dashboard: Rejected — terrible UX, not a real integration
---
### R-004: How should the default landing page change?
**Decision**: Change the default route from `home` (Overview page) to `family-office` (Family Dashboard).
**Rationale**: The current routing flow is:
- `/**` wildcard → redirects to `home`
- `home` → loads `home-page.routes`, default child renders `GfHomeOverviewComponent` (traditional portfolio overview)
The family dashboard at `/family-office` already exists and renders AUM, allocations, distributions, and K1 status. Making it the default landing page requires only a route redirect change in `app.routes.ts`.
**Alternatives considered**:
- Create a new `/dashboard` route: Rejected — `/family-office` already serves this purpose and has the data bindings
- Modify the home Overview component: Rejected — that component is deeply integrated with the Ghostfolio portfolio model
---
### R-005: What existing UI components can be reused?
**Decision**: Reuse `performance-metrics/`, `distribution-chart/`, `entity-card/`, and other existing `libs/ui` components. Create 2-3 new small components for K1 income summary and nav grouping.
**Rationale**: The `libs/ui` library already contains 35+ components including:
- `performance-metrics/` — displays IRR/TVPI/DPI (already exists, used somewhere)
- `distribution-chart/` — distribution visualization
- `entity-card/` — entity summary card
- `entity-logo/` — entity logo display
- `partnership-table/` — partnership members table
- `k-document-form/` — K1 data entry form
- `benchmark-comparison-chart/` — benchmark overlay
- `value/` — formatted value display with trend indicator
- `toggle/` — month/year toggle (used in analysis page)
**New components needed**:
- `k1-income-summary/` — card showing K1 income categories (ordinary, rental, capital gains, deductions, credits)
- `nav-menu-group/` — toolbar button that triggers a mat-menu dropdown with sub-links (reusable for Partnerships and K-1 Center groups)
**Alternatives considered**:
- Build everything from scratch: Rejected — violates Simplicity First principle
- Use third-party nav component library: Rejected — Angular Material mat-menu is sufficient
---
### R-006: What is the dashboard enhancement strategy?
**Decision**: Enhance the existing family dashboard page to also display portfolio summary metrics (from `fetchPortfolioSummary()`) and K1 income summary (from `fetchActivity()`), making it a comprehensive landing page.
**Rationale**: The current family dashboard only calls `fetchDashboard()` which provides AUM, allocations, distributions, and K1 filing status. But the richer portfolio summary (IRR, TVPI, DPI, RVPI, commitments) and K1 activity data (income decomposition) are available via separate endpoints that have zero consumers. Adding 2-3 more data fetch calls and template sections to the existing dashboard component transforms it from a basic status page into a comprehensive family office command center.
**Dashboard sections (target)**:
1. AUM hero card (existing)
2. Allocation charts (existing: by entity, asset class, structure)
3. **NEW**: Portfolio performance metrics (IRR, TVPI, DPI, RVPI from `fetchPortfolioSummary()`)
4. **NEW**: K1 income summary (current year from `fetchActivity()`)
5. Recent distributions (existing)
6. K-1 filing status (existing)
7. **NEW**: Onboarding guide when no data exists
**Alternatives considered**:
- Create a brand new dashboard component: Rejected — existing one already has most of the infrastructure
- Merge dashboard into home/overview: Rejected — home/overview is tightly coupled to Ghostfolio portfolio model

147
specs/008-fo-ui-redesign/spec.md

@ -0,0 +1,147 @@
# Feature Specification: Family Office UI Redesign
**Feature Branch**: `008-fo-ui-redesign`
**Created**: 2025-07-15
**Status**: Draft
**Input**: User description: "Reorganize and redesign the UI for a family office focused view"
## User Scenarios & Testing _(mandatory)_
### User Story 1 - Portfolio Analysis Integrates K1 Data (Priority: P1)
As a family office administrator, I want the portfolio analysis view to display income, gains, deductions, and distributions derived from parsed K1 documents so that I can see my complete financial picture in one place instead of an empty portfolio page.
Currently, the portfolio analysis page shows nothing even though two K1 documents have been successfully parsed. This is because the core portfolio data pipeline pulls only from the traditional Orders/Activities/Accounts model and has no connection to the K1 and partnership data managed by the family office services. This story bridges that gap so parsed K1 data flows into portfolio analysis.
**Why this priority**: This is the single highest-value fix. The user has already done the work of importing and parsing K1s, yet the portfolio remains empty. Without this, the entire application feels broken for a family office use case.
**Independent Test**: Import and parse a K1 document, then navigate to Portfolio Analysis. The page must display partnership-level income, capital gains, and distributions from the parsed K1 rather than showing an empty state.
**Acceptance Scenarios**:
1. **Given** at least one K1 document has been parsed for a partnership, **When** the user navigates to the Portfolio Analysis page, **Then** the page displays a summary of ordinary income, capital gains (short-term and long-term), deductions, and distributions sourced from K1 data.
2. **Given** multiple K1 documents exist for different partnerships, **When** the user views Portfolio Analysis, **Then** each partnership is listed with its own financial breakdown and a total aggregation across all partnerships.
3. **Given** a K1 document has been parsed but contains zero values for some categories, **When** the user views the analysis, **Then** zero-value categories are displayed as $0 (not hidden) to confirm the data was parsed.
4. **Given** no K1 documents have been parsed, **When** the user views Portfolio Analysis, **Then** the page shows an informational empty state that guides the user to import a K1.
---
### User Story 2 - Family Office Navigation Structure (Priority: P2)
As a family office administrator, I want the primary navigation to prioritize family office workflows (Dashboard, Partnerships, K-1 Center, Analysis) so that I can access the tools I use daily without scrolling through irrelevant stock-trading features.
The current navigation has 14+ top-level items, most inherited from the original Ghostfolio individual investor UI. For a family office user, Entities, Partnerships, Distributions, K-1 Documents, K-1 Import, and Portfolio Views are the important pages, while Markets, Watchlist, FIRE calculator, and X-Ray are rarely or never used.
**Why this priority**: Navigation structure determines the day-to-day usability of the application. Fixing the data (P1) is more critical, but reorganizing navigation makes every subsequent task faster and makes the application feel purpose-built.
**Independent Test**: Log in and verify the primary navigation shows five grouped items — Dashboard, Partnerships, K-1 Center, Analysis, Admin — and that legacy Ghostfolio pages are accessible from a secondary location but not in the main navigation.
**Acceptance Scenarios**:
1. **Given** a user logs in, **When** the header navigation renders, **Then** the primary navigation shows exactly these top-level items: Dashboard, Partnerships, K-1 Center, Analysis, Admin.
2. **Given** the user clicks "K-1 Center", **When** the submenu expands, **Then** it shows links to K-1 Import, K-1 Documents, and Cell Mapping (box definitions).
3. **Given** the user clicks "Partnerships", **When** the submenu expands, **Then** it shows links to Entities, Partnerships list, Distributions, and Portfolio Views.
4. **Given** the user wants to access legacy pages (Holdings, Watchlist, Markets, FIRE, X-Ray), **When** they look for these pages, **Then** they are available under a secondary "Legacy" or "More" section in the navigation but are not shown in the primary header.
---
### User Story 3 - Unified Family Office Dashboard (Priority: P3)
As a family office administrator, I want a unified dashboard as the landing page that shows total assets under management, partnership allocation breakdown, recent K1 income summaries, and recent distributions so that I get an immediate snapshot of the family office state upon login.
The current Overview page shows a traditional portfolio balance chart, holdings by market, and performance metrics sourced from the stock/ETF order model. For a family office, the landing page should instead surface partnership valuations, K1-derived income, and distribution history.
**Why this priority**: A purpose-built dashboard is the "front door" of the application and reinforces the family office identity. However, it depends on the data integration (P1) and navigation (P2) being in place first to be useful.
**Independent Test**: Log in and land on the Dashboard. Verify it displays total AUM across partnerships, a breakdown of allocations by partnership, a summary of K1 income for the current tax year, and the most recent distributions.
**Acceptance Scenarios**:
1. **Given** the user has partnerships with parsed K1 data, **When** they land on the Dashboard after login, **Then** they see total assets under management calculated from partnership valuations.
2. **Given** the user has three partnerships, **When** viewing the Dashboard, **Then** a visual breakdown (chart or table) shows each partnership's allocation as a percentage of total AUM.
3. **Given** K1 documents have been parsed for the current tax year, **When** viewing the Dashboard, **Then** a summary card shows total ordinary income, total capital gains, and total deductions for the year.
4. **Given** distributions have been recorded, **When** viewing the Dashboard, **Then** the five most recent distributions are listed with partnership name, date, and amount.
5. **Given** no partnerships or K1 data exist, **When** the user lands on the Dashboard, **Then** a guided onboarding state is displayed explaining the steps: create an entity, add a partnership, import a K1.
---
### User Story 4 - Deprioritize Legacy Ghostfolio Pages (Priority: P4)
As a family office administrator, I want stock-portfolio-oriented pages (Holdings, Summary, Watchlist, Markets, FIRE calculator, X-Ray) moved to a secondary "Legacy" or "More" section so they remain accessible for occasional use without cluttering my primary workflow.
**Why this priority**: These pages still function and may be useful occasionally, but they should not compete for attention with family office features. This is a low-effort navigation change that complements P2.
**Independent Test**: Navigate to the secondary "Legacy" section and verify all original Ghostfolio pages load correctly and function as before.
**Acceptance Scenarios**:
1. **Given** a user navigates to the secondary "Legacy" section, **When** they click any legacy page link, **Then** the page loads with all original functionality intact.
2. **Given** a user is on a legacy page, **When** they look at the navigation, **Then** the primary nav still shows the family office items and the legacy section is clearly marked as secondary.
---
### Edge Cases
- What happens when a K1 document is partially parsed (some boxes extracted, others failed)? The portfolio analysis should display available data with an indicator showing which fields are incomplete.
- What happens when a partnership has K1 data from multiple tax years? The analysis should default to the most recent tax year with an option to select prior years.
- What happens when a user has both traditional stock orders AND K1 partnerships? The dashboard should show the family office view by default, with a clearly labeled toggle or tab to view traditional portfolio data if needed.
- What happens when K1 aggregation rules produce conflicting data? The system should display the raw parsed values with a warning that aggregation may need review.
- What happens when the user has entities but no partnerships linked to them? The dashboard should show entities with a prompt to create partnerships.
## Requirements _(mandatory)_
### Functional Requirements
**Data Integration (Portfolio Analysis + K1)**
- **FR-001**: System MUST surface parsed K1 data (ordinary income, capital gains, deductions, credits, distributions) within the portfolio analysis view.
- **FR-002**: System MUST aggregate K1 data across all partnerships for a total portfolio summary while also allowing per-partnership drill-down.
- **FR-003**: System MUST display the tax year associated with each K1 data set and allow the user to filter or switch between tax years.
- **FR-004**: System MUST show a meaningful empty state on portfolio analysis when no K1 data has been parsed, guiding the user to import a K1.
- **FR-005**: System MUST include family office performance metrics (XIRR, TVPI, DPI, RVPI) alongside K1 income data in the portfolio analysis view.
**Navigation Restructure**
- **FR-006**: System MUST present primary navigation with five top-level items: Dashboard, Partnerships, K-1 Center, Analysis, and Admin.
- **FR-007**: The "K-1 Center" navigation item MUST expand to show sub-links for K-1 Import, K-1 Documents, and Cell Mapping (box definitions).
- **FR-008**: The "Partnerships" navigation item MUST expand to show sub-links for Entities, Partnerships list, Distributions, and Portfolio Views.
- **FR-009**: Legacy Ghostfolio pages (Holdings, Summary, Watchlist, Markets, FIRE, X-Ray) MUST remain accessible from a secondary "Legacy" or "More" section.
- **FR-010**: All existing URLs for legacy pages MUST continue to work (no broken bookmarks or links).
**Dashboard**
- **FR-011**: The default landing page after login MUST be the Family Office Dashboard.
- **FR-012**: The Dashboard MUST display total assets under management calculated from partnership valuations.
- **FR-013**: The Dashboard MUST display a visual allocation breakdown by partnership (percentage of total AUM).
- **FR-014**: The Dashboard MUST display a summary of K1 income for the selected tax year (ordinary income, capital gains, deductions).
- **FR-015**: The Dashboard MUST display the five most recent distributions with partnership name, date, and amount.
- **FR-016**: The Dashboard MUST display an onboarding guide when no entities, partnerships, or K1 data exist.
### Key Entities
- **Entity**: A legal entity (person, trust, LLC) that serves as a partner in one or more partnerships. Has a name, type, tax ID, and related partnerships.
- **Partnership**: An investment partnership that has partners (entities), receives K1 documents, holds valuations, and generates distributions. Key attributes: name, EIN, current valuation, partner entities.
- **K1 Document (KDocument)**: A parsed IRS Schedule K-1 form for a specific partnership and tax year. Contains normalized box values for income, gains, losses, deductions, and credits.
- **Distribution**: A cash or in-kind distribution from a partnership to a partner. Has a date, amount, partnership, and receiving entity.
- **K1 Box Definition**: A mapping of K1 form box numbers to their semantic meaning (e.g., Box 1 = Ordinary Business Income). Used for parsing and aggregation.
- **Portfolio View**: A configurable view combining family office performance metrics (XIRR, TVPI, DPI, RVPI) for selected partnerships.
## Assumptions
- The existing `FamilyOfficeService`, `FamilyOfficeDataService`, and `FamilyOfficePerformanceCalculator` already compute the correct K1-derived metrics — the work is in surfacing them in the right UI views, not recomputing them.
- The existing partnership valuation data is accurate and can serve as the basis for AUM calculations on the dashboard.
- The current authentication and authorization model does not change — all users with access can see all family office data.
- Legacy Ghostfolio pages will not be modified, only relocated in the navigation hierarchy.
- Mobile/responsive behavior should match the existing application's responsive patterns.
## Success Criteria _(mandatory)_
### Measurable Outcomes
- **SC-001**: A user with at least one parsed K1 document sees non-empty portfolio analysis data within 3 seconds of navigating to the Portfolio Analysis page.
- **SC-002**: The primary navigation contains no more than 5 top-level items, all relevant to family office workflows.
- **SC-003**: A new user can navigate from login to importing their first K1 document in under 4 clicks (Dashboard → K-1 Center → K-1 Import → Upload).
- **SC-004**: 100% of existing legacy page URLs continue to function (no 404 errors introduced).
- **SC-005**: The Dashboard landing page loads and displays AUM, partnership breakdown, and K1 summary within 5 seconds for a portfolio with up to 20 partnerships.
- **SC-006**: A user can access any legacy Ghostfolio page (Holdings, Markets, FIRE, X-Ray, Watchlist) from the secondary navigation section within 2 clicks.
- **SC-007**: All K1 income categories (ordinary income, rental income, capital gains short/long term, deductions, credits) are individually visible in the portfolio analysis view — no data is silently omitted.

220
specs/008-fo-ui-redesign/tasks.md

@ -0,0 +1,220 @@
# Tasks: Family Office UI Redesign
**Input**: Design documents from `/specs/008-fo-ui-redesign/`
**Prerequisites**: plan.md, spec.md, research.md, data-model.md, contracts/navigation.md, contracts/analysis-page.md, quickstart.md
**Tests**: Not requested — no test tasks included.
**Organization**: Tasks are grouped by user story to enable independent implementation and testing of each story.
## Format: `[ID] [P?] [Story] Description`
- **[P]**: Can run in parallel (different files, no dependencies)
- **[Story]**: Which user story this task belongs to (e.g., US1, US2, US3)
- Include exact file paths in descriptions
## Path Conventions
- **API**: `apps/api/src/`
- **Client**: `apps/client/src/app/`
- **Common lib**: `libs/common/src/lib/`
- **UI lib**: `libs/ui/src/lib/`
---
## Phase 1: Setup
**Purpose**: No project initialization needed — this feature works entirely within the existing codebase. Phase 1 is minimal: verify the dev environment and existing API endpoints respond correctly.
- [X] T001 Verify dev environment starts cleanly: `docker compose -f docker/docker-compose.dev.yml up -d`, `npx nx serve api`, `npx nx serve client`
- [X] T002 Verify family office API endpoints return data: `GET /api/v1/family-office/dashboard`, `GET /api/v1/family-office/portfolio-summary`, `GET /api/v1/family-office/asset-class-summary`, `GET /api/v1/family-office/activity`
---
## Phase 2: Foundational (Shared UI Components)
**Purpose**: Create reusable UI components in `libs/ui` that will be consumed by multiple user stories (US1 and US3 both use performance metrics and K1 income summary).
**⚠️ CRITICAL**: US1 and US3 depend on these components being available.
- [X] T003 [P] Create K1 income summary component in libs/ui/src/lib/k1-income-summary/k1-income-summary.component.ts — standalone Angular component accepting `IActivityRow[]` input, aggregates and displays: Total Ordinary Income (interest + dividends + remainingK1IncomeDed), Total Capital Gains, Total Distributions, Total Other Adjustments as a Material card with labeled dollar amounts. Zero values display as $0 per FR-001/SC-007.
- [X] T004 [P] SKIPPED — Reusing existing `gf-performance-metrics` component at libs/ui/src/lib/performance-metrics/ which already displays IRR, TVPI, DPI, RVPI as individual mat-cards with proper formatting.
- [X] T005 [P] Create nav menu group component in libs/ui/src/lib/nav-menu-group/nav-menu-group.component.ts — standalone Angular component with a `mat-button` trigger and `mat-menu` dropdown. Inputs: `label: string`, `menuItems: { label: string, routerLink: string }[]`, `isActive: boolean`. Outputs: renders a toolbar button that opens a dropdown with `routerLink` items. Used for Partnerships, K-1 Center, and Admin nav groups.
- [X] T006 Export two new components from libs/ui/src/lib/ barrel files (index.ts) — k1-income-summary and nav-menu-group importable via `@ghostfolio/ui/k1-income-summary` and `@ghostfolio/ui/nav-menu-group`
**Checkpoint**: Three reusable components ready for consumption by US1, US2, US3.
---
## Phase 3: User Story 1 — Portfolio Analysis Integrates K1 Data (Priority: P1) 🎯 MVP
**Goal**: Wire existing family office API data into the portfolio analysis page so parsed K1 data appears instead of an empty portfolio.
**Independent Test**: Navigate to `/portfolio` → Analysis tab. Page shows FO performance metrics (IRR/TVPI/DPI/RVPI), per-entity breakdown table, K1 income summary card, and asset class breakdown. If no FO data, shows empty state with link to K-1 Import.
### Implementation for User Story 1
- [X] T007 [US1] Inject `FamilyOfficeDataService` into apps/client/src/app/pages/portfolio/analysis/analysis-page.component.ts — add import, inject via constructor or `inject()`, add properties for `portfolioSummary: IPortfolioSummary`, `assetClassSummary: IAssetClassSummary`, `activityDetail: IActivityDetail`, and a `hasFamilyOfficeData: boolean` flag
- [X] T008 [US1] Add data fetching in analysis-page.component.ts `ngOnInit` — call `FamilyOfficeDataService.fetchPortfolioSummary()`, `fetchAssetClassSummary()`, and `fetchActivity()`. Subscribe and assign results. Set `hasFamilyOfficeData = true` when any data is non-empty. Use `takeUntilDestroyed` for cleanup.
- [X] T009 [US1] Add FO performance metrics section to analysis page template in apps/client/src/app/pages/portfolio/analysis/analysis-page.component.html — insert after the benchmark comparator section: `<gf-performance-metrics-card>` bound to `portfolioSummary?.totals`, gated by `hasFamilyOfficeData`
- [X] T010 [US1] Add entity breakdown table to analysis page template — after the performance metrics card: a Material table showing each entity from `portfolioSummary.entities` with columns: Entity Name, Original Commitment, % Called, Unfunded, Paid-In, Distributions, IRR, TVPI, DPI. Gated by `portfolioSummary?.entities?.length > 0`.
- [X] T011 [US1] Add K1 income summary section to analysis page template — insert `<gf-k1-income-summary>` bound to `activityDetail?.rows`, positioned after the entity table per contracts/analysis-page.md section ordering
- [X] T012 [US1] Add asset class breakdown table to analysis page template — after the performance breakdown section: a Material table showing each asset class from `assetClassSummary.assetClasses` with columns: Asset Class, Original Commitment, Paid-In, Distributions, IRR, TVPI, DPI. Hidden if no data.
- [X] T013 [US1] Add empty state card to analysis page template — when `!hasFamilyOfficeData && !isLoading`: show a `mat-card` with message "No K-1 data available. Import a K-1 to get started." and a `routerLink` button to `/k1-import` per FR-004
**Checkpoint**: Portfolio analysis page shows K1 data from parsed documents. US1 independently verifiable.
---
## Phase 4: User Story 2 — Family Office Navigation Structure (Priority: P2)
**Goal**: Restructure the primary navigation from 11+ flat items to 5 grouped top-level items per contracts/navigation.md.
**Independent Test**: Log in → header shows Dashboard, Partnerships (dropdown), K-1 Center (dropdown), Analysis, Admin (dropdown). Legacy pages accessible via Admin > Legacy. All existing URLs still work.
### Implementation for User Story 2
- [X] T014 [US2] Refactor desktop navigation in apps/client/src/app/components/header/header.component.html — replace the 11+ `<li>` nav items in the desktop toolbar `<ul>` with 5 items: (1) Dashboard direct link to `/family-office`, (2) Partnerships `<gf-nav-menu-group>` with items for Entities, Partnerships, Distributions, Portfolio Views, (3) K-1 Center `<gf-nav-menu-group>` with items for K-1 Import, K-1 Documents, Cell Mapping, (4) Analysis direct link to existing portfolio route, (5) Admin `<gf-nav-menu-group>` (conditional on `hasPermissionToAccessAdminControl`) with items for Admin Control, Accounts, Resources, Pricing (conditional), and a Legacy sub-section with Overview, Holdings, Summary, Markets, Watchlist, FIRE, X-Ray
- [X] T015 [US2] Update header.component.ts in apps/client/src/app/components/header/header.component.ts — add imports for `GfNavMenuGroupComponent`, define nav group data structures as properties (partnershipsMenuItems, k1CenterMenuItems, adminMenuItems with routes per contracts/navigation.md), add `isActiveRoute()` helper to highlight the correct nav group based on current URL
- [X] T016 [US2] Refactor mobile navigation in header.component.html — update the `d-flex d-sm-none` section in the account `mat-menu` to mirror the 5-group structure with flat sub-items. Group items under dividers/headers labeled "Partnerships", "K-1 Center", "Admin", "Legacy" instead of listing all 11+ flat items.
**Checkpoint**: Navigation shows 5 grouped items. All existing URLs still work (no route changes). US2 independently verifiable.
---
## Phase 5: User Story 3 — Unified Family Office Dashboard (Priority: P3)
**Goal**: Enhance the existing family dashboard page to include portfolio performance metrics and K1 income summary, and make it the default landing page.
**Independent Test**: Navigate to `/` → redirects to `/family-office`. Dashboard shows AUM, allocations, performance metrics (IRR/TVPI/DPI/RVPI), K1 income summary, recent distributions, K1 filing status. If no data, shows onboarding guide.
### Implementation for User Story 3
- [X] T017 [US3] Add portfolio summary data fetching to dashboard in apps/client/src/app/pages/family-dashboard/dashboard-page.component.ts — inject additional calls to `FamilyOfficeDataService.fetchPortfolioSummary()` and `fetchActivity()` alongside the existing `fetchDashboard()`. Store results in new component properties.
- [X] T018 [US3] Add performance metrics section to dashboard template — insert `<gf-performance-metrics-card>` bound to `portfolioSummary?.totals` between the allocation charts section and the recent distributions section
- [X] T019 [US3] Add K1 income summary section to dashboard template — insert `<gf-k1-income-summary>` bound to `activityDetail?.rows` after the performance metrics card, displaying current tax year K1 income breakdown
- [X] T020 [US3] Add onboarding guide to dashboard template — when `dashboard?.entitiesCount === 0 && dashboard?.partnershipsCount === 0`: display a `mat-card` with 3 steps: (1) Create an Entity → link to `/entities`, (2) Add a Partnership → link to `/partnerships`, (3) Import a K-1 → link to `/k1-import`. Per FR-016.
- [X] T021 [US3] Change default route to family-office dashboard in apps/client/src/app/app.routes.ts — update the wildcard redirect from `redirectTo: 'home'` to `redirectTo: 'family-office'` per contracts/navigation.md
**Checkpoint**: Dashboard is the landing page with full FO data. US3 independently verifiable.
---
## Phase 6: User Story 4 — Deprioritize Legacy Ghostfolio Pages (Priority: P4)
**Goal**: Ensure legacy Ghostfolio pages (Holdings, Summary, Watchlist, Markets, FIRE, X-Ray) remain accessible from a secondary location without cluttering the primary navigation.
**Independent Test**: Navigate to Admin > Legacy in the nav dropdown → all 7 legacy pages are listed and clickable. Direct URL access (e.g., `/home/holdings`, `/portfolio/fire`) still works. No 404s introduced.
### Implementation for User Story 4
- [X] T022 [US4] Verify all legacy routes remain in apps/client/src/app/app.routes.ts — confirm no routes were removed during US2 navigation refactor. All paths must remain: `home`, `home/holdings`, `home/summary`, `home/markets`, `home/watchlist`, `portfolio/fire`, `portfolio/x-ray`. This is a verification task, no code change expected if US2 was done correctly per FR-010/SC-004.
- [X] T023 [US4] Verify legacy pages render correctly — manually navigate to each of the 7 legacy URLs and confirm they load with original functionality intact per spec acceptance scenario 1 for US4
**Checkpoint**: All legacy pages accessible and functional. US4 independently verifiable.
---
## Phase 7: Polish & Cross-Cutting Concerns
**Purpose**: Improvements that affect multiple user stories
- [X] T024 [P] Add SCSS styling for new K1 income summary and performance metrics card components — ensure consistent spacing, typography, and responsive behavior matching existing `libs/ui` component patterns
- [X] T025 [P] Add responsive/mobile styling for nav-menu-group component — ensure grouped navigation works correctly on mobile viewports, including proper touch targets and menu positioning
- [X] T026 Run full quickstart.md verification workflow — execute all 4 verification sections (P1-P4) from specs/008-fo-ui-redesign/quickstart.md, documenting any issues
- [X] T027 Verify SC-001 performance: analysis page with K1 data loads within 3 seconds
- [X] T028 Verify SC-003 click count: login → Dashboard → K-1 Center → K-1 Import → Upload achievable in ≤4 clicks
---
## Dependencies & Execution Order
### Phase Dependencies
- **Setup (Phase 1)**: No dependencies — verify existing environment
- **Foundational (Phase 2)**: No code dependencies on Phase 1 — creates reusable UI components
- **User Story 1 (Phase 3)**: Depends on Phase 2 (T003 k1-income-summary, T004 performance-metrics-card)
- **User Story 2 (Phase 4)**: Depends on Phase 2 (T005 nav-menu-group)
- **User Story 3 (Phase 5)**: Depends on Phase 2 (T003, T004) — ALSO benefits from US2 nav being done but is independently testable
- **User Story 4 (Phase 6)**: Depends on US2 (verifies nav refactor didn't break routes)
- **Polish (Phase 7)**: Depends on all prior phases
### User Story Dependencies
- **User Story 1 (P1)**: Can start after Phase 2 — no dependencies on other stories
- **User Story 2 (P2)**: Can start after Phase 2 — no dependencies on other stories
- **User Story 3 (P3)**: Can start after Phase 2 — no dependencies on other stories (US2 nav is nice-to-have but not required)
- **User Story 4 (P4)**: Depends on US2 being complete (verifies its work)
### Within Each User Story
- Component injection and data fetching before template changes
- Empty state handling after main data display is working
- Core implementation before integration
### Parallel Opportunities
- All Phase 2 tasks (T003, T004, T005) can run in parallel — different component directories
- US1 and US2 can start in parallel after Phase 2 — different files entirely
- US3 can start in parallel with US1/US2 after Phase 2 — different page component
- T024 and T025 can run in parallel — different component styles
---
## Parallel Example: Phase 2 (Foundational)
```bash
# Launch all three component creation tasks simultaneously:
Task T003: "Create K1 income summary component in libs/ui/src/lib/k1-income-summary/"
Task T004: "Create performance metrics card component in libs/ui/src/lib/performance-metrics-card/"
Task T005: "Create nav menu group component in libs/ui/src/lib/nav-menu-group/"
```
## Parallel Example: User Stories 1 + 2 (after Phase 2)
```bash
# Developer A: User Story 1 (portfolio analysis)
Task T007: "Inject FamilyOfficeDataService into analysis-page.component.ts"
Task T008: "Add data fetching in analysis-page.component.ts"
# Developer B: User Story 2 (navigation), simultaneously
Task T014: "Refactor desktop navigation in header.component.html"
Task T015: "Update header.component.ts with nav group data"
```
---
## Implementation Strategy
### MVP First (User Story 1 Only)
1. Complete Phase 1: Setup verification (T001-T002)
2. Complete Phase 2: Create k1-income-summary and performance-metrics-card (T003-T004, T006)
3. Complete Phase 3: User Story 1 — wire FO data into analysis page (T007-T013)
4. **STOP and VALIDATE**: Verify analysis page shows K1 data → this is the MVP
5. Deploy/demo if ready — users can immediately see parsed K1 data in portfolio analysis
### Incremental Delivery
1. **Foundation** (T001-T006): Setup + 3 reusable components → ready
2. **US1** (T007-T013): Portfolio analysis shows K1 data → **MVP deployed**
3. **US2** (T014-T016): Navigation restructured to 5 groups → better daily UX
4. **US3** (T017-T021): Dashboard enhanced + set as landing page → complete FO experience
5. **US4** (T022-T023): Legacy page verification → confidence check
6. **Polish** (T024-T028): Styling, performance, full verification
### Key Insight
This feature is primarily a **wiring exercise** — 4 API endpoints and their client HTTP methods already exist but have zero UI consumers. The main work is Angular template and component changes on 3 pages (analysis, dashboard, header) plus 3 new small `libs/ui` components.
---
## Notes
- No backend code changes required — all API endpoints already exist and return correct data
- No Prisma schema changes — all data models already exist
- No new routes — only the wildcard redirect changes from `home` to `family-office`
- All existing URLs must continue to work (SC-004) — route table in app.routes.ts is only additive
- `FamilyOfficeDataService` already has all HTTP methods wired — just import and call them
- Existing `performance-metrics/` component in libs/ui may be reusable — check if T004 can extend it instead of creating a new one
- Commit after each task or logical group
Loading…
Cancel
Save