mirror of https://github.com/ghostfolio/ghostfolio
7 changed files with 842 additions and 7 deletions
@ -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(); |
|||
} |
|||
}); |
|||
} |
|||
} |
|||
@ -0,0 +1,175 @@ |
|||
<div class="container"> |
|||
<h1>Cell Mapping Configuration</h1> |
|||
|
|||
@if (error) { |
|||
<div class="alert alert-error">{{ error }}</div> |
|||
} |
|||
@if (successMessage) { |
|||
<div class="alert alert-success">{{ successMessage }}</div> |
|||
} |
|||
|
|||
<!-- Partnership Selector --> |
|||
<section class="partnership-selector"> |
|||
<mat-form-field appearance="outline"> |
|||
<mat-label>Partnership</mat-label> |
|||
<mat-select [(ngModel)]="selectedPartnershipId" (selectionChange)="onPartnershipChange()"> |
|||
@for (p of partnerships; track p.id) { |
|||
<mat-option [value]="p.id">{{ p.name }}</mat-option> |
|||
} |
|||
</mat-select> |
|||
</mat-form-field> |
|||
</section> |
|||
|
|||
@if (selectedPartnershipId) { |
|||
<!-- Cell Mappings --> |
|||
<section class="cell-mappings"> |
|||
<h2>Cell Mappings</h2> |
|||
|
|||
<table mat-table [dataSource]="mappings" class="mappings-table"> |
|||
<!-- Box Number --> |
|||
<ng-container matColumnDef="boxNumber"> |
|||
<th mat-header-cell *matHeaderCellDef>Box #</th> |
|||
<td mat-cell *matCellDef="let row">{{ row.boxNumber }}</td> |
|||
</ng-container> |
|||
|
|||
<!-- Label --> |
|||
<ng-container matColumnDef="label"> |
|||
<th mat-header-cell *matHeaderCellDef>Label</th> |
|||
<td mat-cell *matCellDef="let row"> |
|||
@if (row.isEditing) { |
|||
<mat-form-field appearance="outline" class="inline-edit"> |
|||
<input matInput [(ngModel)]="row.editLabel" /> |
|||
</mat-form-field> |
|||
} @else { |
|||
{{ row.label }} |
|||
} |
|||
</td> |
|||
</ng-container> |
|||
|
|||
<!-- Description --> |
|||
<ng-container matColumnDef="description"> |
|||
<th mat-header-cell *matHeaderCellDef>Description</th> |
|||
<td mat-cell *matCellDef="let row"> |
|||
@if (row.isEditing) { |
|||
<mat-form-field appearance="outline" class="inline-edit"> |
|||
<input matInput [(ngModel)]="row.editDescription" /> |
|||
</mat-form-field> |
|||
} @else { |
|||
{{ row.description }} |
|||
} |
|||
</td> |
|||
</ng-container> |
|||
|
|||
<!-- Is Custom --> |
|||
<ng-container matColumnDef="isCustom"> |
|||
<th mat-header-cell *matHeaderCellDef>Custom</th> |
|||
<td mat-cell *matCellDef="let row"> |
|||
@if (row.isCustom) { |
|||
<mat-icon class="custom-badge" matTooltip="Partnership-specific override">star</mat-icon> |
|||
} |
|||
</td> |
|||
</ng-container> |
|||
|
|||
<!-- Actions --> |
|||
<ng-container matColumnDef="actions"> |
|||
<th mat-header-cell *matHeaderCellDef>Actions</th> |
|||
<td mat-cell *matCellDef="let row; let i = index"> |
|||
@if (row.isEditing) { |
|||
<button mat-icon-button (click)="saveEditMapping(row)" matTooltip="Save"> |
|||
<mat-icon>check</mat-icon> |
|||
</button> |
|||
<button mat-icon-button (click)="cancelEditMapping(row)" matTooltip="Cancel"> |
|||
<mat-icon>close</mat-icon> |
|||
</button> |
|||
} @else { |
|||
<button mat-icon-button (click)="startEditMapping(row)" matTooltip="Edit"> |
|||
<mat-icon>edit</mat-icon> |
|||
</button> |
|||
@if (row.isCustom) { |
|||
<button mat-icon-button (click)="removeMapping(i)" matTooltip="Remove"> |
|||
<mat-icon>delete</mat-icon> |
|||
</button> |
|||
} |
|||
} |
|||
</td> |
|||
</ng-container> |
|||
|
|||
<tr mat-header-row *matHeaderRowDef="displayedColumns"></tr> |
|||
<tr mat-row *matRowDef="let row; columns: displayedColumns;"></tr> |
|||
</table> |
|||
|
|||
<!-- Add Custom Cell --> |
|||
<div class="add-row"> |
|||
<mat-form-field appearance="outline"> |
|||
<mat-label>Box #</mat-label> |
|||
<input matInput [(ngModel)]="newBoxNumber" placeholder="e.g. 20c" /> |
|||
</mat-form-field> |
|||
<mat-form-field appearance="outline"> |
|||
<mat-label>Label</mat-label> |
|||
<input matInput [(ngModel)]="newLabel" placeholder="e.g. Other deductions" /> |
|||
</mat-form-field> |
|||
<button mat-stroked-button (click)="addCustomCell()" [disabled]="!newBoxNumber || !newLabel"> |
|||
<mat-icon>add</mat-icon> Add Custom Cell |
|||
</button> |
|||
</div> |
|||
|
|||
<!-- Mapping Actions --> |
|||
<div class="mapping-actions"> |
|||
<button mat-flat-button color="primary" (click)="saveMappings()" [disabled]="isSaving"> |
|||
Save Mappings |
|||
</button> |
|||
<button mat-stroked-button color="warn" (click)="resetToDefaults()"> |
|||
Reset to IRS Defaults |
|||
</button> |
|||
</div> |
|||
</section> |
|||
|
|||
<!-- Aggregation Rules --> |
|||
<section class="aggregation-rules"> |
|||
<h2>Aggregation Rules</h2> |
|||
|
|||
@if (aggregationRules.length === 0) { |
|||
<p class="empty-state">No aggregation rules configured.</p> |
|||
} |
|||
|
|||
@for (rule of aggregationRules; track rule.name; let i = $index) { |
|||
<div class="rule-card"> |
|||
<div class="rule-header"> |
|||
<strong>{{ rule.name }}</strong> |
|||
<span class="rule-operation">{{ rule.operation }}</span> |
|||
<button mat-icon-button (click)="removeAggregationRule(i)" matTooltip="Remove rule"> |
|||
<mat-icon>delete</mat-icon> |
|||
</button> |
|||
</div> |
|||
<div class="rule-source-cells"> |
|||
Source cells: |
|||
@for (cell of rule.sourceCells; track cell) { |
|||
<span class="cell-chip">{{ cell }}</span> |
|||
} |
|||
</div> |
|||
</div> |
|||
} |
|||
|
|||
<!-- Add Aggregation Rule --> |
|||
<div class="add-rule-row"> |
|||
<mat-form-field appearance="outline"> |
|||
<mat-label>Rule Name</mat-label> |
|||
<input matInput [(ngModel)]="newRuleName" placeholder="e.g. Total Income" /> |
|||
</mat-form-field> |
|||
<mat-form-field appearance="outline"> |
|||
<mat-label>Source Cells (comma-separated)</mat-label> |
|||
<input matInput [(ngModel)]="newRuleSourceCells" placeholder="e.g. 1, 2, 3, 4a" /> |
|||
</mat-form-field> |
|||
<button mat-stroked-button (click)="addAggregationRule()" [disabled]="!newRuleName || !newRuleSourceCells"> |
|||
<mat-icon>add</mat-icon> Add Rule |
|||
</button> |
|||
</div> |
|||
|
|||
<div class="rule-actions"> |
|||
<button mat-flat-button color="primary" (click)="saveAggregationRules()" [disabled]="isSaving"> |
|||
Save Rules |
|||
</button> |
|||
</div> |
|||
</section> |
|||
} |
|||
</div> |
|||
@ -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; |
|||
} |
|||
Loading…
Reference in new issue