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

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