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 @@ +
| Box # | +{{ row.boxNumber }} | +Label | +
+ @if (row.isEditing) {
+ |
+ Description | +
+ @if (row.isEditing) {
+ |
+ Custom | +
+ @if (row.isCustom) {
+ |
+ Actions | ++ @if (row.isEditing) { + + + } @else { + + @if (row.isCustom) { + + } + } + | +
|---|
No aggregation rules configured.
+ } + + @for (rule of aggregationRules; track rule.name; let i = $index) { +