Compare commits
5
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
b609f19e9f | ||
|
|
7f836f6f71 | ||
|
|
1a33f9d6ce | ||
|
|
983c613b8e | ||
|
|
a982083b14 |
+2
-2
@@ -69,8 +69,8 @@
|
|||||||
"budgets": [
|
"budgets": [
|
||||||
{
|
{
|
||||||
"type": "initial",
|
"type": "initial",
|
||||||
"maximumWarning": "13mb",
|
"maximumWarning": "15mb",
|
||||||
"maximumError": "14mb"
|
"maximumError": "16mb"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"type": "anyComponentStyle",
|
"type": "anyComponentStyle",
|
||||||
|
|||||||
@@ -78,6 +78,22 @@ const routes: Routes = [
|
|||||||
),
|
),
|
||||||
data: { title: 'My Share Requests', breadcrumb: 'My Share Requests' }
|
data: { title: 'My Share Requests', breadcrumb: 'My Share Requests' }
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
path: 'share-closure-request',
|
||||||
|
loadComponent: () =>
|
||||||
|
import('../clerk/share-closure-request/share-closure-request.component').then(
|
||||||
|
(m) => m.ShareClosureRequestComponent
|
||||||
|
),
|
||||||
|
data: { title: 'New Share Closure Request', breadcrumb: 'New Share Closure Request', mode: 'create' }
|
||||||
|
},
|
||||||
|
{
|
||||||
|
path: 'share-closure-requests',
|
||||||
|
loadComponent: () =>
|
||||||
|
import('../clerk/my-share-closure-requests/my-share-closure-requests.component').then(
|
||||||
|
(m) => m.MyShareClosureRequestsComponent
|
||||||
|
),
|
||||||
|
data: { title: 'My Share Closure Requests', breadcrumb: 'My Share Closure 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 Closure maker-checker workflow — mirrors
|
||||||
|
* ClerkShareWithdrawalService, adapted for closing an already-zero-share account. */
|
||||||
|
@Injectable({ providedIn: 'root' })
|
||||||
|
export class ClerkShareClosureService {
|
||||||
|
private http = inject(HttpClient);
|
||||||
|
|
||||||
|
submit(data: any): Observable<any> {
|
||||||
|
return this.http.post('/clerk/share-closure-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-closure-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-closure-requests', { params });
|
||||||
|
}
|
||||||
|
|
||||||
|
getById(id: number): Observable<any> {
|
||||||
|
return this.http.get(`/clerk/share-closure-requests/${id}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
validate(id: number): Observable<any> {
|
||||||
|
return this.http.post(`/clerk/share-closure-requests/${id}/validate`, {});
|
||||||
|
}
|
||||||
|
|
||||||
|
approve(id: number): Observable<any> {
|
||||||
|
return this.http.post(`/clerk/share-closure-requests/${id}/approve`, {});
|
||||||
|
}
|
||||||
|
|
||||||
|
reject(id: number, reason: string): Observable<any> {
|
||||||
|
return this.http.post(`/clerk/share-closure-requests/${id}/reject`, { reason });
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,69 @@
|
|||||||
|
<!--
|
||||||
|
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 CLOSURE</div>
|
||||||
|
<h1 class="pg-title">My Share Closure Requests</h1>
|
||||||
|
<p class="pg-sub">Share account closure 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 Closure Request</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>Closed Date</th>
|
||||||
|
<th>Submitted At</th>
|
||||||
|
<th>Status</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
@if (loading) {
|
||||||
|
<tr>
|
||||||
|
<td colspan="5" class="pg-empty">Loading…</td>
|
||||||
|
</tr>
|
||||||
|
} @else if (loaded && requests.length === 0) {
|
||||||
|
<tr>
|
||||||
|
<td colspan="5" class="pg-empty">No share closure 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>{{ formatDateTime(r.closed_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 { ClerkShareClosureService } from '../../cashier/clerk-share-closure.service';
|
||||||
|
|
||||||
|
/** Clerk Dashboard "My Share Closure Requests" — lists the share account closure requests this
|
||||||
|
* clerk has submitted and their approval status. Mirrors MyShareWithdrawalRequestsComponent. */
|
||||||
|
@Component({
|
||||||
|
selector: 'mifosx-my-share-closure-requests',
|
||||||
|
templateUrl: './my-share-closure-requests.component.html',
|
||||||
|
styleUrls: [
|
||||||
|
'../../cashier/cashier-shared.scss',
|
||||||
|
'../clerk-shared.scss'
|
||||||
|
],
|
||||||
|
standalone: true,
|
||||||
|
imports: [
|
||||||
|
CommonModule,
|
||||||
|
FormsModule,
|
||||||
|
RouterModule
|
||||||
|
]
|
||||||
|
})
|
||||||
|
export class MyShareClosureRequestsComponent implements OnInit {
|
||||||
|
private service = inject(ClerkShareClosureService);
|
||||||
|
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-closure-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,355 @@
|
|||||||
|
<!--
|
||||||
|
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 CLOSURE' }}</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 Closure 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 (only zero-share accounts are eligible)</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]="heldShares" readonly />
|
||||||
|
</label>
|
||||||
|
}
|
||||||
|
@if (loadingAccount) {
|
||||||
|
<div class="pg-empty">Loading account…</div>
|
||||||
|
}
|
||||||
|
@if (account && heldShares !== 0 && !isReview) {
|
||||||
|
<div class="pg-empty" style="grid-column: span 2; color: #b42318">
|
||||||
|
This account still holds {{ heldShares }} share(s) — all shares must be withdrawn first via the Share
|
||||||
|
Withdrawal workflow before it becomes eligible for closure.
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
@if (isReview && reviewRequest) {
|
||||||
|
<div class="pg-card">
|
||||||
|
<div class="pos-section-label">Client Compliance</div>
|
||||||
|
<div class="inv-form-grid" style="padding: 16px">
|
||||||
|
<div class="inv-form-field">
|
||||||
|
<span>KYC Status</span>
|
||||||
|
<span
|
||||||
|
class="swr-chip"
|
||||||
|
[class.swr-chip--ok]="reviewRequest.client_kyc_verified"
|
||||||
|
[class.swr-chip--warn]="!reviewRequest.client_kyc_verified"
|
||||||
|
>
|
||||||
|
{{ reviewRequest.client_kyc_status_label }}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div class="inv-form-field">
|
||||||
|
<span>Risk Category</span>
|
||||||
|
<span
|
||||||
|
class="swr-chip"
|
||||||
|
[class.swr-chip--ok]="!reviewRequest.client_risk_high"
|
||||||
|
[class.swr-chip--warn]="reviewRequest.client_risk_high"
|
||||||
|
>
|
||||||
|
{{ reviewRequest.client_risk_category_label }}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div class="inv-form-field">
|
||||||
|
<span>Open AML Alerts</span>
|
||||||
|
<span
|
||||||
|
class="swr-chip"
|
||||||
|
[class.swr-chip--ok]="!reviewRequest.client_open_aml_alerts"
|
||||||
|
[class.swr-chip--warn]="reviewRequest.client_open_aml_alerts > 0"
|
||||||
|
>
|
||||||
|
{{ reviewRequest.client_open_aml_alerts || 0 }}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
|
||||||
|
@if (account) {
|
||||||
|
<div class="pg-card">
|
||||||
|
<div class="pos-section-label">Closure Details</div>
|
||||||
|
<div class="inv-form-grid" style="padding: 16px">
|
||||||
|
<label class="inv-form-field">
|
||||||
|
<span>* Closed Date</span>
|
||||||
|
<input type="date" [(ngModel)]="closedDate" [disabled]="isReview" />
|
||||||
|
</label>
|
||||||
|
<label class="inv-form-field" style="grid-column: span 2">
|
||||||
|
<span>Note (optional)</span>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
[(ngModel)]="note"
|
||||||
|
[disabled]="isReview"
|
||||||
|
placeholder="e.g. Member exit — all shares redeemed"
|
||||||
|
/>
|
||||||
|
</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 shares held, account status, active loan/guarantee and client compliance 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 & Close Account
|
||||||
|
</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 Closure 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 closure of <strong>{{ account?.accountNo }}</strong> ({{ account?.clientName }}) against the
|
||||||
|
live account, loan/guarantee and compliance state — 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 & Close Account
|
||||||
|
</button>
|
||||||
|
}
|
||||||
|
<button type="button" class="pg-btn" [disabled]="validating" (click)="closeValidateModal()">
|
||||||
|
{{ validating ? 'Validating…' : 'Close' }}
|
||||||
|
</button>
|
||||||
|
</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 Closure 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 Held</td>
|
||||||
|
<td>{{ heldShares }}</td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<td>Closed Date</td>
|
||||||
|
<td>{{ closedDate }}</td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<td>Note</td>
|
||||||
|
<td>{{ note || '—' }}</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 Closure 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,336 @@
|
|||||||
|
/**
|
||||||
|
* 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 { ClerkShareClosureService } from '../../cashier/clerk-share-closure.service';
|
||||||
|
import { SearchSelectComponent, SearchSelectOption } from '../../shared/search-select/search-select.component';
|
||||||
|
|
||||||
|
export type ShareClosureMode = 'create' | 'review';
|
||||||
|
|
||||||
|
interface ActivityEntry {
|
||||||
|
timestamp: string;
|
||||||
|
action: string;
|
||||||
|
user: string;
|
||||||
|
note?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Share Account Closure request — maker screen in the Clerk Dashboard. A clerk finds a member's
|
||||||
|
* Share Account that already holds zero shares (all shares already withdrawn), enters the closed
|
||||||
|
* date plus an optional note, and submits for Bank Manager approval. This is the ONLY entry point
|
||||||
|
* a share account closure should go through — approval server-side executes the real Fineract
|
||||||
|
* "close" command (see ClerkShareClosureRequestApiResource on the backend).
|
||||||
|
*/
|
||||||
|
@Component({
|
||||||
|
selector: 'mifosx-share-closure-request',
|
||||||
|
templateUrl: './share-closure-request.component.html',
|
||||||
|
styleUrls: [
|
||||||
|
'../../cashier/cashier-shared.scss',
|
||||||
|
'../clerk-shared.scss',
|
||||||
|
'../share-withdrawal-request/share-withdrawal-request.component.scss'
|
||||||
|
],
|
||||||
|
standalone: true,
|
||||||
|
imports: [
|
||||||
|
CommonModule,
|
||||||
|
FormsModule,
|
||||||
|
RouterModule,
|
||||||
|
MatIconModule,
|
||||||
|
MatSnackBarModule,
|
||||||
|
SearchSelectComponent
|
||||||
|
]
|
||||||
|
})
|
||||||
|
export class ShareClosureRequestComponent implements OnInit {
|
||||||
|
private route = inject(ActivatedRoute);
|
||||||
|
private router = inject(Router);
|
||||||
|
private sharesService = inject(SharesService);
|
||||||
|
private closureService = inject(ClerkShareClosureService);
|
||||||
|
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: ShareClosureMode | null = null;
|
||||||
|
@Input() overrideRequestId: number | null = null;
|
||||||
|
@Output() closeRequested = new EventEmitter<void>();
|
||||||
|
@Output() actionCompleted = new EventEmitter<void>();
|
||||||
|
|
||||||
|
mode: ShareClosureMode = 'create';
|
||||||
|
requestId: number | null = null;
|
||||||
|
|
||||||
|
submitting = false;
|
||||||
|
loadingAccount = false;
|
||||||
|
|
||||||
|
selectedAccountId = '';
|
||||||
|
accountOptions: SearchSelectOption[] = [];
|
||||||
|
|
||||||
|
account: any = null;
|
||||||
|
|
||||||
|
closedDate = '';
|
||||||
|
note = '';
|
||||||
|
|
||||||
|
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 heldShares(): number {
|
||||||
|
return Number(this.account?.summary?.totalApprovedShares ?? this.account?.totalApprovedShares ?? 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
get isFormValid(): boolean {
|
||||||
|
if (!this.account) return false;
|
||||||
|
if (this.heldShares !== 0) return false;
|
||||||
|
if (!this.closedDate) 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 }[] {
|
||||||
|
return [
|
||||||
|
{ label: 'No shares currently held', pass: this.heldShares === 0, detail: `${this.heldShares} share(s) held` },
|
||||||
|
{ label: 'Closed Date provided', pass: !!this.closedDate, detail: this.closedDate || 'Missing' }
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
get isReview(): boolean {
|
||||||
|
return this.mode === 'review';
|
||||||
|
}
|
||||||
|
|
||||||
|
get pageTitle(): string {
|
||||||
|
return this.isReview ? 'Review Share Closure Request' : 'New Share Closure Request';
|
||||||
|
}
|
||||||
|
|
||||||
|
get pageSubtitle(): string {
|
||||||
|
return this.isReview
|
||||||
|
? "Approve or reject the clerk's share account closure request."
|
||||||
|
: 'Close a Share Account whose shares have already been fully withdrawn, for Bank Manager approval.';
|
||||||
|
}
|
||||||
|
|
||||||
|
ngOnInit(): void {
|
||||||
|
this.mode = this.overrideMode ?? (this.route.snapshot.data?.['mode'] as ShareClosureMode) ?? 'create';
|
||||||
|
const idParam = this.overrideRequestId ?? this.route.snapshot.params?.['id'];
|
||||||
|
this.requestId = idParam ? Number(idParam) : null;
|
||||||
|
|
||||||
|
const today = new Date();
|
||||||
|
this.closedDate = `${today.getFullYear()}-${String(today.getMonth() + 1).padStart(2, '0')}-${String(today.getDate()).padStart(2, '0')}`;
|
||||||
|
|
||||||
|
this.sharesService
|
||||||
|
.getShareAccountSummaries()
|
||||||
|
.pipe(catchError(() => of([])))
|
||||||
|
.subscribe((rows: any[]) => {
|
||||||
|
this.accountOptions = (rows || [])
|
||||||
|
.filter((r) => (r.status?.value || '').toLowerCase() === 'active' && Number(r.totalApprovedShares ?? 0) === 0)
|
||||||
|
.map((r) => ({
|
||||||
|
value: String(r.id),
|
||||||
|
label: `${r.accountNo} — ${r.clientName} (0 shares held)`,
|
||||||
|
searchText: `${r.accountNo} ${r.clientName}`
|
||||||
|
}));
|
||||||
|
});
|
||||||
|
|
||||||
|
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.closureService.getById(id).subscribe({
|
||||||
|
next: (req: any) => {
|
||||||
|
this.reviewRequest = req;
|
||||||
|
this.closedDate = req.closed_date ? String(req.closed_date).split('T')[0].split(' ')[0] : this.closedDate;
|
||||||
|
this.note = req.note ?? '';
|
||||||
|
|
||||||
|
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 closure 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,
|
||||||
|
closedDate: this.closedDate,
|
||||||
|
note: this.note
|
||||||
|
};
|
||||||
|
this.closureService.submit(payload).subscribe({
|
||||||
|
next: () => {
|
||||||
|
this.snackBar.open('Share closure 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 (shares still zero, account still active, no active loan/guarantee,
|
||||||
|
* compliance standing) rather than relying on data already loaded in the browser — catches drift
|
||||||
|
* since submission. Results are revealed one step at a time for the pipeline-style animation. */
|
||||||
|
runBackendValidation(): void {
|
||||||
|
if (!this.reviewRequest || this.validating) return;
|
||||||
|
this.validating = true;
|
||||||
|
this.backendChecks = null;
|
||||||
|
this.validateSteps = [];
|
||||||
|
this.closureService.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.closureService.approve(this.reviewRequest.id).subscribe({
|
||||||
|
next: () => {
|
||||||
|
this.snackBar.open('Share closure approved — account closed.', 'Close', { duration: 6000 });
|
||||||
|
if (this.embedded) {
|
||||||
|
this.actionCompleted.emit();
|
||||||
|
} else {
|
||||||
|
this.router.navigate(['/checker-inbox-and-tasks/share-closure-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.closureService.reject(this.reviewRequest.id, this.rejectionReason.trim()).subscribe({
|
||||||
|
next: () => {
|
||||||
|
this.showRejectDialog = false;
|
||||||
|
this.snackBar.open('Share closure request rejected.', 'Close', { duration: 4000 });
|
||||||
|
if (this.embedded) {
|
||||||
|
this.actionCompleted.emit();
|
||||||
|
} else {
|
||||||
|
this.router.navigate(['/checker-inbox-and-tasks/share-closure-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-closure-requests'] : ['/clerk/dashboard']);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -361,11 +361,14 @@ export class ShareWithdrawalRequestComponent implements OnInit {
|
|||||||
if (!this.reviewRequest) return;
|
if (!this.reviewRequest) return;
|
||||||
this.wdService.approve(this.reviewRequest.id).subscribe({
|
this.wdService.approve(this.reviewRequest.id).subscribe({
|
||||||
next: (res: any) => {
|
next: (res: any) => {
|
||||||
const message =
|
let message =
|
||||||
res?.settlementAmount != null
|
res?.settlementAmount != null
|
||||||
? `Share withdrawal approved. ₹${Number(res.settlementAmount).toLocaleString('en-IN', { minimumFractionDigits: 2 })} credited to the linked savings account.`
|
? `Share withdrawal approved. ₹${Number(res.settlementAmount).toLocaleString('en-IN', { minimumFractionDigits: 2 })} credited to the linked savings account.`
|
||||||
: (res?.settlementWarning ?? 'Share withdrawal approved and processed.');
|
: (res?.settlementWarning ?? 'Share withdrawal approved and processed.');
|
||||||
this.snackBar.open(message, 'Close', { duration: 6000 });
|
if (res?.autoClosureRequestId) {
|
||||||
|
message += ' All shares now withdrawn — a Share Closure Request was auto-submitted for manager review.';
|
||||||
|
}
|
||||||
|
this.snackBar.open(message, 'Close', { duration: 8000 });
|
||||||
if (this.embedded) {
|
if (this.embedded) {
|
||||||
this.actionCompleted.emit();
|
this.actionCompleted.emit();
|
||||||
} else {
|
} else {
|
||||||
|
|||||||
+90
@@ -0,0 +1,90 @@
|
|||||||
|
<div class="fm-dialog">
|
||||||
|
<div class="fm-dialog__header" style="display: flex; align-items: center; gap: 8px;">
|
||||||
|
<mat-icon class="fm-dialog__icon">people</mat-icon>
|
||||||
|
<span style="font-weight: 500; font-size: 18px;">CHILDREN</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<mat-dialog-content>
|
||||||
|
<form [formGroup]="childrenForm">
|
||||||
|
<div formArrayName="children" style="margin-top: 12px; background: #fff; padding: 12px;">
|
||||||
|
@for (child of children.controls; track $index) {
|
||||||
|
<div [formGroupName]="$index"
|
||||||
|
style="position: relative; padding: 24px 16px 16px; margin-top: 16px; margin-bottom: 24px; border: 1px solid #e0e0e0; border-radius: 8px; background: #fff;">
|
||||||
|
<div class="fm-grid">
|
||||||
|
<mat-form-field appearance="outline">
|
||||||
|
<mat-label>Child Full Name*</mat-label>
|
||||||
|
<input matInput formControlName="fullName" required placeholder="Child Full Name*" />
|
||||||
|
</mat-form-field>
|
||||||
|
<mat-form-field appearance="outline">
|
||||||
|
<mat-label>Gender*</mat-label>
|
||||||
|
<mat-select formControlName="genderId" required>
|
||||||
|
@for (g of genderIdOptions; track g.id) {
|
||||||
|
<mat-option [value]="g.id">{{ g.name }}</mat-option>
|
||||||
|
}
|
||||||
|
</mat-select>
|
||||||
|
</mat-form-field>
|
||||||
|
<mat-form-field appearance="outline" (click)="childDobPicker.open()">
|
||||||
|
<mat-label>Date of Birth</mat-label>
|
||||||
|
<input matInput [max]="maxDate" [matDatepicker]="childDobPicker" formControlName="dateOfBirth" />
|
||||||
|
<mat-datepicker-toggle matSuffix [for]="childDobPicker"></mat-datepicker-toggle>
|
||||||
|
<mat-datepicker #childDobPicker></mat-datepicker>
|
||||||
|
</mat-form-field>
|
||||||
|
<mat-form-field appearance="outline">
|
||||||
|
<mat-label>Nationality</mat-label>
|
||||||
|
<mat-select formControlName="nationality">
|
||||||
|
<mat-option value="INDIAN">Indian</mat-option>
|
||||||
|
<mat-option value="NRI">NRI</mat-option>
|
||||||
|
<mat-option value="FOREIGN_NATIONAL">Foreign National</mat-option>
|
||||||
|
<mat-option value="OTHER">Other</mat-option>
|
||||||
|
</mat-select>
|
||||||
|
</mat-form-field>
|
||||||
|
<mat-form-field appearance="outline">
|
||||||
|
<mat-label>Aadhaar Number (Optional)</mat-label>
|
||||||
|
<input
|
||||||
|
matInput
|
||||||
|
formControlName="aadhaarNumber"
|
||||||
|
maxlength="12"
|
||||||
|
placeholder="12-digit Aadhaar"
|
||||||
|
(keypress)="$event.charCode >= 48 && $event.charCode <= 57 ? true : $event.preventDefault()"
|
||||||
|
/>
|
||||||
|
</mat-form-field>
|
||||||
|
<mat-form-field appearance="outline">
|
||||||
|
<mat-label>PAN Number (Optional)</mat-label>
|
||||||
|
<input
|
||||||
|
matInput
|
||||||
|
formControlName="panNumber"
|
||||||
|
maxlength="10"
|
||||||
|
placeholder="ABCDE1234F"
|
||||||
|
style="text-transform: uppercase"
|
||||||
|
(input)="
|
||||||
|
$any(children.at($index)).get('panNumber')
|
||||||
|
?.setValue($any($event.target).value.toUpperCase(), { emitEvent: false })
|
||||||
|
"
|
||||||
|
/>
|
||||||
|
@if (
|
||||||
|
$any(children.at($index)).get('panNumber')?.hasError('pattern') && $any(children.at($index)).get('panNumber')?.touched
|
||||||
|
) {
|
||||||
|
<mat-error>Invalid PAN format</mat-error>
|
||||||
|
}
|
||||||
|
</mat-form-field>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
@if ($index < children.controls.length - 1) { <div style="border-bottom: 1px dashed #ccc; margin-bottom: 16px;">
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
}
|
||||||
|
<div style="border-bottom: 1px dashed #ccc; margin-bottom: 16px;"></div>
|
||||||
|
<button mat-stroked-button type="button" color="primary" (click)="addChild()">
|
||||||
|
<mat-icon>add</mat-icon> Add Another Child
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</mat-dialog-content>
|
||||||
|
|
||||||
|
<mat-dialog-actions align="end">
|
||||||
|
<button mat-stroked-button mat-dialog-close type="button">Cancel</button>
|
||||||
|
<button mat-flat-button color="primary" type="button" [disabled]="childrenForm.invalid" (click)="submit()">
|
||||||
|
Add Member
|
||||||
|
</button>
|
||||||
|
</mat-dialog-actions>
|
||||||
|
</div>
|
||||||
+98
@@ -0,0 +1,98 @@
|
|||||||
|
import { Component, Inject, OnInit } from '@angular/core';
|
||||||
|
import { MAT_DIALOG_DATA, MatDialogRef } from '@angular/material/dialog';
|
||||||
|
import { UntypedFormBuilder, UntypedFormGroup, UntypedFormArray, Validators } from '@angular/forms';
|
||||||
|
|
||||||
|
import { MatIcon } from '@angular/material/icon';
|
||||||
|
import { MatButtonModule } from '@angular/material/button';
|
||||||
|
import { MatInputModule } from '@angular/material/input';
|
||||||
|
import { MatSelectModule } from '@angular/material/select';
|
||||||
|
import { MatDatepickerModule } from '@angular/material/datepicker';
|
||||||
|
import { MatDialogModule } from '@angular/material/dialog';
|
||||||
|
import { MatTooltipModule } from '@angular/material/tooltip';
|
||||||
|
import { ReactiveFormsModule } from '@angular/forms';
|
||||||
|
import { CommonModule } from '@angular/common';
|
||||||
|
|
||||||
|
@Component({
|
||||||
|
selector: 'cbs-client-family-member-child-dialog',
|
||||||
|
templateUrl: './client-family-member-child-dialog.component.html',
|
||||||
|
styleUrls: ['../client-family-member-dialog/client-family-member-dialog.component.scss'],
|
||||||
|
standalone: true,
|
||||||
|
imports: [
|
||||||
|
CommonModule,
|
||||||
|
ReactiveFormsModule,
|
||||||
|
MatDialogModule,
|
||||||
|
MatIcon,
|
||||||
|
MatButtonModule,
|
||||||
|
MatInputModule,
|
||||||
|
MatSelectModule,
|
||||||
|
MatDatepickerModule,
|
||||||
|
MatTooltipModule
|
||||||
|
]
|
||||||
|
})
|
||||||
|
export class ClientFamilyMemberChildDialogComponent implements OnInit {
|
||||||
|
|
||||||
|
childrenForm: UntypedFormGroup;
|
||||||
|
genderIdOptions: any[];
|
||||||
|
maxDate: Date = new Date();
|
||||||
|
|
||||||
|
constructor(
|
||||||
|
public dialogRef: MatDialogRef<ClientFamilyMemberChildDialogComponent>,
|
||||||
|
@Inject(MAT_DIALOG_DATA) public data: any,
|
||||||
|
private formBuilder: UntypedFormBuilder
|
||||||
|
) {
|
||||||
|
this.genderIdOptions = data.genderIdOptions;
|
||||||
|
|
||||||
|
this.childrenForm = this.formBuilder.group({
|
||||||
|
children: this.formBuilder.array([])
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
ngOnInit() {
|
||||||
|
if (this.data.children && this.data.children.length > 0) {
|
||||||
|
this.data.children.forEach((child: any) => {
|
||||||
|
this.children.push(this.formBuilder.group({
|
||||||
|
id: [child.id],
|
||||||
|
fullName: [child.fullName, Validators.required],
|
||||||
|
genderId: [child.genderId, Validators.required],
|
||||||
|
dateOfBirth: [child.dateOfBirth ? new Date(child.dateOfBirth) : ''],
|
||||||
|
nationality: [child.nationality || 'INDIAN'],
|
||||||
|
aadhaarNumber: [child.aadhaarNumber || ''],
|
||||||
|
panNumber: [child.panNumber || '', Validators.pattern(/^[A-Z]{5}[0-9]{4}[A-Z]{1}$/)]
|
||||||
|
}));
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
this.addChild();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
get children(): UntypedFormArray {
|
||||||
|
return this.childrenForm.get('children') as UntypedFormArray;
|
||||||
|
}
|
||||||
|
|
||||||
|
createChildFormGroup(): UntypedFormGroup {
|
||||||
|
return this.formBuilder.group({
|
||||||
|
id: [''],
|
||||||
|
fullName: ['', Validators.required],
|
||||||
|
genderId: ['', Validators.required],
|
||||||
|
dateOfBirth: [''],
|
||||||
|
nationality: ['INDIAN'],
|
||||||
|
aadhaarNumber: [''],
|
||||||
|
panNumber: ['', Validators.pattern(/^[A-Z]{5}[0-9]{4}[A-Z]{1}$/)]
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
addChild(): void {
|
||||||
|
this.children.push(this.createChildFormGroup());
|
||||||
|
}
|
||||||
|
|
||||||
|
removeChild(index: number): void {
|
||||||
|
this.children.removeAt(index);
|
||||||
|
}
|
||||||
|
|
||||||
|
submit(): void {
|
||||||
|
if (this.childrenForm.valid) {
|
||||||
|
this.dialogRef.close(this.children.value);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
+28
@@ -139,6 +139,21 @@
|
|||||||
<span class="fm-readonly-value">{{ familyMemberForm.get('remarks')?.value }}</span>
|
<span class="fm-readonly-value">{{ familyMemberForm.get('remarks')?.value }}</span>
|
||||||
</div>
|
</div>
|
||||||
}
|
}
|
||||||
|
@if (isSpouse && familyMemberForm.get('children')?.value?.length > 0) {
|
||||||
|
<div class="fm-readonly-remarks">
|
||||||
|
<span class="fm-readonly-label">Children</span>
|
||||||
|
<div class="fm-children-list">
|
||||||
|
@for (child of familyMemberForm.get('children')?.value; track $index) {
|
||||||
|
<div class="fm-child-item" style="font-size: 13px; color: #555; margin-top: 4px;">
|
||||||
|
<span>{{ child.fullName }}</span>
|
||||||
|
@if (child.dateOfBirth) {
|
||||||
|
<span> · DOB: {{ child.dateOfBirth | date: 'dd MMM yyyy' }}</span>
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
<!-- ═══ ADD / EDIT MODE ═══ -->
|
<!-- ═══ ADD / EDIT MODE ═══ -->
|
||||||
@@ -447,6 +462,19 @@
|
|||||||
<mat-label>Remarks</mat-label>
|
<mat-label>Remarks</mat-label>
|
||||||
<textarea matInput formControlName="remarks" rows="2"></textarea>
|
<textarea matInput formControlName="remarks" rows="2"></textarea>
|
||||||
</mat-form-field>
|
</mat-form-field>
|
||||||
|
|
||||||
|
<!-- Children Section -->
|
||||||
|
@if (isSpouse) {
|
||||||
|
<div class="fm-section-label" style="margin-top: 16px;">Children</div>
|
||||||
|
<div class="fm-checkbox-row" style="display: flex; align-items: center; justify-content: space-between;">
|
||||||
|
<mat-checkbox formControlName="hasChildren">Does this family member have a child/children?</mat-checkbox>
|
||||||
|
@if (familyMemberForm.get('hasChildren')?.value) {
|
||||||
|
<button mat-stroked-button type="button" color="primary" (click)="openChildrenDialog()" style="margin-left: 16px;">
|
||||||
|
<mat-icon>edit</mat-icon> Edit Children Details ({{ children.length }})
|
||||||
|
</button>
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
}
|
||||||
}
|
}
|
||||||
</form>
|
</form>
|
||||||
</mat-dialog-content>
|
</mat-dialog-content>
|
||||||
|
|||||||
+115
-2
@@ -17,7 +17,7 @@ import {
|
|||||||
MatDialogClose,
|
MatDialogClose,
|
||||||
MatDialogContent
|
MatDialogContent
|
||||||
} from '@angular/material/dialog';
|
} from '@angular/material/dialog';
|
||||||
import { UntypedFormGroup, UntypedFormBuilder, Validators } from '@angular/forms';
|
import { UntypedFormGroup, UntypedFormBuilder, UntypedFormArray, Validators } from '@angular/forms';
|
||||||
import { SettingsService } from 'app/settings/settings.service';
|
import { SettingsService } from 'app/settings/settings.service';
|
||||||
import { Dates } from 'app/core/utils/dates';
|
import { Dates } from 'app/core/utils/dates';
|
||||||
import { ClientsService } from 'app/clients/clients.service';
|
import { ClientsService } from 'app/clients/clients.service';
|
||||||
@@ -58,6 +58,8 @@ export class ClientFamilyMemberDialogComponent implements OnInit {
|
|||||||
private dialog = inject(MatDialog);
|
private dialog = inject(MatDialog);
|
||||||
|
|
||||||
maxDate = new Date();
|
maxDate = new Date();
|
||||||
|
|
||||||
|
hasChildrenChecked = false;
|
||||||
familyMemberForm: UntypedFormGroup;
|
familyMemberForm: UntypedFormGroup;
|
||||||
|
|
||||||
/** Client search */
|
/** Client search */
|
||||||
@@ -197,6 +199,23 @@ export class ClientFamilyMemberDialogComponent implements OnInit {
|
|||||||
// Re-fetch linked client details (profile, signature, KYC status)
|
// Re-fetch linked client details (profile, signature, KYC status)
|
||||||
this.fetchLinkedClientData(m.linkedClientId.toString());
|
this.fetchLinkedClientData(m.linkedClientId.toString());
|
||||||
}
|
}
|
||||||
|
if (m.children && m.children.length > 0) {
|
||||||
|
this.hasChildrenChecked = true;
|
||||||
|
this.familyMemberForm.patchValue({ hasChildren: true });
|
||||||
|
m.children.forEach((child: any) => {
|
||||||
|
this.children.push(
|
||||||
|
this.formBuilder.group({
|
||||||
|
id: [child.id],
|
||||||
|
fullName: [child.fullName, Validators.required],
|
||||||
|
genderId: [child.genderId, Validators.required],
|
||||||
|
dateOfBirth: [child.dateOfBirth ? new Date(child.dateOfBirth) : ''],
|
||||||
|
nationality: [child.nationality || 'INDIAN'],
|
||||||
|
aadhaarNumber: [child.aadhaarNumber || ''],
|
||||||
|
panNumber: [child.panNumber || '', Validators.pattern(/^[A-Z]{5}[0-9]{4}[A-Z]{1}$/)]
|
||||||
|
})
|
||||||
|
);
|
||||||
|
});
|
||||||
|
}
|
||||||
if (this.isViewMode) {
|
if (this.isViewMode) {
|
||||||
this.familyMemberForm.disable();
|
this.familyMemberForm.disable();
|
||||||
}
|
}
|
||||||
@@ -225,8 +244,83 @@ export class ClientFamilyMemberDialogComponent implements OnInit {
|
|||||||
isAlive: [true],
|
isAlive: [true],
|
||||||
marriageDate: [''],
|
marriageDate: [''],
|
||||||
marriageAuthority: [''],
|
marriageAuthority: [''],
|
||||||
linkedClientId: ['']
|
linkedClientId: [''],
|
||||||
|
hasChildren: [false],
|
||||||
|
children: this.formBuilder.array([])
|
||||||
});
|
});
|
||||||
|
|
||||||
|
this.familyMemberForm.get('hasChildren')?.valueChanges.subscribe(value => {
|
||||||
|
if (value && !this.hasChildrenChecked) {
|
||||||
|
this.openChildrenDialog();
|
||||||
|
} else if (!value) {
|
||||||
|
this.hasChildrenChecked = false;
|
||||||
|
this.children.clear();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
get children(): UntypedFormArray {
|
||||||
|
return this.familyMemberForm.get('children') as UntypedFormArray;
|
||||||
|
}
|
||||||
|
|
||||||
|
async openChildrenDialog() {
|
||||||
|
// dynamically import the dialog to avoid circular dependency issues if any
|
||||||
|
const { ClientFamilyMemberChildDialogComponent } = await import('../client-family-member-child-dialog/client-family-member-child-dialog.component');
|
||||||
|
|
||||||
|
const dialogRef = this.dialog.open(ClientFamilyMemberChildDialogComponent, {
|
||||||
|
data: {
|
||||||
|
children: this.children.value,
|
||||||
|
genderIdOptions: this.data.options.genderIdOptions
|
||||||
|
},
|
||||||
|
width: '500px'
|
||||||
|
});
|
||||||
|
|
||||||
|
dialogRef.afterClosed().subscribe((result: any[]) => {
|
||||||
|
if (result) {
|
||||||
|
this.children.clear();
|
||||||
|
result.forEach(child => {
|
||||||
|
this.children.push(this.formBuilder.group({
|
||||||
|
id: [child.id],
|
||||||
|
fullName: [child.fullName, Validators.required],
|
||||||
|
genderId: [child.genderId, Validators.required],
|
||||||
|
dateOfBirth: [child.dateOfBirth],
|
||||||
|
nationality: [child.nationality || 'INDIAN'],
|
||||||
|
aadhaarNumber: [child.aadhaarNumber || ''],
|
||||||
|
panNumber: [child.panNumber || '', Validators.pattern(/^[A-Z]{5}[0-9]{4}[A-Z]{1}$/)]
|
||||||
|
}));
|
||||||
|
});
|
||||||
|
this.hasChildrenChecked = true;
|
||||||
|
this.familyMemberForm.patchValue({ hasChildren: true }, { emitEvent: false });
|
||||||
|
} else {
|
||||||
|
if (this.children.length === 0) {
|
||||||
|
this.hasChildrenChecked = false;
|
||||||
|
this.familyMemberForm.patchValue({ hasChildren: false }, { emitEvent: false });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
createChildFormGroup(): UntypedFormGroup {
|
||||||
|
return this.formBuilder.group({
|
||||||
|
id: [''],
|
||||||
|
fullName: ['', Validators.required],
|
||||||
|
genderId: ['', Validators.required],
|
||||||
|
dateOfBirth: [''],
|
||||||
|
nationality: ['INDIAN'],
|
||||||
|
aadhaarNumber: [''],
|
||||||
|
panNumber: ['', Validators.pattern(/^[A-Z]{5}[0-9]{4}[A-Z]{1}$/)]
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
addChild(): void {
|
||||||
|
this.children.push(this.createChildFormGroup());
|
||||||
|
}
|
||||||
|
|
||||||
|
removeChild(index: number): void {
|
||||||
|
this.children.removeAt(index);
|
||||||
|
if (this.children.length === 0) {
|
||||||
|
this.familyMemberForm.patchValue({ hasChildren: false });
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Handle marriage certificate file selection */
|
/** Handle marriage certificate file selection */
|
||||||
@@ -481,6 +575,25 @@ export class ClientFamilyMemberDialogComponent implements OnInit {
|
|||||||
member._linkedClient = this.selectedClient;
|
member._linkedClient = this.selectedClient;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Format children dates
|
||||||
|
if (this.isSpouse && member.children && member.children.length > 0) {
|
||||||
|
member.children = member.children.map((child: any) => {
|
||||||
|
const c = { ...child };
|
||||||
|
if (c.dateOfBirth instanceof Date) {
|
||||||
|
c.dateOfBirth = this.dateUtils.formatDate(c.dateOfBirth, dateFormat);
|
||||||
|
} else if (!c.dateOfBirth) {
|
||||||
|
delete c.dateOfBirth;
|
||||||
|
}
|
||||||
|
if (c.panNumber) {
|
||||||
|
c.panNumber = c.panNumber.toUpperCase();
|
||||||
|
}
|
||||||
|
return c;
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
delete member.children;
|
||||||
|
}
|
||||||
|
delete member.hasChildren;
|
||||||
|
|
||||||
// Attach marriage certificate file if present
|
// Attach marriage certificate file if present
|
||||||
if (this.marriageCertFile) {
|
if (this.marriageCertFile) {
|
||||||
member._marriageCertFile = this.marriageCertFile;
|
member._marriageCertFile = this.marriageCertFile;
|
||||||
|
|||||||
@@ -960,6 +960,10 @@ export class ClientGeneralStepComponent implements OnInit, OnDestroy, OnChanges
|
|||||||
this.linkIncomeToTurnover();
|
this.linkIncomeToTurnover();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Guards the Monthly/Annual and Income/Turnover auto-sync subscriptions below — set this to
|
||||||
|
* true while programmatically writing both fields of a pair to avoid a feedback loop. */
|
||||||
|
private suppressMonthlyAnnualLink = false;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Keeps a Monthly/Annual pair of amount fields in sync at a 12x ratio — typing into either
|
* Keeps a Monthly/Annual pair of amount fields in sync at a 12x ratio — typing into either
|
||||||
* one auto-fills the other. Uses { emitEvent: false } on the write side so the two
|
* one auto-fills the other. Uses { emitEvent: false } on the write side so the two
|
||||||
|
|||||||
@@ -153,7 +153,8 @@ export class ClientKycStepComponent implements OnInit, OnChanges, OnDestroy {
|
|||||||
kycDocumentName: 'KYC Document Type',
|
kycDocumentName: 'KYC Document Type',
|
||||||
kycDocumentNumber: 'KYC Document Number',
|
kycDocumentNumber: 'KYC Document Number',
|
||||||
amlCheckStatus: 'AML Check Status',
|
amlCheckStatus: 'AML Check Status',
|
||||||
bannedEntityMatch: 'Banned Entity List Match'
|
bannedEntityMatch: 'Banned Entity List Match',
|
||||||
|
remarks: 'Reason for Re-edit'
|
||||||
};
|
};
|
||||||
|
|
||||||
/** Names every currently-invalid `kycForm` control (required-but-empty, or an entered value
|
/** Names every currently-invalid `kycForm` control (required-but-empty, or an entered value
|
||||||
@@ -432,8 +433,8 @@ export class ClientKycStepComponent implements OnInit, OnChanges, OnDestroy {
|
|||||||
aadhaarNumber: [
|
aadhaarNumber: [
|
||||||
'',
|
'',
|
||||||
[
|
[
|
||||||
Validators.pattern(/^\d{12}$/),
|
Validators.pattern(/^(\d{12}|[X•*\s\d]{12,14})$/),
|
||||||
Validators.maxLength(12)
|
Validators.maxLength(14)
|
||||||
]
|
]
|
||||||
],
|
],
|
||||||
panAadhaarSeedingStatus: [''],
|
panAadhaarSeedingStatus: [''],
|
||||||
@@ -961,6 +962,12 @@ export class ClientKycStepComponent implements OnInit, OnChanges, OnDestroy {
|
|||||||
isException: data.isException || false,
|
isException: data.isException || false,
|
||||||
exceptionReason: data.exceptionReason ? data.exceptionReason.split(', ') : []
|
exceptionReason: data.exceptionReason ? data.exceptionReason.split(', ') : []
|
||||||
});
|
});
|
||||||
|
// Set remarks as required if KYC is verified
|
||||||
|
if (this.isKycVerified) {
|
||||||
|
this.kycForm.get('remarks')?.setValidators(Validators.required);
|
||||||
|
this.kycForm.get('remarks')?.updateValueAndValidity();
|
||||||
|
this.kycForm.get('remarks')?.markAsTouched(); // Force red underline so user knows it's blocking
|
||||||
|
}
|
||||||
this.persistedIsException = data.isException === true;
|
this.persistedIsException = data.isException === true;
|
||||||
this.persistedExceptionReason = data.exceptionReason || null;
|
this.persistedExceptionReason = data.exceptionReason || null;
|
||||||
// Sync chip-picker selection with the prefilled document name
|
// Sync chip-picker selection with the prefilled document name
|
||||||
|
|||||||
+28
-1
@@ -36,7 +36,34 @@
|
|||||||
<strong>Status:</strong>
|
<strong>Status:</strong>
|
||||||
<span class="status-badge status-{{ dispatch.status | lowercase }}">{{ dispatch.status }}</span>
|
<span class="status-badge status-{{ dispatch.status | lowercase }}">{{ dispatch.status }}</span>
|
||||||
</p>
|
</p>
|
||||||
</div>
|
|
||||||
|
<!-- Action buttons: only shown for DISPATCHED / IN_TRANSIT, only for users with UPDATE_WELCOMEKIT permission -->
|
||||||
|
<ng-container *mifosxHasPermission="'UPDATE_WELCOMEKIT'">
|
||||||
|
<div
|
||||||
|
*ngIf="canUpdateStatus(dispatch.status)"
|
||||||
|
style="margin-top: 12px; display: flex; gap: 8px; flex-wrap: wrap;"
|
||||||
|
>
|
||||||
|
<button
|
||||||
|
mat-stroked-button
|
||||||
|
color="primary"
|
||||||
|
type="button"
|
||||||
|
[disabled]="isLoading"
|
||||||
|
(click)="updateStatus(dispatch, 'DELIVERED')"
|
||||||
|
>
|
||||||
|
✅ Mark as Delivered
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
mat-stroked-button
|
||||||
|
color="warn"
|
||||||
|
type="button"
|
||||||
|
[disabled]="isLoading"
|
||||||
|
(click)="updateStatus(dispatch, 'RETURNED')"
|
||||||
|
>
|
||||||
|
↩ Mark as Returned
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</ng-container>
|
||||||
|
</div><!-- end dispatch-tracking-panel -->
|
||||||
|
|
||||||
<hr *ngIf="activeDispatches.length > 0 && welcomeKitsData?.canDispatchNew" />
|
<hr *ngIf="activeDispatches.length > 0 && welcomeKitsData?.canDispatchNew" />
|
||||||
|
|
||||||
|
|||||||
+24
@@ -165,6 +165,30 @@ export class WelcomeKitDialogComponent implements OnInit {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
canUpdateStatus(status: string): boolean {
|
||||||
|
return status === 'DISPATCHED' || status === 'IN_TRANSIT';
|
||||||
|
}
|
||||||
|
|
||||||
|
updateStatus(dispatch: any, newStatus: string) {
|
||||||
|
const label = newStatus === 'DELIVERED' ? 'Delivered' : 'Returned';
|
||||||
|
const confirmed = confirm(`Are you sure you want to mark this as ${label}? This action cannot be undone from the UI.`);
|
||||||
|
if (!confirmed) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
this.isLoading = true;
|
||||||
|
this.clientsService.updateWelcomeKitStatus(dispatch.id, newStatus).subscribe({
|
||||||
|
next: () => {
|
||||||
|
this.snackBar.open(`Welcome Kit marked as ${label} successfully`, 'Close', { duration: 3000 });
|
||||||
|
this.fetchData();
|
||||||
|
},
|
||||||
|
error: () => {
|
||||||
|
this.isLoading = false;
|
||||||
|
this.snackBar.open(`Failed to mark Welcome Kit as ${label}`, 'Close', { duration: 3000 });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
getDisplayAddress(dispatch: any): string {
|
getDisplayAddress(dispatch: any): string {
|
||||||
const address = dispatch.deliveryAddress || '';
|
const address = dispatch.deliveryAddress || '';
|
||||||
const name = this.welcomeKitsData?.clientName || '';
|
const name = this.welcomeKitsData?.clientName || '';
|
||||||
|
|||||||
@@ -62,6 +62,7 @@ import { ClientFamilyMembersStepComponent } from './client-stepper/client-family
|
|||||||
import { ClientPreviewStepComponent } from './client-stepper/client-preview-step/client-preview-step.component';
|
import { ClientPreviewStepComponent } from './client-stepper/client-preview-step/client-preview-step.component';
|
||||||
import { ClientAddressStepComponent } from './client-stepper/client-address-step/client-address-step.component';
|
import { ClientAddressStepComponent } from './client-stepper/client-address-step/client-address-step.component';
|
||||||
import { ClientFamilyMemberDialogComponent } from './client-stepper/client-family-members-step/client-family-member-dialog/client-family-member-dialog.component';
|
import { ClientFamilyMemberDialogComponent } from './client-stepper/client-family-members-step/client-family-member-dialog/client-family-member-dialog.component';
|
||||||
|
import { ClientFamilyMemberChildDialogComponent } from './client-stepper/client-family-members-step/client-family-member-child-dialog/client-family-member-child-dialog.component';
|
||||||
import { CaptureImageDialogComponent } from './clients-view/custom-dialogs/capture-image-dialog/capture-image-dialog.component';
|
import { CaptureImageDialogComponent } from './clients-view/custom-dialogs/capture-image-dialog/capture-image-dialog.component';
|
||||||
import { AddClientCollateralComponent } from './clients-view/client-actions/add-client-collateral/add-client-collateral.component';
|
import { AddClientCollateralComponent } from './clients-view/client-actions/add-client-collateral/add-client-collateral.component';
|
||||||
import { ClientDatatableStepComponent } from './client-stepper/client-datatable-step/client-datatable-step.component';
|
import { ClientDatatableStepComponent } from './client-stepper/client-datatable-step/client-datatable-step.component';
|
||||||
@@ -127,6 +128,7 @@ import { Form60TabComponent } from './clients-view/form60-tab/form60-tab.compone
|
|||||||
ClientPreviewStepComponent,
|
ClientPreviewStepComponent,
|
||||||
ClientAddressStepComponent,
|
ClientAddressStepComponent,
|
||||||
ClientFamilyMemberDialogComponent,
|
ClientFamilyMemberDialogComponent,
|
||||||
|
ClientFamilyMemberChildDialogComponent,
|
||||||
CaptureImageDialogComponent,
|
CaptureImageDialogComponent,
|
||||||
AddClientCollateralComponent,
|
AddClientCollateralComponent,
|
||||||
ClientDatatableStepComponent,
|
ClientDatatableStepComponent,
|
||||||
|
|||||||
@@ -123,6 +123,20 @@ export class ClientsService {
|
|||||||
return this.http.post(`/clients/${clientId}/edit/reject`, { rejectReason: rejectReason || '' });
|
return this.http.post(`/clients/${clientId}/edit/reject`, { rejectReason: rejectReason || '' });
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Fetches the exact email content (subject, body, sentAt, openedAt) for a given pending edit.
|
||||||
|
* Only populated after the Checker has approved and the send succeeded. */
|
||||||
|
getClientEditEmailPreview(clientId: string | number, pendingEditId: string | number) {
|
||||||
|
return this.http.get<{
|
||||||
|
emailSubject: string | null;
|
||||||
|
emailBody: string | null;
|
||||||
|
emailSent: boolean;
|
||||||
|
emailSentAt: string | null;
|
||||||
|
emailOpenedAt: string | null;
|
||||||
|
emailFrom: string | null;
|
||||||
|
emailTo: string | null;
|
||||||
|
}>(`/clients/${clientId}/edit/${pendingEditId}/email-preview`);
|
||||||
|
}
|
||||||
|
|
||||||
getClientDataAndTemplate(clientId: string) {
|
getClientDataAndTemplate(clientId: string) {
|
||||||
const httpParams = new HttpParams().set('template', 'true').set('staffInSelectedOfficeOnly', 'true');
|
const httpParams = new HttpParams().set('template', 'true').set('staffInSelectedOfficeOnly', 'true');
|
||||||
return this.http.get(`/clients/${clientId}`, { params: httpParams });
|
return this.http.get(`/clients/${clientId}`, { params: httpParams });
|
||||||
@@ -959,6 +973,10 @@ export class ClientsService {
|
|||||||
return this.http.post(`/clients/${clientId}/welcomekit`, data);
|
return this.http.post(`/clients/${clientId}/welcomekit`, data);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
updateWelcomeKitStatus(welcomeKitId: number, status: string): Observable<any> {
|
||||||
|
return this.http.put(`/welcomekit/${welcomeKitId}`, { status });
|
||||||
|
}
|
||||||
|
|
||||||
getKycExceptions(resolved = false): Observable<any[]> {
|
getKycExceptions(resolved = false): Observable<any[]> {
|
||||||
return this.http.get<any[]>(`/kyc/exceptions?resolved=${resolved}`);
|
return this.http.get<any[]>(`/kyc/exceptions?resolved=${resolved}`);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -84,14 +84,46 @@
|
|||||||
<mat-icon>phone_iphone</mat-icon>
|
<mat-icon>phone_iphone</mat-icon>
|
||||||
<span>Mobile Number</span>
|
<span>Mobile Number</span>
|
||||||
</div>
|
</div>
|
||||||
|
<div class="contact-verify-panel__exception-row" style="margin-bottom: 12px;">
|
||||||
|
<mat-checkbox
|
||||||
|
[checked]="oldMobileNotAccessible"
|
||||||
|
(change)="onOldMobileNotAccessibleToggle($event.checked)"
|
||||||
|
color="primary"
|
||||||
|
>
|
||||||
|
Old mobile number not accessible (lost / deactivated SIM)
|
||||||
|
</mat-checkbox>
|
||||||
|
</div>
|
||||||
<div class="contact-verify-panel__grid">
|
<div class="contact-verify-panel__grid">
|
||||||
<mifosx-client-contact-otp-block
|
@if (!oldMobileNotAccessible) {
|
||||||
[clientId]="clientId"
|
<mifosx-client-contact-otp-block
|
||||||
channel="MOBILE"
|
[clientId]="clientId"
|
||||||
[destination]="contactChanges.oldMobile"
|
channel="MOBILE"
|
||||||
label="Old Mobile Number"
|
[destination]="contactChanges.oldMobile"
|
||||||
(stateChange)="onOldMobileOtpState($event)"
|
label="Old Mobile Number"
|
||||||
></mifosx-client-contact-otp-block>
|
(stateChange)="onOldMobileOtpState($event)"
|
||||||
|
></mifosx-client-contact-otp-block>
|
||||||
|
} @else {
|
||||||
|
<div class="fir-upload-box" style="padding: 12px; border: 1px dashed #cbd5e1; border-radius: 8px; background: #f8fafc;">
|
||||||
|
<div style="font-weight: 500; font-size: 13px; margin-bottom: 6px; color: #475569;">
|
||||||
|
Upload Police Complaint / FIR Copy for Lost Mobile
|
||||||
|
</div>
|
||||||
|
<div style="display: flex; align-items: center; gap: 12px;">
|
||||||
|
<input type="file" (change)="onFirFileSelected($event)" accept=".pdf,.jpg,.jpeg,.png" #firFileInput hidden />
|
||||||
|
<button mat-stroked-button type="button" (click)="firFileInput.click()" [disabled]="uploadingFir">
|
||||||
|
<mat-icon>upload_file</mat-icon>
|
||||||
|
{{ firFileName ? firFileName : 'Select FIR Document' }}
|
||||||
|
</button>
|
||||||
|
@if (uploadingFir) {
|
||||||
|
<mat-progress-spinner mode="indeterminate" diameter="20"></mat-progress-spinner>
|
||||||
|
}
|
||||||
|
@if (firDocumentId && !uploadingFir) {
|
||||||
|
<span style="color: #16a34a; font-size: 12px; font-weight: 500; display: flex; align-items: center; gap: 4px;">
|
||||||
|
<mat-icon style="font-size: 16px; width: 16px; height: 16px;">check_circle</mat-icon> FIR Uploaded Successfully
|
||||||
|
</span>
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
<mifosx-client-contact-otp-block
|
<mifosx-client-contact-otp-block
|
||||||
[clientId]="clientId"
|
[clientId]="clientId"
|
||||||
channel="MOBILE"
|
channel="MOBILE"
|
||||||
|
|||||||
@@ -33,6 +33,7 @@ import { ContactOtpBlockComponent, ContactOtpState } from './contact-otp-block/c
|
|||||||
|
|
||||||
import { MatTabsModule } from '@angular/material/tabs';
|
import { MatTabsModule } from '@angular/material/tabs';
|
||||||
import { MatIconModule } from '@angular/material/icon';
|
import { MatIconModule } from '@angular/material/icon';
|
||||||
|
import { MatCheckboxModule } from '@angular/material/checkbox';
|
||||||
import { MatDivider } from '@angular/material/divider';
|
import { MatDivider } from '@angular/material/divider';
|
||||||
import { FaIconComponent } from '@fortawesome/angular-fontawesome';
|
import { FaIconComponent } from '@fortawesome/angular-fontawesome';
|
||||||
import { MatProgressSpinner } from '@angular/material/progress-spinner';
|
import { MatProgressSpinner } from '@angular/material/progress-spinner';
|
||||||
@@ -51,6 +52,7 @@ import { ORIGINAL_VALUES_PAYLOAD_KEY, buildOriginalValuesSnapshot } from '../sha
|
|||||||
...STANDALONE_SHARED_IMPORTS,
|
...STANDALONE_SHARED_IMPORTS,
|
||||||
MatTabsModule,
|
MatTabsModule,
|
||||||
MatIconModule,
|
MatIconModule,
|
||||||
|
MatCheckboxModule,
|
||||||
MatDivider,
|
MatDivider,
|
||||||
FaIconComponent,
|
FaIconComponent,
|
||||||
MatProgressSpinner,
|
MatProgressSpinner,
|
||||||
@@ -108,6 +110,13 @@ export class EditClientComponent {
|
|||||||
newMobileOtpId: number | null = null;
|
newMobileOtpId: number | null = null;
|
||||||
oldMobileVerified = false;
|
oldMobileVerified = false;
|
||||||
newMobileVerified = false;
|
newMobileVerified = false;
|
||||||
|
|
||||||
|
// FIR exception flow properties for inaccessible old mobile number
|
||||||
|
oldMobileNotAccessible = false;
|
||||||
|
firFileName = '';
|
||||||
|
firDocumentId: number | null = null;
|
||||||
|
uploadingFir = false;
|
||||||
|
|
||||||
oldEmailOtpId: number | null = null;
|
oldEmailOtpId: number | null = null;
|
||||||
newEmailOtpId: number | null = null;
|
newEmailOtpId: number | null = null;
|
||||||
oldEmailVerified = false;
|
oldEmailVerified = false;
|
||||||
@@ -215,11 +224,16 @@ export class EditClientComponent {
|
|||||||
return this.detectSensitiveFieldChanges(this.clientDataAndTemplate);
|
return this.detectSensitiveFieldChanges(this.clientDataAndTemplate);
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Whether every OTP block required by the fields that actually changed has been verified. */
|
/** Whether every OTP block required by the fields that actually changed has been verified (or FIR uploaded for lost mobile). */
|
||||||
get contactVerificationComplete(): boolean {
|
get contactVerificationComplete(): boolean {
|
||||||
const changes = this.contactChanges;
|
const changes = this.contactChanges;
|
||||||
if (changes.mobileChanged && !(this.oldMobileVerified && this.newMobileVerified)) {
|
if (changes.mobileChanged) {
|
||||||
return false;
|
const isOldSatisfied = this.oldMobileNotAccessible
|
||||||
|
? (this.firDocumentId != null)
|
||||||
|
: (this.oldMobileVerified);
|
||||||
|
if (!isOldSatisfied || !this.newMobileVerified) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
if (changes.emailChanged && !(this.oldEmailVerified && this.newEmailVerified)) {
|
if (changes.emailChanged && !(this.oldEmailVerified && this.newEmailVerified)) {
|
||||||
return false;
|
return false;
|
||||||
@@ -227,6 +241,41 @@ export class EditClientComponent {
|
|||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
onOldMobileNotAccessibleToggle(checked?: boolean): void {
|
||||||
|
if (checked !== undefined) {
|
||||||
|
this.oldMobileNotAccessible = checked;
|
||||||
|
}
|
||||||
|
if (this.oldMobileNotAccessible) {
|
||||||
|
this.oldMobileOtpId = null;
|
||||||
|
this.oldMobileVerified = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
onFirFileSelected(event: any): void {
|
||||||
|
const file: File = event.target?.files?.[0];
|
||||||
|
if (!file) return;
|
||||||
|
this.firFileName = file.name;
|
||||||
|
this.uploadingFir = true;
|
||||||
|
|
||||||
|
const formData = new FormData();
|
||||||
|
formData.append('file', file);
|
||||||
|
formData.append('name', 'FIR - Lost Mobile Number');
|
||||||
|
formData.append('description', 'FIR police complaint copy for lost mobile number verification exception');
|
||||||
|
|
||||||
|
this.clientsService.uploadClientDocument(this.clientId, formData).subscribe({
|
||||||
|
next: (res: any) => {
|
||||||
|
this.uploadingFir = false;
|
||||||
|
this.firDocumentId = res?.resourceId || res?.id || null;
|
||||||
|
this.snackBar.open('FIR document uploaded successfully', 'Close', { duration: 3000 });
|
||||||
|
},
|
||||||
|
error: () => {
|
||||||
|
this.uploadingFir = false;
|
||||||
|
this.firDocumentId = null;
|
||||||
|
this.snackBar.open('Failed to upload FIR document', 'Close', { duration: 4000 });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
onOldMobileOtpState(state: ContactOtpState): void {
|
onOldMobileOtpState(state: ContactOtpState): void {
|
||||||
this.oldMobileOtpId = state.otpId;
|
this.oldMobileOtpId = state.otpId;
|
||||||
this.oldMobileVerified = state.verified;
|
this.oldMobileVerified = state.verified;
|
||||||
@@ -254,6 +303,10 @@ export class EditClientComponent {
|
|||||||
this.newMobileOtpId = null;
|
this.newMobileOtpId = null;
|
||||||
this.oldMobileVerified = false;
|
this.oldMobileVerified = false;
|
||||||
this.newMobileVerified = false;
|
this.newMobileVerified = false;
|
||||||
|
this.oldMobileNotAccessible = false;
|
||||||
|
this.firFileName = '';
|
||||||
|
this.firDocumentId = null;
|
||||||
|
this.uploadingFir = false;
|
||||||
this.oldEmailOtpId = null;
|
this.oldEmailOtpId = null;
|
||||||
this.newEmailOtpId = null;
|
this.newEmailOtpId = null;
|
||||||
this.oldEmailVerified = false;
|
this.oldEmailVerified = false;
|
||||||
@@ -439,7 +492,11 @@ export class EditClientComponent {
|
|||||||
// email's when BOTH changed in the same save. See the class-level JSDoc above the OTP
|
// email's when BOTH changed in the same save. See the class-level JSDoc above the OTP
|
||||||
// state fields for the full naming rationale.
|
// state fields for the full naming rationale.
|
||||||
if (changedFields.mobileChanged) {
|
if (changedFields.mobileChanged) {
|
||||||
clientData.mobileOldOtpId = this.oldMobileOtpId;
|
if (this.oldMobileNotAccessible && this.firDocumentId) {
|
||||||
|
(clientData as any).oldMobileVerificationExceptionDocumentId = this.firDocumentId;
|
||||||
|
} else {
|
||||||
|
clientData.mobileOldOtpId = this.oldMobileOtpId;
|
||||||
|
}
|
||||||
clientData.mobileNewOtpId = this.newMobileOtpId;
|
clientData.mobileNewOtpId = this.newMobileOtpId;
|
||||||
}
|
}
|
||||||
if (changedFields.emailChanged) {
|
if (changedFields.emailChanged) {
|
||||||
@@ -630,25 +687,34 @@ export class EditClientComponent {
|
|||||||
|
|
||||||
private detectSensitiveFieldChanges(originalData: any) {
|
private detectSensitiveFieldChanges(originalData: any) {
|
||||||
const form = this.clientGeneralForm;
|
const form = this.clientGeneralForm;
|
||||||
// Trimmed so browser autofill adding invisible leading/trailing whitespace never reads as
|
if (!form || !originalData) {
|
||||||
// a "change" — the masked preview shown in the Verify Contact Change panel only reveals the
|
return {
|
||||||
// first couple of characters, so a whitespace-only difference is otherwise invisible to the
|
nameChanged: false,
|
||||||
// user and just looks like the panel appeared for no reason. These trimmed-but-original-case
|
mobileChanged: false,
|
||||||
// values are what get returned/sent as the actual OTP destination and saved value — only the
|
emailChanged: false,
|
||||||
// *comparison* (below) is further case-normalized for email, so we never silently rewrite the
|
dobChanged: false,
|
||||||
// customer's email casing into the saved record or the OTP destination.
|
oldName: '',
|
||||||
const newMobile = (form?.get('mobileNo')?.value || '').trim();
|
newName: '',
|
||||||
const newEmail = (form?.get('emailAddress')?.value || '').trim();
|
oldMobile: '',
|
||||||
const newDobRaw = form?.get('dateOfBirth')?.value;
|
newMobile: '',
|
||||||
// Local calendar date, NOT `.toISOString()` — that converts to UTC first, which silently
|
oldEmail: '',
|
||||||
// shifts the date by a day in any positive-UTC-offset timezone (e.g. India, UTC+5:30:
|
newEmail: '',
|
||||||
// midnight local on the 15th becomes 18:30 UTC on the 14th). That made this comparison
|
oldDob: '',
|
||||||
// report a DOB "change" — falsely triggering the Re-KYC dialog — even when the user never
|
newDob: ''
|
||||||
// touched the date field, purely because the Date object round-tripped through the picker.
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const mobileControl = form.get('mobileNo');
|
||||||
|
const emailControl = form.get('emailAddress');
|
||||||
|
|
||||||
|
// Trimmed so browser autofill adding invisible leading/trailing whitespace never reads as a change
|
||||||
|
const newMobile = (mobileControl?.value || '').toString().trim();
|
||||||
|
const newEmail = (emailControl?.value || '').toString().trim();
|
||||||
|
const newDobRaw = form.get('dateOfBirth')?.value;
|
||||||
const newDob = newDobRaw instanceof Date ? this.toLocalDateString(newDobRaw) : newDobRaw || '';
|
const newDob = newDobRaw instanceof Date ? this.toLocalDateString(newDobRaw) : newDobRaw || '';
|
||||||
|
|
||||||
const oldMobile = (originalData.mobileNo || '').trim();
|
const oldMobile = (originalData.mobileNo || '').toString().trim();
|
||||||
const oldEmail = (originalData.emailAddress || '').trim();
|
const oldEmail = (originalData.emailAddress || '').toString().trim();
|
||||||
const oldDobRaw = originalData.dateOfBirth;
|
const oldDobRaw = originalData.dateOfBirth;
|
||||||
const oldDob = Array.isArray(oldDobRaw)
|
const oldDob = Array.isArray(oldDobRaw)
|
||||||
? `${oldDobRaw[0]}-${String(oldDobRaw[1]).padStart(2, '0')}-${String(oldDobRaw[2]).padStart(2, '0')}`
|
? `${oldDobRaw[0]}-${String(oldDobRaw[1]).padStart(2, '0')}-${String(oldDobRaw[2]).padStart(2, '0')}`
|
||||||
@@ -669,10 +735,10 @@ export class EditClientComponent {
|
|||||||
).trim();
|
).trim();
|
||||||
|
|
||||||
const newName = buildName({
|
const newName = buildName({
|
||||||
firstname: form?.get('firstname')?.value || '',
|
firstname: form.get('firstname')?.value || '',
|
||||||
middlename: form?.get('middlename')?.value || '',
|
middlename: form.get('middlename')?.value || '',
|
||||||
lastname: form?.get('lastname')?.value || '',
|
lastname: form.get('lastname')?.value || '',
|
||||||
fullname: form?.get('fullname')?.value || ''
|
fullname: form.get('fullname')?.value || ''
|
||||||
});
|
});
|
||||||
const oldName = buildName({
|
const oldName = buildName({
|
||||||
firstname: originalData.firstname || '',
|
firstname: originalData.firstname || '',
|
||||||
@@ -681,12 +747,19 @@ export class EditClientComponent {
|
|||||||
fullname: originalData.fullname || ''
|
fullname: originalData.fullname || ''
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Normalize phone numbers by stripping non-digit characters (+91 9009009009 -> 9009009009)
|
||||||
|
const normPhone = (val: string) => val.replace(/\D/g, '');
|
||||||
|
const newMobileNorm = normPhone(newMobile);
|
||||||
|
const oldMobileNorm = normPhone(oldMobile);
|
||||||
|
|
||||||
|
// Mobile/Email are considered changed ONLY when the control has been edited (dirty) AND normalized values differ
|
||||||
|
const mobileChanged = !!oldMobileNorm && !!newMobileNorm && !!mobileControl?.dirty && (newMobileNorm !== oldMobileNorm);
|
||||||
|
const emailChanged = !!oldEmail && !!newEmail && !!emailControl?.dirty && (newEmail.toLowerCase() !== oldEmail.toLowerCase());
|
||||||
|
|
||||||
return {
|
return {
|
||||||
nameChanged: newName !== oldName,
|
nameChanged: newName !== oldName,
|
||||||
mobileChanged: newMobile !== oldMobile,
|
mobileChanged,
|
||||||
// Case-insensitive: emails are not case-sensitive in practice, and comparing raw case
|
emailChanged,
|
||||||
// would flag a change (surfacing the dual-OTP panel) even when nothing meaningful changed.
|
|
||||||
emailChanged: newEmail.toLowerCase() !== oldEmail.toLowerCase(),
|
|
||||||
dobChanged: newDob !== oldDob,
|
dobChanged: newDob !== oldDob,
|
||||||
oldName,
|
oldName,
|
||||||
newName,
|
newName,
|
||||||
|
|||||||
@@ -384,6 +384,14 @@ export class AuthenticationService {
|
|||||||
return JSON.parse(this.storage.getItem(this.credentialsStorageKey));
|
return JSON.parse(this.storage.getItem(this.credentialsStorageKey));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Returns true when the logged-in user is at Head Office rather than a branch login. */
|
||||||
|
isHeadOfficeUser(): boolean {
|
||||||
|
const creds = this.getCredentials();
|
||||||
|
if (!creds) return false;
|
||||||
|
if (creds.officeId === 1) return true;
|
||||||
|
return (creds.officeName || '').toLowerCase().includes('head office');
|
||||||
|
}
|
||||||
|
|
||||||
/** Returns true when the logged-in user has the "Clerk" role. */
|
/** Returns true when the logged-in user has the "Clerk" role. */
|
||||||
isClerkUser(): boolean {
|
isClerkUser(): boolean {
|
||||||
const creds = this.getCredentials();
|
const creds = this.getCredentials();
|
||||||
|
|||||||
@@ -0,0 +1,98 @@
|
|||||||
|
/**
|
||||||
|
* 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 { AuthenticationService } from './authentication/authentication.service';
|
||||||
|
import { GL_BRANCHES, GlBranch } from 'app/gl-reports/gl-reports-data';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Resolves what branch data the logged-in user is allowed to see.
|
||||||
|
*
|
||||||
|
* Head Office sees every branch and can drill into any one of them; a branch login is
|
||||||
|
* locked to its own book. Previously this logic was copy-pasted into the Cash/Clearing
|
||||||
|
* Summary and the NPA report with slightly different branch code lists; this service is
|
||||||
|
* the single place it lives so new reports cannot drift.
|
||||||
|
*
|
||||||
|
* The logged-in office is matched to a branch by name, since session credentials carry
|
||||||
|
* only officeId and officeName. Office names are seeded to match the GL branch names
|
||||||
|
* exactly (see Liquibase 0442).
|
||||||
|
*/
|
||||||
|
@Injectable({ providedIn: 'root' })
|
||||||
|
export class BranchScopeService {
|
||||||
|
private auth = inject(AuthenticationService);
|
||||||
|
|
||||||
|
/** Sentinel used when a branch login cannot be matched to a known branch. */
|
||||||
|
static readonly UNRESOLVED = '__unresolved__';
|
||||||
|
|
||||||
|
get isHeadOffice(): boolean {
|
||||||
|
return this.auth.isHeadOfficeUser();
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The office name on the session, for labelling the scope in report headers. */
|
||||||
|
get officeName(): string {
|
||||||
|
return this.auth.getCredentials()?.officeName || 'Branch Office';
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Branch code the user is confined to, or null for Head Office.
|
||||||
|
*
|
||||||
|
* Deliberately returns the UNRESOLVED sentinel rather than falling back to the first
|
||||||
|
* branch when the office cannot be matched: showing an unrecognised branch someone
|
||||||
|
* else's figures would be worse than showing them nothing.
|
||||||
|
*/
|
||||||
|
get ownBranchCode(): string | null {
|
||||||
|
if (this.isHeadOffice) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
const name = this.officeName.toLowerCase().trim();
|
||||||
|
const match = GL_BRANCHES.find((b) => b.code !== 'HO' && name === b.name.toLowerCase());
|
||||||
|
// Fall back to a substring match for offices named e.g. "Andheri (E) Branch".
|
||||||
|
const loose = match ?? GL_BRANCHES.find((b) => b.code !== 'HO' && name.includes(b.name.toLowerCase()));
|
||||||
|
return loose?.code ?? BranchScopeService.UNRESOLVED;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** True when the user may choose which branch to view. */
|
||||||
|
get canSelectBranch(): boolean {
|
||||||
|
return this.isHeadOffice;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Branches offered in a selector — everything for Head Office, nothing for a branch login. */
|
||||||
|
get selectableBranches(): GlBranch[] {
|
||||||
|
return this.isHeadOffice ? GL_BRANCHES : [];
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Branch code a report should start on. '' means all branches consolidated. */
|
||||||
|
get defaultBranchCode(): string {
|
||||||
|
return this.isHeadOffice ? '' : (this.ownBranchCode ?? '');
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Forces a requested branch code into what the user is actually allowed to see.
|
||||||
|
* Call this wherever a branch code reaches a data query, so a stale or hand-edited
|
||||||
|
* selection cannot widen the user's scope.
|
||||||
|
*/
|
||||||
|
enforce(requested: string): string {
|
||||||
|
return this.isHeadOffice ? requested : (this.ownBranchCode ?? BranchScopeService.UNRESOLVED);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** True when a branch login's office matched no known branch, so it sees no data. */
|
||||||
|
get isUnresolved(): boolean {
|
||||||
|
return this.ownBranchCode === BranchScopeService.UNRESOLVED;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Human label for the current scope, for report headers. */
|
||||||
|
scopeLabel(selectedCode: string): string {
|
||||||
|
if (!this.isHeadOffice) {
|
||||||
|
return this.officeName;
|
||||||
|
}
|
||||||
|
if (!selectedCode) {
|
||||||
|
return 'All Branches (Consolidated)';
|
||||||
|
}
|
||||||
|
return GL_BRANCHES.find((b) => b.code === selectedCode)?.name ?? selectedCode;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -112,6 +112,17 @@
|
|||||||
</svg>
|
</svg>
|
||||||
My Share Requests
|
My Share Requests
|
||||||
</a>
|
</a>
|
||||||
|
<a
|
||||||
|
class="cashier-nav__item cashier-nav__item--sec"
|
||||||
|
[class.cashier-nav__item--active]="currentUrl.includes('/clerk/share-closure-request')"
|
||||||
|
[routerLink]="['/clerk/share-closure-requests']"
|
||||||
|
>
|
||||||
|
<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
||||||
|
<rect x="4" y="11" width="16" height="9" rx="2" />
|
||||||
|
<path d="M8 11V7a4 4 0 0 1 8 0v4" />
|
||||||
|
</svg>
|
||||||
|
My Share Closures
|
||||||
|
</a>
|
||||||
@if (clerkAccountLoaded) {
|
@if (clerkAccountLoaded) {
|
||||||
<div class="cashier-nav__item-wrap">
|
<div class="cashier-nav__item-wrap">
|
||||||
<a
|
<a
|
||||||
|
|||||||
@@ -0,0 +1,723 @@
|
|||||||
|
/**
|
||||||
|
* 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/.
|
||||||
|
*/
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Data layer for the General Ledger report suite.
|
||||||
|
*
|
||||||
|
* Rather than hard-coding seven finished reports, this file holds a chart of
|
||||||
|
* accounts plus a generated ledger of double-entry postings. Every report is
|
||||||
|
* then computed from those postings, which is what makes the day / month /
|
||||||
|
* branch filters genuinely recompute instead of just re-labelling a static table.
|
||||||
|
*
|
||||||
|
* The generator is deterministic — a fixed-seed LCG, never Math.random — so the
|
||||||
|
* figures are identical on every reload and across users. Amounts are in rupees.
|
||||||
|
*/
|
||||||
|
|
||||||
|
export type GlAccountType = 'ASSET' | 'LIABILITY' | 'INCOME' | 'EXPENSE' | 'CONTINGENT';
|
||||||
|
|
||||||
|
export interface GlAccount {
|
||||||
|
code: string;
|
||||||
|
name: string;
|
||||||
|
type: GlAccountType;
|
||||||
|
/** Balance-sheet / P&L grouping used as a sub-heading in the reports. */
|
||||||
|
group: string;
|
||||||
|
/** Included in the Misc Expense Ledger report. */
|
||||||
|
miscExpense?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface GlBranch {
|
||||||
|
code: string;
|
||||||
|
name: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** One side of a double-entry voucher. */
|
||||||
|
export interface GlPosting {
|
||||||
|
/** ISO yyyy-MM-dd — the posting (value) date. */
|
||||||
|
date: string;
|
||||||
|
branchCode: string;
|
||||||
|
accountCode: string;
|
||||||
|
debit: number;
|
||||||
|
credit: number;
|
||||||
|
voucherNo: string;
|
||||||
|
narration: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const GL_BRANCHES: GlBranch[] = [
|
||||||
|
{ code: 'HO', name: 'Head Office' },
|
||||||
|
{ code: '0002', name: 'Andheri (E)' },
|
||||||
|
{ code: '0003', name: 'Bandra (W)' },
|
||||||
|
{ code: '0004', name: 'Borivali' },
|
||||||
|
{ code: '0005', name: 'Dadar' },
|
||||||
|
{ code: '0006', name: 'Thane' }
|
||||||
|
];
|
||||||
|
|
||||||
|
export const GL_ACCOUNTS: GlAccount[] = [
|
||||||
|
// ── Assets ────────────────────────────────────────────────────────────────
|
||||||
|
{ code: '110101', name: 'Cash in Hand', type: 'ASSET', group: 'Cash & Bank Balances' },
|
||||||
|
{ code: '110102', name: 'Balance with RBI', type: 'ASSET', group: 'Cash & Bank Balances' },
|
||||||
|
{ code: '110103', name: 'Balance with Other Banks — Current', type: 'ASSET', group: 'Cash & Bank Balances' },
|
||||||
|
{ code: '110104', name: 'Balance with Other Banks — Fixed', type: 'ASSET', group: 'Cash & Bank Balances' },
|
||||||
|
{ code: '120101', name: 'SLR Investments — Govt Securities', type: 'ASSET', group: 'Investments' },
|
||||||
|
{ code: '120102', name: 'SLR Investments — State Govt Securities', type: 'ASSET', group: 'Investments' },
|
||||||
|
{ code: '120201', name: 'Non-SLR Investments — Bonds', type: 'ASSET', group: 'Investments' },
|
||||||
|
{ code: '120202', name: 'Non-SLR Investments — Mutual Funds', type: 'ASSET', group: 'Investments' },
|
||||||
|
{ code: '130101', name: 'Cash Credit & Overdrafts', type: 'ASSET', group: 'Loans & Advances' },
|
||||||
|
{ code: '130102', name: 'Term Loans — Short Term', type: 'ASSET', group: 'Loans & Advances' },
|
||||||
|
{ code: '130103', name: 'Term Loans — Medium Term', type: 'ASSET', group: 'Loans & Advances' },
|
||||||
|
{ code: '130104', name: 'Term Loans — Long Term', type: 'ASSET', group: 'Loans & Advances' },
|
||||||
|
{ code: '130105', name: 'Housing Loans', type: 'ASSET', group: 'Loans & Advances' },
|
||||||
|
{ code: '130106', name: 'Gold Loans', type: 'ASSET', group: 'Loans & Advances' },
|
||||||
|
{ code: '140101', name: 'Interest Receivable', type: 'ASSET', group: 'Other Assets' },
|
||||||
|
{ code: '140102', name: 'Premises (net of depreciation)', type: 'ASSET', group: 'Fixed Assets' },
|
||||||
|
{ code: '140103', name: 'Furniture & Fixtures (net)', type: 'ASSET', group: 'Fixed Assets' },
|
||||||
|
{ code: '140104', name: 'Sundry Debtors', type: 'ASSET', group: 'Other Assets' },
|
||||||
|
{ code: '140105', name: 'Advance Tax & TDS', type: 'ASSET', group: 'Other Assets' },
|
||||||
|
{ code: '140199', name: 'Opening Balance Suspense (unreconciled)', type: 'ASSET', group: 'Other Assets' },
|
||||||
|
|
||||||
|
// ── Liabilities & Capital ─────────────────────────────────────────────────
|
||||||
|
{ code: '210101', name: 'Share Capital — Individuals', type: 'LIABILITY', group: 'Capital & Reserves' },
|
||||||
|
{ code: '210201', name: 'Statutory Reserve Fund', type: 'LIABILITY', group: 'Capital & Reserves' },
|
||||||
|
{ code: '210202', name: 'Building Fund', type: 'LIABILITY', group: 'Capital & Reserves' },
|
||||||
|
{ code: '210203', name: 'Investment Depreciation Reserve', type: 'LIABILITY', group: 'Capital & Reserves' },
|
||||||
|
{ code: '210204', name: 'BDDR / Special BDDR', type: 'LIABILITY', group: 'Capital & Reserves' },
|
||||||
|
{ code: '220101', name: 'Savings Bank Deposits', type: 'LIABILITY', group: 'Deposits' },
|
||||||
|
{ code: '220102', name: 'Current Deposits', type: 'LIABILITY', group: 'Deposits' },
|
||||||
|
{ code: '220103', name: 'Term Deposits', type: 'LIABILITY', group: 'Deposits' },
|
||||||
|
{ code: '220104', name: 'Recurring Deposits', type: 'LIABILITY', group: 'Deposits' },
|
||||||
|
{ code: '230101', name: 'Interest Payable on Deposits', type: 'LIABILITY', group: 'Other Liabilities' },
|
||||||
|
{ code: '230102', name: 'Bills Payable', type: 'LIABILITY', group: 'Other Liabilities' },
|
||||||
|
{ code: '230103', name: 'Sundry Creditors', type: 'LIABILITY', group: 'Other Liabilities' },
|
||||||
|
{ code: '230104', name: 'TDS / GST Payable', type: 'LIABILITY', group: 'Other Liabilities' },
|
||||||
|
{ code: '230105', name: 'Overdue Interest Reserve', type: 'LIABILITY', group: 'Other Liabilities' },
|
||||||
|
|
||||||
|
// ── Income ────────────────────────────────────────────────────────────────
|
||||||
|
{ code: '310101', name: 'Interest on Loans & Advances', type: 'INCOME', group: 'Interest Income' },
|
||||||
|
{ code: '310102', name: 'Interest on Investments', type: 'INCOME', group: 'Interest Income' },
|
||||||
|
{ code: '310103', name: 'Interest on Inter-bank Deposits', type: 'INCOME', group: 'Interest Income' },
|
||||||
|
{ code: '320101', name: 'Commission & Exchange', type: 'INCOME', group: 'Other Income' },
|
||||||
|
{ code: '320102', name: 'Locker Rent', type: 'INCOME', group: 'Other Income' },
|
||||||
|
{ code: '320103', name: 'Processing Fees', type: 'INCOME', group: 'Other Income' },
|
||||||
|
{ code: '320104', name: 'Profit on Sale of Securities', type: 'INCOME', group: 'Other Income' },
|
||||||
|
{ code: '320105', name: 'Miscellaneous Income', type: 'INCOME', group: 'Other Income' },
|
||||||
|
|
||||||
|
// ── Expenditure ───────────────────────────────────────────────────────────
|
||||||
|
{ code: '410101', name: 'Interest on Savings Deposits', type: 'EXPENSE', group: 'Interest Expended' },
|
||||||
|
{ code: '410102', name: 'Interest on Term Deposits', type: 'EXPENSE', group: 'Interest Expended' },
|
||||||
|
{ code: '410103', name: 'Interest on Borrowings', type: 'EXPENSE', group: 'Interest Expended' },
|
||||||
|
{ code: '420101', name: 'Salaries & Allowances', type: 'EXPENSE', group: 'Operating Expenses' },
|
||||||
|
{ code: '420102', name: "Directors' Fees", type: 'EXPENSE', group: 'Operating Expenses' },
|
||||||
|
{ code: '420103', name: "Auditors' Fees", type: 'EXPENSE', group: 'Operating Expenses' },
|
||||||
|
{ code: '420104', name: 'Rent, Taxes & Lighting', type: 'EXPENSE', group: 'Operating Expenses' },
|
||||||
|
{ code: '420105', name: 'Depreciation on Bank Property', type: 'EXPENSE', group: 'Operating Expenses' },
|
||||||
|
{
|
||||||
|
code: '430101',
|
||||||
|
name: 'Printing & Stationery',
|
||||||
|
type: 'EXPENSE',
|
||||||
|
group: 'Miscellaneous Expenses',
|
||||||
|
miscExpense: true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
code: '430102',
|
||||||
|
name: 'Postage, Telegram & Telephone',
|
||||||
|
type: 'EXPENSE',
|
||||||
|
group: 'Miscellaneous Expenses',
|
||||||
|
miscExpense: true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
code: '430103',
|
||||||
|
name: 'Advertisement & Publicity',
|
||||||
|
type: 'EXPENSE',
|
||||||
|
group: 'Miscellaneous Expenses',
|
||||||
|
miscExpense: true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
code: '430104',
|
||||||
|
name: 'Repairs & Maintenance',
|
||||||
|
type: 'EXPENSE',
|
||||||
|
group: 'Miscellaneous Expenses',
|
||||||
|
miscExpense: true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
code: '430105',
|
||||||
|
name: 'Travelling & Conveyance',
|
||||||
|
type: 'EXPENSE',
|
||||||
|
group: 'Miscellaneous Expenses',
|
||||||
|
miscExpense: true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
code: '430106',
|
||||||
|
name: 'Computer & Software Expenses',
|
||||||
|
type: 'EXPENSE',
|
||||||
|
group: 'Miscellaneous Expenses',
|
||||||
|
miscExpense: true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
code: '430107',
|
||||||
|
name: 'Legal & Professional Charges',
|
||||||
|
type: 'EXPENSE',
|
||||||
|
group: 'Miscellaneous Expenses',
|
||||||
|
miscExpense: true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
code: '430108',
|
||||||
|
name: 'Insurance (DICGC & Other)',
|
||||||
|
type: 'EXPENSE',
|
||||||
|
group: 'Miscellaneous Expenses',
|
||||||
|
miscExpense: true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
code: '430109',
|
||||||
|
name: 'Bank Charges & Commission Paid',
|
||||||
|
type: 'EXPENSE',
|
||||||
|
group: 'Miscellaneous Expenses',
|
||||||
|
miscExpense: true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
code: '430110',
|
||||||
|
name: 'AGM & Meeting Expenses',
|
||||||
|
type: 'EXPENSE',
|
||||||
|
group: 'Miscellaneous Expenses',
|
||||||
|
miscExpense: true
|
||||||
|
},
|
||||||
|
{ code: '430111', name: 'Electricity Charges', type: 'EXPENSE', group: 'Miscellaneous Expenses', miscExpense: true },
|
||||||
|
{
|
||||||
|
code: '430112',
|
||||||
|
name: 'Other Miscellaneous Expenses',
|
||||||
|
type: 'EXPENSE',
|
||||||
|
group: 'Miscellaneous Expenses',
|
||||||
|
miscExpense: true
|
||||||
|
},
|
||||||
|
|
||||||
|
// ── Contingent (memoranda, not part of the balance sheet totals) ──────────
|
||||||
|
{ code: '910101', name: 'Guarantees Issued — Financial', type: 'CONTINGENT', group: 'Guarantees' },
|
||||||
|
{ code: '910102', name: 'Guarantees Issued — Performance', type: 'CONTINGENT', group: 'Guarantees' },
|
||||||
|
{ code: '910103', name: 'Letters of Credit Outstanding', type: 'CONTINGENT', group: 'Letters of Credit' },
|
||||||
|
{ code: '910104', name: 'Bills for Collection', type: 'CONTINGENT', group: 'Bills & Claims' },
|
||||||
|
{ code: '910105', name: 'Claims Against Bank Not Acknowledged as Debt', type: 'CONTINGENT', group: 'Bills & Claims' },
|
||||||
|
{ code: '910106', name: 'Forward Exchange Contracts', type: 'CONTINGENT', group: 'Other Commitments' },
|
||||||
|
{ code: '910107', name: 'Capital Commitments Not Provided For', type: 'CONTINGENT', group: 'Other Commitments' }
|
||||||
|
];
|
||||||
|
|
||||||
|
/** Financial year covered by the generated ledger. */
|
||||||
|
export const GL_PERIOD = {
|
||||||
|
/** FY 2026-27 opens here. */
|
||||||
|
fyStart: '2026-04-01',
|
||||||
|
/** Postings run to the day before the app's "today" (01 Aug 2026). */
|
||||||
|
fyEnd: '2026-07-31',
|
||||||
|
label: 'FY 2026-27'
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Opening balances as at 01-Apr-2026, keyed by account code. Positive means the
|
||||||
|
* account opens on its natural side (debit for assets/expenses, credit for the
|
||||||
|
* rest). Head Office carries the investments and reserves; branches carry the
|
||||||
|
* customer-facing book.
|
||||||
|
*/
|
||||||
|
const OPENING_BALANCES: Record<string, number> = {
|
||||||
|
'110101': 9_996_000,
|
||||||
|
'110102': 12_400_000,
|
||||||
|
'110103': 13_667_000,
|
||||||
|
// Surplus deposits parked with correspondent banks. Sized so the opening
|
||||||
|
// balance sheet tallies exactly — see the assertion in glPostings().
|
||||||
|
'110104': 54_206_000,
|
||||||
|
'120101': 63_839_000,
|
||||||
|
'120102': 147_895_000,
|
||||||
|
'120201': 22_500_000,
|
||||||
|
'120202': 24_234_000,
|
||||||
|
'130101': 166_614_000,
|
||||||
|
'130102': 125_082_000,
|
||||||
|
'130103': 70_236_000,
|
||||||
|
'130104': 29_356_000,
|
||||||
|
'130105': 10_241_000,
|
||||||
|
'130106': 8_450_000,
|
||||||
|
'140101': 33_541_000,
|
||||||
|
'140102': 3_185_000,
|
||||||
|
'140103': 1_763_000,
|
||||||
|
'140104': 1_240_000,
|
||||||
|
'140105': 657_000,
|
||||||
|
'210101': 27_278_000,
|
||||||
|
'210201': 31_149_000,
|
||||||
|
'210202': 13_642_000,
|
||||||
|
'210203': 3_750_000,
|
||||||
|
'210204': 24_341_000,
|
||||||
|
'220101': 208_116_000,
|
||||||
|
'220102': 19_592_000,
|
||||||
|
'220103': 406_601_000,
|
||||||
|
'220104': 24_880_000,
|
||||||
|
'230101': 731_000,
|
||||||
|
'230102': 291_000,
|
||||||
|
'230103': 5_515_000,
|
||||||
|
'230104': 1_120_000,
|
||||||
|
'230105': 32_096_000,
|
||||||
|
'910101': 1_450_000,
|
||||||
|
'910102': 980_000,
|
||||||
|
'910103': 620_000,
|
||||||
|
'910104': 1_180_000,
|
||||||
|
'910105': 2_638_000,
|
||||||
|
'910106': 0,
|
||||||
|
'910107': 340_000
|
||||||
|
};
|
||||||
|
|
||||||
|
/** Deterministic linear congruential generator — same figures on every load. */
|
||||||
|
function lcg(seed: number): () => number {
|
||||||
|
let s = seed >>> 0;
|
||||||
|
return () => {
|
||||||
|
s = (Math.imul(s, 1664525) + 1013904223) >>> 0;
|
||||||
|
return s / 4294967296;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function isoDate(d: Date): string {
|
||||||
|
const y = d.getUTCFullYear();
|
||||||
|
const m = `${d.getUTCMonth() + 1}`.padStart(2, '0');
|
||||||
|
const day = `${d.getUTCDate()}`.padStart(2, '0');
|
||||||
|
return `${y}-${m}-${day}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Every posting date in the ledger period, Sundays excluded (bank holiday). */
|
||||||
|
export function ledgerDates(): string[] {
|
||||||
|
const out: string[] = [];
|
||||||
|
const cur = new Date(`${GL_PERIOD.fyStart}T00:00:00Z`);
|
||||||
|
const end = new Date(`${GL_PERIOD.fyEnd}T00:00:00Z`);
|
||||||
|
while (cur <= end) {
|
||||||
|
if (cur.getUTCDay() !== 0) {
|
||||||
|
out.push(isoDate(cur));
|
||||||
|
}
|
||||||
|
cur.setUTCDate(cur.getUTCDate() + 1);
|
||||||
|
}
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Templates for the vouchers the generator posts each day. */
|
||||||
|
const VOUCHER_TEMPLATES: {
|
||||||
|
debit: string;
|
||||||
|
credit: string;
|
||||||
|
narration: string;
|
||||||
|
/** Rough rupee scale of the voucher. */
|
||||||
|
scale: number;
|
||||||
|
/** Chance the voucher is raised on any given branch-day. */
|
||||||
|
frequency: number;
|
||||||
|
}[] = [
|
||||||
|
{ debit: '110101', credit: '220101', narration: 'Cash deposit — savings accounts', scale: 900_000, frequency: 1 },
|
||||||
|
{ debit: '220101', credit: '110101', narration: 'Cash withdrawal — savings accounts', scale: 780_000, frequency: 1 },
|
||||||
|
{ debit: '110101', credit: '220103', narration: 'Term deposit received', scale: 1_400_000, frequency: 0.75 },
|
||||||
|
{ debit: '220103', credit: '110101', narration: 'Term deposit matured and paid', scale: 1_150_000, frequency: 0.6 },
|
||||||
|
{ debit: '110101', credit: '220102', narration: 'Current account deposit', scale: 620_000, frequency: 0.8 },
|
||||||
|
{ debit: '220102', credit: '110101', narration: 'Current account withdrawal', scale: 560_000, frequency: 0.8 },
|
||||||
|
{ debit: '110101', credit: '220104', narration: 'Recurring deposit instalment', scale: 180_000, frequency: 0.7 },
|
||||||
|
{ debit: '130101', credit: '110101', narration: 'Cash credit disbursed', scale: 1_250_000, frequency: 0.55 },
|
||||||
|
{ debit: '110101', credit: '130101', narration: 'Cash credit repayment', scale: 1_100_000, frequency: 0.6 },
|
||||||
|
{ debit: '130102', credit: '110101', narration: 'Short term loan disbursed', scale: 850_000, frequency: 0.4 },
|
||||||
|
{ debit: '110101', credit: '130102', narration: 'Short term loan repayment', scale: 760_000, frequency: 0.5 },
|
||||||
|
{ debit: '130103', credit: '110101', narration: 'Medium term loan disbursed', scale: 640_000, frequency: 0.3 },
|
||||||
|
{ debit: '110101', credit: '130104', narration: 'Long term loan repayment', scale: 410_000, frequency: 0.35 },
|
||||||
|
{ debit: '130105', credit: '110101', narration: 'Housing loan disbursed', scale: 520_000, frequency: 0.2 },
|
||||||
|
{ debit: '130106', credit: '110101', narration: 'Gold loan disbursed', scale: 260_000, frequency: 0.4 },
|
||||||
|
// Interest on advances is the dominant earning line — sized so the bank runs a
|
||||||
|
// ~13% net margin rather than the loss the first-cut figures produced.
|
||||||
|
{ debit: '110101', credit: '310101', narration: 'Interest recovered on advances', scale: 430_000, frequency: 1 },
|
||||||
|
{ debit: '140101', credit: '310102', narration: 'Interest accrued on investments', scale: 148_000, frequency: 0.5 },
|
||||||
|
{ debit: '110103', credit: '310103', narration: 'Interest on inter-bank deposits', scale: 42_000, frequency: 0.25 },
|
||||||
|
{ debit: '110101', credit: '320101', narration: 'Commission and exchange collected', scale: 26_000, frequency: 0.9 },
|
||||||
|
{ debit: '110101', credit: '320102', narration: 'Locker rent collected', scale: 9_000, frequency: 0.3 },
|
||||||
|
{ debit: '110101', credit: '320103', narration: 'Loan processing fees', scale: 18_000, frequency: 0.5 },
|
||||||
|
{ debit: '110101', credit: '320105', narration: 'Miscellaneous income', scale: 7_500, frequency: 0.4 },
|
||||||
|
{
|
||||||
|
debit: '410101',
|
||||||
|
credit: '230101',
|
||||||
|
narration: 'Interest provided on savings deposits',
|
||||||
|
scale: 128_000,
|
||||||
|
frequency: 0.8
|
||||||
|
},
|
||||||
|
{
|
||||||
|
debit: '410102',
|
||||||
|
credit: '230101',
|
||||||
|
narration: 'Interest provided on term deposits',
|
||||||
|
scale: 265_000,
|
||||||
|
frequency: 0.9
|
||||||
|
},
|
||||||
|
{ debit: '420101', credit: '110101', narration: 'Salaries and allowances paid', scale: 210_000, frequency: 0.35 },
|
||||||
|
{ debit: '420104', credit: '110101', narration: 'Rent, taxes and lighting', scale: 46_000, frequency: 0.3 },
|
||||||
|
{ debit: '430101', credit: '110101', narration: 'Printing and stationery purchased', scale: 9_400, frequency: 0.3 },
|
||||||
|
{ debit: '430102', credit: '110101', narration: 'Postage and telephone charges', scale: 6_200, frequency: 0.35 },
|
||||||
|
{ debit: '430103', credit: '110101', narration: 'Advertisement and publicity', scale: 12_500, frequency: 0.15 },
|
||||||
|
{ debit: '430104', credit: '110101', narration: 'Repairs and maintenance', scale: 15_800, frequency: 0.25 },
|
||||||
|
{ debit: '430105', credit: '110101', narration: 'Travelling and conveyance', scale: 7_100, frequency: 0.3 },
|
||||||
|
{ debit: '430106', credit: '110101', narration: 'Computer and software expenses', scale: 22_400, frequency: 0.2 },
|
||||||
|
{ debit: '430107', credit: '110101', narration: 'Legal and professional charges', scale: 11_300, frequency: 0.15 },
|
||||||
|
{ debit: '430109', credit: '110101', narration: 'Bank charges and commission paid', scale: 4_800, frequency: 0.3 },
|
||||||
|
{ debit: '430111', credit: '110101', narration: 'Electricity charges', scale: 13_600, frequency: 0.25 },
|
||||||
|
{ debit: '430112', credit: '110101', narration: 'Other miscellaneous expenses', scale: 8_900, frequency: 0.3 },
|
||||||
|
{
|
||||||
|
debit: '110102',
|
||||||
|
credit: '110101',
|
||||||
|
narration: 'Cash remitted to RBI currency chest',
|
||||||
|
scale: 1_800_000,
|
||||||
|
frequency: 0.2
|
||||||
|
},
|
||||||
|
{
|
||||||
|
debit: '110101',
|
||||||
|
credit: '110103',
|
||||||
|
narration: 'Funds drawn from correspondent bank',
|
||||||
|
scale: 1_400_000,
|
||||||
|
frequency: 0.2
|
||||||
|
}
|
||||||
|
];
|
||||||
|
|
||||||
|
/** Head-Office-only vouchers — investments, reserves and centralised charges. */
|
||||||
|
const HO_TEMPLATES: typeof VOUCHER_TEMPLATES = [
|
||||||
|
{ debit: '120101', credit: '110102', narration: 'Purchase of Govt securities', scale: 2_400_000, frequency: 0.12 },
|
||||||
|
{
|
||||||
|
debit: '120102',
|
||||||
|
credit: '110102',
|
||||||
|
narration: 'Purchase of State Govt securities',
|
||||||
|
scale: 1_900_000,
|
||||||
|
frequency: 0.12
|
||||||
|
},
|
||||||
|
{ debit: '110102', credit: '320104', narration: 'Profit on sale of securities', scale: 78_000, frequency: 0.1 },
|
||||||
|
{ debit: '420102', credit: '110101', narration: "Directors' sitting fees", scale: 32_000, frequency: 0.08 },
|
||||||
|
{ debit: '420103', credit: '110101', narration: "Statutory auditors' fees", scale: 45_000, frequency: 0.06 },
|
||||||
|
{ debit: '420105', credit: '140103', narration: 'Depreciation charged on assets', scale: 64_000, frequency: 0.12 },
|
||||||
|
{ debit: '430108', credit: '110101', narration: 'DICGC premium and other insurance', scale: 38_000, frequency: 0.1 },
|
||||||
|
{ debit: '430110', credit: '110101', narration: 'AGM and meeting expenses', scale: 26_000, frequency: 0.06 },
|
||||||
|
{ debit: '410103', credit: '230101', narration: 'Interest on borrowings', scale: 54_000, frequency: 0.1 },
|
||||||
|
{ debit: '230104', credit: '110102', narration: 'TDS and GST remitted', scale: 145_000, frequency: 0.15 }
|
||||||
|
];
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Contingent-account movements are memoranda, each posted single-sided.
|
||||||
|
* Guarantees, LCs and collection bills are raised at the counter, so these run
|
||||||
|
* at every branch — not just Head Office.
|
||||||
|
*/
|
||||||
|
const CONTINGENT_TEMPLATES: { account: string; narration: string; scale: number; frequency: number }[] = [
|
||||||
|
{ account: '910101', narration: 'Financial guarantee issued', scale: 320_000, frequency: 0.1 },
|
||||||
|
{ account: '910102', narration: 'Performance guarantee issued', scale: 240_000, frequency: 0.08 },
|
||||||
|
{ account: '910103', narration: 'Letter of credit opened', scale: 180_000, frequency: 0.07 },
|
||||||
|
{ account: '910104', narration: 'Bills lodged for collection', scale: 95_000, frequency: 0.12 },
|
||||||
|
{ account: '910105', narration: 'Claim lodged against the bank', scale: 140_000, frequency: 0.03 },
|
||||||
|
{ account: '910107', narration: 'Capital commitment contracted', scale: 85_000, frequency: 0.04 }
|
||||||
|
];
|
||||||
|
|
||||||
|
/**
|
||||||
|
* How the opening contingent book is spread across the network. Head Office
|
||||||
|
* carries the capital commitments and large claims; the branches carry the
|
||||||
|
* counter-issued guarantees. Weights are applied per account and the rounding
|
||||||
|
* remainder is absorbed by Head Office so each account's total stays exact.
|
||||||
|
*/
|
||||||
|
const CONTINGENT_BRANCH_WEIGHTS: Record<string, number> = {
|
||||||
|
HO: 0.2,
|
||||||
|
'0002': 0.18,
|
||||||
|
'0003': 0.16,
|
||||||
|
'0004': 0.16,
|
||||||
|
'0005': 0.15,
|
||||||
|
'0006': 0.15
|
||||||
|
};
|
||||||
|
|
||||||
|
let cachedPostings: GlPosting[] | null = null;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The full generated ledger. Built once and memoised — every report reads from
|
||||||
|
* this array, so the reports can never disagree with one another.
|
||||||
|
*/
|
||||||
|
export function glPostings(): GlPosting[] {
|
||||||
|
if (cachedPostings) {
|
||||||
|
return cachedPostings;
|
||||||
|
}
|
||||||
|
const rand = lcg(20260401);
|
||||||
|
const out: GlPosting[] = [];
|
||||||
|
const dates = ledgerDates();
|
||||||
|
let voucherSeq = 1;
|
||||||
|
|
||||||
|
// Opening entries, posted at Head Office on day one so the books start in balance.
|
||||||
|
const openingVoucher = `OB/${GL_PERIOD.label.replace(/\s/g, '')}`;
|
||||||
|
let openingDebit = 0;
|
||||||
|
let openingCredit = 0;
|
||||||
|
|
||||||
|
for (const [
|
||||||
|
code,
|
||||||
|
amount
|
||||||
|
] of Object.entries(OPENING_BALANCES)) {
|
||||||
|
if (!amount) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
const account = GL_ACCOUNTS.find((a) => a.code === code);
|
||||||
|
if (!account) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
const debitSide = account.type === 'ASSET' || account.type === 'EXPENSE' || account.type === 'CONTINGENT';
|
||||||
|
|
||||||
|
// Contingent accounts are single-sided memoranda held at the issuing branch,
|
||||||
|
// so their opening book is spread across the network rather than sat at HO.
|
||||||
|
if (account.type === 'CONTINGENT') {
|
||||||
|
let allocated = 0;
|
||||||
|
for (const branch of GL_BRANCHES) {
|
||||||
|
if (branch.code === 'HO') {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
const share = Math.round((amount * (CONTINGENT_BRANCH_WEIGHTS[branch.code] ?? 0)) / 100) * 100;
|
||||||
|
if (!share) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
allocated += share;
|
||||||
|
out.push({
|
||||||
|
date: GL_PERIOD.fyStart,
|
||||||
|
branchCode: branch.code,
|
||||||
|
accountCode: code,
|
||||||
|
debit: share,
|
||||||
|
credit: 0,
|
||||||
|
voucherNo: openingVoucher,
|
||||||
|
narration: 'Opening balance brought forward'
|
||||||
|
});
|
||||||
|
}
|
||||||
|
// Head Office takes its own share plus whatever rounding left behind.
|
||||||
|
out.push({
|
||||||
|
date: GL_PERIOD.fyStart,
|
||||||
|
branchCode: 'HO',
|
||||||
|
accountCode: code,
|
||||||
|
debit: amount - allocated,
|
||||||
|
credit: 0,
|
||||||
|
voucherNo: openingVoucher,
|
||||||
|
narration: 'Opening balance brought forward'
|
||||||
|
});
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
out.push({
|
||||||
|
date: GL_PERIOD.fyStart,
|
||||||
|
branchCode: 'HO',
|
||||||
|
accountCode: code,
|
||||||
|
debit: debitSide ? amount : 0,
|
||||||
|
credit: debitSide ? 0 : amount,
|
||||||
|
voucherNo: openingVoucher,
|
||||||
|
narration: 'Opening balance brought forward'
|
||||||
|
});
|
||||||
|
if (debitSide) {
|
||||||
|
openingDebit += amount;
|
||||||
|
} else {
|
||||||
|
openingCredit += amount;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// If OPENING_BALANCES is ever edited so the two sides diverge, park the residual
|
||||||
|
// in a visibly-named suspense account rather than letting the trial balance drift.
|
||||||
|
const openingResidual = openingCredit - openingDebit;
|
||||||
|
if (openingResidual !== 0) {
|
||||||
|
out.push({
|
||||||
|
date: GL_PERIOD.fyStart,
|
||||||
|
branchCode: 'HO',
|
||||||
|
accountCode: '140199',
|
||||||
|
debit: openingResidual > 0 ? openingResidual : 0,
|
||||||
|
credit: openingResidual < 0 ? -openingResidual : 0,
|
||||||
|
voucherNo: openingVoucher,
|
||||||
|
narration: 'Unreconciled opening balance difference'
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const round = (n: number): number => Math.round(n / 100) * 100;
|
||||||
|
|
||||||
|
for (const date of dates) {
|
||||||
|
for (const branch of GL_BRANCHES) {
|
||||||
|
// Head Office posts fewer counter transactions than the branches.
|
||||||
|
const weight = branch.code === 'HO' ? 0.45 : 1;
|
||||||
|
const templates = branch.code === 'HO' ? [
|
||||||
|
...VOUCHER_TEMPLATES,
|
||||||
|
...HO_TEMPLATES
|
||||||
|
] : VOUCHER_TEMPLATES;
|
||||||
|
|
||||||
|
for (const tpl of templates) {
|
||||||
|
if (rand() > tpl.frequency * weight) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
// 0.55x .. 1.45x of the template scale keeps daily figures varied but plausible.
|
||||||
|
const amount = round(tpl.scale * (0.55 + rand() * 0.9));
|
||||||
|
if (amount <= 0) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
const voucherNo = `${branch.code}/${date.slice(5, 7)}/${`${voucherSeq++}`.padStart(5, '0')}`;
|
||||||
|
out.push({
|
||||||
|
date,
|
||||||
|
branchCode: branch.code,
|
||||||
|
accountCode: tpl.debit,
|
||||||
|
debit: amount,
|
||||||
|
credit: 0,
|
||||||
|
voucherNo,
|
||||||
|
narration: tpl.narration
|
||||||
|
});
|
||||||
|
out.push({
|
||||||
|
date,
|
||||||
|
branchCode: branch.code,
|
||||||
|
accountCode: tpl.credit,
|
||||||
|
debit: 0,
|
||||||
|
credit: amount,
|
||||||
|
voucherNo,
|
||||||
|
narration: tpl.narration
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Contingent memoranda are raised at every branch, weighted by its size.
|
||||||
|
const contingentWeight = CONTINGENT_BRANCH_WEIGHTS[branch.code] ?? 0.15;
|
||||||
|
for (const tpl of CONTINGENT_TEMPLATES) {
|
||||||
|
// Claims against the bank and capital commitments are Head Office matters.
|
||||||
|
const headOfficeOnly = tpl.account === '910105' || tpl.account === '910107';
|
||||||
|
if (headOfficeOnly && branch.code !== 'HO') {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
const frequency = headOfficeOnly ? tpl.frequency : tpl.frequency * (contingentWeight / 0.2);
|
||||||
|
if (rand() > frequency) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
const amount = round(tpl.scale * (0.55 + rand() * 0.9));
|
||||||
|
out.push({
|
||||||
|
date,
|
||||||
|
branchCode: branch.code,
|
||||||
|
accountCode: tpl.account,
|
||||||
|
debit: amount,
|
||||||
|
credit: 0,
|
||||||
|
voucherNo: `CNT/${branch.code}/${date.slice(5, 7)}/${`${voucherSeq++}`.padStart(5, '0')}`,
|
||||||
|
narration: tpl.narration
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
cachedPostings = out;
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Customer sub-ledger.
|
||||||
|
*
|
||||||
|
* The GL is account-level, so a client-wise report needs a customer dimension on top.
|
||||||
|
* Each customer holds a share of one GL head at one branch; balances are not stored,
|
||||||
|
* they are derived at query time as `share x that head's GL balance`. That way the
|
||||||
|
* client-wise report always reconciles to the General Ledger exactly, whatever period
|
||||||
|
* or branch is selected, instead of drifting as a second source of truth.
|
||||||
|
*/
|
||||||
|
export interface GlCustomer {
|
||||||
|
cif: string;
|
||||||
|
name: string;
|
||||||
|
branchCode: string;
|
||||||
|
/** GL head this relationship sits under. */
|
||||||
|
accountCode: string;
|
||||||
|
accountNo: string;
|
||||||
|
/** Fraction of the branch's balance in that head. Shares per (branch, head) sum to 1. */
|
||||||
|
share: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
const FIRST_NAMES = [
|
||||||
|
'Ravi',
|
||||||
|
'Priya',
|
||||||
|
'Vikram',
|
||||||
|
'Nirav',
|
||||||
|
'Kavita',
|
||||||
|
'Arjun',
|
||||||
|
'Lakshmi',
|
||||||
|
'Sameer',
|
||||||
|
'Anjali',
|
||||||
|
'Rohan',
|
||||||
|
'Meera',
|
||||||
|
'Karan',
|
||||||
|
'Divya',
|
||||||
|
'Sanjay',
|
||||||
|
'Pooja',
|
||||||
|
'Amit',
|
||||||
|
'Sneha',
|
||||||
|
'Manish',
|
||||||
|
'Rekha',
|
||||||
|
'Deepak'
|
||||||
|
];
|
||||||
|
const LAST_NAMES = [
|
||||||
|
'Sharma',
|
||||||
|
'Patel',
|
||||||
|
'Reddy',
|
||||||
|
'Shah',
|
||||||
|
'Verma',
|
||||||
|
'Deshmukh',
|
||||||
|
'Iyer',
|
||||||
|
'Kapoor',
|
||||||
|
'Nair',
|
||||||
|
'Joshi',
|
||||||
|
'Mehta',
|
||||||
|
'Gupta',
|
||||||
|
'Rao',
|
||||||
|
'Bhatt',
|
||||||
|
'Chauhan'
|
||||||
|
];
|
||||||
|
const FIRMS = [
|
||||||
|
'Traders',
|
||||||
|
'Enterprises',
|
||||||
|
'Agencies',
|
||||||
|
'& Sons',
|
||||||
|
'Industries',
|
||||||
|
'Stores',
|
||||||
|
'Corporation'
|
||||||
|
];
|
||||||
|
|
||||||
|
/** Customer-facing heads a client relationship can sit under. */
|
||||||
|
const CUSTOMER_HEADS = [
|
||||||
|
'220101',
|
||||||
|
'220102',
|
||||||
|
'220103',
|
||||||
|
'220104',
|
||||||
|
'130101',
|
||||||
|
'130102',
|
||||||
|
'130103',
|
||||||
|
'130104',
|
||||||
|
'130105',
|
||||||
|
'130106'
|
||||||
|
];
|
||||||
|
|
||||||
|
let cachedCustomers: GlCustomer[] | null = null;
|
||||||
|
|
||||||
|
export function glCustomers(): GlCustomer[] {
|
||||||
|
if (cachedCustomers) {
|
||||||
|
return cachedCustomers;
|
||||||
|
}
|
||||||
|
const rand = lcg(76543210);
|
||||||
|
const out: GlCustomer[] = [];
|
||||||
|
let seq = 1;
|
||||||
|
|
||||||
|
for (const branch of GL_BRANCHES) {
|
||||||
|
for (const head of CUSTOMER_HEADS) {
|
||||||
|
// Deposits are held by many customers; advances by fewer, larger ones.
|
||||||
|
const isAdvance = head.startsWith('13');
|
||||||
|
const count = isAdvance ? 2 + Math.floor(rand() * 3) : 3 + Math.floor(rand() * 4);
|
||||||
|
const weights: number[] = [];
|
||||||
|
for (let i = 0; i < count; i++) {
|
||||||
|
weights.push(0.35 + rand() * 0.65);
|
||||||
|
}
|
||||||
|
const weightTotal = weights.reduce((a, b) => a + b, 0);
|
||||||
|
|
||||||
|
for (let i = 0; i < count; i++) {
|
||||||
|
const corporate = isAdvance && rand() > 0.55;
|
||||||
|
const name = corporate
|
||||||
|
? `${LAST_NAMES[Math.floor(rand() * LAST_NAMES.length)]} ${FIRMS[Math.floor(rand() * FIRMS.length)]}`
|
||||||
|
: `${FIRST_NAMES[Math.floor(rand() * FIRST_NAMES.length)]} ${LAST_NAMES[Math.floor(rand() * LAST_NAMES.length)]}`;
|
||||||
|
out.push({
|
||||||
|
cif: `CIF${`${100000 + seq}`}`,
|
||||||
|
name,
|
||||||
|
branchCode: branch.code,
|
||||||
|
accountCode: head,
|
||||||
|
accountNo: `${branch.code === 'HO' ? '0001' : branch.code}${head}${`${seq}`.padStart(4, '0')}`,
|
||||||
|
share: weights[i] / weightTotal
|
||||||
|
});
|
||||||
|
seq++;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
cachedCustomers = out;
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function glAccount(code: string): GlAccount | undefined {
|
||||||
|
return GL_ACCOUNTS.find((a) => a.code === code);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function glBranchName(code: string): string {
|
||||||
|
return GL_BRANCHES.find((b) => b.code === code)?.name ?? code;
|
||||||
|
}
|
||||||
@@ -20,11 +20,11 @@
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="table-filters">
|
<div class="table-filters">
|
||||||
<mat-form-field class="table-search" appearance="outline">
|
<mat-form-field class="table-search" appearance="outline" subscriptSizing="dynamic">
|
||||||
<input matInput placeholder="Search by client name or account number" [formControl]="searchControl" />
|
<input matInput placeholder="Search by client name or account number" [formControl]="searchControl" />
|
||||||
</mat-form-field>
|
</mat-form-field>
|
||||||
|
|
||||||
<mat-form-field class="table-filter" appearance="outline">
|
<mat-form-field class="table-filter" appearance="outline" subscriptSizing="dynamic">
|
||||||
<mat-select [(value)]="branchFilter" (selectionChange)="applyFilters()" placeholder="Branch">
|
<mat-select [(value)]="branchFilter" (selectionChange)="applyFilters()" placeholder="Branch">
|
||||||
<mat-option value="">All Branches</mat-option>
|
<mat-option value="">All Branches</mat-option>
|
||||||
<mat-option *ngFor="let office of offices" [value]="office.id">
|
<mat-option *ngFor="let office of offices" [value]="office.id">
|
||||||
@@ -33,7 +33,7 @@
|
|||||||
</mat-select>
|
</mat-select>
|
||||||
</mat-form-field>
|
</mat-form-field>
|
||||||
|
|
||||||
<mat-form-field class="table-filter" appearance="outline">
|
<mat-form-field class="table-filter" appearance="outline" subscriptSizing="dynamic">
|
||||||
<mat-select [(value)]="productFilter" (selectionChange)="applyFilters()" placeholder="Share Product">
|
<mat-select [(value)]="productFilter" (selectionChange)="applyFilters()" placeholder="Share Product">
|
||||||
<mat-option value="">All Products</mat-option>
|
<mat-option value="">All Products</mat-option>
|
||||||
<mat-option *ngFor="let product of products" [value]="product.id">
|
<mat-option *ngFor="let product of products" [value]="product.id">
|
||||||
@@ -42,7 +42,7 @@
|
|||||||
</mat-select>
|
</mat-select>
|
||||||
</mat-form-field>
|
</mat-form-field>
|
||||||
|
|
||||||
<mat-form-field class="table-filter" appearance="outline">
|
<mat-form-field class="table-filter" appearance="outline" subscriptSizing="dynamic">
|
||||||
<mat-select [(value)]="statusFilter" (selectionChange)="applyFilters()" placeholder="Status">
|
<mat-select [(value)]="statusFilter" (selectionChange)="applyFilters()" placeholder="Status">
|
||||||
<mat-option value="">All Status</mat-option>
|
<mat-option value="">All Status</mat-option>
|
||||||
<mat-option value="active">Active</mat-option>
|
<mat-option value="active">Active</mat-option>
|
||||||
@@ -53,7 +53,7 @@
|
|||||||
</mat-select>
|
</mat-select>
|
||||||
</mat-form-field>
|
</mat-form-field>
|
||||||
|
|
||||||
<mat-form-field class="table-filter" appearance="outline" (click)="picker.open()">
|
<mat-form-field class="table-filter" appearance="outline" subscriptSizing="dynamic" (click)="picker.open()">
|
||||||
<mat-date-range-input [formGroup]="dateRangeForm" [rangePicker]="picker">
|
<mat-date-range-input [formGroup]="dateRangeForm" [rangePicker]="picker">
|
||||||
<input matStartDate formControlName="start" placeholder="Start date" (dateChange)="applyFilters()" />
|
<input matStartDate formControlName="start" placeholder="Start date" (dateChange)="applyFilters()" />
|
||||||
<input matEndDate formControlName="end" placeholder="End date" (dateChange)="applyFilters()" />
|
<input matEndDate formControlName="end" placeholder="End date" (dateChange)="applyFilters()" />
|
||||||
|
|||||||
@@ -5,37 +5,43 @@
|
|||||||
* License, v. 2.0. If a copy of the MPL was not distributed with this
|
* 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/.
|
* file, You can obtain one at http://mozilla.org/MPL/2.0/.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
|
:host {
|
||||||
|
display: block;
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
.container {
|
.container {
|
||||||
margin: 1.5rem;
|
margin: 1.5rem auto;
|
||||||
padding: 0;
|
max-width: 1400px;
|
||||||
border-radius: var(--radius-xl);
|
padding: 20px 24px;
|
||||||
background: var(--bg-card);
|
border-radius: 16px;
|
||||||
box-shadow: var(--shadow-sm);
|
background: #ffffff;
|
||||||
|
border: 1px solid #e2e8f0;
|
||||||
|
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.04);
|
||||||
|
box-sizing: border-box;
|
||||||
}
|
}
|
||||||
|
|
||||||
.table-header {
|
.table-header {
|
||||||
padding: 1.5rem;
|
margin-bottom: 20px;
|
||||||
border-bottom: 1px solid var(--border-light);
|
|
||||||
|
|
||||||
.table-header-row {
|
.table-header-row {
|
||||||
display: flex;
|
display: flex;
|
||||||
justify-content: space-between;
|
justify-content: space-between;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
margin-bottom: 1.5rem;
|
margin-bottom: 16px;
|
||||||
flex-wrap: wrap;
|
|
||||||
gap: 1rem;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.table-title-group {
|
.table-title-group {
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
gap: 1rem;
|
gap: 12px;
|
||||||
|
|
||||||
.table-title {
|
.table-title {
|
||||||
margin: 0;
|
margin: 0;
|
||||||
font-size: 1.25rem;
|
font-size: 1.25rem;
|
||||||
font-weight: 600;
|
font-weight: 600;
|
||||||
color: var(--text-main);
|
color: #0f172a;
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
gap: 0.75rem;
|
gap: 0.75rem;
|
||||||
@@ -45,83 +51,175 @@
|
|||||||
width: 24px;
|
width: 24px;
|
||||||
height: 24px;
|
height: 24px;
|
||||||
position: relative;
|
position: relative;
|
||||||
display: flex;
|
display: grid;
|
||||||
flex-wrap: wrap;
|
grid-template-columns: 1fr 1fr;
|
||||||
gap: 2px;
|
gap: 2px;
|
||||||
padding: 2px;
|
padding: 2px;
|
||||||
background: var(--bg-hover);
|
background: #f1f5f9;
|
||||||
border-radius: 6px;
|
border-radius: 6px;
|
||||||
|
|
||||||
span {
|
span {
|
||||||
width: 8px;
|
|
||||||
height: 8px;
|
|
||||||
border-radius: 2px;
|
border-radius: 2px;
|
||||||
background: var(--primary-main);
|
background: #1353a0;
|
||||||
opacity: 0.8;
|
|
||||||
|
|
||||||
&:nth-child(2) {
|
&:nth-child(2) { opacity: 0.7; }
|
||||||
opacity: 0.6;
|
&:nth-child(3) { opacity: 0.4; }
|
||||||
}
|
&:nth-child(4) { opacity: 0.2; }
|
||||||
|
|
||||||
&:nth-child(3) {
|
|
||||||
opacity: 0.4;
|
|
||||||
}
|
|
||||||
|
|
||||||
&:nth-child(4) {
|
|
||||||
opacity: 0.2;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
.table-count {
|
.table-count {
|
||||||
padding: 0.25rem 0.75rem;
|
padding: 0.2rem 0.65rem;
|
||||||
background: var(--bg-hover);
|
background: #f1f5f9;
|
||||||
color: var(--text-muted);
|
color: #64748b;
|
||||||
border-radius: var(--radius-full);
|
border-radius: 12px;
|
||||||
font-size: 0.75rem;
|
font-size: 0.75rem;
|
||||||
font-weight: 500;
|
font-weight: 500;
|
||||||
letter-spacing: 0.02em;
|
font-family: 'Roboto Mono', monospace;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* Pill-styled filter bar */
|
||||||
.table-filters {
|
.table-filters {
|
||||||
display: flex;
|
display: flex;
|
||||||
gap: 1rem;
|
|
||||||
align-items: center;
|
align-items: center;
|
||||||
|
gap: 10px;
|
||||||
flex-wrap: wrap;
|
flex-wrap: wrap;
|
||||||
|
padding: 8px 12px;
|
||||||
|
background: #f8fafc;
|
||||||
|
border: 1px solid #e2e8f0;
|
||||||
|
border-radius: 12px;
|
||||||
|
|
||||||
mat-form-field {
|
.table-search {
|
||||||
width: 240px;
|
flex: 1 1 200px;
|
||||||
margin-bottom: -1.25em;
|
min-width: 180px;
|
||||||
|
max-width: 280px;
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.table-filter {
|
||||||
|
flex: 0 1 150px;
|
||||||
|
min-width: 130px;
|
||||||
|
max-width: 170px;
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Override Material Form Field Heights & Subscript Spacing */
|
||||||
|
::ng-deep {
|
||||||
|
.mat-mdc-form-field {
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.mat-mdc-form-field-subscript-wrapper {
|
||||||
|
display: none !important;
|
||||||
|
height: 0 !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.mat-mdc-form-field-flex {
|
||||||
|
height: 38px !important;
|
||||||
|
background: #ffffff !important;
|
||||||
|
align-items: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.mat-mdc-form-field-infix {
|
||||||
|
padding: 6px 0 !important;
|
||||||
|
min-height: 38px !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.mdc-notched-outline__leading {
|
||||||
|
border-radius: 8px 0 0 8px !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.mdc-notched-outline__trailing {
|
||||||
|
border-radius: 0 8px 8px 0 !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.mat-mdc-select-value,
|
||||||
|
.mat-mdc-select-placeholder,
|
||||||
|
input {
|
||||||
|
font-size: 13px !important;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
button[mat-icon-button] {
|
||||||
|
color: #64748b;
|
||||||
|
width: 36px;
|
||||||
|
height: 36px;
|
||||||
|
|
||||||
|
&:hover {
|
||||||
|
color: #0f172a;
|
||||||
|
background: #e2e8f0;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
.table-container {
|
.table-container {
|
||||||
position: relative;
|
border: 1px solid #e2e8f0;
|
||||||
overflow: auto;
|
border-radius: 12px;
|
||||||
border-radius: 0 0 var(--radius-xl) var(--radius-xl);
|
overflow: hidden;
|
||||||
|
background: #ffffff;
|
||||||
|
|
||||||
.table-loader {
|
.table-loader {
|
||||||
position: absolute;
|
height: 2px;
|
||||||
top: 0;
|
|
||||||
left: 0;
|
|
||||||
right: 0;
|
|
||||||
z-index: 10;
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
.modern-table {
|
.modern-table {
|
||||||
width: 100%;
|
width: 100%;
|
||||||
background: transparent;
|
border-collapse: separate;
|
||||||
|
border-spacing: 0;
|
||||||
|
|
||||||
tr.mat-row {
|
::ng-deep {
|
||||||
cursor: pointer;
|
.mat-mdc-header-row {
|
||||||
transition: background-color 0.2s ease;
|
background: #f8fafc;
|
||||||
|
min-height: 38px;
|
||||||
|
height: 38px;
|
||||||
|
}
|
||||||
|
|
||||||
&:hover {
|
.mat-mdc-header-cell {
|
||||||
background-color: var(--bg-hover);
|
font-size: 11px;
|
||||||
|
font-weight: 700;
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 0.05em;
|
||||||
|
color: #64748b;
|
||||||
|
border-bottom: 1px solid #e2e8f0;
|
||||||
|
padding: 0 16px;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.mat-mdc-row {
|
||||||
|
min-height: 42px;
|
||||||
|
height: 42px;
|
||||||
|
border-bottom: 1px solid #f1f5f9;
|
||||||
|
transition: background-color 0.15s ease;
|
||||||
|
cursor: pointer;
|
||||||
|
|
||||||
|
&:last-child {
|
||||||
|
border-bottom: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
&:nth-child(even) {
|
||||||
|
background: #fafbfc;
|
||||||
|
}
|
||||||
|
|
||||||
|
&:hover {
|
||||||
|
background-color: #f1f5f9 !important;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.mat-mdc-cell {
|
||||||
|
font-size: 13px;
|
||||||
|
font-weight: 400;
|
||||||
|
color: #0f172a;
|
||||||
|
padding: 0 16px;
|
||||||
|
white-space: nowrap;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
mat-paginator {
|
||||||
|
border-top: 1px solid #e2e8f0;
|
||||||
|
background: #ffffff;
|
||||||
|
}
|
||||||
|
|||||||
@@ -124,6 +124,13 @@
|
|||||||
</mat-card-header>
|
</mat-card-header>
|
||||||
|
|
||||||
<mat-card-content class="content">
|
<mat-card-content class="content">
|
||||||
|
@if (sharesAccountData.status?.value === 'Active' && (sharesAccountData.summary?.totalApprovedShares ?? 0) === 0) {
|
||||||
|
<div class="closure-eligible-banner">
|
||||||
|
<fa-icon icon="info-circle"></fa-icon>
|
||||||
|
<span>All shares have been withdrawn from this account — it is now eligible for closure.</span>
|
||||||
|
<a class="closure-eligible-banner__link" [routerLink]="['/clerk/share-closure-request']"> Request Closure </a>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
<nav mat-tab-nav-bar class="navigation-tabs" [tabPanel]="tabPanel">
|
<nav mat-tab-nav-bar class="navigation-tabs" [tabPanel]="tabPanel">
|
||||||
<a
|
<a
|
||||||
mat-tab-link
|
mat-tab-link
|
||||||
|
|||||||
@@ -14,3 +14,33 @@
|
|||||||
min-width: 60%;
|
min-width: 60%;
|
||||||
font-weight: 400;
|
font-weight: 400;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.closure-eligible-banner {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 10px;
|
||||||
|
padding: 10px 16px;
|
||||||
|
margin: 0 0 12px;
|
||||||
|
background: #eaf2ff;
|
||||||
|
border: 1px solid rgb(52 87 213 / 25%);
|
||||||
|
border-left: 3px solid #3457d5;
|
||||||
|
border-radius: 8px;
|
||||||
|
font-size: 13px;
|
||||||
|
color: #1b3a8a;
|
||||||
|
|
||||||
|
fa-icon {
|
||||||
|
color: #3457d5;
|
||||||
|
}
|
||||||
|
|
||||||
|
&__link {
|
||||||
|
margin-left: auto;
|
||||||
|
font-weight: 700;
|
||||||
|
color: #3457d5;
|
||||||
|
text-decoration: none;
|
||||||
|
white-space: nowrap;
|
||||||
|
|
||||||
|
&:hover {
|
||||||
|
text-decoration: underline;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -100,11 +100,11 @@ export class SharesButtonsConfiguration {
|
|||||||
{
|
{
|
||||||
name: 'Share Certificate',
|
name: 'Share Certificate',
|
||||||
taskPermissionName: 'READ_SHAREACCOUNTPURCHASE'
|
taskPermissionName: 'READ_SHAREACCOUNTPURCHASE'
|
||||||
},
|
|
||||||
{
|
|
||||||
name: 'Close',
|
|
||||||
taskPermissionName: 'CLOSE_SHAREACCOUNT'
|
|
||||||
}
|
}
|
||||||
|
// 'Close' is intentionally not offered here — closing a share account must go through
|
||||||
|
// the Clerk Dashboard maker-checker flow (Clerk Dashboard > My Share Closures), never a
|
||||||
|
// direct one-step action from this view. The native close command also force-redeems any
|
||||||
|
// remaining shares, bypassing the governed Share Withdrawal approval flow entirely.
|
||||||
];
|
];
|
||||||
break;
|
break;
|
||||||
case 'Submitted and pending approval':
|
case 'Submitted and pending approval':
|
||||||
|
|||||||
+84
@@ -0,0 +1,84 @@
|
|||||||
|
<!--
|
||||||
|
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/.
|
||||||
|
-->
|
||||||
|
|
||||||
|
<h1 mat-dialog-title class="ced-title">
|
||||||
|
<span class="ced-title__icon">✉</span>
|
||||||
|
Approval Email
|
||||||
|
|
||||||
|
<div class="ced-status-row">
|
||||||
|
@if (data.emailSent) {
|
||||||
|
<span class="ced-chip ced-chip--sent">
|
||||||
|
<span class="ced-chip__dot"></span>Sent
|
||||||
|
</span>
|
||||||
|
} @else {
|
||||||
|
<span class="ced-chip ced-chip--notsent">
|
||||||
|
<span class="ced-chip__dot"></span>Not sent
|
||||||
|
</span>
|
||||||
|
}
|
||||||
|
|
||||||
|
@if (data.emailOpenedAt) {
|
||||||
|
<span class="ced-chip ced-chip--seen">
|
||||||
|
<fa-icon icon="eye" class="ced-chip__icon"></fa-icon>Seen
|
||||||
|
</span>
|
||||||
|
} @else {
|
||||||
|
<span class="ced-chip ced-chip--notseen">
|
||||||
|
<fa-icon icon="eye-slash" class="ced-chip__icon"></fa-icon>Not yet seen
|
||||||
|
</span>
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
</h1>
|
||||||
|
|
||||||
|
<div mat-dialog-content class="ced-content">
|
||||||
|
|
||||||
|
<!-- Classic Email Header (Gmail Style) -->
|
||||||
|
<div class="ced-gmail-header">
|
||||||
|
<h2 class="ced-gmail-subject">{{ data.emailSubject ?? '(No Subject)' }}</h2>
|
||||||
|
|
||||||
|
<div class="ced-gmail-meta">
|
||||||
|
<div class="ced-gmail-avatar">
|
||||||
|
<div class="ced-gmail-avatar-letter">C</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="ced-gmail-meta-content">
|
||||||
|
<div class="ced-gmail-meta-top">
|
||||||
|
<span class="ced-gmail-sender-name">CBSNexora</span>
|
||||||
|
<span class="ced-gmail-sender-email"><{{ data.emailFrom ?? 'noreply@cbsnexora.in' }}></span>
|
||||||
|
<span class="ced-gmail-spacer"></span>
|
||||||
|
<span class="ced-gmail-date">{{ data.emailSentAt ? formatDate(data.emailSentAt) : '—' }}</span>
|
||||||
|
</div>
|
||||||
|
<div class="ced-gmail-meta-bottom">
|
||||||
|
<span class="ced-gmail-to">
|
||||||
|
to
|
||||||
|
@if (data.emailTo) {
|
||||||
|
{{ data.emailTo }}
|
||||||
|
} @else {
|
||||||
|
<span style="font-style: italic; color: #d32f2f">No email on file</span>
|
||||||
|
}
|
||||||
|
</span>
|
||||||
|
<fa-icon icon="caret-down" class="ced-gmail-caret"></fa-icon>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- HTML email body -->
|
||||||
|
@if (data.emailBody) {
|
||||||
|
<div class="ced-email-body" [innerHTML]="safeBody"></div>
|
||||||
|
} @else {
|
||||||
|
<div class="ced-empty">
|
||||||
|
<p>No email content available.</p>
|
||||||
|
@if (!data.emailSent) {
|
||||||
|
<p class="ced-empty__hint">The email has not been sent yet for this edit.</p>
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<mat-dialog-actions align="end">
|
||||||
|
<button mat-raised-button color="primary" mat-dialog-close>Close</button>
|
||||||
|
</mat-dialog-actions>
|
||||||
+219
@@ -0,0 +1,219 @@
|
|||||||
|
/**
|
||||||
|
* Copyright since 2025 Mifos Initiative
|
||||||
|
* MPL-2.0
|
||||||
|
*/
|
||||||
|
|
||||||
|
:host {
|
||||||
|
display: block;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ced-title {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 10px;
|
||||||
|
font-size: 1.15rem;
|
||||||
|
font-weight: 600;
|
||||||
|
color: #1a237e;
|
||||||
|
position: relative; /* For aligning badges to the right */
|
||||||
|
|
||||||
|
&__icon {
|
||||||
|
font-size: 1.3rem;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.ced-content {
|
||||||
|
min-width: 560px;
|
||||||
|
max-width: 760px;
|
||||||
|
max-height: 72vh;
|
||||||
|
overflow-y: auto;
|
||||||
|
padding: 0 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── Gmail-Style Email Header ─────────────────────────────────────────────────── */
|
||||||
|
.ced-gmail-header {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
margin-bottom: 30px;
|
||||||
|
padding: 0 4px;
|
||||||
|
font-family: 'Roboto', 'Inter', sans-serif; /* Gmail-like typography */
|
||||||
|
}
|
||||||
|
|
||||||
|
.ced-gmail-subject {
|
||||||
|
font-size: 1.35rem;
|
||||||
|
font-weight: 400;
|
||||||
|
color: #202124;
|
||||||
|
margin: 0 0 24px 0;
|
||||||
|
line-height: 1.4;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ced-gmail-meta {
|
||||||
|
display: flex;
|
||||||
|
align-items: center; /* Avatar vertically centered with text */
|
||||||
|
gap: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ced-gmail-avatar {
|
||||||
|
width: 40px;
|
||||||
|
height: 40px;
|
||||||
|
border-radius: 50%;
|
||||||
|
background-color: #f06292;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ced-gmail-avatar-letter {
|
||||||
|
color: white;
|
||||||
|
font-size: 1.25rem;
|
||||||
|
font-weight: 500;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ced-gmail-meta-content {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
flex-grow: 1;
|
||||||
|
justify-content: center;
|
||||||
|
gap: 2px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ced-gmail-meta-top {
|
||||||
|
display: flex;
|
||||||
|
align-items: baseline;
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ced-gmail-spacer {
|
||||||
|
flex-grow: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ced-gmail-sender-name {
|
||||||
|
font-size: 0.95rem;
|
||||||
|
font-weight: 600;
|
||||||
|
color: #202124;
|
||||||
|
margin-right: 6px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ced-gmail-sender-email {
|
||||||
|
color: #5f6368;
|
||||||
|
font-size: 0.8rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ced-gmail-date {
|
||||||
|
color: #5f6368;
|
||||||
|
font-size: 0.75rem;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ced-gmail-meta-bottom {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ced-gmail-to {
|
||||||
|
font-size: 0.75rem;
|
||||||
|
color: #5f6368;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ced-gmail-caret {
|
||||||
|
font-size: 0.75rem;
|
||||||
|
color: #5f6368;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ced-status-row {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: 8px;
|
||||||
|
margin-left: auto; /* Pushes badges to the right in the flex container */
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── Status chips ───────────────────────────────────────────────────────── */
|
||||||
|
.ced-chip {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 6px;
|
||||||
|
padding: 4px 12px 4px 10px;
|
||||||
|
border-radius: 20px;
|
||||||
|
font-size: 0.82rem;
|
||||||
|
font-weight: 500;
|
||||||
|
|
||||||
|
&__dot {
|
||||||
|
width: 8px;
|
||||||
|
height: 8px;
|
||||||
|
border-radius: 50%;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
&__icon {
|
||||||
|
font-size: 0.82rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
&--sent {
|
||||||
|
background: #e8f5e9;
|
||||||
|
color: #1b5e20;
|
||||||
|
.ced-chip__dot { background: #43a047; }
|
||||||
|
}
|
||||||
|
|
||||||
|
&--notsent {
|
||||||
|
background: #f5f5f5;
|
||||||
|
color: #757575;
|
||||||
|
.ced-chip__dot { background: #bdbdbd; }
|
||||||
|
}
|
||||||
|
|
||||||
|
&--seen {
|
||||||
|
background: #e3f2fd;
|
||||||
|
color: #0d47a1;
|
||||||
|
}
|
||||||
|
|
||||||
|
&--notseen {
|
||||||
|
background: #f5f5f5;
|
||||||
|
color: #9e9e9e;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── Divider ────────────────────────────────────────────────────────────── */
|
||||||
|
.ced-divider {
|
||||||
|
border: none;
|
||||||
|
border-top: 1px solid #eeeeee;
|
||||||
|
margin: 12px 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── Email body iframe-like container ───────────────────────────────────── */
|
||||||
|
.ced-email-body {
|
||||||
|
background: #fafafa;
|
||||||
|
border: 1px solid #e0e0e0;
|
||||||
|
border-radius: 8px;
|
||||||
|
padding: 20px 24px;
|
||||||
|
font-family: Arial, sans-serif;
|
||||||
|
font-size: 0.93rem;
|
||||||
|
line-height: 1.6;
|
||||||
|
color: #333;
|
||||||
|
|
||||||
|
// Reset any Angular Material overrides inside the rendered HTML
|
||||||
|
table {
|
||||||
|
border-collapse: collapse;
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
td, th {
|
||||||
|
padding: 6px 12px;
|
||||||
|
border: 1px solid #e0e0e0;
|
||||||
|
}
|
||||||
|
|
||||||
|
p { margin: 0 0 10px; }
|
||||||
|
ul { padding-left: 20px; }
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── Empty state ────────────────────────────────────────────────────────── */
|
||||||
|
.ced-empty {
|
||||||
|
text-align: center;
|
||||||
|
padding: 32px 0;
|
||||||
|
color: #9e9e9e;
|
||||||
|
|
||||||
|
&__hint {
|
||||||
|
font-size: 0.85rem;
|
||||||
|
margin-top: 6px;
|
||||||
|
}
|
||||||
|
}
|
||||||
+65
@@ -0,0 +1,65 @@
|
|||||||
|
/**
|
||||||
|
* Copyright since 2025 Mifos Initiative
|
||||||
|
*
|
||||||
|
* This Source Code Form is subject to the terms of the Mozilla Public
|
||||||
|
* License, v. 2.0. If a copy of the MPL was not distributed with this
|
||||||
|
* file, You can obtain one at http://mozilla.org/MPL/2.0/.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { Component, inject } from '@angular/core';
|
||||||
|
import {
|
||||||
|
MAT_DIALOG_DATA,
|
||||||
|
MatDialogTitle,
|
||||||
|
MatDialogContent,
|
||||||
|
MatDialogActions,
|
||||||
|
MatDialogClose
|
||||||
|
} from '@angular/material/dialog';
|
||||||
|
import { CdkScrollable } from '@angular/cdk/scrolling';
|
||||||
|
import { STANDALONE_SHARED_IMPORTS } from 'app/standalone-shared.module';
|
||||||
|
import { DomSanitizer, SafeHtml } from '@angular/platform-browser';
|
||||||
|
import { FaIconComponent } from '@fortawesome/angular-fontawesome';
|
||||||
|
|
||||||
|
export interface ClientEditEmailDialogData {
|
||||||
|
emailSubject: string | null;
|
||||||
|
emailBody: string | null;
|
||||||
|
emailSent: boolean;
|
||||||
|
emailSentAt: string | null;
|
||||||
|
emailOpenedAt: string | null;
|
||||||
|
emailFrom: string | null;
|
||||||
|
emailTo: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Displays the exact confirmation email sent to the client when their pending edit was approved.
|
||||||
|
* Shows subject, HTML body (rendered), sent timestamp, and read/seen status. */
|
||||||
|
@Component({
|
||||||
|
selector: 'mifosx-client-edit-email-dialog',
|
||||||
|
templateUrl: './client-edit-email-dialog.component.html',
|
||||||
|
styleUrls: ['./client-edit-email-dialog.component.scss'],
|
||||||
|
standalone: true,
|
||||||
|
imports: [
|
||||||
|
...STANDALONE_SHARED_IMPORTS,
|
||||||
|
MatDialogTitle,
|
||||||
|
CdkScrollable,
|
||||||
|
MatDialogContent,
|
||||||
|
MatDialogActions,
|
||||||
|
MatDialogClose,
|
||||||
|
FaIconComponent
|
||||||
|
]
|
||||||
|
})
|
||||||
|
export class ClientEditEmailDialogComponent {
|
||||||
|
data = inject<ClientEditEmailDialogData>(MAT_DIALOG_DATA);
|
||||||
|
private sanitizer = inject(DomSanitizer);
|
||||||
|
|
||||||
|
get safeBody(): SafeHtml {
|
||||||
|
return this.sanitizer.bypassSecurityTrustHtml(this.data.emailBody ?? '');
|
||||||
|
}
|
||||||
|
|
||||||
|
formatDate(isoDate: string | null | undefined): string {
|
||||||
|
if (!isoDate) return '—';
|
||||||
|
const d = new Date(isoDate);
|
||||||
|
return d.toLocaleString('en-IN', {
|
||||||
|
day: '2-digit', month: 'short', year: 'numeric',
|
||||||
|
hour: '2-digit', minute: '2-digit', hour12: true
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
+25
@@ -126,6 +126,25 @@
|
|||||||
<ng-container matColumnDef="actions">
|
<ng-container matColumnDef="actions">
|
||||||
<th mat-header-cell *matHeaderCellDef>Actions</th>
|
<th mat-header-cell *matHeaderCellDef>Actions</th>
|
||||||
<td mat-cell *matCellDef="let edit">
|
<td mat-cell *matCellDef="let edit">
|
||||||
|
<!-- ── Email status badges (compact, always visible) ── -->
|
||||||
|
<span class="pce-email-badge"
|
||||||
|
[class.pce-email-badge--sent]="edit.emailSent"
|
||||||
|
[class.pce-email-badge--nosent]="!edit.emailSent"
|
||||||
|
[title]="edit.emailSent ? 'Email sent on ' + formatDate(edit.emailSentAt) : 'No confirmation email sent'">
|
||||||
|
<fa-icon icon="envelope"></fa-icon>
|
||||||
|
{{ edit.emailSent ? 'Sent' : 'Not Sent' }}
|
||||||
|
</span>
|
||||||
|
@if (edit.emailSent) {
|
||||||
|
<span class="pce-email-badge"
|
||||||
|
[class.pce-email-badge--seen]="edit.emailOpenedAt"
|
||||||
|
[class.pce-email-badge--notseen]="!edit.emailOpenedAt"
|
||||||
|
[title]="edit.emailOpenedAt ? 'Opened on ' + formatDate(edit.emailOpenedAt) : 'Not yet opened'">
|
||||||
|
<fa-icon [icon]="edit.emailOpenedAt ? 'eye' : 'eye-slash'"></fa-icon>
|
||||||
|
{{ edit.emailOpenedAt ? 'Seen' : 'Not Seen' }}
|
||||||
|
</span>
|
||||||
|
}
|
||||||
|
|
||||||
|
<!-- ── Action buttons ── -->
|
||||||
<button mat-stroked-button class="pce-btn pce-btn-view" (click)="viewCompare(edit)">
|
<button mat-stroked-button class="pce-btn pce-btn-view" (click)="viewCompare(edit)">
|
||||||
<fa-icon icon="eye" class="m-r-10"></fa-icon>Compare
|
<fa-icon icon="eye" class="m-r-10"></fa-icon>Compare
|
||||||
</button>
|
</button>
|
||||||
@@ -137,6 +156,12 @@
|
|||||||
<fa-icon icon="ban" class="m-r-10"></fa-icon>Reject
|
<fa-icon icon="ban" class="m-r-10"></fa-icon>Reject
|
||||||
</button>
|
</button>
|
||||||
}
|
}
|
||||||
|
@if (edit.emailSent) {
|
||||||
|
<button mat-stroked-button class="pce-btn pce-btn-email" (click)="viewEmail(edit)"
|
||||||
|
title="View the exact confirmation email sent to the client">
|
||||||
|
<fa-icon icon="envelope" class="m-r-10"></fa-icon>View Email
|
||||||
|
</button>
|
||||||
|
}
|
||||||
</td>
|
</td>
|
||||||
</ng-container>
|
</ng-container>
|
||||||
|
|
||||||
|
|||||||
+38
@@ -346,3 +346,41 @@ $text-muted: #64748b;
|
|||||||
line-height: 1.5;
|
line-height: 1.5;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* ── Email badges ───────────────────────────────────────────────────────── */
|
||||||
|
.pce-email-badge {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 4px;
|
||||||
|
padding: 2px 6px;
|
||||||
|
border-radius: 4px;
|
||||||
|
font-size: 0.72rem;
|
||||||
|
font-weight: 600;
|
||||||
|
margin-right: 6px;
|
||||||
|
white-space: nowrap;
|
||||||
|
vertical-align: middle;
|
||||||
|
|
||||||
|
fa-icon { font-size: 0.7rem; }
|
||||||
|
|
||||||
|
&--sent {
|
||||||
|
background: #e8f5e9;
|
||||||
|
color: #1b5e20;
|
||||||
|
border: 1px solid #c8e6c9;
|
||||||
|
}
|
||||||
|
&--nosent {
|
||||||
|
background: #f5f5f5;
|
||||||
|
color: #9e9e9e;
|
||||||
|
border: 1px solid #e0e0e0;
|
||||||
|
}
|
||||||
|
&--seen {
|
||||||
|
background: #e3f2fd;
|
||||||
|
color: #0d47a1;
|
||||||
|
border: 1px solid #bbdefb;
|
||||||
|
}
|
||||||
|
&--notseen {
|
||||||
|
background: #fff3e0;
|
||||||
|
color: #e65100;
|
||||||
|
border: 1px solid #ffe0b2;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
|||||||
+42
-1
@@ -34,6 +34,7 @@ import { InputBase } from 'app/shared/form-dialog/formfield/model/input-base';
|
|||||||
import { STANDALONE_SHARED_IMPORTS } from 'app/standalone-shared.module';
|
import { STANDALONE_SHARED_IMPORTS } from 'app/standalone-shared.module';
|
||||||
import { NON_COMPARABLE_KEYS, PENDING_EDIT_FIELD_LABELS } from 'app/clients/shared/client-edit-diff.util';
|
import { NON_COMPARABLE_KEYS, PENDING_EDIT_FIELD_LABELS } from 'app/clients/shared/client-edit-diff.util';
|
||||||
import { ClientEditCompareDialogComponent } from './client-edit-compare-dialog/client-edit-compare-dialog.component';
|
import { ClientEditCompareDialogComponent } from './client-edit-compare-dialog/client-edit-compare-dialog.component';
|
||||||
|
import { ClientEditEmailDialogComponent } from './client-edit-email-dialog/client-edit-email-dialog.component';
|
||||||
|
|
||||||
/** Maps a payload key that holds a CodeValue/template id to the options array it should be
|
/** Maps a payload key that holds a CodeValue/template id to the options array it should be
|
||||||
* resolved against — populated from /clients/template plus the same per-code-value lookups the
|
* resolved against — populated from /clients/template plus the same per-code-value lookups the
|
||||||
@@ -97,7 +98,8 @@ const NATIONALITY_LABELS: Record<string, string> = {
|
|||||||
MatRowDef,
|
MatRowDef,
|
||||||
MatRow,
|
MatRow,
|
||||||
MatIcon,
|
MatIcon,
|
||||||
MatPaginatorModule
|
MatPaginatorModule,
|
||||||
|
ClientEditEmailDialogComponent
|
||||||
]
|
]
|
||||||
})
|
})
|
||||||
export class PendingClientEditsComponent implements OnInit {
|
export class PendingClientEditsComponent implements OnInit {
|
||||||
@@ -423,6 +425,45 @@ export class PendingClientEditsComponent implements OnInit {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Opens the email preview dialog for an edit row. Fetches the email content first (subject,
|
||||||
|
* body, sent/opened timestamps) via the email-preview endpoint, then opens the dialog. */
|
||||||
|
viewEmail(edit: any): void {
|
||||||
|
this.clientsService.getClientEditEmailPreview(edit.clientId, edit.id).subscribe({
|
||||||
|
next: (preview) => {
|
||||||
|
try {
|
||||||
|
this.dialog.open(ClientEditEmailDialogComponent, {
|
||||||
|
width: '800px',
|
||||||
|
maxWidth: '96vw',
|
||||||
|
data: {
|
||||||
|
emailSubject: preview?.emailSubject || null,
|
||||||
|
emailBody: preview?.emailBody || null,
|
||||||
|
emailSent: preview?.emailSent || false,
|
||||||
|
emailSentAt: preview?.emailSentAt || null,
|
||||||
|
emailOpenedAt: preview?.emailOpenedAt || null,
|
||||||
|
emailFrom: preview?.emailFrom || null,
|
||||||
|
emailTo: preview?.emailTo || null
|
||||||
|
}
|
||||||
|
});
|
||||||
|
} catch (e: any) {
|
||||||
|
console.error('Dialog open failed (next): ', e);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
error: () => {
|
||||||
|
try {
|
||||||
|
// Fallback: open dialog with no-content state
|
||||||
|
this.dialog.open(ClientEditEmailDialogComponent, {
|
||||||
|
width: '800px',
|
||||||
|
maxWidth: '96vw',
|
||||||
|
data: { emailSubject: null, emailBody: null, emailSent: false,
|
||||||
|
emailSentAt: null, emailOpenedAt: null, emailFrom: null, emailTo: null }
|
||||||
|
});
|
||||||
|
} catch (e: any) {
|
||||||
|
console.error('Dialog open failed (error): ', e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
reload(): void {
|
reload(): void {
|
||||||
this.load();
|
this.load();
|
||||||
}
|
}
|
||||||
|
|||||||
+82
@@ -0,0 +1,82 @@
|
|||||||
|
<!--
|
||||||
|
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 Closure 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>Closed 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="8" class="pg-empty">Loading…</td>
|
||||||
|
</tr>
|
||||||
|
} @else if (loaded && requests.length === 0) {
|
||||||
|
<tr>
|
||||||
|
<td colspan="8" class="pg-empty">No {{ filterStatus | lowercase }} share closure 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>{{ formatDateTime(r.closed_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-closure-request
|
||||||
|
[embedded]="true"
|
||||||
|
[overrideMode]="'review'"
|
||||||
|
[overrideRequestId]="reviewRequestId"
|
||||||
|
(closeRequested)="closeReviewModal()"
|
||||||
|
(actionCompleted)="onReviewActionCompleted()"
|
||||||
|
></mifosx-share-closure-request>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
+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 { ClerkShareClosureService } from '../../../cashier/clerk-share-closure.service';
|
||||||
|
import { ShareClosureRequestComponent } from '../../../clerk/share-closure-request/share-closure-request.component';
|
||||||
|
|
||||||
|
/** Approval Center — Share Closure Requests tab. A Bank Manager (checker) reviews, approves or
|
||||||
|
* rejects share account closure requests submitted from the Clerk Dashboard. Mirrors
|
||||||
|
* ShareWithdrawalRequestsComponent. 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-closure-requests',
|
||||||
|
templateUrl: './share-closure-requests.component.html',
|
||||||
|
styleUrls: [
|
||||||
|
'../../../cashier/cashier-shared.scss',
|
||||||
|
'../../../clerk/clerk-shared.scss',
|
||||||
|
'../share-withdrawal-requests/share-withdrawal-requests.component.scss'
|
||||||
|
],
|
||||||
|
standalone: true,
|
||||||
|
imports: [
|
||||||
|
CommonModule,
|
||||||
|
FormsModule,
|
||||||
|
ShareClosureRequestComponent
|
||||||
|
]
|
||||||
|
})
|
||||||
|
export class ShareClosureRequestsComponent implements OnInit {
|
||||||
|
private service = inject(ClerkShareClosureService);
|
||||||
|
|
||||||
|
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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -190,6 +190,18 @@
|
|||||||
<fa-icon icon="hand-holding-usd" class="ac-tab__ic"></fa-icon>
|
<fa-icon icon="hand-holding-usd" class="ac-tab__ic"></fa-icon>
|
||||||
<span>Share Withdrawal Requests</span>
|
<span>Share Withdrawal Requests</span>
|
||||||
</a>
|
</a>
|
||||||
|
<a
|
||||||
|
mat-tab-link
|
||||||
|
class="ac-tab"
|
||||||
|
[routerLink]="['./share-closure-requests']"
|
||||||
|
routerLinkActive
|
||||||
|
#shareClosureRequests="routerLinkActive"
|
||||||
|
[active]="shareClosureRequests.isActive"
|
||||||
|
*mifosxHasPermission="'ALL_FUNCTIONS_READ'"
|
||||||
|
>
|
||||||
|
<fa-icon icon="lock" class="ac-tab__ic"></fa-icon>
|
||||||
|
<span>Share Closure Requests</span>
|
||||||
|
</a>
|
||||||
<a
|
<a
|
||||||
mat-tab-link
|
mat-tab-link
|
||||||
class="ac-tab"
|
class="ac-tab"
|
||||||
|
|||||||
@@ -18,6 +18,7 @@ import { CheckerInboxAndTasksComponent } from './checker-inbox-and-tasks/checker
|
|||||||
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 { ShareWithdrawalRequestsComponent } from './checker-inbox-and-tasks-tabs/share-withdrawal-requests/share-withdrawal-requests.component';
|
||||||
|
import { ShareClosureRequestsComponent } from './checker-inbox-and-tasks-tabs/share-closure-requests/share-closure-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';
|
||||||
@@ -165,6 +166,11 @@ const routes: Routes = [
|
|||||||
component: ShareWithdrawalRequestsComponent,
|
component: ShareWithdrawalRequestsComponent,
|
||||||
data: { title: 'Share Withdrawal Requests' }
|
data: { title: 'Share Withdrawal Requests' }
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
path: 'share-closure-requests',
|
||||||
|
component: ShareClosureRequestsComponent,
|
||||||
|
data: { title: 'Share Closure Requests' }
|
||||||
|
},
|
||||||
{
|
{
|
||||||
path: 'deposit-requests',
|
path: 'deposit-requests',
|
||||||
component: DepositRequestsComponent,
|
component: DepositRequestsComponent,
|
||||||
@@ -219,6 +225,19 @@ const routes: Routes = [
|
|||||||
}
|
}
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
path: 'checker-inbox-and-tasks/share-closure-requests',
|
||||||
|
children: [
|
||||||
|
{
|
||||||
|
path: ':id/review',
|
||||||
|
loadComponent: () =>
|
||||||
|
import('../clerk/share-closure-request/share-closure-request.component').then(
|
||||||
|
(m) => m.ShareClosureRequestComponent
|
||||||
|
),
|
||||||
|
data: { title: 'Review Share Closure Request', breadcrumb: 'Review Share Closure', mode: 'review' }
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
{
|
{
|
||||||
path: 'checker-inbox-and-tasks/disbursement-requests',
|
path: 'checker-inbox-and-tasks/disbursement-requests',
|
||||||
children: [
|
children: [
|
||||||
|
|||||||
@@ -21,7 +21,7 @@ import {
|
|||||||
MatRow
|
MatRow
|
||||||
} from '@angular/material/table';
|
} from '@angular/material/table';
|
||||||
import { MatButton, MatIconButton } from '@angular/material/button';
|
import { MatButton, MatIconButton } from '@angular/material/button';
|
||||||
import { MatIcon } from '@angular/material/icon';
|
import { MatIconModule } from '@angular/material/icon';
|
||||||
import { MatMenu, MatMenuItem, MatMenuTrigger } from '@angular/material/menu';
|
import { MatMenu, MatMenuItem, MatMenuTrigger } from '@angular/material/menu';
|
||||||
import { MatTooltip } from '@angular/material/tooltip';
|
import { MatTooltip } from '@angular/material/tooltip';
|
||||||
import { MatProgressBar } from '@angular/material/progress-bar';
|
import { MatProgressBar } from '@angular/material/progress-bar';
|
||||||
@@ -52,7 +52,7 @@ import { RevokeDscDialogComponent } from '../revoke-dsc-dialog/revoke-dsc-dialog
|
|||||||
MatRow,
|
MatRow,
|
||||||
MatButton,
|
MatButton,
|
||||||
MatIconButton,
|
MatIconButton,
|
||||||
MatIcon,
|
MatIconModule,
|
||||||
MatMenu,
|
MatMenu,
|
||||||
MatMenuItem,
|
MatMenuItem,
|
||||||
MatMenuTrigger,
|
MatMenuTrigger,
|
||||||
|
|||||||
@@ -9,8 +9,8 @@
|
|||||||
/* tslint:disable */
|
/* tslint:disable */
|
||||||
export default {
|
export default {
|
||||||
'mifos_x': {
|
'mifos_x': {
|
||||||
'version': '260805',
|
'version': '260806',
|
||||||
'hash': '8c7d8287'
|
'hash': 'ddb215de'
|
||||||
},
|
},
|
||||||
'allow_switching_backend_instance': true
|
'allow_switching_backend_instance': true
|
||||||
};
|
};
|
||||||
|
|||||||
Reference in New Issue
Block a user