feat(shares): add Share Withdrawal maker-checker workflow with GL settlement
Clerk submits a governed share redemption request (shares, Board Resolution No., Meeting Date, settlement Payment Type); Bank Manager reviews with a live backend-validated checklist (account/GL state, not just submit-time snapshot) before approving, which redeems the shares and settles the linked savings account in one transaction. Adds a Passbook tab to the Share Account view, a reusable search-select control, and a Savings Product "Map Payment Types to Specific Funds" fund-source override (only Loan Products had this before) so settlement can be GL-mapped correctly. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
@@ -62,6 +62,22 @@ const routes: Routes = [
|
|||||||
),
|
),
|
||||||
data: { title: 'My Disbursement Requests', breadcrumb: 'My Disbursement Requests' }
|
data: { title: 'My Disbursement Requests', breadcrumb: 'My Disbursement Requests' }
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
path: 'share-withdrawal-request',
|
||||||
|
loadComponent: () =>
|
||||||
|
import('../clerk/share-withdrawal-request/share-withdrawal-request.component').then(
|
||||||
|
(m) => m.ShareWithdrawalRequestComponent
|
||||||
|
),
|
||||||
|
data: { title: 'New Share Withdrawal Request', breadcrumb: 'New Share Withdrawal Request', mode: 'create' }
|
||||||
|
},
|
||||||
|
{
|
||||||
|
path: 'share-withdrawal-requests',
|
||||||
|
loadComponent: () =>
|
||||||
|
import('../clerk/my-share-withdrawal-requests/my-share-withdrawal-requests.component').then(
|
||||||
|
(m) => m.MyShareWithdrawalRequestsComponent
|
||||||
|
),
|
||||||
|
data: { title: 'My Share Requests', breadcrumb: 'My Share Requests' }
|
||||||
|
},
|
||||||
{
|
{
|
||||||
path: 'account-facilities',
|
path: 'account-facilities',
|
||||||
loadComponent: () =>
|
loadComponent: () =>
|
||||||
|
|||||||
@@ -0,0 +1,53 @@
|
|||||||
|
/**
|
||||||
|
* Copyright since 2025 Mifos Initiative
|
||||||
|
*
|
||||||
|
* This Source Code Form is subject to the terms of the Mozilla Public
|
||||||
|
* License, v. 2.0. If a copy of the MPL was not distributed with this
|
||||||
|
* file, You can obtain one at http://mozilla.org/MPL/2.0/.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { Injectable, inject } from '@angular/core';
|
||||||
|
import { HttpClient, HttpParams } from '@angular/common/http';
|
||||||
|
import { Observable } from 'rxjs';
|
||||||
|
|
||||||
|
/** HTTP service for the Share Withdrawal (redeem shares) maker-checker workflow — mirrors
|
||||||
|
* ClerkWithdrawalService (cash withdrawal), adapted for shares. */
|
||||||
|
@Injectable({ providedIn: 'root' })
|
||||||
|
export class ClerkShareWithdrawalService {
|
||||||
|
private http = inject(HttpClient);
|
||||||
|
|
||||||
|
submit(data: any): Observable<any> {
|
||||||
|
return this.http.post('/clerk/share-withdrawal-requests', data);
|
||||||
|
}
|
||||||
|
|
||||||
|
getMyRequests(status?: string, offset = 0, limit = 20, fromDate?: string, toDate?: string): Observable<any> {
|
||||||
|
let params = new HttpParams().set('offset', String(offset)).set('limit', String(limit));
|
||||||
|
if (status) params = params.set('status', status);
|
||||||
|
if (fromDate) params = params.set('fromDate', fromDate);
|
||||||
|
if (toDate) params = params.set('toDate', toDate);
|
||||||
|
return this.http.get('/clerk/share-withdrawal-requests/my', { params });
|
||||||
|
}
|
||||||
|
|
||||||
|
getPendingRequests(status = 'PENDING', offset = 0, limit = 20, fromDate?: string, toDate?: string): Observable<any> {
|
||||||
|
let params = new HttpParams().set('status', status).set('offset', String(offset)).set('limit', String(limit));
|
||||||
|
if (fromDate) params = params.set('fromDate', fromDate);
|
||||||
|
if (toDate) params = params.set('toDate', toDate);
|
||||||
|
return this.http.get('/clerk/share-withdrawal-requests', { params });
|
||||||
|
}
|
||||||
|
|
||||||
|
getById(id: number): Observable<any> {
|
||||||
|
return this.http.get(`/clerk/share-withdrawal-requests/${id}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
validate(id: number): Observable<any> {
|
||||||
|
return this.http.post(`/clerk/share-withdrawal-requests/${id}/validate`, {});
|
||||||
|
}
|
||||||
|
|
||||||
|
approve(id: number): Observable<any> {
|
||||||
|
return this.http.post(`/clerk/share-withdrawal-requests/${id}/approve`, {});
|
||||||
|
}
|
||||||
|
|
||||||
|
reject(id: number, reason: string): Observable<any> {
|
||||||
|
return this.http.post(`/clerk/share-withdrawal-requests/${id}/reject`, { reason });
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -146,7 +146,7 @@
|
|||||||
[class.ws-f__ctl--req]="!row.paymentTypeId"
|
[class.ws-f__ctl--req]="!row.paymentTypeId"
|
||||||
>
|
>
|
||||||
<option [ngValue]="null">— Select —</option>
|
<option [ngValue]="null">— Select —</option>
|
||||||
@for (pt of paymentTypes; track pt.id) {
|
@for (pt of paymentTypesForRow(row); track pt.id) {
|
||||||
<option [ngValue]="pt.id">{{ pt.name }}</option>
|
<option [ngValue]="pt.id">{{ pt.name }}</option>
|
||||||
}
|
}
|
||||||
</select>
|
</select>
|
||||||
@@ -231,8 +231,11 @@
|
|||||||
<span class="ws-caption">Payment instrument</span>
|
<span class="ws-caption">Payment instrument</span>
|
||||||
<div class="ws-group__fields">
|
<div class="ws-group__fields">
|
||||||
<div class="ws-f ws-f--amt">
|
<div class="ws-f ws-f--amt">
|
||||||
<label class="ws-f__lbl" title="Transaction Amount">
|
<label
|
||||||
AMOUNT
|
class="ws-f__lbl"
|
||||||
|
[title]="row.acTyp === 'SHR' ? 'Number of Shares' : 'Transaction Amount'"
|
||||||
|
>
|
||||||
|
{{ row.acTyp === 'SHR' ? 'SHARE NO.' : 'AMOUNT' }}
|
||||||
@if (!isClerk && activeWithdrawalRequest) {
|
@if (!isClerk && activeWithdrawalRequest) {
|
||||||
<span class="ws-amt-locked-badge" title="Amount locked — approved withdrawal">🔒</span>
|
<span class="ws-amt-locked-badge" title="Amount locked — approved withdrawal">🔒</span>
|
||||||
}
|
}
|
||||||
@@ -242,20 +245,48 @@
|
|||||||
[class.ws-amt-wrap--cash]="isCashPayment && selectedRow === row"
|
[class.ws-amt-wrap--cash]="isCashPayment && selectedRow === row"
|
||||||
[class.ws-amt-wrap--locked]="!isClerk && !!activeWithdrawalRequest"
|
[class.ws-amt-wrap--locked]="!isClerk && !!activeWithdrawalRequest"
|
||||||
>
|
>
|
||||||
<span class="ws-amt-prefix">₹</span>
|
@if (row.acTyp !== 'SHR') {
|
||||||
|
<span class="ws-amt-prefix">₹</span>
|
||||||
|
}
|
||||||
<input
|
<input
|
||||||
class="ws-f__ctl ws-amt-input"
|
class="ws-f__ctl ws-amt-input"
|
||||||
type="number"
|
type="number"
|
||||||
[value]="row.amount ?? ''"
|
[value]="row.amount ?? ''"
|
||||||
(input)="row.amount = +$any($event.target).value"
|
(input)="row.amount = +$any($event.target).value; syncShareLinkedAmounts()"
|
||||||
(click)="$event.stopPropagation()"
|
(click)="$event.stopPropagation()"
|
||||||
placeholder="0.00"
|
[placeholder]="row.acTyp === 'SHR' ? '0' : '0.00'"
|
||||||
[readonly]="!isClerk && !!activeWithdrawalRequest"
|
[readonly]="!isClerk && !!activeWithdrawalRequest"
|
||||||
[class.ws-amt-input--locked]="!isClerk && !!activeWithdrawalRequest"
|
[class.ws-amt-input--locked]="!isClerk && !!activeWithdrawalRequest"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
@if (row.acTyp === 'SHR' && rowSharePrices[row.acNo]) {
|
||||||
|
<span class="ws-f__hint"
|
||||||
|
>= ₹{{ (row.amount ?? 0) * rowSharePrices[row.acNo] | number: '1.2-2' }} @ ₹{{
|
||||||
|
rowSharePrices[row.acNo] | number: '1.2-2'
|
||||||
|
}}/share</span
|
||||||
|
>
|
||||||
|
}
|
||||||
</div>
|
</div>
|
||||||
@if (row.typ !== 'Withdrawal Slip') {
|
@if (row.acTyp === 'SHR' && row.typ === 'Enter Bank Transfer') {
|
||||||
|
<div class="ws-f ws-f--cheqno">
|
||||||
|
<label class="ws-f__lbl" title="Board Resolution Number">RESOLUTION NO.</label>
|
||||||
|
<input
|
||||||
|
class="ws-f__ctl"
|
||||||
|
[(ngModel)]="row.resolutionNumber"
|
||||||
|
(click)="$event.stopPropagation()"
|
||||||
|
placeholder="e.g. BR-2026-014"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div class="ws-f ws-f--cheqdt">
|
||||||
|
<label class="ws-f__lbl" title="Date of Meeting">MEETING DATE</label>
|
||||||
|
<input
|
||||||
|
class="ws-f__ctl"
|
||||||
|
type="date"
|
||||||
|
[(ngModel)]="row.meetingDate"
|
||||||
|
(click)="$event.stopPropagation()"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
} @else if (row.typ !== 'Withdrawal Slip') {
|
||||||
<div class="ws-f ws-f--cheqno">
|
<div class="ws-f ws-f--cheqno">
|
||||||
<label class="ws-f__lbl" title="Cheque Number">CHEQUE NO.</label>
|
<label class="ws-f__lbl" title="Cheque Number">CHEQUE NO.</label>
|
||||||
<input
|
<input
|
||||||
@@ -712,6 +743,32 @@
|
|||||||
'—'
|
'—'
|
||||||
}}</span>
|
}}</span>
|
||||||
</div>
|
</div>
|
||||||
|
@if (selectedRow?.acTyp === 'SHR') {
|
||||||
|
<div class="te-field">
|
||||||
|
<span class="te-label">No. of Shares Held</span
|
||||||
|
><span class="te-value te-mono">{{ accountDetails.summary?.totalApprovedShares ?? 0 }}</span>
|
||||||
|
</div>
|
||||||
|
<div class="te-field">
|
||||||
|
<span class="te-label">Market Price / Share</span
|
||||||
|
><span class="te-value te-mono"
|
||||||
|
>{{ currencySymbol }}{{ accountDetails.currentMarketPrice | number: '1.2-2' }}</span
|
||||||
|
>
|
||||||
|
</div>
|
||||||
|
<div class="te-field">
|
||||||
|
<span class="te-label">Resolution No.</span
|
||||||
|
><span class="te-value te-mono">{{ accountDetails.resolutionNumber || '—' }}</span>
|
||||||
|
</div>
|
||||||
|
<div class="te-field">
|
||||||
|
<span class="te-label">Linked Savings A/C No.</span
|
||||||
|
><span class="te-value te-mono">{{ accountDetails.savingsAccountNumber || '—' }}</span>
|
||||||
|
</div>
|
||||||
|
@if (accountDetails.linkedLoanAccountNo) {
|
||||||
|
<div class="te-field">
|
||||||
|
<span class="te-label">Linked Loan A/C No.</span
|
||||||
|
><span class="te-value te-mono">{{ accountDetails.linkedLoanAccountNo }}</span>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="te-panel__header te-panel__header--mt">
|
<div class="te-panel__header te-panel__header--mt">
|
||||||
@@ -724,7 +781,13 @@
|
|||||||
<div class="te-panel__body">
|
<div class="te-panel__body">
|
||||||
<div class="te-balance-row">
|
<div class="te-balance-row">
|
||||||
<span class="te-bal-num">1.</span>
|
<span class="te-bal-num">1.</span>
|
||||||
<span class="te-bal-label">{{ accountDetails?.glCode ? 'GL Balance' : 'Current Balance' }}</span>
|
<span class="te-bal-label">{{
|
||||||
|
selectedRow?.acTyp === 'SHR'
|
||||||
|
? 'Share Balance'
|
||||||
|
: accountDetails?.glCode
|
||||||
|
? 'GL Balance'
|
||||||
|
: 'Current Balance'
|
||||||
|
}}</span>
|
||||||
<span class="te-bal-value te-bal-value--cr"
|
<span class="te-bal-value te-bal-value--cr"
|
||||||
>{{ currencySymbol }}{{ currentBalance | number: '1.2-2' }}</span
|
>{{ currencySymbol }}{{ currentBalance | number: '1.2-2' }}</span
|
||||||
>
|
>
|
||||||
@@ -1281,7 +1344,7 @@
|
|||||||
[class.ws-f__ctl--req]="!row.paymentTypeId"
|
[class.ws-f__ctl--req]="!row.paymentTypeId"
|
||||||
>
|
>
|
||||||
<option [ngValue]="null">— Select —</option>
|
<option [ngValue]="null">— Select —</option>
|
||||||
@for (pt of paymentTypes; track pt.id) {
|
@for (pt of paymentTypesForRow(row); track pt.id) {
|
||||||
<option [ngValue]="pt.id">{{ pt.name }}</option>
|
<option [ngValue]="pt.id">{{ pt.name }}</option>
|
||||||
}
|
}
|
||||||
</select>
|
</select>
|
||||||
@@ -1360,7 +1423,7 @@
|
|||||||
<div class="ws-group__fields">
|
<div class="ws-group__fields">
|
||||||
<div class="ws-f ws-f--amt">
|
<div class="ws-f ws-f--amt">
|
||||||
<label class="ws-f__lbl">
|
<label class="ws-f__lbl">
|
||||||
AMOUNT
|
{{ row.acTyp === 'SHR' ? 'SHARE NO.' : 'AMOUNT' }}
|
||||||
@if (!isClerk && activeWithdrawalRequest) {
|
@if (!isClerk && activeWithdrawalRequest) {
|
||||||
<span class="ws-amt-locked-badge" title="Amount locked — approved withdrawal">🔒</span>
|
<span class="ws-amt-locked-badge" title="Amount locked — approved withdrawal">🔒</span>
|
||||||
}
|
}
|
||||||
@@ -1370,27 +1433,51 @@
|
|||||||
[class.ws-amt-wrap--cash]="isCashPayment && selectedRow === row"
|
[class.ws-amt-wrap--cash]="isCashPayment && selectedRow === row"
|
||||||
[class.ws-amt-wrap--locked]="!isClerk && !!activeWithdrawalRequest"
|
[class.ws-amt-wrap--locked]="!isClerk && !!activeWithdrawalRequest"
|
||||||
>
|
>
|
||||||
<span class="ws-amt-prefix">₹</span>
|
@if (row.acTyp !== 'SHR') {
|
||||||
|
<span class="ws-amt-prefix">₹</span>
|
||||||
|
}
|
||||||
<input
|
<input
|
||||||
class="ws-f__ctl ws-amt-input"
|
class="ws-f__ctl ws-amt-input"
|
||||||
type="number"
|
type="number"
|
||||||
[value]="row.amount ?? ''"
|
[value]="row.amount ?? ''"
|
||||||
(input)="row.amount = +$any($event.target).value"
|
(input)="row.amount = +$any($event.target).value; syncShareLinkedAmounts()"
|
||||||
(click)="$event.stopPropagation()"
|
(click)="$event.stopPropagation()"
|
||||||
placeholder="0.00"
|
[placeholder]="row.acTyp === 'SHR' ? '0' : '0.00'"
|
||||||
[readonly]="!isClerk && !!activeWithdrawalRequest"
|
[readonly]="!isClerk && !!activeWithdrawalRequest"
|
||||||
[class.ws-amt-input--locked]="!isClerk && !!activeWithdrawalRequest"
|
[class.ws-amt-input--locked]="!isClerk && !!activeWithdrawalRequest"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
@if (row.acTyp === 'SHR' && rowSharePrices[row.acNo]) {
|
||||||
|
<span class="ws-f__hint"
|
||||||
|
>= ₹{{ (row.amount ?? 0) * rowSharePrices[row.acNo] | number: '1.2-2' }} @ ₹{{
|
||||||
|
rowSharePrices[row.acNo] | number: '1.2-2'
|
||||||
|
}}/share</span
|
||||||
|
>
|
||||||
|
}
|
||||||
</div>
|
</div>
|
||||||
<div class="ws-f ws-f--cheqno">
|
@if (row.acTyp === 'SHR' && row.typ === 'Enter Bank Transfer') {
|
||||||
<label class="ws-f__lbl">CHEQUE NO.</label>
|
<div class="ws-f ws-f--cheqno">
|
||||||
<input class="ws-f__ctl ws-f__ctl--mono" [(ngModel)]="row.chequeNo" placeholder="—" />
|
<label class="ws-f__lbl">RESOLUTION NO.</label>
|
||||||
</div>
|
<input
|
||||||
<div class="ws-f ws-f--cheqdt">
|
class="ws-f__ctl"
|
||||||
<label class="ws-f__lbl">CHEQ. DATE</label>
|
[(ngModel)]="row.resolutionNumber"
|
||||||
<input class="ws-f__ctl" [(ngModel)]="row.cheqDate" placeholder="dd-mm-yyyy" />
|
placeholder="e.g. BR-2026-014"
|
||||||
</div>
|
/>
|
||||||
|
</div>
|
||||||
|
<div class="ws-f ws-f--cheqdt">
|
||||||
|
<label class="ws-f__lbl">MEETING DATE</label>
|
||||||
|
<input class="ws-f__ctl" type="date" [(ngModel)]="row.meetingDate" />
|
||||||
|
</div>
|
||||||
|
} @else {
|
||||||
|
<div class="ws-f ws-f--cheqno">
|
||||||
|
<label class="ws-f__lbl">CHEQUE NO.</label>
|
||||||
|
<input class="ws-f__ctl ws-f__ctl--mono" [(ngModel)]="row.chequeNo" placeholder="—" />
|
||||||
|
</div>
|
||||||
|
<div class="ws-f ws-f--cheqdt">
|
||||||
|
<label class="ws-f__lbl">CHEQ. DATE</label>
|
||||||
|
<input class="ws-f__ctl" [(ngModel)]="row.cheqDate" placeholder="dd-mm-yyyy" />
|
||||||
|
</div>
|
||||||
|
}
|
||||||
<div class="ws-f ws-f--tok">
|
<div class="ws-f ws-f--tok">
|
||||||
<label class="ws-f__lbl">TOK. NO.</label>
|
<label class="ws-f__lbl">TOK. NO.</label>
|
||||||
<input
|
<input
|
||||||
|
|||||||
@@ -961,6 +961,14 @@ input.ws-amt-input--locked {
|
|||||||
flex-shrink: 0;
|
flex-shrink: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.ws-f__hint {
|
||||||
|
display: block;
|
||||||
|
margin-top: 2px;
|
||||||
|
font-size: 10px;
|
||||||
|
color: $ws-muted;
|
||||||
|
font-weight: 500;
|
||||||
|
}
|
||||||
|
|
||||||
.ws-amt-input {
|
.ws-amt-input {
|
||||||
flex: 1;
|
flex: 1;
|
||||||
height: 100%;
|
height: 100%;
|
||||||
|
|||||||
@@ -21,6 +21,7 @@ import { AuthenticationService } from '../../core/authentication/authentication.
|
|||||||
import { ClerkUiStateService, ClerkSavedAccount } from '../clerk-ui-state.service';
|
import { ClerkUiStateService, ClerkSavedAccount } from '../clerk-ui-state.service';
|
||||||
import { ClerkWithdrawalService } from '../clerk-withdrawal.service';
|
import { ClerkWithdrawalService } from '../clerk-withdrawal.service';
|
||||||
import { ClerkDisbursementService } from '../clerk-disbursement.service';
|
import { ClerkDisbursementService } from '../clerk-disbursement.service';
|
||||||
|
import { ClerkShareWithdrawalService } from '../clerk-share-withdrawal.service';
|
||||||
import { CashierDepositService } from '../cashier-deposit.service';
|
import { CashierDepositService } from '../cashier-deposit.service';
|
||||||
import { SettingsService } from '../../settings/settings.service';
|
import { SettingsService } from '../../settings/settings.service';
|
||||||
import {
|
import {
|
||||||
@@ -47,6 +48,10 @@ interface TxnRow {
|
|||||||
routingCode: string;
|
routingCode: string;
|
||||||
/** DD issuance charges/commission — only used when TYPE=DD, TXN TP=Cash, AC.TP=GL */
|
/** DD issuance charges/commission — only used when TYPE=DD, TXN TP=Cash, AC.TP=GL */
|
||||||
ddCharges: number | null;
|
ddCharges: number | null;
|
||||||
|
/** Only used when this row is AC.TP=SHR inside an Enter Bank Transfer to its linked savings
|
||||||
|
* account — governs the share redemption (see submitShareRedemptionTransfer()). */
|
||||||
|
resolutionNumber?: string;
|
||||||
|
meetingDate?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
interface Denomination {
|
interface Denomination {
|
||||||
@@ -753,6 +758,7 @@ export class TransactionEntryComponent implements OnInit, OnDestroy {
|
|||||||
private clerkUiState = inject(ClerkUiStateService);
|
private clerkUiState = inject(ClerkUiStateService);
|
||||||
private clerkWithdrawalService = inject(ClerkWithdrawalService);
|
private clerkWithdrawalService = inject(ClerkWithdrawalService);
|
||||||
private clerkDisbursementService = inject(ClerkDisbursementService);
|
private clerkDisbursementService = inject(ClerkDisbursementService);
|
||||||
|
private clerkShareWithdrawalService = inject(ClerkShareWithdrawalService);
|
||||||
private cashierDepositService = inject(CashierDepositService);
|
private cashierDepositService = inject(CashierDepositService);
|
||||||
readonly router = inject(Router);
|
readonly router = inject(Router);
|
||||||
private settingsService = inject(SettingsService);
|
private settingsService = inject(SettingsService);
|
||||||
@@ -769,9 +775,38 @@ export class TransactionEntryComponent implements OnInit, OnDestroy {
|
|||||||
loadingAccount = false;
|
loadingAccount = false;
|
||||||
accountError = '';
|
accountError = '';
|
||||||
|
|
||||||
|
/** Share redemption settlement is always a book-transfer to the linked savings account — never a
|
||||||
|
* cash handover. A Share (SHR) row's own TXN TP. never offers a cash payment type, and neither
|
||||||
|
* does its paired CR (savings) row in "Enter Bank Transfer" mode — that CR row's payment type is
|
||||||
|
* the one that actually governs how the settlement credit posts (see submitShareRedemptionTransfer()).
|
||||||
|
* Every other account type/mode keeps the full, unfiltered list. */
|
||||||
|
paymentTypesForRow(row: TxnRow): any[] {
|
||||||
|
if (row.acTyp === 'SHR') {
|
||||||
|
return this.paymentTypes.filter((pt: any) => !pt.isCashPayment);
|
||||||
|
}
|
||||||
|
const isShareSettlementCredit =
|
||||||
|
row.typ === 'Enter Bank Transfer' &&
|
||||||
|
row.dbCr === 'Cr' &&
|
||||||
|
this.rows.some((r) => r.typ === 'Enter Bank Transfer' && r.dbCr === 'Dr' && r.acTyp === 'SHR');
|
||||||
|
if (isShareSettlementCredit) {
|
||||||
|
return this.paymentTypes.filter((pt: any) => !pt.isCashPayment);
|
||||||
|
}
|
||||||
|
return this.paymentTypes;
|
||||||
|
}
|
||||||
|
|
||||||
// Per-row account id/name map: acNo → Fineract id/name (used for multi-row disbursement)
|
// Per-row account id/name map: acNo → Fineract id/name (used for multi-row disbursement)
|
||||||
rowAccountIds: Record<string, number> = {};
|
rowAccountIds: Record<string, number> = {};
|
||||||
rowAccountNames: Record<string, string> = {};
|
rowAccountNames: Record<string, string> = {};
|
||||||
|
// Per-row Share account market price, keyed by acNo — lets a Share row's "SHARE NO." field
|
||||||
|
// auto-fill the settlement amount on any other row added alongside it (calculator convenience
|
||||||
|
// only; a Share row still can never itself be saved from this screen — see accountTypeConfig).
|
||||||
|
rowSharePrices: Record<string, number> = {};
|
||||||
|
// Per-row client id, keyed by acNo — needed to submit a Share Withdrawal Request from a row
|
||||||
|
// that isn't necessarily the currently "selected" one.
|
||||||
|
rowClientIds: Record<string, number> = {};
|
||||||
|
// Per-row linked savings account number, keyed by the Share account's own acNo — used to make
|
||||||
|
// sure a Share-to-linked-account Bank Transfer actually credits the right account.
|
||||||
|
rowLinkedSavingsAcNo: Record<string, string> = {};
|
||||||
|
|
||||||
// Full account + client data
|
// Full account + client data
|
||||||
accountDetails: any = null;
|
accountDetails: any = null;
|
||||||
@@ -1205,6 +1240,9 @@ export class TransactionEntryComponent implements OnInit, OnDestroy {
|
|||||||
};
|
};
|
||||||
this.rows.push(mirror);
|
this.rows.push(mirror);
|
||||||
this.selectedRow = mirror;
|
this.selectedRow = mirror;
|
||||||
|
if (active.acTyp === 'SHR') {
|
||||||
|
this.syncShareLinkedAmounts();
|
||||||
|
}
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
const row: TxnRow = {
|
const row: TxnRow = {
|
||||||
@@ -1499,6 +1537,182 @@ export class TransactionEntryComponent implements OnInit, OnDestroy {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** One-step Share redemption from Bank Transfer mode: DR row is the Share account (its
|
||||||
|
* "SHARE NO." field), CR row must be that account's own linked savings account. Submits a
|
||||||
|
* governed Share Withdrawal Request (same maker-checker table/flow as the dedicated screen) —
|
||||||
|
* approval both redeems the shares and settles the linked account automatically. */
|
||||||
|
private submitShareRedemptionTransfer(shareRow: TxnRow, savingsRow: TxnRow): void {
|
||||||
|
// Belt-and-braces: the TXN TP. dropdown already hides cash payment types for the CR (savings)
|
||||||
|
// row (see paymentTypesForRow()), but re-check here too in case its payment type was set before
|
||||||
|
// that filter applied — settlement is always a book-transfer to the linked savings account, and
|
||||||
|
// it's the CR row's payment type that actually governs how that credit posts, never the DR
|
||||||
|
// (Share) row's, which has no real payment-type concept.
|
||||||
|
const settlementPaymentType = this.paymentTypes.find((p: any) => p.id === savingsRow.paymentTypeId);
|
||||||
|
if (!savingsRow.paymentTypeId) {
|
||||||
|
this.showAlert('error', 'Settlement Payment Type Required', [
|
||||||
|
'Select a Payment Type on the CR (savings 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 to the linked savings account, never cash.',
|
||||||
|
'Select a non-cash Payment Type on the CR row (e.g. Transfer or Share Redemption Transfer).'
|
||||||
|
]);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const shareAccountId = this.rowAccountIds[shareRow.acNo];
|
||||||
|
if (!shareAccountId) {
|
||||||
|
this.showAlert('error', 'Share Account Not Loaded', [`Please load account details for: ${shareRow.acNo}`]);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (!this.rowAccountIds[savingsRow.acNo]) {
|
||||||
|
this.showAlert('error', 'Savings Account Not Loaded', [`Please load account details for: ${savingsRow.acNo}`]);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const linkedAcNo = this.rowLinkedSavingsAcNo[shareRow.acNo];
|
||||||
|
const enteredAcNo = savingsRow.acNo?.trim();
|
||||||
|
const matchesLinked =
|
||||||
|
!!linkedAcNo &&
|
||||||
|
(linkedAcNo === enteredAcNo || linkedAcNo.endsWith(enteredAcNo) || savingsRow.ecsAcNo === linkedAcNo);
|
||||||
|
if (linkedAcNo && !matchesLinked) {
|
||||||
|
this.showAlert('error', 'Wrong Credit Account', [
|
||||||
|
`The CR account must be this Share account's own linked savings account: ${linkedAcNo}`,
|
||||||
|
`You entered: ${enteredAcNo}`
|
||||||
|
]);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (!linkedAcNo) {
|
||||||
|
this.showAlert('error', 'No Linked Savings Account', [
|
||||||
|
'This Share account has no linked savings account configured — settlement has nowhere to go.'
|
||||||
|
]);
|
||||||
|
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. Business logic still only uses settlementPaymentTypeId above; the rest of
|
||||||
|
// these fields are not re-read by the backend, they are the audit record of what was on screen.
|
||||||
|
const settlementRowSnapshot = {
|
||||||
|
branch: savingsRow.br,
|
||||||
|
accountType: savingsRow.acTyp,
|
||||||
|
accountNo: savingsRow.acNo,
|
||||||
|
ecsAccountNo: savingsRow.ecsAcNo || null,
|
||||||
|
dbCr: savingsRow.dbCr,
|
||||||
|
amount: savingsRow.amount ?? null,
|
||||||
|
chequeNo: savingsRow.chequeNo || null,
|
||||||
|
chequeDate: savingsRow.cheqDate || null,
|
||||||
|
tokenNo: savingsRow.tokNo || null,
|
||||||
|
description: savingsRow.description || null,
|
||||||
|
bankName: savingsRow.bankName || null,
|
||||||
|
routingCode: savingsRow.routingCode || null,
|
||||||
|
paymentTypeId: savingsRow.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: savingsRow.paymentTypeId,
|
||||||
|
settlementRowSnapshot
|
||||||
|
};
|
||||||
|
|
||||||
|
const sharePrice = this.rowSharePrices[shareRow.acNo] ?? 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 })}`,
|
||||||
|
`Board Resolution No.: ${payload.resolutionNumber}`,
|
||||||
|
`Date of Meeting: ${payload.meetingDate}`,
|
||||||
|
`Settlement Credit To: ${linkedAcNo}`,
|
||||||
|
`Settlement Payment Type: ${settlementPaymentType?.name ?? '—'}`,
|
||||||
|
...(settlementRowSnapshot.ecsAccountNo ? [`ECS A/C No.: ${settlementRowSnapshot.ecsAccountNo}`] : []),
|
||||||
|
...(settlementRowSnapshot.chequeNo ? [`Cheque No.: ${settlementRowSnapshot.chequeNo}`] : []),
|
||||||
|
...(settlementRowSnapshot.chequeDate ? [`Cheque Date: ${settlementRowSnapshot.chequeDate}`] : []),
|
||||||
|
...(settlementRowSnapshot.tokenNo ? [`Token No.: ${settlementRowSnapshot.tokenNo}`] : []),
|
||||||
|
...(settlementRowSnapshot.description ? [`CR Row Remarks: ${settlementRowSnapshot.description}`] : []),
|
||||||
|
'',
|
||||||
|
'Validation checks:',
|
||||||
|
` ✓ Share account and linked savings account both loaded`,
|
||||||
|
` ✓ CR account matches this Share account's linked savings account`,
|
||||||
|
` ✓ Settlement Payment Type is non-cash`,
|
||||||
|
` ✓ Board Resolution Number and Date of Meeting provided`,
|
||||||
|
'',
|
||||||
|
'Pending Manager approval — redemption and settlement both post together on approval.'
|
||||||
|
];
|
||||||
|
this.showConfirmAlert('info', 'Confirm Share Withdrawal Request', confirmLines, 'Submit for Approval', () =>
|
||||||
|
this.doSubmitShareRedemptionTransfer(payload, shareRow, shares, linkedAcNo)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
private doSubmitShareRedemptionTransfer(
|
||||||
|
payload: Record<string, unknown>,
|
||||||
|
shareRow: TxnRow,
|
||||||
|
shares: number,
|
||||||
|
linkedAcNo: 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) {
|
||||||
|
this.showAlert('success', 'Share Withdrawal Submitted', [
|
||||||
|
`${shares} share(s) from ${shareRow.acNo} → settlement to ${linkedAcNo} on approval.`,
|
||||||
|
'Pending Manager approval. Track status in "My Share Requests".'
|
||||||
|
]);
|
||||||
|
this.rows = [];
|
||||||
|
this.rowAccountIds = {};
|
||||||
|
this.rowAccountNames = {};
|
||||||
|
this.rowSharePrices = {};
|
||||||
|
this.rowClientIds = {};
|
||||||
|
this.rowLinkedSavingsAcNo = {};
|
||||||
|
this.addRow();
|
||||||
|
this.selectedRow = this.rows[0];
|
||||||
|
this.accountDetails = null;
|
||||||
|
this.clientDetails = null;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
/** Cashier enters a token number → look up the APPROVED withdrawal request for auto-fill. */
|
/** Cashier enters a token number → look up the APPROVED withdrawal request for auto-fill. */
|
||||||
onTokNoBlur(row: TxnRow): void {
|
onTokNoBlur(row: TxnRow): void {
|
||||||
if (this.isClerk || !row.tokNo?.trim()) return;
|
if (this.isClerk || !row.tokNo?.trim()) return;
|
||||||
@@ -1535,6 +1749,25 @@ export class TransactionEntryComponent implements OnInit, OnDestroy {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** When exactly one row on screen is a Share account with a known market price, auto-fills every
|
||||||
|
* OTHER row's amount as (shares entered on that row) × (its market price) — a calculator
|
||||||
|
* convenience for filling the paired settlement entry. The Share row itself still can never be
|
||||||
|
* saved from this screen (see the SHR guard in confirmSubmit()) — this only helps compute the
|
||||||
|
* correct amount to carry over into the separate manual Deposit Slip entry. */
|
||||||
|
syncShareLinkedAmounts(): void {
|
||||||
|
const shareRows = this.rows.filter((r) => r.acTyp?.toUpperCase() === 'SHR' && this.rowSharePrices[r.acNo]);
|
||||||
|
if (shareRows.length !== 1) return;
|
||||||
|
const shareRow = shareRows[0];
|
||||||
|
const price = this.rowSharePrices[shareRow.acNo] ?? 0;
|
||||||
|
const shares = Number(shareRow.amount) || 0;
|
||||||
|
const total = Math.round(shares * price * 100) / 100;
|
||||||
|
this.rows.forEach((r) => {
|
||||||
|
if (r !== shareRow) {
|
||||||
|
r.amount = total;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
onAcNoBlur(row: TxnRow, index: number): void {
|
onAcNoBlur(row: TxnRow, index: number): void {
|
||||||
if (!row.acNo.trim()) return;
|
if (!row.acNo.trim()) return;
|
||||||
this.selectedRow = row;
|
this.selectedRow = row;
|
||||||
@@ -1633,12 +1866,14 @@ export class TransactionEntryComponent implements OnInit, OnDestroy {
|
|||||||
if (token !== this.searchToken) return; // stale result — newer search started
|
if (token !== this.searchToken) return; // stale result — newer search started
|
||||||
const allItems: any[] = res?.pageItems ?? (Array.isArray(res) ? res : []);
|
const allItems: any[] = res?.pageItems ?? (Array.isArray(res) ? res : []);
|
||||||
const f = cfg.matchField;
|
const f = cfg.matchField;
|
||||||
// Exact match first, then suffix/partial match (backend may return by last-N-digits)
|
// 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
|
||||||
|
// FULL unfiltered list rather than a server-side-narrowed one, so a partial match can't be
|
||||||
|
// gated on "exactly one row came back" — it never would be for those types.
|
||||||
const exactMatch = allItems.find((x: any) => x[f] === acNo);
|
const exactMatch = allItems.find((x: any) => x[f] === acNo);
|
||||||
const partialMatch =
|
const partialMatch = !exactMatch
|
||||||
!exactMatch && allItems.length === 1
|
? (allItems.find((x: any) => x[f]?.endsWith(acNo)) ?? allItems.find((x: any) => x[f]?.includes(acNo)))
|
||||||
? allItems.find((x: any) => x[f]?.endsWith(acNo) || x[f]?.includes(acNo))
|
: null;
|
||||||
: null;
|
|
||||||
const match = exactMatch ?? partialMatch ?? null;
|
const match = exactMatch ?? partialMatch ?? null;
|
||||||
if (match?.id) {
|
if (match?.id) {
|
||||||
const clientIdFromSearch = acTyp !== 'GL' && match.clientId != null ? String(match.clientId) : null;
|
const clientIdFromSearch = acTyp !== 'GL' && match.clientId != null ? String(match.clientId) : null;
|
||||||
@@ -1659,6 +1894,17 @@ export class TransactionEntryComponent implements OnInit, OnDestroy {
|
|||||||
siAccountType: number;
|
siAccountType: number;
|
||||||
txnBase: string;
|
txnBase: string;
|
||||||
txnSearchSupported: boolean;
|
txnSearchSupported: boolean;
|
||||||
|
/** True when this account type has no separate REST transactions sub-resource at all (e.g.
|
||||||
|
* Share accounts — Fineract returns transactions embedded on the account detail response
|
||||||
|
* itself via ?associations=all, never at /accounts/share/{id}/transactions). Skips the
|
||||||
|
* "today's transactions" HTTP call entirely instead of hitting a 404. */
|
||||||
|
txnFetchUnsupported?: boolean;
|
||||||
|
/** True when this account type has no standing-instruction "from account type" mapping on
|
||||||
|
* the backend at all (PortfolioAccountType only defines LOAN/SAVINGS/CURRENT — Share isn't
|
||||||
|
* a valid standing-instruction source account). Skips that HTTP call entirely instead of
|
||||||
|
* sending an fromAccountType value the backend's query-builder silently can't match,
|
||||||
|
* which otherwise produces a malformed "WHERE <empty>" SQL statement server-side. */
|
||||||
|
siFetchUnsupported?: boolean;
|
||||||
maxLimit: number;
|
maxLimit: number;
|
||||||
skipGlobalSearch: boolean;
|
skipGlobalSearch: boolean;
|
||||||
listQueryParam: string;
|
listQueryParam: string;
|
||||||
@@ -1693,6 +1939,8 @@ export class TransactionEntryComponent implements OnInit, OnDestroy {
|
|||||||
siAccountType: 4,
|
siAccountType: 4,
|
||||||
txnBase: '/accounts/share',
|
txnBase: '/accounts/share',
|
||||||
txnSearchSupported: false,
|
txnSearchSupported: false,
|
||||||
|
txnFetchUnsupported: true,
|
||||||
|
siFetchUnsupported: true,
|
||||||
maxLimit: 500,
|
maxLimit: 500,
|
||||||
skipGlobalSearch: true,
|
skipGlobalSearch: true,
|
||||||
listQueryParam: 'accountNo',
|
listQueryParam: 'accountNo',
|
||||||
@@ -1805,6 +2053,8 @@ export class TransactionEntryComponent implements OnInit, OnDestroy {
|
|||||||
siAccountType: number;
|
siAccountType: number;
|
||||||
txnBase: string;
|
txnBase: string;
|
||||||
txnSearchSupported: boolean;
|
txnSearchSupported: boolean;
|
||||||
|
txnFetchUnsupported?: boolean;
|
||||||
|
siFetchUnsupported?: boolean;
|
||||||
detailParams?: string;
|
detailParams?: string;
|
||||||
},
|
},
|
||||||
token: number,
|
token: number,
|
||||||
@@ -1837,13 +2087,29 @@ export class TransactionEntryComponent implements OnInit, OnDestroy {
|
|||||||
this.rowAccountIds[this.selectedRow.acNo] = acc.id;
|
this.rowAccountIds[this.selectedRow.acNo] = acc.id;
|
||||||
this.rowAccountNames[this.selectedRow.acNo] =
|
this.rowAccountNames[this.selectedRow.acNo] =
|
||||||
acc.name ?? acc.displayName ?? acc.glAccountName ?? acc.accountNo ?? '';
|
acc.name ?? acc.displayName ?? acc.glAccountName ?? acc.accountNo ?? '';
|
||||||
|
this.rowClientIds[this.selectedRow.acNo] = acc.clientId ?? null;
|
||||||
}
|
}
|
||||||
if (this.selectedRow) {
|
if (this.selectedRow) {
|
||||||
this.selectedRow.ecsAcNo = acc.glCode ?? acc.savingsAccountNumber ?? acc.accountNo ?? '';
|
// Share accounts: ECS field is the share account's own number — acc.savingsAccountNumber
|
||||||
|
// is a different, unrelated account (the linked settlement savings account) and must
|
||||||
|
// never be shown here.
|
||||||
|
this.selectedRow.ecsAcNo =
|
||||||
|
acTyp?.toUpperCase() === 'SHR'
|
||||||
|
? (acc.accountNo ?? '')
|
||||||
|
: (acc.glCode ?? acc.savingsAccountNumber ?? acc.accountNo ?? '');
|
||||||
}
|
}
|
||||||
if (this.isClerk) this.clerkUiState.setFacilities(this.facilities, acNo, acTyp);
|
if (this.isClerk) this.clerkUiState.setFacilities(this.facilities, acNo, acTyp);
|
||||||
this.computeSameDayNeftRtgs(acc.transactions);
|
this.computeSameDayNeftRtgs(acc.transactions);
|
||||||
this.computeSameDayChequeClearing(acc.transactions);
|
this.computeSameDayChequeClearing(acc.transactions);
|
||||||
|
if (acTyp?.toUpperCase() === 'SHR' && this.selectedRow?.acNo) {
|
||||||
|
// Keyed by the row's own typed acNo (e.g. "0011"), not the resolved full account number
|
||||||
|
// (e.g. "000000011") — rowAccountIds/rowAccountNames above use the same key, and every
|
||||||
|
// lookup elsewhere (syncShareLinkedAmounts, submitShareRedemptionTransfer, the template's
|
||||||
|
// rowSharePrices[row.acNo]) reads via row.acNo, so this must match or every lookup misses.
|
||||||
|
this.rowSharePrices[this.selectedRow.acNo] = Number(acc.currentMarketPrice) || 0;
|
||||||
|
this.rowLinkedSavingsAcNo[this.selectedRow.acNo] = acc.savingsAccountNumber ?? '';
|
||||||
|
this.syncShareLinkedAmounts();
|
||||||
|
}
|
||||||
return true;
|
return true;
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -1913,18 +2179,22 @@ export class TransactionEntryComponent implements OnInit, OnDestroy {
|
|||||||
documents: this.http.get<any[]>(`/clients/${clientId}/documents`).pipe(catchError(() => of([]))),
|
documents: this.http.get<any[]>(`/clients/${clientId}/documents`).pipe(catchError(() => of([]))),
|
||||||
notes: this.http.get<any>(`/savings/${accountId}/notes`).pipe(catchError(() => of([]))),
|
notes: this.http.get<any>(`/savings/${accountId}/notes`).pipe(catchError(() => of([]))),
|
||||||
linkedAcc: this.http.get<any>(`/clients/${clientId}/accounts`).pipe(catchError(() => of(null))),
|
linkedAcc: this.http.get<any>(`/clients/${clientId}/accounts`).pipe(catchError(() => of(null))),
|
||||||
si: this.http
|
si: cfg.siFetchUnsupported
|
||||||
.get<any>(`/standinginstructions?fromAccountType=${cfg.siAccountType}&fromAccountId=${accountId}&limit=10`)
|
? of({ pageItems: [] })
|
||||||
.pipe(catchError(() => of({ pageItems: [] }))),
|
|
||||||
todayTxns: cfg.txnSearchSupported
|
|
||||||
? this.http
|
|
||||||
.get<any>(
|
|
||||||
`${cfg.txnBase}/${accountId}/transactions/search?fromDate=${todayStr}&toDate=${todayStr}&limit=20`
|
|
||||||
)
|
|
||||||
.pipe(catchError(() => of({ pageItems: [] })))
|
|
||||||
: this.http
|
: this.http
|
||||||
.get<any>(`${cfg.txnBase}/${accountId}/transactions?limit=50`)
|
.get<any>(`/standinginstructions?fromAccountType=${cfg.siAccountType}&fromAccountId=${accountId}&limit=10`)
|
||||||
.pipe(catchError(() => of({ pageItems: [] })))
|
.pipe(catchError(() => of({ pageItems: [] }))),
|
||||||
|
todayTxns: cfg.txnFetchUnsupported
|
||||||
|
? of({ pageItems: [] })
|
||||||
|
: cfg.txnSearchSupported
|
||||||
|
? this.http
|
||||||
|
.get<any>(
|
||||||
|
`${cfg.txnBase}/${accountId}/transactions/search?fromDate=${todayStr}&toDate=${todayStr}&limit=20`
|
||||||
|
)
|
||||||
|
.pipe(catchError(() => of({ pageItems: [] })))
|
||||||
|
: this.http
|
||||||
|
.get<any>(`${cfg.txnBase}/${accountId}/transactions?limit=50`)
|
||||||
|
.pipe(catchError(() => of({ pageItems: [] })))
|
||||||
});
|
});
|
||||||
|
|
||||||
if (clientIdHint) {
|
if (clientIdHint) {
|
||||||
@@ -2089,6 +2359,11 @@ export class TransactionEntryComponent implements OnInit, OnDestroy {
|
|||||||
// ── Balance getters ────────────────────────────────────────────────────────
|
// ── Balance getters ────────────────────────────────────────────────────────
|
||||||
|
|
||||||
get currentBalance(): number {
|
get currentBalance(): number {
|
||||||
|
if (this.selectedRow?.acTyp === 'SHR') {
|
||||||
|
const shares = Number(this.accountDetails?.summary?.totalApprovedShares ?? 0);
|
||||||
|
const unitPrice = Number(this.accountDetails?.currentMarketPrice ?? 0);
|
||||||
|
return shares * unitPrice;
|
||||||
|
}
|
||||||
return this.accountDetails?.summary?.accountBalance ?? this.accountDetails?.organizationRunningBalance ?? 0;
|
return this.accountDetails?.summary?.accountBalance ?? this.accountDetails?.organizationRunningBalance ?? 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -2502,6 +2777,23 @@ export class TransactionEntryComponent implements OnInit, OnDestroy {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Share redemption via linked-account transfer: DR=Share account, CR=its linked savings
|
||||||
|
// account. A Share account can never be debited by a plain ledger transfer (no such Fineract
|
||||||
|
// command exists) — so this exact one-DR/one-CR shape is routed to submitShareRedemptionTransfer()
|
||||||
|
// instead, which submits a governed Share Withdrawal Request through the same maker-checker
|
||||||
|
// table as the dedicated screen; approval both redeems the shares and settles the linked account.
|
||||||
|
const shareDrRows = drRows.filter((r) => r.acTyp === 'SHR');
|
||||||
|
if (shareDrRows.length > 0) {
|
||||||
|
if (drRows.length !== 1 || crRows.length !== 1) {
|
||||||
|
this.showAlert('error', 'Unsupported Share Transfer', [
|
||||||
|
'A Share redemption transfer supports exactly one DR row (the Share account) and one CR row (its linked savings account).'
|
||||||
|
]);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
this.submitShareRedemptionTransfer(shareDrRows[0], crRows[0]);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
// Bank transfer: only savings (SD) or current (CA/CB) accounts allowed
|
// Bank transfer: only savings (SD) or current (CA/CB) accounts allowed
|
||||||
const allowedTypes = new Set([
|
const allowedTypes = new Set([
|
||||||
'SD',
|
'SD',
|
||||||
@@ -3004,6 +3296,24 @@ export class TransactionEntryComponent implements OnInit, OnDestroy {
|
|||||||
const accountId = this.accountDetails.id;
|
const accountId = this.accountDetails.id;
|
||||||
const cfg = this.accountTypeConfig(row.acTyp);
|
const cfg = this.accountTypeConfig(row.acTyp);
|
||||||
|
|
||||||
|
// Share accounts must never go through this generic Deposit/Withdrawal Slip flow — both
|
||||||
|
// buildDepositPayload() and buildWithdrawalPayload() hardcode accountType: 'SAVINGS' and send
|
||||||
|
// savingsAccountId: this.accountDetails.id, which for a Share account is the SHARE account's
|
||||||
|
// own numeric id, not a real savings account id. Approving that request would call
|
||||||
|
// savingsAccountWithdrawal/Deposit against whatever savings account happens to share that same
|
||||||
|
// numeric id — a completely unrelated account. Share purchase/redemption must go through the
|
||||||
|
// dedicated Buy Shares / Share Withdrawal Request screens, which carry the correct governance
|
||||||
|
// (resolution number, meeting date, minimum-tenure and linked-loan checks).
|
||||||
|
if (row.acTyp === 'SHR') {
|
||||||
|
this.showAlert('error', 'Not Supported for Share Accounts', [
|
||||||
|
isDeposit
|
||||||
|
? 'Share purchases must be made from the Buy Shares screen on the Share Account itself.'
|
||||||
|
: 'Share withdrawal (redeem shares) must be submitted from "New Share Withdrawal Request" in the Clerk Dashboard.',
|
||||||
|
'This generic Deposit/Withdrawal Slip does not carry the Board Resolution Number or the governance checks required for shares.'
|
||||||
|
]);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
// GL withdrawal → direct journal entry (no maker-checker)
|
// GL withdrawal → direct journal entry (no maker-checker)
|
||||||
// GL deposit → falls through to maker-checker block below
|
// GL deposit → falls through to maker-checker block below
|
||||||
if (row.acTyp === 'GL' && !isDeposit) {
|
if (row.acTyp === 'GL' && !isDeposit) {
|
||||||
|
|||||||
@@ -0,0 +1,77 @@
|
|||||||
|
/**
|
||||||
|
* 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/.
|
||||||
|
*/
|
||||||
|
|
||||||
|
/* Small shared layout/status-pill classes for the Share Withdrawal maker-checker screens —
|
||||||
|
* self-contained (this branch has no separate "investment" module to borrow them from). */
|
||||||
|
|
||||||
|
.inv-form-grid {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(auto-fit, minmax(180px, 1fr));
|
||||||
|
gap: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.inv-form-field {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 4px;
|
||||||
|
font-size: 11px;
|
||||||
|
font-weight: 600;
|
||||||
|
color: #64748b;
|
||||||
|
|
||||||
|
input,
|
||||||
|
select,
|
||||||
|
textarea {
|
||||||
|
height: 36px;
|
||||||
|
padding: 0 10px;
|
||||||
|
border: 1px solid #e2e8f0;
|
||||||
|
border-radius: 8px;
|
||||||
|
font-size: 13px;
|
||||||
|
color: #0d1b2a;
|
||||||
|
font-family: inherit;
|
||||||
|
background: #fff;
|
||||||
|
box-sizing: border-box;
|
||||||
|
}
|
||||||
|
|
||||||
|
textarea {
|
||||||
|
height: auto;
|
||||||
|
padding: 8px 10px;
|
||||||
|
resize: vertical;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.inv-form-actions {
|
||||||
|
display: flex;
|
||||||
|
gap: 10px;
|
||||||
|
padding: 12px 16px 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.inv-badge {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
padding: 3px 10px;
|
||||||
|
border-radius: 999px;
|
||||||
|
font-size: 11px;
|
||||||
|
font-weight: 700;
|
||||||
|
background: #f1f5f9;
|
||||||
|
color: #475569;
|
||||||
|
|
||||||
|
&--active {
|
||||||
|
background: #fef3c7;
|
||||||
|
color: #92400e;
|
||||||
|
}
|
||||||
|
|
||||||
|
&--matured {
|
||||||
|
background: #dcfce7;
|
||||||
|
color: #166534;
|
||||||
|
}
|
||||||
|
|
||||||
|
&--written_off {
|
||||||
|
background: #fee2e2;
|
||||||
|
color: #991b1b;
|
||||||
|
}
|
||||||
|
}
|
||||||
+73
@@ -0,0 +1,73 @@
|
|||||||
|
<!--
|
||||||
|
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">CLERK · SHARE WITHDRAWAL</div>
|
||||||
|
<h1 class="pg-title">My Share Requests</h1>
|
||||||
|
<p class="pg-sub">Share withdrawal requests you have submitted, and their approval status.</p>
|
||||||
|
</div>
|
||||||
|
<div class="pg-toolbar">
|
||||||
|
<button type="button" class="pg-btn pg-btn--primary" (click)="newRequest()">New Share Withdrawal</button>
|
||||||
|
</div>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<div class="pg-card">
|
||||||
|
<div class="inv-form-grid" style="padding: 12px 16px">
|
||||||
|
<label class="inv-form-field">
|
||||||
|
<span>Status</span>
|
||||||
|
<select [(ngModel)]="filterStatus" (change)="onFilterChange()">
|
||||||
|
@for (opt of statusOptions; track opt.value) {
|
||||||
|
<option [value]="opt.value">{{ opt.label }}</option>
|
||||||
|
}
|
||||||
|
</select>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
<table class="pg-table">
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th>Account No</th>
|
||||||
|
<th>Client</th>
|
||||||
|
<th class="num">Shares</th>
|
||||||
|
<th>Resolution No.</th>
|
||||||
|
<th>Meeting Date</th>
|
||||||
|
<th>Submitted At</th>
|
||||||
|
<th>Status</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
@if (loading) {
|
||||||
|
<tr>
|
||||||
|
<td colspan="7" class="pg-empty">Loading…</td>
|
||||||
|
</tr>
|
||||||
|
} @else if (loaded && requests.length === 0) {
|
||||||
|
<tr>
|
||||||
|
<td colspan="7" class="pg-empty">No share withdrawal requests found.</td>
|
||||||
|
</tr>
|
||||||
|
} @else {
|
||||||
|
@for (r of requests; track r.id) {
|
||||||
|
<tr>
|
||||||
|
<td>
|
||||||
|
<span class="pg-chip">{{ r.account_no }}</span>
|
||||||
|
</td>
|
||||||
|
<td>{{ r.client_name }}</td>
|
||||||
|
<td class="num">{{ r.requested_shares }}</td>
|
||||||
|
<td>{{ r.resolution_number }}</td>
|
||||||
|
<td>{{ formatDateTime(r.meeting_date) }}</td>
|
||||||
|
<td>{{ formatDateTime(r.submitted_at) }}</td>
|
||||||
|
<td>
|
||||||
|
<span [class]="statusClass(r.status)">{{ statusLabel(r.status) }}</span>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
@@ -0,0 +1,112 @@
|
|||||||
|
/**
|
||||||
|
* Copyright since 2025 Mifos Initiative
|
||||||
|
*
|
||||||
|
* This Source Code Form is subject to the terms of the Mozilla Public
|
||||||
|
* License, v. 2.0. If a copy of the MPL was not distributed with this
|
||||||
|
* file, You can obtain one at http://mozilla.org/MPL/2.0/.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { Component, OnInit, inject } from '@angular/core';
|
||||||
|
import { CommonModule } from '@angular/common';
|
||||||
|
import { FormsModule } from '@angular/forms';
|
||||||
|
import { Router, RouterModule } from '@angular/router';
|
||||||
|
import { catchError } from 'rxjs/operators';
|
||||||
|
import { of } from 'rxjs';
|
||||||
|
|
||||||
|
import { ClerkShareWithdrawalService } from '../../cashier/clerk-share-withdrawal.service';
|
||||||
|
|
||||||
|
/** Clerk Dashboard "My Share Requests" — lists the share withdrawal requests this clerk has
|
||||||
|
* submitted and their approval status. Mirrors MyRequestsComponent/MyDisbursementRequestsComponent. */
|
||||||
|
@Component({
|
||||||
|
selector: 'mifosx-my-share-withdrawal-requests',
|
||||||
|
templateUrl: './my-share-withdrawal-requests.component.html',
|
||||||
|
styleUrls: [
|
||||||
|
'../../cashier/cashier-shared.scss',
|
||||||
|
'../clerk-shared.scss'
|
||||||
|
],
|
||||||
|
standalone: true,
|
||||||
|
imports: [
|
||||||
|
CommonModule,
|
||||||
|
FormsModule,
|
||||||
|
RouterModule
|
||||||
|
]
|
||||||
|
})
|
||||||
|
export class MyShareWithdrawalRequestsComponent implements OnInit {
|
||||||
|
private service = inject(ClerkShareWithdrawalService);
|
||||||
|
private router = inject(Router);
|
||||||
|
|
||||||
|
requests: any[] = [];
|
||||||
|
totalRecords = 0;
|
||||||
|
loading = false;
|
||||||
|
loaded = false;
|
||||||
|
|
||||||
|
filterStatus = '';
|
||||||
|
|
||||||
|
get statusOptions(): { value: string; label: string }[] {
|
||||||
|
return [
|
||||||
|
{ value: '', label: 'All' },
|
||||||
|
{ value: 'PENDING', label: 'Pending' },
|
||||||
|
{ value: 'APPROVED', label: 'Approved' },
|
||||||
|
{ value: 'REJECTED', label: 'Rejected' }
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
ngOnInit(): void {
|
||||||
|
this.load();
|
||||||
|
}
|
||||||
|
|
||||||
|
load(): void {
|
||||||
|
this.loading = true;
|
||||||
|
this.service
|
||||||
|
.getMyRequests(this.filterStatus || undefined, 0, 50)
|
||||||
|
.pipe(catchError(() => of({ pageItems: [], totalFilteredRecords: 0 })))
|
||||||
|
.subscribe((res: any) => {
|
||||||
|
this.requests = res?.pageItems ?? [];
|
||||||
|
this.totalRecords = Number(res?.totalFilteredRecords ?? 0);
|
||||||
|
this.loading = false;
|
||||||
|
this.loaded = true;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
onFilterChange(): void {
|
||||||
|
this.load();
|
||||||
|
}
|
||||||
|
|
||||||
|
newRequest(): void {
|
||||||
|
this.router.navigate(['/clerk/share-withdrawal-request']);
|
||||||
|
}
|
||||||
|
|
||||||
|
statusClass(status: string): string {
|
||||||
|
const map: Record<string, string> = {
|
||||||
|
PENDING: 'inv-badge--active',
|
||||||
|
APPROVED: 'inv-badge--matured',
|
||||||
|
REJECTED: 'inv-badge--written_off'
|
||||||
|
};
|
||||||
|
return `inv-badge ${map[status] ?? ''}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
statusLabel(status: string): string {
|
||||||
|
const map: Record<string, string> = {
|
||||||
|
PENDING: 'Pending Approval',
|
||||||
|
APPROVED: 'Approved',
|
||||||
|
REJECTED: 'Rejected'
|
||||||
|
};
|
||||||
|
return map[status] ?? status;
|
||||||
|
}
|
||||||
|
|
||||||
|
formatDateTime(val: any): string {
|
||||||
|
if (!val) return '—';
|
||||||
|
try {
|
||||||
|
const d = new Date(val);
|
||||||
|
return d.toLocaleString('en-IN', {
|
||||||
|
day: '2-digit',
|
||||||
|
month: 'short',
|
||||||
|
year: 'numeric',
|
||||||
|
hour: '2-digit',
|
||||||
|
minute: '2-digit'
|
||||||
|
});
|
||||||
|
} catch {
|
||||||
|
return String(val);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,393 @@
|
|||||||
|
<!--
|
||||||
|
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">{{ isReview ? 'CHECKER · APPROVAL' : 'CLERK · SHARE WITHDRAWAL' }}</div>
|
||||||
|
<h1 class="pg-title">{{ pageTitle }}</h1>
|
||||||
|
<p class="pg-sub">{{ pageSubtitle }}</p>
|
||||||
|
</div>
|
||||||
|
<div class="pg-toolbar">
|
||||||
|
<button type="button" class="pg-btn" (click)="goBack()">
|
||||||
|
<mat-icon>arrow_back</mat-icon>
|
||||||
|
{{ isReview ? 'Back to Requests' : 'My Share Requests' }}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<div class="pg-card">
|
||||||
|
<div class="pos-section-label">Share Account</div>
|
||||||
|
<div class="inv-form-grid" style="padding: 16px">
|
||||||
|
@if (!isReview) {
|
||||||
|
<label class="inv-form-field" style="grid-column: span 2">
|
||||||
|
<span>* Search Account No. / Client Name</span>
|
||||||
|
<mifosx-search-select
|
||||||
|
[options]="accountOptions"
|
||||||
|
[(ngModel)]="selectedAccountId"
|
||||||
|
(ngModelChange)="onAccountSelected($event)"
|
||||||
|
placeholder="Search share account no. or client name…"
|
||||||
|
></mifosx-search-select>
|
||||||
|
</label>
|
||||||
|
}
|
||||||
|
@if (account) {
|
||||||
|
<label class="inv-form-field">
|
||||||
|
<span>Account No</span>
|
||||||
|
<input type="text" [value]="account.accountNo" readonly />
|
||||||
|
</label>
|
||||||
|
<label class="inv-form-field">
|
||||||
|
<span>Client</span>
|
||||||
|
<input type="text" [value]="account.clientName" readonly />
|
||||||
|
</label>
|
||||||
|
<label class="inv-form-field">
|
||||||
|
<span>Shares Held</span>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
[value]="account.summary?.totalApprovedShares ?? account.totalApprovedShares ?? 0"
|
||||||
|
readonly
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
<label class="inv-form-field">
|
||||||
|
<span>Unit Price</span>
|
||||||
|
<input type="text" [value]="'₹' + (unitPrice | number: '1.2-2')" readonly />
|
||||||
|
</label>
|
||||||
|
}
|
||||||
|
@if (loadingAccount) {
|
||||||
|
<div class="pg-empty">Loading account…</div>
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
@if (account) {
|
||||||
|
<div class="pg-card">
|
||||||
|
<div class="pos-section-label">Withdrawal Details</div>
|
||||||
|
<div class="inv-form-grid" style="padding: 16px">
|
||||||
|
<label class="inv-form-field">
|
||||||
|
<span>* Shares to Withdraw</span>
|
||||||
|
<input type="number" [(ngModel)]="requestedShares" min="1" [disabled]="isReview" />
|
||||||
|
</label>
|
||||||
|
<label class="inv-form-field">
|
||||||
|
<span>* Requested Date</span>
|
||||||
|
<input type="date" [(ngModel)]="requestedDate" [disabled]="isReview" />
|
||||||
|
</label>
|
||||||
|
<label class="inv-form-field">
|
||||||
|
<span>Withdrawal Value</span>
|
||||||
|
<input type="text" [value]="'₹' + (withdrawalValue | number: '1.2-2')" readonly />
|
||||||
|
</label>
|
||||||
|
<label class="inv-form-field">
|
||||||
|
<span>Remaining Shares After Withdrawal</span>
|
||||||
|
<input type="text" [value]="remainingShares" readonly />
|
||||||
|
</label>
|
||||||
|
<label class="inv-form-field">
|
||||||
|
<span>* Board Resolution Number</span>
|
||||||
|
<input type="text" [(ngModel)]="resolutionNumber" [disabled]="isReview" placeholder="e.g. BR-2026-014" />
|
||||||
|
</label>
|
||||||
|
<label class="inv-form-field">
|
||||||
|
<span>* Date of Meeting</span>
|
||||||
|
<input type="date" [(ngModel)]="meetingDate" [disabled]="isReview" />
|
||||||
|
</label>
|
||||||
|
<label class="inv-form-field">
|
||||||
|
<span>* Settlement Payment Type</span>
|
||||||
|
@if (!isReview) {
|
||||||
|
<select [(ngModel)]="settlementPaymentTypeId">
|
||||||
|
<option [ngValue]="null">Select…</option>
|
||||||
|
@for (pt of paymentTypeOptions; track pt.id) {
|
||||||
|
<option [ngValue]="pt.id">{{ pt.name }}</option>
|
||||||
|
}
|
||||||
|
</select>
|
||||||
|
} @else {
|
||||||
|
<input type="text" [value]="settlementPaymentTypeName" readonly />
|
||||||
|
}
|
||||||
|
</label>
|
||||||
|
<label class="inv-form-field" style="grid-column: span 2">
|
||||||
|
<span>Description / Remarks</span>
|
||||||
|
<input type="text" [(ngModel)]="description" [disabled]="isReview" />
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
@if (!isReview) {
|
||||||
|
<div class="inv-form-actions">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="pg-btn pg-btn--primary"
|
||||||
|
[disabled]="!isFormValid || submitting"
|
||||||
|
(click)="openConfirmDialog()"
|
||||||
|
>
|
||||||
|
{{ submitting ? 'Submitting…' : 'Review & Submit' }}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
|
||||||
|
@if (isReview && reviewRequest?.status === 'PENDING') {
|
||||||
|
<div class="pg-card">
|
||||||
|
<div class="pos-section-label">Verification</div>
|
||||||
|
<div class="swrv-trigger">
|
||||||
|
<div class="swrv-trigger__icon">
|
||||||
|
<mat-icon>fact_check</mat-icon>
|
||||||
|
</div>
|
||||||
|
<div class="swrv-trigger__text">
|
||||||
|
<div class="swrv-trigger__title">Run a live backend validation</div>
|
||||||
|
<div class="swrv-trigger__sub">
|
||||||
|
Re-checks account status, share balance, linked savings account and GL mapping before you approve.
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<button type="button" class="pg-btn pg-btn--primary" (click)="openValidateModal()">
|
||||||
|
<mat-icon>play_circle</mat-icon>
|
||||||
|
Validate Request
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="inv-form-actions">
|
||||||
|
<button type="button" class="pg-btn pg-btn--primary" (click)="approveReview()">
|
||||||
|
<mat-icon>check_circle</mat-icon>
|
||||||
|
Approve & Process Withdrawal
|
||||||
|
</button>
|
||||||
|
<button type="button" class="pg-btn pg-btn--danger" (click)="openRejectDialog()">
|
||||||
|
<mat-icon>cancel</mat-icon>
|
||||||
|
Reject
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
|
||||||
|
@if (showValidateModal) {
|
||||||
|
<div class="swrv-modal">
|
||||||
|
<div class="swrv-modal__card">
|
||||||
|
<div class="swrv-modal__header">
|
||||||
|
<div class="swrv-modal__title">
|
||||||
|
<span class="swrv-modal__title-icon"><mat-icon>fact_check</mat-icon></span>
|
||||||
|
Validate Share Withdrawal Request
|
||||||
|
</div>
|
||||||
|
<button type="button" class="swrv-modal__close" [disabled]="validating" (click)="closeValidateModal()">
|
||||||
|
<mat-icon>close</mat-icon>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="swrv-modal__body">
|
||||||
|
<p class="swrv-modal__intro">
|
||||||
|
Re-checking <strong>{{ requestedShares }} share(s)</strong> withdrawal from
|
||||||
|
<strong>{{ account?.accountNo }}</strong> ({{ account?.clientName }}) against the live account, savings link
|
||||||
|
and GL configuration — not just what was captured at submission time.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
@if (backendChecks && !validating && !validateAllPassed) {
|
||||||
|
<div class="swrv-modal__warn">
|
||||||
|
<mat-icon>warning</mat-icon>
|
||||||
|
<div>
|
||||||
|
<strong>Some checks need review before approving.</strong>
|
||||||
|
Scroll down to see which ones failed and why.
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
|
||||||
|
<ol class="swrv-steps">
|
||||||
|
@for (step of validateSteps; track step.label; let i = $index) {
|
||||||
|
<li
|
||||||
|
class="swrv-step"
|
||||||
|
[class.swrv-step--active]="step.status === 'active'"
|
||||||
|
[class.swrv-step--done]="step.status === 'done'"
|
||||||
|
[class.swrv-step--failed]="step.status === 'failed'"
|
||||||
|
>
|
||||||
|
<div class="swrv-step__indicator">
|
||||||
|
@if (step.status === 'done') {
|
||||||
|
<mat-icon>check</mat-icon>
|
||||||
|
} @else if (step.status === 'active') {
|
||||||
|
<mat-icon class="swrv-spin">autorenew</mat-icon>
|
||||||
|
} @else if (step.status === 'failed') {
|
||||||
|
<mat-icon>close</mat-icon>
|
||||||
|
} @else {
|
||||||
|
<span>{{ i + 1 }}</span>
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
<div class="swrv-step__content">
|
||||||
|
<div class="swrv-step__label">{{ step.label }}</div>
|
||||||
|
<div class="swrv-step__msg">{{ step.detail }}</div>
|
||||||
|
</div>
|
||||||
|
</li>
|
||||||
|
}
|
||||||
|
</ol>
|
||||||
|
|
||||||
|
@if (validating && validateSteps.length === 0) {
|
||||||
|
<div class="pg-empty">Connecting to server…</div>
|
||||||
|
}
|
||||||
|
|
||||||
|
@if (backendChecks && !validating && validateAllPassed) {
|
||||||
|
<div class="swrv-modal__result">
|
||||||
|
<mat-icon>verified</mat-icon>
|
||||||
|
<span>All checks passed — safe to approve.</span>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="swrv-modal__footer">
|
||||||
|
@if (!validating && backendChecks) {
|
||||||
|
<button type="button" class="pg-btn" (click)="runBackendValidation()">
|
||||||
|
<mat-icon>refresh</mat-icon>
|
||||||
|
Re-run
|
||||||
|
</button>
|
||||||
|
}
|
||||||
|
@if (!validating && backendChecks && validateAllPassed) {
|
||||||
|
<button type="button" class="pg-btn pg-btn--primary" (click)="closeValidateModal(); approveReview()">
|
||||||
|
<mat-icon>check_circle</mat-icon>
|
||||||
|
Approve & Process Withdrawal
|
||||||
|
</button>
|
||||||
|
}
|
||||||
|
<button type="button" class="pg-btn" [disabled]="validating" (click)="closeValidateModal()">
|
||||||
|
{{ validating ? 'Validating…' : 'Close' }}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
|
||||||
|
@if (isReview && reviewRequest?.status === 'APPROVED') {
|
||||||
|
<div class="pg-card">
|
||||||
|
<div class="pos-section-label">Settlement</div>
|
||||||
|
<div class="inv-form-grid" style="padding: 16px">
|
||||||
|
@if (reviewRequest?.settlement_amount) {
|
||||||
|
<label class="inv-form-field">
|
||||||
|
<span>Amount Credited</span>
|
||||||
|
<input type="text" [value]="'₹' + (reviewRequest.settlement_amount | number: '1.2-2')" readonly />
|
||||||
|
</label>
|
||||||
|
<label class="inv-form-field">
|
||||||
|
<span>Credited To (Linked Savings A/C)</span>
|
||||||
|
<input type="text" [value]="account?.savingsAccountNumber || '—'" readonly />
|
||||||
|
</label>
|
||||||
|
<label class="inv-form-field">
|
||||||
|
<span>Savings Transaction Ref.</span>
|
||||||
|
<input type="text" [value]="reviewRequest.settlement_savings_transaction_id" readonly />
|
||||||
|
</label>
|
||||||
|
} @else {
|
||||||
|
<div class="pg-empty" style="grid-column: span 2">
|
||||||
|
Settlement is processed automatically on approval. Details aren't available when reopening an
|
||||||
|
already-approved request — check the linked savings account's transaction history to confirm.
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
|
||||||
|
@if (isReview && activityLog.length > 0) {
|
||||||
|
<div class="pg-card">
|
||||||
|
<div class="pos-section-label">Activity Log</div>
|
||||||
|
<table class="pg-table">
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th>Timestamp</th>
|
||||||
|
<th>Action</th>
|
||||||
|
<th>User</th>
|
||||||
|
<th>Note</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
@for (entry of activityLog; track entry.timestamp) {
|
||||||
|
<tr>
|
||||||
|
<td>{{ entry.timestamp | date: 'medium' }}</td>
|
||||||
|
<td>{{ entry.action }}</td>
|
||||||
|
<td>{{ entry.user }}</td>
|
||||||
|
<td>{{ entry.note || '—' }}</td>
|
||||||
|
</tr>
|
||||||
|
}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
|
||||||
|
@if (showConfirmDialog) {
|
||||||
|
<div class="swr-modal-backdrop">
|
||||||
|
<div class="swr-modal">
|
||||||
|
<h3>Confirm Share Withdrawal Request</h3>
|
||||||
|
<p class="pg-sub">Review the details below before submitting for Bank Manager approval.</p>
|
||||||
|
<table class="pg-table">
|
||||||
|
<tbody>
|
||||||
|
<tr>
|
||||||
|
<td>Account No / Client</td>
|
||||||
|
<td>{{ account?.accountNo }} — {{ account?.clientName }}</td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<td>Shares to Withdraw</td>
|
||||||
|
<td>
|
||||||
|
{{ requestedShares }} @ ₹{{ unitPrice | number: '1.2-2' }} = ₹{{ withdrawalValue | number: '1.2-2' }}
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<td>Remaining Shares After Withdrawal</td>
|
||||||
|
<td>{{ remainingShares }}</td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<td>Requested Date</td>
|
||||||
|
<td>{{ requestedDate }}</td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<td>Board Resolution Number</td>
|
||||||
|
<td>{{ resolutionNumber }}</td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<td>Date of Meeting</td>
|
||||||
|
<td>{{ meetingDate }}</td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<td>Settlement Payment Type</td>
|
||||||
|
<td>{{ settlementPaymentTypeName }}</td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<td>Linked Savings Account</td>
|
||||||
|
<td>{{ account?.savingsAccountNumber || 'None — settlement will not run automatically' }}</td>
|
||||||
|
</tr>
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
|
||||||
|
<div class="pos-section-label" style="margin-top: 12px">Validation Checks</div>
|
||||||
|
<table class="pg-table">
|
||||||
|
<tbody>
|
||||||
|
@for (check of verificationChecks; track check.label) {
|
||||||
|
<tr>
|
||||||
|
<td>{{ check.label }}</td>
|
||||||
|
<td [class.swr-check-pass]="check.pass" [class.swr-check-fail]="!check.pass">
|
||||||
|
{{ check.pass ? 'Pass — ' : 'Review — ' }}{{ check.detail }}
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
|
||||||
|
<div class="inv-form-actions">
|
||||||
|
<button type="button" class="pg-btn pg-btn--primary" [disabled]="submitting" (click)="confirmSubmit()">
|
||||||
|
{{ submitting ? 'Submitting…' : 'Confirm & Submit' }}
|
||||||
|
</button>
|
||||||
|
<button type="button" class="pg-btn" (click)="cancelConfirmDialog()">Back to Edit</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
|
||||||
|
@if (showRejectDialog) {
|
||||||
|
<div class="swr-modal-backdrop">
|
||||||
|
<div class="swr-modal">
|
||||||
|
<h3>Reject Share Withdrawal Request</h3>
|
||||||
|
<label class="inv-form-field">
|
||||||
|
<span>* Rejection Reason</span>
|
||||||
|
<textarea rows="3" [(ngModel)]="rejectionReason"></textarea>
|
||||||
|
</label>
|
||||||
|
<div class="inv-form-actions">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="pg-btn pg-btn--danger"
|
||||||
|
[disabled]="!rejectionReason.trim()"
|
||||||
|
(click)="confirmRejectReview()"
|
||||||
|
>
|
||||||
|
Confirm Reject
|
||||||
|
</button>
|
||||||
|
<button type="button" class="pg-btn" (click)="showRejectDialog = false">Cancel</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
</div>
|
||||||
@@ -0,0 +1,410 @@
|
|||||||
|
/**
|
||||||
|
* 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/.
|
||||||
|
*/
|
||||||
|
|
||||||
|
.swr-modal-backdrop {
|
||||||
|
position: fixed;
|
||||||
|
inset: 0;
|
||||||
|
background: rgb(13 27 42 / 55%);
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
z-index: 1000;
|
||||||
|
}
|
||||||
|
|
||||||
|
.swr-modal {
|
||||||
|
background: #fff;
|
||||||
|
border-radius: 12px;
|
||||||
|
padding: 20px;
|
||||||
|
width: 420px;
|
||||||
|
max-width: 90vw;
|
||||||
|
max-height: 85vh;
|
||||||
|
overflow-y: auto;
|
||||||
|
box-shadow: 0 12px 32px rgb(15 23 42 / 20%);
|
||||||
|
|
||||||
|
h3 {
|
||||||
|
margin: 0 0 12px;
|
||||||
|
font-size: 16px;
|
||||||
|
color: #0d1b2a;
|
||||||
|
}
|
||||||
|
|
||||||
|
textarea {
|
||||||
|
width: 100%;
|
||||||
|
padding: 8px 10px;
|
||||||
|
border: 1px solid #e2e8f0;
|
||||||
|
border-radius: 8px;
|
||||||
|
font-family: inherit;
|
||||||
|
font-size: 13px;
|
||||||
|
resize: vertical;
|
||||||
|
box-sizing: border-box;
|
||||||
|
}
|
||||||
|
|
||||||
|
&:has(table) {
|
||||||
|
width: 560px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.swr-check-pass {
|
||||||
|
color: #1a7f37;
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
|
||||||
|
.swr-check-fail {
|
||||||
|
color: #b42318;
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ---------- Verification trigger card ---------- */
|
||||||
|
.swrv-trigger {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 14px;
|
||||||
|
padding: 16px;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
|
||||||
|
&__icon {
|
||||||
|
width: 42px;
|
||||||
|
height: 42px;
|
||||||
|
border-radius: 12px;
|
||||||
|
background: linear-gradient(135deg, #3457d5, #6c8cff);
|
||||||
|
color: #fff;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
flex-shrink: 0;
|
||||||
|
box-shadow: 0 4px 12px rgb(52 87 213 / 30%);
|
||||||
|
}
|
||||||
|
|
||||||
|
&__text {
|
||||||
|
flex: 1;
|
||||||
|
min-width: 200px;
|
||||||
|
}
|
||||||
|
|
||||||
|
&__title {
|
||||||
|
font-size: 13px;
|
||||||
|
font-weight: 700;
|
||||||
|
color: #0d1b2a;
|
||||||
|
}
|
||||||
|
|
||||||
|
&__sub {
|
||||||
|
font-size: 12px;
|
||||||
|
color: #64748b;
|
||||||
|
margin-top: 2px;
|
||||||
|
line-height: 1.4;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ---------- Validate pipeline modal ---------- */
|
||||||
|
@keyframes swrv-fade {
|
||||||
|
from {
|
||||||
|
opacity: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
to {
|
||||||
|
opacity: 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes swrv-zoom {
|
||||||
|
from {
|
||||||
|
opacity: 0;
|
||||||
|
transform: scale(0.95) translateY(8px);
|
||||||
|
}
|
||||||
|
|
||||||
|
to {
|
||||||
|
opacity: 1;
|
||||||
|
transform: scale(1) translateY(0);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes swrv-spin {
|
||||||
|
from {
|
||||||
|
transform: rotate(0deg);
|
||||||
|
}
|
||||||
|
|
||||||
|
to {
|
||||||
|
transform: rotate(360deg);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.swrv-spin {
|
||||||
|
animation: swrv-spin 0.9s linear infinite;
|
||||||
|
}
|
||||||
|
|
||||||
|
.swrv-modal {
|
||||||
|
position: fixed;
|
||||||
|
inset: 0;
|
||||||
|
background: rgb(15 25 45 / 55%);
|
||||||
|
backdrop-filter: blur(3px);
|
||||||
|
z-index: 1200;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
padding: 20px;
|
||||||
|
animation: swrv-fade 0.2s ease-out;
|
||||||
|
|
||||||
|
&__card {
|
||||||
|
background: #fff;
|
||||||
|
border-radius: 16px;
|
||||||
|
width: 100%;
|
||||||
|
max-width: 520px;
|
||||||
|
max-height: 90vh;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
overflow: hidden;
|
||||||
|
box-shadow: 0 24px 60px rgb(0 0 0 / 25%);
|
||||||
|
animation: swrv-zoom 0.25s ease-out;
|
||||||
|
}
|
||||||
|
|
||||||
|
&__header {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
padding: 18px 22px;
|
||||||
|
border-bottom: 1px solid #eef1f5;
|
||||||
|
}
|
||||||
|
|
||||||
|
&__title {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 10px;
|
||||||
|
font-size: 15px;
|
||||||
|
font-weight: 700;
|
||||||
|
color: #0d1b2a;
|
||||||
|
}
|
||||||
|
|
||||||
|
&__title-icon {
|
||||||
|
width: 34px;
|
||||||
|
height: 34px;
|
||||||
|
border-radius: 10px;
|
||||||
|
background: linear-gradient(135deg, #3457d5, #6c8cff);
|
||||||
|
color: #fff;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
&__close {
|
||||||
|
width: 32px;
|
||||||
|
height: 32px;
|
||||||
|
border-radius: 8px;
|
||||||
|
border: 1px solid #e2e8f0;
|
||||||
|
background: #fff;
|
||||||
|
color: #64748b;
|
||||||
|
cursor: pointer;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
transition: all 0.12s;
|
||||||
|
|
||||||
|
&:not(:disabled):hover {
|
||||||
|
color: #b42318;
|
||||||
|
border-color: #b42318;
|
||||||
|
background: #fdecea;
|
||||||
|
}
|
||||||
|
|
||||||
|
&:disabled {
|
||||||
|
opacity: 0.4;
|
||||||
|
cursor: not-allowed;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
&__body {
|
||||||
|
padding: 20px 22px;
|
||||||
|
overflow-y: auto;
|
||||||
|
flex: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
&__intro {
|
||||||
|
font-size: 13px;
|
||||||
|
color: #475569;
|
||||||
|
line-height: 1.55;
|
||||||
|
margin: 0 0 16px;
|
||||||
|
|
||||||
|
strong {
|
||||||
|
color: #0d1b2a;
|
||||||
|
font-weight: 700;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
&__warn {
|
||||||
|
display: flex;
|
||||||
|
gap: 10px;
|
||||||
|
padding: 12px 14px;
|
||||||
|
background: #fef3e2;
|
||||||
|
border: 1px solid rgb(217 119 6 / 30%);
|
||||||
|
border-left: 3px solid #d97706;
|
||||||
|
border-radius: 8px;
|
||||||
|
margin-bottom: 16px;
|
||||||
|
font-size: 12px;
|
||||||
|
color: #633806;
|
||||||
|
line-height: 1.5;
|
||||||
|
|
||||||
|
mat-icon {
|
||||||
|
color: #d97706;
|
||||||
|
font-size: 18px;
|
||||||
|
width: 18px;
|
||||||
|
height: 18px;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
strong {
|
||||||
|
display: block;
|
||||||
|
font-weight: 700;
|
||||||
|
color: #4d2a05;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
&__result {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
margin-top: 6px;
|
||||||
|
padding: 12px 14px;
|
||||||
|
background: #eafaf0;
|
||||||
|
border: 1px solid rgb(26 127 55 / 25%);
|
||||||
|
border-radius: 8px;
|
||||||
|
font-size: 13px;
|
||||||
|
font-weight: 600;
|
||||||
|
color: #1a7f37;
|
||||||
|
|
||||||
|
mat-icon {
|
||||||
|
font-size: 18px;
|
||||||
|
width: 18px;
|
||||||
|
height: 18px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
&__footer {
|
||||||
|
padding: 16px 22px;
|
||||||
|
border-top: 1px solid #eef1f5;
|
||||||
|
display: flex;
|
||||||
|
justify-content: flex-end;
|
||||||
|
gap: 10px;
|
||||||
|
background: #f8fafc;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.swrv-steps {
|
||||||
|
list-style: none;
|
||||||
|
margin: 0;
|
||||||
|
padding: 0;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
}
|
||||||
|
|
||||||
|
.swrv-step {
|
||||||
|
display: flex;
|
||||||
|
gap: 14px;
|
||||||
|
padding: 10px 0;
|
||||||
|
position: relative;
|
||||||
|
|
||||||
|
&:not(:last-child)::after {
|
||||||
|
content: '';
|
||||||
|
position: absolute;
|
||||||
|
left: 15px;
|
||||||
|
top: 38px;
|
||||||
|
bottom: -4px;
|
||||||
|
width: 2px;
|
||||||
|
background: #e2e8f0;
|
||||||
|
}
|
||||||
|
|
||||||
|
&__indicator {
|
||||||
|
width: 32px;
|
||||||
|
height: 32px;
|
||||||
|
border-radius: 50%;
|
||||||
|
background: #fff;
|
||||||
|
border: 2px solid #e2e8f0;
|
||||||
|
color: #64748b;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
font-size: 12px;
|
||||||
|
font-weight: 700;
|
||||||
|
flex-shrink: 0;
|
||||||
|
position: relative;
|
||||||
|
z-index: 1;
|
||||||
|
transition: all 0.25s;
|
||||||
|
|
||||||
|
mat-icon {
|
||||||
|
font-size: 16px;
|
||||||
|
width: 16px;
|
||||||
|
height: 16px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
&__content {
|
||||||
|
padding-top: 5px;
|
||||||
|
flex: 1;
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
&__label {
|
||||||
|
font-size: 13px;
|
||||||
|
font-weight: 600;
|
||||||
|
color: #475569;
|
||||||
|
}
|
||||||
|
|
||||||
|
&__msg {
|
||||||
|
font-size: 11px;
|
||||||
|
color: #94a3b8;
|
||||||
|
margin-top: 2px;
|
||||||
|
}
|
||||||
|
|
||||||
|
&--active {
|
||||||
|
.swrv-step__indicator {
|
||||||
|
background: #3457d5;
|
||||||
|
border-color: #3457d5;
|
||||||
|
color: #fff;
|
||||||
|
box-shadow: 0 0 0 4px rgb(52 87 213 / 15%);
|
||||||
|
}
|
||||||
|
|
||||||
|
.swrv-step__label {
|
||||||
|
color: #3457d5;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
&--done {
|
||||||
|
.swrv-step__indicator {
|
||||||
|
background: #1a7f37;
|
||||||
|
border-color: #1a7f37;
|
||||||
|
color: #fff;
|
||||||
|
}
|
||||||
|
|
||||||
|
&::after {
|
||||||
|
background: #1a7f37;
|
||||||
|
}
|
||||||
|
|
||||||
|
.swrv-step__label {
|
||||||
|
color: #0d1b2a;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
&--failed {
|
||||||
|
.swrv-step__indicator {
|
||||||
|
background: #b42318;
|
||||||
|
border-color: #b42318;
|
||||||
|
color: #fff;
|
||||||
|
}
|
||||||
|
|
||||||
|
.swrv-step__label {
|
||||||
|
color: #b42318;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (prefers-reduced-motion: reduce) {
|
||||||
|
.swrv-spin {
|
||||||
|
animation: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.swrv-modal,
|
||||||
|
.swrv-modal__card {
|
||||||
|
animation: none;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,413 @@
|
|||||||
|
/**
|
||||||
|
* 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, OnInit, Output, inject } from '@angular/core';
|
||||||
|
import { CommonModule } from '@angular/common';
|
||||||
|
import { FormsModule } from '@angular/forms';
|
||||||
|
import { ActivatedRoute, Router, RouterModule } from '@angular/router';
|
||||||
|
import { MatIconModule } from '@angular/material/icon';
|
||||||
|
import { MatSnackBar, MatSnackBarModule } from '@angular/material/snack-bar';
|
||||||
|
import { catchError } from 'rxjs/operators';
|
||||||
|
import { of } from 'rxjs';
|
||||||
|
|
||||||
|
import { SharesService } from '../../shares/shares.service';
|
||||||
|
import { ClerkShareWithdrawalService } from '../../cashier/clerk-share-withdrawal.service';
|
||||||
|
import { CashierService } from '../../cashier/cashier.service';
|
||||||
|
import { SearchSelectComponent, SearchSelectOption } from '../../shared/search-select/search-select.component';
|
||||||
|
|
||||||
|
export type ShareWdMode = 'create' | 'review';
|
||||||
|
|
||||||
|
interface ActivityEntry {
|
||||||
|
timestamp: string;
|
||||||
|
action: string;
|
||||||
|
user: string;
|
||||||
|
note?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Share Withdrawal (redeem shares) request — maker screen in the Clerk Dashboard. A clerk finds
|
||||||
|
* the member's Share Account, enters how many shares to withdraw plus the governing Board
|
||||||
|
* Resolution Number / Date of Meeting, and submits for Bank Manager approval. This is the ONLY
|
||||||
|
* entry point a share withdrawal should go through — approval server-side executes the real
|
||||||
|
* "redeemshares" command (see ClerkShareWithdrawalRequestApiResource on the backend).
|
||||||
|
*/
|
||||||
|
@Component({
|
||||||
|
selector: 'mifosx-share-withdrawal-request',
|
||||||
|
templateUrl: './share-withdrawal-request.component.html',
|
||||||
|
styleUrls: [
|
||||||
|
'../../cashier/cashier-shared.scss',
|
||||||
|
'../clerk-shared.scss',
|
||||||
|
'./share-withdrawal-request.component.scss'
|
||||||
|
],
|
||||||
|
standalone: true,
|
||||||
|
imports: [
|
||||||
|
CommonModule,
|
||||||
|
FormsModule,
|
||||||
|
RouterModule,
|
||||||
|
MatIconModule,
|
||||||
|
MatSnackBarModule,
|
||||||
|
SearchSelectComponent
|
||||||
|
]
|
||||||
|
})
|
||||||
|
export class ShareWithdrawalRequestComponent implements OnInit {
|
||||||
|
private route = inject(ActivatedRoute);
|
||||||
|
private router = inject(Router);
|
||||||
|
private sharesService = inject(SharesService);
|
||||||
|
private wdService = inject(ClerkShareWithdrawalService);
|
||||||
|
private cashierService = inject(CashierService);
|
||||||
|
private snackBar = inject(MatSnackBar);
|
||||||
|
|
||||||
|
/** When embedded (e.g. opened as a popup from a list screen instead of routed to directly), mode
|
||||||
|
* and requestId come from these inputs rather than the route, and success/close actions emit
|
||||||
|
* events instead of navigating away. */
|
||||||
|
@Input() embedded = false;
|
||||||
|
@Input() overrideMode: ShareWdMode | null = null;
|
||||||
|
@Input() overrideRequestId: number | null = null;
|
||||||
|
@Output() closeRequested = new EventEmitter<void>();
|
||||||
|
@Output() actionCompleted = new EventEmitter<void>();
|
||||||
|
|
||||||
|
mode: ShareWdMode = 'create';
|
||||||
|
requestId: number | null = null;
|
||||||
|
|
||||||
|
submitting = false;
|
||||||
|
loadingAccount = false;
|
||||||
|
|
||||||
|
selectedAccountId = '';
|
||||||
|
accountOptions: SearchSelectOption[] = [];
|
||||||
|
|
||||||
|
account: any = null;
|
||||||
|
|
||||||
|
requestedShares: number | null = null;
|
||||||
|
requestedDate = '';
|
||||||
|
resolutionNumber = '';
|
||||||
|
meetingDate = '';
|
||||||
|
description = '';
|
||||||
|
|
||||||
|
paymentTypes: any[] = [];
|
||||||
|
settlementPaymentTypeId: number | null = null;
|
||||||
|
|
||||||
|
activityLog: ActivityEntry[] = [];
|
||||||
|
reviewRequest: any = null;
|
||||||
|
showRejectDialog = false;
|
||||||
|
rejectionReason = '';
|
||||||
|
showConfirmDialog = false;
|
||||||
|
|
||||||
|
showValidateModal = false;
|
||||||
|
validating = false;
|
||||||
|
validateAllPassed = false;
|
||||||
|
backendChecks: { label: string; pass: boolean; detail: string }[] | null = null;
|
||||||
|
validateSteps: { label: string; detail: string; pass: boolean; status: 'pending' | 'active' | 'done' | 'failed' }[] =
|
||||||
|
[];
|
||||||
|
|
||||||
|
get remainingShares(): number {
|
||||||
|
const held = Number(this.account?.summary?.totalApprovedShares ?? this.account?.totalApprovedShares ?? 0);
|
||||||
|
return held - (this.requestedShares || 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
get unitPrice(): number {
|
||||||
|
return Number(this.account?.currentMarketPrice ?? 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
get withdrawalValue(): number {
|
||||||
|
return (this.requestedShares || 0) * this.unitPrice;
|
||||||
|
}
|
||||||
|
|
||||||
|
get paymentTypeOptions(): any[] {
|
||||||
|
return this.paymentTypes.filter((pt: any) => !pt.isCashPayment);
|
||||||
|
}
|
||||||
|
|
||||||
|
get selectedSettlementPaymentType(): any {
|
||||||
|
return this.paymentTypes.find((pt: any) => pt.id === this.settlementPaymentTypeId);
|
||||||
|
}
|
||||||
|
|
||||||
|
get settlementPaymentTypeName(): string {
|
||||||
|
if (this.isReview) {
|
||||||
|
return this.reviewRequest?.settlement_payment_type_name || '—';
|
||||||
|
}
|
||||||
|
return this.selectedSettlementPaymentType?.name || '—';
|
||||||
|
}
|
||||||
|
|
||||||
|
get isFormValid(): boolean {
|
||||||
|
if (!this.account) return false;
|
||||||
|
if (!this.requestedShares || this.requestedShares <= 0) return false;
|
||||||
|
const held = Number(this.account?.summary?.totalApprovedShares ?? this.account?.totalApprovedShares ?? 0);
|
||||||
|
if (this.requestedShares > held) return false;
|
||||||
|
if (!this.requestedDate) return false;
|
||||||
|
if (!this.resolutionNumber.trim()) return false;
|
||||||
|
if (!this.meetingDate) return false;
|
||||||
|
if (!this.settlementPaymentTypeId) return false;
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Shown to the clerk before submit and to the manager before approval, so both can verify the same checks. */
|
||||||
|
get verificationChecks(): { label: string; pass: boolean; detail: string }[] {
|
||||||
|
const held = Number(this.account?.summary?.totalApprovedShares ?? this.account?.totalApprovedShares ?? 0);
|
||||||
|
const shares = this.requestedShares || 0;
|
||||||
|
return [
|
||||||
|
{
|
||||||
|
label: 'Board Resolution Number provided',
|
||||||
|
pass: !!this.resolutionNumber?.trim(),
|
||||||
|
detail: this.resolutionNumber?.trim() || 'Missing'
|
||||||
|
},
|
||||||
|
{ label: 'Date of Meeting provided', pass: !!this.meetingDate, detail: this.meetingDate || 'Missing' },
|
||||||
|
{
|
||||||
|
label: 'Shares requested within shares held',
|
||||||
|
pass: shares > 0 && shares <= held,
|
||||||
|
detail: `${shares} of ${held} shares held`
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: 'Linked savings account confirmed',
|
||||||
|
pass: !!this.account?.savingsAccountNumber,
|
||||||
|
detail: this.account?.savingsAccountNumber || 'No linked savings account — settlement cannot run automatically'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: 'Settlement Payment Type selected (non-cash)',
|
||||||
|
pass: this.settlementPaymentTypeName !== '—',
|
||||||
|
detail: this.settlementPaymentTypeName
|
||||||
|
}
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
get isReview(): boolean {
|
||||||
|
return this.mode === 'review';
|
||||||
|
}
|
||||||
|
|
||||||
|
get pageTitle(): string {
|
||||||
|
return this.isReview ? 'Review Share Withdrawal Request' : 'New Share Withdrawal Request';
|
||||||
|
}
|
||||||
|
|
||||||
|
get pageSubtitle(): string {
|
||||||
|
return this.isReview
|
||||||
|
? "Approve or reject the clerk's share withdrawal request."
|
||||||
|
: 'Submit a share withdrawal (redeem shares) request for Bank Manager approval.';
|
||||||
|
}
|
||||||
|
|
||||||
|
ngOnInit(): void {
|
||||||
|
this.mode = this.overrideMode ?? (this.route.snapshot.data?.['mode'] as ShareWdMode) ?? 'create';
|
||||||
|
const idParam = this.overrideRequestId ?? this.route.snapshot.params?.['id'];
|
||||||
|
this.requestId = idParam ? Number(idParam) : null;
|
||||||
|
|
||||||
|
const today = new Date();
|
||||||
|
this.requestedDate = `${today.getFullYear()}-${String(today.getMonth() + 1).padStart(2, '0')}-${String(today.getDate()).padStart(2, '0')}`;
|
||||||
|
this.meetingDate = this.requestedDate;
|
||||||
|
|
||||||
|
this.sharesService
|
||||||
|
.getShareAccountSummaries()
|
||||||
|
.pipe(catchError(() => of([])))
|
||||||
|
.subscribe((rows: any[]) => {
|
||||||
|
this.accountOptions = (rows || [])
|
||||||
|
.filter((r) => (r.status?.value || '').toLowerCase() === 'active')
|
||||||
|
.map((r) => ({
|
||||||
|
value: String(r.id),
|
||||||
|
label: `${r.accountNo} — ${r.clientName} (${r.totalApprovedShares} shares)`,
|
||||||
|
searchText: `${r.accountNo} ${r.clientName}`
|
||||||
|
}));
|
||||||
|
});
|
||||||
|
|
||||||
|
this.cashierService
|
||||||
|
.getPaymentTypes()
|
||||||
|
.pipe(catchError(() => of([])))
|
||||||
|
.subscribe((types: any[]) => (this.paymentTypes = types ?? []));
|
||||||
|
|
||||||
|
if (this.requestId && this.mode === 'review') {
|
||||||
|
this.loadReviewRequest(this.requestId);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
onAccountSelected(accountId: string): void {
|
||||||
|
this.selectedAccountId = accountId;
|
||||||
|
if (!accountId) {
|
||||||
|
this.account = null;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
this.loadingAccount = true;
|
||||||
|
this.sharesService
|
||||||
|
.getSharesAccountData(accountId, false)
|
||||||
|
.pipe(catchError(() => of(null)))
|
||||||
|
.subscribe((acc: any) => {
|
||||||
|
this.account = acc;
|
||||||
|
this.loadingAccount = false;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
private loadReviewRequest(id: number): void {
|
||||||
|
this.wdService.getById(id).subscribe({
|
||||||
|
next: (req: any) => {
|
||||||
|
this.reviewRequest = req;
|
||||||
|
this.requestedShares = Number(req.requested_shares);
|
||||||
|
this.requestedDate = req.requested_date
|
||||||
|
? String(req.requested_date).split('T')[0].split(' ')[0]
|
||||||
|
: this.requestedDate;
|
||||||
|
this.resolutionNumber = req.resolution_number ?? '';
|
||||||
|
this.meetingDate = req.meeting_date ? String(req.meeting_date).split('T')[0].split(' ')[0] : this.meetingDate;
|
||||||
|
this.description = req.description ?? '';
|
||||||
|
|
||||||
|
if (req.activity_log) {
|
||||||
|
try {
|
||||||
|
this.activityLog = JSON.parse(req.activity_log);
|
||||||
|
} catch {
|
||||||
|
this.activityLog = [];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
this.sharesService
|
||||||
|
.getSharesAccountData(String(req.share_account_id), false)
|
||||||
|
.pipe(catchError(() => of(null)))
|
||||||
|
.subscribe((acc: any) => (this.account = acc));
|
||||||
|
},
|
||||||
|
error: () => this.snackBar.open('Failed to load share withdrawal request.', 'Close', { duration: 5000 })
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/** "Review before Save" per requirement — opens the confirm dialog instead of submitting directly. */
|
||||||
|
openConfirmDialog(): void {
|
||||||
|
if (!this.isFormValid || this.submitting) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
this.showConfirmDialog = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
cancelConfirmDialog(): void {
|
||||||
|
this.showConfirmDialog = false;
|
||||||
|
}
|
||||||
|
|
||||||
|
confirmSubmit(): void {
|
||||||
|
this.showConfirmDialog = false;
|
||||||
|
this.submitting = true;
|
||||||
|
const payload = {
|
||||||
|
shareAccountId: Number(this.selectedAccountId),
|
||||||
|
accountNo: this.account?.accountNo,
|
||||||
|
clientId: this.account?.clientId,
|
||||||
|
clientName: this.account?.clientName,
|
||||||
|
requestedShares: this.requestedShares,
|
||||||
|
requestedDate: this.requestedDate,
|
||||||
|
resolutionNumber: this.resolutionNumber.trim(),
|
||||||
|
meetingDate: this.meetingDate,
|
||||||
|
description: this.description,
|
||||||
|
settlementPaymentTypeId: this.settlementPaymentTypeId
|
||||||
|
};
|
||||||
|
this.wdService.submit(payload).subscribe({
|
||||||
|
next: () => {
|
||||||
|
this.snackBar.open('Share withdrawal request submitted for approval.', 'Close', { duration: 4000 });
|
||||||
|
if (this.embedded) {
|
||||||
|
this.actionCompleted.emit();
|
||||||
|
} else {
|
||||||
|
this.router.navigate(['/clerk/dashboard']);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
error: (err) => {
|
||||||
|
this.submitting = false;
|
||||||
|
this.snackBar.open(err?.error?.defaultUserMessage || 'Could not submit the request.', 'Close', {
|
||||||
|
duration: 6000
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
openValidateModal(): void {
|
||||||
|
if (!this.reviewRequest) return;
|
||||||
|
this.showValidateModal = true;
|
||||||
|
this.runBackendValidation();
|
||||||
|
}
|
||||||
|
|
||||||
|
closeValidateModal(): void {
|
||||||
|
if (this.validating) return;
|
||||||
|
this.showValidateModal = false;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Re-checks live backend state (account still active, GL mapping still configured, etc.) rather
|
||||||
|
* than relying on data already loaded in the browser — catches drift since submission. Results
|
||||||
|
* are revealed one step at a time (see revealValidateSteps()) for the pipeline-style animation. */
|
||||||
|
runBackendValidation(): void {
|
||||||
|
if (!this.reviewRequest || this.validating) return;
|
||||||
|
this.validating = true;
|
||||||
|
this.backendChecks = null;
|
||||||
|
this.validateSteps = [];
|
||||||
|
this.wdService.validate(this.reviewRequest.id).subscribe({
|
||||||
|
next: (res: any) => {
|
||||||
|
const checks = (res?.checks ?? []) as { label: string; pass: boolean; detail: string }[];
|
||||||
|
this.backendChecks = checks;
|
||||||
|
this.validateAllPassed = !!res?.allPassed;
|
||||||
|
this.validateSteps = checks.map((c) => ({ ...c, status: 'pending' as const }));
|
||||||
|
this.revealValidateSteps(0);
|
||||||
|
},
|
||||||
|
error: (err) => {
|
||||||
|
this.validating = false;
|
||||||
|
this.snackBar.open(err?.error?.defaultUserMessage || 'Could not run backend validation.', 'Close', {
|
||||||
|
duration: 6000
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
private revealValidateSteps(index: number): void {
|
||||||
|
if (index >= this.validateSteps.length) {
|
||||||
|
this.validating = false;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
this.validateSteps[index].status = 'active';
|
||||||
|
setTimeout(() => {
|
||||||
|
this.validateSteps[index].status = this.validateSteps[index].pass ? 'done' : 'failed';
|
||||||
|
this.revealValidateSteps(index + 1);
|
||||||
|
}, 320);
|
||||||
|
}
|
||||||
|
|
||||||
|
approveReview(): void {
|
||||||
|
if (!this.reviewRequest) return;
|
||||||
|
this.wdService.approve(this.reviewRequest.id).subscribe({
|
||||||
|
next: (res: any) => {
|
||||||
|
const message =
|
||||||
|
res?.settlementAmount != null
|
||||||
|
? `Share withdrawal approved. ₹${Number(res.settlementAmount).toLocaleString('en-IN', { minimumFractionDigits: 2 })} credited to the linked savings account.`
|
||||||
|
: (res?.settlementWarning ?? 'Share withdrawal approved and processed.');
|
||||||
|
this.snackBar.open(message, 'Close', { duration: 6000 });
|
||||||
|
if (this.embedded) {
|
||||||
|
this.actionCompleted.emit();
|
||||||
|
} else {
|
||||||
|
this.router.navigate(['/checker-inbox-and-tasks/share-withdrawal-requests']);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
error: (err) =>
|
||||||
|
this.snackBar.open(err?.error?.defaultUserMessage || 'Could not approve the request.', 'Close', {
|
||||||
|
duration: 6000
|
||||||
|
})
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
openRejectDialog(): void {
|
||||||
|
this.showRejectDialog = true;
|
||||||
|
this.rejectionReason = '';
|
||||||
|
}
|
||||||
|
|
||||||
|
confirmRejectReview(): void {
|
||||||
|
if (!this.reviewRequest || !this.rejectionReason.trim()) return;
|
||||||
|
this.wdService.reject(this.reviewRequest.id, this.rejectionReason.trim()).subscribe({
|
||||||
|
next: () => {
|
||||||
|
this.showRejectDialog = false;
|
||||||
|
this.snackBar.open('Share withdrawal request rejected.', 'Close', { duration: 4000 });
|
||||||
|
if (this.embedded) {
|
||||||
|
this.actionCompleted.emit();
|
||||||
|
} else {
|
||||||
|
this.router.navigate(['/checker-inbox-and-tasks/share-withdrawal-requests']);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
error: (err) =>
|
||||||
|
this.snackBar.open(err?.error?.defaultUserMessage || 'Could not reject the request.', 'Close', {
|
||||||
|
duration: 6000
|
||||||
|
})
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
goBack(): void {
|
||||||
|
if (this.embedded) {
|
||||||
|
this.closeRequested.emit();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
this.router.navigate(this.isReview ? ['/checker-inbox-and-tasks/share-withdrawal-requests'] : ['/clerk/dashboard']);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -99,6 +99,19 @@
|
|||||||
</svg>
|
</svg>
|
||||||
My Disbursements
|
My Disbursements
|
||||||
</a>
|
</a>
|
||||||
|
<a
|
||||||
|
class="cashier-nav__item cashier-nav__item--sec"
|
||||||
|
[class.cashier-nav__item--active]="currentUrl.includes('/clerk/share-withdrawal-request')"
|
||||||
|
[routerLink]="['/clerk/share-withdrawal-requests']"
|
||||||
|
>
|
||||||
|
<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
||||||
|
<circle cx="12" cy="12" r="9" />
|
||||||
|
<path
|
||||||
|
d="M9.5 9a2.5 2.5 0 0 1 2.5-1.5c1.4 0 2.5.8 2.5 2s-1 1.5-2.5 1.9c-1.5.4-2.5.9-2.5 2.1s1.1 2 2.5 2 2.5-.7 2.5-1.6"
|
||||||
|
/>
|
||||||
|
</svg>
|
||||||
|
My Share Requests
|
||||||
|
</a>
|
||||||
@if (clerkAccountLoaded) {
|
@if (clerkAccountLoaded) {
|
||||||
<div class="cashier-nav__item-wrap">
|
<div class="cashier-nav__item-wrap">
|
||||||
<a
|
<a
|
||||||
|
|||||||
@@ -179,9 +179,9 @@ export class ShellComponent implements OnInit, OnDestroy {
|
|||||||
return this.router.url;
|
return this.router.url;
|
||||||
}
|
}
|
||||||
|
|
||||||
/** True on the manager's full-screen withdrawal-request review screen. */
|
/** True on the manager's full-screen withdrawal-request (cash or share) review screen. */
|
||||||
get isWithdrawalReviewUrl(): boolean {
|
get isWithdrawalReviewUrl(): boolean {
|
||||||
return /\/checker-inbox-and-tasks\/withdrawal-requests\/\d+\/review/.test(this.router.url);
|
return /\/checker-inbox-and-tasks\/(share-)?withdrawal-requests\/\d+\/review/.test(this.router.url);
|
||||||
}
|
}
|
||||||
|
|
||||||
/** ESC key — exit fullscreen at Clerk Dashboard / Election / review screen, restoring full shell header. */
|
/** ESC key — exit fullscreen at Clerk Dashboard / Election / review screen, restoring full shell header. */
|
||||||
|
|||||||
+45
@@ -115,6 +115,51 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
}
|
}
|
||||||
|
|
||||||
|
<div class="spa-group">
|
||||||
|
<div class="spa-group__header">
|
||||||
|
<h4>Map Payment Types to Specific Funds</h4>
|
||||||
|
<button type="button" class="spa-add-btn" (click)="addPaymentFundSourceMapping()">+ Add</button>
|
||||||
|
</div>
|
||||||
|
<div class="spa-list" [formArrayName]="'paymentChannelToFundSourceMappings'">
|
||||||
|
@for (row of paymentChannelToFundSourceMappings.controls; track $index; let i = $index) {
|
||||||
|
<div class="spa-other-row-wrap">
|
||||||
|
<div class="spa-row spa-row--other" [formGroupName]="i">
|
||||||
|
<div class="spa-row__meta spa-row__meta--other">
|
||||||
|
<mat-form-field class="spa-row__other-field" appearance="outline" subscriptSizing="dynamic">
|
||||||
|
<mat-label>Payment Type</mat-label>
|
||||||
|
<mat-select formControlName="paymentTypeId">
|
||||||
|
@for (paymentType of paymentTypeOptions; track paymentType.id) {
|
||||||
|
<mat-option [value]="paymentType.id">{{ paymentType.name }}</mat-option>
|
||||||
|
}
|
||||||
|
</mat-select>
|
||||||
|
</mat-form-field>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="spa-row__actions spa-row__actions--other">
|
||||||
|
<mat-form-field class="spa-row__other-field" appearance="outline" subscriptSizing="dynamic">
|
||||||
|
<mat-label>Fund Source</mat-label>
|
||||||
|
<mat-select formControlName="fundSourceAccountId">
|
||||||
|
@for (glAccount of fundSourceAccountOptions; track glAccount.id) {
|
||||||
|
<mat-option [value]="glAccount.id">
|
||||||
|
{{ '(' + glAccount.glCode + ') ' + glAccount.name }}
|
||||||
|
</mat-option>
|
||||||
|
}
|
||||||
|
</mat-select>
|
||||||
|
</mat-form-field>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="spa-remove-btn spa-remove-btn--side"
|
||||||
|
(click)="removePaymentFundSourceMapping(i)"
|
||||||
|
>
|
||||||
|
<i class="fas fa-times"></i>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
}
|
}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
+52
-1
@@ -50,6 +50,11 @@ type OtherGlMappingValue = {
|
|||||||
glAccountId: number | null;
|
glAccountId: number | null;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
type PaymentFundSourceValue = {
|
||||||
|
paymentTypeId: number | null;
|
||||||
|
fundSourceAccountId: number | null;
|
||||||
|
};
|
||||||
|
|
||||||
@Component({
|
@Component({
|
||||||
selector: 'mifosx-saving-product-accounting-step',
|
selector: 'mifosx-saving-product-accounting-step',
|
||||||
templateUrl: './saving-product-accounting-step.component.html',
|
templateUrl: './saving-product-accounting-step.component.html',
|
||||||
@@ -78,6 +83,11 @@ export class SavingProductAccountingStepComponent implements OnInit {
|
|||||||
expenseAccountOptions: GLAccount[] = [];
|
expenseAccountOptions: GLAccount[] = [];
|
||||||
incomeAccountOptions: GLAccount[] = [];
|
incomeAccountOptions: GLAccount[] = [];
|
||||||
assetAccountOptions: GLAccount[] = [];
|
assetAccountOptions: GLAccount[] = [];
|
||||||
|
paymentTypeOptions: any[] = [];
|
||||||
|
/** A payment-type Fund Source override can legitimately be a suspense/clearing Liability account
|
||||||
|
* (e.g. an existing "Sundry" account bridging to another module) rather than only a cash-like
|
||||||
|
* Asset account, so this field accepts either. */
|
||||||
|
fundSourceAccountOptions: GLAccount[] = [];
|
||||||
accountingSectionConfigs: AccountingSectionConfig[] = [];
|
accountingSectionConfigs: AccountingSectionConfig[] = [];
|
||||||
|
|
||||||
constructor() {
|
constructor() {
|
||||||
@@ -98,6 +108,8 @@ export class SavingProductAccountingStepComponent implements OnInit {
|
|||||||
this.liabilityAccountOptions = accountingMappingOptions.liabilityAccountOptions || [];
|
this.liabilityAccountOptions = accountingMappingOptions.liabilityAccountOptions || [];
|
||||||
this.expenseAccountOptions = accountingMappingOptions.expenseAccountOptions || [];
|
this.expenseAccountOptions = accountingMappingOptions.expenseAccountOptions || [];
|
||||||
this.incomeAccountOptions = accountingMappingOptions.incomeAccountOptions || [];
|
this.incomeAccountOptions = accountingMappingOptions.incomeAccountOptions || [];
|
||||||
|
this.paymentTypeOptions = this.savingProductsTemplate?.paymentTypeOptions || [];
|
||||||
|
this.fundSourceAccountOptions = this.assetAccountOptions.concat(this.liabilityAccountOptions);
|
||||||
this.buildAccountingSectionConfigs();
|
this.buildAccountingSectionConfigs();
|
||||||
this.savingProductAccountingForm.patchValue({
|
this.savingProductAccountingForm.patchValue({
|
||||||
accountingRule: accountingRuleId
|
accountingRule: accountingRuleId
|
||||||
@@ -105,6 +117,7 @@ export class SavingProductAccountingStepComponent implements OnInit {
|
|||||||
|
|
||||||
this.syncAccountingControls(accountingRuleId);
|
this.syncAccountingControls(accountingRuleId);
|
||||||
this.patchAccountingValues(mappings);
|
this.patchAccountingValues(mappings);
|
||||||
|
this.patchPaymentFundSourceMappings(this.savingProductsTemplate?.paymentChannelToFundSourceMappings || []);
|
||||||
this.setOverdraftControl();
|
this.setOverdraftControl();
|
||||||
this.setDormancyControl();
|
this.setDormancyControl();
|
||||||
}
|
}
|
||||||
@@ -115,10 +128,48 @@ export class SavingProductAccountingStepComponent implements OnInit {
|
|||||||
assetsOtherGlMappings: this.formBuilder.array([]),
|
assetsOtherGlMappings: this.formBuilder.array([]),
|
||||||
liabilitiesOtherGlMappings: this.formBuilder.array([]),
|
liabilitiesOtherGlMappings: this.formBuilder.array([]),
|
||||||
expensesOtherGlMappings: this.formBuilder.array([]),
|
expensesOtherGlMappings: this.formBuilder.array([]),
|
||||||
incomeOtherGlMappings: this.formBuilder.array([])
|
incomeOtherGlMappings: this.formBuilder.array([]),
|
||||||
|
paymentChannelToFundSourceMappings: this.formBuilder.array([])
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
get paymentChannelToFundSourceMappings(): UntypedFormArray {
|
||||||
|
return this.savingProductAccountingForm.get('paymentChannelToFundSourceMappings') as UntypedFormArray;
|
||||||
|
}
|
||||||
|
|
||||||
|
private createPaymentFundSourceGroup(value: Partial<PaymentFundSourceValue> = {}) {
|
||||||
|
return this.formBuilder.group({
|
||||||
|
paymentTypeId: [
|
||||||
|
value.paymentTypeId ?? this.paymentTypeOptions[0]?.id ?? null,
|
||||||
|
Validators.required
|
||||||
|
],
|
||||||
|
fundSourceAccountId: [
|
||||||
|
value.fundSourceAccountId ?? this.fundSourceAccountOptions[0]?.id ?? null,
|
||||||
|
Validators.required
|
||||||
|
]
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
private patchPaymentFundSourceMappings(mappings: any[]): void {
|
||||||
|
this.paymentChannelToFundSourceMappings.clear();
|
||||||
|
(mappings || []).forEach((mapping) => {
|
||||||
|
this.paymentChannelToFundSourceMappings.push(
|
||||||
|
this.createPaymentFundSourceGroup({
|
||||||
|
paymentTypeId: mapping.paymentType?.id,
|
||||||
|
fundSourceAccountId: mapping.fundSourceAccount?.id
|
||||||
|
})
|
||||||
|
);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
addPaymentFundSourceMapping(): void {
|
||||||
|
this.paymentChannelToFundSourceMappings.push(this.createPaymentFundSourceGroup());
|
||||||
|
}
|
||||||
|
|
||||||
|
removePaymentFundSourceMapping(index: number): void {
|
||||||
|
this.paymentChannelToFundSourceMappings.removeAt(index);
|
||||||
|
}
|
||||||
|
|
||||||
private setConditionalControls() {
|
private setConditionalControls() {
|
||||||
this.savingProductAccountingForm.get('accountingRule')?.valueChanges.subscribe((accountingRule: number) => {
|
this.savingProductAccountingForm.get('accountingRule')?.valueChanges.subscribe((accountingRule: number) => {
|
||||||
this.syncAccountingControls(accountingRule);
|
this.syncAccountingControls(accountingRule);
|
||||||
|
|||||||
+9
-2
@@ -74,6 +74,12 @@ export class ShareProductAccountingStepComponent implements OnInit {
|
|||||||
liabilityAccountData: GLAccount[] = [];
|
liabilityAccountData: GLAccount[] = [];
|
||||||
incomeAccountData: GLAccount[] = [];
|
incomeAccountData: GLAccount[] = [];
|
||||||
equityAccountData: GLAccount[] = [];
|
equityAccountData: GLAccount[] = [];
|
||||||
|
/** "Share Reference" is where a redemption's credit side lands pending settlement — some banks
|
||||||
|
* model that as a receivable-like Asset (e.g. "Cash Vault", this field's original default),
|
||||||
|
* others as a payable-like Liability suspense account (e.g. an existing "Sundry" account
|
||||||
|
* already used elsewhere as Shares Suspense). Both are valid conventions, so this field accepts
|
||||||
|
* either instead of Asset-only. */
|
||||||
|
referenceAccountData: GLAccount[] = [];
|
||||||
|
|
||||||
constructor() {
|
constructor() {
|
||||||
this.createShareProductAccountingForm();
|
this.createShareProductAccountingForm();
|
||||||
@@ -85,6 +91,7 @@ export class ShareProductAccountingStepComponent implements OnInit {
|
|||||||
this.liabilityAccountData = opts.liabilityAccountOptions || [];
|
this.liabilityAccountData = opts.liabilityAccountOptions || [];
|
||||||
this.incomeAccountData = opts.incomeAccountOptions || [];
|
this.incomeAccountData = opts.incomeAccountOptions || [];
|
||||||
this.equityAccountData = opts.equityAccountOptions || [];
|
this.equityAccountData = opts.equityAccountOptions || [];
|
||||||
|
this.referenceAccountData = this.assetAccountData.concat(this.liabilityAccountData);
|
||||||
|
|
||||||
this.buildAccountingSectionConfigs();
|
this.buildAccountingSectionConfigs();
|
||||||
|
|
||||||
@@ -135,10 +142,10 @@ export class ShareProductAccountingStepComponent implements OnInit {
|
|||||||
rows: [
|
rows: [
|
||||||
{
|
{
|
||||||
key: 'shareReferenceId',
|
key: 'shareReferenceId',
|
||||||
label: 'Share Reference (Cash Vault)',
|
label: 'Share Reference (Cash Vault or Suspense)',
|
||||||
inputLabel: 'Share reference',
|
inputLabel: 'Share reference',
|
||||||
controlName: 'shareReferenceId',
|
controlName: 'shareReferenceId',
|
||||||
options: this.assetAccountData,
|
options: this.referenceAccountData,
|
||||||
required: true
|
required: true
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
|
|||||||
@@ -0,0 +1,236 @@
|
|||||||
|
/**
|
||||||
|
* 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, ElementRef, forwardRef, HostListener, Input, ViewChild } from '@angular/core';
|
||||||
|
import { CommonModule } from '@angular/common';
|
||||||
|
import { ControlValueAccessor, FormsModule, NG_VALUE_ACCESSOR } from '@angular/forms';
|
||||||
|
|
||||||
|
export interface SearchSelectOption {
|
||||||
|
value: string;
|
||||||
|
label: string;
|
||||||
|
/** What typed text is matched against — defaults to `label` when omitted, but a caller can
|
||||||
|
* pass a richer string (e.g. account no + customer name + branch) so a search hits fields
|
||||||
|
* that aren't all visible in the collapsed label. */
|
||||||
|
searchText?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Drop-in replacement for a plain `<select>` when the option list is long enough that scanning
|
||||||
|
* it is impractical (e.g. hundreds of accounts) — a text box that filters the option list as you
|
||||||
|
* type, instead of a native dropdown you can only scroll through. Selection only commits on
|
||||||
|
* click; typing without picking an option reverts the box to the last committed selection on
|
||||||
|
* blur, so the bound ngModel value never drifts from an actual, valid option.
|
||||||
|
*
|
||||||
|
* Usage: <mifosx-search-select [options]="opts" [(ngModel)]="selectedValue"></mifosx-search-select>
|
||||||
|
*/
|
||||||
|
@Component({
|
||||||
|
selector: 'mifosx-search-select',
|
||||||
|
standalone: true,
|
||||||
|
imports: [
|
||||||
|
CommonModule,
|
||||||
|
FormsModule
|
||||||
|
],
|
||||||
|
providers: [
|
||||||
|
{
|
||||||
|
provide: NG_VALUE_ACCESSOR,
|
||||||
|
useExisting: forwardRef(() => SearchSelectComponent),
|
||||||
|
multi: true
|
||||||
|
}
|
||||||
|
],
|
||||||
|
template: `
|
||||||
|
<div class="inv-search-select">
|
||||||
|
<input
|
||||||
|
#inputEl
|
||||||
|
type="text"
|
||||||
|
class="inv-search-select__input"
|
||||||
|
[value]="query"
|
||||||
|
[placeholder]="placeholder"
|
||||||
|
[disabled]="disabled"
|
||||||
|
(input)="onInput($event)"
|
||||||
|
(focus)="onFocus()"
|
||||||
|
(blur)="onBlur()"
|
||||||
|
/>
|
||||||
|
@if (open) {
|
||||||
|
<ul
|
||||||
|
class="inv-search-select__list"
|
||||||
|
[style.top.px]="listTop"
|
||||||
|
[style.left.px]="listLeft"
|
||||||
|
[style.width.px]="listWidth"
|
||||||
|
(mousedown)="$event.preventDefault()"
|
||||||
|
>
|
||||||
|
@for (o of filteredOptions; track o.value) {
|
||||||
|
<li
|
||||||
|
class="inv-search-select__option"
|
||||||
|
[class.inv-search-select__option--active]="o.value === value"
|
||||||
|
(click)="selectOption(o)"
|
||||||
|
>
|
||||||
|
{{ o.label }}
|
||||||
|
</li>
|
||||||
|
} @empty {
|
||||||
|
<li class="inv-search-select__empty">No matches</li>
|
||||||
|
}
|
||||||
|
</ul>
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
`,
|
||||||
|
styles: [
|
||||||
|
`
|
||||||
|
/* Angular view encapsulation stops the host page's shared styles from reaching inside this
|
||||||
|
* component's own template, so its look is re-declared here rather than inherited. */
|
||||||
|
:host {
|
||||||
|
display: block;
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
.inv-search-select {
|
||||||
|
position: relative;
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
.inv-search-select__input {
|
||||||
|
width: 100%;
|
||||||
|
height: 36px;
|
||||||
|
padding: 0 10px;
|
||||||
|
border: 1px solid #e2e8f0;
|
||||||
|
border-radius: 8px;
|
||||||
|
font-size: 13px;
|
||||||
|
color: #0d1b2a;
|
||||||
|
font-family: inherit;
|
||||||
|
background: #fff;
|
||||||
|
box-sizing: border-box;
|
||||||
|
}
|
||||||
|
/* position: fixed, anchored via JS to the input's own screen coords (see
|
||||||
|
* updateListPosition()) rather than absolute inside this component — most of this app's
|
||||||
|
* cards use overflow: hidden for rounded corners, which would otherwise clip a dropdown
|
||||||
|
* that extends past the card's edge. */
|
||||||
|
.inv-search-select__list {
|
||||||
|
position: fixed;
|
||||||
|
z-index: 2000;
|
||||||
|
max-height: 260px;
|
||||||
|
overflow-y: auto;
|
||||||
|
margin: 0;
|
||||||
|
padding: 4px;
|
||||||
|
list-style: none;
|
||||||
|
background: #fff;
|
||||||
|
border: 1px solid #e2e8f0;
|
||||||
|
border-radius: 8px;
|
||||||
|
box-shadow: 0 8px 24px rgba(13, 27, 42, 0.18);
|
||||||
|
}
|
||||||
|
.inv-search-select__option {
|
||||||
|
padding: 8px 10px;
|
||||||
|
border-radius: 6px;
|
||||||
|
font-size: 13px;
|
||||||
|
color: #0d1b2a;
|
||||||
|
cursor: pointer;
|
||||||
|
white-space: nowrap;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
}
|
||||||
|
.inv-search-select__option:hover,
|
||||||
|
.inv-search-select__option--active {
|
||||||
|
background: #eef2ff;
|
||||||
|
}
|
||||||
|
.inv-search-select__empty {
|
||||||
|
padding: 8px 10px;
|
||||||
|
font-size: 12px;
|
||||||
|
color: #94a3b8;
|
||||||
|
}
|
||||||
|
`
|
||||||
|
]
|
||||||
|
})
|
||||||
|
export class SearchSelectComponent implements ControlValueAccessor {
|
||||||
|
@Input() options: SearchSelectOption[] = [];
|
||||||
|
@Input() placeholder = 'Search…';
|
||||||
|
|
||||||
|
@ViewChild('inputEl') private inputEl?: ElementRef<HTMLInputElement>;
|
||||||
|
|
||||||
|
value = '';
|
||||||
|
query = '';
|
||||||
|
open = false;
|
||||||
|
disabled = false;
|
||||||
|
|
||||||
|
listTop = 0;
|
||||||
|
listLeft = 0;
|
||||||
|
listWidth = 0;
|
||||||
|
|
||||||
|
private onChangeFn: (v: string) => void = () => {};
|
||||||
|
onTouched: () => void = () => {};
|
||||||
|
|
||||||
|
get filteredOptions(): SearchSelectOption[] {
|
||||||
|
const q = this.query.trim().toLowerCase();
|
||||||
|
if (!q) {
|
||||||
|
return this.options;
|
||||||
|
}
|
||||||
|
return this.options.filter((o) => (o.searchText || o.label).toLowerCase().includes(q));
|
||||||
|
}
|
||||||
|
|
||||||
|
private labelFor(value: string): string {
|
||||||
|
return this.options.find((o) => o.value === value)?.label ?? '';
|
||||||
|
}
|
||||||
|
|
||||||
|
writeValue(v: string): void {
|
||||||
|
this.value = v ?? '';
|
||||||
|
this.query = this.labelFor(this.value);
|
||||||
|
}
|
||||||
|
|
||||||
|
registerOnChange(fn: (v: string) => void): void {
|
||||||
|
this.onChangeFn = fn;
|
||||||
|
}
|
||||||
|
|
||||||
|
registerOnTouched(fn: () => void): void {
|
||||||
|
this.onTouched = fn;
|
||||||
|
}
|
||||||
|
|
||||||
|
setDisabledState(isDisabled: boolean): void {
|
||||||
|
this.disabled = isDisabled;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Anchors the fixed-position list to the input's current on-screen box. */
|
||||||
|
private updateListPosition(): void {
|
||||||
|
const el = this.inputEl?.nativeElement;
|
||||||
|
if (!el) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const rect = el.getBoundingClientRect();
|
||||||
|
this.listTop = rect.bottom + 4;
|
||||||
|
this.listLeft = rect.left;
|
||||||
|
this.listWidth = rect.width;
|
||||||
|
}
|
||||||
|
|
||||||
|
@HostListener('window:scroll')
|
||||||
|
@HostListener('window:resize')
|
||||||
|
onWindowChange(): void {
|
||||||
|
if (this.open) {
|
||||||
|
this.updateListPosition();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
onInput(event: Event): void {
|
||||||
|
this.query = (event.target as HTMLInputElement).value;
|
||||||
|
this.open = true;
|
||||||
|
this.updateListPosition();
|
||||||
|
}
|
||||||
|
|
||||||
|
onFocus(): void {
|
||||||
|
this.open = true;
|
||||||
|
this.updateListPosition();
|
||||||
|
}
|
||||||
|
|
||||||
|
onBlur(): void {
|
||||||
|
this.open = false;
|
||||||
|
this.onTouched();
|
||||||
|
// Discard any uncommitted typed text — only a clicked option ever changes `value`.
|
||||||
|
this.query = this.labelFor(this.value);
|
||||||
|
}
|
||||||
|
|
||||||
|
selectOption(o: SearchSelectOption): void {
|
||||||
|
this.value = o.value;
|
||||||
|
this.query = o.label;
|
||||||
|
this.open = false;
|
||||||
|
this.onChangeFn(this.value);
|
||||||
|
this.onTouched();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,210 @@
|
|||||||
|
<!--
|
||||||
|
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="passbook-container">
|
||||||
|
<!-- Filter Bar -->
|
||||||
|
<div class="passbook-filter-bar no-print">
|
||||||
|
<form [formGroup]="dateRangeForm" class="passbook-filter-form">
|
||||||
|
<mat-form-field appearance="outline" class="passbook-date-field" (click)="fromDatePicker.open()">
|
||||||
|
<mat-label>{{ 'labels.inputs.From Date' | translate }}</mat-label>
|
||||||
|
<input matInput [min]="minDate" [max]="maxDate" [matDatepicker]="fromDatePicker" formControlName="fromDate" />
|
||||||
|
<mat-datepicker-toggle matSuffix [for]="fromDatePicker"></mat-datepicker-toggle>
|
||||||
|
<mat-datepicker #fromDatePicker></mat-datepicker>
|
||||||
|
</mat-form-field>
|
||||||
|
|
||||||
|
<mat-form-field appearance="outline" class="passbook-date-field" (click)="toDatePicker.open()">
|
||||||
|
<mat-label>{{ 'labels.inputs.To Date' | translate }}</mat-label>
|
||||||
|
<input matInput [min]="minDate" [max]="maxDate" [matDatepicker]="toDatePicker" formControlName="toDate" />
|
||||||
|
<mat-datepicker-toggle matSuffix [for]="toDatePicker"></mat-datepicker-toggle>
|
||||||
|
<mat-datepicker #toDatePicker></mat-datepicker>
|
||||||
|
</mat-form-field>
|
||||||
|
|
||||||
|
<button
|
||||||
|
mat-raised-button
|
||||||
|
color="primary"
|
||||||
|
type="button"
|
||||||
|
[disabled]="!dateRangeForm.valid"
|
||||||
|
(click)="filterTransactions()"
|
||||||
|
>
|
||||||
|
<mat-icon>filter_list</mat-icon>
|
||||||
|
{{ 'labels.buttons.Filter' | translate }}
|
||||||
|
</button>
|
||||||
|
|
||||||
|
@if (isFiltered) {
|
||||||
|
<button mat-stroked-button type="button" (click)="clearFilter()">
|
||||||
|
<mat-icon>clear</mat-icon>
|
||||||
|
{{ 'labels.buttons.Clear' | translate }}
|
||||||
|
</button>
|
||||||
|
}
|
||||||
|
|
||||||
|
<span class="passbook-filter-spacer"></span>
|
||||||
|
|
||||||
|
<button mat-raised-button color="accent" type="button" (click)="printPassbook()">
|
||||||
|
<mat-icon>print</mat-icon>
|
||||||
|
{{ 'labels.buttons.Print' | translate }}
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Share Passbook -->
|
||||||
|
<div class="statement-wrapper" #passbookContent>
|
||||||
|
<!-- Bank Header -->
|
||||||
|
<div class="bank-header">
|
||||||
|
<div class="bank-logo-area">
|
||||||
|
<div class="bank-logo-circle">
|
||||||
|
<mat-icon>account_balance</mat-icon>
|
||||||
|
</div>
|
||||||
|
<div class="bank-name-block">
|
||||||
|
<div class="bank-title">LincPay Co-operative Bank Ltd.</div>
|
||||||
|
<div class="bank-subtitle">Share Account Passbook</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="statement-label">
|
||||||
|
<div class="stmt-label-text">SHARE HOLDING STATEMENT</div>
|
||||||
|
<div class="stmt-period">As on: {{ maxDate | dateFormat }}</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Account Details -->
|
||||||
|
<div class="account-details-band">
|
||||||
|
<div class="account-details-grid">
|
||||||
|
<div class="detail-block">
|
||||||
|
<div class="detail-label">Member Name</div>
|
||||||
|
<div class="detail-value bold">{{ clientName | uppercase }}</div>
|
||||||
|
</div>
|
||||||
|
<div class="detail-block">
|
||||||
|
<div class="detail-label">Share Account No.</div>
|
||||||
|
<div class="detail-value mono bold">{{ accountNo }}</div>
|
||||||
|
</div>
|
||||||
|
<div class="detail-block">
|
||||||
|
<div class="detail-label">Product</div>
|
||||||
|
<div class="detail-value">{{ productName }}</div>
|
||||||
|
</div>
|
||||||
|
<div class="detail-block">
|
||||||
|
<div class="detail-label">Account Opening Date</div>
|
||||||
|
<div class="detail-value">{{ activatedOnDate | dateFormat }}</div>
|
||||||
|
</div>
|
||||||
|
<div class="detail-block">
|
||||||
|
<div class="detail-label">Status</div>
|
||||||
|
<div class="detail-value status-badge" [class.active]="status === 'Active'">{{ status }}</div>
|
||||||
|
</div>
|
||||||
|
<div class="detail-block">
|
||||||
|
<div class="detail-label">Linked Savings A/C No.</div>
|
||||||
|
<div class="detail-value mono">{{ savingsAccountNumber || '—' }}</div>
|
||||||
|
</div>
|
||||||
|
@if (resolutionNumber) {
|
||||||
|
<div class="detail-block">
|
||||||
|
<div class="detail-label">Resolution No.</div>
|
||||||
|
<div class="detail-value">{{ resolutionNumber }}</div>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Balance Summary Strip -->
|
||||||
|
<div class="balance-summary-strip">
|
||||||
|
<div class="balance-box">
|
||||||
|
<div class="balance-box-label">Approved Shares</div>
|
||||||
|
<div class="balance-box-value">{{ totalApprovedShares }}</div>
|
||||||
|
</div>
|
||||||
|
<div class="balance-divider"></div>
|
||||||
|
<div class="balance-box">
|
||||||
|
<div class="balance-box-label">Share Value</div>
|
||||||
|
<div class="balance-box-value">
|
||||||
|
{{ currentMarketPrice | currency: currency?.code : 'symbol-narrow' : '1.2-2' }}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="balance-divider"></div>
|
||||||
|
<div class="balance-box">
|
||||||
|
<div class="balance-box-label">Current Value</div>
|
||||||
|
<div class="balance-box-value">{{ currentValue | currency: currency?.code : 'symbol-narrow' : '1.2-2' }}</div>
|
||||||
|
</div>
|
||||||
|
<div class="balance-divider"></div>
|
||||||
|
<div class="balance-box">
|
||||||
|
<div class="balance-box-label">Total Transactions</div>
|
||||||
|
<div class="balance-box-value">{{ filteredTransactions.length }}</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Transactions Table -->
|
||||||
|
@if (filteredTransactions.length > 0) {
|
||||||
|
<table class="statement-table">
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th class="col-sr">#</th>
|
||||||
|
<th class="col-date">Date</th>
|
||||||
|
<th class="col-desc">Description</th>
|
||||||
|
<th class="col-ref">Ref No.</th>
|
||||||
|
<th class="col-amount text-right">Shares Purchased</th>
|
||||||
|
<th class="col-amount text-right">Shares Redeemed</th>
|
||||||
|
<th class="col-balance text-right">Share Balance</th>
|
||||||
|
<th class="col-amount text-right">Amount (₹)</th>
|
||||||
|
<th class="col-desc">Resolution No. / Meeting</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
@for (txn of filteredTransactions; track txn.id; let i = $index) {
|
||||||
|
<tr [class.debit-row]="isRedeem(txn)" [class.credit-row]="!isRedeem(txn)">
|
||||||
|
<td class="col-sr text-center">{{ i + 1 }}</td>
|
||||||
|
<td class="col-date">{{ txn.purchasedDate | dateFormat }}</td>
|
||||||
|
<td class="col-desc">
|
||||||
|
<span class="txn-desc">{{ txn.type.value }} ({{ txn.status.value }})</span>
|
||||||
|
</td>
|
||||||
|
<td class="col-ref text-muted mono">{{ txn.id }}</td>
|
||||||
|
<td class="col-amount text-right credit-amount">
|
||||||
|
@if (!isRedeem(txn)) {
|
||||||
|
{{ txn.numberOfShares }}
|
||||||
|
}
|
||||||
|
</td>
|
||||||
|
<td class="col-amount text-right debit-amount">
|
||||||
|
@if (isRedeem(txn)) {
|
||||||
|
{{ txn.numberOfShares }}
|
||||||
|
}
|
||||||
|
</td>
|
||||||
|
<td class="col-balance text-right balance-amount">{{ txn.runningBalance }}</td>
|
||||||
|
<td class="col-amount text-right">
|
||||||
|
{{ txn.amount | currency: currency?.code : 'symbol-narrow' : '1.2-2' }}
|
||||||
|
</td>
|
||||||
|
<td class="col-desc">
|
||||||
|
@if (isRedeem(txn)) {
|
||||||
|
{{ txn.resolutionNumber || '—' }}
|
||||||
|
@if (txn.meetingDate) {
|
||||||
|
({{ txn.meetingDate | dateFormat }})
|
||||||
|
}
|
||||||
|
} @else {
|
||||||
|
—
|
||||||
|
}
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
}
|
||||||
|
</tbody>
|
||||||
|
<tfoot>
|
||||||
|
<tr class="total-row">
|
||||||
|
<td colspan="6" class="text-right total-label">Closing Share Balance</td>
|
||||||
|
<td class="text-right closing-balance">{{ totalApprovedShares }}</td>
|
||||||
|
<td colspan="2"></td>
|
||||||
|
</tr>
|
||||||
|
</tfoot>
|
||||||
|
</table>
|
||||||
|
} @else {
|
||||||
|
<div class="no-data-block">
|
||||||
|
<mat-icon>inbox</mat-icon>
|
||||||
|
<p>No transactions found for the selected date range.</p>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
|
||||||
|
<!-- Statement Footer -->
|
||||||
|
<div class="statement-footer">
|
||||||
|
<div class="footer-note">
|
||||||
|
This is a computer-generated statement and does not require a signature. For queries, contact your branch or
|
||||||
|
call customer care.
|
||||||
|
</div>
|
||||||
|
<div class="footer-meta">Generated on: {{ maxDate | dateFormat }} | Account No: {{ accountNo }}</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
@@ -0,0 +1,462 @@
|
|||||||
|
/**
|
||||||
|
* 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/.
|
||||||
|
*/
|
||||||
|
|
||||||
|
// ─── Variables ──────────────────────────────────────────────────────────────
|
||||||
|
$bank-primary: #1a3a6b; // deep navy
|
||||||
|
$bank-accent: #c8a951; // gold trim
|
||||||
|
$debit-color: #c0392b;
|
||||||
|
$credit-color: #1a7a3f;
|
||||||
|
$balance-color: #1a3a6b;
|
||||||
|
$border-color: #d0d7e3;
|
||||||
|
$row-alt: #f4f7fb;
|
||||||
|
|
||||||
|
// ─── Container ──────────────────────────────────────────────────────────────
|
||||||
|
.passbook-container {
|
||||||
|
padding: 16px;
|
||||||
|
background: #eef1f7;
|
||||||
|
min-height: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── Filter Bar ─────────────────────────────────────────────────────────────
|
||||||
|
.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 ───────────────────────────────────────────────────────
|
||||||
|
.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 ─────────────────────────────────────────────────────────────
|
||||||
|
.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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── Account Details Band ────────────────────────────────────────────────────
|
||||||
|
.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 ───────────────────────────────────────────────────
|
||||||
|
.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;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── Transactions Table ───────────────────────────────────────────────────────
|
||||||
|
.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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── Column widths ────────────────────────────────────────────────────────────
|
||||||
|
.col-sr {
|
||||||
|
width: 38px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.col-date {
|
||||||
|
width: 100px;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.col-desc {
|
||||||
|
min-width: 140px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.col-ref {
|
||||||
|
width: 70px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.col-amount {
|
||||||
|
width: 100px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.col-balance {
|
||||||
|
width: 90px;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── Amount colors ────────────────────────────────────────────────────────────
|
||||||
|
.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;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── Badges & misc ────────────────────────────────────────────────────────────
|
||||||
|
.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 ──────────────────────────────────────────────────────────────────
|
||||||
|
.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 ─────────────────────────────────────────────────────────
|
||||||
|
.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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── Responsive ───────────────────────────────────────────────────────────────
|
||||||
|
@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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── Print ────────────────────────────────────────────────────────────────────
|
||||||
|
@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 {
|
||||||
|
-webkit-print-color-adjust: exact;
|
||||||
|
print-color-adjust: exact;
|
||||||
|
}
|
||||||
|
|
||||||
|
.balance-summary-strip {
|
||||||
|
-webkit-print-color-adjust: exact;
|
||||||
|
print-color-adjust: exact;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,182 @@
|
|||||||
|
/**
|
||||||
|
* Copyright since 2025 Mifos Initiative
|
||||||
|
*
|
||||||
|
* This Source Code Form is subject to the terms of the Mozilla Public
|
||||||
|
* License, v. 2.0. If a copy of the MPL was not distributed with this
|
||||||
|
* file, You can obtain one at http://mozilla.org/MPL/2.0/.
|
||||||
|
*/
|
||||||
|
|
||||||
|
/** Angular Imports */
|
||||||
|
import { Component, OnInit, ElementRef, ViewChild, inject } from '@angular/core';
|
||||||
|
import { ActivatedRoute } from '@angular/router';
|
||||||
|
import { UntypedFormBuilder, Validators } from '@angular/forms';
|
||||||
|
import { CurrencyPipe } from '@angular/common';
|
||||||
|
import { MatIcon } from '@angular/material/icon';
|
||||||
|
import { DateFormatPipe } from '../../../pipes/date-format.pipe';
|
||||||
|
import { STANDALONE_SHARED_IMPORTS } from 'app/standalone-shared.module';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Share Account Passbook — a printable, chronological (oldest-first) ledger of every
|
||||||
|
* Purchase/Redeem entry, mirroring PassbookTabComponent (Savings) but for share holdings:
|
||||||
|
* shares purchased/redeemed instead of amount deposited/withdrawn, plus the Board Resolution
|
||||||
|
* Number / Date of Meeting on redeem rows.
|
||||||
|
*/
|
||||||
|
@Component({
|
||||||
|
selector: 'mifosx-shares-passbook-tab',
|
||||||
|
templateUrl: './passbook-tab.component.html',
|
||||||
|
styleUrls: ['./passbook-tab.component.scss'],
|
||||||
|
standalone: true,
|
||||||
|
imports: [
|
||||||
|
...STANDALONE_SHARED_IMPORTS,
|
||||||
|
MatIcon,
|
||||||
|
DateFormatPipe,
|
||||||
|
CurrencyPipe
|
||||||
|
]
|
||||||
|
})
|
||||||
|
export class SharesPassbookTabComponent implements OnInit {
|
||||||
|
private route = inject(ActivatedRoute);
|
||||||
|
private formBuilder = inject(UntypedFormBuilder);
|
||||||
|
|
||||||
|
@ViewChild('passbookContent') passbookContent: ElementRef;
|
||||||
|
|
||||||
|
/** Account data */
|
||||||
|
accountNo = '';
|
||||||
|
clientName = '';
|
||||||
|
productName = '';
|
||||||
|
currency: any = null;
|
||||||
|
status = '';
|
||||||
|
activatedOnDate: any = null;
|
||||||
|
resolutionNumber = '';
|
||||||
|
savingsAccountNumber = '';
|
||||||
|
totalApprovedShares = 0;
|
||||||
|
currentMarketPrice = 0;
|
||||||
|
|
||||||
|
/** Transactions */
|
||||||
|
allTransactions: any[] = [];
|
||||||
|
filteredTransactions: any[] = [];
|
||||||
|
|
||||||
|
/** Date range form */
|
||||||
|
minDate = new Date(2000, 0, 1);
|
||||||
|
maxDate = new Date();
|
||||||
|
dateRangeForm: any;
|
||||||
|
isFiltered = false;
|
||||||
|
|
||||||
|
get currentValue(): number {
|
||||||
|
return this.totalApprovedShares * this.currentMarketPrice;
|
||||||
|
}
|
||||||
|
|
||||||
|
ngOnInit(): void {
|
||||||
|
this.route.parent.data.subscribe((data: { sharesAccountData: any }) => {
|
||||||
|
const account = data.sharesAccountData;
|
||||||
|
this.accountNo = account.accountNo || '';
|
||||||
|
this.clientName = account.clientName || '';
|
||||||
|
this.productName = account.productName || '';
|
||||||
|
this.currency = account.currency || null;
|
||||||
|
this.status = account.status?.value || '';
|
||||||
|
this.activatedOnDate = account.timeline?.activatedDate || null;
|
||||||
|
this.resolutionNumber = account.resolutionNumber || '';
|
||||||
|
this.savingsAccountNumber = account.savingsAccountNumber || '';
|
||||||
|
this.totalApprovedShares = account.summary?.totalApprovedShares || 0;
|
||||||
|
this.currentMarketPrice = account.currentMarketPrice || 0;
|
||||||
|
|
||||||
|
const transactions = (account.purchasedShares || []).filter(
|
||||||
|
(transaction: any) => transaction.type.value !== 'Charge Payment'
|
||||||
|
);
|
||||||
|
this.allTransactions = this.recalculateRunningBalances(this.sortAscending(transactions));
|
||||||
|
this.filteredTransactions = [...this.allTransactions];
|
||||||
|
});
|
||||||
|
|
||||||
|
this.dateRangeForm = this.formBuilder.group({
|
||||||
|
fromDate: [
|
||||||
|
'',
|
||||||
|
Validators.required
|
||||||
|
],
|
||||||
|
toDate: [
|
||||||
|
'',
|
||||||
|
Validators.required
|
||||||
|
]
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
isRedeem(transaction: any): boolean {
|
||||||
|
return transaction.type?.value === 'Redeem';
|
||||||
|
}
|
||||||
|
|
||||||
|
filterTransactions(): void {
|
||||||
|
const fromDate = this.dateRangeForm.value.fromDate;
|
||||||
|
const toDate = this.dateRangeForm.value.toDate;
|
||||||
|
|
||||||
|
if (!fromDate || !toDate) return;
|
||||||
|
|
||||||
|
const from = new Date(fromDate).getTime();
|
||||||
|
const to = new Date(toDate).getTime();
|
||||||
|
|
||||||
|
this.filteredTransactions = this.allTransactions.filter((txn: any) => {
|
||||||
|
const txnDate = this.getTransactionDateAsTime(txn.purchasedDate);
|
||||||
|
return txnDate >= from && txnDate <= to;
|
||||||
|
});
|
||||||
|
this.isFiltered = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
clearFilter(): void {
|
||||||
|
this.dateRangeForm.reset();
|
||||||
|
this.filteredTransactions = [...this.allTransactions];
|
||||||
|
this.isFiltered = false;
|
||||||
|
}
|
||||||
|
|
||||||
|
private getTransactionDateAsTime(date: any): number {
|
||||||
|
if (Array.isArray(date) && date.length >= 3) {
|
||||||
|
return new Date(date[0], date[1] - 1, date[2]).getTime();
|
||||||
|
}
|
||||||
|
return new Date(date).getTime();
|
||||||
|
}
|
||||||
|
|
||||||
|
printPassbook(): void {
|
||||||
|
const content = this.passbookContent?.nativeElement?.innerHTML;
|
||||||
|
if (!content) return;
|
||||||
|
|
||||||
|
const printWindow = window.open('', 'SharePassbook', 'height=700,width=900');
|
||||||
|
printWindow.document.write(`
|
||||||
|
<html>
|
||||||
|
<head>
|
||||||
|
<title>Share Account Passbook - ${this.accountNo}</title>
|
||||||
|
<style>
|
||||||
|
* { margin: 0; padding: 0; box-sizing: border-box; }
|
||||||
|
body { font-family: 'Segoe UI', Arial, sans-serif; padding: 20px; color: #333; }
|
||||||
|
.passbook-header { text-align: center; margin-bottom: 20px; border-bottom: 2px solid #1a3a6b; padding-bottom: 12px; }
|
||||||
|
table { width: 100%; border-collapse: collapse; font-size: 11px; }
|
||||||
|
th { background: #1a3a6b; color: #fff; padding: 6px 8px; text-align: left; font-weight: 600; }
|
||||||
|
td { padding: 5px 8px; border-bottom: 1px solid #e0e0e0; }
|
||||||
|
tr:nth-child(even) { background: #fafafa; }
|
||||||
|
.text-right { text-align: right; }
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
${content}
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
|
`);
|
||||||
|
printWindow.document.close();
|
||||||
|
printWindow.focus();
|
||||||
|
printWindow.print();
|
||||||
|
printWindow.close();
|
||||||
|
}
|
||||||
|
|
||||||
|
private recalculateRunningBalances(transactions: any[]): any[] {
|
||||||
|
let balance = 0;
|
||||||
|
return transactions.map((txn) => {
|
||||||
|
const shares = Number(txn.numberOfShares) || 0;
|
||||||
|
balance += this.isRedeem(txn) ? -shares : shares;
|
||||||
|
return { ...txn, runningBalance: balance };
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
private sortAscending(transactions: any[]): any[] {
|
||||||
|
return [...transactions].sort((a, b) => {
|
||||||
|
const dateA = this.getTransactionDateAsTime(a.purchasedDate);
|
||||||
|
const dateB = this.getTransactionDateAsTime(b.purchasedDate);
|
||||||
|
if (dateA !== dateB) return dateA - dateB;
|
||||||
|
return (a.id as number) - (b.id as number);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -145,6 +145,16 @@
|
|||||||
>
|
>
|
||||||
{{ 'labels.inputs.Transactions' | translate }}
|
{{ 'labels.inputs.Transactions' | translate }}
|
||||||
</a>
|
</a>
|
||||||
|
<a
|
||||||
|
mat-tab-link
|
||||||
|
[routerLink]="['./passbook']"
|
||||||
|
routerLinkActive
|
||||||
|
#passbook="routerLinkActive"
|
||||||
|
[active]="passbook.isActive"
|
||||||
|
*mifosxHasPermission="'READ_SHAREACCOUNTPURCHASE'"
|
||||||
|
>
|
||||||
|
Passbook
|
||||||
|
</a>
|
||||||
<a
|
<a
|
||||||
mat-tab-link
|
mat-tab-link
|
||||||
[routerLink]="['./charges']"
|
[routerLink]="['./charges']"
|
||||||
|
|||||||
@@ -50,12 +50,10 @@ export class SharesButtonsConfiguration {
|
|||||||
name: 'Reject Additional Shares',
|
name: 'Reject Additional Shares',
|
||||||
icon: 'arrow-left',
|
icon: 'arrow-left',
|
||||||
taskPermissionName: 'REJECTADDITIONAL_SHAREACCOUNT'
|
taskPermissionName: 'REJECTADDITIONAL_SHAREACCOUNT'
|
||||||
},
|
|
||||||
{
|
|
||||||
name: 'Redeem Shares',
|
|
||||||
icon: 'arrow-left',
|
|
||||||
taskPermissionName: 'WITHDRAW_SAVINGSACCOUNT'
|
|
||||||
}
|
}
|
||||||
|
// 'Redeem Shares' is intentionally not offered here — a share withdrawal must go
|
||||||
|
// through the Clerk Dashboard maker-checker flow (Clerk Dashboard > My Share Requests),
|
||||||
|
// never a direct one-step action from this view.
|
||||||
];
|
];
|
||||||
break;
|
break;
|
||||||
case 'Submitted and pending approval':
|
case 'Submitted and pending approval':
|
||||||
|
|||||||
@@ -54,6 +54,25 @@
|
|||||||
</td>
|
</td>
|
||||||
</ng-container>
|
</ng-container>
|
||||||
|
|
||||||
|
<ng-container matColumnDef="runningBalance">
|
||||||
|
<th mat-header-cell *matHeaderCellDef>Share Balance</th>
|
||||||
|
<td mat-cell *matCellDef="let transaction">{{ transaction.runningBalance }}</td>
|
||||||
|
</ng-container>
|
||||||
|
|
||||||
|
<ng-container matColumnDef="resolutionMeeting">
|
||||||
|
<th mat-header-cell *matHeaderCellDef>Resolution No. / Meeting Date</th>
|
||||||
|
<td mat-cell *matCellDef="let transaction">
|
||||||
|
@if (transaction.type.value === 'Redeem') {
|
||||||
|
{{ transaction.resolutionNumber || '—' }}
|
||||||
|
@if (transaction.meetingDate) {
|
||||||
|
({{ transaction.meetingDate | dateFormat }})
|
||||||
|
}
|
||||||
|
} @else {
|
||||||
|
—
|
||||||
|
}
|
||||||
|
</td>
|
||||||
|
</ng-container>
|
||||||
|
|
||||||
<tr mat-header-row *matHeaderRowDef="displayedColumns"></tr>
|
<tr mat-header-row *matHeaderRowDef="displayedColumns"></tr>
|
||||||
<tr mat-row *matRowDef="let row; columns: displayedColumns"></tr>
|
<tr mat-row *matRowDef="let row; columns: displayedColumns"></tr>
|
||||||
</table>
|
</table>
|
||||||
|
|||||||
@@ -56,14 +56,18 @@ export class TransactionsTabComponent implements OnInit {
|
|||||||
transactionsData: any;
|
transactionsData: any;
|
||||||
/** Data source for transactions table. */
|
/** Data source for transactions table. */
|
||||||
dataSource: MatTableDataSource<any>;
|
dataSource: MatTableDataSource<any>;
|
||||||
/** Columns to be displayed in transactions table. */
|
/** Columns to be displayed in transactions table — a share account's passbook: every
|
||||||
|
* purchase/redeem entry plus the running share balance and, for a withdrawal, the Board
|
||||||
|
* Resolution Number / Date of Meeting that authorised it. */
|
||||||
displayedColumns: string[] = [
|
displayedColumns: string[] = [
|
||||||
'transactionDate',
|
'transactionDate',
|
||||||
'transactionType',
|
'transactionType',
|
||||||
'totalShares',
|
'totalShares',
|
||||||
'purchasedOrRedeemedPrice',
|
'purchasedOrRedeemedPrice',
|
||||||
'chargeAmount',
|
'chargeAmount',
|
||||||
'amountRecievedOrReturned'
|
'amountRecievedOrReturned',
|
||||||
|
'runningBalance',
|
||||||
|
'resolutionMeeting'
|
||||||
];
|
];
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -73,9 +77,19 @@ export class TransactionsTabComponent implements OnInit {
|
|||||||
constructor() {
|
constructor() {
|
||||||
this.route.parent.data.subscribe((data: { sharesAccountData: any }) => {
|
this.route.parent.data.subscribe((data: { sharesAccountData: any }) => {
|
||||||
this.shareAccountData = data.sharesAccountData;
|
this.shareAccountData = data.sharesAccountData;
|
||||||
this.transactionsData = this.shareAccountData.purchasedShares.filter(
|
this.transactionsData = this.shareAccountData.purchasedShares
|
||||||
(transaction: any) => transaction.type.value !== 'Charge Payment'
|
.filter((transaction: any) => transaction.type.value !== 'Charge Payment')
|
||||||
);
|
.slice()
|
||||||
|
.sort((a: any, b: any) => new Date(a.purchasedDate).getTime() - new Date(b.purchasedDate).getTime());
|
||||||
|
|
||||||
|
let runningBalance = 0;
|
||||||
|
this.transactionsData.forEach((transaction: any) => {
|
||||||
|
const shares = Number(transaction.numberOfShares) || 0;
|
||||||
|
runningBalance += transaction.type.value === 'Redeem' ? -shares : shares;
|
||||||
|
transaction.runningBalance = runningBalance;
|
||||||
|
});
|
||||||
|
// Passbook reads newest-first.
|
||||||
|
this.transactionsData.reverse();
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -26,6 +26,7 @@ import { ShareAccountActionsResolver } from './common-resolvers/share-account-ac
|
|||||||
import { SharesAccountTemplateResolver } from './common-resolvers/shares-account-template.resolver';
|
import { SharesAccountTemplateResolver } from './common-resolvers/shares-account-template.resolver';
|
||||||
import { SharesAccountAndTemplateResolver } from './common-resolvers/share-account-and-template.resolver';
|
import { SharesAccountAndTemplateResolver } from './common-resolvers/share-account-and-template.resolver';
|
||||||
import { GeneralTabComponent } from './shares-account-view/general-tab/general-tab.component';
|
import { GeneralTabComponent } from './shares-account-view/general-tab/general-tab.component';
|
||||||
|
import { SharesPassbookTabComponent } from './shares-account-view/passbook-tab/passbook-tab.component';
|
||||||
|
|
||||||
/** Shares Routes */
|
/** Shares Routes */
|
||||||
const routes: Routes = [
|
const routes: Routes = [
|
||||||
@@ -75,6 +76,11 @@ const routes: Routes = [
|
|||||||
component: TransactionsTabComponent,
|
component: TransactionsTabComponent,
|
||||||
data: { title: 'Shares Account Transactions', breadcrumb: 'Transactions', routeParamBreadcrumb: false }
|
data: { title: 'Shares Account Transactions', breadcrumb: 'Transactions', routeParamBreadcrumb: false }
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
path: 'passbook',
|
||||||
|
component: SharesPassbookTabComponent,
|
||||||
|
data: { title: 'Shares Account Passbook', breadcrumb: 'Passbook', routeParamBreadcrumb: false }
|
||||||
|
},
|
||||||
{
|
{
|
||||||
path: 'charges',
|
path: 'charges',
|
||||||
component: ChargesTabComponent,
|
component: ChargesTabComponent,
|
||||||
|
|||||||
+86
@@ -0,0 +1,86 @@
|
|||||||
|
<!--
|
||||||
|
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-card">
|
||||||
|
<div class="pos-section-label">Share Withdrawal Requests — Review, Approve or Reject</div>
|
||||||
|
<div class="inv-form-grid" style="padding: 12px 16px">
|
||||||
|
<label class="inv-form-field">
|
||||||
|
<span>Status</span>
|
||||||
|
<select [(ngModel)]="filterStatus" (change)="onFilterChange()">
|
||||||
|
@for (opt of statusOptions; track opt.value) {
|
||||||
|
<option [value]="opt.value">{{ opt.label }}</option>
|
||||||
|
}
|
||||||
|
</select>
|
||||||
|
</label>
|
||||||
|
<label class="inv-form-field" style="align-self: end">
|
||||||
|
<button type="button" class="pg-btn pg-btn--ghost" [disabled]="loading" (click)="load()">Refresh</button>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
<table class="pg-table">
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th>Account No</th>
|
||||||
|
<th>Client</th>
|
||||||
|
<th class="num">Shares</th>
|
||||||
|
<th>Resolution No.</th>
|
||||||
|
<th>Meeting Date</th>
|
||||||
|
<th>Clerk</th>
|
||||||
|
<th>Manager</th>
|
||||||
|
<th>Submitted At</th>
|
||||||
|
<th>Status</th>
|
||||||
|
<th></th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
@if (loading) {
|
||||||
|
<tr>
|
||||||
|
<td colspan="10" class="pg-empty">Loading…</td>
|
||||||
|
</tr>
|
||||||
|
} @else if (loaded && requests.length === 0) {
|
||||||
|
<tr>
|
||||||
|
<td colspan="10" class="pg-empty">No {{ filterStatus | lowercase }} share withdrawal requests.</td>
|
||||||
|
</tr>
|
||||||
|
} @else {
|
||||||
|
@for (r of requests; track r.id) {
|
||||||
|
<tr>
|
||||||
|
<td>
|
||||||
|
<span class="pg-chip">{{ r.account_no }}</span>
|
||||||
|
</td>
|
||||||
|
<td>{{ r.client_name }}</td>
|
||||||
|
<td class="num">{{ r.requested_shares }}</td>
|
||||||
|
<td>{{ r.resolution_number }}</td>
|
||||||
|
<td>{{ formatDateTime(r.meeting_date) }}</td>
|
||||||
|
<td>{{ r.clerk_name }}</td>
|
||||||
|
<td>{{ r.manager_name || r.manager_username || '—' }}</td>
|
||||||
|
<td>{{ formatDateTime(r.submitted_at) }}</td>
|
||||||
|
<td>
|
||||||
|
<span [class]="statusClass(r.status)">{{ statusLabel(r.status) }}</span>
|
||||||
|
</td>
|
||||||
|
<td>
|
||||||
|
<button type="button" class="pg-btn pg-btn--ghost" (click)="review(r)">Review</button>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
@if (showReviewModal && reviewRequestId) {
|
||||||
|
<div class="swr-list-popup-backdrop" (click)="closeReviewModal()">
|
||||||
|
<div class="swr-list-popup-panel" (click)="$event.stopPropagation()">
|
||||||
|
<mifosx-share-withdrawal-request
|
||||||
|
[embedded]="true"
|
||||||
|
[overrideMode]="'review'"
|
||||||
|
[overrideRequestId]="reviewRequestId"
|
||||||
|
(closeRequested)="closeReviewModal()"
|
||||||
|
(actionCompleted)="onReviewActionCompleted()"
|
||||||
|
></mifosx-share-withdrawal-request>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
+60
@@ -0,0 +1,60 @@
|
|||||||
|
/**
|
||||||
|
* 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/.
|
||||||
|
*/
|
||||||
|
|
||||||
|
@keyframes swr-list-popup-fade {
|
||||||
|
from {
|
||||||
|
opacity: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
to {
|
||||||
|
opacity: 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes swr-list-popup-zoom {
|
||||||
|
from {
|
||||||
|
opacity: 0;
|
||||||
|
transform: scale(0.97) translateY(10px);
|
||||||
|
}
|
||||||
|
|
||||||
|
to {
|
||||||
|
opacity: 1;
|
||||||
|
transform: scale(1) translateY(0);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.swr-list-popup-backdrop {
|
||||||
|
position: fixed;
|
||||||
|
inset: 0;
|
||||||
|
background: rgb(15 25 45 / 55%);
|
||||||
|
backdrop-filter: blur(3px);
|
||||||
|
z-index: 1100;
|
||||||
|
display: flex;
|
||||||
|
align-items: flex-start;
|
||||||
|
justify-content: center;
|
||||||
|
padding: 32px 20px;
|
||||||
|
overflow-y: auto;
|
||||||
|
animation: swr-list-popup-fade 0.2s ease-out;
|
||||||
|
}
|
||||||
|
|
||||||
|
.swr-list-popup-panel {
|
||||||
|
background: #f7f9fc;
|
||||||
|
border-radius: 16px;
|
||||||
|
width: 100%;
|
||||||
|
max-width: 1100px;
|
||||||
|
box-shadow: 0 24px 60px rgb(0 0 0 / 25%);
|
||||||
|
animation: swr-list-popup-zoom 0.25s ease-out;
|
||||||
|
padding: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (prefers-reduced-motion: reduce) {
|
||||||
|
.swr-list-popup-backdrop,
|
||||||
|
.swr-list-popup-panel {
|
||||||
|
animation: none;
|
||||||
|
}
|
||||||
|
}
|
||||||
+128
@@ -0,0 +1,128 @@
|
|||||||
|
/**
|
||||||
|
* Copyright since 2025 Mifos Initiative
|
||||||
|
*
|
||||||
|
* This Source Code Form is subject to the terms of the Mozilla Public
|
||||||
|
* License, v. 2.0. If a copy of the MPL was not distributed with this
|
||||||
|
* file, You can obtain one at http://mozilla.org/MPL/2.0/.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { Component, OnInit, inject } from '@angular/core';
|
||||||
|
import { CommonModule } from '@angular/common';
|
||||||
|
import { FormsModule } from '@angular/forms';
|
||||||
|
import { catchError } from 'rxjs/operators';
|
||||||
|
import { of } from 'rxjs';
|
||||||
|
|
||||||
|
import { ClerkShareWithdrawalService } from '../../../cashier/clerk-share-withdrawal.service';
|
||||||
|
import { ShareWithdrawalRequestComponent } from '../../../clerk/share-withdrawal-request/share-withdrawal-request.component';
|
||||||
|
|
||||||
|
/** Approval Center — Share Withdrawal Requests tab. A Bank Manager (checker) reviews, approves
|
||||||
|
* or rejects share withdrawal requests submitted from the Clerk Dashboard. Mirrors
|
||||||
|
* WithdrawalRequestsComponent (cash withdrawal), adapted for shares. Clicking "Review" opens the
|
||||||
|
* review screen in a popup on top of this list, rather than navigating away, so approving/rejecting
|
||||||
|
* drops the manager straight back into the (now refreshed) list. */
|
||||||
|
@Component({
|
||||||
|
selector: 'mifosx-share-withdrawal-requests',
|
||||||
|
templateUrl: './share-withdrawal-requests.component.html',
|
||||||
|
styleUrls: [
|
||||||
|
'../../../cashier/cashier-shared.scss',
|
||||||
|
'../../../clerk/clerk-shared.scss',
|
||||||
|
'./share-withdrawal-requests.component.scss'
|
||||||
|
],
|
||||||
|
standalone: true,
|
||||||
|
imports: [
|
||||||
|
CommonModule,
|
||||||
|
FormsModule,
|
||||||
|
ShareWithdrawalRequestComponent
|
||||||
|
]
|
||||||
|
})
|
||||||
|
export class ShareWithdrawalRequestsComponent implements OnInit {
|
||||||
|
private service = inject(ClerkShareWithdrawalService);
|
||||||
|
|
||||||
|
requests: any[] = [];
|
||||||
|
totalRecords = 0;
|
||||||
|
loading = false;
|
||||||
|
loaded = false;
|
||||||
|
|
||||||
|
filterStatus = 'PENDING';
|
||||||
|
|
||||||
|
showReviewModal = false;
|
||||||
|
reviewRequestId: number | null = null;
|
||||||
|
|
||||||
|
get statusOptions(): { value: string; label: string }[] {
|
||||||
|
return [
|
||||||
|
{ value: 'PENDING', label: 'Pending' },
|
||||||
|
{ value: 'APPROVED', label: 'Approved' },
|
||||||
|
{ value: 'REJECTED', label: 'Rejected' }
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
ngOnInit(): void {
|
||||||
|
this.load();
|
||||||
|
}
|
||||||
|
|
||||||
|
load(): void {
|
||||||
|
this.loading = true;
|
||||||
|
this.service
|
||||||
|
.getPendingRequests(this.filterStatus, 0, 50)
|
||||||
|
.pipe(catchError(() => of({ pageItems: [], totalFilteredRecords: 0 })))
|
||||||
|
.subscribe((res: any) => {
|
||||||
|
this.requests = res?.pageItems ?? [];
|
||||||
|
this.totalRecords = Number(res?.totalFilteredRecords ?? 0);
|
||||||
|
this.loading = false;
|
||||||
|
this.loaded = true;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
onFilterChange(): void {
|
||||||
|
this.load();
|
||||||
|
}
|
||||||
|
|
||||||
|
review(req: any): void {
|
||||||
|
this.reviewRequestId = req.id;
|
||||||
|
this.showReviewModal = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
closeReviewModal(): void {
|
||||||
|
this.showReviewModal = false;
|
||||||
|
this.reviewRequestId = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
onReviewActionCompleted(): void {
|
||||||
|
this.closeReviewModal();
|
||||||
|
this.load();
|
||||||
|
}
|
||||||
|
|
||||||
|
statusClass(status: string): string {
|
||||||
|
const map: Record<string, string> = {
|
||||||
|
PENDING: 'inv-badge--active',
|
||||||
|
APPROVED: 'inv-badge--matured',
|
||||||
|
REJECTED: 'inv-badge--written_off'
|
||||||
|
};
|
||||||
|
return `inv-badge ${map[status] ?? ''}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
statusLabel(status: string): string {
|
||||||
|
const map: Record<string, string> = {
|
||||||
|
PENDING: 'Pending',
|
||||||
|
APPROVED: 'Approved',
|
||||||
|
REJECTED: 'Rejected'
|
||||||
|
};
|
||||||
|
return map[status] ?? status;
|
||||||
|
}
|
||||||
|
|
||||||
|
formatDateTime(val: any): string {
|
||||||
|
if (!val) return '—';
|
||||||
|
try {
|
||||||
|
const d = new Date(val);
|
||||||
|
return d.toLocaleString('en-IN', {
|
||||||
|
day: '2-digit',
|
||||||
|
month: 'short',
|
||||||
|
year: 'numeric',
|
||||||
|
hour: '2-digit',
|
||||||
|
minute: '2-digit'
|
||||||
|
});
|
||||||
|
} catch {
|
||||||
|
return String(val);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -178,6 +178,18 @@
|
|||||||
<fa-icon icon="money-bill-wave" class="ac-tab__ic"></fa-icon>
|
<fa-icon icon="money-bill-wave" class="ac-tab__ic"></fa-icon>
|
||||||
<span>Withdrawal Requests</span>
|
<span>Withdrawal Requests</span>
|
||||||
</a>
|
</a>
|
||||||
|
<a
|
||||||
|
mat-tab-link
|
||||||
|
class="ac-tab"
|
||||||
|
[routerLink]="['./share-withdrawal-requests']"
|
||||||
|
routerLinkActive
|
||||||
|
#shareWithdrawalRequests="routerLinkActive"
|
||||||
|
[active]="shareWithdrawalRequests.isActive"
|
||||||
|
*mifosxHasPermission="'ALL_FUNCTIONS_READ'"
|
||||||
|
>
|
||||||
|
<fa-icon icon="hand-holding-usd" class="ac-tab__ic"></fa-icon>
|
||||||
|
<span>Share Withdrawal Requests</span>
|
||||||
|
</a>
|
||||||
<a
|
<a
|
||||||
mat-tab-link
|
mat-tab-link
|
||||||
class="ac-tab"
|
class="ac-tab"
|
||||||
|
|||||||
@@ -17,6 +17,7 @@ import { Route } from '../core/route/route.service';
|
|||||||
import { CheckerInboxAndTasksComponent } from './checker-inbox-and-tasks/checker-inbox-and-tasks.component';
|
import { CheckerInboxAndTasksComponent } from './checker-inbox-and-tasks/checker-inbox-and-tasks.component';
|
||||||
import { CheckerInboxComponent } from './checker-inbox-and-tasks-tabs/checker-inbox/checker-inbox.component';
|
import { CheckerInboxComponent } from './checker-inbox-and-tasks-tabs/checker-inbox/checker-inbox.component';
|
||||||
import { WithdrawalRequestsComponent } from './checker-inbox-and-tasks-tabs/withdrawal-requests/withdrawal-requests.component';
|
import { WithdrawalRequestsComponent } from './checker-inbox-and-tasks-tabs/withdrawal-requests/withdrawal-requests.component';
|
||||||
|
import { ShareWithdrawalRequestsComponent } from './checker-inbox-and-tasks-tabs/share-withdrawal-requests/share-withdrawal-requests.component';
|
||||||
import { DepositRequestsComponent } from './checker-inbox-and-tasks-tabs/deposit-requests/deposit-requests.component';
|
import { DepositRequestsComponent } from './checker-inbox-and-tasks-tabs/deposit-requests/deposit-requests.component';
|
||||||
import { DisbursementRequestsComponent } from './checker-inbox-and-tasks-tabs/disbursement-requests/disbursement-requests.component';
|
import { DisbursementRequestsComponent } from './checker-inbox-and-tasks-tabs/disbursement-requests/disbursement-requests.component';
|
||||||
import { ClientApprovalComponent } from './checker-inbox-and-tasks-tabs/client-approval/client-approval.component';
|
import { ClientApprovalComponent } from './checker-inbox-and-tasks-tabs/client-approval/client-approval.component';
|
||||||
@@ -159,6 +160,11 @@ const routes: Routes = [
|
|||||||
component: WithdrawalRequestsComponent,
|
component: WithdrawalRequestsComponent,
|
||||||
data: { title: 'Withdrawal Requests' }
|
data: { title: 'Withdrawal Requests' }
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
path: 'share-withdrawal-requests',
|
||||||
|
component: ShareWithdrawalRequestsComponent,
|
||||||
|
data: { title: 'Share Withdrawal Requests' }
|
||||||
|
},
|
||||||
{
|
{
|
||||||
path: 'deposit-requests',
|
path: 'deposit-requests',
|
||||||
component: DepositRequestsComponent,
|
component: DepositRequestsComponent,
|
||||||
@@ -200,6 +206,19 @@ const routes: Routes = [
|
|||||||
}
|
}
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
path: 'checker-inbox-and-tasks/share-withdrawal-requests',
|
||||||
|
children: [
|
||||||
|
{
|
||||||
|
path: ':id/review',
|
||||||
|
loadComponent: () =>
|
||||||
|
import('../clerk/share-withdrawal-request/share-withdrawal-request.component').then(
|
||||||
|
(m) => m.ShareWithdrawalRequestComponent
|
||||||
|
),
|
||||||
|
data: { title: 'Review Share Withdrawal Request', breadcrumb: 'Review Share Withdrawal', mode: 'review' }
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
{
|
{
|
||||||
path: 'checker-inbox-and-tasks/disbursement-requests',
|
path: 'checker-inbox-and-tasks/disbursement-requests',
|
||||||
children: [
|
children: [
|
||||||
|
|||||||
@@ -9,8 +9,8 @@
|
|||||||
/* tslint:disable */
|
/* tslint:disable */
|
||||||
export default {
|
export default {
|
||||||
'mifos_x': {
|
'mifos_x': {
|
||||||
'version': '260731',
|
'version': '260805',
|
||||||
'hash': 'ca85bae4'
|
'hash': '8c7d8287'
|
||||||
},
|
},
|
||||||
'allow_switching_backend_instance': true
|
'allow_switching_backend_instance': true
|
||||||
};
|
};
|
||||||
|
|||||||
Reference in New Issue
Block a user