diff --git a/.gemini/settings.json b/.gemini/settings.json
new file mode 100644
index 00000000..d4f1f4ca
--- /dev/null
+++ b/.gemini/settings.json
@@ -0,0 +1,13 @@
+{
+ "mcpServers": {
+ "context7": {
+ "command": "npx",
+ "args": [
+ "-y",
+ "@upstash/context7-mcp",
+ "--api-key",
+ "ctx7sk-200afd5d-1a75-4c28-a3fb-3f3a26a721a0"
+ ]
+ }
+ }
+}
\ No newline at end of file
diff --git a/brewman/brewman/main.py b/brewman/brewman/main.py
index 9ceaef89..1e5d1ab3 100644
--- a/brewman/brewman/main.py
+++ b/brewman/brewman/main.py
@@ -50,6 +50,7 @@ from .routers import (
tag,
temporal_product,
title,
+ transaction_import,
user,
voucher,
voucher_types,
@@ -163,6 +164,7 @@ app.include_router(employee_benefit.router, prefix="/api/employee-benefit", tags
app.include_router(incentive.router, prefix="/api/incentive", tags=["vouchers"])
app.include_router(credit_salary.router, prefix="/api/credit-salary", tags=["vouchers"])
app.include_router(voucher.router, prefix="/api", tags=["vouchers"])
+app.include_router(transaction_import.router, prefix="/api/transaction-import", tags=["vouchers"])
app.include_router(lock_information.router, prefix="/api/lock-information", tags=["settings"])
app.include_router(maintenance.router, prefix="/api/maintenance", tags=["settings"])
diff --git a/brewman/brewman/routers/transaction_import.py b/brewman/brewman/routers/transaction_import.py
new file mode 100644
index 00000000..73c497b0
--- /dev/null
+++ b/brewman/brewman/routers/transaction_import.py
@@ -0,0 +1,273 @@
+import io
+import logging
+import uuid
+
+from datetime import date, datetime
+from typing import Annotated, Any
+
+import pandas as pd
+
+from fastapi import APIRouter, File, Form, HTTPException, Security, UploadFile
+from pydantic import BaseModel
+from sqlalchemy import select
+from sqlalchemy.orm import Session
+
+from ..core.security import get_current_active_user as get_user
+from ..db.session import SessionDep
+from ..models.account_base import AccountBase
+from ..models.journal import Journal
+from ..models.voucher import Voucher
+from ..models.voucher_type import VoucherType
+from ..schemas.user import UserToken
+
+
+router = APIRouter()
+logger = logging.getLogger(__name__)
+
+
+class ColumnMapping(BaseModel):
+ sheet_name: str
+ date_col: str
+ account_col: str | None = None
+ narration_col: str | None = None
+ amount_col: str | None = None
+ debit_col: str | None = None
+ credit_col: str | None = None
+ default_account_id: uuid.UUID | None = None
+
+
+class PreviewRow(BaseModel):
+ index: int
+ date_: date | None
+ account_id: uuid.UUID | None
+ account_name: str | None
+ narration: str
+ debit: float
+ credit: float
+ is_valid: bool
+ validation_error: str | None = None
+
+
+class ImportExecutionRequest(BaseModel):
+ primary_account_id: uuid.UUID
+ rows: list[PreviewRow]
+
+
+@router.post("/parse-headers")
+async def parse_headers(
+ user: Annotated[UserToken, Security(get_user, scopes=["journal"])],
+ file: UploadFile = File(...),
+):
+ try:
+ content = await file.read()
+ excel = pd.ExcelFile(io.BytesIO(content))
+ sheets = excel.sheet_names
+ headers = {}
+ for sheet in sheets:
+ df = pd.read_excel(io.BytesIO(content), sheet_name=sheet, nrows=0)
+ headers[sheet] = [str(col) if pd.notna(col) else f"Unnamed_{i}" for i, col in enumerate(df.columns)]
+ return {"sheets": sheets, "headers": headers}
+ except Exception as e:
+ logger.error(f"Error parsing headers: {e}")
+ raise HTTPException(status_code=400, detail="Invalid Excel file format.")
+
+
+def _parse_date(val: Any) -> date | None:
+ if pd.isna(val):
+ return None
+ if isinstance(val, datetime):
+ return val.date()
+ if isinstance(val, date):
+ return val
+ try:
+ return pd.to_datetime(val, dayfirst=True).date()
+ except Exception:
+ return None
+
+
+def _find_account(val: str, db: Session) -> uuid.UUID | None:
+ if not val or pd.isna(val):
+ return None
+ val_str = str(val).strip()
+
+ # Try exact UUID
+ try:
+ uid = uuid.UUID(val_str)
+ account = db.execute(select(AccountBase).where(AccountBase.id == uid)).scalar_one_or_none()
+ if account:
+ return account.id
+ except ValueError:
+ pass
+
+ # Try name match
+ account = db.execute(select(AccountBase).where(AccountBase.name.ilike(val_str))).scalar_one_or_none()
+ if account:
+ return account.id
+
+ return None
+
+
+@router.post("/preview")
+async def preview_import(
+ user: Annotated[UserToken, Security(get_user, scopes=["journal"])],
+ db: SessionDep,
+ mapping_str: str = Form(...),
+ file: UploadFile = File(...),
+):
+ try:
+ mapping = ColumnMapping.model_validate_json(mapping_str)
+ except Exception as e:
+ raise HTTPException(status_code=400, detail=f"Invalid mapping configuration: {e}")
+
+ try:
+ content = await file.read()
+ df = pd.read_excel(io.BytesIO(content), sheet_name=mapping.sheet_name)
+ except Exception:
+ raise HTTPException(status_code=400, detail="Could not read sheet data.")
+
+ preview_rows = []
+
+ for idx, row in df.iterrows():
+ is_valid = True
+ validation_error = None
+
+ # 1. Date
+ row_date = _parse_date(row.get(mapping.date_col)) if mapping.date_col else None
+ if not row_date:
+ is_valid = False
+ validation_error = "Invalid or missing date."
+
+ # 2. Account
+ account_id = None
+ account_name = None
+ if mapping.account_col and mapping.account_col in row:
+ raw_acc = row.get(mapping.account_col)
+ if not pd.isna(raw_acc):
+ account_name = str(raw_acc)
+ account_id = _find_account(raw_acc, db)
+
+ if not account_id and mapping.default_account_id:
+ account_id = mapping.default_account_id
+
+ if not account_id and is_valid:
+ is_valid = False
+ validation_error = f"Account '{account_name}' not found." if account_name else "Account missing."
+
+ # 3. Narration
+ narration = ""
+ if mapping.narration_col and mapping.narration_col in row:
+ val = row.get(mapping.narration_col)
+ if not pd.isna(val):
+ narration = str(val)
+
+ # 4. Amount / Debit / Credit
+ debit = 0.0
+ credit = 0.0
+
+ if mapping.debit_col and mapping.credit_col:
+ d_val = row.get(mapping.debit_col, 0)
+ c_val = row.get(mapping.credit_col, 0)
+ debit = float(d_val) if not pd.isna(d_val) else 0.0
+ credit = float(c_val) if not pd.isna(c_val) else 0.0
+ elif mapping.amount_col:
+ amt = row.get(mapping.amount_col, 0)
+ amt = float(amt) if not pd.isna(amt) else 0.0
+ if amt > 0:
+ debit = amt
+ elif amt < 0:
+ credit = abs(amt)
+
+ if debit == 0 and credit == 0 and is_valid:
+ is_valid = False
+ validation_error = "Transaction amount is zero."
+
+ preview_rows.append(
+ PreviewRow(
+ index=idx,
+ date_=row_date,
+ account_id=account_id,
+ account_name=account_name,
+ narration=narration,
+ debit=debit,
+ credit=credit,
+ is_valid=is_valid,
+ validation_error=validation_error,
+ )
+ )
+
+ return {"rows": preview_rows}
+
+
+@router.post("/execute")
+async def execute_import(
+ request: ImportExecutionRequest,
+ user: Annotated[UserToken, Security(get_user, scopes=["journal"])],
+ db: SessionDep,
+):
+ primary_acc = db.execute(
+ select(AccountBase).where(AccountBase.id == request.primary_account_id)
+ ).scalar_one_or_none()
+ if not primary_acc:
+ raise HTTPException(status_code=400, detail="Primary account not found.")
+
+ created_vouchers = []
+
+ for row in request.rows:
+ if not row.is_valid or not row.date_ or not row.account_id:
+ continue
+
+ other_acc = db.execute(select(AccountBase).where(AccountBase.id == row.account_id)).scalar_one_or_none()
+ if not other_acc:
+ continue
+
+ voucher = Voucher(
+ date_=row.date_,
+ narration=row.narration,
+ is_starred=False,
+ user_id=user.id_,
+ voucher_type=VoucherType.JOURNAL,
+ )
+ db.add(voucher)
+
+ # Determine signs for journals
+ if row.debit > 0:
+ amount = row.debit
+ # Primary gets Debit (1)
+ j_primary = Journal(
+ amount=amount,
+ debit=1,
+ account_id=primary_acc.id,
+ cost_centre_id=primary_acc.cost_centre_id,
+ )
+ # Other gets Credit (-1)
+ j_other = Journal(
+ amount=amount,
+ debit=-1,
+ account_id=other_acc.id,
+ cost_centre_id=other_acc.cost_centre_id,
+ )
+ else:
+ amount = row.credit
+ # Primary gets Credit (-1)
+ j_primary = Journal(
+ amount=amount,
+ debit=-1,
+ account_id=primary_acc.id,
+ cost_centre_id=primary_acc.cost_centre_id,
+ )
+ # Other gets Debit (1)
+ j_other = Journal(
+ amount=amount,
+ debit=1,
+ account_id=other_acc.id,
+ cost_centre_id=other_acc.cost_centre_id,
+ )
+
+ voucher.journals.append(j_primary)
+ voucher.journals.append(j_other)
+ db.add(j_primary)
+ db.add(j_other)
+ created_vouchers.append(voucher)
+
+ db.commit()
+ return {"message": f"Successfully imported {len(created_vouchers)} entries."}
diff --git a/overlord/package.json b/overlord/package.json
index fb49e09f..d211ec16 100644
--- a/overlord/package.json
+++ b/overlord/package.json
@@ -25,6 +25,8 @@
"@angular/platform-browser": "^21.1.5",
"@angular/platform-browser-dynamic": "^21.1.5",
"@angular/router": "^21.1.5",
+ "html2canvas": "^1.4.1",
+ "jspdf": "^4.2.0",
"mathjs": "^15.1.1",
"moment": "^2.30.1",
"rxjs": "~7.8.0",
diff --git a/overlord/src/app/app.routes.ts b/overlord/src/app/app.routes.ts
index a5f029c9..9a5beb12 100644
--- a/overlord/src/app/app.routes.ts
+++ b/overlord/src/app/app.routes.ts
@@ -177,6 +177,10 @@ export const routes: Routes = [
path: 'entries',
loadChildren: () => import('./entries/entries.routes').then((mod) => mod.routes),
},
+ {
+ path: 'transaction-import',
+ loadChildren: () => import('./transaction-import/transaction-import.routes').then((mod) => mod.routes),
+ },
{
path: 'users',
loadChildren: () => import('./user/user.routes').then((mod) => mod.routes),
diff --git a/overlord/src/app/core/nav-bar/nav-bar.component.html b/overlord/src/app/core/nav-bar/nav-bar.component.html
index d27f7b32..5b94db64 100644
--- a/overlord/src/app/core/nav-bar/nav-bar.component.html
+++ b/overlord/src/app/core/nav-bar/nav-bar.component.html
@@ -9,6 +9,7 @@
Payment - F5
Receipt - F6
Issue
+ Transaction Import
diff --git a/overlord/src/app/transaction-import/transaction-import.component.css b/overlord/src/app/transaction-import/transaction-import.component.css
new file mode 100644
index 00000000..e60c9ce7
--- /dev/null
+++ b/overlord/src/app/transaction-import/transaction-import.component.css
@@ -0,0 +1,75 @@
+.container {
+ padding: 24px;
+}
+
+.step-container {
+ padding: 16px 0;
+}
+
+h3 {
+ margin-top: 16px;
+ margin-bottom: 8px;
+ color: #333;
+}
+
+.full-width {
+ width: 100%;
+ max-width: 400px;
+}
+
+.file-upload-zone {
+ border: 2px dashed #ccc;
+ padding: 32px;
+ text-align: center;
+ border-radius: 8px;
+ margin-bottom: 16px;
+ max-width: 400px;
+}
+
+.mapping-grid {
+ display: flex;
+ flex-wrap: wrap;
+ gap: 16px;
+ align-items: center;
+}
+
+.or-separator {
+ font-weight: bold;
+ color: #888;
+ padding: 0 16px;
+}
+
+.actions {
+ margin-top: 24px;
+ display: flex;
+ gap: 16px;
+}
+
+.summary-bar {
+ display: flex;
+ align-items: center;
+ gap: 24px;
+ padding: 16px;
+ background-color: #f5f5f5;
+ border-radius: 8px;
+ margin-bottom: 16px;
+}
+
+.summary-item {
+ font-weight: bold;
+ font-size: 1.1em;
+}
+
+table {
+ width: 100%;
+}
+
+.invalid-row td {
+ color: #999;
+ text-decoration: line-through;
+}
+
+.error-text {
+ color: #f44336;
+ font-weight: 500;
+}
diff --git a/overlord/src/app/transaction-import/transaction-import.component.html b/overlord/src/app/transaction-import/transaction-import.component.html
new file mode 100644
index 00000000..6667eb2d
--- /dev/null
+++ b/overlord/src/app/transaction-import/transaction-import.component.html
@@ -0,0 +1,275 @@
+
+
+
+
+
+
Select Primary Account
+
+ Destination Account
+
+
+ @for (acc of filteredAccounts(); track acc.id) {
+
+ {{ acc.name }}
+
+ }
+
+
+
+
Upload Excel or CSV Statement
+
+
+
+
+
+
+
+
+
+
+
+
+
+
Select Sheet
+
+ Sheet Name
+
+ @for (sheet of availableSheets(); track sheet) {
+ {{ sheet }}
+ }
+
+
+
+
Column Mappings
+
+
+ Date Column
+ ({ ...m, date_col: $event }))"
+ required
+ >
+ @for (h of currentHeaders(); track h) {
+ {{ h }}
+ }
+
+
+
+
+ Other Account Column
+ ({ ...m, account_col: $event }))"
+ >
+ -- None --
+ @for (h of currentHeaders(); track h) {
+ {{ h }}
+ }
+
+
+
+
+ Narration / Description
+ ({ ...m, narration_col: $event }))"
+ >
+ -- None --
+ @for (h of currentHeaders(); track h) {
+ {{ h }}
+ }
+
+
+
+
+
Amount Mapping
+
+
+ Amount Column (Single)
+ ({ ...m, amount_col: $event, debit_col: null, credit_col: null }))"
+ >
+ -- None --
+ @for (h of currentHeaders(); track h) {
+ {{ h }}
+ }
+
+ Use this if amount is one column (+/-)
+
+
OR
+
+ Debit Column
+ ({ ...m, debit_col: $event, amount_col: null }))"
+ >
+ -- None --
+ @for (h of currentHeaders(); track h) {
+ {{ h }}
+ }
+
+
+
+ Credit Column
+ ({ ...m, credit_col: $event, amount_col: null }))"
+ >
+ -- None --
+ @for (h of currentHeaders(); track h) {
+ {{ h }}
+ }
+
+
+
+
+
Fallback Account
+
+ Default Account (if unmapped)
+ ({ ...m, default_account_id: $event }))"
+ >
+ -- None --
+ @for (acc of accounts(); track acc.id) {
+ {{ acc.name }}
+ }
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
Selected: {{ totalSelected() }}
+
Total Debit: {{ totalDebit() | number: '1.2-2' }}
+
Total Credit: {{ totalCredit() | number: '1.2-2' }}
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/overlord/src/app/transaction-import/transaction-import.component.ts b/overlord/src/app/transaction-import/transaction-import.component.ts
new file mode 100644
index 00000000..e5c60d5f
--- /dev/null
+++ b/overlord/src/app/transaction-import/transaction-import.component.ts
@@ -0,0 +1,273 @@
+import { SelectionModel } from '@angular/cdk/collections';
+import { CommonModule } from '@angular/common';
+import { ChangeDetectionStrategy, Component, computed, inject, signal } from '@angular/core';
+import { FormsModule } from '@angular/forms';
+import { MatAutocompleteModule } from '@angular/material/autocomplete';
+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 { MatSnackBar, MatSnackBarModule } from '@angular/material/snack-bar';
+import { MatStepperModule } from '@angular/material/stepper';
+import { MatTableModule } from '@angular/material/table';
+import { Title } from '@angular/platform-browser';
+import { Router } from '@angular/router';
+
+import { Account } from '../core/account';
+import { AccountService } from '../core/account.service';
+import { ColumnMapping, PreviewRow, TransactionImportService } from './transaction-import.service';
+
+@Component({
+ selector: 'app-transaction-import',
+ standalone: true,
+ changeDetection: ChangeDetectionStrategy.OnPush,
+ imports: [
+ CommonModule,
+ FormsModule,
+ MatStepperModule,
+ MatFormFieldModule,
+ MatInputModule,
+ MatSelectModule,
+ MatButtonModule,
+ MatCheckboxModule,
+ MatTableModule,
+ MatAutocompleteModule,
+ MatIconModule,
+ MatSnackBarModule,
+ ],
+ templateUrl: './transaction-import.component.html',
+ styleUrls: ['./transaction-import.component.css'],
+})
+export class TransactionImportComponent {
+ private titleService = inject(Title);
+ private accountService = inject(AccountService);
+ private importService = inject(TransactionImportService);
+ private snackBar = inject(MatSnackBar);
+ private router = inject(Router);
+
+ // Step 1
+ accounts = signal([]);
+ primaryAccountId = signal(null);
+ selectedFile = signal(null);
+
+ // Step 2
+ availableSheets = signal([]);
+ availableHeaders = signal>({});
+ selectedSheet = signal('');
+
+ mapping = signal({
+ sheet_name: '',
+ date_col: '',
+ account_col: null,
+ narration_col: null,
+ amount_col: null,
+ debit_col: null,
+ credit_col: null,
+ default_account_id: null,
+ });
+
+ // Step 3
+ previewRows = signal([]);
+ selection = new SelectionModel(true, []);
+ selectedRows = signal([]);
+ displayedColumns: string[] = ['select', 'date', 'account', 'narration', 'debit', 'credit', 'status'];
+
+ // Computed state
+ currentHeaders = computed(() => {
+ return this.availableHeaders()[this.selectedSheet()] || [];
+ });
+
+ totalSelected = computed(() => this.selectedRows().length);
+ totalDebit = computed(() => this.selectedRows().reduce((sum, row) => sum + row.debit, 0));
+ totalCredit = computed(() => this.selectedRows().reduce((sum, row) => sum + row.credit, 0));
+
+ constructor() {
+ this.titleService.setTitle('Import Transactions');
+ this.loadAccounts();
+ this.selection.changed.subscribe(() => {
+ this.selectedRows.set(this.selection.selected);
+ });
+ }
+
+ loadAccounts() {
+ this.accountService.list().subscribe((accounts) => {
+ this.accounts.set(accounts);
+ });
+ }
+
+ filteredAccounts = signal([]);
+ primaryAccountName = signal('');
+
+ onPrimaryAccountSearch(query: string) {
+ if (this.primaryAccountName() === query && this.primaryAccountId() !== null) {
+ return;
+ }
+ this.primaryAccountName.set(query);
+ this.primaryAccountId.set(null);
+ if (query && query.length >= 2) {
+ this.accountService.autocomplete(query).subscribe((res) => this.filteredAccounts.set(res));
+ } else {
+ this.filteredAccounts.set([]);
+ }
+ }
+
+ onPrimaryAccountSelected(acc: Account) {
+ this.primaryAccountId.set(acc.id || null);
+ this.primaryAccountName.set(acc.name);
+ }
+
+ onRowAccountSearch(row: PreviewRow, query: string) {
+ if (row.account_name === query && row.account_id !== null) {
+ return;
+ }
+ row.account_name = query;
+ row.account_id = null;
+ row.is_valid = false;
+ // Notify angular about row update for template binding
+ this.previewRows.set([...this.previewRows()]);
+ if (query && query.length >= 2) {
+ this.accountService.autocomplete(query).subscribe((res) => this.filteredAccounts.set(res));
+ } else {
+ this.filteredAccounts.set([]);
+ }
+ }
+
+ onRowAccountSelected(row: PreviewRow, acc: Account) {
+ row.account_id = acc.id || null;
+ row.account_name = acc.name;
+ row.is_valid = true;
+ row.validation_error = null;
+ this.previewRows.set([...this.previewRows()]);
+ }
+
+ onFileSelected(event: Event) {
+ const file = (event.target as HTMLInputElement).files?.[0];
+ if (file) {
+ this.selectedFile.set(file);
+ }
+ }
+
+ onUploadAndParseHeaders() {
+ const file = this.selectedFile();
+ if (!file) return;
+
+ this.importService.parseHeaders(file).subscribe({
+ next: (res) => {
+ this.availableSheets.set(res.sheets);
+ this.availableHeaders.set(res.headers);
+ if (res.sheets.length > 0) {
+ this.selectedSheet.set(res.sheets[0]);
+ this.autoMapColumns(res.sheets[0]);
+ }
+ },
+ error: (err) => {
+ this.snackBar.open('Error parsing file headers: ' + err.message, 'Close', { duration: 3000 });
+ },
+ });
+ }
+
+ onSheetChange() {
+ this.autoMapColumns(this.selectedSheet());
+ }
+
+ autoMapColumns(sheet: string) {
+ const headers = this.availableHeaders()[sheet] || [];
+ const hLower = headers.map((h) => h.toLowerCase());
+
+ const mapping: ColumnMapping = {
+ sheet_name: sheet,
+ date_col: '',
+ account_col: null,
+ narration_col: null,
+ amount_col: null,
+ debit_col: null,
+ credit_col: null,
+ default_account_id: null,
+ };
+
+ // Auto-detect Date
+ let idx = hLower.findIndex((h) => h.includes('date'));
+ if (idx !== -1) mapping.date_col = headers[idx];
+
+ // Auto-detect Account
+ idx = hLower.findIndex((h) => h.includes('account'));
+ if (idx !== -1) mapping.account_col = headers[idx];
+
+ // Auto-detect Narration
+ idx = hLower.findIndex((h) => h.includes('narration') || h.includes('description') || h.includes('particulars'));
+ if (idx !== -1) mapping.narration_col = headers[idx];
+
+ // Auto-detect Amount / Debit / Credit
+ const dIdx = hLower.findIndex((h) => h === 'debit' || h === 'dr');
+ const cIdx = hLower.findIndex((h) => h === 'credit' || h === 'cr');
+ if (dIdx !== -1 && cIdx !== -1) {
+ mapping.debit_col = headers[dIdx];
+ mapping.credit_col = headers[cIdx];
+ } else {
+ idx = hLower.findIndex((h) => h.includes('amount'));
+ if (idx !== -1) mapping.amount_col = headers[idx];
+ }
+
+ this.mapping.set(mapping);
+ }
+
+ onGeneratePreview() {
+ const file = this.selectedFile();
+ if (!file) return;
+
+ this.mapping.update((m) => ({ ...m, sheet_name: this.selectedSheet() }));
+
+ this.importService.preview(file, this.mapping()).subscribe({
+ next: (res) => {
+ this.previewRows.set(res.rows);
+ this.selection.clear();
+ // Select all valid by default
+ const validRows = res.rows.filter((r) => r.is_valid);
+ this.selection.select(...validRows);
+ },
+ error: (err) => {
+ this.snackBar.open('Error generating preview: ' + err.message, 'Close', { duration: 3000 });
+ },
+ });
+ }
+
+ isAllSelected() {
+ const numSelected = this.selection.selected.length;
+ const numValid = this.previewRows().filter((r) => r.is_valid).length;
+ return numSelected === numValid;
+ }
+
+ masterToggle() {
+ if (this.isAllSelected()) {
+ this.selection.clear();
+ return;
+ }
+ const validRows = this.previewRows().filter((r) => r.is_valid);
+ this.selection.select(...validRows);
+ }
+
+ onExecuteImport() {
+ const selectedRows = this.selection.selected;
+ if (selectedRows.length === 0) return;
+
+ const accountId = this.primaryAccountId();
+ if (!accountId) return;
+
+ const req = {
+ primary_account_id: accountId,
+ rows: selectedRows,
+ };
+
+ this.importService.execute(req).subscribe({
+ next: (res) => {
+ this.snackBar.open(res.message, 'Close', { duration: 3000 });
+ this.router.navigate(['/daybook']);
+ },
+ error: (err) => {
+ this.snackBar.open('Error importing transactions: ' + err.message, 'Close', { duration: 3000 });
+ },
+ });
+ }
+}
diff --git a/overlord/src/app/transaction-import/transaction-import.routes.ts b/overlord/src/app/transaction-import/transaction-import.routes.ts
new file mode 100644
index 00000000..8a72ca7a
--- /dev/null
+++ b/overlord/src/app/transaction-import/transaction-import.routes.ts
@@ -0,0 +1,11 @@
+import { Routes } from '@angular/router';
+
+import { TransactionImportComponent } from './transaction-import.component';
+
+export const routes: Routes = [
+ {
+ path: '',
+ component: TransactionImportComponent,
+ title: 'Import Transactions',
+ },
+];
diff --git a/overlord/src/app/transaction-import/transaction-import.service.ts b/overlord/src/app/transaction-import/transaction-import.service.ts
new file mode 100644
index 00000000..e5b0b1d3
--- /dev/null
+++ b/overlord/src/app/transaction-import/transaction-import.service.ts
@@ -0,0 +1,72 @@
+import { HttpClient } from '@angular/common/http';
+import { inject, Injectable } from '@angular/core';
+import { Observable } from 'rxjs';
+import { catchError } from 'rxjs/operators';
+
+import { ErrorLoggerService } from '../core/error-logger.service';
+
+const url = '/api/transaction-import';
+
+export interface ColumnMapping {
+ sheet_name: string;
+ date_col: string;
+ account_col?: string | null;
+ narration_col?: string | null;
+ amount_col?: string | null;
+ debit_col?: string | null;
+ credit_col?: string | null;
+ default_account_id?: string | null;
+}
+
+export interface PreviewRow {
+ index: number;
+ date_: string | null;
+ account_id: string | null;
+ account_name: string | null;
+ narration: string;
+ debit: number;
+ credit: number;
+ is_valid: boolean;
+ validation_error?: string | null;
+}
+
+export interface ImportExecutionRequest {
+ primary_account_id: string;
+ rows: PreviewRow[];
+}
+
+@Injectable({ providedIn: 'root' })
+export class TransactionImportService {
+ private http = inject(HttpClient);
+ private log = inject(ErrorLoggerService);
+
+ parseHeaders(file: File): Observable<{ sheets: string[]; headers: Record }> {
+ const formData = new FormData();
+ formData.append('file', file);
+ return this.http
+ .post<{ sheets: string[]; headers: Record }>(`${url}/parse-headers`, formData)
+ .pipe(catchError(this.log.handleError('TransactionImportService', 'parseHeaders'))) as Observable<{
+ sheets: string[];
+ headers: Record;
+ }>;
+ }
+
+ preview(file: File, mapping: ColumnMapping): Observable<{ rows: PreviewRow[] }> {
+ const formData = new FormData();
+ formData.append('file', file);
+ formData.append('mapping_str', JSON.stringify(mapping));
+ return this.http
+ .post<{ rows: PreviewRow[] }>(`${url}/preview`, formData)
+ .pipe(catchError(this.log.handleError('TransactionImportService', 'preview'))) as Observable<{
+ rows: PreviewRow[];
+ }>;
+ }
+
+ execute(request: ImportExecutionRequest): Observable<{ message: string }> {
+ return this.http
+ .post<{ message: string }>(`${url}/execute`, request)
+ .pipe(catchError(this.log.handleError('TransactionImportService', 'execute'))) as Observable<{
+ message: string;
+ }>;
+ }
+}