Merge branch 'feature/dds' into merge/dds-into-dev-latest

Brings the DDS (Daily Deposit Scheme) UI from feature/dds onto the
current dev-latest:
- Agent settlement (Agent Tran), Close Party account, DDS reports,
  Party-Savings link field
- Agent collection entry screen, per-agent GL account, maturity auto
  close
- Approvals tab, vouchers, payout & close, Sundry GL search, closure
  tab and fixes
- Menu: DDS Party Master, DDS Accounts, DDS Collection, DDS Reports and
  Locker links placed in the Deposits mega panel groups; Agent Master,
  Commission Slab and DDS Batch Processing under Organization; DDS
  Approvals tab in the checker inbox

No content conflicts. Nothing from dev-latest is removed.

Merge adjustments:
- package-lock.json: kept dev-latest's lock file. feature/dds does not
  add or change any dependency in package.json; its lock file was only
  regenerated (about 18k lines removed and 12k added), which would have
  changed resolved versions for packages already on dev-latest.
- Dropped src/app/deposits/party-master/CBS-Backend.code-workspace, a
  VS Code workspace file holding local folder paths, not app code.
- angular.json: bundle budget raised to 18mb warning / 20mb error, as on
  feature/dds.

Lint fixes required by the pre-commit hook (no behaviour change):
- Prettier formatting on 12 DDS files.
- create-party.component.scss: the dense data-entry section deliberately
  re-targets the base selectors to override them, so
  no-duplicate-selectors is disabled for that section instead of
  merging rules and changing the cascade.
- notice-dialog / create-dds-account-type scss: stylelint-disable-line
  for the "ch" width unit.
- commission-slab-master.component.scss: stylelint auto-fix.

Verified with tsc -p src/tsconfig.app.json --noEmit, stylelint, prettier
and eslint on all merged files.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
2026-10-12 15:10:37 +05:30
co-authored by Claude Opus 5.5
84 changed files with 17814 additions and 9 deletions
@@ -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,528 @@
/**
* 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%);
}
/* stylelint-disable no-duplicate-selectors -- the dense layout below deliberately re-targets the base selectors above to override them. */
/*
* 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,79 @@
<!--
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() } : {})
});
}
}