From 885b39d970277f93016c9e277cbf20f982f45588 Mon Sep 17 00:00:00 2001 From: tarunmalviya Date: Thu, 1 Oct 2026 13:08:52 +0530 Subject: [PATCH] DDS: approvals tab, vouchers, payout & close, Sundry GL search, closure tab, fixes --- src/app/accounts/accounts-routing.module.ts | 15 + src/app/core/shell/shell.component.html | 34 +- .../apply-dds-receipt-dialog.component.html | 381 ++++++++++++ .../apply-dds-receipt-dialog.component.scss | 95 +++ .../apply-dds-receipt-dialog.component.ts | 351 +++++++++++ .../daily-transaction-entry.component.html | 280 +++++++-- .../daily-transaction-entry.component.scss | 113 +++- .../daily-transaction-entry.component.ts | 355 +++++++++-- .../dds-account-view.component.html | 557 ++++++++++++++++++ .../dds-account-view.component.scss | 459 +++++++++++++++ .../dds-account-view.component.ts | 297 ++++++++++ .../dds-accounts/dds-accounts.component.html | 211 +++++++ .../dds-accounts/dds-accounts.component.scss | 56 ++ .../dds-accounts/dds-accounts.component.ts | 265 +++++++++ .../dds-approvals/dds-approval-notice.ts | 46 ++ .../dds-approval-review.component.html | 265 +++++++++ .../dds-approval-review.component.ts | 148 +++++ .../dds-approvals.component.html | 176 ++++++ .../dds-approvals/dds-approvals.component.ts | 215 +++++++ .../deposits/party-master/client-search.ts | 49 ++ .../create-party/create-party.component.html | 198 +++++-- .../create-party/create-party.component.ts | 278 +++++++-- .../due-for-closure.component.html | 7 +- .../due-for-closure.component.ts | 32 +- .../party-master/party-master.component.html | 24 +- .../party-master/party-master.component.ts | 25 +- .../payout-dialog.component.html | 113 +++- .../payout-dialog.component.scss | 20 + .../payout-dialog/payout-dialog.component.ts | 125 +++- .../premature-close-dialog.component.html | 75 ++- .../premature-close-dialog.component.scss | 20 + .../premature-close-dialog.component.ts | 96 ++- .../agent-master/agent-master.component.html | 17 +- .../agent-master/agent-master.component.ts | 23 +- .../create-agent/create-agent.component.html | 103 +++- .../create-agent/create-agent.component.scss | 45 ++ .../create-agent/create-agent.component.ts | 392 +++++++++++- .../agent-master/offices-under.ts | 27 + .../commission-slab-master.component.ts | 42 +- .../dds-batch-processing.component.ts | 26 +- src/app/organization/organization.service.ts | 69 +++ .../create-dds-account-type.component.html | 51 +- .../create-dds-account-type.component.ts | 40 +- .../dds-account-types.component.html | 14 +- src/app/products/products-routing.module.ts | 6 +- src/app/products/products.component.html | 6 +- .../checker-inbox-and-tasks.component.html | 4 + src/app/tasks/tasks-routing.module.ts | 6 + 48 files changed, 5878 insertions(+), 374 deletions(-) create mode 100644 src/app/deposits/daily-transaction-entry/apply-dds-receipt-dialog/apply-dds-receipt-dialog.component.html create mode 100644 src/app/deposits/daily-transaction-entry/apply-dds-receipt-dialog/apply-dds-receipt-dialog.component.scss create mode 100644 src/app/deposits/daily-transaction-entry/apply-dds-receipt-dialog/apply-dds-receipt-dialog.component.ts create mode 100644 src/app/deposits/dds-accounts/dds-account-view/dds-account-view.component.html create mode 100644 src/app/deposits/dds-accounts/dds-account-view/dds-account-view.component.scss create mode 100644 src/app/deposits/dds-accounts/dds-account-view/dds-account-view.component.ts create mode 100644 src/app/deposits/dds-accounts/dds-accounts.component.html create mode 100644 src/app/deposits/dds-accounts/dds-accounts.component.scss create mode 100644 src/app/deposits/dds-accounts/dds-accounts.component.ts create mode 100644 src/app/deposits/dds-approvals/dds-approval-notice.ts create mode 100644 src/app/deposits/dds-approvals/dds-approval-review/dds-approval-review.component.html create mode 100644 src/app/deposits/dds-approvals/dds-approval-review/dds-approval-review.component.ts create mode 100644 src/app/deposits/dds-approvals/dds-approvals.component.html create mode 100644 src/app/deposits/dds-approvals/dds-approvals.component.ts create mode 100644 src/app/deposits/party-master/client-search.ts create mode 100644 src/app/organization/agent-master/offices-under.ts diff --git a/src/app/accounts/accounts-routing.module.ts b/src/app/accounts/accounts-routing.module.ts index 83efecf30..228a5357f 100644 --- a/src/app/accounts/accounts-routing.module.ts +++ b/src/app/accounts/accounts-routing.module.ts @@ -17,6 +17,8 @@ import { DueForClosureComponent } from '../deposits/party-master/due-for-closure 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([ @@ -71,6 +73,19 @@ const routes: Routes = [ } ] }, + { + // 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, diff --git a/src/app/core/shell/shell.component.html b/src/app/core/shell/shell.component.html index c6bc12365..61efec6d0 100644 --- a/src/app/core/shell/shell.component.html +++ b/src/app/core/shell/shell.component.html @@ -883,7 +883,11 @@ (click)="subLedgerOpen = false" > Suit File Entry - Locker Entry Recurring +
@@ -1184,7 +1196,12 @@ -
Reports
- - + 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 index 169f2550f..e8ae7d13b 100644 --- a/src/app/deposits/daily-transaction-entry/daily-transaction-entry.component.html +++ b/src/app/deposits/daily-transaction-entry/daily-transaction-entry.component.html @@ -79,8 +79,8 @@ } @else {

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

}
@@ -121,7 +121,7 @@ required [matDatepicker]="txnDatePicker" formControlName="transactionDate" - (dateChange)="loadPendingEntries()" + (dateChange)="loadPendingEntries(); loadAgentMoney()" /> @@ -132,7 +132,10 @@ @for (type of transactionTypes; track type.label) { - {{ type.label }}@if (!type.supported) { — not available yet} + {{ type.label }} + @if (!type.supported) { + — not available yet + } } @@ -151,7 +154,7 @@
-

Collections to settle

+

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

@@ -160,15 +163,25 @@ @if (pendingLoading) {

Reading this agent's entries...

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

- {{ agentInfo.name }} has written nothing down for this date. Nothing to settle. -

+

{{ 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 }} + {{ entry.transactionMode }} + @if (entry.advanceDays) { + · {{ entry.advanceDays }}d advance + } + @if (entry.arrearsDays) { + · {{ entry.arrearsDays }}d previous + } + {{ entry.amount | number: '1.2-2' }}
} @@ -181,49 +194,145 @@ -

- Take {{ pendingTotal | number: '1.2-2' }} from {{ agentInfo.name }}, then post. +

+ 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) { + + @if (isAgentTran && agentInfo.name) {
-

Agent Settlement

+

Agent's cash

+ +
-

- Cash the agent hands over to the branch, deducted from the agent's collected balance shown above. -

-
- - Amount - - - - TRTP - - @for (mode of transactionModes; track mode) { - {{ mode }} - } - - - - AC Type - - - - A/C No. - - - - Description - - + + @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' }} +
+ } + }
} @@ -236,28 +345,80 @@
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 - + @@ -287,17 +448,20 @@ - - + + @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 index 6255bc5fa..5a05ca32b 100644 --- a/src/app/deposits/daily-transaction-entry/daily-transaction-entry.component.scss +++ b/src/app/deposits/daily-transaction-entry/daily-transaction-entry.component.scss @@ -160,13 +160,14 @@ $primary: #0f766e; .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 */ + 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; @@ -275,7 +276,6 @@ $primary: #0f766e; color: rgb(0 0 0 / 45%); } - /* * Marks a party this agent has already collected from today, on the row where it is picked. * @@ -334,7 +334,6 @@ $primary: #0f766e; font-weight: 500; } - /* The agent's written-down round, as the counter reads it. @@ -347,12 +346,19 @@ $primary: #0f766e; align-items: baseline; gap: 12px; padding: 10px 2px; - border-bottom: 1px solid rgba(0, 0, 0, 0.08); + 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; @@ -393,3 +399,90 @@ $primary: #0f766e; 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 index 92d8bcaaa..dce4d07d8 100644 --- a/src/app/deposits/daily-transaction-entry/daily-transaction-entry.component.ts +++ b/src/app/deposits/daily-transaction-entry/daily-transaction-entry.component.ts @@ -12,17 +12,25 @@ import { UntypedFormBuilder, UntypedFormGroup, UntypedFormArray, Validators } fr 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. @@ -45,7 +53,11 @@ import { Dates } from 'app/core/utils/dates'; selector: 'mifosx-daily-transaction-entry', templateUrl: './daily-transaction-entry.component.html', styleUrls: ['./daily-transaction-entry.component.scss'], - imports: [...STANDALONE_SHARED_IMPORTS] + imports: [ + ...STANDALONE_SHARED_IMPORTS, + MatAutocompleteModule, + MatIconModule + ] }) export class DailyTransactionEntryComponent implements OnInit { private formBuilder = inject(UntypedFormBuilder); @@ -301,9 +313,11 @@ export class DailyTransactionEntryComponent implements OnInit { 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'}` - ); + row + .get('description') + ?.setValue( + `${picked.days} ${picked.days === 1 ? 'day' : 'days'} ${mode === 'advance' ? 'in advance' : 'arrears'}` + ); this.recalculateRowBalance(this.collections.length - 1); }); } @@ -373,10 +387,11 @@ export class DailyTransactionEntryComponent implements OnInit { } /** - * Reads what this agent has outstanding for the date on the form. + * Reads everything this agent has outstanding up to the date on the form, customer by customer. * - * Asked again whenever the agent or the date changes, because both pick out a different day's - * round - and a list left over from the previous choice is a list the clerk might settle. + * 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; @@ -387,10 +402,12 @@ export class DailyTransactionEntryComponent implements OnInit { } this.pendingLoading = true; - const on = this.dateUtils.formatDate(date, 'yyyy-MM-dd'); - this.organizationService.getAgentCollectionDay(agentId, on).subscribe({ + 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'); + this.pendingEntries = (summary?.entries || []).filter( + (entry: any) => entry.status === 'PENDING' && String(entry.entryDate) <= upTo + ); this.pendingLoading = false; }, error: () => { @@ -400,6 +417,138 @@ export class DailyTransactionEntryComponent implements OnInit { }); } + /** 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'; } @@ -487,6 +636,113 @@ export class DailyTransactionEntryComponent implements OnInit { 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 }); } /** @@ -527,6 +783,9 @@ export class DailyTransactionEntryComponent implements OnInit { if (!agentId) { this.agentParties = []; this.pendingEntries = []; + this.agentGlCode = null; + this.agentDays = []; + this.agentReceipts = []; return; } @@ -542,10 +801,20 @@ export class DailyTransactionEntryComponent implements OnInit { 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.agentInfo = { + name: '', + address: '', + city: '', + accountNo: '', + guarantor1: '', + guarantor2: '', + currentBalance: 0 + }; } }); @@ -559,6 +828,7 @@ export class DailyTransactionEntryComponent implements OnInit { this.loadTodaysEntries(); this.loadPendingEntries(); + this.loadAgentMoney(); } /** This agent's collections already recorded for the date on the screen. */ @@ -574,8 +844,8 @@ export class DailyTransactionEntryComponent implements OnInit { next: (transactions: any[]) => { this.todaysEntries = (transactions || []).filter( (transaction: any) => - transaction.transactionType === 'PARTY_TRAN' - && this.dateUtils.formatDate(transaction.transactionDate, 'yyyy-MM-dd') === onDate + transaction.transactionType === 'PARTY_TRAN' && + this.dateUtils.formatDate(transaction.transactionDate, 'yyyy-MM-dd') === onDate ); }, error: () => (this.todaysEntries = []) @@ -672,36 +942,18 @@ export class DailyTransactionEntryComponent implements OnInit { * warn about. */ save(): void { - if (!this.isPartyTran) { - this.postTransaction(); + // 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; } - if (this.pendingEntries.length === 0) { - return; - } - - const money = Number(this.pendingTotal).toLocaleString('en-IN', { minimumFractionDigits: 2 }); - const count = this.pendingEntries.length; - - this.dialog - .open(ConfirmationDialogComponent, { - data: { - type: 'warn', - heading: 'Settle these collections', - dialogContext: - `Take ${money} from ${this.agentInfo.name || 'the agent'} and post ` - + `${count} ${count === 1 ? 'collection' : 'collections'}? ` - + 'Every customer on the list is credited and the cash is recorded against the agent. ' - + 'This cannot be undone.' - } - }) - .afterClosed() - .subscribe((response: any) => { - if (response?.confirm) { - this.postTransaction(); - } - }); + this.postTransaction(); } private postTransaction(): void { @@ -743,10 +995,17 @@ export class DailyTransactionEntryComponent implements OnInit { // 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(() => { + 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. - this.alertService.alert({ type: 'Success', message: posted }); + // + // 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; @@ -794,8 +1053,18 @@ export class DailyTransactionEntryComponent implements OnInit { dateFormat: 'yyyy-MM-dd', locale: this.settingsService.language.code }) - .subscribe(() => { + .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 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/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 index 66ba08cdd..45f765ff1 100644 --- a/src/app/deposits/party-master/create-party/create-party.component.html +++ b/src/app/deposits/party-master/create-party/create-party.component.html @@ -26,7 +26,9 @@

Customer & Scheme

- Who this account is for, which scheme it runs under, and which agent collects for it + Who this account is for, which scheme it runs under, and which agent collects for it
@@ -42,7 +44,11 @@ autocomplete="off" /> - + @for (client of clientOptions; track client.id) {
@@ -81,7 +87,11 @@ autocomplete="off" /> - + @for (type of subTypeOptions; track type.id) {
@@ -114,7 +124,11 @@ autocomplete="off" /> - + @for (agent of agentOptions; track agent.id) {
@@ -163,20 +177,37 @@
- - @if (savingsAccountNo) { + @if (accountNo) { + +
+ + +
+ } @else if (savingsAccountNo) {
@@ -224,7 +255,7 @@
- +
@@ -234,39 +265,45 @@
- +
- +
- +
- +
- +
- +
@@ -284,35 +321,61 @@
- +
- +
- +
- +
- +
- +
@@ -330,7 +393,12 @@
- + @@ -364,7 +432,9 @@

Minor / Guardian

- Only for a minor's account — the guardian who operates it until they come of age + Only for a minor's account — the guardian who operates it until they come of age
@@ -382,7 +452,13 @@
- + Turns 18 on @@ -396,12 +472,17 @@ matInput required formControlName="guardianClientId" + [readonly]="isFromClient('guardianClientId')" [matAutocomplete]="guardianAuto" placeholder="Type an ID or search by name…" autocomplete="off" /> - + @for (client of guardianOptions; track client.id) {
@@ -428,7 +509,7 @@
- + To the minor
@@ -535,24 +616,8 @@
-
- - - - % - -
- -
- - - - @for (type of withdrawTypes; track type) { - {{ type }} - } - - -
+ +
@@ -610,7 +675,13 @@
- + Write the reason in your own words
@@ -687,17 +758,7 @@
- -
- - - - @for (code of advCodes; track code) { - {{ code }} - } - - -
+
@@ -723,7 +784,7 @@
- DDS Account - @if (p.savingsAccountNo) { + @if (p.accountNo) { +
+ +
{{ p.subTypeDescription || p.subType }}
+
+ } @else if (p.savingsAccountNo) {
{{ p.savingsAccountStatus }}
diff --git a/src/app/deposits/party-master/party-master.component.ts b/src/app/deposits/party-master/party-master.component.ts index d6641447a..7ec5f2c8a 100644 --- a/src/app/deposits/party-master/party-master.component.ts +++ b/src/app/deposits/party-master/party-master.component.ts @@ -117,8 +117,24 @@ export class PartyMasterComponent implements OnInit { parties: any[] = []; filteredCount = 0; - @ViewChild(MatPaginator, { static: true }) paginator: MatPaginator; - @ViewChild(MatSort, { static: true }) sort: MatSort; + 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; @@ -163,7 +179,10 @@ export class PartyMasterComponent implements OnInit { 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); + 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: () => { 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 index 4e724598e..7553845f3 100644 --- a/src/app/deposits/party-master/payout-dialog/payout-dialog.component.html +++ b/src/app/deposits/party-master/payout-dialog/payout-dialog.component.html @@ -20,11 +20,67 @@ Matured on {{ data.maturityDate | dateFormat }}
-
- Amount payable - {{ data.currentBalance | number: '1.2-2' }} +
+ 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) { @@ -46,28 +102,57 @@ Account @for (account of ownAccounts; track account.id) { - - {{ account.accountNo }} · {{ account.productName }} - + {{ account.accountNo }} · {{ account.productName }} } } - To an account at another bank - - Held in Matured Deposits Payable, then sent by NEFT or IMPS from Fund Transfers - + 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 sits in the bank's Matured Deposits Payable - account until the transfer is made from Fund Transfers — so it is accounted for the whole - time, and a failed transfer loses nothing. + 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.
} } @@ -75,5 +160,5 @@ - + 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 index 7d58ea20a..7e62e8ee6 100644 --- a/src/app/deposits/party-master/payout-dialog/payout-dialog.component.scss +++ b/src/app/deposits/party-master/payout-dialog/payout-dialog.component.scss @@ -38,6 +38,22 @@ 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 { @@ -90,3 +106,7 @@ 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 index 120900150..0ca81b9c1 100644 --- a/src/app/deposits/party-master/payout-dialog/payout-dialog.component.ts +++ b/src/app/deposits/party-master/payout-dialog/payout-dialog.component.ts @@ -14,6 +14,7 @@ import { MAT_DIALOG_DATA, MatDialogModule, MatDialogRef } from '@angular/materia 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'; @@ -46,6 +47,7 @@ import { DateFormatPipe } from 'app/pipes/date-format.pipe'; MatButtonModule, MatRadioModule, MatSelectModule, + MatAutocompleteModule, MatFormFieldModule, MatInputModule, MatProgressBarModule, @@ -72,9 +74,90 @@ export class PayoutDialogComponent implements OnInit { */ 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 || []; @@ -96,14 +179,52 @@ export class PayoutDialogComponent implements OnInit { }); } + /** 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.selectedAccountNo; + return this.destination === 'other' ? !!this.sundryGlAccountId : !!this.selectedAccountNo; } payOut(): void { - this.dialogRef.close(this.destination === 'own' ? { toSavingsAccountNo: this.selectedAccountNo } : {}); + 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 index fea269d3b..7fc420782 100644 --- 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 @@ -47,8 +47,8 @@
Interest recalculated at {{ reducedRate | number: '1.0-2' }}% - instead of {{ data.interestRate | number: '1.0-2' }}% for the whole period, and the difference taken - back before the balance is paid out. + instead of {{ data.interestRate | number: '1.0-2' }}% for the whole period, and the difference taken back + before the balance is paid out.
} @else {
Their interest is recalculated at the scheme's reduced rate.
@@ -61,8 +61,8 @@
Agent — {{ data.agentCode }}
- The customer keeps a full month's interest, and the agent funds the days left in this month. The - balance is paid out in full. + The customer keeps a full month's interest, and the agent funds the days left in this month. The balance is + paid out in full.
@@ -86,7 +86,7 @@ Into another account of theirs at this bank - Park it for transfer to another bank + Park in Sundry (no account here) @if (destination === 'own' && ownAccounts.length > 0) { @@ -100,19 +100,76 @@ } + @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 matured-deposits payable for the - branch to send on. + 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.

} -

This closes the account. It cannot be reopened.

+ + @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 index a20cc2ccb..7d88e158a 100644 --- 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 @@ -110,3 +110,23 @@ 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 index 0cc913c5d..53db816e0 100644 --- 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 @@ -14,6 +14,7 @@ import { MAT_DIALOG_DATA, MatDialogModule, MatDialogRef } from '@angular/materia 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'; @@ -51,6 +52,7 @@ import { DateFormatPipe } from 'app/pipes/date-format.pipe'; MatButtonModule, MatRadioModule, MatSelectModule, + MatAutocompleteModule, MatFormFieldModule, MatInputModule, MatProgressBarModule, @@ -73,6 +75,61 @@ export class PrematureCloseDialogComponent implements OnInit { 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; /** @@ -84,6 +141,12 @@ export class PrematureCloseDialogComponent implements OnInit { 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 || []; @@ -120,17 +183,46 @@ export class PrematureCloseDialogComponent implements OnInit { 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; } - return this.destination === 'other' || !!this.selectedAccountNo; + 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 === '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/organization/agent-master/agent-master.component.html b/src/app/organization/agent-master/agent-master.component.html index f5733e41e..06483cd18 100644 --- a/src/app/organization/agent-master/agent-master.component.html +++ b/src/app/organization/agent-master/agent-master.component.html @@ -43,7 +43,10 @@
Active Agents
-
+
{{ totalCurrentBalance | number: '1.2-2' }}
@@ -92,6 +95,18 @@ + + + GL Account + + @if (a.glAccountCode) { + {{ a.glAccountCode }} · {{ a.glAccountName }} + } @else { + Not set + } + + + Branch diff --git a/src/app/organization/agent-master/agent-master.component.ts b/src/app/organization/agent-master/agent-master.component.ts index aa6572d10..88d7675b2 100644 --- a/src/app/organization/agent-master/agent-master.component.ts +++ b/src/app/organization/agent-master/agent-master.component.ts @@ -29,6 +29,10 @@ 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. @@ -61,11 +65,13 @@ import { OrganizationService } from 'app/organization/organization.service'; }) export class AgentMasterComponent implements OnInit { private organizationService = inject(OrganizationService); + private authenticationService = inject(AuthenticationService); displayedColumns: string[] = [ 'agentCode', 'clientName', 'homeBranch', + 'glAccount', 'agentCity', 'agentSavingAccountNo', 'agentCurrentBalance', @@ -103,8 +109,21 @@ export class AgentMasterComponent implements OnInit { } private loadAgents(): void { - this.organizationService.getAgents().subscribe((agents: any[]) => { - this.agents = agents || []; + 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; 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 index 088295fc1..807a9b050 100644 --- a/src/app/organization/agent-master/create-agent/create-agent.component.html +++ b/src/app/organization/agent-master/create-agent/create-agent.component.html @@ -28,9 +28,45 @@
- Client ID - - @if (clientLookupFailed) { + 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 }} @@ -72,8 +108,7 @@
- @@ -111,7 +146,7 @@ (selectionChange)="onSavingsAccountTypePicked()" > @for (accountType of savingsAccountTypes; track accountType) { - {{ accountType }} + {{ savingsAccountTypeCodes[accountType] || accountType }} } Agent Birth Date - - + + From the client's record
@@ -179,10 +211,37 @@ @for (guarantor of guarantors.controls; track guarantor; let i = $index) {
- Client ID - + Client ID or Name + + + + @for (client of guarantorOptionsFor(guarantor); track client.id) { + +
+ {{ client.id }} + {{ client.displayName }} + {{ client.officeName }} +
+
+ } +
+ @if (guarantor.get('clientId')?.hasError('selfGuarantor')) { - An agent cannot be its own guarantor + 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 }
@@ -244,7 +303,13 @@ -
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 index a0247c6dd..e1852abe2 100644 --- a/src/app/organization/agent-master/create-agent/create-agent.component.scss +++ b/src/app/organization/agent-master/create-agent/create-agent.component.scss @@ -144,3 +144,48 @@ $primary: #0f766e; 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 index 50e9b4844..0847ae6a6 100644 --- a/src/app/organization/agent-master/create-agent/create-agent.component.ts +++ b/src/app/organization/agent-master/create-agent/create-agent.component.ts @@ -7,7 +7,7 @@ */ /** Angular Imports */ -import { Component, OnInit, inject } from '@angular/core'; +import { ChangeDetectorRef, Component, ElementRef, OnInit, ViewChild, inject } from '@angular/core'; import { UntypedFormBuilder, UntypedFormGroup, @@ -28,9 +28,14 @@ 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 { @@ -61,7 +66,8 @@ function setGroupField(group: UntypedFormGroup, controlName: string, value: any) imports: [ ...STANDALONE_SHARED_IMPORTS, // See the Party form: an unimported directive is ignored without a word. - RestrictInputDirective + RestrictInputDirective, + MatAutocompleteModule ] }) export class CreateAgentComponent implements OnInit { @@ -74,21 +80,69 @@ export class CreateAgentComponent implements OnInit { 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 latest date of birth the picker will offer: eighteen years ago today. - * - * It was previously today, which let a clerk save an agent born on the day they were entered — - * the picker opens on today, so a stray Enter was enough. The figure matches the rule the server - * applies, so the calendar cannot offer a date the save would then refuse. + * 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. */ - maxBirthDate = new Date(new Date().getFullYear() - 18, new Date().getMonth(), new Date().getDate()); + 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 @@ -103,6 +157,12 @@ export class CreateAgentComponent implements OnInit { */ 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; @@ -141,7 +201,11 @@ export class CreateAgentComponent implements OnInit { agentForm: UntypedFormGroup = this.formBuilder.group({ clientId: [ '', - Validators.required + // A name typed to search with is not a client; only an id picked or keyed saves. + [ + Validators.required, + Validators.pattern(/^\d+$/) + ] ], agentCode: [ { @@ -186,10 +250,8 @@ export class CreateAgentComponent implements OnInit { '', Validators.required ], - agentBirthDate: [ - '', - Validators.required - ], + // Filled from the client, whose date of birth was checked when they were opened. + agentBirthDate: [''], agentCurrentBalance: [ { value: 0, @@ -209,8 +271,39 @@ export class CreateAgentComponent implements OnInit { }); 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) => (this.offices = Array.isArray(offices) ? offices : offices?.pageItems || []), + 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 = []) }); @@ -220,7 +313,9 @@ export class CreateAgentComponent implements OnInit { // 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)); } + if (clientId) { + this.loadClientSavingsAccounts(String(clientId)); + } }, error: () => (this.ddsProductId = null) }); @@ -245,11 +340,24 @@ export class CreateAgentComponent implements OnInit { this.agentForm .get('clientId') ?.valueChanges.pipe(debounceTime(500), distinctUntilChanged()) - .subscribe(() => { + .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)); + } } }); } @@ -264,7 +372,9 @@ export class CreateAgentComponent implements OnInit { '', [ Validators.required, - this.notTheAgentItself() + Validators.pattern(/^\d+$/), + this.notTheAgentItself(), + this.notAlreadyAGuarantor() ] ], guarantorName: [ @@ -288,14 +398,103 @@ export class CreateAgentComponent implements OnInit { }); // 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((clientId) => this.loadGuarantorClient(group, clientId)); + .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. @@ -312,7 +511,14 @@ export class CreateAgentComponent implements OnInit { /** 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']) { + for (const controlName of [ + 'guarantorName', + 'telephone', + 'address1', + 'address2', + 'city', + 'pinCode' + ]) { group.get(controlName)?.setValue(''); } if (!clientId) { @@ -343,11 +549,18 @@ export class CreateAgentComponent implements OnInit { } addGuarantor(): void { - this.guarantors.push(this.buildGuarantorGroup()); + 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. */ @@ -360,6 +573,60 @@ export class CreateAgentComponent implements OnInit { '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 @@ -370,6 +637,7 @@ export class CreateAgentComponent implements OnInit { 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. @@ -378,22 +646,42 @@ export class CreateAgentComponent implements OnInit { 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 branch the person is on the bank's books at. Offered as a starting point rather than - // forced, since an agent can be recorded at one office and collect out of another. Set as a - // number rather than through setField, whose String() would stop it matching an option. - if (client?.officeId) { - this.agentForm.get('officeId')?.setValue(Number(client.officeId)); - } + // 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)); }, @@ -429,6 +717,11 @@ export class CreateAgentComponent implements OnInit { 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 = []; @@ -539,10 +832,23 @@ export class CreateAgentComponent implements OnInit { })) }; + 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(() => this.exit()); + this.organizationService.updateAgent(this.agentId, payload).subscribe(done); } else { - this.organizationService.createAgent(payload).subscribe(() => this.exit()); + this.organizationService.createAgent(payload).subscribe(done); } } @@ -591,3 +897,33 @@ export class CreateAgentComponent implements OnInit { } } } + +/** 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.ts b/src/app/organization/commission-slab-master/commission-slab-master.component.ts index c1a96c849..6519a1193 100644 --- a/src/app/organization/commission-slab-master/commission-slab-master.component.ts +++ b/src/app/organization/commission-slab-master/commission-slab-master.component.ts @@ -15,6 +15,7 @@ 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. @@ -144,9 +145,9 @@ export class CommissionSlabMasterComponent implements OnInit { 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) + slab.slabType === candidate.slabType && + Number(candidate.slabFrom) <= Number(slab.slabTo) && + Number(candidate.slabTo) >= Number(slab.slabFrom) ); } @@ -257,8 +258,12 @@ export class CommissionSlabMasterComponent implements OnInit { locale: 'en' }) .subscribe({ - next: () => { - this.alertService.alert({ type: 'Success', message: 'Slab corrected' }); + 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(); }, @@ -268,7 +273,9 @@ export class CommissionSlabMasterComponent implements OnInit { this.alertService.alert({ type: 'Error', message: - error?.error?.errors?.[0]?.defaultUserMessage || error?.error?.defaultUserMessage || 'Could not correct the slab' + error?.error?.errors?.[0]?.defaultUserMessage || + error?.error?.defaultUserMessage || + 'Could not correct the slab' }); } }); @@ -298,11 +305,20 @@ export class CommissionSlabMasterComponent implements OnInit { })); this.organizationService.createCommissionSlabs(payload).subscribe({ - next: () => { - this.alertService.alert({ - type: 'Success', - message: `${newSlabs.length} ${newSlabs.length === 1 ? 'slab' : 'slabs'} saved` - }); + 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 @@ -311,7 +327,9 @@ export class CommissionSlabMasterComponent implements OnInit { this.alertService.alert({ type: 'Error', message: - error?.error?.errors?.[0]?.defaultUserMessage || error?.error?.defaultUserMessage || 'Could not save the slabs' + 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.ts b/src/app/organization/dds-batch-processing/dds-batch-processing.component.ts index a4ed266a6..37a2b5ee5 100644 --- a/src/app/organization/dds-batch-processing/dds-batch-processing.component.ts +++ b/src/app/organization/dds-batch-processing/dds-batch-processing.component.ts @@ -22,6 +22,7 @@ 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. @@ -264,11 +265,11 @@ export class DdsBatchProcessingComponent implements OnInit { type: 'warn', heading: `Post ${this.jobTypeLabel}?`, dialogContext: - `${count} ${count === 1 ? 'entry' : 'entries'} totalling ${total} will be applied. ` - + (isPosting + `${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.' + : 'This records what has been earned against each account. ') + + 'It cannot be undone from this screen.' } }) .afterClosed() @@ -296,11 +297,21 @@ export class DdsBatchProcessingComponent implements OnInit { const runId = this.generatedRun.id; const crediting = this.isPostingJob; - this.organizationService.postDdsBatchRun(runId).subscribe(() => { + 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', @@ -312,8 +323,9 @@ export class DdsBatchProcessingComponent implements OnInit { 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.' + 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); }); diff --git a/src/app/organization/organization.service.ts b/src/app/organization/organization.service.ts index 91030b2d9..547cae9b6 100644 --- a/src/app/organization/organization.service.ts +++ b/src/app/organization/organization.service.ts @@ -339,6 +339,37 @@ export class OrganizationService { * @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) } }); } @@ -376,6 +407,14 @@ export class OrganizationService { 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} @@ -404,6 +443,26 @@ export class OrganizationService { 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. @@ -417,6 +476,16 @@ export class OrganizationService { }); } + /** + * @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 */ 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 index 761aa3737..3e0eff78f 100644 --- 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 @@ -11,15 +11,15 @@
-

Account Type Creation

-

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

+

DDS Product Creation

+

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

- Account Type Group + Product Group @for (group of accountTypeGroups; track group) { {{ group }} @@ -28,15 +28,24 @@ - A/c.Type + Product Code - A/c. Type Description + 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 @@ -84,8 +93,8 @@ -->

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. + 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.

@@ -97,26 +106,36 @@ 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.ts b/src/app/products/dds-account-types/create-dds-account-type/create-dds-account-type.component.ts index dc7911ba3..0cdbcf188 100644 --- 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 @@ -16,7 +16,9 @@ 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, @@ -40,6 +42,7 @@ export class CreateDdsAccountTypeComponent implements OnInit { private route = inject(ActivatedRoute); private productsService = inject(ProductsService); private alertService = inject(AlertService); + private organizationService = inject(OrganizationService); private settingsService = inject(SettingsService); accountTypeGroups = ['DDS']; @@ -85,6 +88,15 @@ export class CreateDdsAccountTypeComponent implements OnInit { 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 @@ -157,6 +169,7 @@ export class CreateDdsAccountTypeComponent implements OnInit { 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, @@ -178,8 +191,10 @@ export class CreateDdsAccountTypeComponent implements OnInit { 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, + 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), @@ -187,13 +202,22 @@ export class CreateDdsAccountTypeComponent implements OnInit { locale: this.settingsService.language.code }; - const done = () => { - this.alertService.alert({ - type: 'Success', - message: this.isEdit - ? `Account type ${payload.accountTypeCode} updated` - : `Account type ${payload.accountTypeCode} created` - }); + 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(); }; 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 index 5fbea59c9..cea8c4202 100644 --- a/src/app/products/dds-account-types/dds-account-types.component.html +++ b/src/app/products/dds-account-types/dds-account-types.component.html @@ -11,8 +11,8 @@
-

DDS Account Types

-

Scheme variants a Daily Deposit account can be opened under.

+

DDS Product

+

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

@@ -23,7 +23,7 @@
@@ -33,7 +33,7 @@
{{ totalCount | number }}
-
Total Account Types
+
Total DDS Products
@@ -52,7 +52,7 @@
@@ -64,14 +64,14 @@

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

} @else {
- +
A/c TypeProduct
diff --git a/src/app/products/products-routing.module.ts b/src/app/products/products-routing.module.ts index d043fd8a5..52d438830 100644 --- a/src/app/products/products-routing.module.ts +++ b/src/app/products/products-routing.module.ts @@ -737,7 +737,7 @@ const routes: Routes = [ }, { path: 'dds-account-types', - data: { title: 'DDS Account Types', breadcrumb: 'DDS Account Types' }, + data: { title: 'DDS Product', breadcrumb: 'DDS Product' }, children: [ { path: '', @@ -746,12 +746,12 @@ const routes: Routes = [ { path: 'create', component: CreateDdsAccountTypeComponent, - data: { title: 'Create Account Type', breadcrumb: 'Create' } + data: { title: 'Create DDS Product', breadcrumb: 'Create' } }, { path: ':accountTypeId/edit', component: CreateDdsAccountTypeComponent, - data: { title: 'Edit Account Type', breadcrumb: 'Edit', routeParamBreadcrumb: false } + data: { title: 'Edit DDS Product', breadcrumb: 'Edit', routeParamBreadcrumb: false } } ] }, diff --git a/src/app/products/products.component.html b/src/app/products/products.component.html index 766211fa5..77186ce4c 100644 --- a/src/app/products/products.component.html +++ b/src/app/products/products.component.html @@ -131,8 +131,10 @@
-

DDS Account Types

-

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

+

DDS Product

+

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

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 03f8fa26a..3ae13cd26 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 @@ -200,6 +200,10 @@ Disbursement & Transfers + + + DDS Approvals +