Browse Source

feat(k1-import): Phase 2 foundational - modules, extractor interface, seed data, routes

pull/6701/head
Robert Patch 2 months ago
parent
commit
3d25b555b6
  1. 4
      apps/api/src/app/app.module.ts
  2. 27
      apps/api/src/app/cell-mapping/cell-mapping.controller.ts
  3. 14
      apps/api/src/app/cell-mapping/cell-mapping.module.ts
  4. 181
      apps/api/src/app/cell-mapping/cell-mapping.service.ts
  5. 22
      apps/api/src/app/k1-import/extractors/k1-extractor.interface.ts
  6. 31
      apps/api/src/app/k1-import/k1-import.controller.ts
  7. 32
      apps/api/src/app/k1-import/k1-import.module.ts
  8. 30
      apps/api/src/app/k1-import/k1-import.service.ts
  9. 14
      apps/client/src/app/app.routes.ts
  10. 15
      apps/client/src/app/pages/cell-mapping/cell-mapping-page.routes.ts
  11. 15
      apps/client/src/app/pages/k1-import/k1-import-page.routes.ts
  12. 201
      apps/client/src/app/services/k1-import-data.service.ts
  13. 14
      specs/004-k1-scan-import/tasks.md

4
apps/api/src/app/app.module.ts

@ -55,6 +55,8 @@ import { FamilyOfficeModule } from './family-office/family-office.module';
import { HealthModule } from './health/health.module'; import { HealthModule } from './health/health.module';
import { ImportModule } from './import/import.module'; import { ImportModule } from './import/import.module';
import { InfoModule } from './info/info.module'; import { InfoModule } from './info/info.module';
import { CellMappingModule } from './cell-mapping/cell-mapping.module';
import { K1ImportModule } from './k1-import/k1-import.module';
import { KDocumentModule } from './k-document/k-document.module'; import { KDocumentModule } from './k-document/k-document.module';
import { LogoModule } from './logo/logo.module'; import { LogoModule } from './logo/logo.module';
import { PartnershipModule } from './partnership/partnership.module'; import { PartnershipModule } from './partnership/partnership.module';
@ -129,6 +131,8 @@ import { UserModule } from './user/user.module';
HealthModule, HealthModule,
ImportModule, ImportModule,
InfoModule, InfoModule,
CellMappingModule,
K1ImportModule,
KDocumentModule, KDocumentModule,
LogoModule, LogoModule,
MarketDataModule, MarketDataModule,

27
apps/api/src/app/cell-mapping/cell-mapping.controller.ts

@ -0,0 +1,27 @@
import { HasPermission } from '@ghostfolio/api/decorators/has-permission.decorator';
import { HasPermissionGuard } from '@ghostfolio/api/guards/has-permission.guard';
import { permissions } from '@ghostfolio/common/permissions';
import type { RequestWithUser } from '@ghostfolio/common/types';
import {
Body,
Controller,
Delete,
Get,
Inject,
Put,
Query,
UseGuards
} from '@nestjs/common';
import { REQUEST } from '@nestjs/core';
import { AuthGuard } from '@nestjs/passport';
import { CellMappingService } from './cell-mapping.service';
@Controller('cell-mapping')
export class CellMappingController {
public constructor(
private readonly cellMappingService: CellMappingService,
@Inject(REQUEST) private readonly request: RequestWithUser
) {}
}

14
apps/api/src/app/cell-mapping/cell-mapping.module.ts

@ -0,0 +1,14 @@
import { PrismaModule } from '@ghostfolio/api/services/prisma/prisma.module';
import { Module } from '@nestjs/common';
import { CellMappingController } from './cell-mapping.controller';
import { CellMappingService } from './cell-mapping.service';
@Module({
controllers: [CellMappingController],
exports: [CellMappingService],
imports: [PrismaModule],
providers: [CellMappingService]
})
export class CellMappingModule {}

181
apps/api/src/app/cell-mapping/cell-mapping.service.ts

@ -0,0 +1,181 @@
import { PrismaService } from '@ghostfolio/api/services/prisma/prisma.service';
import { Injectable, OnModuleInit } from '@nestjs/common';
/** Default IRS K-1 (Form 1065) cell mappings */
const IRS_DEFAULT_MAPPINGS: Array<{
boxNumber: string;
label: string;
description: string;
sortOrder: number;
}> = [
{ boxNumber: '1', label: 'Ordinary business income (loss)', description: 'IRS Schedule K-1 Box 1', sortOrder: 1 },
{ boxNumber: '2', label: 'Net rental real estate income (loss)', description: 'IRS Schedule K-1 Box 2', sortOrder: 2 },
{ boxNumber: '3', label: 'Other net rental income (loss)', description: 'IRS Schedule K-1 Box 3', sortOrder: 3 },
{ boxNumber: '4', label: 'Guaranteed payments for services', description: 'IRS Schedule K-1 Box 4', sortOrder: 4 },
{ boxNumber: '4a', label: 'Guaranteed payments for capital', description: 'IRS Schedule K-1 Box 4a', sortOrder: 5 },
{ boxNumber: '4b', label: 'Total guaranteed payments', description: 'IRS Schedule K-1 Box 4b', sortOrder: 6 },
{ boxNumber: '5', label: 'Interest income', description: 'IRS Schedule K-1 Box 5', sortOrder: 7 },
{ boxNumber: '6a', label: 'Ordinary dividends', description: 'IRS Schedule K-1 Box 6a', sortOrder: 8 },
{ boxNumber: '6b', label: 'Qualified dividends', description: 'IRS Schedule K-1 Box 6b', sortOrder: 9 },
{ boxNumber: '6c', label: 'Dividend equivalents', description: 'IRS Schedule K-1 Box 6c', sortOrder: 10 },
{ boxNumber: '7', label: 'Royalties', description: 'IRS Schedule K-1 Box 7', sortOrder: 11 },
{ boxNumber: '8', label: 'Net short-term capital gain (loss)', description: 'IRS Schedule K-1 Box 8', sortOrder: 12 },
{ boxNumber: '9a', label: 'Net long-term capital gain (loss)', description: 'IRS Schedule K-1 Box 9a', sortOrder: 13 },
{ boxNumber: '9b', label: 'Collectibles (28%) gain (loss)', description: 'IRS Schedule K-1 Box 9b', sortOrder: 14 },
{ boxNumber: '9c', label: 'Unrecaptured section 1250 gain', description: 'IRS Schedule K-1 Box 9c', sortOrder: 15 },
{ boxNumber: '10', label: 'Net section 1231 gain (loss)', description: 'IRS Schedule K-1 Box 10', sortOrder: 16 },
{ boxNumber: '11', label: 'Other income (loss)', description: 'IRS Schedule K-1 Box 11', sortOrder: 17 },
{ boxNumber: '12', label: 'Section 179 deduction', description: 'IRS Schedule K-1 Box 12', sortOrder: 18 },
{ boxNumber: '13', label: 'Other deductions', description: 'IRS Schedule K-1 Box 13', sortOrder: 19 },
{ boxNumber: '14', label: 'Self-employment earnings (loss)', description: 'IRS Schedule K-1 Box 14', sortOrder: 20 },
{ boxNumber: '15', label: 'Credits', description: 'IRS Schedule K-1 Box 15', sortOrder: 21 },
{ boxNumber: '16', label: 'Foreign transactions', description: 'IRS Schedule K-1 Box 16', sortOrder: 22 },
{ boxNumber: '17', label: 'Alternative minimum tax (AMT) items', description: 'IRS Schedule K-1 Box 17', sortOrder: 23 },
{ boxNumber: '18', label: 'Tax-exempt income and nondeductible expenses', description: 'IRS Schedule K-1 Box 18', sortOrder: 24 },
{ boxNumber: '19a', label: 'Distributions — Cash and marketable securities', description: 'IRS Schedule K-1 Box 19a', sortOrder: 25 },
{ boxNumber: '19b', label: 'Distributions — Other property', description: 'IRS Schedule K-1 Box 19b', sortOrder: 26 },
{ boxNumber: '20', label: 'Other information', description: 'IRS Schedule K-1 Box 20', sortOrder: 27 },
{ boxNumber: '21', label: 'Foreign taxes paid or accrued', description: 'IRS Schedule K-1 Box 21', sortOrder: 28 }
];
/** Default aggregation rules */
const DEFAULT_AGGREGATION_RULES: Array<{
name: string;
operation: string;
sourceCells: string[];
sortOrder: number;
}> = [
{
name: 'Total Ordinary Income',
operation: 'SUM',
sourceCells: ['1'],
sortOrder: 1
},
{
name: 'Total Capital Gains',
operation: 'SUM',
sourceCells: ['8', '9a', '9b', '9c', '10'],
sortOrder: 2
},
{
name: 'Total Deductions',
operation: 'SUM',
sourceCells: ['12', '13'],
sortOrder: 3
}
];
@Injectable()
export class CellMappingService implements OnModuleInit {
public constructor(private readonly prismaService: PrismaService) {}
public async onModuleInit() {
await this.seedDefaultMappings();
await this.seedDefaultAggregationRules();
}
/**
* Seed default IRS cell mappings (partnershipId = null) if they don't exist
*/
public async seedDefaultMappings() {
const existingCount = await this.prismaService.cellMapping.count({
where: { partnershipId: null }
});
if (existingCount > 0) {
return;
}
await this.prismaService.cellMapping.createMany({
data: IRS_DEFAULT_MAPPINGS.map((mapping) => ({
...mapping,
partnershipId: null,
isCustom: false
}))
});
}
/**
* Seed default aggregation rules (partnershipId = null) if they don't exist
*/
public async seedDefaultAggregationRules() {
const existingCount = await this.prismaService.cellAggregationRule.count({
where: { partnershipId: null }
});
if (existingCount > 0) {
return;
}
await this.prismaService.cellAggregationRule.createMany({
data: DEFAULT_AGGREGATION_RULES.map((rule) => ({
...rule,
partnershipId: null
}))
});
}
/**
* Get cell mappings for a partnership (with global defaults for unmapped boxes)
*/
public async getMappings(partnershipId?: string) {
if (!partnershipId) {
return this.prismaService.cellMapping.findMany({
where: { partnershipId: null },
orderBy: { sortOrder: 'asc' }
});
}
// Get partnership-specific mappings
const partnershipMappings = await this.prismaService.cellMapping.findMany({
where: { partnershipId },
orderBy: { sortOrder: 'asc' }
});
// Get global defaults for any boxes not overridden
const globalMappings = await this.prismaService.cellMapping.findMany({
where: { partnershipId: null },
orderBy: { sortOrder: 'asc' }
});
const partnershipBoxNumbers = new Set(
partnershipMappings.map((m) => m.boxNumber)
);
const mergedMappings = [
...partnershipMappings,
...globalMappings.filter((g) => !partnershipBoxNumbers.has(g.boxNumber))
];
return mergedMappings.sort((a, b) => a.sortOrder - b.sortOrder);
}
/**
* Get aggregation rules for a partnership (with global defaults)
*/
public async getAggregationRules(partnershipId?: string) {
if (!partnershipId) {
return this.prismaService.cellAggregationRule.findMany({
where: { partnershipId: null },
orderBy: { sortOrder: 'asc' }
});
}
const partnershipRules =
await this.prismaService.cellAggregationRule.findMany({
where: { partnershipId },
orderBy: { sortOrder: 'asc' }
});
if (partnershipRules.length > 0) {
return partnershipRules;
}
// Fall back to global defaults
return this.prismaService.cellAggregationRule.findMany({
where: { partnershipId: null },
orderBy: { sortOrder: 'asc' }
});
}
}

22
apps/api/src/app/k1-import/extractors/k1-extractor.interface.ts

@ -0,0 +1,22 @@
import type { K1ExtractionResult } from '@ghostfolio/common/interfaces';
/**
* Interface for K-1 PDF data extractors.
* Each extractor implements a different extraction strategy
* (pdf-parse for digital PDFs, Azure DI for scanned, tesseract as fallback).
*/
export interface K1Extractor {
/**
* Extract structured K-1 data from a PDF buffer.
* @param buffer - The PDF file content as a Buffer
* @param fileName - Original filename of the uploaded PDF
* @returns Extracted K-1 fields with confidence scores
*/
extract(buffer: Buffer, fileName: string): Promise<K1ExtractionResult>;
/**
* Check if this extractor is available/configured.
* For example, Azure extractor requires API keys to be configured.
*/
isAvailable(): boolean;
}

31
apps/api/src/app/k1-import/k1-import.controller.ts

@ -0,0 +1,31 @@
import { HasPermission } from '@ghostfolio/api/decorators/has-permission.decorator';
import { HasPermissionGuard } from '@ghostfolio/api/guards/has-permission.guard';
import { permissions } from '@ghostfolio/common/permissions';
import type { RequestWithUser } from '@ghostfolio/common/types';
import {
Body,
Controller,
Get,
Inject,
Param,
Post,
Put,
Query,
UploadedFile,
UseGuards,
UseInterceptors
} from '@nestjs/common';
import { REQUEST } from '@nestjs/core';
import { AuthGuard } from '@nestjs/passport';
import { FileInterceptor } from '@nestjs/platform-express';
import { K1ImportService } from './k1-import.service';
@Controller('k1-import')
export class K1ImportController {
public constructor(
private readonly k1ImportService: K1ImportService,
@Inject(REQUEST) private readonly request: RequestWithUser
) {}
}

32
apps/api/src/app/k1-import/k1-import.module.ts

@ -0,0 +1,32 @@
import { PrismaModule } from '@ghostfolio/api/services/prisma/prisma.module';
import { Module } from '@nestjs/common';
import { CellMappingModule } from '../cell-mapping/cell-mapping.module';
import { UploadModule } from '../upload/upload.module';
import { K1ImportController } from './k1-import.controller';
import { K1ImportService } from './k1-import.service';
import { K1AggregationService } from './k1-aggregation.service';
import { K1AllocationService } from './k1-allocation.service';
import { K1ConfidenceService } from './k1-confidence.service';
import { K1FieldMapperService } from './k1-field-mapper.service';
import { AzureExtractor } from './extractors/azure-extractor';
import { PdfParseExtractor } from './extractors/pdf-parse-extractor';
import { TesseractExtractor } from './extractors/tesseract-extractor';
@Module({
controllers: [K1ImportController],
exports: [K1ImportService],
imports: [CellMappingModule, PrismaModule, UploadModule],
providers: [
AzureExtractor,
K1AggregationService,
K1AllocationService,
K1ConfidenceService,
K1FieldMapperService,
K1ImportService,
PdfParseExtractor,
TesseractExtractor
]
})
export class K1ImportModule {}

30
apps/api/src/app/k1-import/k1-import.service.ts

@ -0,0 +1,30 @@
import { PrismaService } from '@ghostfolio/api/services/prisma/prisma.service';
import { UploadService } from '../upload/upload.service';
import { CellMappingService } from '../cell-mapping/cell-mapping.service';
import { K1FieldMapperService } from './k1-field-mapper.service';
import { K1ConfidenceService } from './k1-confidence.service';
import { K1AllocationService } from './k1-allocation.service';
import { K1AggregationService } from './k1-aggregation.service';
import { PdfParseExtractor } from './extractors/pdf-parse-extractor';
import { AzureExtractor } from './extractors/azure-extractor';
import { TesseractExtractor } from './extractors/tesseract-extractor';
import { HttpException, Injectable } from '@nestjs/common';
import { K1ImportStatus } from '@prisma/client';
import { StatusCodes, getReasonPhrase } from 'http-status-codes';
@Injectable()
export class K1ImportService {
public constructor(
private readonly prismaService: PrismaService,
private readonly uploadService: UploadService,
private readonly cellMappingService: CellMappingService,
private readonly fieldMapperService: K1FieldMapperService,
private readonly confidenceService: K1ConfidenceService,
private readonly allocationService: K1AllocationService,
private readonly aggregationService: K1AggregationService,
private readonly pdfParseExtractor: PdfParseExtractor,
private readonly azureExtractor: AzureExtractor,
private readonly tesseractExtractor: TesseractExtractor
) {}
}

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

@ -176,6 +176,13 @@ export const routes: Routes = [
(m) => m.routes (m) => m.routes
) )
}, },
{
path: 'cell-mapping',
loadChildren: () =>
import('./pages/cell-mapping/cell-mapping-page.routes').then(
(m) => m.routes
)
},
{ {
path: 'k-documents', path: 'k-documents',
loadChildren: () => loadChildren: () =>
@ -183,6 +190,13 @@ export const routes: Routes = [
(m) => m.routes (m) => m.routes
) )
}, },
{
path: 'k1-import',
loadChildren: () =>
import('./pages/k1-import/k1-import-page.routes').then(
(m) => m.routes
)
},
{ {
path: 'reports', path: 'reports',
loadChildren: () => loadChildren: () =>

15
apps/client/src/app/pages/cell-mapping/cell-mapping-page.routes.ts

@ -0,0 +1,15 @@
import { AuthGuard } from '@ghostfolio/client/core/auth.guard';
import { Routes } from '@angular/router';
export const routes: Routes = [
{
canActivate: [AuthGuard],
loadComponent: () =>
import('./cell-mapping-page.component').then(
(c) => c.CellMappingPageComponent
),
path: '',
title: 'Cell Mapping'
}
];

15
apps/client/src/app/pages/k1-import/k1-import-page.routes.ts

@ -0,0 +1,15 @@
import { AuthGuard } from '@ghostfolio/client/core/auth.guard';
import { Routes } from '@angular/router';
export const routes: Routes = [
{
canActivate: [AuthGuard],
loadComponent: () =>
import('./k1-import-page.component').then(
(c) => c.K1ImportPageComponent
),
path: '',
title: 'K-1 Import'
}
];

201
apps/client/src/app/services/k1-import-data.service.ts

@ -0,0 +1,201 @@
import type {
K1ExtractionResult,
K1ImportSessionSummary,
K1AggregationResult
} from '@ghostfolio/common/interfaces';
import type {
ConfirmK1ImportDto,
VerifyK1ImportDto
} from '@ghostfolio/common/dtos';
import { HttpClient, HttpParams } from '@angular/common/http';
import { Injectable } from '@angular/core';
import { Observable } from 'rxjs';
@Injectable({
providedIn: 'root'
})
export class K1ImportDataService {
public constructor(private http: HttpClient) {}
// ── K1 Import Endpoints ──────────────────────────────────────────
/**
* Upload a K-1 PDF and initiate extraction.
* POST /api/v1/k1-import/upload
*/
public uploadK1(formData: FormData): Observable<any> {
return this.http.post('/api/v1/k1-import/upload', formData);
}
/**
* Get the current state of an import session.
* GET /api/v1/k1-import/:id
*/
public fetchImportSession(sessionId: string): Observable<any> {
return this.http.get(`/api/v1/k1-import/${sessionId}`);
}
/**
* Submit user-verified extraction data.
* PUT /api/v1/k1-import/:id/verify
*/
public verifyImportSession(
sessionId: string,
data: VerifyK1ImportDto
): Observable<any> {
return this.http.put(`/api/v1/k1-import/${sessionId}/verify`, data);
}
/**
* Confirm verified data and trigger auto-creation of model objects.
* POST /api/v1/k1-import/:id/confirm
*/
public confirmImportSession(
sessionId: string,
data: ConfirmK1ImportDto
): Observable<any> {
return this.http.post(`/api/v1/k1-import/${sessionId}/confirm`, data);
}
/**
* Cancel an import session.
* POST /api/v1/k1-import/:id/cancel
*/
public cancelImportSession(sessionId: string): Observable<any> {
return this.http.post(`/api/v1/k1-import/${sessionId}/cancel`, {});
}
/**
* List import sessions for a partnership.
* GET /api/v1/k1-import/history
*/
public fetchImportHistory(params: {
partnershipId: string;
taxYear?: number;
}): Observable<K1ImportSessionSummary[]> {
let httpParams = new HttpParams().set(
'partnershipId',
params.partnershipId
);
if (params.taxYear) {
httpParams = httpParams.set('taxYear', params.taxYear.toString());
}
return this.http.get<K1ImportSessionSummary[]>(
'/api/v1/k1-import/history',
{ params: httpParams }
);
}
/**
* Re-run extraction on a previously uploaded PDF.
* POST /api/v1/k1-import/:id/reprocess
*/
public reprocessImportSession(sessionId: string): Observable<any> {
return this.http.post(`/api/v1/k1-import/${sessionId}/reprocess`, {});
}
// ── Cell Mapping Endpoints ───────────────────────────────────────
/**
* Get cell mappings for a partnership (with global defaults).
* GET /api/v1/cell-mapping
*/
public fetchCellMappings(partnershipId?: string): Observable<any[]> {
let httpParams = new HttpParams();
if (partnershipId) {
httpParams = httpParams.set('partnershipId', partnershipId);
}
return this.http.get<any[]>('/api/v1/cell-mapping', {
params: httpParams
});
}
/**
* Update or create cell mappings for a partnership.
* PUT /api/v1/cell-mapping
*/
public updateCellMappings(data: {
partnershipId: string;
mappings: Array<{
boxNumber: string;
label: string;
description?: string;
isCustom: boolean;
}>;
}): Observable<any[]> {
return this.http.put<any[]>('/api/v1/cell-mapping', data);
}
/**
* Reset a partnership's cell mappings to IRS defaults.
* DELETE /api/v1/cell-mapping/reset
*/
public resetCellMappings(partnershipId: string): Observable<void> {
const httpParams = new HttpParams().set('partnershipId', partnershipId);
return this.http.delete<void>('/api/v1/cell-mapping/reset', {
params: httpParams
});
}
// ── Aggregation Rule Endpoints ───────────────────────────────────
/**
* Get aggregation rules for a partnership.
* GET /api/v1/cell-mapping/aggregation-rules
*/
public fetchAggregationRules(partnershipId?: string): Observable<any[]> {
let httpParams = new HttpParams();
if (partnershipId) {
httpParams = httpParams.set('partnershipId', partnershipId);
}
return this.http.get<any[]>('/api/v1/cell-mapping/aggregation-rules', {
params: httpParams
});
}
/**
* Create or update aggregation rules for a partnership.
* PUT /api/v1/cell-mapping/aggregation-rules
*/
public updateAggregationRules(data: {
partnershipId: string;
rules: Array<{
name: string;
operation: string;
sourceCells: string[];
}>;
}): Observable<any[]> {
return this.http.put<any[]>(
'/api/v1/cell-mapping/aggregation-rules',
data
);
}
/**
* Compute aggregation values for a specific KDocument.
* GET /api/v1/cell-mapping/aggregation-rules/compute
*/
public computeAggregations(params: {
kDocumentId: string;
partnershipId?: string;
}): Observable<K1AggregationResult[]> {
let httpParams = new HttpParams().set('kDocumentId', params.kDocumentId);
if (params.partnershipId) {
httpParams = httpParams.set('partnershipId', params.partnershipId);
}
return this.http.get<K1AggregationResult[]>(
'/api/v1/cell-mapping/aggregation-rules/compute',
{ params: httpParams }
);
}
}

14
specs/004-k1-scan-import/tasks.md

@ -42,13 +42,13 @@
**⚠️ CRITICAL**: No user story work can begin until this phase is complete **⚠️ CRITICAL**: No user story work can begin until this phase is complete
- [ ] T007 Create K1Import NestJS module skeleton (module, empty controller, empty service) in apps/api/src/app/k1-import/k1-import.module.ts - [X] T007 Create K1Import NestJS module skeleton (module, empty controller, empty service) in apps/api/src/app/k1-import/k1-import.module.ts
- [ ] T008 [P] Create CellMapping NestJS module skeleton (module, empty controller, empty service) in apps/api/src/app/cell-mapping/cell-mapping.module.ts - [X] T008 [P] Create CellMapping NestJS module skeleton (module, empty controller, empty service) in apps/api/src/app/cell-mapping/cell-mapping.module.ts
- [ ] T009 Register K1ImportModule and CellMappingModule in apps/api/src/app/app.module.ts - [X] T009 Register K1ImportModule and CellMappingModule in apps/api/src/app/app.module.ts
- [ ] T010 Create K1 extractor interface (K1Extractor with extract method returning K1ExtractionResult) in apps/api/src/app/k1-import/extractors/k1-extractor.interface.ts - [X] T010 Create K1 extractor interface (K1Extractor with extract method returning K1ExtractionResult) in apps/api/src/app/k1-import/extractors/k1-extractor.interface.ts
- [ ] T011 Implement cell mapping seed logic (28 IRS default rows + 3 default aggregation rules) in apps/api/src/app/cell-mapping/cell-mapping.service.ts - [X] T011 Implement cell mapping seed logic (28 IRS default rows + 3 default aggregation rules) in apps/api/src/app/cell-mapping/cell-mapping.service.ts
- [ ] T012 [P] Create K1 import frontend data service (HTTP client for all k1-import and cell-mapping endpoints) in apps/client/src/app/services/k1-import-data.service.ts - [X] T012 [P] Create K1 import frontend data service (HTTP client for all k1-import and cell-mapping endpoints) in apps/client/src/app/services/k1-import-data.service.ts
- [ ] T013 [P] Create frontend route configurations for K1 import pages in apps/client/src/app/pages/k1-import/k1-import-page.routes.ts and cell mapping pages in apps/client/src/app/pages/cell-mapping/cell-mapping-page.routes.ts - [X] T013 [P] Create frontend route configurations for K1 import pages in apps/client/src/app/pages/k1-import/k1-import-page.routes.ts and cell mapping pages in apps/client/src/app/pages/cell-mapping/cell-mapping-page.routes.ts
**Checkpoint**: Foundation ready — user story implementation can now begin in parallel **Checkpoint**: Foundation ready — user story implementation can now begin in parallel

Loading…
Cancel
Save