Compare commits

...
Author SHA1 Message Date
niteshandClaude Sonnet 5.5 c766a6f092 feat(dds): batch run preview on its own page, runs popup, projected GLs in approvals
- Generate opens the run on /organization/dds-batch-processing/run/:id with a back arrow,
  Post and Discard beside the figures, agent-wise table, View popup and CSV export; the
  table scrolls inside the page. A posted, running or failed run opens there read-only
- Batch screen lists generated-but-unposted runs as buttons to reopen them; Previous runs
  moved into a popup with status and job chips, search, date range, pagination, row
  detail and export; posting progress is followed from the run status
- Shared helpers for job names, money and agent grouping
- Approval review shows, for a calculation run, the entries its matching Posting run will
  book, marked "when posted"

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-28 22:04:26 +05:30
niteshandClaude Sonnet 5.5 0275a46326 fix(shell): keep the shell between modules and cancel abandoned requests
- Route reuse treats the shell of every feature module as one, so a click from one module
  into another no longer rebuilds the shell (menu bar vanishing, menu, notification,
  business date and approval-stream calls repeated on every such click)
- The HTTP cache interceptor cancels the inner request when its caller walks away, so
  abandoned streams stop holding browser connections and queueing later requests
- /approvals/counts is not cached

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-28 21:03:52 +05:30
niteshandClaude Sonnet 5.5 306af59099 feat(dds): savings-style DDS account view, batch processing redesign, slab master in Products
- DDS account view: Transactions and Passbook tabs laid out like savings, a per-line
  View Transaction popup (general, payment details, accounting and audit, receipt print)
  with actions menu (view, receipt, journal entries)
- DDS batch processing: job tiles with calculate then post pairs, compact agent and party
  ranges, agent-wise preview with a View popup of each agent's accounts, search, and CSV
  export of all details or one agent; previous runs summary; OD interest hidden
- Discard removes the generated preview on the server
- Commission Slab Master moved from Organization to Products, with a how-to card, rates
  and commission at "To", gap warnings to the paisa, loading state and a calculator popup
- Party edit: scheme terms and agent locked on an active account, with a lock badge

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-28 17:13:33 +05:30
niteshandClaude Opus 5.5 53bb97d6ed feat(dds): DDS product creation in tabs with an Accounting tab
- DDS Product Creation split into Details / Interest & Maturity /
  Premature Closure / Accounting tabs, like the savings product
- Accounting tab picks Customer Deposits (liability), Interest Expense
  and Agent Commission Expense (expense) GLs; product view shows them
- Approval review shows the product's GLs, old -> new on edits
- Agent master no longer reads the removed dds-deposit-product setting

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-28 12:43:51 +05:30
niteshandClaude Sonnet 5.5 293b671966 feat(dds): upgrade DDS UI - agent, party, daily entry, approvals, product view
- Agent master: tabbed layout, customer search dialog, GL dropdown, account card, view page
- Party master: tabbed layout, expected party code, nominee cards, relation from code values
- Daily transaction entry: compact style, collections popup, Apply DDS Receipt placement
- Approvals: new review modal, Agent Approval tab, DDS routing into product/pending account
- DDS product: view page and list buttons; drop Auto Interest Post Day

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-28 10:50:03 +05:30
keshav 357f666421 KYC management: stage-wise notices, pagination, RD base chart 2026-10-20 17:23:42 +05:30
niteshandClaude Opus 5.5 3e5cc26e49 feat(fixed-deposits): FD quote from the account engine, FD Special payout schedule popup, Special terms locked
- Rate chart step (account opening): the rate, maturity date, maturity amount
  and interest now come from POST fixeddepositaccounts/account-preview for
  every FD type, so the quote matches the account once created (the old
  calculate-fd-interest call counted quarters from the deposit date and was
  off, e.g. FD DOUBLE by 36,979.23). The client-side formula fallback is
  removed: on an error the message is shown, nothing is estimated.
- Dates are sent as the local calendar day (toISOString() turned 21 Oct at
  local midnight into 20 Oct).
- FD Special: total interest paid out monthly, monthly payout, principal at
  maturity, maturity date, number of payouts, and "View payout schedule",
  which opens the existing Monthly Interest Payout Schedule dialog in a new
  preview mode (Days column, no paid / pending status).
- Preview step: FD Special labels (total interest paid out monthly,
  principal at maturity).
- FD product terms: choosing FD Special locks Interest Calculation Type to
  Simple, as Compounding and Posting are locked to Monthly (the backend
  refuses anything else).
- Preview step: three existing `!= null` template checks rewritten for the
  eqeqeq lint rule, same meaning.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-20 16:44:47 +05:30
niteshandClaude Opus 5.5 c3713f8a90 Merge feature/approval-live-badge-counts into dev-latest
Live pending-count badges on the Approval Center tabs and the Approvals menu,
served over one SSE stream.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-20 11:09:08 +05:30
niteshandClaude Sonnet 5.5 f804d356d7 fix(rd-products): save RD Interest Paid under expenses, as on FD and savings
The backend finds the account by name in any group. Products saved while it was under liabilities are still read from there, so the name is not left behind as an "other" liability row.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-20 10:48:37 +05:30
niteshandClaude Sonnet 5.5 1daa8e60a0 fix(cashier): keep the A/C TP kind on SD/FD/RD lookups in transaction entry
SD, FD and RD share one /savingsaccounts list that returns a deposit account of any kind for a number. The lookup now keeps only the deposit type chosen in A/C TP, and when the number belongs to another kind the error says so, so the clerk can fix A/C TP.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-20 10:48:23 +05:30
niteshandClaude Opus 5.5 55f5c1cf32 feat(ui): longer startup splash and updated loading animation
The startup splash now holds for at least 4 seconds (progress 2% per tick up to 90%)
instead of 2, and the loading animation is updated.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-16 12:31:24 +05:30
niteshandClaude Opus 5.5 6507e68203 feat(cashier): FD is credit-only on the transaction entry, and a Bank Transfer can fund an FD
- A Fixed Deposit row is credit-only on the clerk and cashier screens (isFdCreditOnly):
  its DR button is disabled, and Save refuses any FD row left on DR (for example by a
  Withdrawal Slip TYPE). Money leaves an FD only through its maturity or premature
  closure; the clerk's "Close this account" flow is the one exception.
- Bank Transfer / Fund Transfer accepts an FD on the CR side, so an FD can be funded from
  the customer's own Savings or Current account. The server enforces the funding rules
  (first and only funding, exactly the principal, the source account's owner, freeze
  status and available balance).

Requires the backend change "FD funding rules and no FD withdrawals from clerk/cashier requests".

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-16 12:31:03 +05:30
niteshandClaude Sonnet 5.5 4d205672df feat(approvals): live pending-count badges on every Approval Center tab and the Approvals menu
The badges are pushed, not polled. ApprovalCountsService opens one
Server-Sent Events stream (/approvals/counts/stream) through HttpClient so the
login and tenant headers apply; the server writes a `counts` event the moment
the numbers change.

- Total badge on the Approvals menu, count badge on 26 Approval Center tabs
  (Reject Account and Terminated Account are lists, not requests: no badge).
- Last numbers are kept per login in localStorage, so a page refresh shows them
  at once until the stream's first event makes them exact.
- Stream recycles every few minutes and reconnects at once; after a failure it
  retries with a growing wait and reads /approvals/counts meanwhile.
- NO_PROGRESS_BAR token: the background stream does not run the top progress bar.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-15 15:49:36 +05:30
niteshandClaude Sonnet 5.5 ab8ed7e67e feat(vouchers): voucher correction screens and a correction status on the voucher registers
A cash deposit posted to the wrong account is moved to the right one in four steps: unpass the voucher, a manager
approves, the cashier enters the correct account, a manager approves again. Nothing changes on any account until
the final approval.

- Voucher Correction page with four-step strip and popups: today's vouchers to unpass (admin or clerk), the
  unpassed vouchers waiting for their correct account (cashier) with the wrong and correct account side by side
  and the balance check, and the caller's own requests. A cashier whose customer has used the money closes the
  request and deposits separately.
- Reached from the cashier bar, the clerk bar and the Cashier menu; the manager reviews both steps under
  Approvals > Voucher Corrections, laid out like the other approval tabs. Routes for Cash Transfers approvals are
  added to the same tab bar.
- A Correction column on My Vouchers, the day book and the voucher register (and in the voucher detail popup)
  says whether a voucher is unpassed, revoked in favour of another, a correction, or closed, with the reason.
- The deposit taken off the wrong account no longer shows on its passbook or transaction list.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-15 12:28:27 +05:30
niteshandClaude Sonnet 5.5 7ee230e58a feat(cashier): maker-checker on cash transfer, real branches, compact Approval Center
- Cash Transfer now raises a request ("Send for approval") instead of posting. A My Requests button in the
  page header opens the caller's requests with their status, and a Cash Transfers approvals page (review
  popup with the full denomination breakdown, approve, reject with a reason) lets a different manager release
  the cash.
- Transaction Entry's Branch dropdown lists the installation's real offices instead of a fixed list. A login
  that cannot read offices is offered only its own branch (read-only), so no "Unauthorized Request" popup.
- Approval Center header and tab bar made compact so more tabs fit on a 100% window.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-15 12:27:39 +05:30
niteshandClaude Sonnet 5.5 7f3c97a205 feat(cashier): GL withdrawal slips, GL account panel with parent GL, and Payment lock on token withdrawals
Transaction Entry:
- GL withdrawal slip: a GL Withdrawal Slip is always a debit. The clerk raises it as a
  request (GL, cash vault GL, office and currency ride on the payload), the manager
  approves it and the cashier pays it against its token; nothing posts directly from this
  screen any more (submitGlJournalEntry removed). Cashiers without an approved token are
  told to enter one. GL credit-only rules moved into isGlCreditOnly(), and extra rows are
  not allowed while a Withdrawal Slip is selected.
- A/C NO. now starts the automatic account search after six characters.
- Clerk: with AC. TP. = GL show a "GL Account Information" panel (code, name, type, usage,
  normal balance, parent GL, manual entries, status, description) instead of the empty
  client panels; the parent GL name is looked up from /glaccounts/{parentId} for GL only.
- Cashier: once a token (withdrawal / closure request) is verified, TYPE is disabled and
  fixed to Payment, and onTypChange refuses to change it.

Requires the backend change "GL withdrawal slips through request, approval and cashier payment".

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-14 13:51:38 +05:30
niteshandClaude Sonnet 5.5 4e3b49a96a feat(ui): remove Undo / Revert transaction actions
Removes the Undo button and Undo menu item from savings, current, fixed deposit, recurring deposit
and loan transactions, the account-transfer Undo, and the Revert Transaction button on the
journal entry transaction detail, to prevent accidental reversal of posted entries.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-13 18:41:39 +05:30
niteshandClaude Sonnet 5.5 915deddb74 feat(cashier): cashier-only cash operations, exchange PAN/denomination checks and Branch Cash Position
- Cash Exchange Entry: customer details with PAN (mandatory above Rs 50,000), cashier card,
  denomination-wise validation against the master and real-time impact preview.
- Inter-Cashier Transfer: sender fixed to the logged-in cashier, both sides' details and
  denomination holdings shown, blocked until both are allocated cash.
- Reconciliation, exchange and inter-cashier operate on the logged-in cashier only.
- New Branch Cash Position screen (vault + cashier drawers, denomination-wise).
- Customer search dialog shows the KYC PAN.
- Cashier menu cleanup: Excess/Closing report and Exchange Detail removed, nav compacted to one row.
- Reverse actions removed from cash transfer, settlement, exchange and inter-cashier.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-13 18:41:24 +05:30
238 changed files with 35280 additions and 6884 deletions
@@ -7,16 +7,6 @@
-->
<div class="container">
<div class="container m-b-20 align-end gap-2px">
@if (!viewAccountTransferData.reversed) {
<span>
<button mat-raised-button color="warn" *mifosxHasPermission="'ADJUST_ACCOUNTTRANSFER'">
<fa-icon icon="undo" class="m-r-10"></fa-icon>{{ 'labels.buttons.Undo' | translate }}
</button>
</span>
}
</div>
<mat-card>
<mat-card-content>
<div class="transfer-container">
+12 -1
View File
@@ -10,7 +10,8 @@
/** Angular Imports */
import { Injectable, inject } from '@angular/core';
import { HttpClient, HttpParams } from '@angular/common/http';
import { HttpClient, HttpContext, HttpParams } from '@angular/common/http';
import { SILENT_HTTP_ERROR } from 'app/core/http/silent-error.token';
/** rxjs Imports */
import { Observable } from 'rxjs';
@@ -198,6 +199,16 @@ export class AccountingService {
return this.http.get('/journalentries/ledger', { params: httpParams });
}
/**
* The whole transfer a ledger transaction belongs to, when it is one branch's part of a transfer between branches.
* An extra, informational lookup: if it fails the screen simply shows no banner, so a failure raises no error popup.
*/
getInterBranchTransfer(transactionId: string): Observable<any> {
return this.http.get(`/accounting/interbranchtransfers/${transactionId}`, {
context: new HttpContext().set(SILENT_HTTP_ERROR, true)
});
}
/**
* @param {string} transactionId Transaction ID to be reverted.
* @param {string} comments Any comments.
+16
View File
@@ -142,6 +142,22 @@ export class AmlService {
return this.http.get<any[]>('/aml/ctr-reports/lines', { params });
}
/**
* Cash transactions of the threshold or more that no generated report contains yet, shaped like the lines above.
* They show on the FIU screen as soon as they are posted; generating the month is what files them.
*/
getCtrPendingLines(): Observable<any[]> {
return this.http.get<any[]>('/aml/ctr-reports/pending-lines');
}
/**
* How much came into and went out of each current / Cash Credit account over the last `days` days, with the
* customer's declared turnover and the sanctioned limit alongside.
*/
getCcTracking(days = 30): Observable<any> {
return this.http.get<any>('/aml/cc-tracking', { params: new HttpParams().set('days', String(days)) });
}
/** month format: yyyy-MM */
generateCtrReport(month: string): Observable<any> {
return this.http.post('/aml/ctr-reports/generate', { month });
@@ -0,0 +1,114 @@
<!--
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="pg">
<header class="pg-head">
<div>
<div class="pg-eyebrow">BRANCH OPERATIONS · BRANCH CASH POSITION</div>
<h1 class="pg-title">Branch Cash Position</h1>
<p class="pg-sub">
The notes and coins the branch holds, denomination by denomination: the vault plus every cashier's drawer. Cash
moving between the vault and a cashier nets out; deposits, withdrawals and exchanges change it.
</p>
</div>
</header>
<div class="pg-toolbar pg-toolbar--mat">
<mat-form-field appearance="outline" subscriptSizing="dynamic">
<mat-label>As of Date</mat-label>
<input matInput type="date" [value]="asOfDate" (change)="onDateChange($any($event.target).value)" />
</mat-form-field>
<button type="button" class="pg-btn pg-btn--primary" (click)="load()" [disabled]="loading">
<mat-icon>refresh</mat-icon> Refresh
</button>
</div>
@if (errorMsg) {
<div class="pg-empty bcp-error">{{ errorMsg }}</div>
}
@if (loading) {
<div class="pg-empty">Loading…</div>
}
@if (position; as p) {
<div class="bcp-totals">
<div class="bcp-total">
<span>Branch</span><strong>{{ p.officeName }}</strong>
</div>
<div class="bcp-total">
<span>Vault</span><strong>₹{{ p.vaultAmount | number: '1.2-2' }}</strong>
</div>
<div class="bcp-total">
<span>Cashiers' drawers</span><strong>₹{{ p.cashiersAmount | number: '1.2-2' }}</strong>
</div>
<div class="bcp-total bcp-total--main">
<span>Bank total</span><strong>₹{{ p.totalAmount | number: '1.2-2' }}</strong>
</div>
</div>
@if (!p.vaultHasBreakdown) {
<div class="pg-empty bcp-note">
No vault denomination breakdown was recorded on or before {{ p.asOfDate }}, so the vault column reads 0. Open
the business day with a note/coin count to start tracking it.
</div>
}
<ng-template #table let-rows let-label="label" let-coin="coin">
@if (rows.length > 0) {
<div class="pos-section-label" [class.pos-section-label--coin]="coin">{{ label }}</div>
<table class="pg-table">
<thead>
<tr>
<th>Denomination</th>
<th class="num">Vault</th>
<th class="num">Cashiers</th>
<th class="num">Bank total (count)</th>
<th class="num">Bank total (₹)</th>
<th class="num">Exchange today</th>
</tr>
</thead>
<tbody>
@for (d of rows; track d.denominationId) {
<tr>
<td>
<span class="pg-chip" [class.pg-chip--coin]="coin">₹{{ d.denominationValue }}</span>
</td>
<td class="num">{{ d.vaultCount }}</td>
<td class="num">{{ d.cashierCount }}</td>
<td class="num bcp-strong">{{ d.totalCount }}</td>
<td class="num">₹{{ d.totalAmount | number: '1.2-2' }}</td>
<td
class="num bcp-move"
[class.bcp-move--in]="d.exchangeNet > 0"
[class.bcp-move--out]="d.exchangeNet < 0"
>
{{ movement(d.exchangeNet) }}
</td>
</tr>
}
</tbody>
</table>
}
</ng-template>
<div class="pg-card bcp-card">
<ng-container
*ngTemplateOutlet="table; context: { $implicit: notes(), label: 'Notes', coin: false }"
></ng-container>
<ng-container
*ngTemplateOutlet="table; context: { $implicit: coins(), label: 'Coins', coin: true }"
></ng-container>
</div>
<p class="bcp-foot">
<strong>Exchange today</strong> is the notes taken in less the notes given out by cash exchanges on
{{ p.asOfDate }}. It is already part of the Cashiers column; it is shown beside the total so a change in the mix
can be seen.
</p>
}
</div>
@@ -0,0 +1,82 @@
/**
* 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/.
*/
.bcp-error {
color: #dc2626;
}
.bcp-note {
margin-bottom: 12px;
text-align: left;
}
.bcp-totals {
display: flex;
flex-wrap: wrap;
gap: 12px;
margin-bottom: 14px;
}
.bcp-total {
flex: 1 1 180px;
padding: 12px 14px;
background: #fff;
border: 1px solid #e2e8f0;
border-radius: 12px;
span {
display: block;
font-size: 11px;
font-weight: 600;
letter-spacing: 0.04em;
text-transform: uppercase;
color: #64748b;
}
strong {
font-size: 18px;
font-weight: 700;
color: #0f172a;
}
&--main {
background: #e6f4ee;
border-color: #1f7a4d;
strong {
color: #0f4f3c;
}
}
}
.bcp-card {
margin-bottom: 14px;
}
.bcp-strong {
font-weight: 700;
}
.bcp-move {
font-weight: 700;
color: #94a3b8;
&--in {
color: #16a34a;
}
&--out {
color: #dc2626;
}
}
.bcp-foot {
margin: 0;
font-size: 12px;
color: #64748b;
}
@@ -0,0 +1,112 @@
/**
* 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 { MatIconModule } from '@angular/material/icon';
import { MatFormFieldModule } from '@angular/material/form-field';
import { MatInputModule } from '@angular/material/input';
import { of } from 'rxjs';
import { catchError } from 'rxjs/operators';
import { CashierService } from '../cashier.service';
import { businessToday } from 'app/core/utils/business-today';
interface BranchDenomRow {
denominationId: number;
denominationValue: number;
kind: string;
vaultCount: number;
cashierCount: number;
totalCount: number;
totalAmount: number;
/** Notes taken in less notes given out by exchanges on the day. */
exchangeNet: number;
}
interface BranchPosition {
officeId: number;
officeName: string;
asOfDate: string;
vaultHasBreakdown: boolean;
vaultAmount: number;
cashiersAmount: number;
totalAmount: number;
denominations: BranchDenomRow[];
}
/**
* What the branch holds, denomination by denomination: the vault plus every cashier's drawer. An exchange
* (a customer hands in 10 x ₹500 and leaves with 50 x ₹100) leaves the vault alone and changes a drawer, so
* it only shows up in this total -- +10 on ₹500, -50 on ₹100.
*/
@Component({
selector: 'mifosx-branch-cash-position',
templateUrl: './branch-cash-position.component.html',
styleUrls: [
'../cashier-shared.scss',
'../cashier-position/cashier-position.component.scss',
'./branch-cash-position.component.scss'
],
standalone: true,
imports: [
CommonModule,
MatIconModule,
MatFormFieldModule,
MatInputModule
]
})
export class BranchCashPositionComponent implements OnInit {
private service = inject(CashierService);
asOfDate = '';
loading = false;
errorMsg = '';
position: BranchPosition | null = null;
ngOnInit(): void {
const d = businessToday();
this.asOfDate = `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, '0')}-${String(d.getDate()).padStart(2, '0')}`;
this.load();
}
load(): void {
this.loading = true;
this.errorMsg = '';
this.position = null;
this.service
.branchCashPosition(this.asOfDate)
.pipe(catchError(() => of(null)))
.subscribe((data: BranchPosition | null) => {
this.loading = false;
if (!data) {
this.errorMsg = 'Could not load the branch cash position.';
return;
}
this.position = data;
});
}
onDateChange(value: string): void {
this.asOfDate = value;
this.load();
}
notes(): BranchDenomRow[] {
return (this.position?.denominations || []).filter((d) => d.kind !== 'coin');
}
coins(): BranchDenomRow[] {
return (this.position?.denominations || []).filter((d) => d.kind === 'coin');
}
/** "+10", "-50", or a dash when the day's exchanges did not touch the denomination. */
movement(net: number): string {
if (!net) return '—';
return net > 0 ? `+${net}` : `${net}`;
}
}
@@ -22,50 +22,255 @@
</div>
</header>
<!-- ── Customer Info Bar ── -->
<div class="cx-customer-bar">
<div
class="cx-customer-toggle"
[class.cx-customer-toggle--active]="isAccountHolder"
(click)="toggleAccountHolder()"
<!-- ── Cashier: whose drawer takes the customer's notes and gives the change ── -->
@if (cashier) {
<section class="cx-cashier">
<div class="cx-avatar cx-avatar--sm">{{ cashierInitials }}</div>
<div class="cx-cashier__id">
<div class="cx-cashier__label">Cashier at the counter</div>
<div class="cx-cashier__name">{{ cashier.name || '—' }}</div>
<div class="cx-cashier__flow">
<span><mat-icon>south</mat-icon> Receives from customer</span>
<span><mat-icon>north</mat-icon> Gives to customer</span>
</div>
</div>
<div class="cx-cashier__stats">
<div>
<span>Counter</span><strong>{{ cashier.tellerName || '—' }}</strong>
</div>
<div>
<span>Branch</span><strong>{{ cashier.officeName || '—' }}</strong>
</div>
<div>
<span>Allocated today</span
><strong>{{
cashier.allocatedAmount === null ? '—' : '₹' + (cashier.allocatedAmount | number: '1.2-2')
}}</strong>
</div>
<div>
<span>Cash in hand</span><strong>₹{{ cashier.totalAmount | number: '1.2-2' }}</strong>
</div>
</div>
</section>
} @else if (noCashier) {
<section class="cx-cashier cx-cashier--none">
<mat-icon>person_off</mat-icon>
<div>
<div class="cx-cashier__name">Your login is not linked to a cashier</div>
<div class="cx-cashier__label">
A cash exchange moves notes in and out of a cashier's drawer, so it can only be recorded by a cashier. Log in
as a cashier to continue.
</div>
</div>
</section>
}
<!-- ── Customer: an existing client (details from their record) or a walk-in identified at the counter ── -->
<section class="cx-cust">
<header class="cx-cust__head">
<div class="cx-cust__title">
<span class="cx-cust__title-icon"><mat-icon>groups</mat-icon></span>
<div>
<div class="cx-cust__title-text">Customer</div>
<div class="cx-cust__title-sub">Who is exchanging cash at the counter</div>
</div>
</div>
<div class="cx-cust__modes" role="tablist" aria-label="Customer type">
<button
type="button"
role="tab"
class="cx-cust__mode"
[class.cx-cust__mode--active]="customerMode === 'existing'"
[attr.aria-selected]="customerMode === 'existing'"
(click)="setCustomerMode('existing')"
>
<mat-icon>{{ isAccountHolder ? 'check_box' : 'check_box_outline_blank' }}</mat-icon>
<span>Account Holder</span>
<mat-icon>badge</mat-icon> Existing Customer
</button>
<button
type="button"
role="tab"
class="cx-cust__mode"
[class.cx-cust__mode--active]="customerMode === 'walkin'"
[attr.aria-selected]="customerMode === 'walkin'"
(click)="setCustomerMode('walkin')"
>
<mat-icon>person_outline</mat-icon> Walk-in Customer
</button>
</div>
</header>
@if (isAccountHolder) {
<label class="cx-field cx-field--inline">
<span>Account No.</span>
<input
type="text"
[value]="accountNo"
(input)="accountNo = $any($event.target).value"
placeholder="e.g. 000101234567"
/>
</label>
<div class="cx-cust__body">
<!-- Who -->
<div class="cx-cust__who">
@if (customerMode === 'existing') {
@if (loadingCustomer) {
<div class="cx-cust__empty">
<mat-icon class="cx-cust__empty-icon cx-spin">autorenew</mat-icon>
<div class="cx-cust__empty-title">Loading customer details…</div>
</div>
} @else if (!selectedCustomer) {
<div class="cx-cust__empty">
<mat-icon class="cx-cust__empty-icon">person_search</mat-icon>
<div class="cx-cust__empty-title">No customer selected</div>
<div class="cx-cust__empty-sub">Search by name, account no., PAN or Aadhaar</div>
<button type="button" class="cx-btn cx-btn--primary" (click)="openCustomerSearch()">
<mat-icon>search</mat-icon> Search Customer
</button>
</div>
} @else {
<span class="cx-walk-in"><mat-icon>person_outline</mat-icon> Walk-in Customer</span>
<div class="cx-cust__card">
<div class="cx-cust__card-top">
<div class="cx-avatar">{{ customerInitials }}</div>
<div class="cx-cust__card-id">
<div class="cx-cust__name">{{ selectedCustomer.displayName }}</div>
<div class="cx-cust__badges">
<span
class="cx-kyc"
[class.cx-kyc--ok]="selectedCustomer.kycStatus === 'Verified'"
[class.cx-kyc--warn]="selectedCustomer.kycStatus !== 'Verified'"
>
<mat-icon>{{
selectedCustomer.kycStatus === 'Verified' ? 'verified_user' : 'gpp_maybe'
}}</mat-icon>
KYC: {{ selectedCustomer.kycStatus }}
</span>
@if (selectedCustomer.accountType) {
<span class="cx-chip-tag">{{ selectedCustomer.accountType }}</span>
}
@if (needsIdProof) {
<div class="cx-pan-required">
<mat-icon>warning</mat-icon>
<span>Amount ≥ ₹50,000 — Customer PAN / Aadhaar required (RBI)</span>
</div>
<label class="cx-field cx-field--inline cx-field--pan">
<span>PAN / Aadhaar *</span>
</div>
<div class="cx-cust__card-actions">
<button type="button" class="cx-link-btn" (click)="openCustomerSearch()">
<mat-icon>swap_horiz</mat-icon> Change
</button>
<button type="button" class="cx-link-btn cx-link-btn--danger" (click)="clearCustomer()">
<mat-icon>close</mat-icon> Remove
</button>
</div>
</div>
<div class="cx-cust__grid">
<div>
<span>CIF No.</span><strong>{{ selectedCustomer.cifNumber || '—' }}</strong>
</div>
<div>
<span>Client ID</span><strong>{{ selectedCustomer.clientId }}</strong>
</div>
<div>
<span>Mobile</span><strong>{{ selectedCustomer.mobileNo || '—' }}</strong>
</div>
<div>
<span>Branch</span><strong>{{ selectedCustomer.officeName || '—' }}</strong>
</div>
<div>
<span>Account</span><strong>{{ selectedCustomer.accountNo || 'No account' }}</strong>
</div>
<div>
<span>PAN on record</span>
<strong>
{{ selectedCustomer.kycPan || 'Not on record' }}
@if (selectedCustomer.kycPan) {
<span
class="cx-kyc cx-kyc--sm"
[class.cx-kyc--ok]="selectedCustomer.panVerified"
[class.cx-kyc--warn]="!selectedCustomer.panVerified"
>{{ selectedCustomer.panVerified ? 'Verified' : 'Not verified' }}</span
>
}
</strong>
</div>
</div>
</div>
}
} @else {
<div class="cx-cust__walkin">
<label class="cx-field">
<span>Customer Name{{ needsPan ? ' *' : '' }}</span>
<input
type="text"
[value]="customerIdProof"
(input)="customerIdProof = $any($event.target).value"
placeholder="ABCDE1234F"
maxlength="20"
[value]="walkInName"
(input)="walkInName = $any($event.target).value"
placeholder="Full name as on ID"
maxlength="150"
autocomplete="off"
/>
</label>
<label class="cx-field">
<span>Mobile</span>
<input
type="tel"
inputmode="numeric"
[value]="walkInMobile"
(input)="walkInMobile = $any($event.target).value"
placeholder="10-digit mobile number"
maxlength="15"
autocomplete="off"
/>
</label>
</div>
}
</div>
<!-- PAN -->
<div
class="cx-pan"
[class.cx-pan--required]="panMissing"
[class.cx-pan--invalid]="panFormatInvalid"
[class.cx-pan--valid]="panValue && !panFormatInvalid"
>
<div class="cx-pan__head">
<mat-icon>credit_card</mat-icon>
<span class="cx-pan__label">PAN Card</span>
<span class="cx-pan__tag" [class.cx-pan__tag--on]="needsPan">{{ needsPan ? 'Mandatory' : 'Optional' }}</span>
</div>
<div class="cx-pan__input">
<input
type="text"
[value]="panNumber"
(input)="panNumber = $any($event.target).value.toUpperCase()"
placeholder="ABCDE1234F"
maxlength="10"
autocomplete="off"
spellcheck="false"
aria-label="PAN number"
/>
@if (panValue && !panFormatInvalid) {
<mat-icon class="cx-pan__state cx-pan__state--ok">check_circle</mat-icon>
} @else if (panFormatInvalid) {
<mat-icon class="cx-pan__state cx-pan__state--bad">error</mat-icon>
}
</div>
<div class="cx-pan__hint">
@if (panFormatInvalid) {
PAN is 5 letters, 4 digits, 1 letter (e.g. ABCDE1234F).
} @else if (panFromKyc) {
<mat-icon>link</mat-icon> Fetched from the customer's KYC record.
} @else if (panMissing) {
Required: this exchange is above ₹50,000 (Income Tax Rule 114B / RBI).
} @else {
Mandatory for exchanges above ₹50,000.
}
</div>
</div>
</div>
</section>
<div class="cx-cols">
<!-- The cashier's count of a denomination and what this exchange does to it, updated as counts are typed. -->
<ng-template #drawerCell let-d="d">
@if (availableFor(d) === null) {
—
} @else {
In drawer <strong>{{ availableFor(d) }}</strong>
@if (netFor(d) !== 0) {
<span class="cx-after">
<mat-icon>{{ netFor(d) > 0 ? 'trending_up' : 'trending_down' }}</mat-icon>
<strong>{{ drawerAfter(d) }}</strong>
<small>({{ netFor(d) > 0 ? '+' : '' }}{{ netFor(d) }})</small>
</span>
}
}
</ng-template>
<!-- RECEIVED -->
<section class="cx-col cx-col--received">
<header class="cx-col-head">
@@ -78,7 +283,7 @@
<div class="cx-subhead">Bank Notes</div>
}
@for (d of notes; track d.id) {
<div class="cx-row">
<div class="cx-row cx-row--stock">
<span class="cx-chip">{{ d.label }}</span>
<div class="cx-step">
<button type="button" (click)="bump('r', d, -1)" [disabled]="countFor('r', d) === 0">−</button>
@@ -90,6 +295,9 @@
/>
<button type="button" (click)="bump('r', d, 1)">+</button>
</div>
<span class="cx-avail" [class.cx-avail--up]="netFor(d) > 0" [class.cx-avail--down]="netFor(d) < 0">
<ng-container *ngTemplateOutlet="drawerCell; context: { d: d }"></ng-container>
</span>
<span class="cx-amt" [class.cx-amt--zero]="countFor('r', d) === 0"
>₹{{ subtotal('r', d) | number: '1.2-2' }}</span
>
@@ -99,7 +307,7 @@
<div class="cx-subhead">Coins</div>
}
@for (d of coins; track d.id) {
<div class="cx-row">
<div class="cx-row cx-row--stock">
<span class="cx-chip cx-chip--coin">{{ d.label }}</span>
<div class="cx-step">
<button type="button" (click)="bump('r', d, -1)" [disabled]="countFor('r', d) === 0">−</button>
@@ -111,6 +319,9 @@
/>
<button type="button" (click)="bump('r', d, 1)">+</button>
</div>
<span class="cx-avail" [class.cx-avail--up]="netFor(d) > 0" [class.cx-avail--down]="netFor(d) < 0">
<ng-container *ngTemplateOutlet="drawerCell; context: { d: d }"></ng-container>
</span>
<span class="cx-amt" [class.cx-amt--zero]="countFor('r', d) === 0"
>₹{{ subtotal('r', d) | number: '1.2-2' }}</span
>
@@ -131,7 +342,7 @@
<div class="cx-subhead">Bank Notes</div>
}
@for (d of notes; track d.id) {
<div class="cx-row">
<div class="cx-row cx-row--stock" [class.cx-row--over]="exceedsAvailable(d)">
<span class="cx-chip">{{ d.label }}</span>
<div class="cx-step">
<button type="button" (click)="bump('g', d, -1)" [disabled]="countFor('g', d) === 0">−</button>
@@ -141,8 +352,16 @@
[value]="countFor('g', d)"
(input)="onChange('g', d, $any($event.target).value)"
/>
<button type="button" (click)="bump('g', d, 1)">+</button>
<button type="button" (click)="bump('g', d, 1)" [disabled]="!canGiveMore(d)">+</button>
</div>
<span
class="cx-avail"
[class.cx-avail--up]="netFor(d) > 0"
[class.cx-avail--down]="netFor(d) < 0"
[class.cx-avail--over]="exceedsAvailable(d)"
>
<ng-container *ngTemplateOutlet="drawerCell; context: { d: d }"></ng-container>
</span>
<span class="cx-amt" [class.cx-amt--zero]="countFor('g', d) === 0"
>₹{{ subtotal('g', d) | number: '1.2-2' }}</span
>
@@ -152,7 +371,7 @@
<div class="cx-subhead">Coins</div>
}
@for (d of coins; track d.id) {
<div class="cx-row">
<div class="cx-row cx-row--stock" [class.cx-row--over]="exceedsAvailable(d)">
<span class="cx-chip cx-chip--coin">{{ d.label }}</span>
<div class="cx-step">
<button type="button" (click)="bump('g', d, -1)" [disabled]="countFor('g', d) === 0">−</button>
@@ -162,8 +381,16 @@
[value]="countFor('g', d)"
(input)="onChange('g', d, $any($event.target).value)"
/>
<button type="button" (click)="bump('g', d, 1)">+</button>
<button type="button" (click)="bump('g', d, 1)" [disabled]="!canGiveMore(d)">+</button>
</div>
<span
class="cx-avail"
[class.cx-avail--up]="netFor(d) > 0"
[class.cx-avail--down]="netFor(d) < 0"
[class.cx-avail--over]="exceedsAvailable(d)"
>
<ng-container *ngTemplateOutlet="drawerCell; context: { d: d }"></ng-container>
</span>
<span class="cx-amt" [class.cx-amt--zero]="countFor('g', d) === 0"
>₹{{ subtotal('g', d) | number: '1.2-2' }}</span
>
@@ -173,6 +400,30 @@
</section>
</div>
<!-- What this exchange does to the cashier's drawer, denomination by denomination. -->
@if (impactRows.length) {
<div class="cx-impact">
<div class="cx-impact__title"><mat-icon>inventory_2</mat-icon> Drawer after this exchange</div>
<div class="cx-impact__chips">
@for (r of impactRows; track r.id) {
<span
class="cx-impact__chip"
[class.cx-impact__chip--up]="r.net > 0"
[class.cx-impact__chip--down]="r.net < 0"
>
<b>{{ r.label }}</b>
{{ r.before }} → {{ r.after }}
<em>({{ r.net > 0 ? '+' : '' }}{{ r.net }})</em>
</span>
}
</div>
<div class="cx-impact__note">
The branch's total for each denomination moves by the same amount; the vault is not touched (Branch Ops → Branch
Cash Position).
</div>
</div>
}
<div class="cx-summary">
<div class="cx-sum">
<span>Received</span><strong>₹{{ receivedTotal | number: '1.2-2' }}</strong>
@@ -213,9 +464,15 @@
</div>
</div>
}
@if (needsIdProof && !customerIdProof.trim()) {
@if (panMissing) {
<div class="cx-warn cx-warn--pan">
<mat-icon>badge</mat-icon> Enter customer PAN / Aadhaar to proceed (amount ≥ ₹50,000).
<mat-icon>badge</mat-icon> Enter the customer's PAN to proceed (amount above ₹50,000).
</div>
}
@if (!customerIdentified && !loadingCustomer) {
<div class="cx-warn cx-warn--pan">
<mat-icon>person_search</mat-icon>
{{ customerMode === 'existing' ? 'Search and select the customer.' : "Enter the walk-in customer's name." }}
</div>
}
@@ -73,45 +73,510 @@
}
}
/* ── Customer info bar ── */
.cx-customer-bar {
/* ── Customer: existing client or walk-in, plus PAN ── */
.cx-cust {
background: #fff;
border: 1px solid #e2e8f0;
border-radius: 14px;
box-shadow: 0 1px 2px rgb(15 23 42 / 4%);
overflow: hidden;
&__head {
display: flex;
align-items: center;
gap: 12px;
padding: 10px 14px;
background: #f8fafc;
border: 1px solid #e2e8f0;
border-radius: 10px;
justify-content: space-between;
flex-wrap: wrap;
}
gap: 12px;
padding: 12px 16px;
background: linear-gradient(180deg, #f8fafc, #f1f5f9);
border-bottom: 1px solid #e2e8f0;
}
.cx-customer-toggle {
&__title {
display: flex;
align-items: center;
gap: 10px;
}
&__title-icon {
display: grid;
place-items: center;
width: 36px;
height: 36px;
border-radius: 10px;
background: #e6f4ee;
color: #0f4f3c;
mat-icon {
font-size: 20px;
width: 20px;
height: 20px;
}
}
&__title-text {
font-size: 14px;
font-weight: 700;
color: #0f172a;
}
&__title-sub {
font-size: 12px;
color: #64748b;
}
&__modes {
display: inline-flex;
padding: 3px;
background: #e2e8f0;
border-radius: 10px;
}
&__mode {
display: inline-flex;
align-items: center;
gap: 6px;
height: 32px;
padding: 0 14px;
border: 0;
border-radius: 8px;
background: transparent;
font-size: 13px;
font-weight: 600;
color: #475569;
cursor: pointer;
user-select: none;
transition:
background 0.15s ease,
color 0.15s ease;
mat-icon {
font-size: 20px;
height: 20px;
width: 20px;
font-size: 18px;
width: 18px;
height: 18px;
}
&:hover {
color: #0f172a;
}
&--active,
&--active:hover {
background: #fff;
color: #0f4f3c;
box-shadow: 0 1px 3px rgb(15 23 42 / 15%);
}
}
&__body {
display: grid;
grid-template-columns: minmax(0, 1.7fr) minmax(260px, 1fr);
gap: 16px;
padding: 16px;
}
&__who {
min-width: 0;
}
/* Nothing picked yet, or loading */
&__empty {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
gap: 4px;
min-height: 150px;
padding: 20px;
text-align: center;
background: #f8fafc;
border: 1.5px dashed #cbd5e1;
border-radius: 12px;
}
&__empty-icon {
font-size: 36px;
width: 36px;
height: 36px;
color: #94a3b8;
}
&--active {
color: #1e40af;
&__empty-title {
font-size: 14px;
font-weight: 600;
color: #334155;
}
&__empty-sub {
margin-bottom: 8px;
font-size: 12px;
color: #64748b;
}
/* A picked client */
&__card {
padding: 14px;
background: #fff;
border: 1px solid #e2e8f0;
border-radius: 12px;
}
&__card-top {
display: flex;
align-items: center;
flex-wrap: wrap;
gap: 12px;
}
&__card-id {
flex: 1 1 180px;
min-width: 0;
}
&__name {
font-size: 16px;
font-weight: 700;
color: #0f172a;
overflow-wrap: anywhere;
}
&__badges {
display: flex;
align-items: center;
flex-wrap: wrap;
gap: 6px;
margin-top: 4px;
}
&__card-actions {
display: flex;
gap: 4px;
}
&__grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(150px, 1fr));
gap: 12px 16px;
margin-top: 12px;
padding-top: 12px;
border-top: 1px dashed #e2e8f0;
span {
display: block;
margin-bottom: 2px;
font-size: 11px;
color: #64748b;
}
strong {
font-size: 13px;
font-weight: 600;
color: #0f172a;
}
}
/* A walk-in, identified at the counter */
&__walkin {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 12px;
.cx-field {
font-size: 12px;
}
.cx-field input {
width: 100%;
height: 40px;
box-sizing: border-box;
}
}
.cx-field input:focus {
outline: none;
border-color: #1f7a4d;
box-shadow: 0 0 0 3px rgb(31 122 77 / 15%);
}
}
.cx-avatar {
display: grid;
place-items: center;
flex: 0 0 auto;
width: 44px;
height: 44px;
border-radius: 50%;
background: linear-gradient(135deg, #1f7a4d, #0f4f3c);
font-size: 15px;
font-weight: 700;
letter-spacing: 0.02em;
color: #fff;
&--sm {
width: 40px;
height: 40px;
font-size: 14px;
}
}
/* The cashier at the counter, or why there is none */
.cx-cashier {
display: flex;
align-items: center;
flex-wrap: wrap;
gap: 14px 20px;
padding: 12px 16px;
background: #fff;
border: 1px solid #e2e8f0;
border-left: 4px solid #1f7a4d;
border-radius: 14px;
box-shadow: 0 1px 2px rgb(15 23 42 / 4%);
&__id {
flex: 1 1 220px;
min-width: 0;
}
&__label {
font-size: 11px;
font-weight: 600;
letter-spacing: 0.04em;
text-transform: uppercase;
color: #64748b;
}
&__name {
font-size: 16px;
font-weight: 700;
color: #0f172a;
}
&__flow {
display: flex;
flex-wrap: wrap;
gap: 4px 14px;
margin-top: 2px;
font-size: 12px;
color: #475569;
span {
display: inline-flex;
align-items: center;
gap: 2px;
}
mat-icon {
color: #1e40af;
font-size: 14px;
width: 14px;
height: 14px;
color: #94a3b8;
}
}
&__stats {
display: flex;
flex-wrap: wrap;
gap: 10px 28px;
span {
display: block;
font-size: 11px;
color: #64748b;
}
strong {
font-size: 14px;
font-weight: 600;
color: #0f172a;
}
}
&--none {
background: #fffbeb;
border-color: #fcd34d;
border-left-color: #d97706;
mat-icon {
color: #d97706;
}
.cx-cashier__label {
font-weight: 500;
letter-spacing: 0;
text-transform: none;
color: #92400e;
}
}
}
.cx-walk-in {
.cx-kyc {
display: inline-flex;
align-items: center;
gap: 4px;
padding: 2px 8px;
border-radius: 999px;
font-size: 11px;
font-weight: 600;
mat-icon {
font-size: 14px;
width: 14px;
height: 14px;
}
&--ok {
background: #dcfce7;
color: #166534;
}
&--warn {
background: #fef3c7;
color: #92400e;
}
&--sm {
margin-left: 6px;
padding: 1px 6px;
font-size: 10px;
}
}
.cx-chip-tag {
padding: 2px 8px;
border-radius: 6px;
background: #eef2ff;
font-size: 11px;
font-weight: 600;
color: #3730a3;
}
.cx-link-btn {
display: inline-flex;
align-items: center;
gap: 4px;
height: 30px;
padding: 0 10px;
border: 0;
border-radius: 8px;
background: transparent;
font-size: 12px;
font-weight: 600;
color: #1e40af;
cursor: pointer;
mat-icon {
font-size: 16px;
width: 16px;
height: 16px;
}
&:hover {
background: #eff6ff;
}
&--danger {
color: #b91c1c;
&:hover {
background: #fef2f2;
}
}
}
/* PAN: neutral until needed, amber when required and missing, red when malformed, green when valid */
.cx-pan {
display: flex;
flex-direction: column;
align-self: start;
gap: 8px;
padding: 14px;
background: #f8fafc;
border: 1px solid #e2e8f0;
border-radius: 12px;
&__head {
display: flex;
align-items: center;
gap: 8px;
mat-icon {
font-size: 20px;
width: 20px;
height: 20px;
color: #475569;
}
}
&__label {
font-size: 13px;
font-weight: 700;
color: #0f172a;
}
&__tag {
margin-left: auto;
padding: 2px 8px;
border-radius: 999px;
background: #e2e8f0;
font-size: 11px;
font-weight: 600;
color: #475569;
&--on {
background: #fef3c7;
color: #92400e;
}
}
&__input {
position: relative;
input {
width: 100%;
height: 44px;
box-sizing: border-box;
padding: 0 40px 0 12px;
background: #fff;
border: 1.5px solid #cbd5e1;
border-radius: 10px;
font-family: ui-monospace, sfmono-regular, menlo, consolas, monospace;
font-size: 18px;
font-weight: 600;
letter-spacing: 0.18em;
text-transform: uppercase;
color: #0f172a;
&::placeholder {
font-weight: 500;
color: #cbd5e1;
}
&:focus {
outline: none;
border-color: #1f7a4d;
box-shadow: 0 0 0 3px rgb(31 122 77 / 15%);
}
}
}
&__state {
position: absolute;
top: 50%;
right: 10px;
transform: translateY(-50%);
font-size: 22px;
width: 22px;
height: 22px;
&--ok {
color: #16a34a;
}
&--bad {
color: #dc2626;
}
}
&__hint {
display: flex;
align-items: center;
gap: 4px;
@@ -119,53 +584,73 @@
color: #64748b;
mat-icon {
font-size: 16px;
height: 16px;
width: 16px;
font-size: 14px;
width: 14px;
height: 14px;
}
}
}
.cx-pan-required {
display: flex;
align-items: center;
gap: 6px;
padding: 4px 10px;
&--required {
background: #fffbeb;
border: 1px solid #fcd34d;
border-radius: 6px;
font-size: 12px;
font-weight: 600;
color: #92400e;
mat-icon {
font-size: 16px;
height: 16px;
width: 16px;
color: #d97706;
}
}
.cx-field--inline {
flex-direction: row;
align-items: center;
gap: 8px;
span {
white-space: nowrap;
}
input {
width: 180px;
}
}
.cx-field--pan input {
width: 160px;
border-color: #fcd34d;
&:focus {
outline: none;
border-color: #d97706;
.cx-pan__input input {
border-color: #f59e0b;
}
.cx-pan__hint {
font-weight: 600;
color: #92400e;
}
}
&--invalid {
background: #fef2f2;
border-color: #fecaca;
.cx-pan__input input {
border-color: #ef4444;
}
.cx-pan__hint {
font-weight: 600;
color: #b91c1c;
}
}
&--valid {
background: #f0fdf4;
border-color: #bbf7d0;
.cx-pan__input input {
border-color: #22c55e;
}
.cx-pan__hint {
color: #166534;
}
}
}
.cx-spin {
animation: cx-spin 1s linear infinite;
}
@keyframes cx-spin {
to {
transform: rotate(360deg);
}
}
@media (width <= 900px) {
.cx-cust__body {
grid-template-columns: 1fr;
}
}
@media (width <= 600px) {
.cx-cust__walkin {
grid-template-columns: 1fr;
}
}
@@ -345,6 +830,129 @@
border-bottom: 1px dotted #eef2f7;
}
/* Both sides: the cashier's count of each denomination sits between the counter and the amount. */
.cx-row--stock {
grid-template-columns: 64px 120px 1fr 96px;
}
.cx-row--over {
background: #fef2f2;
}
.cx-avail {
padding-left: 6px;
font-size: 12px;
color: #64748b;
strong {
font-weight: 700;
color: #0f172a;
}
/* the count after this exchange: green when it goes up, red when it goes down */
.cx-after {
display: inline-flex;
align-items: center;
gap: 3px;
margin-left: 6px;
font-weight: 600;
mat-icon {
font-size: 16px;
width: 16px;
height: 16px;
}
small {
font-size: 11px;
}
}
&--up .cx-after,
&--up .cx-after strong {
color: #15803d;
}
&--down .cx-after,
&--down .cx-after strong {
color: #b45309;
}
&--over,
&--over strong,
&--over .cx-after,
&--over .cx-after strong {
color: #b91c1c;
}
}
/* The drawer after the whole exchange, one chip per denomination it changes */
.cx-impact {
padding: 12px 14px;
background: #fff;
border: 1px solid #e2e8f0;
border-radius: 12px;
&__title {
display: flex;
align-items: center;
gap: 6px;
margin-bottom: 8px;
font-size: 13px;
font-weight: 700;
color: #0f172a;
mat-icon {
font-size: 18px;
width: 18px;
height: 18px;
color: #475569;
}
}
&__chips {
display: flex;
flex-wrap: wrap;
gap: 8px;
}
&__chip {
display: inline-flex;
align-items: center;
gap: 6px;
padding: 5px 10px;
border-radius: 8px;
background: #f1f5f9;
font-size: 12px;
color: #334155;
b {
font-weight: 700;
}
em {
font-style: normal;
font-weight: 700;
}
&--up {
background: #dcfce7;
color: #166534;
}
&--down {
background: #fef3c7;
color: #92400e;
}
}
&__note {
margin-top: 8px;
font-size: 11px;
color: #64748b;
}
}
.cx-chip {
display: inline-flex;
align-items: center;
@@ -11,7 +11,15 @@ import { CommonModule } from '@angular/common';
import { FormsModule } from '@angular/forms';
import { MatIconModule } from '@angular/material/icon';
import { MatSnackBar, MatSnackBarModule } from '@angular/material/snack-bar';
import { MatDialog, MatDialogModule } from '@angular/material/dialog';
import { forkJoin, of } from 'rxjs';
import { catchError } from 'rxjs/operators';
import { CashierService } from '../cashier.service';
import { ClientsService } from 'app/clients/clients.service';
import {
CustomerSearchDialogComponent,
CustomerSearchResult
} from 'app/shared/customer-search-dialog/customer-search-dialog.component';
import { businessToday } from 'app/core/utils/business-today';
interface Denom {
@@ -21,6 +29,47 @@ interface Denom {
label: string;
}
/** An existing client picked through Customer Search, with what the counter needs to identify them. */
interface SelectedCustomer {
clientId: number;
displayName: string;
cifNumber: string | null;
mobileNo: string | null;
officeName: string | null;
accountNo: string | null;
accountType: string | null;
/** PAN on the client's KYC record, if any. */
kycPan: string | null;
panVerified: boolean;
kycStatus: string;
}
/** The cashier at the counter: the one who takes the customer's notes and hands over the change. */
interface CashierInfo {
cashierId: number;
name: string;
tellerName: string | null;
officeName: string | null;
/** Cash the vault allocated to them for the day, once known. */
allocatedAmount: number | null;
/** Cash they hold now. */
totalAmount: number;
}
/** Same rule as ClientKycApiConstants.PAN_REGEX on the server: 5 letters, 4 digits, 1 letter. */
const PAN_PATTERN = /^[A-Z]{5}[0-9]{4}[A-Z]$/;
/** Income Tax Rule 114B / RBI: a cash exchange above this needs the customer's PAN. */
const PAN_MANDATORY_ABOVE = 50000;
const KYC_STATUS: Record<number, string> = {
100: 'Pending',
200: 'In Progress',
300: 'Verified',
400: 'Rejected',
500: 'Expired'
};
/** Cashier cash-exchange entry: customer gives some denominations and takes equal-value change (net 0). */
@Component({
selector: 'mifosx-cash-exchange-entry',
@@ -31,11 +80,14 @@ interface Denom {
CommonModule,
FormsModule,
MatIconModule,
MatSnackBarModule
MatSnackBarModule,
MatDialogModule
]
})
export class CashExchangeEntryComponent implements OnInit {
private service = inject(CashierService);
private clientsService = inject(ClientsService);
private dialog = inject(MatDialog);
private snackBar = inject(MatSnackBar);
notes: Denom[] = [];
@@ -47,11 +99,17 @@ export class CashExchangeEntryComponent implements OnInit {
saving = false;
// ── Customer info ──
isAccountHolder = false;
accountNo = '';
customerIdProof = '';
/** An existing client found through Customer Search, or a walk-in identified at the counter. */
customerMode: 'existing' | 'walkin' = 'walkin';
selectedCustomer: SelectedCustomer | null = null;
loadingCustomer = false;
walkInName = '';
walkInMobile = '';
panNumber = '';
// ── Cashier position (for denomination overdraft check) ──
// ── Cashier at the counter, and their position (for the denomination overdraft check) ──
/** The cashier linked to the logged-in user; null when the login is not a cashier's. */
cashier: CashierInfo | null = null;
private availableMap = new Map<number, number>();
positionLoaded = false;
@@ -88,7 +146,19 @@ export class CashExchangeEntryComponent implements OnInit {
((res?.denominations || []) as any[]).forEach((d: any) => {
if (d.denominationId) this.availableMap.set(Number(d.denominationId), Number(d.count ?? 0));
});
const cashierId = res?.cashierId ? Number(res.cashierId) : null;
this.cashier = cashierId
? {
cashierId,
name: res.cashierName || '',
tellerName: null,
officeName: null,
allocatedAmount: null,
totalAmount: Number(res.totalAmount ?? 0)
}
: null;
this.positionLoaded = true;
if (cashierId) this.loadCashierDetails(cashierId);
},
error: () => {
this.positionLoaded = true;
@@ -96,6 +166,31 @@ export class CashExchangeEntryComponent implements OnInit {
});
}
/** The counter, branch and today's allocation; the card still shows name and cash in hand without them. */
private loadCashierDetails(cashierId: number): void {
this.service
.getInterCashierCashierSummary(cashierId, this.exchangeDate)
.pipe(catchError(() => of(null)))
.subscribe((r: any) => {
if (!r || this.cashier?.cashierId !== cashierId) return;
this.cashier = {
...this.cashier,
tellerName: r.tellerName || null,
officeName: r.officeName || null,
allocatedAmount: Number(r.allocatedAmount ?? 0)
};
});
}
/** Up to two letters for the cashier's avatar. */
get cashierInitials(): string {
const words = (this.cashier?.name || '').trim().split(/\s+/).filter(Boolean);
if (words.length === 0) return '?';
const first = words[0].charAt(0);
const last = words.length > 1 ? words[words.length - 1].charAt(0) : '';
return (first + last).toUpperCase();
}
countFor(side: 'r' | 'g', d: Denom): number {
return (side === 'r' ? this.receivedCounts : this.givenCounts)[d.id] || 0;
}
@@ -129,14 +224,102 @@ export class CashExchangeEntryComponent implements OnInit {
return this.receivedTotal > 0 && this.receivedTotal === this.givenTotal;
}
/** Above ₹50,000 PAN/ID is mandatory per RBI guideline */
get needsIdProof(): boolean {
return this.receivedTotal >= 50000;
/** Above ₹50,000 the customer's PAN is mandatory (Income Tax Rule 114B, followed under RBI guidelines). */
get needsPan(): boolean {
return this.receivedTotal > PAN_MANDATORY_ABOVE;
}
/** Up to two letters for the avatar: first and last word of the customer's name. */
get customerInitials(): string {
const words = (this.selectedCustomer?.displayName || '').trim().split(/\s+/).filter(Boolean);
if (words.length === 0) return '?';
const first = words[0].charAt(0);
const last = words.length > 1 ? words[words.length - 1].charAt(0) : '';
return (first + last).toUpperCase();
}
get panValue(): string {
return this.panNumber.trim().toUpperCase();
}
/** A PAN that is entered must be well-formed, required or not. */
get panFormatInvalid(): boolean {
return !!this.panValue && !PAN_PATTERN.test(this.panValue);
}
get panMissing(): boolean {
return this.needsPan && !this.panValue;
}
/** The PAN comes from the client's KYC record, so the cashier confirms it rather than types it. */
get panFromKyc(): boolean {
return !!this.selectedCustomer?.kycPan && this.panValue === this.selectedCustomer.kycPan.toUpperCase();
}
/** Who the customer is has to be known: a picked client, or a walk-in's name once PAN is required. */
get customerIdentified(): boolean {
if (this.customerMode === 'existing') return !!this.selectedCustomer;
return !this.needsPan || this.walkInName.trim().length > 0;
}
/** A login with no cashier behind it has no drawer to take notes into or give change from. */
get noCashier(): boolean {
return this.positionLoaded && !this.cashier;
}
/** How many of the denomination the cashier holds, or null while that is not known (loading, or no cashier). */
availableFor(d: Denom): number | null {
if (!this.positionLoaded || !this.cashier) return null;
return this.availableMap.get(d.id) ?? 0;
}
/** The cashier is giving more of this denomination than they hold. */
exceedsAvailable(d: Denom): boolean {
const available = this.availableFor(d);
return available !== null && this.countFor('g', d) > available;
}
/** The + on the given side stops at what the cashier holds; a larger typed number is still caught and shown. */
canGiveMore(d: Denom): boolean {
const available = this.availableFor(d);
return available === null || this.countFor('g', d) < available;
}
/** What this exchange does to the cashier's count of the denomination: notes taken in less notes given out. */
netFor(d: Denom): number {
return this.countFor('r', d) - this.countFor('g', d);
}
/** The cashier's count of the denomination once this exchange is recorded, or null while it is not known. */
drawerAfter(d: Denom): number | null {
const available = this.availableFor(d);
return available === null ? null : available + this.netFor(d);
}
/**
* The denominations this exchange changes, with the drawer's count before and after, so the effect shows
* while the counts are being typed. The branch's total for each denomination moves by the same amount, as
* the customer's notes go into the drawer and the change comes out of it; the vault is not involved.
*/
get impactRows(): Array<{ id: number; label: string; kind: string; net: number; before: number; after: number }> {
if (!this.positionLoaded || !this.cashier) return [];
return this.denoms
.filter((d) => this.netFor(d) !== 0)
.map((d) => ({
id: d.id,
label: d.label,
kind: d.kind,
net: this.netFor(d),
before: this.availableFor(d) ?? 0,
after: this.drawerAfter(d) ?? 0
}));
}
/** Denominations where cashier is giving more than currently available. */
get givenOverdraftItems(): Array<{ label: string; available: number; requested: number }> {
if (!this.positionLoaded) return [];
// Without a cashier every denomination would read as "0 available"; the missing cashier is the
// thing to tell the user, not a stock shortage.
if (!this.positionLoaded || !this.cashier) return [];
return this.denoms
.map((d) => ({ label: d.label, available: this.availableMap.get(d.id) ?? 0, requested: this.countFor('g', d) }))
.filter((item) => item.requested > 0 && item.requested > item.available);
@@ -147,21 +330,77 @@ export class CashExchangeEntryComponent implements OnInit {
}
get valid(): boolean {
return this.matches && (!this.needsIdProof || this.customerIdProof.trim().length > 0) && !this.hasGivenOverdraft;
return (
this.matches &&
!!this.cashier &&
!this.hasGivenOverdraft &&
!this.panFormatInvalid &&
!this.panMissing &&
this.customerIdentified &&
!this.loadingCustomer
);
}
toggleAccountHolder(): void {
this.isAccountHolder = !this.isAccountHolder;
if (!this.isAccountHolder) this.accountNo = '';
setCustomerMode(mode: 'existing' | 'walkin'): void {
if (this.customerMode === mode) return;
this.customerMode = mode;
this.selectedCustomer = null;
this.walkInName = '';
this.walkInMobile = '';
this.panNumber = '';
}
/** Pick an existing client through Customer Search, then load their details and KYC (PAN). */
openCustomerSearch(): void {
this.dialog
// An existing customer without an account is still a known customer, so the account is not required.
.open(CustomerSearchDialogComponent, { width: '960px', maxWidth: '95vw', data: { requireAccount: false } })
.afterClosed()
.subscribe((result: CustomerSearchResult | undefined) => {
if (!result?.clientId) return;
this.loadCustomer(result);
});
}
private loadCustomer(result: CustomerSearchResult): void {
this.loadingCustomer = true;
const id = String(result.clientId);
forkJoin({
client: this.clientsService.getClientData(id, true).pipe(catchError(() => of(null))),
kyc: this.clientsService.getClientKyc(id, true).pipe(catchError(() => of(null)))
}).subscribe(({ client, kyc }: { client: any; kyc: any }) => {
const kycPan: string | null = kyc?.panNumber || result.panNumber || null;
this.selectedCustomer = {
clientId: result.clientId,
displayName: client?.displayName || result.displayName,
cifNumber: client?.cifNumber || null,
mobileNo: client?.mobileNo || result.mobileNo || null,
officeName: client?.officeName || result.officeName || null,
accountNo: result.accountNo || null,
accountType: result.accountType || null,
kycPan,
panVerified: !!kyc?.panVerified,
kycStatus: KYC_STATUS[Number(kyc?.kycStatus?.id ?? kyc?.kycStatus)] || 'Not on record'
};
this.panNumber = kycPan ? kycPan.toUpperCase() : '';
this.loadingCustomer = false;
});
}
clearCustomer(): void {
this.selectedCustomer = null;
this.panNumber = '';
}
clear(): void {
this.receivedCounts = {};
this.givenCounts = {};
this.note = '';
this.accountNo = '';
this.customerIdProof = '';
this.isAccountHolder = false;
this.customerMode = 'walkin';
this.selectedCustomer = null;
this.walkInName = '';
this.walkInMobile = '';
this.panNumber = '';
}
submit(): void {
@@ -171,11 +410,17 @@ export class CashExchangeEntryComponent implements OnInit {
this.denoms
.filter((d) => this.countFor(side, d) > 0)
.map((d) => ({ denominationId: d.id, denominationValue: d.value, count: this.countFor(side, d) }));
const customer = this.customerMode === 'existing' ? this.selectedCustomer : null;
const pan = this.panValue || null;
const payload: any = {
exchangeDate: this.exchangeDate,
note: this.note || null,
customerIdProof: this.customerIdProof.trim() || null,
accountNo: this.isAccountHolder && this.accountNo.trim() ? this.accountNo.trim() : null,
clientId: customer?.clientId ?? null,
accountNo: customer?.accountNo ?? null,
customerName: customer ? customer.displayName : this.walkInName.trim() || null,
customerMobile: customer ? customer.mobileNo : this.walkInMobile.trim() || null,
panNumber: pan,
customerIdProof: pan,
received: build('r'),
given: build('g')
};
@@ -11,25 +11,11 @@
<div>
<div class="pg-eyebrow">CASHIER · HISTORY</div>
<h1 class="pg-title">Cash Movement History</h1>
<p class="pg-sub">All transfers, settlements, and inter-cashier movements. Reverse any active transaction.</p>
<p class="pg-sub">All transfers, settlements, inter-cashier movements and cash exchanges.</p>
</div>
<button type="button" class="pg-btn pg-btn--ghost" (click)="load()"><mat-icon>refresh</mat-icon> Refresh</button>
</header>
<!-- Reversal confirm panel -->
@if (reversingId !== null) {
<div class="reverse-panel">
<mat-icon class="reverse-icon">warning</mat-icon>
<span
>Reverse <strong>#{{ reversingId }}</strong
>? This cannot be undone.</span
>
<input class="reverse-reason" type="text" [(ngModel)]="reverseReason" placeholder="Reason (optional)" />
<button type="button" class="pg-btn pg-btn--danger" (click)="confirmReverse()">Confirm Reversal</button>
<button type="button" class="pg-btn pg-btn--ghost" (click)="cancelReverse()">Cancel</button>
</div>
}
@if (loading) {
<div class="pg-empty">Loading…</div>
}
@@ -134,11 +120,6 @@
>
<mat-icon>visibility</mat-icon>
</button>
@if (!r.isReversed) {
<button type="button" class="pg-btn pg-btn--ghost btn-sm" title="Reverse" (click)="startReverse(r)">
<mat-icon>undo</mat-icon>
</button>
}
</div>
</td>
</tr>
@@ -187,6 +168,12 @@
<span class="detail-meta__label">Date</span>
<span class="detail-meta__val">{{ detail.row.date }}</span>
</div>
@if (detail.row.type === 'exchange') {
<div class="detail-meta__item">
<span class="detail-meta__label">Customer PAN</span>
<span class="detail-meta__val">{{ detail.row.panNumber || '—' }}</span>
</div>
}
<div class="detail-meta__item">
<span class="detail-meta__label">Total Amount</span>
<span class="detail-meta__val detail-meta__val--amount">₹{{ detail.row.amount | number: '1.2-2' }}</span>
@@ -47,38 +47,6 @@
}
}
.reverse-panel {
display: flex;
align-items: center;
gap: 12px;
padding: 12px 16px;
background: #fffbeb;
border: 1px solid #fcd34d;
border-radius: 10px;
flex-wrap: wrap;
font-size: 13px;
color: #92400e;
.reverse-icon {
color: #f59e0b;
font-size: 20px;
height: 20px;
width: 20px;
}
.reverse-reason {
flex: 1 1 200px;
height: 34px;
padding: 0 10px;
border: 1px solid #fcd34d;
border-radius: 7px;
font-size: 13px;
font-family: inherit;
background: #fff;
color: #1a1a1a;
}
}
/* ── Action buttons cell ── */
.action-btns {
display: flex;
@@ -8,9 +8,7 @@
import { Component, OnInit, inject } from '@angular/core';
import { CommonModule } from '@angular/common';
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';
@@ -31,6 +29,8 @@ interface TxnRow {
reversedBy: number | null;
reversedAt: string | null;
reverseReason: string | null;
/** Customer PAN recorded on a cash exchange. */
panNumber?: string | null;
type: 'transfer' | 'settlement' | 'inter' | 'exchange';
}
@@ -52,7 +52,10 @@ interface DetailView {
loading: boolean;
}
/** Unified history of all cash movements with reversal and detail view support. */
/**
* Unified history of all cash movements with a detail view. Read-only: cash movements cannot be reversed
* from here (an earlier reversal still shows as Reversed, with who, when and why).
*/
@Component({
selector: 'mifosx-cash-history',
templateUrl: './cash-history.component.html',
@@ -63,16 +66,13 @@ interface DetailView {
standalone: true,
imports: [
CommonModule,
FormsModule,
MatIconModule,
MatSnackBarModule,
MatTabsModule,
MatPaginatorModule
]
})
export class CashHistoryComponent implements OnInit {
private service = inject(CashierService);
private snackBar = inject(MatSnackBar);
transfers: TxnRow[] = [];
settlements: TxnRow[] = [];
@@ -99,10 +99,6 @@ export class CashHistoryComponent implements OnInit {
private denomKindMap = new Map<number, 'note' | 'coin'>();
reversingId: number | null = null;
reversingType: string | null = null;
reverseReason = '';
detail: DetailView | null = null;
ngOnInit(): void {
@@ -212,7 +208,8 @@ export class CashHistoryComponent implements OnInit {
this.exchanges = (exchangeItems as any[]).map((e) => ({
id: e.id,
date: e.exchangeDate,
fromParty: e.accountNo ? `Account ${e.accountNo}` : 'Walk-in Customer',
fromParty: this.exchangeParty(e),
panNumber: e.panNumber ?? null,
toParty: e.performedByUsername || '—',
amount: e.exchangeAmount,
note: e.note,
@@ -310,7 +307,8 @@ export class CashHistoryComponent implements OnInit {
this.exchanges = (res.pageItems ?? []).map((e: any) => ({
id: e.id,
date: e.exchangeDate,
fromParty: e.accountNo ? `Account ${e.accountNo}` : 'Walk-in Customer',
fromParty: this.exchangeParty(e),
panNumber: e.panNumber ?? null,
toParty: e.performedByUsername || '—',
amount: e.exchangeAmount,
note: e.note,
@@ -392,39 +390,12 @@ export class CashHistoryComponent implements OnInit {
this.detail = null;
}
startReverse(row: TxnRow): void {
this.reversingId = row.id;
this.reversingType = row.type;
this.reverseReason = '';
/** Who the exchange was with: the customer's name (and account, if one), else the account, else a walk-in. */
private exchangeParty(e: any): string {
if (e.customerName) {
return e.accountNo ? `${e.customerName} (A/C ${e.accountNo})` : e.customerName;
}
cancelReverse(): void {
this.reversingId = null;
this.reversingType = null;
this.reverseReason = '';
}
confirmReverse(): void {
if (!this.reversingId || !this.reversingType) return;
const id = this.reversingId;
const type = this.reversingType;
const reason = this.reverseReason;
this.cancelReverse();
let obs$;
if (type === 'transfer') obs$ = this.service.reverseCashTransfer(id, reason);
else if (type === 'settlement') obs$ = this.service.reverseCashSettlement(id, reason);
else if (type === 'exchange') obs$ = this.service.reverseCashExchange(id, reason);
else obs$ = this.service.reverseInterCashierTransfer(id, reason);
obs$.pipe(catchError(() => of(null))).subscribe((res: any) => {
if (res?.reversed) {
this.snackBar.open('Transaction reversed successfully', 'Close', { duration: 3000 });
this.load();
} else {
this.snackBar.open('Reversal failed', 'Close', { duration: 3000 });
}
});
return e.accountNo ? `Account ${e.accountNo}` : 'Walk-in Customer';
}
typeLabel(type: string): string {
@@ -0,0 +1,229 @@
<!--
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="cv-tab-content cta-tab">
<!-- ── TITLE BAR ── -->
<div class="cta-action-bar">
<div class="cta-action-bar__left">
<mat-icon class="cta-action-bar__icon">swap_horiz</mat-icon>
<div>
<div class="cta-action-bar__title">Cash Transfers</div>
<div class="cta-action-bar__sub">
Approve or reject an allocation of cash from the vault to a cashier. The cash leaves the vault only when it is
approved.
</div>
</div>
</div>
<div class="cta-counts">
<span class="cta-count"
><strong>{{ all.length }}</strong> waiting</span
>
</div>
</div>
<!-- ── FILTER BAR ── -->
<div class="cta-filter-bar">
<div class="cta-search-box">
<mat-icon>search</mat-icon>
<input type="text" placeholder="Search by cashier, vault, user or note" [(ngModel)]="searchTerm" />
@if (searchTerm) {
<button type="button" class="cta-search-box__clear" (click)="searchTerm = ''">
<mat-icon>close</mat-icon>
</button>
}
</div>
<button type="button" class="cta-btn cta-btn--refresh" (click)="load()" [disabled]="loading">
<mat-icon>refresh</mat-icon>
</button>
</div>
<!-- ── TABLE ── -->
@if (loading && !loaded) {
<div class="cta-empty"><p class="cta-empty__title">Loading…</p></div>
} @else if (rows.length === 0) {
<div class="cta-empty">
<mat-icon class="cta-empty__icon">inbox</mat-icon>
<p class="cta-empty__title">No cash transfer waiting</p>
<p class="cta-empty__text">There is no cash allocation waiting for approval.</p>
</div>
} @else {
<div class="cta-table-wrap">
<table class="cta-table">
<thead>
<tr>
<th>Date</th>
<th>From</th>
<th>To (Cashier)</th>
<th class="num">Amount</th>
<th>Note</th>
<th>Raised By</th>
<th>Raised At</th>
<th class="cta-th-action">Action</th>
</tr>
</thead>
<tbody>
@for (r of rows; track r.id) {
<tr class="cta-row">
<td class="cta-dt">{{ r.transfer_date || '—' }}</td>
<td>{{ r.from_party || '—' }}</td>
<td class="cta-strong">{{ r.to_party || '—' }}</td>
<td class="num cta-amount">₹{{ r.total_amount | number: '1.2-2' }}</td>
<td class="cta-reason" [title]="r.note || ''">{{ r.note || '—' }}</td>
<td>{{ r.maker_name || '—' }}</td>
<td class="cta-dt">{{ formatDateTime(r.maker_at) }}</td>
<td class="cta-action-cell">
<button type="button" class="cta-btn cta-btn--approve" (click)="review(r)">
<mat-icon>rate_review</mat-icon> Review
</button>
</td>
</tr>
}
</tbody>
</table>
</div>
}
</div>
<!-- ─── Popup: review a request, every note and coin it asks for ───────────── -->
<ng-template #reviewDialog>
@if (reviewing) {
<div class="cta-dlg">
<header class="cta-dlg__head">
<mat-icon class="cta-dlg__ico cta-dlg__ico--ok">swap_horiz</mat-icon>
<div class="cta-dlg__titles">
<div class="cta-dlg__title">Review cash transfer</div>
<div class="cta-dlg__sub">
Raised by {{ reviewing.maker_name || '—' }} · {{ formatDateTime(reviewing.maker_at) }}
</div>
</div>
<button type="button" class="cta-dlg__x" aria-label="Close" (click)="closeReview()">
<mat-icon>close</mat-icon>
</button>
</header>
<div class="cta-dlg__body">
<div class="cta-total">
<span>Amount to release</span>
<strong>₹{{ reviewing.total_amount | number: '1.2-2' }}</strong>
</div>
<div class="cta-grid">
<div class="cta-cell">
<div class="cta-label">Transfer date</div>
<div class="cta-value">{{ reviewing.transfer_date || '—' }}</div>
</div>
<div class="cta-cell">
<div class="cta-label">From</div>
<div class="cta-value">{{ reviewing.from_party || '—' }}</div>
</div>
<div class="cta-cell">
<div class="cta-label">To (cashier)</div>
<div class="cta-value">{{ reviewing.to_party || '—' }}</div>
</div>
<div class="cta-cell">
<div class="cta-label">Notes and coins</div>
<div class="cta-value">
{{ piecesOf(reviewing) }} pieces · {{ linesOf(reviewing).length }} denominations
</div>
</div>
@if (reviewing.note) {
<div class="cta-cell cta-cell--wide">
<div class="cta-label">Note</div>
<div class="cta-value">{{ reviewing.note }}</div>
</div>
}
</div>
<div class="cta-label">Denomination breakdown</div>
<table class="cta-mini">
<thead>
<tr>
<th>Denomination</th>
<th class="num">Count</th>
<th class="num">Amount</th>
</tr>
</thead>
<tbody>
@for (l of linesOf(reviewing); track l.value) {
<tr>
<td>₹{{ l.value }}</td>
<td class="num">{{ l.count }}</td>
<td class="num">₹{{ l.amount | number: '1.2-2' }}</td>
</tr>
}
</tbody>
<tfoot>
<tr>
<td>Total</td>
<td class="num">{{ piecesOf(reviewing) }}</td>
<td class="num">₹{{ reviewing.total_amount | number: '1.2-2' }}</td>
</tr>
</tfoot>
</table>
</div>
<footer class="cta-dlg__foot">
<button type="button" class="cta-btn cta-btn--ghost" (click)="closeReview()">Close</button>
<button
type="button"
class="cta-btn cta-btn--reject"
[disabled]="busyId === reviewing.id"
(click)="rejectFromReview(reviewing)"
>
<mat-icon>close</mat-icon> Reject
</button>
<button
type="button"
class="cta-btn cta-btn--approve"
[disabled]="busyId === reviewing.id"
(click)="approve(reviewing)"
>
<mat-icon>check</mat-icon> Approve
</button>
</footer>
</div>
}
</ng-template>
<!-- ─── Popup: reject with a reason ───────────────────────────────────────── -->
<ng-template #rejectDialog>
@if (rejecting) {
<div class="cta-dlg">
<header class="cta-dlg__head">
<mat-icon class="cta-dlg__ico">block</mat-icon>
<div class="cta-dlg__titles">
<div class="cta-dlg__title">Reject cash transfer</div>
<div class="cta-dlg__sub">₹{{ rejecting.total_amount | number: '1.2-2' }} to {{ rejecting.to_party }}</div>
</div>
<button type="button" class="cta-dlg__x" aria-label="Close" (click)="cancelReject()">
<mat-icon>close</mat-icon>
</button>
</header>
<div class="cta-dlg__body">
<label class="cta-label" for="cta-reason">Reason (required)</label>
<textarea
id="cta-reason"
rows="3"
placeholder="Why is this being rejected?"
[(ngModel)]="rejectReason"
></textarea>
</div>
<footer class="cta-dlg__foot">
<button type="button" class="cta-btn cta-btn--ghost" (click)="cancelReject()">Cancel</button>
<button
type="button"
class="cta-btn cta-btn--reject-solid"
[disabled]="!rejectReason.trim() || busyId === rejecting.id"
(click)="confirmReject()"
>
Confirm reject
</button>
</footer>
</div>
}
</ng-template>
@@ -0,0 +1,573 @@
/**
* 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/.
*/
// Laid out like the other approval tabs (Withdrawal Requests): title bar, filter bar, one table.
:host {
display: block;
}
.cta-tab {
padding: 0;
}
.cta-action-bar {
display: flex;
align-items: center;
justify-content: space-between;
gap: 16px;
padding: 10px 16px;
background: #fff;
border-bottom: 1px solid #e2e8f0;
&__left {
display: flex;
align-items: center;
gap: 12px;
}
&__icon {
font-size: 22px;
width: 22px;
height: 22px;
color: #0f4f3c;
}
&__title {
font-size: 13px;
font-weight: 700;
color: #0d1b2a;
}
&__sub {
max-width: 640px;
margin-top: 1px;
font-size: 11px;
color: #64748b;
}
}
.cta-counts {
display: flex;
gap: 8px;
}
.cta-count {
padding: 2px 10px;
border-radius: 999px;
background: #f1f5f9;
font-size: 11px;
color: #475569;
strong {
color: #0f4f3c;
}
}
.cta-filter-bar {
display: flex;
align-items: center;
gap: 6px;
flex-wrap: wrap;
padding: 6px 16px;
background: #f8fafc;
border-bottom: 1px solid #e2e8f0;
}
.cta-fb-sep {
flex-shrink: 0;
width: 1px;
height: 22px;
margin: 0 4px;
background: #e2e8f0;
}
.cta-search-box {
display: inline-flex;
align-items: center;
gap: 6px;
height: 30px;
min-width: 240px;
padding: 0 10px;
border: 1px solid #e2e8f0;
border-radius: 8px;
background: #fff;
mat-icon {
flex-shrink: 0;
font-size: 17px;
width: 17px;
height: 17px;
color: #94a3b8;
}
&:hover {
border-color: #cbd5e1;
}
&:focus-within {
border-color: #0f4f3c;
box-shadow: 0 0 0 3px rgb(15 79 60 / 8%);
}
input {
flex: 1;
border: none;
outline: none;
background: transparent;
font-family: inherit;
font-size: 12px;
color: #0d1b2a;
&::placeholder {
color: #94a3b8;
}
}
&__clear {
display: inline-flex;
align-items: center;
justify-content: center;
flex-shrink: 0;
width: 20px;
height: 20px;
padding: 0;
border: none;
border-radius: 50%;
background: none;
color: #94a3b8;
cursor: pointer;
mat-icon {
font-size: 15px;
width: 15px;
height: 15px;
color: inherit;
}
&:hover {
color: #dc2626;
background: #fef2f2;
}
}
}
.cta-filter-select {
height: 30px;
min-width: 140px;
padding: 0 10px;
border: 1px solid #e2e8f0;
border-radius: 7px;
background: #fff;
font-family: inherit;
font-size: 12px;
color: #0d1b2a;
cursor: pointer;
&:focus {
outline: none;
border-color: #0f4f3c;
}
}
// ── Buttons ─────────────────────────────────────────────────────────────────
.cta-btn {
display: inline-flex;
align-items: center;
gap: 4px;
height: 26px;
padding: 0 10px;
border: 1px solid transparent;
border-radius: 7px;
font-family: inherit;
font-size: 11px;
font-weight: 600;
white-space: nowrap;
cursor: pointer;
mat-icon {
font-size: 14px;
width: 14px;
height: 14px;
}
&:disabled {
opacity: 0.45;
cursor: not-allowed;
}
&--refresh {
height: 30px;
width: 30px;
justify-content: center;
padding: 0;
margin-left: auto;
border-color: #e2e8f0;
background: #fff;
color: #475569;
}
&--approve {
background: #0f4f3c;
color: #fff;
&:hover:not(:disabled) {
background: #0a3d2e;
}
}
&--reject {
border-color: #fecaca;
background: #fff;
color: #dc2626;
&:hover:not(:disabled) {
background: #fef2f2;
}
}
&--reject-solid {
background: #dc2626;
color: #fff;
}
&--ghost {
border-color: #e2e8f0;
background: #fff;
color: #475569;
}
}
// ── Table ───────────────────────────────────────────────────────────────────
.cta-table-wrap {
overflow-x: auto;
background: #fff;
}
.cta-table {
width: 100%;
border-collapse: collapse;
font-size: 11px;
th {
padding: 7px 10px;
border-bottom: 1px solid #e2e8f0;
background: #f8fafc;
font-size: 9.5px;
font-weight: 800;
letter-spacing: 0.06em;
text-align: left;
text-transform: uppercase;
color: #94a3b8;
white-space: nowrap;
}
td {
padding: 5px 10px;
border-bottom: 1px solid #eef2f7;
vertical-align: middle;
color: #0d1b2a;
}
.num {
text-align: right;
font-variant-numeric: tabular-nums;
}
.cta-th-action {
text-align: right;
}
}
.cta-row:hover td {
background: #f8fafc;
}
.cta-mono {
font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace;
letter-spacing: 0.02em;
}
.cta-voucher {
font-weight: 700;
color: #0f4f3c;
}
.cta-strong {
font-weight: 700;
}
.cta-sub {
margin-top: 1px;
font-size: 11px;
color: #64748b;
}
.cta-none {
color: #cbd5e1;
}
.cta-amount {
font-weight: 700;
}
.cta-reason {
max-width: 220px;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.cta-dt {
white-space: nowrap;
color: #475569;
}
.cta-kind {
display: inline-block;
padding: 2px 10px;
border-radius: 999px;
font-size: 11px;
font-weight: 700;
&--unpass {
background: #fef3c7;
color: #92400e;
}
&--correction {
background: #dbeafe;
color: #1e40af;
}
}
.cta-action-cell {
display: flex;
justify-content: flex-end;
gap: 6px;
white-space: nowrap;
}
// ── Empty ───────────────────────────────────────────────────────────────────
.cta-empty {
padding: 28px 16px;
text-align: center;
color: #94a3b8;
&__icon {
display: block;
margin: 0 auto 6px;
font-size: 30px;
width: 30px;
height: 30px;
opacity: 0.6;
}
&__title {
margin: 0;
font-size: 12px;
font-weight: 700;
color: #64748b;
}
&__text {
margin: 3px 0 0;
font-size: 11px;
}
}
// ── Reject popup ────────────────────────────────────────────────────────────
.cta-dlg {
font-size: 12px;
&__head {
display: flex;
align-items: center;
gap: 10px;
padding: 10px 14px;
border-bottom: 1px solid #e2e8f0;
background: #f8fafc;
}
&__ico {
flex: 0 0 32px;
height: 32px;
width: 32px;
border-radius: 9px;
background: #fef2f2;
color: #dc2626;
font-size: 18px;
line-height: 32px;
text-align: center;
}
&__titles {
flex: 1;
min-width: 0;
}
&__title {
font-size: 14px;
font-weight: 700;
color: #0f172a;
}
&__sub {
font-size: 11px;
color: #64748b;
}
&__x {
display: inline-flex;
align-items: center;
justify-content: center;
height: 28px;
width: 28px;
border: 0;
border-radius: 50%;
background: transparent;
color: #64748b;
cursor: pointer;
&:hover {
background: #e2e8f0;
}
mat-icon {
font-size: 18px;
height: 18px;
width: 18px;
}
}
&__body {
padding: 12px 14px;
max-height: 64vh;
overflow-y: auto;
textarea {
width: 100%;
padding: 7px 10px;
border: 1px solid #cbd5e1;
border-radius: 8px;
font: inherit;
font-size: 12px;
resize: vertical;
&:focus {
outline: 0;
border-color: #0f4f3c;
box-shadow: 0 0 0 3px rgb(15 79 60 / 8%);
}
}
}
&__foot {
display: flex;
justify-content: flex-end;
gap: 8px;
padding: 10px 14px;
border-top: 1px solid #e2e8f0;
background: #f8fafc;
}
}
.cta-label {
display: block;
margin-bottom: 4px;
font-size: 10px;
font-weight: 700;
letter-spacing: 0.05em;
text-transform: uppercase;
color: #94a3b8;
}
.cta-dlg__ico--ok {
background: #f0fdf4;
color: #1f7a4d;
}
.cta-total {
display: flex;
align-items: baseline;
justify-content: space-between;
margin-bottom: 10px;
padding: 8px 12px;
border-radius: 10px;
background: #ecfdf5;
border: 1px solid #a7f3d0;
color: #065f46;
font-size: 12px;
font-weight: 600;
strong {
font-size: 18px;
font-weight: 800;
font-variant-numeric: tabular-nums;
}
}
.cta-grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(150px, 1fr));
gap: 6px;
margin-bottom: 12px;
}
.cta-cell {
padding: 6px 10px;
border-radius: 8px;
background: #f8fafc;
border: 1px solid #eef2f7;
&--wide {
grid-column: 1 / -1;
}
}
.cta-value {
font-size: 12px;
font-weight: 600;
color: #0f172a;
word-break: break-word;
}
.cta-mini {
width: 100%;
margin-top: 4px;
border-collapse: collapse;
font-size: 12px;
th {
padding: 6px 10px;
border-bottom: 1px solid #e2e8f0;
background: #f8fafc;
font-size: 10px;
font-weight: 800;
letter-spacing: 0.05em;
text-align: left;
text-transform: uppercase;
color: #94a3b8;
}
td {
padding: 5px 10px;
border-bottom: 1px solid #eef2f7;
}
.num {
text-align: right;
font-variant-numeric: tabular-nums;
}
tfoot td {
border-top: 2px solid #e2e8f0;
border-bottom: 0;
font-weight: 800;
background: #f8fafc;
}
}
@@ -0,0 +1,190 @@
/**
* 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, TemplateRef, ViewChild, inject } from '@angular/core';
import { CommonModule } from '@angular/common';
import { FormsModule } from '@angular/forms';
import { MatDialog, MatDialogModule, MatDialogRef } from '@angular/material/dialog';
import { MatIconModule } from '@angular/material/icon';
import { MatSnackBar, MatSnackBarModule } from '@angular/material/snack-bar';
import { of } from 'rxjs';
import { catchError } from 'rxjs/operators';
import { CashierService } from '../cashier.service';
/** A cash transfer request waiting for a checker, as the server keeps it (snake_case columns). */
interface CashTransferRequest {
id: number;
transfer_date: string | null;
from_party: string | null;
to_party: string | null;
note: string | null;
total_amount: number;
maker_name: string | null;
maker_at: string | null;
denominations: { denominationValue: number; count: number }[];
}
/**
* Checker side of Cash Transfer: approves or rejects an allocation of cash from the vault to a cashier. The cash
* leaves the vault only when it is approved, by someone other than the person who raised it.
*/
@Component({
selector: 'mifosx-cash-transfer-approvals',
templateUrl: './cash-transfer-approvals.component.html',
styleUrls: ['./cash-transfer-approvals.component.scss'],
standalone: true,
imports: [
CommonModule,
FormsModule,
MatDialogModule,
MatIconModule,
MatSnackBarModule
]
})
export class CashTransferApprovalsComponent implements OnInit {
private service = inject(CashierService);
private snackBar = inject(MatSnackBar);
private dialog = inject(MatDialog);
@ViewChild('rejectDialog') rejectDialog!: TemplateRef<unknown>;
@ViewChild('reviewDialog') reviewDialog!: TemplateRef<unknown>;
private dialogRef: MatDialogRef<unknown> | null = null;
private reviewRef: MatDialogRef<unknown> | null = null;
/** The request open in the review popup, with every note and coin it asks for. */
reviewing: CashTransferRequest | null = null;
all: CashTransferRequest[] = [];
loading = false;
loaded = false;
busyId: number | null = null;
searchTerm = '';
rejecting: CashTransferRequest | null = null;
rejectReason = '';
ngOnInit(): void {
this.load();
}
load(): void {
this.loading = true;
this.service
.getCashTransferRequests('PENDING')
.pipe(catchError(() => of([] as CashTransferRequest[])))
.subscribe((rows) => {
this.all = rows as CashTransferRequest[];
this.loading = false;
this.loaded = true;
});
}
get rows(): CashTransferRequest[] {
const term = this.searchTerm.trim().toLowerCase();
if (!term) return this.all;
return this.all.filter((r) => [
r.from_party,
r.to_party,
r.maker_name,
r.note
].some((v) => (v ?? '').toLowerCase().includes(term)));
}
/** One line per denomination asked for, largest first, with what each line adds up to. */
linesOf(r: CashTransferRequest): { value: number; count: number; amount: number }[] {
return [...(r.denominations ?? [])]
.filter((d) => d.count > 0)
.sort((a, b) => b.denominationValue - a.denominationValue)
.map((d) => ({ value: d.denominationValue, count: d.count, amount: d.denominationValue * d.count }));
}
/** How many notes and coins the request asks for in all. */
piecesOf(r: CashTransferRequest): number {
return this.linesOf(r).reduce((sum, l) => sum + l.count, 0);
}
review(r: CashTransferRequest): void {
this.reviewing = r;
this.reviewRef?.close();
this.reviewRef = this.dialog.open(this.reviewDialog, { width: '560px', maxWidth: '95vw', autoFocus: false });
this.reviewRef.afterClosed().subscribe(() => (this.reviewing = null));
}
closeReview(): void {
this.reviewRef?.close();
}
/** Rejecting from the review popup: the review closes, the reason popup opens for the same request. */
rejectFromReview(r: CashTransferRequest): void {
this.reviewRef?.close();
this.startReject(r);
}
formatDateTime(value: string | null): string {
if (!value) return '—';
const d = new Date(value);
return isNaN(d.getTime())
? value
: d.toLocaleString('en-IN', { day: '2-digit', month: 'short', hour: '2-digit', minute: '2-digit' });
}
approve(r: CashTransferRequest): void {
if (this.busyId) return;
this.busyId = r.id;
this.service.approveCashTransferRequest(r.id).subscribe({
next: (res: any) => {
this.busyId = null;
this.reviewRef?.close();
this.snackBar.open(
res?.glWarning ? `Approved (GL warning: ${res.glWarning})` : 'Approved — cash allocated to the cashier',
'Close',
{ duration: 6000 }
);
this.load();
},
error: (err) => {
this.busyId = null;
this.fail(err, 'Could not approve');
}
});
}
startReject(r: CashTransferRequest): void {
this.rejecting = r;
this.rejectReason = '';
this.dialogRef = this.dialog.open(this.rejectDialog, { width: '440px', maxWidth: '95vw', autoFocus: false });
this.dialogRef.afterClosed().subscribe(() => (this.rejecting = null));
}
cancelReject(): void {
this.dialogRef?.close();
}
confirmReject(): void {
if (!this.rejecting || !this.rejectReason.trim() || this.busyId) return;
const id = this.rejecting.id;
this.busyId = id;
this.service.rejectCashTransferRequest(id, this.rejectReason.trim()).subscribe({
next: () => {
this.busyId = null;
this.dialogRef?.close();
this.snackBar.open('Rejected', 'Close', { duration: 4000 });
this.load();
},
error: (err) => {
this.busyId = null;
this.fail(err, 'Could not reject');
}
});
}
private fail(err: any, fallback: string): void {
const msg = err?.error?.errors?.[0]?.defaultUserMessage ?? err?.error?.defaultUserMessage ?? fallback;
this.snackBar.open(msg, 'Close', { duration: 8000 });
}
}
@@ -13,7 +13,15 @@
<h1 class="pg-title">Cash Transfer</h1>
<p class="pg-sub">Move cash denomination-wise from one party to another (e.g. Vault → Cashier for opening).</p>
</div>
<div class="ct-head-actions">
<button type="button" class="pg-btn pg-btn--ghost" (click)="openRequests()">
<mat-icon>pending_actions</mat-icon> My Requests
@if (pendingCount) {
<span class="ct-badge">{{ pendingCount }}</span>
}
</button>
<button type="button" class="pg-btn pg-btn--ghost" (click)="clear()"><mat-icon>undo</mat-icon> Clear</button>
</div>
</header>
<div class="pg-toolbar pg-toolbar--mat">
@@ -155,7 +163,56 @@
<div class="pg-total-bar">
Total to transfer: <strong>₹{{ total | number: '1.2-2' }}</strong>
<button type="button" class="pg-btn pg-btn--primary" [disabled]="!valid || saving" (click)="submit()">
<mat-icon>swap_horiz</mat-icon> Record Transfer
<mat-icon>send</mat-icon> Send for approval
</button>
</div>
</div>
<!-- Popup: this person's own cash transfer requests, and where each stands. The cash moves only once a manager approves. -->
<ng-template #requestsDialog>
<div class="ct-dlg">
<header class="ct-dlg__head">
<mat-icon class="ct-dlg__ico">pending_actions</mat-icon>
<div class="ct-dlg__titles">
<div class="ct-dlg__title">My transfer requests</div>
<div class="ct-dlg__sub">Cash leaves the vault only after a manager approves</div>
</div>
<button type="button" class="ct-dlg__x" aria-label="Close" (click)="closeRequests()">
<mat-icon>close</mat-icon>
</button>
</header>
<div class="ct-dlg__body">
@if (myRequests.length) {
<table class="pg-table">
<thead>
<tr>
<th>Date</th>
<th>To</th>
<th class="num">Amount</th>
<th>Status</th>
<th>Approved by</th>
</tr>
</thead>
<tbody>
@for (r of myRequests; track r.id) {
<tr>
<td>{{ r.transfer_date || '—' }}</td>
<td>{{ r.to_party || '—' }}</td>
<td class="num">₹{{ r.total_amount | number: '1.2-2' }}</td>
<td>
<span class="ct-status" [class]="'ct-status ct-status--' + r.status">{{ r.status }}</span>
@if (r.rejection_reason) {
<div class="ct-requests__note">{{ r.rejection_reason }}</div>
}
</td>
<td>{{ r.checker_name || '—' }}</td>
</tr>
}
</tbody>
</table>
} @else {
<div class="pg-empty">You have not raised any cash transfer request yet.</div>
}
</div>
</div>
</ng-template>
@@ -49,3 +49,131 @@
font-size: 12px;
color: #991b1b;
}
.ct-requests {
padding: 0;
&__title {
padding: 10px 14px;
border-bottom: 1px solid #e2e8f0;
font-size: 13px;
font-weight: 700;
color: #0d1b2a;
}
&__note {
margin-top: 2px;
font-size: 11px;
color: #64748b;
}
}
.ct-status {
display: inline-block;
padding: 2px 10px;
border-radius: 999px;
background: #e2e8f0;
font-size: 11px;
font-weight: 700;
&--PENDING {
background: #fef3c7;
color: #92400e;
}
&--APPROVED {
background: #d1fae5;
color: #065f46;
}
&--REJECTED {
background: #fee2e2;
color: #991b1b;
}
}
.ct-head-actions {
display: flex;
gap: 8px;
flex-wrap: wrap;
}
.ct-badge {
min-width: 18px;
padding: 0 6px;
border-radius: 999px;
background: #f59e0b;
color: #fff;
font-size: 11px;
font-weight: 700;
text-align: center;
}
.ct-dlg {
font-size: 12px;
&__head {
display: flex;
align-items: center;
gap: 10px;
padding: 10px 14px;
border-bottom: 1px solid #e2e8f0;
background: #f8fafc;
}
&__ico {
flex: 0 0 32px;
height: 32px;
width: 32px;
border-radius: 9px;
background: #f0fdf4;
color: #1f7a4d;
font-size: 18px;
line-height: 32px;
text-align: center;
}
&__titles {
flex: 1;
min-width: 0;
}
&__title {
font-size: 14px;
font-weight: 700;
color: #0f172a;
}
&__sub {
font-size: 11px;
color: #64748b;
}
&__x {
display: inline-flex;
align-items: center;
justify-content: center;
height: 28px;
width: 28px;
border: 0;
border-radius: 50%;
background: transparent;
color: #64748b;
cursor: pointer;
&:hover {
background: #e2e8f0;
}
mat-icon {
font-size: 18px;
height: 18px;
width: 18px;
}
}
&__body {
max-height: 60vh;
overflow-y: auto;
}
}
@@ -6,9 +6,10 @@
* file, You can obtain one at http://mozilla.org/MPL/2.0/.
*/
import { Component, OnInit, inject } from '@angular/core';
import { Component, OnInit, TemplateRef, ViewChild, inject } from '@angular/core';
import { CommonModule } from '@angular/common';
import { FormsModule } from '@angular/forms';
import { MatDialog, MatDialogModule, MatDialogRef } from '@angular/material/dialog';
import { MatIconModule } from '@angular/material/icon';
import { MatSnackBar, MatSnackBarModule } from '@angular/material/snack-bar';
import { MatFormFieldModule } from '@angular/material/form-field';
@@ -18,6 +19,7 @@ import { of } from 'rxjs';
import { catchError } from 'rxjs/operators';
import { CashierService } from '../cashier.service';
import { businessToday } from 'app/core/utils/business-today';
import { AuthenticationService } from '../../core/authentication/authentication.service';
interface Denom {
id: number;
@@ -39,6 +41,7 @@ interface Denom {
CommonModule,
FormsModule,
MatIconModule,
MatDialogModule,
MatSnackBarModule,
MatFormFieldModule,
MatSelectModule,
@@ -48,6 +51,11 @@ interface Denom {
export class CashTransferComponent implements OnInit {
private service = inject(CashierService);
private snackBar = inject(MatSnackBar);
private dialog = inject(MatDialog);
@ViewChild('requestsDialog') requestsDialog!: TemplateRef<unknown>;
private requestsRef: MatDialogRef<unknown> | null = null;
private authenticationService = inject(AuthenticationService);
notes: Denom[] = [];
coins: Denom[] = [];
@@ -82,7 +90,34 @@ export class CashTransferComponent implements OnInit {
vaultHasBreakdown = true;
loadingVault = false;
/** The caller's own transfer requests, newest first, so a request can be followed to its approval. */
myRequests: any[] = [];
loadMyRequests(): void {
this.service
.getCashTransferRequests(undefined, true)
.pipe(catchError(() => of([] as any[])))
.subscribe((rows) => (this.myRequests = rows.slice(0, 20)));
}
/** How many of this person's requests are still waiting for a manager. */
get pendingCount(): number {
return this.myRequests.filter((r) => r.status === 'PENDING').length;
}
/** Opens the list of this person's requests, freshly read, so a request can be followed to its approval. */
openRequests(): void {
this.loadMyRequests();
this.requestsRef?.close();
this.requestsRef = this.dialog.open(this.requestsDialog, { width: '720px', maxWidth: '95vw', autoFocus: false });
}
closeRequests(): void {
this.requestsRef?.close();
}
ngOnInit(): void {
this.loadMyRequests();
const d = businessToday();
this.transferDate = `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, '0')}-${String(d.getDate()).padStart(2, '0')}`;
this.service.getActiveDenominations().subscribe({
@@ -106,7 +141,14 @@ export class CashTransferComponent implements OnInit {
.getTellers()
.pipe(catchError(() => of([] as any[])))
.subscribe((list) => {
this.tellers = (list || []).map((t: any) => ({ id: t.id, name: t.name }));
// The vault and its cash are physical, so a branch moves cash only from its own tellers. The
// backend lists every teller below the user's office (Head Office sees all branches), which is
// right for reports but wrong here.
const myOfficeId = this.authenticationService.getCredentials()?.officeId;
const own = (list || []).filter(
(t: any) => !myOfficeId || t.officeId == null || Number(t.officeId) === Number(myOfficeId)
);
this.tellers = own.map((t: any) => ({ id: t.id, name: t.name }));
if (this.tellers.length === 1) {
this.onTellerSelect(this.tellers[0].id);
}
@@ -245,16 +287,11 @@ export class CashTransferComponent implements OnInit {
.filter((d) => this.countFor(d) > 0)
.map((d) => ({ denominationId: d.id, denominationValue: d.value, count: this.countFor(d) }))
};
this.service.createCashTransfer(payload).subscribe({
next: (res: any) => {
if (res?.glWarning) {
this.snackBar.open(`Transfer saved (GL warning: ${res.glWarning})`, 'Close', {
duration: 8000,
panelClass: ['snack-warn']
});
} else {
this.snackBar.open('Cash transfer recorded', 'Close', { duration: 3000 });
}
this.service.createCashTransferRequest(payload).subscribe({
next: () => {
// Nothing has moved yet: a manager approves the allocation first, and only then does the cash leave the vault.
this.snackBar.open('Cash transfer sent for manager approval', 'Close', { duration: 5000 });
this.loadMyRequests();
this.clear();
this.note = '';
this.fromParty = '';
+21
View File
@@ -23,6 +23,7 @@ import { DayBookComponent } from './day-book/day-book.component';
import { VoucherReportComponent } from './voucher-report/voucher-report.component';
import { BranchDayComponent } from './branch-day/branch-day.component';
import { VaultPositionComponent } from './vault-position/vault-position.component';
import { BranchCashPositionComponent } from './branch-cash-position/branch-cash-position.component';
import { AccountRegisterComponent } from './account-register/account-register.component';
import { ClosingCashReportComponent } from './closing-cash-report/closing-cash-report.component';
import { ExcessTransactionReportComponent } from './excess-transaction-report/excess-transaction-report.component';
@@ -82,6 +83,13 @@ const routes: Routes = [
),
data: { title: 'My Share Requests', breadcrumb: 'My Share Requests' }
},
{
// The same page as the cashier's, reached from the clerk dashboard: a clerk unpasses vouchers here.
path: 'voucher-correction',
loadComponent: () =>
import('./voucher-correction/voucher-correction.component').then((m) => m.VoucherCorrectionComponent),
data: { title: 'Voucher Correction', breadcrumb: 'Voucher Correction' }
},
{
path: 'closure-requests',
loadComponent: () =>
@@ -225,6 +233,13 @@ const routes: Routes = [
component: VaultPositionComponent,
data: { title: 'Vault Position', breadcrumb: 'Vault Position' }
},
{
// What the whole branch holds per denomination: the vault plus every cashier's drawer, so an
// exchange (which leaves the vault alone) is visible in the bank's total.
path: 'branch-position',
component: BranchCashPositionComponent,
data: { title: 'Branch Cash Position', breadcrumb: 'Branch Cash Position' }
},
{
// The same day book the clerk and cashier read, reached from where a principal officer
// signing the day off actually looks. It carries the Officer Verification Sheet — each
@@ -283,6 +298,12 @@ const routes: Routes = [
component: ReconciliationComponent,
data: { title: 'Reconciliation', breadcrumb: 'Reconciliation' }
},
{
path: 'voucher-correction',
loadComponent: () =>
import('./voucher-correction/voucher-correction.component').then((m) => m.VoucherCorrectionComponent),
data: { title: 'Voucher Correction', breadcrumb: 'Voucher Correction' }
},
{
path: 'cash-history',
component: CashHistoryComponent,
+29 -13
View File
@@ -32,9 +32,6 @@ export class CashierService {
getCashExchange(id: number): Observable<any> {
return this.http.get(`/cashexchange/${id}`);
}
reverseCashExchange(id: number, reason: string): Observable<any> {
return this.http.post(`/cashexchange/${id}/reverse`, { reason });
}
// ── Tellers / Cashiers ──
getTellers(officeId?: number): Observable<any[]> {
@@ -50,8 +47,21 @@ export class CashierService {
}
// ── Cash Transfer ──
createCashTransfer(data: any): Observable<any> {
return this.http.post('/cashtransfer', data);
/** Cash leaves the vault only when a different person approves, so the screen raises a request, not a transfer. */
createCashTransferRequest(data: any): Observable<any> {
return this.http.post('/cashtransfer/requests', data);
}
/** Requests by status (comma separated); mine narrows them to the ones the caller raised. */
getCashTransferRequests(status?: string, mine = false): Observable<any[]> {
let params = new HttpParams().set('mine', String(mine));
if (status) params = params.set('status', status);
return this.http.get<any[]>('/cashtransfer/requests', { params });
}
approveCashTransferRequest(id: number): Observable<any> {
return this.http.post(`/cashtransfer/requests/${id}/approve`, {});
}
rejectCashTransferRequest(id: number, reason: string): Observable<any> {
return this.http.post(`/cashtransfer/requests/${id}/reject`, { reason });
}
getCashTransfers(offset = 0, limit = 20): Observable<any> {
const params = new HttpParams().set('offset', offset).set('limit', limit);
@@ -60,9 +70,6 @@ export class CashierService {
getCashTransferById(id: number): Observable<any> {
return this.http.get(`/cashtransfer/${id}`);
}
reverseCashTransfer(id: number, reason: string): Observable<any> {
return this.http.post(`/cashtransfer/${id}/reverse`, { reason });
}
// ── Cash Settlement ──
createCashSettlement(data: any): Observable<any> {
@@ -75,9 +82,6 @@ export class CashierService {
getCashSettlementById(id: number): Observable<any> {
return this.http.get(`/cashsettlement/${id}`);
}
reverseCashSettlement(id: number, reason: string): Observable<any> {
return this.http.post(`/cashsettlement/${id}/reverse`, { reason });
}
// ── Inter-cashier Transfer ──
createInterCashierTransfer(data: any): Observable<any> {
@@ -90,8 +94,10 @@ export class CashierService {
getInterCashierById(id: number): Observable<any> {
return this.http.get(`/cashintercashier/${id}`);
}
reverseInterCashierTransfer(id: number, reason: string): Observable<any> {
return this.http.post(`/cashintercashier/${id}/reverse`, { reason });
/** One cashier's details, today's vault allocation and denomination-wise cash, for either side of a transfer. */
getInterCashierCashierSummary(cashierId: number, date: string): Observable<any> {
const params = new HttpParams().set('cashierId', cashierId).set('date', date);
return this.http.get<any>('/cashintercashier/cashier-summary', { params });
}
// ── Reconciliation ──
@@ -318,6 +324,16 @@ export class CashierService {
if (asOfDate) params = params.set('asOfDate', asOfDate);
return this.http.get('/cashreports/vault-position', { params });
}
/**
* Denomination-wise cash the whole branch holds: its vault(s) plus every cashier's drawer, with the day's
* exchange movement beside it. Not for a cashier -- the server refuses it.
*/
branchCashPosition(asOfDate?: string, officeId?: number): Observable<any> {
let params = new HttpParams();
if (asOfDate) params = params.set('asOfDate', asOfDate);
if (officeId) params = params.set('officeId', String(officeId));
return this.http.get('/cashreports/branch-position', { params });
}
excessTransactionReport(threshold: number, fromDate?: string, toDate?: string): Observable<any[]> {
let params = new HttpParams().set('threshold', String(threshold));
if (fromDate) params = params.set('fromDate', fromDate);
@@ -202,6 +202,7 @@
<th class="num">Amount</th>
<th>Payment</th>
<th>Status</th>
<th>Correction</th>
<th>By</th>
<th>Approved By</th>
<th>Time</th>
@@ -231,6 +232,18 @@
<td>
<span [class]="statusClass(r.status)">{{ r.status }}</span>
</td>
<td>
<!-- Where the voucher stands after a correction: unpassed, revoked, closed or corrected. -->
<mifosx-voucher-status
[status]="r.voucherStatus"
[revokedBy]="r.revokedByVoucherNo"
[replaces]="r.replacesVoucherNo"
[note]="r.correctionNote"
></mifosx-voucher-status>
@if (!r.voucherStatus || r.voucherStatus === 'ACTIVE') {
<span class="db-muted">—</span>
}
</td>
<td>{{ r.handledBy ?? '—' }}</td>
<td>
{{ r.approvedBy ?? '—' }}
@@ -256,7 +269,7 @@
—
}
</td>
<td colspan="5"></td>
<td colspan="6"></td>
</tr>
</tfoot>
</table>
@@ -14,6 +14,7 @@ import { Router } from '@angular/router';
import { catchError, of } from 'rxjs';
import { CashierService } from '../cashier.service';
import { businessToday } from 'app/core/utils/business-today';
import { VoucherStatusComponent, VoucherStatus } from '../voucher-status/voucher-status.component';
/**
* Which part of the day's work a row came from. Set by the server, one per source register.
@@ -26,6 +27,11 @@ type DayBookSection = 'CASHIER' | 'CLERK' | 'SYSTEM';
interface DayBookRow {
voucherNo: string | null;
/** Where the voucher stands after a correction: active, revoked, under correction, or corrected. */
voucherStatus?: VoucherStatus;
revokedByVoucherNo?: string | null;
replacesVoucherNo?: string | null;
correctionNote?: string | null;
section: DayBookSection;
side: 'CR' | 'DR';
kind: string;
@@ -58,7 +64,8 @@ interface DayBookRow {
imports: [
CommonModule,
FormsModule,
MatIconModule
MatIconModule,
VoucherStatusComponent
]
})
export class DayBookComponent implements OnInit {
@@ -11,41 +11,29 @@
<div>
<div class="pg-eyebrow">CASHIER · INTER-CASHIER TRANSFER</div>
<h1 class="pg-title">Inter-Cashier Transfer</h1>
<p class="pg-sub">Move denomination-wise cash between two cashiers within the same teller.</p>
<p class="pg-sub">Hand over denomination-wise cash from your drawer to another cashier at your counter.</p>
</div>
<button type="button" class="pg-btn pg-btn--ghost" (click)="clear()"><mat-icon>undo</mat-icon> Clear</button>
</header>
<div class="pg-toolbar pg-toolbar--mat">
@if (tellers.length === 1) {
<mat-form-field appearance="outline" subscriptSizing="dynamic">
<mat-label>Teller</mat-label>
<input matInput type="text" [value]="tellers[0].name" readonly />
</mat-form-field>
} @else {
<mat-form-field appearance="outline" subscriptSizing="dynamic">
<mat-label>Teller</mat-label>
<mat-select [value]="selectedTellerId" (selectionChange)="onTellerSelect($event.value)">
@for (t of tellers; track t.id) {
<mat-option [value]="t.id">{{ t.name }}</mat-option>
}
@if (tellers.length === 0) {
<mat-option [value]="null" disabled>No tellers configured</mat-option>
}
</mat-select>
</mat-form-field>
@if (ownCashierMissing) {
<div class="ict-alert">
<mat-icon>person_off</mat-icon>
Your login is not linked to a cashier. An inter-cashier transfer is made by the cashier handing the cash over, so
log in as that cashier.
</div>
}
<div class="pg-toolbar pg-toolbar--mat">
<mat-form-field appearance="outline" subscriptSizing="dynamic">
<mat-label>From (Cashier)</mat-label>
<mat-select [value]="fromCashierId" (selectionChange)="onFromSelect($event.value)" [disabled]="!selectedTellerId">
@for (c of availableFrom; track c.id) {
<mat-option [value]="c.id">{{ c.label }}</mat-option>
}
@if (selectedTellerId && allCashiers.length === 0 && !loadingCashiers) {
<mat-option [value]="null" disabled>No cashiers</mat-option>
}
</mat-select>
<mat-label>Teller</mat-label>
<input matInput type="text" [value]="tellerName || '—'" readonly />
</mat-form-field>
<!-- The sender is always the cashier who is logged in; nothing to pick. -->
<mat-form-field appearance="outline" subscriptSizing="dynamic">
<mat-label>From (You)</mat-label>
<input matInput type="text" [value]="fromParty || '—'" readonly />
</mat-form-field>
<mat-form-field appearance="outline" subscriptSizing="dynamic">
@@ -54,7 +42,7 @@
@for (c of availableTo; track c.id) {
<mat-option [value]="c.id">{{ c.label }}</mat-option>
}
@if (fromCashierId && availableTo.length === 0) {
@if (fromCashierId && !loadingCashiers && availableTo.length === 0) {
<mat-option [value]="null" disabled>No other cashiers</mat-option>
}
</mat-select>
@@ -62,7 +50,7 @@
<mat-form-field appearance="outline" subscriptSizing="dynamic">
<mat-label>Date</mat-label>
<input matInput type="date" [value]="transferDate" (change)="transferDate = $any($event.target).value" />
<input matInput type="date" [value]="transferDate" (change)="onDateChange($any($event.target).value)" />
</mat-form-field>
<mat-form-field appearance="outline" subscriptSizing="dynamic" class="pg-note-field">
@@ -71,26 +59,102 @@
</mat-form-field>
</div>
<!-- Both sides of the transfer: who they are, whether the vault has allocated them cash today,
and what they hold now. -->
@if (fromCashierId || toCashierId) {
<div class="ict-parties">
<ng-container
*ngTemplateOutlet="
partyCard;
context: { title: 'From (Sending Cashier)', s: fromSummary, loading: loadingFrom, picked: !!fromCashierId }
"
></ng-container>
<mat-icon class="ict-parties__arrow">arrow_forward</mat-icon>
<ng-container
*ngTemplateOutlet="
partyCard;
context: { title: 'To (Receiving Cashier)', s: toSummary, loading: loadingTo, picked: !!toCashierId }
"
></ng-container>
</div>
}
<ng-template #partyCard let-title="title" let-s="s" let-loading="loading" let-picked="picked">
<div class="ict-party" [class.ict-party--blocked]="s && !s.cashAllocated">
<div class="ict-party__title">{{ title }}</div>
@if (!picked) {
<div class="ict-party__empty">Select a cashier</div>
} @else if (loading) {
<div class="ict-party__empty">Loading…</div>
} @else if (!s) {
<div class="ict-party__empty">Could not load this cashier's details.</div>
} @else {
<div class="ict-party__name">{{ s.cashierName || '—' }}</div>
<div class="ict-party__meta">
<span><mat-icon>point_of_sale</mat-icon> {{ s.tellerName || '—' }}</span>
<span><mat-icon>account_balance</mat-icon> {{ s.officeName || '—' }}</span>
</div>
<div class="ict-party__figures">
<div>
<div class="ict-party__label">Allocated today</div>
<div class="ict-party__value">₹{{ s.allocatedAmount | number: '1.2-2' }}</div>
</div>
<div>
<div class="ict-party__label">Cash in hand</div>
<div class="ict-party__value">₹{{ s.totalAmount | number: '1.2-2' }}</div>
</div>
</div>
@if (!s.cashAllocated) {
<div class="ict-party__warn">
<mat-icon>block</mat-icon> No cash allocated from the vault for this date — inter-cashier transfer is not
allowed.
</div>
}
}
</div>
</ng-template>
@if (fromSummary && toSummary && !bothAllocated) {
<div class="ict-alert">
<mat-icon>block</mat-icon>
Both cashiers must first be allocated cash from the vault (Cash Transfer) for {{ transferDate }} before an
inter-cashier transfer.
</div>
}
@if (anyExceedsHeld) {
<div class="ict-alert">
<mat-icon>error_outline</mat-icon>
The sending cashier does not hold enough of the highlighted denominations.
</div>
}
<div class="pg-card">
<table class="pg-table">
<thead>
<tr>
<th>Denomination</th>
<th class="num">From holds</th>
<th style="text-align: center">Count</th>
<th class="num">Amount</th>
<th class="num">To holds</th>
</tr>
</thead>
<tbody>
@if (notes.length) {
<tr class="pg-section">
<td colspan="3">Bank Notes</td>
<td colspan="5">Bank Notes</td>
</tr>
}
@for (d of notes; track d.id) {
<tr>
<tr [class.ict-row--over]="exceedsHeld(d)">
<td>
<span class="pg-chip">{{ d.label }}</span>
</td>
<td class="num" [class.ict-held--over]="exceedsHeld(d)">
<ng-container
*ngTemplateOutlet="heldCell; context: { n: heldBy(fromSummary, d), delta: -countFor(d) }"
></ng-container>
</td>
<td style="display: flex; justify-content: center">
<div class="pg-step">
<button type="button" (click)="bump(d, -1)" [disabled]="countFor(d) === 0">−</button>
@@ -99,18 +163,28 @@
</div>
</td>
<td class="num">₹{{ subtotal(d) | number: '1.2-2' }}</td>
<td class="num">
<ng-container
*ngTemplateOutlet="heldCell; context: { n: heldBy(toSummary, d), delta: countFor(d) }"
></ng-container>
</td>
</tr>
}
@if (coins.length) {
<tr class="pg-section">
<td colspan="3">Coins</td>
<td colspan="5">Coins</td>
</tr>
}
@for (d of coins; track d.id) {
<tr>
<tr [class.ict-row--over]="exceedsHeld(d)">
<td>
<span class="pg-chip pg-chip--coin">{{ d.label }}</span>
</td>
<td class="num" [class.ict-held--over]="exceedsHeld(d)">
<ng-container
*ngTemplateOutlet="heldCell; context: { n: heldBy(fromSummary, d), delta: -countFor(d) }"
></ng-container>
</td>
<td style="display: flex; justify-content: center">
<div class="pg-step">
<button type="button" (click)="bump(d, -1)" [disabled]="countFor(d) === 0">−</button>
@@ -119,17 +193,34 @@
</div>
</td>
<td class="num">₹{{ subtotal(d) | number: '1.2-2' }}</td>
<td class="num">
<ng-container
*ngTemplateOutlet="heldCell; context: { n: heldBy(toSummary, d), delta: countFor(d) }"
></ng-container>
</td>
</tr>
}
@if (denoms.length === 0) {
<tr>
<td colspan="3" class="pg-empty">No active denominations. Add some in Cash Denomination.</td>
<td colspan="5" class="pg-empty">No active denominations. Add some in Cash Denomination.</td>
</tr>
}
</tbody>
</table>
</div>
<!-- A held count, with what it becomes once this transfer is recorded. -->
<ng-template #heldCell let-n="n" let-delta="delta">
@if (n === null) {
—
} @else {
{{ n }}
@if (delta !== 0) {
<span class="ict-after">→ {{ n + delta }}</span>
}
}
</ng-template>
<div class="pg-total-bar">
Total to transfer: <strong>₹{{ total | number: '1.2-2' }}</strong>
<button type="button" class="pg-btn pg-btn--primary" [disabled]="!valid || saving" (click)="submit()">
@@ -0,0 +1,150 @@
/**
* 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/.
*/
.ict-parties {
display: grid;
grid-template-columns: 1fr auto 1fr;
align-items: stretch;
gap: 12px;
margin-bottom: 12px;
&__arrow {
align-self: center;
color: #64748b;
}
@media (width <= 760px) {
grid-template-columns: 1fr;
&__arrow {
justify-self: center;
transform: rotate(90deg);
}
}
}
.ict-party {
padding: 12px 14px;
background: #fff;
border: 1px solid #e2e8f0;
border-radius: 10px;
&--blocked {
background: #fef2f2;
border-color: #fecaca;
}
&__title {
font-size: 11px;
font-weight: 700;
letter-spacing: 0.04em;
text-transform: uppercase;
color: #64748b;
}
&__empty {
margin-top: 8px;
font-size: 13px;
color: #94a3b8;
}
&__name {
margin-top: 4px;
font-size: 16px;
font-weight: 700;
color: #0f172a;
}
&__meta {
display: flex;
flex-wrap: wrap;
gap: 14px;
margin-top: 4px;
font-size: 12px;
color: #475569;
span {
display: inline-flex;
align-items: center;
gap: 4px;
}
mat-icon {
font-size: 14px;
width: 14px;
height: 14px;
}
}
&__figures {
display: flex;
gap: 24px;
margin-top: 10px;
}
&__label {
font-size: 11px;
color: #64748b;
}
&__value {
font-size: 15px;
font-weight: 700;
color: #0f172a;
}
&__warn {
display: flex;
align-items: center;
gap: 6px;
margin-top: 10px;
font-size: 12px;
font-weight: 600;
color: #991b1b;
mat-icon {
font-size: 16px;
width: 16px;
height: 16px;
}
}
}
.ict-alert {
display: flex;
align-items: center;
gap: 8px;
margin-bottom: 10px;
padding: 8px 14px;
background: #fef2f2;
border: 1px solid #fecaca;
border-radius: 8px;
font-size: 12px;
color: #991b1b;
mat-icon {
font-size: 16px;
width: 16px;
height: 16px;
}
}
.ict-row--over {
background: #fef2f2;
}
.ict-held--over {
color: #b91c1c;
font-weight: 700;
}
.ict-after {
margin-left: 4px;
font-size: 11px;
color: #64748b;
}
@@ -29,12 +29,27 @@ interface Cashier {
id: number;
label: string;
}
/** One side of the transfer, from GET /cashintercashier/cashier-summary. */
interface CashierSummary {
cashierId: number;
cashierName: string;
tellerName: string;
officeName: string;
cashAllocated: boolean;
allocatedAmount: number;
totalAmount: number;
/** Count held per denomination id. */
held: Record<number, number>;
}
/** Move cash denomination-wise between two cashiers under the same teller. */
@Component({
selector: 'mifosx-inter-cashier-transfer',
templateUrl: './inter-cashier-transfer.component.html',
styleUrls: ['../cashier-shared.scss'],
styleUrls: [
'../cashier-shared.scss',
'./inter-cashier-transfer.component.scss'
],
standalone: true,
imports: [
CommonModule,
@@ -57,7 +72,12 @@ export class InterCashierTransferComponent implements OnInit {
note = '';
saving = false;
tellers: { id: number; name: string }[] = [];
/**
* The sending cashier is always the one logged in: cash is handed over from their own drawer, so there is
* no one else to pick on that side. Only the receiving cashier is chosen.
*/
ownCashierMissing = false;
tellerName = '';
allCashiers: Cashier[] = [];
selectedTellerId: number | null = null;
fromCashierId: number | null = null;
@@ -66,15 +86,19 @@ export class InterCashierTransferComponent implements OnInit {
toParty = '';
loadingCashiers = false;
/** Details and denomination-wise cash of each side, so both are seen before the transfer is recorded. */
fromSummary: CashierSummary | null = null;
toSummary: CashierSummary | null = null;
loadingFrom = false;
loadingTo = false;
get denoms(): Denom[] {
return [
...this.notes,
...this.coins
];
}
get availableFrom(): Cashier[] {
return this.allCashiers;
}
/** Everyone under the same teller except the sender. */
get availableTo(): Cashier[] {
return this.allCashiers.filter((c) => c.id !== this.fromCashierId);
}
@@ -98,25 +122,20 @@ export class InterCashierTransferComponent implements OnInit {
this.coins = [];
}
});
this.service
.getTellers()
.pipe(catchError(() => of([] as any[])))
.subscribe((list) => {
this.tellers = (list || []).map((t: any) => ({ id: t.id, name: t.name }));
if (this.tellers.length === 1) {
this.onTellerSelect(this.tellers[0].id);
// The sender is the cashier behind this login; their counter then gives the receivers to pick from.
this.service.getCurrentCashierInfo().subscribe((info) => {
if (!info) {
this.ownCashierMissing = true;
return;
}
this.fromCashierId = info.cashierId;
this.fromParty = info.cashierName;
this.loadSummary('from');
});
}
onTellerSelect(tellerId: number): void {
this.selectedTellerId = tellerId;
this.fromCashierId = null;
this.toCashierId = null;
this.fromParty = '';
this.toParty = '';
this.allCashiers = [];
if (!tellerId) return;
/** The other cashiers at this counter, one of whom receives the cash. */
private loadReceivers(tellerId: number): void {
this.loadingCashiers = true;
this.service
.getCashiersForTeller(tellerId)
@@ -127,20 +146,90 @@ export class InterCashierTransferComponent implements OnInit {
});
}
onFromSelect(cashierId: number): void {
this.fromCashierId = cashierId;
const c = this.allCashiers.find((x) => x.id === cashierId);
this.fromParty = c ? c.label : '';
if (this.toCashierId === cashierId) {
this.toCashierId = null;
this.toParty = '';
}
}
onToSelect(cashierId: number): void {
this.toCashierId = cashierId;
const c = this.allCashiers.find((x) => x.id === cashierId);
this.toParty = c ? c.label : '';
this.loadSummary('to');
}
onDateChange(value: string): void {
this.transferDate = value;
this.loadSummary('from');
this.loadSummary('to');
}
/** Fetch one side's details, vault allocation and denomination-wise cash for the transfer date. */
private loadSummary(side: 'from' | 'to'): void {
const cashierId = side === 'from' ? this.fromCashierId : this.toCashierId;
if (side === 'from') {
this.fromSummary = null;
} else {
this.toSummary = null;
}
if (!cashierId || !this.transferDate) return;
this.setLoading(side, true);
this.service
.getInterCashierCashierSummary(cashierId, this.transferDate)
.pipe(catchError(() => of(null)))
.subscribe((r: any) => {
this.setLoading(side, false);
// A late answer for a cashier no longer selected on that side is dropped.
const current = side === 'from' ? this.fromCashierId : this.toCashierId;
if (!r || current !== cashierId) return;
// The sender's summary also tells which counter they are at, hence who they can hand cash to.
if (side === 'from' && r.tellerId && this.selectedTellerId !== Number(r.tellerId)) {
this.selectedTellerId = Number(r.tellerId);
this.tellerName = r.tellerName || '';
this.loadReceivers(this.selectedTellerId);
}
const held: Record<number, number> = {};
for (const d of r.denominations || []) {
held[d.denominationId] = (held[d.denominationId] || 0) + Number(d.count || 0);
}
const summary: CashierSummary = {
cashierId: r.cashierId,
cashierName: r.cashierName,
tellerName: r.tellerName,
officeName: r.officeName,
cashAllocated: !!r.cashAllocated,
allocatedAmount: Number(r.allocatedAmount || 0),
totalAmount: Number(r.totalAmount || 0),
held
};
if (side === 'from') {
this.fromSummary = summary;
} else {
this.toSummary = summary;
}
});
}
private setLoading(side: 'from' | 'to', loading: boolean): void {
if (side === 'from') {
this.loadingFrom = loading;
} else {
this.loadingTo = loading;
}
}
/** Count of the denomination the cashier holds, or null while their figures are not loaded. */
heldBy(summary: CashierSummary | null, d: Denom): number | null {
return summary ? summary.held[d.id] || 0 : null;
}
/** The sending cashier is asked to hand on more of this denomination than they hold. */
exceedsHeld(d: Denom): boolean {
return !!this.fromSummary && this.countFor(d) > (this.fromSummary.held[d.id] || 0);
}
get anyExceedsHeld(): boolean {
return this.denoms.some((d) => this.exceedsHeld(d));
}
/** Both sides must have been allocated cash from the vault for the day before they can transfer. */
get bothAllocated(): boolean {
return !!this.fromSummary?.cashAllocated && !!this.toSummary?.cashAllocated;
}
countFor(d: Denom): number {
@@ -160,7 +249,14 @@ export class InterCashierTransferComponent implements OnInit {
return this.denoms.reduce((s, d) => s + this.subtotal(d), 0);
}
get valid(): boolean {
return this.total > 0 && !!this.selectedTellerId && !!this.fromCashierId && !!this.toCashierId;
return (
this.total > 0 &&
!!this.selectedTellerId &&
!!this.fromCashierId &&
!!this.toCashierId &&
this.bothAllocated &&
!this.anyExceedsHeld
);
}
clear(): void {
@@ -187,10 +283,12 @@ export class InterCashierTransferComponent implements OnInit {
this.snackBar.open('Inter-cashier transfer recorded', 'Close', { duration: 3000 });
this.clear();
this.note = '';
this.fromCashierId = null;
// The sender stays the logged-in cashier; the receiver is picked afresh, and the sender's holdings
// are read again now that part of them has gone.
this.toCashierId = null;
this.fromParty = '';
this.toParty = '';
this.toSummary = null;
this.loadSummary('from');
this.saving = false;
},
error: () => {
@@ -146,6 +146,7 @@
<th class="num">Amount</th>
<th>My part</th>
<th>Status</th>
<th>Correction</th>
<th>Time</th>
</tr>
</thead>
@@ -203,6 +204,18 @@
{{ statusLabel(r.verificationStatus) }}
</span>
</td>
<td>
<!-- Where the voucher stands after a correction: unpassed, revoked, closed or corrected. -->
<mifosx-voucher-status
[status]="r.voucherStatus"
[revokedBy]="r.revokedByVoucherNo"
[replaces]="r.replacesVoucherNo"
[note]="r.correctionNote"
></mifosx-voucher-status>
@if (!r.voucherStatus || r.voucherStatus === 'ACTIVE') {
<span class="mw-acct">—</span>
}
</td>
<td class="mw-time">{{ r.createdAt | date: 'HH:mm' }}</td>
</tr>
}
+8 -1
View File
@@ -15,6 +15,7 @@ import { catchError, finalize, of } from 'rxjs';
import { CashierService } from '../cashier.service';
import { VoucherDetailDialogComponent } from './voucher-detail-dialog.component';
import { businessToday } from 'app/core/utils/business-today';
import { VoucherStatusComponent, VoucherStatus } from '../voucher-status/voucher-status.component';
/** What the signed-in user's part in a voucher was. */
export type MyRole = 'RAISED' | 'VERIFIED' | 'RAISED_AND_VERIFIED';
@@ -24,6 +25,11 @@ export type VoucherSide = 'CR' | 'DR' | 'JV';
interface MyWorkRow {
voucherNo: string;
/** Where the voucher stands after a correction: active, revoked, under correction, or corrected. */
voucherStatus?: VoucherStatus;
revokedByVoucherNo?: string | null;
replacesVoucherNo?: string | null;
correctionNote?: string | null;
side: VoucherSide | null;
voucherType: string | null;
amount: number | null;
@@ -76,7 +82,8 @@ interface MyWorkReport {
CommonModule,
FormsModule,
MatIconModule,
MatDialogModule
MatDialogModule,
VoucherStatusComponent
]
})
export class MyWorkComponent implements OnInit {
@@ -11,6 +11,7 @@ import { CommonModule } from '@angular/common';
import { MatDialogModule, MatDialogRef, MAT_DIALOG_DATA } from '@angular/material/dialog';
import { MatButtonModule } from '@angular/material/button';
import { MatIconModule } from '@angular/material/icon';
import { VoucherStatusComponent, VoucherStatus } from '../voucher-status/voucher-status.component';
export interface VoucherDetailDialogData {
/** Already-loaded detail from CashierService.voucherDetail() — the caller fetches, this dialog
@@ -18,6 +19,10 @@ export interface VoucherDetailDialogData {
* duplicated inside every dialog that could ever show a voucher. */
detail: {
voucherNo: string;
voucherStatus?: VoucherStatus;
revokedByVoucherNo?: string | null;
replacesVoucherNo?: string | null;
correctionNote?: string | null;
voucherType: string | null;
amount: number | null;
branchName: string | null;
@@ -58,7 +63,8 @@ export interface VoucherDetailDialogData {
CommonModule,
MatDialogModule,
MatButtonModule,
MatIconModule
MatIconModule,
VoucherStatusComponent
],
template: `
<div class="vd-shell">
@@ -66,7 +72,15 @@ export interface VoucherDetailDialogData {
<div class="vd-header__title">
<mat-icon class="vd-icon">receipt_long</mat-icon>
<div>
<h2>{{ detail.voucherNo }}</h2>
<h2>
{{ detail.voucherNo }}
<mifosx-voucher-status
[status]="detail.voucherStatus"
[revokedBy]="detail.revokedByVoucherNo"
[replaces]="detail.replacesVoucherNo"
[note]="detail.correctionNote"
></mifosx-voucher-status>
</h2>
<p>{{ detail.voucherType || 'Voucher' }} &middot; {{ detail.branchName || 'Head Office' }}</p>
</div>
</div>
@@ -19,34 +19,21 @@
<!-- ── Entry Tab ── -->
<mat-tab label="New Reconciliation">
<div class="pg-tab-body">
@if (ownCashierMissing) {
<div class="pg-empty">
Your login is not linked to a cashier, so there is no cash drawer to reconcile. Ask an administrator to link
your user to a cashier.
</div>
}
<div class="pg-toolbar pg-toolbar--mat">
@if (tellers.length === 1) {
<!-- A reconciliation is the cashier's own count of their own drawer: nothing to pick. -->
<mat-form-field appearance="outline" subscriptSizing="dynamic">
<mat-label>Teller</mat-label>
<input matInput type="text" [value]="tellers[0].name" readonly />
<input matInput type="text" [value]="ownTellerName || '—'" readonly />
</mat-form-field>
} @else {
<mat-form-field appearance="outline" subscriptSizing="dynamic">
<mat-label>Teller</mat-label>
<mat-select [value]="selectedTellerId" (selectionChange)="onTellerSelect($event.value)">
@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</mat-label>
<mat-select
[value]="selectedCashierId"
(selectionChange)="onCashierSelect($event.value)"
[disabled]="!selectedTellerId"
>
@for (c of cashiers; track c.id) {
<mat-option [value]="c.id">{{ c.label }}</mat-option>
}
</mat-select>
<mat-label>Cashier (You)</mat-label>
<input matInput type="text" [value]="cashierName || '—'" readonly />
</mat-form-field>
<mat-form-field appearance="outline" subscriptSizing="dynamic">
@@ -12,7 +12,6 @@ import { FormsModule } from '@angular/forms';
import { MatIconModule } from '@angular/material/icon';
import { MatSnackBar, MatSnackBarModule } from '@angular/material/snack-bar';
import { MatFormFieldModule } from '@angular/material/form-field';
import { MatSelectModule } from '@angular/material/select';
import { MatInputModule } from '@angular/material/input';
import { MatTabsModule } from '@angular/material/tabs';
import { forkJoin, of } from 'rxjs';
@@ -58,7 +57,6 @@ interface ReconRecord {
MatIconModule,
MatSnackBarModule,
MatFormFieldModule,
MatSelectModule,
MatInputModule,
MatTabsModule
]
@@ -67,9 +65,14 @@ export class ReconciliationComponent implements OnInit {
private service = inject(CashierService);
private snackBar = inject(MatSnackBar);
/**
* A reconciliation is the cashier's own count of their own drawer, so nobody else makes one and there is
* no cashier to pick: the screen is fixed to the logged-in cashier. The server refuses anything else.
*/
ownCashierMissing = false;
ownTellerName = '';
// ── Entry form ──
tellers: { id: number; name: string }[] = [];
cashiers: { id: number; label: string }[] = [];
selectedTellerId: number | null = null;
selectedCashierId: number | null = null;
cashierName = '';
@@ -88,42 +91,28 @@ export class ReconciliationComponent implements OnInit {
ngOnInit(): void {
const d = businessToday();
this.reconDate = `${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 }));
if (this.tellers.length === 1) {
this.onTellerSelect(this.tellers[0].id);
}
});
this.lockToOwnCashier();
this.loadHistory();
}
onTellerSelect(tellerId: number): void {
this.selectedTellerId = tellerId;
this.selectedCashierId = null;
this.cashierName = '';
this.cashiers = [];
this.rows = [];
if (!tellerId) return;
/** Take the logged-in cashier as the one being reconciled, with their counter for the record. */
private lockToOwnCashier(): void {
this.service.getCurrentCashierInfo().subscribe((info) => {
if (!info) {
this.ownCashierMissing = true;
return;
}
this.selectedCashierId = info.cashierId;
this.cashierName = info.cashierName;
this.service
.getCashiersForTeller(tellerId)
.pipe(catchError(() => of({ cashiers: [] })))
.getInterCashierCashierSummary(info.cashierId, this.reconDate)
.pipe(catchError(() => of(null)))
.subscribe((r: any) => {
this.cashiers = (r?.cashiers || []).map((c: any) => ({ id: c.id, label: c.staffName }));
if (this.cashiers.length === 1) {
this.onCashierSelect(this.cashiers[0].id);
}
this.selectedTellerId = r?.tellerId ?? null;
this.ownTellerName = r?.tellerName || '';
});
}
onCashierSelect(cashierId: number): void {
this.selectedCashierId = cashierId;
const c = this.cashiers.find((x) => x.id === cashierId);
this.cashierName = c ? c.label : '';
this.rows = [];
this.fetchExpected();
});
}
fetchExpected(): void {
@@ -117,6 +117,8 @@
<select
class="ws-f__ctl"
[(ngModel)]="row.typ"
[disabled]="isTypLocked(row)"
[title]="isTypLocked(row) ? 'Token withdrawal - TYPE is fixed to Payment' : ''"
(click)="$event.stopPropagation()"
(change)="onTypChange(row)"
>
@@ -127,11 +129,16 @@
</div>
<div class="ws-f ws-f--branch">
<label class="ws-f__lbl" title="Branch">BRANCH</label>
<select class="ws-f__ctl" [(ngModel)]="row.br" (click)="$event.stopPropagation()">
<option [ngValue]="1">001 · Main</option>
<option [ngValue]="2">002 · M.G. Road</option>
<option [ngValue]="14">014 · Station Rd</option>
<option [ngValue]="27">027 · Industrial</option>
<select
class="ws-f__ctl"
[(ngModel)]="row.br"
[disabled]="branchSelectOptions.length <= 1"
[title]="branchSelectOptions.length <= 1 ? 'Your branch' : 'Branch'"
(click)="$event.stopPropagation()"
>
@for (b of branchSelectOptions; track b.id) {
<option [ngValue]="b.id">{{ b.name }}</option>
}
</select>
</div>
@if (row.typ === 'DD') {
@@ -177,13 +184,7 @@
class="ws-f__ctl"
[(ngModel)]="row.acTyp"
(click)="$event.stopPropagation()"
(change)="
row.acTyp === 'GL' &&
row.typ !== 'Disbursement' &&
row.typ !== 'Fund Transfer' &&
row.typ !== 'Enter Bank Transfer' &&
(row.dbCr = 'Cr')
"
(change)="isCreditOnly(row) && (row.dbCr = 'Cr')"
>
@for (a of acTypOptionsFor(row); track a) {
<option [value]="a">{{ a }}</option>
@@ -266,12 +267,7 @@
type="button"
class="ws-seg__btn ws-seg__btn--dr"
[class.ws-seg__btn--active-dr]="row.dbCr === 'Dr'"
[disabled]="
row.acTyp === 'GL' &&
row.typ !== 'Disbursement' &&
row.typ !== 'Fund Transfer' &&
row.typ !== 'Enter Bank Transfer'
"
[disabled]="isCreditOnly(row)"
(click)="row.dbCr = 'Dr'"
>
DR
@@ -280,6 +276,7 @@
type="button"
class="ws-seg__btn ws-seg__btn--cr"
[class.ws-seg__btn--active-cr]="row.dbCr === 'Cr'"
[disabled]="row.typ === 'Withdrawal Slip'"
(click)="row.dbCr = 'Cr'"
>
CR
@@ -711,11 +708,38 @@
<circle cx="12" cy="8" r="4" />
<path d="M4 20c0-4 3.6-7 8-7s8 3 8 7" />
</svg>
Personal Information
{{ isGlAccount ? 'GL Account Information' : 'Personal Information' }}
</div>
@if (isGlAccount) {
<div class="te-panel__body">
@for (f of glInfo; track f.label) {
<div class="te-field">
<span class="te-label">{{ f.label }}</span
><span class="te-value te-value-wrap" [class.te-mono]="f.mono">{{ f.value }}</span>
</div>
}
</div>
} @else {
<div class="te-panel__body">
<div class="te-field">
<span class="te-label">Branch</span><span class="te-value">{{ officeName }}</span>
<span class="te-label">Branch</span
><span class="te-value">
@if (isOwnBranchAccount !== null) {
<span
class="te-branch-chip"
[class.te-branch-chip--mine]="isOwnBranchAccount"
[class.te-branch-chip--other]="!isOwnBranchAccount"
[title]="isOwnBranchAccount ? 'Held at your branch' : 'Held at another branch'"
>
<i class="fas fa-map-marker-alt"></i>{{ officeName }}
@if (!isOwnBranchAccount) {
<span class="te-branch-chip__tag">Other branch</span>
}
</span>
} @else {
{{ officeName }}
}
</span>
</div>
<div class="te-field">
<span class="te-label">A/c No.</span
@@ -775,6 +799,7 @@
</div>
}
</div>
}
</div>
<!-- Joint Holders + Nominees -->
@@ -929,6 +954,7 @@
<!-- Other Info + KYC + Account Balances -->
<div class="te-panel">
@if (!isGlAccount) {
<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" />
@@ -997,8 +1023,9 @@
}
}
</div>
}
<div class="te-panel__header te-panel__header--mt">
<div class="te-panel__header" [class.te-panel__header--mt]="!isGlAccount">
<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" />
@@ -1191,7 +1218,24 @@
: '—')
}}</span
>
<span class="te-acs-kv"><b>Branch</b> {{ officeName ?? '—' }}</span>
<span class="te-acs-kv"
><b>Branch</b>
@if (isOwnBranchAccount !== null) {
<span
class="te-branch-chip"
[class.te-branch-chip--mine]="isOwnBranchAccount"
[class.te-branch-chip--other]="!isOwnBranchAccount"
[title]="isOwnBranchAccount ? 'Held at your branch' : 'Held at another branch'"
>
<i class="fas fa-map-marker-alt"></i>{{ officeName }}
@if (!isOwnBranchAccount) {
<span class="te-branch-chip__tag">Other branch</span>
}
</span>
} @else {
{{ officeName }}
}
</span>
<span class="te-acs-kv"
><b>Product</b>
{{
@@ -1567,6 +1611,8 @@
<select
class="ws-f__ctl"
[(ngModel)]="row.typ"
[disabled]="isTypLocked(row)"
[title]="isTypLocked(row) ? 'Token withdrawal - TYPE is fixed to Payment' : ''"
(click)="$event.stopPropagation()"
(change)="onTypChange(row)"
>
@@ -1577,11 +1623,16 @@
</div>
<div class="ws-f ws-f--branch">
<label class="ws-f__lbl">BRANCH</label>
<select class="ws-f__ctl" [(ngModel)]="row.br" (click)="$event.stopPropagation()">
<option [ngValue]="1">001 · Main</option>
<option [ngValue]="2">002 · M.G. Road</option>
<option [ngValue]="14">014 · Station Rd</option>
<option [ngValue]="27">027 · Industrial</option>
<select
class="ws-f__ctl"
[(ngModel)]="row.br"
[disabled]="branchSelectOptions.length <= 1"
[title]="branchSelectOptions.length <= 1 ? 'Your branch' : 'Branch'"
(click)="$event.stopPropagation()"
>
@for (b of branchSelectOptions; track b.id) {
<option [ngValue]="b.id">{{ b.name }}</option>
}
</select>
</div>
@if (row.typ === 'DD') {
@@ -1627,13 +1678,7 @@
class="ws-f__ctl"
[(ngModel)]="row.acTyp"
(click)="$event.stopPropagation()"
(change)="
row.acTyp === 'GL' &&
row.typ !== 'Disbursement' &&
row.typ !== 'Fund Transfer' &&
row.typ !== 'Enter Bank Transfer' &&
(row.dbCr = 'Cr')
"
(change)="isCreditOnly(row) && (row.dbCr = 'Cr')"
>
@for (a of acTypOptionsFor(row); track a) {
<option [value]="a">{{ a }}</option>
@@ -1704,12 +1749,7 @@
type="button"
class="ws-seg__btn ws-seg__btn--dr"
[class.ws-seg__btn--active-dr]="row.dbCr === 'Dr'"
[disabled]="
row.acTyp === 'GL' &&
row.typ !== 'Disbursement' &&
row.typ !== 'Fund Transfer' &&
row.typ !== 'Enter Bank Transfer'
"
[disabled]="isCreditOnly(row)"
(click)="$event.stopPropagation(); row.dbCr = 'Dr'"
>
DR
@@ -1718,6 +1758,7 @@
type="button"
class="ws-seg__btn ws-seg__btn--cr"
[class.ws-seg__btn--active-cr]="row.dbCr === 'Cr'"
[disabled]="row.typ === 'Withdrawal Slip'"
(click)="$event.stopPropagation(); row.dbCr = 'Cr'"
>
CR
@@ -2043,7 +2084,13 @@
<button class="ws-btn ws-btn--ghost" type="button" (click)="onSearch()">Search</button>
<button class="ws-btn ws-btn--mode" type="button">Change Tr.Mode</button>
@if (isClerk) {
<button class="ws-btn ws-btn--add" type="button" (click)="addRow()" title="Add another transaction row">
<button
class="ws-btn ws-btn--add"
type="button"
[disabled]="selectedRow?.typ === 'Withdrawal Slip'"
(click)="addRow()"
title="Add another transaction row"
>
<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5">
<line x1="12" y1="5" x2="12" y2="19" />
<line x1="5" y1="12" x2="19" y2="12" />
@@ -3462,3 +3462,39 @@ select.te-cell {
box-shadow: none;
}
}
/* The branch an account is held at, coloured by where the signed-in user works: green for their own branch, red for any other. */
.te-branch-chip {
display: inline-flex;
align-items: center;
gap: 6px;
padding: 2px 10px;
border: 1px solid transparent;
border-radius: 999px;
font-size: 12px;
font-weight: 700;
white-space: nowrap;
&--mine {
background: #dcfce7;
border-color: #4ade80;
color: #166534;
}
&--other {
background: #fca5a5;
border-color: #f87171;
color: #7f1d1d;
}
}
.te-branch-chip__tag {
padding: 0 6px;
border-radius: 8px;
background: rgb(255 255 255 / 80%);
color: #7f1d1d;
font-size: 10px;
font-weight: 800;
letter-spacing: 0.04em;
text-transform: uppercase;
}
@@ -10,7 +10,8 @@ import { Component, OnInit, OnDestroy, inject, Input, ElementRef, ViewChild } fr
import { CommonModule } from '@angular/common';
import { DomSanitizer, SafeResourceUrl } from '@angular/platform-browser';
import { FormsModule } from '@angular/forms';
import { HttpClient, HttpParams } from '@angular/common/http';
import { HttpClient, HttpContext, HttpParams } from '@angular/common/http';
import { SILENT_HTTP_ERROR } from 'app/core/http/silent-error.token';
import { catchError, debounceTime, distinctUntilChanged, map, timeout } from 'rxjs/operators';
import { forkJoin, of, Subject } from 'rxjs';
import { MatDialog, MatDialogModule, MAT_DIALOG_DATA, MatDialogRef } from '@angular/material/dialog';
@@ -1658,6 +1659,8 @@ export class TransactionEntryComponent implements OnInit, OnDestroy {
// Full account + client data
accountDetails: any = null;
/** Parent (header) GL of the selected GL account, shown in the GL Account Information panel. */
glParent: { glCode?: string; name?: string } | null = null;
clientDetails: any = null;
kycDetails: any = null;
nominees: any[] = [];
@@ -1685,10 +1688,12 @@ export class TransactionEntryComponent implements OnInit, OnDestroy {
private searchToken = 0;
private lastLoadedAcNo = '';
private acNoInput$ = new Subject<{ row: TxnRow; index: number; value: string }>();
/** How many characters of the A/C NO. start the automatic account search. */
private static readonly AC_NO_AUTO_SEARCH_MIN_LENGTH = 6;
cashVaultGlId: number | null = null;
/** The Cash Vault GL account's own code and name — the denomination breakdown for a manual GL
* entry has to be filed against THIS account, not whichever account the teller typed in, since
* that account is never the one physical notes actually moved through (see submitGlJournalEntry).
* that account is never the one physical notes actually moved through.
* Both resolved dynamically off whatever this tenant's own financial-activity mapping names as
* its cash/vault account (see loadCashVaultGl) — never a literal account name or code. */
cashVaultGlCode: string | null = null;
@@ -1873,6 +1878,7 @@ export class TransactionEntryComponent implements OnInit, OnDestroy {
ngOnInit(): void {
const d = businessToday();
this.today = `${String(d.getDate()).padStart(2, '0')}/${String(d.getMonth() + 1).padStart(2, '0')}/${d.getFullYear()}`;
this.loadBranches();
this.addRow();
this.loadDenominations();
this.loadOpeningPosition();
@@ -1903,7 +1909,8 @@ export class TransactionEntryComponent implements OnInit, OnDestroy {
distinctUntilChanged((a, b) => a.value === b.value && a.row === b.row)
)
.subscribe(({ row, index, value }) => {
if (value.trim().length >= 4) {
// Searches on its own once six characters are typed, for clerk and cashier alike.
if (value.trim().length >= TransactionEntryComponent.AC_NO_AUTO_SEARCH_MIN_LENGTH) {
this.selectedRow = row;
this.loadAccountDetails(value.trim(), row.acTyp);
}
@@ -2304,7 +2311,7 @@ export class TransactionEntryComponent implements OnInit, OnDestroy {
// anyway, just without the extra click to get there.
const row: TxnRow = {
typ: this.isClerk ? 'Withdrawal Slip' : 'Receipt',
br: 1,
br: this.defaultBranchId,
trTp: '',
acTyp: 'SD',
acNo: '',
@@ -2323,8 +2330,54 @@ export class TransactionEntryComponent implements OnInit, OnDestroy {
this.rows.push(row);
}
/**
* A GL row is credit-only (money received into the account) except on the transfer types, which pick
* either side, and on a Withdrawal Slip, which is always a debit: the GL is debited and the cash
* vault credited when cash is paid out against it (a clerk's request, approved, then paid by the cashier).
*/
isGlCreditOnly(row: TxnRow): boolean {
return (
row.acTyp === 'GL' &&
row.typ !== 'Disbursement' &&
row.typ !== 'Fund Transfer' &&
row.typ !== 'Enter Bank Transfer' &&
row.typ !== 'Withdrawal Slip'
);
}
/**
* A Fixed Deposit row is credit-only on both the clerk and the cashier screens: an FD is funded once, with
* exactly its principal, and money leaves it only through its maturity or premature closure. The one
* exception is the clerk's own "Close this account" flow, which raises a closure request.
*/
isFdCreditOnly(row: TxnRow): boolean {
return row.acTyp === 'FD' && !this.pendingClosure;
}
/** DR is not offered for this row (see isGlCreditOnly and isFdCreditOnly). */
isCreditOnly(row: TxnRow): boolean {
return this.isGlCreditOnly(row) || this.isFdCreditOnly(row);
}
/** Called when the TYPE dropdown changes — sets sensible defaults for TXN TP and DR/CR */
/**
* A cashier row that was filled from an approved token (withdrawal / account-closure) is cash
* leaving the branch to the member, so its TYPE is fixed to Payment and cannot be changed.
* Never true for the clerk screen or for rows keyed in by hand.
*/
isTypLocked(row: TxnRow): boolean {
return (
!this.isClerk &&
!!row.tokNo?.trim() &&
!!(this.activeWithdrawalRequest || this.activeDividendClaimRequest || this.activeClosureRequest)
);
}
onTypChange(row: TxnRow): void {
if (this.isTypLocked(row)) {
row.typ = 'Payment';
return;
}
switch (row.typ) {
case 'Withdrawal Slip':
row.dbCr = 'Dr';
@@ -2440,7 +2493,7 @@ export class TransactionEntryComponent implements OnInit, OnDestroy {
* such ambiguity — the account itself says what it's for.
*/
showsEntryType(row: TxnRow): boolean {
return row.acTyp === 'GL' && row.dbCr === 'Cr';
return row.acTyp === 'GL';
}
/**
@@ -2618,14 +2671,51 @@ export class TransactionEntryComponent implements OnInit, OnDestroy {
return !!this.selectedRow && Math.abs(this.denominationTotal - this.ddTargetTotal) < 0.005;
}
/**
* The branches an entry can be posted against: the real offices of this installation. Read from the offices
* themselves rather than from a fixed list, so a branch that is added or renamed shows up here without a code change.
*/
branches: { id: number; label: string }[] = [];
private loadBranches(): void {
const own = this.authService.getCredentials();
const ownBranch = own?.officeId
? [{ id: own.officeId, label: this.branchLabel(own.officeId, own.officeName) }]
: [];
// The office list is only asked for by a login allowed to read it. A cashier or clerk is not, and asking anyway
// makes the server refuse and the screen raise an "Unauthorized Request" popup; such a login works in its own
// office, which is the one branch it is offered.
const permissions = own?.permissions ?? [];
const mayReadOffices = permissions.includes('ALL_FUNCTIONS') || permissions.includes('READ_OFFICE');
if (!mayReadOffices) {
this.branches = ownBranch;
return;
}
this.http
.get('/offices')
.pipe(catchError(() => of([])))
.subscribe((list: any) => {
const offices: any[] = Array.isArray(list) ? list : [];
this.branches =
offices.length > 0 ? offices.map((o) => ({ id: o.id, label: this.branchLabel(o.id, o.name) })) : ownBranch;
});
}
private branchLabel(id: number, name: string | null | undefined): string {
return `${String(id).padStart(3, '0')} · ${name ?? 'Branch ' + id}`;
}
/** The office the signed-in user works in, which a new entry is posted against unless they pick another. */
get defaultBranchId(): number {
return this.authService.getCredentials()?.officeId ?? 1;
}
branchNameFor(branchId: number): string {
const map: Record<number, string> = {
1: '001 · Main',
2: '002 · M.G. Road',
14: '014 · Station Rd',
27: '027 · Industrial'
};
return map[branchId] || `Branch ${branchId}`;
return (
this.seenBranches.find((b) => b.id === branchId)?.name ??
this.branches.find((b) => b.id === branchId)?.label ??
`Branch ${branchId}`
);
}
/**
@@ -3918,7 +4008,8 @@ export class TransactionEntryComponent implements OnInit, OnDestroy {
SAVINGS: 'SD',
CURRENT: 'CA',
FIXED_DEPOSIT: 'FD',
RECURRING_DEPOSIT: 'RD'
RECURRING_DEPOSIT: 'RD',
GL: 'GL'
};
openCustomerSearch(): void {
@@ -3974,8 +4065,11 @@ export class TransactionEntryComponent implements OnInit, OnDestroy {
.pipe(catchError(() => of(null)))
.subscribe((res: any) => {
if (token !== this.searchToken) return; // stale result — newer search started
const allItems: any[] = res?.pageItems ?? (Array.isArray(res) ? res : []);
const found: any[] = res?.pageItems ?? (Array.isArray(res) ? res : []);
const f = cfg.matchField;
// SD, FD and RD share one list that returns any kind of deposit account: keep only the kind chosen in A/C TP.
const allItems =
cfg.depositTypeId != null ? found.filter((x: any) => x?.depositType?.id === cfg.depositTypeId) : found;
// Exact match first, then suffix/partial match. Some account types (e.g. Share — its
// list endpoint only accepts offset/limit, no accountNo filter at all) always return the
const exactMatch = allItems.find((x: any) => String(x[f] ?? '').trim() === String(acNo).trim());
@@ -4005,7 +4099,18 @@ export class TransactionEntryComponent implements OnInit, OnDestroy {
return;
}
this.loadingAccount = false;
this.accountError = 'Account not found for: ' + acNo;
// The number may exist as another kind of account (an FD typed under SD, say): say so, so the clerk can fix A/C TP.
const otherKind =
cfg.depositTypeId != null
? found.find(
(x: any) =>
x?.depositType?.id !== cfg.depositTypeId &&
(String(x[f] ?? '').trim() === String(acNo).trim() || String(x[f] ?? '').endsWith(acNo))
)
: null;
this.accountError = otherKind
? `${acTyp} account not found for: ${acNo} (this number is a ${otherKind.depositType?.value ?? 'different kind of'} account - change A/C TP)`
: `${acTyp} account not found for: ${acNo}`;
});
}
@@ -4069,6 +4174,10 @@ export class TransactionEntryComponent implements OnInit, OnDestroy {
* account type — used to keep Header GL accounts out of the results a clerk/cashier can pick
* for a transaction; a Header can never receive a posting, so it should never be offered. */
extraListQueryParams?: string;
/** SD, FD and RD are all served by the one /savingsaccounts list, which answers with a deposit
* account of any kind for a number. This is the depositType.id the chosen A/C type accepts
* (100 savings, 200 fixed, 300 recurring), so a number of another kind is "not found" here. */
depositTypeId?: number;
} {
const t = acTyp?.toUpperCase();
if (t === 'LA') {
@@ -4149,6 +4258,7 @@ export class TransactionEntryComponent implements OnInit, OnDestroy {
return {
list: '/savingsaccounts',
detail: '/fixeddepositaccounts',
depositTypeId: 200,
nominee: 'FD',
search: 'fixeddeposit',
siAccountType: 2,
@@ -4166,6 +4276,7 @@ export class TransactionEntryComponent implements OnInit, OnDestroy {
return {
list: '/savingsaccounts',
detail: '/recurringdepositaccounts',
depositTypeId: 300,
nominee: 'RD',
search: 'recurringdeposit',
siAccountType: 2,
@@ -4183,6 +4294,7 @@ export class TransactionEntryComponent implements OnInit, OnDestroy {
return {
list: '/savingsaccounts',
detail: '/savingsaccounts',
depositTypeId: 100,
nominee: 'SB',
search: 'saving',
siAccountType: 2,
@@ -4242,7 +4354,19 @@ export class TransactionEntryComponent implements OnInit, OnDestroy {
return false;
}
this.accountDetails = acc;
this.resolveAccountBranch(acc, acTyp, token, this.selectedRow);
this.jointHolders = acc.jointHolders ?? [];
// GL only: look up the parent (header) GL's name. Never runs for any other account type.
this.glParent = null;
if (acTyp?.toUpperCase() === 'GL' && acc.parentId) {
this.http
.get<any>(`/glaccounts/${acc.parentId}`)
.pipe(catchError(() => of(null)))
.subscribe((p: any) => {
if (token !== this.searchToken) return;
this.glParent = p ? { glCode: p.glCode, name: p.name } : null;
});
}
// Cash Hold / Clearing Hold breakdown only exists for plain savings accounts (m_savings_account)
// — FD/RD/Current/Share/GL have no such row, so there's nothing to ask for.
if (acTyp?.toUpperCase() === 'SD' && acc.id) {
@@ -4813,6 +4937,109 @@ export class TransactionEntryComponent implements OnInit, OnDestroy {
return this.accountDetails?.officeName ?? this.clientDetails?.officeName ?? '—';
}
/** True when the loaded account is a General Ledger account (/glaccounts), which has no client behind it. */
get isGlAccount(): boolean {
return !!this.accountDetails?.glCode && this.selectedRow?.acTyp === 'GL';
}
/** The GL account's own details, shown in place of the client panels. */
get glInfo(): { label: string; value: string; mono?: boolean }[] {
const gl = this.accountDetails;
if (!gl) return [];
const type = gl.type?.value ?? '—';
const nature = [
'ASSET',
'EXPENSE'
].includes(String(type).toUpperCase()) ? 'Debit' : 'Credit';
return [
{ label: 'GL Code', value: gl.glCode ?? '—', mono: true },
{ label: 'GL Name', value: gl.name ?? '—' },
{ label: 'Account Type', value: type },
{ label: 'Usage', value: gl.usage?.value ?? '—' },
{ label: 'Normal Balance', value: nature },
{
label: 'Parent GL',
value: this.glParent?.name
? `${this.glParent.glCode ? this.glParent.glCode.trim() + ' - ' : ''}${this.glParent.name.trim()}`
: gl.parentId != null
? `ID ${gl.parentId}`
: '—'
},
{ label: 'Manual Entries', value: gl.manualEntriesAllowed ? 'Allowed' : 'Not allowed' },
{ label: 'Status', value: gl.disabled ? 'Disabled' : 'Enabled' },
{ label: 'Description', value: gl.description || '—' }
];
}
/**
* Whether the account on screen is held at the branch the signed-in user works at: true for their own branch, false for
* another branch's, null while either branch is not known (the branch is then shown plain, uncoloured).
*/
get isOwnBranchAccount(): boolean | null {
const mine = this.authService.getCredentials()?.officeId;
const accountOffice = this.accountDetails?.officeId ?? this.clientDetails?.officeId;
if (mine == null || accountOffice == null) return null;
return Number(mine) === Number(accountOffice);
}
/** What the BRANCH selector offers: the offices this login may use, plus the branches of the accounts picked here. */
get branchSelectOptions(): { id: number; name: string }[] {
const merged = this.branches.map(
(branch) => this.seenBranches.find((seen) => seen.id === branch.id) ?? { id: branch.id, name: branch.label }
);
for (const seen of this.seenBranches) {
if (!merged.some((branch) => branch.id === seen.id)) merged.push(seen);
}
return merged;
}
/** Branches learned from the accounts picked on this screen; their names are real, whatever the office list says. */
private seenBranches: { id: number; name: string }[] = [];
/**
* The branch an account is held at. The savings, FD and RD detail responses carry no office, so the screen fell back
* to the client's own branch and an account opened at another branch read as the client's. The client's account list
* names each account's branch, so it is read from there. A current account's own response already carries it.
*/
private resolveAccountBranch(acc: any, acTyp: string, token: number, row: TxnRow | null | undefined): void {
if (acc?.officeId != null && acc?.officeName) {
this.adoptAccountBranch({ id: Number(acc.officeId), name: String(acc.officeName) }, row);
return;
}
const type = (acTyp ?? '').toUpperCase();
if (!acc?.id || !acc?.clientId || ![
'SD',
'FD',
'RD',
'CA'
].includes(type)) return;
this.http
.get<any>(`/clients/${acc.clientId}/accounts`, { context: new HttpContext().set(SILENT_HTTP_ERROR, true) })
.pipe(catchError(() => of(null)))
.subscribe((res) => {
// stale: another account was picked while this was loading
if (token !== this.searchToken || this.accountDetails?.id !== acc.id) return;
const list: any[] = (type === 'CA' ? res?.currentAccounts : res?.savingsAccounts) ?? [];
const match = list.find((a: any) => a.id === acc.id);
if (match?.officeId != null && match?.officeName) {
this.adoptAccountBranch({ id: Number(match.officeId), name: String(match.officeName) }, row);
}
});
}
private adoptAccountBranch(branch: { id: number; name: string }, row: TxnRow | null | undefined): void {
this.accountDetails = { ...this.accountDetails, officeId: branch.id, officeName: branch.name };
// /offices lists only the signed-in user's own branches, or nothing for a user who may not read offices; the account's
// branch must still be selectable, under its real name
if (!this.seenBranches.some((b) => b.id === branch.id)) {
this.seenBranches = [
...this.seenBranches,
branch
];
}
if (row) row.br = branch.id;
}
get openingDate(): string {
const raw =
this.accountDetails?.timeline?.submittedOnDate ??
@@ -5155,6 +5382,20 @@ export class TransactionEntryComponent implements OnInit, OnDestroy {
}
onSave(): void {
// Money never leaves a Fixed Deposit from this screen, clerk or cashier: no withdrawal slip, payment,
// collection or transfer debits an FD. It is paid out only through its maturity or premature closure
// (the clerk's "Close this account" flow). The DR button is already disabled for FD rows (isFdCreditOnly);
// this also catches a row set to DR by its TYPE, such as a Withdrawal Slip.
const fdDebitRows = this.rows.filter((r) => this.isFdCreditOnly(r) && r.dbCr === 'Dr');
if (fdDebitRows.length > 0) {
this.showAlert('error', 'Fixed Deposit Cannot Be Debited', [
`FD account(s) ${fdDebitRows.map((r) => r.acNo).join(', ')} cannot be debited or withdrawn from.`,
'Money leaves a Fixed Deposit only through its maturity or premature closure.',
'An FD can only be credited — once, with exactly its principal.'
]);
return;
}
// Demand drafts commit here like everything else on the worksheet. Checked first because a DD
// row shares its shape with the ordinary entries below — without this it would fall through and
// post as a plain transaction, with the draft never raised and the charges silently dropped.
@@ -5232,7 +5473,9 @@ export class TransactionEntryComponent implements OnInit, OnDestroy {
return;
}
// Bank transfer: only savings (SD) or current (CA/CB) accounts allowed
// Bank transfer: savings (SD) or current (CA/CB) accounts on either side, plus a Fixed Deposit (FD) on
// the CR side only -- funding it from the customer's own account. The server checks the funding rules
// (first and only funding, exactly the principal, the source account's owner and available balance).
const allowedTypes = new Set([
'SD',
'CA',
@@ -5240,13 +5483,13 @@ export class TransactionEntryComponent implements OnInit, OnDestroy {
]);
const invalidAccounts = [
...drRows,
...crRows
...crRows.filter((r) => r.acTyp !== 'FD')
].filter((r) => !allowedTypes.has(r.acTyp));
if (invalidAccounts.length > 0) {
this.showAlert('error', 'Invalid Account Type', [
`Account type(s) not allowed in Bank Transfer: ${invalidAccounts.map((r) => r.acTyp + ' (' + r.acNo + ')').join(', ')}`,
'Only Savings (SD) and Current (CA) accounts are permitted.',
'GL, FD and SHR accounts are not allowed — use Disbursement/Collection instead.',
'Debit side: only Savings (SD) and Current (CA) accounts. Credit side: SD, CA or a Fixed Deposit (FD).',
'GL and SHR accounts are not allowed — use Disbursement/Collection instead.',
'RD installments are paid through a cashier Receipt or Auto Debit.'
]);
return;
@@ -5847,10 +6090,14 @@ export class TransactionEntryComponent implements OnInit, OnDestroy {
return;
}
// GL withdrawal → direct journal entry (no maker-checker)
// GL deposit → falls through to maker-checker block below
if (row.acTyp === 'GL' && !isDeposit) {
this.submitGlJournalEntry(row, amount, isDeposit);
// A GL withdrawal slip is raised by the clerk and paid by the cashier against its token, exactly like a
// savings withdrawal (the clerk branch and the token branch below). Nothing posts from this screen directly:
// manual journal entries are switched off on the server.
if (row.acTyp === 'GL' && !isDeposit && !this.clerkMode && !this.activeWithdrawalRequest) {
this.showAlert('error', 'GL Withdrawal', [
'A GL withdrawal is raised by a clerk and paid by the cashier against its token.',
'Enter the token number of the approved request to pay it.'
]);
return;
}
@@ -6223,17 +6470,34 @@ export class TransactionEntryComponent implements OnInit, OnDestroy {
const txnDateIso = `${today.getFullYear()}-${String(today.getMonth() + 1).padStart(2, '0')}-${String(today.getDate()).padStart(2, '0')}`;
const selectedPt = this.paymentTypes.find((p: any) => p.id === row.paymentTypeId);
const isGl = row.acTyp === 'GL';
// The Entry Type picked for a GL slip has no column of its own on the request, so it rides on the narration.
const entryTypeName = isGl ? this.entryTypeOptions.find((e) => e.id === row.entryTypeCvId)?.name : undefined;
const description = [
row.description,
entryTypeName ? `[Entry Type: ${entryTypeName}]` : ''
]
.filter(Boolean)
.join(' ');
return {
accountNo: this.accountDetails.accountNo,
accountNo: isGl ? (this.accountDetails.glCode ?? this.accountDetails.name) : this.accountDetails.accountNo,
accountType: this.creditEntryAccountType(row.acTyp),
savingsAccountId: this.accountDetails.id,
clientId: this.accountDetails.clientId ?? this.clientDetails?.id ?? null,
clientName: this.clientDetails?.displayName ?? this.accountDetails?.clientName ?? null,
savingsAccountId: isGl ? null : this.accountDetails.id,
// GL-specific fields: the GL being paid out of and the cash GL it is paid from (null for other types)
glAccountId: isGl ? this.accountDetails.id : null,
contraGlId: isGl ? this.cashVaultGlId : null,
officeId: isGl ? row.br || 1 : null,
currencyCode: isGl ? (this.accountDetails?.currency?.code ?? 'INR') : null,
clientId: isGl ? null : (this.accountDetails.clientId ?? this.clientDetails?.id ?? null),
clientName: isGl
? (this.accountDetails?.name ?? null)
: (this.clientDetails?.displayName ?? this.accountDetails?.clientName ?? null),
amount,
txnDate: txnDateIso,
paymentTypeId: row.paymentTypeId || null,
paymentTypeName: selectedPt?.name ?? 'Cash',
description: row.description || null,
description: description || null,
chequeNo: row.chequeNo || null,
bankName: row.bankName || null,
tokNo: row.tokNo?.trim() || null,
@@ -6425,106 +6689,6 @@ export class TransactionEntryComponent implements OnInit, OnDestroy {
this.addRow();
}
private submitGlJournalEntry(row: TxnRow, amount: number, isDeposit: boolean): void {
if (!this.cashVaultGlId) {
this.showAlert('error', 'Cash Vault GL Not Configured', [
'Financial Activity Account (CASH_AT_TELLER) is not configured.',
'Please configure it in System → Financial Activity Accounts.'
]);
this.saving = false;
return;
}
const today = businessToday();
const txnDate = `${String(today.getDate()).padStart(2, '0')}/${String(today.getMonth() + 1).padStart(2, '0')}/${today.getFullYear()}`;
const txnDateIso = `${today.getFullYear()}-${String(today.getMonth() + 1).padStart(2, '0')}-${String(today.getDate()).padStart(2, '0')}`;
const selectedGlId = this.accountDetails.id;
const contraGlId = this.cashVaultGlId;
// isDeposit (CR button) = cashier receives cash → credit selected GL, debit contra (cash vault)
// isWithdrawal (DR button) = cashier pays out cash → debit selected GL, credit contra (cash vault)
const credits = isDeposit ? [{ glAccountId: selectedGlId, amount }] : [{ glAccountId: contraGlId, amount }];
const debits = isDeposit ? [{ glAccountId: contraGlId, amount }] : [{ glAccountId: selectedGlId, amount }];
const payload: any = {
officeId: row.br || 1,
transactionDate: txnDate,
dateFormat: 'dd/MM/yyyy',
locale: 'en',
currencyCode: this.accountDetails?.currency?.code ?? 'INR',
comments: row.description || null,
credits,
debits,
paymentTypeId: row.paymentTypeId
};
// Check if cash payment so we can record denomination after journal entry
const selectedPt = this.paymentTypes.find((p: any) => p.id === row.paymentTypeId);
const isCashPayment = !!selectedPt?.isCashPayment;
this.saving = true;
this.http
.post('/journalentries', payload)
.pipe(
catchError((err: any) => {
const msg =
err?.error?.errors?.[0]?.defaultUserMessage ??
err?.error?.developerMessage ??
'Journal entry failed. Please try again.';
this.showAlert('error', 'Transaction Failed', [msg]);
this.saving = false;
return of(null);
})
)
.subscribe((res: any) => {
this.saving = false;
if (res) {
// Record denomination so cashier position stays accurate
// Same condition as savings: cash + denom entered + denom matches posted amount
if (isCashPayment && this.denominationTotal > 0 && this.denominationTotal === amount) {
const denomBreakdown = [
...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)
}));
// Filed against the Cash Vault GL, not the account the teller selected on screen: that
// selected account (e.g. Sundry Creditors) is the other leg of the journal entry, and
// never the one whose physical notes actually changed hands. Tagging the breakdown to it
// instead of the vault is why this cash never reached the cashier/vault position.
this.http
.post('/cashier/gl-denomination', {
txnId: res.transactionId ?? res.resourceId,
accountNo: this.cashVaultGlCode ?? row.acNo,
accountName: this.cashVaultGlName ?? 'Cash at Vault',
txnType: isDeposit ? 'DEPOSIT' : 'WITHDRAWAL',
txnDate: txnDateIso,
paymentTypeName: selectedPt?.name ?? 'Cash',
denominationBreakdown: denomBreakdown
})
.pipe(catchError(() => of(null)))
.subscribe();
}
const ref = res.transactionId ?? res.resourceId ?? 'N/A';
this.showAlert('success', 'GL Transaction Posted', [
`Journal entry of ₹${amount.toLocaleString('en-IN', { minimumFractionDigits: 2 })} posted successfully.`,
`Transaction ID: ${ref}`
]);
this.clearDenominations();
this.loadOpeningPosition();
this.loadAccountDetails(row.acNo.trim(), row.acTyp);
}
});
}
onSearch(): void {
if (this.selectedRow?.acNo.trim()) {
this.loadAccountDetails(this.selectedRow.acNo.trim(), this.selectedRow.acTyp);
@@ -0,0 +1,170 @@
<!--
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="cv-tab-content vca-tab">
<!-- ── TITLE BAR ── -->
<div class="vca-action-bar">
<div class="vca-action-bar__left">
<mat-icon class="vca-action-bar__icon">undo</mat-icon>
<div>
<div class="vca-action-bar__title">Voucher Corrections</div>
<div class="vca-action-bar__sub">
Approve or reject a voucher unpass, and the correction entry that follows it. Only the correction approval
moves money.
</div>
</div>
</div>
<div class="vca-counts">
<span class="vca-count"
><strong>{{ unpassCount }}</strong> unpass</span
>
<span class="vca-count"
><strong>{{ correctionCount }}</strong> correction</span
>
</div>
</div>
<!-- ── FILTER BAR ── -->
<div class="vca-filter-bar">
<div class="vca-search-box">
<mat-icon>search</mat-icon>
<input type="text" placeholder="Search by voucher, account, customer or user" [(ngModel)]="searchTerm" />
@if (searchTerm) {
<button type="button" class="vca-search-box__clear" (click)="searchTerm = ''">
<mat-icon>close</mat-icon>
</button>
}
</div>
<span class="vca-fb-sep"></span>
<select class="vca-filter-select" [(ngModel)]="kindFilter">
@for (opt of kindOptions; track opt.value) {
<option [value]="opt.value">{{ opt.label }}</option>
}
</select>
<button type="button" class="vca-btn vca-btn--refresh" (click)="load()" [disabled]="loading">
<mat-icon>refresh</mat-icon>
</button>
</div>
<!-- ── TABLE ── -->
@if (loading && !loaded) {
<div class="vca-empty"><p class="vca-empty__title">Loading…</p></div>
} @else if (rows.length === 0) {
<div class="vca-empty">
<mat-icon class="vca-empty__icon">inbox</mat-icon>
<p class="vca-empty__title">No voucher correction waiting</p>
<p class="vca-empty__text">There is no unpass request or correction entry waiting for review.</p>
</div>
} @else {
<div class="vca-table-wrap">
<table class="vca-table">
<thead>
<tr>
<th>Voucher No</th>
<th>Request</th>
<th>Posted To (Wrong)</th>
<th>Correct Account</th>
<th class="num">Amount</th>
<th>Reason</th>
<th>Raised By</th>
<th>Raised At</th>
<th class="vca-th-action">Action</th>
</tr>
</thead>
<tbody>
@for (r of rows; track r.request.id) {
<tr class="vca-row">
<td class="vca-mono vca-voucher">{{ r.request.original_voucher_no }}</td>
<td>
<span class="vca-kind" [class]="'vca-kind vca-kind--' + r.kind">{{ kindLabel(r.kind) }}</span>
</td>
<td>
<div class="vca-mono">{{ r.request.wrong_account_no }}</div>
<div class="vca-sub">{{ r.request.wrong_client_name || '—' }}</div>
</td>
<td>
@if (r.kind === 'correction') {
<div class="vca-mono vca-strong">{{ r.request.correct_account_no }}</div>
<div class="vca-sub">{{ r.request.correct_client_name || '—' }}</div>
} @else {
<span class="vca-none">—</span>
}
</td>
<td class="num vca-amount">₹{{ r.request.amount | number: '1.2-2' }}</td>
<td class="vca-reason" [title]="r.request.reason || ''">{{ r.request.reason || '—' }}</td>
<td>{{ r.raisedBy || '—' }}</td>
<td class="vca-dt">{{ formatDateTime(r.raisedAt) }}</td>
<td class="vca-action-cell">
<button
type="button"
class="vca-btn vca-btn--reject"
[disabled]="busyId === r.request.id"
(click)="startReject(r)"
>
<mat-icon>close</mat-icon> Reject
</button>
<button
type="button"
class="vca-btn vca-btn--approve"
[disabled]="busyId === r.request.id"
(click)="approve(r)"
>
<mat-icon>check</mat-icon>
{{ r.kind === 'unpass' ? 'Approve unpass' : 'Approve correction' }}
</button>
</td>
</tr>
}
</tbody>
</table>
</div>
}
</div>
<!-- ─── Popup: reject with a reason ───────────────────────────────────────── -->
<ng-template #rejectDialog>
@if (rejecting) {
<div class="vca-dlg">
<header class="vca-dlg__head">
<mat-icon class="vca-dlg__ico">block</mat-icon>
<div class="vca-dlg__titles">
<div class="vca-dlg__title">
Reject {{ rejecting.kind === 'unpass' ? 'unpass request' : 'correction entry' }}
</div>
<div class="vca-dlg__sub vca-mono">{{ rejecting.request.original_voucher_no }}</div>
</div>
<button type="button" class="vca-dlg__x" aria-label="Close" (click)="cancelReject()">
<mat-icon>close</mat-icon>
</button>
</header>
<div class="vca-dlg__body">
<label class="vca-label" for="vca-reason">Reason (required)</label>
<textarea
id="vca-reason"
rows="3"
placeholder="Why is this being rejected?"
[(ngModel)]="rejectReason"
></textarea>
</div>
<footer class="vca-dlg__foot">
<button type="button" class="vca-btn vca-btn--ghost" (click)="cancelReject()">Cancel</button>
<button
type="button"
class="vca-btn vca-btn--reject-solid"
[disabled]="!rejectReason.trim() || busyId === rejecting.request.id"
(click)="confirmReject()"
>
Confirm reject
</button>
</footer>
</div>
}
</ng-template>
@@ -0,0 +1,485 @@
/**
* 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/.
*/
// Laid out like the other approval tabs (Withdrawal Requests): title bar, filter bar, one table.
:host {
display: block;
}
.vca-tab {
padding: 0;
}
.vca-action-bar {
display: flex;
align-items: center;
justify-content: space-between;
gap: 16px;
padding: 10px 16px;
background: #fff;
border-bottom: 1px solid #e2e8f0;
&__left {
display: flex;
align-items: center;
gap: 12px;
}
&__icon {
font-size: 22px;
width: 22px;
height: 22px;
color: #0f4f3c;
}
&__title {
font-size: 13px;
font-weight: 700;
color: #0d1b2a;
}
&__sub {
max-width: 640px;
margin-top: 1px;
font-size: 11px;
color: #64748b;
}
}
.vca-counts {
display: flex;
gap: 8px;
}
.vca-count {
padding: 2px 10px;
border-radius: 999px;
background: #f1f5f9;
font-size: 11px;
color: #475569;
strong {
color: #0f4f3c;
}
}
.vca-filter-bar {
display: flex;
align-items: center;
gap: 6px;
flex-wrap: wrap;
padding: 6px 16px;
background: #f8fafc;
border-bottom: 1px solid #e2e8f0;
}
.vca-fb-sep {
flex-shrink: 0;
width: 1px;
height: 22px;
margin: 0 4px;
background: #e2e8f0;
}
.vca-search-box {
display: inline-flex;
align-items: center;
gap: 6px;
height: 30px;
min-width: 240px;
padding: 0 10px;
border: 1px solid #e2e8f0;
border-radius: 8px;
background: #fff;
mat-icon {
flex-shrink: 0;
font-size: 17px;
width: 17px;
height: 17px;
color: #94a3b8;
}
&:hover {
border-color: #cbd5e1;
}
&:focus-within {
border-color: #0f4f3c;
box-shadow: 0 0 0 3px rgb(15 79 60 / 8%);
}
input {
flex: 1;
border: none;
outline: none;
background: transparent;
font-family: inherit;
font-size: 12px;
color: #0d1b2a;
&::placeholder {
color: #94a3b8;
}
}
&__clear {
display: inline-flex;
align-items: center;
justify-content: center;
flex-shrink: 0;
width: 20px;
height: 20px;
padding: 0;
border: none;
border-radius: 50%;
background: none;
color: #94a3b8;
cursor: pointer;
mat-icon {
font-size: 15px;
width: 15px;
height: 15px;
color: inherit;
}
&:hover {
color: #dc2626;
background: #fef2f2;
}
}
}
.vca-filter-select {
height: 30px;
min-width: 140px;
padding: 0 10px;
border: 1px solid #e2e8f0;
border-radius: 7px;
background: #fff;
font-family: inherit;
font-size: 12px;
color: #0d1b2a;
cursor: pointer;
&:focus {
outline: none;
border-color: #0f4f3c;
}
}
// ── Buttons ─────────────────────────────────────────────────────────────────
.vca-btn {
display: inline-flex;
align-items: center;
gap: 4px;
height: 26px;
padding: 0 10px;
border: 1px solid transparent;
border-radius: 7px;
font-family: inherit;
font-size: 11px;
font-weight: 600;
white-space: nowrap;
cursor: pointer;
mat-icon {
font-size: 14px;
width: 14px;
height: 14px;
}
&:disabled {
opacity: 0.45;
cursor: not-allowed;
}
&--refresh {
height: 30px;
width: 30px;
justify-content: center;
padding: 0;
margin-left: auto;
border-color: #e2e8f0;
background: #fff;
color: #475569;
}
&--approve {
background: #0f4f3c;
color: #fff;
&:hover:not(:disabled) {
background: #0a3d2e;
}
}
&--reject {
border-color: #fecaca;
background: #fff;
color: #dc2626;
&:hover:not(:disabled) {
background: #fef2f2;
}
}
&--reject-solid {
background: #dc2626;
color: #fff;
}
&--ghost {
border-color: #e2e8f0;
background: #fff;
color: #475569;
}
}
// ── Table ───────────────────────────────────────────────────────────────────
.vca-table-wrap {
overflow-x: auto;
background: #fff;
}
.vca-table {
width: 100%;
border-collapse: collapse;
font-size: 11px;
th {
padding: 7px 10px;
border-bottom: 1px solid #e2e8f0;
background: #f8fafc;
font-size: 9.5px;
font-weight: 800;
letter-spacing: 0.06em;
text-align: left;
text-transform: uppercase;
color: #94a3b8;
white-space: nowrap;
}
td {
padding: 5px 10px;
border-bottom: 1px solid #eef2f7;
vertical-align: middle;
color: #0d1b2a;
}
.num {
text-align: right;
font-variant-numeric: tabular-nums;
}
.vca-th-action {
text-align: right;
}
}
.vca-row:hover td {
background: #f8fafc;
}
.vca-mono {
font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace;
letter-spacing: 0.02em;
}
.vca-voucher {
font-weight: 700;
color: #0f4f3c;
}
.vca-strong {
font-weight: 700;
}
.vca-sub {
margin-top: 1px;
font-size: 11px;
color: #64748b;
}
.vca-none {
color: #cbd5e1;
}
.vca-amount {
font-weight: 700;
}
.vca-reason {
max-width: 220px;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.vca-dt {
white-space: nowrap;
color: #475569;
}
.vca-kind {
display: inline-block;
padding: 2px 10px;
border-radius: 999px;
font-size: 11px;
font-weight: 700;
&--unpass {
background: #fef3c7;
color: #92400e;
}
&--correction {
background: #dbeafe;
color: #1e40af;
}
}
.vca-action-cell {
display: flex;
justify-content: flex-end;
gap: 6px;
white-space: nowrap;
}
// ── Empty ───────────────────────────────────────────────────────────────────
.vca-empty {
padding: 28px 16px;
text-align: center;
color: #94a3b8;
&__icon {
display: block;
margin: 0 auto 6px;
font-size: 30px;
width: 30px;
height: 30px;
opacity: 0.6;
}
&__title {
margin: 0;
font-size: 12px;
font-weight: 700;
color: #64748b;
}
&__text {
margin: 3px 0 0;
font-size: 11px;
}
}
// ── Reject popup ────────────────────────────────────────────────────────────
.vca-dlg {
font-size: 12px;
&__head {
display: flex;
align-items: center;
gap: 10px;
padding: 10px 14px;
border-bottom: 1px solid #e2e8f0;
background: #f8fafc;
}
&__ico {
flex: 0 0 32px;
height: 32px;
width: 32px;
border-radius: 9px;
background: #fef2f2;
color: #dc2626;
font-size: 18px;
line-height: 32px;
text-align: center;
}
&__titles {
flex: 1;
min-width: 0;
}
&__title {
font-size: 14px;
font-weight: 700;
color: #0f172a;
}
&__sub {
font-size: 11px;
color: #64748b;
}
&__x {
display: inline-flex;
align-items: center;
justify-content: center;
height: 28px;
width: 28px;
border: 0;
border-radius: 50%;
background: transparent;
color: #64748b;
cursor: pointer;
&:hover {
background: #e2e8f0;
}
mat-icon {
font-size: 18px;
height: 18px;
width: 18px;
}
}
&__body {
padding: 12px 14px;
textarea {
width: 100%;
padding: 7px 10px;
border: 1px solid #cbd5e1;
border-radius: 8px;
font: inherit;
font-size: 12px;
resize: vertical;
&:focus {
outline: 0;
border-color: #0f4f3c;
box-shadow: 0 0 0 3px rgb(15 79 60 / 8%);
}
}
}
&__foot {
display: flex;
justify-content: flex-end;
gap: 8px;
padding: 10px 14px;
border-top: 1px solid #e2e8f0;
background: #f8fafc;
}
}
.vca-label {
display: block;
margin-bottom: 4px;
font-size: 10px;
font-weight: 700;
letter-spacing: 0.05em;
text-transform: uppercase;
color: #94a3b8;
}
@@ -0,0 +1,192 @@
/**
* 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, TemplateRef, ViewChild, inject } from '@angular/core';
import { CommonModule } from '@angular/common';
import { FormsModule } from '@angular/forms';
import { MatDialog, MatDialogModule, MatDialogRef } from '@angular/material/dialog';
import { MatIconModule } from '@angular/material/icon';
import { MatSnackBar, MatSnackBarModule } from '@angular/material/snack-bar';
import { of } from 'rxjs';
import { catchError } from 'rxjs/operators';
import { VoucherCorrection, VoucherCorrectionService } from '../voucher-correction.service';
type Kind = 'unpass' | 'correction';
type KindFilter = 'ALL' | Kind;
/** One row of the approvals table: a request, and which of the two steps it is waiting at. */
interface ApprovalRow {
kind: Kind;
request: VoucherCorrection;
raisedBy: string | null;
raisedAt: string | null;
}
/**
* Manager side of the voucher correction, laid out like the other approval tabs: approves or rejects the unpass of a
* voucher, and later the correction entry the cashier made. Only the second approval moves money.
*/
@Component({
selector: 'mifosx-voucher-correction-approvals',
templateUrl: './voucher-correction-approvals.component.html',
styleUrls: ['./voucher-correction-approvals.component.scss'],
standalone: true,
imports: [
CommonModule,
FormsModule,
MatDialogModule,
MatIconModule,
MatSnackBarModule
]
})
export class VoucherCorrectionApprovalsComponent implements OnInit {
private service = inject(VoucherCorrectionService);
private snackBar = inject(MatSnackBar);
private dialog = inject(MatDialog);
@ViewChild('rejectDialog') rejectDialog!: TemplateRef<unknown>;
private dialogRef: MatDialogRef<unknown> | null = null;
all: ApprovalRow[] = [];
loading = false;
loaded = false;
busyId: number | null = null;
kindFilter: KindFilter = 'ALL';
searchTerm = '';
/** The row being rejected, and the reason typed for it. */
rejecting: ApprovalRow | null = null;
rejectReason = '';
readonly kindOptions: { value: KindFilter; label: string }[] = [
{ value: 'ALL', label: 'All requests' },
{ value: 'unpass', label: 'Unpass requests' },
{ value: 'correction', label: 'Correction entries' }
];
ngOnInit(): void {
this.load();
}
load(): void {
this.loading = true;
this.service
.list('UNPASS_PENDING,CORRECTION_PENDING')
.pipe(catchError(() => of([] as VoucherCorrection[])))
.subscribe((rows) => {
this.all = rows.map((r) =>
r.status === 'UNPASS_PENDING'
? { kind: 'unpass', request: r, raisedBy: r.unpass_by_name, raisedAt: r.unpass_at }
: { kind: 'correction', request: r, raisedBy: r.correction_by_name, raisedAt: r.correction_at }
);
this.loading = false;
this.loaded = true;
});
}
/** The table: the kind picked, narrowed by whatever is typed in the search box. */
get rows(): ApprovalRow[] {
const term = this.searchTerm.trim().toLowerCase();
return this.all.filter((r) => {
if (this.kindFilter !== 'ALL' && r.kind !== this.kindFilter) return false;
if (!term) return true;
const q = r.request;
return [
q.original_voucher_no,
q.wrong_account_no,
q.wrong_client_name,
q.correct_account_no,
q.correct_client_name,
r.raisedBy
].some((v) => (v ?? '').toLowerCase().includes(term));
});
}
get unpassCount(): number {
return this.all.filter((r) => r.kind === 'unpass').length;
}
get correctionCount(): number {
return this.all.filter((r) => r.kind === 'correction').length;
}
kindLabel(kind: Kind): string {
return kind === 'unpass' ? 'Unpass' : 'Correction';
}
formatDateTime(value: string | null): string {
if (!value) return '—';
const d = new Date(value);
return isNaN(d.getTime())
? value
: d.toLocaleString('en-IN', { day: '2-digit', month: 'short', hour: '2-digit', minute: '2-digit' });
}
approve(row: ApprovalRow): void {
if (this.busyId) return;
const id = row.request.id;
this.busyId = id;
const call = row.kind === 'unpass' ? this.service.approveUnpass(id) : this.service.approveCorrection(id);
call.subscribe({
next: (res: any) => {
this.busyId = null;
this.snackBar.open(
row.kind === 'unpass'
? `Voucher ${row.request.original_voucher_no} unpassed — the cashier can now enter the correct account`
: `Corrected. New voucher ${res?.newVoucherNo ?? ''}`,
'Close',
{ duration: 6000 }
);
this.load();
},
error: (err) => {
this.busyId = null;
this.fail(err, 'Could not approve');
}
});
}
startReject(row: ApprovalRow): void {
this.rejecting = row;
this.rejectReason = '';
this.dialogRef = this.dialog.open(this.rejectDialog, { width: '440px', maxWidth: '95vw', autoFocus: false });
this.dialogRef.afterClosed().subscribe(() => (this.rejecting = null));
}
cancelReject(): void {
this.dialogRef?.close();
}
confirmReject(): void {
if (!this.rejecting || !this.rejectReason.trim() || this.busyId) return;
const row = this.rejecting;
const id = row.request.id;
this.busyId = id;
const reason = this.rejectReason.trim();
const call =
row.kind === 'unpass' ? this.service.rejectUnpass(id, reason) : this.service.rejectCorrection(id, reason);
call.subscribe({
next: () => {
this.busyId = null;
this.dialogRef?.close();
this.snackBar.open('Rejected', 'Close', { duration: 4000 });
this.load();
},
error: (err) => {
this.busyId = null;
this.fail(err, 'Could not reject');
}
});
}
private fail(err: any, fallback: string): void {
const msg = err?.error?.errors?.[0]?.defaultUserMessage ?? err?.error?.defaultUserMessage ?? fallback;
this.snackBar.open(msg, 'Close', { duration: 8000 });
}
}
@@ -0,0 +1,120 @@
/**
* 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 { Injectable, inject } from '@angular/core';
import { HttpClient, HttpParams } from '@angular/common/http';
import { Observable } from 'rxjs';
/** One row of a voucher correction, as the server keeps it (snake_case columns). */
export interface VoucherCorrection {
id: number;
original_voucher_no: string;
original_txn_id: number;
wrong_savings_account_id: number;
wrong_account_no: string;
wrong_client_name: string | null;
amount: number;
txn_date: string | null;
status: 'UNPASS_PENDING' | 'UNPASSED' | 'UNPASS_REJECTED' | 'CORRECTION_PENDING' | 'COMPLETED' | 'CLOSED';
reason: string | null;
rejection_reason: string | null;
unpass_by_name: string | null;
unpass_at: string | null;
unpass_approved_by_name: string | null;
correct_account_no: string | null;
correct_client_name: string | null;
correction_by_name: string | null;
correction_at: string | null;
correction_approved_by_name: string | null;
new_voucher_no: string | null;
}
/** A cash deposit voucher of today that can still be unpassed. */
export interface EligibleVoucher {
voucherNo: string;
amount: number;
createdAt: string;
cashier: string;
txnId: number;
savingsAccountId: number;
accountNo: string;
clientName: string | null;
paymentType: string;
}
/** What the server knows about a savings account typed in as the correct one. */
export interface AccountLookup {
id: number;
accountNo: string;
clientId: number | null;
clientName: string | null;
balance: number;
currencyCode: string;
canReceive: boolean;
}
/** HTTP service for the voucher correction workflow: unpass, approve, re-enter, approve. */
@Injectable({ providedIn: 'root' })
export class VoucherCorrectionService {
private http = inject(HttpClient);
private readonly base = '/vouchers/corrections';
eligible(search?: string): Observable<EligibleVoucher[]> {
let params = new HttpParams();
if (search) params = params.set('search', search);
return this.http.get<EligibleVoucher[]>(`${this.base}/eligible`, { params });
}
unpass(voucherNo: string, reason: string): Observable<any> {
return this.http.post(`${this.base}/unpass`, { voucherNo, reason });
}
list(status?: string, mine = false): Observable<VoucherCorrection[]> {
let params = new HttpParams().set('mine', String(mine));
if (status) params = params.set('status', status);
return this.http.get<VoucherCorrection[]>(this.base, { params });
}
get(id: number): Observable<VoucherCorrection> {
return this.http.get<VoucherCorrection>(`${this.base}/${id}`);
}
accountLookup(accountNo: string): Observable<AccountLookup> {
return this.http.get<AccountLookup>(`${this.base}/account-lookup`, { params: { accountNo } });
}
submitCorrection(id: number, correctAccountNo: string): Observable<any> {
return this.http.post(`${this.base}/${id}/correction`, { correctAccountNo });
}
/** The cashier gives up the correction, e.g. when the wrong account no longer holds the money. */
close(id: number, reason: string): Observable<any> {
return this.http.post(`${this.base}/${id}/close`, { reason });
}
approveUnpass(id: number): Observable<any> {
return this.http.post(`${this.base}/${id}/unpass/approve`, {});
}
rejectUnpass(id: number, reason: string): Observable<any> {
return this.http.post(`${this.base}/${id}/unpass/reject`, { reason });
}
approveCorrection(id: number): Observable<any> {
return this.http.post(`${this.base}/${id}/correction/approve`, {});
}
rejectCorrection(id: number, reason: string): Observable<any> {
return this.http.post(`${this.base}/${id}/correction/reject`, { reason });
}
/** Ids of the transactions of this account that a completed correction removed. */
voidedTransactions(savingsAccountId: number): Observable<number[]> {
return this.http.get<number[]>(`${this.base}/voided-transactions`, { params: { savingsAccountId } });
}
}
@@ -0,0 +1,446 @@
<!--
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="pg">
<header class="pg-head">
<div>
<div class="pg-eyebrow">CASHIER · VOUCHER CORRECTION</div>
<h1 class="pg-title">Voucher Correction</h1>
<p class="pg-sub">
Move a cash deposit that was posted to the wrong account. Nothing changes on any account until the final
approval.
</p>
</div>
</header>
<!-- The four steps, so it is always clear where a voucher is. -->
<ol class="vc-steps">
<li class="vc-step" [class.vc-step--on]="tab === 'today'">
<span class="vc-step__no">1</span>
<span class="vc-step__txt"><strong>Unpass</strong> the voucher</span>
</li>
<li class="vc-step">
<span class="vc-step__no">2</span>
<span class="vc-step__txt"><strong>Manager</strong> approves</span>
</li>
<li class="vc-step" [class.vc-step--on]="tab === 'unpassed'">
<span class="vc-step__no">3</span>
<span class="vc-step__txt">Enter the <strong>correct account</strong></span>
</li>
<li class="vc-step">
<span class="vc-step__no">4</span>
<span class="vc-step__txt"><strong>Manager</strong> approves, deposit moves</span>
</li>
</ol>
<nav class="vc-tabs">
@if (mayUnpass) {
<button type="button" class="vc-tab" [class.vc-tab--on]="tab === 'today'" (click)="setTab('today')">
<mat-icon>receipt_long</mat-icon> Today's Vouchers
</button>
}
@if (mayCorrect) {
<button type="button" class="vc-tab" [class.vc-tab--on]="tab === 'unpassed'" (click)="setTab('unpassed')">
<mat-icon>edit_note</mat-icon> Unpassed — Correct Entry
@if (unpassed.length) {
<span class="vc-count">{{ unpassed.length }}</span>
}
</button>
}
<button type="button" class="vc-tab" [class.vc-tab--on]="tab === 'mine'" (click)="setTab('mine')">
<mat-icon>history</mat-icon> My Requests
</button>
</nav>
<!-- ─── Today's vouchers ─────────────────────────────────────────────── -->
@if (tab === 'today') {
<div class="vc-toolbar">
<div class="vc-search">
<mat-icon>search</mat-icon>
<input
type="text"
placeholder="Search by voucher, account or customer name"
[(ngModel)]="search"
(keyup.enter)="loadVouchers()"
/>
</div>
<button type="button" class="pg-btn pg-btn--primary" (click)="loadVouchers()">
<mat-icon>search</mat-icon> Search
</button>
<button type="button" class="pg-btn pg-btn--ghost" (click)="search = ''; loadVouchers()">
<mat-icon>refresh</mat-icon> Refresh
</button>
</div>
<div class="pg-card">
<table class="pg-table vc-table">
<thead>
<tr>
<th>Voucher</th>
<th>Account</th>
<th>Customer</th>
<th>Mode</th>
<th>Cashier</th>
<th class="num">Amount</th>
<th class="vc-col-action"></th>
</tr>
</thead>
<tbody>
@for (v of vouchers; track v.voucherNo) {
<tr [class.vc-row--on]="selectedVoucher?.voucherNo === v.voucherNo" (click)="pick(v)">
<td>
<span class="pg-chip">{{ v.voucherNo }}</span>
</td>
<td class="vc-mono">{{ v.accountNo }}</td>
<td>{{ v.clientName || '—' }}</td>
<td>{{ v.paymentType }}</td>
<td>{{ v.cashier }}</td>
<td class="num">₹{{ v.amount | number: '1.2-2' }}</td>
<td class="vc-col-action">
<button
type="button"
class="pg-btn pg-btn--ghost vc-btn-sm"
(click)="pick(v); $event.stopPropagation()"
>
<mat-icon>undo</mat-icon> Unpass
</button>
</td>
</tr>
}
@if (!vouchers.length) {
<tr>
<td colspan="7" class="vc-empty">
<mat-icon>{{ loadingVouchers ? 'hourglass_empty' : 'inbox' }}</mat-icon>
<div>{{ loadingVouchers ? 'Loading…' : 'No cash deposit voucher of today can be unpassed.' }}</div>
</td>
</tr>
}
</tbody>
</table>
</div>
}
<!-- ─── Unpassed: enter the correct account ──────────────────────────── -->
@if (tab === 'unpassed') {
<div class="pg-card">
<table class="pg-table vc-table">
<thead>
<tr>
<th>Voucher</th>
<th>Posted to</th>
<th>Customer</th>
<th>Reason</th>
<th class="num">Amount</th>
<th class="vc-col-action"></th>
</tr>
</thead>
<tbody>
@for (u of unpassed; track u.id) {
<tr [class.vc-row--on]="active?.id === u.id" (click)="open(u)">
<td>
<span class="pg-chip">{{ u.original_voucher_no }}</span>
</td>
<td class="vc-mono">{{ u.wrong_account_no }}</td>
<td>{{ u.wrong_client_name || '—' }}</td>
<td class="vc-clip">{{ u.reason }}</td>
<td class="num">₹{{ u.amount | number: '1.2-2' }}</td>
<td class="vc-col-action">
<button
type="button"
class="pg-btn pg-btn--primary vc-btn-sm"
(click)="open(u); $event.stopPropagation()"
>
<mat-icon>edit</mat-icon> Correct
</button>
</td>
</tr>
}
@if (!unpassed.length) {
<tr>
<td colspan="6" class="vc-empty">
<mat-icon>{{ loadingUnpassed ? 'hourglass_empty' : 'task_alt' }}</mat-icon>
<div>
{{ loadingUnpassed ? 'Loading…' : 'No unpassed voucher is waiting for its correct account.' }}
</div>
</td>
</tr>
}
</tbody>
</table>
</div>
}
<!-- ─── My requests ──────────────────────────────────────────────────── -->
@if (tab === 'mine') {
<div class="pg-card">
<table class="pg-table vc-table">
<thead>
<tr>
<th>Voucher</th>
<th>Posted to</th>
<th>Corrected to</th>
<th class="num">Amount</th>
<th>Status</th>
<th>New voucher</th>
</tr>
</thead>
<tbody>
@for (m of mine; track m.id) {
<tr>
<td>
<span class="pg-chip">{{ m.original_voucher_no }}</span>
</td>
<td class="vc-mono">{{ m.wrong_account_no }}</td>
<td class="vc-mono">{{ m.correct_account_no || '—' }}</td>
<td class="num">₹{{ m.amount | number: '1.2-2' }}</td>
<td>
<span class="vc-status vc-status--{{ m.status }}">{{ statusLabel[m.status] }}</span>
@if (m.rejection_reason) {
<div class="vc-note">{{ m.rejection_reason }}</div>
}
</td>
<td>{{ m.new_voucher_no || '—' }}</td>
</tr>
}
@if (!mine.length) {
<tr>
<td colspan="6" class="vc-empty">
<mat-icon>{{ loadingMine ? 'hourglass_empty' : 'history' }}</mat-icon>
<div>{{ loadingMine ? 'Loading…' : 'You have no voucher corrections yet.' }}</div>
</td>
</tr>
}
</tbody>
</table>
</div>
}
</div>
<!-- ─── Popup: unpass a voucher ──────────────────────────────────────────── -->
<ng-template #unpassDialog>
@if (selectedVoucher) {
<div class="vc-dlg">
<header class="vc-dlg__head">
<mat-icon class="vc-dlg__ico">undo</mat-icon>
<div class="vc-dlg__titles">
<div class="vc-dlg__title">Unpass voucher</div>
<div class="vc-dlg__sub vc-mono">{{ selectedVoucher.voucherNo }}</div>
</div>
<button type="button" class="vc-dlg__x" aria-label="Close" (click)="closeUnpass()">
<mat-icon>close</mat-icon>
</button>
</header>
<div class="vc-dlg__body">
<div class="vc-dlg__amount">
<span>Deposit amount</span>
<strong>₹{{ selectedVoucher.amount | number: '1.2-2' }}</strong>
</div>
<div class="vc-grid">
<div class="vc-cell">
<div class="vc-label">Voucher</div>
<div class="vc-value vc-mono">{{ selectedVoucher.voucherNo }}</div>
</div>
<div class="vc-cell">
<div class="vc-label">Created</div>
<div class="vc-value">{{ selectedVoucher.createdAt | date: 'dd MMM yyyy, hh:mm a' }}</div>
</div>
<div class="vc-cell">
<div class="vc-label">Cashier</div>
<div class="vc-value">{{ selectedVoucher.cashier }}</div>
</div>
<div class="vc-cell">
<div class="vc-label">Mode</div>
<div class="vc-value">{{ selectedVoucher.paymentType }}</div>
</div>
<div class="vc-cell">
<div class="vc-label">Account</div>
<div class="vc-value vc-mono">{{ selectedVoucher.accountNo }}</div>
</div>
<div class="vc-cell">
<div class="vc-label">Customer</div>
<div class="vc-value">{{ selectedVoucher.clientName || '—' }}</div>
</div>
<div class="vc-cell">
<div class="vc-label">Account balance now</div>
<div class="vc-value">
@if (voucherAccount) {
₹{{ voucherAccount.balance | number: '1.2-2' }}
} @else {
—
}
</div>
</div>
<div class="vc-cell">
<div class="vc-label">Account status</div>
<div class="vc-value">
@if (voucherAccount) {
{{ voucherAccount.canReceive ? 'Active' : 'Not active' }} · {{ voucherAccount.currencyCode }}
} @else {
—
}
</div>
</div>
</div>
<label class="vc-label" for="vc-reason">Reason for unpassing</label>
<textarea
id="vc-reason"
class="vc-reason"
rows="2"
placeholder="Why is this voucher wrong? e.g. deposited in the wrong account (required)"
[(ngModel)]="unpassReason"
></textarea>
<div class="vc-info">
<mat-icon>info</mat-icon>
A manager approves the unpass first. Nothing changes on any account until the corrected entry is approved: the
deposit stays on {{ selectedVoucher.accountNo }} till then.
</div>
</div>
<footer class="vc-dlg__foot">
<button type="button" class="pg-btn pg-btn--ghost" (click)="closeUnpass()">Cancel</button>
<button type="button" class="pg-btn pg-btn--primary" [disabled]="!canUnpass" (click)="unpass()">
<mat-icon>send</mat-icon> Send for approval
</button>
</footer>
</div>
}
</ng-template>
<!-- ─── Popup: enter the correct account ─────────────────────────────────── -->
<ng-template #correctionDialog>
@if (active) {
<div class="vc-dlg">
<header class="vc-dlg__head">
<mat-icon class="vc-dlg__ico">edit_note</mat-icon>
<div class="vc-dlg__titles">
<div class="vc-dlg__title">Correction entry</div>
<div class="vc-dlg__sub vc-mono">{{ active.original_voucher_no }}</div>
</div>
<button type="button" class="vc-dlg__x" aria-label="Close" (click)="closeActive()">
<mat-icon>close</mat-icon>
</button>
</header>
<div class="vc-dlg__body">
<div class="vc-dlg__amount">
<span>Amount to move</span>
<strong>₹{{ active.amount | number: '1.2-2' }}</strong>
</div>
<!-- Everything but the account number is fixed. -->
<div class="vc-grid">
<div class="vc-cell">
<div class="vc-label">Transaction date</div>
<div class="vc-value">{{ active.txn_date || '—' }}</div>
</div>
<div class="vc-cell">
<div class="vc-label">Mode</div>
<div class="vc-value">Cash</div>
</div>
<div class="vc-cell">
<div class="vc-label">Unpassed by</div>
<div class="vc-value">{{ active.unpass_by_name || '—' }}</div>
</div>
<div class="vc-cell">
<div class="vc-label">Approved by</div>
<div class="vc-value">{{ active.unpass_approved_by_name || '—' }}</div>
</div>
<div class="vc-cell vc-cell--wide">
<div class="vc-label">Reason</div>
<div class="vc-value">{{ active.reason }}</div>
</div>
</div>
<div class="vc-compare">
<!-- The account the deposit went to -->
<div class="vc-acct vc-acct--wrong">
<div class="vc-acct__tag">Posted to (wrong)</div>
<div class="vc-acct__no vc-mono">{{ active.wrong_account_no }}</div>
<div class="vc-acct__name">{{ active.wrong_client_name || '—' }}</div>
<div class="vc-acct__bal">
Balance:
@if (wrongAccount) {
<strong>₹{{ wrongAccount.balance | number: '1.2-2' }}</strong>
@if (wrongHasBalance) {
<span class="vc-ok"><mat-icon>check_circle</mat-icon> covers the deposit</span>
} @else {
<span class="vc-bad"
><mat-icon>cancel</mat-icon> does not cover ₹{{ active.amount | number: '1.2-2' }}</span
>
}
} @else {
—
}
</div>
</div>
<mat-icon class="vc-arrow">arrow_forward</mat-icon>
<!-- The account it should have gone to -->
<div class="vc-acct" [class.vc-acct--ok]="correctOk" [class.vc-acct--wrong]="!!lookupError">
<div class="vc-acct__tag">Correct account</div>
<input
id="vc-correct"
class="vc-acct__input vc-mono"
type="text"
placeholder="Type the account number"
[ngModel]="correctAccountNo"
(ngModelChange)="onAccountInput($event)"
(blur)="lookup()"
/>
@if (lookingUp) {
<div class="vc-acct__bal">Looking up…</div>
}
@if (correctAccount) {
<div class="vc-acct__name">{{ correctAccount.clientName || '—' }}</div>
<div class="vc-acct__bal">
Balance: <strong>₹{{ correctAccount.balance | number: '1.2-2' }}</strong>
@if (correctOk) {
<span class="vc-ok"><mat-icon>check_circle</mat-icon> active, can receive</span>
}
</div>
}
@if (lookupError) {
<div class="vc-bad vc-acct__err"><mat-icon>error</mat-icon> {{ lookupError }}</div>
}
</div>
</div>
@if (wrongAccount && !wrongHasBalance) {
<div class="vc-warn">
<mat-icon>warning</mat-icon>
<div>
<strong>The customer has already used this money,</strong> so it cannot be taken back from
{{ active.wrong_account_no }}. Deposit ₹{{ active.amount | number: '1.2-2' }} into the correct account
from your own pocket as a fresh deposit (Transaction Entry), then press <strong>Close request</strong>.
</div>
</div>
}
</div>
<footer class="vc-dlg__foot">
<button
type="button"
class="pg-btn pg-btn--ghost vc-dlg__close-req"
[disabled]="sending"
(click)="closeRequest()"
>
<mat-icon>block</mat-icon> Close request
</button>
<button type="button" class="pg-btn pg-btn--ghost" (click)="closeActive()">Cancel</button>
<button type="button" class="pg-btn pg-btn--primary" [disabled]="!canSave" (click)="save()">
<mat-icon>check</mat-icon> Save &amp; send for approval
</button>
</footer>
</div>
}
</ng-template>
@@ -0,0 +1,599 @@
/**
* 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/.
*/
// Compact sizing: the page has to fit a 100% browser window without scrolling for the common case.
.pg {
padding: 14px 20px;
gap: 12px;
}
.pg-title {
font-size: 20px;
margin: 2px 0;
}
.pg-sub {
font-size: 12px;
}
.pg-btn {
height: 30px;
padding: 0 12px;
font-size: 12px;
mat-icon {
font-size: 16px;
height: 16px;
width: 16px;
}
&:disabled {
opacity: 0.45;
cursor: not-allowed;
}
}
.pg-table {
font-size: 12px;
th {
padding: 8px 12px;
font-size: 10px;
}
td {
padding: 7px 12px;
}
}
// ── Steps ───────────────────────────────────────────────────────────────────
.vc-steps {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(170px, 1fr));
gap: 8px;
margin: 0;
padding: 0;
list-style: none;
}
.vc-step {
display: flex;
align-items: center;
gap: 8px;
padding: 6px 10px;
border: 1px solid #e2e8f0;
border-radius: 10px;
background: #fff;
font-size: 12px;
color: #64748b;
&__no {
display: inline-flex;
align-items: center;
justify-content: center;
flex: 0 0 20px;
height: 20px;
border-radius: 50%;
background: #e2e8f0;
font-size: 11px;
font-weight: 700;
color: #475569;
}
&--on {
border-color: #1f7a4d;
background: #f0fdf4;
color: #0f4f3c;
.vc-step__no {
background: #1f7a4d;
color: #fff;
}
}
}
// ── Tabs ────────────────────────────────────────────────────────────────────
.vc-tabs {
display: flex;
gap: 2px;
border-bottom: 1px solid #e2e8f0;
flex-wrap: wrap;
}
.vc-tab {
display: inline-flex;
align-items: center;
gap: 5px;
padding: 7px 12px;
margin-bottom: -1px;
border: 0;
border-bottom: 2px solid transparent;
background: transparent;
font: inherit;
font-size: 12px;
font-weight: 600;
color: #64748b;
cursor: pointer;
mat-icon {
font-size: 16px;
height: 16px;
width: 16px;
}
&:hover {
color: #0f4f3c;
}
&--on {
color: #0f4f3c;
border-bottom-color: #1f7a4d;
}
}
.vc-count {
padding: 0 7px;
border-radius: 999px;
background: #f59e0b;
color: #fff;
font-size: 10px;
font-weight: 700;
}
// ── Search ──────────────────────────────────────────────────────────────────
.vc-toolbar {
display: flex;
align-items: center;
gap: 8px;
flex-wrap: wrap;
}
.vc-search {
display: flex;
align-items: center;
gap: 6px;
flex: 1 1 280px;
max-width: 420px;
height: 30px;
padding: 0 10px;
border: 1px solid #cbd5e1;
border-radius: 999px;
background: #fff;
&:focus-within {
border-color: #1f7a4d;
box-shadow: 0 0 0 3px rgb(31 122 77 / 12%);
}
mat-icon {
font-size: 16px;
height: 16px;
width: 16px;
color: #94a3b8;
}
input {
flex: 1;
min-width: 0;
border: 0;
outline: 0;
background: transparent;
font: inherit;
font-size: 12px;
}
}
// ── Table ───────────────────────────────────────────────────────────────────
.vc-table {
tbody tr {
cursor: pointer;
}
tr.vc-row--on td {
background: #f0fdf4;
&:first-child {
box-shadow: inset 3px 0 0 #1f7a4d;
}
}
.vc-col-action {
width: 1%;
text-align: right;
white-space: nowrap;
}
.vc-clip {
max-width: 240px;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.pg-chip {
font-size: 11px;
padding: 2px 8px;
}
}
.vc-btn-sm {
height: 26px;
padding: 0 10px;
font-size: 11px;
}
.vc-mono {
font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace;
letter-spacing: 0.02em;
}
.vc-empty {
padding: 24px 12px;
text-align: center;
color: #94a3b8;
font-size: 12px;
mat-icon {
display: block;
margin: 0 auto 4px;
font-size: 28px;
height: 28px;
width: 28px;
opacity: 0.6;
}
}
// ── Popups ──────────────────────────────────────────────────────────────────
.vc-dlg {
display: flex;
flex-direction: column;
max-height: 86vh;
font-size: 12px;
&__head {
display: flex;
align-items: center;
gap: 10px;
padding: 10px 14px;
border-bottom: 1px solid #e2e8f0;
background: #f8fafc;
}
&__ico {
flex: 0 0 32px;
height: 32px;
width: 32px;
border-radius: 9px;
background: #f0fdf4;
color: #1f7a4d;
font-size: 18px;
line-height: 32px;
text-align: center;
}
&__titles {
flex: 1;
min-width: 0;
}
&__title {
font-size: 14px;
font-weight: 700;
color: #0f172a;
}
&__sub {
font-size: 11px;
color: #64748b;
}
&__x {
display: inline-flex;
align-items: center;
justify-content: center;
height: 28px;
width: 28px;
border: 0;
border-radius: 50%;
background: transparent;
color: #64748b;
cursor: pointer;
mat-icon {
font-size: 18px;
height: 18px;
width: 18px;
}
&:hover {
background: #e2e8f0;
}
}
&__body {
padding: 12px 14px;
overflow-y: auto;
}
&__amount {
display: flex;
align-items: baseline;
justify-content: space-between;
margin-bottom: 10px;
padding: 8px 12px;
border-radius: 10px;
background: #ecfdf5;
border: 1px solid #a7f3d0;
color: #065f46;
font-size: 12px;
font-weight: 600;
strong {
font-size: 18px;
font-weight: 800;
font-variant-numeric: tabular-nums;
}
}
&__foot {
display: flex;
justify-content: flex-end;
gap: 8px;
padding: 10px 14px;
border-top: 1px solid #e2e8f0;
background: #f8fafc;
}
}
.vc-warn {
display: flex;
align-items: flex-start;
gap: 8px;
margin-top: 10px;
padding: 8px 12px;
border-radius: 8px;
background: #fffbeb;
border: 1px solid #fde68a;
color: #92400e;
font-size: 11px;
mat-icon {
flex: 0 0 18px;
height: 18px;
width: 18px;
font-size: 18px;
}
}
.vc-dlg__close-req {
margin-right: auto;
color: #b45309;
}
.vc-label {
display: block;
margin-bottom: 3px;
font-size: 10px;
font-weight: 700;
letter-spacing: 0.05em;
text-transform: uppercase;
color: #94a3b8;
}
.vc-reason {
width: 100%;
padding: 7px 10px;
border: 1px solid #cbd5e1;
border-radius: 8px;
font: inherit;
font-size: 12px;
resize: vertical;
&:focus {
outline: 0;
border-color: #1f7a4d;
box-shadow: 0 0 0 3px rgb(31 122 77 / 12%);
}
}
.vc-info {
display: flex;
align-items: flex-start;
gap: 6px;
margin-top: 8px;
padding: 7px 10px;
border-radius: 8px;
background: #eff6ff;
color: #1e40af;
font-size: 11px;
mat-icon {
flex: 0 0 16px;
height: 16px;
width: 16px;
font-size: 16px;
}
}
.vc-grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(130px, 1fr));
gap: 6px;
margin-bottom: 10px;
}
.vc-cell {
padding: 6px 10px;
border-radius: 8px;
background: #f8fafc;
border: 1px solid #eef2f7;
}
.vc-cell--wide {
grid-column: 1 / -1;
}
.vc-value {
font-size: 12px;
font-weight: 600;
color: #0f172a;
word-break: break-word;
}
// ── Wrong → Correct ─────────────────────────────────────────────────────────
.vc-compare {
display: grid;
grid-template-columns: 1fr auto 1fr;
align-items: stretch;
gap: 8px;
@media (width <= 640px) {
grid-template-columns: 1fr;
}
}
.vc-arrow {
align-self: center;
color: #94a3b8;
@media (width <= 640px) {
transform: rotate(90deg);
justify-self: center;
}
}
.vc-acct {
padding: 10px 12px;
border: 1px solid #e2e8f0;
border-radius: 10px;
background: #fff;
&--wrong {
border-color: #fecaca;
background: #fef2f2;
}
&--ok {
border-color: #a7f3d0;
background: #ecfdf5;
}
&__tag {
margin-bottom: 4px;
font-size: 10px;
font-weight: 700;
letter-spacing: 0.05em;
text-transform: uppercase;
color: #64748b;
}
&__no {
font-size: 14px;
font-weight: 700;
color: #0f172a;
}
&__input {
width: 100%;
height: 32px;
padding: 0 10px;
border: 1px solid #cbd5e1;
border-radius: 8px;
background: #fff;
font-size: 13px;
font-weight: 600;
&:focus {
outline: 0;
border-color: #1f7a4d;
box-shadow: 0 0 0 3px rgb(31 122 77 / 12%);
}
}
&__name {
margin-top: 4px;
font-size: 12px;
font-weight: 600;
color: #1e293b;
}
&__bal {
margin-top: 3px;
font-size: 11px;
color: #475569;
}
&__err {
margin-top: 6px;
}
}
.vc-ok,
.vc-bad {
display: inline-flex;
align-items: center;
gap: 3px;
margin-left: 6px;
font-weight: 700;
mat-icon {
font-size: 14px;
height: 14px;
width: 14px;
}
}
.vc-ok {
color: #059669;
}
.vc-bad {
color: #dc2626;
}
.vc-note {
margin-top: 3px;
font-size: 11px;
color: #64748b;
}
// ── Status ──────────────────────────────────────────────────────────────────
.vc-status {
display: inline-block;
padding: 2px 8px;
border-radius: 999px;
background: #e2e8f0;
font-size: 11px;
font-weight: 700;
&--UNPASS_PENDING,
&--CORRECTION_PENDING {
background: #fef3c7;
color: #92400e;
}
&--UNPASSED {
background: #dbeafe;
color: #1e40af;
}
&--COMPLETED {
background: #d1fae5;
color: #065f46;
}
&--CLOSED {
background: #f1f5f9;
color: #475569;
}
&--UNPASS_REJECTED {
background: #fee2e2;
color: #991b1b;
}
}
@@ -0,0 +1,307 @@
/**
* 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, TemplateRef, ViewChild, inject } from '@angular/core';
import { CommonModule } from '@angular/common';
import { FormsModule } from '@angular/forms';
import { MatDialog, MatDialogModule, MatDialogRef } from '@angular/material/dialog';
import { MatIconModule } from '@angular/material/icon';
import { MatSnackBar, MatSnackBarModule } from '@angular/material/snack-bar';
import { of } from 'rxjs';
import { catchError } from 'rxjs/operators';
import { AuthenticationService } from '../../core/authentication/authentication.service';
import {
AccountLookup,
EligibleVoucher,
VoucherCorrection,
VoucherCorrectionService
} from '../voucher-correction.service';
type Tab = 'today' | 'unpassed' | 'mine';
/**
* Cashier side of correcting a cash deposit that went to the wrong account.
*
* Today vouchers: pick the voucher and unpass it (needs a manager approval; nothing moves).
* Unpassed: open the approved voucher, whose details are fixed, change only the account number and save
* (needs a manager approval again; only then do the accounts change).
*/
@Component({
selector: 'mifosx-voucher-correction',
templateUrl: './voucher-correction.component.html',
styleUrls: [
'../cashier-shared.scss',
'./voucher-correction.component.scss'
],
standalone: true,
imports: [
CommonModule,
FormsModule,
MatIconModule,
MatDialogModule,
MatSnackBarModule
]
})
export class VoucherCorrectionComponent implements OnInit {
private service = inject(VoucherCorrectionService);
private snackBar = inject(MatSnackBar);
private dialog = inject(MatDialog);
private auth = inject(AuthenticationService);
/** Unpassing is for the back office (an administrator or a clerk); re-entering the account is the cashier only. */
readonly mayUnpass = this.auth.isClerkUser() || !!this.auth.getCredentials()?.permissions?.includes('ALL_FUNCTIONS');
readonly mayCorrect = this.auth.isCashierUser();
@ViewChild('unpassDialog') unpassDialog!: TemplateRef<unknown>;
@ViewChild('correctionDialog') correctionDialog!: TemplateRef<unknown>;
private dialogRef: MatDialogRef<unknown> | null = null;
tab: Tab = this.mayUnpass ? 'today' : this.mayCorrect ? 'unpassed' : 'mine';
// Today vouchers
search = '';
vouchers: EligibleVoucher[] = [];
loadingVouchers = false;
selectedVoucher: EligibleVoucher | null = null;
/** The account the voucher was posted to, for the balance and status shown in the popup. */
voucherAccount: AccountLookup | null = null;
unpassReason = '';
sending = false;
// Unpassed vouchers waiting for the correct account
unpassed: VoucherCorrection[] = [];
loadingUnpassed = false;
active: VoucherCorrection | null = null;
wrongAccount: AccountLookup | null = null;
correctAccountNo = '';
correctAccount: AccountLookup | null = null;
lookupError = '';
lookingUp = false;
// The cashier own requests
mine: VoucherCorrection[] = [];
loadingMine = false;
readonly statusLabel: Record<string, string> = {
UNPASS_PENDING: 'Unpass awaiting approval',
UNPASSED: 'Unpassed — enter correct account',
UNPASS_REJECTED: 'Unpass rejected',
CORRECTION_PENDING: 'Correction awaiting approval',
COMPLETED: 'Corrected',
CLOSED: 'Closed — cashier deposits separately'
};
ngOnInit(): void {
if (this.mayUnpass) this.loadVouchers();
if (this.mayCorrect) this.loadUnpassed();
if (this.tab === 'mine') this.loadMine();
}
setTab(tab: Tab): void {
this.tab = tab;
if (tab === 'today') this.loadVouchers();
if (tab === 'unpassed') this.loadUnpassed();
if (tab === 'mine') this.loadMine();
}
// ── Today vouchers ─────────────────────────────────────────────────────────
loadVouchers(): void {
this.loadingVouchers = true;
this.service
.eligible(this.search.trim())
.pipe(catchError(() => of([] as EligibleVoucher[])))
.subscribe((rows) => {
this.vouchers = rows;
this.loadingVouchers = false;
if (this.selectedVoucher && !rows.some((r) => r.voucherNo === this.selectedVoucher?.voucherNo)) {
this.selectedVoucher = null;
}
});
}
pick(v: EligibleVoucher): void {
this.selectedVoucher = v;
this.unpassReason = '';
this.voucherAccount = null;
this.service
.accountLookup(v.accountNo)
.pipe(catchError(() => of(null)))
.subscribe((a) => (this.voucherAccount = a));
this.showDialog(this.unpassDialog, () => (this.selectedVoucher = null));
}
closeUnpass(): void {
this.dialogRef?.close();
}
get canUnpass(): boolean {
return !!this.selectedVoucher && this.unpassReason.trim().length > 0 && !this.sending;
}
unpass(): void {
if (!this.selectedVoucher || !this.canUnpass) return;
this.sending = true;
this.service.unpass(this.selectedVoucher.voucherNo, this.unpassReason.trim()).subscribe({
next: () => {
this.sending = false;
this.snackBar.open(`Voucher ${this.selectedVoucher?.voucherNo} sent for unpass approval`, 'Close', {
duration: 4000
});
this.dialogRef?.close();
this.unpassReason = '';
this.loadVouchers();
},
error: (err) => {
this.sending = false;
this.fail(err, 'Could not unpass the voucher');
}
});
}
// ── Unpassed vouchers: enter the correct account ───────────────────────────
loadUnpassed(): void {
this.loadingUnpassed = true;
this.service
.list('UNPASSED')
.pipe(catchError(() => of([] as VoucherCorrection[])))
.subscribe((rows) => {
this.unpassed = rows;
this.loadingUnpassed = false;
});
}
open(row: VoucherCorrection): void {
this.active = row;
this.correctAccountNo = '';
this.correctAccount = null;
this.lookupError = '';
this.wrongAccount = null;
// The account the deposit went to: its balance is what the "can this be taken back" check reads.
this.service
.accountLookup(row.wrong_account_no)
.pipe(catchError(() => of(null)))
.subscribe((a) => (this.wrongAccount = a));
this.showDialog(this.correctionDialog, () => (this.active = null));
}
closeActive(): void {
this.dialogRef?.close();
}
onAccountInput(value: string): void {
this.correctAccountNo = value;
this.correctAccount = null;
this.lookupError = '';
// Same rule as the Transaction Entry screen: searches on its own once six characters are typed.
if (value.trim().length >= 6) this.lookup();
}
lookup(): void {
const accountNo = this.correctAccountNo.trim();
if (!accountNo || this.lookingUp) return;
this.lookingUp = true;
this.service.accountLookup(accountNo).subscribe({
next: (a) => {
this.lookingUp = false;
// A late answer for a number no longer typed is dropped.
if (accountNo !== this.correctAccountNo.trim()) return;
this.correctAccount = a;
if (this.active && a.id === this.active.wrong_savings_account_id) {
this.lookupError = 'This is the account the deposit was already posted to.';
} else if (!a.canReceive) {
this.lookupError = 'This account is not active, so it cannot receive the deposit.';
}
},
error: (err) => {
this.lookingUp = false;
this.lookupError = err?.error?.errors?.[0]?.defaultUserMessage ?? 'Account not found.';
}
});
}
/** The wrong account still holds the deposit, so it can be taken back without overdrawing it. */
get wrongHasBalance(): boolean {
return !!this.wrongAccount && !!this.active && this.wrongAccount.balance >= this.active.amount;
}
get correctOk(): boolean {
return !!this.correctAccount && this.correctAccount.canReceive && !this.lookupError;
}
get canSave(): boolean {
return !!this.active && this.correctOk && this.wrongHasBalance && !this.sending;
}
save(): void {
if (!this.active || !this.canSave) return;
this.sending = true;
this.service.submitCorrection(this.active.id, this.correctAccountNo.trim()).subscribe({
next: () => {
this.sending = false;
this.snackBar.open('Correction sent for approval', 'Close', { duration: 4000 });
this.dialogRef?.close();
this.loadUnpassed();
},
error: (err) => {
this.sending = false;
this.fail(err, 'Could not save the correction');
}
});
}
/**
* Gives up this correction. The usual reason is that the wrong account no longer holds the money, so the cashier
* puts the amount into the right account from their own pocket as a fresh deposit instead.
*/
closeRequest(): void {
if (!this.active || this.sending) return;
const reason = this.wrongHasBalance
? 'Closed by the cashier'
: 'Wrong account no longer holds the amount; cashier deposits it into the correct account separately';
this.sending = true;
this.service.close(this.active.id, reason).subscribe({
next: () => {
this.sending = false;
this.snackBar.open('Request closed', 'Close', { duration: 4000 });
this.dialogRef?.close();
this.loadUnpassed();
},
error: (err) => {
this.sending = false;
this.fail(err, 'Could not close the request');
}
});
}
// ── My requests ────────────────────────────────────────────────────────────
loadMine(): void {
this.loadingMine = true;
this.service
.list(undefined, true)
.pipe(catchError(() => of([] as VoucherCorrection[])))
.subscribe((rows) => {
this.mine = rows;
this.loadingMine = false;
});
}
/** Opens one of the two popups; onClose runs however it is dismissed (button, backdrop or Esc). */
private showDialog(template: TemplateRef<unknown>, onClose: () => void): void {
this.dialogRef?.close();
this.dialogRef = this.dialog.open(template, { width: '600px', maxWidth: '95vw', autoFocus: false });
this.dialogRef.afterClosed().subscribe(onClose);
}
private fail(err: any, fallback: string): void {
const msg = err?.error?.errors?.[0]?.defaultUserMessage ?? err?.error?.defaultUserMessage ?? fallback;
this.snackBar.open(msg, 'Close', { duration: 6000 });
}
}
@@ -169,6 +169,7 @@
<th>Debit</th>
<th>Credit</th>
<th class="num">Amount</th>
<th>Correction</th>
<th>User</th>
<!-- Two dates, not one, on the system register.
A voucher's timestamp is the minute it was written; the posting behind it carries
@@ -217,6 +218,18 @@
</td>
}
<td class="num">{{ row.amount | number: '1.2-2' }}</td>
<!-- Where the voucher stands after a correction: unpassed, revoked, closed or corrected. -->
<td>
<mifosx-voucher-status
[status]="row.voucherStatus"
[revokedBy]="row.revokedByVoucherNo"
[replaces]="row.replacesVoucherNo"
[note]="row.correctionNote"
></mifosx-voucher-status>
@if (!row.voucherStatus || row.voucherStatus === 'ACTIVE') {
<span class="vr-active">—</span>
}
</td>
<td>{{ row.username || '—' }}</td>
@if (systemOnly) {
<td class="vr-ledgerdate" [class.vr-ledgerdate--prior]="row.postedAfterClose">
@@ -92,3 +92,8 @@
text-transform: uppercase;
color: #7c3aed;
}
.vr-active {
font-size: 11px;
color: #94a3b8;
}
@@ -14,6 +14,7 @@ import { MatIconModule } from '@angular/material/icon';
import { catchError, of } from 'rxjs';
import { CashierService } from '../cashier.service';
import { businessToday } from 'app/core/utils/business-today';
import { VoucherStatusComponent, VoucherStatus } from '../voucher-status/voucher-status.component';
/**
* Which side of the register a screen shows.
@@ -26,6 +27,11 @@ export type VoucherSide = 'CR' | 'DR' | 'ALL';
/** One voucher as the register shows it: the number, the two sides, and who raised it. */
interface VoucherRow {
voucherNo: string;
/** Where the voucher stands after a correction: active, revoked, under correction, or corrected. */
voucherStatus?: VoucherStatus;
revokedByVoucherNo?: string | null;
replacesVoucherNo?: string | null;
correctionNote?: string | null;
voucherType: string | null;
amount: number | null;
createdAt: string | null;
@@ -82,7 +88,8 @@ interface VoucherReport {
imports: [
CommonModule,
FormsModule,
MatIconModule
MatIconModule,
VoucherStatusComponent
]
})
export class VoucherReportComponent implements OnInit {
@@ -0,0 +1,137 @@
/**
* 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, Input } from '@angular/core';
import { CommonModule } from '@angular/common';
/** Where a voucher stands after a voucher correction. */
export type VoucherStatus =
| 'ACTIVE'
| 'REVOKED'
| 'UNPASS_PENDING'
| 'UNPASSED'
| 'CORRECTION_PENDING'
| 'CORRECTED'
| 'CLOSED'
| null
| undefined;
/**
* A small badge saying whether a voucher was revoked, is under correction, or replaced another one.
* Nothing is shown for an ordinary (active) voucher, so the registers read as before.
*/
@Component({
selector: 'mifosx-voucher-status',
standalone: true,
imports: [CommonModule],
template: `
@if (label) {
<span class="vs" [class]="'vs vs--' + status" [title]="hint">{{ label }}</span>
@if (note) {
<div class="vs-note">{{ note }}</div>
}
}
`,
styles: [
`
.vs {
display: inline-block;
margin-left: 6px;
padding: 1px 8px;
border-radius: 999px;
background: #e2e8f0;
color: #334155;
font-size: 10px;
font-weight: 700;
white-space: nowrap;
vertical-align: middle;
}
.vs-note {
margin-top: 2px;
max-width: 280px;
font-size: 11px;
line-height: 1.3;
color: #64748b;
}
.vs--REVOKED {
background: #fee2e2;
color: #991b1b;
}
.vs--UNPASS_PENDING,
.vs--CORRECTION_PENDING {
background: #fef3c7;
color: #92400e;
}
.vs--UNPASSED {
background: #dbeafe;
color: #1e40af;
}
.vs--CLOSED {
background: #f1f5f9;
color: #475569;
}
.vs--CORRECTED {
background: #d1fae5;
color: #065f46;
}
`
]
})
export class VoucherStatusComponent {
@Input() status: VoucherStatus;
/** The voucher that replaced this one, when it is revoked. */
@Input() revokedBy: string | null | undefined;
/** The voucher this one replaced, when it is the corrected one. */
@Input() replaces: string | null | undefined;
/** Why: the reason the voucher was unpassed, or why the correction was closed. */
@Input() note: string | null | undefined;
get label(): string {
switch (this.status) {
case 'REVOKED':
return this.revokedBy ? `Revoked → ${this.revokedBy}` : 'Revoked';
case 'UNPASS_PENDING':
return 'Unpass pending';
case 'UNPASSED':
return 'Unpassed';
case 'CORRECTION_PENDING':
return 'Correction pending';
case 'CLOSED':
return 'Closed — cashier deposits separately';
case 'CORRECTED':
return this.replaces ? `Corrected (replaces ${this.replaces})` : 'Corrected';
default:
return '';
}
}
get hint(): string {
switch (this.status) {
case 'REVOKED':
return 'This voucher was revoked: its deposit was moved to another account and a new voucher replaced it.';
case 'UNPASS_PENDING':
return 'A manager has not yet approved unpassing this voucher.';
case 'UNPASSED':
return 'Unpassed: waiting for the cashier to enter the correct account.';
case 'CORRECTION_PENDING':
return 'The correct account is entered; waiting for a manager to approve.';
case 'CLOSED':
return 'The correction was closed by the cashier; the voucher stays as it was.';
case 'CORRECTED':
return 'This voucher was raised by a correction and replaces the earlier one.';
default:
return '';
}
}
}
@@ -102,6 +102,27 @@
</div>
}
<!-- Money moving between branches: the approver must see it before approving -->
@if (isInterBranch) {
<div class="dri-interbranch">
<mat-icon>alt_route</mat-icon>
<div>
<div class="dri-interbranch__title">Inter-branch transfer</div>
<div class="dri-interbranch__text">
@if (branchNames(drEntries) && branchNames(crEntries)) {
Money moves from <strong>{{ branchNames(drEntries) }}</strong> to
<strong>{{ branchNames(crEntries) }}</strong
>.
} @else {
These accounts are held at different branches: <strong>{{ branchNames(creditEntries) }}</strong
>.
}
Each account is posted in its own branch's books. Check the accounts before approving.
</div>
</div>
</div>
}
<!-- Bank Transfer summary card (replaces GL card) -->
@if (isBankTransferMode) {
<div class="dri-card">
@@ -154,6 +175,7 @@
<th>Account No</th>
<th>Account Name</th>
<th>Type</th>
<th>Branch</th>
<th class="num">{{ isCollectionMode ? 'Amount (DR)' : 'Amount (CR)' }}</th>
</tr>
</thead>
@@ -166,11 +188,16 @@
<td>
<span class="dri-type-badge">{{ accountTypeLabel(entry.accountType) }}</span>
</td>
<td>
<span class="dri-branch-badge" [class.dri-branch-badge--cross]="isInterBranch">{{
entry.officeName || '—'
}}</span>
</td>
<td class="num dri-cr-amount">₹{{ entry.amount | number: '1.2-2' }}</td>
</tr>
}
<tr class="dri-cr-total-row">
<td colspan="4" class="dri-cr-total-label">{{ isCollectionMode ? 'Total Debit' : 'Total Credit' }}</td>
<td colspan="5" class="dri-cr-total-label">{{ isCollectionMode ? 'Total Debit' : 'Total Credit' }}</td>
<td class="num dri-cr-total-amount">₹{{ totalCreditAmount() | number: '1.2-2' }}</td>
</tr>
</tbody>
@@ -192,6 +219,7 @@
<th>Account No</th>
<th>Account Name</th>
<th>Type</th>
<th>Branch</th>
<th class="num">Amount (DR)</th>
</tr>
</thead>
@@ -204,11 +232,16 @@
<td>
<span class="dri-type-badge">{{ accountTypeLabel(entry.accountType) }}</span>
</td>
<td>
<span class="dri-branch-badge" [class.dri-branch-badge--cross]="isInterBranch">{{
entry.officeName || '—'
}}</span>
</td>
<td class="num dri-cr-amount">₹{{ entry.amount | number: '1.2-2' }}</td>
</tr>
}
<tr class="dri-cr-total-row">
<td colspan="4" class="dri-cr-total-label">Total Debit</td>
<td colspan="5" class="dri-cr-total-label">Total Debit</td>
<td class="num dri-cr-total-amount">₹{{ totalDebitAmount() | number: '1.2-2' }}</td>
</tr>
</tbody>
@@ -228,6 +261,7 @@
<th>Account No</th>
<th>Account Name</th>
<th>Type</th>
<th>Branch</th>
<th class="num">Amount (CR)</th>
</tr>
</thead>
@@ -240,11 +274,16 @@
<td>
<span class="dri-type-badge">{{ accountTypeLabel(entry.accountType) }}</span>
</td>
<td>
<span class="dri-branch-badge" [class.dri-branch-badge--cross]="isInterBranch">{{
entry.officeName || '—'
}}</span>
</td>
<td class="num dri-cr-amount">₹{{ entry.amount | number: '1.2-2' }}</td>
</tr>
}
<tr class="dri-cr-total-row">
<td colspan="4" class="dri-cr-total-label">Total Credit</td>
<td colspan="5" class="dri-cr-total-label">Total Credit</td>
<td class="num dri-cr-total-amount">₹{{ totalCreditAmount() | number: '1.2-2' }}</td>
</tr>
</tbody>
@@ -534,3 +534,54 @@ $shadow-md: 0 4px 16px rgb(0 0 0 / 9%);
font-family: 'Courier New', monospace;
letter-spacing: 0.02em;
}
/* A leg's branch. Amber on every leg when the request moves money between branches, neutral otherwise. */
.dri-branch-badge {
display: inline-flex;
align-items: center;
padding: 3px 10px;
border-radius: 20px;
background: #f1f5f9;
border: 1px solid #cbd5e1;
font-size: 11px;
font-weight: 700;
color: #475569;
&--cross {
background: #fffbeb;
border-color: #fcd34d;
color: #92400e;
}
}
/* Shown above the summary when the accounts are at different branches. */
.dri-interbranch {
display: flex;
align-items: flex-start;
gap: 12px;
margin: 0 0 12px;
padding: 12px 16px;
border: 1px solid #fcd34d;
border-left: 4px solid #f59e0b;
border-radius: 8px;
background: #fffbeb;
color: #78350f;
mat-icon {
flex-shrink: 0;
color: #d97706;
}
&__title {
font-size: 13px;
font-weight: 800;
letter-spacing: 0.02em;
text-transform: uppercase;
}
&__text {
margin-top: 2px;
font-size: 13px;
line-height: 1.5;
}
}
@@ -25,6 +25,9 @@ interface CreditEntry {
accountType: string;
amount: number;
dbCr?: string;
/** The branch the account is held at; absent for a GL leg or when the server could not resolve it. */
officeId?: number | null;
officeName?: string | null;
}
@Component({
@@ -167,6 +170,19 @@ export class DisbursementReviewComponent implements OnInit {
return this.creditEntries.filter((e) => e.dbCr !== 'Dr');
}
/**
* True when the accounts in this request are held at more than one branch, so money moves between branches. The
* approver has to see that before approving: it is booked in each branch's own books.
*/
get isInterBranch(): boolean {
return new Set(this.creditEntries.filter((e) => e.officeId != null).map((e) => e.officeId)).size > 1;
}
/** Distinct branch names among the given legs, in order, e.g. for "Head Office" or "Head Office, bhopal". */
branchNames(entries: CreditEntry[]): string {
return [...new Set(entries.map((e) => e.officeName).filter((name): name is string => !!name))].join(', ');
}
totalCreditAmount(): number {
const entries = this.isCollectionMode ? this.drEntries : this.crEntries;
return entries.reduce((sum, e) => sum + (e.amount ?? 0), 0);
@@ -930,6 +930,10 @@
createClientForm.get('mobileNo')?.hasError('pattern') && createClientForm.get('mobileNo')?.touched
) {
<mat-error>Enter valid 10-digit number (starts with 6-9)</mat-error>
} @else if (createClientForm.get('mobileNo')?.hasError('contactTaken')) {
<mat-error
>This mobile number already belongs to another customer. Please enter a different number.</mat-error
>
}
</mat-form-field>
@@ -940,7 +944,11 @@
color="primary"
type="button"
class="verify-btn"
[disabled]="readOnly || createClientForm.get('mobileNo')?.value?.length !== 10"
[disabled]="
readOnly ||
createClientForm.get('mobileNo')?.value?.length !== 10 ||
createClientForm.get('mobileNo')?.hasError('contactTaken')
"
(click)="verifyWithDIP()"
>
<fa-icon icon="shield-alt" class="m-r-10"></fa-icon>
@@ -1008,6 +1016,8 @@
createClientForm.get('emailAddress')?.hasError('required') && createClientForm.get('emailAddress')?.touched
) {
<mat-error>Email is <strong>required</strong></mat-error>
} @else if (createClientForm.get('emailAddress')?.hasError('contactTaken')) {
<mat-error>This email already belongs to another customer. Please enter a different email.</mat-error>
} @else if (createClientForm.get('emailAddress')?.invalid && createClientForm.get('emailAddress')?.touched) {
<mat-error>Enter a valid email (e.g. name&#64;example.com)</mat-error>
}
@@ -27,15 +27,16 @@ import {
UntypedFormControl,
ReactiveFormsModule,
ValidatorFn,
AsyncValidatorFn,
AbstractControl,
ValidationErrors
} from '@angular/forms';
import { Subject, Subscription, merge } from 'rxjs';
import { filter, switchMap, takeUntil } from 'rxjs/operators';
import { Subject, Subscription, merge, of, timer } from 'rxjs';
import { catchError, filter, map, switchMap, takeUntil } from 'rxjs/operators';
import { ClientsService } from 'app/clients/clients.service';
import { Dates } from 'app/core/utils/dates';
import { LegalFormId } from 'app/clients/models/legal-form.enum';
import { toProperNameCase } from 'app/clients/utils/name-case.util';
import { toPersonNameCase, toProperNameCase } from 'app/clients/utils/name-case.util';
import { ExternalNationalIdService } from 'app/clients/services/external-national-id.service';
/** Custom Services */
@@ -936,6 +937,29 @@ export class ClientGeneralStepComponent implements OnInit, OnDestroy, OnChanges
};
}
/**
* A mobile number or email that already belongs to another customer. This is a different person using a contact
* that is taken (a PAN or Aadhaar match is what means the same customer), so the answer is to enter another one.
* Runs only once the format validators pass, and a new keystroke cancels the previous call, which is what
* debounces it. The client being edited is excluded so its own number is not flagged.
*/
private contactTakenValidator(field: 'mobileNo' | 'emailAddress'): AsyncValidatorFn {
return (control: AbstractControl) => {
const value = (control.value ?? '').toString().trim();
if (!value) {
return of(null);
}
return timer(400).pipe(
switchMap(() =>
this.clientService.lookupClientIdentity({ [field]: value, excludeClientId: this.clientData?.id ?? null })
),
map((result) => (result?.contactConflicts?.[field] ? { contactTaken: true } : null)),
// A failed lookup must not block the form: the server still refuses a duplicate when the client is saved.
catchError(() => of(null))
);
};
}
/**
* Creates the client form.
*/
@@ -969,7 +993,8 @@ export class ClientGeneralStepComponent implements OnInit, OnDestroy, OnChanges
Validators.required,
Validators.pattern(/^[6-9]\d{9}$/),
Validators.maxLength(10)
]
],
this.contactTakenValidator('mobileNo')
],
alternateMobileNo: [
'',
@@ -986,7 +1011,8 @@ export class ClientGeneralStepComponent implements OnInit, OnDestroy, OnChanges
Validators.email,
Validators.pattern(/^[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\.[a-zA-Z]{2,}$/),
Validators.maxLength(100)
]
],
this.contactTakenValidator('emailAddress')
],
dateOfBirth: [
'',
@@ -1456,7 +1482,10 @@ export class ClientGeneralStepComponent implements OnInit, OnDestroy, OnChanges
const control = this.createClientForm.get(controlName);
if (control && control.value) {
const originalValue = control.value;
const capitalized = toProperNameCase(originalValue);
// An entity's full name keeps its typed spelling (matched against the PAN name); a person's
// name parts are title-cased, the same as the Full Name line shows them.
const capitalized =
controlName === 'fullname' ? toProperNameCase(originalValue) : toPersonNameCase(originalValue);
if (originalValue !== capitalized) {
control.setValue(capitalized);
}
@@ -1925,12 +1954,11 @@ export class ClientGeneralStepComponent implements OnInit, OnDestroy, OnChanges
/** Returns capitalized full name preview from first + middle + last */
get fullNamePreview(): string {
const capitalize = (s: string) => (s ? s.charAt(0).toUpperCase() + s.slice(1).toLowerCase() : '');
const salutationId = this.createClientForm?.get('salutationId')?.value;
const salutation = this.salutationOptions?.find((s: any) => s.id === salutationId)?.name || '';
const first = capitalize(this.createClientForm?.get('firstname')?.value || '');
const middle = capitalize(this.createClientForm?.get('middlename')?.value || '');
const last = capitalize(this.createClientForm?.get('lastname')?.value || '');
const first = toPersonNameCase(this.createClientForm?.get('firstname')?.value);
const middle = toPersonNameCase(this.createClientForm?.get('middlename')?.value);
const last = toPersonNameCase(this.createClientForm?.get('lastname')?.value);
const full = [
first,
middle,
@@ -2360,6 +2388,15 @@ export class ClientGeneralStepComponent implements OnInit, OnDestroy, OnChanges
if (generalDetails.fullname) {
generalDetails.fullname = toProperNameCase(generalDetails.fullname);
}
for (const part of [
'firstname',
'middlename',
'lastname'
]) {
if (generalDetails[part]) {
generalDetails[part] = toPersonNameCase(generalDetails[part]);
}
}
if (generalDetails.submittedOnDate instanceof Date) {
generalDetails.submittedOnDate = this.dateUtils.formatDate(generalDetails.submittedOnDate, dateFormat);
@@ -0,0 +1,211 @@
<!--
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/.
-->
<section class="identity-check" [class.identity-check--blocking]="status === 'matched' || status === 'conflict'">
<header class="identity-check__header">
<mat-icon class="identity-check__icon">fingerprint</mat-icon>
<div>
<h3 class="identity-check__title">Existing customer check</h3>
<p class="identity-check__hint">
@if (isEntity) {
Enter the company's GSTIN or PAN. If either is already on file in any branch, it is the same customer and a
new one is not created.
} @else {
Enter PAN or Aadhaar. If either is already on file in any branch, it is the same customer and a new one is not
created. Nothing found? What you enter here is filled into the KYC step for you.
}
</p>
</div>
</header>
@if (form) {
<form [formGroup]="form" class="identity-check__fields">
@if (isEntity) {
<mat-form-field appearance="outline" class="identity-check__field">
<mat-label>GSTIN</mat-label>
<input
matInput
formControlName="gstin"
class="identity-check__upper"
maxlength="15"
mifosxRestrictInput="gstin"
[errorStateMatcher]="errorMatcher"
placeholder="e.g. 22AAAAA0000A1Z5"
(input)="capitalise('gstin', $event)"
/>
@if (form.get('gstin')?.hasError('pattern')) {
<mat-error>Invalid GSTIN. It has 15 characters, like 22AAAAA0000A1Z5</mat-error>
}
</mat-form-field>
}
<mat-form-field appearance="outline" class="identity-check__field">
<mat-label>{{ isEntity ? 'Company PAN' : 'PAN' }}</mat-label>
<input
matInput
formControlName="pan"
class="identity-check__upper"
maxlength="10"
mifosxRestrictInput="pan"
[errorStateMatcher]="errorMatcher"
placeholder="e.g. ABCDE1234F"
(input)="capitalise('pan', $event)"
/>
@if (form.get('pan')?.hasError('pattern')) {
<mat-error>Invalid PAN. Use 5 letters, 4 digits, 1 letter, like ABCDE1234F</mat-error>
}
</mat-form-field>
@if (!isEntity) {
<mat-form-field appearance="outline" class="identity-check__field">
<mat-label>Aadhaar</mat-label>
<input
matInput
formControlName="aadhaar"
maxlength="12"
inputmode="numeric"
mifosxRestrictInput="digits"
[errorStateMatcher]="errorMatcher"
placeholder="12 digits"
/>
@if (form.get('aadhaar')?.hasError('pattern')) {
<mat-error>Invalid Aadhaar. It must be exactly 12 digits</mat-error>
} @else {
<mat-hint>Only a fingerprint of this number is sent, never the number</mat-hint>
}
</mat-form-field>
}
</form>
}
@switch (status) {
@case ('checking') {
<div class="identity-check__status identity-check__status--info">
<mat-progress-spinner mode="indeterminate" diameter="18"></mat-progress-spinner>
Checking all branches…
</div>
}
@case ('not-found') {
<div class="identity-check__status identity-check__status--ok">
<mat-icon>check_circle</mat-icon>
<span>
No existing customer found. You can continue; these details will be filled into the KYC step so you do not
type them again.
</span>
</div>
}
@case ('error') {
<div class="identity-check__status identity-check__status--warn">
<mat-icon>warning</mat-icon>
<span>
Could not check right now. You can continue; the PAN, Aadhaar and GSTIN are checked again when the KYC is
saved.
</span>
<button mat-button type="button" (click)="recheck()">Check again</button>
</div>
}
@case ('conflict') {
<div class="identity-check__status identity-check__status--error">
<mat-icon>report</mat-icon>
<span>
These details belong to <strong>different customers</strong>. Check what was typed. If they are correct, ask a
manager to review before continuing.
</span>
</div>
@for (m of matches; track m.clientId) {
<div class="identity-check__card">
<strong>{{ m.displayName }}</strong>
<span class="identity-check__meta">CIF {{ m.cifNumber }} · {{ m.officeName }}</span>
<span class="identity-check__chips">
@for (by of m.matchedBy; track by) {
<span class="identity-check__chip">{{ matchedByLabel(by) }} matches</span>
}
</span>
</div>
}
}
@case ('matched') {
@if (match; as m) {
<div class="identity-check__status identity-check__status--error">
<mat-icon>person_search</mat-icon>
<span> This is an <strong>existing customer</strong>. A new client is not created for the same person. </span>
</div>
<div class="identity-check__card">
<div class="identity-check__card-head">
<strong>{{ m.displayName }}</strong>
<span class="identity-check__chips">
@for (by of m.matchedBy; track by) {
<span class="identity-check__chip">{{ matchedByLabel(by) }} matches</span>
}
</span>
</div>
<dl class="identity-check__facts">
<div>
<dt>CIF</dt>
<dd>{{ m.cifNumber }}</dd>
</div>
<div>
<dt>Home branch</dt>
<dd>{{ m.officeName }}</dd>
</div>
<div>
<dt>Type</dt>
<dd>{{ legalFormLabel(m) }}</dd>
</div>
@if (m.panMasked) {
<div>
<dt>PAN</dt>
<dd>{{ m.panMasked }}</dd>
</div>
}
@if (m.aadhaarMasked) {
<div>
<dt>Aadhaar</dt>
<dd>{{ m.aadhaarMasked }}</dd>
</div>
}
@if (m.gstinMasked) {
<div>
<dt>GSTIN</dt>
<dd>{{ m.gstinMasked }}</dd>
</div>
}
</dl>
@if (m.visibleToCaller) {
<button
mat-flat-button
color="primary"
type="button"
class="identity-check__action"
[routerLink]="['/clients', m.clientId, 'general']"
>
Open this customer
</button>
} @else {
<p class="identity-check__note">
This customer is registered at <strong>{{ m.officeName }}</strong
>. They stay a single customer (one CIF). Open their page, then choose which account to open for them at
this branch. They are listed under this branch once an account is opened here.
</p>
<button
mat-flat-button
color="primary"
type="button"
class="identity-check__action"
[disabled]="linking"
(click)="openAccountHere(m)"
>
{{ linking ? 'Please wait…' : 'Open account for this customer' }}
</button>
}
</div>
}
}
}
</section>
@@ -0,0 +1,157 @@
/**
* 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;
}
.identity-check {
margin-bottom: 16px;
padding: 16px 20px;
background: #f9fafb;
border: 1px solid #e5e7eb;
border-radius: 12px;
&--blocking {
border-color: #fecaca;
background: #fffafa;
}
&__header {
display: flex;
gap: 12px;
align-items: flex-start;
margin-bottom: 12px;
}
&__icon {
flex: none;
color: var(--md-sys-color-primary, #1074b9);
}
&__title {
margin: 0;
font-size: 15px;
font-weight: 600;
}
&__hint {
margin: 2px 0 0;
font-size: 13px;
color: #6b7280;
}
// PAN and GSTIN read in capitals even before the value is rewritten.
&__upper {
text-transform: uppercase;
}
&__fields {
display: flex;
flex-wrap: wrap;
gap: 0 16px;
}
&__field {
flex: 1 1 220px;
min-width: 200px;
}
&__status {
display: flex;
gap: 10px;
align-items: center;
padding: 10px 12px;
font-size: 13px;
border-radius: 8px;
&--info {
background: #eff6ff;
color: #1e40af;
}
&--ok {
background: #ecfdf5;
color: #047857;
}
&--warn {
background: #fffbeb;
color: #92400e;
}
&--error {
background: #fef2f2;
color: #991b1b;
}
}
&__card {
margin-top: 10px;
padding: 12px 14px;
background: #fff;
border: 1px solid #fecaca;
border-radius: 10px;
}
&__card-head {
display: flex;
flex-wrap: wrap;
gap: 8px 12px;
align-items: center;
justify-content: space-between;
}
&__meta {
margin-left: 8px;
font-size: 13px;
color: #6b7280;
}
&__chips {
display: inline-flex;
flex-wrap: wrap;
gap: 6px;
}
&__chip {
padding: 2px 8px;
font-size: 12px;
color: #991b1b;
background: #fee2e2;
border-radius: 999px;
}
&__facts {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(140px, 1fr));
gap: 8px 16px;
margin: 10px 0 0;
dt {
font-size: 12px;
color: #6b7280;
}
dd {
margin: 0;
font-size: 14px;
font-weight: 500;
}
}
&__action {
margin-top: 12px;
}
&__note {
margin: 12px 0 0;
font-size: 13px;
color: #374151;
}
}
@@ -0,0 +1,308 @@
/**
* 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,
EventEmitter,
Input,
OnChanges,
OnDestroy,
OnInit,
Output,
SimpleChanges,
inject
} from '@angular/core';
import {
AbstractControl,
FormGroupDirective,
NgForm,
UntypedFormBuilder,
UntypedFormGroup,
Validators
} from '@angular/forms';
import { ErrorStateMatcher } from '@angular/material/core';
import { Router } from '@angular/router';
import { MatIcon } from '@angular/material/icon';
import { MatProgressSpinner } from '@angular/material/progress-spinner';
import { Observable, Subject, merge, of } from 'rxjs';
import { catchError, debounceTime, map, switchMap, takeUntil, tap } from 'rxjs/operators';
import { STANDALONE_SHARED_IMPORTS } from 'app/standalone-shared.module';
import { AuthenticationService } from 'app/core/authentication/authentication.service';
import { RestrictInputDirective } from '../../../directives/restrict-input.directive';
import { ClientsService } from '../../clients.service';
import { ClientIdentityLookupResult, ClientIdentityMatch } from '../../models/client-identity.model';
import { aadhaarHash } from '../../utils/aadhaar-hash.util';
export type ClientIdentityStatus = 'idle' | 'checking' | 'not-found' | 'matched' | 'conflict' | 'error';
/**
* What the parent needs from the identity check: whether creation must stop, and the identifiers the officer entered
* so the KYC step can be filled in without typing them again.
*/
export interface ClientIdentityState {
status: ClientIdentityStatus;
/** Individual: the person's PAN. Entity: the company's PAN. */
pan: string | null;
/** The 12 digits, held in memory only to pre-fill the KYC step. Never sent: only its hash is. */
aadhaar: string | null;
gstin: string | null;
/** True when the customer already exists (or the identifiers disagree), so a new client must not be created. */
blocking: boolean;
}
/**
* Shows a field's error as soon as something has been typed in it, not only after the field is left: a PAN that is
* wrong should say so while it is being typed, and the form should not look fine in the meantime.
*/
class ShowErrorWhenDirty implements ErrorStateMatcher {
isErrorState(control: AbstractControl | null, _form: FormGroupDirective | NgForm | null): boolean {
return !!control && control.invalid && (control.dirty || control.touched);
}
}
const PAN_PATTERN = /^[A-Za-z]{5}\d{4}[A-Za-z]$/;
const AADHAAR_PATTERN = /^\d{12}$/;
const GSTIN_PATTERN = /^\d{2}[A-Za-z]{5}\d{4}[A-Za-z][1-9A-Za-z]Z[0-9A-Za-z]$/;
/**
* "Is this person already a customer of the bank?" — asked at the top of the General step, before anything is saved.
*
* Any ONE of PAN / Aadhaar (individual) or PAN / GSTIN (entity) matching an existing customer, in any branch, means
* the same customer, so a second client must not be created. When nothing matches, what was entered is carried to the
* KYC step so it does not have to be typed again.
*/
@Component({
selector: 'mifosx-client-identity-check',
templateUrl: './client-identity-check.component.html',
styleUrls: ['./client-identity-check.component.scss'],
imports: [
...STANDALONE_SHARED_IMPORTS,
MatIcon,
MatProgressSpinner,
RestrictInputDirective
]
})
export class ClientIdentityCheckComponent implements OnInit, OnChanges, OnDestroy {
private formBuilder = inject(UntypedFormBuilder);
private clientsService = inject(ClientsService);
private router = inject(Router);
private authenticationService = inject(AuthenticationService);
/** 1 = individual, 2 = entity (non-individual). */
@Input() legalFormType = 1;
@Output() identityChange = new EventEmitter<ClientIdentityState>();
form: UntypedFormGroup;
readonly errorMatcher = new ShowErrorWhenDirty();
status: ClientIdentityStatus = 'idle';
matches: ClientIdentityMatch[] = [];
/** True while the customer is being introduced to this branch. */
linking = false;
private destroy$ = new Subject<void>();
private recheck$ = new Subject<void>();
get isEntity(): boolean {
return this.legalFormType === 2;
}
/** The first (and normally only) existing customer found. */
get match(): ClientIdentityMatch | null {
return this.matches[0] ?? null;
}
/**
* True while a field holds something that is not a valid PAN / Aadhaar / GSTIN. Such a value is never looked up or
* carried to the KYC step, so the parent must not let the officer carry on past it as if the check had passed.
*/
get hasInvalidInput(): boolean {
return !!this.form?.invalid;
}
markAllAsTouched(): void {
this.form?.markAllAsTouched();
}
get state(): ClientIdentityState {
const v = this.normalized();
return {
status: this.status,
pan: v.pan,
aadhaar: v.aadhaar,
gstin: v.gstin,
blocking: this.status === 'matched' || this.status === 'conflict'
};
}
ngOnInit(): void {
this.form = this.formBuilder.group({
pan: [
'',
Validators.pattern(PAN_PATTERN)
],
aadhaar: [
'',
Validators.pattern(AADHAAR_PATTERN)
],
gstin: [
'',
Validators.pattern(GSTIN_PATTERN)
]
});
// A pause in typing (or a manual re-check) runs one lookup; typing again cancels the one in flight.
merge(this.form.valueChanges, this.recheck$)
.pipe(
debounceTime(400),
tap(() => this.startChecking()),
switchMap(() => this.lookup()),
takeUntil(this.destroy$)
)
.subscribe((result) => this.applyResult(result));
}
ngOnChanges(changes: SimpleChanges): void {
// Switching individual <-> entity swaps which identifiers apply; what was typed for the other kind is stale.
if (changes['legalFormType'] && !changes['legalFormType'].firstChange) {
this.reset();
}
}
ngOnDestroy(): void {
this.destroy$.next();
this.destroy$.complete();
}
/** Clears the fields and the result, e.g. when a new client creation session starts. */
reset(): void {
this.form?.reset({ pan: '', aadhaar: '', gstin: '' }, { emitEvent: false });
this.status = 'idle';
this.matches = [];
this.identityChange.emit(this.state);
}
recheck(): void {
this.recheck$.next();
}
/**
* PAN and GSTIN are always written in capitals, so what is typed is capitalised as it is typed. The caret is put
* back where it was, otherwise editing the middle of a PAN would throw it to the end. The shared restrict-input
* directive only capitalises when the field is left, which let lower-case show while typing.
*/
capitalise(controlName: 'pan' | 'gstin', event: Event): void {
const input = event.target as HTMLInputElement;
const upper = input.value.toUpperCase();
if (upper !== input.value) {
const { selectionStart, selectionEnd } = input;
input.value = upper;
input.setSelectionRange(selectionStart, selectionEnd);
}
this.form.get(controlName)?.setValue(upper, { emitEvent: false });
}
/** Identifiers that are complete and well formed; anything half-typed is ignored rather than looked up. */
private normalized(): { pan: string | null; aadhaar: string | null; gstin: string | null } {
const raw = this.form?.getRawValue() ?? { pan: '', aadhaar: '', gstin: '' };
const pan = String(raw.pan ?? '')
.replace(/\s+/g, '')
.toUpperCase();
const aadhaar = String(raw.aadhaar ?? '').replace(/[\s-]/g, '');
const gstin = String(raw.gstin ?? '')
.replace(/\s+/g, '')
.toUpperCase();
return {
pan: PAN_PATTERN.test(pan) ? pan : null,
aadhaar: !this.isEntity && AADHAAR_PATTERN.test(aadhaar) ? aadhaar : null,
gstin: this.isEntity && GSTIN_PATTERN.test(gstin) ? gstin : null
};
}
private startChecking(): void {
const v = this.normalized();
if (!v.pan && !v.aadhaar && !v.gstin) {
this.status = 'idle';
this.matches = [];
this.identityChange.emit(this.state);
return;
}
this.status = 'checking';
this.matches = [];
this.identityChange.emit(this.state);
}
private lookup(): Observable<ClientIdentityLookupResult | 'error' | null> {
const v = this.normalized();
if (!v.pan && !v.aadhaar && !v.gstin) {
return of(null);
}
return this.clientsService
.lookupClientIdentity({ panNumber: v.pan, aadhaarHash: aadhaarHash(v.aadhaar), gstinNumber: v.gstin })
.pipe(
map((result): ClientIdentityLookupResult | 'error' => result),
catchError(() => of('error' as const))
);
}
private applyResult(result: ClientIdentityLookupResult | 'error' | null): void {
if (result === null) {
// Nothing complete to look up; startChecking() already reset to idle.
return;
}
if (result === 'error') {
// Not blocking: the server checks again when the KYC is saved, so a failed lookup must not stop onboarding.
this.status = 'error';
this.matches = [];
} else if (result.conflict) {
this.status = 'conflict';
this.matches = result.matches;
} else if (result.matched) {
this.status = 'matched';
this.matches = result.matches;
} else {
this.status = 'not-found';
this.matches = [];
}
this.identityChange.emit(this.state);
}
/**
* Starts opening an account at THIS branch for a customer registered at another one. The customer stays at the home
* branch (one CIF). This branch is introduced to them, which only lets it open their page; they are listed under
* this branch once an account is actually opened here. An account opened for a customer of another branch is held
* at the branch that opens it.
*/
openAccountHere(match: ClientIdentityMatch): void {
const officeId = this.authenticationService.getCredentials()?.officeId;
if (this.linking || officeId == null) {
return;
}
this.linking = true;
this.clientsService.linkClientToMyBranch(match.clientId).subscribe({
next: () => {
this.linking = false;
this.router.navigate([
'/clients',
match.clientId,
'general'
]);
},
// The failure is shown by the global error handler.
error: () => (this.linking = false)
});
}
/** Legal form of the existing customer, for the card. */
legalFormLabel(match: ClientIdentityMatch): string {
return match.legalFormEnum === 2 ? 'Non-Individual' : 'Individual';
}
matchedByLabel(by: string): string {
return by === 'AADHAAR' ? 'Aadhaar' : by;
}
}
@@ -43,6 +43,8 @@ import { KycUploadHelperService } from '../../shared/kyc-upload-helper.service';
import { CaptureImageDialogComponent } from '../../clients-view/custom-dialogs/capture-image-dialog/capture-image-dialog.component';
import { DrawSignatureDialogComponent } from '../../clients-view/custom-dialogs/draw-signature-dialog/draw-signature-dialog.component';
import { dataUrlToFile } from 'app/shared/utils/data-url.util';
import { aadhaarHash } from '../../utils/aadhaar-hash.util';
import { ClientIdentityState } from '../client-identity-check/client-identity-check.component';
import { DocumentPreviewDialogComponent } from '../../shared/document-preview-dialog/document-preview-dialog.component';
import {
PanDeclarationDialogComponent,
@@ -108,6 +110,32 @@ export class ClientKycStepComponent implements OnInit, OnChanges, OnDestroy {
@Input() verificationDateOfBirth: Date | null = null;
/** Read-only mode — disables form editing until user clicks edit icon */
@Input() readOnly = false;
/**
* The identifiers the officer already typed into the existing-customer check on the General step. They are filled
* in here so they are not typed twice, but only into fields that are still empty: whatever was entered on this
* step wins. Ignored while the check says the customer already exists, since no client is created then.
*/
@Input() set identityPrefill(identity: ClientIdentityState | null) {
if (!identity || identity.blocking) {
return;
}
const isEmpty = (name: string) => !String(this.kycForm?.get(name)?.value ?? '').trim();
const patch: Record<string, unknown> = {};
if (this.isNonIndividual) {
if (identity.gstin && isEmpty('gstinNumber')) patch['gstinNumber'] = identity.gstin;
if (identity.pan && isEmpty('enterprisePan')) patch['enterprisePan'] = identity.pan;
} else {
if (identity.pan && isEmpty('panNumber')) {
patch['panNumber'] = identity.pan;
patch['hasPan'] = true;
}
if (identity.aadhaar && isEmpty('aadhaarNumber')) patch['aadhaarNumber'] = identity.aadhaar;
}
if (Object.keys(patch).length) {
this.kycForm.patchValue(patch);
}
}
@Input() calculatedRisk: {
category: number;
reason: string;
@@ -1756,7 +1784,8 @@ export class ClientKycStepComponent implements OnInit, OnChanges, OnDestroy {
kycType: this.kycTypeValue,
riskCategory: this.currentRiskCategory,
panNumber: pan,
aadhaarNumberMasked: aadhaarRaw.length === 12 ? 'XXXXXXXX' + aadhaarRaw.slice(-4) : 'XXXXXXXX0000'
aadhaarNumberMasked: aadhaarRaw.length === 12 ? 'XXXXXXXX' + aadhaarRaw.slice(-4) : 'XXXXXXXX0000',
...this.aadhaarHashField(aadhaarRaw)
};
// A proprietorship reaches this method (it verifies the proprietor's individual PAN) but is
@@ -2358,7 +2387,8 @@ export class ClientKycStepComponent implements OnInit, OnChanges, OnDestroy {
locale: 'en',
kycType: this.kycTypeValue,
riskCategory: this.currentRiskCategory,
aadhaarNumberMasked: aadhaarMasked
aadhaarNumberMasked: aadhaarMasked,
...this.aadhaarHashField(aadhaar)
};
const panVal = this.kycForm.value.panNumber;
if (panVal) {
@@ -2679,6 +2709,15 @@ export class ClientKycStepComponent implements OnInit, OnChanges, OnDestroy {
}
}
/**
* `{ aadhaarHash }` for a full 12-digit Aadhaar, `{}` otherwise. The number itself is never sent: KYC calls are
* recorded server-side, so only its SHA-256 goes.
*/
private aadhaarHashField(aadhaar: string | null | undefined): { aadhaarHash?: string } {
const hash = aadhaarHash(aadhaar);
return hash ? { aadhaarHash: hash } : {};
}
get kycData(): any {
const formVal = this.kycForm.getRawValue();
const aadhaarRaw = formVal.aadhaarNumber || '';
@@ -2707,6 +2746,9 @@ export class ClientKycStepComponent implements OnInit, OnChanges, OnDestroy {
// For non-individual: Company PAN is used as panNumber for verification
panNumber: this.isNonIndividual ? enterprisePanNorm : v.hasPan ? panNorm : null,
aadhaarNumberMasked: this.isNonIndividual ? 'XXXXXXXX0000' : aadhaarMasked,
// The fingerprint of the full number, so the server can tell two Aadhaar numbers apart (the masked form keeps
// only the last four digits). Absent when the field holds a masked value reloaded from the server.
...(this.isNonIndividual ? {} : this.aadhaarHashField(aadhaarRaw)),
// Only ever sent when this screen actually holds a value. The seeding status is derived by
// the provider during PAN verification and written straight to the record, so sending an
// empty one back would blank a field the server filled in — which reads as an officer
@@ -90,6 +90,21 @@
<mifosx-long-text textValue="{{ element.productName }}" chars="35"></mifosx-long-text>
</td>
</ng-container>
<ng-container matColumnDef="Branch">
<th mat-header-cell *matHeaderCellDef>{{ 'labels.inputs.Branch' | translate }}</th>
<td mat-cell *matCellDef="let element">
@if (element.officeName) {
<span [class]="branchChipClass(element)" [title]="branchChipTitle(element)">
<i class="fa fa-map-marker"></i>{{ element.officeName }}
@if (isHomeBranch(element)) {
<span class="branch-chip__home">Home</span>
}
</span>
} @else {
—
}
</td>
</ng-container>
<ng-container matColumnDef="Last Active">
<th mat-header-cell *matHeaderCellDef>{{ 'labels.inputs.Last Active' | translate }}</th>
<td mat-cell *matCellDef="let element">{{ element.lastActiveTransactionDate | dateFormat }}</td>
@@ -203,6 +218,21 @@
<mifosx-long-text textValue="{{ element.productName }}" chars="35"></mifosx-long-text>
</td>
</ng-container>
<ng-container matColumnDef="Branch">
<th mat-header-cell *matHeaderCellDef>{{ 'labels.inputs.Branch' | translate }}</th>
<td mat-cell *matCellDef="let element">
@if (element.officeName) {
<span [class]="branchChipClass(element)" [title]="branchChipTitle(element)">
<i class="fa fa-map-marker"></i>{{ element.officeName }}
@if (isHomeBranch(element)) {
<span class="branch-chip__home">Home</span>
}
</span>
} @else {
—
}
</td>
</ng-container>
<ng-container matColumnDef="Closed Date">
<th mat-header-cell *matHeaderCellDef>{{ 'labels.inputs.Closed Date' | translate }}</th>
<td mat-cell *matCellDef="let element">{{ element.timeline.closedOnDate | dateFormat }}</td>
@@ -262,6 +292,21 @@
<mifosx-long-text textValue="{{ element.productName }}" chars="35"></mifosx-long-text>
</td>
</ng-container>
<ng-container matColumnDef="Branch">
<th mat-header-cell *matHeaderCellDef>{{ 'labels.inputs.Branch' | translate }}</th>
<td mat-cell *matCellDef="let element">
@if (element.officeName) {
<span [class]="branchChipClass(element)" [title]="branchChipTitle(element)">
<i class="fa fa-map-marker"></i>{{ element.officeName }}
@if (isHomeBranch(element)) {
<span class="branch-chip__home">Home</span>
}
</span>
} @else {
—
}
</td>
</ng-container>
<ng-container matColumnDef="Last Active">
<th mat-header-cell *matHeaderCellDef>{{ 'labels.inputs.Last Active' | translate }}</th>
<td mat-cell *matCellDef="let element">{{ element.lastActiveTransactionDate | dateFormat }}</td>
@@ -452,6 +497,21 @@
<th mat-header-cell *matHeaderCellDef>{{ 'labels.inputs.Fixed Deposit Product' | translate }}</th>
<td mat-cell *matCellDef="let element">{{ element.productName }}</td>
</ng-container>
<ng-container matColumnDef="Branch">
<th mat-header-cell *matHeaderCellDef>{{ 'labels.inputs.Branch' | translate }}</th>
<td mat-cell *matCellDef="let element">
@if (element.officeName) {
<span [class]="branchChipClass(element)" [title]="branchChipTitle(element)">
<i class="fa fa-map-marker"></i>{{ element.officeName }}
@if (isHomeBranch(element)) {
<span class="branch-chip__home">Home</span>
}
</span>
} @else {
—
}
</td>
</ng-container>
<ng-container matColumnDef="Last Active">
<th mat-header-cell *matHeaderCellDef>{{ 'labels.inputs.Last Active' | translate }}</th>
<td mat-cell *matCellDef="let element">{{ element.lastActiveTransactionDate | dateFormat }}</td>
@@ -551,6 +611,21 @@
<mifosx-long-text textValue="{{ element.productName }}" chars="35"></mifosx-long-text>
</td>
</ng-container>
<ng-container matColumnDef="Branch">
<th mat-header-cell *matHeaderCellDef>{{ 'labels.inputs.Branch' | translate }}</th>
<td mat-cell *matCellDef="let element">
@if (element.officeName) {
<span [class]="branchChipClass(element)" [title]="branchChipTitle(element)">
<i class="fa fa-map-marker"></i>{{ element.officeName }}
@if (isHomeBranch(element)) {
<span class="branch-chip__home">Home</span>
}
</span>
} @else {
—
}
</td>
</ng-container>
<ng-container matColumnDef="Closed Date">
<th mat-header-cell *matHeaderCellDef>{{ 'labels.inputs.Closed Date' | translate }}</th>
<td mat-cell *matCellDef="let element">{{ element.timeline.closedOnDate | dateFormat }}</td>
@@ -611,10 +686,27 @@
<mifosx-long-text textValue="{{ element.productName }}" chars="35"></mifosx-long-text>
</td>
</ng-container>
<ng-container matColumnDef="Branch">
<th mat-header-cell *matHeaderCellDef>{{ 'labels.inputs.Branch' | translate }}</th>
<td mat-cell *matCellDef="let element">
@if (element.officeName) {
<span [class]="branchChipClass(element)" [title]="branchChipTitle(element)">
<i class="fa fa-map-marker"></i>{{ element.officeName }}
@if (isHomeBranch(element)) {
<span class="branch-chip__home">Home</span>
}
</span>
} @else {
—
}
</td>
</ng-container>
<ng-container matColumnDef="Status">
<th mat-header-cell *matHeaderCellDef>Status</th>
<td mat-cell *matCellDef="let element">
<span class="rd-status" [class.rd-status--ended]="element.timeline?.closedOnDate">{{ rdStatusLabel(element) }}</span>
<span class="rd-status" [class.rd-status--ended]="element.timeline?.closedOnDate">{{
rdStatusLabel(element)
}}</span>
@if (element.timeline?.closedOnDate) {
<div class="rd-status__date">on {{ element.timeline.closedOnDate | dateFormat }}</div>
}
@@ -440,3 +440,66 @@ table[mat-table] {
font-size: 11px;
color: #64748b;
}
/*
* Branch an account is held at, as a pill coloured by where the signed-in user works: green for their own branch, red for
* any other. When the customer banks at several other branches the reds run from light to dark, one shade per branch.
*/
.branch-chip {
display: inline-flex;
align-items: center;
gap: 6px;
padding: 3px 10px;
border-radius: 999px;
border: 1px solid transparent;
font-size: 12px;
font-weight: 600;
white-space: nowrap;
.fa {
font-size: 12px;
}
&--mine {
background: #dcfce7;
color: #166534;
border-color: #4ade80;
}
&--other-0 {
background: #fee2e2;
color: #991b1b;
border-color: #fca5a5;
}
&--other-1 {
background: #fca5a5;
color: #7f1d1d;
border-color: #f87171;
}
&--other-2 {
background: #dc2626;
color: #fff;
border-color: #b91c1c;
}
&--other-3 {
background: #991b1b;
color: #fff;
border-color: #7f1d1d;
}
}
.branch-chip__home {
padding: 0 6px;
border-radius: 8px;
/* fixed colours: the chip text turns white on the dark reds, which would vanish against this light tag */
background: rgb(255 255 255 / 85%);
color: #1f2937;
font-size: 10px;
font-weight: 700;
letter-spacing: 0.04em;
text-transform: uppercase;
}
@@ -39,6 +39,7 @@ import { DateFormatPipe } from '../../../pipes/date-format.pipe';
import { FormatNumberPipe } from '../../../pipes/format-number.pipe';
import { CurrencyPipe } from '@angular/common';
import { STANDALONE_SHARED_IMPORTS } from 'app/standalone-shared.module';
import { AuthenticationService } from '../../../core/authentication/authentication.service';
import { ReportsService } from 'app/reports/reports.service';
import { SettingsService } from 'app/settings/settings.service';
import { Subject } from 'rxjs';
@@ -151,6 +152,7 @@ export class GeneralTabComponent implements OnDestroy {
private router = inject(Router);
private reportsService = inject(ReportsService);
private settingsService = inject(SettingsService);
private authenticationService = inject(AuthenticationService);
/** Open Loan Accounts Columns */
openLoansColumns: string[] = [
@@ -177,6 +179,7 @@ export class GeneralTabComponent implements OnDestroy {
openSavingsColumns: string[] = [
'Account No',
'Saving Account',
'Branch',
'Last Active',
'Balance',
'Actions'
@@ -185,12 +188,14 @@ export class GeneralTabComponent implements OnDestroy {
closedSavingsColumns: string[] = [
'Account No',
'Saving Account',
'Branch',
'Closed Date'
];
/** Closed Recurring Deposit Accounts Columns - with how each one ended, for tracking. */
recurringColumns: string[] = [
'Account No',
'Saving Account',
'Branch',
'Status',
'Last Active',
'Balance',
@@ -336,12 +341,76 @@ export class GeneralTabComponent implements OnDestroy {
/** Client Id */
clientid: any;
/** The customer's own (home) branch, to tell it from branches the customer banks at elsewhere. */
clientOfficeId: number | null = null;
/** How many red shades the branch chips have (general-tab.component.scss, .branch-chip--other-0 ... -3), light to dark. */
private static readonly OTHER_BRANCH_SHADES = 4;
/** The branch the signed-in user works at: accounts held here read green, every other branch red. */
private get myOfficeId(): number | null {
const officeId = this.authenticationService.getCredentials()?.officeId;
return officeId == null ? null : Number(officeId);
}
/**
* The other branches this customer banks at, lightest to darkest. Ordered by branch id so the shades stay put between
* visits; computed over every deposit account on the page so one branch has the same shade in every table.
*/
private otherBranchIds(): number[] {
const mine = this.myOfficeId;
const ids = new Set<number>();
for (const account of [
...this.savingAccounts,
...this.currentAccounts
]) {
const officeId = Number(account?.officeId);
if (Number.isFinite(officeId) && officeId !== mine) {
ids.add(officeId);
}
}
return [...ids].sort((a, b) => a - b);
}
/** Green for an account at the signed-in user's branch; red for any other branch, a different shade for each. */
branchChipClass(account: any): string {
const officeId = Number(account?.officeId);
if (officeId === this.myOfficeId) {
return 'branch-chip branch-chip--mine';
}
const others = this.otherBranchIds();
const rank = Math.max(others.indexOf(officeId), 0);
const last = GeneralTabComponent.OTHER_BRANCH_SHADES - 1;
// one other branch gets a mid red, several are spread from the lightest to the darkest
const shade = others.length <= 1 ? 1 : Math.round((rank * last) / (others.length - 1));
return `branch-chip branch-chip--other-${shade}`;
}
branchChipTitle(account: any): string {
const parts = [account?.officeName];
if (Number(account?.officeId) === this.myOfficeId) {
parts.push('your branch');
}
if (this.isHomeBranch(account)) {
parts.push('customer home branch');
}
return parts.join(' - ');
}
isHomeBranch(account: any): boolean {
return this.clientOfficeId != null && Number(account?.officeId) === this.clientOfficeId;
}
/**
* @param {ActivatedRoute} route Activated Route
* @param {ClientsService} clientService Clients Service
* @param {Router} router Router
*/
constructor() {
// The customer's record is resolved on the parent route, not on this tab's own route data.
this.route.parent?.data.subscribe((data: any) => {
this.clientOfficeId = data?.clientViewData?.officeId ?? null;
});
this.route.data.subscribe(
(data: { clientAccountsData: any; clientChargesData: any; clientSummary: any; clientCollateralData: any }) => {
this.clientAccountData = data.clientAccountsData;
+22
View File
@@ -50,6 +50,17 @@
/>
</mat-form-field>
@if (isHeadOffice) {
<mat-form-field class="table-filter" appearance="outline">
<mat-select [(value)]="officeFilter" (selectionChange)="officeChanged()" placeholder="Branch">
<mat-option [value]="null">All Branches</mat-option>
@for (office of offices; track office.id) {
<mat-option [value]="office.id">{{ office.name }}</mat-option>
}
</mat-select>
</mat-form-field>
}
<mat-form-field class="table-filter" appearance="outline">
<mat-select [(value)]="statusFilter" (selectionChange)="applyClientFilters()" placeholder="Status">
<mat-option value="">All Status</mat-option>
@@ -92,6 +103,11 @@
row.legalForm?.id === 2 ? 'business' : 'person'
}}</mat-icon>
{{ hideClientData ? maskName(row.displayName) : row.displayName }}
@if (!isHeadOffice && row.officeId && myOfficeId !== null && row.officeId !== myOfficeId) {
<span class="row-home-chip" matTooltip="Registered at another branch; linked to this branch">
Home: {{ row.officeName }}
</span>
}
@if (row._ckycDone) {
<mat-icon class="row-ckyc-icon" matTooltip="CKYC Done">verified</mat-icon>
}
@@ -114,6 +130,12 @@
</td>
</ng-container>
<!-- Branch Column (Head Office only) -->
<ng-container matColumnDef="officeName">
<th mat-header-cell *matHeaderCellDef>Branch</th>
<td mat-cell *matCellDef="let row">{{ row.officeName }}</td>
</ng-container>
<!-- Status Column -->
<ng-container matColumnDef="status">
<th mat-header-cell *matHeaderCellDef>{{ 'labels.inputs.Status' | translate }}</th>
+12
View File
@@ -7,3 +7,15 @@
*/
@use '../shared/styles/list-layout' as *;
// A customer registered at another branch that has been linked to this one.
.row-home-chip {
margin-left: 8px;
padding: 1px 8px;
font-size: 11px;
font-weight: 500;
color: #92400e;
background: #fef3c7;
border-radius: 999px;
white-space: nowrap;
}
+27 -1
View File
@@ -38,6 +38,8 @@ import { MatIconButton, MatButton } from '@angular/material/button';
import { MatIcon } from '@angular/material/icon';
import { MatTooltip } from '@angular/material/tooltip';
import { STANDALONE_SHARED_IMPORTS } from 'app/standalone-shared.module';
import { AuthenticationService } from '../core/authentication/authentication.service';
import { OrganizationService } from '../organization/organization.service';
@Component({
selector: 'mifosx-clients',
@@ -71,6 +73,15 @@ import { STANDALONE_SHARED_IMPORTS } from 'app/standalone-shared.module';
})
export class ClientsComponent implements OnInit {
private clientService = inject(ClientsService);
private authenticationService = inject(AuthenticationService);
private organizationService = inject(OrganizationService);
/** Head Office sees every branch's clients, so it gets a Branch filter. A branch user only ever sees its own. */
isHeadOffice = this.authenticationService.isHeadOfficeUser();
offices: { id: number; name: string }[] = [];
officeFilter: number | null = null;
/** The caller's own branch, to tell a customer of another branch (linked here) from this branch's own. */
myOfficeId = this.authenticationService.getCredentials()?.officeId ?? null;
/** Returns true if client data masking is enabled */
get hideClientData(): boolean {
@@ -90,6 +101,7 @@ export class ClientsComponent implements OnInit {
'displayName',
'cifNumber',
'legalForm',
'officeName',
'status',
'profileCompletion',
'actions'
@@ -119,6 +131,13 @@ export class ClientsComponent implements OnInit {
@ViewChild(MatSort) sort: MatSort;
ngOnInit() {
if (!this.isHeadOffice) {
this.displayedColumns = this.displayedColumns.filter((c) => c !== 'officeName');
} else {
this.organizationService.getOffices().subscribe((offices: any[]) => {
this.offices = (offices || []).map((o) => ({ id: o.id, name: o.name }));
});
}
if (environment.preloadClients) {
this.getClients();
}
@@ -144,7 +163,8 @@ export class ClientsComponent implements OnInit {
this.sortDirection,
false,
'',
true
true,
this.isHeadOffice ? this.officeFilter : null
)
.subscribe(
(data: any) => {
@@ -233,6 +253,12 @@ export class ClientsComponent implements OnInit {
this.dataSource.data = filtered;
}
/** Branch filter is applied by the server so paging and the record count stay correct. */
officeChanged() {
this.resetPaginator();
this.getClients();
}
pageChanged(event: PageEvent) {
this.pageSize = event.pageSize;
this.currentPage = event.pageIndex;
+187 -3
View File
@@ -18,6 +18,7 @@ import { map, catchError } from 'rxjs/operators';
import { environment } from '../../environments/environment';
import { dataUrlToFile } from 'app/shared/utils/data-url.util';
import { ClientIdentityLookupResult } from './models/client-identity.model';
/**
* Clients service.
@@ -742,9 +743,14 @@ export class ClientsService {
sortDirection: string = '',
activeOnly: boolean = false,
legalFormType: string = '',
silent: boolean = false
silent: boolean = false,
officeId: number | null = null
) {
const innerRequest: any = { text };
// Branch filter (Head Office list). The server still limits results to the caller's office hierarchy.
if (officeId != null) {
innerRequest.officeId = officeId;
}
if (activeOnly) {
innerRequest.activeOnly = true;
}
@@ -868,6 +874,39 @@ export class ClientsService {
return this.http.post(`/clients/${clientId}/kyc`, kycData);
}
/**
* "Is this person already a customer of the bank?" — across every branch, by PAN, Aadhaar fingerprint or GSTIN.
* Any ONE of them matching means the same customer. `contactConflicts` reports a phone/email already used by another
* client, which is never the same customer. Only the Aadhaar's SHA-256 is sent, never the number.
*/
/**
* Introduces a customer of another branch to the caller's branch, so the branch can see them and open an account
* for them there. The customer stays at the home branch. Safe to repeat.
*/
linkClientToMyBranch(
clientId: number
): Observable<{ clientId: number; linked: boolean; officeId: number; officeName: string }> {
return this.http.post<{ clientId: number; linked: boolean; officeId: number; officeName: string }>(
'/clientidentity/link',
{
clientId
}
);
}
lookupClientIdentity(identity: {
panNumber?: string | null;
aadhaarHash?: string | null;
gstinNumber?: string | null;
mobileNo?: string | null;
emailAddress?: string | null;
excludeClientId?: number | null;
}): Observable<ClientIdentityLookupResult> {
return this.http.post<ClientIdentityLookupResult>('/clientidentity/lookup', identity, {
context: new HttpContext().set(SILENT_HTTP_ERROR, true)
});
}
/**
* Screen a client name against the UN Security Council consolidated sanctions list.
* @param name The name to screen
@@ -1073,9 +1112,146 @@ export class ClientsService {
*/
saveKycContactLog(
clientId: string | number,
data: { channel: string; notes: string; officerName?: string }
data: { channel: string; notes: string; officerName?: string; noticeType?: string },
silent = false
): Observable<any> {
return this.http.post(`/kyc/exceptions/${clientId}/contact-log`, data);
return this.http.post(
`/kyc/exceptions/${clientId}/contact-log`,
data,
silent ? { context: new HttpContext().set(SILENT_HTTP_ERROR, true) } : {}
);
}
/**
* The subject and text a customer would receive for the stage they are at today.
* `type` is KYC or DORMANCY; the body comes back empty when they are at no such stage.
*/
previewKycNotice(clientId: string | number, type: string): Observable<{ subject?: string; body?: string }> {
return this.http.get<{ subject?: string; body?: string }>(
`/kyc/exceptions/${clientId}/contact-log/preview?type=${type}`,
{
context: new HttpContext().set(SILENT_HTTP_ERROR, true)
}
);
}
/** Queues notices to be sent in the background by the queue job. */
enqueueKycNotices(body: {
items: { clientId: number; noticeType: string; channel: string; accountNo?: string; stageLabel?: string }[];
notes?: string;
officerName?: string;
sendAt?: string;
}): Observable<{ queued: number; skipped: number; unreachable: number }> {
return this.http.post<{ queued: number; skipped: number; unreachable: number }>('/kyc/notice-queue', body);
}
/** Queued notices, newest first; status narrows to PENDING, SENT, FAILED or CANCELLED. */
getKycNoticeQueue(status = ''): Observable<any[]> {
return this.http.get<any[]>(`/kyc/notice-queue${status ? `?status=${status}` : ''}`, {
context: new HttpContext().set(SILENT_HTTP_ERROR, true)
});
}
actOnKycNotice(id: number, command: 'cancel' | 'retry'): Observable<any> {
return this.http.post(`/kyc/notice-queue/${id}?command=${command}`, {});
}
/** What each of these customers has been sent so far: the latest notice, how many, and which channels are done. */
getKycContactSummary(clientIds: number[]): Observable<
{
clientId: number;
count: number;
lastAt: string;
lastChannel: string;
lastStatus: string;
doneChannels: string[];
balance?: number;
hasMobile?: boolean;
hasEmail?: boolean;
mobileMasked?: string | null;
emailMasked?: string | null;
}[]
> {
return this.http.get<
{
clientId: number;
count: number;
lastAt: string;
lastChannel: string;
lastStatus: string;
doneChannels: string[];
hasMobile?: boolean;
hasEmail?: boolean;
mobileMasked?: string | null;
emailMasked?: string | null;
}[]
>('/kyc/contact-audit/clients', {
params: new HttpParams().set('ids', clientIds.join(',')),
context: new HttpContext().set(SILENT_HTTP_ERROR, true)
});
}
/** Notices per customer (counts, outcomes, the latest), most recently contacted first, a page at a time. */
getKycContactAuditCustomers(
filter: {
from?: string;
to?: string;
channel?: string;
status?: string;
search?: string;
real?: boolean;
offset?: number;
limit?: number;
} = {}
): Observable<{ total: number; rows: any[] }> {
return this.http.get<{ total: number; rows: any[] }>('/kyc/contact-audit/customers', {
params: this.auditParams(filter),
context: new HttpContext().set(SILENT_HTTP_ERROR, true)
});
}
/** Notices per day (customers, notices, outcomes), newest day first. */
getKycContactAuditDays(
filter: { from?: string; to?: string; channel?: string; status?: string; search?: string; real?: boolean } = {}
): Observable<any[]> {
return this.http.get<any[]>('/kyc/contact-audit/days', {
params: this.auditParams(filter),
context: new HttpContext().set(SILENT_HTTP_ERROR, true)
});
}
/** Customer notices on record, newest first, a page at a time. */
getKycContactAudit(
filter: {
from?: string;
to?: string;
channel?: string;
status?: string;
search?: string;
real?: boolean;
offset?: number;
limit?: number;
} = {}
): Observable<{ total: number; rows: any[] }> {
return this.http.get<{ total: number; rows: any[] }>('/kyc/contact-audit', {
params: this.auditParams(filter),
context: new HttpContext().set(SILENT_HTTP_ERROR, true)
});
}
private auditParams(filter: Record<string, string | number | boolean | undefined>): HttpParams {
let params = new HttpParams();
Object.entries(filter).forEach(
([
k,
v
]) => {
if (v !== undefined && v !== null && v !== '') {
params = params.set(k, String(v));
}
}
);
return params;
}
getKycContactLog(clientId: string | number): Observable<any[]> {
@@ -1092,6 +1268,14 @@ export class ClientsService {
return this.http.get<any[]>('/kyc/exceptions/lapsed');
}
/** Clients whose re-KYC falls due within the next `days` days (default 30), soonest first. */
getExpiringKyc(days = 30): Observable<any[]> {
// The caller reports a failure in its own banner, so the global popup is suppressed.
return this.http.get<any[]>(`/kyc/exceptions/expiring?days=${days}`, {
context: new HttpContext().set(SILENT_HTTP_ERROR, true)
});
}
/**
* Savings accounts the dormancy job has marked.
*
@@ -38,6 +38,12 @@
}
</div>
}
@if (!createdClientId) {
<mifosx-client-identity-check
[legalFormType]="legalFormType"
(identityChange)="onIdentityChange($event)"
></mifosx-client-identity-check>
}
<mifosx-client-general-step
[clientTemplate]="clientTemplate"
[prefillData]="prefillData"
@@ -79,6 +85,7 @@
[verificationDateOfBirth]="clientGeneralStep?.verificationDateOfBirth || null"
[readOnly]="isTabReadOnly(1)"
[calculatedRisk]="clientGeneralStep?.calculatedRisk"
[identityPrefill]="identity"
></mifosx-client-kyc-step>
</div>
@@ -23,6 +23,10 @@ import { ClientIndiaAddressStepComponent } from '../client-stepper/client-india-
import { ClientEntityKycStepComponent } from '../client-stepper/client-entity-kyc-step/client-entity-kyc-step.component';
import { ClientDatatableStepComponent } from '../client-stepper/client-datatable-step/client-datatable-step.component';
import { ClientKycStepComponent } from '../client-stepper/client-kyc-step/client-kyc-step.component';
import {
ClientIdentityCheckComponent,
ClientIdentityState
} from '../client-stepper/client-identity-check/client-identity-check.component';
import { ClientPreviewStepComponent } from '../client-stepper/client-preview-step/client-preview-step.component';
import { ClientFamilyMembersStepComponent } from '../client-stepper/client-family-members-step/client-family-members-step.component';
@@ -60,6 +64,7 @@ import { STANDALONE_SHARED_IMPORTS } from 'app/standalone-shared.module';
ClientEntityKycStepComponent,
ClientDatatableStepComponent,
ClientKycStepComponent,
ClientIdentityCheckComponent,
ClientPreviewStepComponent,
ClientFamilyMembersStepComponent,
MatProgressBarModule,
@@ -79,6 +84,7 @@ export class CreateClientComponent {
@ViewChild('clientIndiaAddress') clientIndiaAddressStep: ClientIndiaAddressStepComponent;
@ViewChild(ClientEntityKycStepComponent) clientEntityKycStep: ClientEntityKycStepComponent;
@ViewChild('clientKyc') clientKycStep: ClientKycStepComponent;
@ViewChild(ClientIdentityCheckComponent) identityCheck: ClientIdentityCheckComponent;
@ViewChild(ClientFamilyMembersStepComponent) clientFamilyMembersStep: ClientFamilyMembersStepComponent;
@ViewChildren('dtclient') clientDatatables: QueryList<ClientDatatableStepComponent>;
@@ -87,6 +93,17 @@ export class CreateClientComponent {
selectedTabIndex = 0;
createdClientId = '';
savingClient = false;
/**
* Result of the existing-customer check at the top of the General step. While it says the customer already exists
* (or the identifiers disagree) a new client must not be created; otherwise the identifiers typed there are filled
* into the KYC step.
*/
identity: ClientIdentityState | null = null;
onIdentityChange(state: ClientIdentityState): void {
this.identity = state;
}
/** Pre-fill data passed from "Create as Client" on a manual family member */
prefillData: any = null;
@@ -138,6 +155,8 @@ export class CreateClientComponent {
this.highestTabReached = 0;
this.validationError = '';
this.prefillData = null;
this.identity = null;
this.identityCheck?.reset();
this.clientGeneralStep?.resetForNewClient();
this.clientKycStep?.resetForNewClient();
this.clientIndiaAddressStep?.resetForNewClient();
@@ -212,6 +231,10 @@ export class CreateClientComponent {
const idx = this.selectedTabIndex;
if (idx === 0) {
// The customer is already on the books (any branch), or the identifiers belong to different customers.
if (!this.createdClientId && (this.identity?.blocking || this.identity?.status === 'checking')) return false;
// A PAN / Aadhaar / GSTIN typed in the wrong format is neither looked up nor carried on, so it must be fixed or cleared.
if (!this.createdClientId && this.identityCheck?.hasInvalidInput) return false;
if (!this.clientGeneralStep || !this.clientGeneralForm) return false;
if (this.clientGeneralStep.sanctionsBlocked) return false;
// A number the officer rejected on the DIP result must be replaced, not carried forward.
@@ -274,6 +297,17 @@ export class CreateClientComponent {
if (this.savingClient) return 'Saving — please wait…';
if (this.currentStepValid) return '';
const idx = this.selectedTabIndex;
if (idx === 0 && !this.createdClientId && this.identityCheck?.hasInvalidInput) {
return 'Correct the PAN / Aadhaar / GSTIN format in the Existing customer check, or clear the field.';
}
if (idx === 0 && !this.createdClientId && this.identity?.status === 'checking') {
return 'Checking whether this customer already exists…';
}
if (idx === 0 && !this.createdClientId && this.identity?.blocking) {
return this.identity.status === 'conflict'
? 'The PAN / Aadhaar / GSTIN entered belong to different customers — correct them or ask a manager.'
: 'This customer already exists — a new client is not created for the same person. Open the existing customer instead.';
}
if (idx === 0 && this.clientGeneralStep?.sanctionsBlocked) {
const disposition = this.clientGeneralStep.sanctionsDisposition;
if (disposition === 'FALSE_POSITIVE') {
@@ -358,6 +392,19 @@ export class CreateClientComponent {
onNext() {
// If on General tab and client not yet created — save first
if (this.selectedTabIndex === 0 && !this.createdClientId) {
// Defensive re-check: the button is disabled while this holds, but a second client for the same person is the
// one mistake this screen exists to prevent.
if (this.identityCheck?.hasInvalidInput) {
this.identityCheck.markAllAsTouched();
this.snackBar.open(this.currentStepBlockerMessage, 'Close', { duration: 6000 });
return;
}
if (this.identity?.blocking) {
this.snackBar.open(this.currentStepBlockerMessage || 'This customer already exists.', 'Close', {
duration: 6000
});
return;
}
if (!this.clientGeneralForm?.valid) {
this.clientGeneralForm?.markAllAsTouched();
setTimeout(() => {
@@ -0,0 +1,34 @@
/**
* 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/.
*/
/** An existing customer found by PAN / Aadhaar fingerprint / GSTIN. Identifiers are masked by the server. */
export interface ClientIdentityMatch {
clientId: number;
displayName: string;
cifNumber: string;
officeId: number;
officeName: string;
legalFormEnum: number | null;
kycStatus: number;
/** True when the caller's branch can open this client; false when it sits in another branch. */
visibleToCaller: boolean;
panMasked: string | null;
aadhaarMasked: string | null;
gstinMasked: string | null;
matchedBy: Array<'PAN' | 'AADHAAR' | 'GSTIN'>;
}
export interface ClientIdentityLookupResult {
/** Any ONE of PAN / Aadhaar / GSTIN matched an existing customer: the same person or entity. */
matched: boolean;
/** The identifiers point at different customers: bad data or fraud, not a customer to reuse. */
conflict: boolean;
matches: ClientIdentityMatch[];
/** A phone or email already used by another client. Never means "the same customer". */
contactConflicts: { mobileNo: boolean; emailAddress: boolean };
}
@@ -0,0 +1,52 @@
/**
* 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 { createHash } from 'crypto';
import { aadhaarHash, sha256Hex } from './aadhaar-hash.util';
describe('sha256Hex', () => {
it('matches the published SHA-256 test vectors', () => {
expect(sha256Hex('')).toBe('e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855');
expect(sha256Hex('abc')).toBe('ba7816bf8f01cfea414140de5dae2223b00361a396177a9cb410ff61f20015ad');
expect(sha256Hex('abcdbcdecdefdefgefghfghighijhijkijkljklmklmnlmnomnopnopq')).toBe(
'248d6a61d20638b8e5c026930c3e6039a33ce45964ff2167f6ecedd419db06c1'
);
});
it('agrees with Node on 12-digit inputs, including ones that need a second block boundary', () => {
for (const value of [
'000000000000',
'123456789012',
'999999999999',
'0'.repeat(55),
'0'.repeat(56),
'0'.repeat(64)
]) {
expect(sha256Hex(value)).toBe(createHash('sha256').update(value).digest('hex'));
}
});
});
describe('aadhaarHash', () => {
it('hashes a 12-digit number', () => {
expect(aadhaarHash('123456789012')).toBe(createHash('sha256').update('123456789012').digest('hex'));
});
it('ignores the spaces and hyphens people type between the groups', () => {
expect(aadhaarHash('1234 5678 9012')).toBe(aadhaarHash('123456789012'));
expect(aadhaarHash('1234-5678-9012')).toBe(aadhaarHash('123456789012'));
});
it('returns null for anything that is not a full number', () => {
expect(aadhaarHash('')).toBeNull();
expect(aadhaarHash(null)).toBeNull();
expect(aadhaarHash('XXXXXXXX1234')).toBeNull();
expect(aadhaarHash('XXXXXXXX0000')).toBeNull();
expect(aadhaarHash('12345678901')).toBeNull();
});
});
+185
View File
@@ -0,0 +1,185 @@
/**
* 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/.
*/
/**
* The comparable fingerprint of an Aadhaar number: SHA-256 of the 12 digits, as 64 lowercase hex characters.
*
* Only this fingerprint is sent to the server. The KYC create/update calls are recorded in an audit table and a log
* on the server, so the 12-digit number must not be in the request.
*
* A plain, unkeyed SHA-256 of 12 digits can be reversed by trying every number, so this is a lookup key and not
* protection against someone who can read the stored value.
*
* Written out in full instead of using `crypto.subtle` because that API is asynchronous and only exists in secure
* contexts, while the callers (a form-value getter, a blur handler) need the answer synchronously and the app may be
* served over plain HTTP.
*/
const K = [
0x428a2f98,
0x71374491,
0xb5c0fbcf,
0xe9b5dba5,
0x3956c25b,
0x59f111f1,
0x923f82a4,
0xab1c5ed5,
0xd807aa98,
0x12835b01,
0x243185be,
0x550c7dc3,
0x72be5d74,
0x80deb1fe,
0x9bdc06a7,
0xc19bf174,
0xe49b69c1,
0xefbe4786,
0x0fc19dc6,
0x240ca1cc,
0x2de92c6f,
0x4a7484aa,
0x5cb0a9dc,
0x76f988da,
0x983e5152,
0xa831c66d,
0xb00327c8,
0xbf597fc7,
0xc6e00bf3,
0xd5a79147,
0x06ca6351,
0x14292967,
0x27b70a85,
0x2e1b2138,
0x4d2c6dfc,
0x53380d13,
0x650a7354,
0x766a0abb,
0x81c2c92e,
0x92722c85,
0xa2bfe8a1,
0xa81a664b,
0xc24b8b70,
0xc76c51a3,
0xd192e819,
0xd6990624,
0xf40e3585,
0x106aa070,
0x19a4c116,
0x1e376c08,
0x2748774c,
0x34b0bcb5,
0x391c0cb3,
0x4ed8aa4a,
0x5b9cca4f,
0x682e6ff3,
0x748f82ee,
0x78a5636f,
0x84c87814,
0x8cc70208,
0x90befffa,
0xa4506ceb,
0xbef9a3f7,
0xc67178f2
];
function rotr(x: number, n: number): number {
return (x >>> n) | (x << (32 - n));
}
/** SHA-256 of a string (UTF-8 encoded), as lowercase hex. */
export function sha256Hex(text: string): string {
const bytes: number[] = [];
for (const ch of unescape(encodeURIComponent(text))) {
bytes.push(ch.charCodeAt(0));
}
const bitLength = bytes.length * 8;
bytes.push(0x80);
while (bytes.length % 64 !== 56) {
bytes.push(0);
}
// 64-bit big-endian message length; inputs here are tiny so the high word is always 0.
const high = Math.floor(bitLength / 0x100000000);
const low = bitLength >>> 0;
for (const word of [
high,
low
]) {
bytes.push((word >>> 24) & 0xff, (word >>> 16) & 0xff, (word >>> 8) & 0xff, word & 0xff);
}
const h = [
0x6a09e667,
0xbb67ae85,
0x3c6ef372,
0xa54ff53a,
0x510e527f,
0x9b05688c,
0x1f83d9ab,
0x5be0cd19
];
const w = new Array<number>(64);
for (let offset = 0; offset < bytes.length; offset += 64) {
for (let i = 0; i < 16; i++) {
const j = offset + i * 4;
w[i] = (bytes[j] << 24) | (bytes[j + 1] << 16) | (bytes[j + 2] << 8) | bytes[j + 3];
}
for (let i = 16; i < 64; i++) {
const s0 = rotr(w[i - 15], 7) ^ rotr(w[i - 15], 18) ^ (w[i - 15] >>> 3);
const s1 = rotr(w[i - 2], 17) ^ rotr(w[i - 2], 19) ^ (w[i - 2] >>> 10);
w[i] = (w[i - 16] + s0 + w[i - 7] + s1) | 0;
}
let [
a,
b,
c,
d,
e,
f,
g,
hh
] = h;
for (let i = 0; i < 64; i++) {
const S1 = rotr(e, 6) ^ rotr(e, 11) ^ rotr(e, 25);
const ch = (e & f) ^ (~e & g);
const t1 = (hh + S1 + ch + K[i] + w[i]) | 0;
const S0 = rotr(a, 2) ^ rotr(a, 13) ^ rotr(a, 22);
const maj = (a & b) ^ (a & c) ^ (b & c);
const t2 = (S0 + maj) | 0;
hh = g;
g = f;
f = e;
e = (d + t1) | 0;
d = c;
c = b;
b = a;
a = (t1 + t2) | 0;
}
h[0] = (h[0] + a) | 0;
h[1] = (h[1] + b) | 0;
h[2] = (h[2] + c) | 0;
h[3] = (h[3] + d) | 0;
h[4] = (h[4] + e) | 0;
h[5] = (h[5] + f) | 0;
h[6] = (h[6] + g) | 0;
h[7] = (h[7] + hh) | 0;
}
return h.map((x) => (x >>> 0).toString(16).padStart(8, '0')).join('');
}
/**
* The fingerprint for a 12-digit Aadhaar, or null when the value is not one (too short, masked, a placeholder).
* Spaces and hyphens people type between the groups are ignored.
*/
export function aadhaarHash(aadhaar: string | null | undefined): string | null {
const digits = (aadhaar ?? '').replace(/[\s-]/g, '');
return /^\d{12}$/.test(digits) ? sha256Hex(digits) : null;
}
+18 -1
View File
@@ -6,7 +6,7 @@
* file, You can obtain one at http://mozilla.org/MPL/2.0/.
*/
import { toProperNameCase } from './name-case.util';
import { toPersonNameCase, toProperNameCase } from './name-case.util';
describe('toProperNameCase', () => {
it('raises a lower-case first letter on every word', () => {
@@ -53,3 +53,20 @@ describe('toProperNameCase', () => {
expect(toProperNameCase(undefined)).toBe('');
});
});
describe('toPersonNameCase', () => {
it('title-cases every word, lowering mixed or upper case', () => {
expect(toPersonNameCase('keshav SIngh sisodiya')).toBe('Keshav Singh Sisodiya');
expect(toPersonNameCase('KESHAV')).toBe('Keshav');
});
it('keeps hyphen and apostrophe name prefixes', () => {
expect(toPersonNameCase('JEAN-marie')).toBe('Jean-Marie');
expect(toPersonNameCase("D'SOUZA")).toBe("D'Souza");
});
it('returns an empty string for blank or non-string input', () => {
expect(toPersonNameCase(' ')).toBe('');
expect(toPersonNameCase(null)).toBe('');
});
});
+14
View File
@@ -62,3 +62,17 @@ export function toProperNameCase(value: string | null | undefined): string {
}
return collapsed.split(' ').map(capitalizeWord).join(' ');
}
/**
* Title case for an individual's first, middle and last name: each word's first letter raised,
* the rest lowered, so 'SIngh' and 'singh' both become 'Singh'. This is what the Full Name line on
* the general step has always shown; using it for the stored value too keeps the preview and the
* saved client the same as that line. Entity names stay on {@link toProperNameCase} — their
* spelling is matched against the registered PAN name.
*/
export function toPersonNameCase(value: string | null | undefined): string {
if (typeof value !== 'string') {
return '';
}
return toProperNameCase(value.toLowerCase());
}
+17 -4
View File
@@ -35,6 +35,7 @@ export class CacheInterceptor implements HttpInterceptor {
'/rbireservereturn',
'/rbireserveconfig',
'/tdsconfig',
'/profitappropriationconfig',
'/tds/deposit-interest',
// Declarations and challans are entered on the very screens that read them back.
'/tds/declarations',
@@ -53,7 +54,15 @@ export class CacheInterceptor implements HttpInterceptor {
// deposit may be closed at all - a stale copy would show a released lien as still blocking.
'/liens',
// The RD Dashboard counts late installments as of today; a cached copy would hide a payment just taken.
'/recurringdepositaccounts/dashboard'
'/recurringdepositaccounts/dashboard',
// Notes are also written by action screens (approve, close, deposit...), so a cached list would hide them.
'/notes',
// The ALM maturity breakdown counts deposits and loans as of the date asked and reads the bank's
// own bucket edges from the query; a cached copy would show another date's or another setting's figures.
'/almreturn',
// The Approvals badge: a stream that stays open for minutes and a count that moves with every request raised or
// actioned. A copy of either is of no use, and the stream must never be held by anything but its own caller.
'/approvals/counts'
];
private isCacheable(url: string): boolean {
@@ -87,8 +96,9 @@ export class CacheInterceptor implements HttpInterceptor {
// Create new response to avoid side-effects
subscriber.next(new HttpResponse(cachedData as Object));
subscriber.complete();
} else {
next.handle(request).subscribe(
return undefined;
}
const inner = next.handle(request).subscribe(
(event) => {
if (environment.httpCacheEnabled && event instanceof HttpResponse) {
this.httpCacheService.setCacheData(request.urlWithParams, event);
@@ -98,7 +108,10 @@ export class CacheInterceptor implements HttpInterceptor {
(error) => subscriber.error(error),
() => subscriber.complete()
);
}
// The request is cancelled when its caller walks away (a page is left, a screen is closed). The inner request has to
// be cancelled with it: left to run, it kept its connection open. A browser holds about six per site, so a few
// streams that were let go but never closed filled them and every later request waited in the queue.
return () => inner.unsubscribe();
});
}
}
@@ -0,0 +1,16 @@
/**
* 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 { HttpContextToken } from '@angular/common/http';
/**
* Per-request opt-out of the top progress bar. Set via `{ context: new HttpContext().set(NO_PROGRESS_BAR, true) }`
* on background calls the user did not ask for (such as the Approvals badge listening for changes), which would
* otherwise keep the bar running or make it flash on a screen that has not changed.
*/
export const NO_PROGRESS_BAR = new HttpContextToken<boolean>(() => false);
@@ -16,6 +16,7 @@ import { finalize } from 'rxjs/operators';
/** Custom Services */
import { ProgressBarService } from './progress-bar.service';
import { NO_PROGRESS_BAR } from './no-progress.token';
/**
* Http Request interceptor to start/stop loading the progress bar.
@@ -29,6 +30,9 @@ export class ProgressInterceptor implements HttpInterceptor {
* and stop when a response or error is received.
*/
intercept(request: HttpRequest<any>, next: HttpHandler): Observable<HttpEvent<any>> {
if (request.context.get(NO_PROGRESS_BAR)) {
return next.handle(request);
}
this.progressBarService.increase();
return next.handle(request).pipe(
finalize(() => {
+17 -1
View File
@@ -9,6 +9,7 @@
/** Angular Imports */
import { ActivatedRouteSnapshot, DetachedRouteHandle, RouteReuseStrategy } from '@angular/router';
import { Injectable } from '@angular/core';
import { ShellComponent } from '../shell/shell.component';
/**
* A route strategy allowing for explicit route reuse.
@@ -31,7 +32,22 @@ export class RouteReusableStrategy extends RouteReuseStrategy {
return null;
}
/**
* Every feature module registers its own copy of the shell route (Route.withShell), so two pages in different modules
* sit under two different shell routes. Treated as different, a click from one module into another destroyed the
* shell and built a new one: the menu bar vanished until it had loaded again, and the shell's own calls (menu access,
* notifications, business date, the approval stream) went out again on every such click. The shell is the same
* component whichever module it wraps, so it is kept: only the page inside it changes, until a hard refresh.
*/
public shouldReuseRoute(future: ActivatedRouteSnapshot, curr: ActivatedRouteSnapshot): boolean {
return future.routeConfig === curr.routeConfig || future.data.reuse;
return (
future.routeConfig === curr.routeConfig ||
future.data.reuse ||
(RouteReusableStrategy.isShell(future) && RouteReusableStrategy.isShell(curr))
);
}
private static isShell(route: ActivatedRouteSnapshot): boolean {
return route.routeConfig?.component === ShellComponent;
}
}
+40 -37
View File
@@ -131,6 +131,14 @@
</svg>
My Closure Requests
</a>
<a
class="cashier-nav__item cashier-nav__item--sec"
[class.cashier-nav__item--active]="currentUrl.includes('/clerk/voucher-correction')"
[routerLink]="['/clerk/voucher-correction']"
>
<fa-icon icon="undo"></fa-icon>
Voucher Correction
</a>
@if (clerkAccountLoaded) {
<div class="cashier-nav__item-wrap">
<a
@@ -256,13 +264,6 @@
>
<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']"
@@ -270,13 +271,6 @@
>
<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']"
@@ -284,6 +278,13 @@
>
<fa-icon icon="wallet"></fa-icon> Position
</a>
<a
class="cashier-nav__item"
[routerLink]="['/cashier/reconciliation']"
routerLinkActive="cashier-nav__item--active"
>
<fa-icon icon="balance-scale"></fa-icon> Reconciliation
</a>
<a
class="cashier-nav__item"
[routerLink]="['/cashier/cash-history']"
@@ -294,6 +295,13 @@
<a class="cashier-nav__item" [routerLink]="['/cashier/my-work']" routerLinkActive="cashier-nav__item--active">
<fa-icon icon="user-check"></fa-icon> My Vouchers
</a>
<a
class="cashier-nav__item"
[routerLink]="['/cashier/voucher-correction']"
routerLinkActive="cashier-nav__item--active"
>
<fa-icon icon="undo"></fa-icon> Voucher Correction
</a>
<a
class="cashier-nav__item"
[routerLink]="['/cashier/received-report']"
@@ -308,13 +316,6 @@
>
<fa-icon icon="list"></fa-icon> Payment
</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>
}
}
@@ -464,6 +465,11 @@
*mifosxHasPermission="'MENU_TASKS'"
>
<fa-icon icon="check" class="ni-ico"></fa-icon>Approvals
@if (approvalTotal > 0) {
<span class="ni-badge" title="Requests waiting for approval">{{
approvalTotal > 99 ? '99+' : approvalTotal
}}</span>
}
</a>
<a class="ni" tabindex="0" [matMenuTriggerFor]="cashierMenu" *mifosxHasPermission="'MENU_CASHIER'">
<fa-icon icon="money-bill" class="ni-ico"></fa-icon>Cashier
@@ -1094,6 +1100,9 @@
<button mat-menu-item tabindex="0" [routerLink]="['/branch-ops/vault-position']">
<fa-icon icon="lock" class="menu-item-icon"></fa-icon> Vault Position
</button>
<button mat-menu-item tabindex="0" [routerLink]="['/branch-ops/branch-position']">
<fa-icon icon="coins" class="menu-item-icon"></fa-icon> Branch Cash Position
</button>
</div>
<div class="deposit-menu-group">
<div class="deposit-group-title">Verification &amp; Reports</div>
@@ -1166,18 +1175,18 @@
<!-- Cash Entries & Exchange -->
<div class="deposit-menu-group">
<div class="deposit-group-title">Cash &amp; Counter</div>
<button mat-menu-item tabindex="0" [routerLink]="['/cashier/cash-exchange']">
<fa-icon icon="exchange" class="menu-item-icon"></fa-icon> Cash Exchange Entry
</button>
<button mat-menu-item tabindex="0" [routerLink]="['/cashier/exchange-detail']">
<fa-icon icon="globe" class="menu-item-icon"></fa-icon> Currency Exchange Detail
</button>
<!-- Cash Exchange, Currency Exchange Detail, Inter-Cashier Transfer and Reconciliation are not
here: they move or count notes in one cashier's own drawer, so only the cashier who is logged
in does them, from the cashier screen. -->
<button mat-menu-item tabindex="0" [routerLink]="['/cashier/transaction-entry']">
<fa-icon icon="keyboard" class="menu-item-icon"></fa-icon> Transaction Entry
</button>
<button mat-menu-item tabindex="0" [routerLink]="['/system/denomination-master']">
<fa-icon icon="coins" class="menu-item-icon"></fa-icon> Cash Denomination
</button>
<button mat-menu-item tabindex="0" [routerLink]="['/cashier/voucher-correction']">
<fa-icon icon="undo" class="menu-item-icon"></fa-icon> Voucher Correction
</button>
</div>
<!-- Transfers & Settlement -->
@@ -1186,9 +1195,6 @@
<button mat-menu-item tabindex="0" [routerLink]="['/cashier/cash-transfer']">
<fa-icon icon="random" class="menu-item-icon"></fa-icon> Cash Transfer
</button>
<button mat-menu-item tabindex="0" [routerLink]="['/cashier/inter-cashier']">
<fa-icon icon="exchange-alt" class="menu-item-icon"></fa-icon> Inter-Cashier Transfer
</button>
<button mat-menu-item tabindex="0" [routerLink]="['/cashier/cash-settlement']">
<fa-icon icon="check-circle" class="menu-item-icon"></fa-icon> Cash Settlement
</button>
@@ -1209,9 +1215,6 @@
<button mat-menu-item tabindex="0" [routerLink]="['/cashier/excess-report']">
<fa-icon icon="exclamation-circle" class="menu-item-icon"></fa-icon> Excess Transaction Report
</button>
<button mat-menu-item tabindex="0" [routerLink]="['/cashier/reconciliation']">
<fa-icon icon="balance-scale" class="menu-item-icon"></fa-icon> Reconciliation
</button>
<button mat-menu-item tabindex="0" [routerLink]="['/cashier/cash-history']">
<fa-icon icon="history" class="menu-item-icon"></fa-icon> Cash History
</button>
@@ -2038,11 +2041,11 @@
</a>
<a
class="gl-link"
[class.hidden]="!matchesRbiSearch('ALM')"
(click)="rbiMenuOpen = false; openAlmReportDialog()"
[class.hidden]="!matchesRbiSearch('ALM Average Liabilities and Assets Time Bucket Breakdown')"
(click)="rbiMenuOpen = false; openAlmBucketBreakdownDialog()"
>
<fa-icon icon="balance-scale" class="gl-icon" style="color: #4f46e5"></fa-icon> ALM (Asset Liability
Management)
<fa-icon icon="balance-scale" class="gl-icon" style="color: #4f46e5"></fa-icon> ALM (Average Liabilities
and Assets) – Time Bucket Breakdown
</a>
<a class="gl-link" [class.hidden]="!matchesRbiSearch('Balance Sheet')" (click)="rbiMenuOpen = false">
<fa-icon icon="file-alt" class="gl-icon"></fa-icon> Balance Sheet (BR Act & Others)
+53
View File
@@ -244,6 +244,44 @@ $color: #e2e4ec;
}
}
/*
* The cashier's bar is meant to be one row at a normal window size: it carries about eleven items, and at
* the usual padding the last one wrapped onto a second row. The items are tightened so they fit, and spread
* across the row. As the window narrows they tighten further, then drop their icons, and only wrap in a very
* narrow window. The clerk's bar (cashier-nav--clerk) has its own dropdowns and is left as it was.
*/
.cashier-nav:not(.cashier-nav--clerk) {
flex-wrap: nowrap;
justify-content: space-between;
gap: 0;
padding: 4px 8px;
.cashier-nav__item {
flex: 0 0 auto;
gap: 4px;
padding: 5px 8px;
font-size: 11.5px;
}
@media (width <= 1240px) {
.cashier-nav__item {
padding: 5px 6px;
font-size: 11px;
}
}
@media (width <= 1120px) {
.cashier-nav__item fa-icon {
display: none;
}
}
@media (width <= 900px) {
flex-wrap: wrap;
justify-content: flex-start;
}
}
.sidenav {
overflow-x: hidden;
position: relative;
@@ -1044,3 +1082,18 @@ $gray-700: #334155;
width: $bar;
}
}
// How many requests are waiting under Approvals.
.ni-badge {
min-width: 16px;
height: 16px;
margin-left: 4px;
padding: 0 5px;
border-radius: 999px;
background: #f59e0b;
color: #fff;
font-size: 10px;
font-weight: 700;
line-height: 16px;
text-align: center;
}
+37 -2
View File
@@ -17,6 +17,7 @@ import { TenantMenuAccessService } from '../../directives/has-permission/tenant-
/** rxjs Imports */
import { Observable, Subscription } from 'rxjs';
import { ApprovalCountsService } from '../../tasks/approval-counts.service';
import { map, filter } from 'rxjs/operators';
/** Custom Services */
@@ -68,6 +69,11 @@ export class ShellComponent implements OnInit, OnDestroy {
private authService = inject(AuthenticationService);
private clerkUiStateService = inject(ClerkUiStateService);
private tenantMenuAccessService = inject(TenantMenuAccessService);
private approvalCounts = inject(ApprovalCountsService);
/** Requests waiting across every Approval Center tab this user can see, shown on the Approvals menu. */
approvalTotal = 0;
private approvalTotalSub?: Subscription;
/** Subscription to breakpoint observer for handset. */
isHandset$: Observable<boolean> = this.breakpointObserver
@@ -75,8 +81,9 @@ export class ShellComponent implements OnInit, OnDestroy {
.pipe(map((result) => result.matches));
/** Sets the initial state of sidenav as collapsed. Not collapsed if false. */
sidenavCollapsed = true;
/** Progress bar mode. */
progressBarMode: string;
/** Progress bar mode. It starts from what is running right now: the template shows the bar for anything that is not
* 'none', so an empty start left it running until some request happened to finish. */
progressBarMode: string = this.progressBarService.getRequestsRunning() > 0 ? 'indeterminate' : 'none';
/** Subscription to progress bar. */
progressBar$: Subscription;
/** Hides nav/sidebar for full-screen cashier routes. */
@@ -144,6 +151,13 @@ export class ShellComponent implements OnInit, OnDestroy {
* Subscribes to progress bar to update its mode.
*/
ngOnInit() {
// The Approvals badge: counts refresh every minute while the app is open, and again whenever the Approval Center opens.
this.approvalTotalSub = this.approvalCounts.total$.subscribe((total) => {
this.approvalTotal = total;
this.cdr.markForCheck();
});
this.approvalCounts.start();
this.progressBar$ = this.progressBarService.updateProgressBar.subscribe((mode: string) => {
this.progressBarMode = mode;
this.cdr.detectChanges();
@@ -364,10 +378,31 @@ export class ShellComponent implements OnInit, OnDestroy {
});
}
/**
* Opens the ALM Time-Bucket Breakdown: which balance-sheet item goes to which bucket of the
* Structural Liquidity Statement. Rules are editable and kept per browser.
*/
async openAlmBucketBreakdownDialog(): Promise<void> {
const { AlmBucketBreakdownDialogComponent } =
await import('../../home/dashboard/alm-bucket-breakdown-dialog/alm-bucket-breakdown-dialog.component');
this.dialog.open(AlmBucketBreakdownDialogComponent, {
width: '94vw',
maxWidth: '1320px',
maxHeight: '92vh',
panelClass: 'crr-slr-dialog-panel',
autoFocus: false,
disableClose: false,
hasBackdrop: true,
backdropClass: 'alm-dlg-backdrop'
});
}
/**
* Unsubscribes from progress bar.
*/
ngOnDestroy() {
this.approvalTotalSub?.unsubscribe();
this.approvalCounts.stop();
if (this.progressBar$) {
this.progressBar$.unsubscribe();
}
@@ -838,9 +838,23 @@
<span class="ni-mono">{{ c.clientCif || '—' }}</span>
</td>
<td class="ni-td ni-td--center">
@if (readOnly) {
<span class="ni-pct-badge" [class.ni-pct-badge--full]="c.sharePercentage === 100">
{{ c.sharePercentage }}%
</span>
} @else {
<span class="ni-pct-input">
<input
#pct
type="number"
min="0"
max="100"
[value]="c.sharePercentage"
(change)="setPartnerShare(c, pct.value); pct.value = '' + c.sharePercentage"
aria-label="Ownership percentage"
/>%
</span>
}
</td>
<td class="ni-td ni-td--center">
<button
@@ -465,6 +465,31 @@ $ni-bg: #f9fafb;
color: #374151;
}
.ni-pct-input {
display: inline-flex;
align-items: center;
gap: 2px;
font-size: 12px;
font-weight: 700;
color: $ni-primary;
input {
width: 52px;
padding: 3px 6px;
border: 1px solid #c7d2fe;
border-radius: 6px;
background: #eef2ff;
font: inherit;
color: inherit;
text-align: right;
&:focus {
outline: 2px solid $ni-primary;
outline-offset: 0;
}
}
}
.ni-pct-badge {
display: inline-flex;
align-items: center;
@@ -29,6 +29,8 @@ import { STANDALONE_SHARED_IMPORTS } from 'app/standalone-shared.module';
import { IntroducerSearchComponent } from 'app/shared/introducer-search/introducer-search.component';
import { AddRelatedClientDialogComponent } from './add-related-client-dialog/add-related-client-dialog.component';
import { AlertService } from 'app/core/alert/alert.service';
import { forkJoin, of } from 'rxjs';
import { catchError } from 'rxjs/operators';
/**
* Current Account Details Step
@@ -207,6 +209,9 @@ export class CurrentAccountDetailsStepComponent implements OnInit {
this.autoDetectAccountStatus();
}
}
if (this.isNonIndividualClient && !this.isEditMode && !this.readOnly) {
this.prefillPartnersFromClientSignatories(client);
}
this.cdr.detectChanges();
});
}
@@ -622,6 +627,52 @@ export class CurrentAccountDetailsStepComponent implements OnInit {
}
// ── Non-Individual (existing) helpers ──────────────────────────────────
/**
* A new entity account starts with the authorised signatories recorded on the client at creation
* (clientNonPersonDetails.authorizedSignatoryClientIds), so they need not be added again. Ownership is
* not captured at client creation, so it starts at 0 and is entered in the table; Add Partner stays
* available for anyone else.
*/
private prefillPartnersFromClientSignatories(client: any): void {
if (this.relatedClients.length > 0) {
return;
}
const ids: number[] = String(client?.clientNonPersonDetails?.authorizedSignatoryClientIds ?? '')
.split(',')
.map((id) => Number(id.trim()))
.filter((id, i, all) => id > 0 && id !== this.primaryClientId && all.indexOf(id) === i);
if (!ids.length) {
return;
}
// One lookup per signatory for the CIF; a signatory that can no longer be read is skipped, not fatal.
forkJoin(
ids.map((id) => this.clientsService.getClientData(String(id), true).pipe(catchError(() => of(null))))
).subscribe((signatories: any[]) => {
// The user may have added partners by hand while the lookups ran — never overwrite those.
if (this.relatedClients.length > 0) {
return;
}
this.relatedClients = signatories
.filter((s) => !!s?.id)
.map((s) => ({
clientId: s.id,
clientName: s.displayName ?? s.fullname ?? '',
clientCif: s.cifNumber ?? s.cifNo ?? '',
sharePercentage: 0,
isMandatory: false
}));
this.autoSetSignatoryRights();
this.cdr.detectChanges();
});
}
/** Inline ownership edit — clamped so the partners' total never exceeds 100%. */
setPartnerShare(client: any, raw: any): void {
const othersTotal = this.totalSharePercentage - (client.sharePercentage || 0);
const value = Math.round(Number(raw) || 0);
client.sharePercentage = Math.min(Math.max(value, 0), Math.max(0, 100 - othersTotal));
}
openAddRelatedClientDialog(): void {
const excludedClientIds = [
...(this.primaryClientId ? [this.primaryClientId] : []),
@@ -135,18 +135,6 @@
<mat-icon><fa-icon icon="eye" size="sm"></fa-icon></mat-icon>
<span>{{ 'labels.text.View Transaction' | translate }}</span>
</button>
@if (!transaction.reversed) {
<span>
<button
mat-menu-item
*mifosxHasPermission="'ADJUSTTRANSACTION_CURRENTACCOUNT'"
(click)="undoTransaction(transaction, $event)"
>
<mat-icon><fa-icon icon="undo" size="sm"></fa-icon></mat-icon>
<span>{{ 'tooltips.Undo Transaction' | translate }}</span>
</button>
</span>
}
<button
mat-menu-item
class="account-action-button"
@@ -40,18 +40,6 @@
</button>
</span>
}
@if (allowUndo()) {
<span>
<button
mat-raised-button
color="warn"
*mifosxHasPermission="'UNDOTRANSACTION_CURRENTACCOUNT'"
(click)="undoTransaction()"
>
<fa-icon icon="undo" class="m-r-10"></fa-icon>{{ 'labels.buttons.Undo' | translate }}
</button>
</span>
}
</div>
<div class="container">
@@ -56,12 +56,13 @@
flex-shrink: 0;
}
// A ticked day or voucher, in the screen's own green rather than the shared sheet's blue.
.option-picked {
border-color: #2563eb !important;
background: #eff6ff !important;
border-color: #0f766e !important;
background: #e6f4ee !important;
.option-check {
color: #2563eb;
color: #0f766e;
}
}
@@ -93,3 +94,76 @@
border-radius: 6px;
background: #f8fafc;
}
// ── Compact density ──────────────────────────────────────────────────────────────────────────────────
// A step smaller than the sheet it shares, for this screen only: the shared sheet is sized in rem for
// Apply Additional Shares and is left alone, so the whole dialog is scaled here instead. Nothing in it
// is an overlay, so the scaling cannot throw a popup out of place.
.dialog-wrapper {
zoom: 0.88;
}
// ── The Daily Transaction screen's colours ───────────────────────────────────────────────────────────
// The shared sheet is indigo and blue, for Apply Additional Shares. This dialog opens from a screen
// drawn in green and teal, so it takes that palette here - the shared sheet is left as it is, and these
// rules only state what differs from it.
$page-teal: #0f766e;
$page-dark: #0f4f3c;
$page-soft: #e6f4ee;
$page-border: #b7ddd0;
.dialog-header {
background: linear-gradient(135deg, $page-dark 0%, $page-teal 70%, #1f7a5c 100%);
}
.sc-card {
border-color: $page-border;
}
.slot-step {
background: linear-gradient(135deg, $page-dark 0%, $page-teal 100%);
}
.slot-search {
border-color: $page-border;
background: $page-soft;
color: $page-teal;
&:hover {
background: $page-teal;
border-color: $page-teal;
color: #fff;
box-shadow: 0 2px 8px rgb(15 118 110 / 25%);
}
}
.option {
&:hover {
border-color: $page-teal;
background: $page-soft;
box-shadow: 0 2px 6px rgb(15 118 110 / 10%);
}
&:hover .option-radio {
border-color: $page-teal;
}
}
.option-info:hover {
color: $page-teal;
border-color: $page-teal;
background: $page-soft;
}
.gl-table tbody tr:hover {
background: $page-soft;
}
.dialog-actions [mat-flat-button][color='primary']:not([disabled]) {
background: linear-gradient(135deg, $page-teal 0%, $page-dark 100%) !important;
box-shadow: 0 2px 8px rgb(15 118 110 / 28%) !important;
&:hover {
box-shadow: 0 4px 14px rgb(15 118 110 / 40%) !important;
}
}
@@ -0,0 +1,78 @@
<!--
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="cd">
<div class="cd-head">
<span class="cd-head__ic"><i class="fas fa-list"></i></span>
<div class="cd-head__text">
<h2>Collections up to {{ data.dateText }}</h2>
<p>{{ data.agentName }} - written down and not yet handed in</p>
</div>
<button mat-stroked-button type="button" class="cd-refresh" (click)="refresh()">
<i class="fas fa-sync"></i> Refresh
</button>
<button type="button" class="cd-close" (click)="close()" aria-label="Close"><i class="fas fa-times"></i></button>
</div>
<div class="cd-body">
@if (data.loading()) {
<p class="cd-empty">Reading this agent's entries...</p>
} @else if (entries.length === 0) {
<p class="cd-empty">{{ data.agentName }} has nothing outstanding up to this date.</p>
} @else {
<div class="cd-table">
<div class="cd-row cd-row--head">
<span class="cd-date">Date</span>
<span class="cd-code">Party</span>
<span class="cd-name">Name</span>
<span class="cd-mode">Mode</span>
<span class="cd-amount">Amount</span>
</div>
@for (entry of pageEntries; track entry.id) {
<!-- Clicking a line closes this and shows that party on the screen. -->
<div class="cd-row" (click)="pick(entry)" title="Show this party's account">
<span class="cd-date">{{ entry.entryDate | dateFormat }}</span>
<span class="cd-code">{{ entry.partyCode }}</span>
<span class="cd-name">{{ entry.partyName }}</span>
<span class="cd-mode"
><span class="cd-chip">{{ entry.transactionMode }}</span>
@if (entry.advanceDays) {
<small>{{ entry.advanceDays }}d advance</small>
}
@if (entry.arrearsDays) {
<small>{{ entry.arrearsDays }}d previous</small>
}
</span>
<span class="cd-amount">{{ entry.amount | number: '1.2-2' }}</span>
</div>
}
</div>
<div class="cd-total">
<span>{{ entries.length }} {{ entries.length === 1 ? 'entry' : 'entries' }}</span>
<strong>{{ data.total() | number: '1.2-2' }}</strong>
</div>
<p class="cd-note">
The cash and its receipts are under Agent Tran. Click a line to see that customer's account.
</p>
}
</div>
@if (entries.length > 0) {
<mat-paginator
class="cd-paginator"
[length]="entries.length"
[pageSize]="pageSize"
[pageIndex]="pageIndex"
[pageSizeOptions]="pageSizeOptions"
(page)="onPage($event)"
showFirstLastButtons
></mat-paginator>
}
</div>
@@ -0,0 +1,235 @@
/**
* 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/.
*/
$primary: #0f766e;
$primary-dark: #0f4f3c;
$primary-light: #e6f4ee;
$border: #e2e8f0;
$text-dark: #0f172a;
$text-mid: #475569;
$text-soft: #94a3b8;
:host {
display: block;
}
// Small type throughout: this is a list to read down, and a page of it should fit the window.
.cd {
display: flex;
flex-direction: column;
max-height: 86vh;
font-size: 12.5px;
color: $text-dark;
}
.cd-head {
display: flex;
align-items: center;
gap: 10px;
padding: 12px 16px;
border-bottom: 1px solid $border;
&__ic {
display: grid;
place-items: center;
flex: 0 0 auto;
width: 30px;
height: 30px;
border-radius: 9px;
background: $primary-light;
color: $primary-dark;
font-size: 13px;
}
&__text {
flex: 1;
min-width: 0;
h2 {
margin: 0;
font-size: 14px;
font-weight: 700;
}
p {
margin: 1px 0 0;
font-size: 11.5px;
color: $text-mid;
}
}
}
.cd-refresh {
height: 30px;
padding: 0 12px;
font-size: 12px;
color: $primary;
}
.cd-close {
display: grid;
place-items: center;
width: 28px;
height: 28px;
border: 0;
border-radius: 8px;
background: transparent;
color: $text-mid;
cursor: pointer;
&:hover {
background: #f1f5f9;
}
}
.cd-body {
flex: 1;
min-height: 0;
padding: 10px 16px 6px;
overflow-y: auto;
}
.cd-empty {
margin: 8px 0;
padding: 16px;
border: 1.5px dashed #cbd5e1;
border-radius: 10px;
background: #f8fafc;
color: $text-mid;
text-align: center;
}
.cd-table {
border: 1px solid $border;
border-radius: 10px;
overflow: hidden;
}
.cd-row {
display: grid;
grid-template-columns: 112px 78px minmax(0, 1fr) 120px 92px;
align-items: center;
gap: 10px;
padding: 7px 12px;
border-bottom: 1px solid #eef2f6;
cursor: pointer;
&:last-child {
border-bottom: 0;
}
&:hover {
background: #f0fdf9;
}
&--head {
cursor: default;
background: #f1f5f9;
font-size: 10.5px;
font-weight: 700;
letter-spacing: 0.05em;
text-transform: uppercase;
color: $text-soft;
&:hover {
background: #f1f5f9;
}
}
}
.cd-date {
color: $text-mid;
font-variant-numeric: tabular-nums;
}
.cd-code {
font-weight: 700;
color: $primary-dark;
font-family: ui-monospace, 'SF Mono', 'Cascadia Mono', Consolas, monospace;
font-size: 12px;
}
.cd-name {
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.cd-mode {
display: flex;
align-items: center;
gap: 6px;
flex-wrap: wrap;
small {
font-size: 10.5px;
color: $text-mid;
}
}
.cd-chip {
padding: 1px 8px;
border-radius: 999px;
background: #eef2ff;
color: #3730a3;
font-size: 10.5px;
font-weight: 600;
letter-spacing: 0.04em;
text-transform: uppercase;
}
.cd-amount {
text-align: right;
font-weight: 700;
font-variant-numeric: tabular-nums;
}
.cd-total {
display: flex;
justify-content: flex-end;
align-items: baseline;
gap: 12px;
margin-top: 8px;
padding: 8px 12px;
border-radius: 8px;
background: $primary-light;
color: $text-mid;
strong {
font-size: 15px;
color: $primary-dark;
font-variant-numeric: tabular-nums;
}
}
.cd-note {
margin: 8px 2px 4px;
font-size: 11.5px;
color: $text-soft;
}
.cd-paginator {
flex: 0 0 auto;
border-top: 1px solid $border;
font-size: 12px;
--mat-paginator-container-text-size: 12px;
--mat-paginator-container-size: 44px;
--mat-paginator-form-field-container-height: 30px;
--mat-paginator-form-field-container-vertical-padding: 3px;
}
@media (width <= 640px) {
.cd-row {
grid-template-columns: 90px 70px minmax(0, 1fr) 80px;
.cd-mode {
display: none;
}
}
}
@@ -0,0 +1,93 @@
/**
* 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, inject } from '@angular/core';
import { CommonModule } from '@angular/common';
import { MAT_DIALOG_DATA, MatDialogModule, MatDialogRef } from '@angular/material/dialog';
import { MatButtonModule } from '@angular/material/button';
import { MatPaginatorModule, PageEvent } from '@angular/material/paginator';
import { DateFormatPipe } from 'app/pipes/date-format.pipe';
/**
* What the dialog is given. The entries are read through functions rather than copied in, so a Refresh
* on the screen behind - which reloads the list - shows here as soon as it arrives.
*/
export interface CollectionsDialogData {
agentName: string;
/** The day the list runs up to, already worded for a reader. */
dateText: string;
entries: () => any[];
total: () => number;
loading: () => boolean;
refresh: () => void;
}
/**
* Everything the agent wrote down and has not handed in yet, up to the chosen day, customer by
* customer - the list the clerk counts a bag of cash against.
*
* It lives in a window, paged, rather than down the page: a round can run to hundreds of lines, and
* the screen behind it has a good deal more to show. A line can be clicked to see that customer's
* account on the screen; the dialog closes with the entry picked.
*/
@Component({
selector: 'mifosx-dds-collections-dialog',
standalone: true,
templateUrl: './collections-dialog.component.html',
styleUrls: ['./collections-dialog.component.scss'],
imports: [
CommonModule,
MatDialogModule,
MatButtonModule,
MatPaginatorModule,
DateFormatPipe
]
})
export class CollectionsDialogComponent {
readonly data = inject<CollectionsDialogData>(MAT_DIALOG_DATA);
private dialogRef = inject(MatDialogRef<CollectionsDialogComponent>);
readonly pageSizeOptions = [
10,
25,
50,
100
];
pageIndex = 0;
pageSize = 10;
get entries(): any[] {
return this.data.entries() || [];
}
/** The lines on the page being looked at. */
get pageEntries(): any[] {
const start = this.pageIndex * this.pageSize;
return this.entries.slice(start, start + this.pageSize);
}
onPage(event: PageEvent): void {
this.pageIndex = event.pageIndex;
this.pageSize = event.pageSize;
}
refresh(): void {
// A reloaded list may be shorter than the page being looked at.
this.pageIndex = 0;
this.data.refresh();
}
/** Closes with the line clicked, for the screen to show whose account it is. */
pick(entry: any): void {
this.dialogRef.close(entry);
}
close(): void {
this.dialogRef.close();
}
}
@@ -11,14 +11,20 @@
<button mat-stroked-button [routerLink]="['../']" class="dte-back">
<i class="fas fa-arrow-left"></i> Deposits
</button>
<div class="dte-header__icon"><i class="fas fa-coins"></i></div>
<div class="dte-header__text">
<h2 class="dte-header__title">Daily Transaction Detail</h2>
<p class="dte-header__sub">
An agent's round - what was collected, the cash handed in and applied - and transfers between accounts.
</p>
</div>
</div>
<div class="dte-card">
<form [formGroup]="entryForm">
<div class="dte-info-panels">
<div class="dte-info-panel">
<div class="dte-info-panel__title">Agent Information</div>
<div class="dte-info-panel__title"><i class="fas fa-user-tie"></i>Agent Information</div>
<dl>
<dt>Name</dt>
<dd>{{ agentInfo.name || '—' }}</dd>
@@ -28,17 +34,22 @@
<dd>{{ agentInfo.city || '—' }}</dd>
<dt>A/C</dt>
<dd>{{ agentInfo.accountNo || '—' }}</dd>
<!-- One line for each guarantor the agent actually has - not two fixed lines, one of them empty. -->
@for (guarantor of agentInfo.guarantors; track $index; let i = $index) {
<dt>{{ agentInfo.guarantors.length > 1 ? 'Guarantor ' + (i + 1) : 'Guarantor' }}</dt>
<dd>{{ guarantor }}</dd>
} @empty {
<dt>Guarantor</dt>
<dd>{{ agentInfo.guarantor1 || '—' }}</dd>
<dt>Guarantor</dt>
<dd>{{ agentInfo.guarantor2 || '—' }}</dd>
<dd>—</dd>
}
<dt>Curr Bal</dt>
<dd>{{ agentInfo.currentBalance | number: '1.2-2' }}</dd>
</dl>
</div>
<div class="dte-info-panel">
<div class="dte-info-panel__title">Party Information</div>
<!-- Wider than the agent's card, with its details two pairs to a line, so a party's account is read without scrolling. -->
<div class="dte-info-panel dte-info-panel--party">
<div class="dte-info-panel__title"><i class="fas fa-user"></i>Party Information</div>
@if (partyInfo) {
<dl>
<dt>Client Id</dt>
@@ -79,15 +90,62 @@
</dl>
} @else {
<p class="dte-info-panel__hint">
Pick a party in a collection row below — this panel shows whose account it is and what the balance
<i class="fas fa-info-circle"></i>
Click an entry in the collections list below — this panel shows whose account it is and what the balance
becomes.
</p>
}
</div>
</div>
<!-- The round at a glance, once an agent is picked: nothing here is typed or saved. -->
@if (agentInfo.name) {
<div class="dte-kpis">
<div class="dte-kpi">
<span class="dte-kpi__label"><i class="fas fa-users"></i>Parties</span>
<strong class="dte-kpi__value">{{ agentParties.length }}</strong>
<span class="dte-kpi__hint">to collect from</span>
</div>
@if (isPartyTran) {
<div class="dte-kpi">
<span class="dte-kpi__label"><i class="fas fa-list-ol"></i>Entries</span>
<strong class="dte-kpi__value">{{ pendingEntries.length }}</strong>
<span class="dte-kpi__hint">written down, not handed in</span>
</div>
<div class="dte-kpi dte-kpi--accent">
<span class="dte-kpi__label"><i class="fas fa-rupee-sign"></i>To count</span>
<strong class="dte-kpi__value">{{ pendingTotal | number: '1.2-2' }}</strong>
<span class="dte-kpi__hint">the agent should hand over</span>
</div>
}
<!-- Agent Tran: what the agent owes on everything written down, what the cashier has taken in, and what is left. -->
@if (isAgentTran) {
<div class="dte-kpi dte-kpi--owed">
<span class="dte-kpi__label"><i class="fas fa-file-invoice"></i>To take</span>
<strong class="dte-kpi__value">{{ agentOwed | number: '1.2-2' }}</strong>
<span class="dte-kpi__hint">written down, not handed in</span>
</div>
<div class="dte-kpi dte-kpi--received">
<span class="dte-kpi__label"><i class="fas fa-check-circle"></i>Received (approved)</span>
<strong class="dte-kpi__value">{{ agentReceived | number: '1.2-2' }}</strong>
<span class="dte-kpi__hint">taken in by the cashier</span>
</div>
<div class="dte-kpi" [class.dte-kpi--due]="agentBalance > 0">
<span class="dte-kpi__label"><i class="fas fa-hourglass-half"></i>Still to take</span>
<strong class="dte-kpi__value">{{ agentBalance | number: '1.2-2' }}</strong>
<span class="dte-kpi__hint">left to receive</span>
</div>
}
<div class="dte-kpi">
<span class="dte-kpi__label"><i class="fas fa-wallet"></i>Agent Balance</span>
<strong class="dte-kpi__value">{{ agentInfo.currentBalance | number: '1.2-2' }}</strong>
<span class="dte-kpi__hint">held by {{ agentInfo.name }}</span>
</div>
</div>
}
<div class="dte-controls">
<mat-form-field appearance="outline" class="dte-field dte-field--agent">
<mat-form-field appearance="outline" subscriptSizing="dynamic" class="dte-field dte-field--agent">
<mat-label>Agent</mat-label>
<mat-select required formControlName="agentId" (selectionChange)="loadAgent()">
@for (agent of selectableAgents; track agent.id) {
@@ -112,7 +170,7 @@
}
</mat-form-field>
<mat-form-field appearance="outline" class="dte-field">
<mat-form-field appearance="outline" subscriptSizing="dynamic" class="dte-field">
<mat-label>Date</mat-label>
<!-- The date picks out a round, not just a posting date: a Party Tran settles whatever
the agent wrote down for that day, so changing it has to fetch that day's list. -->
@@ -127,7 +185,7 @@
<mat-datepicker #txnDatePicker></mat-datepicker>
</mat-form-field>
<mat-form-field appearance="outline" class="dte-field">
<mat-form-field appearance="outline" subscriptSizing="dynamic" class="dte-field">
<mat-label>Tran. Type</mat-label>
<mat-select required formControlName="transactionType">
@for (type of transactionTypes; track type.label) {
@@ -140,100 +198,43 @@
}
</mat-select>
</mat-form-field>
</div>
<!--
What the agent wrote down on their round, waiting to be counted.
This used to be an empty grid the clerk typed the round into, and typing it posted it: a
customer was credited the moment a line was keyed, whether or not the cash behind it ever
reached the branch. The agent records their own collections now, and what the clerk does here
is agree with them - so there is nothing to type, only a list to check against a bag of cash.
-->
@if (isPartyTran && agentInfo.name) {
<div class="dte-section">
<div class="dte-section__header">
<i class="fas fa-list"></i>
<h3>Collections up to {{ entryForm.get('transactionDate')?.value | dateFormat }}</h3>
<button mat-stroked-button type="button" class="dte-add-row" (click)="loadPendingEntries()">
<i class="fas fa-sync"></i> Refresh
</button>
</div>
@if (pendingLoading) {
<p class="dte-empty">Reading this agent's entries...</p>
} @else if (pendingEntries.length === 0) {
<p class="dte-empty">{{ agentInfo.name }} has nothing outstanding up to this date.</p>
} @else {
@for (entry of pendingEntries; track entry.id) {
<div class="dte-pending-row">
<!-- The day the agent took it, on the line itself. All the lines carry the date
chosen above, but a row that says so can be read, printed or shown to the agent
on its own without the header to explain it. -->
<span class="dte-pending-row__date">{{ entry.entryDate | dateFormat }}</span>
<span class="dte-pending-row__code">{{ entry.partyCode }}</span>
<span class="dte-pending-row__name">{{ entry.partyName }}</span>
<span class="dte-pending-row__mode"
>{{ entry.transactionMode }}
@if (entry.advanceDays) {
· {{ entry.advanceDays }}d advance
}
@if (entry.arrearsDays) {
· {{ entry.arrearsDays }}d previous
}
</span>
<span class="dte-pending-row__amount">{{ entry.amount | number: '1.2-2' }}</span>
</div>
}
<div class="dte-total">
<span>{{ pendingEntries.length }} {{ pendingEntries.length === 1 ? 'entry' : 'entries' }}</span>
<strong>{{ pendingTotal | number: '1.2-2' }}</strong>
</div>
<!--
The figure the clerk counts against, said in words rather than left as a column total -
it is the one number they are acting on, and the rest of the panel is working out.
It says what the agent wrote down and stops there. The money side - what is owed, what the
cashier has taken in, and applying it - is Agent Tran, so each type has one job.
-->
<p class="dte-expected dte-expected--note">
Everything {{ agentInfo.name }} wrote down and has not handed in yet, up to this date, customer by
customer. The cash and its receipts are under Agent Tran.
</p>
}
</div>
}
<!--
The agent's money: what they owe on everything written down, what the cashier has taken in
against it, and what is left.
This used to be a form the clerk typed an amount into to take the agent's cash in here. The
cash is taken at the cashier's counter now, into the agent's own GL account, and one way to take
the same cash in is the only safe number of ways - so there is nothing to type. What is done here
is applying the cashier's receipts to the customers, in Apply DDS Receipt.
-->
<!-- Agent Tran: the cashier's receipts are applied to the customers from here - the dialog shows the days and receipts. -->
@if (isAgentTran && agentInfo.name) {
<div class="dte-section">
<div class="dte-section__header">
<i class="fas fa-hand-holding-usd"></i>
<h3>Agent's cash</h3>
<button mat-stroked-button type="button" class="dte-add-row" (click)="loadAgentMoney()">
<div class="dte-agent-actions">
<button mat-stroked-button type="button" class="dte-refresh-btn" (click)="loadAgentMoney()">
<i class="fas fa-sync"></i> Refresh
</button>
<button
mat-raised-button
color="primary"
type="button"
class="dte-apply-receipt"
(click)="openApplyReceipt()"
>
<button mat-raised-button color="primary" type="button" class="dte-apply-btn" (click)="openApplyReceipt()">
<i class="fas fa-receipt"></i> Apply DDS Receipt
</button>
</div>
}
<!-- The agent's written-down round, in a window of its own and paged. -->
@if (isPartyTran && agentInfo.name) {
<button mat-stroked-button type="button" class="dte-view-btn" (click)="openCollections()">
<i class="fas fa-list"></i> View Collections
<span class="dte-view-btn__count">{{ pendingEntries.length }}</span>
</button>
}
</div>
<!--
What the agent wrote down on their round, waiting to be counted, is no longer listed down this
page - a long round pushed everything else out of sight. It opens from View Collections above, in a
window of its own and paged, and a line clicked there shows that customer in Party Information.
-->
<!--
The agent's money used to have a section of its own down the page: what they owe on everything
written down, what the cashier has taken in, what is left, then the outstanding days and the
cashier's receipts. Apply DDS Receipt shows the same days and receipts, so the section is gone -
the three figures sit in the summary above, the button is beside the Date, and what is below is
only what the dialog does not say: which GL the cash goes into and anything that looks wrong.
-->
@if (isAgentTran && agentInfo.name) {
<div class="dte-notes">
@if (!agentGlCode) {
<p class="dte-expected dte-expected--note">
{{ agentInfo.name }} has no GL account, so the cashier has nowhere to take their cash. Set one on Agent
@@ -246,21 +247,6 @@
takes {{ agentInfo.name }}'s cash into this GL (Entry Type DDS).
</p>
}
<div class="dte-money">
<div class="dte-money__box">
<span class="dte-money__label">To take</span>
<strong>{{ agentOwed | number: '1.2-2' }}</strong>
</div>
<div class="dte-money__box">
<span class="dte-money__label">Received (approved)</span>
<strong>{{ agentReceived | number: '1.2-2' }}</strong>
</div>
<div class="dte-money__box" [class.dte-money__box--due]="agentBalance > 0">
<span class="dte-money__label">Still to take</span>
<strong>{{ agentBalance | number: '1.2-2' }}</strong>
</div>
</div>
@if (agentReceivedBeyondDay > 0) {
<p class="dte-expected dte-expected--note">
{{ agentReceivedBeyondDay | number: '1.2-2' }} received at the counter is more than everything outstanding
@@ -273,66 +259,6 @@
{{ agentWaiting | number: '1.2-2' }} more is at the counter awaiting the manager's approval.
</p>
}
<h4 class="dte-subhead">
Outstanding entries up to {{ entryForm.get('transactionDate')?.value | dateFormat }}
</h4>
@if (agentMoneyLoading) {
<p class="dte-empty">Reading this agent's entries...</p>
} @else if (agentDays.length === 0) {
<p class="dte-empty">{{ agentInfo.name }} has nothing outstanding.</p>
} @else {
@for (day of agentDays; track day.date) {
<div class="dte-pending-row dte-pending-row--day" (click)="toggleAgentDay(day.date)">
<span class="dte-pending-row__date">{{ day.date | dateFormat }}</span>
<span class="dte-pending-row__name">
{{ day.entries.length }} {{ day.entries.length === 1 ? 'entry' : 'entries' }}
</span>
<span class="dte-pending-row__amount">{{ day.total | number: '1.2-2' }}</span>
<i [class]="agentOpenDay === day.date ? 'fas fa-chevron-up' : 'fas fa-chevron-down'"></i>
</div>
@if (agentOpenDay === day.date) {
<div class="dte-day-lines">
@for (entry of day.entries; track entry.id) {
<div class="dte-pending-row">
<span class="dte-pending-row__code">{{ entry.partyCode }}</span>
<span class="dte-pending-row__name">{{ entry.partyName }}</span>
<span class="dte-pending-row__mode"
>{{ entry.transactionMode }}
@if (entry.advanceDays) {
· {{ entry.advanceDays }}d advance
}
@if (entry.arrearsDays) {
· {{ entry.arrearsDays }}d previous
}
</span>
<span class="dte-pending-row__amount">{{ entry.amount | number: '1.2-2' }}</span>
</div>
}
</div>
}
}
}
<h4 class="dte-subhead">Cashier receipts</h4>
@if (agentReceipts.length === 0) {
<p class="dte-empty">
No DDS receipt on {{ agentInfo.name }}'s GL account{{ agentGlCode ? ' ' + agentGlCode : '' }} waiting to
be applied.
</p>
} @else {
@for (receipt of agentReceipts; track receipt.id) {
<div class="dte-pending-row">
<span class="dte-pending-row__code">{{ receipt.voucherNo }}</span>
<span class="dte-pending-row__date">{{ receipt.txnDate | dateFormat }}</span>
<span class="dte-pending-row__name">{{ receipt.cashierName || '—' }}</span>
<span class="dte-status" [class.dte-status--ok]="receipt.status === 'APPROVED'">
{{ receipt.status === 'APPROVED' ? 'Approved' : 'Awaiting manager' }}
</span>
<span class="dte-pending-row__amount">{{ receipt.amount | number: '1.2-2' }}</span>
</div>
}
}
</div>
}
@@ -342,7 +268,11 @@
<i class="fas fa-exchange-alt"></i>
<h3>Withdraw Deposite</h3>
</div>
<div class="dte-grid">
<div class="dte-grid dte-grid--transfer">
<!-- The account the money goes into. -->
<div class="dte-party-card dte-party-card--to">
<div class="dte-party-card__title"><i class="fas fa-arrow-circle-down"></i>Money goes into</div>
<div class="dte-party-card__fields">
<mat-form-field appearance="outline" class="dte-field">
<mat-label>Creditor Party Code</mat-label>
<input
@@ -356,7 +286,10 @@
placeholder="Search by code or name…"
autocomplete="off"
/>
<mat-icon matSuffix class="dte-list-arrow" (click)="$event.stopPropagation(); creditorTrigger.openPanel()"
<mat-icon
matSuffix
class="dte-list-arrow"
(click)="$event.stopPropagation(); creditorTrigger.openPanel()"
>arrow_drop_down</mat-icon
>
<mat-autocomplete #creditorAuto="matAutocomplete" panelWidth="min(30rem, 92vw)">
@@ -379,7 +312,13 @@
<mat-label>Creditor Curr Bal</mat-label>
<input matInput formControlName="creditorPartyBalance" readonly />
</mat-form-field>
</div>
</div>
<!-- The account the money is taken from. -->
<div class="dte-party-card dte-party-card--from">
<div class="dte-party-card__title"><i class="fas fa-arrow-circle-up"></i>Money is taken from</div>
<div class="dte-party-card__fields">
<mat-form-field appearance="outline" class="dte-field">
<mat-label>Debit Party Code</mat-label>
<input
@@ -393,7 +332,10 @@
placeholder="Search by code or name…"
autocomplete="off"
/>
<mat-icon matSuffix class="dte-list-arrow" (click)="$event.stopPropagation(); debitTrigger.openPanel()"
<mat-icon
matSuffix
class="dte-list-arrow"
(click)="$event.stopPropagation(); debitTrigger.openPanel()"
>arrow_drop_down</mat-icon
>
<mat-autocomplete #debitAuto="matAutocomplete" panelWidth="min(30rem, 92vw)">
@@ -420,6 +362,8 @@
<mat-label>Debit Curr Bal</mat-label>
<input matInput formControlName="debitPartyBalance" readonly />
</mat-form-field>
</div>
</div>
<mat-form-field appearance="outline" class="dte-field">
<mat-label>Amount</mat-label>
File diff suppressed because it is too large Load Diff
@@ -19,6 +19,7 @@ import { ConfirmationDialogComponent } from 'app/shared/confirmation-dialog/conf
/** Custom Components */
import { DayCollectionDialogComponent } from './day-collection-dialog/day-collection-dialog.component';
import { NoticeDialogComponent } from './notice-dialog/notice-dialog.component';
import { CollectionsDialogComponent } from './collections-dialog/collections-dialog.component';
import {
ApplyDdsReceiptDialogComponent,
EntryDay,
@@ -152,6 +153,8 @@ export class DailyTransactionEntryComponent implements OnInit {
accountNo: '',
guarantor1: '',
guarantor2: '',
// Every guarantor the agent has, by name - the panel lists as many as there are rather than two fixed lines.
guarantors: [] as string[],
currentBalance: 0
};
@@ -505,6 +508,34 @@ export class DailyTransactionEntryComponent implements OnInit {
* agent still has outstanding. Its own screen, like Apply Additional Shares, so the list above stays
* what it is - what to take from the agent - and the applying is done in one deliberate place.
*/
/**
* Opens what the agent wrote down up to the chosen day, in a window of its own and paged - it used to
* run down the page, where a long round pushed everything else out of sight. A line clicked there
* shows that customer in the Party Information panel.
*/
openCollections(): void {
this.dialog
.open(CollectionsDialogComponent, {
width: '820px',
maxWidth: '96vw',
autoFocus: false,
data: {
agentName: this.agentInfo.name,
dateText: this.dateUtils.formatDate(this.entryForm.get('transactionDate')?.value, 'dd MMMM yyyy'),
entries: () => this.pendingEntries,
total: () => this.pendingTotal,
loading: () => this.pendingLoading,
refresh: () => this.loadPendingEntries()
}
})
.afterClosed()
.subscribe((entry: any) => {
if (entry) {
this.showPartyOfEntry(entry);
}
});
}
openApplyReceipt(): void {
const agentId = this.entryForm.get('agentId')?.value;
if (!agentId) {
@@ -513,7 +544,7 @@ export class DailyTransactionEntryComponent implements OnInit {
const picked = this.agents.find((agent: any) => agent.id === agentId);
this.dialog
.open(ApplyDdsReceiptDialogComponent, {
width: '780px',
width: '700px',
maxWidth: '96vw',
panelClass: 'apply-collection-dialog-panel',
data: {
@@ -799,6 +830,7 @@ export class DailyTransactionEntryComponent implements OnInit {
accountNo: agent.agentSavingAccountNo || '',
guarantor1: guarantors[0]?.guarantorName || '',
guarantor2: guarantors[1]?.guarantorName || '',
guarantors: guarantors.map((guarantor: any) => guarantor?.guarantorName).filter((name: any) => !!name),
currentBalance: agent.agentCurrentBalance || 0
};
this.agentGlCode = agent.glAccountCode || null;
@@ -813,6 +845,7 @@ export class DailyTransactionEntryComponent implements OnInit {
accountNo: '',
guarantor1: '',
guarantor2: '',
guarantors: [],
currentBalance: 0
};
}
@@ -890,11 +923,29 @@ export class DailyTransactionEntryComponent implements OnInit {
row.get('balanceAfterTransaction')?.setValue(current + amount, { emitEvent: false });
}
/**
* The entry in the collections list the Party Information panel is showing, if one was clicked. The
* agent writes the round down on their own screen now, so there is no row here to key a party into -
* the panel is filled from the line the clerk is looking at instead.
*/
selectedEntry: any = null;
/** Shows whose account a written-down entry is, and what its balance becomes with the entry on it. */
showPartyOfEntry(entry: any): void {
this.selectedEntry = entry;
this.activeRow = -1;
this.partyInfo = this.agentParties.find((party) => String(party.partyCode) === String(entry?.partyCode)) || null;
}
/** The balance the party panel shows - live against whatever the active row currently holds. */
get partyBalanceAfter(): number {
if (!this.partyInfo || this.activeRow < 0) {
if (!this.partyInfo) {
return 0;
}
if (this.activeRow < 0) {
// No keyed row: it is the entry picked from the list that is being added to the balance.
return Number(this.partyInfo.currentBalance || 0) + Number(this.selectedEntry?.amount || 0);
}
const row = this.collections.at(this.activeRow);
return Number(this.partyInfo.currentBalance || 0) + Number(row?.get('amount')?.value || 0);
}
@@ -6,6 +6,8 @@
* file, You can obtain one at http://mozilla.org/MPL/2.0/.
*/
/* stylelint-disable no-duplicate-selectors -- the compact-density pass below deliberately re-opens earlier rules; kept in cascade order */
.dcd-field {
width: 100%;
}
@@ -40,3 +42,34 @@
color: rgb(0 0 0 / 60%);
}
}
// ── Compact density ──────────────────────────────────────────────────────────────────────────────────
// The same step smaller as the rest of the screen it opens from.
:host {
--mat-form-field-container-height: 38px;
--mat-form-field-container-vertical-padding: 8px;
--mat-form-field-container-text-size: 13px;
--mat-form-field-container-text-line-height: 20px;
--mat-form-field-outlined-label-text-populated-size: 11px;
--mat-form-field-subscript-text-size: 11px;
--mat-select-trigger-text-size: 13px;
font-size: 13px;
}
/* stylelint-disable-next-line selector-no-qualifying-type */
h2[mat-dialog-title] {
font-size: 15px;
}
.dcd-amount {
padding: 8px 12px;
&__figure {
font-size: 1.1rem;
}
&__basis {
font-size: 0.74rem;
}
}
@@ -6,6 +6,8 @@
* file, You can obtain one at http://mozilla.org/MPL/2.0/.
*/
/* stylelint-disable no-duplicate-selectors -- the compact-density pass below deliberately re-opens earlier rules; kept in cascade order */
.nd-head {
display: flex;
align-items: center;
@@ -42,3 +44,22 @@
line-height: 1.55;
color: rgb(0 0 0 / 70%);
}
// ── Compact density ──────────────────────────────────────────────────────────────────────────────────
.nd-head {
padding: 14px 20px 2px;
&__ic {
width: 30px;
height: 30px;
}
&__title {
font-size: 0.95rem;
}
}
.nd-message {
font-size: 0.82rem;
line-height: 1.5;
}
@@ -54,27 +54,33 @@
</div>
</div>
<!-- Tabs -->
<div class="dav-tabs">
<button class="dav-tab" [class.dav-tab--on]="tab === 'general'" (click)="tab = 'general'">
<fa-icon icon="info-circle"></fa-icon> General
</button>
<button class="dav-tab" [class.dav-tab--on]="tab === 'transactions'" (click)="tab = 'transactions'">
<fa-icon icon="exchange-alt"></fa-icon> Transactions
</button>
<button class="dav-tab" [class.dav-tab--on]="tab === 'passbook'" (click)="tab = 'passbook'">
<fa-icon icon="book"></fa-icon> Passbook
</button>
<button class="dav-tab" [class.dav-tab--on]="tab === 'nominees'" (click)="tab = 'nominees'">
<fa-icon icon="user"></fa-icon> Nominees
</button>
<!-- Pill-style tab navigation, as the savings account page has it -->
<nav mat-tab-nav-bar class="dav-tabs" [tabPanel]="tabPanel">
<a mat-tab-link class="dav-tab" [active]="tab === 'general'" (click)="tab = 'general'">
<fa-icon icon="info-circle" class="dav-tab__ic"></fa-icon>
<span>{{ 'labels.inputs.General' | translate }}</span>
</a>
<a mat-tab-link class="dav-tab" [active]="tab === 'transactions'" (click)="tab = 'transactions'">
<fa-icon icon="exchange-alt" class="dav-tab__ic"></fa-icon>
<span>{{ 'labels.inputs.Transactions' | translate }}</span>
</a>
<a mat-tab-link class="dav-tab" [active]="tab === 'passbook'" (click)="tab = 'passbook'">
<fa-icon icon="book" class="dav-tab__ic"></fa-icon>
<span>{{ 'labels.inputs.Passbook' | translate }}</span>
</a>
<a mat-tab-link class="dav-tab" [active]="tab === 'nominees'" (click)="tab = 'nominees'">
<fa-icon icon="user-shield" class="dav-tab__ic"></fa-icon>
<span>Nominees</span>
</a>
@if (account.status === 'CLOSED') {
<button class="dav-tab" [class.dav-tab--on]="tab === 'closure'" (click)="tab = 'closure'">
<fa-icon icon="lock"></fa-icon> Closure
</button>
<a mat-tab-link class="dav-tab" [active]="tab === 'closure'" (click)="tab = 'closure'">
<fa-icon icon="lock" class="dav-tab__ic"></fa-icon>
<span>Closure</span>
</a>
}
</div>
</nav>
<mat-tab-nav-panel #tabPanel class="dav-body">
<!-- Closure: how a closed account was closed and where its money went -->
@if (tab === 'closure') {
@if (!closure) {
@@ -172,7 +178,9 @@
</div>
}
} @else if (closure.destination === 'SUNDRY') {
<div class="dav-row"><span>Parked In</span><strong>Sundry (customer has no account here)</strong></div>
<div class="dav-row">
<span>Parked In</span><strong>Sundry (customer has no account here)</strong>
</div>
<div class="dav-row">
<span>GL Account</span
><strong class="dav-mono">{{ closure.sundryGl?.glCode }} · {{ closure.sundryGl?.glName }}</strong>
@@ -337,170 +345,356 @@
</div>
}
<!-- Transactions -->
<!-- Transactions: laid out as the savings account's Transactions tab -->
@if (tab === 'transactions') {
<div class="dav-card">
<div class="dav-toolbar">
<div class="dav-datepick">
<div class="txn-tab">
<div class="txn-tab__card" [hidden]="!hasTransactions">
<div class="txn-tab__toolbar">
<label class="txn-tab__date">
<span>From</span>
<input type="date" [formControl]="fromDateControl" [max]="today" />
</div>
<div class="dav-datepick">
</label>
<label class="txn-tab__date">
<span>To</span>
<input type="date" [formControl]="toDateControl" [max]="today" />
</div>
<button class="dav-btn" (click)="showToday()"><fa-icon icon="clock"></fa-icon> Today</button>
</label>
<button mat-stroked-button type="button" class="txn-tab__btn" (click)="showToday()">
<fa-icon icon="clock"></fa-icon> Today
</button>
@if (hasDateFilters) {
<button class="dav-btn" (click)="clearDateFilters()"><fa-icon icon="times"></fa-icon> Clear</button>
<button mat-stroked-button type="button" class="txn-tab__btn" (click)="clearDateFilters()">
<fa-icon icon="times"></fa-icon> {{ 'labels.buttons.Clear' | translate }}
</button>
}
<div class="dav-totals">
<span class="dav-cr">Credits ₹{{ totalCredits | formatNumber }}</span>
<span class="dav-dr">Debits ₹{{ totalDebits | formatNumber }}</span>
</div>
<button class="dav-btn dav-btn--primary" (click)="exportCsv()" [disabled]="shownTransactions.length === 0">
Export
<span class="txn-tab__spacer"></span>
<span class="txn-tab__total txn-tab__total--cr"
>Credits {{ totalCredits | currency: 'INR' : 'symbol-narrow' : '1.2-2' }}</span
>
<span class="txn-tab__total txn-tab__total--dr"
>Debits {{ totalDebits | currency: 'INR' : 'symbol-narrow' : '1.2-2' }}</span
>
<button
mat-flat-button
type="button"
class="txn-tab__export"
(click)="exportCsv()"
[disabled]="shownTransactions.length === 0"
>
{{ 'labels.buttons.Export' | translate }}
</button>
</div>
@if (shownTransactions.length === 0) {
<div class="dav-state">
<fa-icon icon="inbox" size="2x"></fa-icon>
<span>{{
hasDateFilters
? 'Nothing moved on this account in these dates'
: 'Nothing has been credited to this account yet'
}}</span>
</div>
} @else {
<div class="dav-table-wrap">
<table class="dav-table">
<thead>
<tr>
<th>#</th>
<th>ID</th>
<th>Transaction Date</th>
<th>Transaction Type</th>
<th>Voucher</th>
<th>Description</th>
<th class="dav-right">Debit</th>
<th class="dav-right">Credit</th>
<th class="dav-right">Balance</th>
</tr>
</thead>
<tbody>
@for (txn of shownTransactions; track txn.id; let i = $index) {
<tr>
<td>{{ i + 1 }}</td>
<td>{{ txn.id }}</td>
<td>{{ txn.transactionDate | date: 'dd MMMM yyyy' }}</td>
<td>{{ label(txn.transactionType) }}</td>
<td class="dav-mono">{{ txn.voucherNo || '—' }}</td>
<td class="dav-desc">{{ txn.description }}</td>
<td class="dav-right">
{{ txn.direction === 'DEBIT' ? '₹' + (txn.amount | formatNumber) : 'N/A' }}
<table mat-table [dataSource]="txnDataSource" matSort>
<ng-container matColumnDef="row">
<th mat-header-cell *matHeaderCellDef mat-sort-header>#</th>
<td mat-cell *matCellDef="let txn; let idx = index" (click)="viewTransaction(txn)">
{{ idx + 1 }}
</td>
<td class="dav-right">
{{ txn.direction === 'CREDIT' ? '₹' + (txn.amount | formatNumber) : 'N/A' }}
</ng-container>
<ng-container matColumnDef="id">
<th mat-header-cell *matHeaderCellDef mat-sort-header>{{ 'labels.inputs.Id' | translate }}</th>
<td
mat-cell
*matCellDef="let txn"
class="td-select"
[ngClass]="txnRowClass(txn)"
(click)="viewTransaction(txn)"
>
{{ txn.id }}
</td>
<td class="dav-right">
<strong>₹{{ txn.balanceAfterTransaction || 0 | formatNumber }}</strong>
</ng-container>
<ng-container matColumnDef="date">
<th mat-header-cell *matHeaderCellDef mat-sort-header>
{{ 'labels.inputs.Transaction Date' | translate }}
</th>
<td mat-cell *matCellDef="let txn" [ngClass]="txnRowClass(txn)" (click)="viewTransaction(txn)">
{{ txn.transactionDate | dateFormat }}
</td>
</tr>
}
</tbody>
</table>
</div>
}
</div>
}
</ng-container>
<!-- Passbook: the same entries oldest first, the way a passbook is written up, and printable -->
@if (tab === 'passbook') {
<div class="dav-card">
<div class="dav-toolbar dav-noprint">
<div class="dav-datepick">
<span>From</span>
<input type="date" [formControl]="fromDateControl" [max]="today" />
</div>
<div class="dav-datepick">
<span>To</span>
<input type="date" [formControl]="toDateControl" [max]="today" />
</div>
@if (hasDateFilters) {
<button class="dav-btn" (click)="clearDateFilters()"><fa-icon icon="times"></fa-icon> Clear</button>
}
<button class="dav-btn dav-btn--primary dav-push" (click)="printPassbook()">
<fa-icon icon="print"></fa-icon> Print
</button>
</div>
<div class="dav-passbook" id="dds-passbook">
<div class="dav-pb-head">
<div>
<div class="dav-pb-title">DDS Passbook</div>
<div class="dav-pb-sub">{{ account.officeName }}</div>
</div>
<div class="dav-pb-grid">
<span>Account No</span><strong class="dav-mono">{{ account.accountNo }}</strong> <span>Party Code</span
><strong>{{ account.partyCode }}</strong> <span>Name</span
><strong>{{ account.clientName || account.partyName }}</strong> <span>Product</span
><strong>{{ account.subType }} · {{ account.interestRate }} %</strong> <span>Opened On</span
><strong>{{ account.openingDate | date: 'dd/MM/yyyy' }}</strong> <span>Agent</span
><strong>{{ account.agentCode }}</strong>
</div>
</div>
@if (passbookEntries.length === 0) {
<div class="dav-state">
<span>{{ hasDateFilters ? 'No entries in these dates' : 'No entries yet' }}</span>
</div>
} @else {
<table class="dav-table dav-pb-table">
<thead>
<tr>
<th>Date</th>
<th>Voucher</th>
<th>Particulars</th>
<th class="dav-right">Withdrawals</th>
<th class="dav-right">Deposits</th>
<th class="dav-right">Balance</th>
</tr>
</thead>
<tbody>
@for (txn of passbookEntries; track txn.id) {
<tr>
<td>{{ txn.transactionDate | date: 'dd/MM/yyyy' }}</td>
<td class="dav-mono">{{ txn.voucherNo || '' }}</td>
<td>
<ng-container matColumnDef="transactionType">
<th mat-header-cell *matHeaderCellDef mat-sort-header>
{{ 'labels.inputs.Transaction Type' | translate }}
</th>
<td mat-cell *matCellDef="let txn" [ngClass]="txnRowClass(txn)" (click)="viewTransaction(txn)">
{{ label(txn.transactionType) }}
@if (txn.description) {
<span class="dav-pb-note">{{ txn.description }}</span>
</td>
</ng-container>
<ng-container matColumnDef="debit">
<th mat-header-cell *matHeaderCellDef mat-sort-header>{{ 'labels.inputs.Debit' | translate }}</th>
<td mat-cell *matCellDef="let txn" [ngClass]="txnRowClass(txn)" (click)="viewTransaction(txn)">
{{ txn.direction === 'DEBIT' ? (txn.amount | currency: 'INR' : 'symbol-narrow' : '1.2-2') : 'N/A' }}
</td>
</ng-container>
<ng-container matColumnDef="credit">
<th mat-header-cell *matHeaderCellDef mat-sort-header>{{ 'labels.inputs.Credit' | translate }}</th>
<td mat-cell *matCellDef="let txn" [ngClass]="txnRowClass(txn)" (click)="viewTransaction(txn)">
{{ txn.direction === 'CREDIT' ? (txn.amount | currency: 'INR' : 'symbol-narrow' : '1.2-2') : 'N/A' }}
</td>
</ng-container>
<ng-container matColumnDef="balance">
<th mat-header-cell *matHeaderCellDef mat-sort-header>{{ 'labels.inputs.Balance' | translate }} ↑</th>
<td mat-cell *matCellDef="let txn" [ngClass]="txnRowClass(txn)" (click)="viewTransaction(txn)">
{{ txn.balanceAfterTransaction || 0 | currency: 'INR' : 'symbol-narrow' : '1.2-2' }}
</td>
</ng-container>
<ng-container matColumnDef="actions">
<th mat-header-cell class="center" *matHeaderCellDef>{{ 'labels.inputs.Actions' | translate }}</th>
<td mat-cell class="center" *matCellDef="let txn">
<button mat-icon-button [matMenuTriggerFor]="txnMenu" aria-label="Transaction actions">
<mat-icon>more_vert</mat-icon>
</button>
<mat-menu #txnMenu="matMenu">
<button mat-menu-item (click)="viewTransaction(txn)">
<mat-icon><fa-icon icon="eye" size="sm"></fa-icon></mat-icon>
<span>{{ 'labels.text.View Transaction' | translate }}</span>
</button>
<button mat-menu-item (click)="viewTransaction(txn, true)">
<mat-icon><fa-icon icon="receipt" size="sm"></fa-icon></mat-icon>
<span>{{ 'tooltips.View Receipts' | translate }}</span>
</button>
<button mat-menu-item [disabled]="!txn.journalTransactionId" (click)="viewJournalEntries(txn)">
<mat-icon><fa-icon icon="table-cells" size="sm"></fa-icon></mat-icon>
<span>{{ 'tooltips.View Journal Entries' | translate }}</span>
</button>
</mat-menu>
</td>
</ng-container>
<tr mat-header-row *matHeaderRowDef="txnColumns"></tr>
<tr mat-row *matRowDef="let row; columns: txnColumns" class="select-row"></tr>
<tr class="mat-mdc-row" *matNoDataRow>
<td class="mat-mdc-cell txn-tab__nodata" [attr.colspan]="txnColumns.length">
Nothing moved on this account in these dates
</td>
</tr>
</table>
<mat-paginator [pageSize]="50" [pageSizeOptions]="[50, 100, 200]" showFirstLastButtons></mat-paginator>
</div>
<div class="txn-tab__empty" [hidden]="hasTransactions">
<fa-icon icon="inbox" class="txn-tab__empty-ico"></fa-icon>
<div class="txn-tab__empty-title">
{{ 'labels.text.No transaction was found' | translate }}
</div>
<div class="txn-tab__empty-sub">Collections, interest and payouts will appear here.</div>
</div>
</div>
}
<!-- Passbook: laid out as the savings account's Passbook tab - oldest first, the way it is written up -->
@if (tab === 'passbook') {
<div class="passbook-container">
<div class="passbook-filter-bar no-print">
<div class="passbook-filter-form">
<mat-form-field appearance="outline" class="passbook-date-field" subscriptSizing="dynamic">
<mat-label>{{ 'labels.inputs.From Date' | translate }}</mat-label>
<input matInput type="date" [formControl]="fromDateControl" [max]="today" />
</mat-form-field>
<mat-form-field appearance="outline" class="passbook-date-field" subscriptSizing="dynamic">
<mat-label>{{ 'labels.inputs.To Date' | translate }}</mat-label>
<input matInput type="date" [formControl]="toDateControl" [max]="today" />
</mat-form-field>
@if (hasDateFilters) {
<button mat-stroked-button type="button" (click)="clearDateFilters()">
<mat-icon>clear</mat-icon>
{{ 'labels.buttons.Clear' | translate }}
</button>
}
<span class="passbook-filter-spacer"></span>
<button mat-raised-button color="accent" type="button" (click)="printPassbook()">
<mat-icon>print</mat-icon>
{{ 'labels.buttons.Print' | translate }}
</button>
</div>
</div>
<div class="statement-wrapper" id="dds-passbook">
<!-- Bank Header -->
<div class="bank-header">
<div class="bank-logo-area">
<div class="bank-logo-circle">
<mat-icon>account_balance</mat-icon>
</div>
<div class="bank-name-block">
<div class="bank-title">{{ bankName }}</div>
<div class="bank-subtitle">{{ bankSubtitle }}</div>
</div>
</div>
<div class="statement-label">
<div class="stmt-label-text">DDS PASSBOOK</div>
<div class="stmt-period">As on: {{ today | dateFormat }}</div>
</div>
</div>
<!-- Branch line -->
@if (branchName || ifscCode || micrCode) {
<div class="branch-line">
@if (branchName) {
<span>{{ branchName }} Branch</span>
}
@if (branchAddress) {
<span>{{ branchAddress }}</span>
}
@if (ifscCode) {
<span>IFSC: {{ ifscCode }}</span>
}
@if (micrCode) {
<span>MICR: {{ micrCode }}</span>
}
</div>
}
<!-- Account Details -->
<div class="account-details-band">
<div class="account-details-grid">
<div class="detail-block">
<div class="detail-label">Account Holder</div>
<div class="detail-value bold">{{ account.clientName || account.partyName | uppercase }}</div>
</div>
<div class="detail-block">
<div class="detail-label">Account Number</div>
<div class="detail-value mono bold">{{ account.accountNo }}</div>
</div>
<div class="detail-block">
<div class="detail-label">Party Code</div>
<div class="detail-value mono">{{ account.partyCode }}</div>
</div>
<div class="detail-block">
<div class="detail-label">Product</div>
<div class="detail-value">{{ account.subType }}</div>
</div>
<div class="detail-block">
<div class="detail-label">Account Opening Date</div>
<div class="detail-value">{{ account.openingDate | dateFormat }}</div>
</div>
<div class="detail-block">
<div class="detail-label">Status</div>
<div class="detail-value status-badge" [class.active]="account.status === 'ACTIVE'">
{{ account.status }}
</div>
</div>
<div class="detail-block">
<div class="detail-label">Agent</div>
<div class="detail-value">
{{ account.agentCode }}{{ account.agentName ? ' · ' + account.agentName : '' }}
</div>
</div>
<div class="detail-block">
<div class="detail-label">Interest Rate (p.a.)</div>
<div class="detail-value">{{ account.interestRate }}%</div>
</div>
<div class="detail-block">
<div class="detail-label">Maturity Date</div>
<div class="detail-value">{{ account.maturityDate | dateFormat }}</div>
</div>
</div>
</div>
<!-- Balance Summary Strip -->
<div class="balance-summary-strip">
<div class="balance-box">
<div class="balance-box-label">Current Balance</div>
<div class="balance-box-value">
{{ account.currentBalance || 0 | currency: 'INR' : 'symbol-narrow' : '1.2-2' }}
</div>
</div>
<div class="balance-divider"></div>
<div class="balance-box">
<div class="balance-box-label">Maturity Amount</div>
<div class="balance-box-value">
{{ account.maturityAmount || 0 | currency: 'INR' : 'symbol-narrow' : '1.2-2' }}
</div>
</div>
<div class="balance-divider"></div>
<div class="balance-box">
<div class="balance-box-label">Total Transactions</div>
<div class="balance-box-value">{{ passbookEntries.length }}</div>
</div>
</div>
<!-- Transactions Table -->
@if (passbookEntries.length > 0) {
<table class="statement-table">
<thead>
<tr>
<th class="col-sr">#</th>
<th class="col-date">Date</th>
<th class="col-desc">Description</th>
<th class="col-ref">Ref No.</th>
<th class="col-amount text-right">Debit (Dr)</th>
<th class="col-amount text-right">Credit (Cr)</th>
<th class="col-balance text-right">Balance (&#8377;)</th>
</tr>
</thead>
<tbody>
@for (txn of passbookEntries; track txn.id; let i = $index) {
<tr [class.debit-row]="txn.direction === 'DEBIT'" [class.credit-row]="txn.direction === 'CREDIT'">
<td class="col-sr text-center">{{ i + 1 }}</td>
<td class="col-date">{{ txn.transactionDate | dateFormat }}</td>
<td class="col-desc">
<span class="txn-desc">{{ label(txn.transactionType) }}</span>
</td>
<td class="col-ref text-muted mono">{{ txn.refNo || txn.id }}</td>
<td class="col-amount text-right debit-amount">
@if (txn.direction === 'DEBIT') {
{{ txn.amount | currency: 'INR' : 'symbol-narrow' : '1.2-2' }}
}
</td>
<td class="dav-right">{{ txn.direction === 'DEBIT' ? (txn.amount | formatNumber) : '' }}</td>
<td class="dav-right">{{ txn.direction === 'CREDIT' ? (txn.amount | formatNumber) : '' }}</td>
<td class="dav-right">
<strong>{{ txn.balanceAfterTransaction || 0 | formatNumber }}</strong>
<td class="col-amount text-right credit-amount">
@if (txn.direction === 'CREDIT') {
{{ txn.amount | currency: 'INR' : 'symbol-narrow' : '1.2-2' }}
}
</td>
<td class="col-balance text-right balance-amount">
{{ txn.balanceAfterTransaction || 0 | currency: 'INR' : 'symbol-narrow' : '1.2-2' }}
</td>
</tr>
}
</tbody>
<tfoot>
<tr>
<td colspan="3"><strong>Total</strong></td>
<td class="dav-right">
<strong>{{ totalDebits | formatNumber }}</strong>
<tr class="total-row">
<td colspan="4" class="text-right total-label">Total / Closing Balance</td>
<td class="text-right total-label debit-amount">
{{ totalDebits | currency: 'INR' : 'symbol-narrow' : '1.2-2' }}
</td>
<td class="dav-right">
<strong>{{ totalCredits | formatNumber }}</strong>
<td class="text-right total-label credit-amount">
{{ totalCredits | currency: 'INR' : 'symbol-narrow' : '1.2-2' }}
</td>
<td class="dav-right">
<strong>{{ passbookClosingBalance | formatNumber }}</strong>
<td class="text-right closing-balance">
{{ passbookClosingBalance | currency: 'INR' : 'symbol-narrow' : '1.2-2' }}
</td>
</tr>
</tfoot>
</table>
} @else {
<div class="no-data-block">
<mat-icon>inbox</mat-icon>
<p>{{ hasDateFilters ? 'No transactions found for the selected date range.' : 'No entries yet.' }}</p>
</div>
}
<!-- Statement Footer -->
<div class="statement-footer">
<div class="footer-note">
This is a computer-generated statement and does not require a signature. For queries, contact your
branch or call customer care.
</div>
<div class="footer-meta">
Generated on: {{ today | dateFormat }} &nbsp;|&nbsp; Account No: {{ account.accountNo }}
</div>
</div>
</div>
</div>
}
@@ -543,7 +737,9 @@
<td>{{ nominee.nominationDate ? (nominee.nominationDate | date: 'dd MMM yyyy') : '—' }}</td>
<td>{{ nominee.guardianName || '—' }}</td>
<td class="dav-right">
{{ account.nomineeSplitMode === 'Sequence' ? nominee.sequenceNo : (nominee.sharePercent ?? '—') }}
{{
account.nomineeSplitMode === 'Sequence' ? nominee.sequenceNo : (nominee.sharePercent ?? '—')
}}
</td>
</tr>
}
@@ -553,5 +749,6 @@
}
</div>
}
</mat-tab-nav-panel>
}
</div>
@@ -157,45 +157,89 @@
}
}
/* ── Tabs ─────────────────────────────────────────────────────────────── */
/* ── Tabs: the savings account page's pill tabs ───────────────────────── */
$tab-navy: #0d1b2a;
$tab-blue: #1e88e5;
$tab-blue-d: #1e40af;
$tab-border: #e2e8f0;
.dav-tabs {
display: flex;
gap: 8px;
padding: 8px;
border-radius: 14px;
background: #fff;
border: 1px solid rgb(0 0 0 / 7%);
border: 1px solid $tab-border;
border-radius: 14px;
padding: 6px;
box-shadow: 0 1px 3px rgb(15 23 42 / 4%);
overflow: hidden;
::ng-deep {
.mat-mdc-tab-link-container {
border-bottom: none !important;
}
.mat-mdc-tab-links {
gap: 4px;
flex-wrap: wrap;
}
.mdc-tab-indicator__content--underline,
.mat-mdc-tab-header-pagination-controls-enabled .mdc-tab-indicator__content--underline {
display: none !important;
}
}
}
.dav-tab {
flex: 1;
display: flex;
display: inline-flex !important;
align-items: center;
justify-content: center;
gap: 6px;
padding: 10px 16px;
border: 0;
border-radius: 10px;
background: transparent;
font-size: 0.95rem;
font-weight: 600;
color: #475569;
gap: 8px;
height: 38px !important;
min-width: 0 !important;
padding: 0 16px !important;
border-radius: 10px !important;
font-family: Inter, 'DM Sans', Roboto, sans-serif !important;
font-size: 13px !important;
font-weight: 600 !important;
letter-spacing: 0.01em !important;
color: #64748b !important;
background: transparent !important;
transition: all 0.15s ease;
white-space: nowrap;
border: 1px solid transparent !important;
opacity: 1 !important;
cursor: pointer;
&:hover {
background: #f1f5f9;
&__ic {
font-size: 12px;
color: inherit;
}
&:hover:not(.mdc-tab--active) {
background: #f1f5f9 !important;
color: $tab-navy !important;
}
&.mdc-tab--active,
&.mat-mdc-tab-link-active {
background: linear-gradient(135deg, $tab-blue 0%, $tab-blue-d 100%) !important;
color: #fff !important;
box-shadow: 0 2px 6px -1px rgb(30 64 175 / 35%);
border-color: $tab-blue-d !important;
::ng-deep .mdc-tab__text-label,
::ng-deep .mat-mdc-tab-link-content,
::ng-deep span,
.dav-tab__ic {
color: #fff !important;
opacity: 1 !important;
}
}
}
.dav-tab--on {
color: #fff;
background: linear-gradient(90deg, #1d4ed8, #2563eb);
box-shadow: 0 4px 12px rgb(37 99 235 / 30%);
&:hover {
background: linear-gradient(90deg, #1d4ed8, #2563eb);
}
.dav-body {
display: flex;
flex-direction: column;
gap: 16px;
}
/* ── Cards ────────────────────────────────────────────────────────────── */
@@ -269,38 +313,7 @@
color: rgb(0 0 0 / 50%);
}
/* ── Transactions ─────────────────────────────────────────────────────── */
.dav-toolbar {
display: flex;
align-items: center;
flex-wrap: wrap;
gap: 12px;
padding: 14px 20px;
background: #f8fafc;
border-bottom: 1px solid rgb(0 0 0 / 7%);
}
.dav-datepick {
display: flex;
flex-direction: column;
gap: 2px;
span {
font-size: 0.68rem;
font-weight: 700;
letter-spacing: 0.06em;
text-transform: uppercase;
color: rgb(0 0 0 / 45%);
}
input {
padding: 6px 10px;
border-radius: 8px;
border: 1px solid rgb(0 0 0 / 15%);
font: inherit;
}
}
/* ── Shared tables and buttons (nominees, not-found state) ─────────── */
.dav-btn {
display: inline-flex;
@@ -319,21 +332,6 @@
}
}
.dav-btn--primary {
color: #fff;
border-color: transparent;
background: linear-gradient(90deg, #1d4ed8, #2563eb);
}
.dav-totals {
margin-left: auto;
display: flex;
gap: 16px;
font-size: 0.88rem;
font-weight: 700;
font-variant-numeric: tabular-nums;
}
.dav-table-wrap {
overflow-x: auto;
}
@@ -366,11 +364,6 @@
}
}
.dav-desc {
max-width: 320px;
color: rgb(0 0 0 / 65%);
}
@media (width <= 720px) {
.dav-page {
padding: 16px;
@@ -382,66 +375,11 @@
}
.dav-tab {
font-size: 0.85rem;
padding: 8px;
padding: 0 12px !important;
font-size: 12px !important;
}
}
/* ── Passbook ─────────────────────────────────────────────────────────── */
.dav-push {
margin-left: auto;
}
.dav-passbook {
padding: 20px;
}
.dav-pb-head {
display: flex;
justify-content: space-between;
flex-wrap: wrap;
gap: 16px 32px;
margin-bottom: 16px;
padding-bottom: 16px;
border-bottom: 2px solid #1e3a8a;
}
.dav-pb-title {
font-size: 1.2rem;
font-weight: 700;
color: #1e3a8a;
}
.dav-pb-sub {
font-size: 0.85rem;
color: rgb(0 0 0 / 55%);
}
.dav-pb-grid {
display: grid;
grid-template-columns: auto auto;
gap: 4px 16px;
font-size: 0.88rem;
span {
color: rgb(0 0 0 / 55%);
}
}
.dav-pb-table {
tfoot td {
padding: 14px 16px;
border-top: 2px solid rgb(0 0 0 / 15%);
}
}
.dav-pb-note {
display: block;
font-size: 0.78rem;
color: rgb(0 0 0 / 50%);
}
/* Closure tab */
.dav-closure-tag {
margin-left: 8px;
@@ -457,3 +395,629 @@
border-top: 1px solid rgb(0 0 0 / 10%);
font-weight: 600;
}
/* ── Transactions: the savings account's Transactions tab ─────────────── */
$txn-navy: #0d1b2a;
$txn-slate: #475569;
$txn-muted: #64748b;
$txn-border: #e2e8f0;
$txn-soft: #f8fafc;
$txn-blue: #1e40af;
.txn-tab {
width: 100%;
table {
width: 100%;
}
}
.txn-tab__card {
background: #fff;
border: 1px solid $txn-border;
border-radius: 14px;
box-shadow: 0 1px 3px rgb(15 23 42 / 4%);
overflow: hidden;
}
.txn-tab__toolbar {
display: flex;
align-items: center;
gap: 18px;
padding: 12px 18px;
background: $txn-soft;
border-bottom: 1px solid $txn-border;
flex-wrap: wrap;
}
.txn-tab__date {
display: flex;
align-items: center;
gap: 8px;
font-size: 13px;
color: $txn-slate;
font-weight: 500;
input {
height: 34px;
padding: 0 10px;
border-radius: 8px;
border: 1px solid $txn-border;
background: #fff;
font: inherit;
color: $txn-navy;
}
}
.txn-tab__btn {
height: 34px !important;
border-radius: 8px !important;
font-size: 13px !important;
font-weight: 600 !important;
fa-icon {
margin-right: 4px;
}
}
.txn-tab__spacer {
flex: 1;
}
.txn-tab__total {
font-size: 13px;
font-weight: 700;
font-variant-numeric: tabular-nums;
}
.txn-tab__total--cr {
color: #1a7a3f;
}
.txn-tab__total--dr {
color: #c0392b;
}
.txn-tab__export {
height: 36px !important;
border-radius: 8px !important;
font-weight: 600 !important;
font-size: 13px !important;
padding: 0 16px !important;
background: linear-gradient(135deg, #1e88e5 0%, $txn-blue 100%) !important;
color: #fff !important;
&:disabled {
opacity: 0.5;
}
}
::ng-deep .txn-tab__card {
.mat-mdc-header-row {
background: $txn-soft;
}
.mat-mdc-header-cell {
font-size: 11.5px;
font-weight: 800;
color: $txn-slate;
text-transform: uppercase;
letter-spacing: 0.06em;
padding: 12px 14px;
border-bottom: 1px solid $txn-border;
}
.mat-mdc-cell {
font-size: 13px;
color: $txn-navy;
padding: 11px 14px;
border-bottom: 1px solid #f1f5f9;
}
.mat-mdc-row:hover {
background: #f8fafc;
}
.mat-column-debit,
.mat-column-credit,
.mat-column-balance {
text-align: right;
font-variant-numeric: tabular-nums;
}
.mat-mdc-paginator {
border-top: 1px solid $txn-border;
background: $txn-soft;
min-height: 48px;
}
.transfer {
color: #6d28d9;
}
.txn-tab__nodata {
padding: 32px 14px;
text-align: center;
color: $txn-muted;
}
}
.txn-tab__empty {
background: #fff;
border: 1px solid $txn-border;
border-radius: 14px;
box-shadow: 0 1px 3px rgb(15 23 42 / 4%);
padding: 56px 24px;
text-align: center;
color: $txn-muted;
}
.txn-tab__empty-ico {
font-size: 34px;
color: #cbd5e1;
margin-bottom: 8px;
}
.txn-tab__empty-title {
font-size: 14.5px;
font-weight: 700;
color: $txn-slate;
margin-top: 6px;
}
.txn-tab__empty-sub {
font-size: 12.5px;
color: $txn-muted;
margin-top: 2px;
}
/* ── Passbook: the savings account's Passbook tab ─────────────────────── */
$bank-primary: #1a3a6b;
$bank-accent: #c8a951;
$debit-color: #c0392b;
$credit-color: #1a7a3f;
$balance-color: #1a3a6b;
$border-color: #d0d7e3;
$row-alt: #f4f7fb;
.passbook-container {
padding: 16px;
background: #eef1f7;
border-radius: 14px;
min-height: 100%;
}
.passbook-filter-bar {
margin-bottom: 16px;
background: #fff;
border-radius: 8px;
padding: 12px 16px;
border: 1px solid $border-color;
}
.passbook-filter-form {
display: flex;
align-items: center;
gap: 12px;
flex-wrap: wrap;
}
.passbook-date-field {
width: 180px;
}
.passbook-filter-spacer {
flex: 1;
}
.statement-wrapper {
background: #fff;
border: 1px solid $border-color;
border-radius: 8px;
overflow: hidden;
box-shadow: 0 2px 12px rgb(0 0 0 / 8%);
font-family: 'Segoe UI', Arial, sans-serif;
}
.bank-header {
background: $bank-primary;
padding: 18px 24px;
display: flex;
justify-content: space-between;
align-items: center;
border-bottom: 4px solid $bank-accent;
}
.bank-logo-area {
display: flex;
align-items: center;
gap: 14px;
}
.bank-logo-circle {
width: 52px;
height: 52px;
border-radius: 50%;
background: $bank-accent;
display: flex;
align-items: center;
justify-content: center;
mat-icon {
color: $bank-primary;
font-size: 28px;
width: 28px;
height: 28px;
}
}
.bank-name-block {
.bank-title {
color: #fff;
font-size: 18px;
font-weight: 700;
letter-spacing: 0.3px;
}
.bank-subtitle {
color: $bank-accent;
font-size: 11px;
font-weight: 500;
letter-spacing: 1.5px;
text-transform: uppercase;
margin-top: 2px;
}
}
.statement-label {
text-align: right;
.stmt-label-text {
color: $bank-accent;
font-size: 13px;
font-weight: 700;
letter-spacing: 2px;
text-transform: uppercase;
}
.stmt-period {
color: rgb(255 255 255 / 75%);
font-size: 11px;
margin-top: 4px;
}
}
.branch-line {
background: #e8edf5;
border-bottom: 1px solid $border-color;
padding: 6px 24px;
display: flex;
flex-wrap: wrap;
gap: 4px 18px;
font-size: 11px;
color: #4a5a7a;
}
.account-details-band {
background: #f0f3f9;
border-bottom: 1px solid $border-color;
padding: 14px 24px;
}
.account-details-grid {
display: grid;
grid-template-columns: repeat(3, 1fr);
gap: 12px 24px;
}
.detail-block {
.detail-label {
font-size: 9px;
font-weight: 700;
color: #7a8aaa;
text-transform: uppercase;
letter-spacing: 0.8px;
margin-bottom: 3px;
}
.detail-value {
font-size: 13px;
color: #222;
&.bold {
font-weight: 600;
}
&.mono {
font-family: 'Courier New', monospace;
letter-spacing: 1px;
}
&.status-badge {
display: inline-block;
padding: 1px 8px;
border-radius: 10px;
font-size: 11px;
font-weight: 600;
background: #eee;
color: #555;
&.active {
background: #d4edda;
color: #155724;
}
}
}
}
.balance-summary-strip {
display: flex;
align-items: stretch;
background: $bank-primary;
padding: 0;
}
.balance-box {
flex: 1;
padding: 12px 20px;
text-align: center;
.balance-box-label {
font-size: 10px;
font-weight: 600;
color: rgb(255 255 255 / 65%);
text-transform: uppercase;
letter-spacing: 1px;
margin-bottom: 4px;
}
.balance-box-value {
font-size: 16px;
font-weight: 700;
color: $bank-accent;
}
}
.balance-divider {
width: 1px;
background: rgb(255 255 255 / 15%);
margin: 8px 0;
}
.statement-table {
width: 100%;
border-collapse: collapse;
font-size: 12.5px;
thead tr {
background: #e8edf5;
border-top: 1px solid $border-color;
border-bottom: 2px solid $bank-primary;
}
thead th {
padding: 9px 10px;
font-size: 11px;
font-weight: 700;
color: $bank-primary;
text-transform: uppercase;
letter-spacing: 0.4px;
white-space: nowrap;
}
tbody tr {
border-bottom: 1px solid #e8ecf3;
transition: background 0.1s;
&:nth-child(even) {
background: $row-alt;
}
&:hover {
background: #dce6f5;
}
}
tbody td {
padding: 7px 10px;
vertical-align: middle;
color: #333;
}
tfoot .total-row {
background: #e8edf5;
border-top: 2px solid $bank-primary;
td {
padding: 9px 10px;
}
}
}
.col-sr {
width: 38px;
}
.col-date {
width: 110px;
white-space: nowrap;
}
.col-desc {
min-width: 160px;
}
.col-ref {
width: 100px;
}
.col-amount {
width: 110px;
}
.col-balance {
width: 120px;
}
.debit-amount {
color: $debit-color;
font-weight: 500;
}
.credit-amount {
color: $credit-color;
font-weight: 500;
}
.balance-amount {
color: $balance-color;
font-weight: 600;
font-family: 'Courier New', monospace;
}
.total-label {
font-size: 12px;
font-weight: 700;
color: $bank-primary;
text-transform: uppercase;
letter-spacing: 0.5px;
}
.closing-balance {
font-size: 14px;
font-weight: 700;
color: $bank-primary;
font-family: 'Courier New', monospace;
}
.txn-desc {
font-weight: 500;
}
.text-right {
text-align: right;
}
.text-center {
text-align: center;
}
.text-muted {
color: #999;
}
.mono {
font-family: 'Courier New', monospace;
font-size: 11px;
}
.no-data-block {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
padding: 48px;
color: #aaa;
mat-icon {
font-size: 48px;
width: 48px;
height: 48px;
margin-bottom: 12px;
}
p {
font-size: 14px;
margin: 0;
}
}
.statement-footer {
background: #f0f3f9;
border-top: 1px solid $border-color;
padding: 12px 24px;
text-align: center;
.footer-note {
font-size: 10px;
color: #888;
margin-bottom: 4px;
font-style: italic;
}
.footer-meta {
font-size: 10px;
color: #aaa;
letter-spacing: 0.3px;
}
}
@media (width <= 768px) {
.account-details-grid {
grid-template-columns: 1fr 1fr;
}
.bank-header {
flex-direction: column;
gap: 12px;
align-items: flex-start;
}
.statement-label {
text-align: left;
}
.balance-summary-strip {
flex-direction: column;
}
.balance-divider {
width: auto;
height: 1px;
margin: 0 20px;
}
.passbook-filter-form {
flex-direction: column;
align-items: stretch;
}
.passbook-date-field {
width: 100%;
}
.passbook-filter-spacer {
display: none;
}
}
@media (width <= 480px) {
.account-details-grid {
grid-template-columns: 1fr;
}
}
@media print {
.no-print {
display: none !important;
}
.passbook-container {
background: #fff;
padding: 0;
}
.statement-wrapper {
border: none;
box-shadow: none;
border-radius: 0;
}
.bank-header,
.balance-summary-strip {
-webkit-print-color-adjust: exact;
print-color-adjust: exact;
}
}
@@ -6,14 +6,37 @@
* file, You can obtain one at http://mozilla.org/MPL/2.0/.
*/
import { Component, OnInit, inject } from '@angular/core';
import { Component, DestroyRef, OnInit, ViewChild, inject } from '@angular/core';
import { takeUntilDestroyed } from '@angular/core/rxjs-interop';
import { ActivatedRoute, Router } from '@angular/router';
import { FormControl, ReactiveFormsModule } from '@angular/forms';
import { NgClass } from '@angular/common';
import { CurrencyPipe, NgClass } from '@angular/common';
import { MatTooltipModule } from '@angular/material/tooltip';
import { MatProgressSpinnerModule } from '@angular/material/progress-spinner';
import { MatTabLink, MatTabNav, MatTabNavPanel } from '@angular/material/tabs';
import {
MatCell,
MatCellDef,
MatColumnDef,
MatHeaderCell,
MatHeaderCellDef,
MatHeaderRow,
MatHeaderRowDef,
MatNoDataRow,
MatRow,
MatRowDef,
MatTable,
MatTableDataSource
} from '@angular/material/table';
import { MatPaginator } from '@angular/material/paginator';
import { MatSort, MatSortHeader } from '@angular/material/sort';
import { MatIcon } from '@angular/material/icon';
import { MatIconButton } from '@angular/material/button';
import { MatMenu, MatMenuItem, MatMenuTrigger } from '@angular/material/menu';
import { MatDialog } from '@angular/material/dialog';
import { DdsTransactionViewDialogComponent } from './dds-transaction-view-dialog/dds-transaction-view-dialog.component';
import { FaIconComponent } from '@fortawesome/angular-fontawesome';
import { forkJoin, of } from 'rxjs';
import { forkJoin, merge, of } from 'rxjs';
import { catchError } from 'rxjs/operators';
import { STANDALONE_SHARED_IMPORTS } from 'app/standalone-shared.module';
import { FormatNumberPipe } from 'app/pipes/format-number.pipe';
@@ -52,6 +75,29 @@ const TRANSACTION_LABELS: Record<string, string> = {
FaIconComponent,
MatTooltipModule,
MatProgressSpinnerModule,
MatTabNav,
MatTabLink,
MatTabNavPanel,
MatTable,
MatColumnDef,
MatHeaderCellDef,
MatHeaderCell,
MatCellDef,
MatCell,
MatHeaderRowDef,
MatHeaderRow,
MatRowDef,
MatRow,
MatNoDataRow,
MatSort,
MatSortHeader,
MatPaginator,
MatIcon,
MatIconButton,
MatMenu,
MatMenuItem,
MatMenuTrigger,
CurrencyPipe,
FormatNumberPipe
]
})
@@ -59,6 +105,8 @@ export class DdsAccountViewComponent implements OnInit {
private route = inject(ActivatedRoute);
private router = inject(Router);
private organizationService = inject(OrganizationService);
private destroyRef = inject(DestroyRef);
private dialog = inject(MatDialog);
isLoading = true;
account: any = null;
@@ -79,6 +127,40 @@ export class DdsAccountViewComponent implements OnInit {
CLOSED_NIL_BALANCE: 'Closed with nil balance'
};
/** Transactions tab table, laid out as the savings account's: oldest first, 50 to a page, sortable. */
readonly txnColumns: string[] = [
'row',
'id',
'date',
'transactionType',
'debit',
'credit',
'balance',
'actions'
];
readonly txnDataSource = new MatTableDataSource<any>([]);
/** The paginator and sort only exist while the Transactions tab is open, so they are re-attached each time. */
@ViewChild(MatPaginator) set txnPaginator(paginator: MatPaginator | undefined) {
this.txnDataSource.paginator = paginator ?? null;
}
@ViewChild(MatSort) set txnSort(sort: MatSort | undefined) {
this.txnDataSource.sort = sort ?? null;
}
/**
* Passbook letterhead - the bank from Institution Profile and the issuing branch from Branch Profile,
* as the savings passbook takes them. Falls back to a generic label when the profile was never filled in.
*/
bankName = 'Bank';
bankSubtitle = 'Account Statement';
branchName = '';
branchAddress = '';
ifscCode = '';
micrCode = '';
/** The passbook reads oldest first, as it is written up. */
get passbookEntries(): any[] {
return [...this.shownTransactions].reverse();
@@ -92,37 +174,140 @@ export class DdsAccountViewComponent implements OnInit {
/**
* Prints the passbook alone. It is copied into a window of its own with only its own styles, so the
* page's menus, banner and tabs do not come out on the paper.
* page's menus, banner and tabs do not come out on the paper - the same way the savings passbook prints.
*/
printPassbook(): void {
const passbook = document.getElementById('dds-passbook');
const popup = window.open('', '_blank', 'width=900,height=700');
if (!passbook || !popup) {
if (!passbook) {
return;
}
popup.document.write(`<html><head><title>${this.account?.accountNo || 'DDS'} Passbook</title><style>
body { font-family: Arial, sans-serif; margin: 24px; color: #111; }
.dav-pb-head { display: flex; justify-content: space-between; gap: 24px; margin-bottom: 16px; }
.dav-pb-title { font-size: 20px; font-weight: 700; }
.dav-pb-sub { color: #555; }
.dav-pb-grid { display: grid; grid-template-columns: auto auto; gap: 2px 12px; font-size: 13px; }
.dav-pb-grid span { color: #555; }
table { width: 100%; border-collapse: collapse; font-size: 13px; }
th, td { border: 1px solid #999; padding: 6px 8px; text-align: left; }
.dav-right { text-align: right; }
.dav-pb-note { display: block; font-size: 11px; color: #555; }
</style></head><body>${passbook.innerHTML}</body></html>`);
const clone = passbook.cloneNode(true) as HTMLElement;
clone.querySelectorAll('.no-print').forEach((el: Element) => el.remove());
// The icon font is not available in the popup: the bank logo becomes an inline SVG, other icons go.
clone.querySelectorAll('mat-icon, .mat-icon').forEach((el: Element) => {
if (el.closest('.bank-logo-circle')) {
el.outerHTML = DdsAccountViewComponent.BANK_LOGO_SVG;
} else {
el.remove();
}
});
const popup = window.open('', '_blank', 'width=1100,height=800');
if (!popup) {
return;
}
popup.document.write(`<html><head><meta charset="utf-8" />
<title>${this.account?.accountNo || 'DDS'} Passbook</title>
<style>${DdsAccountViewComponent.PRINT_STYLES}</style></head>
<body>${clone.outerHTML}</body></html>`);
popup.document.close();
popup.focus();
popup.print();
}
/** Material "account_balance" glyph, inlined so the print window needs no icon font. */
private static readonly BANK_LOGO_SVG = `
<svg class="bank-logo-svg" viewBox="0 0 24 24" width="26" height="26" aria-hidden="true">
<path d="M4 10h3v7H4zM10.5 10h3v7h-3zM2 19h20v3H2zM17 10h3v7h-3zM12 1L2 6v2h20V6z" />
</svg>`;
/** Mirrors the passbook styles in the component's SCSS - scoped styles do not reach the popup. */
private static readonly PRINT_STYLES = `
@page { size: A4 portrait; margin: 10mm; }
* { margin: 0; padding: 0; box-sizing: border-box; -webkit-print-color-adjust: exact; print-color-adjust: exact; }
body { font-family: 'Segoe UI', Arial, sans-serif; color: #333; background: #fff; }
.statement-wrapper { background: #fff; border: 1px solid #d0d7e3; border-radius: 6px; overflow: hidden; }
.bank-header { background: #1a3a6b; padding: 14px 20px; display: flex; justify-content: space-between;
align-items: center; border-bottom: 4px solid #c8a951; }
.bank-logo-area { display: flex; align-items: center; gap: 12px; }
.bank-logo-circle { width: 44px; height: 44px; border-radius: 50%; background: #c8a951; display: flex;
align-items: center; justify-content: center; }
.bank-logo-svg { fill: #1a3a6b; display: block; }
.bank-title { color: #fff; font-size: 16px; font-weight: 700; letter-spacing: 0.3px; }
.bank-subtitle { color: #c8a951; font-size: 10px; font-weight: 500; letter-spacing: 1.5px;
text-transform: uppercase; margin-top: 2px; }
.statement-label { text-align: right; }
.stmt-label-text { color: #c8a951; font-size: 12px; font-weight: 700; letter-spacing: 2px; text-transform: uppercase; }
.stmt-period { color: rgba(255, 255, 255, 0.75); font-size: 10px; margin-top: 3px; }
.branch-line { background: #e8edf5; border-bottom: 1px solid #d0d7e3; padding: 5px 20px; display: flex;
flex-wrap: wrap; gap: 4px 16px; font-size: 9.5px; color: #4a5a7a; }
.account-details-band { background: #f0f3f9; border-bottom: 1px solid #d0d7e3; padding: 12px 20px; }
.account-details-grid { display: grid; grid-template-columns: repeat(3, 1fr); gap: 10px 24px; }
.detail-label { font-size: 8.5px; font-weight: 700; color: #7a8aaa; text-transform: uppercase;
letter-spacing: 0.8px; margin-bottom: 2px; }
.detail-value { font-size: 12px; color: #222; }
.detail-value.bold { font-weight: 600; }
.detail-value.mono { font-family: 'Courier New', monospace; letter-spacing: 1px; }
.detail-value.status-badge { display: inline-block; padding: 1px 8px; border-radius: 10px; font-size: 10px;
font-weight: 600; background: #eee; color: #555; }
.detail-value.status-badge.active { background: #d4edda; color: #155724; }
.balance-summary-strip { display: flex; align-items: stretch; background: #1a3a6b; }
.balance-box { flex: 1; padding: 10px 18px; text-align: center; }
.balance-box-label { font-size: 9px; font-weight: 600; color: rgba(255, 255, 255, 0.65);
text-transform: uppercase; letter-spacing: 1px; margin-bottom: 3px; }
.balance-box-value { font-size: 15px; font-weight: 700; color: #c8a951; }
.balance-divider { width: 1px; background: rgba(255, 255, 255, 0.15); margin: 6px 0; }
.statement-table { width: 100%; border-collapse: collapse; font-size: 11px; }
.statement-table thead { display: table-header-group; }
.statement-table tfoot { display: table-row-group; }
.statement-table tr { page-break-inside: avoid; break-inside: avoid; }
.statement-table thead tr { background: #e8edf5; border-top: 1px solid #d0d7e3; border-bottom: 2px solid #1a3a6b; }
.statement-table thead th { padding: 8px 9px; font-size: 10px; font-weight: 700; color: #1a3a6b;
text-transform: uppercase; letter-spacing: 0.4px; white-space: nowrap; text-align: left; }
.statement-table tbody tr { border-bottom: 1px solid #e8ecf3; }
.statement-table tbody tr:nth-child(even) { background: #f4f7fb; }
.statement-table tbody td { padding: 6px 9px; vertical-align: middle; color: #333; }
.statement-table tfoot .total-row { background: #e8edf5; border-top: 2px solid #1a3a6b; }
.statement-table tfoot .total-row td { padding: 8px 9px; }
.col-sr { width: 34px; }
.col-date { width: 100px; white-space: nowrap; }
.col-desc { min-width: 150px; }
.col-ref { width: 90px; }
.col-amount { width: 100px; }
.col-balance { width: 110px; }
.debit-amount { color: #c0392b; font-weight: 500; }
.credit-amount { color: #1a7a3f; font-weight: 500; }
.balance-amount { color: #1a3a6b; font-weight: 600; font-family: 'Courier New', monospace; }
.total-label { font-size: 11px; font-weight: 700; color: #1a3a6b; text-transform: uppercase; letter-spacing: 0.5px; }
.closing-balance { font-size: 13px; font-weight: 700; color: #1a3a6b; font-family: 'Courier New', monospace; }
.txn-desc { font-weight: 500; }
.txn-note { display: block; font-size: 9.5px; color: #777; }
.text-right { text-align: right; }
.text-center { text-align: center; }
.text-muted { color: #999; }
.mono { font-family: 'Courier New', monospace; font-size: 10px; }
.no-data-block { padding: 40px; text-align: center; color: #aaa; font-size: 13px; }
.statement-footer { background: #f0f3f9; border-top: 1px solid #d0d7e3; padding: 10px 20px; text-align: center; }
.footer-note { font-size: 9px; color: #888; margin-bottom: 3px; font-style: italic; }
.footer-meta { font-size: 9px; color: #aaa; letter-spacing: 0.3px; }
`;
fromDateControl = new FormControl('');
toDateControl = new FormControl('');
readonly today = new Date().toISOString().split('T')[0];
ngOnInit(): void {
this.txnDataSource.sortingDataAccessor = (txn: any, column: string) => {
switch (column) {
case 'date':
return `${txn.transactionDate || ''}#${String(txn.id).padStart(12, '0')}`;
case 'transactionType':
return this.label(txn.transactionType);
case 'voucher':
return txn.voucherNo || '';
case 'debit':
case 'credit':
return Number(txn.amount || 0);
case 'balance':
return Number(txn.balanceAfterTransaction || 0);
default:
return Number(txn.id || 0);
}
};
merge(this.fromDateControl.valueChanges, this.toDateControl.valueChanges)
.pipe(takeUntilDestroyed(this.destroyRef))
.subscribe(() => this.refreshTransactionTable());
const partyId = this.route.snapshot.paramMap.get('partyId');
if (!partyId) {
this.isLoading = false;
@@ -144,10 +329,39 @@ export class DdsAccountViewComponent implements OnInit {
(a: any, b: any) =>
String(b.transactionDate).localeCompare(String(a.transactionDate)) || Number(b.id) - Number(a.id)
);
this.refreshTransactionTable();
this.loadLetterhead(account);
this.isLoading = false;
});
}
/** The Transactions tab reads oldest first, as the savings account's does, within the chosen dates. */
private refreshTransactionTable(): void {
this.txnDataSource.data = this.passbookEntries;
}
/** Institution Profile for the bank; the account's own office's Branch Profile, else the first on record. */
private loadLetterhead(account: any): void {
this.branchName = account?.officeName || '';
forkJoin({
institution: this.organizationService.getInstitutionProfile().pipe(catchError(() => of(null))),
branches: this.organizationService.getBranchProfiles().pipe(catchError(() => of([])))
}).subscribe(({ institution, branches }: any) => {
if (institution) {
this.bankName = institution.passbookHeaderName || institution.bankName || institution.legalName || 'Bank';
this.bankSubtitle = institution.licenseNumber ? `Lic. No. ${institution.licenseNumber}` : 'Account Statement';
}
const list: any[] = Array.isArray(branches) ? branches : [];
const branch = list.find((b: any) => account?.officeId && b.officeId === account.officeId) || list[0];
if (branch) {
this.branchName = branch.officeName || this.branchName;
this.branchAddress = branch.address || '';
this.ifscCode = branch.ifscCode || '';
this.micrCode = branch.micrCode || '';
}
});
}
/** The transactions in the chosen range - all of them when no range is set. */
get shownTransactions(): any[] {
const from = this.fromDateControl.value;
@@ -164,6 +378,11 @@ export class DdsAccountViewComponent implements OnInit {
});
}
/** Whether the account has any transactions at all, whatever the date filter says. */
get hasTransactions(): boolean {
return this.transactions.length > 0;
}
get totalCredits(): number {
return this.sumOf('CREDIT');
}
@@ -209,6 +428,11 @@ export class DdsAccountViewComponent implements OnInit {
return this.transactions.length > 0 ? this.transactions[0].transactionDate : null;
}
/** Row tint, as the savings transactions table has it - money moved by transfer shows in violet. */
txnRowClass(txn: any): string {
return txn.transactionType === 'TRANSFER' || txn.transactionType === 'WITHDRAW_DEPOSITE' ? 'transfer' : '';
}
/** The rows on screen, as a spreadsheet the branch can keep or send on. */
exportCsv(): void {
const header = [
@@ -268,6 +492,35 @@ export class DdsAccountViewComponent implements OnInit {
this.toDateControl.setValue('');
}
/**
* A line in full - what it was, when, the payment details, voucher, reference, journal, branch and
* who keyed it - as a savings account's View Transaction shows one. From the Receipt menu item it
* prints straight away.
*/
viewTransaction(txn: any, printReceipt = false): void {
this.dialog.open(DdsTransactionViewDialogComponent, {
width: '640px',
maxWidth: '95vw',
data: {
transaction: txn,
account: this.account,
typeLabel: this.label(txn.transactionType),
printOnOpen: printReceipt
}
});
}
viewJournalEntries(txn: any): void {
if (txn.journalTransactionId) {
this.router.navigate([
'/',
'journal-entry',
'view',
txn.journalTransactionId
]);
}
}
label(type: string): string {
return TRANSACTION_LABELS[type] || String(type || '').replace(/_/g, ' ');
}
@@ -0,0 +1,144 @@
<!--
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="dtv">
<header class="dtv-head" [class.dtv-head--debit]="!isCredit">
<div class="dtv-head__ic"><fa-icon [icon]="isCredit ? 'arrow-down' : 'arrow-up'"></fa-icon></div>
<div class="dtv-head__text">
<h2>{{ data.typeLabel }}</h2>
<p>Transaction #{{ txn.id }} · {{ txn.refNo || '—' }}</p>
</div>
<div class="dtv-head__amount">
<span>{{ isCredit ? 'Credit' : 'Debit' }}</span>
<strong>{{ txn.amount | currency: currency : 'symbol-narrow' : '1.2-2' }}</strong>
</div>
<button type="button" class="dtv-close" (click)="close()" aria-label="Close">
<fa-icon icon="times"></fa-icon>
</button>
</header>
<div class="dtv-body" id="dds-txn-receipt">
<!-- What the printed receipt opens with; hidden on screen, where the header above says it. -->
<div class="dtv-print-only">
<h2>DDS Transaction Receipt</h2>
<div class="muted">
{{ account.partyName }} · A/c {{ account.accountNo }} · {{ txn.officeName || account.officeName || '' }}
</div>
<div class="amount">
{{ isCredit ? 'Credit' : 'Debit' }} {{ txn.amount | currency: currency : 'symbol-narrow' : '1.2-2' }}
</div>
</div>
<section class="dtv-section">
<h3>General</h3>
<table class="dtv-kv">
<tr>
<td class="k">Id</td>
<td>{{ txn.id }}</td>
</tr>
<tr>
<td class="k">Reference No.</td>
<td class="mono">{{ txn.refNo || '—' }}</td>
</tr>
<tr>
<td class="k">Transaction Type</td>
<td>{{ data.typeLabel }} ({{ txn.direction }})</td>
</tr>
<tr>
<td class="k">Transaction Date</td>
<td>{{ txn.transactionDate | date: 'dd MMM yyyy' }}</td>
</tr>
<tr>
<td class="k">Submitted On</td>
<td>{{ txn.submittedOnDate ? (txn.submittedOnDate | date: 'dd MMM yyyy') : '—' }}</td>
</tr>
<tr>
<td class="k">Currency</td>
<td>{{ currency }}</td>
</tr>
<tr>
<td class="k">Amount</td>
<td class="strong">{{ txn.amount | currency: currency : 'symbol-narrow' : '1.2-2' }}</td>
</tr>
<tr>
<td class="k">Running Balance</td>
<td class="strong">
{{ txn.balanceAfterTransaction || 0 | currency: currency : 'symbol-narrow' : '1.2-2' }}
</td>
</tr>
<tr>
<td class="k">Note</td>
<td>{{ txn.description || '—' }}</td>
</tr>
</table>
</section>
<section class="dtv-section">
<h3>Payment Details</h3>
<table class="dtv-kv">
<tr>
<td class="k">Mode</td>
<td>{{ txn.transactionMode || '—' }}</td>
</tr>
<tr>
<td class="k">Account Type / No.</td>
<td>{{ txn.accountType || '—' }}{{ txn.accountNo ? ' · ' + txn.accountNo : '' }}</td>
</tr>
<tr>
<td class="k">Cheque No.</td>
<td>{{ txn.chequeNo || '—' }}</td>
</tr>
<tr>
<td class="k">Token No.</td>
<td>{{ txn.tokenNo || '—' }}</td>
</tr>
</table>
</section>
<section class="dtv-section">
<h3>Accounting &amp; Audit</h3>
<table class="dtv-kv">
<tr>
<td class="k">Voucher No.</td>
<td class="mono">{{ txn.voucherNo || '—' }}</td>
</tr>
<tr>
<td class="k">Journal Entry</td>
<td class="mono">{{ txn.journalTransactionId || 'None - nothing moved on the books' }}</td>
</tr>
<tr>
<td class="k">Branch</td>
<td>{{ txn.officeName || account.officeName || '—' }}</td>
</tr>
<tr>
<td class="k">Agent</td>
<td>{{ txn.agentCode }}{{ txn.agentName ? ' · ' + txn.agentName : '' }}</td>
</tr>
<tr>
<td class="k">Created By</td>
<td>{{ txn.createdByUsername || '—' }}</td>
</tr>
<tr>
<td class="k">Created On</td>
<td>{{ txn.createdDate ? (txn.createdDate | date: 'dd MMM yyyy, hh:mm a') : '—' }}</td>
</tr>
</table>
</section>
</div>
<footer class="dtv-actions">
<button mat-stroked-button type="button" (click)="close()">Close</button>
<span class="dtv-actions__spacer"></span>
<button mat-stroked-button type="button" [disabled]="!txn.journalTransactionId" (click)="viewJournalEntries()">
<fa-icon icon="table-cells"></fa-icon> View Journal Entries
</button>
<button mat-flat-button color="primary" type="button" (click)="printReceipt()">
<fa-icon icon="receipt"></fa-icon> Print Receipt
</button>
</footer>
</div>
@@ -0,0 +1,167 @@
/**
* 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/.
*/
$primary: #1353a0;
$credit: #15803d;
$debit: #b91c1c;
$border: #e2e8f0;
$text: #0f172a;
$text-mid: #475569;
:host {
display: block;
}
.dtv {
display: flex;
flex-direction: column;
max-height: 88vh;
color: $text;
font-size: 13px;
}
.dtv-head {
display: flex;
align-items: center;
gap: 12px;
padding: 16px 20px;
background: linear-gradient(135deg, #1e40af, #2563eb);
color: #fff;
&--debit {
background: linear-gradient(135deg, #7f1d1d, #b91c1c);
}
&__ic {
display: grid;
place-items: center;
flex: none;
width: 38px;
height: 38px;
border-radius: 10px;
background: rgb(255 255 255 / 18%);
}
&__text {
flex: 1;
min-width: 0;
h2 {
margin: 0;
font-size: 16px;
font-weight: 700;
}
p {
margin: 2px 0 0;
font-size: 12px;
opacity: 0.85;
}
}
&__amount {
display: flex;
flex-direction: column;
align-items: flex-end;
span {
font-size: 11px;
letter-spacing: 0.06em;
text-transform: uppercase;
opacity: 0.85;
}
strong {
font-size: 18px;
}
}
}
.dtv-close {
border: 0;
background: transparent;
color: #fff;
font-size: 16px;
cursor: pointer;
}
.dtv-body {
flex: 1;
min-height: 0;
padding: 14px 20px;
overflow-y: auto;
}
.dtv-print-only {
display: none;
}
.dtv-section {
& + & {
margin-top: 14px;
}
h3 {
margin: 0 0 6px;
font-size: 11px;
font-weight: 700;
letter-spacing: 0.08em;
text-transform: uppercase;
color: $primary;
}
}
.dtv-kv {
width: 100%;
border: 1px solid $border;
border-radius: 8px;
border-collapse: separate;
border-spacing: 0;
overflow: hidden;
td {
padding: 7px 12px;
border-bottom: 1px solid #eef2f6;
vertical-align: top;
}
tr:last-child td {
border-bottom: 0;
}
.k {
width: 38%;
background: #f8fafc;
color: $text-mid;
font-weight: 600;
}
.mono {
font-family: ui-monospace, 'Cascadia Mono', Consolas, monospace;
}
.strong {
font-weight: 700;
}
}
.dtv-actions {
display: flex;
align-items: center;
gap: 8px;
padding: 12px 20px;
border-top: 1px solid $border;
&__spacer {
flex: 1;
}
fa-icon {
margin-right: 6px;
}
}
@@ -0,0 +1,109 @@
/**
* 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, inject } from '@angular/core';
import { CurrencyPipe, DatePipe } from '@angular/common';
import { Router } from '@angular/router';
import { MAT_DIALOG_DATA, MatDialogRef } from '@angular/material/dialog';
import { MatButton } from '@angular/material/button';
import { FaIconComponent } from '@fortawesome/angular-fontawesome';
import { STANDALONE_SHARED_IMPORTS } from 'app/standalone-shared.module';
/** What the account view hands the dialog: the line, its account, and how to name its type. */
export interface DdsTransactionViewData {
transaction: any;
account: any;
typeLabel: string;
/** Opened from the Receipt menu item: print the receipt straight away. */
printOnOpen?: boolean;
}
/**
* One DDS passbook line in full - everything the line was saved with - the way a savings account's
* View Transaction shows one: what it was, when, how much, and who keyed it, with the voucher, its
* own reference and the journal it was booked under. Prints as a receipt.
*/
@Component({
selector: 'mifosx-dds-transaction-view-dialog',
templateUrl: './dds-transaction-view-dialog.component.html',
styleUrls: ['./dds-transaction-view-dialog.component.scss'],
imports: [
...STANDALONE_SHARED_IMPORTS,
CurrencyPipe,
DatePipe,
MatButton,
FaIconComponent
]
})
export class DdsTransactionViewDialogComponent {
private dialogRef = inject(MatDialogRef<DdsTransactionViewDialogComponent>);
private router = inject(Router);
data = inject<DdsTransactionViewData>(MAT_DIALOG_DATA);
get txn(): any {
return this.data.transaction || {};
}
get account(): any {
return this.data.account || {};
}
get isCredit(): boolean {
return this.txn.direction === 'CREDIT';
}
get currency(): string {
return this.account.currencyCode || 'INR';
}
constructor() {
if (this.data.printOnOpen) {
// After the view has drawn, so the receipt has something in it to print.
setTimeout(() => this.printReceipt(), 300);
}
}
viewJournalEntries(): void {
if (!this.txn.journalTransactionId) {
return;
}
this.dialogRef.close();
this.router.navigate([
'/',
'journal-entry',
'view',
this.txn.journalTransactionId
]);
}
/** The receipt block alone, in a window of its own - as the savings receipt prints. */
printReceipt(): void {
const receipt = document.getElementById('dds-txn-receipt');
const popup = window.open('', '_blank', 'width=720,height=800');
if (!receipt || !popup) {
return;
}
popup.document.write(`<html><head><title>${this.txn.refNo || 'DDS'} Receipt</title><style>
body { font-family: Arial, sans-serif; font-size: 12px; color: #111; margin: 24px; }
h2 { margin: 0 0 4px; font-size: 16px; } .muted { color: #555; }
table { width: 100%; border-collapse: collapse; margin-top: 12px; }
td { padding: 6px 8px; border-bottom: 1px solid #ddd; vertical-align: top; }
td.k { width: 38%; color: #555; } .amount { font-size: 18px; font-weight: 700; }
.sign { margin-top: 48px; display: flex; justify-content: space-between; }
</style></head><body>${receipt.innerHTML}
<div class="sign"><span>Customer</span><span>Authorised Signatory</span></div>
</body></html>`);
popup.document.close();
popup.focus();
popup.print();
}
close(): void {
this.dialogRef.close();
}
}
@@ -19,12 +19,462 @@
<span>Loading entry…</span>
</div>
} @else {
<div class="dri-body">
<div class="dri-body" [class.ddr-body]="isProductReview || isAgentReview || isPartyReview">
@if (isProductReview) {
<!--
A DDS product request reads as the product it creates or changes: who and what at the top,
the four terms a checker looks at first, then every term grouped as the Account Type screen
asks for them. An edit shows each changed term as old → new.
-->
<section class="ddr-hero">
<div class="ddr-hero__icon">
<mat-icon>{{ isEdit ? 'edit_note' : 'savings' }}</mat-icon>
</div>
<div class="ddr-hero__main">
<div class="ddr-hero__eyebrow">
<span class="ddr-chip" [class.ddr-chip--edit]="isEdit">{{
isEdit ? 'Edit request' : 'New product'
}}</span>
<span class="dri-status" [ngClass]="statusClass(entry.status)">{{ entry.status }}</span>
</div>
<h2 class="ddr-hero__title">{{ valueOf('accountTypeDescription') || entry.details || 'DDS Product' }}</h2>
<div class="ddr-hero__code">
<span class="ddr-mono">{{ valueOf('accountTypeCode') || '—' }}</span>
@if (valueOf('accountTypeGroup')) {
<span class="ddr-dot"></span>
<span>{{ valueOf('accountTypeGroup') }} group</span>
}
</div>
</div>
<div class="ddr-hero__meta">
<span class="ddr-meta">
<mat-icon>person</mat-icon>
<span>
<b>{{ entry.makerName || entry.maker }}</b>
<small>{{ entry.maker }}</small>
</span>
</span>
<span class="ddr-meta">
<mat-icon>schedule</mat-icon>
<span>
<b>{{ formatDateTime(entry.madeOn) }}</b>
<small>Submitted</small>
</span>
</span>
@if (entry.status !== 'PENDING' && entry.checker) {
<span class="ddr-meta">
<mat-icon>verified_user</mat-icon>
<span>
<b>{{ entry.checkerName || entry.checker }}</b>
<small>{{ entry.status === 'APPROVED' ? 'Approved by' : 'Rejected by' }}</small>
</span>
</span>
}
</div>
</section>
<!-- The terms a checker reads first. -->
<section class="ddr-stats">
<div class="ddr-stat ddr-stat--accent">
<span class="ddr-stat__label"><mat-icon>percent</mat-icon>Interest Rate</span>
<span class="ddr-stat__value">{{ valueOf('interestRate') || '—' }}</span>
<span class="ddr-stat__hint">p.a., worked out daily</span>
</div>
<div class="ddr-stat">
<span class="ddr-stat__label"><mat-icon>event_repeat</mat-icon>Interest Posting</span>
<span class="ddr-stat__value">
{{ valueOf('autoInterestPost') === 'No' ? 'Manual' : valueOf('autoInterestPostWhen') || 'Monthly' }}
</span>
<span class="ddr-stat__hint">
{{ valueOf('autoInterestPost') === 'No' ? 'Posted from the batch screen' : 'Automatic, at period end' }}
</span>
</div>
<div class="ddr-stat">
<span class="ddr-stat__label"><mat-icon>lock_clock</mat-icon>Lock-in</span>
<span class="ddr-stat__value">{{ valueOf('prematureLockinMonths') || '—' }}</span>
<span class="ddr-stat__hint">before early closure</span>
</div>
<div class="ddr-stat">
<span class="ddr-stat__label"><mat-icon>event_available</mat-icon>Maturity</span>
<span class="ddr-stat__value">{{
valueOf('maturityAutoClose') === 'Yes' ? 'Auto close' : 'Manual payout'
}}</span>
<span class="ddr-stat__hint">on the maturity date</span>
</div>
</section>
@if (compareFields) {
<div class="ddr-banner" [class.ddr-banner--none]="!changedCount">
<mat-icon>{{ changedCount ? 'compare_arrows' : 'info' }}</mat-icon>
@if (changedCount) {
<span>
<b>{{ changedCount }} term{{ changedCount !== 1 ? 's' : '' }}</b> will change on approval - highlighted
below as current → proposed.
</span>
} @else {
<span>This edit changes nothing - every term matches the product as it stands.</span>
}
</div>
}
@for (group of fieldGroups; track group.name) {
<section class="ddr-section">
<header class="ddr-section__head">
<span class="ddr-section__icon"
><mat-icon>{{ group.icon }}</mat-icon></span
>
<span class="ddr-section__title">{{ group.name }}</span>
@if (group.changed) {
<span class="ddr-chip ddr-chip--edit">{{ group.changed }} changed</span>
}
</header>
@if (group.name === 'Accounting') {
<!--
The ledger accounts the product posts through, laid out as the product's own Accounting
tab lays them out: the kind of account, what it is for, and the account by name and code.
-->
<div class="ddr-acct">
@for (field of group.fields; track field.key) {
<div class="ddr-acct__row" [class.ddr-acct__row--changed]="field.changed">
<div class="ddr-acct__meta">
<span class="ddr-acct__kind" [ngClass]="'ddr-acct__kind--' + glKind(field.key).toLowerCase()">{{
glKind(field.key)
}}</span>
<span class="ddr-acct__label">
{{ field.label }}
@if (field.changed) {
<span class="ddr-tile__flag">Changed</span>
}
</span>
</div>
<div class="ddr-acct__value">
@if (field.changed) {
<span class="ddr-acct__gl ddr-acct__gl--old">
{{ glName(field.current) }}
@if (glCode(field.current)) {
<span class="ddr-acct__code">{{ glCode(field.current) }}</span>
}
</span>
<mat-icon class="ddr-acct__arrow">arrow_forward</mat-icon>
}
<span class="ddr-acct__gl" [class.ddr-acct__gl--none]="!field.value">
{{ glName(field.value) }}
@if (glCode(field.value)) {
<span class="ddr-acct__code">{{ glCode(field.value) }}</span>
}
</span>
</div>
</div>
}
</div>
} @else {
<div class="ddr-grid">
@for (field of group.fields; track field.key) {
<div class="ddr-tile" [class.ddr-tile--changed]="field.changed">
<span class="ddr-tile__label">
{{ field.label }}
@if (field.changed) {
<span class="ddr-tile__flag">Changed</span>
}
</span>
@if (field.changed) {
<span class="ddr-tile__diff">
<span class="ddr-tile__old">{{ field.current || '—' }}</span>
<mat-icon>arrow_forward</mat-icon>
<ng-container *ngTemplateOutlet="valueTpl; context: { field: field }"></ng-container>
</span>
} @else {
<ng-container *ngTemplateOutlet="valueTpl; context: { field: field }"></ng-container>
}
</div>
}
</div>
}
</section>
}
<ng-template #valueTpl let-field="field">
@if (field.format === 'yesno' && field.value) {
<span class="ddr-pill" [class.ddr-pill--yes]="field.value === 'Yes'">
<mat-icon>{{ field.value === 'Yes' ? 'check_circle' : 'remove_circle_outline' }}</mat-icon>
{{ field.value }}
</span>
} @else {
<span class="ddr-tile__value" [class.ddr-mono]="field.key === 'accountTypeCode'">{{
field.value || '—'
}}</span>
}
</ng-template>
} @else if (isAgentReview) {
<!--
An agent request reads as the agent it opens or changes: who and when at the top, then the
client, address, branch and accounts - the branch, the ledger account and the client by name,
not number - and the guarantors named with it. An edit lists only what it changes.
-->
<section class="ddr-hero">
<div class="ddr-hero__icon">
<mat-icon>{{ isEdit ? 'manage_accounts' : 'person_add' }}</mat-icon>
</div>
<div class="ddr-hero__main">
<div class="ddr-hero__eyebrow">
<span class="ddr-chip" [class.ddr-chip--edit]="isEdit">{{ isEdit ? 'Edit request' : 'New agent' }}</span>
<span class="dri-status" [ngClass]="statusClass(entry.status)">{{ entry.status }}</span>
</div>
<h2 class="ddr-hero__title">{{ valueOf('agentNameId') || entry.details || 'DDS Agent' }}</h2>
<div class="ddr-hero__code">
@if (valueOf('clientId')) {
<span>Client {{ valueOf('clientId') }}</span>
}
@if (valueOf('agentSavingAccountNo')) {
<span class="ddr-dot"></span>
<span class="ddr-mono">{{ valueOf('agentSavingAccountNo') }}</span>
}
</div>
</div>
<div class="ddr-hero__meta">
<span class="ddr-meta">
<mat-icon>person</mat-icon>
<span>
<b>{{ entry.makerName || entry.maker }}</b>
<small>{{ entry.maker }}</small>
</span>
</span>
<span class="ddr-meta">
<mat-icon>schedule</mat-icon>
<span>
<b>{{ formatDateTime(entry.madeOn) }}</b>
<small>Submitted</small>
</span>
</span>
@if (entry.status !== 'PENDING' && entry.checker) {
<span class="ddr-meta">
<mat-icon>verified_user</mat-icon>
<span>
<b>{{ entry.checkerName || entry.checker }}</b>
<small>{{ entry.status === 'APPROVED' ? 'Approved by' : 'Rejected by' }}</small>
</span>
</span>
}
</div>
</section>
@for (group of fieldGroups; track group.name) {
<section class="ddr-section">
<header class="ddr-section__head">
<span class="ddr-section__icon"
><mat-icon>{{ group.icon }}</mat-icon></span
>
<span class="ddr-section__title">{{ group.name }}</span>
</header>
<div class="ddr-grid">
@for (field of group.fields; track field.key) {
<div class="ddr-tile">
<span class="ddr-tile__label">{{ field.label }}</span>
<span class="ddr-tile__value" [class.ddr-mono]="field.key === 'agentSavingAccountNo'">{{
field.value || '—'
}}</span>
</div>
}
</div>
</section>
}
<section class="ddr-section">
<header class="ddr-section__head">
<span class="ddr-section__icon"><mat-icon>shield_person</mat-icon></span>
<span class="ddr-section__title">Guarantors</span>
<span class="ddr-chip ddr-chip--edit">{{ guarantors.length }}</span>
</header>
@if (guarantors.length) {
<table class="dri-cr-table">
<thead>
<tr>
<th>#</th>
<th>Client</th>
<th>Name</th>
<th>Telephone</th>
<th>Address</th>
</tr>
</thead>
<tbody>
@for (g of guarantors; track $index; let i = $index) {
<tr>
<td class="dri-cr-idx">{{ i + 1 }}</td>
<td>{{ g.client || '—' }}</td>
<td>{{ g.name || '—' }}</td>
<td class="cell-mono">{{ g.telephone || '—' }}</td>
<td>{{ g.address || '—' }}</td>
</tr>
}
</tbody>
</table>
} @else {
<div class="ddr-tile">
<span class="ddr-tile__value">No guarantors named in this request.</span>
</div>
}
</section>
} @else if (isPartyReview) {
<!--
A DDS account request reads as the account it opens or changes: who it is for and when, the
terms a checker looks at first, then every detail in the sections the Party Master Entry screen
has - the client, scheme, agent and branch by name, not number - and the nominees named with it.
An edit lists only what it changes.
-->
<section class="ddr-hero">
<div class="ddr-hero__icon">
<mat-icon>{{ isEdit ? 'edit_note' : 'account_balance_wallet' }}</mat-icon>
</div>
<div class="ddr-hero__main">
<div class="ddr-hero__eyebrow">
<span class="ddr-chip" [class.ddr-chip--edit]="isEdit">{{
isEdit ? 'Edit request' : 'New DDS account'
}}</span>
<span class="dri-status" [ngClass]="statusClass(entry.status)">{{ entry.status }}</span>
</div>
<h2 class="ddr-hero__title">{{ valueOf('partyName') || entry.details || 'DDS Account' }}</h2>
<div class="ddr-hero__code">
@if (valueOf('subType')) {
<span>{{ valueOf('subType') }}</span>
}
@if (valueOf('clientId')) {
<span class="ddr-dot"></span>
<span>Client {{ valueOf('clientId') }}</span>
}
</div>
</div>
<div class="ddr-hero__meta">
<span class="ddr-meta">
<mat-icon>person</mat-icon>
<span>
<b>{{ entry.makerName || entry.maker }}</b>
<small>{{ entry.maker }}</small>
</span>
</span>
<span class="ddr-meta">
<mat-icon>schedule</mat-icon>
<span>
<b>{{ formatDateTime(entry.madeOn) }}</b>
<small>Submitted</small>
</span>
</span>
@if (entry.status !== 'PENDING' && entry.checker) {
<span class="ddr-meta">
<mat-icon>verified_user</mat-icon>
<span>
<b>{{ entry.checkerName || entry.checker }}</b>
<small>{{ entry.status === 'APPROVED' ? 'Approved by' : 'Rejected by' }}</small>
</span>
</span>
}
</div>
</section>
<!-- The terms a checker reads first. -->
<section class="ddr-stats">
<div class="ddr-stat ddr-stat--accent">
<span class="ddr-stat__label"><mat-icon>payments</mat-icon>Daily Deposit</span>
<span class="ddr-stat__value">{{ valueOf('dailyDepositAmount') || '—' }}</span>
<span class="ddr-stat__hint">collected every day</span>
</div>
<div class="ddr-stat">
<span class="ddr-stat__label"><mat-icon>date_range</mat-icon>Term</span>
<span class="ddr-stat__value">{{ valueOf('days') ? valueOf('days') + ' days' : '—' }}</span>
<span class="ddr-stat__hint">{{ valueOf('month') ? valueOf('month') + ' months' : '' }}</span>
</div>
<div class="ddr-stat">
<span class="ddr-stat__label"><mat-icon>event</mat-icon>Opening Date</span>
<span class="ddr-stat__value">{{ valueOf('openingDate') || '—' }}</span>
<span class="ddr-stat__hint">{{
valueOf('maturityDate') ? 'Matures ' + valueOf('maturityDate') : 'account opens'
}}</span>
</div>
<div class="ddr-stat">
<span class="ddr-stat__label"><mat-icon>support_agent</mat-icon>Agent</span>
<span class="ddr-stat__value ddr-stat__value--small">{{ valueOf('agentId') || '—' }}</span>
<span class="ddr-stat__hint">{{ valueOf('officeId') || '' }}</span>
</div>
</section>
@for (group of fieldGroups; track group.name) {
<section class="ddr-section">
<header class="ddr-section__head">
<span class="ddr-section__icon"
><mat-icon>{{ group.icon }}</mat-icon></span
>
<span class="ddr-section__title">{{ group.name }}</span>
</header>
<div class="ddr-grid">
@for (field of group.fields; track field.key) {
<div class="ddr-tile">
<span class="ddr-tile__label">{{ field.label }}</span>
@if (field.format === 'yesno') {
<span class="ddr-pill" [class.ddr-pill--yes]="field.value === 'Yes'">
<mat-icon>{{ field.value === 'Yes' ? 'check_circle' : 'remove_circle_outline' }}</mat-icon>
{{ field.value }}
</span>
} @else {
<span class="ddr-tile__value">{{ field.value || '—' }}</span>
}
</div>
}
</div>
</section>
}
<section class="ddr-section">
<header class="ddr-section__head">
<span class="ddr-section__icon"><mat-icon>family_restroom</mat-icon></span>
<span class="ddr-section__title">Nominees</span>
@if (detail?.nomineeSplit && nominees.length) {
<span class="ddr-chip ddr-chip--edit">Split by {{ detail.nomineeSplit }}</span>
}
<span class="ddr-chip ddr-chip--edit">{{ nominees.length }}</span>
</header>
@if (nominees.length) {
<table class="dri-cr-table">
<thead>
<tr>
<th>#</th>
<th>Name</th>
<th>Relation</th>
<th>Gender</th>
<th>Mobile</th>
<th>Birth Date</th>
<th>Guardian</th>
<th class="num">Share</th>
</tr>
</thead>
<tbody>
@for (n of nominees; track $index; let i = $index) {
<tr>
<td class="dri-cr-idx">{{ i + 1 }}</td>
<td>{{ n.name || '—' }}</td>
<td>{{ n.relation || '—' }}</td>
<td>{{ n.gender || '—' }}</td>
<td class="cell-mono">{{ n.mobileNo || '—' }}</td>
<td>{{ n.birthDate || '—' }}</td>
<td>{{ n.guardianName || '—' }}</td>
<td class="num">{{ n.share || '—' }}</td>
</tr>
}
</tbody>
</table>
} @else {
<div class="ddr-tile">
<span class="ddr-tile__value">No nominee named in this request.</span>
</div>
}
</section>
} @else {
<!-- Header card: the voucher it moves under, its status, who made it and when -->
<div class="dri-card dri-card--header">
<div class="dri-card-header">
<div class="dri-card-header__left">
<!-- No voucher slot for a change that moves no money - it would only ever read "—". -->
@if (voucherNo || movesMoney) {
<span class="dri-voucher">{{ voucherNo || '—' }}</span>
}
<span class="dri-status" [ngClass]="statusClass(entry.status)">
{{ entry.status }}{{ entry.voucherCancelled ? ' · VOUCHER CANCELLED' : '' }}
</span>
@@ -55,24 +505,42 @@
{{ entry.type }} Summary
</div>
<div class="dri-detail-grid">
<!-- Amount and sides only for an entry that moves money; a product or agent change has neither. -->
@if (movesMoney) {
<div class="dri-detail-item">
<span class="dri-detail-label">Total Amount</span>
<span class="dri-detail-value dri-amount">
{{ entry.amount !== null && entry.amount !== undefined ? '₹' + (entry.amount | number: '1.2-2') : '—' }}
{{
entry.amount !== null && entry.amount !== undefined ? '₹' + (entry.amount | number: '1.2-2') : '—'
}}
</span>
</div>
@if (entriesProjected) {
<div class="dri-detail-item dri-detail-item--full">
<span class="dri-detail-label">Ledger</span>
<span class="dri-detail-value"
>No journal entry is made when this is approved - it records what has been earned. The entries below
are what the matching Posting run will book.</span
>
</div>
}
<div class="dri-detail-item">
<span class="dri-detail-label">DR Accounts</span>
<span class="dri-detail-label">{{
entriesProjected ? 'DR Accounts (when posted)' : 'DR Accounts'
}}</span>
<span class="dri-detail-value"
>{{ debitLines.length }} account{{ debitLines.length !== 1 ? 's' : '' }}</span
>
</div>
<div class="dri-detail-item">
<span class="dri-detail-label">CR Accounts</span>
<span class="dri-detail-label">{{
entriesProjected ? 'CR Accounts (when posted)' : 'CR Accounts'
}}</span>
<span class="dri-detail-value"
>{{ creditLines.length }} account{{ creditLines.length !== 1 ? 's' : '' }}</span
>
</div>
}
<div class="dri-detail-item dri-detail-item--full">
<span class="dri-detail-label">Details</span>
<span class="dri-detail-value">{{ entry.details || '—' }}</span>
@@ -85,13 +553,17 @@
}
</div>
</div>
}
<!-- Debit entries -->
@if (debitLines.length) {
<div class="dri-card">
<div class="dri-section-title">
<mat-icon>arrow_upward</mat-icon>
Debit Entries ({{ debitLines.length }} account{{ debitLines.length !== 1 ? 's' : '' }} — funds out)
{{ entriesProjected ? 'Debit entries when posted' : 'Debit Entries' }} ({{ debitLines.length }} account{{
debitLines.length !== 1 ? 's' : ''
}}
— funds out)
</div>
<table class="dri-cr-table">
<thead>
@@ -129,7 +601,10 @@
<div class="dri-card">
<div class="dri-section-title">
<mat-icon>arrow_downward</mat-icon>
Credit Entries ({{ creditLines.length }} account{{ creditLines.length !== 1 ? 's' : '' }} — funds in)
{{ entriesProjected ? 'Credit entries when posted' : 'Credit Entries' }} ({{ creditLines.length }} account{{
creditLines.length !== 1 ? 's' : ''
}}
— funds in)
</div>
<table class="dri-cr-table">
<thead>
@@ -225,7 +700,7 @@
}
@if (entry.status === 'PENDING' && !done) {
<div class="dri-actions">
<div class="dri-actions" [class.ddr-actions]="isProductReview || isAgentReview || isPartyReview">
@if (!confirmReject) {
<button type="button" class="dri-btn dri-btn--approve" [disabled]="!canAct" (click)="act('approve')">
<mat-icon>check_circle</mat-icon>
@@ -0,0 +1,563 @@
/**
* 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/.
*/
// The DDS product review. Layered on the Disbursement & Transfers sheet and drawn from the same
// palette, so it reads as part of the same Approval Center rather than a screen of its own.
$brand: #0f4f3c;
$brand-mid: #1f7a5c;
$brand-light: #e6f4f0;
$primary: #4a6cf7;
$warning: #b45309;
$warning-bg: #fffbeb;
$warning-border: #fde68a;
$text-dark: #0f172a;
$text-mid: #475569;
$text-soft: #94a3b8;
$border: #e2e8f0;
$bg-white: #fff;
$bg-soft: #f8fafc;
$radius: 14px;
$shadow-sm:
0 1px 3px rgb(15 23 42 / 6%),
0 1px 2px rgb(15 23 42 / 4%);
@mixin icon($size) {
font-size: $size;
width: $size;
height: $size;
line-height: $size;
}
.ddr-mono {
font-family: 'JetBrains Mono', SFMono-Regular, Consolas, monospace;
letter-spacing: 0.02em;
}
// A product review has more to lay out than a money movement, so the dialog widens for it.
.dri-body.ddr-body {
max-width: none;
gap: 16px;
padding-bottom: 0;
}
.dri--dialog:has(.ddr-body) {
width: min(880px, 94vw);
}
// ── Hero ─────────────────────────────────────────────────────────────────────
.ddr-hero {
display: grid;
grid-template-columns: auto 1fr auto;
gap: 18px;
align-items: center;
padding: 22px 24px;
border-radius: $radius;
background:
radial-gradient(circle at 100% 0%, rgb(255 255 255 / 12%) 0, transparent 45%),
linear-gradient(135deg, $brand 0%, $brand-mid 100%);
color: #fff;
box-shadow: 0 8px 24px rgb(15 79 60 / 22%);
&__icon {
display: grid;
place-items: center;
width: 56px;
height: 56px;
border-radius: 16px;
background: rgb(255 255 255 / 14%);
border: 1px solid rgb(255 255 255 / 22%);
mat-icon {
@include icon(28px);
}
}
&__main {
min-width: 0;
}
&__eyebrow {
display: flex;
align-items: center;
gap: 8px;
margin-bottom: 6px;
}
&__title {
margin: 0;
font-size: 21px;
font-weight: 700;
line-height: 1.25;
overflow-wrap: anywhere;
}
&__code {
display: flex;
align-items: center;
gap: 8px;
margin-top: 4px;
font-size: 13px;
color: rgb(255 255 255 / 78%);
}
&__meta {
display: flex;
flex-direction: column;
gap: 10px;
padding-left: 18px;
border-left: 1px solid rgb(255 255 255 / 18%);
}
// Status reads on the dark band as a light chip, whatever its colour on a white card.
.dri-status {
background: rgb(255 255 255 / 92%);
font-size: 11px;
padding: 3px 10px;
}
}
.ddr-dot {
width: 4px;
height: 4px;
border-radius: 50%;
background: currentcolor;
opacity: 0.6;
}
.ddr-meta {
display: flex;
align-items: center;
gap: 8px;
font-size: 12px;
mat-icon {
@include icon(18px);
opacity: 0.75;
}
b {
display: block;
font-weight: 600;
white-space: nowrap;
}
small {
display: block;
font-size: 11px;
color: rgb(255 255 255 / 65%);
}
}
.ddr-chip {
display: inline-flex;
align-items: center;
padding: 3px 10px;
border-radius: 999px;
font-size: 11px;
font-weight: 700;
letter-spacing: 0.04em;
text-transform: uppercase;
background: rgb(255 255 255 / 16%);
color: #fff;
border: 1px solid rgb(255 255 255 / 28%);
&--edit {
background: $warning-bg;
color: $warning;
border-color: $warning-border;
}
}
// ── Key terms ────────────────────────────────────────────────────────────────
.ddr-stats {
display: grid;
grid-template-columns: repeat(4, minmax(0, 1fr));
gap: 12px;
}
.ddr-stat {
display: flex;
flex-direction: column;
gap: 4px;
padding: 14px 16px;
background: $bg-white;
border: 1px solid $border;
border-radius: 12px;
box-shadow: $shadow-sm;
&__label {
display: flex;
align-items: center;
gap: 6px;
font-size: 11px;
font-weight: 700;
letter-spacing: 0.05em;
text-transform: uppercase;
color: $text-soft;
mat-icon {
@include icon(16px);
color: $brand-mid;
}
}
&__value {
font-size: 18px;
font-weight: 700;
color: $text-dark;
overflow-wrap: anywhere;
}
&__hint {
font-size: 11.5px;
color: $text-mid;
min-height: 14px;
}
// A value that is a name rather than a figure - an agent's code and name - reads smaller.
&__value--small {
font-size: 14px;
line-height: 1.3;
}
&--accent {
background: $brand-light;
border-color: #b7ddd0;
.ddr-stat__value {
font-size: 26px;
color: $brand;
}
}
}
// ── Edit banner ──────────────────────────────────────────────────────────────
.ddr-banner {
display: flex;
align-items: center;
gap: 10px;
padding: 12px 16px;
border-radius: 12px;
font-size: 13px;
background: $warning-bg;
border: 1px solid $warning-border;
color: $warning;
mat-icon {
@include icon(20px);
flex-shrink: 0;
}
&--none {
background: $bg-soft;
border-color: $border;
color: $text-mid;
}
}
// ── Sections ─────────────────────────────────────────────────────────────────
.ddr-section {
background: $bg-white;
border: 1px solid $border;
border-radius: $radius;
box-shadow: $shadow-sm;
overflow: hidden;
&__head {
display: flex;
align-items: center;
gap: 10px;
padding: 12px 18px;
border-bottom: 1px solid $border;
background: linear-gradient(90deg, $bg-soft 0%, $bg-white 100%);
}
&__icon {
display: grid;
place-items: center;
width: 28px;
height: 28px;
border-radius: 8px;
background: $brand-light;
color: $brand;
mat-icon {
@include icon(17px);
}
}
&__title {
font-size: 12.5px;
font-weight: 700;
letter-spacing: 0.06em;
text-transform: uppercase;
color: $text-mid;
margin-right: auto;
}
}
.ddr-grid {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
}
.ddr-tile {
display: flex;
flex-direction: column;
gap: 6px;
padding: 14px 18px;
border-bottom: 1px solid $border;
min-width: 0;
// Two columns: a hairline between them, and none under the last row.
&:nth-child(odd) {
border-right: 1px solid $border;
}
&:last-child,
&:nth-last-child(2):nth-child(odd) {
border-bottom: 0;
}
&__label {
display: flex;
align-items: center;
gap: 8px;
font-size: 11.5px;
font-weight: 600;
letter-spacing: 0.03em;
text-transform: uppercase;
color: $text-soft;
}
&__value {
font-size: 15px;
font-weight: 600;
color: $text-dark;
overflow-wrap: anywhere;
}
&__flag {
padding: 1px 7px;
border-radius: 999px;
font-size: 10px;
font-weight: 700;
letter-spacing: 0.04em;
background: $warning-bg;
color: $warning;
border: 1px solid $warning-border;
}
&__diff {
display: flex;
align-items: center;
flex-wrap: wrap;
gap: 8px;
mat-icon {
@include icon(16px);
color: $warning;
}
}
&__old {
font-size: 14px;
color: $text-soft;
text-decoration: line-through;
}
&--changed {
background: linear-gradient(90deg, rgb(253 230 138 / 22%) 0%, $bg-white 85%);
box-shadow: inset 3px 0 0 #f59e0b;
}
}
.ddr-pill {
display: inline-flex;
align-items: center;
gap: 5px;
width: fit-content;
padding: 3px 10px 3px 7px;
border-radius: 999px;
font-size: 13px;
font-weight: 600;
background: #f1f5f9;
color: $text-mid;
mat-icon {
@include icon(16px);
}
&--yes {
background: #dcfce7;
color: #15803d;
}
}
// ── Actions stay in reach however far the terms run ─────────────────────────
.ddr-actions {
position: sticky;
bottom: 0;
z-index: 2;
margin: 4px -20px 0;
padding: 14px 20px;
background: rgb(255 255 255 / 94%);
backdrop-filter: blur(6px);
border-top: 1px solid $border;
box-shadow: 0 -6px 16px rgb(15 23 42 / 6%);
}
// ── Narrow screens ───────────────────────────────────────────────────────────
/* stylelint-disable-next-line media-feature-range-notation -- Safari compatibility */
@media (max-width: 720px) {
.ddr-hero {
grid-template-columns: auto 1fr;
&__meta {
grid-column: 1 / -1;
flex-flow: row wrap;
gap: 14px;
padding: 12px 0 0;
border-left: 0;
border-top: 1px solid rgb(255 255 255 / 18%);
}
}
.ddr-stats {
grid-template-columns: repeat(2, minmax(0, 1fr));
}
.ddr-grid {
grid-template-columns: 1fr;
}
.ddr-tile {
&:nth-child(odd) {
border-right: 0;
}
&:nth-last-child(2):nth-child(odd) {
border-bottom: 1px solid $border;
}
}
}
// ── A DDS product's Accounting, as its own Accounting tab shows it ───────────────────────────────────
.ddr-acct {
display: flex;
flex-direction: column;
gap: 10px;
padding: 16px 20px 20px;
&__row {
display: flex;
align-items: center;
justify-content: space-between;
gap: 16px;
padding: 12px 14px;
border: 1px solid $border;
border-radius: 10px;
background: $bg-white;
&--changed {
border-color: $warning-border;
background: $warning-bg;
}
@media (width <= 720px) {
flex-direction: column;
align-items: flex-start;
}
}
&__meta {
display: flex;
align-items: center;
gap: 10px;
min-width: 0;
}
&__kind {
flex: none;
padding: 2px 10px;
border-radius: 999px;
font-size: 11px;
font-weight: 700;
letter-spacing: 0.04em;
text-transform: uppercase;
&--liability {
background: #dbeafe;
color: #1e40af;
}
&--expense {
background: #fef3c7;
color: #92400e;
}
}
&__label {
display: inline-flex;
align-items: center;
gap: 8px;
font-size: 14px;
font-weight: 600;
color: $text-dark;
}
&__value {
display: flex;
align-items: center;
flex-wrap: wrap;
gap: 8px;
justify-content: flex-end;
}
&__gl {
display: inline-flex;
align-items: center;
gap: 8px;
font-size: 14px;
font-weight: 600;
color: $brand;
&--old {
font-weight: 500;
color: $text-soft;
text-decoration: line-through;
}
&--none {
color: #b91c1c;
}
}
&__code {
padding: 1px 8px;
border: 1px solid $border;
border-radius: 6px;
background: $bg-soft;
font-family: ui-monospace, 'SF Mono', 'Cascadia Mono', Consolas, monospace;
font-size: 12px;
font-weight: 600;
color: $text-mid;
}
&__arrow {
width: 18px;
height: 18px;
font-size: 18px;
color: $warning;
}
}
@@ -25,7 +25,10 @@ import { OrganizationService } from 'app/organization/organization.service';
@Component({
selector: 'mifosx-dds-approval-review',
templateUrl: './dds-approval-review.component.html',
styleUrls: ['../../../clerk/disbursement-review/disbursement-review.component.scss'],
styleUrls: [
'../../../clerk/disbursement-review/disbursement-review.component.scss',
'./dds-approval-review.component.scss'
],
standalone: true,
imports: [
CommonModule,
@@ -76,6 +79,129 @@ export class DdsApprovalReviewComponent implements OnInit {
return (this.detail?.lines || []).filter((line: any) => line.side === 'CR');
}
/** A product change's terms, field by field - empty for an entry that moves money. */
get fields(): any[] {
return this.detail?.fields || [];
}
/** An edit still waiting is shown against the product as it stands today. */
get compareFields(): boolean {
return this.fields.some((field: any) => 'current' in field);
}
/** A DDS product request, which is laid out as the product rather than as a money movement. */
get isProductReview(): boolean {
return this.entry?.entity === 'DDSACCOUNTTYPE' && this.fields.length > 0;
}
/** A DDS agent request, laid out as the agent and their guarantors rather than as a money movement. */
get isAgentReview(): boolean {
return this.entry?.entity === 'AGENT' && this.fields.length > 0;
}
/** A DDS account request, laid out as the account and its nominees rather than as a money movement. */
get isPartyReview(): boolean {
return this.entry?.entity === 'PARTY' && this.fields.length > 0;
}
/** The nominees a DDS account request names. */
get nominees(): any[] {
return this.detail?.nominees || [];
}
/** The guarantors an agent request names. */
get guarantors(): any[] {
return this.detail?.guarantors || [];
}
get isEdit(): boolean {
return this.entry?.action === 'UPDATE';
}
/** A term's value as it will stand once approved, by its request parameter name. */
valueOf(key: string): string {
return this.fields.find((field: any) => field.key === key)?.value || '';
}
/** The kind of ledger account a product's Accounting row names - customers' deposits are owed, the rest are costs. */
glKind(key: string): string {
return key === 'depositLiabilityGlId' ? 'Liability' : 'Expense';
}
/** "DDS CUSTOMER DEPOSITS (200601)" → "DDS CUSTOMER DEPOSITS"; a missing account reads "Not set". */
glName(value: string | null | undefined): string {
if (!value) {
return 'Not set';
}
const match = /^(.*) (([^()]+))$/.exec(value);
return match ? match[1] : value;
}
/** "DDS CUSTOMER DEPOSITS (200601)" → "200601". */
glCode(value: string | null | undefined): string {
const match = value ? /^(.*) (([^()]+))$/.exec(value) : null;
return match ? match[2] : '';
}
get changedCount(): number {
return this.fields.filter((field: any) => field.changed).length;
}
private static readonly GROUP_ICONS: Record<string, string> = {
Product: 'inventory_2',
Interest: 'trending_up',
'Maturity & Premature Closure': 'event_busy',
Accounting: 'menu_book',
'Customer & Scheme': 'groups',
'Personal & Contact': 'contact_page',
'Minor / Guardian': 'escalator_warning',
'Scheme Terms': 'event_note',
'Balances & Status': 'toggle_on',
'Maturity Payout': 'currency_exchange',
Agent: 'badge',
'Address & Contact': 'location_on',
'Branch & Accounts': 'account_balance',
Balances: 'balance'
};
/** The terms in the sections the server groups them into, in the order it sends them. */
get fieldGroups(): { name: string; icon: string; changed: number; fields: any[] }[] {
if (this.groupsFor !== this.fields) {
const groups: { name: string; icon: string; changed: number; fields: any[] }[] = [];
for (const field of this.fields) {
const name = field.group || 'Details';
let group = groups.find((g) => g.name === name);
if (!group) {
group = { name, icon: DdsApprovalReviewComponent.GROUP_ICONS[name] || 'tune', changed: 0, fields: [] };
groups.push(group);
}
group.fields.push(field);
group.changed += field.changed ? 1 : 0;
}
this.groupsFor = this.fields;
this.groups = groups;
}
return this.groups;
}
/** Built once per load rather than on every change detection pass. */
private groupsFor: any[] | null = null;
private groups: { name: string; icon: string; changed: number; fields: any[] }[] = [];
/** Whether the entry moves money at all, and so has an amount and debit and credit sides to show. */
/**
* A calculation run is approved to record what has been earned in DDS's own tables; it makes no journal
* entry itself. The sides shown for it are what the matching Posting run will book, marked so they are
* not read as entries made on approval.
*/
get entriesProjected(): boolean {
return (this.detail?.lines || []).some((line: any) => line.projected);
}
get movesMoney(): boolean {
return (this.entry?.amount !== null && this.entry?.amount !== undefined) || !!this.detail?.lines?.length;
}
total(lines: any[]): number {
return lines.reduce((sum: number, line: any) => sum + Number(line.amount || 0), 0);
}
@@ -21,6 +21,7 @@ import { OrganizationService } from 'app/organization/organization.service';
import { SettingsService } from 'app/settings/settings.service';
import { AuthenticationService } from 'app/core/authentication/authentication.service';
import { DdsApprovalReviewComponent } from './dds-approval-review/dds-approval-review.component';
import { isDdsRoutedRequest } from 'app/tasks/product-entities';
/**
* Approval Center › DDS Approvals: every DDS entry waiting on a checker, in the clerk's terms - what
@@ -104,7 +105,11 @@ export class DdsApprovalsComponent implements OnInit {
.getDdsApprovals(this.filterStatus, this.isoDate(this.fromDate), this.isoDate(this.toDate))
.pipe(catchError(() => of([])))
.subscribe((rows: any) => {
this.entries = Array.isArray(rows) ? rows : [];
// Pending DDS product and account requests wait under Product Approval and Pending Account
// instead; once actioned they show here again, so the DDS history stays in one place.
this.entries = (Array.isArray(rows) ? rows : []).filter(
(row: any) => this.filterStatus !== 'PENDING' || !isDdsRoutedRequest(row?.entity, row?.action)
);
this.pageIndex = 0;
this.loading = false;
this.loaded = true;
@@ -331,12 +331,14 @@
<span class="fdrc-maturity-row__value">{{ matchResult.appliedRate | number: '1.2-2' }}% p.a.</span>
</div>
<div class="fdrc-maturity-row fdrc-maturity-row--interest">
<span class="fdrc-maturity-row__label">Interest Earned</span>
<span class="fdrc-maturity-row__label">{{
isSpecial ? 'Total Interest (paid out monthly)' : 'Interest Earned'
}}</span>
<span class="fdrc-maturity-row__value">₹ {{ matchResult.interestEarned | number: '1.2-2' }}</span>
</div>
@if (isSpecial && matchResult.estimatedMonthlyPayout) {
<div class="fdrc-maturity-row">
<span class="fdrc-maturity-row__label">Monthly Payout</span>
<span class="fdrc-maturity-row__label">Monthly Payout (approx., by days in the month)</span>
<span class="fdrc-maturity-row__value fdrc-maturity-row__value--highlight">
₹ {{ matchResult.estimatedMonthlyPayout | number: '1.2-2' }} / month
</span>
@@ -351,13 +353,64 @@
}
</span>
<span class="fdrc-maturity-row__value fdrc-maturity-row__value--big">
₹ {{ (isSpecial ? principalAmount : matchResult.maturityAmount) | number: '1.2-2' }}
₹ {{ matchResult.maturityAmount | number: '1.2-2' }}
</span>
</div>
@if (isSpecial && specialPreview) {
<div class="fdrc-maturity-row">
<span class="fdrc-maturity-row__label">Maturity Date</span>
<span class="fdrc-maturity-row__value">{{
specialPreview.maturityDate | date: 'dd MMM yyyy'
}}</span>
</div>
<div class="fdrc-maturity-row">
<span class="fdrc-maturity-row__label">Monthly payouts</span>
<span class="fdrc-maturity-row__value">{{ specialPreview.numberOfPayouts }}</span>
</div>
<div class="fdrc-maturity-row">
<span class="fdrc-maturity-row__label">Principal + all payouts</span>
<span class="fdrc-maturity-row__value"
>₹ {{ specialPreview.totalValueAtMaturity | number: '1.2-2' }}</span
>
</div>
}
</div>
</div>
@if (isSpecial && specialPreview) {
<div class="fdrc-schedule">
<div class="fdrc-schedule__head">
<span><i class="fas fa-calendar-alt"></i> Monthly Interest Payout Schedule</span>
<button
mat-stroked-button
type="button"
class="fdrc-schedule__toggle"
(click)="openSpecialSchedule()"
>
View payout schedule ({{ specialPreview.numberOfPayouts }})
</button>
</div>
<div class="fdrc-schedule__note">
From {{ specialPreview.startDate | date: 'dd MMM yyyy' }} (the day the account is opened) to
{{ specialPreview.maturityDate | date: 'dd MMM yyyy' }}, worked out by the account engine from the
product.
</div>
</div>
}
@if (!matchResult.maturityAmount) {
}
@if (specialPreviewLoading) {
<div class="fdrc-payout-hint">
<i class="fas fa-spinner fa-spin"></i>
Working out the maturity from the product…
</div>
}
@if (specialPreviewError) {
<div class="fdrc-payout-hint fdrc-payout-hint--error">
<i class="fas fa-exclamation-triangle"></i>
{{ specialPreviewError }}
</div>
}
@if (!matchResult.maturityAmount && !specialPreviewLoading && !specialPreviewError) {
<div class="fdrc-payout-hint">
<i class="fas fa-lightbulb"></i>
Enter a deposit amount above to see the maturity calculation.
@@ -1229,3 +1229,72 @@ $red-light: #fee2e2;
font-weight: 700;
}
}
/* FD Special: payout schedule from the backend preview */
.fdrc-schedule {
margin-top: 12px;
border: 1px solid #bbf7d0;
border-radius: 10px;
background: #fff;
overflow: hidden;
&__head {
display: flex;
align-items: center;
justify-content: space-between;
gap: 8px;
padding: 8px 14px;
background: #f0fdf4;
font-size: 12px;
font-weight: 700;
color: #166534;
text-transform: uppercase;
letter-spacing: 0.05em;
}
&__note {
padding: 6px 14px;
font-size: 12px;
color: #475569;
}
&__wrap {
max-height: 360px;
overflow: auto;
}
&__table {
width: 100%;
border-collapse: collapse;
font-size: 13px;
th,
td {
padding: 6px 14px;
border-top: 1px solid #e2e8f0;
text-align: left;
}
th {
position: sticky;
top: 0;
background: #f8fafc;
font-size: 11px;
text-transform: uppercase;
color: #64748b;
}
.num {
text-align: right;
}
tfoot td {
font-weight: 700;
background: #f0fdf4;
}
}
}
.fdrc-payout-hint--error {
color: #b91c1c;
}

Some files were not shown because too many files have changed in this diff Show More