feat(cashier): add full-screen cashier teller workstation dashboard

- Add Transaction Entry screen with full account details panel
  (personal info, joint holders, nominees, KYC, balances)
- Role-based layout: CASHIER role hides nav/sidebar/ticker,
  redirects to /cashier/transaction-entry on login
- Denomination entry with opening/closing position preview
  (notes first then coins, stepper +/- round button inputs)
- Auto-fetch savings/current/FD/RD accounts with associations,
  client KYC, nominees, addresses, family members (S/D/W/H/O)
- Submit deposit/withdrawal via POST /transactions with denominationBreakdown
- Cashier position auto-loads for CASHIER role (no filter dropdowns)
- Pagination added to cash-history tabs and currency-exchange-detail
- Cashier menu simplified for cashier role permissions

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
Jaanu423
2026-06-05 10:59:34 +05:30
co-authored by Claude Sonnet 4.6
parent c8e9ab8f84
commit 9094b9a0eb
18 changed files with 2828 additions and 190 deletions
@@ -35,27 +35,55 @@
}
<mat-tab-group animationDuration="150ms">
<mat-tab label="Cash Transfers ({{ transfers.length }})">
<mat-tab label="Cash Transfers ({{ transfersTotal }})">
<div class="pg-tab-body">
<ng-container *ngTemplateOutlet="txnTable; context: { rows: transfers }"></ng-container>
<mat-paginator
[length]="transfersTotal"
[pageSize]="pageSize"
[pageSizeOptions]="pageSizeOptions"
showFirstLastButtons
(page)="onPageChange('transfers', $event)"
></mat-paginator>
</div>
</mat-tab>
<mat-tab label="Settlements ({{ settlements.length }})">
<mat-tab label="Settlements ({{ settlementsTotal }})">
<div class="pg-tab-body">
<ng-container *ngTemplateOutlet="txnTable; context: { rows: settlements }"></ng-container>
<mat-paginator
[length]="settlementsTotal"
[pageSize]="pageSize"
[pageSizeOptions]="pageSizeOptions"
showFirstLastButtons
(page)="onPageChange('settlements', $event)"
></mat-paginator>
</div>
</mat-tab>
<mat-tab label="Inter-Cashier ({{ interCashier.length }})">
<mat-tab label="Inter-Cashier ({{ interTotal }})">
<div class="pg-tab-body">
<ng-container *ngTemplateOutlet="txnTable; context: { rows: interCashier }"></ng-container>
<mat-paginator
[length]="interTotal"
[pageSize]="pageSize"
[pageSizeOptions]="pageSizeOptions"
showFirstLastButtons
(page)="onPageChange('inter', $event)"
></mat-paginator>
</div>
</mat-tab>
<mat-tab label="Cash Exchanges ({{ exchanges.length }})">
<mat-tab label="Cash Exchanges ({{ exchangesTotal }})">
<div class="pg-tab-body">
<ng-container *ngTemplateOutlet="txnTable; context: { rows: exchanges }"></ng-container>
<mat-paginator
[length]="exchangesTotal"
[pageSize]="pageSize"
[pageSizeOptions]="pageSizeOptions"
showFirstLastButtons
(page)="onPageChange('exchanges', $event)"
></mat-paginator>
</div>
</mat-tab>
</mat-tab-group>
@@ -12,6 +12,7 @@ import { FormsModule } from '@angular/forms';
import { MatIconModule } from '@angular/material/icon';
import { MatSnackBar, MatSnackBarModule } from '@angular/material/snack-bar';
import { MatTabsModule } from '@angular/material/tabs';
import { MatPaginatorModule, PageEvent } from '@angular/material/paginator';
import { forkJoin, of } from 'rxjs';
import { catchError } from 'rxjs/operators';
import { CashierService } from '../cashier.service';
@@ -65,7 +66,8 @@ interface DetailView {
FormsModule,
MatIconModule,
MatSnackBarModule,
MatTabsModule
MatTabsModule,
MatPaginatorModule
]
})
export class CashHistoryComponent implements OnInit {
@@ -78,6 +80,23 @@ export class CashHistoryComponent implements OnInit {
exchanges: TxnRow[] = [];
loading = false;
// Pagination — per tab
pageSize = 20;
pageSizeOptions = [
10,
20,
50,
100
];
transfersTotal = 0;
transfersOffset = 0;
settlementsTotal = 0;
settlementsOffset = 0;
interTotal = 0;
interOffset = 0;
exchangesTotal = 0;
exchangesOffset = 0;
private denomKindMap = new Map<number, 'note' | 'coin'>();
reversingId: number | null = null;
@@ -96,15 +115,55 @@ export class CashHistoryComponent implements OnInit {
this.load();
}
onPageChange(tab: 'transfers' | 'settlements' | 'inter' | 'exchanges', event: PageEvent): void {
this.pageSize = event.pageSize;
if (tab === 'transfers') {
this.transfersOffset = event.pageIndex * event.pageSize;
this.loadTransfers();
}
if (tab === 'settlements') {
this.settlementsOffset = event.pageIndex * event.pageSize;
this.loadSettlements();
}
if (tab === 'inter') {
this.interOffset = event.pageIndex * event.pageSize;
this.loadInter();
}
if (tab === 'exchanges') {
this.exchangesOffset = event.pageIndex * event.pageSize;
this.loadExchanges();
}
}
load(): void {
this.transfersOffset = 0;
this.settlementsOffset = 0;
this.interOffset = 0;
this.exchangesOffset = 0;
this.loading = true;
forkJoin({
transfers: this.service.getCashTransfers().pipe(catchError(() => of([]))),
settlements: this.service.getCashSettlements().pipe(catchError(() => of([]))),
inter: this.service.getInterCashierTransfers().pipe(catchError(() => of([]))),
exchanges: this.service.getCashExchanges().pipe(catchError(() => of([])))
transfers: this.service
.getCashTransfers(0, this.pageSize)
.pipe(catchError(() => of({ pageItems: [], totalFilteredRecords: 0 }))),
settlements: this.service
.getCashSettlements(0, this.pageSize)
.pipe(catchError(() => of({ pageItems: [], totalFilteredRecords: 0 }))),
inter: this.service
.getInterCashierTransfers(0, this.pageSize)
.pipe(catchError(() => of({ pageItems: [], totalFilteredRecords: 0 }))),
exchanges: this.service
.getCashExchanges(0, this.pageSize)
.pipe(catchError(() => of({ pageItems: [], totalFilteredRecords: 0 })))
}).subscribe(({ transfers, settlements, inter, exchanges }) => {
this.transfers = (transfers as any[]).map((t) => ({
this.transfersTotal = transfers.totalFilteredRecords ?? 0;
this.settlementsTotal = settlements.totalFilteredRecords ?? 0;
this.interTotal = inter.totalFilteredRecords ?? 0;
this.exchangesTotal = exchanges.totalFilteredRecords ?? 0;
const transferItems = transfers.pageItems ?? [];
const settlementItems = settlements.pageItems ?? [];
const interItems = inter.pageItems ?? [];
const exchangeItems = exchanges.pageItems ?? [];
this.transfers = (transferItems as any[]).map((t) => ({
id: t.id,
date: t.transferDate,
fromParty: t.fromParty,
@@ -119,7 +178,7 @@ export class CashHistoryComponent implements OnInit {
reverseReason: t.reverseReason ?? null,
type: 'transfer'
}));
this.settlements = (settlements as any[]).map((s) => ({
this.settlements = (settlementItems as any[]).map((s) => ({
id: s.id,
date: s.settlementDate,
fromParty: s.fromParty,
@@ -135,7 +194,7 @@ export class CashHistoryComponent implements OnInit {
reverseReason: s.reverseReason ?? null,
type: 'settlement'
}));
this.interCashier = (inter as any[]).map((i) => ({
this.interCashier = (interItems as any[]).map((i) => ({
id: i.id,
date: i.transferDate,
fromParty: i.fromParty,
@@ -150,7 +209,7 @@ export class CashHistoryComponent implements OnInit {
reverseReason: i.reverseReason ?? null,
type: 'inter'
}));
this.exchanges = (exchanges as any[]).map((e) => ({
this.exchanges = (exchangeItems as any[]).map((e) => ({
id: e.id,
date: e.exchangeDate,
fromParty: e.accountNo ? `Account ${e.accountNo}` : 'Walk-in Customer',
@@ -169,6 +228,103 @@ export class CashHistoryComponent implements OnInit {
});
}
private loadTransfers(): void {
this.service
.getCashTransfers(this.transfersOffset, this.pageSize)
.pipe(catchError(() => of({ pageItems: [], totalFilteredRecords: 0 })))
.subscribe((res: any) => {
this.transfersTotal = res.totalFilteredRecords ?? 0;
this.transfers = (res.pageItems ?? []).map((t: any) => ({
id: t.id,
date: t.transferDate,
fromParty: t.fromParty,
toParty: t.toParty,
amount: t.transferAmount,
note: t.note,
isReversed: t.isReversed,
performedByUsername: t.performedByUsername,
createdAt: t.createdAt,
reversedBy: t.reversedBy ?? null,
reversedAt: t.reversedAt ?? null,
reverseReason: t.reverseReason ?? null,
type: 'transfer'
}));
});
}
private loadSettlements(): void {
this.service
.getCashSettlements(this.settlementsOffset, this.pageSize)
.pipe(catchError(() => of({ pageItems: [], totalFilteredRecords: 0 })))
.subscribe((res: any) => {
this.settlementsTotal = res.totalFilteredRecords ?? 0;
this.settlements = (res.pageItems ?? []).map((s: any) => ({
id: s.id,
date: s.settlementDate,
fromParty: s.fromParty,
fromCashierId: s.fromCashierId ?? undefined,
toParty: s.toParty,
amount: s.settlementAmount,
note: s.note,
isReversed: s.isReversed,
performedByUsername: s.performedByUsername,
createdAt: s.createdAt,
reversedBy: s.reversedBy ?? null,
reversedAt: s.reversedAt ?? null,
reverseReason: s.reverseReason ?? null,
type: 'settlement'
}));
});
}
private loadInter(): void {
this.service
.getInterCashierTransfers(this.interOffset, this.pageSize)
.pipe(catchError(() => of({ pageItems: [], totalFilteredRecords: 0 })))
.subscribe((res: any) => {
this.interTotal = res.totalFilteredRecords ?? 0;
this.interCashier = (res.pageItems ?? []).map((i: any) => ({
id: i.id,
date: i.transferDate,
fromParty: i.fromParty,
toParty: i.toParty,
amount: i.transferAmount,
note: i.note,
isReversed: i.isReversed,
performedByUsername: i.performedByUsername,
createdAt: i.createdAt,
reversedBy: i.reversedBy ?? null,
reversedAt: i.reversedAt ?? null,
reverseReason: i.reverseReason ?? null,
type: 'inter'
}));
});
}
private loadExchanges(): void {
this.service
.getCashExchanges(this.exchangesOffset, this.pageSize)
.pipe(catchError(() => of({ pageItems: [], totalFilteredRecords: 0 })))
.subscribe((res: any) => {
this.exchangesTotal = res.totalFilteredRecords ?? 0;
this.exchanges = (res.pageItems ?? []).map((e: any) => ({
id: e.id,
date: e.exchangeDate,
fromParty: e.accountNo ? `Account ${e.accountNo}` : 'Walk-in Customer',
toParty: e.performedByUsername || '—',
amount: e.exchangeAmount,
note: e.note,
isReversed: e.isReversed ?? false,
performedByUsername: e.performedByUsername,
createdAt: e.createdAt,
reversedBy: e.reversedBy ?? null,
reversedAt: e.reversedAt ?? null,
reverseReason: e.reverseReason ?? null,
type: 'exchange'
}));
});
}
viewDetail(row: TxnRow): void {
this.detail = {
row,
@@ -18,34 +18,44 @@
</header>
<div class="pg-toolbar pg-toolbar--mat">
<mat-form-field appearance="outline" subscriptSizing="dynamic">
<mat-label>Teller (optional)</mat-label>
<mat-select [value]="selectedTellerId" (selectionChange)="onTellerSelect($event.value)">
<mat-option [value]="null">All Tellers</mat-option>
@for (t of tellers; track t.id) {
<mat-option [value]="t.id">{{ t.name }}</mat-option>
}
</mat-select>
</mat-form-field>
<!-- Teller + Cashier filters — hidden for cashier role (auto-loads own position) -->
@if (!isCashierMode) {
<mat-form-field appearance="outline" subscriptSizing="dynamic">
<mat-label>Teller (optional)</mat-label>
<mat-select [value]="selectedTellerId" (selectionChange)="onTellerSelect($event.value)">
<mat-option [value]="null">All Tellers</mat-option>
@for (t of tellers; track t.id) {
<mat-option [value]="t.id">{{ t.name }}</mat-option>
}
</mat-select>
</mat-form-field>
<mat-form-field appearance="outline" subscriptSizing="dynamic">
<mat-label>Cashier (optional)</mat-label>
<mat-select [value]="selectedCashierId" (selectionChange)="selectedCashierId = $event.value">
<mat-option [value]="null">All Cashiers</mat-option>
@for (c of cashiers; track c.id) {
<mat-option [value]="c.id">{{ c.label }}</mat-option>
}
</mat-select>
</mat-form-field>
<mat-form-field appearance="outline" subscriptSizing="dynamic">
<mat-label>Cashier (optional)</mat-label>
<mat-select [value]="selectedCashierId" (selectionChange)="selectedCashierId = $event.value">
<mat-option [value]="null">All Cashiers</mat-option>
@for (c of cashiers; track c.id) {
<mat-option [value]="c.id">{{ c.label }}</mat-option>
}
</mat-select>
</mat-form-field>
}
<mat-form-field appearance="outline" subscriptSizing="dynamic">
<mat-label>As of Date</mat-label>
<input matInput type="date" [value]="asOfDate" (change)="asOfDate = $any($event.target).value" />
</mat-form-field>
<button type="button" class="pg-btn pg-btn--primary" (click)="load()" [disabled]="loading">
<mat-icon>bar_chart</mat-icon> Load Position
</button>
@if (isCashierMode) {
<!-- Cashier mode: just a refresh button -->
<button type="button" class="pg-btn pg-btn--primary" (click)="loadMe()" [disabled]="loading">
<mat-icon>refresh</mat-icon> Refresh
</button>
} @else {
<button type="button" class="pg-btn pg-btn--primary" (click)="load()" [disabled]="loading">
<mat-icon>bar_chart</mat-icon> Load Position
</button>
}
</div>
@if (errorMsg) {
@@ -16,6 +16,7 @@ import { MatInputModule } from '@angular/material/input';
import { forkJoin, of } from 'rxjs';
import { catchError } from 'rxjs/operators';
import { CashierService } from '../cashier.service';
import { AuthenticationService } from '../../core/authentication/authentication.service';
interface DenomRow {
denominationId: number;
@@ -54,6 +55,7 @@ interface CashierPos {
})
export class CashierPositionComponent implements OnInit {
private service = inject(CashierService);
private authService = inject(AuthenticationService);
tellers: { id: number; name: string }[] = [];
cashiers: { id: number; label: string }[] = [];
@@ -65,15 +67,64 @@ export class CashierPositionComponent implements OnInit {
results: CashierPos[] = [];
errorMsg = '';
// True when logged-in user is a regular cashier — hides teller/cashier filters
isCashierMode = false;
ngOnInit(): void {
const d = new Date();
this.asOfDate = `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, '0')}-${String(d.getDate()).padStart(2, '0')}`;
this.service
.getTellers()
.pipe(catchError(() => of([] as any[])))
.subscribe((list) => {
this.tellers = (list || []).map((t: any) => ({ id: t.id, name: t.name }));
});
this.isCashierMode = this.authService.isCashierUser();
if (this.isCashierMode) {
// Auto-load current cashier's own position immediately
this.loadMe();
} else {
this.service
.getTellers()
.pipe(catchError(() => of([] as any[])))
.subscribe((list) => {
this.tellers = (list || []).map((t: any) => ({ id: t.id, name: t.name }));
});
}
}
/** Load position for the currently authenticated cashier via /me endpoint. */
loadMe(): void {
this.loading = true;
this.results = [];
this.errorMsg = '';
forkJoin({
position: this.service.getCashierPositionForMe(this.asOfDate).pipe(catchError(() => of(null))),
morning: this.service.getCashierMorningBalance(0, this.asOfDate).pipe(catchError(() => of(null)))
}).subscribe(({ position, morning }) => {
this.loading = false;
if (!position || !position.cashierId) {
this.errorMsg = 'No cashier record linked to your account. Contact your administrator.';
return;
}
// Reload morning balance using the resolved cashier ID
this.service
.getCashierMorningBalance(position.cashierId, this.asOfDate)
.pipe(catchError(() => of(null)))
.subscribe((morningData: any) => {
const morningMap = new Map<number, number>();
((morningData?.denominations || []) as any[]).forEach((d: any) =>
morningMap.set(d.denominationId, d.morningCount ?? 0)
);
const morningTotal = Number(morningData?.totalMorning ?? 0);
this.results = [
{
...position,
morningTotal,
denominations: ((position.denominations || []) as any[]).map((d: any) => ({
...d,
morningCount: morningMap.get(d.denominationId) ?? 0
}))
}
];
});
});
}
onTellerSelect(tellerId: number): void {
+10
View File
@@ -82,6 +82,16 @@ const routes: Routes = [
path: 'share-collection',
component: ShareCollectionComponent,
data: { title: 'Share Collection', breadcrumb: 'Share Collection' }
},
{
path: 'transaction-entry',
loadComponent: () =>
import('./transaction-entry/transaction-entry.component').then((m) => m.TransactionEntryComponent),
data: {
title: 'Cashier Transaction Entry',
breadcrumb: 'Transaction Entry',
hideShellLayout: true
}
}
]
}
+23 -9
View File
@@ -8,7 +8,8 @@
import { Injectable, inject } from '@angular/core';
import { HttpClient, HttpParams } from '@angular/common/http';
import { Observable } from 'rxjs';
import { Observable, of } from 'rxjs';
import { map, catchError } from 'rxjs/operators';
/** HTTP service for the Cashier menu features. */
@Injectable({ providedIn: 'root' })
@@ -24,8 +25,9 @@ export class CashierService {
createCashExchange(data: any): Observable<any> {
return this.http.post('/cashexchange', data);
}
getCashExchanges(): Observable<any[]> {
return this.http.get<any[]>('/cashexchange');
getCashExchanges(offset = 0, limit = 20): Observable<any> {
const params = new HttpParams().set('offset', offset).set('limit', limit);
return this.http.get<any>('/cashexchange', { params });
}
getCashExchange(id: number): Observable<any> {
return this.http.get(`/cashexchange/${id}`);
@@ -49,8 +51,9 @@ export class CashierService {
createCashTransfer(data: any): Observable<any> {
return this.http.post('/cashtransfer', data);
}
getCashTransfers(): Observable<any[]> {
return this.http.get<any[]>('/cashtransfer');
getCashTransfers(offset = 0, limit = 20): Observable<any> {
const params = new HttpParams().set('offset', offset).set('limit', limit);
return this.http.get<any>('/cashtransfer', { params });
}
getCashTransferById(id: number): Observable<any> {
return this.http.get(`/cashtransfer/${id}`);
@@ -63,8 +66,9 @@ export class CashierService {
createCashSettlement(data: any): Observable<any> {
return this.http.post('/cashsettlement', data);
}
getCashSettlements(): Observable<any[]> {
return this.http.get<any[]>('/cashsettlement');
getCashSettlements(offset = 0, limit = 20): Observable<any> {
const params = new HttpParams().set('offset', offset).set('limit', limit);
return this.http.get<any>('/cashsettlement', { params });
}
getCashSettlementById(id: number): Observable<any> {
return this.http.get(`/cashsettlement/${id}`);
@@ -77,8 +81,9 @@ export class CashierService {
createInterCashierTransfer(data: any): Observable<any> {
return this.http.post('/cashintercashier', data);
}
getInterCashierTransfers(): Observable<any[]> {
return this.http.get<any[]>('/cashintercashier');
getInterCashierTransfers(offset = 0, limit = 20): Observable<any> {
const params = new HttpParams().set('offset', offset).set('limit', limit);
return this.http.get<any>('/cashintercashier', { params });
}
getInterCashierById(id: number): Observable<any> {
return this.http.get(`/cashintercashier/${id}`);
@@ -157,6 +162,15 @@ export class CashierService {
return this.http.get('/cashreconciliation/expected/me', { params });
}
// ── Current cashier identity ──
// Returns cashierId + cashierName for the logged-in user, or null if not a cashier.
getCurrentCashierInfo(): Observable<{ cashierId: number; cashierName: string } | null> {
return this.getCashierPositionForMe().pipe(
map((pos: any) => (pos?.cashierId ? { cashierId: pos.cashierId, cashierName: pos.cashierName ?? '' } : null)),
catchError(() => of(null))
);
}
// ── Payment Types ──
getPaymentTypes(): Observable<any[]> {
return this.http.get<any[]>('/paymenttypes');
@@ -52,5 +52,15 @@
}
</tbody>
</table>
<mat-paginator
[length]="totalRecords"
[pageSize]="pageSize"
[pageIndex]="pageIndex"
[pageSizeOptions]="pageSizeOptions"
showFirstLastButtons
(page)="onPageChange($event)"
>
</mat-paginator>
</div>
</div>
@@ -10,6 +10,7 @@ import { Component, OnInit, inject } from '@angular/core';
import { CommonModule } from '@angular/common';
import { RouterLink } from '@angular/router';
import { MatIconModule } from '@angular/material/icon';
import { MatPaginatorModule, PageEvent } from '@angular/material/paginator';
import { CashierService } from '../cashier.service';
/** Lists recorded cash exchanges (Currency Exchange Detail). */
@@ -21,25 +22,48 @@ import { CashierService } from '../cashier.service';
imports: [
CommonModule,
RouterLink,
MatIconModule
MatIconModule,
MatPaginatorModule
]
})
export class CurrencyExchangeDetailComponent implements OnInit {
private service = inject(CashierService);
exchanges: any[] = [];
totalRecords = 0;
pageSize = 20;
pageIndex = 0;
pageSizeOptions = [
10,
20,
50,
100
];
loading = true;
ngOnInit(): void {
this.service.getCashExchanges().subscribe({
next: (list) => {
this.exchanges = list || [];
this.load();
}
load(): void {
this.loading = true;
this.service.getCashExchanges(this.pageIndex * this.pageSize, this.pageSize).subscribe({
next: (res: any) => {
this.exchanges = res.pageItems ?? [];
this.totalRecords = res.totalFilteredRecords ?? 0;
this.loading = false;
},
error: () => {
this.exchanges = [];
this.totalRecords = 0;
this.loading = false;
}
});
}
onPageChange(event: PageEvent): void {
this.pageIndex = event.pageIndex;
this.pageSize = event.pageSize;
this.load();
}
}
@@ -0,0 +1,644 @@
<!--
Copyright since 2025 Mifos Initiative
This Source Code Form is subject to the terms of the Mozilla Public
License, v. 2.0. If a copy of the MPL was not distributed with this
file, You can obtain one at http://mozilla.org/MPL/2.0/.
-->
<div class="te-root">
<!-- ── ENTRY GRID shown when no account loaded yet ────────────────── -->
@if (!accountDetails && !loadingAccount) {
<div class="te-grid-wrap">
<table class="te-grid">
<thead>
<tr>
<th>Typ</th>
<th>Br.</th>
<th>Tr.Tp.</th>
<th>Actyp</th>
<th>A/c No.</th>
<th>Ecs A/c No.</th>
<th>Db./Cr.</th>
<th>Cheque No.</th>
<th>Amount</th>
<th>Description</th>
<th>Tok.No.</th>
<th>Cheq.Date</th>
</tr>
</thead>
<tbody>
@for (row of rows; track $index; let i = $index) {
<tr [class.te-row--selected]="selectedRow === row" (click)="onRowSelect(row, i)">
<td><input class="te-cell te-cell--xs" [(ngModel)]="row.typ" (click)="$event.stopPropagation()" /></td>
<td>
<input
class="te-cell te-cell--xs"
type="number"
[(ngModel)]="row.br"
(click)="$event.stopPropagation()"
/>
</td>
<td>
<select class="te-cell te-cell--sm" [(ngModel)]="row.trTp" (click)="$event.stopPropagation()">
<option value=""></option>
@for (t of trTpOptions; track t) {
<option [value]="t">{{ t }}</option>
}
</select>
</td>
<td>
<select class="te-cell te-cell--sm" [(ngModel)]="row.acTyp" (click)="$event.stopPropagation()">
@for (a of acTypOptions; track a) {
<option [value]="a">{{ a }}</option>
}
</select>
</td>
<td>
<input
class="te-cell te-cell--md"
[(ngModel)]="row.acNo"
(click)="$event.stopPropagation()"
(blur)="onAcNoBlur(row, i)"
/>
</td>
<td>
<input class="te-cell te-cell--lg" [(ngModel)]="row.ecsAcNo" (click)="$event.stopPropagation()" />
</td>
<td>
<select class="te-cell te-cell--sm" [(ngModel)]="row.dbCr" (click)="$event.stopPropagation()">
<option value=""></option>
@for (d of dbCrOptions; track d) {
<option [value]="d">{{ d }}</option>
}
</select>
</td>
<td>
<input class="te-cell te-cell--md" [(ngModel)]="row.chequeNo" (click)="$event.stopPropagation()" />
</td>
<td>
<input
class="te-cell te-cell--md"
type="number"
[(ngModel)]="row.amount"
(click)="$event.stopPropagation()"
/>
</td>
<td>
<input class="te-cell te-cell--lg" [(ngModel)]="row.description" (click)="$event.stopPropagation()" />
</td>
<td><input class="te-cell te-cell--sm" [(ngModel)]="row.tokNo" (click)="$event.stopPropagation()" /></td>
<td>
<input
class="te-cell te-cell--sm"
[(ngModel)]="row.cheqDate"
placeholder="dd/mm/yy"
(click)="$event.stopPropagation()"
/>
</td>
</tr>
}
</tbody>
</table>
<div class="te-grid-footer">
<span class="te-eff-date"
>Eff. Dt : <strong>{{ today }}</strong></span
>
<button class="te-add-btn" type="button" (click)="addRow()" title="Add row">+</button>
</div>
</div>
}
<!-- ── STATUS ────────────────────────────────────────────────────────── -->
@if (loadingAccount) {
<div class="te-loading">Loading account details…</div>
}
@if (accountError && !loadingAccount) {
<div class="te-error">{{ accountError }}</div>
}
<!-- ── ACCOUNT DETAIL ─────────────────────────────────────────────────── -->
@if (accountDetails && !loadingAccount) {
<div class="te-detail">
<!-- ROW 1: Personal | Joint + Nominees | Other + Balances -->
<div class="te-detail-top">
<!-- Personal Information -->
<div class="te-panel">
<div class="te-panel__header">
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<circle cx="12" cy="8" r="4" />
<path d="M4 20c0-4 3.6-7 8-7s8 3 8 7" />
</svg>
Personal Information
</div>
<div class="te-panel__body">
<div class="te-field">
<span class="te-label">Branch</span><span class="te-value">{{ officeName }}</span>
</div>
<div class="te-field">
<span class="te-label">A/c No.</span><span class="te-value te-mono">{{ accountDetails.accountNo }}</span>
</div>
<div class="te-field">
<span class="te-label">Name</span><span class="te-value te-value-name">{{ clientName }}</span>
</div>
<div class="te-field">
<span class="te-label">S/D/W/H/O</span><span class="te-value">{{ guardianName }}</span>
</div>
<div class="te-field">
<span class="te-label">Address</span><span class="te-value te-value-wrap">{{ clientAddress }}</span>
</div>
<div class="te-field">
<span class="te-label">Email</span><span class="te-value">{{ clientEmail }}</span>
</div>
<div class="te-field-row">
<div class="te-field">
<span class="te-label">Last Txn</span><span class="te-value">{{ lastTransactionDate }}</span>
</div>
<div class="te-field">
<span class="te-label">Type</span
><span class="te-value">{{ accountDetails.depositType?.value ?? selectedRow?.acTyp ?? '—' }}</span>
</div>
</div>
<div class="te-field-row">
<div class="te-field">
<span class="te-label">Opening Dt</span><span class="te-value">{{ openingDate }}</span>
</div>
<div class="te-field">
<span class="te-label">Status</span
><span class="te-value">{{ accountDetails.status?.value ?? '—' }}</span>
</div>
</div>
<div class="te-field">
<span class="te-label">CIF No.</span><span class="te-value te-mono">{{ cifNumber }}</span>
</div>
</div>
</div>
<!-- Joint Holders + Nominees -->
<div class="te-panel">
<div class="te-panel__header">
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<path d="M17 21v-2a4 4 0 0 0-4-4H5a4 4 0 0 0-4 4v2" />
<circle cx="9" cy="7" r="4" />
<path d="M23 21v-2a4 4 0 0 0-3-3.87" />
<path d="M16 3.13a4 4 0 0 1 0 7.75" />
</svg>
Joint A/c Holders
@if (accountDetails?.modeOfOperation) {
<span class="te-mandate-badge">{{ modeOfOperationLabel }}</span>
}
</div>
<div class="te-panel__body">
@if (jointHolders.length === 0) {
<span class="te-dim">No joint holders</span>
}
@for (jh of jointHolders; track jh.id ?? $index) {
<div class="te-joint-row">
<span class="te-joint-name">{{ jh.displayName }}</span>
<span class="te-badge" [class.te-badge--primary]="jh.isPrimary || jh.primary">
{{ jh.role ?? (jh.isPrimary || jh.primary ? 'Primary' : 'Co-Holder') }}
</span>
@if (jh.sharePercentage) {
<span class="te-dim">{{ jh.sharePercentage }}%</span>
}
@if (jh.signingAuthority) {
<span
class="te-sign-badge"
[class.te-sign-badge--mandatory]="jh.signingAuthority?.toLowerCase() === 'mandatory'"
[class.te-sign-badge--optional]="jh.signingAuthority?.toLowerCase() === 'optional'"
>
{{ jh.signingAuthority }}
</span>
}
</div>
}
</div>
<div class="te-panel__header te-panel__header--mt">
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<path d="M12 22s8-4 8-10V5l-8-3-8 3v7c0 6 8 10 8 10z" />
</svg>
Nominees
</div>
<div class="te-panel__body">
@if (nominees.length === 0) {
<span class="te-dim">No nominees</span>
}
@for (n of nominees; track n.id ?? $index) {
<div class="te-joint-row">
<span class="te-joint-name">{{ n.nomineeDisplayName ?? '—' }}</span>
<span class="te-dim">{{ n.relationshipName ?? '—' }}</span>
@if (n.percentageShare) {
<span class="te-badge">{{ n.percentageShare }}%</span>
}
@if (n.nomineeIsMinor) {
<span class="te-badge" style="background: #fff7ed; color: #c2410c; border-color: #fed7aa">Minor</span>
}
</div>
}
</div>
</div>
<!-- Other Info + KYC + Account Balances -->
<div class="te-panel">
<div class="te-panel__header">
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<rect x="3" y="4" width="18" height="18" rx="2" />
<line x1="16" y1="2" x2="16" y2="6" />
<line x1="8" y1="2" x2="8" y2="6" />
<line x1="3" y1="10" x2="21" y2="10" />
</svg>
Other Information
</div>
<div class="te-panel__body">
<div class="te-field">
<span class="te-label">Phone No.</span><span class="te-value">{{ clientPhone }}</span>
</div>
<div class="te-field">
<span class="te-label">Client ID</span><span class="te-value te-mono">{{ accountDetails.clientId }}</span>
</div>
<div class="te-field">
<span class="te-label">PAN</span><span class="te-value te-mono">{{ panNumber }}</span>
</div>
<div class="te-field">
<span class="te-label">KYC Status</span>
<span class="te-value" [style.color]="kycDetails?.kycStatus === 300 ? '#059669' : '#f59e0b'">{{
kycStatus
}}</span>
</div>
<div class="te-field">
<span class="te-label">CKYC No.</span><span class="te-value te-mono">{{ ckycNumber }}</span>
</div>
<div class="te-field">
<span class="te-label">Aadhaar</span><span class="te-value te-mono">{{ aadhaarNumber }}</span>
</div>
<div class="te-field">
<span class="te-label">Product</span>
<span class="te-value">{{
accountDetails.savingsProductName ||
accountDetails.depositProductName ||
accountDetails.productName ||
'—'
}}</span>
</div>
</div>
<div class="te-panel__header te-panel__header--mt">
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<line x1="12" y1="1" x2="12" y2="23" />
<path d="M17 5H9.5a3.5 3.5 0 0 0 0 7h5a3.5 3.5 0 0 1 0 7H6" />
</svg>
Account Information
</div>
<div class="te-panel__body">
<div class="te-balance-row">
<span class="te-bal-num">1.</span><span class="te-bal-label">Current Balance</span>
<span class="te-bal-value te-bal-value--cr"
>{{ currencySymbol }}{{ currentBalance | number: '1.2-2' }}</span
>
</div>
<div class="te-balance-row">
<span class="te-bal-num">2.</span><span class="te-bal-label">Today's Clg. Amount</span>
<span class="te-bal-value">{{ currencySymbol }}{{ todayClgAmount | number: '1.2-2' }}</span>
</div>
<div class="te-balance-row">
<span class="te-bal-num">3.</span><span class="te-bal-label">Clear Balance (2-3)</span>
<span class="te-bal-value te-bal-value--cr"
>{{ currencySymbol }}{{ clearBalance | number: '1.2-2' }}</span
>
</div>
<div class="te-balance-row">
<span class="te-bal-num">4.</span><span class="te-bal-label">Freeze Amount</span>
<span class="te-bal-value">{{ currencySymbol }}{{ freezeAmount | number: '1.2-2' }}</span>
</div>
<div class="te-balance-row">
<span class="te-bal-num">5.</span><span class="te-bal-label">Withdrawable Amt.</span>
<span class="te-bal-value te-bal-value--cr"
>{{ currencySymbol }}{{ withdrawableAmount | number: '1.2-2' }}</span
>
</div>
</div>
</div>
</div>
<!-- NET BALANCE BAR -->
<div class="te-balance-bar">
<div class="te-bal-item">
<span class="te-bal-item__label">Net Balance</span>
<span class="te-bal-item__val te-bal-item__val--cr te-bal-item__val--big">
{{ currencySymbol }}{{ clearBalance | number: '1.2-2' }} CR
</span>
</div>
<div class="te-bal-item">
<span class="te-bal-item__label">A/c Type</span>
<span class="te-bal-item__val">{{
accountDetails.depositType?.value || accountDetails.accountType || selectedRow?.acTyp || '—'
}}</span>
</div>
<div class="te-bal-item">
<span class="te-bal-item__label">Interest Rate</span>
<span class="te-bal-item__val">{{ accountDetails.nominalAnnualInterestRate ?? 0 | number: '1.2-2' }}%</span>
</div>
<div class="te-bal-item">
<span class="te-bal-item__label">Currency</span>
<span class="te-bal-item__val">{{ accountDetails.currency?.code ?? 'INR' }}</span>
</div>
</div>
<!-- BOTTOM: Basic Info -->
<div class="te-panel">
<div class="te-panel__header">
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z" />
<polyline points="14 2 14 8 20 8" />
</svg>
Basic Information
</div>
<div class="te-panel__body te-basic-grid">
<div class="te-balance-row">
<span class="te-bal-label">Opening Balance</span
><span class="te-bal-value">{{ currencySymbol }}{{ currentBalance | number: '1.2-2' }}</span>
</div>
<div class="te-balance-row">
<span class="te-bal-label">Cash Hold</span><span class="te-bal-value">0.00</span>
</div>
<div class="te-balance-row">
<span class="te-bal-label">Transfer Hold</span><span class="te-bal-value">0.00</span>
</div>
<div class="te-balance-row">
<span class="te-bal-label">Clearing Hold</span><span class="te-bal-value">0.00</span>
</div>
<div class="te-balance-row te-balance-row--total">
<span class="te-bal-label">Bal. After This Trans.</span>
<span class="te-bal-value te-bal-value--cr">{{ currencySymbol }}{{ clearBalance | number: '1.2-2' }}</span>
</div>
</div>
</div>
<!-- ── DENOMINATION + OPENING/CLOSING ──────────────────────────── -->
<div class="te-denom-wrap">
<!-- Opening Balance -->
<div class="te-panel te-panel--denom">
<div class="te-panel__header">
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<circle cx="12" cy="12" r="10" />
<polyline points="12 6 12 12 16 14" />
</svg>
Opening Position — {{ today }}
</div>
<div class="te-panel__body">
@if (!openingLoaded) {
<span class="te-dim">Loading…</span>
}
@if (openingLoaded && openingDenoms.length === 0) {
<span class="te-dim">No opening position found</span>
}
@if (openingLoaded && openingDenoms.length > 0) {
<table class="te-denom-table">
<thead>
<tr>
<th>Denomination</th>
<th class="num">Opening Count</th>
<th class="num">Amount</th>
</tr>
</thead>
<tbody>
@for (d of sortedOpeningDenoms; track d.denominationId) {
@if (d.count > 0) {
<tr>
<td>
<span class="te-denom-chip" [class.te-denom-chip--coin]="d.kind === 'coin'"
>₹{{ d.denominationValue }}</span
>
</td>
<td class="num">{{ d.count }}</td>
<td class="num te-cr">₹{{ d.count * d.denominationValue | number: '1.2-2' }}</td>
</tr>
}
}
</tbody>
<tfoot>
<tr>
<td><strong>Total</strong></td>
<td></td>
<td class="num te-cr">
<strong>₹{{ openingTotal | number: '1.2-2' }}</strong>
</td>
</tr>
</tfoot>
</table>
}
</div>
</div>
<!-- Denomination Entry -->
<div class="te-panel te-panel--denom">
<div class="te-panel__header">
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<rect x="2" y="7" width="20" height="14" rx="2" />
<path d="M16 21V5a2 2 0 0 0-2-2h-4a2 2 0 0 0-2 2v16" />
</svg>
Denomination Entry
@if (denominationTotal > 0) {
<span class="te-mandate-badge">₹{{ denominationTotal | number: '1.2-2' }}</span>
}
</div>
<div class="te-panel__body">
<table class="te-denom-table">
<thead>
<tr>
<th>Denomination</th>
<th class="num">Count</th>
<th class="num">Amount</th>
</tr>
</thead>
<tbody>
@for (d of allDenominations; track d.id) {
<tr>
<td>
<span class="te-denom-chip" [class.te-denom-chip--coin]="d.kind === 'coin'">
{{ d.label }}
</span>
</td>
<td class="num">
<div class="te-denom-stepper">
<button type="button" (click)="bumpDenom(d, -1)" [disabled]="countFor(d) === 0">−</button>
<input
type="number"
min="0"
[value]="countFor(d) || ''"
placeholder="0"
(input)="onDenomChange(d, $any($event.target).value)"
/>
<button type="button" (click)="bumpDenom(d, 1)">+</button>
</div>
</td>
<td class="num te-cr">
{{ subtotalFor(d) > 0 ? (subtotalFor(d) | number: '1.2-2') : '—' }}
</td>
</tr>
}
</tbody>
<tfoot>
<tr>
<td><strong>Total</strong></td>
<td></td>
<td class="num te-cr">
<strong>₹{{ denominationTotal | number: '1.2-2' }}</strong>
</td>
</tr>
</tfoot>
</table>
@if (denominationTotal > 0) {
<div class="te-denom-actions">
<button class="te-btn te-btn--primary" type="button" (click)="applyDenominationTotal()">
Apply ₹{{ denominationTotal | number: '1.2-2' }} to Amount
</button>
<button class="te-btn" type="button" (click)="clearDenominations()">Clear</button>
</div>
}
</div>
</div>
<!-- Closing Position -->
<div class="te-panel te-panel--denom">
<div class="te-panel__header">
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<polyline points="20 6 9 17 4 12" />
</svg>
Closing Position (Preview)
</div>
<div class="te-panel__body">
@if (closingDenoms.length === 0) {
<span class="te-dim">Enter denomination to see closing position</span>
}
@if (closingDenoms.length > 0) {
<table class="te-denom-table">
<thead>
<tr>
<th>Denomination</th>
<th class="num">Opening</th>
<th class="num">{{ selectedRow?.dbCr === 'Cr' ? '+Deposit' : '-Withdrawal' }}</th>
<th class="num">Closing</th>
</tr>
</thead>
<tbody>
@for (d of sortedClosingDenoms; track d.denominationValue + d.kind) {
<tr [class.te-closing-row--neg]="d.closingCount < 0">
<td>
<span class="te-denom-chip" [class.te-denom-chip--coin]="d.kind === 'coin'"
>₹{{ d.denominationValue }}</span
>
</td>
<td class="num">{{ d.openCount }}</td>
<td class="num" [style.color]="selectedRow?.dbCr === 'Cr' ? '#059669' : '#dc2626'">
{{ selectedRow?.dbCr === 'Cr' ? '+' : '-' }}{{ d.entered }}
</td>
<td class="num" [class.te-cr]="d.closingCount >= 0" [class.te-dr]="d.closingCount < 0">
{{ d.closingCount }}
</td>
</tr>
}
</tbody>
<tfoot>
<tr>
<td><strong>Total</strong></td>
<td class="num">₹{{ openingTotal | number: '1.2-2' }}</td>
<td class="num" [style.color]="selectedRow?.dbCr === 'Cr' ? '#059669' : '#dc2626'">
{{ selectedRow?.dbCr === 'Cr' ? '+' : '-' }}₹{{ denominationTotal | number: '1.2-2' }}
</td>
<td class="num" [class.te-cr]="closingTotal >= 0" [class.te-dr]="closingTotal < 0">
<strong>₹{{ closingTotal | number: '1.2-2' }}</strong>
</td>
</tr>
</tfoot>
</table>
}
</div>
</div>
</div>
<!-- ── ENTRY GRID (bottom) ─────────────────────────────────────── -->
<div class="te-grid-wrap">
<table class="te-grid">
<thead>
<tr>
<th>Typ</th>
<th>Br.</th>
<th>Tr.Tp.</th>
<th>Actyp</th>
<th>A/c No.</th>
<th>Ecs A/c No.</th>
<th>Db./Cr.</th>
<th>Cheque No.</th>
<th>Amount</th>
<th>Description</th>
<th>Tok.No.</th>
<th>Cheq.Date</th>
</tr>
</thead>
<tbody>
@for (row of rows; track $index; let i = $index) {
<tr [class.te-row--selected]="selectedRow === row" (click)="onRowSelect(row, i)">
<td><input class="te-cell te-cell--xs" [(ngModel)]="row.typ" /></td>
<td><input class="te-cell te-cell--xs" type="number" [(ngModel)]="row.br" /></td>
<td>
<select class="te-cell te-cell--sm" [(ngModel)]="row.trTp">
<option value=""></option>
@for (t of trTpOptions; track t) {
<option [value]="t">{{ t }}</option>
}
</select>
</td>
<td>
<select class="te-cell te-cell--sm" [(ngModel)]="row.acTyp">
@for (a of acTypOptions; track a) {
<option [value]="a">{{ a }}</option>
}
</select>
</td>
<td><input class="te-cell te-cell--md" [(ngModel)]="row.acNo" (blur)="onAcNoBlur(row, i)" /></td>
<td><input class="te-cell te-cell--lg" [(ngModel)]="row.ecsAcNo" /></td>
<td>
<select class="te-cell te-cell--sm" [(ngModel)]="row.dbCr">
<option value=""></option>
@for (d of dbCrOptions; track d) {
<option [value]="d">{{ d }}</option>
}
</select>
</td>
<td><input class="te-cell te-cell--md" [(ngModel)]="row.chequeNo" /></td>
<td><input class="te-cell te-cell--md" type="number" [(ngModel)]="row.amount" /></td>
<td><input class="te-cell te-cell--lg" [(ngModel)]="row.description" /></td>
<td><input class="te-cell te-cell--sm" [(ngModel)]="row.tokNo" /></td>
<td><input class="te-cell te-cell--sm" [(ngModel)]="row.cheqDate" placeholder="dd/mm/yy" /></td>
</tr>
}
</tbody>
</table>
<div class="te-grid-footer">
<span class="te-eff-date"
>Eff. Dt : <strong>{{ today }}</strong></span
>
<button class="te-add-btn" type="button" (click)="addRow()" title="Add row">+</button>
</div>
</div>
<!-- ACTION BUTTONS -->
<div class="te-actions">
<button class="te-btn te-btn--exit" type="button" (click)="onExit()">Exit</button>
<button class="te-btn" type="button" (click)="onCancel()">Cancel</button>
<button class="te-btn" type="button">Drop</button>
<button class="te-btn" type="button" (click)="onSearch()">Search</button>
<button class="te-btn te-btn--mode" type="button">Change Tr.Mode</button>
<button class="te-btn te-btn--primary" type="button" (click)="onSave()" [disabled]="saving">
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5">
<polyline points="20 6 9 17 4 12" />
</svg>
{{ saving ? 'Posting…' : 'Save' }}
</button>
</div>
</div>
}
</div>
@@ -0,0 +1,839 @@
/**
* Copyright since 2025 Mifos Initiative
*
* This Source Code Form is subject to the terms of the Mozilla Public
* License, v. 2.0. If a copy of the MPL was not distributed with this
* file, You can obtain one at http://mozilla.org/MPL/2.0/.
*/
:host {
display: block;
min-height: 100vh;
background: #f0f2f5;
font-family: Inter, Roboto, sans-serif;
font-size: 13px;
}
// ── Root wrapper ──────────────────────────────────────────────────────────────
.te-root {
display: flex;
flex-direction: column;
gap: 10px;
padding: 10px 14px;
}
// ── Entry Grid Card ───────────────────────────────────────────────────────────
.te-grid-wrap {
background: #fff;
border: 1px solid #e2e8f0;
border-radius: 10px;
overflow: hidden;
box-shadow: 0 1px 4px rgb(0 0 0 / 6%);
}
.te-grid {
width: 100%;
border-collapse: collapse;
font-size: 12px;
th {
background: #1e293b;
color: #94a3b8;
padding: 7px 8px;
text-align: left;
font-size: 11px;
font-weight: 700;
letter-spacing: 0.04em;
text-transform: uppercase;
border-right: 1px solid #334155;
white-space: nowrap;
&:last-child {
border-right: none;
}
}
td {
border-right: 1px solid #f1f5f9;
border-bottom: 1px solid #f1f5f9;
padding: 3px 4px;
background: #fff;
&:last-child {
border-right: none;
}
}
tr.te-row--selected td {
background: #eff6ff;
}
tr:hover td {
background: #f8fafc;
cursor: pointer;
}
&--mini th {
font-size: 10px;
padding: 5px 6px;
background: #334155;
}
}
.te-cell {
border: 1px solid #e2e8f0;
border-radius: 5px;
background: #f8fafc;
font-family: 'Roboto Mono', monospace;
font-size: 12px;
padding: 4px 7px;
color: #1e293b;
outline: none;
width: 100%;
transition:
border-color 0.15s,
background 0.15s;
&:focus {
border-color: #3b82f6;
background: #fff;
box-shadow: 0 0 0 2px rgb(59 130 246 / 15%);
}
&--xs {
width: 36px;
}
&--sm {
width: 56px;
}
&--md {
width: 88px;
}
&--lg {
width: 130px;
}
}
select.te-cell {
cursor: pointer;
}
.te-grid-footer {
display: flex;
align-items: center;
justify-content: space-between;
padding: 6px 12px;
background: #f8fafc;
border-top: 1px solid #e2e8f0;
}
.te-eff-date {
font-size: 12px;
color: #64748b;
strong {
color: #0f172a;
}
}
.te-add-btn {
width: 26px;
height: 26px;
border-radius: 50%;
background: #0ea5e9;
color: #fff;
border: none;
font-size: 18px;
font-weight: 300;
line-height: 1;
cursor: pointer;
display: flex;
align-items: center;
justify-content: center;
transition: background 0.15s;
box-shadow: 0 2px 6px rgb(14 165 233 / 35%);
&:hover {
background: #0284c7;
}
}
// ── Status messages ──────────────────────────────────────────────────────────
.te-loading {
display: flex;
align-items: center;
gap: 10px;
padding: 14px 16px;
background: #fff;
border: 1px solid #e2e8f0;
border-radius: 10px;
color: #64748b;
font-size: 13px;
&::before {
content: '';
width: 16px;
height: 16px;
border: 2px solid #e2e8f0;
border-top-color: #3b82f6;
border-radius: 50%;
animation: te-spin 0.7s linear infinite;
}
}
@keyframes te-spin {
to {
transform: rotate(360deg);
}
}
.te-error {
padding: 10px 16px;
background: #fef2f2;
border: 1px solid #fecaca;
border-radius: 8px;
color: #dc2626;
font-size: 13px;
font-weight: 500;
}
// ── Main Detail Panel ─────────────────────────────────────────────────────────
.te-detail {
display: flex;
flex-direction: column;
gap: 10px;
}
.te-detail-top {
display: grid;
grid-template-columns: 1.1fr 0.9fr 1.1fr;
gap: 10px;
@media (width <= 1100px) {
grid-template-columns: 1fr 1fr;
}
@media (width <= 700px) {
grid-template-columns: 1fr;
}
}
// ── Panel Card ────────────────────────────────────────────────────────────────
.te-panel {
background: #fff;
border: 1px solid #e2e8f0;
border-radius: 10px;
overflow: hidden;
box-shadow: 0 1px 4px rgb(0 0 0 / 5%);
&__header {
display: flex;
align-items: center;
gap: 8px;
padding: 9px 14px;
background: linear-gradient(135deg, #1e293b, #334155);
color: #e2e8f0;
font-size: 11px;
font-weight: 700;
letter-spacing: 0.06em;
text-transform: uppercase;
&--mt {
margin-top: 0;
border-top: 1px solid #e2e8f0;
background: linear-gradient(135deg, #334155, #475569);
}
}
&__body {
padding: 10px 14px;
&--center {
display: flex;
align-items: center;
justify-content: center;
min-height: 60px;
}
}
}
// ── Field rows ────────────────────────────────────────────────────────────────
.te-field {
display: flex;
align-items: flex-start;
gap: 6px;
margin-bottom: 5px;
font-size: 12px;
border-bottom: 1px dotted #f1f5f9;
padding-bottom: 4px;
&:last-child {
border-bottom: none;
margin-bottom: 0;
}
}
.te-field-row {
display: flex;
gap: 10px;
.te-field {
flex: 1;
}
}
.te-label {
min-width: 88px;
color: #64748b;
font-size: 11px;
font-weight: 600;
white-space: nowrap;
padding-top: 1px;
}
.te-value {
color: #1e293b;
font-size: 12px;
font-weight: 500;
flex: 1;
&-name {
font-weight: 700;
color: #0f172a;
font-size: 13px;
}
&-wrap {
white-space: normal;
word-break: break-word;
}
}
.te-mono {
font-family: 'Roboto Mono', 'Courier New', monospace;
}
.te-dim {
color: #94a3b8;
font-size: 11px;
}
// ── Joint holders / Nominees ──────────────────────────────────────────────────
.te-joint-row {
display: flex;
align-items: center;
gap: 8px;
padding: 6px 0;
border-bottom: 1px solid #f1f5f9;
font-size: 12px;
&:last-child {
border-bottom: none;
}
}
.te-joint-name {
flex: 1;
font-weight: 600;
color: #1e293b;
}
.te-badge {
display: inline-flex;
align-items: center;
padding: 2px 8px;
border-radius: 999px;
font-size: 10px;
font-weight: 700;
background: #f1f5f9;
color: #475569;
border: 1px solid #e2e8f0;
white-space: nowrap;
&--primary {
background: #dbeafe;
color: #1d4ed8;
border-color: #bfdbfe;
}
}
.te-mandate-badge {
display: inline-flex;
align-items: center;
padding: 2px 8px;
border-radius: 999px;
font-size: 10px;
font-weight: 700;
background: #fef3c7;
color: #92400e;
border: 1px solid #fde68a;
margin-left: 8px;
text-transform: none;
letter-spacing: 0;
}
.te-sign-badge {
display: inline-flex;
align-items: center;
padding: 2px 7px;
border-radius: 999px;
font-size: 10px;
font-weight: 700;
background: #f1f5f9;
color: #475569;
&--mandatory {
background: #fff7ed;
color: #c2410c;
border: 1px solid #fdba74;
}
&--optional {
background: #f0fdf4;
color: #15803d;
border: 1px solid #86efac;
}
}
// ── Balance rows ──────────────────────────────────────────────────────────────
.te-balance-row {
display: flex;
align-items: center;
justify-content: space-between;
padding: 5px 0;
border-bottom: 1px dotted #f1f5f9;
font-size: 12px;
&:last-child {
border-bottom: none;
}
&--total {
border-top: 1px solid #e2e8f0;
border-bottom: none;
font-weight: 700;
padding-top: 6px;
margin-top: 4px;
}
}
.te-bal-num {
width: 18px;
color: #94a3b8;
font-size: 11px;
flex-shrink: 0;
}
.te-bal-label {
flex: 1;
color: #475569;
font-size: 12px;
}
.te-bal-value {
font-weight: 700;
font-variant-numeric: tabular-nums;
text-align: right;
min-width: 100px;
color: #1e293b;
&--cr {
color: #059669;
}
&--dr {
color: #dc2626;
}
}
// ── Net Balance Bar ───────────────────────────────────────────────────────────
.te-balance-bar {
display: flex;
align-items: center;
flex-wrap: wrap;
gap: 0;
background: #fff;
border: 1px solid #e2e8f0;
border-radius: 10px;
overflow: hidden;
box-shadow: 0 1px 4px rgb(0 0 0 / 5%);
}
.te-bal-item {
display: flex;
align-items: center;
gap: 8px;
padding: 12px 20px;
border-right: 1px solid #e2e8f0;
font-size: 12px;
&:last-child {
border-right: none;
}
&__label {
color: #64748b;
font-weight: 600;
font-size: 11px;
text-transform: uppercase;
letter-spacing: 0.04em;
white-space: nowrap;
}
&__val {
font-weight: 700;
color: #1e293b;
font-variant-numeric: tabular-nums;
&--cr {
color: #059669;
}
&--big {
font-size: 18px;
}
}
}
// ── Denomination Section ──────────────────────────────────────────────────────
.te-denom-wrap {
display: grid;
grid-template-columns: repeat(3, 1fr);
gap: 10px;
@media (width <= 1100px) {
grid-template-columns: 1fr 1fr;
}
@media (width <= 700px) {
grid-template-columns: 1fr;
}
}
.te-denom-table {
width: 100%;
border-collapse: collapse;
font-size: 12px;
th {
background: #f1f5f9;
color: #64748b;
font-size: 10px;
font-weight: 700;
letter-spacing: 0.05em;
text-transform: uppercase;
padding: 5px 10px;
border-bottom: 1px solid #e2e8f0;
text-align: left;
&.num {
text-align: right;
}
}
td {
padding: 5px 10px;
border-bottom: 1px solid #f8fafc;
color: #1e293b;
font-size: 12px;
&.num {
text-align: right;
font-variant-numeric: tabular-nums;
}
}
tr:last-child td {
border-bottom: none;
}
tr:hover td {
background: #f8fafc;
}
tfoot td {
background: #f1f5f9;
border-top: 2px solid #e2e8f0;
font-size: 12px;
padding: 6px 10px;
}
}
// ── Section header row inside denomination table ──────────────────────────────
.te-denom-section-row td {
padding: 4px 10px;
font-size: 10px;
font-weight: 700;
letter-spacing: 0.06em;
text-transform: uppercase;
border-top: 1px solid #e2e8f0;
&.te-denom-section-row__label {
color: #1d4ed8;
background: #eff6ff;
}
}
.te-denom-section-row--coin td.te-denom-section-row__label {
color: #92400e;
background: #fffbeb;
}
.te-denom-section-label {
font-size: 10px;
font-weight: 700;
letter-spacing: 0.06em;
text-transform: uppercase;
color: #1d4ed8;
background: #eff6ff;
border-bottom: 1px solid #dbeafe;
border-top: 1px solid #e2e8f0;
padding: 4px 10px;
&--coin {
color: #92400e;
background: #fffbeb;
border-color: #fde68a;
}
}
.te-denom-chip {
display: inline-flex;
align-items: center;
justify-content: center;
min-width: 48px;
height: 24px;
padding: 0 8px;
border-radius: 6px;
font-size: 12px;
font-weight: 700;
background: linear-gradient(135deg, #ecfeff, #cffafe);
color: #155e75;
border: 1px solid #a5f3fc;
&--coin {
border-radius: 50%;
width: 32px;
height: 32px;
min-width: 32px;
padding: 0;
background: linear-gradient(135deg, #fef9c3, #fde68a);
color: #854d0e;
border-color: #fcd34d;
}
}
.te-denom-stepper {
display: inline-flex;
align-items: center;
gap: 6px;
margin-left: auto;
button {
width: 28px;
height: 28px;
border-radius: 50%;
border: 1.5px solid #e2e8f0;
background: #f8fafc;
font-size: 16px;
font-weight: 600;
color: #475569;
cursor: pointer;
display: flex;
align-items: center;
justify-content: center;
flex-shrink: 0;
transition: all 0.15s;
padding: 0;
line-height: 1;
&:hover:not(:disabled) {
background: #0ea5e9;
border-color: #0ea5e9;
color: #fff;
}
&:disabled {
opacity: 0.3;
cursor: not-allowed;
}
}
input {
width: 64px;
height: 32px;
border: 1.5px solid #e2e8f0;
border-radius: 8px;
text-align: center;
font-size: 14px;
font-weight: 700;
color: #1e293b;
background: #fff;
outline: none;
appearance: textfield;
transition:
border-color 0.15s,
box-shadow 0.15s;
&:focus {
border-color: #0ea5e9;
box-shadow: 0 0 0 2px rgb(14 165 233 / 15%);
}
&::-webkit-outer-spin-button,
&::-webkit-inner-spin-button {
appearance: none;
}
&::placeholder {
color: #cbd5e1;
}
}
}
.te-denom-actions {
display: flex;
gap: 8px;
margin-top: 10px;
padding-top: 10px;
border-top: 1px solid #e2e8f0;
}
.te-cr {
color: #059669 !important;
}
.te-dr {
color: #dc2626 !important;
}
.te-closing-row--neg td {
background: #fef2f2;
}
// ── Basic Info horizontal layout ─────────────────────────────────────────────
.te-basic-grid {
display: grid;
grid-template-columns: repeat(5, 1fr);
.te-balance-row {
flex-direction: column;
align-items: flex-start;
border-right: 1px solid #f1f5f9;
border-bottom: none;
padding: 10px 16px;
&:last-child {
border-right: none;
}
&--total {
border-top: none;
padding-top: 10px;
margin-top: 0;
}
.te-bal-label {
font-size: 11px;
color: #94a3b8;
margin-bottom: 4px;
}
.te-bal-value {
font-size: 15px;
min-width: unset;
}
}
@media (width <= 900px) {
grid-template-columns: repeat(3, 1fr);
}
}
// ── Action Buttons ────────────────────────────────────────────────────────────
.te-actions {
display: flex;
gap: 8px;
padding: 12px 16px;
background: #fff;
border: 1px solid #e2e8f0;
border-radius: 10px;
justify-content: flex-end;
flex-wrap: wrap;
box-shadow: 0 1px 4px rgb(0 0 0 / 5%);
}
.te-btn {
display: inline-flex;
align-items: center;
gap: 6px;
padding: 7px 18px;
border-radius: 8px;
font-size: 13px;
font-weight: 600;
font-family: inherit;
cursor: pointer;
border: 1.5px solid #e2e8f0;
background: #fff;
color: #475569;
transition: all 0.15s;
&:hover {
background: #f8fafc;
border-color: #cbd5e1;
color: #1e293b;
}
&:active {
transform: scale(0.98);
}
&--primary {
background: linear-gradient(135deg, #0ea5e9, #0284c7);
color: #fff;
border-color: transparent;
box-shadow: 0 2px 8px rgb(14 165 233 / 30%);
&:hover {
background: linear-gradient(135deg, #0284c7, #0369a1);
}
}
&--exit {
background: #fff;
color: #ef4444;
border-color: #fecaca;
&:hover {
background: #fef2f2;
}
}
&--mode {
background: linear-gradient(135deg, #8b5cf6, #7c3aed);
color: #fff;
border-color: transparent;
box-shadow: 0 2px 8px rgb(139 92 246 / 30%);
min-width: 130px;
&:hover {
background: linear-gradient(135deg, #7c3aed, #6d28d9);
}
}
}
@@ -0,0 +1,655 @@
/**
* Copyright since 2025 Mifos Initiative
*
* This Source Code Form is subject to the terms of the Mozilla Public
* License, v. 2.0. If a copy of the MPL was not distributed with this
* file, You can obtain one at http://mozilla.org/MPL/2.0/.
*/
import { Component, OnInit, inject } from '@angular/core';
import { CommonModule } from '@angular/common';
import { FormsModule } from '@angular/forms';
import { HttpClient, HttpParams } from '@angular/common/http';
import { catchError } from 'rxjs/operators';
import { forkJoin, of } from 'rxjs';
import { CashierService } from '../cashier.service';
interface TxnRow {
typ: string;
br: number;
trTp: string;
acTyp: string;
acNo: string;
ecsAcNo: string;
dbCr: string;
chequeNo: string;
amount: number | null;
description: string;
tokNo: string;
cheqDate: string;
}
interface Denomination {
id: number;
value: number;
kind: 'note' | 'coin';
label: string;
}
@Component({
selector: 'mifosx-transaction-entry',
templateUrl: './transaction-entry.component.html',
styleUrls: ['./transaction-entry.component.scss'],
standalone: true,
imports: [
CommonModule,
FormsModule
]
})
export class TransactionEntryComponent implements OnInit {
private http = inject(HttpClient);
private cashierService = inject(CashierService);
today = '';
rows: TxnRow[] = [];
selectedRow: TxnRow | null = null;
loadingAccount = false;
accountError = '';
// Full account + client data
accountDetails: any = null; // savings account API response
clientDetails: any = null; // client personal details
kycDetails: any = null; // KYC (PAN, Aadhaar, CKYC)
nominees: any[] = []; // nominee list
jointHolders: any[] = []; // joint holders
clientAddresses: any[] = []; // client addresses
acTypOptions = [
'SD',
'CA',
'FD',
'RD',
'SHR',
'CC'
];
dbCrOptions = [
'Dr',
'Cr'
];
trTpOptions = [
'C',
'D',
'T'
];
// ── Denomination ──────────────────────────────────────────────────────────
notes: Denomination[] = [];
coins: Denomination[] = [];
denominationCounts: Record<string, number> = {};
// ── Opening / Closing position ────────────────────────────────────────────
openingDenoms: any[] = [];
openingTotal = 0;
openingLoaded = false;
// ── Transaction saving state ──────────────────────────────────────────────
saving = false;
ngOnInit(): void {
const d = new Date();
this.today = `${String(d.getDate()).padStart(2, '0')}/${String(d.getMonth() + 1).padStart(2, '0')}/${d.getFullYear()}`;
this.addRow();
this.loadDenominations();
this.loadOpeningPosition();
}
private loadDenominations(): void {
this.cashierService
.getActiveDenominations()
.pipe(catchError(() => of([])))
.subscribe((list: any[]) => {
const rows: Denomination[] = (list || []).map((d: any) => ({
id: d.id,
value: d.denomination ?? d.value,
kind: d.kind as 'note' | 'coin',
label: '₹' + (d.denomination ?? d.value)
}));
const order = (a: Denomination, b: Denomination) => b.value - a.value;
this.notes = rows.filter((r) => r.kind === 'note').sort(order);
this.coins = rows.filter((r) => r.kind === 'coin').sort(order);
});
}
private loadOpeningPosition(): void {
const d = new Date();
const asOfDate = `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, '0')}-${String(d.getDate()).padStart(2, '0')}`;
this.cashierService
.getCashierPositionForMe(asOfDate)
.pipe(catchError(() => of(null)))
.subscribe((res: any) => {
this.openingDenoms = res?.denominations ?? [];
this.openingTotal = Number(res?.totalAmount ?? 0);
this.openingLoaded = true;
});
}
// ── Denomination helpers ──────────────────────────────────────────────────
denomKey(d: Denomination): string {
return `${d.kind}:${d.value}`;
}
countFor(d: Denomination): number {
return this.denominationCounts[this.denomKey(d)] || 0;
}
subtotalFor(d: Denomination): number {
return this.countFor(d) * d.value;
}
onDenomChange(d: Denomination, raw: any): void {
this.denominationCounts[this.denomKey(d)] = Math.max(0, Math.floor(Number(raw) || 0));
}
bumpDenom(d: Denomination, delta: number): void {
this.denominationCounts[this.denomKey(d)] = Math.max(0, this.countFor(d) + delta);
}
get notesTotal(): number {
return this.notes.reduce((s, d) => s + this.subtotalFor(d), 0);
}
get coinsTotal(): number {
return this.coins.reduce((s, d) => s + this.subtotalFor(d), 0);
}
get denominationTotal(): number {
return this.notesTotal + this.coinsTotal;
}
/**
* Denomination Entry order: ALL notes (desc) first, then ALL coins (desc).
* This matches how cashiers physically count cash — notes first, coins after.
* Opening/Closing panels use value-based interleaving (reporting format).
*/
get allDenominations(): Denomination[] {
const desc = (a: Denomination, b: Denomination) => b.value - a.value;
return [
...[...this.notes].sort(desc),
...[...this.coins].sort(desc)
];
}
// ── Opening / Closing ─────────────────────────────────────────────────────
get openingNotes(): any[] {
return this.openingDenoms.filter((d) => d.kind !== 'coin');
}
get openingCoins(): any[] {
return this.openingDenoms.filter((d) => d.kind === 'coin');
}
/** Opening sorted: all notes (desc) then all coins (desc) */
get sortedOpeningDenoms(): any[] {
const desc = (a: any, b: any) => b.denominationValue - a.denominationValue;
const notes = this.openingDenoms.filter((d) => d.kind !== 'coin').sort(desc);
const coins = this.openingDenoms.filter((d) => d.kind === 'coin').sort(desc);
return [
...notes,
...coins
];
}
/** Closing sorted: all notes (desc) then all coins (desc) */
get sortedClosingDenoms(): any[] {
const desc = (a: any, b: any) => b.denominationValue - a.denominationValue;
const notes = this.closingDenoms.filter((d) => d.kind !== 'coin').sort(desc);
const coins = this.closingDenoms.filter((d) => d.kind === 'coin').sort(desc);
return [
...notes,
...coins
];
}
get closingTotal(): number {
const row = this.selectedRow;
const sign = row?.dbCr === 'Cr' ? 1 : -1; // Cr = Deposit (+), Dr = Withdrawal (-)
return this.openingTotal + this.denominationTotal * sign;
}
get closingDenoms(): any[] {
const openMap = new Map<string, any>();
for (const d of this.openingDenoms) {
openMap.set(`${d.kind}:${d.denominationValue}`, d);
}
const sign = this.selectedRow?.dbCr === 'Cr' ? 1 : -1;
const result: any[] = [];
for (const denom of [
...this.notes,
...this.coins
]) {
const key = this.denomKey(denom);
const open = openMap.get(key);
const openCount = open?.count ?? 0;
const entered = this.countFor(denom);
if (openCount === 0 && entered === 0) continue;
result.push({
denominationValue: denom.value,
kind: denom.kind,
openCount,
entered,
closingCount: openCount + entered * sign
});
}
return result.sort((a, b) => b.denominationValue - a.denominationValue);
}
applyDenominationTotal(): void {
if (this.selectedRow) {
this.selectedRow.amount = this.denominationTotal;
}
}
clearDenominations(): void {
this.denominationCounts = {};
}
addRow(): void {
this.rows.push({
typ: '~',
br: 1,
trTp: '',
acTyp: 'SD',
acNo: '',
ecsAcNo: '',
dbCr: '',
chequeNo: '',
amount: null,
description: '',
tokNo: '',
cheqDate: ''
});
}
onAcNoBlur(row: TxnRow, index: number): void {
if (!row.acNo.trim()) return;
this.selectedRow = row;
this.loadAccountDetails(row.acNo.trim(), row.acTyp);
}
onRowSelect(row: TxnRow, index: number): void {
this.selectedRow = row;
// Only reload if account number changed or nothing is loaded yet
const alreadyLoaded = this.accountDetails && this.accountDetails.accountNo === row.acNo.trim();
if (row.acNo.trim() && !alreadyLoaded) {
this.loadAccountDetails(row.acNo.trim(), row.acTyp);
}
}
private loadAccountDetails(acNo: string, acTyp: string): void {
this.loadingAccount = true;
this.accountDetails = null;
this.clientDetails = null;
this.kycDetails = null;
this.nominees = [];
this.jointHolders = [];
this.clientAddresses = [];
this.accountError = '';
const cfg = this.accountTypeConfig(acTyp);
// Step 1: search by accountNo on the correct endpoint for this account type
this.http
.get<any>(`${cfg.list}?accountNo=${encodeURIComponent(acNo)}&offset=0&limit=1`)
.pipe(catchError(() => of(null)))
.subscribe((res: any) => {
const items: any[] = res?.pageItems ?? (Array.isArray(res) ? res : []);
if (items.length > 0 && items[0].id) {
this.fetchAllDetails(items[0].id, acNo, cfg);
return;
}
// Fallback: global search
this.http
.get<any>(`/search?query=${encodeURIComponent(acNo)}&resource=${cfg.search}`)
.pipe(catchError(() => of([])))
.subscribe((found: any) => {
const list: any[] = Array.isArray(found) ? found : [];
if (list.length > 0 && list[0].entityId) {
this.fetchAllDetails(list[0].entityId, acNo, cfg);
} else {
this.loadingAccount = false;
this.accountError = 'Account not found for: ' + acNo;
}
});
});
}
/** Maps UI account type → { listEndpoint, detailEndpoint, nomineeType, searchResource } */
private accountTypeConfig(acTyp: string): { list: string; detail: string; nominee: string; search: string } {
const t = acTyp?.toUpperCase();
if (t === 'CA' || t === 'CB' || t === 'CC') {
return { list: '/currentaccounts', detail: '/currentaccounts', nominee: 'CB', search: 'currentaccount' };
}
if (t === 'FD') {
return { list: '/fixeddepositaccounts', detail: '/fixeddepositaccounts', nominee: 'FD', search: 'fixeddeposit' };
}
if (t === 'RD') {
return {
list: '/recurringdepositaccounts',
detail: '/recurringdepositaccounts',
nominee: 'RD',
search: 'recurringdeposit'
};
}
// Default: SD / SB — Savings
return { list: '/savingsaccounts', detail: '/savingsaccounts', nominee: 'SB', search: 'saving' };
}
/** Maps UI account type code → backend accountType code for nominee filter */
private toNomineeAccountType(acTyp: string): string {
return this.accountTypeConfig(acTyp).nominee;
}
/** Parallel fetch: account (with associations) + client + KYC + nominees */
private fetchAllDetails(
accountId: number,
acNo: string,
cfg: { list: string; detail: string; nominee: string; search: string }
): void {
this.http
.get<any>(`${cfg.detail}/${accountId}?associations=all`)
.pipe(catchError(() => of(null)))
.subscribe((acc: any) => {
if (!acc) {
this.loadingAccount = false;
this.accountError = 'Could not load account details.';
return;
}
this.accountDetails = acc;
this.jointHolders = acc.jointHolders ?? [];
const clientId = acc.clientId?.toString();
if (!clientId) {
this.loadingAccount = false;
return;
}
const acType = cfg.nominee;
// Parallel: client + KYC + nominees + addresses + family members
forkJoin({
client: this.http.get<any>(`/clients/${clientId}`).pipe(catchError(() => of(null))),
kyc: this.http.get<any>(`/clients/${clientId}/kyc`).pipe(catchError(() => of([]))),
nominees: this.http
.get<any>(`/clients/${clientId}/nominees?accountNo=${acNo}&accountType=${acType}`)
.pipe(catchError(() => of([]))),
addresses: this.http.get<any>(`/client/${clientId}/addresses`).pipe(catchError(() => of([]))),
family: this.http.get<any>(`/clients/${clientId}/familymembers`).pipe(catchError(() => of([])))
}).subscribe(({ client, kyc, nominees, addresses, family }) => {
this.loadingAccount = false;
this.clientDetails = { ...client, familyMembers: Array.isArray(family) ? family : [] };
const kycArr: any[] = Array.isArray(kyc) ? kyc : kyc ? [kyc] : [];
this.kycDetails = kycArr[0] ?? null;
this.nominees = Array.isArray(nominees) ? nominees : [];
this.clientAddresses = Array.isArray(addresses) ? addresses : [];
});
});
}
// ── Balance getters ────────────────────────────────────────────────────────
get currentBalance(): number {
return this.accountDetails?.summary?.accountBalance ?? 0;
}
get clearBalance(): number {
return this.accountDetails?.summary?.availableBalance ?? this.currentBalance;
}
get todayClgAmount(): number {
return this.accountDetails?.summary?.totalDepositsPostMaturityLevel ?? 0;
}
get freezeAmount(): number {
return this.accountDetails?.summary?.holdAmountBalance ?? 0;
}
get withdrawableAmount(): number {
const avail = this.accountDetails?.summary?.availableBalance ?? this.currentBalance;
return Math.max(0, avail - this.freezeAmount);
}
get currencySymbol(): string {
return this.accountDetails?.currency?.displaySymbol ?? '₹';
}
get modeOfOperationLabel(): string {
const modeMap: Record<string, string> = {
FORMER_OR_SURVIVOR: 'Former or Survivor',
EITHER_OR_SURVIVOR: 'Either or Survivor',
ANYONE_OR_SURVIVOR: 'Any One or Survivor',
JOINTLY: 'Both Jointly',
SINGLE: 'Single',
OTHER: 'Other'
};
const mode = this.accountDetails?.modeOfOperation ?? '';
return modeMap[mode] ?? mode.replace(/_/g, ' ');
}
// ── Client detail getters ─────────────────────────────────────────────────
get clientName(): string {
return this.clientDetails?.displayName ?? this.accountDetails?.clientName ?? '—';
}
get guardianName(): string {
// S/D/W/H/O derived from family members
// API returns: firstName, middleName, lastName, relationship
const family: any[] = this.clientDetails?.familyMembers ?? [];
if (!family.length) return this.clientDetails?.guardianClientName ?? '—';
const priority = [
'FATHER',
'MOTHER',
'HUSBAND',
'WIFE',
'SPOUSE'
];
const prefixMap: Record<string, string> = {
FATHER: 'S/O',
MOTHER: 'D/O',
HUSBAND: 'W/O',
WIFE: 'H/O',
SPOUSE: 'W/O'
};
for (const rel of priority) {
const member = family.find((m: any) => (m.relationship ?? m.relationshipName ?? '').toUpperCase() === rel);
if (member) {
// API uses camelCase: firstName, middleName, lastName
const name = [
member.firstName,
member.middleName,
member.lastName
]
.filter((v) => v && v.trim())
.join(' ')
.trim();
return name ? `${prefixMap[rel] ?? rel} ${name}` : (prefixMap[rel] ?? rel);
}
}
// Fallback: first family member
const m = family[0];
const name = [
m.firstName,
m.middleName,
m.lastName
]
.filter((v) => v && v.trim())
.join(' ')
.trim();
return name || '—';
}
get clientAddress(): string {
// From /client/{id}/addresses — AddressData fields: addressLine1, townVillage, city, stateName, postalCode
if (this.clientAddresses.length > 0) {
const a = this.clientAddresses[0];
return [
a.addressLine1,
a.addressLine2,
a.addressLine3,
a.townVillage,
a.city,
a.countyDistrict,
a.stateName ?? a.stateProvinceLabel,
a.postalCode
]
.filter((v) => v && v.toString().trim())
.join(', ');
}
return '—';
}
get clientPhone(): string {
const mob = this.clientDetails?.mobileNo ?? this.accountDetails?.clientMobileNo;
const alt = this.clientDetails?.alternateMobileNo;
return (
[
mob,
alt
]
.filter(Boolean)
.join(' / ') || '—'
);
}
get clientEmail(): string {
return this.clientDetails?.emailAddress ?? '—';
}
get officeName(): string {
return this.accountDetails?.officeName ?? this.clientDetails?.officeName ?? '—';
}
get openingDate(): string {
const d = this.accountDetails?.timeline?.submittedOnDate;
return d ? `${d[2]}/${d[1]}/${d[0]}` : '—';
}
get lastTransactionDate(): string {
const d = this.accountDetails?.lastActiveTransactionDate;
return d ? `${d[2]}/${d[1]}/${d[0]}` : '—';
}
get panNumber(): string {
return this.kycDetails?.panNumber ?? this.kycDetails?.pan_number ?? '—';
}
get aadhaarNumber(): string {
return this.kycDetails?.aadhaarNumberMasked ?? this.kycDetails?.aadhaar_number_masked ?? 'XXXX/XXXX/XXXX';
}
get ckycNumber(): string {
return this.kycDetails?.ckycNumber ?? this.kycDetails?.ckyc_number ?? '—';
}
get kycStatus(): string {
const s = this.kycDetails?.kycStatus ?? this.kycDetails?.kyc_status;
if (!s) return '—';
const map: Record<number, string> = { 100: 'Pending', 200: 'In Progress', 300: 'Verified', 400: 'Rejected' };
return map[s] ?? `KYC-${s}`;
}
get cifNumber(): string {
return this.clientDetails?.cifNumber ?? this.clientDetails?.cif_number ?? '—';
}
onSave(): void {
const row = this.selectedRow;
if (!row || !row.acNo.trim() || !this.accountDetails) return;
const amount = row.amount ?? this.denominationTotal;
if (!amount || amount <= 0) {
alert('Please enter amount or denomination.');
return;
}
const isDeposit = row.dbCr === 'Cr';
const command = isDeposit ? 'deposit' : 'withdrawal';
const accountId = this.accountDetails.id;
const cfg = this.accountTypeConfig(row.acTyp);
const today = new Date();
const txnDate = `${String(today.getDate()).padStart(2, '0')}/${String(today.getMonth() + 1).padStart(2, '0')}/${today.getFullYear()}`;
const payload: any = {
transactionDate: txnDate,
transactionAmount: amount,
dateFormat: 'dd/MM/yyyy',
locale: 'en',
note: row.description || null
};
if (row.chequeNo) payload['checkNumber'] = row.chequeNo;
if (this.denominationTotal > 0) {
payload['denominationBreakdown'] = [
...this.notes,
...this.coins
]
.filter((d) => this.countFor(d) > 0)
.map((d) => ({
denominationId: d.id,
denominationValue: d.value,
kind: d.kind,
count: this.countFor(d),
subtotal: this.subtotalFor(d)
}));
}
this.saving = true;
const params = new HttpParams().set('command', command);
this.http
.post(`${cfg.detail}/${accountId}/transactions`, payload, { params })
.pipe(
catchError((err) => {
const msg =
err?.error?.errors?.[0]?.defaultUserMessage ?? err?.error?.developerMessage ?? 'Transaction failed.';
alert(msg);
this.saving = false;
return of(null);
})
)
.subscribe((res: any) => {
this.saving = false;
if (res) {
alert(`Transaction posted. Ref: ${res.resourceId ?? res.transactionId ?? 'N/A'}`);
this.clearDenominations();
this.loadOpeningPosition();
this.loadAccountDetails(row.acNo.trim(), row.acTyp);
}
});
}
onCancel(): void {
this.accountDetails = null;
this.clientDetails = null;
this.kycDetails = null;
this.nominees = [];
this.jointHolders = [];
this.clientAddresses = [];
this.accountError = '';
this.selectedRow = null;
this.rows = [];
this.addRow();
}
onSearch(): void {
if (this.selectedRow?.acNo.trim()) {
this.loadAccountDetails(this.selectedRow.acNo.trim(), this.selectedRow.acTyp);
}
}
onExit(): void {
history.back();
}
}
@@ -388,6 +388,30 @@ export class AuthenticationService {
return JSON.parse(this.storage.getItem(this.credentialsStorageKey));
}
/**
* Returns true ONLY for regular cashier role (not Head Cashier).
* Regular cashier → full-screen transaction entry, no nav.
* Head Cashier → normal CBS interface, all menus visible.
*/
isCashierUser(): boolean {
const creds = this.getCredentials();
if (!creds || !creds.permissions) return false;
// Super-admin / all-functions users are never cashier-only
if (creds.permissions.includes('ALL_FUNCTIONS')) return false;
if (creds.roles && Array.isArray(creds.roles)) {
const roleNames: string[] = creds.roles.map((r: any) => (r.name || r.role || '').toLowerCase().trim());
// Head Cashier gets full access — NOT a cashier-only user
if (roleNames.some((n) => n.includes('head cashier') || n === 'head_cashier')) {
return false;
}
// Regular CASHIER role → cashier-only mode
if (roleNames.some((n) => n === 'cashier')) {
return true;
}
}
return false;
}
/**
* Sets the user credentials.
*
+217 -133
View File
@@ -8,7 +8,7 @@
<!-- Application Shell -->
<mat-sidenav-container id="mifosx-shell-container" autosize>
<!-- Sidenav -->
<!-- Sidenav — always rendered so #sidenav ref exists; closed for cashier mode -->
<mat-sidenav
#sidenav
class="sidebar-panel"
@@ -18,15 +18,17 @@
}"
[attr.role]="(isHandset$ | async) ? 'dialog' : 'navigation'"
[mode]="(isHandset$ | async) ? 'over' : 'side'"
[opened]="(isHandset$ | async) === false"
[opened]="!hideShellLayout && (isHandset$ | async) === false"
>
<mifosx-sidenav [sidenavCollapsed]="sidenavCollapsed"></mifosx-sidenav>
@if (!hideShellLayout) {
<mifosx-sidenav [sidenavCollapsed]="sidenavCollapsed"></mifosx-sidenav>
}
</mat-sidenav>
<!-- Content -->
<mat-sidenav-content class="sidenav">
<!-- Sidebar Toggle Handle (hidden on small/handset screens) -->
@if ((isHandset$ | async) === false) {
<mat-sidenav-content class="sidenav" [class.sidenav--fullscreen]="hideShellLayout">
<!-- Sidebar Toggle Handle — hidden in cashier full-screen mode -->
@if (!hideShellLayout && (isHandset$ | async) === false) {
<div
class="sidebar-handle"
[class.collapsed]="sidenavCollapsed"
@@ -52,9 +54,78 @@
</div>
}
<!-- Toolbar -->
<!-- Toolbar always visible (shows CBSNexora, Branch, Session info) -->
<mifosx-toolbar [sidenav]="sidenav" (collapse)="toggleCollapse($event)"></mifosx-toolbar>
<!-- Cashier simplified menu bar — visible only in cashier full-screen mode -->
@if (hideShellLayout) {
<nav class="cashier-nav">
<a
class="cashier-nav__item cashier-nav__item--primary"
[routerLink]="['/cashier/transaction-entry']"
routerLinkActive="cashier-nav__item--active"
>
<fa-icon icon="keyboard"></fa-icon> Transaction Entry
</a>
<a
class="cashier-nav__item"
[routerLink]="['/cashier/share-collection']"
routerLinkActive="cashier-nav__item--active"
>
<fa-icon icon="hand-holding-usd"></fa-icon> Share Collection
</a>
<a
class="cashier-nav__item"
[routerLink]="['/cashier/cash-exchange']"
routerLinkActive="cashier-nav__item--active"
>
<fa-icon icon="exchange"></fa-icon> Cash Exchange
</a>
<a
class="cashier-nav__item"
[routerLink]="['/cashier/exchange-detail']"
routerLinkActive="cashier-nav__item--active"
>
<fa-icon icon="globe"></fa-icon> Exchange Detail
</a>
<a
class="cashier-nav__item"
[routerLink]="['/cashier/inter-cashier']"
routerLinkActive="cashier-nav__item--active"
>
<fa-icon icon="exchange-alt"></fa-icon> Inter-Cashier
</a>
<a
class="cashier-nav__item"
[routerLink]="['/cashier/excess-report']"
routerLinkActive="cashier-nav__item--active"
>
<fa-icon icon="exclamation-circle"></fa-icon> Excess Report
</a>
<a
class="cashier-nav__item"
[routerLink]="['/cashier/cashier-position']"
routerLinkActive="cashier-nav__item--active"
>
<fa-icon icon="wallet"></fa-icon> Position
</a>
<a
class="cashier-nav__item"
[routerLink]="['/cashier/cash-history']"
routerLinkActive="cashier-nav__item--active"
>
<fa-icon icon="history"></fa-icon> Cash History
</a>
<a
class="cashier-nav__item"
[routerLink]="['/cashier/closing-report']"
routerLinkActive="cashier-nav__item--active"
>
<fa-icon icon="file-alt"></fa-icon> Closing Report
</a>
</nav>
}
<!-- Live KPI ticker items — referenced twice below for seamless marquee loop -->
<ng-template #kpiTickerItems>
<span class="ti"><span class="ti-k">CASH</span><span class="ti-v amber">₹14,82,500</span></span>
@@ -206,133 +277,141 @@
════════ END OLD ORDER ════════
-->
<div class="main-header">
<!-- Row 1 — Client Master · CFCFRMS · Approvals · Cashier · Clearing · Deposits · Advances · Share · NPA · General Ledger · Sub Ledger -->
<div class="nav-row r1">
<a class="ni" tabindex="0" [matMenuTriggerFor]="institutionMenu" *mifosxHasPermission="'MENU_INSTITUTION'">
<fa-icon icon="users-cog" class="ni-ico"></fa-icon>Client Master
<fa-icon icon="caret-down" class="ni-caret"></fa-icon>
</a>
<a class="ni" tabindex="0" routerLink="/cfcfrms" *mifosxHasPermission="'SIDEBAR_CFCFRMS'">
<fa-icon icon="user-shield" class="ni-ico"></fa-icon>CFCFRMS
</a>
<a
class="ni"
tabindex="0"
routerLink="/checker-inbox-and-tasks/checker-inbox"
*mifosxHasPermission="'MENU_TASKS'"
>
<fa-icon icon="check" class="ni-ico"></fa-icon>Approvals
</a>
<a class="ni" tabindex="0" [matMenuTriggerFor]="cashierMenu" *mifosxHasPermission="'MENU_CASHIER'">
<fa-icon icon="money-bill" class="ni-ico"></fa-icon>Cashier
<fa-icon icon="caret-down" class="ni-caret"></fa-icon>
</a>
<a class="ni" tabindex="0" [matMenuTriggerFor]="clearingMenu" *mifosxHasPermission="'MENU_CLEARING'">
<fa-icon icon="exchange" class="ni-ico"></fa-icon>Clearing
<fa-icon icon="caret-down" class="ni-caret"></fa-icon>
</a>
<a class="ni" tabindex="0" [matMenuTriggerFor]="depositsMenu" *mifosxHasPermission="'MENU_DEPOSITS'">
<fa-icon icon="piggy-bank" class="ni-ico"></fa-icon>Deposits
<fa-icon icon="caret-down" class="ni-caret"></fa-icon>
</a>
<a class="ni" tabindex="0" [matMenuTriggerFor]="advancesMenu" *mifosxHasPermission="'MENU_ADVANCES'">
<fa-icon icon="hand-holding-usd" class="ni-ico"></fa-icon>Advances
<fa-icon icon="caret-down" class="ni-caret"></fa-icon>
</a>
<a class="ni" tabindex="0" [matMenuTriggerFor]="shareMenu" *mifosxHasPermission="'MENU_SHARE'">
<fa-icon icon="coins" class="ni-ico"></fa-icon>Share
<fa-icon icon="caret-down" class="ni-caret"></fa-icon>
</a>
<a class="ni" tabindex="0" [matMenuTriggerFor]="npaMenu" *mifosxHasPermission="'MENU_NPA'">
<fa-icon icon="exclamation-triangle" class="ni-ico"></fa-icon>NPA
<fa-icon icon="caret-down" class="ni-caret"></fa-icon>
</a>
<a
class="ni"
tabindex="0"
(click)="glMenuOpen = !glMenuOpen; subLedgerOpen = false"
*mifosxHasPermission="'MENU_GENERAL_LEDGER'"
>
<fa-icon icon="book-open" class="ni-ico"></fa-icon>General Ledger
<fa-icon [icon]="glMenuOpen ? 'caret-up' : 'caret-down'" class="ni-caret"></fa-icon>
</a>
<a
class="ni"
tabindex="0"
(click)="subLedgerOpen = !subLedgerOpen; glMenuOpen = false"
*mifosxHasPermission="'MENU_SUB_LEDGER'"
>
<fa-icon icon="book" class="ni-ico"></fa-icon>Sub Ledger
<fa-icon [icon]="subLedgerOpen ? 'caret-up' : 'caret-down'" class="ni-caret"></fa-icon>
</a>
@if (!hideShellLayout) {
<div class="main-header">
<!-- Row 1 — Client Master · CFCFRMS · Approvals · Cashier · Clearing · Deposits · Advances · Share · NPA · General Ledger · Sub Ledger -->
<div class="nav-row r1">
<a class="ni" tabindex="0" [matMenuTriggerFor]="institutionMenu" *mifosxHasPermission="'MENU_INSTITUTION'">
<fa-icon icon="users-cog" class="ni-ico"></fa-icon>Client Master
<fa-icon icon="caret-down" class="ni-caret"></fa-icon>
</a>
<a class="ni" tabindex="0" routerLink="/cfcfrms" *mifosxHasPermission="'SIDEBAR_CFCFRMS'">
<fa-icon icon="user-shield" class="ni-ico"></fa-icon>CFCFRMS
</a>
<a
class="ni"
tabindex="0"
routerLink="/checker-inbox-and-tasks/checker-inbox"
*mifosxHasPermission="'MENU_TASKS'"
>
<fa-icon icon="check" class="ni-ico"></fa-icon>Approvals
</a>
<a class="ni" tabindex="0" [matMenuTriggerFor]="cashierMenu" *mifosxHasPermission="'MENU_CASHIER'">
<fa-icon icon="money-bill" class="ni-ico"></fa-icon>Cashier
<fa-icon icon="caret-down" class="ni-caret"></fa-icon>
</a>
<a class="ni" tabindex="0" [matMenuTriggerFor]="clearingMenu" *mifosxHasPermission="'MENU_CLEARING'">
<fa-icon icon="exchange" class="ni-ico"></fa-icon>Clearing
<fa-icon icon="caret-down" class="ni-caret"></fa-icon>
</a>
<a class="ni" tabindex="0" [matMenuTriggerFor]="depositsMenu" *mifosxHasPermission="'MENU_DEPOSITS'">
<fa-icon icon="piggy-bank" class="ni-ico"></fa-icon>Deposits
<fa-icon icon="caret-down" class="ni-caret"></fa-icon>
</a>
<a class="ni" tabindex="0" [matMenuTriggerFor]="advancesMenu" *mifosxHasPermission="'MENU_ADVANCES'">
<fa-icon icon="hand-holding-usd" class="ni-ico"></fa-icon>Advances
<fa-icon icon="caret-down" class="ni-caret"></fa-icon>
</a>
<a class="ni" tabindex="0" [matMenuTriggerFor]="shareMenu" *mifosxHasPermission="'MENU_SHARE'">
<fa-icon icon="coins" class="ni-ico"></fa-icon>Share
<fa-icon icon="caret-down" class="ni-caret"></fa-icon>
</a>
<a class="ni" tabindex="0" [matMenuTriggerFor]="npaMenu" *mifosxHasPermission="'MENU_NPA'">
<fa-icon icon="exclamation-triangle" class="ni-ico"></fa-icon>NPA
<fa-icon icon="caret-down" class="ni-caret"></fa-icon>
</a>
<a
class="ni"
tabindex="0"
(click)="glMenuOpen = !glMenuOpen; subLedgerOpen = false"
*mifosxHasPermission="'MENU_GENERAL_LEDGER'"
>
<fa-icon icon="book-open" class="ni-ico"></fa-icon>General Ledger
<fa-icon [icon]="glMenuOpen ? 'caret-up' : 'caret-down'" class="ni-caret"></fa-icon>
</a>
<a
class="ni"
tabindex="0"
(click)="subLedgerOpen = !subLedgerOpen; glMenuOpen = false"
*mifosxHasPermission="'MENU_SUB_LEDGER'"
>
<fa-icon icon="book" class="ni-ico"></fa-icon>Sub Ledger
<fa-icon [icon]="subLedgerOpen ? 'caret-up' : 'caret-down'" class="ni-caret"></fa-icon>
</a>
</div>
<!-- Row 2 — Accounting · CKYC · Insurance · Digital · HRM · Admin · RBI · Reports · Others · Reconciliation · Documents Hub -->
<div class="nav-row r2">
<a class="ni" tabindex="0" routerLink="/accounting" *mifosxHasPermission="'MENU_ACCOUNTING'">
<fa-icon icon="money-bill-alt" class="ni-ico"></fa-icon>Accounting
</a>
<a class="ni" tabindex="0" [matMenuTriggerFor]="ckycMenu" *mifosxHasPermission="'MENU_CKYC_MGMT'">
<fa-icon icon="fingerprint" class="ni-ico"></fa-icon>CKYC
<fa-icon icon="caret-down" class="ni-caret"></fa-icon>
</a>
<a class="ni" tabindex="0" [matMenuTriggerFor]="insuranceMenu" *mifosxHasPermission="'MENU_INSURANCE'">
<fa-icon icon="shield-alt" class="ni-ico"></fa-icon>Insurance
<fa-icon icon="caret-down" class="ni-caret"></fa-icon>
</a>
<a class="ni" tabindex="0" [matMenuTriggerFor]="digitalMenu" *mifosxHasPermission="'MENU_DIGITAL'">
<fa-icon icon="mobile-alt" class="ni-ico"></fa-icon>Digital
<fa-icon icon="caret-down" class="ni-caret"></fa-icon>
</a>
<a class="ni" tabindex="0" [matMenuTriggerFor]="hrmMenu" *mifosxHasPermission="'MENU_HRM'">
<fa-icon icon="money-check" class="ni-ico"></fa-icon>HRM
<fa-icon icon="caret-down" class="ni-caret"></fa-icon>
</a>
<a class="ni" tabindex="0" [matMenuTriggerFor]="adminMenu" *mifosxHasPermission="'MENU_ADMIN'">
<fa-icon icon="shield-alt" class="ni-ico"></fa-icon>Admin
<fa-icon icon="caret-down" class="ni-caret"></fa-icon>
</a>
<a
class="ni"
tabindex="0"
(click)="rbiMenuOpen = !rbiMenuOpen; glMenuOpen = false"
*mifosxHasPermission="'MENU_RBI'"
>
<fa-icon icon="university" class="ni-ico"></fa-icon>RBI
<fa-icon [icon]="rbiMenuOpen ? 'caret-up' : 'caret-down'" class="ni-caret"></fa-icon>
</a>
<a class="ni" tabindex="0" [matMenuTriggerFor]="reportsMenu" *mifosxHasPermission="'MENU_REPORTS'">
<fa-icon icon="chart-bar" class="ni-ico"></fa-icon>Reports
<fa-icon icon="caret-down" class="ni-caret"></fa-icon>
</a>
<a class="ni" tabindex="0" [matMenuTriggerFor]="othersMenu" *mifosxHasPermission="'MENU_OTHERS'">
<fa-icon icon="folder-open" class="ni-ico"></fa-icon>Others
<fa-icon icon="caret-down" class="ni-caret"></fa-icon>
</a>
<a
class="ni"
tabindex="0"
[matMenuTriggerFor]="reconciliationMenu"
*mifosxHasPermission="'MENU_CONFIG_WIZARD'"
>
<fa-icon icon="sync-alt" class="ni-ico"></fa-icon>Reconciliation
<fa-icon icon="caret-down" class="ni-caret"></fa-icon>
</a>
<a class="ni" tabindex="0" [matMenuTriggerFor]="letterMgmtMenu" *mifosxHasPermission="'MENU_LETTER_MGMT'">
<fa-icon icon="envelope" class="ni-ico"></fa-icon>Documents Hub
<fa-icon icon="caret-down" class="ni-caret"></fa-icon>
</a>
</div>
</div>
<!-- Row 2 — Accounting · CKYC · Insurance · Digital · HRM · Admin · RBI · Reports · Others · Reconciliation · Documents Hub -->
<div class="nav-row r2">
<a class="ni" tabindex="0" routerLink="/accounting" *mifosxHasPermission="'MENU_ACCOUNTING'">
<fa-icon icon="money-bill-alt" class="ni-ico"></fa-icon>Accounting
</a>
<a class="ni" tabindex="0" [matMenuTriggerFor]="ckycMenu" *mifosxHasPermission="'MENU_CKYC_MGMT'">
<fa-icon icon="fingerprint" class="ni-ico"></fa-icon>CKYC
<fa-icon icon="caret-down" class="ni-caret"></fa-icon>
</a>
<a class="ni" tabindex="0" [matMenuTriggerFor]="insuranceMenu" *mifosxHasPermission="'MENU_INSURANCE'">
<fa-icon icon="shield-alt" class="ni-ico"></fa-icon>Insurance
<fa-icon icon="caret-down" class="ni-caret"></fa-icon>
</a>
<a class="ni" tabindex="0" [matMenuTriggerFor]="digitalMenu" *mifosxHasPermission="'MENU_DIGITAL'">
<fa-icon icon="mobile-alt" class="ni-ico"></fa-icon>Digital
<fa-icon icon="caret-down" class="ni-caret"></fa-icon>
</a>
<a class="ni" tabindex="0" [matMenuTriggerFor]="hrmMenu" *mifosxHasPermission="'MENU_HRM'">
<fa-icon icon="money-check" class="ni-ico"></fa-icon>HRM
<fa-icon icon="caret-down" class="ni-caret"></fa-icon>
</a>
<a class="ni" tabindex="0" [matMenuTriggerFor]="adminMenu" *mifosxHasPermission="'MENU_ADMIN'">
<fa-icon icon="shield-alt" class="ni-ico"></fa-icon>Admin
<fa-icon icon="caret-down" class="ni-caret"></fa-icon>
</a>
<a
class="ni"
tabindex="0"
(click)="rbiMenuOpen = !rbiMenuOpen; glMenuOpen = false"
*mifosxHasPermission="'MENU_RBI'"
>
<fa-icon icon="university" class="ni-ico"></fa-icon>RBI
<fa-icon [icon]="rbiMenuOpen ? 'caret-up' : 'caret-down'" class="ni-caret"></fa-icon>
</a>
<a class="ni" tabindex="0" [matMenuTriggerFor]="reportsMenu" *mifosxHasPermission="'MENU_REPORTS'">
<fa-icon icon="chart-bar" class="ni-ico"></fa-icon>Reports
<fa-icon icon="caret-down" class="ni-caret"></fa-icon>
</a>
<a class="ni" tabindex="0" [matMenuTriggerFor]="othersMenu" *mifosxHasPermission="'MENU_OTHERS'">
<fa-icon icon="folder-open" class="ni-ico"></fa-icon>Others
<fa-icon icon="caret-down" class="ni-caret"></fa-icon>
</a>
<a class="ni" tabindex="0" [matMenuTriggerFor]="reconciliationMenu" *mifosxHasPermission="'MENU_CONFIG_WIZARD'">
<fa-icon icon="sync-alt" class="ni-ico"></fa-icon>Reconciliation
<fa-icon icon="caret-down" class="ni-caret"></fa-icon>
</a>
<a class="ni" tabindex="0" [matMenuTriggerFor]="letterMgmtMenu" *mifosxHasPermission="'MENU_LETTER_MGMT'">
<fa-icon icon="envelope" class="ni-ico"></fa-icon>Documents Hub
<fa-icon icon="caret-down" class="ni-caret"></fa-icon>
</a>
<!-- LIVE KPI TICKER (below the menu — pauses on hover) -->
<div class="kpi-ticker" role="marquee" aria-label="Live key-performance ticker">
<span class="kpi-live">
<span class="kpi-live-dot"></span>
LIVE
</span>
<div class="kpi-track">
<!-- Render the item block twice so the marquee loops seamlessly. -->
<ng-container *ngTemplateOutlet="kpiTickerItems"></ng-container>
<ng-container *ngTemplateOutlet="kpiTickerItems"></ng-container>
</div>
</div>
</div>
<!-- LIVE KPI TICKER (below the menu — pauses on hover) -->
<div class="kpi-ticker" role="marquee" aria-label="Live key-performance ticker">
<span class="kpi-live">
<span class="kpi-live-dot"></span>
LIVE
</span>
<div class="kpi-track">
<!-- Render the item block twice so the marquee loops seamlessly. -->
<ng-container *ngTemplateOutlet="kpiTickerItems"></ng-container>
<ng-container *ngTemplateOutlet="kpiTickerItems"></ng-container>
</div>
</div>
}
<!-- end @if (!hideShellLayout) -->
<!-- Row 1 Menus -->
<mat-menu #institutionMenu="matMenu" [overlapTrigger]="false" class="nav-dropdown">
@@ -728,6 +807,9 @@
<button mat-menu-item tabindex="0" [routerLink]="['/cashier/share-collection']">
<fa-icon icon="hand-holding-usd" class="menu-item-icon"></fa-icon> Share Collection
</button>
<button mat-menu-item tabindex="0" [routerLink]="['/cashier/transaction-entry']">
<fa-icon icon="keyboard" class="menu-item-icon"></fa-icon> Transaction Entry
</button>
</mat-menu>
<mat-menu #depositsMenu="matMenu" class="deposits-mega-panel" [overlapTrigger]="false">
@@ -1607,7 +1689,9 @@
<!-- Content -->
<mifosx-content></mifosx-content>
<!-- Footer -->
<mifosx-footer [styleClass]="'main-page'"></mifosx-footer>
<!-- Footer — hidden in cashier full-screen mode -->
@if (!hideShellLayout) {
<mifosx-footer [styleClass]="'main-page'"></mifosx-footer>
}
</mat-sidenav-content>
</mat-sidenav-container>
+62
View File
@@ -87,10 +87,72 @@ $bar: 190px;
$foo: $bar * 8;
$color: #e2e4ec;
// ── Cashier simplified menu bar ──────────────────────────────────────────────
.cashier-nav {
display: flex;
align-items: center;
flex-wrap: wrap;
gap: 2px;
background: #1a2744;
padding: 4px 12px;
border-bottom: 2px solid #0ea5e9;
&__item {
display: inline-flex;
align-items: center;
gap: 5px;
padding: 5px 12px;
border-radius: 4px;
font-size: 12px;
font-weight: 600;
color: #94a3b8;
text-decoration: none;
transition:
background 0.15s,
color 0.15s;
white-space: nowrap;
fa-icon {
font-size: 12px;
}
&:hover {
background: #223060;
color: #e2e8f0;
}
&--active {
background: #0ea5e9;
color: #fff;
}
&--primary {
background: #1353a0;
color: #fff;
font-weight: 700;
&:hover {
background: #1a6ac8;
}
}
}
}
.sidenav {
overflow-x: hidden;
position: relative;
&--fullscreen {
margin-left: 0 !important;
width: 100vw !important;
max-width: 100vw !important;
mifosx-content {
padding: 0 !important;
margin: 0 !important;
}
}
// Constrain routed page content so tables/cards shrink when sidebar opens
mifosx-content {
display: block;
+19 -1
View File
@@ -10,10 +10,12 @@
import { BreakpointObserver, Breakpoints } from '@angular/cdk/layout';
import { ChangeDetectorRef, Component, OnDestroy, OnInit, inject } from '@angular/core';
import { MatDialog } from '@angular/material/dialog';
import { Router, NavigationEnd } from '@angular/router';
import { AuthenticationService } from '../authentication/authentication.service';
/** rxjs Imports */
import { Observable, Subscription } from 'rxjs';
import { map } from 'rxjs/operators';
import { map, filter } from 'rxjs/operators';
/** Custom Services */
import { ProgressBarService } from '../progress-bar/progress-bar.service';
@@ -60,6 +62,8 @@ export class ShellComponent implements OnInit, OnDestroy {
private progressBarService = inject(ProgressBarService);
private cdr = inject(ChangeDetectorRef);
private dialog = inject(MatDialog);
private router = inject(Router);
private authService = inject(AuthenticationService);
/** Subscription to breakpoint observer for handset. */
isHandset$: Observable<boolean> = this.breakpointObserver
@@ -71,6 +75,9 @@ export class ShellComponent implements OnInit, OnDestroy {
progressBarMode: string;
/** Subscription to progress bar. */
progressBar$: Subscription;
/** Hides nav/sidebar for full-screen cashier routes. */
hideShellLayout = false;
private routeSub: Subscription;
/** Sub Ledger mega menu state */
subLedgerOpen = false;
@@ -110,6 +117,14 @@ export class ShellComponent implements OnInit, OnDestroy {
this.progressBarMode = mode;
this.cdr.detectChanges();
});
// Cashier users: always hide shell layout (role-based, not URL-based)
const checkLayout = () => {
this.hideShellLayout = this.authService.isCashierUser();
this.cdr.detectChanges();
};
this.routeSub = this.router.events.pipe(filter((e) => e instanceof NavigationEnd)).subscribe(() => checkLayout());
setTimeout(() => checkLayout(), 0);
}
/**
@@ -257,5 +272,8 @@ export class ShellComponent implements OnInit, OnDestroy {
if (this.progressBar$) {
this.progressBar$.unsubscribe();
}
if (this.routeSub) {
this.routeSub.unsubscribe();
}
}
}
+5
View File
@@ -99,6 +99,11 @@ export class HomeComponent implements OnInit, AfterViewInit {
* Set Form.
*/
ngOnInit() {
// Cashier users bypass the home dashboard entirely
if (this.authenticationService.isCashierUser()) {
this.router.navigate(['/cashier/transaction-entry'], { replaceUrl: true });
return;
}
const credentials = this.authenticationService.getCredentials();
this.username = credentials.username;
this.tenant = this.tenantIdentifier();
+5 -1
View File
@@ -50,6 +50,7 @@ import { STANDALONE_SHARED_IMPORTS } from 'app/standalone-shared.module';
import { M3IconComponent } from '../shared/m3-ui/m3-icon/m3-icon.component';
import { VersionService } from '../system/version.service';
import { AuthenticationService } from '../core/authentication/authentication.service';
/**
* Login component.
@@ -220,6 +221,7 @@ export class LoginComponent implements OnInit, OnDestroy {
private dialog = inject(MatDialog);
private twoFactorDialogRef: MatDialogRef<TwoFactorAuthenticationComponent> | null = null;
private router = inject(Router);
private authService = inject(AuthenticationService);
private versionService = inject(VersionService);
@@ -283,7 +285,9 @@ export class LoginComponent implements OnInit, OnDestroy {
this.resetPassword = false;
this.twoFactorAuthenticationRequired = false;
this.closeTwoFactorDialog();
this.router.navigate(['/'], { replaceUrl: true });
// Cashier users go directly to the full-screen transaction entry dashboard
const destination = this.authService.isCashierUser() ? ['/cashier/transaction-entry'] : ['/'];
this.router.navigate(destination, { replaceUrl: true });
} else if (alertType === 'Tenant Changed') {
this.updateLogo();
}
+2 -2
View File
@@ -9,8 +9,8 @@
/* tslint:disable */
export default {
'mifos_x': {
'version': '260602',
'hash': '3be2f962'
'version': '260603',
'hash': '5f7de01b'
},
'allow_switching_backend_instance': true
};