Compare commits

...
Author SHA1 Message Date
niteshandClaude Sonnet 5.5 c766a6f092 feat(dds): batch run preview on its own page, runs popup, projected GLs in approvals
- Generate opens the run on /organization/dds-batch-processing/run/:id with a back arrow,
  Post and Discard beside the figures, agent-wise table, View popup and CSV export; the
  table scrolls inside the page. A posted, running or failed run opens there read-only
- Batch screen lists generated-but-unposted runs as buttons to reopen them; Previous runs
  moved into a popup with status and job chips, search, date range, pagination, row
  detail and export; posting progress is followed from the run status
- Shared helpers for job names, money and agent grouping
- Approval review shows, for a calculation run, the entries its matching Posting run will
  book, marked "when posted"

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-28 22:04:26 +05:30
niteshandClaude Sonnet 5.5 0275a46326 fix(shell): keep the shell between modules and cancel abandoned requests
- Route reuse treats the shell of every feature module as one, so a click from one module
  into another no longer rebuilds the shell (menu bar vanishing, menu, notification,
  business date and approval-stream calls repeated on every such click)
- The HTTP cache interceptor cancels the inner request when its caller walks away, so
  abandoned streams stop holding browser connections and queueing later requests
- /approvals/counts is not cached

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-28 21:03:52 +05:30
niteshandClaude Sonnet 5.5 306af59099 feat(dds): savings-style DDS account view, batch processing redesign, slab master in Products
- DDS account view: Transactions and Passbook tabs laid out like savings, a per-line
  View Transaction popup (general, payment details, accounting and audit, receipt print)
  with actions menu (view, receipt, journal entries)
- DDS batch processing: job tiles with calculate then post pairs, compact agent and party
  ranges, agent-wise preview with a View popup of each agent's accounts, search, and CSV
  export of all details or one agent; previous runs summary; OD interest hidden
- Discard removes the generated preview on the server
- Commission Slab Master moved from Organization to Products, with a how-to card, rates
  and commission at "To", gap warnings to the paisa, loading state and a calculator popup
- Party edit: scheme terms and agent locked on an active account, with a lock badge

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-28 17:13:33 +05:30
niteshandClaude Opus 5.5 53bb97d6ed feat(dds): DDS product creation in tabs with an Accounting tab
- DDS Product Creation split into Details / Interest & Maturity /
  Premature Closure / Accounting tabs, like the savings product
- Accounting tab picks Customer Deposits (liability), Interest Expense
  and Agent Commission Expense (expense) GLs; product view shows them
- Approval review shows the product's GLs, old -> new on edits
- Agent master no longer reads the removed dds-deposit-product setting

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-28 12:43:51 +05:30
niteshandClaude Sonnet 5.5 293b671966 feat(dds): upgrade DDS UI - agent, party, daily entry, approvals, product view
- Agent master: tabbed layout, customer search dialog, GL dropdown, account card, view page
- Party master: tabbed layout, expected party code, nominee cards, relation from code values
- Daily transaction entry: compact style, collections popup, Apply DDS Receipt placement
- Approvals: new review modal, Agent Approval tab, DDS routing into product/pending account
- DDS product: view page and list buttons; drop Auto Interest Post Day

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-28 10:50:03 +05:30
69 changed files with 12645 additions and 3000 deletions
+11 -4
View File
@@ -59,7 +59,10 @@ export class CacheInterceptor implements HttpInterceptor {
'/notes', '/notes',
// The ALM maturity breakdown counts deposits and loans as of the date asked and reads the bank's // The ALM maturity breakdown counts deposits and loans as of the date asked and reads the bank's
// own bucket edges from the query; a cached copy would show another date's or another setting's figures. // own bucket edges from the query; a cached copy would show another date's or another setting's figures.
'/almreturn' '/almreturn',
// The Approvals badge: a stream that stays open for minutes and a count that moves with every request raised or
// actioned. A copy of either is of no use, and the stream must never be held by anything but its own caller.
'/approvals/counts'
]; ];
private isCacheable(url: string): boolean { private isCacheable(url: string): boolean {
@@ -93,8 +96,9 @@ export class CacheInterceptor implements HttpInterceptor {
// Create new response to avoid side-effects // Create new response to avoid side-effects
subscriber.next(new HttpResponse(cachedData as Object)); subscriber.next(new HttpResponse(cachedData as Object));
subscriber.complete(); subscriber.complete();
} else { return undefined;
next.handle(request).subscribe( }
const inner = next.handle(request).subscribe(
(event) => { (event) => {
if (environment.httpCacheEnabled && event instanceof HttpResponse) { if (environment.httpCacheEnabled && event instanceof HttpResponse) {
this.httpCacheService.setCacheData(request.urlWithParams, event); this.httpCacheService.setCacheData(request.urlWithParams, event);
@@ -104,7 +108,10 @@ export class CacheInterceptor implements HttpInterceptor {
(error) => subscriber.error(error), (error) => subscriber.error(error),
() => subscriber.complete() () => subscriber.complete()
); );
} // The request is cancelled when its caller walks away (a page is left, a screen is closed). The inner request has to
// be cancelled with it: left to run, it kept its connection open. A browser holds about six per site, so a few
// streams that were let go but never closed filled them and every later request waited in the queue.
return () => inner.unsubscribe();
}); });
} }
} }
+17 -1
View File
@@ -9,6 +9,7 @@
/** Angular Imports */ /** Angular Imports */
import { ActivatedRouteSnapshot, DetachedRouteHandle, RouteReuseStrategy } from '@angular/router'; import { ActivatedRouteSnapshot, DetachedRouteHandle, RouteReuseStrategy } from '@angular/router';
import { Injectable } from '@angular/core'; import { Injectable } from '@angular/core';
import { ShellComponent } from '../shell/shell.component';
/** /**
* A route strategy allowing for explicit route reuse. * A route strategy allowing for explicit route reuse.
@@ -31,7 +32,22 @@ export class RouteReusableStrategy extends RouteReuseStrategy {
return null; return null;
} }
/**
* Every feature module registers its own copy of the shell route (Route.withShell), so two pages in different modules
* sit under two different shell routes. Treated as different, a click from one module into another destroyed the
* shell and built a new one: the menu bar vanished until it had loaded again, and the shell's own calls (menu access,
* notifications, business date, the approval stream) went out again on every such click. The shell is the same
* component whichever module it wraps, so it is kept: only the page inside it changes, until a hard refresh.
*/
public shouldReuseRoute(future: ActivatedRouteSnapshot, curr: ActivatedRouteSnapshot): boolean { public shouldReuseRoute(future: ActivatedRouteSnapshot, curr: ActivatedRouteSnapshot): boolean {
return future.routeConfig === curr.routeConfig || future.data.reuse; return (
future.routeConfig === curr.routeConfig ||
future.data.reuse ||
(RouteReusableStrategy.isShell(future) && RouteReusableStrategy.isShell(curr))
);
}
private static isShell(route: ActivatedRouteSnapshot): boolean {
return route.routeConfig?.component === ShellComponent;
} }
} }
+3 -2
View File
@@ -81,8 +81,9 @@ export class ShellComponent implements OnInit, OnDestroy {
.pipe(map((result) => result.matches)); .pipe(map((result) => result.matches));
/** Sets the initial state of sidenav as collapsed. Not collapsed if false. */ /** Sets the initial state of sidenav as collapsed. Not collapsed if false. */
sidenavCollapsed = true; sidenavCollapsed = true;
/** Progress bar mode. */ /** Progress bar mode. It starts from what is running right now: the template shows the bar for anything that is not
progressBarMode: string; * 'none', so an empty start left it running until some request happened to finish. */
progressBarMode: string = this.progressBarService.getRequestsRunning() > 0 ? 'indeterminate' : 'none';
/** Subscription to progress bar. */ /** Subscription to progress bar. */
progressBar$: Subscription; progressBar$: Subscription;
/** Hides nav/sidebar for full-screen cashier routes. */ /** Hides nav/sidebar for full-screen cashier routes. */
@@ -56,12 +56,13 @@
flex-shrink: 0; flex-shrink: 0;
} }
// A ticked day or voucher, in the screen's own green rather than the shared sheet's blue.
.option-picked { .option-picked {
border-color: #2563eb !important; border-color: #0f766e !important;
background: #eff6ff !important; background: #e6f4ee !important;
.option-check { .option-check {
color: #2563eb; color: #0f766e;
} }
} }
@@ -93,3 +94,76 @@
border-radius: 6px; border-radius: 6px;
background: #f8fafc; background: #f8fafc;
} }
// ── Compact density ──────────────────────────────────────────────────────────────────────────────────
// A step smaller than the sheet it shares, for this screen only: the shared sheet is sized in rem for
// Apply Additional Shares and is left alone, so the whole dialog is scaled here instead. Nothing in it
// is an overlay, so the scaling cannot throw a popup out of place.
.dialog-wrapper {
zoom: 0.88;
}
// ── The Daily Transaction screen's colours ───────────────────────────────────────────────────────────
// The shared sheet is indigo and blue, for Apply Additional Shares. This dialog opens from a screen
// drawn in green and teal, so it takes that palette here - the shared sheet is left as it is, and these
// rules only state what differs from it.
$page-teal: #0f766e;
$page-dark: #0f4f3c;
$page-soft: #e6f4ee;
$page-border: #b7ddd0;
.dialog-header {
background: linear-gradient(135deg, $page-dark 0%, $page-teal 70%, #1f7a5c 100%);
}
.sc-card {
border-color: $page-border;
}
.slot-step {
background: linear-gradient(135deg, $page-dark 0%, $page-teal 100%);
}
.slot-search {
border-color: $page-border;
background: $page-soft;
color: $page-teal;
&:hover {
background: $page-teal;
border-color: $page-teal;
color: #fff;
box-shadow: 0 2px 8px rgb(15 118 110 / 25%);
}
}
.option {
&:hover {
border-color: $page-teal;
background: $page-soft;
box-shadow: 0 2px 6px rgb(15 118 110 / 10%);
}
&:hover .option-radio {
border-color: $page-teal;
}
}
.option-info:hover {
color: $page-teal;
border-color: $page-teal;
background: $page-soft;
}
.gl-table tbody tr:hover {
background: $page-soft;
}
.dialog-actions [mat-flat-button][color='primary']:not([disabled]) {
background: linear-gradient(135deg, $page-teal 0%, $page-dark 100%) !important;
box-shadow: 0 2px 8px rgb(15 118 110 / 28%) !important;
&:hover {
box-shadow: 0 4px 14px rgb(15 118 110 / 40%) !important;
}
}
@@ -0,0 +1,78 @@
<!--
Copyright since 2025 Mifos Initiative
This Source Code Form is subject to the terms of the Mozilla Public
License, v. 2.0. If a copy of the MPL was not distributed with this
file, You can obtain one at http://mozilla.org/MPL/2.0/.
-->
<div class="cd">
<div class="cd-head">
<span class="cd-head__ic"><i class="fas fa-list"></i></span>
<div class="cd-head__text">
<h2>Collections up to {{ data.dateText }}</h2>
<p>{{ data.agentName }} - written down and not yet handed in</p>
</div>
<button mat-stroked-button type="button" class="cd-refresh" (click)="refresh()">
<i class="fas fa-sync"></i> Refresh
</button>
<button type="button" class="cd-close" (click)="close()" aria-label="Close"><i class="fas fa-times"></i></button>
</div>
<div class="cd-body">
@if (data.loading()) {
<p class="cd-empty">Reading this agent's entries...</p>
} @else if (entries.length === 0) {
<p class="cd-empty">{{ data.agentName }} has nothing outstanding up to this date.</p>
} @else {
<div class="cd-table">
<div class="cd-row cd-row--head">
<span class="cd-date">Date</span>
<span class="cd-code">Party</span>
<span class="cd-name">Name</span>
<span class="cd-mode">Mode</span>
<span class="cd-amount">Amount</span>
</div>
@for (entry of pageEntries; track entry.id) {
<!-- Clicking a line closes this and shows that party on the screen. -->
<div class="cd-row" (click)="pick(entry)" title="Show this party's account">
<span class="cd-date">{{ entry.entryDate | dateFormat }}</span>
<span class="cd-code">{{ entry.partyCode }}</span>
<span class="cd-name">{{ entry.partyName }}</span>
<span class="cd-mode"
><span class="cd-chip">{{ entry.transactionMode }}</span>
@if (entry.advanceDays) {
<small>{{ entry.advanceDays }}d advance</small>
}
@if (entry.arrearsDays) {
<small>{{ entry.arrearsDays }}d previous</small>
}
</span>
<span class="cd-amount">{{ entry.amount | number: '1.2-2' }}</span>
</div>
}
</div>
<div class="cd-total">
<span>{{ entries.length }} {{ entries.length === 1 ? 'entry' : 'entries' }}</span>
<strong>{{ data.total() | number: '1.2-2' }}</strong>
</div>
<p class="cd-note">
The cash and its receipts are under Agent Tran. Click a line to see that customer's account.
</p>
}
</div>
@if (entries.length > 0) {
<mat-paginator
class="cd-paginator"
[length]="entries.length"
[pageSize]="pageSize"
[pageIndex]="pageIndex"
[pageSizeOptions]="pageSizeOptions"
(page)="onPage($event)"
showFirstLastButtons
></mat-paginator>
}
</div>
@@ -0,0 +1,235 @@
/**
* Copyright since 2025 Mifos Initiative
*
* This Source Code Form is subject to the terms of the Mozilla Public
* License, v. 2.0. If a copy of the MPL was not distributed with this
* file, You can obtain one at http://mozilla.org/MPL/2.0/.
*/
$primary: #0f766e;
$primary-dark: #0f4f3c;
$primary-light: #e6f4ee;
$border: #e2e8f0;
$text-dark: #0f172a;
$text-mid: #475569;
$text-soft: #94a3b8;
:host {
display: block;
}
// Small type throughout: this is a list to read down, and a page of it should fit the window.
.cd {
display: flex;
flex-direction: column;
max-height: 86vh;
font-size: 12.5px;
color: $text-dark;
}
.cd-head {
display: flex;
align-items: center;
gap: 10px;
padding: 12px 16px;
border-bottom: 1px solid $border;
&__ic {
display: grid;
place-items: center;
flex: 0 0 auto;
width: 30px;
height: 30px;
border-radius: 9px;
background: $primary-light;
color: $primary-dark;
font-size: 13px;
}
&__text {
flex: 1;
min-width: 0;
h2 {
margin: 0;
font-size: 14px;
font-weight: 700;
}
p {
margin: 1px 0 0;
font-size: 11.5px;
color: $text-mid;
}
}
}
.cd-refresh {
height: 30px;
padding: 0 12px;
font-size: 12px;
color: $primary;
}
.cd-close {
display: grid;
place-items: center;
width: 28px;
height: 28px;
border: 0;
border-radius: 8px;
background: transparent;
color: $text-mid;
cursor: pointer;
&:hover {
background: #f1f5f9;
}
}
.cd-body {
flex: 1;
min-height: 0;
padding: 10px 16px 6px;
overflow-y: auto;
}
.cd-empty {
margin: 8px 0;
padding: 16px;
border: 1.5px dashed #cbd5e1;
border-radius: 10px;
background: #f8fafc;
color: $text-mid;
text-align: center;
}
.cd-table {
border: 1px solid $border;
border-radius: 10px;
overflow: hidden;
}
.cd-row {
display: grid;
grid-template-columns: 112px 78px minmax(0, 1fr) 120px 92px;
align-items: center;
gap: 10px;
padding: 7px 12px;
border-bottom: 1px solid #eef2f6;
cursor: pointer;
&:last-child {
border-bottom: 0;
}
&:hover {
background: #f0fdf9;
}
&--head {
cursor: default;
background: #f1f5f9;
font-size: 10.5px;
font-weight: 700;
letter-spacing: 0.05em;
text-transform: uppercase;
color: $text-soft;
&:hover {
background: #f1f5f9;
}
}
}
.cd-date {
color: $text-mid;
font-variant-numeric: tabular-nums;
}
.cd-code {
font-weight: 700;
color: $primary-dark;
font-family: ui-monospace, 'SF Mono', 'Cascadia Mono', Consolas, monospace;
font-size: 12px;
}
.cd-name {
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.cd-mode {
display: flex;
align-items: center;
gap: 6px;
flex-wrap: wrap;
small {
font-size: 10.5px;
color: $text-mid;
}
}
.cd-chip {
padding: 1px 8px;
border-radius: 999px;
background: #eef2ff;
color: #3730a3;
font-size: 10.5px;
font-weight: 600;
letter-spacing: 0.04em;
text-transform: uppercase;
}
.cd-amount {
text-align: right;
font-weight: 700;
font-variant-numeric: tabular-nums;
}
.cd-total {
display: flex;
justify-content: flex-end;
align-items: baseline;
gap: 12px;
margin-top: 8px;
padding: 8px 12px;
border-radius: 8px;
background: $primary-light;
color: $text-mid;
strong {
font-size: 15px;
color: $primary-dark;
font-variant-numeric: tabular-nums;
}
}
.cd-note {
margin: 8px 2px 4px;
font-size: 11.5px;
color: $text-soft;
}
.cd-paginator {
flex: 0 0 auto;
border-top: 1px solid $border;
font-size: 12px;
--mat-paginator-container-text-size: 12px;
--mat-paginator-container-size: 44px;
--mat-paginator-form-field-container-height: 30px;
--mat-paginator-form-field-container-vertical-padding: 3px;
}
@media (width <= 640px) {
.cd-row {
grid-template-columns: 90px 70px minmax(0, 1fr) 80px;
.cd-mode {
display: none;
}
}
}
@@ -0,0 +1,93 @@
/**
* 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, inject } from '@angular/core';
import { CommonModule } from '@angular/common';
import { MAT_DIALOG_DATA, MatDialogModule, MatDialogRef } from '@angular/material/dialog';
import { MatButtonModule } from '@angular/material/button';
import { MatPaginatorModule, PageEvent } from '@angular/material/paginator';
import { DateFormatPipe } from 'app/pipes/date-format.pipe';
/**
* What the dialog is given. The entries are read through functions rather than copied in, so a Refresh
* on the screen behind - which reloads the list - shows here as soon as it arrives.
*/
export interface CollectionsDialogData {
agentName: string;
/** The day the list runs up to, already worded for a reader. */
dateText: string;
entries: () => any[];
total: () => number;
loading: () => boolean;
refresh: () => void;
}
/**
* Everything the agent wrote down and has not handed in yet, up to the chosen day, customer by
* customer - the list the clerk counts a bag of cash against.
*
* It lives in a window, paged, rather than down the page: a round can run to hundreds of lines, and
* the screen behind it has a good deal more to show. A line can be clicked to see that customer's
* account on the screen; the dialog closes with the entry picked.
*/
@Component({
selector: 'mifosx-dds-collections-dialog',
standalone: true,
templateUrl: './collections-dialog.component.html',
styleUrls: ['./collections-dialog.component.scss'],
imports: [
CommonModule,
MatDialogModule,
MatButtonModule,
MatPaginatorModule,
DateFormatPipe
]
})
export class CollectionsDialogComponent {
readonly data = inject<CollectionsDialogData>(MAT_DIALOG_DATA);
private dialogRef = inject(MatDialogRef<CollectionsDialogComponent>);
readonly pageSizeOptions = [
10,
25,
50,
100
];
pageIndex = 0;
pageSize = 10;
get entries(): any[] {
return this.data.entries() || [];
}
/** The lines on the page being looked at. */
get pageEntries(): any[] {
const start = this.pageIndex * this.pageSize;
return this.entries.slice(start, start + this.pageSize);
}
onPage(event: PageEvent): void {
this.pageIndex = event.pageIndex;
this.pageSize = event.pageSize;
}
refresh(): void {
// A reloaded list may be shorter than the page being looked at.
this.pageIndex = 0;
this.data.refresh();
}
/** Closes with the line clicked, for the screen to show whose account it is. */
pick(entry: any): void {
this.dialogRef.close(entry);
}
close(): void {
this.dialogRef.close();
}
}
@@ -11,14 +11,20 @@
<button mat-stroked-button [routerLink]="['../']" class="dte-back"> <button mat-stroked-button [routerLink]="['../']" class="dte-back">
<i class="fas fa-arrow-left"></i> Deposits <i class="fas fa-arrow-left"></i> Deposits
</button> </button>
<div class="dte-header__icon"><i class="fas fa-coins"></i></div>
<div class="dte-header__text">
<h2 class="dte-header__title">Daily Transaction Detail</h2> <h2 class="dte-header__title">Daily Transaction Detail</h2>
<p class="dte-header__sub">
An agent's round - what was collected, the cash handed in and applied - and transfers between accounts.
</p>
</div>
</div> </div>
<div class="dte-card"> <div class="dte-card">
<form [formGroup]="entryForm"> <form [formGroup]="entryForm">
<div class="dte-info-panels"> <div class="dte-info-panels">
<div class="dte-info-panel"> <div class="dte-info-panel">
<div class="dte-info-panel__title">Agent Information</div> <div class="dte-info-panel__title"><i class="fas fa-user-tie"></i>Agent Information</div>
<dl> <dl>
<dt>Name</dt> <dt>Name</dt>
<dd>{{ agentInfo.name || '—' }}</dd> <dd>{{ agentInfo.name || '—' }}</dd>
@@ -28,17 +34,22 @@
<dd>{{ agentInfo.city || '—' }}</dd> <dd>{{ agentInfo.city || '—' }}</dd>
<dt>A/C</dt> <dt>A/C</dt>
<dd>{{ agentInfo.accountNo || '—' }}</dd> <dd>{{ agentInfo.accountNo || '—' }}</dd>
<!-- One line for each guarantor the agent actually has - not two fixed lines, one of them empty. -->
@for (guarantor of agentInfo.guarantors; track $index; let i = $index) {
<dt>{{ agentInfo.guarantors.length > 1 ? 'Guarantor ' + (i + 1) : 'Guarantor' }}</dt>
<dd>{{ guarantor }}</dd>
} @empty {
<dt>Guarantor</dt> <dt>Guarantor</dt>
<dd>{{ agentInfo.guarantor1 || '—' }}</dd> <dd>—</dd>
<dt>Guarantor</dt> }
<dd>{{ agentInfo.guarantor2 || '—' }}</dd>
<dt>Curr Bal</dt> <dt>Curr Bal</dt>
<dd>{{ agentInfo.currentBalance | number: '1.2-2' }}</dd> <dd>{{ agentInfo.currentBalance | number: '1.2-2' }}</dd>
</dl> </dl>
</div> </div>
<div class="dte-info-panel"> <!-- Wider than the agent's card, with its details two pairs to a line, so a party's account is read without scrolling. -->
<div class="dte-info-panel__title">Party Information</div> <div class="dte-info-panel dte-info-panel--party">
<div class="dte-info-panel__title"><i class="fas fa-user"></i>Party Information</div>
@if (partyInfo) { @if (partyInfo) {
<dl> <dl>
<dt>Client Id</dt> <dt>Client Id</dt>
@@ -79,15 +90,62 @@
</dl> </dl>
} @else { } @else {
<p class="dte-info-panel__hint"> <p class="dte-info-panel__hint">
Pick a party in a collection row below — this panel shows whose account it is and what the balance <i class="fas fa-info-circle"></i>
Click an entry in the collections list below — this panel shows whose account it is and what the balance
becomes. becomes.
</p> </p>
} }
</div> </div>
</div> </div>
<!-- The round at a glance, once an agent is picked: nothing here is typed or saved. -->
@if (agentInfo.name) {
<div class="dte-kpis">
<div class="dte-kpi">
<span class="dte-kpi__label"><i class="fas fa-users"></i>Parties</span>
<strong class="dte-kpi__value">{{ agentParties.length }}</strong>
<span class="dte-kpi__hint">to collect from</span>
</div>
@if (isPartyTran) {
<div class="dte-kpi">
<span class="dte-kpi__label"><i class="fas fa-list-ol"></i>Entries</span>
<strong class="dte-kpi__value">{{ pendingEntries.length }}</strong>
<span class="dte-kpi__hint">written down, not handed in</span>
</div>
<div class="dte-kpi dte-kpi--accent">
<span class="dte-kpi__label"><i class="fas fa-rupee-sign"></i>To count</span>
<strong class="dte-kpi__value">{{ pendingTotal | number: '1.2-2' }}</strong>
<span class="dte-kpi__hint">the agent should hand over</span>
</div>
}
<!-- Agent Tran: what the agent owes on everything written down, what the cashier has taken in, and what is left. -->
@if (isAgentTran) {
<div class="dte-kpi dte-kpi--owed">
<span class="dte-kpi__label"><i class="fas fa-file-invoice"></i>To take</span>
<strong class="dte-kpi__value">{{ agentOwed | number: '1.2-2' }}</strong>
<span class="dte-kpi__hint">written down, not handed in</span>
</div>
<div class="dte-kpi dte-kpi--received">
<span class="dte-kpi__label"><i class="fas fa-check-circle"></i>Received (approved)</span>
<strong class="dte-kpi__value">{{ agentReceived | number: '1.2-2' }}</strong>
<span class="dte-kpi__hint">taken in by the cashier</span>
</div>
<div class="dte-kpi" [class.dte-kpi--due]="agentBalance > 0">
<span class="dte-kpi__label"><i class="fas fa-hourglass-half"></i>Still to take</span>
<strong class="dte-kpi__value">{{ agentBalance | number: '1.2-2' }}</strong>
<span class="dte-kpi__hint">left to receive</span>
</div>
}
<div class="dte-kpi">
<span class="dte-kpi__label"><i class="fas fa-wallet"></i>Agent Balance</span>
<strong class="dte-kpi__value">{{ agentInfo.currentBalance | number: '1.2-2' }}</strong>
<span class="dte-kpi__hint">held by {{ agentInfo.name }}</span>
</div>
</div>
}
<div class="dte-controls"> <div class="dte-controls">
<mat-form-field appearance="outline" class="dte-field dte-field--agent"> <mat-form-field appearance="outline" subscriptSizing="dynamic" class="dte-field dte-field--agent">
<mat-label>Agent</mat-label> <mat-label>Agent</mat-label>
<mat-select required formControlName="agentId" (selectionChange)="loadAgent()"> <mat-select required formControlName="agentId" (selectionChange)="loadAgent()">
@for (agent of selectableAgents; track agent.id) { @for (agent of selectableAgents; track agent.id) {
@@ -112,7 +170,7 @@
} }
</mat-form-field> </mat-form-field>
<mat-form-field appearance="outline" class="dte-field"> <mat-form-field appearance="outline" subscriptSizing="dynamic" class="dte-field">
<mat-label>Date</mat-label> <mat-label>Date</mat-label>
<!-- The date picks out a round, not just a posting date: a Party Tran settles whatever <!-- The date picks out a round, not just a posting date: a Party Tran settles whatever
the agent wrote down for that day, so changing it has to fetch that day's list. --> the agent wrote down for that day, so changing it has to fetch that day's list. -->
@@ -127,7 +185,7 @@
<mat-datepicker #txnDatePicker></mat-datepicker> <mat-datepicker #txnDatePicker></mat-datepicker>
</mat-form-field> </mat-form-field>
<mat-form-field appearance="outline" class="dte-field"> <mat-form-field appearance="outline" subscriptSizing="dynamic" class="dte-field">
<mat-label>Tran. Type</mat-label> <mat-label>Tran. Type</mat-label>
<mat-select required formControlName="transactionType"> <mat-select required formControlName="transactionType">
@for (type of transactionTypes; track type.label) { @for (type of transactionTypes; track type.label) {
@@ -140,100 +198,43 @@
} }
</mat-select> </mat-select>
</mat-form-field> </mat-form-field>
</div>
<!-- <!-- Agent Tran: the cashier's receipts are applied to the customers from here - the dialog shows the days and receipts. -->
What the agent wrote down on their round, waiting to be counted.
This used to be an empty grid the clerk typed the round into, and typing it posted it: a
customer was credited the moment a line was keyed, whether or not the cash behind it ever
reached the branch. The agent records their own collections now, and what the clerk does here
is agree with them - so there is nothing to type, only a list to check against a bag of cash.
-->
@if (isPartyTran && agentInfo.name) {
<div class="dte-section">
<div class="dte-section__header">
<i class="fas fa-list"></i>
<h3>Collections up to {{ entryForm.get('transactionDate')?.value | dateFormat }}</h3>
<button mat-stroked-button type="button" class="dte-add-row" (click)="loadPendingEntries()">
<i class="fas fa-sync"></i> Refresh
</button>
</div>
@if (pendingLoading) {
<p class="dte-empty">Reading this agent's entries...</p>
} @else if (pendingEntries.length === 0) {
<p class="dte-empty">{{ agentInfo.name }} has nothing outstanding up to this date.</p>
} @else {
@for (entry of pendingEntries; track entry.id) {
<div class="dte-pending-row">
<!-- The day the agent took it, on the line itself. All the lines carry the date
chosen above, but a row that says so can be read, printed or shown to the agent
on its own without the header to explain it. -->
<span class="dte-pending-row__date">{{ entry.entryDate | dateFormat }}</span>
<span class="dte-pending-row__code">{{ entry.partyCode }}</span>
<span class="dte-pending-row__name">{{ entry.partyName }}</span>
<span class="dte-pending-row__mode"
>{{ entry.transactionMode }}
@if (entry.advanceDays) {
· {{ entry.advanceDays }}d advance
}
@if (entry.arrearsDays) {
· {{ entry.arrearsDays }}d previous
}
</span>
<span class="dte-pending-row__amount">{{ entry.amount | number: '1.2-2' }}</span>
</div>
}
<div class="dte-total">
<span>{{ pendingEntries.length }} {{ pendingEntries.length === 1 ? 'entry' : 'entries' }}</span>
<strong>{{ pendingTotal | number: '1.2-2' }}</strong>
</div>
<!--
The figure the clerk counts against, said in words rather than left as a column total -
it is the one number they are acting on, and the rest of the panel is working out.
It says what the agent wrote down and stops there. The money side - what is owed, what the
cashier has taken in, and applying it - is Agent Tran, so each type has one job.
-->
<p class="dte-expected dte-expected--note">
Everything {{ agentInfo.name }} wrote down and has not handed in yet, up to this date, customer by
customer. The cash and its receipts are under Agent Tran.
</p>
}
</div>
}
<!--
The agent's money: what they owe on everything written down, what the cashier has taken in
against it, and what is left.
This used to be a form the clerk typed an amount into to take the agent's cash in here. The
cash is taken at the cashier's counter now, into the agent's own GL account, and one way to take
the same cash in is the only safe number of ways - so there is nothing to type. What is done here
is applying the cashier's receipts to the customers, in Apply DDS Receipt.
-->
@if (isAgentTran && agentInfo.name) { @if (isAgentTran && agentInfo.name) {
<div class="dte-section"> <div class="dte-agent-actions">
<div class="dte-section__header"> <button mat-stroked-button type="button" class="dte-refresh-btn" (click)="loadAgentMoney()">
<i class="fas fa-hand-holding-usd"></i>
<h3>Agent's cash</h3>
<button mat-stroked-button type="button" class="dte-add-row" (click)="loadAgentMoney()">
<i class="fas fa-sync"></i> Refresh <i class="fas fa-sync"></i> Refresh
</button> </button>
<button <button mat-raised-button color="primary" type="button" class="dte-apply-btn" (click)="openApplyReceipt()">
mat-raised-button
color="primary"
type="button"
class="dte-apply-receipt"
(click)="openApplyReceipt()"
>
<i class="fas fa-receipt"></i> Apply DDS Receipt <i class="fas fa-receipt"></i> Apply DDS Receipt
</button> </button>
</div> </div>
}
<!-- The agent's written-down round, in a window of its own and paged. -->
@if (isPartyTran && agentInfo.name) {
<button mat-stroked-button type="button" class="dte-view-btn" (click)="openCollections()">
<i class="fas fa-list"></i> View Collections
<span class="dte-view-btn__count">{{ pendingEntries.length }}</span>
</button>
}
</div>
<!--
What the agent wrote down on their round, waiting to be counted, is no longer listed down this
page - a long round pushed everything else out of sight. It opens from View Collections above, in a
window of its own and paged, and a line clicked there shows that customer in Party Information.
-->
<!--
The agent's money used to have a section of its own down the page: what they owe on everything
written down, what the cashier has taken in, what is left, then the outstanding days and the
cashier's receipts. Apply DDS Receipt shows the same days and receipts, so the section is gone -
the three figures sit in the summary above, the button is beside the Date, and what is below is
only what the dialog does not say: which GL the cash goes into and anything that looks wrong.
-->
@if (isAgentTran && agentInfo.name) {
<div class="dte-notes">
@if (!agentGlCode) { @if (!agentGlCode) {
<p class="dte-expected dte-expected--note"> <p class="dte-expected dte-expected--note">
{{ agentInfo.name }} has no GL account, so the cashier has nowhere to take their cash. Set one on Agent {{ agentInfo.name }} has no GL account, so the cashier has nowhere to take their cash. Set one on Agent
@@ -246,21 +247,6 @@
takes {{ agentInfo.name }}'s cash into this GL (Entry Type DDS). takes {{ agentInfo.name }}'s cash into this GL (Entry Type DDS).
</p> </p>
} }
<div class="dte-money">
<div class="dte-money__box">
<span class="dte-money__label">To take</span>
<strong>{{ agentOwed | number: '1.2-2' }}</strong>
</div>
<div class="dte-money__box">
<span class="dte-money__label">Received (approved)</span>
<strong>{{ agentReceived | number: '1.2-2' }}</strong>
</div>
<div class="dte-money__box" [class.dte-money__box--due]="agentBalance > 0">
<span class="dte-money__label">Still to take</span>
<strong>{{ agentBalance | number: '1.2-2' }}</strong>
</div>
</div>
@if (agentReceivedBeyondDay > 0) { @if (agentReceivedBeyondDay > 0) {
<p class="dte-expected dte-expected--note"> <p class="dte-expected dte-expected--note">
{{ agentReceivedBeyondDay | number: '1.2-2' }} received at the counter is more than everything outstanding {{ agentReceivedBeyondDay | number: '1.2-2' }} received at the counter is more than everything outstanding
@@ -273,66 +259,6 @@
{{ agentWaiting | number: '1.2-2' }} more is at the counter awaiting the manager's approval. {{ agentWaiting | number: '1.2-2' }} more is at the counter awaiting the manager's approval.
</p> </p>
} }
<h4 class="dte-subhead">
Outstanding entries up to {{ entryForm.get('transactionDate')?.value | dateFormat }}
</h4>
@if (agentMoneyLoading) {
<p class="dte-empty">Reading this agent's entries...</p>
} @else if (agentDays.length === 0) {
<p class="dte-empty">{{ agentInfo.name }} has nothing outstanding.</p>
} @else {
@for (day of agentDays; track day.date) {
<div class="dte-pending-row dte-pending-row--day" (click)="toggleAgentDay(day.date)">
<span class="dte-pending-row__date">{{ day.date | dateFormat }}</span>
<span class="dte-pending-row__name">
{{ day.entries.length }} {{ day.entries.length === 1 ? 'entry' : 'entries' }}
</span>
<span class="dte-pending-row__amount">{{ day.total | number: '1.2-2' }}</span>
<i [class]="agentOpenDay === day.date ? 'fas fa-chevron-up' : 'fas fa-chevron-down'"></i>
</div>
@if (agentOpenDay === day.date) {
<div class="dte-day-lines">
@for (entry of day.entries; track entry.id) {
<div class="dte-pending-row">
<span class="dte-pending-row__code">{{ entry.partyCode }}</span>
<span class="dte-pending-row__name">{{ entry.partyName }}</span>
<span class="dte-pending-row__mode"
>{{ entry.transactionMode }}
@if (entry.advanceDays) {
· {{ entry.advanceDays }}d advance
}
@if (entry.arrearsDays) {
· {{ entry.arrearsDays }}d previous
}
</span>
<span class="dte-pending-row__amount">{{ entry.amount | number: '1.2-2' }}</span>
</div>
}
</div>
}
}
}
<h4 class="dte-subhead">Cashier receipts</h4>
@if (agentReceipts.length === 0) {
<p class="dte-empty">
No DDS receipt on {{ agentInfo.name }}'s GL account{{ agentGlCode ? ' ' + agentGlCode : '' }} waiting to
be applied.
</p>
} @else {
@for (receipt of agentReceipts; track receipt.id) {
<div class="dte-pending-row">
<span class="dte-pending-row__code">{{ receipt.voucherNo }}</span>
<span class="dte-pending-row__date">{{ receipt.txnDate | dateFormat }}</span>
<span class="dte-pending-row__name">{{ receipt.cashierName || '—' }}</span>
<span class="dte-status" [class.dte-status--ok]="receipt.status === 'APPROVED'">
{{ receipt.status === 'APPROVED' ? 'Approved' : 'Awaiting manager' }}
</span>
<span class="dte-pending-row__amount">{{ receipt.amount | number: '1.2-2' }}</span>
</div>
}
}
</div> </div>
} }
@@ -342,7 +268,11 @@
<i class="fas fa-exchange-alt"></i> <i class="fas fa-exchange-alt"></i>
<h3>Withdraw Deposite</h3> <h3>Withdraw Deposite</h3>
</div> </div>
<div class="dte-grid"> <div class="dte-grid dte-grid--transfer">
<!-- The account the money goes into. -->
<div class="dte-party-card dte-party-card--to">
<div class="dte-party-card__title"><i class="fas fa-arrow-circle-down"></i>Money goes into</div>
<div class="dte-party-card__fields">
<mat-form-field appearance="outline" class="dte-field"> <mat-form-field appearance="outline" class="dte-field">
<mat-label>Creditor Party Code</mat-label> <mat-label>Creditor Party Code</mat-label>
<input <input
@@ -356,7 +286,10 @@
placeholder="Search by code or name…" placeholder="Search by code or name…"
autocomplete="off" autocomplete="off"
/> />
<mat-icon matSuffix class="dte-list-arrow" (click)="$event.stopPropagation(); creditorTrigger.openPanel()" <mat-icon
matSuffix
class="dte-list-arrow"
(click)="$event.stopPropagation(); creditorTrigger.openPanel()"
>arrow_drop_down</mat-icon >arrow_drop_down</mat-icon
> >
<mat-autocomplete #creditorAuto="matAutocomplete" panelWidth="min(30rem, 92vw)"> <mat-autocomplete #creditorAuto="matAutocomplete" panelWidth="min(30rem, 92vw)">
@@ -379,7 +312,13 @@
<mat-label>Creditor Curr Bal</mat-label> <mat-label>Creditor Curr Bal</mat-label>
<input matInput formControlName="creditorPartyBalance" readonly /> <input matInput formControlName="creditorPartyBalance" readonly />
</mat-form-field> </mat-form-field>
</div>
</div>
<!-- The account the money is taken from. -->
<div class="dte-party-card dte-party-card--from">
<div class="dte-party-card__title"><i class="fas fa-arrow-circle-up"></i>Money is taken from</div>
<div class="dte-party-card__fields">
<mat-form-field appearance="outline" class="dte-field"> <mat-form-field appearance="outline" class="dte-field">
<mat-label>Debit Party Code</mat-label> <mat-label>Debit Party Code</mat-label>
<input <input
@@ -393,7 +332,10 @@
placeholder="Search by code or name…" placeholder="Search by code or name…"
autocomplete="off" autocomplete="off"
/> />
<mat-icon matSuffix class="dte-list-arrow" (click)="$event.stopPropagation(); debitTrigger.openPanel()" <mat-icon
matSuffix
class="dte-list-arrow"
(click)="$event.stopPropagation(); debitTrigger.openPanel()"
>arrow_drop_down</mat-icon >arrow_drop_down</mat-icon
> >
<mat-autocomplete #debitAuto="matAutocomplete" panelWidth="min(30rem, 92vw)"> <mat-autocomplete #debitAuto="matAutocomplete" panelWidth="min(30rem, 92vw)">
@@ -420,6 +362,8 @@
<mat-label>Debit Curr Bal</mat-label> <mat-label>Debit Curr Bal</mat-label>
<input matInput formControlName="debitPartyBalance" readonly /> <input matInput formControlName="debitPartyBalance" readonly />
</mat-form-field> </mat-form-field>
</div>
</div>
<mat-form-field appearance="outline" class="dte-field"> <mat-form-field appearance="outline" class="dte-field">
<mat-label>Amount</mat-label> <mat-label>Amount</mat-label>
File diff suppressed because it is too large Load Diff
@@ -19,6 +19,7 @@ import { ConfirmationDialogComponent } from 'app/shared/confirmation-dialog/conf
/** Custom Components */ /** Custom Components */
import { DayCollectionDialogComponent } from './day-collection-dialog/day-collection-dialog.component'; import { DayCollectionDialogComponent } from './day-collection-dialog/day-collection-dialog.component';
import { NoticeDialogComponent } from './notice-dialog/notice-dialog.component'; import { NoticeDialogComponent } from './notice-dialog/notice-dialog.component';
import { CollectionsDialogComponent } from './collections-dialog/collections-dialog.component';
import { import {
ApplyDdsReceiptDialogComponent, ApplyDdsReceiptDialogComponent,
EntryDay, EntryDay,
@@ -152,6 +153,8 @@ export class DailyTransactionEntryComponent implements OnInit {
accountNo: '', accountNo: '',
guarantor1: '', guarantor1: '',
guarantor2: '', guarantor2: '',
// Every guarantor the agent has, by name - the panel lists as many as there are rather than two fixed lines.
guarantors: [] as string[],
currentBalance: 0 currentBalance: 0
}; };
@@ -505,6 +508,34 @@ export class DailyTransactionEntryComponent implements OnInit {
* agent still has outstanding. Its own screen, like Apply Additional Shares, so the list above stays * 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. * what it is - what to take from the agent - and the applying is done in one deliberate place.
*/ */
/**
* Opens what the agent wrote down up to the chosen day, in a window of its own and paged - it used to
* run down the page, where a long round pushed everything else out of sight. A line clicked there
* shows that customer in the Party Information panel.
*/
openCollections(): void {
this.dialog
.open(CollectionsDialogComponent, {
width: '820px',
maxWidth: '96vw',
autoFocus: false,
data: {
agentName: this.agentInfo.name,
dateText: this.dateUtils.formatDate(this.entryForm.get('transactionDate')?.value, 'dd MMMM yyyy'),
entries: () => this.pendingEntries,
total: () => this.pendingTotal,
loading: () => this.pendingLoading,
refresh: () => this.loadPendingEntries()
}
})
.afterClosed()
.subscribe((entry: any) => {
if (entry) {
this.showPartyOfEntry(entry);
}
});
}
openApplyReceipt(): void { openApplyReceipt(): void {
const agentId = this.entryForm.get('agentId')?.value; const agentId = this.entryForm.get('agentId')?.value;
if (!agentId) { if (!agentId) {
@@ -513,7 +544,7 @@ export class DailyTransactionEntryComponent implements OnInit {
const picked = this.agents.find((agent: any) => agent.id === agentId); const picked = this.agents.find((agent: any) => agent.id === agentId);
this.dialog this.dialog
.open(ApplyDdsReceiptDialogComponent, { .open(ApplyDdsReceiptDialogComponent, {
width: '780px', width: '700px',
maxWidth: '96vw', maxWidth: '96vw',
panelClass: 'apply-collection-dialog-panel', panelClass: 'apply-collection-dialog-panel',
data: { data: {
@@ -799,6 +830,7 @@ export class DailyTransactionEntryComponent implements OnInit {
accountNo: agent.agentSavingAccountNo || '', accountNo: agent.agentSavingAccountNo || '',
guarantor1: guarantors[0]?.guarantorName || '', guarantor1: guarantors[0]?.guarantorName || '',
guarantor2: guarantors[1]?.guarantorName || '', guarantor2: guarantors[1]?.guarantorName || '',
guarantors: guarantors.map((guarantor: any) => guarantor?.guarantorName).filter((name: any) => !!name),
currentBalance: agent.agentCurrentBalance || 0 currentBalance: agent.agentCurrentBalance || 0
}; };
this.agentGlCode = agent.glAccountCode || null; this.agentGlCode = agent.glAccountCode || null;
@@ -813,6 +845,7 @@ export class DailyTransactionEntryComponent implements OnInit {
accountNo: '', accountNo: '',
guarantor1: '', guarantor1: '',
guarantor2: '', guarantor2: '',
guarantors: [],
currentBalance: 0 currentBalance: 0
}; };
} }
@@ -890,11 +923,29 @@ export class DailyTransactionEntryComponent implements OnInit {
row.get('balanceAfterTransaction')?.setValue(current + amount, { emitEvent: false }); row.get('balanceAfterTransaction')?.setValue(current + amount, { emitEvent: false });
} }
/**
* The entry in the collections list the Party Information panel is showing, if one was clicked. The
* agent writes the round down on their own screen now, so there is no row here to key a party into -
* the panel is filled from the line the clerk is looking at instead.
*/
selectedEntry: any = null;
/** Shows whose account a written-down entry is, and what its balance becomes with the entry on it. */
showPartyOfEntry(entry: any): void {
this.selectedEntry = entry;
this.activeRow = -1;
this.partyInfo = this.agentParties.find((party) => String(party.partyCode) === String(entry?.partyCode)) || null;
}
/** The balance the party panel shows - live against whatever the active row currently holds. */ /** The balance the party panel shows - live against whatever the active row currently holds. */
get partyBalanceAfter(): number { get partyBalanceAfter(): number {
if (!this.partyInfo || this.activeRow < 0) { if (!this.partyInfo) {
return 0; return 0;
} }
if (this.activeRow < 0) {
// No keyed row: it is the entry picked from the list that is being added to the balance.
return Number(this.partyInfo.currentBalance || 0) + Number(this.selectedEntry?.amount || 0);
}
const row = this.collections.at(this.activeRow); const row = this.collections.at(this.activeRow);
return Number(this.partyInfo.currentBalance || 0) + Number(row?.get('amount')?.value || 0); return Number(this.partyInfo.currentBalance || 0) + Number(row?.get('amount')?.value || 0);
} }
@@ -6,6 +6,8 @@
* file, You can obtain one at http://mozilla.org/MPL/2.0/. * file, You can obtain one at http://mozilla.org/MPL/2.0/.
*/ */
/* stylelint-disable no-duplicate-selectors -- the compact-density pass below deliberately re-opens earlier rules; kept in cascade order */
.dcd-field { .dcd-field {
width: 100%; width: 100%;
} }
@@ -40,3 +42,34 @@
color: rgb(0 0 0 / 60%); color: rgb(0 0 0 / 60%);
} }
} }
// ── Compact density ──────────────────────────────────────────────────────────────────────────────────
// The same step smaller as the rest of the screen it opens from.
:host {
--mat-form-field-container-height: 38px;
--mat-form-field-container-vertical-padding: 8px;
--mat-form-field-container-text-size: 13px;
--mat-form-field-container-text-line-height: 20px;
--mat-form-field-outlined-label-text-populated-size: 11px;
--mat-form-field-subscript-text-size: 11px;
--mat-select-trigger-text-size: 13px;
font-size: 13px;
}
/* stylelint-disable-next-line selector-no-qualifying-type */
h2[mat-dialog-title] {
font-size: 15px;
}
.dcd-amount {
padding: 8px 12px;
&__figure {
font-size: 1.1rem;
}
&__basis {
font-size: 0.74rem;
}
}
@@ -6,6 +6,8 @@
* file, You can obtain one at http://mozilla.org/MPL/2.0/. * file, You can obtain one at http://mozilla.org/MPL/2.0/.
*/ */
/* stylelint-disable no-duplicate-selectors -- the compact-density pass below deliberately re-opens earlier rules; kept in cascade order */
.nd-head { .nd-head {
display: flex; display: flex;
align-items: center; align-items: center;
@@ -42,3 +44,22 @@
line-height: 1.55; line-height: 1.55;
color: rgb(0 0 0 / 70%); color: rgb(0 0 0 / 70%);
} }
// ── Compact density ──────────────────────────────────────────────────────────────────────────────────
.nd-head {
padding: 14px 20px 2px;
&__ic {
width: 30px;
height: 30px;
}
&__title {
font-size: 0.95rem;
}
}
.nd-message {
font-size: 0.82rem;
line-height: 1.5;
}
@@ -54,27 +54,33 @@
</div> </div>
</div> </div>
<!-- Tabs --> <!-- Pill-style tab navigation, as the savings account page has it -->
<div class="dav-tabs"> <nav mat-tab-nav-bar class="dav-tabs" [tabPanel]="tabPanel">
<button class="dav-tab" [class.dav-tab--on]="tab === 'general'" (click)="tab = 'general'"> <a mat-tab-link class="dav-tab" [active]="tab === 'general'" (click)="tab = 'general'">
<fa-icon icon="info-circle"></fa-icon> General <fa-icon icon="info-circle" class="dav-tab__ic"></fa-icon>
</button> <span>{{ 'labels.inputs.General' | translate }}</span>
<button class="dav-tab" [class.dav-tab--on]="tab === 'transactions'" (click)="tab = 'transactions'"> </a>
<fa-icon icon="exchange-alt"></fa-icon> Transactions <a mat-tab-link class="dav-tab" [active]="tab === 'transactions'" (click)="tab = 'transactions'">
</button> <fa-icon icon="exchange-alt" class="dav-tab__ic"></fa-icon>
<button class="dav-tab" [class.dav-tab--on]="tab === 'passbook'" (click)="tab = 'passbook'"> <span>{{ 'labels.inputs.Transactions' | translate }}</span>
<fa-icon icon="book"></fa-icon> Passbook </a>
</button> <a mat-tab-link class="dav-tab" [active]="tab === 'passbook'" (click)="tab = 'passbook'">
<button class="dav-tab" [class.dav-tab--on]="tab === 'nominees'" (click)="tab = 'nominees'"> <fa-icon icon="book" class="dav-tab__ic"></fa-icon>
<fa-icon icon="user"></fa-icon> Nominees <span>{{ 'labels.inputs.Passbook' | translate }}</span>
</button> </a>
<a mat-tab-link class="dav-tab" [active]="tab === 'nominees'" (click)="tab = 'nominees'">
<fa-icon icon="user-shield" class="dav-tab__ic"></fa-icon>
<span>Nominees</span>
</a>
@if (account.status === 'CLOSED') { @if (account.status === 'CLOSED') {
<button class="dav-tab" [class.dav-tab--on]="tab === 'closure'" (click)="tab = 'closure'"> <a mat-tab-link class="dav-tab" [active]="tab === 'closure'" (click)="tab = 'closure'">
<fa-icon icon="lock"></fa-icon> Closure <fa-icon icon="lock" class="dav-tab__ic"></fa-icon>
</button> <span>Closure</span>
</a>
} }
</div> </nav>
<mat-tab-nav-panel #tabPanel class="dav-body">
<!-- Closure: how a closed account was closed and where its money went --> <!-- Closure: how a closed account was closed and where its money went -->
@if (tab === 'closure') { @if (tab === 'closure') {
@if (!closure) { @if (!closure) {
@@ -172,7 +178,9 @@
</div> </div>
} }
} @else if (closure.destination === 'SUNDRY') { } @else if (closure.destination === 'SUNDRY') {
<div class="dav-row"><span>Parked In</span><strong>Sundry (customer has no account here)</strong></div> <div class="dav-row">
<span>Parked In</span><strong>Sundry (customer has no account here)</strong>
</div>
<div class="dav-row"> <div class="dav-row">
<span>GL Account</span <span>GL Account</span
><strong class="dav-mono">{{ closure.sundryGl?.glCode }} · {{ closure.sundryGl?.glName }}</strong> ><strong class="dav-mono">{{ closure.sundryGl?.glCode }} · {{ closure.sundryGl?.glName }}</strong>
@@ -337,170 +345,356 @@
</div> </div>
} }
<!-- Transactions --> <!-- Transactions: laid out as the savings account's Transactions tab -->
@if (tab === 'transactions') { @if (tab === 'transactions') {
<div class="dav-card"> <div class="txn-tab">
<div class="dav-toolbar"> <div class="txn-tab__card" [hidden]="!hasTransactions">
<div class="dav-datepick"> <div class="txn-tab__toolbar">
<label class="txn-tab__date">
<span>From</span> <span>From</span>
<input type="date" [formControl]="fromDateControl" [max]="today" /> <input type="date" [formControl]="fromDateControl" [max]="today" />
</div> </label>
<div class="dav-datepick"> <label class="txn-tab__date">
<span>To</span> <span>To</span>
<input type="date" [formControl]="toDateControl" [max]="today" /> <input type="date" [formControl]="toDateControl" [max]="today" />
</div> </label>
<button class="dav-btn" (click)="showToday()"><fa-icon icon="clock"></fa-icon> Today</button> <button mat-stroked-button type="button" class="txn-tab__btn" (click)="showToday()">
<fa-icon icon="clock"></fa-icon> Today
</button>
@if (hasDateFilters) { @if (hasDateFilters) {
<button class="dav-btn" (click)="clearDateFilters()"><fa-icon icon="times"></fa-icon> Clear</button> <button mat-stroked-button type="button" class="txn-tab__btn" (click)="clearDateFilters()">
<fa-icon icon="times"></fa-icon> {{ 'labels.buttons.Clear' | translate }}
</button>
} }
<div class="dav-totals"> <span class="txn-tab__spacer"></span>
<span class="dav-cr">Credits ₹{{ totalCredits | formatNumber }}</span> <span class="txn-tab__total txn-tab__total--cr"
<span class="dav-dr">Debits ₹{{ totalDebits | formatNumber }}</span> >Credits {{ totalCredits | currency: 'INR' : 'symbol-narrow' : '1.2-2' }}</span
</div> >
<button class="dav-btn dav-btn--primary" (click)="exportCsv()" [disabled]="shownTransactions.length === 0"> <span class="txn-tab__total txn-tab__total--dr"
Export >Debits {{ totalDebits | currency: 'INR' : 'symbol-narrow' : '1.2-2' }}</span
>
<button
mat-flat-button
type="button"
class="txn-tab__export"
(click)="exportCsv()"
[disabled]="shownTransactions.length === 0"
>
{{ 'labels.buttons.Export' | translate }}
</button> </button>
</div> </div>
@if (shownTransactions.length === 0) { <table mat-table [dataSource]="txnDataSource" matSort>
<div class="dav-state"> <ng-container matColumnDef="row">
<fa-icon icon="inbox" size="2x"></fa-icon> <th mat-header-cell *matHeaderCellDef mat-sort-header>#</th>
<span>{{ <td mat-cell *matCellDef="let txn; let idx = index" (click)="viewTransaction(txn)">
hasDateFilters {{ idx + 1 }}
? 'Nothing moved on this account in these dates'
: 'Nothing has been credited to this account yet'
}}</span>
</div>
} @else {
<div class="dav-table-wrap">
<table class="dav-table">
<thead>
<tr>
<th>#</th>
<th>ID</th>
<th>Transaction Date</th>
<th>Transaction Type</th>
<th>Voucher</th>
<th>Description</th>
<th class="dav-right">Debit</th>
<th class="dav-right">Credit</th>
<th class="dav-right">Balance</th>
</tr>
</thead>
<tbody>
@for (txn of shownTransactions; track txn.id; let i = $index) {
<tr>
<td>{{ i + 1 }}</td>
<td>{{ txn.id }}</td>
<td>{{ txn.transactionDate | date: 'dd MMMM yyyy' }}</td>
<td>{{ label(txn.transactionType) }}</td>
<td class="dav-mono">{{ txn.voucherNo || '—' }}</td>
<td class="dav-desc">{{ txn.description }}</td>
<td class="dav-right">
{{ txn.direction === 'DEBIT' ? '₹' + (txn.amount | formatNumber) : 'N/A' }}
</td> </td>
<td class="dav-right"> </ng-container>
{{ txn.direction === 'CREDIT' ? '₹' + (txn.amount | formatNumber) : 'N/A' }}
<ng-container matColumnDef="id">
<th mat-header-cell *matHeaderCellDef mat-sort-header>{{ 'labels.inputs.Id' | translate }}</th>
<td
mat-cell
*matCellDef="let txn"
class="td-select"
[ngClass]="txnRowClass(txn)"
(click)="viewTransaction(txn)"
>
{{ txn.id }}
</td> </td>
<td class="dav-right"> </ng-container>
<strong>₹{{ txn.balanceAfterTransaction || 0 | formatNumber }}</strong>
<ng-container matColumnDef="date">
<th mat-header-cell *matHeaderCellDef mat-sort-header>
{{ 'labels.inputs.Transaction Date' | translate }}
</th>
<td mat-cell *matCellDef="let txn" [ngClass]="txnRowClass(txn)" (click)="viewTransaction(txn)">
{{ txn.transactionDate | dateFormat }}
</td> </td>
</tr> </ng-container>
}
</tbody>
</table>
</div>
}
</div>
}
<!-- Passbook: the same entries oldest first, the way a passbook is written up, and printable --> <ng-container matColumnDef="transactionType">
@if (tab === 'passbook') { <th mat-header-cell *matHeaderCellDef mat-sort-header>
<div class="dav-card"> {{ 'labels.inputs.Transaction Type' | translate }}
<div class="dav-toolbar dav-noprint"> </th>
<div class="dav-datepick"> <td mat-cell *matCellDef="let txn" [ngClass]="txnRowClass(txn)" (click)="viewTransaction(txn)">
<span>From</span>
<input type="date" [formControl]="fromDateControl" [max]="today" />
</div>
<div class="dav-datepick">
<span>To</span>
<input type="date" [formControl]="toDateControl" [max]="today" />
</div>
@if (hasDateFilters) {
<button class="dav-btn" (click)="clearDateFilters()"><fa-icon icon="times"></fa-icon> Clear</button>
}
<button class="dav-btn dav-btn--primary dav-push" (click)="printPassbook()">
<fa-icon icon="print"></fa-icon> Print
</button>
</div>
<div class="dav-passbook" id="dds-passbook">
<div class="dav-pb-head">
<div>
<div class="dav-pb-title">DDS Passbook</div>
<div class="dav-pb-sub">{{ account.officeName }}</div>
</div>
<div class="dav-pb-grid">
<span>Account No</span><strong class="dav-mono">{{ account.accountNo }}</strong> <span>Party Code</span
><strong>{{ account.partyCode }}</strong> <span>Name</span
><strong>{{ account.clientName || account.partyName }}</strong> <span>Product</span
><strong>{{ account.subType }} · {{ account.interestRate }} %</strong> <span>Opened On</span
><strong>{{ account.openingDate | date: 'dd/MM/yyyy' }}</strong> <span>Agent</span
><strong>{{ account.agentCode }}</strong>
</div>
</div>
@if (passbookEntries.length === 0) {
<div class="dav-state">
<span>{{ hasDateFilters ? 'No entries in these dates' : 'No entries yet' }}</span>
</div>
} @else {
<table class="dav-table dav-pb-table">
<thead>
<tr>
<th>Date</th>
<th>Voucher</th>
<th>Particulars</th>
<th class="dav-right">Withdrawals</th>
<th class="dav-right">Deposits</th>
<th class="dav-right">Balance</th>
</tr>
</thead>
<tbody>
@for (txn of passbookEntries; track txn.id) {
<tr>
<td>{{ txn.transactionDate | date: 'dd/MM/yyyy' }}</td>
<td class="dav-mono">{{ txn.voucherNo || '' }}</td>
<td>
{{ label(txn.transactionType) }} {{ label(txn.transactionType) }}
@if (txn.description) { </td>
<span class="dav-pb-note">{{ txn.description }}</span> </ng-container>
<ng-container matColumnDef="debit">
<th mat-header-cell *matHeaderCellDef mat-sort-header>{{ 'labels.inputs.Debit' | translate }}</th>
<td mat-cell *matCellDef="let txn" [ngClass]="txnRowClass(txn)" (click)="viewTransaction(txn)">
{{ txn.direction === 'DEBIT' ? (txn.amount | currency: 'INR' : 'symbol-narrow' : '1.2-2') : 'N/A' }}
</td>
</ng-container>
<ng-container matColumnDef="credit">
<th mat-header-cell *matHeaderCellDef mat-sort-header>{{ 'labels.inputs.Credit' | translate }}</th>
<td mat-cell *matCellDef="let txn" [ngClass]="txnRowClass(txn)" (click)="viewTransaction(txn)">
{{ txn.direction === 'CREDIT' ? (txn.amount | currency: 'INR' : 'symbol-narrow' : '1.2-2') : 'N/A' }}
</td>
</ng-container>
<ng-container matColumnDef="balance">
<th mat-header-cell *matHeaderCellDef mat-sort-header>{{ 'labels.inputs.Balance' | translate }} ↑</th>
<td mat-cell *matCellDef="let txn" [ngClass]="txnRowClass(txn)" (click)="viewTransaction(txn)">
{{ txn.balanceAfterTransaction || 0 | currency: 'INR' : 'symbol-narrow' : '1.2-2' }}
</td>
</ng-container>
<ng-container matColumnDef="actions">
<th mat-header-cell class="center" *matHeaderCellDef>{{ 'labels.inputs.Actions' | translate }}</th>
<td mat-cell class="center" *matCellDef="let txn">
<button mat-icon-button [matMenuTriggerFor]="txnMenu" aria-label="Transaction actions">
<mat-icon>more_vert</mat-icon>
</button>
<mat-menu #txnMenu="matMenu">
<button mat-menu-item (click)="viewTransaction(txn)">
<mat-icon><fa-icon icon="eye" size="sm"></fa-icon></mat-icon>
<span>{{ 'labels.text.View Transaction' | translate }}</span>
</button>
<button mat-menu-item (click)="viewTransaction(txn, true)">
<mat-icon><fa-icon icon="receipt" size="sm"></fa-icon></mat-icon>
<span>{{ 'tooltips.View Receipts' | translate }}</span>
</button>
<button mat-menu-item [disabled]="!txn.journalTransactionId" (click)="viewJournalEntries(txn)">
<mat-icon><fa-icon icon="table-cells" size="sm"></fa-icon></mat-icon>
<span>{{ 'tooltips.View Journal Entries' | translate }}</span>
</button>
</mat-menu>
</td>
</ng-container>
<tr mat-header-row *matHeaderRowDef="txnColumns"></tr>
<tr mat-row *matRowDef="let row; columns: txnColumns" class="select-row"></tr>
<tr class="mat-mdc-row" *matNoDataRow>
<td class="mat-mdc-cell txn-tab__nodata" [attr.colspan]="txnColumns.length">
Nothing moved on this account in these dates
</td>
</tr>
</table>
<mat-paginator [pageSize]="50" [pageSizeOptions]="[50, 100, 200]" showFirstLastButtons></mat-paginator>
</div>
<div class="txn-tab__empty" [hidden]="hasTransactions">
<fa-icon icon="inbox" class="txn-tab__empty-ico"></fa-icon>
<div class="txn-tab__empty-title">
{{ 'labels.text.No transaction was found' | translate }}
</div>
<div class="txn-tab__empty-sub">Collections, interest and payouts will appear here.</div>
</div>
</div>
}
<!-- Passbook: laid out as the savings account's Passbook tab - oldest first, the way it is written up -->
@if (tab === 'passbook') {
<div class="passbook-container">
<div class="passbook-filter-bar no-print">
<div class="passbook-filter-form">
<mat-form-field appearance="outline" class="passbook-date-field" subscriptSizing="dynamic">
<mat-label>{{ 'labels.inputs.From Date' | translate }}</mat-label>
<input matInput type="date" [formControl]="fromDateControl" [max]="today" />
</mat-form-field>
<mat-form-field appearance="outline" class="passbook-date-field" subscriptSizing="dynamic">
<mat-label>{{ 'labels.inputs.To Date' | translate }}</mat-label>
<input matInput type="date" [formControl]="toDateControl" [max]="today" />
</mat-form-field>
@if (hasDateFilters) {
<button mat-stroked-button type="button" (click)="clearDateFilters()">
<mat-icon>clear</mat-icon>
{{ 'labels.buttons.Clear' | translate }}
</button>
}
<span class="passbook-filter-spacer"></span>
<button mat-raised-button color="accent" type="button" (click)="printPassbook()">
<mat-icon>print</mat-icon>
{{ 'labels.buttons.Print' | translate }}
</button>
</div>
</div>
<div class="statement-wrapper" id="dds-passbook">
<!-- Bank Header -->
<div class="bank-header">
<div class="bank-logo-area">
<div class="bank-logo-circle">
<mat-icon>account_balance</mat-icon>
</div>
<div class="bank-name-block">
<div class="bank-title">{{ bankName }}</div>
<div class="bank-subtitle">{{ bankSubtitle }}</div>
</div>
</div>
<div class="statement-label">
<div class="stmt-label-text">DDS PASSBOOK</div>
<div class="stmt-period">As on: {{ today | dateFormat }}</div>
</div>
</div>
<!-- Branch line -->
@if (branchName || ifscCode || micrCode) {
<div class="branch-line">
@if (branchName) {
<span>{{ branchName }} Branch</span>
}
@if (branchAddress) {
<span>{{ branchAddress }}</span>
}
@if (ifscCode) {
<span>IFSC: {{ ifscCode }}</span>
}
@if (micrCode) {
<span>MICR: {{ micrCode }}</span>
}
</div>
}
<!-- Account Details -->
<div class="account-details-band">
<div class="account-details-grid">
<div class="detail-block">
<div class="detail-label">Account Holder</div>
<div class="detail-value bold">{{ account.clientName || account.partyName | uppercase }}</div>
</div>
<div class="detail-block">
<div class="detail-label">Account Number</div>
<div class="detail-value mono bold">{{ account.accountNo }}</div>
</div>
<div class="detail-block">
<div class="detail-label">Party Code</div>
<div class="detail-value mono">{{ account.partyCode }}</div>
</div>
<div class="detail-block">
<div class="detail-label">Product</div>
<div class="detail-value">{{ account.subType }}</div>
</div>
<div class="detail-block">
<div class="detail-label">Account Opening Date</div>
<div class="detail-value">{{ account.openingDate | dateFormat }}</div>
</div>
<div class="detail-block">
<div class="detail-label">Status</div>
<div class="detail-value status-badge" [class.active]="account.status === 'ACTIVE'">
{{ account.status }}
</div>
</div>
<div class="detail-block">
<div class="detail-label">Agent</div>
<div class="detail-value">
{{ account.agentCode }}{{ account.agentName ? ' · ' + account.agentName : '' }}
</div>
</div>
<div class="detail-block">
<div class="detail-label">Interest Rate (p.a.)</div>
<div class="detail-value">{{ account.interestRate }}%</div>
</div>
<div class="detail-block">
<div class="detail-label">Maturity Date</div>
<div class="detail-value">{{ account.maturityDate | dateFormat }}</div>
</div>
</div>
</div>
<!-- Balance Summary Strip -->
<div class="balance-summary-strip">
<div class="balance-box">
<div class="balance-box-label">Current Balance</div>
<div class="balance-box-value">
{{ account.currentBalance || 0 | currency: 'INR' : 'symbol-narrow' : '1.2-2' }}
</div>
</div>
<div class="balance-divider"></div>
<div class="balance-box">
<div class="balance-box-label">Maturity Amount</div>
<div class="balance-box-value">
{{ account.maturityAmount || 0 | currency: 'INR' : 'symbol-narrow' : '1.2-2' }}
</div>
</div>
<div class="balance-divider"></div>
<div class="balance-box">
<div class="balance-box-label">Total Transactions</div>
<div class="balance-box-value">{{ passbookEntries.length }}</div>
</div>
</div>
<!-- Transactions Table -->
@if (passbookEntries.length > 0) {
<table class="statement-table">
<thead>
<tr>
<th class="col-sr">#</th>
<th class="col-date">Date</th>
<th class="col-desc">Description</th>
<th class="col-ref">Ref No.</th>
<th class="col-amount text-right">Debit (Dr)</th>
<th class="col-amount text-right">Credit (Cr)</th>
<th class="col-balance text-right">Balance (&#8377;)</th>
</tr>
</thead>
<tbody>
@for (txn of passbookEntries; track txn.id; let i = $index) {
<tr [class.debit-row]="txn.direction === 'DEBIT'" [class.credit-row]="txn.direction === 'CREDIT'">
<td class="col-sr text-center">{{ i + 1 }}</td>
<td class="col-date">{{ txn.transactionDate | dateFormat }}</td>
<td class="col-desc">
<span class="txn-desc">{{ label(txn.transactionType) }}</span>
</td>
<td class="col-ref text-muted mono">{{ txn.refNo || txn.id }}</td>
<td class="col-amount text-right debit-amount">
@if (txn.direction === 'DEBIT') {
{{ txn.amount | currency: 'INR' : 'symbol-narrow' : '1.2-2' }}
} }
</td> </td>
<td class="dav-right">{{ txn.direction === 'DEBIT' ? (txn.amount | formatNumber) : '' }}</td> <td class="col-amount text-right credit-amount">
<td class="dav-right">{{ txn.direction === 'CREDIT' ? (txn.amount | formatNumber) : '' }}</td> @if (txn.direction === 'CREDIT') {
<td class="dav-right"> {{ txn.amount | currency: 'INR' : 'symbol-narrow' : '1.2-2' }}
<strong>{{ txn.balanceAfterTransaction || 0 | formatNumber }}</strong> }
</td>
<td class="col-balance text-right balance-amount">
{{ txn.balanceAfterTransaction || 0 | currency: 'INR' : 'symbol-narrow' : '1.2-2' }}
</td> </td>
</tr> </tr>
} }
</tbody> </tbody>
<tfoot> <tfoot>
<tr> <tr class="total-row">
<td colspan="3"><strong>Total</strong></td> <td colspan="4" class="text-right total-label">Total / Closing Balance</td>
<td class="dav-right"> <td class="text-right total-label debit-amount">
<strong>{{ totalDebits | formatNumber }}</strong> {{ totalDebits | currency: 'INR' : 'symbol-narrow' : '1.2-2' }}
</td> </td>
<td class="dav-right"> <td class="text-right total-label credit-amount">
<strong>{{ totalCredits | formatNumber }}</strong> {{ totalCredits | currency: 'INR' : 'symbol-narrow' : '1.2-2' }}
</td> </td>
<td class="dav-right"> <td class="text-right closing-balance">
<strong>{{ passbookClosingBalance | formatNumber }}</strong> {{ passbookClosingBalance | currency: 'INR' : 'symbol-narrow' : '1.2-2' }}
</td> </td>
</tr> </tr>
</tfoot> </tfoot>
</table> </table>
} @else {
<div class="no-data-block">
<mat-icon>inbox</mat-icon>
<p>{{ hasDateFilters ? 'No transactions found for the selected date range.' : 'No entries yet.' }}</p>
</div>
} }
<!-- Statement Footer -->
<div class="statement-footer">
<div class="footer-note">
This is a computer-generated statement and does not require a signature. For queries, contact your
branch or call customer care.
</div>
<div class="footer-meta">
Generated on: {{ today | dateFormat }} &nbsp;|&nbsp; Account No: {{ account.accountNo }}
</div>
</div>
</div> </div>
</div> </div>
} }
@@ -543,7 +737,9 @@
<td>{{ nominee.nominationDate ? (nominee.nominationDate | date: 'dd MMM yyyy') : '—' }}</td> <td>{{ nominee.nominationDate ? (nominee.nominationDate | date: 'dd MMM yyyy') : '—' }}</td>
<td>{{ nominee.guardianName || '—' }}</td> <td>{{ nominee.guardianName || '—' }}</td>
<td class="dav-right"> <td class="dav-right">
{{ account.nomineeSplitMode === 'Sequence' ? nominee.sequenceNo : (nominee.sharePercent ?? '—') }} {{
account.nomineeSplitMode === 'Sequence' ? nominee.sequenceNo : (nominee.sharePercent ?? '—')
}}
</td> </td>
</tr> </tr>
} }
@@ -553,5 +749,6 @@
} }
</div> </div>
} }
</mat-tab-nav-panel>
} }
</div> </div>
@@ -157,45 +157,89 @@
} }
} }
/* ── Tabs ─────────────────────────────────────────────────────────────── */ /* ── Tabs: the savings account page's pill tabs ───────────────────────── */
$tab-navy: #0d1b2a;
$tab-blue: #1e88e5;
$tab-blue-d: #1e40af;
$tab-border: #e2e8f0;
.dav-tabs { .dav-tabs {
display: flex;
gap: 8px;
padding: 8px;
border-radius: 14px;
background: #fff; background: #fff;
border: 1px solid rgb(0 0 0 / 7%); border: 1px solid $tab-border;
border-radius: 14px;
padding: 6px;
box-shadow: 0 1px 3px rgb(15 23 42 / 4%);
overflow: hidden;
::ng-deep {
.mat-mdc-tab-link-container {
border-bottom: none !important;
}
.mat-mdc-tab-links {
gap: 4px;
flex-wrap: wrap;
}
.mdc-tab-indicator__content--underline,
.mat-mdc-tab-header-pagination-controls-enabled .mdc-tab-indicator__content--underline {
display: none !important;
}
}
} }
.dav-tab { .dav-tab {
flex: 1; display: inline-flex !important;
display: flex;
align-items: center; align-items: center;
justify-content: center; gap: 8px;
gap: 6px; height: 38px !important;
padding: 10px 16px; min-width: 0 !important;
border: 0; padding: 0 16px !important;
border-radius: 10px; border-radius: 10px !important;
background: transparent; font-family: Inter, 'DM Sans', Roboto, sans-serif !important;
font-size: 0.95rem; font-size: 13px !important;
font-weight: 600; font-weight: 600 !important;
color: #475569; letter-spacing: 0.01em !important;
color: #64748b !important;
background: transparent !important;
transition: all 0.15s ease;
white-space: nowrap;
border: 1px solid transparent !important;
opacity: 1 !important;
cursor: pointer; cursor: pointer;
&:hover { &__ic {
background: #f1f5f9; font-size: 12px;
color: inherit;
}
&:hover:not(.mdc-tab--active) {
background: #f1f5f9 !important;
color: $tab-navy !important;
}
&.mdc-tab--active,
&.mat-mdc-tab-link-active {
background: linear-gradient(135deg, $tab-blue 0%, $tab-blue-d 100%) !important;
color: #fff !important;
box-shadow: 0 2px 6px -1px rgb(30 64 175 / 35%);
border-color: $tab-blue-d !important;
::ng-deep .mdc-tab__text-label,
::ng-deep .mat-mdc-tab-link-content,
::ng-deep span,
.dav-tab__ic {
color: #fff !important;
opacity: 1 !important;
}
} }
} }
.dav-tab--on { .dav-body {
color: #fff; display: flex;
background: linear-gradient(90deg, #1d4ed8, #2563eb); flex-direction: column;
box-shadow: 0 4px 12px rgb(37 99 235 / 30%); gap: 16px;
&:hover {
background: linear-gradient(90deg, #1d4ed8, #2563eb);
}
} }
/* ── Cards ────────────────────────────────────────────────────────────── */ /* ── Cards ────────────────────────────────────────────────────────────── */
@@ -269,38 +313,7 @@
color: rgb(0 0 0 / 50%); color: rgb(0 0 0 / 50%);
} }
/* ── Transactions ─────────────────────────────────────────────────────── */ /* ── Shared tables and buttons (nominees, not-found state) ─────────── */
.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 { .dav-btn {
display: inline-flex; display: inline-flex;
@@ -319,21 +332,6 @@
} }
} }
.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 { .dav-table-wrap {
overflow-x: auto; overflow-x: auto;
} }
@@ -366,11 +364,6 @@
} }
} }
.dav-desc {
max-width: 320px;
color: rgb(0 0 0 / 65%);
}
@media (width <= 720px) { @media (width <= 720px) {
.dav-page { .dav-page {
padding: 16px; padding: 16px;
@@ -382,66 +375,11 @@
} }
.dav-tab { .dav-tab {
font-size: 0.85rem; padding: 0 12px !important;
padding: 8px; font-size: 12px !important;
} }
} }
/* ── 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 */ /* Closure tab */
.dav-closure-tag { .dav-closure-tag {
margin-left: 8px; margin-left: 8px;
@@ -457,3 +395,629 @@
border-top: 1px solid rgb(0 0 0 / 10%); border-top: 1px solid rgb(0 0 0 / 10%);
font-weight: 600; font-weight: 600;
} }
/* ── Transactions: the savings account's Transactions tab ─────────────── */
$txn-navy: #0d1b2a;
$txn-slate: #475569;
$txn-muted: #64748b;
$txn-border: #e2e8f0;
$txn-soft: #f8fafc;
$txn-blue: #1e40af;
.txn-tab {
width: 100%;
table {
width: 100%;
}
}
.txn-tab__card {
background: #fff;
border: 1px solid $txn-border;
border-radius: 14px;
box-shadow: 0 1px 3px rgb(15 23 42 / 4%);
overflow: hidden;
}
.txn-tab__toolbar {
display: flex;
align-items: center;
gap: 18px;
padding: 12px 18px;
background: $txn-soft;
border-bottom: 1px solid $txn-border;
flex-wrap: wrap;
}
.txn-tab__date {
display: flex;
align-items: center;
gap: 8px;
font-size: 13px;
color: $txn-slate;
font-weight: 500;
input {
height: 34px;
padding: 0 10px;
border-radius: 8px;
border: 1px solid $txn-border;
background: #fff;
font: inherit;
color: $txn-navy;
}
}
.txn-tab__btn {
height: 34px !important;
border-radius: 8px !important;
font-size: 13px !important;
font-weight: 600 !important;
fa-icon {
margin-right: 4px;
}
}
.txn-tab__spacer {
flex: 1;
}
.txn-tab__total {
font-size: 13px;
font-weight: 700;
font-variant-numeric: tabular-nums;
}
.txn-tab__total--cr {
color: #1a7a3f;
}
.txn-tab__total--dr {
color: #c0392b;
}
.txn-tab__export {
height: 36px !important;
border-radius: 8px !important;
font-weight: 600 !important;
font-size: 13px !important;
padding: 0 16px !important;
background: linear-gradient(135deg, #1e88e5 0%, $txn-blue 100%) !important;
color: #fff !important;
&:disabled {
opacity: 0.5;
}
}
::ng-deep .txn-tab__card {
.mat-mdc-header-row {
background: $txn-soft;
}
.mat-mdc-header-cell {
font-size: 11.5px;
font-weight: 800;
color: $txn-slate;
text-transform: uppercase;
letter-spacing: 0.06em;
padding: 12px 14px;
border-bottom: 1px solid $txn-border;
}
.mat-mdc-cell {
font-size: 13px;
color: $txn-navy;
padding: 11px 14px;
border-bottom: 1px solid #f1f5f9;
}
.mat-mdc-row:hover {
background: #f8fafc;
}
.mat-column-debit,
.mat-column-credit,
.mat-column-balance {
text-align: right;
font-variant-numeric: tabular-nums;
}
.mat-mdc-paginator {
border-top: 1px solid $txn-border;
background: $txn-soft;
min-height: 48px;
}
.transfer {
color: #6d28d9;
}
.txn-tab__nodata {
padding: 32px 14px;
text-align: center;
color: $txn-muted;
}
}
.txn-tab__empty {
background: #fff;
border: 1px solid $txn-border;
border-radius: 14px;
box-shadow: 0 1px 3px rgb(15 23 42 / 4%);
padding: 56px 24px;
text-align: center;
color: $txn-muted;
}
.txn-tab__empty-ico {
font-size: 34px;
color: #cbd5e1;
margin-bottom: 8px;
}
.txn-tab__empty-title {
font-size: 14.5px;
font-weight: 700;
color: $txn-slate;
margin-top: 6px;
}
.txn-tab__empty-sub {
font-size: 12.5px;
color: $txn-muted;
margin-top: 2px;
}
/* ── Passbook: the savings account's Passbook tab ─────────────────────── */
$bank-primary: #1a3a6b;
$bank-accent: #c8a951;
$debit-color: #c0392b;
$credit-color: #1a7a3f;
$balance-color: #1a3a6b;
$border-color: #d0d7e3;
$row-alt: #f4f7fb;
.passbook-container {
padding: 16px;
background: #eef1f7;
border-radius: 14px;
min-height: 100%;
}
.passbook-filter-bar {
margin-bottom: 16px;
background: #fff;
border-radius: 8px;
padding: 12px 16px;
border: 1px solid $border-color;
}
.passbook-filter-form {
display: flex;
align-items: center;
gap: 12px;
flex-wrap: wrap;
}
.passbook-date-field {
width: 180px;
}
.passbook-filter-spacer {
flex: 1;
}
.statement-wrapper {
background: #fff;
border: 1px solid $border-color;
border-radius: 8px;
overflow: hidden;
box-shadow: 0 2px 12px rgb(0 0 0 / 8%);
font-family: 'Segoe UI', Arial, sans-serif;
}
.bank-header {
background: $bank-primary;
padding: 18px 24px;
display: flex;
justify-content: space-between;
align-items: center;
border-bottom: 4px solid $bank-accent;
}
.bank-logo-area {
display: flex;
align-items: center;
gap: 14px;
}
.bank-logo-circle {
width: 52px;
height: 52px;
border-radius: 50%;
background: $bank-accent;
display: flex;
align-items: center;
justify-content: center;
mat-icon {
color: $bank-primary;
font-size: 28px;
width: 28px;
height: 28px;
}
}
.bank-name-block {
.bank-title {
color: #fff;
font-size: 18px;
font-weight: 700;
letter-spacing: 0.3px;
}
.bank-subtitle {
color: $bank-accent;
font-size: 11px;
font-weight: 500;
letter-spacing: 1.5px;
text-transform: uppercase;
margin-top: 2px;
}
}
.statement-label {
text-align: right;
.stmt-label-text {
color: $bank-accent;
font-size: 13px;
font-weight: 700;
letter-spacing: 2px;
text-transform: uppercase;
}
.stmt-period {
color: rgb(255 255 255 / 75%);
font-size: 11px;
margin-top: 4px;
}
}
.branch-line {
background: #e8edf5;
border-bottom: 1px solid $border-color;
padding: 6px 24px;
display: flex;
flex-wrap: wrap;
gap: 4px 18px;
font-size: 11px;
color: #4a5a7a;
}
.account-details-band {
background: #f0f3f9;
border-bottom: 1px solid $border-color;
padding: 14px 24px;
}
.account-details-grid {
display: grid;
grid-template-columns: repeat(3, 1fr);
gap: 12px 24px;
}
.detail-block {
.detail-label {
font-size: 9px;
font-weight: 700;
color: #7a8aaa;
text-transform: uppercase;
letter-spacing: 0.8px;
margin-bottom: 3px;
}
.detail-value {
font-size: 13px;
color: #222;
&.bold {
font-weight: 600;
}
&.mono {
font-family: 'Courier New', monospace;
letter-spacing: 1px;
}
&.status-badge {
display: inline-block;
padding: 1px 8px;
border-radius: 10px;
font-size: 11px;
font-weight: 600;
background: #eee;
color: #555;
&.active {
background: #d4edda;
color: #155724;
}
}
}
}
.balance-summary-strip {
display: flex;
align-items: stretch;
background: $bank-primary;
padding: 0;
}
.balance-box {
flex: 1;
padding: 12px 20px;
text-align: center;
.balance-box-label {
font-size: 10px;
font-weight: 600;
color: rgb(255 255 255 / 65%);
text-transform: uppercase;
letter-spacing: 1px;
margin-bottom: 4px;
}
.balance-box-value {
font-size: 16px;
font-weight: 700;
color: $bank-accent;
}
}
.balance-divider {
width: 1px;
background: rgb(255 255 255 / 15%);
margin: 8px 0;
}
.statement-table {
width: 100%;
border-collapse: collapse;
font-size: 12.5px;
thead tr {
background: #e8edf5;
border-top: 1px solid $border-color;
border-bottom: 2px solid $bank-primary;
}
thead th {
padding: 9px 10px;
font-size: 11px;
font-weight: 700;
color: $bank-primary;
text-transform: uppercase;
letter-spacing: 0.4px;
white-space: nowrap;
}
tbody tr {
border-bottom: 1px solid #e8ecf3;
transition: background 0.1s;
&:nth-child(even) {
background: $row-alt;
}
&:hover {
background: #dce6f5;
}
}
tbody td {
padding: 7px 10px;
vertical-align: middle;
color: #333;
}
tfoot .total-row {
background: #e8edf5;
border-top: 2px solid $bank-primary;
td {
padding: 9px 10px;
}
}
}
.col-sr {
width: 38px;
}
.col-date {
width: 110px;
white-space: nowrap;
}
.col-desc {
min-width: 160px;
}
.col-ref {
width: 100px;
}
.col-amount {
width: 110px;
}
.col-balance {
width: 120px;
}
.debit-amount {
color: $debit-color;
font-weight: 500;
}
.credit-amount {
color: $credit-color;
font-weight: 500;
}
.balance-amount {
color: $balance-color;
font-weight: 600;
font-family: 'Courier New', monospace;
}
.total-label {
font-size: 12px;
font-weight: 700;
color: $bank-primary;
text-transform: uppercase;
letter-spacing: 0.5px;
}
.closing-balance {
font-size: 14px;
font-weight: 700;
color: $bank-primary;
font-family: 'Courier New', monospace;
}
.txn-desc {
font-weight: 500;
}
.text-right {
text-align: right;
}
.text-center {
text-align: center;
}
.text-muted {
color: #999;
}
.mono {
font-family: 'Courier New', monospace;
font-size: 11px;
}
.no-data-block {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
padding: 48px;
color: #aaa;
mat-icon {
font-size: 48px;
width: 48px;
height: 48px;
margin-bottom: 12px;
}
p {
font-size: 14px;
margin: 0;
}
}
.statement-footer {
background: #f0f3f9;
border-top: 1px solid $border-color;
padding: 12px 24px;
text-align: center;
.footer-note {
font-size: 10px;
color: #888;
margin-bottom: 4px;
font-style: italic;
}
.footer-meta {
font-size: 10px;
color: #aaa;
letter-spacing: 0.3px;
}
}
@media (width <= 768px) {
.account-details-grid {
grid-template-columns: 1fr 1fr;
}
.bank-header {
flex-direction: column;
gap: 12px;
align-items: flex-start;
}
.statement-label {
text-align: left;
}
.balance-summary-strip {
flex-direction: column;
}
.balance-divider {
width: auto;
height: 1px;
margin: 0 20px;
}
.passbook-filter-form {
flex-direction: column;
align-items: stretch;
}
.passbook-date-field {
width: 100%;
}
.passbook-filter-spacer {
display: none;
}
}
@media (width <= 480px) {
.account-details-grid {
grid-template-columns: 1fr;
}
}
@media print {
.no-print {
display: none !important;
}
.passbook-container {
background: #fff;
padding: 0;
}
.statement-wrapper {
border: none;
box-shadow: none;
border-radius: 0;
}
.bank-header,
.balance-summary-strip {
-webkit-print-color-adjust: exact;
print-color-adjust: exact;
}
}
@@ -6,14 +6,37 @@
* file, You can obtain one at http://mozilla.org/MPL/2.0/. * file, You can obtain one at http://mozilla.org/MPL/2.0/.
*/ */
import { Component, OnInit, inject } from '@angular/core'; import { Component, DestroyRef, OnInit, ViewChild, inject } from '@angular/core';
import { takeUntilDestroyed } from '@angular/core/rxjs-interop';
import { ActivatedRoute, Router } from '@angular/router'; import { ActivatedRoute, Router } from '@angular/router';
import { FormControl, ReactiveFormsModule } from '@angular/forms'; import { FormControl, ReactiveFormsModule } from '@angular/forms';
import { NgClass } from '@angular/common'; import { CurrencyPipe, NgClass } from '@angular/common';
import { MatTooltipModule } from '@angular/material/tooltip'; import { MatTooltipModule } from '@angular/material/tooltip';
import { MatProgressSpinnerModule } from '@angular/material/progress-spinner'; import { MatProgressSpinnerModule } from '@angular/material/progress-spinner';
import { MatTabLink, MatTabNav, MatTabNavPanel } from '@angular/material/tabs';
import {
MatCell,
MatCellDef,
MatColumnDef,
MatHeaderCell,
MatHeaderCellDef,
MatHeaderRow,
MatHeaderRowDef,
MatNoDataRow,
MatRow,
MatRowDef,
MatTable,
MatTableDataSource
} from '@angular/material/table';
import { MatPaginator } from '@angular/material/paginator';
import { MatSort, MatSortHeader } from '@angular/material/sort';
import { MatIcon } from '@angular/material/icon';
import { MatIconButton } from '@angular/material/button';
import { MatMenu, MatMenuItem, MatMenuTrigger } from '@angular/material/menu';
import { MatDialog } from '@angular/material/dialog';
import { DdsTransactionViewDialogComponent } from './dds-transaction-view-dialog/dds-transaction-view-dialog.component';
import { FaIconComponent } from '@fortawesome/angular-fontawesome'; import { FaIconComponent } from '@fortawesome/angular-fontawesome';
import { forkJoin, of } from 'rxjs'; import { forkJoin, merge, of } from 'rxjs';
import { catchError } from 'rxjs/operators'; import { catchError } from 'rxjs/operators';
import { STANDALONE_SHARED_IMPORTS } from 'app/standalone-shared.module'; import { STANDALONE_SHARED_IMPORTS } from 'app/standalone-shared.module';
import { FormatNumberPipe } from 'app/pipes/format-number.pipe'; import { FormatNumberPipe } from 'app/pipes/format-number.pipe';
@@ -52,6 +75,29 @@ const TRANSACTION_LABELS: Record<string, string> = {
FaIconComponent, FaIconComponent,
MatTooltipModule, MatTooltipModule,
MatProgressSpinnerModule, MatProgressSpinnerModule,
MatTabNav,
MatTabLink,
MatTabNavPanel,
MatTable,
MatColumnDef,
MatHeaderCellDef,
MatHeaderCell,
MatCellDef,
MatCell,
MatHeaderRowDef,
MatHeaderRow,
MatRowDef,
MatRow,
MatNoDataRow,
MatSort,
MatSortHeader,
MatPaginator,
MatIcon,
MatIconButton,
MatMenu,
MatMenuItem,
MatMenuTrigger,
CurrencyPipe,
FormatNumberPipe FormatNumberPipe
] ]
}) })
@@ -59,6 +105,8 @@ export class DdsAccountViewComponent implements OnInit {
private route = inject(ActivatedRoute); private route = inject(ActivatedRoute);
private router = inject(Router); private router = inject(Router);
private organizationService = inject(OrganizationService); private organizationService = inject(OrganizationService);
private destroyRef = inject(DestroyRef);
private dialog = inject(MatDialog);
isLoading = true; isLoading = true;
account: any = null; account: any = null;
@@ -79,6 +127,40 @@ export class DdsAccountViewComponent implements OnInit {
CLOSED_NIL_BALANCE: 'Closed with nil balance' CLOSED_NIL_BALANCE: 'Closed with nil balance'
}; };
/** Transactions tab table, laid out as the savings account's: oldest first, 50 to a page, sortable. */
readonly txnColumns: string[] = [
'row',
'id',
'date',
'transactionType',
'debit',
'credit',
'balance',
'actions'
];
readonly txnDataSource = new MatTableDataSource<any>([]);
/** The paginator and sort only exist while the Transactions tab is open, so they are re-attached each time. */
@ViewChild(MatPaginator) set txnPaginator(paginator: MatPaginator | undefined) {
this.txnDataSource.paginator = paginator ?? null;
}
@ViewChild(MatSort) set txnSort(sort: MatSort | undefined) {
this.txnDataSource.sort = sort ?? null;
}
/**
* Passbook letterhead - the bank from Institution Profile and the issuing branch from Branch Profile,
* as the savings passbook takes them. Falls back to a generic label when the profile was never filled in.
*/
bankName = 'Bank';
bankSubtitle = 'Account Statement';
branchName = '';
branchAddress = '';
ifscCode = '';
micrCode = '';
/** The passbook reads oldest first, as it is written up. */ /** The passbook reads oldest first, as it is written up. */
get passbookEntries(): any[] { get passbookEntries(): any[] {
return [...this.shownTransactions].reverse(); return [...this.shownTransactions].reverse();
@@ -92,37 +174,140 @@ export class DdsAccountViewComponent implements OnInit {
/** /**
* Prints the passbook alone. It is copied into a window of its own with only its own styles, so the * 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. * page's menus, banner and tabs do not come out on the paper - the same way the savings passbook prints.
*/ */
printPassbook(): void { printPassbook(): void {
const passbook = document.getElementById('dds-passbook'); const passbook = document.getElementById('dds-passbook');
const popup = window.open('', '_blank', 'width=900,height=700'); if (!passbook) {
if (!passbook || !popup) {
return; return;
} }
popup.document.write(`<html><head><title>${this.account?.accountNo || 'DDS'} Passbook</title><style> const clone = passbook.cloneNode(true) as HTMLElement;
body { font-family: Arial, sans-serif; margin: 24px; color: #111; } clone.querySelectorAll('.no-print').forEach((el: Element) => el.remove());
.dav-pb-head { display: flex; justify-content: space-between; gap: 24px; margin-bottom: 16px; } // The icon font is not available in the popup: the bank logo becomes an inline SVG, other icons go.
.dav-pb-title { font-size: 20px; font-weight: 700; } clone.querySelectorAll('mat-icon, .mat-icon').forEach((el: Element) => {
.dav-pb-sub { color: #555; } if (el.closest('.bank-logo-circle')) {
.dav-pb-grid { display: grid; grid-template-columns: auto auto; gap: 2px 12px; font-size: 13px; } el.outerHTML = DdsAccountViewComponent.BANK_LOGO_SVG;
.dav-pb-grid span { color: #555; } } else {
table { width: 100%; border-collapse: collapse; font-size: 13px; } el.remove();
th, td { border: 1px solid #999; padding: 6px 8px; text-align: left; } }
.dav-right { text-align: right; } });
.dav-pb-note { display: block; font-size: 11px; color: #555; } const popup = window.open('', '_blank', 'width=1100,height=800');
</style></head><body>${passbook.innerHTML}</body></html>`); if (!popup) {
return;
}
popup.document.write(`<html><head><meta charset="utf-8" />
<title>${this.account?.accountNo || 'DDS'} Passbook</title>
<style>${DdsAccountViewComponent.PRINT_STYLES}</style></head>
<body>${clone.outerHTML}</body></html>`);
popup.document.close(); popup.document.close();
popup.focus(); popup.focus();
popup.print(); popup.print();
} }
/** Material "account_balance" glyph, inlined so the print window needs no icon font. */
private static readonly BANK_LOGO_SVG = `
<svg class="bank-logo-svg" viewBox="0 0 24 24" width="26" height="26" aria-hidden="true">
<path d="M4 10h3v7H4zM10.5 10h3v7h-3zM2 19h20v3H2zM17 10h3v7h-3zM12 1L2 6v2h20V6z" />
</svg>`;
/** Mirrors the passbook styles in the component's SCSS - scoped styles do not reach the popup. */
private static readonly PRINT_STYLES = `
@page { size: A4 portrait; margin: 10mm; }
* { margin: 0; padding: 0; box-sizing: border-box; -webkit-print-color-adjust: exact; print-color-adjust: exact; }
body { font-family: 'Segoe UI', Arial, sans-serif; color: #333; background: #fff; }
.statement-wrapper { background: #fff; border: 1px solid #d0d7e3; border-radius: 6px; overflow: hidden; }
.bank-header { background: #1a3a6b; padding: 14px 20px; display: flex; justify-content: space-between;
align-items: center; border-bottom: 4px solid #c8a951; }
.bank-logo-area { display: flex; align-items: center; gap: 12px; }
.bank-logo-circle { width: 44px; height: 44px; border-radius: 50%; background: #c8a951; display: flex;
align-items: center; justify-content: center; }
.bank-logo-svg { fill: #1a3a6b; display: block; }
.bank-title { color: #fff; font-size: 16px; font-weight: 700; letter-spacing: 0.3px; }
.bank-subtitle { color: #c8a951; font-size: 10px; font-weight: 500; letter-spacing: 1.5px;
text-transform: uppercase; margin-top: 2px; }
.statement-label { text-align: right; }
.stmt-label-text { color: #c8a951; font-size: 12px; font-weight: 700; letter-spacing: 2px; text-transform: uppercase; }
.stmt-period { color: rgba(255, 255, 255, 0.75); font-size: 10px; margin-top: 3px; }
.branch-line { background: #e8edf5; border-bottom: 1px solid #d0d7e3; padding: 5px 20px; display: flex;
flex-wrap: wrap; gap: 4px 16px; font-size: 9.5px; color: #4a5a7a; }
.account-details-band { background: #f0f3f9; border-bottom: 1px solid #d0d7e3; padding: 12px 20px; }
.account-details-grid { display: grid; grid-template-columns: repeat(3, 1fr); gap: 10px 24px; }
.detail-label { font-size: 8.5px; font-weight: 700; color: #7a8aaa; text-transform: uppercase;
letter-spacing: 0.8px; margin-bottom: 2px; }
.detail-value { font-size: 12px; color: #222; }
.detail-value.bold { font-weight: 600; }
.detail-value.mono { font-family: 'Courier New', monospace; letter-spacing: 1px; }
.detail-value.status-badge { display: inline-block; padding: 1px 8px; border-radius: 10px; font-size: 10px;
font-weight: 600; background: #eee; color: #555; }
.detail-value.status-badge.active { background: #d4edda; color: #155724; }
.balance-summary-strip { display: flex; align-items: stretch; background: #1a3a6b; }
.balance-box { flex: 1; padding: 10px 18px; text-align: center; }
.balance-box-label { font-size: 9px; font-weight: 600; color: rgba(255, 255, 255, 0.65);
text-transform: uppercase; letter-spacing: 1px; margin-bottom: 3px; }
.balance-box-value { font-size: 15px; font-weight: 700; color: #c8a951; }
.balance-divider { width: 1px; background: rgba(255, 255, 255, 0.15); margin: 6px 0; }
.statement-table { width: 100%; border-collapse: collapse; font-size: 11px; }
.statement-table thead { display: table-header-group; }
.statement-table tfoot { display: table-row-group; }
.statement-table tr { page-break-inside: avoid; break-inside: avoid; }
.statement-table thead tr { background: #e8edf5; border-top: 1px solid #d0d7e3; border-bottom: 2px solid #1a3a6b; }
.statement-table thead th { padding: 8px 9px; font-size: 10px; font-weight: 700; color: #1a3a6b;
text-transform: uppercase; letter-spacing: 0.4px; white-space: nowrap; text-align: left; }
.statement-table tbody tr { border-bottom: 1px solid #e8ecf3; }
.statement-table tbody tr:nth-child(even) { background: #f4f7fb; }
.statement-table tbody td { padding: 6px 9px; vertical-align: middle; color: #333; }
.statement-table tfoot .total-row { background: #e8edf5; border-top: 2px solid #1a3a6b; }
.statement-table tfoot .total-row td { padding: 8px 9px; }
.col-sr { width: 34px; }
.col-date { width: 100px; white-space: nowrap; }
.col-desc { min-width: 150px; }
.col-ref { width: 90px; }
.col-amount { width: 100px; }
.col-balance { width: 110px; }
.debit-amount { color: #c0392b; font-weight: 500; }
.credit-amount { color: #1a7a3f; font-weight: 500; }
.balance-amount { color: #1a3a6b; font-weight: 600; font-family: 'Courier New', monospace; }
.total-label { font-size: 11px; font-weight: 700; color: #1a3a6b; text-transform: uppercase; letter-spacing: 0.5px; }
.closing-balance { font-size: 13px; font-weight: 700; color: #1a3a6b; font-family: 'Courier New', monospace; }
.txn-desc { font-weight: 500; }
.txn-note { display: block; font-size: 9.5px; color: #777; }
.text-right { text-align: right; }
.text-center { text-align: center; }
.text-muted { color: #999; }
.mono { font-family: 'Courier New', monospace; font-size: 10px; }
.no-data-block { padding: 40px; text-align: center; color: #aaa; font-size: 13px; }
.statement-footer { background: #f0f3f9; border-top: 1px solid #d0d7e3; padding: 10px 20px; text-align: center; }
.footer-note { font-size: 9px; color: #888; margin-bottom: 3px; font-style: italic; }
.footer-meta { font-size: 9px; color: #aaa; letter-spacing: 0.3px; }
`;
fromDateControl = new FormControl(''); fromDateControl = new FormControl('');
toDateControl = new FormControl(''); toDateControl = new FormControl('');
readonly today = new Date().toISOString().split('T')[0]; readonly today = new Date().toISOString().split('T')[0];
ngOnInit(): void { ngOnInit(): void {
this.txnDataSource.sortingDataAccessor = (txn: any, column: string) => {
switch (column) {
case 'date':
return `${txn.transactionDate || ''}#${String(txn.id).padStart(12, '0')}`;
case 'transactionType':
return this.label(txn.transactionType);
case 'voucher':
return txn.voucherNo || '';
case 'debit':
case 'credit':
return Number(txn.amount || 0);
case 'balance':
return Number(txn.balanceAfterTransaction || 0);
default:
return Number(txn.id || 0);
}
};
merge(this.fromDateControl.valueChanges, this.toDateControl.valueChanges)
.pipe(takeUntilDestroyed(this.destroyRef))
.subscribe(() => this.refreshTransactionTable());
const partyId = this.route.snapshot.paramMap.get('partyId'); const partyId = this.route.snapshot.paramMap.get('partyId');
if (!partyId) { if (!partyId) {
this.isLoading = false; this.isLoading = false;
@@ -144,10 +329,39 @@ export class DdsAccountViewComponent implements OnInit {
(a: any, b: any) => (a: any, b: any) =>
String(b.transactionDate).localeCompare(String(a.transactionDate)) || Number(b.id) - Number(a.id) String(b.transactionDate).localeCompare(String(a.transactionDate)) || Number(b.id) - Number(a.id)
); );
this.refreshTransactionTable();
this.loadLetterhead(account);
this.isLoading = false; this.isLoading = false;
}); });
} }
/** The Transactions tab reads oldest first, as the savings account's does, within the chosen dates. */
private refreshTransactionTable(): void {
this.txnDataSource.data = this.passbookEntries;
}
/** Institution Profile for the bank; the account's own office's Branch Profile, else the first on record. */
private loadLetterhead(account: any): void {
this.branchName = account?.officeName || '';
forkJoin({
institution: this.organizationService.getInstitutionProfile().pipe(catchError(() => of(null))),
branches: this.organizationService.getBranchProfiles().pipe(catchError(() => of([])))
}).subscribe(({ institution, branches }: any) => {
if (institution) {
this.bankName = institution.passbookHeaderName || institution.bankName || institution.legalName || 'Bank';
this.bankSubtitle = institution.licenseNumber ? `Lic. No. ${institution.licenseNumber}` : 'Account Statement';
}
const list: any[] = Array.isArray(branches) ? branches : [];
const branch = list.find((b: any) => account?.officeId && b.officeId === account.officeId) || list[0];
if (branch) {
this.branchName = branch.officeName || this.branchName;
this.branchAddress = branch.address || '';
this.ifscCode = branch.ifscCode || '';
this.micrCode = branch.micrCode || '';
}
});
}
/** The transactions in the chosen range - all of them when no range is set. */ /** The transactions in the chosen range - all of them when no range is set. */
get shownTransactions(): any[] { get shownTransactions(): any[] {
const from = this.fromDateControl.value; const from = this.fromDateControl.value;
@@ -164,6 +378,11 @@ export class DdsAccountViewComponent implements OnInit {
}); });
} }
/** Whether the account has any transactions at all, whatever the date filter says. */
get hasTransactions(): boolean {
return this.transactions.length > 0;
}
get totalCredits(): number { get totalCredits(): number {
return this.sumOf('CREDIT'); return this.sumOf('CREDIT');
} }
@@ -209,6 +428,11 @@ export class DdsAccountViewComponent implements OnInit {
return this.transactions.length > 0 ? this.transactions[0].transactionDate : null; return this.transactions.length > 0 ? this.transactions[0].transactionDate : null;
} }
/** Row tint, as the savings transactions table has it - money moved by transfer shows in violet. */
txnRowClass(txn: any): string {
return txn.transactionType === 'TRANSFER' || txn.transactionType === 'WITHDRAW_DEPOSITE' ? 'transfer' : '';
}
/** The rows on screen, as a spreadsheet the branch can keep or send on. */ /** The rows on screen, as a spreadsheet the branch can keep or send on. */
exportCsv(): void { exportCsv(): void {
const header = [ const header = [
@@ -268,6 +492,35 @@ export class DdsAccountViewComponent implements OnInit {
this.toDateControl.setValue(''); this.toDateControl.setValue('');
} }
/**
* A line in full - what it was, when, the payment details, voucher, reference, journal, branch and
* who keyed it - as a savings account's View Transaction shows one. From the Receipt menu item it
* prints straight away.
*/
viewTransaction(txn: any, printReceipt = false): void {
this.dialog.open(DdsTransactionViewDialogComponent, {
width: '640px',
maxWidth: '95vw',
data: {
transaction: txn,
account: this.account,
typeLabel: this.label(txn.transactionType),
printOnOpen: printReceipt
}
});
}
viewJournalEntries(txn: any): void {
if (txn.journalTransactionId) {
this.router.navigate([
'/',
'journal-entry',
'view',
txn.journalTransactionId
]);
}
}
label(type: string): string { label(type: string): string {
return TRANSACTION_LABELS[type] || String(type || '').replace(/_/g, ' '); return TRANSACTION_LABELS[type] || String(type || '').replace(/_/g, ' ');
} }
@@ -0,0 +1,144 @@
<!--
Copyright since 2025 Mifos Initiative
This Source Code Form is subject to the terms of the Mozilla Public
License, v. 2.0. If a copy of the MPL was not distributed with this
file, You can obtain one at http://mozilla.org/MPL/2.0/.
-->
<div class="dtv">
<header class="dtv-head" [class.dtv-head--debit]="!isCredit">
<div class="dtv-head__ic"><fa-icon [icon]="isCredit ? 'arrow-down' : 'arrow-up'"></fa-icon></div>
<div class="dtv-head__text">
<h2>{{ data.typeLabel }}</h2>
<p>Transaction #{{ txn.id }} · {{ txn.refNo || '—' }}</p>
</div>
<div class="dtv-head__amount">
<span>{{ isCredit ? 'Credit' : 'Debit' }}</span>
<strong>{{ txn.amount | currency: currency : 'symbol-narrow' : '1.2-2' }}</strong>
</div>
<button type="button" class="dtv-close" (click)="close()" aria-label="Close">
<fa-icon icon="times"></fa-icon>
</button>
</header>
<div class="dtv-body" id="dds-txn-receipt">
<!-- What the printed receipt opens with; hidden on screen, where the header above says it. -->
<div class="dtv-print-only">
<h2>DDS Transaction Receipt</h2>
<div class="muted">
{{ account.partyName }} · A/c {{ account.accountNo }} · {{ txn.officeName || account.officeName || '' }}
</div>
<div class="amount">
{{ isCredit ? 'Credit' : 'Debit' }} {{ txn.amount | currency: currency : 'symbol-narrow' : '1.2-2' }}
</div>
</div>
<section class="dtv-section">
<h3>General</h3>
<table class="dtv-kv">
<tr>
<td class="k">Id</td>
<td>{{ txn.id }}</td>
</tr>
<tr>
<td class="k">Reference No.</td>
<td class="mono">{{ txn.refNo || '—' }}</td>
</tr>
<tr>
<td class="k">Transaction Type</td>
<td>{{ data.typeLabel }} ({{ txn.direction }})</td>
</tr>
<tr>
<td class="k">Transaction Date</td>
<td>{{ txn.transactionDate | date: 'dd MMM yyyy' }}</td>
</tr>
<tr>
<td class="k">Submitted On</td>
<td>{{ txn.submittedOnDate ? (txn.submittedOnDate | date: 'dd MMM yyyy') : '—' }}</td>
</tr>
<tr>
<td class="k">Currency</td>
<td>{{ currency }}</td>
</tr>
<tr>
<td class="k">Amount</td>
<td class="strong">{{ txn.amount | currency: currency : 'symbol-narrow' : '1.2-2' }}</td>
</tr>
<tr>
<td class="k">Running Balance</td>
<td class="strong">
{{ txn.balanceAfterTransaction || 0 | currency: currency : 'symbol-narrow' : '1.2-2' }}
</td>
</tr>
<tr>
<td class="k">Note</td>
<td>{{ txn.description || '—' }}</td>
</tr>
</table>
</section>
<section class="dtv-section">
<h3>Payment Details</h3>
<table class="dtv-kv">
<tr>
<td class="k">Mode</td>
<td>{{ txn.transactionMode || '—' }}</td>
</tr>
<tr>
<td class="k">Account Type / No.</td>
<td>{{ txn.accountType || '—' }}{{ txn.accountNo ? ' · ' + txn.accountNo : '' }}</td>
</tr>
<tr>
<td class="k">Cheque No.</td>
<td>{{ txn.chequeNo || '—' }}</td>
</tr>
<tr>
<td class="k">Token No.</td>
<td>{{ txn.tokenNo || '—' }}</td>
</tr>
</table>
</section>
<section class="dtv-section">
<h3>Accounting &amp; Audit</h3>
<table class="dtv-kv">
<tr>
<td class="k">Voucher No.</td>
<td class="mono">{{ txn.voucherNo || '—' }}</td>
</tr>
<tr>
<td class="k">Journal Entry</td>
<td class="mono">{{ txn.journalTransactionId || 'None - nothing moved on the books' }}</td>
</tr>
<tr>
<td class="k">Branch</td>
<td>{{ txn.officeName || account.officeName || '—' }}</td>
</tr>
<tr>
<td class="k">Agent</td>
<td>{{ txn.agentCode }}{{ txn.agentName ? ' · ' + txn.agentName : '' }}</td>
</tr>
<tr>
<td class="k">Created By</td>
<td>{{ txn.createdByUsername || '—' }}</td>
</tr>
<tr>
<td class="k">Created On</td>
<td>{{ txn.createdDate ? (txn.createdDate | date: 'dd MMM yyyy, hh:mm a') : '—' }}</td>
</tr>
</table>
</section>
</div>
<footer class="dtv-actions">
<button mat-stroked-button type="button" (click)="close()">Close</button>
<span class="dtv-actions__spacer"></span>
<button mat-stroked-button type="button" [disabled]="!txn.journalTransactionId" (click)="viewJournalEntries()">
<fa-icon icon="table-cells"></fa-icon> View Journal Entries
</button>
<button mat-flat-button color="primary" type="button" (click)="printReceipt()">
<fa-icon icon="receipt"></fa-icon> Print Receipt
</button>
</footer>
</div>
@@ -0,0 +1,167 @@
/**
* Copyright since 2025 Mifos Initiative
*
* This Source Code Form is subject to the terms of the Mozilla Public
* License, v. 2.0. If a copy of the MPL was not distributed with this
* file, You can obtain one at http://mozilla.org/MPL/2.0/.
*/
$primary: #1353a0;
$credit: #15803d;
$debit: #b91c1c;
$border: #e2e8f0;
$text: #0f172a;
$text-mid: #475569;
:host {
display: block;
}
.dtv {
display: flex;
flex-direction: column;
max-height: 88vh;
color: $text;
font-size: 13px;
}
.dtv-head {
display: flex;
align-items: center;
gap: 12px;
padding: 16px 20px;
background: linear-gradient(135deg, #1e40af, #2563eb);
color: #fff;
&--debit {
background: linear-gradient(135deg, #7f1d1d, #b91c1c);
}
&__ic {
display: grid;
place-items: center;
flex: none;
width: 38px;
height: 38px;
border-radius: 10px;
background: rgb(255 255 255 / 18%);
}
&__text {
flex: 1;
min-width: 0;
h2 {
margin: 0;
font-size: 16px;
font-weight: 700;
}
p {
margin: 2px 0 0;
font-size: 12px;
opacity: 0.85;
}
}
&__amount {
display: flex;
flex-direction: column;
align-items: flex-end;
span {
font-size: 11px;
letter-spacing: 0.06em;
text-transform: uppercase;
opacity: 0.85;
}
strong {
font-size: 18px;
}
}
}
.dtv-close {
border: 0;
background: transparent;
color: #fff;
font-size: 16px;
cursor: pointer;
}
.dtv-body {
flex: 1;
min-height: 0;
padding: 14px 20px;
overflow-y: auto;
}
.dtv-print-only {
display: none;
}
.dtv-section {
& + & {
margin-top: 14px;
}
h3 {
margin: 0 0 6px;
font-size: 11px;
font-weight: 700;
letter-spacing: 0.08em;
text-transform: uppercase;
color: $primary;
}
}
.dtv-kv {
width: 100%;
border: 1px solid $border;
border-radius: 8px;
border-collapse: separate;
border-spacing: 0;
overflow: hidden;
td {
padding: 7px 12px;
border-bottom: 1px solid #eef2f6;
vertical-align: top;
}
tr:last-child td {
border-bottom: 0;
}
.k {
width: 38%;
background: #f8fafc;
color: $text-mid;
font-weight: 600;
}
.mono {
font-family: ui-monospace, 'Cascadia Mono', Consolas, monospace;
}
.strong {
font-weight: 700;
}
}
.dtv-actions {
display: flex;
align-items: center;
gap: 8px;
padding: 12px 20px;
border-top: 1px solid $border;
&__spacer {
flex: 1;
}
fa-icon {
margin-right: 6px;
}
}
@@ -0,0 +1,109 @@
/**
* Copyright since 2025 Mifos Initiative
*
* This Source Code Form is subject to the terms of the Mozilla Public
* License, v. 2.0. If a copy of the MPL was not distributed with this
* file, You can obtain one at http://mozilla.org/MPL/2.0/.
*/
import { Component, inject } from '@angular/core';
import { CurrencyPipe, DatePipe } from '@angular/common';
import { Router } from '@angular/router';
import { MAT_DIALOG_DATA, MatDialogRef } from '@angular/material/dialog';
import { MatButton } from '@angular/material/button';
import { FaIconComponent } from '@fortawesome/angular-fontawesome';
import { STANDALONE_SHARED_IMPORTS } from 'app/standalone-shared.module';
/** What the account view hands the dialog: the line, its account, and how to name its type. */
export interface DdsTransactionViewData {
transaction: any;
account: any;
typeLabel: string;
/** Opened from the Receipt menu item: print the receipt straight away. */
printOnOpen?: boolean;
}
/**
* One DDS passbook line in full - everything the line was saved with - the way a savings account's
* View Transaction shows one: what it was, when, how much, and who keyed it, with the voucher, its
* own reference and the journal it was booked under. Prints as a receipt.
*/
@Component({
selector: 'mifosx-dds-transaction-view-dialog',
templateUrl: './dds-transaction-view-dialog.component.html',
styleUrls: ['./dds-transaction-view-dialog.component.scss'],
imports: [
...STANDALONE_SHARED_IMPORTS,
CurrencyPipe,
DatePipe,
MatButton,
FaIconComponent
]
})
export class DdsTransactionViewDialogComponent {
private dialogRef = inject(MatDialogRef<DdsTransactionViewDialogComponent>);
private router = inject(Router);
data = inject<DdsTransactionViewData>(MAT_DIALOG_DATA);
get txn(): any {
return this.data.transaction || {};
}
get account(): any {
return this.data.account || {};
}
get isCredit(): boolean {
return this.txn.direction === 'CREDIT';
}
get currency(): string {
return this.account.currencyCode || 'INR';
}
constructor() {
if (this.data.printOnOpen) {
// After the view has drawn, so the receipt has something in it to print.
setTimeout(() => this.printReceipt(), 300);
}
}
viewJournalEntries(): void {
if (!this.txn.journalTransactionId) {
return;
}
this.dialogRef.close();
this.router.navigate([
'/',
'journal-entry',
'view',
this.txn.journalTransactionId
]);
}
/** The receipt block alone, in a window of its own - as the savings receipt prints. */
printReceipt(): void {
const receipt = document.getElementById('dds-txn-receipt');
const popup = window.open('', '_blank', 'width=720,height=800');
if (!receipt || !popup) {
return;
}
popup.document.write(`<html><head><title>${this.txn.refNo || 'DDS'} Receipt</title><style>
body { font-family: Arial, sans-serif; font-size: 12px; color: #111; margin: 24px; }
h2 { margin: 0 0 4px; font-size: 16px; } .muted { color: #555; }
table { width: 100%; border-collapse: collapse; margin-top: 12px; }
td { padding: 6px 8px; border-bottom: 1px solid #ddd; vertical-align: top; }
td.k { width: 38%; color: #555; } .amount { font-size: 18px; font-weight: 700; }
.sign { margin-top: 48px; display: flex; justify-content: space-between; }
</style></head><body>${receipt.innerHTML}
<div class="sign"><span>Customer</span><span>Authorised Signatory</span></div>
</body></html>`);
popup.document.close();
popup.focus();
popup.print();
}
close(): void {
this.dialogRef.close();
}
}
@@ -19,12 +19,462 @@
<span>Loading entry…</span> <span>Loading entry…</span>
</div> </div>
} @else { } @else {
<div class="dri-body"> <div class="dri-body" [class.ddr-body]="isProductReview || isAgentReview || isPartyReview">
@if (isProductReview) {
<!--
A DDS product request reads as the product it creates or changes: who and what at the top,
the four terms a checker looks at first, then every term grouped as the Account Type screen
asks for them. An edit shows each changed term as old → new.
-->
<section class="ddr-hero">
<div class="ddr-hero__icon">
<mat-icon>{{ isEdit ? 'edit_note' : 'savings' }}</mat-icon>
</div>
<div class="ddr-hero__main">
<div class="ddr-hero__eyebrow">
<span class="ddr-chip" [class.ddr-chip--edit]="isEdit">{{
isEdit ? 'Edit request' : 'New product'
}}</span>
<span class="dri-status" [ngClass]="statusClass(entry.status)">{{ entry.status }}</span>
</div>
<h2 class="ddr-hero__title">{{ valueOf('accountTypeDescription') || entry.details || 'DDS Product' }}</h2>
<div class="ddr-hero__code">
<span class="ddr-mono">{{ valueOf('accountTypeCode') || '—' }}</span>
@if (valueOf('accountTypeGroup')) {
<span class="ddr-dot"></span>
<span>{{ valueOf('accountTypeGroup') }} group</span>
}
</div>
</div>
<div class="ddr-hero__meta">
<span class="ddr-meta">
<mat-icon>person</mat-icon>
<span>
<b>{{ entry.makerName || entry.maker }}</b>
<small>{{ entry.maker }}</small>
</span>
</span>
<span class="ddr-meta">
<mat-icon>schedule</mat-icon>
<span>
<b>{{ formatDateTime(entry.madeOn) }}</b>
<small>Submitted</small>
</span>
</span>
@if (entry.status !== 'PENDING' && entry.checker) {
<span class="ddr-meta">
<mat-icon>verified_user</mat-icon>
<span>
<b>{{ entry.checkerName || entry.checker }}</b>
<small>{{ entry.status === 'APPROVED' ? 'Approved by' : 'Rejected by' }}</small>
</span>
</span>
}
</div>
</section>
<!-- The terms a checker reads first. -->
<section class="ddr-stats">
<div class="ddr-stat ddr-stat--accent">
<span class="ddr-stat__label"><mat-icon>percent</mat-icon>Interest Rate</span>
<span class="ddr-stat__value">{{ valueOf('interestRate') || '—' }}</span>
<span class="ddr-stat__hint">p.a., worked out daily</span>
</div>
<div class="ddr-stat">
<span class="ddr-stat__label"><mat-icon>event_repeat</mat-icon>Interest Posting</span>
<span class="ddr-stat__value">
{{ valueOf('autoInterestPost') === 'No' ? 'Manual' : valueOf('autoInterestPostWhen') || 'Monthly' }}
</span>
<span class="ddr-stat__hint">
{{ valueOf('autoInterestPost') === 'No' ? 'Posted from the batch screen' : 'Automatic, at period end' }}
</span>
</div>
<div class="ddr-stat">
<span class="ddr-stat__label"><mat-icon>lock_clock</mat-icon>Lock-in</span>
<span class="ddr-stat__value">{{ valueOf('prematureLockinMonths') || '—' }}</span>
<span class="ddr-stat__hint">before early closure</span>
</div>
<div class="ddr-stat">
<span class="ddr-stat__label"><mat-icon>event_available</mat-icon>Maturity</span>
<span class="ddr-stat__value">{{
valueOf('maturityAutoClose') === 'Yes' ? 'Auto close' : 'Manual payout'
}}</span>
<span class="ddr-stat__hint">on the maturity date</span>
</div>
</section>
@if (compareFields) {
<div class="ddr-banner" [class.ddr-banner--none]="!changedCount">
<mat-icon>{{ changedCount ? 'compare_arrows' : 'info' }}</mat-icon>
@if (changedCount) {
<span>
<b>{{ changedCount }} term{{ changedCount !== 1 ? 's' : '' }}</b> will change on approval - highlighted
below as current → proposed.
</span>
} @else {
<span>This edit changes nothing - every term matches the product as it stands.</span>
}
</div>
}
@for (group of fieldGroups; track group.name) {
<section class="ddr-section">
<header class="ddr-section__head">
<span class="ddr-section__icon"
><mat-icon>{{ group.icon }}</mat-icon></span
>
<span class="ddr-section__title">{{ group.name }}</span>
@if (group.changed) {
<span class="ddr-chip ddr-chip--edit">{{ group.changed }} changed</span>
}
</header>
@if (group.name === 'Accounting') {
<!--
The ledger accounts the product posts through, laid out as the product's own Accounting
tab lays them out: the kind of account, what it is for, and the account by name and code.
-->
<div class="ddr-acct">
@for (field of group.fields; track field.key) {
<div class="ddr-acct__row" [class.ddr-acct__row--changed]="field.changed">
<div class="ddr-acct__meta">
<span class="ddr-acct__kind" [ngClass]="'ddr-acct__kind--' + glKind(field.key).toLowerCase()">{{
glKind(field.key)
}}</span>
<span class="ddr-acct__label">
{{ field.label }}
@if (field.changed) {
<span class="ddr-tile__flag">Changed</span>
}
</span>
</div>
<div class="ddr-acct__value">
@if (field.changed) {
<span class="ddr-acct__gl ddr-acct__gl--old">
{{ glName(field.current) }}
@if (glCode(field.current)) {
<span class="ddr-acct__code">{{ glCode(field.current) }}</span>
}
</span>
<mat-icon class="ddr-acct__arrow">arrow_forward</mat-icon>
}
<span class="ddr-acct__gl" [class.ddr-acct__gl--none]="!field.value">
{{ glName(field.value) }}
@if (glCode(field.value)) {
<span class="ddr-acct__code">{{ glCode(field.value) }}</span>
}
</span>
</div>
</div>
}
</div>
} @else {
<div class="ddr-grid">
@for (field of group.fields; track field.key) {
<div class="ddr-tile" [class.ddr-tile--changed]="field.changed">
<span class="ddr-tile__label">
{{ field.label }}
@if (field.changed) {
<span class="ddr-tile__flag">Changed</span>
}
</span>
@if (field.changed) {
<span class="ddr-tile__diff">
<span class="ddr-tile__old">{{ field.current || '—' }}</span>
<mat-icon>arrow_forward</mat-icon>
<ng-container *ngTemplateOutlet="valueTpl; context: { field: field }"></ng-container>
</span>
} @else {
<ng-container *ngTemplateOutlet="valueTpl; context: { field: field }"></ng-container>
}
</div>
}
</div>
}
</section>
}
<ng-template #valueTpl let-field="field">
@if (field.format === 'yesno' && field.value) {
<span class="ddr-pill" [class.ddr-pill--yes]="field.value === 'Yes'">
<mat-icon>{{ field.value === 'Yes' ? 'check_circle' : 'remove_circle_outline' }}</mat-icon>
{{ field.value }}
</span>
} @else {
<span class="ddr-tile__value" [class.ddr-mono]="field.key === 'accountTypeCode'">{{
field.value || '—'
}}</span>
}
</ng-template>
} @else if (isAgentReview) {
<!--
An agent request reads as the agent it opens or changes: who and when at the top, then the
client, address, branch and accounts - the branch, the ledger account and the client by name,
not number - and the guarantors named with it. An edit lists only what it changes.
-->
<section class="ddr-hero">
<div class="ddr-hero__icon">
<mat-icon>{{ isEdit ? 'manage_accounts' : 'person_add' }}</mat-icon>
</div>
<div class="ddr-hero__main">
<div class="ddr-hero__eyebrow">
<span class="ddr-chip" [class.ddr-chip--edit]="isEdit">{{ isEdit ? 'Edit request' : 'New agent' }}</span>
<span class="dri-status" [ngClass]="statusClass(entry.status)">{{ entry.status }}</span>
</div>
<h2 class="ddr-hero__title">{{ valueOf('agentNameId') || entry.details || 'DDS Agent' }}</h2>
<div class="ddr-hero__code">
@if (valueOf('clientId')) {
<span>Client {{ valueOf('clientId') }}</span>
}
@if (valueOf('agentSavingAccountNo')) {
<span class="ddr-dot"></span>
<span class="ddr-mono">{{ valueOf('agentSavingAccountNo') }}</span>
}
</div>
</div>
<div class="ddr-hero__meta">
<span class="ddr-meta">
<mat-icon>person</mat-icon>
<span>
<b>{{ entry.makerName || entry.maker }}</b>
<small>{{ entry.maker }}</small>
</span>
</span>
<span class="ddr-meta">
<mat-icon>schedule</mat-icon>
<span>
<b>{{ formatDateTime(entry.madeOn) }}</b>
<small>Submitted</small>
</span>
</span>
@if (entry.status !== 'PENDING' && entry.checker) {
<span class="ddr-meta">
<mat-icon>verified_user</mat-icon>
<span>
<b>{{ entry.checkerName || entry.checker }}</b>
<small>{{ entry.status === 'APPROVED' ? 'Approved by' : 'Rejected by' }}</small>
</span>
</span>
}
</div>
</section>
@for (group of fieldGroups; track group.name) {
<section class="ddr-section">
<header class="ddr-section__head">
<span class="ddr-section__icon"
><mat-icon>{{ group.icon }}</mat-icon></span
>
<span class="ddr-section__title">{{ group.name }}</span>
</header>
<div class="ddr-grid">
@for (field of group.fields; track field.key) {
<div class="ddr-tile">
<span class="ddr-tile__label">{{ field.label }}</span>
<span class="ddr-tile__value" [class.ddr-mono]="field.key === 'agentSavingAccountNo'">{{
field.value || '—'
}}</span>
</div>
}
</div>
</section>
}
<section class="ddr-section">
<header class="ddr-section__head">
<span class="ddr-section__icon"><mat-icon>shield_person</mat-icon></span>
<span class="ddr-section__title">Guarantors</span>
<span class="ddr-chip ddr-chip--edit">{{ guarantors.length }}</span>
</header>
@if (guarantors.length) {
<table class="dri-cr-table">
<thead>
<tr>
<th>#</th>
<th>Client</th>
<th>Name</th>
<th>Telephone</th>
<th>Address</th>
</tr>
</thead>
<tbody>
@for (g of guarantors; track $index; let i = $index) {
<tr>
<td class="dri-cr-idx">{{ i + 1 }}</td>
<td>{{ g.client || '—' }}</td>
<td>{{ g.name || '—' }}</td>
<td class="cell-mono">{{ g.telephone || '—' }}</td>
<td>{{ g.address || '—' }}</td>
</tr>
}
</tbody>
</table>
} @else {
<div class="ddr-tile">
<span class="ddr-tile__value">No guarantors named in this request.</span>
</div>
}
</section>
} @else if (isPartyReview) {
<!--
A DDS account request reads as the account it opens or changes: who it is for and when, the
terms a checker looks at first, then every detail in the sections the Party Master Entry screen
has - the client, scheme, agent and branch by name, not number - and the nominees named with it.
An edit lists only what it changes.
-->
<section class="ddr-hero">
<div class="ddr-hero__icon">
<mat-icon>{{ isEdit ? 'edit_note' : 'account_balance_wallet' }}</mat-icon>
</div>
<div class="ddr-hero__main">
<div class="ddr-hero__eyebrow">
<span class="ddr-chip" [class.ddr-chip--edit]="isEdit">{{
isEdit ? 'Edit request' : 'New DDS account'
}}</span>
<span class="dri-status" [ngClass]="statusClass(entry.status)">{{ entry.status }}</span>
</div>
<h2 class="ddr-hero__title">{{ valueOf('partyName') || entry.details || 'DDS Account' }}</h2>
<div class="ddr-hero__code">
@if (valueOf('subType')) {
<span>{{ valueOf('subType') }}</span>
}
@if (valueOf('clientId')) {
<span class="ddr-dot"></span>
<span>Client {{ valueOf('clientId') }}</span>
}
</div>
</div>
<div class="ddr-hero__meta">
<span class="ddr-meta">
<mat-icon>person</mat-icon>
<span>
<b>{{ entry.makerName || entry.maker }}</b>
<small>{{ entry.maker }}</small>
</span>
</span>
<span class="ddr-meta">
<mat-icon>schedule</mat-icon>
<span>
<b>{{ formatDateTime(entry.madeOn) }}</b>
<small>Submitted</small>
</span>
</span>
@if (entry.status !== 'PENDING' && entry.checker) {
<span class="ddr-meta">
<mat-icon>verified_user</mat-icon>
<span>
<b>{{ entry.checkerName || entry.checker }}</b>
<small>{{ entry.status === 'APPROVED' ? 'Approved by' : 'Rejected by' }}</small>
</span>
</span>
}
</div>
</section>
<!-- The terms a checker reads first. -->
<section class="ddr-stats">
<div class="ddr-stat ddr-stat--accent">
<span class="ddr-stat__label"><mat-icon>payments</mat-icon>Daily Deposit</span>
<span class="ddr-stat__value">{{ valueOf('dailyDepositAmount') || '—' }}</span>
<span class="ddr-stat__hint">collected every day</span>
</div>
<div class="ddr-stat">
<span class="ddr-stat__label"><mat-icon>date_range</mat-icon>Term</span>
<span class="ddr-stat__value">{{ valueOf('days') ? valueOf('days') + ' days' : '—' }}</span>
<span class="ddr-stat__hint">{{ valueOf('month') ? valueOf('month') + ' months' : '' }}</span>
</div>
<div class="ddr-stat">
<span class="ddr-stat__label"><mat-icon>event</mat-icon>Opening Date</span>
<span class="ddr-stat__value">{{ valueOf('openingDate') || '—' }}</span>
<span class="ddr-stat__hint">{{
valueOf('maturityDate') ? 'Matures ' + valueOf('maturityDate') : 'account opens'
}}</span>
</div>
<div class="ddr-stat">
<span class="ddr-stat__label"><mat-icon>support_agent</mat-icon>Agent</span>
<span class="ddr-stat__value ddr-stat__value--small">{{ valueOf('agentId') || '—' }}</span>
<span class="ddr-stat__hint">{{ valueOf('officeId') || '' }}</span>
</div>
</section>
@for (group of fieldGroups; track group.name) {
<section class="ddr-section">
<header class="ddr-section__head">
<span class="ddr-section__icon"
><mat-icon>{{ group.icon }}</mat-icon></span
>
<span class="ddr-section__title">{{ group.name }}</span>
</header>
<div class="ddr-grid">
@for (field of group.fields; track field.key) {
<div class="ddr-tile">
<span class="ddr-tile__label">{{ field.label }}</span>
@if (field.format === 'yesno') {
<span class="ddr-pill" [class.ddr-pill--yes]="field.value === 'Yes'">
<mat-icon>{{ field.value === 'Yes' ? 'check_circle' : 'remove_circle_outline' }}</mat-icon>
{{ field.value }}
</span>
} @else {
<span class="ddr-tile__value">{{ field.value || '—' }}</span>
}
</div>
}
</div>
</section>
}
<section class="ddr-section">
<header class="ddr-section__head">
<span class="ddr-section__icon"><mat-icon>family_restroom</mat-icon></span>
<span class="ddr-section__title">Nominees</span>
@if (detail?.nomineeSplit && nominees.length) {
<span class="ddr-chip ddr-chip--edit">Split by {{ detail.nomineeSplit }}</span>
}
<span class="ddr-chip ddr-chip--edit">{{ nominees.length }}</span>
</header>
@if (nominees.length) {
<table class="dri-cr-table">
<thead>
<tr>
<th>#</th>
<th>Name</th>
<th>Relation</th>
<th>Gender</th>
<th>Mobile</th>
<th>Birth Date</th>
<th>Guardian</th>
<th class="num">Share</th>
</tr>
</thead>
<tbody>
@for (n of nominees; track $index; let i = $index) {
<tr>
<td class="dri-cr-idx">{{ i + 1 }}</td>
<td>{{ n.name || '—' }}</td>
<td>{{ n.relation || '—' }}</td>
<td>{{ n.gender || '—' }}</td>
<td class="cell-mono">{{ n.mobileNo || '—' }}</td>
<td>{{ n.birthDate || '—' }}</td>
<td>{{ n.guardianName || '—' }}</td>
<td class="num">{{ n.share || '—' }}</td>
</tr>
}
</tbody>
</table>
} @else {
<div class="ddr-tile">
<span class="ddr-tile__value">No nominee named in this request.</span>
</div>
}
</section>
} @else {
<!-- Header card: the voucher it moves under, its status, who made it and when --> <!-- Header card: the voucher it moves under, its status, who made it and when -->
<div class="dri-card dri-card--header"> <div class="dri-card dri-card--header">
<div class="dri-card-header"> <div class="dri-card-header">
<div class="dri-card-header__left"> <div class="dri-card-header__left">
<!-- No voucher slot for a change that moves no money - it would only ever read "—". -->
@if (voucherNo || movesMoney) {
<span class="dri-voucher">{{ voucherNo || '—' }}</span> <span class="dri-voucher">{{ voucherNo || '—' }}</span>
}
<span class="dri-status" [ngClass]="statusClass(entry.status)"> <span class="dri-status" [ngClass]="statusClass(entry.status)">
{{ entry.status }}{{ entry.voucherCancelled ? ' · VOUCHER CANCELLED' : '' }} {{ entry.status }}{{ entry.voucherCancelled ? ' · VOUCHER CANCELLED' : '' }}
</span> </span>
@@ -55,24 +505,42 @@
{{ entry.type }} Summary {{ entry.type }} Summary
</div> </div>
<div class="dri-detail-grid"> <div class="dri-detail-grid">
<!-- Amount and sides only for an entry that moves money; a product or agent change has neither. -->
@if (movesMoney) {
<div class="dri-detail-item"> <div class="dri-detail-item">
<span class="dri-detail-label">Total Amount</span> <span class="dri-detail-label">Total Amount</span>
<span class="dri-detail-value dri-amount"> <span class="dri-detail-value dri-amount">
{{ entry.amount !== null && entry.amount !== undefined ? '₹' + (entry.amount | number: '1.2-2') : '—' }} {{
entry.amount !== null && entry.amount !== undefined ? '₹' + (entry.amount | number: '1.2-2') : '—'
}}
</span> </span>
</div> </div>
@if (entriesProjected) {
<div class="dri-detail-item dri-detail-item--full">
<span class="dri-detail-label">Ledger</span>
<span class="dri-detail-value"
>No journal entry is made when this is approved - it records what has been earned. The entries below
are what the matching Posting run will book.</span
>
</div>
}
<div class="dri-detail-item"> <div class="dri-detail-item">
<span class="dri-detail-label">DR Accounts</span> <span class="dri-detail-label">{{
entriesProjected ? 'DR Accounts (when posted)' : 'DR Accounts'
}}</span>
<span class="dri-detail-value" <span class="dri-detail-value"
>{{ debitLines.length }} account{{ debitLines.length !== 1 ? 's' : '' }}</span >{{ debitLines.length }} account{{ debitLines.length !== 1 ? 's' : '' }}</span
> >
</div> </div>
<div class="dri-detail-item"> <div class="dri-detail-item">
<span class="dri-detail-label">CR Accounts</span> <span class="dri-detail-label">{{
entriesProjected ? 'CR Accounts (when posted)' : 'CR Accounts'
}}</span>
<span class="dri-detail-value" <span class="dri-detail-value"
>{{ creditLines.length }} account{{ creditLines.length !== 1 ? 's' : '' }}</span >{{ creditLines.length }} account{{ creditLines.length !== 1 ? 's' : '' }}</span
> >
</div> </div>
}
<div class="dri-detail-item dri-detail-item--full"> <div class="dri-detail-item dri-detail-item--full">
<span class="dri-detail-label">Details</span> <span class="dri-detail-label">Details</span>
<span class="dri-detail-value">{{ entry.details || '—' }}</span> <span class="dri-detail-value">{{ entry.details || '—' }}</span>
@@ -85,13 +553,17 @@
} }
</div> </div>
</div> </div>
}
<!-- Debit entries --> <!-- Debit entries -->
@if (debitLines.length) { @if (debitLines.length) {
<div class="dri-card"> <div class="dri-card">
<div class="dri-section-title"> <div class="dri-section-title">
<mat-icon>arrow_upward</mat-icon> <mat-icon>arrow_upward</mat-icon>
Debit Entries ({{ debitLines.length }} account{{ debitLines.length !== 1 ? 's' : '' }} — funds out) {{ entriesProjected ? 'Debit entries when posted' : 'Debit Entries' }} ({{ debitLines.length }} account{{
debitLines.length !== 1 ? 's' : ''
}}
— funds out)
</div> </div>
<table class="dri-cr-table"> <table class="dri-cr-table">
<thead> <thead>
@@ -129,7 +601,10 @@
<div class="dri-card"> <div class="dri-card">
<div class="dri-section-title"> <div class="dri-section-title">
<mat-icon>arrow_downward</mat-icon> <mat-icon>arrow_downward</mat-icon>
Credit Entries ({{ creditLines.length }} account{{ creditLines.length !== 1 ? 's' : '' }} — funds in) {{ entriesProjected ? 'Credit entries when posted' : 'Credit Entries' }} ({{ creditLines.length }} account{{
creditLines.length !== 1 ? 's' : ''
}}
— funds in)
</div> </div>
<table class="dri-cr-table"> <table class="dri-cr-table">
<thead> <thead>
@@ -225,7 +700,7 @@
} }
@if (entry.status === 'PENDING' && !done) { @if (entry.status === 'PENDING' && !done) {
<div class="dri-actions"> <div class="dri-actions" [class.ddr-actions]="isProductReview || isAgentReview || isPartyReview">
@if (!confirmReject) { @if (!confirmReject) {
<button type="button" class="dri-btn dri-btn--approve" [disabled]="!canAct" (click)="act('approve')"> <button type="button" class="dri-btn dri-btn--approve" [disabled]="!canAct" (click)="act('approve')">
<mat-icon>check_circle</mat-icon> <mat-icon>check_circle</mat-icon>
@@ -0,0 +1,563 @@
/**
* 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 DDS product review. Layered on the Disbursement & Transfers sheet and drawn from the same
// palette, so it reads as part of the same Approval Center rather than a screen of its own.
$brand: #0f4f3c;
$brand-mid: #1f7a5c;
$brand-light: #e6f4f0;
$primary: #4a6cf7;
$warning: #b45309;
$warning-bg: #fffbeb;
$warning-border: #fde68a;
$text-dark: #0f172a;
$text-mid: #475569;
$text-soft: #94a3b8;
$border: #e2e8f0;
$bg-white: #fff;
$bg-soft: #f8fafc;
$radius: 14px;
$shadow-sm:
0 1px 3px rgb(15 23 42 / 6%),
0 1px 2px rgb(15 23 42 / 4%);
@mixin icon($size) {
font-size: $size;
width: $size;
height: $size;
line-height: $size;
}
.ddr-mono {
font-family: 'JetBrains Mono', SFMono-Regular, Consolas, monospace;
letter-spacing: 0.02em;
}
// A product review has more to lay out than a money movement, so the dialog widens for it.
.dri-body.ddr-body {
max-width: none;
gap: 16px;
padding-bottom: 0;
}
.dri--dialog:has(.ddr-body) {
width: min(880px, 94vw);
}
// ── Hero ─────────────────────────────────────────────────────────────────────
.ddr-hero {
display: grid;
grid-template-columns: auto 1fr auto;
gap: 18px;
align-items: center;
padding: 22px 24px;
border-radius: $radius;
background:
radial-gradient(circle at 100% 0%, rgb(255 255 255 / 12%) 0, transparent 45%),
linear-gradient(135deg, $brand 0%, $brand-mid 100%);
color: #fff;
box-shadow: 0 8px 24px rgb(15 79 60 / 22%);
&__icon {
display: grid;
place-items: center;
width: 56px;
height: 56px;
border-radius: 16px;
background: rgb(255 255 255 / 14%);
border: 1px solid rgb(255 255 255 / 22%);
mat-icon {
@include icon(28px);
}
}
&__main {
min-width: 0;
}
&__eyebrow {
display: flex;
align-items: center;
gap: 8px;
margin-bottom: 6px;
}
&__title {
margin: 0;
font-size: 21px;
font-weight: 700;
line-height: 1.25;
overflow-wrap: anywhere;
}
&__code {
display: flex;
align-items: center;
gap: 8px;
margin-top: 4px;
font-size: 13px;
color: rgb(255 255 255 / 78%);
}
&__meta {
display: flex;
flex-direction: column;
gap: 10px;
padding-left: 18px;
border-left: 1px solid rgb(255 255 255 / 18%);
}
// Status reads on the dark band as a light chip, whatever its colour on a white card.
.dri-status {
background: rgb(255 255 255 / 92%);
font-size: 11px;
padding: 3px 10px;
}
}
.ddr-dot {
width: 4px;
height: 4px;
border-radius: 50%;
background: currentcolor;
opacity: 0.6;
}
.ddr-meta {
display: flex;
align-items: center;
gap: 8px;
font-size: 12px;
mat-icon {
@include icon(18px);
opacity: 0.75;
}
b {
display: block;
font-weight: 600;
white-space: nowrap;
}
small {
display: block;
font-size: 11px;
color: rgb(255 255 255 / 65%);
}
}
.ddr-chip {
display: inline-flex;
align-items: center;
padding: 3px 10px;
border-radius: 999px;
font-size: 11px;
font-weight: 700;
letter-spacing: 0.04em;
text-transform: uppercase;
background: rgb(255 255 255 / 16%);
color: #fff;
border: 1px solid rgb(255 255 255 / 28%);
&--edit {
background: $warning-bg;
color: $warning;
border-color: $warning-border;
}
}
// ── Key terms ────────────────────────────────────────────────────────────────
.ddr-stats {
display: grid;
grid-template-columns: repeat(4, minmax(0, 1fr));
gap: 12px;
}
.ddr-stat {
display: flex;
flex-direction: column;
gap: 4px;
padding: 14px 16px;
background: $bg-white;
border: 1px solid $border;
border-radius: 12px;
box-shadow: $shadow-sm;
&__label {
display: flex;
align-items: center;
gap: 6px;
font-size: 11px;
font-weight: 700;
letter-spacing: 0.05em;
text-transform: uppercase;
color: $text-soft;
mat-icon {
@include icon(16px);
color: $brand-mid;
}
}
&__value {
font-size: 18px;
font-weight: 700;
color: $text-dark;
overflow-wrap: anywhere;
}
&__hint {
font-size: 11.5px;
color: $text-mid;
min-height: 14px;
}
// A value that is a name rather than a figure - an agent's code and name - reads smaller.
&__value--small {
font-size: 14px;
line-height: 1.3;
}
&--accent {
background: $brand-light;
border-color: #b7ddd0;
.ddr-stat__value {
font-size: 26px;
color: $brand;
}
}
}
// ── Edit banner ──────────────────────────────────────────────────────────────
.ddr-banner {
display: flex;
align-items: center;
gap: 10px;
padding: 12px 16px;
border-radius: 12px;
font-size: 13px;
background: $warning-bg;
border: 1px solid $warning-border;
color: $warning;
mat-icon {
@include icon(20px);
flex-shrink: 0;
}
&--none {
background: $bg-soft;
border-color: $border;
color: $text-mid;
}
}
// ── Sections ─────────────────────────────────────────────────────────────────
.ddr-section {
background: $bg-white;
border: 1px solid $border;
border-radius: $radius;
box-shadow: $shadow-sm;
overflow: hidden;
&__head {
display: flex;
align-items: center;
gap: 10px;
padding: 12px 18px;
border-bottom: 1px solid $border;
background: linear-gradient(90deg, $bg-soft 0%, $bg-white 100%);
}
&__icon {
display: grid;
place-items: center;
width: 28px;
height: 28px;
border-radius: 8px;
background: $brand-light;
color: $brand;
mat-icon {
@include icon(17px);
}
}
&__title {
font-size: 12.5px;
font-weight: 700;
letter-spacing: 0.06em;
text-transform: uppercase;
color: $text-mid;
margin-right: auto;
}
}
.ddr-grid {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
}
.ddr-tile {
display: flex;
flex-direction: column;
gap: 6px;
padding: 14px 18px;
border-bottom: 1px solid $border;
min-width: 0;
// Two columns: a hairline between them, and none under the last row.
&:nth-child(odd) {
border-right: 1px solid $border;
}
&:last-child,
&:nth-last-child(2):nth-child(odd) {
border-bottom: 0;
}
&__label {
display: flex;
align-items: center;
gap: 8px;
font-size: 11.5px;
font-weight: 600;
letter-spacing: 0.03em;
text-transform: uppercase;
color: $text-soft;
}
&__value {
font-size: 15px;
font-weight: 600;
color: $text-dark;
overflow-wrap: anywhere;
}
&__flag {
padding: 1px 7px;
border-radius: 999px;
font-size: 10px;
font-weight: 700;
letter-spacing: 0.04em;
background: $warning-bg;
color: $warning;
border: 1px solid $warning-border;
}
&__diff {
display: flex;
align-items: center;
flex-wrap: wrap;
gap: 8px;
mat-icon {
@include icon(16px);
color: $warning;
}
}
&__old {
font-size: 14px;
color: $text-soft;
text-decoration: line-through;
}
&--changed {
background: linear-gradient(90deg, rgb(253 230 138 / 22%) 0%, $bg-white 85%);
box-shadow: inset 3px 0 0 #f59e0b;
}
}
.ddr-pill {
display: inline-flex;
align-items: center;
gap: 5px;
width: fit-content;
padding: 3px 10px 3px 7px;
border-radius: 999px;
font-size: 13px;
font-weight: 600;
background: #f1f5f9;
color: $text-mid;
mat-icon {
@include icon(16px);
}
&--yes {
background: #dcfce7;
color: #15803d;
}
}
// ── Actions stay in reach however far the terms run ─────────────────────────
.ddr-actions {
position: sticky;
bottom: 0;
z-index: 2;
margin: 4px -20px 0;
padding: 14px 20px;
background: rgb(255 255 255 / 94%);
backdrop-filter: blur(6px);
border-top: 1px solid $border;
box-shadow: 0 -6px 16px rgb(15 23 42 / 6%);
}
// ── Narrow screens ───────────────────────────────────────────────────────────
/* stylelint-disable-next-line media-feature-range-notation -- Safari compatibility */
@media (max-width: 720px) {
.ddr-hero {
grid-template-columns: auto 1fr;
&__meta {
grid-column: 1 / -1;
flex-flow: row wrap;
gap: 14px;
padding: 12px 0 0;
border-left: 0;
border-top: 1px solid rgb(255 255 255 / 18%);
}
}
.ddr-stats {
grid-template-columns: repeat(2, minmax(0, 1fr));
}
.ddr-grid {
grid-template-columns: 1fr;
}
.ddr-tile {
&:nth-child(odd) {
border-right: 0;
}
&:nth-last-child(2):nth-child(odd) {
border-bottom: 1px solid $border;
}
}
}
// ── A DDS product's Accounting, as its own Accounting tab shows it ───────────────────────────────────
.ddr-acct {
display: flex;
flex-direction: column;
gap: 10px;
padding: 16px 20px 20px;
&__row {
display: flex;
align-items: center;
justify-content: space-between;
gap: 16px;
padding: 12px 14px;
border: 1px solid $border;
border-radius: 10px;
background: $bg-white;
&--changed {
border-color: $warning-border;
background: $warning-bg;
}
@media (width <= 720px) {
flex-direction: column;
align-items: flex-start;
}
}
&__meta {
display: flex;
align-items: center;
gap: 10px;
min-width: 0;
}
&__kind {
flex: none;
padding: 2px 10px;
border-radius: 999px;
font-size: 11px;
font-weight: 700;
letter-spacing: 0.04em;
text-transform: uppercase;
&--liability {
background: #dbeafe;
color: #1e40af;
}
&--expense {
background: #fef3c7;
color: #92400e;
}
}
&__label {
display: inline-flex;
align-items: center;
gap: 8px;
font-size: 14px;
font-weight: 600;
color: $text-dark;
}
&__value {
display: flex;
align-items: center;
flex-wrap: wrap;
gap: 8px;
justify-content: flex-end;
}
&__gl {
display: inline-flex;
align-items: center;
gap: 8px;
font-size: 14px;
font-weight: 600;
color: $brand;
&--old {
font-weight: 500;
color: $text-soft;
text-decoration: line-through;
}
&--none {
color: #b91c1c;
}
}
&__code {
padding: 1px 8px;
border: 1px solid $border;
border-radius: 6px;
background: $bg-soft;
font-family: ui-monospace, 'SF Mono', 'Cascadia Mono', Consolas, monospace;
font-size: 12px;
font-weight: 600;
color: $text-mid;
}
&__arrow {
width: 18px;
height: 18px;
font-size: 18px;
color: $warning;
}
}
@@ -25,7 +25,10 @@ import { OrganizationService } from 'app/organization/organization.service';
@Component({ @Component({
selector: 'mifosx-dds-approval-review', selector: 'mifosx-dds-approval-review',
templateUrl: './dds-approval-review.component.html', templateUrl: './dds-approval-review.component.html',
styleUrls: ['../../../clerk/disbursement-review/disbursement-review.component.scss'], styleUrls: [
'../../../clerk/disbursement-review/disbursement-review.component.scss',
'./dds-approval-review.component.scss'
],
standalone: true, standalone: true,
imports: [ imports: [
CommonModule, CommonModule,
@@ -76,6 +79,129 @@ export class DdsApprovalReviewComponent implements OnInit {
return (this.detail?.lines || []).filter((line: any) => line.side === 'CR'); return (this.detail?.lines || []).filter((line: any) => line.side === 'CR');
} }
/** A product change's terms, field by field - empty for an entry that moves money. */
get fields(): any[] {
return this.detail?.fields || [];
}
/** An edit still waiting is shown against the product as it stands today. */
get compareFields(): boolean {
return this.fields.some((field: any) => 'current' in field);
}
/** A DDS product request, which is laid out as the product rather than as a money movement. */
get isProductReview(): boolean {
return this.entry?.entity === 'DDSACCOUNTTYPE' && this.fields.length > 0;
}
/** A DDS agent request, laid out as the agent and their guarantors rather than as a money movement. */
get isAgentReview(): boolean {
return this.entry?.entity === 'AGENT' && this.fields.length > 0;
}
/** A DDS account request, laid out as the account and its nominees rather than as a money movement. */
get isPartyReview(): boolean {
return this.entry?.entity === 'PARTY' && this.fields.length > 0;
}
/** The nominees a DDS account request names. */
get nominees(): any[] {
return this.detail?.nominees || [];
}
/** The guarantors an agent request names. */
get guarantors(): any[] {
return this.detail?.guarantors || [];
}
get isEdit(): boolean {
return this.entry?.action === 'UPDATE';
}
/** A term's value as it will stand once approved, by its request parameter name. */
valueOf(key: string): string {
return this.fields.find((field: any) => field.key === key)?.value || '';
}
/** The kind of ledger account a product's Accounting row names - customers' deposits are owed, the rest are costs. */
glKind(key: string): string {
return key === 'depositLiabilityGlId' ? 'Liability' : 'Expense';
}
/** "DDS CUSTOMER DEPOSITS (200601)" → "DDS CUSTOMER DEPOSITS"; a missing account reads "Not set". */
glName(value: string | null | undefined): string {
if (!value) {
return 'Not set';
}
const match = /^(.*) (([^()]+))$/.exec(value);
return match ? match[1] : value;
}
/** "DDS CUSTOMER DEPOSITS (200601)" → "200601". */
glCode(value: string | null | undefined): string {
const match = value ? /^(.*) (([^()]+))$/.exec(value) : null;
return match ? match[2] : '';
}
get changedCount(): number {
return this.fields.filter((field: any) => field.changed).length;
}
private static readonly GROUP_ICONS: Record<string, string> = {
Product: 'inventory_2',
Interest: 'trending_up',
'Maturity & Premature Closure': 'event_busy',
Accounting: 'menu_book',
'Customer & Scheme': 'groups',
'Personal & Contact': 'contact_page',
'Minor / Guardian': 'escalator_warning',
'Scheme Terms': 'event_note',
'Balances & Status': 'toggle_on',
'Maturity Payout': 'currency_exchange',
Agent: 'badge',
'Address & Contact': 'location_on',
'Branch & Accounts': 'account_balance',
Balances: 'balance'
};
/** The terms in the sections the server groups them into, in the order it sends them. */
get fieldGroups(): { name: string; icon: string; changed: number; fields: any[] }[] {
if (this.groupsFor !== this.fields) {
const groups: { name: string; icon: string; changed: number; fields: any[] }[] = [];
for (const field of this.fields) {
const name = field.group || 'Details';
let group = groups.find((g) => g.name === name);
if (!group) {
group = { name, icon: DdsApprovalReviewComponent.GROUP_ICONS[name] || 'tune', changed: 0, fields: [] };
groups.push(group);
}
group.fields.push(field);
group.changed += field.changed ? 1 : 0;
}
this.groupsFor = this.fields;
this.groups = groups;
}
return this.groups;
}
/** Built once per load rather than on every change detection pass. */
private groupsFor: any[] | null = null;
private groups: { name: string; icon: string; changed: number; fields: any[] }[] = [];
/** Whether the entry moves money at all, and so has an amount and debit and credit sides to show. */
/**
* A calculation run is approved to record what has been earned in DDS's own tables; it makes no journal
* entry itself. The sides shown for it are what the matching Posting run will book, marked so they are
* not read as entries made on approval.
*/
get entriesProjected(): boolean {
return (this.detail?.lines || []).some((line: any) => line.projected);
}
get movesMoney(): boolean {
return (this.entry?.amount !== null && this.entry?.amount !== undefined) || !!this.detail?.lines?.length;
}
total(lines: any[]): number { total(lines: any[]): number {
return lines.reduce((sum: number, line: any) => sum + Number(line.amount || 0), 0); return lines.reduce((sum: number, line: any) => sum + Number(line.amount || 0), 0);
} }
@@ -21,6 +21,7 @@ import { OrganizationService } from 'app/organization/organization.service';
import { SettingsService } from 'app/settings/settings.service'; import { SettingsService } from 'app/settings/settings.service';
import { AuthenticationService } from 'app/core/authentication/authentication.service'; import { AuthenticationService } from 'app/core/authentication/authentication.service';
import { DdsApprovalReviewComponent } from './dds-approval-review/dds-approval-review.component'; import { DdsApprovalReviewComponent } from './dds-approval-review/dds-approval-review.component';
import { isDdsRoutedRequest } from 'app/tasks/product-entities';
/** /**
* Approval Center › DDS Approvals: every DDS entry waiting on a checker, in the clerk's terms - what * Approval Center › DDS Approvals: every DDS entry waiting on a checker, in the clerk's terms - what
@@ -104,7 +105,11 @@ export class DdsApprovalsComponent implements OnInit {
.getDdsApprovals(this.filterStatus, this.isoDate(this.fromDate), this.isoDate(this.toDate)) .getDdsApprovals(this.filterStatus, this.isoDate(this.fromDate), this.isoDate(this.toDate))
.pipe(catchError(() => of([]))) .pipe(catchError(() => of([])))
.subscribe((rows: any) => { .subscribe((rows: any) => {
this.entries = Array.isArray(rows) ? rows : []; // Pending DDS product and account requests wait under Product Approval and Pending Account
// instead; once actioned they show here again, so the DDS history stays in one place.
this.entries = (Array.isArray(rows) ? rows : []).filter(
(row: any) => this.filterStatus !== 'PENDING' || !isDdsRoutedRequest(row?.entity, row?.action)
);
this.pageIndex = 0; this.pageIndex = 0;
this.loading = false; this.loading = false;
this.loaded = true; this.loaded = true;
@@ -11,16 +11,39 @@
<button mat-stroked-button [routerLink]="['/accounts/dds']" class="pm-back"> <button mat-stroked-button [routerLink]="['/accounts/dds']" class="pm-back">
<i class="fas fa-arrow-left"></i> Party Master <i class="fas fa-arrow-left"></i> Party Master
</button> </button>
<div class="pm-header__icon"><i class="fas fa-users"></i></div>
<div class="pm-header__text">
<h2 class="pm-header__title">{{ partyId ? 'Edit Party' : 'Party Master Entry' }}</h2> <h2 class="pm-header__title">{{ partyId ? 'Edit Party' : 'Party Master Entry' }}</h2>
<p class="pm-header__sub">
{{
partyId
? 'Update this daily deposit party.'
: 'Open a daily deposit party: who it is for, the scheme it runs under and the agent who collects.'
}}
</p>
</div>
@if (isClosed) { @if (isClosed) {
<span class="pm-closed-badge">Closed — view only</span> <span class="pm-closed-badge">Closed — view only</span>
} @else if (termsLocked) {
<span
class="pm-locked-badge"
matTooltip="Customer, product, agent, branch, opening date and deposit terms (daily amount, deposit, days, months, interest/pay/reopen dates) cannot be changed on an active account. Contact details, nominees, lien and freeze can."
>
<i class="fas fa-lock"></i> Active — scheme terms locked
</span>
} }
</div> </div>
<div class="pm-card"> <div class="pm-card">
<form [formGroup]="partyForm"> <form [formGroup]="partyForm">
<mat-tab-group class="pm-tabs" [(selectedIndex)]="selectedTabIndex" animationDuration="200ms"> <mat-tab-group class="pm-tabs" [(selectedIndex)]="selectedTabIndex" animationDuration="200ms">
<mat-tab label="Main Detail"> <mat-tab>
<ng-template mat-tab-label>
<i class="fas fa-id-card pm-tab-icon"></i>Customer &amp; Scheme
@if (tabHasError('customer')) {
<i class="fas fa-exclamation-circle pm-tab-alert" aria-label="Needs attention"></i>
}
</ng-template>
<div class="pm-tab-content"> <div class="pm-tab-content">
<div class="pm-section"> <div class="pm-section">
<div class="pm-section__header"> <div class="pm-section__header">
@@ -38,35 +61,27 @@
matInput matInput
required required
formControlName="clientId" formControlName="clientId"
[matAutocomplete]="clientAuto" (keydown.enter)="$event.preventDefault(); openClientSearch()"
(focus)="onClientFocus()" placeholder="Client ID, or search by name or mobile number…"
placeholder="Click to see customers, or type a name or ID…"
autocomplete="off" autocomplete="off"
/> />
<fa-icon matSuffix icon="search" class="pm-search-ic"></fa-icon> <!-- Opens Search Customer; picking a customer puts them here, which fills the rest. -->
<mat-autocomplete <button
#clientAuto="matAutocomplete" matSuffix
panelWidth="min(30rem, 92vw)" type="button"
[displayWith]="displayClient" class="pm-search-btn"
aria-label="Search customer"
matTooltip="Search by ID, name or mobile number"
[disabled]="partyForm.get('clientId')?.disabled"
(click)="$event.stopPropagation(); openClientSearch()"
> >
@for (client of clientOptions; track client.id) { <i class="fas fa-search"></i>
<mat-option [value]="client"> </button>
<div class="pm-opt"> @if (clientNeedsSearch) {
<span class="pm-opt-id">{{ client.id }}</span> <mat-hint class="pm-hint--missing"
<span class="pm-opt-main">{{ client.displayName }}</span> >Press search to find this client by name or mobile number</mat-hint
<span class="pm-opt-sub">{{ client.officeName }}</span> >
</div> } @else if (loadedClientName) {
</mat-option>
}
<!-- Says so when the list is only the first page, so a customer missing from
it reads as "keep typing" rather than "not a customer here". -->
@if (clientListIsPartial) {
<mat-option disabled class="pm-opt-more">
Showing {{ clientOptions.length }} of {{ clientTotal }} — type a name to find the rest
</mat-option>
}
</mat-autocomplete>
@if (loadedClientName) {
<mat-hint class="pm-hint--found">{{ loadedClientName }}</mat-hint> <mat-hint class="pm-hint--found">{{ loadedClientName }}</mat-hint>
} @else if (clientLookupFailed) { } @else if (clientLookupFailed) {
<mat-hint class="pm-hint--missing">No client with this ID</mat-hint> <mat-hint class="pm-hint--missing">No client with this ID</mat-hint>
@@ -228,18 +243,28 @@
</div> </div>
} }
<!-- Not a field: the server gives the code when the party is saved. A new party is shown the
code it is expected to get; one already on the books shows the code it has. -->
<div class="pm-f"> <div class="pm-f">
<label class="pm-f-lbl">Party Code</label> <label class="pm-f-lbl">Party Code</label>
<mat-form-field appearance="outline" class="pm-field"> <div class="pm-code">
<input matInput formControlName="partyCode" placeholder="Auto-generated" /> <span class="pm-code__value">{{
</mat-form-field> (partyId ? partyForm.get('partyCode')?.value : expectedPartyCode) || '—'
}}</span>
<span class="pm-code__hint">{{
partyId ? 'Assigned when this party was opened' : 'Expected - assigned when saved'
}}</span>
</div>
</div> </div>
<!-- Not a field: the number of the DDS account opened most recently, for reference. Read from
the books and never saved. -->
<div class="pm-f"> <div class="pm-f">
<label class="pm-f-lbl">Last A/c</label> <label class="pm-f-lbl">Last A/c</label>
<mat-form-field appearance="outline" class="pm-field"> <div class="pm-code">
<input matInput formControlName="lastAccount" placeholder="—" /> <span class="pm-code__value">{{ lastAccountNo || '—' }}</span>
</mat-form-field> <span class="pm-code__hint">Latest DDS account opened</span>
</div>
</div> </div>
<div class="pm-f"> <div class="pm-f">
@@ -251,7 +276,27 @@
</mat-select> </mat-select>
</mat-form-field> </mat-form-field>
</div> </div>
</div>
</div>
</div>
</mat-tab>
<!-- The same section, set out over its own tab: who the party is, where, and how to reach them. -->
<mat-tab>
<ng-template mat-tab-label>
<i class="fas fa-address-card pm-tab-icon"></i>Personal &amp; Contact
@if (tabHasError('personal')) {
<i class="fas fa-exclamation-circle pm-tab-alert" aria-label="Needs attention"></i>
}
</ng-template>
<div class="pm-tab-content">
<div class="pm-section">
<div class="pm-section__header">
<i class="fas fa-address-card"></i>
<h3>Personal &amp; Contact</h3>
<span class="pm-section__sub">Name, address, contact and identity - filled from the client</span>
</div>
<div class="pm-grid">
<div class="pm-f pm-f--wide"> <div class="pm-f pm-f--wide">
<label class="pm-f-lbl">Party Name<b>*</b></label> <label class="pm-f-lbl">Party Name<b>*</b></label>
<mat-form-field appearance="outline" class="pm-field"> <mat-form-field appearance="outline" class="pm-field">
@@ -427,7 +472,17 @@
</div> </div>
</div> </div>
</div> </div>
</div>
</mat-tab>
<mat-tab>
<ng-template mat-tab-label>
<i class="fas fa-child pm-tab-icon"></i>Minor / Guardian
@if (tabHasError('minor')) {
<i class="fas fa-exclamation-circle pm-tab-alert" aria-label="Needs attention"></i>
}
</ng-template>
<div class="pm-tab-content">
<div class="pm-section"> <div class="pm-section">
<div class="pm-section__header"> <div class="pm-section__header">
<i class="fas fa-user-friends"></i> <i class="fas fa-user-friends"></i>
@@ -519,7 +574,17 @@
<p class="pm-note">An adult's account needs no guardian details.</p> <p class="pm-note">An adult's account needs no guardian details.</p>
} }
</div> </div>
</div>
</mat-tab>
<mat-tab>
<ng-template mat-tab-label>
<i class="fas fa-calendar-day pm-tab-icon"></i>Scheme Terms
@if (tabHasError('scheme')) {
<i class="fas fa-exclamation-circle pm-tab-alert" aria-label="Needs attention"></i>
}
</ng-template>
<div class="pm-tab-content">
<div class="pm-section"> <div class="pm-section">
<div class="pm-section__header"> <div class="pm-section__header">
<i class="fas fa-calendar-day"></i> <i class="fas fa-calendar-day"></i>
@@ -620,7 +685,17 @@
<!-- Withdraw Type is decided when the account is paid out, not when it is opened. --> <!-- Withdraw Type is decided when the account is paid out, not when it is opened. -->
</div> </div>
</div> </div>
</div>
</mat-tab>
<mat-tab>
<ng-template mat-tab-label>
<i class="fas fa-balance-scale pm-tab-icon"></i>Balances &amp; Status
@if (tabHasError('balances')) {
<i class="fas fa-exclamation-circle pm-tab-alert" aria-label="Needs attention"></i>
}
</ng-template>
<div class="pm-tab-content">
<div class="pm-section"> <div class="pm-section">
<div class="pm-section__header"> <div class="pm-section__header">
<i class="fas fa-exclamation-triangle"></i> <i class="fas fa-exclamation-triangle"></i>
@@ -761,8 +836,19 @@
<!-- Adv Code is decided when the account is paid out, not when it is opened. --> <!-- Adv Code is decided when the account is paid out, not when it is opened. -->
</div> </div>
</div> </div>
</div>
</mat-tab>
<!-- Only when the account is paid out into a savings account - as the section always was. -->
@if (creditToRequired) { @if (creditToRequired) {
<mat-tab>
<ng-template mat-tab-label>
<i class="fas fa-exchange-alt pm-tab-icon"></i>Maturity Payout
@if (tabHasError('payout')) {
<i class="fas fa-exclamation-circle pm-tab-alert" aria-label="Needs attention"></i>
}
</ng-template>
<div class="pm-tab-content">
<div class="pm-section"> <div class="pm-section">
<div class="pm-section__header"> <div class="pm-section__header">
<i class="fas fa-exchange-alt"></i> <i class="fas fa-exchange-alt"></i>
@@ -821,15 +907,30 @@
</div> </div>
</div> </div>
</div> </div>
}
</div> </div>
</mat-tab> </mat-tab>
}
<mat-tab label="Nominee's Detail"> <mat-tab>
<ng-template mat-tab-label>
<i class="fas fa-user-friends pm-tab-icon"></i>Nominee's Detail
@if (tabHasError('nominee')) {
<i class="fas fa-exclamation-circle pm-tab-alert" aria-label="Needs attention"></i>
}
</ng-template>
<div class="pm-tab-content"> <div class="pm-tab-content">
<div class="pm-section"> <div class="pm-section">
<div class="pm-grid"> <div class="pm-section__header">
<div class="pm-f"> <i class="fas fa-user-friends"></i>
<h3>Nominee's Detail</h3>
<span class="pm-section__sub"
>Who the deposit is paid to if the account holder is not there to claim it</span
>
</div>
<!-- Whether there are nominees, how the deposit is split between them, and how it adds up. -->
<div class="pm-nom-bar">
<div class="pm-f pm-nom-bar__applicable">
<label class="pm-f-lbl">Nominee</label> <label class="pm-f-lbl">Nominee</label>
<mat-form-field appearance="outline" class="pm-field"> <mat-form-field appearance="outline" class="pm-field">
<mat-select formControlName="nomineeApplicable"> <mat-select formControlName="nomineeApplicable">
@@ -840,42 +941,80 @@
</div> </div>
@if (nomineesRequired) { @if (nomineesRequired) {
<mat-radio-group formControlName="nomineeSplitMode" class="pm-radio-group"> <div class="pm-f pm-nom-bar__mode">
<label class="pm-f-lbl">Split by</label>
<mat-radio-group formControlName="nomineeSplitMode" class="pm-nom-mode">
<mat-radio-button value="Percentage">Percentage</mat-radio-button> <mat-radio-button value="Percentage">Percentage</mat-radio-button>
<mat-radio-button value="Sequence">Sequence</mat-radio-button> <mat-radio-button value="Sequence">Sequence</mat-radio-button>
</mat-radio-group> </mat-radio-group>
</div>
<button mat-stroked-button type="button" class="pm-add-nominee" (click)="addNominee()">
<i class="fas fa-plus"></i> Add Nominee
</button>
@if (splitByPercentage && nominees.length > 0) { @if (splitByPercentage && nominees.length > 0) {
<span class="pm-share-total" [class.pm-share-total-bad]="nomineeShareError"> <div class="pm-nom-total" [class.pm-nom-total--bad]="nomineeShareError">
{{ nomineeShareTotal }}% of 100% allocated <div class="pm-nom-total__text">
<strong>{{ nomineeShareTotal }}%</strong> of 100% allocated
@if (nomineeShareError) { @if (nomineeShareError) {
— {{ nomineeShareError }} <span class="pm-nom-total__error">— {{ nomineeShareError }}</span>
} }
</span> </div>
<div class="pm-nom-total__track">
<div
class="pm-nom-total__fill"
[class.pm-nom-total__fill--done]="nomineeShareTotal === 100"
[style.width.%]="nomineeShareTotal > 100 ? 100 : nomineeShareTotal"
></div>
</div>
</div>
} @else if (splitBySequence && nominees.length > 0) { } @else if (splitBySequence && nominees.length > 0) {
<span class="pm-share-total" [class.pm-share-total-bad]="nomineeOrderError"> <div class="pm-nom-total" [class.pm-nom-total--bad]="nomineeOrderError">
<div class="pm-nom-total__text">
@if (nomineeOrderError) { @if (nomineeOrderError) {
{{ nomineeOrderError }} {{ nomineeOrderError }}
} @else { } @else {
Paid in the order numbered, 1 first Paid in the order numbered, 1 first
} }
</span> </div>
</div>
} }
<button mat-stroked-button type="button" class="pm-nom-add" (click)="addNominee()">
<i class="fas fa-plus"></i> Add Nominee
</button>
} }
</div> </div>
@if (!nomineesRequired) { @if (!nomineesRequired) {
<p class="pm-note">Set Nominee to Yes to nominate someone for this account.</p> <p class="pm-note">Set Nominee to Yes to nominate someone for this account.</p>
} @else if (nominees.length === 0) {
<div class="pm-nom-empty">
<i class="fas fa-user-plus"></i>
<p>No nominee added yet.</p>
<span>Use Add Nominee to name who the deposit is paid to.</span>
</div>
} }
@for (nominee of nominees.controls; track nominee; let i = $index) { @for (nominee of nominees.controls; track nominee; let i = $index) {
<div class="pm-nominee-row" [formGroup]="$any(nominee)"> <div class="pm-nom-card" [formGroup]="$any(nominee)">
<div class="pm-f pm-f--date"> <div class="pm-nom-card__head">
<label class="pm-f-lbl">Nom.Dt<b>*</b></label> <span class="pm-nom-card__no">{{ i + 1 }}</span>
<span class="pm-nom-card__title">Nominee {{ i + 1 }}</span>
@if (isNomineeMinor(nominee)) {
<span class="pm-nom-card__badge">Minor</span>
}
<button
type="button"
class="pm-nom-card__remove"
(click)="removeNominee(i)"
matTooltip="Remove nominee"
aria-label="Remove nominee"
>
<i class="fas fa-trash"></i>
</button>
</div>
<div class="pm-nom-card__grid">
<div class="pm-f">
<label class="pm-f-lbl">Nomination Date<b>*</b></label>
<mat-form-field appearance="outline" class="pm-field"> <mat-form-field appearance="outline" class="pm-field">
<input matInput required [matDatepicker]="nomDatePicker" formControlName="nominationDate" /> <input matInput required [matDatepicker]="nomDatePicker" formControlName="nominationDate" />
<mat-datepicker-toggle matIconSuffix [for]="nomDatePicker"></mat-datepicker-toggle> <mat-datepicker-toggle matIconSuffix [for]="nomDatePicker"></mat-datepicker-toggle>
@@ -894,30 +1033,26 @@
<input matInput formControlName="mobileNo" mifosxRestrictInput="digits" /> <input matInput formControlName="mobileNo" mifosxRestrictInput="digits" />
</mat-form-field> </mat-form-field>
</div> </div>
<div class="pm-f pm-f--date"> <div class="pm-f">
<label class="pm-f-lbl">Birth Dt</label> <label class="pm-f-lbl">Birth Date</label>
<mat-form-field appearance="outline" class="pm-field"> <mat-form-field appearance="outline" class="pm-field">
<input matInput [matDatepicker]="nomBirthPicker" formControlName="birthDate" /> <input matInput [matDatepicker]="nomBirthPicker" formControlName="birthDate" />
<mat-datepicker-toggle matIconSuffix [for]="nomBirthPicker"></mat-datepicker-toggle> <mat-datepicker-toggle matIconSuffix [for]="nomBirthPicker"></mat-datepicker-toggle>
<mat-datepicker #nomBirthPicker></mat-datepicker> <mat-datepicker #nomBirthPicker></mat-datepicker>
</mat-form-field> </mat-form-field>
</div> </div>
<div class="pm-f pm-f--tight">
<!-- Read from the date of birth, not asked: the two arriving separately is how
a ten-year-old came to be recorded as not a minor. -->
<label class="pm-f-lbl">Minor</label>
<mat-form-field appearance="outline" class="pm-field">
<input matInput readonly [value]="isNomineeMinor(nominee) ? 'Yes' : 'No'" />
<mat-hint>From date of birth</mat-hint>
</mat-form-field>
</div>
<div class="pm-f"> <div class="pm-f">
<label class="pm-f-lbl">Relation<b>*</b></label> <label class="pm-f-lbl">Relation<b>*</b></label>
<mat-form-field appearance="outline" class="pm-field"> <mat-form-field appearance="outline" class="pm-field">
<input matInput required formControlName="relation" /> <mat-select required formControlName="relation">
@for (relation of relationChoices(nominee); track relation) {
<mat-option [value]="relation">{{ relation }}</mat-option>
}
</mat-select>
</mat-form-field> </mat-form-field>
</div> </div>
<div class="pm-f pm-f--tight"> <div class="pm-f">
<label class="pm-f-lbl">Gender<b>*</b></label> <label class="pm-f-lbl">Gender<b>*</b></label>
<mat-form-field appearance="outline" class="pm-field"> <mat-form-field appearance="outline" class="pm-field">
<mat-select required formControlName="gender"> <mat-select required formControlName="gender">
@@ -928,21 +1063,16 @@
</mat-form-field> </mat-form-field>
</div> </div>
<div class="pm-f"> <div class="pm-f">
<label class="pm-f-lbl"> <!-- Read from the date of birth, not asked: the two arriving separately is how
Guar. Name a ten-year-old came to be recorded as not a minor. -->
@if (isNomineeMinor(nominee)) { <label class="pm-f-lbl">Minor</label>
<b>*</b>
}
</label>
<mat-form-field appearance="outline" class="pm-field"> <mat-form-field appearance="outline" class="pm-field">
<input matInput [required]="isNomineeMinor(nominee)" formControlName="guardianName" /> <input matInput readonly [value]="isNomineeMinor(nominee) ? 'Yes' : 'No'" />
@if (isNomineeMinor(nominee)) { <mat-hint>From date of birth</mat-hint>
<mat-hint>Required for a minor</mat-hint>
}
</mat-form-field> </mat-form-field>
</div> </div>
@if (splitByPercentage) { @if (splitByPercentage) {
<div class="pm-f pm-f--tight"> <div class="pm-f">
<label class="pm-f-lbl">Share %<b>*</b></label> <label class="pm-f-lbl">Share %<b>*</b></label>
<mat-form-field appearance="outline" class="pm-field"> <mat-form-field appearance="outline" class="pm-field">
<input <input
@@ -957,22 +1087,26 @@
</mat-form-field> </mat-form-field>
</div> </div>
} @else { } @else {
<div class="pm-f pm-f--tight"> <div class="pm-f">
<label class="pm-f-lbl">Order<b>*</b></label> <label class="pm-f-lbl">Order<b>*</b></label>
<mat-form-field appearance="outline" class="pm-field"> <mat-form-field appearance="outline" class="pm-field">
<input matInput type="number" min="1" step="1" required formControlName="sequenceNo" /> <input matInput type="number" min="1" step="1" required formControlName="sequenceNo" />
</mat-form-field> </mat-form-field>
</div> </div>
} }
<button
mat-icon-button <!-- Only for a minor, who cannot receive the money themselves - an adult has no guardian to
type="button" name. Whatever was typed is cleared when the date of birth makes them an adult. -->
class="pm-remove-nominee" @if (isNomineeMinor(nominee)) {
(click)="removeNominee(i)" <div class="pm-f pm-nom-card__guardian">
matTooltip="Remove nominee" <label class="pm-f-lbl">Guardian Name<b>*</b></label>
> <mat-form-field appearance="outline" class="pm-field">
<i class="fas fa-trash"></i> <input matInput required formControlName="guardianName" />
</button> <mat-hint>Required for a minor</mat-hint>
</mat-form-field>
</div>
}
</div>
</div> </div>
} }
</div> </div>
@@ -1036,6 +1170,16 @@
<i class="fas fa-lock" style="margin-right: 6px"></i> Close Account <i class="fas fa-lock" style="margin-right: 6px"></i> Close Account
</button> </button>
} }
@if (selectedTabIndex > 0) {
<button mat-stroked-button type="button" (click)="selectedTabIndex = selectedTabIndex - 1">
<i class="fas fa-arrow-left" style="margin-right: 8px"></i>Previous
</button>
}
@if (selectedTabIndex < tabCount - 1) {
<button mat-stroked-button type="button" color="primary" (click)="selectedTabIndex = selectedTabIndex + 1">
Next<i class="fas fa-arrow-right" style="margin-left: 8px"></i>
</button>
}
<button <button
mat-raised-button mat-raised-button
color="primary" color="primary"
@@ -135,75 +135,12 @@ $primary: #0f766e;
height: 56px; height: 56px;
} }
.pm-add-nominee {
color: $primary;
height: fit-content;
align-self: center;
}
/*
* The running total of the shares entered, beside the button that adds another. Reads as ordinary
* text while the split still adds up and turns to the warning colour when it does not, so the clerk
* sees the state of the whole nomination without having to try to save it.
*/
.pm-share-total {
align-self: center;
margin-left: 12px;
font-size: 13px;
color: rgb(0 0 0 / 60%);
}
.pm-share-total-bad {
color: #b3261e;
font-weight: 500;
}
.pm-empty { .pm-empty {
color: rgb(0 0 0 / 50%); color: rgb(0 0 0 / 50%);
font-size: 14px; font-size: 14px;
margin: 0 0 8px; margin: 0 0 8px;
} }
/*
* One nominee, laid out as fields that wrap rather than columns that must all fit.
*
* A fixed column per field only works while the count is fixed, and it is not - the Share % field
* appears and disappears with the split mode, and a tenth column pushed the remove button onto a
* line of its own. Each field instead states the width it needs to stay readable and the row breaks
* where it runs out of space, so nothing is squeezed and nothing is orphaned.
*/
.pm-nominee-row {
display: flex;
flex-wrap: wrap;
gap: 4px 12px;
align-items: start;
padding: 12px 0;
border-top: 1px dashed rgb(0 0 0 / 10%);
&:first-of-type {
border-top: none;
}
/* Names and addresses need room; a date, Yes/No or a percentage does not. */
.pm-f {
flex: 1 1 170px;
min-width: 0;
}
.pm-f--date {
flex: 0 1 140px;
}
.pm-f--tight {
flex: 0 1 100px;
}
}
.pm-remove-nominee {
color: #b91c1c;
margin-top: 8px;
}
.pm-action-bar { .pm-action-bar {
display: flex; display: flex;
justify-content: flex-end; justify-content: flex-end;
@@ -260,6 +197,22 @@ $primary: #0f766e;
color: rgb(0 0 0 / 60%); color: rgb(0 0 0 / 60%);
} }
/* On an active account: the scheme terms are locked, the rest is editable. */
.pm-locked-badge {
margin-left: 12px;
padding: 3px 11px;
border-radius: 11px;
background: #fff7ed;
color: #9a3412;
font-size: 0.72rem;
font-weight: 600;
cursor: help;
i {
margin-right: 4px;
}
}
/* Says whose details a block of fields is for, so the screen explains itself. */ /* Says whose details a block of fields is for, so the screen explains itself. */
.pm-section__sub { .pm-section__sub {
flex: 1 1 auto; flex: 1 1 auto;
@@ -454,24 +407,6 @@ input.pm-money {
} }
} }
// The nominee grid is its own row of fields, so its labels sit above rather than beside - there is
// no room for a label column eight fields across.
.pm-nominee-row {
gap: 0 10px;
padding: 3px 0;
.pm-f {
grid-template-columns: minmax(0, 1fr);
row-gap: 1px;
}
.pm-f-lbl {
text-align: left;
padding-top: 0;
font-size: 10.5px;
}
}
.pm-note { .pm-note {
margin: 0; margin: 0;
font-size: 0.74rem; font-size: 0.74rem;
@@ -526,3 +461,478 @@ input.pm-money {
border-color: rgb(180 83 9 / 25%); border-color: rgb(180 83 9 / 25%);
color: #b45309; color: #b45309;
} }
/*
* Modern look.
*
* The Agent Master Entry screen's look, carried over to this one: an icon and a line of explanation in the header, a
* soft card with a tab bar, each group of fields in a panel of its own, labels above the fields they name. The fields,
* their order and every rule about them are as they were; only how they are drawn has changed. Kept compact - the
* fields stay short and the hint line under each stays, so an operator opening accounts all day still sees a good part
* of the form at once.
*/
$primary-dark: #0f4f3c;
$primary-light: #e6f4ee;
$border: #e2e8f0;
$text-dark: #0f172a;
$text-mid: #475569;
$danger: #b3261e;
.pm-container {
padding: 20px 16px 24px;
}
.pm-header {
gap: 14px;
margin-bottom: 16px;
&__icon {
display: grid;
place-items: center;
flex: 0 0 auto;
width: 46px;
height: 46px;
border-radius: 14px;
background: linear-gradient(135deg, $primary 0%, $primary-dark 100%);
color: #fff;
font-size: 19px;
box-shadow: 0 6px 16px rgb(15 79 60 / 22%);
}
&__text {
min-width: 0;
}
&__title {
font-size: 22px;
font-weight: 600;
color: $text-dark;
}
&__sub {
margin: 2px 0 0;
font-size: 13px;
color: $text-mid;
}
}
.pm-card {
border: 1px solid $border;
border-radius: 14px;
box-shadow: 0 2px 10px rgb(15 23 42 / 6%);
overflow: hidden;
// Field outlines: a quiet grey that turns to the brand colour when the field is used, with softer corners.
--mat-form-field-outlined-container-shape: 8px;
--mat-form-field-outlined-outline-color: #cbd5e1;
--mat-form-field-outlined-hover-outline-color: #94a3b8;
--mat-form-field-outlined-focus-outline-color: #{$primary};
}
.pm-tabs {
::ng-deep {
.mat-mdc-tab-header {
background: #f8fafc;
border-bottom: 1px solid $border;
}
// Seven tabs now share the row, so each gives up a little of its padding.
.mat-mdc-tab {
min-width: 100px;
padding: 0 14px;
height: 52px;
}
.mat-mdc-tab .mdc-tab__text-label {
color: $text-mid;
font-weight: 500;
}
.mat-mdc-tab.mdc-tab--active .mdc-tab__text-label {
color: $primary-dark;
font-weight: 600;
}
.mat-mdc-tab .mdc-tab-indicator__content--underline {
border-color: $primary;
border-top-width: 3px;
}
// A date picker's panel and a field's hint open past the edge of a tab; do not clip them.
.mat-mdc-tab-body,
.mat-mdc-tab-body-content {
overflow: visible !important;
}
}
}
.pm-tab-icon {
margin-right: 8px;
font-size: 14px;
}
// ── Nominee's Detail ────────────────────────────────────────────────────────────────────────────────
// What decides the nomination - whether there are nominees, how the deposit is split, how it adds up -
// sits in one bar above the nominees; each nominee is a card of its own.
.pm-nom-bar {
display: flex;
align-items: flex-end;
flex-wrap: wrap;
gap: 8px 20px;
margin-bottom: 18px;
padding: 14px 16px 6px;
border: 1px solid $border;
border-radius: 12px;
background: #f8fafc;
&__applicable {
flex: 0 0 150px;
}
&__mode {
flex: 0 0 auto;
}
}
// Percentage or Sequence, as two pills rather than two loose radio buttons.
.pm-nom-mode {
display: inline-flex;
gap: 8px;
height: 56px;
align-items: center;
::ng-deep .mat-mdc-radio-button {
padding: 0 12px 0 2px;
border: 1px solid #cbd5e1;
border-radius: 999px;
background: #fff;
font-size: 13px;
}
::ng-deep .mat-mdc-radio-button.mat-mdc-radio-checked {
border-color: #1f7a4d;
background: #e6f4ee;
font-weight: 600;
}
}
.pm-nom-total {
flex: 1 1 240px;
align-self: center;
margin-bottom: 14px;
min-width: 200px;
&__text {
font-size: 13px;
color: $text-mid;
}
&__error {
margin-left: 4px;
}
&__track {
height: 6px;
margin-top: 6px;
border-radius: 999px;
background: #e2e8f0;
overflow: hidden;
}
&__fill {
height: 100%;
border-radius: 999px;
background: #f59e0b;
transition: width 0.2s ease;
&--done {
background: #16a34a;
}
}
// The split does not add up: the same warning colour the form has always used for it.
&--bad &__text {
color: $danger;
font-weight: 500;
}
&--bad &__fill {
background: $danger;
}
}
.pm-nom-add {
margin-bottom: 14px;
color: $primary;
white-space: nowrap;
}
.pm-nom-empty {
display: flex;
flex-direction: column;
align-items: center;
gap: 4px;
padding: 28px 16px;
border: 1.5px dashed #cbd5e1;
border-radius: 12px;
background: #f8fafc;
color: #94a3b8;
text-align: center;
i {
font-size: 26px;
}
p {
margin: 0;
font-size: 14px;
font-weight: 600;
color: $text-mid;
}
span {
font-size: 12.5px;
}
}
.pm-nom-card {
margin-bottom: 14px;
padding: 12px 16px 6px;
border: 1px solid $border;
border-radius: 12px;
background: #fff;
box-shadow: 0 1px 3px rgb(15 23 42 / 5%);
&__head {
display: flex;
align-items: center;
gap: 10px;
margin-bottom: 10px;
}
&__no {
display: grid;
place-items: center;
width: 26px;
height: 26px;
border-radius: 50%;
background: #e6f4ee;
color: #0f4f3c;
font-size: 12px;
font-weight: 700;
}
&__title {
font-size: 14px;
font-weight: 600;
color: #0f172a;
}
&__badge {
padding: 2px 10px;
border-radius: 999px;
background: #fef3c7;
color: #b45309;
font-size: 11px;
font-weight: 700;
letter-spacing: 0.04em;
text-transform: uppercase;
}
&__remove {
margin-left: auto;
padding: 6px 9px;
border: 0;
border-radius: 8px;
background: transparent;
color: $danger;
cursor: pointer;
transition: background 0.15s;
&:hover {
background: #fef2f2;
}
}
&__grid {
display: grid;
grid-template-columns: repeat(4, minmax(0, 1fr));
gap: 0 16px;
}
// The guardian is named on a line of its own, where it reads as belonging to the minor above it.
&__guardian {
grid-column: 1 / -1;
}
}
@media (width <= 900px) {
.pm-nom-card__grid {
grid-template-columns: repeat(2, minmax(0, 1fr));
}
}
@media (width <= 560px) {
.pm-nom-card__grid {
grid-template-columns: 1fr;
}
}
// The party code, shown rather than asked for: it is given when the party is saved.
.pm-code {
display: flex;
flex-direction: column;
justify-content: center;
gap: 1px;
min-height: 56px;
padding: 6px 16px;
border: 1px solid #b7ddd0;
border-radius: 4px;
background: #e6f4ee;
&__value {
font-family: ui-monospace, 'SF Mono', 'Cascadia Mono', Consolas, monospace;
font-size: 17px;
font-weight: 700;
letter-spacing: 0.04em;
color: #0f4f3c;
}
&__hint {
font-size: 11.5px;
color: #475569;
}
}
// A tab that holds a field still to be put right.
.pm-tab-alert {
margin-left: 8px;
font-size: 14px;
color: #b3261e;
}
.pm-tab-content {
padding: 16px 20px 6px;
}
// Each group of fields in a panel of its own, its name beside a small tinted icon.
.pm-section {
margin-bottom: 14px;
padding: 12px 16px 6px;
border: 1px solid $border;
border-radius: 12px;
background: #fff;
&:last-of-type {
margin-bottom: 8px;
border-bottom: 1px solid $border;
}
&__header {
margin-bottom: 10px;
gap: 2px 10px;
i {
display: inline-grid;
place-items: center;
flex: 0 0 auto;
width: 26px;
height: 26px;
border-radius: 8px;
background: rgb(15 118 110 / 10%);
color: $primary;
font-size: 12px;
}
h3 {
font-size: 12px;
color: $primary-dark;
}
}
}
.pm-section__sub {
font-size: 0.76rem;
color: $text-mid;
}
.pm-grid {
gap: 4px 16px;
}
// The label above the field it names, rather than beside it.
.pm-f {
grid-template-columns: minmax(0, 1fr);
row-gap: 2px;
}
// Material leaves 16px under every field for its own hint; the hint line is already reserved below, so the label above
// the next field is what separates the rows.
.pm-f .pm-field {
margin-bottom: 4px;
}
.pm-f-lbl {
padding-top: 0;
font-size: 11.5px;
font-weight: 600;
letter-spacing: 0.01em;
text-align: left;
color: $text-mid;
b {
color: $danger;
}
}
.pm-card {
::ng-deep {
.mat-mdc-form-field-infix {
min-height: 30px;
}
// A field that cannot be typed in reads as filled-in fact, not as an empty box.
.mat-mdc-form-field.mat-form-field-disabled .mat-mdc-text-field-wrapper {
background: #f1f5f9;
}
.mat-mdc-form-field-hint {
color: $text-mid;
}
}
}
// The search icon is a button: it opens Search Customer.
.pm-search-btn {
margin-right: 4px;
padding: 4px 7px;
border: 0;
border-radius: 8px;
background: transparent;
color: rgb(0 0 0 / 55%);
font-size: 0.85rem;
cursor: pointer;
transition:
background 0.15s,
color 0.15s;
&:hover:not(:disabled) {
background: $primary-light;
color: $primary-dark;
}
&:disabled {
cursor: default;
opacity: 0.4;
}
}
.pm-action-bar {
padding: 12px 20px;
background: #f8fafc;
}
.pm-callout {
margin: 0 20px 12px;
border-radius: 10px;
}
@@ -23,6 +23,10 @@ import { MatCheckbox } from '@angular/material/checkbox';
import { MatRadioModule } from '@angular/material/radio'; import { MatRadioModule } from '@angular/material/radio';
import { MatAutocompleteModule } from '@angular/material/autocomplete'; import { MatAutocompleteModule } from '@angular/material/autocomplete';
import { MatDialog } from '@angular/material/dialog'; import { MatDialog } from '@angular/material/dialog';
import {
CustomerSearchDialogComponent,
CustomerSearchResult
} from 'app/shared/customer-search-dialog/customer-search-dialog.component';
import { debounceTime, distinctUntilChanged } from 'rxjs/operators'; import { debounceTime, distinctUntilChanged } from 'rxjs/operators';
import { STANDALONE_SHARED_IMPORTS } from 'app/standalone-shared.module'; import { STANDALONE_SHARED_IMPORTS } from 'app/standalone-shared.module';
@@ -33,7 +37,6 @@ import { ClientsService } from 'app/clients/clients.service';
import { SettingsService } from 'app/settings/settings.service'; import { SettingsService } from 'app/settings/settings.service';
import { Dates } from 'app/core/utils/dates'; import { Dates } from 'app/core/utils/dates';
import { AlertService } from 'app/core/alert/alert.service'; import { AlertService } from 'app/core/alert/alert.service';
import { searchClientsAnyCase } from '../client-search';
/** Custom Components */ /** Custom Components */
import { PrematureCloseDialogComponent } from '../premature-close-dialog/premature-close-dialog.component'; import { PrematureCloseDialogComponent } from '../premature-close-dialog/premature-close-dialog.component';
@@ -121,6 +124,133 @@ export class CreatePartyComponent implements OnInit {
} }
selectedTabIndex = 0; selectedTabIndex = 0;
/**
* How a nominee is related to the account holder, picked rather than typed. Read from the bank's own
* RELATIONSHIP code - m_code and m_code_value, the list a client's family members are recorded
* against - and from nowhere else, so a relation added or retired there shows up here.
*/
relationOptions: string[] = [];
/**
* The relations to offer on one nominee: the list, plus whatever that nominee already holds when it
* is not on it - a relation saved before this was a list must still show when edited.
*/
relationChoices(nominee: AbstractControl): string[] {
const current = String(nominee.get('relation')?.value ?? '').trim();
return current && !this.relationOptions.includes(current) ? [
...this.relationOptions,
current
] : this.relationOptions;
}
/** The code a new party is expected to get - an estimate; the server gives the real one on saving. */
expectedPartyCode = '';
/**
* The number of the DDS account opened most recently, shown as Last A/c. For reference only - it is
* read from the books, never typed, and nothing is saved from it.
*/
lastAccountNo = '';
/**
* Customer & Scheme, Personal & Contact, Minor / Guardian, Scheme Terms, Balances & Status,
* Nominee's Detail - and Maturity Payout between the last two when the account is paid into a
* savings account. Every field is part of the one form whichever tab it is on, so moving between
* tabs loses and changes nothing.
*/
get tabCount(): number {
return this.creditToRequired ? 7 : 6;
}
/** The controls each tab holds, so a tab can say it still has something to put right. */
private static readonly TAB_CONTROLS: Record<string, string[]> = {
customer: [
'clientId',
'subType',
'agentId',
'officeId',
'partyCode',
'nominal'
],
personal: [
'partyName',
'partyName1',
'address1',
'address2',
'address3',
'city',
'pincode',
'gender',
'telephone',
'mobileNoSms',
'aadharNo',
'panCardNo',
'ckycId',
'operation',
'birthDate',
'occupation',
'risk'
],
minor: [
'adultMinor',
'guardianClientId',
'minAccountOpt',
'minorDate',
'relation'
],
scheme: [
'openingDate',
'dailyDepositAmount',
'days',
'month',
'depositAmount',
'maturityDate',
'ledgerNo',
'reopenDate'
],
balances: [
'freezeCode',
'interestDate',
'isEmailActive',
'isSmsActive',
'lien',
'lienReason',
'lienReasonOther',
'od1Flag',
'od2Flag',
'payDate',
'spDescription'
],
payout: [
'creditToAccount',
'creditToAccountType',
'creditToBranch'
],
nominee: [
'nomineeApplicable',
'nomineeSplitMode'
]
};
/**
* Whether a tab holds something to put right: a field that is invalid and has been visited, or - on
* the nominee tab - a nominee whose shares or order do not add up. Display only; Save is decided as
* before.
*/
tabHasError(tab: string): boolean {
const visited = (control: AbstractControl | null) =>
!!control && control.invalid && (control.touched || control.dirty);
if (tab === 'nominee') {
const rows = this.nominees.controls.some((row) =>
Object.values((row as UntypedFormGroup).controls).some((control) => visited(control))
);
const flagged = CreatePartyComponent.TAB_CONTROLS['nominee'].some((name) => visited(this.partyForm.get(name)));
return rows || flagged || !!this.nomineeShareError || !!this.nomineeOrderError;
}
return (CreatePartyComponent.TAB_CONTROLS[tab] ?? []).some((name) => visited(this.partyForm.get(name)));
}
partyStatus = 'ACTIVE'; partyStatus = 'ACTIVE';
linkedSavingsAccountId: number | null = null; linkedSavingsAccountId: number | null = null;
@@ -152,29 +282,9 @@ export class CreatePartyComponent implements OnInit {
agentLookupFailed = false; agentLookupFailed = false;
loadedGuardianName = ''; loadedGuardianName = '';
/** Clients matching what has been typed - an officer knows the customer's name, rarely their id. */ /** True when something other than an id has been typed in Client ID or Name: it is found with the search button. */
clientOptions: any[] = []; clientNeedsSearch = false;
/**
* The first page of clients, shown the moment the field is focused so the officer can pick from a
* list instead of having to know something to type. Fetched once and kept.
*
* Capped rather than "all": it is a page of names on a branch with 28 customers today and a bank
* with far more later, and the field has to stay usable in both. Typing still searches every
* client, not just these - see searchClients.
*/
private initialClients: any[] = [];
private static readonly CLIENT_LIST_SIZE = 50;
/** How many clients exist in total, to say so when the list below is only part of them. */
clientTotal = 0;
/** True while the options being shown are the opening list rather than search results. */
showingClientList = false;
get clientListIsPartial(): boolean {
return this.showingClientList && this.clientTotal > this.clientOptions.length;
}
guardianOptions: any[] = []; guardianOptions: any[] = [];
/** The account types matching what has been typed into Sub Type. */ /** The account types matching what has been typed into Sub Type. */
subTypeOptions: any[] = []; subTypeOptions: any[] = [];
@@ -190,6 +300,31 @@ export class CreatePartyComponent implements OnInit {
return this.partyStatus === 'CLOSED'; return this.partyStatus === 'CLOSED';
} }
/**
* What an active account's scheme is built on - the customer and product, its branch, its opening
* its collecting agent and its deposit terms - is shown but cannot be changed, as the server refuses
* it too. Contact details, nominees, lien and freeze stay editable.
*/
static readonly LOCKED_WHEN_ACTIVE = [
'clientId',
'subType',
'agentId',
'officeId',
'openingDate',
'dailyDepositAmount',
'depositAmount',
'days',
'month',
'interestDate',
'payDate',
'reopenDate'
];
/** An account already running: its scheme terms are locked. */
get termsLocked(): boolean {
return !!this.partyId && this.partyStatus === 'ACTIVE';
}
/** The DDS Account Type master - what the legacy screen's Sub Type dropdown was fed from. */ /** The DDS Account Type master - what the legacy screen's Sub Type dropdown was fed from. */
subTypes: any[] = []; subTypes: any[] = [];
@@ -326,12 +461,6 @@ export class CreatePartyComponent implements OnInit {
Validators.required Validators.required
], ],
savingsAccountNo: [''], savingsAccountNo: [''],
lastAccount: [
{
value: '',
disabled: true
}
],
partyCode: [ partyCode: [
{ {
value: '', value: '',
@@ -549,6 +678,30 @@ export class CreatePartyComponent implements OnInit {
} }
}); });
this.clientsService.getCodeValuesByName('RELATIONSHIP').subscribe((values: any[]) => {
const names = (values || [])
.filter((value: any) => value?.active !== false)
.map((value: any) => titleCase(String(value?.name ?? '').replace(/_/g, ' ')))
.filter((name: string) => !!name);
if (names.length > 0) {
this.relationOptions = names;
}
});
// The code a new party is expected to get, shown in place of a Party Code field - the server gives
// the code when the party is saved, never keyed. Read for an edit too, since Cancel leaves it
// ready for a new entry. A failure just leaves the card without a figure.
this.organizationService.getNextPartyCode().subscribe({
next: (result: any) => {
this.expectedPartyCode = result?.partyCode ?? '';
this.lastAccountNo = result?.lastAccountNo ?? '';
},
error: () => {
this.expectedPartyCode = '';
this.lastAccountNo = '';
}
});
const routeId = this.route.snapshot.paramMap.get('partyId'); const routeId = this.route.snapshot.paramMap.get('partyId');
if (routeId) { if (routeId) {
this.organizationService.getParty(routeId).subscribe((party: any) => this.patchFormFromParty(party)); this.organizationService.getParty(routeId).subscribe((party: any) => this.patchFormFromParty(party));
@@ -567,9 +720,9 @@ export class CreatePartyComponent implements OnInit {
// ---------------------------------------------------------------- lookups // ---------------------------------------------------------------- lookups
/** /**
* Fills the identity block from the customer, the way the legacy screen did - except that the * Fills the identity block from the customer, the way the legacy screen did - keyed in as an id, which is fetched as
* customer can be found by name as well as by id, since an officer at the counter is told a name. * it is typed (the way the Agent screen does it), or found by name or mobile number with the search button, which
* The control holds the picked client object, or whatever has been typed so far. * puts the id in the box.
*/ */
private wireClientLookup(): void { private wireClientLookup(): void {
this.partyForm this.partyForm
@@ -584,69 +737,21 @@ export class CreatePartyComponent implements OnInit {
this.partyForm.get('adultMinor')?.enable({ emitEvent: false }); this.partyForm.get('adultMinor')?.enable({ emitEvent: false });
this.loadedClientName = ''; this.loadedClientName = '';
this.clientLookupFailed = false; this.clientLookupFailed = false;
this.clientNeedsSearch = false;
// Picked from the list. const typed = String(value && typeof value === 'object' ? value.id : (value ?? '')).trim();
if (value && typeof value === 'object') {
this.clientOptions = [];
this.fillFromClient(value.id, value.displayName);
return;
}
const typed = String(value ?? '').trim();
if (!typed) { if (!typed) {
// Emptying the field puts the opening list back, rather than leaving nothing to pick from.
this.showClientList();
return; return;
} }
// A bare number is an id typed straight in; anything else is a name to search for. A number // A bare number is an id typed straight in.
// can be both, so both are tried and whichever matches wins.
if (/^\d+$/.test(typed)) { if (/^\d+$/.test(typed)) {
this.fillFromClient(typed, ''); this.fillFromClient(typed, value && typeof value === 'object' ? value.displayName : '');
}
this.searchClients(typed);
});
}
private searchClients(term: string): void {
searchClientsAnyCase(this.clientsService, term).subscribe((found: any[]) => {
this.showingClientList = false;
this.clientOptions = found.slice(0, 25);
});
}
/**
* Opens the customer list when the field is focused.
*
* Without this the officer has to know something before anything appears, which is backwards: the
* point of the field is to find a customer, and a blank box is no help in doing that. A customer
* already picked is left alone - reopening the list over a chosen name only invites a misclick.
*/
onClientFocus(): void {
if (this.partyForm.get('clientId')?.value) {
return;
}
this.showClientList();
}
/** Shows the opening list, fetching it the first time it is asked for. */
private showClientList(): void {
if (this.initialClients.length > 0) {
this.showingClientList = true;
this.clientOptions = this.initialClients;
return; return;
} }
this.clientsService.getClients('displayName', 'ASC', 0, CreatePartyComponent.CLIENT_LIST_SIZE).subscribe({ // A name is not looked up as it is typed; the search button finds the customer and puts the id here.
next: (response: any) => { this.clientNeedsSearch = true;
const found = response?.pageItems || response || [];
this.initialClients = Array.isArray(found) ? found : [];
this.clientTotal = response?.totalFilteredRecords ?? this.initialClients.length;
this.showingClientList = true;
this.clientOptions = this.initialClients;
},
// A failed list is not a failed screen - the officer can still type a name or an id.
error: () => (this.clientOptions = [])
}); });
} }
@@ -777,6 +882,35 @@ export class CreatePartyComponent implements OnInit {
}); });
} }
/**
* Opens Search Customer - the same dialog, and the same customer search API, the Agent screen and the cash
* screens use - to find the client by ID, name or mobile number. The id of the one picked goes into the field,
* which then fills in the rest as an id keyed in does.
*/
openClientSearch(): void {
const control = this.partyForm.get('clientId');
if (!control || control.disabled) {
return;
}
// What was typed so far is searched for; a client already picked is not a search term.
const value = control.value;
const typed = value && typeof value === 'object' ? '' : String(value ?? '').trim();
this.dialog
.open(CustomerSearchDialogComponent, {
width: '960px',
maxWidth: '95vw',
// A party is a customer first: they need not hold an account to be picked, and are listed once however many
// accounts they have.
data: { requireAccount: false, uniqueCustomers: true, prefill: typed }
})
.afterClosed()
.subscribe((result: CustomerSearchResult | undefined) => {
if (result?.clientId) {
control.setValue(String(result.clientId));
}
});
}
/** What the Client ID box shows for a picked client, rather than "[object Object]". */ /** What the Client ID box shows for a picked client, rather than "[object Object]". */
displayClient = (value: any): string => { displayClient = (value: any): string => {
if (!value) { if (!value) {
@@ -803,7 +937,13 @@ export class CreatePartyComponent implements OnInit {
return this.filledFromClient.has(controlName); return this.filledFromClient.has(controlName);
} }
/** The client's risk category, from their KYC record. */ /**
* The client's risk category, PAN and CKYC number, from their KYC record.
*
* The PAN and the CKYC number are filed on the KYC record as well as - for some clients, only on -
* it, not as ID proofs. Reading the ID proofs alone left both empty for a client whose KYC held them,
* which is the record the client screens and the customer search read first.
*/
private loadClientRisk(clientId: string): void { private loadClientRisk(clientId: string): void {
this.clientsService.getClientKyc(clientId, true).subscribe({ this.clientsService.getClientKyc(clientId, true).subscribe({
next: (kyc: any) => { next: (kyc: any) => {
@@ -812,6 +952,8 @@ export class CreatePartyComponent implements OnInit {
.split('.') .split('.')
.pop(); .pop();
this.fillFromClientRecord('risk', titleCase(code)); this.fillFromClientRecord('risk', titleCase(code));
this.fillFromClientRecord('panCardNo', kyc?.panNumber);
this.fillFromClientRecord('ckycId', kyc?.ckycNumber);
}, },
// A client without a KYC record just leaves the risk to be chosen. // A client without a KYC record just leaves the risk to be chosen.
error: () => undefined error: () => undefined
@@ -1554,7 +1696,6 @@ export class CreatePartyComponent implements OnInit {
officeId: raw.officeId, officeId: raw.officeId,
savingsAccountNo: this.linkedSavingsAccountId ? undefined : raw.savingsAccountNo || undefined, savingsAccountNo: this.linkedSavingsAccountId ? undefined : raw.savingsAccountNo || undefined,
subType: raw.subType, subType: raw.subType,
lastAccount: raw.lastAccount || undefined,
nominal: raw.nominal === 'Yes', nominal: raw.nominal === 'Yes',
aadharNo: raw.aadharNo || undefined, aadharNo: raw.aadharNo || undefined,
partyName: raw.partyName, partyName: raw.partyName,
@@ -1638,6 +1779,12 @@ export class CreatePartyComponent implements OnInit {
}; };
if (this.partyId) { if (this.partyId) {
// An active account's locked terms are not sent at all, so an edit never even asks to change them.
if (this.termsLocked) {
for (const name of CreatePartyComponent.LOCKED_WHEN_ACTIVE) {
delete payload[name];
}
}
this.organizationService.updateParty(this.partyId, payload).subscribe(done); this.organizationService.updateParty(this.partyId, payload).subscribe(done);
} else { } else {
this.organizationService.createParty(payload).subscribe(done); this.organizationService.createParty(payload).subscribe(done);
@@ -1850,7 +1997,6 @@ export class CreatePartyComponent implements OnInit {
agentId: party.agentId, agentId: party.agentId,
officeId: party.officeId, officeId: party.officeId,
partyCode: party.partyCode, partyCode: party.partyCode,
lastAccount: party.lastAccount,
nominal: party.nominal ? 'Yes' : 'No', nominal: party.nominal ? 'Yes' : 'No',
aadharNo: party.aadharNo, aadharNo: party.aadharNo,
partyName: party.partyName, partyName: party.partyName,
@@ -1931,6 +2077,10 @@ export class CreatePartyComponent implements OnInit {
// A closed party is a record to look at, not to change. // A closed party is a record to look at, not to change.
if (this.isClosed) { if (this.isClosed) {
this.partyForm.disable({ emitEvent: false }); this.partyForm.disable({ emitEvent: false });
} else if (this.termsLocked) {
for (const name of CreatePartyComponent.LOCKED_WHEN_ACTIVE) {
this.partyForm.get(name)?.disable({ emitEvent: false });
}
} }
} }
} }
@@ -159,11 +159,22 @@
<!-- A closed agent is off the books: nothing can be changed on it, only read. --> <!-- A closed agent is off the books: nothing can be changed on it, only read. -->
@if (a.closeDate) { @if (a.closeDate) {
<span class="am-closed-on">Closed {{ a.closeDate | dateFormat }}</span> <span class="am-closed-on">Closed {{ a.closeDate | dateFormat }}</span>
} @else { }
<button
type="button"
class="pl-action-btn pl-action-btn--view"
[routerLink]="[a.id]"
(click)="$event.stopPropagation()"
matTooltip="View agent details"
>
<fa-icon icon="eye"></fa-icon><span>View</span>
</button>
@if (!a.closeDate) {
<button <button
type="button" type="button"
class="pl-action-btn pl-action-btn--edit" class="pl-action-btn pl-action-btn--edit"
[routerLink]="[a.id, 'edit']" [routerLink]="[a.id, 'edit']"
(click)="$event.stopPropagation()"
matTooltip="Edit this agent" matTooltip="Edit this agent"
> >
<fa-icon icon="edit"></fa-icon><span>Edit</span> <fa-icon icon="edit"></fa-icon><span>Edit</span>
@@ -176,6 +187,7 @@
<tr <tr
mat-row mat-row
*matRowDef="let row; columns: displayedColumns" *matRowDef="let row; columns: displayedColumns"
[routerLink]="[row.id]"
class="pl-row" class="pl-row"
[class.am-row--inactive]="!row.active" [class.am-row--inactive]="!row.active"
></tr> ></tr>
@@ -11,55 +11,84 @@
<button mat-stroked-button [routerLink]="['../']" class="am-back"> <button mat-stroked-button [routerLink]="['../']" class="am-back">
<i class="fas fa-arrow-left"></i> {{ 'labels.heading.Organization' | translate }} <i class="fas fa-arrow-left"></i> {{ 'labels.heading.Organization' | translate }}
</button> </button>
<div class="am-header__icon"><i class="fas fa-user-tie"></i></div>
<div class="am-header__text">
<h2 class="am-header__title">Agent Master Entry</h2> <h2 class="am-header__title">Agent Master Entry</h2>
<p class="am-header__sub">
{{ agentId ? 'Update this collection agent.' : 'Register a collection agent, their accounts and guarantors.' }}
</p>
</div>
</div> </div>
<div class="am-card">
<form [formGroup]="agentForm"> <form [formGroup]="agentForm">
<!-- <!--
Follows the legacy Agent Master Entry screen field for field. Entering a Client ID fills the Follows the legacy Agent Master Entry screen field for field. Entering a Client ID fills the
name, address, city, pincode and telephone from that client, as the legacy screen did, and name, address, city, pincode and telephone from that client, as the legacy screen did, and
also defaults the home branch and stocks the two savings-account dropdowns from that client. also defaults the home branch and stocks the two savings-account dropdowns from that client.
The fields are the same ones the legacy screen had, set out over tabs so each page is short;
they all belong to the one form, so nothing is lost by moving between tabs.
--> -->
<div class="am-section"> <mat-tab-group
<div class="am-section__header"> class="am-tabs"
<i class="fas fa-id-card"></i> animationDuration="200ms"
[preserveContent]="true"
[(selectedIndex)]="selectedTabIndex"
>
<!-- ── 1. Agent details ─────────────────────────────────────────────────────────── -->
<mat-tab>
<ng-template mat-tab-label>
<i class="fas fa-id-card am-tab-icon"></i>
Agent Details
@if (tabHasError(0)) {
<i class="fas fa-exclamation-circle am-tab-alert" aria-label="Needs attention"></i>
}
</ng-template>
<div class="am-tab-content">
<div class="am-tab-intro">
<h3>Agent Details</h3> <h3>Agent Details</h3>
<p>
Choose the branch, then find the client who will act as agent - their name, address and accounts are
filled in from them.
</p>
</div> </div>
<div class="am-grid"> <div class="am-grid">
<!-- Asked first: the clients offered, and whether a client can be made an agent here at all,
depend on the branch. Which branch the agent collects out of: the one logged in to or a
branch under it. The option carries the office's id while showing its name, so renaming
a branch later leaves the agent where it is. -->
<mat-form-field appearance="outline" class="am-field">
<mat-label>Home Branch</mat-label>
<mat-select required formControlName="officeId">
@for (office of offices; track office.id) {
<mat-option [value]="office.id">{{ office.name }}</mat-option>
}
</mat-select>
<mat-hint>The branch this agent's collections belong to</mat-hint>
</mat-form-field>
<mat-form-field appearance="outline" class="am-field"> <mat-form-field appearance="outline" class="am-field">
<mat-label>Client ID or Name</mat-label> <mat-label>Client ID or Name</mat-label>
<input <input
matInput matInput
required required
formControlName="clientId" formControlName="clientId"
[matAutocomplete]="clientAuto" (keydown.enter)="$event.preventDefault(); openClientSearch()"
#clientInput placeholder="Client ID, or search by name or mobile number…"
#clientTrigger="matAutocompleteTrigger"
(focus)="onClientFocus()"
placeholder="Click to see clients, or type a name or ID…"
autocomplete="off" autocomplete="off"
/> />
<i matSuffix class="fas fa-search am-search-ic"></i> <!-- Opens Search Customer; picking a customer puts their id here, which fills the rest. -->
<!-- Picking a client puts their id in the field, which then fills the rest as before. --> <button
<mat-autocomplete #clientAuto="matAutocomplete" panelWidth="min(30rem, 92vw)"> matSuffix
@for (client of clientOptions; track client.id) { type="button"
<mat-option [value]="client.id"> class="am-search-btn"
<div class="am-opt"> aria-label="Search customer"
<span class="am-opt-id">{{ client.id }}</span> [disabled]="agentId !== null"
<span class="am-opt-main">{{ client.displayName }}</span> (click)="openClientSearch()"
<span class="am-opt-sub">{{ client.officeName }}</span> >
</div> <i class="fas fa-search"></i>
</mat-option> </button>
} @if (clientNeedsSearch) {
@if (clientListIsPartial) { <mat-hint class="am-hint--error">Press search to find this client by name or mobile number</mat-hint>
<mat-option disabled class="am-opt-more">
Showing {{ clientOptions.length }} of {{ clientTotal }} — type a name to find the rest
</mat-option>
}
</mat-autocomplete>
@if (agentForm.get('clientId')?.hasError('pattern')) {
<mat-hint class="am-hint--error">Pick a client from the list</mat-hint>
} @else if (clientAlreadyAgent) { } @else if (clientAlreadyAgent) {
<mat-hint class="am-hint--error">This client is already agent {{ clientAlreadyAgent }}</mat-hint> <mat-hint class="am-hint--error">This client is already agent {{ clientAlreadyAgent }}</mat-hint>
} @else if (clientAgeProblem) { } @else if (clientAgeProblem) {
@@ -73,16 +102,64 @@
} }
</mat-form-field> </mat-form-field>
<mat-form-field appearance="outline" class="am-field"> <!-- Not a field: the server assigns the code when the agent is saved. A new agent is shown
<mat-label>Agent Code</mat-label> the code it is expected to get, worked out from the agents already on the books. -->
<input matInput formControlName="agentCode" placeholder="Auto-generated" /> <div class="am-code">
</mat-form-field> <span class="am-code__label">Agent Code</span>
<span class="am-code__value">{{
(agentId ? agentForm.get('agentCode')?.value : expectedAgentCode) || '—'
}}</span>
<span class="am-code__hint">{{
agentId ? 'Assigned when this agent was opened' : 'Expected - assigned when saved'
}}</span>
</div>
<mat-form-field appearance="outline" class="am-field am-field--wide"> <mat-form-field appearance="outline" class="am-field am-field--wide">
<mat-label>Agent Name ID</mat-label> <mat-label>Agent Name ID</mat-label>
<input matInput required formControlName="agentNameId" /> <input matInput required formControlName="agentNameId" />
</mat-form-field> </mat-form-field>
<!-- Not picked: a new agent is opened on the business date, and an agent being edited shows
the date it was opened on. -->
<mat-form-field appearance="outline" class="am-field">
<mat-label>Agent Opening Date</mat-label>
<input
matInput
required
readonly
[matDatepicker]="openingDatePicker"
formControlName="agentOpeningDate"
/>
<mat-datepicker #openingDatePicker></mat-datepicker>
<mat-hint>{{ agentId ? 'The date this agent was opened' : 'Business date' }}</mat-hint>
</mat-form-field>
<!-- Copied from the client, whose date of birth was taken and checked when they were opened. -->
<mat-form-field appearance="outline" class="am-field">
<mat-label>Agent Birth Date</mat-label>
<input matInput readonly [matDatepicker]="birthDatePicker" formControlName="agentBirthDate" />
<mat-datepicker #birthDatePicker></mat-datepicker>
<mat-hint>From the client's record</mat-hint>
</mat-form-field>
</div>
</div>
</mat-tab>
<!-- ── 2. Address & contact ─────────────────────────────────────────────────────── -->
<mat-tab>
<ng-template mat-tab-label>
<i class="fas fa-map-marker-alt am-tab-icon"></i>
Address &amp; Contact
@if (tabHasError(1)) {
<i class="fas fa-exclamation-circle am-tab-alert" aria-label="Needs attention"></i>
}
</ng-template>
<div class="am-tab-content">
<div class="am-tab-intro">
<h3>Address &amp; Contact</h3>
<p>Filled from the client; change anything that differs for the agent.</p>
</div>
<div class="am-grid">
<mat-form-field appearance="outline" class="am-field am-field--wide"> <mat-form-field appearance="outline" class="am-field am-field--wide">
<mat-label>Agent Address (Line 1)</mat-label> <mat-label>Agent Address (Line 1)</mat-label>
<input matInput required formControlName="agentAddress1" /> <input matInput required formControlName="agentAddress1" />
@@ -107,29 +184,46 @@
<mat-label>Agent Telephone</mat-label> <mat-label>Agent Telephone</mat-label>
<input matInput formControlName="agentTelephone" mifosxRestrictInput="phone" /> <input matInput formControlName="agentTelephone" mifosxRestrictInput="phone" />
</mat-form-field> </mat-form-field>
</div>
</div>
</mat-tab>
<!-- Which branch the agent collects out of: the one logged in to or a branch under it. The <!-- ── 3. Branch & accounts ─────────────────────────────────────────────────────── -->
option carries the office's id while showing its name, so renaming a branch later <mat-tab>
leaves the agent where it is. --> <ng-template mat-tab-label>
<mat-form-field appearance="outline" class="am-field"> <i class="fas fa-university am-tab-icon"></i>
<mat-label>Home Branch</mat-label> Accounts
<mat-select required formControlName="officeId"> @if (tabHasError(2)) {
@for (office of offices; track office.id) { <i class="fas fa-exclamation-circle am-tab-alert" aria-label="Needs attention"></i>
<mat-option [value]="office.id">{{ office.name }}</mat-option>
} }
</mat-select> </ng-template>
<mat-hint>The branch this agent's collections belong to</mat-hint> <div class="am-tab-content">
</mat-form-field> <div class="am-tab-intro">
<h3>Accounts</h3>
<p>The ledger account the agent's cash passes through, and the savings account commission is paid into.</p>
</div>
<div class="am-grid">
<!-- The account this agent's cash sits in between collecting it and bringing it in, so a <!-- The account this agent's cash sits in between collecting it and bringing it in, so a
balance left standing on it is this agent's and nobody else's. Required, and one agent balance left standing on it is this agent's and nobody else's. Required, and one agent
to an account - an account already taken is refused by name, not by number. Only to an account - an account already taken is refused by name, not by number. Only
detail accounts are offered: a header account is a heading over others and carries no detail accounts are offered: a header account is a heading over others and carries no
entries of its own. --> entries of its own. -->
<mat-form-field appearance="outline" class="am-field"> <mat-form-field appearance="outline" class="am-field am-field--full">
<mat-label>GL Account</mat-label> <mat-label>GL Account</mat-label>
<mat-select required formControlName="glAccountId"> <mat-select
@for (account of glAccounts; track account.id) { required
formControlName="glAccountId"
(openedChange)="!$event && glAccountSearch.setValue('')"
>
<!-- Type a code or a name to narrow the list; cleared again when the list closes. -->
<mat-option>
<ngx-mat-select-search
placeholderLabel="Search by code or name"
noEntriesFoundLabel="No account found"
[formControl]="glAccountSearch"
></ngx-mat-select-search>
</mat-option>
@for (account of filteredGlAccounts; track account.id) {
<mat-option [value]="account.id">{{ account.glCode }} · {{ account.name }}</mat-option> <mat-option [value]="account.id">{{ account.glCode }} · {{ account.name }}</mat-option>
} }
</mat-select> </mat-select>
@@ -138,7 +232,7 @@
<!-- Type first, then the number - the order the legacy screen used. The list is built from <!-- Type first, then the number - the order the legacy screen used. The list is built from
the client's own accounts, so every type offered is one they actually hold. --> the client's own accounts, so every type offered is one they actually hold. -->
<mat-form-field appearance="outline" class="am-field"> <mat-form-field appearance="outline" class="am-field am-field--wide">
<mat-label>Agent Saving A/C Type</mat-label> <mat-label>Agent Saving A/C Type</mat-label>
<mat-select <mat-select
required required
@@ -146,7 +240,9 @@
(selectionChange)="onSavingsAccountTypePicked()" (selectionChange)="onSavingsAccountTypePicked()"
> >
@for (accountType of savingsAccountTypes; track accountType) { @for (accountType of savingsAccountTypes; track accountType) {
<mat-option [value]="accountType">{{ savingsAccountTypeCodes[accountType] || accountType }}</mat-option> <mat-option [value]="accountType">{{
savingsAccountTypeCodes[accountType] || accountType
}}</mat-option>
} }
</mat-select> </mat-select>
<!-- Two different reasons for an empty list, and the fix differs: enter a client, or <!-- Two different reasons for an empty list, and the fix differs: enter a client, or
@@ -168,7 +264,7 @@
account, and a number that exists only as text is a payment with nowhere to land. --> account, and a number that exists only as text is a payment with nowhere to land. -->
<mat-form-field appearance="outline" class="am-field"> <mat-form-field appearance="outline" class="am-field">
<mat-label>Agent Saving A/C No</mat-label> <mat-label>Agent Saving A/C No</mat-label>
<mat-select required formControlName="agentSavingAccountNo"> <mat-select required formControlName="agentSavingAccountNo" (selectionChange)="onSavingsAccountPicked()">
@for (account of accountsOfChosenType; track account.id) { @for (account of accountsOfChosenType; track account.id) {
<mat-option [value]="account.accountNo">{{ account.accountNo }}</mat-option> <mat-option [value]="account.accountNo">{{ account.accountNo }}</mat-option>
} }
@@ -178,76 +274,135 @@
} }
</mat-form-field> </mat-form-field>
<mat-form-field appearance="outline" class="am-field"> <!-- How the chosen account stands, read from the account itself: whether it can take the
<mat-label>Agent Opening Date</mat-label> commission, what it holds, what is on hold and what is left to use. -->
<input matInput required [matDatepicker]="openingDatePicker" formControlName="agentOpeningDate" /> @if (savingsInfoLoading) {
<mat-datepicker-toggle matIconSuffix [for]="openingDatePicker"></mat-datepicker-toggle> <div class="am-acct am-field--full am-acct--note">Reading the account…</div>
<mat-datepicker #openingDatePicker></mat-datepicker> } @else if (savingsInfoFailed) {
</mat-form-field> <div class="am-acct am-field--full am-acct--note am-acct--error">
Could not read this account's balance just now.
<!-- Copied from the client, whose date of birth was taken and checked when they were opened. --> </div>
<mat-form-field appearance="outline" class="am-field"> } @else if (savingsInfo) {
<mat-label>Agent Birth Date</mat-label> <div class="am-acct am-field--full">
<input matInput readonly [matDatepicker]="birthDatePicker" formControlName="agentBirthDate" /> <div class="am-acct__tile">
<mat-datepicker #birthDatePicker></mat-datepicker> <span class="am-acct__label">Account Status</span>
<mat-hint>From the client's record</mat-hint> <span
</mat-form-field> class="am-acct__status"
[class.am-acct__status--ok]="savingsInfo.statusClass === 'ok'"
[class.am-acct__status--wait]="savingsInfo.statusClass === 'wait'"
[class.am-acct__status--off]="savingsInfo.statusClass === 'off'"
>
<i class="fas fa-circle"></i>{{ savingsInfo.status }}
</span>
</div>
<div class="am-acct__tile">
<span class="am-acct__label">Balance</span>
<span class="am-acct__value"
>{{ savingsInfo.symbol }}{{ savingsInfo.balance | number: '1.2-2' }}</span
>
</div>
<div class="am-acct__tile">
<span class="am-acct__label">Hold</span>
<span class="am-acct__value" [class.am-acct__value--hold]="savingsInfo.hold > 0">
{{ savingsInfo.symbol }}{{ savingsInfo.hold | number: '1.2-2' }}
</span>
</div>
<div class="am-acct__tile am-acct__tile--accent">
<span class="am-acct__label">Available Balance</span>
<span class="am-acct__value"
>{{ savingsInfo.symbol }}{{ savingsInfo.available | number: '1.2-2' }}</span
>
</div> </div>
</div> </div>
}
</div>
</div>
</mat-tab>
<div class="am-section"> <!-- ── 4. Guarantors ────────────────────────────────────────────────────────────── -->
<div class="am-section__header"> <mat-tab>
<i class="fas fa-user-shield"></i> <ng-template mat-tab-label>
<i class="fas fa-user-shield am-tab-icon"></i>
Guarantors
@if (guarantors.length > 0) {
<span class="am-tab-count">{{ guarantors.length }}</span>
}
@if (tabHasError(3)) {
<i class="fas fa-exclamation-circle am-tab-alert" aria-label="Needs attention"></i>
}
</ng-template>
<div class="am-tab-content">
<div class="am-tab-intro am-tab-intro--split">
<div>
<h3>Guarantor's Detail</h3> <h3>Guarantor's Detail</h3>
<p>Someone other than the agent who stands behind them. Add as many as are needed.</p>
</div>
<button mat-stroked-button type="button" class="am-add-guarantor" (click)="addGuarantor()"> <button mat-stroked-button type="button" class="am-add-guarantor" (click)="addGuarantor()">
<i class="fas fa-plus"></i> Add Guarantor <i class="fas fa-plus"></i> Add Guarantor
</button> </button>
</div> </div>
@if (guarantors.length === 0) { @if (guarantors.length === 0) {
<div class="am-empty">
<i class="fas fa-user-shield"></i>
<p class="am-empty-guarantors">No guarantors added yet.</p> <p class="am-empty-guarantors">No guarantors added yet.</p>
</div>
} }
@for (guarantor of guarantors.controls; track guarantor; let i = $index) { @for (guarantor of guarantors.controls; track guarantor; let i = $index) {
<div class="am-guarantor-row" [formGroup]="$any(guarantor)"> <div class="am-guarantor-row" [formGroup]="$any(guarantor)">
<div class="am-guarantor-head">
<span class="am-guarantor-no">{{ i + 1 }}</span>
<span class="am-guarantor-title">Guarantor {{ i + 1 }}</span>
<button
type="button"
class="am-remove-guarantor"
(click)="removeGuarantor(i)"
matTooltip="Remove guarantor"
aria-label="Remove guarantor"
>
<i class="fas fa-trash"></i>
</button>
</div>
<div class="am-guarantor-grid">
<mat-form-field appearance="outline" class="am-field"> <mat-form-field appearance="outline" class="am-field">
<mat-label>Client ID or Name</mat-label> <mat-label>Client ID or Name</mat-label>
<input <input
matInput matInput
required required
formControlName="clientId" formControlName="clientId"
[matAutocomplete]="guarantorAuto" (keydown.enter)="$event.preventDefault(); openGuarantorSearch(guarantor)"
#guarantorInput placeholder="Client ID, or search by name or mobile number…"
#guarantorTrigger="matAutocompleteTrigger"
(focus)="onGuarantorFocus(guarantor, guarantorTrigger, guarantorInput)"
placeholder="Click to see clients, or type…"
autocomplete="off" autocomplete="off"
/> />
<i matSuffix class="fas fa-search am-search-ic"></i> <button
<mat-autocomplete #guarantorAuto="matAutocomplete" panelWidth="min(30rem, 92vw)"> matSuffix
@for (client of guarantorOptionsFor(guarantor); track client.id) { type="button"
<mat-option [value]="client.id"> class="am-search-btn"
<div class="am-opt"> aria-label="Search customer"
<span class="am-opt-id">{{ client.id }}</span> (click)="openGuarantorSearch(guarantor)"
<span class="am-opt-main">{{ client.displayName }}</span> >
<span class="am-opt-sub">{{ client.officeName }}</span> <i class="fas fa-search"></i>
</div> </button>
</mat-option>
}
</mat-autocomplete>
<!-- Shown as soon as it is keyed, not only after leaving the field. --> <!-- Shown as soon as it is keyed, not only after leaving the field. -->
@if (guarantor.get('clientId')?.hasError('selfGuarantor')) { @if (guarantor.get('clientId')?.hasError('selfGuarantor')) {
<mat-hint class="am-hint--error">The agent cannot be their own guarantor</mat-hint> <mat-hint class="am-hint--error">The agent cannot be their own guarantor</mat-hint>
} @else if (guarantor.get('clientId')?.hasError('duplicateGuarantor')) { } @else if (guarantor.get('clientId')?.hasError('duplicateGuarantor')) {
<mat-hint class="am-hint--error">This client is already a guarantor above</mat-hint> <mat-hint class="am-hint--error">This client is already a guarantor above</mat-hint>
} @else if (guarantor.get('clientId')?.hasError('pattern')) { } @else if (guarantor.get('clientId')?.hasError('pattern')) {
<mat-hint class="am-hint--error">Pick a client from the list</mat-hint> <mat-hint class="am-hint--error"
>Press search to find this client by name or mobile number</mat-hint
>
} }
</mat-form-field> </mat-form-field>
<mat-form-field appearance="outline" class="am-field"> <mat-form-field appearance="outline" class="am-field">
<mat-label>Guarantor Name</mat-label> <mat-label>Guarantor Name</mat-label>
<input matInput required formControlName="guarantorName" /> <input matInput required formControlName="guarantorName" />
</mat-form-field> </mat-form-field>
<mat-form-field appearance="outline" class="am-field">
<mat-label>Telephone</mat-label>
<input matInput formControlName="telephone" mifosxRestrictInput="phone" />
</mat-form-field>
<mat-form-field appearance="outline" class="am-field"> <mat-form-field appearance="outline" class="am-field">
<mat-label>Address 1</mat-label> <mat-label>Address 1</mat-label>
<input matInput required formControlName="address1" /> <input matInput required formControlName="address1" />
@@ -264,27 +419,28 @@
<mat-label>Pin Code</mat-label> <mat-label>Pin Code</mat-label>
<input matInput required formControlName="pinCode" mifosxRestrictInput="digits" /> <input matInput required formControlName="pinCode" mifosxRestrictInput="digits" />
</mat-form-field> </mat-form-field>
<mat-form-field appearance="outline" class="am-field"> </div>
<mat-label>Telephone</mat-label>
<input matInput formControlName="telephone" mifosxRestrictInput="phone" />
</mat-form-field>
<button
mat-icon-button
type="button"
class="am-remove-guarantor"
(click)="removeGuarantor(i)"
matTooltip="Remove guarantor"
>
<i class="fas fa-trash"></i>
</button>
</div> </div>
} }
</div> </div>
</mat-tab>
<div class="am-section"> <!-- ── 5. Balances ────────────────────────────────────────────────────────────────
<div class="am-section__header"> Only for an agent already on the books. A new agent always starts at nothing - the form
<i class="fas fa-balance-scale"></i> still sends 0 - so there is nothing to ask; the tab then shows what the agent holds. -->
@if (agentId) {
<mat-tab>
<ng-template mat-tab-label>
<i class="fas fa-balance-scale am-tab-icon"></i>
Balances
@if (tabHasError(4)) {
<i class="fas fa-exclamation-circle am-tab-alert" aria-label="Needs attention"></i>
}
</ng-template>
<div class="am-tab-content">
<div class="am-tab-intro">
<h3>Balances</h3> <h3>Balances</h3>
<p>What the agent holds on the books at the start.</p>
</div> </div>
<div class="am-grid"> <div class="am-grid">
<mat-form-field appearance="outline" class="am-field"> <mat-form-field appearance="outline" class="am-field">
@@ -298,11 +454,27 @@
</mat-form-field> </mat-form-field>
</div> </div>
</div> </div>
</mat-tab>
}
</mat-tab-group>
<div class="am-action-bar"> <div class="am-action-bar">
<div class="am-action-bar__left">
<button mat-raised-button type="button" (click)="exit()">Exit</button> <button mat-raised-button type="button" (click)="exit()">Exit</button>
<button mat-stroked-button type="button" (click)="cancel()">Cancel</button> <button mat-stroked-button type="button" (click)="cancel()">Cancel</button>
<button mat-stroked-button type="button" (click)="retrieve()">Retrieve</button> <button mat-stroked-button type="button" (click)="retrieve()">Retrieve</button>
</div>
<div class="am-action-bar__right">
@if (selectedTabIndex > 0) {
<button mat-stroked-button type="button" (click)="selectedTabIndex = selectedTabIndex - 1">
<i class="fas fa-arrow-left am-btn-ic"></i>Previous
</button>
}
@if (selectedTabIndex < tabCount - 1) {
<button mat-stroked-button type="button" color="primary" (click)="selectedTabIndex = selectedTabIndex + 1">
Next<i class="fas fa-arrow-right am-btn-ic am-btn-ic--after"></i>
</button>
}
<button <button
mat-raised-button mat-raised-button
color="primary" color="primary"
@@ -313,6 +485,6 @@
Save Save
</button> </button>
</div> </div>
</form>
</div> </div>
</form>
</div> </div>
@@ -7,6 +7,13 @@
*/ */
$primary: #0f766e; $primary: #0f766e;
$primary-dark: #0f4f3c;
$primary-light: #e6f4ee;
$danger: #b3261e;
$border: #e2e8f0;
$text-dark: #0f172a;
$text-mid: #475569;
$text-soft: #94a3b8;
.am-container { .am-container {
max-width: 1040px; max-width: 1040px;
@@ -14,16 +21,41 @@ $primary: #0f766e;
padding: 24px 16px; padding: 24px 16px;
} }
// ── Header ──────────────────────────────────────────────────────────────────
.am-header { .am-header {
display: flex; display: flex;
align-items: center; align-items: center;
gap: 16px; gap: 14px;
margin-bottom: 20px; margin-bottom: 20px;
&__icon {
display: grid;
place-items: center;
flex: 0 0 auto;
width: 46px;
height: 46px;
border-radius: 14px;
background: linear-gradient(135deg, $primary 0%, $primary-dark 100%);
color: #fff;
font-size: 19px;
box-shadow: 0 6px 16px rgb(15 79 60 / 22%);
}
&__text {
min-width: 0;
}
&__title { &__title {
margin: 0; margin: 0;
font-size: 22px; font-size: 22px;
font-weight: 500; font-weight: 600;
color: $text-dark;
}
&__sub {
margin: 2px 0 0;
font-size: 13px;
color: $text-mid;
} }
} }
@@ -31,40 +63,110 @@ $primary: #0f766e;
color: $primary; color: $primary;
} }
.am-card { // ── Tabs ────────────────────────────────────────────────────────────────────
.am-tabs {
background: var(--md-sys-color-surface, #fff); background: var(--md-sys-color-surface, #fff);
border-radius: 8px; border: 1px solid $border;
box-shadow: 0 2px 8px rgb(0 0 0 / 8%); border-radius: 14px;
padding: 8px 24px 24px; box-shadow: 0 2px 10px rgb(15 23 42 / 6%);
overflow: hidden;
::ng-deep {
.mat-mdc-tab-header {
background: #f8fafc;
border-bottom: 1px solid $border;
}
.mat-mdc-tab {
min-width: 100px;
padding: 0 16px;
height: 54px;
}
.mat-mdc-tab .mdc-tab__text-label {
color: $text-mid;
font-weight: 500;
}
.mat-mdc-tab.mdc-tab--active .mdc-tab__text-label {
color: $primary-dark;
font-weight: 600;
}
.mat-mdc-tab .mdc-tab-indicator__content--underline {
border-color: $primary;
border-top-width: 3px;
}
.mat-mdc-tab-body-wrapper {
min-height: 220px;
}
// Floating labels and the date picker's panel open past the edge of a tab; do not clip them.
.mat-mdc-tab-body,
.mat-mdc-tab-body-content {
overflow: visible !important;
}
}
} }
.am-section { .am-tab-icon {
padding: 20px 0; margin-right: 8px;
border-bottom: 1px solid rgb(0 0 0 / 8%); font-size: 14px;
}
&:last-of-type { .am-tab-alert {
border-bottom: none; margin-left: 8px;
} font-size: 14px;
color: $danger;
}
&__header { .am-tab-count {
display: flex; display: inline-grid;
align-items: center; place-items: center;
gap: 10px; min-width: 20px;
margin-bottom: 16px; height: 20px;
margin-left: 8px;
padding: 0 6px;
border-radius: 999px;
background: $primary-light;
color: $primary-dark;
font-size: 11px;
font-weight: 700;
}
i { .am-tab-content {
color: $primary; padding: 22px 24px 8px;
} }
.am-tab-intro {
margin-bottom: 18px;
padding-bottom: 12px;
border-bottom: 1px dashed $border;
h3 { h3 {
margin: 0; margin: 0;
font-size: 16px; font-size: 16px;
font-weight: 600; font-weight: 600;
flex: 1; color: $text-dark;
} }
p {
margin: 3px 0 0;
font-size: 13px;
color: $text-mid;
}
&--split {
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
flex-wrap: wrap;
} }
} }
// ── Fields ──────────────────────────────────────────────────────────────────
.am-grid { .am-grid {
display: grid; display: grid;
grid-template-columns: repeat(3, 1fr); grid-template-columns: repeat(3, 1fr);
@@ -77,115 +179,319 @@ $primary: #0f766e;
&--wide { &--wide {
grid-column: span 2; grid-column: span 2;
} }
}
.am-add-guarantor { &--full {
color: $primary; grid-column: 1 / -1;
}
.am-empty-guarantors {
color: rgb(0 0 0 / 50%);
font-size: 14px;
margin: 0 0 8px;
}
.am-guarantor-row {
display: grid;
grid-template-columns: repeat(7, 1fr) auto;
gap: 8px 12px;
align-items: start;
padding: 10px 0;
border-top: 1px dashed rgb(0 0 0 / 10%);
&:first-of-type {
border-top: none;
} }
} }
.am-limit-row { // How the account commission is paid into stands: status, balance, hold, available balance.
.am-acct {
display: grid; display: grid;
grid-template-columns: repeat(3, 1fr) auto; grid-template-columns: repeat(4, minmax(0, 1fr));
gap: 8px 12px; gap: 0;
align-items: start; margin-bottom: 8px;
padding: 10px 0; border: 1px solid $border;
border-top: 1px dashed rgb(0 0 0 / 10%); border-radius: 12px;
background: #fff;
overflow: hidden;
&:first-of-type { &--note {
border-top: none; display: block;
padding: 14px 18px;
font-size: 13px;
color: $text-mid;
background: #f8fafc;
} }
}
.am-remove-guarantor { &--error {
color: #b91c1c; color: $danger;
margin-top: 8px; }
}
.am-action-bar { &__tile {
display: flex; display: flex;
justify-content: flex-end; flex-direction: column;
gap: 12px; gap: 6px;
margin-top: 20px; padding: 14px 18px;
padding-top: 16px; border-right: 1px solid $border;
flex-wrap: wrap;
&:last-child {
border-right: 0;
}
&--accent {
background: $primary-light;
}
}
&__label {
font-size: 11px;
font-weight: 700;
letter-spacing: 0.05em;
text-transform: uppercase;
color: $text-soft;
}
&__value {
font-size: 18px;
font-weight: 700;
color: $text-dark;
&--hold {
color: #b45309;
}
}
&__status {
display: inline-flex;
align-items: center;
gap: 7px;
width: fit-content;
padding: 3px 12px 3px 9px;
border-radius: 999px;
font-size: 13px;
font-weight: 600;
background: #f1f5f9;
color: $text-mid;
i {
font-size: 8px;
}
&--ok {
background: #dcfce7;
color: #15803d;
}
&--wait {
background: #fef3c7;
color: #b45309;
}
&--off {
background: #fee2e2;
color: #b91c1c;
}
}
}
@media (width <= 720px) {
.am-acct {
grid-template-columns: repeat(2, minmax(0, 1fr));
&__tile:nth-child(2) {
border-right: 0;
}
&__tile:nth-child(-n + 2) {
border-bottom: 1px solid $border;
}
}
}
// The agent code, shown rather than asked for: it is assigned when the agent is saved.
.am-code {
display: flex;
flex-direction: column;
justify-content: center;
gap: 1px;
min-height: 56px;
margin-bottom: 20px;
padding: 6px 16px;
border: 1px solid #b7ddd0;
border-radius: 4px;
background: $primary-light;
&__label {
font-size: 11px;
font-weight: 700;
letter-spacing: 0.05em;
text-transform: uppercase;
color: $text-mid;
}
&__value {
font-family: ui-monospace, 'SF Mono', 'Cascadia Mono', Consolas, monospace;
font-size: 17px;
font-weight: 700;
letter-spacing: 0.04em;
color: $primary-dark;
}
&__hint {
font-size: 11.5px;
color: $text-mid;
}
} }
/* Client ID lookup came back empty — the officer typed an id that doesn't exist. */ /* Client ID lookup came back empty — the officer typed an id that doesn't exist. */
.am-hint--error { .am-hint--error {
color: #b3261e; color: $danger;
}
// The search icon is a button: it opens Search Customer.
.am-search-btn {
margin-right: 8px;
padding: 6px 8px;
border: 0;
border-radius: 8px;
background: transparent;
color: rgb(0 0 0 / 55%);
font-size: 1rem;
cursor: pointer;
transition:
background 0.15s,
color 0.15s;
&:hover:not(:disabled) {
background: $primary-light;
color: $primary-dark;
}
&:disabled {
cursor: default;
opacity: 0.4;
}
}
// ── Guarantors ──────────────────────────────────────────────────────────────
.am-add-guarantor {
color: $primary;
}
.am-empty {
display: flex;
flex-direction: column;
align-items: center;
gap: 6px;
margin-bottom: 16px;
padding: 28px 16px;
border: 1.5px dashed #cbd5e1;
border-radius: 12px;
background: #f8fafc;
color: $text-soft;
i {
font-size: 28px;
}
}
.am-empty-guarantors {
margin: 0;
font-size: 14px;
color: $text-mid;
}
.am-guarantor-row {
margin-bottom: 16px;
padding: 14px 16px 6px;
border: 1px solid $border;
border-radius: 12px;
background: #fff;
box-shadow: 0 1px 3px rgb(15 23 42 / 5%);
}
.am-guarantor-head {
display: flex;
align-items: center;
gap: 10px;
margin-bottom: 12px;
}
.am-guarantor-no {
display: grid;
place-items: center;
width: 26px;
height: 26px;
border-radius: 50%;
background: $primary-light;
color: $primary-dark;
font-size: 12px;
font-weight: 700;
}
.am-guarantor-title {
flex: 1;
font-size: 14px;
font-weight: 600;
color: $text-dark;
}
.am-guarantor-grid {
display: grid;
grid-template-columns: repeat(3, 1fr);
gap: 8px 16px;
}
.am-remove-guarantor {
padding: 6px 9px;
border: 0;
border-radius: 8px;
background: transparent;
color: $danger;
cursor: pointer;
transition: background 0.15s;
&:hover {
background: #fef2f2;
}
}
// ── Action bar ──────────────────────────────────────────────────────────────
.am-action-bar {
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
flex-wrap: wrap;
margin-top: 18px;
padding: 14px 0;
border-top: 1px solid rgb(0 0 0 / 8%);
&__left,
&__right {
display: flex;
align-items: center;
gap: 12px;
flex-wrap: wrap;
}
}
.am-btn-ic {
margin-right: 8px;
font-size: 12px;
&--after {
margin-right: 0;
margin-left: 8px;
}
} }
@media (width <= 900px) { @media (width <= 900px) {
.am-grid { .am-grid,
.am-guarantor-grid {
grid-template-columns: repeat(2, 1fr); grid-template-columns: repeat(2, 1fr);
} }
.am-guarantor-row, .am-tab-content {
.am-limit-row { padding: 18px 14px 4px;
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. */ @media (width <= 560px) {
.am-opt { .am-grid,
display: flex; .am-guarantor-grid {
align-items: baseline; grid-template-columns: 1fr;
gap: 10px; }
overflow: hidden;
}
.am-opt-more { .am-field--wide,
font-size: 0.75rem; .am-field--full {
font-style: italic; grid-column: span 1;
opacity: 0.75; }
}
.am-opt-id { .am-action-bar__left,
flex: 0 0 auto; .am-action-bar__right {
min-width: 52px; width: 100%;
font-family: ui-monospace, 'SF Mono', 'Cascadia Mono', monospace; justify-content: flex-end;
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%);
} }
@@ -7,9 +7,10 @@
*/ */
/** Angular Imports */ /** Angular Imports */
import { ChangeDetectorRef, Component, ElementRef, OnInit, ViewChild, inject } from '@angular/core'; import { Component, OnInit, inject } from '@angular/core';
import { import {
UntypedFormBuilder, UntypedFormBuilder,
UntypedFormControl,
UntypedFormGroup, UntypedFormGroup,
UntypedFormArray, UntypedFormArray,
Validators, Validators,
@@ -24,8 +25,8 @@ import { STANDALONE_SHARED_IMPORTS } from 'app/standalone-shared.module';
/** Custom Services */ /** Custom Services */
import { OrganizationService } from 'app/organization/organization.service'; import { OrganizationService } from 'app/organization/organization.service';
import { AccountingService } from 'app/accounting/accounting.service'; import { AccountingService } from 'app/accounting/accounting.service';
import { SystemService } from 'app/system/system.service';
import { ClientsService } from 'app/clients/clients.service'; import { ClientsService } from 'app/clients/clients.service';
import { SavingsService } from 'app/savings/savings.service';
import { SettingsService } from 'app/settings/settings.service'; import { SettingsService } from 'app/settings/settings.service';
import { Dates } from 'app/core/utils/dates'; import { Dates } from 'app/core/utils/dates';
import { AlertService } from 'app/core/alert/alert.service'; import { AlertService } from 'app/core/alert/alert.service';
@@ -33,10 +34,19 @@ import { AuthenticationService } from 'app/core/authentication/authentication.se
/** Custom Directives */ /** Custom Directives */
import { RestrictInputDirective } from 'app/directives/restrict-input.directive'; import { RestrictInputDirective } from 'app/directives/restrict-input.directive';
import { MatAutocompleteModule, MatAutocompleteTrigger } from '@angular/material/autocomplete'; import { MatDialog } from '@angular/material/dialog';
import { searchClientsAnyCase } from 'app/deposits/party-master/client-search'; import { MatTabsModule } from '@angular/material/tabs';
import { NgxMatSelectSearchModule } from 'ngx-mat-select-search';
import { businessToday } from 'app/core/utils/business-today';
import {
CustomerSearchDialogComponent,
CustomerSearchResult
} from 'app/shared/customer-search-dialog/customer-search-dialog.component';
import { officesUnder } from '../offices-under'; import { officesUnder } from '../offices-under';
/** The chart's account type for a liability: 1 asset, 2 liability, 3 equity, 4 income, 5 expense. */
const AGENT_GL_TYPE_LIABILITY = 2;
/** Writes a value into a form group only when there is something to write. */ /** Writes a value into a form group only when there is something to write. */
function setGroupField(group: UntypedFormGroup, controlName: string, value: any): void { function setGroupField(group: UntypedFormGroup, controlName: string, value: any): void {
if (value === null || value === undefined || value === '') { if (value === null || value === undefined || value === '') {
@@ -67,7 +77,8 @@ function setGroupField(group: UntypedFormGroup, controlName: string, value: any)
...STANDALONE_SHARED_IMPORTS, ...STANDALONE_SHARED_IMPORTS,
// See the Party form: an unimported directive is ignored without a word. // See the Party form: an unimported directive is ignored without a word.
RestrictInputDirective, RestrictInputDirective,
MatAutocompleteModule MatTabsModule,
NgxMatSelectSearchModule
] ]
}) })
export class CreateAgentComponent implements OnInit { export class CreateAgentComponent implements OnInit {
@@ -76,17 +87,13 @@ export class CreateAgentComponent implements OnInit {
private route = inject(ActivatedRoute); private route = inject(ActivatedRoute);
private organizationService = inject(OrganizationService); private organizationService = inject(OrganizationService);
private clientsService = inject(ClientsService); private clientsService = inject(ClientsService);
private savingsService = inject(SavingsService);
private accountingService = inject(AccountingService); private accountingService = inject(AccountingService);
private systemService = inject(SystemService);
private settingsService = inject(SettingsService); private settingsService = inject(SettingsService);
private dateUtils = inject(Dates); private dateUtils = inject(Dates);
private alertService = inject(AlertService); private alertService = inject(AlertService);
private authenticationService = inject(AuthenticationService); private authenticationService = inject(AuthenticationService);
private changeDetector = inject(ChangeDetectorRef); private dialog = inject(MatDialog);
/** The agent's client field and its list, so a list that arrives late can still be opened. */
@ViewChild('clientTrigger') clientTrigger?: MatAutocompleteTrigger;
@ViewChild('clientInput') clientInput?: ElementRef<HTMLInputElement>;
/** /**
* The branch the officer is logged in to. An agent is opened there and nowhere else: the Home * The branch the officer is logged in to. An agent is opened there and nowhere else: the Home
@@ -118,11 +125,6 @@ export class CreateAgentComponent implements OnInit {
private static readonly MIN_AGENT_AGE = 16; private static readonly MIN_AGENT_AGE = 16;
private static readonly MAX_AGENT_AGE = 60; 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; agentId: number | null = null;
/** Shown under the Client ID field once a client has been resolved. */ /** Shown under the Client ID field once a client has been resolved. */
@@ -130,17 +132,99 @@ export class CreateAgentComponent implements OnInit {
clientLookupFailed = false; clientLookupFailed = false;
/** /**
* Clients to pick from, as on the Party form: the first page when the field is focused, then * Set when what was typed in the Client ID field is not an ID - a name, or a mobile number - so the
* whatever matches what has been typed. An officer knows the agent's name, rarely their client id. * field can say to search for it rather than calling it a client that does not exist.
*/ */
clientOptions: any[] = []; clientNeedsSearch = false;
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 tabs the form is set out over - Agent Details, Address & Contact, Branch & Accounts,
* Guarantors, Balances - and the one showing. Every field is part of the one form whichever tab
* it is on, so moving between tabs loses and changes nothing.
*/
selectedTabIndex = 0;
/** Balances is the fifth tab, and only an agent already on the books has one. */
get tabCount(): number {
return this.agentId ? 5 : 4;
}
/** The controls each tab holds, in tab order, so a tab can say it still needs attention. */
private static readonly TAB_CONTROLS: string[][] = [
[
'officeId',
'clientId',
'agentCode',
'agentNameId',
'agentOpeningDate',
'agentBirthDate'
],
[
'agentAddress1',
'agentAddress2',
'agentCity',
'agentPinCode',
'agentTelephone'
],
[
'glAccountId',
'agentSavingAccountType',
'agentSavingAccountNo'
],
[],
[
'agentCurrentBalance',
'agentOpeningBalance'
]
];
/**
* Whether a tab holds something to put right: a field that is invalid and has been visited, or - on
* the first tab - a client who cannot be made an agent. Display only; Save is decided as before.
*/
tabHasError(index: number): boolean {
const visited = (control: AbstractControl | null) =>
!!control && control.invalid && (control.touched || control.dirty);
if (index === 3) {
return this.guarantors.controls.some((row) => {
const group = row as UntypedFormGroup;
return Object.values(group.controls).some((control) => visited(control));
});
}
const controls = CreateAgentComponent.TAB_CONTROLS[index] ?? [];
const flagged = controls.some((name) => visited(this.agentForm.get(name)));
if (index === 0) {
return (
flagged ||
this.clientNeedsSearch ||
!!this.clientAlreadyAgent ||
!!this.clientAgeProblem ||
this.clientOtherBranch
);
}
return flagged;
}
/** An Indian mobile number is ten digits; a client ID is never that long. */
private static readonly MOBILE_NUMBER_LENGTH = 10;
/**
* The code the next agent is expected to get, shown in place of an Agent Code field - the code is
* assigned by the server when the agent is saved, never keyed. It is worked out the way the server
* does it: "AG" and one more than the highest number among every agent on the books, closed ones
* included. An estimate only - another agent saved first, or one waiting for approval, can take it.
*/
expectedAgentCode = '';
private static nextAgentCode(agents: any[]): string {
let highest = 0;
for (const agent of agents) {
const match = /^AG(\d+)$/.exec(String(agent?.agentCode ?? ''));
if (match) {
highest = Math.max(highest, Number(match[1]));
}
}
return `AG${String(highest + 1).padStart(4, '0')}`;
} }
/** /**
@@ -176,15 +260,29 @@ export class CreateAgentComponent implements OnInit {
glAccounts: any[] = []; glAccounts: any[] = [];
/** /**
* The savings product a DDS customer's own scheme account is opened under, read from the setting * What is typed in the GL Account list's search box. The list narrows to the accounts whose code or
* that names it rather than guessed from a product name a bank is free to change. * name contains it, as the account pickers on the product screens do. Only what is shown changes -
* * the field still holds the account's id.
* Held so those accounts can be kept out of the agent's own list. They are savings accounts and
* pass every other test, but they belong to a customer's scheme: commission paid into one would be
* counted as that customer's deposits by every interest run afterwards. The server refuses it
* either way - this is so the choice is never offered rather than taken and then thrown back.
*/ */
private ddsProductId: number | null = null; glAccountSearch = new UntypedFormControl('');
get filteredGlAccounts(): any[] {
const search = String(this.glAccountSearch.value ?? '')
.trim()
.toLowerCase();
if (!search) {
return this.glAccounts;
}
return this.glAccounts.filter(
(account: any) =>
String(account.glCode ?? '')
.toLowerCase()
.includes(search) ||
String(account.name ?? '')
.toLowerCase()
.includes(search)
);
}
/** /**
* The accounts on offer once a type has been chosen. Type is asked first, as the legacy screen * The accounts on offer once a type has been chosen. Type is asked first, as the legacy screen
@@ -202,9 +300,10 @@ export class CreateAgentComponent implements OnInit {
clientId: [ clientId: [
'', '',
// A name typed to search with is not a client; only an id picked or keyed saves. // A name typed to search with is not a client; only an id picked or keyed saves.
// Up to nine digits - a mobile number is ten, so one typed in full is not mistaken for an id.
[ [
Validators.required, Validators.required,
Validators.pattern(/^\d+$/) Validators.pattern(/^\d{1,9}$/)
] ]
], ],
agentCode: [ agentCode: [
@@ -246,8 +345,10 @@ export class CreateAgentComponent implements OnInit {
'', '',
Validators.required Validators.required
], ],
// A new agent is opened on the bank's business date, not the computer's clock; it can still be
// changed, and an agent being edited keeps the date it was opened on.
agentOpeningDate: [ agentOpeningDate: [
'', businessToday(),
Validators.required Validators.required
], ],
// Filled from the client, whose date of birth was checked when they were opened. // Filled from the client, whose date of birth was checked when they were opened.
@@ -271,13 +372,11 @@ export class CreateAgentComponent implements OnInit {
}); });
ngOnInit(): void { ngOnInit(): void {
// A different branch has different clients: the list is read again, and a client already keyed // A client already keyed is checked against a different branch when that is chosen.
// is checked against the new branch.
this.agentForm this.agentForm
.get('officeId') .get('officeId')
?.valueChanges.pipe(distinctUntilChanged()) ?.valueChanges.pipe(distinctUntilChanged())
.subscribe(() => { .subscribe(() => {
this.initialClients = [];
if (!this.agentId && this.agentForm.get('clientId')?.value) { if (!this.agentId && this.agentForm.get('clientId')?.value) {
this.loadClient(); this.loadClient();
} }
@@ -291,6 +390,7 @@ export class CreateAgentComponent implements OnInit {
this.agentCodeByClientId[String(agent.clientId)] = agent.agentCode; this.agentCodeByClientId[String(agent.clientId)] = agent.agentCode;
} }
} }
this.expectedAgentCode = CreateAgentComponent.nextAgentCode(agents || []);
}, },
error: () => (this.agentCodeByClientId = {}) error: () => (this.agentCodeByClientId = {})
}); });
@@ -307,25 +407,19 @@ export class CreateAgentComponent implements OnInit {
error: () => (this.offices = []) error: () => (this.offices = [])
}); });
this.systemService.getConfigurationByName('dds-deposit-product').subscribe({
next: (config: any) => {
this.ddsProductId = config?.enabled && config?.value ? Number(config.value) : null;
// The client may already have been keyed while this was in flight, so the list is rebuilt
// rather than left showing an account this now knows to exclude.
const clientId = this.agentForm.get('clientId')?.value;
if (clientId) {
this.loadClientSavingsAccounts(String(clientId));
}
},
error: () => (this.ddsProductId = null)
});
this.accountingService.getChartOfAccounts().subscribe({ this.accountingService.getChartOfAccounts().subscribe({
next: (accounts: any) => { next: (accounts: any) => {
const rows = Array.isArray(accounts) ? accounts : accounts?.pageItems || []; const rows = Array.isArray(accounts) ? accounts : accounts?.pageItems || [];
// usage 1 is a detail account, 2 a header. Disabled accounts refuse every posting, so an // usage 1 is a detail account, 2 a header. Disabled accounts refuse every posting, so an
// agent pointed at one would fail on their first collection rather than here. // agent pointed at one would fail on their first collection rather than here.
this.glAccounts = rows.filter((a: any) => a.usage?.id === 1 && !a.disabled); //
// Liability accounts only (type 2). The cashier credits this account with the cash an agent
// hands over and Apply DDS Receipt debits it as the money is placed on the customers, so what
// stands on it is held for others - a liability. An asset, income or expense account would
// book that money as the bank's own.
this.glAccounts = rows.filter(
(a: any) => a.usage?.id === 1 && !a.disabled && a.type?.id === AGENT_GL_TYPE_LIABILITY
);
}, },
error: () => (this.glAccounts = []) error: () => (this.glAccounts = [])
}); });
@@ -342,22 +436,63 @@ export class CreateAgentComponent implements OnInit {
?.valueChanges.pipe(debounceTime(500), distinctUntilChanged()) ?.valueChanges.pipe(debounceTime(500), distinctUntilChanged())
.subscribe((value) => { .subscribe((value) => {
const typed = String(value ?? '').trim(); const typed = String(value ?? '').trim();
// A name is something to search for, not a client yet - the fields stay until one is picked. // A name or a mobile number is something to search for, not a client yet. Nothing is looked
if (typed && !/^\d+$/.test(typed)) { // up as the officer types - the search button does that - so the fields stay until one is
this.searchClients(typed); // picked, and no client is asked for on a guess.
this.clientNeedsSearch = this.needsSearch(typed);
if (this.clientNeedsSearch) {
return; return;
} }
if (!typed) {
this.showClientList();
}
this.loadClient(); this.loadClient();
// A guarantor row that was fine may now be the agent itself, or the other way round. // A guarantor row that was fine may now be the agent itself, or the other way round.
for (const row of this.guarantors.controls) { for (const row of this.guarantors.controls) {
row.get('clientId')?.updateValueAndValidity(); 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));
} }
});
}
/**
* Whether what was typed is something to search for rather than a client ID: letters, which make it
* a name, or digits as long as a mobile number.
*/
private needsSearch(typed: string): boolean {
if (!typed) {
return false;
}
return !/^\d+$/.test(typed) || typed.length >= CreateAgentComponent.MOBILE_NUMBER_LENGTH;
}
/**
* Opens Search Customer - the same dialog, and the same customer search API, the cash screens use -
* to find the agent's client by ID, name or mobile number, and puts the one picked into the field,
* which then fills the rest as an ID keyed in would.
*/
openClientSearch(): void {
if (this.agentId) {
return;
}
this.openCustomerSearch((result) => this.agentForm.get('clientId')?.setValue(String(result.clientId)));
}
/** The same search for a guarantor row, which fills that row from the client picked. */
openGuarantorSearch(group: AbstractControl): void {
this.openCustomerSearch((result) => group.get('clientId')?.setValue(String(result.clientId)), group);
}
private openCustomerSearch(picked: (result: CustomerSearchResult) => void, row?: AbstractControl): void {
const typed = String((row ?? this.agentForm).get('clientId')?.value ?? '').trim();
this.dialog
.open(CustomerSearchDialogComponent, {
width: '960px',
maxWidth: '95vw',
// An agent or guarantor is a customer first - they need not hold an account to be picked, and
// are listed once however many accounts they have. What was already typed is searched for.
data: { requireAccount: false, uniqueCustomers: true, prefill: typed }
})
.afterClosed()
.subscribe((result: CustomerSearchResult | undefined) => {
if (result?.clientId) {
picked(result);
} }
}); });
} }
@@ -372,7 +507,7 @@ export class CreateAgentComponent implements OnInit {
'', '',
[ [
Validators.required, Validators.required,
Validators.pattern(/^\d+$/), Validators.pattern(/^\d{1,9}$/),
this.notTheAgentItself(), this.notTheAgentItself(),
this.notAlreadyAGuarantor() this.notAlreadyAGuarantor()
] ]
@@ -398,26 +533,16 @@ 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. // 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. // A keyed id fills the row; a name or mobile number is found with the row's search button.
group group
.get('clientId') .get('clientId')
?.valueChanges.pipe(debounceTime(500), distinctUntilChanged()) ?.valueChanges.pipe(debounceTime(500), distinctUntilChanged())
.subscribe((value) => { .subscribe((value) => {
const typed = String(value ?? '').trim(); const typed = String(value ?? '').trim();
if (typed && !/^\d+$/.test(typed)) { if (this.needsSearch(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; return;
} }
this.setGuarantorOptions(group, []);
this.loadGuarantorClient(group, typed); 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. // Two rows naming the same client: the other row's check has to be run again too.
for (const row of this.guarantors.controls) { for (const row of this.guarantors.controls) {
if (row !== group) { if (row !== group) {
@@ -429,59 +554,6 @@ export class CreateAgentComponent implements OnInit {
return group; 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. */ /** The same client cannot guarantee one agent twice - it counts as one guarantee, not two. */
private notAlreadyAGuarantor(): ValidatorFn { private notAlreadyAGuarantor(): ValidatorFn {
return (control: AbstractControl): ValidationErrors | null => { return (control: AbstractControl): ValidationErrors | null => {
@@ -551,8 +623,6 @@ export class CreateAgentComponent implements OnInit {
addGuarantor(): void { addGuarantor(): void {
const group = this.buildGuarantorGroup(); const group = this.buildGuarantorGroup();
this.guarantors.push(group); 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 { removeGuarantor(index: number): void {
@@ -573,60 +643,6 @@ export class CreateAgentComponent implements OnInit {
'agentPinCode' '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. * 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 * They always show the client currently entered: changing the id replaces them, clearing it
@@ -645,6 +661,8 @@ export class CreateAgentComponent implements OnInit {
this.savingsAccountTypes = []; this.savingsAccountTypes = [];
this.agentForm.get('agentSavingAccountType')?.setValue(''); this.agentForm.get('agentSavingAccountType')?.setValue('');
this.agentForm.get('agentSavingAccountNo')?.setValue(''); this.agentForm.get('agentSavingAccountNo')?.setValue('');
this.savingsInfo = null;
this.savingsInfoFailed = false;
this.clientOtherBranch = false; this.clientOtherBranch = false;
this.clientAlreadyAgent = ''; this.clientAlreadyAgent = '';
@@ -707,11 +725,8 @@ export class CreateAgentComponent implements OnInit {
// Savings only. Commission is credited whenever a run posts, and a fixed deposit is a // Savings only. Commission is credited whenever a run posts, and a fixed deposit is a
// single sum placed for a term while a recurring one takes only its own instalments - // single sum placed for a term while a recurring one takes only its own instalments -
// neither accepts the credit, so the posting would fail rather than the choice. // neither accepts the credit, so the posting would fail rather than the choice.
// A DDS scheme account is a savings account too and so passes the test above. It is // A DDS customer's scheme account would pass this too; the server refuses one on save.
// dropped by the product the setting names - not by a product called "DDS", which is a
// name the bank may change - so the list offers only accounts the agent can be paid in.
account.depositType?.id === CreateAgentComponent.SAVINGS_DEPOSIT_TYPE && account.depositType?.id === CreateAgentComponent.SAVINGS_DEPOSIT_TYPE &&
(this.ddsProductId === null || account.productId !== this.ddsProductId) &&
(account.status?.active || account.status?.submittedAndPendingApproval || account.status?.approved) (account.status?.active || account.status?.submittedAndPendingApproval || account.status?.approved)
); );
this.savingsAccountTypes = [ this.savingsAccountTypes = [
@@ -722,6 +737,9 @@ export class CreateAgentComponent implements OnInit {
this.savingsAccountTypeCodes[account.productName] = account.shortProductName; this.savingsAccountTypeCodes[account.productName] = account.shortProductName;
} }
} }
// An agent being edited already has an account chosen: show how it stands as soon as the
// client's accounts are known.
this.showSavingsAccountInfo(this.agentForm.get('agentSavingAccountNo')?.value);
}, },
error: () => { error: () => {
this.clientSavingsAccounts = []; this.clientSavingsAccounts = [];
@@ -738,6 +756,63 @@ export class CreateAgentComponent implements OnInit {
*/ */
onSavingsAccountTypePicked(): void { onSavingsAccountTypePicked(): void {
this.agentForm.get('agentSavingAccountNo')?.setValue(''); this.agentForm.get('agentSavingAccountNo')?.setValue('');
this.savingsInfo = null;
}
/**
* How the account commission is paid into stands: its status, balance, what is on hold, and what
* is left to use. Read from the account itself, the same figures the cashier's screen shows for it,
* so the officer sees whether it can take the credit before saving rather than when a run fails.
* Display only - nothing here is saved or checked.
*/
savingsInfo: {
status: string;
statusClass: 'ok' | 'wait' | 'off';
symbol: string;
balance: number;
hold: number;
available: number;
} | null = null;
savingsInfoLoading = false;
savingsInfoFailed = false;
onSavingsAccountPicked(): void {
this.showSavingsAccountInfo(this.agentForm.get('agentSavingAccountNo')?.value);
}
private showSavingsAccountInfo(accountNo: string | null | undefined): void {
this.savingsInfo = null;
this.savingsInfoFailed = false;
this.savingsInfoLoading = false;
const match = accountNo ? this.clientSavingsAccounts.find((account: any) => account.accountNo === accountNo) : null;
if (!match) {
return;
}
this.savingsInfoLoading = true;
this.savingsService.getSavingsAccountData(String(match.id)).subscribe({
next: (account: any) => {
// The account may have been swapped for another while this was on its way.
if (this.agentForm.get('agentSavingAccountNo')?.value !== accountNo) {
return;
}
const status = account?.status ?? {};
this.savingsInfo = {
status: status.value || '—',
statusClass: status.active ? 'ok' : status.submittedAndPendingApproval || status.approved ? 'wait' : 'off',
symbol: account?.currency?.displaySymbol ?? '₹',
balance: Number(account?.summary?.accountBalance ?? 0),
// Savings carries a lien or hold as a figure on the account itself, and its available
// balance is already worked out with every hold taken off - as on the cashier's screen.
hold: Number(account?.savingsAmountOnHold ?? 0),
available: Number(account?.summary?.availableBalance ?? account?.summary?.accountBalance ?? 0)
};
this.savingsInfoLoading = false;
},
error: () => {
this.savingsInfoLoading = false;
this.savingsInfoFailed = true;
}
});
} }
private loadClientAddress(clientId: string): void { private loadClientAddress(clientId: string): void {
@@ -790,9 +865,14 @@ export class CreateAgentComponent implements OnInit {
this.agentForm.reset(); this.agentForm.reset();
this.agentForm.patchValue({ this.agentForm.patchValue({
agentCurrentBalance: 0, agentCurrentBalance: 0,
agentOpeningBalance: 0 agentOpeningBalance: 0,
agentOpeningDate: businessToday()
}); });
this.guarantors.clear(); this.guarantors.clear();
this.savingsInfo = null;
this.savingsInfoFailed = false;
// Back to the first tab, where a fresh entry begins.
this.selectedTabIndex = 0;
} }
exit(): void { exit(): void {
@@ -910,9 +990,6 @@ function asDate(value: any): Date | null {
return null; 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. */ /** 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 { function ageOn(birthDate: Date | null, on: Date): number | null {
if (!birthDate) { if (!birthDate) {
@@ -0,0 +1,193 @@
<!--
Copyright since 2025 Mifos Initiative
This Source Code Form is subject to the terms of the Mozilla Public
License, v. 2.0. If a copy of the MPL was not distributed with this
file, You can obtain one at http://mozilla.org/MPL/2.0/.
-->
<div class="vsp-root va-page">
<button type="button" class="va-back" (click)="back()">
<fa-icon icon="arrow-left"></fa-icon>
<span>All agents</span>
</button>
@if (loading) {
<div class="va-state">Loading agent…</div>
} @else if (notFound || !agent) {
<div class="va-state">This agent could not be found.</div>
} @else {
<!-- HERO -->
<div class="vsp-hero">
<div class="vsp-hero-left">
<div class="vsp-hero-icon"><fa-icon icon="user-tie"></fa-icon></div>
<div class="vsp-hero-text">
<div class="vsp-hero-eyebrow">DDS Agent</div>
<h2 class="vsp-hero-title">{{ displayName }}</h2>
<div class="vsp-hero-meta">
<span class="vsp-pill vsp-pill--code">{{ agent.agentCode }}</span>
@if (agent.homeBranch) {
<span class="vsp-pill vsp-pill--teal">{{ agent.homeBranch }}</span>
}
@if (agent.closeDate) {
<span class="vsp-pill vsp-pill--gray">
<fa-icon icon="times-circle"></fa-icon>
Closed {{ agent.closeDate | dateFormat }}
</span>
} @else {
<span class="vsp-pill" [class.vsp-pill--green]="agent.active" [class.vsp-pill--amber]="!agent.active">
<fa-icon [icon]="agent.active ? 'check-circle' : 'times-circle'"></fa-icon>
{{ agent.active ? 'Active' : 'Inactive' }}
</span>
}
</div>
</div>
</div>
<!-- A closed agent is off the books: nothing can be changed on it, only read. -->
@if (!agent.closeDate) {
<button class="vsp-edit-btn" type="button" [routerLink]="['../', agent.id, 'edit']">
<fa-icon icon="edit"></fa-icon>
<span>Edit</span>
</button>
}
</div>
<!-- DETAILS -->
<section class="vsp-section">
<div class="vsp-section-h">
<div class="vsp-section-ic vsp-section-ic--blue"><fa-icon icon="info-circle"></fa-icon></div>
<h3>Details</h3>
</div>
<div class="vsp-grid">
<div class="vsp-kv">
<span class="vsp-k">Agent Code</span>
<span class="vsp-v"
><strong>{{ agent.agentCode }}</strong></span
>
</div>
<div class="vsp-kv">
<span class="vsp-k">Client</span>
<span class="vsp-v">{{ agent.clientName || '—' }}</span>
</div>
<div class="vsp-kv">
<span class="vsp-k">Agent Name ID</span>
<span class="vsp-v">{{ agent.agentNameId || '—' }}</span>
</div>
<div class="vsp-kv">
<span class="vsp-k">Home Branch</span>
<span class="vsp-v">{{ agent.homeBranch || '—' }}</span>
</div>
<div class="vsp-kv">
<span class="vsp-k">Opening Date</span>
<span class="vsp-v">{{ agent.agentOpeningDate ? (agent.agentOpeningDate | dateFormat) : '—' }}</span>
</div>
<div class="vsp-kv">
<span class="vsp-k">Birth Date</span>
<span class="vsp-v">{{ agent.agentBirthDate ? (agent.agentBirthDate | dateFormat) : '—' }}</span>
</div>
<div class="vsp-kv">
<span class="vsp-k">Created</span>
<span class="vsp-v">
{{ agent.createdDate ? (agent.createdDate | dateFormat: 'DD MMM YYYY, hh:mm A') : '—' }}
</span>
</div>
<div class="vsp-kv">
<span class="vsp-k">Last Modified</span>
<span class="vsp-v">
{{ agent.lastModifiedDate ? (agent.lastModifiedDate | dateFormat: 'DD MMM YYYY, hh:mm A') : '—' }}
</span>
</div>
</div>
</section>
<!-- ADDRESS & CONTACT -->
<section class="vsp-section">
<div class="vsp-section-h">
<div class="vsp-section-ic vsp-section-ic--amber"><fa-icon icon="map-marker-alt"></fa-icon></div>
<h3>Address &amp; Contact</h3>
</div>
<div class="vsp-grid">
<div class="vsp-kv vsp-kv--span">
<span class="vsp-k">Address</span>
<span class="vsp-v">{{ address || '—' }}</span>
</div>
<div class="vsp-kv">
<span class="vsp-k">City</span>
<span class="vsp-v">{{ agent.agentCity || '—' }}</span>
</div>
<div class="vsp-kv">
<span class="vsp-k">Pin Code</span>
<span class="vsp-v">{{ agent.agentPinCode || '—' }}</span>
</div>
<div class="vsp-kv">
<span class="vsp-k">Telephone</span>
<span class="vsp-v">{{ agent.agentTelephone || '—' }}</span>
</div>
</div>
</section>
<!-- ACCOUNTS & BALANCES -->
<section class="vsp-section">
<div class="vsp-section-h">
<div class="vsp-section-ic vsp-section-ic--green"><fa-icon icon="coins"></fa-icon></div>
<h3>Accounts &amp; Balances</h3>
</div>
<div class="vsp-grid">
<div class="vsp-kv">
<span class="vsp-k">GL Account</span>
<span class="vsp-v">
@if (agent.glAccountCode) {
{{ agent.glAccountCode }} · {{ agent.glAccountName }}
} @else {
—
}
</span>
</div>
<div class="vsp-kv">
<span class="vsp-k">Saving A/C Type</span>
<span class="vsp-v">{{ agent.agentSavingAccountType || '—' }}</span>
</div>
<div class="vsp-kv">
<span class="vsp-k">Saving A/C No</span>
<span class="vsp-v">{{ agent.agentSavingAccountNo || '—' }}</span>
</div>
<div class="vsp-kv">
<span class="vsp-k">Current Balance</span>
<span class="vsp-v vsp-v--accent">{{ money(agent.agentCurrentBalance) }}</span>
</div>
<div class="vsp-kv">
<span class="vsp-k">Opening Balance</span>
<span class="vsp-v">{{ money(agent.agentOpeningBalance) }}</span>
</div>
</div>
</section>
<!-- GUARANTORS -->
<section class="vsp-section">
<div class="vsp-section-h">
<div class="vsp-section-ic vsp-section-ic--blue"><fa-icon icon="user-shield"></fa-icon></div>
<h3>Guarantors ({{ agent.guarantors?.length || 0 }})</h3>
</div>
@for (guarantor of agent.guarantors || []; track $index) {
<div class="vsp-grid">
<div class="vsp-kv">
<span class="vsp-k">Guarantor {{ $index + 1 }}</span>
<span class="vsp-v"
><strong>{{ guarantor.guarantorName || '—' }}</strong></span
>
</div>
<div class="vsp-kv">
<span class="vsp-k">Telephone</span>
<span class="vsp-v">{{ guarantor.telephone || '—' }}</span>
</div>
<div class="vsp-kv vsp-kv--span">
<span class="vsp-k">Address</span>
<span class="vsp-v">{{ guarantorAddress(guarantor) || '—' }}</span>
</div>
</div>
} @empty {
<div class="va-empty">No guarantors on file.</div>
}
</section>
}
</div>
@@ -0,0 +1,51 @@
/**
* 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/.
*/
// Only what the agent view adds to the fixed deposit product sheet it shares: a way back to the list and a
// placeholder while the agent loads.
.va-page {
padding-top: 8px;
}
.va-back {
display: inline-flex;
align-items: center;
gap: 8px;
align-self: flex-start;
padding: 6px 12px;
border: 1px solid var(--vsp-g200);
border-radius: 8px;
background: #fff;
color: var(--vsp-g700);
font: inherit;
font-size: 12.5px;
font-weight: 600;
cursor: pointer;
transition:
background 0.15s ease,
border-color 0.15s ease;
&:hover {
background: var(--vsp-g100);
border-color: var(--vsp-g300);
}
}
.va-state {
padding: 32px 0;
text-align: center;
color: var(--vsp-g500);
font-size: 14px;
}
.va-empty {
padding: 14px 0 4px;
color: var(--vsp-g500);
font-size: 13px;
}
@@ -0,0 +1,103 @@
/**
* Copyright since 2025 Mifos Initiative
*
* This Source Code Form is subject to the terms of the Mozilla Public
* License, v. 2.0. If a copy of the MPL was not distributed with this
* file, You can obtain one at http://mozilla.org/MPL/2.0/.
*/
import { Component, OnInit, inject } from '@angular/core';
import { ActivatedRoute, Router, RouterLink } from '@angular/router';
import { FaIconComponent } from '@fortawesome/angular-fontawesome';
import { STANDALONE_SHARED_IMPORTS } from 'app/standalone-shared.module';
import { OrganizationService } from 'app/organization/organization.service';
/**
* A DDS collection agent, read-only: who the agent is, where they live, the accounts they collect against, their
* balances and the guarantors standing behind them. Laid out as the DDS product view is laid out - and drawn by the same
* stylesheet - so every master reads the same way. Edit leads to the same form the list does, and is not offered on an
* agent that has been closed.
*/
@Component({
selector: 'mifosx-view-agent',
templateUrl: './view-agent.component.html',
styleUrls: [
'../../../products/fixed-deposit-products/view-fixed-deposit-product/fixed-deposit-general-tab/fixed-deposit-general-tab.component.scss',
'./view-agent.component.scss'
],
imports: [
...STANDALONE_SHARED_IMPORTS,
RouterLink,
FaIconComponent
]
})
export class ViewAgentComponent implements OnInit {
private route = inject(ActivatedRoute);
private router = inject(Router);
private organizationService = inject(OrganizationService);
agent: any = null;
loading = true;
notFound = false;
ngOnInit(): void {
const id = this.route.snapshot.paramMap.get('agentId');
if (!id) {
this.router.navigate(['/organization/agent-master']);
return;
}
this.organizationService.getAgent(id).subscribe({
next: (agent: any) => {
this.agent = agent;
this.loading = false;
},
error: () => {
this.loading = false;
this.notFound = true;
}
});
}
private static readonly MONEY = new Intl.NumberFormat('en-IN', {
minimumFractionDigits: 2,
maximumFractionDigits: 2
});
money(value: any): string {
return ViewAgentComponent.MONEY.format(Number(value || 0));
}
/** The agent's name as the list shows it: the name on file, falling back to the client's. */
get displayName(): string {
return this.agent?.agentNameId || this.agent?.clientName || this.agent?.agentCode || '';
}
/** The address on one line, skipping whatever was left blank. */
get address(): string {
return [
this.agent?.agentAddress1,
this.agent?.agentAddress2,
this.agent?.agentCity,
this.agent?.agentPinCode
]
.filter((part) => !!part)
.join(', ');
}
/** A guarantor's address on one line, skipping whatever was left blank. */
guarantorAddress(guarantor: any): string {
return [
guarantor?.address1,
guarantor?.address2,
guarantor?.city,
guarantor?.pinCode
]
.filter((part) => !!part)
.join(', ');
}
back(): void {
this.router.navigate(['/organization/agent-master']);
}
}
@@ -1,157 +0,0 @@
<!--
Copyright since 2025 Mifos Initiative
This Source Code Form is subject to the terms of the Mozilla Public
License, v. 2.0. If a copy of the MPL was not distributed with this
file, You can obtain one at http://mozilla.org/MPL/2.0/.
-->
<div class="csm-container">
<div class="csm-header">
<button mat-stroked-button [routerLink]="['../']" class="csm-back">
<i class="fas fa-arrow-left"></i> {{ 'labels.heading.Organization' | translate }}
</button>
<h2 class="csm-header__title">Commission Slab Master</h2>
</div>
<div class="csm-card">
<table class="csm-table">
<thead>
<tr>
<th>Slab Type</th>
<th>Slab From</th>
<th>Slab To</th>
<th>Comm Per</th>
<th></th>
</tr>
</thead>
<tbody>
@for (slab of slabs; track slab; let i = $index) {
@if (editingIndex === i) {
<!-- The same row turned into inputs, so a correction is made where the value is read. -->
<tr [formGroup]="editSlabForm" class="csm-new-row">
<td>
<mat-form-field appearance="outline" class="csm-inline-field">
<mat-select formControlName="slabType">
@for (type of slabTypes; track type) {
<mat-option [value]="type">{{ type }}</mat-option>
}
</mat-select>
</mat-form-field>
</td>
<td>
<mat-form-field appearance="outline" class="csm-inline-field">
<input matInput type="number" formControlName="slabFrom" />
</mat-form-field>
</td>
<td>
<mat-form-field appearance="outline" class="csm-inline-field">
<input matInput type="number" formControlName="slabTo" />
</mat-form-field>
</td>
<td>
<mat-form-field appearance="outline" class="csm-inline-field">
<input matInput type="number" formControlName="commissionPercent" />
</mat-form-field>
</td>
<td class="csm-row-actions">
<button mat-icon-button color="primary" type="button" (click)="saveEdit()" matTooltip="Save correction">
<i class="fas fa-check"></i>
</button>
<button mat-icon-button type="button" (click)="cancelEdit()" matTooltip="Cancel">
<i class="fas fa-times"></i>
</button>
</td>
</tr>
} @else {
<tr>
<td>{{ slab.slabType }}</td>
<td>{{ slab.slabFrom }}</td>
<td>{{ slab.slabTo }}</td>
<td>{{ slab.commissionPercent }}%</td>
<td class="csm-row-actions">
@if (slab.id) {
<!--
A saved slab is corrected, never removed. Deleting a range would leave a hole
that pays nothing to any agent whose volume lands in it, with nothing on screen
to say why - and there is deliberately no delete endpoint behind it.
-->
<button mat-icon-button color="primary" type="button" (click)="startEdit(i)" matTooltip="Edit slab">
<i class="fas fa-pen"></i>
</button>
} @else {
<!-- Not saved yet, so taking it back off the table costs nothing. -->
<button mat-icon-button type="button" (click)="removeSlab(i)" matTooltip="Remove before saving">
<i class="fas fa-times"></i>
</button>
}
</td>
</tr>
}
}
<tr [formGroup]="newSlabForm" class="csm-new-row">
<td>
<mat-form-field appearance="outline" class="csm-inline-field">
<mat-select required formControlName="slabType" placeholder="Slab Type">
@for (type of slabTypes; track type) {
<mat-option [value]="type">{{ type }}</mat-option>
}
</mat-select>
</mat-form-field>
</td>
<td>
<mat-form-field appearance="outline" class="csm-inline-field">
<input matInput required type="number" formControlName="slabFrom" />
</mat-form-field>
</td>
<td>
<mat-form-field appearance="outline" class="csm-inline-field">
<input matInput required type="number" formControlName="slabTo" />
</mat-form-field>
</td>
<td>
<mat-form-field appearance="outline" class="csm-inline-field">
<input matInput required type="number" formControlName="commissionPercent" />
</mat-form-field>
</td>
<td class="csm-row-actions">
<!--
Deliberately not disabled while the row is incomplete. A greyed-out button says the
screen will not accept this, but never which field it is waiting for; letting the
click through means addSlab() can say so.
-->
<button mat-icon-button color="primary" type="button" (click)="addSlab()" matTooltip="Add slab">
<i class="fas fa-plus"></i>
</button>
</td>
</tr>
</tbody>
</table>
@if (slabs.length === 0) {
<p class="csm-empty">No commission slabs defined yet — add one using the row above.</p>
}
@if (coverage.length > 0) {
<div class="csm-coverage">
@for (entry of coverage; track entry.slabType) {
<div class="csm-coverage__row">
<span class="csm-coverage__type">{{ entry.slabType }}</span>
<span class="csm-coverage__range"> covers {{ entry.from | number }} to {{ entry.to | number }} </span>
@if (entry.gaps.length > 0) {
<span class="csm-coverage__gap">
nothing set for {{ entry.gaps.join(', ') }} — an agent landing there earns no commission
</span>
}
</div>
}
</div>
}
<div class="csm-action-bar">
<button mat-raised-button type="button" (click)="exit()">Exit</button>
<button mat-raised-button color="primary" type="button" (click)="save()">Save</button>
</div>
</div>
</div>
@@ -1,163 +0,0 @@
/**
* Copyright since 2025 Mifos Initiative
*
* This Source Code Form is subject to the terms of the Mozilla Public
* License, v. 2.0. If a copy of the MPL was not distributed with this
* file, You can obtain one at http://mozilla.org/MPL/2.0/.
*/
$primary: #0f766e;
.csm-container {
// Four inputs sit side by side in the entry row; 760px squeezed them out of the card.
max-width: 980px;
margin: 0 auto;
padding: 24px 16px;
}
.csm-header {
display: flex;
align-items: center;
gap: 16px;
margin-bottom: 20px;
&__title {
margin: 0;
font-size: 22px;
font-weight: 500;
}
}
.csm-back {
color: $primary;
}
.csm-card {
background: var(--md-sys-color-surface, #fff);
border-radius: 8px;
box-shadow: 0 2px 8px rgb(0 0 0 / 8%);
padding: 16px 24px 24px;
}
.csm-table {
width: 100%;
border-collapse: collapse;
table-layout: fixed;
// Sized rather than left to the browser, so the entry row keeps its shape whatever is typed in.
th:nth-child(1),
td:nth-child(1) {
width: 26%;
}
th:nth-child(2),
td:nth-child(2) {
width: 22%;
}
th:nth-child(3),
td:nth-child(3) {
width: 22%;
}
th:nth-child(4),
td:nth-child(4) {
width: 18%;
}
th:nth-child(5),
td:nth-child(5) {
width: 12%;
}
th {
text-align: left;
font-size: 13px;
text-transform: uppercase;
letter-spacing: 0.04em;
color: rgb(0 0 0 / 55%);
padding: 8px 10px;
border-bottom: 2px solid rgb(0 0 0 / 10%);
}
td {
padding: 6px 10px;
border-bottom: 1px solid rgb(0 0 0 / 6%);
vertical-align: middle;
}
}
.csm-inline-field {
width: 100%;
// Without this the field holds its own 180px floor and pushes the next column out of the card.
min-width: 0;
::ng-deep .mat-mdc-text-field-wrapper {
height: 44px;
}
}
.csm-row-actions {
text-align: center;
width: 48px;
}
.csm-new-row {
background: rgb(15 118 110 / 4%);
}
.csm-empty {
color: rgb(0 0 0 / 50%);
font-size: 14px;
margin: 12px 0 0;
}
.csm-action-bar {
display: flex;
justify-content: flex-end;
gap: 12px;
margin-top: 20px;
padding-top: 16px;
border-top: 1px solid rgb(0 0 0 / 8%);
}
/*
* What the slabs actually cover, and any figure between them that none of them claims.
*
* A gap is legitimate - a bank may set no commission below some floor - so this reports rather than
* warns. But it has to be said out loud: the commission run finds no slab for a volume in a gap and
* moves on, so the agent is simply paid nothing that month and nothing on screen explains it.
*/
.csm-coverage {
margin-top: 14px;
padding: 12px 16px;
border: 1px solid rgb(0 0 0 / 12%);
border-radius: 6px;
background: rgb(0 0 0 / 2.5%);
display: flex;
flex-direction: column;
gap: 6px;
&__row {
display: flex;
align-items: baseline;
gap: 10px;
flex-wrap: wrap;
font-size: 0.86rem;
}
&__type {
font-weight: 600;
min-width: 90px;
}
&__range {
color: rgb(0 0 0 / 62%);
font-variant-numeric: tabular-nums;
}
&__gap {
color: #b45309;
font-variant-numeric: tabular-nums;
}
}
@@ -11,13 +11,82 @@
<button mat-stroked-button [routerLink]="['../']" class="bp-back"> <button mat-stroked-button [routerLink]="['../']" class="bp-back">
<i class="fas fa-arrow-left"></i> {{ 'labels.heading.Organization' | translate }} <i class="fas fa-arrow-left"></i> {{ 'labels.heading.Organization' | translate }}
</button> </button>
<div class="bp-header__ic"><i class="fas fa-cogs"></i></div>
<div>
<h2 class="bp-header__title">DDS Interest &amp; Commission</h2> <h2 class="bp-header__title">DDS Interest &amp; Commission</h2>
<p class="bp-header__sub">
Calculate, check and post DDS interest and agent commission, and close matured accounts
</p>
</div>
<span class="bp-header__spacer"></span>
<button mat-stroked-button type="button" class="bp-runs-btn" (click)="openRuns()">
<i class="fas fa-history"></i> Previous runs
<span class="bp-runs-btn__count">{{ runStats.total }}</span>
@if (runStats.failed) {
<span class="bp-runs-btn__warn" matTooltip="{{ runStats.failed }} failed"></span>
}
</button>
</div> </div>
<div class="bp-card">
<form [formGroup]="batchForm"> <form [formGroup]="batchForm">
<!-- ── 1. The job ─────────────────────────────────────────────────────────────────────────── -->
<div class="bp-card">
<div class="bp-step-head">
<span class="bp-step-no">1</span>
<div>
<h3>Choose the job</h3>
<p>Each month: <b>Calculate</b> first, check the preview, then <b>Post</b>.</p>
</div>
</div>
<div class="bp-jobs">
@for (group of jobGroups; track group.title) {
<div class="bp-job" [class.bp-job--active]="group.steps.includes(jobType)">
<div class="bp-job__head">
<span class="bp-job__ic"><i class="fas" [ngClass]="group.icon"></i></span>
<div>
<span class="bp-job__title">{{ group.title }}</span>
<span class="bp-job__hint">{{ group.hint }}</span>
</div>
</div>
<div class="bp-job__steps">
@for (code of group.steps; track code; let last = $last) {
<button
type="button"
class="bp-job__step"
[class.bp-job__step--on]="jobType === code"
(click)="selectJob(code)"
>
<i class="fas" [ngClass]="code.endsWith('_POSTING') ? 'fa-check-double' : 'fa-calculator'"></i>
{{ stepLabel(code) }}
</button>
@if (!last) {
<i class="fas fa-arrow-right bp-job__arrow"></i>
}
}
</div>
</div>
}
</div>
</div>
<!-- ── 2. Scope and date ──────────────────────────────────────────────────────────────────── -->
<div class="bp-card">
<div class="bp-step-head">
<span class="bp-step-no">2</span>
<div>
<h3>
{{ jobTypeLabel }}
<span class="bp-kind" [class.bp-kind--post]="isPostingJob">{{
isPostingJob ? 'Moves money' : 'Calculation only'
}}</span>
</h3>
<p>Which branches, agents and accounts, and as of which date.</p>
</div>
</div>
<div class="bp-grid"> <div class="bp-grid">
<mat-form-field appearance="outline" class="bp-field bp-field--wide"> <mat-form-field appearance="outline" class="bp-field" subscriptSizing="dynamic">
<mat-label>Job Type</mat-label> <mat-label>Job Type</mat-label>
<mat-select required formControlName="jobType"> <mat-select required formControlName="jobType">
@for (type of jobTypes; track type.code) { @for (type of jobTypes; track type.code) {
@@ -26,7 +95,14 @@
</mat-select> </mat-select>
</mat-form-field> </mat-form-field>
<mat-form-field appearance="outline" class="bp-field"> <mat-form-field appearance="outline" class="bp-field" subscriptSizing="dynamic">
<mat-label>{{ isPostingJob ? 'Posting Date' : 'Interest Date' }}</mat-label>
<input matInput required [matDatepicker]="jobDatePicker" formControlName="jobDate" />
<mat-datepicker-toggle matIconSuffix [for]="jobDatePicker"></mat-datepicker-toggle>
<mat-datepicker #jobDatePicker></mat-datepicker>
</mat-form-field>
<mat-form-field appearance="outline" class="bp-field" subscriptSizing="dynamic">
<mat-label>Branch</mat-label> <mat-label>Branch</mat-label>
<mat-select required formControlName="branchScope"> <mat-select required formControlName="branchScope">
@for (scope of branchScopes; track scope) { @for (scope of branchScopes; track scope) {
@@ -37,51 +113,59 @@
@if (isBranchSelected) { @if (isBranchSelected) {
<div class="bp-field bp-field--wide bp-branch-checklist"> <div class="bp-field bp-field--wide bp-branch-checklist">
<span class="bp-group-label">Branches ({{ selectedOfficeNames.size }} selected)</span>
<div class="bp-branch-checklist__list">
@for (office of offices; track office.id) { @for (office of offices; track office.id) {
<mat-checkbox [checked]="selectedOfficeNames.has(office.name)" (change)="toggleOffice(office.name)"> <mat-checkbox [checked]="selectedOfficeNames.has(office.name)" (change)="toggleOffice(office.name)">
{{ office.name }} {{ office.name }}
</mat-checkbox> </mat-checkbox>
} }
@if (offices.length === 0) { @if (offices.length === 0) {
<span class="bp-empty">Loading branches...</span> <span class="bp-empty"><i class="fas fa-circle-notch fa-spin"></i> Loading branches...</span>
} }
</div> </div>
</div>
} }
<mat-form-field appearance="outline" class="bp-field"> <div class="bp-ranges bp-field--wide">
<mat-label>{{ isPostingJob ? 'Posting Date' : 'Interest Date' }}</mat-label> <div class="bp-range">
<input matInput required [matDatepicker]="jobDatePicker" formControlName="jobDate" /> <span class="bp-group-label"><i class="fas fa-user-tie"></i> Agents</span>
<mat-datepicker-toggle matIconSuffix [for]="jobDatePicker"></mat-datepicker-toggle> <div class="bp-range__fields">
<mat-datepicker #jobDatePicker></mat-datepicker> <mat-form-field appearance="outline" class="bp-field" subscriptSizing="dynamic">
</mat-form-field> <mat-label>From</mat-label>
<mat-form-field appearance="outline" class="bp-field">
<mat-label>Agent From</mat-label>
<input matInput required type="number" formControlName="agentFrom" /> <input matInput required type="number" formControlName="agentFrom" />
</mat-form-field> </mat-form-field>
<span class="bp-range__to">to</span>
<mat-form-field appearance="outline" class="bp-field"> <mat-form-field appearance="outline" class="bp-field" subscriptSizing="dynamic">
<mat-label>Agent To</mat-label> <mat-label>To</mat-label>
<input matInput required type="number" formControlName="agentTo" /> <input matInput required type="number" formControlName="agentTo" />
</mat-form-field> </mat-form-field>
</div>
</div>
<!-- Commission is worked out per agent on their whole collection, so a party range would <!-- Commission is worked out per agent on their whole collection, so a party range would
imply a filter that has no effect. --> imply a filter that has no effect. -->
@if (!isCommissionJob) { @if (!isCommissionJob) {
<mat-form-field appearance="outline" class="bp-field"> <div class="bp-range">
<mat-label>Party From</mat-label> <span class="bp-group-label"><i class="fas fa-users"></i> Parties</span>
<div class="bp-range__fields">
<mat-form-field appearance="outline" class="bp-field" subscriptSizing="dynamic">
<mat-label>From</mat-label>
<input matInput required type="number" formControlName="partyFrom" /> <input matInput required type="number" formControlName="partyFrom" />
</mat-form-field> </mat-form-field>
<span class="bp-range__to">to</span>
<mat-form-field appearance="outline" class="bp-field"> <mat-form-field appearance="outline" class="bp-field" subscriptSizing="dynamic">
<mat-label>Party To</mat-label> <mat-label>To</mat-label>
<input matInput required type="number" formControlName="partyTo" /> <input matInput required type="number" formControlName="partyTo" />
</mat-form-field> </mat-form-field>
</div>
</div>
} }
</div> </div>
</div>
<div class="bp-callout"> <div class="bp-callout" [class.bp-callout--post]="isPostingJob">
<i class="fas fa-info-circle"></i> <i class="fas" [ngClass]="isPostingJob ? 'fa-exchange-alt' : 'fa-info-circle'"></i>
<span> <span>
@if (isPostingJob) { @if (isPostingJob) {
<strong>{{ jobTypeLabel }}</strong> moves what has already been calculated to where it belongs — into the <strong>{{ jobTypeLabel }}</strong> moves what has already been calculated to where it belongs — into the
@@ -95,130 +179,61 @@
</span> </span>
</div> </div>
@if (generatedRun) {
<div class="bp-section">
<div class="bp-section__header">
<i class="fas fa-table"></i>
<h3>{{ labelOf(generatedRun.jobType) }} — preview</h3>
<span class="bp-section__note"> Run #{{ generatedRun.id }} · nothing applied yet </span>
</div>
@if (previewRows.length === 0) {
<p class="bp-empty">
Nothing to apply. Either this period has already been processed, or there was no activity in it.
</p>
} @else {
<div class="bp-table-scroll">
@if (showsCommissionBreakdown) {
<table class="bp-table bp-table--breakdown">
<thead>
<tr>
<th>Agent</th>
<th>Period</th>
<th class="bp-num">Collection</th>
<th class="bp-num">Rate</th>
<th class="bp-num">Commission</th>
<th class="bp-num">TDS</th>
<th class="bp-num">Deductions</th>
<th class="bp-num">Payable</th>
</tr>
</thead>
<tbody>
@for (row of previewRows; track row.id) {
<tr>
<td>
<span class="bp-code">{{ row.agentCode }}</span>
<span class="bp-sub">{{ row.agentName }}</span>
</td>
<td class="bp-period">{{ row.fromDate | dateFormat }} – {{ row.toDate | dateFormat }}</td>
<td class="bp-num">{{ money(row.baseAmount) }}</td>
<td class="bp-num">{{ row.ratePercent }}%</td>
<td class="bp-num">{{ money(row.grossAmount) }}</td>
<td class="bp-num bp-minus">{{ money(row.tdsAmount) }}</td>
<td class="bp-num bp-minus">{{ money(row.deductionAmount) }}</td>
<td class="bp-num bp-total">{{ money(row.amount) }}</td>
</tr>
}
</tbody>
<tfoot>
<tr>
<td colspan="7">Total payable</td>
<td class="bp-num bp-total">{{ money(previewTotal) }}</td>
</tr>
</tfoot>
</table>
} @else {
<table class="bp-table">
<thead>
<tr>
<th>{{ isCommissionJob ? 'Agent' : 'Party' }}</th>
@if (!isCommissionJob) {
<th>Agent</th>
}
<th>Period</th>
<th class="bp-num">Amount</th>
</tr>
</thead>
<tbody>
@for (row of previewRows; track row.id) {
<tr>
<td>
@if (isCommissionJob) {
<span class="bp-code">{{ row.agentCode }}</span>
<span class="bp-sub">{{ row.agentName }}</span>
} @else {
<span class="bp-code">{{ row.partyCode }}</span>
<span class="bp-sub">{{ row.partyName }}</span>
}
</td>
@if (!isCommissionJob) {
<td>{{ row.agentCode }}</td>
}
<td class="bp-period">{{ row.fromDate | dateFormat }} – {{ row.toDate | dateFormat }}</td>
<td class="bp-num bp-total">{{ money(row.amount) }}</td>
</tr>
}
</tbody>
<tfoot>
<tr>
<td [attr.colspan]="isCommissionJob ? 2 : 3">Total</td>
<td class="bp-num bp-total">{{ money(previewTotal) }}</td>
</tr>
</tfoot>
</table>
}
</div>
}
</div>
}
<div class="bp-action-bar"> <div class="bp-action-bar">
<button mat-raised-button type="button" (click)="exit()">Exit</button> <button mat-stroked-button type="button" (click)="exit()">Exit</button>
@if (generatedRun) { <span class="bp-action-bar__spacer"></span>
<button mat-stroked-button type="button" (click)="discard()">Discard</button> <!-- Generate opens the run on a page of its own; Post and Discard are there, beside the figures. -->
}
<button mat-stroked-button type="button" [disabled]="batchForm.invalid" (click)="generate()">
<i class="fas fa-calculator"></i> Generate
</button>
<button <button
mat-raised-button mat-flat-button
color="primary" color="primary"
type="button" type="button"
[disabled]="!generatedRun || previewRows.length === 0" [disabled]="batchForm.invalid || generating"
(click)="post()" (click)="generate()"
> >
<i class="fas fa-check"></i> Post @if (generating) {
<i class="fas fa-circle-notch fa-spin"></i> Generating…
} @else {
<i class="fas fa-calculator"></i> Generate
}
</button> </button>
</div> </div>
</form>
</div> </div>
<!--
A run generated and left unposted is still there to go back to. Each is one button, so the page a
clerk closed by mistake is a click away - not a Generate again.
-->
@if (pendingPreviews.length > 0) {
<div class="bp-card bp-previews">
<div class="bp-step-head bp-step-head--plain">
<span class="bp-step-no bp-step-no--muted"><i class="fas fa-table"></i></span>
<div>
<h3>Generated, not posted yet</h3>
<p>Open a run to check its figures, then Post or Discard it.</p>
</div>
</div>
<div class="bp-previews__list">
@for (run of pendingPreviews; track run.id) {
<button type="button" class="bp-preview-btn" (click)="openRun(run.id)">
<span class="bp-preview-btn__job">{{ labelOf(run.jobType) }}</span>
<span class="bp-preview-btn__meta">
Run #{{ run.id }} · {{ run.totalRows }} {{ run.totalRows === 1 ? 'entry' : 'entries' }} · ₹
{{ money(run.totalAmount) }}
</span>
<span class="bp-preview-btn__go">Open <i class="fas fa-arrow-right"></i></span>
</button>
}
</div>
</div>
}
</form>
<!-- <!--
A crediting run costs what a savings deposit costs on every account it touches, so the server does A crediting run costs what a savings deposit costs on every account it touches, so the server does
it in the background and answers at once. Without this the screen would report a run as posted it in the background and answers at once. Without this the screen would report a run as posted
seconds after it began, and the clerk would go looking for credits still being written. seconds after it began, and the clerk would go looking for credits still being written.
--> -->
@if (postingRunId) { @if (runStats.running > 0) {
<div class="bp-running"> <div class="bp-running">
<i class="fas fa-circle-notch fa-spin"></i> <i class="fas fa-circle-notch fa-spin"></i>
<span> <span>
@@ -228,47 +243,138 @@
</div> </div>
} }
<!-- Without this a generated-but-unposted run vanishes the moment the page is left, and there is <!--
no way to tell whether a month's interest was ever actually posted. --> Every run and whether it was applied - without this a generated-but-unposted run vanishes the moment
<div class="bp-card"> the page is left, and there is no way to tell whether a month's interest was ever actually posted.
<div class="bp-section__header"> In a popup so the page itself stays one screen: the list can run to hundreds of runs, filtered and
<i class="fas fa-history"></i> paged here, with each run's full detail opening under its row.
-->
<ng-template #runsDialog>
<div class="bpr">
<div class="bpr-head">
<span class="bpr-head__ic"><i class="fas fa-history"></i></span>
<div class="bpr-head__text">
<h3>Previous runs</h3> <h3>Previous runs</h3>
<p>Every run, and whether it was applied.</p>
</div>
<button type="button" class="bpr-icon-btn" (click)="loadRecentRuns()" matTooltip="Refresh" aria-label="Refresh">
<i class="fas fa-sync-alt" [class.fa-spin]="runsLoading"></i>
</button>
<button type="button" class="bpr-icon-btn" mat-dialog-close aria-label="Close">
<i class="fas fa-times"></i>
</button>
</div> </div>
@if (recentRuns.length === 0) { <div class="bpr-body">
<p class="bp-empty">No batch has been run yet.</p> <!-- A status chip says how many runs are in it and filters to them; the chips combine. -->
<div class="bpr-statuses">
@for (status of runStatuses; track status.code) {
<button
type="button"
class="bpr-chip bpr-chip--{{ status.code.toLowerCase() }}"
[class.bpr-chip--on]="runFilter.statuses.has(status.code)"
(click)="toggleRunStatus(status.code)"
>
{{ status.label }} <b>{{ runCount(status.code) }}</b>
</button>
}
<span class="bpr-total">{{ recentRuns.length }} runs in all</span>
</div>
<div class="bpr-filters">
<label class="bpr-field bpr-field--search">
<span>Search</span>
<input
type="search"
placeholder="Run number or job"
[value]="runFilter.search"
(input)="setRunSearch($any($event.target).value)"
/>
</label>
<label class="bpr-field">
<span>Job date from</span>
<input type="date" [value]="runFilter.from" (change)="setRunDate('from', $any($event.target).value)" />
</label>
<label class="bpr-field">
<span>to</span>
<input type="date" [value]="runFilter.to" (change)="setRunDate('to', $any($event.target).value)" />
</label>
@if (hasRunFilters) {
<button type="button" class="bpr-clear" (click)="clearRunFilters()">
<i class="fas fa-times"></i> Clear filters
</button>
}
</div>
<div class="bpr-jobs">
<span class="bpr-jobs__label">Job</span>
@for (job of runJobOptions; track job.code) {
<button
type="button"
class="bpr-chip"
[class.bpr-chip--on]="runFilter.jobs.has(job.code)"
(click)="toggleRunJob(job.code)"
>
{{ job.label }}
</button>
}
</div>
@if (runsLoading && recentRuns.length === 0) {
<div class="bp-skeleton">
@for (row of [1, 2, 3, 4, 5]; track row) {
<span class="bp-skeleton__bar"></span>
}
</div>
} @else if (recentRuns.length === 0) {
<p class="bp-empty bp-empty--box"><i class="fas fa-inbox"></i> No batch has been run yet.</p>
} @else if (filteredRuns.length === 0) {
<p class="bp-empty bp-empty--box">
<i class="fas fa-filter"></i> No run matches these filters.
<button type="button" class="bpr-clear" (click)="clearRunFilters()">Clear filters</button>
</p>
} @else { } @else {
<div class="bp-table-scroll"> <div class="bp-table-scroll bpr-scroll">
<table class="bp-table bp-table--runs"> <table class="bp-table bp-table--runs">
<thead> <thead>
<tr> <tr>
<th class="bpr-caret"></th>
<th>#</th> <th>#</th>
<th>Job</th> <th>Job</th>
<th>Date</th> <th>Job date</th>
<th class="bp-num">Entries</th> <th class="bp-num">Entries</th>
<th class="bp-num">Amount</th> <th class="bp-num">Amount</th>
<th>Status</th> <th>Status</th>
<th>Posted On</th> <th>Generated</th>
<th>Posted on</th>
</tr> </tr>
</thead> </thead>
<tbody> <tbody>
@for (run of recentRuns; track run.id) { @for (run of pagedRuns; track run.id) {
<tr> <tr
<td>{{ run.id }}</td> class="bpr-row"
<td>{{ labelOf(run.jobType) }}</td> [class.bpr-row--open]="expandedRunId === run.id"
(click)="toggleRunDetail(run.id)"
>
<td class="bpr-caret">
<i class="fas" [ngClass]="expandedRunId === run.id ? 'fa-chevron-down' : 'fa-chevron-right'"></i>
</td>
<td class="bp-runid">#{{ run.id }}</td>
<td>
<span class="bp-jobname">{{ labelOf(run.jobType) }}</span>
</td>
<td>{{ run.jobDate | dateFormat }}</td> <td>{{ run.jobDate | dateFormat }}</td>
<td class="bp-num">{{ run.totalRows }}</td> <td class="bp-num">{{ run.totalRows }}</td>
<td class="bp-num">{{ money(run.totalAmount) }}</td> <td class="bp-num">{{ money(run.totalAmount) }}</td>
<!-- <!--
Four states, not two. A run that is still crediting is not "not posted" — that Four states, not two. A run that is still crediting is not "not posted" - that
reads as a run somebody forgot, and a clerk would run it again on top of the one reads as a run somebody forgot, and a clerk would run it again on top of the one
already going. One that failed says so, with the reason the server recorded. already going. One that failed says so, with the reason the server recorded.
--> -->
<td> <td>
@switch (run.status) { @switch (run.status) {
@case ('POSTED') { @case ('POSTED') {
<span class="bp-status bp-status--posted">Posted</span> <span class="bp-status bp-status--posted"><i class="fas fa-check-circle"></i> Posted</span>
} }
@case ('POSTING') { @case ('POSTING') {
<span class="bp-status bp-status--running"> <span class="bp-status bp-status--running">
@@ -276,24 +382,88 @@
</span> </span>
} }
@case ('FAILED') { @case ('FAILED') {
<span <span class="bp-status bp-status--failed">
class="bp-status bp-status--failed" <i class="fas fa-exclamation-circle"></i> Failed
[matTooltip]="run.failureReason || 'No reason recorded'"
>
Failed
</span> </span>
} }
@default { @default {
<span class="bp-status">Not posted</span> <span class="bp-status"><i class="far fa-clock"></i> Not posted</span>
} }
} }
</td> </td>
<td>{{ run.generatedOnDate ? (run.generatedOnDate | dateFormat) : '—' }}</td>
<td>{{ run.postedOnDate ? (run.postedOnDate | dateFormat) : '—' }}</td> <td>{{ run.postedOnDate ? (run.postedOnDate | dateFormat) : '—' }}</td>
</tr> </tr>
@if (expandedRunId === run.id) {
<tr class="bpr-detail">
<td colspan="9">
<div class="bpr-detail__grid">
<div>
<span>Branch</span><strong>{{ run.branchScope || '—' }}</strong>
</div>
<div>
<span>Scheme</span><strong>{{ run.subType || 'All schemes' }}</strong>
</div>
<div>
<span>Agents</span><strong>{{ run.agentFrom }} to {{ run.agentTo }}</strong>
</div>
<div>
<span>Parties</span><strong>{{ run.partyFrom }} to {{ run.partyTo }}</strong>
</div>
<div>
<span>Entries posted</span><strong>{{ run.rowsPosted ?? 0 }} of {{ run.totalRows }}</strong>
</div>
<div>
<span>Amount</span><strong>₹ {{ money(run.totalAmount) }}</strong>
</div>
</div>
<div class="bpr-detail__open">
<button mat-stroked-button type="button" (click)="openRun(run.id)">
<i class="fas fa-external-link-alt"></i>
{{ run.status === 'CALCULATED' ? 'Open preview' : 'Open run' }}
</button>
</div>
@if (run.status === 'FAILED') {
<p class="bpr-detail__fail">
<i class="fas fa-exclamation-circle"></i>
{{ run.failureReason || 'No reason recorded' }}
</p>
}
</td>
</tr>
}
} }
</tbody> </tbody>
<tfoot>
<tr>
<td colspan="5">
{{ hasRunFilters ? 'Total of the ' + filteredRuns.length + ' runs shown' : 'Total' }}
</td>
<td class="bp-num">{{ money(filteredRunsTotal) }}</td>
<td colspan="3"></td>
</tr>
</tfoot>
</table> </table>
</div> </div>
<mat-paginator
[length]="filteredRuns.length"
[pageSize]="runsPageSize"
[pageIndex]="runsPageIndex"
[pageSizeOptions]="[10, 25, 50]"
showFirstLastButtons
(page)="onRunsPage($event)"
></mat-paginator>
} }
</div> </div>
<div class="bpr-actions">
<button mat-stroked-button type="button" mat-dialog-close>Close</button>
<span class="bp-action-bar__spacer"></span>
<button mat-stroked-button type="button" [disabled]="filteredRuns.length === 0" (click)="exportRuns()">
<i class="fas fa-file-export"></i> Export {{ hasRunFilters ? 'filtered' : 'all' }}
</button>
</div>
</div>
</ng-template>
</div> </div>
File diff suppressed because it is too large Load Diff
@@ -7,14 +7,14 @@
*/ */
/** Angular Imports */ /** Angular Imports */
import { Component, OnInit, inject } from '@angular/core'; import { Component, OnDestroy, OnInit, TemplateRef, ViewChild, inject } from '@angular/core';
import { UntypedFormBuilder, UntypedFormGroup, Validators } from '@angular/forms'; import { UntypedFormBuilder, UntypedFormGroup, Validators } from '@angular/forms';
import { Router } from '@angular/router'; import { Router } from '@angular/router';
import { MatDialog } from '@angular/material/dialog'; import { MatDialog, MatDialogClose } from '@angular/material/dialog';
import { MatPaginator, PageEvent } from '@angular/material/paginator';
import { MatTooltip } from '@angular/material/tooltip'; import { MatTooltip } from '@angular/material/tooltip';
import { DateAdapter, MAT_DATE_FORMATS } from '@angular/material/core'; import { DateAdapter, MAT_DATE_FORMATS } from '@angular/material/core';
import { STANDALONE_SHARED_IMPORTS } from 'app/standalone-shared.module'; import { STANDALONE_SHARED_IMPORTS } from 'app/standalone-shared.module';
import { ConfirmationDialogComponent } from 'app/shared/confirmation-dialog/confirmation-dialog.component';
import { DdsDateAdapter, DDS_DATE_FORMATS } from './dds-date-adapter'; import { DdsDateAdapter, DDS_DATE_FORMATS } from './dds-date-adapter';
/** Custom Services */ /** Custom Services */
@@ -22,7 +22,7 @@ import { OrganizationService } from 'app/organization/organization.service';
import { AlertService } from 'app/core/alert/alert.service'; import { AlertService } from 'app/core/alert/alert.service';
import { SettingsService } from 'app/settings/settings.service'; import { SettingsService } from 'app/settings/settings.service';
import { Dates } from 'app/core/utils/dates'; import { Dates } from 'app/core/utils/dates';
import { announceHeldForApproval, heldForApproval } from 'app/deposits/dds-approvals/dds-approval-notice'; import { csvAmount, dateText, formatMoney, jobLabel } from './dds-batch.shared';
/** /**
* DDS Interest & Commission batch processing — the legacy Interest Part menu. * DDS Interest & Commission batch processing — the legacy Interest Part menu.
@@ -42,7 +42,9 @@ import { announceHeldForApproval, heldForApproval } from 'app/deposits/dds-appro
imports: [ imports: [
...STANDALONE_SHARED_IMPORTS, ...STANDALONE_SHARED_IMPORTS,
// The reason a run failed is too long to sit in the column, and too useful to leave out. // The reason a run failed is too long to sit in the column, and too useful to leave out.
MatTooltip MatTooltip,
MatDialogClose,
MatPaginator
], ],
// Day-first dates, as the legacy screen showed them. Declared here rather than application-wide: // Day-first dates, as the legacy screen showed them. Declared here rather than application-wide:
// a @Component's providers reach only this component, so no other screen's datepicker changes. // a @Component's providers reach only this component, so no other screen's datepicker changes.
@@ -51,7 +53,7 @@ import { announceHeldForApproval, heldForApproval } from 'app/deposits/dds-appro
{ provide: MAT_DATE_FORMATS, useValue: DDS_DATE_FORMATS } { provide: MAT_DATE_FORMATS, useValue: DDS_DATE_FORMATS }
] ]
}) })
export class DdsBatchProcessingComponent implements OnInit { export class DdsBatchProcessingComponent implements OnInit, OnDestroy {
private formBuilder = inject(UntypedFormBuilder); private formBuilder = inject(UntypedFormBuilder);
private router = inject(Router); private router = inject(Router);
private organizationService = inject(OrganizationService); private organizationService = inject(OrganizationService);
@@ -67,8 +69,8 @@ export class DdsBatchProcessingComponent implements OnInit {
jobTypes: Array<{ label: string; code: string }> = [ jobTypes: Array<{ label: string; code: string }> = [
{ label: 'Interest Calculation', code: 'INTEREST_CALCULATION' }, { label: 'Interest Calculation', code: 'INTEREST_CALCULATION' },
{ label: 'Interest Posting', code: 'INTEREST_POSTING' }, { label: 'Interest Posting', code: 'INTEREST_POSTING' },
{ label: 'OD Interest Calculation', code: 'OD_INTEREST_CALCULATION' }, // OD Interest Calculation / Posting are not offered to run. Their names stay in RUN_LABELS so a
{ label: 'OD Interest Posting', code: 'OD_INTEREST_POSTING' }, // run of either already on the list below still reads by name.
{ label: 'Commission Calculation', code: 'COMMISSION_CALCULATION' }, { label: 'Commission Calculation', code: 'COMMISSION_CALCULATION' },
{ label: 'Commission Posting', code: 'COMMISSION_POSTING' }, { label: 'Commission Posting', code: 'COMMISSION_POSTING' },
// Last, and on its own: the others move money onto accounts that stay open, while this one pays // Last, and on its own: the others move money onto accounts that stay open, while this one pays
@@ -82,18 +84,258 @@ export class DdsBatchProcessingComponent implements OnInit {
'Selected' 'Selected'
]; ];
private static readonly MONEY = new Intl.NumberFormat('en-IN', { /**
minimumFractionDigits: 2, * The job menu as tiles, each calculate/post pair side by side, so the order a month is run in
maximumFractionDigits: 2 * reads off the screen: calculate, check, then post. Maturity Auto Close stands on its own.
*/
readonly jobGroups: Array<{ title: string; icon: string; hint: string; steps: string[] }> = [
{
title: 'Interest',
icon: 'fa-percent',
hint: 'Customer interest on the daily balance',
steps: [
'INTEREST_CALCULATION',
'INTEREST_POSTING'
]
},
{
title: 'Agent Commission',
icon: 'fa-user-tie',
hint: 'Agent commission on collection, from the slab chart',
steps: [
'COMMISSION_CALCULATION',
'COMMISSION_POSTING'
]
},
{
title: 'Maturity',
icon: 'fa-flag-checkered',
hint: 'Pays out and closes matured accounts',
steps: ['MATURITY_AUTO_CLOSE']
}
];
/** Picks a job from its tile - the same control the form validates. */
selectJob(code: string): void {
this.batchForm.get('jobType')?.setValue(code);
}
/** "Calculate" or "Post" (or "Run") - what a tile's button says. */
stepLabel(code: string): string {
if (code.endsWith('_CALCULATION')) {
return 'Calculate';
}
return code.endsWith('_POSTING') ? 'Post' : 'Run';
}
// ── Previous runs, in a popup ─────────────────────────────────────────────────────────────────────
@ViewChild('runsDialog') runsDialog: TemplateRef<unknown>;
/** The statuses a run can be in, as the screen names them, in the order a run moves through them. */
readonly runStatuses: Array<{ code: string; label: string }> = [
{ code: 'CALCULATED', label: 'Not posted' },
{ code: 'POSTING', label: 'Running' },
{ code: 'POSTED', label: 'Posted' },
{ code: 'FAILED', label: 'Failed' }
];
/** Every filter at once: what is typed, the jobs and statuses picked (any number), and a job date range. */
runFilter = {
search: '',
jobs: new Set<string>(),
statuses: new Set<string>(),
from: '',
to: ''
};
filteredRuns: any[] = [];
runsPageIndex = 0;
runsPageSize = 10;
/** The run whose full detail is open under its row. */
expandedRunId: number | null = null;
/** The jobs the list actually holds - so a retired job (OD interest) still filters, and an unused one does not clutter. */
get runJobOptions(): Array<{ code: string; label: string }> {
const codes = Array.from(new Set(this.recentRuns.map((run) => run.jobType)));
return codes.map((code) => ({ code, label: this.labelOf(code) }));
}
get hasRunFilters(): boolean {
const f = this.runFilter;
return !!(f.search.trim() || f.jobs.size || f.statuses.size || f.from || f.to);
}
get pagedRuns(): any[] {
const start = this.runsPageIndex * this.runsPageSize;
return this.filteredRuns.slice(start, start + this.runsPageSize);
}
/** What the filtered list adds up to, so the footer says it without anyone counting rows. */
get filteredRunsTotal(): number {
return this.filteredRuns.reduce((total, run) => total + Number(run.totalAmount || 0), 0);
}
statusLabel(code: string): string {
return this.runStatuses.find((status) => status.code === code)?.label ?? code;
}
/** How many runs are in each status, for the numbers on the status chips (against the whole list, not the filtered one). */
runCount(statusCode: string): number {
return this.recentRuns.filter((run) => run.status === statusCode).length;
}
openRuns(): void {
// Fresh each time it is opened: a run started or posted since is on the list.
this.loadRecentRuns();
this.dialog.open(this.runsDialog, { width: '1040px', maxWidth: '96vw', autoFocus: false });
}
/** Filters the list and returns to its first page - a narrower list may have fewer pages than the one being read. */
applyRunFilters(): void {
const f = this.runFilter;
const term = f.search.trim().toLowerCase();
this.filteredRuns = this.recentRuns.filter((run) => {
if (term && !`#${run.id} ${run.id} ${this.labelOf(run.jobType)}`.toLowerCase().includes(term)) {
return false;
}
if (f.jobs.size && !f.jobs.has(run.jobType)) {
return false;
}
if (f.statuses.size && !f.statuses.has(run.status)) {
return false;
}
const day = dateText(run.jobDate);
if (f.from && day < f.from) {
return false;
}
return !(f.to && day > f.to);
}); });
this.runsPageIndex = 0;
}
setRunSearch(value: string): void {
this.runFilter.search = value;
this.applyRunFilters();
}
setRunDate(which: 'from' | 'to', value: string): void {
this.runFilter[which] = value;
this.applyRunFilters();
}
toggleRunJob(code: string): void {
this.toggleIn(this.runFilter.jobs, code);
}
toggleRunStatus(code: string): void {
this.toggleIn(this.runFilter.statuses, code);
}
private toggleIn(set: Set<string>, value: string): void {
if (set.has(value)) {
set.delete(value);
} else {
set.add(value);
}
this.applyRunFilters();
}
clearRunFilters(): void {
this.runFilter = { search: '', jobs: new Set<string>(), statuses: new Set<string>(), from: '', to: '' };
this.applyRunFilters();
}
onRunsPage(event: PageEvent): void {
this.runsPageIndex = event.pageIndex;
this.runsPageSize = event.pageSize;
}
toggleRunDetail(id: number): void {
this.expandedRunId = this.expandedRunId === id ? null : id;
}
/** The runs the filters leave, with everything the list knows about each, as a file. */
exportRuns(): void {
const quote = (value: any) => `"${String(value ?? '').replace(/"/g, '""')}"`;
const header = [
'Run #',
'Job',
'Job Date',
'Status',
'Entries',
'Entries Posted',
'Amount',
'Generated On',
'Posted On',
'Branch',
'Scheme',
'Agent From',
'Agent To',
'Party From',
'Party To',
'Failure Reason'
];
const lines = this.filteredRuns.map((run) =>
[
run.id,
this.labelOf(run.jobType),
dateText(run.jobDate),
this.statusLabel(run.status),
run.totalRows,
run.rowsPosted,
csvAmount(run.totalAmount),
dateText(run.generatedOnDate),
dateText(run.postedOnDate),
run.branchScope,
run.subType || 'All schemes',
run.agentFrom,
run.agentTo,
run.partyFrom,
run.partyTo,
run.failureReason
]
.map(quote)
.join(',')
);
const blob = new Blob(
['\ufeff' + [
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 = 'dds-batch-runs.csv';
link.click();
URL.revokeObjectURL(link.href);
}
/** True while Generate is working out the run. */
generating = false;
/** True while the list of previous runs is being fetched. */
runsLoading = true;
/** How the runs on the list stand, for the summary above it. */
get runStats(): { total: number; posted: number; running: number; failed: number; pending: number } {
const count = (status: string) => this.recentRuns.filter((run) => run.status === status).length;
const posted = count('POSTED');
const running = count('POSTING');
const failed = count('FAILED');
return {
total: this.recentRuns.length,
posted,
running,
failed,
pending: this.recentRuns.length - posted - running - failed
};
}
offices: any[] = []; offices: any[] = [];
selectedOfficeNames = new Set<string>(); selectedOfficeNames = new Set<string>();
/** The run currently previewed on screen, or null once it has been posted or discarded. */
generatedRun: any = null;
previewRows: any[] = [];
recentRuns: any[] = []; recentRuns: any[] = [];
batchForm: UntypedFormGroup = this.formBuilder.group({ batchForm: UntypedFormGroup = this.formBuilder.group({
@@ -148,19 +390,6 @@ export class DdsBatchProcessingComponent implements OnInit {
return this.batchForm.get('branchScope')?.value === 'Selected'; return this.batchForm.get('branchScope')?.value === 'Selected';
} }
/**
* Only a commission calculation has a breakdown worth showing: it is the one job where the figure
* applied is not the figure worked out, because TDS and the standing deductions come off it.
*/
get showsCommissionBreakdown(): boolean {
return this.jobType === 'COMMISSION_CALCULATION';
}
/** What Generate found, in words — the same figure the Post button is about to apply. */
get previewTotal(): number {
return this.previewRows.reduce((total, row) => total + Number(row.amount || 0), 0);
}
ngOnInit(): void { ngOnInit(): void {
this.organizationService.getOffices().subscribe((offices: any[]) => { this.organizationService.getOffices().subscribe((offices: any[]) => {
this.offices = offices || []; this.offices = offices || [];
@@ -169,14 +398,11 @@ export class DdsBatchProcessingComponent implements OnInit {
} }
money(value: any): string { money(value: any): string {
if (value === null || value === undefined || value === '') { return formatMoney(value);
return '—';
}
return DdsBatchProcessingComponent.MONEY.format(Number(value));
} }
labelOf(code: string): string { labelOf(code: string): string {
return this.jobTypes.find((type) => type.code === code)?.label ?? code; return jobLabel(code);
} }
toggleOffice(officeName: string): void { toggleOffice(officeName: string): void {
@@ -192,9 +418,30 @@ export class DdsBatchProcessingComponent implements OnInit {
* invisible the moment the page is left, and there is no way to tell whether a month's interest * invisible the moment the page is left, and there is no way to tell whether a month's interest
* was ever actually posted. * was ever actually posted.
*/ */
private loadRecentRuns(): void { /** Runs generated and not yet posted - each gets a button to go back to its page. */
this.organizationService.getDdsBatchRuns().subscribe((runs: any[]) => { get pendingPreviews(): any[] {
return this.recentRuns.filter((run) => run.status === 'CALCULATED');
}
/** A run on a page of its own: its figures agent by agent, and Post or Discard while it is still a preview. */
openRun(runId: number): void {
this.dialog.closeAll();
this.router.navigate([
'/organization/dds-batch-processing/run',
runId
]);
}
loadRecentRuns(): void {
this.runsLoading = true;
this.organizationService.getDdsBatchRuns().subscribe({
next: (runs: any[]) => {
this.recentRuns = runs || []; this.recentRuns = runs || [];
this.runsLoading = false;
this.applyRunFilters();
this.watchRunning();
},
error: () => (this.runsLoading = false)
}); });
} }
@@ -224,174 +471,77 @@ export class DdsBatchProcessingComponent implements OnInit {
payload.officeNames = Array.from(this.selectedOfficeNames); payload.officeNames = Array.from(this.selectedOfficeNames);
} }
this.organizationService.generateDdsBatchRun(payload).subscribe((result: any) => { this.generating = true;
this.organizationService.getDdsBatchRun(result.resourceId).subscribe((batchRun: any) => { this.organizationService.generateDdsBatchRun(payload).subscribe({
this.generatedRun = batchRun; error: () => (this.generating = false),
this.previewRows = batchRun.rows || []; next: (result: any) => {
this.organizationService.getDdsBatchRun(result.resourceId).subscribe({
// A run that found nothing is the normal outcome of a period already processed, not a error: () => (this.generating = false),
// failure — but it has to say so, or an empty table reads as the screen being broken. next: (batchRun: any) => {
if (this.previewRows.length === 0) { this.generating = false;
this.alertService.alert({
type: 'Information',
message: `${this.jobTypeLabel} found nothing to apply. Either this period has already been processed, or there was no activity in it.`
});
} else {
this.alertService.alert({
type: 'Success',
message: `${this.jobTypeLabel}: ${this.previewRows.length} ${this.previewRows.length === 1 ? 'entry' : 'entries'} totalling ${this.money(this.previewTotal)} calculated. Nothing has been applied yet — review and press Post.`
});
}
this.loadRecentRuns(); this.loadRecentRuns();
// The run is checked on a page of its own - agent by agent, with Post and Discard beside the
// figures - so this page does not grow a second screen's worth below the form.
this.router.navigate([
'/organization/dds-batch-processing/run',
batchRun.id
]);
}
}); });
});
}
post(): void {
if (!this.generatedRun || this.previewRows.length === 0) {
return;
}
const count = this.previewRows.length;
const total = this.money(this.previewTotal);
const isPosting = this.isPostingJob;
// Post is the step that moves money, and it cannot be undone from this screen — so it names
// exactly what is about to change rather than asking a generic "are you sure".
this.dialog
.open(ConfirmationDialogComponent, {
data: {
type: 'warn',
heading: `Post ${this.jobTypeLabel}?`,
dialogContext:
`${count} ${count === 1 ? 'entry' : 'entries'} totalling ${total} will be applied. ` +
(isPosting
? 'This credits the customers or pays the agents, and appears on the ledger. '
: 'This records what has been earned against each account. ') +
'It cannot be undone from this screen.'
}
})
.afterClosed()
.subscribe((response: any) => {
if (response?.confirm) {
this.applyRun(count, total);
} }
}); });
} }
/** // ── Runs that are going on in the background ──────────────────────────────────────────────────────
* Applies the run, and says what actually happened rather than assuming it finished.
*
* A crediting run costs what a savings deposit costs on every account it touches, so the server
* takes it off the request and does it in the background - the reply comes back in a moment while
* the work is still going on. Reporting that as "posted" would tell the clerk a run had finished
* seconds after it began, and they would go looking for credits that were still being written.
*
* So the run is followed until it settles. A calculation run is done by the time the reply lands
* and says so at once; a crediting run says it is under way, and the list beneath refreshes itself
* until the server marks it posted - or failed, with the reason the server recorded.
*/
private applyRun(count: number, total: string): void {
const label = this.jobTypeLabel;
const runId = this.generatedRun.id;
const crediting = this.isPostingJob;
this.organizationService.postDdsBatchRun(runId).subscribe((response: any) => {
this.generatedRun = null;
this.previewRows = [];
this.loadRecentRuns();
if (heldForApproval(response)) {
announceHeldForApproval(
response,
`${label} (${count} ${count === 1 ? 'entry' : 'entries'}, ${total})`,
this.organizationService,
this.alertService
);
return;
}
if (!crediting) {
this.alertService.alert({
type: 'Success',
message: `${label} posted — ${count} ${count === 1 ? 'entry' : 'entries'}, ${total}.`
});
return;
}
this.postingRunId = runId;
this.alertService.alert({
type: 'Posting',
message:
`${label} is running — ${count} ${count === 1 ? 'entry' : 'entries'}, ${total}. ` +
'It continues in the background; this list will update when it is done.'
});
this.followRun(runId, label);
});
}
/** The crediting run being followed, if one is. The screen reads this to show it is under way. */
postingRunId: number | null = null;
/**
* Watches a background run until the server marks it settled.
*
* Polled rather than pushed: a batch that takes minutes needs no faster news than this, and a poll
* costs nothing when the answer is one row. It gives up after a while rather than asking forever -
* the run is still on the list either way, and a clerk can read its status there.
*/
private followRun(runId: number, label: string): void {
let checksLeft = DdsBatchProcessingComponent.MAX_STATUS_CHECKS;
const check = () => {
this.organizationService.getDdsBatchRuns().subscribe({
next: (runs: any[]) => {
const run = (runs || []).find((each: any) => each.id === runId);
const status = run?.status;
if (status === 'POSTED' || status === 'FAILED') {
this.postingRunId = null;
this.loadRecentRuns();
this.alertService.alert(
status === 'POSTED'
? { type: 'Success', message: `${label} posted — ${run.rowsPosted ?? run.totalRows} entries applied.` }
: { type: 'Error', message: `${label} failed: ${run.failureReason || 'see the run for details'}` }
);
return;
}
if (--checksLeft > 0) {
setTimeout(check, DdsBatchProcessingComponent.STATUS_CHECK_INTERVAL_MS);
} else {
// Still going after the wait. Left on the list rather than reported either way - saying
// "failed" for a run that is merely long would be worse than saying nothing.
this.postingRunId = null;
this.loadRecentRuns();
}
},
// A failed status check says nothing about the run itself, which carries on regardless.
error: () => {
if (--checksLeft > 0) {
setTimeout(check, DdsBatchProcessingComponent.STATUS_CHECK_INTERVAL_MS);
} else {
this.postingRunId = null;
}
}
});
};
setTimeout(check, DdsBatchProcessingComponent.STATUS_CHECK_INTERVAL_MS);
}
private static readonly STATUS_CHECK_INTERVAL_MS = 4000; private static readonly STATUS_CHECK_INTERVAL_MS = 4000;
/** Twenty minutes of checking, which is longer than any run measured here has taken. */ /** Twenty minutes of checking, which is longer than any run measured here has taken. */
private static readonly MAX_STATUS_CHECKS = 300; private static readonly MAX_STATUS_CHECKS = 300;
private checksLeft = DdsBatchProcessingComponent.MAX_STATUS_CHECKS;
private watching: ReturnType<typeof setTimeout> | null = null;
private runningIds = new Set<number>();
/** Drops the preview without applying it — the safe way out of a calculation that looks wrong. */ /**
discard(): void { * A crediting run is done by the server in the background, so the list is read again until none is
this.generatedRun = null; * still running, and the clerk is told how each one ended - posted, or failed with the reason the
this.previewRows = []; * server recorded. Polled rather than pushed: a batch that takes minutes needs no faster news, and
* a poll costs one query. It gives up after a while - the run is on the list either way.
*/
private watchRunning(): void {
const nowRunning = new Set<number>(this.recentRuns.filter((run) => run.status === 'POSTING').map((run) => run.id));
for (const id of this.runningIds) {
if (nowRunning.has(id)) {
continue;
}
const run = this.recentRuns.find((each) => each.id === id);
const label = this.labelOf(run?.jobType);
if (run?.status === 'POSTED') {
this.alertService.alert({
type: 'Success',
message: `${label} posted — ${run.rowsPosted ?? run.totalRows} entries applied.`
});
} else if (run?.status === 'FAILED') {
this.alertService.alert({
type: 'Error',
message: `${label} failed: ${run.failureReason || 'see the run for details'}`
});
}
}
this.runningIds = nowRunning;
if (this.watching) {
clearTimeout(this.watching);
this.watching = null;
}
if (nowRunning.size > 0 && this.checksLeft-- > 0) {
this.watching = setTimeout(() => this.loadRecentRuns(), DdsBatchProcessingComponent.STATUS_CHECK_INTERVAL_MS);
}
}
ngOnDestroy(): void {
if (this.watching) {
clearTimeout(this.watching);
}
} }
exit(): void { exit(): void {
@@ -0,0 +1,101 @@
/**
* Copyright since 2025 Mifos Initiative
*
* This Source Code Form is subject to the terms of the Mozilla Public
* License, v. 2.0. If a copy of the MPL was not distributed with this
* file, You can obtain one at http://mozilla.org/MPL/2.0/.
*/
/**
* What the DDS batch screen and its run preview page share: how a job is named, how money and dates
* read, and how a run's rows are grouped under their agent.
*/
/** One agent's lines of a previewed run: an agent and the DDS accounts under them (or, for commission, their one row). */
export interface AgentGroup {
key: string;
agentCode: string;
agentName: string;
initials: string;
rows: any[];
total: number;
}
/** Every job a run can have been, including those no longer offered to run (OD interest). */
export const JOB_LABELS: Record<string, string> = {
INTEREST_CALCULATION: 'Interest Calculation',
INTEREST_POSTING: 'Interest Posting',
OD_INTEREST_CALCULATION: 'OD Interest Calculation',
OD_INTEREST_POSTING: 'OD Interest Posting',
COMMISSION_CALCULATION: 'Commission Calculation',
COMMISSION_POSTING: 'Commission Posting',
MATURITY_AUTO_CLOSE: 'Maturity Auto Close'
};
export function jobLabel(code: string): string {
return JOB_LABELS[code] ?? code;
}
export function isCommissionJobType(code: string): boolean {
return (code || '').startsWith('COMMISSION');
}
export function isPostingJobType(code: string): boolean {
return (code || '').endsWith('_POSTING');
}
const MONEY = new Intl.NumberFormat('en-IN', { minimumFractionDigits: 2, maximumFractionDigits: 2 });
export function formatMoney(value: any): string {
if (value === null || value === undefined || value === '') {
return '—';
}
return MONEY.format(Number(value));
}
/** A date as the server sent it - text, or a [year, month, day] array - for a file or a comparison. */
export function dateText(value: any): string {
if (!value) {
return '';
}
if (Array.isArray(value)) {
return `${value[0]}-${String(value[1]).padStart(2, '0')}-${String(value[2]).padStart(2, '0')}`;
}
return String(value);
}
export function csvAmount(value: any): string {
return value === null || value === undefined || value === '' ? '' : Number(value).toFixed(2);
}
/**
* The rows of a run as agents with their accounts under them. A run can list a thousand accounts;
* read agent by agent it is a handful of lines.
*/
export function groupByAgent(rows: any[]): AgentGroup[] {
const byAgent = new Map<string, AgentGroup>();
for (const row of rows) {
const key = String(row.agentId ?? row.agentCode ?? '');
let group = byAgent.get(key);
if (!group) {
const name = String(row.agentName ?? '');
group = {
key,
agentCode: row.agentCode ?? '',
agentName: name,
initials: name
.split(/\s+/)
.filter(Boolean)
.slice(0, 2)
.map((word) => word[0].toUpperCase())
.join(''),
rows: [],
total: 0
};
byAgent.set(key, group);
}
group.rows.push(row);
group.total += Number(row.amount || 0);
}
return Array.from(byAgent.values());
}
@@ -0,0 +1,338 @@
<!--
Copyright since 2025 Mifos Initiative
This Source Code Form is subject to the terms of the Mozilla Public
License, v. 2.0. If a copy of the MPL was not distributed with this
file, You can obtain one at http://mozilla.org/MPL/2.0/.
-->
<div class="bp-container bprv">
<div class="bp-header">
<button
mat-icon-button
type="button"
class="bprv-back"
(click)="back()"
matTooltip="Back to DDS Interest &amp; Commission"
aria-label="Back"
>
<i class="fas fa-arrow-left"></i>
</button>
<div class="bp-header__ic"><i class="fas fa-table"></i></div>
<div>
<h2 class="bp-header__title">
{{ jobTypeLabel || 'Batch run' }} — {{ canAct ? 'preview' : 'run' }}
@if (generatedRun) {
<span
class="bp-status"
[class.bp-status--posted]="generatedRun.status === 'POSTED'"
[class.bp-status--failed]="generatedRun.status === 'FAILED'"
[class.bp-status--running]="generatedRun.status === 'POSTING'"
>{{ statusText }}</span
>
}
</h2>
<p class="bp-header__sub">
@if (generatedRun) {
Run #{{ generatedRun.id }} ·
{{
canAct
? 'nothing applied yet — check the figures, then Post.'
: 'read only — this run is ' + statusText.toLowerCase() + '.'
}}
}
</p>
</div>
<span class="bp-header__spacer"></span>
@if (canAct) {
<button mat-stroked-button type="button" [disabled]="working" (click)="discard()">
<i class="fas fa-times"></i> Discard
</button>
<button
mat-flat-button
color="primary"
type="button"
[disabled]="working || previewRows.length === 0"
(click)="post()"
>
<i class="fas fa-check"></i> Post
</button>
}
</div>
@if (loading) {
<div class="bp-card">
<div class="bp-skeleton">
@for (row of [1, 2, 3, 4, 5]; track row) {
<span class="bp-skeleton__bar"></span>
}
</div>
</div>
} @else if (notFound) {
<div class="bp-card">
<p class="bp-empty bp-empty--box">
<i class="fas fa-inbox"></i> This run no longer exists - a new Generate or Discard may have replaced it.
<button mat-stroked-button type="button" (click)="back()">Back</button>
</p>
</div>
} @else {
@if (generatedRun?.status === 'FAILED') {
<p class="bpr-detail__fail bprv-banner">
<i class="fas fa-exclamation-circle"></i> {{ generatedRun.failureReason || 'No reason recorded' }}
</p>
}
<div class="bp-card bprv-card">
@if (previewRows.length === 0) {
<p class="bp-empty bp-empty--box">
<i class="fas fa-inbox"></i>
Nothing to apply. Either this period has already been processed, or there was no activity in it.
</p>
} @else {
<div class="bp-kpis">
<div class="bp-kpi">
<span>Agents</span><strong>{{ agentGroups.length }}</strong>
</div>
<div class="bp-kpi">
<span>{{ isCommissionJob ? 'Entries' : 'Accounts' }}</span
><strong>{{ previewRows.length }}</strong>
</div>
<div class="bp-kpi bp-kpi--accent">
<span>{{ showsCommissionBreakdown ? 'Total payable' : 'Total' }}</span>
<strong>₹ {{ money(previewTotal) }}</strong>
</div>
<div class="bp-kpi">
<span>Date</span><strong>{{ generatedRun.jobDate | dateFormat }}</strong>
</div>
</div>
<div class="bp-tools">
<span class="bp-tools__hint">
{{
isCommissionJob
? 'One line per agent - View shows how their commission was worked out.'
: 'Grouped by collecting agent - View shows the DDS accounts under each.'
}}
</span>
<span class="bp-tools__spacer"></span>
<button mat-stroked-button type="button" (click)="exportPreview()">
<i class="fas fa-file-export"></i> Export all details
</button>
</div>
<div class="bp-table-scroll">
@if (isCommissionJob) {
<table class="bp-table bp-table--breakdown">
<thead>
<tr>
<th>Agent</th>
<th>Period</th>
@if (showsCommissionBreakdown) {
<th class="bp-num">Collection</th>
<th class="bp-num">Rate</th>
<th class="bp-num">Commission</th>
<th class="bp-num">TDS</th>
<th class="bp-num">Deductions</th>
}
<th class="bp-num">{{ showsCommissionBreakdown ? 'Payable' : 'Amount' }}</th>
<th class="bp-view-col"></th>
</tr>
</thead>
<tbody>
@for (group of agentGroups; track group.key) {
@for (row of group.rows; track row.id) {
<tr>
<td>
<span class="bp-code">{{ row.agentCode }}</span>
<span class="bp-sub">{{ row.agentName }}</span>
</td>
<td class="bp-period">{{ row.fromDate | dateFormat }} – {{ row.toDate | dateFormat }}</td>
@if (showsCommissionBreakdown) {
<td class="bp-num">{{ money(row.baseAmount) }}</td>
<td class="bp-num">
<span class="bp-rate">{{ row.ratePercent }}%</span>
</td>
<td class="bp-num">{{ money(row.grossAmount) }}</td>
<td class="bp-num bp-minus">{{ money(row.tdsAmount) }}</td>
<td class="bp-num bp-minus">{{ money(row.deductionAmount) }}</td>
}
<td class="bp-num bp-total">{{ money(row.amount) }}</td>
<td class="bp-view-col">
<button mat-stroked-button type="button" class="bp-view-btn" (click)="viewAgent(group)">
<i class="fas fa-eye"></i> View
</button>
</td>
</tr>
}
}
</tbody>
<tfoot>
<tr>
<td [attr.colspan]="showsCommissionBreakdown ? 7 : 2">Total</td>
<td class="bp-num bp-total">{{ money(previewTotal) }}</td>
<td></td>
</tr>
</tfoot>
</table>
} @else {
<table class="bp-table">
<thead>
<tr>
<th>Agent</th>
<th class="bp-num">Accounts</th>
<th class="bp-num">Amount</th>
<th class="bp-view-col"></th>
</tr>
</thead>
<tbody>
@for (group of agentGroups; track group.key) {
<tr class="bp-agent-row">
<td>
<span class="bp-avatar">{{ group.initials }}</span>
<span class="bp-code bp-code--inline">{{ group.agentCode }}</span>
<span class="bp-sub bp-sub--inline">{{ group.agentName }}</span>
</td>
<td class="bp-num">{{ group.rows.length }}</td>
<td class="bp-num bp-total">{{ money(group.total) }}</td>
<td class="bp-view-col">
<button mat-stroked-button type="button" class="bp-view-btn" (click)="viewAgent(group)">
<i class="fas fa-eye"></i> View
</button>
</td>
</tr>
}
</tbody>
<tfoot>
<tr>
<td>Total</td>
<td class="bp-num">{{ previewRows.length }}</td>
<td class="bp-num bp-total">{{ money(previewTotal) }}</td>
<td></td>
</tr>
</tfoot>
</table>
}
</div>
}
</div>
}
<!-- One agent's accounts (or commission breakdown) in a popup, opened from View. -->
<ng-template #agentDialog>
@if (selectedGroup; as group) {
<div class="bpd">
<div class="bpd-head">
<span class="bpd-av">{{ group.initials }}</span>
<div class="bpd-head__text">
<h3>
<span class="bp-code bp-code--inline">{{ group.agentCode }}</span> {{ group.agentName }}
</h3>
<p>
{{ labelOf(generatedRun?.jobType) }} · Run #{{ generatedRun?.id }} ·
{{ generatedRun?.jobDate | dateFormat }}
</p>
</div>
<button type="button" class="bpd-close" mat-dialog-close aria-label="Close">
<i class="fas fa-times"></i>
</button>
</div>
<div class="bpd-body">
@if (isCommissionJob) {
@for (row of group.rows; track row.id) {
<div class="bpd-grid">
@if (showsCommissionBreakdown) {
<div class="bpd-cell">
<span>Collection</span><strong>₹ {{ money(row.baseAmount) }}</strong>
</div>
<div class="bpd-cell">
<span>Slab rate</span><strong>{{ row.ratePercent }}%</strong>
</div>
<div class="bpd-cell">
<span>Commission</span><strong>₹ {{ money(row.grossAmount) }}</strong>
</div>
<div class="bpd-cell">
<span>– TDS</span><strong class="bp-minus">₹ {{ money(row.tdsAmount) }}</strong>
</div>
<div class="bpd-cell">
<span>– Deductions</span><strong class="bp-minus">₹ {{ money(row.deductionAmount) }}</strong>
</div>
}
<div class="bpd-cell bpd-cell--net">
<span>{{ showsCommissionBreakdown ? 'Payable to agent' : 'To be paid out' }}</span>
<strong>₹ {{ money(row.amount) }}</strong>
</div>
</div>
<p class="bpd-note">
Period {{ row.fromDate | dateFormat }} – {{ row.toDate | dateFormat }}. The agent's collection from all
their customers is added up and one slab rate applies to the whole figure.
</p>
}
} @else {
<div class="bpd-kpis">
<div class="bpd-cell">
<span>Accounts</span><strong>{{ group.rows.length }}</strong>
</div>
<div class="bpd-cell bpd-cell--net">
<span>Total</span><strong>₹ {{ money(group.total) }}</strong>
</div>
</div>
<input
type="search"
class="bpd-search"
placeholder="Search party code or name"
[value]="agentSearch"
(input)="agentSearch = $any($event.target).value"
/>
<div class="bp-table-scroll bpd-scroll">
<table class="bp-table">
<thead>
<tr>
<th>#</th>
<th>Party</th>
<th>Period</th>
<th class="bp-num">Rate</th>
<th class="bp-num">Amount</th>
</tr>
</thead>
<tbody>
@for (row of popupRows; track row.id; let i = $index) {
<tr>
<td class="bp-runid">{{ i + 1 }}</td>
<td>
<span class="bp-code bp-code--inline">{{ row.partyCode }}</span>
<span class="bp-sub bp-sub--inline">{{ row.partyName }}</span>
</td>
<td class="bp-period">{{ row.fromDate | dateFormat }} – {{ row.toDate | dateFormat }}</td>
<td class="bp-num">{{ row.ratePercent ? row.ratePercent + '%' : '—' }}</td>
<td class="bp-num bp-total">{{ money(row.amount) }}</td>
</tr>
} @empty {
<tr>
<td colspan="5" class="bp-empty">No account matches the search.</td>
</tr>
}
</tbody>
<tfoot>
<tr>
<td colspan="4">{{ agentSearch ? 'Total of the accounts shown' : 'Total' }}</td>
<td class="bp-num bp-total">{{ money(popupTotal) }}</td>
</tr>
</tfoot>
</table>
</div>
}
</div>
<div class="bpd-actions">
<button mat-stroked-button type="button" mat-dialog-close>Close</button>
<span class="bp-action-bar__spacer"></span>
<button mat-flat-button color="primary" type="button" (click)="exportAgent(group)">
<i class="fas fa-file-export"></i> Export this agent
</button>
</div>
</div>
}
</ng-template>
</div>
@@ -0,0 +1,36 @@
/**
* 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 batch screen's styles are loaded with this one; only what is particular to the run page is here.
.bprv-back {
flex: none;
color: #0f766e;
}
.bprv-banner {
margin: 0 0 12px;
}
// The page itself should not scroll: the table takes what is left of the window and scrolls inside.
.bprv-card {
display: flex;
flex-direction: column;
margin-bottom: 0;
.bp-table-scroll {
max-height: calc(100vh - 430px);
min-height: 200px;
overflow: auto;
th {
position: sticky;
top: 0;
z-index: 1;
}
}
}
@@ -0,0 +1,401 @@
/**
* Copyright since 2025 Mifos Initiative
*
* This Source Code Form is subject to the terms of the Mozilla Public
* License, v. 2.0. If a copy of the MPL was not distributed with this
* file, You can obtain one at http://mozilla.org/MPL/2.0/.
*/
/** Angular Imports */
import { Component, OnInit, TemplateRef, ViewChild, inject } from '@angular/core';
import { ActivatedRoute, Router } from '@angular/router';
import { MatDialog, MatDialogClose } from '@angular/material/dialog';
import { MatTooltip } from '@angular/material/tooltip';
import { STANDALONE_SHARED_IMPORTS } from 'app/standalone-shared.module';
import { ConfirmationDialogComponent } from 'app/shared/confirmation-dialog/confirmation-dialog.component';
/** 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';
import {
AgentGroup,
csvAmount,
dateText,
formatMoney,
groupByAgent,
isCommissionJobType,
isPostingJobType,
jobLabel
} from 'app/organization/dds-batch-processing/dds-batch.shared';
/**
* One generated DDS batch run, on a page of its own: the figures Generate worked out, agent by agent,
* with Post and Discard beside them.
*
* A run is a preview until it is posted - generating applied nothing - so this page is where it is
* checked, applied or thrown away. A run already posted, running or failed opens here too, read-only,
* so any run on the list can be looked into line by line.
*/
@Component({
selector: 'mifosx-dds-batch-run-preview',
templateUrl: './dds-batch-run-preview.component.html',
// The screen's own styles are reused as they are: the tables, status badges and agent popup look the
// same on both pages, and a second copy of them would drift.
styleUrls: [
'../dds-batch-processing/dds-batch-processing.component.scss',
'./dds-batch-run-preview.component.scss'
],
imports: [
...STANDALONE_SHARED_IMPORTS,
MatTooltip,
MatDialogClose
]
})
export class DdsBatchRunPreviewComponent implements OnInit {
private route = inject(ActivatedRoute);
private router = inject(Router);
private organizationService = inject(OrganizationService);
private alertService = inject(AlertService);
private dialog = inject(MatDialog);
@ViewChild('agentDialog') agentDialog: TemplateRef<unknown>;
/** The run on show, and its lines. Named as on the batch screen so the agent popup reads the same. */
generatedRun: any = null;
previewRows: any[] = [];
loading = true;
notFound = false;
working = false;
selectedGroup: AgentGroup | null = null;
agentSearch = '';
private groupsFor: any[] | null = null;
private groups: AgentGroup[] = [];
ngOnInit(): void {
const runId = this.route.snapshot.paramMap.get('runId');
this.organizationService.getDdsBatchRun(runId).subscribe({
next: (run: any) => {
this.generatedRun = run;
this.previewRows = run?.rows || [];
this.loading = false;
},
error: () => {
this.loading = false;
this.notFound = true;
}
});
}
get jobType(): string {
return this.generatedRun?.jobType ?? '';
}
get jobTypeLabel(): string {
return jobLabel(this.jobType);
}
get isCommissionJob(): boolean {
return isCommissionJobType(this.jobType);
}
get isPostingJob(): boolean {
return isPostingJobType(this.jobType);
}
/** Only a commission calculation has a breakdown worth showing: TDS and deductions come off its figure. */
get showsCommissionBreakdown(): boolean {
return this.jobType === 'COMMISSION_CALCULATION';
}
/** Only a run that is still a preview can be posted or thrown away. */
get canAct(): boolean {
return this.generatedRun?.status === 'CALCULATED';
}
get statusText(): string {
switch (this.generatedRun?.status) {
case 'POSTED':
return 'Posted';
case 'POSTING':
return 'Running';
case 'FAILED':
return 'Failed';
default:
return 'Not posted';
}
}
get previewTotal(): number {
return this.previewRows.reduce((total, row) => total + Number(row.amount || 0), 0);
}
money(value: any): string {
return formatMoney(value);
}
labelOf(code: string): string {
return jobLabel(code);
}
/** The preview as agents with their accounts under them, built once per load. */
get agentGroups(): AgentGroup[] {
if (this.groupsFor !== this.previewRows) {
this.groups = groupByAgent(this.previewRows);
this.groupsFor = this.previewRows;
}
return this.groups;
}
/** The popup's accounts, narrowed by its search box (party code or name). */
get popupRows(): any[] {
const rows = this.selectedGroup?.rows ?? [];
const term = this.agentSearch.trim().toLowerCase();
if (!term) {
return rows;
}
return rows.filter((row) => `${row.partyCode ?? ''} ${row.partyName ?? ''}`.toLowerCase().includes(term));
}
get popupTotal(): number {
return this.popupRows.reduce((total, row) => total + Number(row.amount || 0), 0);
}
viewAgent(group: AgentGroup): void {
this.selectedGroup = group;
this.agentSearch = '';
this.dialog.open(this.agentDialog, { width: '860px', maxWidth: '95vw', autoFocus: false });
}
back(): void {
this.router.navigate(['/organization/dds-batch-processing']);
}
/** Every line of the run, agent by agent, with subtotals and the run's total. */
exportPreview(): void {
if (this.previewRows.length === 0) {
return;
}
this.downloadCsv(this.agentGroups, true);
}
/** The one agent open in the popup, with the accounts the search is showing. */
exportAgent(group: AgentGroup): void {
this.downloadCsv([{ ...group, rows: this.popupRows, total: this.popupTotal }], false);
}
private downloadCsv(groups: AgentGroup[], withRunTotal: boolean): void {
const run = this.generatedRun;
const quote = (value: any) => `"${String(value ?? '').replace(/"/g, '""')}"`;
const row = (cells: any[]) => cells.map(quote).join(',');
const lines: string[] = [];
const runTotal = csvAmount(groups.reduce((t, g) => t + g.total, 0));
if (this.isCommissionJob) {
lines.push(
row([
'Agent Code',
'Agent Name',
'From',
'To',
'Collection',
'Rate %',
'Commission',
'TDS',
'Deductions',
'Payable'
])
);
for (const group of groups) {
for (const each of group.rows) {
lines.push(
row([
group.agentCode,
group.agentName,
dateText(each.fromDate),
dateText(each.toDate),
csvAmount(each.baseAmount),
each.ratePercent ?? '',
csvAmount(each.grossAmount),
csvAmount(each.tdsAmount),
csvAmount(each.deductionAmount),
csvAmount(each.amount)
])
);
}
}
if (withRunTotal) {
lines.push(
row([
'Total',
'',
'',
'',
'',
'',
'',
'',
'',
runTotal
])
);
}
} else {
lines.push(
row([
'Agent Code',
'Agent Name',
'Party Code',
'Party Name',
'From',
'To',
'Rate %',
'Amount'
])
);
for (const group of groups) {
for (const each of group.rows) {
lines.push(
row([
group.agentCode,
group.agentName,
each.partyCode,
each.partyName,
dateText(each.fromDate),
dateText(each.toDate),
each.ratePercent ?? '',
csvAmount(each.amount)
])
);
}
lines.push(
row([
group.agentCode,
group.agentName,
'',
`Agent total (${group.rows.length} accounts)`,
'',
'',
'',
csvAmount(group.total)
])
);
}
if (withRunTotal) {
lines.push(
row([
'Run total',
'',
'',
`${groups.length} agents, ${groups.reduce((t, g) => t + g.rows.length, 0)} accounts`,
'',
'',
'',
runTotal
])
);
}
}
// A byte-order mark so Excel opens the rupee sign and any non-English names correctly.
const blob = new Blob(['' + lines.join('\r\n')], { type: 'text/csv;charset=utf-8' });
const name = withRunTotal
? `${this.jobTypeLabel}-run-${run.id}`
: `${this.jobTypeLabel}-run-${run.id}-${groups[0]?.agentCode ?? 'agent'}`;
const link = document.createElement('a');
link.href = URL.createObjectURL(blob);
link.download = `${name.replace(/\s+/g, '-').toLowerCase()}.csv`;
link.click();
URL.revokeObjectURL(link.href);
}
post(): void {
if (!this.canAct || this.previewRows.length === 0) {
return;
}
const count = this.previewRows.length;
const total = this.money(this.previewTotal);
// Post is the step that moves money, and it cannot be undone from here - so it names exactly what
// is about to change rather than asking a generic "are you sure".
this.dialog
.open(ConfirmationDialogComponent, {
data: {
type: 'warn',
heading: `Post ${this.jobTypeLabel}?`,
dialogContext:
`${count} ${count === 1 ? 'entry' : 'entries'} totalling ${total} will be applied. ` +
(this.isPostingJob
? 'This credits the customers or pays the agents, and appears on the ledger. '
: 'This records what has been earned against each account. ') +
'It cannot be undone from this screen.'
}
})
.afterClosed()
.subscribe((response: any) => {
if (response?.confirm) {
this.applyRun(count, total);
}
});
}
/**
* Applies the run, and says what happened rather than assuming it finished.
*
* A crediting run takes as long as a savings deposit on every account it touches, so the server does
* it in the background and answers at once. It is reported as running, not as posted - the batch
* screen the clerk returns to follows it until the server marks it posted or failed.
*/
private applyRun(count: number, total: string): void {
const label = this.jobTypeLabel;
const crediting = this.isPostingJob;
this.working = true;
this.organizationService.postDdsBatchRun(this.generatedRun.id).subscribe({
error: () => (this.working = false),
next: (response: any) => {
this.working = false;
if (heldForApproval(response)) {
announceHeldForApproval(
response,
`${label} (${count} ${count === 1 ? 'entry' : 'entries'}, ${total})`,
this.organizationService,
this.alertService
);
} else if (crediting) {
this.alertService.alert({
type: 'Posting',
message:
`${label} is running — ${count} ${count === 1 ? 'entry' : 'entries'}, ${total}. ` +
'It continues in the background; the batch screen updates when it is done.'
});
} else {
this.alertService.alert({
type: 'Success',
message: `${label} posted — ${count} ${count === 1 ? 'entry' : 'entries'}, ${total}.`
});
}
this.back();
}
});
}
/** Throws the preview away - off the server, so it does not linger as "Not posted". */
discard(): void {
if (!this.canAct) {
return;
}
this.working = true;
this.organizationService.discardDdsBatchRun(this.generatedRun.id).subscribe({
error: () => (this.working = false),
next: () => {
this.working = false;
this.alertService.alert({ type: 'Information', message: `${this.jobTypeLabel} preview discarded.` });
this.back();
}
});
}
}
@@ -19,8 +19,9 @@ import { LoanProvisioningCriteriaComponent } from './loan-provisioning-criteria/
import { OfficesComponent } from './offices/offices.component'; import { OfficesComponent } from './offices/offices.component';
import { AgentMasterComponent } from './agent-master/agent-master.component'; import { AgentMasterComponent } from './agent-master/agent-master.component';
import { CreateAgentComponent } from './agent-master/create-agent/create-agent.component'; import { CreateAgentComponent } from './agent-master/create-agent/create-agent.component';
import { CommissionSlabMasterComponent } from './commission-slab-master/commission-slab-master.component'; import { ViewAgentComponent } from './agent-master/view-agent/view-agent.component';
import { DdsBatchProcessingComponent } from './dds-batch-processing/dds-batch-processing.component'; import { DdsBatchProcessingComponent } from './dds-batch-processing/dds-batch-processing.component';
import { DdsBatchRunPreviewComponent } from './dds-batch-run-preview/dds-batch-run-preview.component';
import { EmployeesComponent } from './employees/employees.component'; import { EmployeesComponent } from './employees/employees.component';
import { CreateEmployeeComponent } from './employees/create-employee/create-employee.component'; import { CreateEmployeeComponent } from './employees/create-employee/create-employee.component';
import { ViewEmployeeComponent } from './employees/view-employee/view-employee.component'; import { ViewEmployeeComponent } from './employees/view-employee/view-employee.component';
@@ -254,6 +255,11 @@ const routes: Routes = [
component: CreateAgentComponent, component: CreateAgentComponent,
data: { title: 'Create Agent', breadcrumb: 'Create' } data: { title: 'Create Agent', breadcrumb: 'Create' }
}, },
{
path: ':agentId',
component: ViewAgentComponent,
data: { title: 'View Agent', breadcrumb: 'View', routeParamBreadcrumb: false }
},
{ {
path: ':agentId/edit', path: ':agentId/edit',
component: CreateAgentComponent, component: CreateAgentComponent,
@@ -262,14 +268,20 @@ const routes: Routes = [
] ]
}, },
{ {
path: 'commission-slab-master', path: 'dds-batch-processing',
component: CommissionSlabMasterComponent, data: { title: 'DDS Interest & Commission Batch', breadcrumb: 'DDS Batch Processing' },
data: { title: 'Commission Slab Master', breadcrumb: 'Commission Slab Master' } children: [
{
path: '',
component: DdsBatchProcessingComponent
}, },
{ {
path: 'dds-batch-processing', // One generated run: its figures agent by agent, with Post and Discard while it is a preview.
component: DdsBatchProcessingComponent, path: 'run/:runId',
data: { title: 'DDS Interest & Commission Batch', breadcrumb: 'DDS Batch Processing' } component: DdsBatchRunPreviewComponent,
data: { title: 'DDS Batch Run', breadcrumb: 'Run', routeParamBreadcrumb: false }
}
]
}, },
{ {
// Bank's own profile — name, legal name, code, registration, address/contact. A // Bank's own profile — name, legal name, code, registration, address/contact. A
@@ -225,19 +225,6 @@
</a> </a>
</div> </div>
<div #commissionSlabMaster>
<a class="org-card" [routerLink]="['commission-slab-master']">
<div class="org-card__icon org-card__icon--teal">
<i class="fas fa-percentage"></i>
</div>
<div class="org-card__body">
<h3 class="org-card__title">Commission Slab Master</h3>
<p class="org-card__desc">Define collection-volume and override commission percentage slabs</p>
</div>
<i class="fas fa-chevron-right org-card__arrow"></i>
</a>
</div>
<div #ddsBatchProcessing> <div #ddsBatchProcessing>
<a class="org-card" [routerLink]="['dds-batch-processing']"> <a class="org-card" [routerLink]="['dds-batch-processing']">
<div class="org-card__icon org-card__icon--teal"> <div class="org-card__icon org-card__icon--teal">
@@ -272,6 +272,11 @@ export class OrganizationService {
return this.http.get('/dds/parties'); return this.http.get('/dds/parties');
} }
/** The code the next DDS party is expected to get - { partyCode: 'PT0005' } - to show before saving. */
getNextPartyCode(): Observable<any> {
return this.http.get('/dds/parties/nextcode');
}
/** /**
* The parties a given agent collects for — what the daily transaction screen works from, since * The parties a given agent collects for — what the daily transaction screen works from, since
* it handles one agent's round at a time. * it handles one agent's round at a time.
@@ -536,6 +541,11 @@ export class OrganizationService {
return this.http.get('/dds/batchruns'); return this.http.get('/dds/batchruns');
} }
/** Deletes a run that was only previewed (generated, never posted). */
discardDdsBatchRun(batchRunId: string | number): Observable<any> {
return this.http.delete(`/dds/batchruns/${batchRunId}`);
}
/** /**
* Ends the relationship with an agent. Refused while they still have accounts assigned, hold * Ends the relationship with an agent. Refused while they still have accounts assigned, hold
* collected cash, or are owed commission — distinct from deactivating, which is never refused. * collected cash, or are owed commission — distinct from deactivating, which is never refused.
@@ -0,0 +1,311 @@
<!--
Copyright since 2025 Mifos Initiative
This Source Code Form is subject to the terms of the Mozilla Public
License, v. 2.0. If a copy of the MPL was not distributed with this
file, You can obtain one at http://mozilla.org/MPL/2.0/.
-->
<div class="csm-container">
<div class="csm-header">
<button mat-stroked-button [routerLink]="['../']" class="csm-back">
<i class="fas fa-arrow-left"></i> {{ 'labels.heading.Products' | translate }}
</button>
<div class="csm-header__ic"><i class="fas fa-percent"></i></div>
<div>
<h2 class="csm-header__title">Commission Slab Master</h2>
<p class="csm-header__sub">The DDS agent commission chart - a percentage on each agent's collection volume</p>
</div>
<span class="csm-header__spacer"></span>
<button mat-flat-button color="primary" type="button" class="csm-calc-btn" (click)="openCalculator()">
<i class="fas fa-calculator"></i> Commission Calculator
</button>
</div>
<!-- How the chart is read, so nobody has to guess what a slab does. -->
<div class="csm-howto">
<div class="csm-howto__step">
<span class="csm-howto__no">1</span>
<span>The commission run adds up each agent's collections not yet counted.</span>
</div>
<div class="csm-howto__step">
<span class="csm-howto__no">2</span>
<span>It finds the <b>Account</b> slab where <b>From ≤ collection ≤ To</b>.</span>
</div>
<div class="csm-howto__step">
<span class="csm-howto__no">3</span>
<span>Commission = collection × slab % . TDS and the agent's deductions come off that.</span>
</div>
<div class="csm-howto__step csm-howto__step--warn">
<span class="csm-howto__no">!</span>
<span
>A collection with no slab (a gap, below the first or above the last) stops the run. Start each slab
<b>0.01</b> after the last one ends.</span
>
</div>
</div>
<div class="csm-card">
<div class="csm-card__head">
<h3>Slabs</h3>
@if (loading) {
<span class="csm-card__count csm-card__count--loading"
><mat-spinner diameter="14" class="csm-spin"></mat-spinner> Loading slabs…</span
>
} @else {
<span class="csm-card__count">{{ slabs.length }} {{ slabs.length === 1 ? 'slab' : 'slabs' }}</span>
}
</div>
<table class="csm-table">
<thead>
<tr>
<th>Slab Type</th>
<th class="num">Slab From (₹)</th>
<th class="num">Slab To (₹)</th>
<th class="num">Comm %</th>
<th class="num">Commission at "To"</th>
<th></th>
</tr>
</thead>
<tbody>
@if (loading) {
<!-- Placeholder rows while the chart is fetched, so nobody reads an empty chart as "no slabs". -->
@for (row of skeletonRows; track row) {
<tr class="csm-skeleton">
<td><span class="csm-skeleton__bar csm-skeleton__bar--wide"></span></td>
<td class="num"><span class="csm-skeleton__bar"></span></td>
<td class="num"><span class="csm-skeleton__bar"></span></td>
<td class="num"><span class="csm-skeleton__bar csm-skeleton__bar--short"></span></td>
<td class="num"><span class="csm-skeleton__bar"></span></td>
<td></td>
</tr>
}
}
@for (slab of slabs; track slab; let i = $index) {
@if (editingIndex === i) {
<!-- The same row turned into inputs, so a correction is made where the value is read. -->
<tr [formGroup]="editSlabForm" class="csm-new-row">
<td>
<mat-form-field appearance="outline" class="csm-inline-field" subscriptSizing="dynamic">
<mat-select formControlName="slabType">
@for (type of slabTypes; track type) {
<mat-option [value]="type">{{ typeInfo(type).title }}</mat-option>
}
</mat-select>
</mat-form-field>
</td>
<td>
<mat-form-field appearance="outline" class="csm-inline-field" subscriptSizing="dynamic">
<input matInput type="number" step="0.01" formControlName="slabFrom" />
</mat-form-field>
</td>
<td>
<mat-form-field appearance="outline" class="csm-inline-field" subscriptSizing="dynamic">
<input matInput type="number" step="0.01" formControlName="slabTo" />
</mat-form-field>
</td>
<td>
<mat-form-field appearance="outline" class="csm-inline-field" subscriptSizing="dynamic">
<input matInput type="number" step="0.01" formControlName="commissionPercent" />
<span matTextSuffix>%</span>
</mat-form-field>
</td>
<td></td>
<td class="csm-row-actions">
<button mat-icon-button color="primary" type="button" (click)="saveEdit()" matTooltip="Save correction">
<i class="fas fa-check"></i>
</button>
<button mat-icon-button type="button" (click)="cancelEdit()" matTooltip="Cancel">
<i class="fas fa-times"></i>
</button>
</td>
</tr>
} @else {
<tr [class.csm-row--unsaved]="!slab.id" [class.csm-row--unused]="!typeInfo(slab.slabType).used">
<td>
<span class="csm-type" [class.csm-type--unused]="!typeInfo(slab.slabType).used">{{
slab.slabType
}}</span>
@if (!typeInfo(slab.slabType).used) {
<span class="csm-tag csm-tag--warn" [matTooltip]="typeInfo(slab.slabType).hint">Not applied</span>
}
@if (!slab.id) {
<span class="csm-tag">Unsaved</span>
}
</td>
<td class="num">{{ slab.slabFrom | number: '1.2-2' }}</td>
<td class="num">{{ slab.slabTo | number: '1.2-2' }}</td>
<td class="num">
<span class="csm-rate">{{ slab.commissionPercent | number: '1.0-4' }}%</span>
</td>
<td class="num csm-muted">₹ {{ commissionAtTop(slab) | number: '1.2-2' }}</td>
<td class="csm-row-actions">
@if (slab.id) {
<!--
A saved slab is corrected, never removed. Deleting a range would leave a hole
that pays nothing to any agent whose volume lands in it, with nothing on screen
to say why - and there is deliberately no delete endpoint behind it.
-->
<button mat-icon-button color="primary" type="button" (click)="startEdit(i)" matTooltip="Edit slab">
<i class="fas fa-pen"></i>
</button>
} @else {
<!-- Not saved yet, so taking it back off the table costs nothing. -->
<button mat-icon-button type="button" (click)="removeSlab(i)" matTooltip="Remove before saving">
<i class="fas fa-times"></i>
</button>
}
</td>
</tr>
}
}
<tr [formGroup]="newSlabForm" class="csm-new-row" [hidden]="loading || loadFailed">
<td>
<mat-form-field appearance="outline" class="csm-inline-field" subscriptSizing="dynamic">
<mat-select required formControlName="slabType" placeholder="Slab Type">
@for (type of slabTypes; track type) {
<mat-option [value]="type">{{ typeInfo(type).title }}</mat-option>
}
</mat-select>
</mat-form-field>
</td>
<td>
<mat-form-field appearance="outline" class="csm-inline-field" subscriptSizing="dynamic">
<input matInput required type="number" step="0.01" formControlName="slabFrom" placeholder="From" />
</mat-form-field>
</td>
<td>
<mat-form-field appearance="outline" class="csm-inline-field" subscriptSizing="dynamic">
<input matInput required type="number" step="0.01" formControlName="slabTo" placeholder="To" />
</mat-form-field>
</td>
<td>
<mat-form-field appearance="outline" class="csm-inline-field" subscriptSizing="dynamic">
<input matInput required type="number" step="0.01" formControlName="commissionPercent" />
<span matTextSuffix>%</span>
</mat-form-field>
</td>
<td class="csm-muted csm-hint">
{{ newSlabForm.get('slabType')?.value ? typeInfo(newSlabForm.get('slabType')?.value).hint : 'Pick a type' }}
</td>
<td class="csm-row-actions">
<!--
Deliberately not disabled while the row is incomplete. A greyed-out button says the
screen will not accept this, but never which field it is waiting for; letting the
click through means addSlab() can say so.
-->
<button mat-icon-button color="primary" type="button" (click)="addSlab()" matTooltip="Add slab">
<i class="fas fa-plus"></i>
</button>
</td>
</tr>
</tbody>
</table>
@if (loadFailed) {
<p class="csm-error">
<i class="fas fa-exclamation-triangle"></i> The commission slabs could not be loaded.
<button mat-stroked-button type="button" (click)="reload()">Try again</button>
</p>
} @else if (!loading && slabs.length === 0) {
<p class="csm-empty">No commission slabs defined yet — add one using the row above.</p>
}
@if (!loading && coverage.length > 0) {
<div class="csm-coverage">
@for (entry of coverage; track entry.slabType) {
<div class="csm-coverage__row">
<span class="csm-type" [class.csm-type--unused]="!typeInfo(entry.slabType).used">{{ entry.slabType }}</span>
<span class="csm-coverage__range">
covers ₹ {{ entry.from | number: '1.2-2' }} to ₹ {{ entry.to | number: '1.2-2' }}
</span>
@if (typeInfo(entry.slabType).used && entry.from > 0) {
<span class="csm-coverage__gap">
<i class="fas fa-exclamation-triangle"></i> below ₹ {{ entry.from | number: '1.2-2' }} has no slab
</span>
}
@if (entry.gaps.length > 0) {
<span class="csm-coverage__gap">
<i class="fas fa-exclamation-triangle"></i> no slab for {{ entry.gaps.join(', ') }} — the commission run
stops for an agent landing there
</span>
}
@if (!typeInfo(entry.slabType).used) {
<span class="csm-coverage__note">{{ typeInfo(entry.slabType).hint }}</span>
}
</div>
}
</div>
}
<div class="csm-action-bar">
<button mat-raised-button type="button" (click)="exit()">Exit</button>
<button mat-raised-button color="primary" type="button" (click)="save()">Save</button>
</div>
</div>
<!-- Try an amount against the saved chart, in a popup opened from the header. -->
<ng-template #calculatorDialog>
<div class="csm-calc" [formGroup]="calcForm">
<div class="csm-calc__head">
<div>
<h3><i class="fas fa-calculator"></i> Commission Calculator</h3>
<span class="csm-muted">Uses the saved Account slabs - the same arithmetic as the commission run</span>
</div>
<button type="button" class="csm-calc__close" mat-dialog-close aria-label="Close">
<i class="fas fa-times"></i>
</button>
</div>
<div class="csm-calc__inputs">
<mat-form-field appearance="outline" subscriptSizing="dynamic">
<mat-label>Agent's collection (₹)</mat-label>
<input matInput type="number" step="0.01" formControlName="volume" />
</mat-form-field>
<mat-form-field appearance="outline" subscriptSizing="dynamic">
<mat-label>TDS %</mat-label>
<input matInput type="number" step="0.01" formControlName="tdsPercent" />
</mat-form-field>
<mat-form-field appearance="outline" subscriptSizing="dynamic">
<mat-label>Deductions (₹)</mat-label>
<input matInput type="number" step="0.01" formControlName="deductions" />
</mat-form-field>
</div>
@if (calculation; as c) {
@if (c.slab) {
<div class="csm-calc__result">
<div class="csm-calc__cell">
<span>Slab</span>
<strong>₹ {{ c.slab.slabFrom | number: '1.2-2' }} – {{ c.slab.slabTo | number: '1.2-2' }}</strong>
</div>
<div class="csm-calc__cell">
<span>Rate</span><strong>{{ c.rate | number: '1.0-4' }}%</strong>
</div>
<div class="csm-calc__cell">
<span>Gross</span><strong>₹ {{ c.gross | number: '1.2-2' }}</strong>
</div>
<div class="csm-calc__cell">
<span>– TDS</span><strong>₹ {{ c.tds | number: '1.2-2' }}</strong>
</div>
<div class="csm-calc__cell">
<span>– Deductions</span><strong>₹ {{ c.deductions | number: '1.2-2' }}</strong>
</div>
<div class="csm-calc__cell csm-calc__cell--net">
<span>Net to agent</span><strong>₹ {{ (c.net > 0 ? c.net : 0) | number: '1.2-2' }}</strong>
</div>
</div>
@if (c.net <= 0) {
<p class="csm-calc__note">The deductions swallow this commission - the run pays this agent nothing.</p>
}
} @else {
<p class="csm-calc__none">
<i class="fas fa-exclamation-triangle"></i> No saved <b>Account</b> slab covers this amount — the commission
run would stop with "no commission slab" for this agent.
</p>
}
}
</div>
</ng-template>
</div>
@@ -0,0 +1,493 @@
/**
* Copyright since 2025 Mifos Initiative
*
* This Source Code Form is subject to the terms of the Mozilla Public
* License, v. 2.0. If a copy of the MPL was not distributed with this
* file, You can obtain one at http://mozilla.org/MPL/2.0/.
*/
$primary: #0f766e;
$primary-dark: #134e4a;
$primary-soft: #e6f4ee;
$border: #e2e8f0;
$text: #0f172a;
$text-mid: #475569;
$text-soft: #94a3b8;
$warn: #b45309;
$warn-soft: #fffbeb;
.csm-container {
max-width: 1080px;
margin: 0 auto;
padding: 24px 16px 40px;
color: $text;
}
.csm-header {
display: flex;
align-items: center;
gap: 14px;
margin-bottom: 16px;
&__ic {
display: grid;
place-items: center;
flex: none;
width: 44px;
height: 44px;
border-radius: 12px;
background: linear-gradient(135deg, $primary, $primary-dark);
color: #fff;
}
&__title {
margin: 0;
font-size: 21px;
font-weight: 600;
}
&__sub {
margin: 2px 0 0;
font-size: 13px;
color: $text-mid;
}
&__spacer {
flex: 1;
}
}
.csm-back {
color: $primary;
}
// ── How the chart is read ─────────────────────────────────────────────────────────────────────────────
.csm-howto {
display: grid;
grid-template-columns: repeat(4, minmax(0, 1fr));
gap: 10px;
margin-bottom: 16px;
@media (width <= 900px) {
grid-template-columns: 1fr 1fr;
}
&__step {
display: flex;
align-items: flex-start;
gap: 10px;
padding: 12px;
border: 1px solid $border;
border-radius: 10px;
background: #fff;
font-size: 12.5px;
line-height: 1.45;
color: $text-mid;
&--warn {
border-color: #fde68a;
background: $warn-soft;
color: $warn;
}
}
&__no {
display: grid;
place-items: center;
flex: none;
width: 22px;
height: 22px;
border-radius: 50%;
background: $primary-soft;
color: $primary;
font-size: 12px;
font-weight: 700;
}
&__step--warn &__no {
background: #fef3c7;
color: $warn;
}
}
// ── Cards ─────────────────────────────────────────────────────────────────────────────────────────────
.csm-card {
padding: 16px 20px 20px;
border: 1px solid $border;
border-radius: 12px;
background: var(--md-sys-color-surface, #fff);
box-shadow: 0 1px 3px rgb(0 0 0 / 5%);
& + & {
margin-top: 16px;
}
&__head {
display: flex;
align-items: baseline;
justify-content: space-between;
gap: 12px;
margin-bottom: 10px;
h3 {
margin: 0;
font-size: 13px;
font-weight: 700;
letter-spacing: 0.06em;
text-transform: uppercase;
color: $primary-dark;
i {
margin-right: 6px;
}
}
}
&__count {
padding: 2px 10px;
border-radius: 999px;
background: $primary-soft;
color: $primary;
font-size: 12px;
font-weight: 600;
}
}
// ── Slab table ────────────────────────────────────────────────────────────────────────────────────────
.csm-table {
width: 100%;
border-collapse: collapse;
table-layout: fixed;
th:nth-child(1),
td:nth-child(1) {
width: 30%;
}
th:nth-child(2),
td:nth-child(2),
th:nth-child(3),
td:nth-child(3) {
width: 17%;
}
th:nth-child(4),
td:nth-child(4) {
width: 12%;
}
th:nth-child(5),
td:nth-child(5) {
width: 16%;
}
th:nth-child(6),
td:nth-child(6) {
width: 8%;
}
th {
padding: 9px 10px;
border-bottom: 1px solid $border;
background: #f8fafc;
font-size: 11px;
font-weight: 700;
letter-spacing: 0.06em;
text-align: left;
text-transform: uppercase;
color: $text-soft;
}
td {
padding: 8px 10px;
border-bottom: 1px solid #eef2f6;
vertical-align: middle;
font-size: 13.5px;
}
.num {
text-align: right;
font-variant-numeric: tabular-nums;
}
}
// Loading: placeholder rows with a soft shimmer, and a spinner in the count badge.
.csm-skeleton td {
padding: 13px 10px;
}
.csm-skeleton__bar {
display: inline-block;
width: 70%;
height: 12px;
border-radius: 6px;
background: linear-gradient(90deg, #eef2f6 25%, #e2e8f0 37%, #eef2f6 63%);
background-size: 400% 100%;
animation: csm-shimmer 1.2s ease-in-out infinite;
&--wide {
width: 55%;
}
&--short {
width: 40%;
}
}
@keyframes csm-shimmer {
0% {
background-position: 100% 50%;
}
100% {
background-position: 0 50%;
}
}
.csm-card__count--loading {
display: inline-flex;
align-items: center;
gap: 6px;
}
.csm-error {
display: flex;
align-items: center;
gap: 10px;
margin: 12px 0 0;
padding: 10px 12px;
border-radius: 8px;
background: #fef2f2;
color: #b91c1c;
font-size: 13px;
}
.csm-row--unsaved {
background: #f0fdf9;
}
.csm-row--unused td {
color: $text-soft;
}
.csm-type {
font-weight: 600;
color: $primary-dark;
&--unused {
color: $text-soft;
}
}
.csm-tag {
margin-left: 6px;
padding: 1px 8px;
border-radius: 999px;
background: #e0e7ff;
color: #3730a3;
font-size: 10.5px;
font-weight: 600;
letter-spacing: 0.04em;
text-transform: uppercase;
&--warn {
background: #fef3c7;
color: $warn;
cursor: help;
}
}
.csm-rate {
padding: 2px 8px;
border-radius: 6px;
background: $primary-soft;
color: $primary;
font-weight: 700;
}
.csm-muted {
color: $text-mid;
}
.csm-hint {
font-size: 11.5px;
line-height: 1.35;
}
.csm-inline-field {
width: 100%;
min-width: 0;
}
.csm-row-actions {
text-align: center;
}
.csm-new-row {
background: rgb(15 118 110 / 4%);
}
.csm-empty {
margin: 12px 0 0;
font-size: 14px;
color: $text-mid;
}
.csm-action-bar {
display: flex;
justify-content: flex-end;
gap: 12px;
margin-top: 18px;
padding-top: 14px;
border-top: 1px solid $border;
}
// What the slabs cover, and any figure none of them claims.
.csm-coverage {
display: flex;
flex-direction: column;
gap: 6px;
margin-top: 14px;
padding: 12px 16px;
border: 1px solid $border;
border-radius: 8px;
background: #f8fafc;
&__row {
display: flex;
flex-wrap: wrap;
align-items: baseline;
gap: 10px;
font-size: 0.86rem;
}
&__range {
color: $text-mid;
font-variant-numeric: tabular-nums;
}
&__gap {
color: $warn;
font-variant-numeric: tabular-nums;
i {
margin-right: 4px;
}
}
&__note {
font-size: 12px;
color: $text-soft;
}
}
// ── Calculator (popup) ────────────────────────────────────────────────────────────────────────────────
.csm-calc-btn i {
margin-right: 6px;
}
.csm-calc {
padding: 18px 20px 20px;
color: $text;
&__head {
display: flex;
align-items: flex-start;
justify-content: space-between;
gap: 12px;
margin-bottom: 14px;
h3 {
margin: 0 0 2px;
font-size: 14px;
font-weight: 700;
letter-spacing: 0.04em;
text-transform: uppercase;
color: $primary-dark;
i {
margin-right: 6px;
}
}
.csm-muted {
font-size: 12px;
}
}
&__close {
border: 0;
background: transparent;
color: $text-mid;
font-size: 16px;
cursor: pointer;
}
&__inputs {
display: grid;
grid-template-columns: 2fr 1fr 1fr;
gap: 12px;
@media (width <= 720px) {
grid-template-columns: 1fr;
}
}
&__result {
display: grid;
grid-template-columns: repeat(6, minmax(0, 1fr));
gap: 8px;
margin-top: 14px;
@media (width <= 900px) {
grid-template-columns: repeat(3, minmax(0, 1fr));
}
}
&__cell {
display: flex;
flex-direction: column;
gap: 3px;
padding: 10px 12px;
border: 1px solid $border;
border-radius: 8px;
background: #f8fafc;
span {
font-size: 11px;
font-weight: 600;
letter-spacing: 0.04em;
text-transform: uppercase;
color: $text-soft;
}
strong {
font-size: 14px;
font-variant-numeric: tabular-nums;
}
&--net {
border-color: #a7f3d0;
background: $primary-soft;
strong {
color: $primary-dark;
font-size: 16px;
}
}
}
&__none,
&__note {
margin: 12px 0 0;
padding: 10px 12px;
border-radius: 8px;
background: $warn-soft;
color: $warn;
font-size: 13px;
i {
margin-right: 6px;
}
}
}
@@ -7,7 +7,9 @@
*/ */
/** Angular Imports */ /** Angular Imports */
import { Component, OnInit, inject } from '@angular/core'; import { Component, OnInit, TemplateRef, ViewChild, inject } from '@angular/core';
import { MatDialog, MatDialogClose } from '@angular/material/dialog';
import { MatProgressSpinner } from '@angular/material/progress-spinner';
import { UntypedFormBuilder, UntypedFormGroup, Validators } from '@angular/forms'; import { UntypedFormBuilder, UntypedFormGroup, Validators } from '@angular/forms';
import { Router } from '@angular/router'; import { Router } from '@angular/router';
import { STANDALONE_SHARED_IMPORTS } from 'app/standalone-shared.module'; import { STANDALONE_SHARED_IMPORTS } from 'app/standalone-shared.module';
@@ -35,13 +37,25 @@ import { announceHeldForApproval, heldForApproval } from 'app/deposits/dds-appro
selector: 'mifosx-commission-slab-master', selector: 'mifosx-commission-slab-master',
templateUrl: './commission-slab-master.component.html', templateUrl: './commission-slab-master.component.html',
styleUrls: ['./commission-slab-master.component.scss'], styleUrls: ['./commission-slab-master.component.scss'],
imports: [...STANDALONE_SHARED_IMPORTS] imports: [
...STANDALONE_SHARED_IMPORTS,
MatDialogClose,
MatProgressSpinner
]
}) })
export class CommissionSlabMasterComponent implements OnInit { export class CommissionSlabMasterComponent implements OnInit {
private formBuilder = inject(UntypedFormBuilder); private formBuilder = inject(UntypedFormBuilder);
private router = inject(Router); private router = inject(Router);
private organizationService = inject(OrganizationService); private organizationService = inject(OrganizationService);
private alertService = inject(AlertService); private alertService = inject(AlertService);
private dialog = inject(MatDialog);
@ViewChild('calculatorDialog') calculatorDialog: TemplateRef<unknown>;
/** The calculator in a popup, so it is one click from the top of the page rather than a scroll away. */
openCalculator(): void {
this.dialog.open(this.calculatorDialog, { width: '820px', maxWidth: '95vw', autoFocus: 'first-tabbable' });
}
private static readonly SLAB_TYPE_CODES: Record<string, string> = { private static readonly SLAB_TYPE_CODES: Record<string, string> = {
Account: 'ACCOUNT', Account: 'ACCOUNT',
@@ -57,6 +71,65 @@ export class CommissionSlabMasterComponent implements OnInit {
'Commission' 'Commission'
]; ];
/**
* What each slab type is for, as the screen says it. Only ACCOUNT slabs are read by the commission
* run (BatchRunWritePlatformServiceJpaRepositoryImpl.generateCommissionRows): the agent's collection
* volume is matched to one, and its percentage is the agent's commission. COMMISSION is the legacy
* senior-agent override - saved, but nothing applies it, since there is no agent hierarchy yet.
*/
static readonly SLAB_TYPE_INFO: Record<string, { title: string; hint: string; used: boolean }> = {
Account: {
title: 'Account - collection volume',
hint: "Used by the commission run: the agent's collection is matched to the slab it falls in.",
used: true
},
Commission: {
title: 'Commission - senior-agent override',
hint: 'Saved but not applied by any run yet - there is no agent hierarchy to apply it to.',
used: false
}
};
typeInfo(type: string) {
return CommissionSlabMasterComponent.SLAB_TYPE_INFO[type] || { title: type, hint: '', used: false };
}
/** Try a collection amount against the chart: which slab, what rate, and the commission it pays. */
calcForm: UntypedFormGroup = this.formBuilder.group({
volume: [10000],
tdsPercent: [0],
deductions: [0]
});
/** The same arithmetic the commission run uses (DdsCommissionCalculation), rounded half-up to paise. */
get calculation(): {
slab: any;
rate: number;
gross: number;
tds: number;
deductions: number;
net: number;
} | null {
const volume = Number(this.calcForm.get('volume')?.value || 0);
if (!(volume > 0)) {
return null;
}
const slab = this.slabs.find(
(row) => row.id && row.slabType === 'Account' && volume >= Number(row.slabFrom) && volume <= Number(row.slabTo)
);
const rate = slab ? Number(slab.commissionPercent) : 0;
const round2 = (n: number) => Math.round((n + Number.EPSILON) * 100) / 100;
const gross = round2((volume * rate) / 100);
const tds = round2((gross * Number(this.calcForm.get('tdsPercent')?.value || 0)) / 100);
const deductions = Number(this.calcForm.get('deductions')?.value || 0);
return { slab: slab || null, rate, gross, tds, deductions, net: round2(gross - tds - deductions) };
}
/** The commission a slab pays at the top of its range - what the table shows beside each slab. */
commissionAtTop(slab: any): number {
return Math.round(Number(slab.slabTo) * Number(slab.commissionPercent)) / 100;
}
displayedColumns: string[] = [ displayedColumns: string[] = [
'slabType', 'slabType',
'slabFrom', 'slabFrom',
@@ -88,10 +161,39 @@ export class CommissionSlabMasterComponent implements OnInit {
ngOnInit(): void { ngOnInit(): void {
this.loadSlabs(); this.loadSlabs();
// Picking a type on the entry row starts the new slab a paisa above where that type's chart ends,
// so consecutive slabs meet with no gap a rupee-and-paise collection could fall into.
this.newSlabForm.get('slabType')?.valueChanges.subscribe((type: string) => {
const from = this.newSlabForm.get('slabFrom');
if (!type || !from || Number(from.value) !== 0) {
return;
}
const tops = this.slabs.filter((slab) => slab.slabType === type).map((slab) => Number(slab.slabTo));
if (tops.length > 0) {
from.setValue(Math.round((Math.max(...tops) + 0.01) * 100) / 100);
}
});
} }
/** True while the chart is being fetched - the table shows a loader rather than an empty chart. */
loading = true;
/** The fetch failed: said on screen, so an empty table is not read as "no slabs". */
loadFailed = false;
/** Placeholder rows shown while loading, so the table keeps its shape. */
readonly skeletonRows = [
1,
2,
3,
4,
5
];
private loadSlabs(): void { private loadSlabs(): void {
this.organizationService.getCommissionSlabs().subscribe((slabs: any[]) => { this.loading = true;
this.loadFailed = false;
this.organizationService.getCommissionSlabs().subscribe({
next: (slabs: any[]) => {
this.slabs = (slabs || []).map((slab) => ({ this.slabs = (slabs || []).map((slab) => ({
id: slab.id, id: slab.id,
slabType: CommissionSlabMasterComponent.SLAB_TYPE_LABELS[slab.slabType] || slab.slabType, slabType: CommissionSlabMasterComponent.SLAB_TYPE_LABELS[slab.slabType] || slab.slabType,
@@ -99,15 +201,25 @@ export class CommissionSlabMasterComponent implements OnInit {
slabTo: slab.slabTo, slabTo: slab.slabTo,
commissionPercent: slab.commissionPercent commissionPercent: slab.commissionPercent
})); }));
this.loading = false;
},
error: () => {
this.loading = false;
this.loadFailed = true;
}
}); });
} }
reload(): void {
this.loadSlabs();
}
/** /**
* Where each slab type's coverage begins and ends, and any figure in between that no slab claims. * Where each slab type's coverage begins and ends, and any figure in between that no slab claims.
* *
* A gap is not an error - a bank may well decide there is no commission below some floor - but it * A gap has to be visible: the commission run stops with "no commission slab" for an agent whose
* has to be visible. The commission run simply finds no slab for a volume that falls in a gap and * collection falls in a gap, or below the first slab or above the last, so the chart should run from
* moves on, so the agent is paid nothing for that month with nothing on screen to say why. * 0 to beyond the largest collection any agent makes, with each slab starting a paisa after the last.
*/ */
get coverage(): Array<{ slabType: string; from: number; to: number; gaps: string[] }> { get coverage(): Array<{ slabType: string; from: number; to: number; gaps: string[] }> {
return this.slabTypes return this.slabTypes
@@ -121,12 +233,16 @@ export class CommissionSlabMasterComponent implements OnInit {
return null; return null;
} }
// To the paisa: a collection is rupees and paise, and the run matches from <= volume <= to, so
// 1-10000 followed by 10001-20000 leaves 10000.01 to 10000.99 with no slab at all.
const paise = (n: number) => Math.round(n * 100);
const money = (p: number) => (p / 100).toFixed(2);
const gaps: string[] = []; const gaps: string[] = [];
for (let i = 1; i < rows.length; i++) { for (let i = 1; i < rows.length; i++) {
const previousEnd = rows[i - 1].to; const previousEnd = paise(rows[i - 1].to);
const nextStart = rows[i].from; const nextStart = paise(rows[i].from);
if (nextStart > previousEnd + 1) { if (nextStart > previousEnd + 1) {
gaps.push(`${previousEnd + 1} - ${nextStart - 1}`); gaps.push(`${money(previousEnd + 1)} - ${money(nextStart - 1)}`);
} }
} }
@@ -180,6 +296,13 @@ export class CommissionSlabMasterComponent implements OnInit {
return; return;
} }
// The same rules the server applies to the whole chart on Save, caught here on the row being added.
const chartError = this.chartErrorFor(candidate);
if (chartError) {
this.alertService.alert({ type: 'Error', message: chartError });
return;
}
this.slabs.push(candidate); this.slabs.push(candidate);
this.newSlabForm.reset({ this.newSlabForm.reset({
slabType: '', slabType: '',
@@ -189,6 +312,31 @@ export class CommissionSlabMasterComponent implements OnInit {
}); });
} }
/**
* Why a new slab would break its type's chart, or null. A chart starts at 0 and each slab begins one
* paisa after the one before ends - the commission run stops for any collection no slab covers - and
* amounts are to the paisa. Slabs are added at the top of the chart, in order.
*/
private chartErrorFor(candidate: { slabType: string; slabFrom: number; slabTo: number }): string | null {
const paise = (n: any) => Math.round(Number(n) * 100);
const twoDecimals = (n: any) => Math.abs(Number(n) * 100 - paise(n)) < 1e-6;
if (!twoDecimals(candidate.slabFrom) || !twoDecimals(candidate.slabTo)) {
return 'Amounts can have at most two decimals (paise)';
}
const tops = this.slabs.filter((slab) => slab.slabType === candidate.slabType).map((slab) => paise(slab.slabTo));
const expected = tops.length === 0 ? 0 : Math.max(...tops) + 1;
const from = paise(candidate.slabFrom);
if (from !== expected) {
const at = (expected / 100).toFixed(2);
return tops.length === 0
? `The first ${candidate.slabType} slab must start at 0`
: from > expected
? `This leaves a gap: nothing covers ${at} to ${((from - 1) / 100).toFixed(2)}. Start this slab at ${at}`
: `Start this slab at ${at} - one paisa after the last ${candidate.slabType} slab ends`;
}
return null;
}
/** Only ever an unsaved row - a saved slab is corrected, not taken away. */ /** Only ever an unsaved row - a saved slab is corrected, not taken away. */
removeSlab(index: number): void { removeSlab(index: number): void {
this.slabs.splice(index, 1); this.slabs.splice(index, 1);
@@ -262,7 +410,13 @@ export class CommissionSlabMasterComponent implements OnInit {
if (heldForApproval(response)) { if (heldForApproval(response)) {
announceHeldForApproval(response, 'The slab correction', this.organizationService, this.alertService); announceHeldForApproval(response, 'The slab correction', this.organizationService, this.alertService);
} else { } else {
this.alertService.alert({ type: 'Success', message: 'Slab corrected' }); const moved = response?.changes?.neighboursAdjusted;
this.alertService.alert({
type: 'Success',
message: moved
? `Slab corrected - the next/previous slab moved with it so the chart has no gap`
: 'Slab corrected'
});
} }
this.editingIndex = -1; this.editingIndex = -1;
this.loadSlabs(); this.loadSlabs();
@@ -282,7 +436,7 @@ export class CommissionSlabMasterComponent implements OnInit {
} }
exit(): void { exit(): void {
this.router.navigate(['/organization']); this.router.navigate(['/products']);
} }
save(): void { save(): void {
@@ -7,7 +7,6 @@
--> -->
<div class="dat-root"> <div class="dat-root">
<div class="dat-card">
<div class="dat-head"> <div class="dat-head">
<div class="dat-head__ic"><fa-icon icon="calendar-day"></fa-icon></div> <div class="dat-head__ic"><fa-icon icon="calendar-day"></fa-icon></div>
<div> <div>
@@ -16,7 +15,27 @@
</div> </div>
</div> </div>
<!--
One form, split into tabs the way the savings product screen is. Every tab stays in the DOM
(preserveContent), so a field filled on one tab is still there when the user comes back to it.
-->
<form [formGroup]="accountTypeForm"> <form [formGroup]="accountTypeForm">
<mat-tab-group
class="dat-tabs"
animationDuration="200ms"
[preserveContent]="true"
[(selectedIndex)]="selectedTabIndex"
>
<!-- ── Details ─────────────────────────────────────────────────────────────────────────── -->
<mat-tab>
<ng-template mat-tab-label>
<i class="fas fa-info-circle dat-tab-icon"></i>
Details
@if (tabHasError(detailsControls)) {
<span class="dat-tab-dot" title="Something on this tab still needs filling in"></span>
}
</ng-template>
<div class="dat-tab-content">
<div class="dat-grid"> <div class="dat-grid">
<mat-form-field appearance="outline" class="dat-field"> <mat-form-field appearance="outline" class="dat-field">
<mat-label>Product Group</mat-label> <mat-label>Product Group</mat-label>
@@ -29,14 +48,34 @@
<mat-form-field appearance="outline" class="dat-field"> <mat-form-field appearance="outline" class="dat-field">
<mat-label>Product Code</mat-label> <mat-label>Product Code</mat-label>
<input matInput required maxlength="20" formControlName="accountTypeCode" style="text-transform: uppercase" /> <input
matInput
required
maxlength="20"
formControlName="accountTypeCode"
style="text-transform: uppercase"
/>
</mat-form-field> </mat-form-field>
<mat-form-field appearance="outline" class="dat-field dat-field--wide"> <mat-form-field appearance="outline" class="dat-field dat-field--wide">
<mat-label>Product Description</mat-label> <mat-label>Product Description</mat-label>
<input matInput required maxlength="200" formControlName="accountTypeDescription" /> <input matInput required maxlength="200" formControlName="accountTypeDescription" />
</mat-form-field> </mat-form-field>
</div>
</div>
</mat-tab>
<!-- ── Interest & Maturity ─────────────────────────────────────────────────────────────── -->
<mat-tab>
<ng-template mat-tab-label>
<i class="fas fa-percent dat-tab-icon"></i>
Interest &amp; Maturity
@if (tabHasError(interestControls)) {
<span class="dat-tab-dot" title="Something on this tab still needs filling in"></span>
}
</ng-template>
<div class="dat-tab-content">
<div class="dat-grid">
<mat-form-field appearance="outline" class="dat-field"> <mat-form-field appearance="outline" class="dat-field">
<mat-label>Interest Rate (%)</mat-label> <mat-label>Interest Rate (%)</mat-label>
<input matInput required type="number" min="0" max="100" step="0.01" formControlName="interestRate" /> <input matInput required type="number" min="0" max="100" step="0.01" formControlName="interestRate" />
@@ -61,16 +100,10 @@
<mat-option [value]="option">{{ option }}</mat-option> <mat-option [value]="option">{{ option }}</mat-option>
} }
</mat-select> </mat-select>
<!-- No posting day to pick: interest posts at period end, as on savings and fixed deposits. -->
<mat-hint>Interest posts on the last day of each month, quarter, half-year or year</mat-hint>
</mat-form-field> </mat-form-field>
<!-- Only meaningful while auto-posting is on — the legacy screen hid it the same way. -->
@if (autoPostOn) {
<mat-form-field appearance="outline" class="dat-field">
<mat-label>Auto Interest Post Day</mat-label>
<input matInput required type="number" min="1" max="31" formControlName="autoInterestPostDay" />
</mat-form-field>
}
<!-- <!--
Asked as Yes/No rather than as a tickbox to match Auto Interest Post directly above it: Asked as Yes/No rather than as a tickbox to match Auto Interest Post directly above it:
the two are the same kind of decision, and a screen that asks one of them one way and the the two are the same kind of decision, and a screen that asks one of them one way and the
@@ -82,19 +115,32 @@
<mat-option [value]="true">Yes</mat-option> <mat-option [value]="true">Yes</mat-option>
<mat-option [value]="false">No</mat-option> <mat-option [value]="false">No</mat-option>
</mat-select> </mat-select>
<mat-hint>Closes the account on its maturity date, if it names a savings account to be paid into</mat-hint> <mat-hint
>Closes the account on its maturity date, if it names a savings account to be paid into</mat-hint
>
</mat-form-field> </mat-form-field>
</div> </div>
</div>
</mat-tab>
<!-- ── Premature Closure ───────────────────────────────────────────────────────────────── -->
<!-- <!--
The terms for closing an account on this scheme early. Set once here rather than asked of the The terms for closing an account on this scheme early. Set once here rather than asked of the
clerk at closing time: they are the bank's terms and the same for every account on the scheme, clerk at closing time: they are the bank's terms and the same for every account on the scheme,
and a figure typed at the counter is a figure that can be typed differently next time. and a figure typed at the counter is a figure that can be typed differently next time.
--> -->
<h3 class="dat-section">Premature Closure</h3> <mat-tab>
<p class="dat-section-note"> <ng-template mat-tab-label>
Applied when an account on this scheme is closed before its term ends. The branch chooses at the time whether <i class="fas fa-door-open dat-tab-icon"></i>
the customer or the agent bears the cost; these set what that costs. Premature Closure
@if (tabHasError(prematureControls)) {
<span class="dat-tab-dot" title="Something on this tab still needs filling in"></span>
}
</ng-template>
<div class="dat-tab-content">
<p class="dat-note">
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.
</p> </p>
<div class="dat-grid"> <div class="dat-grid">
@@ -132,14 +178,91 @@
<mat-hint>Charged whoever bears the interest — the round was not kept going.</mat-hint> <mat-hint>Charged whoever bears the interest — the round was not kept going.</mat-hint>
</mat-form-field> </mat-form-field>
</div> </div>
</div>
</mat-tab>
<!-- ── Accounting ──────────────────────────────────────────────────────────────────────── -->
<!--
The product's own ledger accounts, laid out the way the savings product's Accounting tab is:
grouped by kind, each row with the GL search picker. Only accounts of the right kind are offered.
-->
<mat-tab>
<ng-template mat-tab-label>
<i class="fas fa-book dat-tab-icon"></i>
Accounting
@if (tabHasError(accountingControls)) {
<span class="dat-tab-dot" title="Something on this tab still needs filling in"></span>
}
</ng-template>
<div class="dat-tab-content">
<div class="spa-section">
<div class="spa-section__header">
<span class="spa-section__icon"><i class="fas fa-book"></i></span>
<h3>Accounting</h3>
</div>
<div class="spa-section__body">
@if (glLoadFailed) {
<div class="dat-gl-error">
<i class="fas fa-exclamation-triangle"></i>
The chart of accounts could not be loaded, so no ledger account can be picked. Reload the page to try
again.
</div>
}
@for (section of accountingSections; track section.key) {
<div class="spa-group">
<h4>{{ section.title }}</h4>
<div class="spa-list">
@for (row of section.rows; track row.controlName) {
<div class="spa-row">
<div class="spa-row__meta">
<span class="spa-pill" [ngClass]="'spa-pill--' + row.pill">{{ row.pillLabel }}</span>
<div class="spa-row__text">
<span class="spa-row__label">
{{ row.label }}
@if (row.required) {
<span class="spa-req">*</span>
}
</span>
<span class="spa-row__hint">{{ row.hint }}</span>
</div>
</div>
<mifosx-gl-account-selector
class="spa-row__input"
[inputFormControl]="accountTypeForm.get(row.controlName)"
[glAccountList]="row.options"
[required]="row.required"
[inputLabel]="row.inputLabel"
></mifosx-gl-account-selector>
</div>
}
</div>
</div>
}
</div>
</div>
</div>
</mat-tab>
</mat-tab-group>
<div class="dat-actions"> <div class="dat-actions">
<button mat-raised-button type="button" (click)="cancel()">Cancel</button> <button mat-stroked-button type="button" (click)="cancel()">Cancel</button>
<span class="dat-actions__spacer"></span>
<button mat-stroked-button type="button" [disabled]="selectedTabIndex === 0" (click)="previousTab()">
<i class="fas fa-arrow-left"></i>
Previous
</button>
@if (selectedTabIndex < lastTabIndex) {
<button mat-stroked-button color="primary" type="button" (click)="nextTab()">
Next
<i class="fas fa-arrow-right"></i>
</button>
}
<button mat-raised-button color="primary" type="button" [disabled]="accountTypeForm.invalid" (click)="submit()"> <button mat-raised-button color="primary" type="button" [disabled]="accountTypeForm.invalid" (click)="submit()">
<fa-icon icon="check" style="margin-right: 6px"></fa-icon> <fa-icon icon="check" style="margin-right: 6px"></fa-icon>
{{ isEdit ? 'Update' : 'Submit' }} {{ isEdit ? 'Update' : 'Submit' }}
</button> </button>
</div> </div>
</form> </form>
</div>
</div> </div>
@@ -6,57 +6,104 @@
* file, You can obtain one at http://mozilla.org/MPL/2.0/. * file, You can obtain one at http://mozilla.org/MPL/2.0/.
*/ */
.dat-root { // Laid out like the savings product screen - a header, then the form in tabs - in the DDS screens'
padding: 20px 24px 40px; // own teal.
} $teal: #0f766e;
$teal-dark: #134e4a;
$teal-soft: #e6f4ee;
$border: #e2e8f0;
$text: #0f172a;
$text-mid: #475569;
$text-soft: #94a3b8;
.dat-card { .dat-root {
background: #fff; max-width: 960px;
border: 1px solid rgb(0 0 0 / 8%);
border-radius: 10px;
box-shadow: 0 1px 3px rgb(0 0 0 / 6%);
overflow: hidden;
max-width: 880px;
margin: 0 auto; margin: 0 auto;
padding: 24px 16px 40px;
} }
.dat-head { .dat-head {
display: flex; display: flex;
align-items: center; align-items: center;
gap: 14px; gap: 14px;
padding: 20px 24px; margin-bottom: 18px;
border-bottom: 1px solid rgb(0 0 0 / 7%);
h2 { h2 {
margin: 0; margin: 0;
font-size: 1.2rem; font-size: 1.2rem;
font-weight: 600; font-weight: 600;
color: $text;
} }
p { p {
margin: 2px 0 0; margin: 2px 0 0;
font-size: 0.83rem; font-size: 0.83rem;
color: rgb(0 0 0 / 55%); color: $text-mid;
} }
}
.dat-head__ic { &__ic {
display: grid;
place-items: center;
flex: none;
width: 46px; width: 46px;
height: 46px; height: 46px;
border-radius: 12px; border-radius: 12px;
display: grid;
place-items: center;
font-size: 1.05rem; font-size: 1.05rem;
color: #fff; color: #fff;
background: linear-gradient(135deg, #0f766e, #134e4a); background: linear-gradient(135deg, $teal, $teal-dark);
flex: none; }
}
// ── Tabs ─────────────────────────────────────────────────────────────────────────────────────────────
.dat-tabs {
background: #fff;
border-radius: 10px;
box-shadow: 0 2px 8px rgb(0 0 0 / 8%);
::ng-deep {
.mat-mdc-tab-header {
border-bottom: 1px solid rgb(0 0 0 / 8%);
}
.mat-mdc-tab {
min-width: 130px;
padding: 0 16px;
}
.mat-mdc-tab.mdc-tab--active .mdc-tab__text-label {
color: $teal;
font-weight: 600;
}
.mdc-tab-indicator__content--underline {
border-color: $teal !important;
}
}
}
.dat-tab-icon {
margin-right: 8px;
font-size: 13px;
}
// A tab with something still to fill in.
.dat-tab-dot {
display: inline-block;
width: 7px;
height: 7px;
margin-left: 6px;
border-radius: 50%;
background: #dc2626;
}
.dat-tab-content {
padding: 24px 20px;
} }
.dat-grid { .dat-grid {
display: grid; display: grid;
grid-template-columns: repeat(2, minmax(220px, 1fr)); grid-template-columns: repeat(2, minmax(220px, 1fr));
gap: 8px 20px; gap: 8px 20px;
padding: 26px 24px 8px;
} }
.dat-field { .dat-field {
@@ -67,42 +114,196 @@
grid-column: 1 / -1; grid-column: 1 / -1;
} }
/* .dat-note {
* Divides the scheme's own settings from the terms it charges for closing early. A rule rather than a margin: 0 0 18px;
* second card, because it is the same record being filled in - the break is to stop three money max-width: 72ch; /* stylelint-disable-line unit-allowed-list */
* figures reading as more of the posting settings above them.
*/
.dat-section {
margin: 20px 0 0;
padding: 18px 24px 0;
border-top: 1px solid rgb(0 0 0 / 7%);
font-size: 0.82rem;
font-weight: 600;
letter-spacing: 0.04em;
text-transform: uppercase;
color: #0f766e;
}
.dat-section-note {
margin: 6px 0 0;
padding: 0 24px;
max-width: 68ch; /* stylelint-disable-line unit-allowed-list */
font-size: 0.8rem; font-size: 0.8rem;
line-height: 1.5; line-height: 1.5;
color: rgb(0 0 0 / 60%); color: $text-mid;
} }
/* The note already carries the spacing, so the grid under it does not add its own again. */ .dat-gl-error {
.dat-section-note + .dat-grid { margin: 0 0 16px;
padding-top: 14px; padding: 10px 12px;
border: 1px solid #fecaca;
border-radius: 8px;
background: #fef2f2;
font-size: 0.82rem;
color: #b91c1c;
i {
margin-right: 6px;
}
} }
// ── Accounting tab, as the savings product's ──────────────────────────────────────────────────────────
.spa-section {
overflow: hidden;
border: 1px solid $border;
border-radius: 10px;
background: #fff;
&__header {
display: flex;
align-items: center;
gap: 10px;
padding: 12px 20px;
border-bottom: 1px solid #e8eaed;
background: #f8f9fa;
h3 {
margin: 0;
font-size: 14px;
font-weight: 600;
color: #202124;
}
}
&__icon {
display: inline-flex;
align-items: center;
justify-content: center;
width: 24px;
height: 24px;
border-radius: 6px;
background: $teal-soft;
color: $teal;
font-size: 13px;
}
&__body {
padding: 16px 20px 20px;
}
}
.spa-group {
& + & {
margin-top: 20px;
}
h4 {
margin: 0 0 12px;
font-size: 12px;
font-weight: 700;
letter-spacing: 0.08em;
text-transform: uppercase;
color: $text-soft;
}
}
.spa-list {
display: flex;
flex-direction: column;
gap: 10px;
}
.spa-row {
display: flex;
align-items: stretch;
justify-content: space-between;
gap: 16px;
padding: 10px 12px;
border: 1px solid $border;
border-radius: 8px;
background: #fff;
@media (width <= 900px) {
flex-direction: column;
}
&__meta {
display: flex;
align-items: center;
gap: 10px;
flex: 1 1 auto;
min-width: 0;
}
&__text {
display: flex;
flex-direction: column;
gap: 2px;
min-width: 0;
}
&__label {
font-size: 14px;
font-weight: 600;
color: #334155;
}
&__hint {
font-size: 12px;
line-height: 1.4;
color: $text-mid;
}
&__input {
display: block;
flex: 0 0 22rem;
min-width: 18rem;
max-width: 22rem;
@media (width <= 900px) {
width: 100%;
min-width: 0;
max-width: 100%;
}
::ng-deep {
.layout-column {
width: 100%;
margin-right: 0 !important;
}
mat-form-field {
width: 100%;
}
}
}
}
.spa-req {
color: #dc2626;
}
.spa-pill {
flex: none;
padding: 2px 10px;
border-radius: 999px;
font-size: 11px;
font-weight: 700;
letter-spacing: 0.04em;
text-transform: uppercase;
&--blue {
background: #dbeafe;
color: #1e40af;
}
&--amber {
background: #fef3c7;
color: #92400e;
}
}
// ── Footer ───────────────────────────────────────────────────────────────────────────────────────────
.dat-actions { .dat-actions {
display: flex; display: flex;
justify-content: flex-end; align-items: center;
gap: 12px; gap: 10px;
padding: 16px 24px; margin-top: 16px;
border-top: 1px solid rgb(0 0 0 / 7%); padding: 14px 0;
border-top: 1px solid rgb(0 0 0 / 8%);
&__spacer {
flex: 1;
}
i {
margin: 0 4px;
font-size: 12px;
}
} }
@media (width <= 720px) { @media (width <= 720px) {
@@ -11,19 +11,40 @@ import { Component, OnInit, inject } from '@angular/core';
import { UntypedFormBuilder, UntypedFormGroup, Validators } from '@angular/forms'; import { UntypedFormBuilder, UntypedFormGroup, Validators } from '@angular/forms';
import { ActivatedRoute, Router } from '@angular/router'; import { ActivatedRoute, Router } from '@angular/router';
import { FaIconComponent } from '@fortawesome/angular-fontawesome'; import { FaIconComponent } from '@fortawesome/angular-fontawesome';
import { MatTabsModule } from '@angular/material/tabs';
import { STANDALONE_SHARED_IMPORTS } from 'app/standalone-shared.module'; import { STANDALONE_SHARED_IMPORTS } from 'app/standalone-shared.module';
import { GlAccountSelectorComponent } from 'app/shared/accounting/gl-account-selector/gl-account-selector.component';
import { GLAccount } from 'app/shared/models/general.model';
/** Custom Services */ /** Custom Services */
import { ProductsService } from 'app/products/products.service'; import { ProductsService } from 'app/products/products.service';
import { AlertService } from 'app/core/alert/alert.service'; import { AlertService } from 'app/core/alert/alert.service';
import { OrganizationService } from 'app/organization/organization.service'; import { OrganizationService } from 'app/organization/organization.service';
import { SettingsService } from 'app/settings/settings.service'; import { SettingsService } from 'app/settings/settings.service';
import { AccountingService } from 'app/accounting/accounting.service';
import { announceHeldForApproval, heldForApproval } from 'app/deposits/dds-approvals/dds-approval-notice'; import { announceHeldForApproval, heldForApproval } from 'app/deposits/dds-approvals/dds-approval-notice';
/** acc_gl_account classification: 2 liability, 5 expense. */
const GL_LIABILITY = 2;
const GL_EXPENSE = 5;
/** One ledger account the product names, as a row on the Accounting tab. */
interface AccountingRow {
controlName: string;
label: string;
inputLabel: string;
hint: string;
pill: 'blue' | 'amber';
pillLabel: string;
required: boolean;
options: GLAccount[];
}
/** /**
* Account Type Creation — the legacy DDS screen this replaces, field for field: group, code, * Account Type Creation — the legacy DDS screen this replaces, field for field: group, code,
* description, and whether interest posts automatically and how often. Nothing else: DDS never used * description, and whether interest posts automatically and how often - laid out in tabs the way
* the deposit-product engine, so there are no terms, charges or accounting tabs behind this. * the savings product screen is, with an Accounting tab for the ledger accounts the product posts
* through.
* *
* The same form handles create and edit; an `accountTypeId` in the route switches it to edit. * The same form handles create and edit; an `accountTypeId` in the route switches it to edit.
*/ */
@@ -33,7 +54,9 @@ import { announceHeldForApproval, heldForApproval } from 'app/deposits/dds-appro
styleUrls: ['./create-dds-account-type.component.scss'], styleUrls: ['./create-dds-account-type.component.scss'],
imports: [ imports: [
...STANDALONE_SHARED_IMPORTS, ...STANDALONE_SHARED_IMPORTS,
FaIconComponent FaIconComponent,
MatTabsModule,
GlAccountSelectorComponent
] ]
}) })
export class CreateDdsAccountTypeComponent implements OnInit { export class CreateDdsAccountTypeComponent implements OnInit {
@@ -44,6 +67,38 @@ export class CreateDdsAccountTypeComponent implements OnInit {
private alertService = inject(AlertService); private alertService = inject(AlertService);
private organizationService = inject(OrganizationService); private organizationService = inject(OrganizationService);
private settingsService = inject(SettingsService); private settingsService = inject(SettingsService);
private accountingService = inject(AccountingService);
/** The tab on show; Previous and Next move it. */
selectedTabIndex = 0;
readonly lastTabIndex = 3;
/** Which controls sit on which tab - a tab whose controls are invalid shows a dot on its label. */
readonly detailsControls = [
'accountTypeGroup',
'accountTypeCode',
'accountTypeDescription'
];
readonly interestControls = [
'interestRate',
'autoInterestPost',
'autoInterestPostWhen',
'maturityAutoClose'
];
readonly prematureControls = [
'prematureLockinMonths',
'prematureInterestReduction',
'prematureAgentCommissionPenalty'
];
readonly accountingControls = [
'depositLiabilityGlId',
'interestExpenseGlId',
'commissionExpenseGlId'
];
/** The Accounting tab's rows, grouped by kind as the savings product's are. */
accountingSections: { key: string; title: string; rows: AccountingRow[] }[] = [];
glLoadFailed = false;
accountTypeGroups = ['DDS']; accountTypeGroups = ['DDS'];
@@ -78,7 +133,6 @@ export class CreateDdsAccountTypeComponent implements OnInit {
'Monthly', 'Monthly',
Validators.required Validators.required
], ],
autoInterestPostDay: [''],
/* /*
* Defaults to No, so a scheme created without a thought about it keeps behaving the way every * Defaults to No, so a scheme created without a thought about it keeps behaving the way every
* scheme behaves today - the clerk pays out matured accounts by hand. Turning it on is a * scheme behaves today - the clerk pays out matured accounts by hand. Turning it on is a
@@ -125,6 +179,23 @@ export class CreateDdsAccountTypeComponent implements OnInit {
Validators.min(0), Validators.min(0),
Validators.max(100) Validators.max(100)
] ]
],
/*
* The product's ledger accounts (Accounting tab) - the only accounts DDS posts through for this
* product; nothing is taken from Global Configurations. All three are required, as a savings
* product's control and interest accounts are.
*/
depositLiabilityGlId: [
null,
Validators.required
],
interestExpenseGlId: [
null,
Validators.required
],
commissionExpenseGlId: [
null,
Validators.required
] ]
}); });
@@ -132,29 +203,8 @@ export class CreateDdsAccountTypeComponent implements OnInit {
return this.accountTypeId !== null; return this.accountTypeId !== null;
} }
/**
* "Auto Interest Post Day" only means anything while auto-posting is on, and the legacy screen
* hid it the same way. "When" stays visible either way, as it did there.
*/
get autoPostOn(): boolean {
return !!this.accountTypeForm.get('autoInterestPost')?.value;
}
ngOnInit(): void { ngOnInit(): void {
// The posting day is required exactly while auto-posting is on, and is cleared when it is off. this.loadLedgerAccounts();
const dayControl = this.accountTypeForm.get('autoInterestPostDay');
const applyDayRule = (autoPostOn: boolean) => {
if (autoPostOn) {
dayControl?.setValidators([Validators.required]);
} else {
dayControl?.clearValidators();
dayControl?.setValue('');
}
dayControl?.updateValueAndValidity();
};
applyDayRule(this.autoPostOn);
this.accountTypeForm.get('autoInterestPost')?.valueChanges.subscribe((value) => applyDayRule(!!value));
const routeId = this.route.snapshot.paramMap.get('accountTypeId'); const routeId = this.route.snapshot.paramMap.get('accountTypeId');
if (!routeId) { if (!routeId) {
return; return;
@@ -167,32 +217,115 @@ export class CreateDdsAccountTypeComponent implements OnInit {
accountTypeDescription: accountType.accountTypeDescription, accountTypeDescription: accountType.accountTypeDescription,
autoInterestPost: !!accountType.autoInterestPost, autoInterestPost: !!accountType.autoInterestPost,
autoInterestPostWhen: accountType.autoInterestPostWhen || 'Monthly', autoInterestPostWhen: accountType.autoInterestPostWhen || 'Monthly',
autoInterestPostDay: accountType.autoInterestPostDay ?? '',
maturityAutoClose: !!accountType.maturityAutoClose, maturityAutoClose: !!accountType.maturityAutoClose,
interestRate: accountType.interestRate ?? '', interestRate: accountType.interestRate ?? '',
// ?? rather than ||, because zero is a real answer here - a scheme with no lock-in, or one // ?? 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. // that charges nothing - and must not be replaced by the default.
prematureLockinMonths: accountType.prematureLockinMonths ?? 12, prematureLockinMonths: accountType.prematureLockinMonths ?? 12,
prematureInterestReduction: accountType.prematureInterestReduction ?? 1, prematureInterestReduction: accountType.prematureInterestReduction ?? 1,
prematureAgentCommissionPenalty: accountType.prematureAgentCommissionPenalty ?? 1 prematureAgentCommissionPenalty: accountType.prematureAgentCommissionPenalty ?? 1,
depositLiabilityGlId: accountType.depositLiabilityGlId ?? null,
interestExpenseGlId: accountType.interestExpenseGlId ?? null,
commissionExpenseGlId: accountType.commissionExpenseGlId ?? null
}); });
}); });
} }
/** True once a control on the tab is invalid and the user has been there or tried to submit. */
tabHasError(controls: string[]): boolean {
return controls.some((name) => {
const control = this.accountTypeForm.get(name);
return !!control && control.invalid && (control.touched || control.dirty);
});
}
previousTab(): void {
this.selectedTabIndex = Math.max(0, this.selectedTabIndex - 1);
}
nextTab(): void {
this.selectedTabIndex = Math.min(this.lastTabIndex, this.selectedTabIndex + 1);
}
/**
* Detail accounts that can take a posting, split by kind so each row offers only the kind its job
* needs - the backend refuses any other kind, and this keeps the wrong one from being picked at all.
*/
private loadLedgerAccounts(): void {
this.accountingService.getChartOfAccounts().subscribe({
next: (accounts: any) => {
const rows: GLAccount[] = (Array.isArray(accounts) ? accounts : accounts?.pageItems || []).filter(
(a: any) => a.usage?.id === 1 && !a.disabled
);
const ofType = (...types: number[]) => rows.filter((a: any) => types.includes(a.type?.id));
this.buildAccountingSections(ofType(GL_LIABILITY), ofType(GL_EXPENSE));
},
error: () => {
this.glLoadFailed = true;
this.buildAccountingSections([], []);
}
});
}
private buildAccountingSections(liabilities: GLAccount[], expenses: GLAccount[]): void {
this.accountingSections = [
{
key: 'liabilities',
title: 'Liabilities',
rows: [
{
controlName: 'depositLiabilityGlId',
label: 'DDS Customer Deposits',
inputLabel: 'Customer deposits account',
hint: 'What the bank owes the customers on this product - credited on collection, debited on payout.',
pill: 'blue',
pillLabel: 'Liability',
required: true,
options: liabilities
}
]
},
{
key: 'expenses',
title: 'Expenses',
rows: [
{
controlName: 'interestExpenseGlId',
label: 'Interest Expense',
inputLabel: 'Interest expense account',
hint: 'What interest paid to the customers on this product costs the bank.',
pill: 'amber',
pillLabel: 'Expense',
required: true,
options: expenses
},
{
controlName: 'commissionExpenseGlId',
label: 'Agent Commission Expense',
inputLabel: 'Commission expense account',
hint: "Agents' commission on collecting for this product. An agent collecting for several products is shared out by what was collected for each.",
pill: 'amber',
pillLabel: 'Expense',
required: true,
options: expenses
}
]
}
];
}
cancel(): void { cancel(): void {
this.backToList(); this.backToList();
} }
submit(): void { submit(): void {
if (this.accountTypeForm.invalid) { if (this.accountTypeForm.invalid) {
this.accountTypeForm.markAllAsTouched();
return; return;
} }
const raw = this.accountTypeForm.getRawValue(); const raw = this.accountTypeForm.getRawValue();
const payload = { const payload = {
...raw, ...raw,
// The posting day only applies while auto-posting is on; send nothing for it otherwise.
autoInterestPostDay:
raw.autoInterestPost && raw.autoInterestPostDay ? Number(raw.autoInterestPostDay) : undefined,
// Sent as numbers, not the strings a text input yields - the backend reads them as decimals. // Sent as numbers, not the strings a text input yields - the backend reads them as decimals.
interestRate: Number(raw.interestRate), interestRate: Number(raw.interestRate),
prematureLockinMonths: Number(raw.prematureLockinMonths), prematureLockinMonths: Number(raw.prematureLockinMonths),
@@ -93,9 +93,7 @@
<td mat-cell *matCellDef="let t"> <td mat-cell *matCellDef="let t">
@if (t.autoInterestPost) { @if (t.autoInterestPost) {
{{ t.autoInterestPostWhen || 'Yes' }} {{ t.autoInterestPostWhen || 'Yes' }}
@if (t.autoInterestPostDay) { <span class="pl-product-sub">period end</span>
<span class="pl-product-sub">day {{ t.autoInterestPostDay }}</span>
}
} @else { } @else {
No No
} }
@@ -112,11 +110,22 @@
<ng-container matColumnDef="actions"> <ng-container matColumnDef="actions">
<th mat-header-cell *matHeaderCellDef class="num">Actions</th> <th mat-header-cell *matHeaderCellDef class="num">Actions</th>
<td mat-cell *matCellDef="let t" class="num"> <td mat-cell *matCellDef="let t" class="num">
<button
type="button"
class="pl-action-btn pl-action-btn--view"
[routerLink]="[t.id]"
(click)="$event.stopPropagation()"
matTooltip="View product details"
>
<fa-icon icon="eye"></fa-icon><span>View</span>
</button>
<button <button
type="button" type="button"
class="pl-action-btn pl-action-btn--edit" class="pl-action-btn pl-action-btn--edit"
[routerLink]="[t.id, 'edit']" [routerLink]="[t.id, 'edit']"
(click)="$event.stopPropagation()"
matTooltip="Edit this account type" matTooltip="Edit this account type"
*mifosxHasPermission="'UPDATE_DDSACCOUNTTYPE'"
> >
<fa-icon icon="edit"></fa-icon><span>Edit</span> <fa-icon icon="edit"></fa-icon><span>Edit</span>
</button> </button>
@@ -124,7 +133,7 @@
</ng-container> </ng-container>
<tr mat-header-row *matHeaderRowDef="displayedColumns"></tr> <tr mat-header-row *matHeaderRowDef="displayedColumns"></tr>
<tr mat-row *matRowDef="let row; columns: displayedColumns" class="pl-row"></tr> <tr mat-row *matRowDef="let row; columns: displayedColumns" [routerLink]="[row.id]" class="pl-row"></tr>
</table> </table>
@if (filteredCount === 0 && totalCount > 0) { @if (filteredCount === 0 && totalCount > 0) {
@@ -0,0 +1,193 @@
<!--
Copyright since 2025 Mifos Initiative
This Source Code Form is subject to the terms of the Mozilla Public
License, v. 2.0. If a copy of the MPL was not distributed with this
file, You can obtain one at http://mozilla.org/MPL/2.0/.
-->
<div class="vsp-root vdt-page">
<button type="button" class="vdt-back" (click)="back()">
<fa-icon icon="arrow-left"></fa-icon>
<span>All DDS products</span>
</button>
@if (loading) {
<div class="vdt-state">Loading product…</div>
} @else if (notFound || !accountType) {
<div class="vdt-state">This DDS product could not be found.</div>
} @else {
<!-- HERO -->
<div class="vsp-hero">
<div class="vsp-hero-left">
<div class="vsp-hero-icon"><fa-icon icon="calendar-day"></fa-icon></div>
<div class="vsp-hero-text">
<div class="vsp-hero-eyebrow">DDS Product</div>
<h2 class="vsp-hero-title">{{ accountType.accountTypeDescription }}</h2>
<div class="vsp-hero-meta">
<span class="vsp-pill vsp-pill--code">{{ accountType.accountTypeCode }}</span>
@if (accountType.accountTypeGroup) {
<span class="vsp-pill vsp-pill--teal">{{ accountType.accountTypeGroup }}</span>
}
<span
class="vsp-pill"
[class.vsp-pill--green]="accountType.active"
[class.vsp-pill--amber]="!accountType.active"
>
<fa-icon [icon]="accountType.active ? 'check-circle' : 'times-circle'"></fa-icon>
{{ accountType.active ? 'Active' : 'Inactive' }}
</span>
</div>
</div>
</div>
<button
class="vsp-edit-btn"
type="button"
[routerLink]="['../', accountType.id, 'edit']"
*mifosxHasPermission="'UPDATE_DDSACCOUNTTYPE'"
>
<fa-icon icon="edit"></fa-icon>
<span>Edit</span>
</button>
</div>
<!-- DETAILS -->
<section class="vsp-section">
<div class="vsp-section-h">
<div class="vsp-section-ic vsp-section-ic--blue"><fa-icon icon="info-circle"></fa-icon></div>
<h3>Details</h3>
</div>
<div class="vsp-grid">
<div class="vsp-kv">
<span class="vsp-k">Account Type Code</span>
<span class="vsp-v"
><strong>{{ accountType.accountTypeCode }}</strong></span
>
</div>
<div class="vsp-kv">
<span class="vsp-k">Account Type Group</span>
<span class="vsp-v">{{ accountType.accountTypeGroup || '—' }}</span>
</div>
<div class="vsp-kv vsp-kv--span">
<span class="vsp-k">Description</span>
<span class="vsp-v">{{ accountType.accountTypeDescription || '—' }}</span>
</div>
<div class="vsp-kv">
<span class="vsp-k">Created</span>
<span class="vsp-v">
{{ accountType.createdDate ? (accountType.createdDate | dateFormat: 'DD MMM YYYY, hh:mm A') : '—' }}
</span>
</div>
<div class="vsp-kv">
<span class="vsp-k">Last Modified</span>
<span class="vsp-v">
{{
accountType.lastModifiedDate ? (accountType.lastModifiedDate | dateFormat: 'DD MMM YYYY, hh:mm A') : '—'
}}
</span>
</div>
</div>
</section>
<!-- INTEREST -->
<section class="vsp-section">
<div class="vsp-section-h">
<div class="vsp-section-ic vsp-section-ic--green"><fa-icon icon="coins"></fa-icon></div>
<h3>Interest</h3>
</div>
<div class="vsp-grid">
<div class="vsp-kv">
<span class="vsp-k">Interest Rate</span>
<span class="vsp-v vsp-v--accent">{{ percent(accountType.interestRate) }}</span>
<span class="vdt-note">Worked out daily on the balance (rate ÷ 365)</span>
</div>
<div class="vsp-kv">
<span class="vsp-k">Auto Interest Post</span>
<span class="vsp-v">
<span
class="vsp-pill"
[class.vsp-pill--green]="accountType.autoInterestPost"
[class.vsp-pill--gray]="!accountType.autoInterestPost"
>
{{ accountType.autoInterestPost ? 'Yes' : 'No' }}
</span>
</span>
</div>
@if (accountType.autoInterestPost) {
<div class="vsp-kv">
<span class="vsp-k">Auto Interest Post When</span>
<span class="vsp-v">{{ accountType.autoInterestPostWhen || 'Monthly' }}</span>
<span class="vdt-note">Posts on the last day of each period</span>
</div>
}
</div>
</section>
<!-- MATURITY & PREMATURE CLOSURE -->
<section class="vsp-section">
<div class="vsp-section-h">
<div class="vsp-section-ic vsp-section-ic--amber"><fa-icon icon="calendar-day"></fa-icon></div>
<h3>Maturity &amp; Premature Closure</h3>
</div>
<div class="vsp-grid">
<div class="vsp-kv">
<span class="vsp-k">Maturity Auto Close</span>
<span class="vsp-v">
<span
class="vsp-pill"
[class.vsp-pill--green]="accountType.maturityAutoClose"
[class.vsp-pill--gray]="!accountType.maturityAutoClose"
>
{{ accountType.maturityAutoClose ? 'Yes' : 'No' }}
</span>
</span>
<span class="vdt-note">Closes on the maturity date, if it names a savings account to be paid into</span>
</div>
<div class="vsp-kv">
<span class="vsp-k">Premature Lock-in</span>
<span class="vsp-v">{{ lockinLabel(accountType.prematureLockinMonths) }}</span>
<span class="vdt-note">Below this, early closure is refused outright</span>
</div>
<div class="vsp-kv">
<span class="vsp-k">Premature Interest Reduction</span>
<span class="vsp-v">{{ percent(accountType.prematureInterestReduction) }}</span>
<span class="vdt-note">Taken off the customer's rate when they bear the cost of closing early</span>
</div>
<div class="vsp-kv">
<span class="vsp-k">Agent Commission Penalty</span>
<span class="vsp-v">{{ percent(accountType.prematureAgentCommissionPenalty) }}</span>
<span class="vdt-note">Taken off the agent's commission on an early closure</span>
</div>
</div>
</section>
<!-- ACCOUNTING: the only ledger accounts DDS posts through for this product -->
<section class="vsp-section">
<div class="vsp-section-h">
<div class="vsp-section-ic vsp-section-ic--blue"><fa-icon icon="book"></fa-icon></div>
<h3>Accounting</h3>
</div>
<div class="vsp-grid">
<div class="vsp-kv">
<span class="vsp-k">DDS Customer Deposits (Liability)</span>
<span class="vsp-v">{{
glLabel(accountType.depositLiabilityGlCode, accountType.depositLiabilityGlName)
}}</span>
<span class="vdt-note">Credited on collection, debited on payout</span>
</div>
<div class="vsp-kv">
<span class="vsp-k">Interest Expense</span>
<span class="vsp-v">{{ glLabel(accountType.interestExpenseGlCode, accountType.interestExpenseGlName) }}</span>
<span class="vdt-note">Debited when interest is posted to customers</span>
</div>
<div class="vsp-kv">
<span class="vsp-k">Agent Commission Expense</span>
<span class="vsp-v">{{
glLabel(accountType.commissionExpenseGlCode, accountType.commissionExpenseGlName)
}}</span>
<span class="vdt-note">Debited with this product's share of agents' commission</span>
</div>
</div>
</section>
}
</div>
@@ -0,0 +1,53 @@
/**
* 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/.
*/
// Only what the DDS view adds to the fixed deposit product sheet it shares: a way back to the
// list, a line of explanation under a term, and a placeholder while the product loads.
.vdt-page {
padding-top: 8px;
}
.vdt-back {
display: inline-flex;
align-items: center;
gap: 8px;
align-self: flex-start;
padding: 6px 12px;
border: 1px solid var(--vsp-g200);
border-radius: 8px;
background: #fff;
color: var(--vsp-g700);
font: inherit;
font-size: 12.5px;
font-weight: 600;
cursor: pointer;
transition:
background 0.15s,
color 0.15s;
&:hover {
background: var(--vsp-g100);
color: var(--vsp-g900);
}
}
.vdt-note {
font-size: 11.5px;
line-height: 1.4;
color: var(--vsp-g500);
}
.vdt-state {
padding: 48px 16px;
text-align: center;
color: var(--vsp-g500);
background: #fff;
border: 1px solid var(--vsp-g200);
border-radius: 12px;
}
@@ -0,0 +1,88 @@
/**
* 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, RouterLink } from '@angular/router';
import { FaIconComponent } from '@fortawesome/angular-fontawesome';
import { STANDALONE_SHARED_IMPORTS } from 'app/standalone-shared.module';
import { ProductsService } from 'app/products/products.service';
/**
* A DDS product, read-only: the terms an account opened under it takes, laid out as the savings,
* fixed and recurring deposit product views lay theirs out - and drawn by the same stylesheet, so a
* checker or clerk reads every product the same way. Edit leads to the same form the list does.
*/
@Component({
selector: 'mifosx-view-dds-account-type',
templateUrl: './view-dds-account-type.component.html',
styleUrls: [
'../../fixed-deposit-products/view-fixed-deposit-product/fixed-deposit-general-tab/fixed-deposit-general-tab.component.scss',
'./view-dds-account-type.component.scss'
],
imports: [
...STANDALONE_SHARED_IMPORTS,
RouterLink,
FaIconComponent
]
})
export class ViewDdsAccountTypeComponent implements OnInit {
private route = inject(ActivatedRoute);
private router = inject(Router);
private productsService = inject(ProductsService);
accountType: any = null;
loading = true;
notFound = false;
ngOnInit(): void {
const id = this.route.snapshot.paramMap.get('accountTypeId');
if (!id) {
this.router.navigate(['/products/dds-account-types']);
return;
}
this.productsService.getDdsAccountType(id).subscribe({
next: (accountType: any) => {
this.accountType = accountType;
this.loading = false;
},
error: () => {
this.loading = false;
this.notFound = true;
}
});
}
/** 12 → "12 months", 1 → "1 month", 0 → "None - may be closed from day one". */
lockinLabel(months: number | null | undefined): string {
if (months === null || months === undefined) {
return '—';
}
if (months === 0) {
return 'None - may be closed from day one';
}
return `${months} month${months === 1 ? '' : 's'}`;
}
/** A rate or percentage as typed, without the trailing zeros the database pads it with. */
percent(value: number | string | null | undefined): string {
if (value === null || value === undefined || value === '') {
return '—';
}
return `${Number(value)}%`;
}
/** A ledger account as "NAME (code)", or a plain "Not set" when the product names none. */
glLabel(code: string | null | undefined, name: string | null | undefined): string {
return code ? `${name} (${code})` : 'Not set';
}
back(): void {
this.router.navigate(['/products/dds-account-types']);
}
}
@@ -32,6 +32,8 @@ import { RecurringDepositProductsComponent } from './recurring-deposit-products/
import { ViewRecurringDepositProductComponent } from './recurring-deposit-products/view-recurring-deposit-product/view-recurring-deposit-product.component'; import { ViewRecurringDepositProductComponent } from './recurring-deposit-products/view-recurring-deposit-product/view-recurring-deposit-product.component';
import { DdsAccountTypesComponent } from './dds-account-types/dds-account-types.component'; import { DdsAccountTypesComponent } from './dds-account-types/dds-account-types.component';
import { CreateDdsAccountTypeComponent } from './dds-account-types/create-dds-account-type/create-dds-account-type.component'; import { CreateDdsAccountTypeComponent } from './dds-account-types/create-dds-account-type/create-dds-account-type.component';
import { ViewDdsAccountTypeComponent } from './dds-account-types/view-dds-account-type/view-dds-account-type.component';
import { CommissionSlabMasterComponent } from './commission-slab-master/commission-slab-master.component';
import { ChargesComponent } from './charges/charges.component'; import { ChargesComponent } from './charges/charges.component';
import { ViewChargeComponent } from './charges/view-charge/view-charge.component'; import { ViewChargeComponent } from './charges/view-charge/view-charge.component';
import { CreateChargeComponent } from './charges/create-charge/create-charge.component'; import { CreateChargeComponent } from './charges/create-charge/create-charge.component';
@@ -739,6 +741,11 @@ const routes: Routes = [
} }
] ]
}, },
{
path: 'commission-slab-master',
component: CommissionSlabMasterComponent,
data: { title: 'Commission Slab Master', breadcrumb: 'Commission Slab Master' }
},
{ {
path: 'dds-account-types', path: 'dds-account-types',
data: { title: 'DDS Product', breadcrumb: 'DDS Product' }, data: { title: 'DDS Product', breadcrumb: 'DDS Product' },
@@ -756,6 +763,12 @@ const routes: Routes = [
path: ':accountTypeId/edit', path: ':accountTypeId/edit',
component: CreateDdsAccountTypeComponent, component: CreateDdsAccountTypeComponent,
data: { title: 'Edit DDS Product', breadcrumb: 'Edit', routeParamBreadcrumb: false } data: { title: 'Edit DDS Product', breadcrumb: 'Edit', routeParamBreadcrumb: false }
},
{
// After 'create', so that word is never read as a product id.
path: ':accountTypeId',
component: ViewDdsAccountTypeComponent,
data: { title: 'View DDS Product', breadcrumb: 'View', routeParamBreadcrumb: false }
} }
] ]
}, },
+13
View File
@@ -153,6 +153,19 @@
</a> </a>
</div> </div>
<div #commissionSlabMaster>
<a class="pd-card" [routerLink]="['commission-slab-master']" *mifosxHasPermission="'READ_COMMISSIONSLAB'">
<div class="pd-card__icon pd-card__icon--teal">
<i class="fas fa-percentage"></i>
</div>
<div class="pd-card__body">
<h3 class="pd-card__title">Commission Slab Master</h3>
<p class="pd-card__desc">DDS agent commission chart - a percentage on each agent's collection volume</p>
</div>
<i class="fas fa-chevron-right pd-card__arrow"></i>
</a>
</div>
<a class="pd-card" [routerLink]="['products-mix']" *mifosxHasPermission="'READ_PRODUCTMIX'"> <a class="pd-card" [routerLink]="['products-mix']" *mifosxHasPermission="'READ_PRODUCTMIX'">
<div class="pd-card__icon pd-card__icon--indigo"> <div class="pd-card__icon pd-card__icon--indigo">
<i class="fas fa-random"></i> <i class="fas fa-random"></i>
@@ -37,10 +37,24 @@ export interface CustomerSearchDialogData {
* need one; a screen that only needs the customer (cash exchange) passes false. * need one; a screen that only needs the customer (cash exchange) passes false.
*/ */
requireAccount?: boolean; requireAccount?: boolean;
/**
* Whether each customer is listed once. The search returns a row per account, which an account
* screen wants; a screen that is choosing the customer itself (an agent, a guarantor) wants each
* person once however many accounts they hold.
*/
uniqueCustomers?: boolean;
/**
* What the caller had already typed, to search for on opening. All digits is read as a client ID,
* or a mobile number once it is as long as one; anything else is a name.
*/
prefill?: string;
} }
type AccountTypeKey = 'savings' | 'current' | 'fixedDeposit' | 'recurringDeposit'; type AccountTypeKey = 'savings' | 'current' | 'fixedDeposit' | 'recurringDeposit';
/** An Indian mobile number is ten digits; a client ID is never that long. */
const MOBILE_NUMBER_LENGTH = 10;
@Component({ @Component({
selector: 'mifosx-customer-search-dialog', selector: 'mifosx-customer-search-dialog',
standalone: true, standalone: true,
@@ -77,6 +91,19 @@ type AccountTypeKey = 'savings' | 'current' | 'fixedDeposit' | 'recurringDeposit
<mat-dialog-content class="csd-content"> <mat-dialog-content class="csd-content">
<form [formGroup]="form" class="csd-form" (keydown.enter)="onEnter($event)"> <form [formGroup]="form" class="csd-form" (keydown.enter)="onEnter($event)">
<div class="csd-grid"> <div class="csd-grid">
<mat-form-field appearance="outline" subscriptSizing="dynamic" class="csd-field">
<mat-label>Client ID</mat-label>
<mat-icon matPrefix>badge</mat-icon>
<input
matInput
formControlName="clientId"
placeholder="Client ID"
inputmode="numeric"
autocomplete="off"
(input)="digitsOnly('clientId')"
/>
</mat-form-field>
<mat-form-field appearance="outline" subscriptSizing="dynamic" class="csd-field csd-field--name"> <mat-form-field appearance="outline" subscriptSizing="dynamic" class="csd-field csd-field--name">
<mat-label>Name</mat-label> <mat-label>Name</mat-label>
<mat-icon matPrefix>person</mat-icon> <mat-icon matPrefix>person</mat-icon>
@@ -84,6 +111,20 @@ type AccountTypeKey = 'savings' | 'current' | 'fixedDeposit' | 'recurringDeposit
</mat-form-field> </mat-form-field>
<mat-form-field appearance="outline" subscriptSizing="dynamic" class="csd-field"> <mat-form-field appearance="outline" subscriptSizing="dynamic" class="csd-field">
<mat-label>Mobile Number</mat-label>
<mat-icon matPrefix>call</mat-icon>
<input
matInput
formControlName="mobileNo"
placeholder="Mobile number"
maxlength="10"
inputmode="numeric"
autocomplete="off"
(input)="digitsOnly('mobileNo')"
/>
</mat-form-field>
<mat-form-field appearance="outline" subscriptSizing="dynamic" class="csd-field csd-field--wide">
<mat-label>Account Number</mat-label> <mat-label>Account Number</mat-label>
<mat-icon matPrefix>account_balance_wallet</mat-icon> <mat-icon matPrefix>account_balance_wallet</mat-icon>
<input matInput formControlName="accountNumber" placeholder="Any account" autocomplete="off" /> <input matInput formControlName="accountNumber" placeholder="Any account" autocomplete="off" />
@@ -266,12 +307,13 @@ type AccountTypeKey = 'savings' | 'current' | 'fixedDeposit' | 'recurringDeposit
width: 100%; width: 100%;
} }
.csd-field--name { .csd-field--name,
.csd-field--wide {
grid-column: span 2; grid-column: span 2;
} }
.csd-field--address { .csd-field--address {
grid-column: span 3; grid-column: 1 / -1;
} }
.csd-upper { .csd-upper {
@@ -522,6 +564,7 @@ type AccountTypeKey = 'savings' | 'current' | 'fixedDeposit' | 'recurringDeposit
} }
.csd-field--name, .csd-field--name,
.csd-field--wide,
.csd-field--address { .csd-field--address {
grid-column: span 1; grid-column: span 1;
} }
@@ -550,7 +593,9 @@ export class CustomerSearchDialogComponent implements OnInit {
ngOnInit(): void { ngOnInit(): void {
this.form = this.fb.group({ this.form = this.fb.group({
clientId: [''],
name: [''], name: [''],
mobileNo: [''],
accountNumber: [''], accountNumber: [''],
aadhaarNumber: [''], aadhaarNumber: [''],
panNumber: [''], panNumber: [''],
@@ -560,6 +605,36 @@ export class CustomerSearchDialogComponent implements OnInit {
fixedDeposit: [false], fixedDeposit: [false],
recurringDeposit: [false] recurringDeposit: [false]
}); });
// What the caller had typed already is searched for straight away, so opening the dialog from a
// half-filled field does not mean typing it a second time.
const typed = (this.data?.prefill ?? '').trim();
if (typed) {
const field = this.fieldFor(typed);
this.form.get(field)?.setValue(typed);
this.onSearch();
}
}
/**
* Which field something typed belongs in. Digits alone are a client ID - they are short - until
* they are as long as a mobile number, and anything with letters in it is a name.
*/
private fieldFor(typed: string): 'clientId' | 'mobileNo' | 'name' {
if (!/^\d+$/.test(typed)) {
return 'name';
}
return typed.length >= MOBILE_NUMBER_LENGTH ? 'mobileNo' : 'clientId';
}
/** Keeps a numeric field to digits however it was filled - typed, pasted or dropped. */
digitsOnly(name: string): void {
const control = this.form.get(name);
const value = String(control?.value ?? '');
const digits = value.replace(/\D/g, '');
if (value !== digits) {
control?.setValue(digits, { emitEvent: false });
}
} }
/** The account screens need an account to open; a caller that only needs the customer opts out. */ /** The account screens need an account to open; a caller that only needs the customer opts out. */
@@ -627,14 +702,25 @@ export class CustomerSearchDialogComponent implements OnInit {
if (v.fixedDeposit) accountTypes.push('FIXED_DEPOSIT'); if (v.fixedDeposit) accountTypes.push('FIXED_DEPOSIT');
if (v.recurringDeposit) accountTypes.push('RECURRING_DEPOSIT'); if (v.recurringDeposit) accountTypes.push('RECURRING_DEPOSIT');
if (!v.name && !v.accountNumber && !v.aadhaarNumber && !v.panNumber && !v.address && accountTypes.length === 0) { if (
!v.clientId &&
!v.name &&
!v.mobileNo &&
!v.accountNumber &&
!v.aadhaarNumber &&
!v.panNumber &&
!v.address &&
accountTypes.length === 0
) {
this.snackBar.open('Enter at least one search field, or check an account type.', 'Close', { duration: 3000 }); this.snackBar.open('Enter at least one search field, or check an account type.', 'Close', { duration: 3000 });
return; return;
} }
this.loading = true; this.loading = true;
let params = new HttpParams(); let params = new HttpParams();
if (v.clientId) params = params.set('clientId', v.clientId);
if (v.name) params = params.set('name', v.name); if (v.name) params = params.set('name', v.name);
if (v.mobileNo) params = params.set('mobileNo', v.mobileNo);
if (v.accountNumber) params = params.set('accountNumber', v.accountNumber); if (v.accountNumber) params = params.set('accountNumber', v.accountNumber);
if (v.aadhaarNumber) params = params.set('aadhaarNumber', v.aadhaarNumber); if (v.aadhaarNumber) params = params.set('aadhaarNumber', v.aadhaarNumber);
if (v.panNumber) params = params.set('panNumber', v.panNumber); if (v.panNumber) params = params.set('panNumber', v.panNumber);
@@ -643,7 +729,7 @@ export class CustomerSearchDialogComponent implements OnInit {
this.http.get<CustomerSearchResult[]>('/customer-search', { params }).subscribe({ this.http.get<CustomerSearchResult[]>('/customer-search', { params }).subscribe({
next: (data) => { next: (data) => {
this.results = data || []; this.results = this.data?.uniqueCustomers ? this.onePerCustomer(data || []) : data || [];
this.searched = true; this.searched = true;
this.loading = false; this.loading = false;
}, },
@@ -654,9 +740,17 @@ export class CustomerSearchDialogComponent implements OnInit {
}); });
} }
/** The search answers per account; one row per customer is the first of each person's rows. */
private onePerCustomer(rows: CustomerSearchResult[]): CustomerSearchResult[] {
const seen = new Set<number>();
return rows.filter((row) => !seen.has(row.clientId) && !!seen.add(row.clientId));
}
onClear(): void { onClear(): void {
this.form.reset({ this.form.reset({
clientId: '',
name: '', name: '',
mobileNo: '',
accountNumber: '', accountNumber: '',
aadhaarNumber: '', aadhaarNumber: '',
panNumber: '', panNumber: '',
@@ -0,0 +1,125 @@
<!--
Copyright since 2025 Mifos Initiative
This Source Code Form is subject to the terms of the Mozilla Public
License, v. 2.0. If a copy of the MPL was not distributed with this
file, You can obtain one at http://mozilla.org/MPL/2.0/.
-->
<div class="psp-tab-content">
@if (dataSource.data.length > 0) {
<div class="psp-action-bar">
<mat-form-field appearance="outline" class="psp-search-field">
<mat-icon matPrefix>search</mat-icon>
<input
#filterInput
matInput
placeholder="Search by agent, client, branch or account"
(keyup)="applyFilter(filterInput.value)"
/>
</mat-form-field>
</div>
<div class="table-container">
<table mat-table [dataSource]="dataSource" class="modern-table">
<ng-container matColumnDef="request">
<th mat-header-cell *matHeaderCellDef>Request</th>
<td mat-cell *matCellDef="let row">
<span class="status-badge status-badge--action" [class.status-badge--delete]="row.action === 'CLOSE'">{{
requestLabel(row)
}}</span>
</td>
</ng-container>
<ng-container matColumnDef="agent">
<th mat-header-cell *matHeaderCellDef>Agent</th>
<td mat-cell *matCellDef="let row">
<strong>{{ row.agentName || '—' }}</strong>
<!-- A new agent has no code until it is approved. -->
<div class="cell-mono">{{ row.agentCode || 'Code on approval' }}</div>
</td>
</ng-container>
<ng-container matColumnDef="client">
<th mat-header-cell *matHeaderCellDef>Client</th>
<td mat-cell *matCellDef="let row">{{ row.clientName || '—' }}</td>
</ng-container>
<ng-container matColumnDef="branch">
<th mat-header-cell *matHeaderCellDef>Branch</th>
<td mat-cell *matCellDef="let row">{{ row.officeName || '—' }}</td>
</ng-container>
<ng-container matColumnDef="savingAccount">
<th mat-header-cell *matHeaderCellDef>Saving A/C</th>
<td mat-cell *matCellDef="let row" class="cell-mono">{{ row.savingAccountNo || '—' }}</td>
</ng-container>
<ng-container matColumnDef="guarantors">
<th mat-header-cell *matHeaderCellDef>Guarantors</th>
<td mat-cell *matCellDef="let row">{{ row.guarantorCount ?? 0 }}</td>
</ng-container>
<ng-container matColumnDef="maker">
<th mat-header-cell *matHeaderCellDef>Submitted By</th>
<td mat-cell *matCellDef="let row">{{ row.makerName || row.maker }}</td>
</ng-container>
<ng-container matColumnDef="madeOn">
<th mat-header-cell *matHeaderCellDef>Submitted On</th>
<td mat-cell *matCellDef="let row">{{ formatDate(row.submittedOn) }}</td>
</ng-container>
<ng-container matColumnDef="status">
<th mat-header-cell *matHeaderCellDef>Status</th>
<td mat-cell *matCellDef="let row">
<span class="status-badge status-badge--pending">{{ row.status }}</span>
</td>
</ng-container>
<ng-container matColumnDef="actions">
<th mat-header-cell *matHeaderCellDef>Actions</th>
<td mat-cell *matCellDef="let row">
<div class="aa-actions">
<button mat-stroked-button class="psp-btn psp-btn-view" (click)="view(row)">
<fa-icon icon="eye" class="m-r-10"></fa-icon>View
</button>
<!-- Not offered to whoever made the request - another user with checker rights has to act. -->
<button
mat-stroked-button
class="psp-btn psp-btn-approve"
[disabled]="!canAct(row)"
[title]="isMine(row) ? 'You made this request, so another user has to approve it' : ''"
(click)="approve(row)"
>
<fa-icon icon="check" class="m-r-10"></fa-icon>Approve
</button>
<button
mat-stroked-button
class="psp-btn psp-btn-reject"
[disabled]="!canAct(row)"
[title]="isMine(row) ? 'You made this request, so another user has to reject it' : ''"
(click)="reject(row)"
>
<fa-icon icon="ban" class="m-r-10"></fa-icon>Reject
</button>
</div>
</td>
</ng-container>
<tr mat-header-row *matHeaderRowDef="displayedColumns"></tr>
<tr mat-row *matRowDef="let row; columns: displayedColumns" class="table-row"></tr>
</table>
</div>
} @else if (loading) {
<div class="empty-state">
<p class="empty-title">Loading agent requests…</p>
</div>
} @else {
<div class="empty-state">
<fa-icon icon="user-tie" class="empty-icon"></fa-icon>
<p class="empty-title">No agent requests waiting</p>
<p class="empty-text">There are no agents awaiting approval at this time.</p>
</div>
}
</div>
@@ -0,0 +1,30 @@
/**
* Copyright since 2025 Mifos Initiative
*
* This Source Code Form is subject to the terms of the Mozilla Public
* License, v. 2.0. If a copy of the MPL was not distributed with this
* file, You can obtain one at http://mozilla.org/MPL/2.0/.
*/
// What the Agent Approval tab adds to the Product Approval sheet it shares.
// View, Approve and Reject sit on one line: the column takes the width its buttons need and the
// other columns - whose names and addresses can wrap - give way.
.aa-actions {
display: flex;
flex-wrap: nowrap;
align-items: center;
gap: 6px;
white-space: nowrap;
.psp-btn {
flex: 0 0 auto;
margin-right: 0;
}
}
th.mat-column-actions,
td.mat-column-actions {
width: 1%;
white-space: nowrap;
}
@@ -0,0 +1,175 @@
/**
* Copyright since 2025 Mifos Initiative
*
* This Source Code Form is subject to the terms of the Mozilla Public
* License, v. 2.0. If a copy of the MPL was not distributed with this
* file, You can obtain one at http://mozilla.org/MPL/2.0/.
*/
import { Component, OnInit, inject } from '@angular/core';
import {
MatTableDataSource,
MatTable,
MatColumnDef,
MatHeaderCellDef,
MatHeaderCell,
MatCellDef,
MatCell,
MatHeaderRowDef,
MatHeaderRow,
MatRowDef,
MatRow
} from '@angular/material/table';
import { MatDialog } from '@angular/material/dialog';
import { MatIcon } from '@angular/material/icon';
import { FaIconComponent } from '@fortawesome/angular-fontawesome';
import { of } from 'rxjs';
import { catchError } from 'rxjs/operators';
import { STANDALONE_SHARED_IMPORTS } from 'app/standalone-shared.module';
import { OrganizationService } from 'app/organization/organization.service';
import { AuthenticationService } from 'app/core/authentication/authentication.service';
import { DdsApprovalReviewComponent } from 'app/deposits/dds-approvals/dds-approval-review/dds-approval-review.component';
import { ddsMadeOn } from '../../product-entities';
/**
* Approvals › Agent Approval: every DDS agent request waiting on a checker - an agent being opened,
* changed or closed.
*
* An agent has no voucher and no amount, so the money columns of DDS Approvals say nothing about one.
* This lists them by what identifies an agent - who they are, which branch, which savings account the
* commission goes into, how many guarantors - and opens the same review the DDS queue uses, which
* shows the agent and their guarantors in full and approves or rejects through the DDS queue.
*/
@Component({
selector: 'mifosx-agent-approvals',
templateUrl: './agent-approvals.component.html',
// The same look as Product Approval, so the tabs of this screen read alike.
styleUrls: [
'../pending-savings-products/pending-savings-products.component.scss',
'./agent-approvals.component.scss'
],
imports: [
...STANDALONE_SHARED_IMPORTS,
FaIconComponent,
MatTable,
MatColumnDef,
MatHeaderCellDef,
MatHeaderCell,
MatCellDef,
MatCell,
MatHeaderRowDef,
MatHeaderRow,
MatRowDef,
MatRow,
MatIcon
]
})
export class AgentApprovalsComponent implements OnInit {
private organizationService = inject(OrganizationService);
private authenticationService = inject(AuthenticationService);
private dialog = inject(MatDialog);
private readonly currentUserId = this.authenticationService.getCredentials()?.userId;
dataSource = new MatTableDataSource<any>([]);
loading = true;
displayedColumns: string[] = [
'request',
'agent',
'client',
'branch',
'savingAccount',
'guarantors',
'maker',
'madeOn',
'status',
'actions'
];
ngOnInit(): void {
this.dataSource.filterPredicate = (row: any, filter: string) =>
[
row.agentName,
row.agentCode,
row.clientName,
row.officeName,
row.savingAccountNo,
row.type,
row.maker,
row.makerName
]
.filter(Boolean)
.join(' ')
.toLowerCase()
.includes(filter);
this.load();
}
load(): void {
this.loading = true;
this.organizationService
.getDdsApprovals('PENDING')
.pipe(catchError(() => of([])))
.subscribe((rows: any) => {
const agents = (Array.isArray(rows) ? rows : []).filter((row: any) => row?.entity === 'AGENT');
this.dataSource.data = agents.map((row: any) => ({ ...row, submittedOn: ddsMadeOn(row.madeOn) }));
this.loading = false;
});
}
applyFilter(value: string = ''): void {
this.dataSource.filter = value.trim().toLowerCase();
}
/** What the request is, as the badge reads it. */
requestLabel(row: any): string {
return row.action === 'CREATE' ? 'New' : row.action === 'CLOSE' ? 'Close' : 'Edit';
}
isMine(row: any): boolean {
return !!this.currentUserId && Number(row.makerId) === Number(this.currentUserId);
}
formatDate(date: Date | null): string {
return date ? date.toLocaleString('en-IN') : '';
}
/** Opens the agent in full - the client, address, branch, accounts and guarantors the request carries. */
view(row: any): void {
this.dialog
.open(DdsApprovalReviewComponent, {
data: { entry: row, currentUserId: this.currentUserId },
autoFocus: false
})
.afterClosed()
.subscribe((done: boolean) => {
if (done) {
this.load();
}
});
}
/**
* Whether this user may approve or reject the request. It is the same act the review and DDS
* Approvals perform, through the DDS queue, which refuses whoever made the request - so it is not
* offered to them here either.
*/
canAct(row: any): boolean {
return !this.isMine(row);
}
/**
* Approve and Reject open the agent in full rather than acting at once: a request is never actioned
* without its client, address, branch, accounts and guarantors on screen. The review carries the
* Approve and Reject buttons, and asks before rejecting.
*/
approve(row: any): void {
this.view(row);
}
reject(row: any): void {
this.view(row);
}
}
@@ -40,7 +40,7 @@ import { FaIconComponent } from '@fortawesome/angular-fontawesome';
import { MatCheckbox } from '@angular/material/checkbox'; import { MatCheckbox } from '@angular/material/checkbox';
import { DateFormatPipe } from '../../../pipes/date-format.pipe'; import { DateFormatPipe } from '../../../pipes/date-format.pipe';
import { STANDALONE_SHARED_IMPORTS } from 'app/standalone-shared.module'; import { STANDALONE_SHARED_IMPORTS } from 'app/standalone-shared.module';
import { isProductEntity, isTempTableAccountEdit } from '../../product-entities'; import { isDdsRoutedRequest, isProductEntity, isTempTableAccountEdit } from '../../product-entities';
@Component({ @Component({
selector: 'mifosx-checker-inbox', selector: 'mifosx-checker-inbox',
@@ -118,7 +118,11 @@ export class CheckerInboxComponent implements OnInit {
*/ */
private excludeRoutedElsewhere(items: any[]): any[] { private excludeRoutedElsewhere(items: any[]): any[] {
return (items || []).filter( return (items || []).filter(
(item: any) => !isProductEntity(item?.entityName) && !isTempTableAccountEdit(item?.entityName, item?.actionName) (item: any) =>
!isProductEntity(item?.entityName) &&
!isTempTableAccountEdit(item?.entityName, item?.actionName) &&
// DDS product and account requests are listed under Product Approval and Pending Account.
!isDdsRoutedRequest(item?.entityName, item?.actionName)
); );
} }
@@ -61,7 +61,10 @@
<ng-container matColumnDef="accountNo"> <ng-container matColumnDef="accountNo">
<th mat-header-cell *matHeaderCellDef>Account No</th> <th mat-header-cell *matHeaderCellDef>Account No</th>
<td mat-cell *matCellDef="let account" class="cell-mono">{{ account.accountNo }}</td> <!-- A new DDS account has no number until it is approved. -->
<td mat-cell *matCellDef="let account" class="cell-mono">
{{ account.accountNo || (account.source === 'DDS' ? 'On approval' : '') }}
</td>
</ng-container> </ng-container>
<ng-container matColumnDef="clientName"> <ng-container matColumnDef="clientName">
@@ -35,6 +35,9 @@ import { FormDialogComponent } from 'app/shared/form-dialog/form-dialog.componen
import { FormfieldBase } from 'app/shared/form-dialog/formfield/model/formfield-base'; import { FormfieldBase } from 'app/shared/form-dialog/formfield/model/formfield-base';
import { DatepickerBase } from 'app/shared/form-dialog/formfield/model/datepicker-base'; import { DatepickerBase } from 'app/shared/form-dialog/formfield/model/datepicker-base';
import { STANDALONE_SHARED_IMPORTS } from 'app/standalone-shared.module'; import { STANDALONE_SHARED_IMPORTS } from 'app/standalone-shared.module';
import { OrganizationService } from 'app/organization/organization.service';
import { AuthenticationService } from 'app/core/authentication/authentication.service';
import { DdsApprovalReviewComponent } from 'app/deposits/dds-approvals/dds-approval-review/dds-approval-review.component';
@Component({ @Component({
selector: 'mifosx-pending-account', selector: 'mifosx-pending-account',
@@ -64,6 +67,8 @@ export class PendingAccountComponent {
private dateUtils = inject(Dates); private dateUtils = inject(Dates);
private settingsService = inject(SettingsService); private settingsService = inject(SettingsService);
private tasksService = inject(TasksService); private tasksService = inject(TasksService);
private organizationService = inject(OrganizationService);
private authenticationService = inject(AuthenticationService);
dataSource: MatTableDataSource<any>; dataSource: MatTableDataSource<any>;
selection: SelectionModel<any>; selection: SelectionModel<any>;
@@ -110,7 +115,27 @@ export class PendingAccountComponent {
this.dataSource.filter = filterValue.trim().toLowerCase(); this.dataSource.filter = filterValue.trim().toLowerCase();
} }
/** A DDS account opening or edit, which waits in - and is actioned through - the DDS approval queue. */
private isDds(account: any): boolean {
return account?.source === 'DDS';
}
navigateToAccount(account: any): void { navigateToAccount(account: any): void {
if (this.isDds(account)) {
// A new DDS account has no page until it is approved, so it opens the DDS review instead.
this.dialog
.open(DdsApprovalReviewComponent, {
data: { entry: account.ddsEntry, currentUserId: this.authenticationService.getCredentials()?.userId },
autoFocus: false
})
.afterClosed()
.subscribe((done: boolean) => {
if (done) {
this.reload();
}
});
return;
}
const clientId = account.clientId; const clientId = account.clientId;
const accountId = account.id; const accountId = account.id;
const depositTypeId = account.depositType?.id; const depositTypeId = account.depositType?.id;
@@ -190,7 +215,9 @@ export class PendingAccountComponent {
selected.forEach((account: any) => { selected.forEach((account: any) => {
const depositTypeId = account.depositType?.id; const depositTypeId = account.depositType?.id;
let reject$; let reject$;
if (account.accountType === 'Current') { if (this.isDds(account)) {
reject$ = this.organizationService.actOnDdsApproval(account.id, 'reject');
} else if (account.accountType === 'Current') {
reject$ = this.tasksService.rejectCurrentAccount(account.id, payload); reject$ = this.tasksService.rejectCurrentAccount(account.id, payload);
} else if (depositTypeId === 200) { } else if (depositTypeId === 200) {
reject$ = this.tasksService.rejectFDAccount(account.id, payload); reject$ = this.tasksService.rejectFDAccount(account.id, payload);
@@ -223,6 +250,10 @@ export class PendingAccountComponent {
* approving from inside an individual account (see e.g. ApproveSavingsAccountComponent). * approving from inside an individual account (see e.g. ApproveSavingsAccountComponent).
*/ */
private approveAndActivate$(account: any, approvedOnDate: string, dateFormat: string, locale: string) { private approveAndActivate$(account: any, approvedOnDate: string, dateFormat: string, locale: string) {
// A DDS account opens active once its request is approved; there is no separate activation step.
if (this.isDds(account)) {
return this.organizationService.actOnDdsApproval(account.id, 'approve');
}
const approvePayload = { approvedOnDate, dateFormat, locale }; const approvePayload = { approvedOnDate, dateFormat, locale };
const activatePayload = { activatedOnDate: approvedOnDate, dateFormat, locale }; const activatePayload = { activatedOnDate: approvedOnDate, dateFormat, locale };
const depositTypeId = account.depositType?.id; const depositTypeId = account.depositType?.id;
@@ -35,6 +35,8 @@ import { FormfieldBase } from 'app/shared/form-dialog/formfield/model/formfield-
import { InputBase } from 'app/shared/form-dialog/formfield/model/input-base'; import { InputBase } from 'app/shared/form-dialog/formfield/model/input-base';
import { STANDALONE_SHARED_IMPORTS } from 'app/standalone-shared.module'; import { STANDALONE_SHARED_IMPORTS } from 'app/standalone-shared.module';
import { productTypeDisplayLabel } from '../../product-entities'; import { productTypeDisplayLabel } from '../../product-entities';
import { AuthenticationService } from 'app/core/authentication/authentication.service';
import { DdsApprovalReviewComponent } from 'app/deposits/dds-approvals/dds-approval-review/dds-approval-review.component';
@Component({ @Component({
selector: 'mifosx-pending-savings-products', selector: 'mifosx-pending-savings-products',
@@ -62,6 +64,7 @@ export class PendingSavingsProductsComponent {
private dialog = inject(MatDialog); private dialog = inject(MatDialog);
private productsService = inject(ProductsService); private productsService = inject(ProductsService);
private tasksService = inject(TasksService); private tasksService = inject(TasksService);
private authenticationService = inject(AuthenticationService);
dataSource: MatTableDataSource<any>; dataSource: MatTableDataSource<any>;
showData = false; showData = false;
@@ -173,6 +176,10 @@ export class PendingSavingsProductsComponent {
* renders the submitted payload and can action the entry itself. * renders the submitted payload and can action the entry itself.
*/ */
viewDiff(row: any): void { viewDiff(row: any): void {
if (this.isDds(row)) {
this.openDdsReview(row);
return;
}
if (!this.isPendingEdit(row)) { if (!this.isPendingEdit(row)) {
this.dialog this.dialog
.open(ViewCheckerInboxComponent, { .open(ViewCheckerInboxComponent, {
@@ -212,7 +219,35 @@ export class PendingSavingsProductsComponent {
this.loadingDiffFor = null; this.loadingDiffFor = null;
} }
/** A DDS product request, which is read and actioned through the DDS approval queue. */
isDds(row: any): boolean {
return row?.source === 'DDS';
}
/**
* The DDS review shows the product's terms - against today's on an edit - and approves or rejects
* it there, refusing the maker as the DDS queue does. Approve and Reject on the row open it too, so
* a DDS product is never actioned without its terms on screen.
*/
private openDdsReview(row: any): void {
this.dialog
.open(DdsApprovalReviewComponent, {
data: { entry: row.ddsEntry, currentUserId: this.authenticationService.getCredentials()?.userId },
autoFocus: false
})
.afterClosed()
.subscribe((done: boolean) => {
if (done) {
this.reload();
}
});
}
approve(row: any): void { approve(row: any): void {
if (this.isDds(row)) {
this.openDdsReview(row);
return;
}
const isEdit = this.isPendingEdit(row); const isEdit = this.isPendingEdit(row);
const dialogRef = this.dialog.open(ConfirmationDialogComponent, { const dialogRef = this.dialog.open(ConfirmationDialogComponent, {
data: { data: {
@@ -234,6 +269,10 @@ export class PendingSavingsProductsComponent {
} }
reject(row: any): void { reject(row: any): void {
if (this.isDds(row)) {
this.openDdsReview(row);
return;
}
if (!this.isPendingEdit(row)) { if (!this.isPendingEdit(row)) {
// The native /makercheckers reject endpoint takes no payload, so there is nowhere to store a // The native /makercheckers reject endpoint takes no payload, so there is nowhere to store a
// reason - ask for plain confirmation rather than collecting text that would be discarded. // reason - ask for plain confirmation rather than collecting text that would be discarded.
@@ -271,6 +271,13 @@
<span class="ac-tab__count" title="Waiting for action">{{ count('dds-approvals') }}</span> <span class="ac-tab__count" title="Waiting for action">{{ count('dds-approvals') }}</span>
} }
</a> </a>
<a class="ac-tab" routerLink="./agent-approvals" routerLinkActive="ac-tab--active">
<fa-icon icon="user-tie" class="ac-tab__ic"></fa-icon>
<span>Agent Approval</span>
@if (count('agent-approvals')) {
<span class="ac-tab__count" title="Waiting for action">{{ count('agent-approvals') }}</span>
}
</a>
<a <a
class="ac-tab" class="ac-tab"
routerLink="./chequebook-requests" routerLink="./chequebook-requests"
@@ -10,17 +10,21 @@ import { Injectable, inject } from '@angular/core';
import { Resolve } from '@angular/router'; import { Resolve } from '@angular/router';
import { Observable, forkJoin, of } from 'rxjs'; import { Observable, forkJoin, of } from 'rxjs';
import { map, catchError } from 'rxjs/operators'; import { map, catchError } from 'rxjs/operators';
import { OrganizationService } from 'app/organization/organization.service';
import { TasksService } from '../tasks.service'; import { TasksService } from '../tasks.service';
import { ddsMadeOn, isDdsAccountEntry } from '../product-entities';
@Injectable() @Injectable()
export class GetPendingAccounts implements Resolve<any> { export class GetPendingAccounts implements Resolve<any> {
private tasksService = inject(TasksService); private tasksService = inject(TasksService);
private organizationService = inject(OrganizationService);
resolve(): Observable<any> { resolve(): Observable<any> {
return forkJoin({ return forkJoin({
savings: this.tasksService.getPendingSavingsAccounts().pipe(catchError(() => of({ pageItems: [] }))), savings: this.tasksService.getPendingSavingsAccounts().pipe(catchError(() => of({ pageItems: [] }))),
current: this.tasksService.getPendingCurrentAccounts().pipe(catchError(() => of([]))), current: this.tasksService.getPendingCurrentAccounts().pipe(catchError(() => of([]))),
offices: this.tasksService.getAllOffices().pipe(catchError(() => of([]))) offices: this.tasksService.getAllOffices().pipe(catchError(() => of([]))),
dds: this.organizationService.getDdsApprovals('PENDING').pipe(catchError(() => of([])))
}).pipe( }).pipe(
map((results) => { map((results) => {
const officeMap: Record<number, string> = {}; const officeMap: Record<number, string> = {};
@@ -39,9 +43,34 @@ export class GetPendingAccounts implements Resolve<any> {
(a: any) => ({ ...a, accountType: 'Current' }) (a: any) => ({ ...a, accountType: 'Current' })
); );
/*
* DDS account openings and edits wait in the DDS approval queue rather than as submitted
* accounts, so they are laid onto the same columns here. The status filter in the component
* reads status.id 100, which these carry so they are kept like any other pending account.
*/
const ddsItems = (Array.isArray(results.dds) ? results.dds : [])
.filter((entry: any) => isDdsAccountEntry(entry))
.map((entry: any) => ({
id: entry.id,
source: 'DDS',
ddsEntry: entry,
accountNo: entry.accountNo || '',
clientName: entry.clientName || entry.details,
productName: entry.productName,
accountType: entry.action === 'UPDATE' ? 'DDS · Edit' : 'DDS',
officeName: entry.officeName || '',
fieldOfficerName: entry.agentName || '',
timeline: { submittedOnDate: ddsMadeOn(entry.madeOn) },
status: {
id: 100,
value: entry.action === 'UPDATE' ? 'Edit pending approval' : 'Submitted and pending approval'
}
}));
return { pageItems: [ return { pageItems: [
...savingsItems, ...savingsItems,
...currentItems ...currentItems,
...ddsItems
] }; ] };
}) })
); );
@@ -11,8 +11,15 @@ import { Resolve } from '@angular/router';
import { Observable, forkJoin, of } from 'rxjs'; import { Observable, forkJoin, of } from 'rxjs';
import { catchError, map } from 'rxjs/operators'; import { catchError, map } from 'rxjs/operators';
import { ProductsService } from 'app/products/products.service'; import { ProductsService } from 'app/products/products.service';
import { OrganizationService } from 'app/organization/organization.service';
import { TasksService } from '../tasks.service'; import { TasksService } from '../tasks.service';
import { isProductEntity, isTempTableProductEdit, PRODUCT_ENTITY_TYPE_LABEL } from '../product-entities'; import {
ddsMadeOn,
isDdsProductEntry,
isProductEntity,
isTempTableProductEdit,
PRODUCT_ENTITY_TYPE_LABEL
} from '../product-entities';
/** /**
* Builds the Approvals -> Product Approval list from the two pending-request sources the platform * Builds the Approvals -> Product Approval list from the two pending-request sources the platform
@@ -32,6 +39,7 @@ import { isProductEntity, isTempTableProductEdit, PRODUCT_ENTITY_TYPE_LABEL } fr
export class GetPendingSavingsProducts implements Resolve<any> { export class GetPendingSavingsProducts implements Resolve<any> {
private productsService = inject(ProductsService); private productsService = inject(ProductsService);
private tasksService = inject(TasksService); private tasksService = inject(TasksService);
private organizationService = inject(OrganizationService);
resolve(): Observable<any> { resolve(): Observable<any> {
return forkJoin({ return forkJoin({
@@ -42,7 +50,8 @@ export class GetPendingSavingsProducts implements Resolve<any> {
rd: this.productsService.getPendingRecurringDepositProductEdits().pipe(catchError(() => of([]))), rd: this.productsService.getPendingRecurringDepositProductEdits().pipe(catchError(() => of([]))),
// includeJson so a CREATE row - which has no resourceId, and therefore no product to look up - // includeJson so a CREATE row - which has no resourceId, and therefore no product to look up -
// can still show the submitted product name. // can still show the submitted product name.
makerChecker: this.tasksService.getMakerCheckerData({ includeJson: true }).pipe(catchError(() => of([]))) makerChecker: this.tasksService.getMakerCheckerData({ includeJson: true }).pipe(catchError(() => of([]))),
dds: this.organizationService.getDdsApprovals('PENDING').pipe(catchError(() => of([])))
}).pipe( }).pipe(
map((results) => [ map((results) => [
...this.asEditRows(results.savings, 'SAVINGS'), ...this.asEditRows(results.savings, 'SAVINGS'),
@@ -50,11 +59,31 @@ export class GetPendingSavingsProducts implements Resolve<any> {
...this.asEditRows(results.share, 'SHARE'), ...this.asEditRows(results.share, 'SHARE'),
...this.asEditRows(results.fd, 'FD'), ...this.asEditRows(results.fd, 'FD'),
...this.asEditRows(results.rd, 'RD'), ...this.asEditRows(results.rd, 'RD'),
...this.asMakerCheckerRows(results.makerChecker) ...this.asMakerCheckerRows(results.makerChecker),
...this.asDdsRows(results.dds)
]) ])
); );
} }
/** DDS product requests, from the DDS queue - which is also how they are viewed and actioned. */
private asDdsRows(entries: any): any[] {
return (Array.isArray(entries) ? entries : [])
.filter((entry: any) => isDdsProductEntry(entry))
.map((entry: any) => ({
id: entry.id,
source: 'DDS',
entityName: entry.entity,
actionName: entry.action,
productType: 'DDS',
savingsProductId: entry.resourceId ?? null,
savingsProductName: entry.details || entry.type,
makerUsername: entry.maker,
createdOn: ddsMadeOn(entry.madeOn),
status: 'PENDING',
ddsEntry: entry
}));
}
/** Staged product edits, unchanged apart from the source/action tags the merged table needs. */ /** Staged product edits, unchanged apart from the source/action tags the merged table needs. */
private asEditRows(edits: any[], productType: string): any[] { private asEditRows(edits: any[], productType: string): any[] {
return (edits || []).map((edit: any) => ({ return (edits || []).map((edit: any) => ({
+49 -1
View File
@@ -56,6 +56,53 @@ export const TEMP_TABLE_ACCOUNT_ENTITIES: readonly string[] = [
'CURRENTACCOUNT' 'CURRENTACCOUNT'
]; ];
/**
* DDS keeps its own approval queue (/dds/approvals), which also describes each entry in the clerk's
* terms. Its product and account requests are listed in Product Approval and Pending Account as well,
* so a checker finds them where every other product and account waits - but they are read from, and
* actioned through, that DDS queue rather than /makercheckers.
*/
export function isDdsProductEntry(entry: any): boolean {
return isDdsRoutedRequest(entry?.entity, entry?.action) && entry?.entity === 'DDSACCOUNTTYPE';
}
export function isDdsAccountEntry(entry: any): boolean {
return isDdsRoutedRequest(entry?.entity, entry?.action) && entry?.entity === 'PARTY';
}
/**
* Whether a command is a DDS product or DDS account create/edit - one listed under Product Approval or
* Pending Account, and so left out of DDS Approvals and the Checker Inbox, which would otherwise show
* the same request a second and third time.
*/
export function isDdsRoutedRequest(
entityName: string | undefined | null,
actionName: string | undefined | null
): boolean {
const entity = (entityName ?? '').toUpperCase();
// An agent request of any kind waits under Agent Approval: an agent has no voucher and no amount,
// so the money columns of DDS Approvals say nothing about one.
if (entity === 'AGENT') {
return true;
}
return [
'DDSACCOUNTTYPE',
'PARTY'
].includes(entity) && [
'CREATE',
'UPDATE'
].includes((actionName ?? '').toUpperCase());
}
/** The DDS queue's "2026-10-27 11:04" as a Date, for the tables' date columns. */
export function ddsMadeOn(madeOn: string | undefined | null): Date | null {
if (!madeOn) {
return null;
}
const d = new Date(String(madeOn).replace(' ', 'T'));
return isNaN(d.getTime()) ? null : d;
}
/** Short type badge shown in the Product Approval table, matching the pending-edit productType tags. */ /** Short type badge shown in the Product Approval table, matching the pending-edit productType tags. */
export const PRODUCT_ENTITY_TYPE_LABEL: Record<string, string> = { export const PRODUCT_ENTITY_TYPE_LABEL: Record<string, string> = {
LOANPRODUCT: 'LOAN', LOANPRODUCT: 'LOAN',
@@ -111,7 +158,8 @@ export const PRODUCT_TYPE_DISPLAY_LABEL: Record<string, string> = {
FD: 'FIXED DEPOSIT PRODUCT', FD: 'FIXED DEPOSIT PRODUCT',
'FIXED DEPOSIT': 'FIXED DEPOSIT PRODUCT', 'FIXED DEPOSIT': 'FIXED DEPOSIT PRODUCT',
RD: 'RECURRING DEPOSIT PRODUCT', RD: 'RECURRING DEPOSIT PRODUCT',
'RECURRING DEPOSIT': 'RECURRING DEPOSIT PRODUCT' 'RECURRING DEPOSIT': 'RECURRING DEPOSIT PRODUCT',
DDS: 'DDS PRODUCT'
}; };
/** Label shown in the Product Approval Type column. Unmapped types render unchanged. */ /** Label shown in the Product Approval Type column. Unmapped types render unchanged. */
+6
View File
@@ -56,6 +56,7 @@ import { GetPendingCurrentAccountEdits } from './common-resolvers/getPendingCurr
import { GetPendingShareAccountEdits } from './common-resolvers/getPendingShareAccountEdits.resolver'; import { GetPendingShareAccountEdits } from './common-resolvers/getPendingShareAccountEdits.resolver';
import { GetPendingShareAccounts } from './common-resolvers/getPendingShareAccounts.resolver'; import { GetPendingShareAccounts } from './common-resolvers/getPendingShareAccounts.resolver';
import { DdsApprovalsComponent } from '../deposits/dds-approvals/dds-approvals.component'; import { DdsApprovalsComponent } from '../deposits/dds-approvals/dds-approvals.component';
import { AgentApprovalsComponent } from './checker-inbox-and-tasks-tabs/agent-approvals/agent-approvals.component';
/** Tasks Routes */ /** Tasks Routes */
const routes: Routes = [ const routes: Routes = [
@@ -235,6 +236,11 @@ const routes: Routes = [
component: DdsApprovalsComponent, component: DdsApprovalsComponent,
data: { title: 'DDS Approvals' } data: { title: 'DDS Approvals' }
}, },
{
path: 'agent-approvals',
component: AgentApprovalsComponent,
data: { title: 'Agent Approval' }
},
{ {
path: 'chequebook-requests', path: 'chequebook-requests',
component: ChequebookRequestsComponent, component: ChequebookRequestsComponent,