Compare commits

...
12 Commits
Author SHA1 Message Date
tarunmalviya 7d1025f5ce Locker module UI: dashboard, class master, locker list, waiting list, allotment form 2026-10-05 15:30:12 +05:30
tarunmalviya 885b39d970 DDS: approvals tab, vouchers, payout & close, Sundry GL search, closure tab, fixes 2026-10-01 13:08:52 +05:30
tarunmalviya d47e0860f6 Merge remote-tracking branch 'origin/dev-latest' into feature/dds-agent-settlement-reports
# Conflicts:
#	src/app/core/shell/shell.component.html
#	src/app/organization/organization-routing.module.ts
#	src/environments/.env.ts
2026-09-23 12:11:12 +05:30
tarunmalviyaandClaude Opus 5 e2eff9914f DDS: agent collection entry screen, per-agent GL account, maturity auto close
The Daily Transaction Entry screen no longer composes a Party Tran. An agent records their own round
elsewhere; this screen reads back what they wrote down, shows what it adds up to, and settles the
day once a clerk has counted the cash against it. Nothing is typed here and nothing is edited - what
settles is what the entries say, to the rupee.

Agent Master now asks which ledger account the agent's collections pass through, one agent to an
account, and offers only the accounts that can carry them. The DDS scheme product is kept out of the
agent's own savings list for the same reason the server refuses it: debits are blocked on those
accounts, so commission paid into one would go in and never come out.

The DDS product carries a Maturity Auto Close setting, asked as Yes/No to match the Auto Interest
Post question directly above it, and Batch Processing offers the run that applies it.

Also: the report viewer stops printing two money columns ungrouped and stops totalling a column that
holds a word, and an agent's birth date cannot be picked later than eighteen years ago.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-23 11:54:49 +05:30
keshav a6e6220100 fix: update sponsor DD approvals, cashier transaction entry, FIU and dividend declaration 2026-09-21 10:24:08 +05:30
niteshandClaude Opus 5 3c363d0372 feat(shares): one-voucher-per-slot receipt dialogs, Apply Additional Shares as a popup
Apply Collection and Apply Additional Shares settle the same Share Collection
receipts for the same member, so they are now deliberately the same screen.

Apply Collection takes exactly two receipts and no more: one for the share value,
one for the entry fee, in two separate slots. They are different things -- the
share receipt is money moving into share capital, while the fee receipt only
proves the fee was already collected and is never posted a second time -- and
picking both out of one ticked list made that distinction invisible.

Apply Additional Shares moves from its own route to a dialog. It was a full page
a clerk had to leave the account for and come back from, to fill in a short form
with the member standing at the counter. Its old route, resolver case and action-
host branch are removed rather than left behind as dead paths.

Both screens now draw their receipts from a single pool, whether a receipt
already names the member or is a voucher paid straight into a GL account; an
unattributed one is attached at the moment it is picked, so what used to be an
Attach in one table and a tick in another is one deliberate click. The by-voucher
search and the GL-entry detail both sit behind buttons, as exceptions rather than
permanent furniture.

Dialog chrome and the receipt shape are shared (share-dialog.shared.scss,
share-receipt-option.ts) so the two cannot drift apart again.

Also fixes content being clipped at the dialog edges: the wrapper cancelled a
24px surface padding that the panel class had already zeroed, pushing every edge
outside the surface where overflow:hidden cut it off, and the height cap sat on
the scrolling body rather than the wrapper, letting the footer stack past the
bottom of the viewport.

Adds the product-wise share summary report (authorised, issued, available and
unpaid shares, distinctive range and account count per product).

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-18 18:56:34 +05:30
nitesh dc7f668056 feat(shares): move voucher search behind a popup, not an always-visible toolbar
The GL Account / Voucher No lookup is for the exception -- a receipt the
unattributed list and the member's own list do not already carry -- so it now
sits behind a "Search a Voucher Not Listed Below" button in both Apply
Collection and Apply Additional Shares, opening the same lookup in a popup
(matching the existing receipt-detail popup) instead of occupying screen space
whether or not anyone needs it. A successful find closes the popup and the
result appears ticked in the list behind it, same as before.
2026-09-18 13:57:14 +05:30
nitesh 124f05d21d Merge branch 'dev-latest' of https://git.lincpay.tech/CBSNexora/cbsnexora into dev-latest
# Conflicts:
#	src/environments/.env.ts
2026-09-18 13:15:48 +05:30
niteshandClaude Sonnet 5 c7428d0cb2 feat(shares): dynamic Share Reports module, voucher attach/GL sync fixes
- Add the full Share Reports surface (final balance, account statement,
  issued/status/nominal/share-wise/guarantor/member-interest reports, enquiry,
  payment certificate, share detail, dividend statement, folio statement,
  distinctive numbers, area-wise, mailing labels/letters, share transfer) as
  standalone routed components, wired into the Share menu
- Wire the unattributed Share Collection receipt list into Apply Collection
  and Apply Additional Shares, with an Attach action independent of
  account/client
- Sync the GL Account and Voucher No fields to whichever receipt is ticked or
  found, and surface the receipt's real Dr/Cr GL entries in its detail popup
- Block Apply Additional Shares until the account's original application is
  settled

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-18 13:06:02 +05:30
keshav 17d97da380 Merge branch 'dev-latest' of https://git.lincpay.tech/CBSNexora/cbsnexora into dev-latest 2026-09-18 10:33:28 +05:30
keshav f9b73a4930 feat(aml,risk-review,accounting): update financial activity mapping options, AML flagged transactions filters, and risk review report 2026-09-18 10:32:47 +05:30
tarunmalviya 57a61b3057 DDS: Agent settlement (Agent Tran), Close Party account UI, reports UI, Party-Savings link field 2026-08-25 10:56:03 +05:30
191 changed files with 37705 additions and 8483 deletions
+2 -2
View File
@@ -69,8 +69,8 @@
"budgets": [
{
"type": "initial",
"maximumWarning": "15mb",
"maximumError": "17mb"
"maximumWarning": "18mb",
"maximumError": "20mb"
},
{
"type": "anyComponentStyle",
+74 -5363
View File
File diff suppressed because it is too large Load Diff
@@ -16,6 +16,7 @@ import { AccountingService } from '../../accounting.service';
import { GLAccount } from 'app/shared/models/general.model';
import { GlAccountSelectorComponent } from '../../../shared/accounting/gl-account-selector/gl-account-selector.component';
import { STANDALONE_SHARED_IMPORTS } from 'app/standalone-shared.module';
import { accountOptionsForActivity } from '../financial-activity-account-options';
/**
* Create financial activity mapping component.
@@ -87,21 +88,11 @@ export class CreateFinancialActivityMappingComponent implements OnInit {
*/
setGlAccountData() {
this.financialActivityMappingForm.get('financialActivityId').valueChanges.subscribe((financialActivityId) => {
switch (financialActivityId) {
case 100:
case 101:
case 102:
case 103:
this.glAccountData = this.glAccountOptions.assetAccountOptions;
break;
case 200:
case 201:
this.glAccountData = this.glAccountOptions.liabilityAccountOptions;
break;
case 300:
this.glAccountData = this.glAccountOptions.equityAccountOptions;
break;
}
this.glAccountData = accountOptionsForActivity(
this.financialActivityData,
financialActivityId,
this.glAccountOptions
);
});
}
@@ -16,6 +16,7 @@ import { AccountingService } from '../../accounting.service';
import { GLAccount } from 'app/shared/models/general.model';
import { GlAccountSelectorComponent } from '../../../shared/accounting/gl-account-selector/gl-account-selector.component';
import { STANDALONE_SHARED_IMPORTS } from 'app/standalone-shared.module';
import { accountOptionsForActivity } from '../financial-activity-account-options';
/**
* Edit financial activity mapping component.
@@ -98,21 +99,11 @@ export class EditFinancialActivityMappingComponent implements OnInit {
*/
setGLAccountData() {
this.financialActivityMappingForm.get('financialActivityId').valueChanges.subscribe((financialActivityId) => {
switch (financialActivityId) {
case 100:
case 101:
case 102:
case 103:
this.glAccountData = this.glAccountOptions.assetAccountOptions;
break;
case 200:
case 201:
this.glAccountData = this.glAccountOptions.liabilityAccountOptions;
break;
case 300:
this.glAccountData = this.glAccountOptions.equityAccountOptions;
break;
}
this.glAccountData = accountOptionsForActivity(
this.financialActivityData,
financialActivityId,
this.glAccountOptions
);
});
}
@@ -0,0 +1,38 @@
/**
* Copyright since 2025 Mifos Initiative
*
* This Source Code Form is subject to the terms of the Mozilla Public
* License, v. 2.0. If a copy of the MPL was not distributed with this
* file, You can obtain one at http://mozilla.org/MPL/2.0/.
*/
/**
* The GL accounts a financial activity may be mapped to.
*
* The activity itself says which kind of account it needs — the template returns `mappedGLAccountType` on every
* option — so the answer is read from the activity rather than worked out from its id. The screens used to switch on
* the id instead, listing the ones they knew:
*
* case 100, 101, 102, 103 → asset
* case 200, 201 → liability
* case 300 → equity
*
* Every activity added since fell through that switch and left the account dropdown empty, which is how dividendPaid
* (202), reserveForDividend (301), distributableProfit (302), retainedProfit (303) and dividendRoundOff (304) became
* unmappable — the screen offered no accounts and gave no reason. A list keyed on ids has to be extended by hand for
* each new activity, and is silently wrong until someone notices; asking the activity what it needs cannot fall out
* of step with the backend.
*
* @param activities the template's financialActivityOptions
* @param activityId the activity the user picked
* @param glAccountOptions the template's glAccountOptions, keyed assetAccountOptions / liabilityAccountOptions / …
*/
export function accountOptionsForActivity(activities: any[], activityId: number, glAccountOptions: any): any[] {
const activity = (activities || []).find((a) => a?.id === activityId);
const accountType = activity?.mappedGLAccountType;
if (!accountType || !glAccountOptions) {
return [];
}
// ASSET → assetAccountOptions, and so on for LIABILITY, EQUITY, INCOME and EXPENSE.
return glAccountOptions[`${accountType.toLowerCase()}AccountOptions`] ?? [];
}
@@ -11,6 +11,14 @@ 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 { CreatePartyComponent } from '../deposits/party-master/create-party/create-party.component';
import { DueForClosureComponent } from '../deposits/party-master/due-for-closure/due-for-closure.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';
import { DdsAccountsComponent } from '../deposits/dds-accounts/dds-accounts.component';
import { DdsAccountViewComponent } from '../deposits/dds-accounts/dds-account-view/dds-account-view.component';
const routes: Routes = [
Route.withShell([
@@ -41,6 +49,59 @@ const routes: Routes = [
title: 'Recurring Deposit Accounts',
breadcrumb: 'Recurring Deposit Accounts'
}
},
{
path: 'dds',
data: { title: 'Daily Deposit Scheme — Party Master', breadcrumb: 'Party Master' },
children: [
{ path: '', component: PartyMasterComponent },
{
path: 'create',
component: CreatePartyComponent,
data: { title: 'Create Party', breadcrumb: 'Create' }
},
{
// Ahead of the :partyId routes, so "dueforclosure" is not read as a party id.
path: 'dueforclosure',
component: DueForClosureComponent,
data: { title: 'Accounts Due for Closure', breadcrumb: 'Due for Closure' }
},
{
path: ':partyId/edit',
component: CreatePartyComponent,
data: { title: 'Edit Party', breadcrumb: 'Edit' }
}
]
},
{
// The DDS accounts listed like Savings Accounts, each opening onto its transactions.
path: 'dds-accounts',
data: { title: 'DDS Accounts', breadcrumb: 'DDS Accounts' },
children: [
{ path: '', component: DdsAccountsComponent },
{
path: ':partyId',
component: DdsAccountViewComponent,
data: { title: 'DDS Account', breadcrumb: 'Account', routeParamBreadcrumb: false }
}
]
},
{
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' }
}
]
}
]
}
+2
View File
@@ -37,6 +37,7 @@ import { GroupsModule } from './groups/groups.module';
import { CentersModule } from './centers/centers.module';
import { AccountingModule } from './accounting/accounting.module';
import { ChequeBookModule } from './chequebook/chequebook.module';
import { LockerModule } from './locker/locker.module';
import { CtsClearingModule } from './ctsclearing/ctsclearing.module';
import { NachClearingModule } from './nachclearing/nachclearing.module';
import { SystemModule } from './system/system.module';
@@ -154,6 +155,7 @@ export function HttpLoaderFactory(http: HttpClient) {
CentersModule,
AccountingModule,
ChequeBookModule,
LockerModule,
CtsClearingModule,
NachClearingModule,
SystemModule,
+12 -4
View File
@@ -390,10 +390,18 @@ export class CashierService {
return this.http.get('/shareadvancecollection/requirement', { params: { shareAccountId: String(shareAccountId) } });
}
/** 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}`);
/**
* Everything an Apply screen needs, in one call: `own` is this member's own unapplied receipts,
* ready to tick; `rows` is every other Share Collection receipt still available — unclaimed GL
* deposits and receipts linked to another member — which attach by voucher instead.
*
* One request rather than two: both lists come from the same table under the same filters, and
* splitting them across endpoints only meant the same rows were fetched twice and, where they
* overlapped, drawn twice on the same screen.
*/
getShareCollectionReceipts(clientId?: number): Observable<any> {
const params = clientId ? new HttpParams().set('clientId', String(clientId)) : undefined;
return this.http.get('/shareadvancecollection/unattributed', { params });
}
/** Moves a receipt out of Sundry into share capital for an activated account. */
@@ -8,7 +8,7 @@
import { Component, OnInit, inject } from '@angular/core';
import { CommonModule } from '@angular/common';
import { FormsModule, ReactiveFormsModule, FormBuilder, FormGroup, Validators } from '@angular/forms';
import { FormsModule, ReactiveFormsModule, FormBuilder, FormGroup, FormArray, Validators } from '@angular/forms';
import { MatDialogModule, MatDialogRef, MAT_DIALOG_DATA } from '@angular/material/dialog';
import { MatFormFieldModule } from '@angular/material/form-field';
import { MatInputModule } from '@angular/material/input';
@@ -35,6 +35,35 @@ export interface CashierIssueDdDialogData {
accountNo?: string;
/** The account holder, for the same reason. */
accountHolderName?: string;
/**
* How many drafts this slip raises. Absent or 1 is the ordinary single draft and the dialog is
* unchanged; 2 or more turns it into a slip, asking for a face value, charge and beneficiary per
* draft and reporting the combined total the account will be debited for.
*/
ddCount?: number;
/**
* Whether the funding account is a GL account rather than a member's. Only affects wording: a GL
* account has no passbook, so it is not promised one.
*/
glFunded?: boolean;
/**
* The leaflets provisionally shown against each line of a slip.
*
* Only ever indicative. The server allocates the real numbers inside the locked transaction that
* submits the slip, so what is shown here is the next-available run at the moment the dialog was
* opened — accurate unless another clerk submits first, which is precisely why the numbers cannot
* be promised until submit returns them.
*/
previewDdNumbers?: string[];
}
/** One captured draft on a multi-DD slip. */
export interface DdSlipLineResult {
beneficiaryName: string;
payableAtBranch: string;
payableAtCity: string;
faceValue: number;
chargesAmount: number;
}
@Component({
@@ -56,10 +85,20 @@ export interface CashierIssueDdDialogData {
<div class="dd-dialog-header__title">
<mat-icon class="dd-icon">receipt_long</mat-icon>
<div>
<h2>{{ isAccountDebit ? 'Account-Debit Demand Draft (DD)' : 'Walk-in Cash Demand Draft (DD)' }}</h2>
<h2>
@if (isSlip) {
Multi-DD Slip · {{ lineCount }} Demand Drafts
} @else {
{{ isAccountDebit ? 'Account-Debit Demand Draft (DD)' : 'Walk-in Cash Demand Draft (DD)' }}
}
</h2>
<p>
Next available DD leaflet auto-selected ·
{{ isAccountDebit ? 'Enter beneficiary details' : 'Enter purchaser &amp; beneficiary details' }}
@if (isSlip) {
One debit funds all {{ lineCount }} drafts · Enter each draft's beneficiary and amount
} @else {
Next available DD leaflet auto-selected ·
{{ isAccountDebit ? 'Enter beneficiary details' : 'Enter purchaser &amp; beneficiary details' }}
}
</p>
</div>
</div>
@@ -69,41 +108,91 @@ export interface CashierIssueDdDialogData {
</div>
<mat-dialog-content class="dd-dialog-body">
<!-- Draft & Financials Summary Card -->
<div class="dd-summary-card">
<!-- The number the customer will actually see printed on the instrument. "Auto-Selected
<!--
Slip running total.
A slip's figures are not known until every line is typed — different drafts carry different
face values and different charges — so the total the account will be debited for is built
up here as the clerk works, rather than shown as a fixed figure up front. Answering "what
is this going to cost my member" without it would mean adding the lines by hand.
-->
@if (isSlip) {
<div class="dd-summary-card">
<div class="summary-col">
<span class="label">Drafts On This Slip</span>
<strong class="dd-number-highlight">{{ lineCount }}</strong>
<span class="sublabel">{{ data.sponsorBankName || 'Sponsor Bank' }} · {{ data.branchName }}</span>
</div>
<div class="summary-divider"></div>
<div class="summary-col">
<span class="label">Total Face Value</span>
<strong class="amount">₹{{ slipFaceValue | number: '1.2-2' }}</strong>
<span class="sublabel">+ Charges ₹{{ slipCharges | number: '1.2-2' }}</span>
</div>
<div class="summary-divider"></div>
<div class="summary-col">
<span class="label">GST ({{ data.gstPercentage }}%)</span>
<strong class="amount">₹{{ slipGst | number: '1.2-2' }}</strong>
<span class="sublabel">on charges only</span>
</div>
<div class="summary-divider"></div>
<div class="summary-col highlight-col">
<span class="label">Total To Debit</span>
<strong class="total-amount">₹{{ slipTotal | number: '1.2-2' }}</strong>
<span class="sublabel">{{ data.accountNo || 'Account' }} · one debit</span>
</div>
</div>
<!--
Said plainly because the numbers on screen are the one thing in this dialog the clerk
cannot rely on. They are the next-available run as it stood when the dialog opened; the
binding allocation happens inside the submit transaction, so a slip filed after another
clerk's gets the following numbers instead.
-->
<div class="dd-preview-note">
<mat-icon>info</mat-icon>
<div>
DD numbers below are <strong>provisional</strong>. The final numbers are allocated when the slip is
submitted and will be confirmed on the receipt.
</div>
</div>
} @else {
<!-- Draft & Financials Summary Card -->
<div class="dd-summary-card">
<!-- The number the customer will actually see printed on the instrument. "Auto-Selected
Leaflet" named the stock item rather than the draft, so it was not obvious that this
is the DD number being issued. -->
<div class="summary-col">
<span class="label">DD Number Being Issued</span>
<strong class="dd-number-highlight">{{ data.leaflet.ddNumber }}</strong>
<span class="sublabel">{{ data.sponsorBankName || 'Sponsor Bank' }} · {{ data.branchName }}</span>
<div class="summary-col">
<span class="label">DD Number Being Issued</span>
<strong class="dd-number-highlight">{{ data.leaflet.ddNumber }}</strong>
<span class="sublabel">{{ data.sponsorBankName || 'Sponsor Bank' }} · {{ data.branchName }}</span>
</div>
<div class="summary-divider"></div>
<div class="summary-col">
<span class="label">Face Value</span>
<strong class="amount">₹{{ data.faceValue | number: '1.2-2' }}</strong>
<span class="sublabel">+ Charges ₹{{ data.chargesAmount | number: '1.2-2' }}</span>
</div>
<div class="summary-divider"></div>
<div class="summary-col">
<span class="label">GST ({{ data.gstPercentage }}%)</span>
<strong class="amount">₹{{ data.gstAmount | number: '1.2-2' }}</strong>
<span class="sublabel">on charges only</span>
</div>
<div class="summary-divider"></div>
<div class="summary-col highlight-col">
<span class="label">{{ isAccountDebit ? 'Total Debited' : 'Total Cash Collected' }}</span>
<strong class="total-amount">₹{{ data.totalCashPayable | number: '1.2-2' }}</strong>
<span class="sublabel">
@if (isAccountDebit) {
{{ data.accountNo || 'Account' }}
} @else {
Denominations verified
}
</span>
</div>
</div>
<div class="summary-divider"></div>
<div class="summary-col">
<span class="label">Face Value</span>
<strong class="amount">₹{{ data.faceValue | number: '1.2-2' }}</strong>
<span class="sublabel">+ Charges ₹{{ data.chargesAmount | number: '1.2-2' }}</span>
</div>
<div class="summary-divider"></div>
<div class="summary-col">
<span class="label">GST ({{ data.gstPercentage }}%)</span>
<strong class="amount">₹{{ data.gstAmount | number: '1.2-2' }}</strong>
<span class="sublabel">on charges only</span>
</div>
<div class="summary-divider"></div>
<div class="summary-col highlight-col">
<span class="label">{{ isAccountDebit ? 'Total Debited' : 'Total Cash Collected' }}</span>
<strong class="total-amount">₹{{ data.totalCashPayable | number: '1.2-2' }}</strong>
<span class="sublabel">
@if (isAccountDebit) {
{{ data.accountNo || 'Account' }}
} @else {
Denominations verified
}
</span>
</div>
</div>
}
<!--
The account funding the draft, stated before the form.
@@ -121,123 +210,237 @@ export interface CashierIssueDdDialogData {
· {{ data.accountHolderName }}
}
<div class="dd-funding-sub">
₹{{ data.totalCashPayable | number: '1.2-2' }} will be debited from this account when a checker
approves. No cash is taken at the counter.
@if (isSlip) {
₹{{ slipTotal | number: '1.2-2' }} will be debited from this account as a
<strong>single transaction</strong> covering all {{ lineCount }} drafts, when a checker approves the
slip —
<!-- A GL account has no passbook, so the reassurance a member gets is phrased for
the ledger instead of promising a statement line that does not exist. -->
@if (isGlFunded) {
one journal entry against this GL account, not {{ lineCount }}.
} @else {
the member's passbook shows one entry, not {{ lineCount }}.
}
} @else {
₹{{ data.totalCashPayable | number: '1.2-2' }} will be debited from this account when a checker
approves. No cash is taken at the counter.
}
</div>
</div>
</div>
}
<form [formGroup]="form" class="dd-form">
<!--
<!--
A slip's drafts, one card each.
Face value and charge sit on the line rather than on the slip because that is how the bank
actually uses this: three drafts to three institutions are three different amounts, and the
charge is levied per draft. The server stores and posts them per line for the same reason.
-->
@if (isSlip) {
<form [formGroup]="slipForm" class="dd-form">
<div formArrayName="lines">
@for (lineCtrl of slipLines.controls; track $index) {
<div class="dd-line-card" [formGroupName]="$index">
<div class="dd-line-head">
<span class="dd-line-badge">DD {{ $index + 1 }} of {{ lineCount }}</span>
@if (previewNumberAt($index)) {
<span class="dd-line-number">No. {{ previewNumberAt($index) }} <em>(provisional)</em></span>
}
<span class="dd-line-total"> Debit ₹{{ lineTotalAt($index) | number: '1.2-2' }} </span>
</div>
<mat-form-field appearance="outline" class="full-width">
<mat-label>Beneficiary Name (In Favour Of) *</mat-label>
<input
matInput
formControlName="beneficiaryName"
placeholder="e.g. UNIVERSITY OF MUMBAI"
maxlength="120"
style="text-transform: uppercase;"
/>
<mat-error *ngIf="lineCtrl.get('beneficiaryName')?.hasError('required')"
>Beneficiary name is required</mat-error
>
<mat-error *ngIf="lineCtrl.get('beneficiaryName')?.hasError('maxlength')"
>Max 120 characters</mat-error
>
</mat-form-field>
<div class="form-row">
<mat-form-field appearance="outline" class="form-field-flex">
<mat-label>Payable At - Branch *</mat-label>
<input
matInput
formControlName="payableAtBranch"
placeholder="e.g. FORT BRANCH"
maxlength="150"
style="text-transform: uppercase;"
/>
<mat-error *ngIf="lineCtrl.get('payableAtBranch')?.hasError('required')"
>Payable Branch is required</mat-error
>
</mat-form-field>
<mat-form-field appearance="outline" class="form-field-flex">
<mat-label>Payable At - City *</mat-label>
<input
matInput
formControlName="payableAtCity"
placeholder="e.g. MUMBAI"
maxlength="100"
style="text-transform: uppercase;"
/>
<mat-error *ngIf="lineCtrl.get('payableAtCity')?.hasError('required')"
>Payable City is required</mat-error
>
</mat-form-field>
</div>
<div class="form-row">
<mat-form-field appearance="outline" class="form-field-flex">
<mat-label>Face Value *</mat-label>
<input matInput type="number" formControlName="faceValue" placeholder="0.00" />
<span matPrefix>₹&nbsp;</span>
<mat-error *ngIf="lineCtrl.get('faceValue')?.hasError('required')"
>Face value is required</mat-error
>
<mat-error *ngIf="lineCtrl.get('faceValue')?.hasError('min')"
>Face value must be greater than zero</mat-error
>
</mat-form-field>
<mat-form-field appearance="outline" class="form-field-flex">
<mat-label>Charges</mat-label>
<input matInput type="number" formControlName="chargesAmount" placeholder="0.00" />
<span matPrefix>₹&nbsp;</span>
<mat-hint>GST ₹{{ lineGstAt($index) | number: '1.2-2' }} at {{ data.gstPercentage }}%</mat-hint>
<mat-error *ngIf="lineCtrl.get('chargesAmount')?.hasError('min')"
>Charges cannot be negative</mat-error
>
</mat-form-field>
</div>
</div>
}
</div>
</form>
} @else {
<form [formGroup]="form" class="dd-form">
<!--
Purchaser identity is asked for only when the bank has none: a walk-in paying cash. On an
account debit the purchaser IS the account holder, already identified and KYC'd, and
re-capturing a name and Aadhaar here would invite a mismatch against the account on record.
-->
@if (!isAccountDebit) {
<div class="form-section-title">Walk-in Purchaser Details</div>
@if (!isAccountDebit) {
<div class="form-section-title">Walk-in Purchaser Details</div>
<div class="form-row">
<mat-form-field appearance="outline" class="form-field-flex">
<mat-label>Walk-in Purchaser Name *</mat-label>
<input
matInput
formControlName="walkinPurchaserName"
placeholder="e.g. RAMESH CHANDRA"
style="text-transform: uppercase;"
/>
<mat-error *ngIf="form.get('walkinPurchaserName')?.hasError('required')"
>Purchaser name is required</mat-error
>
<mat-error *ngIf="form.get('walkinPurchaserName')?.hasError('minlength')"
>Must be at least 3 characters</mat-error
>
</mat-form-field>
<mat-form-field appearance="outline" class="form-field-flex">
<mat-label>12-Digit Aadhaar Number *</mat-label>
<input
matInput
formControlName="walkinPurchaserAadhaar"
placeholder="12-digit Aadhaar"
maxlength="12"
/>
<mat-hint>Masked to last 4 digits before storage</mat-hint>
<mat-error *ngIf="form.get('walkinPurchaserAadhaar')?.hasError('required')"
>Aadhaar number is required</mat-error
>
<mat-error *ngIf="form.get('walkinPurchaserAadhaar')?.hasError('pattern')"
>Must be exactly 12 numeric digits</mat-error
>
</mat-form-field>
</div>
<mat-form-field appearance="outline" class="full-width">
<mat-label>Purchaser Address / Location *</mat-label>
<input
matInput
formControlName="walkinPurchaserAddress"
placeholder="e.g. 14, SECTOR 5, MAIN ROAD, MUMBAI"
style="text-transform: uppercase;"
/>
<mat-error *ngIf="form.get('walkinPurchaserAddress')?.hasError('required')"
>Address is required</mat-error
>
</mat-form-field>
}
<!-- Beneficiary Section -->
<div class="form-section-title">Beneficiary & Payment Details</div>
<mat-form-field appearance="outline" class="full-width">
<mat-label>Beneficiary Name (In Favour Of) *</mat-label>
<input
matInput
formControlName="beneficiaryName"
placeholder="e.g. UNIVERSITY OF MUMBAI"
maxlength="120"
style="text-transform: uppercase;"
/>
<mat-error *ngIf="form.get('beneficiaryName')?.hasError('required')"
>Beneficiary name is required</mat-error
>
<mat-error *ngIf="form.get('beneficiaryName')?.hasError('maxlength')">Max 120 characters</mat-error>
</mat-form-field>
<div class="form-row">
<mat-form-field appearance="outline" class="form-field-flex">
<mat-label>Walk-in Purchaser Name *</mat-label>
<mat-label>Payable At - Branch *</mat-label>
<input
matInput
formControlName="walkinPurchaserName"
placeholder="e.g. RAMESH CHANDRA"
formControlName="payableAtBranch"
placeholder="e.g. FORT BRANCH"
maxlength="150"
style="text-transform: uppercase;"
/>
<mat-error *ngIf="form.get('walkinPurchaserName')?.hasError('required')"
>Purchaser name is required</mat-error
>
<mat-error *ngIf="form.get('walkinPurchaserName')?.hasError('minlength')"
>Must be at least 3 characters</mat-error
<mat-error *ngIf="form.get('payableAtBranch')?.hasError('required')"
>Payable Branch is required</mat-error
>
</mat-form-field>
<mat-form-field appearance="outline" class="form-field-flex">
<mat-label>12-Digit Aadhaar Number *</mat-label>
<mat-label>Payable At - City *</mat-label>
<input
matInput
formControlName="walkinPurchaserAadhaar"
placeholder="12-digit Aadhaar"
maxlength="12"
formControlName="payableAtCity"
placeholder="e.g. MUMBAI"
maxlength="100"
style="text-transform: uppercase;"
/>
<mat-hint>Masked to last 4 digits before storage</mat-hint>
<mat-error *ngIf="form.get('walkinPurchaserAadhaar')?.hasError('required')"
>Aadhaar number is required</mat-error
>
<mat-error *ngIf="form.get('walkinPurchaserAadhaar')?.hasError('pattern')"
>Must be exactly 12 numeric digits</mat-error
>
<mat-error *ngIf="form.get('payableAtCity')?.hasError('required')">Payable City is required</mat-error>
</mat-form-field>
</div>
<mat-form-field appearance="outline" class="full-width">
<mat-label>Purchaser Address / Location *</mat-label>
<input
matInput
formControlName="walkinPurchaserAddress"
placeholder="e.g. 14, SECTOR 5, MAIN ROAD, MUMBAI"
style="text-transform: uppercase;"
/>
<mat-error *ngIf="form.get('walkinPurchaserAddress')?.hasError('required')"
>Address is required</mat-error
>
</mat-form-field>
}
<!-- Beneficiary Section -->
<div class="form-section-title">Beneficiary & Payment Details</div>
<mat-form-field appearance="outline" class="full-width">
<mat-label>Beneficiary Name (In Favour Of) *</mat-label>
<input
matInput
formControlName="beneficiaryName"
placeholder="e.g. UNIVERSITY OF MUMBAI"
maxlength="120"
style="text-transform: uppercase;"
/>
<mat-error *ngIf="form.get('beneficiaryName')?.hasError('required')"
>Beneficiary name is required</mat-error
>
<mat-error *ngIf="form.get('beneficiaryName')?.hasError('maxlength')">Max 120 characters</mat-error>
</mat-form-field>
<div class="form-row">
<mat-form-field appearance="outline" class="form-field-flex">
<mat-label>Payable At - Branch *</mat-label>
<input
matInput
formControlName="payableAtBranch"
placeholder="e.g. FORT BRANCH"
maxlength="150"
style="text-transform: uppercase;"
/>
<mat-error *ngIf="form.get('payableAtBranch')?.hasError('required')"
>Payable Branch is required</mat-error
>
</mat-form-field>
<mat-form-field appearance="outline" class="form-field-flex">
<mat-label>Payable At - City *</mat-label>
<input
matInput
formControlName="payableAtCity"
placeholder="e.g. MUMBAI"
maxlength="100"
style="text-transform: uppercase;"
/>
<mat-error *ngIf="form.get('payableAtCity')?.hasError('required')">Payable City is required</mat-error>
</mat-form-field>
</div>
</form>
</form>
}
</mat-dialog-content>
<mat-dialog-actions class="dd-dialog-actions" align="end">
@if (isSlip) {
<span class="dd-actions-total">
{{ lineCount }} drafts · <strong>₹{{ slipTotal | number: '1.2-2' }}</strong> to debit
</span>
}
<button mat-button type="button" (click)="onClose()">Cancel</button>
<button mat-flat-button color="primary" [disabled]="form.invalid" (click)="onSubmit()">
Save DD Details
<button mat-flat-button color="primary" [disabled]="activeForm.invalid" (click)="onSubmit()">
{{ isSlip ? 'Save Slip Details' : 'Save DD Details' }}
</button>
</mat-dialog-actions>
</div>
@@ -395,6 +598,81 @@ export interface CashierIssueDdDialogData {
line-height: 1.5;
color: #64748b;
}
/* One card per draft on a slip — the boundary is what stops a clerk typing line 2's
beneficiary against line 1's amount. */
.dd-line-card {
border: 1px solid #e2e8f0;
border-left: 3px solid #0369a1;
border-radius: 8px;
padding: 14px 16px 4px;
margin-bottom: 14px;
background: #fcfdff;
}
.dd-line-head {
display: flex;
align-items: center;
gap: 10px;
flex-wrap: wrap;
margin-bottom: 12px;
}
.dd-line-badge {
font-size: 11px;
font-weight: 700;
text-transform: uppercase;
letter-spacing: 0.4px;
color: #ffffff;
background: #0369a1;
padding: 3px 9px;
border-radius: 999px;
}
.dd-line-number {
font-family: monospace;
font-size: 12px;
color: #475569;
}
.dd-line-number em {
font-style: normal;
color: #94a3b8;
font-size: 11px;
}
.dd-line-total {
margin-left: auto;
font-size: 12px;
font-weight: 600;
color: #065f46;
background: #ecfdf5;
border: 1px solid #a7f3d0;
padding: 3px 10px;
border-radius: 6px;
}
.dd-preview-note {
display: flex;
gap: 8px;
align-items: flex-start;
margin: 0 0 16px;
padding: 9px 12px;
background: #fffbeb;
border: 1px solid #fde68a;
border-radius: 6px;
font-size: 12px;
line-height: 1.5;
color: #78350f;
}
.dd-preview-note mat-icon {
color: #b45309;
font-size: 18px;
width: 18px;
height: 18px;
flex-shrink: 0;
}
.dd-actions-total {
margin-right: auto;
font-size: 13px;
color: #475569;
}
.dd-actions-total strong {
color: #065f46;
}
`
]
})
@@ -404,13 +682,127 @@ export class CashierIssueDdDialogComponent implements OnInit {
public data: CashierIssueDdDialogData = inject(MAT_DIALOG_DATA);
form!: FormGroup;
/** Built only in slip mode; `form` stays untouched so the single-draft path is unchanged. */
slipForm!: FormGroup;
/** Whether the draft is funded by debiting a member's account rather than by cash at the counter. */
get isAccountDebit(): boolean {
return this.data.paymentMethod === 'ACCOUNT_DEBIT';
}
/** How many drafts this dialog is capturing — 1 unless the clerk asked for a slip. */
get lineCount(): number {
const n = Number(this.data.ddCount) || 1;
return n > 1 ? n : 1;
}
/** Whether this dialog is capturing a multi-DD slip rather than a single draft. */
get isSlip(): boolean {
return this.lineCount > 1;
}
/** Whether the debit lands on a GL account rather than a member's — wording only. */
get isGlFunded(): boolean {
return !!this.data.glFunded;
}
/** The form the Save button validates against — whichever of the two was built. */
get activeForm(): FormGroup {
return this.isSlip ? this.slipForm : this.form;
}
get slipLines(): FormArray {
return this.slipForm.get('lines') as FormArray;
}
/** The provisional leaflet number shown against a line, if the caller supplied a run of them. */
previewNumberAt(index: number): string | null {
return this.data.previewDdNumbers?.[index] ?? null;
}
private lineValueAt(index: number, field: string): number {
return Number(this.slipLines.at(index)?.get(field)?.value) || 0;
}
/**
* GST on one line's charge.
*
* Rounded per line, matching the server, which computes and stores GST against each draft rather
* than once for the slip. Summing rounded lines and rounding a summed charge can differ by a
* paisa, and it is the per-line figure that ends up on the draft.
*/
lineGstAt(index: number): number {
const charges = this.lineValueAt(index, 'chargesAmount');
if (charges <= 0) return 0;
return Math.round(charges * (Number(this.data.gstPercentage) || 0)) / 100;
}
/** What one line adds to the debit — its face value, its charge and the GST on that charge. */
lineTotalAt(index: number): number {
return this.lineValueAt(index, 'faceValue') + this.lineValueAt(index, 'chargesAmount') + this.lineGstAt(index);
}
get slipFaceValue(): number {
return this.slipLines.controls.reduce((sum, _c, i) => sum + this.lineValueAt(i, 'faceValue'), 0);
}
get slipCharges(): number {
return this.slipLines.controls.reduce((sum, _c, i) => sum + this.lineValueAt(i, 'chargesAmount'), 0);
}
get slipGst(): number {
return this.slipLines.controls.reduce((sum, _c, i) => sum + this.lineGstAt(i), 0);
}
/** The figure the account is debited for — rounded up to the whole rupee, as the single flow does. */
get slipTotal(): number {
return Math.ceil(this.slipFaceValue + this.slipCharges + this.slipGst - 1e-9);
}
ngOnInit(): void {
if (this.isSlip) {
this.slipForm = this.fb.group({
lines: this.fb.array(
Array.from({ length: this.lineCount }, () =>
this.fb.group({
beneficiaryName: [
'',
[
Validators.required,
Validators.maxLength(120)
]
],
payableAtBranch: [
'',
[Validators.required]
],
payableAtCity: [
'',
[Validators.required]
],
// Zero is rejected rather than merely warned about: the server refuses a line with a
// face value at or below zero, and a slip that fails there has already cost the clerk
// every other line they typed.
faceValue: [
null as
| number
| null,
[
Validators.required,
Validators.min(0.01)
]
],
chargesAmount: [
0,
[Validators.min(0)]
]
})
)
)
});
return;
}
// Walk-in identity is required for cash and rejected outright for an account debit — the server
// refuses a walk-in name alongside an account — so the controls are only built when they apply.
// Registering them unconditionally and merely hiding them would hold the form invalid on a
@@ -477,7 +869,25 @@ export class CashierIssueDdDialogComponent implements OnInit {
}
onSubmit(): void {
if (this.form.invalid) return;
if (this.activeForm.invalid) return;
// A slip closes with its lines. There is no walk-in branch here: a slip is only ever offered on
// a member-account debit, where the purchaser is the account holder already on record.
if (this.isSlip) {
const lines: DdSlipLineResult[] = this.slipLines.controls.map((c) => {
const v = c.value;
return {
beneficiaryName: (v.beneficiaryName || '').trim().toUpperCase(),
payableAtBranch: (v.payableAtBranch || '').trim().toUpperCase(),
payableAtCity: (v.payableAtCity || '').trim().toUpperCase(),
faceValue: Number(v.faceValue) || 0,
chargesAmount: Number(v.chargesAmount) || 0
};
});
this.dialogRef.close({ lines });
return;
}
const val = this.form.value;
const beneficiary = {
beneficiaryName: (val.beneficiaryName || '').trim().toUpperCase(),
@@ -319,9 +319,13 @@
[value]="row.amount ?? ''"
(input)="row.amount = +$any($event.target).value; syncShareLinkedAmounts()"
(click)="$event.stopPropagation()"
[placeholder]="row.acTyp === 'SHR' ? '0' : '0.00'"
[readonly]="!isClerk && !!(activeWithdrawalRequest || activeClosureRequest)"
[class.ws-amt-input--locked]="!isClerk && !!(activeWithdrawalRequest || activeClosureRequest)"
[placeholder]="isMultiDdRow(row) ? 'Per draft' : row.acTyp === 'SHR' ? '0' : '0.00'"
[readonly]="
isMultiDdRow(row) || (!isClerk && !!(activeWithdrawalRequest || activeClosureRequest))
"
[class.ws-amt-input--locked]="
isMultiDdRow(row) || (!isClerk && !!(activeWithdrawalRequest || activeClosureRequest))
"
/>
</div>
@if (row.acTyp === 'SHR' && rowSharePrices[row.acNo]) {
@@ -331,6 +335,19 @@
}}/share</span
>
}
<!-- On a slip the face value belongs to each draft, so the row total is the sum of
them and is filled in by the dialog rather than typed here. A clerk-typed
figure could disagree with the drafts, and the account would be debited for
a total the drafts do not add up to. -->
@if (isMultiDdRow(row)) {
<span class="ws-f__hint">
{{
row.ddLines?.length
? 'Total of ' + row.ddLines?.length + ' drafts'
: 'Set per draft in Fill DD Details'
}}
</span>
}
</div>
@if (row.acTyp === 'SHR' && (row.typ === 'Enter Bank Transfer' || row.typ === 'Fund Transfer')) {
<div class="ws-f ws-f--cheqno">
@@ -410,9 +427,36 @@
<div class="ws-group ws-group--flex">
<span class="ws-caption">DD Charges &amp; GST — Admin Rate {{ ddGstPercentage }}%</span>
<div class="ws-group__fields">
<!-- How many drafts this one debit funds. Typed before the details are filled in,
because it is what decides how many beneficiary forms the dialog presents.
Offered only where the server can honour it: a member savings/current
account debit, never a GL or cash draft. -->
@if (canRowBeMultiDd(row)) {
<div class="ws-f ws-f--amt">
<label class="ws-f__lbl" title="Number of demand drafts to raise against this single debit"
>NO. OF DD</label
>
<div class="ws-amt-wrap">
<input
class="ws-f__ctl ws-amt-input"
type="number"
min="1"
[max]="maxDdSlipLines"
step="1"
[value]="ddCountFor(row)"
(input)="onDdCountChange(row, +$any($event.target).value)"
(click)="$event.stopPropagation()"
placeholder="1"
/>
</div>
@if (isMultiDdRow(row)) {
<span class="ws-f__hint">One debit · {{ ddCountFor(row) }} drafts</span>
}
</div>
}
<div class="ws-f ws-f--amt">
<label class="ws-f__lbl" title="DD issuance charges / commission">CHARGES</label>
<div class="ws-amt-wrap">
<div class="ws-amt-wrap" [class.ws-amt-wrap--locked]="isMultiDdRow(row)">
<span class="ws-amt-prefix">₹</span>
<input
class="ws-f__ctl ws-amt-input"
@@ -421,8 +465,15 @@
(input)="row.ddCharges = +$any($event.target).value"
(click)="$event.stopPropagation()"
placeholder="0.00"
[readonly]="isMultiDdRow(row)"
[class.ws-amt-input--locked]="isMultiDdRow(row)"
/>
</div>
<!-- Charges are levied per draft and can differ between them, so on a slip they
are entered against each draft and totalled here. -->
@if (isMultiDdRow(row)) {
<span class="ws-f__hint">Set per draft</span>
}
</div>
<div class="ws-f ws-f--amt">
<label class="ws-f__lbl" title="GST on charges, at the admin-configured rate">
@@ -433,7 +484,7 @@
<input
class="ws-f__ctl ws-amt-input ws-amt-input--locked"
type="text"
[value]="ddGstAmountFor(row) | number: '1.2-2'"
[value]="ddGstDisplayFor(row)"
readonly
/>
</div>
@@ -447,7 +498,7 @@
<input
class="ws-f__ctl ws-amt-input ws-amt-input--locked"
type="text"
[value]="ddTotalPayableFor(row)"
[value]="ddTotalDisplayFor(row)"
readonly
/>
</div>
@@ -460,18 +511,27 @@
[disabled]="saving"
(click)="openIssueWalkinDd(row); $event.stopPropagation()"
[title]="
isDdAccountDebitRow(row)
? 'Auto-selects next available leaflet and opens the beneficiary form'
: 'Auto-selects next available leaflet and opens walk-in purchaser form'
isMultiDdRow(row)
? 'Opens one beneficiary form per draft; DD numbers are allocated on submission'
: isDdAccountDebitRow(row)
? 'Auto-selects next available leaflet and opens the beneficiary form'
: 'Auto-selects next available leaflet and opens walk-in purchaser form'
"
>
<mat-icon style="font-size: 18px; width: 18px; height: 18px">{{
row.ddDetails ? 'edit_note' : 'post_add'
row.ddDetails || row.ddLines?.length ? 'edit_note' : 'post_add'
}}</mat-icon>
<!-- Names the leaflet once details are captured, so the clerk can see at a
glance which rows are ready before pressing Save. -->
glance which rows are ready before pressing Save. A slip names the count
instead: its numbers are not allocated until it is submitted. -->
<span>{{
row.ddDetails ? 'DD ' + row.ddDetails.ddNumber + ' · Edit Details' : 'Fill DD Details'
row.ddLines?.length
? row.ddLines?.length + ' Drafts Captured · Edit Slip'
: row.ddDetails
? 'DD ' + row.ddDetails.ddNumber + ' · Edit Details'
: isMultiDdRow(row)
? 'Fill ' + ddCountFor(row) + ' DD Details'
: 'Fill DD Details'
}}</span>
</button>
</div>
@@ -1694,10 +1754,12 @@
[value]="row.amount ?? ''"
(input)="row.amount = +$any($event.target).value; syncShareLinkedAmounts()"
(click)="$event.stopPropagation()"
[placeholder]="row.acTyp === 'SHR' ? '0' : '0.00'"
[readonly]="!isClerk && !!(activeWithdrawalRequest || activeClosureRequest)"
[placeholder]="isMultiDdRow(row) ? 'Per draft' : row.acTyp === 'SHR' ? '0' : '0.00'"
[readonly]="
isMultiDdRow(row) || (!isClerk && !!(activeWithdrawalRequest || activeClosureRequest))
"
[class.ws-amt-input--locked]="
!isClerk && !!(activeWithdrawalRequest || activeClosureRequest)
isMultiDdRow(row) || (!isClerk && !!(activeWithdrawalRequest || activeClosureRequest))
"
/>
</div>
@@ -1708,6 +1770,19 @@
}}/share</span
>
}
<!-- On a slip the face value belongs to each draft, so the row total is the
sum of them and is filled in by the dialog rather than typed here. A
clerk-typed figure could disagree with the drafts, and the account would
be debited for a total the drafts do not add up to. -->
@if (isMultiDdRow(row)) {
<span class="ws-f__hint">
{{
row.ddLines?.length
? 'Total of ' + row.ddLines?.length + ' drafts'
: 'Set per draft in Fill DD Details'
}}
</span>
}
</div>
@if (
row.acTyp === 'SHR' && (row.typ === 'Enter Bank Transfer' || row.typ === 'Fund Transfer')
@@ -1772,9 +1847,38 @@
<div class="ws-group ws-group--flex">
<span class="ws-caption">DD Charges &amp; GST — Admin Rate {{ ddGstPercentage }}%</span>
<div class="ws-group__fields">
<!-- How many drafts this one debit funds. Typed before the details are filled
in, because it is what decides how many beneficiary forms the dialog
presents. Offered only where the server can honour it: a member
savings/current account debit, never a GL or cash draft. -->
@if (canRowBeMultiDd(row)) {
<div class="ws-f ws-f--amt">
<label
class="ws-f__lbl"
title="Number of demand drafts to raise against this single debit"
>NO. OF DD</label
>
<div class="ws-amt-wrap">
<input
class="ws-f__ctl ws-amt-input"
type="number"
min="1"
[max]="maxDdSlipLines"
step="1"
[value]="ddCountFor(row)"
(input)="onDdCountChange(row, +$any($event.target).value)"
(click)="$event.stopPropagation()"
placeholder="1"
/>
</div>
@if (isMultiDdRow(row)) {
<span class="ws-f__hint">One debit · {{ ddCountFor(row) }} drafts</span>
}
</div>
}
<div class="ws-f ws-f--amt">
<label class="ws-f__lbl" title="DD issuance charges / commission">CHARGES</label>
<div class="ws-amt-wrap">
<div class="ws-amt-wrap" [class.ws-amt-wrap--locked]="isMultiDdRow(row)">
<span class="ws-amt-prefix">₹</span>
<input
class="ws-f__ctl ws-amt-input"
@@ -1783,8 +1887,15 @@
(input)="row.ddCharges = +$any($event.target).value"
(click)="$event.stopPropagation()"
placeholder="0.00"
[readonly]="isMultiDdRow(row)"
[class.ws-amt-input--locked]="isMultiDdRow(row)"
/>
</div>
<!-- Charges are levied per draft and can differ between them, so on a slip
they are entered against each draft and totalled here. -->
@if (isMultiDdRow(row)) {
<span class="ws-f__hint">Set per draft</span>
}
</div>
<div class="ws-f ws-f--amt">
<label class="ws-f__lbl" title="GST on charges, at the admin-configured rate">
@@ -1795,7 +1906,7 @@
<input
class="ws-f__ctl ws-amt-input ws-amt-input--locked"
type="text"
[value]="ddGstAmountFor(row) | number: '1.2-2'"
[value]="ddGstDisplayFor(row)"
readonly
/>
</div>
@@ -1809,7 +1920,7 @@
<input
class="ws-f__ctl ws-amt-input ws-amt-input--locked"
type="text"
[value]="ddTotalPayableFor(row)"
[value]="ddTotalDisplayFor(row)"
readonly
/>
</div>
@@ -1822,13 +1933,28 @@
[disabled]="saving"
(click)="openIssueWalkinDd(row); $event.stopPropagation()"
[title]="
isDdAccountDebitRow(row)
? 'Auto-selects next available leaflet and opens the beneficiary form'
: 'Auto-selects next available leaflet and opens walk-in purchaser form'
isMultiDdRow(row)
? 'Opens one beneficiary form per draft; DD numbers are allocated on submission'
: isDdAccountDebitRow(row)
? 'Auto-selects next available leaflet and opens the beneficiary form'
: 'Auto-selects next available leaflet and opens walk-in purchaser form'
"
>
<mat-icon style="font-size: 18px; width: 18px; height: 18px">post_add</mat-icon>
<span>Fill DD Details</span>
<mat-icon style="font-size: 18px; width: 18px; height: 18px">{{
row.ddDetails || row.ddLines?.length ? 'edit_note' : 'post_add'
}}</mat-icon>
<!-- Names what has been captured, so the clerk can see at a glance which
rows are ready before pressing Save. A slip names its count instead:
its numbers are not allocated until it is submitted. -->
<span>{{
row.ddLines?.length
? row.ddLines?.length + ' Drafts Captured · Edit Slip'
: row.ddDetails
? 'DD ' + row.ddDetails.ddNumber + ' · Edit Details'
: isMultiDdRow(row)
? 'Fill ' + ddCountFor(row) + ' DD Details'
: 'Fill DD Details'
}}</span>
</button>
</div>
</div>
@@ -91,6 +91,40 @@ interface TxnRow {
fundingAccountId?: number;
fundingClientId?: number;
};
/**
* How many drafts this one row raises. 1 (or unset) is the ordinary single draft; 2 or more make
* the row a multi-DD slip — one debit funding several drafts, each to its own beneficiary.
*
* Typed by the clerk BEFORE opening the dialog, so the dialog knows how many beneficiary forms to
* present. Only a member-account debit can be a slip (see canRowBeMultiDd()).
*/
ddCount?: number | null;
/**
* The per-draft lines of a multi-DD slip, captured in the dialog and held here until Save — the
* same "fill now, commit on Save" contract as ddDetails above.
*
* Each line carries its OWN face value and charges: a slip's drafts are routinely for different
* amounts to different beneficiaries, and the charge is per draft rather than per slip. That is
* why the row's own AMOUNT and CHARGES fields are locked and derived from these lines while a
* slip is being built — a clerk-typed row total could disagree with the lines, and the account
* would then be debited for a figure the drafts don't add up to.
*/
ddLines?: DdSlipLine[];
/** The inventory the slip's leaflets are drawn from. The batch endpoint allocates by inventory
* rather than by leaflet, since the numbers are only picked inside its own locked transaction. */
ddInventoryId?: number;
/** Resolved funding account for a slip, captured alongside the lines for the same reason. */
ddFundingAccountId?: number;
ddFundingClientId?: number;
}
/** One draft on a multi-DD slip — its own beneficiary, its own face value, its own charges. */
interface DdSlipLine {
beneficiaryName: string;
payableAtBranch: string;
payableAtCity: string;
faceValue: number;
chargesAmount: number;
}
interface Denomination {
@@ -2415,19 +2449,159 @@ export class TransactionEntryComponent implements OnInit, OnDestroy {
*/
entryTypeOptions: { id: number; name: string }[] = [];
/** GST on DD charges only (never on the DD face value), at the admin-configured rate. */
/**
* Whether this row may raise more than one draft off a single debit.
*
* Any funding source the clerk debits: a member's savings/current account, or a GL account for an
* institutional slip. Cash is the exception the batch endpoint still refuses, and for a real
* reason rather than an arbitrary one — a slip is funded by one payment, and one counted pile of
* notes cannot be apportioned across drafts without the denomination total ceasing to prove what
* was actually taken at the counter.
*/
canRowBeMultiDd(row: TxnRow): boolean {
return this.isDdAccountDebitRow(row);
}
/** Whether the clerk has asked this row for more than one draft. */
isMultiDdRow(row: TxnRow): boolean {
return this.canRowBeMultiDd(row) && (Number(row.ddCount) || 1) > 1;
}
/** How many drafts this row raises — always at least 1. */
ddCountFor(row: TxnRow): number {
const n = Number(row.ddCount) || 1;
return n > 1 ? n : 1;
}
/**
* Clamps a typed DD count into range and discards lines that no longer fit.
*
* Lowering the count after the dialog has been filled would otherwise leave lines beyond the new
* count still on the row: invisible to the clerk, but still sent at Save and still debited for.
*/
onDdCountChange(row: TxnRow, value: number): void {
const n = Math.floor(Number(value) || 1);
const previous = this.ddCountFor(row);
row.ddCount = n < 1 ? 1 : n > this.maxDdSlipLines ? this.maxDdSlipLines : n;
if (row.ddLines && row.ddLines.length !== row.ddCount) {
this.clearDdCapture(row);
}
// Moving onto a slip empties the row's own amount and charge, because on a slip they mean
// nothing: each draft carries its own face value and its own charge, entered in the dialog, and
// these two fields become the total of those. Leaving a previously typed figure sitting here
// would read as the slip's total while describing only one draft — which is exactly what showed
// ₹1,012 against four drafts of ₹1,000.
if (row.ddCount > 1 && previous <= 1) {
row.amount = null;
row.ddCharges = null;
}
}
/** Mirrors MAX_SLIP_LINES on the server, so an over-long slip is refused here with a clear message. */
readonly maxDdSlipLines = 20;
/**
* The run of leaflet numbers a slip will probably draw, for display only.
*
* Counts up from the next-available number, preserving its width so 000123 reads as 000124 rather
* than 124. Indicative by nature — the binding allocation happens inside the server's submit
* transaction, and a slip filed after another clerk's gets the numbers following theirs — which is
* why the dialog labels these provisional and the confirmed numbers come back on submission.
*
* A number that is not a plain run of digits (a prefixed or hyphenated series) cannot be counted
* on safely, so nothing is shown rather than something wrong.
*/
private previewDdNumbersFrom(firstDdNumber: string, count: number): string[] | undefined {
const first = (firstDdNumber || '').trim();
if (!/^\d+$/.test(first)) return undefined;
const width = first.length;
const start = Number(first);
if (!Number.isSafeInteger(start)) return undefined;
return Array.from({ length: count }, (_v, i) => String(start + i).padStart(width, '0'));
}
/** GST on a single charge figure, at the admin-configured rate. */
private ddGstOn(charges: number): number {
const c = Number(charges) || 0;
if (c <= 0) return 0;
return Math.round(c * this.ddGstPercentage) / 100;
}
/**
* GST on DD charges only (never on the DD face value), at the admin-configured rate.
*
* On a slip this is the sum of each line's own GST rather than GST on the summed charges. The two
* can differ by a paisa once rounding is applied per line, and it is the per-line figure that is
* stored against each draft and posted by the server — so the row must show that same total.
*/
ddGstAmountFor(row: TxnRow): number {
const charges = Number(row.ddCharges) || 0;
if (charges <= 0) return 0;
return Math.round(charges * this.ddGstPercentage) / 100;
if (row.ddLines?.length) {
return row.ddLines.reduce((sum, l) => sum + this.ddGstOn(l.chargesAmount), 0);
}
return this.ddGstOn(row.ddCharges ?? 0);
}
/** Face value of the row — the sum of every line once a slip has been captured. */
ddFaceValueFor(row: TxnRow): number {
if (row.ddLines?.length) {
return row.ddLines.reduce((sum, l) => sum + (Number(l.faceValue) || 0), 0);
}
return Number(row.amount) || 0;
}
/** Charges on the row — the sum of every line's own charge once a slip has been captured. */
ddChargesFor(row: TxnRow): number {
if (row.ddLines?.length) {
return row.ddLines.reduce((sum, l) => sum + (Number(l.chargesAmount) || 0), 0);
}
return Number(row.ddCharges) || 0;
}
/** Amount + Charges + GST, rounded UP to the next whole rupee — the total collected from the customer. */
ddTotalPayableFor(row: TxnRow): number {
const amount = Number(row.amount) || 0;
const charges = Number(row.ddCharges) || 0;
const gst = this.ddGstAmountFor(row);
return Math.ceil(amount + charges + gst - 1e-9);
return Math.ceil(this.ddFaceValueFor(row) + this.ddChargesFor(row) + this.ddGstAmountFor(row) - 1e-9);
}
/**
* What the TOTAL field shows — a figure, or a dash while a slip has nothing to total yet.
*
* A slip whose drafts have not been entered has no total: its face values and charges live on the
* lines, and there are no lines. Showing ₹0 there would read as a real total of zero rather than
* as "not yet known", so the field says so instead.
*/
ddTotalDisplayFor(row: TxnRow): string {
if (this.isSlipAwaitingDetails(row)) {
return '—';
}
return String(this.ddTotalPayableFor(row));
}
/** GST field's text, dashed for the same reason as the total while a slip is still empty. */
ddGstDisplayFor(row: TxnRow): string {
if (this.isSlipAwaitingDetails(row)) {
return '—';
}
return this.ddGstAmountFor(row).toFixed(2);
}
/** A slip the clerk has asked for but not yet filled in — nothing to total from. */
isSlipAwaitingDetails(row: TxnRow): boolean {
return this.isMultiDdRow(row) && !row.ddLines?.length;
}
/**
* Forgets a row's captured draft details, leaving the row itself intact.
*
* Both shapes are cleared together on purpose: a row carrying a stale ddDetails alongside fresh
* ddLines (or the reverse) would be read by whichever of the two Save paths it reached first, and
* the clerk would have no way of seeing which one went.
*/
private clearDdCapture(row: TxnRow): void {
row.ddDetails = undefined;
row.ddLines = undefined;
row.ddInventoryId = undefined;
row.ddFundingAccountId = undefined;
row.ddFundingClientId = undefined;
}
get isSelectedRowDdCashGl(): boolean {
@@ -2468,11 +2642,22 @@ export class TransactionEntryComponent implements OnInit, OnDestroy {
]);
return;
}
// On a slip the face value lives on each line, typed in the dialog that is about to open — the
// row's own AMOUNT is the sum of those lines and cannot exist yet. Demanding it here would make
// a slip impossible to start.
const isSlip = this.isMultiDdRow(row);
const faceValue = Number(row.amount);
if (!faceValue || faceValue <= 0) {
if (!isSlip && (!faceValue || faceValue <= 0)) {
this.showAlert('error', 'Invalid Amount', ['Please enter the DD Face Value amount before proceeding.']);
return;
}
if (isSlip && !this.isDdAccountDebitRow(row)) {
this.showAlert('error', 'Multiple DDs Not Available', [
'More than one draft can only be raised against a member savings or current account.',
'Set NO. OF DD back to 1 for this row.'
]);
return;
}
const chargesAmount = Number(row.ddCharges) || 0;
const gstAmount = this.ddGstAmountFor(row);
const totalCashPayable = this.ddTotalPayableFor(row);
@@ -2508,8 +2693,14 @@ export class TransactionEntryComponent implements OnInit, OnDestroy {
}
// Only a cash draft has notes to reconcile. An account debit takes no cash at the counter, so a
// denomination count left over from an earlier row must not be checked against it.
if (!accountDebit && this.denominationTotal > 0 && Math.abs(this.denominationTotal - totalCashPayable) > 0.005) {
// denomination count left over from an earlier row must not be checked against it. A slip is
// always an account debit, and its total is not known until the dialog is filled in either.
if (
!accountDebit &&
!isSlip &&
this.denominationTotal > 0 &&
Math.abs(this.denominationTotal - totalCashPayable) > 0.005
) {
this.showAlert('error', 'Denomination Mismatch', [
`Cash denomination counted (₹${this.denominationTotal}) does not match total payable cash (₹${totalCashPayable}).`,
'Please verify and match the physical cash denomination count.'
@@ -2532,8 +2723,19 @@ export class TransactionEntryComponent implements OnInit, OnDestroy {
const selectedBank = this.sponsorBanks.find((b: any) => b.id === row.sponsorBankId);
const branchName = this.branchNameFor(row.br) || 'Branch ' + row.br;
// A slip is allocated by inventory, not leaflet: the server picks the whole run inside its
// own locked transaction. Without the inventory behind this branch's next-available leaflet
// there is nothing to allocate from, so the slip is refused here rather than at submit.
if (isSlip && !leaflet.inventoryId) {
this.showAlert('error', 'DD Inventory Not Identified', [
'The DD inventory behind this branch and sponsor bank could not be identified, so multiple drafts cannot be allocated together.',
'Set NO. OF DD back to 1, or ask an administrator to check the DD inventory registration.'
]);
return;
}
const dialogRef = this.dialog.open(CashierIssueDdDialogComponent, {
width: '600px',
width: isSlip ? '760px' : '600px',
disableClose: true,
data: {
leaflet,
@@ -2546,7 +2748,10 @@ export class TransactionEntryComponent implements OnInit, OnDestroy {
totalCashPayable,
paymentMethod: accountDebit ? ('ACCOUNT_DEBIT' as const) : ('CASH' as const),
accountNo: accountDebit ? row.acNo : undefined,
accountHolderName: accountDebit ? this.rowAccountNames[(row.acNo || '').trim()] : undefined
accountHolderName: accountDebit ? this.rowAccountNames[(row.acNo || '').trim()] : undefined,
ddCount: isSlip ? this.ddCountFor(row) : undefined,
glFunded: isGlDebit,
previewDdNumbers: isSlip ? this.previewDdNumbersFrom(leaflet.ddNumber, this.ddCountFor(row)) : undefined
}
});
@@ -2555,6 +2760,31 @@ export class TransactionEntryComponent implements OnInit, OnDestroy {
// review the entered details, and no way to abandon them either.
dialogRef.afterClosed().subscribe((result) => {
if (!result) return;
// A slip records its lines instead of a single beneficiary, and takes the row's AMOUNT and
// CHARGES with it: from here on those two are the sum of the lines, shown read-only, so a
// clerk cannot leave a row total behind that disagrees with what the drafts add up to.
if (result.lines) {
this.clearDdCapture(row);
row.ddLines = result.lines;
row.ddInventoryId = leaflet.inventoryId;
row.ddFundingAccountId = fundingAccountId!;
// Null on a GL slip, which is raised against no member at all.
row.ddFundingClientId = fundingClientId ?? undefined;
row.amount = this.ddFaceValueFor(row);
row.ddCharges = this.ddChargesFor(row);
this.showAlert('success', 'Slip Details Captured', [
`${result.lines.length} demand drafts captured in favour of ${result.lines
.map((l: DdSlipLine) => l.beneficiaryName)
.join(', ')}.`,
`₹${this.ddTotalPayableFor(row)} will be debited from ${isGlDebit ? 'GL account ' : ''}${row.acNo} as a single ${
isGlDebit ? 'journal entry' : 'transaction'
} once a checker approves.`,
'DD numbers are allocated on submission. Press Save to send this slip for approval.'
]);
return;
}
row.ddDetails = {
leafletId: leaflet.id,
ddNumber: leaflet.ddNumber,
@@ -2658,7 +2888,7 @@ export class TransactionEntryComponent implements OnInit, OnDestroy {
* got none has no way of telling which row was missing.
*/
private saveDdRows(ddRows: TxnRow[]): void {
const unfilled = ddRows.filter((r) => !r.ddDetails);
const unfilled = ddRows.filter((r) => !r.ddDetails && !r.ddLines?.length);
if (unfilled.length > 0) {
this.showAlert('error', 'DD Details Not Entered', [
unfilled.length === 1
@@ -2670,6 +2900,14 @@ export class TransactionEntryComponent implements OnInit, OnDestroy {
}
for (const row of ddRows) {
// A slip goes to the batch endpoint, which allocates its leaflets and posts one debit for the
// whole run. Sending its lines one at a time through the single-DD endpoint would debit the
// member once per draft — the very thing a slip exists to avoid.
if (row.ddLines?.length) {
this.submitDdSlip(row);
continue;
}
const details = row.ddDetails!;
const leaflet = { id: details.leafletId, ddNumber: details.ddNumber };
if (this.isDdAccountDebitRow(row)) {
@@ -2697,6 +2935,99 @@ export class TransactionEntryComponent implements OnInit, OnDestroy {
}
}
/**
* Submits a multi-DD slip: several drafts raised together against one debit of the member's
* account.
*
* Maker-checker like the single-draft flow, and nothing is debited here. On approval the server
* withdraws the combined face value, charges and GST of every line as ONE transaction and posts
* one journal entry against it, then issues each draft — so the member's passbook carries a single
* entry naming all the DD numbers rather than one entry per draft. The checker approves or rejects
* the slip whole; there is no approving four drafts out of five.
*
* The leaflets are not chosen here. The server allocates the run inside its own locked transaction
* and returns which number went to which line, which is the only point at which the numbers are
* real — two clerks submitting at the same moment can therefore never be handed the same one.
*/
private submitDdSlip(row: TxnRow): void {
const lines = row.ddLines ?? [];
const inventoryId = row.ddInventoryId;
const fundingAccountId = row.ddFundingAccountId;
const fundingClientId = row.ddFundingClientId;
const isGl = row.acTyp === 'GL';
// Re-checked at Save, not just when the details were captured: the row's TYPE, AC.TP or account
// number can all be edited afterwards, and a slip that has drifted onto a cash row is one the
// batch endpoint refuses outright. A GL slip is raised against no member, so it carries no
// client id and none is required of it — the same asymmetry as the single-draft GL path.
if (!inventoryId || !fundingAccountId || (!isGl && !fundingClientId) || !this.canRowBeMultiDd(row)) {
this.showAlert('error', 'Slip Cannot Be Submitted', [
'This row has changed since its drafts were captured, so the slip no longer matches it.',
'Open "Fill DD Details" again and re-enter the drafts.'
]);
return;
}
const d = new Date();
const issuedDate = `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, '0')}-${String(d.getDate()).padStart(2, '0')}`;
const isCurrent = row.acTyp === 'CA' || row.acTyp === 'CB';
// The funding source goes in exactly one of savingsAccountId, currentAccountId or glAccountId,
// as the single-draft path does: which one is set is what decides the debit leg on approval.
const payload = {
clientId: isGl ? undefined : fundingClientId,
issuingBranchId: row.br,
issuedDate,
paymentMethod: (isGl ? 'GL' : 'ACCOUNT_DEBIT') as 'GL' | 'ACCOUNT_DEBIT',
savingsAccountId: isGl || isCurrent ? undefined : fundingAccountId,
currentAccountId: !isGl && isCurrent ? fundingAccountId : undefined,
glAccountId: isGl ? fundingAccountId : undefined,
lines: lines.map((l) => ({
beneficiaryName: l.beneficiaryName,
payableAtBranch: l.payableAtBranch,
payableAtCity: l.payableAtCity,
faceValue: Number(l.faceValue),
chargesAmount: Number(l.chargesAmount) || 0
}))
};
const totalDebited = this.ddTotalPayableFor(row);
const accountNo = row.acNo;
this.saving = true;
this.sponsorDDService.submitIssueRequestBatch(inventoryId, payload).subscribe({
next: (result: any) => {
this.saving = false;
// The allocated numbers, named back to the clerk. Until this response they were provisional,
// and these are what the customer's drafts will actually carry.
const allocated = (result?.lines ?? [])
.map((l: any) => `${l.ddNumber} · ${l.beneficiaryName} · ₹${l.faceValue}`)
.join('\n');
this.showAlert('success', 'DD Slip Submitted', [
`Slip of ${lines.length} demand drafts submitted successfully.`,
allocated || `${lines.length} drafts allocated.`,
`₹${totalDebited} will be debited from ${accountNo} as a single transaction on approval.`,
'Sent to Manager / Head Cashier for maker-checker approval of the whole slip.'
]);
row.amount = null;
row.ddCharges = null;
row.description = '';
row.ddCount = 1;
// Cleared with the row, as the single-draft paths do: leaving the lines behind would let a
// second Save raise the same slip again against leaflets already allocated.
this.clearDdCapture(row);
},
error: (err) => {
this.saving = false;
this.showAlert('error', 'DD Slip Submission Failed', [
err.error?.message || err.message,
'No drafts were issued and nothing has been debited — the whole slip was refused together.'
]);
}
});
}
private submitWalkinCashDd(row: TxnRow, leaflet: any, purchaserData: any): void {
// Read through countFor, which knows the map is keyed by kind:value. Indexing by d.id looked
// right but matched nothing, so every cash DD went in with no breakdown at all and its money
+59 -23
View File
@@ -883,7 +883,11 @@
(click)="subLedgerOpen = false"
><fa-icon icon="balance-scale" class="gl-icon"></fa-icon> Suit File Entry</a
>
<a class="gl-link" [class.hidden]="!matchesSubLedgerSearch('Locker Entry')" (click)="subLedgerOpen = false"
<a
class="gl-link"
[class.hidden]="!matchesSubLedgerSearch('Locker Entry')"
routerLink="/locker"
(click)="subLedgerOpen = false"
><fa-icon icon="lock" class="gl-icon"></fa-icon> Locker Entry</a
>
<a
@@ -1175,14 +1179,29 @@
<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"><fa-icon icon="coins" class="menu-item-icon"></fa-icon> DDS</button>
<button mat-menu-item tabindex="0" [routerLink]="['/accounts/dds']" (click)="depositsMenu.closed.emit()">
<fa-icon icon="coins" class="menu-item-icon"></fa-icon> DDS Party Master
</button>
<button
mat-menu-item
tabindex="0"
[routerLink]="['/accounts/dds-accounts']"
(click)="depositsMenu.closed.emit()"
>
<fa-icon icon="list-alt" class="menu-item-icon"></fa-icon> DDS Accounts
</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">
<button
mat-menu-item
tabindex="0"
[routerLink]="['/accounts/dds-collection']"
(click)="depositsMenu.closed.emit()"
>
<fa-icon icon="calendar-check" class="menu-item-icon"></fa-icon> Today Collection
</button>
<button mat-menu-item tabindex="0">
@@ -1197,6 +1216,14 @@
</div>
<div class="deposit-menu-group">
<div class="deposit-group-title">Reports</div>
<button
mat-menu-item
tabindex="0"
[routerLink]="['/accounts/dds-reports']"
(click)="depositsMenu.closed.emit()"
>
<fa-icon icon="coins" class="menu-item-icon"></fa-icon> DDS Reports
</button>
<button mat-menu-item tabindex="0" [routerLink]="['/savings-daily-interest']">
<fa-icon icon="chart-line" class="menu-item-icon"></fa-icon> Savings Daily Interest
</button>
@@ -1300,72 +1327,79 @@
<!-- Share Operations -->
<div class="deposit-menu-group">
<div class="deposit-group-title">Share Operations</div>
<button mat-menu-item tabindex="0">
<button mat-menu-item tabindex="0" [routerLink]="['/shares/reports/nominal-shares']">
<fa-icon icon="coins" class="menu-item-icon"></fa-icon> Nominal Share Report
</button>
<button mat-menu-item tabindex="0">
<button mat-menu-item tabindex="0" [routerLink]="['/shares/reports/share-wise']">
<fa-icon icon="file-alt" class="menu-item-icon"></fa-icon> Share Wise Report
</button>
<button mat-menu-item tabindex="0">
<button mat-menu-item tabindex="0" [routerLink]="['/shares/reports/guarantor-detail']">
<fa-icon icon="user-shield" class="menu-item-icon"></fa-icon> Guarantor Detail
</button>
<button mat-menu-item tabindex="0">
<button mat-menu-item tabindex="0" [routerLink]="['/shares/reports/member-interest']">
<fa-icon icon="calculator" class="menu-item-icon"></fa-icon> Total Int Rec
</button>
<button mat-menu-item tabindex="0">
<button mat-menu-item tabindex="0" [routerLink]="['/shares/reports/enquiry']">
<fa-icon icon="search" class="menu-item-icon"></fa-icon> Share Enquiry
</button>
</div>
<!-- Share Documents -->
<div class="deposit-menu-group">
<div class="deposit-group-title">Share Documents</div>
<button mat-menu-item tabindex="0">
<button mat-menu-item tabindex="0" [routerLink]="['/shares/reports/payment-certificate']">
<fa-icon icon="file-signature" class="menu-item-icon"></fa-icon> Share Payment Certificate
</button>
<button mat-menu-item tabindex="0">
<button mat-menu-item tabindex="0" [routerLink]="['/shares/reports/share-detail']">
<fa-icon icon="list-alt" class="menu-item-icon"></fa-icon> Share Detail
</button>
<button mat-menu-item tabindex="0">
<button mat-menu-item tabindex="0" [routerLink]="['/shares/reports/dividend-statement']">
<fa-icon icon="dollar-sign" class="menu-item-icon"></fa-icon> Dividend Statement
</button>
<button mat-menu-item tabindex="0">
<button mat-menu-item tabindex="0" [routerLink]="['/shares/transfers']">
<fa-icon icon="exchange" class="menu-item-icon"></fa-icon> Share Transfer
</button>
<button mat-menu-item tabindex="0">
<button mat-menu-item tabindex="0" [routerLink]="['/shares/reports/folio-statement']">
<fa-icon icon="file-alt" class="menu-item-icon"></fa-icon> Folio Statement
</button>
</div>
<!-- Share Reports -->
<div class="deposit-menu-group">
<div class="deposit-group-title">Share Reports</div>
<button mat-menu-item tabindex="0">
<button mat-menu-item tabindex="0" [routerLink]="['/shares/reports/final-balance']">
<fa-icon icon="chart-line" class="menu-item-icon"></fa-icon> Final Balance
</button>
<button mat-menu-item tabindex="0">
<button mat-menu-item tabindex="0" [routerLink]="['/shares/reports/account-statement']">
<fa-icon icon="file-invoice" class="menu-item-icon"></fa-icon> Account Statement
</button>
<button mat-menu-item tabindex="0">
<button mat-menu-item tabindex="0" [routerLink]="['/shares/reports/share-issued']">
<fa-icon icon="check-circle" class="menu-item-icon"></fa-icon> Share Issued
</button>
<button mat-menu-item tabindex="0" [routerLink]="['/shares/share-holders']">
<fa-icon icon="users" class="menu-item-icon"></fa-icon> Share Holder
</button>
<button mat-menu-item tabindex="0">
<button mat-menu-item tabindex="0" [routerLink]="['/shares/reports/share-status']">
<fa-icon icon="info-circle" class="menu-item-icon"></fa-icon> Share Status
</button>
<button mat-menu-item tabindex="0" [routerLink]="['/shares/reports/product-summary']">
<fa-icon icon="layer-group" class="menu-item-icon"></fa-icon> Product Summary
</button>
</div>
<!-- Share Admin -->
<div class="deposit-menu-group">
<div class="deposit-group-title">Share Admin</div>
<button mat-menu-item tabindex="0">
<button mat-menu-item tabindex="0" [routerLink]="['/shares/reports/distinctive-numbers']">
<fa-icon icon="tag" class="menu-item-icon"></fa-icon> Share Devede No Detail
</button>
<button mat-menu-item tabindex="0"><fa-icon icon="map" class="menu-item-icon"></fa-icon> Area Wise</button>
<button mat-menu-item tabindex="0"><fa-icon icon="tag" class="menu-item-icon"></fa-icon> Share Label</button>
<button mat-menu-item tabindex="0">
<button mat-menu-item tabindex="0" [routerLink]="['/shares/reports/area-wise']">
<fa-icon icon="map" class="menu-item-icon"></fa-icon> Area Wise
</button>
<button mat-menu-item tabindex="0" [routerLink]="['/shares/reports/labels']">
<fa-icon icon="tag" class="menu-item-icon"></fa-icon> Share Label
</button>
<button mat-menu-item tabindex="0" [routerLink]="['/shares/reports/letters']">
<fa-icon icon="envelope" class="menu-item-icon"></fa-icon> Share Letter
</button>
<button mat-menu-item tabindex="0">
<button mat-menu-item tabindex="0" [routerLink]="['/letter-mgmt/share-member']">
<fa-icon icon="id-card" class="menu-item-icon"></fa-icon> Share Member Details
</button>
</div>
@@ -1387,7 +1421,9 @@
<button mat-menu-item tabindex="0">
<fa-icon icon="user-slash" class="menu-item-icon" style="color: #c2410c"></fa-icon> Non-Customer Entry
</button>
<button mat-menu-item tabindex="0"><fa-icon icon="lock" class="menu-item-icon"></fa-icon> Locker</button>
<button mat-menu-item routerLink="/locker" tabindex="0">
<fa-icon icon="lock" class="menu-item-icon"></fa-icon> Locker
</button>
<button mat-menu-item routerLink="/investment/bonds" tabindex="0">
<fa-icon icon="chart-line" class="menu-item-icon"></fa-icon> SLR and Non SLR Investment Report
</button>
@@ -0,0 +1,381 @@
<!--
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="dialog-wrapper">
<div class="dialog-header">
<div class="header-icon">
<mat-icon>account_balance_wallet</mat-icon>
</div>
<div class="header-text">
<h2>Apply DDS Receipt</h2>
<p>
Agent <strong class="mono">{{ data.agentCode }}</strong> · {{ data.agentName }}
@if (data.glCode) {
· GL <strong class="mono">{{ data.glCode }}</strong>
}
</p>
</div>
<button type="button" mat-icon-button class="header-close" [mat-dialog-close]="false" aria-label="Close">
<mat-icon>close</mat-icon>
</button>
</div>
<mat-dialog-content class="dialog-body">
@if (loading) {
<div class="state-block">
<mat-spinner diameter="30"></mat-spinner>
<span>Loading entries and receipts…</span>
</div>
} @else {
<!-- What the picked vouchers have to cover, and whether they do. -->
<div class="sc-card">
<div class="sc-row" [class.sc-ok]="canApply" [class.sc-bad]="pickedReceipts.size > 0 && difference !== 0">
<div class="sc-left">
<span class="sc-label">Collections up to {{ data.entryDate | date: 'dd/MM/yyyy' }}</span>
<span class="sc-sub"
>{{ pickedEntryCount }} {{ pickedEntryCount === 1 ? 'entry' : 'entries' }} to settle</span
>
</div>
<div class="sc-right">
<span class="sc-val">{{ required | number: '1.2-2' }}</span>
</div>
</div>
<div
class="sc-row sc-sep"
[class.sc-ok]="canApply"
[class.sc-bad]="pickedReceipts.size > 0 && difference !== 0"
>
<div class="sc-left">
<span class="sc-label">Vouchers picked</span>
<span class="sc-sub"
>{{ pickedReceipts.size }} {{ pickedReceipts.size === 1 ? 'voucher' : 'vouchers' }}</span
>
</div>
<div class="sc-right">
<span
class="sc-val"
[class.sc-val-ok]="canApply"
[class.sc-val-bad]="pickedReceipts.size > 0 && difference !== 0"
>
{{ received | number: '1.2-2' }}
</span>
@if (pickedReceipts.size > 0 && pickedDays.size > 0 && difference < 0) {
<span class="sc-tag sc-tag-bad">Short {{ -difference | number: '1.2-2' }}</span>
} @else if (pickedReceipts.size > 0 && pickedDays.size > 0 && difference > 0) {
<span class="sc-tag sc-tag-bad">Over {{ difference | number: '1.2-2' }}</span>
} @else if (canApply) {
<span class="sc-tag sc-tag-ok">✓ Matches</span>
}
</div>
</div>
<div class="sc-total-row" [class.sc-total-ok]="canApply">
<span class="sc-total-lbl">Total to Apply</span>
<div class="sc-total-nums">
<span class="sc-total-req mono">{{ required | number: '1.2-2' }}</span>
<span class="sc-arrow">→</span>
<span class="sc-total-got mono" [class.sc-total-got-ok]="canApply">{{ received | number: '1.2-2' }}</span>
<span class="sc-status-pill" [class.sc-pill-ok]="canApply" [class.sc-pill-pending]="!canApply">
{{ canApply ? '✓ Ready' : 'Pending' }}
</span>
</div>
</div>
</div>
<!-- ── 1: the days the agent wrote down ──────────────────────────────────── -->
<section class="slot">
<header class="slot-head">
<span class="slot-step">1</span>
<div class="slot-title">
<h3>Outstanding up to {{ data.entryDate | date: 'dd/MM/yyyy' }}</h3>
<p>Everything the agent wrote down and has not handed in, day by day. Open a day to see its lines.</p>
</div>
</header>
@if (days.length) {
<ul class="options">
@for (day of days; track day.date) {
<li class="option" (click)="toggleOpen(day, $event)">
<div class="option-main">
<span class="option-voucher mono">{{ day.date | date: 'dd/MM/yyyy' }}</span>
<span class="option-meta"
>{{ day.entries.length }} {{ day.entries.length === 1 ? 'entry' : 'entries' }}</span
>
</div>
<span class="option-amount mono">{{ day.total | number: '1.2-2' }}</span>
<button
type="button"
class="option-info"
(click)="toggleOpen(day, $event)"
aria-label="Show the day's lines"
>
<mat-icon>{{ openDay === day.date ? 'expand_less' : 'expand_more' }}</mat-icon>
</button>
</li>
@if (openDay === day.date) {
<li class="day-lines">
@if (day.entries.length > 8) {
<input
class="day-search"
type="text"
[(ngModel)]="daySearch"
placeholder="Search party code or name"
autocomplete="off"
/>
}
<div class="day-scroll">
<table class="entries">
<tbody>
@for (entry of visibleEntries(day); track entry.id) {
<tr>
<td class="entry-code">{{ entry.partyCode }}</td>
<td class="entry-name">{{ entry.partyName }}</td>
<td>
{{ entry.transactionMode }}
@if (entry.advanceDays) {
· {{ entry.advanceDays }}d advance
}
@if (entry.arrearsDays) {
· {{ entry.arrearsDays }}d previous
}
</td>
<td class="num mono">{{ entry.amount | number: '1.2-2' }}</td>
</tr>
} @empty {
<tr>
<td colspan="4" class="entry-name">No line matches.</td>
</tr>
}
</tbody>
</table>
</div>
</li>
}
}
</ul>
} @else {
<p class="slot-empty">
{{ data.agentName }} has nothing outstanding for {{ data.entryDate | date: 'dd/MM/yyyy' }}. Change the date
on the screen to the day the entries were written.
</p>
}
</section>
<!-- ── 2: the cashier's receipts ─────────────────────────────────────────── -->
<section class="slot">
<header class="slot-head">
<span class="slot-step">2</span>
<div class="slot-title">
<h3>DDS Cash Receipts</h3>
<p>Tick the vouchers. Posts Dr Agent GL / Cr DDS Customer Deposits.</p>
</div>
<button type="button" class="slot-search" (click)="openSearch()">
<mat-icon>search</mat-icon>
<span>Find by voucher</span>
</button>
</header>
@if (available.length) {
<ul class="options">
@for (option of available; track option.id) {
<li class="option" [class.option-picked]="pickedReceipts.has(option.id)" (click)="toggleReceipt(option)">
<mat-icon class="option-check">{{
pickedReceipts.has(option.id) ? 'check_box' : 'check_box_outline_blank'
}}</mat-icon>
<div class="option-main">
<span class="option-voucher mono">{{ option.voucherNo }}</span>
<span class="option-meta">{{ option.txnDate }} · {{ glLabel(option) }}</span>
@if (option.description) {
<span class="option-desc">{{ option.description }}</span>
}
</div>
<span class="option-amount mono">{{ option.amount | number: '1.2-2' }}</span>
<button type="button" class="option-info" (click)="openDetail(option, $event)" aria-label="GL entries">
<mat-icon>receipt_long</mat-icon>
</button>
</li>
}
</ul>
} @else {
<p class="slot-empty">
No DDS receipt is waiting on this agent's GL account. Collect the cash at
<em>Cashier › Transaction Entry</em> with entry type <strong>DDS</strong> and have the manager approve it,
or find an earlier voucher by number.
</p>
}
@if (pickedReceipts.size > 0 && pickedDays.size > 0 && difference !== 0) {
<p class="slot-note note-bad">
The vouchers ({{ received | number: '1.2-2' }}) have to match the day's entries ({{
required | number: '1.2-2'
}}) exactly, so every customer is credited what the agent wrote down.
</p>
}
</section>
@if (errorMsg && !searchOpen) {
<div class="alert">
<mat-icon>error_outline</mat-icon>
<span>{{ errorMsg }}</span>
</div>
}
}
</mat-dialog-content>
<mat-dialog-actions class="dialog-actions">
<button type="button" mat-stroked-button [mat-dialog-close]="false" [disabled]="submitting">Cancel</button>
<button type="button" mat-flat-button color="primary" [disabled]="!canApply" (click)="apply()">
@if (submitting) {
<mat-spinner diameter="16"></mat-spinner>
<span>Applying…</span>
} @else {
<mat-icon>check</mat-icon>
<span>Apply Receipt</span>
}
</button>
</mat-dialog-actions>
</div>
<!-- ── Find a voucher the list does not carry ───────────────────────────────── -->
@if (searchOpen) {
<div class="overlay" (click)="closeSearch()">
<div class="popup" (click)="$event.stopPropagation()">
<header class="popup-head">
<h3>Find a DDS receipt</h3>
<button type="button" mat-icon-button (click)="closeSearch()" aria-label="Close">
<mat-icon>close</mat-icon>
</button>
</header>
<div class="popup-body">
<label class="field">
<span>GL Account</span>
<div class="field-readonly mono">{{ data.glCode || '—' }} · {{ data.agentCode }}</div>
</label>
<label class="field">
<span>Voucher No.</span>
<input
type="text"
[(ngModel)]="searchVoucherNo"
placeholder="CR/24092026/0001"
(keyup.enter)="runSearch()"
autocomplete="off"
/>
</label>
@if (errorMsg) {
<div class="alert">
<mat-icon>error_outline</mat-icon>
<span>{{ errorMsg }}</span>
</div>
}
</div>
<footer class="popup-foot">
<button type="button" mat-stroked-button (click)="closeSearch()">Cancel</button>
<button
type="button"
mat-flat-button
color="primary"
[disabled]="!searchVoucherNo.trim() || searching"
(click)="runSearch()"
>
@if (searching) {
<mat-spinner diameter="16"></mat-spinner>
} @else {
<span>Find &amp; Use</span>
}
</button>
</footer>
</div>
</div>
}
<!-- ── A receipt's own GL entries ───────────────────────────────────────────── -->
@if (viewing) {
<div class="overlay" (click)="closeDetail()">
<div class="popup" (click)="$event.stopPropagation()">
<header class="popup-head">
<h3>
Voucher <span class="mono">{{ viewing.voucherNo }}</span>
</h3>
<button type="button" mat-icon-button (click)="closeDetail()" aria-label="Close">
<mat-icon>close</mat-icon>
</button>
</header>
<div class="popup-body">
<dl class="detail-list">
<div>
<dt>Date</dt>
<dd class="mono">{{ viewing.txnDate }}</dd>
</div>
<div>
<dt>Amount</dt>
<dd class="mono">{{ viewing.amount | number: '1.2-2' }}</dd>
</div>
<div>
<dt>GL Account</dt>
<dd>{{ glLabel(viewing) }}</dd>
</div>
@if (viewing.cashierName) {
<div>
<dt>Collected By</dt>
<dd>{{ viewing.cashierName }}</dd>
</div>
}
@if (viewing.managerName) {
<div>
<dt>Approved By</dt>
<dd>{{ viewing.managerName }}</dd>
</div>
}
@if (viewing.description) {
<div>
<dt>Description</dt>
<dd>{{ viewing.description }}</dd>
</div>
}
</dl>
<h4 class="detail-heading">Journal Entries</h4>
@if (loadingGlEntries) {
<div class="state-block"><mat-spinner diameter="22"></mat-spinner></div>
} @else if (viewingGlEntries?.length) {
<table class="gl-table">
<thead>
<tr>
<th>Account</th>
<th class="num">Debit</th>
<th class="num">Credit</th>
</tr>
</thead>
<tbody>
@for (entry of viewingGlEntries; track entry.id) {
<tr>
<td>
<span class="mono">{{ entry.glCode }}</span> {{ entry.glName }}
</td>
<td class="num mono">{{ entry.drCr === 'DR' ? (entry.amount | number: '1.2-2') : '' }}</td>
<td class="num mono">{{ entry.drCr === 'CR' ? (entry.amount | number: '1.2-2') : '' }}</td>
</tr>
}
</tbody>
</table>
} @else {
<p class="slot-empty">No journal entries are recorded against this receipt.</p>
}
</div>
<footer class="popup-foot">
<button type="button" mat-stroked-button (click)="closeDetail()">Close</button>
</footer>
</div>
</div>
}
@@ -0,0 +1,95 @@
/**
* Copyright since 2025 Mifos Initiative
*
* This Source Code Form is subject to the terms of the Mozilla Public
* License, v. 2.0. If a copy of the MPL was not distributed with this
* file, You can obtain one at http://mozilla.org/MPL/2.0/.
*/
// The same look as Apply Additional Shares, on purpose: both apply a cashier's receipt to what it was
// collected for, and the counter should meet one screen, not two. Used as it is, never changed here.
@use '../../../shares/share-dialog.shared' as *;
:host {
display: block;
width: 100%;
}
// The agent's outstanding lines - read only, what the receipt has to cover.
.entries {
width: 100%;
border-collapse: collapse;
font-size: 13px;
td {
padding: 7px 4px;
border-bottom: 1px solid #eef2f7;
}
tr:last-child td {
border-bottom: none;
}
.entry-date {
color: #64748b;
white-space: nowrap;
}
.entry-code {
font-weight: 600;
white-space: nowrap;
}
.entry-name {
color: #475569;
}
.num {
text-align: right;
white-space: nowrap;
}
}
// Days and vouchers are ticked, any number of each - a check box where the share dialog has a radio.
.option-check {
color: #94a3b8;
flex-shrink: 0;
}
.option-picked {
border-color: #2563eb !important;
background: #eff6ff !important;
.option-check {
color: #2563eb;
}
}
// A day's own lines, opened under it. Scrolls inside itself, so a thousand customers do not make the
// dialog a thousand lines tall.
.day-lines {
list-style: none;
padding: 6px 10px 10px 42px;
}
.day-search {
width: 100%;
margin-bottom: 6px;
padding: 6px 10px;
border: 1px solid #e2e8f0;
border-radius: 6px;
font: inherit;
font-size: 13px;
}
.day-scroll {
max-height: 240px;
overflow-y: auto;
}
.field-readonly {
padding: 8px 10px;
border: 1px solid #e2e8f0;
border-radius: 6px;
background: #f8fafc;
}
@@ -0,0 +1,351 @@
/**
* 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 { MAT_DIALOG_DATA, MatDialogRef, MatDialogModule } from '@angular/material/dialog';
import { MatButtonModule } from '@angular/material/button';
import { MatIconModule } from '@angular/material/icon';
import { MatProgressSpinnerModule } from '@angular/material/progress-spinner';
import { catchError } from 'rxjs/operators';
import { of } from 'rxjs';
import { OrganizationService } from 'app/organization/organization.service';
import { SettingsService } from 'app/settings/settings.service';
/** One day of an agent's outstanding entries, as the dialog lists them. */
export interface EntryDay {
date: string;
entries: any[];
total: number;
}
/**
* Applies the cashier's DDS receipts to an agent's outstanding collections.
*
* The agent's cash is already in the bank: the cashier took it at the counter into the agent's own GL
* account, tagged DDS, and the manager approved it. Applying it here hands the money on to the
* customers the agent wrote down - Dr the agent's GL, Cr DDS Customer Deposits - and each entry is
* settled against the application. The same screen as Apply Additional Shares.
*
* Everything outstanding up to the day chosen on the screen, shown a day at a time - nothing to tick,
* because what the agent wrote down is settled whole, and an agent who comes in after several days
* brings several days' cash. The vouchers are ticked, since a day's
* cash can come in more than one visit. They have to agree exactly: what the agent wrote down is what
* each customer is owed, so vouchers short of it leave somebody uncredited and vouchers over it leave
* money nobody can place.
*
* The day is shown totalled and its lines opened only when asked for - an agent with a thousand
* customers is a thousand lines, and the day's total is what the counter is checking.
*/
@Component({
selector: 'mifosx-apply-dds-receipt-dialog',
templateUrl: './apply-dds-receipt-dialog.component.html',
styleUrls: ['./apply-dds-receipt-dialog.component.scss'],
standalone: true,
imports: [
CommonModule,
FormsModule,
MatDialogModule,
MatButtonModule,
MatIconModule,
MatProgressSpinnerModule
]
})
export class ApplyDdsReceiptDialogComponent implements OnInit {
private organizationService = inject(OrganizationService);
private settingsService = inject(SettingsService);
public dialogRef = inject(MatDialogRef<ApplyDdsReceiptDialogComponent>);
public data = inject<{
agentId: number;
agentCode: string;
agentName: string;
glCode: string | null;
entryDate: string;
}>(MAT_DIALOG_DATA);
loadingEntries = true;
loadingReceipts = true;
submitting = false;
errorMsg = '';
/** The chosen day's outstanding entries - one day, or none when nothing is outstanding on it. */
days: EntryDay[] = [];
/** DDS receipts on the agent's GL account, approved and not yet applied. */
available: any[] = [];
/**
* The day is settled whole, so it is always included. The vouchers are the clerk's choice, and none
* is picked for them even when only one is listed.
*/
pickedDays = new Set<string>();
pickedReceipts = new Set<number>();
/** The day whose lines are open, and the search within it. */
openDay: string | null = null;
daySearch = '';
get loading(): boolean {
return this.loadingEntries || this.loadingReceipts;
}
ngOnInit(): void {
// Everything outstanding up to the chosen date, whatever day it was written - an agent's cash for
// several days arrives in one visit, and all of it is settled together.
this.organizationService
.getAgentPendingCollections(this.data.agentId)
.pipe(
catchError((err) => {
this.errorMsg = this.readError(err, "Could not load the agent's entries.");
return of(null);
})
)
.subscribe((res: any) => {
this.days = groupByDay(
(res?.entries || []).filter(
(entry: any) => entry.status === 'PENDING' && String(entry.entryDate) <= this.data.entryDate
)
);
this.pickedDays = new Set(this.days.map((day) => day.date));
this.loadingEntries = false;
});
this.organizationService
.getAgentCashReceipts(this.data.agentId)
.pipe(
catchError((err) => {
this.errorMsg = this.readError(err, 'Could not load the available receipts.');
return of([]);
})
)
.subscribe((rows: any) => {
this.available = Array.isArray(rows) ? rows : [];
this.loadingReceipts = false;
});
}
// ── Picking ─────────────────────────────────────────────────────────────────────────────────
toggleReceipt(receipt: any): void {
if (this.submitting) {
return;
}
this.pickedReceipts.has(receipt.id) ? this.pickedReceipts.delete(receipt.id) : this.pickedReceipts.add(receipt.id);
}
toggleOpen(day: EntryDay, event: Event): void {
event.stopPropagation();
this.openDay = this.openDay === day.date ? null : day.date;
this.daySearch = '';
}
/** The open day's lines, narrowed by party code or name. */
visibleEntries(day: EntryDay): any[] {
const term = this.daySearch.trim().toLowerCase();
if (!term) {
return day.entries;
}
return day.entries.filter(
(entry: any) =>
String(entry.partyCode || '')
.toLowerCase()
.includes(term) ||
String(entry.partyName || '')
.toLowerCase()
.includes(term)
);
}
// ── What has to be covered ──────────────────────────────────────────────────────────────────
get entryCount(): number {
return this.days.reduce((sum, day) => sum + day.entries.length, 0);
}
get outstandingTotal(): number {
return this.days.reduce((sum, day) => sum + day.total, 0);
}
get pickedEntryCount(): number {
return this.days.filter((day) => this.pickedDays.has(day.date)).reduce((sum, day) => sum + day.entries.length, 0);
}
get required(): number {
return round2(this.days.filter((day) => this.pickedDays.has(day.date)).reduce((sum, day) => sum + day.total, 0));
}
get received(): number {
return round2(
this.available.filter((r) => this.pickedReceipts.has(r.id)).reduce((sum, r) => sum + Number(r.amount || 0), 0)
);
}
/** Vouchers less entries: below zero they are short, above zero they are over. */
get difference(): number {
return round2(this.received - this.required);
}
get canApply(): boolean {
return this.pickedDays.size > 0 && this.pickedReceipts.size > 0 && this.difference === 0 && !this.submitting;
}
glLabel(option: any): string {
return [
option?.glCode,
option?.glName
]
.filter(Boolean)
.join(' — ');
}
// ── Voucher search, for a receipt the list does not carry ───────────────────────────────────
searching = false;
searchOpen = false;
searchVoucherNo = '';
openSearch(): void {
this.searchOpen = true;
this.searchVoucherNo = '';
this.errorMsg = '';
}
closeSearch(): void {
this.searchOpen = false;
this.errorMsg = '';
}
/**
* Finds one voucher on the agent's GL account and, when it cannot be used, says why - not taken as
* DDS, not approved yet, or already applied - rather than simply not finding it.
*/
runSearch(): void {
const voucher = this.searchVoucherNo.trim();
if (!voucher || this.searching) {
return;
}
this.searching = true;
this.errorMsg = '';
this.organizationService
.getAgentCashReceipts(this.data.agentId, voucher)
.pipe(
catchError((err) => {
this.errorMsg = this.readError(err, `Voucher ${voucher} could not be looked up.`);
return of(null);
})
)
.subscribe((rows: any) => {
this.searching = false;
if (rows === null) {
return;
}
const found = (Array.isArray(rows) ? rows : [])[0];
if (!found) {
this.errorMsg = `Voucher ${voucher} is not on ${this.data.agentCode}'s GL account ${this.data.glCode || ''}.`;
} else if (found.entryType !== 'DDS') {
this.errorMsg = `Voucher ${voucher} was taken as "${found.entryType || 'untagged'}", not DDS. Ask the cashier to correct the entry type.`;
} else if (found.status !== 'APPROVED') {
this.errorMsg = `Voucher ${voucher} is ${found.status}. It can be applied once the manager approves it.`;
} else if (found.applied) {
this.errorMsg = `Voucher ${voucher} has already been applied.`;
} else {
this.available = [
found,
...this.available.filter((entry) => entry.id !== found.id)
];
this.pickedReceipts.add(found.id);
this.searchOpen = false;
}
});
}
// ── A receipt's own GL entries ──────────────────────────────────────────────────────────────
viewing: any = null;
viewingGlEntries: any[] | null = null;
loadingGlEntries = false;
openDetail(option: any, event: Event): void {
event.stopPropagation();
this.viewing = option;
this.viewingGlEntries = null;
this.loadingGlEntries = true;
this.organizationService
.getCashReceiptGlEntries(option.id)
.pipe(catchError(() => of([])))
.subscribe((rows: any) => {
this.viewingGlEntries = Array.isArray(rows) ? rows : [];
this.loadingGlEntries = false;
});
}
closeDetail(): void {
this.viewing = null;
this.viewingGlEntries = null;
}
// ── Submission ──────────────────────────────────────────────────────────────────────────────
apply(): void {
if (!this.canApply) {
return;
}
this.submitting = true;
this.errorMsg = '';
const vouchers = this.available.filter((r) => this.pickedReceipts.has(r.id)).map((r) => r.voucherNo);
const entries = this.pickedEntryCount;
const amount = this.required;
this.organizationService
.settleAgentCollections({
agentId: Number(this.data.agentId),
cashierDepositRequestIds: Array.from(this.pickedReceipts),
entryDates: Array.from(this.pickedDays),
locale: this.settingsService.language.code
})
.pipe(
catchError((err) => {
this.errorMsg = this.readError(err, 'The receipts could not be applied.');
this.submitting = false;
return of(null);
})
)
.subscribe((res: any) => {
if (res) {
this.dialogRef.close({ vouchers, entries, amount, response: res });
}
});
}
private readError(err: any, fallback: string): string {
return (
err?.error?.errors?.[0]?.defaultUserMessage ??
err?.error?.defaultUserMessage ??
err?.error?.developerMessage ??
fallback
);
}
}
/** Entries grouped by the day they were collected, oldest first, each day totalled. */
export function groupByDay(entries: any[]): EntryDay[] {
const byDay = new Map<string, EntryDay>();
for (const entry of entries) {
const date = String(entry.entryDate);
const day = byDay.get(date) ?? { date, entries: [], total: 0 };
day.entries.push(entry);
day.total = round2(day.total + Number(entry.amount || 0));
byDay.set(date, day);
}
return Array.from(byDay.values()).sort((a, b) => a.date.localeCompare(b.date));
}
function round2(value: number): number {
return Math.round(value * 100) / 100;
}
@@ -0,0 +1,468 @@
<!--
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>
@if (partyInfo) {
<dl>
<dt>Client Id</dt>
<dd>{{ partyInfo.clientId }}</dd>
<dt>Name</dt>
<dd>{{ partyInfo.partyName }}</dd>
<dt>Address</dt>
<dd>{{ partyInfo.address1 || '—' }}</dd>
<dt>City</dt>
<dd>{{ partyInfo.city || '—' }}</dd>
<dt>Op. Date</dt>
<dd>{{ partyInfo.openingDate ? (partyInfo.openingDate | dateFormat) : '—' }}</dd>
<dt>Maturity</dt>
<dd>{{ partyInfo.maturityDate ? (partyInfo.maturityDate | dateFormat) : '—' }}</dd>
<dt>Daily Depo</dt>
<dd>{{ partyInfo.dailyDepositAmount | number: '1.2-2' }}</dd>
<dt>Ledger</dt>
<dd>{{ partyInfo.ledgerNo || '—' }}</dd>
<dt>Aadhar No</dt>
<dd>{{ partyInfo.aadharNo || '—' }}</dd>
<dt>Curr Bal</dt>
<dd>{{ partyInfo.currentBalance | number: '1.2-2' }}</dd>
<!--
What should have been collected by today against what has been. Beside the balance
rather than instead of it: the balance is what the customer holds, this is whether
they have kept to the scheme.
-->
<dt>Due So Far</dt>
<dd>{{ partyInfo.expectedToDate | number: '1.2-2' }}</dd>
<dt>Collected</dt>
<dd>{{ partyInfo.collectedToDate | number: '1.2-2' }}</dd>
@if (schedule(partyInfo); as sched) {
<dt [class]="'dte-sched dte-sched--' + sched.state">Schedule</dt>
<dd [class]="'dte-sched dte-sched--' + sched.state">{{ sched.text }}</dd>
}
<dt class="dte-after">Bal. After This Tr</dt>
<dd class="dte-after">{{ partyBalanceAfter | number: '1.2-2' }}</dd>
</dl>
} @else {
<p class="dte-info-panel__hint">
Pick a party in a collection row below — this panel shows whose account it is and what the balance
becomes.
</p>
}
</div>
</div>
<div class="dte-controls">
<mat-form-field appearance="outline" class="dte-field dte-field--agent">
<mat-label>Agent</mat-label>
<mat-select required formControlName="agentId" (selectionChange)="loadAgent()">
@for (agent of selectableAgents; track agent.id) {
<mat-option [value]="agent.id">
{{ agent.agentCode }} · {{ agent.agentNameId }}
@if (!agent.active) {
<span class="dte-agent-off">— not collecting</span>
}
</mat-option>
}
</mat-select>
@if (selectableAgents.length === 0) {
<mat-hint class="dte-hint--missing">No active agent on file</mat-hint>
} @else if (pickedAgentIsInactive) {
<!-- Only reachable on a settlement: this agent no longer collects, but the cash they
already hold still has to be able to reach a branch. -->
<mat-hint class="dte-hint--missing">This agent no longer collects — settling held cash only</mat-hint>
} @else if (agentInfo.name) {
<mat-hint class="dte-hint--found">
{{ agentParties.length }} {{ agentParties.length === 1 ? 'party' : 'parties' }} to collect from
</mat-hint>
}
</mat-form-field>
<mat-form-field appearance="outline" class="dte-field">
<mat-label>Date</mat-label>
<!-- The date picks out a round, not just a posting date: a Party Tran settles whatever
the agent wrote down for that day, so changing it has to fetch that day's list. -->
<input
matInput
required
[matDatepicker]="txnDatePicker"
formControlName="transactionDate"
(dateChange)="loadPendingEntries(); loadAgentMoney()"
/>
<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.label) {
<mat-option [value]="type.label" [disabled]="!type.supported">
{{ type.label }}
@if (!type.supported) {
<span class="dte-soon"> — not available yet</span>
}
</mat-option>
}
</mat-select>
</mat-form-field>
</div>
<!--
What the agent wrote down on their round, waiting to be counted.
This used to be an empty grid the clerk typed the round into, and typing it posted it: a
customer was credited the moment a line was keyed, whether or not the cash behind it ever
reached the branch. The agent records their own collections now, and what the clerk does here
is agree with them - so there is nothing to type, only a list to check against a bag of cash.
-->
@if (isPartyTran && agentInfo.name) {
<div class="dte-section">
<div class="dte-section__header">
<i class="fas fa-list"></i>
<h3>Collections up to {{ entryForm.get('transactionDate')?.value | dateFormat }}</h3>
<button mat-stroked-button type="button" class="dte-add-row" (click)="loadPendingEntries()">
<i class="fas fa-sync"></i> Refresh
</button>
</div>
@if (pendingLoading) {
<p class="dte-empty">Reading this agent's entries...</p>
} @else if (pendingEntries.length === 0) {
<p class="dte-empty">{{ agentInfo.name }} has nothing outstanding up to this date.</p>
} @else {
@for (entry of pendingEntries; track entry.id) {
<div class="dte-pending-row">
<!-- The day the agent took it, on the line itself. All the lines carry the date
chosen above, but a row that says so can be read, printed or shown to the agent
on its own without the header to explain it. -->
<span class="dte-pending-row__date">{{ entry.entryDate | dateFormat }}</span>
<span class="dte-pending-row__code">{{ entry.partyCode }}</span>
<span class="dte-pending-row__name">{{ entry.partyName }}</span>
<span class="dte-pending-row__mode"
>{{ entry.transactionMode }}
@if (entry.advanceDays) {
· {{ entry.advanceDays }}d advance
}
@if (entry.arrearsDays) {
· {{ entry.arrearsDays }}d previous
}
</span>
<span class="dte-pending-row__amount">{{ entry.amount | number: '1.2-2' }}</span>
</div>
}
<div class="dte-total">
<span>{{ pendingEntries.length }} {{ pendingEntries.length === 1 ? 'entry' : 'entries' }}</span>
<strong>{{ pendingTotal | number: '1.2-2' }}</strong>
</div>
<!--
The figure the clerk counts against, said in words rather than left as a column total -
it is the one number they are acting on, and the rest of the panel is working out.
It says what the agent wrote down and stops there. The money side - what is owed, what the
cashier has taken in, and applying it - is Agent Tran, so each type has one job.
-->
<p class="dte-expected dte-expected--note">
Everything {{ agentInfo.name }} wrote down and has not handed in yet, up to this date, customer by
customer. The cash and its receipts are under Agent Tran.
</p>
}
</div>
}
<!--
The agent's money: what they owe on everything written down, what the cashier has taken in
against it, and what is left.
This used to be a form the clerk typed an amount into to take the agent's cash in here. The
cash is taken at the cashier's counter now, into the agent's own GL account, and one way to take
the same cash in is the only safe number of ways - so there is nothing to type. What is done here
is applying the cashier's receipts to the customers, in Apply DDS Receipt.
-->
@if (isAgentTran && agentInfo.name) {
<div class="dte-section">
<div class="dte-section__header">
<i class="fas fa-hand-holding-usd"></i>
<h3>Agent's cash</h3>
<button mat-stroked-button type="button" class="dte-add-row" (click)="loadAgentMoney()">
<i class="fas fa-sync"></i> Refresh
</button>
<button
mat-raised-button
color="primary"
type="button"
class="dte-apply-receipt"
(click)="openApplyReceipt()"
>
<i class="fas fa-receipt"></i> Apply DDS Receipt
</button>
</div>
@if (!agentGlCode) {
<p class="dte-expected dte-expected--note">
{{ agentInfo.name }} has no GL account, so the cashier has nowhere to take their cash. Set one on Agent
Master.
</p>
} @else {
<!-- The GL the cashier takes this agent's cash into - the code to key at Transaction Entry. -->
<p class="dte-expected">
Agent GL: <strong>{{ agentGlCode }}{{ agentGlName ? ' · ' + agentGlName : '' }}</strong> — the cashier
takes {{ agentInfo.name }}'s cash into this GL (Entry Type DDS).
</p>
}
<div class="dte-money">
<div class="dte-money__box">
<span class="dte-money__label">To take</span>
<strong>{{ agentOwed | number: '1.2-2' }}</strong>
</div>
<div class="dte-money__box">
<span class="dte-money__label">Received (approved)</span>
<strong>{{ agentReceived | number: '1.2-2' }}</strong>
</div>
<div class="dte-money__box" [class.dte-money__box--due]="agentBalance > 0">
<span class="dte-money__label">Still to take</span>
<strong>{{ agentBalance | number: '1.2-2' }}</strong>
</div>
</div>
@if (agentReceivedBeyondDay > 0) {
<p class="dte-expected dte-expected--note">
{{ agentReceivedBeyondDay | number: '1.2-2' }} received at the counter is more than everything outstanding
up to {{ entryForm.get('transactionDate')?.value | dateFormat }}. Check the voucher was taken for the
right agent.
</p>
}
@if (agentWaiting > 0) {
<p class="dte-expected dte-expected--note">
{{ agentWaiting | number: '1.2-2' }} more is at the counter awaiting the manager's approval.
</p>
}
<h4 class="dte-subhead">
Outstanding entries up to {{ entryForm.get('transactionDate')?.value | dateFormat }}
</h4>
@if (agentMoneyLoading) {
<p class="dte-empty">Reading this agent's entries...</p>
} @else if (agentDays.length === 0) {
<p class="dte-empty">{{ agentInfo.name }} has nothing outstanding.</p>
} @else {
@for (day of agentDays; track day.date) {
<div class="dte-pending-row dte-pending-row--day" (click)="toggleAgentDay(day.date)">
<span class="dte-pending-row__date">{{ day.date | dateFormat }}</span>
<span class="dte-pending-row__name">
{{ day.entries.length }} {{ day.entries.length === 1 ? 'entry' : 'entries' }}
</span>
<span class="dte-pending-row__amount">{{ day.total | number: '1.2-2' }}</span>
<i [class]="agentOpenDay === day.date ? 'fas fa-chevron-up' : 'fas fa-chevron-down'"></i>
</div>
@if (agentOpenDay === day.date) {
<div class="dte-day-lines">
@for (entry of day.entries; track entry.id) {
<div class="dte-pending-row">
<span class="dte-pending-row__code">{{ entry.partyCode }}</span>
<span class="dte-pending-row__name">{{ entry.partyName }}</span>
<span class="dte-pending-row__mode"
>{{ entry.transactionMode }}
@if (entry.advanceDays) {
· {{ entry.advanceDays }}d advance
}
@if (entry.arrearsDays) {
· {{ entry.arrearsDays }}d previous
}
</span>
<span class="dte-pending-row__amount">{{ entry.amount | number: '1.2-2' }}</span>
</div>
}
</div>
}
}
}
<h4 class="dte-subhead">Cashier receipts</h4>
@if (agentReceipts.length === 0) {
<p class="dte-empty">
No DDS receipt on {{ agentInfo.name }}'s GL account{{ agentGlCode ? ' ' + agentGlCode : '' }} waiting to
be applied.
</p>
} @else {
@for (receipt of agentReceipts; track receipt.id) {
<div class="dte-pending-row">
<span class="dte-pending-row__code">{{ receipt.voucherNo }}</span>
<span class="dte-pending-row__date">{{ receipt.txnDate | dateFormat }}</span>
<span class="dte-pending-row__name">{{ receipt.cashierName || '—' }}</span>
<span class="dte-status" [class.dte-status--ok]="receipt.status === 'APPROVED'">
{{ receipt.status === 'APPROVED' ? 'Approved' : 'Awaiting manager' }}
</span>
<span class="dte-pending-row__amount">{{ receipt.amount | number: '1.2-2' }}</span>
</div>
}
}
</div>
}
@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"
[matAutocomplete]="creditorAuto"
#creditorTrigger="matAutocompleteTrigger"
(click)="creditorTrigger.openPanel()"
(focus)="creditorTrigger.openPanel()"
(blur)="settleTransferParty('creditor')"
placeholder="Search by code or name…"
autocomplete="off"
/>
<mat-icon matSuffix class="dte-list-arrow" (click)="$event.stopPropagation(); creditorTrigger.openPanel()"
>arrow_drop_down</mat-icon
>
<mat-autocomplete #creditorAuto="matAutocomplete" panelWidth="min(30rem, 92vw)">
@for (party of transferPartyOptions('creditor'); track party.id) {
<mat-option [value]="party.partyCode">
{{ party.partyCode }} · {{ party.partyName }} · ₹{{ party.currentBalance | number: '1.2-2' }}
</mat-option>
}
@if (transferPartyOptions('creditor').length === 0) {
<mat-option disabled>No open DDS account matches</mat-option>
}
</mat-autocomplete>
<mat-hint>Account the money goes into</mat-hint>
</mat-form-field>
<mat-form-field appearance="outline" class="dte-field">
<mat-label>Creditor Party Name</mat-label>
<input matInput formControlName="creditorPartyName" readonly />
</mat-form-field>
<mat-form-field appearance="outline" class="dte-field">
<mat-label>Creditor Curr Bal</mat-label>
<input matInput formControlName="creditorPartyBalance" readonly />
</mat-form-field>
<mat-form-field appearance="outline" class="dte-field">
<mat-label>Debit Party Code</mat-label>
<input
matInput
formControlName="debitPartyCode"
[matAutocomplete]="debitAuto"
#debitTrigger="matAutocompleteTrigger"
(click)="debitTrigger.openPanel()"
(focus)="debitTrigger.openPanel()"
(blur)="settleTransferParty('debit')"
placeholder="Search by code or name…"
autocomplete="off"
/>
<mat-icon matSuffix class="dte-list-arrow" (click)="$event.stopPropagation(); debitTrigger.openPanel()"
>arrow_drop_down</mat-icon
>
<mat-autocomplete #debitAuto="matAutocomplete" panelWidth="min(30rem, 92vw)">
@for (party of transferPartyOptions('debit'); track party.id) {
<mat-option [value]="party.partyCode">
{{ party.partyCode }} · {{ party.partyName }} · ₹{{ party.currentBalance | number: '1.2-2' }}
</mat-option>
}
@if (transferPartyOptions('debit').length === 0) {
<mat-option disabled>No open DDS account matches</mat-option>
}
</mat-autocomplete>
@if (transferToItself) {
<mat-hint class="dte-same-party">Pick a different account - it cannot transfer to itself</mat-hint>
} @else {
<mat-hint>Account the money is taken from</mat-hint>
}
</mat-form-field>
<mat-form-field appearance="outline" class="dte-field">
<mat-label>Debit Party Name</mat-label>
<input matInput formControlName="debitPartyName" readonly />
</mat-form-field>
<mat-form-field appearance="outline" class="dte-field">
<mat-label>Debit Curr Bal</mat-label>
<input matInput formControlName="debitPartyBalance" readonly />
</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>
<!-- Party Tran and Agent Tran are read here, never saved: the agent writes the round down on
their own screen and the cash is taken in at the cashier's counter. Agent Tran applies the
cashier's receipts from its own Apply DDS Receipt. Only Withdraw Deposite still saves. -->
@if (!isPartyTran && !isAgentTran) {
<button
mat-raised-button
color="primary"
type="button"
[disabled]="entryForm.invalid || transferToItself"
(click)="save()"
>
Save
</button>
}
</div>
</form>
</div>
</div>
@@ -0,0 +1,488 @@
/**
* 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 {
// Wider than a normal form page: a collection row carries six fields side by side, and a clerk
// keying a round should see the whole of it.
max-width: 1280px;
margin: 0 auto;
padding: 20px 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;
}
// A sensible floor for the fields laid out with flex in .dte-controls, where there is room to spare.
.dte-field {
min-width: 180px;
&--wide {
grid-column: span 2;
}
}
// Inside the collection grid the track decides the width, not the field. Left to its own 180px
// floor a field refuses to shrink into a narrower track and overruns the one beside it.
.dte-collection-row .dte-field {
width: 100%;
min-width: 0;
}
.dte-section {
padding: 16px 0;
border-top: 1px solid rgb(0 0 0 / 8%);
// A very narrow window scrolls the grid sideways rather than the whole page.
overflow-x: auto;
&__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;
}
/*
* One collection per row. Fixed columns rather than an even split, so the row holds its shape as
* fields come and go - Cheque No and Token No only appear for a non-cash entry - and the party
* picker gets the width its "code · name · daily amount" needs.
*/
.dte-collection-row {
display: grid;
grid-template-columns:
minmax(0, 2.2fr) /* Party */
minmax(0, 1fr) /* TRTP */
minmax(0, 1fr) /* AC Type */
minmax(0, 1.4fr) /* A/C No. */
minmax(0, 1fr) /* Amount */
minmax(0, 1.6fr) /* Description */
auto; /* remove */
gap: 8px 12px;
align-items: start;
padding: 10px 0;
border-top: 1px dashed rgb(0 0 0 / 10%);
&:first-of-type {
border-top: none;
}
// Cheque No and Token No push Description and the remove button onto a second line, which is
// what a non-cash entry should look like rather than a row running off the screen.
&:has(.dte-field--instrument) {
grid-template-columns:
minmax(0, 2.2fr) minmax(0, 1fr) minmax(0, 1fr) minmax(0, 1.4fr) minmax(0, 1fr)
minmax(0, 1fr) minmax(0, 1fr) minmax(0, 1.6fr) auto;
}
}
.dte-collection-row .dte-field--wide {
grid-column: span 2;
}
@media (width <= 1200px) {
.dte-collection-row,
.dte-collection-row:has(.dte-field--instrument) {
grid-template-columns: repeat(3, minmax(0, 1fr)) auto;
}
}
.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);
}
}
/* Resolved name under an Agent / Party Code field — the check that the right record was found. */
.dte-hint--found {
color: #15803d;
font-weight: 500;
}
.dte-hint--missing {
color: #b91c1c;
font-weight: 500;
}
/* Marks an agent in the list who is no longer collecting - only offered for settlement. */
.dte-agent-off {
color: #b45309;
font-size: 12px;
font-style: italic;
margin-left: 6px;
}
/* The figure the clerk actually verifies against the passbook before saving. */
.dte-after {
font-weight: 600;
color: #0f766e;
}
/* What the agent should be handing over for this round. */
.dte-total {
display: flex;
justify-content: flex-end;
align-items: baseline;
gap: 14px;
padding: 10px 4px 2px;
border-top: 1px solid rgb(0 0 0 / 10%);
font-size: 0.88rem;
color: rgb(0 0 0 / 60%);
strong {
font-size: 1.1rem;
color: rgb(0 0 0 / 85%);
font-variant-numeric: tabular-nums;
}
}
/* Marks a transaction type the system does not handle yet, so it reads as pending rather than broken. */
.dte-soon {
font-size: 0.78rem;
font-style: italic;
color: rgb(0 0 0 / 45%);
}
/*
* Marks a party this agent has already collected from today, on the row where it is picked.
*
* Amber, not red: a customer paying twice in a day is legitimate - arrears, or two days at once -
* so this is a second look, not an error. Blocking it would stop real work; saying nothing lets a
* whole round be credited twice.
*/
.dte-hint--again {
color: #b45309;
font-weight: 500;
}
/*
* Where a party stands against its instalments. Three states rather than one colour, because they
* call for three different things from the clerk: ahead means take nothing, behind means take more
* than the daily amount, level means take the daily amount as usual.
*
* Behind is amber, not red - falling a few days behind is ordinary in daily collection and is the
* agent's job to recover, not an error. Red here would cry wolf on most rows.
*/
.dte-hint--ahead,
.dte-sched--ahead {
color: #15803d;
font-weight: 500;
}
.dte-hint--behind,
.dte-sched--behind {
color: #b45309;
font-weight: 500;
}
.dte-hint--level,
.dte-sched--level {
color: rgb(0 0 0 / 55%);
}
/* In the party panel the schedule line carries more weight than the fields around it. */
.dte-sched {
font-weight: 500;
}
/* Says why a party in the list cannot be picked, beside the party rather than in a tooltip. */
.dte-covered {
color: #15803d;
font-size: 0.8rem;
}
/*
* An amount that looks like a slip. Red rather than the amber used for arrears: arrears are ordinary
* and the agent's job to recover, while this is a figure that probably was not handed over at all,
* and once saved it cannot be taken back. It still does not block - the clerk decides.
*/
.dte-hint--overpay {
color: #b91c1c;
font-weight: 500;
}
/*
The agent's written-down round, as the counter reads it.
Laid out as lines rather than as the input grid this replaced: there is nothing to fill in here,
only a list to check a bag of cash against, and controls would invite editing something the clerk
has no business editing.
*/
.dte-pending-row {
display: flex;
align-items: baseline;
gap: 12px;
padding: 10px 2px;
border-bottom: 1px solid rgb(0 0 0 / 8%);
&:last-of-type {
border-bottom: none;
}
/* First, and in the quieter weight: the date is context for the line, not what is being read. */
&__date {
min-width: 96px;
opacity: 0.7;
font-variant-numeric: tabular-nums;
}
&__code {
font-weight: 600;
min-width: 84px;
}
&__name {
flex: 1;
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
opacity: 0.75;
}
&__mode {
font-size: 12px;
text-transform: uppercase;
letter-spacing: 0.5px;
opacity: 0.6;
}
/* Amounts are read down the column and compared, so the digits have to line up. */
&__amount {
font-variant-numeric: tabular-nums;
font-weight: 600;
min-width: 96px;
text-align: right;
}
}
/* The one figure the clerk acts on, said as a sentence so it is not read as another column total. */
.dte-expected {
margin: 14px 2px 0;
font-size: 14px;
line-height: 1.5;
strong {
font-variant-numeric: tabular-nums;
}
}
/* Quieter than the figure above it: a note about how the screen behaves, not part of the count. */
.dte-expected--note {
font-size: 13px;
opacity: 0.65;
margin-top: 4px;
}
.dte-apply-receipt {
margin-left: 8px;
}
/* Agent Tran: what the agent owes, what the cashier has taken in, and what is left. */
.dte-money {
display: flex;
gap: 12px;
margin: 8px 0 12px;
flex-wrap: wrap;
&__box {
flex: 1;
min-width: 160px;
padding: 10px 14px;
border: 1px solid rgb(0 0 0 / 10%);
border-radius: 8px;
display: flex;
flex-direction: column;
gap: 4px;
font-variant-numeric: tabular-nums;
strong {
font-size: 18px;
}
&--due {
border-color: rgb(198 40 40 / 40%);
background: rgb(198 40 40 / 4%);
}
}
&__label {
font-size: 12px;
opacity: 0.7;
text-transform: uppercase;
letter-spacing: 0.04em;
}
}
.dte-subhead {
margin: 16px 0 4px;
font-size: 14px;
font-weight: 600;
}
.dte-pending-row--day {
cursor: pointer;
}
.dte-day-lines {
margin-left: 24px;
max-height: 280px;
overflow-y: auto;
border-left: 3px solid rgb(25 118 210 / 30%);
padding-left: 10px;
}
.dte-status {
font-size: 12px;
padding: 2px 8px;
border-radius: 10px;
background: rgb(237 108 2 / 12%);
color: #b45309;
&--ok {
background: rgb(46 125 50 / 12%);
color: #2e7d32;
}
}
.dte-list-arrow {
cursor: pointer;
}
.dte-same-party {
color: #c62828;
font-weight: 600;
}
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,58 @@
<!--
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/.
-->
<h2 mat-dialog-title>{{ title }}</h2>
<mat-dialog-content>
<mat-form-field appearance="outline" class="dcd-field">
<mat-label>Party</mat-label>
<mat-select [(ngModel)]="partyCode" (selectionChange)="partyChanged()">
@for (p of eligibleParties; track p.id) {
<mat-option [value]="p.partyCode">
{{ p.partyCode }} · {{ p.partyName }} · Daily {{ p.dailyDepositAmount | number: '1.0-0' }}
</mat-option>
}
</mat-select>
@if (eligibleParties.length === 0) {
<!-- Only reachable on the arrears side: nobody is behind within the window. -->
<mat-hint class="dcd-none">No party has recoverable days</mat-hint>
}
</mat-form-field>
<mat-form-field appearance="outline" class="dcd-field">
<mat-label>Days</mat-label>
<mat-select [(ngModel)]="days" [disabled]="!partyCode">
@for (d of dayOptions; track d) {
<mat-option [value]="d">{{ d }} {{ d === 1 ? 'day' : 'days' }}</mat-option>
}
</mat-select>
@if (isAdvance) {
<mat-hint>Up to 5 days</mat-hint>
} @else if (partyCode) {
<mat-hint>Only days still within the catch-up window</mat-hint>
}
</mat-form-field>
<!--
What it comes to, shown before it is added rather than after. The clerk quotes this to the
customer, so it has to be the scheme's own figure and has to be visible while deciding.
-->
@if (amount > 0) {
<div class="dcd-amount">
<span class="dcd-amount__figure">{{ amount | number: '1.2-2' }}</span>
<span class="dcd-amount__basis">
{{ days }} × {{ party?.dailyDepositAmount | number: '1.2-2' }} daily
</span>
</div>
}
</mat-dialog-content>
<mat-dialog-actions align="end">
<button mat-button mat-dialog-close type="button">Cancel</button>
<button mat-raised-button color="primary" type="button" [disabled]="!canAdd" (click)="add()">Add</button>
</mat-dialog-actions>
@@ -0,0 +1,42 @@
/**
* 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/.
*/
.dcd-field {
width: 100%;
}
.dcd-none {
color: #b45309;
}
/*
* The figure the clerk quotes at the counter. Larger than the fields above it because it is the one
* thing the customer is told, and it is shown with its basis so the clerk can say where it came from
* rather than just naming a number.
*/
.dcd-amount {
display: flex;
align-items: baseline;
gap: 10px;
margin-top: 6px;
padding: 12px 14px;
border-radius: 6px;
background: rgb(15 118 110 / 8%);
&__figure {
font-size: 1.3rem;
font-weight: 600;
color: #0f766e;
font-variant-numeric: tabular-nums;
}
&__basis {
font-size: 0.8rem;
color: rgb(0 0 0 / 60%);
}
}
@@ -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, inject } from '@angular/core';
import { CommonModule } from '@angular/common';
import { FormsModule } from '@angular/forms';
import { MAT_DIALOG_DATA, MatDialogModule, MatDialogRef } from '@angular/material/dialog';
import { MatButtonModule } from '@angular/material/button';
import { MatSelectModule } from '@angular/material/select';
import { MatFormFieldModule } from '@angular/material/form-field';
import { MatInputModule } from '@angular/material/input';
/**
* Collecting for days other than today - either paid ahead, or owed from days gone by.
*
* Both are the same act from the counter's side: the customer is paying for a number of days, and
* what that comes to is the scheme's business, not the clerk's. So the clerk picks days and the
* amount follows. Typing an amount instead would let the daily figure be worked around a little at a
* time, and would quote the customer something the scheme does not charge.
*
* The two differ only in how many days are on offer. Paying ahead is capped by the bank's allowance.
* Paying arrears is capped by what is still inside the catch-up window - older instalments are gone,
* and offering them would have the clerk asking for money the bank has already written off.
*/
@Component({
selector: 'mifosx-day-collection-dialog',
standalone: true,
imports: [
CommonModule,
FormsModule,
MatDialogModule,
MatButtonModule,
MatSelectModule,
MatFormFieldModule,
MatInputModule
],
templateUrl: './day-collection-dialog.component.html',
styleUrls: ['./day-collection-dialog.component.scss']
})
export class DayCollectionDialogComponent {
private dialogRef = inject(MatDialogRef<DayCollectionDialogComponent>);
/** { mode: 'advance' | 'previous', parties: [] } - the agent's parties, as the screen already has them. */
data = inject<any>(MAT_DIALOG_DATA);
partyCode = '';
days: number | '' = '';
get isAdvance(): boolean {
return this.data?.mode === 'advance';
}
get title(): string {
return this.isAdvance ? 'Collect in advance' : 'Collect previous days';
}
get party(): any {
return (this.data?.parties || []).find((p: any) => String(p.partyCode) === this.partyCode) || null;
}
/**
* The parties this can be used on.
*
* Paying ahead is offered to anyone. Paying arrears is offered only where there are arrears still
* inside the window - on an account that is up to date there is nothing to collect for past days,
* and listing it would invite an entry that is really an advance in disguise.
*/
get eligibleParties(): any[] {
const parties = this.data?.parties || [];
return this.isAdvance ? parties : parties.filter((p: any) => Number(p.recoverableAmount || 0) > 0);
}
/**
* How many days may be picked. Five ahead, matching the bank's allowance and the server's own
* check; for arrears, only what has not yet fallen out of the catch-up window.
*/
get dayOptions(): number[] {
const daily = Number(this.party?.dailyDepositAmount || 0);
if (!this.party || daily <= 0) {
return [];
}
const limit = this.isAdvance
? 5
: Math.floor(Number(this.party.recoverableAmount || 0) / daily);
return Array.from({ length: Math.max(0, limit) }, (_, index) => index + 1);
}
get amount(): number {
return Number(this.party?.dailyDepositAmount || 0) * Number(this.days || 0);
}
/** Clearing the days when the party changes - a count valid for one account is not for another. */
partyChanged(): void {
this.days = '';
}
get canAdd(): boolean {
return !!this.partyCode && Number(this.days) > 0 && this.amount > 0;
}
add(): void {
this.dialogRef.close({
partyCode: this.partyCode,
days: Number(this.days),
amount: this.amount,
// Sent as days, not just an amount: the server recomputes what they come to and checks it
// against this account, so the figure quoted here is the one that gets posted.
advanceDays: this.isAdvance ? Number(this.days) : undefined,
arrearsDays: this.isAdvance ? undefined : Number(this.days)
});
}
}
@@ -0,0 +1,21 @@
<!--
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="nd-head">
<span class="nd-head__ic"><fa-icon icon="info-circle"></fa-icon></span>
<h2 class="nd-head__title">{{ data.heading }}</h2>
</div>
<mat-dialog-content>
<p class="nd-message">{{ data.message }}</p>
</mat-dialog-content>
<!-- One button, because there is nothing here to agree to or call off. -->
<mat-dialog-actions align="end">
<button mat-flat-button color="primary" mat-dialog-close>OK</button>
</mat-dialog-actions>
@@ -0,0 +1,44 @@
/**
* 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/.
*/
.nd-head {
display: flex;
align-items: center;
gap: 12px;
padding: 18px 24px 4px;
/*
* Teal rather than the amber or red the platform's other dialogs use. Nothing has gone wrong here
* and nothing is at risk - the clerk is being told a fact about the agent they picked, and a
* warning colour would send them looking for a fault that is not there.
*/
&__ic {
display: grid;
place-items: center;
width: 34px;
height: 34px;
border-radius: 8px;
background: rgb(15 118 110 / 10%);
color: #0f766e;
flex: none;
}
&__title {
margin: 0;
font-size: 1.05rem;
font-weight: 600;
}
}
.nd-message {
margin: 0;
max-width: 44ch;
font-size: 0.9rem;
line-height: 1.55;
color: rgb(0 0 0 / 70%);
}
@@ -0,0 +1,39 @@
/**
* 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 { MAT_DIALOG_DATA, MatDialogModule } from '@angular/material/dialog';
import { MatButtonModule } from '@angular/material/button';
import { FaIconComponent } from '@fortawesome/angular-fontawesome';
/**
* Something the clerk needs told, with nothing to decide.
*
* The platform's confirmation dialog asks a question - Cancel or Confirm - and its buttons imply a
* choice. Used for a plain statement it leaves the clerk hunting for what they are agreeing to, and
* "Cancel" reads as though the thing being described could be undone. The error dialog has the right
* single button but dresses the message in red, which turns "this agent has no parties" into a fault.
*
* So: one button, and the message stands on its own.
*/
@Component({
selector: 'mifosx-notice-dialog',
standalone: true,
imports: [
MatDialogModule,
MatButtonModule,
FaIconComponent
],
templateUrl: './notice-dialog.component.html',
styleUrls: ['./notice-dialog.component.scss']
})
export class NoticeDialogComponent {
/** { heading, message } */
data = inject<any>(MAT_DIALOG_DATA);
}
@@ -0,0 +1,557 @@
<!--
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="dav-page">
@if (isLoading) {
<div class="dav-card dav-state">
<mat-spinner diameter="32"></mat-spinner>
<span>Loading account…</span>
</div>
} @else if (!account) {
<div class="dav-card dav-state">
<fa-icon icon="inbox" size="2x"></fa-icon>
<span>DDS account not found</span>
<button class="dav-btn" (click)="back()"><fa-icon icon="arrow-left"></fa-icon> DDS Accounts</button>
</div>
} @else {
<!-- Banner, as the savings account page opens -->
<div class="dav-banner">
<div class="dav-banner__icon"><fa-icon icon="coins"></fa-icon></div>
<div class="dav-banner__text">
<span class="dav-pill" [ngClass]="'dav-pill--' + (account.status || '').toLowerCase()"
>● {{ account.status }}</span
>
<h2 class="dav-banner__title">{{ account.subType }}</h2>
<div class="dav-banner__meta">
<span class="dav-banner__label">Account Number</span>
<span class="dav-banner__value dav-mono">{{ account.accountNo }}</span>
<span class="dav-banner__label">Client Name</span>
<span class="dav-banner__value">{{ account.clientName || account.partyName }}</span>
</div>
</div>
<div class="dav-banner__boxes">
<div class="dav-box">
<span class="dav-box__label">Current Balance</span>
<span class="dav-box__value">₹{{ account.currentBalance || 0 | formatNumber }}</span>
</div>
<div class="dav-box">
<span class="dav-box__label">Maturity Amount</span>
<span class="dav-box__value">₹{{ account.maturityAmount || 0 | formatNumber }}</span>
</div>
</div>
<div class="dav-banner__actions">
<button class="dav-icon-btn" (click)="back()" matTooltip="Back to DDS Accounts">
<fa-icon icon="arrow-left"></fa-icon>
</button>
<button class="dav-icon-btn" (click)="openInPartyMaster()" matTooltip="Open in DDS Party Master">
<fa-icon icon="pen"></fa-icon>
</button>
</div>
</div>
<!-- Tabs -->
<div class="dav-tabs">
<button class="dav-tab" [class.dav-tab--on]="tab === 'general'" (click)="tab = 'general'">
<fa-icon icon="info-circle"></fa-icon> General
</button>
<button class="dav-tab" [class.dav-tab--on]="tab === 'transactions'" (click)="tab = 'transactions'">
<fa-icon icon="exchange-alt"></fa-icon> Transactions
</button>
<button class="dav-tab" [class.dav-tab--on]="tab === 'passbook'" (click)="tab = 'passbook'">
<fa-icon icon="book"></fa-icon> Passbook
</button>
<button class="dav-tab" [class.dav-tab--on]="tab === 'nominees'" (click)="tab = 'nominees'">
<fa-icon icon="user"></fa-icon> Nominees
</button>
@if (account.status === 'CLOSED') {
<button class="dav-tab" [class.dav-tab--on]="tab === 'closure'" (click)="tab = 'closure'">
<fa-icon icon="lock"></fa-icon> Closure
</button>
}
</div>
<!-- Closure: how a closed account was closed and where its money went -->
@if (tab === 'closure') {
@if (!closure) {
<div class="dav-card">
<div class="dav-row"><span>Loading closure details…</span></div>
</div>
} @else {
<div class="dav-columns">
<div class="dav-column">
<div class="dav-card">
<div class="dav-card__head"><span class="dav-card__dot"></span> How It Closed</div>
<div class="dav-row">
<span>Type</span>
<strong>
{{ closureLabels[closure.closureType] || closure.closureType }}
@if (closure.withinLockin) {
<em class="dav-closure-tag">within lock-in</em>
}
</strong>
</div>
<div class="dav-row">
<span>Closed On</span><strong>{{ closure.closeDate | date: 'dd MMMM yyyy' }}</strong>
</div>
@if (closure.paidOutBy) {
<div class="dav-row">
<span>Paid Out By</span><strong>{{ closure.paidOutBy }}</strong>
</div>
}
<div class="dav-row">
<span>Closed By</span><strong>{{ closure.closedBy || '—' }}</strong>
</div>
<div class="dav-row">
<span>Approved By</span><strong>{{ closure.approvedBy || '—' }}</strong>
</div>
@if (closure.lockinOverrideReason) {
<div class="dav-row">
<span>Reason</span><strong>{{ closure.lockinOverrideReason }}</strong>
</div>
}
@if (closure.shortfallBorneBy) {
<div class="dav-row">
<span>Cost Borne By</span>
<strong>{{
closure.shortfallBorneBy === 'AGENT' ? 'Agent — ' + closure.agentCode : 'Customer'
}}</strong>
</div>
}
</div>
<div class="dav-card">
<div class="dav-card__head"><span class="dav-card__dot"></span> The Money</div>
<div class="dav-row">
<span>Balance at Close</span><strong>₹{{ closure.balanceAtClose || 0 | formatNumber }}</strong>
</div>
@if (closure.interestRecovered > 0) {
<div class="dav-row">
<span>Interest Recovered</span
><strong class="dav-dr">− ₹{{ closure.interestRecovered | formatNumber }}</strong>
</div>
}
@if (closure.interestToppedUpByAgent > 0) {
<div class="dav-row">
<span>Interest Funded by Agent</span
><strong class="dav-cr">+ ₹{{ closure.interestToppedUpByAgent | formatNumber }}</strong>
</div>
}
<div class="dav-row dav-closure-total">
<span>Paid Out</span><strong>₹{{ closure.paidOut || 0 | formatNumber }}</strong>
</div>
@if (closure.closureType === 'PREMATURE') {
<div class="dav-row">
<span>Agent Penalty ({{ closure.agentCode }})</span
><strong>₹{{ closure.agentCommissionPenalty || 0 | formatNumber }}</strong>
</div>
}
</div>
</div>
<div class="dav-column">
<div class="dav-card">
<div class="dav-card__head"><span class="dav-card__dot"></span> Where It Went</div>
@if (closure.destination === 'OWN_ACCOUNT') {
<div class="dav-row"><span>Paid Into</span><strong>Customer's own account</strong></div>
<div class="dav-row">
<span>Account</span><strong class="dav-mono">{{ closure.creditedAccount?.accountNo }}</strong>
</div>
@if (closure.creditedAccount?.productName) {
<div class="dav-row">
<span>Product</span><strong>{{ closure.creditedAccount.productName }}</strong>
</div>
}
@if (closure.creditedAccount?.owner) {
<div class="dav-row">
<span>Holder</span><strong>{{ closure.creditedAccount.owner }}</strong>
</div>
}
} @else if (closure.destination === 'SUNDRY') {
<div class="dav-row"><span>Parked In</span><strong>Sundry (customer has no account here)</strong></div>
<div class="dav-row">
<span>GL Account</span
><strong class="dav-mono">{{ closure.sundryGl?.glCode }} · {{ closure.sundryGl?.glName }}</strong>
</div>
} @else {
<div class="dav-row"><span>Paid Out</span><strong>Nothing - the balance was nil</strong></div>
}
@if (closure.voucherNo) {
<div class="dav-row">
<span>Voucher</span><strong class="dav-mono">{{ closure.voucherNo }}</strong>
</div>
}
@if (closure.payoutDate) {
<div class="dav-row">
<span>Paid On</span><strong>{{ closure.payoutDate | date: 'dd MMMM yyyy' }}</strong>
</div>
}
@if (closure.passbookNote) {
<div class="dav-row">
<span>Passbook Note</span><strong>{{ closure.passbookNote }}</strong>
</div>
}
</div>
@if (closure.ledgerEntries?.length) {
<div class="dav-card">
<div class="dav-card__head"><span class="dav-card__dot"></span> Ledger Entry</div>
<div class="dav-row">
<span>Transaction ID</span><strong class="dav-mono">{{ closure.ledgerTransactionId }}</strong>
</div>
@for (leg of closure.ledgerEntries; track $index) {
<div class="dav-row">
<span>{{ leg.glCode }} · {{ leg.glName }}</span>
<strong [class.dav-dr]="leg.side === 'DR'" [class.dav-cr]="leg.side === 'CR'">
{{ leg.side }} ₹{{ leg.amount | formatNumber }}
</strong>
</div>
}
</div>
}
</div>
</div>
}
}
<!-- General -->
@if (tab === 'general') {
<div class="dav-columns">
<div class="dav-column">
<div class="dav-card">
<div class="dav-card__head"><span class="dav-card__dot"></span> DDS Details</div>
<div class="dav-row">
<span>Account Number</span><strong class="dav-mono">{{ account.accountNo }}</strong>
</div>
<div class="dav-row">
<span>Party Code</span><strong>{{ account.partyCode }}</strong>
</div>
<div class="dav-row">
<span>Client Name</span><strong>{{ account.clientName }} ({{ account.clientId }})</strong>
</div>
<div class="dav-row">
<span>Agent</span><strong>{{ account.agentCode }} · {{ account.agentName }}</strong>
</div>
<div class="dav-row">
<span>Branch</span><strong>{{ account.officeName }}</strong>
</div>
<div class="dav-row">
<span>Product</span><strong>{{ account.subType }}</strong>
</div>
<div class="dav-row">
<span>Opened On</span><strong>{{ account.openingDate | date: 'dd MMMM yyyy' }}</strong>
</div>
<div class="dav-row">
<span>Matures On</span><strong>{{ account.maturityDate | date: 'dd MMMM yyyy' }}</strong>
</div>
<div class="dav-row">
<span>Daily Deposit</span><strong>₹{{ account.dailyDepositAmount || 0 | formatNumber }}</strong>
</div>
<div class="dav-row">
<span>Term</span><strong>{{ account.month }} months · {{ account.days }} days</strong>
</div>
<div class="dav-row">
<span>Interest Rate</span><strong>{{ account.interestRate }} %</strong>
</div>
<div class="dav-row">
<span>Currency</span><strong>Indian Rupee <em class="dav-mono">[INR]</em></strong>
</div>
</div>
<div class="dav-card">
<div class="dav-card__head"><span class="dav-card__dot"></span> Performance History</div>
<div class="dav-row">
<span>Total Collected</span><strong class="dav-cr">₹{{ totalOf('PARTY_TRAN') | formatNumber }}</strong>
</div>
<div class="dav-row">
<span>Total Interest Earned</span><strong>₹{{ interestEarned | formatNumber }}</strong>
</div>
<div class="dav-row">
<span>Total Paid Out</span><strong class="dav-dr">₹{{ totalDebitsAll | formatNumber }}</strong>
</div>
</div>
</div>
<div class="dav-column">
<div class="dav-card">
<div class="dav-card__head"><span class="dav-card__dot"></span> Account Summary</div>
<div class="dav-row">
<span>Collected to Date</span><strong>₹{{ account.collectedToDate || 0 | formatNumber }}</strong>
</div>
<div class="dav-row">
<span>Expected to Date</span><strong>₹{{ account.expectedToDate || 0 | formatNumber }}</strong>
</div>
<div class="dav-row">
<span>Standing</span>
<strong>
@if (account.advanceDays > 0) {
{{ account.advanceDays }} days ahead
} @else if (account.arrearsDays > 0) {
<span class="dav-dr">{{ account.arrearsDays }} days behind</span>
} @else {
Up to date
}
</strong>
</div>
<div class="dav-row">
<span>Instalments Due</span><strong>{{ account.instalmentsDue ?? 0 }}</strong>
</div>
<div class="dav-row">
<span>Interest Earned</span><strong>₹{{ interestEarned | formatNumber }}</strong>
</div>
<div class="dav-row">
<span>Interest Calculated, Not Posted</span
><strong>₹{{ account.interestCalc || 0 | formatNumber }}</strong>
</div>
<div class="dav-row"><span>Interest Calculated Using</span><strong>Daily Balance</strong></div>
<div class="dav-row"><span>Days in Year</span><strong>365 Days</strong></div>
<div class="dav-row">
<span>Last Interest Date</span
><strong>{{ account.interestDate ? (account.interestDate | date: 'dd MMMM yyyy') : '—' }}</strong>
</div>
<div class="dav-row">
<span>Last Transaction Date</span
><strong>{{ lastTransactionDate ? (lastTransactionDate | date: 'dd MMMM yyyy') : '—' }}</strong>
</div>
<div class="dav-row">
<span>Maturity Amount</span><strong>₹{{ account.maturityAmount || 0 | formatNumber }}</strong>
</div>
<div class="dav-row">
<span>Lien</span>
<strong>{{ account.lien ? 'Yes — ₹' + (account.amountHeldUnderLien || 0) : 'No' }}</strong>
</div>
<div class="dav-row">
<span>Freeze</span><strong>{{ account.freezeCode || 'Normal' }}</strong>
</div>
@if (account.closeDate) {
<div class="dav-row">
<span>Closed On</span><strong>{{ account.closeDate | date: 'dd MMMM yyyy' }}</strong>
</div>
}
</div>
</div>
</div>
}
<!-- Transactions -->
@if (tab === 'transactions') {
<div class="dav-card">
<div class="dav-toolbar">
<div class="dav-datepick">
<span>From</span>
<input type="date" [formControl]="fromDateControl" [max]="today" />
</div>
<div class="dav-datepick">
<span>To</span>
<input type="date" [formControl]="toDateControl" [max]="today" />
</div>
<button class="dav-btn" (click)="showToday()"><fa-icon icon="clock"></fa-icon> Today</button>
@if (hasDateFilters) {
<button class="dav-btn" (click)="clearDateFilters()"><fa-icon icon="times"></fa-icon> Clear</button>
}
<div class="dav-totals">
<span class="dav-cr">Credits ₹{{ totalCredits | formatNumber }}</span>
<span class="dav-dr">Debits ₹{{ totalDebits | formatNumber }}</span>
</div>
<button class="dav-btn dav-btn--primary" (click)="exportCsv()" [disabled]="shownTransactions.length === 0">
Export
</button>
</div>
@if (shownTransactions.length === 0) {
<div class="dav-state">
<fa-icon icon="inbox" size="2x"></fa-icon>
<span>{{
hasDateFilters
? 'Nothing moved on this account in these dates'
: 'Nothing has been credited to this account yet'
}}</span>
</div>
} @else {
<div class="dav-table-wrap">
<table class="dav-table">
<thead>
<tr>
<th>#</th>
<th>ID</th>
<th>Transaction Date</th>
<th>Transaction Type</th>
<th>Voucher</th>
<th>Description</th>
<th class="dav-right">Debit</th>
<th class="dav-right">Credit</th>
<th class="dav-right">Balance</th>
</tr>
</thead>
<tbody>
@for (txn of shownTransactions; track txn.id; let i = $index) {
<tr>
<td>{{ i + 1 }}</td>
<td>{{ txn.id }}</td>
<td>{{ txn.transactionDate | date: 'dd MMMM yyyy' }}</td>
<td>{{ label(txn.transactionType) }}</td>
<td class="dav-mono">{{ txn.voucherNo || '—' }}</td>
<td class="dav-desc">{{ txn.description }}</td>
<td class="dav-right">
{{ txn.direction === 'DEBIT' ? '₹' + (txn.amount | formatNumber) : 'N/A' }}
</td>
<td class="dav-right">
{{ txn.direction === 'CREDIT' ? '₹' + (txn.amount | formatNumber) : 'N/A' }}
</td>
<td class="dav-right">
<strong>₹{{ txn.balanceAfterTransaction || 0 | formatNumber }}</strong>
</td>
</tr>
}
</tbody>
</table>
</div>
}
</div>
}
<!-- Passbook: the same entries oldest first, the way a passbook is written up, and printable -->
@if (tab === 'passbook') {
<div class="dav-card">
<div class="dav-toolbar dav-noprint">
<div class="dav-datepick">
<span>From</span>
<input type="date" [formControl]="fromDateControl" [max]="today" />
</div>
<div class="dav-datepick">
<span>To</span>
<input type="date" [formControl]="toDateControl" [max]="today" />
</div>
@if (hasDateFilters) {
<button class="dav-btn" (click)="clearDateFilters()"><fa-icon icon="times"></fa-icon> Clear</button>
}
<button class="dav-btn dav-btn--primary dav-push" (click)="printPassbook()">
<fa-icon icon="print"></fa-icon> Print
</button>
</div>
<div class="dav-passbook" id="dds-passbook">
<div class="dav-pb-head">
<div>
<div class="dav-pb-title">DDS Passbook</div>
<div class="dav-pb-sub">{{ account.officeName }}</div>
</div>
<div class="dav-pb-grid">
<span>Account No</span><strong class="dav-mono">{{ account.accountNo }}</strong> <span>Party Code</span
><strong>{{ account.partyCode }}</strong> <span>Name</span
><strong>{{ account.clientName || account.partyName }}</strong> <span>Product</span
><strong>{{ account.subType }} · {{ account.interestRate }} %</strong> <span>Opened On</span
><strong>{{ account.openingDate | date: 'dd/MM/yyyy' }}</strong> <span>Agent</span
><strong>{{ account.agentCode }}</strong>
</div>
</div>
@if (passbookEntries.length === 0) {
<div class="dav-state">
<span>{{ hasDateFilters ? 'No entries in these dates' : 'No entries yet' }}</span>
</div>
} @else {
<table class="dav-table dav-pb-table">
<thead>
<tr>
<th>Date</th>
<th>Voucher</th>
<th>Particulars</th>
<th class="dav-right">Withdrawals</th>
<th class="dav-right">Deposits</th>
<th class="dav-right">Balance</th>
</tr>
</thead>
<tbody>
@for (txn of passbookEntries; track txn.id) {
<tr>
<td>{{ txn.transactionDate | date: 'dd/MM/yyyy' }}</td>
<td class="dav-mono">{{ txn.voucherNo || '' }}</td>
<td>
{{ label(txn.transactionType) }}
@if (txn.description) {
<span class="dav-pb-note">{{ txn.description }}</span>
}
</td>
<td class="dav-right">{{ txn.direction === 'DEBIT' ? (txn.amount | formatNumber) : '' }}</td>
<td class="dav-right">{{ txn.direction === 'CREDIT' ? (txn.amount | formatNumber) : '' }}</td>
<td class="dav-right">
<strong>{{ txn.balanceAfterTransaction || 0 | formatNumber }}</strong>
</td>
</tr>
}
</tbody>
<tfoot>
<tr>
<td colspan="3"><strong>Total</strong></td>
<td class="dav-right">
<strong>{{ totalDebits | formatNumber }}</strong>
</td>
<td class="dav-right">
<strong>{{ totalCredits | formatNumber }}</strong>
</td>
<td class="dav-right">
<strong>{{ passbookClosingBalance | formatNumber }}</strong>
</td>
</tr>
</tfoot>
</table>
}
</div>
</div>
}
<!-- Nominees -->
@if (tab === 'nominees') {
<div class="dav-card">
@if (!account.nominees || account.nominees.length === 0) {
<div class="dav-state">
<fa-icon icon="user" size="2x"></fa-icon>
<span>No nominee on this account</span>
</div>
} @else {
<div class="dav-table-wrap">
<table class="dav-table">
<thead>
<tr>
<th>#</th>
<th>Name</th>
<th>Relation</th>
<th>Gender</th>
<th>Mobile</th>
<th>Birth Date</th>
<th>Nominated On</th>
<th>Guardian</th>
<th class="dav-right">{{ account.nomineeSplitMode === 'Sequence' ? 'Order' : 'Share %' }}</th>
</tr>
</thead>
<tbody>
@for (nominee of account.nominees; track nominee.id; let i = $index) {
<tr>
<td>{{ i + 1 }}</td>
<td>
<strong>{{ nominee.name }}</strong>
</td>
<td>{{ nominee.relation }}</td>
<td>{{ nominee.gender }}</td>
<td>{{ nominee.mobileNo || '—' }}</td>
<td>{{ nominee.birthDate ? (nominee.birthDate | date: 'dd MMM yyyy') : '—' }}</td>
<td>{{ nominee.nominationDate ? (nominee.nominationDate | date: 'dd MMM yyyy') : '—' }}</td>
<td>{{ nominee.guardianName || '—' }}</td>
<td class="dav-right">
{{ account.nomineeSplitMode === 'Sequence' ? nominee.sequenceNo : (nominee.sharePercent ?? '—') }}
</td>
</tr>
}
</tbody>
</table>
</div>
}
</div>
}
}
</div>
@@ -0,0 +1,459 @@
/**
* 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/.
*/
/* One DDS account's page, laid out the way the savings account page is: a banner, tabs, cards. */
.dav-page {
display: flex;
flex-direction: column;
gap: 16px;
padding: 24px 32px;
}
.dav-mono {
font-family: ui-monospace, 'SF Mono', 'Cascadia Mono', monospace;
font-style: normal;
}
.dav-cr {
color: #047857;
}
.dav-dr {
color: #b91c1c;
}
.dav-right {
text-align: right !important;
}
/* ── Banner ───────────────────────────────────────────────────────────── */
.dav-banner {
display: flex;
align-items: center;
flex-wrap: wrap;
gap: 16px;
padding: 16px 24px;
border-radius: 16px;
color: #fff;
background: linear-gradient(90deg, #1e3a8a 0%, #2d3fb4 55%, #5b47d6 100%);
box-shadow: 0 8px 24px rgb(30 58 138 / 25%);
}
.dav-banner__icon {
width: 48px;
height: 48px;
border-radius: 12px;
display: grid;
place-items: center;
font-size: 1.3rem;
background: rgb(255 255 255 / 16%);
border: 1px solid rgb(255 255 255 / 25%);
}
.dav-banner__text {
display: flex;
flex-direction: column;
gap: 4px;
min-width: 0;
}
.dav-pill {
align-self: flex-start;
padding: 2px 10px;
border-radius: 999px;
font-size: 0.7rem;
font-weight: 700;
letter-spacing: 0.04em;
background: rgb(16 185 129 / 25%);
color: #a7f3d0;
border: 1px solid rgb(167 243 208 / 40%);
}
.dav-pill--closed {
background: rgb(148 163 184 / 25%);
color: #e2e8f0;
border-color: rgb(226 232 240 / 40%);
}
.dav-banner__title {
margin: 0;
font-size: 1.25rem;
font-weight: 700;
}
.dav-banner__meta {
display: flex;
flex-wrap: wrap;
align-items: baseline;
gap: 6px 12px;
}
.dav-banner__label {
font-size: 0.7rem;
font-weight: 700;
letter-spacing: 0.06em;
text-transform: uppercase;
color: rgb(255 255 255 / 60%);
}
.dav-banner__value {
font-size: 0.9rem;
font-weight: 600;
}
.dav-banner__boxes {
margin-left: auto;
display: flex;
gap: 12px;
}
.dav-box {
display: flex;
flex-direction: column;
gap: 4px;
padding: 10px 18px;
border-radius: 12px;
background: rgb(255 255 255 / 12%);
border: 1px solid rgb(255 255 255 / 25%);
}
.dav-box__label {
font-size: 0.68rem;
font-weight: 700;
letter-spacing: 0.06em;
text-transform: uppercase;
color: rgb(255 255 255 / 70%);
}
.dav-box__value {
font-size: 1.15rem;
font-weight: 700;
font-variant-numeric: tabular-nums;
}
.dav-banner__actions {
display: flex;
gap: 8px;
}
.dav-icon-btn {
width: 38px;
height: 38px;
border-radius: 50%;
border: 1px solid rgb(255 255 255 / 35%);
background: rgb(255 255 255 / 12%);
color: #fff;
cursor: pointer;
&:hover {
background: rgb(255 255 255 / 22%);
}
}
/* ── Tabs ─────────────────────────────────────────────────────────────── */
.dav-tabs {
display: flex;
gap: 8px;
padding: 8px;
border-radius: 14px;
background: #fff;
border: 1px solid rgb(0 0 0 / 7%);
}
.dav-tab {
flex: 1;
display: flex;
align-items: center;
justify-content: center;
gap: 6px;
padding: 10px 16px;
border: 0;
border-radius: 10px;
background: transparent;
font-size: 0.95rem;
font-weight: 600;
color: #475569;
cursor: pointer;
&:hover {
background: #f1f5f9;
}
}
.dav-tab--on {
color: #fff;
background: linear-gradient(90deg, #1d4ed8, #2563eb);
box-shadow: 0 4px 12px rgb(37 99 235 / 30%);
&:hover {
background: linear-gradient(90deg, #1d4ed8, #2563eb);
}
}
/* ── Cards ────────────────────────────────────────────────────────────── */
.dav-columns {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(380px, 1fr));
gap: 16px;
align-items: start;
}
.dav-column {
display: flex;
flex-direction: column;
gap: 16px;
}
.dav-card {
border-radius: 14px;
background: #fff;
border: 1px solid rgb(0 0 0 / 7%);
overflow: hidden;
}
.dav-card__head {
display: flex;
align-items: center;
gap: 12px;
padding: 14px 20px;
font-size: 1rem;
font-weight: 700;
background: #f8fafc;
border-bottom: 1px solid rgb(0 0 0 / 7%);
}
.dav-card__dot {
width: 30px;
height: 30px;
border-radius: 8px;
background: linear-gradient(135deg, #2563eb, #1e40af);
}
.dav-row {
display: flex;
justify-content: space-between;
gap: 16px;
padding: 12px 20px;
border-bottom: 1px dashed rgb(0 0 0 / 7%);
font-size: 0.92rem;
&:last-child {
border-bottom: 0;
}
span {
color: #334155;
}
strong {
text-align: right;
font-weight: 600;
}
}
.dav-state {
display: flex;
flex-direction: column;
align-items: center;
gap: 10px;
padding: 48px 16px;
color: rgb(0 0 0 / 50%);
}
/* ── Transactions ─────────────────────────────────────────────────────── */
.dav-toolbar {
display: flex;
align-items: center;
flex-wrap: wrap;
gap: 12px;
padding: 14px 20px;
background: #f8fafc;
border-bottom: 1px solid rgb(0 0 0 / 7%);
}
.dav-datepick {
display: flex;
flex-direction: column;
gap: 2px;
span {
font-size: 0.68rem;
font-weight: 700;
letter-spacing: 0.06em;
text-transform: uppercase;
color: rgb(0 0 0 / 45%);
}
input {
padding: 6px 10px;
border-radius: 8px;
border: 1px solid rgb(0 0 0 / 15%);
font: inherit;
}
}
.dav-btn {
display: inline-flex;
align-items: center;
gap: 6px;
padding: 8px 14px;
border-radius: 8px;
border: 1px solid rgb(0 0 0 / 15%);
background: #fff;
font-weight: 600;
cursor: pointer;
&:disabled {
opacity: 0.5;
cursor: default;
}
}
.dav-btn--primary {
color: #fff;
border-color: transparent;
background: linear-gradient(90deg, #1d4ed8, #2563eb);
}
.dav-totals {
margin-left: auto;
display: flex;
gap: 16px;
font-size: 0.88rem;
font-weight: 700;
font-variant-numeric: tabular-nums;
}
.dav-table-wrap {
overflow-x: auto;
}
.dav-table {
width: 100%;
border-collapse: collapse;
font-size: 0.92rem;
th {
padding: 14px 16px;
text-align: left;
font-size: 0.72rem;
font-weight: 700;
letter-spacing: 0.06em;
text-transform: uppercase;
color: #475569;
border-bottom: 1px solid rgb(0 0 0 / 8%);
white-space: nowrap;
}
td {
padding: 16px;
border-bottom: 1px solid rgb(0 0 0 / 5%);
font-variant-numeric: tabular-nums;
}
tbody tr:nth-child(odd) {
background: #f8fafc;
}
}
.dav-desc {
max-width: 320px;
color: rgb(0 0 0 / 65%);
}
@media (width <= 720px) {
.dav-page {
padding: 16px;
}
.dav-banner__boxes {
margin-left: 0;
width: 100%;
}
.dav-tab {
font-size: 0.85rem;
padding: 8px;
}
}
/* ── Passbook ─────────────────────────────────────────────────────────── */
.dav-push {
margin-left: auto;
}
.dav-passbook {
padding: 20px;
}
.dav-pb-head {
display: flex;
justify-content: space-between;
flex-wrap: wrap;
gap: 16px 32px;
margin-bottom: 16px;
padding-bottom: 16px;
border-bottom: 2px solid #1e3a8a;
}
.dav-pb-title {
font-size: 1.2rem;
font-weight: 700;
color: #1e3a8a;
}
.dav-pb-sub {
font-size: 0.85rem;
color: rgb(0 0 0 / 55%);
}
.dav-pb-grid {
display: grid;
grid-template-columns: auto auto;
gap: 4px 16px;
font-size: 0.88rem;
span {
color: rgb(0 0 0 / 55%);
}
}
.dav-pb-table {
tfoot td {
padding: 14px 16px;
border-top: 2px solid rgb(0 0 0 / 15%);
}
}
.dav-pb-note {
display: block;
font-size: 0.78rem;
color: rgb(0 0 0 / 50%);
}
/* Closure tab */
.dav-closure-tag {
margin-left: 8px;
padding: 1px 8px;
border-radius: 999px;
background: #fff3e0;
color: #e65100;
font-size: 0.75rem;
font-style: normal;
}
.dav-closure-total {
border-top: 1px solid rgb(0 0 0 / 10%);
font-weight: 600;
}
@@ -0,0 +1,297 @@
/**
* 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 { FormControl, ReactiveFormsModule } from '@angular/forms';
import { NgClass } from '@angular/common';
import { MatTooltipModule } from '@angular/material/tooltip';
import { MatProgressSpinnerModule } from '@angular/material/progress-spinner';
import { FaIconComponent } from '@fortawesome/angular-fontawesome';
import { forkJoin, of } from 'rxjs';
import { catchError } from 'rxjs/operators';
import { STANDALONE_SHARED_IMPORTS } from 'app/standalone-shared.module';
import { FormatNumberPipe } from 'app/pipes/format-number.pipe';
import { OrganizationService } from 'app/organization/organization.service';
/** What each DDS transaction type is called on screen. */
const TRANSACTION_LABELS: Record<string, string> = {
PARTY_TRAN: 'Collection',
INTEREST_POST: 'Interest',
OD_INTEREST_POST: 'OD Interest',
MATURITY_PAYOUT: 'Maturity Payout',
PREMATURE_PAYOUT: 'Premature Payout',
PREMATURE_INT_RECOVERY: 'Interest Recovered (Premature)',
PREMATURE_AGENT_TOP_UP: 'Interest Funded by Agent',
WITHDRAW_DEPOSITE: 'Transfer (Withdraw Deposite)',
TRANSFER: 'Transfer'
};
/**
* One DDS account and everything that has moved on it, as the savings account page shows a savings
* account: the account's terms and where it stands, then its transactions with the balance after
* each, narrowed to a day or a range.
*
* Read-only. The account is changed from DDS Party Master; its money moves through collections, the
* cashier and the batch runs.
*/
@Component({
selector: 'mifosx-dds-account-view',
templateUrl: './dds-account-view.component.html',
styleUrls: ['./dds-account-view.component.scss'],
standalone: true,
imports: [
...STANDALONE_SHARED_IMPORTS,
ReactiveFormsModule,
NgClass,
FaIconComponent,
MatTooltipModule,
MatProgressSpinnerModule,
FormatNumberPipe
]
})
export class DdsAccountViewComponent implements OnInit {
private route = inject(ActivatedRoute);
private router = inject(Router);
private organizationService = inject(OrganizationService);
isLoading = true;
account: any = null;
private transactions: any[] = [];
/** The page's tabs, as the savings account page has them - only those that apply to DDS. */
tab: 'general' | 'transactions' | 'passbook' | 'nominees' | 'closure' = 'general';
/** How a closed account was closed - read only for closed accounts, for the Closure tab. */
closure: any = null;
/** What each kind of closure is called on screen. */
readonly closureLabels: Record<string, string> = {
PREMATURE: 'Premature Close',
MATURITY_PAYOUT_AND_CLOSE: 'Maturity Payout & Close',
MATURITY_PAYOUT_THEN_CLOSE: 'Maturity Payout, then Closed',
MATURITY_AUTO_CLOSE: 'Maturity Auto Close (night job)',
CLOSED_NIL_BALANCE: 'Closed with nil balance'
};
/** The passbook reads oldest first, as it is written up. */
get passbookEntries(): any[] {
return [...this.shownTransactions].reverse();
}
/** The balance after the last entry shown. */
get passbookClosingBalance(): number {
const entries = this.passbookEntries;
return entries.length > 0 ? Number(entries[entries.length - 1].balanceAfterTransaction || 0) : 0;
}
/**
* Prints the passbook alone. It is copied into a window of its own with only its own styles, so the
* page's menus, banner and tabs do not come out on the paper.
*/
printPassbook(): void {
const passbook = document.getElementById('dds-passbook');
const popup = window.open('', '_blank', 'width=900,height=700');
if (!passbook || !popup) {
return;
}
popup.document.write(`<html><head><title>${this.account?.accountNo || 'DDS'} Passbook</title><style>
body { font-family: Arial, sans-serif; margin: 24px; color: #111; }
.dav-pb-head { display: flex; justify-content: space-between; gap: 24px; margin-bottom: 16px; }
.dav-pb-title { font-size: 20px; font-weight: 700; }
.dav-pb-sub { color: #555; }
.dav-pb-grid { display: grid; grid-template-columns: auto auto; gap: 2px 12px; font-size: 13px; }
.dav-pb-grid span { color: #555; }
table { width: 100%; border-collapse: collapse; font-size: 13px; }
th, td { border: 1px solid #999; padding: 6px 8px; text-align: left; }
.dav-right { text-align: right; }
.dav-pb-note { display: block; font-size: 11px; color: #555; }
</style></head><body>${passbook.innerHTML}</body></html>`);
popup.document.close();
popup.focus();
popup.print();
}
fromDateControl = new FormControl('');
toDateControl = new FormControl('');
readonly today = new Date().toISOString().split('T')[0];
ngOnInit(): void {
const partyId = this.route.snapshot.paramMap.get('partyId');
if (!partyId) {
this.isLoading = false;
return;
}
forkJoin({
account: this.organizationService.getParty(partyId).pipe(catchError(() => of(null))),
transactions: this.organizationService.getPartyTransactionsOfParty(partyId).pipe(catchError(() => of([])))
}).subscribe(({ account, transactions }) => {
this.account = account;
if (account?.status === 'CLOSED') {
this.organizationService.getPartyClosure(partyId).subscribe({
next: (closure: any) => (this.closure = closure),
error: () => (this.closure = null)
});
}
// Newest first, and within a day the later entry first - the order a passbook is read back in.
this.transactions = (Array.isArray(transactions) ? transactions : []).sort(
(a: any, b: any) =>
String(b.transactionDate).localeCompare(String(a.transactionDate)) || Number(b.id) - Number(a.id)
);
this.isLoading = false;
});
}
/** The transactions in the chosen range - all of them when no range is set. */
get shownTransactions(): any[] {
const from = this.fromDateControl.value;
const to = this.toDateControl.value;
return this.transactions.filter((txn: any) => {
const day = String(txn.transactionDate || '');
if (from && day < String(from)) {
return false;
}
if (to && day > String(to)) {
return false;
}
return true;
});
}
get totalCredits(): number {
return this.sumOf('CREDIT');
}
get totalDebits(): number {
return this.sumOf('DEBIT');
}
private sumOf(direction: string): number {
return this.shownTransactions
.filter((txn: any) => txn.direction === direction)
.reduce((sum: number, txn: any) => sum + Number(txn.amount || 0), 0);
}
/** Everything of one type ever credited, whatever the date filter says - for the summary cards. */
totalOf(type: string): number {
return this.transactions
.filter((txn: any) => txn.transactionType === type && txn.direction === 'CREDIT')
.reduce((sum: number, txn: any) => sum + Number(txn.amount || 0), 0);
}
/** Interest credited, less any taken back on an early closure. */
get interestEarned(): number {
const recovered = this.transactions
.filter((txn: any) => txn.transactionType === 'PREMATURE_INT_RECOVERY')
.reduce((sum: number, txn: any) => sum + Number(txn.amount || 0), 0);
return (
this.totalOf('INTEREST_POST') +
this.totalOf('OD_INTEREST_POST') +
this.totalOf('PREMATURE_AGENT_TOP_UP') -
recovered
);
}
/** Money that has left the account - payouts, withdrawals, transfers out. */
get totalDebitsAll(): number {
return this.transactions
.filter((txn: any) => txn.direction === 'DEBIT' && txn.transactionType !== 'PREMATURE_INT_RECOVERY')
.reduce((sum: number, txn: any) => sum + Number(txn.amount || 0), 0);
}
get lastTransactionDate(): string | null {
return this.transactions.length > 0 ? this.transactions[0].transactionDate : null;
}
/** The rows on screen, as a spreadsheet the branch can keep or send on. */
exportCsv(): void {
const header = [
'#',
'ID',
'Transaction Date',
'Transaction Type',
'Voucher',
'Description',
'Account',
'Debit',
'Credit',
'Balance'
];
const quote = (value: any) => `"${String(value ?? '').replace(/"/g, '""')}"`;
const lines = this.shownTransactions.map((txn: any, index: number) =>
[
index + 1,
txn.id,
txn.transactionDate,
this.label(txn.transactionType),
txn.voucherNo,
txn.description,
txn.accountNo,
txn.direction === 'DEBIT' ? txn.amount : '',
txn.direction === 'CREDIT' ? txn.amount : '',
txn.balanceAfterTransaction
]
.map(quote)
.join(',')
);
const blob = new Blob(
[[
header.map(quote).join(','),
...lines
].join('\r\n')],
{ type: 'text/csv;charset=utf-8' }
);
const link = document.createElement('a');
link.href = URL.createObjectURL(blob);
link.download = `${this.account?.accountNo || 'dds-account'}-transactions.csv`;
link.click();
URL.revokeObjectURL(link.href);
}
get hasDateFilters(): boolean {
return !!(this.fromDateControl.value || this.toDateControl.value);
}
showToday(): void {
this.fromDateControl.setValue(this.today);
this.toDateControl.setValue(this.today);
}
clearDateFilters(): void {
this.fromDateControl.setValue('');
this.toDateControl.setValue('');
}
label(type: string): string {
return TRANSACTION_LABELS[type] || String(type || '').replace(/_/g, ' ');
}
statusClass(): string {
switch (this.account?.status) {
case 'ACTIVE':
return 'status--active';
case 'CLOSED':
return 'status--closed';
default:
return 'status--default';
}
}
back(): void {
this.router.navigate(['/accounts/dds-accounts']);
}
openInPartyMaster(): void {
this.router.navigate([
'/accounts/dds',
this.account.id,
'edit'
]);
}
}
@@ -0,0 +1,211 @@
<!--
Copyright since 2025 Mifos Initiative
This Source Code Form is subject to the terms of the Mozilla Public
License, v. 2.0. If a copy of the MPL was not distributed with this
file, You can obtain one at http://mozilla.org/MPL/2.0/.
-->
<div class="acl-page">
<!-- Header -->
<div class="acl-header accent--rd">
<div class="acl-header__icon">
<fa-icon icon="coins"></fa-icon>
</div>
<div class="acl-header__text">
<h2 class="acl-header__title">DDS Accounts</h2>
<span class="acl-header__count">
@if (isLoading) {
Loading...
} @else {
{{ allCount }} accounts · {{ activeCount }} active · {{ closedCount }} closed
}
</span>
</div>
</div>
<!-- Search Bar -->
<div class="acl-search-wrap">
<div class="acl-search">
<fa-icon icon="search" class="acl-search__icon"></fa-icon>
<input
[formControl]="searchControl"
class="acl-search__input"
[placeholder]="
searchType === 'accountNo' ? 'Search by account number or party code…' : 'Search by client name…'
"
/>
@if (searchControl.value) {
<button class="acl-search__clear" (click)="clearSearch()">
<fa-icon icon="times"></fa-icon>
</button>
}
</div>
<button
class="acl-btn acl-btn--toggle"
(click)="toggleSearchType()"
[matTooltip]="'Switch to ' + (searchType === 'accountNo' ? 'client name' : 'account number') + ' search'"
>
<fa-icon icon="exchange-alt"></fa-icon>
{{ searchType === 'accountNo' ? 'Acct No' : 'Client' }}
</button>
<button class="acl-btn acl-btn--icon" (click)="loadAccounts()" matTooltip="Refresh">
<fa-icon icon="sync-alt"></fa-icon>
</button>
</div>
<!-- Status: all accounts, only the open ones, or only the closed -->
<div class="dds-status-filter" role="tablist">
<button
type="button"
class="dds-status-filter__btn"
[class.is-on]="statusFilter === 'ALL'"
(click)="setStatusFilter('ALL')"
>
All <span class="dds-status-filter__count">{{ allCount }}</span>
</button>
<button
type="button"
class="dds-status-filter__btn dds-status-filter__btn--active"
[class.is-on]="statusFilter === 'ACTIVE'"
(click)="setStatusFilter('ACTIVE')"
>
Active <span class="dds-status-filter__count">{{ activeCount }}</span>
</button>
<button
type="button"
class="dds-status-filter__btn dds-status-filter__btn--closed"
[class.is-on]="statusFilter === 'CLOSED'"
(click)="setStatusFilter('CLOSED')"
>
Closed <span class="dds-status-filter__count">{{ closedCount }}</span>
</button>
</div>
<!-- Date Filters: by the day the account was opened -->
<div class="acl-filters">
<div class="acl-filters__group">
<div class="acl-datepick">
<span class="acl-datepick__label">Opened From</span>
<input type="date" class="acl-datepick__input" [formControl]="fromDateControl" [max]="today" />
</div>
<div class="acl-filters__arrow">
<fa-icon icon="arrow-right"></fa-icon>
</div>
<div class="acl-datepick">
<span class="acl-datepick__label">To</span>
<input type="date" class="acl-datepick__input" [formControl]="toDateControl" [max]="today" />
</div>
</div>
<div class="acl-filters__sep"></div>
<div class="acl-datepick acl-datepick--ason">
<span class="acl-datepick__label">
<fa-icon icon="clock"></fa-icon>
As On Date
</span>
<input type="date" class="acl-datepick__input" [formControl]="asOnDateControl" [max]="today" />
</div>
@if (hasDateFilters) {
<button class="acl-filters__clear" (click)="clearDateFilters()">
<fa-icon icon="times"></fa-icon>
Clear
</button>
}
</div>
<!-- Table -->
<div class="acl-card">
@if (isLoading) {
<div class="acl-state acl-state--loading">
<mat-spinner diameter="32"></mat-spinner>
<span>Loading accounts…</span>
</div>
}
@if (!isLoading && rows.length === 0) {
<div class="acl-state acl-state--empty">
<div class="acl-state__icon">
<fa-icon icon="inbox"></fa-icon>
</div>
<p class="acl-state__title">No DDS accounts found</p>
<p class="acl-state__sub">Try adjusting your search</p>
</div>
}
@if (!isLoading && rows.length > 0) {
<div class="acl-table-wrap">
<table mat-table [dataSource]="rows" class="acl-table">
<ng-container matColumnDef="accountNo">
<th mat-header-cell *matHeaderCellDef class="acl-th">Account No</th>
<td mat-cell *matCellDef="let row" class="acl-td">
<span class="acl-acct-no">{{ row.accountNo }}</span>
</td>
</ng-container>
<ng-container matColumnDef="depositType">
<th mat-header-cell *matHeaderCellDef class="acl-th acl-th--center">Type</th>
<td mat-cell *matCellDef="let row" class="acl-td acl-td--center">
<span class="acl-type-badge dtype--rd">DDS</span>
</td>
</ng-container>
<ng-container matColumnDef="clientName">
<th mat-header-cell *matHeaderCellDef class="acl-th">Client</th>
<td mat-cell *matCellDef="let row" class="acl-td">
<div class="acl-client">
<div class="acl-client__avatar">{{ (row.clientName || row.partyName || '?')[0] }}</div>
<span class="acl-client__name">{{ row.clientName || row.partyName }}</span>
</div>
</td>
</ng-container>
<ng-container matColumnDef="productName">
<th mat-header-cell *matHeaderCellDef class="acl-th">Product</th>
<td mat-cell *matCellDef="let row" class="acl-td">
<span class="acl-product">{{ row.subType }}</span>
</td>
</ng-container>
<ng-container matColumnDef="balance">
<th mat-header-cell *matHeaderCellDef class="acl-th acl-th--right">Balance</th>
<td mat-cell *matCellDef="let row" class="acl-td acl-td--right">
<span class="acl-balance">₹ {{ row.currentBalance || 0 | formatNumber }}</span>
</td>
</ng-container>
<ng-container matColumnDef="status">
<th mat-header-cell *matHeaderCellDef class="acl-th acl-th--center">Status</th>
<td mat-cell *matCellDef="let row" class="acl-td acl-td--center">
<span class="acl-status" [ngClass]="getStatusClass(row)">{{ row.status }}</span>
</td>
</ng-container>
<ng-container matColumnDef="actions">
<th mat-header-cell *matHeaderCellDef class="acl-th acl-th--center">Actions</th>
<td mat-cell *matCellDef="let row" class="acl-td acl-td--center">
<button class="acl-view-btn" (click)="$event.stopPropagation(); viewAccount(row)" matTooltip="View">
<fa-icon icon="eye"></fa-icon>
</button>
</td>
</ng-container>
<tr mat-header-row *matHeaderRowDef="displayedColumns" class="acl-header-row"></tr>
<tr mat-row *matRowDef="let row; columns: displayedColumns" class="acl-row" (click)="viewAccount(row)"></tr>
</table>
</div>
<mat-paginator
[length]="totalRecords"
[pageSize]="pageSize"
[pageIndex]="pageIndex"
[pageSizeOptions]="[10, 20, 50, 100]"
(page)="onPageChange($event)"
class="acl-paginator"
showFirstLastButtons
></mat-paginator>
}
</div>
</div>
@@ -0,0 +1,56 @@
/**
* 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/.
*/
/* All / Active / Closed - which DDS accounts the list shows. */
.dds-status-filter {
display: flex;
flex-wrap: wrap;
gap: 8px;
margin: 0 0 12px;
&__btn {
display: inline-flex;
align-items: center;
gap: 8px;
padding: 6px 14px;
border: 1px solid rgb(0 0 0 / 12%);
border-radius: 999px;
background: #fff;
color: rgb(0 0 0 / 70%);
font-size: 0.85rem;
font-weight: 500;
cursor: pointer;
&.is-on {
border-color: #1565c0;
background: #e3f2fd;
color: #0d47a1;
}
&--active.is-on {
border-color: #2e7d32;
background: #e8f5e9;
color: #1b5e20;
}
&--closed.is-on {
border-color: #c62828;
background: #ffebee;
color: #b71c1c;
}
}
&__count {
min-width: 22px;
padding: 0 6px;
border-radius: 999px;
background: rgb(0 0 0 / 8%);
font-size: 0.75rem;
text-align: center;
}
}
@@ -0,0 +1,265 @@
/**
* 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, OnDestroy, OnInit, inject } from '@angular/core';
import { Router } from '@angular/router';
import { FormControl, ReactiveFormsModule } from '@angular/forms';
import { NgClass } from '@angular/common';
import { MatPaginator, PageEvent } from '@angular/material/paginator';
import {
MatCell,
MatCellDef,
MatColumnDef,
MatHeaderCell,
MatHeaderCellDef,
MatHeaderRow,
MatHeaderRowDef,
MatRow,
MatRowDef,
MatTable
} from '@angular/material/table';
import { MatTooltipModule } from '@angular/material/tooltip';
import { MatProgressSpinnerModule } from '@angular/material/progress-spinner';
import { FaIconComponent } from '@fortawesome/angular-fontawesome';
import { Subject, merge } from 'rxjs';
import { debounceTime, distinctUntilChanged, takeUntil } from 'rxjs/operators';
import { STANDALONE_SHARED_IMPORTS } from 'app/standalone-shared.module';
import { FormatNumberPipe } from 'app/pipes/format-number.pipe';
import { OrganizationService } from 'app/organization/organization.service';
/**
* The DDS accounts, listed the way Savings Accounts lists savings: account number, client, product,
* balance and status, searched by account number or client and narrowed by opening date. Each opens
* onto the account's own page and its transactions.
*
* Read-only. Accounts are opened and changed from DDS Party Master; money moves through collections,
* the cashier and the batch runs.
*/
@Component({
selector: 'mifosx-dds-accounts',
templateUrl: './dds-accounts.component.html',
// The Savings Accounts list's own look, so the two screens read as one family.
styleUrls: [
'../../accounts/accounts-list/accounts-list.component.scss',
'./dds-accounts.component.scss'
],
standalone: true,
imports: [
...STANDALONE_SHARED_IMPORTS,
ReactiveFormsModule,
NgClass,
FaIconComponent,
MatTable,
MatColumnDef,
MatHeaderCellDef,
MatHeaderCell,
MatCellDef,
MatCell,
MatHeaderRowDef,
MatHeaderRow,
MatRowDef,
MatRow,
MatPaginator,
MatTooltipModule,
MatProgressSpinnerModule,
FormatNumberPipe
]
})
export class DdsAccountsComponent implements OnInit, OnDestroy {
private organizationService = inject(OrganizationService);
private router = inject(Router);
private destroy$ = new Subject<void>();
displayedColumns = [
'accountNo',
'depositType',
'clientName',
'productName',
'balance',
'status',
'actions'
];
/** Every DDS account, fetched once; search, dates and paging work on this. */
private allAccounts: any[] = [];
/** The accounts matching the search and dates, whatever their status - what the status counts read. */
private beforeStatus: any[] = [];
/** The accounts matching the search, dates and status, before paging. */
private matching: any[] = [];
/** Which accounts are shown: all of them, only the open ones, or only the closed. */
statusFilter: 'ALL' | 'ACTIVE' | 'CLOSED' = 'ALL';
/** The page on screen. */
rows: any[] = [];
pageSize = 20;
pageIndex = 0;
isLoading = false;
searchControl = new FormControl('');
searchType: 'accountNo' | 'clientName' = 'accountNo';
fromDateControl = new FormControl('');
toDateControl = new FormControl('');
asOnDateControl = new FormControl('');
readonly today = new Date().toISOString().split('T')[0];
get totalRecords(): number {
return this.matching.length;
}
get activeCount(): number {
return this.beforeStatus.filter((account) => account.status === 'ACTIVE').length;
}
get closedCount(): number {
return this.beforeStatus.filter((account) => account.status === 'CLOSED').length;
}
get allCount(): number {
return this.beforeStatus.length;
}
setStatusFilter(status: 'ALL' | 'ACTIVE' | 'CLOSED'): void {
this.statusFilter = status;
this.pageIndex = 0;
this.applyFilters();
}
get hasDateFilters(): boolean {
return !!((this.fromDateControl.value && this.toDateControl.value) || this.asOnDateControl.value);
}
ngOnInit(): void {
this.loadAccounts();
this.searchControl.valueChanges
.pipe(debounceTime(300), distinctUntilChanged(), takeUntil(this.destroy$))
.subscribe(() => {
this.pageIndex = 0;
this.applyFilters();
});
merge(this.fromDateControl.valueChanges, this.toDateControl.valueChanges, this.asOnDateControl.valueChanges)
.pipe(debounceTime(300), takeUntil(this.destroy$))
.subscribe(() => {
this.pageIndex = 0;
this.applyFilters();
});
}
ngOnDestroy(): void {
this.destroy$.next();
this.destroy$.complete();
}
loadAccounts(): void {
this.isLoading = true;
this.organizationService
.getParties()
.pipe(takeUntil(this.destroy$))
.subscribe({
next: (parties: any) => {
// Newest first, as the savings list shows them.
this.allAccounts = (Array.isArray(parties) ? parties : parties?.pageItems || []).sort(
(a: any, b: any) => Number(b.id) - Number(a.id)
);
this.isLoading = false;
this.applyFilters();
},
error: () => {
this.allAccounts = [];
this.isLoading = false;
this.applyFilters();
}
});
}
/** Search by account number or client, and by the day the account was opened. */
private applyFilters(): void {
const term = String(this.searchControl.value || '')
.trim()
.toLowerCase();
const fromVal = this.fromDateControl.value;
const toVal = this.toDateControl.value;
const bothDates = !!(fromVal && toVal);
const asOn = this.asOnDateControl.value;
this.beforeStatus = this.allAccounts.filter((account: any) => {
if (term) {
const haystack =
this.searchType === 'accountNo'
? `${account.accountNo || ''} ${account.partyCode || ''}`
: `${account.clientName || ''} ${account.partyName || ''}`;
if (!haystack.toLowerCase().includes(term)) {
return false;
}
}
const opened = String(account.openingDate || '');
if (bothDates && opened && (opened < String(fromVal) || opened > String(toVal))) {
return false;
}
// As on a date: the accounts that had been opened by then.
if (asOn && opened && opened > String(asOn)) {
return false;
}
return true;
});
this.matching =
this.statusFilter === 'ALL'
? this.beforeStatus
: this.beforeStatus.filter((account: any) => account.status === this.statusFilter);
this.showPage();
}
private showPage(): void {
const start = this.pageIndex * this.pageSize;
this.rows = this.matching.slice(start, start + this.pageSize);
}
onPageChange(event: PageEvent): void {
this.pageIndex = event.pageIndex;
this.pageSize = event.pageSize;
this.showPage();
}
toggleSearchType(): void {
this.searchType = this.searchType === 'accountNo' ? 'clientName' : 'accountNo';
this.pageIndex = 0;
this.applyFilters();
}
clearSearch(): void {
this.searchControl.setValue('');
}
clearDateFilters(): void {
this.fromDateControl.setValue('', { emitEvent: false });
this.toDateControl.setValue('', { emitEvent: false });
this.asOnDateControl.setValue('', { emitEvent: false });
this.pageIndex = 0;
this.applyFilters();
}
viewAccount(account: any): void {
this.router.navigate([
'/accounts/dds-accounts',
account.id
]);
}
getStatusClass(account: any): string {
switch (account?.status) {
case 'ACTIVE':
return 'status--active';
case 'CLOSED':
return 'status--closed';
default:
return 'status--default';
}
}
}
@@ -0,0 +1,46 @@
/**
* 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 { OrganizationService } from 'app/organization/organization.service';
import { AlertService } from 'app/core/alert/alert.service';
/**
* Whether the server held a DDS entry for a checker rather than carrying it out. The reply then
* names the entry (commandId) and asks for the work to be rolled back, and carries no result of its own.
*/
export function heldForApproval(response: any): boolean {
return !!response?.rollbackTransaction || (!!response?.commandId && response?.resourceId == null);
}
/**
* Tells the clerk their entry went to a checker - with the voucher it will move under - instead of
* saying it was done. Nothing has moved yet, and a message that read as though it had would send them
* looking for money that is still waiting on a manager.
*/
export function announceHeldForApproval(
response: any,
what: string,
organizationService: OrganizationService,
alertService: AlertService
): void {
const say = (voucherNo?: string) =>
alertService.alert({
type: 'Sent for Approval',
message:
`${what} sent for approval${voucherNo ? ' under voucher ' + voucherNo : ''}. ` +
'It takes effect once a checker approves it in Approval Center › DDS Approvals.'
});
if (!response?.commandId) {
say();
return;
}
organizationService.getDdsApproval(response.commandId).subscribe({
next: (entry: any) => say(entry?.voucherNo),
error: () => say()
});
}
@@ -0,0 +1,265 @@
<!--
Copyright since 2025 Mifos Initiative
This Source Code Form is subject to the terms of the Mozilla Public
License, v. 2.0. If a copy of the MPL was not distributed with this
file, You can obtain one at http://mozilla.org/MPL/2.0/.
-->
<!-- Laid out as Disbursement & Transfers reviews its requests, and styled by the same sheet. -->
<div class="dri dri--dialog">
<nav class="cashier-nav cashier-nav--clerk">
<span class="dri-header-title">DDS {{ entry.type }} Review</span>
<span class="cashier-nav__esc-hint" (click)="close()">✕ Close</span>
</nav>
@if (loading) {
<div class="dri-loading">
<mat-icon class="dri-loading__icon">hourglass_empty</mat-icon>
<span>Loading entry…</span>
</div>
} @else {
<div class="dri-body">
<!-- Header card: the voucher it moves under, its status, who made it and when -->
<div class="dri-card dri-card--header">
<div class="dri-card-header">
<div class="dri-card-header__left">
<span class="dri-voucher">{{ voucherNo || '—' }}</span>
<span class="dri-status" [ngClass]="statusClass(entry.status)">
{{ entry.status }}{{ entry.voucherCancelled ? ' · VOUCHER CANCELLED' : '' }}
</span>
</div>
<div class="dri-card-header__meta">
<span class="dri-meta-item">
<mat-icon>person</mat-icon>
{{ entry.makerName || entry.maker }} ({{ entry.maker }})
</span>
<span class="dri-meta-item">
<mat-icon>schedule</mat-icon>
{{ formatDateTime(entry.madeOn) }}
</span>
@if (entry.status !== 'PENDING' && entry.checker) {
<span class="dri-meta-item">
<mat-icon>check_circle</mat-icon>
Actioned by: {{ entry.checkerName || entry.checker }}
</span>
}
</div>
</div>
</div>
<!-- Summary -->
<div class="dri-card">
<div class="dri-section-title">
<mat-icon>swap_horiz</mat-icon>
{{ entry.type }} Summary
</div>
<div class="dri-detail-grid">
<div class="dri-detail-item">
<span class="dri-detail-label">Total Amount</span>
<span class="dri-detail-value dri-amount">
{{ entry.amount !== null && entry.amount !== undefined ? '₹' + (entry.amount | number: '1.2-2') : '—' }}
</span>
</div>
<div class="dri-detail-item">
<span class="dri-detail-label">DR Accounts</span>
<span class="dri-detail-value"
>{{ debitLines.length }} account{{ debitLines.length !== 1 ? 's' : '' }}</span
>
</div>
<div class="dri-detail-item">
<span class="dri-detail-label">CR Accounts</span>
<span class="dri-detail-value"
>{{ creditLines.length }} account{{ creditLines.length !== 1 ? 's' : '' }}</span
>
</div>
<div class="dri-detail-item dri-detail-item--full">
<span class="dri-detail-label">Details</span>
<span class="dri-detail-value">{{ entry.details || '—' }}</span>
</div>
@if (detail?.description) {
<div class="dri-detail-item dri-detail-item--full">
<span class="dri-detail-label">Description</span>
<span class="dri-detail-value">{{ detail.description }}</span>
</div>
}
</div>
</div>
<!-- Debit entries -->
@if (debitLines.length) {
<div class="dri-card">
<div class="dri-section-title">
<mat-icon>arrow_upward</mat-icon>
Debit Entries ({{ debitLines.length }} account{{ debitLines.length !== 1 ? 's' : '' }} — funds out)
</div>
<table class="dri-cr-table">
<thead>
<tr>
<th>#</th>
<th>Account No</th>
<th>Account Name</th>
<th>Type</th>
<th class="num">Amount (DR)</th>
</tr>
</thead>
<tbody>
@for (line of debitLines; track $index; let i = $index) {
<tr>
<td class="dri-cr-idx">{{ i + 1 }}</td>
<td class="cell-mono">{{ line.accountNo || '—' }}</td>
<td>{{ line.accountName || '—' }}</td>
<td>
<span class="dri-type-badge">{{ line.type || '—' }}</span>
</td>
<td class="num dri-cr-amount">₹{{ line.amount | number: '1.2-2' }}</td>
</tr>
}
<tr class="dri-cr-total-row">
<td colspan="4" class="dri-cr-total-label">Total Debit</td>
<td class="num dri-cr-total-amount">₹{{ total(debitLines) | number: '1.2-2' }}</td>
</tr>
</tbody>
</table>
</div>
}
<!-- Credit entries -->
@if (creditLines.length) {
<div class="dri-card">
<div class="dri-section-title">
<mat-icon>arrow_downward</mat-icon>
Credit Entries ({{ creditLines.length }} account{{ creditLines.length !== 1 ? 's' : '' }} — funds in)
</div>
<table class="dri-cr-table">
<thead>
<tr>
<th>#</th>
<th>Account No</th>
<th>Account Name</th>
<th>Type</th>
<th class="num">Amount (CR)</th>
</tr>
</thead>
<tbody>
@for (line of creditLines; track $index; let i = $index) {
<tr>
<td class="dri-cr-idx">{{ i + 1 }}</td>
<td class="cell-mono">{{ line.accountNo || '—' }}</td>
<td>{{ line.accountName || '—' }}</td>
<td>
<span class="dri-type-badge">{{ line.type || '—' }}</span>
</td>
<td class="num dri-cr-amount">₹{{ line.amount | number: '1.2-2' }}</td>
</tr>
}
<tr class="dri-cr-total-row">
<td colspan="4" class="dri-cr-total-label">Total Credit</td>
<td class="num dri-cr-total-amount">₹{{ total(creditLines) | number: '1.2-2' }}</td>
</tr>
</tbody>
</table>
</div>
}
@if (entry.status === 'PENDING' && isMine && !done) {
<div class="dri-card dri-card--processing">
<div class="dri-section-title">
<mat-icon>info</mat-icon>
Your own entry
</div>
<p class="dri-processing-text">
You made this entry, so another user with checker rights has to approve or reject it.
</p>
</div>
}
@if (entry.status === 'REJECTED') {
<div class="dri-card dri-card--rejected">
<div class="dri-section-title">
<mat-icon>cancel</mat-icon>
Rejected
</div>
<p class="dri-rejection-text">
Nothing was done{{ voucherNo ? '. Voucher ' + voucherNo + ' is cancelled and will not be used' : '' }}.
</p>
@if (entry.checker) {
<div class="dri-meta-item" style="margin-top: 8px">
<mat-icon>manage_accounts</mat-icon>
{{ entry.checker }}
</div>
}
</div>
}
@if (entry.status === 'APPROVED') {
<div class="dri-card dri-card--approved">
<div class="dri-section-title">
<mat-icon>check_circle</mat-icon>
Approved and carried out
</div>
<p class="dri-txn-refs cell-mono">
{{
voucherNo
? 'Voucher ' + voucherNo
: entry.entity === 'DDSBATCH'
? 'Posting is running - refresh for its voucher'
: '—'
}}
</p>
<div class="dri-meta-item" style="margin-top: 8px">
<mat-icon>manage_accounts</mat-icon>
Approved by: {{ entry.checker || '—' }}
</div>
</div>
}
@if (error) {
<div class="dri-card dri-card--failed">
<div class="dri-section-title">
<mat-icon>error</mat-icon>
Could not complete
</div>
<p class="dri-rejection-text">{{ error }}</p>
</div>
}
@if (entry.status === 'PENDING' && !done) {
<div class="dri-actions">
@if (!confirmReject) {
<button type="button" class="dri-btn dri-btn--approve" [disabled]="!canAct" (click)="act('approve')">
<mat-icon>check_circle</mat-icon>
{{ working ? 'Processing…' : 'Approve ' + entry.type }}
</button>
<button type="button" class="dri-btn dri-btn--reject" [disabled]="!canAct" (click)="confirmReject = true">
<mat-icon>cancel</mat-icon>
Reject
</button>
} @else {
<div class="dri-reject-form">
<label class="dri-reject-label">
Reject this entry? Nothing will be done{{
voucherNo ? ' and voucher ' + voucherNo + ' will be cancelled' : ''
}}.
</label>
<div class="dri-reject-actions">
<button type="button" class="dri-btn dri-btn--reject" [disabled]="!canAct" (click)="act('reject')">
<mat-icon>cancel</mat-icon>
{{ working ? 'Processing…' : 'Confirm Rejection' }}
</button>
<button
type="button"
class="dri-btn dri-btn--ghost"
[disabled]="working"
(click)="confirmReject = false"
>
Cancel
</button>
</div>
</div>
}
</div>
}
</div>
}
</div>
@@ -0,0 +1,148 @@
/**
* 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 { MAT_DIALOG_DATA, MatDialogModule, MatDialogRef } from '@angular/material/dialog';
import { MatIconModule } from '@angular/material/icon';
import { OrganizationService } from 'app/organization/organization.service';
/**
* One DDS entry, laid out the way Disbursement & Transfers reviews a request - voucher and status on
* top, who made it and when, then the debit and credit entries it moves - and styled by the same
* sheet, so a checker reads both queues the same way.
*
* The checker approves it, which carries it out under the voucher numbered when it was sent, or
* rejects it, which leaves nothing done and cancels that number. Neither is offered to the user who
* made the entry, and the server refuses it as well.
*/
@Component({
selector: 'mifosx-dds-approval-review',
templateUrl: './dds-approval-review.component.html',
styleUrls: ['../../../clerk/disbursement-review/disbursement-review.component.scss'],
standalone: true,
imports: [
CommonModule,
MatDialogModule,
MatIconModule
]
})
export class DdsApprovalReviewComponent implements OnInit {
private organizationService = inject(OrganizationService);
private dialogRef = inject(MatDialogRef<DdsApprovalReviewComponent>);
data = inject<any>(MAT_DIALOG_DATA);
entry: any = this.data.entry;
/** The entry in full, with the debit and credit sides it moves. */
detail: any = null;
loading = true;
working = false;
confirmReject = false;
done = false;
error = '';
/** Set by an approval, in case the list row did not carry the number yet. */
approvedVoucher = '';
ngOnInit(): void {
this.load();
}
private load(): void {
this.organizationService.getDdsApproval(this.entry.id).subscribe({
next: (detail: any) => {
this.detail = detail;
this.entry = { ...this.entry, ...detail };
this.loading = false;
},
error: () => (this.loading = false)
});
}
get voucherNo(): string {
return this.approvedVoucher || this.entry?.voucherNo || '';
}
get debitLines(): any[] {
return (this.detail?.lines || []).filter((line: any) => line.side === 'DR');
}
get creditLines(): any[] {
return (this.detail?.lines || []).filter((line: any) => line.side === 'CR');
}
total(lines: any[]): number {
return lines.reduce((sum: number, line: any) => sum + Number(line.amount || 0), 0);
}
get isMine(): boolean {
return !!this.data.currentUserId && Number(this.entry.makerId) === Number(this.data.currentUserId);
}
get canAct(): boolean {
return this.entry.status === 'PENDING' && !this.isMine && !this.done && !this.working;
}
statusClass(status: string): string {
const map: Record<string, string> = {
PENDING: 'dri-status--pending',
APPROVED: 'dri-status--approved',
REJECTED: 'dri-status--rejected'
};
return map[status] ?? '';
}
act(command: 'approve' | 'reject'): void {
this.working = true;
this.error = '';
this.organizationService.actOnDdsApproval(this.entry.id, command).subscribe({
next: (result: any) => {
this.working = false;
this.done = true;
this.approvedVoucher = result?.voucherNo || '';
this.entry = {
...this.entry,
status: command === 'approve' ? 'APPROVED' : 'REJECTED',
voucherCancelled: command === 'reject' && !!this.voucherNo
};
// The approved entry's sides are now the voucher's own lines.
this.load();
},
error: (err: any) => {
this.working = false;
const body = err?.error;
this.error =
body?.errors?.[0]?.defaultUserMessage ||
body?.defaultUserMessage ||
body?.developerMessage ||
'Could not complete this.';
}
});
}
/** The same form Disbursement & Transfers prints its times in: 29 Sept 2026, 01:25 pm. */
formatDateTime(val: any): string {
if (!val) {
return '—';
}
const d = new Date(String(val).replace(' ', 'T'));
return isNaN(d.getTime())
? String(val)
: d.toLocaleString('en-IN', {
day: '2-digit',
month: 'short',
year: 'numeric',
hour: '2-digit',
minute: '2-digit'
});
}
close(): void {
this.dialogRef.close(this.done);
}
}
@@ -0,0 +1,176 @@
<!--
Copyright since 2025 Mifos Initiative
This Source Code Form is subject to the terms of the Mozilla Public
License, v. 2.0. If a copy of the MPL was not distributed with this
file, You can obtain one at http://mozilla.org/MPL/2.0/.
-->
<div class="cv-tab-content wri-tab">
<div class="wri-action-bar">
<div class="wri-action-bar__left">
<mat-icon class="wri-action-bar__icon">savings</mat-icon>
<div>
<div class="wri-action-bar__title">DDS Approvals</div>
<div class="wri-action-bar__sub">
Review, approve or reject DDS entries - transfers, payouts, early closures, receipts applied and postings.
</div>
</div>
</div>
</div>
<div class="wri-filter-bar">
<select class="wri-filter-select" [(ngModel)]="filterStatus" (change)="onFilterChange()">
@for (opt of statusOptions; track opt.value) {
<option [value]="opt.value">{{ opt.label }}</option>
}
</select>
<span class="wri-fb-sep"></span>
<div class="wri-presets">
@for (p of datePresets; track p.key) {
<button
type="button"
class="wri-preset-chip"
[class.wri-preset-chip--active]="activePreset === p.key"
(click)="applyPreset(p.key)"
>
{{ p.label }}
</button>
}
</div>
<span class="wri-fb-sep"></span>
<div class="wri-date-range">
<div class="wri-date-box">
<span class="wri-date-box__lbl">From</span>
<input
class="wri-date-box__input"
[matDatepicker]="fromPicker"
[(ngModel)]="fromDate"
[max]="maxDate"
(dateChange)="onFromDateChange()"
readonly
/>
<button type="button" class="wri-date-box__btn" (click)="fromPicker.open()">
<mat-icon>event</mat-icon>
</button>
<mat-datepicker #fromPicker></mat-datepicker>
</div>
<span class="wri-date-range__sep">—</span>
<div class="wri-date-box">
<span class="wri-date-box__lbl">To</span>
<input
class="wri-date-box__input"
[matDatepicker]="toPicker"
[(ngModel)]="toDate"
[min]="fromDate"
[max]="maxDate"
(dateChange)="onToDateChange()"
readonly
/>
<button type="button" class="wri-date-box__btn" (click)="toPicker.open()">
<mat-icon>event</mat-icon>
</button>
<mat-datepicker #toPicker></mat-datepicker>
</div>
</div>
<button type="button" class="wri-btn wri-btn--refresh" (click)="load()" [disabled]="loading">
<mat-icon>refresh</mat-icon>
</button>
</div>
@if (loading) {
<div class="empty-state">
<p class="empty-title">Loading…</p>
</div>
} @else if (loaded && entries.length === 0) {
<div class="empty-state">
<i class="fas fa-inbox empty-icon"></i>
<p class="empty-title">No DDS entries</p>
<p class="empty-text">
@if (filterStatus === 'PENDING') {
There are no DDS entries waiting for approval.
} @else {
No {{ filterStatus | lowercase }} DDS entries found.
}
</p>
</div>
} @else {
<div class="wri-table-wrap">
<table class="wri-table">
<thead>
<tr>
<th class="wri-th-id">Voucher No</th>
<th>Type</th>
<th>Account / Details</th>
<th class="num">Amount</th>
<th>Entries</th>
<th>Clerk</th>
<th>Reviewed By</th>
<th>Submitted At</th>
<th>Status</th>
<th class="wri-th-action">Action</th>
</tr>
</thead>
<tbody>
@for (e of pagedEntries; track e.id) {
<tr class="wri-row" (click)="review(e)">
<td class="cell-mono wri-voucher">
{{ e.voucherNo || '—' }}
@if (e.voucherCancelled) {
<span class="wri-gl-name">(cancelled)</span>
}
</td>
<td>
<span class="wri-credit-badge" [style.background]="typeColor(e)">{{ e.type }}</span>
</td>
<td>
<span class="wri-gl-name">{{ e.details || '—' }}</span>
</td>
<td class="num wri-amount">
{{ e.amount !== null && e.amount !== undefined ? '₹' + (e.amount | number: '1.2-2') : '—' }}
</td>
<td class="wri-credit-count">
@if (e.entries) {
<span class="wri-credit-badge">{{ e.entries }} account{{ e.entries !== 1 ? 's' : '' }}</span>
} @else {
—
}
</td>
<td>
{{ e.makerName || e.maker || '—' }}
@if (isMine(e) && e.status === 'PENDING') {
<span class="wri-gl-name">(you)</span>
}
</td>
<td>{{ e.status === 'PENDING' ? '—' : e.checkerName || e.checker || '—' }}</td>
<td class="wri-dt">{{ formatDateTime(e.madeOn) }}</td>
<td>
<span class="wri-status" [ngClass]="statusClass(e.status)">{{ e.status | titlecase }}</span>
</td>
<td class="wri-action-cell" (click)="$event.stopPropagation()">
<button type="button" class="wri-btn wri-btn--review" (click)="review(e)">
<mat-icon>rate_review</mat-icon>
Review
</button>
</td>
</tr>
}
</tbody>
</table>
</div>
<mat-paginator
[length]="entries.length"
[pageSize]="pageSize"
[pageSizeOptions]="pageSizeOptions"
[pageIndex]="pageIndex"
showFirstLastButtons
(page)="onPageChange($event)"
></mat-paginator>
}
</div>
@@ -0,0 +1,215 @@
/**
* 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 { MatPaginatorModule, PageEvent } from '@angular/material/paginator';
import { MatDialog } from '@angular/material/dialog';
import { MatNativeDateModule } from '@angular/material/core';
import { MatDatepickerModule } from '@angular/material/datepicker';
import { catchError } from 'rxjs/operators';
import { of } from 'rxjs';
import { OrganizationService } from 'app/organization/organization.service';
import { SettingsService } from 'app/settings/settings.service';
import { AuthenticationService } from 'app/core/authentication/authentication.service';
import { DdsApprovalReviewComponent } from './dds-approval-review/dds-approval-review.component';
/**
* Approval Center › DDS Approvals: every DDS entry waiting on a checker, in the clerk's terms - what
* it does, on which accounts, for how much - and, once approved, the voucher it moved under.
*
* Laid out like Disbursement & Transfers beside it, and sharing its styles, so the two queues read the
* same way. The entries are the platform's own maker-checker entries; approving one here carries it out.
*/
@Component({
selector: 'mifosx-dds-approvals',
templateUrl: './dds-approvals.component.html',
styleUrls: ['../../tasks/checker-inbox-and-tasks-tabs/disbursement-requests/disbursement-requests.component.scss'],
standalone: true,
imports: [
CommonModule,
FormsModule,
MatIconModule,
MatPaginatorModule,
MatNativeDateModule,
MatDatepickerModule
]
})
export class DdsApprovalsComponent implements OnInit {
private organizationService = inject(OrganizationService);
private settingsService = inject(SettingsService);
private authenticationService = inject(AuthenticationService);
private dialog = inject(MatDialog);
entries: any[] = [];
pageSize = 20;
pageIndex = 0;
readonly pageSizeOptions = [
10,
20,
50
];
/** The page of entries on screen. */
get pagedEntries(): any[] {
const start = this.pageIndex * this.pageSize;
return this.entries.slice(start, start + this.pageSize);
}
onPageChange(event: PageEvent): void {
this.pageSize = event.pageSize;
this.pageIndex = event.pageIndex;
}
filterStatus = 'PENDING';
fromDate: Date | null = null;
toDate: Date | null = null;
maxDate!: Date;
activePreset = 'today';
loading = false;
loaded = false;
readonly currentUserId = this.authenticationService.getCredentials()?.userId;
readonly statusOptions = [
{ value: 'PENDING', label: 'Pending' },
{ value: 'APPROVED', label: 'Approved' },
{ value: 'REJECTED', label: 'Rejected' }
];
readonly datePresets = [
{ key: 'today', label: 'Today' },
{ key: 'week', label: 'This Week' },
{ key: 'month', label: 'This Month' },
{ key: 'all', label: 'All' }
];
ngOnInit(): void {
const today = new Date(this.settingsService.businessDate || new Date());
today.setHours(0, 0, 0, 0);
this.maxDate = today;
this.applyPreset('today');
}
load(): void {
this.loading = true;
this.organizationService
.getDdsApprovals(this.filterStatus, this.isoDate(this.fromDate), this.isoDate(this.toDate))
.pipe(catchError(() => of([])))
.subscribe((rows: any) => {
this.entries = Array.isArray(rows) ? rows : [];
this.pageIndex = 0;
this.loading = false;
this.loaded = true;
});
}
onFilterChange(): void {
this.load();
}
onFromDateChange(): void {
this.activePreset = '';
if (this.fromDate && this.toDate && this.fromDate > this.toDate) {
this.toDate = new Date(this.fromDate);
}
this.load();
}
onToDateChange(): void {
this.activePreset = '';
this.load();
}
applyPreset(key: string): void {
this.activePreset = key;
const today = new Date(this.settingsService.businessDate || new Date());
today.setHours(0, 0, 0, 0);
if (key === 'today') {
this.fromDate = new Date(today);
this.toDate = new Date(today);
} else if (key === 'week') {
const start = new Date(today);
start.setDate(today.getDate() - today.getDay());
this.fromDate = start;
this.toDate = new Date(today);
} else if (key === 'month') {
this.fromDate = new Date(today.getFullYear(), today.getMonth(), 1);
this.toDate = new Date(today);
} else {
this.fromDate = null;
this.toDate = null;
}
this.load();
}
review(entry: any): void {
this.dialog
.open(DdsApprovalReviewComponent, { data: { entry, currentUserId: this.currentUserId }, autoFocus: false })
.afterClosed()
// However the dialog was closed - its Close button, a click outside, Esc - the list is read
// again, so an entry just approved or rejected leaves the Pending list at once.
.subscribe(() => this.load());
}
/** Whose entry it is - a checker cannot pass their own. */
isMine(entry: any): boolean {
return !!this.currentUserId && Number(entry.makerId) === Number(this.currentUserId);
}
statusClass(status: string): string {
const map: Record<string, string> = {
PENDING: 'wri-status--pending',
APPROVED: 'wri-status--approved',
REJECTED: 'wri-status--rejected'
};
return map[status] ?? '';
}
typeColor(entry: any): string {
const map: Record<string, string> = {
Transfer: '#e3f2fd',
'Apply Receipt': '#fce4ec'
};
if (String(entry.type).startsWith('Premature')) {
return '#fff3e0';
}
if (String(entry.type).startsWith('Maturity')) {
return '#e8f5e9';
}
if (String(entry.type).startsWith('Post')) {
return '#ede7f6';
}
return map[entry.type] ?? '#f5f5f5';
}
formatDateTime(val: any): string {
if (!val) {
return '—';
}
const d = new Date(String(val).replace(' ', 'T'));
return isNaN(d.getTime())
? String(val)
: d.toLocaleString('en-IN', {
day: '2-digit',
month: 'short',
year: 'numeric',
hour: '2-digit',
minute: '2-digit'
});
}
private isoDate(d: Date | null): string | undefined {
if (!d) {
return undefined;
}
return `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, '0')}-${String(d.getDate()).padStart(2, '0')}`;
}
}
@@ -0,0 +1,33 @@
<!--
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>
@@ -0,0 +1,97 @@
/**
* 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;
}
}
@@ -0,0 +1,73 @@
/**
* 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']
}
];
}
@@ -0,0 +1,148 @@
<!--
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">
<!-- Only the filters this report's query actually reads are offered. One that changed nothing
would read as a report ignoring what it was asked. -->
@if (!hasNoFilters) {
<div class="rv-grid">
@if (usesDate) {
<mat-form-field appearance="outline" class="rv-field">
<mat-label>{{ dateLabel }} — from</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>{{ dateLabel }} — to</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>
}
@if (usesAgent) {
<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>
}
@if (usesParty) {
<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>
<!-- A report with nothing to fill in has already run itself; the button then only offers to
fetch the figures again, which is what it is called. -->
<button mat-raised-button color="primary" type="button" [disabled]="filterForm.invalid" (click)="runReport()">
<i class="fas" [class.fa-play]="!hasNoFilters" [class.fa-rotate-right]="hasNoFilters"></i>
{{ hasNoFilters ? 'Refresh' : '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 {
<!-- Laid out as the legacy statement printed it: a heading naming the report and the
period, numbered rows, and a total under every money column. -->
<div class="rv-statement">
<div class="rv-statement__head">
<h3 class="rv-statement__title">{{ reportName }}</h3>
@if (usesDate) {
<p class="rv-statement__period">
{{ dateLabel | lowercase }} {{ ranFromDate | dateFormat }} and
{{ ranToDate | dateFormat }}
</p>
} @else {
<p class="rv-statement__period">as on {{ ranOnDate | dateFormat }}</p>
}
<button mat-stroked-button type="button" class="rv-print" (click)="print()">
<i class="fas fa-print"></i> Print
</button>
</div>
<div class="rv-table-scroll">
<table class="rv-table">
<thead>
<tr>
<th class="rv-sr">#</th>
@for (column of columns; track column) {
<th [class.rv-num]="isAmountColumn(column)">{{ column }}</th>
}
</tr>
</thead>
<tbody>
@for (row of rows; track $index) {
<tr>
<td class="rv-sr">{{ $index + 1 }}</td>
@for (column of columns; track column) {
<td [class.rv-num]="isAmountColumn(column)">{{ cell(row, column) }}</td>
}
</tr>
}
</tbody>
@if (hasTotalledColumn) {
<tfoot>
<tr>
<td class="rv-sr"></td>
@for (column of columns; track column; let first = $first) {
<td [class.rv-num]="isAmountColumn(column)">
@if (first) {
Total :
} @else if (isTotalledColumn(column)) {
{{ columnTotal(column) }}
}
</td>
}
</tr>
</tfoot>
}
</table>
</div>
<p class="rv-statement__count">
{{ rows.length }} {{ rows.length === 1 ? 'row' : 'rows' }}
</p>
</div>
}
</div>
}
</form>
</div>
</div>
@@ -0,0 +1,190 @@
/**
* Copyright since 2025 Mifos Initiative
*
* This Source Code Form is subject to the terms of the Mozilla Public
* License, v. 2.0. If a copy of the MPL was not distributed with this
* file, You can obtain one at http://mozilla.org/MPL/2.0/.
*/
$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%);
}
/* Figures line up column-wise so a wrong order of magnitude is visible at a glance. */
.rv-num {
text-align: right;
font-variant-numeric: tabular-nums;
}
/* The row number down the left, as the printed statement carried it. */
.rv-sr {
width: 1%;
color: rgb(0 0 0 / 45%);
font-style: italic;
font-variant-numeric: tabular-nums;
}
tfoot td {
border-bottom: none;
border-top: 2px solid rgb(0 0 0 / 20%);
font-weight: 600;
padding-top: 10px;
}
}
/* ---- the statement block: heading, table, footer count ---- */
.rv-action-bar i {
margin-right: 6px;
}
.rv-statement__head {
display: flex;
align-items: baseline;
flex-wrap: wrap;
gap: 4px 12px;
margin-bottom: 14px;
}
.rv-statement__title {
margin: 0;
font-size: 17px;
font-weight: 600;
flex: 0 0 auto;
}
.rv-statement__period {
margin: 0;
font-size: 13px;
color: rgb(0 0 0 / 55%);
}
.rv-print {
margin-left: auto;
i {
margin-right: 6px;
}
}
.rv-statement__count {
margin: 10px 0 0;
font-size: 12px;
color: rgb(0 0 0 / 45%);
text-align: right;
}
/* Printing a statement should give the statement, not the screen around it. */
@media print {
.rv-header,
.rv-grid,
.rv-action-bar,
.rv-print {
display: none !important;
}
.rv-card {
box-shadow: none;
padding: 0;
}
.rv-table-scroll {
overflow-x: visible;
}
.rv-table {
white-space: normal;
font-size: 11px;
}
}
@media (width <= 900px) {
.rv-grid {
grid-template-columns: repeat(2, 1fr);
}
}
@@ -0,0 +1,346 @@
/**
* 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 { 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 implements OnInit {
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'
};
/**
* Which filters each report actually uses, and what its date range means.
*
* Every report used to be offered the same six filters whether it read them or not — so a date
* range could be typed on the Agent Check List, which has no date in its query at all, and the
* same rows would come back however it was set. A filter that changes nothing is worse than no
* filter: it reads as a report that ignores what it was asked.
*
* Kept in step with the backend by hand; the source of truth is the switch in
* `DdsReportReadPlatformServiceImpl.runReport`.
*/
private static readonly REPORT_FILTERS: Record<string, { date?: string; agent?: boolean; party?: boolean }> = {
AGENT_CHECK_LIST: {},
PARTY_CHECK_LIST: { agent: true, party: true },
PARTY_BALANCE: { agent: true, party: true },
AGENT_WISE_BALANCE: { agent: true },
BALANCE_SUMMARY: { agent: true, party: true },
MASTER_DATA_EXPORT: { agent: true, party: true },
AGENT_WISE_MONTHLY_COLLECTION: { date: 'Collected between', agent: true },
COLLECTION_SUMMARY_AGENT_WISE: { date: 'Collected between', agent: true },
DAILY_COLLECTION_REPORT: { date: 'Collected between', agent: true },
AGENT_PARTYWISE_DAILY_COLLECTION: { date: 'Collected between', agent: true, party: true },
SUPPLEMENTARY_DDS: { date: 'Collected between', agent: true, party: true },
COMMISSION_REGISTER: { date: 'Posted between', agent: true },
AGENT_COMMISSION_STATEMENT: { date: 'Posted between' },
PARTY_INTEREST_REGISTER: { date: 'Posted between', party: true },
INTEREST_CERTIFICATE: { date: 'Interest earned between', agent: true, party: true },
TRANSACTION_REGISTER: { date: 'Transacted between', agent: true, party: true },
CLOSE_PARTY_AC_REGISTER: { date: 'Closed between', agent: true, party: true },
MATURE_DATE_WISE: { date: 'Maturing between', agent: true, party: true }
};
reportName: string = this.route.snapshot.paramMap.get('reportName') || 'Report';
hasRun = false;
available = true;
unavailableMessage = '';
columns: string[] = [];
rows: Array<Record<string, any>> = [];
/** The range the rows on screen were actually fetched for — not whatever the filter says now. */
ranFromDate: Date | null = null;
ranToDate: Date | null = null;
/** When a report carries no date range, the moment it was run is what dates its figures. */
ranOnDate: Date | null = null;
private static readonly MONEY = new Intl.NumberFormat('en-IN', {
minimumFractionDigits: 2,
maximumFractionDigits: 2
});
private static readonly DATE = new Intl.DateTimeFormat('en-GB', {
day: '2-digit',
month: '2-digit',
year: 'numeric'
});
/**
* One cell, rendered the way a printed statement shows it.
*
* Dates arrive from the platform as `[yyyy, m, d]` and numbers as raw decimals, so printing them
* as they come gives "2026,9,2" and "10052.6" — unreadable on a statement someone checks figures
* against. Amounts get Indian grouping and two decimals; identifiers that merely look numeric
* (account numbers, codes) are left alone, since grouping them would corrupt them.
*/
cell(row: Record<string, any>, column: string): string {
const value = row[column];
if (value === null || value === undefined || value === '') {
return '—';
}
if (Array.isArray(value) && value.length >= 3) {
return ReportViewerComponent.DATE.format(new Date(value[0], value[1] - 1, value[2]));
}
if (this.isAmountColumn(column) && typeof value === 'number') {
return ReportViewerComponent.MONEY.format(value);
}
return String(value);
}
/**
* Whether a column holds money, and so should be right-aligned with two decimals.
*
* Decided from the column's name rather than its values: a count column and an account-number
* column are both numbers, and neither reads as an amount.
*/
isAmountColumn(column: string): boolean {
const name = column.toLowerCase();
// Ruled out first, because several of these also contain a money word: a rate is a percentage,
// "Total Accounts" is a count that happens to say total, and an account number is an identifier
// that would be corrupted by grouping.
//
// "agents" is matched exactly rather than as a substring: the two count columns are named
// "Total Agents" and "Active Agents", but "Cash With Agents" and "Commission Due To Agents"
// are money, and a substring test swept those two up and printed them ungrouped.
//
// A "type" column names a kind, never a quantity — "Interest Type" holds the word "Interest",
// which is enough for the money test below to claim it and put a meaningless total under it.
if (
name.includes('a/c no') ||
name.includes('code') ||
name.includes('rate') ||
name.includes('%') ||
name.includes('count') ||
name.includes('entries') ||
name.includes('accounts') ||
name === 'total agents' ||
name === 'active agents' ||
name.includes('type') ||
name.includes('pan')
) {
return false;
}
return (
name.includes('amount') ||
name.includes('balance') ||
name.includes('collect') ||
name.includes('commission') ||
name.includes('deduction') ||
name.includes('deposit') ||
name.includes('advance') ||
name.includes('interest') ||
name.includes('tds') ||
name.includes('total') ||
name.includes('diff') ||
name.includes('cash') ||
name.includes('debit') ||
name.includes('credit') ||
name.includes('settled') ||
name.includes('shortfall') ||
name.includes('due')
);
}
/**
* Whether a total under this column would mean anything.
*
* Not every amount is worth adding up. A "Balance After" on a transaction register is the running
* balance *at that moment*, so its column holds several snapshots of the same account at different
* times — summing them produces a figure that corresponds to nothing, and a reader would sooner or
* later try to reconcile it against something.
*
* A plain balance on a listing report is different: there each row is one account's position at
* one moment, and the sum is the real "total held", which is exactly what a branch wants to see.
*/
isTotalledColumn(column: string): boolean {
if (!this.isAmountColumn(column)) {
return false;
}
return !column.toLowerCase().includes('balance after');
}
/** The figure printed under a column, as the legacy statement's "Total :" row does. */
columnTotal(column: string): string {
const sum = this.rows.reduce((running, row) => {
const value = row[column];
return running + (typeof value === 'number' ? value : 0);
}, 0);
return ReportViewerComponent.MONEY.format(sum);
}
/**
* Whether to print a Total row at all.
*
* A single-row report is already its own total - Balance Summary is one line of figures for the
* whole scheme, and repeating it underneath itself only invites the reader to look for a difference
* between the two.
*/
get hasTotalledColumn(): boolean {
return this.rows.length > 1 && this.columns.some((column) => this.isTotalledColumn(column));
}
print(): void {
window.print();
}
filterForm: UntypedFormGroup = this.formBuilder.group({
fromDate: [''],
toDate: [''],
agentFrom: [1],
agentTo: [99999],
partyFrom: [1],
partyTo: [99999999]
});
private get filters(): { date?: string; agent?: boolean; party?: boolean } {
return ReportViewerComponent.REPORT_FILTERS[ReportViewerComponent.REPORT_KEYS[this.reportName]] ?? {};
}
/** True when this report's query actually reads a date range - only then is one asked for. */
get usesDate(): boolean {
return !!this.filters.date;
}
/** What this report's date range means, e.g. "Posted between" rather than a bare "From Date". */
get dateLabel(): string {
return this.filters.date ?? '';
}
get usesAgent(): boolean {
return !!this.filters.agent;
}
get usesParty(): boolean {
return !!this.filters.party;
}
/** True when this report takes no filters at all, so there is nothing for anyone to fill in. */
get hasNoFilters(): boolean {
return !this.usesDate && !this.usesAgent && !this.usesParty;
}
ngOnInit(): void {
// A date is required exactly for the reports that read one, so a report without dates can be run
// straight away instead of demanding two values it will ignore.
if (this.usesDate) {
this.filterForm.get('fromDate')?.setValidators([Validators.required]);
this.filterForm.get('toDate')?.setValidators([Validators.required]);
this.filterForm.get('fromDate')?.updateValueAndValidity();
this.filterForm.get('toDate')?.updateValueAndValidity();
}
// Nothing to narrow it by means nothing to decide, so the report simply opens showing its
// figures. Making someone press Run on an empty form asks a question with only one answer.
if (this.hasNoFilters) {
this.runReport();
}
}
runReport(): void {
const reportKey = ReportViewerComponent.REPORT_KEYS[this.reportName];
if (!reportKey) {
return;
}
const raw = this.filterForm.getRawValue();
// Only what this report reads is sent. The backend defaults the rest to the full range, so a
// report that ignores a filter is never handed a value that looks like it was applied.
const params: Record<string, any> = {};
if (this.usesDate) {
params['fromDate'] = this.dateUtils.getDate(raw.fromDate);
params['toDate'] = this.dateUtils.getDate(raw.toDate);
}
if (this.usesAgent) {
params['agentFrom'] = raw.agentFrom;
params['agentTo'] = raw.agentTo;
}
if (this.usesParty) {
params['partyFrom'] = raw.partyFrom;
params['partyTo'] = raw.partyTo;
}
this.organizationService
.runDdsReport(reportKey, params)
.subscribe((result: any) => {
this.available = result.available;
this.unavailableMessage = result.message || '';
this.columns = result.columns || [];
this.rows = result.rows || [];
// Captured here so the printed heading names the range these rows came from, and does not
// silently change if the filter is edited without re-running.
this.ranFromDate = raw.fromDate;
this.ranToDate = raw.toDate;
this.ranOnDate = new Date();
this.hasRun = true;
});
}
exit(): void {
this.router.navigate(['../../'], { relativeTo: this.route });
}
}
@@ -0,0 +1,11 @@
{
"folders": [
{
"path": "../../../../../../CBS-Backend"
},
{
"path": "../../../../.."
}
],
"settings": {}
}
@@ -0,0 +1,49 @@
/**
* 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 { Observable, forkJoin, of } from 'rxjs';
import { catchError, map } from 'rxjs/operators';
import { ClientsService } from 'app/clients/clients.service';
/**
* Finds clients by name whatever case the officer types it in.
*
* The platform's name filter matches anywhere in the name but only in the exact case typed, so
* "sneha" finds nobody while "Sneha" finds Ms. Sneha Iyer. Names are stored capitalised, so the term
* is tried as typed, capitalised word by word, and in upper and lower case, and the results merged.
* Done here rather than by changing the platform's search, which every other screen relies on.
*/
export function searchClientsAnyCase(clientsService: ClientsService, term: string, officeId?: any): Observable<any[]> {
const variants = Array.from(
new Set([
term,
term.toLowerCase().replace(/(^|[\s.])(\p{L})/gu, (_match, lead, letter) => lead + letter.toUpperCase()),
term.toUpperCase(),
term.toLowerCase()
])
);
return forkJoin(
variants.map((variant) =>
clientsService.getFilteredClients('displayName', 'ASC', false, variant, officeId).pipe(
map((response: any) => {
const found = response?.pageItems || response || [];
return Array.isArray(found) ? found : [];
}),
catchError(() => of([]))
)
)
).pipe(
map((lists: any[][]) => {
const byId = new Map<number, any>();
for (const client of lists.flat()) {
byId.set(client.id, client);
}
return Array.from(byId.values()).sort((a, b) => String(a.displayName).localeCompare(String(b.displayName)));
})
);
}
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,526 @@
/**
* 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 {
// Wider than the usual form page: the extra width is what keeps this many fields off a
// second screen.
max-width: 1280px;
margin: 0 auto;
padding: 12px 16px 20px;
}
.pm-header {
display: flex;
align-items: center;
gap: 14px;
margin-bottom: 10px;
&__title {
margin: 0;
font-size: 18px;
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;
// The subtitle sits beside the title where there is room and drops below it where there is not.
flex-wrap: wrap;
gap: 2px 10px;
margin-bottom: 16px;
i {
color: $primary;
}
h3 {
margin: 0;
font-size: 16px;
font-weight: 600;
// Held at its natural width: letting it shrink breaks a two-word heading onto three lines.
flex: 0 0 auto;
white-space: nowrap;
}
}
}
.pm-grid {
display: grid;
grid-template-columns: repeat(4, 1fr);
gap: 2px 18px;
align-items: start;
}
/*
* Label to the left of its field, the way the screen this replaces laid them out. A stacked label
* costs a whole row of height per field, and with 60-odd fields that is the difference between an
* operator seeing the form at once and scrolling through three screens of it.
*/
.pm-f {
display: grid;
grid-template-columns: 104px minmax(0, 1fr);
align-items: start;
column-gap: 7px;
&--wide {
grid-column: span 2;
}
}
.pm-f-lbl {
font-size: 11.5px;
line-height: 1.25;
text-align: right;
color: rgb(0 0 0 / 68%);
padding-top: 7px;
// Required marker, in the same red the legacy screen used.
b {
color: #c1121f;
font-weight: 400;
margin-left: 2px;
}
}
.pm-field {
width: 100%;
}
.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;
}
/*
* The running total of the shares entered, beside the button that adds another. Reads as ordinary
* text while the split still adds up and turns to the warning colour when it does not, so the clerk
* sees the state of the whole nomination without having to try to save it.
*/
.pm-share-total {
align-self: center;
margin-left: 12px;
font-size: 13px;
color: rgb(0 0 0 / 60%);
}
.pm-share-total-bad {
color: #b3261e;
font-weight: 500;
}
.pm-empty {
color: rgb(0 0 0 / 50%);
font-size: 14px;
margin: 0 0 8px;
}
/*
* One nominee, laid out as fields that wrap rather than columns that must all fit.
*
* A fixed column per field only works while the count is fixed, and it is not - the Share % field
* appears and disappears with the split mode, and a tenth column pushed the remove button onto a
* line of its own. Each field instead states the width it needs to stay readable and the row breaks
* where it runs out of space, so nothing is squeezed and nothing is orphaned.
*/
.pm-nominee-row {
display: flex;
flex-wrap: wrap;
gap: 4px 12px;
align-items: start;
padding: 12px 0;
border-top: 1px dashed rgb(0 0 0 / 10%);
&:first-of-type {
border-top: none;
}
/* Names and addresses need room; a date, Yes/No or a percentage does not. */
.pm-f {
flex: 1 1 170px;
min-width: 0;
}
.pm-f--date {
flex: 0 1 140px;
}
.pm-f--tight {
flex: 0 1 100px;
}
}
.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);
}
}
/* Resolved name shown under a Client / Agent / Guardian id, as the legacy screen did in red. */
.pm-hint--found {
color: #15803d;
font-weight: 500;
}
.pm-hint--missing {
color: #b91c1c;
font-weight: 500;
}
/* Explains why a block of fields is absent, so the screen does not just look unfinished. */
.pm-note {
margin: 4px 0 0;
font-size: 0.8rem;
color: rgb(0 0 0 / 55%);
font-style: italic;
}
.pm-closed-badge {
margin-left: 12px;
padding: 3px 11px;
border-radius: 11px;
font-size: 0.72rem;
font-weight: 500;
background: rgb(0 0 0 / 7%);
color: rgb(0 0 0 / 60%);
}
/* Says whose details a block of fields is for, so the screen explains itself. */
.pm-section__sub {
flex: 1 1 auto;
min-width: 0;
font-size: 0.78rem;
font-weight: 400;
line-height: 1.45;
color: rgb(0 0 0 / 55%);
}
.pm-search-ic {
color: rgb(0 0 0 / 38%);
font-size: 0.85rem;
}
/*
* Money reads right to left, so a column of figures only lines up when it is right-aligned; the
* tabular figures keep every digit the same width so the decimal points stack.
*/
input.pm-money {
text-align: right;
font-variant-numeric: tabular-nums;
}
/* One row of a Client / Sub Type search list: the id, the name, and where it belongs. */
.pm-opt {
display: flex;
align-items: baseline;
gap: 10px;
overflow: hidden;
}
/* The "there are more" footer row. Quieter than a real option, since it is not one. */
.pm-opt-more {
font-size: 0.75rem;
font-style: italic;
opacity: 0.75;
}
.pm-opt-id {
flex: 0 0 auto;
min-width: 52px;
font-family: ui-monospace, 'SF Mono', 'Cascadia Mono', monospace;
font-size: 0.78rem;
color: var(--pl-accent, #b45309);
}
/*
The name is the reason the row exists, so it gets whatever width is going and is the last thing
to be cut. Before this the branch was fixed-width and the name carried the ellipsis, so in the
narrow panel every customer read as a single letter.
*/
.pm-opt-main {
flex: 1 1 auto;
min-width: 8rem;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.pm-opt-sub {
flex: 0 1 auto;
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
font-size: 0.75rem;
color: rgb(0 0 0 / 50%);
}
/*
* Dense data-entry layout.
*
* The screen this replaces fitted the whole form on one page - an officer opening accounts all day
* reads it at a glance and never scrolls. Material's default field height and spacing turn the same
* 56 fields into three screens' worth, so the whole card is tightened: shorter fields, smaller type,
* and just enough room under each field for the hint that names the client or agent it resolved.
*/
/*
* With the label moved outside, the box only has to hold the value - so it loses the height Material
* reserves for a floating label, and the form fits a screen instead of three.
*/
.pm-card {
::ng-deep {
.mat-mdc-form-field {
font-size: 12.5px;
}
.mat-mdc-form-field-infix {
min-height: 26px;
padding-top: 3px !important;
padding-bottom: 3px !important;
border-top: 0;
}
.mat-mdc-text-field-wrapper {
padding-left: 8px;
padding-right: 8px;
}
.mat-mdc-input-element,
.mat-mdc-select-value-text {
font-size: 12.5px;
}
// Room for one line of hint - the resolved client/agent name that confirms the right record.
.mat-mdc-form-field-subscript-wrapper {
height: 14px;
min-height: 14px;
}
.mat-mdc-form-field-hint {
font-size: 10.5px;
line-height: 14px;
}
// Suffix icons and the datepicker toggle default to a 48px touch target, which reopens all the
// height just saved.
.mat-mdc-icon-button.mat-mdc-button-base {
width: 26px;
height: 26px;
padding: 3px;
}
.mat-mdc-form-field-icon-suffix {
padding-left: 2px;
.mat-datepicker-toggle svg {
width: 15px;
height: 15px;
}
}
.mat-mdc-tab-body-wrapper {
min-height: 0;
}
}
}
.pm-tab-content {
padding: 8px 16px;
}
/*
* Blocks drawn the way the screen this replaces drew them: a hairline between groups of fields,
* with the group's name as a quiet caption above it rather than a heading bar of its own. An
* operator who has filled this form a hundred times navigates by field position; one filling their
* first still gets told which block is which.
*/
.pm-section {
padding: 8px 0 2px;
border-bottom: 1px solid rgb(0 0 0 / 11%);
&__header {
margin-bottom: 6px;
gap: 0 10px;
// Decoration the legacy screen did without, and the row is tighter for losing it.
i {
display: none;
}
h3 {
font-size: 11px;
font-weight: 700;
letter-spacing: 0.07em;
text-transform: uppercase;
color: $primary;
}
}
}
.pm-section__sub {
font-size: 0.72rem;
color: rgb(0 0 0 / 45%);
}
.pm-grid {
gap: 0 12px;
}
.pm-checkbox-field,
.pm-radio-group {
height: 28px;
::ng-deep label {
font-size: 12.5px;
}
}
// The nominee grid is its own row of fields, so its labels sit above rather than beside - there is
// no room for a label column eight fields across.
.pm-nominee-row {
gap: 0 10px;
padding: 3px 0;
.pm-f {
grid-template-columns: minmax(0, 1fr);
row-gap: 1px;
}
.pm-f-lbl {
text-align: left;
padding-top: 0;
font-size: 10.5px;
}
}
.pm-note {
margin: 0;
font-size: 0.74rem;
}
.pm-action-bar {
padding: 10px 18px;
}
/*
The customer's real deposit account. Not a form field - nothing here is typed, it is what the
bank's books say - so it reads as a fact rather than sitting in an input box pretending to be one.
*/
.pm-account {
display: flex;
flex-direction: column;
gap: 2px;
padding: 3px 0;
min-height: 26px;
justify-content: center;
}
.pm-account-no {
font-family: ui-monospace, 'SF Mono', 'Cascadia Mono', monospace;
font-size: 0.85rem;
letter-spacing: 0.02em;
}
.pm-account-meta {
font-size: 0.75rem;
color: rgb(0 0 0 / 55%);
}
.pm-account-live {
color: #15803d;
font-weight: 500;
}
/* Said plainly: this party has no account behind it, which is a real gap, not a blank field. */
.pm-account-missing {
font-size: 0.75rem;
font-style: italic;
color: #9b2c2c;
}
/*
Something the branch has to do, rather than something that has gone wrong - so it reads as an
instruction in the warning colour, not an error in red like the closed-account notice.
*/
.pm-callout--action {
background: rgb(180 83 9 / 8%);
border-color: rgb(180 83 9 / 25%);
color: #b45309;
}
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,166 @@
<!--
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="pl-root">
<div class="pl-header">
<div class="pl-header-left">
<div class="pl-header-ic"><fa-icon icon="clipboard-check"></fa-icon></div>
<div class="pl-header-text">
<h2>Accounts Due for Closure</h2>
<p>Matured DDS accounts still open — pay the customer, then close the account.</p>
</div>
</div>
<div class="pl-header-right">
<button class="pl-create-btn" type="button" [routerLink]="['/accounts/dds']">
<fa-icon icon="arrow-left"></fa-icon>
<span>Party Master</span>
</button>
</div>
</div>
@if (!loading && parties.length > 0) {
<div class="pl-kpis">
<div class="pl-kpi pl-kpi--purple" matTooltip="Matured accounts still open, at either step">
<div class="pl-kpi-ic"><fa-icon icon="clipboard-check"></fa-icon></div>
<div>
<div class="pl-kpi-n">{{ parties.length | number }}</div>
<div class="pl-kpi-l">Outstanding</div>
</div>
</div>
<div
class="pl-kpi pl-kpi--teal"
matTooltip="Money the bank is holding on matured accounts it has not yet paid out"
>
<div class="pl-kpi-ic"><fa-icon icon="wallet"></fa-icon></div>
<div>
<div class="pl-kpi-n">{{ totalPayable | number: '1.2-2' }}</div>
<div class="pl-kpi-l">Still to Pay</div>
</div>
</div>
<!-- Already paid, waiting only on the close. Separated because it is the quicker half of the
list and needs no customer to be reached. -->
@if (awaitingCloseCount > 0) {
<div class="pl-kpi pl-kpi--blue" matTooltip="Paid out in full — these only need closing">
<div class="pl-kpi-ic"><fa-icon icon="check"></fa-icon></div>
<div>
<div class="pl-kpi-n">{{ awaitingCloseCount | number }}</div>
<div class="pl-kpi-l">Ready to Close</div>
</div>
</div>
}
</div>
}
<div class="pl-card">
@if (loading) {
<div class="pl-empty"><p>Loading…</p></div>
} @else if (parties.length === 0) {
<!-- An empty list here is good news, so it says so rather than looking like a failed load. -->
<div class="pl-empty">
<div class="pl-empty-ic dfc-empty-ic"><fa-icon icon="check"></fa-icon></div>
<h3>Nothing to close</h3>
<p>No DDS account has matured and been left open. Every matured account has been paid out and closed.</p>
</div>
} @else {
<div class="pl-table-wrap">
<table class="pl-table dfc-table">
<thead>
<tr>
<th>Party</th>
<th>Customer</th>
<th>Collected By</th>
<th>Matured On</th>
<th class="num">Payable</th>
<th class="num">Action</th>
</tr>
</thead>
<tbody>
@for (party of parties; track party.id) {
<tr class="pl-row">
<td>
<div class="pl-product-meta">
<div class="pl-product-name">{{ party.partyCode }}</div>
<div class="pl-product-sub dfc-account">{{ party.accountNo || party.savingsAccountNo || '—' }}</div>
</div>
</td>
<td>{{ party.partyName }}</td>
<td>
<div class="pl-product-meta">
<div class="pl-product-name">{{ party.agentName || '—' }}</div>
<div class="pl-product-sub">{{ party.agentCode }}</div>
</div>
</td>
<td>
<div class="pl-product-meta">
<div class="pl-product-name">{{ party.maturityDate | dateFormat }}</div>
<!-- How long the bank has been holding it. A maturity from months back is a
different problem from yesterday's, and the date alone does not say so. -->
<div class="pl-product-sub" [class.dfc-stale]="daysOverdue(party) > 30">
{{ daysOverdue(party) }} {{ daysOverdue(party) === 1 ? 'day' : 'days' }} ago
</div>
</div>
</td>
<td class="num">
@if (awaitingPayout(party)) {
<strong>{{ party.currentBalance | number: '1.2-2' }}</strong>
} @else {
<!-- Paid. Shown as words rather than 0.00 so a settled account is not mistaken for
one that was never collected on. -->
<span class="dfc-paid">Paid out</span>
}
</td>
<td class="num">
<!--
Two steps, and an account stays on this list through both: pay the customer, then
close. The officer sees the balance fall to nil before closing, so nothing is
closed on a payment nobody checked.
-->
@if (heldUnderLien(party)) {
<!--
Pledged against a loan. Offering Pay Out here would only be refused by the
server, so the row says why instead — the deposit is the bank's security until
the borrowing against it is cleared.
-->
<span
class="dfc-lien"
[matTooltip]="
(party.amountHeldUnderLien | number: '1.2-2') +
' is pledged against a loan. It is paid out once the loan is repaid and the lien released.'
"
>
<fa-icon icon="lock"></fa-icon>
<span>Held under lien</span>
</span>
} @else if (awaitingPayout(party)) {
<button
type="button"
class="pl-action-btn pl-action-btn--edit"
(click)="payOut(party)"
matTooltip="Pay this money out to the customer"
>
<fa-icon icon="wallet"></fa-icon><span>Pay Out</span>
</button>
} @else {
<button
type="button"
class="pl-action-btn dfc-close-btn"
(click)="closeAccount(party)"
matTooltip="The money has gone out — close this account"
>
<fa-icon icon="check"></fa-icon><span>Close Account</span>
</button>
}
</td>
</tr>
}
</tbody>
</table>
</div>
}
</div>
</div>
@@ -0,0 +1,97 @@
/**
* 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/.
*/
@use 'assets/styles/product-list' as pl;
@include pl.product-list-styles;
/* An empty list here means the work is done, so the mark is the reassuring colour, not the grey one. */
.dfc-empty-ic {
color: #15803d;
}
.dfc-account {
font-family: ui-monospace, 'SF Mono', 'Cascadia Mono', monospace;
font-size: 0.75rem;
}
/*
* A maturity the bank has been sitting on for over a month. Called out because the date alone reads
* the same whether it passed yesterday or in the spring, and the two are not the same problem.
*/
.dfc-stale {
color: #b45309;
font-weight: 500;
}
/*
* An account whose money has already gone out. Words rather than 0.00, so a settled account is not
* read as one that was never collected on - both would show the same figure.
*/
.dfc-paid {
color: #15803d;
font-weight: 600;
font-size: 0.78rem;
}
/*
* The close action. Green rather than the list's usual blue because it is the step that finishes the
* account, and it only ever appears on a row already paid.
*/
.dfc-close-btn {
color: #15803d;
border-color: rgb(21 128 61 / 30%);
background: rgb(21 128 61 / 6%);
&:hover {
background: rgb(21 128 61 / 12%);
border-color: rgb(21 128 61 / 45%);
}
}
/*
* A deposit pledged against a loan. Not a button, because there is nothing for the clerk to do here
* until the loan is cleared — showing one would only lead to a refusal.
*/
.dfc-lien {
display: inline-flex;
align-items: center;
gap: 6px;
padding: 4px 10px;
border: 1px solid rgb(90 74 156 / 30%);
border-radius: 4px;
background: rgb(90 74 156 / 8%);
color: #5a4a9c;
font-size: 0.78rem;
font-weight: 600;
cursor: help;
}
/* Plain table rather than mat-table: this list is short by design and needs no sort or paging. */
.dfc-table {
th,
td {
padding: 10px 14px;
text-align: left;
border-bottom: 1px solid rgb(0 0 0 / 6%);
}
th {
font-size: 0.72rem;
font-weight: 600;
letter-spacing: 0.04em;
text-transform: uppercase;
color: rgb(0 0 0 / 55%);
background: rgb(0 0 0 / 2%);
}
.num {
text-align: right;
font-variant-numeric: tabular-nums;
}
}
@@ -0,0 +1,214 @@
/**
* 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 { MatDialog } from '@angular/material/dialog';
import { FaIconComponent } from '@fortawesome/angular-fontawesome';
import { STANDALONE_SHARED_IMPORTS } from 'app/standalone-shared.module';
/** Custom Services */
import { OrganizationService } from 'app/organization/organization.service';
import { AlertService } from 'app/core/alert/alert.service';
import { SettingsService } from 'app/settings/settings.service';
import { Dates } from 'app/core/utils/dates';
/** Custom Components */
import { PayoutDialogComponent } from '../payout-dialog/payout-dialog.component';
import { ConfirmationDialogComponent } from 'app/shared/confirmation-dialog/confirmation-dialog.component';
/**
* The DDS accounts whose term has run out and which are still open - the branch's list of closures
* waiting to be done.
*
* A list of work, not a report. Nothing closes itself: a matured account is money the bank now owes
* the customer, and where it goes has to be agreed with them, so each one waits here until an
* officer deals with it. Before this existed the maturity date simply passed and nobody was told -
* the bank held the money and the customer had to think to ask for it.
*
* Two steps, not one, and an account stays on this list through both:
*
* balance owing pay it out - to another account of theirs, or into matured-deposits payable
* balance nil paid; close it
*
* Splitting them is what a branch actually does. The officer pays, sees the balance fall to nil and
* the money land where it was meant to, and only then closes. One click doing both would close
* accounts whose payment nobody had checked, and a closed account is far harder to put right.
*
* Oldest maturity first, because that is the one the bank has been holding longest.
*/
@Component({
selector: 'mifosx-due-for-closure',
templateUrl: './due-for-closure.component.html',
styleUrls: ['./due-for-closure.component.scss'],
imports: [
...STANDALONE_SHARED_IMPORTS,
FaIconComponent
]
})
export class DueForClosureComponent implements OnInit {
private organizationService = inject(OrganizationService);
private dialog = inject(MatDialog);
private alertService = inject(AlertService);
private settingsService = inject(SettingsService);
private dateUtils = inject(Dates);
parties: any[] = [];
loading = true;
ngOnInit(): void {
this.load();
}
private load(): void {
this.loading = true;
this.organizationService.getPartiesDueForClosure().subscribe({
next: (parties: any) => {
this.parties = Array.isArray(parties) ? parties : parties?.pageItems || [];
this.loading = false;
},
error: () => {
this.parties = [];
this.loading = false;
}
});
}
/** What the bank is holding in total across every matured account still open. */
get totalPayable(): number {
return this.parties.reduce((sum, party) => sum + Number(party.currentBalance || 0), 0);
}
/**
* Whether this one is still waiting on its payment. Anything left on the account is the customer's
* money, so it is paid before the account can be closed.
*/
awaitingPayout(party: any): boolean {
return Number(party.currentBalance || 0) > 0;
}
/**
* Whether this deposit is standing as security for a loan.
*
* The term has ended and the money is owed, but it cannot leave while it is pledged - so the row
* says so rather than offering a Pay Out the server would refuse. A customer who needed cash before
* their term ended borrowed against the deposit instead of breaking it; the deposit is paid when
* that loan is cleared.
*/
heldUnderLien(party: any): boolean {
return Number(party.amountHeldUnderLien || 0) > 0;
}
/** How many are already paid and now only need closing - the quicker half of the list. */
get awaitingCloseCount(): number {
return this.parties.filter((party) => !this.awaitingPayout(party)).length;
}
/**
* How long the bank has been sitting on this one. Shown because a maturity from three months ago
* is a different problem from one from yesterday, and the list alone would not say so.
*/
daysOverdue(party: any): number {
const matured = this.toDate(party.maturityDate);
if (!matured) {
return 0;
}
return Math.max(0, Math.floor((Date.now() - matured.getTime()) / 86400000));
}
/** The API sends dates as [year, month, day]; month is 1-based there and 0-based in Date. */
private toDate(value: any): Date | null {
if (Array.isArray(value) && value.length >= 3) {
return new Date(value[0], value[1] - 1, value[2]);
}
return value ? new Date(value) : null;
}
payOut(party: any): void {
const dialogRef = this.dialog.open(PayoutDialogComponent, {
data: party,
width: '520px',
autoFocus: false
});
dialogRef.afterClosed().subscribe((payoutCommand: any) => {
if (!payoutCommand) {
return;
}
this.organizationService.payOutParty(party.id, payoutCommand).subscribe({
next: (result: any) => {
if (awaitingApproval(result)) {
this.alertService.alert({
type: 'Sent for Approval',
message: `Payout of ${party.partyCode} is waiting for a manager's approval. Nothing has been paid yet.`
});
this.load();
return;
}
this.alertService.alert({
type: 'Party Paid Out',
message: payoutCommand.toSavingsAccountNo
? `${party.partyCode} paid into ${payoutCommand.toSavingsAccountNo} and the account is closed.`
: `${party.partyCode} parked in Sundry in the customer's name and the account is closed.`
});
this.load();
}
// A refusal is shown by the platform's own error handler, which names the reason - repeating
// it here would say the same thing twice.
});
});
}
/**
* Closes an account whose money has already gone out. The server refuses one still carrying a
* balance, so this cannot close an account out from under an unpaid customer.
*
* Asked for first, because closing is the one step here with no way back.
*/
closeAccount(party: any): void {
const confirmation = this.dialog.open(ConfirmationDialogComponent, {
data: {
heading: `Close ${party.partyCode}?`,
dialogContext: `${party.partyName}'s account has been paid out in full and can be closed. It cannot be reopened.`,
type: 'warn'
}
});
confirmation.afterClosed().subscribe((response: any) => {
if (!response?.confirm) {
return;
}
const dateFormat = this.settingsService.dateFormat;
const locale = this.settingsService.language.code;
this.organizationService
.closeParty(party.id, {
closeDate: this.dateUtils.formatDate(this.settingsService.businessDate || new Date(), dateFormat),
dateFormat,
locale
})
.subscribe({
next: (result: any) => {
this.alertService.alert(
awaitingApproval(result)
? {
type: 'Sent for Approval',
message: `Closing ${party.partyCode} is waiting for a manager's approval. It stays open until then.`
}
: { type: 'Party Closed', message: `${party.partyCode} is closed.` }
);
this.load();
}
});
});
}
}
/** Under maker-checker the command is only recorded - it has a command id but no resource yet. */
function awaitingApproval(result: any): boolean {
return !!result?.commandId && result?.resourceId == null;
}
@@ -0,0 +1,73 @@
<!--
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/.
-->
<h2 mat-dialog-title class="nd-title">
Nominees
<span class="nd-party">{{ data.partyCode }} · {{ data.partyName }}</span>
</h2>
<mat-dialog-content class="nd-body">
@if (nominees.length === 0) {
<p class="nd-empty">Nobody is nominated on this account.</p>
} @else {
<p class="nd-mode">
@if (bySequence) {
Paid in the order listed — the first named who can be found receives the deposit.
} @else {
Split by share of the deposit.
}
</p>
<table class="nd-table">
<thead>
<tr>
<th>@if (bySequence) { Order } @else { # }</th>
<th>Name</th>
<th>Relation</th>
<th>Nominated</th>
@if (!bySequence) {
<th class="nd-num">Share</th>
}
</tr>
</thead>
<tbody>
@for (nominee of nominees; track nominee.id; let i = $index) {
<tr>
<!-- The stated order where there is one; otherwise just the position in the list. -->
<td class="nd-seq">{{ nominee.sequenceNo ?? i + 1 }}</td>
<td>
{{ nominee.name }}
@if (nominee.minor) {
<span class="nd-minor">minor · guardian {{ nominee.guardianName || '—' }}</span>
}
</td>
<td>{{ nominee.relation }}</td>
<td>{{ nominee.nominationDate | dateFormat }}</td>
@if (!bySequence) {
<td class="nd-num">
@if (nominee.sharePercent !== null && nominee.sharePercent !== undefined) {
{{ nominee.sharePercent }}%
} @else {
—
}
</td>
}
</tr>
}
</tbody>
</table>
@if (shareWarning) {
<p class="nd-warn">{{ shareWarning }}</p>
}
}
</mat-dialog-content>
<mat-dialog-actions align="end">
<button mat-raised-button color="primary" mat-dialog-close>Close</button>
</mat-dialog-actions>
@@ -0,0 +1,84 @@
/**
* 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/.
*/
.nd-title {
display: flex;
flex-direction: column;
gap: 2px;
margin-bottom: 0;
}
.nd-party {
font-size: 13px;
font-weight: 400;
color: rgb(0 0 0 / 55%);
}
.nd-body {
min-width: 420px;
padding-top: 8px;
}
.nd-mode {
margin: 0 0 12px;
font-size: 13px;
color: rgb(0 0 0 / 60%);
}
.nd-empty {
margin: 8px 0;
color: rgb(0 0 0 / 55%);
}
.nd-table {
width: 100%;
border-collapse: collapse;
font-size: 14px;
th {
text-align: left;
font-weight: 500;
font-size: 12px;
letter-spacing: 0.04em;
text-transform: uppercase;
color: rgb(0 0 0 / 50%);
padding: 0 8px 6px 0;
border-bottom: 1px solid rgb(0 0 0 / 10%);
}
td {
padding: 8px 8px 8px 0;
border-bottom: 1px solid rgb(0 0 0 / 6%);
vertical-align: top;
}
}
/* Shares line up to be read down the column, not across the row. */
.nd-num {
text-align: right;
font-variant-numeric: tabular-nums;
padding-right: 0;
}
.nd-seq {
width: 32px;
color: rgb(0 0 0 / 45%);
font-variant-numeric: tabular-nums;
}
.nd-minor {
display: block;
font-size: 12px;
color: rgb(0 0 0 / 50%);
}
.nd-warn {
margin: 12px 0 0;
font-size: 13px;
color: #b3261e;
}
@@ -0,0 +1,82 @@
/**
* Copyright since 2025 Mifos Initiative
*
* This Source Code Form is subject to the terms of the Mozilla Public
* License, v. 2.0. If a copy of the MPL was not distributed with this
* file, You can obtain one at http://mozilla.org/MPL/2.0/.
*/
/** Angular Imports */
import { Component, inject } from '@angular/core';
import { CommonModule } from '@angular/common';
import { MAT_DIALOG_DATA, MatDialogModule } from '@angular/material/dialog';
import { MatButtonModule } from '@angular/material/button';
/** Custom Pipes */
import { DateFormatPipe } from 'app/pipes/date-format.pipe';
/**
* Who this account is nominated to, read without opening it for editing.
*
* A clerk asked "who is on this account" has one question, not an intention to change anything, and
* the edit form answers it only after a load, a tab and a scroll - with every field live the whole
* time. This states it and closes.
*
* What is shown follows the split the account was nominated under: a share per nominee under
* Percentage, and the order they are paid in under Sequence, because that ordering is the whole of
* what Sequence records.
*/
@Component({
selector: 'mifosx-nominee-list-dialog',
standalone: true,
imports: [
CommonModule,
MatDialogModule,
MatButtonModule,
DateFormatPipe
],
templateUrl: './nominee-list-dialog.component.html',
styleUrls: ['./nominee-list-dialog.component.scss']
})
export class NomineeListDialogComponent {
/** { partyCode, partyName, splitMode, nominees } */
data = inject<any>(MAT_DIALOG_DATA);
get bySequence(): boolean {
return this.data?.splitMode === 'Sequence';
}
get nominees(): any[] {
return this.data?.nominees || [];
}
/**
* What the shares come to. Shown only when it is not the whole deposit - a nomination recorded
* before shares were captured reads as complete otherwise, and the remainder has nobody named
* to receive it.
*/
get shareTotal(): number | null {
if (this.bySequence) {
return null;
}
const stated = this.nominees.filter((n) => n?.sharePercent !== null && n?.sharePercent !== undefined);
if (stated.length === 0) {
return null;
}
return stated.reduce((sum, n) => sum + Number(n.sharePercent || 0), 0);
}
get shareWarning(): string | null {
if (this.bySequence) {
// Under Sequence the order is the whole of the nomination, so an order nobody stated is the
// same gap a missing share is under Percentage.
const stated = this.nominees.filter((n) => n?.sequenceNo !== null && n?.sequenceNo !== undefined);
return stated.length === this.nominees.length ? null : 'No order was recorded against these nominees.';
}
const total = this.shareTotal;
if (total === null) {
return 'No shares were recorded against these nominees.';
}
return total === 100 ? null : `The shares come to ${total}%, not 100%.`;
}
}
@@ -0,0 +1,240 @@
<!--
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="pl-root">
<div class="pl-header">
<div class="pl-header-left">
<div class="pl-header-ic"><fa-icon icon="users"></fa-icon></div>
<div class="pl-header-text">
<h2>Party Master</h2>
<p>The DDS accounts on file — each one a customer collected from daily.</p>
</div>
</div>
<div class="pl-header-right">
<div class="pl-count-chip" matTooltip="Total parties">
<fa-icon icon="users"></fa-icon>
<strong>{{ totalCount }}</strong>
<span>{{ totalCount === 1 ? 'party' : 'parties' }}</span>
</div>
<button class="pl-create-btn" type="button" [routerLink]="['create']">
<fa-icon icon="plus"></fa-icon>
<span>Create Party</span>
</button>
</div>
</div>
<!--
Matured accounts still open. Money the bank owes a customer that nobody has been told about, so
it is called out on the screen an officer already opens rather than left on a report they would
have to think to run.
-->
@if (dueForClosureCount > 0) {
<a class="pm-due-banner" [routerLink]="['dueforclosure']">
<fa-icon icon="clipboard-check" class="pm-due-banner__ic"></fa-icon>
<span class="pm-due-banner__text">
<strong>{{ dueForClosureCount }}</strong>
{{ dueForClosureCount === 1 ? 'account has' : 'accounts have' }} matured and
{{ dueForClosureCount === 1 ? 'is' : 'are' }} still open —
<!-- Nil once every one of them has been paid: what is left then is the closing, not the money. -->
@if (dueForClosurePayable > 0) {
<strong>{{ dueForClosurePayable | number: '1.2-2' }}</strong> still owed to customers.
} @else {
paid in full, waiting to be closed.
}
</span>
<span class="pm-due-banner__cta"
>Settle {{ dueForClosureCount === 1 ? 'it' : 'them' }} <fa-icon icon="arrow-right"></fa-icon
></span>
</a>
}
<div class="pl-kpis">
<div class="pl-kpi pl-kpi--blue">
<div class="pl-kpi-ic"><fa-icon icon="users"></fa-icon></div>
<div>
<div class="pl-kpi-n">{{ activeCount | number }}</div>
<div class="pl-kpi-l">Open Accounts</div>
</div>
</div>
<div
class="pl-kpi pl-kpi--purple"
matTooltip="What the agents should collect between them on a full collection day"
>
<div class="pl-kpi-ic"><fa-icon icon="calendar-day"></fa-icon></div>
<div>
<div class="pl-kpi-n">{{ totalDailyDue | number: '1.2-2' }}</div>
<div class="pl-kpi-l">Daily Collection Due</div>
</div>
</div>
<div class="pl-kpi pl-kpi--teal" matTooltip="Every open account's running balance added up">
<div class="pl-kpi-ic"><fa-icon icon="wallet"></fa-icon></div>
<div>
<div class="pl-kpi-n">{{ totalCurrentBalance | number: '1.2-2' }}</div>
<div class="pl-kpi-l">Total Deposits Held</div>
</div>
</div>
</div>
<div class="pl-card">
<div class="pl-toolbar">
<div class="pl-search">
<fa-icon icon="search" class="pl-search-ic"></fa-icon>
<input
type="text"
(input)="applyFilter($any($event.target).value)"
placeholder="Search by code, name or agent…"
/>
</div>
</div>
@if (totalCount === 0) {
<div class="pl-empty">
<div class="pl-empty-ic"><fa-icon icon="users"></fa-icon></div>
<h3>No parties yet</h3>
<p>Open one so an agent can start collecting daily deposits against it.</p>
<button class="pl-create-btn" type="button" [routerLink]="['create']">
<fa-icon icon="plus"></fa-icon>
<span>Create Party</span>
</button>
</div>
} @else {
<div class="pl-table-wrap">
<table mat-table [dataSource]="dataSource" matSort class="pl-table">
<ng-container matColumnDef="partyCode">
<th mat-header-cell *matHeaderCellDef mat-sort-header>Party Code</th>
<td mat-cell *matCellDef="let p">
<div class="pl-product-cell">
<div class="pl-product-icon"><fa-icon icon="user"></fa-icon></div>
<div class="pl-product-meta">
<div class="pl-product-name">{{ p.partyCode }}</div>
<div class="pl-product-sub">{{ p.city || '—' }}</div>
</div>
</div>
</td>
</ng-container>
<ng-container matColumnDef="partyName">
<th mat-header-cell *matHeaderCellDef mat-sort-header>Party Name</th>
<td mat-cell *matCellDef="let p">{{ p.partyName }}</td>
</ng-container>
<ng-container matColumnDef="subType">
<th mat-header-cell *matHeaderCellDef mat-sort-header>Sub Type</th>
<td mat-cell *matCellDef="let p">
<span class="pl-tag" [matTooltip]="p.subType">{{ p.subTypeDescription || p.subType }}</span>
</td>
</ng-container>
<ng-container matColumnDef="agentName">
<th mat-header-cell *matHeaderCellDef mat-sort-header>Collected By</th>
<td mat-cell *matCellDef="let p">
<div class="pl-product-meta">
<div class="pl-product-name">{{ p.agentName || '—' }}</div>
<div class="pl-product-sub">{{ p.agentCode }}</div>
</div>
</td>
</ng-container>
<!--
The customer's real deposit account. A party without one is money the bank's books do not
know about, so it is called out rather than shown as a blank cell.
-->
<ng-container matColumnDef="savingsAccountNo">
<th mat-header-cell *matHeaderCellDef>DDS Account</th>
<td mat-cell *matCellDef="let p">
@if (p.accountNo) {
<div class="pl-product-meta">
<div class="pm-list-account">{{ p.accountNo }}</div>
<div class="pl-product-sub">{{ p.subTypeDescription || p.subType }}</div>
</div>
} @else if (p.savingsAccountNo) {
<div class="pl-product-meta">
<div class="pm-list-account">{{ p.savingsAccountNo }}</div>
<div class="pl-product-sub">{{ p.savingsAccountStatus }}</div>
</div>
} @else {
<span class="pm-list-no-account">Not opened</span>
}
</td>
</ng-container>
<ng-container matColumnDef="dailyDepositAmount">
<th mat-header-cell *matHeaderCellDef mat-sort-header class="num">Daily Deposit</th>
<td mat-cell *matCellDef="let p" class="num">{{ p.dailyDepositAmount | number: '1.2-2' }}</td>
</ng-container>
<ng-container matColumnDef="currentBalance">
<th mat-header-cell *matHeaderCellDef mat-sort-header class="num">Current Balance</th>
<td mat-cell *matCellDef="let p" class="num">
<strong>{{ p.currentBalance | number: '1.2-2' }}</strong>
</td>
</ng-container>
<ng-container matColumnDef="status">
<th mat-header-cell *matHeaderCellDef>Status</th>
<td mat-cell *matCellDef="let p">
<span class="pm-pill" [class.pm-pill--off]="p.status === 'CLOSED'">
{{ p.status === 'CLOSED' ? 'Closed' : 'Active' }}
</span>
</td>
</ng-container>
<ng-container matColumnDef="nominees">
<th mat-header-cell *matHeaderCellDef>Nominees</th>
<td mat-cell *matCellDef="let p">
@if (nomineeCount(p) > 0) {
<button
type="button"
class="pl-nominee-btn"
(click)="showNominees(p)"
matTooltip="See who this account is nominated to"
>
{{ nomineeCount(p) }} nominated
</button>
} @else {
<span class="pl-muted">None</span>
}
</td>
</ng-container>
<ng-container matColumnDef="actions">
<th mat-header-cell *matHeaderCellDef class="num">Actions</th>
<td mat-cell *matCellDef="let p" class="num">
<button
type="button"
class="pl-action-btn pl-action-btn--edit"
[routerLink]="[p.id, 'edit']"
[matTooltip]="p.status === 'CLOSED' ? 'View this closed party' : 'Edit this party'"
>
<fa-icon [icon]="p.status === 'CLOSED' ? 'eye' : 'edit'"></fa-icon>
<span>{{ p.status === 'CLOSED' ? 'View' : 'Edit' }}</span>
</button>
</td>
</ng-container>
<tr mat-header-row *matHeaderRowDef="displayedColumns"></tr>
<tr
mat-row
*matRowDef="let row; columns: displayedColumns"
class="pl-row"
[class.pm-row--closed]="row.status === 'CLOSED'"
></tr>
</table>
@if (filteredCount === 0 && totalCount > 0) {
<div class="pl-no-match">
<fa-icon icon="search"></fa-icon>
<span>No parties match your filter.</span>
</div>
}
<mat-paginator [pageSizeOptions]="[10, 25, 50, 100]" [pageSize]="10" showFirstLastButtons></mat-paginator>
</div>
}
</div>
</div>
@@ -0,0 +1,133 @@
/**
* 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/.
*/
@use 'assets/styles/product-list' as pl;
@include pl.product-list-styles;
/* Party-specific amber accent */
:host {
--pl-accent: #b45309;
--pl-accent-l: #fef3c7;
}
/* A closed party stays listed - its collections and interest are still on the books. */
.pm-row--closed td:not(.num) {
opacity: 0.55;
}
.pm-pill {
display: inline-block;
padding: 3px 11px;
border-radius: 11px;
font-size: 0.72rem;
font-weight: 500;
line-height: 1.7;
background: #dcfce7;
color: #15803d;
white-space: nowrap;
}
.pm-pill--off {
background: rgb(0 0 0 / 7%);
color: rgb(0 0 0 / 52%);
}
/* The customer's real deposit account number, in the list. */
.pm-list-account {
font-family: ui-monospace, 'SF Mono', 'Cascadia Mono', monospace;
font-size: 0.8rem;
}
/*
A party with no account behind it. Called out rather than left blank: the balance beside it is
money the bank's own books have no record of, which is the thing worth noticing here.
*/
.pm-list-no-account {
font-size: 0.75rem;
font-style: italic;
color: #9b2c2c;
}
/*
Matured accounts still open. Deliberately loud: it is money the bank owes a customer who has not
been told, and every day it sits there is a day the bank is holding somebody else's money. Styled
as a link to the work rather than a notice about it - reading it should be the same click as
starting on it.
*/
.pm-due-banner {
display: flex;
align-items: center;
gap: 12px;
margin: 0 0 14px;
padding: 12px 16px;
background: rgb(180 83 9 / 8%);
border: 1px solid rgb(180 83 9 / 28%);
border-radius: 8px;
color: #92400e;
text-decoration: none;
transition: background 0.15s ease;
&:hover {
background: rgb(180 83 9 / 13%);
}
&__ic {
font-size: 1.05rem;
flex: 0 0 auto;
}
&__text {
flex: 1 1 auto;
font-size: 0.88rem;
line-height: 1.45;
}
&__cta {
flex: 0 0 auto;
display: inline-flex;
align-items: center;
gap: 6px;
font-size: 0.82rem;
font-weight: 600;
white-space: nowrap;
}
}
@media (width <= 700px) {
.pm-due-banner {
flex-wrap: wrap;
}
}
/*
* The nominee count, which opens the list of names rather than the account itself.
*
* Styled as text that can be clicked rather than as a second action button: the row already has one
* action, and a pair of equal-looking buttons would put "look at this" beside "change this" as
* though the two were the same kind of thing.
*/
.pl-nominee-btn {
border: 0;
background: none;
padding: 0;
font: inherit;
color: var(--pl-accent);
font-weight: 500;
cursor: pointer;
text-decoration: underline;
text-underline-offset: 3px;
&:hover {
text-decoration-thickness: 2px;
}
}
.pl-muted {
color: rgb(0 0 0 / 40%);
}
@@ -0,0 +1,198 @@
/**
* 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, ViewChild, inject } from '@angular/core';
import { MatPaginator } from '@angular/material/paginator';
import { MatSort, MatSortHeader } from '@angular/material/sort';
import {
MatTable,
MatTableDataSource,
MatColumnDef,
MatHeaderCellDef,
MatHeaderCell,
MatCellDef,
MatCell,
MatHeaderRowDef,
MatHeaderRow,
MatRowDef,
MatRow
} from '@angular/material/table';
import { MatTooltip } from '@angular/material/tooltip';
import { FaIconComponent } from '@fortawesome/angular-fontawesome';
import { STANDALONE_SHARED_IMPORTS } from 'app/standalone-shared.module';
import { MatDialog } from '@angular/material/dialog';
/** Custom Services */
import { OrganizationService } from 'app/organization/organization.service';
/** Custom Components */
import { NomineeListDialogComponent } from './nominee-list-dialog/nominee-list-dialog.component';
/**
* Party Master list.
*
* The DDS accounts on file - each one a customer's daily deposit account, opened under a scheme
* (Sub Type) and assigned to the agent who collects for it. Follows the same list -> create/edit
* shape as Agent Master and the product lists, and shares their stylesheet.
*/
@Component({
selector: 'mifosx-party-master',
templateUrl: './party-master.component.html',
styleUrls: ['./party-master.component.scss'],
imports: [
...STANDALONE_SHARED_IMPORTS,
FaIconComponent,
MatTable,
MatSort,
MatColumnDef,
MatHeaderCellDef,
MatHeaderCell,
MatSortHeader,
MatCellDef,
MatCell,
MatHeaderRowDef,
MatHeaderRow,
MatRowDef,
MatRow,
MatPaginator,
MatTooltip
]
})
export class PartyMasterComponent implements OnInit {
private organizationService = inject(OrganizationService);
private dialog = inject(MatDialog);
displayedColumns: string[] = [
'partyCode',
'partyName',
'subType',
'agentName',
'savingsAccountNo',
'dailyDepositAmount',
'currentBalance',
'nominees',
'status',
'actions'
];
/**
* Who a party is nominated to, without opening it for editing.
*
* Asked often enough to belong on the list, and answered from what the list already holds - the
* parties come back with their nominees on them, so this opens rather than fetches.
*/
showNominees(party: any): void {
this.dialog.open(NomineeListDialogComponent, {
data: {
partyCode: party.partyCode,
partyName: party.partyName,
splitMode: party.nomineeSplitMode,
nominees: party.nominees || []
},
autoFocus: false
});
}
/** How many people are named on an account, which is what the list column shows. */
nomineeCount(party: any): number {
return (party?.nominees || []).length;
}
/**
* Matured accounts still open, and what they add up to. Shown here because this is the screen an
* officer already opens - money the bank owes a customer should not wait on somebody thinking to
* run a report.
*/
dueForClosureCount = 0;
dueForClosurePayable = 0;
dataSource: MatTableDataSource<any> = new MatTableDataSource<any>([]);
parties: any[] = [];
filteredCount = 0;
paginator: MatPaginator;
sort: MatSort;
// The table sits inside an @if, so these only exist once the list has loaded. Read statically they
// were never there, the table was never paged, and the paginator read "0 of 0" over a full list.
@ViewChild(MatPaginator) set matPaginator(paginator: MatPaginator) {
this.paginator = paginator;
if (paginator) {
this.dataSource.paginator = paginator;
}
}
@ViewChild(MatSort) set matSort(sort: MatSort) {
this.sort = sort;
if (sort) {
this.dataSource.sort = sort;
}
}
get totalCount(): number {
return this.parties.length;
}
get activeCount(): number {
return this.parties.filter((party) => party.status !== 'CLOSED').length;
}
/** What the whole book is worth today - every open account's running balance added up. */
get totalCurrentBalance(): number {
return this.parties.reduce((sum, party) => sum + Number(party.currentBalance || 0), 0);
}
/** What the agents should be collecting between them on a full collection day. */
get totalDailyDue(): number {
return this.parties
.filter((party) => party.status !== 'CLOSED')
.reduce((sum, party) => sum + Number(party.dailyDepositAmount || 0), 0);
}
ngOnInit(): void {
this.organizationService.getParties().subscribe((parties: any[]) => {
this.parties = parties || [];
this.dataSource = new MatTableDataSource(this.parties);
this.dataSource.paginator = this.paginator;
this.dataSource.sort = this.sort;
this.filteredCount = this.parties.length;
this.dataSource.connect().subscribe((rows) => (this.filteredCount = rows.length));
});
this.loadDueForClosure();
}
/**
* Matured accounts still open. A separate call rather than filtered out of the list above, because
* the server decides what counts as matured - comparing dates here would drift from it the first
* time either changed.
*/
private loadDueForClosure(): void {
this.organizationService.getPartiesDueForClosure().subscribe({
next: (due: any) => {
const rows = Array.isArray(due) ? due : due?.pageItems || [];
this.dueForClosureCount = rows.length;
this.dueForClosurePayable = rows.reduce(
(sum: number, party: any) => sum + Number(party.currentBalance || 0),
0
);
},
// The party list is the point of this screen; a failed banner must not take it down with it.
error: () => {
this.dueForClosureCount = 0;
this.dueForClosurePayable = 0;
}
});
}
applyFilter(filterValue: string): void {
this.dataSource.filter = filterValue.trim().toLowerCase();
}
}
@@ -0,0 +1,164 @@
<!--
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/.
-->
<h2 mat-dialog-title>Pay out {{ data.partyCode }}</h2>
<mat-dialog-content>
<!-- What is being paid and to whom, stated once at the top so the officer confirms against the
customer in front of them rather than against a row they clicked a moment ago. -->
<div class="po-summary">
<div class="po-summary__row">
<span class="po-summary__label">Customer</span>
<span class="po-summary__value">{{ data.partyName }}</span>
</div>
<div class="po-summary__row">
<span class="po-summary__label">Matured on</span>
<span class="po-summary__value">{{ data.maturityDate | dateFormat }}</span>
</div>
<div class="po-summary__row">
<span class="po-summary__label">Opened on</span>
<span class="po-summary__value">{{ data.openingDate | dateFormat }}</span>
</div>
@if (breakdown) {
<div class="po-breakdown">
<div class="po-summary__row">
<span class="po-summary__label">Deposits collected</span>
<span class="po-summary__value">{{ breakdown.deposits | number: '1.2-2' }}</span>
</div>
@if (breakdown.transfersIn > 0) {
<div class="po-summary__row">
<span class="po-summary__label">Transferred in</span>
<span class="po-summary__value">+ {{ breakdown.transfersIn | number: '1.2-2' }}</span>
</div>
}
@if (breakdown.transfersOut > 0) {
<div class="po-summary__row">
<span class="po-summary__label">Transferred out</span>
<span class="po-summary__value">− {{ breakdown.transfersOut | number: '1.2-2' }}</span>
</div>
}
@if (breakdown.other !== 0) {
<div class="po-summary__row">
<span class="po-summary__label">Other (opening balance / adjustments)</span>
<span class="po-summary__value">{{ breakdown.other | number: '1.2-2' }}</span>
</div>
}
<div class="po-summary__row">
<span class="po-summary__label">
Interest @ {{ data.interestRate | number: '1.2-2' }}%
<span class="po-summary__note"
>{{ data.openingDate | dateFormat }} to {{ data.interestDate | dateFormat }}</span
>
</span>
<span class="po-summary__value">+ {{ breakdown.interest | number: '1.2-2' }}</span>
</div>
@if (breakdown.interestToPost > 0) {
<div class="po-summary__row">
<span class="po-summary__label"> Interest calculated, posted with this payout </span>
<span class="po-summary__value">+ {{ breakdown.interestToPost | number: '1.2-2' }}</span>
</div>
}
</div>
}
<div class="po-summary__row po-summary__row--amount">
<span class="po-summary__label">Total payable</span>
<span class="po-summary__value">{{ (breakdown ? breakdown.total : data.currentBalance) | number: '1.2-2' }}</span>
</div>
@if (interestStillToCome) {
<p class="po-summary__note">
Interest from {{ data.interestDate | dateFormat }} to {{ data.maturityDate | dateFormat }} is not worked out yet
- it is added when this is paid out, so the amount paid will be a little more.
</p>
}
</div>
<p class="po-summary__note">
Paying out closes {{ data.partyCode }} - the balance is nil afterwards and it takes nothing more.
</p>
@if (loading) {
<mat-progress-bar mode="indeterminate"></mat-progress-bar>
} @else {
<p class="po-question">Where should this go?</p>
<mat-radio-group [(ngModel)]="destination" class="po-choices">
<mat-radio-button value="own" [disabled]="ownAccounts.length === 0" class="po-choice">
<span class="po-choice__title">Into another account of theirs here</span>
@if (ownAccounts.length === 0) {
<span class="po-choice__note">{{ data.partyName }} has no other savings account at this bank</span>
} @else {
<span class="po-choice__note">Moved across today</span>
}
</mat-radio-button>
@if (destination === 'own' && ownAccounts.length > 0) {
<mat-form-field appearance="outline" class="po-account">
<mat-label>Account</mat-label>
<mat-select [(ngModel)]="selectedAccountNo" panelWidth="min(28rem, 90vw)">
@for (account of ownAccounts; track account.id) {
<mat-option [value]="account.accountNo"> {{ account.accountNo }} · {{ account.productName }} </mat-option>
}
</mat-select>
</mat-form-field>
}
<mat-radio-button value="other" class="po-choice">
<span class="po-choice__title">Park in Sundry</span>
<span class="po-choice__note"
>When the customer has no account here - held in the bank's Sundry account in their name</span
>
</mat-radio-button>
</mat-radio-group>
@if (destination === 'other') {
<mat-form-field appearance="outline" class="po-account">
<mat-label>Park in GL account</mat-label>
<input
matInput
[ngModel]="sundrySearch"
(ngModelChange)="onSundryTyped($event)"
[matAutocomplete]="sundryAuto"
placeholder="Type GL code or name…"
autocomplete="off"
/>
<mat-autocomplete
#sundryAuto="matAutocomplete"
panelWidth="min(28rem, 90vw)"
[displayWith]="sundryDisplay"
(optionSelected)="pickSundry($event.option.value)"
>
@for (account of sundryOptions; track account.id) {
<mat-option [value]="account">{{ account.glCode }} · {{ account.name }}</mat-option>
}
@if (sundryOptions.length === 0) {
<mat-option disabled>No liability GL matches</mat-option>
}
</mat-autocomplete>
@if (!sundryGlAccountId) {
<mat-hint class="po-hint-warn">Search and pick your bank's Sundry GL account</mat-hint>
} @else {
<mat-hint>The balance is held here in the customer's name until it is paid to them</mat-hint>
}
</mat-form-field>
}
@if (destination === 'other') {
<!-- Said plainly, because the officer will be asked by the customer when the money arrives. -->
<div class="po-callout">
The money comes off {{ data.partyCode }} today and is held in the Sundry account chosen above against
{{ data.partyName }} until it is paid to them - so it is accounted for the whole time and nothing is lost.
</div>
}
}
</mat-dialog-content>
<mat-dialog-actions align="end">
<button mat-stroked-button mat-dialog-close>Cancel</button>
<button mat-raised-button color="primary" [disabled]="!canPayOut" (click)="payOut()">Pay out &amp; Close</button>
</mat-dialog-actions>
@@ -0,0 +1,112 @@
/**
* Copyright since 2025 Mifos Initiative
*
* This Source Code Form is subject to the terms of the Mozilla Public
* License, v. 2.0. If a copy of the MPL was not distributed with this
* file, You can obtain one at http://mozilla.org/MPL/2.0/.
*/
/* What is being paid and to whom. Read against the customer standing there, so it comes first. */
.po-summary {
display: flex;
flex-direction: column;
gap: 6px;
padding: 12px 14px;
margin-bottom: 18px;
background: rgb(0 0 0 / 3%);
border-radius: 6px;
&__row {
display: flex;
justify-content: space-between;
gap: 16px;
font-size: 0.85rem;
/* The figure the officer is about to hand over - given the weight that deserves. */
&--amount {
padding-top: 6px;
border-top: 1px solid rgb(0 0 0 / 8%);
font-size: 1rem;
}
}
&__label {
color: rgb(0 0 0 / 55%);
}
&__value {
font-weight: 500;
font-variant-numeric: tabular-nums;
}
&__note {
display: block;
margin: 0;
font-size: 0.75rem;
color: rgb(0 0 0 / 45%);
}
}
/* How the amount payable is made up - one line per source, adding up to the total below. */
.po-breakdown {
display: flex;
flex-direction: column;
gap: 6px;
padding-top: 6px;
border-top: 1px dashed rgb(0 0 0 / 10%);
}
.po-question {
margin: 0 0 10px;
font-size: 0.9rem;
font-weight: 500;
}
.po-choices {
display: flex;
flex-direction: column;
gap: 4px;
}
/*
* Each choice carries its own consequence underneath it. Two radio labels alone would not tell an
* officer that one keeps the money inside the bank and the other starts it on its way out.
*/
.po-choice {
display: block;
padding: 4px 0;
&__title {
display: block;
font-size: 0.88rem;
}
&__note {
display: block;
font-size: 0.75rem;
color: rgb(0 0 0 / 55%);
line-height: 1.4;
}
}
.po-account {
width: 100%;
margin: 8px 0 4px 32px;
max-width: calc(100% - 32px);
}
/* What will actually happen, in the words the customer will ask about. */
.po-callout {
margin-top: 12px;
padding: 10px 12px;
background: rgb(180 83 9 / 8%);
border: 1px solid rgb(180 83 9 / 22%);
border-radius: 6px;
color: #92400e;
font-size: 0.78rem;
line-height: 1.5;
}
.po-hint-warn {
color: #c62828;
}
@@ -0,0 +1,230 @@
/**
* 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 { CommonModule } from '@angular/common';
import { FormsModule } from '@angular/forms';
import { MAT_DIALOG_DATA, MatDialogModule, MatDialogRef } from '@angular/material/dialog';
import { MatButtonModule } from '@angular/material/button';
import { MatRadioModule } from '@angular/material/radio';
import { MatSelectModule } from '@angular/material/select';
import { MatAutocompleteModule } from '@angular/material/autocomplete';
import { MatFormFieldModule } from '@angular/material/form-field';
import { MatInputModule } from '@angular/material/input';
import { MatProgressBarModule } from '@angular/material/progress-bar';
/** Custom Services */
import { OrganizationService } from 'app/organization/organization.service';
/** Custom Pipes */
import { DateFormatPipe } from 'app/pipes/date-format.pipe';
/**
* Where a matured DDS account's money should go.
*
* Two destinations, and the difference is whether the money leaves the bank. Another account of the
* customer's here is a move across the books. Anywhere else means a transfer out, which clears later
* or fails - so the money is parked in the matured-deposits payable account first and the branch
* sends it on from there, rather than being launched into a gap where it has left one account and
* arrived at none.
*
* The amount is not asked for. It is whatever the account holds, worked out by the server: a figure
* typed here could pay out more than the customer has, or quietly leave some behind.
*/
@Component({
selector: 'mifosx-payout-dialog',
standalone: true,
imports: [
CommonModule,
FormsModule,
MatDialogModule,
MatButtonModule,
MatRadioModule,
MatSelectModule,
MatAutocompleteModule,
MatFormFieldModule,
MatInputModule,
MatProgressBarModule,
DateFormatPipe
],
templateUrl: './payout-dialog.component.html',
styleUrls: ['./payout-dialog.component.scss']
})
export class PayoutDialogComponent implements OnInit {
private organizationService = inject(OrganizationService);
private dialogRef = inject(MatDialogRef<PayoutDialogComponent>);
data = inject<any>(MAT_DIALOG_DATA);
/** 'own' moves it to another account here; 'other' parks it for a transfer to another bank. */
destination: 'own' | 'other' = 'own';
/**
* The customer's accounts this maturity can be paid into.
*
* Worked out by the server, which applies the same conditions the payout enforces: an active
* savings account of theirs, not a fixed or recurring deposit, and not one of their DDS scheme
* accounts. Filtering here instead let the two drift - the list once offered a scheme account
* belonging to a party that had already closed, which would have stranded the money.
*/
ownAccounts: any[] = [];
selectedAccountNo = '';
/** Where the balance is parked when the customer has no account here. */
sundryAccounts: any[] = [];
sundryGlAccountId: number | null = null;
/** What is typed in the GL box - a code or part of a name. */
sundrySearch = '';
/** The accounts matching what is typed, by GL code or name, in any case. */
get sundryOptions(): any[] {
const typed = this.sundrySearch.trim().toLowerCase();
if (!typed || typed === this.sundryLabel(this.chosenSundry).toLowerCase()) {
return this.sundryAccounts;
}
return this.sundryAccounts.filter(
(account: any) =>
String(account.glCode).toLowerCase().includes(typed) ||
String(account.name || '')
.toLowerCase()
.includes(typed)
);
}
get chosenSundry(): any {
return this.sundryAccounts.find((account: any) => account.id === this.sundryGlAccountId);
}
/** Shows a picked account in the box as its code and name. */
readonly sundryDisplay = (value: any): string =>
value && typeof value === 'object' ? this.sundryLabel(value) : value || '';
sundryLabel(account: any): string {
return account ? `${account.glCode} · ${account.name}` : '';
}
pickSundry(account: any): void {
this.sundryGlAccountId = account ? account.id : null;
this.sundrySearch = this.sundryLabel(account);
}
/** Typing again clears the choice until an account is picked, so a half-typed code is never sent. */
onSundryTyped(value: any): void {
// Picking from the list hands over the account itself rather than text.
if (value && typeof value === 'object') {
this.pickSundry(value);
return;
}
value = String(value ?? '');
this.sundrySearch = value;
if (value !== this.sundryLabel(this.chosenSundry)) {
const exact = this.sundryAccounts.find(
(account: any) => String(account.glCode).toLowerCase() === value.trim().toLowerCase()
);
this.sundryGlAccountId = exact ? exact.id : null;
}
}
loading = true;
/**
* How the amount payable was made up - what was deposited, what came in or went out by transfer,
* and the interest - read from the account's own transactions so the officer can explain the
* figure to the customer line by line. Null until the transactions have arrived.
*/
breakdown: {
deposits: number;
transfersIn: number;
transfersOut: number;
interest: number;
other: number;
interestToPost: number;
total: number;
} | null = null;
ngOnInit(): void {
// The accounts the balance can be parked in. None is chosen: each bank has its own Sundry account,
// and the clerk searches for it and picks it.
this.organizationService.getDdsSundryAccounts().subscribe({
next: (accounts: any) => (this.sundryAccounts = accounts || []),
error: () => (this.sundryAccounts = [])
});
this.organizationService.getPartyTransactionsOfParty(this.data.id).subscribe({
next: (transactions: any) => (this.breakdown = this.breakdownOf(Array.isArray(transactions) ? transactions : [])),
error: () => (this.breakdown = null)
});
this.organizationService.getCreditableAccounts(this.data.clientId).subscribe({
next: (accounts: any) => {
this.ownAccounts = accounts || [];
// Nothing here to pay into, so the only real choice is a transfer out - offered as the
// default rather than leaving the officer on an option that cannot be completed.
if (this.ownAccounts.length === 0) {
this.destination = 'other';
} else if (this.ownAccounts.length === 1) {
this.selectedAccountNo = this.ownAccounts[0].accountNo;
}
this.loading = false;
},
error: () => {
this.ownAccounts = [];
this.destination = 'other';
this.loading = false;
}
});
}
/** Interest is still to be worked out for part of the term, and the payout will add it. */
get interestStillToCome(): boolean {
return (
!!this.data.interestDate &&
!!this.data.maturityDate &&
String(this.data.interestDate) < String(this.data.maturityDate)
);
}
private breakdownOf(transactions: any[]) {
const sum = (test: (txn: any) => boolean) =>
transactions.filter(test).reduce((total: number, txn: any) => total + Number(txn.amount || 0), 0);
const signed = (type: string) =>
sum((txn) => txn.transactionType === type && txn.direction === 'CREDIT') -
sum((txn) => txn.transactionType === type && txn.direction === 'DEBIT');
const isTransfer = (txn: any) => txn.transactionType === 'WITHDRAW_DEPOSITE' || txn.transactionType === 'TRANSFER';
const deposits = signed('PARTY_TRAN');
const transfersIn = sum((txn) => isTransfer(txn) && txn.direction === 'CREDIT');
const transfersOut = sum((txn) => isTransfer(txn) && txn.direction === 'DEBIT');
const interest =
signed('INTEREST_POST') +
signed('OD_INTEREST_POST') +
signed('PREMATURE_AGENT_TOP_UP') +
signed('PREMATURE_INT_RECOVERY');
const balance = Number(this.data.currentBalance || 0);
// Anything the four lines above do not cover - an opening balance brought over, say - so the lines
// still add up to the balance rather than leaving the officer a gap to explain.
const other = Math.round((balance - (deposits + transfersIn - transfersOut + interest)) * 100) / 100;
// Interest already worked out but not yet credited is posted by the payout itself, first.
const interestToPost = Number(this.data.interestCalc || 0);
return { deposits, transfersIn, transfersOut, interest, other, interestToPost, total: balance + interestToPost };
}
get canPayOut(): boolean {
if (this.loading) {
return false;
}
return this.destination === 'other' ? !!this.sundryGlAccountId : !!this.selectedAccountNo;
}
payOut(): void {
this.dialogRef.close(
this.destination === 'own'
? { toSavingsAccountNo: this.selectedAccountNo }
: { sundryGlAccountId: this.sundryGlAccountId }
);
}
}
@@ -0,0 +1,175 @@
<!--
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/.
-->
<h2 mat-dialog-title>Close {{ data.partyCode }} early</h2>
<mat-dialog-content>
@if (loading) {
<mat-progress-bar mode="indeterminate"></mat-progress-bar>
}
<!--
The account as it stands, so the clerk is choosing against real figures rather than from memory.
The term still to run is the whole reason there is a charge at all.
-->
<div class="pcd-summary">
<div class="pcd-row">
<span>Customer</span>
<strong>{{ data.partyName }}</strong>
</div>
<div class="pcd-row">
<span>Balance</span>
<strong class="pcd-amount">{{ data.currentBalance | number: '1.2-2' }}</strong>
</div>
<div class="pcd-row">
<span>Matures on</span>
<strong>{{ data.maturityDate | dateFormat }}</strong>
</div>
<div class="pcd-row">
<span>Scheme rate</span>
<strong>{{ data.interestRate | number: '1.0-2' }}%</strong>
</div>
</div>
<h4 class="pcd-question">Who bears the cost of closing early?</h4>
<mat-radio-group class="pcd-choices" [(ngModel)]="borneBy">
<label class="pcd-choice" [class.pcd-choice--on]="borneBy === 'CUSTOMER'">
<mat-radio-button value="CUSTOMER"></mat-radio-button>
<div class="pcd-choice-body">
<div class="pcd-choice-title">Customer</div>
@if (scheme) {
<div class="pcd-choice-detail">
Interest recalculated at
<strong>{{ reducedRate | number: '1.0-2' }}%</strong>
instead of {{ data.interestRate | number: '1.0-2' }}% for the whole period, and the difference taken back
before the balance is paid out.
</div>
} @else {
<div class="pcd-choice-detail">Their interest is recalculated at the scheme's reduced rate.</div>
}
</div>
</label>
<label class="pcd-choice" [class.pcd-choice--on]="borneBy === 'AGENT'">
<mat-radio-button value="AGENT"></mat-radio-button>
<div class="pcd-choice-body">
<div class="pcd-choice-title">Agent — {{ data.agentCode }}</div>
<div class="pcd-choice-detail">
The customer keeps a full month's interest, and the agent funds the days left in this month. The balance is
paid out in full.
</div>
</div>
</label>
</mat-radio-group>
<!--
Stated rather than offered as a third option: the agent is charged whichever answer is picked, and
a clerk who did not know that would be surprised by it afterwards.
-->
@if (scheme && scheme.prematureAgentCommissionPenalty > 0) {
<p class="pcd-note">
Either way, {{ data.agentCode }} is charged
<strong>{{ scheme.prematureAgentCommissionPenalty | number: '1.0-2' }}%</strong>
of the commission owed to them — the round was not kept going.
</p>
}
<h4 class="pcd-question">Where should the balance go?</h4>
<mat-radio-group class="pcd-destinations" [(ngModel)]="destination">
<mat-radio-button value="own" [disabled]="ownAccounts.length === 0">
Into another account of theirs at this bank
</mat-radio-button>
<mat-radio-button value="other">Park in Sundry (no account here)</mat-radio-button>
</mat-radio-group>
@if (destination === 'own' && ownAccounts.length > 0) {
<mat-form-field appearance="outline" class="pcd-field">
<mat-label>Credit to account</mat-label>
<mat-select [(ngModel)]="selectedAccountNo">
@for (account of ownAccounts; track account.accountNo) {
<mat-option [value]="account.accountNo"> {{ account.accountNo }} — {{ account.productName }} </mat-option>
}
</mat-select>
</mat-form-field>
}
@if (destination === 'other') {
<mat-form-field appearance="outline" class="pcd-field">
<mat-label>Park in GL account</mat-label>
<input
matInput
[ngModel]="sundrySearch"
(ngModelChange)="onSundryTyped($event)"
[matAutocomplete]="sundryAuto"
placeholder="Type GL code or name…"
autocomplete="off"
/>
<mat-autocomplete
#sundryAuto="matAutocomplete"
panelWidth="min(28rem, 90vw)"
[displayWith]="sundryDisplay"
(optionSelected)="pickSundry($event.option.value)"
>
@for (account of sundryOptions; track account.id) {
<mat-option [value]="account">{{ account.glCode }} · {{ account.name }}</mat-option>
}
@if (sundryOptions.length === 0) {
<mat-option disabled>No liability GL matches</mat-option>
}
</mat-autocomplete>
@if (!sundryGlAccountId) {
<mat-hint class="po-hint-warn">Search and pick your bank's Sundry GL account</mat-hint>
} @else {
<mat-hint>The balance is held here in the customer's name until it is paid to them</mat-hint>
}
</mat-form-field>
}
@if (ownAccounts.length === 0 && !loading) {
<p class="pcd-note">
This customer has no other account here, so the balance is parked in the Sundry account chosen above, in their
name until it is paid to them.
</p>
}
<!--
Inside the lock-in the scheme does not allow closing early, so this is asked for rather than done:
the reason goes to a manager, who approves this one account. The scheme's lock-in stays as it is.
-->
@if (withinLockin) {
<div class="pcd-lockin">
<p class="pcd-lockin-title">Within lock-in until {{ closableFrom | dateFormat }}</p>
<p class="pcd-note">
{{ data.partyCode }} opened on {{ data.openingDate | dateFormat }} and the scheme locks it in for
{{ scheme?.prematureLockinMonths }} months. It can be closed early only if a manager approves it.
</p>
<mat-form-field appearance="outline" class="pcd-field">
<mat-label>Reason for closing within lock-in</mat-label>
<textarea matInput rows="2" maxlength="250" required [(ngModel)]="lockinReason"></textarea>
<mat-hint align="end">{{ lockinReason.length }}/250</mat-hint>
</mat-form-field>
</div>
}
<p class="pcd-warn">
@if (withinLockin) {
Once approved, this closes the account. It cannot be reopened.
} @else {
This closes the account. It cannot be reopened.
}
</p>
</mat-dialog-content>
<mat-dialog-actions align="end">
<button mat-button mat-dialog-close type="button">Cancel</button>
<button mat-raised-button color="warn" type="button" [disabled]="!canClose" (click)="close()">
{{ withinLockin ? 'Send for Approval' : 'Close Account' }}
</button>
</mat-dialog-actions>
@@ -0,0 +1,132 @@
/**
* Copyright since 2025 Mifos Initiative
*
* This Source Code Form is subject to the terms of the Mozilla Public
* License, v. 2.0. If a copy of the MPL was not distributed with this
* file, You can obtain one at http://mozilla.org/MPL/2.0/.
*/
/* The account as it stands, so the choice below is made against figures rather than from memory. */
.pcd-summary {
margin: 4px 0 20px;
padding: 12px 14px;
border-radius: 6px;
background: rgb(0 0 0 / 3%);
}
.pcd-row {
display: flex;
justify-content: space-between;
gap: 16px;
padding: 3px 0;
font-size: 0.85rem;
span {
color: rgb(0 0 0 / 60%);
}
}
.pcd-amount {
font-variant-numeric: tabular-nums;
}
.pcd-question {
margin: 20px 0 10px;
font-size: 0.9rem;
font-weight: 600;
}
.pcd-choices {
display: flex;
flex-direction: column;
gap: 10px;
}
/*
* Each answer is a card rather than a bare radio, because the two are not interchangeable - they take
* money from different people, and the consequence has to be readable before the choice is made.
*/
.pcd-choice {
display: flex;
align-items: flex-start;
gap: 4px;
padding: 10px 12px 12px;
border: 1px solid rgb(0 0 0 / 12%);
border-radius: 6px;
cursor: pointer;
&:hover {
background: rgb(0 0 0 / 2%);
}
}
.pcd-choice--on {
border-color: #b45309;
background: rgb(180 83 9 / 5%);
}
.pcd-choice-body {
padding-top: 2px;
}
.pcd-choice-title {
font-weight: 600;
font-size: 0.88rem;
}
.pcd-choice-detail {
margin-top: 3px;
font-size: 0.8rem;
line-height: 1.5;
color: rgb(0 0 0 / 65%);
}
.pcd-destinations {
display: flex;
flex-direction: column;
gap: 6px;
}
.pcd-field {
width: 100%;
margin-top: 12px;
}
.pcd-note {
margin: 12px 0 0;
font-size: 0.8rem;
line-height: 1.5;
color: rgb(0 0 0 / 65%);
}
/* The one step here with no way back, so it is said plainly rather than left to the button colour. */
.pcd-warn {
margin: 18px 0 0;
padding: 9px 12px;
border-radius: 6px;
border: 1px solid rgb(185 28 28 / 25%);
background: rgb(185 28 28 / 6%);
color: #b91c1c;
font-size: 0.8rem;
font-weight: 500;
}
/* Inside the lock-in: a request for a manager, not a closure, so it is set apart from the terms above. */
.pcd-lockin {
margin-top: 18px;
padding: 12px 14px 4px;
border-radius: 8px;
border: 1px solid rgb(180 83 9 / 30%);
background: rgb(245 158 11 / 8%);
}
.pcd-lockin-title {
margin: 0;
font-size: 0.85rem;
font-weight: 600;
color: #b45309;
}
.po-hint-warn {
color: #c62828;
}
@@ -0,0 +1,228 @@
/**
* 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 { CommonModule } from '@angular/common';
import { FormsModule } from '@angular/forms';
import { MAT_DIALOG_DATA, MatDialogModule, MatDialogRef } from '@angular/material/dialog';
import { MatButtonModule } from '@angular/material/button';
import { MatRadioModule } from '@angular/material/radio';
import { MatSelectModule } from '@angular/material/select';
import { MatAutocompleteModule } from '@angular/material/autocomplete';
import { MatFormFieldModule } from '@angular/material/form-field';
import { MatInputModule } from '@angular/material/input';
import { MatProgressBarModule } from '@angular/material/progress-bar';
/** Custom Services */
import { OrganizationService } from 'app/organization/organization.service';
import { ProductsService } from 'app/products/products.service';
/** Custom Pipes */
import { DateFormatPipe } from 'app/pipes/date-format.pipe';
/**
* Closing a DDS account before its term is up.
*
* A customer who stops saving early costs the bank the interest it had promised on money it no longer
* has for as long as it expected. Somebody has to carry that, and the bank allows two answers - so
* this asks which, and it is the only thing it asks.
*
* What each answer costs is the scheme's, not the clerk's: the reduced rate and the agent's share are
* set once when the scheme is created and shown here as fact. A figure typed at the counter is a
* figure that can be typed differently for the next customer, which is exactly what a published term
* is meant to prevent.
*
* The destination works as it does on a maturity payout - another account of theirs here, or parked
* in matured-deposits payable for a transfer out - because what is left over is still their money and
* has to reach them the same way.
*/
@Component({
selector: 'mifosx-premature-close-dialog',
standalone: true,
imports: [
CommonModule,
FormsModule,
MatDialogModule,
MatButtonModule,
MatRadioModule,
MatSelectModule,
MatAutocompleteModule,
MatFormFieldModule,
MatInputModule,
MatProgressBarModule,
DateFormatPipe
],
templateUrl: './premature-close-dialog.component.html',
styleUrls: ['./premature-close-dialog.component.scss']
})
export class PrematureCloseDialogComponent implements OnInit {
private organizationService = inject(OrganizationService);
private productsService = inject(ProductsService);
private dialogRef = inject(MatDialogRef<PrematureCloseDialogComponent>);
data = inject<any>(MAT_DIALOG_DATA);
/** Who carries the shortfall. No default - the two answers take money from different people. */
borneBy: 'CUSTOMER' | 'AGENT' | '' = '';
/** 'own' moves what is left to another account here; 'other' parks it for a transfer out. */
destination: 'own' | 'other' = 'own';
ownAccounts: any[] = [];
selectedAccountNo = '';
/** Where the balance is parked when the customer has no account here. */
sundryAccounts: any[] = [];
sundryGlAccountId: number | null = null;
/** What is typed in the GL box - a code or part of a name. */
sundrySearch = '';
/** The accounts matching what is typed, by GL code or name, in any case. */
get sundryOptions(): any[] {
const typed = this.sundrySearch.trim().toLowerCase();
if (!typed || typed === this.sundryLabel(this.chosenSundry).toLowerCase()) {
return this.sundryAccounts;
}
return this.sundryAccounts.filter(
(account: any) =>
String(account.glCode).toLowerCase().includes(typed) ||
String(account.name || '')
.toLowerCase()
.includes(typed)
);
}
get chosenSundry(): any {
return this.sundryAccounts.find((account: any) => account.id === this.sundryGlAccountId);
}
/** Shows a picked account in the box as its code and name. */
readonly sundryDisplay = (value: any): string =>
value && typeof value === 'object' ? this.sundryLabel(value) : value || '';
sundryLabel(account: any): string {
return account ? `${account.glCode} · ${account.name}` : '';
}
pickSundry(account: any): void {
this.sundryGlAccountId = account ? account.id : null;
this.sundrySearch = this.sundryLabel(account);
}
/** Typing again clears the choice until an account is picked, so a half-typed code is never sent. */
onSundryTyped(value: any): void {
// Picking from the list hands over the account itself rather than text.
if (value && typeof value === 'object') {
this.pickSundry(value);
return;
}
value = String(value ?? '');
this.sundrySearch = value;
if (value !== this.sundryLabel(this.chosenSundry)) {
const exact = this.sundryAccounts.find(
(account: any) => String(account.glCode).toLowerCase() === value.trim().toLowerCase()
);
this.sundryGlAccountId = exact ? exact.id : null;
}
}
loading = true;
/**
* The scheme's terms, shown so the clerk can tell the customer what each answer costs before
* choosing. Null while loading, and left null if the scheme cannot be read - the choice is then
* still offered, because the server applies the terms either way and a screen that could not
* display them should not block a closure the customer has asked for.
*/
scheme: any = null;
ngOnInit(): void {
// The accounts the balance can be parked in. None is chosen: each bank has its own Sundry account,
// and the clerk searches for it and picks it.
this.organizationService.getDdsSundryAccounts().subscribe({
next: (accounts: any) => (this.sundryAccounts = accounts || []),
error: () => (this.sundryAccounts = [])
});
this.organizationService.getCreditableAccounts(this.data.clientId).subscribe({
next: (accounts: any) => {
this.ownAccounts = accounts || [];
if (this.ownAccounts.length === 0) {
this.destination = 'other';
} else if (this.ownAccounts.length === 1) {
this.selectedAccountNo = this.ownAccounts[0].accountNo;
}
this.loading = false;
},
error: () => {
this.ownAccounts = [];
this.destination = 'other';
this.loading = false;
}
});
// The terms are read from the scheme the account runs on, so what is shown here is what the
// server will actually charge rather than a second copy that can drift from it. Matched on the
// code the party carries, which is the only handle it has on its scheme.
this.productsService.getDdsAccountTypes().subscribe({
next: (schemes: any) => {
const all = Array.isArray(schemes) ? schemes : schemes?.pageItems || [];
this.scheme = all.find((s: any) => s.accountTypeCode === this.data.subType) || null;
},
error: () => (this.scheme = null)
});
}
/** The rate the customer would be left on if they carry it. Never below nothing. */
get reducedRate(): number {
const rate = Number(this.data.interestRate || 0);
const reduction = Number(this.scheme?.prematureInterestReduction || 0);
return Math.max(0, rate - reduction);
}
/** Why the lock-in should be waived for this account - required when it is inside it. */
lockinReason = '';
/**
* The day the scheme's lock-in ends for this account, or null when the scheme has none. Inside it
* the account cannot be closed early on the scheme's terms; it can only be asked for, with a reason,
* for a manager to approve - the scheme's lock-in itself is never changed to let one account through.
*/
get closableFrom(): Date | null {
const months = Number(this.scheme?.prematureLockinMonths || 0);
const opened = this.data.openingDate ? new Date(this.data.openingDate) : null;
if (months <= 0 || !opened || isNaN(opened.getTime())) {
return null;
}
const closable = new Date(opened);
closable.setMonth(closable.getMonth() + months);
return closable;
}
get withinLockin(): boolean {
const closable = this.closableFrom;
return !!closable && closable.getTime() > Date.now();
}
get canClose(): boolean {
if (this.loading || !this.borneBy) {
return false;
}
if (this.withinLockin && !this.lockinReason.trim()) {
return false;
}
return this.destination === 'other' ? !!this.sundryGlAccountId : !!this.selectedAccountNo;
}
close(): void {
this.dialogRef.close({
shortfallBorneBy: this.borneBy,
...(this.destination === 'own' && this.selectedAccountNo ? { toSavingsAccountNo: this.selectedAccountNo } : {}),
...(this.destination === 'other' && this.sundryGlAccountId ? { sundryGlAccountId: this.sundryGlAccountId } : {}),
...(this.withinLockin ? { lockinOverrideReason: this.lockinReason.trim() } : {})
});
}
}
+5 -2
View File
@@ -14,6 +14,7 @@ import { CommonModule } from '@angular/common';
import { HasPermissionDirective } from './has-permission/has-permission.directive';
import { FormatAmountDirective } from './format-amount.directive';
import { ValidateOnFocusDirective } from './validate-on-focus.directive';
import { RestrictInputDirective } from './restrict-input.directive';
/**
* Directives Module
@@ -25,12 +26,14 @@ import { ValidateOnFocusDirective } from './validate-on-focus.directive';
CommonModule,
HasPermissionDirective,
FormatAmountDirective,
ValidateOnFocusDirective
ValidateOnFocusDirective,
RestrictInputDirective
],
exports: [
HasPermissionDirective,
FormatAmountDirective,
ValidateOnFocusDirective
ValidateOnFocusDirective,
RestrictInputDirective
]
})
export class DirectivesModule {}
@@ -0,0 +1,137 @@
/**
* Copyright since 2025 Mifos Initiative
*
* This Source Code Form is subject to the terms of the Mozilla Public
* License, v. 2.0. If a copy of the MPL was not distributed with this
* file, You can obtain one at http://mozilla.org/MPL/2.0/.
*/
import { Directive, HostListener, Input, ElementRef, inject } from '@angular/core';
import { NgControl } from '@angular/forms';
/** The kinds of field this knows how to hold to a shape. */
export type RestrictKind = 'digits' | 'phone' | 'pan' | 'aadhaar';
interface Rule {
/** Characters this kind will accept at all. */
allowed: RegExp;
/** How many of them the field can hold, so it cannot be overfilled. */
maxLength: number;
/** Whether what is typed is forced to upper case as it is entered. */
upper?: boolean;
}
const RULES: Record<RestrictKind, Rule> = {
digits: { allowed: /[0-9]/, maxLength: 20 },
phone: { allowed: /[0-9+()\-\s]/, maxLength: 20 },
// A PAN is five letters, four digits, one letter - and is always written in capitals.
pan: { allowed: /[A-Za-z0-9]/, maxLength: 10, upper: true },
// Twelve digits, or the masked form CKYC returns, which is Xs and the last four digits.
aadhaar: { allowed: /[0-9Xx]/, maxLength: 12, upper: true }
};
/**
* Stops a field being filled with something it could never hold.
*
* A mobile number typed as letters, or a PAN of fifteen characters, is not a mistake worth
* discovering on save - the customer is at the counter now, and the clerk is looking at the field
* they just typed. Refusing the keystroke says so at the moment it happens, with nothing to read
* and nothing to undo.
*
* Pasting is handled too, and is where this earns its place: a number copied from a spreadsheet
* arrives with spaces, dashes or a +91 in front, and pasting is the one way into a field that
* bypasses every keystroke rule. What can be salvaged is kept, the rest dropped.
*
* This only refuses characters the field cannot contain. Whether what is left is a valid number is
* a separate question, answered by the form's own validators and again by the server - a screen
* restriction is a convenience, not a guarantee.
*/
@Directive({
selector: '[mifosxRestrictInput]',
standalone: true
})
export class RestrictInputDirective {
private el = inject(ElementRef<HTMLInputElement>);
private control = inject(NgControl, { optional: true });
@Input('mifosxRestrictInput') kind: RestrictKind = 'digits';
private get rule(): Rule {
return RULES[this.kind] ?? RULES.digits;
}
/**
* Keys that move around or edit rather than enter a character, plus the usual copy/paste and
* select-all chords. Blocking these would leave the clerk unable to correct their own typing.
*/
private isEditingKey(event: KeyboardEvent): boolean {
const navigation = [
'Backspace',
'Delete',
'Tab',
'Escape',
'Enter',
'Home',
'End',
'ArrowLeft',
'ArrowRight',
'ArrowUp',
'ArrowDown'
];
return navigation.includes(event.key) || event.ctrlKey || event.metaKey;
}
@HostListener('keydown', ['$event'])
onKeydown(event: KeyboardEvent): void {
if (this.isEditingKey(event) || event.key.length > 1) {
return;
}
const input = this.el.nativeElement as HTMLInputElement;
const hasSelection = input.selectionStart !== input.selectionEnd;
if (!this.rule.allowed.test(event.key)) {
event.preventDefault();
return;
}
// Replacing a selection does not make the field any longer, so only a plain insert can overfill.
if (!hasSelection && input.value.length >= this.rule.maxLength) {
event.preventDefault();
}
}
@HostListener('paste', ['$event'])
onPaste(event: ClipboardEvent): void {
event.preventDefault();
const pasted = event.clipboardData?.getData('text') ?? '';
const kept = Array.from(pasted)
.filter((ch) => this.rule.allowed.test(ch))
.join('')
.slice(0, this.rule.maxLength);
this.write(this.rule.upper ? kept.toUpperCase() : kept);
}
/** Upper case is applied as the field is left, so the caret does not jump while typing. */
@HostListener('blur')
onBlur(): void {
if (!this.rule.upper) {
return;
}
const current = String(this.el.nativeElement.value ?? '');
if (current !== current.toUpperCase()) {
this.write(current.toUpperCase());
}
}
/**
* Writes through the form control where there is one, so the value the form holds and the value
* on screen stay the same thing - setting only the element would leave the form with the old one.
*/
private write(value: string): void {
this.el.nativeElement.value = value;
if (this.control?.control) {
this.control.control.setValue(value);
} else {
this.el.nativeElement.dispatchEvent(new Event('input'));
}
}
}
+53 -86
View File
@@ -167,7 +167,9 @@
</div>
<!-- The 15th of the month after the one being reported, worked out rather than fixed at
15 Feb 2026 — a due date that never moves stops being a due date. -->
<div class="dc-meta">Due: <strong>{{ ctrDueDateLabel }}</strong></div>
<div class="dc-meta">
Due: <strong>{{ ctrDueDateLabel }}</strong>
</div>
<button class="dc-btn" (click)="showTab('ctr')"><fa-icon icon="external-link-alt"></fa-icon> Open</button>
</div>
<div class="dc-card">
@@ -220,9 +222,7 @@
@if (ctrLinesLoading) {
<div class="fiu-note">Loading the generated CTR lines…</div>
} @else if (ctrLinesError) {
<div class="fiu-note fiu-note--warn">
<fa-icon icon="exclamation-triangle"></fa-icon> {{ ctrLinesError }}
</div>
<div class="fiu-note fiu-note--warn"><fa-icon icon="exclamation-triangle"></fa-icon> {{ ctrLinesError }}</div>
} @else if (!ctrTxns.length) {
<div class="fiu-note fiu-note--warn">
<fa-icon icon="exclamation-triangle"></fa-icon>
@@ -432,87 +432,17 @@
}
<div class="fiu-section">
<!-- LIVE: suspicious AML alerts awaiting AML Officer review (Structuring, PEP, etc.) -->
<div class="fiu-form-meta">
<div class="fiu-form-title">
<h3>Live AML Alerts (Awaiting Triage)</h3>
<p>Flagged by the monitoring engine for suspicious behavioral patterns — triage and whitelist or escalate</p>
</div>
<!-- Whitelisted is deliberately absent: cleared alerts are not STR business and have
their own register below. -->
<select class="fiu-inp" [(ngModel)]="amlAlertStatusFilter">
<option value="">All statuses</option>
<option value="RAISED">Raised</option>
<option value="UNDER_REVIEW">Under Review</option>
<option value="ESCALATED">Escalated</option>
</select>
<!-- The triage queue that used to head this tab has gone. It listed the same open alerts as the AML Rules
Master, and listed them worse: without the severity bands, the per-rule findings or the batch decision that
screen carries. Two places to work the same queue is how an alert gets cleared in one and escalated in the
other. This tab keeps what is about the filing itself - what was cleared, and what became an STR case. -->
<div class="fiu-note">
<fa-icon icon="info-circle"></fa-icon>
<span>
Alerts are triaged in <strong>RBI &rsaquo; AML Rules Master &rsaquo; Flagged Transactions</strong>. What an
officer escalates there arrives here as an STR case.
</span>
</div>
<div class="fiu-tbl-wrap">
<table class="fiu-tbl">
<thead>
<tr>
<th>ID</th>
<th>Client</th>
<th>Type</th>
<th>Rule</th>
<th class="ta-r">Amount</th>
<th>Raised On</th>
<th>Status</th>
<th class="ta-c">Routes To</th>
<th>Notes</th>
<th>Action</th>
</tr>
</thead>
<tbody>
@for (alert of amlSuspiciousAlerts; track alert.id) {
<tr>
<td>{{ alert.id }}</td>
<td>{{ alert.clientDisplayName }}</td>
<td>{{ alert.transactionType }}</td>
<td>{{ alert.ruleCode }} &mdash; {{ alert.ruleDescription }}</td>
<td class="ta-r mono">&#8377;{{ alert.amount | number: '1.0-0' }}</td>
<td>{{ asDate(alert.raisedOn) | date: 'medium' }}</td>
<td>
<span [class]="amlStatusClass(alert.status)">{{ alert.status }}</span>
</td>
<td class="ta-c">
<span [class]="alertRouteClass(alert.status, false)">{{ alertRouteLabel(alert.status, false) }}</span>
</td>
<td>
@if (alert.status === 'RAISED' || alert.status === 'UNDER_REVIEW') {
<input
type="text"
class="fiu-inp"
placeholder="Review notes"
[(ngModel)]="amlReviewNotes[alert.id]"
/>
} @else {
<span class="muted">{{ alert.reviewNotes }}</span>
}
</td>
<td>
@if (alert.status === 'RAISED' || alert.status === 'UNDER_REVIEW') {
<div class="fiu-action-group" *mifosxHasPermission="'REVIEW_AML_ALERT'">
<button class="fiu-btn fiu-btn-g" (click)="whitelistAlert(alert)">Whitelist</button>
<button class="fiu-btn fiu-btn-xml" (click)="escalateAlert(alert)">Escalate</button>
</div>
}
</td>
</tr>
} @empty {
<tr>
<td colspan="10" class="fiu-empty">No suspicious behavioral alerts awaiting triage.</td>
</tr>
}
</tbody>
</table>
</div>
<p class="fiu-tbl-legend">
<span class="report-pill report-pending">Pending</span> awaiting Whitelist/Escalate decision &middot;
<span class="report-pill report-str">→ STR</span> escalated to Principal Officer as an STR case
</p>
<hr style="border: 0; border-top: 1px solid var(--f-g200); margin: 8px 0" />
<!-- Cleared register: whitelisted alerts, kept out of the STR queue but retained with the
evidence recorded at review time. -->
@@ -590,7 +520,7 @@
</tr>
</thead>
<tbody>
@for (sc of amlStrCases; track sc.id) {
@for (sc of liveStrCases; track sc.id) {
<tr [class.selected-row]="selectedStrCase?.id === sc.id">
<td>{{ sc.id }}</td>
<td>{{ sc.clientDisplayName }}</td>
@@ -607,13 +537,50 @@
</tr>
} @empty {
<tr>
<td colspan="8" class="fiu-empty">No STR cases yet.</td>
<td colspan="8" class="fiu-empty">No STR cases outstanding.</td>
</tr>
}
</tbody>
</table>
</div>
<!-- Filed cases are finished work, not outstanding work, so they sit apart from the live queue — but they are
the bank's record of what went to FIU-IND and against which reference, so they stay visible. -->
@if (filedStrCases.length) {
<div class="fiu-form-meta" style="margin-top: 18px">
<div class="fiu-form-title">
<h3>Filed with FIU-IND</h3>
<p>Reported and closed — kept for the record, with the reference the filing was made under.</p>
</div>
</div>
<div class="fiu-tbl-wrap">
<table class="fiu-tbl">
<thead>
<tr>
<th>ID</th>
<th>Client</th>
<th>Conclusion</th>
<th>Filed On</th>
<th>FIU Reference</th>
<th>Action</th>
</tr>
</thead>
<tbody>
@for (sc of filedStrCases; track sc.id) {
<tr>
<td>{{ sc.id }}</td>
<td>{{ sc.clientDisplayName }}</td>
<td>{{ sc.conclusion || '—' }}</td>
<td>{{ asDate(sc.strFiledOn) | date: 'dd MMM yyyy' }}</td>
<td class="mono">{{ sc.strReferenceNo || '—' }}</td>
<td><button class="fiu-btn fiu-btn-g" (click)="selectStrCase(sc)">View</button></td>
</tr>
}
</tbody>
</table>
</div>
}
@if (selectedStrCase) {
<div class="str-case-detail">
<div class="fiu-form-meta">
+35 -58
View File
@@ -220,13 +220,29 @@ export class FiuComponent implements OnInit {
*/
sanctionsStrCases: any[] = [];
sanctionsStrFilter = '';
amlAlertStatusFilter = '';
amlReviewNotes: Record<number, string> = {};
amlCtrReports: any[] = [];
ctrGenerateMonth = '';
amlStrCases: any[] = [];
/**
* The cases still carrying work, which is what "Live" means here.
*
* <p>
* A case that has been filed with FIU-IND is finished: the report is gone, the reference number is on it, and there
* is nothing left for the Principal Officer to do. Leaving it in the live queue is how a working list stops being
* one — every filed case stays on it for good, and an officer opening the tab has to tell the outstanding work from
* the closed by reading a status column. Filed cases are listed below, where they can still be looked up.
*/
get liveStrCases(): any[] {
return this.amlStrCases.filter((c) => c?.status !== 'FILED');
}
/** Cases already filed with FIU-IND, kept for the record rather than as work. */
get filedStrCases(): any[] {
return this.amlStrCases.filter((c) => c?.status === 'FILED');
}
selectedStrCase: any = null;
concludeConclusion: 'NOT_SUSPICIOUS' | 'SUSPICIOUS' = 'NOT_SUSPICIOUS';
concludeInvestigationNotes = '';
@@ -633,10 +649,9 @@ export class FiuComponent implements OnInit {
}
/**
* Always fetches every status. The triage table and the cleared register are two views of the
* same set, so filtering server-side would empty one whenever the other was narrowed — and the
* CTR tab reads this list too, which a status filter chosen on the STR tab should not touch.
* amlAlertStatusFilter is applied client-side in {@link amlSuspiciousAlerts} instead.
* Always fetches every status. This tab shows what was cleared and what became an STR case, which are two views of
* the same set, and the CTR tab reads the list as well — so narrowing it server-side for one view would empty
* another.
*/
loadAmlAlerts(): void {
this.amlService.getAlerts().subscribe((data) => (this.amlAlerts = data || []));
@@ -679,7 +694,8 @@ export class FiuComponent implements OnInit {
this.ctrLinesLoading = false;
this.ctrTxns = [];
this.ctrLinesError =
err?.error?.defaultUserMessage || 'Could not load the generated CTR lines. Generate a report for the month first.';
err?.error?.defaultUserMessage ||
'Could not load the generated CTR lines. Generate a report for the month first.';
}
});
}
@@ -779,48 +795,6 @@ export class FiuComponent implements OnInit {
return (err: any) => this.toast(err?.error?.defaultUserMessage || `${action} failed — please retry`);
}
/**
* Clearing or escalating an alert has to carry a reason: review_notes is the only audit record of
* why a flagged transaction was disposed of, and an empty string leaves no trace of the decision.
*/
private reviewNotesOf(alert: any): string | null {
const notes = (this.amlReviewNotes[alert.id] || '').trim();
if (!notes) {
this.toast(`Enter review notes before disposing of alert #${alert.id}`);
return null;
}
return notes;
}
whitelistAlert(alert: any): void {
const notes = this.reviewNotesOf(alert);
if (!notes) {
return;
}
this.amlService.reviewAlert(alert.id, 'WHITELIST', notes).subscribe({
next: () => {
this.toast(`Alert #${alert.id} whitelisted — no STR raised`);
this.loadAmlAlerts();
},
error: this.apiError(`Whitelisting alert #${alert.id}`)
});
}
escalateAlert(alert: any): void {
const notes = this.reviewNotesOf(alert);
if (!notes) {
return;
}
this.amlService.reviewAlert(alert.id, 'ESCALATE', notes).subscribe({
next: () => {
this.toast(`Alert #${alert.id} escalated — now an STR case for the Principal Officer`);
this.loadAmlAlerts();
this.loadAmlStrCases();
},
error: this.apiError(`Escalating alert #${alert.id}`)
});
}
generateCtrReport(): void {
if (!this.ctrGenerateMonth) {
this.toast('Pick a reporting month first');
@@ -1008,9 +982,16 @@ export class FiuComponent implements OnInit {
get liveStrFiled(): number {
return this.amlStrCases.filter((c) => c.status === 'FILED').length;
}
/** Still needs PO action: untouched, or concluded suspicious but not yet filed with FIU-IND. */
/**
* Still needs PO action: anything neither filed with FIU-IND nor concluded clear.
*
* Counted by what is unfinished rather than by naming the statuses that are. Adding up OPEN and CONCLUDED_SUSPICIOUS
* gives the same figure today, but it is the shape that fails quietly: a case in any status those two do not name
* drops out of the KPI while still sitting in the queue below it, and the dashboard then under-reports the work.
* That is exactly how five undecided alerts hid behind a pending count of 1 on the flagged-transactions screen.
*/
get liveStrPending(): number {
return this.liveStrOpen + this.liveStrAwaitingFiling;
return this.amlStrCases.filter((c) => c?.status !== 'FILED' && c?.status !== 'CONCLUDED_CLEAR').length;
}
get totalCustomers(): number {
return this.kc1.length + this.kc2.length;
@@ -1039,12 +1020,6 @@ export class FiuComponent implements OnInit {
* The STR triage queue: still to decide, or already escalated. Whitelisted alerts are closed
* with no report, so they are not STR business — they live in {@link amlWhitelistedAlerts}.
*/
get amlSuspiciousAlerts(): any[] {
return this.behaviouralAlerts.filter(
(a) => a.status !== 'WHITELISTED' && (!this.amlAlertStatusFilter || a.status === this.amlAlertStatusFilter)
);
}
/** Cleared register: alerts closed as false positives, kept for audit with their evidence. */
get amlWhitelistedAlerts(): any[] {
return this.behaviouralAlerts.filter((a) => a.status === 'WHITELISTED');
@@ -1707,7 +1682,9 @@ export class FiuComponent implements OnInit {
private writeLiveStrXml(cases: any[], filename: string): void {
const xml: string[] = [];
xml.push('<?xml version="1.0" encoding="UTF-8"?>');
xml.push(`<STR xmlns="http://fiuind.gov.in/finnet2/str" reportingMonth="${this.escapeXml(this.reportingMonthLabel)}">`);
xml.push(
`<STR xmlns="http://fiuind.gov.in/finnet2/str" reportingMonth="${this.escapeXml(this.reportingMonthLabel)}">`
);
xml.push(` <REBank name="${this.escapeXml(this.bank.name)}" />`);
if (this.principalOfficerName) {
xml.push(` <PrincipalOfficer name="${this.escapeXml(this.principalOfficerName)}" />`);
@@ -0,0 +1,830 @@
<!--
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="container">
<div class="page-header">
<h2>
{{ (mode === 'create' ? 'New Locker Allotment' : 'Locker Application') | translate }}
<span class="tag"
>{{ 'Application No' | translate }}: {{ allotment?.applicationNo ?? ('Auto' | translate) }}</span
>
@if (allotment) {
<span
class="pill status-pill"
[ngClass]="
allotment.status === 'ACTIVE'
? 'ok'
: allotment.status === 'PENDING_APPROVAL'
? 'warn'
: allotment.status === 'REJECTED'
? 'danger'
: 'neutral'
"
>{{ statusLabel(allotment.status) | translate }}</span
>
}
</h2>
<div class="header-actions">
<button mat-raised-button [routerLink]="['/locker', 'allotment']">{{ 'labels.buttons.Back' | translate }}</button>
</div>
</div>
@if (loadFailed) {
<mat-card class="notice error">
<mat-card-content>{{ 'Could not load this locker application.' }}</mat-card-content>
</mat-card>
} @else {
@if (allotment?.status === 'REJECTED' && allotment?.rejectionReason) {
<mat-card class="notice error">
<mat-card-content>
<strong>{{ 'Rejected' | translate }}:</strong> {{ allotment.rejectionReason }}
</mat-card-content>
</mat-card>
}
<!-- Approver bar: only while pending -->
@if (isPending) {
<mat-card class="notice approve-bar">
<mat-card-content>
<span class="approve-text">
<fa-icon icon="hourglass-half"></fa-icon>&nbsp;{{
'This application is waiting for Branch Manager approval. The locker is reserved until then.'
}}
</span>
<span class="approve-actions">
@if (!rejecting) {
<button
mat-raised-button
color="primary"
[disabled]="busy"
(click)="approve()"
*mifosxHasPermission="'APPROVE_LOCKERALLOTMENT'"
>
<fa-icon icon="check"></fa-icon>&nbsp;{{ 'Approve' | translate }}
</button>
<button
mat-raised-button
color="warn"
[disabled]="busy"
(click)="rejecting = true"
*mifosxHasPermission="'REJECT_LOCKERALLOTMENT'"
>
<fa-icon icon="ban"></fa-icon>&nbsp;{{ 'Reject' | translate }}
</button>
}
</span>
@if (rejecting) {
<div class="reject-box">
<mat-form-field appearance="fill" class="full-width">
<mat-label>{{ 'Reason for rejection' | translate }}</mat-label>
<textarea
matInput
rows="2"
[(ngModel)]="rejectionReason"
[ngModelOptions]="{ standalone: true }"
></textarea>
</mat-form-field>
<div class="form-actions">
<button mat-raised-button type="button" (click)="rejecting = false">
{{ 'labels.buttons.Cancel' | translate }}
</button>
<button
mat-raised-button
color="warn"
type="button"
[disabled]="busy || !rejectionReason.trim()"
(click)="confirmReject()"
>
{{ 'Confirm Reject' | translate }}
</button>
</div>
</div>
}
</mat-card-content>
</mat-card>
}
<mat-card>
<mat-card-content>
<mat-tab-group [(selectedIndex)]="selectedTab" animationDuration="0ms">
<!-- ===================== TAB 1: APPLICATION MASTER ===================== -->
<!-- Laid out like the legacy screen: left = application + address information, right = locker rent,
reference account, contact and KYC. Fields filled from the client are read-only. -->
<mat-tab>
<ng-template mat-tab-label>
<span [class.tab-error]="tab1Errors.length && mainForm.touched">{{
'Application Master Entry' | translate
}}</span>
</ng-template>
<div class="tab-body legacy-layout" [formGroup]="mainForm">
<!-- ================= LEFT ================= -->
<div class="col">
<div class="panel">
<div class="panel-title">{{ 'Application' | translate }}</div>
<div class="grid-2">
<mat-form-field appearance="fill">
<mat-label>{{ 'Branch Code' | translate }}</mat-label>
<input matInput [value]="allotment?.officeName ?? ownOfficeName" readonly />
</mat-form-field>
<mat-form-field appearance="fill">
<mat-label>{{ 'Application No' | translate }}</mat-label>
<input matInput [value]="allotment?.applicationNo ?? ('Auto' | translate)" readonly />
</mat-form-field>
<mat-form-field appearance="fill">
<mat-label>{{ 'Client Id' | translate }}</mat-label>
<input matInput [value]="clientId ?? ''" readonly required />
<mat-hint>{{ 'Filled when the name is picked' | translate }}</mat-hint>
</mat-form-field>
<mat-form-field appearance="fill">
<mat-label>{{ 'Transaction Date' | translate }}</mat-label>
<input matInput [value]="allotment?.transactionDate ?? transactionDate | dateFormat" readonly />
</mat-form-field>
<mat-form-field appearance="fill">
<mat-label>{{ 'Locker No' | translate }}</mat-label>
<mat-select formControlName="lockerId" required>
@for (l of lockers; track l.id) {
<mat-option [value]="l.id">{{ l.classCode }}-{{ l.lockerNo }}</mat-option>
}
</mat-select>
@if (!mainForm.get('classCode')?.value) {
<mat-hint>{{ 'Choose Class Type first' | translate }}</mat-hint>
} @else if (lockers.length === 0) {
<mat-hint class="hint-warn">{{ 'No vacant locker in this class' | translate }}</mat-hint>
} @else {
<mat-hint>{{ 'Only vacant lockers' | translate }}</mat-hint>
}
</mat-form-field>
<mat-form-field appearance="fill">
<mat-label>{{ 'Key No' | translate }}</mat-label>
<input matInput formControlName="keyNo" maxlength="20" required />
</mat-form-field>
</div>
<mifosx-locker-client-picker
label="Name"
[required]="true"
[disabled]="mode !== 'create'"
[clientId]="clientId"
[clientName]="clientName"
(picked)="onClientPicked($event)"
></mifosx-locker-client-picker>
<div class="grid-2">
<mat-form-field appearance="fill">
<mat-label>{{ 'Freeze' | translate }}</mat-label>
<mat-select formControlName="freeze">
<mat-option [value]="false">{{ 'Un Freeze' | translate }}</mat-option>
<mat-option [value]="true">{{ 'Freeze' | translate }}</mat-option>
</mat-select>
</mat-form-field>
<mat-form-field appearance="fill">
<mat-label>{{ 'Risk' | translate }}</mat-label>
<mat-select formControlName="risk">
<mat-option value="">-</mat-option>
@for (r of risks; track r) {
<mat-option [value]="r">{{ r }}</mat-option>
}
</mat-select>
</mat-form-field>
<mat-form-field appearance="fill">
<mat-label>{{ 'Freeze Remark' | translate }}</mat-label>
<textarea matInput rows="1" formControlName="freezeRemark"></textarea>
</mat-form-field>
<mat-form-field appearance="fill">
<mat-label>{{ 'Remarks' | translate }}</mat-label>
<input matInput formControlName="remarks" />
</mat-form-field>
<mat-form-field appearance="fill">
<mat-label>{{ 'Op. Date' }}</mat-label>
<!-- As on the legacy screen: the transaction date until approval sets the real opening date. -->
<input
matInput
[value]="allotment?.openingDate ?? allotment?.transactionDate ?? transactionDate | dateFormat"
readonly
/>
</mat-form-field>
<mat-form-field appearance="fill">
<mat-label>{{ 'Close Date' | translate }}</mat-label>
<input
matInput
[value]="allotment?.closeDate ? (allotment.closeDate | dateFormat) : ''"
readonly
/>
</mat-form-field>
</div>
</div>
<div class="panel">
<div class="panel-title">{{ 'Address Information' | translate }}</div>
<mat-form-field appearance="fill" class="full-width">
<mat-label>{{ 'Address' | translate }}</mat-label>
<input matInput [value]="clientDetails?.addressLine1 ?? ''" readonly required />
</mat-form-field>
<mat-form-field appearance="fill" class="full-width">
<mat-label>{{ 'Address Line 2' | translate }}</mat-label>
<input matInput [value]="clientDetails?.addressLine2 ?? ''" readonly />
</mat-form-field>
<mat-form-field appearance="fill" class="full-width">
<mat-label>{{ 'Address Line 3' | translate }}</mat-label>
<input matInput [value]="clientDetails?.addressLine3 ?? ''" readonly />
</mat-form-field>
<div class="grid-2">
<mat-form-field appearance="fill">
<mat-label>{{ 'Pay Mode' | translate }}</mat-label>
<mat-select formControlName="payMode" required>
@for (p of payModes; track p.value) {
<mat-option [value]="p.value">{{ p.label | translate }}</mat-option>
}
</mat-select>
@if (needsJointHolder) {
<mat-hint>{{ 'Add joint holders on the Joint Member tab' | translate }}</mat-hint>
}
</mat-form-field>
<mat-form-field appearance="fill">
<mat-label>{{ 'Email Active' | translate }}</mat-label>
<mat-select formControlName="emailActive">
<mat-option [value]="false">{{ 'No' | translate }}</mat-option>
<mat-option [value]="true">{{ 'Yes' | translate }}</mat-option>
</mat-select>
</mat-form-field>
<mat-form-field appearance="fill">
<mat-label>{{ 'Occupation' | translate }}</mat-label>
<input matInput [value]="clientDetails?.occupation ?? ''" readonly />
</mat-form-field>
<mat-form-field appearance="fill">
<mat-label>{{ 'SMS Active' | translate }}</mat-label>
<mat-select formControlName="smsActive">
<mat-option [value]="false">{{ 'No' | translate }}</mat-option>
<mat-option [value]="true">{{ 'Yes' | translate }}</mat-option>
</mat-select>
</mat-form-field>
@if (mode !== 'view') {
<mat-form-field appearance="fill">
<mat-label>{{ 'Password' | translate }}</mat-label>
<input
matInput
type="password"
formControlName="password"
autocomplete="new-password"
[required]="mode === 'create'"
/>
@if (mode === 'edit') {
<mat-hint>{{ 'Blank keeps the current password' | translate }}</mat-hint>
}
</mat-form-field>
} @else {
<mat-form-field appearance="fill">
<mat-label>{{ 'Password' | translate }}</mat-label>
<input matInput value="••••••" readonly />
</mat-form-field>
}
<mat-form-field appearance="fill">
<mat-label>{{ 'Gender' | translate }}</mat-label>
<input matInput [value]="clientDetails?.gender ?? ''" readonly />
</mat-form-field>
@if (mode !== 'view') {
<mat-form-field appearance="fill">
<mat-label>{{ 'Confirm Password' | translate }}</mat-label>
<input matInput type="password" formControlName="confirmPassword" autocomplete="new-password" />
@if (mainForm.hasError('passwordMismatch')) {
<mat-hint class="hint-warn">{{ 'Passwords do not match' | translate }}</mat-hint>
}
</mat-form-field>
}
<mat-form-field appearance="fill">
<mat-label>{{ 'Dual Auth Mode' | translate }}</mat-label>
<mat-select formControlName="dualAuth">
<mat-option [value]="false">{{ 'No' | translate }}</mat-option>
<mat-option [value]="true">{{ 'Yes' | translate }}</mat-option>
</mat-select>
</mat-form-field>
<mat-form-field appearance="fill">
<mat-label>{{ 'Inquiry Sr No' | translate }}</mat-label>
<mat-select formControlName="waitingListId" (selectionChange)="onWaitingEntryChange()">
<mat-option value="">{{ 'None' | translate }}</mat-option>
@for (w of waitingEntries; track w.id) {
<mat-option [value]="w.id">
{{ w.id }} · {{ w.customerName }} · {{ w.preferredClasses?.join(', ') }}
@if (w.clientId && w.clientId === clientId) {
· ★
}
</mat-option>
}
@if (allotment?.waitingListId && !selectedWaitingEntry) {
<mat-option [value]="allotment.waitingListId">{{ allotment.waitingListId }}</mat-option>
}
</mat-select>
<mat-hint>{{ 'Waiting List entry, if any' | translate }}</mat-hint>
</mat-form-field>
<mat-form-field appearance="fill">
<mat-label>{{ 'Inquiry Date' | translate }}</mat-label>
<input
matInput
[value]="selectedWaitingEntry ? (selectedWaitingEntry.inquiryDate | dateFormat) : ''"
readonly
/>
</mat-form-field>
<mat-form-field appearance="fill" class="span-2">
<mat-label>{{ 'Inquirer Name' | translate }}</mat-label>
<input matInput [value]="selectedWaitingEntry?.customerName ?? ''" readonly />
</mat-form-field>
</div>
</div>
</div>
<!-- ================= RIGHT ================= -->
<div class="col">
<div class="panel">
<div class="panel-title">{{ 'Locker Rent' | translate }}</div>
<div class="grid-2">
<mat-form-field appearance="fill">
<mat-label>{{ 'Class Type' | translate }}</mat-label>
<mat-select formControlName="classCode" (selectionChange)="onClassChange()" required>
@for (code of classCodes; track code) {
<mat-option [value]="code">{{ code }}</mat-option>
}
</mat-select>
</mat-form-field>
<mat-form-field appearance="fill">
<mat-label>{{ 'Category' | translate }}</mat-label>
<mat-select formControlName="category" required>
@for (c of categories; track c.value) {
<mat-option [value]="c.value">{{ c.label | translate }}</mat-option>
}
</mat-select>
@if (clientDetails) {
<mat-hint>{{
(clientDetails.member ? 'Client is a member' : 'Client is not a member') | translate
}}</mat-hint>
}
</mat-form-field>
<mat-form-field appearance="fill">
<mat-label>{{ 'Class Rent' | translate }}</mat-label>
<input matInput class="num" [value]="classRent | number: '1.2-2'" readonly />
</mat-form-field>
<mat-form-field appearance="fill">
<mat-label>{{ 'Rent With GST' | translate }}</mat-label>
<input matInput class="num" [value]="rentWithGst | number: '1.2-2'" readonly />
<mat-hint>{{ 'GST 18%' | translate }}: {{ gstAmount | number: '1.2-2' }}</mat-hint>
</mat-form-field>
<mat-form-field appearance="fill">
<mat-label>{{ 'Total Rent' | translate }}</mat-label>
<input matInput class="num" [value]="totalPayable | number: '1.2-2'" readonly />
<mat-hint>{{ 'Rent with GST + Deposit' | translate }}</mat-hint>
</mat-form-field>
<mat-form-field appearance="fill">
<mat-label>{{ 'Depo Amt.' }}</mat-label>
<input matInput class="num" [value]="requiredDeposit | number: '1.2-2'" readonly />
</mat-form-field>
</div>
</div>
<div class="panel">
<div class="panel-title">{{ 'Reference Account Details' | translate }}</div>
<div class="grid-2">
<mat-form-field appearance="fill">
<mat-label>{{ 'Branch' | translate }}</mat-label>
<input matInput [value]="allotment?.officeName ?? ownOfficeName" readonly />
</mat-form-field>
<mat-form-field appearance="fill">
<mat-label>{{ 'A/c Type' | translate }}</mat-label>
<mat-select
[value]="rentAccountType"
(selectionChange)="onRentAccountTypeChange($event.value)"
[disabled]="mode === 'view' || mainForm.get('rentExemption')?.value"
>
@for (t of rentAccountTypes; track t) {
<mat-option [value]="t">{{ t }}</mat-option>
}
</mat-select>
</mat-form-field>
<mat-form-field appearance="fill" class="span-2">
<mat-label>{{ 'A/c No.' }}</mat-label>
<mat-select formControlName="rentAccountId" [required]="!mainForm.get('rentExemption')?.value">
@for (a of rentAccountsOfType; track a.id) {
<mat-option [value]="a.id"
>{{ a.accountNo }} · {{ 'Bal' | translate }} {{ a.balance | number: '1.2-2' }}</mat-option
>
}
</mat-select>
@if (mainForm.get('rentExemption')?.value) {
<mat-hint>{{ 'Not needed — rent is exempted' | translate }}</mat-hint>
} @else if (clientDetails && !clientDetails.savingsAccounts?.length) {
<mat-hint class="hint-warn">{{ 'Client has no active savings account' | translate }}</mat-hint>
} @else {
<mat-hint>{{ 'Locker rent is debited from this account' | translate }}</mat-hint>
}
</mat-form-field>
</div>
</div>
<div class="panel">
<div class="panel-title">{{ 'Contact and KYC' | translate }}</div>
<div class="grid-2">
<mat-form-field appearance="fill">
<mat-label>{{ 'City' | translate }}</mat-label>
<input matInput [value]="clientDetails?.city ?? ''" readonly />
</mat-form-field>
<mat-form-field appearance="fill">
<mat-label>{{ 'Phone-1' | translate }}</mat-label>
<input matInput [value]="clientDetails?.mobileNo ?? ''" readonly />
</mat-form-field>
<mat-form-field appearance="fill">
<mat-label>{{ 'Phone-2' | translate }}</mat-label>
<input matInput formControlName="phone2" maxlength="15" />
@if (mainForm.get('phone2')?.hasError('pattern')) {
<mat-error>{{ '10 to 15 digits' | translate }}</mat-error>
}
</mat-form-field>
<mat-form-field appearance="fill">
<mat-label>{{ 'Pincode' | translate }}</mat-label>
<input matInput [value]="clientDetails?.pincode ?? ''" readonly />
</mat-form-field>
<mat-form-field appearance="fill">
<mat-label>{{ 'Rent Up To Date' | translate }}</mat-label>
<input
matInput
[value]="allotment?.rentUpToDate ? (allotment.rentUpToDate | dateFormat) : ''"
readonly
/>
<mat-hint>{{ 'Updated by Rent Entry' | translate }}</mat-hint>
</mat-form-field>
<mat-form-field appearance="fill">
<mat-label>{{ 'Exemption Of Rent' | translate }}</mat-label>
<mat-select formControlName="rentExemption">
<mat-option [value]="false">{{ 'NO' | translate }}</mat-option>
<mat-option [value]="true">{{ 'YES' | translate }}</mat-option>
</mat-select>
</mat-form-field>
<mat-form-field appearance="fill">
<mat-label>{{ 'P.A.N. No.' }}</mat-label>
<input matInput [value]="clientDetails?.panNumber ?? ''" readonly />
</mat-form-field>
<mat-form-field appearance="fill">
<mat-label>{{ 'Aadhaar No.' }}</mat-label>
<input matInput [value]="clientDetails?.aadhaarMasked ?? ''" readonly />
</mat-form-field>
</div>
</div>
<div class="panel">
<div class="panel-title">{{ 'Documents' | translate }}</div>
<div class="grid-2">
<mat-form-field appearance="fill">
<mat-label>{{ 'Photo Received' | translate }}</mat-label>
<mat-select formControlName="photoReceived">
<mat-option [value]="false">{{ 'No' | translate }}</mat-option>
<mat-option [value]="true">{{ 'Yes' | translate }}</mat-option>
</mat-select>
</mat-form-field>
<mat-form-field appearance="fill">
<mat-label>{{ 'Address Proof Received' | translate }}</mat-label>
<mat-select formControlName="addressProofReceived">
<mat-option [value]="false">{{ 'No' | translate }}</mat-option>
<mat-option [value]="true">{{ 'Yes' | translate }}</mat-option>
</mat-select>
</mat-form-field>
</div>
</div>
@if (clientId && clientDetailsFailed) {
<div class="form-error">{{ 'Could not load the client details.' }}</div>
}
</div>
</div>
</mat-tab>
<!-- ===================== TAB 2: JOINT MEMBERS ===================== -->
<mat-tab>
<ng-template mat-tab-label>
<span [class.tab-error]="tab2Errors.length && mainForm.touched">
{{ 'Joint Member Detail' | translate }}
@if (jointHolders.length) {
({{ jointHolders.length }})
}
</span>
</ng-template>
<div class="tab-body">
<div class="form-note">
{{ 'Pay Mode' | translate }}:
<strong>{{ labelOf(payModes, mainForm.get('payMode')?.value) | translate }}</strong>
—
{{
(needsJointHolder
? 'at least one joint holder is required'
: 'joint holders are optional; mandate holders can still be added'
) | translate
}}
</div>
@for (j of jointHolders; track $index; let i = $index) {
<div class="row-card">
<span class="row-no">{{ i + 1 }}</span>
<div class="row-grid">
<mifosx-locker-client-picker
label="Client Name"
[required]="true"
[disabled]="mode === 'view'"
[clientId]="j.clientId"
[clientName]="j.clientName"
[excludeIds]="takenIds(i, jointHolders)"
(picked)="onJointPicked(i, $event)"
></mifosx-locker-client-picker>
<mat-form-field appearance="fill">
<mat-label>{{ 'Join Category' | translate }}</mat-label>
<mat-select
[(ngModel)]="j.joinCategory"
[ngModelOptions]="{ standalone: true }"
[disabled]="mode === 'view'"
>
@for (c of joinCategories; track c.value) {
<mat-option [value]="c.value">{{ c.label | translate }}</mat-option>
}
</mat-select>
</mat-form-field>
</div>
@if (mode !== 'view') {
<button
mat-icon-button
color="warn"
type="button"
[matTooltip]="'Remove' | translate"
(click)="removeJointHolder(i)"
>
<fa-icon icon="trash"></fa-icon>
</button>
}
</div>
}
@if (jointHolders.length === 0) {
<div class="empty-state">{{ 'No joint holders' | translate }}</div>
}
@if (mode !== 'view') {
<button mat-stroked-button color="primary" type="button" (click)="addJointHolder()">
<fa-icon icon="plus"></fa-icon>&nbsp;{{ 'Add Joint Holder' | translate }}
</button>
}
</div>
</mat-tab>
<!-- ===================== TAB 3: OTHER ENTRIES ===================== -->
<mat-tab>
<ng-template mat-tab-label>
<span [class.tab-error]="tab3Errors.length && mainForm.touched">{{ 'Other Entry(s)' | translate }}</span>
</ng-template>
<div class="tab-body">
<div class="section-label">{{ 'Security Deposit (Fixed Deposit)' | translate }}</div>
@if (!clientId) {
<div class="form-note">{{ 'Choose the client on the first tab to see their fixed deposits.' }}</div>
} @else if ((clientDetails?.fixedDeposits ?? []).length === 0) {
<div class="form-note">{{ 'Client has no active fixed deposit.' }}</div>
} @else {
<table class="simple-table">
<thead>
<tr>
<th></th>
<th>{{ 'A/c No' | translate }}</th>
<th>{{ 'Product' | translate }}</th>
<th class="num">{{ 'Depo. Amt' }}</th>
<th>{{ 'Depo Date' | translate }}</th>
<th>{{ 'Matu. Date' }}</th>
</tr>
</thead>
<tbody>
@for (f of clientDetails.fixedDeposits; track f.id) {
<tr>
<td>
<mat-checkbox
[checked]="selectedFdIds.has(f.id)"
[disabled]="mode === 'view' || (f.onHold && !selectedFdIds.has(f.id))"
(change)="toggleFd(f.id, $event.checked)"
></mat-checkbox>
</td>
<td>
{{ f.accountNo }}
@if (f.onHold && !selectedFdIds.has(f.id)) {
<span class="pill neutral">{{ 'Already under lien' | translate }}</span>
}
</td>
<td>{{ f.productName }}</td>
<td class="num">{{ f.depositAmount | number: '1.2-2' }}</td>
<td>{{ f.depositDate | dateFormat }}</td>
<td>{{ f.maturityDate | dateFormat }}</td>
</tr>
}
</tbody>
</table>
}
<div class="rate-strip">
<div>
<span class="k">{{ 'Deposit required' | translate }}</span
>{{ requiredDeposit | number: '1.2-2' }}
</div>
<div>
<span class="k">{{ 'FD selected' | translate }}</span
>{{ selectedFdTotal | number: '1.2-2' }}
</div>
@if (depositShortfall > 0) {
<div class="warn-text">
<fa-icon icon="exclamation-triangle"></fa-icon>&nbsp;{{ 'Short by' | translate }}
{{ depositShortfall | number: '1.2-2' }}
</div>
}
</div>
<div class="form-note">
{{ 'A lien is placed on the selected deposits when the application is approved.' }}
</div>
<div class="section-label">{{ 'Share / Membership' | translate }}</div>
@if (clientDetails) {
<div class="client-panel">
<div>
<span class="k">{{ 'Member' | translate }}</span
>{{ (clientDetails.member ? 'Yes' : 'No') | translate }}
</div>
<div>
<span class="k">{{ 'Membership No' | translate }}</span
>{{ clientDetails.membershipNo || '-' }}
</div>
<div>
<span class="k">{{ 'No Of Shares' | translate }}</span
>{{ clientDetails.totalShares ?? 0 }}
</div>
</div>
} @else {
<div class="form-note">{{ "Shown from the client's share account." }}</div>
}
<div class="section-label">{{ 'Guarantor' | translate }}</div>
@for (g of guarantors; track $index; let i = $index) {
<div class="row-card">
<span class="row-no">{{ i + 1 }}</span>
<div class="row-grid single">
<mifosx-locker-client-picker
label="Guarantor Name"
[required]="true"
[disabled]="mode === 'view'"
[clientId]="g.clientId"
[clientName]="g.clientName"
[excludeIds]="takenIds(i, guarantors)"
(picked)="onGuarantorPicked(i, $event)"
></mifosx-locker-client-picker>
</div>
@if (mode !== 'view') {
<button
mat-icon-button
color="warn"
type="button"
[matTooltip]="'Remove' | translate"
(click)="removeGuarantor(i)"
>
<fa-icon icon="trash"></fa-icon>
</button>
}
</div>
}
@if (guarantors.length === 0) {
<div class="empty-state">{{ 'No guarantors' | translate }}</div>
}
@if (mode !== 'view') {
<button mat-stroked-button color="primary" type="button" (click)="addGuarantor()">
<fa-icon icon="plus"></fa-icon>&nbsp;{{ 'Add Guarantor' | translate }}
</button>
}
</div>
</mat-tab>
<!-- ===================== TAB 4: NOMINEE ===================== -->
<mat-tab>
<ng-template mat-tab-label>
<span [class.tab-error]="tab4Errors.length && mainForm.touched">
{{ "Nominee's Detail" | translate }}
@if (nominees.length) {
({{ nominees.length }})
}
</span>
</ng-template>
<div class="tab-body" [formGroup]="nomineeForm">
<mat-checkbox formControlName="hasNominee" (change)="onHasNomineeChange()">{{
'Nominee' | translate
}}</mat-checkbox>
<div formArrayName="nominees">
@for (n of nominees.controls; track $index; let i = $index) {
<div class="row-card" [formGroupName]="i">
<span class="row-no">{{ i + 1 }}</span>
<div class="row-grid nominee">
<mat-form-field appearance="fill">
<mat-label>{{ 'Nom. Date' }}</mat-label>
<input matInput [matDatepicker]="nomPicker" formControlName="nominationDate" required />
<mat-datepicker-toggle matSuffix [for]="nomPicker"></mat-datepicker-toggle>
<mat-datepicker #nomPicker></mat-datepicker>
</mat-form-field>
<mat-form-field appearance="fill">
<mat-label>{{ 'Name' | translate }}</mat-label>
<input matInput formControlName="name" required />
</mat-form-field>
<mat-form-field appearance="fill">
<mat-label>{{ 'Mobile No' | translate }}</mat-label>
<input matInput formControlName="mobileNo" maxlength="15" />
</mat-form-field>
<mat-form-field appearance="fill">
<mat-label>{{ 'Birth Dt.' }}</mat-label>
<input
matInput
[matDatepicker]="dobPicker"
formControlName="dateOfBirth"
[max]="transactionDate"
/>
<mat-datepicker-toggle matSuffix [for]="dobPicker"></mat-datepicker-toggle>
<mat-datepicker #dobPicker></mat-datepicker>
@if (isMinor(i)) {
<mat-hint class="hint-warn">{{ 'Minor' | translate }}</mat-hint>
}
</mat-form-field>
<mat-form-field appearance="fill">
<mat-label>{{ 'Relation' | translate }}</mat-label>
<mat-select formControlName="relation" required>
@for (r of relations; track r) {
<mat-option [value]="r">{{ r }}</mat-option>
}
</mat-select>
</mat-form-field>
<mat-form-field appearance="fill">
<mat-label>{{ 'Gender' | translate }}</mat-label>
<mat-select formControlName="gender" required>
@for (g of genders; track g) {
<mat-option [value]="g">{{ g }}</mat-option>
}
</mat-select>
</mat-form-field>
<mat-form-field appearance="fill">
<mat-label>{{ 'Guar. Name' }}</mat-label>
<input matInput formControlName="guardianName" [required]="isMinor(i)" />
@if (isMinor(i)) {
<mat-hint>{{ 'Required for a minor nominee' | translate }}</mat-hint>
}
</mat-form-field>
</div>
@if (mode !== 'view') {
<button
mat-icon-button
color="warn"
type="button"
[matTooltip]="'Remove' | translate"
(click)="removeNominee(i)"
>
<fa-icon icon="trash"></fa-icon>
</button>
}
</div>
}
</div>
@if (nomineeForm.get('hasNominee')?.value && mode !== 'view') {
<button mat-stroked-button color="primary" type="button" (click)="addNominee()">
<fa-icon icon="plus"></fa-icon>&nbsp;{{ 'Add Nominee' | translate }}
</button>
}
</div>
</mat-tab>
</mat-tab-group>
@if (mode !== 'view') {
@if (allErrors.length && mainForm.touched) {
<div class="error-list">
@for (e of allErrors; track e) {
<div><fa-icon icon="exclamation-circle"></fa-icon>&nbsp;{{ e | translate }}</div>
}
</div>
}
<div class="form-actions">
<button mat-raised-button type="button" [routerLink]="['/locker', 'allotment']">
{{ 'labels.buttons.Cancel' | translate }}
</button>
<button mat-raised-button color="primary" type="button" [disabled]="busy" (click)="submit()">
{{ (mode === 'create' ? 'Submit for Approval' : 'Save Changes') | translate }}
</button>
</div>
}
</mat-card-content>
</mat-card>
}
</div>
@@ -0,0 +1,351 @@
/**
* 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/.
*/
.container {
padding: 16px;
}
.page-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 16px;
h2 {
display: flex;
align-items: center;
flex-wrap: wrap;
gap: 10px;
margin: 0;
}
}
.header-actions {
display: flex;
gap: 12px;
}
.tag {
padding: 2px 8px;
border-radius: 6px;
background: rgb(0 0 0 / 6%);
color: rgb(0 0 0 / 60%);
font-size: 0.8rem;
font-weight: 500;
}
.notice {
margin-bottom: 16px;
&.error {
border-left: 4px solid #c62828;
}
}
.approve-bar mat-card-content {
display: flex;
flex-wrap: wrap;
align-items: center;
justify-content: space-between;
gap: 12px;
}
.approve-text {
color: #b45309;
font-weight: 500;
}
.approve-actions {
display: flex;
gap: 8px;
}
.reject-box {
flex-basis: 100%;
}
.tab-body {
padding: 16px 4px 8px;
}
/* Tab 1 keeps the legacy screen's sections and field order, stacked one below the other at full width. */
.legacy-layout {
display: flex;
flex-direction: column;
gap: 12px;
}
.col {
display: flex;
flex-direction: column;
gap: 12px;
min-width: 0;
}
.panel {
padding: 12px 14px 4px;
border: 1px solid rgb(0 0 0 / 12%);
border-radius: 6px;
background: #fff;
}
.panel-title {
margin: 0 0 10px;
font-size: 0.85rem;
font-weight: 600;
color: #1976d2;
}
/* Fields flow across the full width, as many per row as fit. */
.grid-2 {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(240px, 1fr));
gap: 0 16px;
.span-2 {
grid-column: span 2;
}
}
input.num {
text-align: right;
font-variant-numeric: tabular-nums;
}
.tab-error {
color: #d32f2f;
}
.section-label {
font-size: 0.75rem;
font-weight: 600;
text-transform: uppercase;
letter-spacing: 0.04em;
color: rgb(0 0 0 / 55%);
margin: 16px 0 8px;
&:first-child {
margin-top: 0;
}
}
.form-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(220px, 1fr));
gap: 12px 20px;
margin-bottom: 8px;
}
.span-2 {
grid-column: span 2;
}
mat-form-field,
.full-width {
width: 100%;
}
.info-box {
display: flex;
flex-direction: column;
justify-content: center;
padding: 0 12px;
border-radius: 6px;
background: rgb(25 118 210 / 6%);
min-height: 56px;
}
.info-label {
font-size: 0.75rem;
color: rgb(0 0 0 / 60%);
}
.info-value {
font-size: 0.95rem;
font-weight: 600;
}
.client-panel {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(200px, 1fr));
gap: 8px 20px;
padding: 12px 16px;
margin-bottom: 8px;
border-radius: 6px;
background: #fafafa;
border: 1px solid rgb(0 0 0 / 8%);
font-size: 0.9rem;
}
.k {
display: block;
font-size: 0.72rem;
color: rgb(0 0 0 / 55%);
}
.rate-strip {
display: flex;
flex-wrap: wrap;
gap: 12px 32px;
padding: 10px 16px;
margin: 4px 0 8px;
border-radius: 6px;
background: rgb(25 118 210 / 6%);
font-variant-numeric: tabular-nums;
}
.sub {
font-size: 0.75rem;
color: rgb(0 0 0 / 55%);
}
.warn-text {
color: #b45309;
font-weight: 600;
align-self: center;
}
.check-row {
display: flex;
flex-wrap: wrap;
gap: 4px 24px;
margin-bottom: 8px;
}
.check-cell {
display: flex;
align-items: center;
}
.row-card {
display: flex;
align-items: flex-start;
gap: 12px;
padding: 8px 8px 0;
margin-bottom: 8px;
border: 1px solid rgb(0 0 0 / 10%);
border-radius: 6px;
}
.row-no {
min-width: 24px;
padding-top: 18px;
font-weight: 600;
color: rgb(0 0 0 / 55%);
}
.row-grid {
flex: 1;
display: grid;
grid-template-columns: 2fr 1fr;
gap: 0 16px;
&.single {
grid-template-columns: 1fr;
}
&.nominee {
grid-template-columns: repeat(auto-fill, minmax(180px, 1fr));
}
}
.simple-table {
width: 100%;
border-collapse: collapse;
margin-bottom: 8px;
th,
td {
padding: 6px 8px;
border-bottom: 1px solid rgb(0 0 0 / 8%);
text-align: left;
}
th {
font-size: 0.8rem;
color: rgb(0 0 0 / 60%);
}
.num {
text-align: right;
font-variant-numeric: tabular-nums;
}
}
.form-note {
color: rgb(0 0 0 / 60%);
font-size: 0.85rem;
margin: 0 0 8px;
}
.form-error {
color: #d32f2f;
font-size: 0.85rem;
margin: 0 0 8px;
}
.hint-warn {
color: #b45309;
}
.error-list {
margin-top: 12px;
padding: 10px 14px;
border-radius: 6px;
background: #fdecea;
color: #c62828;
font-size: 0.85rem;
div + div {
margin-top: 4px;
}
}
.form-actions {
display: flex;
justify-content: flex-end;
gap: 12px;
margin-top: 16px;
}
.empty-state {
padding: 16px;
text-align: center;
color: rgb(0 0 0 / 50%);
}
.pill {
font-size: 11px;
padding: 2px 8px;
border-radius: 6px;
font-weight: 600;
&.ok {
background: #dff5e8;
color: #28c07a;
}
&.warn {
background: #fdf1dc;
color: #b45309;
}
&.danger {
background: #fde2e1;
color: #c62828;
}
&.neutral {
background: #e0e0e0;
color: #757575;
}
}
.status-pill {
font-size: 0.75rem;
}
@@ -0,0 +1,857 @@
/**
* 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 {
AbstractControl,
FormsModule,
UntypedFormArray,
UntypedFormBuilder,
UntypedFormGroup,
ValidationErrors,
ValidatorFn,
Validators
} from '@angular/forms';
import { ActivatedRoute, Router, RouterLink } from '@angular/router';
import { MatButtonModule } from '@angular/material/button';
import { MatTabsModule } from '@angular/material/tabs';
import { MatSnackBar, MatSnackBarModule } from '@angular/material/snack-bar';
import { MatTooltipModule } from '@angular/material/tooltip';
import { FaIconComponent } from '@fortawesome/angular-fontawesome';
import { STANDALONE_SHARED_IMPORTS } from 'app/standalone-shared.module';
/** Custom Services */
import { LockerService } from '../../locker.service';
import { SettingsService } from 'app/settings/settings.service';
import { AuthenticationService } from 'app/core/authentication/authentication.service';
import { Dates } from 'app/core/utils/dates';
import {
LockerClientPickerComponent,
PickedClient
} from '../../shared/locker-client-picker/locker-client-picker.component';
/** Locker operation password and its confirmation must match. */
const passwordMatchValidator: ValidatorFn = (group: AbstractControl): ValidationErrors | null => {
const password = group.get('password')?.value;
const confirm = group.get('confirmPassword')?.value;
return password && confirm && password !== confirm ? { passwordMismatch: true } : null;
};
/** GST on locker rent: 9% CGST + 9% SGST (or 18% IGST) — the total rate shown on the form. */
const GST_RATE = 0.18;
/** One joint holder / mandate holder row on tab 2. */
interface JointHolderRow {
clientId: number | null;
clientName: string | null;
joinCategory: string;
}
/** One guarantor row on tab 3. */
interface GuarantorRow {
clientId: number | null;
clientName: string | null;
}
/**
* Locker allotment — the legacy "A/c Master Maintenance" screen as four tabs: Application Master, Joint Members,
* Other Entries (FD security deposit, membership, guarantors) and Nominees. Saving submits the application for the
* Branch Manager's approval; only an approved application allots the locker. The same screen opens an existing
* application — editable while pending, read-only afterwards — with Approve / Reject for the approver.
*/
@Component({
selector: 'mifosx-locker-allotment-form',
templateUrl: './allotment-form.component.html',
styleUrls: ['./allotment-form.component.scss'],
imports: [
...STANDALONE_SHARED_IMPORTS,
FormsModule,
RouterLink,
MatButtonModule,
MatTabsModule,
MatSnackBarModule,
MatTooltipModule,
FaIconComponent,
LockerClientPickerComponent
]
})
export class AllotmentFormComponent implements OnInit {
private formBuilder = inject(UntypedFormBuilder);
private lockerService = inject(LockerService);
private settingsService = inject(SettingsService);
private authenticationService = inject(AuthenticationService);
private dateUtils = inject(Dates);
private route = inject(ActivatedRoute);
private router = inject(Router);
private snackBar = inject(MatSnackBar);
private notify(message: string, isError = false): void {
this.snackBar.open(message, 'Dismiss', { duration: isError ? 6000 : 3000 });
}
private serverMessage(err: any, fallback: string): string {
const body = err?.error;
return body?.errors?.[0]?.defaultUserMessage || body?.defaultUserMessage || fallback;
}
// ----- Mode -----
/** create: new application; edit: pending and the user may change it; view: everything else (read-only). */
mode: 'create' | 'edit' | 'view' = 'create';
allotmentId: number | null = null;
allotment: any = null;
busy = false;
loadFailed = false;
selectedTab = 0;
// ----- Reference data -----
ownOfficeName = '';
transactionDate: Date = new Date();
permissions: string[] = [];
/** Active class master rows of the branch; rates are taken from the row the chosen locker belongs to. */
classRows: any[] = [];
classCodes: string[] = [];
/** Vacant lockers of the chosen class (plus this application's own locker when it is opened again). */
lockers: any[] = [];
/** Pending waiting-list entries — this client's first. */
waitingEntries: any[] = [];
// ----- Client (tab 1) -----
clientId: number | null = null;
clientName: string | null = null;
clientDetails: any = null;
clientDetailsFailed = false;
// ----- Tabs 2–4 -----
jointHolders: JointHolderRow[] = [];
guarantors: GuarantorRow[] = [];
selectedFdIds = new Set<number>();
readonly payModes = [
{ value: 'SELF', label: 'Self' },
{ value: 'EITHER_OR_SURVIVOR', label: 'Either or Survivor' },
{ value: 'FORMER_OR_SURVIVOR', label: 'Former or Survivor' },
{ value: 'ANYONE_OR_SURVIVOR', label: 'Anyone or Survivor' },
{ value: 'JOINTLY', label: 'Jointly' }
];
readonly categories = [
{ value: 'MEMBER', label: 'Member' },
{ value: 'NON_MEMBER', label: 'Non-Member' },
{ value: 'STAFF', label: 'Staff' }
];
readonly risks = [
'LOW',
'MEDIUM',
'HIGH'
];
readonly joinCategories = [
{ value: 'JOINT_HOLDER', label: 'Joint Holder' },
{ value: 'MANDATE_HOLDER', label: 'Mandate Holder' },
{ value: 'AUTHORISED_SIGNATORY', label: 'Authorised Signatory' }
];
readonly relations = [
'SPOUSE',
'FATHER',
'MOTHER',
'SON',
'DAUGHTER',
'BROTHER',
'SISTER',
'OTHER'
];
readonly genders = [
'MALE',
'FEMALE',
'OTHER'
];
mainForm: UntypedFormGroup;
nomineeForm: UntypedFormGroup;
rejecting = false;
rejectionReason = '';
constructor() {
this.mainForm = this.formBuilder.group(
{
classCode: [
'',
Validators.required
],
lockerId: [
'',
Validators.required
],
keyNo: [
'',
[
Validators.required,
Validators.maxLength(20)
]
],
category: [
'NON_MEMBER',
Validators.required
],
payMode: [
'SELF',
Validators.required
],
password: [
'',
[
Validators.minLength(4),
Validators.maxLength(20)
]
],
confirmPassword: [''],
dualAuth: [false],
smsActive: [true],
emailActive: [false],
rentExemption: [false],
rentAccountId: [''],
risk: [''],
freeze: [false],
freezeRemark: [
'',
Validators.maxLength(200)
],
waitingListId: [''],
phone2: [
'',
Validators.pattern(/^\d{10,15}$/)
],
photoReceived: [false],
addressProofReceived: [false],
remarks: [
'',
Validators.maxLength(500)
]
},
{ validators: passwordMatchValidator }
);
this.nomineeForm = this.formBuilder.group({
hasNominee: [false],
nominees: this.formBuilder.array([])
});
}
get nominees(): UntypedFormArray {
return this.nomineeForm.get('nominees') as UntypedFormArray;
}
ngOnInit(): void {
const credentials = this.authenticationService.getCredentials();
this.permissions = credentials?.permissions ?? [];
this.ownOfficeName = credentials?.officeName ?? '';
const businessDate = this.settingsService.businessDate;
this.transactionDate = businessDate && !isNaN(businessDate.getTime()) ? businessDate : new Date();
this.loadClasses();
this.loadWaitingEntries();
const id = this.route.snapshot.paramMap.get('id');
if (id && id !== 'new') {
this.allotmentId = Number(id);
this.loadAllotment();
} else {
this.mode = 'create';
this.applyPasswordRule();
}
}
// ----- Loading -----
private loadAllotment(): void {
this.lockerService.getAllotment(this.allotmentId!).subscribe({
next: (data: any) => {
this.allotment = data;
const editable = data.status === 'PENDING_APPROVAL' && this.hasPermission('UPDATE_LOCKERALLOTMENT');
this.mode = editable ? 'edit' : 'view';
this.fillFromAllotment(data);
this.applyPasswordRule();
if (this.mode === 'view') {
this.mainForm.disable({ emitEvent: false });
this.nomineeForm.disable({ emitEvent: false });
}
},
error: () => (this.loadFailed = true)
});
}
private fillFromAllotment(data: any): void {
this.clientId = data.clientId;
this.clientName = data.clientName;
this.loadClientDetails(data.clientId);
this.mainForm.patchValue(
{
classCode: data.classCode,
lockerId: data.lockerId,
keyNo: data.keyNo,
category: data.category,
payMode: data.payMode,
dualAuth: !!data.dualAuth,
smsActive: !!data.smsActive,
emailActive: !!data.emailActive,
rentExemption: !!data.rentExemption,
rentAccountId: data.rentAccountId ?? '',
risk: data.risk ?? '',
freeze: !!data.freeze,
freezeRemark: data.freezeRemark ?? '',
waitingListId: data.waitingListId ?? '',
phone2: data.phone2 ?? '',
photoReceived: !!data.photoReceived,
addressProofReceived: !!data.addressProofReceived,
remarks: data.remarks ?? ''
},
{ emitEvent: false }
);
this.loadLockers(data.classCode, { id: data.lockerId, lockerNo: data.lockerNo, lockerClassId: data.lockerClassId });
this.jointHolders = (data.jointHolders ?? []).map((j: any) => ({
clientId: j.clientId,
clientName: j.clientName,
joinCategory: j.joinCategory
}));
this.guarantors = (data.guarantors ?? []).map((g: any) => ({ clientId: g.clientId, clientName: g.clientName }));
this.selectedFdIds = new Set((data.fixedDeposits ?? []).map((f: any) => f.accountId));
this.nominees.clear();
(data.nominees ?? []).forEach((n: any) => this.nominees.push(this.buildNominee(n)));
this.nomineeForm.get('hasNominee')?.setValue(this.nominees.length > 0, { emitEvent: false });
}
private loadClasses(): void {
this.lockerService.getClasses('ACTIVE', 0, 500).subscribe({
next: (res: any) => {
this.classRows = res?.pageItems ?? [];
this.classCodes = Array.from(new Set<string>(this.classRows.map((c: any) => c.classCode))).sort();
},
error: () => {
this.classRows = [];
this.classCodes = [];
}
});
}
/** Vacant lockers of the class; the application's own (reserved) locker is kept in the list when reopened. */
loadLockers(classCode: string, keep?: { id: number; lockerNo: number; lockerClassId: number }): void {
this.lockers = [];
if (!classCode) {
return;
}
this.lockerService.getLockers({ classCode, status: 'VACANT' }, 0, 500).subscribe({
next: (res: any) => {
const rows = res?.pageItems ?? [];
if (keep && !rows.some((l: any) => l.id === keep.id)) {
rows.unshift({ id: keep.id, lockerNo: keep.lockerNo, lockerClassId: keep.lockerClassId, classCode });
}
this.lockers = rows;
},
error: () => (this.lockers = keep ? [{ ...keep, classCode }] : [])
});
}
private loadWaitingEntries(): void {
this.lockerService.getWaitingList('PENDING', 0, 500).subscribe({
next: (res: any) => {
this.waitingEntries = res?.pageItems ?? [];
this.sortWaitingEntries();
},
error: () => (this.waitingEntries = [])
});
}
/** This client's own inquiries first, then queue order. */
private sortWaitingEntries(): void {
this.waitingEntries.sort((a: any, b: any) => {
const aMine = a.clientId && a.clientId === this.clientId ? 0 : 1;
const bMine = b.clientId && b.clientId === this.clientId ? 0 : 1;
return aMine - bMine || a.id - b.id;
});
}
private loadClientDetails(clientId: number): void {
this.clientDetails = null;
this.clientDetailsFailed = false;
this.lockerService.getAllotmentClientDetails(clientId).subscribe({
next: (details: any) => {
this.clientDetails = details;
if (this.mode === 'create') {
// Category follows membership; staff is chosen by hand.
this.mainForm.get('category')?.setValue(details?.member ? 'MEMBER' : 'NON_MEMBER');
if (details?.riskRating && !this.mainForm.get('risk')?.value) {
this.mainForm.get('risk')?.setValue(details.riskRating);
}
const accounts = details?.savingsAccounts ?? [];
if (accounts.length === 1 && !this.mainForm.get('rentAccountId')?.value) {
this.mainForm.get('rentAccountId')?.setValue(accounts[0].id);
}
}
this.syncRentAccountType();
},
error: () => (this.clientDetailsFailed = true)
});
}
// ----- Tab 1 events -----
onClientPicked(client: PickedClient | null): void {
this.clientId = client?.clientId ?? null;
this.clientName = client?.displayName ?? null;
this.clientDetails = null;
this.selectedFdIds.clear();
this.rentAccountType = '';
this.mainForm.get('rentAccountId')?.setValue('');
// The applicant cannot also be their own joint holder or guarantor.
this.jointHolders = this.jointHolders.filter((j) => j.clientId !== this.clientId);
this.guarantors = this.guarantors.filter((g) => g.clientId !== this.clientId);
if (this.clientId) {
this.loadClientDetails(this.clientId);
}
this.sortWaitingEntries();
}
onClassChange(): void {
this.mainForm.get('lockerId')?.setValue('');
this.loadLockers(this.mainForm.get('classCode')?.value);
}
/** Picking a waiting-list entry pre-selects the first class that inquiry asked for. */
onWaitingEntryChange(): void {
const entry = this.selectedWaitingEntry;
const wanted = entry?.preferredClasses?.find((code: string) => this.classCodes.includes(code));
if (wanted && !this.mainForm.get('classCode')?.value) {
this.mainForm.get('classCode')?.setValue(wanted);
this.onClassChange();
}
}
get selectedWaitingEntry(): any {
const id = Number(this.mainForm.get('waitingListId')?.value);
return id ? this.waitingEntries.find((w: any) => w.id === id) : null;
}
// ----- Rates -----
/** Class master row the chosen locker was created from. */
get selectedClassRow(): any {
const lockerId = Number(this.mainForm.get('lockerId')?.value);
const locker = this.lockers.find((l: any) => l.id === lockerId);
const classId = locker?.lockerClassId;
return classId ? this.classRows.find((c: any) => c.id === classId) : null;
}
get isMemberRate(): boolean {
return this.mainForm.get('category')?.value === 'MEMBER';
}
get classRent(): number {
const row = this.selectedClassRow;
if (!row) return 0;
return Number(this.isMemberRate ? row.memberRent : row.rent) || 0;
}
get gstAmount(): number {
return Math.round(this.classRent * GST_RATE * 100) / 100;
}
get rentWithGst(): number {
return Math.round((this.classRent + this.gstAmount) * 100) / 100;
}
get requiredDeposit(): number {
const row = this.selectedClassRow;
if (!row) return 0;
return Number(this.isMemberRate ? row.memberDeposit : row.deposit) || 0;
}
/** First payment at allotment: a year's rent with GST plus the security deposit. */
get totalPayable(): number {
return Math.round((this.rentWithGst + this.requiredDeposit) * 100) / 100;
}
// ----- Reference account (legacy A/c Type → A/c No) -----
/** Savings product of the rent account, as the legacy "A/c Type" picker. */
rentAccountType = '';
get rentAccountTypes(): string[] {
return Array.from(new Set<string>((this.clientDetails?.savingsAccounts ?? []).map((a: any) => a.productName)));
}
get rentAccountsOfType(): any[] {
return (this.clientDetails?.savingsAccounts ?? []).filter(
(a: any) => !this.rentAccountType || a.productName === this.rentAccountType
);
}
onRentAccountTypeChange(type: string): void {
this.rentAccountType = type;
const accounts = this.rentAccountsOfType;
this.mainForm.get('rentAccountId')?.setValue(accounts.length === 1 ? accounts[0].id : '');
}
/** Picks the type of the chosen account, or the only type the client has. */
private syncRentAccountType(): void {
const accounts = this.clientDetails?.savingsAccounts ?? [];
const chosen = accounts.find((a: any) => a.id === Number(this.mainForm.get('rentAccountId')?.value));
this.rentAccountType = chosen?.productName ?? (this.rentAccountTypes.length === 1 ? this.rentAccountTypes[0] : '');
}
get selectedFdTotal(): number {
return (this.clientDetails?.fixedDeposits ?? [])
.filter((f: any) => this.selectedFdIds.has(f.id))
.reduce((sum: number, f: any) => sum + (Number(f.depositAmount) || 0), 0);
}
get depositShortfall(): number {
return Math.max(0, this.requiredDeposit - this.selectedFdTotal);
}
// ----- Tab 2: joint holders -----
/** Any mode other than SELF means more than one person operates the locker. */
get needsJointHolder(): boolean {
return this.mainForm.get('payMode')?.value !== 'SELF';
}
addJointHolder(): void {
this.jointHolders.push({ clientId: null, clientName: null, joinCategory: 'JOINT_HOLDER' });
}
removeJointHolder(index: number): void {
this.jointHolders.splice(index, 1);
}
onJointPicked(index: number, client: PickedClient | null): void {
this.jointHolders[index].clientId = client?.clientId ?? null;
this.jointHolders[index].clientName = client?.displayName ?? null;
}
/** Ids already used on this application, so the same person cannot be picked twice. */
takenIds(exceptIndex: number, rows: { clientId: number | null }[]): number[] {
const ids = rows
.filter((_, i) => i !== exceptIndex)
.map((r) => r.clientId)
.filter((id): id is number => !!id);
return this.clientId ? [
this.clientId,
...ids
] : ids;
}
// ----- Tab 3: deposits and guarantors -----
toggleFd(id: number, checked: boolean): void {
if (checked) {
this.selectedFdIds.add(id);
} else {
this.selectedFdIds.delete(id);
}
}
addGuarantor(): void {
this.guarantors.push({ clientId: null, clientName: null });
}
removeGuarantor(index: number): void {
this.guarantors.splice(index, 1);
}
onGuarantorPicked(index: number, client: PickedClient | null): void {
this.guarantors[index].clientId = client?.clientId ?? null;
this.guarantors[index].clientName = client?.displayName ?? null;
}
// ----- Tab 4: nominees -----
private buildNominee(n: any = {}): UntypedFormGroup {
return this.formBuilder.group({
nominationDate: [
n.nominationDate ? this.dateUtils.parseDate(n.nominationDate) : this.transactionDate,
Validators.required
],
name: [
n.name ?? '',
[
Validators.required,
Validators.maxLength(100)
]
],
mobileNo: [
n.mobileNo ?? '',
Validators.pattern(/^\d{10,15}$/)
],
dateOfBirth: [n.dateOfBirth ? this.dateUtils.parseDate(n.dateOfBirth) : ''],
relation: [
n.relation ?? '',
Validators.required
],
gender: [
n.gender ?? '',
Validators.required
],
guardianName: [
n.guardianName ?? '',
Validators.maxLength(100)
]
});
}
onHasNomineeChange(): void {
if (this.nomineeForm.get('hasNominee')?.value) {
if (this.nominees.length === 0) {
this.addNominee();
}
} else {
this.nominees.clear();
}
}
addNominee(): void {
this.nominees.push(this.buildNominee());
}
removeNominee(index: number): void {
this.nominees.removeAt(index);
if (this.nominees.length === 0) {
this.nomineeForm.get('hasNominee')?.setValue(false);
}
}
/** Under 18 on the transaction date. */
isMinor(index: number): boolean {
const dob = this.nominees.at(index).get('dateOfBirth')?.value;
if (!dob) return false;
const birth = new Date(dob);
const eighteenth = new Date(birth.getFullYear() + 18, birth.getMonth(), birth.getDate());
return eighteenth > this.transactionDate;
}
// ----- Validation per tab (drives the red tab labels and the submit button) -----
get tab1Errors(): string[] {
const errors: string[] = [];
if (!this.clientId) errors.push('Choose the client');
const f = this.mainForm;
if (f.get('classCode')?.invalid) errors.push('Choose the class');
if (f.get('lockerId')?.invalid) errors.push('Choose a vacant locker');
if (f.get('keyNo')?.invalid) errors.push('Enter the key number');
if (f.get('payMode')?.invalid) errors.push('Choose the pay mode');
if (f.get('category')?.invalid) errors.push('Choose the category');
if (this.mode === 'create' && !f.get('password')?.value) errors.push('Set the locker password');
if (f.get('password')?.invalid) errors.push('Password must be 4 to 20 characters');
if (f.hasError('passwordMismatch')) errors.push('Passwords do not match');
if (!f.get('rentExemption')?.value && !f.get('rentAccountId')?.value) errors.push('Choose the rent debit account');
if (f.get('phone2')?.invalid) errors.push('Phone-2 must be 10 to 15 digits');
return errors;
}
get tab2Errors(): string[] {
const errors: string[] = [];
if (this.needsJointHolder && this.jointHolders.length === 0) {
errors.push('This pay mode needs at least one joint holder');
}
if (this.jointHolders.some((j) => !j.clientId)) errors.push('Pick a client on every joint holder row');
return errors;
}
get tab3Errors(): string[] {
return this.guarantors.some((g) => !g.clientId) ? ['Pick a client on every guarantor row'] : [];
}
get tab4Errors(): string[] {
const errors: string[] = [];
if (this.nomineeForm.get('hasNominee')?.value && this.nominees.length === 0) errors.push('Add the nominee');
this.nominees.controls.forEach((group, i) => {
if (group.invalid) errors.push(`Complete nominee ${i + 1}`);
if (this.isMinor(i) && !group.get('guardianName')?.value) {
errors.push(`Nominee ${i + 1} is a minor — enter the guardian name`);
}
});
return errors;
}
get allErrors(): string[] {
return [
...this.tab1Errors,
...this.tab2Errors,
...this.tab3Errors,
...this.tab4Errors
];
}
// ----- Save -----
submit(): void {
if (this.mode === 'view' || this.busy) {
return;
}
if (this.allErrors.length) {
this.mainForm.markAllAsTouched();
this.nomineeForm.markAllAsTouched();
this.selectedTab = this.tab1Errors.length ? 0 : this.tab2Errors.length ? 1 : this.tab3Errors.length ? 2 : 3;
this.notify(this.allErrors[0], true);
return;
}
this.busy = true;
const raw = this.mainForm.getRawValue();
const dateFormat = this.settingsService.dateFormat;
const payload: any = {
clientId: this.clientId,
lockerId: Number(raw.lockerId),
keyNo: raw.keyNo,
category: raw.category,
payMode: raw.payMode,
dualAuth: !!raw.dualAuth,
smsActive: !!raw.smsActive,
emailActive: !!raw.emailActive,
rentExemption: !!raw.rentExemption,
rentAccountId: raw.rentExemption || !raw.rentAccountId ? null : Number(raw.rentAccountId),
risk: raw.risk || null,
freeze: !!raw.freeze,
freezeRemark: raw.freeze ? raw.freezeRemark || null : null,
waitingListId: raw.waitingListId ? Number(raw.waitingListId) : null,
phone2: raw.phone2 || null,
photoReceived: !!raw.photoReceived,
addressProofReceived: !!raw.addressProofReceived,
remarks: raw.remarks || null,
transactionDate: this.dateUtils.formatDate(this.transactionDate, dateFormat),
jointHolders: this.jointHolders.map((j) => ({ clientId: j.clientId, joinCategory: j.joinCategory })),
fixedDepositIds: [...this.selectedFdIds],
guarantors: this.guarantors.map((g) => ({ clientId: g.clientId })),
nominees: this.nominees.getRawValue().map((n: any) => ({
nominationDate: this.dateUtils.formatDate(n.nominationDate, dateFormat),
name: n.name,
mobileNo: n.mobileNo || null,
dateOfBirth: n.dateOfBirth ? this.dateUtils.formatDate(n.dateOfBirth, dateFormat) : null,
relation: n.relation,
gender: n.gender,
guardianName: n.guardianName || null
})),
dateFormat,
locale: this.settingsService.language.code
};
// On edit a blank password keeps the one already set.
if (raw.password) {
payload.password = raw.password;
}
const request =
this.mode === 'create'
? this.lockerService.createAllotment(payload)
: this.lockerService.updateAllotment(this.allotmentId!, payload);
request.subscribe({
next: (res: any) => {
this.busy = false;
this.notify(
this.mode === 'create'
? 'Application submitted for Branch Manager approval'
: 'Application updated — still awaiting approval'
);
this.router.navigate([
'/locker',
'allotment',
res?.resourceId ?? this.allotmentId
]);
if (this.mode === 'edit') {
this.loadAllotment();
}
},
error: (err: any) => {
this.busy = false;
this.notify(this.serverMessage(err, 'Failed to save the application'), true);
}
});
}
// ----- Approval -----
get isPending(): boolean {
return this.allotment?.status === 'PENDING_APPROVAL';
}
approve(): void {
if (!this.allotmentId || this.busy) return;
this.busy = true;
this.lockerService.approveAllotment(this.allotmentId).subscribe({
next: () => {
this.busy = false;
this.notify('Locker allotted');
this.loadAllotment();
},
error: (err: any) => {
this.busy = false;
this.notify(this.serverMessage(err, 'Failed to approve'), true);
}
});
}
confirmReject(): void {
if (!this.allotmentId || this.busy || !this.rejectionReason.trim()) return;
this.busy = true;
this.lockerService.rejectAllotment(this.allotmentId, this.rejectionReason.trim()).subscribe({
next: () => {
this.busy = false;
this.rejecting = false;
this.rejectionReason = '';
this.notify('Application rejected — the locker is vacant again');
this.loadAllotment();
},
error: (err: any) => {
this.busy = false;
this.notify(this.serverMessage(err, 'Failed to reject'), true);
}
});
}
// ----- Helpers -----
/** Password is required only for a new application; on edit a blank one means "keep". */
private applyPasswordRule(): void {
const password = this.mainForm.get('password');
const validators = [
Validators.minLength(4),
Validators.maxLength(20)
];
password?.setValidators(
this.mode === 'create' ? [
Validators.required,
...validators
] : validators
);
password?.updateValueAndValidity({ emitEvent: false });
}
hasPermission(code: string): boolean {
return this.permissions.includes('ALL_FUNCTIONS') || this.permissions.includes(code);
}
labelOf(list: { value: string; label: string }[], value: string): string {
return list.find((x) => x.value === value)?.label ?? value;
}
statusLabel(status: string): string {
switch (status) {
case 'PENDING_APPROVAL':
return 'Pending Approval';
case 'ACTIVE':
return 'Active';
case 'REJECTED':
return 'Rejected';
case 'CLOSED':
return 'Closed';
default:
return status;
}
}
}
@@ -0,0 +1,103 @@
<!--
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="container">
<div class="page-header">
<h2>{{ 'Locker Allotment' | translate }}</h2>
<div class="header-actions">
<button
mat-raised-button
color="primary"
[routerLink]="['/locker', 'allotment', 'new']"
*mifosxHasPermission="'CREATE_LOCKERALLOTMENT'"
>
<fa-icon icon="plus"></fa-icon>&nbsp;{{ 'New Allotment' | translate }}
</button>
<button mat-raised-button [routerLink]="['/locker']">{{ 'labels.buttons.Back' | translate }}</button>
</div>
</div>
<div class="filter-row">
@for (s of statuses; track s.value) {
<button mat-stroked-button [class.active]="statusFilter === s.value" (click)="filterByStatus(s.value)">
{{ s.label | translate }}
</button>
}
</div>
<div class="table-wrapper mat-elevation-z1">
<table mat-table [dataSource]="allotments">
<ng-container matColumnDef="applicationNo">
<th mat-header-cell *matHeaderCellDef class="num">{{ 'Application No' | translate }}</th>
<td mat-cell *matCellDef="let a" class="num">
<strong>{{ a.applicationNo ?? a.id }}</strong>
</td>
</ng-container>
<ng-container matColumnDef="office">
<th mat-header-cell *matHeaderCellDef>{{ 'Branch' | translate }}</th>
<td mat-cell *matCellDef="let a">{{ a.officeName }}</td>
</ng-container>
<ng-container matColumnDef="clientName">
<th mat-header-cell *matHeaderCellDef>{{ 'Client' | translate }}</th>
<td mat-cell *matCellDef="let a">
{{ a.clientName }}
<br /><span class="sub">{{ 'Client ID' | translate }} {{ a.clientId }}</span>
</td>
</ng-container>
<ng-container matColumnDef="locker">
<th mat-header-cell *matHeaderCellDef>{{ 'Locker' | translate }}</th>
<td mat-cell *matCellDef="let a">
<strong>{{ a.classCode }}-{{ a.lockerNo }}</strong>
</td>
</ng-container>
<ng-container matColumnDef="keyNo">
<th mat-header-cell *matHeaderCellDef>{{ 'Key No' | translate }}</th>
<td mat-cell *matCellDef="let a">{{ a.keyNo }}</td>
</ng-container>
<ng-container matColumnDef="payMode">
<th mat-header-cell *matHeaderCellDef>{{ 'Pay Mode' | translate }}</th>
<td mat-cell *matCellDef="let a">{{ a.payMode }}</td>
</ng-container>
<ng-container matColumnDef="openingDate">
<th mat-header-cell *matHeaderCellDef>{{ 'Op. Date' }}</th>
<td mat-cell *matCellDef="let a">{{ a.openingDate | dateFormat }}</td>
</ng-container>
<ng-container matColumnDef="status">
<th mat-header-cell *matHeaderCellDef>{{ 'labels.inputs.Status' | translate }}</th>
<td mat-cell *matCellDef="let a">
<span class="pill" [ngClass]="statusClass(a.status)">{{ statusLabel(a.status) | translate }}</span>
</td>
</ng-container>
<ng-container matColumnDef="actions">
<th mat-header-cell *matHeaderCellDef></th>
<td mat-cell *matCellDef="let a" class="actions-cell">
<button mat-icon-button color="primary" [matTooltip]="'Open' | translate" (click)="open(a)">
<fa-icon icon="eye"></fa-icon>
</button>
</td>
</ng-container>
<tr mat-header-row *matHeaderRowDef="columns"></tr>
<tr mat-row *matRowDef="let row; columns: columns" class="clickable" (click)="open(row)"></tr>
</table>
@if (loadFailed) {
<div class="empty-state">{{ 'Could not load locker applications.' }}</div>
} @else if (allotments.length === 0) {
<div class="empty-state">{{ 'labels.text.No records to display' | translate }}</div>
}
@if (totalRecords > 0) {
<mat-paginator
[length]="totalRecords"
[pageSize]="pageSize"
[pageSizeOptions]="pageSizeOptions"
[pageIndex]="pageIndex"
showFirstLastButtons
(page)="onPageChange($event)"
></mat-paginator>
}
</div>
</div>
@@ -0,0 +1,99 @@
/**
* 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/.
*/
.container {
padding: 16px;
}
.page-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 16px;
}
.header-actions {
display: flex;
gap: 12px;
}
.filter-row {
display: flex;
flex-wrap: wrap;
gap: 8px;
margin-bottom: 16px;
button.active {
background: rgb(25 118 210 / 12%);
}
}
.table-wrapper {
overflow-x: auto;
background: #fff;
}
table {
width: 100%;
}
tr.clickable {
cursor: pointer;
&:hover {
background: rgb(25 118 210 / 4%);
}
}
td.num,
th.num {
text-align: right;
font-variant-numeric: tabular-nums;
}
.sub {
font-size: 0.8rem;
color: rgb(0 0 0 / 55%);
}
.actions-cell {
white-space: nowrap;
}
.pill {
font-size: 11px;
padding: 2px 8px;
border-radius: 6px;
font-weight: 600;
&.ok {
background: #dff5e8;
color: #28c07a;
}
&.warn {
background: #fdf1dc;
color: #b45309;
}
&.danger {
background: #fde2e1;
color: #c62828;
}
&.neutral {
background: #e0e0e0;
color: #757575;
}
}
.empty-state {
padding: 24px;
text-align: center;
color: rgb(0 0 0 / 50%);
}
@@ -0,0 +1,134 @@
/**
* 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 { Router, RouterLink } from '@angular/router';
import { MatButtonModule } from '@angular/material/button';
import { MatTableModule } from '@angular/material/table';
import { MatPaginatorModule, PageEvent } from '@angular/material/paginator';
import { MatTooltipModule } from '@angular/material/tooltip';
import { FaIconComponent } from '@fortawesome/angular-fontawesome';
import { STANDALONE_SHARED_IMPORTS } from 'app/standalone-shared.module';
/** Custom Services */
import { LockerService } from '../../locker.service';
/**
* Locker applications of the branch by status. A new application waits for the Branch Manager's approval; only
* then is the locker allotted. Opening a row shows the full application (and Approve / Reject while pending).
*/
@Component({
selector: 'mifosx-locker-allotment-list',
templateUrl: './allotment-list.component.html',
styleUrls: ['./allotment-list.component.scss'],
imports: [
...STANDALONE_SHARED_IMPORTS,
RouterLink,
MatButtonModule,
MatTableModule,
MatPaginatorModule,
MatTooltipModule,
FaIconComponent
]
})
export class AllotmentListComponent implements OnInit {
private lockerService = inject(LockerService);
private router = inject(Router);
allotments: any[] = [];
loadFailed = false;
statusFilter = 'PENDING_APPROVAL';
pageSize = 10;
pageIndex = 0;
totalRecords = 0;
pageSizeOptions = [
10,
25,
50,
100
];
statuses = [
{ value: 'PENDING_APPROVAL', label: 'Pending Approval' },
{ value: 'ACTIVE', label: 'Active' },
{ value: 'REJECTED', label: 'Rejected' },
{ value: 'CLOSED', label: 'Closed' }
];
columns = [
'applicationNo',
'office',
'clientName',
'locker',
'keyNo',
'payMode',
'openingDate',
'status',
'actions'
];
ngOnInit(): void {
this.loadAllotments();
}
loadAllotments(): void {
this.loadFailed = false;
this.lockerService
.getAllotments(this.statusFilter || undefined, this.pageIndex * this.pageSize, this.pageSize)
.subscribe({
next: (res: any) => {
this.allotments = res?.pageItems ?? [];
this.totalRecords = Number(res?.totalFilteredRecords ?? 0);
},
error: () => {
this.allotments = [];
this.totalRecords = 0;
this.loadFailed = true;
}
});
}
filterByStatus(status: string): void {
this.statusFilter = this.statusFilter === status ? '' : status;
this.pageIndex = 0;
this.loadAllotments();
}
onPageChange(event: PageEvent): void {
this.pageSize = event.pageSize;
this.pageIndex = event.pageIndex;
this.loadAllotments();
}
open(row: any): void {
this.router.navigate([
'/locker',
'allotment',
row.id
]);
}
statusLabel(status: string): string {
return this.statuses.find((s) => s.value === status)?.label ?? status;
}
statusClass(status: string): string {
switch (status) {
case 'ACTIVE':
return 'ok';
case 'PENDING_APPROVAL':
return 'warn';
case 'REJECTED':
return 'danger';
default:
return 'neutral';
}
}
}
@@ -0,0 +1,281 @@
<!--
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="container">
<div class="page-header">
<h2>{{ 'Class Master' | translate }}</h2>
<div class="header-actions">
<button mat-raised-button color="primary" (click)="openAddForm()" *mifosxHasPermission="'CREATE_LOCKERCLASS'">
<fa-icon icon="plus"></fa-icon>&nbsp;{{ 'Add Locker Class' | translate }}
</button>
<button mat-raised-button [routerLink]="['/locker']">{{ 'labels.buttons.Back' | translate }}</button>
</div>
</div>
@if (showForm) {
<mat-card class="add-form">
<mat-card-content>
<h3 class="form-title">
{{ (editingId === null ? 'New Locker Class' : 'Edit Locker Class') | translate }}
<span class="sr-no">{{ 'Sr No' | translate }}: {{ editingId ?? ('Auto' | translate) }}</span>
</h3>
<form [formGroup]="classForm" (ngSubmit)="submit()">
<div class="section-label">{{ 'Class' | translate }}</div>
<div class="form-grid">
@if (isSuperUser) {
<mat-form-field appearance="fill">
<mat-label>{{ 'Branch' | translate }}</mat-label>
<mat-select formControlName="officeId" required>
@for (office of offices; track office.id) {
<mat-option [value]="office.id">{{ office.name }}</mat-option>
}
</mat-select>
</mat-form-field>
} @else {
<div class="total-box">
<span class="total-label">{{ 'Branch' | translate }}</span>
<span class="branch-value">{{ ownOfficeName }}</span>
</div>
}
<mat-form-field appearance="fill">
<mat-label>{{ 'Class' | translate }}</mat-label>
<input matInput formControlName="classCode" placeholder="A1" required />
<mat-hint>{{ 'Letters and digits, e.g. A1, B2' }}</mat-hint>
@if (classForm.get('classCode')?.hasError('pattern')) {
<mat-error>{{ 'Only letters and digits' | translate }}</mat-error>
}
</mat-form-field>
<mat-form-field appearance="fill">
<mat-label>{{ 'Class Name' | translate }}</mat-label>
<mat-select formControlName="className" required>
@for (name of classNames; track name) {
<mat-option [value]="name">{{ name }}</mat-option>
}
</mat-select>
</mat-form-field>
<mat-form-field appearance="fill">
<mat-label>{{ 'Area' | translate }}</mat-label>
<input matInput type="number" formControlName="area" required />
<mat-hint>{{ 'Locker size' | translate }}</mat-hint>
</mat-form-field>
</div>
<div class="section-label">{{ 'Rent and Deposit' | translate }}</div>
<div class="form-grid">
<mat-form-field appearance="fill">
<mat-label>{{ 'Rent (yearly)' | translate }}</mat-label>
<input matInput type="number" formControlName="rent" required />
</mat-form-field>
<mat-form-field appearance="fill">
<mat-label>{{ 'Member Rent (yearly)' | translate }}</mat-label>
<input matInput type="number" formControlName="memberRent" />
<mat-hint>{{ 'Blank = same as Rent' | translate }}</mat-hint>
</mat-form-field>
<mat-form-field appearance="fill">
<mat-label>{{ 'Deposit' | translate }}</mat-label>
<input matInput type="number" formControlName="deposit" required />
</mat-form-field>
<mat-form-field appearance="fill">
<mat-label>{{ 'Member Deposit' | translate }}</mat-label>
<input matInput type="number" formControlName="memberDeposit" />
<mat-hint>{{ 'Blank = same as Deposit' | translate }}</mat-hint>
</mat-form-field>
</div>
<div class="section-label">{{ 'Locker Numbers' | translate }}</div>
<div class="form-grid">
<mat-form-field appearance="fill">
<mat-label>{{ 'Start Locker No' | translate }}</mat-label>
<input matInput type="number" formControlName="startLockerNo" required />
</mat-form-field>
<mat-form-field appearance="fill">
<mat-label>{{ 'End Locker No' | translate }}</mat-label>
<input matInput type="number" formControlName="endLockerNo" required />
</mat-form-field>
<div class="total-box">
<span class="total-label">{{ 'Total Lockers' | translate }}</span>
<span class="total-value">{{ totalLockers }}</span>
</div>
</div>
@if (classForm.hasError('endBeforeStart')) {
<div class="form-error">{{ 'End Locker No cannot be less than Start Locker No' | translate }}</div>
}
@if (overlappingRange; as clash) {
<div class="form-error">
{{ 'Class' | translate }} {{ clash.classCode }} {{ 'already has lockers' | translate }}
{{ clash.startLockerNo }}–{{ clash.endLockerNo }}
</div>
}
@if (editingId !== null) {
<div class="form-note">
{{ 'Class and locker numbers cannot be changed. Add a new row for a different range.' }}
</div>
}
<div class="section-label">{{ 'Effective Period' | translate }}</div>
<div class="form-grid">
<mat-form-field appearance="fill">
<mat-label>{{ 'Effective From' | translate }}</mat-label>
<input matInput [matDatepicker]="fromPicker" formControlName="effectiveFromDate" required />
<mat-datepicker-toggle matSuffix [for]="fromPicker"></mat-datepicker-toggle>
<mat-datepicker #fromPicker></mat-datepicker>
</mat-form-field>
<mat-form-field appearance="fill">
<mat-label>{{ 'Effective To' | translate }}</mat-label>
<input
matInput
[matDatepicker]="toPicker"
formControlName="effectiveToDate"
[min]="classForm.get('effectiveFromDate')?.value"
/>
<mat-datepicker-toggle matSuffix [for]="toPicker"></mat-datepicker-toggle>
<mat-datepicker #toPicker></mat-datepicker>
<mat-hint>{{ 'Blank = no end date' | translate }}</mat-hint>
</mat-form-field>
</div>
@if (classForm.hasError('toBeforeFrom')) {
<div class="form-error">{{ 'Effective To cannot be before Effective From' | translate }}</div>
}
<div class="form-actions">
<button mat-raised-button type="button" (click)="cancel()">
{{ 'labels.buttons.Cancel' | translate }}
</button>
<button
mat-raised-button
color="primary"
type="submit"
[disabled]="classForm.invalid || overlappingRange || busy"
>
{{ 'labels.buttons.Submit' | translate }}
</button>
</div>
</form>
</mat-card-content>
</mat-card>
}
<div class="filter-row">
@for (s of ['ACTIVE', 'INACTIVE']; track s) {
<button mat-stroked-button [class.active]="statusFilter === s" (click)="filterByStatus(s)">{{ s }}</button>
}
</div>
<div class="table-wrapper mat-elevation-z1">
<table mat-table [dataSource]="classList">
<ng-container matColumnDef="srNo">
<th mat-header-cell *matHeaderCellDef class="num">{{ 'Sr No' | translate }}</th>
<td mat-cell *matCellDef="let c" class="num">{{ c.id }}</td>
</ng-container>
<ng-container matColumnDef="office">
<th mat-header-cell *matHeaderCellDef>{{ 'Branch' | translate }}</th>
<td mat-cell *matCellDef="let c">{{ c.officeName }}</td>
</ng-container>
<ng-container matColumnDef="classCode">
<th mat-header-cell *matHeaderCellDef>{{ 'Class' | translate }}</th>
<td mat-cell *matCellDef="let c">
<strong>{{ c.classCode }}</strong>
</td>
</ng-container>
<ng-container matColumnDef="className">
<th mat-header-cell *matHeaderCellDef>{{ 'Class Name' | translate }}</th>
<td mat-cell *matCellDef="let c">
<span class="pill neutral">{{ c.className }}</span>
</td>
</ng-container>
<ng-container matColumnDef="area">
<th mat-header-cell *matHeaderCellDef class="num">{{ 'Area' | translate }}</th>
<td mat-cell *matCellDef="let c" class="num">{{ c.area }}</td>
</ng-container>
<ng-container matColumnDef="rent">
<th mat-header-cell *matHeaderCellDef class="num">{{ 'Rent / Member' | translate }}</th>
<td mat-cell *matCellDef="let c" class="num">
{{ c.rent | number: '1.0-2' }}<br />
<span class="sub">{{ c.memberRent | number: '1.0-2' }}</span>
</td>
</ng-container>
<ng-container matColumnDef="lockerRange">
<th mat-header-cell *matHeaderCellDef>{{ 'Locker No' | translate }}</th>
<td mat-cell *matCellDef="let c">{{ c.startLockerNo }} – {{ c.endLockerNo }}</td>
</ng-container>
<ng-container matColumnDef="totalLockers">
<th mat-header-cell *matHeaderCellDef class="num">{{ 'Total' | translate }}</th>
<td mat-cell *matCellDef="let c" class="num">{{ c.endLockerNo - c.startLockerNo + 1 }}</td>
</ng-container>
<ng-container matColumnDef="occupied">
<th mat-header-cell *matHeaderCellDef class="num">{{ 'Occupied' | translate }}</th>
<td mat-cell *matCellDef="let c" class="num">{{ c.occupied ?? 0 }}</td>
</ng-container>
<ng-container matColumnDef="deposit">
<th mat-header-cell *matHeaderCellDef class="num">{{ 'Deposit / Member' | translate }}</th>
<td mat-cell *matCellDef="let c" class="num">
{{ c.deposit | number: '1.0-2' }}<br />
<span class="sub">{{ c.memberDeposit | number: '1.0-2' }}</span>
</td>
</ng-container>
<ng-container matColumnDef="effectiveFrom">
<th mat-header-cell *matHeaderCellDef>{{ 'Effective From' | translate }}</th>
<td mat-cell *matCellDef="let c">{{ c.effectiveFromDate | dateFormat }}</td>
</ng-container>
<ng-container matColumnDef="effectiveTo">
<th mat-header-cell *matHeaderCellDef>{{ 'Effective To' | translate }}</th>
<td mat-cell *matCellDef="let c">{{ c.effectiveToDate ? (c.effectiveToDate | dateFormat) : '-' }}</td>
</ng-container>
<ng-container matColumnDef="active">
<th mat-header-cell *matHeaderCellDef>{{ 'labels.inputs.Status' | translate }}</th>
<td mat-cell *matCellDef="let c">
<span class="pill" [ngClass]="c.active ? 'ok' : 'neutral'">{{ c.active ? 'Active' : 'Inactive' }}</span>
</td>
</ng-container>
<ng-container matColumnDef="actions">
<th mat-header-cell *matHeaderCellDef></th>
<td mat-cell *matCellDef="let c" class="actions-cell">
@if (c.active) {
<button
mat-icon-button
color="primary"
[matTooltip]="'Edit' | translate"
(click)="edit(c)"
*mifosxHasPermission="'UPDATE_LOCKERCLASS'"
>
<fa-icon icon="pen"></fa-icon>
</button>
<button
mat-icon-button
color="warn"
[matTooltip]="'Deactivate' | translate"
(click)="deactivate(c)"
*mifosxHasPermission="'DELETE_LOCKERCLASS'"
>
<fa-icon icon="trash"></fa-icon>
</button>
}
</td>
</ng-container>
<tr mat-header-row *matHeaderRowDef="columns"></tr>
<tr mat-row *matRowDef="let row; columns: columns"></tr>
</table>
@if (loadFailed) {
<div class="empty-state">
{{ 'Could not load locker classes.' }}
</div>
} @else if (classList.length === 0) {
<div class="empty-state">{{ 'labels.text.No records to display' | translate }}</div>
}
@if (totalRecords > 0) {
<mat-paginator
[length]="totalRecords"
[pageSize]="pageSize"
[pageSizeOptions]="pageSizeOptions"
[pageIndex]="pageIndex"
showFirstLastButtons
(page)="onPageChange($event)"
></mat-paginator>
}
</div>
</div>
@@ -0,0 +1,166 @@
/**
* 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/.
*/
.container {
padding: 16px;
}
.page-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 16px;
}
.header-actions {
display: flex;
gap: 12px;
}
.filter-row {
display: flex;
gap: 8px;
margin-bottom: 16px;
button.active {
background: rgb(25 118 210 / 12%);
}
}
.add-form {
margin-bottom: 16px;
}
.form-title {
margin: 0 0 12px;
font-size: 1.05rem;
font-weight: 600;
}
.sr-no {
margin-left: 12px;
padding: 2px 8px;
border-radius: 6px;
background: rgb(0 0 0 / 6%);
color: rgb(0 0 0 / 60%);
font-size: 0.8rem;
font-weight: 500;
}
.section-label {
font-size: 0.75rem;
font-weight: 600;
text-transform: uppercase;
letter-spacing: 0.04em;
color: rgb(0 0 0 / 55%);
margin: 12px 0 8px;
}
.form-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(220px, 1fr));
gap: 12px 20px;
margin-bottom: 8px;
}
mat-form-field {
width: 100%;
}
.total-box {
display: flex;
flex-direction: column;
justify-content: center;
padding: 0 12px;
border-radius: 6px;
background: rgb(25 118 210 / 6%);
min-height: 56px;
}
.total-label {
font-size: 0.75rem;
color: rgb(0 0 0 / 60%);
}
.total-value {
font-size: 1.3rem;
font-weight: 600;
color: #1976d2;
font-variant-numeric: tabular-nums;
}
.branch-value {
font-size: 1rem;
font-weight: 600;
}
.form-error {
color: #d32f2f;
font-size: 0.85rem;
margin: 0 0 8px;
}
.form-note {
color: rgb(0 0 0 / 60%);
font-size: 0.85rem;
margin: 0 0 8px;
}
.form-actions {
display: flex;
justify-content: flex-end;
gap: 12px;
margin-top: 16px;
}
.table-wrapper {
overflow-x: auto;
background: #fff;
}
table {
width: 100%;
}
td.num,
th.num {
text-align: right;
font-variant-numeric: tabular-nums;
}
.sub {
font-size: 0.8rem;
color: rgb(0 0 0 / 55%);
}
.actions-cell {
white-space: nowrap;
}
.pill {
font-size: 11px;
padding: 2px 8px;
border-radius: 6px;
font-weight: 600;
&.ok {
background: #dff5e8;
color: #28c07a;
}
&.neutral {
background: #e0e0e0;
color: #757575;
}
}
.empty-state {
padding: 24px;
text-align: center;
color: rgb(0 0 0 / 50%);
}
@@ -0,0 +1,409 @@
/**
* 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 {
AbstractControl,
UntypedFormBuilder,
UntypedFormGroup,
ValidationErrors,
ValidatorFn,
Validators
} from '@angular/forms';
import { RouterLink } from '@angular/router';
import { MatIconModule } from '@angular/material/icon';
import { MatButtonModule } from '@angular/material/button';
import { MatTableModule } from '@angular/material/table';
import { MatPaginatorModule, PageEvent } from '@angular/material/paginator';
import { MatSnackBar, MatSnackBarModule } from '@angular/material/snack-bar';
import { MatTooltipModule } from '@angular/material/tooltip';
import { MatDialog } from '@angular/material/dialog';
import { FaIconComponent } from '@fortawesome/angular-fontawesome';
import { STANDALONE_SHARED_IMPORTS } from 'app/standalone-shared.module';
/** Custom Services */
import { LockerService } from '../locker.service';
import { SettingsService } from 'app/settings/settings.service';
import { AuthenticationService } from 'app/core/authentication/authentication.service';
import { Dates } from 'app/core/utils/dates';
import { DeleteDialogComponent } from 'app/shared/delete-dialog/delete-dialog.component';
/** End locker no must not be below the start, and effective-to must not be before effective-from. */
const rangeValidator: ValidatorFn = (group: AbstractControl): ValidationErrors | null => {
const errors: ValidationErrors = {};
const start = Number(group.get('startLockerNo')?.value);
const end = Number(group.get('endLockerNo')?.value);
if (start && end && end < start) {
errors['endBeforeStart'] = true;
}
const from = group.get('effectiveFromDate')?.value;
const to = group.get('effectiveToDate')?.value;
if (from && to && new Date(to) < new Date(from)) {
errors['toBeforeFrom'] = true;
}
return Object.keys(errors).length ? errors : null;
};
/**
* Locker class master — one row per locker number range of a class (a class such as A1 can have several ranges),
* with member / non-member rent and deposit and an effective period. Occupied is counted from allotments by the
* server, never stored. Only a Branch Manager (own branch) or the Super user (any branch) may change it; the
* server enforces both, the buttons here are only hidden to match.
*/
@Component({
selector: 'mifosx-locker-class-master',
templateUrl: './class-master.component.html',
styleUrls: ['./class-master.component.scss'],
imports: [
...STANDALONE_SHARED_IMPORTS,
RouterLink,
MatIconModule,
MatButtonModule,
MatTableModule,
MatPaginatorModule,
MatSnackBarModule,
MatTooltipModule,
FaIconComponent
]
})
export class ClassMasterComponent implements OnInit {
private formBuilder = inject(UntypedFormBuilder);
private lockerService = inject(LockerService);
private settingsService = inject(SettingsService);
private dateUtils = inject(Dates);
private authenticationService = inject(AuthenticationService);
private dialog = inject(MatDialog);
private snackBar = inject(MatSnackBar);
private notify(message: string, isError = false): void {
this.snackBar.open(message, 'Dismiss', { duration: isError ? 6000 : 3000 });
}
/** The server's own reason (overlapping range, allotted lockers, other branch) rather than a generic failure. */
private serverMessage(err: any, fallback: string): string {
const body = err?.error;
return body?.errors?.[0]?.defaultUserMessage || body?.defaultUserMessage || fallback;
}
/** Super user picks any branch; everyone else is fixed to their own. */
isSuperUser = false;
ownOfficeId: number | null = null;
ownOfficeName = '';
offices: any[] = [];
classList: any[] = [];
showForm = false;
busy = false;
loadFailed = false;
/** Id of the row being edited; null while adding. */
editingId: number | null = null;
statusFilter = 'ACTIVE';
pageSize = 10;
pageIndex = 0;
totalRecords = 0;
pageSizeOptions = [
10,
25,
50,
100
];
classNames = [
'SMALL',
'MEDIUM',
'LARGE'
];
classForm: UntypedFormGroup;
columns = [
'srNo',
'office',
'classCode',
'className',
'area',
'rent',
'lockerRange',
'totalLockers',
'occupied',
'deposit',
'effectiveFrom',
'effectiveTo',
'active',
'actions'
];
constructor() {
this.classForm = this.formBuilder.group(
{
officeId: [''],
classCode: [
'',
[
Validators.required,
Validators.maxLength(10),
Validators.pattern(/^[A-Za-z0-9]+$/)
]
],
className: [
'',
Validators.required
],
area: [
'',
[
Validators.required,
Validators.min(1)
]
],
rent: [
'',
[
Validators.required,
Validators.min(0)
]
],
memberRent: [
'',
Validators.min(0)
],
deposit: [
'',
[
Validators.required,
Validators.min(0)
]
],
memberDeposit: [
'',
Validators.min(0)
],
startLockerNo: [
'',
[
Validators.required,
Validators.min(1),
Validators.pattern(/^\d+$/)
]
],
endLockerNo: [
'',
[
Validators.required,
Validators.min(1),
Validators.pattern(/^\d+$/)
]
],
effectiveFromDate: [
new Date(),
Validators.required
],
effectiveToDate: ['']
},
{ validators: rangeValidator }
);
}
ngOnInit(): void {
const credentials = this.authenticationService.getCredentials();
this.isSuperUser = !!credentials?.permissions?.includes('ALL_FUNCTIONS');
this.ownOfficeId = credentials?.officeId ?? null;
this.ownOfficeName = credentials?.officeName ?? '';
if (this.isSuperUser) {
this.lockerService.getOffices().subscribe({
next: (offices: any[]) => (this.offices = offices ?? []),
error: () => (this.offices = [])
});
}
this.loadClasses();
}
/** Lockers the entered range will create — shown live so the user can check it before saving. */
get totalLockers(): number {
const start = Number(this.classForm.get('startLockerNo')?.value);
const end = Number(this.classForm.get('endLockerNo')?.value);
return start && end && end >= start ? end - start + 1 : 0;
}
/** Another active range of the same class already covering any of the entered locker numbers (on the loaded page). */
get overlappingRange(): any | null {
const code = String(this.classForm.get('classCode')?.value || '').toUpperCase();
const start = Number(this.classForm.get('startLockerNo')?.value);
const end = Number(this.classForm.get('endLockerNo')?.value);
const officeId = Number(this.classForm.get('officeId')?.value || this.ownOfficeId);
if (!code || !start || !end || end < start) {
return null;
}
return (
this.classList.find(
(c) =>
c.active &&
c.id !== this.editingId &&
c.officeId === officeId &&
String(c.classCode).toUpperCase() === code &&
start <= c.endLockerNo &&
end >= c.startLockerNo
) ?? null
);
}
loadClasses(): void {
this.loadFailed = false;
this.lockerService
.getClasses(this.statusFilter || undefined, this.pageIndex * this.pageSize, this.pageSize)
.subscribe({
next: (res: any) => {
this.classList = res?.pageItems ?? [];
this.totalRecords = Number(res?.totalFilteredRecords ?? 0);
},
error: () => {
this.classList = [];
this.totalRecords = 0;
this.loadFailed = true;
}
});
}
filterByStatus(status: string): void {
this.statusFilter = this.statusFilter === status ? '' : status;
this.pageIndex = 0;
this.loadClasses();
}
onPageChange(event: PageEvent): void {
this.pageSize = event.pageSize;
this.pageIndex = event.pageIndex;
this.loadClasses();
}
openAddForm(): void {
this.editingId = null;
this.resetForm();
this.showForm = true;
}
/** Class code and locker range stay fixed once saved — a different range is added as a new row. */
edit(row: any): void {
this.editingId = row.id;
this.classForm.reset({
officeId: row.officeId,
classCode: row.classCode,
className: row.className,
area: row.area,
rent: row.rent,
memberRent: row.memberRent,
deposit: row.deposit,
memberDeposit: row.memberDeposit,
startLockerNo: row.startLockerNo,
endLockerNo: row.endLockerNo,
effectiveFromDate: row.effectiveFromDate ? this.dateUtils.parseDate(row.effectiveFromDate) : '',
effectiveToDate: row.effectiveToDate ? this.dateUtils.parseDate(row.effectiveToDate) : ''
});
this.classForm.get('officeId')?.disable();
this.classForm.get('classCode')?.disable();
this.classForm.get('startLockerNo')?.disable();
this.classForm.get('endLockerNo')?.disable();
this.showForm = true;
}
cancel(): void {
this.showForm = false;
this.editingId = null;
this.resetForm();
}
submit(): void {
if (this.classForm.invalid || this.overlappingRange || this.busy) {
return;
}
this.busy = true;
const raw = this.classForm.getRawValue();
const dateFormat = this.settingsService.dateFormat;
// Member rate falls back to the normal rate when left blank, as in the legacy class master.
const payload: any = {
className: raw.className,
area: Number(raw.area),
rent: Number(raw.rent),
memberRent: raw.memberRent !== '' && raw.memberRent != null ? Number(raw.memberRent) : Number(raw.rent),
deposit: Number(raw.deposit),
memberDeposit:
raw.memberDeposit !== '' && raw.memberDeposit != null ? Number(raw.memberDeposit) : Number(raw.deposit),
effectiveFromDate: this.dateUtils.formatDate(raw.effectiveFromDate, dateFormat),
dateFormat,
locale: this.settingsService.language.code
};
if (raw.effectiveToDate) {
payload.effectiveToDate = this.dateUtils.formatDate(raw.effectiveToDate, dateFormat);
} else if (this.editingId != null) {
// Sent blank on edit so a previously set end date can be cleared.
payload.effectiveToDate = null;
}
if (this.editingId == null) {
if (this.isSuperUser && raw.officeId) {
payload.officeId = Number(raw.officeId);
}
payload.classCode = String(raw.classCode).toUpperCase();
payload.startLockerNo = Number(raw.startLockerNo);
payload.endLockerNo = Number(raw.endLockerNo);
}
const request =
this.editingId == null
? this.lockerService.createClass(payload)
: this.lockerService.updateClass(this.editingId, payload);
request.subscribe({
next: () => {
this.busy = false;
this.notify(this.editingId == null ? 'Locker class saved' : 'Locker class updated');
this.cancel();
this.loadClasses();
},
error: (err: any) => {
this.busy = false;
this.notify(this.serverMessage(err, 'Failed to save locker class'), true);
}
});
}
deactivate(row: any): void {
const dialogRef = this.dialog.open(DeleteDialogComponent, {
data: { deleteContext: `locker class ${row.classCode} (lockers ${row.startLockerNo}-${row.endLockerNo})` }
});
dialogRef.afterClosed().subscribe((response: any) => {
if (!response?.delete) {
return;
}
this.lockerService.deactivateClass(row.id).subscribe({
next: () => {
this.notify('Locker class deactivated');
this.loadClasses();
},
error: (err: any) => this.notify(this.serverMessage(err, 'Failed to deactivate locker class'), true)
});
});
}
private resetForm(): void {
this.classForm.reset({
officeId: this.ownOfficeId ?? '',
className: '',
effectiveFromDate: new Date(),
effectiveToDate: ''
});
if (this.isSuperUser) {
this.classForm.get('officeId')?.enable();
} else {
this.classForm.get('officeId')?.disable();
}
this.classForm.get('classCode')?.enable();
this.classForm.get('startLockerNo')?.enable();
this.classForm.get('endLockerNo')?.enable();
}
}
@@ -0,0 +1,45 @@
<!--
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="container">
<div class="page-header">
<h2>{{ 'Locker' | translate }}</h2>
</div>
@for (group of groups; track group.title) {
<h3 class="group-title">{{ group.title | translate }}</h3>
<div class="cards-grid">
@for (screen of group.screens; track screen.label) {
@if (screen.ready) {
<mat-card class="lk-card" [routerLink]="screen.route">
<mat-card-content>
<div class="card-head">
<span class="card-icon">
<fa-icon [icon]="screen.icon"></fa-icon>
</span>
<span class="card-title">{{ screen.label | translate }}</span>
</div>
</mat-card-content>
</mat-card>
} @else {
<mat-card class="lk-card lk-card--disabled" aria-disabled="true">
<mat-card-content>
<div class="card-head">
<span class="card-icon">
<fa-icon [icon]="screen.icon"></fa-icon>
</span>
<span class="card-title">{{ screen.label | translate }}</span>
<span class="soon-badge">{{ 'Coming soon' | translate }}</span>
</div>
</mat-card-content>
</mat-card>
}
}
</div>
}
</div>
@@ -0,0 +1,95 @@
/**
* 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/.
*/
.container {
padding: 16px;
}
.page-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 16px;
}
.group-title {
font-size: 0.95rem;
font-weight: 600;
text-transform: uppercase;
letter-spacing: 0.04em;
color: rgb(0 0 0 / 55%);
margin: 24px 0 12px;
&:first-of-type {
margin-top: 0;
}
}
.cards-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(260px, 1fr));
gap: 16px;
}
.lk-card {
cursor: pointer;
transition:
box-shadow 0.2s ease,
transform 0.2s ease;
&:hover {
box-shadow: 0 4px 12px rgb(0 0 0 / 15%);
transform: translateY(-2px);
}
}
.lk-card--disabled {
cursor: default;
opacity: 0.6;
&:hover {
box-shadow: none;
transform: none;
}
}
.card-head {
display: flex;
align-items: center;
gap: 12px;
}
.card-icon {
display: inline-flex;
align-items: center;
justify-content: center;
width: 42px;
height: 42px;
border-radius: 10px;
flex: 0 0 auto;
background: rgb(25 118 210 / 10%);
color: #1976d2;
font-size: 1.15rem;
}
.card-title {
font-size: 1.05rem;
font-weight: 600;
line-height: 1.2;
}
.soon-badge {
margin-left: auto;
padding: 2px 8px;
border-radius: 10px;
background: rgb(0 0 0 / 8%);
color: rgb(0 0 0 / 60%);
font-size: 0.7rem;
font-weight: 600;
white-space: nowrap;
}
@@ -0,0 +1,162 @@
/**
* 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 { RouterLink } from '@angular/router';
import { MatCardModule } from '@angular/material/card';
import { FaIconComponent } from '@fortawesome/angular-fontawesome';
import { STANDALONE_SHARED_IMPORTS } from 'app/standalone-shared.module';
/** One dashboard card. `ready` stays false until that screen is built, so the card cannot route to a missing page. */
interface LockerScreen {
route: string[];
label: string;
icon: string;
ready: boolean;
}
/** A titled row of cards on the dashboard. */
interface LockerScreenGroup {
title: string;
screens: LockerScreen[];
}
/**
* Safe Deposit Locker landing page — one card per screen, grouped as one-time setup, daily counter work and
* occasional lifecycle actions.
*/
@Component({
selector: 'mifosx-locker-dashboard',
templateUrl: './locker-dashboard.component.html',
styleUrls: ['./locker-dashboard.component.scss'],
imports: [
...STANDALONE_SHARED_IMPORTS,
RouterLink,
MatCardModule,
FaIconComponent
]
})
export class LockerDashboardComponent {
groups: LockerScreenGroup[] = [
{
title: 'Setup',
screens: [
{
route: [
'/locker',
'class-master'
],
label: 'Class Master',
icon: 'layer-group',
ready: true
},
{
route: [
'/locker',
'lockers'
],
label: 'Locker List',
icon: 'th-large',
ready: true
},
{
route: [
'/locker',
'waiting-list'
],
label: 'Waiting List',
icon: 'hourglass-half',
ready: true
},
{
route: [
'/locker',
'gl-mapping'
],
label: 'GL Mapping',
icon: 'book-open',
ready: false
}
]
},
{
title: 'Daily Work',
screens: [
{
route: [
'/locker',
'allotment'
],
label: 'Allotment',
icon: 'file-signature',
ready: true
},
{
route: [
'/locker',
'rent-entry'
],
label: 'Rent Entry',
icon: 'receipt',
ready: false
},
{
route: [
'/locker',
'in-out-entry'
],
label: 'In / Out Entry',
icon: 'door-open',
ready: false
}
]
},
{
title: 'Others',
screens: [
{
route: [
'/locker',
'key-change'
],
label: 'Key Change',
icon: 'key',
ready: false
},
{
route: [
'/locker',
'freeze'
],
label: 'Freeze / Unfreeze',
icon: 'ban',
ready: false
},
{
route: [
'/locker',
'surrender'
],
label: 'Surrender',
icon: 'box-open',
ready: false
},
{
route: [
'/locker',
'reports'
],
label: 'Reports',
icon: 'chart-bar',
ready: false
}
]
}
];
}
@@ -0,0 +1,146 @@
<!--
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="container">
<div class="page-header">
<h2>{{ 'Locker List' | translate }}</h2>
<div class="header-actions">
<button mat-raised-button [routerLink]="['/locker']">{{ 'labels.buttons.Back' | translate }}</button>
</div>
</div>
<div class="tiles">
<button type="button" class="tile" [class.active]="statusFilter === ''" (click)="filterByStatus('')">
<span class="tile-label">{{ 'Total' | translate }}</span>
<span class="tile-value">{{ summary.total }}</span>
</button>
<button
type="button"
class="tile vacant"
[class.active]="statusFilter === 'VACANT'"
(click)="filterByStatus('VACANT')"
>
<span class="tile-label">{{ 'Vacant' | translate }}</span>
<span class="tile-value">{{ summary.vacant }}</span>
</button>
<button
type="button"
class="tile allotted"
[class.active]="statusFilter === 'ALLOTTED'"
(click)="filterByStatus('ALLOTTED')"
>
<span class="tile-label">{{ 'Allotted' | translate }}</span>
<span class="tile-value">{{ summary.allotted }}</span>
</button>
<button
type="button"
class="tile blocked"
[class.active]="statusFilter === 'BLOCKED'"
(click)="filterByStatus('BLOCKED')"
>
<span class="tile-label">{{ 'Blocked' | translate }}</span>
<span class="tile-value">{{ summary.blocked }}</span>
</button>
</div>
<div class="filter-row">
@if (isSuperUser) {
<mat-form-field appearance="fill">
<mat-label>{{ 'Branch' | translate }}</mat-label>
<mat-select [(ngModel)]="officeFilter" (selectionChange)="refresh()">
<mat-option value="">{{ 'All Branches' | translate }}</mat-option>
@for (office of offices; track office.id) {
<mat-option [value]="office.id">{{ office.name }}</mat-option>
}
</mat-select>
</mat-form-field>
}
<mat-form-field appearance="fill">
<mat-label>{{ 'Class' | translate }}</mat-label>
<mat-select [(ngModel)]="classFilter" (selectionChange)="applyFilters()">
<mat-option value="">{{ 'All Classes' | translate }}</mat-option>
@for (code of classCodes; track code) {
<mat-option [value]="code">{{ code }}</mat-option>
}
</mat-select>
</mat-form-field>
<mat-form-field appearance="fill">
<mat-label>{{ 'Status' | translate }}</mat-label>
<mat-select [(ngModel)]="statusFilter" (selectionChange)="applyFilters()">
<mat-option value="">{{ 'All' | translate }}</mat-option>
@for (s of statuses; track s) {
<mat-option [value]="s">{{ s }}</mat-option>
}
</mat-select>
</mat-form-field>
<mat-form-field appearance="fill">
<mat-label>{{ 'Locker No' | translate }}</mat-label>
<input matInput type="number" min="1" [(ngModel)]="lockerNoFilter" (keyup.enter)="applyFilters()" />
</mat-form-field>
<button mat-raised-button color="primary" (click)="applyFilters()">
<fa-icon icon="search"></fa-icon>&nbsp;{{ 'Search' | translate }}
</button>
<button mat-stroked-button (click)="clearFilters()">{{ 'Clear' | translate }}</button>
</div>
<div class="table-wrapper mat-elevation-z1">
<table mat-table [dataSource]="lockers">
<ng-container matColumnDef="office">
<th mat-header-cell *matHeaderCellDef>{{ 'Branch' | translate }}</th>
<td mat-cell *matCellDef="let l">{{ l.officeName }}</td>
</ng-container>
<ng-container matColumnDef="classCode">
<th mat-header-cell *matHeaderCellDef>{{ 'Class' | translate }}</th>
<td mat-cell *matCellDef="let l">
<strong>{{ l.classCode }}</strong>
</td>
</ng-container>
<ng-container matColumnDef="className">
<th mat-header-cell *matHeaderCellDef>{{ 'Class Name' | translate }}</th>
<td mat-cell *matCellDef="let l">
<span class="pill neutral">{{ l.className }}</span>
</td>
</ng-container>
<ng-container matColumnDef="lockerNo">
<th mat-header-cell *matHeaderCellDef class="num">{{ 'Locker No' | translate }}</th>
<td mat-cell *matCellDef="let l" class="num">{{ l.lockerNo }}</td>
</ng-container>
<ng-container matColumnDef="keyNo">
<th mat-header-cell *matHeaderCellDef>{{ 'Key No' | translate }}</th>
<td mat-cell *matCellDef="let l">{{ l.keyNo || '-' }}</td>
</ng-container>
<ng-container matColumnDef="status">
<th mat-header-cell *matHeaderCellDef>{{ 'labels.inputs.Status' | translate }}</th>
<td mat-cell *matCellDef="let l">
<span class="pill" [ngClass]="l.status === 'VACANT' ? 'ok' : l.status === 'ALLOTTED' ? 'info' : 'warn'">{{
l.status
}}</span>
</td>
</ng-container>
<tr mat-header-row *matHeaderRowDef="columns"></tr>
<tr mat-row *matRowDef="let row; columns: columns"></tr>
</table>
@if (loadFailed) {
<div class="empty-state">{{ 'Could not load lockers.' }}</div>
} @else if (lockers.length === 0) {
<div class="empty-state">
{{ 'No lockers found. Lockers are created from Class Master.' }}
</div>
}
@if (totalRecords > 0) {
<mat-paginator
[length]="totalRecords"
[pageSize]="pageSize"
[pageSizeOptions]="pageSizeOptions"
[pageIndex]="pageIndex"
showFirstLastButtons
(page)="onPageChange($event)"
></mat-paginator>
}
</div>
</div>
@@ -0,0 +1,135 @@
/**
* 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/.
*/
.container {
padding: 16px;
}
.page-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 16px;
}
.header-actions {
display: flex;
gap: 12px;
}
.tiles {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(160px, 1fr));
gap: 12px;
margin-bottom: 16px;
}
.tile {
display: flex;
flex-direction: column;
align-items: flex-start;
gap: 4px;
padding: 12px 16px;
border: 1px solid rgb(0 0 0 / 12%);
border-left: 4px solid #1976d2;
border-radius: 6px;
background: #fff;
cursor: pointer;
text-align: left;
font: inherit;
&.vacant {
border-left-color: #28c07a;
}
&.allotted {
border-left-color: #1976d2;
}
&.blocked {
border-left-color: #f59e0b;
}
&.active {
background: rgb(25 118 210 / 8%);
box-shadow: 0 2px 6px rgb(0 0 0 / 10%);
}
}
.tile-label {
font-size: 0.75rem;
color: rgb(0 0 0 / 60%);
text-transform: uppercase;
letter-spacing: 0.04em;
}
.tile-value {
font-size: 1.5rem;
font-weight: 600;
font-variant-numeric: tabular-nums;
}
.filter-row {
display: flex;
flex-wrap: wrap;
align-items: center;
gap: 12px;
margin-bottom: 8px;
mat-form-field {
width: 180px;
}
}
.table-wrapper {
overflow-x: auto;
background: #fff;
}
table {
width: 100%;
}
td.num,
th.num {
text-align: right;
font-variant-numeric: tabular-nums;
}
.pill {
font-size: 11px;
padding: 2px 8px;
border-radius: 6px;
font-weight: 600;
&.ok {
background: #dff5e8;
color: #28c07a;
}
&.info {
background: #e3f0fc;
color: #1976d2;
}
&.warn {
background: #fdf1dc;
color: #b45309;
}
&.neutral {
background: #e0e0e0;
color: #757575;
}
}
.empty-state {
padding: 24px;
text-align: center;
color: rgb(0 0 0 / 50%);
}
@@ -0,0 +1,171 @@
/**
* 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 { FormsModule } from '@angular/forms';
import { RouterLink } from '@angular/router';
import { MatButtonModule } from '@angular/material/button';
import { MatTableModule } from '@angular/material/table';
import { MatPaginatorModule, PageEvent } from '@angular/material/paginator';
import { FaIconComponent } from '@fortawesome/angular-fontawesome';
import { STANDALONE_SHARED_IMPORTS } from 'app/standalone-shared.module';
/** Custom Services */
import { LockerService } from '../locker.service';
import { AuthenticationService } from 'app/core/authentication/authentication.service';
/**
* Read-only locker inventory — every box created from the class master, with its status. The tiles on top are the
* status counts and double as a status filter. Allotment happens on its own screen, not here.
*/
@Component({
selector: 'mifosx-locker-list',
templateUrl: './locker-list.component.html',
styleUrls: ['./locker-list.component.scss'],
imports: [
...STANDALONE_SHARED_IMPORTS,
FormsModule,
RouterLink,
MatButtonModule,
MatTableModule,
MatPaginatorModule,
FaIconComponent
]
})
export class LockerListComponent implements OnInit {
private lockerService = inject(LockerService);
private authenticationService = inject(AuthenticationService);
lockers: any[] = [];
summary = { total: 0, vacant: 0, allotted: 0, blocked: 0 };
loadFailed = false;
isSuperUser = false;
offices: any[] = [];
classCodes: string[] = [];
officeFilter: number | '' = '';
classFilter = '';
statusFilter = '';
lockerNoFilter: number | null = null;
pageSize = 25;
pageIndex = 0;
totalRecords = 0;
pageSizeOptions = [
25,
50,
100
];
statuses = [
'VACANT',
'ALLOTTED',
'BLOCKED'
];
columns = [
'office',
'classCode',
'className',
'lockerNo',
'keyNo',
'status'
];
ngOnInit(): void {
const credentials = this.authenticationService.getCredentials();
this.isSuperUser = !!credentials?.permissions?.includes('ALL_FUNCTIONS');
if (this.isSuperUser) {
this.lockerService.getOffices().subscribe({
next: (offices: any[]) => (this.offices = offices ?? []),
error: () => (this.offices = [])
});
}
this.refresh();
}
/** Reloads the class choices, the counts and the first page — everything the branch filter affects. */
refresh(): void {
this.pageIndex = 0;
this.loadClassCodes();
this.loadSummary();
this.loadLockers();
}
applyFilters(): void {
this.pageIndex = 0;
this.loadLockers();
}
filterByStatus(status: string): void {
this.statusFilter = this.statusFilter === status ? '' : status;
this.applyFilters();
}
clearFilters(): void {
this.classFilter = '';
this.statusFilter = '';
this.lockerNoFilter = null;
this.applyFilters();
}
onPageChange(event: PageEvent): void {
this.pageSize = event.pageSize;
this.pageIndex = event.pageIndex;
this.loadLockers();
}
private loadLockers(): void {
this.loadFailed = false;
this.lockerService
.getLockers(
{
officeId: this.officeFilter || undefined,
classCode: this.classFilter || undefined,
status: this.statusFilter || undefined,
lockerNo: this.lockerNoFilter || undefined
},
this.pageIndex * this.pageSize,
this.pageSize
)
.subscribe({
next: (res: any) => {
this.lockers = res?.pageItems ?? [];
this.totalRecords = Number(res?.totalFilteredRecords ?? 0);
},
error: () => {
this.lockers = [];
this.totalRecords = 0;
this.loadFailed = true;
}
});
}
private loadSummary(): void {
this.lockerService.getLockerSummary(this.officeFilter || undefined).subscribe({
next: (res: any) => (this.summary = res ?? this.summary),
error: () => (this.summary = { total: 0, vacant: 0, allotted: 0, blocked: 0 })
});
}
/** Class choices come from the active class master rows of the branch being viewed. */
private loadClassCodes(): void {
this.lockerService.getClasses('ACTIVE', 0, 500, this.officeFilter || undefined).subscribe({
next: (res: any) => {
const codes = (res?.pageItems ?? []).map((c: any) => c.classCode);
this.classCodes = Array.from(new Set<string>(codes)).sort();
if (this.classFilter && !this.classCodes.includes(this.classFilter)) {
this.classFilter = '';
}
},
error: () => (this.classCodes = [])
});
}
}
+75
View File
@@ -0,0 +1,75 @@
/**
* 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 { NgModule } from '@angular/core';
import { Routes, RouterModule } from '@angular/router';
/** Routing Imports */
import { Route } from '../core/route/route.service';
/** Custom Components */
import { LockerDashboardComponent } from './locker-dashboard/locker-dashboard.component';
import { ClassMasterComponent } from './class-master/class-master.component';
import { LockerListComponent } from './locker-list/locker-list.component';
import { WaitingListComponent } from './waiting-list/waiting-list.component';
import { AllotmentListComponent } from './allotment/allotment-list/allotment-list.component';
import { AllotmentFormComponent } from './allotment/allotment-form/allotment-form.component';
/** Safe Deposit Locker Routes */
const routes: Routes = [
Route.withShell([
{
path: 'locker',
data: { title: 'Locker', breadcrumb: 'Locker' },
children: [
{
path: '',
component: LockerDashboardComponent
},
{
path: 'class-master',
component: ClassMasterComponent,
data: { title: 'Class Master', breadcrumb: 'Class Master' }
},
{
path: 'lockers',
component: LockerListComponent,
data: { title: 'Locker List', breadcrumb: 'Locker List' }
},
{
path: 'waiting-list',
component: WaitingListComponent,
data: { title: 'Waiting List', breadcrumb: 'Waiting List' }
},
{
path: 'allotment',
component: AllotmentListComponent,
data: { title: 'Locker Allotment', breadcrumb: 'Allotment' }
},
{
// Separate from ':id' so saving a new application loads the saved one in a fresh component.
path: 'allotment/new',
component: AllotmentFormComponent,
data: { title: 'New Locker Allotment', breadcrumb: 'New Allotment' }
},
{
path: 'allotment/:id',
component: AllotmentFormComponent,
data: { title: 'Locker Application', routeParamBreadcrumb: 'id' }
}
]
}
])
];
@NgModule({
imports: [RouterModule.forChild(routes)],
exports: [RouterModule]
})
export class LockerRoutingModule {}
+46
View File
@@ -0,0 +1,46 @@
/**
* 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 { NgModule } from '@angular/core';
/** Custom Modules */
import { SharedModule } from '../shared/shared.module';
import { DirectivesModule } from '../directives/directives.module';
import { PipesModule } from 'app/pipes/pipes.module';
import { LockerRoutingModule } from './locker-routing.module';
/** Custom Components (standalone) */
import { LockerDashboardComponent } from './locker-dashboard/locker-dashboard.component';
import { ClassMasterComponent } from './class-master/class-master.component';
import { LockerListComponent } from './locker-list/locker-list.component';
import { WaitingListComponent } from './waiting-list/waiting-list.component';
import { AllotmentListComponent } from './allotment/allotment-list/allotment-list.component';
import { AllotmentFormComponent } from './allotment/allotment-form/allotment-form.component';
/**
* Safe Deposit Locker Module.
*
* Class master with member / non-member rates, locker inventory, waiting list, allotment, rent collection,
* operation register and key / break / freeze / surrender lifecycle. Lockers are allotted only to existing clients.
*/
@NgModule({
imports: [
SharedModule,
DirectivesModule,
PipesModule,
LockerRoutingModule,
LockerDashboardComponent,
ClassMasterComponent,
LockerListComponent,
WaitingListComponent,
AllotmentListComponent,
AllotmentFormComponent
]
})
export class LockerModule {}
+152
View File
@@ -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/.
*/
/** Angular Imports */
import { Injectable, inject } from '@angular/core';
import { HttpClient, HttpParams } from '@angular/common/http';
import { Observable } from 'rxjs';
/**
* Safe Deposit Locker — class master (member / non-member rent and deposit per locker number range), locker
* inventory, waiting list, allotment, rent, operation register and lifecycle.
*/
@Injectable({
providedIn: 'root'
})
export class LockerService {
private http = inject(HttpClient);
// ----- Class Master -----
/**
* status: 'ACTIVE' | 'INACTIVE'. Server-side paginated — returns { pageItems, totalFilteredRecords }.
* The server confines everyone but the Super user to their own branch, whatever officeId is sent.
*/
getClasses(status?: string, offset = 0, limit = 20, officeId?: number): Observable<any> {
let params = new HttpParams().set('offset', offset).set('limit', limit);
if (status) params = params.set('status', status);
if (officeId) params = params.set('officeId', officeId);
return this.http.get('/lockerclasses', { params });
}
createClass(payload: any): Observable<any> {
return this.http.post('/lockerclasses', payload);
}
updateClass(id: string | number, payload: any): Observable<any> {
return this.http.put(`/lockerclasses/${id}`, payload);
}
/** Refused by the server while any locker of the class is allotted. */
deactivateClass(id: string | number): Observable<any> {
return this.http.delete(`/lockerclasses/${id}`);
}
// ----- Locker inventory (read-only) -----
/** status: 'VACANT' | 'ALLOTTED' | 'BLOCKED'. Server-side paginated — returns { pageItems, totalFilteredRecords }. */
getLockers(
filters: { officeId?: number; classCode?: string; status?: string; lockerNo?: number },
offset = 0,
limit = 20
): Observable<any> {
let params = new HttpParams().set('offset', offset).set('limit', limit);
if (filters.officeId) params = params.set('officeId', filters.officeId);
if (filters.classCode) params = params.set('classCode', filters.classCode);
if (filters.status) params = params.set('status', filters.status);
if (filters.lockerNo) params = params.set('lockerNo', filters.lockerNo);
return this.http.get('/lockers', { params });
}
/** { total, vacant, allotted, blocked } for the branches the user may see. */
getLockerSummary(officeId?: number): Observable<any> {
let params = new HttpParams();
if (officeId) params = params.set('officeId', officeId);
return this.http.get('/lockers/summary', { params });
}
// ----- Waiting List -----
/** status: 'PENDING' | 'ALLOTTED' | 'CANCELLED'. Oldest inquiry first. */
getWaitingList(status?: string, offset = 0, limit = 20, officeId?: number): Observable<any> {
let params = new HttpParams().set('offset', offset).set('limit', limit);
if (status) params = params.set('status', status);
if (officeId) params = params.set('officeId', officeId);
return this.http.get('/lockerwaitinglist', { params });
}
createWaitingListEntry(payload: any): Observable<any> {
return this.http.post('/lockerwaitinglist', payload);
}
updateWaitingListEntry(id: string | number, payload: any): Observable<any> {
return this.http.put(`/lockerwaitinglist/${id}`, payload);
}
/** Clients whose name contains the text, case-insensitive — one row per account, so callers de-duplicate by clientId. */
searchClientsByName(name: string): Observable<any[]> {
return this.http.get<any[]>('/customer-search', { params: new HttpParams().set('name', name) });
}
/** Name, contact and first address of a client, to pre-fill an inquiry. Limited to the user's branch scope. */
getWaitingListClientContact(clientId: number): Observable<any> {
return this.http.get(`/lockerwaitinglist/clientcontact/${clientId}`);
}
cancelWaitingListEntry(id: string | number, additionalRemarks?: string): Observable<any> {
const body: any = {};
if (additionalRemarks) body.additionalRemarks = additionalRemarks;
return this.http.post(`/lockerwaitinglist/${id}?command=cancel`, body);
}
// ----- Allotment -----
/** status: 'PENDING_APPROVAL' | 'ACTIVE' | 'REJECTED' | 'CLOSED'. Server-side paginated. */
getAllotments(status?: string, offset = 0, limit = 20, officeId?: number): Observable<any> {
let params = new HttpParams().set('offset', offset).set('limit', limit);
if (status) params = params.set('status', status);
if (officeId) params = params.set('officeId', officeId);
return this.http.get('/lockerallotments', { params });
}
getAllotment(id: string | number): Observable<any> {
return this.http.get(`/lockerallotments/${id}`);
}
/**
* Everything the allotment form fills from the client: contact, address, gender, occupation, PAN, masked Aadhaar,
* membership (decides member rates), active savings accounts (rent debit) and fixed deposits (security deposit).
*/
getAllotmentClientDetails(clientId: number): Observable<any> {
return this.http.get(`/lockerallotments/clientdetails/${clientId}`);
}
createAllotment(payload: any): Observable<any> {
return this.http.post('/lockerallotments', payload);
}
updateAllotment(id: string | number, payload: any): Observable<any> {
return this.http.put(`/lockerallotments/${id}`, payload);
}
/** Branch Manager's decision on a pending application; reject needs a reason. */
approveAllotment(id: string | number): Observable<any> {
return this.http.post(`/lockerallotments/${id}?command=approve`, {});
}
rejectAllotment(id: string | number, reason: string): Observable<any> {
return this.http.post(`/lockerallotments/${id}?command=reject`, { rejectionReason: reason });
}
// ----- Shared -----
/** Branches the logged-in user may see — used for the Super user's branch picker. */
getOffices(): Observable<any> {
return this.http.get('/offices');
}
}
@@ -0,0 +1,178 @@
/**
* 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,
DestroyRef,
EventEmitter,
Input,
OnChanges,
OnInit,
Output,
SimpleChanges,
inject
} from '@angular/core';
import { takeUntilDestroyed } from '@angular/core/rxjs-interop';
import { FormControl, ReactiveFormsModule } from '@angular/forms';
import { MatAutocompleteModule, MatAutocompleteSelectedEvent } from '@angular/material/autocomplete';
import { of } from 'rxjs';
import { catchError, debounceTime, distinctUntilChanged, filter, map, switchMap } from 'rxjs/operators';
import { STANDALONE_SHARED_IMPORTS } from 'app/standalone-shared.module';
/** Custom Services */
import { LockerService } from '../../locker.service';
/** What a pick hands back — enough to show the client and link them. */
export interface PickedClient {
clientId: number;
displayName: string;
mobileNo?: string | null;
officeName?: string | null;
}
/**
* Client name box with type-ahead (case-insensitive, from 2 letters) over existing clients. Emits the picked client,
* and emits null as soon as the text is changed away from it, so a stale client id is never kept for another name.
*/
@Component({
selector: 'mifosx-locker-client-picker',
template: `
<mat-form-field appearance="fill" class="picker-field">
<mat-label>{{ label | translate }}</mat-label>
<input
matInput
[formControl]="nameControl"
[matAutocomplete]="auto"
[required]="required"
autocomplete="off"
[readonly]="disabled"
/>
<mat-autocomplete #auto="matAutocomplete" [displayWith]="displayName" (optionSelected)="onSelected($event)">
@for (c of suggestions; track c.clientId) {
<mat-option [value]="c">
<span class="opt-name">{{ c.displayName }}</span>
<span class="opt-meta">
{{ 'ID' | translate }} {{ c.clientId }}
@if (c.mobileNo) {
· {{ c.mobileNo }}
}
@if (c.officeName) {
· {{ c.officeName }}
}
</span>
</mat-option>
}
</mat-autocomplete>
@if (pickedId) {
<mat-hint class="picked">{{ 'Client ID' | translate }} {{ pickedId }}</mat-hint>
} @else if (!disabled) {
<mat-hint>{{ 'Type 2+ letters and pick a client' | translate }}</mat-hint>
}
</mat-form-field>
`,
styles: [
`
.picker-field {
width: 100%;
}
.opt-name {
display: block;
font-weight: 500;
line-height: 1.2;
}
.opt-meta {
display: block;
font-size: 0.75rem;
color: rgb(0 0 0 / 55%);
line-height: 1.2;
}
.picked {
color: #1976d2;
font-weight: 600;
}
`
],
imports: [
...STANDALONE_SHARED_IMPORTS,
ReactiveFormsModule,
MatAutocompleteModule
]
})
export class LockerClientPickerComponent implements OnInit, OnChanges {
private lockerService = inject(LockerService);
private destroyRef = inject(DestroyRef);
@Input() label = 'Client';
@Input() required = false;
@Input() disabled = false;
/** Pre-selected client, e.g. when an existing application is opened. */
@Input() clientId: number | null = null;
@Input() clientName: string | null = null;
/** Client ids that may not be picked here (e.g. the main applicant in the joint-holder list). */
@Input() excludeIds: number[] = [];
@Output() picked = new EventEmitter<PickedClient | null>();
nameControl = new FormControl<any>('');
suggestions: PickedClient[] = [];
pickedId: number | null = null;
private pickedName = '';
ngOnInit(): void {
this.nameControl.valueChanges
.pipe(
// A picked option puts the object in the box; only typed text counts as a change.
filter((value: any): value is string => typeof value === 'string'),
debounceTime(300),
map((value: string) => value.trim()),
distinctUntilChanged(),
switchMap((name: string) => {
if (this.pickedId && name !== this.pickedName) {
this.pickedId = null;
this.pickedName = '';
this.picked.emit(null);
}
if (name.length < 2 || this.pickedId) {
return of([]);
}
return this.lockerService.searchClientsByName(name).pipe(catchError(() => of([])));
}),
takeUntilDestroyed(this.destroyRef)
)
.subscribe((rows: any[]) => {
// The search returns one row per account; keep one per client.
const seen = new Set<number>();
this.suggestions = (rows ?? [])
.filter(
(r: any) =>
r?.clientId && !this.excludeIds.includes(r.clientId) && !seen.has(r.clientId) && seen.add(r.clientId)
)
.slice(0, 10);
});
}
ngOnChanges(changes: SimpleChanges): void {
if (changes['clientId'] || changes['clientName']) {
this.pickedId = this.clientId ?? null;
this.pickedName = this.clientName ?? '';
this.nameControl.setValue(this.pickedName, { emitEvent: false });
}
}
onSelected(event: MatAutocompleteSelectedEvent): void {
const client: PickedClient = event.option.value;
this.pickedId = client.clientId;
this.pickedName = client.displayName;
this.suggestions = [];
this.nameControl.setValue(client.displayName, { emitEvent: false });
this.picked.emit(client);
}
displayName = (value: any): string => (typeof value === 'string' ? value : (value?.displayName ?? ''));
}
@@ -0,0 +1,293 @@
<!--
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="container">
<div class="page-header">
<h2>{{ 'Waiting List' | translate }}</h2>
<div class="header-actions">
<button
mat-raised-button
color="primary"
(click)="openAddForm()"
*mifosxHasPermission="'CREATE_LOCKERWAITINGLIST'"
>
<fa-icon icon="plus"></fa-icon>&nbsp;{{ 'Add to Waiting List' | translate }}
</button>
<button mat-raised-button [routerLink]="['/locker']">{{ 'labels.buttons.Back' | translate }}</button>
</div>
</div>
@if (showForm) {
<mat-card class="add-form">
<mat-card-content>
<h3 class="form-title">
{{ (editingId === null ? 'New Inquiry' : 'Edit Inquiry') | translate }}
<span class="sr-no">{{ 'Waiting No' | translate }}: {{ editingId ?? ('Auto' | translate) }}</span>
</h3>
<form [formGroup]="entryForm" (ngSubmit)="submit()">
<div class="section-label">{{ 'Inquiry' | translate }}</div>
<div class="form-grid">
@if (isSuperUser) {
<mat-form-field appearance="fill">
<mat-label>{{ 'Branch' | translate }}</mat-label>
<mat-select formControlName="officeId" (selectionChange)="onOfficeChange()" required>
@for (office of offices; track office.id) {
<mat-option [value]="office.id">{{ office.name }}</mat-option>
}
</mat-select>
</mat-form-field>
} @else {
<div class="info-box">
<span class="info-label">{{ 'Branch' | translate }}</span>
<span class="info-value">{{ ownOfficeName }}</span>
</div>
}
<mat-form-field appearance="fill">
<mat-label>{{ 'Inquiry Date' | translate }}</mat-label>
<input matInput [matDatepicker]="inqPicker" formControlName="inquiryDate" [max]="maxDate" required />
<mat-datepicker-toggle matSuffix [for]="inqPicker"></mat-datepicker-toggle>
<mat-datepicker #inqPicker></mat-datepicker>
</mat-form-field>
<mat-form-field appearance="fill">
<mat-label>{{ 'Customer Name' | translate }}</mat-label>
<input
matInput
formControlName="customerName"
[matAutocomplete]="clientAuto"
autocomplete="off"
required
/>
<mat-autocomplete
#clientAuto="matAutocomplete"
[displayWith]="displayClientName"
(optionSelected)="onClientSelected($event)"
>
@for (c of clientSuggestions; track c.clientId) {
<mat-option [value]="c">
<span class="opt-name">{{ c.displayName }}</span>
<span class="opt-meta">
{{ 'ID' | translate }} {{ c.clientId }}
@if (c.mobileNo) {
· {{ c.mobileNo }}
}
@if (c.officeName) {
· {{ c.officeName }}
}
</span>
</mat-option>
}
</mat-autocomplete>
<mat-hint>{{ 'Type 2+ letters to pick an existing client, or enter a new name' | translate }}</mat-hint>
</mat-form-field>
</div>
@if (linkedClient) {
<div class="client-bar">
<span class="client-chip">
<fa-icon icon="user"></fa-icon>&nbsp;{{ 'Existing client' | translate }}: {{ linkedClient.name }} ({{
'Client ID' | translate
}}
{{ linkedClient.id }})
<button
mat-icon-button
type="button"
class="chip-remove"
[matTooltip]="'Remove client link' | translate"
(click)="unlinkClient()"
>
<fa-icon icon="times"></fa-icon>
</button>
</span>
</div>
}
<div class="section-label">{{ 'Address and Contact' | translate }}</div>
<div class="form-grid">
<mat-form-field appearance="fill">
<mat-label>{{ 'Address Line 1' | translate }}</mat-label>
<input matInput formControlName="addressLine1" required />
</mat-form-field>
<mat-form-field appearance="fill">
<mat-label>{{ 'Address Line 2' | translate }}</mat-label>
<input matInput formControlName="addressLine2" />
</mat-form-field>
<mat-form-field appearance="fill">
<mat-label>{{ 'Address Line 3' | translate }}</mat-label>
<input matInput formControlName="addressLine3" />
</mat-form-field>
<mat-form-field appearance="fill">
<mat-label>{{ 'City' | translate }}</mat-label>
<input matInput formControlName="city" required />
</mat-form-field>
<mat-form-field appearance="fill">
<mat-label>{{ 'Pincode' | translate }}</mat-label>
<input matInput formControlName="pincode" maxlength="6" required />
@if (entryForm.get('pincode')?.hasError('pattern')) {
<mat-error>{{ 'Pincode must be 6 digits' | translate }}</mat-error>
}
</mat-form-field>
<mat-form-field appearance="fill">
<mat-label>{{ 'Phone' | translate }}</mat-label>
<input matInput formControlName="phone" maxlength="15" />
@if (entryForm.get('phone')?.hasError('pattern')) {
<mat-error>{{ 'Phone must be 10 to 15 digits' | translate }}</mat-error>
}
</mat-form-field>
<mat-form-field appearance="fill">
<mat-label>{{ 'Email' | translate }}</mat-label>
<input matInput formControlName="email" />
@if (entryForm.get('email')?.hasError('email')) {
<mat-error>{{ 'Email is not valid' | translate }}</mat-error>
}
</mat-form-field>
</div>
<div class="section-label">{{ 'Locker Class' | translate }} *</div>
@if (classCodes.length === 0) {
<div class="form-note">{{ 'No active locker class at this branch. Add one in Class Master first.' }}</div>
} @else {
<div class="class-checks">
@for (code of classCodes; track code) {
<mat-checkbox [checked]="selectedClasses.has(code)" (change)="toggleClass(code, $event.checked)">{{
code
}}</mat-checkbox>
}
</div>
@if (selectedClasses.size === 0) {
<div class="form-error">{{ 'Choose at least one locker class' | translate }}</div>
}
}
<div class="section-label">{{ 'Remarks' | translate }}</div>
<mat-form-field appearance="fill" class="full-width">
<mat-label>{{ 'Remarks' | translate }}</mat-label>
<textarea matInput rows="2" formControlName="remarks"></textarea>
</mat-form-field>
<div class="form-actions">
<button mat-raised-button type="button" (click)="cancelForm()">
{{ 'labels.buttons.Cancel' | translate }}
</button>
<button
mat-raised-button
color="primary"
type="submit"
[disabled]="entryForm.invalid || selectedClasses.size === 0 || busy"
>
{{ 'labels.buttons.Submit' | translate }}
</button>
</div>
</form>
</mat-card-content>
</mat-card>
}
<div class="filter-row">
@for (s of statuses; track s) {
<button mat-stroked-button [class.active]="statusFilter === s" (click)="filterByStatus(s)">{{ s }}</button>
}
</div>
<div class="table-wrapper mat-elevation-z1">
<table mat-table [dataSource]="entries">
<ng-container matColumnDef="waitingNo">
<th mat-header-cell *matHeaderCellDef class="num">{{ 'Waiting No' | translate }}</th>
<td mat-cell *matCellDef="let e" class="num">
<strong>{{ e.id }}</strong>
</td>
</ng-container>
<ng-container matColumnDef="office">
<th mat-header-cell *matHeaderCellDef>{{ 'Branch' | translate }}</th>
<td mat-cell *matCellDef="let e">{{ e.officeName }}</td>
</ng-container>
<ng-container matColumnDef="inquiryDate">
<th mat-header-cell *matHeaderCellDef>{{ 'Inquiry Date' | translate }}</th>
<td mat-cell *matCellDef="let e">{{ e.inquiryDate | dateFormat }}</td>
</ng-container>
<ng-container matColumnDef="customerName">
<th mat-header-cell *matHeaderCellDef>{{ 'Customer Name' | translate }}</th>
<td mat-cell *matCellDef="let e">
{{ e.customerName }}
@if (e.clientId) {
<span class="pill info client-tag" [matTooltip]="'Existing client' | translate"
>{{ 'Client' | translate }} #{{ e.clientId }}</span
>
}
@if (e.remarks) {
<br /><span class="sub">{{ e.remarks }}</span>
}
</td>
</ng-container>
<ng-container matColumnDef="city">
<th mat-header-cell *matHeaderCellDef>{{ 'City' | translate }}</th>
<td mat-cell *matCellDef="let e">{{ e.city }}</td>
</ng-container>
<ng-container matColumnDef="phone">
<th mat-header-cell *matHeaderCellDef>{{ 'Phone' | translate }}</th>
<td mat-cell *matCellDef="let e">{{ e.phone || '-' }}</td>
</ng-container>
<ng-container matColumnDef="classes">
<th mat-header-cell *matHeaderCellDef>{{ 'Locker Class' | translate }}</th>
<td mat-cell *matCellDef="let e">
@for (code of e.preferredClasses; track code) {
<span class="pill neutral class-pill">{{ code }}</span>
}
</td>
</ng-container>
<ng-container matColumnDef="status">
<th mat-header-cell *matHeaderCellDef>{{ 'labels.inputs.Status' | translate }}</th>
<td mat-cell *matCellDef="let e">
<span class="pill" [ngClass]="e.status === 'PENDING' ? 'warn' : e.status === 'ALLOTTED' ? 'ok' : 'neutral'">{{
e.status
}}</span>
</td>
</ng-container>
<ng-container matColumnDef="actions">
<th mat-header-cell *matHeaderCellDef></th>
<td mat-cell *matCellDef="let e" class="actions-cell">
@if (e.status === 'PENDING') {
<button
mat-icon-button
color="primary"
[matTooltip]="'Edit' | translate"
(click)="edit(e)"
*mifosxHasPermission="'UPDATE_LOCKERWAITINGLIST'"
>
<fa-icon icon="pen"></fa-icon>
</button>
<button
mat-icon-button
color="warn"
[matTooltip]="'Cancel' | translate"
(click)="cancelEntry(e)"
*mifosxHasPermission="'CANCEL_LOCKERWAITINGLIST'"
>
<fa-icon icon="ban"></fa-icon>
</button>
}
</td>
</ng-container>
<tr mat-header-row *matHeaderRowDef="columns"></tr>
<tr mat-row *matRowDef="let row; columns: columns"></tr>
</table>
@if (loadFailed) {
<div class="empty-state">{{ 'Could not load the waiting list.' }}</div>
} @else if (entries.length === 0) {
<div class="empty-state">{{ 'labels.text.No records to display' | translate }}</div>
}
@if (totalRecords > 0) {
<mat-paginator
[length]="totalRecords"
[pageSize]="pageSize"
[pageSizeOptions]="pageSizeOptions"
[pageIndex]="pageIndex"
showFirstLastButtons
(page)="onPageChange($event)"
></mat-paginator>
}
</div>
</div>
@@ -0,0 +1,226 @@
/**
* 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/.
*/
.container {
padding: 16px;
}
.page-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 16px;
}
.header-actions {
display: flex;
gap: 12px;
}
.filter-row {
display: flex;
gap: 8px;
margin-bottom: 16px;
button.active {
background: rgb(25 118 210 / 12%);
}
}
.add-form {
margin-bottom: 16px;
}
.form-title {
margin: 0 0 12px;
font-size: 1.05rem;
font-weight: 600;
}
.sr-no {
margin-left: 12px;
padding: 2px 8px;
border-radius: 6px;
background: rgb(0 0 0 / 6%);
color: rgb(0 0 0 / 60%);
font-size: 0.8rem;
font-weight: 500;
}
.section-label {
font-size: 0.75rem;
font-weight: 600;
text-transform: uppercase;
letter-spacing: 0.04em;
color: rgb(0 0 0 / 55%);
margin: 12px 0 8px;
}
.form-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(220px, 1fr));
gap: 12px 20px;
margin-bottom: 8px;
}
mat-form-field {
width: 100%;
}
.info-box {
display: flex;
flex-direction: column;
justify-content: center;
padding: 0 12px;
border-radius: 6px;
background: rgb(25 118 210 / 6%);
min-height: 56px;
}
.info-label {
font-size: 0.75rem;
color: rgb(0 0 0 / 60%);
}
.info-value {
font-size: 1rem;
font-weight: 600;
}
.client-bar {
display: flex;
flex-wrap: wrap;
align-items: center;
gap: 12px;
margin-bottom: 4px;
.form-note {
margin: 0;
}
}
.opt-name {
display: block;
font-weight: 500;
line-height: 1.2;
}
.opt-meta {
display: block;
font-size: 0.75rem;
color: rgb(0 0 0 / 55%);
line-height: 1.2;
}
.client-chip {
display: inline-flex;
align-items: center;
padding: 2px 4px 2px 12px;
border-radius: 16px;
background: #e3f0fc;
color: #1976d2;
font-weight: 600;
font-size: 0.85rem;
}
.chip-remove {
width: 32px;
height: 32px;
padding: 4px;
}
.client-tag {
margin-left: 6px;
}
.class-checks {
display: flex;
flex-wrap: wrap;
gap: 4px 24px;
margin-bottom: 8px;
}
.form-error {
color: #d32f2f;
font-size: 0.85rem;
margin: 0 0 8px;
}
.form-note {
color: rgb(0 0 0 / 60%);
font-size: 0.85rem;
margin: 0 0 8px;
}
.form-actions {
display: flex;
justify-content: flex-end;
gap: 12px;
margin-top: 16px;
}
.table-wrapper {
overflow-x: auto;
background: #fff;
}
table {
width: 100%;
}
td.num,
th.num {
text-align: right;
font-variant-numeric: tabular-nums;
}
.sub {
font-size: 0.8rem;
color: rgb(0 0 0 / 55%);
}
.actions-cell {
white-space: nowrap;
}
.class-pill {
margin-right: 4px;
}
.pill {
font-size: 11px;
padding: 2px 8px;
border-radius: 6px;
font-weight: 600;
&.ok {
background: #dff5e8;
color: #28c07a;
}
&.info {
background: #e3f0fc;
color: #1976d2;
}
&.warn {
background: #fdf1dc;
color: #b45309;
}
&.neutral {
background: #e0e0e0;
color: #757575;
}
}
.empty-state {
padding: 24px;
text-align: center;
color: rgb(0 0 0 / 50%);
}
@@ -0,0 +1,447 @@
/**
* 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, DestroyRef, OnInit, inject } from '@angular/core';
import { takeUntilDestroyed } from '@angular/core/rxjs-interop';
import { UntypedFormBuilder, UntypedFormGroup, Validators } from '@angular/forms';
import { MatAutocompleteModule, MatAutocompleteSelectedEvent } from '@angular/material/autocomplete';
import { of } from 'rxjs';
import { catchError, debounceTime, distinctUntilChanged, filter, map, switchMap } from 'rxjs/operators';
import { RouterLink } from '@angular/router';
import { MatButtonModule } from '@angular/material/button';
import { MatTableModule } from '@angular/material/table';
import { MatPaginatorModule, PageEvent } from '@angular/material/paginator';
import { MatSnackBar, MatSnackBarModule } from '@angular/material/snack-bar';
import { MatTooltipModule } from '@angular/material/tooltip';
import { MatDialog } from '@angular/material/dialog';
import { FaIconComponent } from '@fortawesome/angular-fontawesome';
import { STANDALONE_SHARED_IMPORTS } from 'app/standalone-shared.module';
/** Custom Services */
import { LockerService } from '../locker.service';
import { SettingsService } from 'app/settings/settings.service';
import { AuthenticationService } from 'app/core/authentication/authentication.service';
import { Dates } from 'app/core/utils/dates';
import { CancelDialogComponent } from 'app/shared/cancel-dialog/cancel-dialog.component';
/**
* Locker waiting list — inquiries taken at the counter, oldest first. The inquirer need not be a client; the entry
* number is the waiting number handed to them. Editable and cancellable only while PENDING; allotment will mark it
* ALLOTTED.
*/
@Component({
selector: 'mifosx-locker-waiting-list',
templateUrl: './waiting-list.component.html',
styleUrls: ['./waiting-list.component.scss'],
imports: [
...STANDALONE_SHARED_IMPORTS,
RouterLink,
MatButtonModule,
MatTableModule,
MatPaginatorModule,
MatSnackBarModule,
MatTooltipModule,
MatAutocompleteModule,
FaIconComponent
]
})
export class WaitingListComponent implements OnInit {
private destroyRef = inject(DestroyRef);
private formBuilder = inject(UntypedFormBuilder);
private lockerService = inject(LockerService);
private settingsService = inject(SettingsService);
private authenticationService = inject(AuthenticationService);
private dateUtils = inject(Dates);
private dialog = inject(MatDialog);
private snackBar = inject(MatSnackBar);
private notify(message: string, isError = false): void {
this.snackBar.open(message, 'Dismiss', { duration: isError ? 6000 : 3000 });
}
private serverMessage(err: any, fallback: string): string {
const body = err?.error;
return body?.errors?.[0]?.defaultUserMessage || body?.defaultUserMessage || fallback;
}
entries: any[] = [];
showForm = false;
busy = false;
loadFailed = false;
/** Waiting number of the entry being edited; null while adding. */
editingId: number | null = null;
isSuperUser = false;
ownOfficeId: number | null = null;
ownOfficeName = '';
offices: any[] = [];
/** The existing client the inquiry is linked to, if the inquirer already banks here; null for a walk-in. */
linkedClient: { id: number; name: string } | null = null;
/** Clients matching what is typed in Customer Name, one per client. */
clientSuggestions: any[] = [];
/** Active class codes of the branch the form is for. */
classCodes: string[] = [];
selectedClasses = new Set<string>();
statusFilter = 'PENDING';
pageSize = 10;
pageIndex = 0;
totalRecords = 0;
pageSizeOptions = [
10,
25,
50,
100
];
statuses = [
'PENDING',
'ALLOTTED',
'CANCELLED'
];
/** The bank's business date, not the PC clock — the server rejects an inquiry dated after it. */
maxDate: Date = this.businessDate();
entryForm: UntypedFormGroup;
columns = [
'waitingNo',
'office',
'inquiryDate',
'customerName',
'city',
'phone',
'classes',
'status',
'actions'
];
constructor() {
this.entryForm = this.formBuilder.group({
officeId: [''],
inquiryDate: [
this.maxDate,
Validators.required
],
customerName: [
'',
[
Validators.required,
Validators.maxLength(100)
]
],
addressLine1: [
'',
[
Validators.required,
Validators.maxLength(150)
]
],
addressLine2: [
'',
Validators.maxLength(150)
],
addressLine3: [
'',
Validators.maxLength(150)
],
city: [
'',
[
Validators.required,
Validators.maxLength(60)
]
],
pincode: [
'',
[
Validators.required,
Validators.pattern(/^\d{6}$/)
]
],
phone: [
'',
Validators.pattern(/^\d{10,15}$/)
],
email: [
'',
[
Validators.email,
Validators.maxLength(100)
]
],
remarks: [
'',
Validators.maxLength(500)
]
});
}
ngOnInit(): void {
const credentials = this.authenticationService.getCredentials();
this.isSuperUser = !!credentials?.permissions?.includes('ALL_FUNCTIONS');
this.ownOfficeId = credentials?.officeId ?? null;
this.ownOfficeName = credentials?.officeName ?? '';
if (this.isSuperUser) {
this.lockerService.getOffices().subscribe({
next: (offices: any[]) => (this.offices = offices ?? []),
error: () => (this.offices = [])
});
}
this.watchCustomerName();
this.loadEntries();
}
loadEntries(): void {
this.loadFailed = false;
this.lockerService
.getWaitingList(this.statusFilter || undefined, this.pageIndex * this.pageSize, this.pageSize)
.subscribe({
next: (res: any) => {
this.entries = res?.pageItems ?? [];
this.totalRecords = Number(res?.totalFilteredRecords ?? 0);
},
error: () => {
this.entries = [];
this.totalRecords = 0;
this.loadFailed = true;
}
});
}
filterByStatus(status: string): void {
this.statusFilter = this.statusFilter === status ? '' : status;
this.pageIndex = 0;
this.loadEntries();
}
onPageChange(event: PageEvent): void {
this.pageSize = event.pageSize;
this.pageIndex = event.pageIndex;
this.loadEntries();
}
/** Class checkboxes follow the branch picked on the form (always the user's own for non-Super users). */
loadClassCodes(officeId: number | null): void {
this.lockerService.getClasses('ACTIVE', 0, 500, officeId || undefined).subscribe({
next: (res: any) => {
const rows = (res?.pageItems ?? []).filter((c: any) => !officeId || c.officeId === officeId);
this.classCodes = Array.from(new Set<string>(rows.map((c: any) => c.classCode))).sort();
// A class that is no longer offered stays ticked only if it is still available.
this.selectedClasses = new Set([...this.selectedClasses].filter((code) => this.classCodes.includes(code)));
},
error: () => (this.classCodes = [])
});
}
onOfficeChange(): void {
this.selectedClasses.clear();
this.loadClassCodes(Number(this.entryForm.get('officeId')?.value) || null);
}
toggleClass(code: string, checked: boolean): void {
if (checked) {
this.selectedClasses.add(code);
} else {
this.selectedClasses.delete(code);
}
}
openAddForm(): void {
this.editingId = null;
this.maxDate = this.businessDate();
this.entryForm.reset({ officeId: this.ownOfficeId ?? '', inquiryDate: this.maxDate });
if (this.isSuperUser) {
this.entryForm.get('officeId')?.enable();
} else {
this.entryForm.get('officeId')?.disable();
}
this.selectedClasses.clear();
this.linkedClient = null;
this.clientSuggestions = [];
this.loadClassCodes(this.ownOfficeId);
this.showForm = true;
}
/**
* Typing in Customer Name looks up existing clients (case-insensitive, from 2 letters). Changing the name away from
* a picked client drops the link, so the entry never carries a client id for someone else's name.
*/
private watchCustomerName(): void {
this.entryForm
.get('customerName')!
.valueChanges.pipe(
// Picking a suggestion puts the option object in the field; only typed text counts as a name change,
// otherwise a fast client lookup would be undone by this very selection.
filter((value: any): value is string => typeof value === 'string'),
debounceTime(300),
map((value: string) => value.trim()),
distinctUntilChanged(),
switchMap((name: string) => {
if (this.linkedClient && name !== this.linkedClient.name) {
this.linkedClient = null;
}
if (name.length < 2 || this.linkedClient) {
return of([]);
}
return this.lockerService.searchClientsByName(name).pipe(catchError(() => of([])));
}),
takeUntilDestroyed(this.destroyRef)
)
.subscribe((rows: any[]) => {
// The search returns one row per account; keep the first row of each client.
const seen = new Set<number>();
this.clientSuggestions = (rows ?? [])
.filter((r: any) => r?.clientId && !seen.has(r.clientId) && seen.add(r.clientId))
.slice(0, 10);
});
}
/** Fills name, address and contact from the picked client and links the inquiry to it. */
onClientSelected(event: MatAutocompleteSelectedEvent): void {
const picked = event.option.value;
this.clientSuggestions = [];
this.lockerService.getWaitingListClientContact(picked.clientId).subscribe({
next: (client: any) => {
this.linkedClient = { id: client.clientId, name: client.displayName };
// Only overwrite what the client record actually has, so anything already typed is kept otherwise.
const patch: any = {};
if (client.addressLine1) patch.addressLine1 = client.addressLine1;
if (client.addressLine2) patch.addressLine2 = client.addressLine2;
if (client.addressLine3) patch.addressLine3 = client.addressLine3;
if (client.city) patch.city = client.city;
if (client.pincode) patch.pincode = client.pincode;
if (client.mobileNo) patch.phone = client.mobileNo;
if (client.email) patch.email = client.email;
this.entryForm.patchValue(patch);
this.entryForm.get('customerName')?.setValue(client.displayName, { emitEvent: false });
if (!client.addressLine1 || !client.city || !client.pincode) {
this.notify('Client has no complete address on record — please fill the missing fields.');
}
},
error: (err: any) => {
this.linkedClient = null;
this.notify(this.serverMessage(err, 'Could not load the client details'), true);
}
});
}
/** The option value is the search row; the field must show just the name. */
displayClientName = (value: any): string => (typeof value === 'string' ? value : (value?.displayName ?? ''));
/** Keeps the typed details but stops treating the inquirer as that client. */
unlinkClient(): void {
this.linkedClient = null;
}
edit(row: any): void {
this.editingId = row.id;
this.entryForm.reset({
officeId: row.officeId,
inquiryDate: row.inquiryDate ? this.dateUtils.parseDate(row.inquiryDate) : this.maxDate,
customerName: row.customerName,
addressLine1: row.addressLine1,
addressLine2: row.addressLine2 ?? '',
addressLine3: row.addressLine3 ?? '',
city: row.city,
pincode: row.pincode,
phone: row.phone ?? '',
email: row.email ?? '',
remarks: row.remarks ?? ''
});
// The branch an inquiry was taken at never changes.
this.entryForm.get('officeId')?.disable();
// Keyed to the name shown in the field, so the link survives until that name is actually changed.
this.linkedClient = row.clientId ? { id: row.clientId, name: row.customerName } : null;
this.clientSuggestions = [];
this.selectedClasses = new Set(row.preferredClasses ?? []);
this.loadClassCodes(row.officeId);
this.showForm = true;
}
cancelForm(): void {
this.showForm = false;
this.editingId = null;
}
submit(): void {
if (this.entryForm.invalid || this.selectedClasses.size === 0 || this.busy) {
return;
}
this.busy = true;
const raw = this.entryForm.getRawValue();
const dateFormat = this.settingsService.dateFormat;
const payload: any = {
// Sent as null on edit too, so removing the link is saved.
clientId: this.linkedClient?.id ?? null,
inquiryDate: this.dateUtils.formatDate(raw.inquiryDate, dateFormat),
customerName: this.displayClientName(raw.customerName),
addressLine1: raw.addressLine1,
addressLine2: raw.addressLine2 || null,
addressLine3: raw.addressLine3 || null,
city: raw.city,
pincode: raw.pincode,
phone: raw.phone || null,
email: raw.email || null,
preferredClasses: [...this.selectedClasses],
remarks: raw.remarks || null,
dateFormat,
locale: this.settingsService.language.code
};
if (this.editingId == null && this.isSuperUser && raw.officeId) {
payload.officeId = Number(raw.officeId);
}
const request =
this.editingId == null
? this.lockerService.createWaitingListEntry(payload)
: this.lockerService.updateWaitingListEntry(this.editingId, payload);
request.subscribe({
next: (res: any) => {
this.busy = false;
this.notify(
this.editingId == null
? `Added to waiting list — Waiting No ${res?.resourceId}`
: 'Waiting list entry updated'
);
this.cancelForm();
this.loadEntries();
},
error: (err: any) => {
this.busy = false;
this.notify(this.serverMessage(err, 'Failed to save waiting list entry'), true);
}
});
}
/** Falls back to the PC date only when the business date has not been loaded yet. */
private businessDate(): Date {
const date = this.settingsService.businessDate;
return date && !isNaN(date.getTime()) ? date : new Date();
}
cancelEntry(row: any): void {
const dialogRef = this.dialog.open(CancelDialogComponent, {
data: { cancelContext: `waiting list entry ${row.id} (${row.customerName})` }
});
dialogRef.afterClosed().subscribe((response: any) => {
if (!response?.cancel) {
return;
}
this.lockerService.cancelWaitingListEntry(row.id).subscribe({
next: () => {
this.notify('Waiting list entry cancelled');
this.loadEntries();
},
error: (err: any) => this.notify(this.serverMessage(err, 'Failed to cancel waiting list entry'), true)
});
});
}
}
@@ -0,0 +1,195 @@
<!--
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="pl-root">
<div class="pl-header">
<div class="pl-header-left">
<div class="pl-header-ic"><fa-icon icon="user-tie"></fa-icon></div>
<div class="pl-header-text">
<h2>Agent Master</h2>
<p>The DDS collection agents on file.</p>
</div>
</div>
<div class="pl-header-right">
<div class="pl-count-chip" matTooltip="Total agents">
<fa-icon icon="user-tie"></fa-icon>
<strong>{{ totalCount }}</strong>
<span>{{ totalCount === 1 ? 'agent' : 'agents' }}</span>
</div>
<button class="pl-create-btn" type="button" [routerLink]="['create']">
<fa-icon icon="plus"></fa-icon>
<span>Create Agent</span>
</button>
</div>
</div>
<div class="pl-kpis">
<div class="pl-kpi pl-kpi--blue">
<div class="pl-kpi-ic"><fa-icon icon="user-tie"></fa-icon></div>
<div>
<div class="pl-kpi-n">{{ totalCount | number }}</div>
<div class="pl-kpi-l">Total Agents</div>
</div>
</div>
<div class="pl-kpi pl-kpi--purple">
<div class="pl-kpi-ic"><fa-icon icon="toggle-on"></fa-icon></div>
<div>
<div class="pl-kpi-n">{{ activeCount | number }}</div>
<div class="pl-kpi-l">Active Agents</div>
</div>
</div>
<div
class="pl-kpi pl-kpi--teal"
matTooltip="Money collected from parties that agents have not yet handed over to a branch"
>
<div class="pl-kpi-ic"><fa-icon icon="wallet"></fa-icon></div>
<div>
<div class="pl-kpi-n">{{ totalCurrentBalance | number: '1.2-2' }}</div>
<div class="pl-kpi-l">Cash With Agents (Unsettled)</div>
</div>
</div>
</div>
<div class="pl-card">
<div class="pl-toolbar">
<div class="pl-search">
<fa-icon icon="search" class="pl-search-ic"></fa-icon>
<input type="text" (input)="applyFilter($any($event.target).value)" placeholder="Search by code or name…" />
</div>
</div>
@if (totalCount === 0) {
<div class="pl-empty">
<div class="pl-empty-ic"><fa-icon icon="user-tie"></fa-icon></div>
<h3>No agents yet</h3>
<p>Create one so daily collections can be recorded against it.</p>
<button class="pl-create-btn" type="button" [routerLink]="['create']">
<fa-icon icon="plus"></fa-icon>
<span>Create Agent</span>
</button>
</div>
} @else {
<div class="pl-table-wrap">
<table mat-table [dataSource]="dataSource" matSort class="pl-table">
<ng-container matColumnDef="agentCode">
<th mat-header-cell *matHeaderCellDef mat-sort-header>Agent Code</th>
<td mat-cell *matCellDef="let a">
<div class="pl-product-cell">
<div class="pl-product-icon"><fa-icon icon="user-tie"></fa-icon></div>
<div class="pl-product-meta">
<div class="pl-product-name">{{ a.agentCode }}</div>
</div>
</div>
</td>
</ng-container>
<ng-container matColumnDef="clientName">
<th mat-header-cell *matHeaderCellDef mat-sort-header>Agent Name</th>
<td mat-cell *matCellDef="let a">{{ a.agentNameId }}</td>
</ng-container>
<!-- Which branch the agent's collections belong to. Its own column rather than a subtitle:
it decides which branch's batch run picks the agent up, so it has to be scannable. -->
<!-- The GL the cashier takes this agent's cash into, so it is to hand without opening the agent. -->
<ng-container matColumnDef="glAccount">
<th mat-header-cell *matHeaderCellDef>GL Account</th>
<td mat-cell *matCellDef="let a">
@if (a.glAccountCode) {
<span class="pl-tag">{{ a.glAccountCode }} · {{ a.glAccountName }}</span>
} @else {
<span class="am-no-branch">Not set</span>
}
</td>
</ng-container>
<ng-container matColumnDef="homeBranch">
<th mat-header-cell *matHeaderCellDef mat-sort-header>Branch</th>
<td mat-cell *matCellDef="let a">
@if (a.homeBranch) {
<span class="pl-tag">{{ a.homeBranch }}</span>
} @else {
<span class="am-no-branch">Not set</span>
}
</td>
</ng-container>
<ng-container matColumnDef="agentCity">
<th mat-header-cell *matHeaderCellDef mat-sort-header>City</th>
<td mat-cell *matCellDef="let a">{{ a.agentCity }}</td>
</ng-container>
<ng-container matColumnDef="agentSavingAccountNo">
<th mat-header-cell *matHeaderCellDef>Saving A/C No</th>
<td mat-cell *matCellDef="let a">
<span class="pl-tag">{{ a.agentSavingAccountNo }}</span>
</td>
</ng-container>
<ng-container matColumnDef="agentCurrentBalance">
<th mat-header-cell *matHeaderCellDef mat-sort-header class="num">Current Balance</th>
<td mat-cell *matCellDef="let a" class="num">
<strong>{{ a.agentCurrentBalance | number: '1.2-2' }}</strong>
</td>
</ng-container>
<ng-container matColumnDef="status">
<th mat-header-cell *matHeaderCellDef>Status</th>
<td mat-cell *matCellDef="let a">
<span class="am-pill" [class.am-pill--off]="!a.active">
{{ a.active ? 'Active' : 'Inactive' }}
</span>
</td>
</ng-container>
<ng-container matColumnDef="createdDate">
<th mat-header-cell *matHeaderCellDef mat-sort-header>Created</th>
<td mat-cell *matCellDef="let a">
{{ a.createdDate ? (a.createdDate | dateFormat: 'DD MMM YYYY, hh:mm A') : '—' }}
</td>
</ng-container>
<ng-container matColumnDef="actions">
<th mat-header-cell *matHeaderCellDef class="num">Actions</th>
<td mat-cell *matCellDef="let a" class="num">
<!-- A closed agent is off the books: nothing can be changed on it, only read. -->
@if (a.closeDate) {
<span class="am-closed-on">Closed {{ a.closeDate | dateFormat }}</span>
} @else {
<button
type="button"
class="pl-action-btn pl-action-btn--edit"
[routerLink]="[a.id, 'edit']"
matTooltip="Edit this agent"
>
<fa-icon icon="edit"></fa-icon><span>Edit</span>
</button>
}
</td>
</ng-container>
<tr mat-header-row *matHeaderRowDef="displayedColumns"></tr>
<tr
mat-row
*matRowDef="let row; columns: displayedColumns"
class="pl-row"
[class.am-row--inactive]="!row.active"
></tr>
</table>
@if (filteredCount === 0 && totalCount > 0) {
<div class="pl-no-match">
<fa-icon icon="search"></fa-icon>
<span>No agents match your filter.</span>
</div>
}
<mat-paginator [pageSizeOptions]="[10, 25, 50, 100]" [pageSize]="10" showFirstLastButtons></mat-paginator>
</div>
}
</div>
</div>
@@ -0,0 +1,59 @@
/**
* 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/.
*/
@use 'assets/styles/product-list' as pl;
@include pl.product-list-styles;
/* Agent-specific indigo accent */
:host {
--pl-accent: #4338ca;
--pl-accent-l: #e0e7ff;
}
/* A retired agent stays listed but reads as switched off. */
.am-row--inactive td:not(.num) {
opacity: 0.55;
}
/* Closing is the last, irreversible step, so it does not read like the buttons beside it. */
.pl-action-btn--close {
color: #9b2c2c;
}
/* A closed agent shows when, in place of the actions it no longer has. */
.am-closed-on {
font-size: 12px;
font-style: italic;
color: #9b2c2c;
}
.am-pill {
display: inline-block;
padding: 3px 11px;
border-radius: 11px;
font-size: 0.72rem;
font-weight: 500;
line-height: 1.7;
background: #dcfce7;
color: #15803d;
white-space: nowrap;
}
.am-pill--off {
background: rgb(0 0 0 / 7%);
color: rgb(0 0 0 / 52%);
}
/* An agent with no branch is an agent no branch's batch run will collect for - say so plainly
rather than showing an em dash that reads like "nothing to see here". */
.am-no-branch {
font-size: 12px;
font-style: italic;
color: #9b2c2c;
}
@@ -0,0 +1,147 @@
/**
* 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, ViewChild, inject } from '@angular/core';
import { MatPaginator } from '@angular/material/paginator';
import { MatSort, MatSortHeader } from '@angular/material/sort';
import {
MatTable,
MatTableDataSource,
MatColumnDef,
MatHeaderCellDef,
MatHeaderCell,
MatCellDef,
MatCell,
MatHeaderRowDef,
MatHeaderRow,
MatRowDef,
MatRow
} from '@angular/material/table';
import { MatTooltip } from '@angular/material/tooltip';
import { FaIconComponent } from '@fortawesome/angular-fontawesome';
import { STANDALONE_SHARED_IMPORTS } from 'app/standalone-shared.module';
/** Custom Services */
import { OrganizationService } from 'app/organization/organization.service';
import { AuthenticationService } from 'app/core/authentication/authentication.service';
import { forkJoin, of } from 'rxjs';
import { catchError } from 'rxjs/operators';
import { officesUnder } from './offices-under';
/**
* Agent Master list.
*
* The DDS collection agents on file. Follows the same list -> create/edit shape as the product lists
* and shares their stylesheet.
*/
@Component({
selector: 'mifosx-agent-master',
templateUrl: './agent-master.component.html',
styleUrls: ['./agent-master.component.scss'],
imports: [
...STANDALONE_SHARED_IMPORTS,
FaIconComponent,
MatTable,
MatSort,
MatColumnDef,
MatHeaderCellDef,
MatHeaderCell,
MatSortHeader,
MatCellDef,
MatCell,
MatHeaderRowDef,
MatHeaderRow,
MatRowDef,
MatRow,
MatPaginator,
MatTooltip
]
})
export class AgentMasterComponent implements OnInit {
private organizationService = inject(OrganizationService);
private authenticationService = inject(AuthenticationService);
displayedColumns: string[] = [
'agentCode',
'clientName',
'homeBranch',
'glAccount',
'agentCity',
'agentSavingAccountNo',
'agentCurrentBalance',
'status',
'createdDate',
'actions'
];
dataSource: MatTableDataSource<any> = new MatTableDataSource<any>([]);
agents: any[] = [];
filteredCount = 0;
@ViewChild(MatPaginator, { static: true }) paginator: MatPaginator;
@ViewChild(MatSort, { static: true }) sort: MatSort;
get totalCount(): number {
return this.agents.length;
}
get activeCount(): number {
return this.agents.filter((agent) => agent.active).length;
}
/**
* Cash the agents are collectively holding but have not settled to a branch yet — an agent's
* balance rises on every party collection and falls when it hands the money over. Inactive agents
* count too: money still out with a retired agent is exactly what this figure is for.
*/
get totalCurrentBalance(): number {
return this.agents.reduce((sum, agent) => sum + Number(agent.agentCurrentBalance || 0), 0);
}
ngOnInit(): void {
this.loadAgents();
}
private loadAgents(): void {
const loginOfficeId = this.authenticationService.getCredentials()?.officeId;
forkJoin({
agents: this.organizationService.getAgents(),
offices: this.organizationService.getOffices().pipe(catchError(() => of([])))
}).subscribe(({ agents, offices }: any) => {
// The branch logged in to sees its own agents and those of every branch under it - Head Office,
// at the top, sees them all.
const visible = new Set(
officesUnder(Array.isArray(offices) ? offices : offices?.pageItems || [], loginOfficeId).map(
(office: any) => office.id
)
);
this.agents = (agents || []).filter(
(agent: any) => !loginOfficeId || visible.size === 0 || visible.has(agent.officeId)
);
this.dataSource = new MatTableDataSource(this.agents);
this.dataSource.paginator = this.paginator;
this.dataSource.sort = this.sort;
this.filteredCount = this.agents.length;
this.dataSource.connect().subscribe((rows) => (this.filteredCount = rows.length));
});
}
applyFilter(filterValue: string): void {
this.dataSource.filter = filterValue.trim().toLowerCase();
}
private static readonly MONEY = new Intl.NumberFormat('en-IN', {
minimumFractionDigits: 2,
maximumFractionDigits: 2
});
money(value: any): string {
return AgentMasterComponent.MONEY.format(Number(value || 0));
}
}
@@ -0,0 +1,318 @@
<!--
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">
<!--
Follows the legacy Agent Master Entry screen field for field. Entering a Client ID fills the
name, address, city, pincode and telephone from that client, as the legacy screen did, and
also defaults the home branch and stocks the two savings-account dropdowns from that client.
-->
<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 or Name</mat-label>
<input
matInput
required
formControlName="clientId"
[matAutocomplete]="clientAuto"
#clientInput
#clientTrigger="matAutocompleteTrigger"
(focus)="onClientFocus()"
placeholder="Click to see clients, or type a name or ID…"
autocomplete="off"
/>
<i matSuffix class="fas fa-search am-search-ic"></i>
<!-- Picking a client puts their id in the field, which then fills the rest as before. -->
<mat-autocomplete #clientAuto="matAutocomplete" panelWidth="min(30rem, 92vw)">
@for (client of clientOptions; track client.id) {
<mat-option [value]="client.id">
<div class="am-opt">
<span class="am-opt-id">{{ client.id }}</span>
<span class="am-opt-main">{{ client.displayName }}</span>
<span class="am-opt-sub">{{ client.officeName }}</span>
</div>
</mat-option>
}
@if (clientListIsPartial) {
<mat-option disabled class="am-opt-more">
Showing {{ clientOptions.length }} of {{ clientTotal }} — type a name to find the rest
</mat-option>
}
</mat-autocomplete>
@if (agentForm.get('clientId')?.hasError('pattern')) {
<mat-hint class="am-hint--error">Pick a client from the list</mat-hint>
} @else if (clientAlreadyAgent) {
<mat-hint class="am-hint--error">This client is already agent {{ clientAlreadyAgent }}</mat-hint>
} @else if (clientAgeProblem) {
<mat-hint class="am-hint--error">{{ clientAgeProblem }}</mat-hint>
} @else if (clientOtherBranch) {
<mat-hint class="am-hint--error">This client belongs to another branch</mat-hint>
} @else if (clientLookupFailed) {
<mat-hint class="am-hint--error">No client found with this ID</mat-hint>
} @else if (loadedClientName) {
<mat-hint>{{ loadedClientName }}</mat-hint>
}
</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" />
</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" mifosxRestrictInput="digits" />
</mat-form-field>
<mat-form-field appearance="outline" class="am-field">
<mat-label>Agent Telephone</mat-label>
<input matInput formControlName="agentTelephone" mifosxRestrictInput="phone" />
</mat-form-field>
<!-- Which branch the agent collects out of: the one logged in to or a branch under it. The
option carries the office's id while showing its name, so renaming a branch later
leaves the agent where it is. -->
<mat-form-field appearance="outline" class="am-field">
<mat-label>Home Branch</mat-label>
<mat-select required formControlName="officeId">
@for (office of offices; track office.id) {
<mat-option [value]="office.id">{{ office.name }}</mat-option>
}
</mat-select>
<mat-hint>The branch this agent's collections belong to</mat-hint>
</mat-form-field>
<!-- The account this agent's cash sits in between collecting it and bringing it in, so a
balance left standing on it is this agent's and nobody else's. Required, and one agent
to an account - an account already taken is refused by name, not by number. Only
detail accounts are offered: a header account is a heading over others and carries no
entries of its own. -->
<mat-form-field appearance="outline" class="am-field">
<mat-label>GL Account</mat-label>
<mat-select required formControlName="glAccountId">
@for (account of glAccounts; track account.id) {
<mat-option [value]="account.id">{{ account.glCode }} · {{ account.name }}</mat-option>
}
</mat-select>
<mat-hint>This agent's collections pass through it. One agent to an account.</mat-hint>
</mat-form-field>
<!-- Type first, then the number - the order the legacy screen used. The list is built from
the client's own accounts, so every type offered is one they actually hold. -->
<mat-form-field appearance="outline" class="am-field">
<mat-label>Agent Saving A/C Type</mat-label>
<mat-select
required
formControlName="agentSavingAccountType"
(selectionChange)="onSavingsAccountTypePicked()"
>
@for (accountType of savingsAccountTypes; track accountType) {
<mat-option [value]="accountType">{{ savingsAccountTypeCodes[accountType] || accountType }}</mat-option>
}
</mat-select>
<!-- Two different reasons for an empty list, and the fix differs: enter a client, or
open them a savings account. Saying which saves a guess. -->
@if (savingsAccountTypes.length === 0) {
@if (loadedClientName) {
<mat-hint class="am-hint--error">
{{ loadedClientName }} has no savings account — commission can only be paid into one
</mat-hint>
} @else {
<mat-hint class="am-hint--error"
>Enter a Client ID first — types come from that client's own accounts</mat-hint
>
}
}
</mat-form-field>
<!-- Picked from the client's own accounts, never typed: commission is paid into this
account, and a number that exists only as text is a payment with nowhere to land. -->
<mat-form-field appearance="outline" class="am-field">
<mat-label>Agent Saving A/C No</mat-label>
<mat-select required formControlName="agentSavingAccountNo">
@for (account of accountsOfChosenType; track account.id) {
<mat-option [value]="account.accountNo">{{ account.accountNo }}</mat-option>
}
</mat-select>
@if (accountsOfChosenType.length === 0) {
<mat-hint>Choose an A/C Type first</mat-hint>
}
</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>
<!-- Copied from the client, whose date of birth was taken and checked when they were opened. -->
<mat-form-field appearance="outline" class="am-field">
<mat-label>Agent Birth Date</mat-label>
<input matInput readonly [matDatepicker]="birthDatePicker" formControlName="agentBirthDate" />
<mat-datepicker #birthDatePicker></mat-datepicker>
<mat-hint>From the client's record</mat-hint>
</mat-form-field>
</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 or Name</mat-label>
<input
matInput
required
formControlName="clientId"
[matAutocomplete]="guarantorAuto"
#guarantorInput
#guarantorTrigger="matAutocompleteTrigger"
(focus)="onGuarantorFocus(guarantor, guarantorTrigger, guarantorInput)"
placeholder="Click to see clients, or type…"
autocomplete="off"
/>
<i matSuffix class="fas fa-search am-search-ic"></i>
<mat-autocomplete #guarantorAuto="matAutocomplete" panelWidth="min(30rem, 92vw)">
@for (client of guarantorOptionsFor(guarantor); track client.id) {
<mat-option [value]="client.id">
<div class="am-opt">
<span class="am-opt-id">{{ client.id }}</span>
<span class="am-opt-main">{{ client.displayName }}</span>
<span class="am-opt-sub">{{ client.officeName }}</span>
</div>
</mat-option>
}
</mat-autocomplete>
<!-- Shown as soon as it is keyed, not only after leaving the field. -->
@if (guarantor.get('clientId')?.hasError('selfGuarantor')) {
<mat-hint class="am-hint--error">The agent cannot be their own guarantor</mat-hint>
} @else if (guarantor.get('clientId')?.hasError('duplicateGuarantor')) {
<mat-hint class="am-hint--error">This client is already a guarantor above</mat-hint>
} @else if (guarantor.get('clientId')?.hasError('pattern')) {
<mat-hint class="am-hint--error">Pick a client from the list</mat-hint>
}
</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" mifosxRestrictInput="digits" />
</mat-form-field>
<mat-form-field appearance="outline" class="am-field">
<mat-label>Telephone</mat-label>
<input matInput formControlName="telephone" mifosxRestrictInput="phone" />
</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-balance-scale"></i>
<h3>Balances</h3>
</div>
<div class="am-grid">
<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 type="number" formControlName="agentOpeningBalance" />
</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 || clientOtherBranch || !!clientAlreadyAgent || !!clientAgeProblem"
(click)="save()"
>
Save
</button>
</div>
</form>
</div>
</div>
@@ -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/.
*/
$primary: #0f766e;
.am-container {
max-width: 1040px;
margin: 0 auto;
padding: 24px 16px;
}
.am-header {
display: flex;
align-items: center;
gap: 16px;
margin-bottom: 20px;
&__title {
margin: 0;
font-size: 22px;
font-weight: 500;
}
}
.am-back {
color: $primary;
}
.am-card {
background: var(--md-sys-color-surface, #fff);
border-radius: 8px;
box-shadow: 0 2px 8px rgb(0 0 0 / 8%);
padding: 8px 24px 24px;
}
.am-section {
padding: 20px 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;
}
}
}
.am-grid {
display: grid;
grid-template-columns: repeat(3, 1fr);
gap: 8px 16px;
}
.am-field {
width: 100%;
&--wide {
grid-column: span 2;
}
}
.am-add-guarantor {
color: $primary;
}
.am-empty-guarantors {
color: rgb(0 0 0 / 50%);
font-size: 14px;
margin: 0 0 8px;
}
.am-guarantor-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;
}
}
.am-limit-row {
display: grid;
grid-template-columns: repeat(3, 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;
}
}
.am-remove-guarantor {
color: #b91c1c;
margin-top: 8px;
}
.am-action-bar {
display: flex;
justify-content: flex-end;
gap: 12px;
margin-top: 20px;
padding-top: 16px;
flex-wrap: wrap;
}
/* Client ID lookup came back empty — the officer typed an id that doesn't exist. */
.am-hint--error {
color: #b3261e;
}
@media (width <= 900px) {
.am-grid {
grid-template-columns: repeat(2, 1fr);
}
.am-guarantor-row,
.am-limit-row {
grid-template-columns: repeat(2, 1fr);
}
}
/* One row of the client search list: the id, the name, and the branch - as on the Party form. */
.am-opt {
display: flex;
align-items: baseline;
gap: 10px;
overflow: hidden;
}
.am-opt-more {
font-size: 0.75rem;
font-style: italic;
opacity: 0.75;
}
.am-opt-id {
flex: 0 0 auto;
min-width: 52px;
font-family: ui-monospace, 'SF Mono', 'Cascadia Mono', monospace;
font-size: 0.78rem;
color: #0f766e;
}
.am-opt-main {
flex: 1 1 auto;
min-width: 8rem;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.am-opt-sub {
flex: 0 1 auto;
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
font-size: 0.75rem;
color: rgb(0 0 0 / 50%);
}
.am-search-ic {
margin-right: 8px;
color: rgb(0 0 0 / 40%);
}
@@ -0,0 +1,929 @@
/**
* 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 { ChangeDetectorRef, Component, ElementRef, OnInit, ViewChild, inject } from '@angular/core';
import {
UntypedFormBuilder,
UntypedFormGroup,
UntypedFormArray,
Validators,
ValidatorFn,
AbstractControl,
ValidationErrors
} from '@angular/forms';
import { ActivatedRoute, Router } from '@angular/router';
import { debounceTime, distinctUntilChanged } from 'rxjs/operators';
import { STANDALONE_SHARED_IMPORTS } from 'app/standalone-shared.module';
/** Custom Services */
import { OrganizationService } from 'app/organization/organization.service';
import { AccountingService } from 'app/accounting/accounting.service';
import { SystemService } from 'app/system/system.service';
import { ClientsService } from 'app/clients/clients.service';
import { SettingsService } from 'app/settings/settings.service';
import { Dates } from 'app/core/utils/dates';
import { AlertService } from 'app/core/alert/alert.service';
import { AuthenticationService } from 'app/core/authentication/authentication.service';
/** Custom Directives */
import { RestrictInputDirective } from 'app/directives/restrict-input.directive';
import { MatAutocompleteModule, MatAutocompleteTrigger } from '@angular/material/autocomplete';
import { searchClientsAnyCase } from 'app/deposits/party-master/client-search';
import { officesUnder } from '../offices-under';
/** Writes a value into a form group only when there is something to write. */
function setGroupField(group: UntypedFormGroup, controlName: string, value: any): void {
if (value === null || value === undefined || value === '') {
return;
}
group.get(controlName)?.setValue(String(value));
}
/**
* Agent Master Entry component.
*
* A DDS collection agent — either a person or an institution — registered with its own settlement
* savings account and one or more guarantors. Modelled on the legacy Agent Master Entry screen, field
* for field.
*
* Entering a Client ID fills the name, address, city, pincode and telephone from that client, the way
* the legacy screen did, defaults the home branch to that client's office, and stocks the account
* type and account number dropdowns from that client's own accounts. Filled fields stay editable.
*
* Backed by its own DDS Agent API (`/dds/agents`) — independent of Staff/Client CRUD, per the DDS
* agent/commission hierarchy being genuinely new feature work.
*/
@Component({
selector: 'mifosx-create-agent',
templateUrl: './create-agent.component.html',
styleUrls: ['./create-agent.component.scss'],
imports: [
...STANDALONE_SHARED_IMPORTS,
// See the Party form: an unimported directive is ignored without a word.
RestrictInputDirective,
MatAutocompleteModule
]
})
export class CreateAgentComponent implements OnInit {
private formBuilder = inject(UntypedFormBuilder);
private router = inject(Router);
private route = inject(ActivatedRoute);
private organizationService = inject(OrganizationService);
private clientsService = inject(ClientsService);
private accountingService = inject(AccountingService);
private systemService = inject(SystemService);
private settingsService = inject(SettingsService);
private dateUtils = inject(Dates);
private alertService = inject(AlertService);
private authenticationService = inject(AuthenticationService);
private changeDetector = inject(ChangeDetectorRef);
/** The agent's client field and its list, so a list that arrives late can still be opened. */
@ViewChild('clientTrigger') clientTrigger?: MatAutocompleteTrigger;
@ViewChild('clientInput') clientInput?: ElementRef<HTMLInputElement>;
/**
* The branch the officer is logged in to. An agent is opened there and nowhere else: the Home
* Branch list offers only it, and only its clients can be picked.
*/
private readonly loginOfficeId: number | null = this.authenticationService.getCredentials()?.officeId ?? null;
/**
* The branch the agent is being opened for - the one picked in Home Branch, the login branch until
* then. Its clients are the ones offered, since an agent collects for the branch they belong to.
*/
private get branchOfficeId(): number | null {
return Number(this.agentForm?.get('officeId')?.value) || this.loginOfficeId;
}
/** Set when the client keyed belongs to another branch, which cannot be made an agent here. */
clientOtherBranch = false;
/**
* The code of the agent the keyed client already is, if any. One client is one agent, so they are
* left out of the list and refused when typed.
*/
clientAlreadyAgent = '';
private agentCodeByClientId: Record<string, string> = {};
/** Why the picked client's age rules them out as an agent, or empty when it does not. */
clientAgeProblem = '';
/** The ages an agent is taken on at, matching the rule the server applies. */
private static readonly MIN_AGENT_AGE = 16;
private static readonly MAX_AGENT_AGE = 60;
/** The agent's client list without the clients who are already agents. */
private notYetAgents(clients: any[]): any[] {
return clients.filter((client: any) => !this.agentCodeByClientId[String(client.id)]);
}
agentId: number | null = null;
/** Shown under the Client ID field once a client has been resolved. */
loadedClientName = '';
clientLookupFailed = false;
/**
* Clients to pick from, as on the Party form: the first page when the field is focused, then
* whatever matches what has been typed. An officer knows the agent's name, rarely their client id.
*/
clientOptions: any[] = [];
private initialClients: any[] = [];
private static readonly CLIENT_LIST_SIZE = 50;
clientTotal = 0;
showingClientList = false;
get clientListIsPartial(): boolean {
return this.showingClientList && this.clientTotal > this.clientOptions.length;
}
/**
* The client's own open savings accounts, fetched once the client is resolved. The commission
* account is picked from these rather than typed, so it is always an account that exists and
* always belongs to this agent.
*/
clientSavingsAccounts: any[] = [];
/**
* The account types this client actually holds, taken from their own accounts rather than a fixed
* list. A fixed list would offer types the client has nothing under, and its wording would have to
* match the product names by hand for the filter below to work at all.
*/
savingsAccountTypes: string[] = [];
/**
* Each type's product code, which is what the dropdown shows. The type is still saved by its name,
* as before, so what other screens and reports read is unchanged.
*/
savingsAccountTypeCodes: Record<string, string> = {};
/** Fineract's deposit-account kinds: 100 savings, 200 fixed deposit, 300 recurring deposit. */
private static readonly SAVINGS_DEPOSIT_TYPE = 100;
/** Branches, for the Home Branch field. Defaulted from the client's own office. */
offices: any[] = [];
/**
* The ledger accounts an agent's collections may pass through. Header accounts are left out: they
* are headings over other accounts and carry no entries, so offering one only leads to a refusal.
*/
glAccounts: any[] = [];
/**
* The savings product a DDS customer's own scheme account is opened under, read from the setting
* that names it rather than guessed from a product name a bank is free to change.
*
* Held so those accounts can be kept out of the agent's own list. They are savings accounts and
* pass every other test, but they belong to a customer's scheme: commission paid into one would be
* counted as that customer's deposits by every interest run afterwards. The server refuses it
* either way - this is so the choice is never offered rather than taken and then thrown back.
*/
private ddsProductId: number | null = null;
/**
* The accounts on offer once a type has been chosen. Type is asked first, as the legacy screen
* did, so the officer narrows to a kind of account and then picks one of that kind.
*/
get accountsOfChosenType(): any[] {
const chosenType = this.agentForm?.get('agentSavingAccountType')?.value;
if (!chosenType) {
return [];
}
return this.clientSavingsAccounts.filter((account: any) => account.productName === chosenType);
}
agentForm: UntypedFormGroup = this.formBuilder.group({
clientId: [
'',
// A name typed to search with is not a client; only an id picked or keyed saves.
[
Validators.required,
Validators.pattern(/^\d+$/)
]
],
agentCode: [
{
value: '',
disabled: true
}
],
agentNameId: [
'',
Validators.required
],
agentAddress1: [
'',
Validators.required
],
agentAddress2: [''],
agentCity: [
'',
Validators.required
],
agentPinCode: [
'',
Validators.required
],
agentTelephone: [''],
// Which branch this agent collects out of. Holds the office's id, not its name - the name is
// only what the dropdown shows. The DDS batch runs filter by it.
officeId: [
'',
Validators.required
],
// Asked before the account number, which is then narrowed to accounts of this type.
agentSavingAccountType: [
'',
Validators.required
],
agentSavingAccountNo: [
'',
Validators.required
],
agentOpeningDate: [
'',
Validators.required
],
// Filled from the client, whose date of birth was checked when they were opened.
agentBirthDate: [''],
agentCurrentBalance: [
{
value: 0,
disabled: true
}
],
agentOpeningBalance: [0],
/*
* Required. An agent sharing the scheme's one account leaves a balance that says money is
* outstanding without saying whose - which is exactly what this field was added to answer.
*/
glAccountId: [
'',
Validators.required
],
guarantors: this.formBuilder.array([])
});
ngOnInit(): void {
// A different branch has different clients: the list is read again, and a client already keyed
// is checked against the new branch.
this.agentForm
.get('officeId')
?.valueChanges.pipe(distinctUntilChanged())
.subscribe(() => {
this.initialClients = [];
if (!this.agentId && this.agentForm.get('clientId')?.value) {
this.loadClient();
}
});
// Who is already an agent, so they are neither offered nor accepted as a new one.
this.organizationService.getAgents().subscribe({
next: (agents: any[]) => {
this.agentCodeByClientId = {};
for (const agent of agents || []) {
if (agent.active !== false && agent.clientId != null) {
this.agentCodeByClientId[String(agent.clientId)] = agent.agentCode;
}
}
},
error: () => (this.agentCodeByClientId = {})
});
this.organizationService.getOffices().subscribe({
next: (offices: any) => {
const all = Array.isArray(offices) ? offices : offices?.pageItems || [];
// The branch logged in to and every branch under it - Head Office, at the top, sees them all.
this.offices = officesUnder(all, this.loginOfficeId);
if (!this.agentId && this.offices.some((office: any) => office.id === this.loginOfficeId)) {
this.agentForm.get('officeId')?.setValue(this.loginOfficeId);
}
},
error: () => (this.offices = [])
});
this.systemService.getConfigurationByName('dds-deposit-product').subscribe({
next: (config: any) => {
this.ddsProductId = config?.enabled && config?.value ? Number(config.value) : null;
// The client may already have been keyed while this was in flight, so the list is rebuilt
// rather than left showing an account this now knows to exclude.
const clientId = this.agentForm.get('clientId')?.value;
if (clientId) {
this.loadClientSavingsAccounts(String(clientId));
}
},
error: () => (this.ddsProductId = null)
});
this.accountingService.getChartOfAccounts().subscribe({
next: (accounts: any) => {
const rows = Array.isArray(accounts) ? accounts : accounts?.pageItems || [];
// usage 1 is a detail account, 2 a header. Disabled accounts refuse every posting, so an
// agent pointed at one would fail on their first collection rather than here.
this.glAccounts = rows.filter((a: any) => a.usage?.id === 1 && !a.disabled);
},
error: () => (this.glAccounts = [])
});
// An id in the route means this is an edit; load that agent into the form.
const routeId = this.route.snapshot.paramMap.get('agentId');
if (routeId) {
this.organizationService.getAgent(routeId).subscribe((agent: any) => this.patchFormFromAgent(agent));
}
// Fill the client-derived fields as soon as typing settles - no need to tab out of the field.
this.agentForm
.get('clientId')
?.valueChanges.pipe(debounceTime(500), distinctUntilChanged())
.subscribe((value) => {
const typed = String(value ?? '').trim();
// A name is something to search for, not a client yet - the fields stay until one is picked.
if (typed && !/^\d+$/.test(typed)) {
this.searchClients(typed);
return;
}
if (!typed) {
this.showClientList();
}
this.loadClient();
// A guarantor row that was fine may now be the agent itself, or the other way round.
for (const row of this.guarantors.controls) {
row.get('clientId')?.updateValueAndValidity();
// And the agent's own client leaves (or rejoins) each row's list.
if (!row.get('clientId')?.value) {
this.setGuarantorOptions(row, this.eligibleGuarantors(row, this.initialClients));
}
}
});
}
get guarantors(): UntypedFormArray {
return this.agentForm.get('guarantors') as UntypedFormArray;
}
private buildGuarantorGroup(): UntypedFormGroup {
const group = this.formBuilder.group({
clientId: [
'',
[
Validators.required,
Validators.pattern(/^\d+$/),
this.notTheAgentItself(),
this.notAlreadyAGuarantor()
]
],
guarantorName: [
'',
Validators.required
],
address1: [
'',
Validators.required
],
address2: [''],
city: [
'',
Validators.required
],
pinCode: [
'',
Validators.required
],
telephone: ['']
});
// A guarantor is a client too, so its row fills from the client the same way the agent's does.
// Searched by name like the agent's own client field; a picked or keyed id fills the row.
group
.get('clientId')
?.valueChanges.pipe(debounceTime(500), distinctUntilChanged())
.subscribe((value) => {
const typed = String(value ?? '').trim();
if (typed && !/^\d+$/.test(typed)) {
searchClientsAnyCase(this.clientsService, typed).subscribe((found: any[]) => {
this.setGuarantorOptions(group, this.eligibleGuarantors(group, found).slice(0, 25));
const panel = (group as any).panel;
this.openWhenFocused(panel?.trigger, panel?.input);
});
return;
}
this.setGuarantorOptions(group, []);
this.loadGuarantorClient(group, typed);
if (!typed) {
// Emptied: the list comes back rather than leaving nothing to pick from.
this.onGuarantorFocus(group);
}
// Two rows naming the same client: the other row's check has to be run again too.
for (const row of this.guarantors.controls) {
if (row !== group) {
row.get('clientId')?.updateValueAndValidity({ emitEvent: false });
}
}
});
return group;
}
/**
* Each guarantor row's list lives on the row itself, as a plain array the template reads - the same
* way the agent's own list works - so one row's typing never fills another's.
*/
private setGuarantorOptions(group: AbstractControl, options: any[]): void {
(group as any).clientOptions = options;
}
guarantorOptionsFor(group: AbstractControl): any[] {
return (group as any).clientOptions || GUARANTOR_NO_OPTIONS;
}
/**
* Opens the client list on a guarantor row as soon as it is focused, as the agent's field does.
* The list is filled before the panel is asked to open, since a panel opened empty stays shut.
*/
onGuarantorFocus(group: AbstractControl, trigger?: MatAutocompleteTrigger, input?: HTMLElement): void {
if (trigger) {
// Kept so a search answered later can open this row's list too.
(group as any).panel = { trigger, input };
}
if (group.get('clientId')?.value) {
return;
}
const show = () => {
this.setGuarantorOptions(group, this.eligibleGuarantors(group, this.initialClients));
const panel = (group as any).panel;
this.openWhenFocused(panel?.trigger, panel?.input);
};
if (this.initialClients.length > 0) {
show();
return;
}
this.clientsService.getFilteredClients('displayName', 'ASC', false, '', this.branchOfficeId).subscribe({
next: (response: any) => {
const found = response?.pageItems || response || [];
this.initialClients = Array.isArray(found) ? found.slice(0, CreateAgentComponent.CLIENT_LIST_SIZE) : [];
this.clientTotal = response?.totalFilteredRecords ?? this.initialClients.length;
show();
},
error: () => undefined
});
}
/** Clients who can stand guarantor on this row: not the agent, and not already on another row. */
private eligibleGuarantors(group: AbstractControl, found: any[]): any[] {
const agentClientId = String(this.agentForm.get('clientId')?.value ?? '');
const taken = new Set(
this.guarantors.controls.filter((row) => row !== group).map((row) => String(row.get('clientId')?.value ?? ''))
);
return found.filter((client: any) => String(client.id) !== agentClientId && !taken.has(String(client.id)));
}
/** The same client cannot guarantee one agent twice - it counts as one guarantee, not two. */
private notAlreadyAGuarantor(): ValidatorFn {
return (control: AbstractControl): ValidationErrors | null => {
if (!control.value || !this.agentForm) {
return null;
}
const repeated = this.guarantors.controls.some(
(row) => row.get('clientId') !== control && String(row.get('clientId')?.value) === String(control.value)
);
return repeated ? { duplicateGuarantor: true } : null;
};
}
/**
* An agent cannot stand guarantor for itself - a guarantee is only worth something if it comes
* from someone else.
*/
private notTheAgentItself(): ValidatorFn {
return (control: AbstractControl): ValidationErrors | null => {
const agentClientId = this.agentForm?.get('clientId')?.value;
if (!control.value || !agentClientId) {
return null;
}
return String(control.value) === String(agentClientId) ? { selfGuarantor: true } : null;
};
}
/** Refills a guarantor row from its Client ID, mirroring the agent's own client lookup. */
private loadGuarantorClient(group: UntypedFormGroup, clientId: any): void {
for (const controlName of [
'guarantorName',
'telephone',
'address1',
'address2',
'city',
'pinCode'
]) {
group.get(controlName)?.setValue('');
}
if (!clientId) {
return;
}
this.clientsService.getClientData(String(clientId), true).subscribe({
next: (client: any) => {
setGroupField(group, 'guarantorName', client?.displayName);
setGroupField(group, 'telephone', client?.mobileNo);
this.clientsService.getClientAddressData(String(clientId), true).subscribe({
next: (addresses: any) => {
const address = Array.isArray(addresses) ? addresses[0] : addresses;
if (!address) {
return;
}
setGroupField(group, 'address1', address.addressLine1 || address.street);
setGroupField(group, 'address2', address.addressLine2);
setGroupField(group, 'city', address.city);
setGroupField(group, 'pinCode', address.postalCode);
},
error: () => undefined
});
},
// An id that matches no client just leaves the row for the officer to fill in.
error: () => undefined
});
}
addGuarantor(): void {
const group = this.buildGuarantorGroup();
this.guarantors.push(group);
// Ready before the field is ever focused, so the list is there the moment it is clicked.
this.setGuarantorOptions(group, this.eligibleGuarantors(group, this.initialClients));
}
removeGuarantor(index: number): void {
this.guarantors.removeAt(index);
// A row that clashed with the removed one no longer does.
for (const row of this.guarantors.controls) {
row.get('clientId')?.updateValueAndValidity({ emitEvent: false });
}
}
/** The fields the legacy screen filled from the client, rather than asking the officer for. */
private static readonly CLIENT_DERIVED_FIELDS = [
'agentNameId',
'agentTelephone',
'agentAddress1',
'agentAddress2',
'agentCity',
'agentPinCode'
];
/** Opens the client list when the field is focused, unless a client is already in it. */
onClientFocus(): void {
if (this.agentId || this.agentForm.get('clientId')?.value) {
return;
}
this.showClientList();
}
/** Shows the first page of clients, fetching it the first time it is asked for. */
private showClientList(): void {
if (this.agentId) {
return;
}
if (this.initialClients.length > 0) {
this.showingClientList = true;
this.clientOptions = this.notYetAgents(this.initialClients);
this.openWhenFocused(this.clientTrigger, this.clientInput?.nativeElement);
return;
}
// The chosen branch's clients only, since an agent is opened for the branch they belong to.
this.clientsService.getFilteredClients('displayName', 'ASC', false, '', this.branchOfficeId).subscribe({
next: (response: any) => {
const found = response?.pageItems || response || [];
this.initialClients = Array.isArray(found) ? found.slice(0, CreateAgentComponent.CLIENT_LIST_SIZE) : [];
this.clientTotal = response?.totalFilteredRecords ?? this.initialClients.length;
this.showingClientList = true;
this.clientOptions = this.notYetAgents(this.initialClients);
this.openWhenFocused(this.clientTrigger, this.clientInput?.nativeElement);
},
// A failed list is not a failed screen - the id can still be typed.
error: () => (this.clientOptions = [])
});
}
private searchClients(term: string): void {
searchClientsAnyCase(this.clientsService, term, this.branchOfficeId).subscribe((found: any[]) => {
this.showingClientList = false;
this.clientOptions = this.notYetAgents(found).slice(0, 25);
this.openWhenFocused(this.clientTrigger, this.clientInput?.nativeElement);
});
}
/**
* Shows a list that has just arrived. An autocomplete opened while its list was still empty stays
* shut when the options come in, so the screen is refreshed and the panel opened again - but only
* on the field still being typed in, never one the officer has moved on from.
*/
private openWhenFocused(trigger?: MatAutocompleteTrigger, input?: HTMLElement): void {
this.changeDetector.detectChanges();
if (trigger && input && document.activeElement === input) {
trigger.openPanel();
}
}
/**
* Refills the client-derived fields whenever the Client ID changes, as the legacy screen did.
* They always show the client currently entered: changing the id replaces them, clearing it
* empties them.
*/
loadClient(): void {
if (this.agentId) {
return;
}
this.clearClientDerivedFields();
this.agentForm.get('agentBirthDate')?.setValue('');
this.loadedClientName = '';
this.clientLookupFailed = false;
// A different client has different accounts, so anything picked for the previous one is stale.
this.clientSavingsAccounts = [];
this.savingsAccountTypes = [];
this.agentForm.get('agentSavingAccountType')?.setValue('');
this.agentForm.get('agentSavingAccountNo')?.setValue('');
this.clientOtherBranch = false;
this.clientAlreadyAgent = '';
this.clientAgeProblem = '';
const clientId = this.agentForm.get('clientId')?.value;
if (!clientId) {
return;
}
// One client is one agent: someone already on the books is not made a second agent.
this.clientAlreadyAgent = this.agentCodeByClientId[String(clientId)] || '';
if (this.clientAlreadyAgent) {
return;
}
this.clientsService.getClientData(String(clientId), true).subscribe({
next: (client: any) => {
// An agent is opened at the branch chosen, from that branch's own clients.
if (this.branchOfficeId && client?.officeId && Number(client.officeId) !== this.branchOfficeId) {
this.clientOtherBranch = true;
this.loadedClientName = '';
return;
}
this.loadedClientName = client?.displayName || '';
this.setField('agentNameId', client?.displayName);
this.setField('agentTelephone', client?.mobileNo);
// The client's own date of birth, already taken and checked when the client was opened -
// copied across rather than keyed again.
const birthDate = asDate(client?.dateOfBirth);
this.agentForm.get('agentBirthDate')?.setValue(birthDate);
// Agents are taken on at 16 to 59 - said here, before the rest is filled in, not on Save.
const age = ageOn(birthDate, new Date());
this.clientAgeProblem =
age === null
? 'This client has no date of birth on record'
: age < CreateAgentComponent.MIN_AGENT_AGE || age >= CreateAgentComponent.MAX_AGENT_AGE
? `Age is ${age}. An agent must be ${CreateAgentComponent.MIN_AGENT_AGE} to ${CreateAgentComponent.MAX_AGENT_AGE - 1} years old`
: '';
this.loadClientAddress(String(clientId));
this.loadClientSavingsAccounts(String(clientId));
},
error: () => {
this.clientLookupFailed = true;
}
});
}
/**
* The client's own open savings accounts, to be picked from rather than typed.
*
* The account number used to be free text, and the live data showed the cost: agent AG0001 carried
* "1234567890", which matches no account at all - so every commission recorded against it had
* nowhere to go. Offering only the client's real accounts makes that impossible to key, and the
* account number then carries its correct product prefix by construction rather than by memory.
*/
private loadClientSavingsAccounts(clientId: string): void {
this.clientsService.getClientAccountData(clientId).subscribe({
next: (accounts: any) => {
this.clientSavingsAccounts = (accounts?.savingsAccounts || []).filter(
(account: any) =>
// Savings only. Commission is credited whenever a run posts, and a fixed deposit is a
// single sum placed for a term while a recurring one takes only its own instalments -
// neither accepts the credit, so the posting would fail rather than the choice.
// A DDS scheme account is a savings account too and so passes the test above. It is
// dropped by the product the setting names - not by a product called "DDS", which is a
// name the bank may change - so the list offers only accounts the agent can be paid in.
account.depositType?.id === CreateAgentComponent.SAVINGS_DEPOSIT_TYPE &&
(this.ddsProductId === null || account.productId !== this.ddsProductId) &&
(account.status?.active || account.status?.submittedAndPendingApproval || account.status?.approved)
);
this.savingsAccountTypes = [
...new Set(this.clientSavingsAccounts.map((account: any) => account.productName).filter(Boolean))
];
for (const account of this.clientSavingsAccounts) {
if (account.productName && account.shortProductName) {
this.savingsAccountTypeCodes[account.productName] = account.shortProductName;
}
}
},
error: () => {
this.clientSavingsAccounts = [];
this.savingsAccountTypes = [];
}
});
}
/**
* Clears the account number when the type changes.
*
* Without this the previously picked account stays in the field while the list below it now shows
* a different type - the form would submit an account that contradicts its own type.
*/
onSavingsAccountTypePicked(): void {
this.agentForm.get('agentSavingAccountNo')?.setValue('');
}
private loadClientAddress(clientId: string): void {
this.clientsService.getClientAddressData(clientId, true).subscribe({
next: (addresses: any) => {
const address = Array.isArray(addresses) ? addresses[0] : addresses;
if (!address) {
return;
}
this.setField('agentAddress1', address.addressLine1 || address.street);
this.setField('agentAddress2', address.addressLine2);
this.setField('agentCity', address.city);
this.setField('agentPinCode', address.postalCode);
},
// A client with no address on file is normal — the officer just types it.
error: () => undefined
});
}
private clearClientDerivedFields(): void {
for (const controlName of CreateAgentComponent.CLIENT_DERIVED_FIELDS) {
this.agentForm.get(controlName)?.setValue('');
}
}
private setField(controlName: string, value: any): void {
if (value === null || value === undefined || value === '') {
return;
}
this.agentForm.get(controlName)?.setValue(String(value));
}
retrieve(): void {
const clientId = this.agentForm.get('clientId')?.value;
if (!clientId) {
return;
}
this.organizationService.getAgents().subscribe((agents: any[]) => {
const match = (agents || []).find((agent) => String(agent.clientId) === String(clientId));
if (match) {
this.patchFormFromAgent(match);
}
});
}
cancel(): void {
this.agentId = null;
this.loadedClientName = '';
this.clientLookupFailed = false;
this.agentForm.reset();
this.agentForm.patchValue({
agentCurrentBalance: 0,
agentOpeningBalance: 0
});
this.guarantors.clear();
}
exit(): void {
this.router.navigate(['/organization/agent-master']);
}
save(): void {
const raw = this.agentForm.getRawValue();
const dateFormat = this.settingsService.dateFormat;
const locale = this.settingsService.language.code;
const payload: any = {
clientId: Number(raw.clientId),
agentNameId: raw.agentNameId,
agentAddress1: raw.agentAddress1,
agentAddress2: raw.agentAddress2 || undefined,
agentCity: raw.agentCity,
agentPinCode: raw.agentPinCode,
agentTelephone: raw.agentTelephone || undefined,
officeId: Number(raw.officeId),
glAccountId: raw.glAccountId ? Number(raw.glAccountId) : undefined,
agentSavingAccountType: raw.agentSavingAccountType,
agentSavingAccountNo: raw.agentSavingAccountNo,
agentOpeningDate: this.dateUtils.formatDate(raw.agentOpeningDate, dateFormat),
agentBirthDate: this.dateUtils.formatDate(raw.agentBirthDate, dateFormat),
agentOpeningBalance: raw.agentOpeningBalance || 0,
dateFormat,
locale,
guarantors: (raw.guarantors || []).map((guarantor: any) => ({
clientId: Number(guarantor.clientId),
guarantorName: guarantor.guarantorName,
address1: guarantor.address1,
address2: guarantor.address2 || undefined,
city: guarantor.city,
pinCode: guarantor.pinCode,
telephone: guarantor.telephone || undefined
}))
};
const done = (response: any) => {
// Under maker-checker the command is only recorded, and nothing has been saved yet.
if (!!response?.commandId && response?.resourceId == null) {
this.alertService.alert({
type: 'Sent for Approval',
message: this.agentId
? `Changes to agent ${raw.agentNameId} were sent for a manager's approval and will take effect once approved.`
: `New agent ${raw.agentNameId} was sent for a manager's approval and will be created once approved.`
});
}
this.exit();
};
if (this.agentId) {
this.organizationService.updateAgent(this.agentId, payload).subscribe(done);
} else {
this.organizationService.createAgent(payload).subscribe(done);
}
}
private patchFormFromAgent(agent: any): void {
this.agentId = agent.id;
this.loadedClientName = agent.clientName || '';
this.clientLookupFailed = false;
this.agentForm.patchValue({
clientId: agent.clientId,
agentCode: agent.agentCode,
agentNameId: agent.agentNameId,
agentAddress1: agent.agentAddress1,
agentAddress2: agent.agentAddress2,
agentCity: agent.agentCity,
agentPinCode: agent.agentPinCode,
agentTelephone: agent.agentTelephone,
officeId: agent.officeId ? Number(agent.officeId) : '',
glAccountId: agent.glAccountId ? Number(agent.glAccountId) : '',
agentSavingAccountType: agent.agentSavingAccountType,
agentSavingAccountNo: agent.agentSavingAccountNo,
agentOpeningDate: agent.agentOpeningDate ? new Date(agent.agentOpeningDate) : null,
agentBirthDate: agent.agentBirthDate ? new Date(agent.agentBirthDate) : null,
agentCurrentBalance: agent.agentCurrentBalance,
agentOpeningBalance: agent.agentOpeningBalance
});
// The account list is needed for the dropdown to show the already-saved account as chosen
// rather than blank, so it is fetched on edit as well as on the client lookup.
if (agent.clientId) {
this.loadClientSavingsAccounts(String(agent.clientId));
}
this.guarantors.clear();
for (const guarantor of agent.guarantors || []) {
const group = this.buildGuarantorGroup();
group.patchValue({
clientId: guarantor.clientId,
guarantorName: guarantor.guarantorName,
address1: guarantor.address1,
address2: guarantor.address2,
city: guarantor.city,
pinCode: guarantor.pinCode,
telephone: guarantor.telephone
});
this.guarantors.push(group);
}
}
}
/** A date as the platform sends it - [yyyy, mm, dd] or an ISO string - or null when there is none. */
function asDate(value: any): Date | null {
if (Array.isArray(value) && value.length >= 3) {
return new Date(value[0], value[1] - 1, value[2]);
}
if (value) {
const parsed = new Date(value);
return isNaN(parsed.getTime()) ? null : parsed;
}
return null;
}
/** One shared empty list, so a row with nothing to offer hands the template the same array each time. */
const GUARANTOR_NO_OPTIONS: any[] = [];
/** Full years between a date of birth and a day, or null without a date of birth. */
function ageOn(birthDate: Date | null, on: Date): number | null {
if (!birthDate) {
return null;
}
let age = on.getFullYear() - birthDate.getFullYear();
const birthdayPassed =
on.getMonth() > birthDate.getMonth() ||
(on.getMonth() === birthDate.getMonth() && on.getDate() >= birthDate.getDate());
if (!birthdayPassed) {
age--;
}
return age;
}
@@ -0,0 +1,27 @@
/**
* Copyright since 2025 Mifos Initiative
*
* This Source Code Form is subject to the terms of the Mozilla Public
* License, v. 2.0. If a copy of the MPL was not distributed with this
* file, You can obtain one at http://mozilla.org/MPL/2.0/.
*/
/**
* The given office and every office under it, read from the office hierarchy (".", ".2.", ".2.5."):
* an office's own branches are the ones whose hierarchy starts with its own. Head Office, at the
* top, has them all. Falls back to the office alone when its hierarchy is not known.
*/
export function officesUnder(offices: any[], officeId: number | null | undefined): any[] {
if (!officeId) {
return offices;
}
const own = offices.find((office: any) => office.id === officeId);
if (!own) {
return [];
}
const hierarchy = String(own.hierarchy || '');
if (!hierarchy) {
return [own];
}
return offices.filter((office: any) => String(office.hierarchy || '').startsWith(hierarchy));
}
@@ -0,0 +1,165 @@
<!--
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="csm-container">
<div class="csm-header">
<button mat-stroked-button [routerLink]="['../']" class="csm-back">
<i class="fas fa-arrow-left"></i> {{ 'labels.heading.Organization' | translate }}
</button>
<h2 class="csm-header__title">Commission Slab Master</h2>
</div>
<div class="csm-card">
<table class="csm-table">
<thead>
<tr>
<th>Slab Type</th>
<th>Slab From</th>
<th>Slab To</th>
<th>Comm Per</th>
<th></th>
</tr>
</thead>
<tbody>
@for (slab of slabs; track slab; let i = $index) {
@if (editingIndex === i) {
<!-- The same row turned into inputs, so a correction is made where the value is read. -->
<tr [formGroup]="editSlabForm" class="csm-new-row">
<td>
<mat-form-field appearance="outline" class="csm-inline-field">
<mat-select formControlName="slabType">
@for (type of slabTypes; track type) {
<mat-option [value]="type">{{ type }}</mat-option>
}
</mat-select>
</mat-form-field>
</td>
<td>
<mat-form-field appearance="outline" class="csm-inline-field">
<input matInput type="number" formControlName="slabFrom" />
</mat-form-field>
</td>
<td>
<mat-form-field appearance="outline" class="csm-inline-field">
<input matInput type="number" formControlName="slabTo" />
</mat-form-field>
</td>
<td>
<mat-form-field appearance="outline" class="csm-inline-field">
<input matInput type="number" formControlName="commissionPercent" />
</mat-form-field>
</td>
<td class="csm-row-actions">
<button mat-icon-button color="primary" type="button" (click)="saveEdit()" matTooltip="Save correction">
<i class="fas fa-check"></i>
</button>
<button mat-icon-button type="button" (click)="cancelEdit()" matTooltip="Cancel">
<i class="fas fa-times"></i>
</button>
</td>
</tr>
} @else {
<tr>
<td>{{ slab.slabType }}</td>
<td>{{ slab.slabFrom }}</td>
<td>{{ slab.slabTo }}</td>
<td>{{ slab.commissionPercent }}%</td>
<td class="csm-row-actions">
@if (slab.id) {
<!--
A saved slab is corrected, never removed. Deleting a range would leave a hole
that pays nothing to any agent whose volume lands in it, with nothing on screen
to say why - and there is deliberately no delete endpoint behind it.
-->
<button mat-icon-button color="primary" type="button" (click)="startEdit(i)" matTooltip="Edit slab">
<i class="fas fa-pen"></i>
</button>
} @else {
<!-- Not saved yet, so taking it back off the table costs nothing. -->
<button mat-icon-button type="button" (click)="removeSlab(i)" matTooltip="Remove before saving">
<i class="fas fa-times"></i>
</button>
}
</td>
</tr>
}
}
<tr [formGroup]="newSlabForm" class="csm-new-row">
<td>
<mat-form-field appearance="outline" class="csm-inline-field">
<mat-select required formControlName="slabType" placeholder="Slab Type">
@for (type of slabTypes; track type) {
<mat-option [value]="type">{{ type }}</mat-option>
}
</mat-select>
</mat-form-field>
</td>
<td>
<mat-form-field appearance="outline" class="csm-inline-field">
<input matInput required type="number" formControlName="slabFrom" />
</mat-form-field>
</td>
<td>
<mat-form-field appearance="outline" class="csm-inline-field">
<input matInput required type="number" formControlName="slabTo" />
</mat-form-field>
</td>
<td>
<mat-form-field appearance="outline" class="csm-inline-field">
<input matInput required type="number" formControlName="commissionPercent" />
</mat-form-field>
</td>
<td class="csm-row-actions">
<!--
Deliberately not disabled while the row is incomplete. A greyed-out button says the
screen will not accept this, but never which field it is waiting for; letting the
click through means addSlab() can say so.
-->
<button
mat-icon-button
color="primary"
type="button"
(click)="addSlab()"
matTooltip="Add slab"
>
<i class="fas fa-plus"></i>
</button>
</td>
</tr>
</tbody>
</table>
@if (slabs.length === 0) {
<p class="csm-empty">No commission slabs defined yet — add one using the row above.</p>
}
@if (coverage.length > 0) {
<div class="csm-coverage">
@for (entry of coverage; track entry.slabType) {
<div class="csm-coverage__row">
<span class="csm-coverage__type">{{ entry.slabType }}</span>
<span class="csm-coverage__range">
covers {{ entry.from | number }} to {{ entry.to | number }}
</span>
@if (entry.gaps.length > 0) {
<span class="csm-coverage__gap">
nothing set for {{ entry.gaps.join(', ') }} — an agent landing there earns no commission
</span>
}
</div>
}
</div>
}
<div class="csm-action-bar">
<button mat-raised-button type="button" (click)="exit()">Exit</button>
<button mat-raised-button color="primary" type="button" (click)="save()">Save</button>
</div>
</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/.
*/
$primary: #0f766e;
.csm-container {
// Four inputs sit side by side in the entry row; 760px squeezed them out of the card.
max-width: 980px;
margin: 0 auto;
padding: 24px 16px;
}
.csm-header {
display: flex;
align-items: center;
gap: 16px;
margin-bottom: 20px;
&__title {
margin: 0;
font-size: 22px;
font-weight: 500;
}
}
.csm-back {
color: $primary;
}
.csm-card {
background: var(--md-sys-color-surface, #fff);
border-radius: 8px;
box-shadow: 0 2px 8px rgb(0 0 0 / 8%);
padding: 16px 24px 24px;
}
.csm-table {
width: 100%;
border-collapse: collapse;
table-layout: fixed;
// Sized rather than left to the browser, so the entry row keeps its shape whatever is typed in.
th:nth-child(1), td:nth-child(1) { width: 26%; }
th:nth-child(2), td:nth-child(2) { width: 22%; }
th:nth-child(3), td:nth-child(3) { width: 22%; }
th:nth-child(4), td:nth-child(4) { width: 18%; }
th:nth-child(5), td:nth-child(5) { width: 12%; }
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: 6px 10px;
border-bottom: 1px solid rgb(0 0 0 / 6%);
vertical-align: middle;
}
}
.csm-inline-field {
width: 100%;
// Without this the field holds its own 180px floor and pushes the next column out of the card.
min-width: 0;
::ng-deep .mat-mdc-text-field-wrapper {
height: 44px;
}
}
.csm-row-actions {
text-align: center;
width: 48px;
}
.csm-new-row {
background: rgb(15 118 110 / 4%);
}
.csm-empty {
color: rgb(0 0 0 / 50%);
font-size: 14px;
margin: 12px 0 0;
}
.csm-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%);
}
/*
* What the slabs actually cover, and any figure between them that none of them claims.
*
* A gap is legitimate - a bank may set no commission below some floor - so this reports rather than
* warns. But it has to be said out loud: the commission run finds no slab for a volume in a gap and
* moves on, so the agent is simply paid nothing that month and nothing on screen explains it.
*/
.csm-coverage {
margin-top: 14px;
padding: 12px 16px;
border: 1px solid rgb(0 0 0 / 12%);
border-radius: 6px;
background: rgb(0 0 0 / 2.5%);
display: flex;
flex-direction: column;
gap: 6px;
&__row {
display: flex;
align-items: baseline;
gap: 10px;
flex-wrap: wrap;
font-size: 0.86rem;
}
&__type {
font-weight: 600;
min-width: 90px;
}
&__range {
color: rgb(0 0 0 / 62%);
font-variant-numeric: tabular-nums;
}
&__gap {
color: #b45309;
font-variant-numeric: tabular-nums;
}
}
@@ -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/.
*/
/** Angular Imports */
import { Component, OnInit, inject } from '@angular/core';
import { UntypedFormBuilder, UntypedFormGroup, 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 { AlertService } from 'app/core/alert/alert.service';
import { announceHeldForApproval, heldForApproval } from 'app/deposits/dds-approvals/dds-approval-notice';
/**
* Commission Slab Master component.
*
* Two kinds of slab: ACCOUNT (commission percentage on collection volume)
* and COMMISSION (a senior-agent override on another agent's commission -
* deferred: there is no agent hierarchy modelled yet, see the DDS punch
* list). Modelled directly on the legacy Commission Slab Master screen this
* replaces.
*
* Backed by its own DDS Commission Slab API (`/dds/commissionslabs`) - an
* append-only chart, same as the legacy screen: existing rows load on open,
* newly-added rows post on Save, and there is no edit/delete of a
* previously-saved row.
*/
@Component({
selector: 'mifosx-commission-slab-master',
templateUrl: './commission-slab-master.component.html',
styleUrls: ['./commission-slab-master.component.scss'],
imports: [...STANDALONE_SHARED_IMPORTS]
})
export class CommissionSlabMasterComponent implements OnInit {
private formBuilder = inject(UntypedFormBuilder);
private router = inject(Router);
private organizationService = inject(OrganizationService);
private alertService = inject(AlertService);
private static readonly SLAB_TYPE_CODES: Record<string, string> = {
Account: 'ACCOUNT',
Commission: 'COMMISSION'
};
private static readonly SLAB_TYPE_LABELS: Record<string, string> = {
ACCOUNT: 'Account',
COMMISSION: 'Commission'
};
slabTypes = [
'Account',
'Commission'
];
displayedColumns: string[] = [
'slabType',
'slabFrom',
'slabTo',
'commissionPercent',
'actions'
];
slabs: Array<{ id?: number; slabType: string; slabFrom: number; slabTo: number; commissionPercent: number }> = [];
newSlabForm: UntypedFormGroup = this.formBuilder.group({
slabType: [
'',
Validators.required
],
slabFrom: [
0,
Validators.required
],
slabTo: [
0,
Validators.required
],
commissionPercent: [
0,
Validators.required
]
});
ngOnInit(): void {
this.loadSlabs();
}
private loadSlabs(): void {
this.organizationService.getCommissionSlabs().subscribe((slabs: any[]) => {
this.slabs = (slabs || []).map((slab) => ({
id: slab.id,
slabType: CommissionSlabMasterComponent.SLAB_TYPE_LABELS[slab.slabType] || slab.slabType,
slabFrom: slab.slabFrom,
slabTo: slab.slabTo,
commissionPercent: slab.commissionPercent
}));
});
}
/**
* Where each slab type's coverage begins and ends, and any figure in between that no slab claims.
*
* A gap is not an error - a bank may well decide there is no commission below some floor - but it
* has to be visible. The commission run simply finds no slab for a volume that falls in a gap and
* moves on, so the agent is paid nothing for that month with nothing on screen to say why.
*/
get coverage(): Array<{ slabType: string; from: number; to: number; gaps: string[] }> {
return this.slabTypes
.map((slabType) => {
const rows = this.slabs
.filter((slab) => slab.slabType === slabType)
.map((slab) => ({ from: Number(slab.slabFrom), to: Number(slab.slabTo) }))
.sort((a, b) => a.from - b.from);
if (rows.length === 0) {
return null;
}
const gaps: string[] = [];
for (let i = 1; i < rows.length; i++) {
const previousEnd = rows[i - 1].to;
const nextStart = rows[i].from;
if (nextStart > previousEnd + 1) {
gaps.push(`${previousEnd + 1} - ${nextStart - 1}`);
}
}
return { slabType, from: rows[0].from, to: rows[rows.length - 1].to, gaps };
})
.filter((entry): entry is { slabType: string; from: number; to: number; gaps: string[] } => entry !== null);
}
/**
* The slab already on the table that this range would collide with, if any.
*
* The backend refuses overlapping slabs, but only when Save is pressed - by then the officer may
* have keyed several rows and has to work out which one it objected to. Catching it as the row is
* added names the clash while it is still the row in front of them.
*/
private clashingSlab(candidate: { slabType: string; slabFrom: number; slabTo: number }): any {
return this.slabs.find(
(slab) =>
slab.slabType === candidate.slabType &&
Number(candidate.slabFrom) <= Number(slab.slabTo) &&
Number(candidate.slabTo) >= Number(slab.slabFrom)
);
}
addSlab(): void {
if (this.newSlabForm.invalid) {
// Marking the controls touched is what makes Material show which one is missing; without it
// the button simply does nothing and the officer has no idea what the screen is waiting for.
this.newSlabForm.markAllAsTouched();
this.alertService.alert({ type: 'Error', message: 'Fill the slab type, range and percentage first' });
return;
}
const candidate = this.newSlabForm.getRawValue();
if (Number(candidate.slabFrom) > Number(candidate.slabTo)) {
this.alertService.alert({ type: 'Error', message: 'Slab From cannot be greater than Slab To' });
return;
}
if (Number(candidate.commissionPercent) > 100) {
this.alertService.alert({ type: 'Error', message: 'Commission cannot be more than 100%' });
return;
}
const clash = this.clashingSlab(candidate);
if (clash) {
this.alertService.alert({
type: 'Error',
message: `Overlaps the existing ${clash.slabType} slab ${clash.slabFrom} to ${clash.slabTo}`
});
return;
}
this.slabs.push(candidate);
this.newSlabForm.reset({
slabType: '',
slabFrom: 0,
slabTo: 0,
commissionPercent: 0
});
}
/** Only ever an unsaved row - a saved slab is corrected, not taken away. */
removeSlab(index: number): void {
this.slabs.splice(index, 1);
}
/** Which saved row is currently open for correction, or -1 when none is. */
editingIndex = -1;
editSlabForm: UntypedFormGroup = this.formBuilder.group({
slabType: [
'',
Validators.required
],
slabFrom: [
0,
Validators.required
],
slabTo: [
0,
Validators.required
],
commissionPercent: [
0,
Validators.required
]
});
startEdit(index: number): void {
const slab = this.slabs[index];
this.editingIndex = index;
this.editSlabForm.setValue({
slabType: slab.slabType,
slabFrom: slab.slabFrom,
slabTo: slab.slabTo,
commissionPercent: slab.commissionPercent
});
}
cancelEdit(): void {
this.editingIndex = -1;
}
saveEdit(): void {
const slab = this.slabs[this.editingIndex];
const corrected = this.editSlabForm.getRawValue();
if (this.editSlabForm.invalid) {
this.editSlabForm.markAllAsTouched();
this.alertService.alert({ type: 'Error', message: 'Fill the slab type, range and percentage' });
return;
}
if (Number(corrected.slabFrom) > Number(corrected.slabTo)) {
this.alertService.alert({ type: 'Error', message: 'Slab From cannot be greater than Slab To' });
return;
}
if (Number(corrected.commissionPercent) > 100) {
this.alertService.alert({ type: 'Error', message: 'Commission cannot be more than 100%' });
return;
}
this.organizationService
.updateCommissionSlab(slab.id, {
slabType: CommissionSlabMasterComponent.SLAB_TYPE_CODES[corrected.slabType] || corrected.slabType,
slabFrom: corrected.slabFrom,
slabTo: corrected.slabTo,
commissionPercent: corrected.commissionPercent,
locale: 'en'
})
.subscribe({
next: (response: any) => {
if (heldForApproval(response)) {
announceHeldForApproval(response, 'The slab correction', this.organizationService, this.alertService);
} else {
this.alertService.alert({ type: 'Success', message: 'Slab corrected' });
}
this.editingIndex = -1;
this.loadSlabs();
},
// The backend refuses a correction that overlaps another slab; the reason has to reach the
// officer, not vanish.
error: (error: any) => {
this.alertService.alert({
type: 'Error',
message:
error?.error?.errors?.[0]?.defaultUserMessage ||
error?.error?.defaultUserMessage ||
'Could not correct the slab'
});
}
});
}
exit(): void {
this.router.navigate(['/organization']);
}
save(): void {
const newSlabs = this.slabs.filter((slab) => !slab.id);
if (newSlabs.length === 0) {
// Save used to return here without a word, so a slab typed into the entry row but never added
// looked exactly like a slab that had been saved.
this.alertService.alert({
type: 'Error',
message: 'Nothing new to save — add the slab to the table with + first'
});
return;
}
const payload = newSlabs.map((slab) => ({
slabType: CommissionSlabMasterComponent.SLAB_TYPE_CODES[slab.slabType] || slab.slabType,
slabFrom: slab.slabFrom,
slabTo: slab.slabTo,
commissionPercent: slab.commissionPercent
}));
this.organizationService.createCommissionSlabs(payload).subscribe({
next: (response: any) => {
if (heldForApproval(response)) {
announceHeldForApproval(
response,
`${newSlabs.length} ${newSlabs.length === 1 ? 'slab' : 'slabs'}`,
this.organizationService,
this.alertService
);
} else {
this.alertService.alert({
type: 'Success',
message: `${newSlabs.length} ${newSlabs.length === 1 ? 'slab' : 'slabs'} saved`
});
}
this.loadSlabs();
},
// The backend refuses an overlapping range; the officer needs to read why, not watch nothing
// happen and assume the save went through.
error: (error: any) => {
this.alertService.alert({
type: 'Error',
message:
error?.error?.errors?.[0]?.defaultUserMessage ||
error?.error?.defaultUserMessage ||
'Could not save the slabs'
});
}
});
}
}
@@ -0,0 +1,298 @@
<!--
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="bp-container">
<div class="bp-header">
<button mat-stroked-button [routerLink]="['../']" class="bp-back">
<i class="fas fa-arrow-left"></i> {{ 'labels.heading.Organization' | translate }}
</button>
<h2 class="bp-header__title">DDS Interest &amp; Commission</h2>
</div>
<div class="bp-card">
<form [formGroup]="batchForm">
<div class="bp-grid">
<mat-form-field appearance="outline" class="bp-field bp-field--wide">
<mat-label>Job Type</mat-label>
<mat-select required formControlName="jobType">
@for (type of jobTypes; track type.code) {
<mat-option [value]="type.code">{{ type.label }}</mat-option>
}
</mat-select>
</mat-form-field>
<mat-form-field appearance="outline" class="bp-field">
<mat-label>Branch</mat-label>
<mat-select required formControlName="branchScope">
@for (scope of branchScopes; track scope) {
<mat-option [value]="scope">{{ scope }}</mat-option>
}
</mat-select>
</mat-form-field>
@if (isBranchSelected) {
<div class="bp-field bp-field--wide bp-branch-checklist">
@for (office of offices; track office.id) {
<mat-checkbox [checked]="selectedOfficeNames.has(office.name)" (change)="toggleOffice(office.name)">
{{ office.name }}
</mat-checkbox>
}
@if (offices.length === 0) {
<span class="bp-empty">Loading branches...</span>
}
</div>
}
<mat-form-field appearance="outline" class="bp-field">
<mat-label>{{ isPostingJob ? 'Posting Date' : 'Interest Date' }}</mat-label>
<input matInput required [matDatepicker]="jobDatePicker" formControlName="jobDate" />
<mat-datepicker-toggle matIconSuffix [for]="jobDatePicker"></mat-datepicker-toggle>
<mat-datepicker #jobDatePicker></mat-datepicker>
</mat-form-field>
<mat-form-field appearance="outline" class="bp-field">
<mat-label>Agent From</mat-label>
<input matInput required type="number" formControlName="agentFrom" />
</mat-form-field>
<mat-form-field appearance="outline" class="bp-field">
<mat-label>Agent To</mat-label>
<input matInput required type="number" formControlName="agentTo" />
</mat-form-field>
<!-- Commission is worked out per agent on their whole collection, so a party range would
imply a filter that has no effect. -->
@if (!isCommissionJob) {
<mat-form-field appearance="outline" class="bp-field">
<mat-label>Party From</mat-label>
<input matInput required type="number" formControlName="partyFrom" />
</mat-form-field>
<mat-form-field appearance="outline" class="bp-field">
<mat-label>Party To</mat-label>
<input matInput required type="number" formControlName="partyTo" />
</mat-form-field>
}
</div>
<div class="bp-callout">
<i class="fas fa-info-circle"></i>
<span>
@if (isPostingJob) {
<strong>{{ jobTypeLabel }}</strong> moves what has already been calculated to where it belongs — into the
customer's balance, or out to the agent. <strong>Generate</strong> lists it first; nothing moves until you
press <strong>Post</strong>.
} @else {
<strong>{{ jobTypeLabel }}</strong> works out what has been earned since the last run and records it against
each account. It does not become spendable until the matching Posting job runs.
<strong>Generate</strong> previews it; nothing is recorded until you press <strong>Post</strong>.
}
</span>
</div>
@if (generatedRun) {
<div class="bp-section">
<div class="bp-section__header">
<i class="fas fa-table"></i>
<h3>{{ labelOf(generatedRun.jobType) }} — preview</h3>
<span class="bp-section__note">
Run #{{ generatedRun.id }} · nothing applied yet
</span>
</div>
@if (previewRows.length === 0) {
<p class="bp-empty">
Nothing to apply. Either this period has already been processed, or there was no activity in it.
</p>
} @else {
<div class="bp-table-scroll">
@if (showsCommissionBreakdown) {
<table class="bp-table bp-table--breakdown">
<thead>
<tr>
<th>Agent</th>
<th>Period</th>
<th class="bp-num">Collection</th>
<th class="bp-num">Rate</th>
<th class="bp-num">Commission</th>
<th class="bp-num">TDS</th>
<th class="bp-num">Deductions</th>
<th class="bp-num">Payable</th>
</tr>
</thead>
<tbody>
@for (row of previewRows; track row.id) {
<tr>
<td>
<span class="bp-code">{{ row.agentCode }}</span>
<span class="bp-sub">{{ row.agentName }}</span>
</td>
<td class="bp-period">{{ row.fromDate | dateFormat }} – {{ row.toDate | dateFormat }}</td>
<td class="bp-num">{{ money(row.baseAmount) }}</td>
<td class="bp-num">{{ row.ratePercent }}%</td>
<td class="bp-num">{{ money(row.grossAmount) }}</td>
<td class="bp-num bp-minus">{{ money(row.tdsAmount) }}</td>
<td class="bp-num bp-minus">{{ money(row.deductionAmount) }}</td>
<td class="bp-num bp-total">{{ money(row.amount) }}</td>
</tr>
}
</tbody>
<tfoot>
<tr>
<td colspan="7">Total payable</td>
<td class="bp-num bp-total">{{ money(previewTotal) }}</td>
</tr>
</tfoot>
</table>
} @else {
<table class="bp-table">
<thead>
<tr>
<th>{{ isCommissionJob ? 'Agent' : 'Party' }}</th>
@if (!isCommissionJob) {
<th>Agent</th>
}
<th>Period</th>
<th class="bp-num">Amount</th>
</tr>
</thead>
<tbody>
@for (row of previewRows; track row.id) {
<tr>
<td>
@if (isCommissionJob) {
<span class="bp-code">{{ row.agentCode }}</span>
<span class="bp-sub">{{ row.agentName }}</span>
} @else {
<span class="bp-code">{{ row.partyCode }}</span>
<span class="bp-sub">{{ row.partyName }}</span>
}
</td>
@if (!isCommissionJob) {
<td>{{ row.agentCode }}</td>
}
<td class="bp-period">{{ row.fromDate | dateFormat }} – {{ row.toDate | dateFormat }}</td>
<td class="bp-num bp-total">{{ money(row.amount) }}</td>
</tr>
}
</tbody>
<tfoot>
<tr>
<td [attr.colspan]="isCommissionJob ? 2 : 3">Total</td>
<td class="bp-num bp-total">{{ money(previewTotal) }}</td>
</tr>
</tfoot>
</table>
}
</div>
}
</div>
}
<div class="bp-action-bar">
<button mat-raised-button type="button" (click)="exit()">Exit</button>
@if (generatedRun) {
<button mat-stroked-button type="button" (click)="discard()">Discard</button>
}
<button mat-stroked-button type="button" [disabled]="batchForm.invalid" (click)="generate()">
<i class="fas fa-calculator"></i> Generate
</button>
<button
mat-raised-button
color="primary"
type="button"
[disabled]="!generatedRun || previewRows.length === 0"
(click)="post()"
>
<i class="fas fa-check"></i> Post
</button>
</div>
</form>
</div>
<!--
A crediting run costs what a savings deposit costs on every account it touches, so the server does
it in the background and answers at once. Without this the screen would report a run as posted
seconds after it began, and the clerk would go looking for credits still being written.
-->
@if (postingRunId) {
<div class="bp-running">
<i class="fas fa-circle-notch fa-spin"></i>
<span>
<strong>Posting is running.</strong>
It continues even if you leave this screen — the list below updates when it finishes.
</span>
</div>
}
<!-- Without this a generated-but-unposted run vanishes the moment the page is left, and there is
no way to tell whether a month's interest was ever actually posted. -->
<div class="bp-card">
<div class="bp-section__header">
<i class="fas fa-history"></i>
<h3>Previous runs</h3>
</div>
@if (recentRuns.length === 0) {
<p class="bp-empty">No batch has been run yet.</p>
} @else {
<div class="bp-table-scroll">
<table class="bp-table bp-table--runs">
<thead>
<tr>
<th>#</th>
<th>Job</th>
<th>Date</th>
<th class="bp-num">Entries</th>
<th class="bp-num">Amount</th>
<th>Status</th>
<th>Posted On</th>
</tr>
</thead>
<tbody>
@for (run of recentRuns; track run.id) {
<tr>
<td>{{ run.id }}</td>
<td>{{ labelOf(run.jobType) }}</td>
<td>{{ run.jobDate | dateFormat }}</td>
<td class="bp-num">{{ run.totalRows }}</td>
<td class="bp-num">{{ money(run.totalAmount) }}</td>
<!--
Four states, not two. A run that is still crediting is not "not posted" — that
reads as a run somebody forgot, and a clerk would run it again on top of the one
already going. One that failed says so, with the reason the server recorded.
-->
<td>
@switch (run.status) {
@case ('POSTED') {
<span class="bp-status bp-status--posted">Posted</span>
}
@case ('POSTING') {
<span class="bp-status bp-status--running">
<i class="fas fa-circle-notch fa-spin"></i> Running
</span>
}
@case ('FAILED') {
<span class="bp-status bp-status--failed" [matTooltip]="run.failureReason || 'No reason recorded'">
Failed
</span>
}
@default {
<span class="bp-status">Not posted</span>
}
}
</td>
<td>{{ run.postedOnDate ? (run.postedOnDate | dateFormat) : '—' }}</td>
</tr>
}
</tbody>
</table>
</div>
}
</div>
</div>
@@ -0,0 +1,255 @@
/**
* 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;
.bp-container {
// The commission breakdown carries eight columns; anything narrower makes it scroll on every
// screen rather than only on small ones.
max-width: 1180px;
margin: 0 auto;
padding: 24px 16px;
display: flex;
flex-direction: column;
gap: 20px;
}
.bp-header {
display: flex;
align-items: center;
gap: 16px;
&__title {
margin: 0;
font-size: 22px;
font-weight: 500;
}
}
.bp-back {
color: $primary;
}
.bp-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;
}
.bp-grid {
display: grid;
grid-template-columns: repeat(3, 1fr);
gap: 8px 16px;
}
.bp-field {
width: 100%;
&--wide {
grid-column: span 3;
}
}
.bp-branch-checklist {
display: flex;
flex-wrap: wrap;
gap: 8px 20px;
align-items: center;
padding: 8px 0;
}
.bp-callout {
display: flex;
gap: 10px;
align-items: flex-start;
background: rgb(15 118 110 / 6%);
border-left: 3px solid $primary;
border-radius: 0 8px 8px 0;
padding: 12px 16px;
margin: 16px 0;
font-size: 13px;
i {
color: $primary;
margin-top: 2px;
}
}
.bp-section {
padding: 16px 0;
border-top: 1px solid rgb(0 0 0 / 8%);
&__header {
display: flex;
align-items: center;
gap: 10px;
margin-bottom: 12px;
i {
color: $primary;
}
h3 {
margin: 0;
font-size: 16px;
font-weight: 600;
flex: 0 0 auto;
}
}
&__note {
margin-left: auto;
font-size: 12px;
color: rgb(0 0 0 / 45%);
}
}
.bp-empty {
color: rgb(0 0 0 / 50%);
font-size: 14px;
margin: 0;
}
// A wide table scrolls inside its own box rather than pushing the whole page sideways.
.bp-table-scroll {
overflow-x: auto;
}
.bp-table {
width: 100%;
border-collapse: collapse;
font-size: 13px;
th {
text-align: left;
font-size: 12px;
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%);
white-space: nowrap;
}
td {
padding: 7px 10px;
border-bottom: 1px solid rgb(0 0 0 / 6%);
vertical-align: top;
}
tfoot td {
border-bottom: none;
border-top: 2px solid rgb(0 0 0 / 10%);
font-weight: 600;
}
// Figures line up column-wise so a wrong order of magnitude is visible at a glance.
.bp-num {
text-align: right;
font-variant-numeric: tabular-nums;
white-space: nowrap;
}
.bp-minus {
color: #b91c1c;
}
.bp-total {
font-weight: 600;
}
.bp-period {
white-space: nowrap;
color: rgb(0 0 0 / 60%);
}
}
.bp-code {
display: block;
font-weight: 600;
}
.bp-sub {
display: block;
font-size: 12px;
color: rgb(0 0 0 / 55%);
}
.bp-status {
display: inline-block;
padding: 2px 8px;
border-radius: 10px;
font-size: 12px;
font-weight: 600;
background: rgb(180 83 9 / 12%);
color: #92400e;
&--posted {
background: rgb(15 118 110 / 12%);
color: $primary;
}
/* Still crediting. Blue rather than the amber of a run nobody has posted — one is work in hand,
the other is work forgotten, and a clerk who reads them the same way runs it twice. */
&--running {
background: rgb(22 84 143 / 12%);
color: #16548f;
display: inline-flex;
align-items: center;
gap: 5px;
}
&--failed {
background: rgb(163 42 28 / 12%);
color: #a32a1c;
cursor: help;
}
}
/* Says a run is still going after the clerk has left the button behind. */
.bp-running {
display: flex;
align-items: center;
gap: 10px;
margin-bottom: 16px;
padding: 12px 16px;
border: 1px solid rgb(22 84 143 / 25%);
border-left: 3px solid #16548f;
border-radius: 6px;
background: rgb(22 84 143 / 6%);
color: #16548f;
font-size: 13px;
strong {
font-weight: 600;
}
}
.bp-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%);
i {
margin-right: 6px;
}
}
@media (width <= 900px) {
.bp-grid {
grid-template-columns: repeat(2, 1fr);
}
.bp-field--wide {
grid-column: span 2;
}
}
@@ -0,0 +1,400 @@
/**
* 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 { UntypedFormBuilder, UntypedFormGroup, Validators } from '@angular/forms';
import { Router } from '@angular/router';
import { MatDialog } from '@angular/material/dialog';
import { MatTooltip } from '@angular/material/tooltip';
import { DateAdapter, MAT_DATE_FORMATS } from '@angular/material/core';
import { STANDALONE_SHARED_IMPORTS } from 'app/standalone-shared.module';
import { ConfirmationDialogComponent } from 'app/shared/confirmation-dialog/confirmation-dialog.component';
import { DdsDateAdapter, DDS_DATE_FORMATS } from './dds-date-adapter';
/** Custom Services */
import { OrganizationService } from 'app/organization/organization.service';
import { AlertService } from 'app/core/alert/alert.service';
import { SettingsService } from 'app/settings/settings.service';
import { Dates } from 'app/core/utils/dates';
import { announceHeldForApproval, heldForApproval } from 'app/deposits/dds-approvals/dds-approval-notice';
/**
* DDS Interest & Commission batch processing — the legacy Interest Part menu.
*
* Eight menu entries, which are four Calculate/Post pairs. A *calculation* job works out what has
* been earned since the last one and parks it: interest against the customer's account, commission
* against the agent. Neither is spendable yet. The matching *posting* job then moves that parked
* figure where it belongs — into the customer's balance, or out to the agent.
*
* Every job is itself a two-step Generate then Post. Generate saves what it worked out and changes
* nothing else, so a figure that looks wrong is discarded by simply not posting it.
*/
@Component({
selector: 'mifosx-dds-batch-processing',
templateUrl: './dds-batch-processing.component.html',
styleUrls: ['./dds-batch-processing.component.scss'],
imports: [
...STANDALONE_SHARED_IMPORTS,
// The reason a run failed is too long to sit in the column, and too useful to leave out.
MatTooltip
],
// Day-first dates, as the legacy screen showed them. Declared here rather than application-wide:
// a @Component's providers reach only this component, so no other screen's datepicker changes.
providers: [
{ provide: DateAdapter, useClass: DdsDateAdapter },
{ provide: MAT_DATE_FORMATS, useValue: DDS_DATE_FORMATS }
]
})
export class DdsBatchProcessingComponent implements OnInit {
private formBuilder = inject(UntypedFormBuilder);
private router = inject(Router);
private organizationService = inject(OrganizationService);
private alertService = inject(AlertService);
private dialog = inject(MatDialog);
private settingsService = inject(SettingsService);
private dateUtils = inject(Dates);
/**
* The menu as the legacy system lists it, paired with what the API calls each job. Ordered as
* calculate-then-post within each pair, matching the numbering on the legacy screen.
*/
jobTypes: Array<{ label: string; code: string }> = [
{ label: 'Interest Calculation', code: 'INTEREST_CALCULATION' },
{ label: 'Interest Posting', code: 'INTEREST_POSTING' },
{ label: 'OD Interest Calculation', code: 'OD_INTEREST_CALCULATION' },
{ label: 'OD Interest Posting', code: 'OD_INTEREST_POSTING' },
{ label: 'Commission Calculation', code: 'COMMISSION_CALCULATION' },
{ label: 'Commission Posting', code: 'COMMISSION_POSTING' },
// Last, and on its own: the others move money onto accounts that stay open, while this one pays
// an account out and shuts it. Listing it beside a calculate/post pair would suggest it is the
// second half of one.
{ label: 'Maturity Auto Close', code: 'MATURITY_AUTO_CLOSE' }
];
branchScopes = [
'All',
'Selected'
];
private static readonly MONEY = new Intl.NumberFormat('en-IN', {
minimumFractionDigits: 2,
maximumFractionDigits: 2
});
offices: any[] = [];
selectedOfficeNames = new Set<string>();
/** The run currently previewed on screen, or null once it has been posted or discarded. */
generatedRun: any = null;
previewRows: any[] = [];
recentRuns: any[] = [];
batchForm: UntypedFormGroup = this.formBuilder.group({
jobType: [
'INTEREST_CALCULATION',
Validators.required
],
branchScope: [
'All',
Validators.required
],
jobDate: [
new Date(),
Validators.required
],
agentFrom: [
1,
Validators.required
],
agentTo: [
99999,
Validators.required
],
partyFrom: [
1,
Validators.required
],
partyTo: [
99999999,
Validators.required
]
});
get jobType(): string {
return this.batchForm.get('jobType').value;
}
get jobTypeLabel(): string {
return this.jobTypes.find((type) => type.code === this.jobType)?.label ?? this.jobType;
}
/** Commission is per agent, so the party range does not narrow it. */
get isCommissionJob(): boolean {
return this.jobType.startsWith('COMMISSION');
}
get isPostingJob(): boolean {
return this.jobType.endsWith('_POSTING');
}
get isBranchSelected(): boolean {
return this.batchForm.get('branchScope')?.value === 'Selected';
}
/**
* Only a commission calculation has a breakdown worth showing: it is the one job where the figure
* applied is not the figure worked out, because TDS and the standing deductions come off it.
*/
get showsCommissionBreakdown(): boolean {
return this.jobType === 'COMMISSION_CALCULATION';
}
/** What Generate found, in words — the same figure the Post button is about to apply. */
get previewTotal(): number {
return this.previewRows.reduce((total, row) => total + Number(row.amount || 0), 0);
}
ngOnInit(): void {
this.organizationService.getOffices().subscribe((offices: any[]) => {
this.offices = offices || [];
});
this.loadRecentRuns();
}
money(value: any): string {
if (value === null || value === undefined || value === '') {
return '—';
}
return DdsBatchProcessingComponent.MONEY.format(Number(value));
}
labelOf(code: string): string {
return this.jobTypes.find((type) => type.code === code)?.label ?? code;
}
toggleOffice(officeName: string): void {
if (this.selectedOfficeNames.has(officeName)) {
this.selectedOfficeNames.delete(officeName);
} else {
this.selectedOfficeNames.add(officeName);
}
}
/**
* What every past run did and whether it was applied. Without this a generated-but-unposted run is
* invisible the moment the page is left, and there is no way to tell whether a month's interest
* was ever actually posted.
*/
private loadRecentRuns(): void {
this.organizationService.getDdsBatchRuns().subscribe((runs: any[]) => {
this.recentRuns = runs || [];
});
}
generate(): void {
const raw = this.batchForm.getRawValue();
const dateFormat = this.settingsService.dateFormat;
const payload: any = {
jobType: raw.jobType,
branchScope: raw.branchScope,
jobDate: this.dateUtils.formatDate(raw.jobDate, dateFormat),
agentFrom: raw.agentFrom,
agentTo: raw.agentTo,
dateFormat,
locale: this.settingsService.language.code
};
// The party range narrows an interest job to particular accounts; commission is worked out per
// agent on their whole collection, so sending a range there would only imply a filter that has
// no effect.
if (!this.isCommissionJob) {
payload.partyFrom = raw.partyFrom;
payload.partyTo = raw.partyTo;
}
if (raw.branchScope === 'Selected') {
payload.officeNames = Array.from(this.selectedOfficeNames);
}
this.organizationService.generateDdsBatchRun(payload).subscribe((result: any) => {
this.organizationService.getDdsBatchRun(result.resourceId).subscribe((batchRun: any) => {
this.generatedRun = batchRun;
this.previewRows = batchRun.rows || [];
// A run that found nothing is the normal outcome of a period already processed, not a
// failure — but it has to say so, or an empty table reads as the screen being broken.
if (this.previewRows.length === 0) {
this.alertService.alert({
type: 'Information',
message: `${this.jobTypeLabel} found nothing to apply. Either this period has already been processed, or there was no activity in it.`
});
} else {
this.alertService.alert({
type: 'Success',
message: `${this.jobTypeLabel}: ${this.previewRows.length} ${this.previewRows.length === 1 ? 'entry' : 'entries'} totalling ${this.money(this.previewTotal)} calculated. Nothing has been applied yet — review and press Post.`
});
}
this.loadRecentRuns();
});
});
}
post(): void {
if (!this.generatedRun || this.previewRows.length === 0) {
return;
}
const count = this.previewRows.length;
const total = this.money(this.previewTotal);
const isPosting = this.isPostingJob;
// Post is the step that moves money, and it cannot be undone from this screen — so it names
// exactly what is about to change rather than asking a generic "are you sure".
this.dialog
.open(ConfirmationDialogComponent, {
data: {
type: 'warn',
heading: `Post ${this.jobTypeLabel}?`,
dialogContext:
`${count} ${count === 1 ? 'entry' : 'entries'} totalling ${total} will be applied. ` +
(isPosting
? 'This credits the customers or pays the agents, and appears on the ledger. '
: 'This records what has been earned against each account. ') +
'It cannot be undone from this screen.'
}
})
.afterClosed()
.subscribe((response: any) => {
if (response?.confirm) {
this.applyRun(count, total);
}
});
}
/**
* Applies the run, and says what actually happened rather than assuming it finished.
*
* A crediting run costs what a savings deposit costs on every account it touches, so the server
* takes it off the request and does it in the background - the reply comes back in a moment while
* the work is still going on. Reporting that as "posted" would tell the clerk a run had finished
* seconds after it began, and they would go looking for credits that were still being written.
*
* So the run is followed until it settles. A calculation run is done by the time the reply lands
* and says so at once; a crediting run says it is under way, and the list beneath refreshes itself
* until the server marks it posted - or failed, with the reason the server recorded.
*/
private applyRun(count: number, total: string): void {
const label = this.jobTypeLabel;
const runId = this.generatedRun.id;
const crediting = this.isPostingJob;
this.organizationService.postDdsBatchRun(runId).subscribe((response: any) => {
this.generatedRun = null;
this.previewRows = [];
this.loadRecentRuns();
if (heldForApproval(response)) {
announceHeldForApproval(
response,
`${label} (${count} ${count === 1 ? 'entry' : 'entries'}, ${total})`,
this.organizationService,
this.alertService
);
return;
}
if (!crediting) {
this.alertService.alert({
type: 'Success',
message: `${label} posted — ${count} ${count === 1 ? 'entry' : 'entries'}, ${total}.`
});
return;
}
this.postingRunId = runId;
this.alertService.alert({
type: 'Posting',
message:
`${label} is running — ${count} ${count === 1 ? 'entry' : 'entries'}, ${total}. ` +
'It continues in the background; this list will update when it is done.'
});
this.followRun(runId, label);
});
}
/** The crediting run being followed, if one is. The screen reads this to show it is under way. */
postingRunId: number | null = null;
/**
* Watches a background run until the server marks it settled.
*
* Polled rather than pushed: a batch that takes minutes needs no faster news than this, and a poll
* costs nothing when the answer is one row. It gives up after a while rather than asking forever -
* the run is still on the list either way, and a clerk can read its status there.
*/
private followRun(runId: number, label: string): void {
let checksLeft = DdsBatchProcessingComponent.MAX_STATUS_CHECKS;
const check = () => {
this.organizationService.getDdsBatchRuns().subscribe({
next: (runs: any[]) => {
const run = (runs || []).find((each: any) => each.id === runId);
const status = run?.status;
if (status === 'POSTED' || status === 'FAILED') {
this.postingRunId = null;
this.loadRecentRuns();
this.alertService.alert(
status === 'POSTED'
? { type: 'Success', message: `${label} posted — ${run.rowsPosted ?? run.totalRows} entries applied.` }
: { type: 'Error', message: `${label} failed: ${run.failureReason || 'see the run for details'}` }
);
return;
}
if (--checksLeft > 0) {
setTimeout(check, DdsBatchProcessingComponent.STATUS_CHECK_INTERVAL_MS);
} else {
// Still going after the wait. Left on the list rather than reported either way - saying
// "failed" for a run that is merely long would be worse than saying nothing.
this.postingRunId = null;
this.loadRecentRuns();
}
},
// A failed status check says nothing about the run itself, which carries on regardless.
error: () => {
if (--checksLeft > 0) {
setTimeout(check, DdsBatchProcessingComponent.STATUS_CHECK_INTERVAL_MS);
} else {
this.postingRunId = null;
}
}
});
};
setTimeout(check, DdsBatchProcessingComponent.STATUS_CHECK_INTERVAL_MS);
}
private static readonly STATUS_CHECK_INTERVAL_MS = 4000;
/** Twenty minutes of checking, which is longer than any run measured here has taken. */
private static readonly MAX_STATUS_CHECKS = 300;
/** Drops the preview without applying it — the safe way out of a calculation that looks wrong. */
discard(): void {
this.generatedRun = null;
this.previewRows = [];
}
exit(): void {
this.router.navigate(['/organization']);
}
}

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