Created a transaction import voucher. I will use it to import my credit card and bank statements.

This commit is contained in:
2026-07-20 09:21:46 +00:00
parent 7ad4f8c312
commit 81619f8569
11 changed files with 1001 additions and 0 deletions

13
.gemini/settings.json Normal file
View File

@ -0,0 +1,13 @@
{
"mcpServers": {
"context7": {
"command": "npx",
"args": [
"-y",
"@upstash/context7-mcp",
"--api-key",
"ctx7sk-200afd5d-1a75-4c28-a3fb-3f3a26a721a0"
]
}
}
}

View File

@ -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"])

View File

@ -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."}

View File

@ -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",

View File

@ -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),

View File

@ -9,6 +9,7 @@
<a mat-menu-item routerLink="/payment">Payment - F5</a>
<a mat-menu-item routerLink="/receipt">Receipt - F6</a>
<a mat-menu-item routerLink="/issue">Issue</a>
<a mat-menu-item routerLink="/transaction-import">Transaction Import</a>
</mat-menu>
<button mat-button [matMenuTriggerFor]="voucherMenu">Voucher Entry</button>

View File

@ -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;
}

View File

@ -0,0 +1,275 @@
<div class="container">
<mat-stepper linear #stepper>
<!-- Step 1: Upload & Account -->
<mat-step label="Upload & Account Selection">
<div class="step-container">
<h3>Select Primary Account</h3>
<mat-form-field appearance="outline" class="full-width">
<mat-label>Destination Account</mat-label>
<input
type="text"
matInput
[matAutocomplete]="autoPrimary"
[ngModel]="primaryAccountName()"
(ngModelChange)="onPrimaryAccountSearch($event)"
required
/>
<mat-autocomplete #autoPrimary="matAutocomplete">
@for (acc of filteredAccounts(); track acc.id) {
<mat-option [value]="acc.name" (onSelectionChange)="$event.isUserInput && onPrimaryAccountSelected(acc)">
{{ acc.name }}
</mat-option>
}
</mat-autocomplete>
</mat-form-field>
<h3>Upload Excel or CSV Statement</h3>
<div class="file-upload-zone">
<input type="file" (change)="onFileSelected($event)" accept=".xlsx, .xls, .csv" />
</div>
<div class="actions">
<button
mat-flat-button
color="primary"
[disabled]="!primaryAccountId() || !selectedFile()"
(click)="onUploadAndParseHeaders()"
matStepperNext
>
Next: Map Columns
</button>
</div>
</div>
</mat-step>
<!-- Step 2: Column Mapping -->
<mat-step label="Map Columns">
<div class="step-container">
<h3>Select Sheet</h3>
<mat-form-field appearance="outline" class="full-width">
<mat-label>Sheet Name</mat-label>
<mat-select [ngModel]="selectedSheet()" (ngModelChange)="selectedSheet.set($event); onSheetChange()" required>
@for (sheet of availableSheets(); track sheet) {
<mat-option [value]="sheet">{{ sheet }}</mat-option>
}
</mat-select>
</mat-form-field>
<h3>Column Mappings</h3>
<div class="mapping-grid">
<mat-form-field appearance="outline">
<mat-label>Date Column</mat-label>
<mat-select
[ngModel]="mapping().date_col"
(ngModelChange)="mapping.update((m) => ({ ...m, date_col: $event }))"
required
>
@for (h of currentHeaders(); track h) {
<mat-option [value]="h">{{ h }}</mat-option>
}
</mat-select>
</mat-form-field>
<mat-form-field appearance="outline">
<mat-label>Other Account Column</mat-label>
<mat-select
[ngModel]="mapping().account_col"
(ngModelChange)="mapping.update((m) => ({ ...m, account_col: $event }))"
>
<mat-option [value]="null">-- None --</mat-option>
@for (h of currentHeaders(); track h) {
<mat-option [value]="h">{{ h }}</mat-option>
}
</mat-select>
</mat-form-field>
<mat-form-field appearance="outline">
<mat-label>Narration / Description</mat-label>
<mat-select
[ngModel]="mapping().narration_col"
(ngModelChange)="mapping.update((m) => ({ ...m, narration_col: $event }))"
>
<mat-option [value]="null">-- None --</mat-option>
@for (h of currentHeaders(); track h) {
<mat-option [value]="h">{{ h }}</mat-option>
}
</mat-select>
</mat-form-field>
</div>
<h3>Amount Mapping</h3>
<div class="mapping-grid">
<mat-form-field appearance="outline">
<mat-label>Amount Column (Single)</mat-label>
<mat-select
[ngModel]="mapping().amount_col"
(ngModelChange)="mapping.update((m) => ({ ...m, amount_col: $event, debit_col: null, credit_col: null }))"
>
<mat-option [value]="null">-- None --</mat-option>
@for (h of currentHeaders(); track h) {
<mat-option [value]="h">{{ h }}</mat-option>
}
</mat-select>
<mat-hint>Use this if amount is one column (+/-)</mat-hint>
</mat-form-field>
<div class="or-separator">OR</div>
<mat-form-field appearance="outline">
<mat-label>Debit Column</mat-label>
<mat-select
[ngModel]="mapping().debit_col"
(ngModelChange)="mapping.update((m) => ({ ...m, debit_col: $event, amount_col: null }))"
>
<mat-option [value]="null">-- None --</mat-option>
@for (h of currentHeaders(); track h) {
<mat-option [value]="h">{{ h }}</mat-option>
}
</mat-select>
</mat-form-field>
<mat-form-field appearance="outline">
<mat-label>Credit Column</mat-label>
<mat-select
[ngModel]="mapping().credit_col"
(ngModelChange)="mapping.update((m) => ({ ...m, credit_col: $event, amount_col: null }))"
>
<mat-option [value]="null">-- None --</mat-option>
@for (h of currentHeaders(); track h) {
<mat-option [value]="h">{{ h }}</mat-option>
}
</mat-select>
</mat-form-field>
</div>
<h3>Fallback Account</h3>
<mat-form-field appearance="outline" class="full-width">
<mat-label>Default Account (if unmapped)</mat-label>
<mat-select
[ngModel]="mapping().default_account_id"
(ngModelChange)="mapping.update((m) => ({ ...m, default_account_id: $event }))"
>
<mat-option [value]="null">-- None --</mat-option>
@for (acc of accounts(); track acc.id) {
<mat-option [value]="acc.id">{{ acc.name }}</mat-option>
}
</mat-select>
</mat-form-field>
<div class="actions">
<button mat-button matStepperPrevious>Back</button>
<button
mat-flat-button
color="primary"
[disabled]="
!mapping().date_col || (!mapping().amount_col && (!mapping().debit_col || !mapping().credit_col))
"
(click)="onGeneratePreview()"
matStepperNext
>
Generate Preview
</button>
</div>
</div>
</mat-step>
<!-- Step 3: Preview -->
<mat-step label="Preview & Import">
<div class="step-container">
<div class="summary-bar">
<div class="summary-item">Selected: {{ totalSelected() }}</div>
<div class="summary-item">Total Debit: {{ totalDebit() | number: '1.2-2' }}</div>
<div class="summary-item">Total Credit: {{ totalCredit() | number: '1.2-2' }}</div>
<button mat-flat-button color="accent" (click)="onExecuteImport()" [disabled]="totalSelected() === 0">
Import {{ totalSelected() }} Entries
</button>
</div>
<table mat-table [dataSource]="previewRows()" class="mat-elevation-z2">
<ng-container matColumnDef="select">
<th mat-header-cell *matHeaderCellDef>
<mat-checkbox
(change)="$event ? masterToggle() : null"
[checked]="selection.hasValue() && isAllSelected()"
[indeterminate]="selection.hasValue() && !isAllSelected()"
>
</mat-checkbox>
</th>
<td mat-cell *matCellDef="let row">
<mat-checkbox
(click)="$event.stopPropagation()"
(change)="$event ? selection.toggle(row) : null"
[checked]="selection.isSelected(row)"
[disabled]="!row.is_valid"
>
</mat-checkbox>
</td>
</ng-container>
<ng-container matColumnDef="date">
<th mat-header-cell *matHeaderCellDef>Date</th>
<td mat-cell *matCellDef="let row">{{ row.date_ | date: 'mediumDate' }}</td>
</ng-container>
<ng-container matColumnDef="account">
<th mat-header-cell *matHeaderCellDef>Other Account</th>
<td mat-cell *matCellDef="let row">
<mat-form-field appearance="outline" subscriptSizing="dynamic">
<input
type="text"
matInput
[matAutocomplete]="autoRow"
[ngModel]="row.account_name"
(ngModelChange)="onRowAccountSearch(row, $event)"
/>
<mat-autocomplete #autoRow="matAutocomplete">
@for (acc of filteredAccounts(); track acc.id) {
<mat-option
[value]="acc.name"
(onSelectionChange)="$event.isUserInput && onRowAccountSelected(row, acc)"
>
{{ acc.name }}
</mat-option>
}
</mat-autocomplete>
</mat-form-field>
@if (!row.account_id) {
<div class="error-text">Not Mapped</div>
}
</td>
</ng-container>
<ng-container matColumnDef="narration">
<th mat-header-cell *matHeaderCellDef>Narration</th>
<td mat-cell *matCellDef="let row">{{ row.narration }}</td>
</ng-container>
<ng-container matColumnDef="debit">
<th mat-header-cell *matHeaderCellDef>Debit</th>
<td mat-cell *matCellDef="let row">{{ row.debit | number: '1.2-2' }}</td>
</ng-container>
<ng-container matColumnDef="credit">
<th mat-header-cell *matHeaderCellDef>Credit</th>
<td mat-cell *matCellDef="let row">{{ row.credit | number: '1.2-2' }}</td>
</ng-container>
<ng-container matColumnDef="status">
<th mat-header-cell *matHeaderCellDef>Status</th>
<td mat-cell *matCellDef="let row">
@if (row.is_valid) {
<mat-icon color="primary">check_circle</mat-icon>
} @else {
<mat-icon color="warn" [title]="row.validation_error">error</mat-icon>
}
</td>
</ng-container>
<tr mat-header-row *matHeaderRowDef="displayedColumns"></tr>
<tr mat-row *matRowDef="let row; columns: displayedColumns" [class.invalid-row]="!row.is_valid"></tr>
</table>
<div class="actions">
<button mat-button matStepperPrevious>Back</button>
</div>
</div>
</mat-step>
</mat-stepper>
</div>

View File

@ -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<Account[]>([]);
primaryAccountId = signal<string | null>(null);
selectedFile = signal<File | null>(null);
// Step 2
availableSheets = signal<string[]>([]);
availableHeaders = signal<Record<string, string[]>>({});
selectedSheet = signal<string>('');
mapping = signal<ColumnMapping>({
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<PreviewRow[]>([]);
selection = new SelectionModel<PreviewRow>(true, []);
selectedRows = signal<PreviewRow[]>([]);
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<Account[]>([]);
primaryAccountName = signal<string>('');
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 });
},
});
}
}

View File

@ -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',
},
];

View File

@ -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<string, string[]> }> {
const formData = new FormData();
formData.append('file', file);
return this.http
.post<{ sheets: string[]; headers: Record<string, string[]> }>(`${url}/parse-headers`, formData)
.pipe(catchError(this.log.handleError('TransactionImportService', 'parseHeaders'))) as Observable<{
sheets: string[];
headers: Record<string, string[]>;
}>;
}
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;
}>;
}
}