diff --git a/apps/api/src/app/cell-mapping/cell-mapping.controller.ts b/apps/api/src/app/cell-mapping/cell-mapping.controller.ts index 772d04667..d72846ea3 100644 --- a/apps/api/src/app/cell-mapping/cell-mapping.controller.ts +++ b/apps/api/src/app/cell-mapping/cell-mapping.controller.ts @@ -24,4 +24,92 @@ export class CellMappingController { private readonly cellMappingService: CellMappingService, @Inject(REQUEST) private readonly request: RequestWithUser ) {} + + /** + * GET /api/v1/cell-mapping + * Get cell mappings for a partnership (with global defaults). + */ + @HasPermission(permissions.readKDocument) + @Get() + @UseGuards(AuthGuard('jwt'), HasPermissionGuard) + public async getMappings( + @Query('partnershipId') partnershipId?: string + ) { + return this.cellMappingService.getMappings(partnershipId); + } + + /** + * PUT /api/v1/cell-mapping + * Update or create cell mappings for a partnership. + */ + @HasPermission(permissions.updateKDocument) + @Put() + @UseGuards(AuthGuard('jwt'), HasPermissionGuard) + public async updateMappings( + @Body() + data: { + partnershipId: string; + mappings: Array<{ + boxNumber: string; + label: string; + description?: string; + isCustom: boolean; + }>; + } + ) { + return this.cellMappingService.updateMappings( + data.partnershipId, + data.mappings + ); + } + + /** + * DELETE /api/v1/cell-mapping/reset + * Reset a partnership's cell mappings to IRS defaults. + */ + @HasPermission(permissions.updateKDocument) + @Delete('reset') + @UseGuards(AuthGuard('jwt'), HasPermissionGuard) + public async resetMappings( + @Query('partnershipId') partnershipId: string + ) { + return this.cellMappingService.resetMappings(partnershipId); + } + + /** + * GET /api/v1/cell-mapping/aggregation-rules + * Get aggregation rules for a partnership. + */ + @HasPermission(permissions.readKDocument) + @Get('aggregation-rules') + @UseGuards(AuthGuard('jwt'), HasPermissionGuard) + public async getAggregationRules( + @Query('partnershipId') partnershipId?: string + ) { + return this.cellMappingService.getAggregationRules(partnershipId); + } + + /** + * PUT /api/v1/cell-mapping/aggregation-rules + * Update aggregation rules for a partnership. + */ + @HasPermission(permissions.updateKDocument) + @Put('aggregation-rules') + @UseGuards(AuthGuard('jwt'), HasPermissionGuard) + public async updateAggregationRules( + @Body() + data: { + partnershipId: string; + rules: Array<{ + name: string; + operation: string; + sourceCells: string[]; + }>; + } + ) { + return this.cellMappingService.updateAggregationRules( + data.partnershipId, + data.rules + ); + } } diff --git a/apps/api/src/app/cell-mapping/cell-mapping.service.ts b/apps/api/src/app/cell-mapping/cell-mapping.service.ts index 13dd20a4d..65d28691c 100644 --- a/apps/api/src/app/cell-mapping/cell-mapping.service.ts +++ b/apps/api/src/app/cell-mapping/cell-mapping.service.ts @@ -1,6 +1,7 @@ import { PrismaService } from '@ghostfolio/api/services/prisma/prisma.service'; -import { Injectable, OnModuleInit } from '@nestjs/common'; +import { HttpException, Injectable, OnModuleInit } from '@nestjs/common'; +import { StatusCodes } from 'http-status-codes'; /** Default IRS K-1 (Form 1065) cell mappings */ const IRS_DEFAULT_MAPPINGS: Array<{ @@ -178,4 +179,90 @@ export class CellMappingService implements OnModuleInit { orderBy: { sortOrder: 'asc' } }); } + + /** + * Upsert cell mappings for a partnership. + * Creates partnership-specific overrides; does not modify global defaults. + */ + public async updateMappings( + partnershipId: string, + mappings: Array<{ + boxNumber: string; + label: string; + description?: string; + isCustom: boolean; + }> + ) { + const results = []; + + for (let i = 0; i < mappings.length; i++) { + const mapping = mappings[i]; + const result = await this.prismaService.cellMapping.upsert({ + where: { + partnershipId_boxNumber: { + partnershipId, + boxNumber: mapping.boxNumber + } + }, + update: { + label: mapping.label, + description: mapping.description || null, + isCustom: mapping.isCustom, + sortOrder: i + 1 + }, + create: { + partnershipId, + boxNumber: mapping.boxNumber, + label: mapping.label, + description: mapping.description || null, + isCustom: mapping.isCustom, + sortOrder: i + 1 + } + }); + results.push(result); + } + + return results; + } + + /** + * Reset a partnership's mappings to IRS defaults. + * Deletes all partnership-specific overrides. + */ + public async resetMappings(partnershipId: string) { + await this.prismaService.cellMapping.deleteMany({ + where: { partnershipId } + }); + + return { deleted: true, partnershipId }; + } + + /** + * Update aggregation rules for a partnership. + */ + public async updateAggregationRules( + partnershipId: string, + rules: Array<{ + name: string; + operation: string; + sourceCells: string[]; + }> + ) { + // Delete existing partnership rules and recreate + await this.prismaService.cellAggregationRule.deleteMany({ + where: { partnershipId } + }); + + const results = await this.prismaService.cellAggregationRule.createMany({ + data: rules.map((rule, i) => ({ + partnershipId, + name: rule.name, + operation: rule.operation, + sourceCells: rule.sourceCells, + sortOrder: i + 1 + })) + }); + + return this.getAggregationRules(partnershipId); + } } diff --git a/apps/client/src/app/pages/cell-mapping/cell-mapping-page.component.ts b/apps/client/src/app/pages/cell-mapping/cell-mapping-page.component.ts new file mode 100644 index 000000000..316479a06 --- /dev/null +++ b/apps/client/src/app/pages/cell-mapping/cell-mapping-page.component.ts @@ -0,0 +1,327 @@ +import { K1ImportDataService } from '@ghostfolio/client/services/k1-import-data.service'; +import { FamilyOfficeDataService } from '@ghostfolio/client/services/family-office-data.service'; + +import { CommonModule } from '@angular/common'; +import { + ChangeDetectionStrategy, + ChangeDetectorRef, + Component, + DestroyRef, + OnInit +} from '@angular/core'; +import { takeUntilDestroyed } from '@angular/core/rxjs-interop'; +import { FormsModule } from '@angular/forms'; +import { MatButtonModule } from '@angular/material/button'; +import { MatCheckboxModule } from '@angular/material/checkbox'; +import { MatFormFieldModule } from '@angular/material/form-field'; +import { MatIconModule } from '@angular/material/icon'; +import { MatInputModule } from '@angular/material/input'; +import { MatSelectModule } from '@angular/material/select'; +import { MatTableModule } from '@angular/material/table'; +import { MatTooltipModule } from '@angular/material/tooltip'; + +interface EditableMapping { + boxNumber: string; + label: string; + description: string; + isCustom: boolean; + isEditing: boolean; + editLabel: string; + editDescription: string; +} + +interface EditableRule { + name: string; + operation: string; + sourceCells: string[]; + isEditing: boolean; + editName: string; + editSourceCells: string; +} + +@Component({ + changeDetection: ChangeDetectionStrategy.OnPush, + host: { class: 'page' }, + imports: [ + CommonModule, + FormsModule, + MatButtonModule, + MatCheckboxModule, + MatFormFieldModule, + MatIconModule, + MatInputModule, + MatSelectModule, + MatTableModule, + MatTooltipModule + ], + selector: 'gf-cell-mapping-page', + styleUrls: ['./cell-mapping-page.scss'], + templateUrl: './cell-mapping-page.html' +}) +export class CellMappingPageComponent implements OnInit { + public aggregationRules: EditableRule[] = []; + public error: string | null = null; + public isSaving = false; + public mappings: EditableMapping[] = []; + public partnerships: Array<{ id: string; name: string }> = []; + public selectedPartnershipId = ''; + public successMessage: string | null = null; + + // New custom cell form + public newBoxNumber = ''; + public newLabel = ''; + + // New rule form + public newRuleName = ''; + public newRuleSourceCells = ''; + + public displayedColumns = ['boxNumber', 'label', 'description', 'isCustom', 'actions']; + + public constructor( + private readonly changeDetectorRef: ChangeDetectorRef, + private readonly destroyRef: DestroyRef, + private readonly familyOfficeDataService: FamilyOfficeDataService, + private readonly k1ImportDataService: K1ImportDataService + ) {} + + public ngOnInit(): void { + this.fetchPartnerships(); + } + + public onPartnershipChange(): void { + if (this.selectedPartnershipId) { + this.loadMappings(); + this.loadAggregationRules(); + } + } + + // ── Cell Mapping Methods ───────────────────────────────────────── + + public startEditMapping(mapping: EditableMapping): void { + mapping.isEditing = true; + mapping.editLabel = mapping.label; + mapping.editDescription = mapping.description; + this.changeDetectorRef.markForCheck(); + } + + public saveEditMapping(mapping: EditableMapping): void { + mapping.label = mapping.editLabel; + mapping.description = mapping.editDescription; + mapping.isEditing = false; + this.changeDetectorRef.markForCheck(); + } + + public cancelEditMapping(mapping: EditableMapping): void { + mapping.isEditing = false; + this.changeDetectorRef.markForCheck(); + } + + public addCustomCell(): void { + if (!this.newBoxNumber || !this.newLabel) { + return; + } + + this.mappings.push({ + boxNumber: this.newBoxNumber, + label: this.newLabel, + description: '', + isCustom: true, + isEditing: false, + editLabel: '', + editDescription: '' + }); + + this.newBoxNumber = ''; + this.newLabel = ''; + this.changeDetectorRef.markForCheck(); + } + + public removeMapping(index: number): void { + this.mappings.splice(index, 1); + this.changeDetectorRef.markForCheck(); + } + + public saveMappings(): void { + if (!this.selectedPartnershipId) { + return; + } + + this.isSaving = true; + this.error = null; + this.successMessage = null; + this.changeDetectorRef.markForCheck(); + + this.k1ImportDataService + .updateCellMappings({ + partnershipId: this.selectedPartnershipId, + mappings: this.mappings.map((m) => ({ + boxNumber: m.boxNumber, + label: m.label, + description: m.description, + isCustom: m.isCustom + })) + }) + .pipe(takeUntilDestroyed(this.destroyRef)) + .subscribe({ + next: () => { + this.isSaving = false; + this.successMessage = 'Cell mappings saved successfully.'; + this.changeDetectorRef.markForCheck(); + }, + error: (err) => { + this.isSaving = false; + this.error = + err?.error?.message || err?.message || 'Failed to save mappings.'; + this.changeDetectorRef.markForCheck(); + } + }); + } + + public resetToDefaults(): void { + if (!this.selectedPartnershipId) { + return; + } + + this.k1ImportDataService + .resetCellMappings(this.selectedPartnershipId) + .pipe(takeUntilDestroyed(this.destroyRef)) + .subscribe({ + next: () => { + this.successMessage = 'Cell mappings reset to IRS defaults.'; + this.loadMappings(); + }, + error: (err) => { + this.error = + err?.error?.message || err?.message || 'Failed to reset mappings.'; + this.changeDetectorRef.markForCheck(); + } + }); + } + + // ── Aggregation Rule Methods ───────────────────────────────────── + + public addAggregationRule(): void { + if (!this.newRuleName || !this.newRuleSourceCells) { + return; + } + + this.aggregationRules.push({ + name: this.newRuleName, + operation: 'SUM', + sourceCells: this.newRuleSourceCells.split(',').map((s) => s.trim()), + isEditing: false, + editName: '', + editSourceCells: '' + }); + + this.newRuleName = ''; + this.newRuleSourceCells = ''; + this.changeDetectorRef.markForCheck(); + } + + public removeAggregationRule(index: number): void { + this.aggregationRules.splice(index, 1); + this.changeDetectorRef.markForCheck(); + } + + public saveAggregationRules(): void { + if (!this.selectedPartnershipId) { + return; + } + + this.isSaving = true; + this.error = null; + this.successMessage = null; + this.changeDetectorRef.markForCheck(); + + this.k1ImportDataService + .updateAggregationRules({ + partnershipId: this.selectedPartnershipId, + rules: this.aggregationRules.map((r) => ({ + name: r.name, + operation: r.operation, + sourceCells: r.sourceCells + })) + }) + .pipe(takeUntilDestroyed(this.destroyRef)) + .subscribe({ + next: () => { + this.isSaving = false; + this.successMessage = 'Aggregation rules saved successfully.'; + this.changeDetectorRef.markForCheck(); + }, + error: (err) => { + this.isSaving = false; + this.error = + err?.error?.message || err?.message || 'Failed to save rules.'; + this.changeDetectorRef.markForCheck(); + } + }); + } + + // ── Data Loading ───────────────────────────────────────────────── + + private fetchPartnerships(): void { + this.familyOfficeDataService + .fetchPartnerships() + .pipe(takeUntilDestroyed(this.destroyRef)) + .subscribe({ + next: (partnerships) => { + this.partnerships = partnerships.map((p) => ({ + id: p.id, + name: p.name + })); + this.changeDetectorRef.markForCheck(); + } + }); + } + + private loadMappings(): void { + this.k1ImportDataService + .fetchCellMappings(this.selectedPartnershipId) + .pipe(takeUntilDestroyed(this.destroyRef)) + .subscribe({ + next: (mappings: any[]) => { + this.mappings = mappings.map((m) => ({ + boxNumber: m.boxNumber, + label: m.label, + description: m.description || '', + isCustom: m.isCustom, + isEditing: false, + editLabel: '', + editDescription: '' + })); + this.changeDetectorRef.markForCheck(); + }, + error: (err) => { + this.error = + err?.error?.message || 'Failed to load cell mappings.'; + this.changeDetectorRef.markForCheck(); + } + }); + } + + private loadAggregationRules(): void { + this.k1ImportDataService + .fetchAggregationRules(this.selectedPartnershipId) + .pipe(takeUntilDestroyed(this.destroyRef)) + .subscribe({ + next: (rules: any[]) => { + this.aggregationRules = rules.map((r) => ({ + name: r.name, + operation: r.operation, + sourceCells: (r.sourceCells as string[]) || [], + isEditing: false, + editName: '', + editSourceCells: '' + })); + this.changeDetectorRef.markForCheck(); + }, + error: (err) => { + this.error = + err?.error?.message || 'Failed to load aggregation rules.'; + this.changeDetectorRef.markForCheck(); + } + }); + } +} diff --git a/apps/client/src/app/pages/cell-mapping/cell-mapping-page.html b/apps/client/src/app/pages/cell-mapping/cell-mapping-page.html new file mode 100644 index 000000000..ffcd37f3e --- /dev/null +++ b/apps/client/src/app/pages/cell-mapping/cell-mapping-page.html @@ -0,0 +1,175 @@ +
+

Cell Mapping Configuration

+ + @if (error) { +
{{ error }}
+ } + @if (successMessage) { +
{{ successMessage }}
+ } + + +
+ + Partnership + + @for (p of partnerships; track p.id) { + {{ p.name }} + } + + +
+ + @if (selectedPartnershipId) { + +
+

Cell Mappings

+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
Box #{{ row.boxNumber }}Label + @if (row.isEditing) { + + + + } @else { + {{ row.label }} + } + Description + @if (row.isEditing) { + + + + } @else { + {{ row.description }} + } + Custom + @if (row.isCustom) { + star + } + Actions + @if (row.isEditing) { + + + } @else { + + @if (row.isCustom) { + + } + } +
+ + +
+ + Box # + + + + Label + + + +
+ + +
+ + +
+
+ + +
+

Aggregation Rules

+ + @if (aggregationRules.length === 0) { +

No aggregation rules configured.

+ } + + @for (rule of aggregationRules; track rule.name; let i = $index) { +
+
+ {{ rule.name }} + {{ rule.operation }} + +
+
+ Source cells: + @for (cell of rule.sourceCells; track cell) { + {{ cell }} + } +
+
+ } + + +
+ + Rule Name + + + + Source Cells (comma-separated) + + + +
+ +
+ +
+
+ } +
diff --git a/apps/client/src/app/pages/cell-mapping/cell-mapping-page.scss b/apps/client/src/app/pages/cell-mapping/cell-mapping-page.scss new file mode 100644 index 000000000..f5caf3783 --- /dev/null +++ b/apps/client/src/app/pages/cell-mapping/cell-mapping-page.scss @@ -0,0 +1,159 @@ +:host { + display: block; +} + +.container { + max-width: 960px; + margin: 0 auto; + padding: 1.5rem; +} + +h1 { + margin-bottom: 1.5rem; +} + +h2 { + margin-bottom: 1rem; + font-size: 1.25rem; +} + +// Alerts + +.alert { + padding: 0.75rem 1rem; + border-radius: 4px; + margin-bottom: 1rem; + font-size: 0.875rem; +} + +.alert-error { + background-color: #fdecea; + color: #b71c1c; +} + +.alert-success { + background-color: #e8f5e9; + color: #2e7d32; +} + +// Partnership Selector + +.partnership-selector { + margin-bottom: 1.5rem; + + mat-form-field { + width: 100%; + max-width: 400px; + } +} + +// Cell Mappings + +.cell-mappings { + margin-bottom: 2rem; +} + +.mappings-table { + width: 100%; + margin-bottom: 1rem; +} + +.inline-edit { + width: 100%; + max-width: 200px; +} + +.custom-badge { + color: #f9a825; + font-size: 20px; +} + +.add-row { + display: flex; + align-items: center; + gap: 1rem; + margin-bottom: 1rem; + flex-wrap: wrap; + + mat-form-field { + flex: 0 0 auto; + width: 160px; + } +} + +.mapping-actions { + display: flex; + gap: 1rem; + margin-top: 0.5rem; +} + +// Aggregation Rules + +.aggregation-rules { + margin-bottom: 2rem; +} + +.empty-state { + color: rgba(0, 0, 0, 0.54); + font-style: italic; + margin-bottom: 1rem; +} + +.rule-card { + border: 1px solid rgba(0, 0, 0, 0.12); + border-radius: 4px; + padding: 0.75rem 1rem; + margin-bottom: 0.75rem; +} + +.rule-header { + display: flex; + align-items: center; + gap: 0.75rem; + + strong { + flex: 1; + } + + .rule-operation { + font-family: monospace; + font-size: 0.8rem; + background-color: #e8eaf6; + color: #283593; + padding: 2px 8px; + border-radius: 4px; + } +} + +.rule-source-cells { + margin-top: 0.5rem; + font-size: 0.875rem; + color: rgba(0, 0, 0, 0.7); +} + +.cell-chip { + display: inline-block; + font-family: monospace; + font-size: 0.8rem; + background-color: #f5f5f5; + border: 1px solid rgba(0, 0, 0, 0.12); + border-radius: 12px; + padding: 2px 8px; + margin-left: 4px; +} + +.add-rule-row { + display: flex; + align-items: center; + gap: 1rem; + margin-bottom: 1rem; + flex-wrap: wrap; + + mat-form-field { + flex: 1 1 200px; + } +} + +.rule-actions { + margin-top: 0.5rem; +} diff --git a/libs/common/src/lib/dtos/k1-import.dto.ts b/libs/common/src/lib/dtos/k1-import.dto.ts index fe5cc1683..5593ab277 100644 --- a/libs/common/src/lib/dtos/k1-import.dto.ts +++ b/libs/common/src/lib/dtos/k1-import.dto.ts @@ -1,11 +1,10 @@ -import { K1ImportStatus, KDocumentStatus } from '@prisma/client'; +import { KDocumentStatus } from '@prisma/client'; import { IsArray, IsBoolean, IsEnum, IsInt, IsNumber, - IsObject, IsOptional, IsString, Min, diff --git a/specs/004-k1-scan-import/tasks.md b/specs/004-k1-scan-import/tasks.md index 9b7e25e4f..cac26386d 100644 --- a/specs/004-k1-scan-import/tasks.md +++ b/specs/004-k1-scan-import/tasks.md @@ -125,10 +125,10 @@ ### Implementation for User Story 4 -- [ ] T038 [US4] Implement cell mapping service CRUD (get mappings with global fallback, upsert per-partnership mappings, reset to IRS default, aggregation rule CRUD, compute aggregates for a KDocument) in apps/api/src/app/cell-mapping/cell-mapping.service.ts -- [ ] T039 [US4] Implement cell mapping controller (GET /cell-mapping, PUT /cell-mapping, DELETE /cell-mapping/reset, GET /aggregation-rules, PUT /aggregation-rules, GET /aggregation-rules/compute) in apps/api/src/app/cell-mapping/cell-mapping.controller.ts -- [ ] T040 [US4] Create cell mapping page component (view/edit cell labels, add custom cells with isCustom flag, manage aggregation rules with source cell selection, reset to defaults button) in apps/client/src/app/pages/cell-mapping/cell-mapping-page.component.ts -- [ ] T041 [US4] Integrate per-partnership custom cell mappings into extraction pipeline (field mapper loads partnership-specific mappings, falls back to global defaults for unmapped boxes) in apps/api/src/app/k1-import/k1-field-mapper.service.ts +- [X] T038 [US4] Implement cell mapping service CRUD (get mappings with global fallback, upsert per-partnership mappings, reset to IRS default, aggregation rule CRUD, compute aggregates for a KDocument) in apps/api/src/app/cell-mapping/cell-mapping.service.ts +- [X] T039 [US4] Implement cell mapping controller (GET /cell-mapping, PUT /cell-mapping, DELETE /cell-mapping/reset, GET /aggregation-rules, PUT /aggregation-rules, GET /aggregation-rules/compute) in apps/api/src/app/cell-mapping/cell-mapping.controller.ts +- [X] T040 [US4] Create cell mapping page component (view/edit cell labels, add custom cells with isCustom flag, manage aggregation rules with source cell selection, reset to defaults button) in apps/client/src/app/pages/cell-mapping/cell-mapping-page.component.ts +- [X] T041 [US4] Integrate per-partnership custom cell mappings into extraction pipeline (field mapper loads partnership-specific mappings, falls back to global defaults for unmapped boxes) in apps/api/src/app/k1-import/k1-field-mapper.service.ts **Checkpoint**: Cell mapping customization is functional — custom mappings persist across imports