Compare commits
60
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
b609f19e9f | ||
|
|
7f836f6f71 | ||
|
|
1a33f9d6ce | ||
|
|
983c613b8e | ||
|
|
ddb215decf | ||
|
|
2cc8d979ba | ||
|
|
569f17ef46 | ||
|
|
cc5cd4c6ab | ||
|
|
8c7d8287f9 | ||
|
|
a982083b14 | ||
|
|
a560cd6533 | ||
|
|
0bf337b962 | ||
|
|
1daa12a117 | ||
|
|
f687e1808d | ||
|
|
186a9057a1 | ||
|
|
387bd060e1 | ||
|
|
29f1cdc646 | ||
|
|
1efb520009 | ||
|
|
4ea833ff49 | ||
|
|
5dc284bd1c | ||
|
|
56f13ade67 | ||
|
|
dbd160e7e7 | ||
|
|
468eaa96ab | ||
|
|
f682cb17f6 | ||
|
|
79fedb66e5 | ||
|
|
c1fc95d9b7 | ||
|
|
b422ae6600 | ||
|
|
5fd7e1890e | ||
|
|
0389b1f7c3 | ||
|
|
2e4187c3d2 | ||
|
|
cb4260b7a3 | ||
|
|
349dc91951 | ||
|
|
a9c60083bf | ||
|
|
0c99dc3686 | ||
|
|
733d9d22b2 | ||
|
|
d334dd1669 | ||
|
|
d0536dbb11 | ||
|
|
97d0545f07 | ||
|
|
ca85bae4fb | ||
|
|
a22f8e7e45 | ||
|
|
0207cf85a3 | ||
|
|
3cf01e2733 | ||
|
|
6d205c0ed2 | ||
|
|
ed2a40a8bf | ||
|
|
24b1d7c087 | ||
|
|
0cc79ccd57 | ||
|
|
7abfa394df | ||
|
|
1692925d0d | ||
|
|
b9856a1c93 | ||
|
|
d72155a74a | ||
|
|
3d65900fc4 | ||
|
|
93f4b9b03f | ||
|
|
96c4a28c0f | ||
|
|
8a1b7d5d6c | ||
|
|
1ea4cfe8d8 | ||
|
|
d00eac0aa1 | ||
|
|
9afb177c69 | ||
|
|
bc980d2059 | ||
|
|
f3df626483 | ||
|
|
4660a3eb15 |
@@ -1 +0,0 @@
|
|||||||
{"sessionId":"67d120b4-ff78-435d-be80-f79482a386b4","pid":19832,"acquiredAt":1778067581856}
|
|
||||||
+2
-2
@@ -69,8 +69,8 @@
|
|||||||
"budgets": [
|
"budgets": [
|
||||||
{
|
{
|
||||||
"type": "initial",
|
"type": "initial",
|
||||||
"maximumWarning": "13mb",
|
"maximumWarning": "15mb",
|
||||||
"maximumError": "14mb"
|
"maximumError": "16mb"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"type": "anyComponentStyle",
|
"type": "anyComponentStyle",
|
||||||
|
|||||||
Binary file not shown.
|
After Width: | Height: | Size: 688 KiB |
@@ -89,4 +89,8 @@ export class AccountsService {
|
|||||||
if (params.asOnDate) httpParams = httpParams.set('asOnDate', params.asOnDate);
|
if (params.asOnDate) httpParams = httpParams.set('asOnDate', params.asOnDate);
|
||||||
return this.http.get('/recurringdepositaccounts', { params: httpParams });
|
return this.http.get('/recurringdepositaccounts', { params: httpParams });
|
||||||
}
|
}
|
||||||
|
|
||||||
|
terminateAccount(accountId: string, data: any): Observable<any> {
|
||||||
|
return this.http.post(`/v1/accounts/${accountId}/terminate`, data);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,89 @@
|
|||||||
|
/**
|
||||||
|
* 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';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* AML transaction-monitoring API: live alerts raised by the rule engine, the
|
||||||
|
* two-tier AML Officer / Principal Officer review workflow, STR cases (with
|
||||||
|
* their 7-working-day FIU filing deadline), monthly CTR reports, and a
|
||||||
|
* client's risk-category change history.
|
||||||
|
*/
|
||||||
|
@Injectable({
|
||||||
|
providedIn: 'root'
|
||||||
|
})
|
||||||
|
export class AmlService {
|
||||||
|
private http = inject(HttpClient);
|
||||||
|
|
||||||
|
// ═══ Alerts (AML Officer) ═══
|
||||||
|
|
||||||
|
getAlerts(status?: string): Observable<any[]> {
|
||||||
|
const params = status ? new HttpParams().set('status', status) : undefined;
|
||||||
|
return this.http.get<any[]>('/aml/alerts', { params });
|
||||||
|
}
|
||||||
|
|
||||||
|
/** disposition: 'WHITELIST' | 'ESCALATE' */
|
||||||
|
reviewAlert(alertId: number, disposition: 'WHITELIST' | 'ESCALATE', notes: string): Observable<any> {
|
||||||
|
return this.http.post(`/aml/alerts/${alertId}/review`, { disposition, notes });
|
||||||
|
}
|
||||||
|
|
||||||
|
// ═══ STR cases (Principal Officer) ═══
|
||||||
|
|
||||||
|
getStrCases(): Observable<any[]> {
|
||||||
|
return this.http.get<any[]>('/aml/str-cases');
|
||||||
|
}
|
||||||
|
|
||||||
|
getStrCase(strCaseId: number): Observable<any> {
|
||||||
|
return this.http.get(`/aml/str-cases/${strCaseId}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
getWeeklyDigest(): Observable<any[]> {
|
||||||
|
return this.http.get<any[]>('/aml/str-cases/weekly-digest');
|
||||||
|
}
|
||||||
|
|
||||||
|
assignPrincipalOfficer(strCaseId: number, principalOfficerUserId: number): Observable<any> {
|
||||||
|
return this.http.post(`/aml/str-cases/${strCaseId}/assign`, { principalOfficerUserId });
|
||||||
|
}
|
||||||
|
|
||||||
|
/** conclusion: 'NOT_SUSPICIOUS' | 'SUSPICIOUS' */
|
||||||
|
concludeStrCase(
|
||||||
|
strCaseId: number,
|
||||||
|
conclusion: 'NOT_SUSPICIOUS' | 'SUSPICIOUS',
|
||||||
|
investigationNotes: string,
|
||||||
|
sourceOfFundsNotes: string
|
||||||
|
): Observable<any> {
|
||||||
|
return this.http.post(`/aml/str-cases/${strCaseId}/conclude`, {
|
||||||
|
conclusion,
|
||||||
|
investigationNotes,
|
||||||
|
sourceOfFundsNotes
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
fileStr(strCaseId: number, referenceNo: string): Observable<any> {
|
||||||
|
return this.http.post(`/aml/str-cases/${strCaseId}/file`, { referenceNo });
|
||||||
|
}
|
||||||
|
|
||||||
|
// ═══ CTR reports (mechanical, threshold-based) ═══
|
||||||
|
|
||||||
|
getCtrReports(): Observable<any[]> {
|
||||||
|
return this.http.get<any[]>('/aml/ctr-reports');
|
||||||
|
}
|
||||||
|
|
||||||
|
/** month format: yyyy-MM */
|
||||||
|
generateCtrReport(month: string): Observable<any> {
|
||||||
|
return this.http.post('/aml/ctr-reports/generate', { month });
|
||||||
|
}
|
||||||
|
|
||||||
|
// ═══ Client risk-category history ═══
|
||||||
|
|
||||||
|
getRiskCategoryHistory(clientId: number): Observable<any[]> {
|
||||||
|
return this.http.get<any[]>(`/clients/${clientId}/risk-category-history`);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -36,6 +36,9 @@ import { ClientsModule } from './clients/clients.module';
|
|||||||
import { GroupsModule } from './groups/groups.module';
|
import { GroupsModule } from './groups/groups.module';
|
||||||
import { CentersModule } from './centers/centers.module';
|
import { CentersModule } from './centers/centers.module';
|
||||||
import { AccountingModule } from './accounting/accounting.module';
|
import { AccountingModule } from './accounting/accounting.module';
|
||||||
|
import { ChequeBookModule } from './chequebook/chequebook.module';
|
||||||
|
import { CtsClearingModule } from './ctsclearing/ctsclearing.module';
|
||||||
|
import { NachClearingModule } from './nachclearing/nachclearing.module';
|
||||||
import { SystemModule } from './system/system.module';
|
import { SystemModule } from './system/system.module';
|
||||||
import { ProductsModule } from './products/products.module';
|
import { ProductsModule } from './products/products.module';
|
||||||
import { OrganizationModule } from './organization/organization.module';
|
import { OrganizationModule } from './organization/organization.module';
|
||||||
@@ -66,6 +69,8 @@ import { DigitalModule } from './digital/digital.module';
|
|||||||
import { LetterMgmtModule } from './letter-mgmt/letter-mgmt.module';
|
import { LetterMgmtModule } from './letter-mgmt/letter-mgmt.module';
|
||||||
import { AccountsModule } from './accounts/accounts.module';
|
import { AccountsModule } from './accounts/accounts.module';
|
||||||
import { ElectionModule } from './election/election.module';
|
import { ElectionModule } from './election/election.module';
|
||||||
|
import { SponsorDDModule } from './sponsor-dd/sponsor-dd.module';
|
||||||
|
import { SponsorDDReconciliationModule } from './sponsor-dd-reconciliation/sponsor-dd-reconciliation.module';
|
||||||
import { PortalModule } from '@angular/cdk/portal';
|
import { PortalModule } from '@angular/cdk/portal';
|
||||||
|
|
||||||
/** Main Routing Module */
|
/** Main Routing Module */
|
||||||
@@ -142,6 +147,9 @@ export function HttpLoaderFactory(http: HttpClient) {
|
|||||||
GroupsModule,
|
GroupsModule,
|
||||||
CentersModule,
|
CentersModule,
|
||||||
AccountingModule,
|
AccountingModule,
|
||||||
|
ChequeBookModule,
|
||||||
|
CtsClearingModule,
|
||||||
|
NachClearingModule,
|
||||||
SystemModule,
|
SystemModule,
|
||||||
ProductsModule,
|
ProductsModule,
|
||||||
OrganizationModule,
|
OrganizationModule,
|
||||||
@@ -168,6 +176,8 @@ export function HttpLoaderFactory(http: HttpClient) {
|
|||||||
LetterMgmtModule,
|
LetterMgmtModule,
|
||||||
AccountsModule,
|
AccountsModule,
|
||||||
ElectionModule,
|
ElectionModule,
|
||||||
|
SponsorDDModule,
|
||||||
|
SponsorDDReconciliationModule,
|
||||||
AppRoutingModule,
|
AppRoutingModule,
|
||||||
NotFoundComponent,
|
NotFoundComponent,
|
||||||
CallbackComponent,
|
CallbackComponent,
|
||||||
|
|||||||
@@ -45,6 +45,12 @@
|
|||||||
<input matInput type="date" [value]="settlementDate" (change)="settlementDate = $any($event.target).value" />
|
<input matInput type="date" [value]="settlementDate" (change)="settlementDate = $any($event.target).value" />
|
||||||
</mat-form-field>
|
</mat-form-field>
|
||||||
|
|
||||||
|
@if (tellers.length === 1) {
|
||||||
|
<mat-form-field appearance="outline" subscriptSizing="dynamic">
|
||||||
|
<mat-label>Teller</mat-label>
|
||||||
|
<input matInput type="text" [value]="tellers[0].name" readonly />
|
||||||
|
</mat-form-field>
|
||||||
|
} @else {
|
||||||
<mat-form-field appearance="outline" subscriptSizing="dynamic">
|
<mat-form-field appearance="outline" subscriptSizing="dynamic">
|
||||||
<mat-label>Teller</mat-label>
|
<mat-label>Teller</mat-label>
|
||||||
<mat-select [value]="supervisorTellerId" (selectionChange)="onSupervisorTellerSelect($event.value)">
|
<mat-select [value]="supervisorTellerId" (selectionChange)="onSupervisorTellerSelect($event.value)">
|
||||||
@@ -53,6 +59,7 @@
|
|||||||
}
|
}
|
||||||
</mat-select>
|
</mat-select>
|
||||||
</mat-form-field>
|
</mat-form-field>
|
||||||
|
}
|
||||||
|
|
||||||
<mat-form-field appearance="outline" subscriptSizing="dynamic">
|
<mat-form-field appearance="outline" subscriptSizing="dynamic">
|
||||||
<mat-label>Cashier</mat-label>
|
<mat-label>Cashier</mat-label>
|
||||||
@@ -140,6 +147,12 @@
|
|||||||
}
|
}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
@if (tellers.length === 1) {
|
||||||
|
<mat-form-field appearance="outline" subscriptSizing="dynamic">
|
||||||
|
<mat-label>Teller / Vault (To) *</mat-label>
|
||||||
|
<input matInput type="text" [value]="toParty" readonly />
|
||||||
|
</mat-form-field>
|
||||||
|
} @else {
|
||||||
<mat-form-field appearance="outline" subscriptSizing="dynamic">
|
<mat-form-field appearance="outline" subscriptSizing="dynamic">
|
||||||
<mat-label>Teller / Vault (To) *</mat-label>
|
<mat-label>Teller / Vault (To) *</mat-label>
|
||||||
<mat-select [value]="selectedTellerId" (selectionChange)="onTellerSelect($event.value)">
|
<mat-select [value]="selectedTellerId" (selectionChange)="onTellerSelect($event.value)">
|
||||||
@@ -151,6 +164,7 @@
|
|||||||
}
|
}
|
||||||
</mat-select>
|
</mat-select>
|
||||||
</mat-form-field>
|
</mat-form-field>
|
||||||
|
}
|
||||||
|
|
||||||
<mat-form-field appearance="outline" subscriptSizing="dynamic">
|
<mat-form-field appearance="outline" subscriptSizing="dynamic">
|
||||||
<mat-label>Settlement Date</mat-label>
|
<mat-label>Settlement Date</mat-label>
|
||||||
|
|||||||
@@ -97,6 +97,10 @@ export class CashSettlementComponent implements OnInit {
|
|||||||
.pipe(catchError(() => of([] as any[])))
|
.pipe(catchError(() => of([] as any[])))
|
||||||
.subscribe((list: any[]) => {
|
.subscribe((list: any[]) => {
|
||||||
this.tellers = (list || []).map((t: any) => ({ id: t.id, name: t.name }));
|
this.tellers = (list || []).map((t: any) => ({ id: t.id, name: t.name }));
|
||||||
|
if (this.tellers.length === 1) {
|
||||||
|
this.onTellerSelect(this.tellers[0].id);
|
||||||
|
this.onSupervisorTellerSelect(this.tellers[0].id);
|
||||||
|
}
|
||||||
});
|
});
|
||||||
this.loadData();
|
this.loadData();
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -17,6 +17,12 @@
|
|||||||
</header>
|
</header>
|
||||||
|
|
||||||
<div class="pg-toolbar pg-toolbar--mat">
|
<div class="pg-toolbar pg-toolbar--mat">
|
||||||
|
@if (tellers.length === 1) {
|
||||||
|
<mat-form-field appearance="outline" subscriptSizing="dynamic">
|
||||||
|
<mat-label>From (Teller / Head)</mat-label>
|
||||||
|
<input matInput type="text" [value]="fromParty" readonly />
|
||||||
|
</mat-form-field>
|
||||||
|
} @else {
|
||||||
<mat-form-field appearance="outline" subscriptSizing="dynamic">
|
<mat-form-field appearance="outline" subscriptSizing="dynamic">
|
||||||
<mat-label>From (Teller / Head)</mat-label>
|
<mat-label>From (Teller / Head)</mat-label>
|
||||||
<mat-select [value]="fromTellerId" (selectionChange)="onTellerSelect($event.value)">
|
<mat-select [value]="fromTellerId" (selectionChange)="onTellerSelect($event.value)">
|
||||||
@@ -28,6 +34,7 @@
|
|||||||
}
|
}
|
||||||
</mat-select>
|
</mat-select>
|
||||||
</mat-form-field>
|
</mat-form-field>
|
||||||
|
}
|
||||||
|
|
||||||
<mat-form-field appearance="outline" subscriptSizing="dynamic">
|
<mat-form-field appearance="outline" subscriptSizing="dynamic">
|
||||||
<mat-label>To (Cashier)</mat-label>
|
<mat-label>To (Cashier)</mat-label>
|
||||||
|
|||||||
@@ -95,6 +95,9 @@ export class CashTransferComponent implements OnInit {
|
|||||||
.pipe(catchError(() => of([] as any[])))
|
.pipe(catchError(() => of([] as any[])))
|
||||||
.subscribe((list) => {
|
.subscribe((list) => {
|
||||||
this.tellers = (list || []).map((t: any) => ({ id: t.id, name: t.name }));
|
this.tellers = (list || []).map((t: any) => ({ id: t.id, name: t.name }));
|
||||||
|
if (this.tellers.length === 1) {
|
||||||
|
this.onTellerSelect(this.tellers[0].id);
|
||||||
|
}
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -116,6 +119,9 @@ export class CashTransferComponent implements OnInit {
|
|||||||
.subscribe((r: any) => {
|
.subscribe((r: any) => {
|
||||||
this.cashiers = (r?.cashiers || []).map((c: any) => ({ id: c.id, label: c.staffName }));
|
this.cashiers = (r?.cashiers || []).map((c: any) => ({ id: c.id, label: c.staffName }));
|
||||||
this.loadingCashiers = false;
|
this.loadingCashiers = false;
|
||||||
|
if (this.cashiers.length === 1) {
|
||||||
|
this.onCashierSelect(this.cashiers[0].id);
|
||||||
|
}
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -20,6 +20,12 @@
|
|||||||
<div class="pg-toolbar pg-toolbar--mat">
|
<div class="pg-toolbar pg-toolbar--mat">
|
||||||
<!-- Teller + Cashier filters — hidden for cashier role (auto-loads own position) -->
|
<!-- Teller + Cashier filters — hidden for cashier role (auto-loads own position) -->
|
||||||
@if (!isCashierMode) {
|
@if (!isCashierMode) {
|
||||||
|
@if (tellers.length === 1) {
|
||||||
|
<mat-form-field appearance="outline" subscriptSizing="dynamic">
|
||||||
|
<mat-label>Teller</mat-label>
|
||||||
|
<input matInput type="text" [value]="tellers[0].name" readonly />
|
||||||
|
</mat-form-field>
|
||||||
|
} @else {
|
||||||
<mat-form-field appearance="outline" subscriptSizing="dynamic">
|
<mat-form-field appearance="outline" subscriptSizing="dynamic">
|
||||||
<mat-label>Teller (optional)</mat-label>
|
<mat-label>Teller (optional)</mat-label>
|
||||||
<mat-select [value]="selectedTellerId" (selectionChange)="onTellerSelect($event.value)">
|
<mat-select [value]="selectedTellerId" (selectionChange)="onTellerSelect($event.value)">
|
||||||
@@ -29,6 +35,7 @@
|
|||||||
}
|
}
|
||||||
</mat-select>
|
</mat-select>
|
||||||
</mat-form-field>
|
</mat-form-field>
|
||||||
|
}
|
||||||
|
|
||||||
<mat-form-field appearance="outline" subscriptSizing="dynamic">
|
<mat-form-field appearance="outline" subscriptSizing="dynamic">
|
||||||
<mat-label>Cashier (optional)</mat-label>
|
<mat-label>Cashier (optional)</mat-label>
|
||||||
|
|||||||
@@ -85,6 +85,9 @@ export class CashierPositionComponent implements OnInit {
|
|||||||
.pipe(catchError(() => of([] as any[])))
|
.pipe(catchError(() => of([] as any[])))
|
||||||
.subscribe((list) => {
|
.subscribe((list) => {
|
||||||
this.tellers = (list || []).map((t: any) => ({ id: t.id, name: t.name }));
|
this.tellers = (list || []).map((t: any) => ({ id: t.id, name: t.name }));
|
||||||
|
if (this.tellers.length === 1) {
|
||||||
|
this.onTellerSelect(this.tellers[0].id);
|
||||||
|
}
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -137,6 +140,9 @@ export class CashierPositionComponent implements OnInit {
|
|||||||
.pipe(catchError(() => of({ cashiers: [] })))
|
.pipe(catchError(() => of({ cashiers: [] })))
|
||||||
.subscribe((r: any) => {
|
.subscribe((r: any) => {
|
||||||
this.cashiers = (r?.cashiers || []).map((c: any) => ({ id: c.id, label: c.staffName }));
|
this.cashiers = (r?.cashiers || []).map((c: any) => ({ id: c.id, label: c.staffName }));
|
||||||
|
if (this.cashiers.length === 1) {
|
||||||
|
this.selectedCashierId = this.cashiers[0].id;
|
||||||
|
}
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -62,6 +62,38 @@ const routes: Routes = [
|
|||||||
),
|
),
|
||||||
data: { title: 'My Disbursement Requests', breadcrumb: 'My Disbursement Requests' }
|
data: { title: 'My Disbursement Requests', breadcrumb: 'My Disbursement Requests' }
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
path: 'share-withdrawal-request',
|
||||||
|
loadComponent: () =>
|
||||||
|
import('../clerk/share-withdrawal-request/share-withdrawal-request.component').then(
|
||||||
|
(m) => m.ShareWithdrawalRequestComponent
|
||||||
|
),
|
||||||
|
data: { title: 'New Share Withdrawal Request', breadcrumb: 'New Share Withdrawal Request', mode: 'create' }
|
||||||
|
},
|
||||||
|
{
|
||||||
|
path: 'share-withdrawal-requests',
|
||||||
|
loadComponent: () =>
|
||||||
|
import('../clerk/my-share-withdrawal-requests/my-share-withdrawal-requests.component').then(
|
||||||
|
(m) => m.MyShareWithdrawalRequestsComponent
|
||||||
|
),
|
||||||
|
data: { title: 'My Share Requests', breadcrumb: 'My Share Requests' }
|
||||||
|
},
|
||||||
|
{
|
||||||
|
path: '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: () =>
|
||||||
@@ -70,6 +102,18 @@ const routes: Routes = [
|
|||||||
),
|
),
|
||||||
data: { title: 'Account Facilities', breadcrumb: 'Account Facilities' }
|
data: { title: 'Account Facilities', breadcrumb: 'Account Facilities' }
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
path: 'account-facilities/cheque-book',
|
||||||
|
loadComponent: () =>
|
||||||
|
import('../clerk/cheque-book/cheque-book.component').then((m) => m.ClerkChequeBookComponent),
|
||||||
|
data: { title: 'Cheque Book', breadcrumb: 'Cheque Book' }
|
||||||
|
},
|
||||||
|
{
|
||||||
|
path: 'account-facilities/cheque-book/:id',
|
||||||
|
loadComponent: () =>
|
||||||
|
import('../clerk/cheque-book/cheque-book-detail.component').then((m) => m.ClerkChequeBookDetailComponent),
|
||||||
|
data: { title: 'Cheque Book Detail', breadcrumb: 'Cheque Book Detail' }
|
||||||
|
},
|
||||||
{
|
{
|
||||||
path: 'photo-signature',
|
path: 'photo-signature',
|
||||||
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,53 @@
|
|||||||
|
/**
|
||||||
|
* Copyright since 2025 Mifos Initiative
|
||||||
|
*
|
||||||
|
* This Source Code Form is subject to the terms of the Mozilla Public
|
||||||
|
* License, v. 2.0. If a copy of the MPL was not distributed with this
|
||||||
|
* file, You can obtain one at http://mozilla.org/MPL/2.0/.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { Injectable, inject } from '@angular/core';
|
||||||
|
import { HttpClient, HttpParams } from '@angular/common/http';
|
||||||
|
import { Observable } from 'rxjs';
|
||||||
|
|
||||||
|
/** HTTP service for the Share Withdrawal (redeem shares) maker-checker workflow — mirrors
|
||||||
|
* ClerkWithdrawalService (cash withdrawal), adapted for shares. */
|
||||||
|
@Injectable({ providedIn: 'root' })
|
||||||
|
export class ClerkShareWithdrawalService {
|
||||||
|
private http = inject(HttpClient);
|
||||||
|
|
||||||
|
submit(data: any): Observable<any> {
|
||||||
|
return this.http.post('/clerk/share-withdrawal-requests', data);
|
||||||
|
}
|
||||||
|
|
||||||
|
getMyRequests(status?: string, offset = 0, limit = 20, fromDate?: string, toDate?: string): Observable<any> {
|
||||||
|
let params = new HttpParams().set('offset', String(offset)).set('limit', String(limit));
|
||||||
|
if (status) params = params.set('status', status);
|
||||||
|
if (fromDate) params = params.set('fromDate', fromDate);
|
||||||
|
if (toDate) params = params.set('toDate', toDate);
|
||||||
|
return this.http.get('/clerk/share-withdrawal-requests/my', { params });
|
||||||
|
}
|
||||||
|
|
||||||
|
getPendingRequests(status = 'PENDING', offset = 0, limit = 20, fromDate?: string, toDate?: string): Observable<any> {
|
||||||
|
let params = new HttpParams().set('status', status).set('offset', String(offset)).set('limit', String(limit));
|
||||||
|
if (fromDate) params = params.set('fromDate', fromDate);
|
||||||
|
if (toDate) params = params.set('toDate', toDate);
|
||||||
|
return this.http.get('/clerk/share-withdrawal-requests', { params });
|
||||||
|
}
|
||||||
|
|
||||||
|
getById(id: number): Observable<any> {
|
||||||
|
return this.http.get(`/clerk/share-withdrawal-requests/${id}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
validate(id: number): Observable<any> {
|
||||||
|
return this.http.post(`/clerk/share-withdrawal-requests/${id}/validate`, {});
|
||||||
|
}
|
||||||
|
|
||||||
|
approve(id: number): Observable<any> {
|
||||||
|
return this.http.post(`/clerk/share-withdrawal-requests/${id}/approve`, {});
|
||||||
|
}
|
||||||
|
|
||||||
|
reject(id: number, reason: string): Observable<any> {
|
||||||
|
return this.http.post(`/clerk/share-withdrawal-requests/${id}/reject`, { reason });
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -17,6 +17,12 @@
|
|||||||
</header>
|
</header>
|
||||||
|
|
||||||
<div class="pg-toolbar pg-toolbar--mat">
|
<div class="pg-toolbar pg-toolbar--mat">
|
||||||
|
@if (tellers.length === 1) {
|
||||||
|
<mat-form-field appearance="outline" subscriptSizing="dynamic">
|
||||||
|
<mat-label>Teller</mat-label>
|
||||||
|
<input matInput type="text" [value]="tellers[0].name" readonly />
|
||||||
|
</mat-form-field>
|
||||||
|
} @else {
|
||||||
<mat-form-field appearance="outline" subscriptSizing="dynamic">
|
<mat-form-field appearance="outline" subscriptSizing="dynamic">
|
||||||
<mat-label>Teller</mat-label>
|
<mat-label>Teller</mat-label>
|
||||||
<mat-select [value]="selectedTellerId" (selectionChange)="onTellerSelect($event.value)">
|
<mat-select [value]="selectedTellerId" (selectionChange)="onTellerSelect($event.value)">
|
||||||
@@ -28,6 +34,7 @@
|
|||||||
}
|
}
|
||||||
</mat-select>
|
</mat-select>
|
||||||
</mat-form-field>
|
</mat-form-field>
|
||||||
|
}
|
||||||
|
|
||||||
<mat-form-field appearance="outline" subscriptSizing="dynamic">
|
<mat-form-field appearance="outline" subscriptSizing="dynamic">
|
||||||
<mat-label>From (Cashier)</mat-label>
|
<mat-label>From (Cashier)</mat-label>
|
||||||
|
|||||||
@@ -102,6 +102,9 @@ export class InterCashierTransferComponent implements OnInit {
|
|||||||
.pipe(catchError(() => of([] as any[])))
|
.pipe(catchError(() => of([] as any[])))
|
||||||
.subscribe((list) => {
|
.subscribe((list) => {
|
||||||
this.tellers = (list || []).map((t: any) => ({ id: t.id, name: t.name }));
|
this.tellers = (list || []).map((t: any) => ({ id: t.id, name: t.name }));
|
||||||
|
if (this.tellers.length === 1) {
|
||||||
|
this.onTellerSelect(this.tellers[0].id);
|
||||||
|
}
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -218,6 +218,26 @@
|
|||||||
<span class="mdr-detail__val cell-mono">{{ viewRequest.txn_id }}</span>
|
<span class="mdr-detail__val cell-mono">{{ viewRequest.txn_id }}</span>
|
||||||
</div>
|
</div>
|
||||||
}
|
}
|
||||||
|
@if (viewRequest.dd_face_value != null) {
|
||||||
|
<div class="mdr-detail">
|
||||||
|
<span class="mdr-detail__lbl">DD Face Value</span>
|
||||||
|
<span class="mdr-detail__val">₹{{ viewRequest.dd_face_value | number: '1.2-2' }}</span>
|
||||||
|
</div>
|
||||||
|
<div class="mdr-detail">
|
||||||
|
<span class="mdr-detail__lbl">Charges</span>
|
||||||
|
<span class="mdr-detail__val">₹{{ viewRequest.dd_charges | number: '1.2-2' }}</span>
|
||||||
|
</div>
|
||||||
|
<div class="mdr-detail">
|
||||||
|
<span class="mdr-detail__lbl">GST ({{ viewRequest.dd_gst_percentage }}%)</span>
|
||||||
|
<span class="mdr-detail__val">₹{{ viewRequest.dd_gst_amount | number: '1.2-2' }}</span>
|
||||||
|
</div>
|
||||||
|
<div class="mdr-detail">
|
||||||
|
<span class="mdr-detail__lbl">Total Collected</span>
|
||||||
|
<span class="mdr-detail__val"
|
||||||
|
><strong>₹{{ viewRequest.amount | number: '1.2-2' }}</strong></span
|
||||||
|
>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
@if (viewRequest.description) {
|
@if (viewRequest.description) {
|
||||||
<div class="mdr-detail mdr-detail--wide">
|
<div class="mdr-detail mdr-detail--wide">
|
||||||
<span class="mdr-detail__lbl">Description</span>
|
<span class="mdr-detail__lbl">Description</span>
|
||||||
|
|||||||
@@ -20,6 +20,12 @@
|
|||||||
<mat-tab label="New Reconciliation">
|
<mat-tab label="New Reconciliation">
|
||||||
<div class="pg-tab-body">
|
<div class="pg-tab-body">
|
||||||
<div class="pg-toolbar pg-toolbar--mat">
|
<div class="pg-toolbar pg-toolbar--mat">
|
||||||
|
@if (tellers.length === 1) {
|
||||||
|
<mat-form-field appearance="outline" subscriptSizing="dynamic">
|
||||||
|
<mat-label>Teller</mat-label>
|
||||||
|
<input matInput type="text" [value]="tellers[0].name" readonly />
|
||||||
|
</mat-form-field>
|
||||||
|
} @else {
|
||||||
<mat-form-field appearance="outline" subscriptSizing="dynamic">
|
<mat-form-field appearance="outline" subscriptSizing="dynamic">
|
||||||
<mat-label>Teller</mat-label>
|
<mat-label>Teller</mat-label>
|
||||||
<mat-select [value]="selectedTellerId" (selectionChange)="onTellerSelect($event.value)">
|
<mat-select [value]="selectedTellerId" (selectionChange)="onTellerSelect($event.value)">
|
||||||
@@ -28,6 +34,7 @@
|
|||||||
}
|
}
|
||||||
</mat-select>
|
</mat-select>
|
||||||
</mat-form-field>
|
</mat-form-field>
|
||||||
|
}
|
||||||
|
|
||||||
<mat-form-field appearance="outline" subscriptSizing="dynamic">
|
<mat-form-field appearance="outline" subscriptSizing="dynamic">
|
||||||
<mat-label>Cashier</mat-label>
|
<mat-label>Cashier</mat-label>
|
||||||
|
|||||||
@@ -92,6 +92,9 @@ export class ReconciliationComponent implements OnInit {
|
|||||||
.pipe(catchError(() => of([] as any[])))
|
.pipe(catchError(() => of([] as any[])))
|
||||||
.subscribe((list) => {
|
.subscribe((list) => {
|
||||||
this.tellers = (list || []).map((t: any) => ({ id: t.id, name: t.name }));
|
this.tellers = (list || []).map((t: any) => ({ id: t.id, name: t.name }));
|
||||||
|
if (this.tellers.length === 1) {
|
||||||
|
this.onTellerSelect(this.tellers[0].id);
|
||||||
|
}
|
||||||
});
|
});
|
||||||
this.loadHistory();
|
this.loadHistory();
|
||||||
}
|
}
|
||||||
@@ -108,6 +111,9 @@ export class ReconciliationComponent implements OnInit {
|
|||||||
.pipe(catchError(() => of({ cashiers: [] })))
|
.pipe(catchError(() => of({ cashiers: [] })))
|
||||||
.subscribe((r: any) => {
|
.subscribe((r: any) => {
|
||||||
this.cashiers = (r?.cashiers || []).map((c: any) => ({ id: c.id, label: c.staffName }));
|
this.cashiers = (r?.cashiers || []).map((c: any) => ({ id: c.id, label: c.staffName }));
|
||||||
|
if (this.cashiers.length === 1) {
|
||||||
|
this.onCashierSelect(this.cashiers[0].id);
|
||||||
|
}
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -70,6 +70,13 @@
|
|||||||
<span class="ws-header__title">Transaction Entry</span>
|
<span class="ws-header__title">Transaction Entry</span>
|
||||||
</div>
|
</div>
|
||||||
<div class="ws-header__right">
|
<div class="ws-header__right">
|
||||||
|
<button type="button" class="ws-btn ws-btn--ghost" (click)="openCustomerSearch()">
|
||||||
|
<svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
||||||
|
<circle cx="11" cy="11" r="8" />
|
||||||
|
<line x1="21" y1="21" x2="16.65" y2="16.65" />
|
||||||
|
</svg>
|
||||||
|
Search Customer
|
||||||
|
</button>
|
||||||
<span class="ws-header__effdt">
|
<span class="ws-header__effdt">
|
||||||
<svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
<svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
||||||
<rect x="3" y="4" width="18" height="18" rx="2" />
|
<rect x="3" y="4" width="18" height="18" rx="2" />
|
||||||
@@ -139,7 +146,7 @@
|
|||||||
[class.ws-f__ctl--req]="!row.paymentTypeId"
|
[class.ws-f__ctl--req]="!row.paymentTypeId"
|
||||||
>
|
>
|
||||||
<option [ngValue]="null">— Select —</option>
|
<option [ngValue]="null">— Select —</option>
|
||||||
@for (pt of paymentTypes; track pt.id) {
|
@for (pt of paymentTypesForRow(row); track pt.id) {
|
||||||
<option [ngValue]="pt.id">{{ pt.name }}</option>
|
<option [ngValue]="pt.id">{{ pt.name }}</option>
|
||||||
}
|
}
|
||||||
</select>
|
</select>
|
||||||
@@ -224,8 +231,11 @@
|
|||||||
<span class="ws-caption">Payment instrument</span>
|
<span class="ws-caption">Payment instrument</span>
|
||||||
<div class="ws-group__fields">
|
<div class="ws-group__fields">
|
||||||
<div class="ws-f ws-f--amt">
|
<div class="ws-f ws-f--amt">
|
||||||
<label class="ws-f__lbl" title="Transaction Amount">
|
<label
|
||||||
AMOUNT
|
class="ws-f__lbl"
|
||||||
|
[title]="row.acTyp === 'SHR' ? 'Number of Shares' : 'Transaction Amount'"
|
||||||
|
>
|
||||||
|
{{ row.acTyp === 'SHR' ? 'SHARE NO.' : 'AMOUNT' }}
|
||||||
@if (!isClerk && activeWithdrawalRequest) {
|
@if (!isClerk && activeWithdrawalRequest) {
|
||||||
<span class="ws-amt-locked-badge" title="Amount locked — approved withdrawal">🔒</span>
|
<span class="ws-amt-locked-badge" title="Amount locked — approved withdrawal">🔒</span>
|
||||||
}
|
}
|
||||||
@@ -235,20 +245,48 @@
|
|||||||
[class.ws-amt-wrap--cash]="isCashPayment && selectedRow === row"
|
[class.ws-amt-wrap--cash]="isCashPayment && selectedRow === row"
|
||||||
[class.ws-amt-wrap--locked]="!isClerk && !!activeWithdrawalRequest"
|
[class.ws-amt-wrap--locked]="!isClerk && !!activeWithdrawalRequest"
|
||||||
>
|
>
|
||||||
|
@if (row.acTyp !== 'SHR') {
|
||||||
<span class="ws-amt-prefix">₹</span>
|
<span class="ws-amt-prefix">₹</span>
|
||||||
|
}
|
||||||
<input
|
<input
|
||||||
class="ws-f__ctl ws-amt-input"
|
class="ws-f__ctl ws-amt-input"
|
||||||
type="number"
|
type="number"
|
||||||
[value]="row.amount ?? ''"
|
[value]="row.amount ?? ''"
|
||||||
(input)="row.amount = +$any($event.target).value"
|
(input)="row.amount = +$any($event.target).value; syncShareLinkedAmounts()"
|
||||||
(click)="$event.stopPropagation()"
|
(click)="$event.stopPropagation()"
|
||||||
placeholder="0.00"
|
[placeholder]="row.acTyp === 'SHR' ? '0' : '0.00'"
|
||||||
[readonly]="!isClerk && !!activeWithdrawalRequest"
|
[readonly]="!isClerk && !!activeWithdrawalRequest"
|
||||||
[class.ws-amt-input--locked]="!isClerk && !!activeWithdrawalRequest"
|
[class.ws-amt-input--locked]="!isClerk && !!activeWithdrawalRequest"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
@if (row.acTyp === 'SHR' && rowSharePrices[row.acNo]) {
|
||||||
|
<span class="ws-f__hint"
|
||||||
|
>= ₹{{ (row.amount ?? 0) * rowSharePrices[row.acNo] | number: '1.2-2' }} @ ₹{{
|
||||||
|
rowSharePrices[row.acNo] | number: '1.2-2'
|
||||||
|
}}/share</span
|
||||||
|
>
|
||||||
|
}
|
||||||
</div>
|
</div>
|
||||||
@if (row.typ !== 'Withdrawal Slip') {
|
@if (row.acTyp === 'SHR' && row.typ === 'Enter Bank Transfer') {
|
||||||
|
<div class="ws-f ws-f--cheqno">
|
||||||
|
<label class="ws-f__lbl" title="Board Resolution Number">RESOLUTION NO.</label>
|
||||||
|
<input
|
||||||
|
class="ws-f__ctl"
|
||||||
|
[(ngModel)]="row.resolutionNumber"
|
||||||
|
(click)="$event.stopPropagation()"
|
||||||
|
placeholder="e.g. BR-2026-014"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div class="ws-f ws-f--cheqdt">
|
||||||
|
<label class="ws-f__lbl" title="Date of Meeting">MEETING DATE</label>
|
||||||
|
<input
|
||||||
|
class="ws-f__ctl"
|
||||||
|
type="date"
|
||||||
|
[(ngModel)]="row.meetingDate"
|
||||||
|
(click)="$event.stopPropagation()"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
} @else if (row.typ !== 'Withdrawal Slip') {
|
||||||
<div class="ws-f ws-f--cheqno">
|
<div class="ws-f ws-f--cheqno">
|
||||||
<label class="ws-f__lbl" title="Cheque Number">CHEQUE NO.</label>
|
<label class="ws-f__lbl" title="Cheque Number">CHEQUE NO.</label>
|
||||||
<input
|
<input
|
||||||
@@ -300,6 +338,59 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<!-- ── Row 3: DD Charges & GST — only for TYPE=DD, TXN TP=Cash, AC.TP=GL ── -->
|
||||||
|
@if (isDdCashGlRow(row)) {
|
||||||
|
<div class="ws-group-row ws-group-row--mt">
|
||||||
|
<div class="ws-group ws-group--flex">
|
||||||
|
<span class="ws-caption">DD Charges & GST — Admin Rate {{ ddGstPercentage }}%</span>
|
||||||
|
<div class="ws-group__fields">
|
||||||
|
<div class="ws-f ws-f--amt">
|
||||||
|
<label class="ws-f__lbl" title="DD issuance charges / commission">CHARGES</label>
|
||||||
|
<div class="ws-amt-wrap">
|
||||||
|
<span class="ws-amt-prefix">₹</span>
|
||||||
|
<input
|
||||||
|
class="ws-f__ctl ws-amt-input"
|
||||||
|
type="number"
|
||||||
|
[value]="row.ddCharges ?? ''"
|
||||||
|
(input)="row.ddCharges = +$any($event.target).value"
|
||||||
|
(click)="$event.stopPropagation()"
|
||||||
|
placeholder="0.00"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="ws-f ws-f--amt">
|
||||||
|
<label class="ws-f__lbl" title="GST on charges, at the admin-configured rate">
|
||||||
|
GST ({{ ddGstPercentage }}%)
|
||||||
|
</label>
|
||||||
|
<div class="ws-amt-wrap ws-amt-wrap--locked">
|
||||||
|
<span class="ws-amt-prefix">₹</span>
|
||||||
|
<input
|
||||||
|
class="ws-f__ctl ws-amt-input ws-amt-input--locked"
|
||||||
|
type="text"
|
||||||
|
[value]="ddGstAmountFor(row) | number: '1.2-2'"
|
||||||
|
readonly
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="ws-f ws-f--amt">
|
||||||
|
<label class="ws-f__lbl" title="Amount + Charges + GST, rounded up to the next rupee">
|
||||||
|
TOTAL TO COLLECT
|
||||||
|
</label>
|
||||||
|
<div class="ws-amt-wrap ws-amt-wrap--locked">
|
||||||
|
<span class="ws-amt-prefix">₹</span>
|
||||||
|
<input
|
||||||
|
class="ws-f__ctl ws-amt-input ws-amt-input--locked"
|
||||||
|
type="text"
|
||||||
|
[value]="ddTotalPayableFor(row)"
|
||||||
|
readonly
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
}
|
}
|
||||||
@@ -652,6 +743,32 @@
|
|||||||
'—'
|
'—'
|
||||||
}}</span>
|
}}</span>
|
||||||
</div>
|
</div>
|
||||||
|
@if (selectedRow?.acTyp === 'SHR') {
|
||||||
|
<div class="te-field">
|
||||||
|
<span class="te-label">No. of Shares Held</span
|
||||||
|
><span class="te-value te-mono">{{ accountDetails.summary?.totalApprovedShares ?? 0 }}</span>
|
||||||
|
</div>
|
||||||
|
<div class="te-field">
|
||||||
|
<span class="te-label">Market Price / Share</span
|
||||||
|
><span class="te-value te-mono"
|
||||||
|
>{{ currencySymbol }}{{ accountDetails.currentMarketPrice | number: '1.2-2' }}</span
|
||||||
|
>
|
||||||
|
</div>
|
||||||
|
<div class="te-field">
|
||||||
|
<span class="te-label">Resolution No.</span
|
||||||
|
><span class="te-value te-mono">{{ accountDetails.resolutionNumber || '—' }}</span>
|
||||||
|
</div>
|
||||||
|
<div class="te-field">
|
||||||
|
<span class="te-label">Linked Savings A/C No.</span
|
||||||
|
><span class="te-value te-mono">{{ accountDetails.savingsAccountNumber || '—' }}</span>
|
||||||
|
</div>
|
||||||
|
@if (accountDetails.linkedLoanAccountNo) {
|
||||||
|
<div class="te-field">
|
||||||
|
<span class="te-label">Linked Loan A/C No.</span
|
||||||
|
><span class="te-value te-mono">{{ accountDetails.linkedLoanAccountNo }}</span>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="te-panel__header te-panel__header--mt">
|
<div class="te-panel__header te-panel__header--mt">
|
||||||
@@ -664,7 +781,13 @@
|
|||||||
<div class="te-panel__body">
|
<div class="te-panel__body">
|
||||||
<div class="te-balance-row">
|
<div class="te-balance-row">
|
||||||
<span class="te-bal-num">1.</span>
|
<span class="te-bal-num">1.</span>
|
||||||
<span class="te-bal-label">{{ accountDetails?.glCode ? 'GL Balance' : 'Current Balance' }}</span>
|
<span class="te-bal-label">{{
|
||||||
|
selectedRow?.acTyp === 'SHR'
|
||||||
|
? 'Share Balance'
|
||||||
|
: accountDetails?.glCode
|
||||||
|
? 'GL Balance'
|
||||||
|
: 'Current Balance'
|
||||||
|
}}</span>
|
||||||
<span class="te-bal-value te-bal-value--cr"
|
<span class="te-bal-value te-bal-value--cr"
|
||||||
>{{ currencySymbol }}{{ currentBalance | number: '1.2-2' }}</span
|
>{{ currencySymbol }}{{ currentBalance | number: '1.2-2' }}</span
|
||||||
>
|
>
|
||||||
@@ -681,7 +804,7 @@
|
|||||||
</div>
|
</div>
|
||||||
<div class="te-balance-row">
|
<div class="te-balance-row">
|
||||||
<span class="te-bal-num">4.</span><span class="te-bal-label">Freeze Amount</span>
|
<span class="te-bal-num">4.</span><span class="te-bal-label">Freeze Amount</span>
|
||||||
<span class="te-bal-value">{{ currencySymbol }}{{ freezeAmount | number: '1.2-2' }}</span>
|
<span class="te-bal-value">{{ currencySymbol }}0.00</span>
|
||||||
</div>
|
</div>
|
||||||
<div class="te-balance-row">
|
<div class="te-balance-row">
|
||||||
<span class="te-bal-num">5.</span><span class="te-bal-label">Withdrawable Amt.</span>
|
<span class="te-bal-num">5.</span><span class="te-bal-label">Withdrawable Amt.</span>
|
||||||
@@ -746,7 +869,7 @@
|
|||||||
</div>
|
</div>
|
||||||
<div class="te-bal-item">
|
<div class="te-bal-item">
|
||||||
<span class="te-bal-item__label">Transfer Hold</span>
|
<span class="te-bal-item__label">Transfer Hold</span>
|
||||||
<span class="te-bal-item__val">0.00</span>
|
<span class="te-bal-item__val">{{ freezeAmount | number: '1.2-2' }}</span>
|
||||||
</div>
|
</div>
|
||||||
<div class="te-bal-item">
|
<div class="te-bal-item">
|
||||||
<span class="te-bal-item__label">Clearing Hold</span>
|
<span class="te-bal-item__label">Clearing Hold</span>
|
||||||
@@ -862,7 +985,7 @@
|
|||||||
</div>
|
</div>
|
||||||
<div class="te-acs-b">
|
<div class="te-acs-b">
|
||||||
<span class="te-acs-b-l">Tr. Hold</span>
|
<span class="te-acs-b-l">Tr. Hold</span>
|
||||||
<span class="te-acs-b-v">0.00</span>
|
<span class="te-acs-b-v">{{ freezeAmount | number: '1.2-2' }}</span>
|
||||||
</div>
|
</div>
|
||||||
<div class="te-acs-b">
|
<div class="te-acs-b">
|
||||||
<span class="te-acs-b-l">Clr. Hold</span>
|
<span class="te-acs-b-l">Clr. Hold</span>
|
||||||
@@ -957,12 +1080,16 @@
|
|||||||
<path d="M16 21V5a2 2 0 0 0-2-2h-4a2 2 0 0 0-2 2v16" />
|
<path d="M16 21V5a2 2 0 0 0-2-2h-4a2 2 0 0 0-2 2v16" />
|
||||||
</svg>
|
</svg>
|
||||||
Denomination Entry
|
Denomination Entry
|
||||||
|
@if (isSelectedRowDdCashGl) {
|
||||||
|
<span class="te-mandate-badge">Target ₹{{ ddTargetTotal | number: '1.2-2' }}</span>
|
||||||
|
} @else {
|
||||||
@if (denominationTotal > 0) {
|
@if (denominationTotal > 0) {
|
||||||
<span class="te-mandate-badge">₹{{ denominationTotal | number: '1.2-2' }}</span>
|
<span class="te-mandate-badge">₹{{ denominationTotal | number: '1.2-2' }}</span>
|
||||||
}
|
}
|
||||||
@if (isCashPayment && denominationTotal <= 0) {
|
@if (isCashPayment && denominationTotal <= 0) {
|
||||||
<span class="te-denom-required-badge">⚠ Required for Cash</span>
|
<span class="te-denom-required-badge">⚠ Required for Cash</span>
|
||||||
}
|
}
|
||||||
|
}
|
||||||
</div>
|
</div>
|
||||||
<div class="te-panel__body">
|
<div class="te-panel__body">
|
||||||
<table class="te-denom-table">
|
<table class="te-denom-table">
|
||||||
@@ -1011,7 +1138,26 @@
|
|||||||
</tfoot>
|
</tfoot>
|
||||||
</table>
|
</table>
|
||||||
|
|
||||||
@if (denominationTotal > 0) {
|
@if (isSelectedRowDdCashGl) {
|
||||||
|
<div class="te-denom-actions">
|
||||||
|
@if (denominationTotal <= 0) {
|
||||||
|
<span class="te-denom-required-badge"
|
||||||
|
>⚠ Count notes/coins totalling ₹{{ ddTargetTotal | number: '1.2-2' }}</span
|
||||||
|
>
|
||||||
|
} @else if (ddDenomMatchesTarget()) {
|
||||||
|
<span class="te-denom-match-badge"
|
||||||
|
>✓ Matches Total To Collect ₹{{ ddTargetTotal | number: '1.2-2' }}</span
|
||||||
|
>
|
||||||
|
} @else {
|
||||||
|
<span class="te-denom-required-badge">
|
||||||
|
⚠ Must total ₹{{ ddTargetTotal | number: '1.2-2' }} (counted ₹{{
|
||||||
|
denominationTotal | number: '1.2-2'
|
||||||
|
}})
|
||||||
|
</span>
|
||||||
|
}
|
||||||
|
<button class="te-btn" type="button" (click)="clearDenominations()">Clear</button>
|
||||||
|
</div>
|
||||||
|
} @else if (denominationTotal > 0) {
|
||||||
<div class="te-denom-actions">
|
<div class="te-denom-actions">
|
||||||
<button class="te-btn te-btn--primary" type="button" (click)="applyDenominationTotal()">
|
<button class="te-btn te-btn--primary" type="button" (click)="applyDenominationTotal()">
|
||||||
Apply ₹{{ denominationTotal | number: '1.2-2' }} to Amount
|
Apply ₹{{ denominationTotal | number: '1.2-2' }} to Amount
|
||||||
@@ -1140,6 +1286,13 @@
|
|||||||
<span class="ws-header__title">Transaction Entry</span>
|
<span class="ws-header__title">Transaction Entry</span>
|
||||||
</div>
|
</div>
|
||||||
<div class="ws-header__right">
|
<div class="ws-header__right">
|
||||||
|
<button type="button" class="ws-btn ws-btn--ghost" (click)="openCustomerSearch()">
|
||||||
|
<svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
||||||
|
<circle cx="11" cy="11" r="8" />
|
||||||
|
<line x1="21" y1="21" x2="16.65" y2="16.65" />
|
||||||
|
</svg>
|
||||||
|
Search Customer
|
||||||
|
</button>
|
||||||
<span class="ws-header__effdt">
|
<span class="ws-header__effdt">
|
||||||
<svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
<svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
||||||
<rect x="3" y="4" width="18" height="18" rx="2" />
|
<rect x="3" y="4" width="18" height="18" rx="2" />
|
||||||
@@ -1191,7 +1344,7 @@
|
|||||||
[class.ws-f__ctl--req]="!row.paymentTypeId"
|
[class.ws-f__ctl--req]="!row.paymentTypeId"
|
||||||
>
|
>
|
||||||
<option [ngValue]="null">— Select —</option>
|
<option [ngValue]="null">— Select —</option>
|
||||||
@for (pt of paymentTypes; track pt.id) {
|
@for (pt of paymentTypesForRow(row); track pt.id) {
|
||||||
<option [ngValue]="pt.id">{{ pt.name }}</option>
|
<option [ngValue]="pt.id">{{ pt.name }}</option>
|
||||||
}
|
}
|
||||||
</select>
|
</select>
|
||||||
@@ -1270,7 +1423,7 @@
|
|||||||
<div class="ws-group__fields">
|
<div class="ws-group__fields">
|
||||||
<div class="ws-f ws-f--amt">
|
<div class="ws-f ws-f--amt">
|
||||||
<label class="ws-f__lbl">
|
<label class="ws-f__lbl">
|
||||||
AMOUNT
|
{{ row.acTyp === 'SHR' ? 'SHARE NO.' : 'AMOUNT' }}
|
||||||
@if (!isClerk && activeWithdrawalRequest) {
|
@if (!isClerk && activeWithdrawalRequest) {
|
||||||
<span class="ws-amt-locked-badge" title="Amount locked — approved withdrawal">🔒</span>
|
<span class="ws-amt-locked-badge" title="Amount locked — approved withdrawal">🔒</span>
|
||||||
}
|
}
|
||||||
@@ -1280,19 +1433,42 @@
|
|||||||
[class.ws-amt-wrap--cash]="isCashPayment && selectedRow === row"
|
[class.ws-amt-wrap--cash]="isCashPayment && selectedRow === row"
|
||||||
[class.ws-amt-wrap--locked]="!isClerk && !!activeWithdrawalRequest"
|
[class.ws-amt-wrap--locked]="!isClerk && !!activeWithdrawalRequest"
|
||||||
>
|
>
|
||||||
|
@if (row.acTyp !== 'SHR') {
|
||||||
<span class="ws-amt-prefix">₹</span>
|
<span class="ws-amt-prefix">₹</span>
|
||||||
|
}
|
||||||
<input
|
<input
|
||||||
class="ws-f__ctl ws-amt-input"
|
class="ws-f__ctl ws-amt-input"
|
||||||
type="number"
|
type="number"
|
||||||
[value]="row.amount ?? ''"
|
[value]="row.amount ?? ''"
|
||||||
(input)="row.amount = +$any($event.target).value"
|
(input)="row.amount = +$any($event.target).value; syncShareLinkedAmounts()"
|
||||||
(click)="$event.stopPropagation()"
|
(click)="$event.stopPropagation()"
|
||||||
placeholder="0.00"
|
[placeholder]="row.acTyp === 'SHR' ? '0' : '0.00'"
|
||||||
[readonly]="!isClerk && !!activeWithdrawalRequest"
|
[readonly]="!isClerk && !!activeWithdrawalRequest"
|
||||||
[class.ws-amt-input--locked]="!isClerk && !!activeWithdrawalRequest"
|
[class.ws-amt-input--locked]="!isClerk && !!activeWithdrawalRequest"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
@if (row.acTyp === 'SHR' && rowSharePrices[row.acNo]) {
|
||||||
|
<span class="ws-f__hint"
|
||||||
|
>= ₹{{ (row.amount ?? 0) * rowSharePrices[row.acNo] | number: '1.2-2' }} @ ₹{{
|
||||||
|
rowSharePrices[row.acNo] | number: '1.2-2'
|
||||||
|
}}/share</span
|
||||||
|
>
|
||||||
|
}
|
||||||
</div>
|
</div>
|
||||||
|
@if (row.acTyp === 'SHR' && row.typ === 'Enter Bank Transfer') {
|
||||||
|
<div class="ws-f ws-f--cheqno">
|
||||||
|
<label class="ws-f__lbl">RESOLUTION NO.</label>
|
||||||
|
<input
|
||||||
|
class="ws-f__ctl"
|
||||||
|
[(ngModel)]="row.resolutionNumber"
|
||||||
|
placeholder="e.g. BR-2026-014"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div class="ws-f ws-f--cheqdt">
|
||||||
|
<label class="ws-f__lbl">MEETING DATE</label>
|
||||||
|
<input class="ws-f__ctl" type="date" [(ngModel)]="row.meetingDate" />
|
||||||
|
</div>
|
||||||
|
} @else {
|
||||||
<div class="ws-f ws-f--cheqno">
|
<div class="ws-f ws-f--cheqno">
|
||||||
<label class="ws-f__lbl">CHEQUE NO.</label>
|
<label class="ws-f__lbl">CHEQUE NO.</label>
|
||||||
<input class="ws-f__ctl ws-f__ctl--mono" [(ngModel)]="row.chequeNo" placeholder="—" />
|
<input class="ws-f__ctl ws-f__ctl--mono" [(ngModel)]="row.chequeNo" placeholder="—" />
|
||||||
@@ -1301,6 +1477,7 @@
|
|||||||
<label class="ws-f__lbl">CHEQ. DATE</label>
|
<label class="ws-f__lbl">CHEQ. DATE</label>
|
||||||
<input class="ws-f__ctl" [(ngModel)]="row.cheqDate" placeholder="dd-mm-yyyy" />
|
<input class="ws-f__ctl" [(ngModel)]="row.cheqDate" placeholder="dd-mm-yyyy" />
|
||||||
</div>
|
</div>
|
||||||
|
}
|
||||||
<div class="ws-f ws-f--tok">
|
<div class="ws-f ws-f--tok">
|
||||||
<label class="ws-f__lbl">TOK. NO.</label>
|
<label class="ws-f__lbl">TOK. NO.</label>
|
||||||
<input
|
<input
|
||||||
@@ -1325,6 +1502,58 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<!-- ── Row 3: DD Charges & GST — only for TYPE=DD, TXN TP=Cash, AC.TP=GL ── -->
|
||||||
|
@if (isDdCashGlRow(row)) {
|
||||||
|
<div class="ws-group-row ws-group-row--mt">
|
||||||
|
<div class="ws-group ws-group--flex">
|
||||||
|
<span class="ws-caption">DD Charges & GST — Admin Rate {{ ddGstPercentage }}%</span>
|
||||||
|
<div class="ws-group__fields">
|
||||||
|
<div class="ws-f ws-f--amt">
|
||||||
|
<label class="ws-f__lbl" title="DD issuance charges / commission">CHARGES</label>
|
||||||
|
<div class="ws-amt-wrap">
|
||||||
|
<span class="ws-amt-prefix">₹</span>
|
||||||
|
<input
|
||||||
|
class="ws-f__ctl ws-amt-input"
|
||||||
|
type="number"
|
||||||
|
[value]="row.ddCharges ?? ''"
|
||||||
|
(input)="row.ddCharges = +$any($event.target).value"
|
||||||
|
placeholder="0.00"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="ws-f ws-f--amt">
|
||||||
|
<label class="ws-f__lbl" title="GST on charges, at the admin-configured rate">
|
||||||
|
GST ({{ ddGstPercentage }}%)
|
||||||
|
</label>
|
||||||
|
<div class="ws-amt-wrap ws-amt-wrap--locked">
|
||||||
|
<span class="ws-amt-prefix">₹</span>
|
||||||
|
<input
|
||||||
|
class="ws-f__ctl ws-amt-input ws-amt-input--locked"
|
||||||
|
type="text"
|
||||||
|
[value]="ddGstAmountFor(row) | number: '1.2-2'"
|
||||||
|
readonly
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="ws-f ws-f--amt">
|
||||||
|
<label class="ws-f__lbl" title="Amount + Charges + GST, rounded up to the next rupee">
|
||||||
|
TOTAL TO COLLECT
|
||||||
|
</label>
|
||||||
|
<div class="ws-amt-wrap ws-amt-wrap--locked">
|
||||||
|
<span class="ws-amt-prefix">₹</span>
|
||||||
|
<input
|
||||||
|
class="ws-f__ctl ws-amt-input ws-amt-input--locked"
|
||||||
|
type="text"
|
||||||
|
[value]="ddTotalPayableFor(row)"
|
||||||
|
readonly
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
}
|
}
|
||||||
@@ -1539,8 +1768,26 @@
|
|||||||
>{{ confirmData.symbol }}{{ confirmData.currentBalance | number: '1.2-2' }}</span
|
>{{ confirmData.symbol }}{{ confirmData.currentBalance | number: '1.2-2' }}</span
|
||||||
>
|
>
|
||||||
</div>
|
</div>
|
||||||
|
@if (confirmData.isDdCashGl) {
|
||||||
|
<div class="te-confirm-row">
|
||||||
|
<span class="te-confirm-label">DD Face Value</span
|
||||||
|
><span class="te-confirm-val"
|
||||||
|
>{{ confirmData.symbol }}{{ confirmData.ddFaceValue | number: '1.2-2' }}</span
|
||||||
|
>
|
||||||
|
</div>
|
||||||
|
<div class="te-confirm-row">
|
||||||
|
<span class="te-confirm-label">Charges</span
|
||||||
|
><span class="te-confirm-val">{{ confirmData.symbol }}{{ confirmData.ddCharges | number: '1.2-2' }}</span>
|
||||||
|
</div>
|
||||||
|
<div class="te-confirm-row">
|
||||||
|
<span class="te-confirm-label">GST ({{ confirmData.ddGstPercentage }}%)</span
|
||||||
|
><span class="te-confirm-val"
|
||||||
|
>{{ confirmData.symbol }}{{ confirmData.ddGstAmount | number: '1.2-2' }}</span
|
||||||
|
>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
<div class="te-confirm-row te-confirm-row--amount">
|
<div class="te-confirm-row te-confirm-row--amount">
|
||||||
<span class="te-confirm-label">Deposit Amount</span>
|
<span class="te-confirm-label">{{ confirmData.isDdCashGl ? 'Total To Collect' : 'Deposit Amount' }}</span>
|
||||||
<span class="te-confirm-val te-cr"
|
<span class="te-confirm-val te-cr"
|
||||||
>+{{ confirmData.symbol }}{{ confirmData.amount | number: '1.2-2' }}</span
|
>+{{ confirmData.symbol }}{{ confirmData.amount | number: '1.2-2' }}</span
|
||||||
>
|
>
|
||||||
|
|||||||
@@ -961,6 +961,14 @@ input.ws-amt-input--locked {
|
|||||||
flex-shrink: 0;
|
flex-shrink: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.ws-f__hint {
|
||||||
|
display: block;
|
||||||
|
margin-top: 2px;
|
||||||
|
font-size: 10px;
|
||||||
|
color: $ws-muted;
|
||||||
|
font-weight: 500;
|
||||||
|
}
|
||||||
|
|
||||||
.ws-amt-input {
|
.ws-amt-input {
|
||||||
flex: 1;
|
flex: 1;
|
||||||
height: 100%;
|
height: 100%;
|
||||||
@@ -1928,6 +1936,19 @@ select.te-cell {
|
|||||||
animation: te-pulse 1.5s ease-in-out infinite;
|
animation: te-pulse 1.5s ease-in-out infinite;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Denomination matches DD target badge
|
||||||
|
.te-denom-match-badge {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
padding: 2px 8px;
|
||||||
|
border-radius: 999px;
|
||||||
|
font-size: 10px;
|
||||||
|
font-weight: 700;
|
||||||
|
background: #dcfce7;
|
||||||
|
color: #15803d;
|
||||||
|
border: 1px solid #86efac;
|
||||||
|
}
|
||||||
|
|
||||||
@keyframes te-pulse {
|
@keyframes te-pulse {
|
||||||
0%,
|
0%,
|
||||||
100% {
|
100% {
|
||||||
|
|||||||
@@ -21,8 +21,14 @@ import { AuthenticationService } from '../../core/authentication/authentication.
|
|||||||
import { ClerkUiStateService, ClerkSavedAccount } from '../clerk-ui-state.service';
|
import { ClerkUiStateService, ClerkSavedAccount } from '../clerk-ui-state.service';
|
||||||
import { ClerkWithdrawalService } from '../clerk-withdrawal.service';
|
import { ClerkWithdrawalService } from '../clerk-withdrawal.service';
|
||||||
import { ClerkDisbursementService } from '../clerk-disbursement.service';
|
import { ClerkDisbursementService } from '../clerk-disbursement.service';
|
||||||
|
import { ClerkShareWithdrawalService } from '../clerk-share-withdrawal.service';
|
||||||
import { CashierDepositService } from '../cashier-deposit.service';
|
import { CashierDepositService } from '../cashier-deposit.service';
|
||||||
import { SettingsService } from '../../settings/settings.service';
|
import { SettingsService } from '../../settings/settings.service';
|
||||||
|
import {
|
||||||
|
CustomerSearchDialogComponent,
|
||||||
|
CustomerSearchResult
|
||||||
|
} from '../../shared/customer-search-dialog/customer-search-dialog.component';
|
||||||
|
import { SponsorDDService } from '../../sponsor-dd/sponsor-dd.service';
|
||||||
|
|
||||||
interface TxnRow {
|
interface TxnRow {
|
||||||
typ: string;
|
typ: string;
|
||||||
@@ -40,6 +46,12 @@ interface TxnRow {
|
|||||||
paymentTypeId: number | null;
|
paymentTypeId: number | null;
|
||||||
bankName: string;
|
bankName: string;
|
||||||
routingCode: string;
|
routingCode: string;
|
||||||
|
/** DD issuance charges/commission — only used when TYPE=DD, TXN TP=Cash, AC.TP=GL */
|
||||||
|
ddCharges: number | null;
|
||||||
|
/** Only used when this row is AC.TP=SHR inside an Enter Bank Transfer to its linked savings
|
||||||
|
* account — governs the share redemption (see submitShareRedemptionTransfer()). */
|
||||||
|
resolutionNumber?: string;
|
||||||
|
meetingDate?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
interface Denomination {
|
interface Denomination {
|
||||||
@@ -387,7 +399,12 @@ export class JointHolderDialogComponent implements OnInit {
|
|||||||
this.kyc = kycArr[0] ?? null;
|
this.kyc = kycArr[0] ?? null;
|
||||||
this.photo = photo ?? null;
|
this.photo = photo ?? null;
|
||||||
const docs: any[] = Array.isArray(documents) ? documents : [];
|
const docs: any[] = Array.isArray(documents) ? documents : [];
|
||||||
const sigDoc = docs.find((d: any) => d.name === 'clientSignature' || d.name?.toLowerCase().includes('signature'));
|
// Multiple 'clientSignature' documents can exist for clients who re-cropped/replaced their
|
||||||
|
// signature before uploads became single-slot, so pick the highest id (latest) rather than
|
||||||
|
// the first unordered match.
|
||||||
|
const sigDoc = docs
|
||||||
|
.filter((d: any) => d.name === 'clientSignature' || d.name?.toLowerCase().includes('signature'))
|
||||||
|
.sort((a: any, b: any) => b.id - a.id)[0];
|
||||||
this.loading = false;
|
this.loading = false;
|
||||||
if (sigDoc) {
|
if (sigDoc) {
|
||||||
this.http
|
this.http
|
||||||
@@ -741,9 +758,16 @@ export class TransactionEntryComponent implements OnInit, OnDestroy {
|
|||||||
private clerkUiState = inject(ClerkUiStateService);
|
private clerkUiState = inject(ClerkUiStateService);
|
||||||
private clerkWithdrawalService = inject(ClerkWithdrawalService);
|
private clerkWithdrawalService = inject(ClerkWithdrawalService);
|
||||||
private clerkDisbursementService = inject(ClerkDisbursementService);
|
private clerkDisbursementService = inject(ClerkDisbursementService);
|
||||||
|
private clerkShareWithdrawalService = inject(ClerkShareWithdrawalService);
|
||||||
private cashierDepositService = inject(CashierDepositService);
|
private cashierDepositService = inject(CashierDepositService);
|
||||||
readonly router = inject(Router);
|
readonly router = inject(Router);
|
||||||
private settingsService = inject(SettingsService);
|
private settingsService = inject(SettingsService);
|
||||||
|
private sponsorDDService = inject(SponsorDDService);
|
||||||
|
|
||||||
|
/** GST % on DD issuance charges — loaded from the admin GST/GL Settings (sponsor-dd module). */
|
||||||
|
ddGstPercentage = 18;
|
||||||
|
/** Admin-configured DD limit (sponsor-dd module) — caps a single DD's face value. Null = no limit. */
|
||||||
|
ddLimitAmount: number | null = null;
|
||||||
|
|
||||||
today = '';
|
today = '';
|
||||||
rows: TxnRow[] = [];
|
rows: TxnRow[] = [];
|
||||||
@@ -751,9 +775,38 @@ export class TransactionEntryComponent implements OnInit, OnDestroy {
|
|||||||
loadingAccount = false;
|
loadingAccount = false;
|
||||||
accountError = '';
|
accountError = '';
|
||||||
|
|
||||||
|
/** Share redemption settlement is always a book-transfer to the linked savings account — never a
|
||||||
|
* cash handover. A Share (SHR) row's own TXN TP. never offers a cash payment type, and neither
|
||||||
|
* does its paired CR (savings) row in "Enter Bank Transfer" mode — that CR row's payment type is
|
||||||
|
* the one that actually governs how the settlement credit posts (see submitShareRedemptionTransfer()).
|
||||||
|
* Every other account type/mode keeps the full, unfiltered list. */
|
||||||
|
paymentTypesForRow(row: TxnRow): any[] {
|
||||||
|
if (row.acTyp === 'SHR') {
|
||||||
|
return this.paymentTypes.filter((pt: any) => !pt.isCashPayment);
|
||||||
|
}
|
||||||
|
const isShareSettlementCredit =
|
||||||
|
row.typ === 'Enter Bank Transfer' &&
|
||||||
|
row.dbCr === 'Cr' &&
|
||||||
|
this.rows.some((r) => r.typ === 'Enter Bank Transfer' && r.dbCr === 'Dr' && r.acTyp === 'SHR');
|
||||||
|
if (isShareSettlementCredit) {
|
||||||
|
return this.paymentTypes.filter((pt: any) => !pt.isCashPayment);
|
||||||
|
}
|
||||||
|
return this.paymentTypes;
|
||||||
|
}
|
||||||
|
|
||||||
// Per-row account id/name map: acNo → Fineract id/name (used for multi-row disbursement)
|
// Per-row account id/name map: acNo → Fineract id/name (used for multi-row disbursement)
|
||||||
rowAccountIds: Record<string, number> = {};
|
rowAccountIds: Record<string, number> = {};
|
||||||
rowAccountNames: Record<string, string> = {};
|
rowAccountNames: Record<string, string> = {};
|
||||||
|
// Per-row Share account market price, keyed by acNo — lets a Share row's "SHARE NO." field
|
||||||
|
// auto-fill the settlement amount on any other row added alongside it (calculator convenience
|
||||||
|
// only; a Share row still can never itself be saved from this screen — see accountTypeConfig).
|
||||||
|
rowSharePrices: Record<string, number> = {};
|
||||||
|
// Per-row client id, keyed by acNo — needed to submit a Share Withdrawal Request from a row
|
||||||
|
// that isn't necessarily the currently "selected" one.
|
||||||
|
rowClientIds: Record<string, number> = {};
|
||||||
|
// Per-row linked savings account number, keyed by the Share account's own acNo — used to make
|
||||||
|
// sure a Share-to-linked-account Bank Transfer actually credits the right account.
|
||||||
|
rowLinkedSavingsAcNo: Record<string, string> = {};
|
||||||
|
|
||||||
// Full account + client data
|
// Full account + client data
|
||||||
accountDetails: any = null;
|
accountDetails: any = null;
|
||||||
@@ -889,6 +942,7 @@ export class TransactionEntryComponent implements OnInit, OnDestroy {
|
|||||||
});
|
});
|
||||||
|
|
||||||
this.loadCashVaultGl();
|
this.loadCashVaultGl();
|
||||||
|
this.loadDdGstPercentage();
|
||||||
|
|
||||||
if (this.reviewMode) {
|
if (this.reviewMode) {
|
||||||
this.loadRequestIntoForm(this.reviewRequestId!, 'review');
|
this.loadRequestIntoForm(this.reviewRequestId!, 'review');
|
||||||
@@ -973,6 +1027,18 @@ export class TransactionEntryComponent implements OnInit, OnDestroy {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Admin GST rate (set via GST / GL Settings in the sponsor-dd module) applied to DD issuance charges. */
|
||||||
|
private loadDdGstPercentage(): void {
|
||||||
|
this.sponsorDDService
|
||||||
|
.getGlMappings()
|
||||||
|
.pipe(catchError(() => of([])))
|
||||||
|
.subscribe((mappings: any[]) => {
|
||||||
|
const mapping = mappings && mappings.length ? mappings[0] : null;
|
||||||
|
this.ddGstPercentage = Number(mapping?.gstPercentage ?? 18);
|
||||||
|
this.ddLimitAmount = mapping?.ddLimitAmount != null ? Number(mapping.ddLimitAmount) : null;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
private loadDayStatus(): void {
|
private loadDayStatus(): void {
|
||||||
this.cashierService
|
this.cashierService
|
||||||
.getCashierDayStatus()
|
.getCashierDayStatus()
|
||||||
@@ -1139,7 +1205,9 @@ export class TransactionEntryComponent implements OnInit, OnDestroy {
|
|||||||
}
|
}
|
||||||
|
|
||||||
applyDenominationTotal(): void {
|
applyDenominationTotal(): void {
|
||||||
if (this.selectedRow) {
|
// DD + Cash + GL: amount is derived from Face Value + Charges + GST, not from counted cash —
|
||||||
|
// overwriting it here would feed back into the Charges/GST sum and compound on every click.
|
||||||
|
if (this.selectedRow && !this.isDdCashGlRow(this.selectedRow)) {
|
||||||
this.selectedRow.amount = this.denominationTotal;
|
this.selectedRow.amount = this.denominationTotal;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -1167,10 +1235,14 @@ export class TransactionEntryComponent implements OnInit, OnDestroy {
|
|||||||
tokNo: '',
|
tokNo: '',
|
||||||
paymentTypeId: this.findTransferPaymentTypeId(),
|
paymentTypeId: this.findTransferPaymentTypeId(),
|
||||||
bankName: '',
|
bankName: '',
|
||||||
routingCode: ''
|
routingCode: '',
|
||||||
|
ddCharges: null
|
||||||
};
|
};
|
||||||
this.rows.push(mirror);
|
this.rows.push(mirror);
|
||||||
this.selectedRow = mirror;
|
this.selectedRow = mirror;
|
||||||
|
if (active.acTyp === 'SHR') {
|
||||||
|
this.syncShareLinkedAmounts();
|
||||||
|
}
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
const row: TxnRow = {
|
const row: TxnRow = {
|
||||||
@@ -1188,7 +1260,8 @@ export class TransactionEntryComponent implements OnInit, OnDestroy {
|
|||||||
tokNo: '',
|
tokNo: '',
|
||||||
paymentTypeId: null,
|
paymentTypeId: null,
|
||||||
bankName: '',
|
bankName: '',
|
||||||
routingCode: ''
|
routingCode: '',
|
||||||
|
ddCharges: null
|
||||||
};
|
};
|
||||||
this.rows.push(row);
|
this.rows.push(row);
|
||||||
}
|
}
|
||||||
@@ -1227,6 +1300,40 @@ export class TransactionEntryComponent implements OnInit, OnDestroy {
|
|||||||
return pt?.id ?? null;
|
return pt?.id ?? null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** TYPE=DD + TXN TP=Cash + AC.TP=GL — the only combination that collects DD charges + GST from the customer. */
|
||||||
|
isDdCashGlRow(row: TxnRow): boolean {
|
||||||
|
const pt = this.paymentTypes.find((p: any) => p.id === row.paymentTypeId);
|
||||||
|
return row.typ === 'DD' && !!pt?.isCashPayment && row.acTyp === 'GL';
|
||||||
|
}
|
||||||
|
|
||||||
|
/** GST on DD charges only (never on the DD face value), at the admin-configured rate. */
|
||||||
|
ddGstAmountFor(row: TxnRow): number {
|
||||||
|
const charges = Number(row.ddCharges) || 0;
|
||||||
|
if (charges <= 0) return 0;
|
||||||
|
return Math.round(charges * this.ddGstPercentage) / 100;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Amount + Charges + GST, rounded UP to the next whole rupee — the total collected from the customer. */
|
||||||
|
ddTotalPayableFor(row: TxnRow): number {
|
||||||
|
const amount = Number(row.amount) || 0;
|
||||||
|
const charges = Number(row.ddCharges) || 0;
|
||||||
|
const gst = this.ddGstAmountFor(row);
|
||||||
|
return Math.ceil(amount + charges + gst - 1e-9);
|
||||||
|
}
|
||||||
|
|
||||||
|
get isSelectedRowDdCashGl(): boolean {
|
||||||
|
return !!this.selectedRow && this.isDdCashGlRow(this.selectedRow);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Cash the cashier must physically count for the selected DD row — Face Value + Charges + GST. */
|
||||||
|
get ddTargetTotal(): number {
|
||||||
|
return this.selectedRow ? this.ddTotalPayableFor(this.selectedRow) : 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
ddDenomMatchesTarget(): boolean {
|
||||||
|
return !!this.selectedRow && Math.abs(this.denominationTotal - this.ddTargetTotal) < 0.005;
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Submits a clerk double-entry request (maker-checker, PENDING until manager approves).
|
* Submits a clerk double-entry request (maker-checker, PENDING until manager approves).
|
||||||
* Disbursement: 1 DR [GL] source → N CR savings/current/GL destinations.
|
* Disbursement: 1 DR [GL] source → N CR savings/current/GL destinations.
|
||||||
@@ -1430,6 +1537,182 @@ export class TransactionEntryComponent implements OnInit, OnDestroy {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** One-step Share redemption from Bank Transfer mode: DR row is the Share account (its
|
||||||
|
* "SHARE NO." field), CR row must be that account's own linked savings account. Submits a
|
||||||
|
* governed Share Withdrawal Request (same maker-checker table/flow as the dedicated screen) —
|
||||||
|
* approval both redeems the shares and settles the linked account automatically. */
|
||||||
|
private submitShareRedemptionTransfer(shareRow: TxnRow, savingsRow: TxnRow): void {
|
||||||
|
// Belt-and-braces: the TXN TP. dropdown already hides cash payment types for the CR (savings)
|
||||||
|
// row (see paymentTypesForRow()), but re-check here too in case its payment type was set before
|
||||||
|
// that filter applied — settlement is always a book-transfer to the linked savings account, and
|
||||||
|
// it's the CR row's payment type that actually governs how that credit posts, never the DR
|
||||||
|
// (Share) row's, which has no real payment-type concept.
|
||||||
|
const settlementPaymentType = this.paymentTypes.find((p: any) => p.id === savingsRow.paymentTypeId);
|
||||||
|
if (!savingsRow.paymentTypeId) {
|
||||||
|
this.showAlert('error', 'Settlement Payment Type Required', [
|
||||||
|
'Select a Payment Type on the CR (savings account) row — it governs how the settlement credit posts.'
|
||||||
|
]);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (settlementPaymentType?.isCashPayment) {
|
||||||
|
this.showAlert('error', 'Cash Not Allowed for Share Transactions', [
|
||||||
|
'Share redemption settlement is always a book-transfer to the linked savings account, never cash.',
|
||||||
|
'Select a non-cash Payment Type on the CR row (e.g. Transfer or Share Redemption Transfer).'
|
||||||
|
]);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const shareAccountId = this.rowAccountIds[shareRow.acNo];
|
||||||
|
if (!shareAccountId) {
|
||||||
|
this.showAlert('error', 'Share Account Not Loaded', [`Please load account details for: ${shareRow.acNo}`]);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (!this.rowAccountIds[savingsRow.acNo]) {
|
||||||
|
this.showAlert('error', 'Savings Account Not Loaded', [`Please load account details for: ${savingsRow.acNo}`]);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const linkedAcNo = this.rowLinkedSavingsAcNo[shareRow.acNo];
|
||||||
|
const enteredAcNo = savingsRow.acNo?.trim();
|
||||||
|
const matchesLinked =
|
||||||
|
!!linkedAcNo &&
|
||||||
|
(linkedAcNo === enteredAcNo || linkedAcNo.endsWith(enteredAcNo) || savingsRow.ecsAcNo === linkedAcNo);
|
||||||
|
if (linkedAcNo && !matchesLinked) {
|
||||||
|
this.showAlert('error', 'Wrong Credit Account', [
|
||||||
|
`The CR account must be this Share account's own linked savings account: ${linkedAcNo}`,
|
||||||
|
`You entered: ${enteredAcNo}`
|
||||||
|
]);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (!linkedAcNo) {
|
||||||
|
this.showAlert('error', 'No Linked Savings Account', [
|
||||||
|
'This Share account has no linked savings account configured — settlement has nowhere to go.'
|
||||||
|
]);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const shares = Number(shareRow.amount) || 0;
|
||||||
|
if (shares <= 0) {
|
||||||
|
this.showAlert('error', 'Share No. Required', ['Enter the number of shares to withdraw (greater than zero).']);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (!shareRow.resolutionNumber?.trim()) {
|
||||||
|
this.showAlert('error', 'Board Resolution Number Required', [
|
||||||
|
'A share withdrawal must be authorised by a Board Resolution.'
|
||||||
|
]);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (!shareRow.meetingDate) {
|
||||||
|
this.showAlert('error', 'Date of Meeting Required', ['Enter the Board meeting date that passed the resolution.']);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const today = new Date();
|
||||||
|
const requestedDate = `${today.getFullYear()}-${String(today.getMonth() + 1).padStart(2, '0')}-${String(today.getDate()).padStart(2, '0')}`;
|
||||||
|
|
||||||
|
// Full snapshot of the CR (settlement) row exactly as the clerk filled it in — saved verbatim
|
||||||
|
// for audit purposes. Business logic still only uses settlementPaymentTypeId above; the rest of
|
||||||
|
// these fields are not re-read by the backend, they are the audit record of what was on screen.
|
||||||
|
const settlementRowSnapshot = {
|
||||||
|
branch: savingsRow.br,
|
||||||
|
accountType: savingsRow.acTyp,
|
||||||
|
accountNo: savingsRow.acNo,
|
||||||
|
ecsAccountNo: savingsRow.ecsAcNo || null,
|
||||||
|
dbCr: savingsRow.dbCr,
|
||||||
|
amount: savingsRow.amount ?? null,
|
||||||
|
chequeNo: savingsRow.chequeNo || null,
|
||||||
|
chequeDate: savingsRow.cheqDate || null,
|
||||||
|
tokenNo: savingsRow.tokNo || null,
|
||||||
|
description: savingsRow.description || null,
|
||||||
|
bankName: savingsRow.bankName || null,
|
||||||
|
routingCode: savingsRow.routingCode || null,
|
||||||
|
paymentTypeId: savingsRow.paymentTypeId,
|
||||||
|
paymentTypeName: settlementPaymentType?.name ?? null
|
||||||
|
};
|
||||||
|
|
||||||
|
const payload = {
|
||||||
|
shareAccountId,
|
||||||
|
accountNo: shareRow.acNo,
|
||||||
|
clientId: this.rowClientIds[shareRow.acNo] ?? null,
|
||||||
|
clientName: this.rowAccountNames[shareRow.acNo] ?? null,
|
||||||
|
requestedShares: shares,
|
||||||
|
requestedDate,
|
||||||
|
resolutionNumber: shareRow.resolutionNumber.trim(),
|
||||||
|
meetingDate: shareRow.meetingDate,
|
||||||
|
description: shareRow.description || null,
|
||||||
|
settlementPaymentTypeId: savingsRow.paymentTypeId,
|
||||||
|
settlementRowSnapshot
|
||||||
|
};
|
||||||
|
|
||||||
|
const sharePrice = this.rowSharePrices[shareRow.acNo] ?? 0;
|
||||||
|
const confirmLines = [
|
||||||
|
`Share Account: ${shareRow.acNo} · Client: ${this.rowAccountNames[shareRow.acNo] ?? '—'}`,
|
||||||
|
`Shares to Withdraw: ${shares} @ ₹${sharePrice.toLocaleString('en-IN', { minimumFractionDigits: 2 })} = ₹${(shares * sharePrice).toLocaleString('en-IN', { minimumFractionDigits: 2 })}`,
|
||||||
|
`Board Resolution No.: ${payload.resolutionNumber}`,
|
||||||
|
`Date of Meeting: ${payload.meetingDate}`,
|
||||||
|
`Settlement Credit To: ${linkedAcNo}`,
|
||||||
|
`Settlement Payment Type: ${settlementPaymentType?.name ?? '—'}`,
|
||||||
|
...(settlementRowSnapshot.ecsAccountNo ? [`ECS A/C No.: ${settlementRowSnapshot.ecsAccountNo}`] : []),
|
||||||
|
...(settlementRowSnapshot.chequeNo ? [`Cheque No.: ${settlementRowSnapshot.chequeNo}`] : []),
|
||||||
|
...(settlementRowSnapshot.chequeDate ? [`Cheque Date: ${settlementRowSnapshot.chequeDate}`] : []),
|
||||||
|
...(settlementRowSnapshot.tokenNo ? [`Token No.: ${settlementRowSnapshot.tokenNo}`] : []),
|
||||||
|
...(settlementRowSnapshot.description ? [`CR Row Remarks: ${settlementRowSnapshot.description}`] : []),
|
||||||
|
'',
|
||||||
|
'Validation checks:',
|
||||||
|
` ✓ Share account and linked savings account both loaded`,
|
||||||
|
` ✓ CR account matches this Share account's linked savings account`,
|
||||||
|
` ✓ Settlement Payment Type is non-cash`,
|
||||||
|
` ✓ Board Resolution Number and Date of Meeting provided`,
|
||||||
|
'',
|
||||||
|
'Pending Manager approval — redemption and settlement both post together on approval.'
|
||||||
|
];
|
||||||
|
this.showConfirmAlert('info', 'Confirm Share Withdrawal Request', confirmLines, 'Submit for Approval', () =>
|
||||||
|
this.doSubmitShareRedemptionTransfer(payload, shareRow, shares, linkedAcNo)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
private doSubmitShareRedemptionTransfer(
|
||||||
|
payload: Record<string, unknown>,
|
||||||
|
shareRow: TxnRow,
|
||||||
|
shares: number,
|
||||||
|
linkedAcNo: string
|
||||||
|
): void {
|
||||||
|
this.saving = true;
|
||||||
|
this.clerkShareWithdrawalService
|
||||||
|
.submit(payload)
|
||||||
|
.pipe(
|
||||||
|
catchError((err: any) => {
|
||||||
|
const msg =
|
||||||
|
err?.error?.errors?.[0]?.defaultUserMessage ??
|
||||||
|
err?.error?.developerMessage ??
|
||||||
|
err?.error?.defaultUserMessage ??
|
||||||
|
'Share withdrawal submission failed. Please try again.';
|
||||||
|
this.showAlert('error', 'Submission Failed', [msg]);
|
||||||
|
this.saving = false;
|
||||||
|
return of(null);
|
||||||
|
})
|
||||||
|
)
|
||||||
|
.subscribe((res: any) => {
|
||||||
|
this.saving = false;
|
||||||
|
if (res) {
|
||||||
|
this.showAlert('success', 'Share Withdrawal Submitted', [
|
||||||
|
`${shares} share(s) from ${shareRow.acNo} → settlement to ${linkedAcNo} on approval.`,
|
||||||
|
'Pending Manager approval. Track status in "My Share Requests".'
|
||||||
|
]);
|
||||||
|
this.rows = [];
|
||||||
|
this.rowAccountIds = {};
|
||||||
|
this.rowAccountNames = {};
|
||||||
|
this.rowSharePrices = {};
|
||||||
|
this.rowClientIds = {};
|
||||||
|
this.rowLinkedSavingsAcNo = {};
|
||||||
|
this.addRow();
|
||||||
|
this.selectedRow = this.rows[0];
|
||||||
|
this.accountDetails = null;
|
||||||
|
this.clientDetails = null;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
/** Cashier enters a token number → look up the APPROVED withdrawal request for auto-fill. */
|
/** Cashier enters a token number → look up the APPROVED withdrawal request for auto-fill. */
|
||||||
onTokNoBlur(row: TxnRow): void {
|
onTokNoBlur(row: TxnRow): void {
|
||||||
if (this.isClerk || !row.tokNo?.trim()) return;
|
if (this.isClerk || !row.tokNo?.trim()) return;
|
||||||
@@ -1466,6 +1749,25 @@ export class TransactionEntryComponent implements OnInit, OnDestroy {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** When exactly one row on screen is a Share account with a known market price, auto-fills every
|
||||||
|
* OTHER row's amount as (shares entered on that row) × (its market price) — a calculator
|
||||||
|
* convenience for filling the paired settlement entry. The Share row itself still can never be
|
||||||
|
* saved from this screen (see the SHR guard in confirmSubmit()) — this only helps compute the
|
||||||
|
* correct amount to carry over into the separate manual Deposit Slip entry. */
|
||||||
|
syncShareLinkedAmounts(): void {
|
||||||
|
const shareRows = this.rows.filter((r) => r.acTyp?.toUpperCase() === 'SHR' && this.rowSharePrices[r.acNo]);
|
||||||
|
if (shareRows.length !== 1) return;
|
||||||
|
const shareRow = shareRows[0];
|
||||||
|
const price = this.rowSharePrices[shareRow.acNo] ?? 0;
|
||||||
|
const shares = Number(shareRow.amount) || 0;
|
||||||
|
const total = Math.round(shares * price * 100) / 100;
|
||||||
|
this.rows.forEach((r) => {
|
||||||
|
if (r !== shareRow) {
|
||||||
|
r.amount = total;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
onAcNoBlur(row: TxnRow, index: number): void {
|
onAcNoBlur(row: TxnRow, index: number): void {
|
||||||
if (!row.acNo.trim()) return;
|
if (!row.acNo.trim()) return;
|
||||||
this.selectedRow = row;
|
this.selectedRow = row;
|
||||||
@@ -1503,6 +1805,34 @@ export class TransactionEntryComponent implements OnInit, OnDestroy {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private static readonly SEARCH_ACCOUNT_TYPE_TO_AC_TYP: Record<string, string> = {
|
||||||
|
SAVINGS: 'SD',
|
||||||
|
CURRENT: 'CA',
|
||||||
|
FIXED_DEPOSIT: 'FD',
|
||||||
|
RECURRING_DEPOSIT: 'RD'
|
||||||
|
};
|
||||||
|
|
||||||
|
openCustomerSearch(): void {
|
||||||
|
const dialogRef = this.dialog.open(CustomerSearchDialogComponent, {
|
||||||
|
width: '960px',
|
||||||
|
maxWidth: '95vw'
|
||||||
|
});
|
||||||
|
|
||||||
|
dialogRef.afterClosed().subscribe((result: CustomerSearchResult | undefined) => {
|
||||||
|
if (!result || !result.accountNo) return;
|
||||||
|
// selectedRow is only set once the clerk types into A/C NO. themselves - on a fresh screen
|
||||||
|
// it's still null, so fall back to the first (only) row that addRow() created at startup.
|
||||||
|
const targetRow = this.selectedRow ?? this.rows[0];
|
||||||
|
if (!targetRow) return;
|
||||||
|
|
||||||
|
const acTyp = TransactionEntryComponent.SEARCH_ACCOUNT_TYPE_TO_AC_TYP[result.accountType || ''] || 'SD';
|
||||||
|
targetRow.acTyp = acTyp;
|
||||||
|
targetRow.acNo = result.accountNo;
|
||||||
|
this.selectedRow = targetRow;
|
||||||
|
this.loadAccountDetails(result.accountNo, acTyp);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
private loadAccountDetails(acNo: string, acTyp: string): void {
|
private loadAccountDetails(acNo: string, acTyp: string): void {
|
||||||
this.lastLoadedAcNo = acNo;
|
this.lastLoadedAcNo = acNo;
|
||||||
this.loadingAccount = true;
|
this.loadingAccount = true;
|
||||||
@@ -1536,11 +1866,13 @@ export class TransactionEntryComponent implements OnInit, OnDestroy {
|
|||||||
if (token !== this.searchToken) return; // stale result — newer search started
|
if (token !== this.searchToken) return; // stale result — newer search started
|
||||||
const allItems: any[] = res?.pageItems ?? (Array.isArray(res) ? res : []);
|
const allItems: any[] = res?.pageItems ?? (Array.isArray(res) ? res : []);
|
||||||
const f = cfg.matchField;
|
const f = cfg.matchField;
|
||||||
// Exact match first, then suffix/partial match (backend may return by last-N-digits)
|
// Exact match first, then suffix/partial match. Some account types (e.g. Share — its
|
||||||
|
// list endpoint only accepts offset/limit, no accountNo filter at all) always return the
|
||||||
|
// FULL unfiltered list rather than a server-side-narrowed one, so a partial match can't be
|
||||||
|
// gated on "exactly one row came back" — it never would be for those types.
|
||||||
const exactMatch = allItems.find((x: any) => x[f] === acNo);
|
const exactMatch = allItems.find((x: any) => x[f] === acNo);
|
||||||
const partialMatch =
|
const partialMatch = !exactMatch
|
||||||
!exactMatch && allItems.length === 1
|
? (allItems.find((x: any) => x[f]?.endsWith(acNo)) ?? allItems.find((x: any) => x[f]?.includes(acNo)))
|
||||||
? allItems.find((x: any) => x[f]?.endsWith(acNo) || x[f]?.includes(acNo))
|
|
||||||
: null;
|
: null;
|
||||||
const match = exactMatch ?? partialMatch ?? null;
|
const match = exactMatch ?? partialMatch ?? null;
|
||||||
if (match?.id) {
|
if (match?.id) {
|
||||||
@@ -1562,6 +1894,17 @@ export class TransactionEntryComponent implements OnInit, OnDestroy {
|
|||||||
siAccountType: number;
|
siAccountType: number;
|
||||||
txnBase: string;
|
txnBase: string;
|
||||||
txnSearchSupported: boolean;
|
txnSearchSupported: boolean;
|
||||||
|
/** True when this account type has no separate REST transactions sub-resource at all (e.g.
|
||||||
|
* Share accounts — Fineract returns transactions embedded on the account detail response
|
||||||
|
* itself via ?associations=all, never at /accounts/share/{id}/transactions). Skips the
|
||||||
|
* "today's transactions" HTTP call entirely instead of hitting a 404. */
|
||||||
|
txnFetchUnsupported?: boolean;
|
||||||
|
/** True when this account type has no standing-instruction "from account type" mapping on
|
||||||
|
* the backend at all (PortfolioAccountType only defines LOAN/SAVINGS/CURRENT — Share isn't
|
||||||
|
* a valid standing-instruction source account). Skips that HTTP call entirely instead of
|
||||||
|
* sending an fromAccountType value the backend's query-builder silently can't match,
|
||||||
|
* which otherwise produces a malformed "WHERE <empty>" SQL statement server-side. */
|
||||||
|
siFetchUnsupported?: boolean;
|
||||||
maxLimit: number;
|
maxLimit: number;
|
||||||
skipGlobalSearch: boolean;
|
skipGlobalSearch: boolean;
|
||||||
listQueryParam: string;
|
listQueryParam: string;
|
||||||
@@ -1596,6 +1939,8 @@ export class TransactionEntryComponent implements OnInit, OnDestroy {
|
|||||||
siAccountType: 4,
|
siAccountType: 4,
|
||||||
txnBase: '/accounts/share',
|
txnBase: '/accounts/share',
|
||||||
txnSearchSupported: false,
|
txnSearchSupported: false,
|
||||||
|
txnFetchUnsupported: true,
|
||||||
|
siFetchUnsupported: true,
|
||||||
maxLimit: 500,
|
maxLimit: 500,
|
||||||
skipGlobalSearch: true,
|
skipGlobalSearch: true,
|
||||||
listQueryParam: 'accountNo',
|
listQueryParam: 'accountNo',
|
||||||
@@ -1708,6 +2053,8 @@ export class TransactionEntryComponent implements OnInit, OnDestroy {
|
|||||||
siAccountType: number;
|
siAccountType: number;
|
||||||
txnBase: string;
|
txnBase: string;
|
||||||
txnSearchSupported: boolean;
|
txnSearchSupported: boolean;
|
||||||
|
txnFetchUnsupported?: boolean;
|
||||||
|
siFetchUnsupported?: boolean;
|
||||||
detailParams?: string;
|
detailParams?: string;
|
||||||
},
|
},
|
||||||
token: number,
|
token: number,
|
||||||
@@ -1740,13 +2087,29 @@ export class TransactionEntryComponent implements OnInit, OnDestroy {
|
|||||||
this.rowAccountIds[this.selectedRow.acNo] = acc.id;
|
this.rowAccountIds[this.selectedRow.acNo] = acc.id;
|
||||||
this.rowAccountNames[this.selectedRow.acNo] =
|
this.rowAccountNames[this.selectedRow.acNo] =
|
||||||
acc.name ?? acc.displayName ?? acc.glAccountName ?? acc.accountNo ?? '';
|
acc.name ?? acc.displayName ?? acc.glAccountName ?? acc.accountNo ?? '';
|
||||||
|
this.rowClientIds[this.selectedRow.acNo] = acc.clientId ?? null;
|
||||||
}
|
}
|
||||||
if (this.selectedRow) {
|
if (this.selectedRow) {
|
||||||
this.selectedRow.ecsAcNo = acc.glCode ?? acc.savingsAccountNumber ?? acc.accountNo ?? '';
|
// Share accounts: ECS field is the share account's own number — acc.savingsAccountNumber
|
||||||
|
// is a different, unrelated account (the linked settlement savings account) and must
|
||||||
|
// never be shown here.
|
||||||
|
this.selectedRow.ecsAcNo =
|
||||||
|
acTyp?.toUpperCase() === 'SHR'
|
||||||
|
? (acc.accountNo ?? '')
|
||||||
|
: (acc.glCode ?? acc.savingsAccountNumber ?? acc.accountNo ?? '');
|
||||||
}
|
}
|
||||||
if (this.isClerk) this.clerkUiState.setFacilities(this.facilities, acNo, acTyp);
|
if (this.isClerk) this.clerkUiState.setFacilities(this.facilities, acNo, acTyp);
|
||||||
this.computeSameDayNeftRtgs(acc.transactions);
|
this.computeSameDayNeftRtgs(acc.transactions);
|
||||||
this.computeSameDayChequeClearing(acc.transactions);
|
this.computeSameDayChequeClearing(acc.transactions);
|
||||||
|
if (acTyp?.toUpperCase() === 'SHR' && this.selectedRow?.acNo) {
|
||||||
|
// Keyed by the row's own typed acNo (e.g. "0011"), not the resolved full account number
|
||||||
|
// (e.g. "000000011") — rowAccountIds/rowAccountNames above use the same key, and every
|
||||||
|
// lookup elsewhere (syncShareLinkedAmounts, submitShareRedemptionTransfer, the template's
|
||||||
|
// rowSharePrices[row.acNo]) reads via row.acNo, so this must match or every lookup misses.
|
||||||
|
this.rowSharePrices[this.selectedRow.acNo] = Number(acc.currentMarketPrice) || 0;
|
||||||
|
this.rowLinkedSavingsAcNo[this.selectedRow.acNo] = acc.savingsAccountNumber ?? '';
|
||||||
|
this.syncShareLinkedAmounts();
|
||||||
|
}
|
||||||
return true;
|
return true;
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -1776,7 +2139,12 @@ export class TransactionEntryComponent implements OnInit, OnDestroy {
|
|||||||
this.clientAddresses = Array.isArray(r.addresses) ? r.addresses : [];
|
this.clientAddresses = Array.isArray(r.addresses) ? r.addresses : [];
|
||||||
this.clientPhotoUrl = r.photo ?? null;
|
this.clientPhotoUrl = r.photo ?? null;
|
||||||
const docs: any[] = Array.isArray(r.documents) ? r.documents : [];
|
const docs: any[] = Array.isArray(r.documents) ? r.documents : [];
|
||||||
const sigDoc = docs.find((d: any) => d.name === 'clientSignature' || d.name?.toLowerCase().includes('signature'));
|
// Multiple 'clientSignature' documents can exist for clients who re-cropped/replaced their
|
||||||
|
// signature before uploads became single-slot, so pick the highest id (latest) rather than
|
||||||
|
// the first unordered match.
|
||||||
|
const sigDoc = docs
|
||||||
|
.filter((d: any) => d.name === 'clientSignature' || d.name?.toLowerCase().includes('signature'))
|
||||||
|
.sort((a: any, b: any) => b.id - a.id)[0];
|
||||||
if (sigDoc) {
|
if (sigDoc) {
|
||||||
this.http
|
this.http
|
||||||
.get(`/clients/${clientId}/documents/${sigDoc.id}/attachment`, { responseType: 'blob' })
|
.get(`/clients/${clientId}/documents/${sigDoc.id}/attachment`, { responseType: 'blob' })
|
||||||
@@ -1811,10 +2179,14 @@ export class TransactionEntryComponent implements OnInit, OnDestroy {
|
|||||||
documents: this.http.get<any[]>(`/clients/${clientId}/documents`).pipe(catchError(() => of([]))),
|
documents: this.http.get<any[]>(`/clients/${clientId}/documents`).pipe(catchError(() => of([]))),
|
||||||
notes: this.http.get<any>(`/savings/${accountId}/notes`).pipe(catchError(() => of([]))),
|
notes: this.http.get<any>(`/savings/${accountId}/notes`).pipe(catchError(() => of([]))),
|
||||||
linkedAcc: this.http.get<any>(`/clients/${clientId}/accounts`).pipe(catchError(() => of(null))),
|
linkedAcc: this.http.get<any>(`/clients/${clientId}/accounts`).pipe(catchError(() => of(null))),
|
||||||
si: this.http
|
si: cfg.siFetchUnsupported
|
||||||
|
? of({ pageItems: [] })
|
||||||
|
: this.http
|
||||||
.get<any>(`/standinginstructions?fromAccountType=${cfg.siAccountType}&fromAccountId=${accountId}&limit=10`)
|
.get<any>(`/standinginstructions?fromAccountType=${cfg.siAccountType}&fromAccountId=${accountId}&limit=10`)
|
||||||
.pipe(catchError(() => of({ pageItems: [] }))),
|
.pipe(catchError(() => of({ pageItems: [] }))),
|
||||||
todayTxns: cfg.txnSearchSupported
|
todayTxns: cfg.txnFetchUnsupported
|
||||||
|
? of({ pageItems: [] })
|
||||||
|
: cfg.txnSearchSupported
|
||||||
? this.http
|
? this.http
|
||||||
.get<any>(
|
.get<any>(
|
||||||
`${cfg.txnBase}/${accountId}/transactions/search?fromDate=${todayStr}&toDate=${todayStr}&limit=20`
|
`${cfg.txnBase}/${accountId}/transactions/search?fromDate=${todayStr}&toDate=${todayStr}&limit=20`
|
||||||
@@ -1987,6 +2359,11 @@ export class TransactionEntryComponent implements OnInit, OnDestroy {
|
|||||||
// ── Balance getters ────────────────────────────────────────────────────────
|
// ── Balance getters ────────────────────────────────────────────────────────
|
||||||
|
|
||||||
get currentBalance(): number {
|
get currentBalance(): number {
|
||||||
|
if (this.selectedRow?.acTyp === 'SHR') {
|
||||||
|
const shares = Number(this.accountDetails?.summary?.totalApprovedShares ?? 0);
|
||||||
|
const unitPrice = Number(this.accountDetails?.currentMarketPrice ?? 0);
|
||||||
|
return shares * unitPrice;
|
||||||
|
}
|
||||||
return this.accountDetails?.summary?.accountBalance ?? this.accountDetails?.organizationRunningBalance ?? 0;
|
return this.accountDetails?.summary?.accountBalance ?? this.accountDetails?.organizationRunningBalance ?? 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1999,13 +2376,30 @@ export class TransactionEntryComponent implements OnInit, OnDestroy {
|
|||||||
}
|
}
|
||||||
|
|
||||||
get freezeAmount(): number {
|
get freezeAmount(): number {
|
||||||
return this.accountDetails?.summary?.holdAmountBalance ?? 0;
|
// Savings exposes the lien/user hold as root-level `savingsAmountOnHold`; Current exposes its
|
||||||
|
// own (differently-sourced) hold as `onHoldFunds`. `summary.holdAmountBalance` never existed in
|
||||||
|
// either API response, so this always read 0 before.
|
||||||
|
const acTyp = this.selectedRow?.acTyp;
|
||||||
|
const isCurrent = acTyp === 'CA' || acTyp === 'CB';
|
||||||
|
if (isCurrent) {
|
||||||
|
return this.accountDetails?.onHoldFunds ?? this.accountDetails?.summary?.onHoldFunds ?? 0;
|
||||||
|
}
|
||||||
|
return this.accountDetails?.savingsAmountOnHold ?? 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
get withdrawableAmount(): number {
|
get withdrawableAmount(): number {
|
||||||
const avail = this.accountDetails?.summary?.availableBalance ?? this.currentBalance;
|
const avail = this.accountDetails?.summary?.availableBalance ?? this.currentBalance;
|
||||||
|
const acTyp = this.selectedRow?.acTyp;
|
||||||
|
const isCurrent = acTyp === 'CA' || acTyp === 'CB';
|
||||||
|
// Savings' availableBalance is already computed hold-adjusted on the backend (subtracts both
|
||||||
|
// guarantor and lien holds) — subtracting freezeAmount again here would double-count it.
|
||||||
|
// Current's availableBalance is just the raw balance and does NOT subtract its hold, so it
|
||||||
|
// still needs freezeAmount subtracted client-side.
|
||||||
|
if (isCurrent) {
|
||||||
return Math.max(0, avail - this.freezeAmount);
|
return Math.max(0, avail - this.freezeAmount);
|
||||||
}
|
}
|
||||||
|
return Math.max(0, avail);
|
||||||
|
}
|
||||||
|
|
||||||
get currencySymbol(): string {
|
get currencySymbol(): string {
|
||||||
return this.accountDetails?.currency?.displaySymbol ?? '₹';
|
return this.accountDetails?.currency?.displaySymbol ?? '₹';
|
||||||
@@ -2383,6 +2777,23 @@ export class TransactionEntryComponent implements OnInit, OnDestroy {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Share redemption via linked-account transfer: DR=Share account, CR=its linked savings
|
||||||
|
// account. A Share account can never be debited by a plain ledger transfer (no such Fineract
|
||||||
|
// command exists) — so this exact one-DR/one-CR shape is routed to submitShareRedemptionTransfer()
|
||||||
|
// instead, which submits a governed Share Withdrawal Request through the same maker-checker
|
||||||
|
// table as the dedicated screen; approval both redeems the shares and settles the linked account.
|
||||||
|
const shareDrRows = drRows.filter((r) => r.acTyp === 'SHR');
|
||||||
|
if (shareDrRows.length > 0) {
|
||||||
|
if (drRows.length !== 1 || crRows.length !== 1) {
|
||||||
|
this.showAlert('error', 'Unsupported Share Transfer', [
|
||||||
|
'A Share redemption transfer supports exactly one DR row (the Share account) and one CR row (its linked savings account).'
|
||||||
|
]);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
this.submitShareRedemptionTransfer(shareDrRows[0], crRows[0]);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
// Bank transfer: only savings (SD) or current (CA/CB) accounts allowed
|
// Bank transfer: only savings (SD) or current (CA/CB) accounts allowed
|
||||||
const allowedTypes = new Set([
|
const allowedTypes = new Set([
|
||||||
'SD',
|
'SD',
|
||||||
@@ -2515,20 +2926,55 @@ export class TransactionEntryComponent implements OnInit, OnDestroy {
|
|||||||
|
|
||||||
const selectedPtEarly = this.paymentTypes.find((p: any) => p.id === row.paymentTypeId);
|
const selectedPtEarly = this.paymentTypes.find((p: any) => p.id === row.paymentTypeId);
|
||||||
const isCash = !!selectedPtEarly?.isCashPayment;
|
const isCash = !!selectedPtEarly?.isCashPayment;
|
||||||
|
const isDdCashGl = this.isDdCashGlRow(row);
|
||||||
// Clerk mode: always use manually entered amount; Cashier mode: cash amount comes from denomination
|
// Clerk mode: always use manually entered amount; Cashier mode: cash amount comes from denomination
|
||||||
const amount = isCash && !this.clerkMode ? this.denominationTotal : (row.amount ?? this.denominationTotal);
|
// DD + Cash + GL: amount is DD face value + charges + GST (rounded up), not the counted denomination
|
||||||
|
const amount = isDdCashGl
|
||||||
|
? this.ddTotalPayableFor(row)
|
||||||
|
: isCash && !this.clerkMode
|
||||||
|
? this.denominationTotal
|
||||||
|
: (row.amount ?? this.denominationTotal);
|
||||||
|
|
||||||
if (isCash && this.denominationTotal <= 0 && !this.clerkMode) {
|
if (isCash && this.denominationTotal <= 0 && !this.clerkMode && !isDdCashGl) {
|
||||||
this.showAlert('error', 'Denomination Required', [
|
this.showAlert('error', 'Denomination Required', [
|
||||||
'Cash transaction: Please enter denomination (notes/coins count) first.'
|
'Cash transaction: Please enter denomination (notes/coins count) first.'
|
||||||
]);
|
]);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// DD + Cash + GL: physical cash counted must reconcile with Face Value + Charges + GST
|
||||||
|
if (isDdCashGl && !this.clerkMode) {
|
||||||
|
if (this.denominationTotal <= 0) {
|
||||||
|
this.showAlert('error', 'Denomination Required', [
|
||||||
|
`Cash transaction: Please count notes/coins totalling ₹${amount.toLocaleString('en-IN', { minimumFractionDigits: 2 })} (DD Face Value + Charges + GST).`
|
||||||
|
]);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (Math.abs(this.denominationTotal - amount) > 0.005) {
|
||||||
|
this.showAlert('error', 'Denomination Mismatch', [
|
||||||
|
`Cash counted: ₹${this.denominationTotal.toLocaleString('en-IN', { minimumFractionDigits: 2 })}`,
|
||||||
|
`Total To Collect: ₹${amount.toLocaleString('en-IN', { minimumFractionDigits: 2 })} (Face Value + Charges + GST)`,
|
||||||
|
'Adjust the notes/coins breakdown to match the Total To Collect exactly.'
|
||||||
|
]);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
if (!amount || amount <= 0) {
|
if (!amount || amount <= 0) {
|
||||||
this.showAlert('error', 'Amount Required', ['Please enter a valid transaction amount.']);
|
this.showAlert('error', 'Amount Required', ['Please enter a valid transaction amount.']);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// DD + Cash + GL: reject at cash intake, before the cashier even accepts the money, if the DD
|
||||||
|
// Face Value exceeds the admin-configured DD limit (same limit enforced again at DD issuance).
|
||||||
|
if (isDdCashGl && this.ddLimitAmount != null && this.ddLimitAmount > 0 && Number(row.amount) > this.ddLimitAmount) {
|
||||||
|
this.showAlert('error', 'DD Limit Exceeded', [
|
||||||
|
`DD Face Value (₹${Number(row.amount).toLocaleString('en-IN', { minimumFractionDigits: 2 })}) exceeds the configured DD limit of ₹${this.ddLimitAmount.toLocaleString('en-IN', { minimumFractionDigits: 2 })}.`,
|
||||||
|
'Reduce the amount, or issue this as separate DDs within the limit each.'
|
||||||
|
]);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
const isDeposit = row.dbCr === 'Cr';
|
const isDeposit = row.dbCr === 'Cr';
|
||||||
|
|
||||||
// Clerk cash deposit is not permitted — cash handling (notes/coins) is cashier's responsibility
|
// Clerk cash deposit is not permitted — cash handling (notes/coins) is cashier's responsibility
|
||||||
@@ -2730,6 +3176,13 @@ export class TransactionEntryComponent implements OnInit, OnDestroy {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (isWithdrawal) {
|
if (isWithdrawal) {
|
||||||
|
// Token-based cashier disbursement: the manager approval already validated the balance
|
||||||
|
// and posted the real withdrawal (money already left the account). Re-running the balance/
|
||||||
|
// min-balance checks here would compare the post-deduction balance against the same
|
||||||
|
// already-approved amount again, which fails for almost any real withdrawal.
|
||||||
|
const isTokenDisbursement = !this.isClerk && !!this.activeWithdrawalRequest;
|
||||||
|
|
||||||
|
if (!isTokenDisbursement) {
|
||||||
// 3. Balance check
|
// 3. Balance check
|
||||||
const withdrawable = this.withdrawableAmount;
|
const withdrawable = this.withdrawableAmount;
|
||||||
if (amount > withdrawable) {
|
if (amount > withdrawable) {
|
||||||
@@ -2755,6 +3208,7 @@ export class TransactionEntryComponent implements OnInit, OnDestroy {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// 5. Denomination overdraft — fire whenever denomination is entered for withdrawal
|
// 5. Denomination overdraft — fire whenever denomination is entered for withdrawal
|
||||||
if (this.denominationTotal > 0 && this.hasWithdrawalDenomOverdraft) {
|
if (this.denominationTotal > 0 && this.hasWithdrawalDenomOverdraft) {
|
||||||
@@ -2823,7 +3277,12 @@ export class TransactionEntryComponent implements OnInit, OnDestroy {
|
|||||||
paymentType: selectedPt?.name ?? 'Cash',
|
paymentType: selectedPt?.name ?? 'Cash',
|
||||||
chequeNo: row.chequeNo || null,
|
chequeNo: row.chequeNo || null,
|
||||||
symbol: this.currencySymbol,
|
symbol: this.currencySymbol,
|
||||||
tokNo: this.activeWithdrawalRequest?.tok_no ?? null
|
tokNo: this.activeWithdrawalRequest?.tok_no ?? null,
|
||||||
|
isDdCashGl: this.isDdCashGlRow(row),
|
||||||
|
ddFaceValue: row.amount ?? 0,
|
||||||
|
ddCharges: row.ddCharges ?? 0,
|
||||||
|
ddGstPercentage: this.ddGstPercentage,
|
||||||
|
ddGstAmount: this.ddGstAmountFor(row)
|
||||||
};
|
};
|
||||||
this.showConfirmDialog = true;
|
this.showConfirmDialog = true;
|
||||||
}
|
}
|
||||||
@@ -2831,12 +3290,30 @@ export class TransactionEntryComponent implements OnInit, OnDestroy {
|
|||||||
confirmSubmit(): void {
|
confirmSubmit(): void {
|
||||||
this.showConfirmDialog = false;
|
this.showConfirmDialog = false;
|
||||||
const row = this.selectedRow!;
|
const row = this.selectedRow!;
|
||||||
const amount = row.amount ?? this.denominationTotal;
|
const amount = this.isDdCashGlRow(row) ? this.ddTotalPayableFor(row) : (row.amount ?? this.denominationTotal);
|
||||||
const isDeposit = row.dbCr === 'Cr';
|
const isDeposit = row.dbCr === 'Cr';
|
||||||
const command = isDeposit ? 'deposit' : 'withdrawal';
|
const command = isDeposit ? 'deposit' : 'withdrawal';
|
||||||
const accountId = this.accountDetails.id;
|
const accountId = this.accountDetails.id;
|
||||||
const cfg = this.accountTypeConfig(row.acTyp);
|
const cfg = this.accountTypeConfig(row.acTyp);
|
||||||
|
|
||||||
|
// Share accounts must never go through this generic Deposit/Withdrawal Slip flow — both
|
||||||
|
// buildDepositPayload() and buildWithdrawalPayload() hardcode accountType: 'SAVINGS' and send
|
||||||
|
// savingsAccountId: this.accountDetails.id, which for a Share account is the SHARE account's
|
||||||
|
// own numeric id, not a real savings account id. Approving that request would call
|
||||||
|
// savingsAccountWithdrawal/Deposit against whatever savings account happens to share that same
|
||||||
|
// numeric id — a completely unrelated account. Share purchase/redemption must go through the
|
||||||
|
// dedicated Buy Shares / Share Withdrawal Request screens, which carry the correct governance
|
||||||
|
// (resolution number, meeting date, minimum-tenure and linked-loan checks).
|
||||||
|
if (row.acTyp === 'SHR') {
|
||||||
|
this.showAlert('error', 'Not Supported for Share Accounts', [
|
||||||
|
isDeposit
|
||||||
|
? 'Share purchases must be made from the Buy Shares screen on the Share Account itself.'
|
||||||
|
: 'Share withdrawal (redeem shares) must be submitted from "New Share Withdrawal Request" in the Clerk Dashboard.',
|
||||||
|
'This generic Deposit/Withdrawal Slip does not carry the Board Resolution Number or the governance checks required for shares.'
|
||||||
|
]);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
// GL withdrawal → direct journal entry (no maker-checker)
|
// GL withdrawal → direct journal entry (no maker-checker)
|
||||||
// GL deposit → falls through to maker-checker block below
|
// GL deposit → falls through to maker-checker block below
|
||||||
if (row.acTyp === 'GL' && !isDeposit) {
|
if (row.acTyp === 'GL' && !isDeposit) {
|
||||||
@@ -3236,7 +3713,17 @@ export class TransactionEntryComponent implements OnInit, OnDestroy {
|
|||||||
description: row.description || null,
|
description: row.description || null,
|
||||||
chequeNo: row.chequeNo || null,
|
chequeNo: row.chequeNo || null,
|
||||||
bankName: row.bankName || null,
|
bankName: row.bankName || null,
|
||||||
denominationData: isCash && this.denominationTotal > 0 ? JSON.stringify(this.denominationCounts) : null
|
denominationData: isCash && this.denominationTotal > 0 ? JSON.stringify(this.denominationCounts) : null,
|
||||||
|
// DD + Cash + GL breakdown — persisted so the Sponsor DD issuance screen can read the face
|
||||||
|
// value/charges/GST back once this request is approved, instead of retyping them.
|
||||||
|
...(this.isDdCashGlRow(row)
|
||||||
|
? {
|
||||||
|
ddFaceValue: row.amount ?? 0,
|
||||||
|
ddCharges: row.ddCharges ?? 0,
|
||||||
|
ddGstPercentage: this.ddGstPercentage,
|
||||||
|
ddGstAmount: this.ddGstAmountFor(row)
|
||||||
|
}
|
||||||
|
: {})
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,150 @@
|
|||||||
|
<!--
|
||||||
|
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="container">
|
||||||
|
<div class="page-header">
|
||||||
|
<h2>{{ 'labels.inputs.Cheque Book' | translate }} #{{ book.id }}</h2>
|
||||||
|
<button mat-raised-button [routerLink]="['/chequebook', 'books']">{{ 'labels.buttons.Back' | translate }}</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<mat-card class="info-card">
|
||||||
|
<mat-card-content>
|
||||||
|
<div class="info-grid">
|
||||||
|
<div>
|
||||||
|
<span class="k">{{ 'labels.inputs.Account' | translate }}</span>
|
||||||
|
<span>
|
||||||
|
{{ book.accountNo || book.accountId }}
|
||||||
|
@if (book.clientName) {
|
||||||
|
— {{ book.clientName }}
|
||||||
|
}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<span class="k">{{ 'labels.inputs.Account Type' | translate }}</span
|
||||||
|
><span>{{ book.accountType }}</span>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<span class="k">{{ 'labels.inputs.Office' | translate }}</span
|
||||||
|
><span>{{ book.officeName }}</span>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<span class="k">{{ 'labels.inputs.Series' | translate }}</span
|
||||||
|
><span>{{ book.seriesCode || '–' }}</span>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<span class="k">{{ 'labels.inputs.Leaf Range' | translate }}</span>
|
||||||
|
<span>{{ book.rangeStart ? book.rangeStart + ' – ' + book.rangeEnd : '–' }}</span>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<span class="k">{{ 'labels.inputs.Quantity' | translate }}</span
|
||||||
|
><span>{{ book.noOfBooks }} {{ 'labels.inputs.books' | translate }}</span>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<span class="k">{{ 'labels.inputs.Status' | translate }}</span
|
||||||
|
><span>{{ book.status }}</span>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<span class="k">{{ 'labels.inputs.Issue Date' | translate }}</span
|
||||||
|
><span>{{ book.issueDate ? (book.issueDate | dateFormat) : '–' }}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</mat-card-content>
|
||||||
|
</mat-card>
|
||||||
|
|
||||||
|
@if (book.status === 'ISSUED') {
|
||||||
|
<div class="action-toolbar">
|
||||||
|
<button mat-stroked-button color="warn" (click)="startAction('stoppayment')">
|
||||||
|
{{ 'labels.buttons.Stop Payment' | translate }}
|
||||||
|
</button>
|
||||||
|
<button mat-stroked-button (click)="startAction('revokestoppayment')">
|
||||||
|
{{ 'labels.buttons.Revoke Stop Payment' | translate }}
|
||||||
|
</button>
|
||||||
|
<button mat-stroked-button color="warn" (click)="startAction('reportlost')">
|
||||||
|
{{ 'labels.buttons.Report Lost' | translate }}
|
||||||
|
</button>
|
||||||
|
<button mat-stroked-button (click)="startAction('cancelleaf')">
|
||||||
|
{{ 'labels.buttons.Cancel Leaf' | translate }}
|
||||||
|
</button>
|
||||||
|
<button mat-stroked-button color="primary" (click)="startAction('markused')">
|
||||||
|
{{ 'labels.buttons.Mark as Used' | translate }}
|
||||||
|
</button>
|
||||||
|
<button mat-stroked-button color="warn" (click)="startAction('return')">
|
||||||
|
{{ 'labels.buttons.Flag Returned' | translate }}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
|
||||||
|
@if (activeAction) {
|
||||||
|
<mat-card class="add-form">
|
||||||
|
<mat-card-content>
|
||||||
|
<form [formGroup]="actionForm" (ngSubmit)="submitAction()">
|
||||||
|
<div class="form-grid">
|
||||||
|
<mat-form-field appearance="fill">
|
||||||
|
<mat-label>{{ 'labels.inputs.From Leaf Seq' | translate }}</mat-label>
|
||||||
|
<input matInput type="number" formControlName="fromLeafSeq" />
|
||||||
|
<mat-hint>{{ 'labels.text.Leave blank for the whole book' | translate }}</mat-hint>
|
||||||
|
</mat-form-field>
|
||||||
|
<mat-form-field appearance="fill">
|
||||||
|
<mat-label>{{ 'labels.inputs.To Leaf Seq' | translate }}</mat-label>
|
||||||
|
<input matInput type="number" formControlName="toLeafSeq" />
|
||||||
|
</mat-form-field>
|
||||||
|
@if (activeAction !== 'revokestoppayment') {
|
||||||
|
<mat-form-field appearance="fill">
|
||||||
|
<mat-label>{{ 'labels.inputs.Reason' | translate }}</mat-label>
|
||||||
|
<input matInput formControlName="reason" [required]="activeAction !== 'markused'" />
|
||||||
|
@if (activeAction === 'markused') {
|
||||||
|
<mat-hint>{{
|
||||||
|
'labels.text.Optional — e.g. cleared per bank statement dated ...' | translate
|
||||||
|
}}</mat-hint>
|
||||||
|
}
|
||||||
|
</mat-form-field>
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
<div class="form-actions">
|
||||||
|
<button mat-raised-button type="button" (click)="activeAction = null">
|
||||||
|
{{ 'labels.buttons.Cancel' | translate }}
|
||||||
|
</button>
|
||||||
|
<button mat-raised-button color="warn" type="submit" [disabled]="actionForm.invalid || busy">
|
||||||
|
{{ 'labels.buttons.Submit' | translate }}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</mat-card-content>
|
||||||
|
</mat-card>
|
||||||
|
}
|
||||||
|
|
||||||
|
<div class="leaf-grid-wrapper mat-elevation-z1">
|
||||||
|
<div class="leaf-grid">
|
||||||
|
@for (l of leaves; track l.leafSerialNo) {
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="serial-circle"
|
||||||
|
[ngClass]="
|
||||||
|
l.status === 'ACTIVE'
|
||||||
|
? 'ok'
|
||||||
|
: l.status === 'USED'
|
||||||
|
? 'used'
|
||||||
|
: l.status === 'STOP_PAYMENT'
|
||||||
|
? 'warn'
|
||||||
|
: l.status === 'LOST'
|
||||||
|
? 'bad'
|
||||||
|
: l.status === 'RETURNED'
|
||||||
|
? 'returned'
|
||||||
|
: 'neutral'
|
||||||
|
"
|
||||||
|
(click)="selectLeaf(l)"
|
||||||
|
>
|
||||||
|
{{ l.leafSerialNo }}
|
||||||
|
</button>
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
@if (leaves.length === 0) {
|
||||||
|
<div class="empty-state">{{ 'labels.text.No records to display' | translate }}</div>
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
@@ -0,0 +1,131 @@
|
|||||||
|
/**
|
||||||
|
* 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/.
|
||||||
|
*/
|
||||||
|
|
||||||
|
.container {
|
||||||
|
padding: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.page-header {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: center;
|
||||||
|
margin-bottom: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.info-card {
|
||||||
|
margin-bottom: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.info-grid {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(auto-fill, minmax(200px, 1fr));
|
||||||
|
gap: 12px 24px;
|
||||||
|
|
||||||
|
div {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
}
|
||||||
|
|
||||||
|
.k {
|
||||||
|
font-size: 0.72rem;
|
||||||
|
text-transform: uppercase;
|
||||||
|
color: rgb(0 0 0 / 50%);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.action-toolbar {
|
||||||
|
display: flex;
|
||||||
|
gap: 12px;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
margin-bottom: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.add-form {
|
||||||
|
margin-bottom: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.form-grid {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(auto-fill, minmax(200px, 1fr));
|
||||||
|
gap: 12px 20px;
|
||||||
|
}
|
||||||
|
|
||||||
|
mat-form-field {
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.form-actions {
|
||||||
|
display: flex;
|
||||||
|
justify-content: flex-end;
|
||||||
|
gap: 12px;
|
||||||
|
margin-top: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.leaf-grid-wrapper {
|
||||||
|
background: #fff;
|
||||||
|
padding: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.leaf-grid {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: 14px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.serial-circle {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
width: 48px;
|
||||||
|
height: 48px;
|
||||||
|
border-radius: 50%;
|
||||||
|
border: 2px solid currentcolor;
|
||||||
|
background: #fff;
|
||||||
|
font-size: 12px;
|
||||||
|
font-weight: 700;
|
||||||
|
font-variant-numeric: tabular-nums;
|
||||||
|
cursor: pointer;
|
||||||
|
transition:
|
||||||
|
transform 0.1s ease,
|
||||||
|
box-shadow 0.1s ease;
|
||||||
|
|
||||||
|
&:hover {
|
||||||
|
transform: scale(1.06);
|
||||||
|
box-shadow: 0 2px 6px rgb(0 0 0 / 15%);
|
||||||
|
}
|
||||||
|
|
||||||
|
&.ok {
|
||||||
|
color: #28c07a;
|
||||||
|
}
|
||||||
|
|
||||||
|
&.used {
|
||||||
|
color: #1976d2;
|
||||||
|
}
|
||||||
|
|
||||||
|
&.bad {
|
||||||
|
color: #d32f2f;
|
||||||
|
}
|
||||||
|
|
||||||
|
&.warn {
|
||||||
|
color: #a16207;
|
||||||
|
}
|
||||||
|
|
||||||
|
&.neutral {
|
||||||
|
color: #757575;
|
||||||
|
}
|
||||||
|
|
||||||
|
&.returned {
|
||||||
|
color: #9333ea;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.empty-state {
|
||||||
|
padding: 24px;
|
||||||
|
text-align: center;
|
||||||
|
color: rgb(0 0 0 / 50%);
|
||||||
|
}
|
||||||
@@ -0,0 +1,137 @@
|
|||||||
|
/**
|
||||||
|
* Copyright since 2025 Mifos Initiative
|
||||||
|
*
|
||||||
|
* This Source Code Form is subject to the terms of the Mozilla Public
|
||||||
|
* License, v. 2.0. If a copy of the MPL was not distributed with this
|
||||||
|
* file, You can obtain one at http://mozilla.org/MPL/2.0/.
|
||||||
|
*/
|
||||||
|
|
||||||
|
/** Angular Imports */
|
||||||
|
import { Component, OnInit, inject } from '@angular/core';
|
||||||
|
import { ActivatedRoute, RouterLink } from '@angular/router';
|
||||||
|
import { UntypedFormBuilder, UntypedFormGroup, Validators } from '@angular/forms';
|
||||||
|
import { MatButtonModule } from '@angular/material/button';
|
||||||
|
import { MatSnackBar, MatSnackBarModule } from '@angular/material/snack-bar';
|
||||||
|
import { MatDialog } from '@angular/material/dialog';
|
||||||
|
import { FaIconComponent } from '@fortawesome/angular-fontawesome';
|
||||||
|
import { Observable } from 'rxjs';
|
||||||
|
import { STANDALONE_SHARED_IMPORTS } from 'app/standalone-shared.module';
|
||||||
|
|
||||||
|
/** Custom Services */
|
||||||
|
import { ChequeBookService } from '../chequebook.service';
|
||||||
|
import { LeafDetailDialogComponent } from './leaf-detail-dialog/leaf-detail-dialog.component';
|
||||||
|
|
||||||
|
type LeafAction = 'stoppayment' | 'revokestoppayment' | 'reportlost' | 'cancelleaf' | 'markused' | 'return';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* One issued cheque book: its leaf register, and the six leaf-range actions (stop-payment, revoke,
|
||||||
|
* report-lost, cancel-leaf, mark-used, return) — each defaults to the whole book when no range is given.
|
||||||
|
* "Mark as used" is a manual record (this CBS has no clearing/presentment integration to detect it itself)
|
||||||
|
* — a branch sets it once it sees a leaf clear on the bank/clearing statement. "Return" flags a leaf as
|
||||||
|
* presented-and-dishonoured (insufficient funds, signature mismatch, etc.) — terminal, like report-lost.
|
||||||
|
*/
|
||||||
|
@Component({
|
||||||
|
selector: 'mifosx-cheque-book-detail',
|
||||||
|
templateUrl: './cheque-book-detail.component.html',
|
||||||
|
styleUrls: ['./cheque-book-detail.component.scss'],
|
||||||
|
imports: [
|
||||||
|
...STANDALONE_SHARED_IMPORTS,
|
||||||
|
RouterLink,
|
||||||
|
MatButtonModule,
|
||||||
|
MatSnackBarModule,
|
||||||
|
FaIconComponent
|
||||||
|
]
|
||||||
|
})
|
||||||
|
export class ChequeBookDetailComponent implements OnInit {
|
||||||
|
private route = inject(ActivatedRoute);
|
||||||
|
private formBuilder = inject(UntypedFormBuilder);
|
||||||
|
private chequeBookService = inject(ChequeBookService);
|
||||||
|
private snackBar = inject(MatSnackBar);
|
||||||
|
private dialog = inject(MatDialog);
|
||||||
|
|
||||||
|
private notify(message: string, isError = false): void {
|
||||||
|
this.snackBar.open(message, 'Dismiss', { duration: isError ? 6000 : 3000 });
|
||||||
|
}
|
||||||
|
|
||||||
|
bookId!: string;
|
||||||
|
book: any = {};
|
||||||
|
leaves: any[] = [];
|
||||||
|
busy = false;
|
||||||
|
|
||||||
|
activeAction: LeafAction | null = null;
|
||||||
|
actionForm: UntypedFormGroup;
|
||||||
|
|
||||||
|
constructor() {
|
||||||
|
this.actionForm = this.formBuilder.group({
|
||||||
|
fromLeafSeq: [''],
|
||||||
|
toLeafSeq: [''],
|
||||||
|
reason: ['']
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
ngOnInit(): void {
|
||||||
|
this.bookId = this.route.snapshot.paramMap.get('id') as string;
|
||||||
|
this.loadAll();
|
||||||
|
}
|
||||||
|
|
||||||
|
loadAll(): void {
|
||||||
|
this.chequeBookService.getBook(this.bookId).subscribe((data: any) => (this.book = data || {}));
|
||||||
|
this.chequeBookService.getLeaves(this.bookId).subscribe((data: any[]) => {
|
||||||
|
this.leaves = data || [];
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
selectLeaf(leaf: any): void {
|
||||||
|
this.dialog.open(LeafDetailDialogComponent, { data: leaf, width: '420px' });
|
||||||
|
}
|
||||||
|
|
||||||
|
startAction(action: LeafAction): void {
|
||||||
|
this.activeAction = this.activeAction === action ? null : action;
|
||||||
|
this.actionForm.reset();
|
||||||
|
const reasonControl = this.actionForm.get('reason');
|
||||||
|
if (action === 'revokestoppayment' || action === 'markused') {
|
||||||
|
reasonControl?.clearValidators();
|
||||||
|
} else {
|
||||||
|
reasonControl?.setValidators(Validators.required);
|
||||||
|
}
|
||||||
|
reasonControl?.updateValueAndValidity();
|
||||||
|
}
|
||||||
|
|
||||||
|
submitAction(): void {
|
||||||
|
if (this.actionForm.invalid || this.busy || !this.activeAction) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
this.busy = true;
|
||||||
|
const raw = this.actionForm.value;
|
||||||
|
const payload: any = { locale: 'en' };
|
||||||
|
if (raw.fromLeafSeq !== '' && raw.fromLeafSeq != null) payload.fromLeafSeq = raw.fromLeafSeq;
|
||||||
|
if (raw.toLeafSeq !== '' && raw.toLeafSeq != null) payload.toLeafSeq = raw.toLeafSeq;
|
||||||
|
if (raw.reason) payload.reason = raw.reason;
|
||||||
|
|
||||||
|
const actionCalls: Record<LeafAction, (bookId: string, payload: any) => Observable<any>> = {
|
||||||
|
stoppayment: (id, p) => this.chequeBookService.stopPayment(id, p),
|
||||||
|
revokestoppayment: (id, p) => this.chequeBookService.revokeStopPayment(id, p),
|
||||||
|
reportlost: (id, p) => this.chequeBookService.reportBookLost(id, p),
|
||||||
|
cancelleaf: (id, p) => this.chequeBookService.cancelLeaf(id, p),
|
||||||
|
markused: (id, p) => this.chequeBookService.markUsed(id, p),
|
||||||
|
return: (id, p) => this.chequeBookService.returnBook(id, p)
|
||||||
|
};
|
||||||
|
const call = actionCalls[this.activeAction](this.bookId, payload);
|
||||||
|
|
||||||
|
call.subscribe({
|
||||||
|
next: () => {
|
||||||
|
this.busy = false;
|
||||||
|
this.activeAction = null;
|
||||||
|
this.notify('Action applied');
|
||||||
|
this.loadAll();
|
||||||
|
},
|
||||||
|
error: () => {
|
||||||
|
this.busy = false;
|
||||||
|
this.notify(
|
||||||
|
'Action failed — check the leaf range and that the leaves are in a state that allows this action',
|
||||||
|
true
|
||||||
|
);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
+72
@@ -0,0 +1,72 @@
|
|||||||
|
<!--
|
||||||
|
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>
|
||||||
|
<span
|
||||||
|
class="serial-circle"
|
||||||
|
[ngClass]="
|
||||||
|
data.status === 'ACTIVE'
|
||||||
|
? 'ok'
|
||||||
|
: data.status === 'USED'
|
||||||
|
? 'used'
|
||||||
|
: data.status === 'STOP_PAYMENT'
|
||||||
|
? 'warn'
|
||||||
|
: data.status === 'LOST'
|
||||||
|
? 'bad'
|
||||||
|
: data.status === 'RETURNED'
|
||||||
|
? 'returned'
|
||||||
|
: 'neutral'
|
||||||
|
"
|
||||||
|
>{{ data.leafSerialNo }}</span
|
||||||
|
>
|
||||||
|
</h1>
|
||||||
|
<div mat-dialog-content>
|
||||||
|
<div class="info-grid">
|
||||||
|
<div>
|
||||||
|
<span class="k">{{ 'labels.inputs.Serial No' | translate }}</span
|
||||||
|
><span>{{ data.leafSerialNo }}</span>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<span class="k">{{ 'labels.inputs.Status' | translate }}</span
|
||||||
|
><span>{{ data.status }}</span>
|
||||||
|
</div>
|
||||||
|
@if (data.stopReason) {
|
||||||
|
<div>
|
||||||
|
<span class="k">{{ 'labels.inputs.Stop Reason' | translate }}</span
|
||||||
|
><span>{{ data.stopReason }}</span>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
@if (data.lostReason) {
|
||||||
|
<div>
|
||||||
|
<span class="k">{{ 'labels.inputs.Lost Reason' | translate }}</span
|
||||||
|
><span>{{ data.lostReason }}</span>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
@if (data.cancelledReason) {
|
||||||
|
<div>
|
||||||
|
<span class="k">{{ 'labels.inputs.Cancelled Reason' | translate }}</span
|
||||||
|
><span>{{ data.cancelledReason }}</span>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
@if (data.usedReason) {
|
||||||
|
<div>
|
||||||
|
<span class="k">{{ 'labels.inputs.Used Reason' | translate }}</span
|
||||||
|
><span>{{ data.usedReason }}</span>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
@if (data.returnReason) {
|
||||||
|
<div>
|
||||||
|
<span class="k">{{ 'labels.inputs.Return Reason' | translate }}</span
|
||||||
|
><span>{{ data.returnReason }}</span>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<mat-dialog-actions align="end">
|
||||||
|
<button mat-raised-button mat-dialog-close>{{ 'labels.buttons.Close' | translate }}</button>
|
||||||
|
</mat-dialog-actions>
|
||||||
+59
@@ -0,0 +1,59 @@
|
|||||||
|
/**
|
||||||
|
* 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/.
|
||||||
|
*/
|
||||||
|
|
||||||
|
.info-grid {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(auto-fill, minmax(180px, 1fr));
|
||||||
|
gap: 12px 24px;
|
||||||
|
min-width: 280px;
|
||||||
|
|
||||||
|
div {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
}
|
||||||
|
|
||||||
|
.k {
|
||||||
|
font-size: 0.72rem;
|
||||||
|
text-transform: uppercase;
|
||||||
|
color: rgb(0 0 0 / 50%);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.serial-circle {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
width: 48px;
|
||||||
|
height: 48px;
|
||||||
|
border-radius: 50%;
|
||||||
|
border: 2px solid currentcolor;
|
||||||
|
background: #fff;
|
||||||
|
font-size: 13px;
|
||||||
|
font-weight: 700;
|
||||||
|
font-variant-numeric: tabular-nums;
|
||||||
|
|
||||||
|
&.ok {
|
||||||
|
color: #28c07a;
|
||||||
|
}
|
||||||
|
|
||||||
|
&.used {
|
||||||
|
color: #1976d2;
|
||||||
|
}
|
||||||
|
|
||||||
|
&.bad {
|
||||||
|
color: #d32f2f;
|
||||||
|
}
|
||||||
|
|
||||||
|
&.warn {
|
||||||
|
color: #a16207;
|
||||||
|
}
|
||||||
|
|
||||||
|
&.neutral {
|
||||||
|
color: #757575;
|
||||||
|
}
|
||||||
|
}
|
||||||
+39
@@ -0,0 +1,39 @@
|
|||||||
|
/**
|
||||||
|
* Copyright since 2025 Mifos Initiative
|
||||||
|
*
|
||||||
|
* This Source Code Form is subject to the terms of the Mozilla Public
|
||||||
|
* License, v. 2.0. If a copy of the MPL was not distributed with this
|
||||||
|
* file, You can obtain one at http://mozilla.org/MPL/2.0/.
|
||||||
|
*/
|
||||||
|
|
||||||
|
/** Angular Imports */
|
||||||
|
import { Component, inject } from '@angular/core';
|
||||||
|
import {
|
||||||
|
MatDialogRef,
|
||||||
|
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';
|
||||||
|
|
||||||
|
/** Popup shown when a leaf circle in the cheque-book detail grid is clicked. */
|
||||||
|
@Component({
|
||||||
|
selector: 'mifosx-leaf-detail-dialog',
|
||||||
|
templateUrl: './leaf-detail-dialog.component.html',
|
||||||
|
styleUrls: ['./leaf-detail-dialog.component.scss'],
|
||||||
|
imports: [
|
||||||
|
...STANDALONE_SHARED_IMPORTS,
|
||||||
|
MatDialogTitle,
|
||||||
|
CdkScrollable,
|
||||||
|
MatDialogContent,
|
||||||
|
MatDialogActions,
|
||||||
|
MatDialogClose
|
||||||
|
]
|
||||||
|
})
|
||||||
|
export class LeafDetailDialogComponent {
|
||||||
|
dialogRef = inject<MatDialogRef<LeafDetailDialogComponent>>(MatDialogRef);
|
||||||
|
data = inject(MAT_DIALOG_DATA);
|
||||||
|
}
|
||||||
@@ -0,0 +1,239 @@
|
|||||||
|
<!--
|
||||||
|
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="container">
|
||||||
|
<div class="page-header">
|
||||||
|
<h2>{{ 'labels.inputs.Cheque Books' | translate }}</h2>
|
||||||
|
<div class="header-actions">
|
||||||
|
<button mat-raised-button color="primary" (click)="showRequestForm = !showRequestForm">
|
||||||
|
<fa-icon icon="plus"></fa-icon> {{ 'labels.buttons.Request Cheque Book' | translate }}
|
||||||
|
</button>
|
||||||
|
<button mat-raised-button [routerLink]="['/chequebook']">{{ 'labels.buttons.Back' | translate }}</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="filter-row">
|
||||||
|
@for (s of ['REQUESTED', 'ISSUED', 'REJECTED', 'WITHDRAWN']; track s) {
|
||||||
|
<button mat-stroked-button [class.active]="statusFilter === s" (click)="filterByStatus(s)">{{ s }}</button>
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
@if (showRequestForm) {
|
||||||
|
<mat-card class="add-form">
|
||||||
|
<mat-card-content>
|
||||||
|
<form [formGroup]="requestForm" (ngSubmit)="submitRequest()">
|
||||||
|
<div class="form-grid">
|
||||||
|
<mat-form-field appearance="fill">
|
||||||
|
<mat-label>{{ 'labels.inputs.Account Type' | translate }}</mat-label>
|
||||||
|
<mat-select formControlName="accountType" required (selectionChange)="onAccountTypeChange()">
|
||||||
|
<mat-option value="SAVINGS">{{ 'labels.inputs.Savings' | translate }}</mat-option>
|
||||||
|
<mat-option value="CURRENT">{{ 'labels.inputs.Current' | translate }}</mat-option>
|
||||||
|
</mat-select>
|
||||||
|
</mat-form-field>
|
||||||
|
<mat-form-field appearance="fill">
|
||||||
|
<mat-label>{{ 'labels.inputs.Account Number' | translate }}</mat-label>
|
||||||
|
<input
|
||||||
|
matInput
|
||||||
|
formControlName="accountNo"
|
||||||
|
(input)="onAccountNoInput($any($event.target).value)"
|
||||||
|
required
|
||||||
|
/>
|
||||||
|
@if (searchingAccount) {
|
||||||
|
<mat-hint>{{ 'labels.text.Searching...' | translate }}</mat-hint>
|
||||||
|
} @else if (resolvedAccount) {
|
||||||
|
<mat-hint class="ok-hint">
|
||||||
|
✓ {{ resolvedAccount.accountNo }}
|
||||||
|
@if (resolvedAccount.clientName) {
|
||||||
|
— {{ resolvedAccount.clientName }}
|
||||||
|
}
|
||||||
|
@if (resolvedAccount.status) {
|
||||||
|
({{ resolvedAccount.status }})
|
||||||
|
}
|
||||||
|
</mat-hint>
|
||||||
|
} @else if (accountError) {
|
||||||
|
<mat-hint class="err-hint">{{ accountError | translate }}</mat-hint>
|
||||||
|
} @else {
|
||||||
|
<mat-hint>{{ 'labels.text.Type at least 4 digits of the account number' | translate }}</mat-hint>
|
||||||
|
}
|
||||||
|
</mat-form-field>
|
||||||
|
<mat-form-field appearance="fill">
|
||||||
|
<mat-label>{{ 'labels.inputs.Office' | translate }}</mat-label>
|
||||||
|
<mat-select formControlName="officeId" required>
|
||||||
|
@for (office of offices; track office.id) {
|
||||||
|
<mat-option [value]="office.id">{{ office.name }}</mat-option>
|
||||||
|
}
|
||||||
|
</mat-select>
|
||||||
|
</mat-form-field>
|
||||||
|
<mat-form-field appearance="fill">
|
||||||
|
<mat-label>{{ 'labels.inputs.Series' | translate }}</mat-label>
|
||||||
|
<mat-select formControlName="seriesId" required>
|
||||||
|
@for (s of requestableSeries; track s.id) {
|
||||||
|
<mat-option [value]="s.id">
|
||||||
|
{{ s.seriesCode }} — {{ s.name }} ({{ s.leavesPerBook }}-{{ 'labels.inputs.leaf' | translate }},
|
||||||
|
{{ availableBooksForRequestSeries(s.id) }} {{ 'labels.inputs.books available' | translate }})
|
||||||
|
</mat-option>
|
||||||
|
}
|
||||||
|
</mat-select>
|
||||||
|
<mat-hint>{{ 'labels.text.Only series applicable to this account type are shown' | translate }}</mat-hint>
|
||||||
|
</mat-form-field>
|
||||||
|
<mat-form-field appearance="fill">
|
||||||
|
<mat-label>{{ 'labels.inputs.Number of Books' | translate }}</mat-label>
|
||||||
|
<input matInput type="number" formControlName="noOfBooks" required />
|
||||||
|
<mat-hint>{{ 'labels.text.Leaves handed over = books x the series book size' | translate }}</mat-hint>
|
||||||
|
</mat-form-field>
|
||||||
|
</div>
|
||||||
|
<div class="form-actions">
|
||||||
|
<button mat-raised-button type="button" (click)="showRequestForm = false">
|
||||||
|
{{ 'labels.buttons.Cancel' | translate }}
|
||||||
|
</button>
|
||||||
|
<button mat-raised-button color="primary" type="submit" [disabled]="requestForm.invalid || busy">
|
||||||
|
{{ 'labels.buttons.Submit' | translate }}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</mat-card-content>
|
||||||
|
</mat-card>
|
||||||
|
}
|
||||||
|
|
||||||
|
<div class="table-wrapper mat-elevation-z1">
|
||||||
|
<table mat-table [dataSource]="books">
|
||||||
|
<ng-container matColumnDef="id">
|
||||||
|
<th mat-header-cell *matHeaderCellDef>{{ 'labels.inputs.Id' | translate }}</th>
|
||||||
|
<td mat-cell *matCellDef="let b">{{ b.id }}</td>
|
||||||
|
</ng-container>
|
||||||
|
<ng-container matColumnDef="accountType">
|
||||||
|
<th mat-header-cell *matHeaderCellDef>{{ 'labels.inputs.Account Type' | translate }}</th>
|
||||||
|
<td mat-cell *matCellDef="let b">{{ b.accountType }}</td>
|
||||||
|
</ng-container>
|
||||||
|
<ng-container matColumnDef="account">
|
||||||
|
<th mat-header-cell *matHeaderCellDef>{{ 'labels.inputs.Account' | translate }}</th>
|
||||||
|
<td mat-cell *matCellDef="let b">
|
||||||
|
{{ b.accountNo || b.accountId }}
|
||||||
|
@if (b.clientName) {
|
||||||
|
<span>({{ b.clientName }})</span>
|
||||||
|
}
|
||||||
|
</td>
|
||||||
|
</ng-container>
|
||||||
|
<ng-container matColumnDef="officeName">
|
||||||
|
<th mat-header-cell *matHeaderCellDef>{{ 'labels.inputs.Office' | translate }}</th>
|
||||||
|
<td mat-cell *matCellDef="let b">{{ b.officeName }}</td>
|
||||||
|
</ng-container>
|
||||||
|
<ng-container matColumnDef="seriesCode">
|
||||||
|
<th mat-header-cell *matHeaderCellDef>{{ 'labels.inputs.Series' | translate }}</th>
|
||||||
|
<td mat-cell *matCellDef="let b">{{ b.seriesCode }}</td>
|
||||||
|
</ng-container>
|
||||||
|
<ng-container matColumnDef="quantity">
|
||||||
|
<th mat-header-cell *matHeaderCellDef class="num">{{ 'labels.inputs.Quantity' | translate }}</th>
|
||||||
|
<td mat-cell *matCellDef="let b" class="num">
|
||||||
|
{{ b.noOfBooks }} {{ 'labels.inputs.books' | translate }}
|
||||||
|
@if (b.totalLeaves) {
|
||||||
|
<span class="muted">({{ b.totalLeaves }} {{ 'labels.inputs.leaves' | translate }})</span>
|
||||||
|
}
|
||||||
|
</td>
|
||||||
|
</ng-container>
|
||||||
|
<ng-container matColumnDef="status">
|
||||||
|
<th mat-header-cell *matHeaderCellDef>{{ 'labels.inputs.Status' | translate }}</th>
|
||||||
|
<td mat-cell *matCellDef="let b">
|
||||||
|
<span
|
||||||
|
class="pill"
|
||||||
|
[ngClass]="
|
||||||
|
b.status === 'ISSUED' ? 'ok' : b.status === 'REJECTED' || b.status === 'WITHDRAWN' ? 'bad' : 'neutral'
|
||||||
|
"
|
||||||
|
>{{ b.status }}</span
|
||||||
|
>
|
||||||
|
</td>
|
||||||
|
</ng-container>
|
||||||
|
<ng-container matColumnDef="requestedDate">
|
||||||
|
<th mat-header-cell *matHeaderCellDef>{{ 'labels.inputs.Requested Date' | translate }}</th>
|
||||||
|
<td mat-cell *matCellDef="let b">{{ b.requestedDate | dateFormat }}</td>
|
||||||
|
</ng-container>
|
||||||
|
<ng-container matColumnDef="actions">
|
||||||
|
<th mat-header-cell *matHeaderCellDef></th>
|
||||||
|
<td mat-cell *matCellDef="let b" (click)="$event.stopPropagation()">
|
||||||
|
@if (b.status === 'REQUESTED') {
|
||||||
|
<button mat-button color="primary" (click)="toggleIssue(b)">
|
||||||
|
{{ 'labels.buttons.Issue' | translate }}
|
||||||
|
</button>
|
||||||
|
<button mat-button color="warn" (click)="toggleReject(b)">{{ 'labels.buttons.Reject' | translate }}</button>
|
||||||
|
}
|
||||||
|
@if (b.status === 'ISSUED') {
|
||||||
|
<button mat-button (click)="openBook(b)">{{ 'labels.buttons.View' | translate }}</button>
|
||||||
|
<button mat-button color="warn" (click)="withdraw(b)">{{ 'labels.buttons.Withdraw' | translate }}</button>
|
||||||
|
}
|
||||||
|
</td>
|
||||||
|
</ng-container>
|
||||||
|
<tr mat-header-row *matHeaderRowDef="columns"></tr>
|
||||||
|
<tr mat-row *matRowDef="let row; columns: columns" (click)="openBook(row)" class="clickable-row"></tr>
|
||||||
|
</table>
|
||||||
|
@if (books.length === 0) {
|
||||||
|
<div class="empty-state">{{ 'labels.text.No records to display' | translate }}</div>
|
||||||
|
}
|
||||||
|
@if (totalRecords > 0) {
|
||||||
|
<mat-paginator
|
||||||
|
[length]="totalRecords"
|
||||||
|
[pageSize]="pageSize"
|
||||||
|
[pageSizeOptions]="pageSizeOptions"
|
||||||
|
[pageIndex]="pageIndex"
|
||||||
|
showFirstLastButtons
|
||||||
|
(page)="onPageChange($event)"
|
||||||
|
></mat-paginator>
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
@if (issueForBookId) {
|
||||||
|
<mat-card class="add-form">
|
||||||
|
<mat-card-content>
|
||||||
|
<h3>{{ 'labels.inputs.Issue Cheque Book' | translate }}</h3>
|
||||||
|
<p class="issue-need">
|
||||||
|
{{ 'labels.inputs.Requested' | translate }}: <strong>{{ issueForBook?.noOfBooks }}</strong>
|
||||||
|
{{ 'labels.inputs.books' | translate }} — {{ issueForBook?.seriesCode }}
|
||||||
|
<span [class.err-hint]="issueAvailableBooks < (issueForBook?.noOfBooks || 0)">
|
||||||
|
({{ issueAvailableBooks }} {{ 'labels.inputs.books available' | translate }})
|
||||||
|
</span>
|
||||||
|
</p>
|
||||||
|
<form [formGroup]="issueForm" (ngSubmit)="submitIssue(issueForBookId)">
|
||||||
|
<mat-checkbox formControlName="collectionAcknowledged">
|
||||||
|
{{ 'labels.inputs.Customer collected the book now' | translate }}
|
||||||
|
</mat-checkbox>
|
||||||
|
<div class="form-actions">
|
||||||
|
<button mat-raised-button type="button" (click)="issueForBookId = null; issueForBook = null">
|
||||||
|
{{ 'labels.buttons.Cancel' | translate }}
|
||||||
|
</button>
|
||||||
|
<button mat-raised-button color="primary" type="submit" [disabled]="issueForm.invalid || busy">
|
||||||
|
{{ 'labels.buttons.Issue' | translate }}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</mat-card-content>
|
||||||
|
</mat-card>
|
||||||
|
}
|
||||||
|
|
||||||
|
@if (rejectForBookId) {
|
||||||
|
<mat-card class="add-form">
|
||||||
|
<mat-card-content>
|
||||||
|
<h3>{{ 'labels.inputs.Reject Cheque Book Request' | translate }}</h3>
|
||||||
|
<form [formGroup]="rejectForm" (ngSubmit)="submitReject(rejectForBookId)">
|
||||||
|
<div class="form-grid">
|
||||||
|
<mat-form-field appearance="fill">
|
||||||
|
<mat-label>{{ 'labels.inputs.Rejection Reason' | translate }}</mat-label>
|
||||||
|
<input matInput formControlName="rejectionReason" required />
|
||||||
|
</mat-form-field>
|
||||||
|
</div>
|
||||||
|
<div class="form-actions">
|
||||||
|
<button mat-raised-button type="button" (click)="rejectForBookId = null">
|
||||||
|
{{ 'labels.buttons.Cancel' | translate }}
|
||||||
|
</button>
|
||||||
|
<button mat-raised-button color="warn" type="submit" [disabled]="rejectForm.invalid || busy">
|
||||||
|
{{ 'labels.buttons.Reject' | translate }}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</mat-card-content>
|
||||||
|
</mat-card>
|
||||||
|
}
|
||||||
|
</div>
|
||||||
@@ -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/.
|
||||||
|
*/
|
||||||
|
|
||||||
|
.container {
|
||||||
|
padding: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.page-header {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: center;
|
||||||
|
margin-bottom: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.header-actions {
|
||||||
|
display: flex;
|
||||||
|
gap: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.filter-row {
|
||||||
|
display: flex;
|
||||||
|
gap: 8px;
|
||||||
|
margin-bottom: 16px;
|
||||||
|
|
||||||
|
button.active {
|
||||||
|
background: rgb(25 118 210 / 12%);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.add-form {
|
||||||
|
margin-bottom: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.form-grid {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(auto-fill, minmax(220px, 1fr));
|
||||||
|
gap: 12px 20px;
|
||||||
|
margin-bottom: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
mat-form-field {
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.form-actions {
|
||||||
|
display: flex;
|
||||||
|
justify-content: flex-end;
|
||||||
|
gap: 12px;
|
||||||
|
margin-top: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.table-wrapper {
|
||||||
|
overflow-x: auto;
|
||||||
|
background: #fff;
|
||||||
|
}
|
||||||
|
|
||||||
|
table {
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.clickable-row {
|
||||||
|
cursor: pointer;
|
||||||
|
|
||||||
|
&:hover {
|
||||||
|
background: rgb(0 0 0 / 3%);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
td.num,
|
||||||
|
th.num {
|
||||||
|
text-align: right;
|
||||||
|
font-variant-numeric: tabular-nums;
|
||||||
|
}
|
||||||
|
|
||||||
|
.muted {
|
||||||
|
color: rgb(0 0 0 / 50%);
|
||||||
|
font-size: 0.8rem;
|
||||||
|
margin-left: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.issue-need {
|
||||||
|
margin: 0 0 8px;
|
||||||
|
font-size: 0.9rem;
|
||||||
|
color: rgb(0 0 0 / 70%);
|
||||||
|
}
|
||||||
|
|
||||||
|
::ng-deep .ok-hint {
|
||||||
|
color: #28c07a;
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
|
||||||
|
::ng-deep .err-hint {
|
||||||
|
color: #d32f2f;
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
|
||||||
|
.pill {
|
||||||
|
font-size: 11px;
|
||||||
|
padding: 2px 8px;
|
||||||
|
border-radius: 6px;
|
||||||
|
font-weight: 600;
|
||||||
|
|
||||||
|
&.ok {
|
||||||
|
background: #dff5e8;
|
||||||
|
color: #28c07a;
|
||||||
|
}
|
||||||
|
|
||||||
|
&.bad {
|
||||||
|
background: #fde3e3;
|
||||||
|
color: #d32f2f;
|
||||||
|
}
|
||||||
|
|
||||||
|
&.neutral {
|
||||||
|
background: #e0e0e0;
|
||||||
|
color: #757575;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.empty-state {
|
||||||
|
padding: 24px;
|
||||||
|
text-align: center;
|
||||||
|
color: rgb(0 0 0 / 50%);
|
||||||
|
}
|
||||||
@@ -0,0 +1,366 @@
|
|||||||
|
/**
|
||||||
|
* Copyright since 2025 Mifos Initiative
|
||||||
|
*
|
||||||
|
* This Source Code Form is subject to the terms of the Mozilla Public
|
||||||
|
* License, v. 2.0. If a copy of the MPL was not distributed with this
|
||||||
|
* file, You can obtain one at http://mozilla.org/MPL/2.0/.
|
||||||
|
*/
|
||||||
|
|
||||||
|
/** Angular Imports */
|
||||||
|
import { Component, OnInit, inject } from '@angular/core';
|
||||||
|
import { UntypedFormBuilder, UntypedFormGroup, Validators } from '@angular/forms';
|
||||||
|
import { Router } from '@angular/router';
|
||||||
|
import { MatIconModule } from '@angular/material/icon';
|
||||||
|
import { MatButtonModule } from '@angular/material/button';
|
||||||
|
import { MatTableModule } from '@angular/material/table';
|
||||||
|
import { MatPaginatorModule, PageEvent } from '@angular/material/paginator';
|
||||||
|
import { MatSnackBar, MatSnackBarModule } from '@angular/material/snack-bar';
|
||||||
|
import { FaIconComponent } from '@fortawesome/angular-fontawesome';
|
||||||
|
import { Subject } from 'rxjs';
|
||||||
|
import { debounceTime, distinctUntilChanged } from 'rxjs/operators';
|
||||||
|
import { STANDALONE_SHARED_IMPORTS } from 'app/standalone-shared.module';
|
||||||
|
|
||||||
|
/** Custom Services */
|
||||||
|
import { ChequeBookService } from '../chequebook.service';
|
||||||
|
|
||||||
|
/** Cheque book requisitions and issuance — request, issue (draws from available stock), reject, withdraw. */
|
||||||
|
@Component({
|
||||||
|
selector: 'mifosx-cheque-books',
|
||||||
|
templateUrl: './cheque-books.component.html',
|
||||||
|
styleUrls: ['./cheque-books.component.scss'],
|
||||||
|
imports: [
|
||||||
|
...STANDALONE_SHARED_IMPORTS,
|
||||||
|
MatIconModule,
|
||||||
|
MatButtonModule,
|
||||||
|
MatTableModule,
|
||||||
|
MatPaginatorModule,
|
||||||
|
MatSnackBarModule,
|
||||||
|
FaIconComponent
|
||||||
|
]
|
||||||
|
})
|
||||||
|
export class ChequeBooksComponent implements OnInit {
|
||||||
|
private formBuilder = inject(UntypedFormBuilder);
|
||||||
|
private chequeBookService = inject(ChequeBookService);
|
||||||
|
private router = inject(Router);
|
||||||
|
private snackBar = inject(MatSnackBar);
|
||||||
|
|
||||||
|
private notify(message: string, isError = false): void {
|
||||||
|
this.snackBar.open(message, 'Dismiss', { duration: isError ? 6000 : 3000 });
|
||||||
|
}
|
||||||
|
|
||||||
|
books: any[] = [];
|
||||||
|
totalRecords = 0;
|
||||||
|
pageSize = 10;
|
||||||
|
pageIndex = 0;
|
||||||
|
pageSizeOptions = [
|
||||||
|
10,
|
||||||
|
25,
|
||||||
|
50,
|
||||||
|
100
|
||||||
|
];
|
||||||
|
seriesList: any[] = [];
|
||||||
|
offices: any[] = [];
|
||||||
|
stockLots: any[] = [];
|
||||||
|
busy = false;
|
||||||
|
|
||||||
|
showRequestForm = false;
|
||||||
|
issueForBookId: number | null = null;
|
||||||
|
issueForBook: any = null;
|
||||||
|
rejectForBookId: number | null = null;
|
||||||
|
statusFilter = '';
|
||||||
|
|
||||||
|
requestForm: UntypedFormGroup;
|
||||||
|
issueForm: UntypedFormGroup;
|
||||||
|
rejectForm: UntypedFormGroup;
|
||||||
|
|
||||||
|
// account-number lookup for the request form
|
||||||
|
private accountSearch$ = new Subject<string>();
|
||||||
|
resolvedAccount: any = null;
|
||||||
|
searchingAccount = false;
|
||||||
|
accountError = '';
|
||||||
|
|
||||||
|
columns = [
|
||||||
|
'id',
|
||||||
|
'accountType',
|
||||||
|
'account',
|
||||||
|
'officeName',
|
||||||
|
'seriesCode',
|
||||||
|
'quantity',
|
||||||
|
'status',
|
||||||
|
'requestedDate',
|
||||||
|
'actions'
|
||||||
|
];
|
||||||
|
|
||||||
|
constructor() {
|
||||||
|
this.requestForm = this.formBuilder.group({
|
||||||
|
accountType: [
|
||||||
|
'SAVINGS',
|
||||||
|
Validators.required
|
||||||
|
],
|
||||||
|
accountNo: [
|
||||||
|
'',
|
||||||
|
Validators.required
|
||||||
|
],
|
||||||
|
accountId: [
|
||||||
|
null,
|
||||||
|
Validators.required
|
||||||
|
],
|
||||||
|
officeId: [
|
||||||
|
'',
|
||||||
|
Validators.required
|
||||||
|
],
|
||||||
|
seriesId: [
|
||||||
|
'',
|
||||||
|
Validators.required
|
||||||
|
],
|
||||||
|
noOfBooks: [
|
||||||
|
1,
|
||||||
|
Validators.required
|
||||||
|
]
|
||||||
|
});
|
||||||
|
this.issueForm = this.formBuilder.group({
|
||||||
|
collectionAcknowledged: [false]
|
||||||
|
});
|
||||||
|
this.rejectForm = this.formBuilder.group({
|
||||||
|
rejectionReason: [
|
||||||
|
'',
|
||||||
|
Validators.required
|
||||||
|
]
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
ngOnInit(): void {
|
||||||
|
this.loadBooks();
|
||||||
|
this.loadStock();
|
||||||
|
// populates a dropdown, so fetch every active series in one page rather than paging through it
|
||||||
|
this.chequeBookService
|
||||||
|
.getSeries('ACTIVE', 0, 500)
|
||||||
|
.subscribe((res: any) => (this.seriesList = (res?.pageItems ?? []).filter((s: any) => s.active)));
|
||||||
|
this.chequeBookService.getOffices().subscribe((data: any[]) => (this.offices = data || []));
|
||||||
|
|
||||||
|
// account-number lookup: debounce, only fire once at least 4 chars typed (mirrors the cashier module)
|
||||||
|
this.accountSearch$
|
||||||
|
.pipe(debounceTime(400), distinctUntilChanged())
|
||||||
|
.subscribe((accountNo) => this.doAccountSearch(accountNo));
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Called on every keystroke in the account-number box; clears any prior match and (re)triggers the search. */
|
||||||
|
onAccountNoInput(value: string): void {
|
||||||
|
this.resolvedAccount = null;
|
||||||
|
this.accountError = '';
|
||||||
|
this.requestForm.patchValue({ accountId: null });
|
||||||
|
const trimmed = (value || '').trim();
|
||||||
|
if (trimmed.length >= 4) {
|
||||||
|
this.accountSearch$.next(trimmed);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Re-run the lookup against the other ledger when the account type toggles; the series choice narrows too. */
|
||||||
|
onAccountTypeChange(): void {
|
||||||
|
this.resolvedAccount = null;
|
||||||
|
this.accountError = '';
|
||||||
|
this.requestForm.patchValue({ accountId: null, seriesId: '' });
|
||||||
|
const accountNo = (this.requestForm.get('accountNo')?.value || '').trim();
|
||||||
|
if (accountNo.length >= 4) {
|
||||||
|
this.accountSearch$.next(accountNo);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Series applicable to whichever account type is currently selected in the request form — every series is
|
||||||
|
* tagged SAVINGS-only or CURRENT-only (never both), so this is a direct match. */
|
||||||
|
get requestableSeries(): any[] {
|
||||||
|
const type = this.requestForm.get('accountType')?.value;
|
||||||
|
return this.seriesList.filter((s) => s.applicableAccountType === type);
|
||||||
|
}
|
||||||
|
|
||||||
|
private doAccountSearch(accountNo: string): void {
|
||||||
|
const accountType = this.requestForm.get('accountType')?.value;
|
||||||
|
this.searchingAccount = true;
|
||||||
|
this.accountError = '';
|
||||||
|
this.chequeBookService.searchAccountByNo(accountType, accountNo).subscribe({
|
||||||
|
next: (res: any) => {
|
||||||
|
this.searchingAccount = false;
|
||||||
|
const items = res?.pageItems || (Array.isArray(res) ? res : []);
|
||||||
|
const exact = items.find((i: any) => i.accountNo === accountNo);
|
||||||
|
const match = exact || (items.length === 1 ? items[0] : null);
|
||||||
|
if (match) {
|
||||||
|
this.resolvedAccount = {
|
||||||
|
id: match.id,
|
||||||
|
accountNo: match.accountNo,
|
||||||
|
clientName: match.clientName || match.clientDisplayName || match.groupName || '',
|
||||||
|
status: match.status?.value || ''
|
||||||
|
};
|
||||||
|
this.requestForm.patchValue({ accountId: match.id });
|
||||||
|
} else {
|
||||||
|
this.accountError = 'No account found with that number';
|
||||||
|
}
|
||||||
|
},
|
||||||
|
error: () => {
|
||||||
|
this.searchingAccount = false;
|
||||||
|
this.accountError = 'Account lookup failed';
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
loadStock(): void {
|
||||||
|
// feeds availability calculations below, so fetch every available lot in one page rather than paging through it
|
||||||
|
this.chequeBookService
|
||||||
|
.getStockLots(undefined, undefined, 'AVAILABLE', 0, 500)
|
||||||
|
.subscribe((res: any) => (this.stockLots = res?.pageItems ?? []));
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Whole books currently on hand for a series, optionally narrowed to one office. */
|
||||||
|
availableBooksForSeries(seriesId: number, officeId?: number): number {
|
||||||
|
return this.stockLots
|
||||||
|
.filter((l) => l.seriesId === seriesId && (!officeId || l.officeId === officeId) && l.status === 'AVAILABLE')
|
||||||
|
.reduce((sum, l) => sum + (l.remainingBooks || 0), 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Availability at whichever office is currently chosen in the request form. */
|
||||||
|
availableBooksForRequestSeries(seriesId: number): number {
|
||||||
|
return this.availableBooksForSeries(seriesId, this.requestForm.get('officeId')?.value);
|
||||||
|
}
|
||||||
|
|
||||||
|
loadBooks(): void {
|
||||||
|
this.chequeBookService
|
||||||
|
.getBooks(
|
||||||
|
undefined,
|
||||||
|
undefined,
|
||||||
|
undefined,
|
||||||
|
this.statusFilter || undefined,
|
||||||
|
this.pageIndex * this.pageSize,
|
||||||
|
this.pageSize
|
||||||
|
)
|
||||||
|
.subscribe((res: any) => {
|
||||||
|
this.books = res?.pageItems ?? (Array.isArray(res) ? res : []);
|
||||||
|
this.totalRecords = Number(res?.totalFilteredRecords ?? this.books.length);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
filterByStatus(status: string): void {
|
||||||
|
this.statusFilter = this.statusFilter === status ? '' : status;
|
||||||
|
this.pageIndex = 0;
|
||||||
|
this.loadBooks();
|
||||||
|
}
|
||||||
|
|
||||||
|
onPageChange(event: PageEvent): void {
|
||||||
|
this.pageSize = event.pageSize;
|
||||||
|
this.pageIndex = event.pageIndex;
|
||||||
|
this.loadBooks();
|
||||||
|
}
|
||||||
|
|
||||||
|
openBook(book: any): void {
|
||||||
|
this.router.navigate([
|
||||||
|
'/chequebook',
|
||||||
|
'books',
|
||||||
|
book.id
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
submitRequest(): void {
|
||||||
|
if (this.requestForm.invalid || this.busy) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
this.busy = true;
|
||||||
|
const raw = this.requestForm.value;
|
||||||
|
const payload = {
|
||||||
|
accountType: raw.accountType,
|
||||||
|
accountId: raw.accountId,
|
||||||
|
officeId: raw.officeId,
|
||||||
|
seriesId: raw.seriesId,
|
||||||
|
noOfBooks: raw.noOfBooks,
|
||||||
|
locale: 'en'
|
||||||
|
};
|
||||||
|
this.chequeBookService.requestBook(payload).subscribe({
|
||||||
|
next: () => {
|
||||||
|
this.busy = false;
|
||||||
|
this.showRequestForm = false;
|
||||||
|
this.requestForm.reset({ accountType: 'SAVINGS', noOfBooks: 1 });
|
||||||
|
this.resolvedAccount = null;
|
||||||
|
this.accountError = '';
|
||||||
|
this.notify('Submitted for approval — see Approvals > Cheque Book Requests');
|
||||||
|
this.loadBooks();
|
||||||
|
},
|
||||||
|
error: (err: any) => {
|
||||||
|
this.busy = false;
|
||||||
|
const msg =
|
||||||
|
err?.error?.errors?.[0]?.defaultUserMessage ||
|
||||||
|
err?.error?.developerMessage ||
|
||||||
|
'Request failed — check the account is active and the cheque-book facility is enabled';
|
||||||
|
this.notify(msg, true);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
toggleIssue(book: any): void {
|
||||||
|
const opening = this.issueForBookId !== book.id;
|
||||||
|
this.issueForBookId = opening ? book.id : null;
|
||||||
|
this.issueForBook = opening ? book : null;
|
||||||
|
this.issueForm.reset({ collectionAcknowledged: false });
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Books available right now for the series this book was already requested against. */
|
||||||
|
get issueAvailableBooks(): number {
|
||||||
|
return this.issueForBook?.seriesId
|
||||||
|
? this.availableBooksForSeries(this.issueForBook.seriesId, this.issueForBook.officeId)
|
||||||
|
: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
submitIssue(bookId: number): void {
|
||||||
|
if (this.issueForm.invalid || this.busy) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
this.busy = true;
|
||||||
|
const raw = this.issueForm.value;
|
||||||
|
const payload = { collectionAcknowledged: !!raw.collectionAcknowledged, locale: 'en' };
|
||||||
|
this.chequeBookService.issueBook(bookId, payload).subscribe({
|
||||||
|
next: () => {
|
||||||
|
this.busy = false;
|
||||||
|
this.issueForBookId = null;
|
||||||
|
this.issueForBook = null;
|
||||||
|
this.notify('Cheque book issued');
|
||||||
|
this.loadBooks();
|
||||||
|
this.loadStock();
|
||||||
|
},
|
||||||
|
error: () => {
|
||||||
|
this.busy = false;
|
||||||
|
this.notify('Issue failed — check there is enough available stock for this series at the office', true);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
toggleReject(book: any): void {
|
||||||
|
this.rejectForBookId = this.rejectForBookId === book.id ? null : book.id;
|
||||||
|
this.rejectForm.reset();
|
||||||
|
}
|
||||||
|
|
||||||
|
submitReject(bookId: number): void {
|
||||||
|
if (this.rejectForm.invalid || this.busy) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
this.busy = true;
|
||||||
|
const payload = { rejectionReason: this.rejectForm.value.rejectionReason, locale: 'en' };
|
||||||
|
this.chequeBookService.rejectBook(bookId, payload).subscribe({
|
||||||
|
next: () => {
|
||||||
|
this.busy = false;
|
||||||
|
this.rejectForBookId = null;
|
||||||
|
this.notify('Cheque book request rejected');
|
||||||
|
this.loadBooks();
|
||||||
|
},
|
||||||
|
error: () => {
|
||||||
|
this.busy = false;
|
||||||
|
this.notify('Reject failed', true);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
withdraw(book: any): void {
|
||||||
|
this.chequeBookService.withdrawBook(book.id).subscribe({
|
||||||
|
next: () => {
|
||||||
|
this.notify('Cheque book withdrawn');
|
||||||
|
this.loadBooks();
|
||||||
|
},
|
||||||
|
error: () => this.notify('Withdraw failed', true)
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,136 @@
|
|||||||
|
<!--
|
||||||
|
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="container">
|
||||||
|
<div class="page-header">
|
||||||
|
<h2>{{ 'labels.inputs.Cheque Series' | translate }}</h2>
|
||||||
|
<div class="header-actions">
|
||||||
|
<button mat-raised-button color="primary" (click)="showForm = !showForm">
|
||||||
|
<fa-icon icon="plus"></fa-icon> {{ 'labels.buttons.Add Cheque Series' | translate }}
|
||||||
|
</button>
|
||||||
|
<button mat-raised-button [routerLink]="['/chequebook']">{{ 'labels.buttons.Back' | translate }}</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
@if (showForm) {
|
||||||
|
<mat-card class="add-form">
|
||||||
|
<mat-card-content>
|
||||||
|
<form [formGroup]="seriesForm" (ngSubmit)="submit()">
|
||||||
|
<div class="form-grid">
|
||||||
|
<mat-form-field appearance="fill">
|
||||||
|
<mat-label>{{ 'labels.inputs.Series Code' | translate }}</mat-label>
|
||||||
|
<input matInput formControlName="seriesCode" placeholder="CTS-25" required />
|
||||||
|
</mat-form-field>
|
||||||
|
<mat-form-field appearance="fill">
|
||||||
|
<mat-label>{{ 'labels.inputs.Name' | translate }}</mat-label>
|
||||||
|
<input matInput formControlName="name" required />
|
||||||
|
</mat-form-field>
|
||||||
|
<mat-form-field appearance="fill">
|
||||||
|
<mat-label>{{ 'labels.inputs.Leaves Per Book' | translate }}</mat-label>
|
||||||
|
<input matInput type="number" formControlName="leavesPerBook" required />
|
||||||
|
</mat-form-field>
|
||||||
|
<mat-form-field appearance="fill">
|
||||||
|
<mat-label>{{ 'labels.inputs.Applicable Account Type' | translate }}</mat-label>
|
||||||
|
<mat-select formControlName="applicableAccountType" required>
|
||||||
|
<mat-option value="SAVINGS">{{ 'labels.inputs.Savings Only' | translate }}</mat-option>
|
||||||
|
<mat-option value="CURRENT">{{ 'labels.inputs.Current Only' | translate }}</mat-option>
|
||||||
|
</mat-select>
|
||||||
|
<mat-hint>{{ 'labels.text.SB and CA cheque stationery differ' | translate }}</mat-hint>
|
||||||
|
</mat-form-field>
|
||||||
|
<mat-form-field appearance="fill">
|
||||||
|
<mat-label>{{ 'labels.inputs.Reorder Level' | translate }}</mat-label>
|
||||||
|
<input matInput type="number" formControlName="reorderLevel" />
|
||||||
|
<mat-hint>{{ 'labels.text.Low-stock alert threshold (remaining leaves)' | translate }}</mat-hint>
|
||||||
|
</mat-form-field>
|
||||||
|
</div>
|
||||||
|
<mat-checkbox formControlName="ctsCompliant">{{
|
||||||
|
'labels.inputs.CTS-2010 Compliant' | translate
|
||||||
|
}}</mat-checkbox>
|
||||||
|
<div class="form-actions">
|
||||||
|
<button mat-raised-button type="button" (click)="showForm = false">
|
||||||
|
{{ 'labels.buttons.Cancel' | translate }}
|
||||||
|
</button>
|
||||||
|
<button mat-raised-button color="primary" type="submit" [disabled]="seriesForm.invalid || busy">
|
||||||
|
{{ 'labels.buttons.Submit' | translate }}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</mat-card-content>
|
||||||
|
</mat-card>
|
||||||
|
}
|
||||||
|
|
||||||
|
<div class="filter-row">
|
||||||
|
@for (s of ['ACTIVE', 'INACTIVE']; track s) {
|
||||||
|
<button mat-stroked-button [class.active]="statusFilter === s" (click)="filterByStatus(s)">{{ s }}</button>
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="table-wrapper mat-elevation-z1">
|
||||||
|
<table mat-table [dataSource]="seriesList">
|
||||||
|
<ng-container matColumnDef="seriesCode">
|
||||||
|
<th mat-header-cell *matHeaderCellDef>{{ 'labels.inputs.Series Code' | translate }}</th>
|
||||||
|
<td mat-cell *matCellDef="let s">{{ s.seriesCode }}</td>
|
||||||
|
</ng-container>
|
||||||
|
<ng-container matColumnDef="name">
|
||||||
|
<th mat-header-cell *matHeaderCellDef>{{ 'labels.inputs.Name' | translate }}</th>
|
||||||
|
<td mat-cell *matCellDef="let s">{{ s.name }}</td>
|
||||||
|
</ng-container>
|
||||||
|
<ng-container matColumnDef="leavesPerBook">
|
||||||
|
<th mat-header-cell *matHeaderCellDef class="num">{{ 'labels.inputs.Leaves Per Book' | translate }}</th>
|
||||||
|
<td mat-cell *matCellDef="let s" class="num">{{ s.leavesPerBook }}</td>
|
||||||
|
</ng-container>
|
||||||
|
<ng-container matColumnDef="applicableAccountType">
|
||||||
|
<th mat-header-cell *matHeaderCellDef>{{ 'labels.inputs.Applicable Account Type' | translate }}</th>
|
||||||
|
<td mat-cell *matCellDef="let s">
|
||||||
|
<span class="pill neutral">{{ s.applicableAccountType }}</span>
|
||||||
|
</td>
|
||||||
|
</ng-container>
|
||||||
|
<ng-container matColumnDef="ctsCompliant">
|
||||||
|
<th mat-header-cell *matHeaderCellDef>{{ 'labels.inputs.CTS-2010 Compliant' | translate }}</th>
|
||||||
|
<td mat-cell *matCellDef="let s">
|
||||||
|
<span class="pill" [ngClass]="s.ctsCompliant ? 'ok' : 'neutral'">{{ s.ctsCompliant ? 'Yes' : 'No' }}</span>
|
||||||
|
</td>
|
||||||
|
</ng-container>
|
||||||
|
<ng-container matColumnDef="reorderLevel">
|
||||||
|
<th mat-header-cell *matHeaderCellDef class="num">{{ 'labels.inputs.Reorder Level' | translate }}</th>
|
||||||
|
<td mat-cell *matCellDef="let s" class="num">{{ s.reorderLevel ?? '-' }}</td>
|
||||||
|
</ng-container>
|
||||||
|
<ng-container matColumnDef="active">
|
||||||
|
<th mat-header-cell *matHeaderCellDef>{{ 'labels.inputs.Status' | translate }}</th>
|
||||||
|
<td mat-cell *matCellDef="let s">
|
||||||
|
<span class="pill" [ngClass]="s.active ? 'ok' : 'neutral'">{{ s.active ? 'Active' : 'Inactive' }}</span>
|
||||||
|
</td>
|
||||||
|
</ng-container>
|
||||||
|
<ng-container matColumnDef="actions">
|
||||||
|
<th mat-header-cell *matHeaderCellDef></th>
|
||||||
|
<td mat-cell *matCellDef="let s">
|
||||||
|
@if (s.active) {
|
||||||
|
<button mat-icon-button color="warn" (click)="deactivate(s)">
|
||||||
|
<mat-icon><fa-icon icon="trash"></fa-icon></mat-icon>
|
||||||
|
</button>
|
||||||
|
}
|
||||||
|
</td>
|
||||||
|
</ng-container>
|
||||||
|
<tr mat-header-row *matHeaderRowDef="columns"></tr>
|
||||||
|
<tr mat-row *matRowDef="let row; columns: columns"></tr>
|
||||||
|
</table>
|
||||||
|
@if (seriesList.length === 0) {
|
||||||
|
<div class="empty-state">{{ 'labels.text.No records to display' | translate }}</div>
|
||||||
|
}
|
||||||
|
@if (totalRecords > 0) {
|
||||||
|
<mat-paginator
|
||||||
|
[length]="totalRecords"
|
||||||
|
[pageSize]="pageSize"
|
||||||
|
[pageSizeOptions]="pageSizeOptions"
|
||||||
|
[pageIndex]="pageIndex"
|
||||||
|
showFirstLastButtons
|
||||||
|
(page)="onPageChange($event)"
|
||||||
|
></mat-paginator>
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
@@ -0,0 +1,93 @@
|
|||||||
|
/**
|
||||||
|
* Copyright since 2025 Mifos Initiative
|
||||||
|
*
|
||||||
|
* This Source Code Form is subject to the terms of the Mozilla Public
|
||||||
|
* License, v. 2.0. If a copy of the MPL was not distributed with this
|
||||||
|
* file, You can obtain one at http://mozilla.org/MPL/2.0/.
|
||||||
|
*/
|
||||||
|
|
||||||
|
.container {
|
||||||
|
padding: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.page-header {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: center;
|
||||||
|
margin-bottom: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.header-actions {
|
||||||
|
display: flex;
|
||||||
|
gap: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.filter-row {
|
||||||
|
display: flex;
|
||||||
|
gap: 8px;
|
||||||
|
margin-bottom: 16px;
|
||||||
|
|
||||||
|
button.active {
|
||||||
|
background: rgb(25 118 210 / 12%);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.add-form {
|
||||||
|
margin-bottom: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.form-grid {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(auto-fill, minmax(220px, 1fr));
|
||||||
|
gap: 12px 20px;
|
||||||
|
margin-bottom: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
mat-form-field {
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.form-actions {
|
||||||
|
display: flex;
|
||||||
|
justify-content: flex-end;
|
||||||
|
gap: 12px;
|
||||||
|
margin-top: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.table-wrapper {
|
||||||
|
overflow-x: auto;
|
||||||
|
background: #fff;
|
||||||
|
}
|
||||||
|
|
||||||
|
table {
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
td.num,
|
||||||
|
th.num {
|
||||||
|
text-align: right;
|
||||||
|
font-variant-numeric: tabular-nums;
|
||||||
|
}
|
||||||
|
|
||||||
|
.pill {
|
||||||
|
font-size: 11px;
|
||||||
|
padding: 2px 8px;
|
||||||
|
border-radius: 6px;
|
||||||
|
font-weight: 600;
|
||||||
|
|
||||||
|
&.ok {
|
||||||
|
background: #dff5e8;
|
||||||
|
color: #28c07a;
|
||||||
|
}
|
||||||
|
|
||||||
|
&.neutral {
|
||||||
|
background: #e0e0e0;
|
||||||
|
color: #757575;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.empty-state {
|
||||||
|
padding: 24px;
|
||||||
|
text-align: center;
|
||||||
|
color: rgb(0 0 0 / 50%);
|
||||||
|
}
|
||||||
@@ -0,0 +1,176 @@
|
|||||||
|
/**
|
||||||
|
* Copyright since 2025 Mifos Initiative
|
||||||
|
*
|
||||||
|
* This Source Code Form is subject to the terms of the Mozilla Public
|
||||||
|
* License, v. 2.0. If a copy of the MPL was not distributed with this
|
||||||
|
* file, You can obtain one at http://mozilla.org/MPL/2.0/.
|
||||||
|
*/
|
||||||
|
|
||||||
|
/** Angular Imports */
|
||||||
|
import { Component, OnInit, inject } from '@angular/core';
|
||||||
|
import { UntypedFormBuilder, UntypedFormGroup, Validators } from '@angular/forms';
|
||||||
|
import { RouterLink } from '@angular/router';
|
||||||
|
import { MatIconModule } from '@angular/material/icon';
|
||||||
|
import { MatButtonModule } from '@angular/material/button';
|
||||||
|
import { MatTableModule } from '@angular/material/table';
|
||||||
|
import { MatPaginatorModule, PageEvent } from '@angular/material/paginator';
|
||||||
|
import { MatSnackBar, MatSnackBarModule } from '@angular/material/snack-bar';
|
||||||
|
import { MatDialog } from '@angular/material/dialog';
|
||||||
|
import { FaIconComponent } from '@fortawesome/angular-fontawesome';
|
||||||
|
import { STANDALONE_SHARED_IMPORTS } from 'app/standalone-shared.module';
|
||||||
|
|
||||||
|
/** Custom Services */
|
||||||
|
import { ChequeBookService } from '../chequebook.service';
|
||||||
|
import { DeleteDialogComponent } from 'app/shared/delete-dialog/delete-dialog.component';
|
||||||
|
|
||||||
|
/** Cheque-leaf stationery master — leaves per book, CTS-2010 compliance, low-stock reorder threshold. */
|
||||||
|
@Component({
|
||||||
|
selector: 'mifosx-cheque-series',
|
||||||
|
templateUrl: './cheque-series.component.html',
|
||||||
|
styleUrls: ['./cheque-series.component.scss'],
|
||||||
|
imports: [
|
||||||
|
...STANDALONE_SHARED_IMPORTS,
|
||||||
|
RouterLink,
|
||||||
|
MatIconModule,
|
||||||
|
MatButtonModule,
|
||||||
|
MatTableModule,
|
||||||
|
MatPaginatorModule,
|
||||||
|
MatSnackBarModule,
|
||||||
|
FaIconComponent
|
||||||
|
]
|
||||||
|
})
|
||||||
|
export class ChequeSeriesComponent implements OnInit {
|
||||||
|
private formBuilder = inject(UntypedFormBuilder);
|
||||||
|
private chequeBookService = inject(ChequeBookService);
|
||||||
|
private dialog = inject(MatDialog);
|
||||||
|
private snackBar = inject(MatSnackBar);
|
||||||
|
|
||||||
|
private notify(message: string, isError = false): void {
|
||||||
|
this.snackBar.open(message, 'Dismiss', { duration: isError ? 6000 : 3000 });
|
||||||
|
}
|
||||||
|
|
||||||
|
seriesList: any[] = [];
|
||||||
|
showForm = false;
|
||||||
|
busy = false;
|
||||||
|
|
||||||
|
statusFilter = '';
|
||||||
|
pageSize = 10;
|
||||||
|
pageIndex = 0;
|
||||||
|
totalRecords = 0;
|
||||||
|
pageSizeOptions = [
|
||||||
|
10,
|
||||||
|
25,
|
||||||
|
50,
|
||||||
|
100
|
||||||
|
];
|
||||||
|
|
||||||
|
seriesForm: UntypedFormGroup;
|
||||||
|
|
||||||
|
columns = [
|
||||||
|
'seriesCode',
|
||||||
|
'name',
|
||||||
|
'leavesPerBook',
|
||||||
|
'applicableAccountType',
|
||||||
|
'ctsCompliant',
|
||||||
|
'reorderLevel',
|
||||||
|
'active',
|
||||||
|
'actions'
|
||||||
|
];
|
||||||
|
|
||||||
|
constructor() {
|
||||||
|
this.seriesForm = this.formBuilder.group({
|
||||||
|
seriesCode: [
|
||||||
|
'',
|
||||||
|
Validators.required
|
||||||
|
],
|
||||||
|
name: [
|
||||||
|
'',
|
||||||
|
Validators.required
|
||||||
|
],
|
||||||
|
leavesPerBook: [
|
||||||
|
25,
|
||||||
|
Validators.required
|
||||||
|
],
|
||||||
|
applicableAccountType: [
|
||||||
|
'',
|
||||||
|
Validators.required
|
||||||
|
],
|
||||||
|
ctsCompliant: [true],
|
||||||
|
reorderLevel: [10]
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
ngOnInit(): void {
|
||||||
|
this.loadSeries();
|
||||||
|
}
|
||||||
|
|
||||||
|
loadSeries(): void {
|
||||||
|
this.chequeBookService
|
||||||
|
.getSeries(this.statusFilter || undefined, this.pageIndex * this.pageSize, this.pageSize)
|
||||||
|
.subscribe((res: any) => {
|
||||||
|
this.seriesList = res?.pageItems ?? [];
|
||||||
|
this.totalRecords = Number(res?.totalFilteredRecords ?? 0);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
filterByStatus(status: string): void {
|
||||||
|
this.statusFilter = this.statusFilter === status ? '' : status;
|
||||||
|
this.pageIndex = 0;
|
||||||
|
this.loadSeries();
|
||||||
|
}
|
||||||
|
|
||||||
|
onPageChange(event: PageEvent): void {
|
||||||
|
this.pageSize = event.pageSize;
|
||||||
|
this.pageIndex = event.pageIndex;
|
||||||
|
this.loadSeries();
|
||||||
|
}
|
||||||
|
|
||||||
|
submit(): void {
|
||||||
|
if (this.seriesForm.invalid || this.busy) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
this.busy = true;
|
||||||
|
const raw = this.seriesForm.value;
|
||||||
|
const payload: any = {
|
||||||
|
seriesCode: raw.seriesCode,
|
||||||
|
name: raw.name,
|
||||||
|
leavesPerBook: raw.leavesPerBook,
|
||||||
|
applicableAccountType: raw.applicableAccountType,
|
||||||
|
ctsCompliant: !!raw.ctsCompliant,
|
||||||
|
locale: 'en'
|
||||||
|
};
|
||||||
|
if (raw.reorderLevel !== '' && raw.reorderLevel != null) payload.reorderLevel = raw.reorderLevel;
|
||||||
|
|
||||||
|
this.chequeBookService.createSeries(payload).subscribe({
|
||||||
|
next: () => {
|
||||||
|
this.busy = false;
|
||||||
|
this.showForm = false;
|
||||||
|
this.seriesForm.reset({ leavesPerBook: 25, applicableAccountType: '', ctsCompliant: true, reorderLevel: 10 });
|
||||||
|
this.notify('Submitted for approval — see Approvals > Cheque Book Requests');
|
||||||
|
this.loadSeries();
|
||||||
|
},
|
||||||
|
error: () => {
|
||||||
|
this.busy = false;
|
||||||
|
this.notify('Failed to add cheque series', true);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
deactivate(series: any): void {
|
||||||
|
const dialogRef = this.dialog.open(DeleteDialogComponent, {
|
||||||
|
data: { deleteContext: `cheque series ${series.seriesCode}` }
|
||||||
|
});
|
||||||
|
dialogRef.afterClosed().subscribe((response: any) => {
|
||||||
|
if (!response?.delete) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
this.chequeBookService.deactivateSeries(series.id).subscribe({
|
||||||
|
next: () => {
|
||||||
|
this.notify('Submitted for approval — see Approvals > Cheque Book Requests');
|
||||||
|
this.loadSeries();
|
||||||
|
},
|
||||||
|
error: () => this.notify('Failed to submit for approval', true)
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,29 @@
|
|||||||
|
<!--
|
||||||
|
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="container">
|
||||||
|
<div class="page-header">
|
||||||
|
<h2>{{ 'labels.menus.Cheque Book Inventory' | translate }}</h2>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="cards-grid">
|
||||||
|
@for (screen of screens; track screen.label) {
|
||||||
|
<mat-card class="cb-card" [routerLink]="screen.route">
|
||||||
|
<mat-card-content>
|
||||||
|
<div class="card-head">
|
||||||
|
<span class="card-icon">
|
||||||
|
<fa-icon [icon]="screen.icon"></fa-icon>
|
||||||
|
</span>
|
||||||
|
<span class="card-title">{{ screen.label | translate }}</span>
|
||||||
|
</div>
|
||||||
|
<p class="card-desc">{{ screen.description | translate }}</p>
|
||||||
|
</mat-card-content>
|
||||||
|
</mat-card>
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
@@ -0,0 +1,68 @@
|
|||||||
|
/**
|
||||||
|
* 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/.
|
||||||
|
*/
|
||||||
|
|
||||||
|
.container {
|
||||||
|
padding: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.page-header {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: center;
|
||||||
|
margin-bottom: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.cards-grid {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(auto-fill, minmax(260px, 1fr));
|
||||||
|
gap: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.cb-card {
|
||||||
|
cursor: pointer;
|
||||||
|
transition:
|
||||||
|
box-shadow 0.2s ease,
|
||||||
|
transform 0.2s ease;
|
||||||
|
|
||||||
|
&:hover {
|
||||||
|
box-shadow: 0 4px 12px rgb(0 0 0 / 15%);
|
||||||
|
transform: translateY(-2px);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.card-head {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 12px;
|
||||||
|
margin-bottom: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.card-icon {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
width: 42px;
|
||||||
|
height: 42px;
|
||||||
|
border-radius: 10px;
|
||||||
|
flex: 0 0 auto;
|
||||||
|
background: rgb(25 118 210 / 10%);
|
||||||
|
color: #1976d2;
|
||||||
|
font-size: 1.15rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.card-title {
|
||||||
|
font-size: 1.05rem;
|
||||||
|
font-weight: 600;
|
||||||
|
line-height: 1.2;
|
||||||
|
}
|
||||||
|
|
||||||
|
.card-desc {
|
||||||
|
color: rgb(0 0 0 / 60%);
|
||||||
|
font-size: 0.85rem;
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
@@ -0,0 +1,72 @@
|
|||||||
|
/**
|
||||||
|
* Copyright since 2025 Mifos Initiative
|
||||||
|
*
|
||||||
|
* This Source Code Form is subject to the terms of the Mozilla Public
|
||||||
|
* License, v. 2.0. If a copy of the MPL was not distributed with this
|
||||||
|
* file, You can obtain one at http://mozilla.org/MPL/2.0/.
|
||||||
|
*/
|
||||||
|
|
||||||
|
/** Angular Imports */
|
||||||
|
import { Component } from '@angular/core';
|
||||||
|
import { RouterLink } from '@angular/router';
|
||||||
|
import { MatIconModule } from '@angular/material/icon';
|
||||||
|
import { MatCardModule } from '@angular/material/card';
|
||||||
|
import { FaIconComponent } from '@fortawesome/angular-fontawesome';
|
||||||
|
import { STANDALONE_SHARED_IMPORTS } from 'app/standalone-shared.module';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Cheque Book Inventory landing page — one card per screen (series master, stock, requests/issuance, and
|
||||||
|
* the teller quick-lookup).
|
||||||
|
*/
|
||||||
|
@Component({
|
||||||
|
selector: 'mifosx-chequebook-dashboard',
|
||||||
|
templateUrl: './chequebook-dashboard.component.html',
|
||||||
|
styleUrls: ['./chequebook-dashboard.component.scss'],
|
||||||
|
imports: [
|
||||||
|
...STANDALONE_SHARED_IMPORTS,
|
||||||
|
RouterLink,
|
||||||
|
MatIconModule,
|
||||||
|
MatCardModule,
|
||||||
|
FaIconComponent
|
||||||
|
]
|
||||||
|
})
|
||||||
|
export class ChequeBookDashboardComponent {
|
||||||
|
screens = [
|
||||||
|
{
|
||||||
|
route: [
|
||||||
|
'/chequebook',
|
||||||
|
'series'
|
||||||
|
],
|
||||||
|
label: 'Cheque Series',
|
||||||
|
icon: 'list',
|
||||||
|
description: 'Stationery master — leaves per book, CTS-2010 compliance, low-stock reorder level'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
route: [
|
||||||
|
'/chequebook',
|
||||||
|
'stock'
|
||||||
|
],
|
||||||
|
label: 'Stock Lots & Transfers',
|
||||||
|
icon: 'boxes-stacked',
|
||||||
|
description: 'Receive bulk stock from the press, dispatch to branches, acknowledge receipt'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
route: [
|
||||||
|
'/chequebook',
|
||||||
|
'books'
|
||||||
|
],
|
||||||
|
label: 'Requests & Issuance',
|
||||||
|
icon: 'book',
|
||||||
|
description: 'Customer requisitions, issuance, stop-payment, lost/stolen hotlisting'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
route: [
|
||||||
|
'/chequebook',
|
||||||
|
'lookup'
|
||||||
|
],
|
||||||
|
label: 'Leaf Lookup',
|
||||||
|
icon: 'magnifying-glass',
|
||||||
|
description: 'Counter check — is this cheque number safe to honour?'
|
||||||
|
}
|
||||||
|
];
|
||||||
|
}
|
||||||
@@ -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/.
|
||||||
|
*/
|
||||||
|
|
||||||
|
/** Angular Imports */
|
||||||
|
import { NgModule } from '@angular/core';
|
||||||
|
import { Routes, RouterModule } from '@angular/router';
|
||||||
|
|
||||||
|
/** Routing Imports */
|
||||||
|
import { Route } from '../core/route/route.service';
|
||||||
|
|
||||||
|
/** Custom Components */
|
||||||
|
import { ChequeBookDashboardComponent } from './chequebook-dashboard/chequebook-dashboard.component';
|
||||||
|
import { ChequeSeriesComponent } from './cheque-series/cheque-series.component';
|
||||||
|
import { StockManagementComponent } from './stock-management/stock-management.component';
|
||||||
|
import { ChequeBooksComponent } from './cheque-books/cheque-books.component';
|
||||||
|
import { ChequeBookDetailComponent } from './cheque-book-detail/cheque-book-detail.component';
|
||||||
|
import { LeafLookupComponent } from './leaf-lookup/leaf-lookup.component';
|
||||||
|
|
||||||
|
/** Cheque Book Inventory Routes */
|
||||||
|
const routes: Routes = [
|
||||||
|
Route.withShell([
|
||||||
|
{
|
||||||
|
path: 'chequebook',
|
||||||
|
data: { title: 'Cheque Book Inventory', breadcrumb: 'Cheque Book Inventory' },
|
||||||
|
children: [
|
||||||
|
{
|
||||||
|
path: '',
|
||||||
|
component: ChequeBookDashboardComponent
|
||||||
|
},
|
||||||
|
{
|
||||||
|
path: 'series',
|
||||||
|
component: ChequeSeriesComponent,
|
||||||
|
data: { title: 'Cheque Series', breadcrumb: 'Cheque Series' }
|
||||||
|
},
|
||||||
|
{
|
||||||
|
path: 'stock',
|
||||||
|
component: StockManagementComponent,
|
||||||
|
data: { title: 'Cheque Stock', breadcrumb: 'Cheque Stock' }
|
||||||
|
},
|
||||||
|
{
|
||||||
|
path: 'books',
|
||||||
|
component: ChequeBooksComponent,
|
||||||
|
data: { title: 'Cheque Books', breadcrumb: 'Cheque Books' }
|
||||||
|
},
|
||||||
|
{
|
||||||
|
path: 'books/:id',
|
||||||
|
component: ChequeBookDetailComponent,
|
||||||
|
data: { title: 'Cheque Book', routeParamBreadcrumb: 'id' }
|
||||||
|
},
|
||||||
|
{
|
||||||
|
path: 'lookup',
|
||||||
|
component: LeafLookupComponent,
|
||||||
|
data: { title: 'Leaf Lookup', breadcrumb: 'Leaf Lookup' }
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
])
|
||||||
|
];
|
||||||
|
|
||||||
|
@NgModule({
|
||||||
|
imports: [RouterModule.forChild(routes)],
|
||||||
|
exports: [RouterModule]
|
||||||
|
})
|
||||||
|
export class ChequeBookRoutingModule {}
|
||||||
@@ -0,0 +1,46 @@
|
|||||||
|
/**
|
||||||
|
* Copyright since 2025 Mifos Initiative
|
||||||
|
*
|
||||||
|
* This Source Code Form is subject to the terms of the Mozilla Public
|
||||||
|
* License, v. 2.0. If a copy of the MPL was not distributed with this
|
||||||
|
* file, You can obtain one at http://mozilla.org/MPL/2.0/.
|
||||||
|
*/
|
||||||
|
|
||||||
|
/** Angular Imports */
|
||||||
|
import { NgModule } from '@angular/core';
|
||||||
|
|
||||||
|
/** Custom Modules */
|
||||||
|
import { SharedModule } from '../shared/shared.module';
|
||||||
|
import { DirectivesModule } from '../directives/directives.module';
|
||||||
|
import { PipesModule } from 'app/pipes/pipes.module';
|
||||||
|
import { ChequeBookRoutingModule } from './chequebook-routing.module';
|
||||||
|
|
||||||
|
/** Custom Components (standalone) */
|
||||||
|
import { ChequeBookDashboardComponent } from './chequebook-dashboard/chequebook-dashboard.component';
|
||||||
|
import { ChequeSeriesComponent } from './cheque-series/cheque-series.component';
|
||||||
|
import { StockManagementComponent } from './stock-management/stock-management.component';
|
||||||
|
import { ChequeBooksComponent } from './cheque-books/cheque-books.component';
|
||||||
|
import { ChequeBookDetailComponent } from './cheque-book-detail/cheque-book-detail.component';
|
||||||
|
import { LeafLookupComponent } from './leaf-lookup/leaf-lookup.component';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Cheque Book Inventory Module.
|
||||||
|
*
|
||||||
|
* Stationery master, a unified stock ledger (HO receipt / branch transfer-in), account issuance with
|
||||||
|
* per-leaf serial tracking, stop-payment, and lost/stolen hotlisting for both issued and unissued stock.
|
||||||
|
*/
|
||||||
|
@NgModule({
|
||||||
|
imports: [
|
||||||
|
SharedModule,
|
||||||
|
DirectivesModule,
|
||||||
|
PipesModule,
|
||||||
|
ChequeBookRoutingModule,
|
||||||
|
ChequeBookDashboardComponent,
|
||||||
|
ChequeSeriesComponent,
|
||||||
|
StockManagementComponent,
|
||||||
|
ChequeBooksComponent,
|
||||||
|
ChequeBookDetailComponent,
|
||||||
|
LeafLookupComponent
|
||||||
|
]
|
||||||
|
})
|
||||||
|
export class ChequeBookModule {}
|
||||||
@@ -0,0 +1,226 @@
|
|||||||
|
/**
|
||||||
|
* Copyright since 2025 Mifos Initiative
|
||||||
|
*
|
||||||
|
* This Source Code Form is subject to the terms of the Mozilla Public
|
||||||
|
* License, v. 2.0. If a copy of the MPL was not distributed with this
|
||||||
|
* file, You can obtain one at http://mozilla.org/MPL/2.0/.
|
||||||
|
*/
|
||||||
|
|
||||||
|
/** Angular Imports */
|
||||||
|
import { Injectable, inject } from '@angular/core';
|
||||||
|
import { HttpClient, HttpParams } from '@angular/common/http';
|
||||||
|
import { Observable } from 'rxjs';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Cheque Book Inventory — stationery master, a unified stock ledger (HO receipt / branch transfer-in share
|
||||||
|
* one drawdown-cursor concept), account issuance with per-leaf serial tracking, stop-payment and
|
||||||
|
* lost/stolen hotlisting (both issued and unissued stock).
|
||||||
|
*/
|
||||||
|
@Injectable({
|
||||||
|
providedIn: 'root'
|
||||||
|
})
|
||||||
|
export class ChequeBookService {
|
||||||
|
private http = inject(HttpClient);
|
||||||
|
|
||||||
|
// ----- Series -----
|
||||||
|
|
||||||
|
/** status: 'ACTIVE' | 'INACTIVE'. Server-side paginated — always returns { pageItems, totalFilteredRecords }. */
|
||||||
|
getSeries(status?: string, offset = 0, limit = 20): Observable<any> {
|
||||||
|
let params = new HttpParams().set('offset', offset).set('limit', limit);
|
||||||
|
if (status) params = params.set('status', status);
|
||||||
|
return this.http.get('/chequeseries', { params });
|
||||||
|
}
|
||||||
|
|
||||||
|
createSeries(payload: any): Observable<any> {
|
||||||
|
return this.http.post('/chequeseries', payload);
|
||||||
|
}
|
||||||
|
|
||||||
|
updateSeries(id: string | number, payload: any): Observable<any> {
|
||||||
|
return this.http.put(`/chequeseries/${id}`, payload);
|
||||||
|
}
|
||||||
|
|
||||||
|
deactivateSeries(id: string | number): Observable<any> {
|
||||||
|
return this.http.delete(`/chequeseries/${id}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ----- Stock lots -----
|
||||||
|
|
||||||
|
/** Server-side paginated — always returns { pageItems, totalFilteredRecords }. */
|
||||||
|
getStockLots(
|
||||||
|
officeId?: string | number,
|
||||||
|
seriesId?: string | number,
|
||||||
|
status?: string,
|
||||||
|
offset = 0,
|
||||||
|
limit = 20
|
||||||
|
): Observable<any> {
|
||||||
|
let params = new HttpParams().set('offset', offset).set('limit', limit);
|
||||||
|
if (officeId) params = params.set('officeId', officeId);
|
||||||
|
if (seriesId) params = params.set('seriesId', seriesId);
|
||||||
|
if (status) params = params.set('status', status);
|
||||||
|
return this.http.get('/chequestocklots', { params });
|
||||||
|
}
|
||||||
|
|
||||||
|
receiveStock(payload: any): Observable<any> {
|
||||||
|
return this.http.post('/chequestocklots', payload);
|
||||||
|
}
|
||||||
|
|
||||||
|
reportStockLotLost(lotId: string | number, payload: any): Observable<any> {
|
||||||
|
return this.http.post(`/chequestocklots/${lotId}`, payload, {
|
||||||
|
params: new HttpParams().set('command', 'reportlost')
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// ----- Stock transfers -----
|
||||||
|
|
||||||
|
/** Server-side paginated — always returns { pageItems, totalFilteredRecords }. */
|
||||||
|
getStockTransfers(status?: string, offset = 0, limit = 20): Observable<any> {
|
||||||
|
let params = new HttpParams().set('offset', offset).set('limit', limit);
|
||||||
|
if (status) params = params.set('status', status);
|
||||||
|
return this.http.get('/chequestocktransfers', { params });
|
||||||
|
}
|
||||||
|
|
||||||
|
dispatchStockTransfer(payload: any): Observable<any> {
|
||||||
|
return this.http.post('/chequestocktransfers', payload);
|
||||||
|
}
|
||||||
|
|
||||||
|
receiveStockTransfer(transferId: string | number): Observable<any> {
|
||||||
|
return this.http.post(
|
||||||
|
`/chequestocktransfers/${transferId}`,
|
||||||
|
{},
|
||||||
|
{ params: new HttpParams().set('command', 'receive') }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
rejectStockTransfer(transferId: string | number): Observable<any> {
|
||||||
|
return this.http.post(
|
||||||
|
`/chequestocktransfers/${transferId}`,
|
||||||
|
{},
|
||||||
|
{ params: new HttpParams().set('command', 'reject') }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ----- Cheque books -----
|
||||||
|
|
||||||
|
getBooks(
|
||||||
|
accountType?: string,
|
||||||
|
accountId?: string | number,
|
||||||
|
officeId?: string | number,
|
||||||
|
status?: string,
|
||||||
|
offset = 0,
|
||||||
|
limit = 20
|
||||||
|
): Observable<any> {
|
||||||
|
let params = new HttpParams().set('offset', offset).set('limit', limit);
|
||||||
|
if (accountType) params = params.set('accountType', accountType);
|
||||||
|
if (accountId) params = params.set('accountId', accountId);
|
||||||
|
if (officeId) params = params.set('officeId', officeId);
|
||||||
|
if (status) params = params.set('status', status);
|
||||||
|
return this.http.get('/chequebooks', { params });
|
||||||
|
}
|
||||||
|
|
||||||
|
getBook(id: string | number): Observable<any> {
|
||||||
|
return this.http.get(`/chequebooks/${id}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
getLeaves(bookId: string | number): Observable<any> {
|
||||||
|
return this.http.get(`/chequebooks/${bookId}/leaves`);
|
||||||
|
}
|
||||||
|
|
||||||
|
requestBook(payload: any): Observable<any> {
|
||||||
|
return this.http.post('/chequebooks', payload);
|
||||||
|
}
|
||||||
|
|
||||||
|
issueBook(bookId: string | number, payload: any): Observable<any> {
|
||||||
|
return this.http.post(`/chequebooks/${bookId}`, payload, { params: new HttpParams().set('command', 'issue') });
|
||||||
|
}
|
||||||
|
|
||||||
|
rejectBook(bookId: string | number, payload: any): Observable<any> {
|
||||||
|
return this.http.post(`/chequebooks/${bookId}`, payload, { params: new HttpParams().set('command', 'reject') });
|
||||||
|
}
|
||||||
|
|
||||||
|
withdrawBook(bookId: string | number): Observable<any> {
|
||||||
|
return this.http.post(`/chequebooks/${bookId}`, {}, { params: new HttpParams().set('command', 'withdraw') });
|
||||||
|
}
|
||||||
|
|
||||||
|
stopPayment(bookId: string | number, payload: any): Observable<any> {
|
||||||
|
return this.http.post(`/chequebooks/${bookId}`, payload, {
|
||||||
|
params: new HttpParams().set('command', 'stoppayment')
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
revokeStopPayment(bookId: string | number, payload: any): Observable<any> {
|
||||||
|
return this.http.post(`/chequebooks/${bookId}`, payload, {
|
||||||
|
params: new HttpParams().set('command', 'revokestoppayment')
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
reportBookLost(bookId: string | number, payload: any): Observable<any> {
|
||||||
|
return this.http.post(`/chequebooks/${bookId}`, payload, { params: new HttpParams().set('command', 'reportlost') });
|
||||||
|
}
|
||||||
|
|
||||||
|
cancelLeaf(bookId: string | number, payload: any): Observable<any> {
|
||||||
|
return this.http.post(`/chequebooks/${bookId}`, payload, { params: new HttpParams().set('command', 'cancelleaf') });
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Manually record that leaves cleared, once the branch sees them on the bank/clearing statement. */
|
||||||
|
markUsed(bookId: string | number, payload: any): Observable<any> {
|
||||||
|
return this.http.post(`/chequebooks/${bookId}`, payload, { params: new HttpParams().set('command', 'markused') });
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Flag leaves as returned/dishonoured (insufficient funds, signature mismatch, etc.) — terminal, like reportlost. */
|
||||||
|
returnBook(bookId: string | number, payload: any): Observable<any> {
|
||||||
|
return this.http.post(`/chequebooks/${bookId}`, payload, { params: new HttpParams().set('command', 'return') });
|
||||||
|
}
|
||||||
|
|
||||||
|
// ----- Pending requests (bespoke maker-checker for series/stock/book creation) -----
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A different user than the maker must approve/reject — enforced server-side, not just in this UI.
|
||||||
|
* Server-side paginated and date-filtered, matching the clerk disbursement/withdrawal request queues.
|
||||||
|
*/
|
||||||
|
getPendingRequests(status?: string, fromDate?: string, toDate?: string, offset = 0, limit = 20): Observable<any> {
|
||||||
|
let params = new HttpParams().set('offset', offset).set('limit', 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('/chequebook/pending-requests', { params });
|
||||||
|
}
|
||||||
|
|
||||||
|
getPendingRequest(id: string | number): Observable<any> {
|
||||||
|
return this.http.get(`/chequebook/pending-requests/${id}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
approvePendingRequest(id: string | number): Observable<any> {
|
||||||
|
return this.http.post(
|
||||||
|
`/chequebook/pending-requests/${id}`,
|
||||||
|
{},
|
||||||
|
{ params: new HttpParams().set('command', 'approve') }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
rejectPendingRequest(id: string | number, payload: any): Observable<any> {
|
||||||
|
return this.http.post(`/chequebook/pending-requests/${id}`, payload, {
|
||||||
|
params: new HttpParams().set('command', 'reject')
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// ----- Leaf lookup -----
|
||||||
|
|
||||||
|
lookupLeaf(serialNo: string | number): Observable<any> {
|
||||||
|
return this.http.get('/chequeleaves/lookup', { params: new HttpParams().set('serialNo', serialNo) });
|
||||||
|
}
|
||||||
|
|
||||||
|
// ----- Account lookup (by account number) -----
|
||||||
|
|
||||||
|
/** Search a Savings or Current account by its account number; returns a paginated list to match against. */
|
||||||
|
searchAccountByNo(accountType: string, accountNo: string): Observable<any> {
|
||||||
|
const base = accountType === 'CURRENT' ? '/currentaccounts' : '/savingsaccounts';
|
||||||
|
const params = new HttpParams().set('accountNo', accountNo).set('offset', '0').set('limit', '20');
|
||||||
|
return this.http.get(base, { params });
|
||||||
|
}
|
||||||
|
|
||||||
|
// ----- Shared template data -----
|
||||||
|
|
||||||
|
getOffices(): Observable<any> {
|
||||||
|
return this.http.get('/offices');
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,136 @@
|
|||||||
|
<!--
|
||||||
|
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="container">
|
||||||
|
<div class="page-header">
|
||||||
|
<h2>{{ 'labels.inputs.Leaf Lookup' | translate }}</h2>
|
||||||
|
<button mat-raised-button [routerLink]="['/chequebook']">{{ 'labels.buttons.Back' | translate }}</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<mat-card class="search-card">
|
||||||
|
<mat-card-content>
|
||||||
|
<form [formGroup]="searchForm" (ngSubmit)="search()" class="search-row">
|
||||||
|
<mat-form-field appearance="fill">
|
||||||
|
<mat-label>{{ 'labels.inputs.Cheque Leaf Serial No' | translate }}</mat-label>
|
||||||
|
<input matInput type="number" formControlName="serialNo" required />
|
||||||
|
</mat-form-field>
|
||||||
|
<button mat-raised-button color="primary" type="submit" [disabled]="searchForm.invalid || busy">
|
||||||
|
<fa-icon icon="magnifying-glass"></fa-icon> {{ 'labels.buttons.Search' | translate }}
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
</mat-card-content>
|
||||||
|
</mat-card>
|
||||||
|
|
||||||
|
@if (results.length > 1) {
|
||||||
|
<div class="multi-match-note">
|
||||||
|
{{
|
||||||
|
'labels.text.More than one series has a leaf with this serial number — check the series code against the physical cheque.'
|
||||||
|
| translate
|
||||||
|
}}
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
|
||||||
|
@for (result of results; track result.seriesCode + '-' + result.leafSerialNo) {
|
||||||
|
<mat-card class="result-card">
|
||||||
|
<mat-card-content>
|
||||||
|
<div class="result-header">
|
||||||
|
@if (result.seriesCode) {
|
||||||
|
<span class="series-tag">{{ result.seriesCode }}</span>
|
||||||
|
}
|
||||||
|
<span
|
||||||
|
class="pill big"
|
||||||
|
[ngClass]="
|
||||||
|
result.status === 'ACTIVE'
|
||||||
|
? 'ok'
|
||||||
|
: result.status === 'USED'
|
||||||
|
? 'used'
|
||||||
|
: result.status === 'STOP_PAYMENT'
|
||||||
|
? 'warn'
|
||||||
|
: result.status === 'LOST'
|
||||||
|
? 'bad'
|
||||||
|
: result.status === 'RETURNED'
|
||||||
|
? 'returned'
|
||||||
|
: 'neutral'
|
||||||
|
"
|
||||||
|
>{{ result.status }}</span
|
||||||
|
>
|
||||||
|
</div>
|
||||||
|
<div class="info-grid">
|
||||||
|
<div>
|
||||||
|
<span class="k">{{ 'labels.inputs.Serial No' | translate }}</span
|
||||||
|
><span>{{ result.leafSerialNo }}</span>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<span class="k">{{ 'labels.inputs.Series' | translate }}</span
|
||||||
|
><span>{{ result.seriesCode || '–' }}</span>
|
||||||
|
</div>
|
||||||
|
@if (result.chequeBookId) {
|
||||||
|
<div>
|
||||||
|
<span class="k">{{ 'labels.inputs.Cheque Book' | translate }}</span
|
||||||
|
><span>#{{ result.chequeBookId }}</span>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<span class="k">{{ 'labels.inputs.Account' | translate }}</span
|
||||||
|
><span>{{ result.accountType }} {{ result.accountNo || result.accountId }}</span>
|
||||||
|
</div>
|
||||||
|
@if (result.clientName) {
|
||||||
|
<div>
|
||||||
|
<span class="k">{{ 'labels.inputs.Client' | translate }}</span
|
||||||
|
><span>{{ result.clientName }}</span>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
} @else {
|
||||||
|
<div class="warn-note">
|
||||||
|
{{
|
||||||
|
'labels.text.This leaf was never issued to a customer — reported lost/stolen from stock.' | translate
|
||||||
|
}}
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
<div>
|
||||||
|
<span class="k">{{ 'labels.inputs.Office' | translate }}</span
|
||||||
|
><span>{{ result.officeName }}</span>
|
||||||
|
</div>
|
||||||
|
@if (result.status === 'STOP_PAYMENT') {
|
||||||
|
<div>
|
||||||
|
<span class="k">{{ 'labels.inputs.Stop Reason' | translate }}</span
|
||||||
|
><span>{{ result.stopReason }}</span>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
@if (result.status === 'LOST') {
|
||||||
|
<div>
|
||||||
|
<span class="k">{{ 'labels.inputs.Lost Reason' | translate }}</span
|
||||||
|
><span>{{ result.lostReason }}</span>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
@if (result.status === 'CANCELLED') {
|
||||||
|
<div>
|
||||||
|
<span class="k">{{ 'labels.inputs.Cancelled Reason' | translate }}</span
|
||||||
|
><span>{{ result.cancelledReason }}</span>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
@if (result.status === 'USED' && result.usedReason) {
|
||||||
|
<div>
|
||||||
|
<span class="k">{{ 'labels.inputs.Used Reason' | translate }}</span
|
||||||
|
><span>{{ result.usedReason }}</span>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
@if (result.status === 'RETURNED') {
|
||||||
|
<div>
|
||||||
|
<span class="k">{{ 'labels.inputs.Return Reason' | translate }}</span
|
||||||
|
><span>{{ result.returnReason }}</span>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
</mat-card-content>
|
||||||
|
</mat-card>
|
||||||
|
} @empty {
|
||||||
|
@if (searched && !busy) {
|
||||||
|
<div class="empty-state">{{ 'labels.text.No records to display' | translate }}</div>
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</div>
|
||||||
@@ -0,0 +1,130 @@
|
|||||||
|
/**
|
||||||
|
* 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/.
|
||||||
|
*/
|
||||||
|
|
||||||
|
.container {
|
||||||
|
padding: 16px;
|
||||||
|
max-width: 700px;
|
||||||
|
margin: 0 auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.page-header {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: center;
|
||||||
|
margin-bottom: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.search-card {
|
||||||
|
margin-bottom: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.search-row {
|
||||||
|
display: flex;
|
||||||
|
gap: 16px;
|
||||||
|
align-items: flex-start;
|
||||||
|
|
||||||
|
mat-form-field {
|
||||||
|
flex: 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.result-card {
|
||||||
|
margin-bottom: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.result-header {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 10px;
|
||||||
|
margin-bottom: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.series-tag {
|
||||||
|
font-size: 12px;
|
||||||
|
font-weight: 700;
|
||||||
|
letter-spacing: 0.02em;
|
||||||
|
color: rgb(0 0 0 / 60%);
|
||||||
|
}
|
||||||
|
|
||||||
|
.multi-match-note {
|
||||||
|
margin-bottom: 16px;
|
||||||
|
color: #a16207;
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
|
||||||
|
.info-grid {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(auto-fill, minmax(200px, 1fr));
|
||||||
|
gap: 12px 24px;
|
||||||
|
|
||||||
|
div {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
}
|
||||||
|
|
||||||
|
.k {
|
||||||
|
font-size: 0.72rem;
|
||||||
|
text-transform: uppercase;
|
||||||
|
color: rgb(0 0 0 / 50%);
|
||||||
|
}
|
||||||
|
|
||||||
|
.warn-note {
|
||||||
|
grid-column: 1 / -1;
|
||||||
|
color: #a16207;
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.pill {
|
||||||
|
font-size: 11px;
|
||||||
|
padding: 2px 8px;
|
||||||
|
border-radius: 6px;
|
||||||
|
font-weight: 600;
|
||||||
|
|
||||||
|
&.big {
|
||||||
|
font-size: 14px;
|
||||||
|
padding: 6px 14px;
|
||||||
|
border-radius: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
&.ok {
|
||||||
|
background: #dff5e8;
|
||||||
|
color: #28c07a;
|
||||||
|
}
|
||||||
|
|
||||||
|
&.used {
|
||||||
|
background: #e3edfd;
|
||||||
|
color: #1976d2;
|
||||||
|
}
|
||||||
|
|
||||||
|
&.bad {
|
||||||
|
background: #fde3e3;
|
||||||
|
color: #d32f2f;
|
||||||
|
}
|
||||||
|
|
||||||
|
&.warn {
|
||||||
|
background: #fff3cd;
|
||||||
|
color: #a16207;
|
||||||
|
}
|
||||||
|
|
||||||
|
&.neutral {
|
||||||
|
background: #e0e0e0;
|
||||||
|
color: #757575;
|
||||||
|
}
|
||||||
|
|
||||||
|
&.returned {
|
||||||
|
background: #f3e8fd;
|
||||||
|
color: #9333ea;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.empty-state {
|
||||||
|
padding: 24px;
|
||||||
|
text-align: center;
|
||||||
|
color: rgb(0 0 0 / 50%);
|
||||||
|
}
|
||||||
@@ -0,0 +1,81 @@
|
|||||||
|
/**
|
||||||
|
* Copyright since 2025 Mifos Initiative
|
||||||
|
*
|
||||||
|
* This Source Code Form is subject to the terms of the Mozilla Public
|
||||||
|
* License, v. 2.0. If a copy of the MPL was not distributed with this
|
||||||
|
* file, You can obtain one at http://mozilla.org/MPL/2.0/.
|
||||||
|
*/
|
||||||
|
|
||||||
|
/** Angular Imports */
|
||||||
|
import { Component, inject } from '@angular/core';
|
||||||
|
import { UntypedFormBuilder, UntypedFormGroup, Validators } from '@angular/forms';
|
||||||
|
import { MatIconModule } from '@angular/material/icon';
|
||||||
|
import { MatButtonModule } from '@angular/material/button';
|
||||||
|
import { MatSnackBar, MatSnackBarModule } from '@angular/material/snack-bar';
|
||||||
|
import { FaIconComponent } from '@fortawesome/angular-fontawesome';
|
||||||
|
import { STANDALONE_SHARED_IMPORTS } from 'app/standalone-shared.module';
|
||||||
|
|
||||||
|
/** Custom Services */
|
||||||
|
import { ChequeBookService } from '../chequebook.service';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The teller/counter quick-lookup: given a bare cheque-leaf serial number, is it active, stopped, lost, or
|
||||||
|
* was it ever issued at all — one code path covers every case (issued or unissued-but-stolen stock).
|
||||||
|
*/
|
||||||
|
@Component({
|
||||||
|
selector: 'mifosx-leaf-lookup',
|
||||||
|
templateUrl: './leaf-lookup.component.html',
|
||||||
|
styleUrls: ['./leaf-lookup.component.scss'],
|
||||||
|
imports: [
|
||||||
|
...STANDALONE_SHARED_IMPORTS,
|
||||||
|
MatIconModule,
|
||||||
|
MatButtonModule,
|
||||||
|
MatSnackBarModule,
|
||||||
|
FaIconComponent
|
||||||
|
]
|
||||||
|
})
|
||||||
|
export class LeafLookupComponent {
|
||||||
|
private formBuilder = inject(UntypedFormBuilder);
|
||||||
|
private chequeBookService = inject(ChequeBookService);
|
||||||
|
private snackBar = inject(MatSnackBar);
|
||||||
|
|
||||||
|
private notify(message: string, isError = false): void {
|
||||||
|
this.snackBar.open(message, 'Dismiss', { duration: isError ? 6000 : 3000 });
|
||||||
|
}
|
||||||
|
|
||||||
|
searchForm: UntypedFormGroup;
|
||||||
|
// a bare serial number is only unique within its own series — more than one series can share a number,
|
||||||
|
// so the lookup can legitimately return more than one match
|
||||||
|
results: any[] = [];
|
||||||
|
searched = false;
|
||||||
|
busy = false;
|
||||||
|
|
||||||
|
constructor() {
|
||||||
|
this.searchForm = this.formBuilder.group({
|
||||||
|
serialNo: [
|
||||||
|
'',
|
||||||
|
Validators.required
|
||||||
|
]
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
search(): void {
|
||||||
|
if (this.searchForm.invalid || this.busy) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
this.busy = true;
|
||||||
|
this.searched = true;
|
||||||
|
this.results = [];
|
||||||
|
this.chequeBookService.lookupLeaf(this.searchForm.value.serialNo).subscribe({
|
||||||
|
next: (data: any) => {
|
||||||
|
this.busy = false;
|
||||||
|
this.results = Array.isArray(data) ? data : [data];
|
||||||
|
},
|
||||||
|
error: () => {
|
||||||
|
this.busy = false;
|
||||||
|
this.results = [];
|
||||||
|
this.notify('No cheque leaf found with that serial number', true);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
+47
@@ -0,0 +1,47 @@
|
|||||||
|
<!--
|
||||||
|
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>{{ 'labels.inputs.Report Remaining Stock Lost/Stolen' | translate }}</h1>
|
||||||
|
<div mat-dialog-content>
|
||||||
|
<div class="rll-context">
|
||||||
|
<div class="rll-field">
|
||||||
|
<span class="k">{{ 'labels.inputs.Series' | translate }}</span>
|
||||||
|
<span>{{ data.seriesCode }}</span>
|
||||||
|
</div>
|
||||||
|
<div class="rll-field">
|
||||||
|
<span class="k">{{ 'labels.inputs.Office' | translate }}</span>
|
||||||
|
<span>{{ data.officeName }}</span>
|
||||||
|
</div>
|
||||||
|
<div class="rll-field">
|
||||||
|
<span class="k">{{ 'labels.inputs.Remaining Range' | translate }}</span>
|
||||||
|
<span>{{ data.nextSerialNo }} – {{ data.rangeEnd }}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<form [formGroup]="reportLostForm" (ngSubmit)="submit()" class="rll-form">
|
||||||
|
<div class="form-grid">
|
||||||
|
<mat-form-field appearance="fill">
|
||||||
|
<mat-label>{{ 'labels.inputs.From Serial No' | translate }}</mat-label>
|
||||||
|
<input matInput type="number" formControlName="fromSerialNo" required />
|
||||||
|
<mat-hint>{{
|
||||||
|
'labels.text.Only a suffix of the remaining undrawn range can be reported' | translate
|
||||||
|
}}</mat-hint>
|
||||||
|
</mat-form-field>
|
||||||
|
<mat-form-field appearance="fill">
|
||||||
|
<mat-label>{{ 'labels.inputs.Reason' | translate }}</mat-label>
|
||||||
|
<input matInput formControlName="reason" required />
|
||||||
|
</mat-form-field>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
<mat-dialog-actions align="end">
|
||||||
|
<button mat-raised-button mat-dialog-close>{{ 'labels.buttons.Cancel' | translate }}</button>
|
||||||
|
<button mat-raised-button color="warn" [disabled]="reportLostForm.invalid || busy" (click)="submit()">
|
||||||
|
{{ 'labels.buttons.Report Lost' | translate }}
|
||||||
|
</button>
|
||||||
|
</mat-dialog-actions>
|
||||||
+34
@@ -0,0 +1,34 @@
|
|||||||
|
/**
|
||||||
|
* Copyright since 2025 Mifos Initiative
|
||||||
|
*
|
||||||
|
* This Source Code Form is subject to the terms of the Mozilla Public
|
||||||
|
* License, v. 2.0. If a copy of the MPL was not distributed with this
|
||||||
|
* file, You can obtain one at http://mozilla.org/MPL/2.0/.
|
||||||
|
*/
|
||||||
|
|
||||||
|
.rll-context {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(auto-fill, minmax(150px, 1fr));
|
||||||
|
gap: 10px 20px;
|
||||||
|
min-width: 420px;
|
||||||
|
margin-bottom: 18px;
|
||||||
|
padding-bottom: 14px;
|
||||||
|
border-bottom: 1px solid rgb(0 0 0 / 8%);
|
||||||
|
|
||||||
|
.rll-field {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
}
|
||||||
|
|
||||||
|
.k {
|
||||||
|
font-size: 0.7rem;
|
||||||
|
text-transform: uppercase;
|
||||||
|
color: rgb(0 0 0 / 50%);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.form-grid {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(auto-fill, minmax(200px, 1fr));
|
||||||
|
gap: 12px 20px;
|
||||||
|
}
|
||||||
+90
@@ -0,0 +1,90 @@
|
|||||||
|
/**
|
||||||
|
* Copyright since 2025 Mifos Initiative
|
||||||
|
*
|
||||||
|
* This Source Code Form is subject to the terms of the Mozilla Public
|
||||||
|
* License, v. 2.0. If a copy of the MPL was not distributed with this
|
||||||
|
* file, You can obtain one at http://mozilla.org/MPL/2.0/.
|
||||||
|
*/
|
||||||
|
|
||||||
|
/** Angular Imports */
|
||||||
|
import { Component, inject } from '@angular/core';
|
||||||
|
import { UntypedFormBuilder, UntypedFormGroup, Validators } from '@angular/forms';
|
||||||
|
import {
|
||||||
|
MatDialogRef,
|
||||||
|
MAT_DIALOG_DATA,
|
||||||
|
MatDialogTitle,
|
||||||
|
MatDialogContent,
|
||||||
|
MatDialogActions,
|
||||||
|
MatDialogClose
|
||||||
|
} from '@angular/material/dialog';
|
||||||
|
import { CdkScrollable } from '@angular/cdk/scrolling';
|
||||||
|
import { MatSnackBar } from '@angular/material/snack-bar';
|
||||||
|
import { STANDALONE_SHARED_IMPORTS } from 'app/standalone-shared.module';
|
||||||
|
|
||||||
|
/** Custom Services */
|
||||||
|
import { ChequeBookService } from '../../chequebook.service';
|
||||||
|
|
||||||
|
/** Popup shown when "Report Lost" is clicked on a stock lot row — reports the undrawn suffix as lost/stolen. */
|
||||||
|
@Component({
|
||||||
|
selector: 'mifosx-report-lot-lost-dialog',
|
||||||
|
templateUrl: './report-lot-lost-dialog.component.html',
|
||||||
|
styleUrls: ['./report-lot-lost-dialog.component.scss'],
|
||||||
|
imports: [
|
||||||
|
...STANDALONE_SHARED_IMPORTS,
|
||||||
|
MatDialogTitle,
|
||||||
|
CdkScrollable,
|
||||||
|
MatDialogContent,
|
||||||
|
MatDialogActions,
|
||||||
|
MatDialogClose
|
||||||
|
]
|
||||||
|
})
|
||||||
|
export class ReportLotLostDialogComponent {
|
||||||
|
dialogRef = inject<MatDialogRef<ReportLotLostDialogComponent>>(MatDialogRef);
|
||||||
|
data = inject(MAT_DIALOG_DATA);
|
||||||
|
private chequeBookService = inject(ChequeBookService);
|
||||||
|
private snackBar = inject(MatSnackBar);
|
||||||
|
private formBuilder = inject(UntypedFormBuilder);
|
||||||
|
|
||||||
|
private notify(message: string, isError = false): void {
|
||||||
|
this.snackBar.open(message, 'Dismiss', { duration: isError ? 6000 : 3000 });
|
||||||
|
}
|
||||||
|
|
||||||
|
busy = false;
|
||||||
|
reportLostForm: UntypedFormGroup;
|
||||||
|
|
||||||
|
constructor() {
|
||||||
|
this.reportLostForm = this.formBuilder.group({
|
||||||
|
fromSerialNo: [
|
||||||
|
this.data.nextSerialNo,
|
||||||
|
Validators.required
|
||||||
|
],
|
||||||
|
reason: [
|
||||||
|
'',
|
||||||
|
Validators.required
|
||||||
|
]
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
submit(): void {
|
||||||
|
if (this.reportLostForm.invalid || this.busy) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
this.busy = true;
|
||||||
|
const raw = this.reportLostForm.value;
|
||||||
|
const payload = { fromSerialNo: raw.fromSerialNo, reason: raw.reason, locale: 'en' };
|
||||||
|
this.chequeBookService.reportStockLotLost(this.data.id, payload).subscribe({
|
||||||
|
next: () => {
|
||||||
|
this.busy = false;
|
||||||
|
this.notify('Remaining stock reported lost/stolen');
|
||||||
|
this.dialogRef.close({ reported: true });
|
||||||
|
},
|
||||||
|
error: () => {
|
||||||
|
this.busy = false;
|
||||||
|
this.notify(
|
||||||
|
'Failed to report stock lost — check the serial number is within the remaining undrawn range',
|
||||||
|
true
|
||||||
|
);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,339 @@
|
|||||||
|
<!--
|
||||||
|
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="container">
|
||||||
|
<div class="page-header">
|
||||||
|
<h2>{{ 'labels.inputs.Cheque Stock' | translate }}</h2>
|
||||||
|
<button mat-raised-button [routerLink]="['/chequebook']">{{ 'labels.buttons.Back' | translate }}</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<mat-tab-group>
|
||||||
|
<!-- ============ Stock Lots ============ -->
|
||||||
|
<mat-tab label="{{ 'labels.inputs.Stock Lots' | translate }}">
|
||||||
|
<div class="tab-body">
|
||||||
|
<!-- Denomination-wise availability roll-up: how many 25-leaf / 50-leaf books are on hand -->
|
||||||
|
@if (stockSummary.length > 0) {
|
||||||
|
<div class="summary-strip">
|
||||||
|
<div class="summary-title">{{ 'labels.inputs.Available Stock by Book Size' | translate }}</div>
|
||||||
|
<div class="summary-cards">
|
||||||
|
@for (row of stockSummary; track row.seriesCode + row.leavesPerBook) {
|
||||||
|
<div class="summary-card">
|
||||||
|
<div class="summary-books">{{ row.books }}</div>
|
||||||
|
<div class="summary-label">
|
||||||
|
{{ 'labels.inputs.books' | translate }} × {{ row.leavesPerBook }}-{{
|
||||||
|
'labels.inputs.leaf' | translate
|
||||||
|
}}
|
||||||
|
</div>
|
||||||
|
<div class="summary-series">{{ row.seriesCode }}</div>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
|
||||||
|
<div class="tab-toolbar">
|
||||||
|
<button mat-raised-button color="primary" (click)="showReceiveForm = !showReceiveForm">
|
||||||
|
<fa-icon icon="plus"></fa-icon> {{ 'labels.buttons.Receive Stock' | translate }}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
@if (showReceiveForm) {
|
||||||
|
<mat-card class="add-form">
|
||||||
|
<mat-card-content>
|
||||||
|
<div class="global-allocation-note">
|
||||||
|
{{ 'labels.text.Highest leaf serial allocated so far, across every series' | translate }}:
|
||||||
|
<strong>{{ globalHighestAllocated }}</strong>
|
||||||
|
— {{ 'labels.text.next available start' | translate }}:
|
||||||
|
<strong>{{ globalHighestAllocated + 1 }}</strong>
|
||||||
|
</div>
|
||||||
|
<form [formGroup]="receiveForm" (ngSubmit)="submitReceive()">
|
||||||
|
<div class="form-grid">
|
||||||
|
<mat-form-field appearance="fill">
|
||||||
|
<mat-label>{{ 'labels.inputs.Series' | translate }}</mat-label>
|
||||||
|
<mat-select formControlName="seriesId" required (selectionChange)="onReceiveSeriesChange()">
|
||||||
|
@for (s of seriesList; track s.id) {
|
||||||
|
<mat-option [value]="s.id"
|
||||||
|
>{{ s.seriesCode }} — {{ s.name }} ({{ s.leavesPerBook }}-{{
|
||||||
|
'labels.inputs.leaf' | translate
|
||||||
|
}})</mat-option
|
||||||
|
>
|
||||||
|
}
|
||||||
|
</mat-select>
|
||||||
|
</mat-form-field>
|
||||||
|
<mat-form-field appearance="fill">
|
||||||
|
<mat-label>{{ 'labels.inputs.Office' | translate }}</mat-label>
|
||||||
|
<mat-select formControlName="officeId" required>
|
||||||
|
@for (office of offices; track office.id) {
|
||||||
|
<mat-option [value]="office.id">{{ office.name }}</mat-option>
|
||||||
|
}
|
||||||
|
</mat-select>
|
||||||
|
</mat-form-field>
|
||||||
|
<mat-form-field appearance="fill">
|
||||||
|
<mat-label>{{ 'labels.inputs.Number of Books' | translate }}</mat-label>
|
||||||
|
<input matInput type="number" formControlName="noOfBooks" required />
|
||||||
|
@if (receiveLeavesPerBook) {
|
||||||
|
<mat-hint
|
||||||
|
>{{ receiveTotalLeaves }} {{ 'labels.inputs.leaves' | translate }} ({{
|
||||||
|
'labels.inputs.books' | translate
|
||||||
|
}}
|
||||||
|
× {{ receiveLeavesPerBook }})</mat-hint
|
||||||
|
>
|
||||||
|
}
|
||||||
|
</mat-form-field>
|
||||||
|
<mat-form-field appearance="fill">
|
||||||
|
<mat-label>{{ 'labels.inputs.Range Start' | translate }}</mat-label>
|
||||||
|
<input matInput type="number" formControlName="rangeStart" required />
|
||||||
|
<mat-hint
|
||||||
|
>{{ 'labels.text.Auto-suggested to avoid overlap across every series; editable' | translate }} ({{
|
||||||
|
globalHighestAllocated
|
||||||
|
}}
|
||||||
|
+ 1)</mat-hint
|
||||||
|
>
|
||||||
|
</mat-form-field>
|
||||||
|
<mat-form-field appearance="fill">
|
||||||
|
<mat-label>{{ 'labels.inputs.Range End' | translate }}</mat-label>
|
||||||
|
<input matInput type="number" [value]="receiveRangeEnd ?? ''" readonly />
|
||||||
|
<mat-hint>{{ 'labels.text.Calculated from books x book size' | translate }}</mat-hint>
|
||||||
|
</mat-form-field>
|
||||||
|
<mat-form-field appearance="fill">
|
||||||
|
<mat-label>{{ 'labels.inputs.Batch / PO Reference' | translate }}</mat-label>
|
||||||
|
<input matInput formControlName="batchRef" />
|
||||||
|
</mat-form-field>
|
||||||
|
</div>
|
||||||
|
<div class="form-actions">
|
||||||
|
<button mat-raised-button type="button" (click)="showReceiveForm = false">
|
||||||
|
{{ 'labels.buttons.Cancel' | translate }}
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
mat-raised-button
|
||||||
|
color="primary"
|
||||||
|
type="submit"
|
||||||
|
[disabled]="receiveForm.invalid || !receiveRangeEnd || busy"
|
||||||
|
>
|
||||||
|
{{ 'labels.buttons.Submit' | translate }}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</mat-card-content>
|
||||||
|
</mat-card>
|
||||||
|
}
|
||||||
|
|
||||||
|
<div class="filter-row">
|
||||||
|
@for (s of lotStatusOptions; track s) {
|
||||||
|
<button mat-stroked-button [class.active]="lotStatusFilter === s" (click)="filterLotsByStatus(s)">
|
||||||
|
{{ s }}
|
||||||
|
</button>
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="table-wrapper mat-elevation-z1">
|
||||||
|
<table mat-table [dataSource]="displayedLots">
|
||||||
|
<ng-container matColumnDef="seriesCode">
|
||||||
|
<th mat-header-cell *matHeaderCellDef>{{ 'labels.inputs.Series' | translate }}</th>
|
||||||
|
<td mat-cell *matCellDef="let l">{{ l.seriesCode }}</td>
|
||||||
|
</ng-container>
|
||||||
|
<ng-container matColumnDef="officeName">
|
||||||
|
<th mat-header-cell *matHeaderCellDef>{{ 'labels.inputs.Office' | translate }}</th>
|
||||||
|
<td mat-cell *matCellDef="let l">{{ l.officeName }}</td>
|
||||||
|
</ng-container>
|
||||||
|
<ng-container matColumnDef="range">
|
||||||
|
<th mat-header-cell *matHeaderCellDef class="num">{{ 'labels.inputs.Leaf Range' | translate }}</th>
|
||||||
|
<td mat-cell *matCellDef="let l" class="num">{{ l.rangeStart }} – {{ l.rangeEnd }}</td>
|
||||||
|
</ng-container>
|
||||||
|
<ng-container matColumnDef="remainingLeaves">
|
||||||
|
<th mat-header-cell *matHeaderCellDef class="num">{{ 'labels.inputs.Remaining' | translate }}</th>
|
||||||
|
<td mat-cell *matCellDef="let l" class="num">
|
||||||
|
<strong>{{ l.remainingBooks ?? 0 }}</strong> {{ 'labels.inputs.books' | translate }}
|
||||||
|
<span class="muted">× {{ l.leavesPerBook }}-{{ 'labels.inputs.leaf' | translate }}</span>
|
||||||
|
<span class="muted">({{ l.remainingLeaves }} {{ 'labels.inputs.leaves' | translate }})</span>
|
||||||
|
@if (l.lowStock) {
|
||||||
|
<span class="pill warn">{{ 'labels.text.Low Stock' | translate }}</span>
|
||||||
|
}
|
||||||
|
</td>
|
||||||
|
</ng-container>
|
||||||
|
<ng-container matColumnDef="status">
|
||||||
|
<th mat-header-cell *matHeaderCellDef>{{ 'labels.inputs.Status' | translate }}</th>
|
||||||
|
<td mat-cell *matCellDef="let l">
|
||||||
|
<span
|
||||||
|
class="pill"
|
||||||
|
[ngClass]="l.status === 'AVAILABLE' ? 'ok' : l.status === 'CANCELLED' ? 'bad' : 'neutral'"
|
||||||
|
>{{ l.status }}</span
|
||||||
|
>
|
||||||
|
</td>
|
||||||
|
</ng-container>
|
||||||
|
<ng-container matColumnDef="actions">
|
||||||
|
<th mat-header-cell *matHeaderCellDef></th>
|
||||||
|
<td mat-cell *matCellDef="let l">
|
||||||
|
@if (l.status === 'AVAILABLE') {
|
||||||
|
<button mat-button color="warn" (click)="openReportLost(l)">
|
||||||
|
{{ 'labels.buttons.Report Lost' | translate }}
|
||||||
|
</button>
|
||||||
|
}
|
||||||
|
</td>
|
||||||
|
</ng-container>
|
||||||
|
<tr mat-header-row *matHeaderRowDef="lotColumns"></tr>
|
||||||
|
<tr mat-row *matRowDef="let row; columns: lotColumns"></tr>
|
||||||
|
</table>
|
||||||
|
@if (displayedLots.length === 0) {
|
||||||
|
<div class="empty-state">{{ 'labels.text.No records to display' | translate }}</div>
|
||||||
|
}
|
||||||
|
@if (lotTotalRecords > 0) {
|
||||||
|
<mat-paginator
|
||||||
|
[length]="lotTotalRecords"
|
||||||
|
[pageSize]="lotPageSize"
|
||||||
|
[pageSizeOptions]="pageSizeOptions"
|
||||||
|
[pageIndex]="lotPageIndex"
|
||||||
|
showFirstLastButtons
|
||||||
|
(page)="onLotPageChange($event)"
|
||||||
|
></mat-paginator>
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</mat-tab>
|
||||||
|
|
||||||
|
<!-- ============ Stock Transfers ============ -->
|
||||||
|
<mat-tab label="{{ 'labels.inputs.Stock Transfers' | translate }}">
|
||||||
|
<div class="tab-body">
|
||||||
|
<div class="tab-toolbar">
|
||||||
|
<button mat-raised-button color="primary" (click)="showDispatchForm = !showDispatchForm">
|
||||||
|
<fa-icon icon="plus"></fa-icon> {{ 'labels.buttons.Dispatch Transfer' | translate }}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
@if (showDispatchForm) {
|
||||||
|
<mat-card class="add-form">
|
||||||
|
<mat-card-content>
|
||||||
|
<form [formGroup]="dispatchForm" (ngSubmit)="submitDispatch()">
|
||||||
|
<div class="form-grid">
|
||||||
|
<mat-form-field appearance="fill">
|
||||||
|
<mat-label>{{ 'labels.inputs.Series' | translate }}</mat-label>
|
||||||
|
<mat-select formControlName="seriesId" required>
|
||||||
|
@for (s of seriesList; track s.id) {
|
||||||
|
<mat-option [value]="s.id">{{ s.seriesCode }} — {{ s.name }}</mat-option>
|
||||||
|
}
|
||||||
|
</mat-select>
|
||||||
|
</mat-form-field>
|
||||||
|
<mat-form-field appearance="fill">
|
||||||
|
<mat-label>{{ 'labels.inputs.From Office' | translate }}</mat-label>
|
||||||
|
<mat-select formControlName="fromOfficeId" required>
|
||||||
|
@for (office of offices; track office.id) {
|
||||||
|
<mat-option [value]="office.id">{{ office.name }}</mat-option>
|
||||||
|
}
|
||||||
|
</mat-select>
|
||||||
|
</mat-form-field>
|
||||||
|
<mat-form-field appearance="fill">
|
||||||
|
<mat-label>{{ 'labels.inputs.To Office' | translate }}</mat-label>
|
||||||
|
<mat-select formControlName="toOfficeId" required>
|
||||||
|
@for (office of offices; track office.id) {
|
||||||
|
<mat-option [value]="office.id">{{ office.name }}</mat-option>
|
||||||
|
}
|
||||||
|
</mat-select>
|
||||||
|
</mat-form-field>
|
||||||
|
<mat-form-field appearance="fill">
|
||||||
|
<mat-label>{{ 'labels.inputs.From Lot Id' | translate }}</mat-label>
|
||||||
|
<input matInput type="number" formControlName="fromLotId" required />
|
||||||
|
<mat-hint>{{
|
||||||
|
'labels.text.The stock-lot id to draw the transfer from (see Stock Lots tab)' | translate
|
||||||
|
}}</mat-hint>
|
||||||
|
</mat-form-field>
|
||||||
|
<mat-form-field appearance="fill">
|
||||||
|
<mat-label>{{ 'labels.inputs.No of Leaves' | translate }}</mat-label>
|
||||||
|
<input matInput type="number" formControlName="noOfLeaves" required />
|
||||||
|
</mat-form-field>
|
||||||
|
</div>
|
||||||
|
<div class="form-actions">
|
||||||
|
<button mat-raised-button type="button" (click)="showDispatchForm = false">
|
||||||
|
{{ 'labels.buttons.Cancel' | translate }}
|
||||||
|
</button>
|
||||||
|
<button mat-raised-button color="primary" type="submit" [disabled]="dispatchForm.invalid || busy">
|
||||||
|
{{ 'labels.buttons.Submit' | translate }}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</mat-card-content>
|
||||||
|
</mat-card>
|
||||||
|
}
|
||||||
|
|
||||||
|
<div class="filter-row">
|
||||||
|
@for (s of transferStatusOptions; track s) {
|
||||||
|
<button mat-stroked-button [class.active]="transferStatusFilter === s" (click)="filterTransfersByStatus(s)">
|
||||||
|
{{ s }}
|
||||||
|
</button>
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="table-wrapper mat-elevation-z1">
|
||||||
|
<table mat-table [dataSource]="stockTransfers">
|
||||||
|
<ng-container matColumnDef="seriesCode">
|
||||||
|
<th mat-header-cell *matHeaderCellDef>{{ 'labels.inputs.Series' | translate }}</th>
|
||||||
|
<td mat-cell *matCellDef="let t">{{ t.seriesCode }}</td>
|
||||||
|
</ng-container>
|
||||||
|
<ng-container matColumnDef="fromOfficeName">
|
||||||
|
<th mat-header-cell *matHeaderCellDef>{{ 'labels.inputs.From Office' | translate }}</th>
|
||||||
|
<td mat-cell *matCellDef="let t">{{ t.fromOfficeName }}</td>
|
||||||
|
</ng-container>
|
||||||
|
<ng-container matColumnDef="toOfficeName">
|
||||||
|
<th mat-header-cell *matHeaderCellDef>{{ 'labels.inputs.To Office' | translate }}</th>
|
||||||
|
<td mat-cell *matCellDef="let t">{{ t.toOfficeName }}</td>
|
||||||
|
</ng-container>
|
||||||
|
<ng-container matColumnDef="range">
|
||||||
|
<th mat-header-cell *matHeaderCellDef class="num">{{ 'labels.inputs.Leaf Range' | translate }}</th>
|
||||||
|
<td mat-cell *matCellDef="let t" class="num">{{ t.rangeStart }} – {{ t.rangeEnd }}</td>
|
||||||
|
</ng-container>
|
||||||
|
<ng-container matColumnDef="totalLeaves">
|
||||||
|
<th mat-header-cell *matHeaderCellDef class="num">{{ 'labels.inputs.Leaves' | translate }}</th>
|
||||||
|
<td mat-cell *matCellDef="let t" class="num">{{ t.totalLeaves }}</td>
|
||||||
|
</ng-container>
|
||||||
|
<ng-container matColumnDef="status">
|
||||||
|
<th mat-header-cell *matHeaderCellDef>{{ 'labels.inputs.Status' | translate }}</th>
|
||||||
|
<td mat-cell *matCellDef="let t">
|
||||||
|
<span
|
||||||
|
class="pill"
|
||||||
|
[ngClass]="t.status === 'RECEIVED' ? 'ok' : t.status === 'REJECTED' ? 'bad' : 'neutral'"
|
||||||
|
>{{ t.status }}</span
|
||||||
|
>
|
||||||
|
</td>
|
||||||
|
</ng-container>
|
||||||
|
<ng-container matColumnDef="dispatchDate">
|
||||||
|
<th mat-header-cell *matHeaderCellDef>{{ 'labels.inputs.Dispatch Date' | translate }}</th>
|
||||||
|
<td mat-cell *matCellDef="let t">{{ t.dispatchDate | dateFormat }}</td>
|
||||||
|
</ng-container>
|
||||||
|
<ng-container matColumnDef="actions">
|
||||||
|
<th mat-header-cell *matHeaderCellDef></th>
|
||||||
|
<td mat-cell *matCellDef="let t">
|
||||||
|
@if (t.status === 'DISPATCHED') {
|
||||||
|
<button mat-button color="primary" (click)="receiveTransfer(t)">
|
||||||
|
{{ 'labels.buttons.Receive' | translate }}
|
||||||
|
</button>
|
||||||
|
<button mat-button color="warn" (click)="rejectTransfer(t)">
|
||||||
|
{{ 'labels.buttons.Reject' | translate }}
|
||||||
|
</button>
|
||||||
|
}
|
||||||
|
</td>
|
||||||
|
</ng-container>
|
||||||
|
<tr mat-header-row *matHeaderRowDef="transferColumns"></tr>
|
||||||
|
<tr mat-row *matRowDef="let row; columns: transferColumns"></tr>
|
||||||
|
</table>
|
||||||
|
@if (stockTransfers.length === 0) {
|
||||||
|
<div class="empty-state">{{ 'labels.text.No records to display' | translate }}</div>
|
||||||
|
}
|
||||||
|
@if (transferTotalRecords > 0) {
|
||||||
|
<mat-paginator
|
||||||
|
[length]="transferTotalRecords"
|
||||||
|
[pageSize]="transferPageSize"
|
||||||
|
[pageSizeOptions]="pageSizeOptions"
|
||||||
|
[pageIndex]="transferPageIndex"
|
||||||
|
showFirstLastButtons
|
||||||
|
(page)="onTransferPageChange($event)"
|
||||||
|
></mat-paginator>
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</mat-tab>
|
||||||
|
</mat-tab-group>
|
||||||
|
</div>
|
||||||
@@ -0,0 +1,172 @@
|
|||||||
|
/**
|
||||||
|
* 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/.
|
||||||
|
*/
|
||||||
|
|
||||||
|
.container {
|
||||||
|
padding: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.page-header {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: center;
|
||||||
|
margin-bottom: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tab-body {
|
||||||
|
padding: 16px 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tab-toolbar {
|
||||||
|
margin-bottom: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.filter-row {
|
||||||
|
display: flex;
|
||||||
|
gap: 8px;
|
||||||
|
margin-bottom: 16px;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
|
||||||
|
button.active {
|
||||||
|
background: rgb(25 118 210 / 12%);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.summary-strip {
|
||||||
|
margin-bottom: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.summary-title {
|
||||||
|
font-size: 0.78rem;
|
||||||
|
font-weight: 600;
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 0.4px;
|
||||||
|
color: rgb(0 0 0 / 55%);
|
||||||
|
margin-bottom: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.summary-cards {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.summary-card {
|
||||||
|
min-width: 120px;
|
||||||
|
padding: 12px 16px;
|
||||||
|
border: 1px solid rgb(0 0 0 / 10%);
|
||||||
|
border-radius: 10px;
|
||||||
|
background: rgb(25 118 210 / 6%);
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.summary-books {
|
||||||
|
font-size: 1.6rem;
|
||||||
|
font-weight: 700;
|
||||||
|
color: #1976d2;
|
||||||
|
font-variant-numeric: tabular-nums;
|
||||||
|
}
|
||||||
|
|
||||||
|
.summary-label {
|
||||||
|
font-size: 0.8rem;
|
||||||
|
color: rgb(0 0 0 / 70%);
|
||||||
|
}
|
||||||
|
|
||||||
|
.summary-series {
|
||||||
|
font-size: 0.72rem;
|
||||||
|
color: rgb(0 0 0 / 50%);
|
||||||
|
margin-top: 2px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.muted {
|
||||||
|
color: rgb(0 0 0 / 50%);
|
||||||
|
font-size: 0.8rem;
|
||||||
|
margin-left: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.add-form {
|
||||||
|
margin-bottom: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.global-allocation-note {
|
||||||
|
margin-bottom: 14px;
|
||||||
|
padding: 8px 12px;
|
||||||
|
border-radius: 4px;
|
||||||
|
background: rgb(25 118 210 / 8%);
|
||||||
|
color: rgb(0 0 0 / 70%);
|
||||||
|
font-size: 0.85rem;
|
||||||
|
|
||||||
|
strong {
|
||||||
|
font-variant-numeric: tabular-nums;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.form-grid {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(auto-fill, minmax(220px, 1fr));
|
||||||
|
gap: 12px 20px;
|
||||||
|
}
|
||||||
|
|
||||||
|
mat-form-field {
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.form-actions {
|
||||||
|
display: flex;
|
||||||
|
justify-content: flex-end;
|
||||||
|
gap: 12px;
|
||||||
|
margin-top: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.table-wrapper {
|
||||||
|
overflow-x: auto;
|
||||||
|
background: #fff;
|
||||||
|
}
|
||||||
|
|
||||||
|
table {
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
td.num,
|
||||||
|
th.num {
|
||||||
|
text-align: right;
|
||||||
|
font-variant-numeric: tabular-nums;
|
||||||
|
}
|
||||||
|
|
||||||
|
.pill {
|
||||||
|
font-size: 11px;
|
||||||
|
padding: 2px 8px;
|
||||||
|
border-radius: 6px;
|
||||||
|
font-weight: 600;
|
||||||
|
margin-left: 6px;
|
||||||
|
|
||||||
|
&.ok {
|
||||||
|
background: #dff5e8;
|
||||||
|
color: #28c07a;
|
||||||
|
}
|
||||||
|
|
||||||
|
&.bad {
|
||||||
|
background: #fde3e3;
|
||||||
|
color: #d32f2f;
|
||||||
|
}
|
||||||
|
|
||||||
|
&.warn {
|
||||||
|
background: #fff3cd;
|
||||||
|
color: #a16207;
|
||||||
|
}
|
||||||
|
|
||||||
|
&.neutral {
|
||||||
|
background: #e0e0e0;
|
||||||
|
color: #757575;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.empty-state {
|
||||||
|
padding: 24px;
|
||||||
|
text-align: center;
|
||||||
|
color: rgb(0 0 0 / 50%);
|
||||||
|
}
|
||||||
@@ -0,0 +1,403 @@
|
|||||||
|
/**
|
||||||
|
* Copyright since 2025 Mifos Initiative
|
||||||
|
*
|
||||||
|
* This Source Code Form is subject to the terms of the Mozilla Public
|
||||||
|
* License, v. 2.0. If a copy of the MPL was not distributed with this
|
||||||
|
* file, You can obtain one at http://mozilla.org/MPL/2.0/.
|
||||||
|
*/
|
||||||
|
|
||||||
|
/** Angular Imports */
|
||||||
|
import { Component, OnInit, inject } from '@angular/core';
|
||||||
|
import { UntypedFormBuilder, UntypedFormGroup, Validators } from '@angular/forms';
|
||||||
|
import { RouterLink } from '@angular/router';
|
||||||
|
import { MatIconModule } from '@angular/material/icon';
|
||||||
|
import { MatButtonModule } from '@angular/material/button';
|
||||||
|
import { MatTableModule } from '@angular/material/table';
|
||||||
|
import { MatTabsModule } from '@angular/material/tabs';
|
||||||
|
import { MatPaginatorModule, PageEvent } from '@angular/material/paginator';
|
||||||
|
import { MatSnackBar, MatSnackBarModule } from '@angular/material/snack-bar';
|
||||||
|
import { MatDialog } from '@angular/material/dialog';
|
||||||
|
import { FaIconComponent } from '@fortawesome/angular-fontawesome';
|
||||||
|
import { STANDALONE_SHARED_IMPORTS } from 'app/standalone-shared.module';
|
||||||
|
|
||||||
|
/** Custom Services */
|
||||||
|
import { ChequeBookService } from '../chequebook.service';
|
||||||
|
|
||||||
|
/** Dialog Components */
|
||||||
|
import { ReportLotLostDialogComponent } from './report-lot-lost-dialog/report-lot-lost-dialog.component';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Cheque stock: the unified stock-lot ledger (HO receipt / branch transfer-in share one drawdown-cursor
|
||||||
|
* concept) and the office-to-office transfer workflow (dispatch → receive-ack / reject) that draws from it.
|
||||||
|
*/
|
||||||
|
@Component({
|
||||||
|
selector: 'mifosx-stock-management',
|
||||||
|
templateUrl: './stock-management.component.html',
|
||||||
|
styleUrls: ['./stock-management.component.scss'],
|
||||||
|
imports: [
|
||||||
|
...STANDALONE_SHARED_IMPORTS,
|
||||||
|
RouterLink,
|
||||||
|
MatIconModule,
|
||||||
|
MatButtonModule,
|
||||||
|
MatTableModule,
|
||||||
|
MatTabsModule,
|
||||||
|
MatPaginatorModule,
|
||||||
|
MatSnackBarModule,
|
||||||
|
FaIconComponent
|
||||||
|
]
|
||||||
|
})
|
||||||
|
export class StockManagementComponent implements OnInit {
|
||||||
|
private formBuilder = inject(UntypedFormBuilder);
|
||||||
|
private chequeBookService = inject(ChequeBookService);
|
||||||
|
private snackBar = inject(MatSnackBar);
|
||||||
|
private dialog = inject(MatDialog);
|
||||||
|
|
||||||
|
private notify(message: string, isError = false): void {
|
||||||
|
this.snackBar.open(message, 'Dismiss', { duration: isError ? 6000 : 3000 });
|
||||||
|
}
|
||||||
|
|
||||||
|
seriesList: any[] = [];
|
||||||
|
offices: any[] = [];
|
||||||
|
busy = false;
|
||||||
|
|
||||||
|
showReceiveForm = false;
|
||||||
|
showDispatchForm = false;
|
||||||
|
|
||||||
|
receiveForm: UntypedFormGroup;
|
||||||
|
dispatchForm: UntypedFormGroup;
|
||||||
|
|
||||||
|
lotColumns = [
|
||||||
|
'seriesCode',
|
||||||
|
'officeName',
|
||||||
|
'range',
|
||||||
|
'remainingLeaves',
|
||||||
|
'status',
|
||||||
|
'actions'
|
||||||
|
];
|
||||||
|
transferColumns = [
|
||||||
|
'seriesCode',
|
||||||
|
'fromOfficeName',
|
||||||
|
'toOfficeName',
|
||||||
|
'range',
|
||||||
|
'totalLeaves',
|
||||||
|
'status',
|
||||||
|
'dispatchDate',
|
||||||
|
'actions'
|
||||||
|
];
|
||||||
|
|
||||||
|
readonly lotStatusOptions = [
|
||||||
|
'AVAILABLE',
|
||||||
|
'DISPATCHED',
|
||||||
|
'EXHAUSTED',
|
||||||
|
'PARTIALLY_COMPROMISED',
|
||||||
|
'CANCELLED'
|
||||||
|
];
|
||||||
|
readonly transferStatusOptions = [
|
||||||
|
'DISPATCHED',
|
||||||
|
'RECEIVED',
|
||||||
|
'REJECTED'
|
||||||
|
];
|
||||||
|
|
||||||
|
/** Every stock lot, unfiltered — drives the denomination roll-up and the "next free serial" calculation,
|
||||||
|
* so it must never be limited to whatever page/status the display table happens to be showing. */
|
||||||
|
private allStockLots: any[] = [];
|
||||||
|
|
||||||
|
/** The lot table shown on screen — server-side paginated and status-filtered independently of allStockLots. */
|
||||||
|
displayedLots: any[] = [];
|
||||||
|
lotStatusFilter = '';
|
||||||
|
lotPageSize = 10;
|
||||||
|
lotPageIndex = 0;
|
||||||
|
lotTotalRecords = 0;
|
||||||
|
|
||||||
|
stockTransfers: any[] = [];
|
||||||
|
transferStatusFilter = '';
|
||||||
|
transferPageSize = 10;
|
||||||
|
transferPageIndex = 0;
|
||||||
|
transferTotalRecords = 0;
|
||||||
|
|
||||||
|
pageSizeOptions = [
|
||||||
|
10,
|
||||||
|
25,
|
||||||
|
50,
|
||||||
|
100
|
||||||
|
];
|
||||||
|
|
||||||
|
constructor() {
|
||||||
|
this.receiveForm = this.formBuilder.group({
|
||||||
|
seriesId: [
|
||||||
|
'',
|
||||||
|
Validators.required
|
||||||
|
],
|
||||||
|
officeId: [
|
||||||
|
'',
|
||||||
|
Validators.required
|
||||||
|
],
|
||||||
|
noOfBooks: [
|
||||||
|
1,
|
||||||
|
Validators.required
|
||||||
|
],
|
||||||
|
rangeStart: [
|
||||||
|
'',
|
||||||
|
Validators.required
|
||||||
|
],
|
||||||
|
batchRef: ['']
|
||||||
|
});
|
||||||
|
this.dispatchForm = this.formBuilder.group({
|
||||||
|
seriesId: [
|
||||||
|
'',
|
||||||
|
Validators.required
|
||||||
|
],
|
||||||
|
fromOfficeId: [
|
||||||
|
'',
|
||||||
|
Validators.required
|
||||||
|
],
|
||||||
|
toOfficeId: [
|
||||||
|
'',
|
||||||
|
Validators.required
|
||||||
|
],
|
||||||
|
fromLotId: [
|
||||||
|
'',
|
||||||
|
Validators.required
|
||||||
|
],
|
||||||
|
noOfLeaves: [
|
||||||
|
'',
|
||||||
|
Validators.required
|
||||||
|
]
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
ngOnInit(): void {
|
||||||
|
this.loadAllStockLotsForCalculations();
|
||||||
|
this.loadDisplayedLots();
|
||||||
|
this.loadStockTransfers();
|
||||||
|
// populates dropdowns, so fetch every active series in one page rather than paging through it
|
||||||
|
this.chequeBookService
|
||||||
|
.getSeries('ACTIVE', 0, 500)
|
||||||
|
.subscribe((res: any) => (this.seriesList = (res?.pageItems ?? []).filter((s: any) => s.active)));
|
||||||
|
this.chequeBookService.getOffices().subscribe((data: any[]) => (this.offices = data || []));
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Denomination-wise roll-up of available books, e.g. "CTS-25 (25-leaf): 2000 books". */
|
||||||
|
stockSummary: { seriesCode: string; leavesPerBook: number; books: number; leaves: number }[] = [];
|
||||||
|
|
||||||
|
/** Unfiltered, every status, every page — feeds stockSummary and nextFreeSerial only, never the table. */
|
||||||
|
loadAllStockLotsForCalculations(): void {
|
||||||
|
this.chequeBookService.getStockLots(undefined, undefined, undefined, 0, 500).subscribe((res: any) => {
|
||||||
|
this.allStockLots = res?.pageItems ?? [];
|
||||||
|
this.buildSummary();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
loadDisplayedLots(): void {
|
||||||
|
this.chequeBookService
|
||||||
|
.getStockLots(
|
||||||
|
undefined,
|
||||||
|
undefined,
|
||||||
|
this.lotStatusFilter || undefined,
|
||||||
|
this.lotPageIndex * this.lotPageSize,
|
||||||
|
this.lotPageSize
|
||||||
|
)
|
||||||
|
.subscribe((res: any) => {
|
||||||
|
this.displayedLots = res?.pageItems ?? [];
|
||||||
|
this.lotTotalRecords = Number(res?.totalFilteredRecords ?? 0);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
filterLotsByStatus(status: string): void {
|
||||||
|
this.lotStatusFilter = this.lotStatusFilter === status ? '' : status;
|
||||||
|
this.lotPageIndex = 0;
|
||||||
|
this.loadDisplayedLots();
|
||||||
|
}
|
||||||
|
|
||||||
|
onLotPageChange(event: PageEvent): void {
|
||||||
|
this.lotPageSize = event.pageSize;
|
||||||
|
this.lotPageIndex = event.pageIndex;
|
||||||
|
this.loadDisplayedLots();
|
||||||
|
}
|
||||||
|
|
||||||
|
private buildSummary(): void {
|
||||||
|
const map = new Map<string, { seriesCode: string; leavesPerBook: number; books: number; leaves: number }>();
|
||||||
|
for (const lot of this.allStockLots) {
|
||||||
|
// only stock that can actually be issued/transferred counts toward availability
|
||||||
|
if (lot.status !== 'AVAILABLE') {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
const key = `${lot.seriesCode}|${lot.leavesPerBook}`;
|
||||||
|
const row = map.get(key) || { seriesCode: lot.seriesCode, leavesPerBook: lot.leavesPerBook, books: 0, leaves: 0 };
|
||||||
|
row.books += lot.remainingBooks || 0;
|
||||||
|
row.leaves += lot.remainingLeaves || 0;
|
||||||
|
map.set(key, row);
|
||||||
|
}
|
||||||
|
this.stockSummary = Array.from(map.values()).sort((a, b) => (a.leavesPerBook || 0) - (b.leavesPerBook || 0));
|
||||||
|
}
|
||||||
|
|
||||||
|
loadStockTransfers(): void {
|
||||||
|
this.chequeBookService
|
||||||
|
.getStockTransfers(
|
||||||
|
this.transferStatusFilter || undefined,
|
||||||
|
this.transferPageIndex * this.transferPageSize,
|
||||||
|
this.transferPageSize
|
||||||
|
)
|
||||||
|
.subscribe((res: any) => {
|
||||||
|
this.stockTransfers = res?.pageItems ?? [];
|
||||||
|
this.transferTotalRecords = Number(res?.totalFilteredRecords ?? 0);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
filterTransfersByStatus(status: string): void {
|
||||||
|
this.transferStatusFilter = this.transferStatusFilter === status ? '' : status;
|
||||||
|
this.transferPageIndex = 0;
|
||||||
|
this.loadStockTransfers();
|
||||||
|
}
|
||||||
|
|
||||||
|
onTransferPageChange(event: PageEvent): void {
|
||||||
|
this.transferPageSize = event.pageSize;
|
||||||
|
this.transferPageIndex = event.pageIndex;
|
||||||
|
this.loadStockTransfers();
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The series currently chosen in the receive form. */
|
||||||
|
get receiveSeries(): any {
|
||||||
|
return this.seriesList.find((s) => s.id === this.receiveForm.get('seriesId')?.value);
|
||||||
|
}
|
||||||
|
|
||||||
|
get receiveLeavesPerBook(): number {
|
||||||
|
return this.receiveSeries?.leavesPerBook || 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Leaves to receive = books x this series' book size. */
|
||||||
|
get receiveTotalLeaves(): number {
|
||||||
|
const books = Number(this.receiveForm.get('noOfBooks')?.value) || 0;
|
||||||
|
return books * this.receiveLeavesPerBook;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Derived — the last serial covered by this receipt, so the operator never types it. */
|
||||||
|
get receiveRangeEnd(): number | null {
|
||||||
|
const start = Number(this.receiveForm.get('rangeStart')?.value);
|
||||||
|
const leaves = this.receiveTotalLeaves;
|
||||||
|
if (!start || !leaves) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
return start + leaves - 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Highest leaf-serial allocated so far, across every series/office — the actual number the "Range Start"
|
||||||
|
* suggestion is based on. Shown directly in the form (not just silently applied to the field) so the operator
|
||||||
|
* can see and trust the reasoning instead of taking a pre-filled number on faith. */
|
||||||
|
get globalHighestAllocated(): number {
|
||||||
|
return this.allStockLots.reduce((m, l) => Math.max(m, l.rangeEnd || 0), 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Next unused serial across EVERY series, not just the one being received — a real bank allocates leaf-serial
|
||||||
|
* numbers from one bank-wide numbering authority, so two different cheque designs never legitimately reuse the
|
||||||
|
* same range. Suggesting a start that only avoids the same series' own lots is exactly what let two series end
|
||||||
|
* up overlapping (and, once both got issued to the same account, an unresolvable serial collision) before the
|
||||||
|
* backend's own overlap check was widened to match (ChequeStockLotRepository.countOverlappingAnySeries).
|
||||||
|
*/
|
||||||
|
private nextFreeSerial(): number {
|
||||||
|
return this.globalHighestAllocated + 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
onReceiveSeriesChange(): void {
|
||||||
|
// pre-fill a non-overlapping start; operator can still override
|
||||||
|
this.receiveForm.patchValue({ rangeStart: this.nextFreeSerial() });
|
||||||
|
}
|
||||||
|
|
||||||
|
private reloadLots(): void {
|
||||||
|
this.loadAllStockLotsForCalculations();
|
||||||
|
this.loadDisplayedLots();
|
||||||
|
}
|
||||||
|
|
||||||
|
submitReceive(): void {
|
||||||
|
if (this.receiveForm.invalid || this.busy || !this.receiveRangeEnd) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
this.busy = true;
|
||||||
|
const raw = this.receiveForm.value;
|
||||||
|
const payload: any = {
|
||||||
|
seriesId: raw.seriesId,
|
||||||
|
officeId: raw.officeId,
|
||||||
|
rangeStart: raw.rangeStart,
|
||||||
|
rangeEnd: this.receiveRangeEnd,
|
||||||
|
locale: 'en'
|
||||||
|
};
|
||||||
|
if (raw.batchRef) payload.batchRef = raw.batchRef;
|
||||||
|
|
||||||
|
this.chequeBookService.receiveStock(payload).subscribe({
|
||||||
|
next: () => {
|
||||||
|
this.busy = false;
|
||||||
|
this.showReceiveForm = false;
|
||||||
|
this.receiveForm.reset({ noOfBooks: 1 });
|
||||||
|
this.notify('Submitted for approval — see Approvals > Cheque Book Requests');
|
||||||
|
this.reloadLots();
|
||||||
|
},
|
||||||
|
error: () => {
|
||||||
|
this.busy = false;
|
||||||
|
this.notify('Failed to receive stock — check the leaf range does not overlap existing stock', true);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
openReportLost(lot: any): void {
|
||||||
|
this.dialog
|
||||||
|
.open(ReportLotLostDialogComponent, { data: lot, width: '520px' })
|
||||||
|
.afterClosed()
|
||||||
|
.subscribe((result: any) => {
|
||||||
|
if (result?.reported) {
|
||||||
|
this.reloadLots();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
submitDispatch(): void {
|
||||||
|
if (this.dispatchForm.invalid || this.busy) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
this.busy = true;
|
||||||
|
const raw = this.dispatchForm.value;
|
||||||
|
const payload = {
|
||||||
|
seriesId: raw.seriesId,
|
||||||
|
fromOfficeId: raw.fromOfficeId,
|
||||||
|
toOfficeId: raw.toOfficeId,
|
||||||
|
fromLotId: raw.fromLotId,
|
||||||
|
noOfLeaves: raw.noOfLeaves,
|
||||||
|
locale: 'en'
|
||||||
|
};
|
||||||
|
this.chequeBookService.dispatchStockTransfer(payload).subscribe({
|
||||||
|
next: () => {
|
||||||
|
this.busy = false;
|
||||||
|
this.showDispatchForm = false;
|
||||||
|
this.dispatchForm.reset();
|
||||||
|
this.notify('Stock transfer dispatched');
|
||||||
|
this.reloadLots();
|
||||||
|
this.loadStockTransfers();
|
||||||
|
},
|
||||||
|
error: () => {
|
||||||
|
this.busy = false;
|
||||||
|
this.notify('Failed to dispatch — check the lot has enough remaining leaves', true);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
receiveTransfer(transfer: any): void {
|
||||||
|
this.chequeBookService.receiveStockTransfer(transfer.id).subscribe({
|
||||||
|
next: () => {
|
||||||
|
this.notify('Transfer receipt acknowledged');
|
||||||
|
this.reloadLots();
|
||||||
|
this.loadStockTransfers();
|
||||||
|
},
|
||||||
|
error: () => this.notify('Failed to acknowledge receipt', true)
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
rejectTransfer(transfer: any): void {
|
||||||
|
this.chequeBookService.rejectStockTransfer(transfer.id).subscribe({
|
||||||
|
next: () => {
|
||||||
|
this.notify('Transfer rejected');
|
||||||
|
this.loadStockTransfers();
|
||||||
|
},
|
||||||
|
error: () => this.notify('Failed to reject transfer', true)
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -446,8 +446,12 @@ export class RecordMasterComponent implements OnInit {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
// 2. Signature — client document named 'clientSignature'
|
// 2. Signature — client document named 'clientSignature'. Multiple rows can exist for
|
||||||
const signatureDoc = (documents as any[])?.find((d: any) => d?.name === 'clientSignature');
|
// clients who re-cropped/replaced their signature before uploads became single-slot, so
|
||||||
|
// pick the highest id (latest) rather than the first unordered match.
|
||||||
|
const signatureDoc = (documents as any[])
|
||||||
|
?.filter((d: any) => d?.name === 'clientSignature')
|
||||||
|
?.sort((a: any, b: any) => b.id - a.id)?.[0];
|
||||||
if (signatureDoc) {
|
if (signatureDoc) {
|
||||||
this.viewDetails.docs.signature = {
|
this.viewDetails.docs.signature = {
|
||||||
present: true,
|
present: true,
|
||||||
|
|||||||
@@ -0,0 +1,54 @@
|
|||||||
|
/**
|
||||||
|
* 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/.
|
||||||
|
*/
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Maps a clearing status/decision string to one of the three shared .pill classes
|
||||||
|
* (ok / bad / neutral). Shared by every CTS, NACH and settlement/reconciliation screen so the
|
||||||
|
* colour language stays consistent across the whole clearing feature.
|
||||||
|
*/
|
||||||
|
export function pillClass(status: string | null | undefined): 'ok' | 'bad' | 'neutral' {
|
||||||
|
const s = (status || '').toUpperCase();
|
||||||
|
const ok = [
|
||||||
|
'PASSED',
|
||||||
|
'PASS',
|
||||||
|
'POSTED',
|
||||||
|
'MATCHED',
|
||||||
|
'SETTLED',
|
||||||
|
'SENT',
|
||||||
|
'SENT_TO_CLEARING_HOUSE',
|
||||||
|
'PROCESSED',
|
||||||
|
'VALIDATED',
|
||||||
|
'ACTIVE',
|
||||||
|
'CLEARED',
|
||||||
|
'HONOURED',
|
||||||
|
'COMPLETED'
|
||||||
|
];
|
||||||
|
const bad = [
|
||||||
|
'RETURNED',
|
||||||
|
'RETURN',
|
||||||
|
'REJECTED',
|
||||||
|
'MISMATCH',
|
||||||
|
'DISHONOURED',
|
||||||
|
'CANCELLED',
|
||||||
|
'FAILED',
|
||||||
|
'FAIL',
|
||||||
|
'UNROUTABLE'
|
||||||
|
];
|
||||||
|
if (ok.includes(s)) {
|
||||||
|
return 'ok';
|
||||||
|
}
|
||||||
|
if (bad.includes(s)) {
|
||||||
|
return 'bad';
|
||||||
|
}
|
||||||
|
return 'neutral';
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Extracts the most specific backend error message from a Fineract-style error response. */
|
||||||
|
export function clearingError(err: any, fallback = 'Request failed'): string {
|
||||||
|
return err?.error?.errors?.[0]?.defaultUserMessage ?? err?.error?.defaultUserMessage ?? fallback;
|
||||||
|
}
|
||||||
@@ -0,0 +1,77 @@
|
|||||||
|
/**
|
||||||
|
* Copyright since 2025 Mifos Initiative
|
||||||
|
*
|
||||||
|
* This Source Code Form is subject to the terms of the Mozilla Public
|
||||||
|
* License, v. 2.0. If a copy of the MPL was not distributed with this
|
||||||
|
* file, You can obtain one at http://mozilla.org/MPL/2.0/.
|
||||||
|
*/
|
||||||
|
|
||||||
|
/** Angular Imports */
|
||||||
|
import { Injectable, inject } from '@angular/core';
|
||||||
|
import { HttpClient, HttpParams } from '@angular/common/http';
|
||||||
|
import { Observable } from 'rxjs';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Shared clearing services used across CTS and NACH — return-reason-code master, settlement-batch posting,
|
||||||
|
* and reconciliation.
|
||||||
|
*/
|
||||||
|
@Injectable({
|
||||||
|
providedIn: 'root'
|
||||||
|
})
|
||||||
|
export class ClearingService {
|
||||||
|
private http = inject(HttpClient);
|
||||||
|
|
||||||
|
// ----- Return reason codes -----
|
||||||
|
|
||||||
|
/** appliesTo: 'CTS' | 'NACH'. Returns a raw array of reason codes. */
|
||||||
|
getReturnReasonCodes(appliesTo: string): Observable<any> {
|
||||||
|
return this.http.get('/clearing/return-reason-codes', {
|
||||||
|
params: new HttpParams().set('appliesTo', appliesTo)
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// ----- Settlement batches -----
|
||||||
|
|
||||||
|
/** Server-side paginated — returns { pageItems, totalFilteredRecords }. */
|
||||||
|
getSettlementBatches(clearingType?: string, status?: string, offset = 0, limit = 20): Observable<any> {
|
||||||
|
let params = new HttpParams().set('offset', offset).set('limit', limit);
|
||||||
|
if (clearingType) params = params.set('clearingType', clearingType);
|
||||||
|
if (status) params = params.set('status', status);
|
||||||
|
return this.http.get('/clearing/settlement-batches', { params });
|
||||||
|
}
|
||||||
|
|
||||||
|
getSettlementBatch(id: string | number): Observable<any> {
|
||||||
|
return this.http.get(`/clearing/settlement-batches/${id}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** payload: { clearingType, sourceBatchId }. */
|
||||||
|
prepareSettlement(payload: any): Observable<any> {
|
||||||
|
return this.http.post('/clearing/settlement-batches', payload);
|
||||||
|
}
|
||||||
|
|
||||||
|
postSettlement(id: string | number): Observable<any> {
|
||||||
|
return this.http.post(
|
||||||
|
`/clearing/settlement-batches/${id}`,
|
||||||
|
{},
|
||||||
|
{ params: new HttpParams().set('command', 'post') }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
rejectSettlement(id: string | number, payload: any): Observable<any> {
|
||||||
|
return this.http.post(`/clearing/settlement-batches/${id}`, payload, {
|
||||||
|
params: new HttpParams().set('command', 'reject')
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// ----- Reconciliations -----
|
||||||
|
|
||||||
|
getReconciliations(clearingType?: string, offset = 0, limit = 20): Observable<any> {
|
||||||
|
let params = new HttpParams().set('offset', offset).set('limit', limit);
|
||||||
|
if (clearingType) params = params.set('clearingType', clearingType);
|
||||||
|
return this.http.get('/clearing/reconciliations', { params });
|
||||||
|
}
|
||||||
|
|
||||||
|
runReconciliation(payload: any): Observable<any> {
|
||||||
|
return this.http.post('/clearing/reconciliations', payload);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,91 @@
|
|||||||
|
/**
|
||||||
|
* Copyright since 2025 Mifos Initiative
|
||||||
|
*
|
||||||
|
* This Source Code Form is subject to the terms of the Mozilla Public
|
||||||
|
* License, v. 2.0. If a copy of the MPL was not distributed with this
|
||||||
|
* file, You can obtain one at http://mozilla.org/MPL/2.0/.
|
||||||
|
*/
|
||||||
|
|
||||||
|
/** Angular Imports */
|
||||||
|
import { Injectable, inject } from '@angular/core';
|
||||||
|
import { HttpClient, HttpParams } from '@angular/common/http';
|
||||||
|
import { Observable, of } from 'rxjs';
|
||||||
|
import { catchError, map, switchMap } from 'rxjs/operators';
|
||||||
|
|
||||||
|
export interface JointHolder {
|
||||||
|
clientId: number;
|
||||||
|
cifNumber?: string;
|
||||||
|
displayName: string;
|
||||||
|
sharePercentage?: number;
|
||||||
|
signingAuthority?: string;
|
||||||
|
role?: string;
|
||||||
|
isPrimary?: boolean;
|
||||||
|
isMandatory?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface AccountSignatoryInfo {
|
||||||
|
modeOfOperation: string | null;
|
||||||
|
primaryClientId: number | null;
|
||||||
|
primaryName: string | null;
|
||||||
|
jointHolders: JointHolder[];
|
||||||
|
availableBalance: number | null;
|
||||||
|
onHoldAmount: number | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Fetches account-holder/signatory data needed to verify a cheque's signature — reusing the exact APIs and
|
||||||
|
* conventions already established in the cashier module's joint-holder detail view
|
||||||
|
* (`transaction-entry.component.ts`), so this isn't a second, divergent implementation of the same lookup.
|
||||||
|
*/
|
||||||
|
@Injectable({
|
||||||
|
providedIn: 'root'
|
||||||
|
})
|
||||||
|
export class SignatoryService {
|
||||||
|
private http = inject(HttpClient);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Fetches the savings or current account (with joint holders and mode of operation embedded via
|
||||||
|
* `associations=all` — the same Fineract convention the cashier module already relies on).
|
||||||
|
*/
|
||||||
|
fetchAccountSignatories(accountType: string, accountId: number | string): Observable<AccountSignatoryInfo> {
|
||||||
|
const path =
|
||||||
|
accountType?.toUpperCase() === 'CURRENT' ? `/currentaccounts/${accountId}` : `/savingsaccounts/${accountId}`;
|
||||||
|
return this.http.get<any>(path, { params: new HttpParams().set('associations', 'all') }).pipe(
|
||||||
|
map((acc: any) => ({
|
||||||
|
modeOfOperation: acc?.modeOfOperation ?? null,
|
||||||
|
primaryClientId: acc?.clientId ?? null,
|
||||||
|
primaryName: acc?.clientName ?? acc?.name ?? null,
|
||||||
|
jointHolders: Array.isArray(acc?.jointHolders) ? acc.jointHolders : [],
|
||||||
|
availableBalance: acc?.summary?.availableBalance ?? null,
|
||||||
|
onHoldAmount: acc?.summary?.totalSavingsAmountOnHold ?? acc?.onHoldFundsDerived ?? null
|
||||||
|
}))
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The client's stored signature (a document tagged name === 'clientSignature'), as a blob object URL, or null if none uploaded. */
|
||||||
|
fetchClientSignature(clientId: number | string): Observable<string | null> {
|
||||||
|
return this.http.get<any[]>(`/clients/${clientId}/documents`).pipe(
|
||||||
|
switchMap((docs: any[]) => {
|
||||||
|
// Multiple 'clientSignature' documents can exist for clients who re-cropped/replaced
|
||||||
|
// their signature before uploads became single-slot, so pick the highest id (latest)
|
||||||
|
// rather than the first unordered match.
|
||||||
|
const sigDoc = (docs || [])
|
||||||
|
.filter((d: any) => d.name === 'clientSignature' || d.name?.toLowerCase().includes('signature'))
|
||||||
|
.sort((a: any, b: any) => b.id - a.id)[0];
|
||||||
|
if (!sigDoc) {
|
||||||
|
return of(null);
|
||||||
|
}
|
||||||
|
return this.http.get(`/clients/${clientId}/documents/${sigDoc.id}/attachment`, { responseType: 'blob' }).pipe(
|
||||||
|
map((blob: Blob) => URL.createObjectURL(blob)),
|
||||||
|
catchError(() => of(null))
|
||||||
|
);
|
||||||
|
}),
|
||||||
|
catchError(() => of(null))
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The client's photo (already base64/text from this API), or null if none uploaded. */
|
||||||
|
fetchClientPhoto(clientId: number | string): Observable<string | null> {
|
||||||
|
return this.http.get(`/clients/${clientId}/images`, { responseType: 'text' }).pipe(catchError(() => of(null)));
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -11,116 +11,79 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
.af-page {
|
.af-page {
|
||||||
|
background: #f8fafc;
|
||||||
min-height: calc(100vh - 68px);
|
min-height: calc(100vh - 68px);
|
||||||
background: #f0f4f8;
|
|
||||||
padding: 24px 28px;
|
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
gap: 20px;
|
gap: 12px;
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── Hero ──────────────────────────────────────────────────────────────────────
|
// ── Header ──────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
.af-hero {
|
.af-header {
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
gap: 18px;
|
justify-content: space-between;
|
||||||
background: linear-gradient(135deg, #1e3a8a 0%, #1e40af 40%, #2563eb 100%);
|
padding: 12px 20px;
|
||||||
border-radius: 16px;
|
background: #fff;
|
||||||
padding: 22px 28px;
|
border-bottom: 1px solid #e2e8f0;
|
||||||
color: #fff;
|
|
||||||
box-shadow: 0 8px 32px rgb(37 99 235 / 35%);
|
|
||||||
position: relative;
|
|
||||||
overflow: hidden;
|
|
||||||
|
|
||||||
&::before {
|
&__left {
|
||||||
content: '';
|
display: flex;
|
||||||
position: absolute;
|
align-items: center;
|
||||||
top: -40px;
|
gap: 10px;
|
||||||
right: -40px;
|
|
||||||
width: 200px;
|
|
||||||
height: 200px;
|
|
||||||
background: rgb(255 255 255 / 5%);
|
|
||||||
border-radius: 50%;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
&__icon-wrap {
|
&__icon {
|
||||||
width: 56px;
|
color: #0f4f3c;
|
||||||
height: 56px;
|
|
||||||
background: rgb(255 255 255 / 15%);
|
|
||||||
border: 1px solid rgb(255 255 255 / 25%);
|
|
||||||
border-radius: 14px;
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
justify-content: center;
|
|
||||||
flex-shrink: 0;
|
flex-shrink: 0;
|
||||||
backdrop-filter: blur(8px);
|
|
||||||
}
|
|
||||||
|
|
||||||
&__text {
|
|
||||||
flex: 1;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
&__title {
|
&__title {
|
||||||
font-size: 22px;
|
font-size: 15px;
|
||||||
font-weight: 800;
|
font-weight: 700;
|
||||||
letter-spacing: -0.01em;
|
color: #0d1b2a;
|
||||||
margin: 0 0 5px;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
&__stats {
|
&__sub {
|
||||||
display: flex;
|
font-size: 12px;
|
||||||
gap: 14px;
|
color: #64748b;
|
||||||
align-items: center;
|
margin-top: 1px;
|
||||||
}
|
}
|
||||||
|
|
||||||
&__stat {
|
&__stat {
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
gap: 5px;
|
|
||||||
font-size: 12.5px;
|
|
||||||
color: rgb(255 255 255 / 75%);
|
|
||||||
|
|
||||||
strong {
|
|
||||||
color: #fff;
|
|
||||||
font-weight: 700;
|
font-weight: 700;
|
||||||
|
|
||||||
|
&--green {
|
||||||
|
color: #16a34a;
|
||||||
}
|
}
|
||||||
|
|
||||||
&--green strong {
|
&--red {
|
||||||
color: #86efac;
|
color: #dc2626;
|
||||||
}
|
}
|
||||||
|
|
||||||
&--red strong {
|
|
||||||
color: #fca5a5;
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
&__sep {
|
.af-btn {
|
||||||
width: 1px;
|
|
||||||
height: 14px;
|
|
||||||
background: rgb(255 255 255 / 25%);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
.af-back-btn {
|
|
||||||
display: inline-flex;
|
display: inline-flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
gap: 7px;
|
gap: 6px;
|
||||||
padding: 10px 20px;
|
border-radius: 7px;
|
||||||
background: rgb(255 255 255 / 12%);
|
font-family: inherit;
|
||||||
border: 1px solid rgb(255 255 255 / 25%);
|
|
||||||
border-radius: 10px;
|
|
||||||
color: #fff;
|
|
||||||
font-size: 12.5px;
|
|
||||||
font-weight: 600;
|
font-weight: 600;
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
transition: all 0.2s;
|
border: 1px solid #e2e8f0;
|
||||||
white-space: nowrap;
|
transition: background 0.15s;
|
||||||
backdrop-filter: blur(8px);
|
height: 30px;
|
||||||
|
padding: 0 12px;
|
||||||
|
font-size: 12.5px;
|
||||||
|
background: #fff;
|
||||||
|
color: #0d1b2a;
|
||||||
|
|
||||||
&:hover {
|
&:hover {
|
||||||
background: rgb(255 255 255 / 22%);
|
background: #f0fdf4;
|
||||||
transform: translateX(-2px);
|
border-color: #0f4f3c;
|
||||||
|
color: #0f4f3c;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -132,12 +95,12 @@
|
|||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
justify-content: center;
|
justify-content: center;
|
||||||
gap: 14px;
|
gap: 10px;
|
||||||
padding: 60px 20px;
|
padding: 40px 20px;
|
||||||
|
|
||||||
&__icon {
|
&__icon {
|
||||||
width: 88px;
|
width: 64px;
|
||||||
height: 88px;
|
height: 64px;
|
||||||
background: #e2e8f0;
|
background: #e2e8f0;
|
||||||
border: 1px solid #cbd5e1;
|
border: 1px solid #cbd5e1;
|
||||||
border-radius: 50%;
|
border-radius: 50%;
|
||||||
@@ -147,80 +110,87 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
&__title {
|
&__title {
|
||||||
font-size: 18px;
|
font-size: 15px;
|
||||||
font-weight: 700;
|
font-weight: 700;
|
||||||
color: #1e293b;
|
color: #1e293b;
|
||||||
margin: 6px 0 0;
|
margin: 4px 0 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
&__sub {
|
&__sub {
|
||||||
font-size: 13.5px;
|
font-size: 12.5px;
|
||||||
color: #64748b;
|
color: #64748b;
|
||||||
text-align: center;
|
text-align: center;
|
||||||
margin: 0;
|
margin: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
&__btn {
|
&__btn {
|
||||||
margin-top: 10px;
|
margin-top: 6px;
|
||||||
padding: 10px 24px;
|
padding: 8px 20px;
|
||||||
background: #2563eb;
|
background: #0f4f3c;
|
||||||
border: none;
|
border: none;
|
||||||
border-radius: 10px;
|
border-radius: 8px;
|
||||||
color: #fff;
|
color: #fff;
|
||||||
font-size: 13px;
|
font-size: 12.5px;
|
||||||
font-weight: 600;
|
font-weight: 600;
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
transition: background 0.15s;
|
transition: background 0.15s;
|
||||||
|
|
||||||
&:hover {
|
&:hover {
|
||||||
background: #1d4ed8;
|
background: #0a3d2e;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ── Body ────────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
.af-body {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 12px;
|
||||||
|
padding: 0 20px 16px;
|
||||||
|
}
|
||||||
|
|
||||||
// ── Progress card ──────────────────────────────────────────────────────────────
|
// ── Progress card ──────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
.af-progress-card {
|
.af-progress-card {
|
||||||
background: #fff;
|
background: #fff;
|
||||||
border: 1px solid #e2e8f0;
|
border: 1px solid #e2e8f0;
|
||||||
border-radius: 14px;
|
border-radius: 10px;
|
||||||
padding: 18px 22px;
|
padding: 12px 16px;
|
||||||
box-shadow: 0 1px 4px rgb(0 0 0 / 6%);
|
|
||||||
|
|
||||||
&__top {
|
&__top {
|
||||||
display: flex;
|
display: flex;
|
||||||
justify-content: space-between;
|
justify-content: space-between;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
margin-bottom: 12px;
|
margin-bottom: 8px;
|
||||||
}
|
}
|
||||||
|
|
||||||
&__label {
|
&__label {
|
||||||
font-size: 11.5px;
|
font-size: 11px;
|
||||||
font-weight: 600;
|
font-weight: 700;
|
||||||
color: #64748b;
|
color: #64748b;
|
||||||
text-transform: uppercase;
|
text-transform: uppercase;
|
||||||
letter-spacing: 0.08em;
|
letter-spacing: 0.06em;
|
||||||
}
|
}
|
||||||
|
|
||||||
&__fraction {
|
&__fraction {
|
||||||
font-size: 13px;
|
font-size: 12.5px;
|
||||||
font-weight: 700;
|
font-weight: 700;
|
||||||
color: #16a34a;
|
color: #0f4f3c;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
.af-progress-bar {
|
.af-progress-bar {
|
||||||
height: 10px;
|
height: 7px;
|
||||||
background: #e2e8f0;
|
background: #e2e8f0;
|
||||||
border-radius: 999px;
|
border-radius: 999px;
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
|
|
||||||
&__fill {
|
&__fill {
|
||||||
height: 100%;
|
height: 100%;
|
||||||
background: linear-gradient(90deg, #16a34a, #22c55e);
|
background: #16a34a;
|
||||||
border-radius: 999px;
|
border-radius: 999px;
|
||||||
transition: width 0.5s ease;
|
transition: width 0.4s ease;
|
||||||
box-shadow: 0 0 8px rgb(34 197 94 / 35%);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -229,65 +199,53 @@
|
|||||||
.af-fac-card {
|
.af-fac-card {
|
||||||
background: #fff;
|
background: #fff;
|
||||||
border: 1px solid #e2e8f0;
|
border: 1px solid #e2e8f0;
|
||||||
border-radius: 14px;
|
border-radius: 10px;
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
box-shadow: 0 1px 4px rgb(0 0 0 / 6%);
|
|
||||||
|
|
||||||
&__header {
|
&__header {
|
||||||
display: flex;
|
padding: 9px 16px;
|
||||||
align-items: center;
|
background: #f8fafc;
|
||||||
gap: 9px;
|
border-bottom: 1px solid #e2e8f0;
|
||||||
padding: 12px 22px;
|
color: #64748b;
|
||||||
background: #1e293b;
|
font-size: 11px;
|
||||||
color: #94a3b8;
|
|
||||||
font-size: 11.5px;
|
|
||||||
font-weight: 700;
|
font-weight: 700;
|
||||||
text-transform: uppercase;
|
text-transform: uppercase;
|
||||||
letter-spacing: 0.08em;
|
letter-spacing: 0.06em;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
.af-pills {
|
.af-pills {
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-wrap: wrap;
|
flex-wrap: wrap;
|
||||||
gap: 10px;
|
gap: 7px;
|
||||||
padding: 20px 22px;
|
padding: 12px 16px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.af-pill {
|
.af-pill {
|
||||||
display: inline-flex;
|
display: inline-flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
gap: 6px;
|
gap: 5px;
|
||||||
padding: 7px 15px;
|
padding: 4px 11px;
|
||||||
border-radius: 999px;
|
border-radius: 999px;
|
||||||
font-size: 13px;
|
font-size: 12px;
|
||||||
font-weight: 600;
|
font-weight: 600;
|
||||||
border: 1.5px solid;
|
border: 1px solid;
|
||||||
transition:
|
|
||||||
transform 0.15s,
|
|
||||||
box-shadow 0.15s;
|
|
||||||
cursor: default;
|
cursor: default;
|
||||||
|
|
||||||
&__dot {
|
&__dot {
|
||||||
width: 7px;
|
width: 6px;
|
||||||
height: 7px;
|
height: 6px;
|
||||||
border-radius: 50%;
|
border-radius: 50%;
|
||||||
flex-shrink: 0;
|
flex-shrink: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
&--on {
|
&--on {
|
||||||
background: #f0fdf4;
|
background: #f0fdf4;
|
||||||
border-color: #22c55e;
|
border-color: #86efac;
|
||||||
color: #15803d;
|
color: #166534;
|
||||||
|
|
||||||
.af-pill__dot {
|
.af-pill__dot {
|
||||||
background: #22c55e;
|
background: #22c55e;
|
||||||
box-shadow: 0 0 5px rgb(34 197 94 / 50%);
|
|
||||||
}
|
|
||||||
|
|
||||||
&:hover {
|
|
||||||
transform: translateY(-1px);
|
|
||||||
box-shadow: 0 4px 12px rgb(34 197 94 / 20%);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -307,63 +265,46 @@
|
|||||||
.af-stats-row {
|
.af-stats-row {
|
||||||
display: grid;
|
display: grid;
|
||||||
grid-template-columns: 1fr 1fr;
|
grid-template-columns: 1fr 1fr;
|
||||||
gap: 14px;
|
gap: 10px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.af-stat-card {
|
.af-stat-card {
|
||||||
border-radius: 14px;
|
border-radius: 10px;
|
||||||
padding: 20px 24px;
|
padding: 10px 14px;
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: baseline;
|
||||||
gap: 16px;
|
gap: 7px;
|
||||||
border: 1px solid;
|
border: 1px solid;
|
||||||
box-shadow: 0 1px 4px rgb(0 0 0 / 6%);
|
font-size: 12px;
|
||||||
|
|
||||||
&__icon {
|
&__icon {
|
||||||
width: 44px;
|
font-size: 12px;
|
||||||
height: 44px;
|
font-weight: 700;
|
||||||
border-radius: 12px;
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
justify-content: center;
|
|
||||||
flex-shrink: 0;
|
|
||||||
font-size: 20px;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
&__num {
|
&__num {
|
||||||
font-size: 30px;
|
font-size: 17px;
|
||||||
font-weight: 800;
|
font-weight: 800;
|
||||||
line-height: 1;
|
line-height: 1;
|
||||||
display: block;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
&__label {
|
&__label {
|
||||||
font-size: 12px;
|
font-size: 11px;
|
||||||
font-weight: 600;
|
font-weight: 600;
|
||||||
text-transform: uppercase;
|
text-transform: uppercase;
|
||||||
letter-spacing: 0.08em;
|
letter-spacing: 0.05em;
|
||||||
display: block;
|
|
||||||
margin-top: 3px;
|
|
||||||
opacity: 0.75;
|
opacity: 0.75;
|
||||||
}
|
}
|
||||||
|
|
||||||
&--green {
|
&--green {
|
||||||
background: linear-gradient(135deg, #f0fdf4, #dcfce7);
|
background: #f0fdf4;
|
||||||
border-color: #86efac;
|
border-color: #86efac;
|
||||||
color: #15803d;
|
color: #166534;
|
||||||
|
|
||||||
.af-stat-card__icon {
|
|
||||||
background: #bbf7d0;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
&--red {
|
&--red {
|
||||||
background: linear-gradient(135deg, #f8fafc, #f1f5f9);
|
background: #f8fafc;
|
||||||
border-color: #cbd5e1;
|
border-color: #e2e8f0;
|
||||||
color: #475569;
|
color: #64748b;
|
||||||
|
|
||||||
.af-stat-card__icon {
|
|
||||||
background: #e2e8f0;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -18,34 +18,37 @@ import { ClerkUiStateService } from '../../cashier/clerk-ui-state.service';
|
|||||||
styleUrl: './account-facilities.component.scss',
|
styleUrl: './account-facilities.component.scss',
|
||||||
template: `
|
template: `
|
||||||
<div class="af-page">
|
<div class="af-page">
|
||||||
<!-- Hero -->
|
<!-- Header -->
|
||||||
<div class="af-hero">
|
<div class="af-header">
|
||||||
<div class="af-hero__icon-wrap">
|
<div class="af-header__left">
|
||||||
<svg width="28" height="28" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8">
|
<svg
|
||||||
|
class="af-header__icon"
|
||||||
|
width="24"
|
||||||
|
height="24"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
fill="none"
|
||||||
|
stroke="currentColor"
|
||||||
|
stroke-width="1.8"
|
||||||
|
>
|
||||||
<rect x="1" y="4" width="22" height="16" rx="2" />
|
<rect x="1" y="4" width="22" height="16" rx="2" />
|
||||||
<line x1="1" y1="10" x2="23" y2="10" />
|
<line x1="1" y1="10" x2="23" y2="10" />
|
||||||
</svg>
|
</svg>
|
||||||
</div>
|
<div>
|
||||||
<div class="af-hero__text">
|
<div class="af-header__title">Account Facilities</div>
|
||||||
<h1 class="af-hero__title">Account Facilities</h1>
|
|
||||||
@if (accountLoaded) {
|
@if (accountLoaded) {
|
||||||
<div class="af-hero__stats">
|
<div class="af-header__sub">
|
||||||
<span class="af-hero__stat af-hero__stat--green">
|
<span class="af-header__stat af-header__stat--green">{{ activeCount }} active</span>
|
||||||
<strong>{{ activeCount }}</strong> Active
|
·
|
||||||
</span>
|
<span class="af-header__stat af-header__stat--red">{{ facilities.length - activeCount }} inactive</span>
|
||||||
<span class="af-hero__sep"></span>
|
· {{ facilities.length }} total
|
||||||
<span class="af-hero__stat af-hero__stat--red">
|
|
||||||
<strong>{{ facilities.length - activeCount }}</strong> Inactive
|
|
||||||
</span>
|
|
||||||
<span class="af-hero__sep"></span>
|
|
||||||
<span class="af-hero__stat">
|
|
||||||
<strong>{{ facilities.length }}</strong> Total
|
|
||||||
</span>
|
|
||||||
</div>
|
</div>
|
||||||
|
} @else {
|
||||||
|
<div class="af-header__sub">View all facilities enabled for the loaded account.</div>
|
||||||
}
|
}
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
@if (!embedded) {
|
@if (!embedded) {
|
||||||
<button class="af-back-btn" (click)="goBack()">
|
<button type="button" class="af-btn" (click)="goBack()">
|
||||||
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5">
|
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5">
|
||||||
<polyline points="15 18 9 12 15 6" />
|
<polyline points="15 18 9 12 15 6" />
|
||||||
</svg>
|
</svg>
|
||||||
@@ -58,7 +61,7 @@ import { ClerkUiStateService } from '../../cashier/clerk-ui-state.service';
|
|||||||
<!-- Empty state -->
|
<!-- Empty state -->
|
||||||
<div class="af-empty">
|
<div class="af-empty">
|
||||||
<div class="af-empty__icon">
|
<div class="af-empty__icon">
|
||||||
<svg width="48" height="48" viewBox="0 0 24 24" fill="none" stroke="#475569" stroke-width="1.3">
|
<svg width="40" height="40" viewBox="0 0 24 24" fill="none" stroke="#475569" stroke-width="1.3">
|
||||||
<rect x="1" y="4" width="22" height="16" rx="2" />
|
<rect x="1" y="4" width="22" height="16" rx="2" />
|
||||||
<line x1="1" y1="10" x2="23" y2="10" />
|
<line x1="1" y1="10" x2="23" y2="10" />
|
||||||
</svg>
|
</svg>
|
||||||
@@ -70,6 +73,7 @@ import { ClerkUiStateService } from '../../cashier/clerk-ui-state.service';
|
|||||||
}
|
}
|
||||||
</div>
|
</div>
|
||||||
} @else {
|
} @else {
|
||||||
|
<div class="af-body">
|
||||||
<!-- Progress bar -->
|
<!-- Progress bar -->
|
||||||
<div class="af-progress-card">
|
<div class="af-progress-card">
|
||||||
<div class="af-progress-card__top">
|
<div class="af-progress-card__top">
|
||||||
@@ -83,13 +87,7 @@ import { ClerkUiStateService } from '../../cashier/clerk-ui-state.service';
|
|||||||
|
|
||||||
<!-- Facilities pills -->
|
<!-- Facilities pills -->
|
||||||
<div class="af-fac-card">
|
<div class="af-fac-card">
|
||||||
<div class="af-fac-card__header">
|
<div class="af-fac-card__header">All Facilities</div>
|
||||||
<svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
|
||||||
<rect x="1" y="4" width="22" height="16" rx="2" />
|
|
||||||
<line x1="1" y1="10" x2="23" y2="10" />
|
|
||||||
</svg>
|
|
||||||
All Facilities
|
|
||||||
</div>
|
|
||||||
<div class="af-pills">
|
<div class="af-pills">
|
||||||
@for (f of facilities; track f.label) {
|
@for (f of facilities; track f.label) {
|
||||||
<span class="af-pill" [class.af-pill--on]="f.active" [class.af-pill--off]="!f.active">
|
<span class="af-pill" [class.af-pill--on]="f.active" [class.af-pill--off]="!f.active">
|
||||||
@@ -103,15 +101,12 @@ import { ClerkUiStateService } from '../../cashier/clerk-ui-state.service';
|
|||||||
<!-- Stats row -->
|
<!-- Stats row -->
|
||||||
<div class="af-stats-row">
|
<div class="af-stats-row">
|
||||||
<div class="af-stat-card af-stat-card--green">
|
<div class="af-stat-card af-stat-card--green">
|
||||||
<div class="af-stat-card__icon">✓</div>
|
<span class="af-stat-card__icon">✓</span>
|
||||||
<div>
|
|
||||||
<span class="af-stat-card__num">{{ activeCount }}</span>
|
<span class="af-stat-card__num">{{ activeCount }}</span>
|
||||||
<span class="af-stat-card__label">Active</span>
|
<span class="af-stat-card__label">Active</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
|
||||||
<div class="af-stat-card af-stat-card--red">
|
<div class="af-stat-card af-stat-card--red">
|
||||||
<div class="af-stat-card__icon">✗</div>
|
<span class="af-stat-card__icon">✕</span>
|
||||||
<div>
|
|
||||||
<span class="af-stat-card__num">{{ facilities.length - activeCount }}</span>
|
<span class="af-stat-card__num">{{ facilities.length - activeCount }}</span>
|
||||||
<span class="af-stat-card__label">Inactive</span>
|
<span class="af-stat-card__label">Inactive</span>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -0,0 +1,281 @@
|
|||||||
|
/**
|
||||||
|
* Copyright since 2025 Mifos Initiative
|
||||||
|
*
|
||||||
|
* This Source Code Form is subject to the terms of the Mozilla Public
|
||||||
|
* License, v. 2.0. If a copy of the MPL was not distributed with this
|
||||||
|
* file, You can obtain one at http://mozilla.org/MPL/2.0/.
|
||||||
|
*/
|
||||||
|
|
||||||
|
:host {
|
||||||
|
display: block;
|
||||||
|
}
|
||||||
|
|
||||||
|
.cb-page {
|
||||||
|
background: #f8fafc;
|
||||||
|
min-height: calc(100vh - 68px);
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Header ──────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
.cb-header {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
padding: 14px 20px;
|
||||||
|
background: #fff;
|
||||||
|
border-bottom: 1px solid #e2e8f0;
|
||||||
|
|
||||||
|
&__left {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
&__icon {
|
||||||
|
color: #0f4f3c;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
&__title {
|
||||||
|
font-size: 15px;
|
||||||
|
font-weight: 700;
|
||||||
|
color: #0d1b2a;
|
||||||
|
}
|
||||||
|
|
||||||
|
&__sub {
|
||||||
|
font-size: 12px;
|
||||||
|
color: #64748b;
|
||||||
|
margin-top: 2px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.cb-btn {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 6px;
|
||||||
|
border-radius: 7px;
|
||||||
|
font-family: inherit;
|
||||||
|
font-weight: 600;
|
||||||
|
cursor: pointer;
|
||||||
|
border: 1px solid #e2e8f0;
|
||||||
|
transition: background 0.15s;
|
||||||
|
height: 32px;
|
||||||
|
padding: 0 12px;
|
||||||
|
font-size: 13px;
|
||||||
|
background: #fff;
|
||||||
|
color: #0d1b2a;
|
||||||
|
|
||||||
|
&:hover {
|
||||||
|
background: #f0fdf4;
|
||||||
|
border-color: #0f4f3c;
|
||||||
|
color: #0f4f3c;
|
||||||
|
}
|
||||||
|
|
||||||
|
&--primary {
|
||||||
|
background: #0f4f3c;
|
||||||
|
border-color: #0f4f3c;
|
||||||
|
color: #fff;
|
||||||
|
|
||||||
|
&:hover {
|
||||||
|
background: #0a3d2e;
|
||||||
|
color: #fff;
|
||||||
|
}
|
||||||
|
|
||||||
|
&:disabled {
|
||||||
|
opacity: 0.5;
|
||||||
|
cursor: not-allowed;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.cb-status-msg {
|
||||||
|
padding: 40px 20px;
|
||||||
|
text-align: center;
|
||||||
|
color: #64748b;
|
||||||
|
font-size: 13.5px;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Body ────────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
.cb-body {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 16px;
|
||||||
|
padding: 0 20px 20px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.cb-info-card {
|
||||||
|
background: #fff;
|
||||||
|
border: 1px solid #e2e8f0;
|
||||||
|
border-radius: 10px;
|
||||||
|
padding: 18px 22px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.cb-info-grid {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(auto-fill, minmax(180px, 1fr));
|
||||||
|
gap: 16px 24px;
|
||||||
|
|
||||||
|
&__k {
|
||||||
|
display: block;
|
||||||
|
font-size: 11px;
|
||||||
|
font-weight: 700;
|
||||||
|
color: #94a3b8;
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 0.06em;
|
||||||
|
margin-bottom: 3px;
|
||||||
|
}
|
||||||
|
|
||||||
|
&__v {
|
||||||
|
display: block;
|
||||||
|
font-size: 13.5px;
|
||||||
|
font-weight: 600;
|
||||||
|
color: #0d1b2a;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.cb-pill {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
padding: 2px 10px;
|
||||||
|
border-radius: 20px;
|
||||||
|
font-size: 11px;
|
||||||
|
font-weight: 700;
|
||||||
|
letter-spacing: 0.04em;
|
||||||
|
white-space: nowrap;
|
||||||
|
|
||||||
|
&--ok {
|
||||||
|
background: #dcfce7;
|
||||||
|
color: #166534;
|
||||||
|
}
|
||||||
|
|
||||||
|
&--bad {
|
||||||
|
background: #fee2e2;
|
||||||
|
color: #991b1b;
|
||||||
|
}
|
||||||
|
|
||||||
|
&--neutral {
|
||||||
|
background: #fef3c7;
|
||||||
|
color: #92400e;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.cb-no-books {
|
||||||
|
padding: 30px 10px;
|
||||||
|
text-align: center;
|
||||||
|
color: #94a3b8;
|
||||||
|
font-size: 13px;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Action toolbar ─────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
.cb-action-toolbar {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: 8px;
|
||||||
|
padding: 0 20px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.cb-action-btn {
|
||||||
|
border-radius: 7px;
|
||||||
|
font-family: inherit;
|
||||||
|
font-weight: 600;
|
||||||
|
cursor: pointer;
|
||||||
|
border: 1px solid #e2e8f0;
|
||||||
|
transition: background 0.15s;
|
||||||
|
height: 32px;
|
||||||
|
padding: 0 14px;
|
||||||
|
font-size: 12.5px;
|
||||||
|
background: #fff;
|
||||||
|
color: #0d1b2a;
|
||||||
|
|
||||||
|
&:hover {
|
||||||
|
background: #f0fdf4;
|
||||||
|
border-color: #0f4f3c;
|
||||||
|
}
|
||||||
|
|
||||||
|
&--warn {
|
||||||
|
border-color: #fca5a5;
|
||||||
|
color: #dc2626;
|
||||||
|
|
||||||
|
&:hover {
|
||||||
|
background: #fef2f2;
|
||||||
|
border-color: #dc2626;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
&--primary {
|
||||||
|
background: #0f4f3c;
|
||||||
|
border-color: #0f4f3c;
|
||||||
|
color: #fff;
|
||||||
|
|
||||||
|
&:hover {
|
||||||
|
background: #0a3d2e;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Leaf grid ──────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
.cb-leaf-grid {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.cb-leaf-circle {
|
||||||
|
width: 46px;
|
||||||
|
height: 46px;
|
||||||
|
border-radius: 50%;
|
||||||
|
border: 1.5px solid #cbd5e1;
|
||||||
|
background: #fff;
|
||||||
|
color: #64748b;
|
||||||
|
font-size: 12px;
|
||||||
|
font-weight: 700;
|
||||||
|
font-family: 'Courier New', monospace;
|
||||||
|
cursor: pointer;
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
transition:
|
||||||
|
transform 0.1s,
|
||||||
|
box-shadow 0.1s;
|
||||||
|
|
||||||
|
&:hover {
|
||||||
|
transform: translateY(-1px);
|
||||||
|
box-shadow: 0 3px 8px rgb(0 0 0 / 12%);
|
||||||
|
}
|
||||||
|
|
||||||
|
&--ok {
|
||||||
|
border-color: #22c55e;
|
||||||
|
color: #166534;
|
||||||
|
background: #f0fdf4;
|
||||||
|
}
|
||||||
|
|
||||||
|
&--used {
|
||||||
|
border-color: #cbd5e1;
|
||||||
|
color: #64748b;
|
||||||
|
background: #f8fafc;
|
||||||
|
}
|
||||||
|
|
||||||
|
&--warn {
|
||||||
|
border-color: #f59e0b;
|
||||||
|
color: #92400e;
|
||||||
|
background: #fffbeb;
|
||||||
|
}
|
||||||
|
|
||||||
|
&--bad {
|
||||||
|
border-color: #ef4444;
|
||||||
|
color: #991b1b;
|
||||||
|
background: #fef2f2;
|
||||||
|
}
|
||||||
|
|
||||||
|
&--returned {
|
||||||
|
border-color: #a855f7;
|
||||||
|
color: #6b21a8;
|
||||||
|
background: #faf5ff;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,207 @@
|
|||||||
|
/**
|
||||||
|
* 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 { ActivatedRoute, Router } from '@angular/router';
|
||||||
|
import { MatDialog } from '@angular/material/dialog';
|
||||||
|
import { ChequeBookService } from '../../chequebook/chequebook.service';
|
||||||
|
import { LeafDetailDialogComponent } from '../../chequebook/cheque-book-detail/leaf-detail-dialog/leaf-detail-dialog.component';
|
||||||
|
import { ClerkLeafActionDialogComponent, LeafAction } from './leaf-action-dialog/leaf-action-dialog.component';
|
||||||
|
|
||||||
|
/** Cheque-book detail for the Clerk Dashboard — a fullscreen clerk-styled view of one book, its leaf
|
||||||
|
* register, and the same maker-side leaf actions (stop-payment, revoke, report-lost, cancel-leaf,
|
||||||
|
* mark-used) as the back-office ChequeBookDetailComponent, so a clerk can act on a leaf without leaving
|
||||||
|
* the clerk shell/nav. Every action still goes through the existing maker-checker approval flow server-side.
|
||||||
|
* Each action opens as a popup (ClerkLeafActionDialogComponent) rather than an inline form so it never
|
||||||
|
* pushes the leaf grid around and gets the full dialog width to lay out its fields. */
|
||||||
|
@Component({
|
||||||
|
selector: 'mifosx-clerk-cheque-book-detail',
|
||||||
|
standalone: true,
|
||||||
|
imports: [CommonModule],
|
||||||
|
styleUrl: './cheque-book-detail.component.scss',
|
||||||
|
template: `
|
||||||
|
<div class="cb-page">
|
||||||
|
<!-- Header -->
|
||||||
|
<div class="cb-header">
|
||||||
|
<div class="cb-header__left">
|
||||||
|
<svg
|
||||||
|
class="cb-header__icon"
|
||||||
|
width="26"
|
||||||
|
height="26"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
fill="none"
|
||||||
|
stroke="currentColor"
|
||||||
|
stroke-width="1.8"
|
||||||
|
>
|
||||||
|
<rect x="1" y="4" width="22" height="16" rx="2" />
|
||||||
|
<line x1="1" y1="10" x2="23" y2="10" />
|
||||||
|
</svg>
|
||||||
|
<div>
|
||||||
|
<div class="cb-header__title">Cheque Book #{{ book.id }}</div>
|
||||||
|
<div class="cb-header__sub">
|
||||||
|
{{ book.accountNo || book.accountId }}
|
||||||
|
@if (book.clientName) {
|
||||||
|
· {{ book.clientName }}
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<button type="button" class="cb-btn" (click)="goBack()">
|
||||||
|
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5">
|
||||||
|
<polyline points="15 18 9 12 15 6" />
|
||||||
|
</svg>
|
||||||
|
Back
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
@if (loading) {
|
||||||
|
<div class="cb-status-msg">Loading cheque book…</div>
|
||||||
|
} @else {
|
||||||
|
<div class="cb-body">
|
||||||
|
<!-- Info card -->
|
||||||
|
<div class="cb-info-card">
|
||||||
|
<div class="cb-info-grid">
|
||||||
|
<div>
|
||||||
|
<span class="cb-info-grid__k">Account Type</span>
|
||||||
|
<span class="cb-info-grid__v">{{ book.accountType }}</span>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<span class="cb-info-grid__k">Office</span>
|
||||||
|
<span class="cb-info-grid__v">{{ book.officeName }}</span>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<span class="cb-info-grid__k">Series</span>
|
||||||
|
<span class="cb-info-grid__v">{{ book.seriesCode ?? '—' }}</span>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<span class="cb-info-grid__k">Leaf Range</span>
|
||||||
|
<span class="cb-info-grid__v">
|
||||||
|
{{ book.rangeStart ? book.rangeStart + ' – ' + book.rangeEnd : '—' }}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<span class="cb-info-grid__k">Quantity</span>
|
||||||
|
<span class="cb-info-grid__v">{{ book.noOfBooks }} book{{ book.noOfBooks === 1 ? '' : 's' }}</span>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<span class="cb-info-grid__k">Status</span>
|
||||||
|
<span
|
||||||
|
class="cb-pill"
|
||||||
|
[class.cb-pill--ok]="book.status === 'ISSUED'"
|
||||||
|
[class.cb-pill--bad]="book.status === 'REJECTED' || book.status === 'WITHDRAWN'"
|
||||||
|
[class.cb-pill--neutral]="
|
||||||
|
book.status !== 'ISSUED' && book.status !== 'REJECTED' && book.status !== 'WITHDRAWN'
|
||||||
|
"
|
||||||
|
>{{ book.status }}</span
|
||||||
|
>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<span class="cb-info-grid__k">Issue Date</span>
|
||||||
|
<span class="cb-info-grid__v">{{ book.issueDate ?? '—' }}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Action toolbar — only for an issued book; each button pops up its own action dialog -->
|
||||||
|
@if (book.status === 'ISSUED') {
|
||||||
|
<div class="cb-action-toolbar">
|
||||||
|
<button type="button" class="cb-action-btn cb-action-btn--warn" (click)="openAction('stoppayment')">
|
||||||
|
Stop Payment
|
||||||
|
</button>
|
||||||
|
<button type="button" class="cb-action-btn" (click)="openAction('revokestoppayment')">
|
||||||
|
Revoke Stop Payment
|
||||||
|
</button>
|
||||||
|
<button type="button" class="cb-action-btn cb-action-btn--warn" (click)="openAction('reportlost')">
|
||||||
|
Report Lost
|
||||||
|
</button>
|
||||||
|
<button type="button" class="cb-action-btn" (click)="openAction('cancelleaf')">Cancel Leaf</button>
|
||||||
|
<button type="button" class="cb-action-btn cb-action-btn--primary" (click)="openAction('markused')">
|
||||||
|
Mark as Used
|
||||||
|
</button>
|
||||||
|
<button type="button" class="cb-action-btn cb-action-btn--warn" (click)="openAction('return')">
|
||||||
|
Flag Returned
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
|
||||||
|
<!-- Leaf grid -->
|
||||||
|
<div class="cb-info-card">
|
||||||
|
@if (leaves.length === 0) {
|
||||||
|
<div class="cb-no-books">No leaves to display</div>
|
||||||
|
} @else {
|
||||||
|
<div class="cb-leaf-grid">
|
||||||
|
@for (l of leaves; track l.leafSerialNo) {
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="cb-leaf-circle"
|
||||||
|
[class.cb-leaf-circle--ok]="l.status === 'ACTIVE'"
|
||||||
|
[class.cb-leaf-circle--used]="l.status === 'USED'"
|
||||||
|
[class.cb-leaf-circle--warn]="l.status === 'STOP_PAYMENT'"
|
||||||
|
[class.cb-leaf-circle--bad]="l.status === 'LOST'"
|
||||||
|
[class.cb-leaf-circle--returned]="l.status === 'RETURNED'"
|
||||||
|
(click)="selectLeaf(l)"
|
||||||
|
>
|
||||||
|
{{ l.leafSerialNo }}
|
||||||
|
</button>
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
`
|
||||||
|
})
|
||||||
|
export class ClerkChequeBookDetailComponent implements OnInit {
|
||||||
|
private route = inject(ActivatedRoute);
|
||||||
|
private router = inject(Router);
|
||||||
|
private chequeBookService = inject(ChequeBookService);
|
||||||
|
private dialog = inject(MatDialog);
|
||||||
|
|
||||||
|
bookId!: string;
|
||||||
|
book: any = {};
|
||||||
|
leaves: any[] = [];
|
||||||
|
loading = false;
|
||||||
|
|
||||||
|
ngOnInit(): void {
|
||||||
|
this.bookId = this.route.snapshot.paramMap.get('id') as string;
|
||||||
|
this.loadAll();
|
||||||
|
}
|
||||||
|
|
||||||
|
loadAll(): void {
|
||||||
|
this.loading = true;
|
||||||
|
this.chequeBookService.getBook(this.bookId).subscribe((data: any) => {
|
||||||
|
this.book = data || {};
|
||||||
|
this.loading = false;
|
||||||
|
});
|
||||||
|
this.chequeBookService.getLeaves(this.bookId).subscribe((data: any[]) => {
|
||||||
|
this.leaves = data || [];
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
selectLeaf(leaf: any): void {
|
||||||
|
this.dialog.open(LeafDetailDialogComponent, { data: leaf, width: '420px' });
|
||||||
|
}
|
||||||
|
|
||||||
|
openAction(action: LeafAction): void {
|
||||||
|
const ref = this.dialog.open(ClerkLeafActionDialogComponent, {
|
||||||
|
width: '480px',
|
||||||
|
data: { bookId: this.bookId, action }
|
||||||
|
});
|
||||||
|
ref.afterClosed().subscribe((result) => {
|
||||||
|
if (result?.success) {
|
||||||
|
this.loadAll();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
goBack(): void {
|
||||||
|
this.router.navigate(['/clerk/account-facilities/cheque-book']);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,257 @@
|
|||||||
|
/**
|
||||||
|
* Copyright since 2025 Mifos Initiative
|
||||||
|
*
|
||||||
|
* This Source Code Form is subject to the terms of the Mozilla Public
|
||||||
|
* License, v. 2.0. If a copy of the MPL was not distributed with this
|
||||||
|
* file, You can obtain one at http://mozilla.org/MPL/2.0/.
|
||||||
|
*/
|
||||||
|
|
||||||
|
:host {
|
||||||
|
display: block;
|
||||||
|
}
|
||||||
|
|
||||||
|
.cb-page {
|
||||||
|
background: #f8fafc;
|
||||||
|
min-height: calc(100vh - 68px);
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Header ──────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
.cb-header {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
padding: 14px 20px;
|
||||||
|
background: #fff;
|
||||||
|
border-bottom: 1px solid #e2e8f0;
|
||||||
|
|
||||||
|
&__left {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
&__icon {
|
||||||
|
color: #0f4f3c;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
&__title {
|
||||||
|
font-size: 15px;
|
||||||
|
font-weight: 700;
|
||||||
|
color: #0d1b2a;
|
||||||
|
}
|
||||||
|
|
||||||
|
&__sub {
|
||||||
|
font-size: 12px;
|
||||||
|
color: #64748b;
|
||||||
|
margin-top: 2px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.cb-btn {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 6px;
|
||||||
|
border-radius: 7px;
|
||||||
|
font-family: inherit;
|
||||||
|
font-weight: 600;
|
||||||
|
cursor: pointer;
|
||||||
|
border: 1px solid #e2e8f0;
|
||||||
|
transition: background 0.15s;
|
||||||
|
height: 32px;
|
||||||
|
padding: 0 12px;
|
||||||
|
font-size: 13px;
|
||||||
|
background: #fff;
|
||||||
|
color: #0d1b2a;
|
||||||
|
|
||||||
|
&:hover {
|
||||||
|
background: #f0fdf4;
|
||||||
|
border-color: #0f4f3c;
|
||||||
|
color: #0f4f3c;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Empty state ────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
.cb-empty {
|
||||||
|
flex: 1;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
gap: 14px;
|
||||||
|
padding: 60px 20px;
|
||||||
|
|
||||||
|
&__icon {
|
||||||
|
width: 88px;
|
||||||
|
height: 88px;
|
||||||
|
background: #e2e8f0;
|
||||||
|
border: 1px solid #cbd5e1;
|
||||||
|
border-radius: 50%;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
&__title {
|
||||||
|
font-size: 18px;
|
||||||
|
font-weight: 700;
|
||||||
|
color: #1e293b;
|
||||||
|
margin: 6px 0 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
&__sub {
|
||||||
|
font-size: 13.5px;
|
||||||
|
color: #64748b;
|
||||||
|
text-align: center;
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
&__btn {
|
||||||
|
margin-top: 10px;
|
||||||
|
padding: 10px 24px;
|
||||||
|
background: #0f4f3c;
|
||||||
|
border: none;
|
||||||
|
border-radius: 10px;
|
||||||
|
color: #fff;
|
||||||
|
font-size: 13px;
|
||||||
|
font-weight: 600;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: background 0.15s;
|
||||||
|
|
||||||
|
&:hover {
|
||||||
|
background: #0a3d2e;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Warning banner ─────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
.cb-warn-banner {
|
||||||
|
margin: 0 20px;
|
||||||
|
background: #fffbeb;
|
||||||
|
border: 1px solid #fde68a;
|
||||||
|
color: #92400e;
|
||||||
|
font-size: 13px;
|
||||||
|
font-weight: 600;
|
||||||
|
padding: 12px 18px;
|
||||||
|
border-radius: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.cb-status-msg {
|
||||||
|
padding: 40px 20px;
|
||||||
|
text-align: center;
|
||||||
|
color: #64748b;
|
||||||
|
font-size: 13.5px;
|
||||||
|
|
||||||
|
&--error {
|
||||||
|
color: #dc2626;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.cb-no-books {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: center;
|
||||||
|
gap: 10px;
|
||||||
|
padding: 40px 20px;
|
||||||
|
color: #94a3b8;
|
||||||
|
font-size: 13px;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Table card ─────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
.cb-table-card {
|
||||||
|
margin: 0 20px;
|
||||||
|
background: #fff;
|
||||||
|
border: 1px solid #e2e8f0;
|
||||||
|
border-radius: 10px;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
.cb-table-wrap {
|
||||||
|
overflow-x: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.cb-table {
|
||||||
|
width: 100%;
|
||||||
|
border-collapse: collapse;
|
||||||
|
font-size: 13px;
|
||||||
|
|
||||||
|
th,
|
||||||
|
td {
|
||||||
|
padding: 10px 14px;
|
||||||
|
text-align: left;
|
||||||
|
border-bottom: 1px solid #f1f5f9;
|
||||||
|
}
|
||||||
|
|
||||||
|
th {
|
||||||
|
font-size: 11px;
|
||||||
|
font-weight: 700;
|
||||||
|
color: #64748b;
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 0.06em;
|
||||||
|
background: #f8fafc;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
tbody tr:last-child td {
|
||||||
|
border-bottom: none;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.cb-row {
|
||||||
|
cursor: pointer;
|
||||||
|
transition: background 0.1s;
|
||||||
|
|
||||||
|
&:hover td {
|
||||||
|
background: #f0fdf4;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.cb-cell--num {
|
||||||
|
color: #64748b;
|
||||||
|
font-family: 'Courier New', monospace;
|
||||||
|
}
|
||||||
|
|
||||||
|
.cb-cell--date {
|
||||||
|
color: #64748b;
|
||||||
|
font-size: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.cb-cell--action {
|
||||||
|
color: #0f4f3c;
|
||||||
|
font-weight: 700;
|
||||||
|
font-size: 12px;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.cb-pill {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
padding: 2px 10px;
|
||||||
|
border-radius: 20px;
|
||||||
|
font-size: 11px;
|
||||||
|
font-weight: 700;
|
||||||
|
letter-spacing: 0.04em;
|
||||||
|
white-space: nowrap;
|
||||||
|
|
||||||
|
&--ok {
|
||||||
|
background: #dcfce7;
|
||||||
|
color: #166534;
|
||||||
|
}
|
||||||
|
|
||||||
|
&--bad {
|
||||||
|
background: #fee2e2;
|
||||||
|
color: #991b1b;
|
||||||
|
}
|
||||||
|
|
||||||
|
&--neutral {
|
||||||
|
background: #fef3c7;
|
||||||
|
color: #92400e;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,228 @@
|
|||||||
|
/**
|
||||||
|
* 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, Input, inject } from '@angular/core';
|
||||||
|
import { CommonModule } from '@angular/common';
|
||||||
|
import { Router } from '@angular/router';
|
||||||
|
import { ClerkUiStateService } from '../../cashier/clerk-ui-state.service';
|
||||||
|
import { ChequeBookService } from '../../chequebook/chequebook.service';
|
||||||
|
|
||||||
|
/** Read-only cheque-book view for the account currently loaded on the Clerk Dashboard —
|
||||||
|
* reached via the "Cheque Book" entry in the Account Facilities dropdown. */
|
||||||
|
@Component({
|
||||||
|
selector: 'mifosx-clerk-cheque-book',
|
||||||
|
standalone: true,
|
||||||
|
imports: [CommonModule],
|
||||||
|
styleUrl: './cheque-book.component.scss',
|
||||||
|
template: `
|
||||||
|
<div class="cb-page">
|
||||||
|
<!-- Header -->
|
||||||
|
<div class="cb-header">
|
||||||
|
<div class="cb-header__left">
|
||||||
|
<svg
|
||||||
|
class="cb-header__icon"
|
||||||
|
width="26"
|
||||||
|
height="26"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
fill="none"
|
||||||
|
stroke="currentColor"
|
||||||
|
stroke-width="1.8"
|
||||||
|
>
|
||||||
|
<rect x="1" y="4" width="22" height="16" rx="2" />
|
||||||
|
<line x1="1" y1="10" x2="23" y2="10" />
|
||||||
|
</svg>
|
||||||
|
<div>
|
||||||
|
<div class="cb-header__title">Cheque Book</div>
|
||||||
|
@if (accountLoaded) {
|
||||||
|
<div class="cb-header__sub">
|
||||||
|
{{ accountNo }}
|
||||||
|
@if (clientName) {
|
||||||
|
· {{ clientName }}
|
||||||
|
}
|
||||||
|
@if (books.length > 0) {
|
||||||
|
· {{ books.length }} book{{ books.length === 1 ? '' : 's' }}
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
} @else {
|
||||||
|
<div class="cb-header__sub">View cheque books issued for the loaded account.</div>
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
@if (!embedded) {
|
||||||
|
<button type="button" class="cb-btn" (click)="goBack()">
|
||||||
|
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5">
|
||||||
|
<polyline points="15 18 9 12 15 6" />
|
||||||
|
</svg>
|
||||||
|
Back to Dashboard
|
||||||
|
</button>
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
@if (!accountLoaded) {
|
||||||
|
<div class="cb-empty">
|
||||||
|
<div class="cb-empty__icon">
|
||||||
|
<svg width="48" height="48" viewBox="0 0 24 24" fill="none" stroke="#475569" stroke-width="1.3">
|
||||||
|
<rect x="1" y="4" width="22" height="16" rx="2" />
|
||||||
|
<line x1="1" y1="10" x2="23" y2="10" />
|
||||||
|
</svg>
|
||||||
|
</div>
|
||||||
|
<p class="cb-empty__title">No Account Loaded</p>
|
||||||
|
<p class="cb-empty__sub">Search an account on the Clerk Dashboard first.</p>
|
||||||
|
@if (!embedded) {
|
||||||
|
<button class="cb-empty__btn" (click)="goBack()">Go to Dashboard</button>
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
} @else {
|
||||||
|
@if (!facilityActive) {
|
||||||
|
<div class="cb-warn-banner">Cheque Book facility is not active for this account.</div>
|
||||||
|
}
|
||||||
|
|
||||||
|
@if (loading) {
|
||||||
|
<div class="cb-status-msg">Loading cheque books…</div>
|
||||||
|
} @else if (error) {
|
||||||
|
<div class="cb-status-msg cb-status-msg--error">{{ error }}</div>
|
||||||
|
} @else if (books.length === 0) {
|
||||||
|
<div class="cb-no-books">
|
||||||
|
<svg width="36" height="36" viewBox="0 0 24 24" fill="none" stroke="#cbd5e1" stroke-width="1.3">
|
||||||
|
<rect x="1" y="4" width="22" height="16" rx="2" />
|
||||||
|
<line x1="1" y1="10" x2="23" y2="10" />
|
||||||
|
</svg>
|
||||||
|
<span>No cheque book issued for this account</span>
|
||||||
|
</div>
|
||||||
|
} @else {
|
||||||
|
<div class="cb-table-card">
|
||||||
|
<div class="cb-table-wrap">
|
||||||
|
<table class="cb-table">
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th>Series</th>
|
||||||
|
<th>Books</th>
|
||||||
|
<th>Leaves</th>
|
||||||
|
<th>Status</th>
|
||||||
|
<th>Requested</th>
|
||||||
|
<th></th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
@for (b of books; track b.id) {
|
||||||
|
<tr class="cb-row" (click)="viewBook(b)">
|
||||||
|
<td>{{ b.seriesCode }}</td>
|
||||||
|
<td class="cb-cell--num">{{ b.noOfBooks }}</td>
|
||||||
|
<td class="cb-cell--num">{{ b.totalLeaves ?? '—' }}</td>
|
||||||
|
<td>
|
||||||
|
<span
|
||||||
|
class="cb-pill"
|
||||||
|
[class.cb-pill--ok]="b.status === 'ISSUED'"
|
||||||
|
[class.cb-pill--bad]="b.status === 'REJECTED' || b.status === 'WITHDRAWN'"
|
||||||
|
[class.cb-pill--neutral]="
|
||||||
|
b.status !== 'ISSUED' && b.status !== 'REJECTED' && b.status !== 'WITHDRAWN'
|
||||||
|
"
|
||||||
|
>{{ b.status }}</span
|
||||||
|
>
|
||||||
|
</td>
|
||||||
|
<td class="cb-cell--date">{{ b.requestedDate ?? '—' }}</td>
|
||||||
|
<td class="cb-cell--action">View ›</td>
|
||||||
|
</tr>
|
||||||
|
}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
`
|
||||||
|
})
|
||||||
|
export class ClerkChequeBookComponent implements OnInit {
|
||||||
|
private clerkUiState = inject(ClerkUiStateService);
|
||||||
|
private chequeBookService = inject(ChequeBookService);
|
||||||
|
private router = inject(Router);
|
||||||
|
|
||||||
|
@Input() embedded = false;
|
||||||
|
|
||||||
|
accountLoaded = false;
|
||||||
|
accountNo = '';
|
||||||
|
clientName = '';
|
||||||
|
facilityActive = true;
|
||||||
|
books: any[] = [];
|
||||||
|
loading = false;
|
||||||
|
error = '';
|
||||||
|
|
||||||
|
ngOnInit(): void {
|
||||||
|
const s = this.clerkUiState.snapshot;
|
||||||
|
this.accountLoaded = s.accountLoaded;
|
||||||
|
if (!this.accountLoaded) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
this.accountNo = s.savedAccount?.accountDetails?.accountNo ?? s.savedRow?.acNo ?? '';
|
||||||
|
const clientDetails = s.savedAccount?.clientDetails;
|
||||||
|
this.clientName = clientDetails ? `${clientDetails.firstname ?? ''} ${clientDetails.lastname ?? ''}`.trim() : '';
|
||||||
|
const facility = s.facilities.find((f) => f.label === 'Cheque Book');
|
||||||
|
this.facilityActive = facility ? facility.active : true;
|
||||||
|
|
||||||
|
const accountType = this.toChequeAccountType(s.savedRow?.acTyp);
|
||||||
|
const accountId = s.savedAccount?.accountDetails?.id;
|
||||||
|
if (accountId) {
|
||||||
|
this.loadBooks(accountType, accountId);
|
||||||
|
} else if (this.accountNo) {
|
||||||
|
this.resolveAccountId(accountType, this.accountNo);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Cheque-book series/books are only ever SAVINGS or CURRENT — CA/CB is the clerk's UI code for current accounts. */
|
||||||
|
private toChequeAccountType(acTyp?: string): string {
|
||||||
|
const t = (acTyp || '').toUpperCase();
|
||||||
|
return t === 'CA' || t === 'CB' ? 'CURRENT' : 'SAVINGS';
|
||||||
|
}
|
||||||
|
|
||||||
|
private resolveAccountId(accountType: string, accountNo: string): void {
|
||||||
|
this.loading = true;
|
||||||
|
this.chequeBookService.searchAccountByNo(accountType, accountNo).subscribe({
|
||||||
|
next: (res: any) => {
|
||||||
|
const items = res?.pageItems ?? (Array.isArray(res) ? res : []);
|
||||||
|
const match = items.find((i: any) => i.accountNo === accountNo) ?? (items.length === 1 ? items[0] : null);
|
||||||
|
if (match) {
|
||||||
|
this.loadBooks(accountType, match.id);
|
||||||
|
} else {
|
||||||
|
this.loading = false;
|
||||||
|
this.error = 'Could not resolve this account for cheque book lookup';
|
||||||
|
}
|
||||||
|
},
|
||||||
|
error: () => {
|
||||||
|
this.loading = false;
|
||||||
|
this.error = 'Could not resolve this account for cheque book lookup';
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
private loadBooks(accountType: string, accountId: string | number): void {
|
||||||
|
this.loading = true;
|
||||||
|
this.chequeBookService.getBooks(accountType, accountId, undefined, undefined, 0, 50).subscribe({
|
||||||
|
next: (res: any) => {
|
||||||
|
this.books = res?.pageItems ?? (Array.isArray(res) ? res : []);
|
||||||
|
this.loading = false;
|
||||||
|
},
|
||||||
|
error: () => {
|
||||||
|
this.loading = false;
|
||||||
|
this.error = 'Failed to load cheque book details';
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
viewBook(book: any): void {
|
||||||
|
this.router.navigate([
|
||||||
|
'/clerk/account-facilities/cheque-book',
|
||||||
|
book.id
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
goBack(): void {
|
||||||
|
this.router.navigate(['/clerk/dashboard']);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,87 @@
|
|||||||
|
/**
|
||||||
|
* 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/.
|
||||||
|
*/
|
||||||
|
|
||||||
|
.la-form-grid {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(auto-fit, minmax(180px, 1fr));
|
||||||
|
gap: 14px;
|
||||||
|
padding-top: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.la-field {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 4px;
|
||||||
|
|
||||||
|
&__lbl {
|
||||||
|
font-size: 11px;
|
||||||
|
font-weight: 700;
|
||||||
|
color: #64748b;
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 0.05em;
|
||||||
|
}
|
||||||
|
|
||||||
|
&__input {
|
||||||
|
height: 36px;
|
||||||
|
padding: 0 10px;
|
||||||
|
border: 1px solid #e2e8f0;
|
||||||
|
border-radius: 7px;
|
||||||
|
font-size: 13px;
|
||||||
|
font-family: inherit;
|
||||||
|
color: #0d1b2a;
|
||||||
|
|
||||||
|
&:focus {
|
||||||
|
outline: none;
|
||||||
|
border-color: #0f4f3c;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
&__hint {
|
||||||
|
font-size: 11px;
|
||||||
|
color: #94a3b8;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.la-btn {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 6px;
|
||||||
|
border-radius: 7px;
|
||||||
|
font-family: inherit;
|
||||||
|
font-weight: 600;
|
||||||
|
cursor: pointer;
|
||||||
|
border: 1px solid #e2e8f0;
|
||||||
|
transition: background 0.15s;
|
||||||
|
height: 34px;
|
||||||
|
padding: 0 16px;
|
||||||
|
font-size: 13px;
|
||||||
|
background: #fff;
|
||||||
|
color: #0d1b2a;
|
||||||
|
|
||||||
|
&:hover {
|
||||||
|
background: #f0fdf4;
|
||||||
|
border-color: #0f4f3c;
|
||||||
|
color: #0f4f3c;
|
||||||
|
}
|
||||||
|
|
||||||
|
&--primary {
|
||||||
|
background: #0f4f3c;
|
||||||
|
border-color: #0f4f3c;
|
||||||
|
color: #fff;
|
||||||
|
|
||||||
|
&:hover {
|
||||||
|
background: #0a3d2e;
|
||||||
|
color: #fff;
|
||||||
|
}
|
||||||
|
|
||||||
|
&:disabled {
|
||||||
|
opacity: 0.5;
|
||||||
|
cursor: not-allowed;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,145 @@
|
|||||||
|
/**
|
||||||
|
* 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 { ReactiveFormsModule, UntypedFormBuilder, UntypedFormGroup, Validators } from '@angular/forms';
|
||||||
|
import {
|
||||||
|
MatDialogRef,
|
||||||
|
MAT_DIALOG_DATA,
|
||||||
|
MatDialogTitle,
|
||||||
|
MatDialogContent,
|
||||||
|
MatDialogActions
|
||||||
|
} from '@angular/material/dialog';
|
||||||
|
import { CdkScrollable } from '@angular/cdk/scrolling';
|
||||||
|
import { MatSnackBar } from '@angular/material/snack-bar';
|
||||||
|
import { Observable } from 'rxjs';
|
||||||
|
import { ChequeBookService } from '../../../chequebook/chequebook.service';
|
||||||
|
|
||||||
|
export type LeafAction = 'stoppayment' | 'revokestoppayment' | 'reportlost' | 'cancelleaf' | 'markused' | 'return';
|
||||||
|
|
||||||
|
const ACTION_TITLES: Record<LeafAction, string> = {
|
||||||
|
stoppayment: 'Stop Payment',
|
||||||
|
revokestoppayment: 'Revoke Stop Payment',
|
||||||
|
reportlost: 'Report Lost',
|
||||||
|
cancelleaf: 'Cancel Leaf',
|
||||||
|
markused: 'Mark as Used',
|
||||||
|
return: 'Flag Returned'
|
||||||
|
};
|
||||||
|
|
||||||
|
/** Popup for the five leaf-range actions on a clerk-viewed cheque book — kept as its own dialog so the
|
||||||
|
* action form doesn't push the leaf grid around and always has full width to lay out its fields. */
|
||||||
|
@Component({
|
||||||
|
selector: 'mifosx-clerk-leaf-action-dialog',
|
||||||
|
standalone: true,
|
||||||
|
imports: [
|
||||||
|
ReactiveFormsModule,
|
||||||
|
MatDialogTitle,
|
||||||
|
CdkScrollable,
|
||||||
|
MatDialogContent,
|
||||||
|
MatDialogActions
|
||||||
|
],
|
||||||
|
styleUrl: './leaf-action-dialog.component.scss',
|
||||||
|
template: `
|
||||||
|
<h1 mat-dialog-title>{{ title }}</h1>
|
||||||
|
<div mat-dialog-content>
|
||||||
|
<form [formGroup]="actionForm" class="la-form">
|
||||||
|
<div class="la-form-grid">
|
||||||
|
<label class="la-field">
|
||||||
|
<span class="la-field__lbl">From Leaf Seq</span>
|
||||||
|
<input class="la-field__input" type="number" formControlName="fromLeafSeq" />
|
||||||
|
<span class="la-field__hint">Leave blank for the whole book</span>
|
||||||
|
</label>
|
||||||
|
<label class="la-field">
|
||||||
|
<span class="la-field__lbl">To Leaf Seq</span>
|
||||||
|
<input class="la-field__input" type="number" formControlName="toLeafSeq" />
|
||||||
|
</label>
|
||||||
|
@if (data.action !== 'revokestoppayment') {
|
||||||
|
<label class="la-field">
|
||||||
|
<span class="la-field__lbl">Reason</span>
|
||||||
|
<input class="la-field__input" formControlName="reason" />
|
||||||
|
@if (data.action === 'markused') {
|
||||||
|
<span class="la-field__hint">Optional — e.g. cleared per bank statement dated ...</span>
|
||||||
|
}
|
||||||
|
</label>
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
<mat-dialog-actions align="end">
|
||||||
|
<button type="button" class="la-btn" (click)="dialogRef.close()">Cancel</button>
|
||||||
|
<button type="button" class="la-btn la-btn--primary" [disabled]="actionForm.invalid || busy" (click)="submit()">
|
||||||
|
Submit
|
||||||
|
</button>
|
||||||
|
</mat-dialog-actions>
|
||||||
|
`
|
||||||
|
})
|
||||||
|
export class ClerkLeafActionDialogComponent {
|
||||||
|
dialogRef = inject<MatDialogRef<ClerkLeafActionDialogComponent>>(MatDialogRef);
|
||||||
|
data: { bookId: string; action: LeafAction } = inject(MAT_DIALOG_DATA);
|
||||||
|
private formBuilder = inject(UntypedFormBuilder);
|
||||||
|
private chequeBookService = inject(ChequeBookService);
|
||||||
|
private snackBar = inject(MatSnackBar);
|
||||||
|
|
||||||
|
private notify(message: string, isError = false): void {
|
||||||
|
this.snackBar.open(message, 'Dismiss', { duration: isError ? 6000 : 3000 });
|
||||||
|
}
|
||||||
|
|
||||||
|
busy = false;
|
||||||
|
actionForm: UntypedFormGroup;
|
||||||
|
|
||||||
|
get title(): string {
|
||||||
|
return ACTION_TITLES[this.data.action];
|
||||||
|
}
|
||||||
|
|
||||||
|
constructor() {
|
||||||
|
this.actionForm = this.formBuilder.group({
|
||||||
|
fromLeafSeq: [''],
|
||||||
|
toLeafSeq: [''],
|
||||||
|
reason: [
|
||||||
|
'',
|
||||||
|
this.data.action === 'revokestoppayment' || this.data.action === 'markused' ? [] : Validators.required
|
||||||
|
]
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
submit(): void {
|
||||||
|
if (this.actionForm.invalid || this.busy) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
this.busy = true;
|
||||||
|
const raw = this.actionForm.value;
|
||||||
|
const payload: any = { locale: 'en' };
|
||||||
|
if (raw.fromLeafSeq !== '' && raw.fromLeafSeq != null) payload.fromLeafSeq = raw.fromLeafSeq;
|
||||||
|
if (raw.toLeafSeq !== '' && raw.toLeafSeq != null) payload.toLeafSeq = raw.toLeafSeq;
|
||||||
|
if (raw.reason) payload.reason = raw.reason;
|
||||||
|
|
||||||
|
const actionCalls: Record<LeafAction, (bookId: string, payload: any) => Observable<any>> = {
|
||||||
|
stoppayment: (id, p) => this.chequeBookService.stopPayment(id, p),
|
||||||
|
revokestoppayment: (id, p) => this.chequeBookService.revokeStopPayment(id, p),
|
||||||
|
reportlost: (id, p) => this.chequeBookService.reportBookLost(id, p),
|
||||||
|
cancelleaf: (id, p) => this.chequeBookService.cancelLeaf(id, p),
|
||||||
|
markused: (id, p) => this.chequeBookService.markUsed(id, p),
|
||||||
|
return: (id, p) => this.chequeBookService.returnBook(id, p)
|
||||||
|
};
|
||||||
|
|
||||||
|
actionCalls[this.data.action](this.data.bookId, payload).subscribe({
|
||||||
|
next: () => {
|
||||||
|
this.busy = false;
|
||||||
|
this.notify('Action applied');
|
||||||
|
this.dialogRef.close({ success: true });
|
||||||
|
},
|
||||||
|
error: () => {
|
||||||
|
this.busy = false;
|
||||||
|
this.notify(
|
||||||
|
'Action failed — check the leaf range and that the leaves are in a state that allows this action',
|
||||||
|
true
|
||||||
|
);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,77 @@
|
|||||||
|
/**
|
||||||
|
* Copyright since 2025 Mifos Initiative
|
||||||
|
*
|
||||||
|
* This Source Code Form is subject to the terms of the Mozilla Public
|
||||||
|
* License, v. 2.0. If a copy of the MPL was not distributed with this
|
||||||
|
* file, You can obtain one at http://mozilla.org/MPL/2.0/.
|
||||||
|
*/
|
||||||
|
|
||||||
|
/* Small shared layout/status-pill classes for the Share Withdrawal maker-checker screens —
|
||||||
|
* self-contained (this branch has no separate "investment" module to borrow them from). */
|
||||||
|
|
||||||
|
.inv-form-grid {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(auto-fit, minmax(180px, 1fr));
|
||||||
|
gap: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.inv-form-field {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 4px;
|
||||||
|
font-size: 11px;
|
||||||
|
font-weight: 600;
|
||||||
|
color: #64748b;
|
||||||
|
|
||||||
|
input,
|
||||||
|
select,
|
||||||
|
textarea {
|
||||||
|
height: 36px;
|
||||||
|
padding: 0 10px;
|
||||||
|
border: 1px solid #e2e8f0;
|
||||||
|
border-radius: 8px;
|
||||||
|
font-size: 13px;
|
||||||
|
color: #0d1b2a;
|
||||||
|
font-family: inherit;
|
||||||
|
background: #fff;
|
||||||
|
box-sizing: border-box;
|
||||||
|
}
|
||||||
|
|
||||||
|
textarea {
|
||||||
|
height: auto;
|
||||||
|
padding: 8px 10px;
|
||||||
|
resize: vertical;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.inv-form-actions {
|
||||||
|
display: flex;
|
||||||
|
gap: 10px;
|
||||||
|
padding: 12px 16px 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.inv-badge {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
padding: 3px 10px;
|
||||||
|
border-radius: 999px;
|
||||||
|
font-size: 11px;
|
||||||
|
font-weight: 700;
|
||||||
|
background: #f1f5f9;
|
||||||
|
color: #475569;
|
||||||
|
|
||||||
|
&--active {
|
||||||
|
background: #fef3c7;
|
||||||
|
color: #92400e;
|
||||||
|
}
|
||||||
|
|
||||||
|
&--matured {
|
||||||
|
background: #dcfce7;
|
||||||
|
color: #166534;
|
||||||
|
}
|
||||||
|
|
||||||
|
&--written_off {
|
||||||
|
background: #fee2e2;
|
||||||
|
color: #991b1b;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
+73
@@ -0,0 +1,73 @@
|
|||||||
|
<!--
|
||||||
|
Copyright since 2025 Mifos Initiative
|
||||||
|
|
||||||
|
This Source Code Form is subject to the terms of the Mozilla Public
|
||||||
|
License, v. 2.0. If a copy of the MPL was not distributed with this
|
||||||
|
file, You can obtain one at http://mozilla.org/MPL/2.0/.
|
||||||
|
-->
|
||||||
|
|
||||||
|
<div class="pg">
|
||||||
|
<header class="pg-head">
|
||||||
|
<div>
|
||||||
|
<div class="pg-eyebrow">CLERK · SHARE WITHDRAWAL</div>
|
||||||
|
<h1 class="pg-title">My Share Requests</h1>
|
||||||
|
<p class="pg-sub">Share withdrawal requests you have submitted, and their approval status.</p>
|
||||||
|
</div>
|
||||||
|
<div class="pg-toolbar">
|
||||||
|
<button type="button" class="pg-btn pg-btn--primary" (click)="newRequest()">New Share Withdrawal</button>
|
||||||
|
</div>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<div class="pg-card">
|
||||||
|
<div class="inv-form-grid" style="padding: 12px 16px">
|
||||||
|
<label class="inv-form-field">
|
||||||
|
<span>Status</span>
|
||||||
|
<select [(ngModel)]="filterStatus" (change)="onFilterChange()">
|
||||||
|
@for (opt of statusOptions; track opt.value) {
|
||||||
|
<option [value]="opt.value">{{ opt.label }}</option>
|
||||||
|
}
|
||||||
|
</select>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
<table class="pg-table">
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th>Account No</th>
|
||||||
|
<th>Client</th>
|
||||||
|
<th class="num">Shares</th>
|
||||||
|
<th>Resolution No.</th>
|
||||||
|
<th>Meeting Date</th>
|
||||||
|
<th>Submitted At</th>
|
||||||
|
<th>Status</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
@if (loading) {
|
||||||
|
<tr>
|
||||||
|
<td colspan="7" class="pg-empty">Loading…</td>
|
||||||
|
</tr>
|
||||||
|
} @else if (loaded && requests.length === 0) {
|
||||||
|
<tr>
|
||||||
|
<td colspan="7" class="pg-empty">No share withdrawal requests found.</td>
|
||||||
|
</tr>
|
||||||
|
} @else {
|
||||||
|
@for (r of requests; track r.id) {
|
||||||
|
<tr>
|
||||||
|
<td>
|
||||||
|
<span class="pg-chip">{{ r.account_no }}</span>
|
||||||
|
</td>
|
||||||
|
<td>{{ r.client_name }}</td>
|
||||||
|
<td class="num">{{ r.requested_shares }}</td>
|
||||||
|
<td>{{ r.resolution_number }}</td>
|
||||||
|
<td>{{ formatDateTime(r.meeting_date) }}</td>
|
||||||
|
<td>{{ formatDateTime(r.submitted_at) }}</td>
|
||||||
|
<td>
|
||||||
|
<span [class]="statusClass(r.status)">{{ statusLabel(r.status) }}</span>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
@@ -0,0 +1,112 @@
|
|||||||
|
/**
|
||||||
|
* Copyright since 2025 Mifos Initiative
|
||||||
|
*
|
||||||
|
* This Source Code Form is subject to the terms of the Mozilla Public
|
||||||
|
* License, v. 2.0. If a copy of the MPL was not distributed with this
|
||||||
|
* file, You can obtain one at http://mozilla.org/MPL/2.0/.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { Component, OnInit, inject } from '@angular/core';
|
||||||
|
import { CommonModule } from '@angular/common';
|
||||||
|
import { FormsModule } from '@angular/forms';
|
||||||
|
import { Router, RouterModule } from '@angular/router';
|
||||||
|
import { catchError } from 'rxjs/operators';
|
||||||
|
import { of } from 'rxjs';
|
||||||
|
|
||||||
|
import { ClerkShareWithdrawalService } from '../../cashier/clerk-share-withdrawal.service';
|
||||||
|
|
||||||
|
/** Clerk Dashboard "My Share Requests" — lists the share withdrawal requests this clerk has
|
||||||
|
* submitted and their approval status. Mirrors MyRequestsComponent/MyDisbursementRequestsComponent. */
|
||||||
|
@Component({
|
||||||
|
selector: 'mifosx-my-share-withdrawal-requests',
|
||||||
|
templateUrl: './my-share-withdrawal-requests.component.html',
|
||||||
|
styleUrls: [
|
||||||
|
'../../cashier/cashier-shared.scss',
|
||||||
|
'../clerk-shared.scss'
|
||||||
|
],
|
||||||
|
standalone: true,
|
||||||
|
imports: [
|
||||||
|
CommonModule,
|
||||||
|
FormsModule,
|
||||||
|
RouterModule
|
||||||
|
]
|
||||||
|
})
|
||||||
|
export class MyShareWithdrawalRequestsComponent implements OnInit {
|
||||||
|
private service = inject(ClerkShareWithdrawalService);
|
||||||
|
private router = inject(Router);
|
||||||
|
|
||||||
|
requests: any[] = [];
|
||||||
|
totalRecords = 0;
|
||||||
|
loading = false;
|
||||||
|
loaded = false;
|
||||||
|
|
||||||
|
filterStatus = '';
|
||||||
|
|
||||||
|
get statusOptions(): { value: string; label: string }[] {
|
||||||
|
return [
|
||||||
|
{ value: '', label: 'All' },
|
||||||
|
{ value: 'PENDING', label: 'Pending' },
|
||||||
|
{ value: 'APPROVED', label: 'Approved' },
|
||||||
|
{ value: 'REJECTED', label: 'Rejected' }
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
ngOnInit(): void {
|
||||||
|
this.load();
|
||||||
|
}
|
||||||
|
|
||||||
|
load(): void {
|
||||||
|
this.loading = true;
|
||||||
|
this.service
|
||||||
|
.getMyRequests(this.filterStatus || undefined, 0, 50)
|
||||||
|
.pipe(catchError(() => of({ pageItems: [], totalFilteredRecords: 0 })))
|
||||||
|
.subscribe((res: any) => {
|
||||||
|
this.requests = res?.pageItems ?? [];
|
||||||
|
this.totalRecords = Number(res?.totalFilteredRecords ?? 0);
|
||||||
|
this.loading = false;
|
||||||
|
this.loaded = true;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
onFilterChange(): void {
|
||||||
|
this.load();
|
||||||
|
}
|
||||||
|
|
||||||
|
newRequest(): void {
|
||||||
|
this.router.navigate(['/clerk/share-withdrawal-request']);
|
||||||
|
}
|
||||||
|
|
||||||
|
statusClass(status: string): string {
|
||||||
|
const map: Record<string, string> = {
|
||||||
|
PENDING: 'inv-badge--active',
|
||||||
|
APPROVED: 'inv-badge--matured',
|
||||||
|
REJECTED: 'inv-badge--written_off'
|
||||||
|
};
|
||||||
|
return `inv-badge ${map[status] ?? ''}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
statusLabel(status: string): string {
|
||||||
|
const map: Record<string, string> = {
|
||||||
|
PENDING: 'Pending Approval',
|
||||||
|
APPROVED: 'Approved',
|
||||||
|
REJECTED: 'Rejected'
|
||||||
|
};
|
||||||
|
return map[status] ?? status;
|
||||||
|
}
|
||||||
|
|
||||||
|
formatDateTime(val: any): string {
|
||||||
|
if (!val) return '—';
|
||||||
|
try {
|
||||||
|
const d = new Date(val);
|
||||||
|
return d.toLocaleString('en-IN', {
|
||||||
|
day: '2-digit',
|
||||||
|
month: 'short',
|
||||||
|
year: 'numeric',
|
||||||
|
hour: '2-digit',
|
||||||
|
minute: '2-digit'
|
||||||
|
});
|
||||||
|
} catch {
|
||||||
|
return String(val);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,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']);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,431 @@
|
|||||||
|
<!--
|
||||||
|
Copyright since 2025 Mifos Initiative
|
||||||
|
|
||||||
|
This Source Code Form is subject to the terms of the Mozilla Public
|
||||||
|
License, v. 2.0. If a copy of the MPL was not distributed with this
|
||||||
|
file, You can obtain one at http://mozilla.org/MPL/2.0/.
|
||||||
|
-->
|
||||||
|
|
||||||
|
<div class="pg">
|
||||||
|
<header class="pg-head">
|
||||||
|
<div>
|
||||||
|
<div class="pg-eyebrow">{{ isReview ? 'CHECKER · APPROVAL' : 'CLERK · SHARE WITHDRAWAL' }}</div>
|
||||||
|
<h1 class="pg-title">{{ pageTitle }}</h1>
|
||||||
|
<p class="pg-sub">{{ pageSubtitle }}</p>
|
||||||
|
</div>
|
||||||
|
<div class="pg-toolbar">
|
||||||
|
<button type="button" class="pg-btn" (click)="goBack()">
|
||||||
|
<mat-icon>arrow_back</mat-icon>
|
||||||
|
{{ isReview ? 'Back to Requests' : 'My Share Requests' }}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<div class="pg-card">
|
||||||
|
<div class="pos-section-label">Share Account</div>
|
||||||
|
<div class="inv-form-grid" style="padding: 16px">
|
||||||
|
@if (!isReview) {
|
||||||
|
<label class="inv-form-field" style="grid-column: span 2">
|
||||||
|
<span>* Search Account No. / Client Name</span>
|
||||||
|
<mifosx-search-select
|
||||||
|
[options]="accountOptions"
|
||||||
|
[(ngModel)]="selectedAccountId"
|
||||||
|
(ngModelChange)="onAccountSelected($event)"
|
||||||
|
placeholder="Search share account no. or client name…"
|
||||||
|
></mifosx-search-select>
|
||||||
|
</label>
|
||||||
|
}
|
||||||
|
@if (account) {
|
||||||
|
<label class="inv-form-field">
|
||||||
|
<span>Account No</span>
|
||||||
|
<input type="text" [value]="account.accountNo" readonly />
|
||||||
|
</label>
|
||||||
|
<label class="inv-form-field">
|
||||||
|
<span>Client</span>
|
||||||
|
<input type="text" [value]="account.clientName" readonly />
|
||||||
|
</label>
|
||||||
|
<label class="inv-form-field">
|
||||||
|
<span>Shares Held</span>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
[value]="account.summary?.totalApprovedShares ?? account.totalApprovedShares ?? 0"
|
||||||
|
readonly
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
<label class="inv-form-field">
|
||||||
|
<span>Unit Price</span>
|
||||||
|
<input type="text" [value]="'₹' + (unitPrice | number: '1.2-2')" readonly />
|
||||||
|
</label>
|
||||||
|
}
|
||||||
|
@if (loadingAccount) {
|
||||||
|
<div class="pg-empty">Loading account…</div>
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
@if (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">Withdrawal Details</div>
|
||||||
|
<div class="inv-form-grid" style="padding: 16px">
|
||||||
|
<label class="inv-form-field">
|
||||||
|
<span>* Shares to Withdraw</span>
|
||||||
|
<input type="number" [(ngModel)]="requestedShares" min="1" [disabled]="isReview" />
|
||||||
|
</label>
|
||||||
|
<label class="inv-form-field">
|
||||||
|
<span>* Requested Date</span>
|
||||||
|
<input type="date" [(ngModel)]="requestedDate" [disabled]="isReview" />
|
||||||
|
</label>
|
||||||
|
<label class="inv-form-field">
|
||||||
|
<span>Withdrawal Value</span>
|
||||||
|
<input type="text" [value]="'₹' + (withdrawalValue | number: '1.2-2')" readonly />
|
||||||
|
</label>
|
||||||
|
<label class="inv-form-field">
|
||||||
|
<span>Remaining Shares After Withdrawal</span>
|
||||||
|
<input type="text" [value]="remainingShares" readonly />
|
||||||
|
</label>
|
||||||
|
<label class="inv-form-field">
|
||||||
|
<span>* Board Resolution Number</span>
|
||||||
|
<input type="text" [(ngModel)]="resolutionNumber" [disabled]="isReview" placeholder="e.g. BR-2026-014" />
|
||||||
|
</label>
|
||||||
|
<label class="inv-form-field">
|
||||||
|
<span>* Date of Meeting</span>
|
||||||
|
<input type="date" [(ngModel)]="meetingDate" [disabled]="isReview" />
|
||||||
|
</label>
|
||||||
|
<label class="inv-form-field">
|
||||||
|
<span>* Settlement Payment Type</span>
|
||||||
|
@if (!isReview) {
|
||||||
|
<select [(ngModel)]="settlementPaymentTypeId">
|
||||||
|
<option [ngValue]="null">Select…</option>
|
||||||
|
@for (pt of paymentTypeOptions; track pt.id) {
|
||||||
|
<option [ngValue]="pt.id">{{ pt.name }}</option>
|
||||||
|
}
|
||||||
|
</select>
|
||||||
|
} @else {
|
||||||
|
<input type="text" [value]="settlementPaymentTypeName" readonly />
|
||||||
|
}
|
||||||
|
</label>
|
||||||
|
<label class="inv-form-field" style="grid-column: span 2">
|
||||||
|
<span>Description / Remarks</span>
|
||||||
|
<input type="text" [(ngModel)]="description" [disabled]="isReview" />
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
@if (!isReview) {
|
||||||
|
<div class="inv-form-actions">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="pg-btn pg-btn--primary"
|
||||||
|
[disabled]="!isFormValid || submitting"
|
||||||
|
(click)="openConfirmDialog()"
|
||||||
|
>
|
||||||
|
{{ submitting ? 'Submitting…' : 'Review & Submit' }}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
|
||||||
|
@if (isReview && reviewRequest?.status === 'PENDING') {
|
||||||
|
<div class="pg-card">
|
||||||
|
<div class="pos-section-label">Verification</div>
|
||||||
|
<div class="swrv-trigger">
|
||||||
|
<div class="swrv-trigger__icon">
|
||||||
|
<mat-icon>fact_check</mat-icon>
|
||||||
|
</div>
|
||||||
|
<div class="swrv-trigger__text">
|
||||||
|
<div class="swrv-trigger__title">Run a live backend validation</div>
|
||||||
|
<div class="swrv-trigger__sub">
|
||||||
|
Re-checks account status, share balance, linked savings account and GL mapping before you approve.
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<button type="button" class="pg-btn pg-btn--primary" (click)="openValidateModal()">
|
||||||
|
<mat-icon>play_circle</mat-icon>
|
||||||
|
Validate Request
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="inv-form-actions">
|
||||||
|
<button type="button" class="pg-btn pg-btn--primary" (click)="approveReview()">
|
||||||
|
<mat-icon>check_circle</mat-icon>
|
||||||
|
Approve & Process Withdrawal
|
||||||
|
</button>
|
||||||
|
<button type="button" class="pg-btn pg-btn--danger" (click)="openRejectDialog()">
|
||||||
|
<mat-icon>cancel</mat-icon>
|
||||||
|
Reject
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
|
||||||
|
@if (showValidateModal) {
|
||||||
|
<div class="swrv-modal">
|
||||||
|
<div class="swrv-modal__card">
|
||||||
|
<div class="swrv-modal__header">
|
||||||
|
<div class="swrv-modal__title">
|
||||||
|
<span class="swrv-modal__title-icon"><mat-icon>fact_check</mat-icon></span>
|
||||||
|
Validate Share Withdrawal Request
|
||||||
|
</div>
|
||||||
|
<button type="button" class="swrv-modal__close" [disabled]="validating" (click)="closeValidateModal()">
|
||||||
|
<mat-icon>close</mat-icon>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="swrv-modal__body">
|
||||||
|
<p class="swrv-modal__intro">
|
||||||
|
Re-checking <strong>{{ requestedShares }} share(s)</strong> withdrawal from
|
||||||
|
<strong>{{ account?.accountNo }}</strong> ({{ account?.clientName }}) against the live account, savings link
|
||||||
|
and GL configuration — not just what was captured at submission time.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
@if (backendChecks && !validating && !validateAllPassed) {
|
||||||
|
<div class="swrv-modal__warn">
|
||||||
|
<mat-icon>warning</mat-icon>
|
||||||
|
<div>
|
||||||
|
<strong>Some checks need review before approving.</strong>
|
||||||
|
Scroll down to see which ones failed and why.
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
|
||||||
|
<ol class="swrv-steps">
|
||||||
|
@for (step of validateSteps; track step.label; let i = $index) {
|
||||||
|
<li
|
||||||
|
class="swrv-step"
|
||||||
|
[class.swrv-step--active]="step.status === 'active'"
|
||||||
|
[class.swrv-step--done]="step.status === 'done'"
|
||||||
|
[class.swrv-step--failed]="step.status === 'failed'"
|
||||||
|
>
|
||||||
|
<div class="swrv-step__indicator">
|
||||||
|
@if (step.status === 'done') {
|
||||||
|
<mat-icon>check</mat-icon>
|
||||||
|
} @else if (step.status === 'active') {
|
||||||
|
<mat-icon class="swrv-spin">autorenew</mat-icon>
|
||||||
|
} @else if (step.status === 'failed') {
|
||||||
|
<mat-icon>close</mat-icon>
|
||||||
|
} @else {
|
||||||
|
<span>{{ i + 1 }}</span>
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
<div class="swrv-step__content">
|
||||||
|
<div class="swrv-step__label">{{ step.label }}</div>
|
||||||
|
<div class="swrv-step__msg">{{ step.detail }}</div>
|
||||||
|
</div>
|
||||||
|
</li>
|
||||||
|
}
|
||||||
|
</ol>
|
||||||
|
|
||||||
|
@if (validating && validateSteps.length === 0) {
|
||||||
|
<div class="pg-empty">Connecting to server…</div>
|
||||||
|
}
|
||||||
|
|
||||||
|
@if (backendChecks && !validating && validateAllPassed) {
|
||||||
|
<div class="swrv-modal__result">
|
||||||
|
<mat-icon>verified</mat-icon>
|
||||||
|
<span>All checks passed — safe to approve.</span>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="swrv-modal__footer">
|
||||||
|
@if (!validating && backendChecks) {
|
||||||
|
<button type="button" class="pg-btn" (click)="runBackendValidation()">
|
||||||
|
<mat-icon>refresh</mat-icon>
|
||||||
|
Re-run
|
||||||
|
</button>
|
||||||
|
}
|
||||||
|
@if (!validating && backendChecks && validateAllPassed) {
|
||||||
|
<button type="button" class="pg-btn pg-btn--primary" (click)="closeValidateModal(); approveReview()">
|
||||||
|
<mat-icon>check_circle</mat-icon>
|
||||||
|
Approve & Process Withdrawal
|
||||||
|
</button>
|
||||||
|
}
|
||||||
|
<button type="button" class="pg-btn" [disabled]="validating" (click)="closeValidateModal()">
|
||||||
|
{{ validating ? 'Validating…' : 'Close' }}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
|
||||||
|
@if (isReview && reviewRequest?.status === 'APPROVED') {
|
||||||
|
<div class="pg-card">
|
||||||
|
<div class="pos-section-label">Settlement</div>
|
||||||
|
<div class="inv-form-grid" style="padding: 16px">
|
||||||
|
@if (reviewRequest?.settlement_amount) {
|
||||||
|
<label class="inv-form-field">
|
||||||
|
<span>Amount Credited</span>
|
||||||
|
<input type="text" [value]="'₹' + (reviewRequest.settlement_amount | number: '1.2-2')" readonly />
|
||||||
|
</label>
|
||||||
|
<label class="inv-form-field">
|
||||||
|
<span>Credited To (Linked Savings A/C)</span>
|
||||||
|
<input type="text" [value]="account?.savingsAccountNumber || '—'" readonly />
|
||||||
|
</label>
|
||||||
|
<label class="inv-form-field">
|
||||||
|
<span>Savings Transaction Ref.</span>
|
||||||
|
<input type="text" [value]="reviewRequest.settlement_savings_transaction_id" readonly />
|
||||||
|
</label>
|
||||||
|
} @else {
|
||||||
|
<div class="pg-empty" style="grid-column: span 2">
|
||||||
|
Settlement is processed automatically on approval. Details aren't available when reopening an
|
||||||
|
already-approved request — check the linked savings account's transaction history to confirm.
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
|
||||||
|
@if (isReview && activityLog.length > 0) {
|
||||||
|
<div class="pg-card">
|
||||||
|
<div class="pos-section-label">Activity Log</div>
|
||||||
|
<table class="pg-table">
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th>Timestamp</th>
|
||||||
|
<th>Action</th>
|
||||||
|
<th>User</th>
|
||||||
|
<th>Note</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
@for (entry of activityLog; track entry.timestamp) {
|
||||||
|
<tr>
|
||||||
|
<td>{{ entry.timestamp | date: 'medium' }}</td>
|
||||||
|
<td>{{ entry.action }}</td>
|
||||||
|
<td>{{ entry.user }}</td>
|
||||||
|
<td>{{ entry.note || '—' }}</td>
|
||||||
|
</tr>
|
||||||
|
}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
|
||||||
|
@if (showConfirmDialog) {
|
||||||
|
<div class="swr-modal-backdrop">
|
||||||
|
<div class="swr-modal">
|
||||||
|
<h3>Confirm Share Withdrawal Request</h3>
|
||||||
|
<p class="pg-sub">Review the details below before submitting for Bank Manager approval.</p>
|
||||||
|
<table class="pg-table">
|
||||||
|
<tbody>
|
||||||
|
<tr>
|
||||||
|
<td>Account No / Client</td>
|
||||||
|
<td>{{ account?.accountNo }} — {{ account?.clientName }}</td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<td>Shares to Withdraw</td>
|
||||||
|
<td>
|
||||||
|
{{ requestedShares }} @ ₹{{ unitPrice | number: '1.2-2' }} = ₹{{ withdrawalValue | number: '1.2-2' }}
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<td>Remaining Shares After Withdrawal</td>
|
||||||
|
<td>{{ remainingShares }}</td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<td>Requested Date</td>
|
||||||
|
<td>{{ requestedDate }}</td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<td>Board Resolution Number</td>
|
||||||
|
<td>{{ resolutionNumber }}</td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<td>Date of Meeting</td>
|
||||||
|
<td>{{ meetingDate }}</td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<td>Settlement Payment Type</td>
|
||||||
|
<td>{{ settlementPaymentTypeName }}</td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<td>Linked Savings Account</td>
|
||||||
|
<td>{{ account?.savingsAccountNumber || 'None — settlement will not run automatically' }}</td>
|
||||||
|
</tr>
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
|
||||||
|
<div class="pos-section-label" style="margin-top: 12px">Validation Checks</div>
|
||||||
|
<table class="pg-table">
|
||||||
|
<tbody>
|
||||||
|
@for (check of verificationChecks; track check.label) {
|
||||||
|
<tr>
|
||||||
|
<td>{{ check.label }}</td>
|
||||||
|
<td [class.swr-check-pass]="check.pass" [class.swr-check-fail]="!check.pass">
|
||||||
|
{{ check.pass ? 'Pass — ' : 'Review — ' }}{{ check.detail }}
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
|
||||||
|
<div class="inv-form-actions">
|
||||||
|
<button type="button" class="pg-btn pg-btn--primary" [disabled]="submitting" (click)="confirmSubmit()">
|
||||||
|
{{ submitting ? 'Submitting…' : 'Confirm & Submit' }}
|
||||||
|
</button>
|
||||||
|
<button type="button" class="pg-btn" (click)="cancelConfirmDialog()">Back to Edit</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
|
||||||
|
@if (showRejectDialog) {
|
||||||
|
<div class="swr-modal-backdrop">
|
||||||
|
<div class="swr-modal">
|
||||||
|
<h3>Reject Share Withdrawal Request</h3>
|
||||||
|
<label class="inv-form-field">
|
||||||
|
<span>* Rejection Reason</span>
|
||||||
|
<textarea rows="3" [(ngModel)]="rejectionReason"></textarea>
|
||||||
|
</label>
|
||||||
|
<div class="inv-form-actions">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="pg-btn pg-btn--danger"
|
||||||
|
[disabled]="!rejectionReason.trim()"
|
||||||
|
(click)="confirmRejectReview()"
|
||||||
|
>
|
||||||
|
Confirm Reject
|
||||||
|
</button>
|
||||||
|
<button type="button" class="pg-btn" (click)="showRejectDialog = false">Cancel</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
</div>
|
||||||
@@ -0,0 +1,433 @@
|
|||||||
|
/**
|
||||||
|
* Copyright since 2025 Mifos Initiative
|
||||||
|
*
|
||||||
|
* This Source Code Form is subject to the terms of the Mozilla Public
|
||||||
|
* License, v. 2.0. If a copy of the MPL was not distributed with this
|
||||||
|
* file, You can obtain one at http://mozilla.org/MPL/2.0/.
|
||||||
|
*/
|
||||||
|
|
||||||
|
.swr-modal-backdrop {
|
||||||
|
position: fixed;
|
||||||
|
inset: 0;
|
||||||
|
background: rgb(13 27 42 / 55%);
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
z-index: 1000;
|
||||||
|
}
|
||||||
|
|
||||||
|
.swr-modal {
|
||||||
|
background: #fff;
|
||||||
|
border-radius: 12px;
|
||||||
|
padding: 20px;
|
||||||
|
width: 420px;
|
||||||
|
max-width: 90vw;
|
||||||
|
max-height: 85vh;
|
||||||
|
overflow-y: auto;
|
||||||
|
box-shadow: 0 12px 32px rgb(15 23 42 / 20%);
|
||||||
|
|
||||||
|
h3 {
|
||||||
|
margin: 0 0 12px;
|
||||||
|
font-size: 16px;
|
||||||
|
color: #0d1b2a;
|
||||||
|
}
|
||||||
|
|
||||||
|
textarea {
|
||||||
|
width: 100%;
|
||||||
|
padding: 8px 10px;
|
||||||
|
border: 1px solid #e2e8f0;
|
||||||
|
border-radius: 8px;
|
||||||
|
font-family: inherit;
|
||||||
|
font-size: 13px;
|
||||||
|
resize: vertical;
|
||||||
|
box-sizing: border-box;
|
||||||
|
}
|
||||||
|
|
||||||
|
&:has(table) {
|
||||||
|
width: 560px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.swr-check-pass {
|
||||||
|
color: #1a7f37;
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
|
||||||
|
.swr-check-fail {
|
||||||
|
color: #b42318;
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
|
||||||
|
.swr-chip {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
width: fit-content;
|
||||||
|
padding: 4px 10px;
|
||||||
|
border-radius: 999px;
|
||||||
|
font-size: 12px;
|
||||||
|
font-weight: 600;
|
||||||
|
|
||||||
|
&--ok {
|
||||||
|
background: #eafaf0;
|
||||||
|
color: #1a7f37;
|
||||||
|
}
|
||||||
|
|
||||||
|
&--warn {
|
||||||
|
background: #fdecea;
|
||||||
|
color: #b42318;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ---------- Verification trigger card ---------- */
|
||||||
|
.swrv-trigger {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 14px;
|
||||||
|
padding: 16px;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
|
||||||
|
&__icon {
|
||||||
|
width: 42px;
|
||||||
|
height: 42px;
|
||||||
|
border-radius: 12px;
|
||||||
|
background: linear-gradient(135deg, #3457d5, #6c8cff);
|
||||||
|
color: #fff;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
flex-shrink: 0;
|
||||||
|
box-shadow: 0 4px 12px rgb(52 87 213 / 30%);
|
||||||
|
}
|
||||||
|
|
||||||
|
&__text {
|
||||||
|
flex: 1;
|
||||||
|
min-width: 200px;
|
||||||
|
}
|
||||||
|
|
||||||
|
&__title {
|
||||||
|
font-size: 13px;
|
||||||
|
font-weight: 700;
|
||||||
|
color: #0d1b2a;
|
||||||
|
}
|
||||||
|
|
||||||
|
&__sub {
|
||||||
|
font-size: 12px;
|
||||||
|
color: #64748b;
|
||||||
|
margin-top: 2px;
|
||||||
|
line-height: 1.4;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ---------- Validate pipeline modal ---------- */
|
||||||
|
@keyframes swrv-fade {
|
||||||
|
from {
|
||||||
|
opacity: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
to {
|
||||||
|
opacity: 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes swrv-zoom {
|
||||||
|
from {
|
||||||
|
opacity: 0;
|
||||||
|
transform: scale(0.95) translateY(8px);
|
||||||
|
}
|
||||||
|
|
||||||
|
to {
|
||||||
|
opacity: 1;
|
||||||
|
transform: scale(1) translateY(0);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes swrv-spin {
|
||||||
|
from {
|
||||||
|
transform: rotate(0deg);
|
||||||
|
}
|
||||||
|
|
||||||
|
to {
|
||||||
|
transform: rotate(360deg);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.swrv-spin {
|
||||||
|
animation: swrv-spin 0.9s linear infinite;
|
||||||
|
}
|
||||||
|
|
||||||
|
.swrv-modal {
|
||||||
|
position: fixed;
|
||||||
|
inset: 0;
|
||||||
|
// No backdrop-filter: it would make this element a containing block for any position:fixed
|
||||||
|
// descendant (e.g. if this modal is ever nested inside another fixed popup, or gains one of its
|
||||||
|
// own later) — see the identical fix in share-withdrawal-requests.component.scss for the bug this
|
||||||
|
// caused. Extra opacity compensates for the loss of the blur look.
|
||||||
|
background: rgb(15 25 45 / 65%);
|
||||||
|
z-index: 1200;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
padding: 20px;
|
||||||
|
animation: swrv-fade 0.2s ease-out;
|
||||||
|
|
||||||
|
&__card {
|
||||||
|
background: #fff;
|
||||||
|
border-radius: 16px;
|
||||||
|
width: 100%;
|
||||||
|
max-width: 520px;
|
||||||
|
max-height: 90vh;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
overflow: hidden;
|
||||||
|
box-shadow: 0 24px 60px rgb(0 0 0 / 25%);
|
||||||
|
animation: swrv-zoom 0.25s ease-out;
|
||||||
|
}
|
||||||
|
|
||||||
|
&__header {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
padding: 18px 22px;
|
||||||
|
border-bottom: 1px solid #eef1f5;
|
||||||
|
}
|
||||||
|
|
||||||
|
&__title {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 10px;
|
||||||
|
font-size: 15px;
|
||||||
|
font-weight: 700;
|
||||||
|
color: #0d1b2a;
|
||||||
|
}
|
||||||
|
|
||||||
|
&__title-icon {
|
||||||
|
width: 34px;
|
||||||
|
height: 34px;
|
||||||
|
border-radius: 10px;
|
||||||
|
background: linear-gradient(135deg, #3457d5, #6c8cff);
|
||||||
|
color: #fff;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
&__close {
|
||||||
|
width: 32px;
|
||||||
|
height: 32px;
|
||||||
|
border-radius: 8px;
|
||||||
|
border: 1px solid #e2e8f0;
|
||||||
|
background: #fff;
|
||||||
|
color: #64748b;
|
||||||
|
cursor: pointer;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
transition: all 0.12s;
|
||||||
|
|
||||||
|
&:not(:disabled):hover {
|
||||||
|
color: #b42318;
|
||||||
|
border-color: #b42318;
|
||||||
|
background: #fdecea;
|
||||||
|
}
|
||||||
|
|
||||||
|
&:disabled {
|
||||||
|
opacity: 0.4;
|
||||||
|
cursor: not-allowed;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
&__body {
|
||||||
|
padding: 20px 22px;
|
||||||
|
overflow-y: auto;
|
||||||
|
flex: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
&__intro {
|
||||||
|
font-size: 13px;
|
||||||
|
color: #475569;
|
||||||
|
line-height: 1.55;
|
||||||
|
margin: 0 0 16px;
|
||||||
|
|
||||||
|
strong {
|
||||||
|
color: #0d1b2a;
|
||||||
|
font-weight: 700;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
&__warn {
|
||||||
|
display: flex;
|
||||||
|
gap: 10px;
|
||||||
|
padding: 12px 14px;
|
||||||
|
background: #fef3e2;
|
||||||
|
border: 1px solid rgb(217 119 6 / 30%);
|
||||||
|
border-left: 3px solid #d97706;
|
||||||
|
border-radius: 8px;
|
||||||
|
margin-bottom: 16px;
|
||||||
|
font-size: 12px;
|
||||||
|
color: #633806;
|
||||||
|
line-height: 1.5;
|
||||||
|
|
||||||
|
mat-icon {
|
||||||
|
color: #d97706;
|
||||||
|
font-size: 18px;
|
||||||
|
width: 18px;
|
||||||
|
height: 18px;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
strong {
|
||||||
|
display: block;
|
||||||
|
font-weight: 700;
|
||||||
|
color: #4d2a05;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
&__result {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
margin-top: 6px;
|
||||||
|
padding: 12px 14px;
|
||||||
|
background: #eafaf0;
|
||||||
|
border: 1px solid rgb(26 127 55 / 25%);
|
||||||
|
border-radius: 8px;
|
||||||
|
font-size: 13px;
|
||||||
|
font-weight: 600;
|
||||||
|
color: #1a7f37;
|
||||||
|
|
||||||
|
mat-icon {
|
||||||
|
font-size: 18px;
|
||||||
|
width: 18px;
|
||||||
|
height: 18px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
&__footer {
|
||||||
|
padding: 16px 22px;
|
||||||
|
border-top: 1px solid #eef1f5;
|
||||||
|
display: flex;
|
||||||
|
justify-content: flex-end;
|
||||||
|
gap: 10px;
|
||||||
|
background: #f8fafc;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.swrv-steps {
|
||||||
|
list-style: none;
|
||||||
|
margin: 0;
|
||||||
|
padding: 0;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
}
|
||||||
|
|
||||||
|
.swrv-step {
|
||||||
|
display: flex;
|
||||||
|
gap: 14px;
|
||||||
|
padding: 10px 0;
|
||||||
|
position: relative;
|
||||||
|
|
||||||
|
&:not(:last-child)::after {
|
||||||
|
content: '';
|
||||||
|
position: absolute;
|
||||||
|
left: 15px;
|
||||||
|
top: 38px;
|
||||||
|
bottom: -4px;
|
||||||
|
width: 2px;
|
||||||
|
background: #e2e8f0;
|
||||||
|
}
|
||||||
|
|
||||||
|
&__indicator {
|
||||||
|
width: 32px;
|
||||||
|
height: 32px;
|
||||||
|
border-radius: 50%;
|
||||||
|
background: #fff;
|
||||||
|
border: 2px solid #e2e8f0;
|
||||||
|
color: #64748b;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
font-size: 12px;
|
||||||
|
font-weight: 700;
|
||||||
|
flex-shrink: 0;
|
||||||
|
position: relative;
|
||||||
|
z-index: 1;
|
||||||
|
transition: all 0.25s;
|
||||||
|
|
||||||
|
mat-icon {
|
||||||
|
font-size: 16px;
|
||||||
|
width: 16px;
|
||||||
|
height: 16px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
&__content {
|
||||||
|
padding-top: 5px;
|
||||||
|
flex: 1;
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
&__label {
|
||||||
|
font-size: 13px;
|
||||||
|
font-weight: 600;
|
||||||
|
color: #475569;
|
||||||
|
}
|
||||||
|
|
||||||
|
&__msg {
|
||||||
|
font-size: 11px;
|
||||||
|
color: #94a3b8;
|
||||||
|
margin-top: 2px;
|
||||||
|
}
|
||||||
|
|
||||||
|
&--active {
|
||||||
|
.swrv-step__indicator {
|
||||||
|
background: #3457d5;
|
||||||
|
border-color: #3457d5;
|
||||||
|
color: #fff;
|
||||||
|
box-shadow: 0 0 0 4px rgb(52 87 213 / 15%);
|
||||||
|
}
|
||||||
|
|
||||||
|
.swrv-step__label {
|
||||||
|
color: #3457d5;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
&--done {
|
||||||
|
.swrv-step__indicator {
|
||||||
|
background: #1a7f37;
|
||||||
|
border-color: #1a7f37;
|
||||||
|
color: #fff;
|
||||||
|
}
|
||||||
|
|
||||||
|
&::after {
|
||||||
|
background: #1a7f37;
|
||||||
|
}
|
||||||
|
|
||||||
|
.swrv-step__label {
|
||||||
|
color: #0d1b2a;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
&--failed {
|
||||||
|
.swrv-step__indicator {
|
||||||
|
background: #b42318;
|
||||||
|
border-color: #b42318;
|
||||||
|
color: #fff;
|
||||||
|
}
|
||||||
|
|
||||||
|
.swrv-step__label {
|
||||||
|
color: #b42318;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (prefers-reduced-motion: reduce) {
|
||||||
|
.swrv-spin {
|
||||||
|
animation: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.swrv-modal,
|
||||||
|
.swrv-modal__card {
|
||||||
|
animation: none;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,416 @@
|
|||||||
|
/**
|
||||||
|
* Copyright since 2025 Mifos Initiative
|
||||||
|
*
|
||||||
|
* This Source Code Form is subject to the terms of the Mozilla Public
|
||||||
|
* License, v. 2.0. If a copy of the MPL was not distributed with this
|
||||||
|
* file, You can obtain one at http://mozilla.org/MPL/2.0/.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { Component, EventEmitter, Input, OnInit, Output, inject } from '@angular/core';
|
||||||
|
import { CommonModule } from '@angular/common';
|
||||||
|
import { FormsModule } from '@angular/forms';
|
||||||
|
import { ActivatedRoute, Router, RouterModule } from '@angular/router';
|
||||||
|
import { MatIconModule } from '@angular/material/icon';
|
||||||
|
import { MatSnackBar, MatSnackBarModule } from '@angular/material/snack-bar';
|
||||||
|
import { catchError } from 'rxjs/operators';
|
||||||
|
import { of } from 'rxjs';
|
||||||
|
|
||||||
|
import { SharesService } from '../../shares/shares.service';
|
||||||
|
import { ClerkShareWithdrawalService } from '../../cashier/clerk-share-withdrawal.service';
|
||||||
|
import { CashierService } from '../../cashier/cashier.service';
|
||||||
|
import { SearchSelectComponent, SearchSelectOption } from '../../shared/search-select/search-select.component';
|
||||||
|
|
||||||
|
export type ShareWdMode = 'create' | 'review';
|
||||||
|
|
||||||
|
interface ActivityEntry {
|
||||||
|
timestamp: string;
|
||||||
|
action: string;
|
||||||
|
user: string;
|
||||||
|
note?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Share Withdrawal (redeem shares) request — maker screen in the Clerk Dashboard. A clerk finds
|
||||||
|
* the member's Share Account, enters how many shares to withdraw plus the governing Board
|
||||||
|
* Resolution Number / Date of Meeting, and submits for Bank Manager approval. This is the ONLY
|
||||||
|
* entry point a share withdrawal should go through — approval server-side executes the real
|
||||||
|
* "redeemshares" command (see ClerkShareWithdrawalRequestApiResource on the backend).
|
||||||
|
*/
|
||||||
|
@Component({
|
||||||
|
selector: 'mifosx-share-withdrawal-request',
|
||||||
|
templateUrl: './share-withdrawal-request.component.html',
|
||||||
|
styleUrls: [
|
||||||
|
'../../cashier/cashier-shared.scss',
|
||||||
|
'../clerk-shared.scss',
|
||||||
|
'./share-withdrawal-request.component.scss'
|
||||||
|
],
|
||||||
|
standalone: true,
|
||||||
|
imports: [
|
||||||
|
CommonModule,
|
||||||
|
FormsModule,
|
||||||
|
RouterModule,
|
||||||
|
MatIconModule,
|
||||||
|
MatSnackBarModule,
|
||||||
|
SearchSelectComponent
|
||||||
|
]
|
||||||
|
})
|
||||||
|
export class ShareWithdrawalRequestComponent implements OnInit {
|
||||||
|
private route = inject(ActivatedRoute);
|
||||||
|
private router = inject(Router);
|
||||||
|
private sharesService = inject(SharesService);
|
||||||
|
private wdService = inject(ClerkShareWithdrawalService);
|
||||||
|
private cashierService = inject(CashierService);
|
||||||
|
private snackBar = inject(MatSnackBar);
|
||||||
|
|
||||||
|
/** When embedded (e.g. opened as a popup from a list screen instead of routed to directly), mode
|
||||||
|
* and requestId come from these inputs rather than the route, and success/close actions emit
|
||||||
|
* events instead of navigating away. */
|
||||||
|
@Input() embedded = false;
|
||||||
|
@Input() overrideMode: ShareWdMode | null = null;
|
||||||
|
@Input() overrideRequestId: number | null = null;
|
||||||
|
@Output() closeRequested = new EventEmitter<void>();
|
||||||
|
@Output() actionCompleted = new EventEmitter<void>();
|
||||||
|
|
||||||
|
mode: ShareWdMode = 'create';
|
||||||
|
requestId: number | null = null;
|
||||||
|
|
||||||
|
submitting = false;
|
||||||
|
loadingAccount = false;
|
||||||
|
|
||||||
|
selectedAccountId = '';
|
||||||
|
accountOptions: SearchSelectOption[] = [];
|
||||||
|
|
||||||
|
account: any = null;
|
||||||
|
|
||||||
|
requestedShares: number | null = null;
|
||||||
|
requestedDate = '';
|
||||||
|
resolutionNumber = '';
|
||||||
|
meetingDate = '';
|
||||||
|
description = '';
|
||||||
|
|
||||||
|
paymentTypes: any[] = [];
|
||||||
|
settlementPaymentTypeId: number | null = null;
|
||||||
|
|
||||||
|
activityLog: ActivityEntry[] = [];
|
||||||
|
reviewRequest: any = null;
|
||||||
|
showRejectDialog = false;
|
||||||
|
rejectionReason = '';
|
||||||
|
showConfirmDialog = false;
|
||||||
|
|
||||||
|
showValidateModal = false;
|
||||||
|
validating = false;
|
||||||
|
validateAllPassed = false;
|
||||||
|
backendChecks: { label: string; pass: boolean; detail: string }[] | null = null;
|
||||||
|
validateSteps: { label: string; detail: string; pass: boolean; status: 'pending' | 'active' | 'done' | 'failed' }[] =
|
||||||
|
[];
|
||||||
|
|
||||||
|
get remainingShares(): number {
|
||||||
|
const held = Number(this.account?.summary?.totalApprovedShares ?? this.account?.totalApprovedShares ?? 0);
|
||||||
|
return held - (this.requestedShares || 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
get unitPrice(): number {
|
||||||
|
return Number(this.account?.currentMarketPrice ?? 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
get withdrawalValue(): number {
|
||||||
|
return (this.requestedShares || 0) * this.unitPrice;
|
||||||
|
}
|
||||||
|
|
||||||
|
get paymentTypeOptions(): any[] {
|
||||||
|
return this.paymentTypes.filter((pt: any) => !pt.isCashPayment);
|
||||||
|
}
|
||||||
|
|
||||||
|
get selectedSettlementPaymentType(): any {
|
||||||
|
return this.paymentTypes.find((pt: any) => pt.id === this.settlementPaymentTypeId);
|
||||||
|
}
|
||||||
|
|
||||||
|
get settlementPaymentTypeName(): string {
|
||||||
|
if (this.isReview) {
|
||||||
|
return this.reviewRequest?.settlement_payment_type_name || '—';
|
||||||
|
}
|
||||||
|
return this.selectedSettlementPaymentType?.name || '—';
|
||||||
|
}
|
||||||
|
|
||||||
|
get isFormValid(): boolean {
|
||||||
|
if (!this.account) return false;
|
||||||
|
if (!this.requestedShares || this.requestedShares <= 0) return false;
|
||||||
|
const held = Number(this.account?.summary?.totalApprovedShares ?? this.account?.totalApprovedShares ?? 0);
|
||||||
|
if (this.requestedShares > held) return false;
|
||||||
|
if (!this.requestedDate) return false;
|
||||||
|
if (!this.resolutionNumber.trim()) return false;
|
||||||
|
if (!this.meetingDate) return false;
|
||||||
|
if (!this.settlementPaymentTypeId) return false;
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Shown to the clerk before submit and to the manager before approval, so both can verify the same checks. */
|
||||||
|
get verificationChecks(): { label: string; pass: boolean; detail: string }[] {
|
||||||
|
const held = Number(this.account?.summary?.totalApprovedShares ?? this.account?.totalApprovedShares ?? 0);
|
||||||
|
const shares = this.requestedShares || 0;
|
||||||
|
return [
|
||||||
|
{
|
||||||
|
label: 'Board Resolution Number provided',
|
||||||
|
pass: !!this.resolutionNumber?.trim(),
|
||||||
|
detail: this.resolutionNumber?.trim() || 'Missing'
|
||||||
|
},
|
||||||
|
{ label: 'Date of Meeting provided', pass: !!this.meetingDate, detail: this.meetingDate || 'Missing' },
|
||||||
|
{
|
||||||
|
label: 'Shares requested within shares held',
|
||||||
|
pass: shares > 0 && shares <= held,
|
||||||
|
detail: `${shares} of ${held} shares held`
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: 'Linked savings account confirmed',
|
||||||
|
pass: !!this.account?.savingsAccountNumber,
|
||||||
|
detail: this.account?.savingsAccountNumber || 'No linked savings account — settlement cannot run automatically'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: 'Settlement Payment Type selected (non-cash)',
|
||||||
|
pass: this.settlementPaymentTypeName !== '—',
|
||||||
|
detail: this.settlementPaymentTypeName
|
||||||
|
}
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
get isReview(): boolean {
|
||||||
|
return this.mode === 'review';
|
||||||
|
}
|
||||||
|
|
||||||
|
get pageTitle(): string {
|
||||||
|
return this.isReview ? 'Review Share Withdrawal Request' : 'New Share Withdrawal Request';
|
||||||
|
}
|
||||||
|
|
||||||
|
get pageSubtitle(): string {
|
||||||
|
return this.isReview
|
||||||
|
? "Approve or reject the clerk's share withdrawal request."
|
||||||
|
: 'Submit a share withdrawal (redeem shares) request for Bank Manager approval.';
|
||||||
|
}
|
||||||
|
|
||||||
|
ngOnInit(): void {
|
||||||
|
this.mode = this.overrideMode ?? (this.route.snapshot.data?.['mode'] as ShareWdMode) ?? 'create';
|
||||||
|
const idParam = this.overrideRequestId ?? this.route.snapshot.params?.['id'];
|
||||||
|
this.requestId = idParam ? Number(idParam) : null;
|
||||||
|
|
||||||
|
const today = new Date();
|
||||||
|
this.requestedDate = `${today.getFullYear()}-${String(today.getMonth() + 1).padStart(2, '0')}-${String(today.getDate()).padStart(2, '0')}`;
|
||||||
|
this.meetingDate = this.requestedDate;
|
||||||
|
|
||||||
|
this.sharesService
|
||||||
|
.getShareAccountSummaries()
|
||||||
|
.pipe(catchError(() => of([])))
|
||||||
|
.subscribe((rows: any[]) => {
|
||||||
|
this.accountOptions = (rows || [])
|
||||||
|
.filter((r) => (r.status?.value || '').toLowerCase() === 'active')
|
||||||
|
.map((r) => ({
|
||||||
|
value: String(r.id),
|
||||||
|
label: `${r.accountNo} — ${r.clientName} (${r.totalApprovedShares} shares)`,
|
||||||
|
searchText: `${r.accountNo} ${r.clientName}`
|
||||||
|
}));
|
||||||
|
});
|
||||||
|
|
||||||
|
this.cashierService
|
||||||
|
.getPaymentTypes()
|
||||||
|
.pipe(catchError(() => of([])))
|
||||||
|
.subscribe((types: any[]) => (this.paymentTypes = types ?? []));
|
||||||
|
|
||||||
|
if (this.requestId && this.mode === 'review') {
|
||||||
|
this.loadReviewRequest(this.requestId);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
onAccountSelected(accountId: string): void {
|
||||||
|
this.selectedAccountId = accountId;
|
||||||
|
if (!accountId) {
|
||||||
|
this.account = null;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
this.loadingAccount = true;
|
||||||
|
this.sharesService
|
||||||
|
.getSharesAccountData(accountId, false)
|
||||||
|
.pipe(catchError(() => of(null)))
|
||||||
|
.subscribe((acc: any) => {
|
||||||
|
this.account = acc;
|
||||||
|
this.loadingAccount = false;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
private loadReviewRequest(id: number): void {
|
||||||
|
this.wdService.getById(id).subscribe({
|
||||||
|
next: (req: any) => {
|
||||||
|
this.reviewRequest = req;
|
||||||
|
this.requestedShares = Number(req.requested_shares);
|
||||||
|
this.requestedDate = req.requested_date
|
||||||
|
? String(req.requested_date).split('T')[0].split(' ')[0]
|
||||||
|
: this.requestedDate;
|
||||||
|
this.resolutionNumber = req.resolution_number ?? '';
|
||||||
|
this.meetingDate = req.meeting_date ? String(req.meeting_date).split('T')[0].split(' ')[0] : this.meetingDate;
|
||||||
|
this.description = req.description ?? '';
|
||||||
|
|
||||||
|
if (req.activity_log) {
|
||||||
|
try {
|
||||||
|
this.activityLog = JSON.parse(req.activity_log);
|
||||||
|
} catch {
|
||||||
|
this.activityLog = [];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
this.sharesService
|
||||||
|
.getSharesAccountData(String(req.share_account_id), false)
|
||||||
|
.pipe(catchError(() => of(null)))
|
||||||
|
.subscribe((acc: any) => (this.account = acc));
|
||||||
|
},
|
||||||
|
error: () => this.snackBar.open('Failed to load share withdrawal request.', 'Close', { duration: 5000 })
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/** "Review before Save" per requirement — opens the confirm dialog instead of submitting directly. */
|
||||||
|
openConfirmDialog(): void {
|
||||||
|
if (!this.isFormValid || this.submitting) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
this.showConfirmDialog = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
cancelConfirmDialog(): void {
|
||||||
|
this.showConfirmDialog = false;
|
||||||
|
}
|
||||||
|
|
||||||
|
confirmSubmit(): void {
|
||||||
|
this.showConfirmDialog = false;
|
||||||
|
this.submitting = true;
|
||||||
|
const payload = {
|
||||||
|
shareAccountId: Number(this.selectedAccountId),
|
||||||
|
accountNo: this.account?.accountNo,
|
||||||
|
clientId: this.account?.clientId,
|
||||||
|
clientName: this.account?.clientName,
|
||||||
|
requestedShares: this.requestedShares,
|
||||||
|
requestedDate: this.requestedDate,
|
||||||
|
resolutionNumber: this.resolutionNumber.trim(),
|
||||||
|
meetingDate: this.meetingDate,
|
||||||
|
description: this.description,
|
||||||
|
settlementPaymentTypeId: this.settlementPaymentTypeId
|
||||||
|
};
|
||||||
|
this.wdService.submit(payload).subscribe({
|
||||||
|
next: () => {
|
||||||
|
this.snackBar.open('Share withdrawal request submitted for approval.', 'Close', { duration: 4000 });
|
||||||
|
if (this.embedded) {
|
||||||
|
this.actionCompleted.emit();
|
||||||
|
} else {
|
||||||
|
this.router.navigate(['/clerk/dashboard']);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
error: (err) => {
|
||||||
|
this.submitting = false;
|
||||||
|
this.snackBar.open(err?.error?.defaultUserMessage || 'Could not submit the request.', 'Close', {
|
||||||
|
duration: 6000
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
openValidateModal(): void {
|
||||||
|
if (!this.reviewRequest) return;
|
||||||
|
this.showValidateModal = true;
|
||||||
|
this.runBackendValidation();
|
||||||
|
}
|
||||||
|
|
||||||
|
closeValidateModal(): void {
|
||||||
|
if (this.validating) return;
|
||||||
|
this.showValidateModal = false;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Re-checks live backend state (account still active, GL mapping still configured, etc.) rather
|
||||||
|
* than relying on data already loaded in the browser — catches drift since submission. Results
|
||||||
|
* are revealed one step at a time (see revealValidateSteps()) for the pipeline-style animation. */
|
||||||
|
runBackendValidation(): void {
|
||||||
|
if (!this.reviewRequest || this.validating) return;
|
||||||
|
this.validating = true;
|
||||||
|
this.backendChecks = null;
|
||||||
|
this.validateSteps = [];
|
||||||
|
this.wdService.validate(this.reviewRequest.id).subscribe({
|
||||||
|
next: (res: any) => {
|
||||||
|
const checks = (res?.checks ?? []) as { label: string; pass: boolean; detail: string }[];
|
||||||
|
this.backendChecks = checks;
|
||||||
|
this.validateAllPassed = !!res?.allPassed;
|
||||||
|
this.validateSteps = checks.map((c) => ({ ...c, status: 'pending' as const }));
|
||||||
|
this.revealValidateSteps(0);
|
||||||
|
},
|
||||||
|
error: (err) => {
|
||||||
|
this.validating = false;
|
||||||
|
this.snackBar.open(err?.error?.defaultUserMessage || 'Could not run backend validation.', 'Close', {
|
||||||
|
duration: 6000
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
private revealValidateSteps(index: number): void {
|
||||||
|
if (index >= this.validateSteps.length) {
|
||||||
|
this.validating = false;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
this.validateSteps[index].status = 'active';
|
||||||
|
setTimeout(() => {
|
||||||
|
this.validateSteps[index].status = this.validateSteps[index].pass ? 'done' : 'failed';
|
||||||
|
this.revealValidateSteps(index + 1);
|
||||||
|
}, 320);
|
||||||
|
}
|
||||||
|
|
||||||
|
approveReview(): void {
|
||||||
|
if (!this.reviewRequest) return;
|
||||||
|
this.wdService.approve(this.reviewRequest.id).subscribe({
|
||||||
|
next: (res: any) => {
|
||||||
|
let message =
|
||||||
|
res?.settlementAmount != null
|
||||||
|
? `Share withdrawal approved. ₹${Number(res.settlementAmount).toLocaleString('en-IN', { minimumFractionDigits: 2 })} credited to the linked savings account.`
|
||||||
|
: (res?.settlementWarning ?? 'Share withdrawal approved and processed.');
|
||||||
|
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) {
|
||||||
|
this.actionCompleted.emit();
|
||||||
|
} else {
|
||||||
|
this.router.navigate(['/checker-inbox-and-tasks/share-withdrawal-requests']);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
error: (err) =>
|
||||||
|
this.snackBar.open(err?.error?.defaultUserMessage || 'Could not approve the request.', 'Close', {
|
||||||
|
duration: 6000
|
||||||
|
})
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
openRejectDialog(): void {
|
||||||
|
this.showRejectDialog = true;
|
||||||
|
this.rejectionReason = '';
|
||||||
|
}
|
||||||
|
|
||||||
|
confirmRejectReview(): void {
|
||||||
|
if (!this.reviewRequest || !this.rejectionReason.trim()) return;
|
||||||
|
this.wdService.reject(this.reviewRequest.id, this.rejectionReason.trim()).subscribe({
|
||||||
|
next: () => {
|
||||||
|
this.showRejectDialog = false;
|
||||||
|
this.snackBar.open('Share withdrawal request rejected.', 'Close', { duration: 4000 });
|
||||||
|
if (this.embedded) {
|
||||||
|
this.actionCompleted.emit();
|
||||||
|
} else {
|
||||||
|
this.router.navigate(['/checker-inbox-and-tasks/share-withdrawal-requests']);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
error: (err) =>
|
||||||
|
this.snackBar.open(err?.error?.defaultUserMessage || 'Could not reject the request.', 'Close', {
|
||||||
|
duration: 6000
|
||||||
|
})
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
goBack(): void {
|
||||||
|
if (this.embedded) {
|
||||||
|
this.closeRequested.emit();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
this.router.navigate(this.isReview ? ['/checker-inbox-and-tasks/share-withdrawal-requests'] : ['/clerk/dashboard']);
|
||||||
|
}
|
||||||
|
}
|
||||||
+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;
|
||||||
|
|||||||
+16
@@ -62,6 +62,22 @@
|
|||||||
</div>
|
</div>
|
||||||
}
|
}
|
||||||
|
|
||||||
|
<!-- Mother requirement — shown independently of the Father/Caretaker check below -->
|
||||||
|
@if (!nomineeOnly && clientFamilyMembers.length > 0 && !hasMother) {
|
||||||
|
<div class="fm-step__empty fm-step__empty--required">
|
||||||
|
<mat-icon class="fm-step__empty-icon">error_outline</mat-icon>
|
||||||
|
<span class="fm-step__empty-required">Mother is required.</span>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
|
||||||
|
<!-- Father-or-Caretaker requirement — shown independently of the Mother check above -->
|
||||||
|
@if (!nomineeOnly && clientFamilyMembers.length > 0 && !hasFather && !hasCaretaker) {
|
||||||
|
<div class="fm-step__empty fm-step__empty--required">
|
||||||
|
<mat-icon class="fm-step__empty-icon">error_outline</mat-icon>
|
||||||
|
<span class="fm-step__empty-required">Either Father or a Caretaker is required.</span>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
|
||||||
<!-- Spouse hint for married clients -->
|
<!-- Spouse hint for married clients -->
|
||||||
@if (!nomineeOnly && isMarried && !hasSpouse) {
|
@if (!nomineeOnly && isMarried && !hasSpouse) {
|
||||||
<div class="fm-spouse-hint">
|
<div class="fm-spouse-hint">
|
||||||
|
|||||||
+44
@@ -12,6 +12,7 @@ import { TranslateService } from '@ngx-translate/core';
|
|||||||
import { DeleteDialogComponent } from 'app/shared/delete-dialog/delete-dialog.component';
|
import { DeleteDialogComponent } from 'app/shared/delete-dialog/delete-dialog.component';
|
||||||
import { ClientFamilyMemberDialogComponent } from './client-family-member-dialog/client-family-member-dialog.component';
|
import { ClientFamilyMemberDialogComponent } from './client-family-member-dialog/client-family-member-dialog.component';
|
||||||
import { ImageZoomDialogComponent } from './client-family-member-dialog/client-family-member-dialog.component';
|
import { ImageZoomDialogComponent } from './client-family-member-dialog/client-family-member-dialog.component';
|
||||||
|
import { FamilyMemberAddedDialogComponent } from './family-member-added-dialog.component';
|
||||||
import { ClientsService } from '../../clients.service';
|
import { ClientsService } from '../../clients.service';
|
||||||
import { FaIconComponent } from '@fortawesome/angular-fontawesome';
|
import { FaIconComponent } from '@fortawesome/angular-fontawesome';
|
||||||
import { MatIcon } from '@angular/material/icon';
|
import { MatIcon } from '@angular/material/icon';
|
||||||
@@ -152,6 +153,22 @@ export class ClientFamilyMembersStepComponent implements OnInit {
|
|||||||
ref.afterClosed().subscribe((response: any) => {
|
ref.afterClosed().subscribe((response: any) => {
|
||||||
if (response?.member) {
|
if (response?.member) {
|
||||||
this.clientFamilyMembers.push(response.member);
|
this.clientFamilyMembers.push(response.member);
|
||||||
|
this.showFamilyMemberAddedConfirmation(response.member);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Confirms what was just recorded — in particular the linked client's own CBS Client ID,
|
||||||
|
* since the "Add Member" dialog closes silently otherwise and gives no acknowledgement. */
|
||||||
|
private showFamilyMemberAddedConfirmation(member: any): void {
|
||||||
|
this.dialog.open(FamilyMemberAddedDialogComponent, {
|
||||||
|
width: '360px',
|
||||||
|
data: {
|
||||||
|
clientId: member._linkedClient?.id ?? member.linkedClientId ?? null,
|
||||||
|
name: member._linkedClient?.displayName || member.firstName,
|
||||||
|
relationship: this.getRelName(member.relationshipId),
|
||||||
|
dateOfBirth: member.dateOfBirth,
|
||||||
|
isDependent: member.isDependent
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@@ -223,6 +240,33 @@ export class ClientFamilyMembersStepComponent implements OnInit {
|
|||||||
return this.clientFamilyMembers.length > 0;
|
return this.clientFamilyMembers.length > 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** True if a family member with the given relationship (case-insensitive) has been added */
|
||||||
|
private hasRelationship(name: string): boolean {
|
||||||
|
const target = name.toLowerCase();
|
||||||
|
return this.clientFamilyMembers.some((m: any) => {
|
||||||
|
const resolved = this.getRelName(m.relationshipId ?? m.relationship).toLowerCase();
|
||||||
|
if (resolved === target) return true;
|
||||||
|
return typeof m.relationship === 'string' && m.relationship.toLowerCase() === target;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
get hasMother(): boolean {
|
||||||
|
return this.hasRelationship('mother');
|
||||||
|
}
|
||||||
|
|
||||||
|
get hasFather(): boolean {
|
||||||
|
return this.hasRelationship('father');
|
||||||
|
}
|
||||||
|
|
||||||
|
get hasCaretaker(): boolean {
|
||||||
|
return this.hasRelationship('caretaker');
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Mother is always required; Father is required unless a Caretaker has been added instead. */
|
||||||
|
get familyRelationshipRulesMet(): boolean {
|
||||||
|
return this.hasMother && (this.hasFather || this.hasCaretaker);
|
||||||
|
}
|
||||||
|
|
||||||
/** Check if client's marital status is "Married" */
|
/** Check if client's marital status is "Married" */
|
||||||
get isMarried(): boolean {
|
get isMarried(): boolean {
|
||||||
if (!this.maritalStatusId || !this.maritalStatusOptions?.length) return false;
|
if (!this.maritalStatusId || !this.maritalStatusOptions?.length) return false;
|
||||||
|
|||||||
+145
@@ -0,0 +1,145 @@
|
|||||||
|
/**
|
||||||
|
* 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 { MatDialogRef, MAT_DIALOG_DATA, MatDialogTitle, MatDialogActions } from '@angular/material/dialog';
|
||||||
|
import { MatIcon } from '@angular/material/icon';
|
||||||
|
import { MatButton } from '@angular/material/button';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Confirmation popup shown right after a family member is added — surfaces the linked
|
||||||
|
* client's own CBS Client ID (when the member was linked to an existing client rather than
|
||||||
|
* entered manually) alongside name/relationship, since the "Add Member" dialog itself just
|
||||||
|
* closed silently before this with no acknowledgement of what got recorded.
|
||||||
|
*/
|
||||||
|
@Component({
|
||||||
|
selector: 'mifosx-family-member-added-dialog',
|
||||||
|
imports: [
|
||||||
|
MatDialogTitle,
|
||||||
|
MatDialogActions,
|
||||||
|
MatIcon,
|
||||||
|
MatButton
|
||||||
|
],
|
||||||
|
template: `
|
||||||
|
<h2 mat-dialog-title class="fma-title">
|
||||||
|
<mat-icon class="fma-title__icon">check_circle</mat-icon>
|
||||||
|
Family Member Added
|
||||||
|
</h2>
|
||||||
|
<div class="fma-body">
|
||||||
|
@if (data.clientId) {
|
||||||
|
<div class="fma-row fma-row--id">
|
||||||
|
<span class="fma-row__label">Client ID</span>
|
||||||
|
<span class="fma-row__value fma-row__value--id">{{ data.clientId }}</span>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
<div class="fma-row">
|
||||||
|
<span class="fma-row__label">Name</span>
|
||||||
|
<span class="fma-row__value">{{ data.name }}</span>
|
||||||
|
</div>
|
||||||
|
@if (data.relationship) {
|
||||||
|
<div class="fma-row">
|
||||||
|
<span class="fma-row__label">Relationship</span>
|
||||||
|
<span class="fma-row__value">{{ data.relationship }}</span>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
@if (data.dateOfBirth) {
|
||||||
|
<div class="fma-row">
|
||||||
|
<span class="fma-row__label">Date of Birth</span>
|
||||||
|
<span class="fma-row__value">{{ data.dateOfBirth }}</span>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
@if (data.isDependent) {
|
||||||
|
<div class="fma-row">
|
||||||
|
<span class="fma-row__label">Dependent</span>
|
||||||
|
<span class="fma-row__value">Yes</span>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
<mat-dialog-actions align="end">
|
||||||
|
<button mat-flat-button color="primary" (click)="dialogRef.close()">Done</button>
|
||||||
|
</mat-dialog-actions>
|
||||||
|
`,
|
||||||
|
styles: [
|
||||||
|
`
|
||||||
|
.fma-title {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
font-size: 17px;
|
||||||
|
font-weight: 700;
|
||||||
|
color: #0f172a;
|
||||||
|
margin: 0;
|
||||||
|
padding: 20px 24px 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.fma-title__icon {
|
||||||
|
color: #059669;
|
||||||
|
}
|
||||||
|
|
||||||
|
.fma-body {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 2px;
|
||||||
|
padding: 12px 24px 8px;
|
||||||
|
min-width: 280px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.fma-row {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: center;
|
||||||
|
gap: 16px;
|
||||||
|
padding: 8px 0;
|
||||||
|
border-bottom: 1px solid #f1f5f9;
|
||||||
|
|
||||||
|
&:last-child {
|
||||||
|
border-bottom: none;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.fma-row__label {
|
||||||
|
font-size: 12px;
|
||||||
|
color: #64748b;
|
||||||
|
font-weight: 500;
|
||||||
|
}
|
||||||
|
|
||||||
|
.fma-row__value {
|
||||||
|
font-size: 13px;
|
||||||
|
color: #0f172a;
|
||||||
|
font-weight: 600;
|
||||||
|
text-align: right;
|
||||||
|
}
|
||||||
|
|
||||||
|
.fma-row--id {
|
||||||
|
background: #eff6ff;
|
||||||
|
margin: 0 -24px;
|
||||||
|
padding: 10px 24px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.fma-row__value--id {
|
||||||
|
font-family: 'Roboto Mono', monospace;
|
||||||
|
color: #1353a0;
|
||||||
|
font-size: 14px;
|
||||||
|
}
|
||||||
|
|
||||||
|
mat-dialog-actions {
|
||||||
|
padding: 8px 24px 16px;
|
||||||
|
}
|
||||||
|
`
|
||||||
|
]
|
||||||
|
})
|
||||||
|
export class FamilyMemberAddedDialogComponent {
|
||||||
|
dialogRef = inject<MatDialogRef<FamilyMemberAddedDialogComponent>>(MatDialogRef);
|
||||||
|
data: {
|
||||||
|
clientId?: string | number | null;
|
||||||
|
name: string;
|
||||||
|
relationship?: string;
|
||||||
|
dateOfBirth?: string;
|
||||||
|
isDependent?: boolean;
|
||||||
|
} = inject(MAT_DIALOG_DATA);
|
||||||
|
}
|
||||||
+166
-13
@@ -136,7 +136,7 @@
|
|||||||
| translate
|
| translate
|
||||||
}}
|
}}
|
||||||
</mat-label>
|
</mat-label>
|
||||||
<input matInput required formControlName="fullname" />
|
<input matInput required formControlName="fullname" autocomplete="off" />
|
||||||
@if (createClientForm.controls.fullname.hasError('required')) {
|
@if (createClientForm.controls.fullname.hasError('required')) {
|
||||||
<mat-error>Client name is <strong>required</strong></mat-error>
|
<mat-error>Client name is <strong>required</strong></mat-error>
|
||||||
} @else if (createClientForm.controls.fullname.hasError('pattern')) {
|
} @else if (createClientForm.controls.fullname.hasError('pattern')) {
|
||||||
@@ -214,7 +214,7 @@
|
|||||||
<mat-form-field appearance="outline" class="name-field first-name">
|
<mat-form-field appearance="outline" class="name-field first-name">
|
||||||
<mat-label>{{ 'labels.inputs.First Name' | translate }}</mat-label>
|
<mat-label>{{ 'labels.inputs.First Name' | translate }}</mat-label>
|
||||||
<span matPrefix class="field-prefix-icon"><mat-icon>badge</mat-icon></span>
|
<span matPrefix class="field-prefix-icon"><mat-icon>badge</mat-icon></span>
|
||||||
<input matInput required formControlName="firstname" />
|
<input matInput required formControlName="firstname" autocomplete="off" />
|
||||||
@if (createClientForm.controls.firstname.hasError('required')) {
|
@if (createClientForm.controls.firstname.hasError('required')) {
|
||||||
<mat-error>First name is <strong>required</strong></mat-error>
|
<mat-error>First name is <strong>required</strong></mat-error>
|
||||||
} @else if (createClientForm.controls.firstname.hasError('pattern')) {
|
} @else if (createClientForm.controls.firstname.hasError('pattern')) {
|
||||||
@@ -228,7 +228,7 @@
|
|||||||
<mat-form-field appearance="outline" class="name-field middle-name">
|
<mat-form-field appearance="outline" class="name-field middle-name">
|
||||||
<mat-label>{{ 'labels.inputs.Middle Name' | translate }}</mat-label>
|
<mat-label>{{ 'labels.inputs.Middle Name' | translate }}</mat-label>
|
||||||
<span matPrefix class="field-prefix-icon"><mat-icon>person_outline</mat-icon></span>
|
<span matPrefix class="field-prefix-icon"><mat-icon>person_outline</mat-icon></span>
|
||||||
<input matInput formControlName="middlename" />
|
<input matInput formControlName="middlename" autocomplete="off" />
|
||||||
@if (
|
@if (
|
||||||
createClientForm.controls.middlename?.hasError('pattern') &&
|
createClientForm.controls.middlename?.hasError('pattern') &&
|
||||||
createClientForm.controls.middlename?.touched
|
createClientForm.controls.middlename?.touched
|
||||||
@@ -241,7 +241,7 @@
|
|||||||
<mat-form-field appearance="outline" class="name-field last-name">
|
<mat-form-field appearance="outline" class="name-field last-name">
|
||||||
<mat-label>{{ 'labels.inputs.Last Name' | translate }}</mat-label>
|
<mat-label>{{ 'labels.inputs.Last Name' | translate }}</mat-label>
|
||||||
<span matPrefix class="field-prefix-icon"><mat-icon>person</mat-icon></span>
|
<span matPrefix class="field-prefix-icon"><mat-icon>person</mat-icon></span>
|
||||||
<input matInput required formControlName="lastname" />
|
<input matInput required formControlName="lastname" autocomplete="off" />
|
||||||
@if (createClientForm.controls.lastname.hasError('required')) {
|
@if (createClientForm.controls.lastname.hasError('required')) {
|
||||||
<mat-error>Last name is <strong>required</strong></mat-error>
|
<mat-error>Last name is <strong>required</strong></mat-error>
|
||||||
} @else if (createClientForm.controls.lastname.hasError('pattern')) {
|
} @else if (createClientForm.controls.lastname.hasError('pattern')) {
|
||||||
@@ -358,11 +358,14 @@
|
|||||||
@if (isMinor) {
|
@if (isMinor) {
|
||||||
<span class="minor-badge">Minor</span>
|
<span class="minor-badge">Minor</span>
|
||||||
}
|
}
|
||||||
|
@if (isSeniorCitizen) {
|
||||||
|
<span class="senior-citizen-badge">Senior Citizen</span>
|
||||||
|
}
|
||||||
</div>
|
</div>
|
||||||
}
|
}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Minor / Guardian Mapping (mandatory if age < 10) -->
|
<!-- Minor / Guardian Mapping (mandatory if age < 18) -->
|
||||||
@if (isMinor && createClientForm.get('legalFormId')?.value === LegalFormId.PERSON) {
|
@if (isMinor && createClientForm.get('legalFormId')?.value === LegalFormId.PERSON) {
|
||||||
<div class="guardian-section">
|
<div class="guardian-section">
|
||||||
<!-- Header bar -->
|
<!-- Header bar -->
|
||||||
@@ -995,6 +998,32 @@
|
|||||||
</mat-select>
|
</mat-select>
|
||||||
</mat-form-field>
|
</mat-form-field>
|
||||||
}
|
}
|
||||||
|
|
||||||
|
<!-- CLIENT CREATION contact-OTP verification — create mode only. Edit mode already has
|
||||||
|
its own dual old/new OTP panel in EditClientComponent's template; this single
|
||||||
|
"new value" block only makes sense for a brand-new client (no "old" destination to
|
||||||
|
verify against). Each block only appears once its own field passes validation, so
|
||||||
|
staff enter a plausible-looking number/email before being asked to verify it. -->
|
||||||
|
@if (!clientData) {
|
||||||
|
<div class="contact-otp-row">
|
||||||
|
@if (createClientForm.get('mobileNo')?.valid) {
|
||||||
|
<mifosx-client-contact-otp-block
|
||||||
|
channel="MOBILE"
|
||||||
|
[destination]="createClientForm.get('mobileNo')?.value"
|
||||||
|
label="Verify Mobile Number"
|
||||||
|
(stateChange)="onMobileOtpState($event)"
|
||||||
|
></mifosx-client-contact-otp-block>
|
||||||
|
}
|
||||||
|
@if (createClientForm.get('emailAddress')?.valid) {
|
||||||
|
<mifosx-client-contact-otp-block
|
||||||
|
channel="EMAIL"
|
||||||
|
[destination]="createClientForm.get('emailAddress')?.value"
|
||||||
|
label="Verify Email Address"
|
||||||
|
(stateChange)="onEmailOtpState($event)"
|
||||||
|
></mifosx-client-contact-otp-block>
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -1006,8 +1035,7 @@
|
|||||||
Education & Social Details
|
Education & Social Details
|
||||||
</h4>
|
</h4>
|
||||||
<div class="form-grid">
|
<div class="form-grid">
|
||||||
<!-- Education Level (non-minor) -->
|
<!-- Education Level -->
|
||||||
@if (!isMinor) {
|
|
||||||
<mat-form-field appearance="outline" class="form-field">
|
<mat-form-field appearance="outline" class="form-field">
|
||||||
<mat-label>Education Level</mat-label>
|
<mat-label>Education Level</mat-label>
|
||||||
<mat-select formControlName="educationLevelId">
|
<mat-select formControlName="educationLevelId">
|
||||||
@@ -1068,7 +1096,6 @@
|
|||||||
}
|
}
|
||||||
</mat-select>
|
</mat-select>
|
||||||
</mat-form-field>
|
</mat-form-field>
|
||||||
}
|
|
||||||
|
|
||||||
<!-- Religion -->
|
<!-- Religion -->
|
||||||
<mat-form-field appearance="outline" class="form-field">
|
<mat-form-field appearance="outline" class="form-field">
|
||||||
@@ -1191,21 +1218,25 @@
|
|||||||
</mat-form-field>
|
</mat-form-field>
|
||||||
|
|
||||||
<mat-form-field appearance="outline" class="form-field">
|
<mat-form-field appearance="outline" class="form-field">
|
||||||
<mat-label>Annual Turnover (Last FY)</mat-label>
|
<mat-label>Annual Income</mat-label>
|
||||||
<span matPrefix class="rupee-prefix">₹</span>
|
<span matPrefix class="rupee-prefix">₹</span>
|
||||||
<input matInput type="number" formControlName="annualTurnover" min="0" />
|
<input matInput type="number" formControlName="annualIncome" min="0" />
|
||||||
@if (
|
@if (
|
||||||
createClientForm.get('annualTurnover')?.hasError('pattern') &&
|
createClientForm.get('annualIncome')?.hasError('pattern') && createClientForm.get('annualIncome')?.touched
|
||||||
createClientForm.get('annualTurnover')?.touched
|
|
||||||
) {
|
) {
|
||||||
<mat-error>Enter a valid amount</mat-error>
|
<mat-error>Enter a valid amount</mat-error>
|
||||||
} @else if (
|
} @else if (
|
||||||
createClientForm.get('annualTurnover')?.hasError('min') && createClientForm.get('annualTurnover')?.touched
|
createClientForm.get('annualIncome')?.hasError('min') && createClientForm.get('annualIncome')?.touched
|
||||||
) {
|
) {
|
||||||
<mat-error>Amount cannot be negative</mat-error>
|
<mat-error>Amount cannot be negative</mat-error>
|
||||||
}
|
}
|
||||||
</mat-form-field>
|
</mat-form-field>
|
||||||
|
|
||||||
|
<div class="full-width subsection-title">
|
||||||
|
<fa-icon icon="coins" class="subsection-icon"></fa-icon>
|
||||||
|
Other Income
|
||||||
|
</div>
|
||||||
|
|
||||||
<mat-form-field appearance="outline" class="form-field">
|
<mat-form-field appearance="outline" class="form-field">
|
||||||
<mat-label>Agriculture Income</mat-label>
|
<mat-label>Agriculture Income</mat-label>
|
||||||
<span matPrefix class="rupee-prefix">₹</span>
|
<span matPrefix class="rupee-prefix">₹</span>
|
||||||
@@ -1239,6 +1270,128 @@
|
|||||||
<mat-error>Amount cannot be negative</mat-error>
|
<mat-error>Amount cannot be negative</mat-error>
|
||||||
}
|
}
|
||||||
</mat-form-field>
|
</mat-form-field>
|
||||||
|
|
||||||
|
<mat-form-field appearance="outline" class="form-field">
|
||||||
|
<mat-label>Monthly Turnover</mat-label>
|
||||||
|
<span matPrefix class="rupee-prefix">₹</span>
|
||||||
|
<input matInput type="number" formControlName="monthlyTurnover" min="0" />
|
||||||
|
@if (
|
||||||
|
createClientForm.get('monthlyTurnover')?.hasError('pattern') &&
|
||||||
|
createClientForm.get('monthlyTurnover')?.touched
|
||||||
|
) {
|
||||||
|
<mat-error>Enter a valid amount</mat-error>
|
||||||
|
} @else if (
|
||||||
|
createClientForm.get('monthlyTurnover')?.hasError('min') && createClientForm.get('monthlyTurnover')?.touched
|
||||||
|
) {
|
||||||
|
<mat-error>Amount cannot be negative</mat-error>
|
||||||
|
}
|
||||||
|
</mat-form-field>
|
||||||
|
|
||||||
|
<mat-form-field appearance="outline" class="form-field">
|
||||||
|
<mat-label>Yearly Turnover</mat-label>
|
||||||
|
<span matPrefix class="rupee-prefix">₹</span>
|
||||||
|
<input matInput type="number" formControlName="annualTurnover" min="0" />
|
||||||
|
@if (
|
||||||
|
createClientForm.get('annualTurnover')?.hasError('pattern') &&
|
||||||
|
createClientForm.get('annualTurnover')?.touched
|
||||||
|
) {
|
||||||
|
<mat-error>Enter a valid amount</mat-error>
|
||||||
|
} @else if (
|
||||||
|
createClientForm.get('annualTurnover')?.hasError('min') && createClientForm.get('annualTurnover')?.touched
|
||||||
|
) {
|
||||||
|
<mat-error>Amount cannot be negative</mat-error>
|
||||||
|
}
|
||||||
|
</mat-form-field>
|
||||||
|
|
||||||
|
<div class="full-width subsection-title">
|
||||||
|
<fa-icon icon="user-shield" class="subsection-icon"></fa-icon>
|
||||||
|
Senior Citizen / Person with Disability
|
||||||
|
</div>
|
||||||
|
|
||||||
|
@if (isSeniorCitizen) {
|
||||||
|
<div class="full-width senior-citizen-auto-note">
|
||||||
|
<fa-icon icon="check-circle"></fa-icon>
|
||||||
|
Senior Citizen (60+ years) — detected automatically from Date of Birth
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="full-width doc-upload-field">
|
||||||
|
<label class="doc-upload-label">
|
||||||
|
<input type="file" accept=".jpg,.jpeg,.png,.pdf" hidden (change)="onSeniorCitizenProofSelected($event)" />
|
||||||
|
<fa-icon icon="file-upload"></fa-icon>
|
||||||
|
@if (seniorCitizenProofFile) {
|
||||||
|
<span>{{ seniorCitizenProofFile.name }}</span>
|
||||||
|
} @else if (existingSeniorCitizenProofDoc) {
|
||||||
|
<span>{{ existingSeniorCitizenProofDoc.name }} (uploaded)</span>
|
||||||
|
} @else {
|
||||||
|
<span>Upload Senior Citizen Proof (Age / ID Proof) — optional</span>
|
||||||
|
}
|
||||||
|
</label>
|
||||||
|
@if (seniorCitizenProofFile) {
|
||||||
|
<button type="button" mat-icon-button (click)="removeSeniorCitizenProof()" matTooltip="Remove">
|
||||||
|
<mat-icon>close</mat-icon>
|
||||||
|
</button>
|
||||||
|
} @else if (existingSeniorCitizenProofDoc) {
|
||||||
|
<button type="button" mat-icon-button (click)="viewExistingSeniorCitizenProof()" matTooltip="View">
|
||||||
|
<mat-icon>visibility</mat-icon>
|
||||||
|
</button>
|
||||||
|
<button type="button" mat-icon-button (click)="removeExistingSeniorCitizenProof()" matTooltip="Remove">
|
||||||
|
<mat-icon>close</mat-icon>
|
||||||
|
</button>
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
|
||||||
|
<div class="checkbox-field full-width">
|
||||||
|
<mat-checkbox formControlName="isPwd">Person with Disability (PWD)</mat-checkbox>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
@if (createClientForm.get('isPwd')?.value) {
|
||||||
|
<mat-form-field appearance="outline" class="form-field">
|
||||||
|
<mat-label>Disability Type</mat-label>
|
||||||
|
<mat-select formControlName="pwdCategoryId">
|
||||||
|
@for (category of pwdCategoryOptions; track category.id) {
|
||||||
|
<mat-option [value]="category.id">{{ category.name }}</mat-option>
|
||||||
|
}
|
||||||
|
</mat-select>
|
||||||
|
@if (seniorPwdDocsTouched && !createClientForm.get('pwdCategoryId')?.value) {
|
||||||
|
<mat-error>Disability type is required</mat-error>
|
||||||
|
}
|
||||||
|
</mat-form-field>
|
||||||
|
|
||||||
|
<div class="full-width doc-upload-field">
|
||||||
|
<label
|
||||||
|
class="doc-upload-label"
|
||||||
|
[class.doc-upload-label--missing]="
|
||||||
|
seniorPwdDocsTouched && !pwdCertificateFile && !existingPwdCertificateDoc
|
||||||
|
"
|
||||||
|
>
|
||||||
|
<input type="file" accept=".jpg,.jpeg,.png,.pdf" hidden (change)="onPwdCertificateSelected($event)" />
|
||||||
|
<fa-icon icon="file-upload"></fa-icon>
|
||||||
|
@if (pwdCertificateFile) {
|
||||||
|
<span>{{ pwdCertificateFile.name }}</span>
|
||||||
|
} @else if (existingPwdCertificateDoc) {
|
||||||
|
<span>{{ existingPwdCertificateDoc.name }} (uploaded)</span>
|
||||||
|
} @else {
|
||||||
|
<span>Upload Disability Certificate <span class="doc-upload-req">*</span></span>
|
||||||
|
}
|
||||||
|
</label>
|
||||||
|
@if (pwdCertificateFile) {
|
||||||
|
<button type="button" mat-icon-button (click)="removePwdCertificate()" matTooltip="Remove">
|
||||||
|
<mat-icon>close</mat-icon>
|
||||||
|
</button>
|
||||||
|
} @else if (existingPwdCertificateDoc) {
|
||||||
|
<button type="button" mat-icon-button (click)="viewExistingPwdCertificate()" matTooltip="View">
|
||||||
|
<mat-icon>visibility</mat-icon>
|
||||||
|
</button>
|
||||||
|
<button type="button" mat-icon-button (click)="removeExistingPwdCertificate()" matTooltip="Remove">
|
||||||
|
<mat-icon>close</mat-icon>
|
||||||
|
</button>
|
||||||
|
}
|
||||||
|
@if (seniorPwdDocsTouched && !pwdCertificateFile && !existingPwdCertificateDoc) {
|
||||||
|
<span class="doc-upload-error">Required when PWD is checked</span>
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
}
|
}
|
||||||
|
|||||||
+103
@@ -56,6 +56,78 @@ form {
|
|||||||
grid-column: 1 / -1;
|
grid-column: 1 / -1;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.senior-citizen-auto-note {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
font-size: 12px;
|
||||||
|
color: #1074b9;
|
||||||
|
background: #eff6ff;
|
||||||
|
border: 1px solid #bfdbfe;
|
||||||
|
border-radius: 8px;
|
||||||
|
padding: 8px 12px;
|
||||||
|
margin-bottom: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.doc-upload-field {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
margin-bottom: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.doc-upload-label {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
flex: 1;
|
||||||
|
padding: 10px 14px;
|
||||||
|
border: 1px dashed #cbd5e1;
|
||||||
|
border-radius: 8px;
|
||||||
|
font-size: 13px;
|
||||||
|
color: #475569;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: border-color 0.15s;
|
||||||
|
|
||||||
|
&:hover {
|
||||||
|
border-color: #1074b9;
|
||||||
|
}
|
||||||
|
|
||||||
|
&.doc-upload-label--missing {
|
||||||
|
border-color: #dc2626;
|
||||||
|
color: #dc2626;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.doc-upload-req {
|
||||||
|
color: #dc2626;
|
||||||
|
font-weight: 700;
|
||||||
|
}
|
||||||
|
|
||||||
|
.doc-upload-error {
|
||||||
|
font-size: 11px;
|
||||||
|
color: #dc2626;
|
||||||
|
}
|
||||||
|
|
||||||
|
.subsection-title {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 6px;
|
||||||
|
font-size: 12px;
|
||||||
|
font-weight: 600;
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 0.04em;
|
||||||
|
color: #6b7280;
|
||||||
|
margin: 20px 0 4px;
|
||||||
|
padding-top: 16px;
|
||||||
|
border-top: 1px solid #e5e7eb;
|
||||||
|
|
||||||
|
.subsection-icon {
|
||||||
|
color: var(--md-sys-color-primary, #1074b9);
|
||||||
|
font-size: 12px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
.checkbox-field {
|
.checkbox-field {
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
@@ -234,6 +306,18 @@ form {
|
|||||||
letter-spacing: 0.5px;
|
letter-spacing: 0.5px;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.senior-citizen-badge {
|
||||||
|
font-size: 10px;
|
||||||
|
font-weight: 700;
|
||||||
|
padding: 2px 8px;
|
||||||
|
border-radius: 10px;
|
||||||
|
background: #eff6ff;
|
||||||
|
color: #1074b9;
|
||||||
|
margin-left: 6px;
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 0.5px;
|
||||||
|
}
|
||||||
|
|
||||||
/* ═══ Guardian Section (Redesigned) ═══ */
|
/* ═══ Guardian Section (Redesigned) ═══ */
|
||||||
.guardian-section {
|
.guardian-section {
|
||||||
margin-top: 12px;
|
margin-top: 12px;
|
||||||
@@ -1493,6 +1577,21 @@ form {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* Create-time mobile/email OTP verification — spans both `.form-grid` columns so the two
|
||||||
|
* blocks sit on their own full-width row instead of disrupting the field/verify-button pairing
|
||||||
|
* above them. */
|
||||||
|
.contact-otp-row {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: 16px;
|
||||||
|
grid-column: 1 / -1;
|
||||||
|
margin-top: 4px;
|
||||||
|
|
||||||
|
mifosx-client-contact-otp-block {
|
||||||
|
flex: 1 1 260px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
@media (width <= 768px) {
|
@media (width <= 768px) {
|
||||||
.form-grid {
|
.form-grid {
|
||||||
grid-template-columns: 1fr;
|
grid-template-columns: 1fr;
|
||||||
@@ -1516,6 +1615,10 @@ form {
|
|||||||
max-width: none;
|
max-width: none;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.contact-otp-row {
|
||||||
|
flex-direction: column;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
.guardian-card__avatar.has-photo {
|
.guardian-card__avatar.has-photo {
|
||||||
|
|||||||
+534
-7
@@ -46,10 +46,15 @@ import { MatSelectTrigger } from '@angular/material/select';
|
|||||||
import { MatTooltip } from '@angular/material/tooltip';
|
import { MatTooltip } from '@angular/material/tooltip';
|
||||||
import { MatDialog, MAT_DIALOG_DATA, MatDialogContent, MatDialogClose } from '@angular/material/dialog';
|
import { MatDialog, MAT_DIALOG_DATA, MatDialogContent, MatDialogClose } from '@angular/material/dialog';
|
||||||
import { STANDALONE_SHARED_IMPORTS } from 'app/standalone-shared.module';
|
import { STANDALONE_SHARED_IMPORTS } from 'app/standalone-shared.module';
|
||||||
|
import { DocumentPreviewDialogComponent } from '../../shared/document-preview-dialog/document-preview-dialog.component';
|
||||||
import { DateFormatPipe } from '../../../pipes/date-format.pipe';
|
import { DateFormatPipe } from '../../../pipes/date-format.pipe';
|
||||||
import { SanctionsVerifyDialogComponent } from './sanctions-verify-dialog.component';
|
import { SanctionsVerifyDialogComponent } from './sanctions-verify-dialog.component';
|
||||||
import { DipVerifyDialogComponent } from './dip-verify-dialog.component';
|
import { DipVerifyDialogComponent } from './dip-verify-dialog.component';
|
||||||
import { DinVerifyDialogComponent } from './din-verify-dialog.component';
|
import { DinVerifyDialogComponent } from './din-verify-dialog.component';
|
||||||
|
import {
|
||||||
|
ContactOtpBlockComponent,
|
||||||
|
ContactOtpState
|
||||||
|
} from 'app/clients/edit-client/contact-otp-block/contact-otp-block.component';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Create Client Component
|
* Create Client Component
|
||||||
@@ -68,7 +73,8 @@ import { DinVerifyDialogComponent } from './din-verify-dialog.component';
|
|||||||
MatIcon,
|
MatIcon,
|
||||||
MatTooltip,
|
MatTooltip,
|
||||||
MatSelectTrigger,
|
MatSelectTrigger,
|
||||||
DateFormatPipe
|
DateFormatPipe,
|
||||||
|
ContactOtpBlockComponent
|
||||||
]
|
]
|
||||||
})
|
})
|
||||||
export class ClientGeneralStepComponent implements OnInit, OnDestroy, OnChanges {
|
export class ClientGeneralStepComponent implements OnInit, OnDestroy, OnChanges {
|
||||||
@@ -88,6 +94,148 @@ export class ClientGeneralStepComponent implements OnInit, OnDestroy, OnChanges
|
|||||||
/** Expose enum to template */
|
/** Expose enum to template */
|
||||||
readonly LegalFormId = LegalFormId;
|
readonly LegalFormId = LegalFormId;
|
||||||
|
|
||||||
|
/** Senior Citizen proof is optional; the PWD certificate is mandatory (see seniorPwdDocsComplete). Both, when
|
||||||
|
* attached, are uploaded after client creation. */
|
||||||
|
seniorCitizenProofFile: File | null = null;
|
||||||
|
pwdCertificateFile: File | null = null;
|
||||||
|
|
||||||
|
/** Previously-uploaded documents, loaded in edit mode so the certificate doesn't appear to
|
||||||
|
* "disappear" on reopening — the raw File properties above only ever hold a fresh selection. */
|
||||||
|
existingSeniorCitizenProofDoc: { id: string; name: string } | null = null;
|
||||||
|
existingPwdCertificateDoc: { id: string; name: string } | null = null;
|
||||||
|
|
||||||
|
/** Disability categories (per the Rights of Persons with Disabilities Act, 2016) — fetched from
|
||||||
|
* m_code_value (code "PwdCategory") via the client template, same as occupationCategoryOptions.
|
||||||
|
* Admins can add/reorder/deactivate entries from System -> Manage Codes without a code change. */
|
||||||
|
pwdCategoryOptions: any[] = [];
|
||||||
|
riskRules: any[] = [];
|
||||||
|
|
||||||
|
/** Set true after a save attempt so the UI can highlight a missing required document */
|
||||||
|
seniorPwdDocsTouched = false;
|
||||||
|
|
||||||
|
/** Senior Citizen proof is optional. The PWD certificate + disability type are mandatory
|
||||||
|
* whenever the PWD checkbox is set. */
|
||||||
|
get seniorPwdDocsComplete(): boolean {
|
||||||
|
const pwdOk =
|
||||||
|
!this.createClientForm.get('isPwd')?.value ||
|
||||||
|
((!!this.pwdCertificateFile || !!this.existingPwdCertificateDoc) &&
|
||||||
|
!!this.createClientForm.get('pwdCategoryId')?.value);
|
||||||
|
return pwdOk;
|
||||||
|
}
|
||||||
|
|
||||||
|
markSeniorPwdDocsTouched(): void {
|
||||||
|
this.seniorPwdDocsTouched = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
onSeniorCitizenProofSelected(event: any): void {
|
||||||
|
const file = event.target?.files?.[0];
|
||||||
|
if (file) {
|
||||||
|
this.seniorCitizenProofFile = file;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
removeSeniorCitizenProof(): void {
|
||||||
|
this.seniorCitizenProofFile = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
onPwdCertificateSelected(event: any): void {
|
||||||
|
const file = event.target?.files?.[0];
|
||||||
|
if (file) {
|
||||||
|
this.pwdCertificateFile = file;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
removePwdCertificate(): void {
|
||||||
|
this.pwdCertificateFile = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Loads previously-uploaded senior-citizen-proof / PWD-certificate documents in edit mode —
|
||||||
|
* mirrors the KYC step's loadExistingSignature() pattern so re-opening edit shows what's
|
||||||
|
* already on file instead of an empty upload prompt. */
|
||||||
|
private loadExistingSeniorPwdDocuments(): void {
|
||||||
|
const clientId = this.clientData?.id;
|
||||||
|
if (!clientId) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
this.clientService.getClientDocuments(clientId.toString()).subscribe({
|
||||||
|
next: (documents: any[]) => {
|
||||||
|
const seniorDoc = documents
|
||||||
|
?.filter((d: any) => d.name === 'seniorCitizenProof')
|
||||||
|
?.sort((a: any, b: any) => b.id - a.id)?.[0];
|
||||||
|
if (seniorDoc?.id) {
|
||||||
|
this.existingSeniorCitizenProofDoc = {
|
||||||
|
id: seniorDoc.id.toString(),
|
||||||
|
name: seniorDoc.fileName || 'Senior Citizen Proof'
|
||||||
|
};
|
||||||
|
}
|
||||||
|
const pwdDoc = documents
|
||||||
|
?.filter((d: any) => d.name === 'pwdCertificate')
|
||||||
|
?.sort((a: any, b: any) => b.id - a.id)?.[0];
|
||||||
|
if (pwdDoc?.id) {
|
||||||
|
this.existingPwdCertificateDoc = {
|
||||||
|
id: pwdDoc.id.toString(),
|
||||||
|
name: pwdDoc.fileName || 'Disability Certificate'
|
||||||
|
};
|
||||||
|
}
|
||||||
|
},
|
||||||
|
error: () => {}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
viewExistingSeniorCitizenProof(): void {
|
||||||
|
const clientId = this.clientData?.id;
|
||||||
|
if (!clientId || !this.existingSeniorCitizenProofDoc) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
this.clientService.downloadClientDocument(clientId.toString(), this.existingSeniorCitizenProofDoc.id).subscribe({
|
||||||
|
next: (blob: any) =>
|
||||||
|
this.dialog.open(DocumentPreviewDialogComponent, {
|
||||||
|
data: {
|
||||||
|
blob,
|
||||||
|
title: this.existingSeniorCitizenProofDoc?.name,
|
||||||
|
fileName: this.existingSeniorCitizenProofDoc?.name
|
||||||
|
}
|
||||||
|
}),
|
||||||
|
error: () => {}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
removeExistingSeniorCitizenProof(): void {
|
||||||
|
const clientId = this.clientData?.id;
|
||||||
|
if (!clientId || !this.existingSeniorCitizenProofDoc) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
this.clientService.deleteClientDocument(clientId.toString(), this.existingSeniorCitizenProofDoc.id).subscribe({
|
||||||
|
next: () => (this.existingSeniorCitizenProofDoc = null),
|
||||||
|
error: () => {}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
viewExistingPwdCertificate(): void {
|
||||||
|
const clientId = this.clientData?.id;
|
||||||
|
if (!clientId || !this.existingPwdCertificateDoc) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
this.clientService.downloadClientDocument(clientId.toString(), this.existingPwdCertificateDoc.id).subscribe({
|
||||||
|
next: (blob: any) =>
|
||||||
|
this.dialog.open(DocumentPreviewDialogComponent, {
|
||||||
|
data: { blob, title: this.existingPwdCertificateDoc?.name, fileName: this.existingPwdCertificateDoc?.name }
|
||||||
|
}),
|
||||||
|
error: () => {}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
removeExistingPwdCertificate(): void {
|
||||||
|
const clientId = this.clientData?.id;
|
||||||
|
if (!clientId || !this.existingPwdCertificateDoc) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
this.clientService.deleteClientDocument(clientId.toString(), this.existingPwdCertificateDoc.id).subscribe({
|
||||||
|
next: () => (this.existingPwdCertificateDoc = null),
|
||||||
|
error: () => {}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
/** Minimum date allowed. */
|
/** Minimum date allowed. */
|
||||||
minDate = new Date(2000, 0, 1);
|
minDate = new Date(2000, 0, 1);
|
||||||
/** Maximum date allowed. */
|
/** Maximum date allowed. */
|
||||||
@@ -150,8 +298,10 @@ export class ClientGeneralStepComponent implements OnInit, OnDestroy, OnChanges
|
|||||||
];
|
];
|
||||||
/** Calculated age detail string (Y/M/D) */
|
/** Calculated age detail string (Y/M/D) */
|
||||||
calculatedAgeDetail: string | null = null;
|
calculatedAgeDetail: string | null = null;
|
||||||
/** Whether client is a minor (age < 10) */
|
/** Whether client is a minor (age < 18) */
|
||||||
isMinor = false;
|
isMinor = false;
|
||||||
|
/** Whether client is a senior citizen (age >= 60) — auto-detected from Date of Birth */
|
||||||
|
isSeniorCitizen = false;
|
||||||
/** Age breakdown for DB storage */
|
/** Age breakdown for DB storage */
|
||||||
ageBreakdown = { years: 0, months: 0, days: 0 };
|
ageBreakdown = { years: 0, months: 0, days: 0 };
|
||||||
|
|
||||||
@@ -183,6 +333,45 @@ export class ClientGeneralStepComponent implements OnInit, OnDestroy, OnChanges
|
|||||||
alternateDipLookupLogId: number | null = null;
|
alternateDipLookupLogId: number | null = null;
|
||||||
/** Alternate DIP lookup timestamp from API */
|
/** Alternate DIP lookup timestamp from API */
|
||||||
alternateDipTimestamp: string | null = null;
|
alternateDipTimestamp: string | null = null;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* CLIENT CREATION contact-OTP verification state (mobileNo / emailAddress). Unlike the
|
||||||
|
* edit-flow dual old/new OTP panel in `EditClientComponent`, a brand-new client has no "old"
|
||||||
|
* destination to verify against — so only a single "new value" OTP-verification is needed
|
||||||
|
* per field here. Sent to the backend as `mobileNewOtpId`/`emailNewOtpId` alongside the rest
|
||||||
|
* of the create-client payload (see `CreateClientComponent.saveAndProceed()`), which requires
|
||||||
|
* both whenever `mobileNo`/`emailAddress` are present (and they always are — both are
|
||||||
|
* `Validators.required` on this form).
|
||||||
|
*/
|
||||||
|
mobileOtpVerified = false;
|
||||||
|
mobileNewOtpId: number | null = null;
|
||||||
|
emailOtpVerified = false;
|
||||||
|
emailNewOtpId: number | null = null;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Whether both mobile and email have been OTP-verified for CLIENT CREATION. Always `true`
|
||||||
|
* in edit mode (`clientData` set) — edit-time contact verification is gated separately by
|
||||||
|
* `EditClientComponent.contactVerificationComplete`, not by this component.
|
||||||
|
*/
|
||||||
|
get contactVerified(): boolean {
|
||||||
|
if (this.clientData) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
return this.mobileOtpVerified && this.emailOtpVerified;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Resets create-time OTP verification for mobileNo — called whenever the field's value
|
||||||
|
* changes after a prior verification, since that OTP no longer applies to the new value. */
|
||||||
|
onMobileOtpState(state: ContactOtpState): void {
|
||||||
|
this.mobileOtpVerified = state.verified;
|
||||||
|
this.mobileNewOtpId = state.otpId;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Resets create-time OTP verification for emailAddress — see `onMobileOtpState()`. */
|
||||||
|
onEmailOtpState(state: ContactOtpState): void {
|
||||||
|
this.emailOtpVerified = state.verified;
|
||||||
|
this.emailNewOtpId = state.otpId;
|
||||||
|
}
|
||||||
/** Constitution Types Master with KYC field metadata */
|
/** Constitution Types Master with KYC field metadata */
|
||||||
constitutionTypesMaster = [
|
constitutionTypesMaster = [
|
||||||
{
|
{
|
||||||
@@ -358,6 +547,18 @@ export class ClientGeneralStepComponent implements OnInit, OnDestroy, OnChanges
|
|||||||
ngOnInit() {
|
ngOnInit() {
|
||||||
this.maxDate = this.settingsService.businessDate;
|
this.maxDate = this.settingsService.businessDate;
|
||||||
this.setOptions();
|
this.setOptions();
|
||||||
|
|
||||||
|
// Risk rules are an enhancement on top of the default low/medium-risk fallback in
|
||||||
|
// calculatedRisk — if the endpoint isn't available (e.g. not yet deployed on this
|
||||||
|
// backend), fail silently rather than surfacing a raw HTTP-error toast to the user.
|
||||||
|
this.clientService.getClientRiskRules().subscribe({
|
||||||
|
next: (rules) => {
|
||||||
|
this.riskRules = rules || [];
|
||||||
|
},
|
||||||
|
error: () => {
|
||||||
|
this.riskRules = [];
|
||||||
|
}
|
||||||
|
});
|
||||||
// Fetch marital status options from code values
|
// Fetch marital status options from code values
|
||||||
this.clientService.getCodeValuesByName('MaritalStatus').subscribe((options) => {
|
this.clientService.getCodeValuesByName('MaritalStatus').subscribe((options) => {
|
||||||
this.maritalStatusOptions = options || [];
|
this.maritalStatusOptions = options || [];
|
||||||
@@ -422,6 +623,51 @@ export class ClientGeneralStepComponent implements OnInit, OnDestroy, OnChanges
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Resets transient session state back to a blank-client baseline.
|
||||||
|
*
|
||||||
|
* This app's RouteReusableStrategy (core/route/route-reusable-strategy.ts) keeps the SAME
|
||||||
|
* component instance alive across repeat visits to /clients/create — `ngOnInit`/the
|
||||||
|
* constructor never re-run for a second "create new client" session. Without this, computed
|
||||||
|
* state like `isMinor`, the linked guardian, or a prior sanctions/OTP verification would leak
|
||||||
|
* from whatever client was created in the previous session into the new one. Called by
|
||||||
|
* CreateClientComponent whenever `route.data` re-emits (which happens on every navigation,
|
||||||
|
* even a reused one).
|
||||||
|
*/
|
||||||
|
resetForNewClient(): void {
|
||||||
|
this.createClientForm.reset();
|
||||||
|
// legalFormId's raw FormControl default is '' — the buildDependencies() subscription
|
||||||
|
// treats any non-PERSON value (including '') as "non-individual" and adds fullname /
|
||||||
|
// clientNonPersonDetails. Patch it back to the same PERSON default buildDependencies()
|
||||||
|
// itself applies on first construction, so a reset lands on a blank Individual client
|
||||||
|
// instead of stuck with leftover Entity-only controls.
|
||||||
|
this.createClientForm.get('legalFormId')?.setValue(LegalFormId.PERSON);
|
||||||
|
this.isMinor = false;
|
||||||
|
this.calculatedAgeDetail = null;
|
||||||
|
this.ageBreakdown = { years: 0, months: 0, days: 0 };
|
||||||
|
this.selectedGuardian = null;
|
||||||
|
this.guardianSearchText = '';
|
||||||
|
this.guardianSearchResults = [];
|
||||||
|
this.guardianExpanded = false;
|
||||||
|
if (this.createClientForm.get('guardianDetails')) {
|
||||||
|
this.createClientForm.removeControl('guardianDetails');
|
||||||
|
}
|
||||||
|
// Verification state must not carry over — a previous client's cleared sanctions
|
||||||
|
// screening or verified OTP must not silently apply to a different new client.
|
||||||
|
this.mobileVerified = false;
|
||||||
|
this.dipLookupLogId = null;
|
||||||
|
this.dipTimestamp = null;
|
||||||
|
this.alternateMobileVerified = false;
|
||||||
|
this.alternateDipLookupLogId = null;
|
||||||
|
this.alternateDipTimestamp = null;
|
||||||
|
this.mobileNewOtpId = null;
|
||||||
|
this.emailNewOtpId = null;
|
||||||
|
this.sanctionsVerified = false;
|
||||||
|
this.sanctionsBlocked = false;
|
||||||
|
this.screeningLogId = null;
|
||||||
|
this.verifiedName = '';
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Pre-fills the General step form from a manually-entered family member
|
* Pre-fills the General step form from a manually-entered family member
|
||||||
* when the user clicks "Create as Client" on the family members tab.
|
* when the user clicks "Create as Client" on the family members tab.
|
||||||
@@ -461,6 +707,7 @@ export class ClientGeneralStepComponent implements OnInit, OnDestroy, OnChanges
|
|||||||
*/
|
*/
|
||||||
private prefillForEdit() {
|
private prefillForEdit() {
|
||||||
const data = this.clientData;
|
const data = this.clientData;
|
||||||
|
this.loadExistingSeniorPwdDocuments();
|
||||||
|
|
||||||
// Patch all base controls that exist on the form
|
// Patch all base controls that exist on the form
|
||||||
this.createClientForm.patchValue({
|
this.createClientForm.patchValue({
|
||||||
@@ -477,11 +724,19 @@ export class ClientGeneralStepComponent implements OnInit, OnDestroy, OnChanges
|
|||||||
mobileNo: data.mobileNo,
|
mobileNo: data.mobileNo,
|
||||||
alternateMobileNo: data.alternateMobileNo || '',
|
alternateMobileNo: data.alternateMobileNo || '',
|
||||||
emailAddress: data.emailAddress,
|
emailAddress: data.emailAddress,
|
||||||
dateOfBirth: data.dateOfBirth ? new Date(data.dateOfBirth) : '',
|
dateOfBirth: data.dateOfBirth
|
||||||
|
? Array.isArray(data.dateOfBirth)
|
||||||
|
? new Date(data.dateOfBirth[0], data.dateOfBirth[1] - 1, data.dateOfBirth[2])
|
||||||
|
: new Date(data.dateOfBirth)
|
||||||
|
: '',
|
||||||
occupationCategoryId: data.occupationCategory?.id || data.occupationCategoryId || '',
|
occupationCategoryId: data.occupationCategory?.id || data.occupationCategoryId || '',
|
||||||
occupationId: data.occupation?.id || data.occupationId || '',
|
occupationId: data.occupation?.id || data.occupationId || '',
|
||||||
monthlyIncome: data.monthlyIncome || '',
|
monthlyIncome: data.monthlyIncome || '',
|
||||||
|
annualIncome: data.annualIncome || '',
|
||||||
annualTurnover: data.annualTurnover || '',
|
annualTurnover: data.annualTurnover || '',
|
||||||
|
monthlyTurnover: data.monthlyTurnover || '',
|
||||||
|
isPwd: data.isPwd || false,
|
||||||
|
pwdCategoryId: data.pwdCategory?.id || '',
|
||||||
agricultureIncome: data.agricultureIncome || '',
|
agricultureIncome: data.agricultureIncome || '',
|
||||||
nonAgricultureIncome: data.nonAgricultureIncome || '',
|
nonAgricultureIncome: data.nonAgricultureIncome || '',
|
||||||
submittedOnDate: data.timeline?.submittedOnDate
|
submittedOnDate: data.timeline?.submittedOnDate
|
||||||
@@ -555,6 +810,42 @@ export class ClientGeneralStepComponent implements OnInit, OnDestroy, OnChanges
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Restore linked guardian for minor clients. The `guardianDetails` control group above was
|
||||||
|
// just created synchronously by the dateOfBirth subscription (patched a few lines up), which
|
||||||
|
// also resets `selectedGuardian` to null — so this has to run after that patch, not before it.
|
||||||
|
// If a guardian is linked in the database (or the client is marked minor), we force minor state
|
||||||
|
// and explicitly ensure the form control exists so the guardian details load correctly.
|
||||||
|
if ((data.isMinor || this.isMinor || data.guardianClientId) && data.guardianClientId) {
|
||||||
|
this.isMinor = true;
|
||||||
|
if (!this.createClientForm.get('guardianDetails')) {
|
||||||
|
this.createClientForm.addControl(
|
||||||
|
'guardianDetails',
|
||||||
|
this.formBuilder.group({
|
||||||
|
guardianClientId: [
|
||||||
|
'',
|
||||||
|
Validators.required
|
||||||
|
],
|
||||||
|
guardianRelation: [
|
||||||
|
'',
|
||||||
|
Validators.required
|
||||||
|
]
|
||||||
|
})
|
||||||
|
);
|
||||||
|
}
|
||||||
|
setTimeout(() => {
|
||||||
|
this.createClientForm.get('guardianDetails')?.patchValue({
|
||||||
|
guardianClientId: data.guardianClientId,
|
||||||
|
guardianRelation: data.guardianRelation || ''
|
||||||
|
});
|
||||||
|
this.selectedGuardian = {
|
||||||
|
id: data.guardianClientId,
|
||||||
|
displayName: data.guardianClientDisplayName || '',
|
||||||
|
_loading: true
|
||||||
|
};
|
||||||
|
this.fetchClientFullDetails(data.guardianClientId, this.selectedGuardian);
|
||||||
|
});
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -621,6 +912,13 @@ export class ClientGeneralStepComponent implements OnInit, OnDestroy, OnChanges
|
|||||||
Validators.min(0)
|
Validators.min(0)
|
||||||
]
|
]
|
||||||
],
|
],
|
||||||
|
annualIncome: [
|
||||||
|
'',
|
||||||
|
[
|
||||||
|
Validators.pattern(/^\d+(\.\d{1,2})?$/),
|
||||||
|
Validators.min(0)
|
||||||
|
]
|
||||||
|
],
|
||||||
annualTurnover: [
|
annualTurnover: [
|
||||||
'',
|
'',
|
||||||
[
|
[
|
||||||
@@ -628,6 +926,15 @@ export class ClientGeneralStepComponent implements OnInit, OnDestroy, OnChanges
|
|||||||
Validators.min(0)
|
Validators.min(0)
|
||||||
]
|
]
|
||||||
],
|
],
|
||||||
|
monthlyTurnover: [
|
||||||
|
'',
|
||||||
|
[
|
||||||
|
Validators.pattern(/^\d+(\.\d{1,2})?$/),
|
||||||
|
Validators.min(0)
|
||||||
|
]
|
||||||
|
],
|
||||||
|
isPwd: [false],
|
||||||
|
pwdCategoryId: [''],
|
||||||
agricultureIncome: [
|
agricultureIncome: [
|
||||||
'',
|
'',
|
||||||
[
|
[
|
||||||
@@ -647,6 +954,79 @@ export class ClientGeneralStepComponent implements OnInit, OnDestroy, OnChanges
|
|||||||
Validators.required
|
Validators.required
|
||||||
]
|
]
|
||||||
});
|
});
|
||||||
|
|
||||||
|
this.linkMonthlyAnnual('monthlyIncome', 'annualIncome');
|
||||||
|
this.linkMonthlyAnnual('monthlyTurnover', 'annualTurnover');
|
||||||
|
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
|
||||||
|
* one auto-fills the other. Uses { emitEvent: false } on the write side so the two
|
||||||
|
* subscriptions don't trigger each other back and forth.
|
||||||
|
*/
|
||||||
|
/** Rounds to at most 2 decimals without padding whole numbers with a trailing ".00". */
|
||||||
|
private round2(n: number): string {
|
||||||
|
return (Math.round(n * 100) / 100).toString();
|
||||||
|
}
|
||||||
|
|
||||||
|
private linkMonthlyAnnual(monthlyControlName: string, annualControlName: string): void {
|
||||||
|
const monthly = this.createClientForm.get(monthlyControlName);
|
||||||
|
const annual = this.createClientForm.get(annualControlName);
|
||||||
|
if (!monthly || !annual) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
monthly.valueChanges.subscribe((value) => {
|
||||||
|
if (this.suppressMonthlyAnnualLink) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const num = parseFloat(value);
|
||||||
|
annual.setValue(!isNaN(num) && num >= 0 ? this.round2(num * 12) : '', { emitEvent: false });
|
||||||
|
});
|
||||||
|
|
||||||
|
annual.valueChanges.subscribe((value) => {
|
||||||
|
if (this.suppressMonthlyAnnualLink) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const num = parseFloat(value);
|
||||||
|
monthly.setValue(!isNaN(num) && num >= 0 ? this.round2(num / 12) : '', { emitEvent: false });
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Auto-calculates turnover based on income (doubling monthly income for monthly turnover,
|
||||||
|
* and annual income for annual/yearly turnover).
|
||||||
|
*/
|
||||||
|
private linkIncomeToTurnover(): void {
|
||||||
|
const monthlyIncome = this.createClientForm.get('monthlyIncome');
|
||||||
|
const monthlyTurnover = this.createClientForm.get('monthlyTurnover');
|
||||||
|
const annualIncome = this.createClientForm.get('annualIncome');
|
||||||
|
const annualTurnover = this.createClientForm.get('annualTurnover');
|
||||||
|
|
||||||
|
if (monthlyIncome && monthlyTurnover) {
|
||||||
|
monthlyIncome.valueChanges.subscribe((value) => {
|
||||||
|
if (this.suppressMonthlyAnnualLink) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const num = parseFloat(value);
|
||||||
|
monthlyTurnover.setValue(!isNaN(num) && num >= 0 ? this.round2(num * 2) : '', { emitEvent: true });
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
if (annualIncome && annualTurnover) {
|
||||||
|
annualIncome.valueChanges.subscribe((value) => {
|
||||||
|
if (this.suppressMonthlyAnnualLink) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const num = parseFloat(value);
|
||||||
|
annualTurnover.setValue(!isNaN(num) && num >= 0 ? this.round2(num * 2) : '', { emitEvent: true });
|
||||||
|
});
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -665,6 +1045,7 @@ export class ClientGeneralStepComponent implements OnInit, OnDestroy, OnChanges
|
|||||||
this.occupationCategoryOptions = this.clientTemplate.occupationCategoryOptions || [];
|
this.occupationCategoryOptions = this.clientTemplate.occupationCategoryOptions || [];
|
||||||
this.occupationOptions = this.clientTemplate.occupationOptions || [];
|
this.occupationOptions = this.clientTemplate.occupationOptions || [];
|
||||||
this.salutationOptions = this.clientTemplate.salutationOptions || [];
|
this.salutationOptions = this.clientTemplate.salutationOptions || [];
|
||||||
|
this.pwdCategoryOptions = this.clientTemplate.pwdCategoryOptions || [];
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -780,7 +1161,8 @@ export class ClientGeneralStepComponent implements OnInit, OnDestroy, OnChanges
|
|||||||
this.calculatedAgeDetail = `${years} yr${years !== 1 ? 's' : ''} ${months} mo${months !== 1 ? 's' : ''} ${days} day${days !== 1 ? 's' : ''}`;
|
this.calculatedAgeDetail = `${years} yr${years !== 1 ? 's' : ''} ${months} mo${months !== 1 ? 's' : ''} ${days} day${days !== 1 ? 's' : ''}`;
|
||||||
this.ageBreakdown = { years, months, days };
|
this.ageBreakdown = { years, months, days };
|
||||||
const wasMinor = this.isMinor;
|
const wasMinor = this.isMinor;
|
||||||
this.isMinor = years < 10;
|
this.isMinor = years < 18;
|
||||||
|
this.isSeniorCitizen = years >= 60;
|
||||||
// Add/remove guardian form group based on minor status
|
// Add/remove guardian form group based on minor status
|
||||||
if (this.isMinor && !wasMinor) {
|
if (this.isMinor && !wasMinor) {
|
||||||
this.createClientForm.addControl(
|
this.createClientForm.addControl(
|
||||||
@@ -806,12 +1188,14 @@ export class ClientGeneralStepComponent implements OnInit, OnDestroy, OnChanges
|
|||||||
} else {
|
} else {
|
||||||
this.calculatedAgeDetail = null;
|
this.calculatedAgeDetail = null;
|
||||||
this.isMinor = false;
|
this.isMinor = false;
|
||||||
|
this.isSeniorCitizen = false;
|
||||||
this.ageBreakdown = { years: 0, months: 0, days: 0 };
|
this.ageBreakdown = { years: 0, months: 0, days: 0 };
|
||||||
this.createClientForm.removeControl('guardianDetails');
|
this.createClientForm.removeControl('guardianDetails');
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
this.calculatedAgeDetail = null;
|
this.calculatedAgeDetail = null;
|
||||||
this.isMinor = false;
|
this.isMinor = false;
|
||||||
|
this.isSeniorCitizen = false;
|
||||||
this.ageBreakdown = { years: 0, months: 0, days: 0 };
|
this.ageBreakdown = { years: 0, months: 0, days: 0 };
|
||||||
this.createClientForm.removeControl('guardianDetails');
|
this.createClientForm.removeControl('guardianDetails');
|
||||||
}
|
}
|
||||||
@@ -853,6 +1237,13 @@ export class ClientGeneralStepComponent implements OnInit, OnDestroy, OnChanges
|
|||||||
.valueChanges.pipe(takeUntil(this.destroy$))
|
.valueChanges.pipe(takeUntil(this.destroy$))
|
||||||
.subscribe(() => {
|
.subscribe(() => {
|
||||||
this.mobileVerified = false;
|
this.mobileVerified = false;
|
||||||
|
// Also reset create-time contact-OTP verification — an OTP verified against a
|
||||||
|
// previous mobile number no longer applies once the value is edited. Belt-and-braces
|
||||||
|
// alongside ContactOtpBlockComponent's own destination-change reset: this fires even
|
||||||
|
// if the OTP block was unmounted (e.g. `*ngIf` hid it while the field was momentarily
|
||||||
|
// invalid) and its own reset never had a chance to run.
|
||||||
|
this.mobileOtpVerified = false;
|
||||||
|
this.mobileNewOtpId = null;
|
||||||
});
|
});
|
||||||
|
|
||||||
this.createClientForm
|
this.createClientForm
|
||||||
@@ -861,6 +1252,16 @@ export class ClientGeneralStepComponent implements OnInit, OnDestroy, OnChanges
|
|||||||
.subscribe(() => {
|
.subscribe(() => {
|
||||||
this.alternateMobileVerified = false;
|
this.alternateMobileVerified = false;
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Reset create-time contact-OTP verification when email address changes — see the
|
||||||
|
// mobileNo subscription above for the rationale.
|
||||||
|
this.createClientForm
|
||||||
|
.get('emailAddress')
|
||||||
|
.valueChanges.pipe(takeUntil(this.destroy$))
|
||||||
|
.subscribe(() => {
|
||||||
|
this.emailOtpVerified = false;
|
||||||
|
this.emailNewOtpId = null;
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
ngOnChanges(changes: SimpleChanges) {
|
ngOnChanges(changes: SimpleChanges) {
|
||||||
@@ -1487,6 +1888,9 @@ export class ClientGeneralStepComponent implements OnInit, OnDestroy, OnChanges
|
|||||||
if (n.includes('postgrad') || n === 'pg' || n.includes('master')) return 'postgraduate';
|
if (n.includes('postgrad') || n === 'pg' || n.includes('master')) return 'postgraduate';
|
||||||
if (n.includes('undergrad') || n === 'ug' || n.includes('bachelor') || n.includes('graduate'))
|
if (n.includes('undergrad') || n === 'ug' || n.includes('bachelor') || n.includes('graduate'))
|
||||||
return 'undergraduate';
|
return 'undergraduate';
|
||||||
|
// Diploma is its own Education Level, distinct from School — checked before the school
|
||||||
|
// keywords below so it doesn't get swept into that bucket.
|
||||||
|
if (n.includes('diploma') || n.includes('polytechnic')) return 'diploma';
|
||||||
if (
|
if (
|
||||||
n.includes('school') ||
|
n.includes('school') ||
|
||||||
n.includes('10th') ||
|
n.includes('10th') ||
|
||||||
@@ -1495,7 +1899,6 @@ export class ClientGeneralStepComponent implements OnInit, OnDestroy, OnChanges
|
|||||||
n.includes('hsc') ||
|
n.includes('hsc') ||
|
||||||
n.includes('matric') ||
|
n.includes('matric') ||
|
||||||
n.includes('intermediate') ||
|
n.includes('intermediate') ||
|
||||||
n.includes('diploma') ||
|
|
||||||
n.includes('iti') ||
|
n.includes('iti') ||
|
||||||
n.includes('below')
|
n.includes('below')
|
||||||
)
|
)
|
||||||
@@ -1573,8 +1976,12 @@ export class ClientGeneralStepComponent implements OnInit, OnDestroy, OnChanges
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Degrees filtered by the currently selected Education Level.
|
* Degrees filtered by the currently selected Education Level. Each DegreeQualification code
|
||||||
* Returns all degrees if no level is selected, or category can't be determined.
|
* value is tagged with its owning level in `description` (e.g. "Undergraduate") when seeded —
|
||||||
|
* match on that authoritative field first. Only fall back to the name-keyword heuristic
|
||||||
|
* (getDegreeQualificationCategory) for older/untagged rows that predate this tagging, so
|
||||||
|
* degrees never silently disappear because an admin added one without a description.
|
||||||
|
* Returns all degrees if no level is selected.
|
||||||
*/
|
*/
|
||||||
get filteredDegreeQualificationOptions(): any[] {
|
get filteredDegreeQualificationOptions(): any[] {
|
||||||
const level = this.getSelectedEducationLevel();
|
const level = this.getSelectedEducationLevel();
|
||||||
@@ -1582,6 +1989,9 @@ export class ClientGeneralStepComponent implements OnInit, OnDestroy, OnChanges
|
|||||||
const category = this.getEducationLevelCategory(level.name);
|
const category = this.getEducationLevelCategory(level.name);
|
||||||
if (!category) return this.degreeQualificationOptions;
|
if (!category) return this.degreeQualificationOptions;
|
||||||
const filtered = this.degreeQualificationOptions.filter((d: any) => {
|
const filtered = this.degreeQualificationOptions.filter((d: any) => {
|
||||||
|
if (d.description) {
|
||||||
|
return this.getEducationLevelCategory(d.description) === category;
|
||||||
|
}
|
||||||
const c = this.getDegreeQualificationCategory(d.name);
|
const c = this.getDegreeQualificationCategory(d.name);
|
||||||
return c === category || c === '';
|
return c === category || c === '';
|
||||||
});
|
});
|
||||||
@@ -1744,6 +2154,8 @@ export class ClientGeneralStepComponent implements OnInit, OnDestroy, OnChanges
|
|||||||
// Store extended fields separately (not yet supported by Fineract API)
|
// Store extended fields separately (not yet supported by Fineract API)
|
||||||
// Add isMinor flag
|
// Add isMinor flag
|
||||||
generalDetails.isMinor = this.isMinor;
|
generalDetails.isMinor = this.isMinor;
|
||||||
|
// isSeniorCitizen is auto-detected from DOB (not a form control) — add it explicitly
|
||||||
|
generalDetails.isSeniorCitizen = this.isSeniorCitizen;
|
||||||
|
|
||||||
// Add guardian clientId for minors
|
// Add guardian clientId for minors
|
||||||
if (this.isMinor && generalDetails.guardianDetails) {
|
if (this.isMinor && generalDetails.guardianDetails) {
|
||||||
@@ -1794,6 +2206,121 @@ export class ClientGeneralStepComponent implements OnInit, OnDestroy, OnChanges
|
|||||||
|
|
||||||
return generalDetails;
|
return generalDetails;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
get calculatedRisk(): {
|
||||||
|
category: number;
|
||||||
|
reason: string;
|
||||||
|
amlCheckStatus?: string;
|
||||||
|
bannedEntityMatch?: string;
|
||||||
|
willfulDefaulter?: string;
|
||||||
|
ecgcSalFlag?: string;
|
||||||
|
dormantFlag?: string;
|
||||||
|
} {
|
||||||
|
const legalFormId = this.createClientForm.get('legalFormId')?.value || 1; // 1 = Person, 2 = Entity
|
||||||
|
const occCatId = this.createClientForm.get('occupationCategoryCvId')?.value;
|
||||||
|
const occId = this.createClientForm.get('occupationCvId')?.value;
|
||||||
|
|
||||||
|
const monthlyIncome = parseFloat(this.createClientForm.get('monthlyIncome')?.value) || 0;
|
||||||
|
const annualIncome = parseFloat(this.createClientForm.get('annualIncome')?.value) || 0;
|
||||||
|
const monthlyTurnover = parseFloat(this.createClientForm.get('monthlyTurnover')?.value) || 0;
|
||||||
|
const annualTurnover = parseFloat(this.createClientForm.get('annualTurnover')?.value) || 0;
|
||||||
|
const agricultureIncome = parseFloat(this.createClientForm.get('agricultureIncome')?.value) || 0;
|
||||||
|
const nonAgricultureIncome = parseFloat(this.createClientForm.get('nonAgricultureIncome')?.value) || 0;
|
||||||
|
|
||||||
|
const totalAnnualIncome = annualIncome + agricultureIncome + nonAgricultureIncome;
|
||||||
|
|
||||||
|
// Evaluate rules from DB (ordered by orderPosition ASC)
|
||||||
|
if (this.riskRules && this.riskRules.length > 0) {
|
||||||
|
for (const rule of this.riskRules) {
|
||||||
|
// Skip transaction/AML rules that have no KYC criteria
|
||||||
|
if (
|
||||||
|
rule.legalFormId == null &&
|
||||||
|
rule.occupationCategoryCvId == null &&
|
||||||
|
rule.occupationCvId == null &&
|
||||||
|
rule.minMonthlyIncome == null &&
|
||||||
|
rule.maxMonthlyIncome == null &&
|
||||||
|
rule.minAnnualIncome == null &&
|
||||||
|
rule.maxAnnualIncome == null &&
|
||||||
|
rule.minAnnualTurnover == null &&
|
||||||
|
rule.maxAnnualTurnover == null
|
||||||
|
) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Match legal form
|
||||||
|
if (rule.legalFormId != null && rule.legalFormId !== legalFormId) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Match occupation category
|
||||||
|
if (rule.occupationCategoryCvId != null && rule.occupationCategoryCvId !== occCatId) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Match occupation
|
||||||
|
if (rule.occupationCvId != null && rule.occupationCvId !== occId) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Match monthly income ranges
|
||||||
|
if (rule.minMonthlyIncome != null && monthlyIncome < rule.minMonthlyIncome) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (rule.maxMonthlyIncome != null && monthlyIncome > rule.maxMonthlyIncome) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Match annual income ranges (evaluated against totalAnnualIncome)
|
||||||
|
if (rule.minAnnualIncome != null && totalAnnualIncome < rule.minAnnualIncome) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (rule.maxAnnualIncome != null && totalAnnualIncome > rule.maxAnnualIncome) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Match turnover ranges
|
||||||
|
if (rule.minAnnualTurnover != null && annualTurnover < rule.minAnnualTurnover) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (rule.maxAnnualTurnover != null && annualTurnover > rule.maxAnnualTurnover) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
// All matched!
|
||||||
|
return {
|
||||||
|
category: rule.riskCategory,
|
||||||
|
reason: rule.reason,
|
||||||
|
amlCheckStatus: rule.amlCheckStatus || (rule.riskCategory === 3 ? 'FLAGGED' : 'CLEAR'),
|
||||||
|
bannedEntityMatch: rule.bannedEntityMatch || 'NO',
|
||||||
|
willfulDefaulter: rule.willfulDefaulter || 'NO',
|
||||||
|
ecgcSalFlag: rule.ecgcSalFlag || 'NO',
|
||||||
|
dormantFlag: rule.dormantFlag || 'ACTIVE'
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Default Fallbacks
|
||||||
|
if (legalFormId !== LegalFormId.PERSON) {
|
||||||
|
return {
|
||||||
|
category: 2,
|
||||||
|
reason: 'Non-Individual / Entity profile (Default Medium Risk)',
|
||||||
|
amlCheckStatus: 'CLEAR',
|
||||||
|
bannedEntityMatch: 'NO',
|
||||||
|
willfulDefaulter: 'NO',
|
||||||
|
ecgcSalFlag: 'NO',
|
||||||
|
dormantFlag: 'ACTIVE'
|
||||||
|
};
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
category: 1,
|
||||||
|
reason: 'Default Low Risk profile (Salary / Service / Agriculture / Others)',
|
||||||
|
amlCheckStatus: 'CLEAR',
|
||||||
|
bannedEntityMatch: 'NO',
|
||||||
|
willfulDefaulter: 'NO',
|
||||||
|
ecgcSalFlag: 'NO',
|
||||||
|
dormantFlag: 'ACTIVE'
|
||||||
|
};
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
+35
@@ -42,6 +42,7 @@ import { ClientsService } from 'app/clients/clients.service';
|
|||||||
<strong>UN Security Council</strong> &
|
<strong>UN Security Council</strong> &
|
||||||
<strong>Financial Intelligence Unit - India (FIU-IND under UNSC Resolution 2231)</strong>
|
<strong>Financial Intelligence Unit - India (FIU-IND under UNSC Resolution 2231)</strong>
|
||||||
</p>
|
</p>
|
||||||
|
<p class="regulatory-note">{{ regulatoryBasis }}</p>
|
||||||
</div>
|
</div>
|
||||||
}
|
}
|
||||||
@if (!verifying && !matchFound && !errorOccurred) {
|
@if (!verifying && !matchFound && !errorOccurred) {
|
||||||
@@ -69,7 +70,14 @@ import { ClientsService } from 'app/clients/clients.service';
|
|||||||
<span class="meta-label">Database</span>
|
<span class="meta-label">Database</span>
|
||||||
<span class="meta-value">UN SC Consolidated List</span>
|
<span class="meta-value">UN SC Consolidated List</span>
|
||||||
</div>
|
</div>
|
||||||
|
@if (listDataAsOf) {
|
||||||
|
<div class="meta-row">
|
||||||
|
<span class="meta-label">List Data As Of</span>
|
||||||
|
<span class="meta-value">{{ listDataAsOf }}</span>
|
||||||
</div>
|
</div>
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
<p class="regulatory-note">{{ regulatoryBasis }}</p>
|
||||||
</div>
|
</div>
|
||||||
}
|
}
|
||||||
@if (!verifying && matchFound) {
|
@if (!verifying && matchFound) {
|
||||||
@@ -96,11 +104,18 @@ import { ClientsService } from 'app/clients/clients.service';
|
|||||||
<span class="meta-label">Database</span>
|
<span class="meta-label">Database</span>
|
||||||
<span class="meta-value">UN SC Consolidated List</span>
|
<span class="meta-value">UN SC Consolidated List</span>
|
||||||
</div>
|
</div>
|
||||||
|
@if (listDataAsOf) {
|
||||||
|
<div class="meta-row">
|
||||||
|
<span class="meta-label">List Data As Of</span>
|
||||||
|
<span class="meta-value">{{ listDataAsOf }}</span>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
</div>
|
</div>
|
||||||
<div class="escalate-banner">
|
<div class="escalate-banner">
|
||||||
<fa-icon icon="exclamation-circle" class="escalate-icon"></fa-icon>
|
<fa-icon icon="exclamation-circle" class="escalate-icon"></fa-icon>
|
||||||
<span><strong>Escalate to Principal Officer immediately.</strong> Account opening blocked.</span>
|
<span><strong>Escalate to Principal Officer immediately.</strong> Account opening blocked.</span>
|
||||||
</div>
|
</div>
|
||||||
|
<p class="regulatory-note">{{ regulatoryBasis }}</p>
|
||||||
</div>
|
</div>
|
||||||
}
|
}
|
||||||
@if (!verifying && errorOccurred) {
|
@if (!verifying && errorOccurred) {
|
||||||
@@ -207,6 +222,15 @@ import { ClientsService } from 'app/clients/clients.service';
|
|||||||
text-align: center;
|
text-align: center;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.regulatory-note {
|
||||||
|
font-size: 11px;
|
||||||
|
color: #9ca3af;
|
||||||
|
line-height: 1.5;
|
||||||
|
text-align: center;
|
||||||
|
margin: 8px 0 0;
|
||||||
|
max-width: 420px;
|
||||||
|
}
|
||||||
|
|
||||||
.approve-card {
|
.approve-card {
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
@@ -524,6 +548,10 @@ export class SanctionsVerifyDialogComponent implements OnInit, OnDestroy {
|
|||||||
errorOccurred = false;
|
errorOccurred = false;
|
||||||
errorMessage = '';
|
errorMessage = '';
|
||||||
screeningLogId: number | null = null;
|
screeningLogId: number | null = null;
|
||||||
|
listDataAsOf: string | null = null;
|
||||||
|
|
||||||
|
regulatoryBasis =
|
||||||
|
'Screened as per Section 51-A of the Unlawful Activities (Prevention) Act, 1967 and the RBI Master Direction on KYC.';
|
||||||
|
|
||||||
screenedDate = new Date().toLocaleDateString('en-IN', { day: '2-digit', month: 'short', year: 'numeric' });
|
screenedDate = new Date().toLocaleDateString('en-IN', { day: '2-digit', month: 'short', year: 'numeric' });
|
||||||
progressValue = 0;
|
progressValue = 0;
|
||||||
@@ -624,6 +652,13 @@ export class SanctionsVerifyDialogComponent implements OnInit, OnDestroy {
|
|||||||
'Unable to connect to sanctions screening service.';
|
'Unable to connect to sanctions screening service.';
|
||||||
} else {
|
} else {
|
||||||
this.screeningLogId = this.apiResult?.screeningLogId || null;
|
this.screeningLogId = this.apiResult?.screeningLogId || null;
|
||||||
|
this.listDataAsOf = this.apiResult?.listDataAsOf
|
||||||
|
? new Date(this.apiResult.listDataAsOf).toLocaleDateString('en-IN', {
|
||||||
|
day: '2-digit',
|
||||||
|
month: 'short',
|
||||||
|
year: 'numeric'
|
||||||
|
})
|
||||||
|
: null;
|
||||||
if (this.apiResult?.matchFound) {
|
if (this.apiResult?.matchFound) {
|
||||||
this.matchFound = true;
|
this.matchFound = true;
|
||||||
this.matchCount = this.apiResult.matchCount || 0;
|
this.matchCount = this.apiResult.matchCount || 0;
|
||||||
|
|||||||
+3
-2
@@ -173,8 +173,9 @@
|
|||||||
@if (selectedAddrProof.requiresDate) {
|
@if (selectedAddrProof.requiresDate) {
|
||||||
<mat-form-field appearance="outline" class="addr-ff">
|
<mat-form-field appearance="outline" class="addr-ff">
|
||||||
<mat-label>Issue / bill date</mat-label>
|
<mat-label>Issue / bill date</mat-label>
|
||||||
<span matPrefix class="addr-prefix"><mat-icon>event</mat-icon></span>
|
<input matInput [matDatepicker]="addrIssueDatePicker" formControlName="issueDate" />
|
||||||
<input matInput type="date" formControlName="issueDate" />
|
<mat-datepicker-toggle matSuffix [for]="addrIssueDatePicker"></mat-datepicker-toggle>
|
||||||
|
<mat-datepicker #addrIssueDatePicker></mat-datepicker>
|
||||||
</mat-form-field>
|
</mat-form-field>
|
||||||
}
|
}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
+114
-25
@@ -15,9 +15,12 @@ import { MatCheckbox } from '@angular/material/checkbox';
|
|||||||
import { MatTabsModule } from '@angular/material/tabs';
|
import { MatTabsModule } from '@angular/material/tabs';
|
||||||
import { MatIcon } from '@angular/material/icon';
|
import { MatIcon } from '@angular/material/icon';
|
||||||
import { MatIconButton, MatButton } from '@angular/material/button';
|
import { MatIconButton, MatButton } from '@angular/material/button';
|
||||||
|
import { MatDialog } from '@angular/material/dialog';
|
||||||
import { ClientsService } from '../../clients.service';
|
import { ClientsService } from '../../clients.service';
|
||||||
import { ProofType, normalizeProofType, groupProofsByCategory } from '../../shared/kyc-proof-types';
|
import { ProofType, normalizeProofType, groupProofsByCategory } from '../../shared/kyc-proof-types';
|
||||||
import { KycUploadHelperService } from '../../shared/kyc-upload-helper.service';
|
import { KycUploadHelperService } from '../../shared/kyc-upload-helper.service';
|
||||||
|
import { DocumentPreviewDialogComponent } from '../../shared/document-preview-dialog/document-preview-dialog.component';
|
||||||
|
import { Dates } from 'app/core/utils/dates';
|
||||||
|
|
||||||
@Component({
|
@Component({
|
||||||
selector: 'mifosx-client-india-address-step',
|
selector: 'mifosx-client-india-address-step',
|
||||||
@@ -38,11 +41,20 @@ export class ClientIndiaAddressStepComponent implements OnInit, OnChanges {
|
|||||||
private http = inject(HttpClient);
|
private http = inject(HttpClient);
|
||||||
private clientsService = inject(ClientsService);
|
private clientsService = inject(ClientsService);
|
||||||
private kycUpload = inject(KycUploadHelperService);
|
private kycUpload = inject(KycUploadHelperService);
|
||||||
|
private dialog = inject(MatDialog);
|
||||||
|
private dates = inject(Dates);
|
||||||
|
|
||||||
/** Client ID — when set, loads existing addresses for edit mode */
|
/** Client ID — when set, loads existing addresses for edit mode */
|
||||||
@Input() clientId: string = '';
|
@Input() clientId: string = '';
|
||||||
/** Read-only mode — disables form editing until user clicks edit icon */
|
/** Read-only mode — disables form editing until user clicks edit icon */
|
||||||
@Input() readOnly = false;
|
@Input() readOnly = false;
|
||||||
|
/** Minor clients can't hold utility bills, bank statements or an adult-only OVD in their own
|
||||||
|
* name — restricts the address-proof picker to Aadhaar/Passport when true. */
|
||||||
|
@Input() isMinor = false;
|
||||||
|
/** Non-individual (entity) clients — hides personal OVDs (Aadhaar/Passport/DL/Voter ID/NREGA/
|
||||||
|
* NPR/Birth Certificate/School ID) from the proof-of-address picker; a company's registered
|
||||||
|
* office address is evidenced by utility bills, GST/rent documents etc., not a person's ID. */
|
||||||
|
@Input() isNonIndividual = false;
|
||||||
|
|
||||||
activeTab = 0;
|
activeTab = 0;
|
||||||
sameAsPermanent = false;
|
sameAsPermanent = false;
|
||||||
@@ -235,12 +247,50 @@ export class ClientIndiaAddressStepComponent implements OnInit, OnChanges {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Resets transient address-tab state for a brand-new client.
|
||||||
|
*
|
||||||
|
* This app's RouteReusableStrategy (core/route/route-reusable-strategy.ts) keeps this SAME
|
||||||
|
* component instance alive across repeat visits to /clients/create, so `ngOnInit` never
|
||||||
|
* re-runs for a second "create new client" session — the previous client's address forms,
|
||||||
|
* selected proof-of-address chip and uploaded files would otherwise leak into the new one.
|
||||||
|
* Called by CreateClientComponent.resetForNewClient() whenever a fresh create session begins.
|
||||||
|
*/
|
||||||
|
resetForNewClient(): void {
|
||||||
|
this.permanentForm.reset({ country: 'India' });
|
||||||
|
this.currentForm.reset({ country: 'India' });
|
||||||
|
this.officeForm.reset({ country: 'India' });
|
||||||
|
this.correspondenceForm.reset({ country: 'India' });
|
||||||
|
this.activeTab = 0;
|
||||||
|
this.sameAsPermanent = false;
|
||||||
|
this.pinStatus = {
|
||||||
|
permanent: { loading: false, found: false, error: false, message: '' },
|
||||||
|
current: { loading: false, found: false, error: false, message: '' },
|
||||||
|
office: { loading: false, found: false, error: false, message: '' },
|
||||||
|
correspondence: { loading: false, found: false, error: false, message: '' }
|
||||||
|
};
|
||||||
|
this.localityOptions = { permanent: [], current: [], office: [], correspondence: [] };
|
||||||
|
this.selectedAddrProof = null;
|
||||||
|
this.addrFiles = { front: null, back: null };
|
||||||
|
this.addrMergedFile = null;
|
||||||
|
this.addrMergedPreview = null;
|
||||||
|
this.existingAddressProofIdentifierId = null;
|
||||||
|
this.existingAddressProofDocuments = [];
|
||||||
|
this.addressLoaded = false;
|
||||||
|
// Re-fetch rather than re-filter `addressProofs` in place — if the previous session's
|
||||||
|
// isMinor filtering already reduced it (e.g. down to Aadhaar/Passport only), filtering
|
||||||
|
// that reduced list again can never recover the items it already removed.
|
||||||
|
this.loadProofTypes();
|
||||||
|
}
|
||||||
|
|
||||||
private loadProofTypes(): void {
|
private loadProofTypes(): void {
|
||||||
this.loadingProofs = true;
|
this.loadingProofs = true;
|
||||||
this.clientsService.getKycProofTypes().subscribe({
|
this.clientsService.getKycProofTypes().subscribe({
|
||||||
next: (res: any) => {
|
next: (res: any) => {
|
||||||
const activeFilter = (p: any) => p?.active !== false;
|
const activeFilter = (p: any) => p?.active !== false;
|
||||||
this.addressProofs = (res?.addressProofs || []).filter(activeFilter).map(normalizeProofType);
|
this.addressProofs = this.filterAddressProofsForNonIndividual(
|
||||||
|
this.filterAddressProofsForMinor((res?.addressProofs || []).filter(activeFilter).map(normalizeProofType))
|
||||||
|
);
|
||||||
this.addressGroups = groupProofsByCategory(this.addressProofs);
|
this.addressGroups = groupProofsByCategory(this.addressProofs);
|
||||||
this.loadingProofs = false;
|
this.loadingProofs = false;
|
||||||
},
|
},
|
||||||
@@ -250,6 +300,41 @@ export class ClientIndiaAddressStepComponent implements OnInit, OnChanges {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Minors don't hold utility bills, bank statements, rent agreements, property tax receipts,
|
||||||
|
* employer letters or pension orders in their own name, and Voter ID/Driving Licence both
|
||||||
|
* require adult age — Aadhaar and Passport are the only OVDs left for proof of address. */
|
||||||
|
private static readonly MINOR_ELIGIBLE_ADDRESS_PROOF_CODES = new Set([
|
||||||
|
'AADHAAR',
|
||||||
|
'PASSPORT'
|
||||||
|
]);
|
||||||
|
|
||||||
|
private filterAddressProofsForMinor(proofs: ProofType[]): ProofType[] {
|
||||||
|
if (!this.isMinor) return proofs;
|
||||||
|
return proofs.filter((p) =>
|
||||||
|
ClientIndiaAddressStepComponent.MINOR_ELIGIBLE_ADDRESS_PROOF_CODES.has((p.code || '').toUpperCase())
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Personal OVDs — not valid proof of a registered office/entity address. */
|
||||||
|
private static readonly NON_INDIVIDUAL_EXCLUDED_ADDRESS_PROOF_CODES = new Set([
|
||||||
|
'AADHAAR',
|
||||||
|
'PASSPORT',
|
||||||
|
'VOTER_ID',
|
||||||
|
'DRIVING_LICENSE',
|
||||||
|
'NREGA_JOB_CARD',
|
||||||
|
'NPR_LETTER',
|
||||||
|
'BIRTH_CERTIFICATE',
|
||||||
|
'SCHOOL_ID_CARD'
|
||||||
|
]);
|
||||||
|
|
||||||
|
private filterAddressProofsForNonIndividual(proofs: ProofType[]): ProofType[] {
|
||||||
|
if (!this.isNonIndividual) return proofs;
|
||||||
|
return proofs.filter(
|
||||||
|
(p) =>
|
||||||
|
!ClientIndiaAddressStepComponent.NON_INDIVIDUAL_EXCLUDED_ADDRESS_PROOF_CODES.has((p.code || '').toUpperCase())
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
selectAddrProof(proof: ProofType): void {
|
selectAddrProof(proof: ProofType): void {
|
||||||
this.selectedAddrProof = proof;
|
this.selectedAddrProof = proof;
|
||||||
this.permanentForm.get('proofId')?.setValue(proof.id);
|
this.permanentForm.get('proofId')?.setValue(proof.id);
|
||||||
@@ -330,11 +415,12 @@ export class ClientIndiaAddressStepComponent implements OnInit, OnChanges {
|
|||||||
files.push({ slot: 'Back', file: this.addrFiles.back });
|
files.push({ slot: 'Back', file: this.addrFiles.back });
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
const rawIssueDate = this.permanentForm.get('issueDate')?.value;
|
||||||
return {
|
return {
|
||||||
proof: this.selectedAddrProof,
|
proof: this.selectedAddrProof,
|
||||||
proofId: this.permanentForm.get('proofId')?.value || '',
|
proofId: this.permanentForm.get('proofId')?.value || '',
|
||||||
documentKey: this.permanentForm.get('documentKey')?.value || '',
|
documentKey: this.permanentForm.get('documentKey')?.value || '',
|
||||||
issueDate: this.permanentForm.get('issueDate')?.value || '',
|
issueDate: rawIssueDate ? this.dates.getDate(rawIssueDate) : '',
|
||||||
files
|
files
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
@@ -355,6 +441,25 @@ export class ClientIndiaAddressStepComponent implements OnInit, OnChanges {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
if (changes['isMinor'] && !changes['isMinor'].firstChange && this.addressProofs.length) {
|
||||||
|
const filtered = this.filterAddressProofsForMinor(this.addressProofs);
|
||||||
|
if (filtered.length !== this.addressProofs.length) {
|
||||||
|
this.addressProofs = filtered;
|
||||||
|
this.addressGroups = groupProofsByCategory(this.addressProofs);
|
||||||
|
if (this.selectedAddrProof && !filtered.some((p) => p.id === this.selectedAddrProof?.id)) {
|
||||||
|
this.selectedAddrProof = null;
|
||||||
|
this.permanentForm.get('proofId')?.setValue('');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// Legal form toggled on an already-initialized instance — re-fetch rather than filter
|
||||||
|
// `addressProofs` in place, since the non-individual filter only ever removes entries and
|
||||||
|
// toggling back to individual could never recover what a prior pass had stripped out.
|
||||||
|
if (changes['isNonIndividual'] && !changes['isNonIndividual'].firstChange) {
|
||||||
|
this.selectedAddrProof = null;
|
||||||
|
this.permanentForm.get('proofId')?.setValue('');
|
||||||
|
this.loadProofTypes();
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -458,13 +563,13 @@ export class ClientIndiaAddressStepComponent implements OnInit, OnChanges {
|
|||||||
proofId: matchedProof.id,
|
proofId: matchedProof.id,
|
||||||
documentKey: identifier.documentKey || ''
|
documentKey: identifier.documentKey || ''
|
||||||
});
|
});
|
||||||
// The description carries `Issue date: dd/mm/yyyy` when set on create (see
|
// The description carries `Issue date: yyyy-MM-dd` when set on create (see
|
||||||
// create-client.saveAddressProof) — pull it out so the form's issueDate field
|
// create-client.saveAddressProof) — pull it out so the form's issueDate field
|
||||||
// shows what was previously entered.
|
// shows what was previously entered.
|
||||||
const desc: string = identifier.description || '';
|
const desc: string = identifier.description || '';
|
||||||
const issueMatch = desc.match(/Issue date:\s*(.+)$/i);
|
const issueMatch = desc.match(/Issue date:\s*(.+)$/i);
|
||||||
if (issueMatch) {
|
if (issueMatch) {
|
||||||
this.permanentForm.patchValue({ issueDate: issueMatch[1].trim() });
|
this.permanentForm.patchValue({ issueDate: this.dates.parseDate(issueMatch[1].trim()) });
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -566,33 +671,17 @@ export class ClientIndiaAddressStepComponent implements OnInit, OnChanges {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/** Open the saved address proof in an in-app preview dialog. */
|
||||||
* Open the saved address proof in a new browser tab.
|
|
||||||
*
|
|
||||||
* For images we already hold a base64 data URL in `previewUrl` from
|
|
||||||
* `loadExistingAddressProof`, so we can hand it straight to `window.open`.
|
|
||||||
* For PDFs / other types we need to fetch the binary blob and create a
|
|
||||||
* temporary object URL so the browser's PDF viewer can render it.
|
|
||||||
*/
|
|
||||||
viewExistingAddressProof(): void {
|
viewExistingAddressProof(): void {
|
||||||
if (!this.existingAddressProofIdentifierId || this.existingAddressProofDocuments.length === 0) {
|
if (!this.existingAddressProofIdentifierId || this.existingAddressProofDocuments.length === 0) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
const doc = this.existingAddressProofDocuments[0];
|
const doc = this.existingAddressProofDocuments[0];
|
||||||
if (doc.previewUrl) {
|
|
||||||
window.open(doc.previewUrl, '_blank', 'noopener');
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
this.clientsService.downloadClientIdentificationDocument(this.existingAddressProofIdentifierId, doc.id).subscribe({
|
this.clientsService.downloadClientIdentificationDocument(this.existingAddressProofIdentifierId, doc.id).subscribe({
|
||||||
next: (blob: Blob) => {
|
next: (blob: Blob) =>
|
||||||
const url = URL.createObjectURL(blob);
|
this.dialog.open(DocumentPreviewDialogComponent, {
|
||||||
const w = window.open(url, '_blank', 'noopener');
|
data: { blob, title: doc.fileName || doc.name, fileName: doc.fileName || doc.name }
|
||||||
// Revoke after the tab has had time to load — browsers vary, so
|
}),
|
||||||
// err on the side of a generous wait.
|
|
||||||
if (w) {
|
|
||||||
setTimeout(() => URL.revokeObjectURL(url), 60_000);
|
|
||||||
}
|
|
||||||
},
|
|
||||||
error: () => {
|
error: () => {
|
||||||
// Silent fail — UI shows the saved name so user knows it exists.
|
// Silent fail — UI shows the saved name so user knows it exists.
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -28,6 +28,8 @@
|
|||||||
<div class="form-grid">
|
<div class="form-grid">
|
||||||
<!-- ══ INDIVIDUAL KYC ══ -->
|
<!-- ══ INDIVIDUAL KYC ══ -->
|
||||||
@if (!isNonIndividual) {
|
@if (!isNonIndividual) {
|
||||||
|
<!-- Minors don't hold a PAN card — skip the PAN checkbox/section entirely -->
|
||||||
|
@if (!isMinor) {
|
||||||
@if (readOnly) {
|
@if (readOnly) {
|
||||||
<!-- In view mode the mat-checkbox renders as a faded grey square
|
<!-- In view mode the mat-checkbox renders as a faded grey square
|
||||||
that reads as "blurry". Show a clean Yes/No pill instead. -->
|
that reads as "blurry". Show a clean Yes/No pill instead. -->
|
||||||
@@ -52,9 +54,10 @@
|
|||||||
<mat-checkbox formControlName="hasPan">Client has PAN Card</mat-checkbox>
|
<mat-checkbox formControlName="hasPan">Client has PAN Card</mat-checkbox>
|
||||||
</div>
|
</div>
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
<!-- Individual PAN with inline verify -->
|
<!-- Individual PAN with inline verify -->
|
||||||
@if (kycForm.get('hasPan')?.value) {
|
@if (!isMinor && kycForm.get('hasPan')?.value) {
|
||||||
<div class="form-field verify-field">
|
<div class="form-field verify-field">
|
||||||
<mat-form-field appearance="outline" class="verify-input">
|
<mat-form-field appearance="outline" class="verify-input">
|
||||||
<mat-label>PAN Number</mat-label>
|
<mat-label>PAN Number</mat-label>
|
||||||
@@ -158,6 +161,17 @@
|
|||||||
}
|
}
|
||||||
</button>
|
</button>
|
||||||
}
|
}
|
||||||
|
@if (aadhaarOtpSent && aadhaarVerifyStatus !== 'VERIFIED') {
|
||||||
|
<button
|
||||||
|
mat-stroked-button
|
||||||
|
type="button"
|
||||||
|
class="change-number-btn"
|
||||||
|
[disabled]="readOnly"
|
||||||
|
(click)="changeAadhaarNumber()"
|
||||||
|
>
|
||||||
|
<mat-icon>edit</mat-icon> Change Number
|
||||||
|
</button>
|
||||||
|
}
|
||||||
@if (aadhaarVerifyStatus === 'VERIFIED') {
|
@if (aadhaarVerifyStatus === 'VERIFIED') {
|
||||||
<span class="verify-chip verified"><fa-icon icon="check-circle"></fa-icon> Verified</span>
|
<span class="verify-chip verified"><fa-icon icon="check-circle"></fa-icon> Verified</span>
|
||||||
}
|
}
|
||||||
@@ -709,6 +723,33 @@
|
|||||||
<span>PAN verification requires client to be created first. Complete General tab and click Next to save.</span>
|
<span>PAN verification requires client to be created first. Complete General tab and click Next to save.</span>
|
||||||
</div>
|
</div>
|
||||||
}
|
}
|
||||||
|
|
||||||
|
<!-- KYC Exception Flag -->
|
||||||
|
<div class="exception-flag-box" style="margin-top: 20px; padding: 16px; border: 1px solid #f59e0b; background: #fffbeb; border-radius: 8px; clear: both;">
|
||||||
|
<h5 style="margin: 0 0 12px 0; font-size: 14px; font-weight: 600; color: #b45309; display: flex; align-items: center; gap: 8px;">
|
||||||
|
<fa-icon icon="exclamation-triangle"></fa-icon>
|
||||||
|
KYC Exception Flag
|
||||||
|
</h5>
|
||||||
|
<div style="display: flex; align-items: center; gap: 20px; flex-wrap: wrap;">
|
||||||
|
<mat-checkbox formControlName="isException">Mark as KYC Exception</mat-checkbox>
|
||||||
|
|
||||||
|
@if (kycForm.get('isException')?.value) {
|
||||||
|
<mat-form-field appearance="outline" style="flex: 1; min-width: 280px; margin-bottom: -1.25em;">
|
||||||
|
<mat-label>Exception Reasons</mat-label>
|
||||||
|
<mat-select formControlName="exceptionReason" multiple>
|
||||||
|
@if (!isNonIndividual) {
|
||||||
|
<mat-option value="PAN Card Verification Failed">PAN Card Verification Failed</mat-option>
|
||||||
|
<mat-option value="Aadhaar Verification Failed">Aadhaar Verification Failed</mat-option>
|
||||||
|
} @else {
|
||||||
|
<mat-option value="Enterprise PAN Verification Failed">Enterprise PAN Verification Failed</mat-option>
|
||||||
|
<mat-option value="GSTIN Verification Failed">GSTIN Verification Failed</mat-option>
|
||||||
|
<mat-option value="CIN Verification Failed">CIN Verification Failed</mat-option>
|
||||||
|
}
|
||||||
|
</mat-select>
|
||||||
|
</mat-form-field>
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- ═══ SECTION: KYC Document Management ═══ -->
|
<!-- ═══ SECTION: KYC Document Management ═══ -->
|
||||||
@@ -1035,6 +1076,12 @@
|
|||||||
</h4>
|
</h4>
|
||||||
|
|
||||||
<div class="form-grid">
|
<div class="form-grid">
|
||||||
|
<div class="checkbox-field override-checkbox-wrapper full-width">
|
||||||
|
<mat-checkbox formControlName="allowRiskOverride" [disabled]="readOnly">
|
||||||
|
Override Auto-calculated Risk Category
|
||||||
|
</mat-checkbox>
|
||||||
|
</div>
|
||||||
|
|
||||||
<mat-form-field appearance="outline" class="form-field">
|
<mat-form-field appearance="outline" class="form-field">
|
||||||
<mat-label>Risk Category</mat-label>
|
<mat-label>Risk Category</mat-label>
|
||||||
<mat-select formControlName="riskCategory">
|
<mat-select formControlName="riskCategory">
|
||||||
@@ -1072,6 +1119,64 @@
|
|||||||
<mat-hint>Auto: Low 10yr, Medium 8yr, High 2yr</mat-hint>
|
<mat-hint>Auto: Low 10yr, Medium 8yr, High 2yr</mat-hint>
|
||||||
</mat-form-field>
|
</mat-form-field>
|
||||||
|
|
||||||
|
@if (riskCalculationReason && !kycForm.get('allowRiskOverride')?.value && !readOnly) {
|
||||||
|
<div
|
||||||
|
class="risk-auto-calc-info full-width"
|
||||||
|
[class.risk-auto-calc-info--low]="kycForm.get('riskCategory')?.value === 1"
|
||||||
|
[class.risk-auto-calc-info--medium]="kycForm.get('riskCategory')?.value === 2"
|
||||||
|
[class.risk-auto-calc-info--high]="kycForm.get('riskCategory')?.value === 3"
|
||||||
|
>
|
||||||
|
<mat-icon class="info-icon">info</mat-icon>
|
||||||
|
<span class="info-text"
|
||||||
|
>Auto-determined: <strong>{{ riskCalculationReason }}</strong></span
|
||||||
|
>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
|
||||||
|
<div class="risk-rules-toggle-wrapper full-width">
|
||||||
|
<button mat-button type="button" class="rules-toggle-btn" (click)="showRiskRulesTable = !showRiskRulesTable">
|
||||||
|
<mat-icon class="rules-toggle-icon">{{ showRiskRulesTable ? 'expand_less' : 'expand_more' }}</mat-icon>
|
||||||
|
{{
|
||||||
|
showRiskRulesTable ? 'Hide Risk Classification Criteria Table' : 'View Risk Classification Criteria Table'
|
||||||
|
}}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
@if (showRiskRulesTable) {
|
||||||
|
<div class="risk-rules-card mat-elevation-z1 full-width">
|
||||||
|
<table class="risk-rules-table">
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th>Risk Category</th>
|
||||||
|
<th>Primary Criteria / Basis</th>
|
||||||
|
<th>Examples / Thresholds</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
<tr class="rule-row rule-row--high">
|
||||||
|
<td><span class="risk-badge risk-badge--high">High Risk</span></td>
|
||||||
|
<td>PEPs, High-Risk Industries, High Income / Turnovers</td>
|
||||||
|
<td>
|
||||||
|
Politicians; Real Estate, Import/Export; Commission Agents; Income > ₹20L/yr; Turnover > ₹1 Crore
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
<tr class="rule-row rule-row--medium">
|
||||||
|
<td><span class="risk-badge risk-badge--medium">Medium Risk</span></td>
|
||||||
|
<td>Self-Employed, Professionals, Cross-Border, Moderate Income</td>
|
||||||
|
<td>
|
||||||
|
Business Owners, Traders; Doctors, Lawyers, CAs; NRI, PIO, OCI; Income > ₹6L/yr; Turnover > ₹20L
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
<tr class="rule-row rule-row--low">
|
||||||
|
<td><span class="risk-badge risk-badge--low">Low Risk</span></td>
|
||||||
|
<td>Non-Earning, Government Employment, Agriculture / Manual</td>
|
||||||
|
<td>Students, Minors, Homemakers; Govt/PSU Employees; Farmers, Daily Wage; Income <= ₹6L/yr</td>
|
||||||
|
</tr>
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
|
||||||
<mat-form-field appearance="outline" class="form-field full-width">
|
<mat-form-field appearance="outline" class="form-field full-width">
|
||||||
<mat-label>{{ isKycVerified ? 'Reason for Re-edit *' : 'Remarks' }}</mat-label>
|
<mat-label>{{ isKycVerified ? 'Reason for Re-edit *' : 'Remarks' }}</mat-label>
|
||||||
<textarea
|
<textarea
|
||||||
@@ -1173,6 +1278,12 @@
|
|||||||
</h4>
|
</h4>
|
||||||
|
|
||||||
<div class="form-grid">
|
<div class="form-grid">
|
||||||
|
<div class="checkbox-field override-checkbox-wrapper full-width">
|
||||||
|
<mat-checkbox formControlName="allowAmlOverride" [disabled]="readOnly">
|
||||||
|
Override Auto-calculated AML & Compliance Checks
|
||||||
|
</mat-checkbox>
|
||||||
|
</div>
|
||||||
|
|
||||||
<mat-form-field appearance="outline" class="form-field">
|
<mat-form-field appearance="outline" class="form-field">
|
||||||
<mat-label>AML Check Status</mat-label>
|
<mat-label>AML Check Status</mat-label>
|
||||||
<mat-select required formControlName="amlCheckStatus">
|
<mat-select required formControlName="amlCheckStatus">
|
||||||
@@ -1266,11 +1377,11 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- ═══ SECTION: Form 60/61 ═══ -->
|
<!-- ═══ SECTION: Form 97/98 ═══ -->
|
||||||
<div class="section-block">
|
<div class="section-block">
|
||||||
<h4 class="section-title">
|
<h4 class="section-title">
|
||||||
<fa-icon icon="file-invoice" class="section-icon"></fa-icon>
|
<fa-icon icon="file-invoice" class="section-icon"></fa-icon>
|
||||||
Form 60 / 61
|
Form 97 / 98
|
||||||
@if (!kycForm.get('hasPan')?.value) {
|
@if (!kycForm.get('hasPan')?.value) {
|
||||||
<span class="kyc-badge required-badge">Required — No PAN</span>
|
<span class="kyc-badge required-badge">Required — No PAN</span>
|
||||||
}
|
}
|
||||||
@@ -1278,14 +1389,14 @@
|
|||||||
@if (!kycForm.get('hasPan')?.value) {
|
@if (!kycForm.get('hasPan')?.value) {
|
||||||
<div class="kyc-info-hint warning-hint">
|
<div class="kyc-info-hint warning-hint">
|
||||||
<mat-icon class="kyc-info-hint-icon">warning</mat-icon>
|
<mat-icon class="kyc-info-hint-icon">warning</mat-icon>
|
||||||
<span>Client does not have PAN. Form 60/61 is mandatory.</span>
|
<span>Client does not have PAN. Form 97/98 is mandatory.</span>
|
||||||
</div>
|
</div>
|
||||||
<div class="form-grid">
|
<div class="form-grid">
|
||||||
<mat-form-field appearance="outline" class="form-field">
|
<mat-form-field appearance="outline" class="form-field">
|
||||||
<mat-label>Form Type</mat-label>
|
<mat-label>Form Type</mat-label>
|
||||||
<mat-select formControlName="form60Type">
|
<mat-select formControlName="form60Type">
|
||||||
<mat-option value="FORM_60">Form 60</mat-option>
|
<mat-option value="FORM_60">Form 97</mat-option>
|
||||||
<mat-option value="FORM_61">Form 61</mat-option>
|
<mat-option value="FORM_61">Form 98</mat-option>
|
||||||
</mat-select>
|
</mat-select>
|
||||||
</mat-form-field>
|
</mat-form-field>
|
||||||
<mat-form-field appearance="outline" class="form-field" (click)="form60DatePicker.open()">
|
<mat-form-field appearance="outline" class="form-field" (click)="form60DatePicker.open()">
|
||||||
@@ -1296,26 +1407,26 @@
|
|||||||
</mat-form-field>
|
</mat-form-field>
|
||||||
</div>
|
</div>
|
||||||
} @else {
|
} @else {
|
||||||
<p class="form60-not-needed">Client has PAN — Form 60/61 not required.</p>
|
<p class="form60-not-needed">Client has PAN — Form 97/98 not required.</p>
|
||||||
}
|
}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- ═══ SECTION: Form 15G / 15H ═══ -->
|
<!-- ═══ SECTION: Form 121 ═══ -->
|
||||||
<div class="section-block">
|
<div class="section-block">
|
||||||
<h4 class="section-title">
|
<h4 class="section-title">
|
||||||
<fa-icon icon="receipt" class="section-icon"></fa-icon>
|
<fa-icon icon="receipt" class="section-icon"></fa-icon>
|
||||||
Form 15G / 15H
|
Form 121
|
||||||
</h4>
|
</h4>
|
||||||
<div class="form-grid">
|
<div class="form-grid">
|
||||||
<div class="form-field checkbox-field">
|
<div class="form-field checkbox-field">
|
||||||
<mat-checkbox formControlName="form15Applicable">Form 15G/15H Applicable</mat-checkbox>
|
<mat-checkbox formControlName="form15Applicable">Form 121 Applicable</mat-checkbox>
|
||||||
</div>
|
</div>
|
||||||
@if (kycForm.get('form15Applicable')?.value) {
|
@if (kycForm.get('form15Applicable')?.value) {
|
||||||
<mat-form-field appearance="outline" class="form-field">
|
<mat-form-field appearance="outline" class="form-field">
|
||||||
<mat-label>Form Type</mat-label>
|
<mat-label>Form Type</mat-label>
|
||||||
<mat-select formControlName="form15Type">
|
<mat-select formControlName="form15Type">
|
||||||
<mat-option value="15G">15G (Below 60 years)</mat-option>
|
<mat-option value="15G">Form 121 (Below 60 years)</mat-option>
|
||||||
<mat-option value="15H">15H (Senior Citizen 60+)</mat-option>
|
<mat-option value="15H">Form 121 (Senior Citizen 60+)</mat-option>
|
||||||
</mat-select>
|
</mat-select>
|
||||||
</mat-form-field>
|
</mat-form-field>
|
||||||
<mat-form-field appearance="outline" class="form-field">
|
<mat-form-field appearance="outline" class="form-field">
|
||||||
@@ -1326,6 +1437,7 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|
||||||
<!-- ═══ SECTION: Photograph & Signature ═══ -->
|
<!-- ═══ SECTION: Photograph & Signature ═══ -->
|
||||||
<div class="section-block">
|
<div class="section-block">
|
||||||
<h4 class="section-title">
|
<h4 class="section-title">
|
||||||
@@ -1359,10 +1471,15 @@
|
|||||||
<span class="upload-box__req">*</span>
|
<span class="upload-box__req">*</span>
|
||||||
</span>
|
</span>
|
||||||
<span class="upload-box__hint">JPG / PNG, max 2MB</span>
|
<span class="upload-box__hint">JPG / PNG, max 2MB</span>
|
||||||
|
<div class="upload-box__btn-row">
|
||||||
<label class="upload-box__btn">
|
<label class="upload-box__btn">
|
||||||
<input type="file" accept="image/*" hidden (change)="onPhotoSelected($event)" />
|
<input type="file" accept="image/*" hidden (change)="onPhotoSelected($event)" />
|
||||||
<fa-icon icon="camera"></fa-icon> Choose & Crop
|
<fa-icon icon="camera"></fa-icon> Choose & Crop
|
||||||
</label>
|
</label>
|
||||||
|
<button type="button" class="upload-box__btn upload-box__btn--secondary" (click)="captureWebcamPhoto()">
|
||||||
|
<fa-icon icon="video"></fa-icon> Use Webcam
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
@if (photoSigTouched) {
|
@if (photoSigTouched) {
|
||||||
<span class="upload-box__error">Photograph is required</span>
|
<span class="upload-box__error">Photograph is required</span>
|
||||||
}
|
}
|
||||||
@@ -1393,10 +1510,15 @@
|
|||||||
<span class="upload-box__req">*</span>
|
<span class="upload-box__req">*</span>
|
||||||
</span>
|
</span>
|
||||||
<span class="upload-box__hint">Scanned image, max 1MB</span>
|
<span class="upload-box__hint">Scanned image, max 1MB</span>
|
||||||
|
<div class="upload-box__btn-row">
|
||||||
<label class="upload-box__btn">
|
<label class="upload-box__btn">
|
||||||
<input type="file" accept="image/*" hidden (change)="onSignatureSelected($event)" />
|
<input type="file" accept="image/*" hidden (change)="onSignatureSelected($event)" />
|
||||||
<fa-icon icon="crop"></fa-icon> Choose & Crop
|
<fa-icon icon="crop"></fa-icon> Choose & Crop
|
||||||
</label>
|
</label>
|
||||||
|
<button type="button" class="upload-box__btn upload-box__btn--secondary" (click)="drawSignature()">
|
||||||
|
<fa-icon icon="pen"></fa-icon> Draw Signature
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
@if (photoSigTouched) {
|
@if (photoSigTouched) {
|
||||||
<span class="upload-box__error">Signature is required</span>
|
<span class="upload-box__error">Signature is required</span>
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -266,6 +266,23 @@
|
|||||||
gap: 6px;
|
gap: 6px;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.change-number-btn {
|
||||||
|
font-size: 12px !important;
|
||||||
|
font-weight: 600 !important;
|
||||||
|
padding: 0 14px !important;
|
||||||
|
height: 36px !important;
|
||||||
|
border-radius: 6px !important;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 6px;
|
||||||
|
|
||||||
|
mat-icon {
|
||||||
|
font-size: 16px;
|
||||||
|
width: 16px;
|
||||||
|
height: 16px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
.verify-chip {
|
.verify-chip {
|
||||||
display: inline-flex;
|
display: inline-flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
@@ -297,8 +314,24 @@
|
|||||||
.toggle-vis-btn {
|
.toggle-vis-btn {
|
||||||
width: 28px !important;
|
width: 28px !important;
|
||||||
height: 28px !important;
|
height: 28px !important;
|
||||||
|
padding: 0 !important;
|
||||||
font-size: 13px !important;
|
font-size: 13px !important;
|
||||||
color: #64748b;
|
color: #64748b;
|
||||||
|
|
||||||
|
/* Material reserves a 48x48 invisible touch-target + ripple around every
|
||||||
|
mat-icon-button by default. Shrinking the button to 28px above doesn't
|
||||||
|
shrink that touch-target/ripple with it, so — since the suffix
|
||||||
|
container doesn't clip overflow — it visually spills out past the
|
||||||
|
field's own border, making the icon look misplaced/half-outside the box. */
|
||||||
|
::ng-deep .mat-mdc-button-touch-target {
|
||||||
|
width: 28px !important;
|
||||||
|
height: 28px !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
::ng-deep .mat-mdc-button-persistent-ripple,
|
||||||
|
::ng-deep .mat-ripple {
|
||||||
|
border-radius: 50%;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Verification Info Banner */
|
/* Verification Info Banner */
|
||||||
@@ -1057,9 +1090,17 @@
|
|||||||
color: #94a3b8;
|
color: #94a3b8;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
&__btn-row {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
justify-content: center;
|
||||||
|
gap: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
&__btn {
|
&__btn {
|
||||||
display: inline-flex;
|
display: inline-flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
|
gap: 6px;
|
||||||
padding: 6px 16px;
|
padding: 6px 16px;
|
||||||
background: #fff;
|
background: #fff;
|
||||||
border: 1px solid #d0d5dd;
|
border: 1px solid #d0d5dd;
|
||||||
@@ -1074,6 +1115,10 @@
|
|||||||
border-color: #1353a0;
|
border-color: #1353a0;
|
||||||
color: #1353a0;
|
color: #1353a0;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
&--secondary {
|
||||||
|
background: #f8fafc;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
&__status {
|
&__status {
|
||||||
@@ -2228,3 +2273,161 @@
|
|||||||
width: 95px;
|
width: 95px;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.override-checkbox-wrapper {
|
||||||
|
margin-bottom: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.risk-auto-calc-info {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 10px;
|
||||||
|
padding: 12px 16px;
|
||||||
|
border-radius: 8px;
|
||||||
|
font-size: 13px;
|
||||||
|
font-weight: 500;
|
||||||
|
margin-top: -8px;
|
||||||
|
margin-bottom: 12px;
|
||||||
|
border: 1px solid transparent;
|
||||||
|
|
||||||
|
.info-icon {
|
||||||
|
font-size: 18px !important;
|
||||||
|
width: 18px;
|
||||||
|
height: 18px;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.info-text strong {
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
|
||||||
|
&--low {
|
||||||
|
background: #ecfdf5;
|
||||||
|
color: #065f46;
|
||||||
|
border-color: #a7f3d0;
|
||||||
|
|
||||||
|
.info-icon {
|
||||||
|
color: #10b981;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
&--medium {
|
||||||
|
background: #fffbeb;
|
||||||
|
color: #92400e;
|
||||||
|
border-color: #fde68a;
|
||||||
|
|
||||||
|
.info-icon {
|
||||||
|
color: #f59e0b;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
&--high {
|
||||||
|
background: #fef2f2;
|
||||||
|
color: #991b1b;
|
||||||
|
border-color: #fecaca;
|
||||||
|
|
||||||
|
.info-icon {
|
||||||
|
color: #ef4444;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.risk-rules-toggle-wrapper {
|
||||||
|
margin-top: 4px;
|
||||||
|
margin-bottom: 12px;
|
||||||
|
|
||||||
|
.rules-toggle-btn {
|
||||||
|
font-size: 13px;
|
||||||
|
font-weight: 600;
|
||||||
|
color: #1e6bb8;
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 6px;
|
||||||
|
padding: 6px 12px;
|
||||||
|
border-radius: 20px;
|
||||||
|
transition: all 0.2s ease-in-out;
|
||||||
|
background: #f1f5f9;
|
||||||
|
|
||||||
|
&:hover {
|
||||||
|
background: #e2e8f0;
|
||||||
|
color: #0f172a;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.risk-rules-card {
|
||||||
|
background: #fff;
|
||||||
|
border: 1px solid #e2e8f0;
|
||||||
|
border-radius: 8px;
|
||||||
|
padding: 16px;
|
||||||
|
margin-bottom: 16px;
|
||||||
|
box-shadow: 0 1px 3px rgb(0 0 0 / 5%);
|
||||||
|
}
|
||||||
|
|
||||||
|
.risk-rules-table {
|
||||||
|
width: 100%;
|
||||||
|
border-collapse: collapse;
|
||||||
|
font-size: 13px;
|
||||||
|
text-align: left;
|
||||||
|
|
||||||
|
th {
|
||||||
|
padding: 10px 12px;
|
||||||
|
font-weight: 600;
|
||||||
|
color: #475569;
|
||||||
|
border-bottom: 2px solid #cbd5e1;
|
||||||
|
background: #f8fafc;
|
||||||
|
font-size: 11px;
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 0.5px;
|
||||||
|
}
|
||||||
|
|
||||||
|
td {
|
||||||
|
padding: 12px;
|
||||||
|
border-bottom: 1px solid #e2e8f0;
|
||||||
|
color: #334155;
|
||||||
|
vertical-align: top;
|
||||||
|
line-height: 1.5;
|
||||||
|
}
|
||||||
|
|
||||||
|
tr:last-child td {
|
||||||
|
border-bottom: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.rule-row {
|
||||||
|
transition: background 0.15s ease-in-out;
|
||||||
|
|
||||||
|
&:hover {
|
||||||
|
background: #f8fafc;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.risk-badge {
|
||||||
|
display: inline-block;
|
||||||
|
padding: 4px 10px;
|
||||||
|
border-radius: 12px;
|
||||||
|
font-size: 11px;
|
||||||
|
font-weight: 600;
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 0.3px;
|
||||||
|
text-align: center;
|
||||||
|
width: 80px;
|
||||||
|
|
||||||
|
&--high {
|
||||||
|
background: #fef2f2;
|
||||||
|
color: #dc2626;
|
||||||
|
border: 1px solid #fecaca;
|
||||||
|
}
|
||||||
|
|
||||||
|
&--medium {
|
||||||
|
background: #fffbeb;
|
||||||
|
color: #d97706;
|
||||||
|
border: 1px solid #fde68a;
|
||||||
|
}
|
||||||
|
|
||||||
|
&--low {
|
||||||
|
background: #ecfdf5;
|
||||||
|
color: #16a34a;
|
||||||
|
border: 1px solid #a7f3d0;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -29,7 +29,12 @@ import { ClientsService } from '../../clients.service';
|
|||||||
import { SettingsService } from 'app/settings/settings.service';
|
import { SettingsService } from 'app/settings/settings.service';
|
||||||
import { Dates } from 'app/core/utils/dates';
|
import { Dates } from 'app/core/utils/dates';
|
||||||
import { ProofType, normalizeProofType, groupProofsByCategory } from '../../shared/kyc-proof-types';
|
import { ProofType, normalizeProofType, groupProofsByCategory } from '../../shared/kyc-proof-types';
|
||||||
|
import { deriveKycExceptionFields } from '../../kyc-exception.util';
|
||||||
import { KycUploadHelperService } from '../../shared/kyc-upload-helper.service';
|
import { KycUploadHelperService } from '../../shared/kyc-upload-helper.service';
|
||||||
|
import { CaptureImageDialogComponent } from '../../clients-view/custom-dialogs/capture-image-dialog/capture-image-dialog.component';
|
||||||
|
import { DrawSignatureDialogComponent } from '../../clients-view/custom-dialogs/draw-signature-dialog/draw-signature-dialog.component';
|
||||||
|
import { dataUrlToFile } from 'app/shared/utils/data-url.util';
|
||||||
|
import { DocumentPreviewDialogComponent } from '../../shared/document-preview-dialog/document-preview-dialog.component';
|
||||||
|
|
||||||
@Component({
|
@Component({
|
||||||
selector: 'mifosx-client-kyc-step',
|
selector: 'mifosx-client-kyc-step',
|
||||||
@@ -59,12 +64,23 @@ export class ClientKycStepComponent implements OnInit, OnChanges, OnDestroy {
|
|||||||
/** Set by parent after client is created */
|
/** Set by parent after client is created */
|
||||||
@Input() clientId: string = '';
|
@Input() clientId: string = '';
|
||||||
@Input() isNonIndividual = false;
|
@Input() isNonIndividual = false;
|
||||||
|
/** Minor clients don't hold a PAN card — hides the PAN section on the individual KYC form */
|
||||||
|
@Input() isMinor = false;
|
||||||
/** Constitution metadata (panLabel, showCin, etc.) passed from general step */
|
/** Constitution metadata (panLabel, showCin, etc.) passed from general step */
|
||||||
@Input() constitutionMeta: any = null;
|
@Input() constitutionMeta: any = null;
|
||||||
/** Entity full name (non-individual) — used for enterprise PAN verification */
|
/** Entity full name (non-individual) — used for enterprise PAN verification */
|
||||||
@Input() entityName: string = '';
|
@Input() entityName: string = '';
|
||||||
/** Read-only mode — disables form editing until user clicks edit icon */
|
/** Read-only mode — disables form editing until user clicks edit icon */
|
||||||
@Input() readOnly = false;
|
@Input() readOnly = false;
|
||||||
|
@Input() calculatedRisk: {
|
||||||
|
category: number;
|
||||||
|
reason: string;
|
||||||
|
amlCheckStatus?: string;
|
||||||
|
bannedEntityMatch?: string;
|
||||||
|
willfulDefaulter?: string;
|
||||||
|
ecgcSalFlag?: string;
|
||||||
|
dormantFlag?: string;
|
||||||
|
} | null = null;
|
||||||
|
|
||||||
today = new Date();
|
today = new Date();
|
||||||
kycId: string = '';
|
kycId: string = '';
|
||||||
@@ -128,6 +144,32 @@ export class ClientKycStepComponent implements OnInit, OnChanges, OnDestroy {
|
|||||||
markPhotoSigTouched(): void {
|
markPhotoSigTouched(): void {
|
||||||
this.photoSigTouched = true;
|
this.photoSigTouched = true;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Human-readable label for each `kycForm` control that can carry a `required` error —
|
||||||
|
* used to build a specific "why is Save & Next disabled" message instead of a generic one.
|
||||||
|
* Controls not listed here (e.g. purely pattern-validated ones like panNumber/gstinNumber)
|
||||||
|
* only block when the user has actually typed something invalid, which is covered separately. */
|
||||||
|
private static readonly REQUIRED_FIELD_LABELS: Record<string, string> = {
|
||||||
|
kycDocumentName: 'KYC Document Type',
|
||||||
|
kycDocumentNumber: 'KYC Document Number',
|
||||||
|
amlCheckStatus: 'AML Check Status',
|
||||||
|
bannedEntityMatch: 'Banned Entity List Match',
|
||||||
|
remarks: 'Reason for Re-edit'
|
||||||
|
};
|
||||||
|
|
||||||
|
/** Names every currently-invalid `kycForm` control (required-but-empty, or an entered value
|
||||||
|
* that fails its pattern) in plain English — surfaced via the Save & Next tooltip so staff
|
||||||
|
* aren't left staring at a greyed-out button with no clue what's still missing. Whether or
|
||||||
|
* not photo/signature previews are filled has no bearing on this list (they aren't part of
|
||||||
|
* `kycForm` at all — see `photoSigComplete`). */
|
||||||
|
get invalidFieldLabels(): string[] {
|
||||||
|
if (!this.kycForm) {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
return Object.keys(this.kycForm.controls)
|
||||||
|
.filter((key) => this.kycForm.get(key)?.invalid)
|
||||||
|
.map((key) => ClientKycStepComponent.REQUIRED_FIELD_LABELS[key] || key);
|
||||||
|
}
|
||||||
/** Fallback document types when template API is unavailable */
|
/** Fallback document types when template API is unavailable */
|
||||||
private defaultDocumentTypes = [
|
private defaultDocumentTypes = [
|
||||||
{ name: 'Aadhaar Card', code: 'AADHAAR' },
|
{ name: 'Aadhaar Card', code: 'AADHAAR' },
|
||||||
@@ -136,7 +178,20 @@ export class ClientKycStepComponent implements OnInit, OnChanges, OnDestroy {
|
|||||||
{ name: 'Driving Licence', code: 'DRIVING_LICENSE' },
|
{ name: 'Driving Licence', code: 'DRIVING_LICENSE' },
|
||||||
{ name: 'Voter ID', code: 'VOTER_ID' },
|
{ name: 'Voter ID', code: 'VOTER_ID' },
|
||||||
{ name: 'NREGA Job Card', code: 'NREGA_JOB_CARD' },
|
{ name: 'NREGA Job Card', code: 'NREGA_JOB_CARD' },
|
||||||
{ name: 'National Population Register Letter', code: 'NPR_LETTER' }
|
{ name: 'National Population Register Letter', code: 'NPR_LETTER' },
|
||||||
|
{ name: 'Birth Certificate', code: 'BIRTH_CERTIFICATE' },
|
||||||
|
{ name: 'School ID Card', code: 'SCHOOL_ID_CARD' }
|
||||||
|
];
|
||||||
|
/** Generic RBI-prescribed KYC documents for legal entities (Master Direction on KYC,
|
||||||
|
* Annexure I) — shown for non-individual clients before a specific constitution type is
|
||||||
|
* picked. Individual OVDs (Aadhaar/Passport/DL/Voter ID/NREGA/NPR) don't apply to entities. */
|
||||||
|
private nonIndividualDefaultDocTypes = [
|
||||||
|
{ name: 'Certificate of Incorporation / Registration Certificate', code: 'CERT_INCORPORATION' },
|
||||||
|
{ name: 'PAN Card of Entity', code: 'ENTITY_PAN' },
|
||||||
|
{ name: 'Memorandum & Articles of Association / Partnership Deed / Trust Deed', code: 'CONSTITUTIVE_DOC' },
|
||||||
|
{ name: 'Board Resolution / Authorization Letter', code: 'BOARD_RESOLUTION' },
|
||||||
|
{ name: 'KYC of Authorized Signatory / Power of Attorney Holder', code: 'AUTH_SIGNATORY_KYC' },
|
||||||
|
{ name: 'GST Registration Certificate', code: 'GST_CERTIFICATE' }
|
||||||
];
|
];
|
||||||
/** Constitution-based KYC document types for non-individual clients */
|
/** Constitution-based KYC document types for non-individual clients */
|
||||||
private constitutionDocTypes: Record<string, { name: string; mandatory: boolean }[]> = {
|
private constitutionDocTypes: Record<string, { name: string; mandatory: boolean }[]> = {
|
||||||
@@ -218,6 +273,17 @@ export class ClientKycStepComponent implements OnInit, OnChanges, OnDestroy {
|
|||||||
{ value: 3, label: 'High Risk', years: 2, color: 'red' }
|
{ value: 3, label: 'High Risk', years: 2, color: 'red' }
|
||||||
];
|
];
|
||||||
|
|
||||||
|
riskCalculationReason = '';
|
||||||
|
showRiskRulesTable = false;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Exception state as last read back from the server. Needed to tell "this client never had an
|
||||||
|
* exception" from "this client had one and has now cleared it" — only the latter should be
|
||||||
|
* stamped resolved and surface in the Resolved tab.
|
||||||
|
*/
|
||||||
|
persistedIsException = false;
|
||||||
|
persistedExceptionReason: string | null = null;
|
||||||
|
|
||||||
/** PAN verification */
|
/** PAN verification */
|
||||||
panVerifyStatus: 'NONE' | 'VERIFIED' | 'FAILED' = 'NONE';
|
panVerifyStatus: 'NONE' | 'VERIFIED' | 'FAILED' = 'NONE';
|
||||||
panVerifying = false;
|
panVerifying = false;
|
||||||
@@ -367,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: [''],
|
||||||
@@ -409,7 +475,11 @@ export class ClientKycStepComponent implements OnInit, OnChanges, OnDestroy {
|
|||||||
kycDocIssueDate: [''],
|
kycDocIssueDate: [''],
|
||||||
kycDocExpiryDate: [''],
|
kycDocExpiryDate: [''],
|
||||||
riskCategory: [1],
|
riskCategory: [1],
|
||||||
|
allowRiskOverride: [false],
|
||||||
|
allowAmlOverride: [false],
|
||||||
remarks: [''],
|
remarks: [''],
|
||||||
|
isException: [false],
|
||||||
|
exceptionReason: [[]],
|
||||||
amlCheckStatus: [
|
amlCheckStatus: [
|
||||||
'CLEAR',
|
'CLEAR',
|
||||||
Validators.required
|
Validators.required
|
||||||
@@ -431,22 +501,181 @@ export class ClientKycStepComponent implements OnInit, OnChanges, OnDestroy {
|
|||||||
form15FY: ['']
|
form15FY: ['']
|
||||||
});
|
});
|
||||||
|
|
||||||
|
/** RBI-accepted OVDs for a minor client — mirrors the backend's minor branch in
|
||||||
|
* ClientKycApiResource.getRequiredDocuments(). Voter ID, Driving Licence, PAN and NREGA
|
||||||
|
* all require adult eligibility; National Population Register Letter is deliberately
|
||||||
|
* excluded too (not offered as a minor OVD in this system). */
|
||||||
|
private static readonly MINOR_ELIGIBLE_DOC_CODES = new Set([
|
||||||
|
'AADHAAR',
|
||||||
|
'PASSPORT',
|
||||||
|
'BIRTH_CERTIFICATE',
|
||||||
|
'SCHOOL_ID_CARD'
|
||||||
|
]);
|
||||||
|
|
||||||
|
/** Birth Certificate / School ID Card are minor-specific OVDs — not relevant once the
|
||||||
|
* client is an adult, so they're hidden the other direction. */
|
||||||
|
private static readonly MINOR_ONLY_DOC_CODES = new Set([
|
||||||
|
'BIRTH_CERTIFICATE',
|
||||||
|
'SCHOOL_ID_CARD'
|
||||||
|
]);
|
||||||
|
|
||||||
|
private filterDocTypesForMinor<T extends { name: string; code?: string }>(docs: T[]): T[] {
|
||||||
|
if (this.isMinor) {
|
||||||
|
return docs.filter((d) => ClientKycStepComponent.MINOR_ELIGIBLE_DOC_CODES.has((d.code || '').toUpperCase()));
|
||||||
|
}
|
||||||
|
return docs.filter((d) => !ClientKycStepComponent.MINOR_ONLY_DOC_CODES.has((d.code || '').toUpperCase()));
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Individual-only OVDs vs. entity-only RBI docs — never both, and minor filtering only
|
||||||
|
* ever applies to individuals. */
|
||||||
|
private getDefaultDocTypes(): { name: string; code: string }[] {
|
||||||
|
return this.isNonIndividual
|
||||||
|
? this.nonIndividualDefaultDocTypes
|
||||||
|
: this.filterDocTypesForMinor(this.defaultDocumentTypes);
|
||||||
|
}
|
||||||
|
|
||||||
ngOnInit(): void {
|
ngOnInit(): void {
|
||||||
// Seed defaults immediately so the picker has options before clientId is set
|
this.seedDefaultDocTypes();
|
||||||
// (template API requires clientId; user can still pick a type pre-save).
|
this.loadExistingKycIfNeeded();
|
||||||
if (!this.kycDocumentTypes.length) {
|
this.loadKycTemplate();
|
||||||
this.kycDocumentTypes = this.defaultDocumentTypes.map((d) => ({
|
if (this.isNonIndividual && this.constitutionMeta) {
|
||||||
|
this.updateDocTypesForConstitution();
|
||||||
|
}
|
||||||
|
|
||||||
|
this.kycForm.get('allowRiskOverride')?.valueChanges.subscribe((allowed: boolean) => {
|
||||||
|
const riskControl = this.kycForm.get('riskCategory');
|
||||||
|
if (allowed) {
|
||||||
|
if (!this.readOnly) {
|
||||||
|
riskControl?.enable();
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
riskControl?.disable();
|
||||||
|
if (this.calculatedRisk) {
|
||||||
|
riskControl?.setValue(this.calculatedRisk.category);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
this.kycForm.get('allowAmlOverride')?.valueChanges.subscribe((allowed: boolean) => {
|
||||||
|
const amlControls = [
|
||||||
|
this.kycForm.get('amlCheckStatus'),
|
||||||
|
this.kycForm.get('bannedEntityMatch'),
|
||||||
|
this.kycForm.get('willfulDefaulter'),
|
||||||
|
this.kycForm.get('ecgcSalFlag'),
|
||||||
|
this.kycForm.get('dormantFlag')
|
||||||
|
];
|
||||||
|
if (allowed) {
|
||||||
|
if (!this.readOnly) {
|
||||||
|
amlControls.forEach((ctrl) => ctrl?.enable());
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
amlControls.forEach((ctrl) => ctrl?.disable());
|
||||||
|
this.applyAutoCalculatedAmlFlags();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!this.kycForm.get('allowRiskOverride')?.value) {
|
||||||
|
this.kycForm.get('riskCategory')?.disable();
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!this.kycForm.get('allowAmlOverride')?.value) {
|
||||||
|
this.kycForm.get('amlCheckStatus')?.disable();
|
||||||
|
this.kycForm.get('bannedEntityMatch')?.disable();
|
||||||
|
this.kycForm.get('willfulDefaulter')?.disable();
|
||||||
|
this.kycForm.get('ecgcSalFlag')?.disable();
|
||||||
|
this.kycForm.get('dormantFlag')?.disable();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Seed the document-type picker from defaults (entity RBI docs vs. individual OVDs,
|
||||||
|
* filtered by the current isMinor input for individuals). Only seeds when the list is
|
||||||
|
* empty — the template API (loadKycTemplate) takes over once a clientId is available. */
|
||||||
|
private seedDefaultDocTypes(): void {
|
||||||
|
if (this.kycDocumentTypes.length) return;
|
||||||
|
this.kycDocumentTypes = this.getDefaultDocTypes().map((d) => ({
|
||||||
name: d.name,
|
name: d.name,
|
||||||
code: d.code,
|
code: d.code,
|
||||||
mandatory: false
|
mandatory: false
|
||||||
}));
|
}));
|
||||||
this.rebuildKycDocPicker();
|
this.rebuildKycDocPicker();
|
||||||
}
|
}
|
||||||
this.loadExistingKycIfNeeded();
|
|
||||||
this.loadKycTemplate();
|
/**
|
||||||
if (this.isNonIndividual && this.constitutionMeta) {
|
* Resets transient KYC-tab state for a brand-new client.
|
||||||
this.updateDocTypesForConstitution();
|
*
|
||||||
}
|
* This app's RouteReusableStrategy (core/route/route-reusable-strategy.ts) keeps this SAME
|
||||||
|
* component instance alive across repeat visits to /clients/create, so `ngOnInit` never
|
||||||
|
* re-runs for a second "create new client" session. Without this, the document-type picker
|
||||||
|
* (and any PAN/Aadhaar verification state, uploaded photo/signature) would otherwise leak
|
||||||
|
* from whatever client was created in the previous session into the new one. Called by
|
||||||
|
* CreateClientComponent.resetForNewClient() whenever a fresh create session begins.
|
||||||
|
*/
|
||||||
|
resetForNewClient(): void {
|
||||||
|
this.kycForm.reset({
|
||||||
|
hasPan: true,
|
||||||
|
riskCategory: 1,
|
||||||
|
allowRiskOverride: false,
|
||||||
|
amlCheckStatus: 'CLEAR',
|
||||||
|
bannedEntityMatch: 'NO',
|
||||||
|
willfulDefaulter: 'NO',
|
||||||
|
ecgcSalFlag: 'NO',
|
||||||
|
dormantFlag: 'ACTIVE',
|
||||||
|
accountStatus: 'ACTIVE',
|
||||||
|
form60Type: 'FORM_60',
|
||||||
|
form15Type: '15G',
|
||||||
|
isException: false,
|
||||||
|
exceptionReason: []
|
||||||
|
});
|
||||||
|
this.persistedIsException = false;
|
||||||
|
this.persistedExceptionReason = null;
|
||||||
|
this.kycId = '';
|
||||||
|
this.kycLoaded = false;
|
||||||
|
this.kycStatus = 0;
|
||||||
|
this.kycTemplate = null;
|
||||||
|
this.kycTemplateLoaded = false;
|
||||||
|
this.kycDocumentTypes = [];
|
||||||
|
this.selectedKycDoc = null;
|
||||||
|
this.kycDocFiles = { front: null, back: null };
|
||||||
|
this.kycDocMergedFile = null;
|
||||||
|
this.kycDocMergedPreview = null;
|
||||||
|
this.existingKycIdentifierId = null;
|
||||||
|
this.riskCalculationReason = '';
|
||||||
|
this.showRiskRulesTable = false;
|
||||||
|
this.existingKycDocuments = [];
|
||||||
|
this.photoSigTouched = false;
|
||||||
|
this.panVerifyStatus = 'NONE';
|
||||||
|
this.panVerifying = false;
|
||||||
|
this.panVerifyError = '';
|
||||||
|
this.panVerifyName = '';
|
||||||
|
this.panVerifiedOn = '';
|
||||||
|
this.aadhaarVerifyStatus = 'NONE';
|
||||||
|
this.aadhaarVerifying = false;
|
||||||
|
this.aadhaarVerifyError = '';
|
||||||
|
this.aadhaarVerifyName = '';
|
||||||
|
this.aadhaarVisible = false;
|
||||||
|
this.aadhaarOtpSent = false;
|
||||||
|
this.resetOtpDigits();
|
||||||
|
this.aadhaarOtpReferenceId = '';
|
||||||
|
this.panAadhaarSeedingStatus = '';
|
||||||
|
this.entPanVerifyStatus = 'NONE';
|
||||||
|
this.entPanVerifying = false;
|
||||||
|
this.entPanVerifyName = '';
|
||||||
|
this.entPanVerifiedOn = '';
|
||||||
|
this.gstinVerifyStatus = 'NONE';
|
||||||
|
this.gstinVerifying = false;
|
||||||
|
this.gstinVerifyName = '';
|
||||||
|
this.gstinVerifiedOn = '';
|
||||||
|
this.gstinVerifyState = '';
|
||||||
|
this.cinVerifyStatus = 'NONE';
|
||||||
|
this.cinVerifying = false;
|
||||||
|
this.cinVerifiedOn = '';
|
||||||
|
this.cinCompanyName = '';
|
||||||
|
this.photographFile = null;
|
||||||
|
this.photographPreview = null;
|
||||||
|
this.signatureFile = null;
|
||||||
|
this.signaturePreview = null;
|
||||||
|
this.existingSignatureDocId = '';
|
||||||
|
this.seedDefaultDocTypes();
|
||||||
}
|
}
|
||||||
|
|
||||||
ngOnDestroy(): void {
|
ngOnDestroy(): void {
|
||||||
@@ -464,11 +693,75 @@ export class ClientKycStepComponent implements OnInit, OnChanges, OnDestroy {
|
|||||||
this.kycForm.disable({ emitEvent: false });
|
this.kycForm.disable({ emitEvent: false });
|
||||||
} else {
|
} else {
|
||||||
this.kycForm.enable({ emitEvent: false });
|
this.kycForm.enable({ emitEvent: false });
|
||||||
|
if (!this.kycForm.get('allowRiskOverride')?.value) {
|
||||||
|
this.kycForm.get('riskCategory')?.disable({ emitEvent: false });
|
||||||
|
}
|
||||||
|
if (!this.kycForm.get('allowAmlOverride')?.value) {
|
||||||
|
this.kycForm.get('amlCheckStatus')?.disable({ emitEvent: false });
|
||||||
|
this.kycForm.get('bannedEntityMatch')?.disable({ emitEvent: false });
|
||||||
|
this.kycForm.get('willfulDefaulter')?.disable({ emitEvent: false });
|
||||||
|
this.kycForm.get('ecgcSalFlag')?.disable({ emitEvent: false });
|
||||||
|
this.kycForm.get('dormantFlag')?.disable({ emitEvent: false });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (changes['calculatedRisk']) {
|
||||||
|
const newRisk = changes['calculatedRisk'].currentValue;
|
||||||
|
if (newRisk) {
|
||||||
|
this.riskCalculationReason = newRisk.reason;
|
||||||
|
const allowOverride = this.kycForm.get('allowRiskOverride')?.value;
|
||||||
|
const riskControl = this.kycForm.get('riskCategory');
|
||||||
|
if (!allowOverride && riskControl && riskControl.value !== newRisk.category) {
|
||||||
|
riskControl.setValue(newRisk.category);
|
||||||
|
riskControl.markAsDirty();
|
||||||
|
}
|
||||||
|
|
||||||
|
const allowAmlOverride = this.kycForm.get('allowAmlOverride')?.value;
|
||||||
|
if (!allowAmlOverride) {
|
||||||
|
this.applyAutoCalculatedAmlFlags(newRisk);
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
this.riskCalculationReason = '';
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if (changes['constitutionMeta'] && this.isNonIndividual) {
|
if (changes['constitutionMeta'] && this.isNonIndividual) {
|
||||||
this.updateDocTypesForConstitution();
|
this.updateDocTypesForConstitution();
|
||||||
}
|
}
|
||||||
|
// Legal form toggled on an already-initialized instance (route-reuse keeps this
|
||||||
|
// component alive across create sessions) — the individual OVD list and the entity RBI
|
||||||
|
// doc list are mutually exclusive, so re-seed from scratch rather than filtering in place.
|
||||||
|
if (changes['isNonIndividual'] && !changes['isNonIndividual'].firstChange && !this.kycTemplateLoaded) {
|
||||||
|
this.kycDocumentTypes = [];
|
||||||
|
this.selectedKycDoc = null;
|
||||||
|
this.kycForm.get('kycDocumentName')?.setValue('');
|
||||||
|
this.seedDefaultDocTypes();
|
||||||
|
if (this.isNonIndividual && this.constitutionMeta) {
|
||||||
|
this.updateDocTypesForConstitution();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (changes['isMinor'] && !this.isNonIndividual) {
|
||||||
|
if (this.isMinor) {
|
||||||
|
this.kycForm.get('hasPan')?.setValue(false);
|
||||||
|
this.kycForm.get('panNumber')?.setValue('');
|
||||||
|
}
|
||||||
|
// Re-derive from the full default list, not by re-filtering the already-narrowed
|
||||||
|
// `kycDocumentTypes` — filterDocTypesForMinor() only ever REMOVES entries, so once the
|
||||||
|
// initial (pre-DOB, isMinor=false) seed has already stripped out Birth Certificate /
|
||||||
|
// School ID Card, re-filtering that same shortened list can never bring them back once
|
||||||
|
// the client turns out to be a minor. Once the backend KYC template has loaded
|
||||||
|
// (kycTemplateLoaded), that list is already minor-aware and authoritative — leave it alone.
|
||||||
|
if (!this.kycTemplateLoaded) {
|
||||||
|
const rebuilt = this.filterDocTypesForMinor(
|
||||||
|
this.defaultDocumentTypes.map((d) => ({ name: d.name, code: d.code, mandatory: false }))
|
||||||
|
);
|
||||||
|
this.kycDocumentTypes = rebuilt;
|
||||||
|
const currentName = this.kycForm.get('kycDocumentName')?.value;
|
||||||
|
if (currentName && !rebuilt.some((d) => d.name === currentName)) {
|
||||||
|
this.kycForm.get('kycDocumentName')?.setValue('');
|
||||||
|
}
|
||||||
|
this.rebuildKycDocPicker();
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Update KYC document types based on selected constitution type */
|
/** Update KYC document types based on selected constitution type */
|
||||||
@@ -539,30 +832,17 @@ export class ClientKycStepComponent implements OnInit, OnChanges, OnDestroy {
|
|||||||
this.kycDocMergedPreview = null;
|
this.kycDocMergedPreview = null;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/** Open the saved KYC ID-proof document in an in-app preview dialog. */
|
||||||
* Open the saved KYC ID-proof document in a new browser tab.
|
|
||||||
*
|
|
||||||
* Same flow as the address-step variant: images use the already-loaded
|
|
||||||
* base64 data URL; PDFs (or anything without a cached preview) are fetched
|
|
||||||
* fresh and rendered via a temporary blob URL.
|
|
||||||
*/
|
|
||||||
viewExistingKycDoc(): void {
|
viewExistingKycDoc(): void {
|
||||||
if (!this.existingKycIdentifierId || this.existingKycDocuments.length === 0) {
|
if (!this.existingKycIdentifierId || this.existingKycDocuments.length === 0) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
const doc = this.existingKycDocuments[0];
|
const doc = this.existingKycDocuments[0];
|
||||||
if (doc.previewUrl) {
|
|
||||||
window.open(doc.previewUrl, '_blank', 'noopener');
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
this.clientsService.downloadClientIdentificationDocument(this.existingKycIdentifierId, doc.id).subscribe({
|
this.clientsService.downloadClientIdentificationDocument(this.existingKycIdentifierId, doc.id).subscribe({
|
||||||
next: (blob: Blob) => {
|
next: (blob: Blob) =>
|
||||||
const url = URL.createObjectURL(blob);
|
this.dialog.open(DocumentPreviewDialogComponent, {
|
||||||
const w = window.open(url, '_blank', 'noopener');
|
data: { blob, title: doc.fileName || doc.name, fileName: doc.fileName || doc.name }
|
||||||
if (w) {
|
}),
|
||||||
setTimeout(() => URL.revokeObjectURL(url), 60_000);
|
|
||||||
}
|
|
||||||
},
|
|
||||||
error: () => {
|
error: () => {
|
||||||
/* silent — UI still shows the saved filename */
|
/* silent — UI still shows the saved filename */
|
||||||
}
|
}
|
||||||
@@ -653,6 +933,14 @@ export class ClientKycStepComponent implements OnInit, OnChanges, OnDestroy {
|
|||||||
kycDocExpiryDate: parseDate(data.kycDocExpiryDate),
|
kycDocExpiryDate: parseDate(data.kycDocExpiryDate),
|
||||||
// Risk & Remarks
|
// Risk & Remarks
|
||||||
riskCategory: data.riskCategory || 1,
|
riskCategory: data.riskCategory || 1,
|
||||||
|
allowRiskOverride: this.calculatedRisk ? this.calculatedRisk.category !== (data.riskCategory || 1) : false,
|
||||||
|
allowAmlOverride: this.calculatedRisk
|
||||||
|
? this.calculatedRisk.amlCheckStatus !== (data.amlCheckStatus || 'CLEAR') ||
|
||||||
|
this.calculatedRisk.bannedEntityMatch !== (data.bannedEntityMatch || 'NO') ||
|
||||||
|
this.calculatedRisk.willfulDefaulter !== (data.willfulDefaulter || 'NO') ||
|
||||||
|
this.calculatedRisk.ecgcSalFlag !== (data.ecgcSalFlag || 'NO') ||
|
||||||
|
this.calculatedRisk.dormantFlag !== (data.dormantFlag || 'ACTIVE')
|
||||||
|
: false,
|
||||||
remarks: data.remarks || '',
|
remarks: data.remarks || '',
|
||||||
hasPan: !!data.panNumber,
|
hasPan: !!data.panNumber,
|
||||||
// AML & Compliance
|
// AML & Compliance
|
||||||
@@ -670,8 +958,18 @@ export class ClientKycStepComponent implements OnInit, OnChanges, OnDestroy {
|
|||||||
form60Date: parseDate(data.form60Date),
|
form60Date: parseDate(data.form60Date),
|
||||||
form15Applicable: data.form15Applicable || false,
|
form15Applicable: data.form15Applicable || false,
|
||||||
form15Type: data.form15Type || '15G',
|
form15Type: data.form15Type || '15G',
|
||||||
form15FY: data.form15FY || ''
|
form15FY: data.form15FY || '',
|
||||||
|
isException: data.isException || false,
|
||||||
|
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.persistedExceptionReason = data.exceptionReason || null;
|
||||||
// Sync chip-picker selection with the prefilled document name
|
// Sync chip-picker selection with the prefilled document name
|
||||||
if (data.kycDocumentName) {
|
if (data.kycDocumentName) {
|
||||||
this.selectedKycDoc = this.kycDocProofs.find((p) => p.name === data.kycDocumentName) || null;
|
this.selectedKycDoc = this.kycDocProofs.find((p) => p.name === data.kycDocumentName) || null;
|
||||||
@@ -738,12 +1036,17 @@ export class ClientKycStepComponent implements OnInit, OnChanges, OnDestroy {
|
|||||||
const n = i?.documentType?.name?.toLowerCase();
|
const n = i?.documentType?.name?.toLowerCase();
|
||||||
return !!n && this.kycDocProofs.some((p) => p.name?.toLowerCase() === n);
|
return !!n && this.kycDocProofs.some((p) => p.name?.toLowerCase() === n);
|
||||||
};
|
};
|
||||||
|
// Re-uploading a KYC document creates a new identifier row rather than updating the
|
||||||
|
// old one in place (same pattern as OVD identifiers), so sort newest-first before
|
||||||
|
// picking — otherwise a repeat upload of the same document type would keep showing
|
||||||
|
// the ORIGINAL file forever, since .find() would always hit the oldest matching row.
|
||||||
|
const sorted = [...identifiers].sort((a: any, b: any) => (b?.id ?? 0) - (a?.id ?? 0));
|
||||||
// Pick by document-type name match first; otherwise the first identifier whose
|
// Pick by document-type name match first; otherwise the first identifier whose
|
||||||
// type lives in the KYC proof list (avoid bleeding the address proof in here).
|
// type lives in the KYC proof list (avoid bleeding the address proof in here).
|
||||||
const match = kycDocumentName
|
const match = kycDocumentName
|
||||||
? identifiers.find((i: any) => i?.documentType?.name?.toLowerCase() === kycDocumentName.toLowerCase())
|
? sorted.find((i: any) => i?.documentType?.name?.toLowerCase() === kycDocumentName.toLowerCase())
|
||||||
: null;
|
: null;
|
||||||
const identifier = match || identifiers.find(isKycType);
|
const identifier = match || sorted.find(isKycType);
|
||||||
if (!identifier?.id) return;
|
if (!identifier?.id) return;
|
||||||
|
|
||||||
this.existingKycIdentifierId = identifier.id.toString();
|
this.existingKycIdentifierId = identifier.id.toString();
|
||||||
@@ -818,7 +1121,12 @@ export class ClientKycStepComponent implements OnInit, OnChanges, OnDestroy {
|
|||||||
if (!this.clientId) return;
|
if (!this.clientId) return;
|
||||||
this.clientsService.getClientDocuments(this.clientId).subscribe({
|
this.clientsService.getClientDocuments(this.clientId).subscribe({
|
||||||
next: (documents: any[]) => {
|
next: (documents: any[]) => {
|
||||||
const sigDoc = documents?.find((d: any) => d.name === 'clientSignature');
|
// Multiple 'clientSignature' documents can exist for clients who re-cropped/replaced
|
||||||
|
// their signature before uploads became single-slot, so pick the highest id (latest)
|
||||||
|
// rather than the first unordered match.
|
||||||
|
const sigDoc = documents
|
||||||
|
?.filter((d: any) => d.name === 'clientSignature')
|
||||||
|
?.sort((a: any, b: any) => b.id - a.id)?.[0];
|
||||||
if (sigDoc?.id) {
|
if (sigDoc?.id) {
|
||||||
this.existingSignatureDocId = sigDoc.id.toString();
|
this.existingSignatureDocId = sigDoc.id.toString();
|
||||||
this.clientsService.getClientSignatureImage(this.clientId, this.existingSignatureDocId).subscribe({
|
this.clientsService.getClientSignatureImage(this.clientId, this.existingSignatureDocId).subscribe({
|
||||||
@@ -847,11 +1155,13 @@ export class ClientKycStepComponent implements OnInit, OnChanges, OnDestroy {
|
|||||||
this.kycTemplate = template;
|
this.kycTemplate = template;
|
||||||
this.kycTemplateLoaded = true;
|
this.kycTemplateLoaded = true;
|
||||||
if (template?.requiredDocuments?.length > 0) {
|
if (template?.requiredDocuments?.length > 0) {
|
||||||
this.kycDocumentTypes = template.requiredDocuments.map((doc: any) => ({
|
this.kycDocumentTypes = this.filterDocTypesForMinor(
|
||||||
|
template.requiredDocuments.map((doc: any) => ({
|
||||||
name: doc.name,
|
name: doc.name,
|
||||||
code: doc.code || doc.name,
|
code: doc.code || doc.name,
|
||||||
mandatory: doc.mandatory ?? false
|
mandatory: doc.mandatory ?? false
|
||||||
}));
|
}))
|
||||||
|
);
|
||||||
// Update kycDocumentName validator based on mandatory docs
|
// Update kycDocumentName validator based on mandatory docs
|
||||||
const hasMandatory = this.kycDocumentTypes.some((d) => d.mandatory);
|
const hasMandatory = this.kycDocumentTypes.some((d) => d.mandatory);
|
||||||
if (hasMandatory) {
|
if (hasMandatory) {
|
||||||
@@ -864,7 +1174,7 @@ export class ClientKycStepComponent implements OnInit, OnChanges, OnDestroy {
|
|||||||
error: () => {
|
error: () => {
|
||||||
// Fallback to defaults if template API unavailable
|
// Fallback to defaults if template API unavailable
|
||||||
this.kycTemplateLoaded = true;
|
this.kycTemplateLoaded = true;
|
||||||
this.kycDocumentTypes = this.defaultDocumentTypes.map((d) => ({
|
this.kycDocumentTypes = this.getDefaultDocTypes().map((d) => ({
|
||||||
name: d.name,
|
name: d.name,
|
||||||
code: d.code,
|
code: d.code,
|
||||||
mandatory: false
|
mandatory: false
|
||||||
@@ -1291,6 +1601,21 @@ export class ClientKycStepComponent implements OnInit, OnChanges, OnDestroy {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Lets the user go back and fix a wrong Aadhaar number after an OTP was already sent —
|
||||||
|
* unlocks the (readonly-while-OTP-pending) input and discards the in-flight OTP session,
|
||||||
|
* since it was requested for the now-being-corrected number. */
|
||||||
|
changeAadhaarNumber(): void {
|
||||||
|
this.aadhaarOtpSent = false;
|
||||||
|
this.aadhaarVerifyError = '';
|
||||||
|
this.aadhaarOtpReferenceId = '';
|
||||||
|
this.resetOtpDigits();
|
||||||
|
this.resendCountdown = 0;
|
||||||
|
if (this.resendTimer) {
|
||||||
|
clearInterval(this.resendTimer);
|
||||||
|
this.resendTimer = null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/** Verify Aadhaar OTP */
|
/** Verify Aadhaar OTP */
|
||||||
verifyAadhaarOtp() {
|
verifyAadhaarOtp() {
|
||||||
if (!this.aadhaarOtp || !this.clientId || !this.kycId) return;
|
if (!this.aadhaarOtp || !this.clientId || !this.kycId) return;
|
||||||
@@ -1366,6 +1691,25 @@ export class ClientKycStepComponent implements OnInit, OnChanges, OnDestroy {
|
|||||||
if (!input.files?.length) return;
|
if (!input.files?.length) return;
|
||||||
const file = input.files[0];
|
const file = input.files[0];
|
||||||
input.value = '';
|
input.value = '';
|
||||||
|
await this.openPhotoCropDialog(file);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Webcam capture entry point for the profile photograph — feeds the captured frame
|
||||||
|
* through the same crop dialog + staging/upload path as the file-picker flow above,
|
||||||
|
* so behaviour (immediate upload in edit mode vs staged-until-submit in create mode)
|
||||||
|
* stays identical regardless of capture source. */
|
||||||
|
async captureWebcamPhoto(): Promise<void> {
|
||||||
|
const dialogRef = this.dialog.open(CaptureImageDialogComponent, {
|
||||||
|
data: { captureTarget: 'profile' }
|
||||||
|
});
|
||||||
|
dialogRef.afterClosed().subscribe(async (imageDataUrl: string) => {
|
||||||
|
if (!imageDataUrl) return;
|
||||||
|
const file = dataUrlToFile(imageDataUrl, 'webcam-photograph.jpg');
|
||||||
|
await this.openPhotoCropDialog(file);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
private async openPhotoCropDialog(file: File): Promise<void> {
|
||||||
const { ImageCropDialogComponent } = await import('./image-crop-dialog/image-crop-dialog.component');
|
const { ImageCropDialogComponent } = await import('./image-crop-dialog/image-crop-dialog.component');
|
||||||
const dialogRef = this.dialog.open(ImageCropDialogComponent, {
|
const dialogRef = this.dialog.open(ImageCropDialogComponent, {
|
||||||
width: '680px',
|
width: '680px',
|
||||||
@@ -1391,6 +1735,10 @@ export class ClientKycStepComponent implements OnInit, OnChanges, OnDestroy {
|
|||||||
if (!input.files?.length) return;
|
if (!input.files?.length) return;
|
||||||
const file = input.files[0];
|
const file = input.files[0];
|
||||||
input.value = '';
|
input.value = '';
|
||||||
|
await this.openSignatureCropDialog(file);
|
||||||
|
}
|
||||||
|
|
||||||
|
private async openSignatureCropDialog(file: File): Promise<void> {
|
||||||
const { ImageCropDialogComponent } = await import('./image-crop-dialog/image-crop-dialog.component');
|
const { ImageCropDialogComponent } = await import('./image-crop-dialog/image-crop-dialog.component');
|
||||||
const dialogRef = this.dialog.open(ImageCropDialogComponent, {
|
const dialogRef = this.dialog.open(ImageCropDialogComponent, {
|
||||||
width: '680px',
|
width: '680px',
|
||||||
@@ -1399,41 +1747,50 @@ export class ClientKycStepComponent implements OnInit, OnChanges, OnDestroy {
|
|||||||
});
|
});
|
||||||
dialogRef.afterClosed().subscribe((result: any) => {
|
dialogRef.afterClosed().subscribe((result: any) => {
|
||||||
if (result) {
|
if (result) {
|
||||||
this.signatureFile = result.croppedFile;
|
this.applySignatureFile(result.croppedFile, result.previewUrl);
|
||||||
this.signaturePreview = result.previewUrl;
|
}
|
||||||
// In edit mode, delete old signature doc then upload new one
|
});
|
||||||
if (this.clientId) {
|
}
|
||||||
|
|
||||||
|
/** Draw-with-mouse/touchpad entry point for the specimen signature — same staging/upload
|
||||||
|
* path as the file-picker and webcam flows, minus the crop step (the pad's canvas is
|
||||||
|
* already the right shape/size). */
|
||||||
|
drawSignature(): void {
|
||||||
|
const dialogRef = this.dialog.open(DrawSignatureDialogComponent);
|
||||||
|
dialogRef.afterClosed().subscribe((file: File) => {
|
||||||
|
if (file) {
|
||||||
|
this.applySignatureFile(file, URL.createObjectURL(file));
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Stages the signature locally and, in edit mode, persists it — replacing any existing
|
||||||
|
* signature doc first so a client never ends up with two. Shared by the file-picker, webcam
|
||||||
|
* and draw-pad entry points. */
|
||||||
|
private applySignatureFile(file: File, previewUrl: string): void {
|
||||||
|
this.signatureFile = file;
|
||||||
|
this.signaturePreview = previewUrl;
|
||||||
|
if (!this.clientId) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
if (this.existingSignatureDocId) {
|
if (this.existingSignatureDocId) {
|
||||||
this.clientsService.deleteClientDocument(this.clientId, this.existingSignatureDocId).subscribe({
|
this.clientsService.deleteClientDocument(this.clientId, this.existingSignatureDocId).subscribe({
|
||||||
next: () => {
|
next: () => this.uploadSignatureFile(file),
|
||||||
this.clientsService.uploadClientSignatureImage(this.clientId, result.croppedFile).subscribe({
|
error: () => this.uploadSignatureFile(file)
|
||||||
next: (res: any) => {
|
|
||||||
if (res?.resourceId) this.existingSignatureDocId = res.resourceId.toString();
|
|
||||||
},
|
|
||||||
error: () => {}
|
|
||||||
});
|
|
||||||
},
|
|
||||||
error: () => {
|
|
||||||
this.clientsService.uploadClientSignatureImage(this.clientId, result.croppedFile).subscribe({
|
|
||||||
next: (res: any) => {
|
|
||||||
if (res?.resourceId) this.existingSignatureDocId = res.resourceId.toString();
|
|
||||||
},
|
|
||||||
error: () => {}
|
|
||||||
});
|
|
||||||
}
|
|
||||||
});
|
});
|
||||||
} else {
|
} else {
|
||||||
this.clientsService.uploadClientSignatureImage(this.clientId, result.croppedFile).subscribe({
|
this.uploadSignatureFile(file);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private uploadSignatureFile(file: File): void {
|
||||||
|
this.clientsService.uploadClientSignatureImage(this.clientId, file).subscribe({
|
||||||
next: (res: any) => {
|
next: (res: any) => {
|
||||||
if (res?.resourceId) this.existingSignatureDocId = res.resourceId.toString();
|
if (res?.resourceId) this.existingSignatureDocId = res.resourceId.toString();
|
||||||
},
|
},
|
||||||
error: () => {}
|
error: () => {}
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
}
|
|
||||||
}
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
removePhoto() {
|
removePhoto() {
|
||||||
this.photographFile = null;
|
this.photographFile = null;
|
||||||
@@ -1459,7 +1816,7 @@ export class ClientKycStepComponent implements OnInit, OnChanges, OnDestroy {
|
|||||||
}
|
}
|
||||||
|
|
||||||
get kycData(): any {
|
get kycData(): any {
|
||||||
const formVal = this.readOnly ? this.kycForm.getRawValue() : this.kycForm.value;
|
const formVal = this.kycForm.getRawValue();
|
||||||
const aadhaarRaw = formVal.aadhaarNumber || '';
|
const aadhaarRaw = formVal.aadhaarNumber || '';
|
||||||
const aadhaarMasked = aadhaarRaw.length === 12 ? 'XXXXXXXX' + aadhaarRaw.slice(-4) : null;
|
const aadhaarMasked = aadhaarRaw.length === 12 ? 'XXXXXXXX' + aadhaarRaw.slice(-4) : null;
|
||||||
const dateFormat = this.settingsService.dateFormat;
|
const dateFormat = this.settingsService.dateFormat;
|
||||||
@@ -1512,7 +1869,21 @@ export class ClientKycStepComponent implements OnInit, OnChanges, OnDestroy {
|
|||||||
form15Applicable: v.form15Applicable || false,
|
form15Applicable: v.form15Applicable || false,
|
||||||
form15Type: v.form15Type || null,
|
form15Type: v.form15Type || null,
|
||||||
form15FY: v.form15FY || null,
|
form15FY: v.form15FY || null,
|
||||||
remarks: v.remarks || null
|
remarks: v.remarks || null,
|
||||||
|
// Exception state is DERIVED from verification status, not from a checkbox — an unverified
|
||||||
|
// or blank PAN/Aadhaar is an exception by definition, so the officer cannot forget to flag
|
||||||
|
// it. See deriveKycExceptionFields() for the raise / narrow / close rules.
|
||||||
|
...deriveKycExceptionFields({
|
||||||
|
panVerified: this.isNonIndividual ? this.entPanVerifyStatus === 'VERIFIED' : this.panVerifyStatus === 'VERIFIED',
|
||||||
|
aadhaarVerified: this.aadhaarVerifyStatus === 'VERIFIED',
|
||||||
|
// Non-individual clients submit a dummy masked Aadhaar, so checking it would saddle every
|
||||||
|
// company with a permanent exception.
|
||||||
|
aadhaarApplicable: !this.isNonIndividual,
|
||||||
|
hadException: this.persistedIsException,
|
||||||
|
previousReason: this.persistedExceptionReason,
|
||||||
|
manualReasons: Array.isArray(v.exceptionReason) ? v.exceptionReason : [],
|
||||||
|
todayFormatted: this.dateUtils.formatDate(new Date(), dateFormat)
|
||||||
|
})
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1525,9 +1896,25 @@ export class ClientKycStepComponent implements OnInit, OnChanges, OnDestroy {
|
|||||||
v.enterprisePan ||
|
v.enterprisePan ||
|
||||||
v.cinNumber ||
|
v.cinNumber ||
|
||||||
v.kycDocumentName ||
|
v.kycDocumentName ||
|
||||||
v.form15Applicable
|
v.form15Applicable ||
|
||||||
|
v.isException
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private applyAutoCalculatedAmlFlags(risk = this.calculatedRisk): void {
|
||||||
|
if (!risk) return;
|
||||||
|
this.kycForm.get('amlCheckStatus')?.setValue(risk.amlCheckStatus || 'CLEAR');
|
||||||
|
this.kycForm.get('bannedEntityMatch')?.setValue(risk.bannedEntityMatch || 'NO');
|
||||||
|
this.kycForm.get('willfulDefaulter')?.setValue(risk.willfulDefaulter || 'NO');
|
||||||
|
this.kycForm.get('ecgcSalFlag')?.setValue(risk.ecgcSalFlag || 'NO');
|
||||||
|
this.kycForm.get('dormantFlag')?.setValue(risk.dormantFlag || 'ACTIVE');
|
||||||
|
|
||||||
|
this.kycForm.get('amlCheckStatus')?.markAsDirty();
|
||||||
|
this.kycForm.get('bannedEntityMatch')?.markAsDirty();
|
||||||
|
this.kycForm.get('willfulDefaulter')?.markAsDirty();
|
||||||
|
this.kycForm.get('ecgcSalFlag')?.markAsDirty();
|
||||||
|
this.kycForm.get('dormantFlag')?.markAsDirty();
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
+1
-1
@@ -195,7 +195,7 @@
|
|||||||
client.isMinor ? 'preview-badge preview-badge--danger' : 'preview-badge preview-badge--verified'
|
client.isMinor ? 'preview-badge preview-badge--danger' : 'preview-badge preview-badge--verified'
|
||||||
"
|
"
|
||||||
>
|
>
|
||||||
{{ client.isMinor ? 'Yes (Age < 10)' : 'No' }}
|
{{ client.isMinor ? 'Yes (Age < 18)' : 'No' }}
|
||||||
</span>
|
</span>
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -22,6 +22,7 @@ import { AddFamilyMemberComponent } from './clients-view/family-members-tab/add-
|
|||||||
import { EditFamilyMemberComponent } from './clients-view/family-members-tab/edit-family-member/edit-family-member.component';
|
import { EditFamilyMemberComponent } from './clients-view/family-members-tab/edit-family-member/edit-family-member.component';
|
||||||
import { IdentitiesTabComponent } from './clients-view/identities-tab/identities-tab.component';
|
import { IdentitiesTabComponent } from './clients-view/identities-tab/identities-tab.component';
|
||||||
import { NotesTabComponent } from './clients-view/notes-tab/notes-tab.component';
|
import { NotesTabComponent } from './clients-view/notes-tab/notes-tab.component';
|
||||||
|
import { HistoryTabComponent } from './clients-view/history-tab/history-tab.component';
|
||||||
import { DocumentsTabComponent } from './clients-view/documents-tab/documents-tab.component';
|
import { DocumentsTabComponent } from './clients-view/documents-tab/documents-tab.component';
|
||||||
import { DatatableTabComponent } from './clients-view/datatable-tab/datatable-tab.component';
|
import { DatatableTabComponent } from './clients-view/datatable-tab/datatable-tab.component';
|
||||||
import { AddressTabComponent } from './clients-view/address-tab/address-tab.component';
|
import { AddressTabComponent } from './clients-view/address-tab/address-tab.component';
|
||||||
@@ -59,6 +60,7 @@ import { ClientCollateralResolver } from './common-resolvers/client-collateral.r
|
|||||||
import { KycTabComponent } from './clients-view/kyc-tab/kyc-tab.component';
|
import { KycTabComponent } from './clients-view/kyc-tab/kyc-tab.component';
|
||||||
import { CkycTabComponent } from './clients-view/ckyc-tab/ckyc-tab.component';
|
import { CkycTabComponent } from './clients-view/ckyc-tab/ckyc-tab.component';
|
||||||
import { Form60TabComponent } from './clients-view/form60-tab/form60-tab.component';
|
import { Form60TabComponent } from './clients-view/form60-tab/form60-tab.component';
|
||||||
|
import { Form121TabComponent } from './clients-view/form121-tab/form121-tab.component';
|
||||||
import { EntityClassificationTabComponent } from './clients-view/entity-classification-tab/entity-classification-tab.component';
|
import { EntityClassificationTabComponent } from './clients-view/entity-classification-tab/entity-classification-tab.component';
|
||||||
import { familyAllowedGuard, nomineeAllowedGuard } from './utils/family-nominee.guard';
|
import { familyAllowedGuard, nomineeAllowedGuard } from './utils/family-nominee.guard';
|
||||||
|
|
||||||
@@ -190,7 +192,12 @@ const routes: Routes = [
|
|||||||
{
|
{
|
||||||
path: 'form60',
|
path: 'form60',
|
||||||
component: Form60TabComponent,
|
component: Form60TabComponent,
|
||||||
data: { title: 'Form 60', breadcrumb: 'Form 60', routeParamBreadcrumb: false }
|
data: { title: 'Form 97', breadcrumb: 'Form 97', routeParamBreadcrumb: false }
|
||||||
|
},
|
||||||
|
{
|
||||||
|
path: 'form121',
|
||||||
|
component: Form121TabComponent,
|
||||||
|
data: { title: 'Form 121 / TDS', breadcrumb: 'Form 121 / TDS', routeParamBreadcrumb: false }
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
path: 'documents',
|
path: 'documents',
|
||||||
@@ -208,6 +215,11 @@ const routes: Routes = [
|
|||||||
clientNotes: ClientNotesResolver
|
clientNotes: ClientNotesResolver
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
path: 'history',
|
||||||
|
component: HistoryTabComponent,
|
||||||
|
data: { title: 'History', breadcrumb: 'History', routeParamBreadcrumb: false }
|
||||||
|
},
|
||||||
{
|
{
|
||||||
path: 'datatables',
|
path: 'datatables',
|
||||||
children: [
|
children: [
|
||||||
|
|||||||
@@ -234,8 +234,12 @@ export class CkycTabComponent implements OnInit, OnDestroy {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
// 2. Signature — client document with name='clientSignature'
|
// 2. Signature — client document with name='clientSignature'. Multiple rows can exist
|
||||||
const signatureDoc = (documents as any[])?.find((d: any) => d?.name === 'clientSignature');
|
// for clients who re-cropped/replaced their signature before uploads became single-slot,
|
||||||
|
// so pick the highest id (latest) rather than the first unordered match.
|
||||||
|
const signatureDoc = (documents as any[])
|
||||||
|
?.filter((d: any) => d?.name === 'clientSignature')
|
||||||
|
?.sort((a: any, b: any) => b.id - a.id)?.[0];
|
||||||
if (signatureDoc) {
|
if (signatureDoc) {
|
||||||
this.existing.CLIENT_SIGNATURE = {
|
this.existing.CLIENT_SIGNATURE = {
|
||||||
present: true,
|
present: true,
|
||||||
|
|||||||
@@ -410,6 +410,9 @@
|
|||||||
<button mat-menu-item (click)="doAction('Add Charge')" *mifosxHasPermission="'CREATE_CLIENTCHARGE'">
|
<button mat-menu-item (click)="doAction('Add Charge')" *mifosxHasPermission="'CREATE_CLIENTCHARGE'">
|
||||||
{{ 'labels.buttons.Add Charge' | translate }}
|
{{ 'labels.buttons.Add Charge' | translate }}
|
||||||
</button>
|
</button>
|
||||||
|
<button mat-menu-item (click)="doAction('Welcome Kit')" *mifosxHasPermission="'READ_WELCOMEKIT'">
|
||||||
|
Welcome Kit
|
||||||
|
</button>
|
||||||
<button mat-menu-item (click)="doAction('Create Collateral')">
|
<button mat-menu-item (click)="doAction('Create Collateral')">
|
||||||
{{ 'labels.buttons.Create Collateral' | translate }}
|
{{ 'labels.buttons.Create Collateral' | translate }}
|
||||||
</button>
|
</button>
|
||||||
@@ -426,6 +429,9 @@
|
|||||||
<button mat-menu-item (click)="doAction('Upload Signature')" *mifosxHasPermission="'CREATE_CLIENTIMAGE'">
|
<button mat-menu-item (click)="doAction('Upload Signature')" *mifosxHasPermission="'CREATE_CLIENTIMAGE'">
|
||||||
{{ 'labels.buttons.Upload Signature' | translate }}
|
{{ 'labels.buttons.Upload Signature' | translate }}
|
||||||
</button>
|
</button>
|
||||||
|
<button mat-menu-item (click)="doAction('Capture Signature')" *mifosxHasPermission="'CREATE_CLIENTIMAGE'">
|
||||||
|
Capture Signature (Webcam)
|
||||||
|
</button>
|
||||||
<button mat-menu-item (click)="doAction('Delete Signature')" *mifosxHasPermission="'DELETE_CLIENTIMAGE'">
|
<button mat-menu-item (click)="doAction('Delete Signature')" *mifosxHasPermission="'DELETE_CLIENTIMAGE'">
|
||||||
{{ 'labels.buttons.Delete Signature' | translate }}
|
{{ 'labels.buttons.Delete Signature' | translate }}
|
||||||
</button>
|
</button>
|
||||||
@@ -539,6 +545,15 @@
|
|||||||
>
|
>
|
||||||
<i class="fa fa-file-text cv-tab-icon"></i> {{ 'labels.inputs.Form 60' | translate }}
|
<i class="fa fa-file-text cv-tab-icon"></i> {{ 'labels.inputs.Form 60' | translate }}
|
||||||
</a>
|
</a>
|
||||||
|
<a
|
||||||
|
mat-tab-link
|
||||||
|
[routerLink]="['./form121']"
|
||||||
|
routerLinkActive
|
||||||
|
#form121="routerLinkActive"
|
||||||
|
[active]="form121.isActive"
|
||||||
|
>
|
||||||
|
<i class="fa fa-percent cv-tab-icon"></i> Form 121 / TDS
|
||||||
|
</a>
|
||||||
<a
|
<a
|
||||||
mat-tab-link
|
mat-tab-link
|
||||||
[routerLink]="['./documents']"
|
[routerLink]="['./documents']"
|
||||||
@@ -559,6 +574,15 @@
|
|||||||
>
|
>
|
||||||
<i class="fa fa-sticky-note cv-tab-icon"></i> {{ 'labels.inputs.Notes' | translate }}
|
<i class="fa fa-sticky-note cv-tab-icon"></i> {{ 'labels.inputs.Notes' | translate }}
|
||||||
</a>
|
</a>
|
||||||
|
<a
|
||||||
|
mat-tab-link
|
||||||
|
[routerLink]="['./history']"
|
||||||
|
routerLinkActive
|
||||||
|
#history="routerLinkActive"
|
||||||
|
[active]="history.isActive"
|
||||||
|
>
|
||||||
|
<i class="fa fa-history cv-tab-icon"></i> History
|
||||||
|
</a>
|
||||||
@for (clientDatatable of clientDatatables; track clientDatatable) {
|
@for (clientDatatable of clientDatatables; track clientDatatable) {
|
||||||
<a
|
<a
|
||||||
mat-tab-link
|
mat-tab-link
|
||||||
|
|||||||
@@ -22,6 +22,7 @@ import { DrawSignatureDialogComponent } from './custom-dialogs/draw-signature-di
|
|||||||
import { DeleteDialogComponent } from 'app/shared/delete-dialog/delete-dialog.component';
|
import { DeleteDialogComponent } from 'app/shared/delete-dialog/delete-dialog.component';
|
||||||
import { UploadImageDialogComponent } from './custom-dialogs/upload-image-dialog/upload-image-dialog.component';
|
import { UploadImageDialogComponent } from './custom-dialogs/upload-image-dialog/upload-image-dialog.component';
|
||||||
import { CaptureImageDialogComponent } from './custom-dialogs/capture-image-dialog/capture-image-dialog.component';
|
import { CaptureImageDialogComponent } from './custom-dialogs/capture-image-dialog/capture-image-dialog.component';
|
||||||
|
import { WelcomeKitDialogComponent } from './custom-dialogs/welcome-kit-dialog/welcome-kit-dialog.component';
|
||||||
|
|
||||||
/** Custom Services */
|
/** Custom Services */
|
||||||
import { ClientsService } from '../clients.service';
|
import { ClientsService } from '../clients.service';
|
||||||
@@ -240,6 +241,9 @@ export class ClientsViewComponent implements OnInit {
|
|||||||
case 'Upload Signature':
|
case 'Upload Signature':
|
||||||
this.uploadSignature();
|
this.uploadSignature();
|
||||||
break;
|
break;
|
||||||
|
case 'Capture Signature':
|
||||||
|
this.captureSignatureImage();
|
||||||
|
break;
|
||||||
case 'Delete Signature':
|
case 'Delete Signature':
|
||||||
this.deleteSignature();
|
this.deleteSignature();
|
||||||
break;
|
break;
|
||||||
@@ -252,6 +256,9 @@ export class ClientsViewComponent implements OnInit {
|
|||||||
case 'Delete Image':
|
case 'Delete Image':
|
||||||
this.deleteProfileImage();
|
this.deleteProfileImage();
|
||||||
break;
|
break;
|
||||||
|
case 'Welcome Kit':
|
||||||
|
this.dispatchWelcomeKit();
|
||||||
|
break;
|
||||||
case 'Create Standing Instructions':
|
case 'Create Standing Instructions':
|
||||||
const createStandingInstructionsQueryParams: any = {
|
const createStandingInstructionsQueryParams: any = {
|
||||||
officeId: this.clientViewData.officeId,
|
officeId: this.clientViewData.officeId,
|
||||||
@@ -332,6 +339,8 @@ export class ClientsViewComponent implements OnInit {
|
|||||||
this.uploadSignature();
|
this.uploadSignature();
|
||||||
} else if (response.draw) {
|
} else if (response.draw) {
|
||||||
this.drawSignature();
|
this.drawSignature();
|
||||||
|
} else if (response.capture) {
|
||||||
|
this.captureSignatureImage();
|
||||||
} else if (response.delete) {
|
} else if (response.delete) {
|
||||||
this.deleteSignature();
|
this.deleteSignature();
|
||||||
}
|
}
|
||||||
@@ -391,7 +400,9 @@ export class ClientsViewComponent implements OnInit {
|
|||||||
* Captures clients profile image.
|
* Captures clients profile image.
|
||||||
*/
|
*/
|
||||||
private captureProfileImage() {
|
private captureProfileImage() {
|
||||||
const captureImageDialogRef = this.dialog.open(CaptureImageDialogComponent);
|
const captureImageDialogRef = this.dialog.open(CaptureImageDialogComponent, {
|
||||||
|
data: { captureTarget: 'profile' }
|
||||||
|
});
|
||||||
captureImageDialogRef.afterClosed().subscribe((imageURL: string) => {
|
captureImageDialogRef.afterClosed().subscribe((imageURL: string) => {
|
||||||
if (imageURL) {
|
if (imageURL) {
|
||||||
this.clientsService.uploadCapturedClientProfileImage(this.clientViewData.id, imageURL).subscribe(() => {
|
this.clientsService.uploadCapturedClientProfileImage(this.clientViewData.id, imageURL).subscribe(() => {
|
||||||
@@ -401,6 +412,24 @@ export class ClientsViewComponent implements OnInit {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Captures client signature via webcam (photograph of a paper signature).
|
||||||
|
* Mirrors `captureProfileImage()` — same dialog, parametrized for signature capture,
|
||||||
|
* uploaded via `uploadCapturedClientSignature()` instead of the profile-image upload.
|
||||||
|
*/
|
||||||
|
private captureSignatureImage() {
|
||||||
|
const captureImageDialogRef = this.dialog.open(CaptureImageDialogComponent, {
|
||||||
|
data: { captureTarget: 'signature' }
|
||||||
|
});
|
||||||
|
captureImageDialogRef.afterClosed().subscribe((imageURL: string) => {
|
||||||
|
if (imageURL) {
|
||||||
|
this.clientsService.uploadCapturedClientSignature(this.clientViewData.id, imageURL).subscribe(() => {
|
||||||
|
this.reload();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Uploads the clients image.
|
* Uploads the clients image.
|
||||||
*/
|
*/
|
||||||
@@ -430,4 +459,20 @@ export class ClientsViewComponent implements OnInit {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Opens the Welcome Kit Dispatch dialog.
|
||||||
|
*/
|
||||||
|
private dispatchWelcomeKit() {
|
||||||
|
const welcomeKitDialogRef = this.dialog.open(WelcomeKitDialogComponent, {
|
||||||
|
data: { clientId: this.clientViewData.id },
|
||||||
|
width: '600px'
|
||||||
|
});
|
||||||
|
welcomeKitDialogRef.afterClosed().subscribe((response: any) => {
|
||||||
|
if (response) {
|
||||||
|
// the form was successfully submitted
|
||||||
|
this.reload();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+22
-8
@@ -6,10 +6,24 @@
|
|||||||
file, You can obtain one at http://mozilla.org/MPL/2.0/.
|
file, You can obtain one at http://mozilla.org/MPL/2.0/.
|
||||||
-->
|
-->
|
||||||
|
|
||||||
<div class="layout-column gap-2px">
|
<div class="layout-column gap-2px capture-dialog">
|
||||||
<h1 mat-dialog-title align="center">{{ 'labels.heading.Capture Client Image' | translate }}</h1>
|
<h1 mat-dialog-title align="center" class="capture-title">
|
||||||
|
<fa-icon [icon]="isSignatureCapture ? 'signature' : 'camera'"></fa-icon>
|
||||||
|
<span>{{ dialogTitle }}</span>
|
||||||
|
</h1>
|
||||||
|
<p class="capture-instructions" align="center">{{ dialogInstructions }}</p>
|
||||||
|
|
||||||
<video #video class="capture-video" autoplay></video>
|
<div class="capture-frame" [class.capture-frame--signature]="isSignatureCapture">
|
||||||
|
<video
|
||||||
|
#video
|
||||||
|
class="capture-video"
|
||||||
|
autoplay
|
||||||
|
playsinline
|
||||||
|
disablepictureinpicture
|
||||||
|
disableremoteplayback
|
||||||
|
controlslist="nodownload noremoteplayback nofullscreen"
|
||||||
|
></video>
|
||||||
|
</div>
|
||||||
|
|
||||||
<!-- Using a class will break renderer changes -->
|
<!-- Using a class will break renderer changes -->
|
||||||
<canvas #canvas [ngStyle]="{ display: 'none' }"></canvas>
|
<canvas #canvas [ngStyle]="{ display: 'none' }"></canvas>
|
||||||
@@ -17,20 +31,20 @@
|
|||||||
<!-- Using a class will break renderer changes -->
|
<!-- Using a class will break renderer changes -->
|
||||||
<p #fallback [ngStyle]="{ display: 'none' }" align="center"></p>
|
<p #fallback [ngStyle]="{ display: 'none' }" align="center"></p>
|
||||||
|
|
||||||
<div class="flex-20" align="center">
|
<div class="capture-actions-row">
|
||||||
@if (!isCaptured) {
|
@if (!isCaptured) {
|
||||||
<button #captureButton mat-stroked-button color="primary" (click)="capture()">
|
<button #captureButton mat-stroked-button color="primary" class="capture-btn" (click)="capture()">
|
||||||
{{ 'labels.buttons.Capture' | translate }}
|
<fa-icon icon="camera" size="sm" class="m-r-10"></fa-icon>{{ 'labels.buttons.Capture' | translate }}
|
||||||
</button>
|
</button>
|
||||||
}
|
}
|
||||||
@if (isCaptured) {
|
@if (isCaptured) {
|
||||||
<button mat-stroked-button color="primary" (click)="recapture()">
|
<button mat-stroked-button color="primary" class="capture-btn" (click)="recapture()">
|
||||||
<fa-icon icon="undo" size="sm" class="m-r-10"></fa-icon>{{ 'labels.buttons.Recapture' | translate }}
|
<fa-icon icon="undo" size="sm" class="m-r-10"></fa-icon>{{ 'labels.buttons.Recapture' | translate }}
|
||||||
</button>
|
</button>
|
||||||
}
|
}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<mat-dialog-actions align="end">
|
<mat-dialog-actions align="end" class="capture-dialog-footer">
|
||||||
<button mat-raised-button mat-dialog-close>{{ 'labels.buttons.Cancel' | translate }}</button>
|
<button mat-raised-button mat-dialog-close>{{ 'labels.buttons.Cancel' | translate }}</button>
|
||||||
<button [disabled]="!isCaptured" mat-raised-button color="primary" [mat-dialog-close]="clientImageDataURL">
|
<button [disabled]="!isCaptured" mat-raised-button color="primary" [mat-dialog-close]="clientImageDataURL">
|
||||||
{{ 'labels.buttons.Upload' | translate }}
|
{{ 'labels.buttons.Upload' | translate }}
|
||||||
|
|||||||
+96
-6
@@ -6,13 +6,103 @@
|
|||||||
* file, You can obtain one at http://mozilla.org/MPL/2.0/.
|
* file, You can obtain one at http://mozilla.org/MPL/2.0/.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
@use '../../../../../assets/styles/colours' as colors;
|
/* No explicit width/maxHeight is passed at every `dialog.open(CaptureImageDialogComponent)`
|
||||||
|
call site (there are several, across clients-view/create/edit flows) — capping height and
|
||||||
|
allowing internal scroll here, once, keeps the Cancel/Upload footer reachable on short
|
||||||
|
viewports instead of relying on every caller to remember a maxHeight config. */
|
||||||
|
:host {
|
||||||
|
display: block;
|
||||||
|
max-height: 90vh;
|
||||||
|
overflow-y: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.capture-dialog {
|
||||||
|
padding: 4px 4px 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.capture-title {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
gap: 8px;
|
||||||
|
margin: 0;
|
||||||
|
|
||||||
|
fa-icon {
|
||||||
|
color: #1074b9;
|
||||||
|
font-size: 18px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.capture-instructions {
|
||||||
|
margin: 4px 0 14px;
|
||||||
|
padding: 0 16px;
|
||||||
|
font-size: 13px;
|
||||||
|
color: rgb(0 0 0 / 60%);
|
||||||
|
}
|
||||||
|
|
||||||
|
.capture-frame {
|
||||||
|
width: 100%;
|
||||||
|
max-width: 640px;
|
||||||
|
|
||||||
|
/* Capped in vh, not just px, so the frame shrinks on short viewports (laptop screens with
|
||||||
|
browser chrome eating into the visible height) instead of pushing the Capture/Cancel/
|
||||||
|
Upload buttons below the fold — `object-fit: cover` on the video means shrinking this box
|
||||||
|
never reintroduces pillarboxing, it just changes how much of the live frame is visible. */
|
||||||
|
max-height: 42vh;
|
||||||
|
margin: 0 auto;
|
||||||
|
border-radius: 14px;
|
||||||
|
overflow: hidden;
|
||||||
|
background: #111;
|
||||||
|
box-shadow: 0 8px 24px -8px rgb(15 23 42 / 25%);
|
||||||
|
border: 1px solid rgb(255 255 255 / 8%);
|
||||||
|
|
||||||
|
/* Default (profile) frame follows the requested 4:3 capture stream. */
|
||||||
|
aspect-ratio: 4 / 3;
|
||||||
|
|
||||||
|
/* Signature capture uses a shorter, wider (16:9) frame — paper signatures are landscape-ish
|
||||||
|
and this matches the wider resolution requested from getUserMedia, so the live preview
|
||||||
|
fills the frame edge-to-edge instead of pillarboxing (black bars) inside a mismatched box. */
|
||||||
|
&--signature {
|
||||||
|
aspect-ratio: 16 / 9;
|
||||||
|
max-height: 36vh;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
.capture-video {
|
.capture-video {
|
||||||
width: 100%;
|
width: 100%;
|
||||||
max-width: 640px;
|
height: 100%;
|
||||||
height: auto;
|
object-fit: cover;
|
||||||
object-fit: contain;
|
display: block;
|
||||||
border-radius: 4px;
|
|
||||||
background-color: colors.$black;
|
/* Chromium auto-injects a Picture-in-Picture toggle (and sometimes a cast button) as small
|
||||||
|
square overlay icons in a bare <video>'s corners on hover — the `disablepictureinpicture`/
|
||||||
|
`disableremoteplayback` attributes stop the *feature*, but these pseudo-elements are the
|
||||||
|
belt-and-braces way to make sure the icons themselves never render. */
|
||||||
|
&::-webkit-media-controls,
|
||||||
|
&::-webkit-media-controls-panel,
|
||||||
|
&::-internal-media-controls-overlay-cast-button {
|
||||||
|
display: none !important;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.capture-actions-row {
|
||||||
|
display: flex;
|
||||||
|
justify-content: center;
|
||||||
|
margin: 16px 0 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.capture-btn {
|
||||||
|
border-radius: 999px !important;
|
||||||
|
padding: 0 20px !important;
|
||||||
|
height: 42px;
|
||||||
|
font-weight: 600 !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.capture-dialog-footer {
|
||||||
|
padding: 8px 4px 0 !important;
|
||||||
|
|
||||||
|
.mat-mdc-raised-button {
|
||||||
|
border-radius: 999px !important;
|
||||||
|
font-weight: 600 !important;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+37
-6
@@ -8,11 +8,23 @@
|
|||||||
|
|
||||||
/** Angular Imports */
|
/** Angular Imports */
|
||||||
import { Component, ViewChild, ElementRef, AfterViewInit, Renderer2, OnDestroy, inject } from '@angular/core';
|
import { Component, ViewChild, ElementRef, AfterViewInit, Renderer2, OnDestroy, inject } from '@angular/core';
|
||||||
import { MatDialogRef, MatDialogTitle, MatDialogActions, MatDialogClose } from '@angular/material/dialog';
|
import {
|
||||||
|
MatDialogRef,
|
||||||
|
MAT_DIALOG_DATA,
|
||||||
|
MatDialogTitle,
|
||||||
|
MatDialogActions,
|
||||||
|
MatDialogClose
|
||||||
|
} from '@angular/material/dialog';
|
||||||
import { NgStyle } from '@angular/common';
|
import { NgStyle } from '@angular/common';
|
||||||
import { FaIconComponent } from '@fortawesome/angular-fontawesome';
|
import { FaIconComponent } from '@fortawesome/angular-fontawesome';
|
||||||
import { STANDALONE_SHARED_IMPORTS } from 'app/standalone-shared.module';
|
import { STANDALONE_SHARED_IMPORTS } from 'app/standalone-shared.module';
|
||||||
|
|
||||||
|
/** What the captured image is for — drives the dialog's title/instructions and lets the
|
||||||
|
* caller decide (based on how it opened the dialog) which upload method to invoke on the
|
||||||
|
* resulting data URL. Defaults to 'profile' so existing callers that don't pass `data`
|
||||||
|
* keep working unchanged. */
|
||||||
|
export type CaptureImageTarget = 'profile' | 'signature';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Capture image dialog component
|
* Capture image dialog component
|
||||||
*/
|
*/
|
||||||
@@ -32,6 +44,24 @@ import { STANDALONE_SHARED_IMPORTS } from 'app/standalone-shared.module';
|
|||||||
export class CaptureImageDialogComponent implements AfterViewInit, OnDestroy {
|
export class CaptureImageDialogComponent implements AfterViewInit, OnDestroy {
|
||||||
dialogRef = inject<MatDialogRef<CaptureImageDialogComponent>>(MatDialogRef);
|
dialogRef = inject<MatDialogRef<CaptureImageDialogComponent>>(MatDialogRef);
|
||||||
private renderer = inject(Renderer2);
|
private renderer = inject(Renderer2);
|
||||||
|
private data = inject<{ captureTarget?: CaptureImageTarget } | null>(MAT_DIALOG_DATA, { optional: true });
|
||||||
|
|
||||||
|
/** 'profile' (default, unchanged behaviour) or 'signature' — photographing a paper signature. */
|
||||||
|
captureTarget: CaptureImageTarget = this.data?.captureTarget || 'profile';
|
||||||
|
|
||||||
|
get isSignatureCapture(): boolean {
|
||||||
|
return this.captureTarget === 'signature';
|
||||||
|
}
|
||||||
|
|
||||||
|
get dialogTitle(): string {
|
||||||
|
return this.isSignatureCapture ? 'Capture Client Signature' : 'Capture Client Image';
|
||||||
|
}
|
||||||
|
|
||||||
|
get dialogInstructions(): string {
|
||||||
|
return this.isSignatureCapture
|
||||||
|
? 'Photograph the customer’s signed paper — line up the signature within the frame before capturing.'
|
||||||
|
: 'Line up the customer’s face within the frame before capturing.';
|
||||||
|
}
|
||||||
|
|
||||||
/** Video element reference */
|
/** Video element reference */
|
||||||
@ViewChild('video', { static: true }) video: ElementRef;
|
@ViewChild('video', { static: true }) video: ElementRef;
|
||||||
@@ -62,11 +92,12 @@ export class CaptureImageDialogComponent implements AfterViewInit, OnDestroy {
|
|||||||
*/
|
*/
|
||||||
startCamera() {
|
startCamera() {
|
||||||
if (navigator.mediaDevices && navigator.mediaDevices.getUserMedia) {
|
if (navigator.mediaDevices && navigator.mediaDevices.getUserMedia) {
|
||||||
const videoConstraints: MediaTrackConstraints = {
|
// Signature capture asks for a wide/short (16:9) stream to match the frame it's shown
|
||||||
width: { ideal: 640 },
|
// in — requesting an aspect ratio close to what the CSS box actually renders avoids the
|
||||||
height: { ideal: 480 },
|
// camera negotiating a 4:3 stream that then gets pillarboxed (black bars) inside a 16:9 box.
|
||||||
facingMode: 'user'
|
const videoConstraints: MediaTrackConstraints = this.isSignatureCapture
|
||||||
};
|
? { width: { ideal: 1280 }, height: { ideal: 720 }, facingMode: 'user' }
|
||||||
|
: { width: { ideal: 640 }, height: { ideal: 480 }, facingMode: 'user' };
|
||||||
navigator.mediaDevices
|
navigator.mediaDevices
|
||||||
.getUserMedia({ video: videoConstraints })
|
.getUserMedia({ video: videoConstraints })
|
||||||
.then((stream: MediaStream) => {
|
.then((stream: MediaStream) => {
|
||||||
|
|||||||
+7
-1
@@ -47,7 +47,13 @@ export class DeleteSignatureDialogComponent {
|
|||||||
* @param {any} data Documents data
|
* @param {any} data Documents data
|
||||||
*/
|
*/
|
||||||
constructor() {
|
constructor() {
|
||||||
const signature = this.data.find((document: any) => document.name === 'clientSignature') || {};
|
// Multiple 'clientSignature' documents can exist for clients who re-cropped/replaced their
|
||||||
|
// signature before uploads became single-slot, so pick the highest id (latest) rather than
|
||||||
|
// the first unordered match.
|
||||||
|
const signature =
|
||||||
|
this.data
|
||||||
|
.filter((document: any) => document.name === 'clientSignature')
|
||||||
|
.sort((a: any, b: any) => b.id - a.id)[0] || {};
|
||||||
this.signatureId = signature.id;
|
this.signatureId = signature.id;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+21
-3
@@ -347,9 +347,18 @@
|
|||||||
<i class="fa fa-calendar"></i> Issue / statement date
|
<i class="fa fa-calendar"></i> Issue / statement date
|
||||||
<span class="kyc-opt">(not older than 3 months)</span>
|
<span class="kyc-opt">(not older than 3 months)</span>
|
||||||
</label>
|
</label>
|
||||||
<div class="kyc-input-wrap kyc-input-wrap--date">
|
<div class="kyc-input-wrap kyc-input-wrap--date" (click)="uploadIssueDatePicker.open()">
|
||||||
<i class="fa fa-calendar-o kyc-input-wrap__icon"></i>
|
<input
|
||||||
<input type="date" class="kyc-input kyc-input--date" formControlName="issueDate" />
|
class="kyc-input kyc-input--date"
|
||||||
|
[matDatepicker]="uploadIssueDatePicker"
|
||||||
|
formControlName="issueDate"
|
||||||
|
readonly
|
||||||
|
/>
|
||||||
|
<mat-datepicker-toggle
|
||||||
|
class="kyc-input-wrap__calendar-toggle"
|
||||||
|
[for]="uploadIssueDatePicker"
|
||||||
|
></mat-datepicker-toggle>
|
||||||
|
<mat-datepicker #uploadIssueDatePicker></mat-datepicker>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
}
|
}
|
||||||
@@ -438,6 +447,15 @@
|
|||||||
<div class="fd-field fd-field--file">
|
<div class="fd-field fd-field--file">
|
||||||
<mifosx-file-upload flex="100%" (change)="onFileSelect($event)"></mifosx-file-upload>
|
<mifosx-file-upload flex="100%" (change)="onFileSelect($event)"></mifosx-file-upload>
|
||||||
</div>
|
</div>
|
||||||
|
<div class="fd-webcam-row">
|
||||||
|
<span class="fd-webcam-divider">or</span>
|
||||||
|
<button type="button" mat-stroked-button color="primary" class="fd-webcam-btn" (click)="captureFromWebcam()">
|
||||||
|
<i class="fa fa-camera"></i> Capture from Webcam
|
||||||
|
</button>
|
||||||
|
@if (capturedFileName) {
|
||||||
|
<span class="fd-webcam-confirm"><i class="fa fa-check-circle"></i> {{ capturedFileName }}</span>
|
||||||
|
}
|
||||||
|
</div>
|
||||||
</form>
|
</form>
|
||||||
}
|
}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
+51
-26
@@ -130,6 +130,41 @@ $white: #fff;
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.fd-webcam-row {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 10px;
|
||||||
|
margin-top: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.fd-webcam-divider {
|
||||||
|
font-size: 12px;
|
||||||
|
color: $gray-500;
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 0.04em;
|
||||||
|
}
|
||||||
|
|
||||||
|
.fd-webcam-btn {
|
||||||
|
border-radius: 8px !important;
|
||||||
|
font-weight: 500 !important;
|
||||||
|
|
||||||
|
i {
|
||||||
|
margin-right: 6px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.fd-webcam-confirm {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 6px;
|
||||||
|
font-size: 13px;
|
||||||
|
color: #1e8e3e;
|
||||||
|
|
||||||
|
i {
|
||||||
|
color: #1e8e3e;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
.fd-section-divider {
|
.fd-section-divider {
|
||||||
margin: 8px 0 12px;
|
margin: 8px 0 12px;
|
||||||
border-bottom: 1px solid $gray-200;
|
border-bottom: 1px solid $gray-200;
|
||||||
@@ -353,42 +388,32 @@ $red-900: #993c1d;
|
|||||||
padding-left: 38px;
|
padding-left: 38px;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Date input — match styling of other kyc inputs */
|
/* Date input — opens the Material calendar picker; styled to match other kyc inputs */
|
||||||
.kyc-input--date {
|
.kyc-input--date {
|
||||||
font-family: inherit;
|
font-family: inherit;
|
||||||
color: $gray-900;
|
color: $gray-900;
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
min-height: 40px;
|
min-height: 40px;
|
||||||
padding-right: 14px !important;
|
padding-left: 12px !important;
|
||||||
|
padding-right: 40px !important;
|
||||||
&::-webkit-calendar-picker-indicator {
|
|
||||||
opacity: 0.55;
|
|
||||||
cursor: pointer;
|
|
||||||
filter: grayscale(1);
|
|
||||||
transition: opacity 0.15s;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
&:hover::-webkit-calendar-picker-indicator {
|
.kyc-input-wrap--date .kyc-input-wrap__calendar-toggle {
|
||||||
opacity: 0.9;
|
position: absolute;
|
||||||
}
|
right: 2px;
|
||||||
|
top: 50%;
|
||||||
&::-webkit-datetime-edit {
|
transform: translateY(-50%);
|
||||||
color: $gray-900;
|
|
||||||
padding-top: 1px;
|
|
||||||
}
|
|
||||||
|
|
||||||
&::-webkit-datetime-edit-fields-wrapper {
|
|
||||||
color: $gray-900;
|
|
||||||
}
|
|
||||||
|
|
||||||
&::-webkit-datetime-edit-text {
|
|
||||||
color: $gray-500;
|
color: $gray-500;
|
||||||
padding: 0 2px;
|
|
||||||
|
::ng-deep .mat-mdc-icon-button {
|
||||||
|
width: 32px;
|
||||||
|
height: 32px;
|
||||||
|
padding: 4px;
|
||||||
}
|
}
|
||||||
|
|
||||||
&:invalid::-webkit-datetime-edit,
|
::ng-deep .mat-mdc-icon-button svg {
|
||||||
&:not(:focus):placeholder-shown::-webkit-datetime-edit {
|
width: 18px;
|
||||||
color: $gray-500;
|
height: 18px;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
+26
@@ -16,12 +16,14 @@ import {
|
|||||||
MatDialogClose
|
MatDialogClose
|
||||||
} from '@angular/material/dialog';
|
} from '@angular/material/dialog';
|
||||||
import { ImageCropperDialogComponent } from '../image-cropper-dialog/image-cropper-dialog.component';
|
import { ImageCropperDialogComponent } from '../image-cropper-dialog/image-cropper-dialog.component';
|
||||||
|
import { CaptureImageDialogComponent } from '../capture-image-dialog/capture-image-dialog.component';
|
||||||
import { UntypedFormGroup, UntypedFormBuilder, Validators, ReactiveFormsModule } from '@angular/forms';
|
import { UntypedFormGroup, UntypedFormBuilder, Validators, ReactiveFormsModule } from '@angular/forms';
|
||||||
import { FileUploadComponent } from '../../../../shared/file-upload/file-upload.component';
|
import { FileUploadComponent } from '../../../../shared/file-upload/file-upload.component';
|
||||||
import { MatIconButton } from '@angular/material/button';
|
import { MatIconButton } from '@angular/material/button';
|
||||||
import { STANDALONE_SHARED_IMPORTS } from 'app/standalone-shared.module';
|
import { STANDALONE_SHARED_IMPORTS } from 'app/standalone-shared.module';
|
||||||
import { ClientsService } from '../../../clients.service';
|
import { ClientsService } from '../../../clients.service';
|
||||||
import { ProofType, normalizeProofType, groupProofsByCategory } from '../../../shared/kyc-proof-types';
|
import { ProofType, normalizeProofType, groupProofsByCategory } from '../../../shared/kyc-proof-types';
|
||||||
|
import { dataUrlToFile } from '../../../../shared/utils/data-url.util';
|
||||||
|
|
||||||
type SectionKey = 'id' | 'address';
|
type SectionKey = 'id' | 'address';
|
||||||
|
|
||||||
@@ -72,6 +74,12 @@ export class UploadDocumentDialogComponent implements OnInit {
|
|||||||
addrMergedFile: File | null = null;
|
addrMergedFile: File | null = null;
|
||||||
addrMergedPreview: string | null = null;
|
addrMergedPreview: string | null = null;
|
||||||
|
|
||||||
|
/** Non-identifier (generic) mode only — set after a webcam capture, since
|
||||||
|
* `FileUploadComponent` only reflects files picked through its own native input and has
|
||||||
|
* no input for one supplied programmatically. Cleared if the user then picks a file via
|
||||||
|
* Browse instead, so only one confirmation shows at a time. */
|
||||||
|
capturedFileName: string | null = null;
|
||||||
|
|
||||||
constructor() {
|
constructor() {
|
||||||
const data = this.data;
|
const data = this.data;
|
||||||
this.documentIdentifier = !!data?.documentIdentifier;
|
this.documentIdentifier = !!data?.documentIdentifier;
|
||||||
@@ -390,6 +398,24 @@ export class UploadDocumentDialogComponent implements OnInit {
|
|||||||
if ($event.target.files.length > 0) {
|
if ($event.target.files.length > 0) {
|
||||||
const file = $event.target.files[0];
|
const file = $event.target.files[0];
|
||||||
this.uploadDocumentForm.get('file').setValue(file);
|
this.uploadDocumentForm.get('file').setValue(file);
|
||||||
|
this.capturedFileName = null;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Non-identifier (generic Documents tab) mode only — lets staff photograph a document
|
||||||
|
* with the webcam instead of only being able to browse for an existing file. Reuses the
|
||||||
|
* same capture dialog as the profile-photo/signature flows. */
|
||||||
|
captureFromWebcam(): void {
|
||||||
|
const ref = this.dialog.open(CaptureImageDialogComponent, {
|
||||||
|
width: '720px',
|
||||||
|
maxWidth: '95vw',
|
||||||
|
data: { captureTarget: 'profile' }
|
||||||
|
});
|
||||||
|
ref.afterClosed().subscribe((dataUrl: string | null) => {
|
||||||
|
if (!dataUrl) return;
|
||||||
|
const file = dataUrlToFile(dataUrl, `webcam-capture-${Date.now()}.jpg`);
|
||||||
|
this.uploadDocumentForm.get('file').setValue(file);
|
||||||
|
this.capturedFileName = file.name;
|
||||||
|
});
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user