Compare commits
5
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
c766a6f092 | ||
|
|
0275a46326 | ||
|
|
306af59099 | ||
|
|
53bb97d6ed | ||
|
|
293b671966 |
@@ -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,18 +96,22 @@ 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(
|
|
||||||
(event) => {
|
|
||||||
if (environment.httpCacheEnabled && event instanceof HttpResponse) {
|
|
||||||
this.httpCacheService.setCacheData(request.urlWithParams, event);
|
|
||||||
}
|
|
||||||
subscriber.next(event);
|
|
||||||
},
|
|
||||||
(error) => subscriber.error(error),
|
|
||||||
() => subscriber.complete()
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
const inner = next.handle(request).subscribe(
|
||||||
|
(event) => {
|
||||||
|
if (environment.httpCacheEnabled && event instanceof HttpResponse) {
|
||||||
|
this.httpCacheService.setCacheData(request.urlWithParams, event);
|
||||||
|
}
|
||||||
|
subscriber.next(event);
|
||||||
|
},
|
||||||
|
(error) => subscriber.error(error),
|
||||||
|
() => 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();
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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. */
|
||||||
|
|||||||
+77
-3
@@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
+78
@@ -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>
|
||||||
+235
@@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
+93
@@ -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>
|
||||||
<h2 class="dte-header__title">Daily Transaction Detail</h2>
|
<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>
|
||||||
|
<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>
|
||||||
<dt>Guarantor</dt>
|
<!-- One line for each guarantor the agent actually has - not two fixed lines, one of them empty. -->
|
||||||
<dd>{{ agentInfo.guarantor1 || '—' }}</dd>
|
@for (guarantor of agentInfo.guarantors; track $index; let i = $index) {
|
||||||
<dt>Guarantor</dt>
|
<dt>{{ agentInfo.guarantors.length > 1 ? 'Guarantor ' + (i + 1) : 'Guarantor' }}</dt>
|
||||||
<dd>{{ agentInfo.guarantor2 || '—' }}</dd>
|
<dd>{{ guarantor }}</dd>
|
||||||
|
} @empty {
|
||||||
|
<dt>Guarantor</dt>
|
||||||
|
<dd>—</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.
|
@if (isAgentTran && agentInfo.name) {
|
||||||
|
<div class="dte-agent-actions">
|
||||||
This used to be an empty grid the clerk typed the round into, and typing it posted it: a
|
<button mat-stroked-button type="button" class="dte-refresh-btn" (click)="loadAgentMoney()">
|
||||||
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
|
<i class="fas fa-sync"></i> Refresh
|
||||||
</button>
|
</button>
|
||||||
</div>
|
<button mat-raised-button color="primary" type="button" class="dte-apply-btn" (click)="openApplyReceipt()">
|
||||||
|
|
||||||
@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) {
|
|
||||||
<div class="dte-section">
|
|
||||||
<div class="dte-section__header">
|
|
||||||
<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
|
|
||||||
</button>
|
|
||||||
<button
|
|
||||||
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,84 +268,102 @@
|
|||||||
<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">
|
||||||
<mat-form-field appearance="outline" class="dte-field">
|
<!-- The account the money goes into. -->
|
||||||
<mat-label>Creditor Party Code</mat-label>
|
<div class="dte-party-card dte-party-card--to">
|
||||||
<input
|
<div class="dte-party-card__title"><i class="fas fa-arrow-circle-down"></i>Money goes into</div>
|
||||||
matInput
|
<div class="dte-party-card__fields">
|
||||||
formControlName="creditorPartyCode"
|
<mat-form-field appearance="outline" class="dte-field">
|
||||||
[matAutocomplete]="creditorAuto"
|
<mat-label>Creditor Party Code</mat-label>
|
||||||
#creditorTrigger="matAutocompleteTrigger"
|
<input
|
||||||
(click)="creditorTrigger.openPanel()"
|
matInput
|
||||||
(focus)="creditorTrigger.openPanel()"
|
formControlName="creditorPartyCode"
|
||||||
(blur)="settleTransferParty('creditor')"
|
[matAutocomplete]="creditorAuto"
|
||||||
placeholder="Search by code or name…"
|
#creditorTrigger="matAutocompleteTrigger"
|
||||||
autocomplete="off"
|
(click)="creditorTrigger.openPanel()"
|
||||||
/>
|
(focus)="creditorTrigger.openPanel()"
|
||||||
<mat-icon matSuffix class="dte-list-arrow" (click)="$event.stopPropagation(); creditorTrigger.openPanel()"
|
(blur)="settleTransferParty('creditor')"
|
||||||
>arrow_drop_down</mat-icon
|
placeholder="Search by code or name…"
|
||||||
>
|
autocomplete="off"
|
||||||
<mat-autocomplete #creditorAuto="matAutocomplete" panelWidth="min(30rem, 92vw)">
|
/>
|
||||||
@for (party of transferPartyOptions('creditor'); track party.id) {
|
<mat-icon
|
||||||
<mat-option [value]="party.partyCode">
|
matSuffix
|
||||||
{{ party.partyCode }} · {{ party.partyName }} · ₹{{ party.currentBalance | number: '1.2-2' }}
|
class="dte-list-arrow"
|
||||||
</mat-option>
|
(click)="$event.stopPropagation(); creditorTrigger.openPanel()"
|
||||||
}
|
>arrow_drop_down</mat-icon
|
||||||
@if (transferPartyOptions('creditor').length === 0) {
|
>
|
||||||
<mat-option disabled>No open DDS account matches</mat-option>
|
<mat-autocomplete #creditorAuto="matAutocomplete" panelWidth="min(30rem, 92vw)">
|
||||||
}
|
@for (party of transferPartyOptions('creditor'); track party.id) {
|
||||||
</mat-autocomplete>
|
<mat-option [value]="party.partyCode">
|
||||||
<mat-hint>Account the money goes into</mat-hint>
|
{{ party.partyCode }} · {{ party.partyName }} · ₹{{ party.currentBalance | number: '1.2-2' }}
|
||||||
</mat-form-field>
|
</mat-option>
|
||||||
<mat-form-field appearance="outline" class="dte-field">
|
}
|
||||||
<mat-label>Creditor Party Name</mat-label>
|
@if (transferPartyOptions('creditor').length === 0) {
|
||||||
<input matInput formControlName="creditorPartyName" readonly />
|
<mat-option disabled>No open DDS account matches</mat-option>
|
||||||
</mat-form-field>
|
}
|
||||||
<mat-form-field appearance="outline" class="dte-field">
|
</mat-autocomplete>
|
||||||
<mat-label>Creditor Curr Bal</mat-label>
|
<mat-hint>Account the money goes into</mat-hint>
|
||||||
<input matInput formControlName="creditorPartyBalance" readonly />
|
</mat-form-field>
|
||||||
</mat-form-field>
|
<mat-form-field appearance="outline" class="dte-field">
|
||||||
|
<mat-label>Creditor Party Name</mat-label>
|
||||||
|
<input matInput formControlName="creditorPartyName" readonly />
|
||||||
|
</mat-form-field>
|
||||||
|
<mat-form-field appearance="outline" class="dte-field">
|
||||||
|
<mat-label>Creditor Curr Bal</mat-label>
|
||||||
|
<input matInput formControlName="creditorPartyBalance" readonly />
|
||||||
|
</mat-form-field>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<mat-form-field appearance="outline" class="dte-field">
|
<!-- The account the money is taken from. -->
|
||||||
<mat-label>Debit Party Code</mat-label>
|
<div class="dte-party-card dte-party-card--from">
|
||||||
<input
|
<div class="dte-party-card__title"><i class="fas fa-arrow-circle-up"></i>Money is taken from</div>
|
||||||
matInput
|
<div class="dte-party-card__fields">
|
||||||
formControlName="debitPartyCode"
|
<mat-form-field appearance="outline" class="dte-field">
|
||||||
[matAutocomplete]="debitAuto"
|
<mat-label>Debit Party Code</mat-label>
|
||||||
#debitTrigger="matAutocompleteTrigger"
|
<input
|
||||||
(click)="debitTrigger.openPanel()"
|
matInput
|
||||||
(focus)="debitTrigger.openPanel()"
|
formControlName="debitPartyCode"
|
||||||
(blur)="settleTransferParty('debit')"
|
[matAutocomplete]="debitAuto"
|
||||||
placeholder="Search by code or name…"
|
#debitTrigger="matAutocompleteTrigger"
|
||||||
autocomplete="off"
|
(click)="debitTrigger.openPanel()"
|
||||||
/>
|
(focus)="debitTrigger.openPanel()"
|
||||||
<mat-icon matSuffix class="dte-list-arrow" (click)="$event.stopPropagation(); debitTrigger.openPanel()"
|
(blur)="settleTransferParty('debit')"
|
||||||
>arrow_drop_down</mat-icon
|
placeholder="Search by code or name…"
|
||||||
>
|
autocomplete="off"
|
||||||
<mat-autocomplete #debitAuto="matAutocomplete" panelWidth="min(30rem, 92vw)">
|
/>
|
||||||
@for (party of transferPartyOptions('debit'); track party.id) {
|
<mat-icon
|
||||||
<mat-option [value]="party.partyCode">
|
matSuffix
|
||||||
{{ party.partyCode }} · {{ party.partyName }} · ₹{{ party.currentBalance | number: '1.2-2' }}
|
class="dte-list-arrow"
|
||||||
</mat-option>
|
(click)="$event.stopPropagation(); debitTrigger.openPanel()"
|
||||||
}
|
>arrow_drop_down</mat-icon
|
||||||
@if (transferPartyOptions('debit').length === 0) {
|
>
|
||||||
<mat-option disabled>No open DDS account matches</mat-option>
|
<mat-autocomplete #debitAuto="matAutocomplete" panelWidth="min(30rem, 92vw)">
|
||||||
}
|
@for (party of transferPartyOptions('debit'); track party.id) {
|
||||||
</mat-autocomplete>
|
<mat-option [value]="party.partyCode">
|
||||||
@if (transferToItself) {
|
{{ party.partyCode }} · {{ party.partyName }} · ₹{{ party.currentBalance | number: '1.2-2' }}
|
||||||
<mat-hint class="dte-same-party">Pick a different account - it cannot transfer to itself</mat-hint>
|
</mat-option>
|
||||||
} @else {
|
}
|
||||||
<mat-hint>Account the money is taken from</mat-hint>
|
@if (transferPartyOptions('debit').length === 0) {
|
||||||
}
|
<mat-option disabled>No open DDS account matches</mat-option>
|
||||||
</mat-form-field>
|
}
|
||||||
<mat-form-field appearance="outline" class="dte-field">
|
</mat-autocomplete>
|
||||||
<mat-label>Debit Party Name</mat-label>
|
@if (transferToItself) {
|
||||||
<input matInput formControlName="debitPartyName" readonly />
|
<mat-hint class="dte-same-party">Pick a different account - it cannot transfer to itself</mat-hint>
|
||||||
</mat-form-field>
|
} @else {
|
||||||
<mat-form-field appearance="outline" class="dte-field">
|
<mat-hint>Account the money is taken from</mat-hint>
|
||||||
<mat-label>Debit Curr Bal</mat-label>
|
}
|
||||||
<input matInput formControlName="debitPartyBalance" readonly />
|
</mat-form-field>
|
||||||
</mat-form-field>
|
<mat-form-field appearance="outline" class="dte-field">
|
||||||
|
<mat-label>Debit Party Name</mat-label>
|
||||||
|
<input matInput formControlName="debitPartyName" readonly />
|
||||||
|
</mat-form-field>
|
||||||
|
<mat-form-field appearance="outline" class="dte-field">
|
||||||
|
<mat-label>Debit Curr Bal</mat-label>
|
||||||
|
<input matInput formControlName="debitPartyBalance" readonly />
|
||||||
|
</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);
|
||||||
}
|
}
|
||||||
|
|||||||
+33
@@ -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;
|
||||||
|
}
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@@ -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, ' ');
|
||||||
}
|
}
|
||||||
|
|||||||
+144
@@ -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 & 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>
|
||||||
+167
@@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
+109
@@ -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();
|
||||||
|
}
|
||||||
|
}
|
||||||
+526
-51
@@ -19,79 +19,551 @@
|
|||||||
<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">
|
||||||
<!-- Header card: the voucher it moves under, its status, who made it and when -->
|
@if (isProductReview) {
|
||||||
<div class="dri-card dri-card--header">
|
<!--
|
||||||
<div class="dri-card-header">
|
A DDS product request reads as the product it creates or changes: who and what at the top,
|
||||||
<div class="dri-card-header__left">
|
the four terms a checker looks at first, then every term grouped as the Account Type screen
|
||||||
<span class="dri-voucher">{{ voucherNo || '—' }}</span>
|
asks for them. An edit shows each changed term as old → new.
|
||||||
<span class="dri-status" [ngClass]="statusClass(entry.status)">
|
-->
|
||||||
{{ entry.status }}{{ entry.voucherCancelled ? ' · VOUCHER CANCELLED' : '' }}
|
<section class="ddr-hero">
|
||||||
</span>
|
<div class="ddr-hero__icon">
|
||||||
|
<mat-icon>{{ isEdit ? 'edit_note' : 'savings' }}</mat-icon>
|
||||||
</div>
|
</div>
|
||||||
<div class="dri-card-header__meta">
|
<div class="ddr-hero__main">
|
||||||
<span class="dri-meta-item">
|
<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>
|
<mat-icon>person</mat-icon>
|
||||||
{{ entry.makerName || entry.maker }} ({{ entry.maker }})
|
<span>
|
||||||
|
<b>{{ entry.makerName || entry.maker }}</b>
|
||||||
|
<small>{{ entry.maker }}</small>
|
||||||
|
</span>
|
||||||
</span>
|
</span>
|
||||||
<span class="dri-meta-item">
|
<span class="ddr-meta">
|
||||||
<mat-icon>schedule</mat-icon>
|
<mat-icon>schedule</mat-icon>
|
||||||
{{ formatDateTime(entry.madeOn) }}
|
<span>
|
||||||
|
<b>{{ formatDateTime(entry.madeOn) }}</b>
|
||||||
|
<small>Submitted</small>
|
||||||
|
</span>
|
||||||
</span>
|
</span>
|
||||||
@if (entry.status !== 'PENDING' && entry.checker) {
|
@if (entry.status !== 'PENDING' && entry.checker) {
|
||||||
<span class="dri-meta-item">
|
<span class="ddr-meta">
|
||||||
<mat-icon>check_circle</mat-icon>
|
<mat-icon>verified_user</mat-icon>
|
||||||
Actioned by: {{ entry.checkerName || entry.checker }}
|
<span>
|
||||||
|
<b>{{ entry.checkerName || entry.checker }}</b>
|
||||||
|
<small>{{ entry.status === 'APPROVED' ? 'Approved by' : 'Rejected by' }}</small>
|
||||||
|
</span>
|
||||||
</span>
|
</span>
|
||||||
}
|
}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</section>
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Summary -->
|
<!-- The terms a checker reads first. -->
|
||||||
<div class="dri-card">
|
<section class="ddr-stats">
|
||||||
<div class="dri-section-title">
|
<div class="ddr-stat ddr-stat--accent">
|
||||||
<mat-icon>swap_horiz</mat-icon>
|
<span class="ddr-stat__label"><mat-icon>percent</mat-icon>Interest Rate</span>
|
||||||
{{ entry.type }} Summary
|
<span class="ddr-stat__value">{{ valueOf('interestRate') || '—' }}</span>
|
||||||
</div>
|
<span class="ddr-stat__hint">p.a., worked out daily</span>
|
||||||
<div class="dri-detail-grid">
|
</div>
|
||||||
<div class="dri-detail-item">
|
<div class="ddr-stat">
|
||||||
<span class="dri-detail-label">Total Amount</span>
|
<span class="ddr-stat__label"><mat-icon>event_repeat</mat-icon>Interest Posting</span>
|
||||||
<span class="dri-detail-value dri-amount">
|
<span class="ddr-stat__value">
|
||||||
{{ entry.amount !== null && entry.amount !== undefined ? '₹' + (entry.amount | number: '1.2-2') : '—' }}
|
{{ 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>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
<div class="dri-detail-item">
|
<div class="ddr-stat">
|
||||||
<span class="dri-detail-label">DR Accounts</span>
|
<span class="ddr-stat__label"><mat-icon>lock_clock</mat-icon>Lock-in</span>
|
||||||
<span class="dri-detail-value"
|
<span class="ddr-stat__value">{{ valueOf('prematureLockinMonths') || '—' }}</span>
|
||||||
>{{ debitLines.length }} account{{ debitLines.length !== 1 ? 's' : '' }}</span
|
<span class="ddr-stat__hint">before early closure</span>
|
||||||
>
|
|
||||||
</div>
|
</div>
|
||||||
<div class="dri-detail-item">
|
<div class="ddr-stat">
|
||||||
<span class="dri-detail-label">CR Accounts</span>
|
<span class="ddr-stat__label"><mat-icon>event_available</mat-icon>Maturity</span>
|
||||||
<span class="dri-detail-value"
|
<span class="ddr-stat__value">{{
|
||||||
>{{ creditLines.length }} account{{ creditLines.length !== 1 ? 's' : '' }}</span
|
valueOf('maturityAutoClose') === 'Yes' ? 'Auto close' : 'Manual payout'
|
||||||
>
|
}}</span>
|
||||||
|
<span class="ddr-stat__hint">on the maturity date</span>
|
||||||
</div>
|
</div>
|
||||||
<div class="dri-detail-item dri-detail-item--full">
|
</section>
|
||||||
<span class="dri-detail-label">Details</span>
|
|
||||||
<span class="dri-detail-value">{{ entry.details || '—' }}</span>
|
@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>
|
</div>
|
||||||
@if (detail?.description) {
|
}
|
||||||
<div class="dri-detail-item dri-detail-item--full">
|
|
||||||
<span class="dri-detail-label">Description</span>
|
@for (group of fieldGroups; track group.name) {
|
||||||
<span class="dri-detail-value">{{ detail.description }}</span>
|
<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>
|
</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 -->
|
||||||
|
<div class="dri-card dri-card--header">
|
||||||
|
<div class="dri-card-header">
|
||||||
|
<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-status" [ngClass]="statusClass(entry.status)">
|
||||||
|
{{ entry.status }}{{ entry.voucherCancelled ? ' · VOUCHER CANCELLED' : '' }}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div class="dri-card-header__meta">
|
||||||
|
<span class="dri-meta-item">
|
||||||
|
<mat-icon>person</mat-icon>
|
||||||
|
{{ entry.makerName || entry.maker }} ({{ entry.maker }})
|
||||||
|
</span>
|
||||||
|
<span class="dri-meta-item">
|
||||||
|
<mat-icon>schedule</mat-icon>
|
||||||
|
{{ formatDateTime(entry.madeOn) }}
|
||||||
|
</span>
|
||||||
|
@if (entry.status !== 'PENDING' && entry.checker) {
|
||||||
|
<span class="dri-meta-item">
|
||||||
|
<mat-icon>check_circle</mat-icon>
|
||||||
|
Actioned by: {{ entry.checkerName || entry.checker }}
|
||||||
|
</span>
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
|
||||||
|
<!-- Summary -->
|
||||||
|
<div class="dri-card">
|
||||||
|
<div class="dri-section-title">
|
||||||
|
<mat-icon>swap_horiz</mat-icon>
|
||||||
|
{{ entry.type }} Summary
|
||||||
|
</div>
|
||||||
|
<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">
|
||||||
|
<span class="dri-detail-label">Total Amount</span>
|
||||||
|
<span class="dri-detail-value dri-amount">
|
||||||
|
{{
|
||||||
|
entry.amount !== null && entry.amount !== undefined ? '₹' + (entry.amount | number: '1.2-2') : '—'
|
||||||
|
}}
|
||||||
|
</span>
|
||||||
|
</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">
|
||||||
|
<span class="dri-detail-label">{{
|
||||||
|
entriesProjected ? 'DR Accounts (when posted)' : 'DR Accounts'
|
||||||
|
}}</span>
|
||||||
|
<span class="dri-detail-value"
|
||||||
|
>{{ debitLines.length }} account{{ debitLines.length !== 1 ? 's' : '' }}</span
|
||||||
|
>
|
||||||
|
</div>
|
||||||
|
<div class="dri-detail-item">
|
||||||
|
<span class="dri-detail-label">{{
|
||||||
|
entriesProjected ? 'CR Accounts (when posted)' : 'CR Accounts'
|
||||||
|
}}</span>
|
||||||
|
<span class="dri-detail-value"
|
||||||
|
>{{ creditLines.length }} account{{ creditLines.length !== 1 ? 's' : '' }}</span
|
||||||
|
>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
<div class="dri-detail-item dri-detail-item--full">
|
||||||
|
<span class="dri-detail-label">Details</span>
|
||||||
|
<span class="dri-detail-value">{{ entry.details || '—' }}</span>
|
||||||
|
</div>
|
||||||
|
@if (detail?.description) {
|
||||||
|
<div class="dri-detail-item dri-detail-item--full">
|
||||||
|
<span class="dri-detail-label">Description</span>
|
||||||
|
<span class="dri-detail-value">{{ detail.description }}</span>
|
||||||
|
</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>
|
||||||
|
|||||||
+563
@@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
+127
-1
@@ -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>
|
||||||
<h2 class="pm-header__title">{{ partyId ? 'Edit Party' : 'Party Master Entry' }}</h2>
|
<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>
|
||||||
|
<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 & 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 & 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 & 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 & 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>
|
||||||
|
|
||||||
@if (creditToRequired) {
|
<!-- Only when the account is paid out into a savings account - as the section always was. -->
|
||||||
|
@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,139 +941,172 @@
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
@if (nomineesRequired) {
|
@if (nomineesRequired) {
|
||||||
<mat-radio-group formControlName="nomineeSplitMode" class="pm-radio-group">
|
<div class="pm-f pm-nom-bar__mode">
|
||||||
<mat-radio-button value="Percentage">Percentage</mat-radio-button>
|
<label class="pm-f-lbl">Split by</label>
|
||||||
<mat-radio-button value="Sequence">Sequence</mat-radio-button>
|
<mat-radio-group formControlName="nomineeSplitMode" class="pm-nom-mode">
|
||||||
</mat-radio-group>
|
<mat-radio-button value="Percentage">Percentage</mat-radio-button>
|
||||||
|
<mat-radio-button value="Sequence">Sequence</mat-radio-button>
|
||||||
<button mat-stroked-button type="button" class="pm-add-nominee" (click)="addNominee()">
|
</mat-radio-group>
|
||||||
<i class="fas fa-plus"></i> Add Nominee
|
</div>
|
||||||
</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">
|
||||||
@if (nomineeShareError) {
|
<strong>{{ nomineeShareTotal }}%</strong> of 100% allocated
|
||||||
— {{ nomineeShareError }}
|
@if (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">
|
||||||
@if (nomineeOrderError) {
|
<div class="pm-nom-total__text">
|
||||||
{{ nomineeOrderError }}
|
@if (nomineeOrderError) {
|
||||||
} @else {
|
{{ nomineeOrderError }}
|
||||||
Paid in the order numbered, 1 first
|
} @else {
|
||||||
}
|
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>
|
||||||
<mat-form-field appearance="outline" class="pm-field">
|
<span class="pm-nom-card__title">Nominee {{ i + 1 }}</span>
|
||||||
<input matInput required [matDatepicker]="nomDatePicker" formControlName="nominationDate" />
|
@if (isNomineeMinor(nominee)) {
|
||||||
<mat-datepicker-toggle matIconSuffix [for]="nomDatePicker"></mat-datepicker-toggle>
|
<span class="pm-nom-card__badge">Minor</span>
|
||||||
<mat-datepicker #nomDatePicker></mat-datepicker>
|
}
|
||||||
</mat-form-field>
|
<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>
|
||||||
<div class="pm-f">
|
|
||||||
<label class="pm-f-lbl">Name<b>*</b></label>
|
<div class="pm-nom-card__grid">
|
||||||
<mat-form-field appearance="outline" class="pm-field">
|
<div class="pm-f">
|
||||||
<input matInput required formControlName="name" />
|
<label class="pm-f-lbl">Nomination Date<b>*</b></label>
|
||||||
</mat-form-field>
|
|
||||||
</div>
|
|
||||||
<div class="pm-f">
|
|
||||||
<label class="pm-f-lbl">Mobile No</label>
|
|
||||||
<mat-form-field appearance="outline" class="pm-field">
|
|
||||||
<input matInput formControlName="mobileNo" mifosxRestrictInput="digits" />
|
|
||||||
</mat-form-field>
|
|
||||||
</div>
|
|
||||||
<div class="pm-f pm-f--date">
|
|
||||||
<label class="pm-f-lbl">Birth Dt</label>
|
|
||||||
<mat-form-field appearance="outline" class="pm-field">
|
|
||||||
<input matInput [matDatepicker]="nomBirthPicker" formControlName="birthDate" />
|
|
||||||
<mat-datepicker-toggle matIconSuffix [for]="nomBirthPicker"></mat-datepicker-toggle>
|
|
||||||
<mat-datepicker #nomBirthPicker></mat-datepicker>
|
|
||||||
</mat-form-field>
|
|
||||||
</div>
|
|
||||||
<div class="pm-f pm-f--tight">
|
|
||||||
<!-- Read from the date of birth, not asked: the two arriving separately is how
|
|
||||||
a ten-year-old came to be recorded as not a minor. -->
|
|
||||||
<label class="pm-f-lbl">Minor</label>
|
|
||||||
<mat-form-field appearance="outline" class="pm-field">
|
|
||||||
<input matInput readonly [value]="isNomineeMinor(nominee) ? 'Yes' : 'No'" />
|
|
||||||
<mat-hint>From date of birth</mat-hint>
|
|
||||||
</mat-form-field>
|
|
||||||
</div>
|
|
||||||
<div class="pm-f">
|
|
||||||
<label class="pm-f-lbl">Relation<b>*</b></label>
|
|
||||||
<mat-form-field appearance="outline" class="pm-field">
|
|
||||||
<input matInput required formControlName="relation" />
|
|
||||||
</mat-form-field>
|
|
||||||
</div>
|
|
||||||
<div class="pm-f pm-f--tight">
|
|
||||||
<label class="pm-f-lbl">Gender<b>*</b></label>
|
|
||||||
<mat-form-field appearance="outline" class="pm-field">
|
|
||||||
<mat-select required formControlName="gender">
|
|
||||||
<mat-option value="Male">Male</mat-option>
|
|
||||||
<mat-option value="Female">Female</mat-option>
|
|
||||||
<mat-option value="Other">Other</mat-option>
|
|
||||||
</mat-select>
|
|
||||||
</mat-form-field>
|
|
||||||
</div>
|
|
||||||
<div class="pm-f">
|
|
||||||
<label class="pm-f-lbl">
|
|
||||||
Guar. Name
|
|
||||||
@if (isNomineeMinor(nominee)) {
|
|
||||||
<b>*</b>
|
|
||||||
}
|
|
||||||
</label>
|
|
||||||
<mat-form-field appearance="outline" class="pm-field">
|
|
||||||
<input matInput [required]="isNomineeMinor(nominee)" formControlName="guardianName" />
|
|
||||||
@if (isNomineeMinor(nominee)) {
|
|
||||||
<mat-hint>Required for a minor</mat-hint>
|
|
||||||
}
|
|
||||||
</mat-form-field>
|
|
||||||
</div>
|
|
||||||
@if (splitByPercentage) {
|
|
||||||
<div class="pm-f pm-f--tight">
|
|
||||||
<label class="pm-f-lbl">Share %<b>*</b></label>
|
|
||||||
<mat-form-field appearance="outline" class="pm-field">
|
<mat-form-field appearance="outline" class="pm-field">
|
||||||
<input
|
<input matInput required [matDatepicker]="nomDatePicker" formControlName="nominationDate" />
|
||||||
matInput
|
<mat-datepicker-toggle matIconSuffix [for]="nomDatePicker"></mat-datepicker-toggle>
|
||||||
type="number"
|
<mat-datepicker #nomDatePicker></mat-datepicker>
|
||||||
min="0.01"
|
|
||||||
max="100"
|
|
||||||
step="0.01"
|
|
||||||
required
|
|
||||||
formControlName="sharePercent"
|
|
||||||
/>
|
|
||||||
</mat-form-field>
|
</mat-form-field>
|
||||||
</div>
|
</div>
|
||||||
} @else {
|
<div class="pm-f">
|
||||||
<div class="pm-f pm-f--tight">
|
<label class="pm-f-lbl">Name<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 required formControlName="name" />
|
||||||
</mat-form-field>
|
</mat-form-field>
|
||||||
</div>
|
</div>
|
||||||
}
|
<div class="pm-f">
|
||||||
<button
|
<label class="pm-f-lbl">Mobile No</label>
|
||||||
mat-icon-button
|
<mat-form-field appearance="outline" class="pm-field">
|
||||||
type="button"
|
<input matInput formControlName="mobileNo" mifosxRestrictInput="digits" />
|
||||||
class="pm-remove-nominee"
|
</mat-form-field>
|
||||||
(click)="removeNominee(i)"
|
</div>
|
||||||
matTooltip="Remove nominee"
|
<div class="pm-f">
|
||||||
>
|
<label class="pm-f-lbl">Birth Date</label>
|
||||||
<i class="fas fa-trash"></i>
|
<mat-form-field appearance="outline" class="pm-field">
|
||||||
</button>
|
<input matInput [matDatepicker]="nomBirthPicker" formControlName="birthDate" />
|
||||||
|
<mat-datepicker-toggle matIconSuffix [for]="nomBirthPicker"></mat-datepicker-toggle>
|
||||||
|
<mat-datepicker #nomBirthPicker></mat-datepicker>
|
||||||
|
</mat-form-field>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="pm-f">
|
||||||
|
<label class="pm-f-lbl">Relation<b>*</b></label>
|
||||||
|
<mat-form-field appearance="outline" class="pm-field">
|
||||||
|
<mat-select required formControlName="relation">
|
||||||
|
@for (relation of relationChoices(nominee); track relation) {
|
||||||
|
<mat-option [value]="relation">{{ relation }}</mat-option>
|
||||||
|
}
|
||||||
|
</mat-select>
|
||||||
|
</mat-form-field>
|
||||||
|
</div>
|
||||||
|
<div class="pm-f">
|
||||||
|
<label class="pm-f-lbl">Gender<b>*</b></label>
|
||||||
|
<mat-form-field appearance="outline" class="pm-field">
|
||||||
|
<mat-select required formControlName="gender">
|
||||||
|
<mat-option value="Male">Male</mat-option>
|
||||||
|
<mat-option value="Female">Female</mat-option>
|
||||||
|
<mat-option value="Other">Other</mat-option>
|
||||||
|
</mat-select>
|
||||||
|
</mat-form-field>
|
||||||
|
</div>
|
||||||
|
<div class="pm-f">
|
||||||
|
<!-- 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>
|
||||||
|
@if (splitByPercentage) {
|
||||||
|
<div class="pm-f">
|
||||||
|
<label class="pm-f-lbl">Share %<b>*</b></label>
|
||||||
|
<mat-form-field appearance="outline" class="pm-field">
|
||||||
|
<input
|
||||||
|
matInput
|
||||||
|
type="number"
|
||||||
|
min="0.01"
|
||||||
|
max="100"
|
||||||
|
step="0.01"
|
||||||
|
required
|
||||||
|
formControlName="sharePercent"
|
||||||
|
/>
|
||||||
|
</mat-form-field>
|
||||||
|
</div>
|
||||||
|
} @else {
|
||||||
|
<div class="pm-f">
|
||||||
|
<label class="pm-f-lbl">Order<b>*</b></label>
|
||||||
|
<mat-form-field appearance="outline" class="pm-field">
|
||||||
|
<input matInput type="number" min="1" step="1" required formControlName="sequenceNo" />
|
||||||
|
</mat-form-field>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
|
||||||
|
<!-- Only for a minor, who cannot receive the money themselves - an adult has no guardian to
|
||||||
|
name. Whatever was typed is cleared when the date of birth makes them an adult. -->
|
||||||
|
@if (isNomineeMinor(nominee)) {
|
||||||
|
<div class="pm-f pm-nom-card__guardian">
|
||||||
|
<label class="pm-f-lbl">Guardian Name<b>*</b></label>
|
||||||
|
<mat-form-field appearance="outline" class="pm-field">
|
||||||
|
<input matInput required formControlName="guardianName" />
|
||||||
|
<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,72 +737,24 @@ 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 : '');
|
||||||
|
return;
|
||||||
}
|
}
|
||||||
this.searchClients(typed);
|
|
||||||
|
// A name is not looked up as it is typed; the search button finds the customer and puts the id here.
|
||||||
|
this.clientNeedsSearch = true;
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
private searchClients(term: string): void {
|
|
||||||
searchClientsAnyCase(this.clientsService, term).subscribe((found: any[]) => {
|
|
||||||
this.showingClientList = false;
|
|
||||||
this.clientOptions = found.slice(0, 25);
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Opens the customer list when the field is focused.
|
|
||||||
*
|
|
||||||
* Without this the officer has to know something before anything appears, which is backwards: the
|
|
||||||
* point of the field is to find a customer, and a blank box is no help in doing that. A customer
|
|
||||||
* already picked is left alone - reopening the list over a chosen name only invites a misclick.
|
|
||||||
*/
|
|
||||||
onClientFocus(): void {
|
|
||||||
if (this.partyForm.get('clientId')?.value) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
this.showClientList();
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Shows the opening list, fetching it the first time it is asked for. */
|
|
||||||
private showClientList(): void {
|
|
||||||
if (this.initialClients.length > 0) {
|
|
||||||
this.showingClientList = true;
|
|
||||||
this.clientOptions = this.initialClients;
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
this.clientsService.getClients('displayName', 'ASC', 0, CreatePartyComponent.CLIENT_LIST_SIZE).subscribe({
|
|
||||||
next: (response: any) => {
|
|
||||||
const found = response?.pageItems || response || [];
|
|
||||||
this.initialClients = Array.isArray(found) ? found : [];
|
|
||||||
this.clientTotal = response?.totalFilteredRecords ?? this.initialClients.length;
|
|
||||||
this.showingClientList = true;
|
|
||||||
this.clientOptions = this.initialClients;
|
|
||||||
},
|
|
||||||
// A failed list is not a failed screen - the officer can still type a name or an id.
|
|
||||||
error: () => (this.clientOptions = [])
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
private fillFromClient(clientId: string | number, knownName: string): void {
|
private fillFromClient(clientId: string | number, knownName: string): void {
|
||||||
this.clientsService.getClientData(String(clientId), true).subscribe({
|
this.clientsService.getClientData(String(clientId), true).subscribe({
|
||||||
next: (client: any) => {
|
next: (client: any) => {
|
||||||
@@ -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,298 +11,470 @@
|
|||||||
<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>
|
||||||
<h2 class="am-header__title">Agent Master Entry</h2>
|
<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>
|
||||||
|
<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">
|
-->
|
||||||
<div class="am-section__header">
|
<mat-tab-group
|
||||||
<i class="fas fa-id-card"></i>
|
class="am-tabs"
|
||||||
<h3>Agent Details</h3>
|
animationDuration="200ms"
|
||||||
</div>
|
[preserveContent]="true"
|
||||||
<div class="am-grid">
|
[(selectedIndex)]="selectedTabIndex"
|
||||||
<mat-form-field appearance="outline" class="am-field">
|
>
|
||||||
<mat-label>Client ID or Name</mat-label>
|
<!-- ── 1. Agent details ─────────────────────────────────────────────────────────── -->
|
||||||
<input
|
<mat-tab>
|
||||||
matInput
|
<ng-template mat-tab-label>
|
||||||
required
|
<i class="fas fa-id-card am-tab-icon"></i>
|
||||||
formControlName="clientId"
|
Agent Details
|
||||||
[matAutocomplete]="clientAuto"
|
@if (tabHasError(0)) {
|
||||||
#clientInput
|
<i class="fas fa-exclamation-circle am-tab-alert" aria-label="Needs attention"></i>
|
||||||
#clientTrigger="matAutocompleteTrigger"
|
}
|
||||||
(focus)="onClientFocus()"
|
</ng-template>
|
||||||
placeholder="Click to see clients, or type a name or ID…"
|
<div class="am-tab-content">
|
||||||
autocomplete="off"
|
<div class="am-tab-intro">
|
||||||
/>
|
<h3>Agent Details</h3>
|
||||||
<i matSuffix class="fas fa-search am-search-ic"></i>
|
<p>
|
||||||
<!-- Picking a client puts their id in the field, which then fills the rest as before. -->
|
Choose the branch, then find the client who will act as agent - their name, address and accounts are
|
||||||
<mat-autocomplete #clientAuto="matAutocomplete" panelWidth="min(30rem, 92vw)">
|
filled in from them.
|
||||||
@for (client of clientOptions; track client.id) {
|
</p>
|
||||||
<mat-option [value]="client.id">
|
</div>
|
||||||
<div class="am-opt">
|
<div class="am-grid">
|
||||||
<span class="am-opt-id">{{ client.id }}</span>
|
<!-- Asked first: the clients offered, and whether a client can be made an agent here at all,
|
||||||
<span class="am-opt-main">{{ client.displayName }}</span>
|
depend on the branch. Which branch the agent collects out of: the one logged in to or a
|
||||||
<span class="am-opt-sub">{{ client.officeName }}</span>
|
branch under it. The option carries the office's id while showing its name, so renaming
|
||||||
</div>
|
a branch later leaves the agent where it is. -->
|
||||||
</mat-option>
|
<mat-form-field appearance="outline" class="am-field">
|
||||||
}
|
<mat-label>Home Branch</mat-label>
|
||||||
@if (clientListIsPartial) {
|
<mat-select required formControlName="officeId">
|
||||||
<mat-option disabled class="am-opt-more">
|
@for (office of offices; track office.id) {
|
||||||
Showing {{ clientOptions.length }} of {{ clientTotal }} — type a name to find the rest
|
<mat-option [value]="office.id">{{ office.name }}</mat-option>
|
||||||
</mat-option>
|
}
|
||||||
}
|
</mat-select>
|
||||||
</mat-autocomplete>
|
<mat-hint>The branch this agent's collections belong to</mat-hint>
|
||||||
@if (agentForm.get('clientId')?.hasError('pattern')) {
|
</mat-form-field>
|
||||||
<mat-hint class="am-hint--error">Pick a client from the list</mat-hint>
|
|
||||||
} @else if (clientAlreadyAgent) {
|
|
||||||
<mat-hint class="am-hint--error">This client is already agent {{ clientAlreadyAgent }}</mat-hint>
|
|
||||||
} @else if (clientAgeProblem) {
|
|
||||||
<mat-hint class="am-hint--error">{{ clientAgeProblem }}</mat-hint>
|
|
||||||
} @else if (clientOtherBranch) {
|
|
||||||
<mat-hint class="am-hint--error">This client belongs to another branch</mat-hint>
|
|
||||||
} @else if (clientLookupFailed) {
|
|
||||||
<mat-hint class="am-hint--error">No client found with this ID</mat-hint>
|
|
||||||
} @else if (loadedClientName) {
|
|
||||||
<mat-hint>{{ loadedClientName }}</mat-hint>
|
|
||||||
}
|
|
||||||
</mat-form-field>
|
|
||||||
|
|
||||||
<mat-form-field appearance="outline" class="am-field">
|
|
||||||
<mat-label>Agent Code</mat-label>
|
|
||||||
<input matInput formControlName="agentCode" placeholder="Auto-generated" />
|
|
||||||
</mat-form-field>
|
|
||||||
|
|
||||||
<mat-form-field appearance="outline" class="am-field am-field--wide">
|
|
||||||
<mat-label>Agent Name ID</mat-label>
|
|
||||||
<input matInput required formControlName="agentNameId" />
|
|
||||||
</mat-form-field>
|
|
||||||
|
|
||||||
<mat-form-field appearance="outline" class="am-field am-field--wide">
|
|
||||||
<mat-label>Agent Address (Line 1)</mat-label>
|
|
||||||
<input matInput required formControlName="agentAddress1" />
|
|
||||||
</mat-form-field>
|
|
||||||
|
|
||||||
<mat-form-field appearance="outline" class="am-field am-field--wide">
|
|
||||||
<mat-label>Agent Address (Line 2)</mat-label>
|
|
||||||
<input matInput formControlName="agentAddress2" />
|
|
||||||
</mat-form-field>
|
|
||||||
|
|
||||||
<mat-form-field appearance="outline" class="am-field">
|
|
||||||
<mat-label>Agent City</mat-label>
|
|
||||||
<input matInput required formControlName="agentCity" />
|
|
||||||
</mat-form-field>
|
|
||||||
|
|
||||||
<mat-form-field appearance="outline" class="am-field">
|
|
||||||
<mat-label>Agent PinCode</mat-label>
|
|
||||||
<input matInput required formControlName="agentPinCode" mifosxRestrictInput="digits" />
|
|
||||||
</mat-form-field>
|
|
||||||
|
|
||||||
<mat-form-field appearance="outline" class="am-field">
|
|
||||||
<mat-label>Agent Telephone</mat-label>
|
|
||||||
<input matInput formControlName="agentTelephone" mifosxRestrictInput="phone" />
|
|
||||||
</mat-form-field>
|
|
||||||
|
|
||||||
<!-- 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>
|
|
||||||
|
|
||||||
<!-- 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
|
|
||||||
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
|
|
||||||
entries of its own. -->
|
|
||||||
<mat-form-field appearance="outline" class="am-field">
|
|
||||||
<mat-label>GL Account</mat-label>
|
|
||||||
<mat-select required formControlName="glAccountId">
|
|
||||||
@for (account of glAccounts; track account.id) {
|
|
||||||
<mat-option [value]="account.id">{{ account.glCode }} · {{ account.name }}</mat-option>
|
|
||||||
}
|
|
||||||
</mat-select>
|
|
||||||
<mat-hint>This agent's collections pass through it. One agent to an account.</mat-hint>
|
|
||||||
</mat-form-field>
|
|
||||||
|
|
||||||
<!-- 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. -->
|
|
||||||
<mat-form-field appearance="outline" class="am-field">
|
|
||||||
<mat-label>Agent Saving A/C Type</mat-label>
|
|
||||||
<mat-select
|
|
||||||
required
|
|
||||||
formControlName="agentSavingAccountType"
|
|
||||||
(selectionChange)="onSavingsAccountTypePicked()"
|
|
||||||
>
|
|
||||||
@for (accountType of savingsAccountTypes; track accountType) {
|
|
||||||
<mat-option [value]="accountType">{{ savingsAccountTypeCodes[accountType] || accountType }}</mat-option>
|
|
||||||
}
|
|
||||||
</mat-select>
|
|
||||||
<!-- Two different reasons for an empty list, and the fix differs: enter a client, or
|
|
||||||
open them a savings account. Saying which saves a guess. -->
|
|
||||||
@if (savingsAccountTypes.length === 0) {
|
|
||||||
@if (loadedClientName) {
|
|
||||||
<mat-hint class="am-hint--error">
|
|
||||||
{{ loadedClientName }} has no savings account — commission can only be paid into one
|
|
||||||
</mat-hint>
|
|
||||||
} @else {
|
|
||||||
<mat-hint class="am-hint--error"
|
|
||||||
>Enter a Client ID first — types come from that client's own accounts</mat-hint
|
|
||||||
>
|
|
||||||
}
|
|
||||||
}
|
|
||||||
</mat-form-field>
|
|
||||||
|
|
||||||
<!-- Picked from the client's own accounts, never typed: commission is paid into this
|
|
||||||
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-label>Agent Saving A/C No</mat-label>
|
|
||||||
<mat-select required formControlName="agentSavingAccountNo">
|
|
||||||
@for (account of accountsOfChosenType; track account.id) {
|
|
||||||
<mat-option [value]="account.accountNo">{{ account.accountNo }}</mat-option>
|
|
||||||
}
|
|
||||||
</mat-select>
|
|
||||||
@if (accountsOfChosenType.length === 0) {
|
|
||||||
<mat-hint>Choose an A/C Type first</mat-hint>
|
|
||||||
}
|
|
||||||
</mat-form-field>
|
|
||||||
|
|
||||||
<mat-form-field appearance="outline" class="am-field">
|
|
||||||
<mat-label>Agent Opening Date</mat-label>
|
|
||||||
<input matInput required [matDatepicker]="openingDatePicker" formControlName="agentOpeningDate" />
|
|
||||||
<mat-datepicker-toggle matIconSuffix [for]="openingDatePicker"></mat-datepicker-toggle>
|
|
||||||
<mat-datepicker #openingDatePicker></mat-datepicker>
|
|
||||||
</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>
|
|
||||||
|
|
||||||
<div class="am-section">
|
|
||||||
<div class="am-section__header">
|
|
||||||
<i class="fas fa-user-shield"></i>
|
|
||||||
<h3>Guarantor's Detail</h3>
|
|
||||||
<button mat-stroked-button type="button" class="am-add-guarantor" (click)="addGuarantor()">
|
|
||||||
<i class="fas fa-plus"></i> Add Guarantor
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
@if (guarantors.length === 0) {
|
|
||||||
<p class="am-empty-guarantors">No guarantors added yet.</p>
|
|
||||||
}
|
|
||||||
|
|
||||||
@for (guarantor of guarantors.controls; track guarantor; let i = $index) {
|
|
||||||
<div class="am-guarantor-row" [formGroup]="$any(guarantor)">
|
|
||||||
<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(); openClientSearch()"
|
||||||
#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>
|
<!-- Opens Search Customer; picking a customer puts their id here, which fills the rest. -->
|
||||||
<mat-autocomplete #guarantorAuto="matAutocomplete" panelWidth="min(30rem, 92vw)">
|
<button
|
||||||
@for (client of guarantorOptionsFor(guarantor); track client.id) {
|
matSuffix
|
||||||
<mat-option [value]="client.id">
|
type="button"
|
||||||
<div class="am-opt">
|
class="am-search-btn"
|
||||||
<span class="am-opt-id">{{ client.id }}</span>
|
aria-label="Search customer"
|
||||||
<span class="am-opt-main">{{ client.displayName }}</span>
|
[disabled]="agentId !== null"
|
||||||
<span class="am-opt-sub">{{ client.officeName }}</span>
|
(click)="openClientSearch()"
|
||||||
</div>
|
>
|
||||||
</mat-option>
|
<i class="fas fa-search"></i>
|
||||||
}
|
</button>
|
||||||
</mat-autocomplete>
|
@if (clientNeedsSearch) {
|
||||||
<!-- Shown as soon as it is keyed, not only after leaving the field. -->
|
<mat-hint class="am-hint--error">Press search to find this client by name or mobile number</mat-hint>
|
||||||
@if (guarantor.get('clientId')?.hasError('selfGuarantor')) {
|
} @else if (clientAlreadyAgent) {
|
||||||
<mat-hint class="am-hint--error">The agent cannot be their own guarantor</mat-hint>
|
<mat-hint class="am-hint--error">This client is already agent {{ clientAlreadyAgent }}</mat-hint>
|
||||||
} @else if (guarantor.get('clientId')?.hasError('duplicateGuarantor')) {
|
} @else if (clientAgeProblem) {
|
||||||
<mat-hint class="am-hint--error">This client is already a guarantor above</mat-hint>
|
<mat-hint class="am-hint--error">{{ clientAgeProblem }}</mat-hint>
|
||||||
} @else if (guarantor.get('clientId')?.hasError('pattern')) {
|
} @else if (clientOtherBranch) {
|
||||||
<mat-hint class="am-hint--error">Pick a client from the list</mat-hint>
|
<mat-hint class="am-hint--error">This client belongs to another branch</mat-hint>
|
||||||
|
} @else if (clientLookupFailed) {
|
||||||
|
<mat-hint class="am-hint--error">No client found with this ID</mat-hint>
|
||||||
|
} @else if (loadedClientName) {
|
||||||
|
<mat-hint>{{ loadedClientName }}</mat-hint>
|
||||||
}
|
}
|
||||||
</mat-form-field>
|
</mat-form-field>
|
||||||
<mat-form-field appearance="outline" class="am-field">
|
|
||||||
<mat-label>Guarantor Name</mat-label>
|
<!-- Not a field: the server assigns the code when the agent is saved. A new agent is shown
|
||||||
<input matInput required formControlName="guarantorName" />
|
the code it is expected to get, worked out from the agents already on the books. -->
|
||||||
|
<div class="am-code">
|
||||||
|
<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-label>Agent Name ID</mat-label>
|
||||||
|
<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-form-field appearance="outline" class="am-field">
|
||||||
<mat-label>Address 1</mat-label>
|
<mat-label>Agent Opening Date</mat-label>
|
||||||
<input matInput required formControlName="address1" />
|
<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>
|
</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-form-field appearance="outline" class="am-field">
|
||||||
<mat-label>Address 2</mat-label>
|
<mat-label>Agent Birth Date</mat-label>
|
||||||
<input matInput formControlName="address2" />
|
<input matInput readonly [matDatepicker]="birthDatePicker" formControlName="agentBirthDate" />
|
||||||
|
<mat-datepicker #birthDatePicker></mat-datepicker>
|
||||||
|
<mat-hint>From the client's record</mat-hint>
|
||||||
</mat-form-field>
|
</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 & 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 & 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-label>Agent Address (Line 1)</mat-label>
|
||||||
|
<input matInput required formControlName="agentAddress1" />
|
||||||
|
</mat-form-field>
|
||||||
|
|
||||||
|
<mat-form-field appearance="outline" class="am-field am-field--wide">
|
||||||
|
<mat-label>Agent Address (Line 2)</mat-label>
|
||||||
|
<input matInput formControlName="agentAddress2" />
|
||||||
|
</mat-form-field>
|
||||||
|
|
||||||
<mat-form-field appearance="outline" class="am-field">
|
<mat-form-field appearance="outline" class="am-field">
|
||||||
<mat-label>City</mat-label>
|
<mat-label>Agent City</mat-label>
|
||||||
<input matInput required formControlName="city" />
|
<input matInput required formControlName="agentCity" />
|
||||||
</mat-form-field>
|
</mat-form-field>
|
||||||
|
|
||||||
<mat-form-field appearance="outline" class="am-field">
|
<mat-form-field appearance="outline" class="am-field">
|
||||||
<mat-label>Pin Code</mat-label>
|
<mat-label>Agent PinCode</mat-label>
|
||||||
<input matInput required formControlName="pinCode" mifosxRestrictInput="digits" />
|
<input matInput required formControlName="agentPinCode" mifosxRestrictInput="digits" />
|
||||||
</mat-form-field>
|
</mat-form-field>
|
||||||
|
|
||||||
<mat-form-field appearance="outline" class="am-field">
|
<mat-form-field appearance="outline" class="am-field">
|
||||||
<mat-label>Telephone</mat-label>
|
<mat-label>Agent Telephone</mat-label>
|
||||||
<input matInput formControlName="telephone" mifosxRestrictInput="phone" />
|
<input matInput formControlName="agentTelephone" mifosxRestrictInput="phone" />
|
||||||
</mat-form-field>
|
</mat-form-field>
|
||||||
<button
|
</div>
|
||||||
mat-icon-button
|
</div>
|
||||||
type="button"
|
</mat-tab>
|
||||||
class="am-remove-guarantor"
|
|
||||||
(click)="removeGuarantor(i)"
|
<!-- ── 3. Branch & accounts ─────────────────────────────────────────────────────── -->
|
||||||
matTooltip="Remove guarantor"
|
<mat-tab>
|
||||||
>
|
<ng-template mat-tab-label>
|
||||||
<i class="fas fa-trash"></i>
|
<i class="fas fa-university am-tab-icon"></i>
|
||||||
|
Accounts
|
||||||
|
@if (tabHasError(2)) {
|
||||||
|
<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>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
|
||||||
|
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
|
||||||
|
detail accounts are offered: a header account is a heading over others and carries no
|
||||||
|
entries of its own. -->
|
||||||
|
<mat-form-field appearance="outline" class="am-field am-field--full">
|
||||||
|
<mat-label>GL Account</mat-label>
|
||||||
|
<mat-select
|
||||||
|
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-select>
|
||||||
|
<mat-hint>This agent's collections pass through it. One agent to an account.</mat-hint>
|
||||||
|
</mat-form-field>
|
||||||
|
|
||||||
|
<!-- 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. -->
|
||||||
|
<mat-form-field appearance="outline" class="am-field am-field--wide">
|
||||||
|
<mat-label>Agent Saving A/C Type</mat-label>
|
||||||
|
<mat-select
|
||||||
|
required
|
||||||
|
formControlName="agentSavingAccountType"
|
||||||
|
(selectionChange)="onSavingsAccountTypePicked()"
|
||||||
|
>
|
||||||
|
@for (accountType of savingsAccountTypes; track accountType) {
|
||||||
|
<mat-option [value]="accountType">{{
|
||||||
|
savingsAccountTypeCodes[accountType] || accountType
|
||||||
|
}}</mat-option>
|
||||||
|
}
|
||||||
|
</mat-select>
|
||||||
|
<!-- Two different reasons for an empty list, and the fix differs: enter a client, or
|
||||||
|
open them a savings account. Saying which saves a guess. -->
|
||||||
|
@if (savingsAccountTypes.length === 0) {
|
||||||
|
@if (loadedClientName) {
|
||||||
|
<mat-hint class="am-hint--error">
|
||||||
|
{{ loadedClientName }} has no savings account — commission can only be paid into one
|
||||||
|
</mat-hint>
|
||||||
|
} @else {
|
||||||
|
<mat-hint class="am-hint--error"
|
||||||
|
>Enter a Client ID first — types come from that client's own accounts</mat-hint
|
||||||
|
>
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</mat-form-field>
|
||||||
|
|
||||||
|
<!-- Picked from the client's own accounts, never typed: commission is paid into this
|
||||||
|
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-label>Agent Saving A/C No</mat-label>
|
||||||
|
<mat-select required formControlName="agentSavingAccountNo" (selectionChange)="onSavingsAccountPicked()">
|
||||||
|
@for (account of accountsOfChosenType; track account.id) {
|
||||||
|
<mat-option [value]="account.accountNo">{{ account.accountNo }}</mat-option>
|
||||||
|
}
|
||||||
|
</mat-select>
|
||||||
|
@if (accountsOfChosenType.length === 0) {
|
||||||
|
<mat-hint>Choose an A/C Type first</mat-hint>
|
||||||
|
}
|
||||||
|
</mat-form-field>
|
||||||
|
|
||||||
|
<!-- How the chosen account stands, read from the account itself: whether it can take the
|
||||||
|
commission, what it holds, what is on hold and what is left to use. -->
|
||||||
|
@if (savingsInfoLoading) {
|
||||||
|
<div class="am-acct am-field--full am-acct--note">Reading the account…</div>
|
||||||
|
} @else if (savingsInfoFailed) {
|
||||||
|
<div class="am-acct am-field--full am-acct--note am-acct--error">
|
||||||
|
Could not read this account's balance just now.
|
||||||
|
</div>
|
||||||
|
} @else if (savingsInfo) {
|
||||||
|
<div class="am-acct am-field--full">
|
||||||
|
<div class="am-acct__tile">
|
||||||
|
<span class="am-acct__label">Account Status</span>
|
||||||
|
<span
|
||||||
|
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>
|
||||||
|
</mat-tab>
|
||||||
|
|
||||||
|
<!-- ── 4. Guarantors ────────────────────────────────────────────────────────────── -->
|
||||||
|
<mat-tab>
|
||||||
|
<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>
|
||||||
|
<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()">
|
||||||
|
<i class="fas fa-plus"></i> Add Guarantor
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="am-section">
|
@if (guarantors.length === 0) {
|
||||||
<div class="am-section__header">
|
<div class="am-empty">
|
||||||
<i class="fas fa-balance-scale"></i>
|
<i class="fas fa-user-shield"></i>
|
||||||
<h3>Balances</h3>
|
<p class="am-empty-guarantors">No guarantors added yet.</p>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
|
||||||
|
@for (guarantor of guarantors.controls; track guarantor; let i = $index) {
|
||||||
|
<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-label>Client ID or Name</mat-label>
|
||||||
|
<input
|
||||||
|
matInput
|
||||||
|
required
|
||||||
|
formControlName="clientId"
|
||||||
|
(keydown.enter)="$event.preventDefault(); openGuarantorSearch(guarantor)"
|
||||||
|
placeholder="Client ID, or search by name or mobile number…"
|
||||||
|
autocomplete="off"
|
||||||
|
/>
|
||||||
|
<button
|
||||||
|
matSuffix
|
||||||
|
type="button"
|
||||||
|
class="am-search-btn"
|
||||||
|
aria-label="Search customer"
|
||||||
|
(click)="openGuarantorSearch(guarantor)"
|
||||||
|
>
|
||||||
|
<i class="fas fa-search"></i>
|
||||||
|
</button>
|
||||||
|
<!-- Shown as soon as it is keyed, not only after leaving the field. -->
|
||||||
|
@if (guarantor.get('clientId')?.hasError('selfGuarantor')) {
|
||||||
|
<mat-hint class="am-hint--error">The agent cannot be their own guarantor</mat-hint>
|
||||||
|
} @else if (guarantor.get('clientId')?.hasError('duplicateGuarantor')) {
|
||||||
|
<mat-hint class="am-hint--error">This client is already a guarantor above</mat-hint>
|
||||||
|
} @else if (guarantor.get('clientId')?.hasError('pattern')) {
|
||||||
|
<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 appearance="outline" class="am-field">
|
||||||
|
<mat-label>Guarantor Name</mat-label>
|
||||||
|
<input matInput required formControlName="guarantorName" />
|
||||||
|
</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-label>Address 1</mat-label>
|
||||||
|
<input matInput required formControlName="address1" />
|
||||||
|
</mat-form-field>
|
||||||
|
<mat-form-field appearance="outline" class="am-field">
|
||||||
|
<mat-label>Address 2</mat-label>
|
||||||
|
<input matInput formControlName="address2" />
|
||||||
|
</mat-form-field>
|
||||||
|
<mat-form-field appearance="outline" class="am-field">
|
||||||
|
<mat-label>City</mat-label>
|
||||||
|
<input matInput required formControlName="city" />
|
||||||
|
</mat-form-field>
|
||||||
|
<mat-form-field appearance="outline" class="am-field">
|
||||||
|
<mat-label>Pin Code</mat-label>
|
||||||
|
<input matInput required formControlName="pinCode" mifosxRestrictInput="digits" />
|
||||||
|
</mat-form-field>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
</div>
|
</div>
|
||||||
<div class="am-grid">
|
</mat-tab>
|
||||||
<mat-form-field appearance="outline" class="am-field">
|
|
||||||
<mat-label>Agent Current Balance</mat-label>
|
|
||||||
<input matInput formControlName="agentCurrentBalance" />
|
|
||||||
</mat-form-field>
|
|
||||||
|
|
||||||
<mat-form-field appearance="outline" class="am-field">
|
<!-- ── 5. Balances ────────────────────────────────────────────────────────────────
|
||||||
<mat-label>Agent Opening Balance</mat-label>
|
Only for an agent already on the books. A new agent always starts at nothing - the form
|
||||||
<input matInput type="number" formControlName="agentOpeningBalance" />
|
still sends 0 - so there is nothing to ask; the tab then shows what the agent holds. -->
|
||||||
</mat-form-field>
|
@if (agentId) {
|
||||||
</div>
|
<mat-tab>
|
||||||
</div>
|
<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>
|
||||||
|
<p>What the agent holds on the books at the start.</p>
|
||||||
|
</div>
|
||||||
|
<div class="am-grid">
|
||||||
|
<mat-form-field appearance="outline" class="am-field">
|
||||||
|
<mat-label>Agent Current Balance</mat-label>
|
||||||
|
<input matInput formControlName="agentCurrentBalance" />
|
||||||
|
</mat-form-field>
|
||||||
|
|
||||||
<div class="am-action-bar">
|
<mat-form-field appearance="outline" class="am-field">
|
||||||
|
<mat-label>Agent Opening Balance</mat-label>
|
||||||
|
<input matInput type="number" formControlName="agentOpeningBalance" />
|
||||||
|
</mat-form-field>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</mat-tab>
|
||||||
|
}
|
||||||
|
</mat-tab-group>
|
||||||
|
|
||||||
|
<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;
|
||||||
|
}
|
||||||
|
|
||||||
|
.am-tab-count {
|
||||||
|
display: inline-grid;
|
||||||
|
place-items: center;
|
||||||
|
min-width: 20px;
|
||||||
|
height: 20px;
|
||||||
|
margin-left: 8px;
|
||||||
|
padding: 0 6px;
|
||||||
|
border-radius: 999px;
|
||||||
|
background: $primary-light;
|
||||||
|
color: $primary-dark;
|
||||||
|
font-size: 11px;
|
||||||
|
font-weight: 700;
|
||||||
|
}
|
||||||
|
|
||||||
|
.am-tab-content {
|
||||||
|
padding: 22px 24px 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.am-tab-intro {
|
||||||
|
margin-bottom: 18px;
|
||||||
|
padding-bottom: 12px;
|
||||||
|
border-bottom: 1px dashed $border;
|
||||||
|
|
||||||
|
h3 {
|
||||||
|
margin: 0;
|
||||||
|
font-size: 16px;
|
||||||
|
font-weight: 600;
|
||||||
|
color: $text-dark;
|
||||||
}
|
}
|
||||||
|
|
||||||
&__header {
|
p {
|
||||||
|
margin: 3px 0 0;
|
||||||
|
font-size: 13px;
|
||||||
|
color: $text-mid;
|
||||||
|
}
|
||||||
|
|
||||||
|
&--split {
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
gap: 10px;
|
justify-content: space-between;
|
||||||
margin-bottom: 16px;
|
gap: 12px;
|
||||||
|
flex-wrap: wrap;
|
||||||
i {
|
|
||||||
color: $primary;
|
|
||||||
}
|
|
||||||
|
|
||||||
h3 {
|
|
||||||
margin: 0;
|
|
||||||
font-size: 16px;
|
|
||||||
font-weight: 600;
|
|
||||||
flex: 1;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ── 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;
|
||||||
|
}
|
||||||
|
|
||||||
|
&--error {
|
||||||
|
color: $danger;
|
||||||
|
}
|
||||||
|
|
||||||
|
&__tile {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 6px;
|
||||||
|
padding: 14px 18px;
|
||||||
|
border-right: 1px solid $border;
|
||||||
|
|
||||||
|
&: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;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
.am-remove-guarantor {
|
@media (width <= 720px) {
|
||||||
color: #b91c1c;
|
.am-acct {
|
||||||
margin-top: 8px;
|
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;
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
.am-action-bar {
|
// The agent code, shown rather than asked for: it is assigned when the agent is saved.
|
||||||
|
.am-code {
|
||||||
display: flex;
|
display: flex;
|
||||||
justify-content: flex-end;
|
flex-direction: column;
|
||||||
gap: 12px;
|
justify-content: center;
|
||||||
margin-top: 20px;
|
gap: 1px;
|
||||||
padding-top: 16px;
|
min-height: 56px;
|
||||||
flex-wrap: wrap;
|
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 & 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 & 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>
|
||||||
<h2 class="bp-header__title">DDS Interest & Commission</h2>
|
<div class="bp-header__ic"><i class="fas fa-cogs"></i></div>
|
||||||
|
<div>
|
||||||
|
<h2 class="bp-header__title">DDS Interest & 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">
|
||||||
@for (office of offices; track office.id) {
|
<span class="bp-group-label">Branches ({{ selectedOfficeNames.size }} selected)</span>
|
||||||
<mat-checkbox [checked]="selectedOfficeNames.has(office.name)" (change)="toggleOffice(office.name)">
|
<div class="bp-branch-checklist__list">
|
||||||
{{ office.name }}
|
@for (office of offices; track office.id) {
|
||||||
</mat-checkbox>
|
<mat-checkbox [checked]="selectedOfficeNames.has(office.name)" (change)="toggleOffice(office.name)">
|
||||||
}
|
{{ office.name }}
|
||||||
@if (offices.length === 0) {
|
</mat-checkbox>
|
||||||
<span class="bp-empty">Loading branches...</span>
|
}
|
||||||
}
|
@if (offices.length === 0) {
|
||||||
|
<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>
|
||||||
|
<input matInput required type="number" formControlName="agentFrom" />
|
||||||
|
</mat-form-field>
|
||||||
|
<span class="bp-range__to">to</span>
|
||||||
|
<mat-form-field appearance="outline" class="bp-field" subscriptSizing="dynamic">
|
||||||
|
<mat-label>To</mat-label>
|
||||||
|
<input matInput required type="number" formControlName="agentTo" />
|
||||||
|
</mat-form-field>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<mat-form-field appearance="outline" class="bp-field">
|
<!-- Commission is worked out per agent on their whole collection, so a party range would
|
||||||
<mat-label>Agent From</mat-label>
|
imply a filter that has no effect. -->
|
||||||
<input matInput required type="number" formControlName="agentFrom" />
|
@if (!isCommissionJob) {
|
||||||
</mat-form-field>
|
<div class="bp-range">
|
||||||
|
<span class="bp-group-label"><i class="fas fa-users"></i> Parties</span>
|
||||||
<mat-form-field appearance="outline" class="bp-field">
|
<div class="bp-range__fields">
|
||||||
<mat-label>Agent To</mat-label>
|
<mat-form-field appearance="outline" class="bp-field" subscriptSizing="dynamic">
|
||||||
<input matInput required type="number" formControlName="agentTo" />
|
<mat-label>From</mat-label>
|
||||||
</mat-form-field>
|
<input matInput required type="number" formControlName="partyFrom" />
|
||||||
|
</mat-form-field>
|
||||||
<!-- Commission is worked out per agent on their whole collection, so a party range would
|
<span class="bp-range__to">to</span>
|
||||||
imply a filter that has no effect. -->
|
<mat-form-field appearance="outline" class="bp-field" subscriptSizing="dynamic">
|
||||||
@if (!isCommissionJob) {
|
<mat-label>To</mat-label>
|
||||||
<mat-form-field appearance="outline" class="bp-field">
|
<input matInput required type="number" formControlName="partyTo" />
|
||||||
<mat-label>Party From</mat-label>
|
</mat-form-field>
|
||||||
<input matInput required type="number" formControlName="partyFrom" />
|
</div>
|
||||||
</mat-form-field>
|
</div>
|
||||||
|
}
|
||||||
<mat-form-field appearance="outline" class="bp-field">
|
</div>
|
||||||
<mat-label>Party To</mat-label>
|
|
||||||
<input matInput required type="number" formControlName="partyTo" />
|
|
||||||
</mat-form-field>
|
|
||||||
}
|
|
||||||
</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,72 +243,227 @@
|
|||||||
</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.
|
||||||
<h3>Previous runs</h3>
|
-->
|
||||||
</div>
|
<ng-template #runsDialog>
|
||||||
|
<div class="bpr">
|
||||||
@if (recentRuns.length === 0) {
|
<div class="bpr-head">
|
||||||
<p class="bp-empty">No batch has been run yet.</p>
|
<span class="bpr-head__ic"><i class="fas fa-history"></i></span>
|
||||||
} @else {
|
<div class="bpr-head__text">
|
||||||
<div class="bp-table-scroll">
|
<h3>Previous runs</h3>
|
||||||
<table class="bp-table bp-table--runs">
|
<p>Every run, and whether it was applied.</p>
|
||||||
<thead>
|
</div>
|
||||||
<tr>
|
<button type="button" class="bpr-icon-btn" (click)="loadRecentRuns()" matTooltip="Refresh" aria-label="Refresh">
|
||||||
<th>#</th>
|
<i class="fas fa-sync-alt" [class.fa-spin]="runsLoading"></i>
|
||||||
<th>Job</th>
|
</button>
|
||||||
<th>Date</th>
|
<button type="button" class="bpr-icon-btn" mat-dialog-close aria-label="Close">
|
||||||
<th class="bp-num">Entries</th>
|
<i class="fas fa-times"></i>
|
||||||
<th class="bp-num">Amount</th>
|
</button>
|
||||||
<th>Status</th>
|
|
||||||
<th>Posted On</th>
|
|
||||||
</tr>
|
|
||||||
</thead>
|
|
||||||
<tbody>
|
|
||||||
@for (run of recentRuns; track run.id) {
|
|
||||||
<tr>
|
|
||||||
<td>{{ run.id }}</td>
|
|
||||||
<td>{{ labelOf(run.jobType) }}</td>
|
|
||||||
<td>{{ run.jobDate | dateFormat }}</td>
|
|
||||||
<td class="bp-num">{{ run.totalRows }}</td>
|
|
||||||
<td class="bp-num">{{ money(run.totalAmount) }}</td>
|
|
||||||
<!--
|
|
||||||
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
|
|
||||||
already going. One that failed says so, with the reason the server recorded.
|
|
||||||
-->
|
|
||||||
<td>
|
|
||||||
@switch (run.status) {
|
|
||||||
@case ('POSTED') {
|
|
||||||
<span class="bp-status bp-status--posted">Posted</span>
|
|
||||||
}
|
|
||||||
@case ('POSTING') {
|
|
||||||
<span class="bp-status bp-status--running">
|
|
||||||
<i class="fas fa-circle-notch fa-spin"></i> Running
|
|
||||||
</span>
|
|
||||||
}
|
|
||||||
@case ('FAILED') {
|
|
||||||
<span
|
|
||||||
class="bp-status bp-status--failed"
|
|
||||||
[matTooltip]="run.failureReason || 'No reason recorded'"
|
|
||||||
>
|
|
||||||
Failed
|
|
||||||
</span>
|
|
||||||
}
|
|
||||||
@default {
|
|
||||||
<span class="bp-status">Not posted</span>
|
|
||||||
}
|
|
||||||
}
|
|
||||||
</td>
|
|
||||||
<td>{{ run.postedOnDate ? (run.postedOnDate | dateFormat) : '—' }}</td>
|
|
||||||
</tr>
|
|
||||||
}
|
|
||||||
</tbody>
|
|
||||||
</table>
|
|
||||||
</div>
|
</div>
|
||||||
}
|
|
||||||
</div>
|
<div class="bpr-body">
|
||||||
|
<!-- 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 {
|
||||||
|
<div class="bp-table-scroll bpr-scroll">
|
||||||
|
<table class="bp-table bp-table--runs">
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th class="bpr-caret"></th>
|
||||||
|
<th>#</th>
|
||||||
|
<th>Job</th>
|
||||||
|
<th>Job date</th>
|
||||||
|
<th class="bp-num">Entries</th>
|
||||||
|
<th class="bp-num">Amount</th>
|
||||||
|
<th>Status</th>
|
||||||
|
<th>Generated</th>
|
||||||
|
<th>Posted on</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
@for (run of pagedRuns; track run.id) {
|
||||||
|
<tr
|
||||||
|
class="bpr-row"
|
||||||
|
[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 class="bp-num">{{ run.totalRows }}</td>
|
||||||
|
<td class="bp-num">{{ money(run.totalAmount) }}</td>
|
||||||
|
<!--
|
||||||
|
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
|
||||||
|
already going. One that failed says so, with the reason the server recorded.
|
||||||
|
-->
|
||||||
|
<td>
|
||||||
|
@switch (run.status) {
|
||||||
|
@case ('POSTED') {
|
||||||
|
<span class="bp-status bp-status--posted"><i class="fas fa-check-circle"></i> Posted</span>
|
||||||
|
}
|
||||||
|
@case ('POSTING') {
|
||||||
|
<span class="bp-status bp-status--running">
|
||||||
|
<i class="fas fa-circle-notch fa-spin"></i> Running
|
||||||
|
</span>
|
||||||
|
}
|
||||||
|
@case ('FAILED') {
|
||||||
|
<span class="bp-status bp-status--failed">
|
||||||
|
<i class="fas fa-exclamation-circle"></i> Failed
|
||||||
|
</span>
|
||||||
|
}
|
||||||
|
@default {
|
||||||
|
<span class="bp-status"><i class="far fa-clock"></i> Not posted</span>
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</td>
|
||||||
|
<td>{{ run.generatedOnDate ? (run.generatedOnDate | dateFormat) : '—' }}</td>
|
||||||
|
<td>{{ run.postedOnDate ? (run.postedOnDate | dateFormat) : '—' }}</td>
|
||||||
|
</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>
|
||||||
|
<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>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<mat-paginator
|
||||||
|
[length]="filteredRuns.length"
|
||||||
|
[pageSize]="runsPageSize"
|
||||||
|
[pageIndex]="runsPageIndex"
|
||||||
|
[pageSizeOptions]="[10, 25, 50]"
|
||||||
|
showFirstLastButtons
|
||||||
|
(page)="onRunsPage($event)"
|
||||||
|
></mat-paginator>
|
||||||
|
}
|
||||||
|
</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[] {
|
||||||
this.recentRuns = runs || [];
|
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.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({
|
this.loadRecentRuns();
|
||||||
type: 'Information',
|
// The run is checked on a page of its own - agent by agent, with Post and Discard beside the
|
||||||
message: `${this.jobTypeLabel} found nothing to apply. Either this period has already been processed, or there was no activity in it.`
|
// figures - so this page does not grow a second screen's worth below the form.
|
||||||
});
|
this.router.navigate([
|
||||||
} else {
|
'/organization/dds-batch-processing/run',
|
||||||
this.alertService.alert({
|
batchRun.id
|
||||||
type: 'Success',
|
]);
|
||||||
message: `${this.jobTypeLabel}: ${this.previewRows.length} ${this.previewRows.length === 1 ? 'entry' : 'entries'} totalling ${this.money(this.previewTotal)} calculated. Nothing has been applied yet — review and press Post.`
|
}
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
this.loadRecentRuns();
|
|
||||||
});
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
post(): void {
|
|
||||||
if (!this.generatedRun || this.previewRows.length === 0) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const count = this.previewRows.length;
|
|
||||||
const total = this.money(this.previewTotal);
|
|
||||||
const isPosting = this.isPostingJob;
|
|
||||||
|
|
||||||
// Post is the step that moves money, and it cannot be undone from this screen — so it names
|
|
||||||
// exactly what is about to change rather than asking a generic "are you sure".
|
|
||||||
this.dialog
|
|
||||||
.open(ConfirmationDialogComponent, {
|
|
||||||
data: {
|
|
||||||
type: 'warn',
|
|
||||||
heading: `Post ${this.jobTypeLabel}?`,
|
|
||||||
dialogContext:
|
|
||||||
`${count} ${count === 1 ? 'entry' : 'entries'} totalling ${total} will be applied. ` +
|
|
||||||
(isPosting
|
|
||||||
? 'This credits the customers or pays the agents, and appears on the ledger. '
|
|
||||||
: 'This records what has been earned against each account. ') +
|
|
||||||
'It cannot be undone from this screen.'
|
|
||||||
}
|
|
||||||
})
|
|
||||||
.afterClosed()
|
|
||||||
.subscribe((response: any) => {
|
|
||||||
if (response?.confirm) {
|
|
||||||
this.applyRun(count, total);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Applies the run, and says what actually happened rather than assuming it finished.
|
|
||||||
*
|
|
||||||
* A crediting run costs what a savings deposit costs on every account it touches, so the server
|
|
||||||
* takes it off the request and does it in the background - the reply comes back in a moment while
|
|
||||||
* the work is still going on. Reporting that as "posted" would tell the clerk a run had finished
|
|
||||||
* seconds after it began, and they would go looking for credits that were still being written.
|
|
||||||
*
|
|
||||||
* So the run is followed until it settles. A calculation run is done by the time the reply lands
|
|
||||||
* and says so at once; a crediting run says it is under way, and the list beneath refreshes itself
|
|
||||||
* until the server marks it posted - or failed, with the reason the server recorded.
|
|
||||||
*/
|
|
||||||
private applyRun(count: number, total: string): void {
|
|
||||||
const label = this.jobTypeLabel;
|
|
||||||
const runId = this.generatedRun.id;
|
|
||||||
const crediting = this.isPostingJob;
|
|
||||||
|
|
||||||
this.organizationService.postDdsBatchRun(runId).subscribe((response: any) => {
|
|
||||||
this.generatedRun = null;
|
|
||||||
this.previewRows = [];
|
|
||||||
this.loadRecentRuns();
|
|
||||||
|
|
||||||
if (heldForApproval(response)) {
|
|
||||||
announceHeldForApproval(
|
|
||||||
response,
|
|
||||||
`${label} (${count} ${count === 1 ? 'entry' : 'entries'}, ${total})`,
|
|
||||||
this.organizationService,
|
|
||||||
this.alertService
|
|
||||||
);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!crediting) {
|
|
||||||
this.alertService.alert({
|
|
||||||
type: 'Success',
|
|
||||||
message: `${label} posted — ${count} ${count === 1 ? 'entry' : 'entries'}, ${total}.`
|
|
||||||
});
|
});
|
||||||
return;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
this.postingRunId = runId;
|
|
||||||
this.alertService.alert({
|
|
||||||
type: 'Posting',
|
|
||||||
message:
|
|
||||||
`${label} is running — ${count} ${count === 1 ? 'entry' : 'entries'}, ${total}. ` +
|
|
||||||
'It continues in the background; this list will update when it is done.'
|
|
||||||
});
|
|
||||||
this.followRun(runId, label);
|
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
/** The crediting run being followed, if one is. The screen reads this to show it is under way. */
|
// ── Runs that are going on in the background ──────────────────────────────────────────────────────
|
||||||
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 & 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,
|
||||||
@@ -261,15 +267,21 @@ const routes: Routes = [
|
|||||||
}
|
}
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
{
|
|
||||||
path: 'commission-slab-master',
|
|
||||||
component: CommissionSlabMasterComponent,
|
|
||||||
data: { title: 'Commission Slab Master', breadcrumb: 'Commission Slab Master' }
|
|
||||||
},
|
|
||||||
{
|
{
|
||||||
path: 'dds-batch-processing',
|
path: 'dds-batch-processing',
|
||||||
component: DdsBatchProcessingComponent,
|
data: { title: 'DDS Interest & Commission Batch', breadcrumb: 'DDS Batch Processing' },
|
||||||
data: { title: 'DDS Interest & Commission Batch', breadcrumb: 'DDS Batch Processing' }
|
children: [
|
||||||
|
{
|
||||||
|
path: '',
|
||||||
|
component: DdsBatchProcessingComponent
|
||||||
|
},
|
||||||
|
{
|
||||||
|
// One generated run: its figures agent by agent, with Post and Discard while it is a preview.
|
||||||
|
path: 'run/:runId',
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
+172
-18
@@ -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,26 +161,65 @@ 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.slabs = (slabs || []).map((slab) => ({
|
this.loadFailed = false;
|
||||||
id: slab.id,
|
this.organizationService.getCommissionSlabs().subscribe({
|
||||||
slabType: CommissionSlabMasterComponent.SLAB_TYPE_LABELS[slab.slabType] || slab.slabType,
|
next: (slabs: any[]) => {
|
||||||
slabFrom: slab.slabFrom,
|
this.slabs = (slabs || []).map((slab) => ({
|
||||||
slabTo: slab.slabTo,
|
id: slab.id,
|
||||||
commissionPercent: slab.commissionPercent
|
slabType: CommissionSlabMasterComponent.SLAB_TYPE_LABELS[slab.slabType] || slab.slabType,
|
||||||
}));
|
slabFrom: slab.slabFrom,
|
||||||
|
slabTo: slab.slabTo,
|
||||||
|
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 {
|
||||||
+240
-117
@@ -7,139 +7,262 @@
|
|||||||
-->
|
-->
|
||||||
|
|
||||||
<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>
|
<h2>DDS Product Creation</h2>
|
||||||
<h2>DDS Product Creation</h2>
|
<p>{{ isEdit ? 'Update this Daily Deposit product.' : 'Register a Daily Deposit product.' }}</p>
|
||||||
<p>{{ isEdit ? 'Update this Daily Deposit product.' : 'Register a Daily Deposit product.' }}</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<form [formGroup]="accountTypeForm">
|
<!--
|
||||||
<div class="dat-grid">
|
One form, split into tabs the way the savings product screen is. Every tab stays in the DOM
|
||||||
<mat-form-field appearance="outline" class="dat-field">
|
(preserveContent), so a field filled on one tab is still there when the user comes back to it.
|
||||||
<mat-label>Product Group</mat-label>
|
-->
|
||||||
<mat-select required formControlName="accountTypeGroup">
|
<form [formGroup]="accountTypeForm">
|
||||||
@for (group of accountTypeGroups; track group) {
|
<mat-tab-group
|
||||||
<mat-option [value]="group">{{ group }}</mat-option>
|
class="dat-tabs"
|
||||||
}
|
animationDuration="200ms"
|
||||||
</mat-select>
|
[preserveContent]="true"
|
||||||
</mat-form-field>
|
[(selectedIndex)]="selectedTabIndex"
|
||||||
|
>
|
||||||
<mat-form-field appearance="outline" class="dat-field">
|
<!-- ── Details ─────────────────────────────────────────────────────────────────────────── -->
|
||||||
<mat-label>Product Code</mat-label>
|
<mat-tab>
|
||||||
<input matInput required maxlength="20" formControlName="accountTypeCode" style="text-transform: uppercase" />
|
<ng-template mat-tab-label>
|
||||||
</mat-form-field>
|
<i class="fas fa-info-circle dat-tab-icon"></i>
|
||||||
|
Details
|
||||||
<mat-form-field appearance="outline" class="dat-field dat-field--wide">
|
@if (tabHasError(detailsControls)) {
|
||||||
<mat-label>Product Description</mat-label>
|
<span class="dat-tab-dot" title="Something on this tab still needs filling in"></span>
|
||||||
<input matInput required maxlength="200" formControlName="accountTypeDescription" />
|
|
||||||
</mat-form-field>
|
|
||||||
|
|
||||||
<mat-form-field appearance="outline" class="dat-field">
|
|
||||||
<mat-label>Interest Rate (%)</mat-label>
|
|
||||||
<input matInput required type="number" min="0" max="100" step="0.01" formControlName="interestRate" />
|
|
||||||
<mat-hint>Interest is worked out daily on the balance (rate ÷ 365)</mat-hint>
|
|
||||||
@if (accountTypeForm.get('interestRate')?.hasError('required')) {
|
|
||||||
<mat-error>Interest rate is required</mat-error>
|
|
||||||
}
|
}
|
||||||
</mat-form-field>
|
</ng-template>
|
||||||
|
<div class="dat-tab-content">
|
||||||
|
<div class="dat-grid">
|
||||||
|
<mat-form-field appearance="outline" class="dat-field">
|
||||||
|
<mat-label>Product Group</mat-label>
|
||||||
|
<mat-select required formControlName="accountTypeGroup">
|
||||||
|
@for (group of accountTypeGroups; track group) {
|
||||||
|
<mat-option [value]="group">{{ group }}</mat-option>
|
||||||
|
}
|
||||||
|
</mat-select>
|
||||||
|
</mat-form-field>
|
||||||
|
|
||||||
<mat-form-field appearance="outline" class="dat-field">
|
<mat-form-field appearance="outline" class="dat-field">
|
||||||
<mat-label>Auto Interest Post</mat-label>
|
<mat-label>Product Code</mat-label>
|
||||||
<mat-select required formControlName="autoInterestPost">
|
<input
|
||||||
<mat-option [value]="true">Yes</mat-option>
|
matInput
|
||||||
<mat-option [value]="false">No</mat-option>
|
required
|
||||||
</mat-select>
|
maxlength="20"
|
||||||
</mat-form-field>
|
formControlName="accountTypeCode"
|
||||||
|
style="text-transform: uppercase"
|
||||||
|
/>
|
||||||
|
</mat-form-field>
|
||||||
|
|
||||||
<mat-form-field appearance="outline" class="dat-field">
|
<mat-form-field appearance="outline" class="dat-field dat-field--wide">
|
||||||
<mat-label>Auto Interest Post When</mat-label>
|
<mat-label>Product Description</mat-label>
|
||||||
<mat-select required formControlName="autoInterestPostWhen">
|
<input matInput required maxlength="200" formControlName="accountTypeDescription" />
|
||||||
@for (option of autoInterestPostWhenOptions; track option) {
|
</mat-form-field>
|
||||||
<mat-option [value]="option">{{ option }}</mat-option>
|
</div>
|
||||||
}
|
</div>
|
||||||
</mat-select>
|
</mat-tab>
|
||||||
</mat-form-field>
|
|
||||||
|
|
||||||
<!-- Only meaningful while auto-posting is on — the legacy screen hid it the same way. -->
|
<!-- ── Interest & Maturity ─────────────────────────────────────────────────────────────── -->
|
||||||
@if (autoPostOn) {
|
<mat-tab>
|
||||||
<mat-form-field appearance="outline" class="dat-field">
|
<ng-template mat-tab-label>
|
||||||
<mat-label>Auto Interest Post Day</mat-label>
|
<i class="fas fa-percent dat-tab-icon"></i>
|
||||||
<input matInput required type="number" min="1" max="31" formControlName="autoInterestPostDay" />
|
Interest & Maturity
|
||||||
</mat-form-field>
|
@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-label>Interest Rate (%)</mat-label>
|
||||||
|
<input matInput required type="number" min="0" max="100" step="0.01" formControlName="interestRate" />
|
||||||
|
<mat-hint>Interest is worked out daily on the balance (rate ÷ 365)</mat-hint>
|
||||||
|
@if (accountTypeForm.get('interestRate')?.hasError('required')) {
|
||||||
|
<mat-error>Interest rate is required</mat-error>
|
||||||
|
}
|
||||||
|
</mat-form-field>
|
||||||
|
|
||||||
<!--
|
<mat-form-field appearance="outline" class="dat-field">
|
||||||
Asked as Yes/No rather than as a tickbox to match Auto Interest Post directly above it:
|
<mat-label>Auto Interest Post</mat-label>
|
||||||
the two are the same kind of decision, and a screen that asks one of them one way and the
|
<mat-select required formControlName="autoInterestPost">
|
||||||
other another way reads as though they differ in some way they do not.
|
<mat-option [value]="true">Yes</mat-option>
|
||||||
-->
|
<mat-option [value]="false">No</mat-option>
|
||||||
<mat-form-field appearance="outline" class="dat-field">
|
</mat-select>
|
||||||
<mat-label>Maturity Auto Close</mat-label>
|
</mat-form-field>
|
||||||
<mat-select required formControlName="maturityAutoClose">
|
|
||||||
<mat-option [value]="true">Yes</mat-option>
|
|
||||||
<mat-option [value]="false">No</mat-option>
|
|
||||||
</mat-select>
|
|
||||||
<mat-hint>Closes the account on its maturity date, if it names a savings account to be paid into</mat-hint>
|
|
||||||
</mat-form-field>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
|
<mat-form-field appearance="outline" class="dat-field">
|
||||||
|
<mat-label>Auto Interest Post When</mat-label>
|
||||||
|
<mat-select required formControlName="autoInterestPostWhen">
|
||||||
|
@for (option of autoInterestPostWhenOptions; track option) {
|
||||||
|
<mat-option [value]="option">{{ option }}</mat-option>
|
||||||
|
}
|
||||||
|
</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>
|
||||||
|
|
||||||
|
<!--
|
||||||
|
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
|
||||||
|
other another way reads as though they differ in some way they do not.
|
||||||
|
-->
|
||||||
|
<mat-form-field appearance="outline" class="dat-field">
|
||||||
|
<mat-label>Maturity Auto Close</mat-label>
|
||||||
|
<mat-select required formControlName="maturityAutoClose">
|
||||||
|
<mat-option [value]="true">Yes</mat-option>
|
||||||
|
<mat-option [value]="false">No</mat-option>
|
||||||
|
</mat-select>
|
||||||
|
<mat-hint
|
||||||
|
>Closes the account on its maturity date, if it names a savings account to be paid into</mat-hint
|
||||||
|
>
|
||||||
|
</mat-form-field>
|
||||||
|
</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
|
||||||
</p>
|
@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>
|
||||||
|
|
||||||
<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>Minimum Lock-in (months)</mat-label>
|
<mat-label>Minimum Lock-in (months)</mat-label>
|
||||||
<input matInput required type="number" min="0" max="600" formControlName="prematureLockinMonths" />
|
<input matInput required type="number" min="0" max="600" formControlName="prematureLockinMonths" />
|
||||||
<mat-hint>Before this, early closure is refused outright. 0 allows it from day one.</mat-hint>
|
<mat-hint>Before this, early closure is refused outright. 0 allows it from day one.</mat-hint>
|
||||||
</mat-form-field>
|
</mat-form-field>
|
||||||
|
|
||||||
<mat-form-field appearance="outline" class="dat-field">
|
<mat-form-field appearance="outline" class="dat-field">
|
||||||
<mat-label>Customer: interest rate reduced by (%)</mat-label>
|
<mat-label>Customer: interest rate reduced by (%)</mat-label>
|
||||||
<input
|
<input
|
||||||
matInput
|
matInput
|
||||||
required
|
required
|
||||||
type="number"
|
type="number"
|
||||||
min="0"
|
min="0"
|
||||||
max="100"
|
max="100"
|
||||||
step="0.25"
|
step="0.25"
|
||||||
formControlName="prematureInterestReduction"
|
formControlName="prematureInterestReduction"
|
||||||
/>
|
/>
|
||||||
<mat-hint>Taken off the rate for the whole period, not only the part left to run.</mat-hint>
|
<mat-hint>Taken off the rate for the whole period, not only the part left to run.</mat-hint>
|
||||||
</mat-form-field>
|
</mat-form-field>
|
||||||
|
|
||||||
<mat-form-field appearance="outline" class="dat-field">
|
<mat-form-field appearance="outline" class="dat-field">
|
||||||
<mat-label>Agent: commission reduced by (%)</mat-label>
|
<mat-label>Agent: commission reduced by (%)</mat-label>
|
||||||
<input
|
<input
|
||||||
matInput
|
matInput
|
||||||
required
|
required
|
||||||
type="number"
|
type="number"
|
||||||
min="0"
|
min="0"
|
||||||
max="100"
|
max="100"
|
||||||
step="0.25"
|
step="0.25"
|
||||||
formControlName="prematureAgentCommissionPenalty"
|
formControlName="prematureAgentCommissionPenalty"
|
||||||
/>
|
/>
|
||||||
<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>
|
||||||
|
|
||||||
<div class="dat-actions">
|
<!-- ── Accounting ──────────────────────────────────────────────────────────────────────── -->
|
||||||
<button mat-raised-button type="button" (click)="cancel()">Cancel</button>
|
<!--
|
||||||
<button mat-raised-button color="primary" type="button" [disabled]="accountTypeForm.invalid" (click)="submit()">
|
The product's own ledger accounts, laid out the way the savings product's Accounting tab is:
|
||||||
<fa-icon icon="check" style="margin-right: 6px"></fa-icon>
|
grouped by kind, each row with the GL search picker. Only accounts of the right kind are offered.
|
||||||
{{ isEdit ? 'Update' : 'Submit' }}
|
-->
|
||||||
|
<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">
|
||||||
|
<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>
|
||||||
</div>
|
}
|
||||||
</form>
|
<button mat-raised-button color="primary" type="button" [disabled]="accountTypeForm.invalid" (click)="submit()">
|
||||||
</div>
|
<fa-icon icon="check" style="margin-right: 6px"></fa-icon>
|
||||||
|
{{ isEdit ? 'Update' : 'Submit' }}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
+253
-52
@@ -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;
|
||||||
|
}
|
||||||
|
|
||||||
|
&__ic {
|
||||||
|
display: grid;
|
||||||
|
place-items: center;
|
||||||
|
flex: none;
|
||||||
|
width: 46px;
|
||||||
|
height: 46px;
|
||||||
|
border-radius: 12px;
|
||||||
|
font-size: 1.05rem;
|
||||||
|
color: #fff;
|
||||||
|
background: linear-gradient(135deg, $teal, $teal-dark);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
.dat-head__ic {
|
// ── Tabs ─────────────────────────────────────────────────────────────────────────────────────────────
|
||||||
width: 46px;
|
.dat-tabs {
|
||||||
height: 46px;
|
background: #fff;
|
||||||
border-radius: 12px;
|
border-radius: 10px;
|
||||||
display: grid;
|
box-shadow: 0 2px 8px rgb(0 0 0 / 8%);
|
||||||
place-items: center;
|
|
||||||
font-size: 1.05rem;
|
::ng-deep {
|
||||||
color: #fff;
|
.mat-mdc-tab-header {
|
||||||
background: linear-gradient(135deg, #0f766e, #134e4a);
|
border-bottom: 1px solid rgb(0 0 0 / 8%);
|
||||||
flex: none;
|
}
|
||||||
|
|
||||||
|
.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) {
|
||||||
|
|||||||
+164
-31
@@ -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) {
|
||||||
|
|||||||
+193
@@ -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 & 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>
|
||||||
+53
@@ -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;
|
||||||
|
}
|
||||||
+88
@@ -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 }
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -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: '',
|
||||||
|
|||||||
+125
@@ -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>
|
||||||
+30
@@ -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;
|
||||||
|
}
|
||||||
+175
@@ -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)
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
+4
-1
@@ -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">
|
||||||
|
|||||||
+32
-1
@@ -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;
|
||||||
|
|||||||
+39
@@ -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) => ({
|
||||||
|
|||||||
@@ -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. */
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
Reference in New Issue
Block a user