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>
This commit is contained in:
tarunmalviya
2026-09-23 11:54:49 +05:30
co-authored by Claude Opus 5
parent 57a61b3057
commit e2eff9914f
114 changed files with 10259 additions and 15955 deletions
@@ -0,0 +1,11 @@
{
"folders": [
{
"path": "../../../../../../CBS-Backend"
},
{
"path": "../../../../.."
}
],
"settings": {}
}
@@ -0,0 +1,975 @@
<!--
Copyright since 2025 Mifos Initiative
This Source Code Form is subject to the terms of the Mozilla Public
License, v. 2.0. If a copy of the MPL was not distributed with this
file, You can obtain one at http://mozilla.org/MPL/2.0/.
-->
<div class="pm-container">
<div class="pm-header">
<button mat-stroked-button [routerLink]="['/accounts/dds']" class="pm-back">
<i class="fas fa-arrow-left"></i> Party Master
</button>
<h2 class="pm-header__title">{{ partyId ? 'Edit Party' : 'Party Master Entry' }}</h2>
@if (isClosed) {
<span class="pm-closed-badge">Closed — view only</span>
}
</div>
<div class="pm-card">
<form [formGroup]="partyForm">
<mat-tab-group class="pm-tabs" [(selectedIndex)]="selectedTabIndex" animationDuration="200ms">
<mat-tab label="Main Detail">
<div class="pm-tab-content">
<div class="pm-section">
<div class="pm-section__header">
<i class="fas fa-id-card"></i>
<h3>Customer &amp; Scheme</h3>
<span class="pm-section__sub">Who this account is for, which scheme it runs under, and which agent collects for it</span>
</div>
<div class="pm-grid">
<div class="pm-f">
<label class="pm-f-lbl">Client ID or Name<b>*</b></label>
<mat-form-field appearance="outline" class="pm-field">
<input
matInput
required
formControlName="clientId"
[matAutocomplete]="clientAuto"
(focus)="onClientFocus()"
placeholder="Click to see customers, or type a name or ID…"
autocomplete="off"
/>
<fa-icon matSuffix icon="search" class="pm-search-ic"></fa-icon>
<mat-autocomplete #clientAuto="matAutocomplete" panelWidth="min(30rem, 92vw)" [displayWith]="displayClient">
@for (client of clientOptions; track client.id) {
<mat-option [value]="client">
<div class="pm-opt">
<span class="pm-opt-id">{{ client.id }}</span>
<span class="pm-opt-main">{{ client.displayName }}</span>
<span class="pm-opt-sub">{{ client.officeName }}</span>
</div>
</mat-option>
}
<!-- Says so when the list is only the first page, so a customer missing from
it reads as "keep typing" rather than "not a customer here". -->
@if (clientListIsPartial) {
<mat-option disabled class="pm-opt-more">
Showing {{ clientOptions.length }} of {{ clientTotal }} — type a name to find the rest
</mat-option>
}
</mat-autocomplete>
@if (loadedClientName) {
<mat-hint class="pm-hint--found">{{ loadedClientName }}</mat-hint>
} @else if (clientLookupFailed) {
<mat-hint class="pm-hint--missing">No client with this ID</mat-hint>
}
</mat-form-field>
</div>
<div class="pm-f">
<label class="pm-f-lbl">Sub Type (Scheme)<b>*</b></label>
<mat-form-field appearance="outline" class="pm-field">
<input
matInput
required
formControlName="subType"
[matAutocomplete]="subTypeAuto"
(focus)="showAllSubTypes()"
placeholder="Type to search schemes…"
autocomplete="off"
/>
<fa-icon matSuffix icon="search" class="pm-search-ic"></fa-icon>
<mat-autocomplete #subTypeAuto="matAutocomplete" panelWidth="min(30rem, 92vw)" [displayWith]="displaySubType">
@for (type of subTypeOptions; track type.id) {
<mat-option [value]="type.accountTypeCode">
<div class="pm-opt">
<span class="pm-opt-id">{{ type.accountTypeCode }}</span>
<span class="pm-opt-main">{{ type.accountTypeDescription }}</span>
<span class="pm-opt-sub">{{ type.accountTypeGroup }}</span>
</div>
</mat-option>
}
@if (subTypeOptions.length === 0) {
<mat-option disabled><em>No scheme matches that.</em></mat-option>
}
</mat-autocomplete>
@if (subTypes.length === 0) {
<mat-hint class="pm-hint--missing">No active account type — create one first</mat-hint>
}
</mat-form-field>
</div>
<div class="pm-f">
<label class="pm-f-lbl">Agent<b>*</b></label>
<mat-form-field appearance="outline" class="pm-field">
<input
matInput
required
formControlName="agentId"
[matAutocomplete]="agentAuto"
(focus)="onAgentFocus()"
placeholder="Click to see agents, or type a code or name…"
autocomplete="off"
/>
<fa-icon matSuffix icon="search" class="pm-search-ic"></fa-icon>
<mat-autocomplete #agentAuto="matAutocomplete" panelWidth="min(30rem, 92vw)" [displayWith]="displayAgent">
@for (agent of agentOptions; track agent.id) {
<mat-option [value]="agent">
<div class="pm-opt">
<span class="pm-opt-id">{{ agent.agentCode }}</span>
<span class="pm-opt-main">{{ agent.agentNameId }}</span>
<!-- A stopped agent is still shown, marked: hiding one would silently
drop a party's own collector from the list. -->
<span class="pm-opt-sub">
{{ agent.active ? agent.homeBranch : 'not collecting' }}
</span>
</div>
</mat-option>
}
@if (agentOptions.length === 0) {
<mat-option disabled><em>No agent matches that.</em></mat-option>
}
</mat-autocomplete>
@if (loadedAgentName && !agentLookupFailed) {
<mat-hint class="pm-hint--found">{{ loadedAgentName }}</mat-hint>
} @else if (loadedAgentName && agentLookupFailed) {
<mat-hint class="pm-hint--missing">{{ loadedAgentName }} is deactivated</mat-hint>
} @else if (agentLookupFailed) {
<mat-hint class="pm-hint--missing">No agent with this ID</mat-hint>
}
</mat-form-field>
</div>
<!--
The branch this account belongs to. Asked for, not taken from the agent: an agent
working out of one branch can hold accounts for customers of another, and an
account that silently inherits its agent's branch is counted at the wrong one.
-->
<div class="pm-f">
<label class="pm-f-lbl">Branch<b>*</b></label>
<mat-form-field appearance="outline" class="pm-field">
<mat-select required formControlName="officeId">
@for (office of offices; track office.id) {
<mat-option [value]="office.id">{{ office.name }}</mat-option>
}
</mat-select>
@if (officesError) {
<mat-hint class="pm-hint--missing">{{ officesError }}</mat-hint>
} @else {
<mat-hint>The branch this account belongs to</mat-hint>
}
</mat-form-field>
</div>
<!--
The customer's real deposit account. Three states, and the difference matters: an
account is open, this party predates DDS opening them, or the party is new and the
account is about to be opened. It used to read "Linked (id 36)", which is a number
only the database means anything by.
-->
@if (savingsAccountNo) {
<div class="pm-f">
<label class="pm-f-lbl">DDS Account</label>
<div class="pm-account">
<span class="pm-account-no">{{ savingsAccountNo }}</span>
<span class="pm-account-meta">
{{ savingsProductName }} ·
<span [class.pm-account-live]="savingsAccountStatus === 'Active'">{{ savingsAccountStatus }}</span>
· {{ savingsAccountBalance | number: '1.2-2' }}
</span>
</div>
</div>
} @else if (partyId) {
<div class="pm-f">
<label class="pm-f-lbl">DDS Account</label>
<div class="pm-account">
<span class="pm-account-missing">No account — opened before DDS started opening them</span>
</div>
</div>
} @else {
<div class="pm-f">
<label class="pm-f-lbl">DDS Account</label>
<div class="pm-account">
<span class="pm-account-meta">Opens automatically when this party is saved</span>
</div>
</div>
}
<div class="pm-f">
<label class="pm-f-lbl">Party Code</label>
<mat-form-field appearance="outline" class="pm-field">
<input matInput formControlName="partyCode" placeholder="Auto-generated" />
</mat-form-field>
</div>
<div class="pm-f">
<label class="pm-f-lbl">Last A/c</label>
<mat-form-field appearance="outline" class="pm-field">
<input matInput formControlName="lastAccount" placeholder="—" />
</mat-form-field>
</div>
<div class="pm-f">
<label class="pm-f-lbl">Nominal<b>*</b></label>
<mat-form-field appearance="outline" class="pm-field">
<mat-select required formControlName="nominal">
<mat-option value="Yes">Yes</mat-option>
<mat-option value="No">No</mat-option>
</mat-select>
</mat-form-field>
</div>
<div class="pm-f pm-f--wide">
<label class="pm-f-lbl">Party Name<b>*</b></label>
<mat-form-field appearance="outline" class="pm-field">
<input matInput required formControlName="partyName" />
</mat-form-field>
</div>
<div class="pm-f pm-f--wide">
<label class="pm-f-lbl">Party Name 1</label>
<mat-form-field appearance="outline" class="pm-field">
<input matInput formControlName="partyName1" />
</mat-form-field>
</div>
<div class="pm-f pm-f--wide">
<label class="pm-f-lbl">Address 1<b>*</b></label>
<mat-form-field appearance="outline" class="pm-field">
<input matInput required formControlName="address1" />
</mat-form-field>
</div>
<div class="pm-f pm-f--wide">
<label class="pm-f-lbl">Address 2</label>
<mat-form-field appearance="outline" class="pm-field">
<input matInput formControlName="address2" />
</mat-form-field>
</div>
<div class="pm-f pm-f--wide">
<label class="pm-f-lbl">Address 3</label>
<mat-form-field appearance="outline" class="pm-field">
<input matInput formControlName="address3" />
</mat-form-field>
</div>
<div class="pm-f">
<label class="pm-f-lbl">City<b>*</b></label>
<mat-form-field appearance="outline" class="pm-field">
<input matInput required formControlName="city" />
</mat-form-field>
</div>
<div class="pm-f">
<label class="pm-f-lbl">Pincode<b>*</b></label>
<mat-form-field appearance="outline" class="pm-field">
<input matInput required formControlName="pincode" mifosxRestrictInput="digits" />
</mat-form-field>
</div>
<div class="pm-f">
<label class="pm-f-lbl">Gender</label>
<mat-form-field appearance="outline" class="pm-field">
<mat-select formControlName="gender">
<mat-option value="Male">Male</mat-option>
<mat-option value="Female">Female</mat-option>
<mat-option value="Other">Other</mat-option>
</mat-select>
</mat-form-field>
</div>
<div class="pm-f">
<label class="pm-f-lbl">Telephone</label>
<mat-form-field appearance="outline" class="pm-field">
<input matInput formControlName="telephone" mifosxRestrictInput="phone" />
</mat-form-field>
</div>
<div class="pm-f">
<label class="pm-f-lbl">Mobile No (SMS)<b>*</b></label>
<mat-form-field appearance="outline" class="pm-field">
<input matInput required formControlName="mobileNoSms" mifosxRestrictInput="digits" />
</mat-form-field>
</div>
<div class="pm-f">
<label class="pm-f-lbl">Aadhar No</label>
<mat-form-field appearance="outline" class="pm-field">
<input matInput formControlName="aadharNo" mifosxRestrictInput="aadhaar" />
</mat-form-field>
</div>
<div class="pm-f">
<label class="pm-f-lbl">PAN Card No</label>
<mat-form-field appearance="outline" class="pm-field">
<input matInput formControlName="panCardNo" mifosxRestrictInput="pan" />
</mat-form-field>
</div>
<div class="pm-f">
<label class="pm-f-lbl">CKYC ID</label>
<mat-form-field appearance="outline" class="pm-field">
<input matInput formControlName="ckycId" mifosxRestrictInput="digits" />
</mat-form-field>
</div>
<div class="pm-f">
<label class="pm-f-lbl">Operation<b>*</b></label>
<mat-form-field appearance="outline" class="pm-field">
<mat-select required formControlName="operation">
@for (op of operations; track op) {
<mat-option [value]="op">{{ op }}</mat-option>
}
</mat-select>
</mat-form-field>
</div>
<div class="pm-f">
<label class="pm-f-lbl">Birth Date</label>
<mat-form-field appearance="outline" class="pm-field">
<input matInput [matDatepicker]="birthPicker" formControlName="birthDate" />
<mat-datepicker-toggle matIconSuffix [for]="birthPicker"></mat-datepicker-toggle>
<mat-datepicker #birthPicker></mat-datepicker>
</mat-form-field>
</div>
<div class="pm-f">
<label class="pm-f-lbl">Occupation</label>
<mat-form-field appearance="outline" class="pm-field">
<mat-select formControlName="occupation">
@for (occ of occupations; track occ) {
<mat-option [value]="occ">{{ occ }}</mat-option>
}
</mat-select>
</mat-form-field>
</div>
<div class="pm-f">
<label class="pm-f-lbl">Risk</label>
<mat-form-field appearance="outline" class="pm-field">
<mat-select formControlName="risk">
@for (r of riskLevels; track r) {
<mat-option [value]="r">{{ r }}</mat-option>
}
</mat-select>
</mat-form-field>
</div>
</div>
</div>
<div class="pm-section">
<div class="pm-section__header">
<i class="fas fa-user-friends"></i>
<h3>Minor / Guardian</h3>
<span class="pm-section__sub">Only for a minor's account — the guardian who operates it until they come of age</span>
</div>
<div class="pm-grid">
<div class="pm-f">
<label class="pm-f-lbl">Adult / Minor<b>*</b></label>
<mat-form-field appearance="outline" class="pm-field">
<mat-select required formControlName="adultMinor">
@for (opt of adultMinorOptions; track opt) {
<mat-option [value]="opt">{{ opt }}</mat-option>
}
</mat-select>
</mat-form-field>
</div>
@if (isMinorAccount) {
<div class="pm-f">
<label class="pm-f-lbl">Minor Date<b>*</b></label>
<mat-form-field appearance="outline" class="pm-field">
<input matInput required [matDatepicker]="minorPicker" formControlName="minorDate" />
<mat-datepicker-toggle matIconSuffix [for]="minorPicker"></mat-datepicker-toggle>
<mat-datepicker #minorPicker></mat-datepicker>
<mat-hint>Turns 18 on</mat-hint>
</mat-form-field>
</div>
<div class="pm-f">
<label class="pm-f-lbl">Guardian Client ID or Name<b>*</b></label>
<mat-form-field appearance="outline" class="pm-field">
<input
matInput
required
formControlName="guardianClientId"
[matAutocomplete]="guardianAuto"
placeholder="Type an ID or search by name…"
autocomplete="off"
/>
<fa-icon matSuffix icon="search" class="pm-search-ic"></fa-icon>
<mat-autocomplete #guardianAuto="matAutocomplete" panelWidth="min(30rem, 92vw)" [displayWith]="displayClient">
@for (client of guardianOptions; track client.id) {
<mat-option [value]="client">
<div class="pm-opt">
<span class="pm-opt-id">{{ client.id }}</span>
<span class="pm-opt-main">{{ client.displayName }}</span>
<span class="pm-opt-sub">{{ client.officeName }}</span>
</div>
</mat-option>
}
</mat-autocomplete>
@if (loadedGuardianName) {
<mat-hint class="pm-hint--found">{{ loadedGuardianName }}</mat-hint>
}
</mat-form-field>
</div>
<div class="pm-f">
<label class="pm-f-lbl">Min. A/c Opt</label>
<mat-form-field appearance="outline" class="pm-field">
<input matInput formControlName="minAccountOpt" />
</mat-form-field>
</div>
<div class="pm-f">
<label class="pm-f-lbl">Relation<b>*</b></label>
<mat-form-field appearance="outline" class="pm-field">
<input matInput required formControlName="relation" />
<mat-hint>To the minor</mat-hint>
</mat-form-field>
</div>
}
</div>
@if (!isMinorAccount) {
<p class="pm-note">An adult's account needs no guardian details.</p>
}
</div>
<div class="pm-section">
<div class="pm-section__header">
<i class="fas fa-calendar-day"></i>
<h3>Scheme Terms</h3>
<span class="pm-section__sub">How much is collected daily, for how long, and what it matures to</span>
</div>
<div class="pm-grid">
<div class="pm-f">
<label class="pm-f-lbl">Opening Date<b>*</b></label>
<mat-form-field appearance="outline" class="pm-field">
<!--
A new account opens today, so yesterday is greyed out - a date in the past would
make the scheme look older than it is, with its lock-in further along and its
maturity falling before the money was collected for it. No floor while editing:
an account already on the books opened in the past by definition, and a floor of
today would make every one of them unsaveable.
-->
<input
matInput
required
[matDatepicker]="openingPicker"
[min]="minOpeningDate"
formControlName="openingDate"
/>
<mat-datepicker-toggle matIconSuffix [for]="openingPicker"></mat-datepicker-toggle>
<mat-datepicker #openingPicker></mat-datepicker>
</mat-form-field>
</div>
<div class="pm-f">
<label class="pm-f-lbl">Month</label>
<mat-form-field appearance="outline" class="pm-field">
<input matInput type="number" formControlName="month" />
</mat-form-field>
</div>
<div class="pm-f">
<label class="pm-f-lbl">Maturity Date</label>
<mat-form-field appearance="outline" class="pm-field">
<input matInput [matDatepicker]="maturityPicker" formControlName="maturityDate" />
<mat-datepicker #maturityPicker></mat-datepicker>
<mat-hint>Calculated</mat-hint>
</mat-form-field>
</div>
<div class="pm-f">
<label class="pm-f-lbl">Reopen Date</label>
<mat-form-field appearance="outline" class="pm-field">
<input matInput [matDatepicker]="reopenPicker" formControlName="reopenDate" />
<mat-datepicker-toggle matIconSuffix [for]="reopenPicker"></mat-datepicker-toggle>
<mat-datepicker #reopenPicker></mat-datepicker>
</mat-form-field>
</div>
<div class="pm-f">
<label class="pm-f-lbl">Deposit Amt</label>
<mat-form-field appearance="outline" class="pm-field">
<input matInput type="number" formControlName="depositAmount" />
</mat-form-field>
</div>
<div class="pm-f">
<label class="pm-f-lbl">Daily Deposit Amt<b>*</b></label>
<mat-form-field appearance="outline" class="pm-field">
<input matInput required type="number" formControlName="dailyDepositAmount" />
</mat-form-field>
</div>
<div class="pm-f">
<label class="pm-f-lbl">Days<b>*</b></label>
<mat-form-field appearance="outline" class="pm-field">
<input matInput required type="number" formControlName="days" />
@if (partyForm.get('days')?.hasError('exceedsTerm')) {
<mat-error>Only {{ termDays }} days in this term</mat-error>
} @else if (termDays) {
<mat-hint>Term is {{ termDays }} days</mat-hint>
}
</mat-form-field>
</div>
<div class="pm-f">
<label class="pm-f-lbl">Ledger No</label>
<mat-form-field appearance="outline" class="pm-field">
<input matInput formControlName="ledgerNo" />
<mat-hint>Collection book no.</mat-hint>
</mat-form-field>
</div>
<div class="pm-f">
<label class="pm-f-lbl">Mat. Amt</label>
<mat-form-field appearance="outline" class="pm-field">
<input matInput disabled class="pm-money" [value]="money('maturityAmount')" />
<mat-hint>Calculated</mat-hint>
</mat-form-field>
</div>
<div class="pm-f">
<label class="pm-f-lbl">Interest Rate</label>
<mat-form-field appearance="outline" class="pm-field">
<input matInput type="number" formControlName="interestRate" />
<span matTextSuffix>%</span>
</mat-form-field>
</div>
<div class="pm-f">
<label class="pm-f-lbl">Withdraw Type</label>
<mat-form-field appearance="outline" class="pm-field">
<mat-select formControlName="withdrawType">
@for (type of withdrawTypes; track type) {
<mat-option [value]="type">{{ type }}</mat-option>
}
</mat-select>
</mat-form-field>
</div>
</div>
</div>
<div class="pm-section">
<div class="pm-section__header">
<i class="fas fa-exclamation-triangle"></i>
<h3>Balances &amp; Status</h3>
<span class="pm-section__sub">Kept by collections and the nightly batch — shown here, never typed</span>
</div>
<div class="pm-grid">
<div class="pm-checkbox-field">
<mat-checkbox formControlName="od1Flag">OD1 Flag</mat-checkbox>
</div>
<div class="pm-checkbox-field">
<mat-checkbox formControlName="od2Flag">OD2 Flag</mat-checkbox>
</div>
<div class="pm-f">
<label class="pm-f-lbl">Lien</label>
<mat-form-field appearance="outline" class="pm-field">
<mat-select formControlName="lien">
<mat-option value="Yes">Yes</mat-option>
<mat-option value="No">No</mat-option>
</mat-select>
@if (lienHeld) {
<mat-hint>Nothing can leave this account until it is released</mat-hint>
}
</mat-form-field>
</div>
<!--
Why the money is held. Asked for only when it is: the flag alone says an account is
frozen and never says what for, which is how a lien outlives the loan it secured -
months later nobody can tell whether it has been repaid, so nobody will release it
and nobody can justify keeping it.
-->
@if (lienHeld) {
<div class="pm-f">
<label class="pm-f-lbl">Lien Reason<b>*</b></label>
<mat-form-field appearance="outline" class="pm-field">
<mat-select required formControlName="lienReason">
@for (reason of lienReasons; track reason) {
<mat-option [value]="reason">{{ reason }}</mat-option>
}
</mat-select>
<mat-hint>What this money is being held for</mat-hint>
</mat-form-field>
</div>
<!--
Only where the list genuinely does not cover it. Picking "Other" and leaving
this blank would say no more than leaving the whole reason blank did.
-->
@if (lienReasonIsOther) {
<div class="pm-f">
<label class="pm-f-lbl">Please specify<b>*</b></label>
<mat-form-field appearance="outline" class="pm-field">
<input matInput required maxlength="200" formControlName="lienReasonOther" placeholder="e.g. Loan #1234" />
<mat-hint>Write the reason in your own words</mat-hint>
</mat-form-field>
</div>
}
}
<div class="pm-f">
<label class="pm-f-lbl">Pay Date</label>
<mat-form-field appearance="outline" class="pm-field">
<input matInput [matDatepicker]="payDatePicker" formControlName="payDate" />
<mat-datepicker-toggle matIconSuffix [for]="payDatePicker"></mat-datepicker-toggle>
<mat-datepicker #payDatePicker></mat-datepicker>
</mat-form-field>
</div>
<div class="pm-f">
<label class="pm-f-lbl">Payment Amount</label>
<mat-form-field appearance="outline" class="pm-field">
<input matInput disabled class="pm-money" [value]="money('paymentAmount')" />
</mat-form-field>
</div>
<div class="pm-f">
<label class="pm-f-lbl">Current Bal</label>
<mat-form-field appearance="outline" class="pm-field">
<input matInput disabled class="pm-money" [value]="money('currentBalance')" />
</mat-form-field>
</div>
<div class="pm-f">
<label class="pm-f-lbl">Opening Bal</label>
<mat-form-field appearance="outline" class="pm-field">
<input matInput disabled class="pm-money" [value]="money('openingBalance')" />
</mat-form-field>
</div>
<div class="pm-checkbox-field">
<mat-checkbox formControlName="isSmsActive">Is SMS Active</mat-checkbox>
</div>
<div class="pm-checkbox-field">
<mat-checkbox formControlName="isEmailActive">Is Email Active</mat-checkbox>
</div>
<div class="pm-f">
<label class="pm-f-lbl">Int.Calc</label>
<mat-form-field appearance="outline" class="pm-field">
<input matInput disabled class="pm-money" [value]="money('interestCalc')" />
</mat-form-field>
</div>
<div class="pm-f">
<label class="pm-f-lbl">Interest Date</label>
<mat-form-field appearance="outline" class="pm-field">
<input matInput [matDatepicker]="interestDatePicker" formControlName="interestDate" />
<mat-datepicker-toggle matIconSuffix [for]="interestDatePicker"></mat-datepicker-toggle>
<mat-datepicker #interestDatePicker></mat-datepicker>
</mat-form-field>
</div>
<div class="pm-f pm-f--wide">
<label class="pm-f-lbl">Sp.Description</label>
<mat-form-field appearance="outline" class="pm-field">
<input matInput formControlName="spDescription" />
</mat-form-field>
</div>
<div class="pm-f">
<label class="pm-f-lbl">Fr'z CD</label>
<mat-form-field appearance="outline" class="pm-field">
<mat-select formControlName="freezeCode">
@for (code of freezeCodes; track code) {
<mat-option [value]="code">{{ code }}</mat-option>
}
</mat-select>
</mat-form-field>
</div>
<div class="pm-f">
<label class="pm-f-lbl">Adv Code</label>
<mat-form-field appearance="outline" class="pm-field">
<mat-select formControlName="advCode">
@for (code of advCodes; track code) {
<mat-option [value]="code">{{ code }}</mat-option>
}
</mat-select>
</mat-form-field>
</div>
</div>
</div>
@if (creditToRequired) {
<div class="pm-section">
<div class="pm-section__header">
<i class="fas fa-exchange-alt"></i>
<h3>Maturity Payout Routing</h3>
</div>
<p class="pm-note">Transfer To Saving — say where the maturity proceeds should land.</p>
<div class="pm-grid">
<div class="pm-f">
<label class="pm-f-lbl">Credit To Branch<b>*</b></label>
<mat-form-field appearance="outline" class="pm-field">
<mat-select required formControlName="creditToBranch">
@for (office of offices; track office.id) {
<mat-option [value]="office.name">{{ office.name }}</mat-option>
}
</mat-select>
@if (officesError) {
<mat-hint class="pm-hint--missing">{{ officesError }}</mat-hint>
}
</mat-form-field>
</div>
<!--
Picked from the customer's own savings accounts, never typed. The whole matured
amount lands here, and a number that exists only as text is a payout with
nowhere to go - which is what one party was already carrying.
-->
<div class="pm-f">
<label class="pm-f-lbl">Credit To A/C<b>*</b></label>
<mat-form-field appearance="outline" class="pm-field">
<mat-select
required
formControlName="creditToAccount"
panelWidth="min(30rem, 92vw)"
(selectionChange)="onCreditToAccountPicked($event.value)"
>
@for (account of creditToAccounts; track account.id) {
<mat-option [value]="account.accountNo">
{{ account.accountNo }} · {{ account.productName }}
</mat-option>
}
</mat-select>
@if (creditToAccounts.length === 0) {
<mat-hint class="pm-hint--missing">
{{ loadedClientName || 'This customer' }} has no other savings account to credit
</mat-hint>
}
</mat-form-field>
</div>
<!-- Read off the chosen account rather than asked for separately. -->
<div class="pm-f">
<label class="pm-f-lbl">Credit To A/C Type<b>*</b></label>
<mat-form-field appearance="outline" class="pm-field">
<input matInput readonly formControlName="creditToAccountType" />
</mat-form-field>
</div>
</div>
</div>
}
</div>
</mat-tab>
<mat-tab label="Nominee's Detail">
<div class="pm-tab-content">
<div class="pm-section">
<div class="pm-grid">
<div class="pm-f">
<label class="pm-f-lbl">Nominee</label>
<mat-form-field appearance="outline" class="pm-field">
<mat-select formControlName="nomineeApplicable">
<mat-option value="Yes">Yes</mat-option>
<mat-option value="No">No</mat-option>
</mat-select>
</mat-form-field>
</div>
@if (nomineesRequired) {
<mat-radio-group formControlName="nomineeSplitMode" class="pm-radio-group">
<mat-radio-button value="Percentage">Percentage</mat-radio-button>
<mat-radio-button value="Sequence">Sequence</mat-radio-button>
</mat-radio-group>
<button mat-stroked-button type="button" class="pm-add-nominee" (click)="addNominee()">
<i class="fas fa-plus"></i> Add Nominee
</button>
@if (splitByPercentage && nominees.length > 0) {
<span class="pm-share-total" [class.pm-share-total-bad]="nomineeShareError">
{{ nomineeShareTotal }}% of 100% allocated@if (nomineeShareError) { — {{ nomineeShareError }} }
</span>
} @else if (splitBySequence && nominees.length > 0) {
<span class="pm-share-total" [class.pm-share-total-bad]="nomineeOrderError">
@if (nomineeOrderError) {
{{ nomineeOrderError }}
} @else {
Paid in the order numbered, 1 first
}
</span>
}
}
</div>
@if (!nomineesRequired) {
<p class="pm-note">Set Nominee to Yes to nominate someone for this account.</p>
}
@for (nominee of nominees.controls; track nominee; let i = $index) {
<div class="pm-nominee-row" [formGroup]="$any(nominee)">
<div class="pm-f pm-f--date">
<label class="pm-f-lbl">Nom.Dt<b>*</b></label>
<mat-form-field appearance="outline" class="pm-field">
<input matInput required [matDatepicker]="nomDatePicker" formControlName="nominationDate" />
<mat-datepicker-toggle matIconSuffix [for]="nomDatePicker"></mat-datepicker-toggle>
<mat-datepicker #nomDatePicker></mat-datepicker>
</mat-form-field>
</div>
<div class="pm-f">
<label class="pm-f-lbl">Name<b>*</b></label>
<mat-form-field appearance="outline" class="pm-field">
<input matInput required formControlName="name" />
</mat-form-field>
</div>
<div class="pm-f">
<label class="pm-f-lbl">Mobile No</label>
<mat-form-field appearance="outline" class="pm-field">
<input matInput formControlName="mobileNo" mifosxRestrictInput="digits" />
</mat-form-field>
</div>
<div class="pm-f pm-f--date">
<label class="pm-f-lbl">Birth Dt</label>
<mat-form-field appearance="outline" class="pm-field">
<input matInput [matDatepicker]="nomBirthPicker" formControlName="birthDate" />
<mat-datepicker-toggle matIconSuffix [for]="nomBirthPicker"></mat-datepicker-toggle>
<mat-datepicker #nomBirthPicker></mat-datepicker>
</mat-form-field>
</div>
<div class="pm-f pm-f--tight">
<!-- Read from the date of birth, not asked: the two arriving separately is how
a ten-year-old came to be recorded as not a minor. -->
<label class="pm-f-lbl">Minor</label>
<mat-form-field appearance="outline" class="pm-field">
<input matInput readonly [value]="isNomineeMinor(nominee) ? 'Yes' : 'No'" />
<mat-hint>From date of birth</mat-hint>
</mat-form-field>
</div>
<div class="pm-f">
<label class="pm-f-lbl">Relation<b>*</b></label>
<mat-form-field appearance="outline" class="pm-field">
<input matInput required formControlName="relation" />
</mat-form-field>
</div>
<div class="pm-f pm-f--tight">
<label class="pm-f-lbl">Gender<b>*</b></label>
<mat-form-field appearance="outline" class="pm-field">
<mat-select required formControlName="gender">
<mat-option value="Male">Male</mat-option>
<mat-option value="Female">Female</mat-option>
<mat-option value="Other">Other</mat-option>
</mat-select>
</mat-form-field>
</div>
<div class="pm-f">
<label class="pm-f-lbl">
Guar. Name@if (isNomineeMinor(nominee)) {<b>*</b>}
</label>
<mat-form-field appearance="outline" class="pm-field">
<input matInput [required]="isNomineeMinor(nominee)" formControlName="guardianName" />
@if (isNomineeMinor(nominee)) {
<mat-hint>Required for a minor</mat-hint>
}
</mat-form-field>
</div>
@if (splitByPercentage) {
<div class="pm-f pm-f--tight">
<label class="pm-f-lbl">Share %<b>*</b></label>
<mat-form-field appearance="outline" class="pm-field">
<input matInput type="number" min="0.01" max="100" step="0.01" required formControlName="sharePercent" />
</mat-form-field>
</div>
} @else {
<div class="pm-f pm-f--tight">
<label class="pm-f-lbl">Order<b>*</b></label>
<mat-form-field appearance="outline" class="pm-field">
<input matInput type="number" min="1" step="1" required formControlName="sequenceNo" />
</mat-form-field>
</div>
}
<button
mat-icon-button
type="button"
class="pm-remove-nominee"
(click)="removeNominee(i)"
matTooltip="Remove nominee"
>
<i class="fas fa-trash"></i>
</button>
</div>
}
</div>
</div>
</mat-tab>
</mat-tab-group>
@if (isClosed) {
<div class="pm-callout">
<i class="fas fa-lock"></i>
<span>This account is closed. No further collections or edits are possible.</span>
</div>
}
<!--
A minor who has come of age. The guardian's authority to operate the account ended on that
day, so this is something the branch has to act on rather than a detail on a tab - which is
why it sits beside the action bar where a closed account's notice does.
-->
@if (minorTurnedMajor) {
<div class="pm-callout pm-callout--action">
<i class="fas fa-user-clock"></i>
<span>
This account was opened for a minor who came of age on
<strong>{{ majorOn | dateFormat }}</strong>. The guardian can no longer
operate it — take fresh KYC, a signature and a new mandate from the account holder.
</span>
</div>
}
<div class="pm-action-bar">
<button mat-raised-button type="button" (click)="exit()">Exit</button>
<button mat-stroked-button type="button" (click)="cancel()">Cancel</button>
<!--
Closing before the term is up. Kept here beside the ordinary close rather than on the party
list: it cannot be undone and it changes what the customer is owed, so it belongs on the
screen where the officer has the account open in front of them, not one click from a row.
Hidden once the account has matured - that one is paid out and closed on its own terms, and
the server refuses this on it.
-->
@if (partyId && !isClosed && canCloseEarly) {
<button
mat-stroked-button
type="button"
class="pm-early-btn"
matTooltip="Close this account before its term ends, settling the shortfall"
(click)="closeEarly()"
>
<i class="fas fa-times" style="margin-right: 6px"></i> Close Early
</button>
}
@if (partyId && !isClosed) {
<button
mat-stroked-button
type="button"
color="warn"
matTooltip="Balance must be zero before closing"
(click)="closeAccount()"
>
<i class="fas fa-lock" style="margin-right: 6px"></i> Close Account
</button>
}
<button
mat-raised-button
color="primary"
type="button"
[disabled]="partyForm.invalid || isClosed || !!nomineeShareError || !!nomineeOrderError"
(click)="save()"
>
Save
</button>
</div>
</form>
</div>
</div>
@@ -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,163 @@
<!--
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.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,194 @@
/**
* 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: () => {
this.alertService.alert({
type: 'Party Paid Out',
message: payoutCommand.toSavingsAccountNo
? `${party.partyCode} paid into ${payoutCommand.toSavingsAccountNo}. Close the account once you have checked it.`
: `${party.partyCode} paid into Matured Deposits Payable, ready to transfer out. Close the account once you have checked it.`
});
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(new Date(), dateFormat),
dateFormat,
locale
})
.subscribe({
next: () => {
this.alertService.alert({ type: 'Party Closed', message: `${party.partyCode} is closed.` });
this.load();
}
});
});
}
}
@@ -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%.`;
}
}
@@ -6,538 +6,221 @@
file, You can obtain one at http://mozilla.org/MPL/2.0/.
-->
<div class="pm-container">
<div class="pm-header">
<button mat-stroked-button [routerLink]="['../']" class="pm-back">
<i class="fas fa-arrow-left"></i> Deposits
</button>
<h2 class="pm-header__title">Party Master Entry</h2>
<div 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>
<div class="pm-card">
<form [formGroup]="partyForm">
<mat-tab-group class="pm-tabs" [(selectedIndex)]="selectedTabIndex" animationDuration="200ms">
<mat-tab label="Main Detail">
<div class="pm-tab-content">
<div class="pm-section">
<div class="pm-section__header">
<i class="fas fa-id-card"></i>
<h3>Identity</h3>
</div>
<div class="pm-grid">
<mat-form-field appearance="outline" class="pm-field">
<mat-label>Client ID</mat-label>
<input matInput required formControlName="clientId" />
</mat-form-field>
<mat-form-field appearance="outline" class="pm-field">
<mat-label>Sub Type</mat-label>
<mat-select required formControlName="subType">
@for (type of subTypes; track type) {
<mat-option [value]="type">{{ type }}</mat-option>
}
</mat-select>
</mat-form-field>
<mat-form-field appearance="outline" class="pm-field">
<mat-label>Agent ID</mat-label>
<input matInput required formControlName="agentId" />
</mat-form-field>
@if (linkedSavingsAccountId) {
<mat-form-field appearance="outline" class="pm-field">
<mat-label>Savings Account</mat-label>
<input matInput [value]="'Linked (id ' + linkedSavingsAccountId + ')'" disabled />
</mat-form-field>
} @else {
<mat-form-field appearance="outline" class="pm-field">
<mat-label>Savings Account No (if any)</mat-label>
<input matInput formControlName="savingsAccountNo" />
</mat-form-field>
}
<mat-form-field appearance="outline" class="pm-field">
<mat-label>Party Code</mat-label>
<input matInput formControlName="partyCode" placeholder="Auto-generated" />
</mat-form-field>
<mat-form-field appearance="outline" class="pm-field">
<mat-label>Last A/c</mat-label>
<input matInput formControlName="lastAccount" placeholder="—" />
</mat-form-field>
<mat-form-field appearance="outline" class="pm-field">
<mat-label>Nominal</mat-label>
<mat-select required formControlName="nominal">
<mat-option value="Yes">Yes</mat-option>
<mat-option value="No">No</mat-option>
</mat-select>
</mat-form-field>
<mat-form-field appearance="outline" class="pm-field pm-field--wide">
<mat-label>Party Name</mat-label>
<input matInput required formControlName="partyName" />
</mat-form-field>
<mat-form-field appearance="outline" class="pm-field pm-field--wide">
<mat-label>Party Name 1</mat-label>
<input matInput formControlName="partyName1" />
</mat-form-field>
</div>
</div>
<div class="pm-section">
<div class="pm-section__header">
<i class="fas fa-map-marker-alt"></i>
<h3>Address &amp; Contact</h3>
</div>
<div class="pm-grid">
<mat-form-field appearance="outline" class="pm-field pm-field--wide">
<mat-label>Address 1</mat-label>
<input matInput required formControlName="address1" />
</mat-form-field>
<mat-form-field appearance="outline" class="pm-field pm-field--wide">
<mat-label>Address 2</mat-label>
<input matInput formControlName="address2" />
</mat-form-field>
<mat-form-field appearance="outline" class="pm-field pm-field--wide">
<mat-label>Address 3</mat-label>
<input matInput formControlName="address3" />
</mat-form-field>
<mat-form-field appearance="outline" class="pm-field">
<mat-label>City</mat-label>
<input matInput required formControlName="city" />
</mat-form-field>
<mat-form-field appearance="outline" class="pm-field">
<mat-label>Pincode</mat-label>
<input matInput required formControlName="pincode" />
</mat-form-field>
<mat-form-field appearance="outline" class="pm-field">
<mat-label>Gender</mat-label>
<mat-select formControlName="gender">
<mat-option value="Male">Male</mat-option>
<mat-option value="Female">Female</mat-option>
<mat-option value="Other">Other</mat-option>
</mat-select>
</mat-form-field>
<mat-form-field appearance="outline" class="pm-field">
<mat-label>Telephone</mat-label>
<input matInput formControlName="telephone" />
</mat-form-field>
<mat-form-field appearance="outline" class="pm-field">
<mat-label>Mobile No (SMS)</mat-label>
<input matInput required formControlName="mobileNoSms" />
</mat-form-field>
</div>
</div>
<div class="pm-section">
<div class="pm-section__header">
<i class="fas fa-shield-alt"></i>
<h3>KYC</h3>
</div>
<div class="pm-grid">
<mat-form-field appearance="outline" class="pm-field">
<mat-label>Aadhar No</mat-label>
<input matInput formControlName="aadharNo" />
</mat-form-field>
<mat-form-field appearance="outline" class="pm-field">
<mat-label>PAN Card No</mat-label>
<input matInput formControlName="panCardNo" />
</mat-form-field>
<mat-form-field appearance="outline" class="pm-field">
<mat-label>CKYC ID</mat-label>
<input matInput formControlName="ckycId" />
</mat-form-field>
<mat-form-field appearance="outline" class="pm-field">
<mat-label>Operation</mat-label>
<mat-select required formControlName="operation">
@for (op of operations; track op) {
<mat-option [value]="op">{{ op }}</mat-option>
}
</mat-select>
</mat-form-field>
<mat-form-field appearance="outline" class="pm-field">
<mat-label>Birth Date</mat-label>
<input matInput [matDatepicker]="birthPicker" formControlName="birthDate" />
<mat-datepicker-toggle matIconSuffix [for]="birthPicker"></mat-datepicker-toggle>
<mat-datepicker #birthPicker></mat-datepicker>
</mat-form-field>
<mat-form-field appearance="outline" class="pm-field">
<mat-label>Occupation</mat-label>
<mat-select formControlName="occupation">
@for (occ of occupations; track occ) {
<mat-option [value]="occ">{{ occ }}</mat-option>
}
</mat-select>
</mat-form-field>
<mat-form-field appearance="outline" class="pm-field">
<mat-label>Risk</mat-label>
<mat-select formControlName="risk">
@for (r of riskLevels; track r) {
<mat-option [value]="r">{{ r }}</mat-option>
}
</mat-select>
</mat-form-field>
</div>
</div>
<div class="pm-section">
<div class="pm-section__header">
<i class="fas fa-user-friends"></i>
<h3>Minor / Guardian</h3>
</div>
<div class="pm-grid">
<mat-form-field appearance="outline" class="pm-field">
<mat-label>Adult / Minor</mat-label>
<mat-select required formControlName="adultMinor">
@for (opt of adultMinorOptions; track opt) {
<mat-option [value]="opt">{{ opt }}</mat-option>
}
</mat-select>
</mat-form-field>
<mat-form-field appearance="outline" class="pm-field">
<mat-label>Minor Date</mat-label>
<input matInput [matDatepicker]="minorPicker" formControlName="minorDate" />
<mat-datepicker-toggle matIconSuffix [for]="minorPicker"></mat-datepicker-toggle>
<mat-datepicker #minorPicker></mat-datepicker>
</mat-form-field>
<mat-form-field appearance="outline" class="pm-field">
<mat-label>Guardian Client ID</mat-label>
<input matInput formControlName="guardianClientId" />
</mat-form-field>
<mat-form-field appearance="outline" class="pm-field">
<mat-label>Min. A/c Opt</mat-label>
<input matInput formControlName="minAccountOpt" />
</mat-form-field>
<mat-form-field appearance="outline" class="pm-field">
<mat-label>Relation</mat-label>
<input matInput formControlName="relation" />
</mat-form-field>
</div>
</div>
<div class="pm-section">
<div class="pm-section__header">
<i class="fas fa-calendar-day"></i>
<h3>Scheme Terms</h3>
</div>
<div class="pm-grid">
<mat-form-field appearance="outline" class="pm-field">
<mat-label>Opening Date</mat-label>
<input matInput required [matDatepicker]="openingPicker" formControlName="openingDate" />
<mat-datepicker-toggle matIconSuffix [for]="openingPicker"></mat-datepicker-toggle>
<mat-datepicker #openingPicker></mat-datepicker>
</mat-form-field>
<mat-form-field appearance="outline" class="pm-field">
<mat-label>Month</mat-label>
<input matInput type="number" formControlName="month" />
</mat-form-field>
<mat-form-field appearance="outline" class="pm-field">
<mat-label>Maturity Date</mat-label>
<input matInput formControlName="maturityDate" placeholder="Calculated" />
</mat-form-field>
<mat-form-field appearance="outline" class="pm-field">
<mat-label>Reopen Date</mat-label>
<input matInput [matDatepicker]="reopenPicker" formControlName="reopenDate" />
<mat-datepicker-toggle matIconSuffix [for]="reopenPicker"></mat-datepicker-toggle>
<mat-datepicker #reopenPicker></mat-datepicker>
</mat-form-field>
<mat-form-field appearance="outline" class="pm-field">
<mat-label>Deposit Amt</mat-label>
<input matInput type="number" formControlName="depositAmount" />
</mat-form-field>
<mat-form-field appearance="outline" class="pm-field">
<mat-label>Daily Deposit Amt</mat-label>
<input matInput required type="number" formControlName="dailyDepositAmount" />
</mat-form-field>
<mat-form-field appearance="outline" class="pm-field">
<mat-label>Days</mat-label>
<input matInput required type="number" formControlName="days" />
</mat-form-field>
<mat-form-field appearance="outline" class="pm-field">
<mat-label>Ledger No</mat-label>
<input matInput formControlName="ledgerNo" placeholder="Auto-generated" />
</mat-form-field>
<mat-form-field appearance="outline" class="pm-field">
<mat-label>Mat. Amt</mat-label>
<input matInput formControlName="maturityAmount" placeholder="Calculated" />
</mat-form-field>
<mat-form-field appearance="outline" class="pm-field">
<mat-label>Interest Rate</mat-label>
<input matInput formControlName="interestRate" placeholder="From product" />
</mat-form-field>
<mat-form-field appearance="outline" class="pm-field">
<mat-label>Withdraw Type</mat-label>
<mat-select formControlName="withdrawType">
@for (type of withdrawTypes; track type) {
<mat-option [value]="type">{{ type }}</mat-option>
}
</mat-select>
</mat-form-field>
</div>
</div>
<div class="pm-section">
<div class="pm-section__header">
<i class="fas fa-exclamation-triangle"></i>
<h3>Status &amp; Overdue Flags</h3>
</div>
<div class="pm-grid">
<div class="pm-checkbox-field">
<mat-checkbox formControlName="od1Flag">OD1 Flag</mat-checkbox>
</div>
<div class="pm-checkbox-field">
<mat-checkbox formControlName="od2Flag">OD2 Flag</mat-checkbox>
</div>
<mat-form-field appearance="outline" class="pm-field">
<mat-label>Lien</mat-label>
<mat-select formControlName="lien">
<mat-option value="Yes">Yes</mat-option>
<mat-option value="No">No</mat-option>
</mat-select>
</mat-form-field>
<mat-form-field appearance="outline" class="pm-field">
<mat-label>Pay Date</mat-label>
<input matInput [matDatepicker]="payDatePicker" formControlName="payDate" />
<mat-datepicker-toggle matIconSuffix [for]="payDatePicker"></mat-datepicker-toggle>
<mat-datepicker #payDatePicker></mat-datepicker>
</mat-form-field>
<mat-form-field appearance="outline" class="pm-field">
<mat-label>Payment Amount</mat-label>
<input matInput type="number" formControlName="paymentAmount" />
</mat-form-field>
<mat-form-field appearance="outline" class="pm-field">
<mat-label>Current Bal</mat-label>
<input matInput formControlName="currentBalance" />
</mat-form-field>
<mat-form-field appearance="outline" class="pm-field">
<mat-label>Opening Bal</mat-label>
<input matInput type="number" formControlName="openingBalance" />
</mat-form-field>
<div class="pm-checkbox-field">
<mat-checkbox formControlName="isSmsActive">Is SMS Active</mat-checkbox>
</div>
<div class="pm-checkbox-field">
<mat-checkbox formControlName="isEmailActive">Is Email Active</mat-checkbox>
</div>
<mat-form-field appearance="outline" class="pm-field">
<mat-label>Int.Calc</mat-label>
<input matInput formControlName="interestCalc" />
</mat-form-field>
<mat-form-field appearance="outline" class="pm-field">
<mat-label>Interest Date</mat-label>
<input matInput [matDatepicker]="interestDatePicker" formControlName="interestDate" />
<mat-datepicker-toggle matIconSuffix [for]="interestDatePicker"></mat-datepicker-toggle>
<mat-datepicker #interestDatePicker></mat-datepicker>
</mat-form-field>
<mat-form-field appearance="outline" class="pm-field pm-field--wide">
<mat-label>Sp.Description</mat-label>
<input matInput formControlName="spDescription" />
</mat-form-field>
<mat-form-field appearance="outline" class="pm-field">
<mat-label>Fr'z CD</mat-label>
<mat-select formControlName="freezeCode">
@for (code of freezeCodes; track code) {
<mat-option [value]="code">{{ code }}</mat-option>
}
</mat-select>
</mat-form-field>
<mat-form-field appearance="outline" class="pm-field">
<mat-label>Adv Code</mat-label>
<mat-select formControlName="advCode">
@for (code of advCodes; track code) {
<mat-option [value]="code">{{ code }}</mat-option>
}
</mat-select>
</mat-form-field>
</div>
</div>
@if (partyForm.get('advCode').value === 'Transfer To Saving') {
<div class="pm-section">
<div class="pm-section__header">
<i class="fas fa-exchange-alt"></i>
<h3>Maturity Payout Routing</h3>
</div>
<div class="pm-grid">
<mat-form-field appearance="outline" class="pm-field">
<mat-label>Credit To Branch</mat-label>
<input matInput required formControlName="creditToBranch" />
</mat-form-field>
<mat-form-field appearance="outline" class="pm-field">
<mat-label>Credit To A/C Type</mat-label>
<input matInput required formControlName="creditToAccountType" />
</mat-form-field>
<mat-form-field appearance="outline" class="pm-field">
<mat-label>Credit To A/C</mat-label>
<input matInput required formControlName="creditToAccount" />
</mat-form-field>
</div>
</div>
}
</div>
</mat-tab>
<mat-tab label="Nominee's Detail">
<div class="pm-tab-content">
<div class="pm-section">
<div class="pm-grid">
<mat-form-field appearance="outline" class="pm-field">
<mat-label>Nominee</mat-label>
<mat-select formControlName="nomineeApplicable">
<mat-option value="Yes">Yes</mat-option>
<mat-option value="No">No</mat-option>
</mat-select>
</mat-form-field>
<mat-radio-group formControlName="nomineeSplitMode" class="pm-radio-group">
<mat-radio-button value="Percentage">Percentage</mat-radio-button>
<mat-radio-button value="Sequence">Sequence</mat-radio-button>
</mat-radio-group>
<button mat-stroked-button type="button" class="pm-add-nominee" (click)="addNominee()">
<i class="fas fa-plus"></i> Add Nominee
</button>
</div>
@if (nominees.length === 0) {
<p class="pm-empty">No nominees added yet.</p>
}
@for (nominee of nominees.controls; track nominee; let i = $index) {
<div class="pm-nominee-row" [formGroup]="$any(nominee)">
<mat-form-field appearance="outline" class="pm-field">
<mat-label>Nom.Dt</mat-label>
<input matInput required [matDatepicker]="nomDatePicker" formControlName="nominationDate" />
<mat-datepicker-toggle matIconSuffix [for]="nomDatePicker"></mat-datepicker-toggle>
<mat-datepicker #nomDatePicker></mat-datepicker>
</mat-form-field>
<mat-form-field appearance="outline" class="pm-field">
<mat-label>Name</mat-label>
<input matInput required formControlName="name" />
</mat-form-field>
<mat-form-field appearance="outline" class="pm-field">
<mat-label>Mobile No</mat-label>
<input matInput formControlName="mobileNo" />
</mat-form-field>
<mat-form-field appearance="outline" class="pm-field">
<mat-label>Birth Dt</mat-label>
<input matInput [matDatepicker]="nomBirthPicker" formControlName="birthDate" />
<mat-datepicker-toggle matIconSuffix [for]="nomBirthPicker"></mat-datepicker-toggle>
<mat-datepicker #nomBirthPicker></mat-datepicker>
</mat-form-field>
<mat-form-field appearance="outline" class="pm-field">
<mat-label>Minor</mat-label>
<mat-select required formControlName="minor">
<mat-option value="Yes">Yes</mat-option>
<mat-option value="No">No</mat-option>
</mat-select>
</mat-form-field>
<mat-form-field appearance="outline" class="pm-field">
<mat-label>Relation</mat-label>
<input matInput required formControlName="relation" />
</mat-form-field>
<mat-form-field appearance="outline" class="pm-field">
<mat-label>Gender</mat-label>
<mat-select required formControlName="gender">
<mat-option value="Male">Male</mat-option>
<mat-option value="Female">Female</mat-option>
<mat-option value="Other">Other</mat-option>
</mat-select>
</mat-form-field>
<mat-form-field appearance="outline" class="pm-field">
<mat-label>Guar. Name</mat-label>
<input matInput formControlName="guardianName" />
</mat-form-field>
<button
mat-icon-button
type="button"
class="pm-remove-nominee"
(click)="removeNominee(i)"
matTooltip="Remove nominee"
>
<i class="fas fa-trash"></i>
</button>
</div>
}
</div>
</div>
</mat-tab>
</mat-tab-group>
@if (isClosed) {
<div class="pm-callout">
<i class="fas fa-lock"></i>
<span>This account is closed. No further collections or edits are possible.</span>
</div>
}
<div class="pm-action-bar">
<button mat-raised-button type="button" (click)="exit()">Exit</button>
<button mat-stroked-button type="button" (click)="cancel()">Cancel</button>
<button mat-stroked-button type="button" (click)="retrieve()">Retrieve</button>
@if (partyId && !isClosed) {
<button
mat-stroked-button
type="button"
color="warn"
matTooltip="Balance must be zero before closing"
(click)="closeAccount()"
>
<i class="fas fa-lock" style="margin-right: 6px"></i> Close Account
</button>
<!--
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.
}
<button
mat-raised-button
color="primary"
type="button"
[disabled]="partyForm.invalid || isClosed"
(click)="save()"
>
Save
</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>
</form>
} @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.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>
@@ -6,159 +6,128 @@
* file, You can obtain one at http://mozilla.org/MPL/2.0/.
*/
$primary: #0f766e;
@use 'assets/styles/product-list' as pl;
.pm-container {
max-width: 1080px;
margin: 0 auto;
padding: 24px 16px;
@include pl.product-list-styles;
/* Party-specific amber accent */
:host {
--pl-accent: #b45309;
--pl-accent-l: #fef3c7;
}
.pm-header {
/* 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: 16px;
margin-bottom: 20px;
&__title {
margin: 0;
font-size: 22px;
font-weight: 500;
}
}
.pm-back {
color: $primary;
}
.pm-card {
background: var(--md-sys-color-surface, #fff);
border-radius: 8px;
box-shadow: 0 2px 8px rgb(0 0 0 / 8%);
}
.pm-tabs {
::ng-deep .mat-mdc-tab-body-wrapper {
min-height: 300px;
}
}
.pm-tab-content {
padding: 20px 24px;
}
.pm-section {
padding: 16px 0;
border-bottom: 1px solid rgb(0 0 0 / 8%);
&:last-of-type {
border-bottom: none;
}
&__header {
display: flex;
align-items: center;
gap: 10px;
margin-bottom: 16px;
i {
color: $primary;
}
h3 {
margin: 0;
font-size: 16px;
font-weight: 600;
flex: 1;
}
}
}
.pm-grid {
display: grid;
grid-template-columns: repeat(4, 1fr);
gap: 8px 16px;
align-items: start;
}
.pm-field {
width: 100%;
&--wide {
grid-column: span 2;
}
}
.pm-checkbox-field {
display: flex;
align-items: center;
height: 56px;
}
.pm-radio-group {
display: flex;
align-items: center;
gap: 16px;
height: 56px;
}
.pm-add-nominee {
color: $primary;
height: fit-content;
align-self: center;
}
.pm-empty {
color: rgb(0 0 0 / 50%);
font-size: 14px;
margin: 0 0 8px;
}
.pm-nominee-row {
display: grid;
grid-template-columns: repeat(8, 1fr) auto;
gap: 8px 12px;
align-items: start;
padding: 10px 0;
border-top: 1px dashed rgb(0 0 0 / 10%);
&:first-of-type {
border-top: none;
}
}
.pm-remove-nominee {
color: #b91c1c;
margin-top: 8px;
}
.pm-action-bar {
display: flex;
justify-content: flex-end;
gap: 12px;
padding: 16px 24px;
border-top: 1px solid rgb(0 0 0 / 8%);
}
.pm-callout {
display: flex;
align-items: center;
gap: 10px;
margin: 0 24px;
margin: 0 0 14px;
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;
}
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;
@media (width <= 1000px) {
.pm-grid {
grid-template-columns: repeat(2, 1fr);
&:hover {
background: rgb(180 83 9 / 13%);
}
.pm-nominee-row {
grid-template-columns: repeat(2, 1fr);
&__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%);
}
@@ -7,42 +7,40 @@
*/
/** Angular Imports */
import { Component, inject } from '@angular/core';
import { UntypedFormBuilder, UntypedFormGroup, UntypedFormArray, Validators } from '@angular/forms';
import { Router } from '@angular/router';
import { MatTabsModule } from '@angular/material/tabs';
import { MatCheckbox } from '@angular/material/checkbox';
import { MatRadioModule } from '@angular/material/radio';
import { 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';
import { SettingsService } from 'app/settings/settings.service';
import { Dates } from 'app/core/utils/dates';
/** Custom Components */
import { NomineeListDialogComponent } from './nominee-list-dialog/nominee-list-dialog.component';
/**
* Party Master Entry component.
* Party Master list.
*
* The customer's DDS account — linked to an Agent for daily collection,
* carrying its own scheme terms, KYC, two-stage overdue flags, and a
* nominee list. Modelled directly on the legacy Party Master Entry screen
* this replaces.
*
* Backed by its own DDS Party API (`/dds/parties`). The underlying account
* does not yet reuse the Recurring Deposit account engine (see Product
* Setup) — that linkage is a follow-up once the RD-account-specific fields
* this screen doesn't collect (interest/compounding periods, chart
* selection, ...) have a home. Until then the scheme/balance fields here
* are the Party's own.
*
* "Close Account" (command=close) permanently closes the Party once its
* balance is zero - the backend rejects the command otherwise. A closed
* Party can no longer be edited or receive new Party Tran collections.
*
* savingsAccountNo optionally links this Party to an existing, non-closed real Savings Account of
* the same client (resolved by account number, since that's what an officer has to hand) -
* clarified with the business (2026-08-24) as "link it if the client already has one, otherwise
* leave it". There is no unlink once linked.
* 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',
@@ -50,478 +48,132 @@ import { Dates } from 'app/core/utils/dates';
styleUrls: ['./party-master.component.scss'],
imports: [
...STANDALONE_SHARED_IMPORTS,
MatTabsModule,
MatCheckbox,
MatRadioModule
FaIconComponent,
MatTable,
MatSort,
MatColumnDef,
MatHeaderCellDef,
MatHeaderCell,
MatSortHeader,
MatCellDef,
MatCell,
MatHeaderRowDef,
MatHeaderRow,
MatRowDef,
MatRow,
MatPaginator,
MatTooltip
]
})
export class PartyMasterComponent {
private formBuilder = inject(UntypedFormBuilder);
private router = inject(Router);
export class PartyMasterComponent implements OnInit {
private organizationService = inject(OrganizationService);
private settingsService = inject(SettingsService);
private dateUtils = inject(Dates);
private dialog = inject(MatDialog);
partyId: number | null = null;
selectedTabIndex = 0;
partyStatus = 'ACTIVE';
linkedSavingsAccountId: number | null = null;
get isClosed(): boolean {
return this.partyStatus === 'CLOSED';
}
subTypes = [
'Daily Deposit Scheme',
'Masik Dhan Varsha Deposit'
];
operations = [
'Self',
'Guardian',
'Joint'
];
occupations = [
'Business',
'Service',
'Agriculture',
'Homemaker',
'Student',
'Retired',
'Other'
];
riskLevels = [
'Low',
'Medium',
'High'
];
adultMinorOptions = [
'Adult',
'Minor'
];
withdrawTypes = [
'Cash',
'Transfer',
'Clearing'
];
freezeCodes = [
'Normal',
'Debit Freeze',
'Credit Freeze',
'Total Freeze'
];
advCodes = [
'None',
'Transfer To Saving',
'Renew'
displayedColumns: string[] = [
'partyCode',
'partyName',
'subType',
'agentName',
'savingsAccountNo',
'dailyDepositAmount',
'currentBalance',
'nominees',
'status',
'actions'
];
partyForm: UntypedFormGroup = this.formBuilder.group({
clientId: [
'',
Validators.required
],
subType: [
'',
Validators.required
],
agentId: [
'',
Validators.required
],
savingsAccountNo: [''],
lastAccount: [
{
value: '',
disabled: true
}
],
partyCode: [
{
value: '',
disabled: true
}
],
nominal: [
'No',
Validators.required
],
aadharNo: [''],
partyName: [
'',
Validators.required
],
partyName1: [''],
address1: [
'',
Validators.required
],
address2: [''],
address3: [''],
city: [
'',
Validators.required
],
pincode: [
'',
Validators.required
],
gender: [''],
panCardNo: [''],
telephone: [''],
mobileNoSms: [
'',
Validators.required
],
operation: [
'Self',
Validators.required
],
birthDate: [''],
occupation: [''],
risk: [''],
ckycId: [''],
adultMinor: [
'Adult',
Validators.required
],
minorDate: [''],
guardianClientId: [''],
minAccountOpt: [''],
relation: [''],
openingDate: [
'',
Validators.required
],
month: [''],
maturityDate: [
{
value: '',
disabled: true
}
],
reopenDate: [''],
depositAmount: [0],
dailyDepositAmount: [
0,
Validators.required
],
days: [
'',
Validators.required
],
ledgerNo: [
{
value: '',
disabled: true
}
],
maturityAmount: [
{
value: 0,
disabled: true
}
],
interestRate: [
{
value: 0,
disabled: true
}
],
withdrawType: [''],
od1Flag: [
{
value: false,
disabled: true
}
],
od2Flag: [
{
value: false,
disabled: true
}
],
lien: ['No'],
payDate: [''],
paymentAmount: [0],
currentBalance: [
{
value: 0,
disabled: true
}
],
openingBalance: [0],
isSmsActive: [true],
isEmailActive: [false],
interestCalc: [
{
value: 0,
disabled: true
}
],
interestDate: [''],
spDescription: [''],
freezeCode: ['Normal'],
advCode: ['None'],
creditToBranch: [''],
creditToAccountType: [''],
creditToAccount: [''],
nomineeApplicable: ['No'],
nomineeSplitMode: ['Percentage'],
nominees: this.formBuilder.array([])
});
get nominees(): UntypedFormArray {
return this.partyForm.get('nominees') as UntypedFormArray;
}
private buildNomineeGroup(): UntypedFormGroup {
return this.formBuilder.group({
nominationDate: [
'',
Validators.required
],
name: [
'',
Validators.required
],
mobileNo: [''],
birthDate: [''],
minor: [
'No',
Validators.required
],
relation: [
'',
Validators.required
],
gender: [
'',
Validators.required
],
guardianName: ['']
/**
* 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
});
}
addNominee(): void {
this.nominees.push(this.buildNomineeGroup());
/** How many people are named on an account, which is what the list column shows. */
nomineeCount(party: any): number {
return (party?.nominees || []).length;
}
removeNominee(index: number): void {
this.nominees.removeAt(index);
/**
* 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;
@ViewChild(MatPaginator, { static: true }) paginator: MatPaginator;
@ViewChild(MatSort, { static: true }) sort: MatSort;
get totalCount(): number {
return this.parties.length;
}
retrieve(): void {
const clientId = this.partyForm.get('clientId')?.value;
if (!clientId) {
return;
}
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[]) => {
const match = (parties || []).find((party) => String(party.clientId) === String(clientId));
if (match) {
this.patchFormFromParty(match);
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;
}
});
}
cancel(): void {
this.partyId = null;
this.partyStatus = 'ACTIVE';
this.linkedSavingsAccountId = null;
this.partyForm.reset();
this.partyForm.patchValue({
nominal: 'No',
operation: 'Self',
adultMinor: 'Adult',
lien: 'No',
isSmsActive: true,
isEmailActive: false,
freezeCode: 'Normal',
advCode: 'None',
nomineeApplicable: 'No',
nomineeSplitMode: 'Percentage',
currentBalance: 0,
maturityAmount: 0,
interestRate: 0
});
this.nominees.clear();
}
exit(): void {
this.router.navigate(['/accounts/savings']);
}
save(): void {
const raw = this.partyForm.getRawValue();
const dateFormat = this.settingsService.dateFormat;
const locale = this.settingsService.language.code;
const asDate = (value: any) => (value ? this.dateUtils.formatDate(value, dateFormat) : undefined);
const payload: any = {
clientId: Number(raw.clientId),
agentId: Number(raw.agentId),
savingsAccountNo: this.linkedSavingsAccountId ? undefined : raw.savingsAccountNo || undefined,
subType: raw.subType,
lastAccount: raw.lastAccount || undefined,
nominal: raw.nominal === 'Yes',
aadharNo: raw.aadharNo || undefined,
partyName: raw.partyName,
partyName1: raw.partyName1 || undefined,
address1: raw.address1,
address2: raw.address2 || undefined,
address3: raw.address3 || undefined,
city: raw.city,
pincode: raw.pincode,
gender: raw.gender || undefined,
panCardNo: raw.panCardNo || undefined,
telephone: raw.telephone || undefined,
mobileNoSms: raw.mobileNoSms,
operation: raw.operation,
birthDate: asDate(raw.birthDate),
occupation: raw.occupation || undefined,
risk: raw.risk || undefined,
ckycId: raw.ckycId || undefined,
adultMinor: raw.adultMinor,
minorDate: asDate(raw.minorDate),
guardianClientId: raw.guardianClientId ? Number(raw.guardianClientId) : undefined,
minAccountOpt: raw.minAccountOpt || undefined,
relation: raw.relation || undefined,
openingDate: asDate(raw.openingDate),
month: raw.month || undefined,
reopenDate: asDate(raw.reopenDate),
depositAmount: raw.depositAmount || undefined,
dailyDepositAmount: raw.dailyDepositAmount,
days: raw.days,
withdrawType: raw.withdrawType || undefined,
lien: raw.lien === 'Yes',
payDate: asDate(raw.payDate),
paymentAmount: raw.paymentAmount || undefined,
openingBalance: raw.openingBalance || 0,
isSmsActive: !!raw.isSmsActive,
isEmailActive: !!raw.isEmailActive,
interestDate: asDate(raw.interestDate),
spDescription: raw.spDescription || undefined,
freezeCode: raw.freezeCode || undefined,
advCode: raw.advCode || undefined,
creditToBranch: raw.creditToBranch || undefined,
creditToAccountType: raw.creditToAccountType || undefined,
creditToAccount: raw.creditToAccount || undefined,
nomineeApplicable: raw.nomineeApplicable === 'Yes',
nomineeSplitMode: raw.nomineeSplitMode || undefined,
dateFormat,
locale,
nominees: (raw.nominees || []).map((nominee: any) => ({
nominationDate: this.dateUtils.getDate(nominee.nominationDate),
name: nominee.name,
mobileNo: nominee.mobileNo || undefined,
birthDate: nominee.birthDate ? this.dateUtils.getDate(nominee.birthDate) : undefined,
minor: nominee.minor === 'Yes',
relation: nominee.relation,
gender: nominee.gender,
guardianName: nominee.guardianName || undefined
}))
};
if (this.partyId) {
this.organizationService.updateParty(this.partyId, payload).subscribe(() => this.exit());
} else {
this.organizationService.createParty(payload).subscribe(() => this.exit());
}
}
closeAccount(): void {
if (!this.partyId) {
return;
}
const dateFormat = this.settingsService.dateFormat;
const locale = this.settingsService.language.code;
this.organizationService
.closeParty(this.partyId, {
closeDate: this.dateUtils.formatDate(new Date(), dateFormat),
dateFormat,
locale
})
.subscribe(() => this.exit());
}
private patchFormFromParty(party: any): void {
this.partyId = party.id;
this.partyStatus = party.status || 'ACTIVE';
this.linkedSavingsAccountId = party.savingsAccountId || null;
this.partyForm.patchValue({
clientId: party.clientId,
subType: party.subType,
agentId: party.agentId,
partyCode: party.partyCode,
lastAccount: party.lastAccount,
nominal: party.nominal ? 'Yes' : 'No',
aadharNo: party.aadharNo,
partyName: party.partyName,
partyName1: party.partyName1,
address1: party.address1,
address2: party.address2,
address3: party.address3,
city: party.city,
pincode: party.pincode,
gender: party.gender,
panCardNo: party.panCardNo,
telephone: party.telephone,
mobileNoSms: party.mobileNoSms,
operation: party.operation,
birthDate: party.birthDate ? new Date(party.birthDate) : null,
occupation: party.occupation,
risk: party.risk,
ckycId: party.ckycId,
adultMinor: party.adultMinor,
minorDate: party.minorDate ? new Date(party.minorDate) : null,
guardianClientId: party.guardianClientId,
minAccountOpt: party.minAccountOpt,
relation: party.relation,
openingDate: party.openingDate ? new Date(party.openingDate) : null,
month: party.month,
maturityDate: party.maturityDate,
reopenDate: party.reopenDate ? new Date(party.reopenDate) : null,
depositAmount: party.depositAmount,
dailyDepositAmount: party.dailyDepositAmount,
days: party.days,
ledgerNo: party.ledgerNo,
maturityAmount: party.maturityAmount,
interestRate: party.interestRate,
withdrawType: party.withdrawType,
od1Flag: party.od1Flag,
od2Flag: party.od2Flag,
lien: party.lien ? 'Yes' : 'No',
payDate: party.payDate ? new Date(party.payDate) : null,
paymentAmount: party.paymentAmount,
currentBalance: party.currentBalance,
openingBalance: party.openingBalance,
isSmsActive: party.isSmsActive,
isEmailActive: party.isEmailActive,
interestCalc: party.interestCalc,
interestDate: party.interestDate ? new Date(party.interestDate) : null,
spDescription: party.spDescription,
freezeCode: party.freezeCode,
advCode: party.advCode,
creditToBranch: party.creditToBranch,
creditToAccountType: party.creditToAccountType,
creditToAccount: party.creditToAccount,
nomineeApplicable: party.nomineeApplicable ? 'Yes' : 'No',
nomineeSplitMode: party.nomineeSplitMode
});
this.nominees.clear();
(party.nominees || []).forEach((nominee: any) => {
const group = this.buildNomineeGroup();
group.patchValue({
nominationDate: nominee.nominationDate ? new Date(nominee.nominationDate) : null,
name: nominee.name,
mobileNo: nominee.mobileNo,
birthDate: nominee.birthDate ? new Date(nominee.birthDate) : null,
minor: nominee.minor ? 'Yes' : 'No',
relation: nominee.relation,
gender: nominee.gender,
guardianName: nominee.guardianName
});
this.nominees.push(group);
});
applyFilter(filterValue: string): void {
this.dataSource.filter = filterValue.trim().toLowerCase();
}
}
@@ -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>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 po-summary__row--amount">
<span class="po-summary__label">Amount payable</span>
<span class="po-summary__value">{{ data.currentBalance | number: '1.2-2' }}</span>
</div>
</div>
@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">To an account at another bank</span>
<span class="po-choice__note">
Held in Matured Deposits Payable, then sent by NEFT or IMPS from Fund Transfers
</span>
</mat-radio-button>
</mat-radio-group>
@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 sits in the bank's Matured Deposits Payable
account until the transfer is made from Fund Transfers — so it is accounted for the whole
time, and a failed transfer loses nothing.
</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</button>
</mat-dialog-actions>
@@ -0,0 +1,92 @@
/**
* 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;
}
}
.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;
}
@@ -0,0 +1,109 @@
/**
* Copyright since 2025 Mifos Initiative
*
* This Source Code Form is subject to the terms of the Mozilla Public
* License, v. 2.0. If a copy of the MPL was not distributed with this
* file, You can obtain one at http://mozilla.org/MPL/2.0/.
*/
/** 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 { 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,
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 = '';
loading = true;
ngOnInit(): void {
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;
}
});
}
get canPayOut(): boolean {
if (this.loading) {
return false;
}
return this.destination === 'other' || !!this.selectedAccountNo;
}
payOut(): void {
this.dialogRef.close(this.destination === 'own' ? { toSavingsAccountNo: this.selectedAccountNo } : {});
}
}
@@ -0,0 +1,118 @@
<!--
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 it for transfer to another bank</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 (ownAccounts.length === 0 && !loading) {
<p class="pcd-note">
This customer has no other account here, so the balance is parked in matured-deposits payable for the
branch to send on.
</p>
}
<p class="pcd-warn">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()">
Close Account
</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/.
*/
/* 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;
}
@@ -0,0 +1,136 @@
/**
* 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 { 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,
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 = '';
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 {
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);
}
get canClose(): boolean {
if (this.loading || !this.borneBy) {
return false;
}
return this.destination === 'other' || !!this.selectedAccountNo;
}
close(): void {
this.dialogRef.close({
shortfallBorneBy: this.borneBy,
...(this.destination === 'own' && this.selectedAccountNo ? { toSavingsAccountNo: this.selectedAccountNo } : {})
});
}
}