diff --git a/angular.json b/angular.json index 9f7f267f0..51c068055 100644 --- a/angular.json +++ b/angular.json @@ -76,8 +76,8 @@ "budgets": [ { "type": "initial", - "maximumWarning": "15mb", - "maximumError": "17mb" + "maximumWarning": "18mb", + "maximumError": "20mb" }, { "type": "anyComponentStyle", diff --git a/src/app/accounts/accounts-routing.module.ts b/src/app/accounts/accounts-routing.module.ts index 3654ae7de..228a5357f 100644 --- a/src/app/accounts/accounts-routing.module.ts +++ b/src/app/accounts/accounts-routing.module.ts @@ -11,6 +11,14 @@ import { Routes, RouterModule } from '@angular/router'; import { Route } from '../core/route/route.service'; import { AccountsListComponent } from './accounts-list/accounts-list.component'; +import { PartyMasterComponent } from '../deposits/party-master/party-master.component'; +import { CreatePartyComponent } from '../deposits/party-master/create-party/create-party.component'; +import { DueForClosureComponent } from '../deposits/party-master/due-for-closure/due-for-closure.component'; +import { DailyTransactionEntryComponent } from '../deposits/daily-transaction-entry/daily-transaction-entry.component'; +import { DdsReportsComponent } from '../deposits/dds-reports/dds-reports.component'; +import { ReportViewerComponent } from '../deposits/dds-reports/report-viewer/report-viewer.component'; +import { DdsAccountsComponent } from '../deposits/dds-accounts/dds-accounts.component'; +import { DdsAccountViewComponent } from '../deposits/dds-accounts/dds-account-view/dds-account-view.component'; const routes: Routes = [ Route.withShell([ @@ -41,6 +49,59 @@ const routes: Routes = [ title: 'Recurring Deposit Accounts', breadcrumb: 'Recurring Deposit Accounts' } + }, + { + path: 'dds', + data: { title: 'Daily Deposit Scheme — Party Master', breadcrumb: 'Party Master' }, + children: [ + { path: '', component: PartyMasterComponent }, + { + path: 'create', + component: CreatePartyComponent, + data: { title: 'Create Party', breadcrumb: 'Create' } + }, + { + // Ahead of the :partyId routes, so "dueforclosure" is not read as a party id. + path: 'dueforclosure', + component: DueForClosureComponent, + data: { title: 'Accounts Due for Closure', breadcrumb: 'Due for Closure' } + }, + { + path: ':partyId/edit', + component: CreatePartyComponent, + data: { title: 'Edit Party', breadcrumb: 'Edit' } + } + ] + }, + { + // The DDS accounts listed like Savings Accounts, each opening onto its transactions. + path: 'dds-accounts', + data: { title: 'DDS Accounts', breadcrumb: 'DDS Accounts' }, + children: [ + { path: '', component: DdsAccountsComponent }, + { + path: ':partyId', + component: DdsAccountViewComponent, + data: { title: 'DDS Account', breadcrumb: 'Account', routeParamBreadcrumb: false } + } + ] + }, + { + path: 'dds-collection', + component: DailyTransactionEntryComponent, + data: { title: 'Daily Transaction Entry', breadcrumb: 'Daily Transaction Entry' } + }, + { + path: 'dds-reports', + data: { title: 'DDS Reports', breadcrumb: 'DDS Reports' }, + children: [ + { path: '', component: DdsReportsComponent }, + { + path: 'view/:reportName', + component: ReportViewerComponent, + data: { title: 'DDS Report', breadcrumb: 'reportName', routeParamBreadcrumb: 'reportName' } + } + ] } ] } diff --git a/src/app/core/shell/shell.component.html b/src/app/core/shell/shell.component.html index 16e462fd8..611b7cba4 100644 --- a/src/app/core/shell/shell.component.html +++ b/src/app/core/shell/shell.component.html @@ -914,7 +914,11 @@ (click)="subLedgerOpen = false" > Suit File Entry - Locker Entry Recurring - + +
Agent
-
Reports
+ @@ -1473,7 +1500,9 @@ - + diff --git a/src/app/deposits/daily-transaction-entry/apply-dds-receipt-dialog/apply-dds-receipt-dialog.component.html b/src/app/deposits/daily-transaction-entry/apply-dds-receipt-dialog/apply-dds-receipt-dialog.component.html new file mode 100644 index 000000000..6de1335d7 --- /dev/null +++ b/src/app/deposits/daily-transaction-entry/apply-dds-receipt-dialog/apply-dds-receipt-dialog.component.html @@ -0,0 +1,381 @@ + + +
+
+
+ account_balance_wallet +
+
+

Apply DDS Receipt

+

+ Agent {{ data.agentCode }} · {{ data.agentName }} + @if (data.glCode) { + · GL {{ data.glCode }} + } +

+
+ +
+ + + @if (loading) { +
+ + Loading entries and receipts… +
+ } @else { + +
+
+
+ Collections up to {{ data.entryDate | date: 'dd/MM/yyyy' }} + {{ pickedEntryCount }} {{ pickedEntryCount === 1 ? 'entry' : 'entries' }} to settle +
+
+ {{ required | number: '1.2-2' }} +
+
+
+
+ Vouchers picked + {{ pickedReceipts.size }} {{ pickedReceipts.size === 1 ? 'voucher' : 'vouchers' }} +
+
+ + {{ received | number: '1.2-2' }} + + @if (pickedReceipts.size > 0 && pickedDays.size > 0 && difference < 0) { + Short {{ -difference | number: '1.2-2' }} + } @else if (pickedReceipts.size > 0 && pickedDays.size > 0 && difference > 0) { + Over {{ difference | number: '1.2-2' }} + } @else if (canApply) { + ✓ Matches + } +
+
+ +
+ Total to Apply +
+ {{ required | number: '1.2-2' }} + → + {{ received | number: '1.2-2' }} + + {{ canApply ? '✓ Ready' : 'Pending' }} + +
+
+
+ + +
+
+ 1 +
+

Outstanding up to {{ data.entryDate | date: 'dd/MM/yyyy' }}

+

Everything the agent wrote down and has not handed in, day by day. Open a day to see its lines.

+
+
+ + @if (days.length) { +
    + @for (day of days; track day.date) { +
  • +
    + {{ day.date | date: 'dd/MM/yyyy' }} + {{ day.entries.length }} {{ day.entries.length === 1 ? 'entry' : 'entries' }} +
    + {{ day.total | number: '1.2-2' }} + +
  • + @if (openDay === day.date) { +
  • + @if (day.entries.length > 8) { + + } +
    + + + @for (entry of visibleEntries(day); track entry.id) { + + + + + + + } @empty { + + + + } + +
    {{ entry.partyCode }}{{ entry.partyName }} + {{ entry.transactionMode }} + @if (entry.advanceDays) { + · {{ entry.advanceDays }}d advance + } + @if (entry.arrearsDays) { + · {{ entry.arrearsDays }}d previous + } + {{ entry.amount | number: '1.2-2' }}
    No line matches.
    +
    +
  • + } + } +
+ } @else { +

+ {{ data.agentName }} has nothing outstanding for {{ data.entryDate | date: 'dd/MM/yyyy' }}. Change the date + on the screen to the day the entries were written. +

+ } +
+ + +
+
+ 2 +
+

DDS Cash Receipts

+

Tick the vouchers. Posts Dr Agent GL / Cr DDS Customer Deposits.

+
+ +
+ + @if (available.length) { +
    + @for (option of available; track option.id) { +
  • + {{ + pickedReceipts.has(option.id) ? 'check_box' : 'check_box_outline_blank' + }} +
    + {{ option.voucherNo }} + {{ option.txnDate }} · {{ glLabel(option) }} + @if (option.description) { + {{ option.description }} + } +
    + {{ option.amount | number: '1.2-2' }} + +
  • + } +
+ } @else { +

+ No DDS receipt is waiting on this agent's GL account. Collect the cash at + Cashier › Transaction Entry with entry type DDS and have the manager approve it, + or find an earlier voucher by number. +

+ } + + @if (pickedReceipts.size > 0 && pickedDays.size > 0 && difference !== 0) { +

+ The vouchers ({{ received | number: '1.2-2' }}) have to match the day's entries ({{ + required | number: '1.2-2' + }}) exactly, so every customer is credited what the agent wrote down. +

+ } +
+ + @if (errorMsg && !searchOpen) { +
+ error_outline + {{ errorMsg }} +
+ } + } +
+ + + + + +
+ + +@if (searchOpen) { +
+ +
+} + + +@if (viewing) { +
+ +
+} diff --git a/src/app/deposits/daily-transaction-entry/apply-dds-receipt-dialog/apply-dds-receipt-dialog.component.scss b/src/app/deposits/daily-transaction-entry/apply-dds-receipt-dialog/apply-dds-receipt-dialog.component.scss new file mode 100644 index 000000000..37ec50b55 --- /dev/null +++ b/src/app/deposits/daily-transaction-entry/apply-dds-receipt-dialog/apply-dds-receipt-dialog.component.scss @@ -0,0 +1,95 @@ +/** + * Copyright since 2025 Mifos Initiative + * + * This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. + */ + +// The same look as Apply Additional Shares, on purpose: both apply a cashier's receipt to what it was +// collected for, and the counter should meet one screen, not two. Used as it is, never changed here. +@use '../../../shares/share-dialog.shared' as *; + +:host { + display: block; + width: 100%; +} + +// The agent's outstanding lines - read only, what the receipt has to cover. +.entries { + width: 100%; + border-collapse: collapse; + font-size: 13px; + + td { + padding: 7px 4px; + border-bottom: 1px solid #eef2f7; + } + + tr:last-child td { + border-bottom: none; + } + + .entry-date { + color: #64748b; + white-space: nowrap; + } + + .entry-code { + font-weight: 600; + white-space: nowrap; + } + + .entry-name { + color: #475569; + } + + .num { + text-align: right; + white-space: nowrap; + } +} + +// Days and vouchers are ticked, any number of each - a check box where the share dialog has a radio. +.option-check { + color: #94a3b8; + flex-shrink: 0; +} + +.option-picked { + border-color: #2563eb !important; + background: #eff6ff !important; + + .option-check { + color: #2563eb; + } +} + +// A day's own lines, opened under it. Scrolls inside itself, so a thousand customers do not make the +// dialog a thousand lines tall. +.day-lines { + list-style: none; + padding: 6px 10px 10px 42px; +} + +.day-search { + width: 100%; + margin-bottom: 6px; + padding: 6px 10px; + border: 1px solid #e2e8f0; + border-radius: 6px; + font: inherit; + font-size: 13px; +} + +.day-scroll { + max-height: 240px; + overflow-y: auto; +} + +.field-readonly { + padding: 8px 10px; + border: 1px solid #e2e8f0; + border-radius: 6px; + background: #f8fafc; +} diff --git a/src/app/deposits/daily-transaction-entry/apply-dds-receipt-dialog/apply-dds-receipt-dialog.component.ts b/src/app/deposits/daily-transaction-entry/apply-dds-receipt-dialog/apply-dds-receipt-dialog.component.ts new file mode 100644 index 000000000..1993d96ab --- /dev/null +++ b/src/app/deposits/daily-transaction-entry/apply-dds-receipt-dialog/apply-dds-receipt-dialog.component.ts @@ -0,0 +1,351 @@ +/** + * Copyright since 2025 Mifos Initiative + * + * This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. + */ + +import { Component, OnInit, inject } from '@angular/core'; +import { CommonModule } from '@angular/common'; +import { FormsModule } from '@angular/forms'; +import { MAT_DIALOG_DATA, MatDialogRef, MatDialogModule } from '@angular/material/dialog'; +import { MatButtonModule } from '@angular/material/button'; +import { MatIconModule } from '@angular/material/icon'; +import { MatProgressSpinnerModule } from '@angular/material/progress-spinner'; +import { catchError } from 'rxjs/operators'; +import { of } from 'rxjs'; +import { OrganizationService } from 'app/organization/organization.service'; +import { SettingsService } from 'app/settings/settings.service'; + +/** One day of an agent's outstanding entries, as the dialog lists them. */ +export interface EntryDay { + date: string; + entries: any[]; + total: number; +} + +/** + * Applies the cashier's DDS receipts to an agent's outstanding collections. + * + * The agent's cash is already in the bank: the cashier took it at the counter into the agent's own GL + * account, tagged DDS, and the manager approved it. Applying it here hands the money on to the + * customers the agent wrote down - Dr the agent's GL, Cr DDS Customer Deposits - and each entry is + * settled against the application. The same screen as Apply Additional Shares. + * + * Everything outstanding up to the day chosen on the screen, shown a day at a time - nothing to tick, + * because what the agent wrote down is settled whole, and an agent who comes in after several days + * brings several days' cash. The vouchers are ticked, since a day's + * cash can come in more than one visit. They have to agree exactly: what the agent wrote down is what + * each customer is owed, so vouchers short of it leave somebody uncredited and vouchers over it leave + * money nobody can place. + * + * The day is shown totalled and its lines opened only when asked for - an agent with a thousand + * customers is a thousand lines, and the day's total is what the counter is checking. + */ +@Component({ + selector: 'mifosx-apply-dds-receipt-dialog', + templateUrl: './apply-dds-receipt-dialog.component.html', + styleUrls: ['./apply-dds-receipt-dialog.component.scss'], + standalone: true, + imports: [ + CommonModule, + FormsModule, + MatDialogModule, + MatButtonModule, + MatIconModule, + MatProgressSpinnerModule + ] +}) +export class ApplyDdsReceiptDialogComponent implements OnInit { + private organizationService = inject(OrganizationService); + private settingsService = inject(SettingsService); + + public dialogRef = inject(MatDialogRef); + public data = inject<{ + agentId: number; + agentCode: string; + agentName: string; + glCode: string | null; + entryDate: string; + }>(MAT_DIALOG_DATA); + + loadingEntries = true; + loadingReceipts = true; + submitting = false; + errorMsg = ''; + + /** The chosen day's outstanding entries - one day, or none when nothing is outstanding on it. */ + days: EntryDay[] = []; + + /** DDS receipts on the agent's GL account, approved and not yet applied. */ + available: any[] = []; + + /** + * The day is settled whole, so it is always included. The vouchers are the clerk's choice, and none + * is picked for them even when only one is listed. + */ + pickedDays = new Set(); + pickedReceipts = new Set(); + + /** The day whose lines are open, and the search within it. */ + openDay: string | null = null; + daySearch = ''; + + get loading(): boolean { + return this.loadingEntries || this.loadingReceipts; + } + + ngOnInit(): void { + // Everything outstanding up to the chosen date, whatever day it was written - an agent's cash for + // several days arrives in one visit, and all of it is settled together. + this.organizationService + .getAgentPendingCollections(this.data.agentId) + .pipe( + catchError((err) => { + this.errorMsg = this.readError(err, "Could not load the agent's entries."); + return of(null); + }) + ) + .subscribe((res: any) => { + this.days = groupByDay( + (res?.entries || []).filter( + (entry: any) => entry.status === 'PENDING' && String(entry.entryDate) <= this.data.entryDate + ) + ); + this.pickedDays = new Set(this.days.map((day) => day.date)); + this.loadingEntries = false; + }); + + this.organizationService + .getAgentCashReceipts(this.data.agentId) + .pipe( + catchError((err) => { + this.errorMsg = this.readError(err, 'Could not load the available receipts.'); + return of([]); + }) + ) + .subscribe((rows: any) => { + this.available = Array.isArray(rows) ? rows : []; + this.loadingReceipts = false; + }); + } + + // ── Picking ───────────────────────────────────────────────────────────────────────────────── + + toggleReceipt(receipt: any): void { + if (this.submitting) { + return; + } + this.pickedReceipts.has(receipt.id) ? this.pickedReceipts.delete(receipt.id) : this.pickedReceipts.add(receipt.id); + } + + toggleOpen(day: EntryDay, event: Event): void { + event.stopPropagation(); + this.openDay = this.openDay === day.date ? null : day.date; + this.daySearch = ''; + } + + /** The open day's lines, narrowed by party code or name. */ + visibleEntries(day: EntryDay): any[] { + const term = this.daySearch.trim().toLowerCase(); + if (!term) { + return day.entries; + } + return day.entries.filter( + (entry: any) => + String(entry.partyCode || '') + .toLowerCase() + .includes(term) || + String(entry.partyName || '') + .toLowerCase() + .includes(term) + ); + } + + // ── What has to be covered ────────────────────────────────────────────────────────────────── + + get entryCount(): number { + return this.days.reduce((sum, day) => sum + day.entries.length, 0); + } + + get outstandingTotal(): number { + return this.days.reduce((sum, day) => sum + day.total, 0); + } + + get pickedEntryCount(): number { + return this.days.filter((day) => this.pickedDays.has(day.date)).reduce((sum, day) => sum + day.entries.length, 0); + } + + get required(): number { + return round2(this.days.filter((day) => this.pickedDays.has(day.date)).reduce((sum, day) => sum + day.total, 0)); + } + + get received(): number { + return round2( + this.available.filter((r) => this.pickedReceipts.has(r.id)).reduce((sum, r) => sum + Number(r.amount || 0), 0) + ); + } + + /** Vouchers less entries: below zero they are short, above zero they are over. */ + get difference(): number { + return round2(this.received - this.required); + } + + get canApply(): boolean { + return this.pickedDays.size > 0 && this.pickedReceipts.size > 0 && this.difference === 0 && !this.submitting; + } + + glLabel(option: any): string { + return [ + option?.glCode, + option?.glName + ] + .filter(Boolean) + .join(' — '); + } + + // ── Voucher search, for a receipt the list does not carry ─────────────────────────────────── + + searching = false; + searchOpen = false; + searchVoucherNo = ''; + + openSearch(): void { + this.searchOpen = true; + this.searchVoucherNo = ''; + this.errorMsg = ''; + } + + closeSearch(): void { + this.searchOpen = false; + this.errorMsg = ''; + } + + /** + * Finds one voucher on the agent's GL account and, when it cannot be used, says why - not taken as + * DDS, not approved yet, or already applied - rather than simply not finding it. + */ + runSearch(): void { + const voucher = this.searchVoucherNo.trim(); + if (!voucher || this.searching) { + return; + } + this.searching = true; + this.errorMsg = ''; + this.organizationService + .getAgentCashReceipts(this.data.agentId, voucher) + .pipe( + catchError((err) => { + this.errorMsg = this.readError(err, `Voucher ${voucher} could not be looked up.`); + return of(null); + }) + ) + .subscribe((rows: any) => { + this.searching = false; + if (rows === null) { + return; + } + const found = (Array.isArray(rows) ? rows : [])[0]; + if (!found) { + this.errorMsg = `Voucher ${voucher} is not on ${this.data.agentCode}'s GL account ${this.data.glCode || ''}.`; + } else if (found.entryType !== 'DDS') { + this.errorMsg = `Voucher ${voucher} was taken as "${found.entryType || 'untagged'}", not DDS. Ask the cashier to correct the entry type.`; + } else if (found.status !== 'APPROVED') { + this.errorMsg = `Voucher ${voucher} is ${found.status}. It can be applied once the manager approves it.`; + } else if (found.applied) { + this.errorMsg = `Voucher ${voucher} has already been applied.`; + } else { + this.available = [ + found, + ...this.available.filter((entry) => entry.id !== found.id) + ]; + this.pickedReceipts.add(found.id); + this.searchOpen = false; + } + }); + } + + // ── A receipt's own GL entries ────────────────────────────────────────────────────────────── + + viewing: any = null; + viewingGlEntries: any[] | null = null; + loadingGlEntries = false; + + openDetail(option: any, event: Event): void { + event.stopPropagation(); + this.viewing = option; + this.viewingGlEntries = null; + this.loadingGlEntries = true; + this.organizationService + .getCashReceiptGlEntries(option.id) + .pipe(catchError(() => of([]))) + .subscribe((rows: any) => { + this.viewingGlEntries = Array.isArray(rows) ? rows : []; + this.loadingGlEntries = false; + }); + } + + closeDetail(): void { + this.viewing = null; + this.viewingGlEntries = null; + } + + // ── Submission ────────────────────────────────────────────────────────────────────────────── + + apply(): void { + if (!this.canApply) { + return; + } + this.submitting = true; + this.errorMsg = ''; + const vouchers = this.available.filter((r) => this.pickedReceipts.has(r.id)).map((r) => r.voucherNo); + const entries = this.pickedEntryCount; + const amount = this.required; + this.organizationService + .settleAgentCollections({ + agentId: Number(this.data.agentId), + cashierDepositRequestIds: Array.from(this.pickedReceipts), + entryDates: Array.from(this.pickedDays), + locale: this.settingsService.language.code + }) + .pipe( + catchError((err) => { + this.errorMsg = this.readError(err, 'The receipts could not be applied.'); + this.submitting = false; + return of(null); + }) + ) + .subscribe((res: any) => { + if (res) { + this.dialogRef.close({ vouchers, entries, amount, response: res }); + } + }); + } + + private readError(err: any, fallback: string): string { + return ( + err?.error?.errors?.[0]?.defaultUserMessage ?? + err?.error?.defaultUserMessage ?? + err?.error?.developerMessage ?? + fallback + ); + } +} + +/** Entries grouped by the day they were collected, oldest first, each day totalled. */ +export function groupByDay(entries: any[]): EntryDay[] { + const byDay = new Map(); + for (const entry of entries) { + const date = String(entry.entryDate); + const day = byDay.get(date) ?? { date, entries: [], total: 0 }; + day.entries.push(entry); + day.total = round2(day.total + Number(entry.amount || 0)); + byDay.set(date, day); + } + return Array.from(byDay.values()).sort((a, b) => a.date.localeCompare(b.date)); +} + +function round2(value: number): number { + return Math.round(value * 100) / 100; +} diff --git a/src/app/deposits/daily-transaction-entry/daily-transaction-entry.component.html b/src/app/deposits/daily-transaction-entry/daily-transaction-entry.component.html new file mode 100644 index 000000000..e8ae7d13b --- /dev/null +++ b/src/app/deposits/daily-transaction-entry/daily-transaction-entry.component.html @@ -0,0 +1,468 @@ + + +
+
+ +

Daily Transaction Detail

+
+ +
+
+
+
+
Agent Information
+
+
Name
+
{{ agentInfo.name || '—' }}
+
Address
+
{{ agentInfo.address || '—' }}
+
City
+
{{ agentInfo.city || '—' }}
+
A/C
+
{{ agentInfo.accountNo || '—' }}
+
Guarantor
+
{{ agentInfo.guarantor1 || '—' }}
+
Guarantor
+
{{ agentInfo.guarantor2 || '—' }}
+
Curr Bal
+
{{ agentInfo.currentBalance | number: '1.2-2' }}
+
+
+ +
+
Party Information
+ @if (partyInfo) { +
+
Client Id
+
{{ partyInfo.clientId }}
+
Name
+
{{ partyInfo.partyName }}
+
Address
+
{{ partyInfo.address1 || '—' }}
+
City
+
{{ partyInfo.city || '—' }}
+
Op. Date
+
{{ partyInfo.openingDate ? (partyInfo.openingDate | dateFormat) : '—' }}
+
Maturity
+
{{ partyInfo.maturityDate ? (partyInfo.maturityDate | dateFormat) : '—' }}
+
Daily Depo
+
{{ partyInfo.dailyDepositAmount | number: '1.2-2' }}
+
Ledger
+
{{ partyInfo.ledgerNo || '—' }}
+
Aadhar No
+
{{ partyInfo.aadharNo || '—' }}
+
Curr Bal
+
{{ partyInfo.currentBalance | number: '1.2-2' }}
+ +
Due So Far
+
{{ partyInfo.expectedToDate | number: '1.2-2' }}
+
Collected
+
{{ partyInfo.collectedToDate | number: '1.2-2' }}
+ @if (schedule(partyInfo); as sched) { +
Schedule
+
{{ sched.text }}
+ } +
Bal. After This Tr
+
{{ partyBalanceAfter | number: '1.2-2' }}
+
+ } @else { +

+ Pick a party in a collection row below — this panel shows whose account it is and what the balance + becomes. +

+ } +
+
+ +
+ + Agent + + @for (agent of selectableAgents; track agent.id) { + + {{ agent.agentCode }} · {{ agent.agentNameId }} + @if (!agent.active) { + — not collecting + } + + } + + @if (selectableAgents.length === 0) { + No active agent on file + } @else if (pickedAgentIsInactive) { + + This agent no longer collects — settling held cash only + } @else if (agentInfo.name) { + + {{ agentParties.length }} {{ agentParties.length === 1 ? 'party' : 'parties' }} to collect from + + } + + + + Date + + + + + + + + Tran. Type + + @for (type of transactionTypes; track type.label) { + + {{ type.label }} + @if (!type.supported) { + — not available yet + } + + } + + +
+ + + @if (isPartyTran && agentInfo.name) { +
+
+ +

Collections up to {{ entryForm.get('transactionDate')?.value | dateFormat }}

+ +
+ + @if (pendingLoading) { +

Reading this agent's entries...

+ } @else if (pendingEntries.length === 0) { +

{{ agentInfo.name }} has nothing outstanding up to this date.

+ } @else { + @for (entry of pendingEntries; track entry.id) { +
+ + {{ entry.entryDate | dateFormat }} + {{ entry.partyCode }} + {{ entry.partyName }} + {{ entry.transactionMode }} + @if (entry.advanceDays) { + · {{ entry.advanceDays }}d advance + } + @if (entry.arrearsDays) { + · {{ entry.arrearsDays }}d previous + } + + {{ entry.amount | number: '1.2-2' }} +
+ } + +
+ {{ pendingEntries.length }} {{ pendingEntries.length === 1 ? 'entry' : 'entries' }} + {{ pendingTotal | number: '1.2-2' }} +
+ + +

+ Everything {{ agentInfo.name }} wrote down and has not handed in yet, up to this date, customer by + customer. The cash and its receipts are under Agent Tran. +

+ } +
+ } + + + @if (isAgentTran && agentInfo.name) { +
+
+ +

Agent's cash

+ + +
+ + @if (!agentGlCode) { +

+ {{ agentInfo.name }} has no GL account, so the cashier has nowhere to take their cash. Set one on Agent + Master. +

+ } @else { + +

+ Agent GL: {{ agentGlCode }}{{ agentGlName ? ' · ' + agentGlName : '' }} — the cashier + takes {{ agentInfo.name }}'s cash into this GL (Entry Type DDS). +

+ } + +
+
+ To take + {{ agentOwed | number: '1.2-2' }} +
+
+ Received (approved) + {{ agentReceived | number: '1.2-2' }} +
+
+ Still to take + {{ agentBalance | number: '1.2-2' }} +
+
+ @if (agentReceivedBeyondDay > 0) { +

+ {{ agentReceivedBeyondDay | number: '1.2-2' }} received at the counter is more than everything outstanding + up to {{ entryForm.get('transactionDate')?.value | dateFormat }}. Check the voucher was taken for the + right agent. +

+ } + @if (agentWaiting > 0) { +

+ {{ agentWaiting | number: '1.2-2' }} more is at the counter awaiting the manager's approval. +

+ } + +

+ Outstanding entries up to {{ entryForm.get('transactionDate')?.value | dateFormat }} +

+ @if (agentMoneyLoading) { +

Reading this agent's entries...

+ } @else if (agentDays.length === 0) { +

{{ agentInfo.name }} has nothing outstanding.

+ } @else { + @for (day of agentDays; track day.date) { +
+ {{ day.date | dateFormat }} + + {{ day.entries.length }} {{ day.entries.length === 1 ? 'entry' : 'entries' }} + + {{ day.total | number: '1.2-2' }} + +
+ @if (agentOpenDay === day.date) { +
+ @for (entry of day.entries; track entry.id) { +
+ {{ entry.partyCode }} + {{ entry.partyName }} + {{ entry.transactionMode }} + @if (entry.advanceDays) { + · {{ entry.advanceDays }}d advance + } + @if (entry.arrearsDays) { + · {{ entry.arrearsDays }}d previous + } + + {{ entry.amount | number: '1.2-2' }} +
+ } +
+ } + } + } + +

Cashier receipts

+ @if (agentReceipts.length === 0) { +

+ No DDS receipt on {{ agentInfo.name }}'s GL account{{ agentGlCode ? ' ' + agentGlCode : '' }} waiting to + be applied. +

+ } @else { + @for (receipt of agentReceipts; track receipt.id) { +
+ {{ receipt.voucherNo }} + {{ receipt.txnDate | dateFormat }} + {{ receipt.cashierName || '—' }} + + {{ receipt.status === 'APPROVED' ? 'Approved' : 'Awaiting manager' }} + + {{ receipt.amount | number: '1.2-2' }} +
+ } + } +
+ } + + @if (isWithdrawDeposite) { +
+
+ +

Withdraw Deposite

+
+
+ + Creditor Party Code + + arrow_drop_down + + @for (party of transferPartyOptions('creditor'); track party.id) { + + {{ party.partyCode }} · {{ party.partyName }} · ₹{{ party.currentBalance | number: '1.2-2' }} + + } + @if (transferPartyOptions('creditor').length === 0) { + No open DDS account matches + } + + Account the money goes into + + + Creditor Party Name + + + + Creditor Curr Bal + + + + + Debit Party Code + + arrow_drop_down + + @for (party of transferPartyOptions('debit'); track party.id) { + + {{ party.partyCode }} · {{ party.partyName }} · ₹{{ party.currentBalance | number: '1.2-2' }} + + } + @if (transferPartyOptions('debit').length === 0) { + No open DDS account matches + } + + @if (transferToItself) { + Pick a different account - it cannot transfer to itself + } @else { + Account the money is taken from + } + + + Debit Party Name + + + + Debit Curr Bal + + + + + Amount + + + + Description + + +
+
+ } + + @if (!isPartyTran && !isWithdrawDeposite && !isAgentTran) { +
+

+ "{{ entryForm.get('transactionType').value }}" entry screen — same shape as Party Tran, to be filled in once + the backend distinguishes these transaction types. +

+
+ } + +
+ + + + + + + @if (!isPartyTran && !isAgentTran) { + + } +
+
+
+
diff --git a/src/app/deposits/daily-transaction-entry/daily-transaction-entry.component.scss b/src/app/deposits/daily-transaction-entry/daily-transaction-entry.component.scss new file mode 100644 index 000000000..5a05ca32b --- /dev/null +++ b/src/app/deposits/daily-transaction-entry/daily-transaction-entry.component.scss @@ -0,0 +1,488 @@ +/** + * Copyright since 2025 Mifos Initiative + * + * This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. + */ + +$primary: #0f766e; + +.dte-container { + // Wider than a normal form page: a collection row carries six fields side by side, and a clerk + // keying a round should see the whole of it. + max-width: 1280px; + margin: 0 auto; + padding: 20px 16px; +} + +.dte-header { + display: flex; + align-items: center; + gap: 16px; + margin-bottom: 20px; + + &__title { + margin: 0; + font-size: 22px; + font-weight: 500; + } +} + +.dte-back { + color: $primary; +} + +.dte-card { + background: var(--md-sys-color-surface, #fff); + border-radius: 8px; + box-shadow: 0 2px 8px rgb(0 0 0 / 8%); + padding: 20px 24px 24px; +} + +.dte-info-panels { + display: grid; + grid-template-columns: 1fr 1fr; + gap: 16px; + margin-bottom: 20px; +} + +.dte-info-panel { + border: 1px solid rgb(0 0 0 / 10%); + border-radius: 8px; + padding: 12px 16px; + background: rgb(15 118 110 / 4%); + + &__title { + font-weight: 600; + font-size: 13px; + text-transform: uppercase; + letter-spacing: 0.04em; + color: $primary; + margin-bottom: 8px; + } + + &__hint { + margin: 0; + color: rgb(0 0 0 / 50%); + font-size: 13px; + } + + dl { + display: grid; + grid-template-columns: auto 1fr; + gap: 4px 12px; + margin: 0; + } + + dt { + font-weight: 500; + color: rgb(0 0 0 / 60%); + font-size: 13px; + } + + dd { + margin: 0; + font-size: 13px; + } +} + +.dte-controls { + display: flex; + align-items: flex-start; + gap: 12px; + flex-wrap: wrap; + margin-bottom: 12px; +} + +// A sensible floor for the fields laid out with flex in .dte-controls, where there is room to spare. +.dte-field { + min-width: 180px; + + &--wide { + grid-column: span 2; + } +} + +// Inside the collection grid the track decides the width, not the field. Left to its own 180px +// floor a field refuses to shrink into a narrower track and overruns the one beside it. +.dte-collection-row .dte-field { + width: 100%; + min-width: 0; +} + +.dte-section { + padding: 16px 0; + border-top: 1px solid rgb(0 0 0 / 8%); + // A very narrow window scrolls the grid sideways rather than the whole page. + overflow-x: auto; + + &__header { + display: flex; + align-items: center; + gap: 10px; + margin-bottom: 16px; + + i { + color: $primary; + } + + h3 { + margin: 0; + font-size: 16px; + font-weight: 600; + flex: 1; + } + } +} + +.dte-add-row { + color: $primary; +} + +.dte-empty { + color: rgb(0 0 0 / 50%); + font-size: 14px; + margin: 0; +} + +.dte-grid { + display: grid; + grid-template-columns: repeat(3, 1fr); + gap: 8px 16px; +} + +/* + * One collection per row. Fixed columns rather than an even split, so the row holds its shape as + * fields come and go - Cheque No and Token No only appear for a non-cash entry - and the party + * picker gets the width its "code · name · daily amount" needs. + */ +.dte-collection-row { + display: grid; + grid-template-columns: + minmax(0, 2.2fr) /* Party */ + minmax(0, 1fr) /* TRTP */ + minmax(0, 1fr) /* AC Type */ + minmax(0, 1.4fr) /* A/C No. */ + minmax(0, 1fr) /* Amount */ + minmax(0, 1.6fr) /* Description */ + auto; /* remove */ + + gap: 8px 12px; + align-items: start; + padding: 10px 0; + border-top: 1px dashed rgb(0 0 0 / 10%); + + &:first-of-type { + border-top: none; + } + + // Cheque No and Token No push Description and the remove button onto a second line, which is + // what a non-cash entry should look like rather than a row running off the screen. + &:has(.dte-field--instrument) { + grid-template-columns: + minmax(0, 2.2fr) minmax(0, 1fr) minmax(0, 1fr) minmax(0, 1.4fr) minmax(0, 1fr) + minmax(0, 1fr) minmax(0, 1fr) minmax(0, 1.6fr) auto; + } +} + +.dte-collection-row .dte-field--wide { + grid-column: span 2; +} + +@media (width <= 1200px) { + .dte-collection-row, + .dte-collection-row:has(.dte-field--instrument) { + grid-template-columns: repeat(3, minmax(0, 1fr)) auto; + } +} + +.dte-remove-row { + color: #b91c1c; + margin-top: 8px; +} + +.dte-action-bar { + display: flex; + justify-content: flex-end; + gap: 12px; + margin-top: 20px; + padding-top: 16px; + border-top: 1px solid rgb(0 0 0 / 8%); + flex-wrap: wrap; +} + +@media (width <= 1000px) { + .dte-info-panels { + grid-template-columns: 1fr; + } + + .dte-grid { + grid-template-columns: repeat(2, 1fr); + } + + .dte-collection-row { + grid-template-columns: repeat(2, 1fr); + } +} + +/* Resolved name under an Agent / Party Code field — the check that the right record was found. */ +.dte-hint--found { + color: #15803d; + font-weight: 500; +} + +.dte-hint--missing { + color: #b91c1c; + font-weight: 500; +} + +/* Marks an agent in the list who is no longer collecting - only offered for settlement. */ +.dte-agent-off { + color: #b45309; + font-size: 12px; + font-style: italic; + margin-left: 6px; +} + +/* The figure the clerk actually verifies against the passbook before saving. */ +.dte-after { + font-weight: 600; + color: #0f766e; +} + +/* What the agent should be handing over for this round. */ +.dte-total { + display: flex; + justify-content: flex-end; + align-items: baseline; + gap: 14px; + padding: 10px 4px 2px; + border-top: 1px solid rgb(0 0 0 / 10%); + font-size: 0.88rem; + color: rgb(0 0 0 / 60%); + + strong { + font-size: 1.1rem; + color: rgb(0 0 0 / 85%); + font-variant-numeric: tabular-nums; + } +} + +/* Marks a transaction type the system does not handle yet, so it reads as pending rather than broken. */ +.dte-soon { + font-size: 0.78rem; + font-style: italic; + color: rgb(0 0 0 / 45%); +} + +/* + * Marks a party this agent has already collected from today, on the row where it is picked. + * + * Amber, not red: a customer paying twice in a day is legitimate - arrears, or two days at once - + * so this is a second look, not an error. Blocking it would stop real work; saying nothing lets a + * whole round be credited twice. + */ +.dte-hint--again { + color: #b45309; + font-weight: 500; +} + +/* + * Where a party stands against its instalments. Three states rather than one colour, because they + * call for three different things from the clerk: ahead means take nothing, behind means take more + * than the daily amount, level means take the daily amount as usual. + * + * Behind is amber, not red - falling a few days behind is ordinary in daily collection and is the + * agent's job to recover, not an error. Red here would cry wolf on most rows. + */ +.dte-hint--ahead, +.dte-sched--ahead { + color: #15803d; + font-weight: 500; +} + +.dte-hint--behind, +.dte-sched--behind { + color: #b45309; + font-weight: 500; +} + +.dte-hint--level, +.dte-sched--level { + color: rgb(0 0 0 / 55%); +} + +/* In the party panel the schedule line carries more weight than the fields around it. */ +.dte-sched { + font-weight: 500; +} + +/* Says why a party in the list cannot be picked, beside the party rather than in a tooltip. */ +.dte-covered { + color: #15803d; + font-size: 0.8rem; +} + +/* + * An amount that looks like a slip. Red rather than the amber used for arrears: arrears are ordinary + * and the agent's job to recover, while this is a figure that probably was not handed over at all, + * and once saved it cannot be taken back. It still does not block - the clerk decides. + */ +.dte-hint--overpay { + color: #b91c1c; + font-weight: 500; +} + +/* + The agent's written-down round, as the counter reads it. + + Laid out as lines rather than as the input grid this replaced: there is nothing to fill in here, + only a list to check a bag of cash against, and controls would invite editing something the clerk + has no business editing. +*/ +.dte-pending-row { + display: flex; + align-items: baseline; + gap: 12px; + padding: 10px 2px; + border-bottom: 1px solid rgb(0 0 0 / 8%); + + &:last-of-type { + border-bottom: none; + } + + /* First, and in the quieter weight: the date is context for the line, not what is being read. */ + &__date { + min-width: 96px; + opacity: 0.7; + font-variant-numeric: tabular-nums; + } + + &__code { + font-weight: 600; + min-width: 84px; + } + + &__name { + flex: 1; + min-width: 0; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + opacity: 0.75; + } + + &__mode { + font-size: 12px; + text-transform: uppercase; + letter-spacing: 0.5px; + opacity: 0.6; + } + + /* Amounts are read down the column and compared, so the digits have to line up. */ + &__amount { + font-variant-numeric: tabular-nums; + font-weight: 600; + min-width: 96px; + text-align: right; + } +} + +/* The one figure the clerk acts on, said as a sentence so it is not read as another column total. */ +.dte-expected { + margin: 14px 2px 0; + font-size: 14px; + line-height: 1.5; + + strong { + font-variant-numeric: tabular-nums; + } +} + +/* Quieter than the figure above it: a note about how the screen behaves, not part of the count. */ +.dte-expected--note { + font-size: 13px; + opacity: 0.65; + margin-top: 4px; +} + +.dte-apply-receipt { + margin-left: 8px; +} + +/* Agent Tran: what the agent owes, what the cashier has taken in, and what is left. */ +.dte-money { + display: flex; + gap: 12px; + margin: 8px 0 12px; + flex-wrap: wrap; + + &__box { + flex: 1; + min-width: 160px; + padding: 10px 14px; + border: 1px solid rgb(0 0 0 / 10%); + border-radius: 8px; + display: flex; + flex-direction: column; + gap: 4px; + font-variant-numeric: tabular-nums; + + strong { + font-size: 18px; + } + + &--due { + border-color: rgb(198 40 40 / 40%); + background: rgb(198 40 40 / 4%); + } + } + + &__label { + font-size: 12px; + opacity: 0.7; + text-transform: uppercase; + letter-spacing: 0.04em; + } +} + +.dte-subhead { + margin: 16px 0 4px; + font-size: 14px; + font-weight: 600; +} + +.dte-pending-row--day { + cursor: pointer; +} + +.dte-day-lines { + margin-left: 24px; + max-height: 280px; + overflow-y: auto; + border-left: 3px solid rgb(25 118 210 / 30%); + padding-left: 10px; +} + +.dte-status { + font-size: 12px; + padding: 2px 8px; + border-radius: 10px; + background: rgb(237 108 2 / 12%); + color: #b45309; + + &--ok { + background: rgb(46 125 50 / 12%); + color: #2e7d32; + } +} + +.dte-list-arrow { + cursor: pointer; +} + +.dte-same-party { + color: #c62828; + font-weight: 600; +} diff --git a/src/app/deposits/daily-transaction-entry/daily-transaction-entry.component.ts b/src/app/deposits/daily-transaction-entry/daily-transaction-entry.component.ts new file mode 100644 index 000000000..dce4d07d8 --- /dev/null +++ b/src/app/deposits/daily-transaction-entry/daily-transaction-entry.component.ts @@ -0,0 +1,1100 @@ +/** + * Copyright since 2025 Mifos Initiative + * + * This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. + */ + +/** Angular Imports */ +import { Component, OnInit, inject } from '@angular/core'; +import { UntypedFormBuilder, UntypedFormGroup, UntypedFormArray, Validators } from '@angular/forms'; +import { Router } from '@angular/router'; +import { MatDialog } from '@angular/material/dialog'; +import { STANDALONE_SHARED_IMPORTS } from 'app/standalone-shared.module'; +import { MatAutocompleteModule } from '@angular/material/autocomplete'; +import { MatIconModule } from '@angular/material/icon'; +import { ConfirmationDialogComponent } from 'app/shared/confirmation-dialog/confirmation-dialog.component'; + +/** Custom Components */ +import { DayCollectionDialogComponent } from './day-collection-dialog/day-collection-dialog.component'; +import { NoticeDialogComponent } from './notice-dialog/notice-dialog.component'; +import { + ApplyDdsReceiptDialogComponent, + EntryDay, + groupByDay +} from './apply-dds-receipt-dialog/apply-dds-receipt-dialog.component'; + +/** Custom Services */ +import { OrganizationService } from 'app/organization/organization.service'; +import { AlertService } from 'app/core/alert/alert.service'; +import { SettingsService } from 'app/settings/settings.service'; +import { Dates } from 'app/core/utils/dates'; +import { announceHeldForApproval, heldForApproval } from 'app/deposits/dds-approvals/dds-approval-notice'; + +/** + * Daily Transaction Entry component. + * + * The screen a collection agent uses at the end of a day's round: pick a + * transaction type, then batch-enter that day's collections against + * assigned parties (Party Tran), settle collected cash to the branch + * (Agent Tran), or record a withdrawal/transfer between accounts (Withdraw + * Deposite). Modelled directly on the legacy Daily Transaction Detail + * screen this replaces. + * + * Backed by the DDS Party Transaction API (`/dds/partytransactions`) for + * Party Tran, Agent Tran and Withdraw Deposite - the three types this + * screen actually collects fields for; Party Payment / OD Entry / OD + * Payment remain placeholders until the backend distinguishes them too. + * "Retrieve" (a ready-made list of today's assigned parties) isn't wired - + * that needs a dedicated per-agent Party lookup this MVP doesn't have yet. + */ +@Component({ + selector: 'mifosx-daily-transaction-entry', + templateUrl: './daily-transaction-entry.component.html', + styleUrls: ['./daily-transaction-entry.component.scss'], + imports: [ + ...STANDALONE_SHARED_IMPORTS, + MatAutocompleteModule, + MatIconModule + ] +}) +export class DailyTransactionEntryComponent implements OnInit { + private formBuilder = inject(UntypedFormBuilder); + private router = inject(Router); + private organizationService = inject(OrganizationService); + private alertService = inject(AlertService); + private dialog = inject(MatDialog); + private settingsService = inject(SettingsService); + private dateUtils = inject(Dates); + + private static readonly TRANSACTION_TYPE_CODES: Record = { + 'Party Tran': 'PARTY_TRAN', + 'Agent Tran': 'AGENT_TRAN', + 'Withdraw Deposite': 'WITHDRAW_DEPOSITE' + }; + + /** + * The legacy screen's six types. Only the three the backend actually distinguishes can be picked - + * the rest are shown but not selectable, so the screen stays honest about what the system does + * today instead of accepting an entry it will reject on save. + */ + transactionTypes = [ + { label: 'Party Tran', supported: true }, + { label: 'Agent Tran', supported: true }, + { label: 'Withdraw Deposite', supported: true }, + { label: 'Party Payment', supported: false }, + { label: 'OD Entry', supported: false }, + { label: 'OD Payment', supported: false } + ]; + + transactionModes = [ + 'Cash', + 'Transfer', + 'Clearing' + ]; + + entryForm: UntypedFormGroup = this.formBuilder.group({ + agentId: [ + '', + Validators.required + ], + transactionType: [ + 'Party Tran', + Validators.required + ], + transactionDate: [ + new Date(), + Validators.required + ], + // Withdraw Deposite fields + creditorPartyCode: [''], + creditorPartyName: [ + { + value: '', + disabled: true + } + ], + creditorPartyBalance: [ + { + value: 0, + disabled: true + } + ], + debitPartyCode: [''], + debitPartyName: [ + { + value: '', + disabled: true + } + ], + debitPartyBalance: [ + { + value: 0, + disabled: true + } + ], + withdrawAmount: [0], + withdrawDescription: [''], + // Agent Tran fields + settlementAmount: [0], + settlementMode: ['Cash'], + settlementAccountType: [''], + settlementAccountNo: [''], + settlementDescription: [''], + collections: this.formBuilder.array([]) + }); + + agentInfo = { + name: '', + address: '', + city: '', + accountNo: '', + guarantor1: '', + guarantor2: '', + currentBalance: 0 + }; + + agentLookupFailed = false; + + /** Every agent still on the books - picked from a list rather than keyed by id. */ + agents: any[] = []; + + /** + * The picked agent's parties, fetched once when the agent is chosen. Every party code typed into + * the grid is resolved from here, so a clerk keying a round of thirty collections waits on the + * network once rather than thirty times - and a code belonging to another agent's party simply + * will not resolve, which is the check that matters. + */ + agentParties: any[] = []; + + /** + * The party behind the row being worked on, shown in the panel beside the agent's - who this + * collection is for, what they owe daily, and what their balance becomes once it is saved. + */ + partyInfo: any = null; + + /** Which grid row the party panel is showing. */ + activeRow = -1; + + /** + * What has already been keyed for this agent on the chosen date. + * + * The real risk on this screen is not a wrong figure, it is the same collection book being keyed + * twice - an interruption mid-round, and every customer in it gets credited again while the agent + * appears to be holding cash he already handed over. Showing the round back removes the guesswork. + */ + todaysEntries: any[] = []; + + get todaysTotal(): number { + return this.todaysEntries.reduce((sum, entry) => sum + Number(entry.amount || 0), 0); + } + + /** + * What this party has already been collected for on this date, if anything. + * + * Shown on the row itself rather than in a summary above it. A customer paying twice in a day is + * legitimate - arrears, or two days at once - so this never blocks the entry. It only has to be + * where the clerk is already looking at the moment they pick the party, which a panel at the top + * of the screen is not. + */ + alreadyCollected(index: number): number { + const partyCode = String(this.collections.at(index)?.get('partyCode')?.value || ''); + if (!partyCode) { + return 0; + } + return this.todaysEntries + .filter((entry) => String(entry.partyCode) === partyCode) + .reduce((sum, entry) => sum + Number(entry.amount || 0), 0); + } + + /** + * Where a party stands against the instalments it owes, in the words the branch uses. + * + * The running balance says how much has been paid, never whether it was paid on time - a customer + * four days ahead and one eleven days behind can hold the same figure. This is what the clerk needs + * at the moment they pick the party, so it is worked out per row rather than shown in a summary. + * + * A shortfall is split in two because the bank treats the halves differently: what is still inside + * the catch-up window can be collected today, and what has fallen outside it is gone. Putting them + * together would have the clerk asking for money the bank has already written off. + */ + schedule(party: any): { state: 'ahead' | 'behind' | 'level'; text: string } | null { + const daily = Number(party?.dailyDepositAmount || 0); + if (!party || daily <= 0) { + return null; + } + + const advance = Number(party.advanceAmount || 0); + if (advance > 0) { + return { state: 'ahead', text: `${this.money(advance)} already paid ahead` }; + } + + // What to ask for today is the recoverable shortfall exactly as it stands - today's own + // instalment is already inside it, because the schedule counts the first instalment on the + // opening day itself. Adding the daily on top would charge today twice, which on an account + // opened this morning reads as two days owed on its first day. + // + // Instalments past the catch-up window are deliberately left out of both the figure and the + // words - they cannot be collected, so naming them would only have the clerk asking for money + // the bank has already written off, and reading a shortfall the amount does not cover. + const recoverable = Number(party.recoverableAmount || 0); + if (recoverable > 0) { + return { state: 'behind', text: `Take ${this.money(recoverable)} to complete today` }; + } + + return { state: 'level', text: 'Up to date' }; + } + + private money(value: number): string { + return value.toLocaleString('en-IN', { minimumFractionDigits: 2, maximumFractionDigits: 2 }); + } + + /** The party on a given collection row, so the row can show where that party stands. */ + partyOfRow(index: number): any { + const partyCode = String(this.collections.at(index)?.get('partyCode')?.value || ''); + return partyCode ? this.agentParties.find((p) => String(p.partyCode) === partyCode) : null; + } + + /** + * Says so plainly when the agent just picked has nobody to collect from. + * + * An empty party list looks identical to a list that has not loaded, and a clerk who reads it as + * the second will sit waiting for names that are never coming. Raised as a dialog rather than a + * line under the field because it means this agent cannot be used for a round at all - there is + * nothing to key, and the clerk should go back and pick another. + */ + private warnIfAgentHasNoParties(): void { + if (this.agentParties.length > 0) { + return; + } + const agentId = this.entryForm.get('agentId')?.value; + const agent = this.agents.find((a: any) => String(a.id) === String(agentId)); + this.dialog.open(NoticeDialogComponent, { + data: { + heading: 'No customers for this agent', + // Named for what is actually missing. The agent's own settlement account is a different + // thing and is usually there - saying "no accounts" sends whoever reads it to check the + // wrong record. + message: + `No customer accounts are assigned to ${agent?.agentCode || 'this agent'}, so there is nothing ` + + 'to collect. Assign customers to this agent first, or choose a different agent.' + }, + width: '420px', + autoFocus: false + }); + } + + /** + * Adds a row for days other than today - paid ahead, or owed from days gone by. + * + * Kept off the ordinary row and put behind its own button, because most collections are simply + * today's instalment: a box asking about days would sit empty on nearly every row, and a stray + * value left in one would charge a customer for days they did not pay for. + * + * The row it adds is filled in and left in the list rather than posted, so it is saved with the + * rest of the round and the clerk sees it beside the others before committing. + */ + addDayBasedRow(mode: 'advance' | 'previous'): void { + const dialogRef = this.dialog.open(DayCollectionDialogComponent, { + data: { mode, parties: this.agentParties }, + width: '460px', + autoFocus: false + }); + + dialogRef.afterClosed().subscribe((picked: any) => { + if (!picked) { + return; + } + this.addCollectionRow(); + const row = this.collections.at(this.collections.length - 1); + row.get('partyCode')?.setValue(picked.partyCode); + this.loadPartyForRow(this.collections.length - 1); + row.get('amount')?.setValue(picked.amount); + row.get('advanceDays')?.setValue(picked.advanceDays ?? ''); + row.get('arrearsDays')?.setValue(picked.arrearsDays ?? ''); + row + .get('description') + ?.setValue( + `${picked.days} ${picked.days === 1 ? 'day' : 'days'} ${mode === 'advance' ? 'in advance' : 'arrears'}` + ); + this.recalculateRowBalance(this.collections.length - 1); + }); + } + + /** + * Questions an amount on an ordinary row that is not the day's instalment. + * + * Paying for other days now has its own buttons, and each works out the amount from the scheme + * itself. What is left in the plain Amount box is one thing: today. Anything else is either a + * mistyped figure - a zero too many turns a hundred rupees into a thousand - or days being keyed by + * hand, which is the very thing the buttons exist to stop. + * + * It warns and does not block. A customer can hand over an odd amount, and the clerk is the one who + * saw them do it; what they need is to be told before saving, because a collection cannot be taken + * back afterwards - it can only be carried into the days that follow. + */ + overpayWarning(index: number): string | null { + const party = this.partyOfRow(index); + const daily = Number(party?.dailyDepositAmount || 0); + const amount = Number(this.collections.at(index)?.get('amount')?.value || 0); + if (!party || daily <= 0 || amount <= 0) { + return null; + } + + // Never raised on a row the Advance or Previous Days button filled in: those figures are the + // scheme's own and the server checks them, so they cannot be the slip this is looking for. + const row = this.collections.at(index); + if (row?.get('advanceDays')?.value || row?.get('arrearsDays')?.value) { + return null; + } + + if (amount === daily) { + return null; + } + if (amount > daily) { + return ( + `${this.money(amount)} is more than the daily ${this.money(daily)}. ` + + 'Use Advance to pay ahead, or Previous Days to clear arrears.' + ); + } + return `${this.money(amount)} is less than the daily ${this.money(daily)}.`; + } + + /** + * Whether this party is already paid ahead far enough that today is covered. + * + * The agent still calls - the round does not skip a door - but there is nothing to collect, so the + * row is closed to entry rather than hidden. A party that vanished from the list would have the + * clerk wondering what had happened to it. + */ + isCoveredByAdvance(party: any): boolean { + return Number(party?.advanceDays || 0) > 0; + } + + /** + * The agent's own written-down collections for the chosen date, and whether they are being read. + * + * Held rather than typed. A Party Tran is no longer something the clerk composes - the agent + * composed it on their round, and this screen shows it back so the cash can be counted against it. + */ + pendingEntries: any[] = []; + pendingLoading = false; + + /** What the agent should be handing over: their own entries, added up. */ + get pendingTotal(): number { + return this.pendingEntries.reduce((sum: number, entry: any) => sum + Number(entry.amount || 0), 0); + } + + /** + * Reads everything this agent has outstanding up to the date on the form, customer by customer. + * + * Not only that day's: what was written on earlier days and not yet handed in is still owed, and a + * clerk checking the round should see it in one list rather than open every date in turn. Each line + * carries its own date. Asked again whenever the agent or the date changes. + */ + loadPendingEntries(): void { + const agentId = this.entryForm.get('agentId')?.value; + const date = this.entryForm.get('transactionDate')?.value; + if (!agentId || !date) { + this.pendingEntries = []; + return; + } + + this.pendingLoading = true; + const upTo = this.dateUtils.formatDate(date, 'yyyy-MM-dd'); + this.organizationService.getAgentPendingCollections(agentId).subscribe({ + next: (summary: any) => { + this.pendingEntries = (summary?.entries || []).filter( + (entry: any) => entry.status === 'PENDING' && String(entry.entryDate) <= upTo + ); + this.pendingLoading = false; + }, + error: () => { + this.pendingEntries = []; + this.pendingLoading = false; + } + }); + } + + /** The picked agent's own GL account code - where the cashier receipts their cash. */ + agentGlCode: string | null = null; + agentGlName: string | null = null; + + // ── Agent Tran: the agent's money, owed and received ────────────────────────────────────── + // + // The agent's cash reaches the bank at the cashier's counter, never on this screen. What Agent Tran + // shows is where that stands: what the agent owes on everything they wrote down, what the cashier has + // taken in against it, and what is left - and from here the receipts are applied to the customers. + + /** What the agent still has outstanding up to the date on the form, a day at a time. */ + agentDays: EntryDay[] = []; + + /** DDS receipts on the agent's GL account not yet applied, including those still with the manager. */ + agentReceipts: any[] = []; + + agentMoneyLoading = false; + + /** The day whose lines are open in the Agent Tran list. */ + agentOpenDay: string | null = null; + + get agentOwed(): number { + return this.agentDays.reduce((sum, day) => sum + day.total, 0); + } + + get agentReceived(): number { + return this.agentReceipts.filter((r) => r.status === 'APPROVED').reduce((sum, r) => sum + Number(r.amount || 0), 0); + } + + get agentWaiting(): number { + return this.agentReceipts.filter((r) => r.status !== 'APPROVED').reduce((sum, r) => sum + Number(r.amount || 0), 0); + } + + /** + * What is still to be taken from the agent for this date - never below nothing. The receipts are + * not tied to a date (cash often reaches the counter the day after the round), so more received than + * this date's entries is not a debt the other way; it is cash for another day, said so separately. + */ + get agentBalance(): number { + return Math.max(0, Math.round((this.agentOwed - this.agentReceived) * 100) / 100); + } + + /** Received cash beyond this date's entries - most likely for another day's. */ + get agentReceivedBeyondDay(): number { + return Math.max(0, Math.round((this.agentReceived - this.agentOwed) * 100) / 100); + } + + loadAgentMoney(): void { + const agentId = this.entryForm.get('agentId')?.value; + const date = this.entryForm.get('transactionDate')?.value; + this.agentOpenDay = null; + if (!agentId || !date) { + this.agentDays = []; + this.agentReceipts = []; + return; + } + this.agentMoneyLoading = true; + // Everything still outstanding up to the date on the screen, whatever day it was written: an agent + // who comes in after three days brings three days' cash, and the counter has to see all of it to + // know what to take. + const upTo = this.dateUtils.formatDate(date, 'yyyy-MM-dd'); + this.organizationService.getAgentPendingCollections(agentId).subscribe({ + next: (summary: any) => { + this.agentDays = groupByDay( + (summary?.entries || []).filter((entry: any) => entry.status === 'PENDING' && String(entry.entryDate) <= upTo) + ); + this.agentMoneyLoading = false; + }, + error: () => { + this.agentDays = []; + this.agentMoneyLoading = false; + } + }); + this.organizationService.getAgentCashReceipts(agentId, undefined, true).subscribe({ + next: (rows: any[]) => (this.agentReceipts = rows || []), + error: () => (this.agentReceipts = []) + }); + } + + toggleAgentDay(date: string): void { + this.agentOpenDay = this.agentOpenDay === date ? null : date; + } + + /** + * Opens Apply DDS Receipt: the cashier's DDS receipt on this agent's GL account, applied to what the + * agent still has outstanding. Its own screen, like Apply Additional Shares, so the list above stays + * what it is - what to take from the agent - and the applying is done in one deliberate place. + */ + openApplyReceipt(): void { + const agentId = this.entryForm.get('agentId')?.value; + if (!agentId) { + return; + } + const picked = this.agents.find((agent: any) => agent.id === agentId); + this.dialog + .open(ApplyDdsReceiptDialogComponent, { + width: '780px', + maxWidth: '96vw', + panelClass: 'apply-collection-dialog-panel', + data: { + agentId, + agentCode: picked?.agentCode || '', + agentName: this.agentInfo.name || picked?.agentNameId || '', + glCode: this.agentGlCode, + // The day chosen on the screen: its entries are what the receipts settle. + entryDate: this.dateUtils.formatDate(this.entryForm.get('transactionDate')?.value, 'yyyy-MM-dd') + } + }) + .afterClosed() + .subscribe((result: any) => { + if (!result) { + return; + } + const money = Number(result.amount || 0).toLocaleString('en-IN', { minimumFractionDigits: 2 }); + const vouchers: string[] = result.vouchers || []; + if (heldForApproval(result.response)) { + announceHeldForApproval(result.response, `Applying ${money}`, this.organizationService, this.alertService); + this.loadPendingEntries(); + return; + } + this.alertService.alert({ + type: 'Success', + message: + `${vouchers.length === 1 ? 'Voucher' : 'Vouchers'} ${vouchers.join(', ')} applied - ` + + `${result.entries} ${result.entries === 1 ? 'entry' : 'entries'}, ${money}` + }); + this.loadPendingEntries(); + this.loadTodaysEntries(); + this.loadAgentMoney(); + }); + } + + get isPartyTran(): boolean { + return this.entryForm.get('transactionType').value === 'Party Tran'; + } + + get isWithdrawDeposite(): boolean { + return this.entryForm.get('transactionType').value === 'Withdraw Deposite'; + } + + get isAgentTran(): boolean { + return this.entryForm.get('transactionType').value === 'Agent Tran'; + } + + get collections(): UntypedFormArray { + return this.entryForm.get('collections') as UntypedFormArray; + } + + private buildCollectionRow(): UntypedFormGroup { + const row = this.formBuilder.group({ + partyCode: [ + '', + Validators.required + ], + transactionMode: [ + 'Cash', + Validators.required + ], + accountType: [''], + accountNo: [ + '', + Validators.required + ], + amount: [ + 0, + Validators.required + ], + /* + * Days taken in advance, when the customer is paying ahead rather than for today. Empty on an + * ordinary row. The amount follows from it - the scheme's daily instalment times the days - so + * the clerk picks days, not rupees, and cannot quote a figure the scheme does not charge. + */ + advanceDays: [''], + /* Days of arrears being cleared. Same idea as advanceDays, the other way round. */ + arrearsDays: [''], + chequeNo: [''], + tokenNo: [''], + description: [''], + partyName: [ + { + value: '', + disabled: true + } + ], + balanceAfterTransaction: [ + { + value: 0, + disabled: true + } + ] + }); + + // Cash has no instrument behind it, so switching back to cash drops anything typed against a + // cheque or clearing entry rather than saving a stale number with it. + row.get('transactionMode')?.valueChanges.subscribe((mode) => { + if (mode === 'Cash') { + row.get('chequeNo')?.setValue('', { emitEvent: false }); + row.get('tokenNo')?.setValue('', { emitEvent: false }); + } + }); + + return row; + } + + addCollectionRow(): void { + this.collections.push(this.buildCollectionRow()); + } + + removeCollectionRow(index: number): void { + this.collections.removeAt(index); + } + + ngOnInit(): void { + // Closed agents are off the books entirely; everyone else is kept, and which of them can be + // picked depends on the transaction type - see selectableAgents. + this.organizationService.getAgents().subscribe({ + next: (agents: any[]) => (this.agents = (agents || []).filter((agent: any) => !agent.closeDate)), + error: () => (this.agents = []) + }); + + // Withdraw Deposite: the open accounts either side can be picked from, and a code typed or picked + // fills in whose account it is and what it holds, so the clerk checks the transfer against both. + this.organizationService.getParties().subscribe({ + next: (parties: any) => { + const all = Array.isArray(parties) ? parties : parties?.pageItems || []; + this.openParties = all.filter((party: any) => party.status === 'ACTIVE'); + this.fillTransferParty('creditor'); + this.fillTransferParty('debit'); + }, + error: () => (this.openParties = []) + }); + this.entryForm.get('creditorPartyCode')?.valueChanges.subscribe(() => this.fillTransferParty('creditor')); + this.entryForm.get('debitPartyCode')?.valueChanges.subscribe(() => this.fillTransferParty('debit')); + } + + /** Open DDS accounts, for the two sides of a Withdraw Deposite. */ + openParties: any[] = []; + + /** + * The open accounts either side can be picked from. The server moves money only between two + * accounts of the agent on the entry, so once an agent is chosen only that agent's accounts are offered. + */ + private get transferableParties(): any[] { + const agentId = Number(this.entryForm.get('agentId')?.value) || null; + return agentId ? this.openParties.filter((party: any) => Number(party.agentId) === agentId) : this.openParties; + } + + /** The open accounts matching what has been typed on one side - by code, name or account number, any case. */ + transferPartyOptions(side: 'creditor' | 'debit'): any[] { + const typed = String(this.entryForm.get(side + 'PartyCode')?.value || '') + .trim() + .toLowerCase(); + const other = String(this.entryForm.get(this.otherSide(side) + 'PartyCode')?.value || '') + .trim() + .toUpperCase(); + // Once an account is picked the whole list comes back, so another can be chosen without clearing the box. + const picked = this.transferableParties.some((party: any) => String(party.partyCode).toLowerCase() === typed); + return this.transferableParties + .filter((party: any) => String(party.partyCode).toUpperCase() !== other) + .filter( + (party: any) => + !typed || + picked || + String(party.partyCode).toLowerCase().includes(typed) || + String(party.partyName || '') + .toLowerCase() + .includes(typed) || + String(party.accountNo || '') + .toLowerCase() + .includes(typed) + ) + .slice(0, 50); + } + + /** True when the same account has been put on both sides. */ + get transferToItself(): boolean { + const creditor = String(this.entryForm.get('creditorPartyCode')?.value || '') + .trim() + .toUpperCase(); + const debit = String(this.entryForm.get('debitPartyCode')?.value || '') + .trim() + .toUpperCase(); + return this.isWithdrawDeposite && !!creditor && creditor === debit; + } + + /** + * When the box is left with a name or part of one typed in and only one account matches it, that + * account is taken - so typing "amit" and moving on picks Amit's account without opening the list. + */ + settleTransferParty(side: 'creditor' | 'debit'): void { + const control = this.entryForm.get(side + 'PartyCode'); + const typed = String(control?.value || '').trim(); + if (!typed || this.partyByCode(typed)) { + return; + } + const matches = this.transferPartyOptions(side); + if (matches.length === 1) { + control?.setValue(matches[0].partyCode); + } + } + + private otherSide(side: 'creditor' | 'debit'): 'creditor' | 'debit' { + return side === 'creditor' ? 'debit' : 'creditor'; + } + + private partyByCode(code: string): any { + const wanted = String(code || '') + .trim() + .toUpperCase(); + return wanted ? this.openParties.find((each: any) => String(each.partyCode).toUpperCase() === wanted) : undefined; + } + + /** + * Fills a side's name and balance from the code on it, and clears them when it matches no account. + * The code is matched in any case and put back as the account has it, so "pt0002" becomes PT0002. + */ + private fillTransferParty(side: 'creditor' | 'debit'): void { + const control = this.entryForm.get(side + 'PartyCode'); + const party = this.partyByCode(control?.value); + if (party && control?.value !== party.partyCode) { + control?.setValue(party.partyCode, { emitEvent: false }); + } + this.entryForm.get(side + 'PartyName')?.setValue(party ? party.partyName : '', { emitEvent: false }); + this.entryForm + .get(side + 'PartyBalance') + ?.setValue(party ? Number(party.currentBalance || 0) : 0, { emitEvent: false }); + } + + /** + * The agents that can be picked for the transaction type currently chosen. + * + * A deactivated agent no longer collects, so they are not offered for a collection or a transfer. + * Settlement is the exception: an agent switched off mid-round is still holding the cash they + * already took, and that cash has to be able to reach a branch. Leaving them out of the settlement + * list would strand it with no way to hand it in. + */ + get selectableAgents(): any[] { + if (this.isAgentTran) { + return this.agents; + } + return this.agents.filter((agent: any) => agent.active); + } + + /** Flags a picked agent that is no longer collecting, so the entry is not made by mistake. */ + get pickedAgentIsInactive(): boolean { + const agentId = this.entryForm.get('agentId')?.value; + if (!agentId) { + return false; + } + const picked = this.agents.find((agent: any) => agent.id === agentId); + return !!picked && !picked.active; + } + + /** Whether this row needs a cheque or token number - a cash collection has neither. */ + needsInstrument(index: number): boolean { + return this.collections.at(index)?.get('transactionMode')?.value !== 'Cash'; + } + + loadAgent(): void { + const agentId = this.entryForm.get('agentId')?.value; + this.agentLookupFailed = false; + this.partyInfo = null; + this.activeRow = -1; + if (!agentId) { + this.agentParties = []; + this.pendingEntries = []; + this.agentGlCode = null; + this.agentDays = []; + this.agentReceipts = []; + return; + } + + this.organizationService.getAgent(agentId).subscribe({ + next: (agent: any) => { + const guarantors = agent.guarantors || []; + this.agentInfo = { + name: agent.agentNameId || '', + address: agent.agentAddress1 || '', + city: agent.agentCity || '', + accountNo: agent.agentSavingAccountNo || '', + guarantor1: guarantors[0]?.guarantorName || '', + guarantor2: guarantors[1]?.guarantorName || '', + currentBalance: agent.agentCurrentBalance || 0 + }; + this.agentGlCode = agent.glAccountCode || null; + this.agentGlName = agent.glAccountName || null; + }, + error: () => { + this.agentLookupFailed = true; + this.agentInfo = { + name: '', + address: '', + city: '', + accountNo: '', + guarantor1: '', + guarantor2: '', + currentBalance: 0 + }; + } + }); + + this.organizationService.getPartiesOfAgent(agentId).subscribe({ + next: (parties: any[]) => { + this.agentParties = parties || []; + this.warnIfAgentHasNoParties(); + }, + error: () => (this.agentParties = []) + }); + + this.loadTodaysEntries(); + this.loadPendingEntries(); + this.loadAgentMoney(); + } + + /** This agent's collections already recorded for the date on the screen. */ + loadTodaysEntries(): void { + const agentId = this.entryForm.get('agentId')?.value; + if (!agentId) { + this.todaysEntries = []; + return; + } + const onDate = this.dateUtils.formatDate(this.entryForm.get('transactionDate')?.value, 'yyyy-MM-dd'); + + this.organizationService.getPartyTransactionsOfAgent(agentId).subscribe({ + next: (transactions: any[]) => { + this.todaysEntries = (transactions || []).filter( + (transaction: any) => + transaction.transactionType === 'PARTY_TRAN' && + this.dateUtils.formatDate(transaction.transactionDate, 'yyyy-MM-dd') === onDate + ); + }, + error: () => (this.todaysEntries = []) + }); + } + + /** + * Resolves the party code typed into a row. A code this agent does not collect for is left + * unresolved rather than fetched anyway - an agent posting against someone else's party is the + * mistake this screen most needs to catch. + */ + loadPartyForRow(index: number): void { + const row = this.collections.at(index); + if (!row) { + return; + } + const partyCode = String(row.get('partyCode')?.value || '').trim(); + this.activeRow = index; + + const party = this.agentParties.find((candidate) => String(candidate.partyCode) === partyCode); + this.partyInfo = party || null; + + row.get('partyName')?.setValue(party?.partyName || '', { emitEvent: false }); + // The account credited is the party's own linked savings account, never keyed by hand. + row.get('accountNo')?.setValue(party?.creditToAccount || party?.partyCode || '', { emitEvent: false }); + row.get('accountType')?.setValue(party?.creditToAccountType || 'Savings', { emitEvent: false }); + + this.recalculateRowBalance(index); + } + + /** The party's balance once this row is saved - what the legacy screen called Bal. After This Tr. */ + recalculateRowBalance(index: number): void { + const row = this.collections.at(index); + if (!row) { + return; + } + const party = this.agentParties.find( + (candidate) => String(candidate.partyCode) === String(row.get('partyCode')?.value || '').trim() + ); + const current = Number(party?.currentBalance || 0); + const amount = Number(row.get('amount')?.value || 0); + row.get('balanceAfterTransaction')?.setValue(current + amount, { emitEvent: false }); + } + + /** The balance the party panel shows - live against whatever the active row currently holds. */ + get partyBalanceAfter(): number { + if (!this.partyInfo || this.activeRow < 0) { + return 0; + } + const row = this.collections.at(this.activeRow); + return Number(this.partyInfo.currentBalance || 0) + Number(row?.get('amount')?.value || 0); + } + + /** Total of the round being keyed in - what the agent should be handing over. */ + get collectionTotal(): number { + return this.collections.controls.reduce((sum, row) => sum + Number(row.get('amount')?.value || 0), 0); + } + + /** Re-reads what has already been keyed for this agent on the chosen date. */ + retrieve(): void { + this.loadTodaysEntries(); + } + + viewLedger(): void { + // No ledger view exists yet for this agent. + } + + signature(): void { + // No signature capture/verification flow exists yet. + } + + cancel(): void { + this.entryForm.reset({ + transactionType: 'Party Tran', + transactionDate: new Date() + }); + this.collections.clear(); + } + + exit(): void { + this.router.navigate(['/accounts/dds']); + } + + /** + * Save, asking first when what is about to happen is a settlement. + * + * A settlement credits every customer on the list and records the cash as the agent's, in one + * act that cannot be taken back - so the screen says the figure out loud and waits. The question + * is the one the clerk is actually answering at the counter: is this much really in front of me. + * + * The double-entry guard this used to carry has moved to where the entries are now made. Two + * lines for one account on one day are refused on the agent's own screen, and refused again by + * the server when the day is settled; by the time a clerk sees the list there is nothing left to + * warn about. + */ + save(): void { + // A Party Tran cannot be saved from here at all. The screen shows the agent's own entries so + // the cash can be counted against them; taking that cash in is done where the branch takes cash + // in, and two ways to credit the same customers is one way too many. The button is not rendered + // for this type either - this is the same rule said again where it cannot be bypassed. + // Agent Tran is not saved from here either any more. The agent's cash is taken in at the cashier's + // counter, and a second way to take the same cash in is one way too many; Agent Tran shows where it + // stands and applies the cashier's receipts. + if (this.isPartyTran || this.isAgentTran) { + return; + } + + this.postTransaction(); + } + + private postTransaction(): void { + const raw = this.entryForm.getRawValue(); + + // A Party Tran is no longer a transaction this screen composes. The agent wrote the round down + // on their own; what happens here is that it is settled, all of it at once, through a call of + // its own. Everything else on this screen still posts a transaction the way it always did. + if (this.isPartyTran) { + this.settleCollections(); + return; + } + + const dateFormat = this.settingsService.dateFormat; + const locale = this.settingsService.language.code; + const transactionType = DailyTransactionEntryComponent.TRANSACTION_TYPE_CODES[raw.transactionType]; + + const payload: any = { + agentId: Number(raw.agentId), + transactionType, + transactionDate: this.dateUtils.formatDate(raw.transactionDate, dateFormat), + dateFormat, + locale + }; + + if (this.isWithdrawDeposite) { + payload.creditorPartyCode = raw.creditorPartyCode; + payload.debitPartyCode = raw.debitPartyCode; + payload.withdrawAmount = raw.withdrawAmount; + payload.withdrawDescription = raw.withdrawDescription || undefined; + } else if (this.isAgentTran) { + payload.amount = raw.settlementAmount; + payload.transactionMode = raw.settlementMode; + payload.accountType = raw.settlementAccountType || undefined; + payload.accountNo = raw.settlementAccountNo || undefined; + payload.description = raw.settlementDescription || undefined; + } + + // What was just posted, captured before the form is cleared, so the confirmation can name it. + const posted = this.postedSummary(raw); + + this.organizationService.createPartyTransaction(payload).subscribe((response: any) => { + // On a money screen a silent save is worse than no save: with nothing to confirm it, a clerk + // who looks away reasonably assumes it did not go through and posts the whole round again. + // + // With maker-checker on, the request comes back with a command id and nothing done: it is + // waiting for a checker, and saying it was posted would be telling the clerk the money moved. + const awaitingApproval = !!response?.commandId && response?.resourceId == null; + this.alertService.alert({ + type: 'Success', + message: awaitingApproval ? `Sent for approval: ${posted}. Nothing moves until a checker approves it.` : posted + }); + + this.collections.clear(); + this.partyInfo = null; + this.activeRow = -1; + // The round just saved becomes part of what the screen shows back. + this.loadTodaysEntries(); + this.entryForm.patchValue({ + creditorPartyCode: '', + debitPartyCode: '', + withdrawAmount: 0, + withdrawDescription: '', + settlementAmount: 0, + settlementAccountType: '', + settlementAccountNo: '', + settlementDescription: '' + }); + this.loadAgent(); + }); + } + + /** + * Hands the agent's day over: every entry they wrote down for this date becomes a real credit. + * + * No amount is sent and none is asked for on screen. What settles is what the entries say - a + * figure typed at the counter would be a second opinion about a total the entries already give, + * and the two could differ. The clerk's part is to count the cash against that total and agree. + * + * All or none. A part-settled round would leave the branch holding cash against a list that no + * longer matched it, which is a worse position than simply running it again. + */ + private settleCollections(): void { + const raw = this.entryForm.getRawValue(); + if (this.pendingEntries.length === 0) { + return; + } + + const count = this.pendingEntries.length; + const total = this.pendingTotal; + const agent = this.agentInfo.name || 'agent'; + + this.organizationService + .settleAgentCollections({ + agentId: Number(raw.agentId), + entryDate: this.dateUtils.formatDate(raw.transactionDate, 'yyyy-MM-dd'), + dateFormat: 'yyyy-MM-dd', + locale: this.settingsService.language.code + }) + .subscribe((response: any) => { + const money = Number(total).toLocaleString('en-IN', { minimumFractionDigits: 2 }); + if (heldForApproval(response)) { + announceHeldForApproval( + response, + `${count} ${count === 1 ? 'collection' : 'collections'} for ${agent} (${money})`, + this.organizationService, + this.alertService + ); + this.loadPendingEntries(); + return; + } + this.alertService.alert({ + type: 'Success', + message: count + (count === 1 ? ' collection' : ' collections') + ' posted for ' + agent + ' - ' + money + }); + + // The list empties because those entries are settled now, and the agent's cash goes up by + // what was just taken - both are read again rather than worked out on screen. + this.loadPendingEntries(); + this.loadTodaysEntries(); + this.loadAgent(); + }); + } + + /** + * Says what was actually posted, in the terms the clerk was working in - a count and a total for a + * round of collections, the amount and who it came from for anything else. "Saved successfully" + * would confirm that something happened without confirming that the right thing happened. + */ + private postedSummary(raw: any): string { + const money = (value: any) => Number(value || 0).toLocaleString('en-IN', { minimumFractionDigits: 2 }); + const agent = this.agentInfo.name || 'agent'; + + if (this.isPartyTran) { + const rows = raw.collections || []; + const total = rows.reduce((sum: number, row: any) => sum + Number(row.amount || 0), 0); + return `${rows.length} ${rows.length === 1 ? 'collection' : 'collections'} posted for ${agent} — ${money(total)}`; + } + if (this.isAgentTran) { + return `${agent} settled ${money(raw.settlementAmount)} to the branch`; + } + return `${money(raw.withdrawAmount)} transferred from ${raw.debitPartyCode} to ${raw.creditorPartyCode}`; + } +} diff --git a/src/app/deposits/daily-transaction-entry/day-collection-dialog/day-collection-dialog.component.html b/src/app/deposits/daily-transaction-entry/day-collection-dialog/day-collection-dialog.component.html new file mode 100644 index 000000000..2d2306918 --- /dev/null +++ b/src/app/deposits/daily-transaction-entry/day-collection-dialog/day-collection-dialog.component.html @@ -0,0 +1,56 @@ + + +

{{ title }}

+ + + + Party + + @for (p of eligibleParties; track p.id) { + + {{ p.partyCode }} · {{ p.partyName }} · Daily {{ p.dailyDepositAmount | number: '1.0-0' }} + + } + + @if (eligibleParties.length === 0) { + + No party has recoverable days + } + + + + Days + + @for (d of dayOptions; track d) { + {{ d }} {{ d === 1 ? 'day' : 'days' }} + } + + @if (isAdvance) { + Up to 5 days + } @else if (partyCode) { + Only days still within the catch-up window + } + + + + @if (amount > 0) { +
+ {{ amount | number: '1.2-2' }} + {{ days }} × {{ party?.dailyDepositAmount | number: '1.2-2' }} daily +
+ } +
+ + + + + diff --git a/src/app/deposits/daily-transaction-entry/day-collection-dialog/day-collection-dialog.component.scss b/src/app/deposits/daily-transaction-entry/day-collection-dialog/day-collection-dialog.component.scss new file mode 100644 index 000000000..3f31a5163 --- /dev/null +++ b/src/app/deposits/daily-transaction-entry/day-collection-dialog/day-collection-dialog.component.scss @@ -0,0 +1,42 @@ +/** + * Copyright since 2025 Mifos Initiative + * + * This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. + */ + +.dcd-field { + width: 100%; +} + +.dcd-none { + color: #b45309; +} + +/* + * The figure the clerk quotes at the counter. Larger than the fields above it because it is the one + * thing the customer is told, and it is shown with its basis so the clerk can say where it came from + * rather than just naming a number. + */ +.dcd-amount { + display: flex; + align-items: baseline; + gap: 10px; + margin-top: 6px; + padding: 12px 14px; + border-radius: 6px; + background: rgb(15 118 110 / 8%); + + &__figure { + font-size: 1.3rem; + font-weight: 600; + color: #0f766e; + font-variant-numeric: tabular-nums; + } + + &__basis { + font-size: 0.8rem; + color: rgb(0 0 0 / 60%); + } +} diff --git a/src/app/deposits/daily-transaction-entry/day-collection-dialog/day-collection-dialog.component.ts b/src/app/deposits/daily-transaction-entry/day-collection-dialog/day-collection-dialog.component.ts new file mode 100644 index 000000000..e5af9b496 --- /dev/null +++ b/src/app/deposits/daily-transaction-entry/day-collection-dialog/day-collection-dialog.component.ts @@ -0,0 +1,115 @@ +/** + * Copyright since 2025 Mifos Initiative + * + * This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. + */ + +/** Angular Imports */ +import { Component, inject } from '@angular/core'; +import { CommonModule } from '@angular/common'; +import { FormsModule } from '@angular/forms'; +import { MAT_DIALOG_DATA, MatDialogModule, MatDialogRef } from '@angular/material/dialog'; +import { MatButtonModule } from '@angular/material/button'; +import { MatSelectModule } from '@angular/material/select'; +import { MatFormFieldModule } from '@angular/material/form-field'; +import { MatInputModule } from '@angular/material/input'; + +/** + * Collecting for days other than today - either paid ahead, or owed from days gone by. + * + * Both are the same act from the counter's side: the customer is paying for a number of days, and + * what that comes to is the scheme's business, not the clerk's. So the clerk picks days and the + * amount follows. Typing an amount instead would let the daily figure be worked around a little at a + * time, and would quote the customer something the scheme does not charge. + * + * The two differ only in how many days are on offer. Paying ahead is capped by the bank's allowance. + * Paying arrears is capped by what is still inside the catch-up window - older instalments are gone, + * and offering them would have the clerk asking for money the bank has already written off. + */ +@Component({ + selector: 'mifosx-day-collection-dialog', + standalone: true, + imports: [ + CommonModule, + FormsModule, + MatDialogModule, + MatButtonModule, + MatSelectModule, + MatFormFieldModule, + MatInputModule + ], + templateUrl: './day-collection-dialog.component.html', + styleUrls: ['./day-collection-dialog.component.scss'] +}) +export class DayCollectionDialogComponent { + private dialogRef = inject(MatDialogRef); + /** { mode: 'advance' | 'previous', parties: [] } - the agent's parties, as the screen already has them. */ + data = inject(MAT_DIALOG_DATA); + + partyCode = ''; + days: number | '' = ''; + + get isAdvance(): boolean { + return this.data?.mode === 'advance'; + } + + get title(): string { + return this.isAdvance ? 'Collect in advance' : 'Collect previous days'; + } + + get party(): any { + return (this.data?.parties || []).find((p: any) => String(p.partyCode) === this.partyCode) || null; + } + + /** + * The parties this can be used on. + * + * Paying ahead is offered to anyone. Paying arrears is offered only where there are arrears still + * inside the window - on an account that is up to date there is nothing to collect for past days, + * and listing it would invite an entry that is really an advance in disguise. + */ + get eligibleParties(): any[] { + const parties = this.data?.parties || []; + return this.isAdvance ? parties : parties.filter((p: any) => Number(p.recoverableAmount || 0) > 0); + } + + /** + * How many days may be picked. Five ahead, matching the bank's allowance and the server's own + * check; for arrears, only what has not yet fallen out of the catch-up window. + */ + get dayOptions(): number[] { + const daily = Number(this.party?.dailyDepositAmount || 0); + if (!this.party || daily <= 0) { + return []; + } + const limit = this.isAdvance ? 5 : Math.floor(Number(this.party.recoverableAmount || 0) / daily); + return Array.from({ length: Math.max(0, limit) }, (_, index) => index + 1); + } + + get amount(): number { + return Number(this.party?.dailyDepositAmount || 0) * Number(this.days || 0); + } + + /** Clearing the days when the party changes - a count valid for one account is not for another. */ + partyChanged(): void { + this.days = ''; + } + + get canAdd(): boolean { + return !!this.partyCode && Number(this.days) > 0 && this.amount > 0; + } + + add(): void { + this.dialogRef.close({ + partyCode: this.partyCode, + days: Number(this.days), + amount: this.amount, + // Sent as days, not just an amount: the server recomputes what they come to and checks it + // against this account, so the figure quoted here is the one that gets posted. + advanceDays: this.isAdvance ? Number(this.days) : undefined, + arrearsDays: this.isAdvance ? undefined : Number(this.days) + }); + } +} diff --git a/src/app/deposits/daily-transaction-entry/notice-dialog/notice-dialog.component.html b/src/app/deposits/daily-transaction-entry/notice-dialog/notice-dialog.component.html new file mode 100644 index 000000000..ee905a5bc --- /dev/null +++ b/src/app/deposits/daily-transaction-entry/notice-dialog/notice-dialog.component.html @@ -0,0 +1,21 @@ + + +
+ +

{{ data.heading }}

+
+ + +

{{ data.message }}

+
+ + + + + diff --git a/src/app/deposits/daily-transaction-entry/notice-dialog/notice-dialog.component.scss b/src/app/deposits/daily-transaction-entry/notice-dialog/notice-dialog.component.scss new file mode 100644 index 000000000..1965dd040 --- /dev/null +++ b/src/app/deposits/daily-transaction-entry/notice-dialog/notice-dialog.component.scss @@ -0,0 +1,44 @@ +/** + * Copyright since 2025 Mifos Initiative + * + * This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. + */ + +.nd-head { + display: flex; + align-items: center; + gap: 12px; + padding: 18px 24px 4px; + + /* + * Teal rather than the amber or red the platform's other dialogs use. Nothing has gone wrong here + * and nothing is at risk - the clerk is being told a fact about the agent they picked, and a + * warning colour would send them looking for a fault that is not there. + */ + &__ic { + display: grid; + place-items: center; + width: 34px; + height: 34px; + border-radius: 8px; + background: rgb(15 118 110 / 10%); + color: #0f766e; + flex: none; + } + + &__title { + margin: 0; + font-size: 1.05rem; + font-weight: 600; + } +} + +.nd-message { + margin: 0; + max-width: 44ch; /* stylelint-disable-line unit-allowed-list */ + font-size: 0.9rem; + line-height: 1.55; + color: rgb(0 0 0 / 70%); +} diff --git a/src/app/deposits/daily-transaction-entry/notice-dialog/notice-dialog.component.ts b/src/app/deposits/daily-transaction-entry/notice-dialog/notice-dialog.component.ts new file mode 100644 index 000000000..906d5b209 --- /dev/null +++ b/src/app/deposits/daily-transaction-entry/notice-dialog/notice-dialog.component.ts @@ -0,0 +1,39 @@ +/** + * Copyright since 2025 Mifos Initiative + * + * This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. + */ + +/** Angular Imports */ +import { Component, inject } from '@angular/core'; +import { MAT_DIALOG_DATA, MatDialogModule } from '@angular/material/dialog'; +import { MatButtonModule } from '@angular/material/button'; +import { FaIconComponent } from '@fortawesome/angular-fontawesome'; + +/** + * Something the clerk needs told, with nothing to decide. + * + * The platform's confirmation dialog asks a question - Cancel or Confirm - and its buttons imply a + * choice. Used for a plain statement it leaves the clerk hunting for what they are agreeing to, and + * "Cancel" reads as though the thing being described could be undone. The error dialog has the right + * single button but dresses the message in red, which turns "this agent has no parties" into a fault. + * + * So: one button, and the message stands on its own. + */ +@Component({ + selector: 'mifosx-notice-dialog', + standalone: true, + imports: [ + MatDialogModule, + MatButtonModule, + FaIconComponent + ], + templateUrl: './notice-dialog.component.html', + styleUrls: ['./notice-dialog.component.scss'] +}) +export class NoticeDialogComponent { + /** { heading, message } */ + data = inject(MAT_DIALOG_DATA); +} diff --git a/src/app/deposits/dds-accounts/dds-account-view/dds-account-view.component.html b/src/app/deposits/dds-accounts/dds-account-view/dds-account-view.component.html new file mode 100644 index 000000000..888501fe1 --- /dev/null +++ b/src/app/deposits/dds-accounts/dds-account-view/dds-account-view.component.html @@ -0,0 +1,557 @@ + + +
+ @if (isLoading) { +
+ + Loading account… +
+ } @else if (!account) { +
+ + DDS account not found + +
+ } @else { + +
+
+
+ ● {{ account.status }} +

{{ account.subType }}

+
+ Account Number + {{ account.accountNo }} + Client Name + {{ account.clientName || account.partyName }} +
+
+
+
+ Current Balance + ₹{{ account.currentBalance || 0 | formatNumber }} +
+
+ Maturity Amount + ₹{{ account.maturityAmount || 0 | formatNumber }} +
+
+
+ + +
+
+ + +
+ + + + + @if (account.status === 'CLOSED') { + + } +
+ + + @if (tab === 'closure') { + @if (!closure) { +
+
Loading closure details…
+
+ } @else { +
+
+
+
How It Closed
+
+ Type + + {{ closureLabels[closure.closureType] || closure.closureType }} + @if (closure.withinLockin) { + within lock-in + } + +
+
+ Closed On{{ closure.closeDate | date: 'dd MMMM yyyy' }} +
+ @if (closure.paidOutBy) { +
+ Paid Out By{{ closure.paidOutBy }} +
+ } +
+ Closed By{{ closure.closedBy || '—' }} +
+
+ Approved By{{ closure.approvedBy || '—' }} +
+ @if (closure.lockinOverrideReason) { +
+ Reason{{ closure.lockinOverrideReason }} +
+ } + @if (closure.shortfallBorneBy) { +
+ Cost Borne By + {{ + closure.shortfallBorneBy === 'AGENT' ? 'Agent — ' + closure.agentCode : 'Customer' + }} +
+ } +
+ +
+
The Money
+
+ Balance at Close₹{{ closure.balanceAtClose || 0 | formatNumber }} +
+ @if (closure.interestRecovered > 0) { +
+ Interest Recovered− ₹{{ closure.interestRecovered | formatNumber }} +
+ } + @if (closure.interestToppedUpByAgent > 0) { +
+ Interest Funded by Agent+ ₹{{ closure.interestToppedUpByAgent | formatNumber }} +
+ } +
+ Paid Out₹{{ closure.paidOut || 0 | formatNumber }} +
+ @if (closure.closureType === 'PREMATURE') { +
+ Agent Penalty ({{ closure.agentCode }})₹{{ closure.agentCommissionPenalty || 0 | formatNumber }} +
+ } +
+
+ +
+
+
Where It Went
+ @if (closure.destination === 'OWN_ACCOUNT') { +
Paid IntoCustomer's own account
+
+ Account{{ closure.creditedAccount?.accountNo }} +
+ @if (closure.creditedAccount?.productName) { +
+ Product{{ closure.creditedAccount.productName }} +
+ } + @if (closure.creditedAccount?.owner) { +
+ Holder{{ closure.creditedAccount.owner }} +
+ } + } @else if (closure.destination === 'SUNDRY') { +
Parked InSundry (customer has no account here)
+
+ GL Account{{ closure.sundryGl?.glCode }} · {{ closure.sundryGl?.glName }} +
+ } @else { +
Paid OutNothing - the balance was nil
+ } + @if (closure.voucherNo) { +
+ Voucher{{ closure.voucherNo }} +
+ } + @if (closure.payoutDate) { +
+ Paid On{{ closure.payoutDate | date: 'dd MMMM yyyy' }} +
+ } + @if (closure.passbookNote) { +
+ Passbook Note{{ closure.passbookNote }} +
+ } +
+ + @if (closure.ledgerEntries?.length) { +
+
Ledger Entry
+
+ Transaction ID{{ closure.ledgerTransactionId }} +
+ @for (leg of closure.ledgerEntries; track $index) { +
+ {{ leg.glCode }} · {{ leg.glName }} + + {{ leg.side }} ₹{{ leg.amount | formatNumber }} + +
+ } +
+ } +
+
+ } + } + + + @if (tab === 'general') { +
+
+
+
DDS Details
+
+ Account Number{{ account.accountNo }} +
+
+ Party Code{{ account.partyCode }} +
+
+ Client Name{{ account.clientName }} ({{ account.clientId }}) +
+
+ Agent{{ account.agentCode }} · {{ account.agentName }} +
+
+ Branch{{ account.officeName }} +
+
+ Product{{ account.subType }} +
+
+ Opened On{{ account.openingDate | date: 'dd MMMM yyyy' }} +
+
+ Matures On{{ account.maturityDate | date: 'dd MMMM yyyy' }} +
+
+ Daily Deposit₹{{ account.dailyDepositAmount || 0 | formatNumber }} +
+
+ Term{{ account.month }} months · {{ account.days }} days +
+
+ Interest Rate{{ account.interestRate }} % +
+
+ CurrencyIndian Rupee [INR] +
+
+ +
+
Performance History
+
+ Total Collected₹{{ totalOf('PARTY_TRAN') | formatNumber }} +
+
+ Total Interest Earned₹{{ interestEarned | formatNumber }} +
+
+ Total Paid Out₹{{ totalDebitsAll | formatNumber }} +
+
+
+ +
+
+
Account Summary
+
+ Collected to Date₹{{ account.collectedToDate || 0 | formatNumber }} +
+
+ Expected to Date₹{{ account.expectedToDate || 0 | formatNumber }} +
+
+ Standing + + @if (account.advanceDays > 0) { + {{ account.advanceDays }} days ahead + } @else if (account.arrearsDays > 0) { + {{ account.arrearsDays }} days behind + } @else { + Up to date + } + +
+
+ Instalments Due{{ account.instalmentsDue ?? 0 }} +
+
+ Interest Earned₹{{ interestEarned | formatNumber }} +
+
+ Interest Calculated, Not Posted₹{{ account.interestCalc || 0 | formatNumber }} +
+
Interest Calculated UsingDaily Balance
+
Days in Year365 Days
+
+ Last Interest Date{{ account.interestDate ? (account.interestDate | date: 'dd MMMM yyyy') : '—' }} +
+
+ Last Transaction Date{{ lastTransactionDate ? (lastTransactionDate | date: 'dd MMMM yyyy') : '—' }} +
+
+ Maturity Amount₹{{ account.maturityAmount || 0 | formatNumber }} +
+
+ Lien + {{ account.lien ? 'Yes — ₹' + (account.amountHeldUnderLien || 0) : 'No' }} +
+
+ Freeze{{ account.freezeCode || 'Normal' }} +
+ @if (account.closeDate) { +
+ Closed On{{ account.closeDate | date: 'dd MMMM yyyy' }} +
+ } +
+
+
+ } + + + @if (tab === 'transactions') { +
+
+
+ From + +
+
+ To + +
+ + @if (hasDateFilters) { + + } +
+ Credits ₹{{ totalCredits | formatNumber }} + Debits ₹{{ totalDebits | formatNumber }} +
+ +
+ + @if (shownTransactions.length === 0) { +
+ + {{ + hasDateFilters + ? 'Nothing moved on this account in these dates' + : 'Nothing has been credited to this account yet' + }} +
+ } @else { +
+ + + + + + + + + + + + + + + + @for (txn of shownTransactions; track txn.id; let i = $index) { + + + + + + + + + + + + } + +
#IDTransaction DateTransaction TypeVoucherDescriptionDebitCreditBalance
{{ i + 1 }}{{ txn.id }}{{ txn.transactionDate | date: 'dd MMMM yyyy' }}{{ label(txn.transactionType) }}{{ txn.voucherNo || '—' }}{{ txn.description }} + {{ txn.direction === 'DEBIT' ? '₹' + (txn.amount | formatNumber) : 'N/A' }} + + {{ txn.direction === 'CREDIT' ? '₹' + (txn.amount | formatNumber) : 'N/A' }} + + ₹{{ txn.balanceAfterTransaction || 0 | formatNumber }} +
+
+ } +
+ } + + + @if (tab === 'passbook') { +
+
+
+ From + +
+
+ To + +
+ @if (hasDateFilters) { + + } + +
+ +
+
+
+
DDS Passbook
+
{{ account.officeName }}
+
+
+ Account No{{ account.accountNo }} Party Code{{ account.partyCode }} Name{{ account.clientName || account.partyName }} Product{{ account.subType }} · {{ account.interestRate }} % Opened On{{ account.openingDate | date: 'dd/MM/yyyy' }} Agent{{ account.agentCode }} +
+
+ + @if (passbookEntries.length === 0) { +
+ {{ hasDateFilters ? 'No entries in these dates' : 'No entries yet' }} +
+ } @else { + + + + + + + + + + + + + @for (txn of passbookEntries; track txn.id) { + + + + + + + + + } + + + + + + + + + +
DateVoucherParticularsWithdrawalsDepositsBalance
{{ txn.transactionDate | date: 'dd/MM/yyyy' }}{{ txn.voucherNo || '' }} + {{ label(txn.transactionType) }} + @if (txn.description) { + {{ txn.description }} + } + {{ txn.direction === 'DEBIT' ? (txn.amount | formatNumber) : '' }}{{ txn.direction === 'CREDIT' ? (txn.amount | formatNumber) : '' }} + {{ txn.balanceAfterTransaction || 0 | formatNumber }} +
Total + {{ totalDebits | formatNumber }} + + {{ totalCredits | formatNumber }} + + {{ passbookClosingBalance | formatNumber }} +
+ } +
+
+ } + + + @if (tab === 'nominees') { +
+ @if (!account.nominees || account.nominees.length === 0) { +
+ + No nominee on this account +
+ } @else { +
+ + + + + + + + + + + + + + + + @for (nominee of account.nominees; track nominee.id; let i = $index) { + + + + + + + + + + + + } + +
#NameRelationGenderMobileBirth DateNominated OnGuardian{{ account.nomineeSplitMode === 'Sequence' ? 'Order' : 'Share %' }}
{{ i + 1 }} + {{ nominee.name }} + {{ nominee.relation }}{{ nominee.gender }}{{ nominee.mobileNo || '—' }}{{ nominee.birthDate ? (nominee.birthDate | date: 'dd MMM yyyy') : '—' }}{{ nominee.nominationDate ? (nominee.nominationDate | date: 'dd MMM yyyy') : '—' }}{{ nominee.guardianName || '—' }} + {{ account.nomineeSplitMode === 'Sequence' ? nominee.sequenceNo : (nominee.sharePercent ?? '—') }} +
+
+ } +
+ } + } +
diff --git a/src/app/deposits/dds-accounts/dds-account-view/dds-account-view.component.scss b/src/app/deposits/dds-accounts/dds-account-view/dds-account-view.component.scss new file mode 100644 index 000000000..d0da71aa8 --- /dev/null +++ b/src/app/deposits/dds-accounts/dds-account-view/dds-account-view.component.scss @@ -0,0 +1,459 @@ +/** + * Copyright since 2025 Mifos Initiative + * + * This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. + */ + +/* One DDS account's page, laid out the way the savings account page is: a banner, tabs, cards. */ + +.dav-page { + display: flex; + flex-direction: column; + gap: 16px; + padding: 24px 32px; +} + +.dav-mono { + font-family: ui-monospace, 'SF Mono', 'Cascadia Mono', monospace; + font-style: normal; +} + +.dav-cr { + color: #047857; +} + +.dav-dr { + color: #b91c1c; +} + +.dav-right { + text-align: right !important; +} + +/* ── Banner ───────────────────────────────────────────────────────────── */ + +.dav-banner { + display: flex; + align-items: center; + flex-wrap: wrap; + gap: 16px; + padding: 16px 24px; + border-radius: 16px; + color: #fff; + background: linear-gradient(90deg, #1e3a8a 0%, #2d3fb4 55%, #5b47d6 100%); + box-shadow: 0 8px 24px rgb(30 58 138 / 25%); +} + +.dav-banner__icon { + width: 48px; + height: 48px; + border-radius: 12px; + display: grid; + place-items: center; + font-size: 1.3rem; + background: rgb(255 255 255 / 16%); + border: 1px solid rgb(255 255 255 / 25%); +} + +.dav-banner__text { + display: flex; + flex-direction: column; + gap: 4px; + min-width: 0; +} + +.dav-pill { + align-self: flex-start; + padding: 2px 10px; + border-radius: 999px; + font-size: 0.7rem; + font-weight: 700; + letter-spacing: 0.04em; + background: rgb(16 185 129 / 25%); + color: #a7f3d0; + border: 1px solid rgb(167 243 208 / 40%); +} + +.dav-pill--closed { + background: rgb(148 163 184 / 25%); + color: #e2e8f0; + border-color: rgb(226 232 240 / 40%); +} + +.dav-banner__title { + margin: 0; + font-size: 1.25rem; + font-weight: 700; +} + +.dav-banner__meta { + display: flex; + flex-wrap: wrap; + align-items: baseline; + gap: 6px 12px; +} + +.dav-banner__label { + font-size: 0.7rem; + font-weight: 700; + letter-spacing: 0.06em; + text-transform: uppercase; + color: rgb(255 255 255 / 60%); +} + +.dav-banner__value { + font-size: 0.9rem; + font-weight: 600; +} + +.dav-banner__boxes { + margin-left: auto; + display: flex; + gap: 12px; +} + +.dav-box { + display: flex; + flex-direction: column; + gap: 4px; + padding: 10px 18px; + border-radius: 12px; + background: rgb(255 255 255 / 12%); + border: 1px solid rgb(255 255 255 / 25%); +} + +.dav-box__label { + font-size: 0.68rem; + font-weight: 700; + letter-spacing: 0.06em; + text-transform: uppercase; + color: rgb(255 255 255 / 70%); +} + +.dav-box__value { + font-size: 1.15rem; + font-weight: 700; + font-variant-numeric: tabular-nums; +} + +.dav-banner__actions { + display: flex; + gap: 8px; +} + +.dav-icon-btn { + width: 38px; + height: 38px; + border-radius: 50%; + border: 1px solid rgb(255 255 255 / 35%); + background: rgb(255 255 255 / 12%); + color: #fff; + cursor: pointer; + + &:hover { + background: rgb(255 255 255 / 22%); + } +} + +/* ── Tabs ─────────────────────────────────────────────────────────────── */ + +.dav-tabs { + display: flex; + gap: 8px; + padding: 8px; + border-radius: 14px; + background: #fff; + border: 1px solid rgb(0 0 0 / 7%); +} + +.dav-tab { + flex: 1; + display: flex; + align-items: center; + justify-content: center; + gap: 6px; + padding: 10px 16px; + border: 0; + border-radius: 10px; + background: transparent; + font-size: 0.95rem; + font-weight: 600; + color: #475569; + cursor: pointer; + + &:hover { + background: #f1f5f9; + } +} + +.dav-tab--on { + color: #fff; + background: linear-gradient(90deg, #1d4ed8, #2563eb); + box-shadow: 0 4px 12px rgb(37 99 235 / 30%); + + &:hover { + background: linear-gradient(90deg, #1d4ed8, #2563eb); + } +} + +/* ── Cards ────────────────────────────────────────────────────────────── */ + +.dav-columns { + display: grid; + grid-template-columns: repeat(auto-fit, minmax(380px, 1fr)); + gap: 16px; + align-items: start; +} + +.dav-column { + display: flex; + flex-direction: column; + gap: 16px; +} + +.dav-card { + border-radius: 14px; + background: #fff; + border: 1px solid rgb(0 0 0 / 7%); + overflow: hidden; +} + +.dav-card__head { + display: flex; + align-items: center; + gap: 12px; + padding: 14px 20px; + font-size: 1rem; + font-weight: 700; + background: #f8fafc; + border-bottom: 1px solid rgb(0 0 0 / 7%); +} + +.dav-card__dot { + width: 30px; + height: 30px; + border-radius: 8px; + background: linear-gradient(135deg, #2563eb, #1e40af); +} + +.dav-row { + display: flex; + justify-content: space-between; + gap: 16px; + padding: 12px 20px; + border-bottom: 1px dashed rgb(0 0 0 / 7%); + font-size: 0.92rem; + + &:last-child { + border-bottom: 0; + } + + span { + color: #334155; + } + + strong { + text-align: right; + font-weight: 600; + } +} + +.dav-state { + display: flex; + flex-direction: column; + align-items: center; + gap: 10px; + padding: 48px 16px; + color: rgb(0 0 0 / 50%); +} + +/* ── Transactions ─────────────────────────────────────────────────────── */ + +.dav-toolbar { + display: flex; + align-items: center; + flex-wrap: wrap; + gap: 12px; + padding: 14px 20px; + background: #f8fafc; + border-bottom: 1px solid rgb(0 0 0 / 7%); +} + +.dav-datepick { + display: flex; + flex-direction: column; + gap: 2px; + + span { + font-size: 0.68rem; + font-weight: 700; + letter-spacing: 0.06em; + text-transform: uppercase; + color: rgb(0 0 0 / 45%); + } + + input { + padding: 6px 10px; + border-radius: 8px; + border: 1px solid rgb(0 0 0 / 15%); + font: inherit; + } +} + +.dav-btn { + display: inline-flex; + align-items: center; + gap: 6px; + padding: 8px 14px; + border-radius: 8px; + border: 1px solid rgb(0 0 0 / 15%); + background: #fff; + font-weight: 600; + cursor: pointer; + + &:disabled { + opacity: 0.5; + cursor: default; + } +} + +.dav-btn--primary { + color: #fff; + border-color: transparent; + background: linear-gradient(90deg, #1d4ed8, #2563eb); +} + +.dav-totals { + margin-left: auto; + display: flex; + gap: 16px; + font-size: 0.88rem; + font-weight: 700; + font-variant-numeric: tabular-nums; +} + +.dav-table-wrap { + overflow-x: auto; +} + +.dav-table { + width: 100%; + border-collapse: collapse; + font-size: 0.92rem; + + th { + padding: 14px 16px; + text-align: left; + font-size: 0.72rem; + font-weight: 700; + letter-spacing: 0.06em; + text-transform: uppercase; + color: #475569; + border-bottom: 1px solid rgb(0 0 0 / 8%); + white-space: nowrap; + } + + td { + padding: 16px; + border-bottom: 1px solid rgb(0 0 0 / 5%); + font-variant-numeric: tabular-nums; + } + + tbody tr:nth-child(odd) { + background: #f8fafc; + } +} + +.dav-desc { + max-width: 320px; + color: rgb(0 0 0 / 65%); +} + +@media (width <= 720px) { + .dav-page { + padding: 16px; + } + + .dav-banner__boxes { + margin-left: 0; + width: 100%; + } + + .dav-tab { + font-size: 0.85rem; + padding: 8px; + } +} + +/* ── Passbook ─────────────────────────────────────────────────────────── */ + +.dav-push { + margin-left: auto; +} + +.dav-passbook { + padding: 20px; +} + +.dav-pb-head { + display: flex; + justify-content: space-between; + flex-wrap: wrap; + gap: 16px 32px; + margin-bottom: 16px; + padding-bottom: 16px; + border-bottom: 2px solid #1e3a8a; +} + +.dav-pb-title { + font-size: 1.2rem; + font-weight: 700; + color: #1e3a8a; +} + +.dav-pb-sub { + font-size: 0.85rem; + color: rgb(0 0 0 / 55%); +} + +.dav-pb-grid { + display: grid; + grid-template-columns: auto auto; + gap: 4px 16px; + font-size: 0.88rem; + + span { + color: rgb(0 0 0 / 55%); + } +} + +.dav-pb-table { + tfoot td { + padding: 14px 16px; + border-top: 2px solid rgb(0 0 0 / 15%); + } +} + +.dav-pb-note { + display: block; + font-size: 0.78rem; + color: rgb(0 0 0 / 50%); +} + +/* Closure tab */ +.dav-closure-tag { + margin-left: 8px; + padding: 1px 8px; + border-radius: 999px; + background: #fff3e0; + color: #e65100; + font-size: 0.75rem; + font-style: normal; +} + +.dav-closure-total { + border-top: 1px solid rgb(0 0 0 / 10%); + font-weight: 600; +} diff --git a/src/app/deposits/dds-accounts/dds-account-view/dds-account-view.component.ts b/src/app/deposits/dds-accounts/dds-account-view/dds-account-view.component.ts new file mode 100644 index 000000000..33c3c817b --- /dev/null +++ b/src/app/deposits/dds-accounts/dds-account-view/dds-account-view.component.ts @@ -0,0 +1,297 @@ +/** + * Copyright since 2025 Mifos Initiative + * + * This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. + */ + +import { Component, OnInit, inject } from '@angular/core'; +import { ActivatedRoute, Router } from '@angular/router'; +import { FormControl, ReactiveFormsModule } from '@angular/forms'; +import { NgClass } from '@angular/common'; +import { MatTooltipModule } from '@angular/material/tooltip'; +import { MatProgressSpinnerModule } from '@angular/material/progress-spinner'; +import { FaIconComponent } from '@fortawesome/angular-fontawesome'; +import { forkJoin, of } from 'rxjs'; +import { catchError } from 'rxjs/operators'; +import { STANDALONE_SHARED_IMPORTS } from 'app/standalone-shared.module'; +import { FormatNumberPipe } from 'app/pipes/format-number.pipe'; +import { OrganizationService } from 'app/organization/organization.service'; + +/** What each DDS transaction type is called on screen. */ +const TRANSACTION_LABELS: Record = { + PARTY_TRAN: 'Collection', + INTEREST_POST: 'Interest', + OD_INTEREST_POST: 'OD Interest', + MATURITY_PAYOUT: 'Maturity Payout', + PREMATURE_PAYOUT: 'Premature Payout', + PREMATURE_INT_RECOVERY: 'Interest Recovered (Premature)', + PREMATURE_AGENT_TOP_UP: 'Interest Funded by Agent', + WITHDRAW_DEPOSITE: 'Transfer (Withdraw Deposite)', + TRANSFER: 'Transfer' +}; + +/** + * One DDS account and everything that has moved on it, as the savings account page shows a savings + * account: the account's terms and where it stands, then its transactions with the balance after + * each, narrowed to a day or a range. + * + * Read-only. The account is changed from DDS Party Master; its money moves through collections, the + * cashier and the batch runs. + */ +@Component({ + selector: 'mifosx-dds-account-view', + templateUrl: './dds-account-view.component.html', + styleUrls: ['./dds-account-view.component.scss'], + standalone: true, + imports: [ + ...STANDALONE_SHARED_IMPORTS, + ReactiveFormsModule, + NgClass, + FaIconComponent, + MatTooltipModule, + MatProgressSpinnerModule, + FormatNumberPipe + ] +}) +export class DdsAccountViewComponent implements OnInit { + private route = inject(ActivatedRoute); + private router = inject(Router); + private organizationService = inject(OrganizationService); + + isLoading = true; + account: any = null; + private transactions: any[] = []; + + /** The page's tabs, as the savings account page has them - only those that apply to DDS. */ + tab: 'general' | 'transactions' | 'passbook' | 'nominees' | 'closure' = 'general'; + + /** How a closed account was closed - read only for closed accounts, for the Closure tab. */ + closure: any = null; + + /** What each kind of closure is called on screen. */ + readonly closureLabels: Record = { + PREMATURE: 'Premature Close', + MATURITY_PAYOUT_AND_CLOSE: 'Maturity Payout & Close', + MATURITY_PAYOUT_THEN_CLOSE: 'Maturity Payout, then Closed', + MATURITY_AUTO_CLOSE: 'Maturity Auto Close (night job)', + CLOSED_NIL_BALANCE: 'Closed with nil balance' + }; + + /** The passbook reads oldest first, as it is written up. */ + get passbookEntries(): any[] { + return [...this.shownTransactions].reverse(); + } + + /** The balance after the last entry shown. */ + get passbookClosingBalance(): number { + const entries = this.passbookEntries; + return entries.length > 0 ? Number(entries[entries.length - 1].balanceAfterTransaction || 0) : 0; + } + + /** + * Prints the passbook alone. It is copied into a window of its own with only its own styles, so the + * page's menus, banner and tabs do not come out on the paper. + */ + printPassbook(): void { + const passbook = document.getElementById('dds-passbook'); + const popup = window.open('', '_blank', 'width=900,height=700'); + if (!passbook || !popup) { + return; + } + popup.document.write(`${this.account?.accountNo || 'DDS'} Passbook${passbook.innerHTML}`); + popup.document.close(); + popup.focus(); + popup.print(); + } + + fromDateControl = new FormControl(''); + toDateControl = new FormControl(''); + + readonly today = new Date().toISOString().split('T')[0]; + + ngOnInit(): void { + const partyId = this.route.snapshot.paramMap.get('partyId'); + if (!partyId) { + this.isLoading = false; + return; + } + forkJoin({ + account: this.organizationService.getParty(partyId).pipe(catchError(() => of(null))), + transactions: this.organizationService.getPartyTransactionsOfParty(partyId).pipe(catchError(() => of([]))) + }).subscribe(({ account, transactions }) => { + this.account = account; + if (account?.status === 'CLOSED') { + this.organizationService.getPartyClosure(partyId).subscribe({ + next: (closure: any) => (this.closure = closure), + error: () => (this.closure = null) + }); + } + // Newest first, and within a day the later entry first - the order a passbook is read back in. + this.transactions = (Array.isArray(transactions) ? transactions : []).sort( + (a: any, b: any) => + String(b.transactionDate).localeCompare(String(a.transactionDate)) || Number(b.id) - Number(a.id) + ); + this.isLoading = false; + }); + } + + /** The transactions in the chosen range - all of them when no range is set. */ + get shownTransactions(): any[] { + const from = this.fromDateControl.value; + const to = this.toDateControl.value; + return this.transactions.filter((txn: any) => { + const day = String(txn.transactionDate || ''); + if (from && day < String(from)) { + return false; + } + if (to && day > String(to)) { + return false; + } + return true; + }); + } + + get totalCredits(): number { + return this.sumOf('CREDIT'); + } + + get totalDebits(): number { + return this.sumOf('DEBIT'); + } + + private sumOf(direction: string): number { + return this.shownTransactions + .filter((txn: any) => txn.direction === direction) + .reduce((sum: number, txn: any) => sum + Number(txn.amount || 0), 0); + } + + /** Everything of one type ever credited, whatever the date filter says - for the summary cards. */ + totalOf(type: string): number { + return this.transactions + .filter((txn: any) => txn.transactionType === type && txn.direction === 'CREDIT') + .reduce((sum: number, txn: any) => sum + Number(txn.amount || 0), 0); + } + + /** Interest credited, less any taken back on an early closure. */ + get interestEarned(): number { + const recovered = this.transactions + .filter((txn: any) => txn.transactionType === 'PREMATURE_INT_RECOVERY') + .reduce((sum: number, txn: any) => sum + Number(txn.amount || 0), 0); + return ( + this.totalOf('INTEREST_POST') + + this.totalOf('OD_INTEREST_POST') + + this.totalOf('PREMATURE_AGENT_TOP_UP') - + recovered + ); + } + + /** Money that has left the account - payouts, withdrawals, transfers out. */ + get totalDebitsAll(): number { + return this.transactions + .filter((txn: any) => txn.direction === 'DEBIT' && txn.transactionType !== 'PREMATURE_INT_RECOVERY') + .reduce((sum: number, txn: any) => sum + Number(txn.amount || 0), 0); + } + + get lastTransactionDate(): string | null { + return this.transactions.length > 0 ? this.transactions[0].transactionDate : null; + } + + /** The rows on screen, as a spreadsheet the branch can keep or send on. */ + exportCsv(): void { + const header = [ + '#', + 'ID', + 'Transaction Date', + 'Transaction Type', + 'Voucher', + 'Description', + 'Account', + 'Debit', + 'Credit', + 'Balance' + ]; + const quote = (value: any) => `"${String(value ?? '').replace(/"/g, '""')}"`; + const lines = this.shownTransactions.map((txn: any, index: number) => + [ + index + 1, + txn.id, + txn.transactionDate, + this.label(txn.transactionType), + txn.voucherNo, + txn.description, + txn.accountNo, + txn.direction === 'DEBIT' ? txn.amount : '', + txn.direction === 'CREDIT' ? txn.amount : '', + txn.balanceAfterTransaction + ] + .map(quote) + .join(',') + ); + const blob = new Blob( + [[ + header.map(quote).join(','), + ...lines + ].join('\r\n')], + { type: 'text/csv;charset=utf-8' } + ); + const link = document.createElement('a'); + link.href = URL.createObjectURL(blob); + link.download = `${this.account?.accountNo || 'dds-account'}-transactions.csv`; + link.click(); + URL.revokeObjectURL(link.href); + } + + get hasDateFilters(): boolean { + return !!(this.fromDateControl.value || this.toDateControl.value); + } + + showToday(): void { + this.fromDateControl.setValue(this.today); + this.toDateControl.setValue(this.today); + } + + clearDateFilters(): void { + this.fromDateControl.setValue(''); + this.toDateControl.setValue(''); + } + + label(type: string): string { + return TRANSACTION_LABELS[type] || String(type || '').replace(/_/g, ' '); + } + + statusClass(): string { + switch (this.account?.status) { + case 'ACTIVE': + return 'status--active'; + case 'CLOSED': + return 'status--closed'; + default: + return 'status--default'; + } + } + + back(): void { + this.router.navigate(['/accounts/dds-accounts']); + } + + openInPartyMaster(): void { + this.router.navigate([ + '/accounts/dds', + this.account.id, + 'edit' + ]); + } +} diff --git a/src/app/deposits/dds-accounts/dds-accounts.component.html b/src/app/deposits/dds-accounts/dds-accounts.component.html new file mode 100644 index 000000000..e1edbc9c7 --- /dev/null +++ b/src/app/deposits/dds-accounts/dds-accounts.component.html @@ -0,0 +1,211 @@ + + +
+ +
+
+ +
+
+

DDS Accounts

+ + @if (isLoading) { + Loading... + } @else { + {{ allCount }} accounts · {{ activeCount }} active · {{ closedCount }} closed + } + +
+
+ + +
+ + + +
+ + +
+ + + +
+ + +
+
+
+ Opened From + +
+
+ +
+
+ To + +
+
+ +
+ +
+ + + As On Date + + +
+ + @if (hasDateFilters) { + + } +
+ + +
+ @if (isLoading) { +
+ + Loading accounts… +
+ } + + @if (!isLoading && rows.length === 0) { +
+
+ +
+

No DDS accounts found

+

Try adjusting your search

+
+ } + + @if (!isLoading && rows.length > 0) { +
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
Account No + {{ row.accountNo }} + Type + DDS + Client +
+
{{ (row.clientName || row.partyName || '?')[0] }}
+ {{ row.clientName || row.partyName }} +
+
Product + {{ row.subType }} + Balance + ₹ {{ row.currentBalance || 0 | formatNumber }} + Status + {{ row.status }} + Actions + +
+
+ + + } +
+
diff --git a/src/app/deposits/dds-accounts/dds-accounts.component.scss b/src/app/deposits/dds-accounts/dds-accounts.component.scss new file mode 100644 index 000000000..80e7d5189 --- /dev/null +++ b/src/app/deposits/dds-accounts/dds-accounts.component.scss @@ -0,0 +1,56 @@ +/** + * Copyright since 2025 Mifos Initiative + * + * This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. + */ + +/* All / Active / Closed - which DDS accounts the list shows. */ +.dds-status-filter { + display: flex; + flex-wrap: wrap; + gap: 8px; + margin: 0 0 12px; + + &__btn { + display: inline-flex; + align-items: center; + gap: 8px; + padding: 6px 14px; + border: 1px solid rgb(0 0 0 / 12%); + border-radius: 999px; + background: #fff; + color: rgb(0 0 0 / 70%); + font-size: 0.85rem; + font-weight: 500; + cursor: pointer; + + &.is-on { + border-color: #1565c0; + background: #e3f2fd; + color: #0d47a1; + } + + &--active.is-on { + border-color: #2e7d32; + background: #e8f5e9; + color: #1b5e20; + } + + &--closed.is-on { + border-color: #c62828; + background: #ffebee; + color: #b71c1c; + } + } + + &__count { + min-width: 22px; + padding: 0 6px; + border-radius: 999px; + background: rgb(0 0 0 / 8%); + font-size: 0.75rem; + text-align: center; + } +} diff --git a/src/app/deposits/dds-accounts/dds-accounts.component.ts b/src/app/deposits/dds-accounts/dds-accounts.component.ts new file mode 100644 index 000000000..811e5e9a3 --- /dev/null +++ b/src/app/deposits/dds-accounts/dds-accounts.component.ts @@ -0,0 +1,265 @@ +/** + * Copyright since 2025 Mifos Initiative + * + * This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. + */ + +import { Component, OnDestroy, OnInit, inject } from '@angular/core'; +import { Router } from '@angular/router'; +import { FormControl, ReactiveFormsModule } from '@angular/forms'; +import { NgClass } from '@angular/common'; +import { MatPaginator, PageEvent } from '@angular/material/paginator'; +import { + MatCell, + MatCellDef, + MatColumnDef, + MatHeaderCell, + MatHeaderCellDef, + MatHeaderRow, + MatHeaderRowDef, + MatRow, + MatRowDef, + MatTable +} from '@angular/material/table'; +import { MatTooltipModule } from '@angular/material/tooltip'; +import { MatProgressSpinnerModule } from '@angular/material/progress-spinner'; +import { FaIconComponent } from '@fortawesome/angular-fontawesome'; +import { Subject, merge } from 'rxjs'; +import { debounceTime, distinctUntilChanged, takeUntil } from 'rxjs/operators'; +import { STANDALONE_SHARED_IMPORTS } from 'app/standalone-shared.module'; +import { FormatNumberPipe } from 'app/pipes/format-number.pipe'; +import { OrganizationService } from 'app/organization/organization.service'; + +/** + * The DDS accounts, listed the way Savings Accounts lists savings: account number, client, product, + * balance and status, searched by account number or client and narrowed by opening date. Each opens + * onto the account's own page and its transactions. + * + * Read-only. Accounts are opened and changed from DDS Party Master; money moves through collections, + * the cashier and the batch runs. + */ +@Component({ + selector: 'mifosx-dds-accounts', + templateUrl: './dds-accounts.component.html', + // The Savings Accounts list's own look, so the two screens read as one family. + styleUrls: [ + '../../accounts/accounts-list/accounts-list.component.scss', + './dds-accounts.component.scss' + ], + standalone: true, + imports: [ + ...STANDALONE_SHARED_IMPORTS, + ReactiveFormsModule, + NgClass, + FaIconComponent, + MatTable, + MatColumnDef, + MatHeaderCellDef, + MatHeaderCell, + MatCellDef, + MatCell, + MatHeaderRowDef, + MatHeaderRow, + MatRowDef, + MatRow, + MatPaginator, + MatTooltipModule, + MatProgressSpinnerModule, + FormatNumberPipe + ] +}) +export class DdsAccountsComponent implements OnInit, OnDestroy { + private organizationService = inject(OrganizationService); + private router = inject(Router); + private destroy$ = new Subject(); + + displayedColumns = [ + 'accountNo', + 'depositType', + 'clientName', + 'productName', + 'balance', + 'status', + 'actions' + ]; + + /** Every DDS account, fetched once; search, dates and paging work on this. */ + private allAccounts: any[] = []; + /** The accounts matching the search and dates, whatever their status - what the status counts read. */ + private beforeStatus: any[] = []; + /** The accounts matching the search, dates and status, before paging. */ + private matching: any[] = []; + + /** Which accounts are shown: all of them, only the open ones, or only the closed. */ + statusFilter: 'ALL' | 'ACTIVE' | 'CLOSED' = 'ALL'; + /** The page on screen. */ + rows: any[] = []; + + pageSize = 20; + pageIndex = 0; + isLoading = false; + + searchControl = new FormControl(''); + searchType: 'accountNo' | 'clientName' = 'accountNo'; + fromDateControl = new FormControl(''); + toDateControl = new FormControl(''); + asOnDateControl = new FormControl(''); + + readonly today = new Date().toISOString().split('T')[0]; + + get totalRecords(): number { + return this.matching.length; + } + + get activeCount(): number { + return this.beforeStatus.filter((account) => account.status === 'ACTIVE').length; + } + + get closedCount(): number { + return this.beforeStatus.filter((account) => account.status === 'CLOSED').length; + } + + get allCount(): number { + return this.beforeStatus.length; + } + + setStatusFilter(status: 'ALL' | 'ACTIVE' | 'CLOSED'): void { + this.statusFilter = status; + this.pageIndex = 0; + this.applyFilters(); + } + + get hasDateFilters(): boolean { + return !!((this.fromDateControl.value && this.toDateControl.value) || this.asOnDateControl.value); + } + + ngOnInit(): void { + this.loadAccounts(); + + this.searchControl.valueChanges + .pipe(debounceTime(300), distinctUntilChanged(), takeUntil(this.destroy$)) + .subscribe(() => { + this.pageIndex = 0; + this.applyFilters(); + }); + merge(this.fromDateControl.valueChanges, this.toDateControl.valueChanges, this.asOnDateControl.valueChanges) + .pipe(debounceTime(300), takeUntil(this.destroy$)) + .subscribe(() => { + this.pageIndex = 0; + this.applyFilters(); + }); + } + + ngOnDestroy(): void { + this.destroy$.next(); + this.destroy$.complete(); + } + + loadAccounts(): void { + this.isLoading = true; + this.organizationService + .getParties() + .pipe(takeUntil(this.destroy$)) + .subscribe({ + next: (parties: any) => { + // Newest first, as the savings list shows them. + this.allAccounts = (Array.isArray(parties) ? parties : parties?.pageItems || []).sort( + (a: any, b: any) => Number(b.id) - Number(a.id) + ); + this.isLoading = false; + this.applyFilters(); + }, + error: () => { + this.allAccounts = []; + this.isLoading = false; + this.applyFilters(); + } + }); + } + + /** Search by account number or client, and by the day the account was opened. */ + private applyFilters(): void { + const term = String(this.searchControl.value || '') + .trim() + .toLowerCase(); + const fromVal = this.fromDateControl.value; + const toVal = this.toDateControl.value; + const bothDates = !!(fromVal && toVal); + const asOn = this.asOnDateControl.value; + + this.beforeStatus = this.allAccounts.filter((account: any) => { + if (term) { + const haystack = + this.searchType === 'accountNo' + ? `${account.accountNo || ''} ${account.partyCode || ''}` + : `${account.clientName || ''} ${account.partyName || ''}`; + if (!haystack.toLowerCase().includes(term)) { + return false; + } + } + const opened = String(account.openingDate || ''); + if (bothDates && opened && (opened < String(fromVal) || opened > String(toVal))) { + return false; + } + // As on a date: the accounts that had been opened by then. + if (asOn && opened && opened > String(asOn)) { + return false; + } + return true; + }); + this.matching = + this.statusFilter === 'ALL' + ? this.beforeStatus + : this.beforeStatus.filter((account: any) => account.status === this.statusFilter); + this.showPage(); + } + + private showPage(): void { + const start = this.pageIndex * this.pageSize; + this.rows = this.matching.slice(start, start + this.pageSize); + } + + onPageChange(event: PageEvent): void { + this.pageIndex = event.pageIndex; + this.pageSize = event.pageSize; + this.showPage(); + } + + toggleSearchType(): void { + this.searchType = this.searchType === 'accountNo' ? 'clientName' : 'accountNo'; + this.pageIndex = 0; + this.applyFilters(); + } + + clearSearch(): void { + this.searchControl.setValue(''); + } + + clearDateFilters(): void { + this.fromDateControl.setValue('', { emitEvent: false }); + this.toDateControl.setValue('', { emitEvent: false }); + this.asOnDateControl.setValue('', { emitEvent: false }); + this.pageIndex = 0; + this.applyFilters(); + } + + viewAccount(account: any): void { + this.router.navigate([ + '/accounts/dds-accounts', + account.id + ]); + } + + getStatusClass(account: any): string { + switch (account?.status) { + case 'ACTIVE': + return 'status--active'; + case 'CLOSED': + return 'status--closed'; + default: + return 'status--default'; + } + } +} diff --git a/src/app/deposits/dds-approvals/dds-approval-notice.ts b/src/app/deposits/dds-approvals/dds-approval-notice.ts new file mode 100644 index 000000000..34caac788 --- /dev/null +++ b/src/app/deposits/dds-approvals/dds-approval-notice.ts @@ -0,0 +1,46 @@ +/** + * Copyright since 2025 Mifos Initiative + * + * This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. + */ + +import { OrganizationService } from 'app/organization/organization.service'; +import { AlertService } from 'app/core/alert/alert.service'; + +/** + * Whether the server held a DDS entry for a checker rather than carrying it out. The reply then + * names the entry (commandId) and asks for the work to be rolled back, and carries no result of its own. + */ +export function heldForApproval(response: any): boolean { + return !!response?.rollbackTransaction || (!!response?.commandId && response?.resourceId == null); +} + +/** + * Tells the clerk their entry went to a checker - with the voucher it will move under - instead of + * saying it was done. Nothing has moved yet, and a message that read as though it had would send them + * looking for money that is still waiting on a manager. + */ +export function announceHeldForApproval( + response: any, + what: string, + organizationService: OrganizationService, + alertService: AlertService +): void { + const say = (voucherNo?: string) => + alertService.alert({ + type: 'Sent for Approval', + message: + `${what} sent for approval${voucherNo ? ' under voucher ' + voucherNo : ''}. ` + + 'It takes effect once a checker approves it in Approval Center › DDS Approvals.' + }); + if (!response?.commandId) { + say(); + return; + } + organizationService.getDdsApproval(response.commandId).subscribe({ + next: (entry: any) => say(entry?.voucherNo), + error: () => say() + }); +} diff --git a/src/app/deposits/dds-approvals/dds-approval-review/dds-approval-review.component.html b/src/app/deposits/dds-approvals/dds-approval-review/dds-approval-review.component.html new file mode 100644 index 000000000..9a1b3412e --- /dev/null +++ b/src/app/deposits/dds-approvals/dds-approval-review/dds-approval-review.component.html @@ -0,0 +1,265 @@ + + + +
+ + + @if (loading) { +
+ hourglass_empty + Loading entry… +
+ } @else { +
+ +
+
+
+ {{ voucherNo || '—' }} + + {{ entry.status }}{{ entry.voucherCancelled ? ' · VOUCHER CANCELLED' : '' }} + +
+
+ + person + {{ entry.makerName || entry.maker }} ({{ entry.maker }}) + + + schedule + {{ formatDateTime(entry.madeOn) }} + + @if (entry.status !== 'PENDING' && entry.checker) { + + check_circle + Actioned by: {{ entry.checkerName || entry.checker }} + + } +
+
+
+ + +
+
+ swap_horiz + {{ entry.type }} Summary +
+
+
+ Total Amount + + {{ entry.amount !== null && entry.amount !== undefined ? '₹' + (entry.amount | number: '1.2-2') : '—' }} + +
+
+ DR Accounts + {{ debitLines.length }} account{{ debitLines.length !== 1 ? 's' : '' }} +
+
+ CR Accounts + {{ creditLines.length }} account{{ creditLines.length !== 1 ? 's' : '' }} +
+
+ Details + {{ entry.details || '—' }} +
+ @if (detail?.description) { +
+ Description + {{ detail.description }} +
+ } +
+
+ + + @if (debitLines.length) { +
+
+ arrow_upward + Debit Entries ({{ debitLines.length }} account{{ debitLines.length !== 1 ? 's' : '' }} — funds out) +
+ + + + + + + + + + + + @for (line of debitLines; track $index; let i = $index) { + + + + + + + + } + + + + + +
#Account NoAccount NameTypeAmount (DR)
{{ i + 1 }}{{ line.accountNo || '—' }}{{ line.accountName || '—' }} + {{ line.type || '—' }} + ₹{{ line.amount | number: '1.2-2' }}
Total Debit₹{{ total(debitLines) | number: '1.2-2' }}
+
+ } + + + @if (creditLines.length) { +
+
+ arrow_downward + Credit Entries ({{ creditLines.length }} account{{ creditLines.length !== 1 ? 's' : '' }} — funds in) +
+ + + + + + + + + + + + @for (line of creditLines; track $index; let i = $index) { + + + + + + + + } + + + + + +
#Account NoAccount NameTypeAmount (CR)
{{ i + 1 }}{{ line.accountNo || '—' }}{{ line.accountName || '—' }} + {{ line.type || '—' }} + ₹{{ line.amount | number: '1.2-2' }}
Total Credit₹{{ total(creditLines) | number: '1.2-2' }}
+
+ } + + @if (entry.status === 'PENDING' && isMine && !done) { +
+
+ info + Your own entry +
+

+ You made this entry, so another user with checker rights has to approve or reject it. +

+
+ } + + @if (entry.status === 'REJECTED') { +
+
+ cancel + Rejected +
+

+ Nothing was done{{ voucherNo ? '. Voucher ' + voucherNo + ' is cancelled and will not be used' : '' }}. +

+ @if (entry.checker) { +
+ manage_accounts + {{ entry.checker }} +
+ } +
+ } + + @if (entry.status === 'APPROVED') { +
+
+ check_circle + Approved and carried out +
+

+ {{ + voucherNo + ? 'Voucher ' + voucherNo + : entry.entity === 'DDSBATCH' + ? 'Posting is running - refresh for its voucher' + : '—' + }} +

+
+ manage_accounts + Approved by: {{ entry.checker || '—' }} +
+
+ } + + @if (error) { +
+
+ error + Could not complete +
+

{{ error }}

+
+ } + + @if (entry.status === 'PENDING' && !done) { +
+ @if (!confirmReject) { + + + } @else { +
+ +
+ + +
+
+ } +
+ } +
+ } +
diff --git a/src/app/deposits/dds-approvals/dds-approval-review/dds-approval-review.component.ts b/src/app/deposits/dds-approvals/dds-approval-review/dds-approval-review.component.ts new file mode 100644 index 000000000..4cc45f2e6 --- /dev/null +++ b/src/app/deposits/dds-approvals/dds-approval-review/dds-approval-review.component.ts @@ -0,0 +1,148 @@ +/** + * Copyright since 2025 Mifos Initiative + * + * This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. + */ + +import { Component, OnInit, inject } from '@angular/core'; +import { CommonModule } from '@angular/common'; +import { MAT_DIALOG_DATA, MatDialogModule, MatDialogRef } from '@angular/material/dialog'; +import { MatIconModule } from '@angular/material/icon'; + +import { OrganizationService } from 'app/organization/organization.service'; + +/** + * One DDS entry, laid out the way Disbursement & Transfers reviews a request - voucher and status on + * top, who made it and when, then the debit and credit entries it moves - and styled by the same + * sheet, so a checker reads both queues the same way. + * + * The checker approves it, which carries it out under the voucher numbered when it was sent, or + * rejects it, which leaves nothing done and cancels that number. Neither is offered to the user who + * made the entry, and the server refuses it as well. + */ +@Component({ + selector: 'mifosx-dds-approval-review', + templateUrl: './dds-approval-review.component.html', + styleUrls: ['../../../clerk/disbursement-review/disbursement-review.component.scss'], + standalone: true, + imports: [ + CommonModule, + MatDialogModule, + MatIconModule + ] +}) +export class DdsApprovalReviewComponent implements OnInit { + private organizationService = inject(OrganizationService); + private dialogRef = inject(MatDialogRef); + data = inject(MAT_DIALOG_DATA); + + entry: any = this.data.entry; + /** The entry in full, with the debit and credit sides it moves. */ + detail: any = null; + loading = true; + working = false; + confirmReject = false; + done = false; + error = ''; + /** Set by an approval, in case the list row did not carry the number yet. */ + approvedVoucher = ''; + + ngOnInit(): void { + this.load(); + } + + private load(): void { + this.organizationService.getDdsApproval(this.entry.id).subscribe({ + next: (detail: any) => { + this.detail = detail; + this.entry = { ...this.entry, ...detail }; + this.loading = false; + }, + error: () => (this.loading = false) + }); + } + + get voucherNo(): string { + return this.approvedVoucher || this.entry?.voucherNo || ''; + } + + get debitLines(): any[] { + return (this.detail?.lines || []).filter((line: any) => line.side === 'DR'); + } + + get creditLines(): any[] { + return (this.detail?.lines || []).filter((line: any) => line.side === 'CR'); + } + + total(lines: any[]): number { + return lines.reduce((sum: number, line: any) => sum + Number(line.amount || 0), 0); + } + + get isMine(): boolean { + return !!this.data.currentUserId && Number(this.entry.makerId) === Number(this.data.currentUserId); + } + + get canAct(): boolean { + return this.entry.status === 'PENDING' && !this.isMine && !this.done && !this.working; + } + + statusClass(status: string): string { + const map: Record = { + PENDING: 'dri-status--pending', + APPROVED: 'dri-status--approved', + REJECTED: 'dri-status--rejected' + }; + return map[status] ?? ''; + } + + act(command: 'approve' | 'reject'): void { + this.working = true; + this.error = ''; + this.organizationService.actOnDdsApproval(this.entry.id, command).subscribe({ + next: (result: any) => { + this.working = false; + this.done = true; + this.approvedVoucher = result?.voucherNo || ''; + this.entry = { + ...this.entry, + status: command === 'approve' ? 'APPROVED' : 'REJECTED', + voucherCancelled: command === 'reject' && !!this.voucherNo + }; + // The approved entry's sides are now the voucher's own lines. + this.load(); + }, + error: (err: any) => { + this.working = false; + const body = err?.error; + this.error = + body?.errors?.[0]?.defaultUserMessage || + body?.defaultUserMessage || + body?.developerMessage || + 'Could not complete this.'; + } + }); + } + + /** The same form Disbursement & Transfers prints its times in: 29 Sept 2026, 01:25 pm. */ + formatDateTime(val: any): string { + if (!val) { + return '—'; + } + const d = new Date(String(val).replace(' ', 'T')); + return isNaN(d.getTime()) + ? String(val) + : d.toLocaleString('en-IN', { + day: '2-digit', + month: 'short', + year: 'numeric', + hour: '2-digit', + minute: '2-digit' + }); + } + + close(): void { + this.dialogRef.close(this.done); + } +} diff --git a/src/app/deposits/dds-approvals/dds-approvals.component.html b/src/app/deposits/dds-approvals/dds-approvals.component.html new file mode 100644 index 000000000..b8e4016c7 --- /dev/null +++ b/src/app/deposits/dds-approvals/dds-approvals.component.html @@ -0,0 +1,176 @@ + + +
+
+
+ savings +
+
DDS Approvals
+
+ Review, approve or reject DDS entries - transfers, payouts, early closures, receipts applied and postings. +
+
+
+
+ +
+ + + + +
+ @for (p of datePresets; track p.key) { + + } +
+ + + +
+
+ From + + + +
+ — +
+ To + + + +
+
+ + +
+ + @if (loading) { +
+

Loading…

+
+ } @else if (loaded && entries.length === 0) { +
+ +

No DDS entries

+

+ @if (filterStatus === 'PENDING') { + There are no DDS entries waiting for approval. + } @else { + No {{ filterStatus | lowercase }} DDS entries found. + } +

+
+ } @else { +
+ + + + + + + + + + + + + + + + + @for (e of pagedEntries; track e.id) { + + + + + + + + + + + + + } + +
Voucher NoTypeAccount / DetailsAmountEntriesClerkReviewed BySubmitted AtStatusAction
+ {{ e.voucherNo || '—' }} + @if (e.voucherCancelled) { + (cancelled) + } + + {{ e.type }} + + {{ e.details || '—' }} + + {{ e.amount !== null && e.amount !== undefined ? '₹' + (e.amount | number: '1.2-2') : '—' }} + + @if (e.entries) { + {{ e.entries }} account{{ e.entries !== 1 ? 's' : '' }} + } @else { + — + } + + {{ e.makerName || e.maker || '—' }} + @if (isMine(e) && e.status === 'PENDING') { + (you) + } + {{ e.status === 'PENDING' ? '—' : e.checkerName || e.checker || '—' }}{{ formatDateTime(e.madeOn) }} + {{ e.status | titlecase }} + + +
+
+ + + } +
diff --git a/src/app/deposits/dds-approvals/dds-approvals.component.ts b/src/app/deposits/dds-approvals/dds-approvals.component.ts new file mode 100644 index 000000000..1c699ec1c --- /dev/null +++ b/src/app/deposits/dds-approvals/dds-approvals.component.ts @@ -0,0 +1,215 @@ +/** + * Copyright since 2025 Mifos Initiative + * + * This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. + */ + +import { Component, OnInit, inject } from '@angular/core'; +import { CommonModule } from '@angular/common'; +import { FormsModule } from '@angular/forms'; +import { MatIconModule } from '@angular/material/icon'; +import { MatPaginatorModule, PageEvent } from '@angular/material/paginator'; +import { MatDialog } from '@angular/material/dialog'; +import { MatNativeDateModule } from '@angular/material/core'; +import { MatDatepickerModule } from '@angular/material/datepicker'; +import { catchError } from 'rxjs/operators'; +import { of } from 'rxjs'; + +import { OrganizationService } from 'app/organization/organization.service'; +import { SettingsService } from 'app/settings/settings.service'; +import { AuthenticationService } from 'app/core/authentication/authentication.service'; +import { DdsApprovalReviewComponent } from './dds-approval-review/dds-approval-review.component'; + +/** + * Approval Center › DDS Approvals: every DDS entry waiting on a checker, in the clerk's terms - what + * it does, on which accounts, for how much - and, once approved, the voucher it moved under. + * + * Laid out like Disbursement & Transfers beside it, and sharing its styles, so the two queues read the + * same way. The entries are the platform's own maker-checker entries; approving one here carries it out. + */ +@Component({ + selector: 'mifosx-dds-approvals', + templateUrl: './dds-approvals.component.html', + styleUrls: ['../../tasks/checker-inbox-and-tasks-tabs/disbursement-requests/disbursement-requests.component.scss'], + standalone: true, + imports: [ + CommonModule, + FormsModule, + MatIconModule, + MatPaginatorModule, + MatNativeDateModule, + MatDatepickerModule + ] +}) +export class DdsApprovalsComponent implements OnInit { + private organizationService = inject(OrganizationService); + private settingsService = inject(SettingsService); + private authenticationService = inject(AuthenticationService); + private dialog = inject(MatDialog); + + entries: any[] = []; + pageSize = 20; + pageIndex = 0; + readonly pageSizeOptions = [ + 10, + 20, + 50 + ]; + + /** The page of entries on screen. */ + get pagedEntries(): any[] { + const start = this.pageIndex * this.pageSize; + return this.entries.slice(start, start + this.pageSize); + } + + onPageChange(event: PageEvent): void { + this.pageSize = event.pageSize; + this.pageIndex = event.pageIndex; + } + filterStatus = 'PENDING'; + fromDate: Date | null = null; + toDate: Date | null = null; + maxDate!: Date; + activePreset = 'today'; + loading = false; + loaded = false; + + readonly currentUserId = this.authenticationService.getCredentials()?.userId; + + readonly statusOptions = [ + { value: 'PENDING', label: 'Pending' }, + { value: 'APPROVED', label: 'Approved' }, + { value: 'REJECTED', label: 'Rejected' } + ]; + + readonly datePresets = [ + { key: 'today', label: 'Today' }, + { key: 'week', label: 'This Week' }, + { key: 'month', label: 'This Month' }, + { key: 'all', label: 'All' } + ]; + + ngOnInit(): void { + const today = new Date(this.settingsService.businessDate || new Date()); + today.setHours(0, 0, 0, 0); + this.maxDate = today; + this.applyPreset('today'); + } + + load(): void { + this.loading = true; + this.organizationService + .getDdsApprovals(this.filterStatus, this.isoDate(this.fromDate), this.isoDate(this.toDate)) + .pipe(catchError(() => of([]))) + .subscribe((rows: any) => { + this.entries = Array.isArray(rows) ? rows : []; + this.pageIndex = 0; + this.loading = false; + this.loaded = true; + }); + } + + onFilterChange(): void { + this.load(); + } + + onFromDateChange(): void { + this.activePreset = ''; + if (this.fromDate && this.toDate && this.fromDate > this.toDate) { + this.toDate = new Date(this.fromDate); + } + this.load(); + } + + onToDateChange(): void { + this.activePreset = ''; + this.load(); + } + + applyPreset(key: string): void { + this.activePreset = key; + const today = new Date(this.settingsService.businessDate || new Date()); + today.setHours(0, 0, 0, 0); + if (key === 'today') { + this.fromDate = new Date(today); + this.toDate = new Date(today); + } else if (key === 'week') { + const start = new Date(today); + start.setDate(today.getDate() - today.getDay()); + this.fromDate = start; + this.toDate = new Date(today); + } else if (key === 'month') { + this.fromDate = new Date(today.getFullYear(), today.getMonth(), 1); + this.toDate = new Date(today); + } else { + this.fromDate = null; + this.toDate = null; + } + this.load(); + } + + review(entry: any): void { + this.dialog + .open(DdsApprovalReviewComponent, { data: { entry, currentUserId: this.currentUserId }, autoFocus: false }) + .afterClosed() + // However the dialog was closed - its Close button, a click outside, Esc - the list is read + // again, so an entry just approved or rejected leaves the Pending list at once. + .subscribe(() => this.load()); + } + + /** Whose entry it is - a checker cannot pass their own. */ + isMine(entry: any): boolean { + return !!this.currentUserId && Number(entry.makerId) === Number(this.currentUserId); + } + + statusClass(status: string): string { + const map: Record = { + PENDING: 'wri-status--pending', + APPROVED: 'wri-status--approved', + REJECTED: 'wri-status--rejected' + }; + return map[status] ?? ''; + } + + typeColor(entry: any): string { + const map: Record = { + Transfer: '#e3f2fd', + 'Apply Receipt': '#fce4ec' + }; + if (String(entry.type).startsWith('Premature')) { + return '#fff3e0'; + } + if (String(entry.type).startsWith('Maturity')) { + return '#e8f5e9'; + } + if (String(entry.type).startsWith('Post')) { + return '#ede7f6'; + } + return map[entry.type] ?? '#f5f5f5'; + } + + formatDateTime(val: any): string { + if (!val) { + return '—'; + } + const d = new Date(String(val).replace(' ', 'T')); + return isNaN(d.getTime()) + ? String(val) + : d.toLocaleString('en-IN', { + day: '2-digit', + month: 'short', + year: 'numeric', + hour: '2-digit', + minute: '2-digit' + }); + } + + private isoDate(d: Date | null): string | undefined { + if (!d) { + return undefined; + } + return `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, '0')}-${String(d.getDate()).padStart(2, '0')}`; + } +} diff --git a/src/app/deposits/dds-reports/dds-reports.component.html b/src/app/deposits/dds-reports/dds-reports.component.html new file mode 100644 index 000000000..befe2fe1f --- /dev/null +++ b/src/app/deposits/dds-reports/dds-reports.component.html @@ -0,0 +1,33 @@ + + +
+
+ +

DDS Reports

+
+ + @for (group of reportGroups; track group.title) { +
+ } +
diff --git a/src/app/deposits/dds-reports/dds-reports.component.scss b/src/app/deposits/dds-reports/dds-reports.component.scss new file mode 100644 index 000000000..56830830a --- /dev/null +++ b/src/app/deposits/dds-reports/dds-reports.component.scss @@ -0,0 +1,97 @@ +/** + * Copyright since 2025 Mifos Initiative + * + * This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. + */ + +$primary: #0f766e; + +.dr-container { + max-width: 1080px; + margin: 0 auto; + padding: 24px 16px; +} + +.dr-header { + display: flex; + align-items: center; + gap: 16px; + margin-bottom: 24px; + + &__title { + margin: 0; + font-size: 22px; + font-weight: 500; + } +} + +.dr-back { + color: $primary; +} + +.dr-group { + margin-bottom: 24px; + + &__title { + font-size: 13px; + text-transform: uppercase; + letter-spacing: 0.05em; + color: $primary; + font-weight: 700; + margin-bottom: 10px; + } +} + +.dr-grid { + display: grid; + grid-template-columns: repeat(3, 1fr); + gap: 12px; +} + +.dr-card { + display: flex; + align-items: center; + gap: 12px; + background: var(--md-sys-color-surface, #fff); + border: 1px solid rgb(0 0 0 / 8%); + border-radius: 8px; + padding: 12px 14px; + text-decoration: none; + color: inherit; + transition: box-shadow 0.15s ease; + + &:hover { + box-shadow: 0 2px 8px rgb(0 0 0 / 10%); + } + + &__icon { + color: $primary; + font-size: 16px; + flex: none; + } + + &__title { + flex: 1; + font-size: 13px; + font-weight: 500; + } + + &__arrow { + color: rgb(0 0 0 / 30%); + font-size: 12px; + } +} + +@media (width <= 900px) { + .dr-grid { + grid-template-columns: repeat(2, 1fr); + } +} + +@media (width <= 600px) { + .dr-grid { + grid-template-columns: 1fr; + } +} diff --git a/src/app/deposits/dds-reports/dds-reports.component.ts b/src/app/deposits/dds-reports/dds-reports.component.ts new file mode 100644 index 000000000..63af55d01 --- /dev/null +++ b/src/app/deposits/dds-reports/dds-reports.component.ts @@ -0,0 +1,73 @@ +/** + * Copyright since 2025 Mifos Initiative + * + * This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. + */ + +/** Angular Imports */ +import { Component } from '@angular/core'; +import { STANDALONE_SHARED_IMPORTS } from 'app/standalone-shared.module'; + +/** + * DDS Reports hub component. + * + * Lists every DDS report from the legacy Reports menu, grouped the same + * way (As-On-Date, Interest, Daily/Balance/Master, Transaction). Each + * links to the shared report viewer, parameterised by report name. + * + * UI only for now: no report actually queries the backend yet — see + * ReportViewerComponent. + */ +@Component({ + selector: 'mifosx-dds-reports', + templateUrl: './dds-reports.component.html', + styleUrls: ['./dds-reports.component.scss'], + imports: [...STANDALONE_SHARED_IMPORTS] +}) +export class DdsReportsComponent { + reportGroups = [ + { + title: 'As On Date', + reports: [ + 'Supplementary-DDS (As On)', + 'Agent Check List', + 'Party Check List-As On', + 'Party Balance', + 'Agent Wise Balance', + 'Agent Wise Monthly Collection', + 'Commission Register', + 'Collection Summary Agent Wise', + 'Close Party Ac Register', + 'Agent Partywise Daily Collection', + 'Daily Collection Balance', + 'Interest Certificate', + 'Mature Date Wise Report' + ] + }, + { + title: 'Interest', + reports: [ + 'Party Int. Register', + 'Commission Register' + ] + }, + { + title: 'Daily / Balance / Master', + reports: [ + 'Daily Collection Report', + 'Balance Summary', + 'Master Data Export' + ] + }, + { + title: 'Transaction', + reports: ['Transaction Register'] + }, + { + title: 'Statements', + reports: ['Agent Commission Statement'] + } + ]; +} diff --git a/src/app/deposits/dds-reports/report-viewer/report-viewer.component.html b/src/app/deposits/dds-reports/report-viewer/report-viewer.component.html new file mode 100644 index 000000000..28d38a424 --- /dev/null +++ b/src/app/deposits/dds-reports/report-viewer/report-viewer.component.html @@ -0,0 +1,146 @@ + + +
+
+ +

{{ reportName }}

+
+ +
+
+ + @if (!hasNoFilters) { +
+ @if (usesDate) { + + {{ dateLabel }} — from + + + + + + + {{ dateLabel }} — to + + + + + } + + @if (usesAgent) { + + Agent From + + + + + Agent To + + + } + + @if (usesParty) { + + Party From + + + + + Party To + + + } +
+ } + +
+ + + +
+ + @if (hasRun) { +
+ @if (!available) { +

{{ unavailableMessage }}

+ } @else if (rows.length === 0) { +

No rows matched these filters.

+ } @else { + +
+
+

{{ reportName }}

+ @if (usesDate) { +

+ {{ dateLabel | lowercase }} {{ ranFromDate | dateFormat }} and + {{ ranToDate | dateFormat }} +

+ } @else { +

as on {{ ranOnDate | dateFormat }}

+ } + +
+ +
+ + + + + @for (column of columns; track column) { + + } + + + + @for (row of rows; track $index) { + + + @for (column of columns; track column) { + + } + + } + + @if (hasTotalledColumn) { + + + + @for (column of columns; track column; let first = $first) { + + } + + + } +
#{{ column }}
{{ $index + 1 }}{{ cell(row, column) }}
+ @if (first) { + Total : + } @else if (isTotalledColumn(column)) { + {{ columnTotal(column) }} + } +
+
+ +

{{ rows.length }} {{ rows.length === 1 ? 'row' : 'rows' }}

+
+ } +
+ } +
+
+
diff --git a/src/app/deposits/dds-reports/report-viewer/report-viewer.component.scss b/src/app/deposits/dds-reports/report-viewer/report-viewer.component.scss new file mode 100644 index 000000000..5fd19d364 --- /dev/null +++ b/src/app/deposits/dds-reports/report-viewer/report-viewer.component.scss @@ -0,0 +1,190 @@ +/** + * Copyright since 2025 Mifos Initiative + * + * This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. + */ + +$primary: #0f766e; + +.rv-container { + max-width: 1100px; + margin: 0 auto; + padding: 24px 16px; +} + +.rv-header { + display: flex; + align-items: center; + gap: 16px; + margin-bottom: 20px; + + &__title { + margin: 0; + font-size: 22px; + font-weight: 500; + } +} + +.rv-back { + color: $primary; +} + +.rv-card { + background: var(--md-sys-color-surface, #fff); + border-radius: 8px; + box-shadow: 0 2px 8px rgb(0 0 0 / 8%); + padding: 20px 24px 24px; +} + +.rv-grid { + display: grid; + grid-template-columns: repeat(3, 1fr); + gap: 8px 16px; +} + +.rv-field { + width: 100%; +} + +.rv-action-bar { + display: flex; + justify-content: flex-end; + gap: 12px; + margin-top: 20px; + padding-top: 16px; + border-top: 1px solid rgb(0 0 0 / 8%); +} + +.rv-results { + margin-top: 20px; + padding-top: 16px; + border-top: 1px solid rgb(0 0 0 / 8%); +} + +.rv-empty { + color: rgb(0 0 0 / 50%); + font-size: 14px; + margin: 0; + text-align: center; + padding: 24px 0; +} + +.rv-table-scroll { + overflow-x: auto; +} + +.rv-table { + width: 100%; + border-collapse: collapse; + white-space: nowrap; + + th { + text-align: left; + font-size: 13px; + text-transform: uppercase; + letter-spacing: 0.04em; + color: rgb(0 0 0 / 55%); + padding: 8px 10px; + border-bottom: 2px solid rgb(0 0 0 / 10%); + } + + td { + padding: 8px 10px; + border-bottom: 1px solid rgb(0 0 0 / 6%); + } + + /* Figures line up column-wise so a wrong order of magnitude is visible at a glance. */ + .rv-num { + text-align: right; + font-variant-numeric: tabular-nums; + } + + /* The row number down the left, as the printed statement carried it. */ + .rv-sr { + width: 1%; + color: rgb(0 0 0 / 45%); + font-style: italic; + font-variant-numeric: tabular-nums; + } + + tfoot td { + border-bottom: none; + border-top: 2px solid rgb(0 0 0 / 20%); + font-weight: 600; + padding-top: 10px; + } +} + +/* ---- the statement block: heading, table, footer count ---- */ + +.rv-action-bar i { + margin-right: 6px; +} + +.rv-statement__head { + display: flex; + align-items: baseline; + flex-wrap: wrap; + gap: 4px 12px; + margin-bottom: 14px; +} + +.rv-statement__title { + margin: 0; + font-size: 17px; + font-weight: 600; + flex: 0 0 auto; +} + +.rv-statement__period { + margin: 0; + font-size: 13px; + color: rgb(0 0 0 / 55%); +} + +.rv-print { + margin-left: auto; + + i { + margin-right: 6px; + } +} + +.rv-statement__count { + margin: 10px 0 0; + font-size: 12px; + color: rgb(0 0 0 / 45%); + text-align: right; +} + +/* Printing a statement should give the statement, not the screen around it. */ +@media print { + .rv-header, + .rv-grid, + .rv-action-bar, + .rv-print { + display: none !important; + } + + .rv-card { + box-shadow: none; + padding: 0; + } + + .rv-table-scroll { + overflow-x: visible; + } + + .rv-table { + white-space: normal; + font-size: 11px; + } +} + +@media (width <= 900px) { + .rv-grid { + grid-template-columns: repeat(2, 1fr); + } +} diff --git a/src/app/deposits/dds-reports/report-viewer/report-viewer.component.ts b/src/app/deposits/dds-reports/report-viewer/report-viewer.component.ts new file mode 100644 index 000000000..6bcf14dcc --- /dev/null +++ b/src/app/deposits/dds-reports/report-viewer/report-viewer.component.ts @@ -0,0 +1,344 @@ +/** + * Copyright since 2025 Mifos Initiative + * + * This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. + */ + +/** Angular Imports */ +import { Component, OnInit, inject } from '@angular/core'; +import { ActivatedRoute, Router } from '@angular/router'; +import { UntypedFormBuilder, UntypedFormGroup, Validators } from '@angular/forms'; +import { STANDALONE_SHARED_IMPORTS } from 'app/standalone-shared.module'; + +/** Custom Services */ +import { OrganizationService } from 'app/organization/organization.service'; +import { Dates } from 'app/core/utils/dates'; + +/** + * Generic DDS report viewer. + * + * One shared screen for all 13+ DDS reports — the report name comes from + * the route and is mapped to a backend report key. Every report in the + * legacy system shared the same basic filter shape (date, agent range, + * party range), so this avoids building a bespoke screen per report - the + * backend returns a generic {columns, rows} shape this renders as one + * table regardless of which report it is. + * + * One legacy report (Close Party Ac Register) has no corresponding data in + * the backend yet - Party accounts have no closed/open status at all. Run + * Report still calls through, and the backend's `available: false` + + * message is shown instead of a table. + */ +@Component({ + selector: 'mifosx-dds-report-viewer', + templateUrl: './report-viewer.component.html', + styleUrls: ['./report-viewer.component.scss'], + imports: [...STANDALONE_SHARED_IMPORTS] +}) +export class ReportViewerComponent implements OnInit { + private route = inject(ActivatedRoute); + private router = inject(Router); + private formBuilder = inject(UntypedFormBuilder); + private organizationService = inject(OrganizationService); + private dateUtils = inject(Dates); + + private static readonly REPORT_KEYS: Record = { + 'Supplementary-DDS (As On)': 'SUPPLEMENTARY_DDS', + 'Agent Check List': 'AGENT_CHECK_LIST', + 'Party Check List-As On': 'PARTY_CHECK_LIST', + 'Party Balance': 'PARTY_BALANCE', + 'Agent Wise Balance': 'AGENT_WISE_BALANCE', + 'Agent Wise Monthly Collection': 'AGENT_WISE_MONTHLY_COLLECTION', + 'Commission Register': 'COMMISSION_REGISTER', + 'Collection Summary Agent Wise': 'COLLECTION_SUMMARY_AGENT_WISE', + 'Close Party Ac Register': 'CLOSE_PARTY_AC_REGISTER', + 'Agent Partywise Daily Collection': 'AGENT_PARTYWISE_DAILY_COLLECTION', + 'Daily Collection Balance': 'DAILY_COLLECTION_REPORT', + 'Interest Certificate': 'INTEREST_CERTIFICATE', + 'Mature Date Wise Report': 'MATURE_DATE_WISE', + 'Party Int. Register': 'PARTY_INTEREST_REGISTER', + 'Daily Collection Report': 'DAILY_COLLECTION_REPORT', + 'Balance Summary': 'BALANCE_SUMMARY', + 'Master Data Export': 'MASTER_DATA_EXPORT', + 'Transaction Register': 'TRANSACTION_REGISTER', + 'Agent Commission Statement': 'AGENT_COMMISSION_STATEMENT' + }; + + /** + * Which filters each report actually uses, and what its date range means. + * + * Every report used to be offered the same six filters whether it read them or not — so a date + * range could be typed on the Agent Check List, which has no date in its query at all, and the + * same rows would come back however it was set. A filter that changes nothing is worse than no + * filter: it reads as a report that ignores what it was asked. + * + * Kept in step with the backend by hand; the source of truth is the switch in + * `DdsReportReadPlatformServiceImpl.runReport`. + */ + private static readonly REPORT_FILTERS: Record = { + AGENT_CHECK_LIST: {}, + PARTY_CHECK_LIST: { agent: true, party: true }, + PARTY_BALANCE: { agent: true, party: true }, + AGENT_WISE_BALANCE: { agent: true }, + BALANCE_SUMMARY: { agent: true, party: true }, + MASTER_DATA_EXPORT: { agent: true, party: true }, + + AGENT_WISE_MONTHLY_COLLECTION: { date: 'Collected between', agent: true }, + COLLECTION_SUMMARY_AGENT_WISE: { date: 'Collected between', agent: true }, + DAILY_COLLECTION_REPORT: { date: 'Collected between', agent: true }, + AGENT_PARTYWISE_DAILY_COLLECTION: { date: 'Collected between', agent: true, party: true }, + SUPPLEMENTARY_DDS: { date: 'Collected between', agent: true, party: true }, + + COMMISSION_REGISTER: { date: 'Posted between', agent: true }, + AGENT_COMMISSION_STATEMENT: { date: 'Posted between' }, + PARTY_INTEREST_REGISTER: { date: 'Posted between', party: true }, + INTEREST_CERTIFICATE: { date: 'Interest earned between', agent: true, party: true }, + + TRANSACTION_REGISTER: { date: 'Transacted between', agent: true, party: true }, + CLOSE_PARTY_AC_REGISTER: { date: 'Closed between', agent: true, party: true }, + MATURE_DATE_WISE: { date: 'Maturing between', agent: true, party: true } + }; + + reportName: string = this.route.snapshot.paramMap.get('reportName') || 'Report'; + + hasRun = false; + available = true; + unavailableMessage = ''; + columns: string[] = []; + rows: Array> = []; + + /** The range the rows on screen were actually fetched for — not whatever the filter says now. */ + ranFromDate: Date | null = null; + ranToDate: Date | null = null; + + /** When a report carries no date range, the moment it was run is what dates its figures. */ + ranOnDate: Date | null = null; + + private static readonly MONEY = new Intl.NumberFormat('en-IN', { + minimumFractionDigits: 2, + maximumFractionDigits: 2 + }); + + private static readonly DATE = new Intl.DateTimeFormat('en-GB', { + day: '2-digit', + month: '2-digit', + year: 'numeric' + }); + + /** + * One cell, rendered the way a printed statement shows it. + * + * Dates arrive from the platform as `[yyyy, m, d]` and numbers as raw decimals, so printing them + * as they come gives "2026,9,2" and "10052.6" — unreadable on a statement someone checks figures + * against. Amounts get Indian grouping and two decimals; identifiers that merely look numeric + * (account numbers, codes) are left alone, since grouping them would corrupt them. + */ + cell(row: Record, column: string): string { + const value = row[column]; + + if (value === null || value === undefined || value === '') { + return '—'; + } + if (Array.isArray(value) && value.length >= 3) { + return ReportViewerComponent.DATE.format(new Date(value[0], value[1] - 1, value[2])); + } + if (this.isAmountColumn(column) && typeof value === 'number') { + return ReportViewerComponent.MONEY.format(value); + } + return String(value); + } + + /** + * Whether a column holds money, and so should be right-aligned with two decimals. + * + * Decided from the column's name rather than its values: a count column and an account-number + * column are both numbers, and neither reads as an amount. + */ + isAmountColumn(column: string): boolean { + const name = column.toLowerCase(); + + // Ruled out first, because several of these also contain a money word: a rate is a percentage, + // "Total Accounts" is a count that happens to say total, and an account number is an identifier + // that would be corrupted by grouping. + // + // "agents" is matched exactly rather than as a substring: the two count columns are named + // "Total Agents" and "Active Agents", but "Cash With Agents" and "Commission Due To Agents" + // are money, and a substring test swept those two up and printed them ungrouped. + // + // A "type" column names a kind, never a quantity — "Interest Type" holds the word "Interest", + // which is enough for the money test below to claim it and put a meaningless total under it. + if ( + name.includes('a/c no') || + name.includes('code') || + name.includes('rate') || + name.includes('%') || + name.includes('count') || + name.includes('entries') || + name.includes('accounts') || + name === 'total agents' || + name === 'active agents' || + name.includes('type') || + name.includes('pan') + ) { + return false; + } + + return ( + name.includes('amount') || + name.includes('balance') || + name.includes('collect') || + name.includes('commission') || + name.includes('deduction') || + name.includes('deposit') || + name.includes('advance') || + name.includes('interest') || + name.includes('tds') || + name.includes('total') || + name.includes('diff') || + name.includes('cash') || + name.includes('debit') || + name.includes('credit') || + name.includes('settled') || + name.includes('shortfall') || + name.includes('due') + ); + } + + /** + * Whether a total under this column would mean anything. + * + * Not every amount is worth adding up. A "Balance After" on a transaction register is the running + * balance *at that moment*, so its column holds several snapshots of the same account at different + * times — summing them produces a figure that corresponds to nothing, and a reader would sooner or + * later try to reconcile it against something. + * + * A plain balance on a listing report is different: there each row is one account's position at + * one moment, and the sum is the real "total held", which is exactly what a branch wants to see. + */ + isTotalledColumn(column: string): boolean { + if (!this.isAmountColumn(column)) { + return false; + } + return !column.toLowerCase().includes('balance after'); + } + + /** The figure printed under a column, as the legacy statement's "Total :" row does. */ + columnTotal(column: string): string { + const sum = this.rows.reduce((running, row) => { + const value = row[column]; + return running + (typeof value === 'number' ? value : 0); + }, 0); + return ReportViewerComponent.MONEY.format(sum); + } + + /** + * Whether to print a Total row at all. + * + * A single-row report is already its own total - Balance Summary is one line of figures for the + * whole scheme, and repeating it underneath itself only invites the reader to look for a difference + * between the two. + */ + get hasTotalledColumn(): boolean { + return this.rows.length > 1 && this.columns.some((column) => this.isTotalledColumn(column)); + } + + print(): void { + window.print(); + } + + filterForm: UntypedFormGroup = this.formBuilder.group({ + fromDate: [''], + toDate: [''], + agentFrom: [1], + agentTo: [99999], + partyFrom: [1], + partyTo: [99999999] + }); + + private get filters(): { date?: string; agent?: boolean; party?: boolean } { + return ReportViewerComponent.REPORT_FILTERS[ReportViewerComponent.REPORT_KEYS[this.reportName]] ?? {}; + } + + /** True when this report's query actually reads a date range - only then is one asked for. */ + get usesDate(): boolean { + return !!this.filters.date; + } + + /** What this report's date range means, e.g. "Posted between" rather than a bare "From Date". */ + get dateLabel(): string { + return this.filters.date ?? ''; + } + + get usesAgent(): boolean { + return !!this.filters.agent; + } + + get usesParty(): boolean { + return !!this.filters.party; + } + + /** True when this report takes no filters at all, so there is nothing for anyone to fill in. */ + get hasNoFilters(): boolean { + return !this.usesDate && !this.usesAgent && !this.usesParty; + } + + ngOnInit(): void { + // A date is required exactly for the reports that read one, so a report without dates can be run + // straight away instead of demanding two values it will ignore. + if (this.usesDate) { + this.filterForm.get('fromDate')?.setValidators([Validators.required]); + this.filterForm.get('toDate')?.setValidators([Validators.required]); + this.filterForm.get('fromDate')?.updateValueAndValidity(); + this.filterForm.get('toDate')?.updateValueAndValidity(); + } + + // Nothing to narrow it by means nothing to decide, so the report simply opens showing its + // figures. Making someone press Run on an empty form asks a question with only one answer. + if (this.hasNoFilters) { + this.runReport(); + } + } + + runReport(): void { + const reportKey = ReportViewerComponent.REPORT_KEYS[this.reportName]; + if (!reportKey) { + return; + } + const raw = this.filterForm.getRawValue(); + + // Only what this report reads is sent. The backend defaults the rest to the full range, so a + // report that ignores a filter is never handed a value that looks like it was applied. + const params: Record = {}; + if (this.usesDate) { + params['fromDate'] = this.dateUtils.getDate(raw.fromDate); + params['toDate'] = this.dateUtils.getDate(raw.toDate); + } + if (this.usesAgent) { + params['agentFrom'] = raw.agentFrom; + params['agentTo'] = raw.agentTo; + } + if (this.usesParty) { + params['partyFrom'] = raw.partyFrom; + params['partyTo'] = raw.partyTo; + } + + this.organizationService.runDdsReport(reportKey, params).subscribe((result: any) => { + this.available = result.available; + this.unavailableMessage = result.message || ''; + this.columns = result.columns || []; + this.rows = result.rows || []; + // Captured here so the printed heading names the range these rows came from, and does not + // silently change if the filter is edited without re-running. + this.ranFromDate = raw.fromDate; + this.ranToDate = raw.toDate; + this.ranOnDate = new Date(); + this.hasRun = true; + }); + } + + exit(): void { + this.router.navigate(['../../'], { relativeTo: this.route }); + } +} diff --git a/src/app/deposits/party-master/client-search.ts b/src/app/deposits/party-master/client-search.ts new file mode 100644 index 000000000..a898f8590 --- /dev/null +++ b/src/app/deposits/party-master/client-search.ts @@ -0,0 +1,49 @@ +/** + * Copyright since 2025 Mifos Initiative + * + * This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. + */ + +import { Observable, forkJoin, of } from 'rxjs'; +import { catchError, map } from 'rxjs/operators'; +import { ClientsService } from 'app/clients/clients.service'; + +/** + * Finds clients by name whatever case the officer types it in. + * + * The platform's name filter matches anywhere in the name but only in the exact case typed, so + * "sneha" finds nobody while "Sneha" finds Ms. Sneha Iyer. Names are stored capitalised, so the term + * is tried as typed, capitalised word by word, and in upper and lower case, and the results merged. + * Done here rather than by changing the platform's search, which every other screen relies on. + */ +export function searchClientsAnyCase(clientsService: ClientsService, term: string, officeId?: any): Observable { + const variants = Array.from( + new Set([ + term, + term.toLowerCase().replace(/(^|[\s.])(\p{L})/gu, (_match, lead, letter) => lead + letter.toUpperCase()), + term.toUpperCase(), + term.toLowerCase() + ]) + ); + return forkJoin( + variants.map((variant) => + clientsService.getFilteredClients('displayName', 'ASC', false, variant, officeId).pipe( + map((response: any) => { + const found = response?.pageItems || response || []; + return Array.isArray(found) ? found : []; + }), + catchError(() => of([])) + ) + ) + ).pipe( + map((lists: any[][]) => { + const byId = new Map(); + for (const client of lists.flat()) { + byId.set(client.id, client); + } + return Array.from(byId.values()).sort((a, b) => String(a.displayName).localeCompare(String(b.displayName))); + }) + ); +} diff --git a/src/app/deposits/party-master/create-party/create-party.component.html b/src/app/deposits/party-master/create-party/create-party.component.html new file mode 100644 index 000000000..45f765ff1 --- /dev/null +++ b/src/app/deposits/party-master/create-party/create-party.component.html @@ -0,0 +1,1051 @@ + + +
+
+ +

{{ partyId ? 'Edit Party' : 'Party Master Entry' }}

+ @if (isClosed) { + Closed — view only + } +
+ +
+
+ + +
+
+
+ +

Customer & Scheme

+ Who this account is for, which scheme it runs under, and which agent collects for it +
+
+
+ + + + + + @for (client of clientOptions; track client.id) { + +
+ {{ client.id }} + {{ client.displayName }} + {{ client.officeName }} +
+
+ } + + @if (clientListIsPartial) { + + Showing {{ clientOptions.length }} of {{ clientTotal }} — type a name to find the rest + + } +
+ @if (loadedClientName) { + {{ loadedClientName }} + } @else if (clientLookupFailed) { + No client with this ID + } +
+
+ +
+ + + + + + @for (type of subTypeOptions; track type.id) { + +
+ {{ type.accountTypeCode }} + {{ type.accountTypeDescription }} + {{ type.accountTypeGroup }} +
+
+ } + @if (subTypeOptions.length === 0) { + No scheme matches that. + } +
+ @if (subTypes.length === 0) { + No active account type — create one first + } +
+
+ +
+ + + + + + @for (agent of agentOptions; track agent.id) { + +
+ {{ agent.agentCode }} + {{ agent.agentNameId }} + + + {{ agent.active ? agent.homeBranch : 'not collecting' }} + +
+
+ } + @if (agentOptions.length === 0) { + No agent matches that. + } +
+ @if (loadedAgentName && !agentLookupFailed) { + {{ loadedAgentName }} + } @else if (loadedAgentName && agentLookupFailed) { + {{ loadedAgentName }} is deactivated + } @else if (agentLookupFailed) { + No agent with this ID + } +
+
+ + +
+ + + + @for (office of offices; track office.id) { + {{ office.name }} + } + + @if (officesError) { + {{ officesError }} + } @else { + The branch this account belongs to + } + +
+ + + @if (accountNo) { + +
+ + +
+ } @else if (savingsAccountNo) { +
+ + +
+ } @else if (partyId) { +
+ + +
+ } @else { +
+ + +
+ } + +
+ + + + +
+ +
+ + + + +
+ +
+ + + + Yes + No + + +
+ +
+ + + + +
+ +
+ + + + +
+ +
+ + + + +
+ +
+ + + + +
+ +
+ + + + +
+ +
+ + + + +
+ +
+ + + + +
+ +
+ + + + Male + Female + Other + + +
+ +
+ + + + +
+ +
+ + + + +
+ +
+ + + + +
+ +
+ + + + +
+ +
+ + + + +
+ +
+ + + + @for (op of operations; track op) { + {{ op }} + } + + +
+ +
+ + + + + + +
+ +
+ + + + @for (occ of occupations; track occ) { + {{ occ }} + } + + +
+ +
+ + + + @for (r of riskLevels; track r) { + {{ r }} + } + + +
+
+
+ +
+
+ +

Minor / Guardian

+ Only for a minor's account — the guardian who operates it until they come of age +
+
+
+ + + + @for (opt of adultMinorOptions; track opt) { + {{ opt }} + } + + +
+ + @if (isMinorAccount) { +
+ + + + + + Turns 18 on + +
+ +
+ + + + + + @for (client of guardianOptions; track client.id) { + +
+ {{ client.id }} + {{ client.displayName }} + {{ client.officeName }} +
+
+ } +
+ @if (loadedGuardianName) { + {{ loadedGuardianName }} + } +
+
+ +
+ + + + +
+ +
+ + + + To the minor + +
+ } +
+ @if (!isMinorAccount) { +

An adult's account needs no guardian details.

+ } +
+ +
+
+ +

Scheme Terms

+ How much is collected daily, for how long, and what it matures to +
+
+
+ + + + + + + +
+ +
+ + + + +
+ +
+ + + + + Calculated + +
+ +
+ + + + + + +
+ +
+ + + + +
+ +
+ + + + +
+ +
+ + + + @if (partyForm.get('days')?.hasError('exceedsTerm')) { + Only {{ termDays }} days in this term + } @else if (termDays) { + Term is {{ termDays }} days + } + +
+ +
+ + + + Collection book no. + +
+ +
+ + + + Calculated + +
+ + + +
+
+ +
+
+ +

Balances & Status

+ Kept by collections and the nightly batch — shown here, never typed +
+
+
+ OD1 Flag +
+
+ OD2 Flag +
+ +
+ + + + Yes + No + + @if (lienHeld) { + Nothing can leave this account until it is released + } + +
+ + + @if (lienHeld) { +
+ + + + @for (reason of lienReasons; track reason) { + {{ reason }} + } + + What this money is being held for + +
+ + + @if (lienReasonIsOther) { +
+ + + + Write the reason in your own words + +
+ } + } + +
+ + + + + + +
+ +
+ + + + +
+ +
+ + + + +
+ +
+ + + + +
+ +
+ Is SMS Active +
+
+ Is Email Active +
+ +
+ + + + +
+ +
+ + + + + + +
+ +
+ + + + +
+ +
+ + + + @for (code of freezeCodes; track code) { + {{ code }} + } + + +
+ +
+
+ + @if (creditToRequired) { +
+
+ +

Maturity Payout Routing

+
+

Transfer To Saving — say where the maturity proceeds should land.

+
+
+ + + + @for (office of offices; track office.id) { + {{ office.name }} + } + + @if (officesError) { + {{ officesError }} + } + +
+ + +
+ + + + @for (account of creditToAccounts; track account.id) { + + {{ account.accountNo }} · {{ account.productName }} + + } + + @if (creditToAccounts.length === 0) { + + {{ loadedClientName || 'This customer' }} has no other savings account to credit + + } + +
+ + +
+ + + + +
+
+
+ } +
+
+ + +
+
+
+
+ + + + Yes + No + + +
+ + @if (nomineesRequired) { + + Percentage + Sequence + + + + + @if (splitByPercentage && nominees.length > 0) { + + } @else if (splitBySequence && nominees.length > 0) { + + } + } +
+ + @if (!nomineesRequired) { +

Set Nominee to Yes to nominate someone for this account.

+ } + + @for (nominee of nominees.controls; track nominee; let i = $index) { +
+
+ + + + + + +
+
+ + + + +
+
+ + + + +
+
+ + + + + + +
+
+ + + + + From date of birth + +
+
+ + + + +
+
+ + + + Male + Female + Other + + +
+
+ + + + @if (isNomineeMinor(nominee)) { + Required for a minor + } + +
+ @if (splitByPercentage) { +
+ + + + +
+ } @else { +
+ + + + +
+ } + +
+ } +
+
+
+
+ + @if (isClosed) { +
+ + This account is closed. No further collections or edits are possible. +
+ } + + + @if (minorTurnedMajor) { +
+ + + This account was opened for a minor who came of age on + {{ majorOn | dateFormat }}. The guardian can no longer operate it — take fresh KYC, a signature and a new mandate from the account + holder. + +
+ } + +
+ + + + @if (partyId && !isClosed && canCloseEarly) { + + } + @if (partyId && !isClosed) { + + } + +
+
+
+
diff --git a/src/app/deposits/party-master/create-party/create-party.component.scss b/src/app/deposits/party-master/create-party/create-party.component.scss new file mode 100644 index 000000000..fd737f278 --- /dev/null +++ b/src/app/deposits/party-master/create-party/create-party.component.scss @@ -0,0 +1,528 @@ +/** + * Copyright since 2025 Mifos Initiative + * + * This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. + */ + +$primary: #0f766e; + +.pm-container { + // Wider than the usual form page: the extra width is what keeps this many fields off a + // second screen. + max-width: 1280px; + margin: 0 auto; + padding: 12px 16px 20px; +} + +.pm-header { + display: flex; + align-items: center; + gap: 14px; + margin-bottom: 10px; + + &__title { + margin: 0; + font-size: 18px; + font-weight: 500; + } +} + +.pm-back { + color: $primary; +} + +.pm-card { + background: var(--md-sys-color-surface, #fff); + border-radius: 8px; + box-shadow: 0 2px 8px rgb(0 0 0 / 8%); +} + +.pm-tabs { + ::ng-deep .mat-mdc-tab-body-wrapper { + min-height: 300px; + } +} + +.pm-tab-content { + padding: 20px 24px; +} + +.pm-section { + padding: 16px 0; + border-bottom: 1px solid rgb(0 0 0 / 8%); + + &:last-of-type { + border-bottom: none; + } + + &__header { + display: flex; + align-items: center; + // The subtitle sits beside the title where there is room and drops below it where there is not. + flex-wrap: wrap; + gap: 2px 10px; + margin-bottom: 16px; + + i { + color: $primary; + } + + h3 { + margin: 0; + font-size: 16px; + font-weight: 600; + // Held at its natural width: letting it shrink breaks a two-word heading onto three lines. + flex: 0 0 auto; + white-space: nowrap; + } + } +} + +.pm-grid { + display: grid; + grid-template-columns: repeat(4, 1fr); + gap: 2px 18px; + align-items: start; +} + +/* + * Label to the left of its field, the way the screen this replaces laid them out. A stacked label + * costs a whole row of height per field, and with 60-odd fields that is the difference between an + * operator seeing the form at once and scrolling through three screens of it. + */ +.pm-f { + display: grid; + grid-template-columns: 104px minmax(0, 1fr); + align-items: start; + column-gap: 7px; + + &--wide { + grid-column: span 2; + } +} + +.pm-f-lbl { + font-size: 11.5px; + line-height: 1.25; + text-align: right; + color: rgb(0 0 0 / 68%); + padding-top: 7px; + + // Required marker, in the same red the legacy screen used. + b { + color: #c1121f; + font-weight: 400; + margin-left: 2px; + } +} + +.pm-field { + width: 100%; +} + +.pm-checkbox-field { + display: flex; + align-items: center; + height: 56px; +} + +.pm-radio-group { + display: flex; + align-items: center; + gap: 16px; + height: 56px; +} + +.pm-add-nominee { + color: $primary; + height: fit-content; + align-self: center; +} + +/* + * The running total of the shares entered, beside the button that adds another. Reads as ordinary + * text while the split still adds up and turns to the warning colour when it does not, so the clerk + * sees the state of the whole nomination without having to try to save it. + */ +.pm-share-total { + align-self: center; + margin-left: 12px; + font-size: 13px; + color: rgb(0 0 0 / 60%); +} + +.pm-share-total-bad { + color: #b3261e; + font-weight: 500; +} + +.pm-empty { + color: rgb(0 0 0 / 50%); + font-size: 14px; + margin: 0 0 8px; +} + +/* + * One nominee, laid out as fields that wrap rather than columns that must all fit. + * + * A fixed column per field only works while the count is fixed, and it is not - the Share % field + * appears and disappears with the split mode, and a tenth column pushed the remove button onto a + * line of its own. Each field instead states the width it needs to stay readable and the row breaks + * where it runs out of space, so nothing is squeezed and nothing is orphaned. + */ +.pm-nominee-row { + display: flex; + flex-wrap: wrap; + gap: 4px 12px; + align-items: start; + padding: 12px 0; + border-top: 1px dashed rgb(0 0 0 / 10%); + + &:first-of-type { + border-top: none; + } + + /* Names and addresses need room; a date, Yes/No or a percentage does not. */ + .pm-f { + flex: 1 1 170px; + min-width: 0; + } + + .pm-f--date { + flex: 0 1 140px; + } + + .pm-f--tight { + flex: 0 1 100px; + } +} + +.pm-remove-nominee { + color: #b91c1c; + margin-top: 8px; +} + +.pm-action-bar { + display: flex; + justify-content: flex-end; + gap: 12px; + padding: 16px 24px; + border-top: 1px solid rgb(0 0 0 / 8%); +} + +.pm-callout { + display: flex; + align-items: center; + gap: 10px; + margin: 0 24px; + padding: 12px 16px; + background: rgb(185 28 28 / 8%); + border: 1px solid rgb(185 28 28 / 25%); + border-radius: 6px; + color: #b91c1c; + font-size: 0.9rem; +} + +@media (width <= 1000px) { + .pm-grid { + grid-template-columns: repeat(2, 1fr); + } +} + +/* Resolved name shown under a Client / Agent / Guardian id, as the legacy screen did in red. */ +.pm-hint--found { + color: #15803d; + font-weight: 500; +} + +.pm-hint--missing { + color: #b91c1c; + font-weight: 500; +} + +/* Explains why a block of fields is absent, so the screen does not just look unfinished. */ +.pm-note { + margin: 4px 0 0; + font-size: 0.8rem; + color: rgb(0 0 0 / 55%); + font-style: italic; +} + +.pm-closed-badge { + margin-left: 12px; + padding: 3px 11px; + border-radius: 11px; + font-size: 0.72rem; + font-weight: 500; + background: rgb(0 0 0 / 7%); + color: rgb(0 0 0 / 60%); +} + +/* Says whose details a block of fields is for, so the screen explains itself. */ +.pm-section__sub { + flex: 1 1 auto; + min-width: 0; + font-size: 0.78rem; + font-weight: 400; + line-height: 1.45; + color: rgb(0 0 0 / 55%); +} + +.pm-search-ic { + color: rgb(0 0 0 / 38%); + font-size: 0.85rem; +} + +/* + * Money reads right to left, so a column of figures only lines up when it is right-aligned; the + * tabular figures keep every digit the same width so the decimal points stack. + */ +input.pm-money { + text-align: right; + font-variant-numeric: tabular-nums; +} + +/* One row of a Client / Sub Type search list: the id, the name, and where it belongs. */ +.pm-opt { + display: flex; + align-items: baseline; + gap: 10px; + overflow: hidden; +} + +/* The "there are more" footer row. Quieter than a real option, since it is not one. */ +.pm-opt-more { + font-size: 0.75rem; + font-style: italic; + opacity: 0.75; +} + +.pm-opt-id { + flex: 0 0 auto; + min-width: 52px; + font-family: ui-monospace, 'SF Mono', 'Cascadia Mono', monospace; + font-size: 0.78rem; + color: var(--pl-accent, #b45309); +} + +/* + The name is the reason the row exists, so it gets whatever width is going and is the last thing + to be cut. Before this the branch was fixed-width and the name carried the ellipsis, so in the + narrow panel every customer read as a single letter. +*/ + +.pm-opt-main { + flex: 1 1 auto; + min-width: 8rem; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.pm-opt-sub { + flex: 0 1 auto; + min-width: 0; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + font-size: 0.75rem; + color: rgb(0 0 0 / 50%); +} + +/* stylelint-disable no-duplicate-selectors -- the dense layout below deliberately re-targets the base selectors above to override them. */ + +/* + * Dense data-entry layout. + * + * The screen this replaces fitted the whole form on one page - an officer opening accounts all day + * reads it at a glance and never scrolls. Material's default field height and spacing turn the same + * 56 fields into three screens' worth, so the whole card is tightened: shorter fields, smaller type, + * and just enough room under each field for the hint that names the client or agent it resolved. + */ + +/* + * With the label moved outside, the box only has to hold the value - so it loses the height Material + * reserves for a floating label, and the form fits a screen instead of three. + */ +.pm-card { + ::ng-deep { + .mat-mdc-form-field { + font-size: 12.5px; + } + + .mat-mdc-form-field-infix { + min-height: 26px; + padding-top: 3px !important; + padding-bottom: 3px !important; + border-top: 0; + } + + .mat-mdc-text-field-wrapper { + padding-left: 8px; + padding-right: 8px; + } + + .mat-mdc-input-element, + .mat-mdc-select-value-text { + font-size: 12.5px; + } + + // Room for one line of hint - the resolved client/agent name that confirms the right record. + .mat-mdc-form-field-subscript-wrapper { + height: 14px; + min-height: 14px; + } + + .mat-mdc-form-field-hint { + font-size: 10.5px; + line-height: 14px; + } + + // Suffix icons and the datepicker toggle default to a 48px touch target, which reopens all the + // height just saved. + .mat-mdc-icon-button.mat-mdc-button-base { + width: 26px; + height: 26px; + padding: 3px; + } + + .mat-mdc-form-field-icon-suffix { + padding-left: 2px; + + .mat-datepicker-toggle svg { + width: 15px; + height: 15px; + } + } + + .mat-mdc-tab-body-wrapper { + min-height: 0; + } + } +} + +.pm-tab-content { + padding: 8px 16px; +} + +/* + * Blocks drawn the way the screen this replaces drew them: a hairline between groups of fields, + * with the group's name as a quiet caption above it rather than a heading bar of its own. An + * operator who has filled this form a hundred times navigates by field position; one filling their + * first still gets told which block is which. + */ +.pm-section { + padding: 8px 0 2px; + border-bottom: 1px solid rgb(0 0 0 / 11%); + + &__header { + margin-bottom: 6px; + gap: 0 10px; + + // Decoration the legacy screen did without, and the row is tighter for losing it. + i { + display: none; + } + + h3 { + font-size: 11px; + font-weight: 700; + letter-spacing: 0.07em; + text-transform: uppercase; + color: $primary; + } + } +} + +.pm-section__sub { + font-size: 0.72rem; + color: rgb(0 0 0 / 45%); +} + +.pm-grid { + gap: 0 12px; +} + +.pm-checkbox-field, +.pm-radio-group { + height: 28px; + + ::ng-deep label { + font-size: 12.5px; + } +} + +// The nominee grid is its own row of fields, so its labels sit above rather than beside - there is +// no room for a label column eight fields across. +.pm-nominee-row { + gap: 0 10px; + padding: 3px 0; + + .pm-f { + grid-template-columns: minmax(0, 1fr); + row-gap: 1px; + } + + .pm-f-lbl { + text-align: left; + padding-top: 0; + font-size: 10.5px; + } +} + +.pm-note { + margin: 0; + font-size: 0.74rem; +} + +.pm-action-bar { + padding: 10px 18px; +} + +/* + The customer's real deposit account. Not a form field - nothing here is typed, it is what the + bank's books say - so it reads as a fact rather than sitting in an input box pretending to be one. +*/ +.pm-account { + display: flex; + flex-direction: column; + gap: 2px; + padding: 3px 0; + min-height: 26px; + justify-content: center; +} + +.pm-account-no { + font-family: ui-monospace, 'SF Mono', 'Cascadia Mono', monospace; + font-size: 0.85rem; + letter-spacing: 0.02em; +} + +.pm-account-meta { + font-size: 0.75rem; + color: rgb(0 0 0 / 55%); +} + +.pm-account-live { + color: #15803d; + font-weight: 500; +} + +/* Said plainly: this party has no account behind it, which is a real gap, not a blank field. */ +.pm-account-missing { + font-size: 0.75rem; + font-style: italic; + color: #9b2c2c; +} + +/* + Something the branch has to do, rather than something that has gone wrong - so it reads as an + instruction in the warning colour, not an error in red like the closed-account notice. +*/ +.pm-callout--action { + background: rgb(180 83 9 / 8%); + border-color: rgb(180 83 9 / 25%); + color: #b45309; +} diff --git a/src/app/deposits/party-master/create-party/create-party.component.ts b/src/app/deposits/party-master/create-party/create-party.component.ts new file mode 100644 index 000000000..81ac3fc07 --- /dev/null +++ b/src/app/deposits/party-master/create-party/create-party.component.ts @@ -0,0 +1,1944 @@ +/** + * Copyright since 2025 Mifos Initiative + * + * This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. + */ + +/** Angular Imports */ +import { Component, OnInit, inject } from '@angular/core'; +import { + UntypedFormBuilder, + UntypedFormGroup, + UntypedFormArray, + Validators, + ValidatorFn, + ValidationErrors, + AbstractControl +} from '@angular/forms'; +import { ActivatedRoute, Router } from '@angular/router'; +import { MatTabsModule } from '@angular/material/tabs'; +import { MatCheckbox } from '@angular/material/checkbox'; +import { MatRadioModule } from '@angular/material/radio'; +import { MatAutocompleteModule } from '@angular/material/autocomplete'; +import { MatDialog } from '@angular/material/dialog'; +import { debounceTime, distinctUntilChanged } from 'rxjs/operators'; +import { STANDALONE_SHARED_IMPORTS } from 'app/standalone-shared.module'; + +/** Custom Services */ +import { OrganizationService } from 'app/organization/organization.service'; +import { ProductsService } from 'app/products/products.service'; +import { ClientsService } from 'app/clients/clients.service'; +import { SettingsService } from 'app/settings/settings.service'; +import { Dates } from 'app/core/utils/dates'; +import { AlertService } from 'app/core/alert/alert.service'; +import { searchClientsAnyCase } from '../client-search'; + +/** Custom Components */ +import { PrematureCloseDialogComponent } from '../premature-close-dialog/premature-close-dialog.component'; + +/** Custom Directives */ +import { RestrictInputDirective } from 'app/directives/restrict-input.directive'; + +/** + * Party Master Entry form. + * + * The customer's DDS account - opened under a scheme (Sub Type, chosen from the DDS Account Type + * master) and assigned to the agent who will collect for it daily. Carries its own KYC, scheme + * terms, two-stage overdue flags and a nominee list. Modelled on the legacy Party Master Entry + * screen, field for field. + * + * The legacy screen filled the identity block from the Client ID and showed the agent's name beside + * the agent field; both are reproduced here, and the same lookup fills the guardian's name for a + * minor's account. That field read "Sub Agent" in the legacy system, which had a tier this one does + * not - SubAgent was folded into Agent, so it is named for what it actually points at. Fields the legacy screen greyed out are either client-derived, calculated, or + * written by collections and the batch job - none of them are typed here. + * + * "Close Account" (command=close) permanently closes the Party once its balance is zero - the + * backend rejects the command otherwise. A closed Party can no longer be edited or receive new + * Party Tran collections. + * + * savingsAccountNo optionally links this Party to an existing, non-closed real Savings Account of + * the same client (resolved by account number, since that's what an officer has to hand) - + * clarified with the business (2026-08-24) as "link it if the client already has one, otherwise + * leave it". There is no unlink once linked. + */ +@Component({ + selector: 'mifosx-create-party', + templateUrl: './create-party.component.html', + styleUrls: ['./create-party.component.scss'], + imports: [ + ...STANDALONE_SHARED_IMPORTS, + MatTabsModule, + MatCheckbox, + MatRadioModule, + MatAutocompleteModule, + // Holds the identifier fields to the shape their kind takes, at the keystroke. Imported here + // because a standalone component silently ignores a directive it has not imported - the + // attribute stays on the element and simply does nothing. + RestrictInputDirective + ] +}) +export class CreatePartyComponent implements OnInit { + private formBuilder = inject(UntypedFormBuilder); + private router = inject(Router); + private route = inject(ActivatedRoute); + private organizationService = inject(OrganizationService); + private productsService = inject(ProductsService); + private alertService = inject(AlertService); + private clientsService = inject(ClientsService); + private settingsService = inject(SettingsService); + private dateUtils = inject(Dates); + private dialog = inject(MatDialog); + + /* + * What each identifier has to look like - the same shapes the server holds them to. + * + * Checked here as well so the clerk is told while the customer is still at the counter, rather + * than after a save that comes back rejected. The server checks them again regardless: a screen + * is a convenience, not a guarantee. + */ + private static readonly MOBILE_RE = /^[6-9][0-9]{9}$/; + private static readonly TELEPHONE_RE = /^[0-9+()\-\s]{6,20}$/; + private static readonly PINCODE_RE = /^[1-9][0-9]{5}$/; + /** Twelve digits, or the masked form CKYC hands back. */ + private static readonly AADHAAR_RE = /^([0-9]{12}|[Xx]{8}[0-9]{4})$/; + private static readonly PAN_RE = /^[A-Z]{5}[0-9]{4}[A-Z]$/; + private static readonly CKYC_RE = /^[0-9]{14}$/; + + partyId: number | null = null; + + /** + * The earliest opening date the picker will offer on a new account - today, so the past is greyed + * out. The server refuses a past date on creation too; this only stops the clerk reaching for one. + * + * Null while editing. An account already on the books opened in the past by definition, so a floor + * of today would put every existing party into a state its own form would not accept. + */ + get minOpeningDate(): Date | null { + return this.partyId === null ? new Date() : null; + } + + selectedTabIndex = 0; + partyStatus = 'ACTIVE'; + linkedSavingsAccountId: number | null = null; + + /** + * The customer's real deposit account, as the bank's own books hold it. Read from the account + * itself rather than copied onto the party, so what shows here cannot drift from what is true. + */ + savingsAccountNo: string | null = null; + savingsProductName: string | null = null; + savingsAccountStatus: string | null = null; + savingsAccountBalance: number | null = null; + + /** The DDS account's own number, scheme and balance - the party is the account. */ + accountNo: string | null = null; + accountSchemeName: string | null = null; + accountBalance: number | null = null; + + /** + * A minor account holder who has come of age. The guardian's authority ended on that day, so the + * branch has to take fresh KYC and a new mandate - shown as a call to action rather than a field. + */ + majorOn: string | null = null; + minorTurnedMajor = false; + + /** Shown beside the id fields once the record behind them has been resolved. */ + loadedClientName = ''; + clientLookupFailed = false; + loadedAgentName = ''; + agentLookupFailed = false; + loadedGuardianName = ''; + + /** Clients matching what has been typed - an officer knows the customer's name, rarely their id. */ + clientOptions: any[] = []; + + /** + * The first page of clients, shown the moment the field is focused so the officer can pick from a + * list instead of having to know something to type. Fetched once and kept. + * + * Capped rather than "all": it is a page of names on a branch with 28 customers today and a bank + * with far more later, and the field has to stay usable in both. Typing still searches every + * client, not just these - see searchClients. + */ + private initialClients: any[] = []; + private static readonly CLIENT_LIST_SIZE = 50; + + /** How many clients exist in total, to say so when the list below is only part of them. */ + clientTotal = 0; + + /** True while the options being shown are the opening list rather than search results. */ + showingClientList = false; + + get clientListIsPartial(): boolean { + return this.showingClientList && this.clientTotal > this.clientOptions.length; + } + guardianOptions: any[] = []; + /** The account types matching what has been typed into Sub Type. */ + subTypeOptions: any[] = []; + + /** + * The agents on offer. Every agent is fetched once and filtered here rather than asked of the + * server per keystroke - there is a handful of them, not a branch's worth of customers. + */ + agentOptions: any[] = []; + private allAgents: any[] = []; + + get isClosed(): boolean { + return this.partyStatus === 'CLOSED'; + } + + /** The DDS Account Type master - what the legacy screen's Sub Type dropdown was fed from. */ + subTypes: any[] = []; + + 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' + ]; + + /** The bank's own branches - where maturity proceeds can be credited. */ + /** + * Why a deposit gets held, offered as a list rather than typed. + * + * Free text meant the same reason arrived four ways - "Loan 1234", "loan no 1234", "LOAN#1234" - + * and anything counting them read four different reasons. Picked from a list they group properly, + * and the clerk types only where the list genuinely does not cover it. + */ + lienReasons = [ + 'Loan security', + 'Court order', + 'Tax attachment', + 'Deceased — claim pending', + 'Under investigation', + 'Other' + ]; + + offices: any[] = []; + officesError = ''; + + /** + * The customer's own savings accounts, for Credit To A/C. + * + * Picked rather than typed. The whole matured amount lands here years after somebody fills this + * in, and the live data showed what free text costs: one party carried "1234567890111", which is + * no account at all. Only savings accounts are offered - a fixed or recurring deposit cannot take + * the credit - and only this customer's own, which is what a maturity instruction means in any + * core system. The backend refuses anything else, whatever the screen sends. + */ + creditToAccounts: any[] = []; + + /** The Adv Code that makes the account say where its maturity proceeds should land. */ + private static readonly TRANSFER_TO_SAVING = 'Transfer To Saving'; + + /** Filled from the Client ID, as on the legacy screen. */ + private static readonly CLIENT_DERIVED_FIELDS = [ + 'partyName', + 'partyName1', + 'address1', + 'address2', + 'address3', + 'city', + 'pincode', + 'gender', + 'birthDate', + 'telephone', + 'mobileNoSms', + 'occupation', + 'risk', + 'aadharNo', + 'panCardNo', + 'ckycId', + 'relation', + 'minorDate', + 'guardianClientId' + ]; + + /** Fields holding a value from the client record, shown read-only - see fillFromClientRecord. */ + private filledFromClient = new Set(); + + private static readonly MINOR_FIELDS = [ + 'minorDate', + 'guardianClientId', + 'minAccountOpt', + 'relation' + ]; + + private static readonly CREDIT_TO_FIELDS = [ + 'creditToBranch', + 'creditToAccountType', + 'creditToAccount' + ]; + + partyForm: UntypedFormGroup = this.formBuilder.group({ + clientId: [ + '', + Validators.required + ], + subType: [ + '', + Validators.required + ], + agentId: [ + '', + Validators.required + ], + // The branch this account belongs to. Its own, not the collecting agent's - an agent of one + // branch may hold accounts for customers of another, so it is asked for rather than inherited. + officeId: [ + '', + Validators.required + ], + savingsAccountNo: [''], + lastAccount: [ + { + value: '', + disabled: true + } + ], + partyCode: [ + { + value: '', + disabled: true + } + ], + nominal: [ + 'No', + Validators.required + ], + aadharNo: [ + '', + Validators.pattern(CreatePartyComponent.AADHAAR_RE) + ], + partyName: [ + '', + Validators.required + ], + partyName1: [''], + address1: [ + '', + Validators.required + ], + address2: [''], + address3: [''], + city: [ + '', + Validators.required + ], + pincode: [ + '', + [ + Validators.required, + Validators.pattern(CreatePartyComponent.PINCODE_RE) + ] + ], + gender: [''], + panCardNo: [ + '', + Validators.pattern(CreatePartyComponent.PAN_RE) + ], + telephone: [ + '', + Validators.pattern(CreatePartyComponent.TELEPHONE_RE) + ], + mobileNoSms: [ + '', + [ + Validators.required, + Validators.pattern(CreatePartyComponent.MOBILE_RE) + ] + ], + operation: [ + 'Self', + Validators.required + ], + birthDate: [''], + occupation: [''], + risk: [''], + ckycId: [ + '', + Validators.pattern(CreatePartyComponent.CKYC_RE) + ], + adultMinor: [ + 'Adult', + Validators.required + ], + // Enabled only for a minor's account - see applyAdultMinorRules(). + minorDate: [ + { + value: '', + disabled: true + } + ], + guardianClientId: [ + { + value: '', + disabled: true + } + ], + minAccountOpt: [ + { + value: '', + disabled: true + } + ], + relation: [ + { + value: '', + disabled: true + } + ], + openingDate: [ + '', + Validators.required + ], + month: [''], + // Worked out from the opening date and the tenure - never typed. + maturityDate: [ + { + value: '', + disabled: true + } + ], + reopenDate: [''], + depositAmount: [0], + dailyDepositAmount: [ + 0, + Validators.required + ], + days: [ + '', + Validators.required + ], + ledgerNo: [''], + maturityAmount: [ + { + value: 0, + disabled: true + } + ], + interestRate: [0], + withdrawType: [''], + // The overdue flags, balances and interest figures below are written by collections and the + // DDS batch job. They are shown so an officer can see where the account stands, never typed. + od1Flag: [ + { + value: false, + disabled: true + } + ], + od2Flag: [ + { + value: false, + disabled: true + } + ], + lien: ['No'], + // Asked for only while the lien is set - the rule is applied from the flag rather + // than stated here, so switching it off does not leave a required field behind. + lienReason: [''], + // What the clerk types when the list does not cover it. Kept apart from lienReason so the list + // stays a list - the one value the server stores is worked out at save. + lienReasonOther: [''], + payDate: [ + { + value: '', + disabled: true + } + ], + paymentAmount: [ + { + value: 0, + disabled: true + } + ], + currentBalance: [ + { + value: 0, + disabled: true + } + ], + openingBalance: [ + { + value: 0, + disabled: true + } + ], + isSmsActive: [true], + isEmailActive: [false], + interestCalc: [ + { + value: 0, + disabled: true + } + ], + interestDate: [ + { + value: '', + disabled: true + } + ], + spDescription: [''], + freezeCode: ['Normal'], + advCode: ['None'], + // Mandatory only when Adv Code is Transfer To Saving - see applyAdvCodeRules(). + creditToBranch: [''], + creditToAccountType: [''], + creditToAccount: [''], + nomineeApplicable: ['No'], + nomineeSplitMode: ['Percentage'], + nominees: this.formBuilder.array([]) + }); + + ngOnInit(): void { + this.productsService.getDdsAccountTypes().subscribe((accountTypes: any[]) => { + // A retired scheme takes no new accounts, but one already on it must still show when edited. + this.subTypes = (accountTypes || []).filter((accountType) => accountType.active); + this.subTypeOptions = this.subTypes; + }); + + // Branches are picked, never typed: a free-text branch cannot be matched back to a real office + // when the proceeds are actually moved. If the list cannot be fetched the reason is shown on the + // field - an empty dropdown with no explanation looks like the bank has no branches. + this.organizationService.getOffices().subscribe({ + next: (offices: any) => { + this.offices = Array.isArray(offices) ? offices : offices?.pageItems || []; + this.officesError = this.offices.length === 0 ? 'No branches on file' : ''; + }, + error: (error: any) => { + this.officesError = + error?.error?.defaultUserMessage || + error?.error?.developerMessage || + `Could not load branches (${error?.status || 'no response'})`; + } + }); + + const routeId = this.route.snapshot.paramMap.get('partyId'); + if (routeId) { + this.organizationService.getParty(routeId).subscribe((party: any) => this.patchFormFromParty(party)); + } + + this.partyForm.get('days')?.addValidators(this.withinTerm()); + + this.wireClientLookup(); + this.wireSubTypeSearch(); + this.wireAgentLookup(); + this.wireGuardianLookup(); + this.wireConditionalRules(); + this.wireMaturityCalculation(); + } + + // ---------------------------------------------------------------- lookups + + /** + * Fills the identity block from the customer, the way the legacy screen did - except that the + * customer can be found by name as well as by id, since an officer at the counter is told a name. + * The control holds the picked client object, or whatever has been typed so far. + */ + private wireClientLookup(): void { + this.partyForm + .get('clientId') + ?.valueChanges.pipe(debounceTime(400), distinctUntilChanged()) + .subscribe((value) => { + // Always replace: changing the customer must not leave the previous one's details behind. + for (const controlName of CreatePartyComponent.CLIENT_DERIVED_FIELDS) { + this.partyForm.get(controlName)?.setValue(''); + } + this.filledFromClient.clear(); + this.partyForm.get('adultMinor')?.enable({ emitEvent: false }); + this.loadedClientName = ''; + this.clientLookupFailed = false; + + // Picked from the list. + if (value && typeof value === 'object') { + this.clientOptions = []; + this.fillFromClient(value.id, value.displayName); + return; + } + + const typed = String(value ?? '').trim(); + if (!typed) { + // Emptying the field puts the opening list back, rather than leaving nothing to pick from. + this.showClientList(); + return; + } + + // A bare number is an id typed straight in; anything else is a name to search for. A number + // can be both, so both are tried and whichever matches wins. + if (/^\d+$/.test(typed)) { + this.fillFromClient(typed, ''); + } + this.searchClients(typed); + }); + } + + private searchClients(term: string): void { + searchClientsAnyCase(this.clientsService, term).subscribe((found: any[]) => { + this.showingClientList = false; + this.clientOptions = found.slice(0, 25); + }); + } + + /** + * Opens the customer list when the field is focused. + * + * Without this the officer has to know something before anything appears, which is backwards: the + * point of the field is to find a customer, and a blank box is no help in doing that. A customer + * already picked is left alone - reopening the list over a chosen name only invites a misclick. + */ + onClientFocus(): void { + if (this.partyForm.get('clientId')?.value) { + return; + } + this.showClientList(); + } + + /** Shows the opening list, fetching it the first time it is asked for. */ + private showClientList(): void { + if (this.initialClients.length > 0) { + this.showingClientList = true; + this.clientOptions = this.initialClients; + return; + } + + this.clientsService.getClients('displayName', 'ASC', 0, CreatePartyComponent.CLIENT_LIST_SIZE).subscribe({ + next: (response: any) => { + const found = response?.pageItems || response || []; + this.initialClients = Array.isArray(found) ? found : []; + this.clientTotal = response?.totalFilteredRecords ?? this.initialClients.length; + this.showingClientList = true; + this.clientOptions = this.initialClients; + }, + // A failed list is not a failed screen - the officer can still type a name or an id. + error: () => (this.clientOptions = []) + }); + } + + private fillFromClient(clientId: string | number, knownName: string): void { + this.clientsService.getClientData(String(clientId), true).subscribe({ + next: (client: any) => { + this.loadedClientName = client?.displayName || knownName || ''; + this.clientLookupFailed = false; + this.fillFromClientRecord('partyName', client?.displayName); + this.fillFromClientRecord('mobileNoSms', client?.mobileNo); + this.fillFromClientRecord('telephone', client?.alternateMobileNo); + // Alerts go where the client can actually be reached: SMS with a mobile on record, email with + // an email address on record. + this.partyForm.get('isSmsActive')?.setValue(!!client?.mobileNo); + this.partyForm.get('isEmailActive')?.setValue(!!client?.emailAddress); + // The client record says FEMALE; this form's list says Female. + this.fillFromClientRecord('gender', titleCase(client?.gender?.name || client?.gender)); + const dateOfBirth = this.toDate(client?.dateOfBirth); + if (dateOfBirth) { + this.partyForm.get('birthDate')?.setValue(dateOfBirth); + this.filledFromClient.add('birthDate'); + } + // The client's own occupation, offered even when this form's short list does not carry it. + const occupation = client?.occupation?.name || client?.occupationCategory?.name; + if (occupation) { + if (!this.occupations.includes(occupation)) { + this.occupations = [ + ...this.occupations, + occupation + ]; + } + this.fillFromClientRecord('occupation', occupation); + } + // A minor on the client record is a minor account here, with the same guardian. + this.partyForm.get('adultMinor')?.setValue(client?.isMinor ? 'Minor' : 'Adult'); + // Adult or minor is the client record's, checked when the client was opened - not changed here. + this.partyForm.get('adultMinor')?.disable({ emitEvent: false }); + this.filledFromClient.add('adultMinor'); + if (client?.isMinor) { + if (dateOfBirth) { + // The day they come of age - eighteen is majority in India. + const majority = new Date(dateOfBirth); + majority.setFullYear(majority.getFullYear() + 18); + this.partyForm.get('minorDate')?.setValue(majority); + this.filledFromClient.add('minorDate'); + } + if (client?.guardianClientId) { + this.partyForm + .get('guardianClientId') + ?.setValue({ id: client.guardianClientId, displayName: client.guardianClientDisplayName || '' }); + this.filledFromClient.add('guardianClientId'); + } + this.fillFromClientRecord('relation', titleCase(client?.guardianRelation)); + } + this.loadClientRisk(String(clientId)); + // Nominees are not brought across: the officer records them for this account. + // Proceeds usually go back to an account at the customer's own branch, so that is offered + // first - any branch can still be picked, since the account may well be at another. + const creditToBranch = this.partyForm.get('creditToBranch'); + if (client?.officeName && !creditToBranch?.value) { + creditToBranch?.setValue(client.officeName, { emitEvent: false }); + } + + this.loadClientAddress(String(clientId)); + this.loadClientIdentifiers(String(clientId)); + this.loadCreditToAccounts(String(clientId)); + }, + error: () => { + this.clientLookupFailed = true; + } + }); + } + + /** + * The customer's own accounts a maturity may be credited to. + * + * Worked out by the server, which applies the same conditions the payout enforces years later: an + * active savings account of theirs, not a fixed or recurring deposit, and not a DDS scheme account + * - those are collected on daily and pay out at their own maturity, so naming one here would send + * this maturity into another collection account instead of back to the customer. + * + * Deciding it here instead let the list and the refusal drift apart, and the accounts on offer + * came to include a scheme account of a party that had already closed. + */ + private loadCreditToAccounts(clientId: string): void { + this.organizationService.getCreditableAccounts(clientId).subscribe({ + next: (accounts: any) => (this.creditToAccounts = accounts || []), + error: () => (this.creditToAccounts = []) + }); + } + + /** + * Fills the account type from the account chosen. An account already is of a type, and asking for + * both lets the two disagree - the account is the half that is actually true. + */ + onCreditToAccountPicked(accountNo: string): void { + const account = this.creditToAccounts.find((each: any) => each.accountNo === accountNo); + if (account?.productName) { + this.partyForm.get('creditToAccountType')?.setValue(account.productName); + } + } + + /** + * Aadhar, PAN and CKYC are not plain client fields - they are KYC identifiers filed against the + * client, each tagged with a document type. They are matched by that type's name so the screen + * shows the same numbers the client record already holds, rather than asking for them twice. + */ + private loadClientIdentifiers(clientId: string): void { + this.clientsService.getClientIdentifiers(clientId).subscribe({ + next: (identifiers: any) => { + for (const identifier of Array.isArray(identifiers) ? identifiers : []) { + const words = this.wordsOf(identifier?.documentType?.name); + const documentKey = identifier?.documentKey; + if (!documentKey) { + continue; + } + if (words.includes('aadhaar') || words.includes('aadhar')) { + this.fillFromClientRecord('aadharNo', documentKey); + } else if (words.includes('pan')) { + this.fillFromClientRecord('panCardNo', documentKey); + } else if (words.includes('ckyc')) { + this.fillFromClientRecord('ckycId', documentKey); + } + } + }, + // A client with no identifiers on file is normal - the officer just types them. + error: () => undefined + }); + } + + /** What the Client ID box shows for a picked client, rather than "[object Object]". */ + displayClient = (value: any): string => { + if (!value) { + return ''; + } + return typeof value === 'object' ? `${value.id} — ${value.displayName}` : String(value); + }; + + /** + * Takes a value from the client record, which was checked when the client was opened, and locks + * the field so it is not typed - and checked - a second time. A value the client does not have is + * left for the officer to enter. + */ + private fillFromClientRecord(controlName: string, value: any): void { + if (value === null || value === undefined || String(value).trim() === '') { + return; + } + this.setField(controlName, value); + this.filledFromClient.add(controlName); + } + + /** Whether a field holds a value taken from the client record, and so is shown read-only. */ + isFromClient(controlName: string): boolean { + return this.filledFromClient.has(controlName); + } + + /** The client's risk category, from their KYC record. */ + private loadClientRisk(clientId: string): void { + this.clientsService.getClientKyc(clientId, true).subscribe({ + next: (kyc: any) => { + // kycRiskCategory.medium -> Medium + const code = String(kyc?.riskCategoryCode || '') + .split('.') + .pop(); + this.fillFromClientRecord('risk', titleCase(code)); + }, + // A client without a KYC record just leaves the risk to be chosen. + error: () => undefined + }); + } + + private loadClientAddress(clientId: string): void { + this.clientsService.getClientAddressData(clientId, true).subscribe({ + next: (addresses: any) => { + const address = Array.isArray(addresses) ? addresses[0] : addresses; + if (!address) { + return; + } + this.fillFromClientRecord('address1', address.addressLine1 || address.street); + this.fillFromClientRecord('address2', address.addressLine2); + this.fillFromClientRecord('address3', address.addressLine3); + this.fillFromClientRecord('city', address.city); + this.fillFromClientRecord('pincode', address.postalCode); + }, + // A client with no address on file is normal - the officer just types it. + error: () => undefined + }); + } + + /** Shows the collecting agent's name beside the id, as the legacy screen did in red. */ + private wireAgentLookup(): void { + this.partyForm + .get('agentId') + ?.valueChanges.pipe(debounceTime(400), distinctUntilChanged()) + .subscribe((value) => { + this.loadedAgentName = ''; + this.agentLookupFailed = false; + + // Picked from the list - the agent is already in hand, nothing to look up. + if (value && typeof value === 'object') { + this.agentOptions = []; + this.loadedAgentName = value.agentNameId || value.clientName || ''; + this.agentLookupFailed = !value.active; + return; + } + + const typed = String(value ?? '').trim(); + if (!typed) { + this.showAgentList(); + return; + } + + this.filterAgents(typed); + + // A bare number is an id typed straight in, the way this field always worked. + if (!/^\d+$/.test(typed)) { + return; + } + this.organizationService.getAgent(typed).subscribe({ + next: (agent: any) => { + this.loadedAgentName = agent?.agentNameId || agent?.clientName || ''; + this.agentLookupFailed = !agent?.active; + }, + error: () => { + this.agentLookupFailed = true; + } + }); + }); + } + + /** + * Opens the agent list on focus, so the field can be answered by picking rather than by knowing + * an id. A chosen agent is left alone - reopening over it only invites a misclick. + */ + onAgentFocus(): void { + if (this.partyForm.get('agentId')?.value) { + return; + } + this.showAgentList(); + } + + private showAgentList(): void { + if (this.allAgents.length > 0) { + this.agentOptions = this.allAgents; + return; + } + this.organizationService.getAgents().subscribe({ + next: (agents: any[]) => { + this.allAgents = agents || []; + this.agentOptions = this.allAgents; + }, + // A failed list is not a failed screen - an id can still be typed. + error: () => (this.agentOptions = []) + }); + } + + /** Matches on code or name, since an officer may know either. */ + private filterAgents(term: string): void { + const needle = term.toLowerCase(); + const match = (agent: any) => + String(agent.agentCode || '') + .toLowerCase() + .includes(needle) || + String(agent.agentNameId || '') + .toLowerCase() + .includes(needle) || + String(agent.id) === term; + + if (this.allAgents.length > 0) { + this.agentOptions = this.allAgents.filter(match); + return; + } + this.organizationService.getAgents().subscribe({ + next: (agents: any[]) => { + this.allAgents = agents || []; + this.agentOptions = this.allAgents.filter(match); + }, + error: () => (this.agentOptions = []) + }); + } + + displayAgent = (value: any): string => { + if (!value) { + return ''; + } + return typeof value === 'object' ? `${value.agentCode} — ${value.agentNameId}` : String(value); + }; + + /** A minor's account names a guardian, found the same way the customer is. */ + private wireGuardianLookup(): void { + this.partyForm + .get('guardianClientId') + ?.valueChanges.pipe(debounceTime(400), distinctUntilChanged()) + .subscribe((value) => { + this.loadedGuardianName = ''; + + if (value && typeof value === 'object') { + this.guardianOptions = []; + this.loadedGuardianName = value.displayName || ''; + return; + } + + const typed = String(value ?? '').trim(); + if (!typed) { + this.guardianOptions = []; + return; + } + + if (/^\d+$/.test(typed)) { + this.clientsService.getClientData(typed, true).subscribe({ + next: (client: any) => (this.loadedGuardianName = client?.displayName || ''), + error: () => undefined + }); + } + this.clientsService.getFilteredClients('displayName', 'ASC', false, typed).subscribe({ + next: (response: any) => { + const found = response?.pageItems || response || []; + this.guardianOptions = Array.isArray(found) ? found.slice(0, 25) : []; + }, + error: () => (this.guardianOptions = []) + }); + }); + } + + /** + * Sub Type is picked from the DDS Account Type master. Once a bank runs a dozen schemes a plain + * dropdown is a scroll, so it filters on either the code or the scheme's name as you type. + */ + private wireSubTypeSearch(): void { + this.partyForm.get('subType')?.valueChanges.subscribe((value) => { + // A new account takes its product's rate, which the maturity preview is worked out on. The + // server sets the same rate when it saves; an account being edited already has it. + const scheme = this.subTypes.find((type) => type.accountTypeCode === value); + if (scheme && !this.partyId) { + this.partyForm.get('interestRate')?.setValue(Number(scheme.interestRate) || 0); + } + const term = String(value ?? '') + .toLowerCase() + .trim(); + this.subTypeOptions = !term + ? this.subTypes + : this.subTypes.filter( + (type) => + String(type.accountTypeCode || '') + .toLowerCase() + .includes(term) || + String(type.accountTypeDescription || '') + .toLowerCase() + .includes(term) || + String(type.accountTypeGroup || '') + .toLowerCase() + .includes(term) + ); + }); + } + + /** Opening the box should offer everything, not just what the current value happens to match. */ + showAllSubTypes(): void { + this.subTypeOptions = this.subTypes; + } + + displaySubType = (code: any): string => { + if (!code) { + return ''; + } + const found = this.subTypes.find((type) => type.accountTypeCode === code); + return found ? `${found.accountTypeDescription} (${found.accountTypeCode})` : String(code); + }; + + // ----------------------------------------------------------- conditionals + + private wireConditionalRules(): void { + this.partyForm.get('adultMinor')?.valueChanges.subscribe(() => this.applyAdultMinorRules()); + this.partyForm.get('advCode')?.valueChanges.subscribe(() => this.applyAdvCodeRules()); + this.partyForm.get('lien')?.valueChanges.subscribe(() => this.applyLienRules()); + this.partyForm.get('lienReason')?.valueChanges.subscribe(() => this.applyLienRules()); + this.partyForm.get('nomineeApplicable')?.valueChanges.subscribe(() => this.applyNomineeRules()); + // Switching the split re-rules every nominee already on the form, not just the ones added after + // - a clerk who names three nominees and then picks Percentage must still be asked for shares. + this.partyForm.get('nomineeSplitMode')?.valueChanges.subscribe((mode) => { + this.nominees.controls.forEach((group) => this.applySplitModeTo(group, mode)); + }); + this.applyAdultMinorRules(); + this.applyAdvCodeRules(); + } + + /** + * A minor's account has to say when the holder comes of age and who operates it until then; on an + * adult's account those four fields are meaningless, so they stay disabled and empty. + */ + private applyAdultMinorRules(): void { + const isMinor = this.partyForm.get('adultMinor')?.value === 'Minor'; + for (const controlName of CreatePartyComponent.MINOR_FIELDS) { + const control = this.partyForm.get(controlName); + if (!control) { + continue; + } + if (isMinor) { + control.enable({ emitEvent: false }); + // minAccountOpt is an optional note on how the account may be operated. + if (controlName !== 'minAccountOpt') { + control.setValidators(Validators.required); + } + } else { + control.clearValidators(); + control.setValue('', { emitEvent: false }); + control.disable({ emitEvent: false }); + this.loadedGuardianName = ''; + } + control.updateValueAndValidity({ emitEvent: false }); + } + } + + /** + * Transfer To Saving means the maturity proceeds land in a savings account, so the account has to + * name which one. Any other Adv Code leaves nothing to credit, and the three fields go away. + */ + private applyAdvCodeRules(): void { + const transfers = this.partyForm.get('advCode')?.value === CreatePartyComponent.TRANSFER_TO_SAVING; + for (const controlName of CreatePartyComponent.CREDIT_TO_FIELDS) { + const control = this.partyForm.get(controlName); + if (!control) { + continue; + } + if (transfers) { + control.setValidators(Validators.required); + } else { + control.clearValidators(); + control.setValue('', { emitEvent: false }); + } + control.updateValueAndValidity({ emitEvent: false }); + } + } + + get creditToRequired(): boolean { + return this.partyForm.get('advCode')?.value === CreatePartyComponent.TRANSFER_TO_SAVING; + } + + get isMinorAccount(): boolean { + return this.partyForm.get('adultMinor')?.value === 'Minor'; + } + + get nomineesRequired(): boolean { + return this.partyForm.get('nomineeApplicable')?.value === 'Yes'; + } + + /** + * Which entry in the list a stored reason belongs to. + * + * Anything not on the list was typed under "Other", including reasons saved before there was a + * list - so it comes back as "Other" with the original words intact rather than being lost to a + * dropdown that has no room for it. + */ + private listedLienReason(stored: string | null | undefined): string { + if (!stored) { + return ''; + } + return this.lienReasons.includes(stored) ? stored : 'Other'; + } + + /** True while this account's money is being held, which is what asks for a reason. */ + get lienHeld(): boolean { + return this.partyForm?.get('lien')?.value === 'Yes'; + } + + /** + * A held account has to say what it is held for; a free one has nothing to explain. + * + * The reason is cleared when the hold is lifted rather than left behind, so the form never reads + * "Lien: No" beside an explanation of a hold that no longer exists - the server clears it for the + * same reason. + */ + private applyLienRules(): void { + const reason = this.partyForm.get('lienReason'); + const other = this.partyForm.get('lienReasonOther'); + if (!reason || !other) { + return; + } + + if (this.lienHeld) { + reason.setValidators(Validators.required); + } else { + reason.clearValidators(); + reason.setValue('', { emitEvent: false }); + } + reason.updateValueAndValidity({ emitEvent: false }); + + // Only "Other" asks for words of its own - picking it and leaving it blank says no more than + // leaving the whole thing blank did. + if (this.lienHeld && this.lienReasonIsOther) { + other.setValidators([ + Validators.required, + Validators.maxLength(200) + ]); + } else { + other.clearValidators(); + other.setValue('', { emitEvent: false }); + } + other.updateValueAndValidity({ emitEvent: false }); + } + + /** True while the clerk has chosen to write the reason themselves. */ + get lienReasonIsOther(): boolean { + return this.partyForm?.get('lienReason')?.value === 'Other'; + } + + /** Saying an account has a nominee but naming none would leave the nomination incomplete. */ + private applyNomineeRules(): void { + if (this.nomineesRequired && this.nominees.length === 0) { + this.addNominee(); + } + if (!this.nomineesRequired) { + this.nominees.clear(); + } + } + + // ----------------------------------------------------------- calculations + + /** + * Maturity date and amount follow from the opening date and the tenure, so they are recomputed + * whenever one of those changes rather than being typed - which is why the legacy screen greyed + * them out. + */ + private wireMaturityCalculation(): void { + // The term drives everything downstream, so changing it refills Days as well. + for (const controlName of [ + 'openingDate', + 'month' + ]) { + this.partyForm.get(controlName)?.valueChanges.subscribe(() => this.recalculateTerm()); + } + // Days typed by hand stands: it only moves the maturity date when no month was given. + this.partyForm.get('days')?.valueChanges.subscribe(() => { + this.recalculateMaturityDate(); + this.recalculateMaturityAmount(); + }); + for (const controlName of [ + 'dailyDepositAmount', + 'interestRate' + ]) { + this.partyForm.get(controlName)?.valueChanges.subscribe(() => this.recalculateMaturityAmount()); + } + } + + private recalculateTerm(): void { + this.recalculateMaturityDate(); + // Days is not refilled from the term: whether collection runs every day or skips Sundays and + // holidays is the bank's own rule, and guessing it would quietly put a wrong instalment count - + // and so a wrong maturity amount - in front of the customer. It is only checked, below. + this.partyForm.get('days')?.updateValueAndValidity({ emitEvent: false }); + this.recalculateMaturityAmount(); + } + + private recalculateMaturityDate(): void { + const raw = this.partyForm.getRawValue(); + const openingDate = this.toDate(raw.openingDate); + const months = Number(raw.month) || 0; + const days = Number(raw.days) || 0; + + if (!openingDate || (months <= 0 && days <= 0)) { + this.partyForm.get('maturityDate')?.setValue('', { emitEvent: false }); + return; + } + + const maturity = new Date(openingDate.getTime()); + if (months > 0) { + maturity.setMonth(maturity.getMonth() + months); + } else { + maturity.setDate(maturity.getDate() + days); + } + this.partyForm.get('maturityDate')?.setValue(maturity, { emitEvent: false }); + } + + /** Calendar days the term covers, once a term in months has been given. */ + get termDays(): number | null { + const raw = this.partyForm.getRawValue(); + const openingDate = this.toDate(raw.openingDate); + const months = Number(raw.month) || 0; + if (!openingDate || months <= 0) { + return null; + } + const maturity = new Date(openingDate.getTime()); + maturity.setMonth(maturity.getMonth() + months); + return Math.round((maturity.getTime() - openingDate.getTime()) / (24 * 60 * 60 * 1000)); + } + + /** + * More instalments than there are days in the term is not a bank rule to be checked, it is + * arithmetic: 365 daily collections cannot happen inside three months. Left unchecked it puts a + * three-month maturity date beside a maturity amount worked out over a year, and the customer is + * quoted a figure the account will never reach. + * + * Fewer instalments than days is normal and passes silently - that is just a bank that does not + * collect on Sundays and holidays. + */ + private withinTerm(): ValidatorFn { + return (control: AbstractControl): ValidationErrors | null => { + const term = this.termDays; + const days = Number(control.value) || 0; + if (term === null || days <= 0 || days <= term) { + return null; + } + return { exceedsTerm: { term } }; + }; + } + + private recalculateMaturityAmount(): void { + const raw = this.partyForm.getRawValue(); + this.partyForm + .get('maturityAmount') + ?.setValue( + this.maturityAmountOf( + Number(raw.dailyDepositAmount) || 0, + Number(raw.days) || 0, + Number(raw.interestRate) || 0 + ), + { emitEvent: false } + ); + } + + /** + * Standard recurring-deposit simple interest: each daily instalment earns for the time left to + * maturity, so the first earns on the full term and the last on none. That averages out to + * n(n+1)/2 instalment-days of interest on top of the n instalments themselves. + * + * At a zero rate this is simply the total of the instalments. + */ + private maturityAmountOf(dailyAmount: number, days: number, ratePercent: number): number { + if (dailyAmount <= 0 || days <= 0) { + return 0; + } + const principal = dailyAmount * days; + const interest = ((dailyAmount * days * (days + 1)) / 2) * (ratePercent / 100) * (1 / 365); + return Math.round((principal + interest) * 100) / 100; + } + + // ---------------------------------------------------------------- nominees + + get nominees(): UntypedFormArray { + return this.partyForm.get('nominees') as UntypedFormArray; + } + + private buildNomineeGroup(): UntypedFormGroup { + const group = this.formBuilder.group({ + nominationDate: [ + '', + Validators.required + ], + name: [ + '', + Validators.required + ], + // A nominee the bank cannot reach, or whose age it does not know, is a name and nothing more - + // and the age is what decides whether a guardian has to be named at all. + mobileNo: [ + '', + [ + Validators.required, + Validators.pattern(CreatePartyComponent.MOBILE_RE) + ] + ], + birthDate: [ + '', + Validators.required + ], + relation: [ + '', + Validators.required + ], + gender: [ + '', + Validators.required + ], + guardianName: [''], + // One of these belongs to each split and neither to both: a share under Percentage, a place + // in the order under Sequence. Required-ness is set from the split mode rather than here, so + // switching the mode does not leave a stale rule behind. + sharePercent: [''], + sequenceNo: [''] + }); + + this.applySplitModeTo(group, this.partyForm?.get('nomineeSplitMode')?.value); + + // A nominee who is a minor cannot receive the proceeds themselves, so a guardian is named. + // Driven by the date of birth, which is also what decides whether they are a minor at all. The + // clerk is asked for the date once and the rest follows, rather than being asked a second time + // in a way that can contradict the first. + const applyGuardianRule = () => { + const guardian = group.get('guardianName'); + if (!guardian) { + return; + } + if (this.isNomineeMinor(group)) { + guardian.setValidators(Validators.required); + } else { + guardian.clearValidators(); + guardian.setValue('', { emitEvent: false }); + } + guardian.updateValueAndValidity({ emitEvent: false }); + }; + + // Both dates decide it: being under age is the gap between them, so a correction to either can + // turn a nominee into a minor or out of one. + group.get('birthDate')?.valueChanges.subscribe(applyGuardianRule); + group.get('nominationDate')?.valueChanges.subscribe(applyGuardianRule); + + return group; + } + + addNominee(): void { + this.nominees.push(this.buildNomineeGroup()); + } + + removeNominee(index: number): void { + this.nominees.removeAt(index); + } + + /** True while the deposit is being split by share rather than by order of entitlement. */ + get splitByPercentage(): boolean { + return this.partyForm?.get('nomineeSplitMode')?.value === 'Percentage'; + } + + /** What the shares entered so far come to, so the clerk can see the remainder as they type. */ + get nomineeShareTotal(): number { + return this.nominees.controls.reduce((sum, group) => sum + Number(group.get('sharePercent')?.value || 0), 0); + } + + /** + * A nomination that does not account for the whole deposit names nobody for the remainder, so the + * form will not submit one. Shown as a running total rather than only on save, because a clerk + * who has typed four shares wants to know which one to change, not that the set is wrong. + * + * Silent until at least one share has been typed. A blank form has nothing wrong with it yet, and + * opening the tab to a red warning tells the clerk off for work they have not had a chance to do. + */ + get nomineeShareError(): string | null { + if (!this.splitByPercentage || this.nominees.length === 0 || !this.anyShareEntered) { + return null; + } + const total = this.nomineeShareTotal; + if (total === 100) { + return null; + } + const shortBy = Math.round((100 - total) * 100) / 100; + return shortBy > 0 ? `${shortBy}% still to allocate` : `${Math.abs(shortBy)}% over`; + } + + /** Whether the clerk has started allocating at all, which is what turns the total into a warning. */ + private get anyShareEntered(): boolean { + return this.nominees.controls.some((group) => String(group.get('sharePercent')?.value ?? '').trim() !== ''); + } + + /** True while the deposit passes down an order of entitlement rather than being split by share. */ + get splitBySequence(): boolean { + return this.partyForm?.get('nomineeSplitMode')?.value === 'Sequence'; + } + + /** + * That every place in the order is filled exactly once. + * + * Two nominees given the same place leaves the bank to choose between them, and a gap leaves + * nobody at that place - both are the order failing to say what it exists to say. Checked across + * the whole list, because 1 and 3 are each perfectly valid on their own. + */ + get nomineeOrderError(): string | null { + if (!this.splitBySequence || this.nominees.length === 0) { + return null; + } + const entered = this.nominees.controls + .map((group) => String(group.get('sequenceNo')?.value ?? '').trim()) + .filter((value) => value !== '') + .map(Number); + + if (entered.length === 0) { + return null; + } + const duplicate = entered.find((value, index) => entered.indexOf(value) !== index); + if (duplicate !== undefined) { + return `Order ${duplicate} is used twice.`; + } + if (entered.length < this.nominees.length) { + return 'Every nominee needs a place in the order.'; + } + const missing = Array.from({ length: this.nominees.length }, (_, i) => i + 1).filter((n) => !entered.includes(n)); + return missing.length > 0 ? `Order ${missing.join(', ')} is missing.` : null; + } + + /** + * Turns on whichever of the two the split actually uses, and clears the other. + * + * Leaving the unused one populated would send the server a share on a Sequence nomination, or an + * order on a Percentage one - a second answer to a question that split does not ask. + */ + private applySplitModeTo(group: AbstractControl, splitMode: string): void { + const share = group.get('sharePercent'); + const order = group.get('sequenceNo'); + const byPercentage = splitMode === 'Percentage'; + + if (share) { + if (byPercentage) { + share.setValidators([ + Validators.required, + Validators.min(0.01), + Validators.max(100) + ]); + } else { + share.clearValidators(); + share.setValue('', { emitEvent: false }); + } + share.updateValueAndValidity({ emitEvent: false }); + } + + if (order) { + if (byPercentage) { + order.clearValidators(); + order.setValue('', { emitEvent: false }); + } else { + order.setValidators([ + Validators.required, + Validators.min(1) + ]); + } + order.updateValueAndValidity({ emitEvent: false }); + } + } + + /** + * Whether this nominee was under age on the day they were nominated, worked out from their date + * of birth rather than from a separate Yes/No. + * + * Measured against the nomination date, not today, so the form agrees with what the server + * stores: a nominee who has since come of age was still a minor on the day they were named, and + * the guardian named beside them belongs to that day. + */ + isNomineeMinor(control: AbstractControl): boolean { + const birth = control.get('birthDate')?.value; + const nominated = control.get('nominationDate')?.value; + if (!birth || !nominated) { + return false; + } + const birthDate = new Date(birth); + const majorityOn = new Date(nominated); + majorityOn.setFullYear(majorityOn.getFullYear() - 18); + return birthDate > majorityOn; + } + + // ------------------------------------------------------------------ actions + + cancel(): void { + this.partyId = null; + this.partyStatus = 'ACTIVE'; + this.linkedSavingsAccountId = null; + this.savingsAccountNo = null; + this.savingsProductName = null; + this.savingsAccountStatus = null; + this.savingsAccountBalance = null; + this.accountNo = null; + this.accountSchemeName = null; + this.accountBalance = null; + this.majorOn = null; + this.minorTurnedMajor = false; + this.loadedClientName = ''; + this.loadedAgentName = ''; + this.loadedGuardianName = ''; + this.clientLookupFailed = false; + this.agentLookupFailed = false; + this.partyForm.reset(); + this.partyForm.patchValue({ + nominal: 'No', + operation: 'Self', + adultMinor: 'Adult', + lien: 'No', + lienReason: '', + isSmsActive: true, + isEmailActive: false, + freezeCode: 'Normal', + advCode: 'None', + nomineeApplicable: 'No', + nomineeSplitMode: 'Percentage', + currentBalance: 0, + openingBalance: 0, + maturityAmount: 0, + interestRate: 0 + }); + this.nominees.clear(); + this.applyAdultMinorRules(); + this.applyAdvCodeRules(); + } + + exit(): void { + this.router.navigate(['/accounts/dds']); + } + + 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 = { + // Either a client picked from the search list or an id typed straight in. + clientId: this.idOf(raw.clientId), + // idOf, not Number: the control now holds the picked agent object, and Number({}) is NaN. + agentId: this.idOf(raw.agentId), + officeId: raw.officeId, + 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 ? this.idOf(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, + ledgerNo: raw.ledgerNo || undefined, + // No interest rate: the server gives the account its product's rate. + withdrawType: raw.withdrawType || undefined, + lien: raw.lien === 'Yes', + // Sent only when the money is actually held. Releasing it clears the reason on the + // server, so a released account does not carry an explanation of a hold it no + // longer has. + lienReason: raw.lien === 'Yes' ? (raw.lienReason === 'Other' ? raw.lienReasonOther : raw.lienReason) : undefined, + isSmsActive: !!raw.isSmsActive, + isEmailActive: !!raw.isEmailActive, + 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) => ({ + // Sent through the same asDate as every other date on this form. A payload states one + // dateFormat and the server reads all of it that way, so a nominee date written in a second + // format is one the server is right to refuse. + nominationDate: asDate(nominee.nominationDate), + name: nominee.name, + mobileNo: nominee.mobileNo || undefined, + birthDate: asDate(nominee.birthDate), + relation: nominee.relation, + gender: nominee.gender, + guardianName: nominee.guardianName || undefined, + // Only the one its split actually uses is sent. The other is left out entirely, so the + // record says it was not stated rather than carrying a zero the server would have to tell + // apart from a real entitlement or a real place in the order. + sharePercent: this.splitByPercentage ? Number(nominee.sharePercent) : undefined, + sequenceNo: this.splitBySequence ? Number(nominee.sequenceNo) : undefined + })) + }; + + const partyLabel = raw.partyCode || raw.partyName; + const done = (response: any) => { + // Under maker-checker the command is only recorded, and nothing has been saved yet. + if (!!response?.commandId && response?.resourceId == null) { + this.alertService.alert({ + type: 'Sent for Approval', + message: this.partyId + ? `Changes to party ${partyLabel} were sent for a manager's approval and will take effect once approved.` + : `New party ${partyLabel} was sent for a manager's approval and will be created once approved.` + }); + } + this.exit(); + }; + + if (this.partyId) { + this.organizationService.updateParty(this.partyId, payload).subscribe(done); + } else { + this.organizationService.createParty(payload).subscribe(done); + } + } + + 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(this.settingsService.businessDate || new Date(), dateFormat), + dateFormat, + locale + }) + .subscribe(() => this.exit()); + } + + /** + * Whether this account can still be closed before its term is up. + * + * Only while the maturity date is ahead: once it has passed the account is paid out and closed on + * its own terms, and there is no shortfall to settle. The scheme's lock-in is deliberately not + * checked here - that period lives on the scheme, and a copy of the rule on this screen would drift + * from the one the server applies. An account inside its lock-in is offered the button and refused + * with the date it becomes closable, which tells the officer more than a missing button would. + */ + /** + * The client and agent as ids the API understands. Both fields are autocompletes, so each holds + * either the object picked from the list or the raw text typed into it - the dialog needs the + * client to look up which accounts can take the payout, and the agent's code only to name who is + * being charged. + */ + private get clientIdOfForm(): any { + const value = this.partyForm.get('clientId')?.value; + return value && typeof value === 'object' ? value.id : value; + } + + private get agentCodeOfForm(): string { + const value = this.partyForm.get('agentId')?.value; + if (value && typeof value === 'object') { + return value.agentCode; + } + // The form holds the agent's id; the dialog names them by code. + const agent = (this.allAgents || []).find((option: any) => String(option.id) === String(value)); + return agent?.agentCode ?? String(value ?? ''); + } + + get canCloseEarly(): boolean { + const maturity = this.partyForm.get('maturityDate')?.value; + if (!maturity) { + return false; + } + const matured = maturity instanceof Date ? maturity : new Date(maturity); + return !isNaN(matured.getTime()) && matured.getTime() > Date.now(); + } + + /** + * Closing early. The dialog asks the one thing the bank leaves open - whether the customer or the + * agent carries the cost - and shows what the scheme charges for each. Everything else, including + * the lock-in, is applied by the server. + */ + closeEarly(): void { + if (!this.partyId) { + return; + } + const dialogRef = this.dialog.open(PrematureCloseDialogComponent, { + data: { + partyCode: this.partyForm.get('partyCode')?.value, + partyName: this.partyForm.get('partyName')?.value, + subType: this.partyForm.get('subType')?.value, + interestRate: this.partyForm.get('interestRate')?.value, + maturityDate: this.partyForm.get('maturityDate')?.value, + currentBalance: this.partyForm.get('currentBalance')?.value, + openingDate: this.partyForm.get('openingDate')?.value, + clientId: this.clientIdOfForm, + agentCode: this.agentCodeOfForm + }, + width: '560px', + autoFocus: false + }); + + dialogRef.afterClosed().subscribe((closeCommand: any) => { + if (!closeCommand) { + return; + } + const partyCode = this.partyForm.get('partyCode')?.value; + // Inside the lock-in it is a request with a reason, on its own permission, for a manager to pass. + const withinLockin = !!closeCommand.lockinOverrideReason; + // The date goes up the way every other date on this system does - formatted to the user's own + // setting, with that setting and their locale alongside so the server reads it back the same + // way. An ordinary close sends it like this too; two ways of closing an account should not + // disagree about something as plain as what day it is. + const dateFormat = this.settingsService.dateFormat; + const locale = this.settingsService.language.code; + const request = { + ...closeCommand, + closeDate: this.dateUtils.formatDate(this.settingsService.businessDate || new Date(), dateFormat), + dateFormat, + locale + }; + (withinLockin + ? this.organizationService.prematureCloseOverrideParty(this.partyId as number, request) + : this.organizationService.prematureCloseParty(this.partyId as number, request) + ).subscribe((response: any) => { + // Under maker-checker the command is only recorded, and nothing has closed yet. + const awaitingApproval = !!response?.commandId && response?.resourceId == null; + this.alertService.alert({ + type: awaitingApproval ? 'Sent for Approval' : 'Party Closed', + message: awaitingApproval + ? `Early closure of ${partyCode} is waiting for a manager's approval. The account stays open until then.` + : `${partyCode} is closed.` + }); + this.exit(); + }); + // A refusal - inside the lock-in, held under lien, KYC outstanding - is shown by the platform's + // own error handler, which names the reason. Repeating it here would say the same thing twice. + }); + } + + // ------------------------------------------------------------------ helpers + + /** + * Splits a document type's name into lower-case words, so an identifier can be recognised by a + * whole word rather than a substring. + * + * Banks name these types freely - the records here carry "PAN Card" and "Aadhaar Card", while the + * code list also offers bare "PAN" and "AADHAAR" - so an exact-name match misses them. A substring + * match is worse: "COMPANY EMP ID" contains "pan", and would be filed as the customer's PAN. + */ + private wordsOf(name: any): string[] { + return String(name || '') + .toLowerCase() + .split(/[^a-z0-9]+/) + .filter((word) => word.length > 0); + } + + /** + * Indian digit grouping - 12,34,567.89 rather than 1,234,567.89. An officer reading the first + * says "twelve lakh" at a glance; the second has to be counted. + */ + private static readonly MONEY = new Intl.NumberFormat('en-IN', { + minimumFractionDigits: 2, + maximumFractionDigits: 2 + }); + + /** + * A figure the system keeps rather than the officer types, shown as money: grouped, always to + * paise, and right-aligned so a column of them lines up. The control keeps the plain number. + */ + money(controlName: string): string { + const value = Number(this.partyForm.get(controlName)?.value); + return Number.isFinite(value) ? CreatePartyComponent.MONEY.format(value) : ''; + } + + /** A client field holds either the record picked from the search list or an id typed straight in. */ + private idOf(value: any): number { + return Number(value && typeof value === 'object' ? value.id : value); + } + + private setField(controlName: string, value: any): void { + if (value === null || value === undefined || value === '') { + return; + } + this.partyForm.get(controlName)?.setValue(String(value)); + } + + /** Fineract hands dates back as [yyyy, mm, dd] in places and as a string in others. */ + private toDate(value: any): Date | null { + if (!value) { + return null; + } + if (value instanceof Date) { + return value; + } + if (Array.isArray(value) && value.length >= 3) { + return new Date(value[0], value[1] - 1, value[2]); + } + const parsed = new Date(value); + return isNaN(parsed.getTime()) ? null : parsed; + } + + private patchFormFromParty(party: any): void { + this.partyId = party.id; + this.partyStatus = party.status || 'ACTIVE'; + this.linkedSavingsAccountId = party.savingsAccountId || null; + this.savingsAccountNo = party.savingsAccountNo || null; + this.savingsProductName = party.savingsProductName || null; + this.savingsAccountStatus = party.savingsAccountStatus || null; + this.savingsAccountBalance = party.savingsAccountBalance ?? null; + this.accountNo = party.accountNo || null; + this.accountSchemeName = party.subTypeDescription || party.subType || null; + this.accountBalance = party.currentBalance ?? null; + this.majorOn = party.majorOn || null; + this.minorTurnedMajor = party.minorTurnedMajor === true; + this.loadedClientName = party.clientName || ''; + + // Needed for the Credit To A/C dropdown to show the saved account as chosen rather than blank, + // so it is fetched on edit as well as when a client is first looked up. + if (party.clientId) { + this.loadCreditToAccounts(String(party.clientId)); + } + this.loadedAgentName = party.agentName || ''; + this.partyForm.patchValue({ + clientId: party.clientId, + subType: party.subType, + agentId: party.agentId, + officeId: party.officeId, + 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: this.toDate(party.birthDate), + occupation: party.occupation, + risk: party.risk, + ckycId: party.ckycId, + adultMinor: party.adultMinor, + minorDate: this.toDate(party.minorDate), + guardianClientId: party.guardianClientId, + minAccountOpt: party.minAccountOpt, + relation: party.relation, + openingDate: this.toDate(party.openingDate), + month: party.month, + maturityDate: this.toDate(party.maturityDate), + reopenDate: this.toDate(party.reopenDate), + 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', + lienReason: this.listedLienReason(party.lienReason), + lienReasonOther: this.listedLienReason(party.lienReason) === 'Other' ? party.lienReason : '', + payDate: this.toDate(party.payDate), + paymentAmount: party.paymentAmount, + currentBalance: party.currentBalance, + openingBalance: party.openingBalance, + isSmsActive: party.isSmsActive, + isEmailActive: party.isEmailActive, + interestCalc: party.interestCalc, + interestDate: this.toDate(party.interestDate), + 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: this.toDate(nominee.nominationDate), + name: nominee.name, + mobileNo: nominee.mobileNo, + birthDate: this.toDate(nominee.birthDate), + relation: nominee.relation, + gender: nominee.gender, + guardianName: nominee.guardianName, + sharePercent: nominee.sharePercent, + sequenceNo: nominee.sequenceNo + }); + this.nominees.push(group); + }); + + this.applyAdultMinorRules(); + this.applyAdvCodeRules(); + + // A closed party is a record to look at, not to change. + if (this.isClosed) { + this.partyForm.disable({ emitEvent: false }); + } + } +} + +/** FEMALE or female -> Female; each word, so "SELF EMPLOYED" -> "Self Employed". */ +function titleCase(value: any): string { + return String(value ?? '') + .toLowerCase() + .replace(/(^|\s)(\S)/g, (_match, space, letter) => space + letter.toUpperCase()) + .trim(); +} diff --git a/src/app/deposits/party-master/due-for-closure/due-for-closure.component.html b/src/app/deposits/party-master/due-for-closure/due-for-closure.component.html new file mode 100644 index 000000000..8ff3cbce6 --- /dev/null +++ b/src/app/deposits/party-master/due-for-closure/due-for-closure.component.html @@ -0,0 +1,166 @@ + + +
+
+
+
+
+

Accounts Due for Closure

+

Matured DDS accounts still open — pay the customer, then close the account.

+
+
+
+ +
+
+ + @if (!loading && parties.length > 0) { +
+
+
+
+
{{ parties.length | number }}
+
Outstanding
+
+
+
+
+
+
{{ totalPayable | number: '1.2-2' }}
+
Still to Pay
+
+
+ + @if (awaitingCloseCount > 0) { +
+
+
+
{{ awaitingCloseCount | number }}
+
Ready to Close
+
+
+ } +
+ } + +
+ @if (loading) { +

Loading…

+ } @else if (parties.length === 0) { + +
+
+

Nothing to close

+

No DDS account has matured and been left open. Every matured account has been paid out and closed.

+
+ } @else { +
+ + + + + + + + + + + + + @for (party of parties; track party.id) { + + + + + + + + + } + +
PartyCustomerCollected ByMatured OnPayableAction
+
+
{{ party.partyCode }}
+ +
+
{{ party.partyName }} +
+
{{ party.agentName || '—' }}
+
{{ party.agentCode }}
+
+
+
+
{{ party.maturityDate | dateFormat }}
+ +
+ {{ daysOverdue(party) }} {{ daysOverdue(party) === 1 ? 'day' : 'days' }} ago +
+
+
+ @if (awaitingPayout(party)) { + {{ party.currentBalance | number: '1.2-2' }} + } @else { + + Paid out + } + + + @if (heldUnderLien(party)) { + + + + Held under lien + + } @else if (awaitingPayout(party)) { + + } @else { + + } +
+
+ } +
+
diff --git a/src/app/deposits/party-master/due-for-closure/due-for-closure.component.scss b/src/app/deposits/party-master/due-for-closure/due-for-closure.component.scss new file mode 100644 index 000000000..473e99186 --- /dev/null +++ b/src/app/deposits/party-master/due-for-closure/due-for-closure.component.scss @@ -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; + } +} diff --git a/src/app/deposits/party-master/due-for-closure/due-for-closure.component.ts b/src/app/deposits/party-master/due-for-closure/due-for-closure.component.ts new file mode 100644 index 000000000..7b210a9bc --- /dev/null +++ b/src/app/deposits/party-master/due-for-closure/due-for-closure.component.ts @@ -0,0 +1,214 @@ +/** + * Copyright since 2025 Mifos Initiative + * + * This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. + */ + +/** Angular Imports */ +import { Component, OnInit, inject } from '@angular/core'; +import { MatDialog } from '@angular/material/dialog'; +import { FaIconComponent } from '@fortawesome/angular-fontawesome'; +import { STANDALONE_SHARED_IMPORTS } from 'app/standalone-shared.module'; + +/** Custom Services */ +import { OrganizationService } from 'app/organization/organization.service'; +import { AlertService } from 'app/core/alert/alert.service'; +import { SettingsService } from 'app/settings/settings.service'; +import { Dates } from 'app/core/utils/dates'; + +/** Custom Components */ +import { PayoutDialogComponent } from '../payout-dialog/payout-dialog.component'; +import { ConfirmationDialogComponent } from 'app/shared/confirmation-dialog/confirmation-dialog.component'; + +/** + * The DDS accounts whose term has run out and which are still open - the branch's list of closures + * waiting to be done. + * + * A list of work, not a report. Nothing closes itself: a matured account is money the bank now owes + * the customer, and where it goes has to be agreed with them, so each one waits here until an + * officer deals with it. Before this existed the maturity date simply passed and nobody was told - + * the bank held the money and the customer had to think to ask for it. + * + * Two steps, not one, and an account stays on this list through both: + * + * balance owing pay it out - to another account of theirs, or into matured-deposits payable + * balance nil paid; close it + * + * Splitting them is what a branch actually does. The officer pays, sees the balance fall to nil and + * the money land where it was meant to, and only then closes. One click doing both would close + * accounts whose payment nobody had checked, and a closed account is far harder to put right. + * + * Oldest maturity first, because that is the one the bank has been holding longest. + */ +@Component({ + selector: 'mifosx-due-for-closure', + templateUrl: './due-for-closure.component.html', + styleUrls: ['./due-for-closure.component.scss'], + imports: [ + ...STANDALONE_SHARED_IMPORTS, + FaIconComponent + ] +}) +export class DueForClosureComponent implements OnInit { + private organizationService = inject(OrganizationService); + private dialog = inject(MatDialog); + private alertService = inject(AlertService); + private settingsService = inject(SettingsService); + private dateUtils = inject(Dates); + + parties: any[] = []; + loading = true; + + ngOnInit(): void { + this.load(); + } + + private load(): void { + this.loading = true; + this.organizationService.getPartiesDueForClosure().subscribe({ + next: (parties: any) => { + this.parties = Array.isArray(parties) ? parties : parties?.pageItems || []; + this.loading = false; + }, + error: () => { + this.parties = []; + this.loading = false; + } + }); + } + + /** What the bank is holding in total across every matured account still open. */ + get totalPayable(): number { + return this.parties.reduce((sum, party) => sum + Number(party.currentBalance || 0), 0); + } + + /** + * Whether this one is still waiting on its payment. Anything left on the account is the customer's + * money, so it is paid before the account can be closed. + */ + awaitingPayout(party: any): boolean { + return Number(party.currentBalance || 0) > 0; + } + + /** + * Whether this deposit is standing as security for a loan. + * + * The term has ended and the money is owed, but it cannot leave while it is pledged - so the row + * says so rather than offering a Pay Out the server would refuse. A customer who needed cash before + * their term ended borrowed against the deposit instead of breaking it; the deposit is paid when + * that loan is cleared. + */ + heldUnderLien(party: any): boolean { + return Number(party.amountHeldUnderLien || 0) > 0; + } + + /** How many are already paid and now only need closing - the quicker half of the list. */ + get awaitingCloseCount(): number { + return this.parties.filter((party) => !this.awaitingPayout(party)).length; + } + + /** + * How long the bank has been sitting on this one. Shown because a maturity from three months ago + * is a different problem from one from yesterday, and the list alone would not say so. + */ + daysOverdue(party: any): number { + const matured = this.toDate(party.maturityDate); + if (!matured) { + return 0; + } + return Math.max(0, Math.floor((Date.now() - matured.getTime()) / 86400000)); + } + + /** The API sends dates as [year, month, day]; month is 1-based there and 0-based in Date. */ + private toDate(value: any): Date | null { + if (Array.isArray(value) && value.length >= 3) { + return new Date(value[0], value[1] - 1, value[2]); + } + return value ? new Date(value) : null; + } + + payOut(party: any): void { + const dialogRef = this.dialog.open(PayoutDialogComponent, { + data: party, + width: '520px', + autoFocus: false + }); + + dialogRef.afterClosed().subscribe((payoutCommand: any) => { + if (!payoutCommand) { + return; + } + this.organizationService.payOutParty(party.id, payoutCommand).subscribe({ + next: (result: any) => { + if (awaitingApproval(result)) { + this.alertService.alert({ + type: 'Sent for Approval', + message: `Payout of ${party.partyCode} is waiting for a manager's approval. Nothing has been paid yet.` + }); + this.load(); + return; + } + this.alertService.alert({ + type: 'Party Paid Out', + message: payoutCommand.toSavingsAccountNo + ? `${party.partyCode} paid into ${payoutCommand.toSavingsAccountNo} and the account is closed.` + : `${party.partyCode} parked in Sundry in the customer's name and the account is closed.` + }); + this.load(); + } + // A refusal is shown by the platform's own error handler, which names the reason - repeating + // it here would say the same thing twice. + }); + }); + } + + /** + * Closes an account whose money has already gone out. The server refuses one still carrying a + * balance, so this cannot close an account out from under an unpaid customer. + * + * Asked for first, because closing is the one step here with no way back. + */ + closeAccount(party: any): void { + const confirmation = this.dialog.open(ConfirmationDialogComponent, { + data: { + heading: `Close ${party.partyCode}?`, + dialogContext: `${party.partyName}'s account has been paid out in full and can be closed. It cannot be reopened.`, + type: 'warn' + } + }); + + confirmation.afterClosed().subscribe((response: any) => { + if (!response?.confirm) { + return; + } + const dateFormat = this.settingsService.dateFormat; + const locale = this.settingsService.language.code; + this.organizationService + .closeParty(party.id, { + closeDate: this.dateUtils.formatDate(this.settingsService.businessDate || new Date(), dateFormat), + dateFormat, + locale + }) + .subscribe({ + next: (result: any) => { + this.alertService.alert( + awaitingApproval(result) + ? { + type: 'Sent for Approval', + message: `Closing ${party.partyCode} is waiting for a manager's approval. It stays open until then.` + } + : { type: 'Party Closed', message: `${party.partyCode} is closed.` } + ); + this.load(); + } + }); + }); + } +} + +/** Under maker-checker the command is only recorded - it has a command id but no resource yet. */ +function awaitingApproval(result: any): boolean { + return !!result?.commandId && result?.resourceId == null; +} diff --git a/src/app/deposits/party-master/nominee-list-dialog/nominee-list-dialog.component.html b/src/app/deposits/party-master/nominee-list-dialog/nominee-list-dialog.component.html new file mode 100644 index 000000000..52305780e --- /dev/null +++ b/src/app/deposits/party-master/nominee-list-dialog/nominee-list-dialog.component.html @@ -0,0 +1,79 @@ + + +

+ Nominees + {{ data.partyCode }} · {{ data.partyName }} +

+ + + @if (nominees.length === 0) { +

Nobody is nominated on this account.

+ } @else { +

+ @if (bySequence) { + Paid in the order listed — the first named who can be found receives the deposit. + } @else { + Split by share of the deposit. + } +

+ + + + + + + + + @if (!bySequence) { + + } + + + + @for (nominee of nominees; track nominee.id; let i = $index) { + + + + + + + @if (!bySequence) { + + } + + } + +
+ @if (bySequence) { + Order + } @else { + # + } + NameRelationNominatedShare
{{ nominee.sequenceNo ?? i + 1 }} + {{ nominee.name }} + @if (nominee.minor) { + minor · guardian {{ nominee.guardianName || '—' }} + } + {{ nominee.relation }}{{ nominee.nominationDate | dateFormat }} + @if (nominee.sharePercent !== null && nominee.sharePercent !== undefined) { + {{ nominee.sharePercent }}% + } @else { + — + } +
+ + @if (shareWarning) { +

{{ shareWarning }}

+ } + } +
+ + + + diff --git a/src/app/deposits/party-master/nominee-list-dialog/nominee-list-dialog.component.scss b/src/app/deposits/party-master/nominee-list-dialog/nominee-list-dialog.component.scss new file mode 100644 index 000000000..cfcbc78ea --- /dev/null +++ b/src/app/deposits/party-master/nominee-list-dialog/nominee-list-dialog.component.scss @@ -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; +} diff --git a/src/app/deposits/party-master/nominee-list-dialog/nominee-list-dialog.component.ts b/src/app/deposits/party-master/nominee-list-dialog/nominee-list-dialog.component.ts new file mode 100644 index 000000000..e6e5b4c10 --- /dev/null +++ b/src/app/deposits/party-master/nominee-list-dialog/nominee-list-dialog.component.ts @@ -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(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%.`; + } +} diff --git a/src/app/deposits/party-master/party-master.component.html b/src/app/deposits/party-master/party-master.component.html new file mode 100644 index 000000000..711260aff --- /dev/null +++ b/src/app/deposits/party-master/party-master.component.html @@ -0,0 +1,240 @@ + + +
+
+
+
+
+

Party Master

+

The DDS accounts on file — each one a customer collected from daily.

+
+
+
+
+ + {{ totalCount }} + {{ totalCount === 1 ? 'party' : 'parties' }} +
+ +
+
+ + + @if (dueForClosureCount > 0) { + + + + {{ dueForClosureCount }} + {{ dueForClosureCount === 1 ? 'account has' : 'accounts have' }} matured and + {{ dueForClosureCount === 1 ? 'is' : 'are' }} still open — + + @if (dueForClosurePayable > 0) { + {{ dueForClosurePayable | number: '1.2-2' }} still owed to customers. + } @else { + paid in full, waiting to be closed. + } + + Settle {{ dueForClosureCount === 1 ? 'it' : 'them' }} + + } + +
+
+
+
+
{{ activeCount | number }}
+
Open Accounts
+
+
+
+
+
+
{{ totalDailyDue | number: '1.2-2' }}
+
Daily Collection Due
+
+
+
+
+
+
{{ totalCurrentBalance | number: '1.2-2' }}
+
Total Deposits Held
+
+
+
+ +
+
+ +
+ + @if (totalCount === 0) { +
+
+

No parties yet

+

Open one so an agent can start collecting daily deposits against it.

+ +
+ } @else { +
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
Party Code +
+
+
+
{{ p.partyCode }}
+
{{ p.city || '—' }}
+
+
+
Party Name{{ p.partyName }}Sub Type + {{ p.subTypeDescription || p.subType }} + Collected By +
+
{{ p.agentName || '—' }}
+
{{ p.agentCode }}
+
+
DDS Account + @if (p.accountNo) { +
+ +
{{ p.subTypeDescription || p.subType }}
+
+ } @else if (p.savingsAccountNo) { +
+ +
{{ p.savingsAccountStatus }}
+
+ } @else { + + } +
Daily Deposit{{ p.dailyDepositAmount | number: '1.2-2' }}Current Balance + {{ p.currentBalance | number: '1.2-2' }} + Status + + {{ p.status === 'CLOSED' ? 'Closed' : 'Active' }} + + Nominees + @if (nomineeCount(p) > 0) { + + } @else { + None + } + Actions + +
+ + @if (filteredCount === 0 && totalCount > 0) { +
+ + No parties match your filter. +
+ } + + +
+ } +
+
diff --git a/src/app/deposits/party-master/party-master.component.scss b/src/app/deposits/party-master/party-master.component.scss new file mode 100644 index 000000000..b235f0f44 --- /dev/null +++ b/src/app/deposits/party-master/party-master.component.scss @@ -0,0 +1,133 @@ +/** + * Copyright since 2025 Mifos Initiative + * + * This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. + */ + +@use 'assets/styles/product-list' as pl; + +@include pl.product-list-styles; + +/* Party-specific amber accent */ +:host { + --pl-accent: #b45309; + --pl-accent-l: #fef3c7; +} + +/* A closed party stays listed - its collections and interest are still on the books. */ +.pm-row--closed td:not(.num) { + opacity: 0.55; +} + +.pm-pill { + display: inline-block; + padding: 3px 11px; + border-radius: 11px; + font-size: 0.72rem; + font-weight: 500; + line-height: 1.7; + background: #dcfce7; + color: #15803d; + white-space: nowrap; +} + +.pm-pill--off { + background: rgb(0 0 0 / 7%); + color: rgb(0 0 0 / 52%); +} + +/* The customer's real deposit account number, in the list. */ +.pm-list-account { + font-family: ui-monospace, 'SF Mono', 'Cascadia Mono', monospace; + font-size: 0.8rem; +} + +/* + A party with no account behind it. Called out rather than left blank: the balance beside it is + money the bank's own books have no record of, which is the thing worth noticing here. +*/ +.pm-list-no-account { + font-size: 0.75rem; + font-style: italic; + color: #9b2c2c; +} + +/* + Matured accounts still open. Deliberately loud: it is money the bank owes a customer who has not + been told, and every day it sits there is a day the bank is holding somebody else's money. Styled + as a link to the work rather than a notice about it - reading it should be the same click as + starting on it. +*/ +.pm-due-banner { + display: flex; + align-items: center; + gap: 12px; + margin: 0 0 14px; + padding: 12px 16px; + background: rgb(180 83 9 / 8%); + border: 1px solid rgb(180 83 9 / 28%); + border-radius: 8px; + color: #92400e; + text-decoration: none; + transition: background 0.15s ease; + + &:hover { + background: rgb(180 83 9 / 13%); + } + + &__ic { + font-size: 1.05rem; + flex: 0 0 auto; + } + + &__text { + flex: 1 1 auto; + font-size: 0.88rem; + line-height: 1.45; + } + + &__cta { + flex: 0 0 auto; + display: inline-flex; + align-items: center; + gap: 6px; + font-size: 0.82rem; + font-weight: 600; + white-space: nowrap; + } +} + +@media (width <= 700px) { + .pm-due-banner { + flex-wrap: wrap; + } +} + +/* + * The nominee count, which opens the list of names rather than the account itself. + * + * Styled as text that can be clicked rather than as a second action button: the row already has one + * action, and a pair of equal-looking buttons would put "look at this" beside "change this" as + * though the two were the same kind of thing. + */ +.pl-nominee-btn { + border: 0; + background: none; + padding: 0; + font: inherit; + color: var(--pl-accent); + font-weight: 500; + cursor: pointer; + text-decoration: underline; + text-underline-offset: 3px; + + &:hover { + text-decoration-thickness: 2px; + } +} + +.pl-muted { + color: rgb(0 0 0 / 40%); +} diff --git a/src/app/deposits/party-master/party-master.component.ts b/src/app/deposits/party-master/party-master.component.ts new file mode 100644 index 000000000..7ec5f2c8a --- /dev/null +++ b/src/app/deposits/party-master/party-master.component.ts @@ -0,0 +1,198 @@ +/** + * Copyright since 2025 Mifos Initiative + * + * This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. + */ + +/** Angular Imports */ +import { Component, OnInit, ViewChild, inject } from '@angular/core'; +import { MatPaginator } from '@angular/material/paginator'; +import { MatSort, MatSortHeader } from '@angular/material/sort'; +import { + MatTable, + MatTableDataSource, + MatColumnDef, + MatHeaderCellDef, + MatHeaderCell, + MatCellDef, + MatCell, + MatHeaderRowDef, + MatHeaderRow, + MatRowDef, + MatRow +} from '@angular/material/table'; +import { MatTooltip } from '@angular/material/tooltip'; +import { FaIconComponent } from '@fortawesome/angular-fontawesome'; +import { STANDALONE_SHARED_IMPORTS } from 'app/standalone-shared.module'; + +import { MatDialog } from '@angular/material/dialog'; + +/** Custom Services */ +import { OrganizationService } from 'app/organization/organization.service'; + +/** Custom Components */ +import { NomineeListDialogComponent } from './nominee-list-dialog/nominee-list-dialog.component'; + +/** + * Party Master list. + * + * The DDS accounts on file - each one a customer's daily deposit account, opened under a scheme + * (Sub Type) and assigned to the agent who collects for it. Follows the same list -> create/edit + * shape as Agent Master and the product lists, and shares their stylesheet. + */ +@Component({ + selector: 'mifosx-party-master', + templateUrl: './party-master.component.html', + styleUrls: ['./party-master.component.scss'], + imports: [ + ...STANDALONE_SHARED_IMPORTS, + FaIconComponent, + MatTable, + MatSort, + MatColumnDef, + MatHeaderCellDef, + MatHeaderCell, + MatSortHeader, + MatCellDef, + MatCell, + MatHeaderRowDef, + MatHeaderRow, + MatRowDef, + MatRow, + MatPaginator, + MatTooltip + ] +}) +export class PartyMasterComponent implements OnInit { + private organizationService = inject(OrganizationService); + private dialog = inject(MatDialog); + + displayedColumns: string[] = [ + 'partyCode', + 'partyName', + 'subType', + 'agentName', + 'savingsAccountNo', + 'dailyDepositAmount', + 'currentBalance', + 'nominees', + 'status', + 'actions' + ]; + + /** + * Who a party is nominated to, without opening it for editing. + * + * Asked often enough to belong on the list, and answered from what the list already holds - the + * parties come back with their nominees on them, so this opens rather than fetches. + */ + showNominees(party: any): void { + this.dialog.open(NomineeListDialogComponent, { + data: { + partyCode: party.partyCode, + partyName: party.partyName, + splitMode: party.nomineeSplitMode, + nominees: party.nominees || [] + }, + autoFocus: false + }); + } + + /** How many people are named on an account, which is what the list column shows. */ + nomineeCount(party: any): number { + return (party?.nominees || []).length; + } + + /** + * Matured accounts still open, and what they add up to. Shown here because this is the screen an + * officer already opens - money the bank owes a customer should not wait on somebody thinking to + * run a report. + */ + dueForClosureCount = 0; + dueForClosurePayable = 0; + + dataSource: MatTableDataSource = new MatTableDataSource([]); + parties: any[] = []; + filteredCount = 0; + + paginator: MatPaginator; + sort: MatSort; + + // The table sits inside an @if, so these only exist once the list has loaded. Read statically they + // were never there, the table was never paged, and the paginator read "0 of 0" over a full list. + @ViewChild(MatPaginator) set matPaginator(paginator: MatPaginator) { + this.paginator = paginator; + if (paginator) { + this.dataSource.paginator = paginator; + } + } + + @ViewChild(MatSort) set matSort(sort: MatSort) { + this.sort = sort; + if (sort) { + this.dataSource.sort = sort; + } + } + + get totalCount(): number { + return this.parties.length; + } + + get activeCount(): number { + return this.parties.filter((party) => party.status !== 'CLOSED').length; + } + + /** What the whole book is worth today - every open account's running balance added up. */ + get totalCurrentBalance(): number { + return this.parties.reduce((sum, party) => sum + Number(party.currentBalance || 0), 0); + } + + /** What the agents should be collecting between them on a full collection day. */ + get totalDailyDue(): number { + return this.parties + .filter((party) => party.status !== 'CLOSED') + .reduce((sum, party) => sum + Number(party.dailyDepositAmount || 0), 0); + } + + ngOnInit(): void { + this.organizationService.getParties().subscribe((parties: any[]) => { + this.parties = parties || []; + this.dataSource = new MatTableDataSource(this.parties); + this.dataSource.paginator = this.paginator; + this.dataSource.sort = this.sort; + this.filteredCount = this.parties.length; + this.dataSource.connect().subscribe((rows) => (this.filteredCount = rows.length)); + }); + + this.loadDueForClosure(); + } + + /** + * Matured accounts still open. A separate call rather than filtered out of the list above, because + * the server decides what counts as matured - comparing dates here would drift from it the first + * time either changed. + */ + private loadDueForClosure(): void { + this.organizationService.getPartiesDueForClosure().subscribe({ + next: (due: any) => { + const rows = Array.isArray(due) ? due : due?.pageItems || []; + this.dueForClosureCount = rows.length; + this.dueForClosurePayable = rows.reduce( + (sum: number, party: any) => sum + Number(party.currentBalance || 0), + 0 + ); + }, + // The party list is the point of this screen; a failed banner must not take it down with it. + error: () => { + this.dueForClosureCount = 0; + this.dueForClosurePayable = 0; + } + }); + } + + applyFilter(filterValue: string): void { + this.dataSource.filter = filterValue.trim().toLowerCase(); + } +} diff --git a/src/app/deposits/party-master/payout-dialog/payout-dialog.component.html b/src/app/deposits/party-master/payout-dialog/payout-dialog.component.html new file mode 100644 index 000000000..7553845f3 --- /dev/null +++ b/src/app/deposits/party-master/payout-dialog/payout-dialog.component.html @@ -0,0 +1,164 @@ + + +

Pay out {{ data.partyCode }}

+ + + +
+
+ Customer + {{ data.partyName }} +
+
+ Matured on + {{ data.maturityDate | dateFormat }} +
+
+ Opened on + {{ data.openingDate | dateFormat }} +
+ + @if (breakdown) { +
+
+ Deposits collected + {{ breakdown.deposits | number: '1.2-2' }} +
+ @if (breakdown.transfersIn > 0) { +
+ Transferred in + + {{ breakdown.transfersIn | number: '1.2-2' }} +
+ } + @if (breakdown.transfersOut > 0) { +
+ Transferred out + − {{ breakdown.transfersOut | number: '1.2-2' }} +
+ } + @if (breakdown.other !== 0) { +
+ Other (opening balance / adjustments) + {{ breakdown.other | number: '1.2-2' }} +
+ } +
+ + Interest @ {{ data.interestRate | number: '1.2-2' }}% + {{ data.openingDate | dateFormat }} to {{ data.interestDate | dateFormat }} + + + {{ breakdown.interest | number: '1.2-2' }} +
+ @if (breakdown.interestToPost > 0) { +
+ Interest calculated, posted with this payout + + {{ breakdown.interestToPost | number: '1.2-2' }} +
+ } +
+ } + +
+ Total payable + {{ (breakdown ? breakdown.total : data.currentBalance) | number: '1.2-2' }} +
+ @if (interestStillToCome) { +

+ Interest from {{ data.interestDate | dateFormat }} to {{ data.maturityDate | dateFormat }} is not worked out yet + - it is added when this is paid out, so the amount paid will be a little more. +

+ } +
+

+ Paying out closes {{ data.partyCode }} - the balance is nil afterwards and it takes nothing more. +

+ + @if (loading) { + + } @else { +

Where should this go?

+ + + + Into another account of theirs here + @if (ownAccounts.length === 0) { + {{ data.partyName }} has no other savings account at this bank + } @else { + Moved across today + } + + + @if (destination === 'own' && ownAccounts.length > 0) { + + } + + + Park in Sundry + When the customer has no account here - held in the bank's Sundry account in their name + + + + @if (destination === 'other') { + + } + + @if (destination === 'other') { + +
+ The money comes off {{ data.partyCode }} today and is held in the Sundry account chosen above against + {{ data.partyName }} until it is paid to them - so it is accounted for the whole time and nothing is lost. +
+ } + } +
+ + + + + diff --git a/src/app/deposits/party-master/payout-dialog/payout-dialog.component.scss b/src/app/deposits/party-master/payout-dialog/payout-dialog.component.scss new file mode 100644 index 000000000..7e62e8ee6 --- /dev/null +++ b/src/app/deposits/party-master/payout-dialog/payout-dialog.component.scss @@ -0,0 +1,112 @@ +/** + * Copyright since 2025 Mifos Initiative + * + * This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. + */ + +/* What is being paid and to whom. Read against the customer standing there, so it comes first. */ +.po-summary { + display: flex; + flex-direction: column; + gap: 6px; + padding: 12px 14px; + margin-bottom: 18px; + background: rgb(0 0 0 / 3%); + border-radius: 6px; + + &__row { + display: flex; + justify-content: space-between; + gap: 16px; + font-size: 0.85rem; + + /* The figure the officer is about to hand over - given the weight that deserves. */ + &--amount { + padding-top: 6px; + border-top: 1px solid rgb(0 0 0 / 8%); + font-size: 1rem; + } + } + + &__label { + color: rgb(0 0 0 / 55%); + } + + &__value { + font-weight: 500; + font-variant-numeric: tabular-nums; + } + + &__note { + display: block; + margin: 0; + font-size: 0.75rem; + color: rgb(0 0 0 / 45%); + } +} + +/* How the amount payable is made up - one line per source, adding up to the total below. */ +.po-breakdown { + display: flex; + flex-direction: column; + gap: 6px; + padding-top: 6px; + border-top: 1px dashed rgb(0 0 0 / 10%); +} + +.po-question { + margin: 0 0 10px; + font-size: 0.9rem; + font-weight: 500; +} + +.po-choices { + display: flex; + flex-direction: column; + gap: 4px; +} + +/* + * Each choice carries its own consequence underneath it. Two radio labels alone would not tell an + * officer that one keeps the money inside the bank and the other starts it on its way out. + */ +.po-choice { + display: block; + padding: 4px 0; + + &__title { + display: block; + font-size: 0.88rem; + } + + &__note { + display: block; + font-size: 0.75rem; + color: rgb(0 0 0 / 55%); + line-height: 1.4; + } +} + +.po-account { + width: 100%; + margin: 8px 0 4px 32px; + max-width: calc(100% - 32px); +} + +/* What will actually happen, in the words the customer will ask about. */ +.po-callout { + margin-top: 12px; + padding: 10px 12px; + background: rgb(180 83 9 / 8%); + border: 1px solid rgb(180 83 9 / 22%); + border-radius: 6px; + color: #92400e; + font-size: 0.78rem; + line-height: 1.5; +} + +.po-hint-warn { + color: #c62828; +} diff --git a/src/app/deposits/party-master/payout-dialog/payout-dialog.component.ts b/src/app/deposits/party-master/payout-dialog/payout-dialog.component.ts new file mode 100644 index 000000000..0ca81b9c1 --- /dev/null +++ b/src/app/deposits/party-master/payout-dialog/payout-dialog.component.ts @@ -0,0 +1,230 @@ +/** + * Copyright since 2025 Mifos Initiative + * + * This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. + */ + +/** Angular Imports */ +import { Component, OnInit, inject } from '@angular/core'; +import { CommonModule } from '@angular/common'; +import { FormsModule } from '@angular/forms'; +import { MAT_DIALOG_DATA, MatDialogModule, MatDialogRef } from '@angular/material/dialog'; +import { MatButtonModule } from '@angular/material/button'; +import { MatRadioModule } from '@angular/material/radio'; +import { MatSelectModule } from '@angular/material/select'; +import { MatAutocompleteModule } from '@angular/material/autocomplete'; +import { MatFormFieldModule } from '@angular/material/form-field'; +import { MatInputModule } from '@angular/material/input'; +import { MatProgressBarModule } from '@angular/material/progress-bar'; + +/** Custom Services */ +import { OrganizationService } from 'app/organization/organization.service'; + +/** Custom Pipes */ +import { DateFormatPipe } from 'app/pipes/date-format.pipe'; + +/** + * Where a matured DDS account's money should go. + * + * Two destinations, and the difference is whether the money leaves the bank. Another account of the + * customer's here is a move across the books. Anywhere else means a transfer out, which clears later + * or fails - so the money is parked in the matured-deposits payable account first and the branch + * sends it on from there, rather than being launched into a gap where it has left one account and + * arrived at none. + * + * The amount is not asked for. It is whatever the account holds, worked out by the server: a figure + * typed here could pay out more than the customer has, or quietly leave some behind. + */ +@Component({ + selector: 'mifosx-payout-dialog', + standalone: true, + imports: [ + CommonModule, + FormsModule, + MatDialogModule, + MatButtonModule, + MatRadioModule, + MatSelectModule, + MatAutocompleteModule, + MatFormFieldModule, + MatInputModule, + MatProgressBarModule, + DateFormatPipe + ], + templateUrl: './payout-dialog.component.html', + styleUrls: ['./payout-dialog.component.scss'] +}) +export class PayoutDialogComponent implements OnInit { + private organizationService = inject(OrganizationService); + private dialogRef = inject(MatDialogRef); + data = inject(MAT_DIALOG_DATA); + + /** 'own' moves it to another account here; 'other' parks it for a transfer to another bank. */ + destination: 'own' | 'other' = 'own'; + + /** + * The customer's accounts this maturity can be paid into. + * + * Worked out by the server, which applies the same conditions the payout enforces: an active + * savings account of theirs, not a fixed or recurring deposit, and not one of their DDS scheme + * accounts. Filtering here instead let the two drift - the list once offered a scheme account + * belonging to a party that had already closed, which would have stranded the money. + */ + ownAccounts: any[] = []; + selectedAccountNo = ''; + + /** Where the balance is parked when the customer has no account here. */ + sundryAccounts: any[] = []; + sundryGlAccountId: number | null = null; + /** What is typed in the GL box - a code or part of a name. */ + sundrySearch = ''; + + /** The accounts matching what is typed, by GL code or name, in any case. */ + get sundryOptions(): any[] { + const typed = this.sundrySearch.trim().toLowerCase(); + if (!typed || typed === this.sundryLabel(this.chosenSundry).toLowerCase()) { + return this.sundryAccounts; + } + return this.sundryAccounts.filter( + (account: any) => + String(account.glCode).toLowerCase().includes(typed) || + String(account.name || '') + .toLowerCase() + .includes(typed) + ); + } + + get chosenSundry(): any { + return this.sundryAccounts.find((account: any) => account.id === this.sundryGlAccountId); + } + + /** Shows a picked account in the box as its code and name. */ + readonly sundryDisplay = (value: any): string => + value && typeof value === 'object' ? this.sundryLabel(value) : value || ''; + + sundryLabel(account: any): string { + return account ? `${account.glCode} · ${account.name}` : ''; + } + + pickSundry(account: any): void { + this.sundryGlAccountId = account ? account.id : null; + this.sundrySearch = this.sundryLabel(account); + } + + /** Typing again clears the choice until an account is picked, so a half-typed code is never sent. */ + onSundryTyped(value: any): void { + // Picking from the list hands over the account itself rather than text. + if (value && typeof value === 'object') { + this.pickSundry(value); + return; + } + value = String(value ?? ''); + this.sundrySearch = value; + if (value !== this.sundryLabel(this.chosenSundry)) { + const exact = this.sundryAccounts.find( + (account: any) => String(account.glCode).toLowerCase() === value.trim().toLowerCase() + ); + this.sundryGlAccountId = exact ? exact.id : null; + } + } + loading = true; + + /** + * How the amount payable was made up - what was deposited, what came in or went out by transfer, + * and the interest - read from the account's own transactions so the officer can explain the + * figure to the customer line by line. Null until the transactions have arrived. + */ + breakdown: { + deposits: number; + transfersIn: number; + transfersOut: number; + interest: number; + other: number; + interestToPost: number; + total: number; + } | null = null; + + ngOnInit(): void { + // The accounts the balance can be parked in. None is chosen: each bank has its own Sundry account, + // and the clerk searches for it and picks it. + this.organizationService.getDdsSundryAccounts().subscribe({ + next: (accounts: any) => (this.sundryAccounts = accounts || []), + error: () => (this.sundryAccounts = []) + }); + this.organizationService.getPartyTransactionsOfParty(this.data.id).subscribe({ + next: (transactions: any) => (this.breakdown = this.breakdownOf(Array.isArray(transactions) ? transactions : [])), + error: () => (this.breakdown = null) + }); + + this.organizationService.getCreditableAccounts(this.data.clientId).subscribe({ + next: (accounts: any) => { + this.ownAccounts = accounts || []; + + // Nothing here to pay into, so the only real choice is a transfer out - offered as the + // default rather than leaving the officer on an option that cannot be completed. + if (this.ownAccounts.length === 0) { + this.destination = 'other'; + } else if (this.ownAccounts.length === 1) { + this.selectedAccountNo = this.ownAccounts[0].accountNo; + } + this.loading = false; + }, + error: () => { + this.ownAccounts = []; + this.destination = 'other'; + this.loading = false; + } + }); + } + + /** Interest is still to be worked out for part of the term, and the payout will add it. */ + get interestStillToCome(): boolean { + return ( + !!this.data.interestDate && + !!this.data.maturityDate && + String(this.data.interestDate) < String(this.data.maturityDate) + ); + } + + private breakdownOf(transactions: any[]) { + const sum = (test: (txn: any) => boolean) => + transactions.filter(test).reduce((total: number, txn: any) => total + Number(txn.amount || 0), 0); + const signed = (type: string) => + sum((txn) => txn.transactionType === type && txn.direction === 'CREDIT') - + sum((txn) => txn.transactionType === type && txn.direction === 'DEBIT'); + const isTransfer = (txn: any) => txn.transactionType === 'WITHDRAW_DEPOSITE' || txn.transactionType === 'TRANSFER'; + + const deposits = signed('PARTY_TRAN'); + const transfersIn = sum((txn) => isTransfer(txn) && txn.direction === 'CREDIT'); + const transfersOut = sum((txn) => isTransfer(txn) && txn.direction === 'DEBIT'); + const interest = + signed('INTEREST_POST') + + signed('OD_INTEREST_POST') + + signed('PREMATURE_AGENT_TOP_UP') + + signed('PREMATURE_INT_RECOVERY'); + const balance = Number(this.data.currentBalance || 0); + // Anything the four lines above do not cover - an opening balance brought over, say - so the lines + // still add up to the balance rather than leaving the officer a gap to explain. + const other = Math.round((balance - (deposits + transfersIn - transfersOut + interest)) * 100) / 100; + // Interest already worked out but not yet credited is posted by the payout itself, first. + const interestToPost = Number(this.data.interestCalc || 0); + return { deposits, transfersIn, transfersOut, interest, other, interestToPost, total: balance + interestToPost }; + } + + get canPayOut(): boolean { + if (this.loading) { + return false; + } + return this.destination === 'other' ? !!this.sundryGlAccountId : !!this.selectedAccountNo; + } + + payOut(): void { + this.dialogRef.close( + this.destination === 'own' + ? { toSavingsAccountNo: this.selectedAccountNo } + : { sundryGlAccountId: this.sundryGlAccountId } + ); + } +} diff --git a/src/app/deposits/party-master/premature-close-dialog/premature-close-dialog.component.html b/src/app/deposits/party-master/premature-close-dialog/premature-close-dialog.component.html new file mode 100644 index 000000000..7fc420782 --- /dev/null +++ b/src/app/deposits/party-master/premature-close-dialog/premature-close-dialog.component.html @@ -0,0 +1,175 @@ + + +

Close {{ data.partyCode }} early

+ + + @if (loading) { + + } + + +
+
+ Customer + {{ data.partyName }} +
+
+ Balance + {{ data.currentBalance | number: '1.2-2' }} +
+
+ Matures on + {{ data.maturityDate | dateFormat }} +
+
+ Scheme rate + {{ data.interestRate | number: '1.0-2' }}% +
+
+ +

Who bears the cost of closing early?

+ + + + + + + + + @if (scheme && scheme.prematureAgentCommissionPenalty > 0) { +

+ Either way, {{ data.agentCode }} is charged + {{ scheme.prematureAgentCommissionPenalty | number: '1.0-2' }}% + of the commission owed to them — the round was not kept going. +

+ } + +

Where should the balance go?

+ + + + Into another account of theirs at this bank + + Park in Sundry (no account here) + + + @if (destination === 'own' && ownAccounts.length > 0) { + + Credit to account + + @for (account of ownAccounts; track account.accountNo) { + {{ account.accountNo }} — {{ account.productName }} + } + + + } + + @if (destination === 'other') { + + Park in GL account + + + @for (account of sundryOptions; track account.id) { + {{ account.glCode }} · {{ account.name }} + } + @if (sundryOptions.length === 0) { + No liability GL matches + } + + @if (!sundryGlAccountId) { + Search and pick your bank's Sundry GL account + } @else { + The balance is held here in the customer's name until it is paid to them + } + + } + + @if (ownAccounts.length === 0 && !loading) { +

+ This customer has no other account here, so the balance is parked in the Sundry account chosen above, in their + name until it is paid to them. +

+ } + + + @if (withinLockin) { +
+

Within lock-in until {{ closableFrom | dateFormat }}

+

+ {{ data.partyCode }} opened on {{ data.openingDate | dateFormat }} and the scheme locks it in for + {{ scheme?.prematureLockinMonths }} months. It can be closed early only if a manager approves it. +

+ + Reason for closing within lock-in + + {{ lockinReason.length }}/250 + +
+ } + +

+ @if (withinLockin) { + Once approved, this closes the account. It cannot be reopened. + } @else { + This closes the account. It cannot be reopened. + } +

+
+ + + + + diff --git a/src/app/deposits/party-master/premature-close-dialog/premature-close-dialog.component.scss b/src/app/deposits/party-master/premature-close-dialog/premature-close-dialog.component.scss new file mode 100644 index 000000000..7d88e158a --- /dev/null +++ b/src/app/deposits/party-master/premature-close-dialog/premature-close-dialog.component.scss @@ -0,0 +1,132 @@ +/** + * Copyright since 2025 Mifos Initiative + * + * This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. + */ + +/* The account as it stands, so the choice below is made against figures rather than from memory. */ +.pcd-summary { + margin: 4px 0 20px; + padding: 12px 14px; + border-radius: 6px; + background: rgb(0 0 0 / 3%); +} + +.pcd-row { + display: flex; + justify-content: space-between; + gap: 16px; + padding: 3px 0; + font-size: 0.85rem; + + span { + color: rgb(0 0 0 / 60%); + } +} + +.pcd-amount { + font-variant-numeric: tabular-nums; +} + +.pcd-question { + margin: 20px 0 10px; + font-size: 0.9rem; + font-weight: 600; +} + +.pcd-choices { + display: flex; + flex-direction: column; + gap: 10px; +} + +/* + * Each answer is a card rather than a bare radio, because the two are not interchangeable - they take + * money from different people, and the consequence has to be readable before the choice is made. + */ +.pcd-choice { + display: flex; + align-items: flex-start; + gap: 4px; + padding: 10px 12px 12px; + border: 1px solid rgb(0 0 0 / 12%); + border-radius: 6px; + cursor: pointer; + + &:hover { + background: rgb(0 0 0 / 2%); + } +} + +.pcd-choice--on { + border-color: #b45309; + background: rgb(180 83 9 / 5%); +} + +.pcd-choice-body { + padding-top: 2px; +} + +.pcd-choice-title { + font-weight: 600; + font-size: 0.88rem; +} + +.pcd-choice-detail { + margin-top: 3px; + font-size: 0.8rem; + line-height: 1.5; + color: rgb(0 0 0 / 65%); +} + +.pcd-destinations { + display: flex; + flex-direction: column; + gap: 6px; +} + +.pcd-field { + width: 100%; + margin-top: 12px; +} + +.pcd-note { + margin: 12px 0 0; + font-size: 0.8rem; + line-height: 1.5; + color: rgb(0 0 0 / 65%); +} + +/* The one step here with no way back, so it is said plainly rather than left to the button colour. */ +.pcd-warn { + margin: 18px 0 0; + padding: 9px 12px; + border-radius: 6px; + border: 1px solid rgb(185 28 28 / 25%); + background: rgb(185 28 28 / 6%); + color: #b91c1c; + font-size: 0.8rem; + font-weight: 500; +} + +/* Inside the lock-in: a request for a manager, not a closure, so it is set apart from the terms above. */ +.pcd-lockin { + margin-top: 18px; + padding: 12px 14px 4px; + border-radius: 8px; + border: 1px solid rgb(180 83 9 / 30%); + background: rgb(245 158 11 / 8%); +} + +.pcd-lockin-title { + margin: 0; + font-size: 0.85rem; + font-weight: 600; + color: #b45309; +} + +.po-hint-warn { + color: #c62828; +} diff --git a/src/app/deposits/party-master/premature-close-dialog/premature-close-dialog.component.ts b/src/app/deposits/party-master/premature-close-dialog/premature-close-dialog.component.ts new file mode 100644 index 000000000..53db816e0 --- /dev/null +++ b/src/app/deposits/party-master/premature-close-dialog/premature-close-dialog.component.ts @@ -0,0 +1,228 @@ +/** + * Copyright since 2025 Mifos Initiative + * + * This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. + */ + +/** Angular Imports */ +import { Component, OnInit, inject } from '@angular/core'; +import { CommonModule } from '@angular/common'; +import { FormsModule } from '@angular/forms'; +import { MAT_DIALOG_DATA, MatDialogModule, MatDialogRef } from '@angular/material/dialog'; +import { MatButtonModule } from '@angular/material/button'; +import { MatRadioModule } from '@angular/material/radio'; +import { MatSelectModule } from '@angular/material/select'; +import { MatAutocompleteModule } from '@angular/material/autocomplete'; +import { MatFormFieldModule } from '@angular/material/form-field'; +import { MatInputModule } from '@angular/material/input'; +import { MatProgressBarModule } from '@angular/material/progress-bar'; + +/** Custom Services */ +import { OrganizationService } from 'app/organization/organization.service'; +import { ProductsService } from 'app/products/products.service'; + +/** Custom Pipes */ +import { DateFormatPipe } from 'app/pipes/date-format.pipe'; + +/** + * Closing a DDS account before its term is up. + * + * A customer who stops saving early costs the bank the interest it had promised on money it no longer + * has for as long as it expected. Somebody has to carry that, and the bank allows two answers - so + * this asks which, and it is the only thing it asks. + * + * What each answer costs is the scheme's, not the clerk's: the reduced rate and the agent's share are + * set once when the scheme is created and shown here as fact. A figure typed at the counter is a + * figure that can be typed differently for the next customer, which is exactly what a published term + * is meant to prevent. + * + * The destination works as it does on a maturity payout - another account of theirs here, or parked + * in matured-deposits payable for a transfer out - because what is left over is still their money and + * has to reach them the same way. + */ +@Component({ + selector: 'mifosx-premature-close-dialog', + standalone: true, + imports: [ + CommonModule, + FormsModule, + MatDialogModule, + MatButtonModule, + MatRadioModule, + MatSelectModule, + MatAutocompleteModule, + MatFormFieldModule, + MatInputModule, + MatProgressBarModule, + DateFormatPipe + ], + templateUrl: './premature-close-dialog.component.html', + styleUrls: ['./premature-close-dialog.component.scss'] +}) +export class PrematureCloseDialogComponent implements OnInit { + private organizationService = inject(OrganizationService); + private productsService = inject(ProductsService); + private dialogRef = inject(MatDialogRef); + data = inject(MAT_DIALOG_DATA); + + /** Who carries the shortfall. No default - the two answers take money from different people. */ + borneBy: 'CUSTOMER' | 'AGENT' | '' = ''; + + /** 'own' moves what is left to another account here; 'other' parks it for a transfer out. */ + destination: 'own' | 'other' = 'own'; + + ownAccounts: any[] = []; + selectedAccountNo = ''; + + /** Where the balance is parked when the customer has no account here. */ + sundryAccounts: any[] = []; + sundryGlAccountId: number | null = null; + /** What is typed in the GL box - a code or part of a name. */ + sundrySearch = ''; + + /** The accounts matching what is typed, by GL code or name, in any case. */ + get sundryOptions(): any[] { + const typed = this.sundrySearch.trim().toLowerCase(); + if (!typed || typed === this.sundryLabel(this.chosenSundry).toLowerCase()) { + return this.sundryAccounts; + } + return this.sundryAccounts.filter( + (account: any) => + String(account.glCode).toLowerCase().includes(typed) || + String(account.name || '') + .toLowerCase() + .includes(typed) + ); + } + + get chosenSundry(): any { + return this.sundryAccounts.find((account: any) => account.id === this.sundryGlAccountId); + } + + /** Shows a picked account in the box as its code and name. */ + readonly sundryDisplay = (value: any): string => + value && typeof value === 'object' ? this.sundryLabel(value) : value || ''; + + sundryLabel(account: any): string { + return account ? `${account.glCode} · ${account.name}` : ''; + } + + pickSundry(account: any): void { + this.sundryGlAccountId = account ? account.id : null; + this.sundrySearch = this.sundryLabel(account); + } + + /** Typing again clears the choice until an account is picked, so a half-typed code is never sent. */ + onSundryTyped(value: any): void { + // Picking from the list hands over the account itself rather than text. + if (value && typeof value === 'object') { + this.pickSundry(value); + return; + } + value = String(value ?? ''); + this.sundrySearch = value; + if (value !== this.sundryLabel(this.chosenSundry)) { + const exact = this.sundryAccounts.find( + (account: any) => String(account.glCode).toLowerCase() === value.trim().toLowerCase() + ); + this.sundryGlAccountId = exact ? exact.id : null; + } + } + loading = true; + + /** + * The scheme's terms, shown so the clerk can tell the customer what each answer costs before + * choosing. Null while loading, and left null if the scheme cannot be read - the choice is then + * still offered, because the server applies the terms either way and a screen that could not + * display them should not block a closure the customer has asked for. + */ + scheme: any = null; + + ngOnInit(): void { + // The accounts the balance can be parked in. None is chosen: each bank has its own Sundry account, + // and the clerk searches for it and picks it. + this.organizationService.getDdsSundryAccounts().subscribe({ + next: (accounts: any) => (this.sundryAccounts = accounts || []), + error: () => (this.sundryAccounts = []) + }); + this.organizationService.getCreditableAccounts(this.data.clientId).subscribe({ + next: (accounts: any) => { + this.ownAccounts = accounts || []; + if (this.ownAccounts.length === 0) { + this.destination = 'other'; + } else if (this.ownAccounts.length === 1) { + this.selectedAccountNo = this.ownAccounts[0].accountNo; + } + this.loading = false; + }, + error: () => { + this.ownAccounts = []; + this.destination = 'other'; + this.loading = false; + } + }); + + // The terms are read from the scheme the account runs on, so what is shown here is what the + // server will actually charge rather than a second copy that can drift from it. Matched on the + // code the party carries, which is the only handle it has on its scheme. + this.productsService.getDdsAccountTypes().subscribe({ + next: (schemes: any) => { + const all = Array.isArray(schemes) ? schemes : schemes?.pageItems || []; + this.scheme = all.find((s: any) => s.accountTypeCode === this.data.subType) || null; + }, + error: () => (this.scheme = null) + }); + } + + /** The rate the customer would be left on if they carry it. Never below nothing. */ + get reducedRate(): number { + const rate = Number(this.data.interestRate || 0); + const reduction = Number(this.scheme?.prematureInterestReduction || 0); + return Math.max(0, rate - reduction); + } + + /** Why the lock-in should be waived for this account - required when it is inside it. */ + lockinReason = ''; + + /** + * The day the scheme's lock-in ends for this account, or null when the scheme has none. Inside it + * the account cannot be closed early on the scheme's terms; it can only be asked for, with a reason, + * for a manager to approve - the scheme's lock-in itself is never changed to let one account through. + */ + get closableFrom(): Date | null { + const months = Number(this.scheme?.prematureLockinMonths || 0); + const opened = this.data.openingDate ? new Date(this.data.openingDate) : null; + if (months <= 0 || !opened || isNaN(opened.getTime())) { + return null; + } + const closable = new Date(opened); + closable.setMonth(closable.getMonth() + months); + return closable; + } + + get withinLockin(): boolean { + const closable = this.closableFrom; + return !!closable && closable.getTime() > Date.now(); + } + + get canClose(): boolean { + if (this.loading || !this.borneBy) { + return false; + } + if (this.withinLockin && !this.lockinReason.trim()) { + return false; + } + return this.destination === 'other' ? !!this.sundryGlAccountId : !!this.selectedAccountNo; + } + + close(): void { + this.dialogRef.close({ + shortfallBorneBy: this.borneBy, + ...(this.destination === 'own' && this.selectedAccountNo ? { toSavingsAccountNo: this.selectedAccountNo } : {}), + ...(this.destination === 'other' && this.sundryGlAccountId ? { sundryGlAccountId: this.sundryGlAccountId } : {}), + ...(this.withinLockin ? { lockinOverrideReason: this.lockinReason.trim() } : {}) + }); + } +} diff --git a/src/app/directives/directives.module.ts b/src/app/directives/directives.module.ts index 9b64b94da..81b0494d4 100644 --- a/src/app/directives/directives.module.ts +++ b/src/app/directives/directives.module.ts @@ -14,6 +14,7 @@ import { CommonModule } from '@angular/common'; import { HasPermissionDirective } from './has-permission/has-permission.directive'; import { FormatAmountDirective } from './format-amount.directive'; import { ValidateOnFocusDirective } from './validate-on-focus.directive'; +import { RestrictInputDirective } from './restrict-input.directive'; /** * Directives Module @@ -25,12 +26,14 @@ import { ValidateOnFocusDirective } from './validate-on-focus.directive'; CommonModule, HasPermissionDirective, FormatAmountDirective, - ValidateOnFocusDirective + ValidateOnFocusDirective, + RestrictInputDirective ], exports: [ HasPermissionDirective, FormatAmountDirective, - ValidateOnFocusDirective + ValidateOnFocusDirective, + RestrictInputDirective ] }) export class DirectivesModule {} diff --git a/src/app/directives/restrict-input.directive.ts b/src/app/directives/restrict-input.directive.ts new file mode 100644 index 000000000..176b32cbc --- /dev/null +++ b/src/app/directives/restrict-input.directive.ts @@ -0,0 +1,137 @@ +/** + * Copyright since 2025 Mifos Initiative + * + * This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. + */ + +import { Directive, HostListener, Input, ElementRef, inject } from '@angular/core'; +import { NgControl } from '@angular/forms'; + +/** The kinds of field this knows how to hold to a shape. */ +export type RestrictKind = 'digits' | 'phone' | 'pan' | 'aadhaar'; + +interface Rule { + /** Characters this kind will accept at all. */ + allowed: RegExp; + /** How many of them the field can hold, so it cannot be overfilled. */ + maxLength: number; + /** Whether what is typed is forced to upper case as it is entered. */ + upper?: boolean; +} + +const RULES: Record = { + digits: { allowed: /[0-9]/, maxLength: 20 }, + phone: { allowed: /[0-9+()\-\s]/, maxLength: 20 }, + // A PAN is five letters, four digits, one letter - and is always written in capitals. + pan: { allowed: /[A-Za-z0-9]/, maxLength: 10, upper: true }, + // Twelve digits, or the masked form CKYC returns, which is Xs and the last four digits. + aadhaar: { allowed: /[0-9Xx]/, maxLength: 12, upper: true } +}; + +/** + * Stops a field being filled with something it could never hold. + * + * A mobile number typed as letters, or a PAN of fifteen characters, is not a mistake worth + * discovering on save - the customer is at the counter now, and the clerk is looking at the field + * they just typed. Refusing the keystroke says so at the moment it happens, with nothing to read + * and nothing to undo. + * + * Pasting is handled too, and is where this earns its place: a number copied from a spreadsheet + * arrives with spaces, dashes or a +91 in front, and pasting is the one way into a field that + * bypasses every keystroke rule. What can be salvaged is kept, the rest dropped. + * + * This only refuses characters the field cannot contain. Whether what is left is a valid number is + * a separate question, answered by the form's own validators and again by the server - a screen + * restriction is a convenience, not a guarantee. + */ +@Directive({ + selector: '[mifosxRestrictInput]', + standalone: true +}) +export class RestrictInputDirective { + private el = inject(ElementRef); + private control = inject(NgControl, { optional: true }); + + @Input('mifosxRestrictInput') kind: RestrictKind = 'digits'; + + private get rule(): Rule { + return RULES[this.kind] ?? RULES.digits; + } + + /** + * Keys that move around or edit rather than enter a character, plus the usual copy/paste and + * select-all chords. Blocking these would leave the clerk unable to correct their own typing. + */ + private isEditingKey(event: KeyboardEvent): boolean { + const navigation = [ + 'Backspace', + 'Delete', + 'Tab', + 'Escape', + 'Enter', + 'Home', + 'End', + 'ArrowLeft', + 'ArrowRight', + 'ArrowUp', + 'ArrowDown' + ]; + return navigation.includes(event.key) || event.ctrlKey || event.metaKey; + } + + @HostListener('keydown', ['$event']) + onKeydown(event: KeyboardEvent): void { + if (this.isEditingKey(event) || event.key.length > 1) { + return; + } + const input = this.el.nativeElement as HTMLInputElement; + const hasSelection = input.selectionStart !== input.selectionEnd; + + if (!this.rule.allowed.test(event.key)) { + event.preventDefault(); + return; + } + // Replacing a selection does not make the field any longer, so only a plain insert can overfill. + if (!hasSelection && input.value.length >= this.rule.maxLength) { + event.preventDefault(); + } + } + + @HostListener('paste', ['$event']) + onPaste(event: ClipboardEvent): void { + event.preventDefault(); + const pasted = event.clipboardData?.getData('text') ?? ''; + const kept = Array.from(pasted) + .filter((ch) => this.rule.allowed.test(ch)) + .join('') + .slice(0, this.rule.maxLength); + this.write(this.rule.upper ? kept.toUpperCase() : kept); + } + + /** Upper case is applied as the field is left, so the caret does not jump while typing. */ + @HostListener('blur') + onBlur(): void { + if (!this.rule.upper) { + return; + } + const current = String(this.el.nativeElement.value ?? ''); + if (current !== current.toUpperCase()) { + this.write(current.toUpperCase()); + } + } + + /** + * Writes through the form control where there is one, so the value the form holds and the value + * on screen stay the same thing - setting only the element would leave the form with the old one. + */ + private write(value: string): void { + this.el.nativeElement.value = value; + if (this.control?.control) { + this.control.control.setValue(value); + } else { + this.el.nativeElement.dispatchEvent(new Event('input')); + } + } +} diff --git a/src/app/organization/agent-master/agent-master.component.html b/src/app/organization/agent-master/agent-master.component.html new file mode 100644 index 000000000..06483cd18 --- /dev/null +++ b/src/app/organization/agent-master/agent-master.component.html @@ -0,0 +1,195 @@ + + +
+
+
+
+
+

Agent Master

+

The DDS collection agents on file.

+
+
+
+
+ + {{ totalCount }} + {{ totalCount === 1 ? 'agent' : 'agents' }} +
+ +
+
+ +
+
+
+
+
{{ totalCount | number }}
+
Total Agents
+
+
+
+
+
+
{{ activeCount | number }}
+
Active Agents
+
+
+
+
+
+
{{ totalCurrentBalance | number: '1.2-2' }}
+
Cash With Agents (Unsettled)
+
+
+
+ +
+
+ +
+ + @if (totalCount === 0) { +
+
+

No agents yet

+

Create one so daily collections can be recorded against it.

+ +
+ } @else { +
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
Agent Code +
+
+
+
{{ a.agentCode }}
+
+
+
Agent Name{{ a.agentNameId }}GL Account + @if (a.glAccountCode) { + {{ a.glAccountCode }} · {{ a.glAccountName }} + } @else { + Not set + } + Branch + @if (a.homeBranch) { + {{ a.homeBranch }} + } @else { + Not set + } + City{{ a.agentCity }}Saving A/C No + {{ a.agentSavingAccountNo }} + Current Balance + {{ a.agentCurrentBalance | number: '1.2-2' }} + Status + + {{ a.active ? 'Active' : 'Inactive' }} + + Created + {{ a.createdDate ? (a.createdDate | dateFormat: 'DD MMM YYYY, hh:mm A') : '—' }} + Actions + + @if (a.closeDate) { + Closed {{ a.closeDate | dateFormat }} + } @else { + + } +
+ + @if (filteredCount === 0 && totalCount > 0) { +
+ + No agents match your filter. +
+ } + + +
+ } +
+
diff --git a/src/app/organization/agent-master/agent-master.component.scss b/src/app/organization/agent-master/agent-master.component.scss new file mode 100644 index 000000000..4f6cd4cfe --- /dev/null +++ b/src/app/organization/agent-master/agent-master.component.scss @@ -0,0 +1,59 @@ +/** + * Copyright since 2025 Mifos Initiative + * + * This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. + */ + +@use 'assets/styles/product-list' as pl; + +@include pl.product-list-styles; + +/* Agent-specific indigo accent */ +:host { + --pl-accent: #4338ca; + --pl-accent-l: #e0e7ff; +} + +/* A retired agent stays listed but reads as switched off. */ +.am-row--inactive td:not(.num) { + opacity: 0.55; +} + +/* Closing is the last, irreversible step, so it does not read like the buttons beside it. */ +.pl-action-btn--close { + color: #9b2c2c; +} + +/* A closed agent shows when, in place of the actions it no longer has. */ +.am-closed-on { + font-size: 12px; + font-style: italic; + color: #9b2c2c; +} + +.am-pill { + display: inline-block; + padding: 3px 11px; + border-radius: 11px; + font-size: 0.72rem; + font-weight: 500; + line-height: 1.7; + background: #dcfce7; + color: #15803d; + white-space: nowrap; +} + +.am-pill--off { + background: rgb(0 0 0 / 7%); + color: rgb(0 0 0 / 52%); +} + +/* An agent with no branch is an agent no branch's batch run will collect for - say so plainly + rather than showing an em dash that reads like "nothing to see here". */ +.am-no-branch { + font-size: 12px; + font-style: italic; + color: #9b2c2c; +} diff --git a/src/app/organization/agent-master/agent-master.component.ts b/src/app/organization/agent-master/agent-master.component.ts new file mode 100644 index 000000000..88d7675b2 --- /dev/null +++ b/src/app/organization/agent-master/agent-master.component.ts @@ -0,0 +1,147 @@ +/** + * Copyright since 2025 Mifos Initiative + * + * This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. + */ + +/** Angular Imports */ +import { Component, OnInit, ViewChild, inject } from '@angular/core'; +import { MatPaginator } from '@angular/material/paginator'; +import { MatSort, MatSortHeader } from '@angular/material/sort'; +import { + MatTable, + MatTableDataSource, + MatColumnDef, + MatHeaderCellDef, + MatHeaderCell, + MatCellDef, + MatCell, + MatHeaderRowDef, + MatHeaderRow, + MatRowDef, + MatRow +} from '@angular/material/table'; +import { MatTooltip } from '@angular/material/tooltip'; +import { FaIconComponent } from '@fortawesome/angular-fontawesome'; +import { STANDALONE_SHARED_IMPORTS } from 'app/standalone-shared.module'; + +/** Custom Services */ +import { OrganizationService } from 'app/organization/organization.service'; +import { AuthenticationService } from 'app/core/authentication/authentication.service'; +import { forkJoin, of } from 'rxjs'; +import { catchError } from 'rxjs/operators'; +import { officesUnder } from './offices-under'; + +/** + * Agent Master list. + * + * The DDS collection agents on file. Follows the same list -> create/edit shape as the product lists + * and shares their stylesheet. + */ +@Component({ + selector: 'mifosx-agent-master', + templateUrl: './agent-master.component.html', + styleUrls: ['./agent-master.component.scss'], + imports: [ + ...STANDALONE_SHARED_IMPORTS, + FaIconComponent, + MatTable, + MatSort, + MatColumnDef, + MatHeaderCellDef, + MatHeaderCell, + MatSortHeader, + MatCellDef, + MatCell, + MatHeaderRowDef, + MatHeaderRow, + MatRowDef, + MatRow, + MatPaginator, + MatTooltip + ] +}) +export class AgentMasterComponent implements OnInit { + private organizationService = inject(OrganizationService); + private authenticationService = inject(AuthenticationService); + + displayedColumns: string[] = [ + 'agentCode', + 'clientName', + 'homeBranch', + 'glAccount', + 'agentCity', + 'agentSavingAccountNo', + 'agentCurrentBalance', + 'status', + 'createdDate', + 'actions' + ]; + + dataSource: MatTableDataSource = new MatTableDataSource([]); + agents: any[] = []; + filteredCount = 0; + + @ViewChild(MatPaginator, { static: true }) paginator: MatPaginator; + @ViewChild(MatSort, { static: true }) sort: MatSort; + + get totalCount(): number { + return this.agents.length; + } + + get activeCount(): number { + return this.agents.filter((agent) => agent.active).length; + } + + /** + * Cash the agents are collectively holding but have not settled to a branch yet — an agent's + * balance rises on every party collection and falls when it hands the money over. Inactive agents + * count too: money still out with a retired agent is exactly what this figure is for. + */ + get totalCurrentBalance(): number { + return this.agents.reduce((sum, agent) => sum + Number(agent.agentCurrentBalance || 0), 0); + } + + ngOnInit(): void { + this.loadAgents(); + } + + private loadAgents(): void { + const loginOfficeId = this.authenticationService.getCredentials()?.officeId; + forkJoin({ + agents: this.organizationService.getAgents(), + offices: this.organizationService.getOffices().pipe(catchError(() => of([]))) + }).subscribe(({ agents, offices }: any) => { + // The branch logged in to sees its own agents and those of every branch under it - Head Office, + // at the top, sees them all. + const visible = new Set( + officesUnder(Array.isArray(offices) ? offices : offices?.pageItems || [], loginOfficeId).map( + (office: any) => office.id + ) + ); + this.agents = (agents || []).filter( + (agent: any) => !loginOfficeId || visible.size === 0 || visible.has(agent.officeId) + ); + this.dataSource = new MatTableDataSource(this.agents); + this.dataSource.paginator = this.paginator; + this.dataSource.sort = this.sort; + this.filteredCount = this.agents.length; + this.dataSource.connect().subscribe((rows) => (this.filteredCount = rows.length)); + }); + } + + applyFilter(filterValue: string): void { + this.dataSource.filter = filterValue.trim().toLowerCase(); + } + + private static readonly MONEY = new Intl.NumberFormat('en-IN', { + minimumFractionDigits: 2, + maximumFractionDigits: 2 + }); + + money(value: any): string { + return AgentMasterComponent.MONEY.format(Number(value || 0)); + } +} diff --git a/src/app/organization/agent-master/create-agent/create-agent.component.html b/src/app/organization/agent-master/create-agent/create-agent.component.html new file mode 100644 index 000000000..807a9b050 --- /dev/null +++ b/src/app/organization/agent-master/create-agent/create-agent.component.html @@ -0,0 +1,318 @@ + + +
+
+ +

Agent Master Entry

+
+ +
+
+ +
+
+ +

Agent Details

+
+
+ + Client ID or Name + + + + + @for (client of clientOptions; track client.id) { + +
+ {{ client.id }} + {{ client.displayName }} + {{ client.officeName }} +
+
+ } + @if (clientListIsPartial) { + + Showing {{ clientOptions.length }} of {{ clientTotal }} — type a name to find the rest + + } +
+ @if (agentForm.get('clientId')?.hasError('pattern')) { + Pick a client from the list + } @else if (clientAlreadyAgent) { + This client is already agent {{ clientAlreadyAgent }} + } @else if (clientAgeProblem) { + {{ clientAgeProblem }} + } @else if (clientOtherBranch) { + This client belongs to another branch + } @else if (clientLookupFailed) { + No client found with this ID + } @else if (loadedClientName) { + {{ loadedClientName }} + } +
+ + + Agent Code + + + + + Agent Name ID + + + + + Agent Address (Line 1) + + + + + Agent Address (Line 2) + + + + + Agent City + + + + + Agent PinCode + + + + + Agent Telephone + + + + + + Home Branch + + @for (office of offices; track office.id) { + {{ office.name }} + } + + The branch this agent's collections belong to + + + + + GL Account + + @for (account of glAccounts; track account.id) { + {{ account.glCode }} · {{ account.name }} + } + + This agent's collections pass through it. One agent to an account. + + + + + Agent Saving A/C Type + + @for (accountType of savingsAccountTypes; track accountType) { + {{ savingsAccountTypeCodes[accountType] || accountType }} + } + + + @if (savingsAccountTypes.length === 0) { + @if (loadedClientName) { + + {{ loadedClientName }} has no savings account — commission can only be paid into one + + } @else { + Enter a Client ID first — types come from that client's own accounts + } + } + + + + + Agent Saving A/C No + + @for (account of accountsOfChosenType; track account.id) { + {{ account.accountNo }} + } + + @if (accountsOfChosenType.length === 0) { + Choose an A/C Type first + } + + + + Agent Opening Date + + + + + + + + Agent Birth Date + + + From the client's record + +
+
+ +
+
+ +

Guarantor's Detail

+ +
+ + @if (guarantors.length === 0) { +

No guarantors added yet.

+ } + + @for (guarantor of guarantors.controls; track guarantor; let i = $index) { +
+ + Client ID or Name + + + + @for (client of guarantorOptionsFor(guarantor); track client.id) { + +
+ {{ client.id }} + {{ client.displayName }} + {{ client.officeName }} +
+
+ } +
+ + @if (guarantor.get('clientId')?.hasError('selfGuarantor')) { + The agent cannot be their own guarantor + } @else if (guarantor.get('clientId')?.hasError('duplicateGuarantor')) { + This client is already a guarantor above + } @else if (guarantor.get('clientId')?.hasError('pattern')) { + Pick a client from the list + } +
+ + Guarantor Name + + + + Address 1 + + + + Address 2 + + + + City + + + + Pin Code + + + + Telephone + + + +
+ } +
+ +
+
+ +

Balances

+
+
+ + Agent Current Balance + + + + + Agent Opening Balance + + +
+
+ +
+ + + + +
+
+
+
diff --git a/src/app/organization/agent-master/create-agent/create-agent.component.scss b/src/app/organization/agent-master/create-agent/create-agent.component.scss new file mode 100644 index 000000000..e1852abe2 --- /dev/null +++ b/src/app/organization/agent-master/create-agent/create-agent.component.scss @@ -0,0 +1,191 @@ +/** + * Copyright since 2025 Mifos Initiative + * + * This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. + */ + +$primary: #0f766e; + +.am-container { + max-width: 1040px; + margin: 0 auto; + padding: 24px 16px; +} + +.am-header { + display: flex; + align-items: center; + gap: 16px; + margin-bottom: 20px; + + &__title { + margin: 0; + font-size: 22px; + font-weight: 500; + } +} + +.am-back { + color: $primary; +} + +.am-card { + background: var(--md-sys-color-surface, #fff); + border-radius: 8px; + box-shadow: 0 2px 8px rgb(0 0 0 / 8%); + padding: 8px 24px 24px; +} + +.am-section { + padding: 20px 0; + border-bottom: 1px solid rgb(0 0 0 / 8%); + + &:last-of-type { + border-bottom: none; + } + + &__header { + display: flex; + align-items: center; + gap: 10px; + margin-bottom: 16px; + + i { + color: $primary; + } + + h3 { + margin: 0; + font-size: 16px; + font-weight: 600; + flex: 1; + } + } +} + +.am-grid { + display: grid; + grid-template-columns: repeat(3, 1fr); + gap: 8px 16px; +} + +.am-field { + width: 100%; + + &--wide { + grid-column: span 2; + } +} + +.am-add-guarantor { + color: $primary; +} + +.am-empty-guarantors { + color: rgb(0 0 0 / 50%); + font-size: 14px; + margin: 0 0 8px; +} + +.am-guarantor-row { + display: grid; + grid-template-columns: repeat(7, 1fr) auto; + gap: 8px 12px; + align-items: start; + padding: 10px 0; + border-top: 1px dashed rgb(0 0 0 / 10%); + + &:first-of-type { + border-top: none; + } +} + +.am-limit-row { + display: grid; + grid-template-columns: repeat(3, 1fr) auto; + gap: 8px 12px; + align-items: start; + padding: 10px 0; + border-top: 1px dashed rgb(0 0 0 / 10%); + + &:first-of-type { + border-top: none; + } +} + +.am-remove-guarantor { + color: #b91c1c; + margin-top: 8px; +} + +.am-action-bar { + display: flex; + justify-content: flex-end; + gap: 12px; + margin-top: 20px; + padding-top: 16px; + flex-wrap: wrap; +} + +/* Client ID lookup came back empty — the officer typed an id that doesn't exist. */ +.am-hint--error { + color: #b3261e; +} + +@media (width <= 900px) { + .am-grid { + grid-template-columns: repeat(2, 1fr); + } + + .am-guarantor-row, + .am-limit-row { + grid-template-columns: repeat(2, 1fr); + } +} + +/* One row of the client search list: the id, the name, and the branch - as on the Party form. */ +.am-opt { + display: flex; + align-items: baseline; + gap: 10px; + overflow: hidden; +} + +.am-opt-more { + font-size: 0.75rem; + font-style: italic; + opacity: 0.75; +} + +.am-opt-id { + flex: 0 0 auto; + min-width: 52px; + font-family: ui-monospace, 'SF Mono', 'Cascadia Mono', monospace; + font-size: 0.78rem; + color: #0f766e; +} + +.am-opt-main { + flex: 1 1 auto; + min-width: 8rem; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.am-opt-sub { + flex: 0 1 auto; + min-width: 0; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + font-size: 0.75rem; + color: rgb(0 0 0 / 50%); +} + +.am-search-ic { + margin-right: 8px; + color: rgb(0 0 0 / 40%); +} diff --git a/src/app/organization/agent-master/create-agent/create-agent.component.ts b/src/app/organization/agent-master/create-agent/create-agent.component.ts new file mode 100644 index 000000000..0847ae6a6 --- /dev/null +++ b/src/app/organization/agent-master/create-agent/create-agent.component.ts @@ -0,0 +1,929 @@ +/** + * Copyright since 2025 Mifos Initiative + * + * This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. + */ + +/** Angular Imports */ +import { ChangeDetectorRef, Component, ElementRef, OnInit, ViewChild, inject } from '@angular/core'; +import { + UntypedFormBuilder, + UntypedFormGroup, + UntypedFormArray, + Validators, + ValidatorFn, + AbstractControl, + ValidationErrors +} from '@angular/forms'; +import { ActivatedRoute, Router } from '@angular/router'; +import { debounceTime, distinctUntilChanged } from 'rxjs/operators'; +import { STANDALONE_SHARED_IMPORTS } from 'app/standalone-shared.module'; + +/** Custom Services */ +import { OrganizationService } from 'app/organization/organization.service'; +import { AccountingService } from 'app/accounting/accounting.service'; +import { SystemService } from 'app/system/system.service'; +import { ClientsService } from 'app/clients/clients.service'; +import { SettingsService } from 'app/settings/settings.service'; +import { Dates } from 'app/core/utils/dates'; +import { AlertService } from 'app/core/alert/alert.service'; +import { AuthenticationService } from 'app/core/authentication/authentication.service'; + +/** Custom Directives */ +import { RestrictInputDirective } from 'app/directives/restrict-input.directive'; +import { MatAutocompleteModule, MatAutocompleteTrigger } from '@angular/material/autocomplete'; +import { searchClientsAnyCase } from 'app/deposits/party-master/client-search'; +import { officesUnder } from '../offices-under'; + +/** Writes a value into a form group only when there is something to write. */ +function setGroupField(group: UntypedFormGroup, controlName: string, value: any): void { + if (value === null || value === undefined || value === '') { + return; + } + group.get(controlName)?.setValue(String(value)); +} + +/** + * Agent Master Entry component. + * + * A DDS collection agent — either a person or an institution — registered with its own settlement + * savings account and one or more guarantors. Modelled on the legacy Agent Master Entry screen, field + * for field. + * + * Entering a Client ID fills the name, address, city, pincode and telephone from that client, the way + * the legacy screen did, defaults the home branch to that client's office, and stocks the account + * type and account number dropdowns from that client's own accounts. Filled fields stay editable. + * + * Backed by its own DDS Agent API (`/dds/agents`) — independent of Staff/Client CRUD, per the DDS + * agent/commission hierarchy being genuinely new feature work. + */ +@Component({ + selector: 'mifosx-create-agent', + templateUrl: './create-agent.component.html', + styleUrls: ['./create-agent.component.scss'], + imports: [ + ...STANDALONE_SHARED_IMPORTS, + // See the Party form: an unimported directive is ignored without a word. + RestrictInputDirective, + MatAutocompleteModule + ] +}) +export class CreateAgentComponent implements OnInit { + private formBuilder = inject(UntypedFormBuilder); + private router = inject(Router); + private route = inject(ActivatedRoute); + private organizationService = inject(OrganizationService); + private clientsService = inject(ClientsService); + private accountingService = inject(AccountingService); + private systemService = inject(SystemService); + private settingsService = inject(SettingsService); + private dateUtils = inject(Dates); + private alertService = inject(AlertService); + private authenticationService = inject(AuthenticationService); + private changeDetector = inject(ChangeDetectorRef); + + /** The agent's client field and its list, so a list that arrives late can still be opened. */ + @ViewChild('clientTrigger') clientTrigger?: MatAutocompleteTrigger; + @ViewChild('clientInput') clientInput?: ElementRef; + + /** + * The branch the officer is logged in to. An agent is opened there and nowhere else: the Home + * Branch list offers only it, and only its clients can be picked. + */ + private readonly loginOfficeId: number | null = this.authenticationService.getCredentials()?.officeId ?? null; + + /** + * The branch the agent is being opened for - the one picked in Home Branch, the login branch until + * then. Its clients are the ones offered, since an agent collects for the branch they belong to. + */ + private get branchOfficeId(): number | null { + return Number(this.agentForm?.get('officeId')?.value) || this.loginOfficeId; + } + + /** Set when the client keyed belongs to another branch, which cannot be made an agent here. */ + clientOtherBranch = false; + + /** + * The code of the agent the keyed client already is, if any. One client is one agent, so they are + * left out of the list and refused when typed. + */ + clientAlreadyAgent = ''; + private agentCodeByClientId: Record = {}; + + /** Why the picked client's age rules them out as an agent, or empty when it does not. */ + clientAgeProblem = ''; + /** The ages an agent is taken on at, matching the rule the server applies. */ + private static readonly MIN_AGENT_AGE = 16; + private static readonly MAX_AGENT_AGE = 60; + + /** The agent's client list without the clients who are already agents. */ + private notYetAgents(clients: any[]): any[] { + return clients.filter((client: any) => !this.agentCodeByClientId[String(client.id)]); + } + + agentId: number | null = null; + + /** Shown under the Client ID field once a client has been resolved. */ + loadedClientName = ''; + clientLookupFailed = false; + + /** + * Clients to pick from, as on the Party form: the first page when the field is focused, then + * whatever matches what has been typed. An officer knows the agent's name, rarely their client id. + */ + clientOptions: any[] = []; + private initialClients: any[] = []; + private static readonly CLIENT_LIST_SIZE = 50; + clientTotal = 0; + showingClientList = false; + + get clientListIsPartial(): boolean { + return this.showingClientList && this.clientTotal > this.clientOptions.length; + } + + /** + * The client's own open savings accounts, fetched once the client is resolved. The commission + * account is picked from these rather than typed, so it is always an account that exists and + * always belongs to this agent. + */ + clientSavingsAccounts: any[] = []; + + /** + * The account types this client actually holds, taken from their own accounts rather than a fixed + * list. A fixed list would offer types the client has nothing under, and its wording would have to + * match the product names by hand for the filter below to work at all. + */ + savingsAccountTypes: string[] = []; + + /** + * Each type's product code, which is what the dropdown shows. The type is still saved by its name, + * as before, so what other screens and reports read is unchanged. + */ + savingsAccountTypeCodes: Record = {}; + + /** Fineract's deposit-account kinds: 100 savings, 200 fixed deposit, 300 recurring deposit. */ + private static readonly SAVINGS_DEPOSIT_TYPE = 100; + + /** Branches, for the Home Branch field. Defaulted from the client's own office. */ + offices: any[] = []; + + /** + * The ledger accounts an agent's collections may pass through. Header accounts are left out: they + * are headings over other accounts and carry no entries, so offering one only leads to a refusal. + */ + glAccounts: any[] = []; + + /** + * The savings product a DDS customer's own scheme account is opened under, read from the setting + * that names it rather than guessed from a product name a bank is free to change. + * + * Held so those accounts can be kept out of the agent's own list. They are savings accounts and + * pass every other test, but they belong to a customer's scheme: commission paid into one would be + * counted as that customer's deposits by every interest run afterwards. The server refuses it + * either way - this is so the choice is never offered rather than taken and then thrown back. + */ + private ddsProductId: number | null = null; + + /** + * The accounts on offer once a type has been chosen. Type is asked first, as the legacy screen + * did, so the officer narrows to a kind of account and then picks one of that kind. + */ + get accountsOfChosenType(): any[] { + const chosenType = this.agentForm?.get('agentSavingAccountType')?.value; + if (!chosenType) { + return []; + } + return this.clientSavingsAccounts.filter((account: any) => account.productName === chosenType); + } + + agentForm: UntypedFormGroup = this.formBuilder.group({ + clientId: [ + '', + // A name typed to search with is not a client; only an id picked or keyed saves. + [ + Validators.required, + Validators.pattern(/^\d+$/) + ] + ], + agentCode: [ + { + value: '', + disabled: true + } + ], + agentNameId: [ + '', + Validators.required + ], + agentAddress1: [ + '', + Validators.required + ], + agentAddress2: [''], + agentCity: [ + '', + Validators.required + ], + agentPinCode: [ + '', + Validators.required + ], + agentTelephone: [''], + // Which branch this agent collects out of. Holds the office's id, not its name - the name is + // only what the dropdown shows. The DDS batch runs filter by it. + officeId: [ + '', + Validators.required + ], + // Asked before the account number, which is then narrowed to accounts of this type. + agentSavingAccountType: [ + '', + Validators.required + ], + agentSavingAccountNo: [ + '', + Validators.required + ], + agentOpeningDate: [ + '', + Validators.required + ], + // Filled from the client, whose date of birth was checked when they were opened. + agentBirthDate: [''], + agentCurrentBalance: [ + { + value: 0, + disabled: true + } + ], + agentOpeningBalance: [0], + /* + * Required. An agent sharing the scheme's one account leaves a balance that says money is + * outstanding without saying whose - which is exactly what this field was added to answer. + */ + glAccountId: [ + '', + Validators.required + ], + guarantors: this.formBuilder.array([]) + }); + + ngOnInit(): void { + // A different branch has different clients: the list is read again, and a client already keyed + // is checked against the new branch. + this.agentForm + .get('officeId') + ?.valueChanges.pipe(distinctUntilChanged()) + .subscribe(() => { + this.initialClients = []; + if (!this.agentId && this.agentForm.get('clientId')?.value) { + this.loadClient(); + } + }); + // Who is already an agent, so they are neither offered nor accepted as a new one. + this.organizationService.getAgents().subscribe({ + next: (agents: any[]) => { + this.agentCodeByClientId = {}; + for (const agent of agents || []) { + if (agent.active !== false && agent.clientId != null) { + this.agentCodeByClientId[String(agent.clientId)] = agent.agentCode; + } + } + }, + error: () => (this.agentCodeByClientId = {}) + }); + + this.organizationService.getOffices().subscribe({ + next: (offices: any) => { + const all = Array.isArray(offices) ? offices : offices?.pageItems || []; + // The branch logged in to and every branch under it - Head Office, at the top, sees them all. + this.offices = officesUnder(all, this.loginOfficeId); + if (!this.agentId && this.offices.some((office: any) => office.id === this.loginOfficeId)) { + this.agentForm.get('officeId')?.setValue(this.loginOfficeId); + } + }, + error: () => (this.offices = []) + }); + + this.systemService.getConfigurationByName('dds-deposit-product').subscribe({ + next: (config: any) => { + this.ddsProductId = config?.enabled && config?.value ? Number(config.value) : null; + // The client may already have been keyed while this was in flight, so the list is rebuilt + // rather than left showing an account this now knows to exclude. + const clientId = this.agentForm.get('clientId')?.value; + if (clientId) { + this.loadClientSavingsAccounts(String(clientId)); + } + }, + error: () => (this.ddsProductId = null) + }); + + this.accountingService.getChartOfAccounts().subscribe({ + next: (accounts: any) => { + const rows = Array.isArray(accounts) ? accounts : accounts?.pageItems || []; + // usage 1 is a detail account, 2 a header. Disabled accounts refuse every posting, so an + // agent pointed at one would fail on their first collection rather than here. + this.glAccounts = rows.filter((a: any) => a.usage?.id === 1 && !a.disabled); + }, + error: () => (this.glAccounts = []) + }); + + // An id in the route means this is an edit; load that agent into the form. + const routeId = this.route.snapshot.paramMap.get('agentId'); + if (routeId) { + this.organizationService.getAgent(routeId).subscribe((agent: any) => this.patchFormFromAgent(agent)); + } + + // Fill the client-derived fields as soon as typing settles - no need to tab out of the field. + this.agentForm + .get('clientId') + ?.valueChanges.pipe(debounceTime(500), distinctUntilChanged()) + .subscribe((value) => { + const typed = String(value ?? '').trim(); + // A name is something to search for, not a client yet - the fields stay until one is picked. + if (typed && !/^\d+$/.test(typed)) { + this.searchClients(typed); + return; + } + if (!typed) { + this.showClientList(); + } + this.loadClient(); + // A guarantor row that was fine may now be the agent itself, or the other way round. + for (const row of this.guarantors.controls) { + row.get('clientId')?.updateValueAndValidity(); + // And the agent's own client leaves (or rejoins) each row's list. + if (!row.get('clientId')?.value) { + this.setGuarantorOptions(row, this.eligibleGuarantors(row, this.initialClients)); + } + } + }); + } + + get guarantors(): UntypedFormArray { + return this.agentForm.get('guarantors') as UntypedFormArray; + } + + private buildGuarantorGroup(): UntypedFormGroup { + const group = this.formBuilder.group({ + clientId: [ + '', + [ + Validators.required, + Validators.pattern(/^\d+$/), + this.notTheAgentItself(), + this.notAlreadyAGuarantor() + ] + ], + guarantorName: [ + '', + Validators.required + ], + address1: [ + '', + Validators.required + ], + address2: [''], + city: [ + '', + Validators.required + ], + pinCode: [ + '', + Validators.required + ], + telephone: [''] + }); + + // A guarantor is a client too, so its row fills from the client the same way the agent's does. + // Searched by name like the agent's own client field; a picked or keyed id fills the row. + group + .get('clientId') + ?.valueChanges.pipe(debounceTime(500), distinctUntilChanged()) + .subscribe((value) => { + const typed = String(value ?? '').trim(); + if (typed && !/^\d+$/.test(typed)) { + searchClientsAnyCase(this.clientsService, typed).subscribe((found: any[]) => { + this.setGuarantorOptions(group, this.eligibleGuarantors(group, found).slice(0, 25)); + const panel = (group as any).panel; + this.openWhenFocused(panel?.trigger, panel?.input); + }); + return; + } + this.setGuarantorOptions(group, []); + this.loadGuarantorClient(group, typed); + if (!typed) { + // Emptied: the list comes back rather than leaving nothing to pick from. + this.onGuarantorFocus(group); + } + // Two rows naming the same client: the other row's check has to be run again too. + for (const row of this.guarantors.controls) { + if (row !== group) { + row.get('clientId')?.updateValueAndValidity({ emitEvent: false }); + } + } + }); + + return group; + } + + /** + * Each guarantor row's list lives on the row itself, as a plain array the template reads - the same + * way the agent's own list works - so one row's typing never fills another's. + */ + private setGuarantorOptions(group: AbstractControl, options: any[]): void { + (group as any).clientOptions = options; + } + + guarantorOptionsFor(group: AbstractControl): any[] { + return (group as any).clientOptions || GUARANTOR_NO_OPTIONS; + } + + /** + * Opens the client list on a guarantor row as soon as it is focused, as the agent's field does. + * The list is filled before the panel is asked to open, since a panel opened empty stays shut. + */ + onGuarantorFocus(group: AbstractControl, trigger?: MatAutocompleteTrigger, input?: HTMLElement): void { + if (trigger) { + // Kept so a search answered later can open this row's list too. + (group as any).panel = { trigger, input }; + } + if (group.get('clientId')?.value) { + return; + } + const show = () => { + this.setGuarantorOptions(group, this.eligibleGuarantors(group, this.initialClients)); + const panel = (group as any).panel; + this.openWhenFocused(panel?.trigger, panel?.input); + }; + if (this.initialClients.length > 0) { + show(); + return; + } + this.clientsService.getFilteredClients('displayName', 'ASC', false, '', this.branchOfficeId).subscribe({ + next: (response: any) => { + const found = response?.pageItems || response || []; + this.initialClients = Array.isArray(found) ? found.slice(0, CreateAgentComponent.CLIENT_LIST_SIZE) : []; + this.clientTotal = response?.totalFilteredRecords ?? this.initialClients.length; + show(); + }, + error: () => undefined + }); + } + + /** Clients who can stand guarantor on this row: not the agent, and not already on another row. */ + private eligibleGuarantors(group: AbstractControl, found: any[]): any[] { + const agentClientId = String(this.agentForm.get('clientId')?.value ?? ''); + const taken = new Set( + this.guarantors.controls.filter((row) => row !== group).map((row) => String(row.get('clientId')?.value ?? '')) + ); + return found.filter((client: any) => String(client.id) !== agentClientId && !taken.has(String(client.id))); + } + + /** The same client cannot guarantee one agent twice - it counts as one guarantee, not two. */ + private notAlreadyAGuarantor(): ValidatorFn { + return (control: AbstractControl): ValidationErrors | null => { + if (!control.value || !this.agentForm) { + return null; + } + const repeated = this.guarantors.controls.some( + (row) => row.get('clientId') !== control && String(row.get('clientId')?.value) === String(control.value) + ); + return repeated ? { duplicateGuarantor: true } : null; + }; + } + + /** + * An agent cannot stand guarantor for itself - a guarantee is only worth something if it comes + * from someone else. + */ + private notTheAgentItself(): ValidatorFn { + return (control: AbstractControl): ValidationErrors | null => { + const agentClientId = this.agentForm?.get('clientId')?.value; + if (!control.value || !agentClientId) { + return null; + } + return String(control.value) === String(agentClientId) ? { selfGuarantor: true } : null; + }; + } + + /** Refills a guarantor row from its Client ID, mirroring the agent's own client lookup. */ + private loadGuarantorClient(group: UntypedFormGroup, clientId: any): void { + for (const controlName of [ + 'guarantorName', + 'telephone', + 'address1', + 'address2', + 'city', + 'pinCode' + ]) { + group.get(controlName)?.setValue(''); + } + if (!clientId) { + return; + } + + this.clientsService.getClientData(String(clientId), true).subscribe({ + next: (client: any) => { + setGroupField(group, 'guarantorName', client?.displayName); + setGroupField(group, 'telephone', client?.mobileNo); + this.clientsService.getClientAddressData(String(clientId), true).subscribe({ + next: (addresses: any) => { + const address = Array.isArray(addresses) ? addresses[0] : addresses; + if (!address) { + return; + } + setGroupField(group, 'address1', address.addressLine1 || address.street); + setGroupField(group, 'address2', address.addressLine2); + setGroupField(group, 'city', address.city); + setGroupField(group, 'pinCode', address.postalCode); + }, + error: () => undefined + }); + }, + // An id that matches no client just leaves the row for the officer to fill in. + error: () => undefined + }); + } + + addGuarantor(): void { + const group = this.buildGuarantorGroup(); + this.guarantors.push(group); + // Ready before the field is ever focused, so the list is there the moment it is clicked. + this.setGuarantorOptions(group, this.eligibleGuarantors(group, this.initialClients)); + } + + removeGuarantor(index: number): void { + this.guarantors.removeAt(index); + // A row that clashed with the removed one no longer does. + for (const row of this.guarantors.controls) { + row.get('clientId')?.updateValueAndValidity({ emitEvent: false }); + } + } + + /** The fields the legacy screen filled from the client, rather than asking the officer for. */ + private static readonly CLIENT_DERIVED_FIELDS = [ + 'agentNameId', + 'agentTelephone', + 'agentAddress1', + 'agentAddress2', + 'agentCity', + 'agentPinCode' + ]; + + /** Opens the client list when the field is focused, unless a client is already in it. */ + onClientFocus(): void { + if (this.agentId || this.agentForm.get('clientId')?.value) { + return; + } + this.showClientList(); + } + + /** Shows the first page of clients, fetching it the first time it is asked for. */ + private showClientList(): void { + if (this.agentId) { + return; + } + if (this.initialClients.length > 0) { + this.showingClientList = true; + this.clientOptions = this.notYetAgents(this.initialClients); + this.openWhenFocused(this.clientTrigger, this.clientInput?.nativeElement); + return; + } + // The chosen branch's clients only, since an agent is opened for the branch they belong to. + this.clientsService.getFilteredClients('displayName', 'ASC', false, '', this.branchOfficeId).subscribe({ + next: (response: any) => { + const found = response?.pageItems || response || []; + this.initialClients = Array.isArray(found) ? found.slice(0, CreateAgentComponent.CLIENT_LIST_SIZE) : []; + this.clientTotal = response?.totalFilteredRecords ?? this.initialClients.length; + this.showingClientList = true; + this.clientOptions = this.notYetAgents(this.initialClients); + this.openWhenFocused(this.clientTrigger, this.clientInput?.nativeElement); + }, + // A failed list is not a failed screen - the id can still be typed. + error: () => (this.clientOptions = []) + }); + } + + private searchClients(term: string): void { + searchClientsAnyCase(this.clientsService, term, this.branchOfficeId).subscribe((found: any[]) => { + this.showingClientList = false; + this.clientOptions = this.notYetAgents(found).slice(0, 25); + this.openWhenFocused(this.clientTrigger, this.clientInput?.nativeElement); + }); + } + + /** + * Shows a list that has just arrived. An autocomplete opened while its list was still empty stays + * shut when the options come in, so the screen is refreshed and the panel opened again - but only + * on the field still being typed in, never one the officer has moved on from. + */ + private openWhenFocused(trigger?: MatAutocompleteTrigger, input?: HTMLElement): void { + this.changeDetector.detectChanges(); + if (trigger && input && document.activeElement === input) { + trigger.openPanel(); + } + } + + /** + * Refills the client-derived fields whenever the Client ID changes, as the legacy screen did. + * They always show the client currently entered: changing the id replaces them, clearing it + * empties them. + */ + loadClient(): void { + if (this.agentId) { + return; + } + this.clearClientDerivedFields(); + this.agentForm.get('agentBirthDate')?.setValue(''); + this.loadedClientName = ''; + this.clientLookupFailed = false; + // A different client has different accounts, so anything picked for the previous one is stale. + this.clientSavingsAccounts = []; + this.savingsAccountTypes = []; + this.agentForm.get('agentSavingAccountType')?.setValue(''); + this.agentForm.get('agentSavingAccountNo')?.setValue(''); + + this.clientOtherBranch = false; + this.clientAlreadyAgent = ''; + this.clientAgeProblem = ''; + const clientId = this.agentForm.get('clientId')?.value; + if (!clientId) { + return; + } + + // One client is one agent: someone already on the books is not made a second agent. + this.clientAlreadyAgent = this.agentCodeByClientId[String(clientId)] || ''; + if (this.clientAlreadyAgent) { + return; + } + this.clientsService.getClientData(String(clientId), true).subscribe({ + next: (client: any) => { + // An agent is opened at the branch chosen, from that branch's own clients. + if (this.branchOfficeId && client?.officeId && Number(client.officeId) !== this.branchOfficeId) { + this.clientOtherBranch = true; + this.loadedClientName = ''; + return; + } + this.loadedClientName = client?.displayName || ''; + this.setField('agentNameId', client?.displayName); + this.setField('agentTelephone', client?.mobileNo); + // The client's own date of birth, already taken and checked when the client was opened - + // copied across rather than keyed again. + const birthDate = asDate(client?.dateOfBirth); + this.agentForm.get('agentBirthDate')?.setValue(birthDate); + // Agents are taken on at 16 to 59 - said here, before the rest is filled in, not on Save. + const age = ageOn(birthDate, new Date()); + this.clientAgeProblem = + age === null + ? 'This client has no date of birth on record' + : age < CreateAgentComponent.MIN_AGENT_AGE || age >= CreateAgentComponent.MAX_AGENT_AGE + ? `Age is ${age}. An agent must be ${CreateAgentComponent.MIN_AGENT_AGE} to ${CreateAgentComponent.MAX_AGENT_AGE - 1} years old` + : ''; + this.loadClientAddress(String(clientId)); + this.loadClientSavingsAccounts(String(clientId)); + }, + error: () => { + this.clientLookupFailed = true; + } + }); + } + + /** + * The client's own open savings accounts, to be picked from rather than typed. + * + * The account number used to be free text, and the live data showed the cost: agent AG0001 carried + * "1234567890", which matches no account at all - so every commission recorded against it had + * nowhere to go. Offering only the client's real accounts makes that impossible to key, and the + * account number then carries its correct product prefix by construction rather than by memory. + */ + private loadClientSavingsAccounts(clientId: string): void { + this.clientsService.getClientAccountData(clientId).subscribe({ + next: (accounts: any) => { + this.clientSavingsAccounts = (accounts?.savingsAccounts || []).filter( + (account: any) => + // Savings only. Commission is credited whenever a run posts, and a fixed deposit is a + // single sum placed for a term while a recurring one takes only its own instalments - + // neither accepts the credit, so the posting would fail rather than the choice. + // A DDS scheme account is a savings account too and so passes the test above. It is + // dropped by the product the setting names - not by a product called "DDS", which is a + // name the bank may change - so the list offers only accounts the agent can be paid in. + account.depositType?.id === CreateAgentComponent.SAVINGS_DEPOSIT_TYPE && + (this.ddsProductId === null || account.productId !== this.ddsProductId) && + (account.status?.active || account.status?.submittedAndPendingApproval || account.status?.approved) + ); + this.savingsAccountTypes = [ + ...new Set(this.clientSavingsAccounts.map((account: any) => account.productName).filter(Boolean)) + ]; + for (const account of this.clientSavingsAccounts) { + if (account.productName && account.shortProductName) { + this.savingsAccountTypeCodes[account.productName] = account.shortProductName; + } + } + }, + error: () => { + this.clientSavingsAccounts = []; + this.savingsAccountTypes = []; + } + }); + } + + /** + * Clears the account number when the type changes. + * + * Without this the previously picked account stays in the field while the list below it now shows + * a different type - the form would submit an account that contradicts its own type. + */ + onSavingsAccountTypePicked(): void { + this.agentForm.get('agentSavingAccountNo')?.setValue(''); + } + + private loadClientAddress(clientId: string): void { + this.clientsService.getClientAddressData(clientId, true).subscribe({ + next: (addresses: any) => { + const address = Array.isArray(addresses) ? addresses[0] : addresses; + if (!address) { + return; + } + this.setField('agentAddress1', address.addressLine1 || address.street); + this.setField('agentAddress2', address.addressLine2); + this.setField('agentCity', address.city); + this.setField('agentPinCode', address.postalCode); + }, + // A client with no address on file is normal — the officer just types it. + error: () => undefined + }); + } + + private clearClientDerivedFields(): void { + for (const controlName of CreateAgentComponent.CLIENT_DERIVED_FIELDS) { + this.agentForm.get(controlName)?.setValue(''); + } + } + + private setField(controlName: string, value: any): void { + if (value === null || value === undefined || value === '') { + return; + } + this.agentForm.get(controlName)?.setValue(String(value)); + } + + retrieve(): void { + const clientId = this.agentForm.get('clientId')?.value; + if (!clientId) { + return; + } + this.organizationService.getAgents().subscribe((agents: any[]) => { + const match = (agents || []).find((agent) => String(agent.clientId) === String(clientId)); + if (match) { + this.patchFormFromAgent(match); + } + }); + } + + cancel(): void { + this.agentId = null; + this.loadedClientName = ''; + this.clientLookupFailed = false; + this.agentForm.reset(); + this.agentForm.patchValue({ + agentCurrentBalance: 0, + agentOpeningBalance: 0 + }); + this.guarantors.clear(); + } + + exit(): void { + this.router.navigate(['/organization/agent-master']); + } + + save(): void { + const raw = this.agentForm.getRawValue(); + const dateFormat = this.settingsService.dateFormat; + const locale = this.settingsService.language.code; + + const payload: any = { + clientId: Number(raw.clientId), + agentNameId: raw.agentNameId, + agentAddress1: raw.agentAddress1, + agentAddress2: raw.agentAddress2 || undefined, + agentCity: raw.agentCity, + agentPinCode: raw.agentPinCode, + agentTelephone: raw.agentTelephone || undefined, + officeId: Number(raw.officeId), + glAccountId: raw.glAccountId ? Number(raw.glAccountId) : undefined, + agentSavingAccountType: raw.agentSavingAccountType, + agentSavingAccountNo: raw.agentSavingAccountNo, + agentOpeningDate: this.dateUtils.formatDate(raw.agentOpeningDate, dateFormat), + agentBirthDate: this.dateUtils.formatDate(raw.agentBirthDate, dateFormat), + agentOpeningBalance: raw.agentOpeningBalance || 0, + dateFormat, + locale, + guarantors: (raw.guarantors || []).map((guarantor: any) => ({ + clientId: Number(guarantor.clientId), + guarantorName: guarantor.guarantorName, + address1: guarantor.address1, + address2: guarantor.address2 || undefined, + city: guarantor.city, + pinCode: guarantor.pinCode, + telephone: guarantor.telephone || undefined + })) + }; + + const done = (response: any) => { + // Under maker-checker the command is only recorded, and nothing has been saved yet. + if (!!response?.commandId && response?.resourceId == null) { + this.alertService.alert({ + type: 'Sent for Approval', + message: this.agentId + ? `Changes to agent ${raw.agentNameId} were sent for a manager's approval and will take effect once approved.` + : `New agent ${raw.agentNameId} was sent for a manager's approval and will be created once approved.` + }); + } + this.exit(); + }; + + if (this.agentId) { + this.organizationService.updateAgent(this.agentId, payload).subscribe(done); + } else { + this.organizationService.createAgent(payload).subscribe(done); + } + } + + private patchFormFromAgent(agent: any): void { + this.agentId = agent.id; + this.loadedClientName = agent.clientName || ''; + this.clientLookupFailed = false; + this.agentForm.patchValue({ + clientId: agent.clientId, + agentCode: agent.agentCode, + agentNameId: agent.agentNameId, + agentAddress1: agent.agentAddress1, + agentAddress2: agent.agentAddress2, + agentCity: agent.agentCity, + agentPinCode: agent.agentPinCode, + agentTelephone: agent.agentTelephone, + officeId: agent.officeId ? Number(agent.officeId) : '', + glAccountId: agent.glAccountId ? Number(agent.glAccountId) : '', + agentSavingAccountType: agent.agentSavingAccountType, + agentSavingAccountNo: agent.agentSavingAccountNo, + agentOpeningDate: agent.agentOpeningDate ? new Date(agent.agentOpeningDate) : null, + agentBirthDate: agent.agentBirthDate ? new Date(agent.agentBirthDate) : null, + agentCurrentBalance: agent.agentCurrentBalance, + agentOpeningBalance: agent.agentOpeningBalance + }); + + // The account list is needed for the dropdown to show the already-saved account as chosen + // rather than blank, so it is fetched on edit as well as on the client lookup. + if (agent.clientId) { + this.loadClientSavingsAccounts(String(agent.clientId)); + } + + this.guarantors.clear(); + for (const guarantor of agent.guarantors || []) { + const group = this.buildGuarantorGroup(); + group.patchValue({ + clientId: guarantor.clientId, + guarantorName: guarantor.guarantorName, + address1: guarantor.address1, + address2: guarantor.address2, + city: guarantor.city, + pinCode: guarantor.pinCode, + telephone: guarantor.telephone + }); + this.guarantors.push(group); + } + } +} + +/** A date as the platform sends it - [yyyy, mm, dd] or an ISO string - or null when there is none. */ +function asDate(value: any): Date | null { + if (Array.isArray(value) && value.length >= 3) { + return new Date(value[0], value[1] - 1, value[2]); + } + if (value) { + const parsed = new Date(value); + return isNaN(parsed.getTime()) ? null : parsed; + } + return null; +} + +/** One shared empty list, so a row with nothing to offer hands the template the same array each time. */ +const GUARANTOR_NO_OPTIONS: any[] = []; + +/** Full years between a date of birth and a day, or null without a date of birth. */ +function ageOn(birthDate: Date | null, on: Date): number | null { + if (!birthDate) { + return null; + } + let age = on.getFullYear() - birthDate.getFullYear(); + const birthdayPassed = + on.getMonth() > birthDate.getMonth() || + (on.getMonth() === birthDate.getMonth() && on.getDate() >= birthDate.getDate()); + if (!birthdayPassed) { + age--; + } + return age; +} diff --git a/src/app/organization/agent-master/offices-under.ts b/src/app/organization/agent-master/offices-under.ts new file mode 100644 index 000000000..1129ce228 --- /dev/null +++ b/src/app/organization/agent-master/offices-under.ts @@ -0,0 +1,27 @@ +/** + * Copyright since 2025 Mifos Initiative + * + * This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. + */ + +/** + * The given office and every office under it, read from the office hierarchy (".", ".2.", ".2.5."): + * an office's own branches are the ones whose hierarchy starts with its own. Head Office, at the + * top, has them all. Falls back to the office alone when its hierarchy is not known. + */ +export function officesUnder(offices: any[], officeId: number | null | undefined): any[] { + if (!officeId) { + return offices; + } + const own = offices.find((office: any) => office.id === officeId); + if (!own) { + return []; + } + const hierarchy = String(own.hierarchy || ''); + if (!hierarchy) { + return [own]; + } + return offices.filter((office: any) => String(office.hierarchy || '').startsWith(hierarchy)); +} diff --git a/src/app/organization/commission-slab-master/commission-slab-master.component.html b/src/app/organization/commission-slab-master/commission-slab-master.component.html new file mode 100644 index 000000000..2a56de10a --- /dev/null +++ b/src/app/organization/commission-slab-master/commission-slab-master.component.html @@ -0,0 +1,157 @@ + + +
+
+ +

Commission Slab Master

+
+ +
+ + + + + + + + + + + + @for (slab of slabs; track slab; let i = $index) { + @if (editingIndex === i) { + + + + + + + + + } @else { + + + + + + + + } + } + + + + + + + + + +
Slab TypeSlab FromSlab ToComm Per
+ + + @for (type of slabTypes; track type) { + {{ type }} + } + + + + + + + + + + + + + + + + + +
{{ slab.slabType }}{{ slab.slabFrom }}{{ slab.slabTo }}{{ slab.commissionPercent }}% + @if (slab.id) { + + + } @else { + + + } +
+ + + @for (type of slabTypes; track type) { + {{ type }} + } + + + + + + + + + + + + + + + + + +
+ + @if (slabs.length === 0) { +

No commission slabs defined yet — add one using the row above.

+ } + + @if (coverage.length > 0) { +
+ @for (entry of coverage; track entry.slabType) { +
+ {{ entry.slabType }} + covers {{ entry.from | number }} to {{ entry.to | number }} + @if (entry.gaps.length > 0) { + + nothing set for {{ entry.gaps.join(', ') }} — an agent landing there earns no commission + + } +
+ } +
+ } + +
+ + +
+
+
diff --git a/src/app/organization/commission-slab-master/commission-slab-master.component.scss b/src/app/organization/commission-slab-master/commission-slab-master.component.scss new file mode 100644 index 000000000..33b830b27 --- /dev/null +++ b/src/app/organization/commission-slab-master/commission-slab-master.component.scss @@ -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/. + */ + +$primary: #0f766e; + +.csm-container { + // Four inputs sit side by side in the entry row; 760px squeezed them out of the card. + max-width: 980px; + margin: 0 auto; + padding: 24px 16px; +} + +.csm-header { + display: flex; + align-items: center; + gap: 16px; + margin-bottom: 20px; + + &__title { + margin: 0; + font-size: 22px; + font-weight: 500; + } +} + +.csm-back { + color: $primary; +} + +.csm-card { + background: var(--md-sys-color-surface, #fff); + border-radius: 8px; + box-shadow: 0 2px 8px rgb(0 0 0 / 8%); + padding: 16px 24px 24px; +} + +.csm-table { + width: 100%; + border-collapse: collapse; + table-layout: fixed; + + // Sized rather than left to the browser, so the entry row keeps its shape whatever is typed in. + th:nth-child(1), + td:nth-child(1) { + width: 26%; + } + + th:nth-child(2), + td:nth-child(2) { + width: 22%; + } + + th:nth-child(3), + td:nth-child(3) { + width: 22%; + } + + th:nth-child(4), + td:nth-child(4) { + width: 18%; + } + + th:nth-child(5), + td:nth-child(5) { + width: 12%; + } + + th { + text-align: left; + font-size: 13px; + text-transform: uppercase; + letter-spacing: 0.04em; + color: rgb(0 0 0 / 55%); + padding: 8px 10px; + border-bottom: 2px solid rgb(0 0 0 / 10%); + } + + td { + padding: 6px 10px; + border-bottom: 1px solid rgb(0 0 0 / 6%); + vertical-align: middle; + } +} + +.csm-inline-field { + width: 100%; + // Without this the field holds its own 180px floor and pushes the next column out of the card. + min-width: 0; + + ::ng-deep .mat-mdc-text-field-wrapper { + height: 44px; + } +} + +.csm-row-actions { + text-align: center; + width: 48px; +} + +.csm-new-row { + background: rgb(15 118 110 / 4%); +} + +.csm-empty { + color: rgb(0 0 0 / 50%); + font-size: 14px; + margin: 12px 0 0; +} + +.csm-action-bar { + display: flex; + justify-content: flex-end; + gap: 12px; + margin-top: 20px; + padding-top: 16px; + border-top: 1px solid rgb(0 0 0 / 8%); +} + +/* + * What the slabs actually cover, and any figure between them that none of them claims. + * + * A gap is legitimate - a bank may set no commission below some floor - so this reports rather than + * warns. But it has to be said out loud: the commission run finds no slab for a volume in a gap and + * moves on, so the agent is simply paid nothing that month and nothing on screen explains it. + */ +.csm-coverage { + margin-top: 14px; + padding: 12px 16px; + border: 1px solid rgb(0 0 0 / 12%); + border-radius: 6px; + background: rgb(0 0 0 / 2.5%); + display: flex; + flex-direction: column; + gap: 6px; + + &__row { + display: flex; + align-items: baseline; + gap: 10px; + flex-wrap: wrap; + font-size: 0.86rem; + } + + &__type { + font-weight: 600; + min-width: 90px; + } + + &__range { + color: rgb(0 0 0 / 62%); + font-variant-numeric: tabular-nums; + } + + &__gap { + color: #b45309; + font-variant-numeric: tabular-nums; + } +} diff --git a/src/app/organization/commission-slab-master/commission-slab-master.component.ts b/src/app/organization/commission-slab-master/commission-slab-master.component.ts new file mode 100644 index 000000000..6519a1193 --- /dev/null +++ b/src/app/organization/commission-slab-master/commission-slab-master.component.ts @@ -0,0 +1,337 @@ +/** + * Copyright since 2025 Mifos Initiative + * + * This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. + */ + +/** Angular Imports */ +import { Component, OnInit, inject } from '@angular/core'; +import { UntypedFormBuilder, UntypedFormGroup, Validators } from '@angular/forms'; +import { Router } from '@angular/router'; +import { STANDALONE_SHARED_IMPORTS } from 'app/standalone-shared.module'; + +/** Custom Services */ +import { OrganizationService } from 'app/organization/organization.service'; +import { AlertService } from 'app/core/alert/alert.service'; +import { announceHeldForApproval, heldForApproval } from 'app/deposits/dds-approvals/dds-approval-notice'; + +/** + * Commission Slab Master component. + * + * Two kinds of slab: ACCOUNT (commission percentage on collection volume) + * and COMMISSION (a senior-agent override on another agent's commission - + * deferred: there is no agent hierarchy modelled yet, see the DDS punch + * list). Modelled directly on the legacy Commission Slab Master screen this + * replaces. + * + * Backed by its own DDS Commission Slab API (`/dds/commissionslabs`) - an + * append-only chart, same as the legacy screen: existing rows load on open, + * newly-added rows post on Save, and there is no edit/delete of a + * previously-saved row. + */ +@Component({ + selector: 'mifosx-commission-slab-master', + templateUrl: './commission-slab-master.component.html', + styleUrls: ['./commission-slab-master.component.scss'], + imports: [...STANDALONE_SHARED_IMPORTS] +}) +export class CommissionSlabMasterComponent implements OnInit { + private formBuilder = inject(UntypedFormBuilder); + private router = inject(Router); + private organizationService = inject(OrganizationService); + private alertService = inject(AlertService); + + private static readonly SLAB_TYPE_CODES: Record = { + Account: 'ACCOUNT', + Commission: 'COMMISSION' + }; + private static readonly SLAB_TYPE_LABELS: Record = { + ACCOUNT: 'Account', + COMMISSION: 'Commission' + }; + + slabTypes = [ + 'Account', + 'Commission' + ]; + + displayedColumns: string[] = [ + 'slabType', + 'slabFrom', + 'slabTo', + 'commissionPercent', + 'actions' + ]; + + slabs: Array<{ id?: number; slabType: string; slabFrom: number; slabTo: number; commissionPercent: number }> = []; + + newSlabForm: UntypedFormGroup = this.formBuilder.group({ + slabType: [ + '', + Validators.required + ], + slabFrom: [ + 0, + Validators.required + ], + slabTo: [ + 0, + Validators.required + ], + commissionPercent: [ + 0, + Validators.required + ] + }); + + ngOnInit(): void { + this.loadSlabs(); + } + + private loadSlabs(): void { + this.organizationService.getCommissionSlabs().subscribe((slabs: any[]) => { + this.slabs = (slabs || []).map((slab) => ({ + id: slab.id, + slabType: CommissionSlabMasterComponent.SLAB_TYPE_LABELS[slab.slabType] || slab.slabType, + slabFrom: slab.slabFrom, + slabTo: slab.slabTo, + commissionPercent: slab.commissionPercent + })); + }); + } + + /** + * Where each slab type's coverage begins and ends, and any figure in between that no slab claims. + * + * A gap is not an error - a bank may well decide there is no commission below some floor - but it + * has to be visible. The commission run simply finds no slab for a volume that falls in a gap and + * moves on, so the agent is paid nothing for that month with nothing on screen to say why. + */ + get coverage(): Array<{ slabType: string; from: number; to: number; gaps: string[] }> { + return this.slabTypes + .map((slabType) => { + const rows = this.slabs + .filter((slab) => slab.slabType === slabType) + .map((slab) => ({ from: Number(slab.slabFrom), to: Number(slab.slabTo) })) + .sort((a, b) => a.from - b.from); + + if (rows.length === 0) { + return null; + } + + const gaps: string[] = []; + for (let i = 1; i < rows.length; i++) { + const previousEnd = rows[i - 1].to; + const nextStart = rows[i].from; + if (nextStart > previousEnd + 1) { + gaps.push(`${previousEnd + 1} - ${nextStart - 1}`); + } + } + + return { slabType, from: rows[0].from, to: rows[rows.length - 1].to, gaps }; + }) + .filter((entry): entry is { slabType: string; from: number; to: number; gaps: string[] } => entry !== null); + } + + /** + * The slab already on the table that this range would collide with, if any. + * + * The backend refuses overlapping slabs, but only when Save is pressed - by then the officer may + * have keyed several rows and has to work out which one it objected to. Catching it as the row is + * added names the clash while it is still the row in front of them. + */ + private clashingSlab(candidate: { slabType: string; slabFrom: number; slabTo: number }): any { + return this.slabs.find( + (slab) => + slab.slabType === candidate.slabType && + Number(candidate.slabFrom) <= Number(slab.slabTo) && + Number(candidate.slabTo) >= Number(slab.slabFrom) + ); + } + + addSlab(): void { + if (this.newSlabForm.invalid) { + // Marking the controls touched is what makes Material show which one is missing; without it + // the button simply does nothing and the officer has no idea what the screen is waiting for. + this.newSlabForm.markAllAsTouched(); + this.alertService.alert({ type: 'Error', message: 'Fill the slab type, range and percentage first' }); + return; + } + + const candidate = this.newSlabForm.getRawValue(); + + if (Number(candidate.slabFrom) > Number(candidate.slabTo)) { + this.alertService.alert({ type: 'Error', message: 'Slab From cannot be greater than Slab To' }); + return; + } + if (Number(candidate.commissionPercent) > 100) { + this.alertService.alert({ type: 'Error', message: 'Commission cannot be more than 100%' }); + return; + } + + const clash = this.clashingSlab(candidate); + if (clash) { + this.alertService.alert({ + type: 'Error', + message: `Overlaps the existing ${clash.slabType} slab ${clash.slabFrom} to ${clash.slabTo}` + }); + return; + } + + this.slabs.push(candidate); + this.newSlabForm.reset({ + slabType: '', + slabFrom: 0, + slabTo: 0, + commissionPercent: 0 + }); + } + + /** Only ever an unsaved row - a saved slab is corrected, not taken away. */ + removeSlab(index: number): void { + this.slabs.splice(index, 1); + } + + /** Which saved row is currently open for correction, or -1 when none is. */ + editingIndex = -1; + + editSlabForm: UntypedFormGroup = this.formBuilder.group({ + slabType: [ + '', + Validators.required + ], + slabFrom: [ + 0, + Validators.required + ], + slabTo: [ + 0, + Validators.required + ], + commissionPercent: [ + 0, + Validators.required + ] + }); + + startEdit(index: number): void { + const slab = this.slabs[index]; + this.editingIndex = index; + this.editSlabForm.setValue({ + slabType: slab.slabType, + slabFrom: slab.slabFrom, + slabTo: slab.slabTo, + commissionPercent: slab.commissionPercent + }); + } + + cancelEdit(): void { + this.editingIndex = -1; + } + + saveEdit(): void { + const slab = this.slabs[this.editingIndex]; + const corrected = this.editSlabForm.getRawValue(); + + if (this.editSlabForm.invalid) { + this.editSlabForm.markAllAsTouched(); + this.alertService.alert({ type: 'Error', message: 'Fill the slab type, range and percentage' }); + return; + } + if (Number(corrected.slabFrom) > Number(corrected.slabTo)) { + this.alertService.alert({ type: 'Error', message: 'Slab From cannot be greater than Slab To' }); + return; + } + if (Number(corrected.commissionPercent) > 100) { + this.alertService.alert({ type: 'Error', message: 'Commission cannot be more than 100%' }); + return; + } + + this.organizationService + .updateCommissionSlab(slab.id, { + slabType: CommissionSlabMasterComponent.SLAB_TYPE_CODES[corrected.slabType] || corrected.slabType, + slabFrom: corrected.slabFrom, + slabTo: corrected.slabTo, + commissionPercent: corrected.commissionPercent, + locale: 'en' + }) + .subscribe({ + next: (response: any) => { + if (heldForApproval(response)) { + announceHeldForApproval(response, 'The slab correction', this.organizationService, this.alertService); + } else { + this.alertService.alert({ type: 'Success', message: 'Slab corrected' }); + } + this.editingIndex = -1; + this.loadSlabs(); + }, + // The backend refuses a correction that overlaps another slab; the reason has to reach the + // officer, not vanish. + error: (error: any) => { + this.alertService.alert({ + type: 'Error', + message: + error?.error?.errors?.[0]?.defaultUserMessage || + error?.error?.defaultUserMessage || + 'Could not correct the slab' + }); + } + }); + } + + exit(): void { + this.router.navigate(['/organization']); + } + + save(): void { + const newSlabs = this.slabs.filter((slab) => !slab.id); + if (newSlabs.length === 0) { + // Save used to return here without a word, so a slab typed into the entry row but never added + // looked exactly like a slab that had been saved. + this.alertService.alert({ + type: 'Error', + message: 'Nothing new to save — add the slab to the table with + first' + }); + return; + } + + const payload = newSlabs.map((slab) => ({ + slabType: CommissionSlabMasterComponent.SLAB_TYPE_CODES[slab.slabType] || slab.slabType, + slabFrom: slab.slabFrom, + slabTo: slab.slabTo, + commissionPercent: slab.commissionPercent + })); + + this.organizationService.createCommissionSlabs(payload).subscribe({ + next: (response: any) => { + if (heldForApproval(response)) { + announceHeldForApproval( + response, + `${newSlabs.length} ${newSlabs.length === 1 ? 'slab' : 'slabs'}`, + this.organizationService, + this.alertService + ); + } else { + this.alertService.alert({ + type: 'Success', + message: `${newSlabs.length} ${newSlabs.length === 1 ? 'slab' : 'slabs'} saved` + }); + } + this.loadSlabs(); + }, + // The backend refuses an overlapping range; the officer needs to read why, not watch nothing + // happen and assume the save went through. + error: (error: any) => { + this.alertService.alert({ + type: 'Error', + message: + error?.error?.errors?.[0]?.defaultUserMessage || + error?.error?.defaultUserMessage || + 'Could not save the slabs' + }); + } + }); + } +} diff --git a/src/app/organization/dds-batch-processing/dds-batch-processing.component.html b/src/app/organization/dds-batch-processing/dds-batch-processing.component.html new file mode 100644 index 000000000..7570ffad9 --- /dev/null +++ b/src/app/organization/dds-batch-processing/dds-batch-processing.component.html @@ -0,0 +1,299 @@ + + +
+
+ +

DDS Interest & Commission

+
+ +
+
+
+ + Job Type + + @for (type of jobTypes; track type.code) { + {{ type.label }} + } + + + + + Branch + + @for (scope of branchScopes; track scope) { + {{ scope }} + } + + + + @if (isBranchSelected) { +
+ @for (office of offices; track office.id) { + + {{ office.name }} + + } + @if (offices.length === 0) { + Loading branches... + } +
+ } + + + {{ isPostingJob ? 'Posting Date' : 'Interest Date' }} + + + + + + + Agent From + + + + + Agent To + + + + + @if (!isCommissionJob) { + + Party From + + + + + Party To + + + } +
+ +
+ + + @if (isPostingJob) { + {{ jobTypeLabel }} moves what has already been calculated to where it belongs — into the + customer's balance, or out to the agent. Generate lists it first; nothing moves until you + press Post. + } @else { + {{ jobTypeLabel }} works out what has been earned since the last run and records it against + each account. It does not become spendable until the matching Posting job runs. + Generate previews it; nothing is recorded until you press Post. + } + +
+ + @if (generatedRun) { +
+
+ +

{{ labelOf(generatedRun.jobType) }} — preview

+ Run #{{ generatedRun.id }} · nothing applied yet +
+ + @if (previewRows.length === 0) { +

+ Nothing to apply. Either this period has already been processed, or there was no activity in it. +

+ } @else { +
+ @if (showsCommissionBreakdown) { + + + + + + + + + + + + + + + @for (row of previewRows; track row.id) { + + + + + + + + + + + } + + + + + + + +
AgentPeriodCollectionRateCommissionTDSDeductionsPayable
+ {{ row.agentCode }} + {{ row.agentName }} + {{ row.fromDate | dateFormat }} – {{ row.toDate | dateFormat }}{{ money(row.baseAmount) }}{{ row.ratePercent }}%{{ money(row.grossAmount) }}{{ money(row.tdsAmount) }}{{ money(row.deductionAmount) }}{{ money(row.amount) }}
Total payable{{ money(previewTotal) }}
+ } @else { + + + + + @if (!isCommissionJob) { + + } + + + + + + @for (row of previewRows; track row.id) { + + + @if (!isCommissionJob) { + + } + + + + } + + + + + + + +
{{ isCommissionJob ? 'Agent' : 'Party' }}AgentPeriodAmount
+ @if (isCommissionJob) { + {{ row.agentCode }} + {{ row.agentName }} + } @else { + {{ row.partyCode }} + {{ row.partyName }} + } + {{ row.agentCode }}{{ row.fromDate | dateFormat }} – {{ row.toDate | dateFormat }}{{ money(row.amount) }}
Total{{ money(previewTotal) }}
+ } +
+ } +
+ } + +
+ + @if (generatedRun) { + + } + + +
+
+
+ + + @if (postingRunId) { +
+ + + Posting is running. + It continues even if you leave this screen — the list below updates when it finishes. + +
+ } + + +
+
+ +

Previous runs

+
+ + @if (recentRuns.length === 0) { +

No batch has been run yet.

+ } @else { +
+ + + + + + + + + + + + + + @for (run of recentRuns; track run.id) { + + + + + + + + + + + } + +
#JobDateEntriesAmountStatusPosted On
{{ run.id }}{{ labelOf(run.jobType) }}{{ run.jobDate | dateFormat }}{{ run.totalRows }}{{ money(run.totalAmount) }} + @switch (run.status) { + @case ('POSTED') { + Posted + } + @case ('POSTING') { + + Running + + } + @case ('FAILED') { + + Failed + + } + @default { + Not posted + } + } + {{ run.postedOnDate ? (run.postedOnDate | dateFormat) : '—' }}
+
+ } +
+
diff --git a/src/app/organization/dds-batch-processing/dds-batch-processing.component.scss b/src/app/organization/dds-batch-processing/dds-batch-processing.component.scss new file mode 100644 index 000000000..26dbefd94 --- /dev/null +++ b/src/app/organization/dds-batch-processing/dds-batch-processing.component.scss @@ -0,0 +1,255 @@ +/** + * Copyright since 2025 Mifos Initiative + * + * This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. + */ + +$primary: #0f766e; + +.bp-container { + // The commission breakdown carries eight columns; anything narrower makes it scroll on every + // screen rather than only on small ones. + max-width: 1180px; + margin: 0 auto; + padding: 24px 16px; + display: flex; + flex-direction: column; + gap: 20px; +} + +.bp-header { + display: flex; + align-items: center; + gap: 16px; + + &__title { + margin: 0; + font-size: 22px; + font-weight: 500; + } +} + +.bp-back { + color: $primary; +} + +.bp-card { + background: var(--md-sys-color-surface, #fff); + border-radius: 8px; + box-shadow: 0 2px 8px rgb(0 0 0 / 8%); + padding: 20px 24px 24px; +} + +.bp-grid { + display: grid; + grid-template-columns: repeat(3, 1fr); + gap: 8px 16px; +} + +.bp-field { + width: 100%; + + &--wide { + grid-column: span 3; + } +} + +.bp-branch-checklist { + display: flex; + flex-wrap: wrap; + gap: 8px 20px; + align-items: center; + padding: 8px 0; +} + +.bp-callout { + display: flex; + gap: 10px; + align-items: flex-start; + background: rgb(15 118 110 / 6%); + border-left: 3px solid $primary; + border-radius: 0 8px 8px 0; + padding: 12px 16px; + margin: 16px 0; + font-size: 13px; + + i { + color: $primary; + margin-top: 2px; + } +} + +.bp-section { + padding: 16px 0; + border-top: 1px solid rgb(0 0 0 / 8%); + + &__header { + display: flex; + align-items: center; + gap: 10px; + margin-bottom: 12px; + + i { + color: $primary; + } + + h3 { + margin: 0; + font-size: 16px; + font-weight: 600; + flex: 0 0 auto; + } + } + + &__note { + margin-left: auto; + font-size: 12px; + color: rgb(0 0 0 / 45%); + } +} + +.bp-empty { + color: rgb(0 0 0 / 50%); + font-size: 14px; + margin: 0; +} + +// A wide table scrolls inside its own box rather than pushing the whole page sideways. +.bp-table-scroll { + overflow-x: auto; +} + +.bp-table { + width: 100%; + border-collapse: collapse; + font-size: 13px; + + th { + text-align: left; + font-size: 12px; + text-transform: uppercase; + letter-spacing: 0.04em; + color: rgb(0 0 0 / 55%); + padding: 8px 10px; + border-bottom: 2px solid rgb(0 0 0 / 10%); + white-space: nowrap; + } + + td { + padding: 7px 10px; + border-bottom: 1px solid rgb(0 0 0 / 6%); + vertical-align: top; + } + + tfoot td { + border-bottom: none; + border-top: 2px solid rgb(0 0 0 / 10%); + font-weight: 600; + } + + // Figures line up column-wise so a wrong order of magnitude is visible at a glance. + .bp-num { + text-align: right; + font-variant-numeric: tabular-nums; + white-space: nowrap; + } + + .bp-minus { + color: #b91c1c; + } + + .bp-total { + font-weight: 600; + } + + .bp-period { + white-space: nowrap; + color: rgb(0 0 0 / 60%); + } +} + +.bp-code { + display: block; + font-weight: 600; +} + +.bp-sub { + display: block; + font-size: 12px; + color: rgb(0 0 0 / 55%); +} + +.bp-status { + display: inline-block; + padding: 2px 8px; + border-radius: 10px; + font-size: 12px; + font-weight: 600; + background: rgb(180 83 9 / 12%); + color: #92400e; + + &--posted { + background: rgb(15 118 110 / 12%); + color: $primary; + } + + /* Still crediting. Blue rather than the amber of a run nobody has posted — one is work in hand, + the other is work forgotten, and a clerk who reads them the same way runs it twice. */ + &--running { + background: rgb(22 84 143 / 12%); + color: #16548f; + display: inline-flex; + align-items: center; + gap: 5px; + } + + &--failed { + background: rgb(163 42 28 / 12%); + color: #a32a1c; + cursor: help; + } +} + +/* Says a run is still going after the clerk has left the button behind. */ +.bp-running { + display: flex; + align-items: center; + gap: 10px; + margin-bottom: 16px; + padding: 12px 16px; + border: 1px solid rgb(22 84 143 / 25%); + border-left: 3px solid #16548f; + border-radius: 6px; + background: rgb(22 84 143 / 6%); + color: #16548f; + font-size: 13px; + + strong { + font-weight: 600; + } +} + +.bp-action-bar { + display: flex; + justify-content: flex-end; + gap: 12px; + margin-top: 20px; + padding-top: 16px; + border-top: 1px solid rgb(0 0 0 / 8%); + + i { + margin-right: 6px; + } +} + +@media (width <= 900px) { + .bp-grid { + grid-template-columns: repeat(2, 1fr); + } + + .bp-field--wide { + grid-column: span 2; + } +} diff --git a/src/app/organization/dds-batch-processing/dds-batch-processing.component.ts b/src/app/organization/dds-batch-processing/dds-batch-processing.component.ts new file mode 100644 index 000000000..37a2b5ee5 --- /dev/null +++ b/src/app/organization/dds-batch-processing/dds-batch-processing.component.ts @@ -0,0 +1,400 @@ +/** + * Copyright since 2025 Mifos Initiative + * + * This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. + */ + +/** Angular Imports */ +import { Component, OnInit, inject } from '@angular/core'; +import { UntypedFormBuilder, UntypedFormGroup, Validators } from '@angular/forms'; +import { Router } from '@angular/router'; +import { MatDialog } from '@angular/material/dialog'; +import { MatTooltip } from '@angular/material/tooltip'; +import { DateAdapter, MAT_DATE_FORMATS } from '@angular/material/core'; +import { STANDALONE_SHARED_IMPORTS } from 'app/standalone-shared.module'; +import { ConfirmationDialogComponent } from 'app/shared/confirmation-dialog/confirmation-dialog.component'; +import { DdsDateAdapter, DDS_DATE_FORMATS } from './dds-date-adapter'; + +/** Custom Services */ +import { OrganizationService } from 'app/organization/organization.service'; +import { AlertService } from 'app/core/alert/alert.service'; +import { SettingsService } from 'app/settings/settings.service'; +import { Dates } from 'app/core/utils/dates'; +import { announceHeldForApproval, heldForApproval } from 'app/deposits/dds-approvals/dds-approval-notice'; + +/** + * DDS Interest & Commission batch processing — the legacy Interest Part menu. + * + * Eight menu entries, which are four Calculate/Post pairs. A *calculation* job works out what has + * been earned since the last one and parks it: interest against the customer's account, commission + * against the agent. Neither is spendable yet. The matching *posting* job then moves that parked + * figure where it belongs — into the customer's balance, or out to the agent. + * + * Every job is itself a two-step Generate then Post. Generate saves what it worked out and changes + * nothing else, so a figure that looks wrong is discarded by simply not posting it. + */ +@Component({ + selector: 'mifosx-dds-batch-processing', + templateUrl: './dds-batch-processing.component.html', + styleUrls: ['./dds-batch-processing.component.scss'], + imports: [ + ...STANDALONE_SHARED_IMPORTS, + // The reason a run failed is too long to sit in the column, and too useful to leave out. + MatTooltip + ], + // Day-first dates, as the legacy screen showed them. Declared here rather than application-wide: + // a @Component's providers reach only this component, so no other screen's datepicker changes. + providers: [ + { provide: DateAdapter, useClass: DdsDateAdapter }, + { provide: MAT_DATE_FORMATS, useValue: DDS_DATE_FORMATS } + ] +}) +export class DdsBatchProcessingComponent implements OnInit { + private formBuilder = inject(UntypedFormBuilder); + private router = inject(Router); + private organizationService = inject(OrganizationService); + private alertService = inject(AlertService); + private dialog = inject(MatDialog); + private settingsService = inject(SettingsService); + private dateUtils = inject(Dates); + + /** + * The menu as the legacy system lists it, paired with what the API calls each job. Ordered as + * calculate-then-post within each pair, matching the numbering on the legacy screen. + */ + jobTypes: Array<{ label: string; code: string }> = [ + { label: 'Interest Calculation', code: 'INTEREST_CALCULATION' }, + { label: 'Interest Posting', code: 'INTEREST_POSTING' }, + { label: 'OD Interest Calculation', code: 'OD_INTEREST_CALCULATION' }, + { label: 'OD Interest Posting', code: 'OD_INTEREST_POSTING' }, + { label: 'Commission Calculation', code: 'COMMISSION_CALCULATION' }, + { label: 'Commission Posting', code: 'COMMISSION_POSTING' }, + // Last, and on its own: the others move money onto accounts that stay open, while this one pays + // an account out and shuts it. Listing it beside a calculate/post pair would suggest it is the + // second half of one. + { label: 'Maturity Auto Close', code: 'MATURITY_AUTO_CLOSE' } + ]; + + branchScopes = [ + 'All', + 'Selected' + ]; + + private static readonly MONEY = new Intl.NumberFormat('en-IN', { + minimumFractionDigits: 2, + maximumFractionDigits: 2 + }); + + offices: any[] = []; + selectedOfficeNames = new Set(); + + /** The run currently previewed on screen, or null once it has been posted or discarded. */ + generatedRun: any = null; + previewRows: any[] = []; + + recentRuns: any[] = []; + + batchForm: UntypedFormGroup = this.formBuilder.group({ + jobType: [ + 'INTEREST_CALCULATION', + Validators.required + ], + branchScope: [ + 'All', + Validators.required + ], + jobDate: [ + new Date(), + Validators.required + ], + agentFrom: [ + 1, + Validators.required + ], + agentTo: [ + 99999, + Validators.required + ], + partyFrom: [ + 1, + Validators.required + ], + partyTo: [ + 99999999, + Validators.required + ] + }); + + get jobType(): string { + return this.batchForm.get('jobType').value; + } + + get jobTypeLabel(): string { + return this.jobTypes.find((type) => type.code === this.jobType)?.label ?? this.jobType; + } + + /** Commission is per agent, so the party range does not narrow it. */ + get isCommissionJob(): boolean { + return this.jobType.startsWith('COMMISSION'); + } + + get isPostingJob(): boolean { + return this.jobType.endsWith('_POSTING'); + } + + get isBranchSelected(): boolean { + return this.batchForm.get('branchScope')?.value === 'Selected'; + } + + /** + * Only a commission calculation has a breakdown worth showing: it is the one job where the figure + * applied is not the figure worked out, because TDS and the standing deductions come off it. + */ + get showsCommissionBreakdown(): boolean { + return this.jobType === 'COMMISSION_CALCULATION'; + } + + /** What Generate found, in words — the same figure the Post button is about to apply. */ + get previewTotal(): number { + return this.previewRows.reduce((total, row) => total + Number(row.amount || 0), 0); + } + + ngOnInit(): void { + this.organizationService.getOffices().subscribe((offices: any[]) => { + this.offices = offices || []; + }); + this.loadRecentRuns(); + } + + money(value: any): string { + if (value === null || value === undefined || value === '') { + return '—'; + } + return DdsBatchProcessingComponent.MONEY.format(Number(value)); + } + + labelOf(code: string): string { + return this.jobTypes.find((type) => type.code === code)?.label ?? code; + } + + toggleOffice(officeName: string): void { + if (this.selectedOfficeNames.has(officeName)) { + this.selectedOfficeNames.delete(officeName); + } else { + this.selectedOfficeNames.add(officeName); + } + } + + /** + * What every past run did and whether it was applied. Without this a generated-but-unposted run is + * invisible the moment the page is left, and there is no way to tell whether a month's interest + * was ever actually posted. + */ + private loadRecentRuns(): void { + this.organizationService.getDdsBatchRuns().subscribe((runs: any[]) => { + this.recentRuns = runs || []; + }); + } + + generate(): void { + const raw = this.batchForm.getRawValue(); + const dateFormat = this.settingsService.dateFormat; + + const payload: any = { + jobType: raw.jobType, + branchScope: raw.branchScope, + jobDate: this.dateUtils.formatDate(raw.jobDate, dateFormat), + agentFrom: raw.agentFrom, + agentTo: raw.agentTo, + dateFormat, + locale: this.settingsService.language.code + }; + + // The party range narrows an interest job to particular accounts; commission is worked out per + // agent on their whole collection, so sending a range there would only imply a filter that has + // no effect. + if (!this.isCommissionJob) { + payload.partyFrom = raw.partyFrom; + payload.partyTo = raw.partyTo; + } + + if (raw.branchScope === 'Selected') { + payload.officeNames = Array.from(this.selectedOfficeNames); + } + + this.organizationService.generateDdsBatchRun(payload).subscribe((result: any) => { + this.organizationService.getDdsBatchRun(result.resourceId).subscribe((batchRun: any) => { + this.generatedRun = batchRun; + this.previewRows = batchRun.rows || []; + + // A run that found nothing is the normal outcome of a period already processed, not a + // failure — but it has to say so, or an empty table reads as the screen being broken. + if (this.previewRows.length === 0) { + this.alertService.alert({ + type: 'Information', + message: `${this.jobTypeLabel} found nothing to apply. Either this period has already been processed, or there was no activity in it.` + }); + } else { + this.alertService.alert({ + type: 'Success', + message: `${this.jobTypeLabel}: ${this.previewRows.length} ${this.previewRows.length === 1 ? 'entry' : 'entries'} totalling ${this.money(this.previewTotal)} calculated. Nothing has been applied yet — review and press Post.` + }); + } + + this.loadRecentRuns(); + }); + }); + } + + post(): void { + if (!this.generatedRun || this.previewRows.length === 0) { + return; + } + + const count = this.previewRows.length; + const total = this.money(this.previewTotal); + const isPosting = this.isPostingJob; + + // Post is the step that moves money, and it cannot be undone from this screen — so it names + // exactly what is about to change rather than asking a generic "are you sure". + this.dialog + .open(ConfirmationDialogComponent, { + data: { + type: 'warn', + heading: `Post ${this.jobTypeLabel}?`, + dialogContext: + `${count} ${count === 1 ? 'entry' : 'entries'} totalling ${total} will be applied. ` + + (isPosting + ? 'This credits the customers or pays the agents, and appears on the ledger. ' + : 'This records what has been earned against each account. ') + + 'It cannot be undone from this screen.' + } + }) + .afterClosed() + .subscribe((response: any) => { + if (response?.confirm) { + this.applyRun(count, total); + } + }); + } + + /** + * Applies the run, and says what actually happened rather than assuming it finished. + * + * A crediting run costs what a savings deposit costs on every account it touches, so the server + * takes it off the request and does it in the background - the reply comes back in a moment while + * the work is still going on. Reporting that as "posted" would tell the clerk a run had finished + * seconds after it began, and they would go looking for credits that were still being written. + * + * So the run is followed until it settles. A calculation run is done by the time the reply lands + * and says so at once; a crediting run says it is under way, and the list beneath refreshes itself + * until the server marks it posted - or failed, with the reason the server recorded. + */ + private applyRun(count: number, total: string): void { + const label = this.jobTypeLabel; + const runId = this.generatedRun.id; + const crediting = this.isPostingJob; + + this.organizationService.postDdsBatchRun(runId).subscribe((response: any) => { + this.generatedRun = null; + this.previewRows = []; + this.loadRecentRuns(); + + if (heldForApproval(response)) { + announceHeldForApproval( + response, + `${label} (${count} ${count === 1 ? 'entry' : 'entries'}, ${total})`, + this.organizationService, + this.alertService + ); + return; + } + + if (!crediting) { + this.alertService.alert({ + type: 'Success', + message: `${label} posted — ${count} ${count === 1 ? 'entry' : 'entries'}, ${total}.` + }); + return; + } + + this.postingRunId = runId; + this.alertService.alert({ + type: 'Posting', + message: + `${label} is running — ${count} ${count === 1 ? 'entry' : 'entries'}, ${total}. ` + + 'It continues in the background; this list will update when it is done.' + }); + this.followRun(runId, label); + }); + } + + /** The crediting run being followed, if one is. The screen reads this to show it is under way. */ + postingRunId: number | null = null; + + /** + * Watches a background run until the server marks it settled. + * + * Polled rather than pushed: a batch that takes minutes needs no faster news than this, and a poll + * costs nothing when the answer is one row. It gives up after a while rather than asking forever - + * the run is still on the list either way, and a clerk can read its status there. + */ + private followRun(runId: number, label: string): void { + let checksLeft = DdsBatchProcessingComponent.MAX_STATUS_CHECKS; + + const check = () => { + this.organizationService.getDdsBatchRuns().subscribe({ + next: (runs: any[]) => { + const run = (runs || []).find((each: any) => each.id === runId); + const status = run?.status; + + if (status === 'POSTED' || status === 'FAILED') { + this.postingRunId = null; + this.loadRecentRuns(); + this.alertService.alert( + status === 'POSTED' + ? { type: 'Success', message: `${label} posted — ${run.rowsPosted ?? run.totalRows} entries applied.` } + : { type: 'Error', message: `${label} failed: ${run.failureReason || 'see the run for details'}` } + ); + return; + } + + if (--checksLeft > 0) { + setTimeout(check, DdsBatchProcessingComponent.STATUS_CHECK_INTERVAL_MS); + } else { + // Still going after the wait. Left on the list rather than reported either way - saying + // "failed" for a run that is merely long would be worse than saying nothing. + this.postingRunId = null; + this.loadRecentRuns(); + } + }, + // A failed status check says nothing about the run itself, which carries on regardless. + error: () => { + if (--checksLeft > 0) { + setTimeout(check, DdsBatchProcessingComponent.STATUS_CHECK_INTERVAL_MS); + } else { + this.postingRunId = null; + } + } + }); + }; + + setTimeout(check, DdsBatchProcessingComponent.STATUS_CHECK_INTERVAL_MS); + } + + private static readonly STATUS_CHECK_INTERVAL_MS = 4000; + /** Twenty minutes of checking, which is longer than any run measured here has taken. */ + private static readonly MAX_STATUS_CHECKS = 300; + + /** Drops the preview without applying it — the safe way out of a calculation that looks wrong. */ + discard(): void { + this.generatedRun = null; + this.previewRows = []; + } + + exit(): void { + this.router.navigate(['/organization']); + } +} diff --git a/src/app/organization/dds-batch-processing/dds-date-adapter.ts b/src/app/organization/dds-batch-processing/dds-date-adapter.ts new file mode 100644 index 000000000..bb6947f53 --- /dev/null +++ b/src/app/organization/dds-batch-processing/dds-date-adapter.ts @@ -0,0 +1,70 @@ +/** + * Copyright since 2025 Mifos Initiative + * + * This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. + */ + +import { Injectable } from '@angular/core'; +import { NativeDateAdapter, MatDateFormats } from '@angular/material/core'; + +/** + * Day-first dates for the DDS batch screen. + * + * Angular Material's stock adapter shows and reads dates the American way — 12/31/2026 — and its + * parser hands typed input to `Date.parse`, which cannot read a day-first date at all: typing + * 31/12/2026 yields no date, so the field silently empties and reports itself invalid. + * + * On a screen where the date decides which period's interest is calculated, both halves matter: a + * clerk who reads 12/31 as the 12th of some month picks the wrong period, and one who types the date + * they are used to gets an error with nothing to explain it. + * + * Scoped to this component rather than provided application-wide. Every other datepicker in the app + * has the same behaviour, and changing it globally would alter screens outside DDS. + */ +@Injectable() +export class DdsDateAdapter extends NativeDateAdapter { + /** Matches d/m/yyyy, with or without leading zeros, on / . or - separators. */ + private static readonly DAY_FIRST = /^(\d{1,2})[/.-](\d{1,2})[/.-](\d{4})$/; + + override parse(value: any): Date | null { + if (typeof value === 'string') { + const match = DdsDateAdapter.DAY_FIRST.exec(value.trim()); + if (match) { + const day = Number(match[1]); + const month = Number(match[2]); + const year = Number(match[3]); + + const parsed = new Date(year, month - 1, day); + // JavaScript rolls an out-of-range day into the next month rather than rejecting it, so + // 31/02/2026 would quietly become the 3rd of March. Reading the parts back catches that. + const isRealDate = parsed.getFullYear() === year && parsed.getMonth() === month - 1 && parsed.getDate() === day; + return isRealDate ? parsed : null; + } + } + return super.parse(value); + } + + override format(date: Date, displayFormat: any): string { + if (displayFormat === 'input') { + return `${this.pad(date.getDate())}/${this.pad(date.getMonth() + 1)}/${date.getFullYear()}`; + } + return super.format(date, displayFormat); + } + + private pad(value: number): string { + return value < 10 ? `0${value}` : `${value}`; + } +} + +/** Tells the datepicker to render the field itself day-first; the calendar keeps its usual labels. */ +export const DDS_DATE_FORMATS: MatDateFormats = { + parse: { dateInput: 'input' }, + display: { + dateInput: 'input', + monthYearLabel: { year: 'numeric', month: 'short' }, + dateA11yLabel: { year: 'numeric', month: 'long', day: 'numeric' }, + monthYearA11yLabel: { year: 'numeric', month: 'long' } + } +}; diff --git a/src/app/organization/organization-routing.module.ts b/src/app/organization/organization-routing.module.ts index 50723ef0e..22b411aff 100644 --- a/src/app/organization/organization-routing.module.ts +++ b/src/app/organization/organization-routing.module.ts @@ -17,6 +17,10 @@ import { Route } from '../core/route/route.service'; import { OrganizationComponent } from './organization.component'; import { LoanProvisioningCriteriaComponent } from './loan-provisioning-criteria/loan-provisioning-criteria.component'; import { OfficesComponent } from './offices/offices.component'; +import { AgentMasterComponent } from './agent-master/agent-master.component'; +import { CreateAgentComponent } from './agent-master/create-agent/create-agent.component'; +import { CommissionSlabMasterComponent } from './commission-slab-master/commission-slab-master.component'; +import { DdsBatchProcessingComponent } from './dds-batch-processing/dds-batch-processing.component'; import { EmployeesComponent } from './employees/employees.component'; import { CreateEmployeeComponent } from './employees/create-employee/create-employee.component'; import { ViewEmployeeComponent } from './employees/view-employee/view-employee.component'; @@ -237,6 +241,36 @@ const routes: Routes = [ } ] }, + { + path: 'agent-master', + data: { title: 'Agent Master', breadcrumb: 'Agent Master' }, + children: [ + { + path: '', + component: AgentMasterComponent + }, + { + path: 'create', + component: CreateAgentComponent, + data: { title: 'Create Agent', breadcrumb: 'Create' } + }, + { + path: ':agentId/edit', + component: CreateAgentComponent, + data: { title: 'Edit Agent', breadcrumb: 'Edit', routeParamBreadcrumb: false } + } + ] + }, + { + path: 'commission-slab-master', + component: CommissionSlabMasterComponent, + data: { title: 'Commission Slab Master', breadcrumb: 'Commission Slab Master' } + }, + { + path: 'dds-batch-processing', + component: DdsBatchProcessingComponent, + data: { title: 'DDS Interest & Commission Batch', breadcrumb: 'DDS Batch Processing' } + }, { // Bank's own profile — name, legal name, code, registration, address/contact. A // singleton, unlike every other screen under Organization, so it has no create/list step. diff --git a/src/app/organization/organization.component.html b/src/app/organization/organization.component.html index 6531fe8b2..c19767a54 100644 --- a/src/app/organization/organization.component.html +++ b/src/app/organization/organization.component.html @@ -190,6 +190,47 @@
+
+ +
+ +
+
+

Agent Master

+

+ Register Daily Deposit Scheme collection agents, their commission/TDS settings, limits and guarantors +

+
+ +
+
+ +
+ +
+ +
+
+

Commission Slab Master

+

Define collection-volume and override commission percentage slabs

+
+ +
+
+ +
+ +
+ +
+
+

DDS Interest & Commission Batch

+

Calculate then post interest, OD interest, and commission for DDS accounts

+
+ +
+
+
diff --git a/src/app/organization/organization.service.ts b/src/app/organization/organization.service.ts index 5090e143b..c89ee7739 100644 --- a/src/app/organization/organization.service.ts +++ b/src/app/organization/organization.service.ts @@ -233,6 +233,343 @@ export class OrganizationService { return this.http.get('/currencies'); } + /** + * @returns {Observable} DDS Agents data + */ + getAgents(): Observable { + return this.http.get('/dds/agents'); + } + + /** + * @param {string | number} agentId Agent ID. + * @returns {Observable} DDS Agent. + */ + getAgent(agentId: string | number): Observable { + return this.http.get(`/dds/agents/${agentId}`); + } + + /** + * @param {any} agent Agent to be created. + * @returns {Observable} + */ + createAgent(agent: any): Observable { + return this.http.post('/dds/agents', agent); + } + + /** + * @param {string | number} agentId Agent ID. + * @param {any} agent Agent changes to apply. + * @returns {Observable} + */ + updateAgent(agentId: string | number, agent: any): Observable { + return this.http.put(`/dds/agents/${agentId}`, agent); + } + + /** + * @returns {Observable} DDS Parties data + */ + getParties(): Observable { + return this.http.get('/dds/parties'); + } + + /** + * The parties a given agent collects for — what the daily transaction screen works from, since + * it handles one agent's round at a time. + * + * @param {string | number} agentId Agent ID. + * @returns {Observable} That agent's DDS Parties. + */ + getPartiesOfAgent(agentId: string | number): Observable { + return this.http.get('/dds/parties', { params: new HttpParams().set('agentId', String(agentId)) }); + } + + /** + * @param {string | number} partyId Party ID. + * @returns {Observable} DDS Party. + */ + getParty(partyId: string | number): Observable { + return this.http.get(`/dds/parties/${partyId}`); + } + + /** + * @param {any} party Party to be created. + * @returns {Observable} + */ + createParty(party: any): Observable { + return this.http.post('/dds/parties', party); + } + + /** + * @param {string | number} partyId Party ID. + * @param {any} party Party changes to apply. + * @returns {Observable} + */ + updateParty(partyId: string | number, party: any): Observable { + return this.http.put(`/dds/parties/${partyId}`, party); + } + + /** + * @param {string | number} partyId Party ID. + * @param {any} closeCommand { closeDate } + * @returns {Observable} + */ + closeParty(partyId: string | number, closeCommand: any): Observable { + return this.http.post(`/dds/parties/${partyId}?command=close`, closeCommand); + } + + /** + * The DDS accounts whose term has run out and which are still open - the branch's list of closures + * waiting to be done. Nothing closes itself: a matured account is money the bank owes the customer, + * and where it goes has to be agreed with them. + * + * @returns {Observable} Accounts due for closure, oldest maturity first. + */ + getPartiesDueForClosure(): Observable { + return this.http.get('/dds/parties/dueforclosure'); + } + + /** + * The customer's own accounts a DDS maturity can be paid into - active savings accounts of theirs, + * with fixed and recurring deposits and their other DDS scheme accounts left out. + * + * The server applies the same conditions the payout itself does, so this list can never offer a + * choice that is then refused on save. Filtering here instead meant the two drifted apart: the + * accounts on offer once included a scheme account of a party that had already closed. + * + * @param {string | number} clientId Client ID. + * @returns {Observable} id, accountNo, productName and balance for each. + */ + /** The liability GL accounts a DDS balance can be parked in, for the clerk to search and pick. */ + getDdsSundryAccounts(): Observable { + return this.http.get('/dds/parties/sundryaccounts'); + } + + /** DDS entries waiting on (or already past) a checker - PENDING, APPROVED or REJECTED. */ + getDdsApprovals(status: string, fromDate?: string, toDate?: string): Observable { + const params: any = { status }; + if (fromDate) { + params.fromDate = fromDate; + } + if (toDate) { + params.toDate = toDate; + } + return this.http.get('/dds/approvals', { params }); + } + + getDdsApproval(commandId: string | number): Observable { + return this.http.get(`/dds/approvals/${commandId}`); + } + + /** Approves or rejects a DDS entry; an approval returns the voucher it moved under. */ + actOnDdsApproval(commandId: string | number, command: 'approve' | 'reject'): Observable { + return this.http.post(`/dds/approvals/${commandId}`, {}, { params: { command } }); + } + + /** How a closed DDS account was closed: by whom, what it came to and where the money went. */ + getPartyClosure(partyId: string | number): Observable { + return this.http.get(`/dds/parties/${partyId}/closure`); + } + + getCreditableAccounts(clientId: string | number): Observable { + return this.http.get('/dds/parties/creditaccounts', { params: { clientId: String(clientId) } }); + } + + /** + * Pays a matured DDS account out and closes it. What is paid is what the account holds, not the + * figure quoted at opening. + * + * @param {string | number} partyId Party ID. + * @param {any} payoutCommand { toSavingsAccountNo } - another savings account of the same customer + * to move the money into. Omit it when they have none here: the money is then parked in the + * matured-deposits payable account for the branch to send on to their account at another bank. + * @returns {Observable} + */ + payOutParty(partyId: string | number, payoutCommand: any): Observable { + return this.http.post(`/dds/parties/${partyId}?command=payout`, payoutCommand); + } + + /** + * Closes a DDS account before its term is up, and pays out what is left - one act, unlike a + * maturity payout, which leaves the account open for the clerk to close separately. + * + * @param {string | number} partyId Party ID. + * @param {any} closeCommand { shortfallBorneBy, closeDate, dateFormat, locale, toSavingsAccountNo? }. + * closeDate, dateFormat and locale go up exactly as they do on an ordinary close. + * shortfallBorneBy is mandatory + * and is CUSTOMER (their interest is recalculated at the scheme's reduced rate and the + * difference taken back) or AGENT (the customer keeps a full month's interest and the agent + * funds the days left in it). The agent is charged a share of their commission either way. + * toSavingsAccountNo works as it does for a payout. The lock-in, the rate reduction and the + * agent's share come from the scheme and are applied by the server. + * @returns {Observable} + */ + prematureCloseParty(partyId: string | number, closeCommand: any): Observable { + return this.http.post(`/dds/parties/${partyId}?command=prematureclose`, closeCommand); + } + + /** + * Closes a DDS account early inside its scheme's lock-in, for the reason given. Its own permission, + * so it goes to a manager for approval; the scheme's lock-in is not changed. + */ + prematureCloseOverrideParty(partyId: string | number, closeCommand: any): Observable { + return this.http.post(`/dds/parties/${partyId}?command=prematurecloseoverride`, closeCommand); + } + + /** + * @param {any} transaction Daily transaction batch (PARTY_TRAN collections or a WITHDRAW_DEPOSITE transfer) to post. + * @returns {Observable} + */ + createPartyTransaction(transaction: any): Observable { + return this.http.post('/dds/partytransactions', transaction); + } + + /** + * What an agent wrote down on their round for a date, with what it adds up to. + * + * The counter reads it to count cash against; the agent's own screen shows it back as the record + * of their round. One call for both, so the two can never disagree about the same day. + */ + getAgentCollectionDay(agentId: any, entryDate: string): Observable { + return this.http.get(`/dds/collectionentries?agentId=${agentId}&entryDate=${entryDate}`); + } + + /** + * Turns an agent's outstanding entries for a date into real credits - all of them, together. + * + * No amount is sent. What settles is what the entries say, to the rupee: a figure typed at the + * counter would be a second opinion about a total the entries already give. + */ + settleAgentCollections(settlement: any): Observable { + return this.http.post('/dds/collectionentries/settle', settlement); + } + + /** Everything the agent still has outstanding, whatever day it was written - what a receipt is applied against. */ + getAgentPendingCollections(agentId: any): Observable { + return this.http.get(`/dds/collectionentries/pending?agentId=${agentId}`); + } + + /** + * The cashier's receipts into the agent's GL account: the DDS receipts ready to apply, or - with a + * voucher number - that one voucher in whatever state, so the screen can say why it cannot be used. + */ + getAgentCashReceipts(agentId: any, voucherNo?: string, includeWaiting = false): Observable { + const voucher = voucherNo ? `&voucherNo=${encodeURIComponent(voucherNo)}` : ''; + const waiting = includeWaiting ? '&includeWaiting=true' : ''; + return this.http.get(`/dds/collectionentries/cashreceipts?agentId=${agentId}${voucher}${waiting}`); + } + + /** A cashier receipt's own Dr/Cr lines. */ + getCashReceiptGlEntries(receiptId: any): Observable { + return this.http.get(`/dds/collectionentries/cashreceipts/${receiptId}/glentries`); + } + + /** + * An agent's DDS transactions. The daily entry screen uses this to show what has already been + * keyed for the agent today, so the same collection book is not entered twice. + * + * @param {string | number} agentId Agent ID. + * @returns {Observable} That agent's transactions. + */ + getPartyTransactionsOfAgent(agentId: string | number): Observable { + return this.http.get('/dds/partytransactions', { + params: new HttpParams().set('agentId', String(agentId)) + }); + } + + /** + * @param {string | number} partyId DDS account (party) id. + * @returns {Observable} Everything posted to that DDS account, most recent first. + */ + getPartyTransactionsOfParty(partyId: string | number): Observable { + return this.http.get('/dds/partytransactions', { + params: new HttpParams().set('partyId', String(partyId)) + }); + } + + /** + * @returns {Observable} DDS Commission Slabs data + */ + getCommissionSlabs(): Observable { + return this.http.get('/dds/commissionslabs'); + } + + /** + * @param {any[]} slabs New commission slab rows to add to the chart. + * @returns {Observable} + */ + createCommissionSlabs(slabs: any[]): Observable { + return this.http.post('/dds/commissionslabs', { slabs }); + } + + /** + * Corrects one saved commission slab. The backend re-checks the corrected range against the other + * slabs of its type and refuses an overlap. + * + * @param {string | number} slabId Commission slab ID. + * @param {any} slab The corrected slab. + * @returns {Observable} Command result. + */ + updateCommissionSlab(slabId: string | number, slab: any): Observable { + return this.http.put(`/dds/commissionslabs/${slabId}`, slab); + } + + /** + * @param {any} batch Batch run filters (jobType, branchScope, jobDate, agentFrom/To, partyFrom/To) to generate a preview for. + * @returns {Observable} + */ + generateDdsBatchRun(batch: any): Observable { + return this.http.post('/dds/batchruns', batch); + } + + /** + * @param {string | number} batchRunId Batch run ID. + * @returns {Observable} Batch run, including its previewed/posted rows. + */ + getDdsBatchRun(batchRunId: string | number): Observable { + return this.http.get(`/dds/batchruns/${batchRunId}`); + } + + /** + * @returns {Observable} Every batch run, most recent first — who ran what, and whether it was posted. + */ + getDdsBatchRuns(): Observable { + return this.http.get('/dds/batchruns'); + } + + /** + * Ends the relationship with an agent. Refused while they still have accounts assigned, hold + * collected cash, or are owed commission — distinct from deactivating, which is never refused. + * + * @param {string | number} agentId Agent ID. + * @returns {Observable} + */ + closeAgent(agentId: string | number): Observable { + return this.http.post(`/dds/agents/${agentId}`, {}); + } + + /** + * @param {string | number} batchRunId Batch run ID to post - applies exactly the rows it previewed. + * @returns {Observable} + */ + postDdsBatchRun(batchRunId: string | number): Observable { + return this.http.post(`/dds/batchruns/${batchRunId}`, {}); + } + + /** + * @param {string} reportKey One of the backend DDS report keys. + * @param {any} filters fromDate, toDate (ISO strings), agentFrom/To, partyFrom/To. + * @returns {Observable} { reportKey, available, message, columns, rows } + */ + runDdsReport(reportKey: string, filters: any): Observable { + let httpParams = new HttpParams(); + Object.keys(filters || {}).forEach((key) => { + if (filters[key] !== undefined && filters[key] !== null && filters[key] !== '') { + httpParams = httpParams.set(key, filters[key]); + } + }); + return this.http.get(`/dds/reports/${reportKey}`, { params: httpParams }); + } + /** * @param {any[]} currencies * @returns {Observable} Currencies data diff --git a/src/app/products/dds-account-types/create-dds-account-type/create-dds-account-type.component.html b/src/app/products/dds-account-types/create-dds-account-type/create-dds-account-type.component.html new file mode 100644 index 000000000..3e0eff78f --- /dev/null +++ b/src/app/products/dds-account-types/create-dds-account-type/create-dds-account-type.component.html @@ -0,0 +1,145 @@ + + +
+
+
+
+
+

DDS Product Creation

+

{{ isEdit ? 'Update this Daily Deposit product.' : 'Register a Daily Deposit product.' }}

+
+
+ +
+
+ + Product Group + + @for (group of accountTypeGroups; track group) { + {{ group }} + } + + + + + Product Code + + + + + Product Description + + + + + Interest Rate (%) + + Interest is worked out daily on the balance (rate ÷ 365) + @if (accountTypeForm.get('interestRate')?.hasError('required')) { + Interest rate is required + } + + + + Auto Interest Post + + Yes + No + + + + + Auto Interest Post When + + @for (option of autoInterestPostWhenOptions; track option) { + {{ option }} + } + + + + + @if (autoPostOn) { + + Auto Interest Post Day + + + } + + + + Maturity Auto Close + + Yes + No + + Closes the account on its maturity date, if it names a savings account to be paid into + +
+ + +

Premature Closure

+

+ Applied when an account on this scheme is closed before its term ends. The branch chooses at the time whether + the customer or the agent bears the cost; these set what that costs. +

+ +
+ + Minimum Lock-in (months) + + Before this, early closure is refused outright. 0 allows it from day one. + + + + Customer: interest rate reduced by (%) + + Taken off the rate for the whole period, not only the part left to run. + + + + Agent: commission reduced by (%) + + Charged whoever bears the interest — the round was not kept going. + +
+ +
+ + +
+
+
+
diff --git a/src/app/products/dds-account-types/create-dds-account-type/create-dds-account-type.component.scss b/src/app/products/dds-account-types/create-dds-account-type/create-dds-account-type.component.scss new file mode 100644 index 000000000..a6a629710 --- /dev/null +++ b/src/app/products/dds-account-types/create-dds-account-type/create-dds-account-type.component.scss @@ -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/. + */ + +.dat-root { + padding: 20px 24px 40px; +} + +.dat-card { + background: #fff; + border: 1px solid rgb(0 0 0 / 8%); + border-radius: 10px; + box-shadow: 0 1px 3px rgb(0 0 0 / 6%); + overflow: hidden; + max-width: 880px; + margin: 0 auto; +} + +.dat-head { + display: flex; + align-items: center; + gap: 14px; + padding: 20px 24px; + border-bottom: 1px solid rgb(0 0 0 / 7%); + + h2 { + margin: 0; + font-size: 1.2rem; + font-weight: 600; + } + + p { + margin: 2px 0 0; + font-size: 0.83rem; + color: rgb(0 0 0 / 55%); + } +} + +.dat-head__ic { + width: 46px; + height: 46px; + border-radius: 12px; + display: grid; + place-items: center; + font-size: 1.05rem; + color: #fff; + background: linear-gradient(135deg, #0f766e, #134e4a); + flex: none; +} + +.dat-grid { + display: grid; + grid-template-columns: repeat(2, minmax(220px, 1fr)); + gap: 8px 20px; + padding: 26px 24px 8px; +} + +.dat-field { + width: 100%; +} + +.dat-field--wide { + grid-column: 1 / -1; +} + +/* + * Divides the scheme's own settings from the terms it charges for closing early. A rule rather than a + * second card, because it is the same record being filled in - the break is to stop three money + * figures reading as more of the posting settings above them. + */ +.dat-section { + margin: 20px 0 0; + padding: 18px 24px 0; + border-top: 1px solid rgb(0 0 0 / 7%); + font-size: 0.82rem; + font-weight: 600; + letter-spacing: 0.04em; + text-transform: uppercase; + color: #0f766e; +} + +.dat-section-note { + margin: 6px 0 0; + padding: 0 24px; + max-width: 68ch; /* stylelint-disable-line unit-allowed-list */ + font-size: 0.8rem; + line-height: 1.5; + color: rgb(0 0 0 / 60%); +} + +/* The note already carries the spacing, so the grid under it does not add its own again. */ +.dat-section-note + .dat-grid { + padding-top: 14px; +} + +.dat-actions { + display: flex; + justify-content: flex-end; + gap: 12px; + padding: 16px 24px; + border-top: 1px solid rgb(0 0 0 / 7%); +} + +@media (width <= 720px) { + .dat-grid { + grid-template-columns: 1fr; + } +} diff --git a/src/app/products/dds-account-types/create-dds-account-type/create-dds-account-type.component.ts b/src/app/products/dds-account-types/create-dds-account-type/create-dds-account-type.component.ts new file mode 100644 index 000000000..0cdbcf188 --- /dev/null +++ b/src/app/products/dds-account-types/create-dds-account-type/create-dds-account-type.component.ts @@ -0,0 +1,234 @@ +/** + * Copyright since 2025 Mifos Initiative + * + * This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. + */ + +/** Angular Imports */ +import { Component, OnInit, inject } from '@angular/core'; +import { UntypedFormBuilder, UntypedFormGroup, Validators } from '@angular/forms'; +import { ActivatedRoute, Router } from '@angular/router'; +import { FaIconComponent } from '@fortawesome/angular-fontawesome'; +import { STANDALONE_SHARED_IMPORTS } from 'app/standalone-shared.module'; + +/** Custom Services */ +import { ProductsService } from 'app/products/products.service'; +import { AlertService } from 'app/core/alert/alert.service'; +import { OrganizationService } from 'app/organization/organization.service'; +import { SettingsService } from 'app/settings/settings.service'; +import { announceHeldForApproval, heldForApproval } from 'app/deposits/dds-approvals/dds-approval-notice'; + +/** + * Account Type Creation — the legacy DDS screen this replaces, field for field: group, code, + * description, and whether interest posts automatically and how often. Nothing else: DDS never used + * the deposit-product engine, so there are no terms, charges or accounting tabs behind this. + * + * The same form handles create and edit; an `accountTypeId` in the route switches it to edit. + */ +@Component({ + selector: 'mifosx-create-dds-account-type', + templateUrl: './create-dds-account-type.component.html', + styleUrls: ['./create-dds-account-type.component.scss'], + imports: [ + ...STANDALONE_SHARED_IMPORTS, + FaIconComponent + ] +}) +export class CreateDdsAccountTypeComponent implements OnInit { + private formBuilder = inject(UntypedFormBuilder); + private router = inject(Router); + private route = inject(ActivatedRoute); + private productsService = inject(ProductsService); + private alertService = inject(AlertService); + private organizationService = inject(OrganizationService); + private settingsService = inject(SettingsService); + + accountTypeGroups = ['DDS']; + + autoInterestPostWhenOptions = [ + 'Monthly', + 'Quarterly', + 'Half Yearly', + 'Yearly' + ]; + + /** Set when the route carries an id — the form is then editing rather than creating. */ + accountTypeId: number | null = null; + + accountTypeForm: UntypedFormGroup = this.formBuilder.group({ + accountTypeGroup: [ + 'DDS', + Validators.required + ], + accountTypeCode: [ + '', + Validators.required + ], + accountTypeDescription: [ + '', + Validators.required + ], + autoInterestPost: [ + true, + Validators.required + ], + autoInterestPostWhen: [ + 'Monthly', + Validators.required + ], + autoInterestPostDay: [''], + /* + * Defaults to No, so a scheme created without a thought about it keeps behaving the way every + * scheme behaves today - the clerk pays out matured accounts by hand. Turning it on is a + * decision somebody makes, not one they inherit. + */ + maturityAutoClose: [ + false, + Validators.required + ], + // Mandatory: every account opened on this scheme takes this rate, rather than one typed per customer. + interestRate: [ + '', + [ + Validators.required, + Validators.min(0), + Validators.max(100) + ] + ], + /* + * What this scheme charges when an account is closed before its term is up. Set here rather than + * asked of the clerk at closing time: the terms are the bank's, and the same for every account on + * the scheme. Defaults are the bank's stated terms - 12 months, 1% and 1%. + */ + prematureLockinMonths: [ + 12, + [ + Validators.required, + Validators.min(0), + Validators.max(600) + ] + ], + prematureInterestReduction: [ + 1, + [ + Validators.required, + Validators.min(0), + Validators.max(100) + ] + ], + prematureAgentCommissionPenalty: [ + 1, + [ + Validators.required, + Validators.min(0), + Validators.max(100) + ] + ] + }); + + get isEdit(): boolean { + return this.accountTypeId !== null; + } + + /** + * "Auto Interest Post Day" only means anything while auto-posting is on, and the legacy screen + * hid it the same way. "When" stays visible either way, as it did there. + */ + get autoPostOn(): boolean { + return !!this.accountTypeForm.get('autoInterestPost')?.value; + } + + ngOnInit(): void { + // The posting day is required exactly while auto-posting is on, and is cleared when it is off. + const dayControl = this.accountTypeForm.get('autoInterestPostDay'); + const applyDayRule = (autoPostOn: boolean) => { + if (autoPostOn) { + dayControl?.setValidators([Validators.required]); + } else { + dayControl?.clearValidators(); + dayControl?.setValue(''); + } + dayControl?.updateValueAndValidity(); + }; + applyDayRule(this.autoPostOn); + this.accountTypeForm.get('autoInterestPost')?.valueChanges.subscribe((value) => applyDayRule(!!value)); + + const routeId = this.route.snapshot.paramMap.get('accountTypeId'); + if (!routeId) { + return; + } + this.accountTypeId = Number(routeId); + this.productsService.getDdsAccountType(this.accountTypeId).subscribe((accountType: any) => { + this.accountTypeForm.patchValue({ + accountTypeGroup: accountType.accountTypeGroup, + accountTypeCode: accountType.accountTypeCode, + accountTypeDescription: accountType.accountTypeDescription, + autoInterestPost: !!accountType.autoInterestPost, + autoInterestPostWhen: accountType.autoInterestPostWhen || 'Monthly', + autoInterestPostDay: accountType.autoInterestPostDay ?? '', + maturityAutoClose: !!accountType.maturityAutoClose, + interestRate: accountType.interestRate ?? '', + // ?? rather than ||, because zero is a real answer here - a scheme with no lock-in, or one + // that charges nothing - and must not be replaced by the default. + prematureLockinMonths: accountType.prematureLockinMonths ?? 12, + prematureInterestReduction: accountType.prematureInterestReduction ?? 1, + prematureAgentCommissionPenalty: accountType.prematureAgentCommissionPenalty ?? 1 + }); + }); + } + + cancel(): void { + this.backToList(); + } + + submit(): void { + if (this.accountTypeForm.invalid) { + return; + } + const raw = this.accountTypeForm.getRawValue(); + const payload = { + ...raw, + // The posting day only applies while auto-posting is on; send nothing for it otherwise. + autoInterestPostDay: + raw.autoInterestPost && raw.autoInterestPostDay ? Number(raw.autoInterestPostDay) : undefined, + // Sent as numbers, not the strings a text input yields - the backend reads them as decimals. + interestRate: Number(raw.interestRate), + prematureLockinMonths: Number(raw.prematureLockinMonths), + prematureInterestReduction: Number(raw.prematureInterestReduction), + prematureAgentCommissionPenalty: Number(raw.prematureAgentCommissionPenalty), + // Numeric parameters have to carry the locale they were entered in. + locale: this.settingsService.language.code + }; + + const done = (response: any) => { + if (heldForApproval(response)) { + announceHeldForApproval( + response, + `DDS product ${payload.accountTypeCode} ${this.isEdit ? 'change' : ''}`.trim(), + this.organizationService, + this.alertService + ); + } else { + this.alertService.alert({ + type: 'Success', + message: this.isEdit + ? `Account type ${payload.accountTypeCode} updated` + : `Account type ${payload.accountTypeCode} created` + }); + } + this.backToList(); + }; + + if (this.accountTypeId !== null) { + this.productsService.updateDdsAccountType(this.accountTypeId, payload).subscribe(done); + } else { + this.productsService.createDdsAccountType(payload).subscribe(done); + } + } + + private backToList(): void { + this.router.navigate(['/products/dds-account-types']); + } +} diff --git a/src/app/products/dds-account-types/dds-account-types.component.html b/src/app/products/dds-account-types/dds-account-types.component.html new file mode 100644 index 000000000..cea8c4202 --- /dev/null +++ b/src/app/products/dds-account-types/dds-account-types.component.html @@ -0,0 +1,141 @@ + + +
+
+
+
+
+

DDS Product

+

The Daily Deposit products a customer account can be opened under.

+
+
+
+
+ + {{ totalCount }} + {{ totalCount === 1 ? 'type' : 'types' }} +
+ +
+
+ +
+
+
+
+
{{ totalCount | number }}
+
Total DDS Products
+
+
+
+
+
+
{{ filteredCount | number }}
+
Showing after filter
+
+
+
+ +
+
+ +
+ + @if (totalCount === 0) { +
+
+

No account types yet

+

Create one so Daily Deposit accounts can be opened against it.

+ +
+ } @else { +
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + +
Product +
+
+
+
{{ t.accountTypeCode }}
+
{{ t.accountTypeGroup }}
+
+
+
Description{{ t.accountTypeDescription }}Auto Interest Post + @if (t.autoInterestPost) { + {{ t.autoInterestPostWhen || 'Yes' }} + @if (t.autoInterestPostDay) { + day {{ t.autoInterestPostDay }} + } + } @else { + No + } + Created + {{ t.createdDate ? (t.createdDate | dateFormat: 'DD MMM YYYY, hh:mm A') : '—' }} + Actions + +
+ + @if (filteredCount === 0 && totalCount > 0) { +
+ + No account types match your filter. +
+ } + + +
+ } +
+
diff --git a/src/app/products/dds-account-types/dds-account-types.component.scss b/src/app/products/dds-account-types/dds-account-types.component.scss new file mode 100644 index 000000000..9d6517e68 --- /dev/null +++ b/src/app/products/dds-account-types/dds-account-types.component.scss @@ -0,0 +1,39 @@ +/** + * Copyright since 2025 Mifos Initiative + * + * This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. + */ + +@use 'assets/styles/product-list' as pl; + +@include pl.product-list-styles; + +/* DDS-specific teal accent */ +:host { + --pl-accent: #0f766e; + --pl-accent-l: #ccfbf1; +} + +/* A retired account type stays listed but reads as switched off. */ +.dat-row--inactive td:not(.num) { + opacity: 0.55; +} + +.dat-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; +} + +.dat-pill--off { + background: rgb(0 0 0 / 7%); + color: rgb(0 0 0 / 52%); +} diff --git a/src/app/products/dds-account-types/dds-account-types.component.ts b/src/app/products/dds-account-types/dds-account-types.component.ts new file mode 100644 index 000000000..9b3e13e57 --- /dev/null +++ b/src/app/products/dds-account-types/dds-account-types.component.ts @@ -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/. + */ + +/** Angular Imports */ +import { Component, OnInit, ViewChild, inject } from '@angular/core'; +import { MatPaginator } from '@angular/material/paginator'; +import { MatSort, MatSortHeader } from '@angular/material/sort'; +import { + MatTable, + MatTableDataSource, + MatColumnDef, + MatHeaderCellDef, + MatHeaderCell, + MatCellDef, + MatCell, + MatHeaderRowDef, + MatHeaderRow, + MatRowDef, + MatRow +} from '@angular/material/table'; +import { MatTooltip } from '@angular/material/tooltip'; +import { FaIconComponent } from '@fortawesome/angular-fontawesome'; +import { STANDALONE_SHARED_IMPORTS } from 'app/standalone-shared.module'; + +/** Custom Services */ +import { ProductsService } from 'app/products/products.service'; + +/** + * DDS Account Types list. + * + * The scheme variants a Party account can be opened under - the legacy "Account Type Creation" + * record. Master data only, deliberately not a deposit product: DDS never used the deposit-product + * engine, and interest is accrued off each Party's own rate by the DDS batch job. + * + * Follows the same list -> create/edit shape as every other product list in this section, and + * shares their stylesheet. + */ +@Component({ + selector: 'mifosx-dds-account-types', + templateUrl: './dds-account-types.component.html', + styleUrls: ['./dds-account-types.component.scss'], + imports: [ + ...STANDALONE_SHARED_IMPORTS, + FaIconComponent, + MatTable, + MatSort, + MatColumnDef, + MatHeaderCellDef, + MatHeaderCell, + MatSortHeader, + MatCellDef, + MatCell, + MatHeaderRowDef, + MatHeaderRow, + MatRowDef, + MatRow, + MatPaginator, + MatTooltip + ] +}) +export class DdsAccountTypesComponent implements OnInit { + private productsService = inject(ProductsService); + + displayedColumns: string[] = [ + 'accountTypeCode', + 'accountTypeDescription', + 'autoInterestPost', + 'createdDate', + 'actions' + ]; + + dataSource: MatTableDataSource = new MatTableDataSource([]); + accountTypes: any[] = []; + filteredCount = 0; + + @ViewChild(MatPaginator, { static: true }) paginator: MatPaginator; + @ViewChild(MatSort, { static: true }) sort: MatSort; + + get totalCount(): number { + return this.accountTypes.length; + } + + ngOnInit(): void { + this.loadAccountTypes(); + } + + private loadAccountTypes(): void { + this.productsService.getDdsAccountTypes().subscribe((accountTypes: any[]) => { + this.accountTypes = accountTypes || []; + this.dataSource = new MatTableDataSource(this.accountTypes); + this.dataSource.paginator = this.paginator; + this.dataSource.sort = this.sort; + this.filteredCount = this.accountTypes.length; + this.dataSource.connect().subscribe((rows) => (this.filteredCount = rows.length)); + }); + } + + applyFilter(filterValue: string): void { + this.dataSource.filter = filterValue.trim().toLowerCase(); + } + + /** + * Retires or restores an account type. Never a delete: Parties are opened against an account + * type, so removing it would orphan every account already using it. Deactivating takes it out of + * the pick lists for new accounts and leaves existing ones untouched. + */ +} diff --git a/src/app/products/products-routing.module.ts b/src/app/products/products-routing.module.ts index cacec80f1..52d438830 100644 --- a/src/app/products/products-routing.module.ts +++ b/src/app/products/products-routing.module.ts @@ -30,6 +30,8 @@ import { EditShareProductComponent } from './share-products/edit-share-product/e import { ManageTaxConfigurationsComponent } from './manage-tax-configurations/manage-tax-configurations.component'; import { RecurringDepositProductsComponent } from './recurring-deposit-products/recurring-deposit-products.component'; import { ViewRecurringDepositProductComponent } from './recurring-deposit-products/view-recurring-deposit-product/view-recurring-deposit-product.component'; +import { DdsAccountTypesComponent } from './dds-account-types/dds-account-types.component'; +import { CreateDdsAccountTypeComponent } from './dds-account-types/create-dds-account-type/create-dds-account-type.component'; import { ChargesComponent } from './charges/charges.component'; import { ViewChargeComponent } from './charges/view-charge/view-charge.component'; import { CreateChargeComponent } from './charges/create-charge/create-charge.component'; @@ -733,6 +735,26 @@ const routes: Routes = [ } ] }, + { + path: 'dds-account-types', + data: { title: 'DDS Product', breadcrumb: 'DDS Product' }, + children: [ + { + path: '', + component: DdsAccountTypesComponent + }, + { + path: 'create', + component: CreateDdsAccountTypeComponent, + data: { title: 'Create DDS Product', breadcrumb: 'Create' } + }, + { + path: ':accountTypeId/edit', + component: CreateDdsAccountTypeComponent, + data: { title: 'Edit DDS Product', breadcrumb: 'Edit', routeParamBreadcrumb: false } + } + ] + }, { path: 'fixed-deposit-products', data: { title: 'Fixed Deposit Products', breadcrumb: 'Fixed Deposit Products' }, diff --git a/src/app/products/products.component.html b/src/app/products/products.component.html index f07c81fe7..77186ce4c 100644 --- a/src/app/products/products.component.html +++ b/src/app/products/products.component.html @@ -125,6 +125,21 @@
+
+ +
+ +
+
+

DDS Product

+

+ Create a Daily Deposit Scheme account type — group, code and auto-interest posting +

+
+ +
+
+
diff --git a/src/app/products/products.service.ts b/src/app/products/products.service.ts index ea06b334b..9625cce5d 100644 --- a/src/app/products/products.service.ts +++ b/src/app/products/products.service.ts @@ -348,6 +348,42 @@ export class ProductsService { return this.http.get('/recurringdepositproducts/template'); } + /** + * DDS Account Types — the legacy "Account Type Creation" master (group / code / description / + * auto-interest-post). Master data only, deliberately not a deposit product: DDS accrues + * interest off each Party's own rate, so there is no product engine behind this. + * + * @returns {Observable} DDS account types + */ + getDdsAccountTypes(): Observable { + return this.http.get('/dds/accounttypes'); + } + + /** + * @param {string} accountTypeId DDS account type id + * @returns {Observable} A single DDS account type + */ + getDdsAccountType(accountTypeId: string | number): Observable { + return this.http.get(`/dds/accounttypes/${accountTypeId}`); + } + + /** + * @param {any} accountType DDS account type to create + * @returns {Observable} + */ + createDdsAccountType(accountType: any): Observable { + return this.http.post('/dds/accounttypes', accountType); + } + + /** + * @param {string} accountTypeId DDS account type id + * @param {any} accountType Changes to apply + * @returns {Observable} + */ + updateDdsAccountType(accountTypeId: string | number, accountType: any): Observable { + return this.http.put(`/dds/accounttypes/${accountTypeId}`, accountType); + } + /** * @returns {Observable} Charges data. */ diff --git a/src/app/system/configurations/global-configurations-tab/global-configurations-tab.component.html b/src/app/system/configurations/global-configurations-tab/global-configurations-tab.component.html index c4c147847..4128299e6 100644 --- a/src/app/system/configurations/global-configurations-tab/global-configurations-tab.component.html +++ b/src/app/system/configurations/global-configurations-tab/global-configurations-tab.component.html @@ -53,7 +53,18 @@ {{ 'labels.inputs.Value' | translate }} - {{ configuration.value }} + + {{ configuration.value }} + + @if (glAccountLabel(configuration); as glAccount) { + {{ glAccount }} + } + diff --git a/src/app/system/configurations/global-configurations-tab/global-configurations-tab.component.scss b/src/app/system/configurations/global-configurations-tab/global-configurations-tab.component.scss index 4561ef8a6..9141035e4 100644 --- a/src/app/system/configurations/global-configurations-tab/global-configurations-tab.component.scss +++ b/src/app/system/configurations/global-configurations-tab/global-configurations-tab.component.scss @@ -9,3 +9,14 @@ table { width: 100%; } + +/* + * The ledger account a setting points at. Quieter than the value itself, because the id is what the + * setting actually holds and what gets typed when it is edited - the name is here to say what that + * id means, not to replace it. + */ +.gc-gl-name { + margin-left: 8px; + color: rgb(0 0 0 / 60%); + font-size: 0.82rem; +} diff --git a/src/app/system/configurations/global-configurations-tab/global-configurations-tab.component.ts b/src/app/system/configurations/global-configurations-tab/global-configurations-tab.component.ts index 4af539e36..8a338308b 100644 --- a/src/app/system/configurations/global-configurations-tab/global-configurations-tab.component.ts +++ b/src/app/system/configurations/global-configurations-tab/global-configurations-tab.component.ts @@ -29,6 +29,7 @@ import { ActivatedRoute, Router, RouterLink } from '@angular/router'; /** Custom Services */ import { SystemService } from '../../system.service'; +import { AccountingService } from 'app/accounting/accounting.service'; import { PopoverService } from '../../../configuration-wizard/popover/popover.service'; import { ConfigurationWizardService } from '../../../configuration-wizard/configuration-wizard.service'; import { FaIconComponent } from '@fortawesome/angular-fontawesome'; @@ -71,9 +72,22 @@ export class GlobalConfigurationsTabComponent implements OnInit, AfterViewInit { private router = inject(Router); private configurationWizardService = inject(ConfigurationWizardService); private popoverService = inject(PopoverService); + private accountingService = inject(AccountingService); /** Configuration data. */ configurationData: any; + + /** + * The chart of accounts, so a setting that holds a ledger account's id can be shown by its name. + * + * Several settings store nothing but a number - "44" says which account the money moves through, + * and says it only to the database. An officer checking that the scheme is posting where it should + * would otherwise have to carry the number to the Chart of Accounts and look it up. + * + * Left empty if the list cannot be read - a user without permission on the chart of accounts still + * sees every setting and its value, just without the name beside it. + */ + private glAccountsById = new Map(); /** Columns to be displayed in configurations table. */ displayedColumns: string[] = [ 'name', @@ -119,6 +133,36 @@ export class GlobalConfigurationsTabComponent implements OnInit, AfterViewInit { */ ngOnInit() { this.setConfigurationData(); + this.loadGlAccounts(); + } + + private loadGlAccounts(): void { + this.accountingService.getGlAccounts().subscribe({ + next: (accounts: any) => { + for (const account of accounts || []) { + this.glAccountsById.set(Number(account.id), account); + } + }, + // The settings are the point of this screen; a chart of accounts that will not load must not + // take them down with it. The values still show, only the names beside them are missing. + error: () => this.glAccountsById.clear() + }); + } + + /** + * The ledger account a setting points at, as "name (code)" - the same way the product screens show + * one. Empty for every setting that does not hold a ledger account id, which is most of them. + * + * Only settings whose name marks them as an account mapping are resolved. Reading every numeric + * setting as an account id would put a name against figures that are nothing of the sort - a + * retry count of 3 would come back as whatever account 3 happens to be. + */ + glAccountLabel(configuration: any): string { + if (!configuration?.name?.endsWith('-account')) { + return ''; + } + const account = this.glAccountsById.get(Number(configuration.value)); + return account ? `${account.name} (${account.glCode})` : ''; } /** diff --git a/src/app/tasks/checker-inbox-and-tasks/checker-inbox-and-tasks.component.html b/src/app/tasks/checker-inbox-and-tasks/checker-inbox-and-tasks.component.html index 182b1f6c9..65f82890f 100644 --- a/src/app/tasks/checker-inbox-and-tasks/checker-inbox-and-tasks.component.html +++ b/src/app/tasks/checker-inbox-and-tasks/checker-inbox-and-tasks.component.html @@ -189,6 +189,10 @@ Disbursement & Transfers + + + DDS Approvals +