Created a transaction import voucher. I will use it to import my credit card and bank statements.
This commit is contained in:
@ -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",
|
||||
|
||||
@ -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),
|
||||
|
||||
@ -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>
|
||||
|
||||
|
||||
@ -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;
|
||||
}
|
||||
@ -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>
|
||||
@ -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 });
|
||||
},
|
||||
});
|
||||
}
|
||||
}
|
||||
@ -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',
|
||||
},
|
||||
];
|
||||
@ -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;
|
||||
}>;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user