Compare commits
15
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
c766a6f092 | ||
|
|
0275a46326 | ||
|
|
306af59099 | ||
|
|
53bb97d6ed | ||
|
|
293b671966 | ||
|
|
357f666421 | ||
|
|
3e5cc26e49 | ||
|
|
c3713f8a90 | ||
|
|
f804d356d7 | ||
|
|
1daa8e60a0 | ||
|
|
55f5c1cf32 | ||
|
|
6507e68203 | ||
|
|
4d205672df | ||
|
|
ab8ed7e67e | ||
|
|
7ee230e58a |
@@ -10,7 +10,8 @@
|
||||
|
||||
/** Angular Imports */
|
||||
import { Injectable, inject } from '@angular/core';
|
||||
import { HttpClient, HttpParams } from '@angular/common/http';
|
||||
import { HttpClient, HttpContext, HttpParams } from '@angular/common/http';
|
||||
import { SILENT_HTTP_ERROR } from 'app/core/http/silent-error.token';
|
||||
|
||||
/** rxjs Imports */
|
||||
import { Observable } from 'rxjs';
|
||||
@@ -198,6 +199,16 @@ export class AccountingService {
|
||||
return this.http.get('/journalentries/ledger', { params: httpParams });
|
||||
}
|
||||
|
||||
/**
|
||||
* The whole transfer a ledger transaction belongs to, when it is one branch's part of a transfer between branches.
|
||||
* An extra, informational lookup: if it fails the screen simply shows no banner, so a failure raises no error popup.
|
||||
*/
|
||||
getInterBranchTransfer(transactionId: string): Observable<any> {
|
||||
return this.http.get(`/accounting/interbranchtransfers/${transactionId}`, {
|
||||
context: new HttpContext().set(SILENT_HTTP_ERROR, true)
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {string} transactionId Transaction ID to be reverted.
|
||||
* @param {string} comments Any comments.
|
||||
|
||||
@@ -142,6 +142,22 @@ export class AmlService {
|
||||
return this.http.get<any[]>('/aml/ctr-reports/lines', { params });
|
||||
}
|
||||
|
||||
/**
|
||||
* Cash transactions of the threshold or more that no generated report contains yet, shaped like the lines above.
|
||||
* They show on the FIU screen as soon as they are posted; generating the month is what files them.
|
||||
*/
|
||||
getCtrPendingLines(): Observable<any[]> {
|
||||
return this.http.get<any[]>('/aml/ctr-reports/pending-lines');
|
||||
}
|
||||
|
||||
/**
|
||||
* How much came into and went out of each current / Cash Credit account over the last `days` days, with the
|
||||
* customer's declared turnover and the sanctioned limit alongside.
|
||||
*/
|
||||
getCcTracking(days = 30): Observable<any> {
|
||||
return this.http.get<any>('/aml/cc-tracking', { params: new HttpParams().set('days', String(days)) });
|
||||
}
|
||||
|
||||
/** month format: yyyy-MM */
|
||||
generateCtrReport(month: string): Observable<any> {
|
||||
return this.http.post('/aml/ctr-reports/generate', { month });
|
||||
|
||||
@@ -0,0 +1,229 @@
|
||||
<!--
|
||||
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="cv-tab-content cta-tab">
|
||||
<!-- ── TITLE BAR ── -->
|
||||
<div class="cta-action-bar">
|
||||
<div class="cta-action-bar__left">
|
||||
<mat-icon class="cta-action-bar__icon">swap_horiz</mat-icon>
|
||||
<div>
|
||||
<div class="cta-action-bar__title">Cash Transfers</div>
|
||||
<div class="cta-action-bar__sub">
|
||||
Approve or reject an allocation of cash from the vault to a cashier. The cash leaves the vault only when it is
|
||||
approved.
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="cta-counts">
|
||||
<span class="cta-count"
|
||||
><strong>{{ all.length }}</strong> waiting</span
|
||||
>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- ── FILTER BAR ── -->
|
||||
<div class="cta-filter-bar">
|
||||
<div class="cta-search-box">
|
||||
<mat-icon>search</mat-icon>
|
||||
<input type="text" placeholder="Search by cashier, vault, user or note" [(ngModel)]="searchTerm" />
|
||||
@if (searchTerm) {
|
||||
<button type="button" class="cta-search-box__clear" (click)="searchTerm = ''">
|
||||
<mat-icon>close</mat-icon>
|
||||
</button>
|
||||
}
|
||||
</div>
|
||||
<button type="button" class="cta-btn cta-btn--refresh" (click)="load()" [disabled]="loading">
|
||||
<mat-icon>refresh</mat-icon>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- ── TABLE ── -->
|
||||
@if (loading && !loaded) {
|
||||
<div class="cta-empty"><p class="cta-empty__title">Loading…</p></div>
|
||||
} @else if (rows.length === 0) {
|
||||
<div class="cta-empty">
|
||||
<mat-icon class="cta-empty__icon">inbox</mat-icon>
|
||||
<p class="cta-empty__title">No cash transfer waiting</p>
|
||||
<p class="cta-empty__text">There is no cash allocation waiting for approval.</p>
|
||||
</div>
|
||||
} @else {
|
||||
<div class="cta-table-wrap">
|
||||
<table class="cta-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Date</th>
|
||||
<th>From</th>
|
||||
<th>To (Cashier)</th>
|
||||
<th class="num">Amount</th>
|
||||
<th>Note</th>
|
||||
<th>Raised By</th>
|
||||
<th>Raised At</th>
|
||||
<th class="cta-th-action">Action</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
@for (r of rows; track r.id) {
|
||||
<tr class="cta-row">
|
||||
<td class="cta-dt">{{ r.transfer_date || '—' }}</td>
|
||||
<td>{{ r.from_party || '—' }}</td>
|
||||
<td class="cta-strong">{{ r.to_party || '—' }}</td>
|
||||
<td class="num cta-amount">₹{{ r.total_amount | number: '1.2-2' }}</td>
|
||||
<td class="cta-reason" [title]="r.note || ''">{{ r.note || '—' }}</td>
|
||||
<td>{{ r.maker_name || '—' }}</td>
|
||||
<td class="cta-dt">{{ formatDateTime(r.maker_at) }}</td>
|
||||
<td class="cta-action-cell">
|
||||
<button type="button" class="cta-btn cta-btn--approve" (click)="review(r)">
|
||||
<mat-icon>rate_review</mat-icon> Review
|
||||
</button>
|
||||
</td>
|
||||
</tr>
|
||||
}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
|
||||
<!-- ─── Popup: review a request, every note and coin it asks for ───────────── -->
|
||||
<ng-template #reviewDialog>
|
||||
@if (reviewing) {
|
||||
<div class="cta-dlg">
|
||||
<header class="cta-dlg__head">
|
||||
<mat-icon class="cta-dlg__ico cta-dlg__ico--ok">swap_horiz</mat-icon>
|
||||
<div class="cta-dlg__titles">
|
||||
<div class="cta-dlg__title">Review cash transfer</div>
|
||||
<div class="cta-dlg__sub">
|
||||
Raised by {{ reviewing.maker_name || '—' }} · {{ formatDateTime(reviewing.maker_at) }}
|
||||
</div>
|
||||
</div>
|
||||
<button type="button" class="cta-dlg__x" aria-label="Close" (click)="closeReview()">
|
||||
<mat-icon>close</mat-icon>
|
||||
</button>
|
||||
</header>
|
||||
|
||||
<div class="cta-dlg__body">
|
||||
<div class="cta-total">
|
||||
<span>Amount to release</span>
|
||||
<strong>₹{{ reviewing.total_amount | number: '1.2-2' }}</strong>
|
||||
</div>
|
||||
|
||||
<div class="cta-grid">
|
||||
<div class="cta-cell">
|
||||
<div class="cta-label">Transfer date</div>
|
||||
<div class="cta-value">{{ reviewing.transfer_date || '—' }}</div>
|
||||
</div>
|
||||
<div class="cta-cell">
|
||||
<div class="cta-label">From</div>
|
||||
<div class="cta-value">{{ reviewing.from_party || '—' }}</div>
|
||||
</div>
|
||||
<div class="cta-cell">
|
||||
<div class="cta-label">To (cashier)</div>
|
||||
<div class="cta-value">{{ reviewing.to_party || '—' }}</div>
|
||||
</div>
|
||||
<div class="cta-cell">
|
||||
<div class="cta-label">Notes and coins</div>
|
||||
<div class="cta-value">
|
||||
{{ piecesOf(reviewing) }} pieces · {{ linesOf(reviewing).length }} denominations
|
||||
</div>
|
||||
</div>
|
||||
@if (reviewing.note) {
|
||||
<div class="cta-cell cta-cell--wide">
|
||||
<div class="cta-label">Note</div>
|
||||
<div class="cta-value">{{ reviewing.note }}</div>
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
|
||||
<div class="cta-label">Denomination breakdown</div>
|
||||
<table class="cta-mini">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Denomination</th>
|
||||
<th class="num">Count</th>
|
||||
<th class="num">Amount</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
@for (l of linesOf(reviewing); track l.value) {
|
||||
<tr>
|
||||
<td>₹{{ l.value }}</td>
|
||||
<td class="num">{{ l.count }}</td>
|
||||
<td class="num">₹{{ l.amount | number: '1.2-2' }}</td>
|
||||
</tr>
|
||||
}
|
||||
</tbody>
|
||||
<tfoot>
|
||||
<tr>
|
||||
<td>Total</td>
|
||||
<td class="num">{{ piecesOf(reviewing) }}</td>
|
||||
<td class="num">₹{{ reviewing.total_amount | number: '1.2-2' }}</td>
|
||||
</tr>
|
||||
</tfoot>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
<footer class="cta-dlg__foot">
|
||||
<button type="button" class="cta-btn cta-btn--ghost" (click)="closeReview()">Close</button>
|
||||
<button
|
||||
type="button"
|
||||
class="cta-btn cta-btn--reject"
|
||||
[disabled]="busyId === reviewing.id"
|
||||
(click)="rejectFromReview(reviewing)"
|
||||
>
|
||||
<mat-icon>close</mat-icon> Reject
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
class="cta-btn cta-btn--approve"
|
||||
[disabled]="busyId === reviewing.id"
|
||||
(click)="approve(reviewing)"
|
||||
>
|
||||
<mat-icon>check</mat-icon> Approve
|
||||
</button>
|
||||
</footer>
|
||||
</div>
|
||||
}
|
||||
</ng-template>
|
||||
|
||||
<!-- ─── Popup: reject with a reason ───────────────────────────────────────── -->
|
||||
<ng-template #rejectDialog>
|
||||
@if (rejecting) {
|
||||
<div class="cta-dlg">
|
||||
<header class="cta-dlg__head">
|
||||
<mat-icon class="cta-dlg__ico">block</mat-icon>
|
||||
<div class="cta-dlg__titles">
|
||||
<div class="cta-dlg__title">Reject cash transfer</div>
|
||||
<div class="cta-dlg__sub">₹{{ rejecting.total_amount | number: '1.2-2' }} to {{ rejecting.to_party }}</div>
|
||||
</div>
|
||||
<button type="button" class="cta-dlg__x" aria-label="Close" (click)="cancelReject()">
|
||||
<mat-icon>close</mat-icon>
|
||||
</button>
|
||||
</header>
|
||||
<div class="cta-dlg__body">
|
||||
<label class="cta-label" for="cta-reason">Reason (required)</label>
|
||||
<textarea
|
||||
id="cta-reason"
|
||||
rows="3"
|
||||
placeholder="Why is this being rejected?"
|
||||
[(ngModel)]="rejectReason"
|
||||
></textarea>
|
||||
</div>
|
||||
<footer class="cta-dlg__foot">
|
||||
<button type="button" class="cta-btn cta-btn--ghost" (click)="cancelReject()">Cancel</button>
|
||||
<button
|
||||
type="button"
|
||||
class="cta-btn cta-btn--reject-solid"
|
||||
[disabled]="!rejectReason.trim() || busyId === rejecting.id"
|
||||
(click)="confirmReject()"
|
||||
>
|
||||
Confirm reject
|
||||
</button>
|
||||
</footer>
|
||||
</div>
|
||||
}
|
||||
</ng-template>
|
||||
@@ -0,0 +1,573 @@
|
||||
/**
|
||||
* 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/.
|
||||
*/
|
||||
|
||||
// Laid out like the other approval tabs (Withdrawal Requests): title bar, filter bar, one table.
|
||||
:host {
|
||||
display: block;
|
||||
}
|
||||
|
||||
.cta-tab {
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
.cta-action-bar {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 16px;
|
||||
padding: 10px 16px;
|
||||
background: #fff;
|
||||
border-bottom: 1px solid #e2e8f0;
|
||||
|
||||
&__left {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
&__icon {
|
||||
font-size: 22px;
|
||||
width: 22px;
|
||||
height: 22px;
|
||||
color: #0f4f3c;
|
||||
}
|
||||
|
||||
&__title {
|
||||
font-size: 13px;
|
||||
font-weight: 700;
|
||||
color: #0d1b2a;
|
||||
}
|
||||
|
||||
&__sub {
|
||||
max-width: 640px;
|
||||
margin-top: 1px;
|
||||
font-size: 11px;
|
||||
color: #64748b;
|
||||
}
|
||||
}
|
||||
|
||||
.cta-counts {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.cta-count {
|
||||
padding: 2px 10px;
|
||||
border-radius: 999px;
|
||||
background: #f1f5f9;
|
||||
font-size: 11px;
|
||||
color: #475569;
|
||||
|
||||
strong {
|
||||
color: #0f4f3c;
|
||||
}
|
||||
}
|
||||
|
||||
.cta-filter-bar {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
flex-wrap: wrap;
|
||||
padding: 6px 16px;
|
||||
background: #f8fafc;
|
||||
border-bottom: 1px solid #e2e8f0;
|
||||
}
|
||||
|
||||
.cta-fb-sep {
|
||||
flex-shrink: 0;
|
||||
width: 1px;
|
||||
height: 22px;
|
||||
margin: 0 4px;
|
||||
background: #e2e8f0;
|
||||
}
|
||||
|
||||
.cta-search-box {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
height: 30px;
|
||||
min-width: 240px;
|
||||
padding: 0 10px;
|
||||
border: 1px solid #e2e8f0;
|
||||
border-radius: 8px;
|
||||
background: #fff;
|
||||
|
||||
mat-icon {
|
||||
flex-shrink: 0;
|
||||
font-size: 17px;
|
||||
width: 17px;
|
||||
height: 17px;
|
||||
color: #94a3b8;
|
||||
}
|
||||
|
||||
&:hover {
|
||||
border-color: #cbd5e1;
|
||||
}
|
||||
|
||||
&:focus-within {
|
||||
border-color: #0f4f3c;
|
||||
box-shadow: 0 0 0 3px rgb(15 79 60 / 8%);
|
||||
}
|
||||
|
||||
input {
|
||||
flex: 1;
|
||||
border: none;
|
||||
outline: none;
|
||||
background: transparent;
|
||||
font-family: inherit;
|
||||
font-size: 12px;
|
||||
color: #0d1b2a;
|
||||
|
||||
&::placeholder {
|
||||
color: #94a3b8;
|
||||
}
|
||||
}
|
||||
|
||||
&__clear {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
flex-shrink: 0;
|
||||
width: 20px;
|
||||
height: 20px;
|
||||
padding: 0;
|
||||
border: none;
|
||||
border-radius: 50%;
|
||||
background: none;
|
||||
color: #94a3b8;
|
||||
cursor: pointer;
|
||||
|
||||
mat-icon {
|
||||
font-size: 15px;
|
||||
width: 15px;
|
||||
height: 15px;
|
||||
color: inherit;
|
||||
}
|
||||
|
||||
&:hover {
|
||||
color: #dc2626;
|
||||
background: #fef2f2;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.cta-filter-select {
|
||||
height: 30px;
|
||||
min-width: 140px;
|
||||
padding: 0 10px;
|
||||
border: 1px solid #e2e8f0;
|
||||
border-radius: 7px;
|
||||
background: #fff;
|
||||
font-family: inherit;
|
||||
font-size: 12px;
|
||||
color: #0d1b2a;
|
||||
cursor: pointer;
|
||||
|
||||
&:focus {
|
||||
outline: none;
|
||||
border-color: #0f4f3c;
|
||||
}
|
||||
}
|
||||
|
||||
// ── Buttons ─────────────────────────────────────────────────────────────────
|
||||
.cta-btn {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
height: 26px;
|
||||
padding: 0 10px;
|
||||
border: 1px solid transparent;
|
||||
border-radius: 7px;
|
||||
font-family: inherit;
|
||||
font-size: 11px;
|
||||
font-weight: 600;
|
||||
white-space: nowrap;
|
||||
cursor: pointer;
|
||||
|
||||
mat-icon {
|
||||
font-size: 14px;
|
||||
width: 14px;
|
||||
height: 14px;
|
||||
}
|
||||
|
||||
&:disabled {
|
||||
opacity: 0.45;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
&--refresh {
|
||||
height: 30px;
|
||||
width: 30px;
|
||||
justify-content: center;
|
||||
padding: 0;
|
||||
margin-left: auto;
|
||||
border-color: #e2e8f0;
|
||||
background: #fff;
|
||||
color: #475569;
|
||||
}
|
||||
|
||||
&--approve {
|
||||
background: #0f4f3c;
|
||||
color: #fff;
|
||||
|
||||
&:hover:not(:disabled) {
|
||||
background: #0a3d2e;
|
||||
}
|
||||
}
|
||||
|
||||
&--reject {
|
||||
border-color: #fecaca;
|
||||
background: #fff;
|
||||
color: #dc2626;
|
||||
|
||||
&:hover:not(:disabled) {
|
||||
background: #fef2f2;
|
||||
}
|
||||
}
|
||||
|
||||
&--reject-solid {
|
||||
background: #dc2626;
|
||||
color: #fff;
|
||||
}
|
||||
|
||||
&--ghost {
|
||||
border-color: #e2e8f0;
|
||||
background: #fff;
|
||||
color: #475569;
|
||||
}
|
||||
}
|
||||
|
||||
// ── Table ───────────────────────────────────────────────────────────────────
|
||||
.cta-table-wrap {
|
||||
overflow-x: auto;
|
||||
background: #fff;
|
||||
}
|
||||
|
||||
.cta-table {
|
||||
width: 100%;
|
||||
border-collapse: collapse;
|
||||
font-size: 11px;
|
||||
|
||||
th {
|
||||
padding: 7px 10px;
|
||||
border-bottom: 1px solid #e2e8f0;
|
||||
background: #f8fafc;
|
||||
font-size: 9.5px;
|
||||
font-weight: 800;
|
||||
letter-spacing: 0.06em;
|
||||
text-align: left;
|
||||
text-transform: uppercase;
|
||||
color: #94a3b8;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
td {
|
||||
padding: 5px 10px;
|
||||
border-bottom: 1px solid #eef2f7;
|
||||
vertical-align: middle;
|
||||
color: #0d1b2a;
|
||||
}
|
||||
|
||||
.num {
|
||||
text-align: right;
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
|
||||
.cta-th-action {
|
||||
text-align: right;
|
||||
}
|
||||
}
|
||||
|
||||
.cta-row:hover td {
|
||||
background: #f8fafc;
|
||||
}
|
||||
|
||||
.cta-mono {
|
||||
font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace;
|
||||
letter-spacing: 0.02em;
|
||||
}
|
||||
|
||||
.cta-voucher {
|
||||
font-weight: 700;
|
||||
color: #0f4f3c;
|
||||
}
|
||||
|
||||
.cta-strong {
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.cta-sub {
|
||||
margin-top: 1px;
|
||||
font-size: 11px;
|
||||
color: #64748b;
|
||||
}
|
||||
|
||||
.cta-none {
|
||||
color: #cbd5e1;
|
||||
}
|
||||
|
||||
.cta-amount {
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.cta-reason {
|
||||
max-width: 220px;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.cta-dt {
|
||||
white-space: nowrap;
|
||||
color: #475569;
|
||||
}
|
||||
|
||||
.cta-kind {
|
||||
display: inline-block;
|
||||
padding: 2px 10px;
|
||||
border-radius: 999px;
|
||||
font-size: 11px;
|
||||
font-weight: 700;
|
||||
|
||||
&--unpass {
|
||||
background: #fef3c7;
|
||||
color: #92400e;
|
||||
}
|
||||
|
||||
&--correction {
|
||||
background: #dbeafe;
|
||||
color: #1e40af;
|
||||
}
|
||||
}
|
||||
|
||||
.cta-action-cell {
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
gap: 6px;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
// ── Empty ───────────────────────────────────────────────────────────────────
|
||||
.cta-empty {
|
||||
padding: 28px 16px;
|
||||
text-align: center;
|
||||
color: #94a3b8;
|
||||
|
||||
&__icon {
|
||||
display: block;
|
||||
margin: 0 auto 6px;
|
||||
font-size: 30px;
|
||||
width: 30px;
|
||||
height: 30px;
|
||||
opacity: 0.6;
|
||||
}
|
||||
|
||||
&__title {
|
||||
margin: 0;
|
||||
font-size: 12px;
|
||||
font-weight: 700;
|
||||
color: #64748b;
|
||||
}
|
||||
|
||||
&__text {
|
||||
margin: 3px 0 0;
|
||||
font-size: 11px;
|
||||
}
|
||||
}
|
||||
|
||||
// ── Reject popup ────────────────────────────────────────────────────────────
|
||||
.cta-dlg {
|
||||
font-size: 12px;
|
||||
|
||||
&__head {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
padding: 10px 14px;
|
||||
border-bottom: 1px solid #e2e8f0;
|
||||
background: #f8fafc;
|
||||
}
|
||||
|
||||
&__ico {
|
||||
flex: 0 0 32px;
|
||||
height: 32px;
|
||||
width: 32px;
|
||||
border-radius: 9px;
|
||||
background: #fef2f2;
|
||||
color: #dc2626;
|
||||
font-size: 18px;
|
||||
line-height: 32px;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
&__titles {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
&__title {
|
||||
font-size: 14px;
|
||||
font-weight: 700;
|
||||
color: #0f172a;
|
||||
}
|
||||
|
||||
&__sub {
|
||||
font-size: 11px;
|
||||
color: #64748b;
|
||||
}
|
||||
|
||||
&__x {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
height: 28px;
|
||||
width: 28px;
|
||||
border: 0;
|
||||
border-radius: 50%;
|
||||
background: transparent;
|
||||
color: #64748b;
|
||||
cursor: pointer;
|
||||
|
||||
&:hover {
|
||||
background: #e2e8f0;
|
||||
}
|
||||
|
||||
mat-icon {
|
||||
font-size: 18px;
|
||||
height: 18px;
|
||||
width: 18px;
|
||||
}
|
||||
}
|
||||
|
||||
&__body {
|
||||
padding: 12px 14px;
|
||||
max-height: 64vh;
|
||||
overflow-y: auto;
|
||||
|
||||
textarea {
|
||||
width: 100%;
|
||||
padding: 7px 10px;
|
||||
border: 1px solid #cbd5e1;
|
||||
border-radius: 8px;
|
||||
font: inherit;
|
||||
font-size: 12px;
|
||||
resize: vertical;
|
||||
|
||||
&:focus {
|
||||
outline: 0;
|
||||
border-color: #0f4f3c;
|
||||
box-shadow: 0 0 0 3px rgb(15 79 60 / 8%);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
&__foot {
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
gap: 8px;
|
||||
padding: 10px 14px;
|
||||
border-top: 1px solid #e2e8f0;
|
||||
background: #f8fafc;
|
||||
}
|
||||
}
|
||||
|
||||
.cta-label {
|
||||
display: block;
|
||||
margin-bottom: 4px;
|
||||
font-size: 10px;
|
||||
font-weight: 700;
|
||||
letter-spacing: 0.05em;
|
||||
text-transform: uppercase;
|
||||
color: #94a3b8;
|
||||
}
|
||||
|
||||
.cta-dlg__ico--ok {
|
||||
background: #f0fdf4;
|
||||
color: #1f7a4d;
|
||||
}
|
||||
|
||||
.cta-total {
|
||||
display: flex;
|
||||
align-items: baseline;
|
||||
justify-content: space-between;
|
||||
margin-bottom: 10px;
|
||||
padding: 8px 12px;
|
||||
border-radius: 10px;
|
||||
background: #ecfdf5;
|
||||
border: 1px solid #a7f3d0;
|
||||
color: #065f46;
|
||||
font-size: 12px;
|
||||
font-weight: 600;
|
||||
|
||||
strong {
|
||||
font-size: 18px;
|
||||
font-weight: 800;
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
}
|
||||
|
||||
.cta-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fit, minmax(150px, 1fr));
|
||||
gap: 6px;
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
|
||||
.cta-cell {
|
||||
padding: 6px 10px;
|
||||
border-radius: 8px;
|
||||
background: #f8fafc;
|
||||
border: 1px solid #eef2f7;
|
||||
|
||||
&--wide {
|
||||
grid-column: 1 / -1;
|
||||
}
|
||||
}
|
||||
|
||||
.cta-value {
|
||||
font-size: 12px;
|
||||
font-weight: 600;
|
||||
color: #0f172a;
|
||||
word-break: break-word;
|
||||
}
|
||||
|
||||
.cta-mini {
|
||||
width: 100%;
|
||||
margin-top: 4px;
|
||||
border-collapse: collapse;
|
||||
font-size: 12px;
|
||||
|
||||
th {
|
||||
padding: 6px 10px;
|
||||
border-bottom: 1px solid #e2e8f0;
|
||||
background: #f8fafc;
|
||||
font-size: 10px;
|
||||
font-weight: 800;
|
||||
letter-spacing: 0.05em;
|
||||
text-align: left;
|
||||
text-transform: uppercase;
|
||||
color: #94a3b8;
|
||||
}
|
||||
|
||||
td {
|
||||
padding: 5px 10px;
|
||||
border-bottom: 1px solid #eef2f7;
|
||||
}
|
||||
|
||||
.num {
|
||||
text-align: right;
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
|
||||
tfoot td {
|
||||
border-top: 2px solid #e2e8f0;
|
||||
border-bottom: 0;
|
||||
font-weight: 800;
|
||||
background: #f8fafc;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,190 @@
|
||||
/**
|
||||
* Copyright since 2025 Mifos Initiative
|
||||
*
|
||||
* This Source Code Form is subject to the terms of the Mozilla Public
|
||||
* License, v. 2.0. If a copy of the MPL was not distributed with this
|
||||
* file, You can obtain one at http://mozilla.org/MPL/2.0/.
|
||||
*/
|
||||
|
||||
import { Component, OnInit, TemplateRef, ViewChild, inject } from '@angular/core';
|
||||
import { CommonModule } from '@angular/common';
|
||||
import { FormsModule } from '@angular/forms';
|
||||
import { MatDialog, MatDialogModule, MatDialogRef } from '@angular/material/dialog';
|
||||
import { MatIconModule } from '@angular/material/icon';
|
||||
import { MatSnackBar, MatSnackBarModule } from '@angular/material/snack-bar';
|
||||
import { of } from 'rxjs';
|
||||
import { catchError } from 'rxjs/operators';
|
||||
import { CashierService } from '../cashier.service';
|
||||
|
||||
/** A cash transfer request waiting for a checker, as the server keeps it (snake_case columns). */
|
||||
interface CashTransferRequest {
|
||||
id: number;
|
||||
transfer_date: string | null;
|
||||
from_party: string | null;
|
||||
to_party: string | null;
|
||||
note: string | null;
|
||||
total_amount: number;
|
||||
maker_name: string | null;
|
||||
maker_at: string | null;
|
||||
denominations: { denominationValue: number; count: number }[];
|
||||
}
|
||||
|
||||
/**
|
||||
* Checker side of Cash Transfer: approves or rejects an allocation of cash from the vault to a cashier. The cash
|
||||
* leaves the vault only when it is approved, by someone other than the person who raised it.
|
||||
*/
|
||||
@Component({
|
||||
selector: 'mifosx-cash-transfer-approvals',
|
||||
templateUrl: './cash-transfer-approvals.component.html',
|
||||
styleUrls: ['./cash-transfer-approvals.component.scss'],
|
||||
standalone: true,
|
||||
imports: [
|
||||
CommonModule,
|
||||
FormsModule,
|
||||
MatDialogModule,
|
||||
MatIconModule,
|
||||
MatSnackBarModule
|
||||
]
|
||||
})
|
||||
export class CashTransferApprovalsComponent implements OnInit {
|
||||
private service = inject(CashierService);
|
||||
private snackBar = inject(MatSnackBar);
|
||||
private dialog = inject(MatDialog);
|
||||
|
||||
@ViewChild('rejectDialog') rejectDialog!: TemplateRef<unknown>;
|
||||
@ViewChild('reviewDialog') reviewDialog!: TemplateRef<unknown>;
|
||||
private dialogRef: MatDialogRef<unknown> | null = null;
|
||||
private reviewRef: MatDialogRef<unknown> | null = null;
|
||||
|
||||
/** The request open in the review popup, with every note and coin it asks for. */
|
||||
reviewing: CashTransferRequest | null = null;
|
||||
|
||||
all: CashTransferRequest[] = [];
|
||||
loading = false;
|
||||
loaded = false;
|
||||
busyId: number | null = null;
|
||||
searchTerm = '';
|
||||
|
||||
rejecting: CashTransferRequest | null = null;
|
||||
rejectReason = '';
|
||||
|
||||
ngOnInit(): void {
|
||||
this.load();
|
||||
}
|
||||
|
||||
load(): void {
|
||||
this.loading = true;
|
||||
this.service
|
||||
.getCashTransferRequests('PENDING')
|
||||
.pipe(catchError(() => of([] as CashTransferRequest[])))
|
||||
.subscribe((rows) => {
|
||||
this.all = rows as CashTransferRequest[];
|
||||
this.loading = false;
|
||||
this.loaded = true;
|
||||
});
|
||||
}
|
||||
|
||||
get rows(): CashTransferRequest[] {
|
||||
const term = this.searchTerm.trim().toLowerCase();
|
||||
if (!term) return this.all;
|
||||
return this.all.filter((r) => [
|
||||
r.from_party,
|
||||
r.to_party,
|
||||
r.maker_name,
|
||||
r.note
|
||||
].some((v) => (v ?? '').toLowerCase().includes(term)));
|
||||
}
|
||||
|
||||
/** One line per denomination asked for, largest first, with what each line adds up to. */
|
||||
linesOf(r: CashTransferRequest): { value: number; count: number; amount: number }[] {
|
||||
return [...(r.denominations ?? [])]
|
||||
.filter((d) => d.count > 0)
|
||||
.sort((a, b) => b.denominationValue - a.denominationValue)
|
||||
.map((d) => ({ value: d.denominationValue, count: d.count, amount: d.denominationValue * d.count }));
|
||||
}
|
||||
|
||||
/** How many notes and coins the request asks for in all. */
|
||||
piecesOf(r: CashTransferRequest): number {
|
||||
return this.linesOf(r).reduce((sum, l) => sum + l.count, 0);
|
||||
}
|
||||
|
||||
review(r: CashTransferRequest): void {
|
||||
this.reviewing = r;
|
||||
this.reviewRef?.close();
|
||||
this.reviewRef = this.dialog.open(this.reviewDialog, { width: '560px', maxWidth: '95vw', autoFocus: false });
|
||||
this.reviewRef.afterClosed().subscribe(() => (this.reviewing = null));
|
||||
}
|
||||
|
||||
closeReview(): void {
|
||||
this.reviewRef?.close();
|
||||
}
|
||||
|
||||
/** Rejecting from the review popup: the review closes, the reason popup opens for the same request. */
|
||||
rejectFromReview(r: CashTransferRequest): void {
|
||||
this.reviewRef?.close();
|
||||
this.startReject(r);
|
||||
}
|
||||
|
||||
formatDateTime(value: string | null): string {
|
||||
if (!value) return '—';
|
||||
const d = new Date(value);
|
||||
return isNaN(d.getTime())
|
||||
? value
|
||||
: d.toLocaleString('en-IN', { day: '2-digit', month: 'short', hour: '2-digit', minute: '2-digit' });
|
||||
}
|
||||
|
||||
approve(r: CashTransferRequest): void {
|
||||
if (this.busyId) return;
|
||||
this.busyId = r.id;
|
||||
this.service.approveCashTransferRequest(r.id).subscribe({
|
||||
next: (res: any) => {
|
||||
this.busyId = null;
|
||||
this.reviewRef?.close();
|
||||
this.snackBar.open(
|
||||
res?.glWarning ? `Approved (GL warning: ${res.glWarning})` : 'Approved — cash allocated to the cashier',
|
||||
'Close',
|
||||
{ duration: 6000 }
|
||||
);
|
||||
this.load();
|
||||
},
|
||||
error: (err) => {
|
||||
this.busyId = null;
|
||||
this.fail(err, 'Could not approve');
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
startReject(r: CashTransferRequest): void {
|
||||
this.rejecting = r;
|
||||
this.rejectReason = '';
|
||||
this.dialogRef = this.dialog.open(this.rejectDialog, { width: '440px', maxWidth: '95vw', autoFocus: false });
|
||||
this.dialogRef.afterClosed().subscribe(() => (this.rejecting = null));
|
||||
}
|
||||
|
||||
cancelReject(): void {
|
||||
this.dialogRef?.close();
|
||||
}
|
||||
|
||||
confirmReject(): void {
|
||||
if (!this.rejecting || !this.rejectReason.trim() || this.busyId) return;
|
||||
const id = this.rejecting.id;
|
||||
this.busyId = id;
|
||||
this.service.rejectCashTransferRequest(id, this.rejectReason.trim()).subscribe({
|
||||
next: () => {
|
||||
this.busyId = null;
|
||||
this.dialogRef?.close();
|
||||
this.snackBar.open('Rejected', 'Close', { duration: 4000 });
|
||||
this.load();
|
||||
},
|
||||
error: (err) => {
|
||||
this.busyId = null;
|
||||
this.fail(err, 'Could not reject');
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
private fail(err: any, fallback: string): void {
|
||||
const msg = err?.error?.errors?.[0]?.defaultUserMessage ?? err?.error?.defaultUserMessage ?? fallback;
|
||||
this.snackBar.open(msg, 'Close', { duration: 8000 });
|
||||
}
|
||||
}
|
||||
@@ -13,7 +13,15 @@
|
||||
<h1 class="pg-title">Cash Transfer</h1>
|
||||
<p class="pg-sub">Move cash denomination-wise from one party to another (e.g. Vault → Cashier for opening).</p>
|
||||
</div>
|
||||
<button type="button" class="pg-btn pg-btn--ghost" (click)="clear()"><mat-icon>undo</mat-icon> Clear</button>
|
||||
<div class="ct-head-actions">
|
||||
<button type="button" class="pg-btn pg-btn--ghost" (click)="openRequests()">
|
||||
<mat-icon>pending_actions</mat-icon> My Requests
|
||||
@if (pendingCount) {
|
||||
<span class="ct-badge">{{ pendingCount }}</span>
|
||||
}
|
||||
</button>
|
||||
<button type="button" class="pg-btn pg-btn--ghost" (click)="clear()"><mat-icon>undo</mat-icon> Clear</button>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<div class="pg-toolbar pg-toolbar--mat">
|
||||
@@ -155,7 +163,56 @@
|
||||
<div class="pg-total-bar">
|
||||
Total to transfer: <strong>₹{{ total | number: '1.2-2' }}</strong>
|
||||
<button type="button" class="pg-btn pg-btn--primary" [disabled]="!valid || saving" (click)="submit()">
|
||||
<mat-icon>swap_horiz</mat-icon> Record Transfer
|
||||
<mat-icon>send</mat-icon> Send for approval
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Popup: this person's own cash transfer requests, and where each stands. The cash moves only once a manager approves. -->
|
||||
<ng-template #requestsDialog>
|
||||
<div class="ct-dlg">
|
||||
<header class="ct-dlg__head">
|
||||
<mat-icon class="ct-dlg__ico">pending_actions</mat-icon>
|
||||
<div class="ct-dlg__titles">
|
||||
<div class="ct-dlg__title">My transfer requests</div>
|
||||
<div class="ct-dlg__sub">Cash leaves the vault only after a manager approves</div>
|
||||
</div>
|
||||
<button type="button" class="ct-dlg__x" aria-label="Close" (click)="closeRequests()">
|
||||
<mat-icon>close</mat-icon>
|
||||
</button>
|
||||
</header>
|
||||
<div class="ct-dlg__body">
|
||||
@if (myRequests.length) {
|
||||
<table class="pg-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Date</th>
|
||||
<th>To</th>
|
||||
<th class="num">Amount</th>
|
||||
<th>Status</th>
|
||||
<th>Approved by</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
@for (r of myRequests; track r.id) {
|
||||
<tr>
|
||||
<td>{{ r.transfer_date || '—' }}</td>
|
||||
<td>{{ r.to_party || '—' }}</td>
|
||||
<td class="num">₹{{ r.total_amount | number: '1.2-2' }}</td>
|
||||
<td>
|
||||
<span class="ct-status" [class]="'ct-status ct-status--' + r.status">{{ r.status }}</span>
|
||||
@if (r.rejection_reason) {
|
||||
<div class="ct-requests__note">{{ r.rejection_reason }}</div>
|
||||
}
|
||||
</td>
|
||||
<td>{{ r.checker_name || '—' }}</td>
|
||||
</tr>
|
||||
}
|
||||
</tbody>
|
||||
</table>
|
||||
} @else {
|
||||
<div class="pg-empty">You have not raised any cash transfer request yet.</div>
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
</ng-template>
|
||||
|
||||
@@ -49,3 +49,131 @@
|
||||
font-size: 12px;
|
||||
color: #991b1b;
|
||||
}
|
||||
|
||||
.ct-requests {
|
||||
padding: 0;
|
||||
|
||||
&__title {
|
||||
padding: 10px 14px;
|
||||
border-bottom: 1px solid #e2e8f0;
|
||||
font-size: 13px;
|
||||
font-weight: 700;
|
||||
color: #0d1b2a;
|
||||
}
|
||||
|
||||
&__note {
|
||||
margin-top: 2px;
|
||||
font-size: 11px;
|
||||
color: #64748b;
|
||||
}
|
||||
}
|
||||
|
||||
.ct-status {
|
||||
display: inline-block;
|
||||
padding: 2px 10px;
|
||||
border-radius: 999px;
|
||||
background: #e2e8f0;
|
||||
font-size: 11px;
|
||||
font-weight: 700;
|
||||
|
||||
&--PENDING {
|
||||
background: #fef3c7;
|
||||
color: #92400e;
|
||||
}
|
||||
|
||||
&--APPROVED {
|
||||
background: #d1fae5;
|
||||
color: #065f46;
|
||||
}
|
||||
|
||||
&--REJECTED {
|
||||
background: #fee2e2;
|
||||
color: #991b1b;
|
||||
}
|
||||
}
|
||||
|
||||
.ct-head-actions {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.ct-badge {
|
||||
min-width: 18px;
|
||||
padding: 0 6px;
|
||||
border-radius: 999px;
|
||||
background: #f59e0b;
|
||||
color: #fff;
|
||||
font-size: 11px;
|
||||
font-weight: 700;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.ct-dlg {
|
||||
font-size: 12px;
|
||||
|
||||
&__head {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
padding: 10px 14px;
|
||||
border-bottom: 1px solid #e2e8f0;
|
||||
background: #f8fafc;
|
||||
}
|
||||
|
||||
&__ico {
|
||||
flex: 0 0 32px;
|
||||
height: 32px;
|
||||
width: 32px;
|
||||
border-radius: 9px;
|
||||
background: #f0fdf4;
|
||||
color: #1f7a4d;
|
||||
font-size: 18px;
|
||||
line-height: 32px;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
&__titles {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
&__title {
|
||||
font-size: 14px;
|
||||
font-weight: 700;
|
||||
color: #0f172a;
|
||||
}
|
||||
|
||||
&__sub {
|
||||
font-size: 11px;
|
||||
color: #64748b;
|
||||
}
|
||||
|
||||
&__x {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
height: 28px;
|
||||
width: 28px;
|
||||
border: 0;
|
||||
border-radius: 50%;
|
||||
background: transparent;
|
||||
color: #64748b;
|
||||
cursor: pointer;
|
||||
|
||||
&:hover {
|
||||
background: #e2e8f0;
|
||||
}
|
||||
|
||||
mat-icon {
|
||||
font-size: 18px;
|
||||
height: 18px;
|
||||
width: 18px;
|
||||
}
|
||||
}
|
||||
|
||||
&__body {
|
||||
max-height: 60vh;
|
||||
overflow-y: auto;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -6,9 +6,10 @@
|
||||
* file, You can obtain one at http://mozilla.org/MPL/2.0/.
|
||||
*/
|
||||
|
||||
import { Component, OnInit, inject } from '@angular/core';
|
||||
import { Component, OnInit, TemplateRef, ViewChild, inject } from '@angular/core';
|
||||
import { CommonModule } from '@angular/common';
|
||||
import { FormsModule } from '@angular/forms';
|
||||
import { MatDialog, MatDialogModule, MatDialogRef } from '@angular/material/dialog';
|
||||
import { MatIconModule } from '@angular/material/icon';
|
||||
import { MatSnackBar, MatSnackBarModule } from '@angular/material/snack-bar';
|
||||
import { MatFormFieldModule } from '@angular/material/form-field';
|
||||
@@ -18,6 +19,7 @@ import { of } from 'rxjs';
|
||||
import { catchError } from 'rxjs/operators';
|
||||
import { CashierService } from '../cashier.service';
|
||||
import { businessToday } from 'app/core/utils/business-today';
|
||||
import { AuthenticationService } from '../../core/authentication/authentication.service';
|
||||
|
||||
interface Denom {
|
||||
id: number;
|
||||
@@ -39,6 +41,7 @@ interface Denom {
|
||||
CommonModule,
|
||||
FormsModule,
|
||||
MatIconModule,
|
||||
MatDialogModule,
|
||||
MatSnackBarModule,
|
||||
MatFormFieldModule,
|
||||
MatSelectModule,
|
||||
@@ -48,6 +51,11 @@ interface Denom {
|
||||
export class CashTransferComponent implements OnInit {
|
||||
private service = inject(CashierService);
|
||||
private snackBar = inject(MatSnackBar);
|
||||
private dialog = inject(MatDialog);
|
||||
|
||||
@ViewChild('requestsDialog') requestsDialog!: TemplateRef<unknown>;
|
||||
private requestsRef: MatDialogRef<unknown> | null = null;
|
||||
private authenticationService = inject(AuthenticationService);
|
||||
|
||||
notes: Denom[] = [];
|
||||
coins: Denom[] = [];
|
||||
@@ -82,7 +90,34 @@ export class CashTransferComponent implements OnInit {
|
||||
vaultHasBreakdown = true;
|
||||
loadingVault = false;
|
||||
|
||||
/** The caller's own transfer requests, newest first, so a request can be followed to its approval. */
|
||||
myRequests: any[] = [];
|
||||
|
||||
loadMyRequests(): void {
|
||||
this.service
|
||||
.getCashTransferRequests(undefined, true)
|
||||
.pipe(catchError(() => of([] as any[])))
|
||||
.subscribe((rows) => (this.myRequests = rows.slice(0, 20)));
|
||||
}
|
||||
|
||||
/** How many of this person's requests are still waiting for a manager. */
|
||||
get pendingCount(): number {
|
||||
return this.myRequests.filter((r) => r.status === 'PENDING').length;
|
||||
}
|
||||
|
||||
/** Opens the list of this person's requests, freshly read, so a request can be followed to its approval. */
|
||||
openRequests(): void {
|
||||
this.loadMyRequests();
|
||||
this.requestsRef?.close();
|
||||
this.requestsRef = this.dialog.open(this.requestsDialog, { width: '720px', maxWidth: '95vw', autoFocus: false });
|
||||
}
|
||||
|
||||
closeRequests(): void {
|
||||
this.requestsRef?.close();
|
||||
}
|
||||
|
||||
ngOnInit(): void {
|
||||
this.loadMyRequests();
|
||||
const d = businessToday();
|
||||
this.transferDate = `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, '0')}-${String(d.getDate()).padStart(2, '0')}`;
|
||||
this.service.getActiveDenominations().subscribe({
|
||||
@@ -106,7 +141,14 @@ export class CashTransferComponent implements OnInit {
|
||||
.getTellers()
|
||||
.pipe(catchError(() => of([] as any[])))
|
||||
.subscribe((list) => {
|
||||
this.tellers = (list || []).map((t: any) => ({ id: t.id, name: t.name }));
|
||||
// The vault and its cash are physical, so a branch moves cash only from its own tellers. The
|
||||
// backend lists every teller below the user's office (Head Office sees all branches), which is
|
||||
// right for reports but wrong here.
|
||||
const myOfficeId = this.authenticationService.getCredentials()?.officeId;
|
||||
const own = (list || []).filter(
|
||||
(t: any) => !myOfficeId || t.officeId == null || Number(t.officeId) === Number(myOfficeId)
|
||||
);
|
||||
this.tellers = own.map((t: any) => ({ id: t.id, name: t.name }));
|
||||
if (this.tellers.length === 1) {
|
||||
this.onTellerSelect(this.tellers[0].id);
|
||||
}
|
||||
@@ -245,16 +287,11 @@ export class CashTransferComponent implements OnInit {
|
||||
.filter((d) => this.countFor(d) > 0)
|
||||
.map((d) => ({ denominationId: d.id, denominationValue: d.value, count: this.countFor(d) }))
|
||||
};
|
||||
this.service.createCashTransfer(payload).subscribe({
|
||||
next: (res: any) => {
|
||||
if (res?.glWarning) {
|
||||
this.snackBar.open(`Transfer saved (GL warning: ${res.glWarning})`, 'Close', {
|
||||
duration: 8000,
|
||||
panelClass: ['snack-warn']
|
||||
});
|
||||
} else {
|
||||
this.snackBar.open('Cash transfer recorded', 'Close', { duration: 3000 });
|
||||
}
|
||||
this.service.createCashTransferRequest(payload).subscribe({
|
||||
next: () => {
|
||||
// Nothing has moved yet: a manager approves the allocation first, and only then does the cash leave the vault.
|
||||
this.snackBar.open('Cash transfer sent for manager approval', 'Close', { duration: 5000 });
|
||||
this.loadMyRequests();
|
||||
this.clear();
|
||||
this.note = '';
|
||||
this.fromParty = '';
|
||||
|
||||
@@ -83,6 +83,13 @@ const routes: Routes = [
|
||||
),
|
||||
data: { title: 'My Share Requests', breadcrumb: 'My Share Requests' }
|
||||
},
|
||||
{
|
||||
// The same page as the cashier's, reached from the clerk dashboard: a clerk unpasses vouchers here.
|
||||
path: 'voucher-correction',
|
||||
loadComponent: () =>
|
||||
import('./voucher-correction/voucher-correction.component').then((m) => m.VoucherCorrectionComponent),
|
||||
data: { title: 'Voucher Correction', breadcrumb: 'Voucher Correction' }
|
||||
},
|
||||
{
|
||||
path: 'closure-requests',
|
||||
loadComponent: () =>
|
||||
@@ -291,6 +298,12 @@ const routes: Routes = [
|
||||
component: ReconciliationComponent,
|
||||
data: { title: 'Reconciliation', breadcrumb: 'Reconciliation' }
|
||||
},
|
||||
{
|
||||
path: 'voucher-correction',
|
||||
loadComponent: () =>
|
||||
import('./voucher-correction/voucher-correction.component').then((m) => m.VoucherCorrectionComponent),
|
||||
data: { title: 'Voucher Correction', breadcrumb: 'Voucher Correction' }
|
||||
},
|
||||
{
|
||||
path: 'cash-history',
|
||||
component: CashHistoryComponent,
|
||||
|
||||
@@ -47,8 +47,21 @@ export class CashierService {
|
||||
}
|
||||
|
||||
// ── Cash Transfer ──
|
||||
createCashTransfer(data: any): Observable<any> {
|
||||
return this.http.post('/cashtransfer', data);
|
||||
/** Cash leaves the vault only when a different person approves, so the screen raises a request, not a transfer. */
|
||||
createCashTransferRequest(data: any): Observable<any> {
|
||||
return this.http.post('/cashtransfer/requests', data);
|
||||
}
|
||||
/** Requests by status (comma separated); mine narrows them to the ones the caller raised. */
|
||||
getCashTransferRequests(status?: string, mine = false): Observable<any[]> {
|
||||
let params = new HttpParams().set('mine', String(mine));
|
||||
if (status) params = params.set('status', status);
|
||||
return this.http.get<any[]>('/cashtransfer/requests', { params });
|
||||
}
|
||||
approveCashTransferRequest(id: number): Observable<any> {
|
||||
return this.http.post(`/cashtransfer/requests/${id}/approve`, {});
|
||||
}
|
||||
rejectCashTransferRequest(id: number, reason: string): Observable<any> {
|
||||
return this.http.post(`/cashtransfer/requests/${id}/reject`, { reason });
|
||||
}
|
||||
getCashTransfers(offset = 0, limit = 20): Observable<any> {
|
||||
const params = new HttpParams().set('offset', offset).set('limit', limit);
|
||||
|
||||
@@ -202,6 +202,7 @@
|
||||
<th class="num">Amount</th>
|
||||
<th>Payment</th>
|
||||
<th>Status</th>
|
||||
<th>Correction</th>
|
||||
<th>By</th>
|
||||
<th>Approved By</th>
|
||||
<th>Time</th>
|
||||
@@ -231,6 +232,18 @@
|
||||
<td>
|
||||
<span [class]="statusClass(r.status)">{{ r.status }}</span>
|
||||
</td>
|
||||
<td>
|
||||
<!-- Where the voucher stands after a correction: unpassed, revoked, closed or corrected. -->
|
||||
<mifosx-voucher-status
|
||||
[status]="r.voucherStatus"
|
||||
[revokedBy]="r.revokedByVoucherNo"
|
||||
[replaces]="r.replacesVoucherNo"
|
||||
[note]="r.correctionNote"
|
||||
></mifosx-voucher-status>
|
||||
@if (!r.voucherStatus || r.voucherStatus === 'ACTIVE') {
|
||||
<span class="db-muted">—</span>
|
||||
}
|
||||
</td>
|
||||
<td>{{ r.handledBy ?? '—' }}</td>
|
||||
<td>
|
||||
{{ r.approvedBy ?? '—' }}
|
||||
@@ -256,7 +269,7 @@
|
||||
—
|
||||
}
|
||||
</td>
|
||||
<td colspan="5"></td>
|
||||
<td colspan="6"></td>
|
||||
</tr>
|
||||
</tfoot>
|
||||
</table>
|
||||
|
||||
@@ -14,6 +14,7 @@ import { Router } from '@angular/router';
|
||||
import { catchError, of } from 'rxjs';
|
||||
import { CashierService } from '../cashier.service';
|
||||
import { businessToday } from 'app/core/utils/business-today';
|
||||
import { VoucherStatusComponent, VoucherStatus } from '../voucher-status/voucher-status.component';
|
||||
|
||||
/**
|
||||
* Which part of the day's work a row came from. Set by the server, one per source register.
|
||||
@@ -26,6 +27,11 @@ type DayBookSection = 'CASHIER' | 'CLERK' | 'SYSTEM';
|
||||
|
||||
interface DayBookRow {
|
||||
voucherNo: string | null;
|
||||
/** Where the voucher stands after a correction: active, revoked, under correction, or corrected. */
|
||||
voucherStatus?: VoucherStatus;
|
||||
revokedByVoucherNo?: string | null;
|
||||
replacesVoucherNo?: string | null;
|
||||
correctionNote?: string | null;
|
||||
section: DayBookSection;
|
||||
side: 'CR' | 'DR';
|
||||
kind: string;
|
||||
@@ -58,7 +64,8 @@ interface DayBookRow {
|
||||
imports: [
|
||||
CommonModule,
|
||||
FormsModule,
|
||||
MatIconModule
|
||||
MatIconModule,
|
||||
VoucherStatusComponent
|
||||
]
|
||||
})
|
||||
export class DayBookComponent implements OnInit {
|
||||
|
||||
@@ -146,6 +146,7 @@
|
||||
<th class="num">Amount</th>
|
||||
<th>My part</th>
|
||||
<th>Status</th>
|
||||
<th>Correction</th>
|
||||
<th>Time</th>
|
||||
</tr>
|
||||
</thead>
|
||||
@@ -203,6 +204,18 @@
|
||||
{{ statusLabel(r.verificationStatus) }}
|
||||
</span>
|
||||
</td>
|
||||
<td>
|
||||
<!-- Where the voucher stands after a correction: unpassed, revoked, closed or corrected. -->
|
||||
<mifosx-voucher-status
|
||||
[status]="r.voucherStatus"
|
||||
[revokedBy]="r.revokedByVoucherNo"
|
||||
[replaces]="r.replacesVoucherNo"
|
||||
[note]="r.correctionNote"
|
||||
></mifosx-voucher-status>
|
||||
@if (!r.voucherStatus || r.voucherStatus === 'ACTIVE') {
|
||||
<span class="mw-acct">—</span>
|
||||
}
|
||||
</td>
|
||||
<td class="mw-time">{{ r.createdAt | date: 'HH:mm' }}</td>
|
||||
</tr>
|
||||
}
|
||||
|
||||
@@ -15,6 +15,7 @@ import { catchError, finalize, of } from 'rxjs';
|
||||
import { CashierService } from '../cashier.service';
|
||||
import { VoucherDetailDialogComponent } from './voucher-detail-dialog.component';
|
||||
import { businessToday } from 'app/core/utils/business-today';
|
||||
import { VoucherStatusComponent, VoucherStatus } from '../voucher-status/voucher-status.component';
|
||||
|
||||
/** What the signed-in user's part in a voucher was. */
|
||||
export type MyRole = 'RAISED' | 'VERIFIED' | 'RAISED_AND_VERIFIED';
|
||||
@@ -24,6 +25,11 @@ export type VoucherSide = 'CR' | 'DR' | 'JV';
|
||||
|
||||
interface MyWorkRow {
|
||||
voucherNo: string;
|
||||
/** Where the voucher stands after a correction: active, revoked, under correction, or corrected. */
|
||||
voucherStatus?: VoucherStatus;
|
||||
revokedByVoucherNo?: string | null;
|
||||
replacesVoucherNo?: string | null;
|
||||
correctionNote?: string | null;
|
||||
side: VoucherSide | null;
|
||||
voucherType: string | null;
|
||||
amount: number | null;
|
||||
@@ -76,7 +82,8 @@ interface MyWorkReport {
|
||||
CommonModule,
|
||||
FormsModule,
|
||||
MatIconModule,
|
||||
MatDialogModule
|
||||
MatDialogModule,
|
||||
VoucherStatusComponent
|
||||
]
|
||||
})
|
||||
export class MyWorkComponent implements OnInit {
|
||||
|
||||
@@ -11,6 +11,7 @@ import { CommonModule } from '@angular/common';
|
||||
import { MatDialogModule, MatDialogRef, MAT_DIALOG_DATA } from '@angular/material/dialog';
|
||||
import { MatButtonModule } from '@angular/material/button';
|
||||
import { MatIconModule } from '@angular/material/icon';
|
||||
import { VoucherStatusComponent, VoucherStatus } from '../voucher-status/voucher-status.component';
|
||||
|
||||
export interface VoucherDetailDialogData {
|
||||
/** Already-loaded detail from CashierService.voucherDetail() — the caller fetches, this dialog
|
||||
@@ -18,6 +19,10 @@ export interface VoucherDetailDialogData {
|
||||
* duplicated inside every dialog that could ever show a voucher. */
|
||||
detail: {
|
||||
voucherNo: string;
|
||||
voucherStatus?: VoucherStatus;
|
||||
revokedByVoucherNo?: string | null;
|
||||
replacesVoucherNo?: string | null;
|
||||
correctionNote?: string | null;
|
||||
voucherType: string | null;
|
||||
amount: number | null;
|
||||
branchName: string | null;
|
||||
@@ -58,7 +63,8 @@ export interface VoucherDetailDialogData {
|
||||
CommonModule,
|
||||
MatDialogModule,
|
||||
MatButtonModule,
|
||||
MatIconModule
|
||||
MatIconModule,
|
||||
VoucherStatusComponent
|
||||
],
|
||||
template: `
|
||||
<div class="vd-shell">
|
||||
@@ -66,7 +72,15 @@ export interface VoucherDetailDialogData {
|
||||
<div class="vd-header__title">
|
||||
<mat-icon class="vd-icon">receipt_long</mat-icon>
|
||||
<div>
|
||||
<h2>{{ detail.voucherNo }}</h2>
|
||||
<h2>
|
||||
{{ detail.voucherNo }}
|
||||
<mifosx-voucher-status
|
||||
[status]="detail.voucherStatus"
|
||||
[revokedBy]="detail.revokedByVoucherNo"
|
||||
[replaces]="detail.replacesVoucherNo"
|
||||
[note]="detail.correctionNote"
|
||||
></mifosx-voucher-status>
|
||||
</h2>
|
||||
<p>{{ detail.voucherType || 'Voucher' }} · {{ detail.branchName || 'Head Office' }}</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -129,11 +129,16 @@
|
||||
</div>
|
||||
<div class="ws-f ws-f--branch">
|
||||
<label class="ws-f__lbl" title="Branch">BRANCH</label>
|
||||
<select class="ws-f__ctl" [(ngModel)]="row.br" (click)="$event.stopPropagation()">
|
||||
<option [ngValue]="1">001 · Main</option>
|
||||
<option [ngValue]="2">002 · M.G. Road</option>
|
||||
<option [ngValue]="14">014 · Station Rd</option>
|
||||
<option [ngValue]="27">027 · Industrial</option>
|
||||
<select
|
||||
class="ws-f__ctl"
|
||||
[(ngModel)]="row.br"
|
||||
[disabled]="branchSelectOptions.length <= 1"
|
||||
[title]="branchSelectOptions.length <= 1 ? 'Your branch' : 'Branch'"
|
||||
(click)="$event.stopPropagation()"
|
||||
>
|
||||
@for (b of branchSelectOptions; track b.id) {
|
||||
<option [ngValue]="b.id">{{ b.name }}</option>
|
||||
}
|
||||
</select>
|
||||
</div>
|
||||
@if (row.typ === 'DD') {
|
||||
@@ -179,7 +184,7 @@
|
||||
class="ws-f__ctl"
|
||||
[(ngModel)]="row.acTyp"
|
||||
(click)="$event.stopPropagation()"
|
||||
(change)="isGlCreditOnly(row) && (row.dbCr = 'Cr')"
|
||||
(change)="isCreditOnly(row) && (row.dbCr = 'Cr')"
|
||||
>
|
||||
@for (a of acTypOptionsFor(row); track a) {
|
||||
<option [value]="a">{{ a }}</option>
|
||||
@@ -262,7 +267,7 @@
|
||||
type="button"
|
||||
class="ws-seg__btn ws-seg__btn--dr"
|
||||
[class.ws-seg__btn--active-dr]="row.dbCr === 'Dr'"
|
||||
[disabled]="isGlCreditOnly(row)"
|
||||
[disabled]="isCreditOnly(row)"
|
||||
(click)="row.dbCr = 'Dr'"
|
||||
>
|
||||
DR
|
||||
@@ -717,7 +722,24 @@
|
||||
} @else {
|
||||
<div class="te-panel__body">
|
||||
<div class="te-field">
|
||||
<span class="te-label">Branch</span><span class="te-value">{{ officeName }}</span>
|
||||
<span class="te-label">Branch</span
|
||||
><span class="te-value">
|
||||
@if (isOwnBranchAccount !== null) {
|
||||
<span
|
||||
class="te-branch-chip"
|
||||
[class.te-branch-chip--mine]="isOwnBranchAccount"
|
||||
[class.te-branch-chip--other]="!isOwnBranchAccount"
|
||||
[title]="isOwnBranchAccount ? 'Held at your branch' : 'Held at another branch'"
|
||||
>
|
||||
<i class="fas fa-map-marker-alt"></i>{{ officeName }}
|
||||
@if (!isOwnBranchAccount) {
|
||||
<span class="te-branch-chip__tag">Other branch</span>
|
||||
}
|
||||
</span>
|
||||
} @else {
|
||||
{{ officeName }}
|
||||
}
|
||||
</span>
|
||||
</div>
|
||||
<div class="te-field">
|
||||
<span class="te-label">A/c No.</span
|
||||
@@ -1196,7 +1218,24 @@
|
||||
: '—')
|
||||
}}</span
|
||||
>
|
||||
<span class="te-acs-kv"><b>Branch</b> {{ officeName ?? '—' }}</span>
|
||||
<span class="te-acs-kv"
|
||||
><b>Branch</b>
|
||||
@if (isOwnBranchAccount !== null) {
|
||||
<span
|
||||
class="te-branch-chip"
|
||||
[class.te-branch-chip--mine]="isOwnBranchAccount"
|
||||
[class.te-branch-chip--other]="!isOwnBranchAccount"
|
||||
[title]="isOwnBranchAccount ? 'Held at your branch' : 'Held at another branch'"
|
||||
>
|
||||
<i class="fas fa-map-marker-alt"></i>{{ officeName }}
|
||||
@if (!isOwnBranchAccount) {
|
||||
<span class="te-branch-chip__tag">Other branch</span>
|
||||
}
|
||||
</span>
|
||||
} @else {
|
||||
{{ officeName }}
|
||||
}
|
||||
</span>
|
||||
<span class="te-acs-kv"
|
||||
><b>Product</b>
|
||||
{{
|
||||
@@ -1584,11 +1623,16 @@
|
||||
</div>
|
||||
<div class="ws-f ws-f--branch">
|
||||
<label class="ws-f__lbl">BRANCH</label>
|
||||
<select class="ws-f__ctl" [(ngModel)]="row.br" (click)="$event.stopPropagation()">
|
||||
<option [ngValue]="1">001 · Main</option>
|
||||
<option [ngValue]="2">002 · M.G. Road</option>
|
||||
<option [ngValue]="14">014 · Station Rd</option>
|
||||
<option [ngValue]="27">027 · Industrial</option>
|
||||
<select
|
||||
class="ws-f__ctl"
|
||||
[(ngModel)]="row.br"
|
||||
[disabled]="branchSelectOptions.length <= 1"
|
||||
[title]="branchSelectOptions.length <= 1 ? 'Your branch' : 'Branch'"
|
||||
(click)="$event.stopPropagation()"
|
||||
>
|
||||
@for (b of branchSelectOptions; track b.id) {
|
||||
<option [ngValue]="b.id">{{ b.name }}</option>
|
||||
}
|
||||
</select>
|
||||
</div>
|
||||
@if (row.typ === 'DD') {
|
||||
@@ -1634,7 +1678,7 @@
|
||||
class="ws-f__ctl"
|
||||
[(ngModel)]="row.acTyp"
|
||||
(click)="$event.stopPropagation()"
|
||||
(change)="isGlCreditOnly(row) && (row.dbCr = 'Cr')"
|
||||
(change)="isCreditOnly(row) && (row.dbCr = 'Cr')"
|
||||
>
|
||||
@for (a of acTypOptionsFor(row); track a) {
|
||||
<option [value]="a">{{ a }}</option>
|
||||
@@ -1705,7 +1749,7 @@
|
||||
type="button"
|
||||
class="ws-seg__btn ws-seg__btn--dr"
|
||||
[class.ws-seg__btn--active-dr]="row.dbCr === 'Dr'"
|
||||
[disabled]="isGlCreditOnly(row)"
|
||||
[disabled]="isCreditOnly(row)"
|
||||
(click)="$event.stopPropagation(); row.dbCr = 'Dr'"
|
||||
>
|
||||
DR
|
||||
|
||||
@@ -3462,3 +3462,39 @@ select.te-cell {
|
||||
box-shadow: none;
|
||||
}
|
||||
}
|
||||
|
||||
/* The branch an account is held at, coloured by where the signed-in user works: green for their own branch, red for any other. */
|
||||
.te-branch-chip {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
padding: 2px 10px;
|
||||
border: 1px solid transparent;
|
||||
border-radius: 999px;
|
||||
font-size: 12px;
|
||||
font-weight: 700;
|
||||
white-space: nowrap;
|
||||
|
||||
&--mine {
|
||||
background: #dcfce7;
|
||||
border-color: #4ade80;
|
||||
color: #166534;
|
||||
}
|
||||
|
||||
&--other {
|
||||
background: #fca5a5;
|
||||
border-color: #f87171;
|
||||
color: #7f1d1d;
|
||||
}
|
||||
}
|
||||
|
||||
.te-branch-chip__tag {
|
||||
padding: 0 6px;
|
||||
border-radius: 8px;
|
||||
background: rgb(255 255 255 / 80%);
|
||||
color: #7f1d1d;
|
||||
font-size: 10px;
|
||||
font-weight: 800;
|
||||
letter-spacing: 0.04em;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
@@ -10,7 +10,8 @@ import { Component, OnInit, OnDestroy, inject, Input, ElementRef, ViewChild } fr
|
||||
import { CommonModule } from '@angular/common';
|
||||
import { DomSanitizer, SafeResourceUrl } from '@angular/platform-browser';
|
||||
import { FormsModule } from '@angular/forms';
|
||||
import { HttpClient, HttpParams } from '@angular/common/http';
|
||||
import { HttpClient, HttpContext, HttpParams } from '@angular/common/http';
|
||||
import { SILENT_HTTP_ERROR } from 'app/core/http/silent-error.token';
|
||||
import { catchError, debounceTime, distinctUntilChanged, map, timeout } from 'rxjs/operators';
|
||||
import { forkJoin, of, Subject } from 'rxjs';
|
||||
import { MatDialog, MatDialogModule, MAT_DIALOG_DATA, MatDialogRef } from '@angular/material/dialog';
|
||||
@@ -1877,6 +1878,7 @@ export class TransactionEntryComponent implements OnInit, OnDestroy {
|
||||
ngOnInit(): void {
|
||||
const d = businessToday();
|
||||
this.today = `${String(d.getDate()).padStart(2, '0')}/${String(d.getMonth() + 1).padStart(2, '0')}/${d.getFullYear()}`;
|
||||
this.loadBranches();
|
||||
this.addRow();
|
||||
this.loadDenominations();
|
||||
this.loadOpeningPosition();
|
||||
@@ -2309,7 +2311,7 @@ export class TransactionEntryComponent implements OnInit, OnDestroy {
|
||||
// anyway, just without the extra click to get there.
|
||||
const row: TxnRow = {
|
||||
typ: this.isClerk ? 'Withdrawal Slip' : 'Receipt',
|
||||
br: 1,
|
||||
br: this.defaultBranchId,
|
||||
trTp: '',
|
||||
acTyp: 'SD',
|
||||
acNo: '',
|
||||
@@ -2343,6 +2345,20 @@ export class TransactionEntryComponent implements OnInit, OnDestroy {
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* A Fixed Deposit row is credit-only on both the clerk and the cashier screens: an FD is funded once, with
|
||||
* exactly its principal, and money leaves it only through its maturity or premature closure. The one
|
||||
* exception is the clerk's own "Close this account" flow, which raises a closure request.
|
||||
*/
|
||||
isFdCreditOnly(row: TxnRow): boolean {
|
||||
return row.acTyp === 'FD' && !this.pendingClosure;
|
||||
}
|
||||
|
||||
/** DR is not offered for this row (see isGlCreditOnly and isFdCreditOnly). */
|
||||
isCreditOnly(row: TxnRow): boolean {
|
||||
return this.isGlCreditOnly(row) || this.isFdCreditOnly(row);
|
||||
}
|
||||
|
||||
/** Called when the TYPE dropdown changes — sets sensible defaults for TXN TP and DR/CR */
|
||||
/**
|
||||
* A cashier row that was filled from an approved token (withdrawal / account-closure) is cash
|
||||
@@ -2655,14 +2671,51 @@ export class TransactionEntryComponent implements OnInit, OnDestroy {
|
||||
return !!this.selectedRow && Math.abs(this.denominationTotal - this.ddTargetTotal) < 0.005;
|
||||
}
|
||||
|
||||
/**
|
||||
* The branches an entry can be posted against: the real offices of this installation. Read from the offices
|
||||
* themselves rather than from a fixed list, so a branch that is added or renamed shows up here without a code change.
|
||||
*/
|
||||
branches: { id: number; label: string }[] = [];
|
||||
|
||||
private loadBranches(): void {
|
||||
const own = this.authService.getCredentials();
|
||||
const ownBranch = own?.officeId
|
||||
? [{ id: own.officeId, label: this.branchLabel(own.officeId, own.officeName) }]
|
||||
: [];
|
||||
// The office list is only asked for by a login allowed to read it. A cashier or clerk is not, and asking anyway
|
||||
// makes the server refuse and the screen raise an "Unauthorized Request" popup; such a login works in its own
|
||||
// office, which is the one branch it is offered.
|
||||
const permissions = own?.permissions ?? [];
|
||||
const mayReadOffices = permissions.includes('ALL_FUNCTIONS') || permissions.includes('READ_OFFICE');
|
||||
if (!mayReadOffices) {
|
||||
this.branches = ownBranch;
|
||||
return;
|
||||
}
|
||||
this.http
|
||||
.get('/offices')
|
||||
.pipe(catchError(() => of([])))
|
||||
.subscribe((list: any) => {
|
||||
const offices: any[] = Array.isArray(list) ? list : [];
|
||||
this.branches =
|
||||
offices.length > 0 ? offices.map((o) => ({ id: o.id, label: this.branchLabel(o.id, o.name) })) : ownBranch;
|
||||
});
|
||||
}
|
||||
|
||||
private branchLabel(id: number, name: string | null | undefined): string {
|
||||
return `${String(id).padStart(3, '0')} · ${name ?? 'Branch ' + id}`;
|
||||
}
|
||||
|
||||
/** The office the signed-in user works in, which a new entry is posted against unless they pick another. */
|
||||
get defaultBranchId(): number {
|
||||
return this.authService.getCredentials()?.officeId ?? 1;
|
||||
}
|
||||
|
||||
branchNameFor(branchId: number): string {
|
||||
const map: Record<number, string> = {
|
||||
1: '001 · Main',
|
||||
2: '002 · M.G. Road',
|
||||
14: '014 · Station Rd',
|
||||
27: '027 · Industrial'
|
||||
};
|
||||
return map[branchId] || `Branch ${branchId}`;
|
||||
return (
|
||||
this.seenBranches.find((b) => b.id === branchId)?.name ??
|
||||
this.branches.find((b) => b.id === branchId)?.label ??
|
||||
`Branch ${branchId}`
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -4012,8 +4065,11 @@ export class TransactionEntryComponent implements OnInit, OnDestroy {
|
||||
.pipe(catchError(() => of(null)))
|
||||
.subscribe((res: any) => {
|
||||
if (token !== this.searchToken) return; // stale result — newer search started
|
||||
const allItems: any[] = res?.pageItems ?? (Array.isArray(res) ? res : []);
|
||||
const found: any[] = res?.pageItems ?? (Array.isArray(res) ? res : []);
|
||||
const f = cfg.matchField;
|
||||
// SD, FD and RD share one list that returns any kind of deposit account: keep only the kind chosen in A/C TP.
|
||||
const allItems =
|
||||
cfg.depositTypeId != null ? found.filter((x: any) => x?.depositType?.id === cfg.depositTypeId) : found;
|
||||
// Exact match first, then suffix/partial match. Some account types (e.g. Share — its
|
||||
// list endpoint only accepts offset/limit, no accountNo filter at all) always return the
|
||||
const exactMatch = allItems.find((x: any) => String(x[f] ?? '').trim() === String(acNo).trim());
|
||||
@@ -4043,7 +4099,18 @@ export class TransactionEntryComponent implements OnInit, OnDestroy {
|
||||
return;
|
||||
}
|
||||
this.loadingAccount = false;
|
||||
this.accountError = 'Account not found for: ' + acNo;
|
||||
// The number may exist as another kind of account (an FD typed under SD, say): say so, so the clerk can fix A/C TP.
|
||||
const otherKind =
|
||||
cfg.depositTypeId != null
|
||||
? found.find(
|
||||
(x: any) =>
|
||||
x?.depositType?.id !== cfg.depositTypeId &&
|
||||
(String(x[f] ?? '').trim() === String(acNo).trim() || String(x[f] ?? '').endsWith(acNo))
|
||||
)
|
||||
: null;
|
||||
this.accountError = otherKind
|
||||
? `${acTyp} account not found for: ${acNo} (this number is a ${otherKind.depositType?.value ?? 'different kind of'} account - change A/C TP)`
|
||||
: `${acTyp} account not found for: ${acNo}`;
|
||||
});
|
||||
}
|
||||
|
||||
@@ -4107,6 +4174,10 @@ export class TransactionEntryComponent implements OnInit, OnDestroy {
|
||||
* account type — used to keep Header GL accounts out of the results a clerk/cashier can pick
|
||||
* for a transaction; a Header can never receive a posting, so it should never be offered. */
|
||||
extraListQueryParams?: string;
|
||||
/** SD, FD and RD are all served by the one /savingsaccounts list, which answers with a deposit
|
||||
* account of any kind for a number. This is the depositType.id the chosen A/C type accepts
|
||||
* (100 savings, 200 fixed, 300 recurring), so a number of another kind is "not found" here. */
|
||||
depositTypeId?: number;
|
||||
} {
|
||||
const t = acTyp?.toUpperCase();
|
||||
if (t === 'LA') {
|
||||
@@ -4187,6 +4258,7 @@ export class TransactionEntryComponent implements OnInit, OnDestroy {
|
||||
return {
|
||||
list: '/savingsaccounts',
|
||||
detail: '/fixeddepositaccounts',
|
||||
depositTypeId: 200,
|
||||
nominee: 'FD',
|
||||
search: 'fixeddeposit',
|
||||
siAccountType: 2,
|
||||
@@ -4204,6 +4276,7 @@ export class TransactionEntryComponent implements OnInit, OnDestroy {
|
||||
return {
|
||||
list: '/savingsaccounts',
|
||||
detail: '/recurringdepositaccounts',
|
||||
depositTypeId: 300,
|
||||
nominee: 'RD',
|
||||
search: 'recurringdeposit',
|
||||
siAccountType: 2,
|
||||
@@ -4221,6 +4294,7 @@ export class TransactionEntryComponent implements OnInit, OnDestroy {
|
||||
return {
|
||||
list: '/savingsaccounts',
|
||||
detail: '/savingsaccounts',
|
||||
depositTypeId: 100,
|
||||
nominee: 'SB',
|
||||
search: 'saving',
|
||||
siAccountType: 2,
|
||||
@@ -4280,6 +4354,7 @@ export class TransactionEntryComponent implements OnInit, OnDestroy {
|
||||
return false;
|
||||
}
|
||||
this.accountDetails = acc;
|
||||
this.resolveAccountBranch(acc, acTyp, token, this.selectedRow);
|
||||
this.jointHolders = acc.jointHolders ?? [];
|
||||
// GL only: look up the parent (header) GL's name. Never runs for any other account type.
|
||||
this.glParent = null;
|
||||
@@ -4896,6 +4971,75 @@ export class TransactionEntryComponent implements OnInit, OnDestroy {
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether the account on screen is held at the branch the signed-in user works at: true for their own branch, false for
|
||||
* another branch's, null while either branch is not known (the branch is then shown plain, uncoloured).
|
||||
*/
|
||||
get isOwnBranchAccount(): boolean | null {
|
||||
const mine = this.authService.getCredentials()?.officeId;
|
||||
const accountOffice = this.accountDetails?.officeId ?? this.clientDetails?.officeId;
|
||||
if (mine == null || accountOffice == null) return null;
|
||||
return Number(mine) === Number(accountOffice);
|
||||
}
|
||||
|
||||
/** What the BRANCH selector offers: the offices this login may use, plus the branches of the accounts picked here. */
|
||||
get branchSelectOptions(): { id: number; name: string }[] {
|
||||
const merged = this.branches.map(
|
||||
(branch) => this.seenBranches.find((seen) => seen.id === branch.id) ?? { id: branch.id, name: branch.label }
|
||||
);
|
||||
for (const seen of this.seenBranches) {
|
||||
if (!merged.some((branch) => branch.id === seen.id)) merged.push(seen);
|
||||
}
|
||||
return merged;
|
||||
}
|
||||
|
||||
/** Branches learned from the accounts picked on this screen; their names are real, whatever the office list says. */
|
||||
private seenBranches: { id: number; name: string }[] = [];
|
||||
|
||||
/**
|
||||
* The branch an account is held at. The savings, FD and RD detail responses carry no office, so the screen fell back
|
||||
* to the client's own branch and an account opened at another branch read as the client's. The client's account list
|
||||
* names each account's branch, so it is read from there. A current account's own response already carries it.
|
||||
*/
|
||||
private resolveAccountBranch(acc: any, acTyp: string, token: number, row: TxnRow | null | undefined): void {
|
||||
if (acc?.officeId != null && acc?.officeName) {
|
||||
this.adoptAccountBranch({ id: Number(acc.officeId), name: String(acc.officeName) }, row);
|
||||
return;
|
||||
}
|
||||
const type = (acTyp ?? '').toUpperCase();
|
||||
if (!acc?.id || !acc?.clientId || ![
|
||||
'SD',
|
||||
'FD',
|
||||
'RD',
|
||||
'CA'
|
||||
].includes(type)) return;
|
||||
this.http
|
||||
.get<any>(`/clients/${acc.clientId}/accounts`, { context: new HttpContext().set(SILENT_HTTP_ERROR, true) })
|
||||
.pipe(catchError(() => of(null)))
|
||||
.subscribe((res) => {
|
||||
// stale: another account was picked while this was loading
|
||||
if (token !== this.searchToken || this.accountDetails?.id !== acc.id) return;
|
||||
const list: any[] = (type === 'CA' ? res?.currentAccounts : res?.savingsAccounts) ?? [];
|
||||
const match = list.find((a: any) => a.id === acc.id);
|
||||
if (match?.officeId != null && match?.officeName) {
|
||||
this.adoptAccountBranch({ id: Number(match.officeId), name: String(match.officeName) }, row);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
private adoptAccountBranch(branch: { id: number; name: string }, row: TxnRow | null | undefined): void {
|
||||
this.accountDetails = { ...this.accountDetails, officeId: branch.id, officeName: branch.name };
|
||||
// /offices lists only the signed-in user's own branches, or nothing for a user who may not read offices; the account's
|
||||
// branch must still be selectable, under its real name
|
||||
if (!this.seenBranches.some((b) => b.id === branch.id)) {
|
||||
this.seenBranches = [
|
||||
...this.seenBranches,
|
||||
branch
|
||||
];
|
||||
}
|
||||
if (row) row.br = branch.id;
|
||||
}
|
||||
|
||||
get openingDate(): string {
|
||||
const raw =
|
||||
this.accountDetails?.timeline?.submittedOnDate ??
|
||||
@@ -5238,6 +5382,20 @@ export class TransactionEntryComponent implements OnInit, OnDestroy {
|
||||
}
|
||||
|
||||
onSave(): void {
|
||||
// Money never leaves a Fixed Deposit from this screen, clerk or cashier: no withdrawal slip, payment,
|
||||
// collection or transfer debits an FD. It is paid out only through its maturity or premature closure
|
||||
// (the clerk's "Close this account" flow). The DR button is already disabled for FD rows (isFdCreditOnly);
|
||||
// this also catches a row set to DR by its TYPE, such as a Withdrawal Slip.
|
||||
const fdDebitRows = this.rows.filter((r) => this.isFdCreditOnly(r) && r.dbCr === 'Dr');
|
||||
if (fdDebitRows.length > 0) {
|
||||
this.showAlert('error', 'Fixed Deposit Cannot Be Debited', [
|
||||
`FD account(s) ${fdDebitRows.map((r) => r.acNo).join(', ')} cannot be debited or withdrawn from.`,
|
||||
'Money leaves a Fixed Deposit only through its maturity or premature closure.',
|
||||
'An FD can only be credited — once, with exactly its principal.'
|
||||
]);
|
||||
return;
|
||||
}
|
||||
|
||||
// Demand drafts commit here like everything else on the worksheet. Checked first because a DD
|
||||
// row shares its shape with the ordinary entries below — without this it would fall through and
|
||||
// post as a plain transaction, with the draft never raised and the charges silently dropped.
|
||||
@@ -5315,7 +5473,9 @@ export class TransactionEntryComponent implements OnInit, OnDestroy {
|
||||
return;
|
||||
}
|
||||
|
||||
// Bank transfer: only savings (SD) or current (CA/CB) accounts allowed
|
||||
// Bank transfer: savings (SD) or current (CA/CB) accounts on either side, plus a Fixed Deposit (FD) on
|
||||
// the CR side only -- funding it from the customer's own account. The server checks the funding rules
|
||||
// (first and only funding, exactly the principal, the source account's owner and available balance).
|
||||
const allowedTypes = new Set([
|
||||
'SD',
|
||||
'CA',
|
||||
@@ -5323,13 +5483,13 @@ export class TransactionEntryComponent implements OnInit, OnDestroy {
|
||||
]);
|
||||
const invalidAccounts = [
|
||||
...drRows,
|
||||
...crRows
|
||||
...crRows.filter((r) => r.acTyp !== 'FD')
|
||||
].filter((r) => !allowedTypes.has(r.acTyp));
|
||||
if (invalidAccounts.length > 0) {
|
||||
this.showAlert('error', 'Invalid Account Type', [
|
||||
`Account type(s) not allowed in Bank Transfer: ${invalidAccounts.map((r) => r.acTyp + ' (' + r.acNo + ')').join(', ')}`,
|
||||
'Only Savings (SD) and Current (CA) accounts are permitted.',
|
||||
'GL, FD and SHR accounts are not allowed — use Disbursement/Collection instead.',
|
||||
'Debit side: only Savings (SD) and Current (CA) accounts. Credit side: SD, CA or a Fixed Deposit (FD).',
|
||||
'GL and SHR accounts are not allowed — use Disbursement/Collection instead.',
|
||||
'RD installments are paid through a cashier Receipt or Auto Debit.'
|
||||
]);
|
||||
return;
|
||||
|
||||
+170
@@ -0,0 +1,170 @@
|
||||
<!--
|
||||
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="cv-tab-content vca-tab">
|
||||
<!-- ── TITLE BAR ── -->
|
||||
<div class="vca-action-bar">
|
||||
<div class="vca-action-bar__left">
|
||||
<mat-icon class="vca-action-bar__icon">undo</mat-icon>
|
||||
<div>
|
||||
<div class="vca-action-bar__title">Voucher Corrections</div>
|
||||
<div class="vca-action-bar__sub">
|
||||
Approve or reject a voucher unpass, and the correction entry that follows it. Only the correction approval
|
||||
moves money.
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="vca-counts">
|
||||
<span class="vca-count"
|
||||
><strong>{{ unpassCount }}</strong> unpass</span
|
||||
>
|
||||
<span class="vca-count"
|
||||
><strong>{{ correctionCount }}</strong> correction</span
|
||||
>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- ── FILTER BAR ── -->
|
||||
<div class="vca-filter-bar">
|
||||
<div class="vca-search-box">
|
||||
<mat-icon>search</mat-icon>
|
||||
<input type="text" placeholder="Search by voucher, account, customer or user" [(ngModel)]="searchTerm" />
|
||||
@if (searchTerm) {
|
||||
<button type="button" class="vca-search-box__clear" (click)="searchTerm = ''">
|
||||
<mat-icon>close</mat-icon>
|
||||
</button>
|
||||
}
|
||||
</div>
|
||||
|
||||
<span class="vca-fb-sep"></span>
|
||||
|
||||
<select class="vca-filter-select" [(ngModel)]="kindFilter">
|
||||
@for (opt of kindOptions; track opt.value) {
|
||||
<option [value]="opt.value">{{ opt.label }}</option>
|
||||
}
|
||||
</select>
|
||||
|
||||
<button type="button" class="vca-btn vca-btn--refresh" (click)="load()" [disabled]="loading">
|
||||
<mat-icon>refresh</mat-icon>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- ── TABLE ── -->
|
||||
@if (loading && !loaded) {
|
||||
<div class="vca-empty"><p class="vca-empty__title">Loading…</p></div>
|
||||
} @else if (rows.length === 0) {
|
||||
<div class="vca-empty">
|
||||
<mat-icon class="vca-empty__icon">inbox</mat-icon>
|
||||
<p class="vca-empty__title">No voucher correction waiting</p>
|
||||
<p class="vca-empty__text">There is no unpass request or correction entry waiting for review.</p>
|
||||
</div>
|
||||
} @else {
|
||||
<div class="vca-table-wrap">
|
||||
<table class="vca-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Voucher No</th>
|
||||
<th>Request</th>
|
||||
<th>Posted To (Wrong)</th>
|
||||
<th>Correct Account</th>
|
||||
<th class="num">Amount</th>
|
||||
<th>Reason</th>
|
||||
<th>Raised By</th>
|
||||
<th>Raised At</th>
|
||||
<th class="vca-th-action">Action</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
@for (r of rows; track r.request.id) {
|
||||
<tr class="vca-row">
|
||||
<td class="vca-mono vca-voucher">{{ r.request.original_voucher_no }}</td>
|
||||
<td>
|
||||
<span class="vca-kind" [class]="'vca-kind vca-kind--' + r.kind">{{ kindLabel(r.kind) }}</span>
|
||||
</td>
|
||||
<td>
|
||||
<div class="vca-mono">{{ r.request.wrong_account_no }}</div>
|
||||
<div class="vca-sub">{{ r.request.wrong_client_name || '—' }}</div>
|
||||
</td>
|
||||
<td>
|
||||
@if (r.kind === 'correction') {
|
||||
<div class="vca-mono vca-strong">{{ r.request.correct_account_no }}</div>
|
||||
<div class="vca-sub">{{ r.request.correct_client_name || '—' }}</div>
|
||||
} @else {
|
||||
<span class="vca-none">—</span>
|
||||
}
|
||||
</td>
|
||||
<td class="num vca-amount">₹{{ r.request.amount | number: '1.2-2' }}</td>
|
||||
<td class="vca-reason" [title]="r.request.reason || ''">{{ r.request.reason || '—' }}</td>
|
||||
<td>{{ r.raisedBy || '—' }}</td>
|
||||
<td class="vca-dt">{{ formatDateTime(r.raisedAt) }}</td>
|
||||
<td class="vca-action-cell">
|
||||
<button
|
||||
type="button"
|
||||
class="vca-btn vca-btn--reject"
|
||||
[disabled]="busyId === r.request.id"
|
||||
(click)="startReject(r)"
|
||||
>
|
||||
<mat-icon>close</mat-icon> Reject
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
class="vca-btn vca-btn--approve"
|
||||
[disabled]="busyId === r.request.id"
|
||||
(click)="approve(r)"
|
||||
>
|
||||
<mat-icon>check</mat-icon>
|
||||
{{ r.kind === 'unpass' ? 'Approve unpass' : 'Approve correction' }}
|
||||
</button>
|
||||
</td>
|
||||
</tr>
|
||||
}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
|
||||
<!-- ─── Popup: reject with a reason ───────────────────────────────────────── -->
|
||||
<ng-template #rejectDialog>
|
||||
@if (rejecting) {
|
||||
<div class="vca-dlg">
|
||||
<header class="vca-dlg__head">
|
||||
<mat-icon class="vca-dlg__ico">block</mat-icon>
|
||||
<div class="vca-dlg__titles">
|
||||
<div class="vca-dlg__title">
|
||||
Reject {{ rejecting.kind === 'unpass' ? 'unpass request' : 'correction entry' }}
|
||||
</div>
|
||||
<div class="vca-dlg__sub vca-mono">{{ rejecting.request.original_voucher_no }}</div>
|
||||
</div>
|
||||
<button type="button" class="vca-dlg__x" aria-label="Close" (click)="cancelReject()">
|
||||
<mat-icon>close</mat-icon>
|
||||
</button>
|
||||
</header>
|
||||
<div class="vca-dlg__body">
|
||||
<label class="vca-label" for="vca-reason">Reason (required)</label>
|
||||
<textarea
|
||||
id="vca-reason"
|
||||
rows="3"
|
||||
placeholder="Why is this being rejected?"
|
||||
[(ngModel)]="rejectReason"
|
||||
></textarea>
|
||||
</div>
|
||||
<footer class="vca-dlg__foot">
|
||||
<button type="button" class="vca-btn vca-btn--ghost" (click)="cancelReject()">Cancel</button>
|
||||
<button
|
||||
type="button"
|
||||
class="vca-btn vca-btn--reject-solid"
|
||||
[disabled]="!rejectReason.trim() || busyId === rejecting.request.id"
|
||||
(click)="confirmReject()"
|
||||
>
|
||||
Confirm reject
|
||||
</button>
|
||||
</footer>
|
||||
</div>
|
||||
}
|
||||
</ng-template>
|
||||
+485
@@ -0,0 +1,485 @@
|
||||
/**
|
||||
* 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/.
|
||||
*/
|
||||
|
||||
// Laid out like the other approval tabs (Withdrawal Requests): title bar, filter bar, one table.
|
||||
:host {
|
||||
display: block;
|
||||
}
|
||||
|
||||
.vca-tab {
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
.vca-action-bar {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 16px;
|
||||
padding: 10px 16px;
|
||||
background: #fff;
|
||||
border-bottom: 1px solid #e2e8f0;
|
||||
|
||||
&__left {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
&__icon {
|
||||
font-size: 22px;
|
||||
width: 22px;
|
||||
height: 22px;
|
||||
color: #0f4f3c;
|
||||
}
|
||||
|
||||
&__title {
|
||||
font-size: 13px;
|
||||
font-weight: 700;
|
||||
color: #0d1b2a;
|
||||
}
|
||||
|
||||
&__sub {
|
||||
max-width: 640px;
|
||||
margin-top: 1px;
|
||||
font-size: 11px;
|
||||
color: #64748b;
|
||||
}
|
||||
}
|
||||
|
||||
.vca-counts {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.vca-count {
|
||||
padding: 2px 10px;
|
||||
border-radius: 999px;
|
||||
background: #f1f5f9;
|
||||
font-size: 11px;
|
||||
color: #475569;
|
||||
|
||||
strong {
|
||||
color: #0f4f3c;
|
||||
}
|
||||
}
|
||||
|
||||
.vca-filter-bar {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
flex-wrap: wrap;
|
||||
padding: 6px 16px;
|
||||
background: #f8fafc;
|
||||
border-bottom: 1px solid #e2e8f0;
|
||||
}
|
||||
|
||||
.vca-fb-sep {
|
||||
flex-shrink: 0;
|
||||
width: 1px;
|
||||
height: 22px;
|
||||
margin: 0 4px;
|
||||
background: #e2e8f0;
|
||||
}
|
||||
|
||||
.vca-search-box {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
height: 30px;
|
||||
min-width: 240px;
|
||||
padding: 0 10px;
|
||||
border: 1px solid #e2e8f0;
|
||||
border-radius: 8px;
|
||||
background: #fff;
|
||||
|
||||
mat-icon {
|
||||
flex-shrink: 0;
|
||||
font-size: 17px;
|
||||
width: 17px;
|
||||
height: 17px;
|
||||
color: #94a3b8;
|
||||
}
|
||||
|
||||
&:hover {
|
||||
border-color: #cbd5e1;
|
||||
}
|
||||
|
||||
&:focus-within {
|
||||
border-color: #0f4f3c;
|
||||
box-shadow: 0 0 0 3px rgb(15 79 60 / 8%);
|
||||
}
|
||||
|
||||
input {
|
||||
flex: 1;
|
||||
border: none;
|
||||
outline: none;
|
||||
background: transparent;
|
||||
font-family: inherit;
|
||||
font-size: 12px;
|
||||
color: #0d1b2a;
|
||||
|
||||
&::placeholder {
|
||||
color: #94a3b8;
|
||||
}
|
||||
}
|
||||
|
||||
&__clear {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
flex-shrink: 0;
|
||||
width: 20px;
|
||||
height: 20px;
|
||||
padding: 0;
|
||||
border: none;
|
||||
border-radius: 50%;
|
||||
background: none;
|
||||
color: #94a3b8;
|
||||
cursor: pointer;
|
||||
|
||||
mat-icon {
|
||||
font-size: 15px;
|
||||
width: 15px;
|
||||
height: 15px;
|
||||
color: inherit;
|
||||
}
|
||||
|
||||
&:hover {
|
||||
color: #dc2626;
|
||||
background: #fef2f2;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.vca-filter-select {
|
||||
height: 30px;
|
||||
min-width: 140px;
|
||||
padding: 0 10px;
|
||||
border: 1px solid #e2e8f0;
|
||||
border-radius: 7px;
|
||||
background: #fff;
|
||||
font-family: inherit;
|
||||
font-size: 12px;
|
||||
color: #0d1b2a;
|
||||
cursor: pointer;
|
||||
|
||||
&:focus {
|
||||
outline: none;
|
||||
border-color: #0f4f3c;
|
||||
}
|
||||
}
|
||||
|
||||
// ── Buttons ─────────────────────────────────────────────────────────────────
|
||||
.vca-btn {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
height: 26px;
|
||||
padding: 0 10px;
|
||||
border: 1px solid transparent;
|
||||
border-radius: 7px;
|
||||
font-family: inherit;
|
||||
font-size: 11px;
|
||||
font-weight: 600;
|
||||
white-space: nowrap;
|
||||
cursor: pointer;
|
||||
|
||||
mat-icon {
|
||||
font-size: 14px;
|
||||
width: 14px;
|
||||
height: 14px;
|
||||
}
|
||||
|
||||
&:disabled {
|
||||
opacity: 0.45;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
&--refresh {
|
||||
height: 30px;
|
||||
width: 30px;
|
||||
justify-content: center;
|
||||
padding: 0;
|
||||
margin-left: auto;
|
||||
border-color: #e2e8f0;
|
||||
background: #fff;
|
||||
color: #475569;
|
||||
}
|
||||
|
||||
&--approve {
|
||||
background: #0f4f3c;
|
||||
color: #fff;
|
||||
|
||||
&:hover:not(:disabled) {
|
||||
background: #0a3d2e;
|
||||
}
|
||||
}
|
||||
|
||||
&--reject {
|
||||
border-color: #fecaca;
|
||||
background: #fff;
|
||||
color: #dc2626;
|
||||
|
||||
&:hover:not(:disabled) {
|
||||
background: #fef2f2;
|
||||
}
|
||||
}
|
||||
|
||||
&--reject-solid {
|
||||
background: #dc2626;
|
||||
color: #fff;
|
||||
}
|
||||
|
||||
&--ghost {
|
||||
border-color: #e2e8f0;
|
||||
background: #fff;
|
||||
color: #475569;
|
||||
}
|
||||
}
|
||||
|
||||
// ── Table ───────────────────────────────────────────────────────────────────
|
||||
.vca-table-wrap {
|
||||
overflow-x: auto;
|
||||
background: #fff;
|
||||
}
|
||||
|
||||
.vca-table {
|
||||
width: 100%;
|
||||
border-collapse: collapse;
|
||||
font-size: 11px;
|
||||
|
||||
th {
|
||||
padding: 7px 10px;
|
||||
border-bottom: 1px solid #e2e8f0;
|
||||
background: #f8fafc;
|
||||
font-size: 9.5px;
|
||||
font-weight: 800;
|
||||
letter-spacing: 0.06em;
|
||||
text-align: left;
|
||||
text-transform: uppercase;
|
||||
color: #94a3b8;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
td {
|
||||
padding: 5px 10px;
|
||||
border-bottom: 1px solid #eef2f7;
|
||||
vertical-align: middle;
|
||||
color: #0d1b2a;
|
||||
}
|
||||
|
||||
.num {
|
||||
text-align: right;
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
|
||||
.vca-th-action {
|
||||
text-align: right;
|
||||
}
|
||||
}
|
||||
|
||||
.vca-row:hover td {
|
||||
background: #f8fafc;
|
||||
}
|
||||
|
||||
.vca-mono {
|
||||
font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace;
|
||||
letter-spacing: 0.02em;
|
||||
}
|
||||
|
||||
.vca-voucher {
|
||||
font-weight: 700;
|
||||
color: #0f4f3c;
|
||||
}
|
||||
|
||||
.vca-strong {
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.vca-sub {
|
||||
margin-top: 1px;
|
||||
font-size: 11px;
|
||||
color: #64748b;
|
||||
}
|
||||
|
||||
.vca-none {
|
||||
color: #cbd5e1;
|
||||
}
|
||||
|
||||
.vca-amount {
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.vca-reason {
|
||||
max-width: 220px;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.vca-dt {
|
||||
white-space: nowrap;
|
||||
color: #475569;
|
||||
}
|
||||
|
||||
.vca-kind {
|
||||
display: inline-block;
|
||||
padding: 2px 10px;
|
||||
border-radius: 999px;
|
||||
font-size: 11px;
|
||||
font-weight: 700;
|
||||
|
||||
&--unpass {
|
||||
background: #fef3c7;
|
||||
color: #92400e;
|
||||
}
|
||||
|
||||
&--correction {
|
||||
background: #dbeafe;
|
||||
color: #1e40af;
|
||||
}
|
||||
}
|
||||
|
||||
.vca-action-cell {
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
gap: 6px;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
// ── Empty ───────────────────────────────────────────────────────────────────
|
||||
.vca-empty {
|
||||
padding: 28px 16px;
|
||||
text-align: center;
|
||||
color: #94a3b8;
|
||||
|
||||
&__icon {
|
||||
display: block;
|
||||
margin: 0 auto 6px;
|
||||
font-size: 30px;
|
||||
width: 30px;
|
||||
height: 30px;
|
||||
opacity: 0.6;
|
||||
}
|
||||
|
||||
&__title {
|
||||
margin: 0;
|
||||
font-size: 12px;
|
||||
font-weight: 700;
|
||||
color: #64748b;
|
||||
}
|
||||
|
||||
&__text {
|
||||
margin: 3px 0 0;
|
||||
font-size: 11px;
|
||||
}
|
||||
}
|
||||
|
||||
// ── Reject popup ────────────────────────────────────────────────────────────
|
||||
.vca-dlg {
|
||||
font-size: 12px;
|
||||
|
||||
&__head {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
padding: 10px 14px;
|
||||
border-bottom: 1px solid #e2e8f0;
|
||||
background: #f8fafc;
|
||||
}
|
||||
|
||||
&__ico {
|
||||
flex: 0 0 32px;
|
||||
height: 32px;
|
||||
width: 32px;
|
||||
border-radius: 9px;
|
||||
background: #fef2f2;
|
||||
color: #dc2626;
|
||||
font-size: 18px;
|
||||
line-height: 32px;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
&__titles {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
&__title {
|
||||
font-size: 14px;
|
||||
font-weight: 700;
|
||||
color: #0f172a;
|
||||
}
|
||||
|
||||
&__sub {
|
||||
font-size: 11px;
|
||||
color: #64748b;
|
||||
}
|
||||
|
||||
&__x {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
height: 28px;
|
||||
width: 28px;
|
||||
border: 0;
|
||||
border-radius: 50%;
|
||||
background: transparent;
|
||||
color: #64748b;
|
||||
cursor: pointer;
|
||||
|
||||
&:hover {
|
||||
background: #e2e8f0;
|
||||
}
|
||||
|
||||
mat-icon {
|
||||
font-size: 18px;
|
||||
height: 18px;
|
||||
width: 18px;
|
||||
}
|
||||
}
|
||||
|
||||
&__body {
|
||||
padding: 12px 14px;
|
||||
|
||||
textarea {
|
||||
width: 100%;
|
||||
padding: 7px 10px;
|
||||
border: 1px solid #cbd5e1;
|
||||
border-radius: 8px;
|
||||
font: inherit;
|
||||
font-size: 12px;
|
||||
resize: vertical;
|
||||
|
||||
&:focus {
|
||||
outline: 0;
|
||||
border-color: #0f4f3c;
|
||||
box-shadow: 0 0 0 3px rgb(15 79 60 / 8%);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
&__foot {
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
gap: 8px;
|
||||
padding: 10px 14px;
|
||||
border-top: 1px solid #e2e8f0;
|
||||
background: #f8fafc;
|
||||
}
|
||||
}
|
||||
|
||||
.vca-label {
|
||||
display: block;
|
||||
margin-bottom: 4px;
|
||||
font-size: 10px;
|
||||
font-weight: 700;
|
||||
letter-spacing: 0.05em;
|
||||
text-transform: uppercase;
|
||||
color: #94a3b8;
|
||||
}
|
||||
+192
@@ -0,0 +1,192 @@
|
||||
/**
|
||||
* 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, TemplateRef, ViewChild, inject } from '@angular/core';
|
||||
import { CommonModule } from '@angular/common';
|
||||
import { FormsModule } from '@angular/forms';
|
||||
import { MatDialog, MatDialogModule, MatDialogRef } from '@angular/material/dialog';
|
||||
import { MatIconModule } from '@angular/material/icon';
|
||||
import { MatSnackBar, MatSnackBarModule } from '@angular/material/snack-bar';
|
||||
import { of } from 'rxjs';
|
||||
import { catchError } from 'rxjs/operators';
|
||||
import { VoucherCorrection, VoucherCorrectionService } from '../voucher-correction.service';
|
||||
|
||||
type Kind = 'unpass' | 'correction';
|
||||
type KindFilter = 'ALL' | Kind;
|
||||
|
||||
/** One row of the approvals table: a request, and which of the two steps it is waiting at. */
|
||||
interface ApprovalRow {
|
||||
kind: Kind;
|
||||
request: VoucherCorrection;
|
||||
raisedBy: string | null;
|
||||
raisedAt: string | null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Manager side of the voucher correction, laid out like the other approval tabs: approves or rejects the unpass of a
|
||||
* voucher, and later the correction entry the cashier made. Only the second approval moves money.
|
||||
*/
|
||||
@Component({
|
||||
selector: 'mifosx-voucher-correction-approvals',
|
||||
templateUrl: './voucher-correction-approvals.component.html',
|
||||
styleUrls: ['./voucher-correction-approvals.component.scss'],
|
||||
standalone: true,
|
||||
imports: [
|
||||
CommonModule,
|
||||
FormsModule,
|
||||
MatDialogModule,
|
||||
MatIconModule,
|
||||
MatSnackBarModule
|
||||
]
|
||||
})
|
||||
export class VoucherCorrectionApprovalsComponent implements OnInit {
|
||||
private service = inject(VoucherCorrectionService);
|
||||
private snackBar = inject(MatSnackBar);
|
||||
private dialog = inject(MatDialog);
|
||||
|
||||
@ViewChild('rejectDialog') rejectDialog!: TemplateRef<unknown>;
|
||||
private dialogRef: MatDialogRef<unknown> | null = null;
|
||||
|
||||
all: ApprovalRow[] = [];
|
||||
loading = false;
|
||||
loaded = false;
|
||||
busyId: number | null = null;
|
||||
|
||||
kindFilter: KindFilter = 'ALL';
|
||||
searchTerm = '';
|
||||
|
||||
/** The row being rejected, and the reason typed for it. */
|
||||
rejecting: ApprovalRow | null = null;
|
||||
rejectReason = '';
|
||||
|
||||
readonly kindOptions: { value: KindFilter; label: string }[] = [
|
||||
{ value: 'ALL', label: 'All requests' },
|
||||
{ value: 'unpass', label: 'Unpass requests' },
|
||||
{ value: 'correction', label: 'Correction entries' }
|
||||
];
|
||||
|
||||
ngOnInit(): void {
|
||||
this.load();
|
||||
}
|
||||
|
||||
load(): void {
|
||||
this.loading = true;
|
||||
this.service
|
||||
.list('UNPASS_PENDING,CORRECTION_PENDING')
|
||||
.pipe(catchError(() => of([] as VoucherCorrection[])))
|
||||
.subscribe((rows) => {
|
||||
this.all = rows.map((r) =>
|
||||
r.status === 'UNPASS_PENDING'
|
||||
? { kind: 'unpass', request: r, raisedBy: r.unpass_by_name, raisedAt: r.unpass_at }
|
||||
: { kind: 'correction', request: r, raisedBy: r.correction_by_name, raisedAt: r.correction_at }
|
||||
);
|
||||
this.loading = false;
|
||||
this.loaded = true;
|
||||
});
|
||||
}
|
||||
|
||||
/** The table: the kind picked, narrowed by whatever is typed in the search box. */
|
||||
get rows(): ApprovalRow[] {
|
||||
const term = this.searchTerm.trim().toLowerCase();
|
||||
return this.all.filter((r) => {
|
||||
if (this.kindFilter !== 'ALL' && r.kind !== this.kindFilter) return false;
|
||||
if (!term) return true;
|
||||
const q = r.request;
|
||||
return [
|
||||
q.original_voucher_no,
|
||||
q.wrong_account_no,
|
||||
q.wrong_client_name,
|
||||
q.correct_account_no,
|
||||
q.correct_client_name,
|
||||
r.raisedBy
|
||||
].some((v) => (v ?? '').toLowerCase().includes(term));
|
||||
});
|
||||
}
|
||||
|
||||
get unpassCount(): number {
|
||||
return this.all.filter((r) => r.kind === 'unpass').length;
|
||||
}
|
||||
|
||||
get correctionCount(): number {
|
||||
return this.all.filter((r) => r.kind === 'correction').length;
|
||||
}
|
||||
|
||||
kindLabel(kind: Kind): string {
|
||||
return kind === 'unpass' ? 'Unpass' : 'Correction';
|
||||
}
|
||||
|
||||
formatDateTime(value: string | null): string {
|
||||
if (!value) return '—';
|
||||
const d = new Date(value);
|
||||
return isNaN(d.getTime())
|
||||
? value
|
||||
: d.toLocaleString('en-IN', { day: '2-digit', month: 'short', hour: '2-digit', minute: '2-digit' });
|
||||
}
|
||||
|
||||
approve(row: ApprovalRow): void {
|
||||
if (this.busyId) return;
|
||||
const id = row.request.id;
|
||||
this.busyId = id;
|
||||
const call = row.kind === 'unpass' ? this.service.approveUnpass(id) : this.service.approveCorrection(id);
|
||||
call.subscribe({
|
||||
next: (res: any) => {
|
||||
this.busyId = null;
|
||||
this.snackBar.open(
|
||||
row.kind === 'unpass'
|
||||
? `Voucher ${row.request.original_voucher_no} unpassed — the cashier can now enter the correct account`
|
||||
: `Corrected. New voucher ${res?.newVoucherNo ?? ''}`,
|
||||
'Close',
|
||||
{ duration: 6000 }
|
||||
);
|
||||
this.load();
|
||||
},
|
||||
error: (err) => {
|
||||
this.busyId = null;
|
||||
this.fail(err, 'Could not approve');
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
startReject(row: ApprovalRow): void {
|
||||
this.rejecting = row;
|
||||
this.rejectReason = '';
|
||||
this.dialogRef = this.dialog.open(this.rejectDialog, { width: '440px', maxWidth: '95vw', autoFocus: false });
|
||||
this.dialogRef.afterClosed().subscribe(() => (this.rejecting = null));
|
||||
}
|
||||
|
||||
cancelReject(): void {
|
||||
this.dialogRef?.close();
|
||||
}
|
||||
|
||||
confirmReject(): void {
|
||||
if (!this.rejecting || !this.rejectReason.trim() || this.busyId) return;
|
||||
const row = this.rejecting;
|
||||
const id = row.request.id;
|
||||
this.busyId = id;
|
||||
const reason = this.rejectReason.trim();
|
||||
const call =
|
||||
row.kind === 'unpass' ? this.service.rejectUnpass(id, reason) : this.service.rejectCorrection(id, reason);
|
||||
call.subscribe({
|
||||
next: () => {
|
||||
this.busyId = null;
|
||||
this.dialogRef?.close();
|
||||
this.snackBar.open('Rejected', 'Close', { duration: 4000 });
|
||||
this.load();
|
||||
},
|
||||
error: (err) => {
|
||||
this.busyId = null;
|
||||
this.fail(err, 'Could not reject');
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
private fail(err: any, fallback: string): void {
|
||||
const msg = err?.error?.errors?.[0]?.defaultUserMessage ?? err?.error?.defaultUserMessage ?? fallback;
|
||||
this.snackBar.open(msg, 'Close', { duration: 8000 });
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,120 @@
|
||||
/**
|
||||
* Copyright since 2025 Mifos Initiative
|
||||
*
|
||||
* This Source Code Form is subject to the terms of the Mozilla Public
|
||||
* License, v. 2.0. If a copy of the MPL was not distributed with this
|
||||
* file, You can obtain one at http://mozilla.org/MPL/2.0/.
|
||||
*/
|
||||
|
||||
import { Injectable, inject } from '@angular/core';
|
||||
import { HttpClient, HttpParams } from '@angular/common/http';
|
||||
import { Observable } from 'rxjs';
|
||||
|
||||
/** One row of a voucher correction, as the server keeps it (snake_case columns). */
|
||||
export interface VoucherCorrection {
|
||||
id: number;
|
||||
original_voucher_no: string;
|
||||
original_txn_id: number;
|
||||
wrong_savings_account_id: number;
|
||||
wrong_account_no: string;
|
||||
wrong_client_name: string | null;
|
||||
amount: number;
|
||||
txn_date: string | null;
|
||||
status: 'UNPASS_PENDING' | 'UNPASSED' | 'UNPASS_REJECTED' | 'CORRECTION_PENDING' | 'COMPLETED' | 'CLOSED';
|
||||
reason: string | null;
|
||||
rejection_reason: string | null;
|
||||
unpass_by_name: string | null;
|
||||
unpass_at: string | null;
|
||||
unpass_approved_by_name: string | null;
|
||||
correct_account_no: string | null;
|
||||
correct_client_name: string | null;
|
||||
correction_by_name: string | null;
|
||||
correction_at: string | null;
|
||||
correction_approved_by_name: string | null;
|
||||
new_voucher_no: string | null;
|
||||
}
|
||||
|
||||
/** A cash deposit voucher of today that can still be unpassed. */
|
||||
export interface EligibleVoucher {
|
||||
voucherNo: string;
|
||||
amount: number;
|
||||
createdAt: string;
|
||||
cashier: string;
|
||||
txnId: number;
|
||||
savingsAccountId: number;
|
||||
accountNo: string;
|
||||
clientName: string | null;
|
||||
paymentType: string;
|
||||
}
|
||||
|
||||
/** What the server knows about a savings account typed in as the correct one. */
|
||||
export interface AccountLookup {
|
||||
id: number;
|
||||
accountNo: string;
|
||||
clientId: number | null;
|
||||
clientName: string | null;
|
||||
balance: number;
|
||||
currencyCode: string;
|
||||
canReceive: boolean;
|
||||
}
|
||||
|
||||
/** HTTP service for the voucher correction workflow: unpass, approve, re-enter, approve. */
|
||||
@Injectable({ providedIn: 'root' })
|
||||
export class VoucherCorrectionService {
|
||||
private http = inject(HttpClient);
|
||||
private readonly base = '/vouchers/corrections';
|
||||
|
||||
eligible(search?: string): Observable<EligibleVoucher[]> {
|
||||
let params = new HttpParams();
|
||||
if (search) params = params.set('search', search);
|
||||
return this.http.get<EligibleVoucher[]>(`${this.base}/eligible`, { params });
|
||||
}
|
||||
|
||||
unpass(voucherNo: string, reason: string): Observable<any> {
|
||||
return this.http.post(`${this.base}/unpass`, { voucherNo, reason });
|
||||
}
|
||||
|
||||
list(status?: string, mine = false): Observable<VoucherCorrection[]> {
|
||||
let params = new HttpParams().set('mine', String(mine));
|
||||
if (status) params = params.set('status', status);
|
||||
return this.http.get<VoucherCorrection[]>(this.base, { params });
|
||||
}
|
||||
|
||||
get(id: number): Observable<VoucherCorrection> {
|
||||
return this.http.get<VoucherCorrection>(`${this.base}/${id}`);
|
||||
}
|
||||
|
||||
accountLookup(accountNo: string): Observable<AccountLookup> {
|
||||
return this.http.get<AccountLookup>(`${this.base}/account-lookup`, { params: { accountNo } });
|
||||
}
|
||||
|
||||
submitCorrection(id: number, correctAccountNo: string): Observable<any> {
|
||||
return this.http.post(`${this.base}/${id}/correction`, { correctAccountNo });
|
||||
}
|
||||
|
||||
/** The cashier gives up the correction, e.g. when the wrong account no longer holds the money. */
|
||||
close(id: number, reason: string): Observable<any> {
|
||||
return this.http.post(`${this.base}/${id}/close`, { reason });
|
||||
}
|
||||
|
||||
approveUnpass(id: number): Observable<any> {
|
||||
return this.http.post(`${this.base}/${id}/unpass/approve`, {});
|
||||
}
|
||||
|
||||
rejectUnpass(id: number, reason: string): Observable<any> {
|
||||
return this.http.post(`${this.base}/${id}/unpass/reject`, { reason });
|
||||
}
|
||||
|
||||
approveCorrection(id: number): Observable<any> {
|
||||
return this.http.post(`${this.base}/${id}/correction/approve`, {});
|
||||
}
|
||||
|
||||
rejectCorrection(id: number, reason: string): Observable<any> {
|
||||
return this.http.post(`${this.base}/${id}/correction/reject`, { reason });
|
||||
}
|
||||
|
||||
/** Ids of the transactions of this account that a completed correction removed. */
|
||||
voidedTransactions(savingsAccountId: number): Observable<number[]> {
|
||||
return this.http.get<number[]>(`${this.base}/voided-transactions`, { params: { savingsAccountId } });
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,446 @@
|
||||
<!--
|
||||
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="pg">
|
||||
<header class="pg-head">
|
||||
<div>
|
||||
<div class="pg-eyebrow">CASHIER · VOUCHER CORRECTION</div>
|
||||
<h1 class="pg-title">Voucher Correction</h1>
|
||||
<p class="pg-sub">
|
||||
Move a cash deposit that was posted to the wrong account. Nothing changes on any account until the final
|
||||
approval.
|
||||
</p>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<!-- The four steps, so it is always clear where a voucher is. -->
|
||||
<ol class="vc-steps">
|
||||
<li class="vc-step" [class.vc-step--on]="tab === 'today'">
|
||||
<span class="vc-step__no">1</span>
|
||||
<span class="vc-step__txt"><strong>Unpass</strong> the voucher</span>
|
||||
</li>
|
||||
<li class="vc-step">
|
||||
<span class="vc-step__no">2</span>
|
||||
<span class="vc-step__txt"><strong>Manager</strong> approves</span>
|
||||
</li>
|
||||
<li class="vc-step" [class.vc-step--on]="tab === 'unpassed'">
|
||||
<span class="vc-step__no">3</span>
|
||||
<span class="vc-step__txt">Enter the <strong>correct account</strong></span>
|
||||
</li>
|
||||
<li class="vc-step">
|
||||
<span class="vc-step__no">4</span>
|
||||
<span class="vc-step__txt"><strong>Manager</strong> approves, deposit moves</span>
|
||||
</li>
|
||||
</ol>
|
||||
|
||||
<nav class="vc-tabs">
|
||||
@if (mayUnpass) {
|
||||
<button type="button" class="vc-tab" [class.vc-tab--on]="tab === 'today'" (click)="setTab('today')">
|
||||
<mat-icon>receipt_long</mat-icon> Today's Vouchers
|
||||
</button>
|
||||
}
|
||||
@if (mayCorrect) {
|
||||
<button type="button" class="vc-tab" [class.vc-tab--on]="tab === 'unpassed'" (click)="setTab('unpassed')">
|
||||
<mat-icon>edit_note</mat-icon> Unpassed — Correct Entry
|
||||
@if (unpassed.length) {
|
||||
<span class="vc-count">{{ unpassed.length }}</span>
|
||||
}
|
||||
</button>
|
||||
}
|
||||
<button type="button" class="vc-tab" [class.vc-tab--on]="tab === 'mine'" (click)="setTab('mine')">
|
||||
<mat-icon>history</mat-icon> My Requests
|
||||
</button>
|
||||
</nav>
|
||||
|
||||
<!-- ─── Today's vouchers ─────────────────────────────────────────────── -->
|
||||
@if (tab === 'today') {
|
||||
<div class="vc-toolbar">
|
||||
<div class="vc-search">
|
||||
<mat-icon>search</mat-icon>
|
||||
<input
|
||||
type="text"
|
||||
placeholder="Search by voucher, account or customer name"
|
||||
[(ngModel)]="search"
|
||||
(keyup.enter)="loadVouchers()"
|
||||
/>
|
||||
</div>
|
||||
<button type="button" class="pg-btn pg-btn--primary" (click)="loadVouchers()">
|
||||
<mat-icon>search</mat-icon> Search
|
||||
</button>
|
||||
<button type="button" class="pg-btn pg-btn--ghost" (click)="search = ''; loadVouchers()">
|
||||
<mat-icon>refresh</mat-icon> Refresh
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div class="pg-card">
|
||||
<table class="pg-table vc-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Voucher</th>
|
||||
<th>Account</th>
|
||||
<th>Customer</th>
|
||||
<th>Mode</th>
|
||||
<th>Cashier</th>
|
||||
<th class="num">Amount</th>
|
||||
<th class="vc-col-action"></th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
@for (v of vouchers; track v.voucherNo) {
|
||||
<tr [class.vc-row--on]="selectedVoucher?.voucherNo === v.voucherNo" (click)="pick(v)">
|
||||
<td>
|
||||
<span class="pg-chip">{{ v.voucherNo }}</span>
|
||||
</td>
|
||||
<td class="vc-mono">{{ v.accountNo }}</td>
|
||||
<td>{{ v.clientName || '—' }}</td>
|
||||
<td>{{ v.paymentType }}</td>
|
||||
<td>{{ v.cashier }}</td>
|
||||
<td class="num">₹{{ v.amount | number: '1.2-2' }}</td>
|
||||
<td class="vc-col-action">
|
||||
<button
|
||||
type="button"
|
||||
class="pg-btn pg-btn--ghost vc-btn-sm"
|
||||
(click)="pick(v); $event.stopPropagation()"
|
||||
>
|
||||
<mat-icon>undo</mat-icon> Unpass
|
||||
</button>
|
||||
</td>
|
||||
</tr>
|
||||
}
|
||||
@if (!vouchers.length) {
|
||||
<tr>
|
||||
<td colspan="7" class="vc-empty">
|
||||
<mat-icon>{{ loadingVouchers ? 'hourglass_empty' : 'inbox' }}</mat-icon>
|
||||
<div>{{ loadingVouchers ? 'Loading…' : 'No cash deposit voucher of today can be unpassed.' }}</div>
|
||||
</td>
|
||||
</tr>
|
||||
}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
}
|
||||
|
||||
<!-- ─── Unpassed: enter the correct account ──────────────────────────── -->
|
||||
@if (tab === 'unpassed') {
|
||||
<div class="pg-card">
|
||||
<table class="pg-table vc-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Voucher</th>
|
||||
<th>Posted to</th>
|
||||
<th>Customer</th>
|
||||
<th>Reason</th>
|
||||
<th class="num">Amount</th>
|
||||
<th class="vc-col-action"></th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
@for (u of unpassed; track u.id) {
|
||||
<tr [class.vc-row--on]="active?.id === u.id" (click)="open(u)">
|
||||
<td>
|
||||
<span class="pg-chip">{{ u.original_voucher_no }}</span>
|
||||
</td>
|
||||
<td class="vc-mono">{{ u.wrong_account_no }}</td>
|
||||
<td>{{ u.wrong_client_name || '—' }}</td>
|
||||
<td class="vc-clip">{{ u.reason }}</td>
|
||||
<td class="num">₹{{ u.amount | number: '1.2-2' }}</td>
|
||||
<td class="vc-col-action">
|
||||
<button
|
||||
type="button"
|
||||
class="pg-btn pg-btn--primary vc-btn-sm"
|
||||
(click)="open(u); $event.stopPropagation()"
|
||||
>
|
||||
<mat-icon>edit</mat-icon> Correct
|
||||
</button>
|
||||
</td>
|
||||
</tr>
|
||||
}
|
||||
@if (!unpassed.length) {
|
||||
<tr>
|
||||
<td colspan="6" class="vc-empty">
|
||||
<mat-icon>{{ loadingUnpassed ? 'hourglass_empty' : 'task_alt' }}</mat-icon>
|
||||
<div>
|
||||
{{ loadingUnpassed ? 'Loading…' : 'No unpassed voucher is waiting for its correct account.' }}
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
}
|
||||
|
||||
<!-- ─── My requests ──────────────────────────────────────────────────── -->
|
||||
@if (tab === 'mine') {
|
||||
<div class="pg-card">
|
||||
<table class="pg-table vc-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Voucher</th>
|
||||
<th>Posted to</th>
|
||||
<th>Corrected to</th>
|
||||
<th class="num">Amount</th>
|
||||
<th>Status</th>
|
||||
<th>New voucher</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
@for (m of mine; track m.id) {
|
||||
<tr>
|
||||
<td>
|
||||
<span class="pg-chip">{{ m.original_voucher_no }}</span>
|
||||
</td>
|
||||
<td class="vc-mono">{{ m.wrong_account_no }}</td>
|
||||
<td class="vc-mono">{{ m.correct_account_no || '—' }}</td>
|
||||
<td class="num">₹{{ m.amount | number: '1.2-2' }}</td>
|
||||
<td>
|
||||
<span class="vc-status vc-status--{{ m.status }}">{{ statusLabel[m.status] }}</span>
|
||||
@if (m.rejection_reason) {
|
||||
<div class="vc-note">{{ m.rejection_reason }}</div>
|
||||
}
|
||||
</td>
|
||||
<td>{{ m.new_voucher_no || '—' }}</td>
|
||||
</tr>
|
||||
}
|
||||
@if (!mine.length) {
|
||||
<tr>
|
||||
<td colspan="6" class="vc-empty">
|
||||
<mat-icon>{{ loadingMine ? 'hourglass_empty' : 'history' }}</mat-icon>
|
||||
<div>{{ loadingMine ? 'Loading…' : 'You have no voucher corrections yet.' }}</div>
|
||||
</td>
|
||||
</tr>
|
||||
}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
|
||||
<!-- ─── Popup: unpass a voucher ──────────────────────────────────────────── -->
|
||||
<ng-template #unpassDialog>
|
||||
@if (selectedVoucher) {
|
||||
<div class="vc-dlg">
|
||||
<header class="vc-dlg__head">
|
||||
<mat-icon class="vc-dlg__ico">undo</mat-icon>
|
||||
<div class="vc-dlg__titles">
|
||||
<div class="vc-dlg__title">Unpass voucher</div>
|
||||
<div class="vc-dlg__sub vc-mono">{{ selectedVoucher.voucherNo }}</div>
|
||||
</div>
|
||||
<button type="button" class="vc-dlg__x" aria-label="Close" (click)="closeUnpass()">
|
||||
<mat-icon>close</mat-icon>
|
||||
</button>
|
||||
</header>
|
||||
|
||||
<div class="vc-dlg__body">
|
||||
<div class="vc-dlg__amount">
|
||||
<span>Deposit amount</span>
|
||||
<strong>₹{{ selectedVoucher.amount | number: '1.2-2' }}</strong>
|
||||
</div>
|
||||
|
||||
<div class="vc-grid">
|
||||
<div class="vc-cell">
|
||||
<div class="vc-label">Voucher</div>
|
||||
<div class="vc-value vc-mono">{{ selectedVoucher.voucherNo }}</div>
|
||||
</div>
|
||||
<div class="vc-cell">
|
||||
<div class="vc-label">Created</div>
|
||||
<div class="vc-value">{{ selectedVoucher.createdAt | date: 'dd MMM yyyy, hh:mm a' }}</div>
|
||||
</div>
|
||||
<div class="vc-cell">
|
||||
<div class="vc-label">Cashier</div>
|
||||
<div class="vc-value">{{ selectedVoucher.cashier }}</div>
|
||||
</div>
|
||||
<div class="vc-cell">
|
||||
<div class="vc-label">Mode</div>
|
||||
<div class="vc-value">{{ selectedVoucher.paymentType }}</div>
|
||||
</div>
|
||||
<div class="vc-cell">
|
||||
<div class="vc-label">Account</div>
|
||||
<div class="vc-value vc-mono">{{ selectedVoucher.accountNo }}</div>
|
||||
</div>
|
||||
<div class="vc-cell">
|
||||
<div class="vc-label">Customer</div>
|
||||
<div class="vc-value">{{ selectedVoucher.clientName || '—' }}</div>
|
||||
</div>
|
||||
<div class="vc-cell">
|
||||
<div class="vc-label">Account balance now</div>
|
||||
<div class="vc-value">
|
||||
@if (voucherAccount) {
|
||||
₹{{ voucherAccount.balance | number: '1.2-2' }}
|
||||
} @else {
|
||||
—
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
<div class="vc-cell">
|
||||
<div class="vc-label">Account status</div>
|
||||
<div class="vc-value">
|
||||
@if (voucherAccount) {
|
||||
{{ voucherAccount.canReceive ? 'Active' : 'Not active' }} · {{ voucherAccount.currencyCode }}
|
||||
} @else {
|
||||
—
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<label class="vc-label" for="vc-reason">Reason for unpassing</label>
|
||||
<textarea
|
||||
id="vc-reason"
|
||||
class="vc-reason"
|
||||
rows="2"
|
||||
placeholder="Why is this voucher wrong? e.g. deposited in the wrong account (required)"
|
||||
[(ngModel)]="unpassReason"
|
||||
></textarea>
|
||||
|
||||
<div class="vc-info">
|
||||
<mat-icon>info</mat-icon>
|
||||
A manager approves the unpass first. Nothing changes on any account until the corrected entry is approved: the
|
||||
deposit stays on {{ selectedVoucher.accountNo }} till then.
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<footer class="vc-dlg__foot">
|
||||
<button type="button" class="pg-btn pg-btn--ghost" (click)="closeUnpass()">Cancel</button>
|
||||
<button type="button" class="pg-btn pg-btn--primary" [disabled]="!canUnpass" (click)="unpass()">
|
||||
<mat-icon>send</mat-icon> Send for approval
|
||||
</button>
|
||||
</footer>
|
||||
</div>
|
||||
}
|
||||
</ng-template>
|
||||
|
||||
<!-- ─── Popup: enter the correct account ─────────────────────────────────── -->
|
||||
<ng-template #correctionDialog>
|
||||
@if (active) {
|
||||
<div class="vc-dlg">
|
||||
<header class="vc-dlg__head">
|
||||
<mat-icon class="vc-dlg__ico">edit_note</mat-icon>
|
||||
<div class="vc-dlg__titles">
|
||||
<div class="vc-dlg__title">Correction entry</div>
|
||||
<div class="vc-dlg__sub vc-mono">{{ active.original_voucher_no }}</div>
|
||||
</div>
|
||||
<button type="button" class="vc-dlg__x" aria-label="Close" (click)="closeActive()">
|
||||
<mat-icon>close</mat-icon>
|
||||
</button>
|
||||
</header>
|
||||
|
||||
<div class="vc-dlg__body">
|
||||
<div class="vc-dlg__amount">
|
||||
<span>Amount to move</span>
|
||||
<strong>₹{{ active.amount | number: '1.2-2' }}</strong>
|
||||
</div>
|
||||
|
||||
<!-- Everything but the account number is fixed. -->
|
||||
<div class="vc-grid">
|
||||
<div class="vc-cell">
|
||||
<div class="vc-label">Transaction date</div>
|
||||
<div class="vc-value">{{ active.txn_date || '—' }}</div>
|
||||
</div>
|
||||
<div class="vc-cell">
|
||||
<div class="vc-label">Mode</div>
|
||||
<div class="vc-value">Cash</div>
|
||||
</div>
|
||||
<div class="vc-cell">
|
||||
<div class="vc-label">Unpassed by</div>
|
||||
<div class="vc-value">{{ active.unpass_by_name || '—' }}</div>
|
||||
</div>
|
||||
<div class="vc-cell">
|
||||
<div class="vc-label">Approved by</div>
|
||||
<div class="vc-value">{{ active.unpass_approved_by_name || '—' }}</div>
|
||||
</div>
|
||||
<div class="vc-cell vc-cell--wide">
|
||||
<div class="vc-label">Reason</div>
|
||||
<div class="vc-value">{{ active.reason }}</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="vc-compare">
|
||||
<!-- The account the deposit went to -->
|
||||
<div class="vc-acct vc-acct--wrong">
|
||||
<div class="vc-acct__tag">Posted to (wrong)</div>
|
||||
<div class="vc-acct__no vc-mono">{{ active.wrong_account_no }}</div>
|
||||
<div class="vc-acct__name">{{ active.wrong_client_name || '—' }}</div>
|
||||
<div class="vc-acct__bal">
|
||||
Balance:
|
||||
@if (wrongAccount) {
|
||||
<strong>₹{{ wrongAccount.balance | number: '1.2-2' }}</strong>
|
||||
@if (wrongHasBalance) {
|
||||
<span class="vc-ok"><mat-icon>check_circle</mat-icon> covers the deposit</span>
|
||||
} @else {
|
||||
<span class="vc-bad"
|
||||
><mat-icon>cancel</mat-icon> does not cover ₹{{ active.amount | number: '1.2-2' }}</span
|
||||
>
|
||||
}
|
||||
} @else {
|
||||
—
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<mat-icon class="vc-arrow">arrow_forward</mat-icon>
|
||||
|
||||
<!-- The account it should have gone to -->
|
||||
<div class="vc-acct" [class.vc-acct--ok]="correctOk" [class.vc-acct--wrong]="!!lookupError">
|
||||
<div class="vc-acct__tag">Correct account</div>
|
||||
<input
|
||||
id="vc-correct"
|
||||
class="vc-acct__input vc-mono"
|
||||
type="text"
|
||||
placeholder="Type the account number"
|
||||
[ngModel]="correctAccountNo"
|
||||
(ngModelChange)="onAccountInput($event)"
|
||||
(blur)="lookup()"
|
||||
/>
|
||||
@if (lookingUp) {
|
||||
<div class="vc-acct__bal">Looking up…</div>
|
||||
}
|
||||
@if (correctAccount) {
|
||||
<div class="vc-acct__name">{{ correctAccount.clientName || '—' }}</div>
|
||||
<div class="vc-acct__bal">
|
||||
Balance: <strong>₹{{ correctAccount.balance | number: '1.2-2' }}</strong>
|
||||
@if (correctOk) {
|
||||
<span class="vc-ok"><mat-icon>check_circle</mat-icon> active, can receive</span>
|
||||
}
|
||||
</div>
|
||||
}
|
||||
@if (lookupError) {
|
||||
<div class="vc-bad vc-acct__err"><mat-icon>error</mat-icon> {{ lookupError }}</div>
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@if (wrongAccount && !wrongHasBalance) {
|
||||
<div class="vc-warn">
|
||||
<mat-icon>warning</mat-icon>
|
||||
<div>
|
||||
<strong>The customer has already used this money,</strong> so it cannot be taken back from
|
||||
{{ active.wrong_account_no }}. Deposit ₹{{ active.amount | number: '1.2-2' }} into the correct account
|
||||
from your own pocket as a fresh deposit (Transaction Entry), then press <strong>Close request</strong>.
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
|
||||
<footer class="vc-dlg__foot">
|
||||
<button
|
||||
type="button"
|
||||
class="pg-btn pg-btn--ghost vc-dlg__close-req"
|
||||
[disabled]="sending"
|
||||
(click)="closeRequest()"
|
||||
>
|
||||
<mat-icon>block</mat-icon> Close request
|
||||
</button>
|
||||
<button type="button" class="pg-btn pg-btn--ghost" (click)="closeActive()">Cancel</button>
|
||||
<button type="button" class="pg-btn pg-btn--primary" [disabled]="!canSave" (click)="save()">
|
||||
<mat-icon>check</mat-icon> Save & send for approval
|
||||
</button>
|
||||
</footer>
|
||||
</div>
|
||||
}
|
||||
</ng-template>
|
||||
@@ -0,0 +1,599 @@
|
||||
/**
|
||||
* 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/.
|
||||
*/
|
||||
|
||||
// Compact sizing: the page has to fit a 100% browser window without scrolling for the common case.
|
||||
.pg {
|
||||
padding: 14px 20px;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.pg-title {
|
||||
font-size: 20px;
|
||||
margin: 2px 0;
|
||||
}
|
||||
|
||||
.pg-sub {
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.pg-btn {
|
||||
height: 30px;
|
||||
padding: 0 12px;
|
||||
font-size: 12px;
|
||||
|
||||
mat-icon {
|
||||
font-size: 16px;
|
||||
height: 16px;
|
||||
width: 16px;
|
||||
}
|
||||
|
||||
&:disabled {
|
||||
opacity: 0.45;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
}
|
||||
|
||||
.pg-table {
|
||||
font-size: 12px;
|
||||
|
||||
th {
|
||||
padding: 8px 12px;
|
||||
font-size: 10px;
|
||||
}
|
||||
|
||||
td {
|
||||
padding: 7px 12px;
|
||||
}
|
||||
}
|
||||
|
||||
// ── Steps ───────────────────────────────────────────────────────────────────
|
||||
.vc-steps {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fit, minmax(170px, 1fr));
|
||||
gap: 8px;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
list-style: none;
|
||||
}
|
||||
|
||||
.vc-step {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
padding: 6px 10px;
|
||||
border: 1px solid #e2e8f0;
|
||||
border-radius: 10px;
|
||||
background: #fff;
|
||||
font-size: 12px;
|
||||
color: #64748b;
|
||||
|
||||
&__no {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
flex: 0 0 20px;
|
||||
height: 20px;
|
||||
border-radius: 50%;
|
||||
background: #e2e8f0;
|
||||
font-size: 11px;
|
||||
font-weight: 700;
|
||||
color: #475569;
|
||||
}
|
||||
|
||||
&--on {
|
||||
border-color: #1f7a4d;
|
||||
background: #f0fdf4;
|
||||
color: #0f4f3c;
|
||||
|
||||
.vc-step__no {
|
||||
background: #1f7a4d;
|
||||
color: #fff;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ── Tabs ────────────────────────────────────────────────────────────────────
|
||||
.vc-tabs {
|
||||
display: flex;
|
||||
gap: 2px;
|
||||
border-bottom: 1px solid #e2e8f0;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.vc-tab {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 5px;
|
||||
padding: 7px 12px;
|
||||
margin-bottom: -1px;
|
||||
border: 0;
|
||||
border-bottom: 2px solid transparent;
|
||||
background: transparent;
|
||||
font: inherit;
|
||||
font-size: 12px;
|
||||
font-weight: 600;
|
||||
color: #64748b;
|
||||
cursor: pointer;
|
||||
|
||||
mat-icon {
|
||||
font-size: 16px;
|
||||
height: 16px;
|
||||
width: 16px;
|
||||
}
|
||||
|
||||
&:hover {
|
||||
color: #0f4f3c;
|
||||
}
|
||||
|
||||
&--on {
|
||||
color: #0f4f3c;
|
||||
border-bottom-color: #1f7a4d;
|
||||
}
|
||||
}
|
||||
|
||||
.vc-count {
|
||||
padding: 0 7px;
|
||||
border-radius: 999px;
|
||||
background: #f59e0b;
|
||||
color: #fff;
|
||||
font-size: 10px;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
// ── Search ──────────────────────────────────────────────────────────────────
|
||||
.vc-toolbar {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.vc-search {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
flex: 1 1 280px;
|
||||
max-width: 420px;
|
||||
height: 30px;
|
||||
padding: 0 10px;
|
||||
border: 1px solid #cbd5e1;
|
||||
border-radius: 999px;
|
||||
background: #fff;
|
||||
|
||||
&:focus-within {
|
||||
border-color: #1f7a4d;
|
||||
box-shadow: 0 0 0 3px rgb(31 122 77 / 12%);
|
||||
}
|
||||
|
||||
mat-icon {
|
||||
font-size: 16px;
|
||||
height: 16px;
|
||||
width: 16px;
|
||||
color: #94a3b8;
|
||||
}
|
||||
|
||||
input {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
border: 0;
|
||||
outline: 0;
|
||||
background: transparent;
|
||||
font: inherit;
|
||||
font-size: 12px;
|
||||
}
|
||||
}
|
||||
|
||||
// ── Table ───────────────────────────────────────────────────────────────────
|
||||
.vc-table {
|
||||
tbody tr {
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
tr.vc-row--on td {
|
||||
background: #f0fdf4;
|
||||
|
||||
&:first-child {
|
||||
box-shadow: inset 3px 0 0 #1f7a4d;
|
||||
}
|
||||
}
|
||||
|
||||
.vc-col-action {
|
||||
width: 1%;
|
||||
text-align: right;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.vc-clip {
|
||||
max-width: 240px;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.pg-chip {
|
||||
font-size: 11px;
|
||||
padding: 2px 8px;
|
||||
}
|
||||
}
|
||||
|
||||
.vc-btn-sm {
|
||||
height: 26px;
|
||||
padding: 0 10px;
|
||||
font-size: 11px;
|
||||
}
|
||||
|
||||
.vc-mono {
|
||||
font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace;
|
||||
letter-spacing: 0.02em;
|
||||
}
|
||||
|
||||
.vc-empty {
|
||||
padding: 24px 12px;
|
||||
text-align: center;
|
||||
color: #94a3b8;
|
||||
font-size: 12px;
|
||||
|
||||
mat-icon {
|
||||
display: block;
|
||||
margin: 0 auto 4px;
|
||||
font-size: 28px;
|
||||
height: 28px;
|
||||
width: 28px;
|
||||
opacity: 0.6;
|
||||
}
|
||||
}
|
||||
|
||||
// ── Popups ──────────────────────────────────────────────────────────────────
|
||||
.vc-dlg {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
max-height: 86vh;
|
||||
font-size: 12px;
|
||||
|
||||
&__head {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
padding: 10px 14px;
|
||||
border-bottom: 1px solid #e2e8f0;
|
||||
background: #f8fafc;
|
||||
}
|
||||
|
||||
&__ico {
|
||||
flex: 0 0 32px;
|
||||
height: 32px;
|
||||
width: 32px;
|
||||
border-radius: 9px;
|
||||
background: #f0fdf4;
|
||||
color: #1f7a4d;
|
||||
font-size: 18px;
|
||||
line-height: 32px;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
&__titles {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
&__title {
|
||||
font-size: 14px;
|
||||
font-weight: 700;
|
||||
color: #0f172a;
|
||||
}
|
||||
|
||||
&__sub {
|
||||
font-size: 11px;
|
||||
color: #64748b;
|
||||
}
|
||||
|
||||
&__x {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
height: 28px;
|
||||
width: 28px;
|
||||
border: 0;
|
||||
border-radius: 50%;
|
||||
background: transparent;
|
||||
color: #64748b;
|
||||
cursor: pointer;
|
||||
|
||||
mat-icon {
|
||||
font-size: 18px;
|
||||
height: 18px;
|
||||
width: 18px;
|
||||
}
|
||||
|
||||
&:hover {
|
||||
background: #e2e8f0;
|
||||
}
|
||||
}
|
||||
|
||||
&__body {
|
||||
padding: 12px 14px;
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
&__amount {
|
||||
display: flex;
|
||||
align-items: baseline;
|
||||
justify-content: space-between;
|
||||
margin-bottom: 10px;
|
||||
padding: 8px 12px;
|
||||
border-radius: 10px;
|
||||
background: #ecfdf5;
|
||||
border: 1px solid #a7f3d0;
|
||||
color: #065f46;
|
||||
font-size: 12px;
|
||||
font-weight: 600;
|
||||
|
||||
strong {
|
||||
font-size: 18px;
|
||||
font-weight: 800;
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
}
|
||||
|
||||
&__foot {
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
gap: 8px;
|
||||
padding: 10px 14px;
|
||||
border-top: 1px solid #e2e8f0;
|
||||
background: #f8fafc;
|
||||
}
|
||||
}
|
||||
|
||||
.vc-warn {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
gap: 8px;
|
||||
margin-top: 10px;
|
||||
padding: 8px 12px;
|
||||
border-radius: 8px;
|
||||
background: #fffbeb;
|
||||
border: 1px solid #fde68a;
|
||||
color: #92400e;
|
||||
font-size: 11px;
|
||||
|
||||
mat-icon {
|
||||
flex: 0 0 18px;
|
||||
height: 18px;
|
||||
width: 18px;
|
||||
font-size: 18px;
|
||||
}
|
||||
}
|
||||
|
||||
.vc-dlg__close-req {
|
||||
margin-right: auto;
|
||||
color: #b45309;
|
||||
}
|
||||
|
||||
.vc-label {
|
||||
display: block;
|
||||
margin-bottom: 3px;
|
||||
font-size: 10px;
|
||||
font-weight: 700;
|
||||
letter-spacing: 0.05em;
|
||||
text-transform: uppercase;
|
||||
color: #94a3b8;
|
||||
}
|
||||
|
||||
.vc-reason {
|
||||
width: 100%;
|
||||
padding: 7px 10px;
|
||||
border: 1px solid #cbd5e1;
|
||||
border-radius: 8px;
|
||||
font: inherit;
|
||||
font-size: 12px;
|
||||
resize: vertical;
|
||||
|
||||
&:focus {
|
||||
outline: 0;
|
||||
border-color: #1f7a4d;
|
||||
box-shadow: 0 0 0 3px rgb(31 122 77 / 12%);
|
||||
}
|
||||
}
|
||||
|
||||
.vc-info {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
gap: 6px;
|
||||
margin-top: 8px;
|
||||
padding: 7px 10px;
|
||||
border-radius: 8px;
|
||||
background: #eff6ff;
|
||||
color: #1e40af;
|
||||
font-size: 11px;
|
||||
|
||||
mat-icon {
|
||||
flex: 0 0 16px;
|
||||
height: 16px;
|
||||
width: 16px;
|
||||
font-size: 16px;
|
||||
}
|
||||
}
|
||||
|
||||
.vc-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fit, minmax(130px, 1fr));
|
||||
gap: 6px;
|
||||
margin-bottom: 10px;
|
||||
}
|
||||
|
||||
.vc-cell {
|
||||
padding: 6px 10px;
|
||||
border-radius: 8px;
|
||||
background: #f8fafc;
|
||||
border: 1px solid #eef2f7;
|
||||
}
|
||||
|
||||
.vc-cell--wide {
|
||||
grid-column: 1 / -1;
|
||||
}
|
||||
|
||||
.vc-value {
|
||||
font-size: 12px;
|
||||
font-weight: 600;
|
||||
color: #0f172a;
|
||||
word-break: break-word;
|
||||
}
|
||||
|
||||
// ── Wrong → Correct ─────────────────────────────────────────────────────────
|
||||
.vc-compare {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr auto 1fr;
|
||||
align-items: stretch;
|
||||
gap: 8px;
|
||||
|
||||
@media (width <= 640px) {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
}
|
||||
|
||||
.vc-arrow {
|
||||
align-self: center;
|
||||
color: #94a3b8;
|
||||
|
||||
@media (width <= 640px) {
|
||||
transform: rotate(90deg);
|
||||
justify-self: center;
|
||||
}
|
||||
}
|
||||
|
||||
.vc-acct {
|
||||
padding: 10px 12px;
|
||||
border: 1px solid #e2e8f0;
|
||||
border-radius: 10px;
|
||||
background: #fff;
|
||||
|
||||
&--wrong {
|
||||
border-color: #fecaca;
|
||||
background: #fef2f2;
|
||||
}
|
||||
|
||||
&--ok {
|
||||
border-color: #a7f3d0;
|
||||
background: #ecfdf5;
|
||||
}
|
||||
|
||||
&__tag {
|
||||
margin-bottom: 4px;
|
||||
font-size: 10px;
|
||||
font-weight: 700;
|
||||
letter-spacing: 0.05em;
|
||||
text-transform: uppercase;
|
||||
color: #64748b;
|
||||
}
|
||||
|
||||
&__no {
|
||||
font-size: 14px;
|
||||
font-weight: 700;
|
||||
color: #0f172a;
|
||||
}
|
||||
|
||||
&__input {
|
||||
width: 100%;
|
||||
height: 32px;
|
||||
padding: 0 10px;
|
||||
border: 1px solid #cbd5e1;
|
||||
border-radius: 8px;
|
||||
background: #fff;
|
||||
font-size: 13px;
|
||||
font-weight: 600;
|
||||
|
||||
&:focus {
|
||||
outline: 0;
|
||||
border-color: #1f7a4d;
|
||||
box-shadow: 0 0 0 3px rgb(31 122 77 / 12%);
|
||||
}
|
||||
}
|
||||
|
||||
&__name {
|
||||
margin-top: 4px;
|
||||
font-size: 12px;
|
||||
font-weight: 600;
|
||||
color: #1e293b;
|
||||
}
|
||||
|
||||
&__bal {
|
||||
margin-top: 3px;
|
||||
font-size: 11px;
|
||||
color: #475569;
|
||||
}
|
||||
|
||||
&__err {
|
||||
margin-top: 6px;
|
||||
}
|
||||
}
|
||||
|
||||
.vc-ok,
|
||||
.vc-bad {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 3px;
|
||||
margin-left: 6px;
|
||||
font-weight: 700;
|
||||
|
||||
mat-icon {
|
||||
font-size: 14px;
|
||||
height: 14px;
|
||||
width: 14px;
|
||||
}
|
||||
}
|
||||
|
||||
.vc-ok {
|
||||
color: #059669;
|
||||
}
|
||||
|
||||
.vc-bad {
|
||||
color: #dc2626;
|
||||
}
|
||||
|
||||
.vc-note {
|
||||
margin-top: 3px;
|
||||
font-size: 11px;
|
||||
color: #64748b;
|
||||
}
|
||||
|
||||
// ── Status ──────────────────────────────────────────────────────────────────
|
||||
.vc-status {
|
||||
display: inline-block;
|
||||
padding: 2px 8px;
|
||||
border-radius: 999px;
|
||||
background: #e2e8f0;
|
||||
font-size: 11px;
|
||||
font-weight: 700;
|
||||
|
||||
&--UNPASS_PENDING,
|
||||
&--CORRECTION_PENDING {
|
||||
background: #fef3c7;
|
||||
color: #92400e;
|
||||
}
|
||||
|
||||
&--UNPASSED {
|
||||
background: #dbeafe;
|
||||
color: #1e40af;
|
||||
}
|
||||
|
||||
&--COMPLETED {
|
||||
background: #d1fae5;
|
||||
color: #065f46;
|
||||
}
|
||||
|
||||
&--CLOSED {
|
||||
background: #f1f5f9;
|
||||
color: #475569;
|
||||
}
|
||||
|
||||
&--UNPASS_REJECTED {
|
||||
background: #fee2e2;
|
||||
color: #991b1b;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,307 @@
|
||||
/**
|
||||
* 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, TemplateRef, ViewChild, inject } from '@angular/core';
|
||||
import { CommonModule } from '@angular/common';
|
||||
import { FormsModule } from '@angular/forms';
|
||||
import { MatDialog, MatDialogModule, MatDialogRef } from '@angular/material/dialog';
|
||||
import { MatIconModule } from '@angular/material/icon';
|
||||
import { MatSnackBar, MatSnackBarModule } from '@angular/material/snack-bar';
|
||||
import { of } from 'rxjs';
|
||||
import { catchError } from 'rxjs/operators';
|
||||
import { AuthenticationService } from '../../core/authentication/authentication.service';
|
||||
import {
|
||||
AccountLookup,
|
||||
EligibleVoucher,
|
||||
VoucherCorrection,
|
||||
VoucherCorrectionService
|
||||
} from '../voucher-correction.service';
|
||||
|
||||
type Tab = 'today' | 'unpassed' | 'mine';
|
||||
|
||||
/**
|
||||
* Cashier side of correcting a cash deposit that went to the wrong account.
|
||||
*
|
||||
* Today vouchers: pick the voucher and unpass it (needs a manager approval; nothing moves).
|
||||
* Unpassed: open the approved voucher, whose details are fixed, change only the account number and save
|
||||
* (needs a manager approval again; only then do the accounts change).
|
||||
*/
|
||||
@Component({
|
||||
selector: 'mifosx-voucher-correction',
|
||||
templateUrl: './voucher-correction.component.html',
|
||||
styleUrls: [
|
||||
'../cashier-shared.scss',
|
||||
'./voucher-correction.component.scss'
|
||||
],
|
||||
standalone: true,
|
||||
imports: [
|
||||
CommonModule,
|
||||
FormsModule,
|
||||
MatIconModule,
|
||||
MatDialogModule,
|
||||
MatSnackBarModule
|
||||
]
|
||||
})
|
||||
export class VoucherCorrectionComponent implements OnInit {
|
||||
private service = inject(VoucherCorrectionService);
|
||||
private snackBar = inject(MatSnackBar);
|
||||
private dialog = inject(MatDialog);
|
||||
private auth = inject(AuthenticationService);
|
||||
|
||||
/** Unpassing is for the back office (an administrator or a clerk); re-entering the account is the cashier only. */
|
||||
readonly mayUnpass = this.auth.isClerkUser() || !!this.auth.getCredentials()?.permissions?.includes('ALL_FUNCTIONS');
|
||||
readonly mayCorrect = this.auth.isCashierUser();
|
||||
|
||||
@ViewChild('unpassDialog') unpassDialog!: TemplateRef<unknown>;
|
||||
@ViewChild('correctionDialog') correctionDialog!: TemplateRef<unknown>;
|
||||
private dialogRef: MatDialogRef<unknown> | null = null;
|
||||
|
||||
tab: Tab = this.mayUnpass ? 'today' : this.mayCorrect ? 'unpassed' : 'mine';
|
||||
|
||||
// Today vouchers
|
||||
search = '';
|
||||
vouchers: EligibleVoucher[] = [];
|
||||
loadingVouchers = false;
|
||||
selectedVoucher: EligibleVoucher | null = null;
|
||||
/** The account the voucher was posted to, for the balance and status shown in the popup. */
|
||||
voucherAccount: AccountLookup | null = null;
|
||||
unpassReason = '';
|
||||
sending = false;
|
||||
|
||||
// Unpassed vouchers waiting for the correct account
|
||||
unpassed: VoucherCorrection[] = [];
|
||||
loadingUnpassed = false;
|
||||
active: VoucherCorrection | null = null;
|
||||
wrongAccount: AccountLookup | null = null;
|
||||
correctAccountNo = '';
|
||||
correctAccount: AccountLookup | null = null;
|
||||
lookupError = '';
|
||||
lookingUp = false;
|
||||
|
||||
// The cashier own requests
|
||||
mine: VoucherCorrection[] = [];
|
||||
loadingMine = false;
|
||||
|
||||
readonly statusLabel: Record<string, string> = {
|
||||
UNPASS_PENDING: 'Unpass awaiting approval',
|
||||
UNPASSED: 'Unpassed — enter correct account',
|
||||
UNPASS_REJECTED: 'Unpass rejected',
|
||||
CORRECTION_PENDING: 'Correction awaiting approval',
|
||||
COMPLETED: 'Corrected',
|
||||
CLOSED: 'Closed — cashier deposits separately'
|
||||
};
|
||||
|
||||
ngOnInit(): void {
|
||||
if (this.mayUnpass) this.loadVouchers();
|
||||
if (this.mayCorrect) this.loadUnpassed();
|
||||
if (this.tab === 'mine') this.loadMine();
|
||||
}
|
||||
|
||||
setTab(tab: Tab): void {
|
||||
this.tab = tab;
|
||||
if (tab === 'today') this.loadVouchers();
|
||||
if (tab === 'unpassed') this.loadUnpassed();
|
||||
if (tab === 'mine') this.loadMine();
|
||||
}
|
||||
|
||||
// ── Today vouchers ─────────────────────────────────────────────────────────
|
||||
|
||||
loadVouchers(): void {
|
||||
this.loadingVouchers = true;
|
||||
this.service
|
||||
.eligible(this.search.trim())
|
||||
.pipe(catchError(() => of([] as EligibleVoucher[])))
|
||||
.subscribe((rows) => {
|
||||
this.vouchers = rows;
|
||||
this.loadingVouchers = false;
|
||||
if (this.selectedVoucher && !rows.some((r) => r.voucherNo === this.selectedVoucher?.voucherNo)) {
|
||||
this.selectedVoucher = null;
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
pick(v: EligibleVoucher): void {
|
||||
this.selectedVoucher = v;
|
||||
this.unpassReason = '';
|
||||
this.voucherAccount = null;
|
||||
this.service
|
||||
.accountLookup(v.accountNo)
|
||||
.pipe(catchError(() => of(null)))
|
||||
.subscribe((a) => (this.voucherAccount = a));
|
||||
this.showDialog(this.unpassDialog, () => (this.selectedVoucher = null));
|
||||
}
|
||||
|
||||
closeUnpass(): void {
|
||||
this.dialogRef?.close();
|
||||
}
|
||||
|
||||
get canUnpass(): boolean {
|
||||
return !!this.selectedVoucher && this.unpassReason.trim().length > 0 && !this.sending;
|
||||
}
|
||||
|
||||
unpass(): void {
|
||||
if (!this.selectedVoucher || !this.canUnpass) return;
|
||||
this.sending = true;
|
||||
this.service.unpass(this.selectedVoucher.voucherNo, this.unpassReason.trim()).subscribe({
|
||||
next: () => {
|
||||
this.sending = false;
|
||||
this.snackBar.open(`Voucher ${this.selectedVoucher?.voucherNo} sent for unpass approval`, 'Close', {
|
||||
duration: 4000
|
||||
});
|
||||
this.dialogRef?.close();
|
||||
this.unpassReason = '';
|
||||
this.loadVouchers();
|
||||
},
|
||||
error: (err) => {
|
||||
this.sending = false;
|
||||
this.fail(err, 'Could not unpass the voucher');
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// ── Unpassed vouchers: enter the correct account ───────────────────────────
|
||||
|
||||
loadUnpassed(): void {
|
||||
this.loadingUnpassed = true;
|
||||
this.service
|
||||
.list('UNPASSED')
|
||||
.pipe(catchError(() => of([] as VoucherCorrection[])))
|
||||
.subscribe((rows) => {
|
||||
this.unpassed = rows;
|
||||
this.loadingUnpassed = false;
|
||||
});
|
||||
}
|
||||
|
||||
open(row: VoucherCorrection): void {
|
||||
this.active = row;
|
||||
this.correctAccountNo = '';
|
||||
this.correctAccount = null;
|
||||
this.lookupError = '';
|
||||
this.wrongAccount = null;
|
||||
// The account the deposit went to: its balance is what the "can this be taken back" check reads.
|
||||
this.service
|
||||
.accountLookup(row.wrong_account_no)
|
||||
.pipe(catchError(() => of(null)))
|
||||
.subscribe((a) => (this.wrongAccount = a));
|
||||
this.showDialog(this.correctionDialog, () => (this.active = null));
|
||||
}
|
||||
|
||||
closeActive(): void {
|
||||
this.dialogRef?.close();
|
||||
}
|
||||
|
||||
onAccountInput(value: string): void {
|
||||
this.correctAccountNo = value;
|
||||
this.correctAccount = null;
|
||||
this.lookupError = '';
|
||||
// Same rule as the Transaction Entry screen: searches on its own once six characters are typed.
|
||||
if (value.trim().length >= 6) this.lookup();
|
||||
}
|
||||
|
||||
lookup(): void {
|
||||
const accountNo = this.correctAccountNo.trim();
|
||||
if (!accountNo || this.lookingUp) return;
|
||||
this.lookingUp = true;
|
||||
this.service.accountLookup(accountNo).subscribe({
|
||||
next: (a) => {
|
||||
this.lookingUp = false;
|
||||
// A late answer for a number no longer typed is dropped.
|
||||
if (accountNo !== this.correctAccountNo.trim()) return;
|
||||
this.correctAccount = a;
|
||||
if (this.active && a.id === this.active.wrong_savings_account_id) {
|
||||
this.lookupError = 'This is the account the deposit was already posted to.';
|
||||
} else if (!a.canReceive) {
|
||||
this.lookupError = 'This account is not active, so it cannot receive the deposit.';
|
||||
}
|
||||
},
|
||||
error: (err) => {
|
||||
this.lookingUp = false;
|
||||
this.lookupError = err?.error?.errors?.[0]?.defaultUserMessage ?? 'Account not found.';
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/** The wrong account still holds the deposit, so it can be taken back without overdrawing it. */
|
||||
get wrongHasBalance(): boolean {
|
||||
return !!this.wrongAccount && !!this.active && this.wrongAccount.balance >= this.active.amount;
|
||||
}
|
||||
|
||||
get correctOk(): boolean {
|
||||
return !!this.correctAccount && this.correctAccount.canReceive && !this.lookupError;
|
||||
}
|
||||
|
||||
get canSave(): boolean {
|
||||
return !!this.active && this.correctOk && this.wrongHasBalance && !this.sending;
|
||||
}
|
||||
|
||||
save(): void {
|
||||
if (!this.active || !this.canSave) return;
|
||||
this.sending = true;
|
||||
this.service.submitCorrection(this.active.id, this.correctAccountNo.trim()).subscribe({
|
||||
next: () => {
|
||||
this.sending = false;
|
||||
this.snackBar.open('Correction sent for approval', 'Close', { duration: 4000 });
|
||||
this.dialogRef?.close();
|
||||
this.loadUnpassed();
|
||||
},
|
||||
error: (err) => {
|
||||
this.sending = false;
|
||||
this.fail(err, 'Could not save the correction');
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Gives up this correction. The usual reason is that the wrong account no longer holds the money, so the cashier
|
||||
* puts the amount into the right account from their own pocket as a fresh deposit instead.
|
||||
*/
|
||||
closeRequest(): void {
|
||||
if (!this.active || this.sending) return;
|
||||
const reason = this.wrongHasBalance
|
||||
? 'Closed by the cashier'
|
||||
: 'Wrong account no longer holds the amount; cashier deposits it into the correct account separately';
|
||||
this.sending = true;
|
||||
this.service.close(this.active.id, reason).subscribe({
|
||||
next: () => {
|
||||
this.sending = false;
|
||||
this.snackBar.open('Request closed', 'Close', { duration: 4000 });
|
||||
this.dialogRef?.close();
|
||||
this.loadUnpassed();
|
||||
},
|
||||
error: (err) => {
|
||||
this.sending = false;
|
||||
this.fail(err, 'Could not close the request');
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// ── My requests ────────────────────────────────────────────────────────────
|
||||
|
||||
loadMine(): void {
|
||||
this.loadingMine = true;
|
||||
this.service
|
||||
.list(undefined, true)
|
||||
.pipe(catchError(() => of([] as VoucherCorrection[])))
|
||||
.subscribe((rows) => {
|
||||
this.mine = rows;
|
||||
this.loadingMine = false;
|
||||
});
|
||||
}
|
||||
|
||||
/** Opens one of the two popups; onClose runs however it is dismissed (button, backdrop or Esc). */
|
||||
private showDialog(template: TemplateRef<unknown>, onClose: () => void): void {
|
||||
this.dialogRef?.close();
|
||||
this.dialogRef = this.dialog.open(template, { width: '600px', maxWidth: '95vw', autoFocus: false });
|
||||
this.dialogRef.afterClosed().subscribe(onClose);
|
||||
}
|
||||
|
||||
private fail(err: any, fallback: string): void {
|
||||
const msg = err?.error?.errors?.[0]?.defaultUserMessage ?? err?.error?.defaultUserMessage ?? fallback;
|
||||
this.snackBar.open(msg, 'Close', { duration: 6000 });
|
||||
}
|
||||
}
|
||||
@@ -169,6 +169,7 @@
|
||||
<th>Debit</th>
|
||||
<th>Credit</th>
|
||||
<th class="num">Amount</th>
|
||||
<th>Correction</th>
|
||||
<th>User</th>
|
||||
<!-- Two dates, not one, on the system register.
|
||||
A voucher's timestamp is the minute it was written; the posting behind it carries
|
||||
@@ -217,6 +218,18 @@
|
||||
</td>
|
||||
}
|
||||
<td class="num">{{ row.amount | number: '1.2-2' }}</td>
|
||||
<!-- Where the voucher stands after a correction: unpassed, revoked, closed or corrected. -->
|
||||
<td>
|
||||
<mifosx-voucher-status
|
||||
[status]="row.voucherStatus"
|
||||
[revokedBy]="row.revokedByVoucherNo"
|
||||
[replaces]="row.replacesVoucherNo"
|
||||
[note]="row.correctionNote"
|
||||
></mifosx-voucher-status>
|
||||
@if (!row.voucherStatus || row.voucherStatus === 'ACTIVE') {
|
||||
<span class="vr-active">—</span>
|
||||
}
|
||||
</td>
|
||||
<td>{{ row.username || '—' }}</td>
|
||||
@if (systemOnly) {
|
||||
<td class="vr-ledgerdate" [class.vr-ledgerdate--prior]="row.postedAfterClose">
|
||||
|
||||
@@ -92,3 +92,8 @@
|
||||
text-transform: uppercase;
|
||||
color: #7c3aed;
|
||||
}
|
||||
|
||||
.vr-active {
|
||||
font-size: 11px;
|
||||
color: #94a3b8;
|
||||
}
|
||||
|
||||
@@ -14,6 +14,7 @@ import { MatIconModule } from '@angular/material/icon';
|
||||
import { catchError, of } from 'rxjs';
|
||||
import { CashierService } from '../cashier.service';
|
||||
import { businessToday } from 'app/core/utils/business-today';
|
||||
import { VoucherStatusComponent, VoucherStatus } from '../voucher-status/voucher-status.component';
|
||||
|
||||
/**
|
||||
* Which side of the register a screen shows.
|
||||
@@ -26,6 +27,11 @@ export type VoucherSide = 'CR' | 'DR' | 'ALL';
|
||||
/** One voucher as the register shows it: the number, the two sides, and who raised it. */
|
||||
interface VoucherRow {
|
||||
voucherNo: string;
|
||||
/** Where the voucher stands after a correction: active, revoked, under correction, or corrected. */
|
||||
voucherStatus?: VoucherStatus;
|
||||
revokedByVoucherNo?: string | null;
|
||||
replacesVoucherNo?: string | null;
|
||||
correctionNote?: string | null;
|
||||
voucherType: string | null;
|
||||
amount: number | null;
|
||||
createdAt: string | null;
|
||||
@@ -82,7 +88,8 @@ interface VoucherReport {
|
||||
imports: [
|
||||
CommonModule,
|
||||
FormsModule,
|
||||
MatIconModule
|
||||
MatIconModule,
|
||||
VoucherStatusComponent
|
||||
]
|
||||
})
|
||||
export class VoucherReportComponent implements OnInit {
|
||||
|
||||
@@ -0,0 +1,137 @@
|
||||
/**
|
||||
* Copyright since 2025 Mifos Initiative
|
||||
*
|
||||
* This Source Code Form is subject to the terms of the Mozilla Public
|
||||
* License, v. 2.0. If a copy of the MPL was not distributed with this
|
||||
* file, You can obtain one at http://mozilla.org/MPL/2.0/.
|
||||
*/
|
||||
|
||||
import { Component, Input } from '@angular/core';
|
||||
import { CommonModule } from '@angular/common';
|
||||
|
||||
/** Where a voucher stands after a voucher correction. */
|
||||
export type VoucherStatus =
|
||||
| 'ACTIVE'
|
||||
| 'REVOKED'
|
||||
| 'UNPASS_PENDING'
|
||||
| 'UNPASSED'
|
||||
| 'CORRECTION_PENDING'
|
||||
| 'CORRECTED'
|
||||
| 'CLOSED'
|
||||
| null
|
||||
| undefined;
|
||||
|
||||
/**
|
||||
* A small badge saying whether a voucher was revoked, is under correction, or replaced another one.
|
||||
* Nothing is shown for an ordinary (active) voucher, so the registers read as before.
|
||||
*/
|
||||
@Component({
|
||||
selector: 'mifosx-voucher-status',
|
||||
standalone: true,
|
||||
imports: [CommonModule],
|
||||
template: `
|
||||
@if (label) {
|
||||
<span class="vs" [class]="'vs vs--' + status" [title]="hint">{{ label }}</span>
|
||||
@if (note) {
|
||||
<div class="vs-note">{{ note }}</div>
|
||||
}
|
||||
}
|
||||
`,
|
||||
styles: [
|
||||
`
|
||||
.vs {
|
||||
display: inline-block;
|
||||
margin-left: 6px;
|
||||
padding: 1px 8px;
|
||||
border-radius: 999px;
|
||||
background: #e2e8f0;
|
||||
color: #334155;
|
||||
font-size: 10px;
|
||||
font-weight: 700;
|
||||
white-space: nowrap;
|
||||
vertical-align: middle;
|
||||
}
|
||||
|
||||
.vs-note {
|
||||
margin-top: 2px;
|
||||
max-width: 280px;
|
||||
font-size: 11px;
|
||||
line-height: 1.3;
|
||||
color: #64748b;
|
||||
}
|
||||
|
||||
.vs--REVOKED {
|
||||
background: #fee2e2;
|
||||
color: #991b1b;
|
||||
}
|
||||
|
||||
.vs--UNPASS_PENDING,
|
||||
.vs--CORRECTION_PENDING {
|
||||
background: #fef3c7;
|
||||
color: #92400e;
|
||||
}
|
||||
|
||||
.vs--UNPASSED {
|
||||
background: #dbeafe;
|
||||
color: #1e40af;
|
||||
}
|
||||
|
||||
.vs--CLOSED {
|
||||
background: #f1f5f9;
|
||||
color: #475569;
|
||||
}
|
||||
|
||||
.vs--CORRECTED {
|
||||
background: #d1fae5;
|
||||
color: #065f46;
|
||||
}
|
||||
`
|
||||
]
|
||||
})
|
||||
export class VoucherStatusComponent {
|
||||
@Input() status: VoucherStatus;
|
||||
/** The voucher that replaced this one, when it is revoked. */
|
||||
@Input() revokedBy: string | null | undefined;
|
||||
/** The voucher this one replaced, when it is the corrected one. */
|
||||
@Input() replaces: string | null | undefined;
|
||||
/** Why: the reason the voucher was unpassed, or why the correction was closed. */
|
||||
@Input() note: string | null | undefined;
|
||||
|
||||
get label(): string {
|
||||
switch (this.status) {
|
||||
case 'REVOKED':
|
||||
return this.revokedBy ? `Revoked → ${this.revokedBy}` : 'Revoked';
|
||||
case 'UNPASS_PENDING':
|
||||
return 'Unpass pending';
|
||||
case 'UNPASSED':
|
||||
return 'Unpassed';
|
||||
case 'CORRECTION_PENDING':
|
||||
return 'Correction pending';
|
||||
case 'CLOSED':
|
||||
return 'Closed — cashier deposits separately';
|
||||
case 'CORRECTED':
|
||||
return this.replaces ? `Corrected (replaces ${this.replaces})` : 'Corrected';
|
||||
default:
|
||||
return '';
|
||||
}
|
||||
}
|
||||
|
||||
get hint(): string {
|
||||
switch (this.status) {
|
||||
case 'REVOKED':
|
||||
return 'This voucher was revoked: its deposit was moved to another account and a new voucher replaced it.';
|
||||
case 'UNPASS_PENDING':
|
||||
return 'A manager has not yet approved unpassing this voucher.';
|
||||
case 'UNPASSED':
|
||||
return 'Unpassed: waiting for the cashier to enter the correct account.';
|
||||
case 'CORRECTION_PENDING':
|
||||
return 'The correct account is entered; waiting for a manager to approve.';
|
||||
case 'CLOSED':
|
||||
return 'The correction was closed by the cashier; the voucher stays as it was.';
|
||||
case 'CORRECTED':
|
||||
return 'This voucher was raised by a correction and replaces the earlier one.';
|
||||
default:
|
||||
return '';
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -102,6 +102,27 @@
|
||||
</div>
|
||||
}
|
||||
|
||||
<!-- Money moving between branches: the approver must see it before approving -->
|
||||
@if (isInterBranch) {
|
||||
<div class="dri-interbranch">
|
||||
<mat-icon>alt_route</mat-icon>
|
||||
<div>
|
||||
<div class="dri-interbranch__title">Inter-branch transfer</div>
|
||||
<div class="dri-interbranch__text">
|
||||
@if (branchNames(drEntries) && branchNames(crEntries)) {
|
||||
Money moves from <strong>{{ branchNames(drEntries) }}</strong> to
|
||||
<strong>{{ branchNames(crEntries) }}</strong
|
||||
>.
|
||||
} @else {
|
||||
These accounts are held at different branches: <strong>{{ branchNames(creditEntries) }}</strong
|
||||
>.
|
||||
}
|
||||
Each account is posted in its own branch's books. Check the accounts before approving.
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
|
||||
<!-- Bank Transfer summary card (replaces GL card) -->
|
||||
@if (isBankTransferMode) {
|
||||
<div class="dri-card">
|
||||
@@ -154,6 +175,7 @@
|
||||
<th>Account No</th>
|
||||
<th>Account Name</th>
|
||||
<th>Type</th>
|
||||
<th>Branch</th>
|
||||
<th class="num">{{ isCollectionMode ? 'Amount (DR)' : 'Amount (CR)' }}</th>
|
||||
</tr>
|
||||
</thead>
|
||||
@@ -166,11 +188,16 @@
|
||||
<td>
|
||||
<span class="dri-type-badge">{{ accountTypeLabel(entry.accountType) }}</span>
|
||||
</td>
|
||||
<td>
|
||||
<span class="dri-branch-badge" [class.dri-branch-badge--cross]="isInterBranch">{{
|
||||
entry.officeName || '—'
|
||||
}}</span>
|
||||
</td>
|
||||
<td class="num dri-cr-amount">₹{{ entry.amount | number: '1.2-2' }}</td>
|
||||
</tr>
|
||||
}
|
||||
<tr class="dri-cr-total-row">
|
||||
<td colspan="4" class="dri-cr-total-label">{{ isCollectionMode ? 'Total Debit' : 'Total Credit' }}</td>
|
||||
<td colspan="5" class="dri-cr-total-label">{{ isCollectionMode ? 'Total Debit' : 'Total Credit' }}</td>
|
||||
<td class="num dri-cr-total-amount">₹{{ totalCreditAmount() | number: '1.2-2' }}</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
@@ -192,6 +219,7 @@
|
||||
<th>Account No</th>
|
||||
<th>Account Name</th>
|
||||
<th>Type</th>
|
||||
<th>Branch</th>
|
||||
<th class="num">Amount (DR)</th>
|
||||
</tr>
|
||||
</thead>
|
||||
@@ -204,11 +232,16 @@
|
||||
<td>
|
||||
<span class="dri-type-badge">{{ accountTypeLabel(entry.accountType) }}</span>
|
||||
</td>
|
||||
<td>
|
||||
<span class="dri-branch-badge" [class.dri-branch-badge--cross]="isInterBranch">{{
|
||||
entry.officeName || '—'
|
||||
}}</span>
|
||||
</td>
|
||||
<td class="num dri-cr-amount">₹{{ entry.amount | number: '1.2-2' }}</td>
|
||||
</tr>
|
||||
}
|
||||
<tr class="dri-cr-total-row">
|
||||
<td colspan="4" class="dri-cr-total-label">Total Debit</td>
|
||||
<td colspan="5" class="dri-cr-total-label">Total Debit</td>
|
||||
<td class="num dri-cr-total-amount">₹{{ totalDebitAmount() | number: '1.2-2' }}</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
@@ -228,6 +261,7 @@
|
||||
<th>Account No</th>
|
||||
<th>Account Name</th>
|
||||
<th>Type</th>
|
||||
<th>Branch</th>
|
||||
<th class="num">Amount (CR)</th>
|
||||
</tr>
|
||||
</thead>
|
||||
@@ -240,11 +274,16 @@
|
||||
<td>
|
||||
<span class="dri-type-badge">{{ accountTypeLabel(entry.accountType) }}</span>
|
||||
</td>
|
||||
<td>
|
||||
<span class="dri-branch-badge" [class.dri-branch-badge--cross]="isInterBranch">{{
|
||||
entry.officeName || '—'
|
||||
}}</span>
|
||||
</td>
|
||||
<td class="num dri-cr-amount">₹{{ entry.amount | number: '1.2-2' }}</td>
|
||||
</tr>
|
||||
}
|
||||
<tr class="dri-cr-total-row">
|
||||
<td colspan="4" class="dri-cr-total-label">Total Credit</td>
|
||||
<td colspan="5" class="dri-cr-total-label">Total Credit</td>
|
||||
<td class="num dri-cr-total-amount">₹{{ totalCreditAmount() | number: '1.2-2' }}</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
|
||||
@@ -534,3 +534,54 @@ $shadow-md: 0 4px 16px rgb(0 0 0 / 9%);
|
||||
font-family: 'Courier New', monospace;
|
||||
letter-spacing: 0.02em;
|
||||
}
|
||||
|
||||
/* A leg's branch. Amber on every leg when the request moves money between branches, neutral otherwise. */
|
||||
.dri-branch-badge {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
padding: 3px 10px;
|
||||
border-radius: 20px;
|
||||
background: #f1f5f9;
|
||||
border: 1px solid #cbd5e1;
|
||||
font-size: 11px;
|
||||
font-weight: 700;
|
||||
color: #475569;
|
||||
|
||||
&--cross {
|
||||
background: #fffbeb;
|
||||
border-color: #fcd34d;
|
||||
color: #92400e;
|
||||
}
|
||||
}
|
||||
|
||||
/* Shown above the summary when the accounts are at different branches. */
|
||||
.dri-interbranch {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
gap: 12px;
|
||||
margin: 0 0 12px;
|
||||
padding: 12px 16px;
|
||||
border: 1px solid #fcd34d;
|
||||
border-left: 4px solid #f59e0b;
|
||||
border-radius: 8px;
|
||||
background: #fffbeb;
|
||||
color: #78350f;
|
||||
|
||||
mat-icon {
|
||||
flex-shrink: 0;
|
||||
color: #d97706;
|
||||
}
|
||||
|
||||
&__title {
|
||||
font-size: 13px;
|
||||
font-weight: 800;
|
||||
letter-spacing: 0.02em;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
&__text {
|
||||
margin-top: 2px;
|
||||
font-size: 13px;
|
||||
line-height: 1.5;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -25,6 +25,9 @@ interface CreditEntry {
|
||||
accountType: string;
|
||||
amount: number;
|
||||
dbCr?: string;
|
||||
/** The branch the account is held at; absent for a GL leg or when the server could not resolve it. */
|
||||
officeId?: number | null;
|
||||
officeName?: string | null;
|
||||
}
|
||||
|
||||
@Component({
|
||||
@@ -167,6 +170,19 @@ export class DisbursementReviewComponent implements OnInit {
|
||||
return this.creditEntries.filter((e) => e.dbCr !== 'Dr');
|
||||
}
|
||||
|
||||
/**
|
||||
* True when the accounts in this request are held at more than one branch, so money moves between branches. The
|
||||
* approver has to see that before approving: it is booked in each branch's own books.
|
||||
*/
|
||||
get isInterBranch(): boolean {
|
||||
return new Set(this.creditEntries.filter((e) => e.officeId != null).map((e) => e.officeId)).size > 1;
|
||||
}
|
||||
|
||||
/** Distinct branch names among the given legs, in order, e.g. for "Head Office" or "Head Office, bhopal". */
|
||||
branchNames(entries: CreditEntry[]): string {
|
||||
return [...new Set(entries.map((e) => e.officeName).filter((name): name is string => !!name))].join(', ');
|
||||
}
|
||||
|
||||
totalCreditAmount(): number {
|
||||
const entries = this.isCollectionMode ? this.drEntries : this.crEntries;
|
||||
return entries.reduce((sum, e) => sum + (e.amount ?? 0), 0);
|
||||
|
||||
+11
-1
@@ -930,6 +930,10 @@
|
||||
createClientForm.get('mobileNo')?.hasError('pattern') && createClientForm.get('mobileNo')?.touched
|
||||
) {
|
||||
<mat-error>Enter valid 10-digit number (starts with 6-9)</mat-error>
|
||||
} @else if (createClientForm.get('mobileNo')?.hasError('contactTaken')) {
|
||||
<mat-error
|
||||
>This mobile number already belongs to another customer. Please enter a different number.</mat-error
|
||||
>
|
||||
}
|
||||
</mat-form-field>
|
||||
|
||||
@@ -940,7 +944,11 @@
|
||||
color="primary"
|
||||
type="button"
|
||||
class="verify-btn"
|
||||
[disabled]="readOnly || createClientForm.get('mobileNo')?.value?.length !== 10"
|
||||
[disabled]="
|
||||
readOnly ||
|
||||
createClientForm.get('mobileNo')?.value?.length !== 10 ||
|
||||
createClientForm.get('mobileNo')?.hasError('contactTaken')
|
||||
"
|
||||
(click)="verifyWithDIP()"
|
||||
>
|
||||
<fa-icon icon="shield-alt" class="m-r-10"></fa-icon>
|
||||
@@ -1008,6 +1016,8 @@
|
||||
createClientForm.get('emailAddress')?.hasError('required') && createClientForm.get('emailAddress')?.touched
|
||||
) {
|
||||
<mat-error>Email is <strong>required</strong></mat-error>
|
||||
} @else if (createClientForm.get('emailAddress')?.hasError('contactTaken')) {
|
||||
<mat-error>This email already belongs to another customer. Please enter a different email.</mat-error>
|
||||
} @else if (createClientForm.get('emailAddress')?.invalid && createClientForm.get('emailAddress')?.touched) {
|
||||
<mat-error>Enter a valid email (e.g. name@example.com)</mat-error>
|
||||
}
|
||||
|
||||
+47
-10
@@ -27,15 +27,16 @@ import {
|
||||
UntypedFormControl,
|
||||
ReactiveFormsModule,
|
||||
ValidatorFn,
|
||||
AsyncValidatorFn,
|
||||
AbstractControl,
|
||||
ValidationErrors
|
||||
} from '@angular/forms';
|
||||
import { Subject, Subscription, merge } from 'rxjs';
|
||||
import { filter, switchMap, takeUntil } from 'rxjs/operators';
|
||||
import { Subject, Subscription, merge, of, timer } from 'rxjs';
|
||||
import { catchError, filter, map, switchMap, takeUntil } from 'rxjs/operators';
|
||||
import { ClientsService } from 'app/clients/clients.service';
|
||||
import { Dates } from 'app/core/utils/dates';
|
||||
import { LegalFormId } from 'app/clients/models/legal-form.enum';
|
||||
import { toProperNameCase } from 'app/clients/utils/name-case.util';
|
||||
import { toPersonNameCase, toProperNameCase } from 'app/clients/utils/name-case.util';
|
||||
import { ExternalNationalIdService } from 'app/clients/services/external-national-id.service';
|
||||
|
||||
/** Custom Services */
|
||||
@@ -936,6 +937,29 @@ export class ClientGeneralStepComponent implements OnInit, OnDestroy, OnChanges
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* A mobile number or email that already belongs to another customer. This is a different person using a contact
|
||||
* that is taken (a PAN or Aadhaar match is what means the same customer), so the answer is to enter another one.
|
||||
* Runs only once the format validators pass, and a new keystroke cancels the previous call, which is what
|
||||
* debounces it. The client being edited is excluded so its own number is not flagged.
|
||||
*/
|
||||
private contactTakenValidator(field: 'mobileNo' | 'emailAddress'): AsyncValidatorFn {
|
||||
return (control: AbstractControl) => {
|
||||
const value = (control.value ?? '').toString().trim();
|
||||
if (!value) {
|
||||
return of(null);
|
||||
}
|
||||
return timer(400).pipe(
|
||||
switchMap(() =>
|
||||
this.clientService.lookupClientIdentity({ [field]: value, excludeClientId: this.clientData?.id ?? null })
|
||||
),
|
||||
map((result) => (result?.contactConflicts?.[field] ? { contactTaken: true } : null)),
|
||||
// A failed lookup must not block the form: the server still refuses a duplicate when the client is saved.
|
||||
catchError(() => of(null))
|
||||
);
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates the client form.
|
||||
*/
|
||||
@@ -969,7 +993,8 @@ export class ClientGeneralStepComponent implements OnInit, OnDestroy, OnChanges
|
||||
Validators.required,
|
||||
Validators.pattern(/^[6-9]\d{9}$/),
|
||||
Validators.maxLength(10)
|
||||
]
|
||||
],
|
||||
this.contactTakenValidator('mobileNo')
|
||||
],
|
||||
alternateMobileNo: [
|
||||
'',
|
||||
@@ -986,7 +1011,8 @@ export class ClientGeneralStepComponent implements OnInit, OnDestroy, OnChanges
|
||||
Validators.email,
|
||||
Validators.pattern(/^[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\.[a-zA-Z]{2,}$/),
|
||||
Validators.maxLength(100)
|
||||
]
|
||||
],
|
||||
this.contactTakenValidator('emailAddress')
|
||||
],
|
||||
dateOfBirth: [
|
||||
'',
|
||||
@@ -1456,7 +1482,10 @@ export class ClientGeneralStepComponent implements OnInit, OnDestroy, OnChanges
|
||||
const control = this.createClientForm.get(controlName);
|
||||
if (control && control.value) {
|
||||
const originalValue = control.value;
|
||||
const capitalized = toProperNameCase(originalValue);
|
||||
// An entity's full name keeps its typed spelling (matched against the PAN name); a person's
|
||||
// name parts are title-cased, the same as the Full Name line shows them.
|
||||
const capitalized =
|
||||
controlName === 'fullname' ? toProperNameCase(originalValue) : toPersonNameCase(originalValue);
|
||||
if (originalValue !== capitalized) {
|
||||
control.setValue(capitalized);
|
||||
}
|
||||
@@ -1925,12 +1954,11 @@ export class ClientGeneralStepComponent implements OnInit, OnDestroy, OnChanges
|
||||
|
||||
/** Returns capitalized full name preview from first + middle + last */
|
||||
get fullNamePreview(): string {
|
||||
const capitalize = (s: string) => (s ? s.charAt(0).toUpperCase() + s.slice(1).toLowerCase() : '');
|
||||
const salutationId = this.createClientForm?.get('salutationId')?.value;
|
||||
const salutation = this.salutationOptions?.find((s: any) => s.id === salutationId)?.name || '';
|
||||
const first = capitalize(this.createClientForm?.get('firstname')?.value || '');
|
||||
const middle = capitalize(this.createClientForm?.get('middlename')?.value || '');
|
||||
const last = capitalize(this.createClientForm?.get('lastname')?.value || '');
|
||||
const first = toPersonNameCase(this.createClientForm?.get('firstname')?.value);
|
||||
const middle = toPersonNameCase(this.createClientForm?.get('middlename')?.value);
|
||||
const last = toPersonNameCase(this.createClientForm?.get('lastname')?.value);
|
||||
const full = [
|
||||
first,
|
||||
middle,
|
||||
@@ -2360,6 +2388,15 @@ export class ClientGeneralStepComponent implements OnInit, OnDestroy, OnChanges
|
||||
if (generalDetails.fullname) {
|
||||
generalDetails.fullname = toProperNameCase(generalDetails.fullname);
|
||||
}
|
||||
for (const part of [
|
||||
'firstname',
|
||||
'middlename',
|
||||
'lastname'
|
||||
]) {
|
||||
if (generalDetails[part]) {
|
||||
generalDetails[part] = toPersonNameCase(generalDetails[part]);
|
||||
}
|
||||
}
|
||||
|
||||
if (generalDetails.submittedOnDate instanceof Date) {
|
||||
generalDetails.submittedOnDate = this.dateUtils.formatDate(generalDetails.submittedOnDate, dateFormat);
|
||||
|
||||
+211
@@ -0,0 +1,211 @@
|
||||
<!--
|
||||
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/.
|
||||
-->
|
||||
|
||||
<section class="identity-check" [class.identity-check--blocking]="status === 'matched' || status === 'conflict'">
|
||||
<header class="identity-check__header">
|
||||
<mat-icon class="identity-check__icon">fingerprint</mat-icon>
|
||||
<div>
|
||||
<h3 class="identity-check__title">Existing customer check</h3>
|
||||
<p class="identity-check__hint">
|
||||
@if (isEntity) {
|
||||
Enter the company's GSTIN or PAN. If either is already on file in any branch, it is the same customer and a
|
||||
new one is not created.
|
||||
} @else {
|
||||
Enter PAN or Aadhaar. If either is already on file in any branch, it is the same customer and a new one is not
|
||||
created. Nothing found? What you enter here is filled into the KYC step for you.
|
||||
}
|
||||
</p>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
@if (form) {
|
||||
<form [formGroup]="form" class="identity-check__fields">
|
||||
@if (isEntity) {
|
||||
<mat-form-field appearance="outline" class="identity-check__field">
|
||||
<mat-label>GSTIN</mat-label>
|
||||
<input
|
||||
matInput
|
||||
formControlName="gstin"
|
||||
class="identity-check__upper"
|
||||
maxlength="15"
|
||||
mifosxRestrictInput="gstin"
|
||||
[errorStateMatcher]="errorMatcher"
|
||||
placeholder="e.g. 22AAAAA0000A1Z5"
|
||||
(input)="capitalise('gstin', $event)"
|
||||
/>
|
||||
@if (form.get('gstin')?.hasError('pattern')) {
|
||||
<mat-error>Invalid GSTIN. It has 15 characters, like 22AAAAA0000A1Z5</mat-error>
|
||||
}
|
||||
</mat-form-field>
|
||||
}
|
||||
|
||||
<mat-form-field appearance="outline" class="identity-check__field">
|
||||
<mat-label>{{ isEntity ? 'Company PAN' : 'PAN' }}</mat-label>
|
||||
<input
|
||||
matInput
|
||||
formControlName="pan"
|
||||
class="identity-check__upper"
|
||||
maxlength="10"
|
||||
mifosxRestrictInput="pan"
|
||||
[errorStateMatcher]="errorMatcher"
|
||||
placeholder="e.g. ABCDE1234F"
|
||||
(input)="capitalise('pan', $event)"
|
||||
/>
|
||||
@if (form.get('pan')?.hasError('pattern')) {
|
||||
<mat-error>Invalid PAN. Use 5 letters, 4 digits, 1 letter, like ABCDE1234F</mat-error>
|
||||
}
|
||||
</mat-form-field>
|
||||
|
||||
@if (!isEntity) {
|
||||
<mat-form-field appearance="outline" class="identity-check__field">
|
||||
<mat-label>Aadhaar</mat-label>
|
||||
<input
|
||||
matInput
|
||||
formControlName="aadhaar"
|
||||
maxlength="12"
|
||||
inputmode="numeric"
|
||||
mifosxRestrictInput="digits"
|
||||
[errorStateMatcher]="errorMatcher"
|
||||
placeholder="12 digits"
|
||||
/>
|
||||
@if (form.get('aadhaar')?.hasError('pattern')) {
|
||||
<mat-error>Invalid Aadhaar. It must be exactly 12 digits</mat-error>
|
||||
} @else {
|
||||
<mat-hint>Only a fingerprint of this number is sent, never the number</mat-hint>
|
||||
}
|
||||
</mat-form-field>
|
||||
}
|
||||
</form>
|
||||
}
|
||||
|
||||
@switch (status) {
|
||||
@case ('checking') {
|
||||
<div class="identity-check__status identity-check__status--info">
|
||||
<mat-progress-spinner mode="indeterminate" diameter="18"></mat-progress-spinner>
|
||||
Checking all branches…
|
||||
</div>
|
||||
}
|
||||
@case ('not-found') {
|
||||
<div class="identity-check__status identity-check__status--ok">
|
||||
<mat-icon>check_circle</mat-icon>
|
||||
<span>
|
||||
No existing customer found. You can continue; these details will be filled into the KYC step so you do not
|
||||
type them again.
|
||||
</span>
|
||||
</div>
|
||||
}
|
||||
@case ('error') {
|
||||
<div class="identity-check__status identity-check__status--warn">
|
||||
<mat-icon>warning</mat-icon>
|
||||
<span>
|
||||
Could not check right now. You can continue; the PAN, Aadhaar and GSTIN are checked again when the KYC is
|
||||
saved.
|
||||
</span>
|
||||
<button mat-button type="button" (click)="recheck()">Check again</button>
|
||||
</div>
|
||||
}
|
||||
@case ('conflict') {
|
||||
<div class="identity-check__status identity-check__status--error">
|
||||
<mat-icon>report</mat-icon>
|
||||
<span>
|
||||
These details belong to <strong>different customers</strong>. Check what was typed. If they are correct, ask a
|
||||
manager to review before continuing.
|
||||
</span>
|
||||
</div>
|
||||
@for (m of matches; track m.clientId) {
|
||||
<div class="identity-check__card">
|
||||
<strong>{{ m.displayName }}</strong>
|
||||
<span class="identity-check__meta">CIF {{ m.cifNumber }} · {{ m.officeName }}</span>
|
||||
<span class="identity-check__chips">
|
||||
@for (by of m.matchedBy; track by) {
|
||||
<span class="identity-check__chip">{{ matchedByLabel(by) }} matches</span>
|
||||
}
|
||||
</span>
|
||||
</div>
|
||||
}
|
||||
}
|
||||
@case ('matched') {
|
||||
@if (match; as m) {
|
||||
<div class="identity-check__status identity-check__status--error">
|
||||
<mat-icon>person_search</mat-icon>
|
||||
<span> This is an <strong>existing customer</strong>. A new client is not created for the same person. </span>
|
||||
</div>
|
||||
<div class="identity-check__card">
|
||||
<div class="identity-check__card-head">
|
||||
<strong>{{ m.displayName }}</strong>
|
||||
<span class="identity-check__chips">
|
||||
@for (by of m.matchedBy; track by) {
|
||||
<span class="identity-check__chip">{{ matchedByLabel(by) }} matches</span>
|
||||
}
|
||||
</span>
|
||||
</div>
|
||||
<dl class="identity-check__facts">
|
||||
<div>
|
||||
<dt>CIF</dt>
|
||||
<dd>{{ m.cifNumber }}</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt>Home branch</dt>
|
||||
<dd>{{ m.officeName }}</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt>Type</dt>
|
||||
<dd>{{ legalFormLabel(m) }}</dd>
|
||||
</div>
|
||||
@if (m.panMasked) {
|
||||
<div>
|
||||
<dt>PAN</dt>
|
||||
<dd>{{ m.panMasked }}</dd>
|
||||
</div>
|
||||
}
|
||||
@if (m.aadhaarMasked) {
|
||||
<div>
|
||||
<dt>Aadhaar</dt>
|
||||
<dd>{{ m.aadhaarMasked }}</dd>
|
||||
</div>
|
||||
}
|
||||
@if (m.gstinMasked) {
|
||||
<div>
|
||||
<dt>GSTIN</dt>
|
||||
<dd>{{ m.gstinMasked }}</dd>
|
||||
</div>
|
||||
}
|
||||
</dl>
|
||||
|
||||
@if (m.visibleToCaller) {
|
||||
<button
|
||||
mat-flat-button
|
||||
color="primary"
|
||||
type="button"
|
||||
class="identity-check__action"
|
||||
[routerLink]="['/clients', m.clientId, 'general']"
|
||||
>
|
||||
Open this customer
|
||||
</button>
|
||||
} @else {
|
||||
<p class="identity-check__note">
|
||||
This customer is registered at <strong>{{ m.officeName }}</strong
|
||||
>. They stay a single customer (one CIF). Open their page, then choose which account to open for them at
|
||||
this branch. They are listed under this branch once an account is opened here.
|
||||
</p>
|
||||
<button
|
||||
mat-flat-button
|
||||
color="primary"
|
||||
type="button"
|
||||
class="identity-check__action"
|
||||
[disabled]="linking"
|
||||
(click)="openAccountHere(m)"
|
||||
>
|
||||
{{ linking ? 'Please wait…' : 'Open account for this customer' }}
|
||||
</button>
|
||||
}
|
||||
</div>
|
||||
}
|
||||
}
|
||||
}
|
||||
</section>
|
||||
+157
@@ -0,0 +1,157 @@
|
||||
/**
|
||||
* 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/.
|
||||
*/
|
||||
|
||||
:host {
|
||||
display: block;
|
||||
}
|
||||
|
||||
.identity-check {
|
||||
margin-bottom: 16px;
|
||||
padding: 16px 20px;
|
||||
background: #f9fafb;
|
||||
border: 1px solid #e5e7eb;
|
||||
border-radius: 12px;
|
||||
|
||||
&--blocking {
|
||||
border-color: #fecaca;
|
||||
background: #fffafa;
|
||||
}
|
||||
|
||||
&__header {
|
||||
display: flex;
|
||||
gap: 12px;
|
||||
align-items: flex-start;
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
|
||||
&__icon {
|
||||
flex: none;
|
||||
color: var(--md-sys-color-primary, #1074b9);
|
||||
}
|
||||
|
||||
&__title {
|
||||
margin: 0;
|
||||
font-size: 15px;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
&__hint {
|
||||
margin: 2px 0 0;
|
||||
font-size: 13px;
|
||||
color: #6b7280;
|
||||
}
|
||||
|
||||
// PAN and GSTIN read in capitals even before the value is rewritten.
|
||||
&__upper {
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
&__fields {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 0 16px;
|
||||
}
|
||||
|
||||
&__field {
|
||||
flex: 1 1 220px;
|
||||
min-width: 200px;
|
||||
}
|
||||
|
||||
&__status {
|
||||
display: flex;
|
||||
gap: 10px;
|
||||
align-items: center;
|
||||
padding: 10px 12px;
|
||||
font-size: 13px;
|
||||
border-radius: 8px;
|
||||
|
||||
&--info {
|
||||
background: #eff6ff;
|
||||
color: #1e40af;
|
||||
}
|
||||
|
||||
&--ok {
|
||||
background: #ecfdf5;
|
||||
color: #047857;
|
||||
}
|
||||
|
||||
&--warn {
|
||||
background: #fffbeb;
|
||||
color: #92400e;
|
||||
}
|
||||
|
||||
&--error {
|
||||
background: #fef2f2;
|
||||
color: #991b1b;
|
||||
}
|
||||
}
|
||||
|
||||
&__card {
|
||||
margin-top: 10px;
|
||||
padding: 12px 14px;
|
||||
background: #fff;
|
||||
border: 1px solid #fecaca;
|
||||
border-radius: 10px;
|
||||
}
|
||||
|
||||
&__card-head {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 8px 12px;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
}
|
||||
|
||||
&__meta {
|
||||
margin-left: 8px;
|
||||
font-size: 13px;
|
||||
color: #6b7280;
|
||||
}
|
||||
|
||||
&__chips {
|
||||
display: inline-flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
&__chip {
|
||||
padding: 2px 8px;
|
||||
font-size: 12px;
|
||||
color: #991b1b;
|
||||
background: #fee2e2;
|
||||
border-radius: 999px;
|
||||
}
|
||||
|
||||
&__facts {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fit, minmax(140px, 1fr));
|
||||
gap: 8px 16px;
|
||||
margin: 10px 0 0;
|
||||
|
||||
dt {
|
||||
font-size: 12px;
|
||||
color: #6b7280;
|
||||
}
|
||||
|
||||
dd {
|
||||
margin: 0;
|
||||
font-size: 14px;
|
||||
font-weight: 500;
|
||||
}
|
||||
}
|
||||
|
||||
&__action {
|
||||
margin-top: 12px;
|
||||
}
|
||||
|
||||
&__note {
|
||||
margin: 12px 0 0;
|
||||
font-size: 13px;
|
||||
color: #374151;
|
||||
}
|
||||
}
|
||||
+308
@@ -0,0 +1,308 @@
|
||||
/**
|
||||
* 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,
|
||||
EventEmitter,
|
||||
Input,
|
||||
OnChanges,
|
||||
OnDestroy,
|
||||
OnInit,
|
||||
Output,
|
||||
SimpleChanges,
|
||||
inject
|
||||
} from '@angular/core';
|
||||
import {
|
||||
AbstractControl,
|
||||
FormGroupDirective,
|
||||
NgForm,
|
||||
UntypedFormBuilder,
|
||||
UntypedFormGroup,
|
||||
Validators
|
||||
} from '@angular/forms';
|
||||
import { ErrorStateMatcher } from '@angular/material/core';
|
||||
import { Router } from '@angular/router';
|
||||
import { MatIcon } from '@angular/material/icon';
|
||||
import { MatProgressSpinner } from '@angular/material/progress-spinner';
|
||||
import { Observable, Subject, merge, of } from 'rxjs';
|
||||
import { catchError, debounceTime, map, switchMap, takeUntil, tap } from 'rxjs/operators';
|
||||
import { STANDALONE_SHARED_IMPORTS } from 'app/standalone-shared.module';
|
||||
import { AuthenticationService } from 'app/core/authentication/authentication.service';
|
||||
import { RestrictInputDirective } from '../../../directives/restrict-input.directive';
|
||||
import { ClientsService } from '../../clients.service';
|
||||
import { ClientIdentityLookupResult, ClientIdentityMatch } from '../../models/client-identity.model';
|
||||
import { aadhaarHash } from '../../utils/aadhaar-hash.util';
|
||||
|
||||
export type ClientIdentityStatus = 'idle' | 'checking' | 'not-found' | 'matched' | 'conflict' | 'error';
|
||||
|
||||
/**
|
||||
* What the parent needs from the identity check: whether creation must stop, and the identifiers the officer entered
|
||||
* so the KYC step can be filled in without typing them again.
|
||||
*/
|
||||
export interface ClientIdentityState {
|
||||
status: ClientIdentityStatus;
|
||||
/** Individual: the person's PAN. Entity: the company's PAN. */
|
||||
pan: string | null;
|
||||
/** The 12 digits, held in memory only to pre-fill the KYC step. Never sent: only its hash is. */
|
||||
aadhaar: string | null;
|
||||
gstin: string | null;
|
||||
/** True when the customer already exists (or the identifiers disagree), so a new client must not be created. */
|
||||
blocking: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* Shows a field's error as soon as something has been typed in it, not only after the field is left: a PAN that is
|
||||
* wrong should say so while it is being typed, and the form should not look fine in the meantime.
|
||||
*/
|
||||
class ShowErrorWhenDirty implements ErrorStateMatcher {
|
||||
isErrorState(control: AbstractControl | null, _form: FormGroupDirective | NgForm | null): boolean {
|
||||
return !!control && control.invalid && (control.dirty || control.touched);
|
||||
}
|
||||
}
|
||||
|
||||
const PAN_PATTERN = /^[A-Za-z]{5}\d{4}[A-Za-z]$/;
|
||||
const AADHAAR_PATTERN = /^\d{12}$/;
|
||||
const GSTIN_PATTERN = /^\d{2}[A-Za-z]{5}\d{4}[A-Za-z][1-9A-Za-z]Z[0-9A-Za-z]$/;
|
||||
|
||||
/**
|
||||
* "Is this person already a customer of the bank?" — asked at the top of the General step, before anything is saved.
|
||||
*
|
||||
* Any ONE of PAN / Aadhaar (individual) or PAN / GSTIN (entity) matching an existing customer, in any branch, means
|
||||
* the same customer, so a second client must not be created. When nothing matches, what was entered is carried to the
|
||||
* KYC step so it does not have to be typed again.
|
||||
*/
|
||||
@Component({
|
||||
selector: 'mifosx-client-identity-check',
|
||||
templateUrl: './client-identity-check.component.html',
|
||||
styleUrls: ['./client-identity-check.component.scss'],
|
||||
imports: [
|
||||
...STANDALONE_SHARED_IMPORTS,
|
||||
MatIcon,
|
||||
MatProgressSpinner,
|
||||
RestrictInputDirective
|
||||
]
|
||||
})
|
||||
export class ClientIdentityCheckComponent implements OnInit, OnChanges, OnDestroy {
|
||||
private formBuilder = inject(UntypedFormBuilder);
|
||||
private clientsService = inject(ClientsService);
|
||||
private router = inject(Router);
|
||||
private authenticationService = inject(AuthenticationService);
|
||||
|
||||
/** 1 = individual, 2 = entity (non-individual). */
|
||||
@Input() legalFormType = 1;
|
||||
@Output() identityChange = new EventEmitter<ClientIdentityState>();
|
||||
|
||||
form: UntypedFormGroup;
|
||||
readonly errorMatcher = new ShowErrorWhenDirty();
|
||||
status: ClientIdentityStatus = 'idle';
|
||||
matches: ClientIdentityMatch[] = [];
|
||||
/** True while the customer is being introduced to this branch. */
|
||||
linking = false;
|
||||
|
||||
private destroy$ = new Subject<void>();
|
||||
private recheck$ = new Subject<void>();
|
||||
|
||||
get isEntity(): boolean {
|
||||
return this.legalFormType === 2;
|
||||
}
|
||||
|
||||
/** The first (and normally only) existing customer found. */
|
||||
get match(): ClientIdentityMatch | null {
|
||||
return this.matches[0] ?? null;
|
||||
}
|
||||
|
||||
/**
|
||||
* True while a field holds something that is not a valid PAN / Aadhaar / GSTIN. Such a value is never looked up or
|
||||
* carried to the KYC step, so the parent must not let the officer carry on past it as if the check had passed.
|
||||
*/
|
||||
get hasInvalidInput(): boolean {
|
||||
return !!this.form?.invalid;
|
||||
}
|
||||
|
||||
markAllAsTouched(): void {
|
||||
this.form?.markAllAsTouched();
|
||||
}
|
||||
|
||||
get state(): ClientIdentityState {
|
||||
const v = this.normalized();
|
||||
return {
|
||||
status: this.status,
|
||||
pan: v.pan,
|
||||
aadhaar: v.aadhaar,
|
||||
gstin: v.gstin,
|
||||
blocking: this.status === 'matched' || this.status === 'conflict'
|
||||
};
|
||||
}
|
||||
|
||||
ngOnInit(): void {
|
||||
this.form = this.formBuilder.group({
|
||||
pan: [
|
||||
'',
|
||||
Validators.pattern(PAN_PATTERN)
|
||||
],
|
||||
aadhaar: [
|
||||
'',
|
||||
Validators.pattern(AADHAAR_PATTERN)
|
||||
],
|
||||
gstin: [
|
||||
'',
|
||||
Validators.pattern(GSTIN_PATTERN)
|
||||
]
|
||||
});
|
||||
|
||||
// A pause in typing (or a manual re-check) runs one lookup; typing again cancels the one in flight.
|
||||
merge(this.form.valueChanges, this.recheck$)
|
||||
.pipe(
|
||||
debounceTime(400),
|
||||
tap(() => this.startChecking()),
|
||||
switchMap(() => this.lookup()),
|
||||
takeUntil(this.destroy$)
|
||||
)
|
||||
.subscribe((result) => this.applyResult(result));
|
||||
}
|
||||
|
||||
ngOnChanges(changes: SimpleChanges): void {
|
||||
// Switching individual <-> entity swaps which identifiers apply; what was typed for the other kind is stale.
|
||||
if (changes['legalFormType'] && !changes['legalFormType'].firstChange) {
|
||||
this.reset();
|
||||
}
|
||||
}
|
||||
|
||||
ngOnDestroy(): void {
|
||||
this.destroy$.next();
|
||||
this.destroy$.complete();
|
||||
}
|
||||
|
||||
/** Clears the fields and the result, e.g. when a new client creation session starts. */
|
||||
reset(): void {
|
||||
this.form?.reset({ pan: '', aadhaar: '', gstin: '' }, { emitEvent: false });
|
||||
this.status = 'idle';
|
||||
this.matches = [];
|
||||
this.identityChange.emit(this.state);
|
||||
}
|
||||
|
||||
recheck(): void {
|
||||
this.recheck$.next();
|
||||
}
|
||||
|
||||
/**
|
||||
* PAN and GSTIN are always written in capitals, so what is typed is capitalised as it is typed. The caret is put
|
||||
* back where it was, otherwise editing the middle of a PAN would throw it to the end. The shared restrict-input
|
||||
* directive only capitalises when the field is left, which let lower-case show while typing.
|
||||
*/
|
||||
capitalise(controlName: 'pan' | 'gstin', event: Event): void {
|
||||
const input = event.target as HTMLInputElement;
|
||||
const upper = input.value.toUpperCase();
|
||||
if (upper !== input.value) {
|
||||
const { selectionStart, selectionEnd } = input;
|
||||
input.value = upper;
|
||||
input.setSelectionRange(selectionStart, selectionEnd);
|
||||
}
|
||||
this.form.get(controlName)?.setValue(upper, { emitEvent: false });
|
||||
}
|
||||
|
||||
/** Identifiers that are complete and well formed; anything half-typed is ignored rather than looked up. */
|
||||
private normalized(): { pan: string | null; aadhaar: string | null; gstin: string | null } {
|
||||
const raw = this.form?.getRawValue() ?? { pan: '', aadhaar: '', gstin: '' };
|
||||
const pan = String(raw.pan ?? '')
|
||||
.replace(/\s+/g, '')
|
||||
.toUpperCase();
|
||||
const aadhaar = String(raw.aadhaar ?? '').replace(/[\s-]/g, '');
|
||||
const gstin = String(raw.gstin ?? '')
|
||||
.replace(/\s+/g, '')
|
||||
.toUpperCase();
|
||||
return {
|
||||
pan: PAN_PATTERN.test(pan) ? pan : null,
|
||||
aadhaar: !this.isEntity && AADHAAR_PATTERN.test(aadhaar) ? aadhaar : null,
|
||||
gstin: this.isEntity && GSTIN_PATTERN.test(gstin) ? gstin : null
|
||||
};
|
||||
}
|
||||
|
||||
private startChecking(): void {
|
||||
const v = this.normalized();
|
||||
if (!v.pan && !v.aadhaar && !v.gstin) {
|
||||
this.status = 'idle';
|
||||
this.matches = [];
|
||||
this.identityChange.emit(this.state);
|
||||
return;
|
||||
}
|
||||
this.status = 'checking';
|
||||
this.matches = [];
|
||||
this.identityChange.emit(this.state);
|
||||
}
|
||||
|
||||
private lookup(): Observable<ClientIdentityLookupResult | 'error' | null> {
|
||||
const v = this.normalized();
|
||||
if (!v.pan && !v.aadhaar && !v.gstin) {
|
||||
return of(null);
|
||||
}
|
||||
return this.clientsService
|
||||
.lookupClientIdentity({ panNumber: v.pan, aadhaarHash: aadhaarHash(v.aadhaar), gstinNumber: v.gstin })
|
||||
.pipe(
|
||||
map((result): ClientIdentityLookupResult | 'error' => result),
|
||||
catchError(() => of('error' as const))
|
||||
);
|
||||
}
|
||||
|
||||
private applyResult(result: ClientIdentityLookupResult | 'error' | null): void {
|
||||
if (result === null) {
|
||||
// Nothing complete to look up; startChecking() already reset to idle.
|
||||
return;
|
||||
}
|
||||
if (result === 'error') {
|
||||
// Not blocking: the server checks again when the KYC is saved, so a failed lookup must not stop onboarding.
|
||||
this.status = 'error';
|
||||
this.matches = [];
|
||||
} else if (result.conflict) {
|
||||
this.status = 'conflict';
|
||||
this.matches = result.matches;
|
||||
} else if (result.matched) {
|
||||
this.status = 'matched';
|
||||
this.matches = result.matches;
|
||||
} else {
|
||||
this.status = 'not-found';
|
||||
this.matches = [];
|
||||
}
|
||||
this.identityChange.emit(this.state);
|
||||
}
|
||||
|
||||
/**
|
||||
* Starts opening an account at THIS branch for a customer registered at another one. The customer stays at the home
|
||||
* branch (one CIF). This branch is introduced to them, which only lets it open their page; they are listed under
|
||||
* this branch once an account is actually opened here. An account opened for a customer of another branch is held
|
||||
* at the branch that opens it.
|
||||
*/
|
||||
openAccountHere(match: ClientIdentityMatch): void {
|
||||
const officeId = this.authenticationService.getCredentials()?.officeId;
|
||||
if (this.linking || officeId == null) {
|
||||
return;
|
||||
}
|
||||
this.linking = true;
|
||||
this.clientsService.linkClientToMyBranch(match.clientId).subscribe({
|
||||
next: () => {
|
||||
this.linking = false;
|
||||
this.router.navigate([
|
||||
'/clients',
|
||||
match.clientId,
|
||||
'general'
|
||||
]);
|
||||
},
|
||||
// The failure is shown by the global error handler.
|
||||
error: () => (this.linking = false)
|
||||
});
|
||||
}
|
||||
|
||||
/** Legal form of the existing customer, for the card. */
|
||||
legalFormLabel(match: ClientIdentityMatch): string {
|
||||
return match.legalFormEnum === 2 ? 'Non-Individual' : 'Individual';
|
||||
}
|
||||
|
||||
matchedByLabel(by: string): string {
|
||||
return by === 'AADHAAR' ? 'Aadhaar' : by;
|
||||
}
|
||||
}
|
||||
@@ -43,6 +43,8 @@ import { KycUploadHelperService } from '../../shared/kyc-upload-helper.service';
|
||||
import { CaptureImageDialogComponent } from '../../clients-view/custom-dialogs/capture-image-dialog/capture-image-dialog.component';
|
||||
import { DrawSignatureDialogComponent } from '../../clients-view/custom-dialogs/draw-signature-dialog/draw-signature-dialog.component';
|
||||
import { dataUrlToFile } from 'app/shared/utils/data-url.util';
|
||||
import { aadhaarHash } from '../../utils/aadhaar-hash.util';
|
||||
import { ClientIdentityState } from '../client-identity-check/client-identity-check.component';
|
||||
import { DocumentPreviewDialogComponent } from '../../shared/document-preview-dialog/document-preview-dialog.component';
|
||||
import {
|
||||
PanDeclarationDialogComponent,
|
||||
@@ -108,6 +110,32 @@ export class ClientKycStepComponent implements OnInit, OnChanges, OnDestroy {
|
||||
@Input() verificationDateOfBirth: Date | null = null;
|
||||
/** Read-only mode — disables form editing until user clicks edit icon */
|
||||
@Input() readOnly = false;
|
||||
|
||||
/**
|
||||
* The identifiers the officer already typed into the existing-customer check on the General step. They are filled
|
||||
* in here so they are not typed twice, but only into fields that are still empty: whatever was entered on this
|
||||
* step wins. Ignored while the check says the customer already exists, since no client is created then.
|
||||
*/
|
||||
@Input() set identityPrefill(identity: ClientIdentityState | null) {
|
||||
if (!identity || identity.blocking) {
|
||||
return;
|
||||
}
|
||||
const isEmpty = (name: string) => !String(this.kycForm?.get(name)?.value ?? '').trim();
|
||||
const patch: Record<string, unknown> = {};
|
||||
if (this.isNonIndividual) {
|
||||
if (identity.gstin && isEmpty('gstinNumber')) patch['gstinNumber'] = identity.gstin;
|
||||
if (identity.pan && isEmpty('enterprisePan')) patch['enterprisePan'] = identity.pan;
|
||||
} else {
|
||||
if (identity.pan && isEmpty('panNumber')) {
|
||||
patch['panNumber'] = identity.pan;
|
||||
patch['hasPan'] = true;
|
||||
}
|
||||
if (identity.aadhaar && isEmpty('aadhaarNumber')) patch['aadhaarNumber'] = identity.aadhaar;
|
||||
}
|
||||
if (Object.keys(patch).length) {
|
||||
this.kycForm.patchValue(patch);
|
||||
}
|
||||
}
|
||||
@Input() calculatedRisk: {
|
||||
category: number;
|
||||
reason: string;
|
||||
@@ -1756,7 +1784,8 @@ export class ClientKycStepComponent implements OnInit, OnChanges, OnDestroy {
|
||||
kycType: this.kycTypeValue,
|
||||
riskCategory: this.currentRiskCategory,
|
||||
panNumber: pan,
|
||||
aadhaarNumberMasked: aadhaarRaw.length === 12 ? 'XXXXXXXX' + aadhaarRaw.slice(-4) : 'XXXXXXXX0000'
|
||||
aadhaarNumberMasked: aadhaarRaw.length === 12 ? 'XXXXXXXX' + aadhaarRaw.slice(-4) : 'XXXXXXXX0000',
|
||||
...this.aadhaarHashField(aadhaarRaw)
|
||||
};
|
||||
|
||||
// A proprietorship reaches this method (it verifies the proprietor's individual PAN) but is
|
||||
@@ -2358,7 +2387,8 @@ export class ClientKycStepComponent implements OnInit, OnChanges, OnDestroy {
|
||||
locale: 'en',
|
||||
kycType: this.kycTypeValue,
|
||||
riskCategory: this.currentRiskCategory,
|
||||
aadhaarNumberMasked: aadhaarMasked
|
||||
aadhaarNumberMasked: aadhaarMasked,
|
||||
...this.aadhaarHashField(aadhaar)
|
||||
};
|
||||
const panVal = this.kycForm.value.panNumber;
|
||||
if (panVal) {
|
||||
@@ -2679,6 +2709,15 @@ export class ClientKycStepComponent implements OnInit, OnChanges, OnDestroy {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* `{ aadhaarHash }` for a full 12-digit Aadhaar, `{}` otherwise. The number itself is never sent: KYC calls are
|
||||
* recorded server-side, so only its SHA-256 goes.
|
||||
*/
|
||||
private aadhaarHashField(aadhaar: string | null | undefined): { aadhaarHash?: string } {
|
||||
const hash = aadhaarHash(aadhaar);
|
||||
return hash ? { aadhaarHash: hash } : {};
|
||||
}
|
||||
|
||||
get kycData(): any {
|
||||
const formVal = this.kycForm.getRawValue();
|
||||
const aadhaarRaw = formVal.aadhaarNumber || '';
|
||||
@@ -2707,6 +2746,9 @@ export class ClientKycStepComponent implements OnInit, OnChanges, OnDestroy {
|
||||
// For non-individual: Company PAN is used as panNumber for verification
|
||||
panNumber: this.isNonIndividual ? enterprisePanNorm : v.hasPan ? panNorm : null,
|
||||
aadhaarNumberMasked: this.isNonIndividual ? 'XXXXXXXX0000' : aadhaarMasked,
|
||||
// The fingerprint of the full number, so the server can tell two Aadhaar numbers apart (the masked form keeps
|
||||
// only the last four digits). Absent when the field holds a masked value reloaded from the server.
|
||||
...(this.isNonIndividual ? {} : this.aadhaarHashField(aadhaarRaw)),
|
||||
// Only ever sent when this screen actually holds a value. The seeding status is derived by
|
||||
// the provider during PAN verification and written straight to the record, so sending an
|
||||
// empty one back would blank a field the server filled in — which reads as an officer
|
||||
|
||||
@@ -90,6 +90,21 @@
|
||||
<mifosx-long-text textValue="{{ element.productName }}" chars="35"></mifosx-long-text>
|
||||
</td>
|
||||
</ng-container>
|
||||
<ng-container matColumnDef="Branch">
|
||||
<th mat-header-cell *matHeaderCellDef>{{ 'labels.inputs.Branch' | translate }}</th>
|
||||
<td mat-cell *matCellDef="let element">
|
||||
@if (element.officeName) {
|
||||
<span [class]="branchChipClass(element)" [title]="branchChipTitle(element)">
|
||||
<i class="fa fa-map-marker"></i>{{ element.officeName }}
|
||||
@if (isHomeBranch(element)) {
|
||||
<span class="branch-chip__home">Home</span>
|
||||
}
|
||||
</span>
|
||||
} @else {
|
||||
—
|
||||
}
|
||||
</td>
|
||||
</ng-container>
|
||||
<ng-container matColumnDef="Last Active">
|
||||
<th mat-header-cell *matHeaderCellDef>{{ 'labels.inputs.Last Active' | translate }}</th>
|
||||
<td mat-cell *matCellDef="let element">{{ element.lastActiveTransactionDate | dateFormat }}</td>
|
||||
@@ -203,6 +218,21 @@
|
||||
<mifosx-long-text textValue="{{ element.productName }}" chars="35"></mifosx-long-text>
|
||||
</td>
|
||||
</ng-container>
|
||||
<ng-container matColumnDef="Branch">
|
||||
<th mat-header-cell *matHeaderCellDef>{{ 'labels.inputs.Branch' | translate }}</th>
|
||||
<td mat-cell *matCellDef="let element">
|
||||
@if (element.officeName) {
|
||||
<span [class]="branchChipClass(element)" [title]="branchChipTitle(element)">
|
||||
<i class="fa fa-map-marker"></i>{{ element.officeName }}
|
||||
@if (isHomeBranch(element)) {
|
||||
<span class="branch-chip__home">Home</span>
|
||||
}
|
||||
</span>
|
||||
} @else {
|
||||
—
|
||||
}
|
||||
</td>
|
||||
</ng-container>
|
||||
<ng-container matColumnDef="Closed Date">
|
||||
<th mat-header-cell *matHeaderCellDef>{{ 'labels.inputs.Closed Date' | translate }}</th>
|
||||
<td mat-cell *matCellDef="let element">{{ element.timeline.closedOnDate | dateFormat }}</td>
|
||||
@@ -262,6 +292,21 @@
|
||||
<mifosx-long-text textValue="{{ element.productName }}" chars="35"></mifosx-long-text>
|
||||
</td>
|
||||
</ng-container>
|
||||
<ng-container matColumnDef="Branch">
|
||||
<th mat-header-cell *matHeaderCellDef>{{ 'labels.inputs.Branch' | translate }}</th>
|
||||
<td mat-cell *matCellDef="let element">
|
||||
@if (element.officeName) {
|
||||
<span [class]="branchChipClass(element)" [title]="branchChipTitle(element)">
|
||||
<i class="fa fa-map-marker"></i>{{ element.officeName }}
|
||||
@if (isHomeBranch(element)) {
|
||||
<span class="branch-chip__home">Home</span>
|
||||
}
|
||||
</span>
|
||||
} @else {
|
||||
—
|
||||
}
|
||||
</td>
|
||||
</ng-container>
|
||||
<ng-container matColumnDef="Last Active">
|
||||
<th mat-header-cell *matHeaderCellDef>{{ 'labels.inputs.Last Active' | translate }}</th>
|
||||
<td mat-cell *matCellDef="let element">{{ element.lastActiveTransactionDate | dateFormat }}</td>
|
||||
@@ -452,6 +497,21 @@
|
||||
<th mat-header-cell *matHeaderCellDef>{{ 'labels.inputs.Fixed Deposit Product' | translate }}</th>
|
||||
<td mat-cell *matCellDef="let element">{{ element.productName }}</td>
|
||||
</ng-container>
|
||||
<ng-container matColumnDef="Branch">
|
||||
<th mat-header-cell *matHeaderCellDef>{{ 'labels.inputs.Branch' | translate }}</th>
|
||||
<td mat-cell *matCellDef="let element">
|
||||
@if (element.officeName) {
|
||||
<span [class]="branchChipClass(element)" [title]="branchChipTitle(element)">
|
||||
<i class="fa fa-map-marker"></i>{{ element.officeName }}
|
||||
@if (isHomeBranch(element)) {
|
||||
<span class="branch-chip__home">Home</span>
|
||||
}
|
||||
</span>
|
||||
} @else {
|
||||
—
|
||||
}
|
||||
</td>
|
||||
</ng-container>
|
||||
<ng-container matColumnDef="Last Active">
|
||||
<th mat-header-cell *matHeaderCellDef>{{ 'labels.inputs.Last Active' | translate }}</th>
|
||||
<td mat-cell *matCellDef="let element">{{ element.lastActiveTransactionDate | dateFormat }}</td>
|
||||
@@ -551,6 +611,21 @@
|
||||
<mifosx-long-text textValue="{{ element.productName }}" chars="35"></mifosx-long-text>
|
||||
</td>
|
||||
</ng-container>
|
||||
<ng-container matColumnDef="Branch">
|
||||
<th mat-header-cell *matHeaderCellDef>{{ 'labels.inputs.Branch' | translate }}</th>
|
||||
<td mat-cell *matCellDef="let element">
|
||||
@if (element.officeName) {
|
||||
<span [class]="branchChipClass(element)" [title]="branchChipTitle(element)">
|
||||
<i class="fa fa-map-marker"></i>{{ element.officeName }}
|
||||
@if (isHomeBranch(element)) {
|
||||
<span class="branch-chip__home">Home</span>
|
||||
}
|
||||
</span>
|
||||
} @else {
|
||||
—
|
||||
}
|
||||
</td>
|
||||
</ng-container>
|
||||
<ng-container matColumnDef="Closed Date">
|
||||
<th mat-header-cell *matHeaderCellDef>{{ 'labels.inputs.Closed Date' | translate }}</th>
|
||||
<td mat-cell *matCellDef="let element">{{ element.timeline.closedOnDate | dateFormat }}</td>
|
||||
@@ -611,10 +686,27 @@
|
||||
<mifosx-long-text textValue="{{ element.productName }}" chars="35"></mifosx-long-text>
|
||||
</td>
|
||||
</ng-container>
|
||||
<ng-container matColumnDef="Branch">
|
||||
<th mat-header-cell *matHeaderCellDef>{{ 'labels.inputs.Branch' | translate }}</th>
|
||||
<td mat-cell *matCellDef="let element">
|
||||
@if (element.officeName) {
|
||||
<span [class]="branchChipClass(element)" [title]="branchChipTitle(element)">
|
||||
<i class="fa fa-map-marker"></i>{{ element.officeName }}
|
||||
@if (isHomeBranch(element)) {
|
||||
<span class="branch-chip__home">Home</span>
|
||||
}
|
||||
</span>
|
||||
} @else {
|
||||
—
|
||||
}
|
||||
</td>
|
||||
</ng-container>
|
||||
<ng-container matColumnDef="Status">
|
||||
<th mat-header-cell *matHeaderCellDef>Status</th>
|
||||
<td mat-cell *matCellDef="let element">
|
||||
<span class="rd-status" [class.rd-status--ended]="element.timeline?.closedOnDate">{{ rdStatusLabel(element) }}</span>
|
||||
<span class="rd-status" [class.rd-status--ended]="element.timeline?.closedOnDate">{{
|
||||
rdStatusLabel(element)
|
||||
}}</span>
|
||||
@if (element.timeline?.closedOnDate) {
|
||||
<div class="rd-status__date">on {{ element.timeline.closedOnDate | dateFormat }}</div>
|
||||
}
|
||||
|
||||
@@ -440,3 +440,66 @@ table[mat-table] {
|
||||
font-size: 11px;
|
||||
color: #64748b;
|
||||
}
|
||||
|
||||
/*
|
||||
* Branch an account is held at, as a pill coloured by where the signed-in user works: green for their own branch, red for
|
||||
* any other. When the customer banks at several other branches the reds run from light to dark, one shade per branch.
|
||||
*/
|
||||
.branch-chip {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
padding: 3px 10px;
|
||||
border-radius: 999px;
|
||||
border: 1px solid transparent;
|
||||
font-size: 12px;
|
||||
font-weight: 600;
|
||||
white-space: nowrap;
|
||||
|
||||
.fa {
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
&--mine {
|
||||
background: #dcfce7;
|
||||
color: #166534;
|
||||
border-color: #4ade80;
|
||||
}
|
||||
|
||||
&--other-0 {
|
||||
background: #fee2e2;
|
||||
color: #991b1b;
|
||||
border-color: #fca5a5;
|
||||
}
|
||||
|
||||
&--other-1 {
|
||||
background: #fca5a5;
|
||||
color: #7f1d1d;
|
||||
border-color: #f87171;
|
||||
}
|
||||
|
||||
&--other-2 {
|
||||
background: #dc2626;
|
||||
color: #fff;
|
||||
border-color: #b91c1c;
|
||||
}
|
||||
|
||||
&--other-3 {
|
||||
background: #991b1b;
|
||||
color: #fff;
|
||||
border-color: #7f1d1d;
|
||||
}
|
||||
}
|
||||
|
||||
.branch-chip__home {
|
||||
padding: 0 6px;
|
||||
border-radius: 8px;
|
||||
|
||||
/* fixed colours: the chip text turns white on the dark reds, which would vanish against this light tag */
|
||||
background: rgb(255 255 255 / 85%);
|
||||
color: #1f2937;
|
||||
font-size: 10px;
|
||||
font-weight: 700;
|
||||
letter-spacing: 0.04em;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
@@ -39,6 +39,7 @@ import { DateFormatPipe } from '../../../pipes/date-format.pipe';
|
||||
import { FormatNumberPipe } from '../../../pipes/format-number.pipe';
|
||||
import { CurrencyPipe } from '@angular/common';
|
||||
import { STANDALONE_SHARED_IMPORTS } from 'app/standalone-shared.module';
|
||||
import { AuthenticationService } from '../../../core/authentication/authentication.service';
|
||||
import { ReportsService } from 'app/reports/reports.service';
|
||||
import { SettingsService } from 'app/settings/settings.service';
|
||||
import { Subject } from 'rxjs';
|
||||
@@ -151,6 +152,7 @@ export class GeneralTabComponent implements OnDestroy {
|
||||
private router = inject(Router);
|
||||
private reportsService = inject(ReportsService);
|
||||
private settingsService = inject(SettingsService);
|
||||
private authenticationService = inject(AuthenticationService);
|
||||
|
||||
/** Open Loan Accounts Columns */
|
||||
openLoansColumns: string[] = [
|
||||
@@ -177,6 +179,7 @@ export class GeneralTabComponent implements OnDestroy {
|
||||
openSavingsColumns: string[] = [
|
||||
'Account No',
|
||||
'Saving Account',
|
||||
'Branch',
|
||||
'Last Active',
|
||||
'Balance',
|
||||
'Actions'
|
||||
@@ -185,12 +188,14 @@ export class GeneralTabComponent implements OnDestroy {
|
||||
closedSavingsColumns: string[] = [
|
||||
'Account No',
|
||||
'Saving Account',
|
||||
'Branch',
|
||||
'Closed Date'
|
||||
];
|
||||
/** Closed Recurring Deposit Accounts Columns - with how each one ended, for tracking. */
|
||||
recurringColumns: string[] = [
|
||||
'Account No',
|
||||
'Saving Account',
|
||||
'Branch',
|
||||
'Status',
|
||||
'Last Active',
|
||||
'Balance',
|
||||
@@ -336,12 +341,76 @@ export class GeneralTabComponent implements OnDestroy {
|
||||
/** Client Id */
|
||||
clientid: any;
|
||||
|
||||
/** The customer's own (home) branch, to tell it from branches the customer banks at elsewhere. */
|
||||
clientOfficeId: number | null = null;
|
||||
|
||||
/** How many red shades the branch chips have (general-tab.component.scss, .branch-chip--other-0 ... -3), light to dark. */
|
||||
private static readonly OTHER_BRANCH_SHADES = 4;
|
||||
|
||||
/** The branch the signed-in user works at: accounts held here read green, every other branch red. */
|
||||
private get myOfficeId(): number | null {
|
||||
const officeId = this.authenticationService.getCredentials()?.officeId;
|
||||
return officeId == null ? null : Number(officeId);
|
||||
}
|
||||
|
||||
/**
|
||||
* The other branches this customer banks at, lightest to darkest. Ordered by branch id so the shades stay put between
|
||||
* visits; computed over every deposit account on the page so one branch has the same shade in every table.
|
||||
*/
|
||||
private otherBranchIds(): number[] {
|
||||
const mine = this.myOfficeId;
|
||||
const ids = new Set<number>();
|
||||
for (const account of [
|
||||
...this.savingAccounts,
|
||||
...this.currentAccounts
|
||||
]) {
|
||||
const officeId = Number(account?.officeId);
|
||||
if (Number.isFinite(officeId) && officeId !== mine) {
|
||||
ids.add(officeId);
|
||||
}
|
||||
}
|
||||
return [...ids].sort((a, b) => a - b);
|
||||
}
|
||||
|
||||
/** Green for an account at the signed-in user's branch; red for any other branch, a different shade for each. */
|
||||
branchChipClass(account: any): string {
|
||||
const officeId = Number(account?.officeId);
|
||||
if (officeId === this.myOfficeId) {
|
||||
return 'branch-chip branch-chip--mine';
|
||||
}
|
||||
const others = this.otherBranchIds();
|
||||
const rank = Math.max(others.indexOf(officeId), 0);
|
||||
const last = GeneralTabComponent.OTHER_BRANCH_SHADES - 1;
|
||||
// one other branch gets a mid red, several are spread from the lightest to the darkest
|
||||
const shade = others.length <= 1 ? 1 : Math.round((rank * last) / (others.length - 1));
|
||||
return `branch-chip branch-chip--other-${shade}`;
|
||||
}
|
||||
|
||||
branchChipTitle(account: any): string {
|
||||
const parts = [account?.officeName];
|
||||
if (Number(account?.officeId) === this.myOfficeId) {
|
||||
parts.push('your branch');
|
||||
}
|
||||
if (this.isHomeBranch(account)) {
|
||||
parts.push('customer home branch');
|
||||
}
|
||||
return parts.join(' - ');
|
||||
}
|
||||
|
||||
isHomeBranch(account: any): boolean {
|
||||
return this.clientOfficeId != null && Number(account?.officeId) === this.clientOfficeId;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {ActivatedRoute} route Activated Route
|
||||
* @param {ClientsService} clientService Clients Service
|
||||
* @param {Router} router Router
|
||||
*/
|
||||
constructor() {
|
||||
// The customer's record is resolved on the parent route, not on this tab's own route data.
|
||||
this.route.parent?.data.subscribe((data: any) => {
|
||||
this.clientOfficeId = data?.clientViewData?.officeId ?? null;
|
||||
});
|
||||
this.route.data.subscribe(
|
||||
(data: { clientAccountsData: any; clientChargesData: any; clientSummary: any; clientCollateralData: any }) => {
|
||||
this.clientAccountData = data.clientAccountsData;
|
||||
|
||||
@@ -50,6 +50,17 @@
|
||||
/>
|
||||
</mat-form-field>
|
||||
|
||||
@if (isHeadOffice) {
|
||||
<mat-form-field class="table-filter" appearance="outline">
|
||||
<mat-select [(value)]="officeFilter" (selectionChange)="officeChanged()" placeholder="Branch">
|
||||
<mat-option [value]="null">All Branches</mat-option>
|
||||
@for (office of offices; track office.id) {
|
||||
<mat-option [value]="office.id">{{ office.name }}</mat-option>
|
||||
}
|
||||
</mat-select>
|
||||
</mat-form-field>
|
||||
}
|
||||
|
||||
<mat-form-field class="table-filter" appearance="outline">
|
||||
<mat-select [(value)]="statusFilter" (selectionChange)="applyClientFilters()" placeholder="Status">
|
||||
<mat-option value="">All Status</mat-option>
|
||||
@@ -92,6 +103,11 @@
|
||||
row.legalForm?.id === 2 ? 'business' : 'person'
|
||||
}}</mat-icon>
|
||||
{{ hideClientData ? maskName(row.displayName) : row.displayName }}
|
||||
@if (!isHeadOffice && row.officeId && myOfficeId !== null && row.officeId !== myOfficeId) {
|
||||
<span class="row-home-chip" matTooltip="Registered at another branch; linked to this branch">
|
||||
Home: {{ row.officeName }}
|
||||
</span>
|
||||
}
|
||||
@if (row._ckycDone) {
|
||||
<mat-icon class="row-ckyc-icon" matTooltip="CKYC Done">verified</mat-icon>
|
||||
}
|
||||
@@ -114,6 +130,12 @@
|
||||
</td>
|
||||
</ng-container>
|
||||
|
||||
<!-- Branch Column (Head Office only) -->
|
||||
<ng-container matColumnDef="officeName">
|
||||
<th mat-header-cell *matHeaderCellDef>Branch</th>
|
||||
<td mat-cell *matCellDef="let row">{{ row.officeName }}</td>
|
||||
</ng-container>
|
||||
|
||||
<!-- Status Column -->
|
||||
<ng-container matColumnDef="status">
|
||||
<th mat-header-cell *matHeaderCellDef>{{ 'labels.inputs.Status' | translate }}</th>
|
||||
|
||||
@@ -7,3 +7,15 @@
|
||||
*/
|
||||
|
||||
@use '../shared/styles/list-layout' as *;
|
||||
|
||||
// A customer registered at another branch that has been linked to this one.
|
||||
.row-home-chip {
|
||||
margin-left: 8px;
|
||||
padding: 1px 8px;
|
||||
font-size: 11px;
|
||||
font-weight: 500;
|
||||
color: #92400e;
|
||||
background: #fef3c7;
|
||||
border-radius: 999px;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
@@ -38,6 +38,8 @@ import { MatIconButton, MatButton } from '@angular/material/button';
|
||||
import { MatIcon } from '@angular/material/icon';
|
||||
import { MatTooltip } from '@angular/material/tooltip';
|
||||
import { STANDALONE_SHARED_IMPORTS } from 'app/standalone-shared.module';
|
||||
import { AuthenticationService } from '../core/authentication/authentication.service';
|
||||
import { OrganizationService } from '../organization/organization.service';
|
||||
|
||||
@Component({
|
||||
selector: 'mifosx-clients',
|
||||
@@ -71,6 +73,15 @@ import { STANDALONE_SHARED_IMPORTS } from 'app/standalone-shared.module';
|
||||
})
|
||||
export class ClientsComponent implements OnInit {
|
||||
private clientService = inject(ClientsService);
|
||||
private authenticationService = inject(AuthenticationService);
|
||||
private organizationService = inject(OrganizationService);
|
||||
|
||||
/** Head Office sees every branch's clients, so it gets a Branch filter. A branch user only ever sees its own. */
|
||||
isHeadOffice = this.authenticationService.isHeadOfficeUser();
|
||||
offices: { id: number; name: string }[] = [];
|
||||
officeFilter: number | null = null;
|
||||
/** The caller's own branch, to tell a customer of another branch (linked here) from this branch's own. */
|
||||
myOfficeId = this.authenticationService.getCredentials()?.officeId ?? null;
|
||||
|
||||
/** Returns true if client data masking is enabled */
|
||||
get hideClientData(): boolean {
|
||||
@@ -90,6 +101,7 @@ export class ClientsComponent implements OnInit {
|
||||
'displayName',
|
||||
'cifNumber',
|
||||
'legalForm',
|
||||
'officeName',
|
||||
'status',
|
||||
'profileCompletion',
|
||||
'actions'
|
||||
@@ -119,6 +131,13 @@ export class ClientsComponent implements OnInit {
|
||||
@ViewChild(MatSort) sort: MatSort;
|
||||
|
||||
ngOnInit() {
|
||||
if (!this.isHeadOffice) {
|
||||
this.displayedColumns = this.displayedColumns.filter((c) => c !== 'officeName');
|
||||
} else {
|
||||
this.organizationService.getOffices().subscribe((offices: any[]) => {
|
||||
this.offices = (offices || []).map((o) => ({ id: o.id, name: o.name }));
|
||||
});
|
||||
}
|
||||
if (environment.preloadClients) {
|
||||
this.getClients();
|
||||
}
|
||||
@@ -144,7 +163,8 @@ export class ClientsComponent implements OnInit {
|
||||
this.sortDirection,
|
||||
false,
|
||||
'',
|
||||
true
|
||||
true,
|
||||
this.isHeadOffice ? this.officeFilter : null
|
||||
)
|
||||
.subscribe(
|
||||
(data: any) => {
|
||||
@@ -233,6 +253,12 @@ export class ClientsComponent implements OnInit {
|
||||
this.dataSource.data = filtered;
|
||||
}
|
||||
|
||||
/** Branch filter is applied by the server so paging and the record count stay correct. */
|
||||
officeChanged() {
|
||||
this.resetPaginator();
|
||||
this.getClients();
|
||||
}
|
||||
|
||||
pageChanged(event: PageEvent) {
|
||||
this.pageSize = event.pageSize;
|
||||
this.currentPage = event.pageIndex;
|
||||
|
||||
@@ -18,6 +18,7 @@ import { map, catchError } from 'rxjs/operators';
|
||||
|
||||
import { environment } from '../../environments/environment';
|
||||
import { dataUrlToFile } from 'app/shared/utils/data-url.util';
|
||||
import { ClientIdentityLookupResult } from './models/client-identity.model';
|
||||
|
||||
/**
|
||||
* Clients service.
|
||||
@@ -742,9 +743,14 @@ export class ClientsService {
|
||||
sortDirection: string = '',
|
||||
activeOnly: boolean = false,
|
||||
legalFormType: string = '',
|
||||
silent: boolean = false
|
||||
silent: boolean = false,
|
||||
officeId: number | null = null
|
||||
) {
|
||||
const innerRequest: any = { text };
|
||||
// Branch filter (Head Office list). The server still limits results to the caller's office hierarchy.
|
||||
if (officeId != null) {
|
||||
innerRequest.officeId = officeId;
|
||||
}
|
||||
if (activeOnly) {
|
||||
innerRequest.activeOnly = true;
|
||||
}
|
||||
@@ -868,6 +874,39 @@ export class ClientsService {
|
||||
return this.http.post(`/clients/${clientId}/kyc`, kycData);
|
||||
}
|
||||
|
||||
/**
|
||||
* "Is this person already a customer of the bank?" — across every branch, by PAN, Aadhaar fingerprint or GSTIN.
|
||||
* Any ONE of them matching means the same customer. `contactConflicts` reports a phone/email already used by another
|
||||
* client, which is never the same customer. Only the Aadhaar's SHA-256 is sent, never the number.
|
||||
*/
|
||||
/**
|
||||
* Introduces a customer of another branch to the caller's branch, so the branch can see them and open an account
|
||||
* for them there. The customer stays at the home branch. Safe to repeat.
|
||||
*/
|
||||
linkClientToMyBranch(
|
||||
clientId: number
|
||||
): Observable<{ clientId: number; linked: boolean; officeId: number; officeName: string }> {
|
||||
return this.http.post<{ clientId: number; linked: boolean; officeId: number; officeName: string }>(
|
||||
'/clientidentity/link',
|
||||
{
|
||||
clientId
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
lookupClientIdentity(identity: {
|
||||
panNumber?: string | null;
|
||||
aadhaarHash?: string | null;
|
||||
gstinNumber?: string | null;
|
||||
mobileNo?: string | null;
|
||||
emailAddress?: string | null;
|
||||
excludeClientId?: number | null;
|
||||
}): Observable<ClientIdentityLookupResult> {
|
||||
return this.http.post<ClientIdentityLookupResult>('/clientidentity/lookup', identity, {
|
||||
context: new HttpContext().set(SILENT_HTTP_ERROR, true)
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Screen a client name against the UN Security Council consolidated sanctions list.
|
||||
* @param name The name to screen
|
||||
@@ -1073,9 +1112,146 @@ export class ClientsService {
|
||||
*/
|
||||
saveKycContactLog(
|
||||
clientId: string | number,
|
||||
data: { channel: string; notes: string; officerName?: string }
|
||||
data: { channel: string; notes: string; officerName?: string; noticeType?: string },
|
||||
silent = false
|
||||
): Observable<any> {
|
||||
return this.http.post(`/kyc/exceptions/${clientId}/contact-log`, data);
|
||||
return this.http.post(
|
||||
`/kyc/exceptions/${clientId}/contact-log`,
|
||||
data,
|
||||
silent ? { context: new HttpContext().set(SILENT_HTTP_ERROR, true) } : {}
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* The subject and text a customer would receive for the stage they are at today.
|
||||
* `type` is KYC or DORMANCY; the body comes back empty when they are at no such stage.
|
||||
*/
|
||||
previewKycNotice(clientId: string | number, type: string): Observable<{ subject?: string; body?: string }> {
|
||||
return this.http.get<{ subject?: string; body?: string }>(
|
||||
`/kyc/exceptions/${clientId}/contact-log/preview?type=${type}`,
|
||||
{
|
||||
context: new HttpContext().set(SILENT_HTTP_ERROR, true)
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
/** Queues notices to be sent in the background by the queue job. */
|
||||
enqueueKycNotices(body: {
|
||||
items: { clientId: number; noticeType: string; channel: string; accountNo?: string; stageLabel?: string }[];
|
||||
notes?: string;
|
||||
officerName?: string;
|
||||
sendAt?: string;
|
||||
}): Observable<{ queued: number; skipped: number; unreachable: number }> {
|
||||
return this.http.post<{ queued: number; skipped: number; unreachable: number }>('/kyc/notice-queue', body);
|
||||
}
|
||||
|
||||
/** Queued notices, newest first; status narrows to PENDING, SENT, FAILED or CANCELLED. */
|
||||
getKycNoticeQueue(status = ''): Observable<any[]> {
|
||||
return this.http.get<any[]>(`/kyc/notice-queue${status ? `?status=${status}` : ''}`, {
|
||||
context: new HttpContext().set(SILENT_HTTP_ERROR, true)
|
||||
});
|
||||
}
|
||||
|
||||
actOnKycNotice(id: number, command: 'cancel' | 'retry'): Observable<any> {
|
||||
return this.http.post(`/kyc/notice-queue/${id}?command=${command}`, {});
|
||||
}
|
||||
|
||||
/** What each of these customers has been sent so far: the latest notice, how many, and which channels are done. */
|
||||
getKycContactSummary(clientIds: number[]): Observable<
|
||||
{
|
||||
clientId: number;
|
||||
count: number;
|
||||
lastAt: string;
|
||||
lastChannel: string;
|
||||
lastStatus: string;
|
||||
doneChannels: string[];
|
||||
balance?: number;
|
||||
hasMobile?: boolean;
|
||||
hasEmail?: boolean;
|
||||
mobileMasked?: string | null;
|
||||
emailMasked?: string | null;
|
||||
}[]
|
||||
> {
|
||||
return this.http.get<
|
||||
{
|
||||
clientId: number;
|
||||
count: number;
|
||||
lastAt: string;
|
||||
lastChannel: string;
|
||||
lastStatus: string;
|
||||
doneChannels: string[];
|
||||
hasMobile?: boolean;
|
||||
hasEmail?: boolean;
|
||||
mobileMasked?: string | null;
|
||||
emailMasked?: string | null;
|
||||
}[]
|
||||
>('/kyc/contact-audit/clients', {
|
||||
params: new HttpParams().set('ids', clientIds.join(',')),
|
||||
context: new HttpContext().set(SILENT_HTTP_ERROR, true)
|
||||
});
|
||||
}
|
||||
|
||||
/** Notices per customer (counts, outcomes, the latest), most recently contacted first, a page at a time. */
|
||||
getKycContactAuditCustomers(
|
||||
filter: {
|
||||
from?: string;
|
||||
to?: string;
|
||||
channel?: string;
|
||||
status?: string;
|
||||
search?: string;
|
||||
real?: boolean;
|
||||
offset?: number;
|
||||
limit?: number;
|
||||
} = {}
|
||||
): Observable<{ total: number; rows: any[] }> {
|
||||
return this.http.get<{ total: number; rows: any[] }>('/kyc/contact-audit/customers', {
|
||||
params: this.auditParams(filter),
|
||||
context: new HttpContext().set(SILENT_HTTP_ERROR, true)
|
||||
});
|
||||
}
|
||||
|
||||
/** Notices per day (customers, notices, outcomes), newest day first. */
|
||||
getKycContactAuditDays(
|
||||
filter: { from?: string; to?: string; channel?: string; status?: string; search?: string; real?: boolean } = {}
|
||||
): Observable<any[]> {
|
||||
return this.http.get<any[]>('/kyc/contact-audit/days', {
|
||||
params: this.auditParams(filter),
|
||||
context: new HttpContext().set(SILENT_HTTP_ERROR, true)
|
||||
});
|
||||
}
|
||||
|
||||
/** Customer notices on record, newest first, a page at a time. */
|
||||
getKycContactAudit(
|
||||
filter: {
|
||||
from?: string;
|
||||
to?: string;
|
||||
channel?: string;
|
||||
status?: string;
|
||||
search?: string;
|
||||
real?: boolean;
|
||||
offset?: number;
|
||||
limit?: number;
|
||||
} = {}
|
||||
): Observable<{ total: number; rows: any[] }> {
|
||||
return this.http.get<{ total: number; rows: any[] }>('/kyc/contact-audit', {
|
||||
params: this.auditParams(filter),
|
||||
context: new HttpContext().set(SILENT_HTTP_ERROR, true)
|
||||
});
|
||||
}
|
||||
|
||||
private auditParams(filter: Record<string, string | number | boolean | undefined>): HttpParams {
|
||||
let params = new HttpParams();
|
||||
Object.entries(filter).forEach(
|
||||
([
|
||||
k,
|
||||
v
|
||||
]) => {
|
||||
if (v !== undefined && v !== null && v !== '') {
|
||||
params = params.set(k, String(v));
|
||||
}
|
||||
}
|
||||
);
|
||||
return params;
|
||||
}
|
||||
|
||||
getKycContactLog(clientId: string | number): Observable<any[]> {
|
||||
@@ -1092,6 +1268,14 @@ export class ClientsService {
|
||||
return this.http.get<any[]>('/kyc/exceptions/lapsed');
|
||||
}
|
||||
|
||||
/** Clients whose re-KYC falls due within the next `days` days (default 30), soonest first. */
|
||||
getExpiringKyc(days = 30): Observable<any[]> {
|
||||
// The caller reports a failure in its own banner, so the global popup is suppressed.
|
||||
return this.http.get<any[]>(`/kyc/exceptions/expiring?days=${days}`, {
|
||||
context: new HttpContext().set(SILENT_HTTP_ERROR, true)
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Savings accounts the dormancy job has marked.
|
||||
*
|
||||
|
||||
@@ -38,6 +38,12 @@
|
||||
}
|
||||
</div>
|
||||
}
|
||||
@if (!createdClientId) {
|
||||
<mifosx-client-identity-check
|
||||
[legalFormType]="legalFormType"
|
||||
(identityChange)="onIdentityChange($event)"
|
||||
></mifosx-client-identity-check>
|
||||
}
|
||||
<mifosx-client-general-step
|
||||
[clientTemplate]="clientTemplate"
|
||||
[prefillData]="prefillData"
|
||||
@@ -79,6 +85,7 @@
|
||||
[verificationDateOfBirth]="clientGeneralStep?.verificationDateOfBirth || null"
|
||||
[readOnly]="isTabReadOnly(1)"
|
||||
[calculatedRisk]="clientGeneralStep?.calculatedRisk"
|
||||
[identityPrefill]="identity"
|
||||
></mifosx-client-kyc-step>
|
||||
</div>
|
||||
|
||||
|
||||
@@ -23,6 +23,10 @@ import { ClientIndiaAddressStepComponent } from '../client-stepper/client-india-
|
||||
import { ClientEntityKycStepComponent } from '../client-stepper/client-entity-kyc-step/client-entity-kyc-step.component';
|
||||
import { ClientDatatableStepComponent } from '../client-stepper/client-datatable-step/client-datatable-step.component';
|
||||
import { ClientKycStepComponent } from '../client-stepper/client-kyc-step/client-kyc-step.component';
|
||||
import {
|
||||
ClientIdentityCheckComponent,
|
||||
ClientIdentityState
|
||||
} from '../client-stepper/client-identity-check/client-identity-check.component';
|
||||
import { ClientPreviewStepComponent } from '../client-stepper/client-preview-step/client-preview-step.component';
|
||||
import { ClientFamilyMembersStepComponent } from '../client-stepper/client-family-members-step/client-family-members-step.component';
|
||||
|
||||
@@ -60,6 +64,7 @@ import { STANDALONE_SHARED_IMPORTS } from 'app/standalone-shared.module';
|
||||
ClientEntityKycStepComponent,
|
||||
ClientDatatableStepComponent,
|
||||
ClientKycStepComponent,
|
||||
ClientIdentityCheckComponent,
|
||||
ClientPreviewStepComponent,
|
||||
ClientFamilyMembersStepComponent,
|
||||
MatProgressBarModule,
|
||||
@@ -79,6 +84,7 @@ export class CreateClientComponent {
|
||||
@ViewChild('clientIndiaAddress') clientIndiaAddressStep: ClientIndiaAddressStepComponent;
|
||||
@ViewChild(ClientEntityKycStepComponent) clientEntityKycStep: ClientEntityKycStepComponent;
|
||||
@ViewChild('clientKyc') clientKycStep: ClientKycStepComponent;
|
||||
@ViewChild(ClientIdentityCheckComponent) identityCheck: ClientIdentityCheckComponent;
|
||||
@ViewChild(ClientFamilyMembersStepComponent) clientFamilyMembersStep: ClientFamilyMembersStepComponent;
|
||||
@ViewChildren('dtclient') clientDatatables: QueryList<ClientDatatableStepComponent>;
|
||||
|
||||
@@ -87,6 +93,17 @@ export class CreateClientComponent {
|
||||
selectedTabIndex = 0;
|
||||
createdClientId = '';
|
||||
savingClient = false;
|
||||
/**
|
||||
* Result of the existing-customer check at the top of the General step. While it says the customer already exists
|
||||
* (or the identifiers disagree) a new client must not be created; otherwise the identifiers typed there are filled
|
||||
* into the KYC step.
|
||||
*/
|
||||
identity: ClientIdentityState | null = null;
|
||||
|
||||
onIdentityChange(state: ClientIdentityState): void {
|
||||
this.identity = state;
|
||||
}
|
||||
|
||||
/** Pre-fill data passed from "Create as Client" on a manual family member */
|
||||
prefillData: any = null;
|
||||
|
||||
@@ -138,6 +155,8 @@ export class CreateClientComponent {
|
||||
this.highestTabReached = 0;
|
||||
this.validationError = '';
|
||||
this.prefillData = null;
|
||||
this.identity = null;
|
||||
this.identityCheck?.reset();
|
||||
this.clientGeneralStep?.resetForNewClient();
|
||||
this.clientKycStep?.resetForNewClient();
|
||||
this.clientIndiaAddressStep?.resetForNewClient();
|
||||
@@ -212,6 +231,10 @@ export class CreateClientComponent {
|
||||
const idx = this.selectedTabIndex;
|
||||
|
||||
if (idx === 0) {
|
||||
// The customer is already on the books (any branch), or the identifiers belong to different customers.
|
||||
if (!this.createdClientId && (this.identity?.blocking || this.identity?.status === 'checking')) return false;
|
||||
// A PAN / Aadhaar / GSTIN typed in the wrong format is neither looked up nor carried on, so it must be fixed or cleared.
|
||||
if (!this.createdClientId && this.identityCheck?.hasInvalidInput) return false;
|
||||
if (!this.clientGeneralStep || !this.clientGeneralForm) return false;
|
||||
if (this.clientGeneralStep.sanctionsBlocked) return false;
|
||||
// A number the officer rejected on the DIP result must be replaced, not carried forward.
|
||||
@@ -274,6 +297,17 @@ export class CreateClientComponent {
|
||||
if (this.savingClient) return 'Saving — please wait…';
|
||||
if (this.currentStepValid) return '';
|
||||
const idx = this.selectedTabIndex;
|
||||
if (idx === 0 && !this.createdClientId && this.identityCheck?.hasInvalidInput) {
|
||||
return 'Correct the PAN / Aadhaar / GSTIN format in the Existing customer check, or clear the field.';
|
||||
}
|
||||
if (idx === 0 && !this.createdClientId && this.identity?.status === 'checking') {
|
||||
return 'Checking whether this customer already exists…';
|
||||
}
|
||||
if (idx === 0 && !this.createdClientId && this.identity?.blocking) {
|
||||
return this.identity.status === 'conflict'
|
||||
? 'The PAN / Aadhaar / GSTIN entered belong to different customers — correct them or ask a manager.'
|
||||
: 'This customer already exists — a new client is not created for the same person. Open the existing customer instead.';
|
||||
}
|
||||
if (idx === 0 && this.clientGeneralStep?.sanctionsBlocked) {
|
||||
const disposition = this.clientGeneralStep.sanctionsDisposition;
|
||||
if (disposition === 'FALSE_POSITIVE') {
|
||||
@@ -358,6 +392,19 @@ export class CreateClientComponent {
|
||||
onNext() {
|
||||
// If on General tab and client not yet created — save first
|
||||
if (this.selectedTabIndex === 0 && !this.createdClientId) {
|
||||
// Defensive re-check: the button is disabled while this holds, but a second client for the same person is the
|
||||
// one mistake this screen exists to prevent.
|
||||
if (this.identityCheck?.hasInvalidInput) {
|
||||
this.identityCheck.markAllAsTouched();
|
||||
this.snackBar.open(this.currentStepBlockerMessage, 'Close', { duration: 6000 });
|
||||
return;
|
||||
}
|
||||
if (this.identity?.blocking) {
|
||||
this.snackBar.open(this.currentStepBlockerMessage || 'This customer already exists.', 'Close', {
|
||||
duration: 6000
|
||||
});
|
||||
return;
|
||||
}
|
||||
if (!this.clientGeneralForm?.valid) {
|
||||
this.clientGeneralForm?.markAllAsTouched();
|
||||
setTimeout(() => {
|
||||
|
||||
@@ -0,0 +1,34 @@
|
||||
/**
|
||||
* 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/.
|
||||
*/
|
||||
|
||||
/** An existing customer found by PAN / Aadhaar fingerprint / GSTIN. Identifiers are masked by the server. */
|
||||
export interface ClientIdentityMatch {
|
||||
clientId: number;
|
||||
displayName: string;
|
||||
cifNumber: string;
|
||||
officeId: number;
|
||||
officeName: string;
|
||||
legalFormEnum: number | null;
|
||||
kycStatus: number;
|
||||
/** True when the caller's branch can open this client; false when it sits in another branch. */
|
||||
visibleToCaller: boolean;
|
||||
panMasked: string | null;
|
||||
aadhaarMasked: string | null;
|
||||
gstinMasked: string | null;
|
||||
matchedBy: Array<'PAN' | 'AADHAAR' | 'GSTIN'>;
|
||||
}
|
||||
|
||||
export interface ClientIdentityLookupResult {
|
||||
/** Any ONE of PAN / Aadhaar / GSTIN matched an existing customer: the same person or entity. */
|
||||
matched: boolean;
|
||||
/** The identifiers point at different customers: bad data or fraud, not a customer to reuse. */
|
||||
conflict: boolean;
|
||||
matches: ClientIdentityMatch[];
|
||||
/** A phone or email already used by another client. Never means "the same customer". */
|
||||
contactConflicts: { mobileNo: boolean; emailAddress: boolean };
|
||||
}
|
||||
@@ -0,0 +1,52 @@
|
||||
/**
|
||||
* 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 { createHash } from 'crypto';
|
||||
import { aadhaarHash, sha256Hex } from './aadhaar-hash.util';
|
||||
|
||||
describe('sha256Hex', () => {
|
||||
it('matches the published SHA-256 test vectors', () => {
|
||||
expect(sha256Hex('')).toBe('e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855');
|
||||
expect(sha256Hex('abc')).toBe('ba7816bf8f01cfea414140de5dae2223b00361a396177a9cb410ff61f20015ad');
|
||||
expect(sha256Hex('abcdbcdecdefdefgefghfghighijhijkijkljklmklmnlmnomnopnopq')).toBe(
|
||||
'248d6a61d20638b8e5c026930c3e6039a33ce45964ff2167f6ecedd419db06c1'
|
||||
);
|
||||
});
|
||||
|
||||
it('agrees with Node on 12-digit inputs, including ones that need a second block boundary', () => {
|
||||
for (const value of [
|
||||
'000000000000',
|
||||
'123456789012',
|
||||
'999999999999',
|
||||
'0'.repeat(55),
|
||||
'0'.repeat(56),
|
||||
'0'.repeat(64)
|
||||
]) {
|
||||
expect(sha256Hex(value)).toBe(createHash('sha256').update(value).digest('hex'));
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe('aadhaarHash', () => {
|
||||
it('hashes a 12-digit number', () => {
|
||||
expect(aadhaarHash('123456789012')).toBe(createHash('sha256').update('123456789012').digest('hex'));
|
||||
});
|
||||
|
||||
it('ignores the spaces and hyphens people type between the groups', () => {
|
||||
expect(aadhaarHash('1234 5678 9012')).toBe(aadhaarHash('123456789012'));
|
||||
expect(aadhaarHash('1234-5678-9012')).toBe(aadhaarHash('123456789012'));
|
||||
});
|
||||
|
||||
it('returns null for anything that is not a full number', () => {
|
||||
expect(aadhaarHash('')).toBeNull();
|
||||
expect(aadhaarHash(null)).toBeNull();
|
||||
expect(aadhaarHash('XXXXXXXX1234')).toBeNull();
|
||||
expect(aadhaarHash('XXXXXXXX0000')).toBeNull();
|
||||
expect(aadhaarHash('12345678901')).toBeNull();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,185 @@
|
||||
/**
|
||||
* 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 comparable fingerprint of an Aadhaar number: SHA-256 of the 12 digits, as 64 lowercase hex characters.
|
||||
*
|
||||
* Only this fingerprint is sent to the server. The KYC create/update calls are recorded in an audit table and a log
|
||||
* on the server, so the 12-digit number must not be in the request.
|
||||
*
|
||||
* A plain, unkeyed SHA-256 of 12 digits can be reversed by trying every number, so this is a lookup key and not
|
||||
* protection against someone who can read the stored value.
|
||||
*
|
||||
* Written out in full instead of using `crypto.subtle` because that API is asynchronous and only exists in secure
|
||||
* contexts, while the callers (a form-value getter, a blur handler) need the answer synchronously and the app may be
|
||||
* served over plain HTTP.
|
||||
*/
|
||||
|
||||
const K = [
|
||||
0x428a2f98,
|
||||
0x71374491,
|
||||
0xb5c0fbcf,
|
||||
0xe9b5dba5,
|
||||
0x3956c25b,
|
||||
0x59f111f1,
|
||||
0x923f82a4,
|
||||
0xab1c5ed5,
|
||||
0xd807aa98,
|
||||
0x12835b01,
|
||||
0x243185be,
|
||||
0x550c7dc3,
|
||||
0x72be5d74,
|
||||
0x80deb1fe,
|
||||
0x9bdc06a7,
|
||||
0xc19bf174,
|
||||
0xe49b69c1,
|
||||
0xefbe4786,
|
||||
0x0fc19dc6,
|
||||
0x240ca1cc,
|
||||
0x2de92c6f,
|
||||
0x4a7484aa,
|
||||
0x5cb0a9dc,
|
||||
0x76f988da,
|
||||
0x983e5152,
|
||||
0xa831c66d,
|
||||
0xb00327c8,
|
||||
0xbf597fc7,
|
||||
0xc6e00bf3,
|
||||
0xd5a79147,
|
||||
0x06ca6351,
|
||||
0x14292967,
|
||||
0x27b70a85,
|
||||
0x2e1b2138,
|
||||
0x4d2c6dfc,
|
||||
0x53380d13,
|
||||
0x650a7354,
|
||||
0x766a0abb,
|
||||
0x81c2c92e,
|
||||
0x92722c85,
|
||||
0xa2bfe8a1,
|
||||
0xa81a664b,
|
||||
0xc24b8b70,
|
||||
0xc76c51a3,
|
||||
0xd192e819,
|
||||
0xd6990624,
|
||||
0xf40e3585,
|
||||
0x106aa070,
|
||||
0x19a4c116,
|
||||
0x1e376c08,
|
||||
0x2748774c,
|
||||
0x34b0bcb5,
|
||||
0x391c0cb3,
|
||||
0x4ed8aa4a,
|
||||
0x5b9cca4f,
|
||||
0x682e6ff3,
|
||||
0x748f82ee,
|
||||
0x78a5636f,
|
||||
0x84c87814,
|
||||
0x8cc70208,
|
||||
0x90befffa,
|
||||
0xa4506ceb,
|
||||
0xbef9a3f7,
|
||||
0xc67178f2
|
||||
];
|
||||
|
||||
function rotr(x: number, n: number): number {
|
||||
return (x >>> n) | (x << (32 - n));
|
||||
}
|
||||
|
||||
/** SHA-256 of a string (UTF-8 encoded), as lowercase hex. */
|
||||
export function sha256Hex(text: string): string {
|
||||
const bytes: number[] = [];
|
||||
for (const ch of unescape(encodeURIComponent(text))) {
|
||||
bytes.push(ch.charCodeAt(0));
|
||||
}
|
||||
const bitLength = bytes.length * 8;
|
||||
|
||||
bytes.push(0x80);
|
||||
while (bytes.length % 64 !== 56) {
|
||||
bytes.push(0);
|
||||
}
|
||||
// 64-bit big-endian message length; inputs here are tiny so the high word is always 0.
|
||||
const high = Math.floor(bitLength / 0x100000000);
|
||||
const low = bitLength >>> 0;
|
||||
for (const word of [
|
||||
high,
|
||||
low
|
||||
]) {
|
||||
bytes.push((word >>> 24) & 0xff, (word >>> 16) & 0xff, (word >>> 8) & 0xff, word & 0xff);
|
||||
}
|
||||
|
||||
const h = [
|
||||
0x6a09e667,
|
||||
0xbb67ae85,
|
||||
0x3c6ef372,
|
||||
0xa54ff53a,
|
||||
0x510e527f,
|
||||
0x9b05688c,
|
||||
0x1f83d9ab,
|
||||
0x5be0cd19
|
||||
];
|
||||
const w = new Array<number>(64);
|
||||
|
||||
for (let offset = 0; offset < bytes.length; offset += 64) {
|
||||
for (let i = 0; i < 16; i++) {
|
||||
const j = offset + i * 4;
|
||||
w[i] = (bytes[j] << 24) | (bytes[j + 1] << 16) | (bytes[j + 2] << 8) | bytes[j + 3];
|
||||
}
|
||||
for (let i = 16; i < 64; i++) {
|
||||
const s0 = rotr(w[i - 15], 7) ^ rotr(w[i - 15], 18) ^ (w[i - 15] >>> 3);
|
||||
const s1 = rotr(w[i - 2], 17) ^ rotr(w[i - 2], 19) ^ (w[i - 2] >>> 10);
|
||||
w[i] = (w[i - 16] + s0 + w[i - 7] + s1) | 0;
|
||||
}
|
||||
|
||||
let [
|
||||
a,
|
||||
b,
|
||||
c,
|
||||
d,
|
||||
e,
|
||||
f,
|
||||
g,
|
||||
hh
|
||||
] = h;
|
||||
for (let i = 0; i < 64; i++) {
|
||||
const S1 = rotr(e, 6) ^ rotr(e, 11) ^ rotr(e, 25);
|
||||
const ch = (e & f) ^ (~e & g);
|
||||
const t1 = (hh + S1 + ch + K[i] + w[i]) | 0;
|
||||
const S0 = rotr(a, 2) ^ rotr(a, 13) ^ rotr(a, 22);
|
||||
const maj = (a & b) ^ (a & c) ^ (b & c);
|
||||
const t2 = (S0 + maj) | 0;
|
||||
hh = g;
|
||||
g = f;
|
||||
f = e;
|
||||
e = (d + t1) | 0;
|
||||
d = c;
|
||||
c = b;
|
||||
b = a;
|
||||
a = (t1 + t2) | 0;
|
||||
}
|
||||
h[0] = (h[0] + a) | 0;
|
||||
h[1] = (h[1] + b) | 0;
|
||||
h[2] = (h[2] + c) | 0;
|
||||
h[3] = (h[3] + d) | 0;
|
||||
h[4] = (h[4] + e) | 0;
|
||||
h[5] = (h[5] + f) | 0;
|
||||
h[6] = (h[6] + g) | 0;
|
||||
h[7] = (h[7] + hh) | 0;
|
||||
}
|
||||
|
||||
return h.map((x) => (x >>> 0).toString(16).padStart(8, '0')).join('');
|
||||
}
|
||||
|
||||
/**
|
||||
* The fingerprint for a 12-digit Aadhaar, or null when the value is not one (too short, masked, a placeholder).
|
||||
* Spaces and hyphens people type between the groups are ignored.
|
||||
*/
|
||||
export function aadhaarHash(aadhaar: string | null | undefined): string | null {
|
||||
const digits = (aadhaar ?? '').replace(/[\s-]/g, '');
|
||||
return /^\d{12}$/.test(digits) ? sha256Hex(digits) : null;
|
||||
}
|
||||
@@ -6,7 +6,7 @@
|
||||
* file, You can obtain one at http://mozilla.org/MPL/2.0/.
|
||||
*/
|
||||
|
||||
import { toProperNameCase } from './name-case.util';
|
||||
import { toPersonNameCase, toProperNameCase } from './name-case.util';
|
||||
|
||||
describe('toProperNameCase', () => {
|
||||
it('raises a lower-case first letter on every word', () => {
|
||||
@@ -53,3 +53,20 @@ describe('toProperNameCase', () => {
|
||||
expect(toProperNameCase(undefined)).toBe('');
|
||||
});
|
||||
});
|
||||
|
||||
describe('toPersonNameCase', () => {
|
||||
it('title-cases every word, lowering mixed or upper case', () => {
|
||||
expect(toPersonNameCase('keshav SIngh sisodiya')).toBe('Keshav Singh Sisodiya');
|
||||
expect(toPersonNameCase('KESHAV')).toBe('Keshav');
|
||||
});
|
||||
|
||||
it('keeps hyphen and apostrophe name prefixes', () => {
|
||||
expect(toPersonNameCase('JEAN-marie')).toBe('Jean-Marie');
|
||||
expect(toPersonNameCase("D'SOUZA")).toBe("D'Souza");
|
||||
});
|
||||
|
||||
it('returns an empty string for blank or non-string input', () => {
|
||||
expect(toPersonNameCase(' ')).toBe('');
|
||||
expect(toPersonNameCase(null)).toBe('');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -62,3 +62,17 @@ export function toProperNameCase(value: string | null | undefined): string {
|
||||
}
|
||||
return collapsed.split(' ').map(capitalizeWord).join(' ');
|
||||
}
|
||||
|
||||
/**
|
||||
* Title case for an individual's first, middle and last name: each word's first letter raised,
|
||||
* the rest lowered, so 'SIngh' and 'singh' both become 'Singh'. This is what the Full Name line on
|
||||
* the general step has always shown; using it for the stored value too keeps the preview and the
|
||||
* saved client the same as that line. Entity names stay on {@link toProperNameCase} — their
|
||||
* spelling is matched against the registered PAN name.
|
||||
*/
|
||||
export function toPersonNameCase(value: string | null | undefined): string {
|
||||
if (typeof value !== 'string') {
|
||||
return '';
|
||||
}
|
||||
return toProperNameCase(value.toLowerCase());
|
||||
}
|
||||
|
||||
@@ -35,6 +35,7 @@ export class CacheInterceptor implements HttpInterceptor {
|
||||
'/rbireservereturn',
|
||||
'/rbireserveconfig',
|
||||
'/tdsconfig',
|
||||
'/profitappropriationconfig',
|
||||
'/tds/deposit-interest',
|
||||
// Declarations and challans are entered on the very screens that read them back.
|
||||
'/tds/declarations',
|
||||
@@ -53,7 +54,15 @@ export class CacheInterceptor implements HttpInterceptor {
|
||||
// deposit may be closed at all - a stale copy would show a released lien as still blocking.
|
||||
'/liens',
|
||||
// The RD Dashboard counts late installments as of today; a cached copy would hide a payment just taken.
|
||||
'/recurringdepositaccounts/dashboard'
|
||||
'/recurringdepositaccounts/dashboard',
|
||||
// Notes are also written by action screens (approve, close, deposit...), so a cached list would hide them.
|
||||
'/notes',
|
||||
// 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.
|
||||
'/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 {
|
||||
@@ -87,18 +96,22 @@ export class CacheInterceptor implements HttpInterceptor {
|
||||
// Create new response to avoid side-effects
|
||||
subscriber.next(new HttpResponse(cachedData as Object));
|
||||
subscriber.complete();
|
||||
} else {
|
||||
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()
|
||||
);
|
||||
return undefined;
|
||||
}
|
||||
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();
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,16 @@
|
||||
/**
|
||||
* 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 { HttpContextToken } from '@angular/common/http';
|
||||
|
||||
/**
|
||||
* Per-request opt-out of the top progress bar. Set via `{ context: new HttpContext().set(NO_PROGRESS_BAR, true) }`
|
||||
* on background calls the user did not ask for (such as the Approvals badge listening for changes), which would
|
||||
* otherwise keep the bar running or make it flash on a screen that has not changed.
|
||||
*/
|
||||
export const NO_PROGRESS_BAR = new HttpContextToken<boolean>(() => false);
|
||||
@@ -16,6 +16,7 @@ import { finalize } from 'rxjs/operators';
|
||||
|
||||
/** Custom Services */
|
||||
import { ProgressBarService } from './progress-bar.service';
|
||||
import { NO_PROGRESS_BAR } from './no-progress.token';
|
||||
|
||||
/**
|
||||
* Http Request interceptor to start/stop loading the progress bar.
|
||||
@@ -29,6 +30,9 @@ export class ProgressInterceptor implements HttpInterceptor {
|
||||
* and stop when a response or error is received.
|
||||
*/
|
||||
intercept(request: HttpRequest<any>, next: HttpHandler): Observable<HttpEvent<any>> {
|
||||
if (request.context.get(NO_PROGRESS_BAR)) {
|
||||
return next.handle(request);
|
||||
}
|
||||
this.progressBarService.increase();
|
||||
return next.handle(request).pipe(
|
||||
finalize(() => {
|
||||
|
||||
@@ -9,6 +9,7 @@
|
||||
/** Angular Imports */
|
||||
import { ActivatedRouteSnapshot, DetachedRouteHandle, RouteReuseStrategy } from '@angular/router';
|
||||
import { Injectable } from '@angular/core';
|
||||
import { ShellComponent } from '../shell/shell.component';
|
||||
|
||||
/**
|
||||
* A route strategy allowing for explicit route reuse.
|
||||
@@ -31,7 +32,22 @@ export class RouteReusableStrategy extends RouteReuseStrategy {
|
||||
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 {
|
||||
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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -131,6 +131,14 @@
|
||||
</svg>
|
||||
My Closure Requests
|
||||
</a>
|
||||
<a
|
||||
class="cashier-nav__item cashier-nav__item--sec"
|
||||
[class.cashier-nav__item--active]="currentUrl.includes('/clerk/voucher-correction')"
|
||||
[routerLink]="['/clerk/voucher-correction']"
|
||||
>
|
||||
<fa-icon icon="undo"></fa-icon>
|
||||
Voucher Correction
|
||||
</a>
|
||||
@if (clerkAccountLoaded) {
|
||||
<div class="cashier-nav__item-wrap">
|
||||
<a
|
||||
@@ -287,6 +295,13 @@
|
||||
<a class="cashier-nav__item" [routerLink]="['/cashier/my-work']" routerLinkActive="cashier-nav__item--active">
|
||||
<fa-icon icon="user-check"></fa-icon> My Vouchers
|
||||
</a>
|
||||
<a
|
||||
class="cashier-nav__item"
|
||||
[routerLink]="['/cashier/voucher-correction']"
|
||||
routerLinkActive="cashier-nav__item--active"
|
||||
>
|
||||
<fa-icon icon="undo"></fa-icon> Voucher Correction
|
||||
</a>
|
||||
<a
|
||||
class="cashier-nav__item"
|
||||
[routerLink]="['/cashier/received-report']"
|
||||
@@ -450,6 +465,11 @@
|
||||
*mifosxHasPermission="'MENU_TASKS'"
|
||||
>
|
||||
<fa-icon icon="check" class="ni-ico"></fa-icon>Approvals
|
||||
@if (approvalTotal > 0) {
|
||||
<span class="ni-badge" title="Requests waiting for approval">{{
|
||||
approvalTotal > 99 ? '99+' : approvalTotal
|
||||
}}</span>
|
||||
}
|
||||
</a>
|
||||
<a class="ni" tabindex="0" [matMenuTriggerFor]="cashierMenu" *mifosxHasPermission="'MENU_CASHIER'">
|
||||
<fa-icon icon="money-bill" class="ni-ico"></fa-icon>Cashier
|
||||
@@ -1164,6 +1184,9 @@
|
||||
<button mat-menu-item tabindex="0" [routerLink]="['/system/denomination-master']">
|
||||
<fa-icon icon="coins" class="menu-item-icon"></fa-icon> Cash Denomination
|
||||
</button>
|
||||
<button mat-menu-item tabindex="0" [routerLink]="['/cashier/voucher-correction']">
|
||||
<fa-icon icon="undo" class="menu-item-icon"></fa-icon> Voucher Correction
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- Transfers & Settlement -->
|
||||
@@ -2018,11 +2041,11 @@
|
||||
</a>
|
||||
<a
|
||||
class="gl-link"
|
||||
[class.hidden]="!matchesRbiSearch('ALM')"
|
||||
(click)="rbiMenuOpen = false; openAlmReportDialog()"
|
||||
[class.hidden]="!matchesRbiSearch('ALM Average Liabilities and Assets Time Bucket Breakdown')"
|
||||
(click)="rbiMenuOpen = false; openAlmBucketBreakdownDialog()"
|
||||
>
|
||||
<fa-icon icon="balance-scale" class="gl-icon" style="color: #4f46e5"></fa-icon> ALM (Asset Liability
|
||||
Management)
|
||||
<fa-icon icon="balance-scale" class="gl-icon" style="color: #4f46e5"></fa-icon> ALM (Average Liabilities
|
||||
and Assets) – Time Bucket Breakdown
|
||||
</a>
|
||||
<a class="gl-link" [class.hidden]="!matchesRbiSearch('Balance Sheet')" (click)="rbiMenuOpen = false">
|
||||
<fa-icon icon="file-alt" class="gl-icon"></fa-icon> Balance Sheet (BR Act & Others)
|
||||
|
||||
@@ -1082,3 +1082,18 @@ $gray-700: #334155;
|
||||
width: $bar;
|
||||
}
|
||||
}
|
||||
|
||||
// How many requests are waiting under Approvals.
|
||||
.ni-badge {
|
||||
min-width: 16px;
|
||||
height: 16px;
|
||||
margin-left: 4px;
|
||||
padding: 0 5px;
|
||||
border-radius: 999px;
|
||||
background: #f59e0b;
|
||||
color: #fff;
|
||||
font-size: 10px;
|
||||
font-weight: 700;
|
||||
line-height: 16px;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
@@ -17,6 +17,7 @@ import { TenantMenuAccessService } from '../../directives/has-permission/tenant-
|
||||
|
||||
/** rxjs Imports */
|
||||
import { Observable, Subscription } from 'rxjs';
|
||||
import { ApprovalCountsService } from '../../tasks/approval-counts.service';
|
||||
import { map, filter } from 'rxjs/operators';
|
||||
|
||||
/** Custom Services */
|
||||
@@ -68,6 +69,11 @@ export class ShellComponent implements OnInit, OnDestroy {
|
||||
private authService = inject(AuthenticationService);
|
||||
private clerkUiStateService = inject(ClerkUiStateService);
|
||||
private tenantMenuAccessService = inject(TenantMenuAccessService);
|
||||
private approvalCounts = inject(ApprovalCountsService);
|
||||
|
||||
/** Requests waiting across every Approval Center tab this user can see, shown on the Approvals menu. */
|
||||
approvalTotal = 0;
|
||||
private approvalTotalSub?: Subscription;
|
||||
|
||||
/** Subscription to breakpoint observer for handset. */
|
||||
isHandset$: Observable<boolean> = this.breakpointObserver
|
||||
@@ -75,8 +81,9 @@ export class ShellComponent implements OnInit, OnDestroy {
|
||||
.pipe(map((result) => result.matches));
|
||||
/** Sets the initial state of sidenav as collapsed. Not collapsed if false. */
|
||||
sidenavCollapsed = true;
|
||||
/** Progress bar mode. */
|
||||
progressBarMode: string;
|
||||
/** Progress bar mode. It starts from what is running right now: the template shows the bar for anything that is not
|
||||
* '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. */
|
||||
progressBar$: Subscription;
|
||||
/** Hides nav/sidebar for full-screen cashier routes. */
|
||||
@@ -144,6 +151,13 @@ export class ShellComponent implements OnInit, OnDestroy {
|
||||
* Subscribes to progress bar to update its mode.
|
||||
*/
|
||||
ngOnInit() {
|
||||
// The Approvals badge: counts refresh every minute while the app is open, and again whenever the Approval Center opens.
|
||||
this.approvalTotalSub = this.approvalCounts.total$.subscribe((total) => {
|
||||
this.approvalTotal = total;
|
||||
this.cdr.markForCheck();
|
||||
});
|
||||
this.approvalCounts.start();
|
||||
|
||||
this.progressBar$ = this.progressBarService.updateProgressBar.subscribe((mode: string) => {
|
||||
this.progressBarMode = mode;
|
||||
this.cdr.detectChanges();
|
||||
@@ -364,10 +378,31 @@ export class ShellComponent implements OnInit, OnDestroy {
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Opens the ALM Time-Bucket Breakdown: which balance-sheet item goes to which bucket of the
|
||||
* Structural Liquidity Statement. Rules are editable and kept per browser.
|
||||
*/
|
||||
async openAlmBucketBreakdownDialog(): Promise<void> {
|
||||
const { AlmBucketBreakdownDialogComponent } =
|
||||
await import('../../home/dashboard/alm-bucket-breakdown-dialog/alm-bucket-breakdown-dialog.component');
|
||||
this.dialog.open(AlmBucketBreakdownDialogComponent, {
|
||||
width: '94vw',
|
||||
maxWidth: '1320px',
|
||||
maxHeight: '92vh',
|
||||
panelClass: 'crr-slr-dialog-panel',
|
||||
autoFocus: false,
|
||||
disableClose: false,
|
||||
hasBackdrop: true,
|
||||
backdropClass: 'alm-dlg-backdrop'
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Unsubscribes from progress bar.
|
||||
*/
|
||||
ngOnDestroy() {
|
||||
this.approvalTotalSub?.unsubscribe();
|
||||
this.approvalCounts.stop();
|
||||
if (this.progressBar$) {
|
||||
this.progressBar$.unsubscribe();
|
||||
}
|
||||
|
||||
+17
-3
@@ -838,9 +838,23 @@
|
||||
<span class="ni-mono">{{ c.clientCif || '—' }}</span>
|
||||
</td>
|
||||
<td class="ni-td ni-td--center">
|
||||
<span class="ni-pct-badge" [class.ni-pct-badge--full]="c.sharePercentage === 100">
|
||||
{{ c.sharePercentage }}%
|
||||
</span>
|
||||
@if (readOnly) {
|
||||
<span class="ni-pct-badge" [class.ni-pct-badge--full]="c.sharePercentage === 100">
|
||||
{{ c.sharePercentage }}%
|
||||
</span>
|
||||
} @else {
|
||||
<span class="ni-pct-input">
|
||||
<input
|
||||
#pct
|
||||
type="number"
|
||||
min="0"
|
||||
max="100"
|
||||
[value]="c.sharePercentage"
|
||||
(change)="setPartnerShare(c, pct.value); pct.value = '' + c.sharePercentage"
|
||||
aria-label="Ownership percentage"
|
||||
/>%
|
||||
</span>
|
||||
}
|
||||
</td>
|
||||
<td class="ni-td ni-td--center">
|
||||
<button
|
||||
|
||||
+25
@@ -465,6 +465,31 @@ $ni-bg: #f9fafb;
|
||||
color: #374151;
|
||||
}
|
||||
|
||||
.ni-pct-input {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 2px;
|
||||
font-size: 12px;
|
||||
font-weight: 700;
|
||||
color: $ni-primary;
|
||||
|
||||
input {
|
||||
width: 52px;
|
||||
padding: 3px 6px;
|
||||
border: 1px solid #c7d2fe;
|
||||
border-radius: 6px;
|
||||
background: #eef2ff;
|
||||
font: inherit;
|
||||
color: inherit;
|
||||
text-align: right;
|
||||
|
||||
&:focus {
|
||||
outline: 2px solid $ni-primary;
|
||||
outline-offset: 0;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.ni-pct-badge {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
|
||||
+51
@@ -29,6 +29,8 @@ import { STANDALONE_SHARED_IMPORTS } from 'app/standalone-shared.module';
|
||||
import { IntroducerSearchComponent } from 'app/shared/introducer-search/introducer-search.component';
|
||||
import { AddRelatedClientDialogComponent } from './add-related-client-dialog/add-related-client-dialog.component';
|
||||
import { AlertService } from 'app/core/alert/alert.service';
|
||||
import { forkJoin, of } from 'rxjs';
|
||||
import { catchError } from 'rxjs/operators';
|
||||
|
||||
/**
|
||||
* Current Account Details Step
|
||||
@@ -207,6 +209,9 @@ export class CurrentAccountDetailsStepComponent implements OnInit {
|
||||
this.autoDetectAccountStatus();
|
||||
}
|
||||
}
|
||||
if (this.isNonIndividualClient && !this.isEditMode && !this.readOnly) {
|
||||
this.prefillPartnersFromClientSignatories(client);
|
||||
}
|
||||
this.cdr.detectChanges();
|
||||
});
|
||||
}
|
||||
@@ -622,6 +627,52 @@ export class CurrentAccountDetailsStepComponent implements OnInit {
|
||||
}
|
||||
|
||||
// ── Non-Individual (existing) helpers ──────────────────────────────────
|
||||
/**
|
||||
* A new entity account starts with the authorised signatories recorded on the client at creation
|
||||
* (clientNonPersonDetails.authorizedSignatoryClientIds), so they need not be added again. Ownership is
|
||||
* not captured at client creation, so it starts at 0 and is entered in the table; Add Partner stays
|
||||
* available for anyone else.
|
||||
*/
|
||||
private prefillPartnersFromClientSignatories(client: any): void {
|
||||
if (this.relatedClients.length > 0) {
|
||||
return;
|
||||
}
|
||||
const ids: number[] = String(client?.clientNonPersonDetails?.authorizedSignatoryClientIds ?? '')
|
||||
.split(',')
|
||||
.map((id) => Number(id.trim()))
|
||||
.filter((id, i, all) => id > 0 && id !== this.primaryClientId && all.indexOf(id) === i);
|
||||
if (!ids.length) {
|
||||
return;
|
||||
}
|
||||
// One lookup per signatory for the CIF; a signatory that can no longer be read is skipped, not fatal.
|
||||
forkJoin(
|
||||
ids.map((id) => this.clientsService.getClientData(String(id), true).pipe(catchError(() => of(null))))
|
||||
).subscribe((signatories: any[]) => {
|
||||
// The user may have added partners by hand while the lookups ran — never overwrite those.
|
||||
if (this.relatedClients.length > 0) {
|
||||
return;
|
||||
}
|
||||
this.relatedClients = signatories
|
||||
.filter((s) => !!s?.id)
|
||||
.map((s) => ({
|
||||
clientId: s.id,
|
||||
clientName: s.displayName ?? s.fullname ?? '',
|
||||
clientCif: s.cifNumber ?? s.cifNo ?? '',
|
||||
sharePercentage: 0,
|
||||
isMandatory: false
|
||||
}));
|
||||
this.autoSetSignatoryRights();
|
||||
this.cdr.detectChanges();
|
||||
});
|
||||
}
|
||||
|
||||
/** Inline ownership edit — clamped so the partners' total never exceeds 100%. */
|
||||
setPartnerShare(client: any, raw: any): void {
|
||||
const othersTotal = this.totalSharePercentage - (client.sharePercentage || 0);
|
||||
const value = Math.round(Number(raw) || 0);
|
||||
client.sharePercentage = Math.min(Math.max(value, 0), Math.max(0, 100 - othersTotal));
|
||||
}
|
||||
|
||||
openAddRelatedClientDialog(): void {
|
||||
const excludedClientIds = [
|
||||
...(this.primaryClientId ? [this.primaryClientId] : []),
|
||||
|
||||
+77
-3
@@ -56,12 +56,13 @@
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
// A ticked day or voucher, in the screen's own green rather than the shared sheet's blue.
|
||||
.option-picked {
|
||||
border-color: #2563eb !important;
|
||||
background: #eff6ff !important;
|
||||
border-color: #0f766e !important;
|
||||
background: #e6f4ee !important;
|
||||
|
||||
.option-check {
|
||||
color: #2563eb;
|
||||
color: #0f766e;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -93,3 +94,76 @@
|
||||
border-radius: 6px;
|
||||
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">
|
||||
<i class="fas fa-arrow-left"></i> Deposits
|
||||
</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 class="dte-card">
|
||||
<form [formGroup]="entryForm">
|
||||
<div class="dte-info-panels">
|
||||
<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>
|
||||
<dt>Name</dt>
|
||||
<dd>{{ agentInfo.name || '—' }}</dd>
|
||||
@@ -28,17 +34,22 @@
|
||||
<dd>{{ agentInfo.city || '—' }}</dd>
|
||||
<dt>A/C</dt>
|
||||
<dd>{{ agentInfo.accountNo || '—' }}</dd>
|
||||
<dt>Guarantor</dt>
|
||||
<dd>{{ agentInfo.guarantor1 || '—' }}</dd>
|
||||
<dt>Guarantor</dt>
|
||||
<dd>{{ agentInfo.guarantor2 || '—' }}</dd>
|
||||
<!-- One line for each guarantor the agent actually has - not two fixed lines, one of them empty. -->
|
||||
@for (guarantor of agentInfo.guarantors; track $index; let i = $index) {
|
||||
<dt>{{ agentInfo.guarantors.length > 1 ? 'Guarantor ' + (i + 1) : 'Guarantor' }}</dt>
|
||||
<dd>{{ guarantor }}</dd>
|
||||
} @empty {
|
||||
<dt>Guarantor</dt>
|
||||
<dd>—</dd>
|
||||
}
|
||||
<dt>Curr Bal</dt>
|
||||
<dd>{{ agentInfo.currentBalance | number: '1.2-2' }}</dd>
|
||||
</dl>
|
||||
</div>
|
||||
|
||||
<div class="dte-info-panel">
|
||||
<div class="dte-info-panel__title">Party Information</div>
|
||||
<!-- 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 dte-info-panel--party">
|
||||
<div class="dte-info-panel__title"><i class="fas fa-user"></i>Party Information</div>
|
||||
@if (partyInfo) {
|
||||
<dl>
|
||||
<dt>Client Id</dt>
|
||||
@@ -79,15 +90,62 @@
|
||||
</dl>
|
||||
} @else {
|
||||
<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.
|
||||
</p>
|
||||
}
|
||||
</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">
|
||||
<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-select required formControlName="agentId" (selectionChange)="loadAgent()">
|
||||
@for (agent of selectableAgents; track agent.id) {
|
||||
@@ -112,7 +170,7 @@
|
||||
}
|
||||
</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>
|
||||
<!-- 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. -->
|
||||
@@ -127,7 +185,7 @@
|
||||
<mat-datepicker #txnDatePicker></mat-datepicker>
|
||||
</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-select required formControlName="transactionType">
|
||||
@for (type of transactionTypes; track type.label) {
|
||||
@@ -140,100 +198,43 @@
|
||||
}
|
||||
</mat-select>
|
||||
</mat-form-field>
|
||||
</div>
|
||||
|
||||
<!--
|
||||
What the agent wrote down on their round, waiting to be counted.
|
||||
|
||||
This used to be an empty grid the clerk typed the round into, and typing it posted it: a
|
||||
customer was credited the moment a line was keyed, whether or not the cash behind it ever
|
||||
reached the branch. The agent records their own collections now, and what the clerk does here
|
||||
is agree with them - so there is nothing to type, only a list to check against a bag of cash.
|
||||
-->
|
||||
@if (isPartyTran && agentInfo.name) {
|
||||
<div class="dte-section">
|
||||
<div class="dte-section__header">
|
||||
<i class="fas fa-list"></i>
|
||||
<h3>Collections up to {{ entryForm.get('transactionDate')?.value | dateFormat }}</h3>
|
||||
<button mat-stroked-button type="button" class="dte-add-row" (click)="loadPendingEntries()">
|
||||
<!-- Agent Tran: the cashier's receipts are applied to the customers from here - the dialog shows the days and receipts. -->
|
||||
@if (isAgentTran && agentInfo.name) {
|
||||
<div class="dte-agent-actions">
|
||||
<button mat-stroked-button type="button" class="dte-refresh-btn" (click)="loadAgentMoney()">
|
||||
<i class="fas fa-sync"></i> Refresh
|
||||
</button>
|
||||
</div>
|
||||
|
||||
@if (pendingLoading) {
|
||||
<p class="dte-empty">Reading this agent's entries...</p>
|
||||
} @else if (pendingEntries.length === 0) {
|
||||
<p class="dte-empty">{{ agentInfo.name }} has nothing outstanding up to this date.</p>
|
||||
} @else {
|
||||
@for (entry of pendingEntries; track entry.id) {
|
||||
<div class="dte-pending-row">
|
||||
<!-- The day the agent took it, on the line itself. All the lines carry the date
|
||||
chosen above, but a row that says so can be read, printed or shown to the agent
|
||||
on its own without the header to explain it. -->
|
||||
<span class="dte-pending-row__date">{{ entry.entryDate | dateFormat }}</span>
|
||||
<span class="dte-pending-row__code">{{ entry.partyCode }}</span>
|
||||
<span class="dte-pending-row__name">{{ entry.partyName }}</span>
|
||||
<span class="dte-pending-row__mode"
|
||||
>{{ entry.transactionMode }}
|
||||
@if (entry.advanceDays) {
|
||||
· {{ entry.advanceDays }}d advance
|
||||
}
|
||||
@if (entry.arrearsDays) {
|
||||
· {{ entry.arrearsDays }}d previous
|
||||
}
|
||||
</span>
|
||||
<span class="dte-pending-row__amount">{{ entry.amount | number: '1.2-2' }}</span>
|
||||
</div>
|
||||
}
|
||||
|
||||
<div class="dte-total">
|
||||
<span>{{ pendingEntries.length }} {{ pendingEntries.length === 1 ? 'entry' : 'entries' }}</span>
|
||||
<strong>{{ pendingTotal | number: '1.2-2' }}</strong>
|
||||
</div>
|
||||
|
||||
<!--
|
||||
The figure the clerk counts against, said in words rather than left as a column total -
|
||||
it is the one number they are acting on, and the rest of the panel is working out.
|
||||
|
||||
It says what the agent wrote down and stops there. The money side - what is owed, what the
|
||||
cashier has taken in, and applying it - is Agent Tran, so each type has one job.
|
||||
-->
|
||||
<p class="dte-expected dte-expected--note">
|
||||
Everything {{ agentInfo.name }} wrote down and has not handed in yet, up to this date, customer by
|
||||
customer. The cash and its receipts are under Agent Tran.
|
||||
</p>
|
||||
}
|
||||
</div>
|
||||
}
|
||||
|
||||
<!--
|
||||
The agent's money: what they owe on everything written down, what the cashier has taken in
|
||||
against it, and what is left.
|
||||
|
||||
This used to be a form the clerk typed an amount into to take the agent's cash in here. The
|
||||
cash is taken at the cashier's counter now, into the agent's own GL account, and one way to take
|
||||
the same cash in is the only safe number of ways - so there is nothing to type. What is done here
|
||||
is applying the cashier's receipts to the customers, in Apply DDS Receipt.
|
||||
-->
|
||||
@if (isAgentTran && agentInfo.name) {
|
||||
<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()"
|
||||
>
|
||||
<button mat-raised-button color="primary" type="button" class="dte-apply-btn" (click)="openApplyReceipt()">
|
||||
<i class="fas fa-receipt"></i> Apply DDS Receipt
|
||||
</button>
|
||||
</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) {
|
||||
<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
|
||||
@@ -246,21 +247,6 @@
|
||||
takes {{ agentInfo.name }}'s cash into this GL (Entry Type DDS).
|
||||
</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) {
|
||||
<p class="dte-expected dte-expected--note">
|
||||
{{ 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.
|
||||
</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>
|
||||
}
|
||||
|
||||
@@ -342,84 +268,102 @@
|
||||
<i class="fas fa-exchange-alt"></i>
|
||||
<h3>Withdraw Deposite</h3>
|
||||
</div>
|
||||
<div class="dte-grid">
|
||||
<mat-form-field appearance="outline" class="dte-field">
|
||||
<mat-label>Creditor Party Code</mat-label>
|
||||
<input
|
||||
matInput
|
||||
formControlName="creditorPartyCode"
|
||||
[matAutocomplete]="creditorAuto"
|
||||
#creditorTrigger="matAutocompleteTrigger"
|
||||
(click)="creditorTrigger.openPanel()"
|
||||
(focus)="creditorTrigger.openPanel()"
|
||||
(blur)="settleTransferParty('creditor')"
|
||||
placeholder="Search by code or name…"
|
||||
autocomplete="off"
|
||||
/>
|
||||
<mat-icon matSuffix class="dte-list-arrow" (click)="$event.stopPropagation(); creditorTrigger.openPanel()"
|
||||
>arrow_drop_down</mat-icon
|
||||
>
|
||||
<mat-autocomplete #creditorAuto="matAutocomplete" panelWidth="min(30rem, 92vw)">
|
||||
@for (party of transferPartyOptions('creditor'); track party.id) {
|
||||
<mat-option [value]="party.partyCode">
|
||||
{{ party.partyCode }} · {{ party.partyName }} · ₹{{ party.currentBalance | number: '1.2-2' }}
|
||||
</mat-option>
|
||||
}
|
||||
@if (transferPartyOptions('creditor').length === 0) {
|
||||
<mat-option disabled>No open DDS account matches</mat-option>
|
||||
}
|
||||
</mat-autocomplete>
|
||||
<mat-hint>Account the money goes into</mat-hint>
|
||||
</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 class="dte-grid dte-grid--transfer">
|
||||
<!-- The account the money goes into. -->
|
||||
<div class="dte-party-card dte-party-card--to">
|
||||
<div class="dte-party-card__title"><i class="fas fa-arrow-circle-down"></i>Money goes into</div>
|
||||
<div class="dte-party-card__fields">
|
||||
<mat-form-field appearance="outline" class="dte-field">
|
||||
<mat-label>Creditor Party Code</mat-label>
|
||||
<input
|
||||
matInput
|
||||
formControlName="creditorPartyCode"
|
||||
[matAutocomplete]="creditorAuto"
|
||||
#creditorTrigger="matAutocompleteTrigger"
|
||||
(click)="creditorTrigger.openPanel()"
|
||||
(focus)="creditorTrigger.openPanel()"
|
||||
(blur)="settleTransferParty('creditor')"
|
||||
placeholder="Search by code or name…"
|
||||
autocomplete="off"
|
||||
/>
|
||||
<mat-icon
|
||||
matSuffix
|
||||
class="dte-list-arrow"
|
||||
(click)="$event.stopPropagation(); creditorTrigger.openPanel()"
|
||||
>arrow_drop_down</mat-icon
|
||||
>
|
||||
<mat-autocomplete #creditorAuto="matAutocomplete" panelWidth="min(30rem, 92vw)">
|
||||
@for (party of transferPartyOptions('creditor'); track party.id) {
|
||||
<mat-option [value]="party.partyCode">
|
||||
{{ party.partyCode }} · {{ party.partyName }} · ₹{{ party.currentBalance | number: '1.2-2' }}
|
||||
</mat-option>
|
||||
}
|
||||
@if (transferPartyOptions('creditor').length === 0) {
|
||||
<mat-option disabled>No open DDS account matches</mat-option>
|
||||
}
|
||||
</mat-autocomplete>
|
||||
<mat-hint>Account the money goes into</mat-hint>
|
||||
</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">
|
||||
<mat-label>Debit Party Code</mat-label>
|
||||
<input
|
||||
matInput
|
||||
formControlName="debitPartyCode"
|
||||
[matAutocomplete]="debitAuto"
|
||||
#debitTrigger="matAutocompleteTrigger"
|
||||
(click)="debitTrigger.openPanel()"
|
||||
(focus)="debitTrigger.openPanel()"
|
||||
(blur)="settleTransferParty('debit')"
|
||||
placeholder="Search by code or name…"
|
||||
autocomplete="off"
|
||||
/>
|
||||
<mat-icon matSuffix class="dte-list-arrow" (click)="$event.stopPropagation(); debitTrigger.openPanel()"
|
||||
>arrow_drop_down</mat-icon
|
||||
>
|
||||
<mat-autocomplete #debitAuto="matAutocomplete" panelWidth="min(30rem, 92vw)">
|
||||
@for (party of transferPartyOptions('debit'); track party.id) {
|
||||
<mat-option [value]="party.partyCode">
|
||||
{{ party.partyCode }} · {{ party.partyName }} · ₹{{ party.currentBalance | number: '1.2-2' }}
|
||||
</mat-option>
|
||||
}
|
||||
@if (transferPartyOptions('debit').length === 0) {
|
||||
<mat-option disabled>No open DDS account matches</mat-option>
|
||||
}
|
||||
</mat-autocomplete>
|
||||
@if (transferToItself) {
|
||||
<mat-hint class="dte-same-party">Pick a different account - it cannot transfer to itself</mat-hint>
|
||||
} @else {
|
||||
<mat-hint>Account the money is taken from</mat-hint>
|
||||
}
|
||||
</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>
|
||||
<!-- The account the money is taken from. -->
|
||||
<div class="dte-party-card dte-party-card--from">
|
||||
<div class="dte-party-card__title"><i class="fas fa-arrow-circle-up"></i>Money is taken from</div>
|
||||
<div class="dte-party-card__fields">
|
||||
<mat-form-field appearance="outline" class="dte-field">
|
||||
<mat-label>Debit Party Code</mat-label>
|
||||
<input
|
||||
matInput
|
||||
formControlName="debitPartyCode"
|
||||
[matAutocomplete]="debitAuto"
|
||||
#debitTrigger="matAutocompleteTrigger"
|
||||
(click)="debitTrigger.openPanel()"
|
||||
(focus)="debitTrigger.openPanel()"
|
||||
(blur)="settleTransferParty('debit')"
|
||||
placeholder="Search by code or name…"
|
||||
autocomplete="off"
|
||||
/>
|
||||
<mat-icon
|
||||
matSuffix
|
||||
class="dte-list-arrow"
|
||||
(click)="$event.stopPropagation(); debitTrigger.openPanel()"
|
||||
>arrow_drop_down</mat-icon
|
||||
>
|
||||
<mat-autocomplete #debitAuto="matAutocomplete" panelWidth="min(30rem, 92vw)">
|
||||
@for (party of transferPartyOptions('debit'); track party.id) {
|
||||
<mat-option [value]="party.partyCode">
|
||||
{{ party.partyCode }} · {{ party.partyName }} · ₹{{ party.currentBalance | number: '1.2-2' }}
|
||||
</mat-option>
|
||||
}
|
||||
@if (transferPartyOptions('debit').length === 0) {
|
||||
<mat-option disabled>No open DDS account matches</mat-option>
|
||||
}
|
||||
</mat-autocomplete>
|
||||
@if (transferToItself) {
|
||||
<mat-hint class="dte-same-party">Pick a different account - it cannot transfer to itself</mat-hint>
|
||||
} @else {
|
||||
<mat-hint>Account the money is taken from</mat-hint>
|
||||
}
|
||||
</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-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 */
|
||||
import { DayCollectionDialogComponent } from './day-collection-dialog/day-collection-dialog.component';
|
||||
import { NoticeDialogComponent } from './notice-dialog/notice-dialog.component';
|
||||
import { CollectionsDialogComponent } from './collections-dialog/collections-dialog.component';
|
||||
import {
|
||||
ApplyDdsReceiptDialogComponent,
|
||||
EntryDay,
|
||||
@@ -152,6 +153,8 @@ export class DailyTransactionEntryComponent implements OnInit {
|
||||
accountNo: '',
|
||||
guarantor1: '',
|
||||
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
|
||||
};
|
||||
|
||||
@@ -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
|
||||
* 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 {
|
||||
const agentId = this.entryForm.get('agentId')?.value;
|
||||
if (!agentId) {
|
||||
@@ -513,7 +544,7 @@ export class DailyTransactionEntryComponent implements OnInit {
|
||||
const picked = this.agents.find((agent: any) => agent.id === agentId);
|
||||
this.dialog
|
||||
.open(ApplyDdsReceiptDialogComponent, {
|
||||
width: '780px',
|
||||
width: '700px',
|
||||
maxWidth: '96vw',
|
||||
panelClass: 'apply-collection-dialog-panel',
|
||||
data: {
|
||||
@@ -799,6 +830,7 @@ export class DailyTransactionEntryComponent implements OnInit {
|
||||
accountNo: agent.agentSavingAccountNo || '',
|
||||
guarantor1: guarantors[0]?.guarantorName || '',
|
||||
guarantor2: guarantors[1]?.guarantorName || '',
|
||||
guarantors: guarantors.map((guarantor: any) => guarantor?.guarantorName).filter((name: any) => !!name),
|
||||
currentBalance: agent.agentCurrentBalance || 0
|
||||
};
|
||||
this.agentGlCode = agent.glAccountCode || null;
|
||||
@@ -813,6 +845,7 @@ export class DailyTransactionEntryComponent implements OnInit {
|
||||
accountNo: '',
|
||||
guarantor1: '',
|
||||
guarantor2: '',
|
||||
guarantors: [],
|
||||
currentBalance: 0
|
||||
};
|
||||
}
|
||||
@@ -890,11 +923,29 @@ export class DailyTransactionEntryComponent implements OnInit {
|
||||
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. */
|
||||
get partyBalanceAfter(): number {
|
||||
if (!this.partyInfo || this.activeRow < 0) {
|
||||
if (!this.partyInfo) {
|
||||
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);
|
||||
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/.
|
||||
*/
|
||||
|
||||
/* stylelint-disable no-duplicate-selectors -- the compact-density pass below deliberately re-opens earlier rules; kept in cascade order */
|
||||
|
||||
.dcd-field {
|
||||
width: 100%;
|
||||
}
|
||||
@@ -40,3 +42,34 @@
|
||||
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/.
|
||||
*/
|
||||
|
||||
/* stylelint-disable no-duplicate-selectors -- the compact-density pass below deliberately re-opens earlier rules; kept in cascade order */
|
||||
|
||||
.nd-head {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
@@ -42,3 +44,22 @@
|
||||
line-height: 1.55;
|
||||
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 {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
padding: 8px;
|
||||
border-radius: 14px;
|
||||
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 {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
display: inline-flex !important;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 6px;
|
||||
padding: 10px 16px;
|
||||
border: 0;
|
||||
border-radius: 10px;
|
||||
background: transparent;
|
||||
font-size: 0.95rem;
|
||||
font-weight: 600;
|
||||
color: #475569;
|
||||
gap: 8px;
|
||||
height: 38px !important;
|
||||
min-width: 0 !important;
|
||||
padding: 0 16px !important;
|
||||
border-radius: 10px !important;
|
||||
font-family: Inter, 'DM Sans', Roboto, sans-serif !important;
|
||||
font-size: 13px !important;
|
||||
font-weight: 600 !important;
|
||||
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;
|
||||
|
||||
&:hover {
|
||||
background: #f1f5f9;
|
||||
&__ic {
|
||||
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 {
|
||||
color: #fff;
|
||||
background: linear-gradient(90deg, #1d4ed8, #2563eb);
|
||||
box-shadow: 0 4px 12px rgb(37 99 235 / 30%);
|
||||
|
||||
&:hover {
|
||||
background: linear-gradient(90deg, #1d4ed8, #2563eb);
|
||||
}
|
||||
.dav-body {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 16px;
|
||||
}
|
||||
|
||||
/* ── Cards ────────────────────────────────────────────────────────────── */
|
||||
@@ -269,38 +313,7 @@
|
||||
color: rgb(0 0 0 / 50%);
|
||||
}
|
||||
|
||||
/* ── Transactions ─────────────────────────────────────────────────────── */
|
||||
|
||||
.dav-toolbar {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
flex-wrap: wrap;
|
||||
gap: 12px;
|
||||
padding: 14px 20px;
|
||||
background: #f8fafc;
|
||||
border-bottom: 1px solid rgb(0 0 0 / 7%);
|
||||
}
|
||||
|
||||
.dav-datepick {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 2px;
|
||||
|
||||
span {
|
||||
font-size: 0.68rem;
|
||||
font-weight: 700;
|
||||
letter-spacing: 0.06em;
|
||||
text-transform: uppercase;
|
||||
color: rgb(0 0 0 / 45%);
|
||||
}
|
||||
|
||||
input {
|
||||
padding: 6px 10px;
|
||||
border-radius: 8px;
|
||||
border: 1px solid rgb(0 0 0 / 15%);
|
||||
font: inherit;
|
||||
}
|
||||
}
|
||||
/* ── Shared tables and buttons (nominees, not-found state) ─────────── */
|
||||
|
||||
.dav-btn {
|
||||
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 {
|
||||
overflow-x: auto;
|
||||
}
|
||||
@@ -366,11 +364,6 @@
|
||||
}
|
||||
}
|
||||
|
||||
.dav-desc {
|
||||
max-width: 320px;
|
||||
color: rgb(0 0 0 / 65%);
|
||||
}
|
||||
|
||||
@media (width <= 720px) {
|
||||
.dav-page {
|
||||
padding: 16px;
|
||||
@@ -382,66 +375,11 @@
|
||||
}
|
||||
|
||||
.dav-tab {
|
||||
font-size: 0.85rem;
|
||||
padding: 8px;
|
||||
padding: 0 12px !important;
|
||||
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 */
|
||||
.dav-closure-tag {
|
||||
margin-left: 8px;
|
||||
@@ -457,3 +395,629 @@
|
||||
border-top: 1px solid rgb(0 0 0 / 10%);
|
||||
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/.
|
||||
*/
|
||||
|
||||
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 { FormControl, ReactiveFormsModule } from '@angular/forms';
|
||||
import { NgClass } from '@angular/common';
|
||||
import { CurrencyPipe, NgClass } from '@angular/common';
|
||||
import { MatTooltipModule } from '@angular/material/tooltip';
|
||||
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 { forkJoin, of } from 'rxjs';
|
||||
import { forkJoin, merge, of } from 'rxjs';
|
||||
import { catchError } from 'rxjs/operators';
|
||||
import { STANDALONE_SHARED_IMPORTS } from 'app/standalone-shared.module';
|
||||
import { FormatNumberPipe } from 'app/pipes/format-number.pipe';
|
||||
@@ -52,6 +75,29 @@ const TRANSACTION_LABELS: Record<string, string> = {
|
||||
FaIconComponent,
|
||||
MatTooltipModule,
|
||||
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
|
||||
]
|
||||
})
|
||||
@@ -59,6 +105,8 @@ export class DdsAccountViewComponent implements OnInit {
|
||||
private route = inject(ActivatedRoute);
|
||||
private router = inject(Router);
|
||||
private organizationService = inject(OrganizationService);
|
||||
private destroyRef = inject(DestroyRef);
|
||||
private dialog = inject(MatDialog);
|
||||
|
||||
isLoading = true;
|
||||
account: any = null;
|
||||
@@ -79,6 +127,40 @@ export class DdsAccountViewComponent implements OnInit {
|
||||
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. */
|
||||
get passbookEntries(): any[] {
|
||||
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
|
||||
* 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 {
|
||||
const passbook = document.getElementById('dds-passbook');
|
||||
const popup = window.open('', '_blank', 'width=900,height=700');
|
||||
if (!passbook || !popup) {
|
||||
if (!passbook) {
|
||||
return;
|
||||
}
|
||||
popup.document.write(`<html><head><title>${this.account?.accountNo || 'DDS'} Passbook</title><style>
|
||||
body { font-family: Arial, sans-serif; margin: 24px; color: #111; }
|
||||
.dav-pb-head { display: flex; justify-content: space-between; gap: 24px; margin-bottom: 16px; }
|
||||
.dav-pb-title { font-size: 20px; font-weight: 700; }
|
||||
.dav-pb-sub { color: #555; }
|
||||
.dav-pb-grid { display: grid; grid-template-columns: auto auto; gap: 2px 12px; font-size: 13px; }
|
||||
.dav-pb-grid span { color: #555; }
|
||||
table { width: 100%; border-collapse: collapse; font-size: 13px; }
|
||||
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; }
|
||||
</style></head><body>${passbook.innerHTML}</body></html>`);
|
||||
const clone = passbook.cloneNode(true) as HTMLElement;
|
||||
clone.querySelectorAll('.no-print').forEach((el: Element) => el.remove());
|
||||
// The icon font is not available in the popup: the bank logo becomes an inline SVG, other icons go.
|
||||
clone.querySelectorAll('mat-icon, .mat-icon').forEach((el: Element) => {
|
||||
if (el.closest('.bank-logo-circle')) {
|
||||
el.outerHTML = DdsAccountViewComponent.BANK_LOGO_SVG;
|
||||
} else {
|
||||
el.remove();
|
||||
}
|
||||
});
|
||||
const popup = window.open('', '_blank', 'width=1100,height=800');
|
||||
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.focus();
|
||||
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('');
|
||||
toDateControl = new FormControl('');
|
||||
|
||||
readonly today = new Date().toISOString().split('T')[0];
|
||||
|
||||
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');
|
||||
if (!partyId) {
|
||||
this.isLoading = false;
|
||||
@@ -144,10 +329,39 @@ export class DdsAccountViewComponent implements OnInit {
|
||||
(a: any, b: any) =>
|
||||
String(b.transactionDate).localeCompare(String(a.transactionDate)) || Number(b.id) - Number(a.id)
|
||||
);
|
||||
this.refreshTransactionTable();
|
||||
this.loadLetterhead(account);
|
||||
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. */
|
||||
get shownTransactions(): any[] {
|
||||
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 {
|
||||
return this.sumOf('CREDIT');
|
||||
}
|
||||
@@ -209,6 +428,11 @@ export class DdsAccountViewComponent implements OnInit {
|
||||
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. */
|
||||
exportCsv(): void {
|
||||
const header = [
|
||||
@@ -268,6 +492,35 @@ export class DdsAccountViewComponent implements OnInit {
|
||||
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 {
|
||||
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>
|
||||
</div>
|
||||
} @else {
|
||||
<div class="dri-body">
|
||||
<!-- 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">
|
||||
<span class="dri-voucher">{{ voucherNo || '—' }}</span>
|
||||
<span class="dri-status" [ngClass]="statusClass(entry.status)">
|
||||
{{ entry.status }}{{ entry.voucherCancelled ? ' · VOUCHER CANCELLED' : '' }}
|
||||
</span>
|
||||
<div class="dri-body" [class.ddr-body]="isProductReview || isAgentReview || isPartyReview">
|
||||
@if (isProductReview) {
|
||||
<!--
|
||||
A DDS product request reads as the product it creates or changes: who and what at the top,
|
||||
the four terms a checker looks at first, then every term grouped as the Account Type screen
|
||||
asks for them. An edit shows each changed term as old → new.
|
||||
-->
|
||||
<section class="ddr-hero">
|
||||
<div class="ddr-hero__icon">
|
||||
<mat-icon>{{ isEdit ? 'edit_note' : 'savings' }}</mat-icon>
|
||||
</div>
|
||||
<div class="dri-card-header__meta">
|
||||
<span class="dri-meta-item">
|
||||
<div class="ddr-hero__main">
|
||||
<div class="ddr-hero__eyebrow">
|
||||
<span class="ddr-chip" [class.ddr-chip--edit]="isEdit">{{
|
||||
isEdit ? 'Edit request' : 'New product'
|
||||
}}</span>
|
||||
<span class="dri-status" [ngClass]="statusClass(entry.status)">{{ entry.status }}</span>
|
||||
</div>
|
||||
<h2 class="ddr-hero__title">{{ valueOf('accountTypeDescription') || entry.details || 'DDS Product' }}</h2>
|
||||
<div class="ddr-hero__code">
|
||||
<span class="ddr-mono">{{ valueOf('accountTypeCode') || '—' }}</span>
|
||||
@if (valueOf('accountTypeGroup')) {
|
||||
<span class="ddr-dot"></span>
|
||||
<span>{{ valueOf('accountTypeGroup') }} group</span>
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
<div class="ddr-hero__meta">
|
||||
<span class="ddr-meta">
|
||||
<mat-icon>person</mat-icon>
|
||||
{{ entry.makerName || entry.maker }} ({{ entry.maker }})
|
||||
<span>
|
||||
<b>{{ entry.makerName || entry.maker }}</b>
|
||||
<small>{{ entry.maker }}</small>
|
||||
</span>
|
||||
</span>
|
||||
<span class="dri-meta-item">
|
||||
<span class="ddr-meta">
|
||||
<mat-icon>schedule</mat-icon>
|
||||
{{ formatDateTime(entry.madeOn) }}
|
||||
<span>
|
||||
<b>{{ formatDateTime(entry.madeOn) }}</b>
|
||||
<small>Submitted</small>
|
||||
</span>
|
||||
</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 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>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- 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">
|
||||
<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') : '—' }}
|
||||
<!-- The terms a checker reads first. -->
|
||||
<section class="ddr-stats">
|
||||
<div class="ddr-stat ddr-stat--accent">
|
||||
<span class="ddr-stat__label"><mat-icon>percent</mat-icon>Interest Rate</span>
|
||||
<span class="ddr-stat__value">{{ valueOf('interestRate') || '—' }}</span>
|
||||
<span class="ddr-stat__hint">p.a., worked out daily</span>
|
||||
</div>
|
||||
<div class="ddr-stat">
|
||||
<span class="ddr-stat__label"><mat-icon>event_repeat</mat-icon>Interest Posting</span>
|
||||
<span class="ddr-stat__value">
|
||||
{{ valueOf('autoInterestPost') === 'No' ? 'Manual' : valueOf('autoInterestPostWhen') || 'Monthly' }}
|
||||
</span>
|
||||
<span class="ddr-stat__hint">
|
||||
{{ valueOf('autoInterestPost') === 'No' ? 'Posted from the batch screen' : 'Automatic, at period end' }}
|
||||
</span>
|
||||
</div>
|
||||
<div class="dri-detail-item">
|
||||
<span class="dri-detail-label">DR Accounts</span>
|
||||
<span class="dri-detail-value"
|
||||
>{{ debitLines.length }} account{{ debitLines.length !== 1 ? 's' : '' }}</span
|
||||
>
|
||||
<div class="ddr-stat">
|
||||
<span class="ddr-stat__label"><mat-icon>lock_clock</mat-icon>Lock-in</span>
|
||||
<span class="ddr-stat__value">{{ valueOf('prematureLockinMonths') || '—' }}</span>
|
||||
<span class="ddr-stat__hint">before early closure</span>
|
||||
</div>
|
||||
<div class="dri-detail-item">
|
||||
<span class="dri-detail-label">CR Accounts</span>
|
||||
<span class="dri-detail-value"
|
||||
>{{ creditLines.length }} account{{ creditLines.length !== 1 ? 's' : '' }}</span
|
||||
>
|
||||
<div class="ddr-stat">
|
||||
<span class="ddr-stat__label"><mat-icon>event_available</mat-icon>Maturity</span>
|
||||
<span class="ddr-stat__value">{{
|
||||
valueOf('maturityAutoClose') === 'Yes' ? 'Auto close' : 'Manual payout'
|
||||
}}</span>
|
||||
<span class="ddr-stat__hint">on the maturity date</span>
|
||||
</div>
|
||||
<div class="dri-detail-item dri-detail-item--full">
|
||||
<span class="dri-detail-label">Details</span>
|
||||
<span class="dri-detail-value">{{ entry.details || '—' }}</span>
|
||||
</section>
|
||||
|
||||
@if (compareFields) {
|
||||
<div class="ddr-banner" [class.ddr-banner--none]="!changedCount">
|
||||
<mat-icon>{{ changedCount ? 'compare_arrows' : 'info' }}</mat-icon>
|
||||
@if (changedCount) {
|
||||
<span>
|
||||
<b>{{ changedCount }} term{{ changedCount !== 1 ? 's' : '' }}</b> will change on approval - highlighted
|
||||
below as current → proposed.
|
||||
</span>
|
||||
} @else {
|
||||
<span>This edit changes nothing - every term matches the product as it stands.</span>
|
||||
}
|
||||
</div>
|
||||
@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>
|
||||
}
|
||||
|
||||
@for (group of fieldGroups; track group.name) {
|
||||
<section class="ddr-section">
|
||||
<header class="ddr-section__head">
|
||||
<span class="ddr-section__icon"
|
||||
><mat-icon>{{ group.icon }}</mat-icon></span
|
||||
>
|
||||
<span class="ddr-section__title">{{ group.name }}</span>
|
||||
@if (group.changed) {
|
||||
<span class="ddr-chip ddr-chip--edit">{{ group.changed }} changed</span>
|
||||
}
|
||||
</header>
|
||||
@if (group.name === 'Accounting') {
|
||||
<!--
|
||||
The ledger accounts the product posts through, laid out as the product's own Accounting
|
||||
tab lays them out: the kind of account, what it is for, and the account by name and code.
|
||||
-->
|
||||
<div class="ddr-acct">
|
||||
@for (field of group.fields; track field.key) {
|
||||
<div class="ddr-acct__row" [class.ddr-acct__row--changed]="field.changed">
|
||||
<div class="ddr-acct__meta">
|
||||
<span class="ddr-acct__kind" [ngClass]="'ddr-acct__kind--' + glKind(field.key).toLowerCase()">{{
|
||||
glKind(field.key)
|
||||
}}</span>
|
||||
<span class="ddr-acct__label">
|
||||
{{ field.label }}
|
||||
@if (field.changed) {
|
||||
<span class="ddr-tile__flag">Changed</span>
|
||||
}
|
||||
</span>
|
||||
</div>
|
||||
<div class="ddr-acct__value">
|
||||
@if (field.changed) {
|
||||
<span class="ddr-acct__gl ddr-acct__gl--old">
|
||||
{{ glName(field.current) }}
|
||||
@if (glCode(field.current)) {
|
||||
<span class="ddr-acct__code">{{ glCode(field.current) }}</span>
|
||||
}
|
||||
</span>
|
||||
<mat-icon class="ddr-acct__arrow">arrow_forward</mat-icon>
|
||||
}
|
||||
<span class="ddr-acct__gl" [class.ddr-acct__gl--none]="!field.value">
|
||||
{{ glName(field.value) }}
|
||||
@if (glCode(field.value)) {
|
||||
<span class="ddr-acct__code">{{ glCode(field.value) }}</span>
|
||||
}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
} @else {
|
||||
<div class="ddr-grid">
|
||||
@for (field of group.fields; track field.key) {
|
||||
<div class="ddr-tile" [class.ddr-tile--changed]="field.changed">
|
||||
<span class="ddr-tile__label">
|
||||
{{ field.label }}
|
||||
@if (field.changed) {
|
||||
<span class="ddr-tile__flag">Changed</span>
|
||||
}
|
||||
</span>
|
||||
@if (field.changed) {
|
||||
<span class="ddr-tile__diff">
|
||||
<span class="ddr-tile__old">{{ field.current || '—' }}</span>
|
||||
<mat-icon>arrow_forward</mat-icon>
|
||||
<ng-container *ngTemplateOutlet="valueTpl; context: { field: field }"></ng-container>
|
||||
</span>
|
||||
} @else {
|
||||
<ng-container *ngTemplateOutlet="valueTpl; context: { field: field }"></ng-container>
|
||||
}
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
}
|
||||
</section>
|
||||
}
|
||||
|
||||
<ng-template #valueTpl let-field="field">
|
||||
@if (field.format === 'yesno' && field.value) {
|
||||
<span class="ddr-pill" [class.ddr-pill--yes]="field.value === 'Yes'">
|
||||
<mat-icon>{{ field.value === 'Yes' ? 'check_circle' : 'remove_circle_outline' }}</mat-icon>
|
||||
{{ field.value }}
|
||||
</span>
|
||||
} @else {
|
||||
<span class="ddr-tile__value" [class.ddr-mono]="field.key === 'accountTypeCode'">{{
|
||||
field.value || '—'
|
||||
}}</span>
|
||||
}
|
||||
</ng-template>
|
||||
} @else if (isAgentReview) {
|
||||
<!--
|
||||
An agent request reads as the agent it opens or changes: who and when at the top, then the
|
||||
client, address, branch and accounts - the branch, the ledger account and the client by name,
|
||||
not number - and the guarantors named with it. An edit lists only what it changes.
|
||||
-->
|
||||
<section class="ddr-hero">
|
||||
<div class="ddr-hero__icon">
|
||||
<mat-icon>{{ isEdit ? 'manage_accounts' : 'person_add' }}</mat-icon>
|
||||
</div>
|
||||
<div class="ddr-hero__main">
|
||||
<div class="ddr-hero__eyebrow">
|
||||
<span class="ddr-chip" [class.ddr-chip--edit]="isEdit">{{ isEdit ? 'Edit request' : 'New agent' }}</span>
|
||||
<span class="dri-status" [ngClass]="statusClass(entry.status)">{{ entry.status }}</span>
|
||||
</div>
|
||||
<h2 class="ddr-hero__title">{{ valueOf('agentNameId') || entry.details || 'DDS Agent' }}</h2>
|
||||
<div class="ddr-hero__code">
|
||||
@if (valueOf('clientId')) {
|
||||
<span>Client {{ valueOf('clientId') }}</span>
|
||||
}
|
||||
@if (valueOf('agentSavingAccountNo')) {
|
||||
<span class="ddr-dot"></span>
|
||||
<span class="ddr-mono">{{ valueOf('agentSavingAccountNo') }}</span>
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
<div class="ddr-hero__meta">
|
||||
<span class="ddr-meta">
|
||||
<mat-icon>person</mat-icon>
|
||||
<span>
|
||||
<b>{{ entry.makerName || entry.maker }}</b>
|
||||
<small>{{ entry.maker }}</small>
|
||||
</span>
|
||||
</span>
|
||||
<span class="ddr-meta">
|
||||
<mat-icon>schedule</mat-icon>
|
||||
<span>
|
||||
<b>{{ formatDateTime(entry.madeOn) }}</b>
|
||||
<small>Submitted</small>
|
||||
</span>
|
||||
</span>
|
||||
@if (entry.status !== 'PENDING' && entry.checker) {
|
||||
<span class="ddr-meta">
|
||||
<mat-icon>verified_user</mat-icon>
|
||||
<span>
|
||||
<b>{{ entry.checkerName || entry.checker }}</b>
|
||||
<small>{{ entry.status === 'APPROVED' ? 'Approved by' : 'Rejected by' }}</small>
|
||||
</span>
|
||||
</span>
|
||||
}
|
||||
</div>
|
||||
</section>
|
||||
|
||||
@for (group of fieldGroups; track group.name) {
|
||||
<section class="ddr-section">
|
||||
<header class="ddr-section__head">
|
||||
<span class="ddr-section__icon"
|
||||
><mat-icon>{{ group.icon }}</mat-icon></span
|
||||
>
|
||||
<span class="ddr-section__title">{{ group.name }}</span>
|
||||
</header>
|
||||
<div class="ddr-grid">
|
||||
@for (field of group.fields; track field.key) {
|
||||
<div class="ddr-tile">
|
||||
<span class="ddr-tile__label">{{ field.label }}</span>
|
||||
<span class="ddr-tile__value" [class.ddr-mono]="field.key === 'agentSavingAccountNo'">{{
|
||||
field.value || '—'
|
||||
}}</span>
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
</section>
|
||||
}
|
||||
|
||||
<section class="ddr-section">
|
||||
<header class="ddr-section__head">
|
||||
<span class="ddr-section__icon"><mat-icon>shield_person</mat-icon></span>
|
||||
<span class="ddr-section__title">Guarantors</span>
|
||||
<span class="ddr-chip ddr-chip--edit">{{ guarantors.length }}</span>
|
||||
</header>
|
||||
@if (guarantors.length) {
|
||||
<table class="dri-cr-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>#</th>
|
||||
<th>Client</th>
|
||||
<th>Name</th>
|
||||
<th>Telephone</th>
|
||||
<th>Address</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
@for (g of guarantors; track $index; let i = $index) {
|
||||
<tr>
|
||||
<td class="dri-cr-idx">{{ i + 1 }}</td>
|
||||
<td>{{ g.client || '—' }}</td>
|
||||
<td>{{ g.name || '—' }}</td>
|
||||
<td class="cell-mono">{{ g.telephone || '—' }}</td>
|
||||
<td>{{ g.address || '—' }}</td>
|
||||
</tr>
|
||||
}
|
||||
</tbody>
|
||||
</table>
|
||||
} @else {
|
||||
<div class="ddr-tile">
|
||||
<span class="ddr-tile__value">No guarantors named in this request.</span>
|
||||
</div>
|
||||
}
|
||||
</section>
|
||||
} @else if (isPartyReview) {
|
||||
<!--
|
||||
A DDS account request reads as the account it opens or changes: who it is for and when, the
|
||||
terms a checker looks at first, then every detail in the sections the Party Master Entry screen
|
||||
has - the client, scheme, agent and branch by name, not number - and the nominees named with it.
|
||||
An edit lists only what it changes.
|
||||
-->
|
||||
<section class="ddr-hero">
|
||||
<div class="ddr-hero__icon">
|
||||
<mat-icon>{{ isEdit ? 'edit_note' : 'account_balance_wallet' }}</mat-icon>
|
||||
</div>
|
||||
<div class="ddr-hero__main">
|
||||
<div class="ddr-hero__eyebrow">
|
||||
<span class="ddr-chip" [class.ddr-chip--edit]="isEdit">{{
|
||||
isEdit ? 'Edit request' : 'New DDS account'
|
||||
}}</span>
|
||||
<span class="dri-status" [ngClass]="statusClass(entry.status)">{{ entry.status }}</span>
|
||||
</div>
|
||||
<h2 class="ddr-hero__title">{{ valueOf('partyName') || entry.details || 'DDS Account' }}</h2>
|
||||
<div class="ddr-hero__code">
|
||||
@if (valueOf('subType')) {
|
||||
<span>{{ valueOf('subType') }}</span>
|
||||
}
|
||||
@if (valueOf('clientId')) {
|
||||
<span class="ddr-dot"></span>
|
||||
<span>Client {{ valueOf('clientId') }}</span>
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
<div class="ddr-hero__meta">
|
||||
<span class="ddr-meta">
|
||||
<mat-icon>person</mat-icon>
|
||||
<span>
|
||||
<b>{{ entry.makerName || entry.maker }}</b>
|
||||
<small>{{ entry.maker }}</small>
|
||||
</span>
|
||||
</span>
|
||||
<span class="ddr-meta">
|
||||
<mat-icon>schedule</mat-icon>
|
||||
<span>
|
||||
<b>{{ formatDateTime(entry.madeOn) }}</b>
|
||||
<small>Submitted</small>
|
||||
</span>
|
||||
</span>
|
||||
@if (entry.status !== 'PENDING' && entry.checker) {
|
||||
<span class="ddr-meta">
|
||||
<mat-icon>verified_user</mat-icon>
|
||||
<span>
|
||||
<b>{{ entry.checkerName || entry.checker }}</b>
|
||||
<small>{{ entry.status === 'APPROVED' ? 'Approved by' : 'Rejected by' }}</small>
|
||||
</span>
|
||||
</span>
|
||||
}
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- The terms a checker reads first. -->
|
||||
<section class="ddr-stats">
|
||||
<div class="ddr-stat ddr-stat--accent">
|
||||
<span class="ddr-stat__label"><mat-icon>payments</mat-icon>Daily Deposit</span>
|
||||
<span class="ddr-stat__value">{{ valueOf('dailyDepositAmount') || '—' }}</span>
|
||||
<span class="ddr-stat__hint">collected every day</span>
|
||||
</div>
|
||||
<div class="ddr-stat">
|
||||
<span class="ddr-stat__label"><mat-icon>date_range</mat-icon>Term</span>
|
||||
<span class="ddr-stat__value">{{ valueOf('days') ? valueOf('days') + ' days' : '—' }}</span>
|
||||
<span class="ddr-stat__hint">{{ valueOf('month') ? valueOf('month') + ' months' : '' }}</span>
|
||||
</div>
|
||||
<div class="ddr-stat">
|
||||
<span class="ddr-stat__label"><mat-icon>event</mat-icon>Opening Date</span>
|
||||
<span class="ddr-stat__value">{{ valueOf('openingDate') || '—' }}</span>
|
||||
<span class="ddr-stat__hint">{{
|
||||
valueOf('maturityDate') ? 'Matures ' + valueOf('maturityDate') : 'account opens'
|
||||
}}</span>
|
||||
</div>
|
||||
<div class="ddr-stat">
|
||||
<span class="ddr-stat__label"><mat-icon>support_agent</mat-icon>Agent</span>
|
||||
<span class="ddr-stat__value ddr-stat__value--small">{{ valueOf('agentId') || '—' }}</span>
|
||||
<span class="ddr-stat__hint">{{ valueOf('officeId') || '' }}</span>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
@for (group of fieldGroups; track group.name) {
|
||||
<section class="ddr-section">
|
||||
<header class="ddr-section__head">
|
||||
<span class="ddr-section__icon"
|
||||
><mat-icon>{{ group.icon }}</mat-icon></span
|
||||
>
|
||||
<span class="ddr-section__title">{{ group.name }}</span>
|
||||
</header>
|
||||
<div class="ddr-grid">
|
||||
@for (field of group.fields; track field.key) {
|
||||
<div class="ddr-tile">
|
||||
<span class="ddr-tile__label">{{ field.label }}</span>
|
||||
@if (field.format === 'yesno') {
|
||||
<span class="ddr-pill" [class.ddr-pill--yes]="field.value === 'Yes'">
|
||||
<mat-icon>{{ field.value === 'Yes' ? 'check_circle' : 'remove_circle_outline' }}</mat-icon>
|
||||
{{ field.value }}
|
||||
</span>
|
||||
} @else {
|
||||
<span class="ddr-tile__value">{{ field.value || '—' }}</span>
|
||||
}
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
</section>
|
||||
}
|
||||
|
||||
<section class="ddr-section">
|
||||
<header class="ddr-section__head">
|
||||
<span class="ddr-section__icon"><mat-icon>family_restroom</mat-icon></span>
|
||||
<span class="ddr-section__title">Nominees</span>
|
||||
@if (detail?.nomineeSplit && nominees.length) {
|
||||
<span class="ddr-chip ddr-chip--edit">Split by {{ detail.nomineeSplit }}</span>
|
||||
}
|
||||
<span class="ddr-chip ddr-chip--edit">{{ nominees.length }}</span>
|
||||
</header>
|
||||
@if (nominees.length) {
|
||||
<table class="dri-cr-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>#</th>
|
||||
<th>Name</th>
|
||||
<th>Relation</th>
|
||||
<th>Gender</th>
|
||||
<th>Mobile</th>
|
||||
<th>Birth Date</th>
|
||||
<th>Guardian</th>
|
||||
<th class="num">Share</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
@for (n of nominees; track $index; let i = $index) {
|
||||
<tr>
|
||||
<td class="dri-cr-idx">{{ i + 1 }}</td>
|
||||
<td>{{ n.name || '—' }}</td>
|
||||
<td>{{ n.relation || '—' }}</td>
|
||||
<td>{{ n.gender || '—' }}</td>
|
||||
<td class="cell-mono">{{ n.mobileNo || '—' }}</td>
|
||||
<td>{{ n.birthDate || '—' }}</td>
|
||||
<td>{{ n.guardianName || '—' }}</td>
|
||||
<td class="num">{{ n.share || '—' }}</td>
|
||||
</tr>
|
||||
}
|
||||
</tbody>
|
||||
</table>
|
||||
} @else {
|
||||
<div class="ddr-tile">
|
||||
<span class="ddr-tile__value">No nominee named in this request.</span>
|
||||
</div>
|
||||
}
|
||||
</section>
|
||||
} @else {
|
||||
<!-- Header card: the voucher it moves under, its status, who made it and when -->
|
||||
<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>
|
||||
|
||||
<!-- 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 -->
|
||||
@if (debitLines.length) {
|
||||
<div class="dri-card">
|
||||
<div class="dri-section-title">
|
||||
<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>
|
||||
<table class="dri-cr-table">
|
||||
<thead>
|
||||
@@ -129,7 +601,10 @@
|
||||
<div class="dri-card">
|
||||
<div class="dri-section-title">
|
||||
<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>
|
||||
<table class="dri-cr-table">
|
||||
<thead>
|
||||
@@ -225,7 +700,7 @@
|
||||
}
|
||||
|
||||
@if (entry.status === 'PENDING' && !done) {
|
||||
<div class="dri-actions">
|
||||
<div class="dri-actions" [class.ddr-actions]="isProductReview || isAgentReview || isPartyReview">
|
||||
@if (!confirmReject) {
|
||||
<button type="button" class="dri-btn dri-btn--approve" [disabled]="!canAct" (click)="act('approve')">
|
||||
<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({
|
||||
selector: 'mifosx-dds-approval-review',
|
||||
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,
|
||||
imports: [
|
||||
CommonModule,
|
||||
@@ -76,6 +79,129 @@ export class DdsApprovalReviewComponent implements OnInit {
|
||||
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 {
|
||||
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 { AuthenticationService } from 'app/core/authentication/authentication.service';
|
||||
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
|
||||
@@ -104,7 +105,11 @@ export class DdsApprovalsComponent implements OnInit {
|
||||
.getDdsApprovals(this.filterStatus, this.isoDate(this.fromDate), this.isoDate(this.toDate))
|
||||
.pipe(catchError(() => of([])))
|
||||
.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.loading = false;
|
||||
this.loaded = true;
|
||||
|
||||
+57
-4
@@ -331,12 +331,14 @@
|
||||
<span class="fdrc-maturity-row__value">{{ matchResult.appliedRate | number: '1.2-2' }}% p.a.</span>
|
||||
</div>
|
||||
<div class="fdrc-maturity-row fdrc-maturity-row--interest">
|
||||
<span class="fdrc-maturity-row__label">Interest Earned</span>
|
||||
<span class="fdrc-maturity-row__label">{{
|
||||
isSpecial ? 'Total Interest (paid out monthly)' : 'Interest Earned'
|
||||
}}</span>
|
||||
<span class="fdrc-maturity-row__value">₹ {{ matchResult.interestEarned | number: '1.2-2' }}</span>
|
||||
</div>
|
||||
@if (isSpecial && matchResult.estimatedMonthlyPayout) {
|
||||
<div class="fdrc-maturity-row">
|
||||
<span class="fdrc-maturity-row__label">Monthly Payout</span>
|
||||
<span class="fdrc-maturity-row__label">Monthly Payout (approx., by days in the month)</span>
|
||||
<span class="fdrc-maturity-row__value fdrc-maturity-row__value--highlight">
|
||||
₹ {{ matchResult.estimatedMonthlyPayout | number: '1.2-2' }} / month
|
||||
</span>
|
||||
@@ -351,13 +353,64 @@
|
||||
}
|
||||
</span>
|
||||
<span class="fdrc-maturity-row__value fdrc-maturity-row__value--big">
|
||||
₹ {{ (isSpecial ? principalAmount : matchResult.maturityAmount) | number: '1.2-2' }}
|
||||
₹ {{ matchResult.maturityAmount | number: '1.2-2' }}
|
||||
</span>
|
||||
</div>
|
||||
@if (isSpecial && specialPreview) {
|
||||
<div class="fdrc-maturity-row">
|
||||
<span class="fdrc-maturity-row__label">Maturity Date</span>
|
||||
<span class="fdrc-maturity-row__value">{{
|
||||
specialPreview.maturityDate | date: 'dd MMM yyyy'
|
||||
}}</span>
|
||||
</div>
|
||||
<div class="fdrc-maturity-row">
|
||||
<span class="fdrc-maturity-row__label">Monthly payouts</span>
|
||||
<span class="fdrc-maturity-row__value">{{ specialPreview.numberOfPayouts }}</span>
|
||||
</div>
|
||||
<div class="fdrc-maturity-row">
|
||||
<span class="fdrc-maturity-row__label">Principal + all payouts</span>
|
||||
<span class="fdrc-maturity-row__value"
|
||||
>₹ {{ specialPreview.totalValueAtMaturity | number: '1.2-2' }}</span
|
||||
>
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@if (isSpecial && specialPreview) {
|
||||
<div class="fdrc-schedule">
|
||||
<div class="fdrc-schedule__head">
|
||||
<span><i class="fas fa-calendar-alt"></i> Monthly Interest Payout Schedule</span>
|
||||
<button
|
||||
mat-stroked-button
|
||||
type="button"
|
||||
class="fdrc-schedule__toggle"
|
||||
(click)="openSpecialSchedule()"
|
||||
>
|
||||
View payout schedule ({{ specialPreview.numberOfPayouts }})
|
||||
</button>
|
||||
</div>
|
||||
<div class="fdrc-schedule__note">
|
||||
From {{ specialPreview.startDate | date: 'dd MMM yyyy' }} (the day the account is opened) to
|
||||
{{ specialPreview.maturityDate | date: 'dd MMM yyyy' }}, worked out by the account engine from the
|
||||
product.
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
}
|
||||
@if (!matchResult.maturityAmount) {
|
||||
@if (specialPreviewLoading) {
|
||||
<div class="fdrc-payout-hint">
|
||||
<i class="fas fa-spinner fa-spin"></i>
|
||||
Working out the maturity from the product…
|
||||
</div>
|
||||
}
|
||||
@if (specialPreviewError) {
|
||||
<div class="fdrc-payout-hint fdrc-payout-hint--error">
|
||||
<i class="fas fa-exclamation-triangle"></i>
|
||||
{{ specialPreviewError }}
|
||||
</div>
|
||||
}
|
||||
@if (!matchResult.maturityAmount && !specialPreviewLoading && !specialPreviewError) {
|
||||
<div class="fdrc-payout-hint">
|
||||
<i class="fas fa-lightbulb"></i>
|
||||
Enter a deposit amount above to see the maturity calculation.
|
||||
|
||||
+69
@@ -1229,3 +1229,72 @@ $red-light: #fee2e2;
|
||||
font-weight: 700;
|
||||
}
|
||||
}
|
||||
|
||||
/* FD Special: payout schedule from the backend preview */
|
||||
.fdrc-schedule {
|
||||
margin-top: 12px;
|
||||
border: 1px solid #bbf7d0;
|
||||
border-radius: 10px;
|
||||
background: #fff;
|
||||
overflow: hidden;
|
||||
|
||||
&__head {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 8px;
|
||||
padding: 8px 14px;
|
||||
background: #f0fdf4;
|
||||
font-size: 12px;
|
||||
font-weight: 700;
|
||||
color: #166534;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.05em;
|
||||
}
|
||||
|
||||
&__note {
|
||||
padding: 6px 14px;
|
||||
font-size: 12px;
|
||||
color: #475569;
|
||||
}
|
||||
|
||||
&__wrap {
|
||||
max-height: 360px;
|
||||
overflow: auto;
|
||||
}
|
||||
|
||||
&__table {
|
||||
width: 100%;
|
||||
border-collapse: collapse;
|
||||
font-size: 13px;
|
||||
|
||||
th,
|
||||
td {
|
||||
padding: 6px 14px;
|
||||
border-top: 1px solid #e2e8f0;
|
||||
text-align: left;
|
||||
}
|
||||
|
||||
th {
|
||||
position: sticky;
|
||||
top: 0;
|
||||
background: #f8fafc;
|
||||
font-size: 11px;
|
||||
text-transform: uppercase;
|
||||
color: #64748b;
|
||||
}
|
||||
|
||||
.num {
|
||||
text-align: right;
|
||||
}
|
||||
|
||||
tfoot td {
|
||||
font-weight: 700;
|
||||
background: #f0fdf4;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.fdrc-payout-hint--error {
|
||||
color: #b91c1c;
|
||||
}
|
||||
|
||||
+140
-79
@@ -36,12 +36,39 @@ import { FormsModule } from '@angular/forms';
|
||||
import { DateFormatPipe } from '../../../../pipes/date-format.pipe';
|
||||
import { STANDALONE_SHARED_IMPORTS } from 'app/standalone-shared.module';
|
||||
import { FixedDepositsService } from '../../fixed-deposits.service';
|
||||
import { MatDialog } from '@angular/material/dialog';
|
||||
import { InterestScheduleDialogComponent } from '../../fixed-deposit-account-view/custom-dialogs/interest-schedule-dialog/interest-schedule-dialog.component';
|
||||
import { ClientsService } from 'app/clients/clients.service';
|
||||
import { SettingsService } from 'app/settings/settings.service';
|
||||
import { SENIOR_CITIZEN_AGE_THRESHOLD, deriveSeniorCitizenStatus } from 'app/clients/shared/client-age.util';
|
||||
|
||||
export type FdPeriodUnit = 'Days' | 'Months' | 'Years';
|
||||
|
||||
/**
|
||||
* yyyy-MM-dd of the calendar day as the user sees it. toISOString() gives the UTC day instead, which in India is the day
|
||||
* before for any date at local midnight - so a deposit opened on the 21st was quoted from the 20th.
|
||||
*/
|
||||
function localIsoDate(d: Date): string {
|
||||
const pad = (n: number) => String(n).padStart(2, '0');
|
||||
return `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())}`;
|
||||
}
|
||||
|
||||
/** FD quote from POST fixeddepositaccounts/account-preview (worked out by the account engine, every FD type). */
|
||||
export interface SpecialPayoutPreview {
|
||||
fdType: string;
|
||||
depositAmount: number;
|
||||
annualInterestRate: number;
|
||||
seniorCitizen: boolean;
|
||||
startDate: string;
|
||||
maturityDate: string;
|
||||
numberOfPayouts: number;
|
||||
fullMonthPayout: number;
|
||||
totalInterest: number;
|
||||
maturityAmount: number;
|
||||
totalValueAtMaturity: number;
|
||||
periods: { period: number; fromDate: string; toDate: string; days: number; interestAmount: number }[];
|
||||
}
|
||||
|
||||
export interface SlabMatchResult {
|
||||
slab: any;
|
||||
slabIndex: number;
|
||||
@@ -87,6 +114,7 @@ export class FixedDepositAccountInterestRateChartStepComponent implements OnChan
|
||||
private clientsService = inject(ClientsService);
|
||||
private settingsService = inject(SettingsService);
|
||||
private cdr = inject(ChangeDetectorRef);
|
||||
private dialog = inject(MatDialog);
|
||||
|
||||
@Input() fixedDepositsAccountTemplate: any;
|
||||
@Input() fixedDepositsAccountProductTemplate: any;
|
||||
@@ -163,6 +191,10 @@ export class FixedDepositAccountInterestRateChartStepComponent implements OnChan
|
||||
}
|
||||
|
||||
matchResult: SlabMatchResult | null = null;
|
||||
/** FD Special only: the backend's quote and payout schedule for the tenure and amount entered. */
|
||||
specialPreview: SpecialPayoutPreview | null = null;
|
||||
specialPreviewLoading = false;
|
||||
specialPreviewError: string | null = null;
|
||||
validationWarning: string | null = null;
|
||||
|
||||
// ── FD Type labels ──────────────────────────────────────────────────────
|
||||
@@ -270,6 +302,8 @@ export class FixedDepositAccountInterestRateChartStepComponent implements OnChan
|
||||
calculateRate() {
|
||||
this.validationWarning = null;
|
||||
this.matchResult = null;
|
||||
this.specialPreview = null;
|
||||
this.specialPreviewError = null;
|
||||
|
||||
if (!this.tenureValue || this.tenureValue <= 0) {
|
||||
this.validationWarning = 'Please enter a valid tenure value greater than 0.';
|
||||
@@ -334,30 +368,6 @@ export class FixedDepositAccountInterestRateChartStepComponent implements OnChan
|
||||
const frequencyId = this.resolveFrequencyId(this.tenurePeriodUnit);
|
||||
|
||||
if (this.principalAmount && this.principalAmount > 0) {
|
||||
// Years and Months convert exactly; a Days tenure is sent as days and never converted.
|
||||
let tenureInMonths: number | undefined;
|
||||
let tenureInDays: number | undefined;
|
||||
if (this.tenurePeriodUnit === 'Years') {
|
||||
tenureInMonths = this.tenureValue * 12;
|
||||
} else if (this.tenurePeriodUnit === 'Months') {
|
||||
tenureInMonths = this.tenureValue;
|
||||
} else {
|
||||
// Sent as days, whatever the count. Converting to whole months silently dropped the
|
||||
// remainder — a 200-day deposit quoted as six months lost 20 days of interest, and the
|
||||
// screen still said "200 Days" above the figure. The backend takes days directly and
|
||||
// works the calendar out itself.
|
||||
tenureInDays = enteredDays;
|
||||
}
|
||||
|
||||
// Map compounding period type id → months (0=Daily, 1=Daily, 4=Monthly, 5=Quarterly, 6=SemiAnnual, 7=Annual)
|
||||
const compMap: Record<number, number> = { 1: 0, 4: 1, 5: 3, 6: 6, 7: 12 };
|
||||
const compId = this.fixedDepositsAccountProductTemplate?.interestCompoundingPeriodType?.id ?? 4;
|
||||
const compMonths = compMap[compId] ?? 1;
|
||||
|
||||
// Map posting period type id → months
|
||||
const postId = this.fixedDepositsAccountProductTemplate?.interestPostingPeriodType?.id ?? 4;
|
||||
const postMonths = compMap[postId] ?? 1;
|
||||
|
||||
const emitResult = () => {
|
||||
if (frequencyId !== null) {
|
||||
this.tenureSelected.emit({
|
||||
@@ -375,7 +385,7 @@ export class FixedDepositAccountInterestRateChartStepComponent implements OnChan
|
||||
// Get submittedOnDate — Input takes priority over template data
|
||||
let submittedOnDateStr: string;
|
||||
if (this.submittedOnDate) {
|
||||
submittedOnDateStr = this.submittedOnDate.toISOString().split('T')[0];
|
||||
submittedOnDateStr = localIsoDate(this.submittedOnDate);
|
||||
} else {
|
||||
const submittedOnDateRaw =
|
||||
this.fixedDepositsAccountTemplate?.timeline?.submittedOnDate ??
|
||||
@@ -384,63 +394,18 @@ export class FixedDepositAccountInterestRateChartStepComponent implements OnChan
|
||||
const d = Array.isArray(submittedOnDateRaw)
|
||||
? new Date(submittedOnDateRaw[0], submittedOnDateRaw[1] - 1, submittedOnDateRaw[2])
|
||||
: new Date(submittedOnDateRaw);
|
||||
submittedOnDateStr = !isNaN(d.getTime())
|
||||
? d.toISOString().split('T')[0]
|
||||
: new Date().toISOString().split('T')[0];
|
||||
submittedOnDateStr = !isNaN(d.getTime()) ? localIsoDate(d) : localIsoDate(new Date());
|
||||
} else {
|
||||
submittedOnDateStr = new Date().toISOString().split('T')[0];
|
||||
submittedOnDateStr = localIsoDate(new Date());
|
||||
}
|
||||
}
|
||||
const daysInYearVal =
|
||||
this.fixedDepositsAccountProductTemplate?.interestCalculationDaysInYearType?.id === 360 ? 360 : 365;
|
||||
|
||||
// Always use backend API for accurate calculation (including daily compounding with 0)
|
||||
this.fixedDepositsService
|
||||
.calculateFixedDepositInterest({
|
||||
principalAmount: this.principalAmount,
|
||||
annualInterestRate: rate,
|
||||
tenureInMonths,
|
||||
tenureInDays,
|
||||
interestCompoundingPeriodInMonths: compMonths, // 0 = daily, now supported by backend
|
||||
interestPostingPeriodInMonths: postMonths,
|
||||
submittedOnDate: submittedOnDateStr,
|
||||
daysInYear: daysInYearVal
|
||||
})
|
||||
.subscribe({
|
||||
next: (apiResult: any) => {
|
||||
result.maturityAmount = apiResult.maturityAmount;
|
||||
result.interestEarned =
|
||||
apiResult.totalInterest ?? Math.round((apiResult.maturityAmount - this.principalAmount) * 100) / 100;
|
||||
this.matchResult = { ...result };
|
||||
emitResult();
|
||||
},
|
||||
error: () => {
|
||||
// Fallback if API fails: frontend formula
|
||||
const r = rate / 100;
|
||||
if (compMonths === 0) {
|
||||
// Daily compounding fallback
|
||||
result.maturityAmount =
|
||||
Math.round(this.principalAmount * Math.pow(1 + r / daysInYearVal, enteredDays) * 100) / 100;
|
||||
} else {
|
||||
const n = 12 / compMonths;
|
||||
const effectiveMonths = tenureInMonths ?? enteredDays / 30;
|
||||
const rPerCompounding = rate / (n * 100);
|
||||
// Whole compounding periods, then simple interest on the days left over. Flooring the
|
||||
// periods and stopping there dropped the remainder entirely: a 200-day deposit
|
||||
// compounding monthly quoted only six months of interest and lost the other twenty
|
||||
// days, which is the depositor's money.
|
||||
const wholePeriods = Math.floor(effectiveMonths / compMonths);
|
||||
const compounded = this.principalAmount * Math.pow(1 + rPerCompounding, wholePeriods);
|
||||
const leftoverDays = enteredDays - wholePeriods * compMonths * 30;
|
||||
const leftoverInterest =
|
||||
leftoverDays > 0 ? (compounded * rate * leftoverDays) / (100 * daysInYearVal) : 0;
|
||||
result.maturityAmount = Math.round((compounded + leftoverInterest) * 100) / 100;
|
||||
}
|
||||
result.interestEarned = Math.round((result.maturityAmount - this.principalAmount) * 100) / 100;
|
||||
this.matchResult = { ...result };
|
||||
emitResult();
|
||||
}
|
||||
});
|
||||
// The whole quote - rate, maturity date, maturity amount and, for FD Special, the month-by-month payout
|
||||
// schedule - comes from the backend, which builds the would-be account from the product exactly as Submit does
|
||||
// (its posting periods, compounding, days in year, rate card, senior-citizen rate) and runs the account's own
|
||||
// interest engine. Nothing is worked out here: on an error the quote is simply not shown, never estimated.
|
||||
if (frequencyId !== null) {
|
||||
this.loadAccountPreview(result, rate, submittedOnDateStr, frequencyId, emitResult);
|
||||
}
|
||||
} else {
|
||||
this.matchResult = result;
|
||||
if (frequencyId !== null) {
|
||||
@@ -457,6 +422,96 @@ export class FixedDepositAccountInterestRateChartStepComponent implements OnChan
|
||||
}
|
||||
}
|
||||
|
||||
/** The backend's payout schedule in the same popup the account itself uses, in its quote (preview) mode. */
|
||||
openSpecialSchedule(): void {
|
||||
if (!this.specialPreview) return;
|
||||
this.dialog.open(InterestScheduleDialogComponent, {
|
||||
width: '760px',
|
||||
data: {
|
||||
preview: true,
|
||||
currencyCode: this.fixedDepositsAccountProductTemplate?.currency?.code ?? 'INR',
|
||||
note:
|
||||
'Worked out by the account engine from the product, starting ' +
|
||||
this.specialPreview.startDate +
|
||||
' (the day the account is opened) and ending the day before maturity on ' +
|
||||
this.specialPreview.maturityDate +
|
||||
'. The first and last periods are part-months; each pays for the days it covers.',
|
||||
schedule: this.specialPreview.periods.map((p) => ({
|
||||
fromDate: p.fromDate,
|
||||
toDate: p.toDate,
|
||||
days: p.days,
|
||||
interestAmount: Number(p.interestAmount),
|
||||
paid: false
|
||||
}))
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Asks the backend for the quote (POST fixeddepositaccounts/account-preview) with the same terms Submit sends: the
|
||||
* product's own interest terms, and the senior-citizen rate the same way Submit overrides it. The rate, maturity
|
||||
* amount and interest shown are the backend's, so they match the account once it is created.
|
||||
*/
|
||||
private loadAccountPreview(
|
||||
result: SlabMatchResult,
|
||||
rate: number,
|
||||
submittedOnDateStr: string,
|
||||
frequencyId: number,
|
||||
emitResult: () => void
|
||||
): void {
|
||||
const pt = this.fixedDepositsAccountProductTemplate ?? {};
|
||||
const body: any = {
|
||||
clientId: pt.clientId ?? this.fixedDepositsAccountTemplate?.clientId,
|
||||
productId: pt.depositProductId ?? pt.productId ?? this.fixedDepositsAccountTemplate?.productId,
|
||||
submittedOnDate: submittedOnDateStr,
|
||||
dateFormat: 'yyyy-MM-dd',
|
||||
locale: 'en',
|
||||
depositAmount: this.principalAmount,
|
||||
depositPeriod: this.tenureValue,
|
||||
depositPeriodFrequencyId: frequencyId
|
||||
};
|
||||
if (pt.nominalAnnualInterestRate != null) body.nominalAnnualInterestRate = pt.nominalAnnualInterestRate;
|
||||
if (pt.interestCompoundingPeriodType?.id != null)
|
||||
body.interestCompoundingPeriodType = pt.interestCompoundingPeriodType.id;
|
||||
if (pt.interestPostingPeriodType?.id != null) body.interestPostingPeriodType = pt.interestPostingPeriodType.id;
|
||||
if (pt.interestCalculationType?.id != null) body.interestCalculationType = pt.interestCalculationType.id;
|
||||
if (pt.interestCalculationDaysInYearType?.id != null)
|
||||
body.interestCalculationDaysInYearType = pt.interestCalculationDaysInYearType.id;
|
||||
if (this.isSeniorCitizen && rate > 0) {
|
||||
body.isSeniorCitizen = true;
|
||||
body.seniorCitizenInterestRate = rate;
|
||||
body.nominalAnnualInterestRate = rate;
|
||||
}
|
||||
|
||||
this.specialPreview = null;
|
||||
this.specialPreviewError = null;
|
||||
this.specialPreviewLoading = true;
|
||||
this.fixedDepositsService.accountPreview(body).subscribe({
|
||||
next: (preview: SpecialPayoutPreview) => {
|
||||
this.specialPreviewLoading = false;
|
||||
this.specialPreview = preview;
|
||||
result.appliedRate = Number(preview.annualInterestRate);
|
||||
result.interestEarned = Number(preview.totalInterest);
|
||||
result.estimatedMonthlyPayout = preview.fullMonthPayout != null ? Number(preview.fullMonthPayout) : undefined;
|
||||
result.maturityAmount = Number(preview.maturityAmount);
|
||||
this.matchResult = { ...result };
|
||||
emitResult();
|
||||
this.cdr.markForCheck();
|
||||
},
|
||||
error: (e: any) => {
|
||||
this.specialPreviewLoading = false;
|
||||
this.specialPreviewError =
|
||||
e?.error?.errors?.[0]?.defaultUserMessage ||
|
||||
e?.error?.defaultUserMessage ||
|
||||
'Could not work out the maturity. Check the deposit details and try again.';
|
||||
result.maturityAmount = undefined;
|
||||
result.interestEarned = undefined;
|
||||
this.matchResult = { ...result };
|
||||
this.cdr.markForCheck();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
private resolveFrequencyId(unit: FdPeriodUnit): number | null {
|
||||
const options: any[] = this.fixedDepositsAccountProductTemplate?.periodFrequencyTypeOptions ?? [];
|
||||
const unitLower = unit.toLowerCase();
|
||||
@@ -471,12 +526,16 @@ export class FixedDepositAccountInterestRateChartStepComponent implements OnChan
|
||||
this.tenureValue = null;
|
||||
this.principalAmount = null;
|
||||
this.matchResult = null;
|
||||
this.specialPreview = null;
|
||||
this.specialPreviewError = null;
|
||||
this.validationWarning = null;
|
||||
this.tenurePeriodUnit = this.allowedPeriodUnits[0];
|
||||
}
|
||||
|
||||
onPeriodUnitChange() {
|
||||
this.matchResult = null;
|
||||
this.specialPreview = null;
|
||||
this.specialPreviewError = null;
|
||||
this.validationWarning = null;
|
||||
}
|
||||
|
||||
@@ -524,6 +583,8 @@ export class FixedDepositAccountInterestRateChartStepComponent implements OnChan
|
||||
this.calculateRate();
|
||||
} else {
|
||||
this.matchResult = null;
|
||||
this.specialPreview = null;
|
||||
this.specialPreviewError = null;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
+7
-5
@@ -326,7 +326,7 @@
|
||||
<div class="preview-dorm-step__body">
|
||||
<span class="preview-dorm-step__label">Inactive</span>
|
||||
<span class="preview-dorm-step__days">
|
||||
@if (fixedDepositsAccountProductTemplate?.daysToInactive != null) {
|
||||
@if ((fixedDepositsAccountProductTemplate?.daysToInactive ?? null) !== null) {
|
||||
After {{ fixedDepositsAccountProductTemplate.daysToInactive }} days of no transaction
|
||||
} @else {
|
||||
Not configured
|
||||
@@ -340,7 +340,7 @@
|
||||
<div class="preview-dorm-step__body">
|
||||
<span class="preview-dorm-step__label">Dormant</span>
|
||||
<span class="preview-dorm-step__days">
|
||||
@if (fixedDepositsAccountProductTemplate?.daysToDormancy != null) {
|
||||
@if ((fixedDepositsAccountProductTemplate?.daysToDormancy ?? null) !== null) {
|
||||
{{ fixedDepositsAccountProductTemplate.daysToDormancy }} days after inactive
|
||||
} @else {
|
||||
Not configured
|
||||
@@ -354,7 +354,7 @@
|
||||
<div class="preview-dorm-step__body">
|
||||
<span class="preview-dorm-step__label">Escheat</span>
|
||||
<span class="preview-dorm-step__days">
|
||||
@if (fixedDepositsAccountProductTemplate?.daysToEscheat != null) {
|
||||
@if ((fixedDepositsAccountProductTemplate?.daysToEscheat ?? null) !== null) {
|
||||
{{ fixedDepositsAccountProductTemplate.daysToEscheat }} days after dormant
|
||||
} @else {
|
||||
Not configured
|
||||
@@ -458,13 +458,15 @@
|
||||
<span class="preview-value">{{ maturitySummary.tenureLabel }}</span>
|
||||
</div>
|
||||
<div class="preview-field">
|
||||
<span class="preview-label">Estimated Interest Earned</span>
|
||||
<span class="preview-label">{{
|
||||
isSpecial ? 'Total Interest (paid out monthly)' : 'Estimated Interest Earned'
|
||||
}}</span>
|
||||
<span class="preview-value" style="color: var(--theme-success, #28a745); font-weight: 500">
|
||||
+ {{ maturitySummary.totalInterest | formatNumber }} {{ currencyName }}
|
||||
</span>
|
||||
</div>
|
||||
<div class="preview-field" style="grid-column: span 2">
|
||||
<span class="preview-label">Projected Maturity Amount</span>
|
||||
<span class="preview-label">{{ isSpecial ? 'Principal at Maturity' : 'Projected Maturity Amount' }}</span>
|
||||
<span class="preview-value preview-mono" style="font-weight: 600; font-size: 1.25rem">
|
||||
{{ maturitySummary.maturityAmount | formatNumber }} {{ currencyName }}
|
||||
</span>
|
||||
|
||||
+5
@@ -145,6 +145,11 @@ export class FixedDepositAccountPreviewStepComponent implements OnChanges {
|
||||
SPECIAL: 'FD Special (Monthly Interest Payout)'
|
||||
};
|
||||
|
||||
/** FD Special pays its interest out monthly; the principal alone comes back at maturity. */
|
||||
get isSpecial(): boolean {
|
||||
return this.fixedDepositsAccountProductTemplate?.fdType === 'SPECIAL';
|
||||
}
|
||||
|
||||
getFdTypeLabel(fdType: string): string {
|
||||
return this.fdTypeLabels[fdType] || fdType;
|
||||
}
|
||||
|
||||
+37
-15
@@ -11,15 +11,25 @@
|
||||
<span class="isd-header__icon"><fa-icon icon="calendar-alt"></fa-icon></span>
|
||||
<span class="isd-header__text">
|
||||
<span class="isd-header__title">{{ 'labels.heading.Monthly Interest Payout Schedule' | translate }}</span>
|
||||
<span class="isd-header__sub">{{ paidCount }} of {{ schedule.length }} periods paid so far</span>
|
||||
<span class="isd-header__sub">
|
||||
@if (isPreview) {
|
||||
{{ schedule.length }} monthly payouts
|
||||
} @else {
|
||||
{{ paidCount }} of {{ schedule.length }} periods paid so far
|
||||
}
|
||||
</span>
|
||||
</span>
|
||||
</h2>
|
||||
|
||||
<div mat-dialog-content class="isd-content">
|
||||
<p class="isd-note">
|
||||
<fa-icon icon="info-circle" class="m-r-5"></fa-icon>
|
||||
Computed by the actual interest engine for this account's full term -- the exact amount posted each month can vary
|
||||
slightly with the number of days in that month.
|
||||
@if (data.note) {
|
||||
{{ data.note }}
|
||||
} @else {
|
||||
Computed by the actual interest engine for this account's full term -- the exact amount posted each month can
|
||||
vary slightly with the number of days in that month.
|
||||
}
|
||||
</p>
|
||||
|
||||
<div class="isd-scroll">
|
||||
@@ -28,8 +38,13 @@
|
||||
<tr>
|
||||
<th>{{ 'labels.inputs.From Date' | translate }}</th>
|
||||
<th>{{ 'labels.inputs.To Date' | translate }}</th>
|
||||
@if (isPreview) {
|
||||
<th class="isd-table__amount-col">Days</th>
|
||||
}
|
||||
<th class="isd-table__amount-col">{{ 'labels.inputs.Interest Amount' | translate }}</th>
|
||||
<th class="isd-table__status-col">{{ 'labels.inputs.Status' | translate }}</th>
|
||||
@if (!isPreview) {
|
||||
<th class="isd-table__status-col">{{ 'labels.inputs.Status' | translate }}</th>
|
||||
}
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
@@ -37,28 +52,35 @@
|
||||
<tr [class.isd-table__row--paid]="period.paid">
|
||||
<td>{{ period.fromDate | dateFormat }}</td>
|
||||
<td>{{ period.toDate | dateFormat }}</td>
|
||||
@if (isPreview) {
|
||||
<td class="isd-table__amount-col">{{ period.days }}</td>
|
||||
}
|
||||
<td class="isd-table__amount-col">
|
||||
{{ period.interestAmount | currency: currencyCode : 'symbol-narrow' : '1.2-2' }}
|
||||
</td>
|
||||
<td class="isd-table__status-col">
|
||||
@if (period.paid) {
|
||||
<span class="isd-badge isd-badge--paid">
|
||||
<fa-icon icon="check-circle" class="m-r-5"></fa-icon>Paid
|
||||
</span>
|
||||
} @else {
|
||||
<span class="isd-badge isd-badge--pending">Pending</span>
|
||||
}
|
||||
</td>
|
||||
@if (!isPreview) {
|
||||
<td class="isd-table__status-col">
|
||||
@if (period.paid) {
|
||||
<span class="isd-badge isd-badge--paid">
|
||||
<fa-icon icon="check-circle" class="m-r-5"></fa-icon>Paid
|
||||
</span>
|
||||
} @else {
|
||||
<span class="isd-badge isd-badge--pending">Pending</span>
|
||||
}
|
||||
</td>
|
||||
}
|
||||
</tr>
|
||||
}
|
||||
</tbody>
|
||||
<tfoot>
|
||||
<tr>
|
||||
<td colspan="2">{{ 'labels.inputs.Total Interest Payable' | translate }}</td>
|
||||
<td [attr.colspan]="isPreview ? 3 : 2">{{ 'labels.inputs.Total Interest Payable' | translate }}</td>
|
||||
<td class="isd-table__amount-col">
|
||||
{{ totalInterest | currency: currencyCode : 'symbol-narrow' : '1.2-2' }}
|
||||
</td>
|
||||
<td></td>
|
||||
@if (!isPreview) {
|
||||
<td></td>
|
||||
}
|
||||
</tr>
|
||||
</tfoot>
|
||||
</table>
|
||||
|
||||
+13
-2
@@ -26,6 +26,9 @@ import { STANDALONE_SHARED_IMPORTS } from 'app/standalone-shared.module';
|
||||
* a 24+ month term makes for a long table that would otherwise push every other card on the tab down
|
||||
* the page. Each row also shows whether that period's interest has actually posted yet, not just what
|
||||
* the engine projects it to be.
|
||||
*
|
||||
* Also opened from the account-opening screen with `preview: true`: the backend's quote for an FD Special
|
||||
* not opened yet, so there is no paid / pending status, and each period shows its number of days.
|
||||
*/
|
||||
@Component({
|
||||
selector: 'mifosx-interest-schedule-dialog',
|
||||
@@ -46,14 +49,22 @@ import { STANDALONE_SHARED_IMPORTS } from 'app/standalone-shared.module';
|
||||
export class InterestScheduleDialogComponent {
|
||||
dialogRef = inject<MatDialogRef<InterestScheduleDialogComponent>>(MatDialogRef);
|
||||
data: {
|
||||
schedule: Array<{ fromDate: any; toDate: any; interestAmount: number; paid: boolean }>;
|
||||
schedule: Array<{ fromDate: any; toDate: any; interestAmount: number; paid: boolean; days?: number }>;
|
||||
currencyCode: string;
|
||||
/** Account-opening quote: no paid / pending status. */
|
||||
preview?: boolean;
|
||||
/** Shown above the table instead of the account note. */
|
||||
note?: string;
|
||||
} = inject(MAT_DIALOG_DATA);
|
||||
|
||||
get schedule(): Array<{ fromDate: any; toDate: any; interestAmount: number; paid: boolean }> {
|
||||
get schedule(): Array<{ fromDate: any; toDate: any; interestAmount: number; paid: boolean; days?: number }> {
|
||||
return this.data.schedule ?? [];
|
||||
}
|
||||
|
||||
get isPreview(): boolean {
|
||||
return !!this.data.preview;
|
||||
}
|
||||
|
||||
get currencyCode(): string {
|
||||
return this.data.currencyCode;
|
||||
}
|
||||
|
||||
@@ -154,6 +154,16 @@ export class FixedDepositsService {
|
||||
return this.http.get(`/fixeddepositaccounts/calculate-fd-interest`, { params: httpParams });
|
||||
}
|
||||
|
||||
/**
|
||||
* FD quote while opening the account, for every FD type: the same body as create (clientId, productId,
|
||||
* submittedOnDate, depositAmount, depositPeriod, depositPeriodFrequencyId, interest terms, senior citizen ...). The
|
||||
* backend builds the would-be account from the product, without saving it, and returns the applied rate, maturity
|
||||
* date and amount Submit would store, and the interest periods (FD Special: the monthly payout schedule).
|
||||
*/
|
||||
accountPreview(body: any): Observable<any> {
|
||||
return this.http.post(`/fixeddepositaccounts/account-preview`, body);
|
||||
}
|
||||
|
||||
/**
|
||||
* FD Dashboard's "Renew" action on a matured FD whose product has a renewal grace period
|
||||
* configured: backdated to the original maturity date within the grace window, or from today
|
||||
|
||||
@@ -11,16 +11,39 @@
|
||||
<button mat-stroked-button [routerLink]="['/accounts/dds']" class="pm-back">
|
||||
<i class="fas fa-arrow-left"></i> Party Master
|
||||
</button>
|
||||
<h2 class="pm-header__title">{{ partyId ? 'Edit Party' : 'Party Master Entry' }}</h2>
|
||||
<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) {
|
||||
<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 class="pm-card">
|
||||
<form [formGroup]="partyForm">
|
||||
<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-section">
|
||||
<div class="pm-section__header">
|
||||
@@ -38,35 +61,27 @@
|
||||
matInput
|
||||
required
|
||||
formControlName="clientId"
|
||||
[matAutocomplete]="clientAuto"
|
||||
(focus)="onClientFocus()"
|
||||
placeholder="Click to see customers, or type a name or ID…"
|
||||
(keydown.enter)="$event.preventDefault(); openClientSearch()"
|
||||
placeholder="Client ID, or search by name or mobile number…"
|
||||
autocomplete="off"
|
||||
/>
|
||||
<fa-icon matSuffix icon="search" class="pm-search-ic"></fa-icon>
|
||||
<mat-autocomplete
|
||||
#clientAuto="matAutocomplete"
|
||||
panelWidth="min(30rem, 92vw)"
|
||||
[displayWith]="displayClient"
|
||||
<!-- Opens Search Customer; picking a customer puts them here, which fills the rest. -->
|
||||
<button
|
||||
matSuffix
|
||||
type="button"
|
||||
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) {
|
||||
<mat-option [value]="client">
|
||||
<div class="pm-opt">
|
||||
<span class="pm-opt-id">{{ client.id }}</span>
|
||||
<span class="pm-opt-main">{{ client.displayName }}</span>
|
||||
<span class="pm-opt-sub">{{ client.officeName }}</span>
|
||||
</div>
|
||||
</mat-option>
|
||||
}
|
||||
<!-- Says so when the list is only the first page, so a customer missing from
|
||||
it reads as "keep typing" rather than "not a customer here". -->
|
||||
@if (clientListIsPartial) {
|
||||
<mat-option disabled class="pm-opt-more">
|
||||
Showing {{ clientOptions.length }} of {{ clientTotal }} — type a name to find the rest
|
||||
</mat-option>
|
||||
}
|
||||
</mat-autocomplete>
|
||||
@if (loadedClientName) {
|
||||
<i class="fas fa-search"></i>
|
||||
</button>
|
||||
@if (clientNeedsSearch) {
|
||||
<mat-hint class="pm-hint--missing"
|
||||
>Press search to find this client by name or mobile number</mat-hint
|
||||
>
|
||||
} @else if (loadedClientName) {
|
||||
<mat-hint class="pm-hint--found">{{ loadedClientName }}</mat-hint>
|
||||
} @else if (clientLookupFailed) {
|
||||
<mat-hint class="pm-hint--missing">No client with this ID</mat-hint>
|
||||
@@ -228,18 +243,28 @@
|
||||
</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">
|
||||
<label class="pm-f-lbl">Party Code</label>
|
||||
<mat-form-field appearance="outline" class="pm-field">
|
||||
<input matInput formControlName="partyCode" placeholder="Auto-generated" />
|
||||
</mat-form-field>
|
||||
<div class="pm-code">
|
||||
<span class="pm-code__value">{{
|
||||
(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>
|
||||
|
||||
<!-- 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">
|
||||
<label class="pm-f-lbl">Last A/c</label>
|
||||
<mat-form-field appearance="outline" class="pm-field">
|
||||
<input matInput formControlName="lastAccount" placeholder="—" />
|
||||
</mat-form-field>
|
||||
<div class="pm-code">
|
||||
<span class="pm-code__value">{{ lastAccountNo || '—' }}</span>
|
||||
<span class="pm-code__hint">Latest DDS account opened</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="pm-f">
|
||||
@@ -251,7 +276,27 @@
|
||||
</mat-select>
|
||||
</mat-form-field>
|
||||
</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">
|
||||
<label class="pm-f-lbl">Party Name<b>*</b></label>
|
||||
<mat-form-field appearance="outline" class="pm-field">
|
||||
@@ -427,7 +472,17 @@
|
||||
</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__header">
|
||||
<i class="fas fa-user-friends"></i>
|
||||
@@ -519,7 +574,17 @@
|
||||
<p class="pm-note">An adult's account needs no guardian details.</p>
|
||||
}
|
||||
</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__header">
|
||||
<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. -->
|
||||
</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__header">
|
||||
<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. -->
|
||||
</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__header">
|
||||
<i class="fas fa-exchange-alt"></i>
|
||||
@@ -821,15 +907,30 @@
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
</mat-tab>
|
||||
</div>
|
||||
</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-section">
|
||||
<div class="pm-grid">
|
||||
<div class="pm-f">
|
||||
<div class="pm-section__header">
|
||||
<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>
|
||||
<mat-form-field appearance="outline" class="pm-field">
|
||||
<mat-select formControlName="nomineeApplicable">
|
||||
@@ -840,139 +941,172 @@
|
||||
</div>
|
||||
|
||||
@if (nomineesRequired) {
|
||||
<mat-radio-group formControlName="nomineeSplitMode" class="pm-radio-group">
|
||||
<mat-radio-button value="Percentage">Percentage</mat-radio-button>
|
||||
<mat-radio-button value="Sequence">Sequence</mat-radio-button>
|
||||
</mat-radio-group>
|
||||
|
||||
<button mat-stroked-button type="button" class="pm-add-nominee" (click)="addNominee()">
|
||||
<i class="fas fa-plus"></i> Add Nominee
|
||||
</button>
|
||||
<div class="pm-f pm-nom-bar__mode">
|
||||
<label class="pm-f-lbl">Split by</label>
|
||||
<mat-radio-group formControlName="nomineeSplitMode" class="pm-nom-mode">
|
||||
<mat-radio-button value="Percentage">Percentage</mat-radio-button>
|
||||
<mat-radio-button value="Sequence">Sequence</mat-radio-button>
|
||||
</mat-radio-group>
|
||||
</div>
|
||||
|
||||
@if (splitByPercentage && nominees.length > 0) {
|
||||
<span class="pm-share-total" [class.pm-share-total-bad]="nomineeShareError">
|
||||
{{ nomineeShareTotal }}% of 100% allocated
|
||||
@if (nomineeShareError) {
|
||||
— {{ nomineeShareError }}
|
||||
}
|
||||
</span>
|
||||
<div class="pm-nom-total" [class.pm-nom-total--bad]="nomineeShareError">
|
||||
<div class="pm-nom-total__text">
|
||||
<strong>{{ nomineeShareTotal }}%</strong> of 100% allocated
|
||||
@if (nomineeShareError) {
|
||||
<span class="pm-nom-total__error">— {{ nomineeShareError }}</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) {
|
||||
<span class="pm-share-total" [class.pm-share-total-bad]="nomineeOrderError">
|
||||
@if (nomineeOrderError) {
|
||||
{{ nomineeOrderError }}
|
||||
} @else {
|
||||
Paid in the order numbered, 1 first
|
||||
}
|
||||
</span>
|
||||
<div class="pm-nom-total" [class.pm-nom-total--bad]="nomineeOrderError">
|
||||
<div class="pm-nom-total__text">
|
||||
@if (nomineeOrderError) {
|
||||
{{ nomineeOrderError }}
|
||||
} @else {
|
||||
Paid in the order numbered, 1 first
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
|
||||
<button mat-stroked-button type="button" class="pm-nom-add" (click)="addNominee()">
|
||||
<i class="fas fa-plus"></i> Add Nominee
|
||||
</button>
|
||||
}
|
||||
</div>
|
||||
|
||||
@if (!nomineesRequired) {
|
||||
<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) {
|
||||
<div class="pm-nominee-row" [formGroup]="$any(nominee)">
|
||||
<div class="pm-f pm-f--date">
|
||||
<label class="pm-f-lbl">Nom.Dt<b>*</b></label>
|
||||
<mat-form-field appearance="outline" class="pm-field">
|
||||
<input matInput required [matDatepicker]="nomDatePicker" formControlName="nominationDate" />
|
||||
<mat-datepicker-toggle matIconSuffix [for]="nomDatePicker"></mat-datepicker-toggle>
|
||||
<mat-datepicker #nomDatePicker></mat-datepicker>
|
||||
</mat-form-field>
|
||||
<div class="pm-nom-card" [formGroup]="$any(nominee)">
|
||||
<div class="pm-nom-card__head">
|
||||
<span class="pm-nom-card__no">{{ i + 1 }}</span>
|
||||
<span class="pm-nom-card__title">Nominee {{ i + 1 }}</span>
|
||||
@if (isNomineeMinor(nominee)) {
|
||||
<span class="pm-nom-card__badge">Minor</span>
|
||||
}
|
||||
<button
|
||||
type="button"
|
||||
class="pm-nom-card__remove"
|
||||
(click)="removeNominee(i)"
|
||||
matTooltip="Remove nominee"
|
||||
aria-label="Remove nominee"
|
||||
>
|
||||
<i class="fas fa-trash"></i>
|
||||
</button>
|
||||
</div>
|
||||
<div class="pm-f">
|
||||
<label class="pm-f-lbl">Name<b>*</b></label>
|
||||
<mat-form-field appearance="outline" class="pm-field">
|
||||
<input matInput required formControlName="name" />
|
||||
</mat-form-field>
|
||||
</div>
|
||||
<div class="pm-f">
|
||||
<label class="pm-f-lbl">Mobile No</label>
|
||||
<mat-form-field appearance="outline" class="pm-field">
|
||||
<input matInput formControlName="mobileNo" mifosxRestrictInput="digits" />
|
||||
</mat-form-field>
|
||||
</div>
|
||||
<div class="pm-f pm-f--date">
|
||||
<label class="pm-f-lbl">Birth Dt</label>
|
||||
<mat-form-field appearance="outline" class="pm-field">
|
||||
<input matInput [matDatepicker]="nomBirthPicker" formControlName="birthDate" />
|
||||
<mat-datepicker-toggle matIconSuffix [for]="nomBirthPicker"></mat-datepicker-toggle>
|
||||
<mat-datepicker #nomBirthPicker></mat-datepicker>
|
||||
</mat-form-field>
|
||||
</div>
|
||||
<div class="pm-f pm-f--tight">
|
||||
<!-- Read from the date of birth, not asked: the two arriving separately is how
|
||||
a ten-year-old came to be recorded as not a minor. -->
|
||||
<label class="pm-f-lbl">Minor</label>
|
||||
<mat-form-field appearance="outline" class="pm-field">
|
||||
<input matInput readonly [value]="isNomineeMinor(nominee) ? 'Yes' : 'No'" />
|
||||
<mat-hint>From date of birth</mat-hint>
|
||||
</mat-form-field>
|
||||
</div>
|
||||
<div class="pm-f">
|
||||
<label class="pm-f-lbl">Relation<b>*</b></label>
|
||||
<mat-form-field appearance="outline" class="pm-field">
|
||||
<input matInput required formControlName="relation" />
|
||||
</mat-form-field>
|
||||
</div>
|
||||
<div class="pm-f pm-f--tight">
|
||||
<label class="pm-f-lbl">Gender<b>*</b></label>
|
||||
<mat-form-field appearance="outline" class="pm-field">
|
||||
<mat-select required formControlName="gender">
|
||||
<mat-option value="Male">Male</mat-option>
|
||||
<mat-option value="Female">Female</mat-option>
|
||||
<mat-option value="Other">Other</mat-option>
|
||||
</mat-select>
|
||||
</mat-form-field>
|
||||
</div>
|
||||
<div class="pm-f">
|
||||
<label class="pm-f-lbl">
|
||||
Guar. Name
|
||||
@if (isNomineeMinor(nominee)) {
|
||||
<b>*</b>
|
||||
}
|
||||
</label>
|
||||
<mat-form-field appearance="outline" class="pm-field">
|
||||
<input matInput [required]="isNomineeMinor(nominee)" formControlName="guardianName" />
|
||||
@if (isNomineeMinor(nominee)) {
|
||||
<mat-hint>Required for a minor</mat-hint>
|
||||
}
|
||||
</mat-form-field>
|
||||
</div>
|
||||
@if (splitByPercentage) {
|
||||
<div class="pm-f pm-f--tight">
|
||||
<label class="pm-f-lbl">Share %<b>*</b></label>
|
||||
|
||||
<div class="pm-nom-card__grid">
|
||||
<div class="pm-f">
|
||||
<label class="pm-f-lbl">Nomination Date<b>*</b></label>
|
||||
<mat-form-field appearance="outline" class="pm-field">
|
||||
<input
|
||||
matInput
|
||||
type="number"
|
||||
min="0.01"
|
||||
max="100"
|
||||
step="0.01"
|
||||
required
|
||||
formControlName="sharePercent"
|
||||
/>
|
||||
<input matInput required [matDatepicker]="nomDatePicker" formControlName="nominationDate" />
|
||||
<mat-datepicker-toggle matIconSuffix [for]="nomDatePicker"></mat-datepicker-toggle>
|
||||
<mat-datepicker #nomDatePicker></mat-datepicker>
|
||||
</mat-form-field>
|
||||
</div>
|
||||
} @else {
|
||||
<div class="pm-f pm-f--tight">
|
||||
<label class="pm-f-lbl">Order<b>*</b></label>
|
||||
<div class="pm-f">
|
||||
<label class="pm-f-lbl">Name<b>*</b></label>
|
||||
<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>
|
||||
</div>
|
||||
}
|
||||
<button
|
||||
mat-icon-button
|
||||
type="button"
|
||||
class="pm-remove-nominee"
|
||||
(click)="removeNominee(i)"
|
||||
matTooltip="Remove nominee"
|
||||
>
|
||||
<i class="fas fa-trash"></i>
|
||||
</button>
|
||||
<div 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">
|
||||
<label class="pm-f-lbl">Birth Date</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">
|
||||
<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>
|
||||
@@ -1036,6 +1170,16 @@
|
||||
<i class="fas fa-lock" style="margin-right: 6px"></i> Close Account
|
||||
</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
|
||||
mat-raised-button
|
||||
color="primary"
|
||||
|
||||
@@ -135,75 +135,12 @@ $primary: #0f766e;
|
||||
height: 56px;
|
||||
}
|
||||
|
||||
.pm-add-nominee {
|
||||
color: $primary;
|
||||
height: fit-content;
|
||||
align-self: center;
|
||||
}
|
||||
|
||||
/*
|
||||
* The running total of the shares entered, beside the button that adds another. Reads as ordinary
|
||||
* text while the split still adds up and turns to the warning colour when it does not, so the clerk
|
||||
* sees the state of the whole nomination without having to try to save it.
|
||||
*/
|
||||
.pm-share-total {
|
||||
align-self: center;
|
||||
margin-left: 12px;
|
||||
font-size: 13px;
|
||||
color: rgb(0 0 0 / 60%);
|
||||
}
|
||||
|
||||
.pm-share-total-bad {
|
||||
color: #b3261e;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.pm-empty {
|
||||
color: rgb(0 0 0 / 50%);
|
||||
font-size: 14px;
|
||||
margin: 0 0 8px;
|
||||
}
|
||||
|
||||
/*
|
||||
* One nominee, laid out as fields that wrap rather than columns that must all fit.
|
||||
*
|
||||
* A fixed column per field only works while the count is fixed, and it is not - the Share % field
|
||||
* appears and disappears with the split mode, and a tenth column pushed the remove button onto a
|
||||
* line of its own. Each field instead states the width it needs to stay readable and the row breaks
|
||||
* where it runs out of space, so nothing is squeezed and nothing is orphaned.
|
||||
*/
|
||||
.pm-nominee-row {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 4px 12px;
|
||||
align-items: start;
|
||||
padding: 12px 0;
|
||||
border-top: 1px dashed rgb(0 0 0 / 10%);
|
||||
|
||||
&:first-of-type {
|
||||
border-top: none;
|
||||
}
|
||||
|
||||
/* Names and addresses need room; a date, Yes/No or a percentage does not. */
|
||||
.pm-f {
|
||||
flex: 1 1 170px;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.pm-f--date {
|
||||
flex: 0 1 140px;
|
||||
}
|
||||
|
||||
.pm-f--tight {
|
||||
flex: 0 1 100px;
|
||||
}
|
||||
}
|
||||
|
||||
.pm-remove-nominee {
|
||||
color: #b91c1c;
|
||||
margin-top: 8px;
|
||||
}
|
||||
|
||||
.pm-action-bar {
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
@@ -260,6 +197,22 @@ $primary: #0f766e;
|
||||
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. */
|
||||
.pm-section__sub {
|
||||
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 {
|
||||
margin: 0;
|
||||
font-size: 0.74rem;
|
||||
@@ -526,3 +461,478 @@ input.pm-money {
|
||||
border-color: rgb(180 83 9 / 25%);
|
||||
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 { MatAutocompleteModule } from '@angular/material/autocomplete';
|
||||
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 { 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 { Dates } from 'app/core/utils/dates';
|
||||
import { AlertService } from 'app/core/alert/alert.service';
|
||||
import { searchClientsAnyCase } from '../client-search';
|
||||
|
||||
/** Custom Components */
|
||||
import { PrematureCloseDialogComponent } from '../premature-close-dialog/premature-close-dialog.component';
|
||||
@@ -121,6 +124,133 @@ export class CreatePartyComponent implements OnInit {
|
||||
}
|
||||
|
||||
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';
|
||||
linkedSavingsAccountId: number | null = null;
|
||||
|
||||
@@ -152,29 +282,9 @@ export class CreatePartyComponent implements OnInit {
|
||||
agentLookupFailed = false;
|
||||
loadedGuardianName = '';
|
||||
|
||||
/** Clients matching what has been typed - an officer knows the customer's name, rarely their id. */
|
||||
clientOptions: any[] = [];
|
||||
/** True when something other than an id has been typed in Client ID or Name: it is found with the search button. */
|
||||
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[] = [];
|
||||
/** The account types matching what has been typed into Sub Type. */
|
||||
subTypeOptions: any[] = [];
|
||||
@@ -190,6 +300,31 @@ export class CreatePartyComponent implements OnInit {
|
||||
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. */
|
||||
subTypes: any[] = [];
|
||||
|
||||
@@ -326,12 +461,6 @@ export class CreatePartyComponent implements OnInit {
|
||||
Validators.required
|
||||
],
|
||||
savingsAccountNo: [''],
|
||||
lastAccount: [
|
||||
{
|
||||
value: '',
|
||||
disabled: true
|
||||
}
|
||||
],
|
||||
partyCode: [
|
||||
{
|
||||
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');
|
||||
if (routeId) {
|
||||
this.organizationService.getParty(routeId).subscribe((party: any) => this.patchFormFromParty(party));
|
||||
@@ -567,9 +720,9 @@ export class CreatePartyComponent implements OnInit {
|
||||
// ---------------------------------------------------------------- lookups
|
||||
|
||||
/**
|
||||
* Fills the identity block from the customer, the way the legacy screen did - except that the
|
||||
* customer can be found by name as well as by id, since an officer at the counter is told a name.
|
||||
* The control holds the picked client object, or whatever has been typed so far.
|
||||
* Fills the identity block from the customer, the way the legacy screen did - keyed in as an id, which is fetched as
|
||||
* it is typed (the way the Agent screen does it), or found by name or mobile number with the search button, which
|
||||
* puts the id in the box.
|
||||
*/
|
||||
private wireClientLookup(): void {
|
||||
this.partyForm
|
||||
@@ -584,72 +737,24 @@ export class CreatePartyComponent implements OnInit {
|
||||
this.partyForm.get('adultMinor')?.enable({ emitEvent: false });
|
||||
this.loadedClientName = '';
|
||||
this.clientLookupFailed = false;
|
||||
this.clientNeedsSearch = false;
|
||||
|
||||
// Picked from the list.
|
||||
if (value && typeof value === 'object') {
|
||||
this.clientOptions = [];
|
||||
this.fillFromClient(value.id, value.displayName);
|
||||
return;
|
||||
}
|
||||
|
||||
const typed = String(value ?? '').trim();
|
||||
const typed = String(value && typeof value === 'object' ? value.id : (value ?? '')).trim();
|
||||
if (!typed) {
|
||||
// Emptying the field puts the opening list back, rather than leaving nothing to pick from.
|
||||
this.showClientList();
|
||||
return;
|
||||
}
|
||||
|
||||
// A bare number is an id typed straight in; anything else is a name to search for. A number
|
||||
// can be both, so both are tried and whichever matches wins.
|
||||
// A bare number is an id typed straight in.
|
||||
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 {
|
||||
this.clientsService.getClientData(String(clientId), true).subscribe({
|
||||
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]". */
|
||||
displayClient = (value: any): string => {
|
||||
if (!value) {
|
||||
@@ -803,7 +937,13 @@ export class CreatePartyComponent implements OnInit {
|
||||
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 {
|
||||
this.clientsService.getClientKyc(clientId, true).subscribe({
|
||||
next: (kyc: any) => {
|
||||
@@ -812,6 +952,8 @@ export class CreatePartyComponent implements OnInit {
|
||||
.split('.')
|
||||
.pop();
|
||||
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.
|
||||
error: () => undefined
|
||||
@@ -1554,7 +1696,6 @@ export class CreatePartyComponent implements OnInit {
|
||||
officeId: raw.officeId,
|
||||
savingsAccountNo: this.linkedSavingsAccountId ? undefined : raw.savingsAccountNo || undefined,
|
||||
subType: raw.subType,
|
||||
lastAccount: raw.lastAccount || undefined,
|
||||
nominal: raw.nominal === 'Yes',
|
||||
aadharNo: raw.aadharNo || undefined,
|
||||
partyName: raw.partyName,
|
||||
@@ -1638,6 +1779,12 @@ export class CreatePartyComponent implements OnInit {
|
||||
};
|
||||
|
||||
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);
|
||||
} else {
|
||||
this.organizationService.createParty(payload).subscribe(done);
|
||||
@@ -1850,7 +1997,6 @@ export class CreatePartyComponent implements OnInit {
|
||||
agentId: party.agentId,
|
||||
officeId: party.officeId,
|
||||
partyCode: party.partyCode,
|
||||
lastAccount: party.lastAccount,
|
||||
nominal: party.nominal ? 'Yes' : 'No',
|
||||
aadharNo: party.aadharNo,
|
||||
partyName: party.partyName,
|
||||
@@ -1931,6 +2077,10 @@ export class CreatePartyComponent implements OnInit {
|
||||
// A closed party is a record to look at, not to change.
|
||||
if (this.isClosed) {
|
||||
this.partyForm.disable({ emitEvent: false });
|
||||
} else if (this.termsLocked) {
|
||||
for (const name of CreatePartyComponent.LOCKED_WHEN_ACTIVE) {
|
||||
this.partyForm.get(name)?.disable({ emitEvent: false });
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+4
-3
@@ -110,8 +110,8 @@ export class CreateRecurringDepositsAccountComponent {
|
||||
*/
|
||||
setTemplate($event: any) {
|
||||
this.recurringDepositsAccountProductTemplate = $event;
|
||||
this.productsService.getFdBaseInterestRates().subscribe({
|
||||
next: (rates: any[]) => (this.cbsBaseRates = rates || []),
|
||||
this.productsService.getRdBaseInterestRates().subscribe({
|
||||
next: (rates: any) => (this.cbsBaseRates = Array.isArray(rates) ? rates : rates ? [rates] : []),
|
||||
error: () => (this.cbsBaseRates = [])
|
||||
});
|
||||
}
|
||||
@@ -198,7 +198,8 @@ export class CreateRecurringDepositsAccountComponent {
|
||||
if (this.recurringDepositsAccountProductTemplate?.introducerRequired && !detailsStep?.introducerId) {
|
||||
this.alertService.alert({
|
||||
type: 'Error',
|
||||
message: 'This product requires an introducer. Please choose an existing customer as introducer before submitting.'
|
||||
message:
|
||||
'This product requires an introducer. Please choose an existing customer as introducer before submitting.'
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
+2
-2
@@ -144,8 +144,8 @@ export class EditRecurringDepositAccountComponent {
|
||||
|
||||
setTemplate($event: any) {
|
||||
this.recurringDepositsAccountProductTemplate = $event;
|
||||
this.productsService.getFdBaseInterestRates().subscribe({
|
||||
next: (rates: any[]) => (this.cbsBaseRates = rates || []),
|
||||
this.productsService.getRdBaseInterestRates().subscribe({
|
||||
next: (rates: any) => (this.cbsBaseRates = Array.isArray(rates) ? rates : rates ? [rates] : []),
|
||||
error: () => (this.cbsBaseRates = [])
|
||||
});
|
||||
}
|
||||
|
||||
+6
-2
@@ -127,7 +127,8 @@ export class RecurringDepositsAccountInterestRateChartStepComponent implements O
|
||||
}>();
|
||||
|
||||
get activeBase(): any {
|
||||
return (this.cbsBaseRates || []).find((r) => r.fdType === 'DOUBLE' && r.isActive);
|
||||
// The recurring deposit's own CBS base chart; its active version carries the senior citizen rate of each slab.
|
||||
return (this.cbsBaseRates || []).find((r) => r.isActive) ?? (this.cbsBaseRates || [])[0];
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -173,7 +174,10 @@ export class RecurringDepositsAccountInterestRateChartStepComponent implements O
|
||||
return baseSlabs;
|
||||
}
|
||||
// Products made before RD charts moved to months still hold the base chart's own year slabs
|
||||
const candidates = [...baseSlabs, ...this.normalizeCbsSlabs(cbsSlabs)];
|
||||
const candidates = [
|
||||
...baseSlabs,
|
||||
...this.normalizeCbsSlabs(cbsSlabs)
|
||||
];
|
||||
return productSlabs.map((slab: any) => {
|
||||
if (slab.seniorCitizenRate != null) {
|
||||
return slab;
|
||||
|
||||
+28
@@ -50,6 +50,34 @@
|
||||
<span class="nx-row__label">{{ 'labels.inputs.Maturity Date' | translate }}</span>
|
||||
<span class="nx-row__value">{{ recurringDepositsAccountData.maturityDate | dateFormat }}</span>
|
||||
</div>
|
||||
@if (installmentPosition) {
|
||||
<div class="nx-row">
|
||||
<span class="nx-row__label">Next Installment Due</span>
|
||||
<span class="nx-row__value">
|
||||
@if (installmentPosition.nextDueDate) {
|
||||
{{ installmentPosition.nextDueDate | dateFormat }} ·
|
||||
{{ installmentPosition.installmentAmount | currency: currency?.code : 'symbol-narrow' : '1.2-2' }}
|
||||
} @else {
|
||||
<span class="nx-muted">All installments paid</span>
|
||||
}
|
||||
</span>
|
||||
</div>
|
||||
<div class="nx-row">
|
||||
<span class="nx-row__label">Installments Paid</span>
|
||||
<span class="nx-row__value"
|
||||
>{{ installmentPosition.paidInstallments }} of {{ installmentPosition.totalInstallments }}</span
|
||||
>
|
||||
</div>
|
||||
@if (installmentPosition.overdueInstallments > 0) {
|
||||
<div class="nx-row">
|
||||
<span class="nx-row__label">Overdue</span>
|
||||
<span class="nx-row__value" style="color: var(--mat-sys-error, #d32f2f)">
|
||||
{{ installmentPosition.overdueInstallments }} installment(s) ·
|
||||
{{ installmentPosition.overdueAmount | currency: currency?.code : 'symbol-narrow' : '1.2-2' }}
|
||||
</span>
|
||||
</div>
|
||||
}
|
||||
}
|
||||
@if (
|
||||
recurringDepositsAccountData.timeline.closedOnDate || recurringDepositsAccountData.summary.totalWitddrawals
|
||||
) {
|
||||
|
||||
+31
@@ -46,11 +46,21 @@ export class GeneralTabComponent {
|
||||
totalLienAmount = 0;
|
||||
/** Loan limit today (Lien Marked products only): % that applies, what it comes to, and what is left. */
|
||||
lienLimit: { percentage: number; maxLoanAmount: number; availableForLoan: number } | null = null;
|
||||
/** When the next installment is expected and how much, plus anything already overdue; null once fully paid. */
|
||||
installmentPosition: {
|
||||
nextDueDate: string | null;
|
||||
installmentAmount: number;
|
||||
paidInstallments: number;
|
||||
totalInstallments: number;
|
||||
overdueInstallments: number;
|
||||
overdueAmount: number;
|
||||
} | null = null;
|
||||
|
||||
constructor() {
|
||||
this.route.parent.data.subscribe((data: { recurringDepositsAccountData: any }) => {
|
||||
this.recurringDepositsAccountData = data.recurringDepositsAccountData;
|
||||
this.loadLiens(this.recurringDepositsAccountData?.id);
|
||||
this.loadInstallmentPosition(this.recurringDepositsAccountData?.id);
|
||||
this.currency = this.recurringDepositsAccountData.currency;
|
||||
this.isprematureAllowed = data.recurringDepositsAccountData.maturityDate != null;
|
||||
if (this.router.url.includes('clients')) {
|
||||
@@ -63,6 +73,27 @@ export class GeneralTabComponent {
|
||||
});
|
||||
}
|
||||
|
||||
private loadInstallmentPosition(accountId: any) {
|
||||
this.installmentPosition = null;
|
||||
if (!accountId) {
|
||||
return;
|
||||
}
|
||||
this.recurringDepositsService.getRecurringDepositInstallmentPosition(accountId).subscribe((response: any) => {
|
||||
const row = (response?.accounts ?? []).find((a: any) => Number(a.id) === Number(accountId));
|
||||
if (!row) {
|
||||
return;
|
||||
}
|
||||
this.installmentPosition = {
|
||||
nextDueDate: row.nextDueDate || null,
|
||||
installmentAmount: Number(row.installmentAmount) || 0,
|
||||
paidInstallments: Number(row.paidInstallments) || 0,
|
||||
totalInstallments: Number(row.totalInstallments) || 0,
|
||||
overdueInstallments: Number(row.overdueInstallments) || 0,
|
||||
overdueAmount: Number(row.overdueAmount) || 0
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
private loadLiens(accountId: any) {
|
||||
this.liens = [];
|
||||
this.totalLienAmount = 0;
|
||||
|
||||
@@ -39,6 +39,16 @@ export class RecurringDepositsService {
|
||||
return this.http.get(`/recurringdepositaccounts/${recurringDepositAccountId}/liens`);
|
||||
}
|
||||
|
||||
/**
|
||||
* This deposit's installment position as of the business date (next due date, overdue count/amount), from the
|
||||
* RD Dashboard endpoint - the same paid/overdue rule the auto-debit and penalty jobs use. Older backends ignore
|
||||
* `accountId` and return every RD, so callers pick the row by id.
|
||||
*/
|
||||
getRecurringDepositInstallmentPosition(recurringDepositAccountId: any): Observable<any> {
|
||||
const httpParams = new HttpParams().set('accountId', recurringDepositAccountId);
|
||||
return this.http.get('/recurringdepositaccounts/dashboard', { params: httpParams });
|
||||
}
|
||||
|
||||
/**
|
||||
* @param clientId Client Id
|
||||
* @param clientName Client Name
|
||||
|
||||
@@ -10,7 +10,7 @@ import { Directive, HostListener, Input, ElementRef, inject } from '@angular/cor
|
||||
import { NgControl } from '@angular/forms';
|
||||
|
||||
/** The kinds of field this knows how to hold to a shape. */
|
||||
export type RestrictKind = 'digits' | 'phone' | 'pan' | 'aadhaar';
|
||||
export type RestrictKind = 'digits' | 'phone' | 'pan' | 'aadhaar' | 'gstin' | 'tan';
|
||||
|
||||
interface Rule {
|
||||
/** Characters this kind will accept at all. */
|
||||
@@ -27,7 +27,10 @@ const RULES: Record<RestrictKind, Rule> = {
|
||||
// A PAN is five letters, four digits, one letter - and is always written in capitals.
|
||||
pan: { allowed: /[A-Za-z0-9]/, maxLength: 10, upper: true },
|
||||
// Twelve digits, or the masked form CKYC returns, which is Xs and the last four digits.
|
||||
aadhaar: { allowed: /[0-9Xx]/, maxLength: 12, upper: true }
|
||||
aadhaar: { allowed: /[0-9Xx]/, maxLength: 12, upper: true },
|
||||
// GSTIN is fifteen letters and digits, TAN ten; both are always written in capitals.
|
||||
gstin: { allowed: /[A-Za-z0-9]/, maxLength: 15, upper: true },
|
||||
tan: { allowed: /[A-Za-z0-9]/, maxLength: 10, upper: true }
|
||||
};
|
||||
|
||||
/**
|
||||
|
||||
@@ -54,6 +54,8 @@ export interface CtrTransaction {
|
||||
declaration: string;
|
||||
/** Customer name resolved from KC1 (UI convenience, not in actual TC2 schema) */
|
||||
customerName?: string;
|
||||
/** True for a transaction that has crossed the threshold but is not yet in a generated CTR. */
|
||||
pendingFiling?: boolean;
|
||||
/**
|
||||
* CTR does not judge suspicion. It records only whether the source of funds was
|
||||
* evidenced at the counter (CTR filed, nothing further) or could not be, in which
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user