Created a transaction import voucher. I will use it to import my credit card and bank statements.
This commit is contained in:
@@ -0,0 +1,13 @@
|
|||||||
|
{
|
||||||
|
"mcpServers": {
|
||||||
|
"context7": {
|
||||||
|
"command": "npx",
|
||||||
|
"args": [
|
||||||
|
"-y",
|
||||||
|
"@upstash/context7-mcp",
|
||||||
|
"--api-key",
|
||||||
|
"ctx7sk-200afd5d-1a75-4c28-a3fb-3f3a26a721a0"
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -50,6 +50,7 @@ from .routers import (
|
|||||||
tag,
|
tag,
|
||||||
temporal_product,
|
temporal_product,
|
||||||
title,
|
title,
|
||||||
|
transaction_import,
|
||||||
user,
|
user,
|
||||||
voucher,
|
voucher,
|
||||||
voucher_types,
|
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(incentive.router, prefix="/api/incentive", tags=["vouchers"])
|
||||||
app.include_router(credit_salary.router, prefix="/api/credit-salary", 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(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(lock_information.router, prefix="/api/lock-information", tags=["settings"])
|
||||||
app.include_router(maintenance.router, prefix="/api/maintenance", tags=["settings"])
|
app.include_router(maintenance.router, prefix="/api/maintenance", tags=["settings"])
|
||||||
|
|||||||
@@ -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."}
|
||||||
@@ -25,6 +25,8 @@
|
|||||||
"@angular/platform-browser": "^21.1.5",
|
"@angular/platform-browser": "^21.1.5",
|
||||||
"@angular/platform-browser-dynamic": "^21.1.5",
|
"@angular/platform-browser-dynamic": "^21.1.5",
|
||||||
"@angular/router": "^21.1.5",
|
"@angular/router": "^21.1.5",
|
||||||
|
"html2canvas": "^1.4.1",
|
||||||
|
"jspdf": "^4.2.0",
|
||||||
"mathjs": "^15.1.1",
|
"mathjs": "^15.1.1",
|
||||||
"moment": "^2.30.1",
|
"moment": "^2.30.1",
|
||||||
"rxjs": "~7.8.0",
|
"rxjs": "~7.8.0",
|
||||||
|
|||||||
@@ -177,6 +177,10 @@ export const routes: Routes = [
|
|||||||
path: 'entries',
|
path: 'entries',
|
||||||
loadChildren: () => import('./entries/entries.routes').then((mod) => mod.routes),
|
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',
|
path: 'users',
|
||||||
loadChildren: () => import('./user/user.routes').then((mod) => mod.routes),
|
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="/payment">Payment - F5</a>
|
||||||
<a mat-menu-item routerLink="/receipt">Receipt - F6</a>
|
<a mat-menu-item routerLink="/receipt">Receipt - F6</a>
|
||||||
<a mat-menu-item routerLink="/issue">Issue</a>
|
<a mat-menu-item routerLink="/issue">Issue</a>
|
||||||
|
<a mat-menu-item routerLink="/transaction-import">Transaction Import</a>
|
||||||
</mat-menu>
|
</mat-menu>
|
||||||
<button mat-button [matMenuTriggerFor]="voucherMenu">Voucher Entry</button>
|
<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