feat(cashier, sponsor-dd): GL settlement and DD funding entry points, DD registry redesign
Transaction Entry (clerk): - Route a two-row Fund Transfer of Dr SHR / Cr GL to the share-withdrawal request with settlementMode GL, so a clerk can settle a share withdrawal into any GL account instead of the member's linked savings account. - Offer GL as an account type for Demand Draft funding, and submit it as a GL debit (no client or savings/current account is involved). My Work: - Add a voucher detail dialog so a clerk can open a raised voucher from the request list and see its GL lines. Closing Cash Report: - Fix the total row so it reflects the corrected opening balance. DD Leaflet Registry and DD Inventory: - Redesign both pages onto the shared usr-* visual language: gradient header badge, status filter chips with counts, card-wrapped sticky-header table, and one primary action per row with the rest behind an overflow menu. Filtering, pagination and every action condition are unchanged. - Register faHandPaper, needed by the new Stop Payment menu item. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -178,6 +178,13 @@ export class CashierService {
|
||||
return this.http.get('/vouchers/my-work', { params });
|
||||
}
|
||||
|
||||
/** One voucher and every debit/credit line it is made of — the full slip behind a summarised
|
||||
* row (a "+2 more" badge in a register only ever shows the first side of each). */
|
||||
voucherDetail(voucherNo: string): Observable<any> {
|
||||
const params = new HttpParams().set('voucherNo', voucherNo);
|
||||
return this.http.get('/vouchers/detail', { params });
|
||||
}
|
||||
|
||||
/**
|
||||
* Account register for one date: what opened and closed that day, each account's balance as it
|
||||
* stood then, and what it holds now.
|
||||
|
||||
@@ -174,19 +174,24 @@ export class ClosingCashReportComponent implements OnInit {
|
||||
return this.noteRows.reduce((s, r) => s + Number(r.settledOutAmount ?? 0), 0);
|
||||
}
|
||||
|
||||
/**
|
||||
* Summed from each row's own `netCount`, not re-derived from today's legs — a row's `netCount`
|
||||
* already carries forward what a cashier was holding from before today (`openingCount`), for the
|
||||
* force-closed-day/late-settlement case. Recomputing here from only today's vaultIn/deposit/
|
||||
* withdrawal/settledOut dropped that carry-forward and put the Total row back into negative
|
||||
* numbers a cashier had already reconciled.
|
||||
*/
|
||||
get noteNetCount(): number {
|
||||
return this.noteVaultInCount + this.noteDepositCount - this.noteWithdrawalCount - this.noteSettledOutCount;
|
||||
return this.noteRows.reduce((s, r) => s + Number(r.netCount ?? 0), 0);
|
||||
}
|
||||
|
||||
/**
|
||||
* What is left in the drawer in notes: everything that came in, less everything that went out.
|
||||
*
|
||||
* Was deposits minus withdrawals, which is the counter's movement rather than the drawer's
|
||||
* contents — it left out the cash drawn from the vault, so a teller holding a ₹500 note saw a net
|
||||
* of zero and had nothing to count against.
|
||||
* What is left in the drawer in notes: everything that came in, less everything that went out —
|
||||
* including cash carried forward from before today. See `noteNetCount` for why this sums each
|
||||
* row's own `netAmount` rather than re-deriving it from today's legs.
|
||||
*/
|
||||
get noteNetAmount(): number {
|
||||
return this.noteVaultInAmount + this.noteDepositAmount - this.noteWithdrawalAmount - this.noteSettledOutAmount;
|
||||
return this.noteRows.reduce((s, r) => s + Number(r.netAmount ?? 0), 0);
|
||||
}
|
||||
get coinRows(): any[] {
|
||||
return this.rows.filter((r) => r.kind === 'coin');
|
||||
|
||||
@@ -152,9 +152,21 @@
|
||||
<tbody>
|
||||
@for (r of g.rows; track r.voucherNo) {
|
||||
<!-- The left edge repeats the section's own colour, so a row still says which part it
|
||||
is when the heading has scrolled away. -->
|
||||
<tr [class]="'mw-row mw-row--' + r.myRole">
|
||||
<td class="mw-vno">{{ r.voucherNo }}</td>
|
||||
is when the heading has scrolled away. Clicking anywhere on the row opens the full
|
||||
slip -- every line, not just the first debit and first credit this table has room
|
||||
to show. -->
|
||||
<tr
|
||||
[class]="'mw-row mw-row--' + r.myRole"
|
||||
class="mw-row--clickable"
|
||||
(click)="openDetail(r.voucherNo)"
|
||||
[attr.aria-busy]="loadingDetailFor === r.voucherNo"
|
||||
>
|
||||
<td class="mw-vno">
|
||||
{{ r.voucherNo }}
|
||||
@if (loadingDetailFor === r.voucherNo) {
|
||||
<mat-icon class="mw-vno__spinner" inline>hourglass_empty</mat-icon>
|
||||
}
|
||||
</td>
|
||||
<td>
|
||||
<span class="mw-badge" [class]="'mw-badge--' + (r.side || 'JV')">{{ sideLabel(r.side) }}</span>
|
||||
</td>
|
||||
@@ -171,7 +183,7 @@
|
||||
<span class="mw-acct">{{ r.creditAccount }}</span>
|
||||
}
|
||||
@if (r.extraLines > 0) {
|
||||
<span class="mw-more">+{{ r.extraLines }} more</span>
|
||||
<span class="mw-more" title="Click the row to see every line">+{{ r.extraLines }} more</span>
|
||||
}
|
||||
</td>
|
||||
<td class="num">₹{{ r.amount | number: '1.2-2' }}</td>
|
||||
|
||||
@@ -178,6 +178,21 @@
|
||||
}
|
||||
}
|
||||
|
||||
.mw-row--clickable {
|
||||
cursor: pointer;
|
||||
|
||||
&:hover {
|
||||
background: #f8fafc;
|
||||
}
|
||||
}
|
||||
|
||||
.mw-vno__spinner {
|
||||
margin-left: 4px;
|
||||
font-size: 14px;
|
||||
vertical-align: middle;
|
||||
color: #94a3b8;
|
||||
}
|
||||
|
||||
/* One section per part played. The heading carries the section's own count and total, so a maker's
|
||||
tally and a checker's are each readable without adding rows up. */
|
||||
.mw-group {
|
||||
|
||||
@@ -10,8 +10,10 @@ import { Component, OnInit, inject } from '@angular/core';
|
||||
import { CommonModule } from '@angular/common';
|
||||
import { FormsModule } from '@angular/forms';
|
||||
import { MatIconModule } from '@angular/material/icon';
|
||||
import { catchError, of } from 'rxjs';
|
||||
import { MatDialog, MatDialogModule } from '@angular/material/dialog';
|
||||
import { catchError, finalize, of } from 'rxjs';
|
||||
import { CashierService } from '../cashier.service';
|
||||
import { VoucherDetailDialogComponent } from './voucher-detail-dialog.component';
|
||||
|
||||
/** What the signed-in user's part in a voucher was. */
|
||||
export type MyRole = 'RAISED' | 'VERIFIED' | 'RAISED_AND_VERIFIED';
|
||||
@@ -72,11 +74,17 @@ interface MyWorkReport {
|
||||
imports: [
|
||||
CommonModule,
|
||||
FormsModule,
|
||||
MatIconModule
|
||||
MatIconModule,
|
||||
MatDialogModule
|
||||
]
|
||||
})
|
||||
export class MyWorkComponent implements OnInit {
|
||||
private service = inject(CashierService);
|
||||
private dialog = inject(MatDialog);
|
||||
|
||||
/** Which voucher's detail is currently being fetched, so the row that was clicked can show its
|
||||
* own spinner instead of a page-wide one for what is a small, single-row lookup. */
|
||||
loadingDetailFor: string | null = null;
|
||||
|
||||
/** Day is the working default; a month is what a close or an audit asks for. */
|
||||
period: 'DAY' | 'MONTH' = 'DAY';
|
||||
@@ -323,6 +331,31 @@ export class MyWorkComponent implements OnInit {
|
||||
return { from: first, to: lastIso };
|
||||
}
|
||||
|
||||
/** Opens the full slip for one voucher — every debit and credit line, not just the first side of
|
||||
* each a table row has room to show. */
|
||||
openDetail(voucherNo: string): void {
|
||||
if (this.loadingDetailFor) {
|
||||
return;
|
||||
}
|
||||
this.loadingDetailFor = voucherNo;
|
||||
this.service
|
||||
.voucherDetail(voucherNo)
|
||||
.pipe(
|
||||
catchError(() => of(null)),
|
||||
finalize(() => (this.loadingDetailFor = null))
|
||||
)
|
||||
.subscribe((detail: any) => {
|
||||
if (!detail) {
|
||||
return;
|
||||
}
|
||||
this.dialog.open(VoucherDetailDialogComponent, {
|
||||
data: { detail },
|
||||
width: '600px',
|
||||
autoFocus: false
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
private todayIso(): string {
|
||||
const d = new Date();
|
||||
return `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, '0')}-${String(d.getDate()).padStart(2, '0')}`;
|
||||
|
||||
@@ -0,0 +1,320 @@
|
||||
/**
|
||||
* 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 { MatDialogModule, MatDialogRef, MAT_DIALOG_DATA } from '@angular/material/dialog';
|
||||
import { MatButtonModule } from '@angular/material/button';
|
||||
import { MatIconModule } from '@angular/material/icon';
|
||||
|
||||
export interface VoucherDetailDialogData {
|
||||
/** Already-loaded detail from CashierService.voucherDetail() — the caller fetches, this dialog
|
||||
* only renders, so one loading state lives in the row's own click handler rather than being
|
||||
* duplicated inside every dialog that could ever show a voucher. */
|
||||
detail: {
|
||||
voucherNo: string;
|
||||
voucherType: string | null;
|
||||
amount: number | null;
|
||||
branchName: string | null;
|
||||
createdBy: string | null;
|
||||
createdAt: string | null;
|
||||
totalDebit: number;
|
||||
totalCredit: number;
|
||||
balanced: boolean;
|
||||
lines: {
|
||||
lineNo: number;
|
||||
entryType: 'DR' | 'CR';
|
||||
partyName: string | null;
|
||||
accountNo: string | null;
|
||||
amount: number;
|
||||
description: string | null;
|
||||
}[];
|
||||
/** GL entries a native platform command posted for the same underlying event, outside this
|
||||
* voucher entirely — a share redemption's Dr Share Capital / Cr Share Sundry pair, posted at
|
||||
* manager approval, before this voucher's own Dr Sundry / Cr Cash at Vault settlement leg even
|
||||
* exists. Empty for every voucher that already is the whole transaction on its own. */
|
||||
relatedLines: {
|
||||
stage: string;
|
||||
entryType: 'DR' | 'CR';
|
||||
partyName: string | null;
|
||||
accountNo: string | null;
|
||||
amount: number;
|
||||
description: string | null;
|
||||
}[];
|
||||
};
|
||||
}
|
||||
|
||||
/** The full slip behind a summarised register row — every debit and credit line a voucher is
|
||||
* made of, not just the first side of each that a table row has room for. */
|
||||
@Component({
|
||||
selector: 'mifosx-voucher-detail-dialog',
|
||||
standalone: true,
|
||||
imports: [
|
||||
CommonModule,
|
||||
MatDialogModule,
|
||||
MatButtonModule,
|
||||
MatIconModule
|
||||
],
|
||||
template: `
|
||||
<div class="vd-shell">
|
||||
<div class="vd-header">
|
||||
<div class="vd-header__title">
|
||||
<mat-icon class="vd-icon">receipt_long</mat-icon>
|
||||
<div>
|
||||
<h2>{{ detail.voucherNo }}</h2>
|
||||
<p>{{ detail.voucherType || 'Voucher' }} · {{ detail.branchName || 'Head Office' }}</p>
|
||||
</div>
|
||||
</div>
|
||||
<button mat-icon-button mat-dialog-close aria-label="Close">
|
||||
<mat-icon>close</mat-icon>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div class="vd-meta">
|
||||
<span
|
||||
>Raised by <strong>{{ detail.createdBy || '—' }}</strong></span
|
||||
>
|
||||
@if (detail.createdAt) {
|
||||
<span>{{ detail.createdAt | date: 'dd MMM yyyy, h:mm a' }}</span>
|
||||
}
|
||||
@if (!detail.balanced) {
|
||||
<span class="vd-unbalanced">
|
||||
<mat-icon inline>warning</mat-icon>
|
||||
Debit and credit do not balance
|
||||
</span>
|
||||
}
|
||||
</div>
|
||||
|
||||
<table class="vd-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Side</th>
|
||||
<th>Party</th>
|
||||
<th>Account No.</th>
|
||||
<th class="num">Amount</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
@for (line of detail.lines; track line.lineNo) {
|
||||
<tr>
|
||||
<td>
|
||||
<span
|
||||
class="vd-side"
|
||||
[class.vd-side--dr]="line.entryType === 'DR'"
|
||||
[class.vd-side--cr]="line.entryType === 'CR'"
|
||||
>
|
||||
{{ line.entryType === 'DR' ? 'Debit' : 'Credit' }}
|
||||
</span>
|
||||
</td>
|
||||
<td>{{ line.partyName || '—' }}</td>
|
||||
<td class="vd-acct">{{ line.accountNo || '—' }}</td>
|
||||
<td class="num">₹{{ line.amount | number: '1.2-2' }}</td>
|
||||
</tr>
|
||||
@if (line.description) {
|
||||
<tr class="vd-desc-row">
|
||||
<td></td>
|
||||
<td colspan="3" class="vd-desc">{{ line.description }}</td>
|
||||
</tr>
|
||||
}
|
||||
} @empty {
|
||||
<tr>
|
||||
<td colspan="4" class="vd-empty">This voucher's individual lines were not recorded.</td>
|
||||
</tr>
|
||||
}
|
||||
</tbody>
|
||||
@if (detail.lines.length > 0) {
|
||||
<tfoot>
|
||||
<tr>
|
||||
<td colspan="3">Total</td>
|
||||
<td class="num">
|
||||
₹{{ detail.totalDebit | number: '1.2-2' }} / ₹{{ detail.totalCredit | number: '1.2-2' }}
|
||||
</td>
|
||||
</tr>
|
||||
</tfoot>
|
||||
}
|
||||
</table>
|
||||
|
||||
@if (detail.relatedLines.length > 0) {
|
||||
<div class="vd-related">
|
||||
<div class="vd-related__title">
|
||||
<mat-icon inline>link</mat-icon>
|
||||
Also posted for this transaction — not part of this voucher
|
||||
</div>
|
||||
<table class="vd-table vd-table--related">
|
||||
<tbody>
|
||||
@for (line of detail.relatedLines; track $index) {
|
||||
<tr>
|
||||
<td>
|
||||
<span
|
||||
class="vd-side"
|
||||
[class.vd-side--dr]="line.entryType === 'DR'"
|
||||
[class.vd-side--cr]="line.entryType === 'CR'"
|
||||
>
|
||||
{{ line.entryType === 'DR' ? 'Debit' : 'Credit' }}
|
||||
</span>
|
||||
</td>
|
||||
<td>{{ line.partyName || '—' }}</td>
|
||||
<td class="vd-acct">{{ line.accountNo || '—' }}</td>
|
||||
<td class="num">₹{{ line.amount | number: '1.2-2' }}</td>
|
||||
</tr>
|
||||
}
|
||||
</tbody>
|
||||
</table>
|
||||
<p class="vd-related__hint">
|
||||
{{ detail.relatedLines[0].stage }} — this and the lines above are the same transaction's full accounting.
|
||||
</p>
|
||||
</div>
|
||||
}
|
||||
|
||||
<div class="vd-actions">
|
||||
<button mat-flat-button color="primary" mat-dialog-close>Close</button>
|
||||
</div>
|
||||
</div>
|
||||
`,
|
||||
styles: [
|
||||
`
|
||||
.vd-shell {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 16px;
|
||||
padding: 20px 24px 16px;
|
||||
min-width: 420px;
|
||||
max-width: 560px;
|
||||
}
|
||||
.vd-header {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
justify-content: space-between;
|
||||
}
|
||||
.vd-header__title {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
}
|
||||
.vd-header__title h2 {
|
||||
margin: 0;
|
||||
font-size: 18px;
|
||||
font-weight: 600;
|
||||
}
|
||||
.vd-header__title p {
|
||||
margin: 2px 0 0;
|
||||
font-size: 13px;
|
||||
color: rgba(0, 0, 0, 0.6);
|
||||
}
|
||||
.vd-icon {
|
||||
color: #1976d2;
|
||||
}
|
||||
.vd-meta {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 6px 16px;
|
||||
font-size: 13px;
|
||||
color: rgba(0, 0, 0, 0.65);
|
||||
}
|
||||
.vd-unbalanced {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
color: #b91c1c;
|
||||
font-weight: 600;
|
||||
}
|
||||
.vd-table {
|
||||
width: 100%;
|
||||
border-collapse: collapse;
|
||||
font-size: 13px;
|
||||
}
|
||||
.vd-table th {
|
||||
text-align: left;
|
||||
text-transform: uppercase;
|
||||
font-size: 11px;
|
||||
letter-spacing: 0.03em;
|
||||
color: rgba(0, 0, 0, 0.5);
|
||||
padding: 6px 8px;
|
||||
border-bottom: 1px solid rgba(0, 0, 0, 0.1);
|
||||
}
|
||||
.vd-table td {
|
||||
padding: 8px;
|
||||
border-bottom: 1px solid rgba(0, 0, 0, 0.06);
|
||||
}
|
||||
.vd-table .num {
|
||||
text-align: right;
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
.vd-acct {
|
||||
color: rgba(0, 0, 0, 0.55);
|
||||
}
|
||||
.vd-side {
|
||||
display: inline-block;
|
||||
padding: 2px 8px;
|
||||
border-radius: 4px;
|
||||
font-size: 11px;
|
||||
font-weight: 600;
|
||||
}
|
||||
.vd-side--dr {
|
||||
background: #fee2e2;
|
||||
color: #b91c1c;
|
||||
}
|
||||
.vd-side--cr {
|
||||
background: #dcfce7;
|
||||
color: #15803d;
|
||||
}
|
||||
.vd-desc-row td {
|
||||
padding-top: 0;
|
||||
border-bottom: 1px solid rgba(0, 0, 0, 0.06);
|
||||
}
|
||||
.vd-desc {
|
||||
font-size: 12px;
|
||||
color: rgba(0, 0, 0, 0.5);
|
||||
font-style: italic;
|
||||
}
|
||||
.vd-empty {
|
||||
text-align: center;
|
||||
color: rgba(0, 0, 0, 0.5);
|
||||
padding: 16px 8px;
|
||||
}
|
||||
.vd-table tfoot td {
|
||||
font-weight: 600;
|
||||
border-bottom: none;
|
||||
border-top: 1px solid rgba(0, 0, 0, 0.1);
|
||||
}
|
||||
.vd-related {
|
||||
border-top: 1px dashed rgba(0, 0, 0, 0.15);
|
||||
padding-top: 12px;
|
||||
}
|
||||
.vd-related__title {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
font-size: 12px;
|
||||
font-weight: 600;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.03em;
|
||||
color: #6d28d9;
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
.vd-table--related td {
|
||||
background: #faf5ff;
|
||||
}
|
||||
.vd-related__hint {
|
||||
margin: 6px 0 0;
|
||||
font-size: 12px;
|
||||
color: rgba(0, 0, 0, 0.5);
|
||||
}
|
||||
.vd-actions {
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
}
|
||||
`
|
||||
]
|
||||
})
|
||||
export class VoucherDetailDialogComponent {
|
||||
dialogRef = inject(MatDialogRef<VoucherDetailDialogComponent>);
|
||||
private data = inject<VoucherDetailDialogData>(MAT_DIALOG_DATA);
|
||||
|
||||
detail = this.data.detail;
|
||||
}
|
||||
@@ -166,22 +166,30 @@
|
||||
</div>
|
||||
<div class="ws-f ws-f--actyp">
|
||||
<label class="ws-f__lbl" title="Account Type">AC. TP.</label>
|
||||
<select
|
||||
class="ws-f__ctl"
|
||||
[(ngModel)]="row.acTyp"
|
||||
(click)="$event.stopPropagation()"
|
||||
(change)="
|
||||
row.acTyp === 'GL' &&
|
||||
row.typ !== 'Disbursement' &&
|
||||
row.typ !== 'Fund Transfer' &&
|
||||
row.typ !== 'Enter Bank Transfer' &&
|
||||
(row.dbCr = 'Cr')
|
||||
"
|
||||
>
|
||||
@for (a of acTypOptionsFor(row); track a) {
|
||||
<option [value]="a">{{ a }}</option>
|
||||
}
|
||||
</select>
|
||||
@if (acTypLockedByToken && row === selectedRow) {
|
||||
<span
|
||||
class="ws-f__ctl ws-f__ctl--locked"
|
||||
title="Locked — filled from a token-driven request, not a normal account entry"
|
||||
>{{ acTypLockedLabel }}</span
|
||||
>
|
||||
} @else {
|
||||
<select
|
||||
class="ws-f__ctl"
|
||||
[(ngModel)]="row.acTyp"
|
||||
(click)="$event.stopPropagation()"
|
||||
(change)="
|
||||
row.acTyp === 'GL' &&
|
||||
row.typ !== 'Disbursement' &&
|
||||
row.typ !== 'Fund Transfer' &&
|
||||
row.typ !== 'Enter Bank Transfer' &&
|
||||
(row.dbCr = 'Cr')
|
||||
"
|
||||
>
|
||||
@for (a of acTypOptionsFor(row); track a) {
|
||||
<option [value]="a">{{ a }}</option>
|
||||
}
|
||||
</select>
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -206,14 +214,20 @@
|
||||
? ddGlMapping?.ddPayableGlCode || 'Auto GL'
|
||||
: 'Account number'
|
||||
"
|
||||
[readonly]="row.typ === 'DD' && !isDdAccountDebitRow(row)"
|
||||
[class.ws-f__ctl--readonly]="row.typ === 'DD' && !isDdAccountDebitRow(row)"
|
||||
[readonly]="
|
||||
(row.typ === 'DD' && !isDdAccountDebitRow(row)) || (acTypLockedByToken && row === selectedRow)
|
||||
"
|
||||
[class.ws-f__ctl--readonly]="
|
||||
(row.typ === 'DD' && !isDdAccountDebitRow(row)) || (acTypLockedByToken && row === selectedRow)
|
||||
"
|
||||
[title]="
|
||||
isDdAccountDebitRow(row)
|
||||
? 'Member account the draft is funded from'
|
||||
: row.typ === 'DD'
|
||||
? 'Configured Sponsor DD Payable GL (Auto-selected)'
|
||||
: 'Account number'
|
||||
acTypLockedByToken && row === selectedRow
|
||||
? 'Locked — filled from a token-driven request, not a normal account entry'
|
||||
: isDdAccountDebitRow(row)
|
||||
? 'Member account the draft is funded from'
|
||||
: row.typ === 'DD'
|
||||
? 'Configured Sponsor DD Payable GL (Auto-selected)'
|
||||
: 'Account number'
|
||||
"
|
||||
/>
|
||||
</div>
|
||||
@@ -1523,22 +1537,30 @@
|
||||
</div>
|
||||
<div class="ws-f ws-f--actyp">
|
||||
<label class="ws-f__lbl">AC. TP.</label>
|
||||
<select
|
||||
class="ws-f__ctl"
|
||||
[(ngModel)]="row.acTyp"
|
||||
(click)="$event.stopPropagation()"
|
||||
(change)="
|
||||
row.acTyp === 'GL' &&
|
||||
row.typ !== 'Disbursement' &&
|
||||
row.typ !== 'Fund Transfer' &&
|
||||
row.typ !== 'Enter Bank Transfer' &&
|
||||
(row.dbCr = 'Cr')
|
||||
"
|
||||
>
|
||||
@for (a of acTypOptionsFor(row); track a) {
|
||||
<option [value]="a">{{ a }}</option>
|
||||
}
|
||||
</select>
|
||||
@if (acTypLockedByToken && row === selectedRow) {
|
||||
<span
|
||||
class="ws-f__ctl ws-f__ctl--locked"
|
||||
title="Locked — filled from a token-driven request, not a normal account entry"
|
||||
>{{ acTypLockedLabel }}</span
|
||||
>
|
||||
} @else {
|
||||
<select
|
||||
class="ws-f__ctl"
|
||||
[(ngModel)]="row.acTyp"
|
||||
(click)="$event.stopPropagation()"
|
||||
(change)="
|
||||
row.acTyp === 'GL' &&
|
||||
row.typ !== 'Disbursement' &&
|
||||
row.typ !== 'Fund Transfer' &&
|
||||
row.typ !== 'Enter Bank Transfer' &&
|
||||
(row.dbCr = 'Cr')
|
||||
"
|
||||
>
|
||||
@for (a of acTypOptionsFor(row); track a) {
|
||||
<option [value]="a">{{ a }}</option>
|
||||
}
|
||||
</select>
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -1556,10 +1578,16 @@
|
||||
[placeholder]="
|
||||
row.typ === 'DD' ? ddGlMapping?.ddPayableGlCode || 'Auto GL' : 'Account number'
|
||||
"
|
||||
[readonly]="row.typ === 'DD'"
|
||||
[class.ws-f__ctl--readonly]="row.typ === 'DD'"
|
||||
[readonly]="row.typ === 'DD' || (acTypLockedByToken && row === selectedRow)"
|
||||
[class.ws-f__ctl--readonly]="
|
||||
row.typ === 'DD' || (acTypLockedByToken && row === selectedRow)
|
||||
"
|
||||
[title]="
|
||||
row.typ === 'DD' ? 'Configured Sponsor DD Payable GL (Auto-selected)' : 'Account number'
|
||||
acTypLockedByToken && row === selectedRow
|
||||
? 'Locked — filled from a token-driven request, not a normal account entry'
|
||||
: row.typ === 'DD'
|
||||
? 'Configured Sponsor DD Payable GL (Auto-selected)'
|
||||
: 'Account number'
|
||||
"
|
||||
/>
|
||||
</div>
|
||||
|
||||
@@ -916,6 +916,20 @@ input.ws-f__ctl--readonly {
|
||||
font-size: 11px;
|
||||
}
|
||||
|
||||
/* AC.TP shown as a locked label instead of the normal editable select, while the row is filled
|
||||
from a token-driven request whose real account type row.acTyp cannot show (see
|
||||
acTypLockedByToken in the component). */
|
||||
span.ws-f__ctl--locked {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
background: #f1f5f9;
|
||||
color: #475569;
|
||||
cursor: not-allowed;
|
||||
user-select: none;
|
||||
font-size: 11px;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
input.ws-amt-input--locked {
|
||||
background: #fef9c3;
|
||||
color: #713f12;
|
||||
|
||||
@@ -1703,15 +1703,18 @@ export class TransactionEntryComponent implements OnInit, OnDestroy {
|
||||
/**
|
||||
* Account types a clerk may fund a demand draft from.
|
||||
*
|
||||
* A clerk raises a draft against a member's own balance, so the only sensible sources are the
|
||||
* accounts a member actually holds money in. GL was offered here too, and picking it quietly
|
||||
* switched the row onto the cash path — the one that asks for a walk-in purchaser's name and a
|
||||
* note-and-coin count, neither of which a clerk debiting an account has. The option is removed
|
||||
* rather than validated afterwards, so the wrong path cannot be reached at all.
|
||||
* A clerk raises a draft against a member's own balance (SD/CA) or, now, against a GL account
|
||||
* directly for an institutional/holding-account draft (see isDdAccountDebitRow()) — both are
|
||||
* governed, maker-checker submissions. GL used to be offered here too in a different sense: picking
|
||||
* it quietly switched the row onto the CASHIER's walk-in cash path instead (the one that asks for a
|
||||
* purchaser's name and a note-and-coin count), which isDdAccountDebitRow() now disambiguates by
|
||||
* role — GL means "debit this GL account" for a clerk, and still means "cash/GL purchase" for a
|
||||
* cashier, who never reaches this list at all (isDdAccountDebitRow() is clerk-only).
|
||||
*/
|
||||
private static readonly DD_CLERK_ACTYPS = [
|
||||
'SD',
|
||||
'CA'
|
||||
'CA',
|
||||
'GL'
|
||||
];
|
||||
|
||||
/**
|
||||
@@ -1726,6 +1729,33 @@ export class TransactionEntryComponent implements OnInit, OnDestroy {
|
||||
}
|
||||
return this.acTypOptions;
|
||||
}
|
||||
|
||||
/** True while the selected row is filled from a token-driven request whose row.acTyp is a
|
||||
* technical stand-in rather than the account's real type — a dividend claim or an account closure
|
||||
* both set row.acTyp = 'SD' purely to route past the SHR-specific guards elsewhere on this screen
|
||||
* (see onTokNoBlur()), not because the counter is paying against a savings account. The AC.TP
|
||||
* dropdown must not be left editable there: it already shows the wrong value, and a cashier
|
||||
* changing it further would desync the row from the request it was auto-filled from. A plain
|
||||
* withdrawal-slip token is deliberately excluded — its row.acTyp is resolved to the account's real
|
||||
* type there, so the normal editable control already shows it correctly and there's nothing to
|
||||
* lock. */
|
||||
get acTypLockedByToken(): boolean {
|
||||
return !!(this.activeDividendClaimRequest || this.activeClosureRequest);
|
||||
}
|
||||
|
||||
/** What to show in place of the AC.TP dropdown while it's locked — the same label the account
|
||||
* summary strip above already uses for each of these token pools, so the two never contradict
|
||||
* each other on screen. */
|
||||
get acTypLockedLabel(): string {
|
||||
if (this.activeClosureRequest) {
|
||||
return 'Closure';
|
||||
}
|
||||
if (this.activeDividendClaimRequest) {
|
||||
return 'Dividend';
|
||||
}
|
||||
return '';
|
||||
}
|
||||
|
||||
dbCrOptions = [
|
||||
'Dr',
|
||||
'Cr'
|
||||
@@ -2321,14 +2351,21 @@ export class TransactionEntryComponent implements OnInit, OnDestroy {
|
||||
}
|
||||
|
||||
/**
|
||||
* TYPE=DD funded by debiting a member's savings/current account rather than by cash.
|
||||
* TYPE=DD funded by debiting a member's savings/current account, or a GL account directly, rather
|
||||
* than by cash.
|
||||
*
|
||||
* Clerk-only. A cashier's counter takes cash and nothing else, so offering them an account debit
|
||||
* would let a draft be raised against an account with no cash to reconcile it against — and the
|
||||
* clerk flow is the one that carries a maker-checker step over the member's balance.
|
||||
* Clerk-only. A cashier's counter takes cash and nothing else, so offering them an account/GL debit
|
||||
* would let a draft be raised with no cash to reconcile it against — and the clerk flow is the one
|
||||
* that carries a maker-checker step over the money moved. AC.TP = GL means something different for
|
||||
* a cashier's own DD row (a walk-in cash/GL purchase, see isDdCashGlRow()) — this method is checked
|
||||
* first there specifically so a clerk's GL row is claimed here instead.
|
||||
*/
|
||||
isDdAccountDebitRow(row: TxnRow): boolean {
|
||||
return this.isClerk && row.typ === 'DD' && (row.acTyp === 'SD' || row.acTyp === 'CA' || row.acTyp === 'CB');
|
||||
return (
|
||||
this.isClerk &&
|
||||
row.typ === 'DD' &&
|
||||
(row.acTyp === 'SD' || row.acTyp === 'CA' || row.acTyp === 'CB' || row.acTyp === 'GL')
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -2408,19 +2445,24 @@ export class TransactionEntryComponent implements OnInit, OnDestroy {
|
||||
// An account-funded draft needs the account resolved to a Fineract id before it can be sent:
|
||||
// the server takes a savings/current account id and a client id, not the typed number. Both are
|
||||
// filled in by the A/C NO. lookup, so an unresolved account means the number was never found.
|
||||
// A GL-funded draft is the exception: a GL account is never tied to a client at all (see
|
||||
// fetchAllDetails() — GL rows never populate rowClientIds), so only the account id is required.
|
||||
const isGlDebit = accountDebit && row.acTyp === 'GL';
|
||||
let fundingAccountId: number | null = null;
|
||||
let fundingClientId: number | null = null;
|
||||
if (accountDebit) {
|
||||
const acNo = (row.acNo || '').trim();
|
||||
if (!acNo) {
|
||||
this.showAlert('error', 'Account Required', [
|
||||
'Enter the account number the Demand Draft is funded from.'
|
||||
isGlDebit
|
||||
? 'Enter the GL account the Demand Draft is funded from.'
|
||||
: 'Enter the account number the Demand Draft is funded from.'
|
||||
]);
|
||||
return;
|
||||
}
|
||||
fundingAccountId = this.rowAccountIds[acNo] ?? null;
|
||||
fundingClientId = this.rowClientIds[acNo] ?? null;
|
||||
if (!fundingAccountId || !fundingClientId) {
|
||||
fundingClientId = isGlDebit ? null : (this.rowClientIds[acNo] ?? null);
|
||||
if (!fundingAccountId || (!isGlDebit && !fundingClientId)) {
|
||||
this.showAlert('error', 'Account Not Found', [
|
||||
`Account ${acNo} could not be resolved.`,
|
||||
'Check the account number and wait for its details to load before raising the draft.'
|
||||
@@ -2510,29 +2552,32 @@ export class TransactionEntryComponent implements OnInit, OnDestroy {
|
||||
}
|
||||
|
||||
/**
|
||||
* Raises a Demand Draft funded by debiting a member's savings or current account.
|
||||
* Raises a Demand Draft funded by debiting a member's savings/current account, or a GL account
|
||||
* directly.
|
||||
*
|
||||
* Clerk-only, and a maker-checker request like the cash flow: nothing is debited here. The member's
|
||||
* account is only touched when a checker approves, which is also when the balance is re-checked —
|
||||
* so a draft raised against a sufficient balance can still be refused if the balance has since
|
||||
* gone. No denominations are sent, because no cash is taken.
|
||||
* Clerk-only, and a maker-checker request like the cash flow: nothing is debited here. The
|
||||
* account/GL is only touched when a checker approves, which for a member account is also when the
|
||||
* balance is re-checked — so a draft raised against a sufficient balance can still be refused if
|
||||
* the balance has since gone (a GL account carries no such balance check). No denominations are
|
||||
* sent, because no cash is taken.
|
||||
*
|
||||
* The account goes in savingsAccountId or currentAccountId, never both: the server insists on
|
||||
* exactly one, since which table the account lives in decides which control GL is debited.
|
||||
* The funding account goes in exactly one of savingsAccountId, currentAccountId or glAccountId: the
|
||||
* server insists on exactly one, since which one decides what gets debited on approval.
|
||||
*/
|
||||
private submitAccountDebitDd(
|
||||
row: TxnRow,
|
||||
leaflet: any,
|
||||
beneficiaryData: any,
|
||||
fundingAccountId: number,
|
||||
fundingClientId: number
|
||||
fundingClientId: number | null
|
||||
): void {
|
||||
const d = new Date();
|
||||
const issuedDate = `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, '0')}-${String(d.getDate()).padStart(2, '0')}`;
|
||||
const isCurrent = row.acTyp === 'CA' || row.acTyp === 'CB';
|
||||
const isGl = row.acTyp === 'GL';
|
||||
|
||||
const payload = {
|
||||
clientId: fundingClientId,
|
||||
clientId: isGl ? undefined : (fundingClientId ?? undefined),
|
||||
beneficiaryName: beneficiaryData.beneficiaryName,
|
||||
payableAtBranch: beneficiaryData.payableAtBranch,
|
||||
payableAtCity: beneficiaryData.payableAtCity,
|
||||
@@ -2540,9 +2585,10 @@ export class TransactionEntryComponent implements OnInit, OnDestroy {
|
||||
chargesAmount: Number(row.ddCharges) || 0,
|
||||
issuingBranchId: row.br,
|
||||
issuedDate,
|
||||
paymentMethod: 'ACCOUNT_DEBIT' as const,
|
||||
savingsAccountId: isCurrent ? undefined : fundingAccountId,
|
||||
currentAccountId: isCurrent ? fundingAccountId : undefined
|
||||
paymentMethod: (isGl ? 'GL' : 'ACCOUNT_DEBIT') as 'GL' | 'ACCOUNT_DEBIT',
|
||||
savingsAccountId: isGl || isCurrent ? undefined : fundingAccountId,
|
||||
currentAccountId: isGl || !isCurrent ? undefined : fundingAccountId,
|
||||
glAccountId: isGl ? fundingAccountId : undefined
|
||||
};
|
||||
|
||||
this.saving = true;
|
||||
@@ -3044,6 +3090,168 @@ export class TransactionEntryComponent implements OnInit, OnDestroy {
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Share redemption settled to a GL account the clerk picks on the CR row, instead of the
|
||||
* member's linked savings account — mirrors submitShareRedemptionTransfer() above, but the
|
||||
* credit side is any GL account (e.g. a "DD Payable" holding account), not a fixed linked
|
||||
* account, so there is no "must match the linked account" check here.
|
||||
*/
|
||||
private submitShareGlSettlementTransfer(shareRow: TxnRow, glRow: TxnRow): void {
|
||||
// Belt-and-braces: the TXN TP. dropdown already hides cash payment types for AC.TP = SHR (see
|
||||
// paymentTypesForRow()), but re-check here too in case the payment type was set before that
|
||||
// filter applied.
|
||||
const settlementPaymentType = this.paymentTypes.find((p: any) => p.id === glRow.paymentTypeId);
|
||||
if (!glRow.paymentTypeId) {
|
||||
this.showAlert('error', 'Settlement Payment Type Required', [
|
||||
'Select a Payment Type on the CR (GL account) row — it governs how the settlement credit posts.'
|
||||
]);
|
||||
return;
|
||||
}
|
||||
if (settlementPaymentType?.isCashPayment) {
|
||||
this.showAlert('error', 'Cash Not Allowed for Share Transactions', [
|
||||
'Share redemption settlement is always a book-transfer, never cash.',
|
||||
'Select Transfer as the Payment Type on the CR row.'
|
||||
]);
|
||||
return;
|
||||
}
|
||||
|
||||
const shareAccountId = this.rowAccountIds[shareRow.acNo];
|
||||
if (!shareAccountId) {
|
||||
this.showAlert('error', 'Share Account Not Loaded', [`Please load account details for: ${shareRow.acNo}`]);
|
||||
return;
|
||||
}
|
||||
const settlementGlAccountId = this.rowAccountIds[glRow.acNo];
|
||||
if (!settlementGlAccountId) {
|
||||
this.showAlert('error', 'GL Account Not Loaded', [`Please load account details for: ${glRow.acNo}`]);
|
||||
return;
|
||||
}
|
||||
|
||||
const shares = Number(shareRow.amount) || 0;
|
||||
if (shares <= 0) {
|
||||
this.showAlert('error', 'Share No. Required', ['Enter the number of shares to withdraw (greater than zero).']);
|
||||
return;
|
||||
}
|
||||
if (!shareRow.resolutionNumber?.trim()) {
|
||||
this.showAlert('error', 'Board Resolution Number Required', [
|
||||
'A share withdrawal must be authorised by a Board Resolution.'
|
||||
]);
|
||||
return;
|
||||
}
|
||||
if (!shareRow.meetingDate) {
|
||||
this.showAlert('error', 'Date of Meeting Required', ['Enter the Board meeting date that passed the resolution.']);
|
||||
return;
|
||||
}
|
||||
|
||||
const today = new Date();
|
||||
const requestedDate = `${today.getFullYear()}-${String(today.getMonth() + 1).padStart(2, '0')}-${String(today.getDate()).padStart(2, '0')}`;
|
||||
|
||||
// Full snapshot of the CR (settlement) row exactly as the clerk filled it in — saved verbatim
|
||||
// for audit purposes, same as the linked-savings path above.
|
||||
const settlementRowSnapshot = {
|
||||
branch: glRow.br,
|
||||
accountType: glRow.acTyp,
|
||||
accountNo: glRow.acNo,
|
||||
ecsAccountNo: glRow.ecsAcNo || null,
|
||||
dbCr: glRow.dbCr,
|
||||
amount: glRow.amount ?? null,
|
||||
chequeNo: glRow.chequeNo || null,
|
||||
chequeDate: glRow.cheqDate || null,
|
||||
tokenNo: glRow.tokNo || null,
|
||||
description: glRow.description || null,
|
||||
bankName: glRow.bankName || null,
|
||||
routingCode: glRow.routingCode || null,
|
||||
paymentTypeId: glRow.paymentTypeId,
|
||||
paymentTypeName: settlementPaymentType?.name ?? null
|
||||
};
|
||||
|
||||
const payload = {
|
||||
shareAccountId,
|
||||
accountNo: shareRow.acNo,
|
||||
clientId: this.rowClientIds[shareRow.acNo] ?? null,
|
||||
clientName: this.rowAccountNames[shareRow.acNo] ?? null,
|
||||
requestedShares: shares,
|
||||
requestedDate,
|
||||
resolutionNumber: shareRow.resolutionNumber.trim(),
|
||||
meetingDate: shareRow.meetingDate,
|
||||
description: shareRow.description || null,
|
||||
settlementPaymentTypeId: glRow.paymentTypeId,
|
||||
settlementRowSnapshot,
|
||||
settlementMode: 'GL',
|
||||
settlementGlAccountId
|
||||
};
|
||||
|
||||
const sharePrice = this.rowSharePrices[shareRow.acNo] ?? 0;
|
||||
const currentHolding = this.rowShareHoldings[shareRow.acNo] ?? 0;
|
||||
const remainingShares = currentHolding - shares;
|
||||
const willCloseAccount = remainingShares <= 0;
|
||||
const confirmLines = [
|
||||
`Share Account: ${shareRow.acNo} · Client: ${this.rowAccountNames[shareRow.acNo] ?? '—'}`,
|
||||
`Shares to Withdraw: ${shares} @ ₹${sharePrice.toLocaleString('en-IN', { minimumFractionDigits: 2 })} = ₹${(shares * sharePrice).toLocaleString('en-IN', { minimumFractionDigits: 2 })}`,
|
||||
`Remaining Shares After Withdrawal: ${Math.max(remainingShares, 0)}`,
|
||||
...(willCloseAccount
|
||||
? ['⚠ All shares withdrawn — this account will be CLOSED automatically when the Manager approves.']
|
||||
: []),
|
||||
`Board Resolution No.: ${payload.resolutionNumber}`,
|
||||
`Date of Meeting: ${payload.meetingDate}`,
|
||||
`Settlement Credit To (GL): ${glRow.acNo}`,
|
||||
`Settlement Payment Type: ${settlementPaymentType?.name ?? '—'}`,
|
||||
...(settlementRowSnapshot.description ? [`CR Row Remarks: ${settlementRowSnapshot.description}`] : []),
|
||||
'',
|
||||
willCloseAccount
|
||||
? 'Pending Manager approval — redemption, settlement and account closure all post together on approval.'
|
||||
: 'Pending Manager approval — redemption and settlement both post together on approval.'
|
||||
];
|
||||
this.showConfirmAlert('info', 'Confirm Share Withdrawal Request', confirmLines, 'Submit for Approval', () =>
|
||||
this.doSubmitShareGlSettlementTransfer(payload, shareRow, shares, glRow.acNo)
|
||||
);
|
||||
}
|
||||
|
||||
private doSubmitShareGlSettlementTransfer(
|
||||
payload: Record<string, unknown>,
|
||||
shareRow: TxnRow,
|
||||
shares: number,
|
||||
glAcNo: string
|
||||
): void {
|
||||
this.saving = true;
|
||||
this.clerkShareWithdrawalService
|
||||
.submit(payload)
|
||||
.pipe(
|
||||
catchError((err: any) => {
|
||||
const msg =
|
||||
err?.error?.errors?.[0]?.defaultUserMessage ??
|
||||
err?.error?.developerMessage ??
|
||||
err?.error?.defaultUserMessage ??
|
||||
'Share withdrawal submission failed. Please try again.';
|
||||
this.showAlert('error', 'Submission Failed', [msg]);
|
||||
this.saving = false;
|
||||
return of(null);
|
||||
})
|
||||
)
|
||||
.subscribe((res: any) => {
|
||||
this.saving = false;
|
||||
if (res) {
|
||||
const willCloseAccount = (this.rowShareHoldings[shareRow.acNo] ?? 0) - shares <= 0;
|
||||
this.showAlert('success', 'Share Withdrawal Submitted', [
|
||||
`${shares} share(s) from ${shareRow.acNo} → settlement to GL ${glAcNo} on approval.`,
|
||||
willCloseAccount
|
||||
? 'Pending Manager approval — this will close the share account, since all shares will be withdrawn. Track status in "My Share Requests".'
|
||||
: 'Pending Manager approval. Track status in "My Share Requests".'
|
||||
]);
|
||||
this.rows = [];
|
||||
this.rowAccountIds = {};
|
||||
this.rowAccountNames = {};
|
||||
this.rowSharePrices = {};
|
||||
this.rowClientIds = {};
|
||||
this.rowLinkedSavingsAcNo = {};
|
||||
this.rowShareHoldings = {};
|
||||
this.addRow();
|
||||
this.selectedRow = this.rows[0];
|
||||
this.accountDetails = null;
|
||||
this.clientDetails = null;
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
private doSubmitShareRedemptionTransfer(
|
||||
payload: Record<string, unknown>,
|
||||
shareRow: TxnRow,
|
||||
@@ -3103,37 +3311,23 @@ export class TransactionEntryComponent implements OnInit, OnDestroy {
|
||||
.pipe(
|
||||
catchError((err: any) => {
|
||||
// Not a withdrawal-request token at all (as opposed to PENDING/WITHDRAWN/REJECTED,
|
||||
// which are legitimate matches with their own specific error) — try the dividend
|
||||
// cash-claim pool, then the account-closure pool, before giving up: all three share
|
||||
// the same TOK. NO. field at this counter.
|
||||
// which are legitimate matches with their own specific error) — try the account-closure
|
||||
// pool before giving up: both share the same TOK. NO. field at this counter. A dividend
|
||||
// claim was once a third pool here, but a dividend never actually pays out in cash over
|
||||
// this counter — it only ever settles through a Demand Draft, which is its own DD
|
||||
// issuance request and its own token, so looking one up here could only ever 404.
|
||||
if (err?.status === 404) {
|
||||
return this.dividendClaimRequestService.getByToken(token).pipe(
|
||||
catchError((divErr: any) => {
|
||||
if (divErr?.status === 404) {
|
||||
return this.closureRequestService.getByToken(token).pipe(
|
||||
catchError((closeErr: any) => {
|
||||
this.loadingWithdrawal = false;
|
||||
const msg: string =
|
||||
closeErr?.error?.message ??
|
||||
closeErr?.error?.errors?.[0]?.defaultUserMessage ??
|
||||
`Token '${token}' could not be verified. Please try again.`;
|
||||
this.showAlert('error', `Token '${token}'`, [msg]);
|
||||
return of(null);
|
||||
}),
|
||||
map((closeReq: any) => (closeReq ? { __closureRequest: true, ...closeReq } : null))
|
||||
);
|
||||
}
|
||||
return this.closureRequestService.getByToken(token).pipe(
|
||||
catchError((closeErr: any) => {
|
||||
this.loadingWithdrawal = false;
|
||||
const msg: string =
|
||||
divErr?.error?.message ??
|
||||
divErr?.error?.errors?.[0]?.defaultUserMessage ??
|
||||
closeErr?.error?.message ??
|
||||
closeErr?.error?.errors?.[0]?.defaultUserMessage ??
|
||||
`Token '${token}' could not be verified. Please try again.`;
|
||||
this.showAlert('error', `Token '${token}'`, [msg]);
|
||||
return of(null);
|
||||
}),
|
||||
// Only tag a result that actually came from the dividend pool — a closure hit
|
||||
// recovered by the inner fallback above is already tagged and must stay that way.
|
||||
map((divReq: any) => (divReq && !divReq.__closureRequest ? { __dividendClaim: true, ...divReq } : divReq))
|
||||
map((closeReq: any) => (closeReq ? { __closureRequest: true, ...closeReq } : null))
|
||||
);
|
||||
}
|
||||
this.loadingWithdrawal = false;
|
||||
@@ -3159,7 +3353,16 @@ export class TransactionEntryComponent implements OnInit, OnDestroy {
|
||||
this.clientDetails = null;
|
||||
this.accountError = '';
|
||||
row.acNo = req.client_name ?? 'Dividend Claim #' + req.id;
|
||||
// Marks this acNo as already resolved, the same way loadAccountDetails() itself would —
|
||||
// without it, the next click into this row (onRowSelect) sees an acNo that was never
|
||||
// "loaded" and re-searches for a real account matching this stub string, which can land
|
||||
// on an unrelated real account whose number happens to share a suffix with it.
|
||||
this.lastLoadedAcNo = row.acNo;
|
||||
row.acTyp = 'SD';
|
||||
// Every token-driven row is cash leaving the branch to a member, which is what the
|
||||
// Payment register is for — not what this row's TYPE happened to default to before the
|
||||
// token was entered.
|
||||
row.typ = 'Payment';
|
||||
row.amount = Number(req.amount) || null;
|
||||
row.dbCr = 'Dr';
|
||||
this.selectedRow = row;
|
||||
@@ -3181,7 +3384,10 @@ export class TransactionEntryComponent implements OnInit, OnDestroy {
|
||||
this.clientDetails = null;
|
||||
this.accountError = '';
|
||||
row.acNo = req.account_no ?? '';
|
||||
// See the same line in the dividend-claim branch above for why this is required.
|
||||
this.lastLoadedAcNo = row.acNo;
|
||||
row.acTyp = 'SD';
|
||||
row.typ = 'Payment';
|
||||
row.amount = Number(req.cashier_disbursed_amount) || null;
|
||||
row.dbCr = 'Dr';
|
||||
// A closure request stores payment_type_id as NULL, meaning "Cash (Default)" — resolve the
|
||||
@@ -3194,15 +3400,23 @@ export class TransactionEntryComponent implements OnInit, OnDestroy {
|
||||
}
|
||||
|
||||
this.activeWithdrawalRequest = req;
|
||||
// A plain withdrawal-slip request carries its own real account_type (SAVINGS, CURRENT,
|
||||
// FIXED_DEPOSIT or RECURRING_DEPOSIT — see ClerkWithdrawalRequestApiResource.submit()), unlike
|
||||
// the closure/dividend pools above, which are never anything but a savings-shaped stub.
|
||||
// Hardcoding 'SD' here searched every non-savings account on the wrong endpoint (Current
|
||||
// accounts live under /currentaccounts, not /savingsaccounts) and showed it mislabelled even
|
||||
// when the lookup happened to succeed.
|
||||
const acTyp = TransactionEntryComponent.SEARCH_ACCOUNT_TYPE_TO_AC_TYP[req.account_type || ''] || 'SD';
|
||||
row.acNo = req.account_no ?? '';
|
||||
row.acTyp = 'SD';
|
||||
row.acTyp = acTyp;
|
||||
row.typ = 'Payment';
|
||||
row.amount = Number(req.amount) || null;
|
||||
row.dbCr = 'Dr';
|
||||
row.paymentTypeId = req.payment_type_id ?? null;
|
||||
row.description = req.description ?? '';
|
||||
this.selectedRow = row;
|
||||
if (req.account_no) {
|
||||
this.loadAccountDetails(req.account_no.trim(), 'SD');
|
||||
this.loadAccountDetails(req.account_no.trim(), acTyp);
|
||||
}
|
||||
});
|
||||
}
|
||||
@@ -3229,10 +3443,24 @@ export class TransactionEntryComponent implements OnInit, OnDestroy {
|
||||
onAcNoBlur(row: TxnRow, index: number): void {
|
||||
if (!row.acNo.trim()) return;
|
||||
this.selectedRow = row;
|
||||
// A token-driven row's account fields are already authoritative (see onTokNoBlur) -- merely
|
||||
// clicking into and out of the A/C NO. field here must not re-search for them. Doing so used
|
||||
// row.acTyp's internal stand-in value (e.g. 'SD' for a dividend claim, which is never the
|
||||
// account's real type) against the account's real number, either finding nothing ("Account
|
||||
// not found") or, worse, matching a completely unrelated real account that happened to share a
|
||||
// number suffix.
|
||||
if (this.activeWithdrawalRequest || this.activeDividendClaimRequest || this.activeClosureRequest) {
|
||||
return;
|
||||
}
|
||||
this.loadAccountDetails(row.acNo.trim(), row.acTyp);
|
||||
}
|
||||
|
||||
onAcNoInput(row: TxnRow, index: number, value: string): void {
|
||||
// Same reasoning as the guard at the top of onAcNoBlur() -- a token-driven row's account
|
||||
// fields must not be edited or re-searched via this field at all.
|
||||
if (this.activeWithdrawalRequest || this.activeDividendClaimRequest || this.activeClosureRequest) {
|
||||
return;
|
||||
}
|
||||
row.acNo = value;
|
||||
this.lastLoadedAcNo = '';
|
||||
this.acNoInput$.next({ row, index, value });
|
||||
@@ -3341,8 +3569,15 @@ export class TransactionEntryComponent implements OnInit, OnDestroy {
|
||||
const match = exactMatch ?? partialMatch ?? idMatch ?? null;
|
||||
if (match?.id) {
|
||||
const matchedGlCode = match[f] ?? acNo;
|
||||
if (this.selectedRow && this.selectedRow.typ === 'DD' && match.glCode) {
|
||||
this.selectedRow.acNo = match.glCode;
|
||||
// Any row resolved via the partial/endsWith/id fallback above (not an exact code match)
|
||||
// loads and displays the right account, but the row itself still carries whatever shorter
|
||||
// string the cashier actually typed to search with -- and that's what gets submitted and
|
||||
// shown everywhere downstream (vouchers, My Work, credit_entries), not the account's real
|
||||
// full number. Correct the row to the account's actual code whenever the match wasn't
|
||||
// already exact, for every account type -- not just the DD/GL cases this was previously
|
||||
// limited to.
|
||||
if (this.selectedRow && !exactMatch && matchedGlCode) {
|
||||
this.selectedRow.acNo = matchedGlCode;
|
||||
}
|
||||
const clientIdFromSearch = acTyp !== 'GL' && match.clientId != null ? String(match.clientId) : null;
|
||||
this.fetchAllDetails(match.id, matchedGlCode, cfg, token, acTyp, clientIdFromSearch);
|
||||
@@ -4519,6 +4754,26 @@ export class TransactionEntryComponent implements OnInit, OnDestroy {
|
||||
// "Disbursement". 'Enter Bank Transfer' never carries a GL leg, so it always stays in this
|
||||
// branch; only skip out to the Disbursement/Collection branch below when every row is 'Fund
|
||||
// Transfer' and at least one of them is GL.
|
||||
// Share redemption settled to a GL account instead of the linked savings account: DR=Share
|
||||
// account, CR=any GL account the clerk picks (e.g. a "DD Payable" holding account, ahead of a
|
||||
// second, independent manual entry that later moves the value out to an actual draft). Checked
|
||||
// ahead of both branches below — the Enter Bank Transfer/Fund Transfer branch would otherwise
|
||||
// treat a Fund Transfer carrying a GL leg as the Disbursement/Collection shape (see
|
||||
// fundTransferRowsHaveGl below), and a Share account can never be debited by that path at all —
|
||||
// no such Fineract command exists — so it would either be silently mis-posted or rejected late.
|
||||
if (
|
||||
this.isClerk &&
|
||||
this.rows.length === 2 &&
|
||||
this.rows.every((r) => r.typ === 'Fund Transfer' || r.typ === 'Enter Bank Transfer') &&
|
||||
this.rows.some((r) => r.dbCr === 'Dr' && r.acTyp === 'SHR') &&
|
||||
this.rows.some((r) => r.dbCr === 'Cr' && r.acTyp === 'GL')
|
||||
) {
|
||||
const shareRow = this.rows.find((r) => r.dbCr === 'Dr' && r.acTyp === 'SHR')!;
|
||||
const glRow = this.rows.find((r) => r.dbCr === 'Cr' && r.acTyp === 'GL')!;
|
||||
this.submitShareGlSettlementTransfer(shareRow, glRow);
|
||||
return;
|
||||
}
|
||||
|
||||
const fundTransferRowsHaveGl =
|
||||
this.rows.every((r) => r.typ === 'Fund Transfer') && this.rows.some((r) => r.acTyp === 'GL');
|
||||
if (
|
||||
|
||||
Reference in New Issue
Block a user