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