Skip to content

Commit 67cefdf

Browse files
committed
feat(import-wizard): implement multi-step import wizard with file upload, preview, and mapping detection
- Added ImportWizardComponent with stepper navigation for file import. - Implemented Step1UploadComponent for file selection and validation. - Created Step2DetectComponent to display detected column mappings and preview data. - Introduced Step3ConfigureComponent, Step4PreviewComponent, and Step5ConfirmComponent as placeholders for future functionality. - Enhanced ImportService to handle file validation and import confirmation with detailed error handling. - Integrated apollo-upload-client for handling file uploads in GraphQL requests. - Updated app routing to include the new import wizard path. - Added styles and HTML templates for each step of the import wizard. - Created debug.xlsx for testing import functionality.
1 parent d128b0e commit 67cefdf

28 files changed

Lines changed: 1034 additions & 44 deletions

.devcontainer/devcontainer.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,7 @@
1616
"vscode": {
1717
"extensions": [
1818
"ms-dotnettools.csharp",
19+
"ms-dotnettools.csdevkit",
1920
"ms-azuretools.vscode-docker",
2021
"graphql.vscode-graphql",
2122
"graphql.vscode-graphql-syntax",

PhantomDave.BankTracking.Api/Program.cs

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,9 @@ public class Program
2121
public static void Main(string[] args)
2222
{
2323
var builder = WebApplication.CreateBuilder(args);
24+
25+
ExcelPackage.License.SetNonCommercialPersonal("BankTracker Developer");
26+
2427

2528
var connectionString = builder.Configuration.GetConnectionString("DefaultConnection")
2629
?? throw new InvalidOperationException("Connection string 'DefaultConnection' not configured.");

PhantomDave.BankTracking.Api/Services/FileImportService.cs

Lines changed: 90 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -19,7 +19,8 @@ public class ParsedFileData
1919
public int TotalRows { get; set; }
2020
public string DetectedDelimiter { get; set; } = ",";
2121
public string DetectedEncoding { get; set; } = "UTF-8";
22-
public FileType FileTypeExt { get; set; } = FileType.Xlsx;
22+
public FileType FileTypeExt { get; set; } = FileType.Xlsx;
23+
public int HeaderRowIndex { get; set; } = 1;
2324
}
2425
public class FileImportService
2526
{
@@ -36,12 +37,17 @@ public async Task<ParsedFileData> ParseFileAsync(IFile file)
3637
var sampleText = await streamReader.ReadToEndAsync();
3738
parsedData.DetectedDelimiter = DetectDelimiter(sampleText);
3839
reader.Position = 0;
39-
parsedData.Rows = await ParseCsvAsync(reader);
40+
var (rows, headerRowIndex) = await ParseCsvAsync(reader);
41+
parsedData.Rows = rows;
42+
parsedData.HeaderRowIndex = headerRowIndex;
4043
}
4144
else if (file.Name.EndsWith(".xlsx", StringComparison.OrdinalIgnoreCase))
4245
{
4346
parsedData.FileTypeExt = FileType.Xlsx;
44-
parsedData.Rows = ParseXlsxAsync(reader).Result;
47+
var parsedXlsxData = await ParseXlsxAsync(reader);
48+
parsedData.Rows = parsedXlsxData.Rows;
49+
parsedData.Headers = parsedXlsxData.Headers;
50+
parsedData.HeaderRowIndex = parsedXlsxData.HeaderRowIndex;
4551
}
4652
else
4753
{
@@ -51,7 +57,7 @@ public async Task<ParsedFileData> ParseFileAsync(IFile file)
5157
return parsedData;
5258
}
5359

54-
private static async Task<List<Dictionary<string, string>>> ParseCsvAsync(Stream stream)
60+
private static async Task<(List<Dictionary<string, string>> Rows, int HeaderRowIndex)> ParseCsvAsync(Stream stream)
5561
{
5662
var rows = new List<Dictionary<string, string>>();
5763

@@ -71,7 +77,7 @@ private static async Task<List<Dictionary<string, string>>> ParseCsvAsync(Stream
7177

7278
if (headers == null || headers.Length == 0)
7379
{
74-
return rows;
80+
return (rows, 1);
7581
}
7682

7783
while (await csv.ReadAsync())
@@ -84,40 +90,108 @@ private static async Task<List<Dictionary<string, string>>> ParseCsvAsync(Stream
8490
rows.Add(row);
8591
}
8692

87-
return rows;
93+
return (rows, 1);
8894
}
8995

90-
private static async Task<List<Dictionary<string, string>>> ParseXlsxAsync(Stream stream)
96+
private async Task<ParsedFileData> ParseXlsxAsync(Stream stream)
9197
{
92-
var rows = new List<Dictionary<string, string>>();
98+
var parsedData = new ParsedFileData();
99+
100+
parsedData.Rows = new List<Dictionary<string, string>>();
93101

94102
using var package = new ExcelPackage(stream);
95103
var worksheet = package.Workbook.Worksheets.FirstOrDefault();
96104

97105
if (worksheet?.Dimension == null)
98106
{
99-
return rows;
107+
return new ParsedFileData { Rows = parsedData.Rows, HeaderRowIndex = 1 };
100108
}
101109

102-
var headers = new List<string>();
110+
var headerRowIndex = DetectHeaderRow(worksheet);
111+
112+
parsedData.Headers = new List<string>();
103113
for (var col = 1; col <= worksheet.Dimension.End.Column; col++)
104114
{
105-
var headerValue = worksheet.Cells[1, col].Text ?? $"Column{col}";
106-
headers.Add(headerValue);
115+
var headerValue = worksheet.Cells[headerRowIndex, col].Text ?? $"Column{col}";
116+
parsedData.Headers.Add(headerValue);
107117
}
108118

109-
for (var row = 2; row <= worksheet.Dimension.End.Row; row++)
119+
for (var row = headerRowIndex + 1; row <= worksheet.Dimension.End.Row; row++)
110120
{
111121
var rowData = new Dictionary<string, string>();
112122
for (var col = 1; col <= worksheet.Dimension.End.Column; col++)
113123
{
114124
var cellValue = worksheet.Cells[row, col].Text ?? string.Empty;
115-
rowData[headers[col - 1]] = cellValue;
125+
rowData[parsedData.Headers[col - 1]] = cellValue;
126+
}
127+
parsedData.Rows.Add(rowData);
128+
}
129+
130+
return await Task.FromResult(parsedData);
131+
}
132+
133+
private static int DetectHeaderRow(ExcelWorksheet worksheet)
134+
{
135+
var maxRowsToCheck = Math.Min(50, worksheet.Dimension.End.Row);
136+
var bestRow = 1;
137+
var bestScore = 0;
138+
139+
var headerKeywords = new[]
140+
{
141+
"date", "data", "fecha", "datum",
142+
"amount", "importo", "monto", "betrag",
143+
"description", "descrizione", "descripcion", "beschreibung",
144+
"balance", "saldo", "name", "nome", "currency", "valuta"
145+
};
146+
147+
for (var row = 1; row <= maxRowsToCheck; row++)
148+
{
149+
var score = 0;
150+
var hasContent = false;
151+
var nonEmptyCells = 0;
152+
153+
for (var col = 1; col <= worksheet.Dimension.End.Column; col++)
154+
{
155+
var cellText = worksheet.Cells[row, col].Text?.Trim() ?? string.Empty;
156+
157+
if (!string.IsNullOrWhiteSpace(cellText))
158+
{
159+
hasContent = true;
160+
nonEmptyCells++;
161+
162+
var lowerText = cellText.ToLowerInvariant();
163+
164+
foreach (var keyword in headerKeywords)
165+
{
166+
if (lowerText.Contains(keyword))
167+
{
168+
score += 10;
169+
break;
170+
}
171+
}
172+
173+
if (cellText.Length > 3 && cellText.Length < 50 &&
174+
!decimal.TryParse(cellText.Replace(",", "."), out _) &&
175+
!DateTime.TryParse(cellText, out _))
176+
{
177+
score += 2;
178+
}
179+
}
180+
}
181+
182+
if (hasContent && nonEmptyCells >= worksheet.Dimension.End.Column / 2)
183+
{
184+
score += nonEmptyCells;
185+
}
186+
187+
if (score > bestScore)
188+
{
189+
bestScore = score;
190+
bestRow = row;
116191
}
117-
rows.Add(rowData);
118192
}
119193

120-
return await Task.FromResult(rows);
194+
return bestRow;
121195
}
122196

123197
private static string DetectDelimiter(string sampleText)

PhantomDave.BankTracking.Api/Types/Mutations/ImportMutations.cs

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -22,7 +22,7 @@ public async Task<ImportPreviewType> PreviewImport(
2222
{
2323
DetectedColumns = detectionResults,
2424
Headers = parsedFile.Headers,
25-
SampleRows = parsedFile.Rows.Take(5).ToList(),
25+
SampleRows = [.. parsedFile.Rows.Take(5)],
2626
TotalRows = parsedFile.Rows.Count
2727
});
2828
}
18.3 KB
Binary file not shown.

frontend/package-lock.json

Lines changed: 48 additions & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

frontend/package.json

Lines changed: 9 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -40,20 +40,21 @@
4040
"@angular/router": "^20.3.10",
4141
"@apollo/client": "^4.0.9",
4242
"apollo-angular": "^12.1.0",
43+
"apollo-upload-client": "^19.0.0",
4344
"eslint": "^9.39.1",
4445
"graphql": "^16.12.0",
4546
"prettier": "^3.6.2",
4647
"rxjs": "~7.8.2",
4748
"tslib": "^2.8.1"
4849
},
4950
"devDependencies": {
50-
"@angular/build": "^20.3.9",
51-
"@angular/cli": "^20.3.9",
52-
"@angular/compiler-cli": "^20.3.10",
5351
"@angular-eslint/builder": "^20.6.0",
5452
"@angular-eslint/eslint-plugin": "^20.6.0",
5553
"@angular-eslint/eslint-plugin-template": "^20.6.0",
5654
"@angular-eslint/template-parser": "^20.6.0",
55+
"@angular/build": "^20.3.9",
56+
"@angular/cli": "^20.3.9",
57+
"@angular/compiler-cli": "^20.3.10",
5758
"@eslint/eslintrc": "^3.1.0",
5859
"@eslint/js": "^9.11.1",
5960
"@graphql-codegen/cli": "^6.0.1",
@@ -64,16 +65,16 @@
6465
"@graphql-codegen/typescript-operations": "^5.0.2",
6566
"@parcel/watcher": "^2.5.1",
6667
"@types/jasmine": "~5.1.12",
68+
"@typescript-eslint/eslint-plugin": "^8.46.4",
69+
"@typescript-eslint/parser": "^8.46.4",
70+
"eslint-config-prettier": "^10.1.8",
71+
"eslint-plugin-prettier": "^5.5.4",
6772
"jasmine-core": "~5.12.1",
6873
"karma": "~6.4.4",
6974
"karma-chrome-launcher": "~3.2.0",
7075
"karma-coverage": "~2.2.1",
7176
"karma-jasmine": "~5.1.0",
7277
"karma-jasmine-html-reporter": "~2.1.0",
73-
"typescript": "~5.9.3",
74-
"@typescript-eslint/parser": "^8.46.4",
75-
"@typescript-eslint/eslint-plugin": "^8.46.4",
76-
"eslint-config-prettier": "^10.1.8",
77-
"eslint-plugin-prettier": "^5.5.4"
78+
"typescript": "~5.9.3"
7879
}
7980
}

frontend/src/app/app.config.ts

Lines changed: 16 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -1,18 +1,17 @@
11
import { provideApollo } from 'apollo-angular';
2-
import { HttpLink } from 'apollo-angular/http';
32

4-
import { HttpHeaders, provideHttpClient, withInterceptors } from '@angular/common/http';
3+
import { provideHttpClient, withInterceptors } from '@angular/common/http';
54
import {
65
ApplicationConfig,
76
importProvidersFrom,
8-
inject,
97
provideBrowserGlobalErrorListeners,
108
provideZonelessChangeDetection,
119
} from '@angular/core';
1210
import { MatSnackBarModule } from '@angular/material/snack-bar';
1311
import { provideNativeDateAdapter } from '@angular/material/core';
1412
import { provideRouter } from '@angular/router';
1513
import { ApolloLink, InMemoryCache } from '@apollo/client';
14+
import UploadHttpLink from 'apollo-upload-client/UploadHttpLink.mjs';
1615
import { SetContextLink } from '@apollo/client/link/context';
1716

1817
import { routes } from './app.routes';
@@ -28,29 +27,36 @@ export const appConfig: ApplicationConfig = {
2827
provideRouter(routes),
2928
provideHttpClient(withInterceptors([unauthorizedInterceptor])),
3029
provideApollo(() => {
31-
const httpLink = inject(HttpLink);
32-
33-
const authLink = new SetContextLink((prevContext, _operation) => {
34-
const prev = prevContext?.headers;
35-
let headers = prev instanceof HttpHeaders ? prev : new HttpHeaders(prev ?? {});
30+
const authLink = new SetContextLink((prevContext: { [key: string]: unknown }, _operation) => {
31+
const prevHeaders = (prevContext?.['headers'] as Record<string, string> | undefined) ?? {};
32+
const headers: Record<string, string> = { ...prevHeaders };
3633

3734
const sessionRaw = localStorage.getItem('sessionData');
3835
if (sessionRaw) {
3936
try {
4037
const session = JSON.parse(sessionRaw) as { token?: string };
4138
if (session?.token) {
42-
headers = headers.set('Authorization', `Bearer ${session.token}`);
39+
headers['Authorization'] = `Bearer ${session.token}`;
4340
}
4441
} catch {
4542
localStorage.removeItem('sessionData');
4643
}
4744
}
45+
// HotChocolate requires this header on multipart requests (HC0077)
46+
// See: https://chillicream.com/docs/hotchocolate/v15/server/files#client-usage
47+
headers['GraphQL-Preflight'] = '1';
48+
49+
// Keep Apollo preflight to force a CORS preflight in browsers (harmless on non-multipart)
50+
headers['Apollo-Require-Preflight'] = 'true';
4851

4952
return { headers };
5053
});
5154

55+
// Use UploadHttpLink to support GraphQL multipart requests for file uploads
56+
const uploadLink = new UploadHttpLink({ uri: environment.graphqlUri });
57+
5258
return {
53-
link: ApolloLink.from([authLink, httpLink.create({ uri: environment.graphqlUri })]),
59+
link: ApolloLink.from([authLink, uploadLink]),
5460
cache: new InMemoryCache(),
5561
};
5662
}),

frontend/src/app/app.routes.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@ import { RegisterComponent } from './components/welcome-layout/register-componen
77
import { authenticateGuard } from './guards/authenticate-guard';
88
import { BalanceComponent } from './balance/balance-component/balance-component';
99
import { SettingsComponent } from './components/settings-component/settings-component';
10+
import { ImportWizardComponent } from './components/import/import-wizard-component/import-wizard-component';
1011

1112
export const routes: Routes = [
1213
{ path: '', redirectTo: 'login', pathMatch: 'full' },
@@ -16,5 +17,6 @@ export const routes: Routes = [
1617
{ path: 'config', component: MonthlyRecapComponent, canActivate: [authenticateGuard] },
1718
{ path: 'balance', component: BalanceComponent, canActivate: [authenticateGuard] },
1819
{ path: 'settings', component: SettingsComponent, canActivate: [authenticateGuard] },
20+
{ path: 'import', component: ImportWizardComponent, canActivate: [authenticateGuard] },
1921
{ path: '**', redirectTo: 'login' },
2022
];

0 commit comments

Comments
 (0)