Compare commits

..
Author SHA1 Message Date
keshav 1273bdbe0e Merge branch 'dev' of https://git.lincpay.tech/CBSNexora/cbsnexora into dev
Tests / testRigor (push) Canceled after 0s
build-run / Run Lint, Build and Deploy (push) Canceled after 0s
Publish Image in Docker Hub / build (push) Canceled after 0s
Playwright E2E Tests / Run Playwright Tests (push) Canceled after 0s
2026-08-25 11:21:13 +05:30
keshav 0b79b0b0d3 feat: implement cashier dashboard, dividend dashboards, share accounts views, and checker tasks 2026-08-25 11:20:42 +05:30
nitesh f0e059c621 feat(deposits): FD Renewal History tab, redesigned Payment action, and renewal-aware Transactions/certificate
Publish Image in Docker Hub / build (push) Canceled after 0s
Playwright E2E Tests / Run Playwright Tests (push) Canceled after 0s
Tests / testRigor (push) Canceled after 0s
build-run / Run Lint, Build and Deploy (push) Canceled after 0s
WHAT THIS CHANGES
==================
Frontend counterpart to the backend FD in-place renewal, senior-citizen
rate, and Payment action work.

1. RENEWAL HISTORY TAB (new) -- fixed-deposit-account-view/renewal-history-tab/.
   A vertical timeline, one card per term of the FD, oldest first. Backend
   data arrives as a list of lowercase-keyed maps (Postgres unquoted SQL
   aliases), which the component normalises. Includes term-rate-chart-dialog,
   a popup showing one term's full rate-by-tenure chart, including the
   senior-citizen column, with an "Applied" badge on the rate that was
   actually used. Route added in fixed-deposits-routing.module.ts, tab
   link added in fixed-deposit-account-view.component.html (shown only
   when the account has renewal history).

2. PAYMENT DIALOG (new) -- fixed-deposit-account-view/custom-dialogs/payment-dialog/.
   Card-based picker for the four supported closure types (Transfer to
   Savings, Re-Invest Principal and Interest, Re-Invest Principal Only,
   Partial Re-Invest), with Withdraw Deposit filtered out client-side.
   Fetches its own template via a new fixedDepositsService method
   (getFixedDepositsAccountPaymentTemplate, command=payment) rather than
   reusing the Close template, since the Payment template is the only one
   the backend includes PARTIAL_REINVEST in.
   The Partial Re-Invest amount cap is date-dependent: the backend bounds
   it against the account balance AFTER posting interest to the chosen
   closed-on date, which lands on the maturity amount on or after
   maturity and on the current balance before it. The dialog mirrors this
   (reinvestCap / processingOnOrAfterMaturity) so the shown split actually
   adds up and the client-side validator agrees with the server. This was
   wrong in an earlier pass of this work (compared against a fixed figure
   regardless of date) and was caught and fixed via a real reconciliation
   check (77000 + 23000 did not equal the displayed total) before this
   commit.
   Added: a loading-failure state (previously the spinner ran forever on
   a failed template fetch), an inline submit-error message (previously a
   failed POST left the dialog open with no feedback), an empty-savings-
   accounts hint, and accessibility attributes on the option cards
   (role="radio", aria-checked, a visible check icon, focus-visible
   outline) since selection was previously colour-only.

3. TRANSACTIONS TAB -- merges synthetic "FD Renewed" marker rows into the
   real transaction list, since an in-place renewal posts no actual
   ledger transaction and would otherwise look like the balance changed
   for no reason.

4. INTEREST RATE CHART TAB -- added the senior-citizen rate column.

5. DEPOSIT CERTIFICATE DIALOG -- lists the full renewal history on FD
   certificates. renewalCount is renewalHistory.length - 1 (the first
   row is the original term, not a renewal); an earlier version of this
   used the raw row count and overstated the renewal count by one on a
   customer-facing document, caught and fixed before this commit.

6. CLIENTS GENERAL TAB -- fixedDepositAccountsForDisplay folds a renewed
   FD's superseded terms out of the account list (each renewal used to
   show as two unrelated accounts, one Closed and one Active). Memoised
   on the source array's identity so it does not rebuild, and rebind the
   table, on every change-detection tick. Only rewrites FD rows; Savings,
   RD, Current, and Share account rows pass through this getter
   untouched, and the RD/Savings sections of the template were left
   bound directly to the original savingAccounts array.

KNOWN CAVEATS
=============
  - This depends on the paired backend commit (same date, "feat(deposits):
    FD in-place renewal, senior-citizen rate persistence, and Payment
    action"). Deploying this frontend without that backend change means
    the Payment dialog and Renewal History tab will call endpoints that
    do not exist yet.
  - Renewal-history rows rely on the backend returning lowercase column
    keys (a Postgres-specific behaviour of unquoted SQL aliases via
    JdbcTemplate). Confirmed correct against this project's Postgres
    dev database; if this project ever runs on a different database
    engine, that assumption would need re-checking.
  - src/environments/.env.ts is intentionally excluded from this commit;
    it is a local build-version stamp file that should never be
    committed.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-23 16:46:22 +05:30
niteshandClaude Opus 5 5c76aa6b27 feat(tasks,deposits): review product edits in a dialog showing only what changed
build-run / Run Lint, Build and Deploy (push) Canceled after 0s
Publish Image in Docker Hub / build (push) Canceled after 0s
Playwright E2E Tests / Run Playwright Tests (push) Canceled after 0s
Tests / testRigor (push) Canceled after 0s
The pending-product-edit comparison listed every field the edit form posted -- around fifty,
nearly all unchanged -- as an inline panel under the table, which buried the handful a checker
actually needs to look at. It is now a dialog listing only fields whose value differs.

Values are resolved to what the maker saw rather than what the payload carries: enum and
GL-account ids become their labels, dates in either API shape read the same, and empty
values no longer register as changes. Without that, option fields compared an expanded
{id, value} object against a bare id and reported every one of them as changed.

Fixed Deposit accounts created by a reinvestment now show the account they came from, and the
matured account shows the one it went into, each linking to the other.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-21 13:00:25 +05:30
niteshandClaude Sonnet 5 e58fd3c79a feat(deposits): sync linked account with maturity terms, add deposit certificate printing
build-run / Run Lint, Build and Deploy (push) Canceled after 0s
Publish Image in Docker Hub / build (push) Canceled after 0s
Playwright E2E Tests / Run Playwright Tests (push) Canceled after 0s
Tests / testRigor (push) Canceled after 0s
Fixed and Recurring Deposit wizards now bidirectionally sync the Product tab's
linked-account selection with the new Terms tab's Transfer-to-Savings account,
disable the Withdraw Deposit maturity instruction end-to-end, and card-style
the Transfer to Savings picker to match the existing linked-account UI. Adds
a printable FD/RD deposit certificate from the account view page, following
the existing letter-mgmt certificate pattern.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-20 11:05:25 +05:30
nitesh 1b2ef5126e Merge branch 'dev' of https://git.lincpay.tech/CBSNexora/cbsnexora into dev
build-run / Run Lint, Build and Deploy (push) Canceled after 0s
Publish Image in Docker Hub / build (push) Canceled after 0s
Playwright E2E Tests / Run Playwright Tests (push) Canceled after 0s
Tests / testRigor (push) Canceled after 0s
2026-08-19 10:44:53 +05:30
nitesh b953afe2da fix(clients): remove leftover unresolved git conflict markers on Last Name field
An unresolved git stash conflict had been committed as-is, leaving
literal <<<<<<< Updated upstream / ======= / >>>>>>> Stashed changes
markers inside the template. Angular rendered them as plain text right
in the create-client form. Kept the blur-triggered capitalizeControl()
version (matches First/Middle Name) and dropped the now-unused
formatName() left behind by the other side of the conflict.
2026-08-19 10:43:00 +05:30
nitesh ff781e50e3 feat(savings,deposits,current-accounts): streamline account activation flow
Matches the backend change that stopped auto-depositing money at
activation: removes the opening-balance field from every Activate
screen (Savings, Current, FD, RD) since funding now happens as a
separate deposit afterward, and removes the Digital-Facility step from
every Create wizard since every facility starts disabled by default
and gets turned on later through Edit Nominee & Features.

Also merges Approve+Activate into a single action for Savings,
Current, FD and RD accounts -- one click takes the account straight to
Active instead of two separate screens.
2026-08-19 10:39:52 +05:30
240 changed files with 12319 additions and 13484 deletions
+2 -1
View File
@@ -36,7 +36,8 @@
"playwright:headed": "playwright test --headed",
"playwright:debug": "playwright test --debug",
"headers:check": "node scripts/check-file-headers.js",
"headers:add": "node scripts/add-file-headers.js"
"headers:add": "node scripts/add-file-headers.js",
"mock:dip": "node \"../cbsnexora-backend/scripts/mock-dip-lookup-service.js\""
},
"dependencies": {
"@angular/animations": "20.3.16",
+20
View File
@@ -44,6 +44,26 @@ export class AccountingService {
return this.http.get('/accounting/profitandloss', { params: httpParams });
}
/**
* How much profit a share dividend may actually be declared out of — net profit reduced by the
* statutory appropriations that legally precede a dividend (Statutory Reserve Fund, education
* fund) and by dividends already declared for other share products over an overlapping period.
* @param {string} fromDate Period start, yyyy-MM-dd.
* @param {string} toDate Period end, yyyy-MM-dd.
* @param {string} excludeProductId Leave this product's own declarations out of `alreadyDeclared`
* (its overlapping declaration is separately blocked outright, so counting it would double up).
* @returns {Observable<any>} `{ netProfit, statutoryReserveAmount, educationFundAmount,
* distributableProfit, alreadyDeclared, availableForDeclaration, maximumRatePercentage,
* existingDeclarations[] }`.
*/
getDistributableProfit(fromDate: string, toDate: string, excludeProductId?: string): Observable<any> {
let httpParams = new HttpParams().set('fromDate', fromDate).set('toDate', toDate);
if (excludeProductId) {
httpParams = httpParams.set('excludeProductId', excludeProductId);
}
return this.http.get('/accounting/distributable-profit', { params: httpParams });
}
/**
* Total share dividend actually paid to members in a date range — derived from the Dividend
* Payable GL account's own ledger (no separate "Dividend Paid" account needed).
@@ -11,10 +11,6 @@ import { Routes, RouterModule } from '@angular/router';
import { Route } from '../core/route/route.service';
import { AccountsListComponent } from './accounts-list/accounts-list.component';
import { PartyMasterComponent } from '../deposits/party-master/party-master.component';
import { DailyTransactionEntryComponent } from '../deposits/daily-transaction-entry/daily-transaction-entry.component';
import { DdsReportsComponent } from '../deposits/dds-reports/dds-reports.component';
import { ReportViewerComponent } from '../deposits/dds-reports/report-viewer/report-viewer.component';
const routes: Routes = [
Route.withShell([
@@ -45,28 +41,6 @@ const routes: Routes = [
title: 'Recurring Deposit Accounts',
breadcrumb: 'Recurring Deposit Accounts'
}
},
{
path: 'dds',
component: PartyMasterComponent,
data: { title: 'Daily Deposit Scheme — Party Master', breadcrumb: 'Party Master' }
},
{
path: 'dds-collection',
component: DailyTransactionEntryComponent,
data: { title: 'Daily Transaction Entry', breadcrumb: 'Daily Transaction Entry' }
},
{
path: 'dds-reports',
data: { title: 'DDS Reports', breadcrumb: 'DDS Reports' },
children: [
{ path: '', component: DdsReportsComponent },
{
path: 'view/:reportName',
component: ReportViewerComponent,
data: { title: 'DDS Report', breadcrumb: 'reportName', routeParamBreadcrumb: 'reportName' }
}
]
}
]
}
@@ -0,0 +1,169 @@
<!--
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 ar-print">
<header class="pg-head">
<div>
<div class="pg-eyebrow">REPORTS · ACCOUNT REGISTER</div>
<h1 class="pg-title">Account Register</h1>
<p class="pg-sub">
Every account as it stood on the chosen date — which ones opened and closed that day, the balance each held
then, and what it holds now.
</p>
</div>
<div class="pg-toolbar ar-noprint">
<button type="button" class="pg-btn" (click)="shiftDate(-1)" title="Previous day">
<mat-icon>chevron_left</mat-icon>
</button>
<label class="pg-field">
<span>As on date</span>
<input type="date" [value]="date" (change)="date = $any($event.target).value; load()" />
</label>
<button type="button" class="pg-btn" (click)="shiftDate(1)" title="Next day">
<mat-icon>chevron_right</mat-icon>
</button>
<button type="button" class="pg-btn" (click)="goToday()">Today</button>
<button type="button" class="pg-btn pg-btn--primary" (click)="load()" [disabled]="loading">
<mat-icon>refresh</mat-icon> Reload
</button>
</div>
</header>
<!-- Shown only on paper, where the toolbar is hidden and the date has to be stated. -->
<p class="ar-printhead">Account Register as on {{ date }} · {{ section }}</p>
<div class="db-filters ar-noprint">
<label class="pg-field">
<span>Section</span>
<select [(ngModel)]="section" (change)="load()">
@for (s of sections; track s.value) {
<option [value]="s.value">{{ s.label }}</option>
}
</select>
</label>
<label class="pg-field ar-check">
<span>Movement</span>
<span class="ar-check__box">
<input type="checkbox" [(ngModel)]="movementOnly" />
Only accounts opened / closed on this date
</span>
</label>
<label class="pg-field db-filters__search">
<span>Search</span>
<input type="text" [(ngModel)]="search" placeholder="Account no, client, product, status…" />
</label>
<button type="button" class="pg-btn" (click)="printRegister()" [disabled]="filteredRows.length === 0">
<mat-icon>print</mat-icon> Print
</button>
<button type="button" class="pg-btn" (click)="exportCsv()" [disabled]="filteredRows.length === 0">
<mat-icon>download</mat-icon> CSV
</button>
</div>
@if (errorMsg) {
<div class="pg-alert pg-alert--error">{{ errorMsg }}</div>
}
@if (loading) {
<div class="pg-empty">Loading…</div>
}
@if (!loading && loaded) {
<!-- What actually happened that day, read before the list itself. -->
<div class="ar-summary">
<div class="ar-summary__item ar-summary__item--in">
<span class="ar-summary__label">Accounts opened on {{ date }}</span>
<span class="ar-summary__value">{{ openedCount }}</span>
</div>
<div class="ar-summary__item ar-summary__item--out">
<span class="ar-summary__label">Accounts closed on {{ date }}</span>
<span class="ar-summary__value">{{ closedCount }}</span>
</div>
<div class="ar-summary__item">
<span class="ar-summary__label">Balance on {{ date }}</span>
<span class="ar-summary__value">₹{{ shownBalanceOnDate | number: '1.2-2' }}</span>
</div>
<div class="ar-summary__item">
<span class="ar-summary__label">Current balance</span>
<span class="ar-summary__value">₹{{ shownCurrentBalance | number: '1.2-2' }}</span>
</div>
</div>
}
@if (!loading && loaded && filteredRows.length === 0) {
<div class="pg-empty">
<mat-icon>account_balance_wallet</mat-icon>
<p>
@if (movementOnly) {
No account was opened or closed on {{ date }}.
} @else {
No accounts existed on {{ date }}.
}
</p>
</div>
}
@if (filteredRows.length > 0) {
<div class="pg-card">
<table class="pg-table">
<thead>
<tr>
<th>Account No</th>
<th>Section</th>
<th>Client</th>
<th>Product</th>
<th>Status</th>
<th>Opened</th>
<th>Closed</th>
<th class="num">Balance on {{ date }}</th>
<th class="num">Current Balance</th>
</tr>
</thead>
<tbody>
@for (r of filteredRows; track r.section + r.accountId) {
<tr [class.ar-row--opened]="r.openedOnDate" [class.ar-row--closed]="r.closedOnDate">
<td>
<span class="pg-chip">{{ r.accountNo }}</span>
@if (r.openedOnDate) {
<span class="ar-tag ar-tag--in">OPENED</span>
}
@if (r.closedOnDate) {
<span class="ar-tag ar-tag--out">CLOSED</span>
}
</td>
<td>{{ r.section }}</td>
<td>{{ r.clientName ?? '—' }}</td>
<td>{{ r.productName ?? '—' }}</td>
<td>
<span [class]="statusClass(r.status)">{{ r.status }}</span>
</td>
<td>{{ r.openedOn ?? '—' }}</td>
<td>{{ r.closedOn ?? '—' }}</td>
<td class="num">₹{{ r.balanceOnDate | number: '1.2-2' }}</td>
<td class="num">
₹{{ r.currentBalance | number: '1.2-2' }}
@if (r.section === 'SHARE' && r.sharesHeld) {
<span class="db-muted">· {{ r.sharesHeld }} sh</span>
}
</td>
</tr>
}
</tbody>
<tfoot>
<tr>
<td colspan="7">
{{ filteredRows.length }} account(s) · {{ openedCount }} opened · {{ closedCount }} closed
</td>
<td class="num">₹{{ shownBalanceOnDate | number: '1.2-2' }}</td>
<td class="num">₹{{ shownCurrentBalance | number: '1.2-2' }}</td>
</tr>
</tfoot>
</table>
</div>
}
</div>
@@ -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/.
*/
/* Page chrome comes from cashier-shared.scss; badges, filters and muted text are shared with the
day book. Only what belongs to the account register lives here. */
.ar-summary {
display: flex;
gap: 12px;
flex-wrap: wrap;
margin-bottom: 14px;
&__item {
flex: 1 1 180px;
padding: 12px 16px;
background: #fff;
border: 1px solid #e2e8f0;
border-radius: 8px;
&--in {
border-left: 4px solid #16a34a;
}
&--out {
border-left: 4px solid #dc2626;
}
}
&__label {
display: block;
font-size: 10px;
font-weight: 800;
text-transform: uppercase;
letter-spacing: 0.05em;
color: #94a3b8;
}
&__value {
display: block;
margin-top: 4px;
font-size: 20px;
font-weight: 800;
font-variant-numeric: tabular-nums;
color: #0d1b2a;
}
}
/* Checkbox sits inside a pg-field, which is laid out for inputs — this keeps the label and box on
one line without fighting that. */
.ar-check {
&__box {
display: flex;
align-items: center;
gap: 6px;
height: 38px;
font-size: 12px;
color: #475569;
white-space: nowrap;
}
}
/* Marks the accounts that actually moved on the chosen date — the reason someone opens a register
for a past day at all. */
.ar-tag {
display: inline-block;
margin-left: 6px;
padding: 1px 6px;
border-radius: 4px;
font-size: 9px;
font-weight: 800;
letter-spacing: 0.04em;
&--in {
background: #dcfce7;
color: #15803d;
}
&--out {
background: #fee2e2;
color: #b91c1c;
}
}
.ar-row--opened td:first-child {
box-shadow: inset 3px 0 0 #22c55e;
}
.ar-row--closed td:first-child {
box-shadow: inset 3px 0 0 #ef4444;
}
/* Only shown on paper: the screen already states the date in the toolbar. */
.ar-printhead {
display: none;
}
/* A register is meant to be printed and filed, so paper gets its own treatment: the toolbar and
filters go, the table gains borders, and rows are not split across pages. */
@media print {
.ar-noprint {
display: none !important;
}
.ar-printhead {
display: block;
margin: 0 0 10px;
font-size: 13px;
font-weight: 800;
color: #000;
}
.pg {
padding: 0;
}
.ar-summary__item {
border: 1px solid #999;
}
.pg-table {
font-size: 10px;
th,
td {
border: 1px solid #999;
padding: 4px 6px;
color: #000;
}
}
tr {
page-break-inside: avoid;
}
/* Table headers repeat on every page, so a multi-page register stays readable. */
thead {
display: table-header-group;
}
}
@@ -0,0 +1,221 @@
/**
* Copyright since 2025 Mifos Initiative
*
* This Source Code Form is subject to the terms of the Mozilla Public
* License, v. 2.0. If a copy of the MPL was not distributed with this
* file, You can obtain one at http://mozilla.org/MPL/2.0/.
*/
import { Component, OnInit, inject } from '@angular/core';
import { CommonModule } from '@angular/common';
import { FormsModule } from '@angular/forms';
import { MatIconModule } from '@angular/material/icon';
import { catchError, of } from 'rxjs';
import { CashierService } from '../cashier.service';
interface RegisterRow {
accountId: number;
accountNo: string;
section: 'SAVINGS' | 'FD' | 'RD' | 'SHARE';
clientName: string | null;
productName: string | null;
status: string;
openedOn: string | null;
closedOn: string | null;
/** True when this account was opened (or closed) on the very date being viewed. */
openedOnDate: boolean;
closedOnDate: boolean;
/** The account's balance as it stood on the register's date, for every section. */
balanceOnDate: number | null;
currentBalance: number | null;
sharesHeld?: number;
}
/**
* Account register for one date: which accounts opened and closed that day, each account's balance
* as it stood then, and what it holds now.
*/
@Component({
selector: 'mifosx-account-register',
templateUrl: './account-register.component.html',
styleUrls: [
'../cashier-shared.scss',
'../day-book/day-book.component.scss',
'./account-register.component.scss'
],
standalone: true,
imports: [
CommonModule,
FormsModule,
MatIconModule
]
})
export class AccountRegisterComponent implements OnInit {
private service = inject(CashierService);
date = '';
section: 'ALL' | 'SAVINGS' | 'FD' | 'RD' | 'SHARE' = 'ALL';
search = '';
/** Narrows to just the accounts that opened or closed on the chosen date. */
movementOnly = false;
rows: RegisterRow[] = [];
openedCount = 0;
closedCount = 0;
totalBalanceOnDate = 0;
totalCurrentBalance = 0;
loading = false;
loaded = false;
errorMsg = '';
readonly sections = [
{ value: 'ALL', label: 'All accounts' },
{ value: 'SAVINGS', label: 'Savings' },
{ value: 'FD', label: 'Fixed Deposit' },
{ value: 'RD', label: 'Recurring Deposit' },
{ value: 'SHARE', label: 'Share' }
];
ngOnInit(): void {
this.date = this.today();
this.load();
}
/** Local date — toISOString() would shift the day in any timezone behind UTC. */
private today(): string {
const d = new Date();
return `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, '0')}-${String(d.getDate()).padStart(2, '0')}`;
}
load(): void {
if (!this.date) return;
this.loading = true;
this.errorMsg = '';
this.service
.accountRegister(this.date, this.section)
.pipe(
catchError((err: any) => {
this.errorMsg =
err?.error?.errors?.[0]?.defaultUserMessage ??
err?.error?.message ??
'Could not load the account register.';
return of(null);
})
)
.subscribe((res: any) => {
this.loading = false;
this.loaded = true;
this.rows = res?.accounts ?? [];
this.openedCount = Number(res?.openedCount ?? 0);
this.closedCount = Number(res?.closedCount ?? 0);
this.totalBalanceOnDate = Number(res?.totalBalanceOnDate ?? 0);
this.totalCurrentBalance = Number(res?.totalCurrentBalance ?? 0);
});
}
shiftDate(days: number): void {
const [
y,
m,
d
] = this.date.split('-').map(Number);
const dt = new Date(y, m - 1, d + days);
this.date = `${dt.getFullYear()}-${String(dt.getMonth() + 1).padStart(2, '0')}-${String(dt.getDate()).padStart(2, '0')}`;
this.load();
}
goToday(): void {
this.date = this.today();
this.load();
}
/** Free-text narrowing over the rows already fetched — no round trip per keystroke. */
get filteredRows(): RegisterRow[] {
const base = this.movementOnly ? this.rows.filter((r) => r.openedOnDate || r.closedOnDate) : this.rows;
const q = this.search.trim().toLowerCase();
if (!q) return base;
return base.filter((r) =>
[
r.accountNo,
r.clientName,
r.productName,
r.status,
r.section
]
.filter(Boolean)
.some((v) => String(v).toLowerCase().includes(q))
);
}
/** Totals for what is on screen, so a filter or search still reconciles. */
get shownBalanceOnDate(): number {
return this.filteredRows.reduce((s, r) => s + Number(r.balanceOnDate ?? 0), 0);
}
get shownCurrentBalance(): number {
return this.filteredRows.reduce((s, r) => s + Number(r.currentBalance ?? 0), 0);
}
statusClass(status: string): string {
switch (status) {
case 'ACTIVE':
return 'db-badge db-badge--ok';
case 'CLOSED':
case 'PREMATURELY CLOSED':
case 'REJECTED':
case 'WITHDRAWN':
return 'db-badge db-badge--rejected';
case 'SUBMITTED':
case 'APPROVED':
return 'db-badge db-badge--pending';
default:
return 'db-badge';
}
}
exportCsv(): void {
const header = [
'Account No',
'Section',
'Client',
'Product',
'Status',
'Opened On',
'Closed On',
`Balance on ${this.date}`,
'Current Balance'
];
const lines = this.filteredRows.map((r) =>
[
r.accountNo,
r.section,
r.clientName ?? '',
r.productName ?? '',
r.status,
r.openedOn ?? '',
r.closedOn ?? '',
r.balanceOnDate == null ? '' : String(r.balanceOnDate),
String(r.currentBalance ?? 0)
]
// Quote every field and double embedded quotes: client names routinely contain commas.
.map((v) => `"${String(v).replace(/"/g, '""')}"`)
.join(',')
);
const csv = [
header.join(','),
...lines
].join('\n');
const blob = new Blob([csv], { type: 'text/csv;charset=utf-8;' });
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
a.download = `account-register-${this.date}.csv`;
a.click();
URL.revokeObjectURL(url);
}
/** Hands the page to the browser's print dialog; print styles drop the toolbar and filters. */
printRegister(): void {
window.print();
}
}
@@ -0,0 +1,337 @@
<!--
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 · {{ missedOnly ? 'MISSED CLOSE' : 'DAY REGISTER' }}</div>
<h1 class="pg-title">{{ missedOnly ? 'Missed Close Register' : 'Day Open / Day Close' }}</h1>
<p class="pg-sub">
@if (missedOnly) {
Business days that were opened and never closed. While a day stays open its closing balance never carries
forward, a back-dated entry can still be posted into it, and any cash difference on that date goes unnoticed.
} @else {
The branch's business day, opened each morning and closed each evening. A day left open never carries its
closing balance forward, and any cash difference on that date goes unnoticed — so the ones still hanging are
listed first.
}
</p>
</div>
<div class="pg-toolbar">
@if (canOpenToday && !missedOnly) {
<button type="button" class="pg-btn pg-btn--primary" (click)="startOpen()">
<mat-icon>lock_open</mat-icon> Open Day
</button>
}
<button type="button" class="pg-btn" (click)="load()" [disabled]="loading">
<mat-icon>refresh</mat-icon> Reload
</button>
</div>
</header>
@if (errorMsg) {
<div class="pg-alert pg-alert--error">{{ errorMsg }}</div>
}
@if (successMsg) {
<div class="bd-success">{{ successMsg }}</div>
}
@if (!canOperate && loaded) {
<div class="ve-note">
<mat-icon>info</mat-icon>
You can see the register but not open or close a day — a Clerk, Head Cashier or Bank Manager does that, since a
cashier works inside the day and cannot declare it closed.
</div>
}
<!-- Missed closes first: these are the ones that need action. -->
@if (missed.length > 0) {
<div class="db-exceptions">
<div class="db-exceptions__head">
<mat-icon>warning</mat-icon>
<strong>{{ missed.length }} business day{{ missed.length === 1 ? '' : 's' }} never closed</strong>
</div>
@for (m of missed; track m.id) {
<div class="bd-missed">
<span class="pg-chip">{{ m.businessDate }}</span>
<strong class="bd-missed__age"> open {{ m.daysOpen }} day{{ m.daysOpen === 1 ? '' : 's' }} </strong>
<span class="db-muted">
opened {{ timeOnly(m.openedAt) }} by {{ m.openedByName }} · opening ₹{{ m.openingCash | number: '1.2-2' }}
</span>
@if (canOperate) {
<button type="button" class="pg-btn pg-btn--warn bd-missed__act" (click)="startClose(m)">
Close this day
</button>
}
</div>
}
</div>
}
<!-- On the missed-close screen, nothing to report is itself the answer — and has to be stated,
or the page looks like it failed to load. -->
@if (missedOnly && loaded && missed.length === 0) {
<div class="pg-empty">
<mat-icon>check_circle</mat-icon>
<p>No missed closes. Every business day opened for this branch has been closed.</p>
</div>
}
<!-- Current day -->
@if (!missedOnly && openDay) {
<div class="bd-current">
<div class="bd-current__main">
<span class="db-badge db-badge--pending">DAY OPEN</span>
<strong class="bd-current__date">{{ openDay.businessDate }}</strong>
<span class="db-muted">
opened {{ timeOnly(openDay.openedAt) }} by {{ openDay.openedByName }} · opening cash ₹{{
openDay.openingCash | number: '1.2-2'
}}
</span>
</div>
@if (canOperate) {
<button type="button" class="pg-btn pg-btn--primary" (click)="startClose(openDay)">
<mat-icon>lock</mat-icon> Close Day
</button>
}
</div>
} @else if (loaded && canOperate) {
<div class="bd-current bd-current--shut">
<div class="bd-current__main">
<span class="db-badge">NO DAY OPEN</span>
<span class="db-muted">Open the business day before any cash transaction is accepted.</span>
</div>
</div>
}
<!-- Open-day form -->
@if (showOpenForm) {
<div class="db-panel bd-panel">
<div class="db-panel__head">
<strong>Open business day</strong>
<button type="button" class="db-icon-btn" (click)="cancelOpen()" title="Close">
<mat-icon>close</mat-icon>
</button>
</div>
<div class="db-panel__body">
<label class="pg-field">
<span>Business date</span>
<input type="date" [(ngModel)]="openDate" />
</label>
<label class="pg-field">
<span>Opening cash (leave blank to carry forward)</span>
<input type="number" step="0.01" [(ngModel)]="openingCash" placeholder="Carried from last close" />
</label>
</div>
<p class="bd-hint">
Left blank, yesterday's closing figure carries forward automatically — which is what keeps the opening balance
from drifting.
</p>
<div class="db-panel__actions">
<button type="button" class="pg-btn pg-btn--primary" (click)="submitOpen()" [disabled]="opening || !openDate">
Open Day
</button>
<button type="button" class="pg-btn" (click)="cancelOpen()">Cancel</button>
</div>
</div>
}
<!-- Close-day form -->
@if (closeTarget) {
<div class="db-panel bd-panel" [class.bd-panel--late]="isLateClose">
<div class="db-panel__head">
<strong>Close business day — {{ closeTarget.businessDate }}</strong>
<button type="button" class="db-icon-btn" (click)="cancelClose()" title="Close">
<mat-icon>close</mat-icon>
</button>
</div>
@if (isLateClose) {
<p class="db-panel__warn">
This day is being closed after its own date. It will be recorded as a
<strong>force close</strong>, with the reason below kept permanently against it.
</p>
}
@if (previewLoading) {
<p class="db-muted">Working out the day's figure…</p>
}
<!-- The arithmetic, spelled out. Whoever closes the day can see where the expected figure
comes from instead of being asked to agree with a number they cannot check. -->
@if (preview) {
<div class="bd-calc">
<div class="bd-calc__row">
<span>Opening cash (carried forward)</span>
<span class="bd-calc__amt">₹{{ preview.openingCash | number: '1.2-2' }}</span>
</div>
<div class="bd-calc__row bd-calc__row--in">
<span>+ Cash deposits taken (approved)</span>
<span class="bd-calc__amt">₹{{ preview.deposits | number: '1.2-2' }}</span>
</div>
<div class="bd-calc__row bd-calc__row--in">
<span>+ Cash drawn from vault</span>
<span class="bd-calc__amt">₹{{ preview.vaultIn | number: '1.2-2' }}</span>
</div>
<div class="bd-calc__row bd-calc__row--out">
<span>− Cash paid out (approved withdrawals)</span>
<span class="bd-calc__amt">₹{{ preview.withdrawals | number: '1.2-2' }}</span>
</div>
<div class="bd-calc__row bd-calc__row--out">
<span>− Cash settled back to vault</span>
<span class="bd-calc__amt">₹{{ preview.settledOut | number: '1.2-2' }}</span>
</div>
<div class="bd-calc__row bd-calc__row--total">
<span>Cash the branch should be holding</span>
<span class="bd-calc__amt">₹{{ preview.expectedCash | number: '1.2-2' }}</span>
</div>
</div>
}
<div class="db-panel__body">
<label class="pg-field">
<span>Physical cash counted</span>
<input type="number" step="0.01" [(ngModel)]="closingCash" placeholder="0.00" />
</label>
@if (preview && liveDifference !== null) {
@if (liveDifference === 0) {
<div class="db-cmp db-cmp--good">
<span class="db-cmp__label">Difference</span>
<span class="db-cmp__value">Tallied</span>
</div>
} @else {
<div class="db-cmp db-cmp--bad">
<span class="db-cmp__label">Difference</span>
<span class="db-cmp__value">
{{ liveDifference > 0 ? 'Excess' : 'Short' }} ₹{{
liveDifference < 0 ? -liveDifference : (liveDifference | number: '1.2-2')
}}
</span>
</div>
}
}
@if (preview && liveDifference !== 0) {
<button type="button" class="pg-btn" (click)="useExpected()">Use expected figure</button>
}
</div>
<p class="bd-hint">
Pre-filled with what the books expect — change it only if the drawer counted something different, and that
difference is then recorded against the day.
</p>
@if (isLateClose) {
<label class="pg-field bd-panel__wide">
<span>Why was this day left open? (required)</span>
<input
type="text"
[(ngModel)]="forceReason"
placeholder="e.g. cashier on leave, cash not settled that evening"
/>
</label>
}
<label class="pg-field bd-panel__wide">
<span>Remarks (optional)</span>
<input type="text" [(ngModel)]="closeRemarks" placeholder="Anything worth recording about this day" />
</label>
<p class="bd-hint">
The figure above is stored against the record, so a difference stays readable later even if a transaction is
reversed afterwards.
</p>
<div class="db-panel__actions">
<button type="button" class="pg-btn pg-btn--primary" (click)="submitClose()" [disabled]="!canSubmitClose">
{{ isLateClose ? 'Force Close Day' : 'Close Day' }}
</button>
<button type="button" class="pg-btn" (click)="cancelClose()">Cancel</button>
</div>
</div>
}
@if (loading) {
<div class="pg-empty">Loading…</div>
}
@if (!loading && loaded && days.length === 0 && !missedOnly) {
<div class="pg-empty">
<mat-icon>event_note</mat-icon>
<p>No business day has been opened for this branch yet.</p>
</div>
}
<!-- The register -->
@if (days.length > 0 && !missedOnly) {
<div class="pg-card">
<table class="pg-table">
<thead>
<tr>
<th>Business Date</th>
<th>Status</th>
<th>Opened By</th>
<th class="num">Opening ₹</th>
<th>Closed By</th>
<th class="num">Closing ₹</th>
<th class="num">Book ₹</th>
<th>Difference</th>
<th>Note</th>
</tr>
</thead>
<tbody>
@for (d of days; track d.id) {
<tr>
<td>
<span class="pg-chip">{{ d.businessDate }}</span>
</td>
<td>
@if (d.status === 'OPEN') {
<span class="db-badge db-badge--pending">OPEN</span>
} @else if (d.forceClosed) {
<span class="db-badge db-badge--rejected" [title]="d.forceCloseReason ?? ''">FORCE CLOSED</span>
} @else {
<span class="db-badge db-badge--ok">CLOSED</span>
}
</td>
<td>
{{ d.openedByName ?? '—' }}
<span class="db-muted">· {{ timeOnly(d.openedAt) }}</span>
</td>
<td class="num">₹{{ d.openingCash | number: '1.2-2' }}</td>
<td>
{{ d.closedByName ?? '—' }}
@if (d.closedAt) {
<span class="db-muted">· {{ timeOnly(d.closedAt) }}</span>
}
</td>
<td class="num">{{ d.closingCash != null ? '₹' + (d.closingCash | number: '1.2-2') : '—' }}</td>
<td class="num">{{ d.bookCash != null ? '₹' + (d.bookCash | number: '1.2-2') : '—' }}</td>
<td>
@if (d.cashDifference == null) {
—
} @else if (d.cashDifference === 0) {
<span class="db-badge db-badge--ok">Tallied</span>
} @else {
<span class="db-badge db-badge--rejected">{{ differenceLabel(d.cashDifference) }}</span>
}
</td>
<td class="bd-note">
@if (d.forceCloseReason) {
<strong>Left open:</strong> {{ d.forceCloseReason }}
}
@if (d.remarks) {
<span class="db-muted">{{ d.remarks }}</span>
}
</td>
</tr>
}
</tbody>
</table>
</div>
}
</div>
@@ -0,0 +1,172 @@
/**
* 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/.
*/
/* Page chrome comes from cashier-shared.scss; the badges, panel and comparison tiles are shared
with the day book. Only what is specific to the day register lives here. */
/* Read-only notice for a cashier. Defined here rather than borrowed from the exceptions screen so
this component does not depend on another component's stylesheet. */
.ve-note {
display: flex;
align-items: center;
gap: 8px;
padding: 10px 14px;
margin-bottom: 14px;
background: #eff6ff;
border: 1px solid #bfdbfe;
border-radius: 8px;
color: #1e40af;
font-size: 12px;
mat-icon {
font-size: 18px;
width: 18px;
height: 18px;
}
}
.bd-success {
padding: 10px 14px;
margin-bottom: 14px;
background: #f0fdf4;
border: 1px solid #bbf7d0;
border-radius: 8px;
color: #14532d;
font-size: 13px;
}
/* One row per never-closed day, inside the shared red exception panel. */
.bd-missed {
display: flex;
align-items: center;
gap: 10px;
flex-wrap: wrap;
padding: 7px 0;
font-size: 12px;
border-top: 1px dotted #fecaca;
&__age {
color: #b91c1c;
}
/* Pushed right so the date and age read before the action. */
&__act {
margin-left: auto;
height: 28px;
font-size: 11px;
}
}
/* The branch's current day — the one thing someone opening this screen is looking for. */
.bd-current {
display: flex;
align-items: center;
gap: 12px;
flex-wrap: wrap;
padding: 14px 16px;
margin-bottom: 14px;
background: #fff;
border: 1px solid #e2e8f0;
border-left: 4px solid #f59e0b;
border-radius: 8px;
/* No day open at all: nothing is running, so the amber "in progress" edge would mislead. */
&--shut {
border-left-color: #94a3b8;
background: #f8fafc;
}
&__main {
display: flex;
align-items: center;
gap: 10px;
flex-wrap: wrap;
}
&__date {
font-size: 15px;
color: #0d1b2a;
}
.pg-btn {
margin-left: auto;
}
}
.bd-panel {
margin-bottom: 14px;
background: #f8fafc;
border: 1px solid #e2e8f0;
border-radius: 8px;
/* A late close is a different act from an ordinary one, and looks like it. */
&--late {
border-left: 4px solid #dc2626;
}
&__wide {
display: block;
margin-top: 12px;
max-width: 640px;
}
}
/* The closing figure's arithmetic, laid out as a running sum so each line can be checked against
the screen it came from. */
.bd-calc {
max-width: 520px;
margin-bottom: 14px;
padding: 10px 14px;
background: #fff;
border: 1px solid #e2e8f0;
border-radius: 8px;
font-size: 12px;
&__row {
display: flex;
justify-content: space-between;
gap: 16px;
padding: 5px 0;
color: #475569;
&--in .bd-calc__amt {
color: #15803d;
}
&--out .bd-calc__amt {
color: #b91c1c;
}
&--total {
margin-top: 4px;
padding-top: 8px;
border-top: 2px solid #e2e8f0;
font-weight: 800;
font-size: 14px;
color: #0d1b2a;
}
}
&__amt {
font-variant-numeric: tabular-nums;
font-weight: 700;
white-space: nowrap;
}
}
.bd-hint {
margin: 10px 0 0;
font-size: 11px;
color: #94a3b8;
}
.bd-note {
max-width: 280px;
font-size: 11px;
line-height: 1.4;
}
@@ -0,0 +1,284 @@
/**
* Copyright since 2025 Mifos Initiative
*
* This Source Code Form is subject to the terms of the Mozilla Public
* License, v. 2.0. If a copy of the MPL was not distributed with this
* file, You can obtain one at http://mozilla.org/MPL/2.0/.
*/
import { Component, OnInit, inject } from '@angular/core';
import { CommonModule } from '@angular/common';
import { FormsModule } from '@angular/forms';
import { MatIconModule } from '@angular/material/icon';
import { ActivatedRoute } from '@angular/router';
import { catchError, of } from 'rxjs';
import { CashierService } from '../cashier.service';
interface BranchDay {
id: number;
officeId: number;
officeName: string | null;
businessDate: string;
openedByName: string | null;
openedAt: string | null;
openingCash: number | null;
closedByName: string | null;
closedAt: string | null;
closingCash: number | null;
bookCash: number | null;
cashDifference: number | null;
forceClosed: boolean;
forceCloseReason: string | null;
remarks: string | null;
status: 'OPEN' | 'CLOSED';
/** Only present on the missed-close list. */
daysOpen?: number;
}
/**
* Branch day register — opening and closing the branch's business day, and the list of days that
* were opened and never closed.
*
* A day left open is not a tidiness problem: its closing balance never carries forward, a
* back-dated entry can still land in it, and any cash difference on that date goes unnoticed.
*/
@Component({
selector: 'mifosx-branch-day',
templateUrl: './branch-day.component.html',
styleUrls: [
'../cashier-shared.scss',
'../day-book/day-book.component.scss',
'./branch-day.component.scss'
],
standalone: true,
imports: [
CommonModule,
FormsModule,
MatIconModule
]
})
export class BranchDayComponent implements OnInit {
private service = inject(CashierService);
private route = inject(ActivatedRoute);
/**
* Set from the route: the missed-close register is the same data as the day register, shown with
* only the never-closed days. Two menu entries used to point at one screen, so choosing "Missed
* Close Register" looked like nothing had happened whenever the branch was up to date.
*/
missedOnly = false;
days: BranchDay[] = [];
missed: BranchDay[] = [];
openDay: any = null;
today = '';
/** Set by the backend from the caller's roles — a cashier may read the register but not act on it. */
canOperate = false;
loading = false;
loaded = false;
errorMsg = '';
successMsg = '';
// Open-day form
showOpenForm = false;
openDate = '';
openingCash: number | null = null;
opening = false;
// Close-day form. The date is fixed by which day is being closed, never typed.
closeTarget: BranchDay | null = null;
closingCash: number | null = null;
closeRemarks = '';
forceReason = '';
closing = false;
ngOnInit(): void {
this.missedOnly = !!this.route.snapshot.data['missedOnly'];
this.load();
}
load(): void {
this.loading = true;
this.errorMsg = '';
this.service
.branchDayRegister()
.pipe(
catchError((err: any) => {
this.errorMsg =
err?.error?.errors?.[0]?.defaultUserMessage ?? err?.error?.message ?? 'Could not load the day register.';
return of(null);
})
)
.subscribe((res: any) => {
this.loading = false;
this.loaded = true;
this.days = res?.days ?? [];
this.openDay = res?.openDay ?? null;
this.today = res?.today ?? '';
this.canOperate = !!res?.canOperate;
this.openDate = this.today;
});
this.service
.branchDayMissed()
.pipe(catchError(() => of(null)))
.subscribe((res: any) => {
this.missed = res?.missed ?? [];
});
}
/** True when the branch has no open day at all, so today can be opened. */
get canOpenToday(): boolean {
return this.canOperate && !this.openDay;
}
startOpen(): void {
this.showOpenForm = true;
this.openDate = this.today;
// Left blank on purpose: the backend carries yesterday's closing forward, and typing a figure
// over it is how a carried balance drifts.
this.openingCash = null;
this.successMsg = '';
this.errorMsg = '';
}
cancelOpen(): void {
this.showOpenForm = false;
}
submitOpen(): void {
if (!this.openDate) return;
this.opening = true;
this.errorMsg = '';
this.service
.openBranchDay(this.openDate, this.openingCash ?? undefined)
.pipe(
catchError((err: any) => {
this.errorMsg =
err?.error?.errors?.[0]?.defaultUserMessage ?? err?.error?.message ?? 'Could not open the day.';
return of(null);
})
)
.subscribe((res: any) => {
this.opening = false;
if (res) {
this.showOpenForm = false;
this.successMsg = `Business day ${res.businessDate} opened.`;
this.load();
}
});
}
/** The day's expected figure and the working behind it, fetched when the close form opens. */
preview: any = null;
previewLoading = false;
startClose(day: BranchDay): void {
this.closeTarget = day;
this.closingCash = null;
this.closeRemarks = '';
this.forceReason = '';
this.successMsg = '';
this.errorMsg = '';
this.preview = null;
// Pre-fill the counted figure with what the books expect. It stays editable — the day is still
// closed on what was physically counted — but in the ordinary case where the drawer tallies
// there is nothing to type, and no chance of a slip of the keyboard inventing a difference.
this.previewLoading = true;
this.service
.branchDayClosePreview(day.businessDate)
.pipe(catchError(() => of(null)))
.subscribe((res: any) => {
this.previewLoading = false;
this.preview = res;
if (res?.expectedCash != null && this.closingCash == null) {
this.closingCash = Number(res.expectedCash);
}
});
}
/** Difference the form would record right now, so it is visible before saving. */
get liveDifference(): number | null {
if (!this.preview || this.closingCash == null) return null;
return Number(this.closingCash) - Number(this.preview.expectedCash ?? 0);
}
/** Puts the expected figure back after an edit. */
useExpected(): void {
if (this.preview?.expectedCash != null) {
this.closingCash = Number(this.preview.expectedCash);
}
}
cancelClose(): void {
this.closeTarget = null;
}
/** A date earlier than today is a late close, and has to say why it was left open. */
get isLateClose(): boolean {
return !!this.closeTarget && this.closeTarget.businessDate < this.today;
}
get canSubmitClose(): boolean {
if (!this.closeTarget || this.closingCash == null || this.closing) return false;
return !this.isLateClose || !!this.forceReason.trim();
}
submitClose(): void {
const day = this.closeTarget;
if (!day || this.closingCash == null) return;
this.closing = true;
this.errorMsg = '';
this.service
.closeBranchDay(
day.businessDate,
Number(this.closingCash),
this.closeRemarks.trim() || undefined,
this.isLateClose ? this.forceReason.trim() : undefined
)
.pipe(
catchError((err: any) => {
this.errorMsg =
err?.error?.errors?.[0]?.defaultUserMessage ?? err?.error?.message ?? 'Could not close the day.';
return of(null);
})
)
.subscribe((res: any) => {
this.closing = false;
if (res) {
this.closeTarget = null;
const diff = Number(res.cashDifference ?? 0);
this.successMsg =
diff === 0
? `Business day ${res.businessDate} closed — cash tallied.`
: `Business day ${res.businessDate} closed with a difference of ₹${diff.toFixed(2)}.`;
this.load();
}
});
}
/** Short/excess wording, since a bare signed number reads ambiguously on a cash figure. */
differenceLabel(d: number | null): string {
if (d == null) return '—';
const n = Number(d);
if (n === 0) return 'Tallied';
return n > 0 ? `Excess ₹${Math.abs(n).toFixed(2)}` : `Short ₹${Math.abs(n).toFixed(2)}`;
}
stamp(iso: string | null): string {
if (!iso) return '—';
const [
d,
t
] = iso.split('T');
return t ? `${d} ${t.slice(0, 5)}` : d;
}
timeOnly(iso: string | null): string {
if (!iso) return '—';
const t = iso.split('T')[1];
return t ? t.slice(0, 5) : '—';
}
}
+41
View File
@@ -19,6 +19,10 @@ import { InterCashierTransferComponent } from './inter-cashier-transfer/inter-ca
import { CashierPositionComponent } from './cashier-position/cashier-position.component';
import { ReconciliationComponent } from './reconciliation/reconciliation.component';
import { CashHistoryComponent } from './cash-history/cash-history.component';
import { DayBookComponent } from './day-book/day-book.component';
import { VoucherExceptionsComponent } from './voucher-exceptions/voucher-exceptions.component';
import { BranchDayComponent } from './branch-day/branch-day.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';
import { ShareCollectionComponent } from './share-collection/share-collection.component';
@@ -128,6 +132,33 @@ const routes: Routes = [
}
]
},
{
// Branch-level operations, kept out of the cashier tree on purpose: opening and closing the
// day governs the whole branch, and is not something a cashier does.
path: 'branch-ops',
data: { title: 'Branch Operations', breadcrumb: 'Branch Operations' },
children: [
{
path: 'day-register',
component: BranchDayComponent,
data: { title: 'Day Open / Day Close', breadcrumb: 'Day Register' }
},
{
// Same screen, narrowed to the days that were never closed. A separate route rather than
// a second link to the register, so choosing it visibly does something.
path: 'missed-close',
component: BranchDayComponent,
data: { title: 'Missed Close Register', breadcrumb: 'Missed Close', missedOnly: true }
},
{
// Lives here alongside the day register because both answer "what did this branch look
// like on that date"; it is linked from the Reports menu, where people go to find it.
path: 'account-register',
component: AccountRegisterComponent,
data: { title: 'Account Register', breadcrumb: 'Account Register' }
}
]
},
{
path: 'cashier',
data: { title: 'Cashier', breadcrumb: 'Cashier' },
@@ -172,6 +203,16 @@ const routes: Routes = [
component: CashHistoryComponent,
data: { title: 'Cash History', breadcrumb: 'Cash History' }
},
{
path: 'day-book',
component: DayBookComponent,
data: { title: 'Day Book', breadcrumb: 'Day Book' }
},
{
path: 'voucher-exceptions',
component: VoucherExceptionsComponent,
data: { title: 'Voucher Exceptions', breadcrumb: 'Voucher Exceptions' }
},
{
path: 'closing-report',
component: ClosingCashReportComponent,
+207
View File
@@ -113,6 +113,146 @@ export class CashierService {
const params = new HttpParams().set('date', date);
return this.http.get('/cashreports/closing', { params });
}
/**
* Every voucher raised on one date — deposits, withdrawals, disbursements, collections and
* bank transfers in a single list, with the day's credit and debit totals.
*/
dayBook(date: string, side?: 'CR' | 'DR', status?: string): Observable<any> {
let params = new HttpParams().set('date', date);
if (side) params = params.set('side', side);
if (status) params = params.set('status', status);
return this.http.get('/cashreports/day-book', { params });
}
// ── Voucher physical verification & attachments ──
// The voucher number always travels in the body or a query parameter, never as a path segment:
// numbers are formatted CR/001/2108/0001 and Spring Security's strict firewall rejects the
// encoded slashes with a 400 before the request reaches the resource.
/** Records the outcome of checking a voucher against the paper slip. */
verifyVoucher(
voucherNo: string,
status: 'VERIFIED' | 'EXCEPTION',
slipAmount?: number,
remarks?: string
): Observable<any> {
return this.http.post('/vouchers/verify', {
voucherNo,
status,
slipAmount: slipAmount != null ? String(slipAmount) : null,
remarks: remarks ?? null,
locale: 'en'
});
}
uploadVoucherAttachment(
voucherNo: string,
fileName: string,
contentType: string,
fileData: string,
description?: string
): Observable<any> {
return this.http.post('/vouchers/attachments', {
voucherNo,
fileName,
contentType,
fileData,
description: description ?? null
});
}
listVoucherAttachments(voucherNo: string): Observable<any> {
const params = new HttpParams().set('voucherNo', voucherNo);
return this.http.get('/vouchers/attachments', { params });
}
deleteVoucherAttachment(id: number): Observable<any> {
return this.http.delete(`/vouchers/attachments/${id}`);
}
/** Fetched as a blob through HttpClient so the interceptor's auth header travels with it. */
downloadVoucherAttachment(id: number): Observable<Blob> {
return this.http.get(`/vouchers/attachments/${id}/download`, { responseType: 'blob' });
}
/**
* Flagged vouchers awaiting (or carrying) a conclusion. Not scoped to a date — an exception
* raised on Friday still needs answering on Monday.
*/
voucherExceptions(status: 'OPEN' | 'RESOLVED' | 'ALL' = 'OPEN'): Observable<any> {
const params = new HttpParams().set('status', status);
return this.http.get('/vouchers/exceptions', { params });
}
/** Closes an exception. Manager / head cashier only, and a supporting document must exist. */
resolveVoucherException(voucherNo: string, resolutionStatus: string, resolutionNotes: string): Observable<any> {
return this.http.post('/vouchers/exceptions/resolve', { voucherNo, resolutionStatus, resolutionNotes });
}
/**
* Account register for one date: what opened and closed that day, each account's balance as it
* stood then, and what it holds now.
*/
accountRegister(date: string, section?: string): Observable<any> {
let params = new HttpParams().set('date', date);
if (section && section !== 'ALL') params = params.set('section', section);
return this.http.get('/accountregister', { params });
}
// ── Branch day register ──
/** The branch's day register, plus which day is currently open. Defaults to the caller's office. */
branchDayRegister(officeId?: number, limit?: number): Observable<any> {
let params = new HttpParams();
if (officeId) params = params.set('officeId', String(officeId));
if (limit) params = params.set('limit', String(limit));
return this.http.get('/branchday', { params });
}
/** Days opened and never closed — today's own open day excluded. */
branchDayMissed(officeId?: number): Observable<any> {
let params = new HttpParams();
if (officeId) params = params.set('officeId', String(officeId));
return this.http.get('/branchday/missed', { params });
}
/**
* What the day should close at, and the arithmetic behind it, so the closing figure does not
* have to be worked out by hand.
*/
branchDayClosePreview(businessDate?: string, officeId?: number): Observable<any> {
let params = new HttpParams();
if (businessDate) params = params.set('businessDate', businessDate);
if (officeId) params = params.set('officeId', String(officeId));
return this.http.get('/branchday/close-preview', { params });
}
openBranchDay(businessDate: string, openingCash?: number, officeId?: number): Observable<any> {
return this.http.post('/branchday/open', {
businessDate,
openingCash: openingCash != null ? String(openingCash) : null,
officeId: officeId ?? null,
locale: 'en'
});
}
/** forceCloseReason is required when closing a date earlier than today. */
closeBranchDay(
businessDate: string,
closingCash: number,
remarks?: string,
forceCloseReason?: string,
officeId?: number
): Observable<any> {
return this.http.post('/branchday/close', {
businessDate,
closingCash: String(closingCash),
remarks: remarks ?? null,
forceCloseReason: forceCloseReason ?? null,
officeId: officeId ?? null,
locale: 'en'
});
}
excessTransactionReport(threshold: number, fromDate?: string, toDate?: string): Observable<any[]> {
let params = new HttpParams().set('threshold', String(threshold));
if (fromDate) params = params.set('fromDate', fromDate);
@@ -188,4 +328,71 @@ export class CashierService {
getShareCollectionStatus(shareAccountId: number): Observable<any> {
return this.http.get(`/cashier/sharecollection/${shareAccountId}`);
}
/** One unapplied Share Sundry receipt, found by the voucher number the member quotes. */
getShareAdvanceByVoucher(voucherNo: string, clientId?: number): Observable<any> {
// The voucher travels as a query parameter, not a path segment: numbers are formatted
// CR/001/2108/0001, and Spring Security's strict firewall rejects the encoded slashes
// (%2F) outright with a 400 before the request ever reaches the resource.
// clientId lets the backend adopt a cashier deposit made straight to the Share Sundry GL,
// which carries no member of its own.
let params = new HttpParams().set('voucherNo', voucherNo);
if (clientId) {
params = params.set('clientId', String(clientId));
}
return this.http.get('/shareadvancecollection/by-voucher', { params });
}
/** Receipts still sitting in Share Sundry, optionally for a single member. */
getUnappliedShareAdvances(clientId?: number): Observable<any> {
const suffix = clientId ? `?clientId=${clientId}` : '';
return this.http.get(`/shareadvancecollection${suffix}`);
}
/** Moves a receipt out of Sundry into share capital for an activated account. */
applyShareAdvance(collectionId: number, data: any): Observable<any> {
return this.http.post(`/shareadvancecollection/${collectionId}/apply`, data);
}
/**
* Whether this share account can be settled from the member's linked savings, and for how much.
*
* Reports a shortfall before the clerk raises anything, so the member can be told at the counter
* rather than after a request has been submitted and has to be rejected.
* @returns {Observable<any>} `{ eligible, reason, shareAmount, chargeAmount, totalRequired,
* savingsAccountNo, savingsBalance }`.
*/
getShareSavingsDebitEligibility(shareAccountId: number): Observable<any> {
return this.http.get(`/sharesavingsdebit/eligibility/${shareAccountId}`);
}
/**
* Records a request to settle a share application by debiting the member's savings. Nothing moves
* until a manager approves it — unlike the cash route, where the cashier's receipt is already the
* second pair of eyes.
*/
submitShareSavingsDebit(data: any): Observable<any> {
return this.http.post('/sharesavingsdebit', data);
}
/** Savings-debit requests, PENDING by default. */
getShareSavingsDebitRequests(status?: string, shareAccountId?: number): Observable<any> {
const params: string[] = [];
if (status) {
params.push(`status=${status}`);
}
if (shareAccountId) {
params.push(`shareAccountId=${shareAccountId}`);
}
return this.http.get(`/sharesavingsdebit${params.length ? '?' + params.join('&') : ''}`);
}
/** Debits the member's savings and issues the shares. Manager only, and not the submitting clerk. */
approveShareSavingsDebit(id: number): Observable<any> {
return this.http.post(`/sharesavingsdebit/${id}/approve`, {});
}
rejectShareSavingsDebit(id: number, rejectionReason?: string): Observable<any> {
return this.http.post(`/sharesavingsdebit/${id}/reject`, { rejectionReason });
}
}
@@ -0,0 +1,393 @@
<!--
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 · DAY BOOK</div>
<h1 class="pg-title">Day Book</h1>
<p class="pg-sub">
Every voucher raised on the selected date — cash deposits, withdrawals, disbursements, collections and bank
transfers, with credit and debit totals.
</p>
</div>
<div class="pg-toolbar">
<button type="button" class="pg-btn" (click)="shiftDate(-1)" title="Previous day">
<mat-icon>chevron_left</mat-icon>
</button>
<label class="pg-field">
<span>Date</span>
<input type="date" [value]="date" (change)="date = $any($event.target).value; load()" />
</label>
<button type="button" class="pg-btn" (click)="shiftDate(1)" title="Next day">
<mat-icon>chevron_right</mat-icon>
</button>
<button type="button" class="pg-btn" (click)="goToday()">Today</button>
<button type="button" class="pg-btn pg-btn--primary" (click)="load()" [disabled]="loading">
<mat-icon>refresh</mat-icon> Reload
</button>
</div>
</header>
<!-- Filters -->
<div class="db-filters">
<label class="pg-field">
<span>Side</span>
<select [(ngModel)]="side" (change)="load()">
<option value="">All</option>
<option value="CR">CR · Credit (money in)</option>
<option value="DR">DR · Debit (money out)</option>
</select>
</label>
<label class="pg-field">
<span>Status</span>
<select [(ngModel)]="status" (change)="load()">
<option value="">All</option>
<option value="APPROVED">Approved</option>
<option value="PENDING">Pending</option>
<option value="REJECTED">Rejected</option>
</select>
</label>
<label class="pg-field db-filters__search">
<span>Search</span>
<input type="text" [(ngModel)]="search" placeholder="Voucher no, account, party, payment type, narration…" />
</label>
<button type="button" class="pg-btn" (click)="exportCsv()" [disabled]="filteredRows.length === 0">
<mat-icon>download</mat-icon> CSV
</button>
</div>
@if (errorMsg) {
<div class="pg-alert pg-alert--error">{{ errorMsg }}</div>
}
<!-- Exceptions surface at the top of the day, not only as a badge buried in their own row: a
flag raised for investigation is useless if someone has to scroll to find it. -->
@if (!loading && exceptionRows.length > 0) {
<div class="db-exceptions">
<div class="db-exceptions__head">
<mat-icon>warning</mat-icon>
<strong>
{{ exceptionRows.length }} voucher{{ exceptionRows.length === 1 ? '' : 's' }} awaiting investigation
</strong>
@if (resolvedExceptionCount > 0) {
<span class="db-muted">({{ resolvedExceptionCount }} already closed)</span>
}
<button type="button" class="pg-btn pg-btn--warn" (click)="toggleExceptionsOnly()">
{{ exceptionsOnly ? 'Show all vouchers' : 'Show only these' }}
</button>
</div>
@for (e of exceptionRows; track e.voucherNo) {
<div class="db-exceptions__item">
<span class="pg-chip">{{ e.voucherNo }}</span>
<span class="db-exceptions__amt">
System ₹{{ e.amount | number: '1.2-2' }}
@if (e.slipAmount != null && e.slipAmount !== e.amount) {
· Slip ₹{{ e.slipAmount | number: '1.2-2' }}
<strong class="db-exceptions__diff"
>(diff ₹{{ e.slipAmount - (e.amount ?? 0) | number: '1.2-2' }})</strong
>
}
</span>
<span class="db-exceptions__why">{{ e.verifyRemarks }}</span>
<span class="db-muted">— {{ e.verifiedByName }}, {{ timeOf(e.verifiedAt) }}</span>
</div>
}
</div>
}
@if (loading) {
<div class="pg-empty">Loading…</div>
}
@if (!loading && loaded && rows.length === 0) {
<div class="pg-empty">
<mat-icon>receipt_long</mat-icon>
<p>No vouchers were raised on {{ date }}.</p>
</div>
}
@if (!loading && filteredRows.length > 0) {
<!-- Totals bar. Reflects what is on screen, so a search or side filter still reconciles. -->
<div class="closing-summary">
<div class="closing-summary__item closing-summary__item--in">
<span class="closing-summary__label">Credit · CR (money in)</span>
<span class="closing-summary__value">₹{{ shownCredit | number: '1.2-2' }}</span>
</div>
<div class="closing-summary__sep">−</div>
<div class="closing-summary__item closing-summary__item--out">
<span class="closing-summary__label">Debit · DR (money out)</span>
<span class="closing-summary__value">₹{{ shownDebit | number: '1.2-2' }}</span>
</div>
<div class="closing-summary__sep">=</div>
<div
class="closing-summary__item"
[class.closing-summary__item--net-pos]="shownCredit - shownDebit >= 0"
[class.closing-summary__item--net-neg]="shownCredit - shownDebit < 0"
>
<span class="closing-summary__label">Net</span>
<span class="closing-summary__value">₹{{ shownCredit - shownDebit | number: '1.2-2' }}</span>
</div>
</div>
<p class="db-note">
Totals count approved vouchers only — pending and rejected rows are listed but never move the money.
</p>
<div class="pg-card">
<table class="pg-table">
<thead>
<tr>
<th>Voucher No</th>
<th>Dr/Cr</th>
<th>Type</th>
<th>Account</th>
<th>Party</th>
<th class="num">Amount</th>
<th>Payment</th>
<th>Status</th>
<th>By</th>
<th>Approved By</th>
<th>Slip Check</th>
<th>Time</th>
<th></th>
</tr>
</thead>
<tbody>
@for (r of filteredRows; track r.voucherNo ?? r.submittedAt) {
<tr [class.db-row--cr]="r.side === 'CR'" [class.db-row--dr]="r.side === 'DR'">
<td>
<span class="pg-chip">{{ r.voucherNo ?? '—' }}</span>
</td>
<td>
<span class="db-side" [class.db-side--cr]="r.side === 'CR'" [class.db-side--dr]="r.side === 'DR'">
{{ r.side }}
</span>
</td>
<td>{{ r.kind }}</td>
<td>
{{ r.accountNo ?? '—' }}
@if (r.accountType) {
<span class="db-muted">· {{ r.accountType }}</span>
}
</td>
<td>{{ r.party ?? '—' }}</td>
<td class="num">₹{{ r.amount | number: '1.2-2' }}</td>
<td>{{ r.paymentTypeName ?? '—' }}</td>
<td>
<span [class]="statusClass(r.status)">{{ r.status }}</span>
</td>
<td>{{ r.handledBy ?? '—' }}</td>
<td>
{{ r.approvedBy ?? '—' }}
@if (r.approvedBy && r.actionedAt) {
<span class="db-muted">· {{ timeOf(r.actionedAt) }}</span>
}
</td>
<td>
@if (r.verifyStatus === 'VERIFIED') {
<span class="db-badge db-badge--ok" [title]="'Verified by ' + (r.verifiedByName ?? '')"
>✓ MATCHED</span
>
} @else if (r.verifyStatus === 'EXCEPTION' && r.resolutionStatus) {
<!-- Closed: the flag is history now, so it reads as settled rather than pending. -->
<span
class="db-badge db-badge--ok"
[title]="resolutionLabel(r.resolutionStatus) + ' — ' + (r.resolutionNotes ?? '')"
>
✓ RESOLVED
</span>
} @else if (r.verifyStatus === 'EXCEPTION') {
<span
class="db-badge db-badge--rejected"
[title]="'Slip ₹' + r.slipAmount + ' vs system ₹' + r.amount + ' — ' + (r.verifyRemarks ?? '')"
>
⚠ EXCEPTION
</span>
} @else {
<span class="db-muted">Not checked</span>
}
</td>
<td>{{ timeOf(r.submittedAt) }}</td>
<td class="db-actions">
<button type="button" class="db-icon-btn" (click)="openVerify(r)" title="Check against the paper slip">
<mat-icon>fact_check</mat-icon>
</button>
<button type="button" class="db-icon-btn" (click)="openAttach(r)" title="Attach or view scanned slip">
<mat-icon>attach_file</mat-icon>
@if (r.attachmentCount > 0) {
<span class="db-icon-btn__count">{{ r.attachmentCount }}</span>
}
</button>
</td>
</tr>
<!-- A flagged voucher spells out its reason in the table itself. A tooltip is not
enough: it cannot be printed, exported, or read without a mouse. -->
@if (r.verifyStatus === 'EXCEPTION' && verifyRow !== r) {
<tr class="db-exc-row" [class.db-exc-row--resolved]="r.resolutionStatus">
<td colspan="13">
<mat-icon>{{ r.resolutionStatus ? 'task_alt' : 'warning' }}</mat-icon>
<strong>Exception</strong>
@if (r.slipAmount != null && r.slipAmount !== r.amount) {
— slip ₹{{ r.slipAmount | number: '1.2-2' }} vs system ₹{{ r.amount | number: '1.2-2' }} (difference
₹{{ r.slipAmount - (r.amount ?? 0) | number: '1.2-2' }})
}
— {{ r.verifyRemarks }}
<span class="db-muted">· raised by {{ r.verifiedByName }} at {{ timeOf(r.verifiedAt) }}</span>
<!-- Both halves of the record: what the cashier found, and how it was settled. -->
@if (r.resolutionStatus) {
<div class="db-exc-row__resolution">
<strong>Closed — {{ resolutionLabel(r.resolutionStatus) }}:</strong>
{{ r.resolutionNotes }}
<span class="db-muted">· by {{ r.resolvedByName }} at {{ timeOf(r.resolvedAt) }}</span>
</div>
}
</td>
</tr>
}
<!-- Verification panel, opened inline under its own voucher so the two amounts sit
next to the row they describe. -->
@if (verifyRow === r) {
<tr class="db-panel-row">
<td colspan="13">
<div class="db-panel">
<div class="db-panel__head">
<strong>Slip check — {{ r.voucherNo }}</strong>
<button type="button" class="db-icon-btn" (click)="closeVerify()" title="Close">
<mat-icon>close</mat-icon>
</button>
</div>
<div class="db-panel__body">
<div class="db-cmp">
<span class="db-cmp__label">System recorded</span>
<span class="db-cmp__value">₹{{ r.amount | number: '1.2-2' }}</span>
</div>
<label class="pg-field">
<span>Amount written on slip</span>
<input type="number" step="0.01" [(ngModel)]="verifySlipAmount" />
</label>
@if (verifySlipBlank) {
<div class="db-cmp">
<span class="db-cmp__label">Difference</span>
<span class="db-cmp__value">—</span>
</div>
} @else if (verifyMismatch) {
<div class="db-cmp db-cmp--bad">
<span class="db-cmp__label">Difference</span>
<span class="db-cmp__value">₹{{ verifyDifference | number: '1.2-2' }}</span>
</div>
} @else {
<div class="db-cmp db-cmp--good">
<span class="db-cmp__label">Match</span>
<span class="db-cmp__value">Amounts agree</span>
</div>
}
</div>
<label class="pg-field db-panel__remarks">
<span>{{ verifyMismatch ? 'What did you find? (required)' : 'Remarks (optional)' }}</span>
<input
type="text"
[(ngModel)]="verifyRemarks"
placeholder="e.g. slip written as 500, system posted 400 — teller keyed it wrong"
/>
</label>
@if (verifyMismatch) {
<p class="db-panel__warn">
Saving this marks the voucher as an <strong>exception</strong> for investigation — it does not
change the posted amount.
</p>
}
<div class="db-panel__actions">
<button
type="button"
class="pg-btn pg-btn--primary"
(click)="saveVerify()"
[disabled]="verifySaving || verifySlipBlank"
>
{{ verifyMismatch ? 'Mark Exception' : 'Mark Verified' }}
</button>
<!-- Raising an exception on an already-matched voucher stays available: a
slip cleared in haste, or a difference noticed later, has to be
recordable without erasing the earlier verdict. -->
@if (!verifyMismatch) {
<button
type="button"
class="pg-btn pg-btn--warn"
(click)="raiseException()"
[disabled]="verifySaving"
>
Raise Exception
</button>
}
<button type="button" class="pg-btn" (click)="closeVerify()">Cancel</button>
</div>
</div>
</td>
</tr>
}
<!-- Attachment panel for this voucher. -->
@if (attachRow === r) {
<tr class="db-panel-row">
<td colspan="13">
<div class="db-panel">
<div class="db-panel__head">
<strong>Attachments — {{ r.voucherNo }}</strong>
<button type="button" class="db-icon-btn" (click)="closeAttach()" title="Close">
<mat-icon>close</mat-icon>
</button>
</div>
<label class="db-upload">
<input type="file" (change)="onFileSelected($event)" [disabled]="attachUploading" />
<span class="db-muted">Scanned slip, cheque image or ID proof · max 10 MB</span>
</label>
@if (attachUploading) {
<p class="db-muted">Uploading…</p>
}
@if (attachLoading) {
<p class="db-muted">Loading…</p>
}
@if (!attachLoading && attachments.length === 0) {
<p class="db-muted">Nothing attached to this voucher yet.</p>
}
@for (a of attachments; track a.id) {
<div class="db-file">
<mat-icon>description</mat-icon>
<span class="db-file__name">{{ a.fileName }}</span>
<span class="db-muted">{{ a.fileSize }} bytes · {{ a.uploadedByName }}</span>
<button type="button" class="db-icon-btn" (click)="downloadAttachment(a)" title="Download">
<mat-icon>download</mat-icon>
</button>
<button type="button" class="db-icon-btn" (click)="deleteAttachment(a)" title="Delete">
<mat-icon>delete</mat-icon>
</button>
</div>
}
</div>
</td>
</tr>
}
}
</tbody>
<tfoot>
<tr>
<td colspan="5">Showing {{ filteredRows.length }} of {{ rows.length }} voucher(s)</td>
<td class="num">₹{{ shownCredit - shownDebit | number: '1.2-2' }}</td>
<td colspan="7"></td>
</tr>
</tfoot>
</table>
</div>
}
@if (!loading && loaded && rows.length > 0 && filteredRows.length === 0) {
<div class="pg-empty">No voucher matches “{{ search }}”.</div>
}
</div>
@@ -0,0 +1,393 @@
/**
* 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/.
*/
/* Layout and page chrome come from cashier-shared.scss; only what is specific to the day book
lives here. */
.db-filters {
display: flex;
align-items: flex-end;
gap: 12px;
flex-wrap: wrap;
padding: 12px 16px;
margin-bottom: 12px;
background: #f8fafc;
border: 1px solid #e2e8f0;
border-radius: 8px;
/* The search box takes the slack so the two selects keep their natural width. */
&__search {
flex: 1 1 260px;
}
}
.db-note {
margin: 8px 0 12px;
font-size: 11px;
color: #94a3b8;
}
/* Dr/Cr marker — the one thing the eye should catch when scanning a day's vouchers. */
.db-side {
display: inline-block;
min-width: 28px;
text-align: center;
padding: 2px 6px;
border-radius: 4px;
font-size: 11px;
font-weight: 800;
letter-spacing: 0.04em;
background: #f1f5f9;
color: #475569;
&--cr {
background: #dcfce7;
color: #15803d;
}
&--dr {
background: #fee2e2;
color: #b91c1c;
}
}
/* A hairline on the leading edge, tinted by side: readable without colouring the whole row,
which would fight the table's own hover state. */
.db-row--cr td:first-child {
box-shadow: inset 3px 0 0 #22c55e;
}
.db-row--dr td:first-child {
box-shadow: inset 3px 0 0 #ef4444;
}
.db-muted {
color: #94a3b8;
font-size: 11px;
}
.db-badge {
display: inline-block;
padding: 2px 8px;
border-radius: 999px;
font-size: 10px;
font-weight: 800;
text-transform: uppercase;
letter-spacing: 0.05em;
background: #f1f5f9;
color: #475569;
&--ok {
background: #dcfce7;
color: #15803d;
}
&--pending {
background: #fef3c7;
color: #b45309;
}
&--rejected {
background: #fee2e2;
color: #b91c1c;
}
}
/* Not defined in cashier-shared.scss, so the day book carries its own. */
.pg-alert {
padding: 10px 14px;
margin-bottom: 12px;
border-radius: 6px;
font-size: 13px;
&--error {
background: #fef2f2;
border: 1px solid #fecaca;
color: #b91c1c;
}
}
/* Exception summary, above the table: the day's flagged vouchers listed in full so nobody has to
hunt for a badge. */
.db-exceptions {
margin-bottom: 14px;
padding: 12px 16px;
background: #fef2f2;
border: 1px solid #fecaca;
border-left: 4px solid #dc2626;
border-radius: 8px;
&__head {
display: flex;
align-items: center;
gap: 10px;
margin-bottom: 10px;
color: #b91c1c;
font-size: 13px;
mat-icon {
font-size: 20px;
width: 20px;
height: 20px;
}
/* Pushed to the right so the count reads first. */
.pg-btn {
margin-left: auto;
height: 30px;
font-size: 12px;
}
}
&__item {
display: flex;
align-items: center;
gap: 10px;
flex-wrap: wrap;
padding: 6px 0;
font-size: 12px;
border-top: 1px dotted #fecaca;
}
&__amt {
font-variant-numeric: tabular-nums;
color: #334155;
}
&__diff {
color: #b91c1c;
}
&__why {
color: #0d1b2a;
font-weight: 600;
}
}
/* The reason spelled out under its own flagged row. */
.db-exc-row td {
background: #fef2f2 !important;
border-bottom: 1px solid #fecaca !important;
color: #7f1d1d;
font-size: 12px;
padding: 8px 14px !important;
mat-icon {
font-size: 15px;
width: 15px;
height: 15px;
vertical-align: -3px;
margin-right: 4px;
}
}
/* A closed exception stays on the row as history, but drops the alarm colouring — it is no longer
something anyone has to act on. */
.db-exc-row--resolved td {
background: #f0fdf4 !important;
border-bottom: 1px solid #bbf7d0 !important;
color: #14532d;
}
.db-exc-row__resolution {
margin-top: 4px;
padding-top: 4px;
border-top: 1px dotted #bbf7d0;
}
.db-actions {
white-space: nowrap;
}
/* Raising an exception is a deliberate, consequential act — styled to read that way without
looking destructive, since nothing is deleted. */
.pg-btn--warn {
background: #fffbeb;
border-color: #fde68a;
color: #92400e;
&:hover {
background: #fef3c7;
}
}
.db-icon-btn {
position: relative;
display: inline-flex;
align-items: center;
justify-content: center;
width: 28px;
height: 28px;
padding: 0;
border: 1px solid #e2e8f0;
border-radius: 6px;
background: #fff;
color: #475569;
cursor: pointer;
&:hover {
background: #f1f5f9;
color: #0d1b2a;
}
mat-icon {
font-size: 16px;
width: 16px;
height: 16px;
}
/* Attachment count rides on the paperclip so a voucher with a scanned slip reads as different
at a glance, without a separate column. */
&__count {
position: absolute;
top: -5px;
right: -5px;
min-width: 14px;
height: 14px;
padding: 0 3px;
border-radius: 999px;
background: #2563eb;
color: #fff;
font-size: 9px;
font-weight: 800;
line-height: 14px;
}
}
/* Inline panel, opened under the row it belongs to. */
.db-panel-row td {
background: #f8fafc !important;
padding: 0 !important;
}
.db-panel {
padding: 14px 16px;
border-left: 3px solid #2563eb;
&__head {
display: flex;
align-items: center;
justify-content: space-between;
margin-bottom: 12px;
font-size: 13px;
color: #0d1b2a;
}
&__body {
display: flex;
align-items: flex-end;
gap: 16px;
flex-wrap: wrap;
}
&__remarks {
display: block;
margin-top: 12px;
max-width: 640px;
}
&__warn {
margin: 10px 0 0;
padding: 8px 12px;
border-radius: 6px;
background: #fffbeb;
border: 1px solid #fde68a;
color: #92400e;
font-size: 12px;
}
&__actions {
display: flex;
gap: 8px;
margin-top: 14px;
}
}
/* The two amounts being compared, side by side. */
.db-cmp {
padding: 8px 14px;
border-radius: 6px;
background: #fff;
border: 1px solid #e2e8f0;
&__label {
display: block;
font-size: 10px;
font-weight: 800;
text-transform: uppercase;
letter-spacing: 0.05em;
color: #94a3b8;
}
&__value {
display: block;
margin-top: 2px;
font-size: 15px;
font-weight: 800;
font-variant-numeric: tabular-nums;
color: #0d1b2a;
}
&--good {
background: #f0fdf4;
border-color: #bbf7d0;
.db-cmp__value {
color: #15803d;
}
}
&--bad {
background: #fef2f2;
border-color: #fecaca;
.db-cmp__value {
color: #b91c1c;
}
}
}
.db-upload {
display: flex;
align-items: center;
gap: 12px;
flex-wrap: wrap;
margin-bottom: 12px;
}
.db-file {
display: flex;
align-items: center;
gap: 10px;
padding: 8px 12px;
margin-bottom: 6px;
background: #fff;
border: 1px solid #e2e8f0;
border-radius: 6px;
font-size: 12px;
mat-icon {
font-size: 18px;
width: 18px;
height: 18px;
color: #64748b;
}
&__name {
font-weight: 700;
color: #0d1b2a;
}
}
.pg-table tfoot td {
background: #f8fafc;
font-weight: 800;
font-size: 12px;
color: #334155;
border-top: 1px solid #e2e8f0;
}
@@ -0,0 +1,519 @@
/**
* Copyright since 2025 Mifos Initiative
*
* This Source Code Form is subject to the terms of the Mozilla Public
* License, v. 2.0. If a copy of the MPL was not distributed with this
* file, You can obtain one at http://mozilla.org/MPL/2.0/.
*/
import { Component, OnInit, inject } from '@angular/core';
import { CommonModule } from '@angular/common';
import { FormsModule } from '@angular/forms';
import { MatIconModule } from '@angular/material/icon';
import { catchError, of } from 'rxjs';
import { CashierService } from '../cashier.service';
interface DayBookRow {
voucherNo: string | null;
side: 'CR' | 'DR';
kind: string;
accountNo: string | null;
accountType: string | null;
party: string | null;
amount: number | null;
paymentTypeName: string | null;
description: string | null;
status: string;
handledBy: string | null;
approvedBy: string | null;
txnId: string | null;
submittedAt: string | null;
actionedAt: string | null;
/** null until someone has physically checked the slip. */
verifyStatus: 'VERIFIED' | 'EXCEPTION' | null;
slipAmount: number | null;
verifyRemarks: string | null;
verifiedByName: string | null;
verifiedAt: string | null;
/** Set once a clerk or manager has closed the exception; null while it is still open. */
resolutionStatus: string | null;
resolutionNotes: string | null;
resolvedByName: string | null;
resolvedAt: string | null;
attachmentCount: number;
}
/**
* Day book — every voucher raised on one date in a single list: cash deposits, withdrawals,
* disbursements, collections and bank transfers, with the day's credit and debit totals.
*/
@Component({
selector: 'mifosx-day-book',
templateUrl: './day-book.component.html',
styleUrls: [
'../cashier-shared.scss',
'./day-book.component.scss'
],
standalone: true,
imports: [
CommonModule,
FormsModule,
MatIconModule
]
})
export class DayBookComponent implements OnInit {
private service = inject(CashierService);
date = '';
side: '' | 'CR' | 'DR' = '';
status = '';
search = '';
rows: DayBookRow[] = [];
creditTotal = 0;
debitTotal = 0;
netTotal = 0;
loading = false;
loaded = false;
errorMsg = '';
ngOnInit(): void {
this.date = this.today();
this.load();
}
/** Local date as yyyy-MM-dd — toISOString() would shift the day in any timezone behind UTC. */
private today(): string {
const d = new Date();
return `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, '0')}-${String(d.getDate()).padStart(2, '0')}`;
}
load(): void {
if (!this.date) return;
this.loading = true;
this.errorMsg = '';
this.service
.dayBook(this.date, this.side || undefined, this.status || undefined)
.pipe(
catchError((err: any) => {
this.errorMsg =
err?.error?.errors?.[0]?.defaultUserMessage ?? err?.error?.message ?? 'Could not load the day book.';
return of(null);
})
)
.subscribe((res: any) => {
this.loading = false;
this.loaded = true;
this.rows = res?.rows ?? [];
this.creditTotal = Number(res?.creditTotal ?? 0);
this.debitTotal = Number(res?.debitTotal ?? 0);
this.netTotal = Number(res?.netTotal ?? 0);
});
}
/** Shifts the selected date by whole days, so yesterday is one click rather than a date picker. */
shiftDate(days: number): void {
const [
y,
m,
d
] = this.date.split('-').map(Number);
const dt = new Date(y, m - 1, d + days);
this.date = `${dt.getFullYear()}-${String(dt.getMonth() + 1).padStart(2, '0')}-${String(dt.getDate()).padStart(2, '0')}`;
this.load();
}
goToday(): void {
this.date = this.today();
this.load();
}
/** Show only the flagged vouchers — set from the exception banner. */
exceptionsOnly = false;
/**
* Vouchers still awaiting a conclusion. A closed exception drops out of the banner — it stays
* visible on its own row as history, but it is no longer something anyone has to act on.
*/
get exceptionRows(): DayBookRow[] {
return this.rows.filter((r) => r.verifyStatus === 'EXCEPTION' && !r.resolutionStatus);
}
/** Closed exceptions, kept for the record rather than for action. */
get resolvedExceptionCount(): number {
return this.rows.filter((r) => r.verifyStatus === 'EXCEPTION' && r.resolutionStatus).length;
}
/** Human label for a resolution code. */
resolutionLabel(status: string | null): string {
switch (status) {
case 'RESOLVED_SYSTEM_ERROR':
return 'System entry was wrong';
case 'RESOLVED_SLIP_ERROR':
return 'Slip was written wrong';
case 'RESOLVED_NO_ACTION':
return 'No action needed';
default:
return status ?? '';
}
}
/** Vouchers nobody has checked against a slip yet. */
get uncheckedCount(): number {
return this.rows.filter((r) => !r.verifyStatus).length;
}
toggleExceptionsOnly(): void {
this.exceptionsOnly = !this.exceptionsOnly;
}
/** Free-text narrowing over the rows already fetched — no extra round trip per keystroke. */
get filteredRows(): DayBookRow[] {
const base = this.exceptionsOnly ? this.exceptionRows : this.rows;
const q = this.search.trim().toLowerCase();
if (!q) return base;
return base.filter((r) =>
[
r.voucherNo,
r.accountNo,
r.party,
r.kind,
r.paymentTypeName,
r.handledBy,
r.description
]
.filter(Boolean)
.some((v) => String(v).toLowerCase().includes(q))
);
}
/** Totals for what is on screen, so a search or side filter still reconciles. */
get shownCredit(): number {
return this.filteredRows
.filter((r) => r.side === 'CR' && this.countsAsMoney(r))
.reduce((s, r) => s + Number(r.amount ?? 0), 0);
}
get shownDebit(): number {
return this.filteredRows
.filter((r) => r.side === 'DR' && this.countsAsMoney(r))
.reduce((s, r) => s + Number(r.amount ?? 0), 0);
}
/** Only approved money is real money — a pending or rejected voucher must not move the totals. */
private countsAsMoney(r: DayBookRow): boolean {
const s = (r.status ?? '').toUpperCase();
return s === 'APPROVED' || s === 'COMPLETED';
}
statusClass(status: string): string {
switch ((status ?? '').toUpperCase()) {
case 'APPROVED':
case 'COMPLETED':
return 'db-badge db-badge--ok';
case 'PENDING':
return 'db-badge db-badge--pending';
case 'REJECTED':
return 'db-badge db-badge--rejected';
default:
return 'db-badge';
}
}
/** Time of day only — the date is already the page's subject. */
timeOf(iso: string | null): string {
if (!iso) return '—';
const t = iso.split('T')[1];
return t ? t.slice(0, 5) : '—';
}
// ── Physical slip verification ────────────────────────────────────────────
/** Row whose verification panel is open, or null when none is. */
verifyRow: DayBookRow | null = null;
verifySlipAmount: number | null = null;
verifyRemarks = '';
verifySaving = false;
openVerify(r: DayBookRow): void {
this.verifyRow = r;
// Pre-filled with the system's own figure: the common case is that the two agree, and when
// they don't the cashier only has to change the digits that actually differ.
this.verifySlipAmount = Number(r.amount ?? 0);
this.verifyRemarks = r.verifyRemarks ?? '';
this.attachRow = null;
}
closeVerify(): void {
this.verifyRow = null;
this.verifySlipAmount = null;
this.verifyRemarks = '';
}
/** True when the amount typed off the slip differs from what the system recorded. */
get verifyMismatch(): boolean {
if (!this.verifyRow || this.verifySlipAmount == null) return false;
return Number(this.verifySlipAmount) !== Number(this.verifyRow.amount ?? 0);
}
/**
* True while the slip box is empty. Clearing the field must not read as "amounts agree" — an
* unanswered question is not a match, so the panel shows neither verdict until a figure is typed.
*/
get verifySlipBlank(): boolean {
return this.verifySlipAmount == null || String(this.verifySlipAmount).trim() === '';
}
get verifyDifference(): number {
if (!this.verifyRow || this.verifySlipAmount == null) return 0;
return Number(this.verifySlipAmount) - Number(this.verifyRow.amount ?? 0);
}
/**
* Saves the verdict. The status is derived from the figures rather than picked by the user —
* if the slip and the system disagree it is an exception, whatever anyone intended to click.
*/
saveVerify(): void {
const row = this.verifyRow;
if (!row?.voucherNo) return;
const isException = this.verifyMismatch;
if (isException && !this.verifyRemarks.trim()) {
this.errorMsg = 'The amounts differ — add a remark saying what you found before saving.';
return;
}
this.verifySaving = true;
this.errorMsg = '';
this.service
.verifyVoucher(
row.voucherNo,
isException ? 'EXCEPTION' : 'VERIFIED',
isException ? Number(this.verifySlipAmount) : undefined,
this.verifyRemarks.trim() || undefined
)
.pipe(
catchError((err: any) => {
this.errorMsg =
err?.error?.errors?.[0]?.defaultUserMessage ?? err?.error?.message ?? 'Could not save the verification.';
return of(null);
})
)
.subscribe((res: any) => {
this.verifySaving = false;
if (res) {
this.closeVerify();
this.load();
}
});
}
/**
* Flags a voucher whose amounts agree — the problem is something other than the figure: a
* missing signature, a wrong account, a slip the member disputes. Deliberately not a "clear the
* check" action: erasing a verdict would erase the evidence that anything was ever wrong, which
* is exactly what an investigation needs.
*/
raiseException(): void {
const row = this.verifyRow;
if (!row?.voucherNo) return;
if (!this.verifyRemarks.trim()) {
this.errorMsg = 'Say what the problem is before raising an exception.';
return;
}
this.verifySaving = true;
this.errorMsg = '';
this.service
// The slip amount is sent as-is: the figures match, so it doubles as the record of what
// both the slip and the system said at the time the exception was raised.
.verifyVoucher(
row.voucherNo,
'EXCEPTION',
Number(this.verifySlipAmount ?? row.amount ?? 0),
this.verifyRemarks.trim()
)
.pipe(
catchError((err: any) => {
this.errorMsg =
err?.error?.errors?.[0]?.defaultUserMessage ?? err?.error?.message ?? 'Could not raise the exception.';
return of(null);
})
)
.subscribe((res: any) => {
this.verifySaving = false;
if (res) {
this.closeVerify();
this.load();
}
});
}
// ── Attachments ───────────────────────────────────────────────────────────
attachRow: DayBookRow | null = null;
attachments: any[] = [];
attachLoading = false;
attachUploading = false;
openAttach(r: DayBookRow): void {
this.attachRow = r;
this.verifyRow = null;
this.attachments = [];
if (!r.voucherNo) return;
this.attachLoading = true;
this.service
.listVoucherAttachments(r.voucherNo)
.pipe(catchError(() => of(null)))
.subscribe((res: any) => {
this.attachLoading = false;
this.attachments = res?.attachments ?? [];
});
}
closeAttach(): void {
this.attachRow = null;
this.attachments = [];
}
onFileSelected(event: Event): void {
const input = event.target as HTMLInputElement;
const file = input.files?.[0];
const row = this.attachRow;
if (!file || !row?.voucherNo) return;
// 10 MB, matching the backend's own ceiling — rejecting here saves uploading the whole file
// only to have it refused.
if (file.size > 10 * 1024 * 1024) {
this.errorMsg = 'That file is larger than 10 MB. Please scan it at a lower resolution.';
input.value = '';
return;
}
const reader = new FileReader();
reader.onload = () => {
this.attachUploading = true;
this.errorMsg = '';
this.service
.uploadVoucherAttachment(
row.voucherNo!,
file.name,
file.type || 'application/octet-stream',
String(reader.result)
)
.pipe(
catchError((err: any) => {
this.errorMsg =
err?.error?.errors?.[0]?.defaultUserMessage ?? err?.error?.message ?? 'Upload failed. Please try again.';
return of(null);
})
)
.subscribe((res: any) => {
this.attachUploading = false;
input.value = '';
if (res) {
this.openAttach(row);
this.load();
}
});
};
reader.readAsDataURL(file);
}
/**
* Fetched through HttpClient rather than window.open: the download endpoint needs the auth
* header the interceptor adds, which a bare browser navigation would not carry. The blob is
* then handed to the browser as a normal save.
*/
downloadAttachment(a: any): void {
this.service
.downloadVoucherAttachment(a.id)
.pipe(catchError(() => of(null)))
.subscribe((blob: Blob | null) => {
if (!blob) {
this.errorMsg = 'Could not download that file.';
return;
}
const url = URL.createObjectURL(blob);
const link = document.createElement('a');
link.href = url;
link.download = a.fileName ?? 'attachment';
link.click();
URL.revokeObjectURL(url);
});
}
deleteAttachment(a: any): void {
const row = this.attachRow;
this.service
.deleteVoucherAttachment(a.id)
.pipe(catchError(() => of(null)))
.subscribe(() => {
if (row) this.openAttach(row);
this.load();
});
}
exportCsv(): void {
const header = [
'Voucher No',
'Side',
'Type',
'Account',
'Party',
'Amount',
'Payment Type',
'Status',
'Handled By',
'Approved By',
'Slip Check',
'Slip Amount',
'Remarks',
'Checked By',
'Resolution',
'Resolution Notes',
'Closed By',
'Attachments',
'Time'
];
const lines = this.filteredRows.map((r) =>
[
r.voucherNo ?? '',
r.side,
r.kind,
r.accountNo ?? '',
r.party ?? '',
String(r.amount ?? 0),
r.paymentTypeName ?? '',
r.status,
r.handledBy ?? '',
r.approvedBy ?? '',
r.verifyStatus ?? 'NOT CHECKED',
r.slipAmount != null ? String(r.slipAmount) : '',
r.verifyRemarks ?? '',
r.verifiedByName ?? '',
r.resolutionStatus ? this.resolutionLabel(r.resolutionStatus) : '',
r.resolutionNotes ?? '',
r.resolvedByName ?? '',
String(r.attachmentCount ?? 0),
this.timeOf(r.submittedAt)
]
// Quote every field and double any embedded quote: names and narrations routinely
// contain commas, which would otherwise split into extra columns.
.map((v) => `"${String(v).replace(/"/g, '""')}"`)
.join(',')
);
const csv = [
header.join(','),
...lines
].join('\n');
const blob = new Blob([csv], { type: 'text/csv;charset=utf-8;' });
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
a.download = `day-book-${this.date}.csv`;
a.click();
URL.revokeObjectURL(url);
}
}
@@ -2317,8 +2317,9 @@ export class TransactionEntryComponent implements OnInit, OnDestroy {
alerts.push({ type: 'danger', message: 'Sanctions Match — DO NOT process' });
if (isYes(this.kycDetails?.willfulDefaulter))
alerts.push({ type: 'danger', message: 'Willful Defaulter — Refer to Manager' });
if (this.kycDetails?.amlCheckStatus === 'FLAGGED')
alerts.push({ type: 'danger', message: 'AML Flag — Suspicious Activity' });
// No AML-suspicion banner here by design. Sanctions and willful-defaulter above are legal
// prohibitions the cashier has to act on at the counter; AML suspicion is not — showing it
// risks tipping off the customer. It stays in the AML review queue and the STR/CTR reports.
return alerts;
}
@@ -3220,21 +3221,7 @@ export class TransactionEntryComponent implements OnInit, OnDestroy {
return;
}
const AML_GL = 200000;
if (amount > AML_GL && isCash) {
this.showConfirmAlert(
'warning',
'AML Alert — Large Cash Transaction',
[
`Transaction Amount: ₹${amount.toLocaleString('en-IN', { minimumFractionDigits: 2 })}`,
'This transaction requires manager approval.',
'Have you obtained approval to proceed?'
],
'Yes, Proceed',
() => this.openConfirmDialog(row, amount, isDeposit, selectedPtEarly)
);
return;
}
// No AML prompt on the GL path either — see the note on the savings/current path below.
this.openConfirmDialog(row, amount, isDeposit, selectedPtEarly);
return;
}
@@ -3314,23 +3301,12 @@ export class TransactionEntryComponent implements OnInit, OnDestroy {
}
}
// 6. AML large cash
const AML_THRESHOLD = 200000;
if (amount > AML_THRESHOLD && selectedPt?.isCashPayment) {
this.showConfirmAlert(
'warning',
'AML Alert — Large Cash Transaction',
[
`Transaction Amount: ₹${amount.toLocaleString('en-IN', { minimumFractionDigits: 2 })}`,
'This transaction requires manager approval.',
'Have you obtained approval to proceed?'
],
'Yes, Proceed',
() => this.openConfirmDialog(row, amount, isDeposit, selectedPt)
);
return;
}
// AML monitoring is deliberately invisible here. Every posted transaction is evaluated
// server-side against the AML rule catalog (AmlRuleEvaluationService) after it succeeds, and
// anything it flags surfaces only in the AML reports — alerts, STR cases and CTR returns.
// The cashier is never prompted or warned at the counter: tipping off the customer defeats
// the purpose of suspicious-transaction monitoring, and a hardcoded client-side threshold
// duplicated none of the real rule set anyway.
this.openConfirmDialog(row, amount, isDeposit, selectedPt);
}
@@ -0,0 +1,183 @@
<!--
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 · EXCEPTIONS</div>
<h1 class="pg-title">Voucher Exceptions</h1>
<p class="pg-sub">
Vouchers flagged at the counter — where the slip and the system disagree, or something else about the entry is
wrong. A clerk or manager reviews each one with the cashier, records what was found, and attaches the supporting
document.
</p>
</div>
<div class="pg-toolbar">
<label class="pg-field">
<span>Show</span>
<select [(ngModel)]="filter" (change)="load()">
<option value="OPEN">Open</option>
<option value="RESOLVED">Resolved</option>
<option value="ALL">All</option>
</select>
</label>
<button type="button" class="pg-btn pg-btn--primary" (click)="load()" [disabled]="loading">
<mat-icon>refresh</mat-icon> Reload
</button>
</div>
</header>
@if (errorMsg) {
<div class="pg-alert pg-alert--error">{{ errorMsg }}</div>
}
@if (!canResolve && loaded) {
<div class="ve-note">
<mat-icon>info</mat-icon>
You can see these exceptions but not close them — a Clerk, Head Cashier or Bank Manager records the conclusion, so
that whoever raised the flag is not the one who rules on it.
</div>
}
@if (loading) {
<div class="pg-empty">Loading…</div>
}
@if (!loading && loaded && exceptions.length === 0) {
<div class="pg-empty">
<mat-icon>check_circle</mat-icon>
<p>{{ filter === 'OPEN' ? 'No open exceptions. Everything checked so far agrees.' : 'Nothing to show.' }}</p>
</div>
}
@for (e of exceptions; track e.voucherNo) {
<div class="ve-card" [class.ve-card--resolved]="e.resolutionStatus">
<div class="ve-card__head">
<span class="pg-chip">{{ e.voucherNo }}</span>
@if (e.resolutionStatus) {
<span class="db-badge db-badge--ok">RESOLVED</span>
} @else {
<span class="db-badge db-badge--rejected">⚠ OPEN</span>
}
<span class="db-muted">
{{ e.accountNo }} · {{ e.party }} · raised by {{ e.raisedByName }} on {{ stamp(e.raisedAt) }}
</span>
@if (!e.resolutionStatus && canResolve) {
<button type="button" class="pg-btn pg-btn--primary ve-card__act" (click)="openResolve(e)">
Review &amp; Close
</button>
}
</div>
<div class="ve-card__amts">
<div class="db-cmp">
<span class="db-cmp__label">System recorded</span>
<span class="db-cmp__value">₹{{ e.systemAmount | number: '1.2-2' }}</span>
</div>
@if (!amountsAgree(e)) {
<div class="db-cmp db-cmp--bad">
<span class="db-cmp__label">Written on slip</span>
<span class="db-cmp__value">₹{{ e.slipAmount | number: '1.2-2' }}</span>
</div>
<div class="db-cmp db-cmp--bad">
<span class="db-cmp__label">Difference</span>
<span class="db-cmp__value">₹{{ difference(e) | number: '1.2-2' }}</span>
</div>
} @else {
<div class="db-cmp">
<span class="db-cmp__label">Amounts</span>
<span class="db-cmp__value">Agree — flagged for another reason</span>
</div>
}
</div>
<p class="ve-card__why"><strong>Cashier reported:</strong> {{ e.remarks }}</p>
@if (e.resolutionStatus) {
<div class="ve-card__resolution">
<strong>{{ resolutionLabel(e.resolutionStatus) }}</strong> — {{ e.resolutionNotes }}
<span class="db-muted">· closed by {{ e.resolvedByName }} on {{ stamp(e.resolvedAt) }}</span>
</div>
}
<!-- Resolution panel, opened under the exception it closes. -->
@if (resolveRow === e) {
<div class="db-panel ve-panel">
<div class="db-panel__head">
<strong>Close exception — {{ e.voucherNo }}</strong>
<button type="button" class="db-icon-btn" (click)="closeResolve()" title="Close">
<mat-icon>close</mat-icon>
</button>
</div>
<label class="pg-field ve-panel__field">
<span>What was wrong?</span>
<select [(ngModel)]="resolutionStatus">
<option value="">— Select —</option>
@for (o of resolutionOptions; track o.value) {
<option [value]="o.value">{{ o.label }}</option>
}
</select>
</label>
<label class="pg-field ve-panel__field">
<span>What was found, and what was done about it?</span>
<input
type="text"
[(ngModel)]="resolutionNotes"
placeholder="e.g. teller keyed 700 instead of 500 — reversing entry passed, member informed"
/>
</label>
<div class="ve-panel__docs">
<strong>Supporting document</strong>
<p class="db-muted">
An exception cannot be closed on someone's word alone — attach the corrected slip, a signed note, or
whatever the decision rests on.
</p>
<label class="db-upload">
<input type="file" (change)="onFileSelected($event)" [disabled]="attachUploading" />
<span class="db-muted">Max 10 MB</span>
</label>
@if (attachUploading) {
<p class="db-muted">Uploading…</p>
}
@if (attachLoading) {
<p class="db-muted">Loading…</p>
}
@if (!attachLoading && attachments.length === 0) {
<p class="ve-panel__warn">No document attached yet — required before closing.</p>
}
@for (a of attachments; track a.id) {
<div class="db-file">
<mat-icon>description</mat-icon>
<span class="db-file__name">{{ a.fileName }}</span>
<span class="db-muted">{{ a.uploadedByName }}</span>
<button type="button" class="db-icon-btn" (click)="downloadAttachment(a)" title="Download">
<mat-icon>download</mat-icon>
</button>
</div>
}
</div>
<div class="db-panel__actions">
<button
type="button"
class="pg-btn pg-btn--primary"
(click)="saveResolution()"
[disabled]="!canSubmitResolution"
>
Close Exception
</button>
<button type="button" class="pg-btn" (click)="closeResolve()">Cancel</button>
</div>
</div>
}
</div>
}
</div>
@@ -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/.
*/
/* Page chrome comes from cashier-shared.scss, and the comparison tiles, panel and file rows are
shared with the day book. Only the exception card itself is defined here. */
.ve-note {
display: flex;
align-items: center;
gap: 8px;
padding: 10px 14px;
margin-bottom: 14px;
background: #eff6ff;
border: 1px solid #bfdbfe;
border-radius: 8px;
color: #1e40af;
font-size: 12px;
mat-icon {
font-size: 18px;
width: 18px;
height: 18px;
}
}
/* One card per flagged voucher. Open ones carry a red edge; closed ones go green, so a queue
reads at a glance without opening anything. */
.ve-card {
padding: 14px 16px;
margin-bottom: 12px;
background: #fff;
border: 1px solid #e2e8f0;
border-left: 4px solid #dc2626;
border-radius: 8px;
&--resolved {
border-left-color: #16a34a;
background: #fafffb;
}
&__head {
display: flex;
align-items: center;
gap: 10px;
flex-wrap: wrap;
margin-bottom: 12px;
}
/* Pushed right so the identity of the voucher reads first. */
&__act {
margin-left: auto;
height: 32px;
font-size: 12px;
}
&__amts {
display: flex;
align-items: flex-end;
gap: 12px;
flex-wrap: wrap;
margin-bottom: 12px;
}
&__why {
margin: 0;
padding: 8px 12px;
background: #f8fafc;
border-radius: 6px;
font-size: 12px;
color: #0d1b2a;
}
&__resolution {
margin-top: 10px;
padding: 8px 12px;
background: #f0fdf4;
border: 1px solid #bbf7d0;
border-radius: 6px;
font-size: 12px;
color: #14532d;
}
}
.ve-panel {
margin-top: 12px;
background: #f8fafc;
border: 1px solid #e2e8f0;
border-radius: 8px;
&__field {
display: block;
margin-bottom: 12px;
max-width: 640px;
}
&__docs {
padding: 12px;
background: #fff;
border: 1px solid #e2e8f0;
border-radius: 6px;
font-size: 12px;
p {
margin: 4px 0 10px;
}
}
&__warn {
padding: 6px 10px;
background: #fffbeb;
border: 1px solid #fde68a;
border-radius: 4px;
color: #92400e;
}
}
@@ -0,0 +1,248 @@
/**
* Copyright since 2025 Mifos Initiative
*
* This Source Code Form is subject to the terms of the Mozilla Public
* License, v. 2.0. If a copy of the MPL was not distributed with this
* file, You can obtain one at http://mozilla.org/MPL/2.0/.
*/
import { Component, OnInit, inject } from '@angular/core';
import { CommonModule } from '@angular/common';
import { FormsModule } from '@angular/forms';
import { MatIconModule } from '@angular/material/icon';
import { catchError, of } from 'rxjs';
import { CashierService } from '../cashier.service';
interface VoucherException {
voucherNo: string;
systemAmount: number | null;
slipAmount: number | null;
remarks: string | null;
raisedByName: string | null;
raisedAt: string | null;
accountNo: string | null;
party: string | null;
raisedAgainst: string | null;
attachmentCount: number;
resolutionStatus: string | null;
resolutionNotes: string | null;
resolvedByName: string | null;
resolvedAt: string | null;
}
/**
* Voucher exceptions raised at the counter, and the conclusion recorded on each.
*
* The cashier who spots a difference cannot close it — this screen is where a clerk, head cashier
* or manager sits down with them, records what was actually wrong, and attaches the paper that
* supports the decision.
*/
@Component({
selector: 'mifosx-voucher-exceptions',
templateUrl: './voucher-exceptions.component.html',
styleUrls: [
'../cashier-shared.scss',
'../day-book/day-book.component.scss',
'./voucher-exceptions.component.scss'
],
standalone: true,
imports: [
CommonModule,
FormsModule,
MatIconModule
]
})
export class VoucherExceptionsComponent implements OnInit {
private service = inject(CashierService);
filter: 'OPEN' | 'RESOLVED' | 'ALL' = 'OPEN';
exceptions: VoucherException[] = [];
/** Set by the backend from the caller's own roles — the cashier who raised a flag cannot close it. */
canResolve = false;
loading = false;
loaded = false;
errorMsg = '';
// Resolution panel
resolveRow: VoucherException | null = null;
resolutionStatus = '';
resolutionNotes = '';
resolveSaving = false;
// Attachments for the voucher being resolved
attachments: any[] = [];
attachLoading = false;
attachUploading = false;
readonly resolutionOptions = [
{ value: 'RESOLVED_SYSTEM_ERROR', label: 'System entry was wrong' },
{ value: 'RESOLVED_SLIP_ERROR', label: 'Slip was written wrong' },
{ value: 'RESOLVED_NO_ACTION', label: 'No action needed' }
];
ngOnInit(): void {
this.load();
}
load(): void {
this.loading = true;
this.errorMsg = '';
this.service
.voucherExceptions(this.filter)
.pipe(
catchError((err: any) => {
this.errorMsg =
err?.error?.errors?.[0]?.defaultUserMessage ?? err?.error?.message ?? 'Could not load voucher exceptions.';
return of(null);
})
)
.subscribe((res: any) => {
this.loading = false;
this.loaded = true;
this.exceptions = res?.exceptions ?? [];
this.canResolve = !!res?.canResolve;
});
}
difference(e: VoucherException): number {
if (e.slipAmount == null) return 0;
return Number(e.slipAmount) - Number(e.systemAmount ?? 0);
}
/** True when the flag was about something other than the amount. */
amountsAgree(e: VoucherException): boolean {
return e.slipAmount == null || Number(e.slipAmount) === Number(e.systemAmount ?? 0);
}
openResolve(e: VoucherException): void {
this.resolveRow = e;
this.resolutionStatus = '';
this.resolutionNotes = '';
this.loadAttachments(e);
}
closeResolve(): void {
this.resolveRow = null;
this.attachments = [];
}
private loadAttachments(e: VoucherException): void {
this.attachments = [];
this.attachLoading = true;
this.service
.listVoucherAttachments(e.voucherNo)
.pipe(catchError(() => of(null)))
.subscribe((res: any) => {
this.attachLoading = false;
this.attachments = res?.attachments ?? [];
});
}
onFileSelected(event: Event): void {
const input = event.target as HTMLInputElement;
const file = input.files?.[0];
const row = this.resolveRow;
if (!file || !row) return;
// 10 MB, matching the backend — refusing here saves uploading the whole file to be rejected.
if (file.size > 10 * 1024 * 1024) {
this.errorMsg = 'That file is larger than 10 MB. Please scan it at a lower resolution.';
input.value = '';
return;
}
const reader = new FileReader();
reader.onload = () => {
this.attachUploading = true;
this.errorMsg = '';
this.service
.uploadVoucherAttachment(
row.voucherNo,
file.name,
file.type || 'application/octet-stream',
String(reader.result),
'Supporting document for exception resolution'
)
.pipe(
catchError((err: any) => {
this.errorMsg =
err?.error?.errors?.[0]?.defaultUserMessage ?? err?.error?.message ?? 'Upload failed. Please try again.';
return of(null);
})
)
.subscribe((res: any) => {
this.attachUploading = false;
input.value = '';
if (res) {
this.loadAttachments(row);
this.load();
}
});
};
reader.readAsDataURL(file);
}
downloadAttachment(a: any): void {
this.service
.downloadVoucherAttachment(a.id)
.pipe(catchError(() => of(null)))
.subscribe((blob: Blob | null) => {
if (!blob) {
this.errorMsg = 'Could not download that file.';
return;
}
const url = URL.createObjectURL(blob);
const link = document.createElement('a');
link.href = url;
link.download = a.fileName ?? 'attachment';
link.click();
URL.revokeObjectURL(url);
});
}
/** Mirrors the backend's own rule, so the button explains itself before it is pressed. */
get canSubmitResolution(): boolean {
return (
!!this.resolutionStatus && !!this.resolutionNotes.trim() && this.attachments.length > 0 && !this.resolveSaving
);
}
saveResolution(): void {
const row = this.resolveRow;
if (!row) return;
this.resolveSaving = true;
this.errorMsg = '';
this.service
.resolveVoucherException(row.voucherNo, this.resolutionStatus, this.resolutionNotes.trim())
.pipe(
catchError((err: any) => {
this.errorMsg =
err?.error?.errors?.[0]?.defaultUserMessage ?? err?.error?.message ?? 'Could not close the exception.';
return of(null);
})
)
.subscribe((res: any) => {
this.resolveSaving = false;
if (res) {
this.closeResolve();
this.load();
}
});
}
resolutionLabel(status: string | null): string {
if (!status) return '';
return this.resolutionOptions.find((o) => o.value === status)?.label ?? status;
}
/** Date and time — an exception queue spans days, so the day matters here. */
stamp(iso: string | null): string {
if (!iso) return '—';
const [
d,
t
] = iso.split('T');
return t ? `${d} ${t.slice(0, 5)}` : d;
}
}
+19 -31
View File
@@ -20,37 +20,29 @@ import { SILENT_HTTP_ERROR } from '../core/http/silent-error.token';
export class DividendClaimRequestService {
private http = inject(HttpClient);
/**
* Records a claim for an unclaimed dividend, collected as a Demand Draft.
*
* A dividend is only ever paid out as a draft — the savings-credit, cash and loan-set-off routes
* this endpoint once accepted are retired, so the payload carries just what a draft needs: how
* much, drawn in whose favour, and payable where.
*/
submit(
lockId: number,
breakdown: {
loanId?: number;
loanApplyAmount?: number;
savingsAccountId?: number;
savingsApplyAmount?: number;
cashApplyAmount?: number;
tokNo?: string;
claim: {
ddApplyAmount: number;
ddBeneficiaryName: string;
ddPayableAtBranch: string;
ddPayableAtCity: string;
}
): Observable<any> {
const body: any = { lockId };
if (breakdown.loanId != null) {
body.loanId = breakdown.loanId;
}
if (breakdown.loanApplyAmount != null) {
body.loanApplyAmount = String(breakdown.loanApplyAmount);
}
if (breakdown.savingsAccountId != null) {
body.savingsAccountId = breakdown.savingsAccountId;
}
if (breakdown.savingsApplyAmount != null) {
body.savingsApplyAmount = String(breakdown.savingsApplyAmount);
}
if (breakdown.cashApplyAmount != null) {
body.cashApplyAmount = String(breakdown.cashApplyAmount);
}
if (breakdown.tokNo != null) {
body.tokNo = breakdown.tokNo;
}
return this.http.post('/clerk/dividend-claim-requests', body);
return this.http.post('/clerk/dividend-claim-requests', {
lockId,
ddApplyAmount: String(claim.ddApplyAmount),
ddBeneficiaryName: claim.ddBeneficiaryName,
ddPayableAtBranch: claim.ddPayableAtBranch,
ddPayableAtCity: claim.ddPayableAtCity
});
}
// Silenced: onTokNoBlur() in Transaction Entry probes this only after the withdrawal-request
@@ -68,10 +60,6 @@ export class DividendClaimRequestService {
});
}
getOverdueLoans(clientId: number): Observable<any> {
return this.http.get(`/clerk/dividend-claim-requests/overdue-loans/${clientId}`);
}
list(status = 'PENDING'): Observable<any> {
return this.http.get('/clerk/dividend-claim-requests', { params: { status } });
}
@@ -200,11 +200,47 @@
<button type="button" class="pg-btn" [disabled]="locksLoading" (click)="loadLocks()">Refresh</button>
</label>
</div>
<!-- Financial-year chips, built from the locks themselves: a new declaration adds its year here
on its own, and a year with nothing left to show stops being offered. Shown even for a single
year, so the filter is visibly there before a second year exists to use it on. -->
@if (lockYears.length > 0) {
<div class="dd-year-filter">
<span class="dd-year-filter__label">Financial year</span>
<div class="dd-filter-group">
<button
type="button"
class="dd-filter-btn"
[class.dd-filter-btn--active]="lockYearFilter === 'ALL'"
(click)="setLockYearFilter('ALL')"
>
All years
</button>
@for (year of lockYears; track year) {
<button
type="button"
class="dd-filter-btn"
[class.dd-filter-btn--active]="lockYearFilter === year"
(click)="setLockYearFilter(year)"
>
FY {{ year }}
</button>
}
</div>
<span class="dd-year-filter__total">
{{ visibleLocks.length }} lock{{ visibleLocks.length === 1 ? '' : 's' }} ·
{{ visibleLocksTotal | number: '1.2-2' }}
</span>
</div>
}
<table class="pg-table">
<thead>
<tr>
<th>Client</th>
<th>Savings Account</th>
<!-- Which declaration this lock came from. A member can hold several unclaimed dividends at
once — this year's plus an earlier year they never collected — and amount alone cannot
tell them apart. -->
<th>Dividend</th>
<th class="num">Amount</th>
<th>Locked Date</th>
<th>Release Due</th>
@@ -218,16 +254,23 @@
<tr>
<td colspan="8" class="pg-empty">Loading…</td>
</tr>
} @else if (locksLoaded && locks.length === 0) {
} @else if (locksLoaded && visibleLocks.length === 0) {
<tr>
<td colspan="8" class="pg-empty">No dividend locks match this filter.</td>
</tr>
} @else {
@for (l of locks; track l.lockId) {
@for (l of visibleLocks; track l.lockId) {
<tr>
<td>{{ l.clientName }}</td>
<td>
<span class="pg-chip">{{ l.savingsAccountNo }}</span>
@if (l.financialYear) {
<div class="dd-dividend-year">FY {{ l.financialYear }}</div>
<div class="dd-dividend-period">
{{ l.productName }} · {{ l.periodStartDate }} → {{ l.periodEndDate }}
</div>
} @else {
—
}
</td>
<td class="num">{{ l.amount | number: '1.2-2' }}</td>
<td>{{ l.lockedDate }}</td>
@@ -249,48 +292,33 @@
<td colspan="8">
<div class="dd-apply-panel dd-apply-panel--wrap">
<div class="dd-apply-panel-title">
Unclaimed amount: {{ l.amount | number: '1.2-2' }} — split it below (loan, savings, cash)
Unclaimed amount: {{ l.amount | number: '1.2-2' }}
@if (l.financialYear) {
<span> (FY {{ l.financialYear }} dividend)</span>
}
— issue as a Demand Draft
</div>
@if (claimOverdueLoans[l.lockId] && claimOverdueLoans[l.lockId].length > 0) {
<label class="inv-form-field">
<span>Apply to Overdue Loan</span>
<select [(ngModel)]="claimLoanId[l.lockId]">
@for (loan of claimOverdueLoans[l.lockId]; track loan.loanId) {
<option [ngValue]="loan.loanId">
{{ loan.accountNo }} — Overdue {{ loan.overdueAmount | number: '1.2-2' }}
</option>
}
</select>
</label>
<label class="inv-form-field">
<span>Loan Amount</span>
<input type="number" [(ngModel)]="claimLoanAmount[l.lockId]" min="0" step="0.01" />
</label>
} @else {
<div class="pg-empty-inline">No overdue loan for this client.</div>
}
<!-- A dividend is collected only as a Demand Draft. Amount defaults to the full
unclaimed balance; anything left unclaimed stays on the lock for a later visit. -->
<label class="inv-form-field">
<span>Credit to Savings</span>
<input type="number" [(ngModel)]="claimSavingsAmount[l.lockId]" min="0" step="0.01" />
<span>DD Amount</span>
<input type="number" [(ngModel)]="claimDdAmount[l.lockId]" min="0" step="0.01" />
</label>
@if (!l.savingsAccountId && claimSavingsAmount[l.lockId] > 0) {
<label class="inv-form-field">
<span>Savings Account ID (no linked account — enter one to credit)</span>
<input type="number" [(ngModel)]="claimSavingsAccountOverride[l.lockId]" min="1" step="1" />
</label>
}
<label class="inv-form-field">
<span>Cash</span>
<input type="number" [(ngModel)]="claimCashAmount[l.lockId]" min="0" step="0.01" />
<span>Beneficiary Name</span>
<input type="text" [(ngModel)]="claimDdBeneficiary[l.lockId]" />
</label>
<label class="inv-form-field">
<span>Payable At — Branch</span>
<input type="text" [(ngModel)]="claimDdPayableAtBranch[l.lockId]" />
</label>
<label class="inv-form-field">
<span>Payable At — City</span>
<input type="text" [(ngModel)]="claimDdPayableAtCity[l.lockId]" />
</label>
@if (claimCashAmount[l.lockId] > 0) {
<label class="inv-form-field">
<span>Token No. (for cashier hand-over)</span>
<input type="text" [(ngModel)]="claimTokNo[l.lockId]" />
</label>
}
<div class="dd-apply-panel-total">
Total: {{ claimTotal(l) | number: '1.2-2' }} / {{ l.amount | number: '1.2-2' }}
Claiming: {{ claimDdAmount[l.lockId] || 0 | number: '1.2-2' }} /
{{ l.amount | number: '1.2-2' }}
</div>
@if (claimError) {
<div class="dd-apply-error">{{ claimError }}</div>
@@ -315,6 +343,21 @@
<div class="pg-card" style="margin-top: 16px">
<div class="pos-section-label">Dividend Claim Requests — Pending Review</div>
<!-- The draft just drawn. Approving refreshes the list, so the row it came from is gone by the
time the clerk looks — and the DD number is exactly what they need to write on the
counterfoil and hand over. -->
@if (lastIssuedDd) {
<div class="dd-issued-banner">
<div class="dd-issued-banner__main">
Demand Draft <strong>{{ lastIssuedDd.ddNumber }}</strong> issued to {{ lastIssuedDd.clientName }} for
{{ lastIssuedDd.amount | number: '1.2-2' }}
</div>
<button type="button" class="dd-issued-banner__close" (click)="lastIssuedDd = null" aria-label="Dismiss">
&times;
</button>
</div>
}
<div class="inv-form-grid" style="padding: 12px 16px">
<label class="inv-form-field" style="align-self: end">
<button type="button" class="pg-btn" [disabled]="claimRequestsLoading" (click)="loadClaimRequests()">
@@ -326,53 +369,44 @@
<thead>
<tr>
<th>Client</th>
<th>Savings Account</th>
<th class="num">To Loan</th>
<th class="num">To Savings</th>
<th class="num">Cash</th>
<!-- A dividend is collected only as a Demand Draft, so what the checker needs to see is the
draft about to be drawn: for how much, in whose favour, payable where. -->
<th class="num">DD Amount</th>
<th>Beneficiary</th>
<th>Payable At</th>
<th class="num">Unclaimed Balance</th>
<th>Client Loan Status</th>
<th>Requested By</th>
<th></th>
</tr>
</thead>
<tbody>
@if (claimRequestsLoading) {
<tr>
<td colspan="8" class="pg-empty">Loading…</td>
<td colspan="7" class="pg-empty">Loading…</td>
</tr>
} @else if (claimRequestsLoaded && claimRequests.length === 0) {
<tr>
<td colspan="8" class="pg-empty">No dividend claim requests pending review.</td>
<td colspan="7" class="pg-empty">No dividend claim requests pending review.</td>
</tr>
} @else {
@for (req of claimRequests; track req.id) {
<tr>
<td>{{ req.client_name }}</td>
<td class="num">{{ req.dd_apply_amount | number: '1.2-2' }}</td>
<td>{{ req.dd_beneficiary_name || '—' }}</td>
<td>
<span class="pg-chip">{{ req.savings_account_no }}</span>
</td>
<td class="num">
{{
req.loan_apply_amount > 0
? (req.loan_apply_amount | number: '1.2-2') + ' (' + req.loan_account_no + ')'
: '—'
}}
</td>
<td class="num">{{ req.savings_apply_amount > 0 ? (req.savings_apply_amount | number: '1.2-2') : '—' }}</td>
<td class="num">
{{ req.cash_apply_amount > 0 ? (req.cash_apply_amount | number: '1.2-2') : '—' }}
@if (req.cash_apply_amount > 0 && req.tok_no) {
<div class="pg-empty-inline">Tok: {{ req.tok_no }}</div>
@if (req.dd_payable_at_branch || req.dd_payable_at_city) {
{{ req.dd_payable_at_branch }}
@if (req.dd_payable_at_branch && req.dd_payable_at_city) {
,
}
{{ req.dd_payable_at_city }}
} @else {
—
}
</td>
<td class="num">{{ req.locked_amount | number: '1.2-2' }}</td>
<td>
@if (req.has_overdue_loan) {
<span class="dd-badge dd-badge--failed">Has Overdue EMI</span>
} @else {
<span class="dd-badge dd-badge--posted">No Overdue Loan</span>
}
</td>
<td>{{ req.clerk_name }}</td>
<td>
<button type="button" class="pg-btn" [disabled]="claimActioningId === req.id" (click)="approveClaim(req)">
{{ claimActioningId === req.id ? 'Approving…' : 'Approve' }}
@@ -387,7 +421,7 @@
</tr>
@if (expandedRejectClaimId === req.id) {
<tr class="dd-apply-row">
<td colspan="8">
<td colspan="7">
<div class="dd-apply-panel">
<label class="inv-form-field">
<span>Rejection Reason</span>
@@ -169,3 +169,78 @@
color: #fff;
}
}
/* Which declaration a lock belongs to. The financial year leads because that is how a member asks
for it ("my 2026-27 dividend"); the product and exact period sit under it for the clerk who needs
to be certain, without crowding the row. */
.dd-dividend-year {
font-weight: 600;
font-size: 13px;
color: #1e293b;
white-space: nowrap;
}
.dd-dividend-period {
font-size: 11.5px;
color: #64748b;
white-space: nowrap;
}
/* Financial-year chips. Sits below the status chips as a second, narrower axis over the same list —
status is fetched from the server, year filters what came back. */
.dd-year-filter {
display: flex;
align-items: center;
gap: 12px;
flex-wrap: wrap;
padding: 0 16px 12px;
&__label {
font-size: 12px;
font-weight: 600;
color: #64748b;
text-transform: uppercase;
letter-spacing: 0.03em;
}
&__total {
margin-left: auto;
font-size: 12.5px;
color: #475569;
font-variant-numeric: tabular-nums;
white-space: nowrap;
}
}
/* The DD number just drawn. Approving refreshes the pending list, so the row it came from is gone
by the time the clerk reads it — and that number is what goes on the counterfoil. */
.dd-issued-banner {
display: flex;
align-items: center;
gap: 12px;
margin: 12px 16px 0;
padding: 10px 14px;
border: 1px solid #86efac;
border-radius: 6px;
background: #f0fdf4;
color: #166534;
font-size: 13.5px;
&__main {
flex: 1;
}
&__close {
border: none;
background: none;
color: #166534;
font-size: 20px;
line-height: 1;
cursor: pointer;
padding: 0 4px;
&:hover {
color: #14532d;
}
}
}
@@ -56,19 +56,30 @@ export class DividendDashboardComponent implements OnInit {
declarationsLoading = false;
declarationsLoaded = false;
/** Every lock the status filter returned, before the year filter narrows it. */
locks: any[] = [];
locksLoading = false;
locksLoaded = false;
lockStatusFilter = 'ALL';
/**
* Financial years that actually have locks, newest first — derived from the data rather than
* listed anywhere, so a new declaration adds its year on its own and a year whose locks are all
* gone stops being offered. No maintenance as years roll over.
*/
lockYears: string[] = [];
lockYearFilter = 'ALL';
expandedClaimLockId: number | null = null;
claimOverdueLoans: Record<number, any[]> = {};
claimLoanId: Record<number, number | null> = {};
claimLoanAmount: Record<number, number | null> = {};
claimSavingsAmount: Record<number, number | null> = {};
claimSavingsAccountOverride: Record<number, number | null> = {};
claimCashAmount: Record<number, number | null> = {};
claimTokNo: Record<number, string> = {};
/**
* A dividend is collected as a Demand Draft and nothing else — the savings-credit, cash and
* loan-set-off routes are retired, so the panel only gathers what a draft needs: how much, drawn
* in whose favour, and payable where.
*/
claimDdAmount: Record<number, number | null> = {};
claimDdBeneficiary: Record<number, string> = {};
claimDdPayableAtBranch: Record<number, string> = {};
claimDdPayableAtCity: Record<number, string> = {};
claimSubmitting = false;
claimError: string | null = null;
@@ -77,6 +88,13 @@ export class DividendDashboardComponent implements OnInit {
claimRequestsLoaded = false;
claimActioningId: number | null = null;
claimActionError: Record<number, string> = {};
/**
* The Demand Draft just drawn by approving a claim. Held so the clerk can read off the number
* after the row it came from has left the pending list — it is what goes on the counterfoil and
* into the member's hand, and there is nowhere else on this screen to find it.
*/
lastIssuedDd: { ddNumber: string; clientName: string; amount: number } | null = null;
expandedRejectClaimId: number | null = null;
rejectReason: Record<number, string> = {};
@@ -95,74 +113,45 @@ export class DividendDashboardComponent implements OnInit {
}
this.expandedClaimLockId = lock.lockId;
this.claimError = null;
this.claimLoanId[lock.lockId] = null;
this.claimLoanAmount[lock.lockId] = null;
this.claimSavingsAmount[lock.lockId] = Number(lock.amount);
this.claimSavingsAccountOverride[lock.lockId] = null;
this.claimCashAmount[lock.lockId] = null;
this.claimTokNo[lock.lockId] = '';
this.claimRequestService
.getOverdueLoans(lock.clientId)
.pipe(catchError(() => of([])))
.subscribe((loans: any[]) => {
this.claimOverdueLoans[lock.lockId] = loans ?? [];
if (loans && loans.length > 0) {
this.claimLoanId[lock.lockId] = loans[0].loanId;
const loanAmt = Math.min(Number(lock.amount), Number(loans[0].overdueAmount));
this.claimLoanAmount[lock.lockId] = loanAmt;
this.claimSavingsAmount[lock.lockId] = Number((Number(lock.amount) - loanAmt).toFixed(2));
}
});
}
claimTotal(lock: any): number {
return (
Number(this.claimLoanAmount[lock.lockId] || 0) +
Number(this.claimSavingsAmount[lock.lockId] || 0) +
Math.floor(Number(this.claimCashAmount[lock.lockId] || 0))
);
// Defaults to the whole unclaimed balance and the member's own name, which is what a member
// asks for almost every time; the clerk overrides only when the member wants otherwise.
this.claimDdAmount[lock.lockId] = Number(lock.amount);
this.claimDdBeneficiary[lock.lockId] = lock.clientName ?? '';
this.claimDdPayableAtBranch[lock.lockId] = '';
this.claimDdPayableAtCity[lock.lockId] = '';
}
submitClaim(lock: any): void {
const loanAmount = Number(this.claimLoanAmount[lock.lockId] || 0);
const savingsAmount = Number(this.claimSavingsAmount[lock.lockId] || 0);
// Cash can only be handed over in whole-rupee notes/coins (no denomination below ₹1), while a
// dividend share often works out to paise — floor to the nearest rupee so the denomination
// total the cashier counts can always match exactly; any leftover paise stays unclaimed.
const cashAmount = Math.floor(Number(this.claimCashAmount[lock.lockId] || 0));
const total = loanAmount + savingsAmount + cashAmount;
if (total <= 0) {
this.claimError = 'Enter at least one amount (loan, savings, or cash), greater than zero.';
const ddAmount = Number(this.claimDdAmount[lock.lockId] || 0);
const beneficiary = this.claimDdBeneficiary[lock.lockId]?.trim();
const payableAtBranch = this.claimDdPayableAtBranch[lock.lockId]?.trim();
const payableAtCity = this.claimDdPayableAtCity[lock.lockId]?.trim();
if (ddAmount <= 0) {
this.claimError = 'Enter a DD amount greater than zero.';
return;
}
if (total > Number(lock.amount)) {
this.claimError = `Total (${total}) cannot exceed the unclaimed amount of ${lock.amount}.`;
if (ddAmount > Number(lock.amount)) {
this.claimError = `DD amount (${ddAmount}) cannot exceed the unclaimed amount of ${lock.amount}.`;
return;
}
if (loanAmount > 0 && !this.claimLoanId[lock.lockId]) {
this.claimError = 'Select a loan to apply the loan amount towards.';
if (!beneficiary) {
this.claimError = 'Enter the beneficiary name — a Demand Draft cannot be drawn without a payee.';
return;
}
if (savingsAmount > 0 && !lock.savingsAccountId && !this.claimSavingsAccountOverride[lock.lockId]) {
this.claimError = 'This share account has no linked savings account — enter a Savings Account ID to credit.';
return;
}
const tokNo = this.claimTokNo[lock.lockId]?.trim();
if (cashAmount > 0 && !tokNo) {
this.claimError = 'Enter a Token No. for the cash portion — the cashier will use it to hand over the cash.';
if (!payableAtBranch || !payableAtCity) {
this.claimError = 'Enter both the branch and city the Demand Draft is payable at.';
return;
}
this.claimSubmitting = true;
this.claimError = null;
this.claimRequestService
.submit(lock.lockId, {
loanId: loanAmount > 0 ? this.claimLoanId[lock.lockId] : undefined,
loanApplyAmount: loanAmount > 0 ? loanAmount : undefined,
savingsAccountId:
savingsAmount > 0 && !lock.savingsAccountId ? this.claimSavingsAccountOverride[lock.lockId] : undefined,
savingsApplyAmount: savingsAmount > 0 ? savingsAmount : undefined,
cashApplyAmount: cashAmount > 0 ? cashAmount : undefined,
tokNo: cashAmount > 0 ? tokNo : undefined
ddApplyAmount: ddAmount,
ddBeneficiaryName: beneficiary,
ddPayableAtBranch: payableAtBranch,
ddPayableAtCity: payableAtCity
})
.subscribe({
next: () => {
@@ -193,8 +182,18 @@ export class DividendDashboardComponent implements OnInit {
this.claimActioningId = req.id;
delete this.claimActionError[req.id];
this.claimRequestService.approve(req.id).subscribe({
next: () => {
next: (res: any) => {
this.claimActioningId = null;
// The draft number is the one thing the clerk needs off this action — it is what they write
// on the counterfoil and hand to the member. Approving refreshes the list, so the row this
// came from disappears; without surfacing it here the number would be lost.
if (res?.ddNumber) {
this.lastIssuedDd = {
ddNumber: res.ddNumber,
clientName: req.client_name,
amount: Number(res.totalDisbursed) || Number(req.dd_apply_amount) || 0
};
}
this.loadClaimRequests();
this.loadLocks();
this.loadSummary();
@@ -269,16 +268,49 @@ export class DividendDashboardComponent implements OnInit {
.pipe(catchError(() => of([])))
.subscribe((res: any[]) => {
this.locks = res ?? [];
this.rebuildLockYears();
this.locksLoading = false;
this.locksLoaded = true;
});
}
/**
* Rebuilds the year chips from whatever locks came back, newest first.
*
* A year the current status filter has no locks for disappears from the chips, so the selected
* year is reset to ALL when that happens — otherwise the table would show nothing with no
* indication why.
*/
private rebuildLockYears(): void {
this.lockYears = [...new Set(this.locks.map((l) => l.financialYear).filter((y) => !!y))].sort((a, b) =>
b.localeCompare(a)
);
if (this.lockYearFilter !== 'ALL' && !this.lockYears.includes(this.lockYearFilter)) {
this.lockYearFilter = 'ALL';
}
}
/** Locks the table actually renders — the status filter is applied server-side, the year here. */
get visibleLocks(): any[] {
return this.lockYearFilter === 'ALL'
? this.locks
: this.locks.filter((l) => l.financialYear === this.lockYearFilter);
}
/** Total of what is on screen, so a year's worth of dividend can be read off directly. */
get visibleLocksTotal(): number {
return this.visibleLocks.reduce((sum, l) => sum + (Number(l.amount) || 0), 0);
}
setLockStatusFilter(status: string): void {
this.lockStatusFilter = status;
this.loadLocks();
}
setLockYearFilter(year: string): void {
this.lockYearFilter = year;
}
lockStatusClass(status: string): string {
switch (status) {
case 'UNCLAIMED':
@@ -263,7 +263,6 @@
<mat-form-field appearance="outline" class="name-field last-name">
<mat-label>{{ 'labels.inputs.Last Name' | translate }}</mat-label>
<span matPrefix class="field-prefix-icon"><mat-icon>person</mat-icon></span>
<<<<<<< Updated upstream
<input
matInput
required
@@ -272,9 +271,6 @@
(blur)="capitalizeControl('lastname')"
style="text-transform: capitalize"
/>
=======
<input matInput required formControlName="lastname" autocomplete="off" (input)="formatName('lastname')" />
>>>>>>> Stashed changes
@if (createClientForm.controls.lastname.hasError('required')) {
<mat-error>Last name is <strong>required</strong></mat-error>
} @else if (createClientForm.controls.lastname.hasError('pattern')) {
@@ -2590,28 +2590,6 @@ export class ClientGeneralStepComponent implements OnInit, OnDestroy, OnChanges
dormantFlag: 'ACTIVE'
};
}
/**
* Automatically capitalizes the first letter of each word and lowercases the rest.
* e.g., "sri rAm" -> "Sri Ram"
*/
formatName(controlName: string): void {
const control = this.createClientForm.get(controlName);
if (!control || typeof control.value !== 'string') return;
const val = control.value;
const formatted = val
.split(' ')
.map((word: string) => {
if (!word) return '';
return word.charAt(0).toUpperCase() + word.slice(1).toLowerCase();
})
.join(' ');
if (formatted !== val) {
control.setValue(formatted, { emitEvent: false });
}
}
}
/**
@@ -435,12 +435,17 @@
@if (!showClosedFixedAccounts) {
<div class="table-wrapper">
<table mat-table [dataSource]="savingAccounts | accountsFilter: 'saving' : 'open' : 'isFixed'">
<table mat-table [dataSource]="fixedDepositAccountsForDisplay | accountsFilter: 'saving' : 'open' : 'isFixed'">
<ng-container matColumnDef="Account No">
<th mat-header-cell *matHeaderCellDef>{{ 'labels.inputs.Account No' | translate }}</th>
<td mat-cell *matCellDef="let element">
<i class="fa fa-stop" [ngClass]="element.status.code | statusLookup"></i>
<mifosx-account-number accountNo="{{ element.accountNo }}"></mifosx-account-number>
<mifosx-account-number
accountNo="{{ element.fdAccountNumber || element.accountNo }}"
></mifosx-account-number>
@if (element.renewalCycleNo) {
<span class="fd-renewal-badge">Renewal {{ element.renewalCycleNo }}</span>
}
</td>
</ng-container>
<ng-container matColumnDef="Saving Account">
@@ -524,12 +529,20 @@
<!-- Closed Fixed Deposit Accounts -->
@if (showClosedFixedAccounts) {
<div class="table-wrapper">
<table mat-table [dataSource]="savingAccounts | accountsFilter: 'saving' : 'closed' : 'isFixed'">
<table
mat-table
[dataSource]="fixedDepositAccountsForDisplay | accountsFilter: 'saving' : 'closed' : 'isFixed'"
>
<ng-container matColumnDef="Account No">
<th mat-header-cell *matHeaderCellDef>{{ 'labels.inputs.Account No' | translate }}</th>
<td mat-cell *matCellDef="let element">
<i class="fa fa-stop" [ngClass]="element.status.code | statusLookup"></i>
<mifosx-account-number accountNo="{{ element.accountNo }}"></mifosx-account-number>
<mifosx-account-number
accountNo="{{ element.fdAccountNumber || element.accountNo }}"
></mifosx-account-number>
@if (element.renewalCycleNo) {
<span class="fd-renewal-badge">Renewal {{ element.renewalCycleNo }}</span>
}
</td>
</ng-container>
<ng-container matColumnDef="Saving Account">
@@ -209,6 +209,20 @@ $red: #d93025;
gap: 0;
}
// Marks an FD row that is a renewal of an earlier term, since the row shows the original FD number
// rather than this term's own account number.
.fd-renewal-badge {
display: inline-block;
margin-left: 8px;
padding: 1px 7px;
border-radius: 10px;
background-color: $gray-200;
color: $gray-700;
font-size: 11px;
font-weight: 500;
white-space: nowrap;
}
// ── Table Wrapper ──
.table-wrapper {
border-radius: 0 0 8px 8px;
@@ -234,7 +234,57 @@ export class GeneralTabComponent implements OnDestroy {
}
get fixedDepositCount(): number {
return this.savingAccounts.filter((a: any) => a.depositType?.code === 'depositAccountType.fixedDeposit').length;
return this.fixedDepositAccountsForDisplay.filter(
(a: any) => a.depositType?.code === 'depositAccountType.fixedDeposit'
).length;
}
/**
* Accounts for the Fixed Deposit sections, with superseded renewal cycles folded away.
*
* An FD renewal closes one account and opens the next term as its own account row, so a renewed FD
* would otherwise appear twice on this page -- the matured term under Closed and the new term under
* Active -- reading as two unrelated deposits. A closed term is therefore dropped whenever a later
* cycle of the same FD (same `fdAccountNumber`) exists, leaving one row per FD that shows its
* current term. A chain that has genuinely ended keeps its final term, so it still lists as closed.
*
* Only FD rows are rewritten; savings, RD, current and share accounts pass through untouched.
*/
get fixedDepositAccountsForDisplay(): any[] {
// Memoised on the source array's identity. This getter is bound straight to two [dataSource] inputs, so
// returning a freshly built array on every change-detection pass would make the table re-bind and
// re-render its rows on every tick -- mouse moves included.
if (this.fdDisplayCacheSource !== this.savingAccounts) {
this.fdDisplayCacheSource = this.savingAccounts;
this.fdDisplayCache = this.foldSupersededFdCycles(this.savingAccounts);
}
return this.fdDisplayCache;
}
private fdDisplayCacheSource: any[] = null;
private fdDisplayCache: any[] = [];
private foldSupersededFdCycles(accounts: any[]): any[] {
const latestCycleByFd = new Map<string, number>();
for (const account of accounts) {
if (account?.depositType?.code !== 'depositAccountType.fixedDeposit') {
continue;
}
const fdNumber = account.fdAccountNumber ?? account.accountNo;
const cycle = account.renewalCycleNo ?? 0;
if (cycle > (latestCycleByFd.get(fdNumber) ?? -1)) {
latestCycleByFd.set(fdNumber, cycle);
}
}
return accounts.filter((account: any) => {
if (account?.depositType?.code !== 'depositAccountType.fixedDeposit') {
return true;
}
const fdNumber = account.fdAccountNumber ?? account.accountNo;
const cycle = account.renewalCycleNo ?? 0;
return cycle === latestCycleByFd.get(fdNumber);
});
}
get recurringDepositCount(): number {
+15 -1
View File
@@ -47,7 +47,12 @@ export class ErrorHandlerInterceptor implements HttpInterceptor {
private handleError(response: HttpErrorResponse, request: HttpRequest<any>): Observable<HttpEvent<any>> {
const status = response.status;
const errorBody = response.error;
let errorMessage = errorBody?.developerMessage || response.message;
// defaultUserMessage first: it is the sentence written for the person reading it, while
// developerMessage is for the log. Falling back to response.message last, since that is
// Angular's own "Http failure response for <url>: 409 Conflict" — a description of the
// transport, not of what went wrong, and useless to whoever is looking at the dialog.
let errorMessage =
errorBody?.defaultUserMessage || errorBody?.developerMessage || errorBody?.message || response.message;
if (errorBody?.errors?.[0]) {
errorMessage = errorBody.errors[0].defaultUserMessage || errorBody.errors[0].developerMessage;
}
@@ -104,6 +109,15 @@ export class ErrorHandlerInterceptor implements HttpInterceptor {
type: this.translate.instant('error.resource.not.found'),
message: errorMessage || 'Resource does not exist!'
};
} else if (status === 409) {
// A 409 is not a failure of permission or of the request itself — the action is simply not
// allowed in the record's current state (already approved, maker cannot check their own work,
// a guarantee still outstanding). The server always explains which, so its message is shown
// as-is under a title that does not contradict it.
alert = {
type: 'Action Not Allowed',
message: errorMessage || 'This action is not allowed in the current state.'
};
} else if (status === 500) {
alert = { type: 'Internal Server Error', message: 'Internal Server Error. Please try again later.' };
} else if (status === 501) {
+80 -14
View File
@@ -173,6 +173,20 @@
Today's Activity
</a>
}
<!-- Outside the clerkAccountLoaded block on purpose: an open exception has to be
reachable whether or not the clerk currently has an account loaded. -->
<a
class="cashier-nav__item cashier-nav__item--sec"
[class.cashier-nav__item--active]="currentUrl.includes('/cashier/voucher-exceptions')"
[routerLink]="['/cashier/voucher-exceptions']"
>
<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<path d="M10.29 3.86 1.82 18a2 2 0 0 0 1.71 3h16.94a2 2 0 0 0 1.71-3L13.71 3.86a2 2 0 0 0-3.42 0z" />
<line x1="12" y1="9" x2="12" y2="13" />
<line x1="12" y1="17" x2="12.01" y2="17" />
</svg>
Exceptions
</a>
<span class="cashier-nav__esc-hint" (click)="onEscapeKey()"> <kbd>ESC</kbd> Show Header </span>
</nav>
} @else {
@@ -234,6 +248,20 @@
>
<fa-icon icon="history"></fa-icon> Cash History
</a>
<a
class="cashier-nav__item"
[routerLink]="['/cashier/day-book']"
routerLinkActive="cashier-nav__item--active"
>
<fa-icon icon="book"></fa-icon> Day Book
</a>
<a
class="cashier-nav__item"
[routerLink]="['/cashier/voucher-exceptions']"
routerLinkActive="cashier-nav__item--active"
>
<fa-icon icon="exclamation-triangle"></fa-icon> Exceptions
</a>
<a
class="cashier-nav__item"
[routerLink]="['/cashier/closing-report']"
@@ -430,14 +458,13 @@
<fa-icon icon="money-bill" class="ni-ico"></fa-icon>Cashier
<fa-icon icon="caret-down" class="ni-caret"></fa-icon>
</a>
<a
class="ni"
tabindex="0"
routerLink="/clerk/dashboard"
(click)="hideShellLayout = true"
*mifosxHasPermission="'MENU_CLERK'"
>
<a class="ni" tabindex="0" [matMenuTriggerFor]="clerkMenu" *mifosxHasPermission="'MENU_CLERK'">
<fa-icon icon="id-card" class="ni-ico"></fa-icon>Clerk
<fa-icon icon="caret-down" class="ni-caret"></fa-icon>
</a>
<a class="ni" tabindex="0" [matMenuTriggerFor]="branchOpsMenu" *mifosxHasPermission="'MENU_BRANCH_OPS'">
<fa-icon icon="building" class="ni-ico"></fa-icon>Branch Ops
<fa-icon icon="caret-down" class="ni-caret"></fa-icon>
</a>
<a class="ni" tabindex="0" [matMenuTriggerFor]="clearingMenu" *mifosxHasPermission="'MENU_CLEARING'">
<fa-icon icon="exchange" class="ni-ico"></fa-icon>Clearing
@@ -582,6 +609,9 @@
</mat-menu>
<mat-menu #reportsMenu="matMenu" [overlapTrigger]="false" class="nav-dropdown">
<button mat-menu-item [routerLink]="['/branch-ops/account-register']" tabindex="0">
<i class="fa fa-book menu-item-icon"></i> Account Register
</button>
<button mat-menu-item [routerLink]="['/reports']" tabindex="0">
<i class="fa fa-th-list menu-item-icon"></i> All Reports
</button>
@@ -993,6 +1023,41 @@
}
<!-- Sub Menu Dropdowns -->
<!-- Branch-level operations. Starts with the day register; other branch-wide controls belong
here rather than under Cashier, which is one role's workspace. -->
<mat-menu #branchOpsMenu="matMenu" [overlapTrigger]="false" class="nav-dropdown">
<button mat-menu-item tabindex="0" [routerLink]="['/branch-ops/day-register']">
<fa-icon icon="calendar-check" class="menu-item-icon"></fa-icon> Day Open / Day Close
</button>
<button mat-menu-item tabindex="0" [routerLink]="['/branch-ops/missed-close']">
<fa-icon icon="exclamation-triangle" class="menu-item-icon"></fa-icon> Missed Close Register
</button>
</mat-menu>
<!-- Clerk was a bare link straight into the fullscreen dashboard, which left the clerk's other
screens reachable only from inside that fullscreen nav. Anything a clerk needs from the
normal shell — an open voucher exception above all — belongs here. -->
<mat-menu #clerkMenu="matMenu" [overlapTrigger]="false" class="nav-dropdown">
<button mat-menu-item tabindex="0" routerLink="/clerk/dashboard" (click)="hideShellLayout = true">
<fa-icon icon="id-card" class="menu-item-icon"></fa-icon> Clerk Dashboard
</button>
<button mat-menu-item tabindex="0" [routerLink]="['/cashier/voucher-exceptions']">
<fa-icon icon="exclamation-triangle" class="menu-item-icon"></fa-icon> Voucher Exceptions
</button>
<button mat-menu-item tabindex="0" [routerLink]="['/cashier/day-book']">
<fa-icon icon="book" class="menu-item-icon"></fa-icon> Day Book
</button>
<button mat-menu-item tabindex="0" [routerLink]="['/clerk/withdrawal-requests']">
<fa-icon icon="money-bill-wave" class="menu-item-icon"></fa-icon> My Withdrawal Requests
</button>
<button mat-menu-item tabindex="0" [routerLink]="['/clerk/my-disbursement-requests']">
<fa-icon icon="exchange" class="menu-item-icon"></fa-icon> My Disbursement Requests
</button>
<button mat-menu-item tabindex="0" [routerLink]="['/clerk/dividend-dashboard']">
<fa-icon icon="chart-pie" class="menu-item-icon"></fa-icon> Dividend Dashboard
</button>
</mat-menu>
<mat-menu #cashierMenu="matMenu" [overlapTrigger]="false" class="nav-dropdown">
<button mat-menu-item tabindex="0" [routerLink]="['/cashier/cash-exchange']">
<fa-icon icon="exchange" class="menu-item-icon"></fa-icon> Cash Exchange Entry
@@ -1027,6 +1092,12 @@
<button mat-menu-item tabindex="0" [routerLink]="['/cashier/cash-history']">
<fa-icon icon="history" class="menu-item-icon"></fa-icon> Cash History
</button>
<button mat-menu-item tabindex="0" [routerLink]="['/cashier/day-book']">
<fa-icon icon="book" class="menu-item-icon"></fa-icon> Day Book
</button>
<button mat-menu-item tabindex="0" [routerLink]="['/cashier/voucher-exceptions']">
<fa-icon icon="exclamation-triangle" class="menu-item-icon"></fa-icon> Voucher Exceptions
</button>
<button mat-menu-item tabindex="0" [routerLink]="['/cashier/transaction-entry']">
<fa-icon icon="keyboard" class="menu-item-icon"></fa-icon> Transaction Entry
</button>
@@ -1048,16 +1119,14 @@
<button mat-menu-item tabindex="0" [routerLink]="['/accounts/recurring-deposits']">
<fa-icon icon="sync" class="menu-item-icon"></fa-icon> Recurring
</button>
<button mat-menu-item tabindex="0" [routerLink]="['/accounts/dds']">
<fa-icon icon="coins" class="menu-item-icon"></fa-icon> DDS
</button>
<button mat-menu-item tabindex="0"><fa-icon icon="coins" class="menu-item-icon"></fa-icon> DDS</button>
</div>
<div class="deposit-menu-group">
<div class="deposit-group-title">Agent</div>
<button mat-menu-item tabindex="0">
<fa-icon icon="hand-holding-usd" class="menu-item-icon"></fa-icon> Agent Commission
</button>
<button mat-menu-item tabindex="0" [routerLink]="['/accounts/dds-collection']">
<button mat-menu-item tabindex="0">
<fa-icon icon="calendar-check" class="menu-item-icon"></fa-icon> Today Collection
</button>
<button mat-menu-item tabindex="0">
@@ -1072,9 +1141,6 @@
</div>
<div class="deposit-menu-group">
<div class="deposit-group-title">Reports</div>
<button mat-menu-item tabindex="0" [routerLink]="['/accounts/dds-reports']">
<fa-icon icon="coins" class="menu-item-icon"></fa-icon> DDS Reports
</button>
<button mat-menu-item tabindex="0">
<fa-icon icon="clock" class="menu-item-icon"></fa-icon> Account Expiry
</button>
@@ -12,9 +12,7 @@
<fa-icon icon="university" class="cca-header__icon"></fa-icon>
<div>
<h2 class="cca-header__title">Create Current Account</h2>
<div class="cca-header__sub">
Pick a product, confirm currency, set digital channels and review before submit.
</div>
<div class="cca-header__sub">Pick a product, confirm currency and review before submit.</div>
</div>
</div>
@@ -78,34 +76,7 @@
</div>
</mat-tab>
<!-- Tab 3: Digital -->
<mat-tab [disabled]="!currentAccountProductTemplate">
<ng-template mat-tab-label>
<fa-icon icon="mobile-alt" class="cca-tab__icon"></fa-icon>
Digital
</ng-template>
<div class="cca-tab-body">
@if (currentAccountProductTemplate) {
<mifosx-current-account-digital-step
[currentAccountProductTemplate]="currentAccountProductTemplate"
></mifosx-current-account-digital-step>
}
<div class="cca-actions">
<button mat-stroked-button (click)="prev()">
<fa-icon icon="arrow-left" class="m-r-10"></fa-icon>
{{ 'labels.buttons.Previous' | translate }}
</button>
<span class="cca-actions__spacer"></span>
<button mat-raised-button color="primary" (click)="next()">
{{ 'labels.buttons.Next' | translate }}
<fa-icon icon="arrow-right" class="m-l-10"></fa-icon>
</button>
</div>
</div>
</mat-tab>
<!-- Tab 4: Preview -->
<!-- Tab 3: Preview -->
<mat-tab [disabled]="!currentAccountProductTemplate">
<ng-template mat-tab-label>
<fa-icon icon="eye" class="cca-tab__icon"></fa-icon>
@@ -57,8 +57,8 @@ export class CreateCurrentAccountComponent {
currentAccountProductTemplate: any;
/** Currently visible wizard tab (0-indexed). */
selectedTabIndex = 0;
/** Total wizard tabs — Product, Currency, Digital, Preview. */
private readonly totalTabs = 4;
/** Total wizard tabs — Product, Currency, Preview. */
private readonly totalTabs = 3;
@ViewChild(CurrentAccountDetailsStepComponent, { static: true })
currentAccountDetailsStep: CurrentAccountDetailsStepComponent;
@@ -30,18 +30,6 @@
</mat-error>
}
</mat-form-field>
<mat-form-field class="flex-fill">
<mat-label>Opening Balance</mat-label>
<input matInput type="number" [min]="minRequiredOpeningBalance ?? 0" formControlName="openingBalance" />
<span matPrefix>₹&nbsp;</span>
@if (minRequiredOpeningBalance) {
<mat-hint>Minimum required: ₹{{ minRequiredOpeningBalance }}</mat-hint>
}
@if (activateCurrentAccountForm.controls.openingBalance.hasError('min')) {
<mat-error>Opening balance must be at least ₹{{ minRequiredOpeningBalance ?? 0 }}</mat-error>
}
</mat-form-field>
</div>
</mat-card-content>
@@ -7,7 +7,7 @@
*/
/** Angular Imports */
import { Component, OnInit, Input, inject } from '@angular/core';
import { Component, OnInit, inject } from '@angular/core';
import { UntypedFormGroup, UntypedFormBuilder, Validators, ReactiveFormsModule } from '@angular/forms';
import { ActivatedRoute, Router, RouterLink } from '@angular/router';
import { Dates } from 'app/core/utils/dates';
@@ -36,8 +36,6 @@ export class ActivateCurrentAccountComponent implements OnInit {
private router = inject(Router);
private settingsService = inject(SettingsService);
@Input() minRequiredOpeningBalance: number | null = null;
/** Minimum date allowed. */
minDate = new Date(2000, 0, 1);
/** Maximum date allowed. */
@@ -71,18 +69,10 @@ export class ActivateCurrentAccountComponent implements OnInit {
* Creates the activate current account form.
*/
createActivateCurrentAccountForm() {
const minBalance = this.minRequiredOpeningBalance ?? 0;
this.activateCurrentAccountForm = this.formBuilder.group({
activatedOnDate: [
'',
Validators.required
],
openingBalance: [
minBalance,
[
Validators.required,
Validators.min(minBalance)
]
]
});
}
@@ -10,6 +10,7 @@
<mat-card>
<form [formGroup]="approveCurrentAccountForm" (ngSubmit)="submit()">
<mat-card-content>
<p class="mat-body-2">This approves and activates the account in one step -- it goes straight to Active.</p>
<div class="layout-column">
<mat-form-field (click)="approvedOnDatePicker.open()">
<mat-label>{{ 'labels.inputs.Approved On Date' | translate }}</mat-label>
@@ -81,8 +81,9 @@ export class ApproveCurrentAccountComponent implements OnInit {
}
/**
* Submits the form and approves the current account,
* if successful redirects to the current account.
* Submits the form, approves the current account, then immediately activates it on the same
* date -- Approve and Activate are still two separate Fineract commands underneath (each shows
* up in the audit trail), but the officer only sees and clicks through one step.
*/
submit() {
const approveCurrentAccountFormData = this.approveCurrentAccountForm.value;
@@ -98,7 +99,16 @@ export class ApproveCurrentAccountComponent implements OnInit {
locale
};
this.currentAccountsService.executeCurrentAccountCommand(this.accountId, 'approve', data).subscribe(() => {
this.router.navigate(['../../transactions'], { relativeTo: this.route });
const activationData = {
activatedOnDate: approveCurrentAccountFormData.approvedOnDate,
dateFormat,
locale
};
this.currentAccountsService
.executeCurrentAccountCommand(this.accountId, 'activate', activationData)
.subscribe(() => {
this.router.navigate(['../../transactions'], { relativeTo: this.route });
});
});
}
}
@@ -13,9 +13,7 @@
<mifosx-reject-current-account></mifosx-reject-current-account>
}
@if (actions['Activate']) {
<mifosx-activate-current-account
[minRequiredOpeningBalance]="accountData?.minRequiredOpeningBalance"
></mifosx-activate-current-account>
<mifosx-activate-current-account></mifosx-activate-current-account>
}
@if (actions['Undo Approval']) {
<mifosx-undo-approval-current-account></mifosx-undo-approval-current-account>
@@ -15,7 +15,6 @@
<h3 class="dig-header__title">Digital &amp; Account Features</h3>
<p class="dig-header__sub">Toggle the facilities available for this account (as per RBI guidelines)</p>
</div>
<span class="dig-header__badge">FROM PRODUCT</span>
</div>
<div class="dig-grid">
@@ -52,12 +52,11 @@ export class CurrentAccountDigitalStepComponent {
}
}
// Note: a new account always starts with every digital facility off, even if the product has
// its own defaults configured -- those are enabled deliberately, one at a time, after the
// account is created and active, not inherited automatically at opening.
@Input()
set currentAccountProductTemplate(value: any) {
if (!this.hasExistingAccount && value?.accountFacilityConfig) {
this.facilityConfig = { ...DEFAULT_FACILITY_CONFIG, ...value.accountFacilityConfig };
}
}
currentAccountProductTemplate: any;
get currentAccountDigital() {
return { accountFacilityConfig: { ...this.facilityConfig } };
@@ -136,12 +136,11 @@ export class CurrentAccountPreviewStepComponent implements OnChanges {
return this.currentAccountSettingsForm?.getRawValue?.() || this.currentAccount || {};
}
/** Digital facility config as actually captured by a digital step in this wizard -- no fallback
* to the product's own defaults, since account creation no longer has a digital step at all and
* must not preview facilities the officer never actually chose. */
get digitalFacilityConfig(): any {
return (
this.currentAccountDigital?.accountFacilityConfig ??
this.currentAccountProductTemplate?.accountFacilityConfig ??
null
);
return this.currentAccountDigital?.accountFacilityConfig ?? null;
}
get hasDigitalSection(): boolean {
@@ -1,241 +0,0 @@
<!--
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="dte-container">
<div class="dte-header">
<button mat-stroked-button [routerLink]="['../']" class="dte-back">
<i class="fas fa-arrow-left"></i> Deposits
</button>
<h2 class="dte-header__title">Daily Transaction Detail</h2>
</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>
<dl>
<dt>Name</dt>
<dd>{{ agentInfo.name || '—' }}</dd>
<dt>Address</dt>
<dd>{{ agentInfo.address || '—' }}</dd>
<dt>City</dt>
<dd>{{ agentInfo.city || '—' }}</dd>
<dt>A/C</dt>
<dd>{{ agentInfo.accountNo || '—' }}</dd>
<dt>Guarantor</dt>
<dd>{{ agentInfo.guarantor1 || '—' }}</dd>
<dt>Guarantor</dt>
<dd>{{ agentInfo.guarantor2 || '—' }}</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>
<p class="dte-info-panel__hint">Populated once a party is selected in a collection row below.</p>
</div>
</div>
<div class="dte-controls">
<mat-form-field appearance="outline" class="dte-field">
<mat-label>Agent</mat-label>
<input matInput required formControlName="agentId" />
</mat-form-field>
<button mat-stroked-button type="button" (click)="loadAgent()"><i class="fas fa-search"></i> Load</button>
<mat-form-field appearance="outline" class="dte-field">
<mat-label>Date</mat-label>
<input matInput required [matDatepicker]="txnDatePicker" formControlName="transactionDate" />
<mat-datepicker-toggle matIconSuffix [for]="txnDatePicker"></mat-datepicker-toggle>
<mat-datepicker #txnDatePicker></mat-datepicker>
</mat-form-field>
<mat-form-field appearance="outline" class="dte-field">
<mat-label>Tran. Type</mat-label>
<mat-select required formControlName="transactionType">
@for (type of transactionTypes; track type) {
<mat-option [value]="type">{{ type }}</mat-option>
}
</mat-select>
</mat-form-field>
</div>
@if (isPartyTran) {
<div class="dte-section">
<div class="dte-section__header">
<i class="fas fa-list"></i>
<h3>Today's Collections</h3>
<button mat-stroked-button type="button" class="dte-add-row" (click)="addCollectionRow()">
<i class="fas fa-plus"></i> Add Row
</button>
</div>
@if (collections.length === 0) {
<p class="dte-empty">No collection rows added yet.</p>
}
@for (row of collections.controls; track row; let i = $index) {
<div class="dte-collection-row" [formGroup]="$any(row)">
<mat-form-field appearance="outline" class="dte-field">
<mat-label>Party Code</mat-label>
<input matInput required formControlName="partyCode" />
</mat-form-field>
<mat-form-field appearance="outline" class="dte-field">
<mat-label>TRTP</mat-label>
<mat-select required formControlName="transactionMode">
@for (mode of transactionModes; track mode) {
<mat-option [value]="mode">{{ mode }}</mat-option>
}
</mat-select>
</mat-form-field>
<mat-form-field appearance="outline" class="dte-field">
<mat-label>AC Type</mat-label>
<input matInput formControlName="accountType" />
</mat-form-field>
<mat-form-field appearance="outline" class="dte-field">
<mat-label>A/C No.</mat-label>
<input matInput required formControlName="accountNo" />
</mat-form-field>
<mat-form-field appearance="outline" class="dte-field">
<mat-label>Amount</mat-label>
<input matInput required type="number" formControlName="amount" />
</mat-form-field>
<mat-form-field appearance="outline" class="dte-field">
<mat-label>Cheque No</mat-label>
<input matInput formControlName="chequeNo" />
</mat-form-field>
<mat-form-field appearance="outline" class="dte-field">
<mat-label>Token No</mat-label>
<input matInput formControlName="tokenNo" />
</mat-form-field>
<mat-form-field appearance="outline" class="dte-field dte-field--wide">
<mat-label>Description</mat-label>
<input matInput formControlName="description" />
</mat-form-field>
<button
mat-icon-button
type="button"
class="dte-remove-row"
(click)="removeCollectionRow(i)"
matTooltip="Remove row"
>
<i class="fas fa-trash"></i>
</button>
</div>
}
</div>
}
@if (isAgentTran) {
<div class="dte-section">
<div class="dte-section__header">
<i class="fas fa-hand-holding-usd"></i>
<h3>Agent Settlement</h3>
</div>
<p class="dte-info-panel__hint">
Cash the agent hands over to the branch, deducted from the agent's collected balance shown above.
</p>
<div class="dte-grid">
<mat-form-field appearance="outline" class="dte-field">
<mat-label>Amount</mat-label>
<input matInput required type="number" formControlName="settlementAmount" />
</mat-form-field>
<mat-form-field appearance="outline" class="dte-field">
<mat-label>TRTP</mat-label>
<mat-select required formControlName="settlementMode">
@for (mode of transactionModes; track mode) {
<mat-option [value]="mode">{{ mode }}</mat-option>
}
</mat-select>
</mat-form-field>
<mat-form-field appearance="outline" class="dte-field">
<mat-label>AC Type</mat-label>
<input matInput formControlName="settlementAccountType" />
</mat-form-field>
<mat-form-field appearance="outline" class="dte-field">
<mat-label>A/C No.</mat-label>
<input matInput formControlName="settlementAccountNo" />
</mat-form-field>
<mat-form-field appearance="outline" class="dte-field dte-field--wide">
<mat-label>Description</mat-label>
<input matInput formControlName="settlementDescription" />
</mat-form-field>
</div>
</div>
}
@if (isWithdrawDeposite) {
<div class="dte-section">
<div class="dte-section__header">
<i class="fas fa-exchange-alt"></i>
<h3>Withdraw Deposite</h3>
</div>
<div class="dte-grid">
<mat-form-field appearance="outline" class="dte-field">
<mat-label>Creditor Party Code</mat-label>
<input matInput formControlName="creditorPartyCode" />
</mat-form-field>
<mat-form-field appearance="outline" class="dte-field">
<mat-label>Creditor Party Name</mat-label>
<input matInput formControlName="creditorPartyName" />
</mat-form-field>
<mat-form-field appearance="outline" class="dte-field">
<mat-label>Creditor Curr Bal</mat-label>
<input matInput formControlName="creditorPartyBalance" />
</mat-form-field>
<mat-form-field appearance="outline" class="dte-field">
<mat-label>Debit Party Code</mat-label>
<input matInput formControlName="debitPartyCode" />
</mat-form-field>
<mat-form-field appearance="outline" class="dte-field">
<mat-label>Debit Party Name</mat-label>
<input matInput formControlName="debitPartyName" />
</mat-form-field>
<mat-form-field appearance="outline" class="dte-field">
<mat-label>Debit Curr Bal</mat-label>
<input matInput formControlName="debitPartyBalance" />
</mat-form-field>
<mat-form-field appearance="outline" class="dte-field">
<mat-label>Amount</mat-label>
<input matInput type="number" formControlName="withdrawAmount" />
</mat-form-field>
<mat-form-field appearance="outline" class="dte-field dte-field--wide">
<mat-label>Description</mat-label>
<input matInput formControlName="withdrawDescription" />
</mat-form-field>
</div>
</div>
}
@if (!isPartyTran && !isWithdrawDeposite && !isAgentTran) {
<div class="dte-section">
<p class="dte-empty">
"{{ entryForm.get('transactionType').value }}" entry screen — same shape as Party Tran, to be filled in once
the backend distinguishes these transaction types.
</p>
</div>
}
<div class="dte-action-bar">
<button mat-raised-button type="button" (click)="exit()">Exit</button>
<button mat-stroked-button type="button" (click)="cancel()">Cancel</button>
<button mat-stroked-button type="button" (click)="retrieve()">Retrieve</button>
<button mat-stroked-button type="button" (click)="viewLedger()">View Ledger</button>
<button mat-stroked-button type="button" (click)="signature()">Signature</button>
<button mat-raised-button color="primary" type="button" [disabled]="entryForm.invalid" (click)="save()">
Save
</button>
</div>
</form>
</div>
</div>
@@ -1,187 +0,0 @@
/**
* 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;
.dte-container {
max-width: 1080px;
margin: 0 auto;
padding: 24px 16px;
}
.dte-header {
display: flex;
align-items: center;
gap: 16px;
margin-bottom: 20px;
&__title {
margin: 0;
font-size: 22px;
font-weight: 500;
}
}
.dte-back {
color: $primary;
}
.dte-card {
background: var(--md-sys-color-surface, #fff);
border-radius: 8px;
box-shadow: 0 2px 8px rgb(0 0 0 / 8%);
padding: 20px 24px 24px;
}
.dte-info-panels {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 16px;
margin-bottom: 20px;
}
.dte-info-panel {
border: 1px solid rgb(0 0 0 / 10%);
border-radius: 8px;
padding: 12px 16px;
background: rgb(15 118 110 / 4%);
&__title {
font-weight: 600;
font-size: 13px;
text-transform: uppercase;
letter-spacing: 0.04em;
color: $primary;
margin-bottom: 8px;
}
&__hint {
margin: 0;
color: rgb(0 0 0 / 50%);
font-size: 13px;
}
dl {
display: grid;
grid-template-columns: auto 1fr;
gap: 4px 12px;
margin: 0;
}
dt {
font-weight: 500;
color: rgb(0 0 0 / 60%);
font-size: 13px;
}
dd {
margin: 0;
font-size: 13px;
}
}
.dte-controls {
display: flex;
align-items: flex-start;
gap: 12px;
flex-wrap: wrap;
margin-bottom: 12px;
}
.dte-field {
min-width: 180px;
&--wide {
grid-column: span 2;
}
}
.dte-section {
padding: 16px 0;
border-top: 1px solid rgb(0 0 0 / 8%);
&__header {
display: flex;
align-items: center;
gap: 10px;
margin-bottom: 16px;
i {
color: $primary;
}
h3 {
margin: 0;
font-size: 16px;
font-weight: 600;
flex: 1;
}
}
}
.dte-add-row {
color: $primary;
}
.dte-empty {
color: rgb(0 0 0 / 50%);
font-size: 14px;
margin: 0;
}
.dte-grid {
display: grid;
grid-template-columns: repeat(3, 1fr);
gap: 8px 16px;
}
.dte-collection-row {
display: grid;
grid-template-columns: repeat(7, 1fr) auto;
gap: 8px 12px;
align-items: start;
padding: 10px 0;
border-top: 1px dashed rgb(0 0 0 / 10%);
&:first-of-type {
border-top: none;
}
}
.dte-collection-row .dte-field--wide {
grid-column: span 2;
}
.dte-remove-row {
color: #b91c1c;
margin-top: 8px;
}
.dte-action-bar {
display: flex;
justify-content: flex-end;
gap: 12px;
margin-top: 20px;
padding-top: 16px;
border-top: 1px solid rgb(0 0 0 / 8%);
flex-wrap: wrap;
}
@media (width <= 1000px) {
.dte-info-panels {
grid-template-columns: 1fr;
}
.dte-grid {
grid-template-columns: repeat(2, 1fr);
}
.dte-collection-row {
grid-template-columns: repeat(2, 1fr);
}
}
@@ -1,290 +0,0 @@
/**
* 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/.
*/
/** Angular Imports */
import { Component, inject } from '@angular/core';
import { UntypedFormBuilder, UntypedFormGroup, UntypedFormArray, Validators } from '@angular/forms';
import { Router } from '@angular/router';
import { STANDALONE_SHARED_IMPORTS } from 'app/standalone-shared.module';
/** Custom Services */
import { OrganizationService } from 'app/organization/organization.service';
import { SettingsService } from 'app/settings/settings.service';
import { Dates } from 'app/core/utils/dates';
/**
* Daily Transaction Entry component.
*
* The screen a collection agent uses at the end of a day's round: pick a
* transaction type, then batch-enter that day's collections against
* assigned parties (Party Tran), settle collected cash to the branch
* (Agent Tran), or record a withdrawal/transfer between accounts (Withdraw
* Deposite). Modelled directly on the legacy Daily Transaction Detail
* screen this replaces.
*
* Backed by the DDS Party Transaction API (`/dds/partytransactions`) for
* Party Tran, Agent Tran and Withdraw Deposite - the three types this
* screen actually collects fields for; Party Payment / OD Entry / OD
* Payment remain placeholders until the backend distinguishes them too.
* "Retrieve" (a ready-made list of today's assigned parties) isn't wired -
* that needs a dedicated per-agent Party lookup this MVP doesn't have yet.
*/
@Component({
selector: 'mifosx-daily-transaction-entry',
templateUrl: './daily-transaction-entry.component.html',
styleUrls: ['./daily-transaction-entry.component.scss'],
imports: [...STANDALONE_SHARED_IMPORTS]
})
export class DailyTransactionEntryComponent {
private formBuilder = inject(UntypedFormBuilder);
private router = inject(Router);
private organizationService = inject(OrganizationService);
private settingsService = inject(SettingsService);
private dateUtils = inject(Dates);
private static readonly TRANSACTION_TYPE_CODES: Record<string, string> = {
'Party Tran': 'PARTY_TRAN',
'Agent Tran': 'AGENT_TRAN',
'Withdraw Deposite': 'WITHDRAW_DEPOSITE'
};
transactionTypes = [
'Party Tran',
'Agent Tran',
'Party Payment',
'OD Entry',
'OD Payment',
'Withdraw Deposite'
];
transactionModes = [
'Cash',
'Transfer',
'Clearing'
];
entryForm: UntypedFormGroup = this.formBuilder.group({
agentId: [
'',
Validators.required
],
transactionType: [
'Party Tran',
Validators.required
],
transactionDate: [
new Date(),
Validators.required
],
// Withdraw Deposite fields
creditorPartyCode: [''],
creditorPartyName: [
{
value: '',
disabled: true
}
],
creditorPartyBalance: [
{
value: 0,
disabled: true
}
],
debitPartyCode: [''],
debitPartyName: [
{
value: '',
disabled: true
}
],
debitPartyBalance: [
{
value: 0,
disabled: true
}
],
withdrawAmount: [0],
withdrawDescription: [''],
// Agent Tran fields
settlementAmount: [0],
settlementMode: ['Cash'],
settlementAccountType: [''],
settlementAccountNo: [''],
settlementDescription: [''],
collections: this.formBuilder.array([])
});
agentInfo = {
name: '',
address: '',
city: '',
accountNo: '',
guarantor1: '',
guarantor2: '',
currentBalance: 0
};
get isPartyTran(): boolean {
return this.entryForm.get('transactionType').value === 'Party Tran';
}
get isWithdrawDeposite(): boolean {
return this.entryForm.get('transactionType').value === 'Withdraw Deposite';
}
get isAgentTran(): boolean {
return this.entryForm.get('transactionType').value === 'Agent Tran';
}
get collections(): UntypedFormArray {
return this.entryForm.get('collections') as UntypedFormArray;
}
private buildCollectionRow(): UntypedFormGroup {
return this.formBuilder.group({
partyCode: [
'',
Validators.required
],
transactionMode: [
'Cash',
Validators.required
],
accountType: [''],
accountNo: [
'',
Validators.required
],
amount: [
0,
Validators.required
],
chequeNo: [''],
tokenNo: [''],
description: [''],
partyName: [
{
value: '',
disabled: true
}
],
balanceAfterTransaction: [
{
value: 0,
disabled: true
}
]
});
}
addCollectionRow(): void {
this.collections.push(this.buildCollectionRow());
}
removeCollectionRow(index: number): void {
this.collections.removeAt(index);
}
loadAgent(): void {
const agentId = this.entryForm.get('agentId')?.value;
if (!agentId) {
return;
}
this.organizationService.getAgent(agentId).subscribe((agent: any) => {
const guarantors = agent.guarantors || [];
this.agentInfo = {
name: agent.agentNameId || '',
address: agent.agentAddress1 || '',
city: agent.agentCity || '',
accountNo: agent.agentSavingAccountNo || '',
guarantor1: guarantors[0]?.guarantorName || '',
guarantor2: guarantors[1]?.guarantorName || '',
currentBalance: agent.agentCurrentBalance || 0
};
});
}
retrieve(): void {
// No "my assigned parties" read API exists yet — this will pre-fill
// the collections grid with today's assigned parties once available.
}
viewLedger(): void {
// No ledger view exists yet for this agent.
}
signature(): void {
// No signature capture/verification flow exists yet.
}
cancel(): void {
this.entryForm.reset({
transactionType: 'Party Tran',
transactionDate: new Date()
});
this.collections.clear();
}
exit(): void {
this.router.navigate(['/accounts/savings']);
}
save(): void {
const raw = this.entryForm.getRawValue();
const dateFormat = this.settingsService.dateFormat;
const locale = this.settingsService.language.code;
const transactionType = DailyTransactionEntryComponent.TRANSACTION_TYPE_CODES[raw.transactionType];
const payload: any = {
agentId: Number(raw.agentId),
transactionType,
transactionDate: this.dateUtils.formatDate(raw.transactionDate, dateFormat),
dateFormat,
locale
};
if (this.isPartyTran) {
payload.collections = (raw.collections || []).map((row: any) => ({
partyCode: row.partyCode,
transactionMode: row.transactionMode,
accountType: row.accountType || undefined,
accountNo: row.accountNo,
amount: row.amount,
chequeNo: row.chequeNo || undefined,
tokenNo: row.tokenNo || undefined,
description: row.description || undefined
}));
} else if (this.isWithdrawDeposite) {
payload.creditorPartyCode = raw.creditorPartyCode;
payload.debitPartyCode = raw.debitPartyCode;
payload.withdrawAmount = raw.withdrawAmount;
payload.withdrawDescription = raw.withdrawDescription || undefined;
} else if (this.isAgentTran) {
payload.amount = raw.settlementAmount;
payload.transactionMode = raw.settlementMode;
payload.accountType = raw.settlementAccountType || undefined;
payload.accountNo = raw.settlementAccountNo || undefined;
payload.description = raw.settlementDescription || undefined;
}
this.organizationService.createPartyTransaction(payload).subscribe(() => {
this.collections.clear();
this.entryForm.patchValue({
creditorPartyCode: '',
debitPartyCode: '',
withdrawAmount: 0,
withdrawDescription: '',
settlementAmount: 0,
settlementAccountType: '',
settlementAccountNo: '',
settlementDescription: ''
});
this.loadAgent();
});
}
}
@@ -1,33 +0,0 @@
<!--
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="dr-container">
<div class="dr-header">
<button mat-stroked-button [routerLink]="['../']" class="dr-back">
<i class="fas fa-arrow-left"></i> Deposits
</button>
<h2 class="dr-header__title">DDS Reports</h2>
</div>
@for (group of reportGroups; track group.title) {
<div class="dr-group">
<div class="dr-group__title">{{ group.title }}</div>
<div class="dr-grid">
@for (report of group.reports; track report) {
<a class="dr-card" [routerLink]="['view', report]">
<div class="dr-card__icon">
<i class="fas fa-file-alt"></i>
</div>
<div class="dr-card__title">{{ report }}</div>
<i class="fas fa-chevron-right dr-card__arrow"></i>
</a>
}
</div>
</div>
}
</div>
@@ -1,97 +0,0 @@
/**
* 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;
.dr-container {
max-width: 1080px;
margin: 0 auto;
padding: 24px 16px;
}
.dr-header {
display: flex;
align-items: center;
gap: 16px;
margin-bottom: 24px;
&__title {
margin: 0;
font-size: 22px;
font-weight: 500;
}
}
.dr-back {
color: $primary;
}
.dr-group {
margin-bottom: 24px;
&__title {
font-size: 13px;
text-transform: uppercase;
letter-spacing: 0.05em;
color: $primary;
font-weight: 700;
margin-bottom: 10px;
}
}
.dr-grid {
display: grid;
grid-template-columns: repeat(3, 1fr);
gap: 12px;
}
.dr-card {
display: flex;
align-items: center;
gap: 12px;
background: var(--md-sys-color-surface, #fff);
border: 1px solid rgb(0 0 0 / 8%);
border-radius: 8px;
padding: 12px 14px;
text-decoration: none;
color: inherit;
transition: box-shadow 0.15s ease;
&:hover {
box-shadow: 0 2px 8px rgb(0 0 0 / 10%);
}
&__icon {
color: $primary;
font-size: 16px;
flex: none;
}
&__title {
flex: 1;
font-size: 13px;
font-weight: 500;
}
&__arrow {
color: rgb(0 0 0 / 30%);
font-size: 12px;
}
}
@media (width <= 900px) {
.dr-grid {
grid-template-columns: repeat(2, 1fr);
}
}
@media (width <= 600px) {
.dr-grid {
grid-template-columns: 1fr;
}
}
@@ -1,73 +0,0 @@
/**
* 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/.
*/
/** Angular Imports */
import { Component } from '@angular/core';
import { STANDALONE_SHARED_IMPORTS } from 'app/standalone-shared.module';
/**
* DDS Reports hub component.
*
* Lists every DDS report from the legacy Reports menu, grouped the same
* way (As-On-Date, Interest, Daily/Balance/Master, Transaction). Each
* links to the shared report viewer, parameterised by report name.
*
* UI only for now: no report actually queries the backend yet — see
* ReportViewerComponent.
*/
@Component({
selector: 'mifosx-dds-reports',
templateUrl: './dds-reports.component.html',
styleUrls: ['./dds-reports.component.scss'],
imports: [...STANDALONE_SHARED_IMPORTS]
})
export class DdsReportsComponent {
reportGroups = [
{
title: 'As On Date',
reports: [
'Supplementary-DDS (As On)',
'Agent Check List',
'Party Check List-As On',
'Party Balance',
'Agent Wise Balance',
'Agent Wise Monthly Collection',
'Commission Register',
'Collection Summary Agent Wise',
'Close Party Ac Register',
'Agent Partywise Daily Collection',
'Daily Collection Balance',
'Interest Certificate',
'Mature Date Wise Report'
]
},
{
title: 'Interest',
reports: [
'Party Int. Register',
'Commission Register'
]
},
{
title: 'Daily / Balance / Master',
reports: [
'Daily Collection Report',
'Balance Summary',
'Master Data Export'
]
},
{
title: 'Transaction',
reports: ['Transaction Register']
},
{
title: 'Statements',
reports: ['Agent Commission Statement']
}
];
}
@@ -1,94 +0,0 @@
<!--
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="rv-container">
<div class="rv-header">
<button mat-stroked-button [routerLink]="['../../']" class="rv-back">
<i class="fas fa-arrow-left"></i> DDS Reports
</button>
<h2 class="rv-header__title">{{ reportName }}</h2>
</div>
<div class="rv-card">
<form [formGroup]="filterForm">
<div class="rv-grid">
<mat-form-field appearance="outline" class="rv-field">
<mat-label>From Date</mat-label>
<input matInput required [matDatepicker]="fromPicker" formControlName="fromDate" />
<mat-datepicker-toggle matIconSuffix [for]="fromPicker"></mat-datepicker-toggle>
<mat-datepicker #fromPicker></mat-datepicker>
</mat-form-field>
<mat-form-field appearance="outline" class="rv-field">
<mat-label>To Date</mat-label>
<input matInput required [matDatepicker]="toPicker" formControlName="toDate" />
<mat-datepicker-toggle matIconSuffix [for]="toPicker"></mat-datepicker-toggle>
<mat-datepicker #toPicker></mat-datepicker>
</mat-form-field>
<mat-form-field appearance="outline" class="rv-field">
<mat-label>Agent From</mat-label>
<input matInput type="number" formControlName="agentFrom" />
</mat-form-field>
<mat-form-field appearance="outline" class="rv-field">
<mat-label>Agent To</mat-label>
<input matInput type="number" formControlName="agentTo" />
</mat-form-field>
<mat-form-field appearance="outline" class="rv-field">
<mat-label>Party From</mat-label>
<input matInput type="number" formControlName="partyFrom" />
</mat-form-field>
<mat-form-field appearance="outline" class="rv-field">
<mat-label>Party To</mat-label>
<input matInput type="number" formControlName="partyTo" />
</mat-form-field>
</div>
<div class="rv-action-bar">
<button mat-raised-button type="button" (click)="exit()">Exit</button>
<button mat-raised-button color="primary" type="button" [disabled]="filterForm.invalid" (click)="runReport()">
<i class="fas fa-play" style="margin-right: 6px"></i> Run Report
</button>
</div>
@if (hasRun) {
<div class="rv-results">
@if (!available) {
<p class="rv-empty">{{ unavailableMessage }}</p>
} @else if (rows.length === 0) {
<p class="rv-empty">No rows matched these filters.</p>
} @else {
<div class="rv-table-scroll">
<table class="rv-table">
<thead>
<tr>
@for (column of columns; track column) {
<th>{{ column }}</th>
}
</tr>
</thead>
<tbody>
@for (row of rows; track row) {
<tr>
@for (column of columns; track column) {
<td>{{ row[column] }}</td>
}
</tr>
}
</tbody>
</table>
</div>
}
</div>
}
</form>
</div>
</div>
@@ -1,103 +0,0 @@
/**
* 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;
.rv-container {
max-width: 1100px;
margin: 0 auto;
padding: 24px 16px;
}
.rv-header {
display: flex;
align-items: center;
gap: 16px;
margin-bottom: 20px;
&__title {
margin: 0;
font-size: 22px;
font-weight: 500;
}
}
.rv-back {
color: $primary;
}
.rv-card {
background: var(--md-sys-color-surface, #fff);
border-radius: 8px;
box-shadow: 0 2px 8px rgb(0 0 0 / 8%);
padding: 20px 24px 24px;
}
.rv-grid {
display: grid;
grid-template-columns: repeat(3, 1fr);
gap: 8px 16px;
}
.rv-field {
width: 100%;
}
.rv-action-bar {
display: flex;
justify-content: flex-end;
gap: 12px;
margin-top: 20px;
padding-top: 16px;
border-top: 1px solid rgb(0 0 0 / 8%);
}
.rv-results {
margin-top: 20px;
padding-top: 16px;
border-top: 1px solid rgb(0 0 0 / 8%);
}
.rv-empty {
color: rgb(0 0 0 / 50%);
font-size: 14px;
margin: 0;
text-align: center;
padding: 24px 0;
}
.rv-table-scroll {
overflow-x: auto;
}
.rv-table {
width: 100%;
border-collapse: collapse;
white-space: nowrap;
th {
text-align: left;
font-size: 13px;
text-transform: uppercase;
letter-spacing: 0.04em;
color: rgb(0 0 0 / 55%);
padding: 8px 10px;
border-bottom: 2px solid rgb(0 0 0 / 10%);
}
td {
padding: 8px 10px;
border-bottom: 1px solid rgb(0 0 0 / 6%);
}
}
@media (width <= 900px) {
.rv-grid {
grid-template-columns: repeat(2, 1fr);
}
}
@@ -1,120 +0,0 @@
/**
* 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/.
*/
/** Angular Imports */
import { Component, inject } from '@angular/core';
import { ActivatedRoute, Router } from '@angular/router';
import { UntypedFormBuilder, UntypedFormGroup, Validators } from '@angular/forms';
import { STANDALONE_SHARED_IMPORTS } from 'app/standalone-shared.module';
/** Custom Services */
import { OrganizationService } from 'app/organization/organization.service';
import { Dates } from 'app/core/utils/dates';
/**
* Generic DDS report viewer.
*
* One shared screen for all 13+ DDS reports — the report name comes from
* the route and is mapped to a backend report key. Every report in the
* legacy system shared the same basic filter shape (date, agent range,
* party range), so this avoids building a bespoke screen per report - the
* backend returns a generic {columns, rows} shape this renders as one
* table regardless of which report it is.
*
* One legacy report (Close Party Ac Register) has no corresponding data in
* the backend yet - Party accounts have no closed/open status at all. Run
* Report still calls through, and the backend's `available: false` +
* message is shown instead of a table.
*/
@Component({
selector: 'mifosx-dds-report-viewer',
templateUrl: './report-viewer.component.html',
styleUrls: ['./report-viewer.component.scss'],
imports: [...STANDALONE_SHARED_IMPORTS]
})
export class ReportViewerComponent {
private route = inject(ActivatedRoute);
private router = inject(Router);
private formBuilder = inject(UntypedFormBuilder);
private organizationService = inject(OrganizationService);
private dateUtils = inject(Dates);
private static readonly REPORT_KEYS: Record<string, string> = {
'Supplementary-DDS (As On)': 'SUPPLEMENTARY_DDS',
'Agent Check List': 'AGENT_CHECK_LIST',
'Party Check List-As On': 'PARTY_CHECK_LIST',
'Party Balance': 'PARTY_BALANCE',
'Agent Wise Balance': 'AGENT_WISE_BALANCE',
'Agent Wise Monthly Collection': 'AGENT_WISE_MONTHLY_COLLECTION',
'Commission Register': 'COMMISSION_REGISTER',
'Collection Summary Agent Wise': 'COLLECTION_SUMMARY_AGENT_WISE',
'Close Party Ac Register': 'CLOSE_PARTY_AC_REGISTER',
'Agent Partywise Daily Collection': 'AGENT_PARTYWISE_DAILY_COLLECTION',
'Daily Collection Balance': 'DAILY_COLLECTION_REPORT',
'Interest Certificate': 'INTEREST_CERTIFICATE',
'Mature Date Wise Report': 'MATURE_DATE_WISE',
'Party Int. Register': 'PARTY_INTEREST_REGISTER',
'Daily Collection Report': 'DAILY_COLLECTION_REPORT',
'Balance Summary': 'BALANCE_SUMMARY',
'Master Data Export': 'MASTER_DATA_EXPORT',
'Transaction Register': 'TRANSACTION_REGISTER',
'Agent Commission Statement': 'AGENT_COMMISSION_STATEMENT'
};
reportName: string = this.route.snapshot.paramMap.get('reportName') || 'Report';
hasRun = false;
available = true;
unavailableMessage = '';
columns: string[] = [];
rows: Array<Record<string, any>> = [];
filterForm: UntypedFormGroup = this.formBuilder.group({
fromDate: [
'',
Validators.required
],
toDate: [
'',
Validators.required
],
agentFrom: [1],
agentTo: [99999],
partyFrom: [1],
partyTo: [99999999]
});
runReport(): void {
const reportKey = ReportViewerComponent.REPORT_KEYS[this.reportName];
if (!reportKey) {
return;
}
const raw = this.filterForm.getRawValue();
this.organizationService
.runDdsReport(reportKey, {
fromDate: this.dateUtils.getDate(raw.fromDate),
toDate: this.dateUtils.getDate(raw.toDate),
agentFrom: raw.agentFrom,
agentTo: raw.agentTo,
partyFrom: raw.partyFrom,
partyTo: raw.partyTo
})
.subscribe((result: any) => {
this.available = result.available;
this.unavailableMessage = result.message || '';
this.columns = result.columns || [];
this.rows = result.rows || [];
this.hasRun = true;
});
}
exit(): void {
this.router.navigate(['../../'], { relativeTo: this.route });
}
}
@@ -33,6 +33,7 @@
<mifosx-fixed-deposit-account-details-step
[fixedDepositsAccountTemplate]="fixedDepositsAccountTemplate"
(fixedDepositsAccountProductTemplate)="setTemplate($event)"
[externalLinkedAccountId]="fixedDepositAccountMaturityTermsStep?.selectedTransferAccount?.id ?? null"
[hideButtons]="true"
>
</mifosx-fixed-deposit-account-details-step>
@@ -107,7 +108,40 @@
</div>
</mat-tab>
<!-- Tab 4: Preview -->
<!-- Tab 4: Terms -->
<mat-tab [disabled]="!fixedDepositsAccountProductTemplate">
<ng-template mat-tab-label>
<fa-icon icon="file-alt" class="cfa-tab__icon"></fa-icon>
Terms
</ng-template>
<div class="cfa-tab-body">
@if (fixedDepositsAccountProductTemplate) {
<mifosx-fixed-deposit-account-maturity-terms-step
[fixedDepositsAccountTemplate]="fixedDepositsAccountTemplate"
[fixedDepositsAccountProductTemplate]="fixedDepositsAccountProductTemplate"
[linkedSavingsAccountId]="
fixedDepositsAccountDetailsStep?.selectedLinkedAccount?.accountType === 'SAVINGS'
? fixedDepositsAccountDetailsStep?.selectedLinkedAccount?.id
: null
"
></mifosx-fixed-deposit-account-maturity-terms-step>
}
<div class="cfa-actions">
<button mat-stroked-button (click)="prev()">
<fa-icon icon="arrow-left" class="m-r-10"></fa-icon>
{{ 'labels.buttons.Previous' | translate }}
</button>
<span class="cfa-actions__spacer"></span>
<button mat-raised-button color="primary" (click)="next()">
{{ 'labels.buttons.Next' | translate }}
<fa-icon icon="arrow-right" class="m-l-10"></fa-icon>
</button>
</div>
</div>
</mat-tab>
<!-- Tab 5: Preview -->
<mat-tab [disabled]="!fixedDepositsAccountProductTemplate">
<ng-template mat-tab-label>
<fa-icon icon="eye" class="cfa-tab__icon"></fa-icon>
@@ -121,6 +155,7 @@
[fixedDepositAccountData]="fixedDepositAccount"
[selectedTenure]="selectedTenure"
[fdNominees]="fixedDepositAccountCurrencyStep?.nominees ?? []"
[closureSettings]="fixedDepositAccountMaturityTermsStep?.closureSettings ?? {}"
[hideButtons]="true"
(submitEvent)="submit()"
></mifosx-fixed-deposit-account-preview-step>
@@ -24,6 +24,7 @@ import { MatTabsModule } from '@angular/material/tabs';
import { FaIconComponent } from '@fortawesome/angular-fontawesome';
import { FixedDepositAccountInterestRateChartStepComponent } from '../fixed-deposit-account-stepper/fixed-deposit-account-interest-rate-chart-step/fixed-deposit-account-interest-rate-chart-step.component';
import { FixedDepositAccountPreviewStepComponent } from '../fixed-deposit-account-stepper/fixed-deposit-account-preview-step/fixed-deposit-account-preview-step.component';
import { FixedDepositAccountMaturityTermsStepComponent } from '../fixed-deposit-account-stepper/fixed-deposit-account-maturity-terms-step/fixed-deposit-account-maturity-terms-step.component';
import { STANDALONE_SHARED_IMPORTS } from 'app/standalone-shared.module';
/**
@@ -40,6 +41,7 @@ import { STANDALONE_SHARED_IMPORTS } from 'app/standalone-shared.module';
FixedDepositAccountDetailsStepComponent,
FixedDepositAccountInterestRateChartStepComponent,
FixedDepositAccountCurrencyStepComponent,
FixedDepositAccountMaturityTermsStepComponent,
FixedDepositAccountPreviewStepComponent
]
})
@@ -56,6 +58,10 @@ export class CreateFixedDepositAccountComponent {
fixedDepositsAccountDetailsStep: FixedDepositAccountDetailsStepComponent;
@ViewChild(FixedDepositAccountCurrencyStepComponent)
fixedDepositAccountCurrencyStep: FixedDepositAccountCurrencyStepComponent;
@ViewChild(FixedDepositAccountPreviewStepComponent)
fixedDepositAccountPreviewStep: FixedDepositAccountPreviewStepComponent;
@ViewChild(FixedDepositAccountMaturityTermsStepComponent)
fixedDepositAccountMaturityTermsStep: FixedDepositAccountMaturityTermsStepComponent;
/** Fixed Deposits Account Template */
fixedDepositsAccountTemplate: any;
@@ -63,8 +69,8 @@ export class CreateFixedDepositAccountComponent {
fixedDepositsAccountProductTemplate: any;
/** Currently visible wizard tab (0-indexed). */
selectedTabIndex = 0;
/** Total wizard tabs — Product, Currency, Chart, Preview. */
private readonly totalTabs = 4;
/** Total wizard tabs — Product, Currency, Chart, Terms, Preview. */
private readonly totalTabs = 5;
/** Active CBS base interest rate versions for the selected product's FD type */
cbsBaseRates: any[] = [];
/** Tenure selected from the interest rate chart step */
@@ -243,6 +249,7 @@ export class CreateFixedDepositAccountComponent {
...this.fixedDepositAccount,
...terms,
...settings,
...(this.fixedDepositAccountMaturityTermsStep?.closureSettings ?? {}),
...(this.selectedTenure
? {
depositPeriod: this.selectedTenure.depositPeriod,
@@ -35,6 +35,7 @@
<mifosx-fixed-deposit-account-details-step
[fixedDepositsAccountTemplate]="fixedDepositsAccountAndTemplate"
(fixedDepositsAccountProductTemplate)="setTemplate($event)"
[externalLinkedAccountId]="fixedDepositAccountMaturityTermsStep?.selectedTransferAccount?.id ?? null"
[hideButtons]="true"
>
</mifosx-fixed-deposit-account-details-step>
@@ -111,7 +112,40 @@
</div>
</mat-tab>
<!-- Tab 4: Preview -->
<!-- Tab 4: Terms -->
<mat-tab [disabled]="!fixedDepositsAccountProductTemplate">
<ng-template mat-tab-label>
<fa-icon icon="file-alt" class="cfa-tab__icon"></fa-icon>
Terms
</ng-template>
<div class="cfa-tab-body">
@if (fixedDepositsAccountProductTemplate) {
<mifosx-fixed-deposit-account-maturity-terms-step
[fixedDepositsAccountTemplate]="fixedDepositsAccountAndTemplate"
[fixedDepositsAccountProductTemplate]="fixedDepositsAccountProductTemplate"
[linkedSavingsAccountId]="
fixedDepositsAccountDetailsStep?.selectedLinkedAccount?.accountType === 'SAVINGS'
? fixedDepositsAccountDetailsStep?.selectedLinkedAccount?.id
: null
"
></mifosx-fixed-deposit-account-maturity-terms-step>
}
<div class="cfa-actions">
<button mat-stroked-button (click)="prev()">
<fa-icon icon="arrow-left" class="m-r-10"></fa-icon>
{{ 'labels.buttons.Previous' | translate }}
</button>
<span class="cfa-actions__spacer"></span>
<button mat-raised-button color="primary" (click)="next()">
{{ 'labels.buttons.Next' | translate }}
<fa-icon icon="arrow-right" class="m-l-10"></fa-icon>
</button>
</div>
</div>
</mat-tab>
<!-- Tab 5: Preview -->
<mat-tab [disabled]="!fixedDepositsAccountProductTemplate">
<ng-template mat-tab-label>
<fa-icon icon="eye" class="cfa-tab__icon"></fa-icon>
@@ -125,6 +159,7 @@
[fixedDepositAccountData]="fixedDepositAccount"
[selectedTenure]="selectedTenure"
[fdNominees]="fixedDepositAccountCurrencyStep?.nominees ?? []"
[closureSettings]="fixedDepositAccountMaturityTermsStep?.closureSettings ?? {}"
[hideButtons]="true"
(submitEvent)="submit()"
></mifosx-fixed-deposit-account-preview-step>
@@ -25,6 +25,7 @@ import { MatTabsModule } from '@angular/material/tabs';
import { FaIconComponent } from '@fortawesome/angular-fontawesome';
import { FixedDepositAccountInterestRateChartStepComponent } from '../fixed-deposit-account-stepper/fixed-deposit-account-interest-rate-chart-step/fixed-deposit-account-interest-rate-chart-step.component';
import { FixedDepositAccountPreviewStepComponent } from '../fixed-deposit-account-stepper/fixed-deposit-account-preview-step/fixed-deposit-account-preview-step.component';
import { FixedDepositAccountMaturityTermsStepComponent } from '../fixed-deposit-account-stepper/fixed-deposit-account-maturity-terms-step/fixed-deposit-account-maturity-terms-step.component';
import { STANDALONE_SHARED_IMPORTS } from 'app/standalone-shared.module';
/**
@@ -41,6 +42,7 @@ import { STANDALONE_SHARED_IMPORTS } from 'app/standalone-shared.module';
FixedDepositAccountDetailsStepComponent,
FixedDepositAccountInterestRateChartStepComponent,
FixedDepositAccountCurrencyStepComponent,
FixedDepositAccountMaturityTermsStepComponent,
FixedDepositAccountPreviewStepComponent
]
})
@@ -58,6 +60,10 @@ export class EditFixedDepositAccountComponent {
fixedDepositsAccountDetailsStep: FixedDepositAccountDetailsStepComponent;
@ViewChild(FixedDepositAccountCurrencyStepComponent)
fixedDepositAccountCurrencyStep: FixedDepositAccountCurrencyStepComponent;
@ViewChild(FixedDepositAccountPreviewStepComponent)
fixedDepositAccountPreviewStep: FixedDepositAccountPreviewStepComponent;
@ViewChild(FixedDepositAccountMaturityTermsStepComponent)
fixedDepositAccountMaturityTermsStep: FixedDepositAccountMaturityTermsStepComponent;
/** Full account data + template from resolver */
fixedDepositsAccountAndTemplate: any;
@@ -65,7 +71,7 @@ export class EditFixedDepositAccountComponent {
fixedDepositsAccountProductTemplate: any;
/** Currently visible wizard tab (0-indexed). */
selectedTabIndex = 0;
private readonly totalTabs = 4;
private readonly totalTabs = 5;
/** Active CBS base interest rate versions for the selected product's FD type */
cbsBaseRates: any[] = [];
/** Tenure selected from the interest rate chart step */
@@ -236,6 +242,7 @@ export class EditFixedDepositAccountComponent {
...this.fixedDepositAccount,
...terms,
...settings,
...(this.fixedDepositAccountMaturityTermsStep?.closureSettings ?? {}),
...(this.selectedTenure
? {
depositPeriod: this.selectedTenure.depositPeriod,
@@ -7,7 +7,17 @@
*/
/** Angular Imports */
import { Component, OnInit, Input, Output, EventEmitter, inject, ChangeDetectorRef } from '@angular/core';
import {
Component,
OnInit,
OnChanges,
SimpleChanges,
Input,
Output,
EventEmitter,
inject,
ChangeDetectorRef
} from '@angular/core';
import { UntypedFormGroup, UntypedFormBuilder, Validators, FormsModule } from '@angular/forms';
import { SettingsService } from 'app/settings/settings.service';
import { Dates } from 'app/core/utils/dates';
@@ -43,7 +53,7 @@ import { IntroducerSearchComponent } from 'app/shared/introducer-search/introduc
IntroducerSearchComponent
]
})
export class FixedDepositAccountDetailsStepComponent implements OnInit {
export class FixedDepositAccountDetailsStepComponent implements OnInit, OnChanges {
private formBuilder = inject(UntypedFormBuilder);
private fixedDepositsService = inject(FixedDepositsService);
private settingsService = inject(SettingsService);
@@ -56,6 +66,9 @@ export class FixedDepositAccountDetailsStepComponent implements OnInit {
/** Fixed Deposits Account Template */
@Input() fixedDepositsAccountTemplate: any;
@Input() hideButtons = false;
/** Linked-account id picked elsewhere in the wizard (e.g. the Terms step's Transfer to Savings
* account) -- pre-fills this card so the officer doesn't have to pick the same account twice. */
@Input() externalLinkedAccountId: number | null = null;
/** Minimum date allowed. */
minDate = new Date(2000, 0, 1);
@@ -222,6 +235,23 @@ export class FixedDepositAccountDetailsStepComponent implements OnInit {
this.dalSearch = '';
}
ngOnChanges(changes: SimpleChanges): void {
if (!changes.externalLinkedAccountId || this.selectedLinkedAccount) {
return;
}
const id = this.externalLinkedAccountId;
if (!id) return;
const savings = this.dalAllSavings.find((a: any) => a.id === id);
const current = this.dalAllCurrent.find((a: any) => a.id === id);
if (savings) {
this.dalSelectedType = 'SAVINGS';
this.selectedLinkedAccount = savings;
} else if (current) {
this.dalSelectedType = 'CURRENT';
this.selectedLinkedAccount = current;
}
}
private loadClientAccounts(): void {
const clientId = String(this.fixedDepositsAccountTemplate?.clientId);
if (!clientId) return;
@@ -0,0 +1,108 @@
<!--
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/.
-->
<form [formGroup]="closureSettingsForm" class="layout-row-wrap gap-2px responsive-column">
<h4 class="mat-h4 flex-98">{{ 'labels.heading.For Pre-mature closure' | translate }}</h4>
<mat-form-field class="flex-48">
<mat-label>{{ 'labels.inputs.Maturity Instructions' | translate }}</mat-label>
<mat-select formControlName="maturityInstructionId">
@for (maturityInstruction of maturityInstructionOptions; track maturityInstruction) {
<mat-option [value]="maturityInstruction.id">
{{ maturityInstruction.value | translateKey: 'catalogs' }}
</mat-option>
}
</mat-select>
</mat-form-field>
@if (closureSettingsForm.contains('transferToSavingsId')) {
<div class="dal-card flex-98">
<div class="dal-card__header">
<div class="dal-card__header-left">
<div class="dal-card__icon">
<mat-icon>savings</mat-icon>
</div>
<div>
<h3 class="dal-card__title">{{ 'labels.inputs.Transfer to savings account' | translate }}</h3>
<span class="dal-card__sub">Maturity proceeds will be credited to this account</span>
</div>
</div>
</div>
<div class="dal-card__body">
@if (selectedTransferAccount) {
<div class="dal-selected">
<div class="dal-selected__info">
<span class="dal-type-badge dal-type-badge--savings">
<mat-icon>savings</mat-icon>
Savings
</span>
<div class="dal-selected__details">
<span class="dal-selected__accno">{{ selectedTransferAccount.accountNo }}</span>
<span class="dal-selected__product">{{ selectedTransferAccount.savingsProductName }}</span>
</div>
@if (selectedTransferAccount.summary?.accountBalance != null) {
<div class="dal-selected__balance">
<span class="dal-selected__bal-label">Balance</span>
<span class="dal-selected__bal-amount">
{{ selectedTransferAccount.currency?.code }}
{{ selectedTransferAccount.summary.accountBalance | number: '1.2-2' }}
</span>
</div>
}
</div>
<button
mat-stroked-button
color="primary"
type="button"
class="dal-change-btn"
(click)="clearTransferAccount()"
>
<mat-icon>swap_horiz</mat-icon>
Change
</button>
</div>
} @else {
<mat-form-field appearance="outline" class="flex-98">
<mat-label>{{ 'labels.inputs.Transfer to savings account' | translate }}</mat-label>
<mat-select formControlName="transferToSavingsId" required>
@for (account of savingsAccountsData; track account) {
<mat-option [value]="account.id">
({{ account.accountNo }}) {{ account.savingsProductName }}
</mat-option>
}
</mat-select>
<mat-error>Transfer to savings account is required</mat-error>
</mat-form-field>
}
</div>
</div>
}
<mat-checkbox class="flex-98 margin-v" labelPosition="before" formControlName="preClosurePenalApplicable">
{{ 'labels.inputs.Apply Penal Interest (less)' | translate }}
</mat-checkbox>
@if (closureSettingsForm.value.preClosurePenalApplicable) {
<mat-form-field class="flex-48">
<mat-label>{{ 'labels.inputs.Penal Interest' | translate }} (%)</mat-label>
<input type="number" matInput formControlName="preClosurePenalInterest" min="0" step="0.01" />
</mat-form-field>
<mat-form-field class="flex-48">
<mat-label>{{ 'labels.inputs.Period' | translate }}</mat-label>
<mat-select formControlName="preClosurePenalInterestOnTypeId">
@for (preClosurePenalInterestOnType of preClosurePenalInterestOnTypeData; track preClosurePenalInterestOnType) {
<mat-option [value]="preClosurePenalInterestOnType.id">
{{ preClosurePenalInterestOnType.value | translateKey: 'catalogs' }}
</mat-option>
}
</mat-select>
</mat-form-field>
}
</form>
@@ -0,0 +1,152 @@
/**
* 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/.
*/
// ── Transfer-to-Savings account card (mirrors the Deposit Account Link card) ──
.dal-card {
margin-top: 16px;
border: 1px solid #e2e8f0;
border-radius: 12px;
background: #fff;
overflow: hidden;
&__header {
display: flex;
align-items: center;
gap: 14px;
padding: 16px 24px;
border-bottom: 1px solid #f1f5f9;
background: linear-gradient(135deg, #f0f9ff 0%, #e0f2fe 100%);
}
&__header-left {
display: flex;
align-items: center;
gap: 14px;
}
&__icon {
width: 44px;
height: 44px;
border-radius: 10px;
background: #0ea5e9;
display: flex;
align-items: center;
justify-content: center;
color: #fff;
}
&__title {
margin: 0;
font-size: 15px;
font-weight: 700;
color: #0c4a6e;
}
&__sub {
display: block;
font-size: 12px;
color: #64748b;
margin-top: 2px;
}
&__body {
padding: 20px 24px;
}
}
.dal-selected {
display: flex;
align-items: center;
gap: 16px;
padding: 16px 20px;
border: 2px solid #0ea5e9;
border-radius: 12px;
background: #f0f9ff;
&__info {
display: flex;
align-items: center;
gap: 14px;
flex: 1;
flex-wrap: wrap;
}
&__details {
display: flex;
flex-direction: column;
gap: 2px;
flex: 1;
}
&__accno {
font-size: 15px;
font-weight: 700;
color: #0c4a6e;
font-family: monospace;
}
&__product {
font-size: 12px;
color: #64748b;
}
&__balance {
display: flex;
flex-direction: column;
align-items: flex-end;
gap: 2px;
}
&__bal-label {
font-size: 10px;
color: #94a3b8;
text-transform: uppercase;
letter-spacing: 0.5px;
}
&__bal-amount {
font-size: 15px;
font-weight: 700;
color: #0c4a6e;
}
}
.dal-change-btn {
white-space: nowrap;
}
.dal-type-badge {
display: inline-flex;
align-items: center;
gap: 4px;
padding: 4px 12px;
border-radius: 999px;
font-size: 12px;
font-weight: 700;
white-space: nowrap;
background: #f1f5f9;
color: #475569;
border: 1px solid #e2e8f0;
mat-icon {
font-size: 14px;
width: 14px;
height: 14px;
}
&--savings {
background: #eff6ff;
color: #1d4ed8;
border-color: #bfdbfe;
mat-icon {
color: #2563eb;
}
}
}
@@ -0,0 +1,117 @@
/**
* 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/.
*/
/** Angular Imports */
import { Component, Input, OnChanges, SimpleChanges, inject } from '@angular/core';
import { UntypedFormGroup, UntypedFormBuilder, UntypedFormControl, Validators } from '@angular/forms';
import { MatCheckbox } from '@angular/material/checkbox';
import { MatIcon } from '@angular/material/icon';
import { STANDALONE_SHARED_IMPORTS } from 'app/standalone-shared.module';
/**
* Fixed Deposit Account Maturity Terms Step -- what happens to this account at maturity
* (Withdraw / Transfer to Savings / Reinvest) and the pre-mature-closure penalty, decided
* per account rather than silently inherited from the product.
*/
@Component({
selector: 'mifosx-fixed-deposit-account-maturity-terms-step',
templateUrl: './fixed-deposit-account-maturity-terms-step.component.html',
styleUrls: ['./fixed-deposit-account-maturity-terms-step.component.scss'],
imports: [
...STANDALONE_SHARED_IMPORTS,
MatCheckbox,
MatIcon
]
})
export class FixedDepositAccountMaturityTermsStepComponent implements OnChanges {
private formBuilder = inject(UntypedFormBuilder);
@Input() fixedDepositsAccountTemplate: any;
@Input() fixedDepositsAccountProductTemplate: any;
/** Account already linked elsewhere in the wizard (the Details step's Deposit Account Link) --
* pre-fills Transfer to Savings so the officer doesn't have to pick the same account twice. */
@Input() linkedSavingsAccountId: number | null = null;
closureSettingsForm: UntypedFormGroup;
maturityInstructionOptions: any[] = [];
savingsAccountsData: any[] = [];
preClosurePenalInterestOnTypeData: any[] = [];
constructor() {
this.closureSettingsForm = this.formBuilder.group({
maturityInstructionId: [''],
preClosurePenalApplicable: [false],
preClosurePenalInterest: [
'',
Validators.min(0)
],
preClosurePenalInterestOnTypeId: ['']
});
this.closureSettingsForm.get('maturityInstructionId').valueChanges.subscribe((value: number) => {
// 200 = Transfer to Savings -- Withdraw Deposit (100) needs no target account, and both
// Reinvest options (300/400) open a brand-new FD instead, so they ignore this field too.
if (value === 200) {
this.closureSettingsForm.addControl(
'transferToSavingsId',
new UntypedFormControl(this.linkedSavingsAccountId ?? '', Validators.required)
);
} else {
this.closureSettingsForm.removeControl('transferToSavingsId');
}
});
}
ngOnChanges(changes: SimpleChanges) {
if (changes.linkedSavingsAccountId) {
const control = this.closureSettingsForm.get('transferToSavingsId');
if (control && !control.value && this.linkedSavingsAccountId) {
control.setValue(this.linkedSavingsAccountId);
}
}
if (this.fixedDepositsAccountProductTemplate) {
// Withdraw Deposit (id 100) books the maturity proceeds out of the bank's cash/vault the moment
// the account matures, before the customer has actually collected it -- disabled everywhere.
this.maturityInstructionOptions = (
this.fixedDepositsAccountProductTemplate.maturityInstructionOptions ?? []
).filter((option: any) => option.id !== 100);
this.savingsAccountsData = this.fixedDepositsAccountProductTemplate.savingsAccounts ?? [];
this.preClosurePenalInterestOnTypeData =
this.fixedDepositsAccountProductTemplate.preClosurePenalInterestOnTypeOptions ?? [];
if (!this.closureSettingsForm.get('preClosurePenalApplicable').dirty) {
this.closureSettingsForm.patchValue({
preClosurePenalApplicable: this.fixedDepositsAccountProductTemplate.preClosurePenalApplicable ?? false,
preClosurePenalInterest: this.fixedDepositsAccountProductTemplate.preClosurePenalInterest ?? '',
preClosurePenalInterestOnTypeId:
this.fixedDepositsAccountProductTemplate.preClosurePenalInterestOnType?.id ?? ''
});
}
}
}
/** The linked-savings account for the transfer-to-savings maturity instruction, once picked. */
get selectedTransferAccount(): any {
const id = this.closureSettingsForm.value.transferToSavingsId;
if (!id) return null;
return this.savingsAccountsData.find((account: any) => account.id === id) ?? null;
}
clearTransferAccount(): void {
this.closureSettingsForm.get('transferToSavingsId')?.setValue('');
}
/** Chosen maturity/closure settings, ready to merge into the submit payload. */
get closureSettings() {
const settings = this.closureSettingsForm.getRawValue();
for (const key in settings) {
if (settings[key] === '') {
delete settings[key];
}
}
return settings;
}
}
@@ -683,6 +683,41 @@
</div>
</div>
<div class="preview-section">
<div class="preview-section__header">
<i class="fas fa-flag-checkered preview-section__icon"></i>
<span class="preview-section__title">Maturity &amp; Pre-mature Closure</span>
</div>
<div class="preview-body">
<div class="preview-grid preview-grid--three">
<div class="preview-field">
<span class="preview-label">{{ 'labels.inputs.Maturity Instructions' | translate }}</span>
<span class="preview-value">{{ closureMaturityInstructionLabel }}</span>
</div>
@if (closureSettings?.transferToSavingsId) {
<div class="preview-field">
<span class="preview-label">{{ 'labels.inputs.Transfer to savings account' | translate }}</span>
<span class="preview-value">{{ closureTransferToSavingsLabel }}</span>
</div>
}
<div class="preview-field">
<span class="preview-label">{{ 'labels.inputs.Apply Penal Interest (less)' | translate }}</span>
<span class="preview-value">{{ closureSettings?.preClosurePenalApplicable | yesNo }}</span>
</div>
@if (closureSettings?.preClosurePenalApplicable) {
<div class="preview-field">
<span class="preview-label">{{ 'labels.inputs.Penal Interest' | translate }} (%)</span>
<span class="preview-value">{{ closureSettings?.preClosurePenalInterest }}%</span>
</div>
<div class="preview-field">
<span class="preview-label">{{ 'labels.inputs.Period' | translate }}</span>
<span class="preview-value">{{ closurePenalInterestOnTypeLabel }}</span>
</div>
}
</div>
</div>
</div>
@if (!hideButtons) {
<div class="layout-row responsive-column align-center gap-2px margin-t">
<button mat-raised-button matStepperPrevious>
@@ -164,7 +164,26 @@ export class FixedDepositAccountPreviewStepComponent implements OnChanges {
/** Form submission event */
@Output() submitEvent = new EventEmitter();
constructor() {}
/** Maturity/pre-mature-closure choices made in the Terms step -- shown here read-only. */
@Input() closureSettings: any = {};
get closureMaturityInstructionLabel(): string {
const options = this.fixedDepositsAccountProductTemplate?.maturityInstructionOptions ?? [];
const match = options.find((o: any) => o.id === this.closureSettings?.maturityInstructionId);
return match?.value ?? '—';
}
get closureTransferToSavingsLabel(): string {
const accounts = this.fixedDepositsAccountProductTemplate?.savingsAccounts ?? [];
const match = accounts.find((a: any) => a.id === this.closureSettings?.transferToSavingsId);
return match ? `(${match.accountNo}) ${match.savingsProductName}` : '—';
}
get closurePenalInterestOnTypeLabel(): string {
const options = this.fixedDepositsAccountProductTemplate?.preClosurePenalInterestOnTypeOptions ?? [];
const match = options.find((o: any) => o.id === this.closureSettings?.preClosurePenalInterestOnTypeId);
return match?.value ?? '—';
}
ngOnChanges() {
if (this.fixedDepositsAccountProductTemplate) {
@@ -0,0 +1,191 @@
<!--
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="pmt">
<div class="pmt__header">
<span class="pmt__header-ico"><fa-icon icon="sync-alt"></fa-icon></span>
<div class="pmt__header-text">
<h2 class="pmt__title">{{ 'labels.heading.Payment' | translate }}</h2>
<p class="pmt__subtitle">Process this Fixed Deposit now -- works whether it's Active or Matured.</p>
</div>
</div>
<div mat-dialog-content class="pmt__content">
@if (loadingTemplate) {
<div class="pmt__loading">
<mat-spinner diameter="36"></mat-spinner>
</div>
} @else if (loadFailed) {
<div class="pmt__error" role="alert">
<fa-icon icon="times-circle" class="m-r-10"></fa-icon>
Could not load this account's payment options. Close this dialog and try again.
</div>
} @else {
<form [formGroup]="paymentForm">
@if (reinvestCap !== null && reinvestCap !== undefined) {
<div class="pmt__stat">
<span class="pmt__stat-label">
{{
remainderIsApproximate
? 'Amount available on the selected date'
: ('labels.inputs.Maturity Amount' | translate)
}}
</span>
<span class="pmt__stat-value">{{ reinvestCap | formatNumber }}</span>
</div>
}
<mat-form-field appearance="outline" class="pmt__date" (click)="closedOnDatePicker.open()">
<mat-label>{{ 'labels.inputs.Closed On Date' | translate }}</mat-label>
<input
matInput
[max]="maxDate"
[matDatepicker]="closedOnDatePicker"
required
formControlName="closedOnDate"
/>
<mat-datepicker-toggle matSuffix [for]="closedOnDatePicker"></mat-datepicker-toggle>
<mat-datepicker #closedOnDatePicker></mat-datepicker>
@if (paymentForm.controls.closedOnDate.hasError('required')) {
<mat-error>
{{ 'labels.inputs.Closed On Date' | translate }} {{ 'labels.commons.is' | translate }}
<strong>{{ 'labels.commons.required' | translate }}</strong>
</mat-error>
}
</mat-form-field>
<div class="pmt__section-label" id="pmt-action-label">{{ 'labels.inputs.Action' | translate }}</div>
<div class="pmt__options" role="radiogroup" aria-labelledby="pmt-action-label">
@for (closureOption of onAccountClosureOptions; track closureOption) {
<button
type="button"
class="pmt__option"
role="radio"
[attr.aria-checked]="paymentForm.get('onAccountClosureId').value === closureOption.id"
[class.pmt__option--active]="paymentForm.get('onAccountClosureId').value === closureOption.id"
(click)="selectClosureType(closureOption.id)"
>
<span class="pmt__option-ico"><fa-icon [icon]="iconFor(closureOption.id)"></fa-icon></span>
<span class="pmt__option-text">
<span class="pmt__option-title">{{ closureOption.value }}</span>
<span class="pmt__option-desc">{{ descriptionFor(closureOption.id) }}</span>
</span>
@if (paymentForm.get('onAccountClosureId').value === closureOption.id) {
<fa-icon icon="check-circle" class="pmt__option-check"></fa-icon>
}
</button>
}
</div>
@if (
paymentForm.controls.onAccountClosureId.hasError('required') &&
paymentForm.controls.onAccountClosureId.touched
) {
<div class="pmt__option-error">
{{ 'labels.inputs.Account action' | translate }} {{ 'labels.commons.is' | translate }}
{{ 'labels.commons.required' | translate }}
</div>
}
@if (isPartialReinvest) {
<div class="pmt__split">
<mat-form-field appearance="outline" class="pmt__split-input">
<mat-label>{{ 'labels.inputs.Reinvest Amount' | translate }}</mat-label>
<input type="number" matInput required formControlName="reinvestAmount" />
@if (paymentForm.controls.reinvestAmount?.hasError('required')) {
<mat-error>
{{ 'labels.inputs.Reinvest Amount' | translate }} {{ 'labels.commons.is' | translate }}
<strong>{{ 'labels.commons.required' | translate }}</strong>
</mat-error>
}
@if (paymentForm.controls.reinvestAmount?.hasError('min')) {
<mat-error>{{ 'labels.inputs.Reinvest Amount must be greater than zero' | translate }}</mat-error>
}
@if (paymentForm.controls.reinvestAmount?.hasError('belowMaturityAmount')) {
<mat-error>{{
'labels.inputs.Reinvest Amount must be less than the maturity amount' | translate
}}</mat-error>
}
</mat-form-field>
<span class="pmt__split-arrow"><fa-icon icon="arrow-right"></fa-icon></span>
<div class="pmt__split-remainder">
<span class="pmt__split-remainder-label">{{ 'labels.inputs.Remainder to Transfer' | translate }}</span>
<span class="pmt__split-remainder-value">{{
remainderAmount !== null ? (remainderAmount | formatNumber) : '—'
}}</span>
</div>
</div>
@if (remainderIsApproximate) {
<p class="pmt__hint">
<fa-icon icon="info-circle" class="m-r-5"></fa-icon>
The selected date is before maturity. Interest up to that date is posted first, so the amount actually
transferred will be at least the figure shown.
</p>
}
}
@if (noDestinationAccounts) {
<p class="pmt__hint pmt__hint--warn" role="alert">
<fa-icon icon="info-circle" class="m-r-5"></fa-icon>
This customer has no active savings account to transfer into. Open one first, or choose an action that keeps
the funds in this deposit.
</p>
}
@if (paymentForm.contains('toSavingsAccountId')) {
<mat-form-field appearance="outline">
<mat-label>{{ 'labels.inputs.Transfer to Savings' | translate }}</mat-label>
<mat-select required formControlName="toSavingsAccountId">
@for (account of savingsAccountsData; track account) {
<mat-option [value]="account.id">
{{ account.accountNo }}
</mat-option>
}
</mat-select>
@if (paymentForm.controls.toSavingsAccountId?.hasError('required')) {
<mat-error>
{{ 'labels.inputs.Transfer to savings account' | translate }} {{ 'labels.commons.is' | translate }}
<strong>{{ 'labels.commons.required' | translate }}</strong>
</mat-error>
}
</mat-form-field>
}
@if (paymentForm.contains('transferDescription')) {
<mat-form-field appearance="outline">
<mat-label>{{ 'labels.inputs.Transfer Description' | translate }}</mat-label>
<input matInput formControlName="transferDescription" />
</mat-form-field>
}
<mat-form-field appearance="outline">
<mat-label>{{ 'labels.inputs.Note' | translate }}</mat-label>
<textarea matInput formControlName="note" cdkTextareaAutosize cdkAutosizeMinRows="2"></textarea>
</mat-form-field>
@if (submitError) {
<p class="pmt__error" role="alert">
<fa-icon icon="times-circle" class="m-r-5"></fa-icon>
{{ submitError }}
</p>
}
</form>
}
</div>
<div mat-dialog-actions align="end" class="pmt__actions">
<button mat-button mat-dialog-close [disabled]="submitting">{{ 'labels.buttons.Cancel' | translate }}</button>
<button
mat-raised-button
color="primary"
[disabled]="!paymentForm?.valid || submitting || loadFailed"
(click)="submit()"
>
{{ submitting ? ('labels.buttons.Processing' | translate) : ('labels.buttons.Confirm' | translate) }}
</button>
</div>
</div>
@@ -0,0 +1,296 @@
/**
* 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/.
*/
$navy: #0d1b2a;
$slate: #475569;
$muted: #64748b;
$border: #e2e8f0;
$soft: #f8fafc;
$blue: #1e40af;
$blue-soft: #eff6ff;
$green: #166534;
$green-soft: #f0fdf4;
.pmt {
display: flex;
flex-direction: column;
min-height: 320px;
max-height: 85vh;
overflow: hidden;
margin: -24px;
}
.pmt__header {
display: flex;
align-items: flex-start;
gap: 14px;
padding: 22px 28px 18px;
background: linear-gradient(135deg, #1e3a8a 0%, #1e40af 55%, #4338ca 100%);
color: #fff;
flex: 0 0 auto;
}
.pmt__header-ico {
width: 40px;
height: 40px;
min-width: 40px;
border-radius: 10px;
background: rgb(255 255 255 / 15%);
display: flex;
align-items: center;
justify-content: center;
font-size: 18px;
}
.pmt__title {
margin: 0;
font-size: 19px;
font-weight: 700;
}
.pmt__subtitle {
margin: 4px 0 0;
font-size: 12.5px;
color: rgb(255 255 255 / 82%);
line-height: 1.4;
}
.pmt__content {
padding: 22px 28px 8px !important;
overflow-y: auto;
flex: 1 1 auto;
}
.pmt__loading {
display: flex;
justify-content: center;
align-items: center;
min-height: 260px;
}
.pmt__stat {
display: flex;
flex-direction: column;
gap: 2px;
background: $blue-soft;
border: 1px solid #dbeafe;
border-radius: 10px;
padding: 12px 16px;
margin-bottom: 18px;
}
.pmt__stat-label {
font-size: 11px;
font-weight: 700;
text-transform: uppercase;
letter-spacing: 0.05em;
color: $blue;
}
.pmt__stat-value {
font-size: 22px;
font-weight: 800;
color: $navy;
font-variant-numeric: tabular-nums;
}
.pmt__date {
width: 100%;
}
.pmt__section-label {
font-size: 11.5px;
font-weight: 700;
text-transform: uppercase;
letter-spacing: 0.05em;
color: $slate;
margin: 4px 0 10px;
}
.pmt__options {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(190px, 1fr));
gap: 10px;
margin-bottom: 6px;
}
.pmt__option {
display: flex;
align-items: flex-start;
gap: 10px;
text-align: left;
background: #fff;
border: 1.5px solid $border;
border-radius: 10px;
padding: 12px 14px;
cursor: pointer;
font: inherit;
transition:
border-color 0.15s,
background 0.15s,
box-shadow 0.15s;
}
.pmt__option:hover {
border-color: #93c5fd;
background: $blue-soft;
}
.pmt__option--active {
border-color: $blue;
background: $blue-soft;
box-shadow: 0 0 0 1px $blue;
}
.pmt__option-ico {
width: 30px;
height: 30px;
min-width: 30px;
border-radius: 8px;
background: $soft;
color: $blue;
display: flex;
align-items: center;
justify-content: center;
font-size: 13px;
}
.pmt__option--active .pmt__option-ico {
background: $blue;
color: #fff;
}
.pmt__option-text {
display: flex;
flex-direction: column;
gap: 2px;
}
.pmt__option-title {
font-size: 13px;
font-weight: 700;
color: $navy;
}
.pmt__option-desc {
font-size: 11px;
color: $muted;
line-height: 1.35;
}
.pmt__option:focus-visible {
outline: 2px solid $blue;
outline-offset: 2px;
}
.pmt__option-check {
margin-left: auto;
color: $blue;
font-size: 13px;
}
.pmt__option-error {
color: #b91c1c;
font-size: 12px;
margin: 4px 0 14px;
}
.pmt__error {
display: flex;
align-items: flex-start;
gap: 4px;
background: #fef2f2;
border: 1px solid #fecaca;
border-radius: 8px;
color: #b91c1c;
font-size: 12.5px;
line-height: 1.45;
padding: 10px 14px;
margin: 4px 0 8px;
}
.pmt__hint {
display: flex;
align-items: flex-start;
gap: 4px;
background: $soft;
border: 1px solid $border;
border-radius: 8px;
color: $slate;
font-size: 12px;
line-height: 1.45;
padding: 9px 13px;
margin: 0 0 10px;
}
.pmt__hint--warn {
background: #fffbeb;
border-color: #fde68a;
color: #92400e;
}
.pmt__split {
display: flex;
align-items: center;
gap: 12px;
background: $soft;
border: 1px dashed #cbd5e1;
border-radius: 10px;
padding: 14px 16px 0;
margin: 4px 0 8px;
flex-wrap: wrap;
}
.pmt__split-input {
flex: 1 1 180px;
min-width: 160px;
}
.pmt__split-arrow {
color: $muted;
font-size: 14px;
margin-bottom: 20px;
}
.pmt__split-remainder {
display: flex;
flex-direction: column;
gap: 2px;
background: $green-soft;
border: 1px solid #bbf7d0;
border-radius: 8px;
padding: 8px 14px;
margin-bottom: 14px;
flex: 1 1 180px;
min-width: 160px;
}
.pmt__split-remainder-label {
font-size: 10.5px;
font-weight: 700;
text-transform: uppercase;
letter-spacing: 0.04em;
color: $green;
}
.pmt__split-remainder-value {
font-size: 16px;
font-weight: 800;
color: $green;
font-variant-numeric: tabular-nums;
}
.pmt__content mat-form-field {
width: 100%;
}
.pmt__actions {
padding: 14px 28px 20px !important;
border-top: 1px solid $border;
flex: 0 0 auto;
}
@@ -0,0 +1,288 @@
/**
* 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/.
*/
/** Angular Imports */
import { Component, OnInit, inject } from '@angular/core';
import {
MAT_DIALOG_DATA,
MatDialogRef,
MatDialogContent,
MatDialogActions,
MatDialogClose
} from '@angular/material/dialog';
import { CdkScrollable } from '@angular/cdk/scrolling';
import { CdkTextareaAutosize } from '@angular/cdk/text-field';
import { MatProgressSpinnerModule } from '@angular/material/progress-spinner';
import { FaIconComponent } from '@fortawesome/angular-fontawesome';
import { UntypedFormBuilder, UntypedFormGroup, UntypedFormControl, ValidatorFn, Validators } from '@angular/forms';
/** Custom Services */
import { FixedDepositsService } from '../../../fixed-deposits.service';
import { SettingsService } from 'app/settings/settings.service';
import { Dates } from 'app/core/utils/dates';
import { FormatNumberPipe } from '../../../../../pipes/format-number.pipe';
import { STANDALONE_SHARED_IMPORTS } from 'app/standalone-shared.module';
/**
* "Payment" dialog -- an always-available alternative to Premature Close (Active-only, no reinvest) and
* Close (Matured-only, no partial option). Works on an FD in either state and offers a fourth choice,
* Partial Reinvest, alongside the ones the backend's `DepositAccountOnClosureType` already had: the
* entered amount renews on this same account, the rest transfers to a savings account picked here.
*/
@Component({
selector: 'mifosx-payment-dialog',
templateUrl: './payment-dialog.component.html',
styleUrls: ['./payment-dialog.component.scss'],
imports: [
...STANDALONE_SHARED_IMPORTS,
CdkScrollable,
MatDialogContent,
MatDialogActions,
MatDialogClose,
CdkTextareaAutosize,
MatProgressSpinnerModule,
FaIconComponent,
FormatNumberPipe
]
})
export class PaymentDialogComponent implements OnInit {
/** The `onAccountClosureId` codes each closure type is registered under on the backend. */
private static readonly WITHDRAW_DEPOSIT_ID = 100;
private static readonly TRANSFER_TO_SAVINGS_ID = 200;
private static readonly REINVEST_FULL_ID = 300;
private static readonly REINVEST_PRINCIPAL_ID = 400;
private static readonly PARTIAL_REINVEST_ID = 500;
private static readonly ACTION_ICONS: Record<number, string> = {
[PaymentDialogComponent.WITHDRAW_DEPOSIT_ID]: 'money-bill-wave',
[PaymentDialogComponent.TRANSFER_TO_SAVINGS_ID]: 'wallet',
[PaymentDialogComponent.REINVEST_FULL_ID]: 'sync-alt',
[PaymentDialogComponent.REINVEST_PRINCIPAL_ID]: 'piggy-bank',
[PaymentDialogComponent.PARTIAL_REINVEST_ID]: 'arrows-alt-h'
};
private static readonly ACTION_DESCRIPTIONS: Record<number, string> = {
[PaymentDialogComponent.WITHDRAW_DEPOSIT_ID]: 'Pay out the full balance in cash',
[PaymentDialogComponent.TRANSFER_TO_SAVINGS_ID]: 'Move the full balance to another savings account',
[PaymentDialogComponent.REINVEST_FULL_ID]: 'Renew the full amount, same account number',
[PaymentDialogComponent.REINVEST_PRINCIPAL_ID]:
'Renew only the principal; interest transferred to a savings account',
[PaymentDialogComponent.PARTIAL_REINVEST_ID]: 'Renew part of it here; transfer the rest'
};
dialogRef = inject<MatDialogRef<PaymentDialogComponent>>(MatDialogRef);
data: { accountId: number | string; accountBalance?: number; maturityDate?: any } = inject(MAT_DIALOG_DATA);
private formBuilder = inject(UntypedFormBuilder);
private fixedDepositsService = inject(FixedDepositsService);
private settingsService = inject(SettingsService);
private dateUtils = inject(Dates);
paymentForm: UntypedFormGroup;
/** Loaded once, on open, from the same closure template the existing Close action already uses. */
onAccountClosureOptions: any[] = [];
savingsAccountsData: any[] = [];
maturityAmount: number = null;
loadingTemplate = true;
loadFailed = false;
submitting = false;
submitError: string = null;
maxDate: Date;
ngOnInit(): void {
this.maxDate = this.settingsService.businessDate;
this.paymentForm = this.formBuilder.group({
closedOnDate: [
'',
Validators.required
],
onAccountClosureId: [
'',
Validators.required
],
note: ['']
});
this.paymentForm.get('onAccountClosureId').valueChanges.subscribe((id: number) => this.onClosureTypeChange(id));
// The cap depends on whether the chosen date is on or after maturity, so a date change can make an
// already-entered amount valid or invalid. Without this the amount keeps its verdict from the old date.
this.paymentForm
.get('closedOnDate')
.valueChanges.subscribe(() =>
this.paymentForm.get('reinvestAmount')?.updateValueAndValidity({ emitEvent: false })
);
this.fixedDepositsService.getFixedDepositsAccountPaymentTemplate(this.data.accountId as string).subscribe({
next: (response: any) => {
// Withdraw Deposit books cash out of the vault immediately, which isn't appropriate for a
// staff-triggered Payment that may run well before or after the actual counter transaction --
// Transfer to Savings or a Reinvest option covers every real case instead.
this.onAccountClosureOptions = (response.onAccountClosureOptions ?? []).filter(
(option: any) => option.id !== PaymentDialogComponent.WITHDRAW_DEPOSIT_ID
);
this.savingsAccountsData = response.savingsAccounts ?? [];
this.maturityAmount = response.maturityAmount;
this.loadingTemplate = false;
},
// Without this the spinner would run forever on a failed load, leaving Cancel as the only way out
// and no indication of why the form never appeared.
error: () => {
this.loadingTemplate = false;
this.loadFailed = true;
}
});
}
/**
* The amount being split, and the bound the entered partial amount is checked against.
* <p>
* The server derives this from the account's balance AFTER it posts interest up to the chosen date, so which figure
* matches depends on that date. On or after maturity it posts the remaining full-rate interest, landing on the
* maturity amount. Before maturity it posts penalty-aware interest instead, which lands somewhere between the current
* balance and the maturity amount -- unknowable here, so the current balance is used as a floor that can never accept
* an amount the server would reject, and {@link remainderIsApproximate} tells the user the transfer will be larger.
*/
get reinvestCap(): number | null {
if (this.processingOnOrAfterMaturity) {
return this.maturityAmount ?? this.data.accountBalance ?? null;
}
return this.data.accountBalance ?? this.maturityAmount ?? null;
}
/** Whether the chosen closure date is on or after the deposit's maturity date. */
private get processingOnOrAfterMaturity(): boolean {
const chosen = this.paymentForm?.get('closedOnDate')?.value;
if (!chosen || !this.data.maturityDate) {
return false;
}
return this.toTime(chosen) >= this.toTime(this.data.maturityDate);
}
/** Dates arrive here either as a Date from the picker or as the API's [yyyy, mm, dd] array. */
private toTime(value: any): number {
if (value instanceof Date) {
return new Date(value.getFullYear(), value.getMonth(), value.getDate()).getTime();
}
if (Array.isArray(value)) {
const [
year,
month,
day
] = value;
return new Date(year, (month ?? 1) - 1, day ?? 1).getTime();
}
return new Date(value).getTime();
}
/** True when the cap is the pre-maturity balance, so the figures shown are a floor rather than exact. */
get remainderIsApproximate(): boolean {
return !this.processingOnOrAfterMaturity;
}
iconFor(id: number): string {
return PaymentDialogComponent.ACTION_ICONS[id] ?? 'circle';
}
descriptionFor(id: number): string {
return PaymentDialogComponent.ACTION_DESCRIPTIONS[id] ?? '';
}
selectClosureType(id: number): void {
this.paymentForm.get('onAccountClosureId').setValue(id);
this.paymentForm.get('onAccountClosureId').markAsTouched();
}
/** Adds/removes the destination-account and partial-amount controls to match what the chosen type needs. */
private onClosureTypeChange(id: number): void {
const needsDestinationAccount =
id === PaymentDialogComponent.TRANSFER_TO_SAVINGS_ID ||
id === PaymentDialogComponent.PARTIAL_REINVEST_ID ||
id === PaymentDialogComponent.REINVEST_PRINCIPAL_ID;
if (needsDestinationAccount) {
if (!this.paymentForm.contains('toSavingsAccountId')) {
this.paymentForm.addControl('toSavingsAccountId', new UntypedFormControl('', Validators.required));
this.paymentForm.addControl('transferDescription', new UntypedFormControl(''));
}
} else {
this.paymentForm.removeControl('toSavingsAccountId');
this.paymentForm.removeControl('transferDescription');
}
if (id === PaymentDialogComponent.PARTIAL_REINVEST_ID) {
if (!this.paymentForm.contains('reinvestAmount')) {
this.paymentForm.addControl(
'reinvestAmount',
new UntypedFormControl('', [
Validators.required,
Validators.min(0.01),
this.belowMaturityAmountValidator()
])
);
}
} else {
this.paymentForm.removeControl('reinvestAmount');
}
}
/** Mirrors the backend's own `0 < reinvestAmount < accountBalance` rule, so a bad amount fails before submit. */
private belowMaturityAmountValidator(): ValidatorFn {
return (control) => {
const cap = Number(this.reinvestCap);
if (!cap || control.value === null || control.value === '') {
return null;
}
return Number(control.value) >= cap ? { belowMaturityAmount: true } : null;
};
}
get isPartialReinvest(): boolean {
return this.paymentForm?.get('onAccountClosureId')?.value === PaymentDialogComponent.PARTIAL_REINVEST_ID;
}
/** True when the chosen action needs a destination account but the customer has none to pick. */
get noDestinationAccounts(): boolean {
return (
!this.loadingTemplate && this.paymentForm?.contains('toSavingsAccountId') && this.savingsAccountsData.length === 0
);
}
/** What leaves this FD when the entered partial amount is kept behind; null until both figures are known. */
get remainderAmount(): number | null {
const reinvestAmount = this.paymentForm?.get('reinvestAmount')?.value;
if (!this.isPartialReinvest || reinvestAmount === null || reinvestAmount === '' || !this.reinvestCap) {
return null;
}
return Number(this.reinvestCap) - Number(reinvestAmount);
}
submit(): void {
const formValue = { ...this.paymentForm.value };
const locale = this.settingsService.language.code;
const dateFormat = this.settingsService.dateFormat;
if (formValue.closedOnDate instanceof Date) {
formValue.closedOnDate = this.dateUtils.formatDate(formValue.closedOnDate, dateFormat);
}
const data = { ...formValue, dateFormat, locale };
this.submitting = true;
this.submitError = null;
this.fixedDepositsService
.executeFixedDepositsAccountCommand(this.data.accountId as string, 'payment', data)
.subscribe({
next: () => {
this.dialogRef.close({ confirm: true });
},
// Keep the dialog open with the entered values and say what went wrong, rather than leaving a
// permanently disabled Confirm behind whatever modal the global interceptor threw up.
error: (error: any) => {
this.submitting = false;
this.submitError =
error?.error?.defaultUserMessage ??
error?.error?.errors?.[0]?.defaultUserMessage ??
'This payment could not be processed. Please review the details and try again.';
}
});
}
}
@@ -37,7 +37,7 @@
<span class="fd-hero__meta-item">
<span class="fd-hero__meta-label">{{ 'labels.inputs.Account Number' | translate }}</span>
<mifosx-account-number
accountNo="{{ fixedDepositsAccountData.accountNo }}"
accountNo="{{ fixedDepositsAccountData.fdAccountNumber || fixedDepositsAccountData.accountNo }}"
display="left"
></mifosx-account-number>
</span>
@@ -79,6 +79,16 @@
<!-- Action menu (kebab) -->
<div class="fd-hero__actions">
@if (!fixedDepositsAccountData.status.rejected && !fixedDepositsAccountData.status.submittedAndPendingApproval) {
<button
mat-icon-button
matTooltip="Print Certificate"
(click)="printCertificate()"
aria-label="Print certificate"
>
<fa-icon icon="print"></fa-icon>
</button>
}
<button mat-icon-button [matMenuTriggerFor]="accountMenu" aria-label="Account actions" yPosition="below">
<fa-icon icon="ellipsis-v"></fa-icon>
</button>
@@ -220,6 +230,19 @@
<span>{{ 'labels.inputs.Transactions' | translate }}</span>
</a>
}
@if (fixedDepositsAccountData.renewalHistory?.length) {
<a
mat-tab-link
class="fd-tab"
[routerLink]="['./renewal-history']"
routerLinkActive
#renewalHistory="routerLinkActive"
[active]="renewalHistory.isActive"
>
<fa-icon icon="history" class="fd-tab__ic"></fa-icon>
<span>{{ 'labels.inputs.Renewal History' | translate }}</span>
</a>
}
<a
mat-tab-link
class="fd-tab"
@@ -16,6 +16,8 @@ import { ConfirmationDialogComponent } from 'app/shared/confirmation-dialog/conf
import { CalculateInterestDialogComponent } from './custom-dialogs/calculate-interest-dialog/calculate-interest-dialog.component';
import { PostInterestDialogComponent } from './custom-dialogs/post-interest-dialog/post-interest-dialog.component';
import { ToggleWithholdTaxDialogComponent } from './custom-dialogs/toggle-withhold-tax-dialog/toggle-withhold-tax-dialog.component';
import { PaymentDialogComponent } from './custom-dialogs/payment-dialog/payment-dialog.component';
import { DepositCertificateDialogComponent } from 'app/shared/deposit-certificate-dialog/deposit-certificate-dialog.component';
/** Custom Button Config. */
import { FixedDepositsButtonsConfiguration } from './fixed-deposits-buttons.config';
@@ -98,6 +100,7 @@ export class FixedDepositAccountViewComponent implements OnInit {
'Post Interest': 'interest',
'Enable Withhold Tax': 'interest',
'Disable Withhold Tax': 'interest',
Payment: 'transact',
'Premature Close': 'danger',
'Undo Activation': 'admin',
Close: 'danger',
@@ -121,6 +124,7 @@ export class FixedDepositAccountViewComponent implements OnInit {
'Post Interest': 'percentage',
'Enable Withhold Tax': 'percentage',
'Disable Withhold Tax': 'percentage',
Payment: 'sync-alt',
'Premature Close': 'times-circle',
'Undo Activation': 'undo',
Close: 'times-circle',
@@ -260,9 +264,35 @@ export class FixedDepositAccountViewComponent implements OnInit {
case 'Terminate':
this.terminateFixedDepositsAccount();
break;
case 'Payment':
this.openPaymentDialog();
break;
}
}
/**
* Opens the Payment action -- unlike Premature Close/Close, works whether the account is Active or
* Matured, and is the only place a Partial Reinvest can be done.
*/
private openPaymentDialog(): void {
const paymentDialogRef = this.dialog.open(PaymentDialogComponent, {
width: '640px',
maxWidth: '95vw',
data: {
accountId: this.fixedDepositsAccountData.id,
// The server bounds a partial reinvest by the balance AFTER it posts interest to the chosen date,
// so the dialog needs both figures plus the maturity date to know which one that will be.
accountBalance: this.fixedDepositsAccountData.summary?.accountBalance,
maturityDate: this.fixedDepositsAccountData.maturityDate
}
});
paymentDialogRef.afterClosed().subscribe((response: any) => {
if (response?.confirm) {
this.reload();
}
});
}
/**
* Deletes Fixed Deposits Account.
*/
@@ -358,6 +388,20 @@ export class FixedDepositAccountViewComponent implements OnInit {
});
}
/**
* Opens a printable deposit certificate for this account.
*/
printCertificate() {
this.dialog.open(DepositCertificateDialogComponent, {
width: '900px',
data: {
accountType: 'FD',
accountData: this.fixedDepositsAccountData,
clientId: this.fixedDepositsAccountData.clientId
}
});
}
/**
* Disables withhold tax for fixed deposits account
* Fixed deposits endpoint is not supported so using Savings endpoint.
@@ -116,6 +116,9 @@ export class FixedDepositsButtonsConfiguration {
},
{
name: 'Add Charge'
},
{
name: 'Payment'
}
];
break;
@@ -83,6 +83,22 @@
}}</span>
</div>
}
@if (fixedDepositsAccountData.reinvestedFromAccountNo) {
<div class="nx-row">
<span class="nx-row__label">{{ 'labels.inputs.Reinvested From' | translate }}</span>
<span class="nx-row__value">
<a [routerLink]="reinvestedFromLink">{{ fixedDepositsAccountData.reinvestedFromAccountNo }}</a>
</span>
</div>
}
@if (fixedDepositsAccountData.reinvestedIntoAccountNo) {
<div class="nx-row">
<span class="nx-row__label">{{ 'labels.inputs.Reinvested Into' | translate }}</span>
<span class="nx-row__value">
<a [routerLink]="reinvestedIntoLink">{{ fixedDepositsAccountData.reinvestedIntoAccountNo }}</a>
</span>
</div>
}
</div>
</div>
@@ -48,4 +48,25 @@ export class GeneralTabComponent {
this.entityType = 'Center';
}
}
/**
* A reinvested FD always keeps the same client/group/center as the account it came from, so the
* counterpart account's view page sits at the same route -- only the account-id segment differs.
*/
private siblingAccountLink(targetAccountId: number): string | null {
const segments = this.router.url.split('?')[0].split('/').filter(Boolean);
const idx = segments.indexOf('fixed-deposits-accounts');
if (idx === -1) return null;
return '/' + segments.slice(0, idx + 1).join('/') + '/' + targetAccountId + '/general';
}
get reinvestedFromLink(): string | null {
const id = this.fixedDepositsAccountData?.reinvestedFromAccountId;
return id ? this.siblingAccountLink(id) : null;
}
get reinvestedIntoLink(): string | null {
const id = this.fixedDepositsAccountData?.reinvestedIntoAccountId;
return id ? this.siblingAccountLink(id) : null;
}
}
@@ -36,6 +36,17 @@
<td mat-cell *matCellDef="let chartSlab">{{ chartSlab.annualInterestRate | formatNumber }} %</td>
</ng-container>
<ng-container matColumnDef="seniorCitizen">
<th mat-header-cell *matHeaderCellDef><fa-icon icon="user-shield" class="m-r-5"></fa-icon>Senior Citizen</th>
<td mat-cell *matCellDef="let chartSlab">
{{
chartSlab.seniorCitizenRate !== null && chartSlab.seniorCitizenRate !== undefined
? (chartSlab.seniorCitizenRate | formatNumber) + ' %'
: '-'
}}
</td>
</ng-container>
<ng-container matColumnDef="description">
<th mat-header-cell *matHeaderCellDef>{{ 'labels.inputs.Description' | translate }}</th>
<td mat-cell *matCellDef="let chartSlab">{{ chartSlab.description }}</td>
@@ -71,6 +71,7 @@ export class InterestRateChartTabComponent {
'period',
'amountRange',
'interest',
'seniorCitizen',
'description',
'actions'
];
@@ -0,0 +1,139 @@
<!--
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="rh-container">
@if (renewalHistory.length) {
<!-- ─── Summary strip ──────────────────────────────────────────── -->
<div class="rh-summary">
<div class="rh-summary__item">
<span class="rh-summary__label">FD Number</span>
<span class="rh-summary__value">{{ fdNumber }}</span>
</div>
<div class="rh-summary__item">
<span class="rh-summary__label">Terms So Far</span>
<span class="rh-summary__value">{{ renewalHistory.length }}</span>
</div>
<div class="rh-summary__item">
<span class="rh-summary__label">Original Principal</span>
<span class="rh-summary__value">{{ originalPrincipal | currency: currency.code : 'symbol-narrow' }}</span>
</div>
<div class="rh-summary__item rh-summary__item--highlight">
<span class="rh-summary__label">Current Value</span>
<span class="rh-summary__value">{{ currentValue | currency: currency.code : 'symbol-narrow' }}</span>
</div>
<div class="rh-summary__item rh-summary__item--positive">
<span class="rh-summary__label">Lifetime Interest</span>
<span class="rh-summary__value">{{ lifetimeInterestEarned | currency: currency.code : 'symbol-narrow' }}</span>
</div>
</div>
<!-- ─── Vertical timeline ──────────────────────────────────────── -->
<div class="rh-timeline">
@for (
term of renewalHistory;
track term.historyid ?? term.accountid + '-' + term.renewalcycleno;
let last = $last
) {
<div class="rh-node">
<div class="rh-node__rail">
<span class="rh-node__dot" [class.rh-node__dot--active]="term.renewalstatus === 'ACTIVE'">
@if (term.renewalstatus === 'ACTIVE') {
<span class="rh-node__pulse"></span>
}
</span>
@if (!last) {
<span class="rh-node__line"></span>
}
</div>
<div class="rh-card" [class.rh-card--active]="term.renewalstatus === 'ACTIVE'">
<div class="rh-card__head">
<div class="rh-card__title-group">
<h4 class="rh-card__title">
{{ term.renewalcycleno === 0 ? 'Original Term' : 'Renewal ' + term.renewalcycleno }}
</h4>
<span class="rh-badge" [class.rh-badge--active]="term.renewalstatus === 'ACTIVE'">
<fa-icon [icon]="term.renewalstatus === 'ACTIVE' ? 'check-circle' : 'clock'"></fa-icon>
{{ term.renewalstatus === 'ACTIVE' ? 'Active' : 'Matured' }}
</span>
@if (renewalTypeLabel(term)) {
<span class="rh-tag">
<fa-icon [icon]="renewalTypeIcon(term)"></fa-icon>
{{ renewalTypeLabel(term) }}
</span>
}
</div>
<button type="button" class="rh-chart-btn" (click)="openRateChart(term)">
<fa-icon icon="table"></fa-icon>
View Rate Chart
</button>
</div>
<div class="rh-card__period">
<fa-icon icon="calendar-alt" class="rh-card__period-ico"></fa-icon>
<span>{{ term.activatedondate | dateFormat }}</span>
<fa-icon icon="arrow-right" class="rh-card__period-arrow"></fa-icon>
<span>{{ term.maturitydate | dateFormat }}</span>
@if (tenureLabel(term)) {
<span class="rh-card__tenure">({{ tenureLabel(term) }})</span>
}
</div>
<div class="rh-stats">
<div class="rh-stat">
<span class="rh-stat__label">Principal</span>
<span class="rh-stat__value">{{ term.depositamount | currency: currency.code : 'symbol-narrow' }}</span>
</div>
<div class="rh-stat">
<span class="rh-stat__label">Rate</span>
<span class="rh-stat__value">{{ term.interestrate }}%</span>
</div>
<div class="rh-stat">
<span class="rh-stat__label">{{
term.renewalstatus === 'ACTIVE' ? 'Interest So Far' : 'Interest Earned'
}}</span>
<span class="rh-stat__value rh-stat__value--positive">
{{
term.interestearned === null || term.interestearned === undefined
? '—'
: (term.interestearned | currency: currency.code : 'symbol-narrow')
}}
</span>
</div>
<div class="rh-stat rh-stat--highlight">
<span class="rh-stat__label">{{
term.maturedamount === null || term.maturedamount === undefined ? 'Projected Maturity' : 'Matured At'
}}</span>
<span class="rh-stat__value">
{{ maturityFor(term) | currency: currency.code : 'symbol-narrow' }}
</span>
</div>
</div>
<div class="rh-card__foot">
<fa-icon icon="info-circle" class="rh-card__foot-ico"></fa-icon>
<span>
Ledger account:
@if (term.accountid === fixedDepositsAccountData.id) {
<strong>{{ term.accountno }}</strong>
} @else {
<a [routerLink]="siblingLink(term.accountid)">{{ term.accountno }}</a>
}
</span>
</div>
</div>
</div>
}
</div>
} @else {
<div class="rh-empty">
<fa-icon icon="history" class="rh-empty__icon"></fa-icon>
<p class="rh-empty__text">{{ 'labels.messages.No Renewal History Available' | translate }}</p>
</div>
}
</div>
@@ -0,0 +1,364 @@
/**
* 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/.
*/
$navy: #0d1b2a;
$slate: #475569;
$muted: #64748b;
$border: #e2e8f0;
$soft: #f8fafc;
$positive: #047857;
$positive-soft: #ecfdf5;
$accent: #2563eb;
$accent-soft: #eff6ff;
.rh-container {
max-width: 860px;
margin: 0 auto;
padding: 4px 0 24px;
}
/* ─── Summary strip ─────────────────────────────────────────────────── */
.rh-summary {
display: grid;
grid-template-columns: repeat(5, minmax(0, 1fr));
gap: 1px;
background: $border;
border: 1px solid $border;
border-radius: 14px;
overflow: hidden;
margin-bottom: 28px;
box-shadow: 0 1px 3px rgb(15 23 42 / 4%);
&__item {
display: flex;
flex-direction: column;
gap: 4px;
padding: 14px 16px;
background: #fff;
}
&__label {
font-size: 10.5px;
font-weight: 600;
color: $muted;
text-transform: uppercase;
letter-spacing: 0.04em;
}
&__value {
font-size: 15px;
font-weight: 700;
color: $navy;
font-variant-numeric: tabular-nums;
}
&__item--highlight {
background: $accent-soft;
.rh-summary__value {
color: $accent;
}
}
&__item--positive {
background: $positive-soft;
.rh-summary__value {
color: $positive;
}
}
}
@media (width <= 720px) {
.rh-summary {
grid-template-columns: repeat(2, minmax(0, 1fr));
}
}
/* ─── Timeline rail ──────────────────────────────────────────────────── */
.rh-timeline {
display: flex;
flex-direction: column;
}
.rh-node {
display: flex;
gap: 18px;
}
.rh-node__rail {
flex: none;
width: 20px;
display: flex;
flex-direction: column;
align-items: center;
}
.rh-node__dot {
position: relative;
width: 16px;
height: 16px;
margin-top: 22px;
border-radius: 50%;
background: #cbd5e1;
border: 3px solid #fff;
box-shadow: 0 0 0 2px #cbd5e1;
flex: none;
&--active {
background: $positive;
box-shadow: 0 0 0 2px $positive;
}
}
.rh-node__pulse {
position: absolute;
inset: -6px;
border-radius: 50%;
border: 2px solid $positive;
opacity: 0.6;
animation: rh-pulse 1.8s ease-out infinite;
}
@keyframes rh-pulse {
0% {
transform: scale(0.6);
opacity: 0.7;
}
100% {
transform: scale(1.6);
opacity: 0;
}
}
.rh-node__line {
flex: 1;
width: 2px;
min-height: 24px;
background: $border;
margin: 4px 0;
}
/* ─── Term card ──────────────────────────────────────────────────────── */
.rh-card {
flex: 1;
min-width: 0;
background: #fff;
border: 1px solid $border;
border-radius: 14px;
box-shadow: 0 1px 3px rgb(15 23 42 / 4%);
padding: 16px 18px;
margin-bottom: 20px;
transition: box-shadow 0.15s ease;
&--active {
border-color: rgb(4 120 87 / 20%);
box-shadow: 0 2px 10px rgb(4 120 87 / 8%);
}
&:hover {
box-shadow: 0 4px 14px rgb(15 23 42 / 8%);
}
&__head {
display: flex;
align-items: flex-start;
justify-content: space-between;
gap: 12px;
flex-wrap: wrap;
}
&__title-group {
display: flex;
align-items: center;
gap: 8px;
flex-wrap: wrap;
}
&__title {
margin: 0;
font-size: 15px;
font-weight: 700;
color: $navy;
}
&__period {
display: flex;
align-items: center;
gap: 8px;
margin-top: 8px;
font-size: 12.5px;
color: $slate;
font-weight: 500;
&-ico {
color: $muted;
font-size: 11px;
}
&-arrow {
color: $muted;
font-size: 10px;
}
}
&__tenure {
color: $muted;
font-weight: 400;
}
&__foot {
display: flex;
align-items: center;
gap: 6px;
margin-top: 12px;
padding-top: 10px;
border-top: 1px dashed $border;
font-size: 11.5px;
color: $muted;
&-ico {
font-size: 10px;
}
a {
color: $accent;
font-weight: 600;
text-decoration: none;
&:hover {
text-decoration: underline;
}
}
}
}
/* ─── Status badge / auto-renewed tag ───────────────────────────────── */
.rh-badge {
display: inline-flex;
align-items: center;
gap: 5px;
padding: 2px 9px;
border-radius: 999px;
background: $soft;
color: $muted;
font-size: 10.5px;
font-weight: 700;
letter-spacing: 0.02em;
text-transform: uppercase;
&--active {
background: $positive-soft;
color: $positive;
}
}
.rh-tag {
display: inline-flex;
align-items: center;
gap: 5px;
padding: 2px 9px;
border-radius: 999px;
background: $accent-soft;
color: $accent;
font-size: 10.5px;
font-weight: 600;
}
/* ─── View Rate Chart button ─────────────────────────────────────────── */
.rh-chart-btn {
display: inline-flex;
align-items: center;
gap: 7px;
padding: 6px 14px;
border: 1px solid $border;
border-radius: 999px;
background: #fff;
color: $accent;
font-size: 12px;
font-weight: 600;
cursor: pointer;
transition: all 0.15s ease;
white-space: nowrap;
&:hover {
background: $accent-soft;
border-color: $accent;
}
}
/* ─── Stat grid ──────────────────────────────────────────────────────── */
.rh-stats {
display: grid;
grid-template-columns: repeat(4, minmax(0, 1fr));
gap: 10px;
margin-top: 14px;
}
@media (width <= 640px) {
.rh-stats {
grid-template-columns: repeat(2, minmax(0, 1fr));
}
}
.rh-stat {
display: flex;
flex-direction: column;
gap: 3px;
padding: 9px 12px;
background: $soft;
border-radius: 10px;
&__label {
font-size: 10px;
font-weight: 600;
color: $muted;
text-transform: uppercase;
letter-spacing: 0.03em;
}
&__value {
font-size: 13.5px;
font-weight: 700;
color: $navy;
font-variant-numeric: tabular-nums;
&--positive {
color: $positive;
}
}
&--highlight {
background: $accent-soft;
.rh-stat__value {
color: $accent;
}
}
}
/* ─── Empty state ────────────────────────────────────────────────────── */
.rh-empty {
display: flex;
flex-direction: column;
align-items: center;
gap: 10px;
padding: 64px 16px;
color: $muted;
&__icon {
font-size: 30px;
opacity: 0.4;
}
&__text {
margin: 0;
font-size: 13px;
}
}
@@ -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/.
*/
import { Component, OnInit, inject } from '@angular/core';
import { ActivatedRoute, Router } from '@angular/router';
import { MatDialog } from '@angular/material/dialog';
import { Currency } from 'app/shared/models/general.model';
import { CurrencyPipe } from '@angular/common';
import { DateFormatPipe } from '../../../../pipes/date-format.pipe';
import { FaIconComponent } from '@fortawesome/angular-fontawesome';
import { STANDALONE_SHARED_IMPORTS } from 'app/standalone-shared.module';
import { ProductsService } from 'app/products/products.service';
import { TermRateChartDialogComponent } from './term-rate-chart-dialog/term-rate-chart-dialog.component';
/**
* Every term of one FD, oldest first, as a vertical timeline -- one card per renewal cycle. Its own tab
* rather than a card on General, since a long-lived FD can carry many terms and each one's full detail
* (dates, rate, principal, interest, and its own rate chart) needs real room to read comfortably.
*/
@Component({
selector: 'mifosx-renewal-history-tab',
templateUrl: './renewal-history-tab.component.html',
styleUrls: ['./renewal-history-tab.component.scss'],
imports: [
...STANDALONE_SHARED_IMPORTS,
FaIconComponent,
CurrencyPipe,
DateFormatPipe
]
})
export class RenewalHistoryTabComponent implements OnInit {
private route = inject(ActivatedRoute);
private router = inject(Router);
private dialog = inject(MatDialog);
private productsService = inject(ProductsService);
fixedDepositsAccountData: any;
currency: Currency;
/**
* The bank's currently published senior-citizen premium per tenure band, for this FD's type. Fetched once
* from the same CBS Base Interest Rates screen staff maintain, so a term's rate-chart popup can show the
* premium alongside the standard rate -- neither the account's own chart snapshot nor the archived history
* carries a senior-citizen column, since that concept only exists on the base rate master.
*/
private activeBaseSlabs: any[] = [];
constructor() {
this.route.parent.data.subscribe((data: { fixedDepositsAccountData: any }) => {
this.fixedDepositsAccountData = data.fixedDepositsAccountData;
this.currency = this.fixedDepositsAccountData.currency;
});
}
ngOnInit(): void {
const fdType = this.fixedDepositsAccountData?.fdType;
if (!fdType) {
return;
}
this.productsService.getFdBaseInterestRates(fdType).subscribe({
next: (rates: any[]) => {
const active = (rates || []).find((r: any) => r.isActive);
this.activeBaseSlabs = active?.slabs ?? [];
},
error: () => {}
});
}
/**
* The published senior-citizen rate for a tenure band, matched by period type and range against the active
* base rate card. Returns null when nothing matches (no published base version for this FD type, or this
* particular band isn't on it, or the matching band simply carries no premium) -- displayed as a blank
* rather than a misleading zero.
*/
private seniorCitizenRateFor(periodTypeEnum: number, fromPeriod: number, toPeriod: number | null): number | null {
const match = this.activeBaseSlabs.find(
(s) =>
s.periodTypeEnum === periodTypeEnum &&
s.fromPeriod === fromPeriod &&
(s.toPeriod ?? null) === (toPeriod ?? null)
);
return match?.seniorCitizenRate > 0 ? match.seniorCitizenRate : null;
}
/**
* A reinvested FD always keeps the same client/group/center as the account it came from, so the
* counterpart account's view page sits at the same route -- only the account-id segment differs.
*/
private siblingAccountLink(targetAccountId: number): string | null {
const segments = this.router.url.split('?')[0].split('/').filter(Boolean);
const idx = segments.indexOf('fixed-deposits-accounts');
if (idx === -1) return null;
return '/' + segments.slice(0, idx + 1).join('/') + '/' + targetAccountId + '/general';
}
siblingLink(targetAccountId: number): string | null {
return this.siblingAccountLink(targetAccountId);
}
/**
* Every term of this FD, oldest first, as resolved by the backend. Keys are lower-cased because they
* arrive straight from the SQL result-set column labels.
*/
get renewalHistory(): any[] {
return this.fixedDepositsAccountData?.renewalHistory ?? [];
}
get fdNumber(): string {
return this.fixedDepositsAccountData?.fdAccountNumber ?? this.fixedDepositsAccountData?.accountNo;
}
/** The very first term's opening principal -- what the customer originally deposited. */
get originalPrincipal(): number | null {
return this.renewalHistory[0]?.depositamount ?? null;
}
/** The latest term's settled or projected value -- what the FD is worth right now. */
get currentValue(): number | null {
const latest = this.renewalHistory[this.renewalHistory.length - 1];
return latest ? this.maturityFor(latest) : null;
}
/**
* Total interest earned across every term, settled terms plus the running term's interest-to-date. Null
* only when there is nothing to sum, so the summary strip can tell "no data yet" apart from "zero".
*/
get lifetimeInterestEarned(): number | null {
if (!this.renewalHistory.length) {
return null;
}
let total = 0;
for (const term of this.renewalHistory) {
if (term.interestearned != null) {
total += Number(term.interestearned);
} else {
const projected = this.maturityFor(term);
if (projected != null && term.depositamount != null) {
total += Number(projected) - Number(term.depositamount);
}
}
}
return total;
}
/**
* A term's settled maturity amount, or its projection while it is still running. Resolved here rather
* than inline so the choice isn't left to operator precedence between `??` and the currency pipe.
*/
maturityFor(term: any): number | null {
return term?.maturedamount ?? term?.projectedmaturityamount ?? null;
}
/** periodTypeEnum values as archived -- same encoding as SavingsPeriodFrequencyType (DAYS=0 ... YEARS=3). */
private static readonly PERIOD_TYPE_LABELS: Record<number, string> = {
0: 'Days',
1: 'Weeks',
2: 'Months',
3: 'Years'
};
/** "10 Years" rather than a bare number, using the same period-type encoding the archived charts use. */
tenureLabel(term: any): string {
if (term?.tenure == null) {
return '';
}
const unit = RenewalHistoryTabComponent.PERIOD_TYPE_LABELS[term.tenurefrequencyenum] ?? '';
return unit ? `${term.tenure} ${unit}` : `${term.tenure}`;
}
/** How the renewal happened, for display as a small tag -- null when the term hasn't renewed (yet). */
renewalTypeLabel(term: any): string | null {
if (term?.renewaltype === 'AUTO') return 'Auto-Renewed';
if (term?.renewaltype === 'MANUAL') return 'Renewed by Staff';
return null;
}
renewalTypeIcon(term: any): string {
return term?.renewaltype === 'MANUAL' ? 'user-cog' : 'robot';
}
/**
* The rate-by-tenure chart for one term of this FD's history, normalised to one shape regardless of source.
*
* A term's chart is only archived once it closes via an in-place renewal, so the term still running has no
* archived rows -- for that one term (recognisable as the one matching this account's own id), the
* account's own live chart is shown instead, since it is the same data. The two sources use different key
* casing and represent the period type differently (a raw enum int here, an expanded {value} object on the
* live chart), which this maps into one consistent shape the dialog can render without knowing which source
* it came from.
*/
private chartSlabsFor(term: any): any[] {
const raw = term?.chartSlabs?.length
? term.chartSlabs
: term?.accountid === this.fixedDepositsAccountData?.id
? (this.fixedDepositsAccountData?.accountChart?.chartSlabs ?? [])
: [];
return raw.map((slab: any) => {
const periodTypeEnum = slab.periodType?.id ?? slab.periodtypeenum;
const fromPeriod = slab.fromperiod ?? slab.fromPeriod;
const toPeriod = slab.toperiod ?? slab.toPeriod;
// The archived/live row's own senior_citizen_rate is the true figure as published when this term
// was actually priced -- preferred whenever present. The live lookup against today's base rate
// card is only a fallback, for the currently-running term (not archived yet) or an older archive
// row written before that column existed -- flagged as such so the dialog can say so.
const persistedSeniorRate = slab.seniorcitizenrate ?? slab.seniorCitizenRate ?? null;
return {
description: slab.description,
fromPeriod,
toPeriod,
periodTypeLabel: slab.periodType?.value ?? RenewalHistoryTabComponent.PERIOD_TYPE_LABELS[periodTypeEnum] ?? '',
annualInterestRate: slab.annualinterestrate ?? slab.annualInterestRate,
seniorCitizenRate:
persistedSeniorRate ?? this.seniorCitizenRateFor(periodTypeEnum, fromPeriod, toPeriod ?? null),
seniorCitizenRateIsLive: persistedSeniorRate == null
};
});
}
/** Opens the full rate chart for one term in a popup, rather than pushing every later term down the page. */
openRateChart(term: any): void {
this.dialog.open(TermRateChartDialogComponent, {
width: '560px',
data: {
term,
slabs: this.chartSlabsFor(term),
isSeniorCitizen: !!this.fixedDepositsAccountData?.isSeniorCitizen
}
});
}
}
@@ -0,0 +1,77 @@
<!--
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="trc-dialog">
<h2 mat-dialog-title class="trc-header">
<span class="trc-header__icon"><fa-icon icon="table"></fa-icon></span>
<span class="trc-header__text">
<span class="trc-header__title">{{ termLabel }} — Rate Chart</span>
<span class="trc-header__sub"
>{{ term.activatedondate | dateFormat }} → {{ term.maturitydate | dateFormat }}</span
>
</span>
</h2>
<div mat-dialog-content class="trc-content">
@if (slabs.length) {
<table class="trc-table">
<thead>
<tr>
<th>Description</th>
<th>Tenure</th>
<th class="trc-table__rate-col">Rate</th>
<th class="trc-table__rate-col">
<fa-icon icon="user-shield" class="trc-table__sc-ico"></fa-icon>
Senior Citizen
</th>
</tr>
</thead>
<tbody>
@for (slab of slabs; track $index) {
<tr [class.trc-table__row--applied]="isAppliedSlab(slab) || isAppliedSeniorSlab(slab)">
<td>{{ slab.description }}</td>
<td>{{ slab.fromPeriod }} – {{ slab.toPeriod || '∞' }} {{ slab.periodTypeLabel }}</td>
<td class="trc-table__rate-col">
{{ slab.annualInterestRate }}%
@if (isAppliedSlab(slab)) {
<span class="trc-applied-badge">Applied</span>
}
</td>
<td class="trc-table__rate-col trc-table__sc-col">
{{
slab.seniorCitizenRate !== null && slab.seniorCitizenRate !== undefined
? slab.seniorCitizenRate + '%'
: '—'
}}
@if (isAppliedSeniorSlab(slab)) {
<span class="trc-applied-badge">Applied</span>
}
</td>
</tr>
}
</tbody>
</table>
@if (hasLiveSeniorRate) {
<p class="trc-note">
<fa-icon icon="info-circle"></fa-icon>
Senior citizen rate for this term wasn't recorded at the time; the figure shown is today's published rate, for
reference.
</p>
}
} @else {
<div class="trc-empty">
<fa-icon icon="info-circle" class="trc-empty__icon"></fa-icon>
<p>No rate chart was archived for this term.</p>
</div>
}
</div>
<div mat-dialog-actions align="end" class="trc-actions">
<button mat-button mat-dialog-close>{{ 'labels.buttons.Close' | translate }}</button>
</div>
</div>
@@ -0,0 +1,159 @@
/**
* 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/.
*/
$navy: #0d1b2a;
$slate: #475569;
$muted: #64748b;
$border: #e2e8f0;
$soft: #f8fafc;
$positive: #047857;
$positive-soft: #ecfdf5;
$accent: #b8860b;
.trc-dialog {
min-width: 420px;
max-width: 540px;
}
.trc-header {
display: flex;
align-items: center;
gap: 12px;
margin: 0 0 4px;
&__icon {
flex: none;
width: 38px;
height: 38px;
display: inline-flex;
align-items: center;
justify-content: center;
border-radius: 10px;
background: linear-gradient(135deg, #3b82f6 0%, #1e40af 100%);
color: #fff;
font-size: 15px;
}
&__text {
display: flex;
flex-direction: column;
min-width: 0;
}
&__title {
font-size: 15px;
font-weight: 700;
color: $navy;
line-height: 1.3;
}
&__sub {
font-size: 12px;
color: $muted;
font-weight: 500;
}
}
.trc-content {
padding-top: 4px;
}
.trc-table {
width: 100%;
border-collapse: collapse;
font-size: 13px;
th {
padding: 8px 10px;
background: $soft;
color: $muted;
font-weight: 600;
text-align: left;
border-bottom: 1px solid $border;
}
td {
padding: 9px 10px;
color: $slate;
border-bottom: 1px solid $border;
font-variant-numeric: tabular-nums;
}
&__rate-col {
text-align: right;
}
&__row--applied {
background: $positive-soft;
td {
color: $positive;
font-weight: 700;
}
}
&__sc-ico {
color: $accent;
margin-right: 4px;
}
&__sc-col {
color: $accent;
font-weight: 600;
}
}
.trc-applied-badge {
display: inline-block;
margin-left: 6px;
padding: 1px 7px;
border-radius: 10px;
background: $positive;
color: #fff;
font-size: 9.5px;
font-weight: 700;
letter-spacing: 0.03em;
text-transform: uppercase;
vertical-align: middle;
}
.trc-note {
display: flex;
align-items: flex-start;
gap: 6px;
margin: 10px 2px 0;
color: $muted;
font-size: 11px;
font-style: italic;
line-height: 1.4;
}
.trc-empty {
display: flex;
flex-direction: column;
align-items: center;
gap: 8px;
padding: 32px 8px;
color: $muted;
text-align: center;
&__icon {
font-size: 22px;
opacity: 0.5;
}
p {
margin: 0;
font-size: 13px;
}
}
.trc-actions {
padding-top: 6px;
}
@@ -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 {
MAT_DIALOG_DATA,
MatDialogRef,
MatDialogTitle,
MatDialogContent,
MatDialogActions,
MatDialogClose
} 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';
/**
* The full rate-by-tenure chart one term of an FD was priced on, in a popup rather than inline in the
* timeline: a chart can run to several tenure bands, and showing it in place would push every later term
* down the page every time one is opened.
*/
@Component({
selector: 'mifosx-term-rate-chart-dialog',
templateUrl: './term-rate-chart-dialog.component.html',
styleUrls: ['./term-rate-chart-dialog.component.scss'],
imports: [
...STANDALONE_SHARED_IMPORTS,
MatDialogTitle,
MatDialogContent,
MatDialogActions,
MatDialogClose,
MatButton,
FaIconComponent
]
})
export class TermRateChartDialogComponent {
dialogRef = inject<MatDialogRef<TermRateChartDialogComponent>>(MatDialogRef);
data = inject(MAT_DIALOG_DATA);
get term(): any {
return this.data.term;
}
get slabs(): any[] {
return this.data.slabs ?? [];
}
get termLabel(): string {
return this.term?.renewalcycleno === 0 ? 'Original Term' : 'Renewal ' + this.term?.renewalcycleno;
}
/** Whether the DEPOSITOR (not just the tenure band) was a senior citizen for this term. */
get isSeniorCitizen(): boolean {
return !!this.data.isSeniorCitizen;
}
/**
* True when at least one senior-citizen figure shown is a live lookup against today's base rate card rather than the
* true rate persisted for this specific term -- only possible for the currently-running term (not archived yet) or an
* older archive row that predates the persisted column. Drives whether the disclaimer note is shown at all.
*/
get hasLiveSeniorRate(): boolean {
return this.slabs.some((s) => s.seniorCitizenRateIsLive && s.seniorCitizenRate != null);
}
/**
* True for the STANDARD-rate cell that was actually applied to this term. Never true for a senior-citizen
* term, since that term was priced off the senior column instead -- see {@link isAppliedSeniorSlab}.
* Compared as numbers since one side arrives as a string from the currency-agnostic archive row.
*/
isAppliedSlab(slab: any): boolean {
return (
!this.isSeniorCitizen &&
this.term?.interestrate != null &&
Number(slab.annualInterestRate) === Number(this.term.interestrate)
);
}
/** True for the SENIOR-CITIZEN cell that was actually applied, for a term held by a senior-citizen depositor. */
isAppliedSeniorSlab(slab: any): boolean {
return (
this.isSeniorCitizen &&
slab.seniorCitizenRate != null &&
this.term?.interestrate != null &&
Number(slab.seniorCitizenRate) === Number(this.term.interestrate)
);
}
}
@@ -36,7 +36,11 @@
[ngClass]="savingsTransactionColor(transaction)"
(click)="showTransactions(transaction)"
>
{{ transaction.id }}
@if (transaction.isRenewalEvent) {
<fa-icon icon="sync-alt" class="txn-tab__renewal-ico"></fa-icon>
} @else {
{{ transaction.id }}
}
</td>
</ng-container>
@@ -72,7 +76,13 @@
[ngClass]="savingsTransactionColor(transaction)"
(click)="showTransactions(transaction)"
>
{{ isDebit(transaction.transactionType) ? (transaction.amount | formatNumber) : 'N/A' }}
{{
transaction.isRenewalEvent
? '—'
: isDebit(transaction.transactionType)
? (transaction.amount | formatNumber)
: 'N/A'
}}
</td>
</ng-container>
@@ -84,7 +94,13 @@
[ngClass]="savingsTransactionColor(transaction)"
(click)="showTransactions(transaction)"
>
{{ !isDebit(transaction.transactionType) ? (transaction.amount | formatNumber) : 'N/A' }}
{{
transaction.isRenewalEvent
? '—'
: !isDebit(transaction.transactionType)
? (transaction.amount | formatNumber)
: 'N/A'
}}
</td>
</ng-container>
@@ -96,36 +112,40 @@
[ngClass]="savingsTransactionColor(transaction)"
(click)="showTransactions(transaction)"
>
{{ transaction.runningBalance | formatNumber }}
{{ transaction.isRenewalEvent ? '—' : (transaction.runningBalance | formatNumber) }}
</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 transaction; let idx = index">
<button mat-icon-button [matMenuTriggerFor]="transactionMenu" aria-label="" class="action-button">
<mat-icon>more_vert</mat-icon>
</button>
<mat-menu #transactionMenu="matMenu">
<button mat-menu-item (click)="showTransactions(transaction)">
<mat-icon><fa-icon icon="eye" size="sm"></fa-icon></mat-icon>
<span>{{ 'labels.text.View Transaction' | translate }}</span>
@if (transaction.isRenewalEvent) {
<span class="txn-tab__renewal-tag">No ledger impact</span>
} @else {
<button mat-icon-button [matMenuTriggerFor]="transactionMenu" aria-label="" class="action-button">
<mat-icon>more_vert</mat-icon>
</button>
@if (!isFirstTransaction(idx)) {
<button mat-menu-item (click)="undoTransaction(transaction)">
<mat-icon><fa-icon icon="undo" size="sm"></fa-icon></mat-icon>
<span>{{ 'tooltips.Undo Transaction' | translate }}</span>
<mat-menu #transactionMenu="matMenu">
<button mat-menu-item (click)="showTransactions(transaction)">
<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)="routeEdit($event)"
[routerLink]="['/', 'journal-entry', 'view', 'S' + transaction.id]"
>
<mat-icon><fa-icon icon="table-cells" size="sm"></fa-icon></mat-icon>
<span>{{ 'tooltips.View Journal Entries' | translate }}</span>
</button>
</mat-menu>
@if (!isFirstTransaction(idx)) {
<button mat-menu-item (click)="undoTransaction(transaction)">
<mat-icon><fa-icon icon="undo" size="sm"></fa-icon></mat-icon>
<span>{{ 'tooltips.Undo Transaction' | translate }}</span>
</button>
}
<button
mat-menu-item
(click)="routeEdit($event)"
[routerLink]="['/', 'journal-entry', 'view', 'S' + transaction.id]"
>
<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>
@@ -97,4 +97,32 @@ table {
color: $muted;
font-style: italic;
}
.mat-mdc-cell.renewal-marker {
background: #eff6ff;
color: $blue;
font-style: italic;
cursor: default;
}
.mat-mdc-row:has(.renewal-marker):hover {
background: #eff6ff;
cursor: default;
}
}
.txn-tab__renewal-ico {
color: $blue;
}
.txn-tab__renewal-tag {
display: inline-block;
padding: 2px 8px;
border-radius: 10px;
background: #dbeafe;
color: $blue;
font-size: 10.5px;
font-weight: 700;
text-transform: uppercase;
letter-spacing: 0.04em;
}
@@ -113,13 +113,71 @@ export class TransactionsTabComponent implements OnInit {
*/
constructor() {
this.route.parent.data.subscribe((data: { fixedDepositsAccountData: any }) => {
this.transactionsData = data.fixedDepositsAccountData.transactions;
this.transactionsData = this.withRenewalMarkers(
data.fixedDepositsAccountData.transactions,
data.fixedDepositsAccountData.renewalHistory
);
this.clientId = this.route.parent.snapshot.params['clientId'];
this.accountId = this.route.parent.snapshot.params['fixedDepositAccountId'];
this.status = data.fixedDepositsAccountData.status.value;
});
}
/**
* An in-place renewal moves no money -- the matured balance simply becomes the new term's opening
* principal -- so it posts no withdrawal, deposit, or journal entry (see
* DepositAccountDomainServiceJpa#handleFDAccountMaturityClosure). Without something here, a renewal
* would be invisible in this list even though the account's rate and term just changed. These rows
* are synthesized purely for display from the renewal history already loaded for the Renewal History
* tab -- never written anywhere, never matched against a real transaction id -- so undo, journal-entry
* lookup, and every real transaction's running balance stay exactly as they were.
*/
private withRenewalMarkers(transactions: any[], renewalHistory: any[]): any[] {
const markers: any[] = (renewalHistory ?? [])
.filter((term: any) => term.renewalcycleno > 0)
.map((term: any) => {
const marker: any = {
isRenewalEvent: true,
id: null,
date: term.activatedondate,
amount: null,
runningBalance: null,
reversed: false,
transfer: null,
renewalCycleNo: term.renewalcycleno,
transactionType: {
value: term.renewaltype === 'MANUAL' ? 'FD Renewed (Staff)' : 'FD Auto-Renewed',
accrual: false,
withdrawal: false,
feeDeduction: false,
overdraftInterest: false,
withholdTax: false
}
};
return marker;
});
return [
...(transactions ?? []),
...markers
].sort((a, b) => this.toEpoch(b.date) - this.toEpoch(a.date));
}
/** Same [yyyy, MM, dd]-or-ISO-string shape DateFormatPipe already handles, resolved to a sortable epoch. */
private toEpoch(value: any): number {
if (!value) {
return 0;
}
if (Array.isArray(value)) {
const [
year,
month,
day
] = value;
return new Date(year, (month ?? 1) - 1, day ?? 1).getTime();
}
return new Date(value).getTime();
}
ngOnInit() {
this.hideAccrualsParam = new UntypedFormControl(false);
this.hideReversedParam = new UntypedFormControl(false);
@@ -161,6 +219,9 @@ export class TransactionsTabComponent implements OnInit {
* @param {any} transactionsData Transactions Data
*/
showTransactions(transactionsData: any) {
if (transactionsData.isRenewalEvent) {
return;
}
if (transactionsData.transfer) {
this.router.navigate([`account-transfers/account-transfers/${transactionsData.transfer.id}`], {
relativeTo: this.route
@@ -191,7 +252,10 @@ export class TransactionsTabComponent implements OnInit {
this.dataSource.sort = this.sort;
}
savingsTransactionColor(transaction: SavingsAccountTransaction): string {
savingsTransactionColor(transaction: any): string {
if (transaction.isRenewalEvent) {
return 'renewal-marker';
}
if (transaction.reversed) {
return 'strike';
} else if (transaction.transfer) {
@@ -10,6 +10,7 @@
<mat-card>
<form [formGroup]="approveFixedDepositsAccountForm" (ngSubmit)="submit()">
<mat-card-content>
<p class="mat-body-2">This approves and activates the account in one step -- it goes straight to Active.</p>
<div class="layout-column">
<mat-form-field (click)="approvedOnDatePicker.open()">
<mat-label>{{ 'labels.inputs.Approved On Date' | translate }}</mat-label>
@@ -81,8 +81,9 @@ export class ApproveFixedDepositsAccountComponent implements OnInit {
}
/**
* Submits the form and approves the fixed deposit account,
* if successful redirects to the fixed deposit account.
* Submits the form, approves the fixed deposit account, then immediately activates it on the
* same date -- Approve and Activate are still two separate Fineract commands underneath (each
* shows up in the audit trail), but the officer only sees and clicks through one step.
*/
submit() {
const approveFixedDepositsAccountFormData = this.approveFixedDepositsAccountForm.value;
@@ -98,7 +99,16 @@ export class ApproveFixedDepositsAccountComponent implements OnInit {
locale
};
this.fixedDepositsService.executeFixedDepositsAccountCommand(this.accountId, 'approve', data).subscribe(() => {
this.router.navigate(['../../'], { relativeTo: this.route });
const activationData = {
activatedOnDate: approveFixedDepositsAccountFormData.approvedOnDate,
dateFormat,
locale
};
this.fixedDepositsService
.executeFixedDepositsAccountCommand(this.accountId, 'activate', activationData)
.subscribe(() => {
this.router.navigate(['../../'], { relativeTo: this.route });
});
});
}
}
@@ -31,6 +31,7 @@ import { FixedDepositsAccountActionsResolver } from './common-resolvers/fixed-de
import { FixedDepositsAccountTemplateResolver } from './common-resolvers/fixed-deposit-account-template.resolver';
import { FixedDepositsAccountAndTemplateResolver } from './common-resolvers/fixed-deposit-account-and-template.resolver';
import { GeneralTabComponent } from './fixed-deposit-account-view/general-tab/general-tab.component';
import { RenewalHistoryTabComponent } from './fixed-deposit-account-view/renewal-history-tab/renewal-history-tab.component';
import { AccountNomineesTabComponent } from 'app/shared/account-nominees-tab/account-nominees-tab.component';
const routes: Routes = [
@@ -80,6 +81,15 @@ const routes: Routes = [
routeParamBreadcrumb: false
}
},
{
path: 'renewal-history',
component: RenewalHistoryTabComponent,
data: {
title: 'Fixed Deposit Account Renewal History',
breadcrumb: 'Renewal History',
routeParamBreadcrumb: false
}
},
{
path: 'transactions',
component: TransactionsTabComponent,
@@ -87,6 +87,17 @@ export class FixedDepositsService {
return this.http.get(`/fixeddepositaccounts/${accountId}/template`, { params: httpParams });
}
/**
* Template for the Payment action. Same shape as the closure template, but its `onAccountClosureOptions` also
* include Partial Re-Invest, which the closure commands exclude because only Payment implements it.
* @param {string} accountId Fixed Deposits Account Id
* @returns {Observable<any>}
*/
getFixedDepositsAccountPaymentTemplate(accountId: string): Observable<any> {
const httpParams = new HttpParams().set('command', 'payment');
return this.http.get(`/fixeddepositaccounts/${accountId}/template`, { params: httpParams });
}
/**
* @param {string} accountId Fixed Deposits Account Id
* @returns {Observable<any>}
@@ -1,543 +0,0 @@
<!--
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="pm-container">
<div class="pm-header">
<button mat-stroked-button [routerLink]="['../']" class="pm-back">
<i class="fas fa-arrow-left"></i> Deposits
</button>
<h2 class="pm-header__title">Party Master Entry</h2>
</div>
<div class="pm-card">
<form [formGroup]="partyForm">
<mat-tab-group class="pm-tabs" [(selectedIndex)]="selectedTabIndex" animationDuration="200ms">
<mat-tab label="Main Detail">
<div class="pm-tab-content">
<div class="pm-section">
<div class="pm-section__header">
<i class="fas fa-id-card"></i>
<h3>Identity</h3>
</div>
<div class="pm-grid">
<mat-form-field appearance="outline" class="pm-field">
<mat-label>Client ID</mat-label>
<input matInput required formControlName="clientId" />
</mat-form-field>
<mat-form-field appearance="outline" class="pm-field">
<mat-label>Sub Type</mat-label>
<mat-select required formControlName="subType">
@for (type of subTypes; track type) {
<mat-option [value]="type">{{ type }}</mat-option>
}
</mat-select>
</mat-form-field>
<mat-form-field appearance="outline" class="pm-field">
<mat-label>Agent ID</mat-label>
<input matInput required formControlName="agentId" />
</mat-form-field>
@if (linkedSavingsAccountId) {
<mat-form-field appearance="outline" class="pm-field">
<mat-label>Savings Account</mat-label>
<input matInput [value]="'Linked (id ' + linkedSavingsAccountId + ')'" disabled />
</mat-form-field>
} @else {
<mat-form-field appearance="outline" class="pm-field">
<mat-label>Savings Account No (if any)</mat-label>
<input matInput formControlName="savingsAccountNo" />
</mat-form-field>
}
<mat-form-field appearance="outline" class="pm-field">
<mat-label>Party Code</mat-label>
<input matInput formControlName="partyCode" placeholder="Auto-generated" />
</mat-form-field>
<mat-form-field appearance="outline" class="pm-field">
<mat-label>Last A/c</mat-label>
<input matInput formControlName="lastAccount" placeholder="—" />
</mat-form-field>
<mat-form-field appearance="outline" class="pm-field">
<mat-label>Nominal</mat-label>
<mat-select required formControlName="nominal">
<mat-option value="Yes">Yes</mat-option>
<mat-option value="No">No</mat-option>
</mat-select>
</mat-form-field>
<mat-form-field appearance="outline" class="pm-field pm-field--wide">
<mat-label>Party Name</mat-label>
<input matInput required formControlName="partyName" />
</mat-form-field>
<mat-form-field appearance="outline" class="pm-field pm-field--wide">
<mat-label>Party Name 1</mat-label>
<input matInput formControlName="partyName1" />
</mat-form-field>
</div>
</div>
<div class="pm-section">
<div class="pm-section__header">
<i class="fas fa-map-marker-alt"></i>
<h3>Address &amp; Contact</h3>
</div>
<div class="pm-grid">
<mat-form-field appearance="outline" class="pm-field pm-field--wide">
<mat-label>Address 1</mat-label>
<input matInput required formControlName="address1" />
</mat-form-field>
<mat-form-field appearance="outline" class="pm-field pm-field--wide">
<mat-label>Address 2</mat-label>
<input matInput formControlName="address2" />
</mat-form-field>
<mat-form-field appearance="outline" class="pm-field pm-field--wide">
<mat-label>Address 3</mat-label>
<input matInput formControlName="address3" />
</mat-form-field>
<mat-form-field appearance="outline" class="pm-field">
<mat-label>City</mat-label>
<input matInput required formControlName="city" />
</mat-form-field>
<mat-form-field appearance="outline" class="pm-field">
<mat-label>Pincode</mat-label>
<input matInput required formControlName="pincode" />
</mat-form-field>
<mat-form-field appearance="outline" class="pm-field">
<mat-label>Gender</mat-label>
<mat-select formControlName="gender">
<mat-option value="Male">Male</mat-option>
<mat-option value="Female">Female</mat-option>
<mat-option value="Other">Other</mat-option>
</mat-select>
</mat-form-field>
<mat-form-field appearance="outline" class="pm-field">
<mat-label>Telephone</mat-label>
<input matInput formControlName="telephone" />
</mat-form-field>
<mat-form-field appearance="outline" class="pm-field">
<mat-label>Mobile No (SMS)</mat-label>
<input matInput required formControlName="mobileNoSms" />
</mat-form-field>
</div>
</div>
<div class="pm-section">
<div class="pm-section__header">
<i class="fas fa-shield-alt"></i>
<h3>KYC</h3>
</div>
<div class="pm-grid">
<mat-form-field appearance="outline" class="pm-field">
<mat-label>Aadhar No</mat-label>
<input matInput formControlName="aadharNo" />
</mat-form-field>
<mat-form-field appearance="outline" class="pm-field">
<mat-label>PAN Card No</mat-label>
<input matInput formControlName="panCardNo" />
</mat-form-field>
<mat-form-field appearance="outline" class="pm-field">
<mat-label>CKYC ID</mat-label>
<input matInput formControlName="ckycId" />
</mat-form-field>
<mat-form-field appearance="outline" class="pm-field">
<mat-label>Operation</mat-label>
<mat-select required formControlName="operation">
@for (op of operations; track op) {
<mat-option [value]="op">{{ op }}</mat-option>
}
</mat-select>
</mat-form-field>
<mat-form-field appearance="outline" class="pm-field">
<mat-label>Birth Date</mat-label>
<input matInput [matDatepicker]="birthPicker" formControlName="birthDate" />
<mat-datepicker-toggle matIconSuffix [for]="birthPicker"></mat-datepicker-toggle>
<mat-datepicker #birthPicker></mat-datepicker>
</mat-form-field>
<mat-form-field appearance="outline" class="pm-field">
<mat-label>Occupation</mat-label>
<mat-select formControlName="occupation">
@for (occ of occupations; track occ) {
<mat-option [value]="occ">{{ occ }}</mat-option>
}
</mat-select>
</mat-form-field>
<mat-form-field appearance="outline" class="pm-field">
<mat-label>Risk</mat-label>
<mat-select formControlName="risk">
@for (r of riskLevels; track r) {
<mat-option [value]="r">{{ r }}</mat-option>
}
</mat-select>
</mat-form-field>
</div>
</div>
<div class="pm-section">
<div class="pm-section__header">
<i class="fas fa-user-friends"></i>
<h3>Minor / Guardian</h3>
</div>
<div class="pm-grid">
<mat-form-field appearance="outline" class="pm-field">
<mat-label>Adult / Minor</mat-label>
<mat-select required formControlName="adultMinor">
@for (opt of adultMinorOptions; track opt) {
<mat-option [value]="opt">{{ opt }}</mat-option>
}
</mat-select>
</mat-form-field>
<mat-form-field appearance="outline" class="pm-field">
<mat-label>Minor Date</mat-label>
<input matInput [matDatepicker]="minorPicker" formControlName="minorDate" />
<mat-datepicker-toggle matIconSuffix [for]="minorPicker"></mat-datepicker-toggle>
<mat-datepicker #minorPicker></mat-datepicker>
</mat-form-field>
<mat-form-field appearance="outline" class="pm-field">
<mat-label>Guardian Client ID</mat-label>
<input matInput formControlName="guardianClientId" />
</mat-form-field>
<mat-form-field appearance="outline" class="pm-field">
<mat-label>Min. A/c Opt</mat-label>
<input matInput formControlName="minAccountOpt" />
</mat-form-field>
<mat-form-field appearance="outline" class="pm-field">
<mat-label>Relation</mat-label>
<input matInput formControlName="relation" />
</mat-form-field>
</div>
</div>
<div class="pm-section">
<div class="pm-section__header">
<i class="fas fa-calendar-day"></i>
<h3>Scheme Terms</h3>
</div>
<div class="pm-grid">
<mat-form-field appearance="outline" class="pm-field">
<mat-label>Opening Date</mat-label>
<input matInput required [matDatepicker]="openingPicker" formControlName="openingDate" />
<mat-datepicker-toggle matIconSuffix [for]="openingPicker"></mat-datepicker-toggle>
<mat-datepicker #openingPicker></mat-datepicker>
</mat-form-field>
<mat-form-field appearance="outline" class="pm-field">
<mat-label>Month</mat-label>
<input matInput type="number" formControlName="month" />
</mat-form-field>
<mat-form-field appearance="outline" class="pm-field">
<mat-label>Maturity Date</mat-label>
<input matInput formControlName="maturityDate" placeholder="Calculated" />
</mat-form-field>
<mat-form-field appearance="outline" class="pm-field">
<mat-label>Reopen Date</mat-label>
<input matInput [matDatepicker]="reopenPicker" formControlName="reopenDate" />
<mat-datepicker-toggle matIconSuffix [for]="reopenPicker"></mat-datepicker-toggle>
<mat-datepicker #reopenPicker></mat-datepicker>
</mat-form-field>
<mat-form-field appearance="outline" class="pm-field">
<mat-label>Deposit Amt</mat-label>
<input matInput type="number" formControlName="depositAmount" />
</mat-form-field>
<mat-form-field appearance="outline" class="pm-field">
<mat-label>Daily Deposit Amt</mat-label>
<input matInput required type="number" formControlName="dailyDepositAmount" />
</mat-form-field>
<mat-form-field appearance="outline" class="pm-field">
<mat-label>Days</mat-label>
<input matInput required type="number" formControlName="days" />
</mat-form-field>
<mat-form-field appearance="outline" class="pm-field">
<mat-label>Ledger No</mat-label>
<input matInput formControlName="ledgerNo" placeholder="Auto-generated" />
</mat-form-field>
<mat-form-field appearance="outline" class="pm-field">
<mat-label>Mat. Amt</mat-label>
<input matInput formControlName="maturityAmount" placeholder="Calculated" />
</mat-form-field>
<mat-form-field appearance="outline" class="pm-field">
<mat-label>Interest Rate</mat-label>
<input matInput formControlName="interestRate" placeholder="From product" />
</mat-form-field>
<mat-form-field appearance="outline" class="pm-field">
<mat-label>Withdraw Type</mat-label>
<mat-select formControlName="withdrawType">
@for (type of withdrawTypes; track type) {
<mat-option [value]="type">{{ type }}</mat-option>
}
</mat-select>
</mat-form-field>
</div>
</div>
<div class="pm-section">
<div class="pm-section__header">
<i class="fas fa-exclamation-triangle"></i>
<h3>Status &amp; Overdue Flags</h3>
</div>
<div class="pm-grid">
<div class="pm-checkbox-field">
<mat-checkbox formControlName="od1Flag">OD1 Flag</mat-checkbox>
</div>
<div class="pm-checkbox-field">
<mat-checkbox formControlName="od2Flag">OD2 Flag</mat-checkbox>
</div>
<mat-form-field appearance="outline" class="pm-field">
<mat-label>Lien</mat-label>
<mat-select formControlName="lien">
<mat-option value="Yes">Yes</mat-option>
<mat-option value="No">No</mat-option>
</mat-select>
</mat-form-field>
<mat-form-field appearance="outline" class="pm-field">
<mat-label>Pay Date</mat-label>
<input matInput [matDatepicker]="payDatePicker" formControlName="payDate" />
<mat-datepicker-toggle matIconSuffix [for]="payDatePicker"></mat-datepicker-toggle>
<mat-datepicker #payDatePicker></mat-datepicker>
</mat-form-field>
<mat-form-field appearance="outline" class="pm-field">
<mat-label>Payment Amount</mat-label>
<input matInput type="number" formControlName="paymentAmount" />
</mat-form-field>
<mat-form-field appearance="outline" class="pm-field">
<mat-label>Current Bal</mat-label>
<input matInput formControlName="currentBalance" />
</mat-form-field>
<mat-form-field appearance="outline" class="pm-field">
<mat-label>Opening Bal</mat-label>
<input matInput type="number" formControlName="openingBalance" />
</mat-form-field>
<div class="pm-checkbox-field">
<mat-checkbox formControlName="isSmsActive">Is SMS Active</mat-checkbox>
</div>
<div class="pm-checkbox-field">
<mat-checkbox formControlName="isEmailActive">Is Email Active</mat-checkbox>
</div>
<mat-form-field appearance="outline" class="pm-field">
<mat-label>Int.Calc</mat-label>
<input matInput formControlName="interestCalc" />
</mat-form-field>
<mat-form-field appearance="outline" class="pm-field">
<mat-label>Interest Date</mat-label>
<input matInput [matDatepicker]="interestDatePicker" formControlName="interestDate" />
<mat-datepicker-toggle matIconSuffix [for]="interestDatePicker"></mat-datepicker-toggle>
<mat-datepicker #interestDatePicker></mat-datepicker>
</mat-form-field>
<mat-form-field appearance="outline" class="pm-field pm-field--wide">
<mat-label>Sp.Description</mat-label>
<input matInput formControlName="spDescription" />
</mat-form-field>
<mat-form-field appearance="outline" class="pm-field">
<mat-label>Fr'z CD</mat-label>
<mat-select formControlName="freezeCode">
@for (code of freezeCodes; track code) {
<mat-option [value]="code">{{ code }}</mat-option>
}
</mat-select>
</mat-form-field>
<mat-form-field appearance="outline" class="pm-field">
<mat-label>Adv Code</mat-label>
<mat-select formControlName="advCode">
@for (code of advCodes; track code) {
<mat-option [value]="code">{{ code }}</mat-option>
}
</mat-select>
</mat-form-field>
</div>
</div>
@if (partyForm.get('advCode').value === 'Transfer To Saving') {
<div class="pm-section">
<div class="pm-section__header">
<i class="fas fa-exchange-alt"></i>
<h3>Maturity Payout Routing</h3>
</div>
<div class="pm-grid">
<mat-form-field appearance="outline" class="pm-field">
<mat-label>Credit To Branch</mat-label>
<input matInput required formControlName="creditToBranch" />
</mat-form-field>
<mat-form-field appearance="outline" class="pm-field">
<mat-label>Credit To A/C Type</mat-label>
<input matInput required formControlName="creditToAccountType" />
</mat-form-field>
<mat-form-field appearance="outline" class="pm-field">
<mat-label>Credit To A/C</mat-label>
<input matInput required formControlName="creditToAccount" />
</mat-form-field>
</div>
</div>
}
</div>
</mat-tab>
<mat-tab label="Nominee's Detail">
<div class="pm-tab-content">
<div class="pm-section">
<div class="pm-grid">
<mat-form-field appearance="outline" class="pm-field">
<mat-label>Nominee</mat-label>
<mat-select formControlName="nomineeApplicable">
<mat-option value="Yes">Yes</mat-option>
<mat-option value="No">No</mat-option>
</mat-select>
</mat-form-field>
<mat-radio-group formControlName="nomineeSplitMode" class="pm-radio-group">
<mat-radio-button value="Percentage">Percentage</mat-radio-button>
<mat-radio-button value="Sequence">Sequence</mat-radio-button>
</mat-radio-group>
<button mat-stroked-button type="button" class="pm-add-nominee" (click)="addNominee()">
<i class="fas fa-plus"></i> Add Nominee
</button>
</div>
@if (nominees.length === 0) {
<p class="pm-empty">No nominees added yet.</p>
}
@for (nominee of nominees.controls; track nominee; let i = $index) {
<div class="pm-nominee-row" [formGroup]="$any(nominee)">
<mat-form-field appearance="outline" class="pm-field">
<mat-label>Nom.Dt</mat-label>
<input matInput required [matDatepicker]="nomDatePicker" formControlName="nominationDate" />
<mat-datepicker-toggle matIconSuffix [for]="nomDatePicker"></mat-datepicker-toggle>
<mat-datepicker #nomDatePicker></mat-datepicker>
</mat-form-field>
<mat-form-field appearance="outline" class="pm-field">
<mat-label>Name</mat-label>
<input matInput required formControlName="name" />
</mat-form-field>
<mat-form-field appearance="outline" class="pm-field">
<mat-label>Mobile No</mat-label>
<input matInput formControlName="mobileNo" />
</mat-form-field>
<mat-form-field appearance="outline" class="pm-field">
<mat-label>Birth Dt</mat-label>
<input matInput [matDatepicker]="nomBirthPicker" formControlName="birthDate" />
<mat-datepicker-toggle matIconSuffix [for]="nomBirthPicker"></mat-datepicker-toggle>
<mat-datepicker #nomBirthPicker></mat-datepicker>
</mat-form-field>
<mat-form-field appearance="outline" class="pm-field">
<mat-label>Minor</mat-label>
<mat-select required formControlName="minor">
<mat-option value="Yes">Yes</mat-option>
<mat-option value="No">No</mat-option>
</mat-select>
</mat-form-field>
<mat-form-field appearance="outline" class="pm-field">
<mat-label>Relation</mat-label>
<input matInput required formControlName="relation" />
</mat-form-field>
<mat-form-field appearance="outline" class="pm-field">
<mat-label>Gender</mat-label>
<mat-select required formControlName="gender">
<mat-option value="Male">Male</mat-option>
<mat-option value="Female">Female</mat-option>
<mat-option value="Other">Other</mat-option>
</mat-select>
</mat-form-field>
<mat-form-field appearance="outline" class="pm-field">
<mat-label>Guar. Name</mat-label>
<input matInput formControlName="guardianName" />
</mat-form-field>
<button
mat-icon-button
type="button"
class="pm-remove-nominee"
(click)="removeNominee(i)"
matTooltip="Remove nominee"
>
<i class="fas fa-trash"></i>
</button>
</div>
}
</div>
</div>
</mat-tab>
</mat-tab-group>
@if (isClosed) {
<div class="pm-callout">
<i class="fas fa-lock"></i>
<span>This account is closed. No further collections or edits are possible.</span>
</div>
}
<div class="pm-action-bar">
<button mat-raised-button type="button" (click)="exit()">Exit</button>
<button mat-stroked-button type="button" (click)="cancel()">Cancel</button>
<button mat-stroked-button type="button" (click)="retrieve()">Retrieve</button>
@if (partyId && !isClosed) {
<button
mat-stroked-button
type="button"
color="warn"
matTooltip="Balance must be zero before closing"
(click)="closeAccount()"
>
<i class="fas fa-lock" style="margin-right: 6px"></i> Close Account
</button>
}
<button
mat-raised-button
color="primary"
type="button"
[disabled]="partyForm.invalid || isClosed"
(click)="save()"
>
Save
</button>
</div>
</form>
</div>
</div>
@@ -1,164 +0,0 @@
/**
* 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;
.pm-container {
max-width: 1080px;
margin: 0 auto;
padding: 24px 16px;
}
.pm-header {
display: flex;
align-items: center;
gap: 16px;
margin-bottom: 20px;
&__title {
margin: 0;
font-size: 22px;
font-weight: 500;
}
}
.pm-back {
color: $primary;
}
.pm-card {
background: var(--md-sys-color-surface, #fff);
border-radius: 8px;
box-shadow: 0 2px 8px rgb(0 0 0 / 8%);
}
.pm-tabs {
::ng-deep .mat-mdc-tab-body-wrapper {
min-height: 300px;
}
}
.pm-tab-content {
padding: 20px 24px;
}
.pm-section {
padding: 16px 0;
border-bottom: 1px solid rgb(0 0 0 / 8%);
&:last-of-type {
border-bottom: none;
}
&__header {
display: flex;
align-items: center;
gap: 10px;
margin-bottom: 16px;
i {
color: $primary;
}
h3 {
margin: 0;
font-size: 16px;
font-weight: 600;
flex: 1;
}
}
}
.pm-grid {
display: grid;
grid-template-columns: repeat(4, 1fr);
gap: 8px 16px;
align-items: start;
}
.pm-field {
width: 100%;
&--wide {
grid-column: span 2;
}
}
.pm-checkbox-field {
display: flex;
align-items: center;
height: 56px;
}
.pm-radio-group {
display: flex;
align-items: center;
gap: 16px;
height: 56px;
}
.pm-add-nominee {
color: $primary;
height: fit-content;
align-self: center;
}
.pm-empty {
color: rgb(0 0 0 / 50%);
font-size: 14px;
margin: 0 0 8px;
}
.pm-nominee-row {
display: grid;
grid-template-columns: repeat(8, 1fr) auto;
gap: 8px 12px;
align-items: start;
padding: 10px 0;
border-top: 1px dashed rgb(0 0 0 / 10%);
&:first-of-type {
border-top: none;
}
}
.pm-remove-nominee {
color: #b91c1c;
margin-top: 8px;
}
.pm-action-bar {
display: flex;
justify-content: flex-end;
gap: 12px;
padding: 16px 24px;
border-top: 1px solid rgb(0 0 0 / 8%);
}
.pm-callout {
display: flex;
align-items: center;
gap: 10px;
margin: 0 24px;
padding: 12px 16px;
background: rgb(185 28 28 / 8%);
border: 1px solid rgb(185 28 28 / 25%);
border-radius: 6px;
color: #b91c1c;
font-size: 0.9rem;
}
@media (width <= 1000px) {
.pm-grid {
grid-template-columns: repeat(2, 1fr);
}
.pm-nominee-row {
grid-template-columns: repeat(2, 1fr);
}
}
@@ -1,527 +0,0 @@
/**
* 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/.
*/
/** Angular Imports */
import { Component, inject } from '@angular/core';
import { UntypedFormBuilder, UntypedFormGroup, UntypedFormArray, Validators } from '@angular/forms';
import { Router } from '@angular/router';
import { MatTabsModule } from '@angular/material/tabs';
import { MatCheckbox } from '@angular/material/checkbox';
import { MatRadioModule } from '@angular/material/radio';
import { STANDALONE_SHARED_IMPORTS } from 'app/standalone-shared.module';
/** Custom Services */
import { OrganizationService } from 'app/organization/organization.service';
import { SettingsService } from 'app/settings/settings.service';
import { Dates } from 'app/core/utils/dates';
/**
* Party Master Entry component.
*
* The customer's DDS account — linked to an Agent for daily collection,
* carrying its own scheme terms, KYC, two-stage overdue flags, and a
* nominee list. Modelled directly on the legacy Party Master Entry screen
* this replaces.
*
* Backed by its own DDS Party API (`/dds/parties`). The underlying account
* does not yet reuse the Recurring Deposit account engine (see Product
* Setup) — that linkage is a follow-up once the RD-account-specific fields
* this screen doesn't collect (interest/compounding periods, chart
* selection, ...) have a home. Until then the scheme/balance fields here
* are the Party's own.
*
* "Close Account" (command=close) permanently closes the Party once its
* balance is zero - the backend rejects the command otherwise. A closed
* Party can no longer be edited or receive new Party Tran collections.
*
* savingsAccountNo optionally links this Party to an existing, non-closed real Savings Account of
* the same client (resolved by account number, since that's what an officer has to hand) -
* clarified with the business (2026-08-24) as "link it if the client already has one, otherwise
* leave it". There is no unlink once linked.
*/
@Component({
selector: 'mifosx-party-master',
templateUrl: './party-master.component.html',
styleUrls: ['./party-master.component.scss'],
imports: [
...STANDALONE_SHARED_IMPORTS,
MatTabsModule,
MatCheckbox,
MatRadioModule
]
})
export class PartyMasterComponent {
private formBuilder = inject(UntypedFormBuilder);
private router = inject(Router);
private organizationService = inject(OrganizationService);
private settingsService = inject(SettingsService);
private dateUtils = inject(Dates);
partyId: number | null = null;
selectedTabIndex = 0;
partyStatus = 'ACTIVE';
linkedSavingsAccountId: number | null = null;
get isClosed(): boolean {
return this.partyStatus === 'CLOSED';
}
subTypes = [
'Daily Deposit Scheme',
'Masik Dhan Varsha Deposit'
];
operations = [
'Self',
'Guardian',
'Joint'
];
occupations = [
'Business',
'Service',
'Agriculture',
'Homemaker',
'Student',
'Retired',
'Other'
];
riskLevels = [
'Low',
'Medium',
'High'
];
adultMinorOptions = [
'Adult',
'Minor'
];
withdrawTypes = [
'Cash',
'Transfer',
'Clearing'
];
freezeCodes = [
'Normal',
'Debit Freeze',
'Credit Freeze',
'Total Freeze'
];
advCodes = [
'None',
'Transfer To Saving',
'Renew'
];
partyForm: UntypedFormGroup = this.formBuilder.group({
clientId: [
'',
Validators.required
],
subType: [
'',
Validators.required
],
agentId: [
'',
Validators.required
],
savingsAccountNo: [''],
lastAccount: [
{
value: '',
disabled: true
}
],
partyCode: [
{
value: '',
disabled: true
}
],
nominal: [
'No',
Validators.required
],
aadharNo: [''],
partyName: [
'',
Validators.required
],
partyName1: [''],
address1: [
'',
Validators.required
],
address2: [''],
address3: [''],
city: [
'',
Validators.required
],
pincode: [
'',
Validators.required
],
gender: [''],
panCardNo: [''],
telephone: [''],
mobileNoSms: [
'',
Validators.required
],
operation: [
'Self',
Validators.required
],
birthDate: [''],
occupation: [''],
risk: [''],
ckycId: [''],
adultMinor: [
'Adult',
Validators.required
],
minorDate: [''],
guardianClientId: [''],
minAccountOpt: [''],
relation: [''],
openingDate: [
'',
Validators.required
],
month: [''],
maturityDate: [
{
value: '',
disabled: true
}
],
reopenDate: [''],
depositAmount: [0],
dailyDepositAmount: [
0,
Validators.required
],
days: [
'',
Validators.required
],
ledgerNo: [
{
value: '',
disabled: true
}
],
maturityAmount: [
{
value: 0,
disabled: true
}
],
interestRate: [
{
value: 0,
disabled: true
}
],
withdrawType: [''],
od1Flag: [
{
value: false,
disabled: true
}
],
od2Flag: [
{
value: false,
disabled: true
}
],
lien: ['No'],
payDate: [''],
paymentAmount: [0],
currentBalance: [
{
value: 0,
disabled: true
}
],
openingBalance: [0],
isSmsActive: [true],
isEmailActive: [false],
interestCalc: [
{
value: 0,
disabled: true
}
],
interestDate: [''],
spDescription: [''],
freezeCode: ['Normal'],
advCode: ['None'],
creditToBranch: [''],
creditToAccountType: [''],
creditToAccount: [''],
nomineeApplicable: ['No'],
nomineeSplitMode: ['Percentage'],
nominees: this.formBuilder.array([])
});
get nominees(): UntypedFormArray {
return this.partyForm.get('nominees') as UntypedFormArray;
}
private buildNomineeGroup(): UntypedFormGroup {
return this.formBuilder.group({
nominationDate: [
'',
Validators.required
],
name: [
'',
Validators.required
],
mobileNo: [''],
birthDate: [''],
minor: [
'No',
Validators.required
],
relation: [
'',
Validators.required
],
gender: [
'',
Validators.required
],
guardianName: ['']
});
}
addNominee(): void {
this.nominees.push(this.buildNomineeGroup());
}
removeNominee(index: number): void {
this.nominees.removeAt(index);
}
retrieve(): void {
const clientId = this.partyForm.get('clientId')?.value;
if (!clientId) {
return;
}
this.organizationService.getParties().subscribe((parties: any[]) => {
const match = (parties || []).find((party) => String(party.clientId) === String(clientId));
if (match) {
this.patchFormFromParty(match);
}
});
}
cancel(): void {
this.partyId = null;
this.partyStatus = 'ACTIVE';
this.linkedSavingsAccountId = null;
this.partyForm.reset();
this.partyForm.patchValue({
nominal: 'No',
operation: 'Self',
adultMinor: 'Adult',
lien: 'No',
isSmsActive: true,
isEmailActive: false,
freezeCode: 'Normal',
advCode: 'None',
nomineeApplicable: 'No',
nomineeSplitMode: 'Percentage',
currentBalance: 0,
maturityAmount: 0,
interestRate: 0
});
this.nominees.clear();
}
exit(): void {
this.router.navigate(['/accounts/savings']);
}
save(): void {
const raw = this.partyForm.getRawValue();
const dateFormat = this.settingsService.dateFormat;
const locale = this.settingsService.language.code;
const asDate = (value: any) => (value ? this.dateUtils.formatDate(value, dateFormat) : undefined);
const payload: any = {
clientId: Number(raw.clientId),
agentId: Number(raw.agentId),
savingsAccountNo: this.linkedSavingsAccountId ? undefined : raw.savingsAccountNo || undefined,
subType: raw.subType,
lastAccount: raw.lastAccount || undefined,
nominal: raw.nominal === 'Yes',
aadharNo: raw.aadharNo || undefined,
partyName: raw.partyName,
partyName1: raw.partyName1 || undefined,
address1: raw.address1,
address2: raw.address2 || undefined,
address3: raw.address3 || undefined,
city: raw.city,
pincode: raw.pincode,
gender: raw.gender || undefined,
panCardNo: raw.panCardNo || undefined,
telephone: raw.telephone || undefined,
mobileNoSms: raw.mobileNoSms,
operation: raw.operation,
birthDate: asDate(raw.birthDate),
occupation: raw.occupation || undefined,
risk: raw.risk || undefined,
ckycId: raw.ckycId || undefined,
adultMinor: raw.adultMinor,
minorDate: asDate(raw.minorDate),
guardianClientId: raw.guardianClientId ? Number(raw.guardianClientId) : undefined,
minAccountOpt: raw.minAccountOpt || undefined,
relation: raw.relation || undefined,
openingDate: asDate(raw.openingDate),
month: raw.month || undefined,
reopenDate: asDate(raw.reopenDate),
depositAmount: raw.depositAmount || undefined,
dailyDepositAmount: raw.dailyDepositAmount,
days: raw.days,
withdrawType: raw.withdrawType || undefined,
lien: raw.lien === 'Yes',
payDate: asDate(raw.payDate),
paymentAmount: raw.paymentAmount || undefined,
openingBalance: raw.openingBalance || 0,
isSmsActive: !!raw.isSmsActive,
isEmailActive: !!raw.isEmailActive,
interestDate: asDate(raw.interestDate),
spDescription: raw.spDescription || undefined,
freezeCode: raw.freezeCode || undefined,
advCode: raw.advCode || undefined,
creditToBranch: raw.creditToBranch || undefined,
creditToAccountType: raw.creditToAccountType || undefined,
creditToAccount: raw.creditToAccount || undefined,
nomineeApplicable: raw.nomineeApplicable === 'Yes',
nomineeSplitMode: raw.nomineeSplitMode || undefined,
dateFormat,
locale,
nominees: (raw.nominees || []).map((nominee: any) => ({
nominationDate: this.dateUtils.getDate(nominee.nominationDate),
name: nominee.name,
mobileNo: nominee.mobileNo || undefined,
birthDate: nominee.birthDate ? this.dateUtils.getDate(nominee.birthDate) : undefined,
minor: nominee.minor === 'Yes',
relation: nominee.relation,
gender: nominee.gender,
guardianName: nominee.guardianName || undefined
}))
};
if (this.partyId) {
this.organizationService.updateParty(this.partyId, payload).subscribe(() => this.exit());
} else {
this.organizationService.createParty(payload).subscribe(() => this.exit());
}
}
closeAccount(): void {
if (!this.partyId) {
return;
}
const dateFormat = this.settingsService.dateFormat;
const locale = this.settingsService.language.code;
this.organizationService
.closeParty(this.partyId, {
closeDate: this.dateUtils.formatDate(new Date(), dateFormat),
dateFormat,
locale
})
.subscribe(() => this.exit());
}
private patchFormFromParty(party: any): void {
this.partyId = party.id;
this.partyStatus = party.status || 'ACTIVE';
this.linkedSavingsAccountId = party.savingsAccountId || null;
this.partyForm.patchValue({
clientId: party.clientId,
subType: party.subType,
agentId: party.agentId,
partyCode: party.partyCode,
lastAccount: party.lastAccount,
nominal: party.nominal ? 'Yes' : 'No',
aadharNo: party.aadharNo,
partyName: party.partyName,
partyName1: party.partyName1,
address1: party.address1,
address2: party.address2,
address3: party.address3,
city: party.city,
pincode: party.pincode,
gender: party.gender,
panCardNo: party.panCardNo,
telephone: party.telephone,
mobileNoSms: party.mobileNoSms,
operation: party.operation,
birthDate: party.birthDate ? new Date(party.birthDate) : null,
occupation: party.occupation,
risk: party.risk,
ckycId: party.ckycId,
adultMinor: party.adultMinor,
minorDate: party.minorDate ? new Date(party.minorDate) : null,
guardianClientId: party.guardianClientId,
minAccountOpt: party.minAccountOpt,
relation: party.relation,
openingDate: party.openingDate ? new Date(party.openingDate) : null,
month: party.month,
maturityDate: party.maturityDate,
reopenDate: party.reopenDate ? new Date(party.reopenDate) : null,
depositAmount: party.depositAmount,
dailyDepositAmount: party.dailyDepositAmount,
days: party.days,
ledgerNo: party.ledgerNo,
maturityAmount: party.maturityAmount,
interestRate: party.interestRate,
withdrawType: party.withdrawType,
od1Flag: party.od1Flag,
od2Flag: party.od2Flag,
lien: party.lien ? 'Yes' : 'No',
payDate: party.payDate ? new Date(party.payDate) : null,
paymentAmount: party.paymentAmount,
currentBalance: party.currentBalance,
openingBalance: party.openingBalance,
isSmsActive: party.isSmsActive,
isEmailActive: party.isEmailActive,
interestCalc: party.interestCalc,
interestDate: party.interestDate ? new Date(party.interestDate) : null,
spDescription: party.spDescription,
freezeCode: party.freezeCode,
advCode: party.advCode,
creditToBranch: party.creditToBranch,
creditToAccountType: party.creditToAccountType,
creditToAccount: party.creditToAccount,
nomineeApplicable: party.nomineeApplicable ? 'Yes' : 'No',
nomineeSplitMode: party.nomineeSplitMode
});
this.nominees.clear();
(party.nominees || []).forEach((nominee: any) => {
const group = this.buildNomineeGroup();
group.patchValue({
nominationDate: nominee.nominationDate ? new Date(nominee.nominationDate) : null,
name: nominee.name,
mobileNo: nominee.mobileNo,
birthDate: nominee.birthDate ? new Date(nominee.birthDate) : null,
minor: nominee.minor ? 'Yes' : 'No',
relation: nominee.relation,
gender: nominee.gender,
guardianName: nominee.guardianName
});
this.nominees.push(group);
});
}
}
@@ -33,6 +33,7 @@
<mifosx-recurring-deposits-account-details-step
[recurringDepositsAccountTemplate]="recurringDepositsAccountTemplate"
(recurringDepositsAccountProductTemplate)="setTemplate($event)"
[externalLinkedAccountId]="recurringDepositsAccountMaturityTermsStep?.selectedTransferAccount?.id ?? null"
>
</mifosx-recurring-deposits-account-details-step>
@@ -111,7 +112,40 @@
</div>
</mat-tab>
<!-- Tab 4: Preview -->
<!-- Tab 4: Terms -->
<mat-tab [disabled]="!recurringDepositsAccountProductTemplate">
<ng-template mat-tab-label>
<fa-icon icon="file-alt" class="cra-tab__icon"></fa-icon>
Terms
</ng-template>
<div class="cra-tab-body">
@if (recurringDepositsAccountProductTemplate) {
<mifosx-recurring-deposits-account-maturity-terms-step
[recurringDepositsAccountTemplate]="recurringDepositsAccountTemplate"
[recurringDepositsAccountProductTemplate]="recurringDepositsAccountProductTemplate"
[linkedSavingsAccountId]="
recurringDepositsAccountDetailsStep?.selectedLinkedAccount?.accountType === 'SAVINGS'
? recurringDepositsAccountDetailsStep?.selectedLinkedAccount?.id
: null
"
></mifosx-recurring-deposits-account-maturity-terms-step>
}
<div class="cra-actions">
<button mat-stroked-button (click)="prev()">
<fa-icon icon="arrow-left" class="m-r-10"></fa-icon>
{{ 'labels.buttons.Previous' | translate }}
</button>
<span class="cra-actions__spacer"></span>
<button mat-raised-button color="primary" (click)="next()">
{{ 'labels.buttons.Next' | translate }}
<fa-icon icon="arrow-right" class="m-l-10"></fa-icon>
</button>
</div>
</div>
</mat-tab>
<!-- Tab 5: Preview -->
<mat-tab [disabled]="!recurringDepositsAccountProductTemplate">
<ng-template mat-tab-label>
<fa-icon icon="eye" class="cra-tab__icon"></fa-icon>
@@ -132,6 +166,7 @@
[rdDaysInYear]="rdDaysInYear"
[rdAppliedRate]="rdAppliedRate"
[rdIsSeniorCitizen]="rdIsSeniorCitizen"
[closureSettings]="recurringDepositsAccountMaturityTermsStep?.closureSettings ?? {}"
[hideButtons]="true"
(submitEvent)="submit()"
>
@@ -24,6 +24,7 @@ import { FaIconComponent } from '@fortawesome/angular-fontawesome';
import { RecurringDepositsAccountCurrencyStepComponent } from '../recurring-deposits-account-stepper/recurring-deposits-account-currency-step/recurring-deposits-account-currency-step.component';
import { RecurringDepositsAccountInterestRateChartStepComponent } from '../recurring-deposits-account-stepper/recurring-deposits-account-interest-rate-chart-step/recurring-deposits-account-interest-rate-chart-step.component';
import { RecurringDepositsAccountPreviewStepComponent } from '../recurring-deposits-account-stepper/recurring-deposits-account-preview-step/recurring-deposits-account-preview-step.component';
import { RecurringDepositsAccountMaturityTermsStepComponent } from '../recurring-deposits-account-stepper/recurring-deposits-account-maturity-terms-step/recurring-deposits-account-maturity-terms-step.component';
import { STANDALONE_SHARED_IMPORTS } from 'app/standalone-shared.module';
/**
@@ -40,6 +41,7 @@ import { STANDALONE_SHARED_IMPORTS } from 'app/standalone-shared.module';
RecurringDepositsAccountDetailsStepComponent,
RecurringDepositsAccountCurrencyStepComponent,
RecurringDepositsAccountInterestRateChartStepComponent,
RecurringDepositsAccountMaturityTermsStepComponent,
RecurringDepositsAccountPreviewStepComponent
]
})
@@ -61,6 +63,12 @@ export class CreateRecurringDepositsAccountComponent {
@ViewChild(RecurringDepositsAccountInterestRateChartStepComponent)
recurringDepositsAccountChartStep: RecurringDepositsAccountInterestRateChartStepComponent;
@ViewChild(RecurringDepositsAccountPreviewStepComponent)
recurringDepositsAccountPreviewStep: RecurringDepositsAccountPreviewStepComponent;
@ViewChild(RecurringDepositsAccountMaturityTermsStepComponent)
recurringDepositsAccountMaturityTermsStep: RecurringDepositsAccountMaturityTermsStepComponent;
/** Recurring Deposits Account Template */
recurringDepositsAccountTemplate: any;
/** Recurring Deposit Account Product Template */
@@ -68,7 +76,7 @@ export class CreateRecurringDepositsAccountComponent {
/** Currently visible wizard tab (0-indexed). */
selectedTabIndex = 0;
/** Total wizard tabs — Product, Currency, Chart, Preview. */
private readonly totalTabs = 4;
private readonly totalTabs = 5;
/** Active CBS base interest rate versions */
cbsBaseRates: any[] = [];
/** Selected tenure value and frequency from calculator */
@@ -258,6 +266,7 @@ export class CreateRecurringDepositsAccountComponent {
...details,
...terms,
...settings,
...(this.recurringDepositsAccountMaturityTermsStep?.closureSettings ?? {}),
...productDefaults,
clientId: this.recurringDepositsAccountTemplate.clientId,
mandatoryRecommendedDepositAmount: pt?.minDepositAmount ?? 0,
@@ -35,6 +35,7 @@
<mifosx-recurring-deposits-account-details-step
[recurringDepositsAccountTemplate]="recurringDepositsAccountAndTemplate"
(recurringDepositsAccountProductTemplate)="setTemplate($event)"
[externalLinkedAccountId]="recurringDepositsAccountMaturityTermsStep?.selectedTransferAccount?.id ?? null"
>
</mifosx-recurring-deposits-account-details-step>
@@ -115,7 +116,40 @@
</div>
</mat-tab>
<!-- Tab 4: Preview -->
<!-- Tab 4: Terms -->
<mat-tab [disabled]="!recurringDepositsAccountProductTemplate">
<ng-template mat-tab-label>
<fa-icon icon="file-alt" class="cra-tab__icon"></fa-icon>
Terms
</ng-template>
<div class="cra-tab-body">
@if (recurringDepositsAccountProductTemplate) {
<mifosx-recurring-deposits-account-maturity-terms-step
[recurringDepositsAccountTemplate]="recurringDepositsAccountAndTemplate"
[recurringDepositsAccountProductTemplate]="recurringDepositsAccountProductTemplate"
[linkedSavingsAccountId]="
recurringDepositsAccountDetailsStep?.selectedLinkedAccount?.accountType === 'SAVINGS'
? recurringDepositsAccountDetailsStep?.selectedLinkedAccount?.id
: null
"
></mifosx-recurring-deposits-account-maturity-terms-step>
}
<div class="cra-actions">
<button mat-stroked-button (click)="prev()">
<fa-icon icon="arrow-left" class="m-r-10"></fa-icon>
{{ 'labels.buttons.Previous' | translate }}
</button>
<span class="cra-actions__spacer"></span>
<button mat-raised-button color="primary" (click)="next()">
{{ 'labels.buttons.Next' | translate }}
<fa-icon icon="arrow-right" class="m-l-10"></fa-icon>
</button>
</div>
</div>
</mat-tab>
<!-- Tab 5: Preview -->
<mat-tab [disabled]="!recurringDepositsAccountProductTemplate">
<ng-template mat-tab-label>
<fa-icon icon="eye" class="cra-tab__icon"></fa-icon>
@@ -136,6 +170,7 @@
[rdDaysInYear]="rdDaysInYear"
[rdAppliedRate]="rdAppliedRate"
[rdIsSeniorCitizen]="rdIsSeniorCitizen"
[closureSettings]="recurringDepositsAccountMaturityTermsStep?.closureSettings ?? {}"
[hideButtons]="true"
(submitEvent)="submit()"
>
@@ -25,6 +25,7 @@ import { FaIconComponent } from '@fortawesome/angular-fontawesome';
import { RecurringDepositsAccountCurrencyStepComponent } from '../recurring-deposits-account-stepper/recurring-deposits-account-currency-step/recurring-deposits-account-currency-step.component';
import { RecurringDepositsAccountInterestRateChartStepComponent } from '../recurring-deposits-account-stepper/recurring-deposits-account-interest-rate-chart-step/recurring-deposits-account-interest-rate-chart-step.component';
import { RecurringDepositsAccountPreviewStepComponent } from '../recurring-deposits-account-stepper/recurring-deposits-account-preview-step/recurring-deposits-account-preview-step.component';
import { RecurringDepositsAccountMaturityTermsStepComponent } from '../recurring-deposits-account-stepper/recurring-deposits-account-maturity-terms-step/recurring-deposits-account-maturity-terms-step.component';
import { STANDALONE_SHARED_IMPORTS } from 'app/standalone-shared.module';
/**
@@ -41,6 +42,7 @@ import { STANDALONE_SHARED_IMPORTS } from 'app/standalone-shared.module';
RecurringDepositsAccountDetailsStepComponent,
RecurringDepositsAccountCurrencyStepComponent,
RecurringDepositsAccountInterestRateChartStepComponent,
RecurringDepositsAccountMaturityTermsStepComponent,
RecurringDepositsAccountPreviewStepComponent
]
})
@@ -63,13 +65,19 @@ export class EditRecurringDepositAccountComponent {
@ViewChild(RecurringDepositsAccountInterestRateChartStepComponent)
recurringDepositsAccountChartStep: RecurringDepositsAccountInterestRateChartStepComponent;
@ViewChild(RecurringDepositsAccountPreviewStepComponent)
recurringDepositsAccountPreviewStep: RecurringDepositsAccountPreviewStepComponent;
@ViewChild(RecurringDepositsAccountMaturityTermsStepComponent)
recurringDepositsAccountMaturityTermsStep: RecurringDepositsAccountMaturityTermsStepComponent;
/** Full account data + template from resolver */
recurringDepositsAccountAndTemplate: any;
/** Product template emitted by details step */
recurringDepositsAccountProductTemplate: any;
/** Currently visible wizard tab (0-indexed). */
selectedTabIndex = 0;
private readonly totalTabs = 4;
private readonly totalTabs = 5;
/** Active CBS base interest rate versions */
cbsBaseRates: any[] = [];
/** Selected tenure value and frequency from calculator */
@@ -277,6 +285,7 @@ export class EditRecurringDepositAccountComponent {
...details,
...terms,
...settings,
...(this.recurringDepositsAccountMaturityTermsStep?.closureSettings ?? {}),
...productDefaults,
clientId: this.recurringDepositsAccountAndTemplate.clientId,
mandatoryRecommendedDepositAmount: pt?.minDepositAmount ?? 0,
@@ -10,6 +10,7 @@
<mat-card>
<form [formGroup]="approveRecurringDepositsAccountForm" (ngSubmit)="submit()">
<mat-card-content>
<p class="mat-body-2">This approves and activates the account in one step -- it goes straight to Active.</p>
<div class="layout-column">
<mat-form-field (click)="approvedOnDatePicker.open()">
<mat-label>{{ 'labels.inputs.Approved On Date' | translate }}</mat-label>
@@ -81,8 +81,9 @@ export class ApproveRecurringDepositsAccountComponent implements OnInit {
}
/**
* Submits the form and approves the recurring deposit account,
* if successful redirects to the recurring deposit account.
* Submits the form, approves the recurring deposit account, then immediately activates it on
* the same date -- Approve and Activate are still two separate Fineract commands underneath
* (each shows up in the audit trail), but the officer only sees and clicks through one step.
*/
submit() {
const approveRecurringDepositsAccountFormData = this.approveRecurringDepositsAccountForm.value;
@@ -103,7 +104,16 @@ export class ApproveRecurringDepositsAccountComponent implements OnInit {
this.recurringDepositsService
.executeRecurringDepositsAccountCommand(this.accountId, 'approve', data)
.subscribe(() => {
this.router.navigate(['../../'], { relativeTo: this.route });
const activationData = {
activatedOnDate: approveRecurringDepositsAccountFormData.approvedOnDate,
dateFormat,
locale
};
this.recurringDepositsService
.executeRecurringDepositsAccountCommand(this.accountId, 'activate', activationData)
.subscribe(() => {
this.router.navigate(['../../'], { relativeTo: this.route });
});
});
}
}
@@ -7,7 +7,17 @@
*/
/** Angular Imports */
import { Component, OnInit, Input, Output, EventEmitter, inject, ChangeDetectorRef } from '@angular/core';
import {
Component,
OnInit,
OnChanges,
SimpleChanges,
Input,
Output,
EventEmitter,
inject,
ChangeDetectorRef
} from '@angular/core';
import { UntypedFormGroup, UntypedFormBuilder, Validators, FormsModule } from '@angular/forms';
import { SettingsService } from 'app/settings/settings.service';
import { Dates } from 'app/core/utils/dates';
@@ -43,7 +53,7 @@ import { IntroducerSearchComponent } from 'app/shared/introducer-search/introduc
IntroducerSearchComponent
]
})
export class RecurringDepositsAccountDetailsStepComponent implements OnInit {
export class RecurringDepositsAccountDetailsStepComponent implements OnInit, OnChanges {
private formBuilder = inject(UntypedFormBuilder);
private recurringDepositsService = inject(RecurringDepositsService);
private settingsService = inject(SettingsService);
@@ -56,6 +66,10 @@ export class RecurringDepositsAccountDetailsStepComponent implements OnInit {
/** Recurring Deposits Account Template */
@Input() recurringDepositsAccountTemplate: any;
/** Linked-account id picked elsewhere in the wizard (e.g. the Terms step's Transfer to Savings
* account) -- pre-fills this card so the officer doesn't have to pick the same account twice. */
@Input() externalLinkedAccountId: number | null = null;
/** Minimum date allowed. */
minDate = new Date(2000, 0, 1);
/** Maximum date allowed. */
@@ -198,6 +212,23 @@ export class RecurringDepositsAccountDetailsStepComponent implements OnInit {
this.dalSearch = '';
}
ngOnChanges(changes: SimpleChanges): void {
if (!changes.externalLinkedAccountId || this.selectedLinkedAccount) {
return;
}
const id = this.externalLinkedAccountId;
if (!id) return;
const savings = this.dalAllSavings.find((a: any) => a.id === id);
const current = this.dalAllCurrent.find((a: any) => a.id === id);
if (savings) {
this.dalSelectedType = 'SAVINGS';
this.selectedLinkedAccount = savings;
} else if (current) {
this.dalSelectedType = 'CURRENT';
this.selectedLinkedAccount = current;
}
}
private loadClientAccounts(): void {
const clientId = String(this.recurringDepositsAccountTemplate?.clientId);
if (!clientId) return;
@@ -0,0 +1,108 @@
<!--
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/.
-->
<form [formGroup]="closureSettingsForm" class="layout-row-wrap gap-2px responsive-column">
<h4 class="mat-h4 flex-98">{{ 'labels.heading.For Pre-mature closure' | translate }}</h4>
<mat-form-field class="flex-48">
<mat-label>{{ 'labels.inputs.Maturity Instructions' | translate }}</mat-label>
<mat-select formControlName="maturityInstructionId">
@for (maturityInstruction of maturityInstructionOptions; track maturityInstruction) {
<mat-option [value]="maturityInstruction.id">
{{ maturityInstruction.value | translateKey: 'catalogs' }}
</mat-option>
}
</mat-select>
</mat-form-field>
@if (closureSettingsForm.contains('transferToSavingsId')) {
<div class="dal-card flex-98">
<div class="dal-card__header">
<div class="dal-card__header-left">
<div class="dal-card__icon">
<mat-icon>savings</mat-icon>
</div>
<div>
<h3 class="dal-card__title">{{ 'labels.inputs.Transfer to savings account' | translate }}</h3>
<span class="dal-card__sub">Maturity proceeds will be credited to this account</span>
</div>
</div>
</div>
<div class="dal-card__body">
@if (selectedTransferAccount) {
<div class="dal-selected">
<div class="dal-selected__info">
<span class="dal-type-badge dal-type-badge--savings">
<mat-icon>savings</mat-icon>
Savings
</span>
<div class="dal-selected__details">
<span class="dal-selected__accno">{{ selectedTransferAccount.accountNo }}</span>
<span class="dal-selected__product">{{ selectedTransferAccount.savingsProductName }}</span>
</div>
@if (selectedTransferAccount.summary?.accountBalance != null) {
<div class="dal-selected__balance">
<span class="dal-selected__bal-label">Balance</span>
<span class="dal-selected__bal-amount">
{{ selectedTransferAccount.currency?.code }}
{{ selectedTransferAccount.summary.accountBalance | number: '1.2-2' }}
</span>
</div>
}
</div>
<button
mat-stroked-button
color="primary"
type="button"
class="dal-change-btn"
(click)="clearTransferAccount()"
>
<mat-icon>swap_horiz</mat-icon>
Change
</button>
</div>
} @else {
<mat-form-field appearance="outline" class="flex-98">
<mat-label>{{ 'labels.inputs.Transfer to savings account' | translate }}</mat-label>
<mat-select formControlName="transferToSavingsId" required>
@for (account of savingsAccountsData; track account) {
<mat-option [value]="account.id">
({{ account.accountNo }}) {{ account.savingsProductName }}
</mat-option>
}
</mat-select>
<mat-error>Transfer to savings account is required</mat-error>
</mat-form-field>
}
</div>
</div>
}
<mat-checkbox class="flex-98 margin-v" labelPosition="before" formControlName="preClosurePenalApplicable">
{{ 'labels.inputs.Apply Penal Interest (less)' | translate }}
</mat-checkbox>
@if (closureSettingsForm.value.preClosurePenalApplicable) {
<mat-form-field class="flex-48">
<mat-label>{{ 'labels.inputs.Penal Interest' | translate }} (%)</mat-label>
<input type="number" matInput formControlName="preClosurePenalInterest" min="0" step="0.01" />
</mat-form-field>
<mat-form-field class="flex-48">
<mat-label>{{ 'labels.inputs.Period' | translate }}</mat-label>
<mat-select formControlName="preClosurePenalInterestOnTypeId">
@for (preClosurePenalInterestOnType of preClosurePenalInterestOnTypeData; track preClosurePenalInterestOnType) {
<mat-option [value]="preClosurePenalInterestOnType.id">
{{ preClosurePenalInterestOnType.value | translateKey: 'catalogs' }}
</mat-option>
}
</mat-select>
</mat-form-field>
}
</form>
@@ -0,0 +1,152 @@
/**
* 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/.
*/
// ── Transfer-to-Savings account card (mirrors the Deposit Account Link card) ──
.dal-card {
margin-top: 16px;
border: 1px solid #e2e8f0;
border-radius: 12px;
background: #fff;
overflow: hidden;
&__header {
display: flex;
align-items: center;
gap: 14px;
padding: 16px 24px;
border-bottom: 1px solid #f1f5f9;
background: linear-gradient(135deg, #f0f9ff 0%, #e0f2fe 100%);
}
&__header-left {
display: flex;
align-items: center;
gap: 14px;
}
&__icon {
width: 44px;
height: 44px;
border-radius: 10px;
background: #0ea5e9;
display: flex;
align-items: center;
justify-content: center;
color: #fff;
}
&__title {
margin: 0;
font-size: 15px;
font-weight: 700;
color: #0c4a6e;
}
&__sub {
display: block;
font-size: 12px;
color: #64748b;
margin-top: 2px;
}
&__body {
padding: 20px 24px;
}
}
.dal-selected {
display: flex;
align-items: center;
gap: 16px;
padding: 16px 20px;
border: 2px solid #0ea5e9;
border-radius: 12px;
background: #f0f9ff;
&__info {
display: flex;
align-items: center;
gap: 14px;
flex: 1;
flex-wrap: wrap;
}
&__details {
display: flex;
flex-direction: column;
gap: 2px;
flex: 1;
}
&__accno {
font-size: 15px;
font-weight: 700;
color: #0c4a6e;
font-family: monospace;
}
&__product {
font-size: 12px;
color: #64748b;
}
&__balance {
display: flex;
flex-direction: column;
align-items: flex-end;
gap: 2px;
}
&__bal-label {
font-size: 10px;
color: #94a3b8;
text-transform: uppercase;
letter-spacing: 0.5px;
}
&__bal-amount {
font-size: 15px;
font-weight: 700;
color: #0c4a6e;
}
}
.dal-change-btn {
white-space: nowrap;
}
.dal-type-badge {
display: inline-flex;
align-items: center;
gap: 4px;
padding: 4px 12px;
border-radius: 999px;
font-size: 12px;
font-weight: 700;
white-space: nowrap;
background: #f1f5f9;
color: #475569;
border: 1px solid #e2e8f0;
mat-icon {
font-size: 14px;
width: 14px;
height: 14px;
}
&--savings {
background: #eff6ff;
color: #1d4ed8;
border-color: #bfdbfe;
mat-icon {
color: #2563eb;
}
}
}
@@ -0,0 +1,116 @@
/**
* 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/.
*/
/** Angular Imports */
import { Component, Input, OnChanges, SimpleChanges, inject } from '@angular/core';
import { UntypedFormGroup, UntypedFormBuilder, UntypedFormControl, Validators } from '@angular/forms';
import { MatCheckbox } from '@angular/material/checkbox';
import { MatIcon } from '@angular/material/icon';
import { STANDALONE_SHARED_IMPORTS } from 'app/standalone-shared.module';
/**
* Recurring Deposit Account Maturity Terms Step -- what happens to this account at maturity
* (Withdraw / Transfer to Savings / Reinvest) and the pre-mature-closure penalty, decided
* per account rather than silently inherited from the product.
*/
@Component({
selector: 'mifosx-recurring-deposits-account-maturity-terms-step',
templateUrl: './recurring-deposits-account-maturity-terms-step.component.html',
styleUrls: ['./recurring-deposits-account-maturity-terms-step.component.scss'],
imports: [
...STANDALONE_SHARED_IMPORTS,
MatCheckbox,
MatIcon
]
})
export class RecurringDepositsAccountMaturityTermsStepComponent implements OnChanges {
private formBuilder = inject(UntypedFormBuilder);
@Input() recurringDepositsAccountTemplate: any;
@Input() recurringDepositsAccountProductTemplate: any;
/** Account already linked elsewhere in the wizard (the Details step's Deposit Account Link) --
* pre-fills Transfer to Savings so the officer doesn't have to pick the same account twice. */
@Input() linkedSavingsAccountId: number | null = null;
closureSettingsForm: UntypedFormGroup;
maturityInstructionOptions: any[] = [];
savingsAccountsData: any[] = [];
preClosurePenalInterestOnTypeData: any[] = [];
constructor() {
this.closureSettingsForm = this.formBuilder.group({
maturityInstructionId: [''],
preClosurePenalApplicable: [false],
preClosurePenalInterest: [
'',
Validators.min(0)
],
preClosurePenalInterestOnTypeId: ['']
});
this.closureSettingsForm.get('maturityInstructionId').valueChanges.subscribe((value: number) => {
// 200 = Transfer to Savings -- Withdraw Deposit (100) needs no target account, and both
// Reinvest options (300/400) open a brand-new RD instead, so they ignore this field too.
if (value === 200) {
this.closureSettingsForm.addControl(
'transferToSavingsId',
new UntypedFormControl(this.linkedSavingsAccountId ?? '', Validators.required)
);
} else {
this.closureSettingsForm.removeControl('transferToSavingsId');
}
});
}
ngOnChanges(changes: SimpleChanges) {
if (changes.linkedSavingsAccountId) {
const control = this.closureSettingsForm.get('transferToSavingsId');
if (control && !control.value && this.linkedSavingsAccountId) {
control.setValue(this.linkedSavingsAccountId);
}
}
const template = this.recurringDepositsAccountProductTemplate || this.recurringDepositsAccountTemplate;
if (template) {
// Withdraw Deposit (id 100) books the maturity proceeds out of the bank's cash/vault the moment
// the account matures, before the customer has actually collected it -- disabled everywhere.
this.maturityInstructionOptions = (template.maturityInstructionOptions ?? []).filter(
(option: any) => option.id !== 100
);
this.savingsAccountsData = template.savingsAccounts ?? [];
this.preClosurePenalInterestOnTypeData = template.preClosurePenalInterestOnTypeOptions ?? [];
if (!this.closureSettingsForm.get('preClosurePenalApplicable').dirty) {
this.closureSettingsForm.patchValue({
preClosurePenalApplicable: template.preClosurePenalApplicable ?? false,
preClosurePenalInterest: template.preClosurePenalInterest ?? '',
preClosurePenalInterestOnTypeId: template.preClosurePenalInterestOnType?.id ?? ''
});
}
}
}
/** The linked-savings account for the transfer-to-savings maturity instruction, once picked. */
get selectedTransferAccount(): any {
const id = this.closureSettingsForm.value.transferToSavingsId;
if (!id) return null;
return this.savingsAccountsData.find((account: any) => account.id === id) ?? null;
}
clearTransferAccount(): void {
this.closureSettingsForm.get('transferToSavingsId')?.setValue('');
}
/** Chosen maturity/closure settings, ready to merge into the submit payload. */
get closureSettings() {
const settings = this.closureSettingsForm.getRawValue();
for (const key in settings) {
if (settings[key] === '') {
delete settings[key];
}
}
return settings;
}
}
@@ -853,6 +853,41 @@
}
</div>
<div class="preview-section">
<div class="preview-section__header">
<i class="fas fa-flag-checkered preview-section__icon"></i>
<span class="preview-section__title">Maturity &amp; Pre-mature Closure</span>
</div>
<div class="preview-body">
<div class="preview-grid preview-grid--three">
<div class="preview-field">
<span class="preview-label">{{ 'labels.inputs.Maturity Instructions' | translate }}</span>
<span class="preview-value">{{ closureMaturityInstructionLabel }}</span>
</div>
@if (closureSettings?.transferToSavingsId) {
<div class="preview-field">
<span class="preview-label">{{ 'labels.inputs.Transfer to savings account' | translate }}</span>
<span class="preview-value">{{ closureTransferToSavingsLabel }}</span>
</div>
}
<div class="preview-field">
<span class="preview-label">{{ 'labels.inputs.Apply Penal Interest (less)' | translate }}</span>
<span class="preview-value">{{ closureSettings?.preClosurePenalApplicable | yesNo }}</span>
</div>
@if (closureSettings?.preClosurePenalApplicable) {
<div class="preview-field">
<span class="preview-label">{{ 'labels.inputs.Penal Interest' | translate }} (%)</span>
<span class="preview-value">{{ closureSettings?.preClosurePenalInterest }}%</span>
</div>
<div class="preview-field">
<span class="preview-label">{{ 'labels.inputs.Period' | translate }}</span>
<span class="preview-value">{{ closurePenalInterestOnTypeLabel }}</span>
</div>
}
</div>
</div>
</div>
@if (!hideButtons) {
<div class="layout-row responsive-column align-center gap-2px margin-t">
<button mat-raised-button matStepperPrevious>
@@ -201,7 +201,29 @@ export class RecurringDepositsAccountPreviewStepComponent implements OnChanges {
/** Expand Chart Slab Index used in the view */
expandChartSlabIndex: number;
constructor() {}
/** Maturity/pre-mature-closure choices made in the Terms step -- shown here read-only. */
@Input() closureSettings: any = {};
get closureMaturityInstructionLabel(): string {
const template = this.recurringDepositsAccountProductTemplate || this.recurringDepositsAccountTemplate;
const options = template?.maturityInstructionOptions ?? [];
const match = options.find((o: any) => o.id === this.closureSettings?.maturityInstructionId);
return match?.value ?? '—';
}
get closureTransferToSavingsLabel(): string {
const template = this.recurringDepositsAccountProductTemplate || this.recurringDepositsAccountTemplate;
const accounts = template?.savingsAccounts ?? [];
const match = accounts.find((a: any) => a.id === this.closureSettings?.transferToSavingsId);
return match ? `(${match.accountNo}) ${match.savingsProductName}` : '—';
}
get closurePenalInterestOnTypeLabel(): string {
const template = this.recurringDepositsAccountProductTemplate || this.recurringDepositsAccountTemplate;
const options = template?.preClosurePenalInterestOnTypeOptions ?? [];
const match = options.find((o: any) => o.id === this.closureSettings?.preClosurePenalInterestOnTypeId);
return match?.value ?? '—';
}
ngOnChanges() {
const template = this.recurringDepositsAccountProductTemplate || this.recurringDepositsAccountTemplate;
@@ -17,6 +17,7 @@ import {
} from '@angular/forms';
import { SettingsService } from 'app/settings/settings.service';
import { Currency } from 'app/shared/models/general.model';
import { OptionData } from 'app/shared/models/option-data.model';
import { MatCheckbox } from '@angular/material/checkbox';
import { InputAmountComponent } from '../../../../shared/input-amount/input-amount.component';
import { MatDivider } from '@angular/material/divider';
@@ -61,6 +62,10 @@ export class RecurringDepositsAccountSettingsStepComponent implements OnInit, On
periodFrequencyTypeData: any[] = [];
/** Preclosure Penal Interest Type on Data */
preClosurePenalInterestOnTypeData: any[] = [];
/** Options for what happens to the account at maturity */
maturityInstructionOptions: OptionData[];
/** Savings accounts eligible as a maturity-proceeds transfer target */
savingsAccountsData: any;
/** Tax Group */
taxGroup: any;
currency: Currency | null = null;
@@ -91,6 +96,7 @@ export class RecurringDepositsAccountSettingsStepComponent implements OnInit, On
: '',
maturityInstructions: recurringDepositsAccount.maturityInstructions ?? false,
creditMaturityProceeds: recurringDepositsAccount.creditMaturityProceeds ?? false,
maturityInstructionId: recurringDepositsAccount.maturityInstructionId ?? '',
penaltyGracePeriodMissedInstallments: recurringDepositsAccount.penaltyGracePeriodMissedInstallments ?? false,
panelRequestApplicable: recurringDepositsAccount.panelRequestApplicable ?? false,
noPenaltyNomineeHeirClosure: recurringDepositsAccount.noPenaltyNomineeHeirClosure ?? false,
@@ -182,6 +188,7 @@ export class RecurringDepositsAccountSettingsStepComponent implements OnInit, On
lockinPeriodFrequencyType: [''],
maturityInstructions: [false],
creditMaturityProceeds: [false],
maturityInstructionId: [''],
penaltyGracePeriodMissedInstallments: [false],
panelRequestApplicable: [false],
noPenaltyNomineeHeirClosure: [false],
@@ -217,6 +224,8 @@ export class RecurringDepositsAccountSettingsStepComponent implements OnInit, On
this.lockinPeriodFrequencyTypeData = recurringDepositsAccount.lockinPeriodFrequencyTypeOptions;
this.periodFrequencyTypeData = recurringDepositsAccount.periodFrequencyTypeOptions;
this.preClosurePenalInterestOnTypeData = recurringDepositsAccount.preClosurePenalInterestOnTypeOptions;
this.maturityInstructionOptions = recurringDepositsAccount.maturityInstructionOptions;
this.savingsAccountsData = recurringDepositsAccount.savingsAccounts;
}
buildDependencies() {
@@ -233,6 +242,16 @@ export class RecurringDepositsAccountSettingsStepComponent implements OnInit, On
this.recurringDepositAccountSettingsForm.addControl('recurringFrequencyType', new UntypedFormControl(''));
}
});
this.recurringDepositAccountSettingsForm.get('maturityInstructionId').valueChanges.subscribe((value: number) => {
if (value > 100) {
this.recurringDepositAccountSettingsForm.addControl(
'transferToSavingsId',
new UntypedFormControl('', Validators.required)
);
} else {
this.recurringDepositAccountSettingsForm.removeControl('transferToSavingsId');
}
});
}
get recurringDepositAccountSettings() {
@@ -87,6 +87,19 @@
<!-- Action menu (kebab) -->
<div class="rd-hero__actions">
@if (
!recurringDepositsAccountData.status.rejected &&
!recurringDepositsAccountData.status.submittedAndPendingApproval
) {
<button
mat-icon-button
matTooltip="Print Certificate"
(click)="printCertificate()"
aria-label="Print certificate"
>
<fa-icon icon="print"></fa-icon>
</button>
}
<button mat-icon-button [matMenuTriggerFor]="accountMenu" aria-label="Account actions" yPosition="below">
<fa-icon icon="ellipsis-v"></fa-icon>
</button>
@@ -22,6 +22,7 @@ import { RecurringDepositsButtonsConfiguration } from './recurring-deposits-butt
import { DeleteDialogComponent } from 'app/shared/delete-dialog/delete-dialog.component';
import { ConfirmationDialogComponent } from 'app/shared/confirmation-dialog/confirmation-dialog.component';
import { RecurringDepositConfirmationDialogComponent } from './custom-dialogs/recurring-deposit-confirmation-dialog/recurring-deposit-confirmation-dialog.component';
import { DepositCertificateDialogComponent } from 'app/shared/deposit-certificate-dialog/deposit-certificate-dialog.component';
import { Currency } from 'app/shared/models/general.model';
import { TranslateService } from '@ngx-translate/core';
import { AccountsService } from 'app/accounts/accounts.service';
@@ -426,6 +427,20 @@ export class RecurringDepositsAccountViewComponent implements OnInit {
});
}
/**
* Opens a printable deposit certificate for this account.
*/
printCertificate() {
this.dialog.open(DepositCertificateDialogComponent, {
width: '900px',
data: {
accountType: 'RD',
accountData: this.recurringDepositsAccountData,
clientId: this.recurringDepositsAccountData.clientId
}
});
}
/**
* Disables withhold tax for recurring deposits account
* Recurring deposits endpoint is not supported so using Savings endpoint.
@@ -1,355 +0,0 @@
<!--
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="am-container">
<div class="am-header">
<button mat-stroked-button [routerLink]="['../']" class="am-back">
<i class="fas fa-arrow-left"></i> {{ 'labels.heading.Organization' | translate }}
</button>
<h2 class="am-header__title">Agent Master Entry</h2>
</div>
<div class="am-card">
<form [formGroup]="agentForm">
<div class="am-section">
<div class="am-section__header">
<i class="fas fa-id-card"></i>
<h3>Agent Details</h3>
</div>
<div class="am-grid">
<mat-form-field appearance="outline" class="am-field">
<mat-label>Client ID</mat-label>
<input matInput required formControlName="clientId" />
</mat-form-field>
<mat-form-field appearance="outline" class="am-field">
<mat-label>Agent Code</mat-label>
<input matInput formControlName="agentCode" placeholder="Auto-generated" />
</mat-form-field>
<mat-form-field appearance="outline" class="am-field am-field--wide">
<mat-label>Agent Name ID</mat-label>
<input matInput required formControlName="agentNameId" placeholder="Person or institution name" />
</mat-form-field>
<mat-form-field appearance="outline" class="am-field am-field--wide">
<mat-label>Agent Address (Line 1)</mat-label>
<input matInput required formControlName="agentAddress1" />
</mat-form-field>
<mat-form-field appearance="outline" class="am-field am-field--wide">
<mat-label>Agent Address (Line 2)</mat-label>
<input matInput formControlName="agentAddress2" />
</mat-form-field>
<mat-form-field appearance="outline" class="am-field">
<mat-label>Agent City</mat-label>
<input matInput required formControlName="agentCity" />
</mat-form-field>
<mat-form-field appearance="outline" class="am-field">
<mat-label>Agent PinCode</mat-label>
<input matInput required formControlName="agentPinCode" />
</mat-form-field>
<mat-form-field appearance="outline" class="am-field">
<mat-label>Agent Telephone</mat-label>
<input matInput formControlName="agentTelephone" />
</mat-form-field>
<mat-form-field appearance="outline" class="am-field">
<mat-label>Home Branch</mat-label>
<input matInput required formControlName="homeBranch" />
</mat-form-field>
</div>
</div>
<div class="am-section">
<div class="am-section__header">
<i class="fas fa-percent"></i>
<h3>Collection &amp; Commission Settings</h3>
</div>
<div class="am-grid">
<mat-form-field appearance="outline" class="am-field">
<mat-label>Com. Calculation</mat-label>
<mat-select required formControlName="commissionCalculation">
<mat-option value="Yes">Yes</mat-option>
<mat-option value="No">No</mat-option>
</mat-select>
</mat-form-field>
<mat-form-field appearance="outline" class="am-field">
<mat-label>TDS</mat-label>
<mat-select required formControlName="tdsApplicable">
<mat-option value="Yes">Yes</mat-option>
<mat-option value="No">No</mat-option>
</mat-select>
</mat-form-field>
<mat-form-field appearance="outline" class="am-field">
<mat-label>TDS %</mat-label>
<input matInput type="number" formControlName="tdsPercent" />
</mat-form-field>
<mat-form-field appearance="outline" class="am-field">
<mat-label>Cur.TDS</mat-label>
<input matInput formControlName="currentTds" />
</mat-form-field>
<mat-form-field appearance="outline" class="am-field">
<mat-label>TDS Amt</mat-label>
<input matInput formControlName="tdsAmount" />
</mat-form-field>
<mat-form-field appearance="outline" class="am-field">
<mat-label>Limit</mat-label>
<input matInput type="number" formControlName="limit" />
</mat-form-field>
<mat-form-field appearance="outline" class="am-field">
<mat-label>Machine Rent</mat-label>
<input matInput type="number" formControlName="machineRent" />
</mat-form-field>
</div>
</div>
<div class="am-section">
<div class="am-section__header">
<i class="fas fa-piggy-bank"></i>
<h3>Settlement &amp; Security Accounts</h3>
</div>
<div class="am-grid">
<mat-form-field appearance="outline" class="am-field">
<mat-label>Saving Branch</mat-label>
<input matInput required formControlName="savingBranch" />
</mat-form-field>
<mat-form-field appearance="outline" class="am-field">
<mat-label>Agent Saving A/C Type</mat-label>
<mat-select required formControlName="agentSavingAccountType">
@for (accountType of agentSavingAccountTypes; track accountType) {
<mat-option [value]="accountType">{{ accountType }}</mat-option>
}
</mat-select>
</mat-form-field>
<mat-form-field appearance="outline" class="am-field">
<mat-label>Agent Saving A/C No</mat-label>
<input matInput required formControlName="agentSavingAccountNo" />
</mat-form-field>
<mat-form-field appearance="outline" class="am-field">
<mat-label>Trf Branch</mat-label>
<input matInput formControlName="transferBranch" />
</mat-form-field>
<mat-form-field appearance="outline" class="am-field">
<mat-label>Trf.Amt A/C Type</mat-label>
<input matInput formControlName="transferAmountAccountType" />
</mat-form-field>
<mat-form-field appearance="outline" class="am-field">
<mat-label>Trf.Amt A/C No</mat-label>
<input matInput formControlName="transferAccountNo" />
</mat-form-field>
<mat-form-field appearance="outline" class="am-field">
<mat-label>Agent Security Branch</mat-label>
<input matInput formControlName="agentSecurityBranch" />
</mat-form-field>
<mat-form-field appearance="outline" class="am-field">
<mat-label>Agent Security A/C Type</mat-label>
<mat-select formControlName="agentSecurityAccountType">
@for (accountType of agentSavingAccountTypes; track accountType) {
<mat-option [value]="accountType">{{ accountType }}</mat-option>
}
</mat-select>
</mat-form-field>
<mat-form-field appearance="outline" class="am-field">
<mat-label>Agent Security A/C No</mat-label>
<input matInput formControlName="agentSecurityAccountNo" />
</mat-form-field>
<mat-form-field appearance="outline" class="am-field">
<mat-label>Agent Opening Date</mat-label>
<input matInput required [matDatepicker]="openingDatePicker" formControlName="agentOpeningDate" />
<mat-datepicker-toggle matIconSuffix [for]="openingDatePicker"></mat-datepicker-toggle>
<mat-datepicker #openingDatePicker></mat-datepicker>
</mat-form-field>
<mat-form-field appearance="outline" class="am-field">
<mat-label>Agent Birth Date</mat-label>
<input
matInput
required
[max]="maxDate"
[matDatepicker]="birthDatePicker"
formControlName="agentBirthDate"
/>
<mat-datepicker-toggle matIconSuffix [for]="birthDatePicker"></mat-datepicker-toggle>
<mat-datepicker #birthDatePicker></mat-datepicker>
</mat-form-field>
<mat-form-field appearance="outline" class="am-field">
<mat-label>Agent Current Balance</mat-label>
<input matInput formControlName="agentCurrentBalance" />
</mat-form-field>
<mat-form-field appearance="outline" class="am-field">
<mat-label>Agent Opening Balance</mat-label>
<input matInput formControlName="agentOpeningBalance" />
</mat-form-field>
</div>
</div>
<div class="am-section">
<div class="am-section__header">
<i class="fas fa-clock"></i>
<h3>Collection Limit History</h3>
<button mat-stroked-button type="button" class="am-add-guarantor" (click)="addLimit()">
<i class="fas fa-plus"></i> Add Limit
</button>
</div>
@if (limits.length === 0) {
<p class="am-empty-guarantors">No limit periods added yet.</p>
}
@for (limitRow of limits.controls; track limitRow; let i = $index) {
<div class="am-limit-row" [formGroup]="$any(limitRow)">
<mat-form-field appearance="outline" class="am-field">
<mat-label>From Date</mat-label>
<input matInput required [matDatepicker]="fromPicker" formControlName="fromDate" />
<mat-datepicker-toggle matIconSuffix [for]="fromPicker"></mat-datepicker-toggle>
<mat-datepicker #fromPicker></mat-datepicker>
</mat-form-field>
<mat-form-field appearance="outline" class="am-field">
<mat-label>To Date</mat-label>
<input matInput required [matDatepicker]="toPicker" formControlName="toDate" />
<mat-datepicker-toggle matIconSuffix [for]="toPicker"></mat-datepicker-toggle>
<mat-datepicker #toPicker></mat-datepicker>
</mat-form-field>
<mat-form-field appearance="outline" class="am-field">
<mat-label>Limit</mat-label>
<input matInput required type="number" formControlName="limit" />
</mat-form-field>
<button
mat-icon-button
type="button"
class="am-remove-guarantor"
(click)="removeLimit(i)"
matTooltip="Remove limit period"
>
<i class="fas fa-trash"></i>
</button>
</div>
}
</div>
<div class="am-section">
<div class="am-section__header">
<i class="fas fa-user-shield"></i>
<h3>Guarantor's Detail</h3>
<button mat-stroked-button type="button" class="am-add-guarantor" (click)="addGuarantor()">
<i class="fas fa-plus"></i> Add Guarantor
</button>
</div>
@if (guarantors.length === 0) {
<p class="am-empty-guarantors">No guarantors added yet.</p>
}
@for (guarantor of guarantors.controls; track guarantor; let i = $index) {
<div class="am-guarantor-row" [formGroup]="$any(guarantor)">
<mat-form-field appearance="outline" class="am-field">
<mat-label>Client ID</mat-label>
<input matInput required formControlName="clientId" />
</mat-form-field>
<mat-form-field appearance="outline" class="am-field">
<mat-label>Guarantor Name</mat-label>
<input matInput required formControlName="guarantorName" />
</mat-form-field>
<mat-form-field appearance="outline" class="am-field">
<mat-label>Address 1</mat-label>
<input matInput required formControlName="address1" />
</mat-form-field>
<mat-form-field appearance="outline" class="am-field">
<mat-label>Address 2</mat-label>
<input matInput formControlName="address2" />
</mat-form-field>
<mat-form-field appearance="outline" class="am-field">
<mat-label>City</mat-label>
<input matInput required formControlName="city" />
</mat-form-field>
<mat-form-field appearance="outline" class="am-field">
<mat-label>Pin Code</mat-label>
<input matInput required formControlName="pinCode" />
</mat-form-field>
<mat-form-field appearance="outline" class="am-field">
<mat-label>Telephone</mat-label>
<input matInput formControlName="telephone" />
</mat-form-field>
<button
mat-icon-button
type="button"
class="am-remove-guarantor"
(click)="removeGuarantor(i)"
matTooltip="Remove guarantor"
>
<i class="fas fa-trash"></i>
</button>
</div>
}
</div>
<div class="am-section">
<div class="am-section__header">
<i class="fas fa-wallet"></i>
<h3>Deductions &amp; Deposits</h3>
</div>
<div class="am-grid">
<mat-form-field appearance="outline" class="am-field">
<mat-label>Comm</mat-label>
<input matInput formControlName="commission" />
</mat-form-field>
<mat-form-field appearance="outline" class="am-field">
<mat-label>Other Comm</mat-label>
<input matInput type="number" formControlName="otherCommission" />
</mat-form-field>
<mat-form-field appearance="outline" class="am-field">
<mat-label>Ag.Deposit</mat-label>
<input matInput type="number" formControlName="agentDeposit" />
</mat-form-field>
<mat-form-field appearance="outline" class="am-field">
<mat-label>Adv.Festival</mat-label>
<input matInput type="number" formControlName="festivalAdvance" />
</mat-form-field>
<mat-form-field appearance="outline" class="am-field">
<mat-label>Other Deduction</mat-label>
<input matInput type="number" formControlName="otherDeduction" />
</mat-form-field>
</div>
</div>
<div class="am-action-bar">
<button mat-raised-button type="button" (click)="exit()">Exit</button>
<button mat-stroked-button type="button" (click)="cancel()">Cancel</button>
<button mat-stroked-button type="button" (click)="retrieve()">Retrieve</button>
<button mat-raised-button color="primary" type="button" [disabled]="agentForm.invalid" (click)="save()">
Save
</button>
</div>
</form>
</div>
</div>

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