Compare commits
104
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
e78e44f917 | ||
|
|
d032dd7e75 | ||
|
|
96bebd339b | ||
|
|
fca550f4c4 | ||
|
|
357f666421 | ||
|
|
3e5cc26e49 | ||
|
|
c3713f8a90 | ||
|
|
f804d356d7 | ||
|
|
1daa8e60a0 | ||
|
|
55f5c1cf32 | ||
|
|
6507e68203 | ||
|
|
4d205672df | ||
|
|
ab8ed7e67e | ||
|
|
7ee230e58a | ||
|
|
7f3c97a205 | ||
|
|
4e3b49a96a | ||
|
|
915deddb74 | ||
|
|
211027b9d8 | ||
|
|
d88f2a6eb1 | ||
|
|
606d14b995 | ||
|
|
afcd3af918 | ||
|
|
0eb087e3c3 | ||
|
|
7c23bdd6af | ||
|
|
767461faae | ||
|
|
a87cf42c5c | ||
|
|
c91dcf4d99 | ||
|
|
2d2eac8d03 | ||
|
|
3928936b93 | ||
|
|
799a6930b8 | ||
|
|
1cebb84906 | ||
|
|
c90528e109 | ||
|
|
79bffaca69 | ||
|
|
d61584f291 | ||
|
|
4acee3e63b | ||
|
|
cec5223378 | ||
|
|
a5a64d4efe | ||
|
|
0ea57b3f82 | ||
|
|
0976bc230c | ||
|
|
6b0f218b70 | ||
|
|
6226682b36 | ||
|
|
38884610d2 | ||
|
|
d6d7d912c0 | ||
|
|
454988d803 | ||
|
|
17bfe4c7e6 | ||
|
|
7c868ae073 | ||
|
|
5ab4e9eaf0 | ||
|
|
4fc6784695 | ||
|
|
174a2e7b6b | ||
|
|
9f7d9d28fd | ||
|
|
1ffac4b1e5 | ||
|
|
4dee1db360 | ||
|
|
0c6ebfdc1a | ||
|
|
b6994c0b76 | ||
|
|
885b39d970 | ||
|
|
4f02f24aae | ||
|
|
73871a0993 | ||
|
|
7b0cb5bb63 | ||
|
|
74a51a0626 | ||
|
|
283ab2f733 | ||
|
|
646e73d08a | ||
|
|
a7ed3883df | ||
|
|
18c3bb6356 | ||
|
|
6afa79bd37 | ||
|
|
ec23d57734 | ||
|
|
192bc7725e | ||
|
|
eb278978bd | ||
|
|
3f6485f652 | ||
|
|
8d0d8822f0 | ||
|
|
59e1eeacc3 | ||
|
|
4ca08f6ee9 | ||
|
|
f451672ce7 | ||
|
|
6cbb31b5c6 | ||
|
|
90f26d32b7 | ||
|
|
a5ed3fde21 | ||
|
|
5ee0a3fc53 | ||
|
|
6f259ad06e | ||
|
|
be35f3cbc3 | ||
|
|
7b9a6394c8 | ||
|
|
1eaa8cc8cf | ||
|
|
a652ce1093 | ||
|
|
50b3542dfe | ||
|
|
89c091671f | ||
|
|
865ad8ba88 | ||
|
|
08a427467a | ||
|
|
3f59cc6b91 | ||
|
|
b0777e9845 | ||
|
|
d47e0860f6 | ||
|
|
e2eff9914f | ||
|
|
a6e6220100 | ||
|
|
3c363d0372 | ||
|
|
dc7f668056 | ||
|
|
124f05d21d | ||
|
|
c7428d0cb2 | ||
|
|
17d97da380 | ||
|
|
f9b73a4930 | ||
|
|
e53dfb5a66 | ||
|
|
41540ad24e | ||
|
|
b71619c402 | ||
|
|
d18bd9cf37 | ||
|
|
cda7c52d11 | ||
|
|
d132bbe716 | ||
|
|
6eaf9b959f | ||
|
|
b184a0909c | ||
|
|
57a61b3057 |
+9
-2
@@ -31,6 +31,8 @@
|
|||||||
"@angular/localize/init"
|
"@angular/localize/init"
|
||||||
],
|
],
|
||||||
"assets": [
|
"assets": [
|
||||||
|
"src/favicon.ico",
|
||||||
|
"src/apple-touch-icon.png",
|
||||||
"src/assets/images/cbsnexora-favicon.svg",
|
"src/assets/images/cbsnexora-favicon.svg",
|
||||||
"src/robots.txt",
|
"src/robots.txt",
|
||||||
"src/manifest.json",
|
"src/manifest.json",
|
||||||
@@ -39,6 +41,11 @@
|
|||||||
"glob": "**/*",
|
"glob": "**/*",
|
||||||
"input": "node_modules/tinymce",
|
"input": "node_modules/tinymce",
|
||||||
"output": "assets/tinymce"
|
"output": "assets/tinymce"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"glob": "lottie.min.js",
|
||||||
|
"input": "node_modules/lottie-web/build/player",
|
||||||
|
"output": "assets/js"
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
"styles": [
|
"styles": [
|
||||||
@@ -69,8 +76,8 @@
|
|||||||
"budgets": [
|
"budgets": [
|
||||||
{
|
{
|
||||||
"type": "initial",
|
"type": "initial",
|
||||||
"maximumWarning": "15mb",
|
"maximumWarning": "18mb",
|
||||||
"maximumError": "17mb"
|
"maximumError": "20mb"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"type": "anyComponentStyle",
|
"type": "anyComponentStyle",
|
||||||
|
|||||||
Generated
+7
@@ -41,6 +41,7 @@
|
|||||||
"jspdf-autotable": "^5.0.2",
|
"jspdf-autotable": "^5.0.2",
|
||||||
"lightgallery": "^2.9.0",
|
"lightgallery": "^2.9.0",
|
||||||
"lodash": "4.17.23",
|
"lodash": "4.17.23",
|
||||||
|
"lottie-web": "^5.13.0",
|
||||||
"moment": "^2.29.4",
|
"moment": "^2.29.4",
|
||||||
"ngx-image-cropper": "^9.1.6",
|
"ngx-image-cropper": "^9.1.6",
|
||||||
"ngx-mat-select-search": "^8.0.2",
|
"ngx-mat-select-search": "^8.0.2",
|
||||||
@@ -18380,6 +18381,12 @@
|
|||||||
"url": "https://github.com/chalk/wrap-ansi?sponsor=1"
|
"url": "https://github.com/chalk/wrap-ansi?sponsor=1"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/lottie-web": {
|
||||||
|
"version": "5.13.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/lottie-web/-/lottie-web-5.13.0.tgz",
|
||||||
|
"integrity": "sha512-+gfBXl6sxXMPe8tKQm7qzLnUy5DUPJPKIyRHwtpCpyUEYjHYRJC/5gjUvdkuO2c3JllrPtHXH5UJJK8LRYl5yQ==",
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
"node_modules/lru-cache": {
|
"node_modules/lru-cache": {
|
||||||
"version": "5.1.1",
|
"version": "5.1.1",
|
||||||
"resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-5.1.1.tgz",
|
"resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-5.1.1.tgz",
|
||||||
|
|||||||
+2
-1
@@ -15,7 +15,7 @@
|
|||||||
"ng": "ng",
|
"ng": "ng",
|
||||||
"build": "node version.js && npm run env -s && ng build --configuration production --output-hashing=none",
|
"build": "node version.js && npm run env -s && ng build --configuration production --output-hashing=none",
|
||||||
"build:prod": "node version.js && node --max-old-space-size=16384 ./node_modules/@angular/cli/bin/ng build --configuration production --output-hashing=none --base-href=/web-app/",
|
"build:prod": "node version.js && node --max-old-space-size=16384 ./node_modules/@angular/cli/bin/ng build --configuration production --output-hashing=none --base-href=/web-app/",
|
||||||
"start": "npm run env -s && cross-env NODE_OPTIONS=\"--max_old_space_size=16384\" ng serve --proxy-config proxy.localhost.conf.js",
|
"start": "npm run env -s && cross-env NODE_OPTIONS=\"--max_old_space_size=4096\" ng serve --proxy-config proxy.localhost.conf.js",
|
||||||
"start:share": "npm run env -s && cross-env NODE_OPTIONS=\"--max_old_space_size=16384\" ng serve --host 0.0.0.0 --proxy-config proxy.localhost.conf.js",
|
"start:share": "npm run env -s && cross-env NODE_OPTIONS=\"--max_old_space_size=16384\" ng serve --host 0.0.0.0 --proxy-config proxy.localhost.conf.js",
|
||||||
"start:demo": "npm run env -s && cross-env NODE_OPTIONS=\"--max_old_space_size=16384\" ng serve --proxy-config proxy.conf.js",
|
"start:demo": "npm run env -s && cross-env NODE_OPTIONS=\"--max_old_space_size=16384\" ng serve --proxy-config proxy.conf.js",
|
||||||
"serve:dev": "npm run env -s && cross-env NODE_OPTIONS=\"--max_old_space_size=16384\" ng serve --source-map --proxy-config proxy.localhost.conf.js",
|
"serve:dev": "npm run env -s && cross-env NODE_OPTIONS=\"--max_old_space_size=16384\" ng serve --source-map --proxy-config proxy.localhost.conf.js",
|
||||||
@@ -71,6 +71,7 @@
|
|||||||
"jspdf-autotable": "^5.0.2",
|
"jspdf-autotable": "^5.0.2",
|
||||||
"lightgallery": "^2.9.0",
|
"lightgallery": "^2.9.0",
|
||||||
"lodash": "4.17.23",
|
"lodash": "4.17.23",
|
||||||
|
"lottie-web": "^5.13.0",
|
||||||
"moment": "^2.29.4",
|
"moment": "^2.29.4",
|
||||||
"ngx-image-cropper": "^9.1.6",
|
"ngx-image-cropper": "^9.1.6",
|
||||||
"ngx-mat-select-search": "^8.0.2",
|
"ngx-mat-select-search": "^8.0.2",
|
||||||
|
|||||||
@@ -7,16 +7,6 @@
|
|||||||
-->
|
-->
|
||||||
|
|
||||||
<div class="container">
|
<div class="container">
|
||||||
<div class="container m-b-20 align-end gap-2px">
|
|
||||||
@if (!viewAccountTransferData.reversed) {
|
|
||||||
<span>
|
|
||||||
<button mat-raised-button color="warn" *mifosxHasPermission="'ADJUST_ACCOUNTTRANSFER'">
|
|
||||||
<fa-icon icon="undo" class="m-r-10"></fa-icon>{{ 'labels.buttons.Undo' | translate }}
|
|
||||||
</button>
|
|
||||||
</span>
|
|
||||||
}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<mat-card>
|
<mat-card>
|
||||||
<mat-card-content>
|
<mat-card-content>
|
||||||
<div class="transfer-container">
|
<div class="transfer-container">
|
||||||
|
|||||||
@@ -10,7 +10,8 @@
|
|||||||
|
|
||||||
/** Angular Imports */
|
/** Angular Imports */
|
||||||
import { Injectable, inject } from '@angular/core';
|
import { Injectable, inject } from '@angular/core';
|
||||||
import { HttpClient, HttpParams } from '@angular/common/http';
|
import { HttpClient, HttpContext, HttpParams } from '@angular/common/http';
|
||||||
|
import { SILENT_HTTP_ERROR } from 'app/core/http/silent-error.token';
|
||||||
|
|
||||||
/** rxjs Imports */
|
/** rxjs Imports */
|
||||||
import { Observable } from 'rxjs';
|
import { Observable } from 'rxjs';
|
||||||
@@ -198,6 +199,16 @@ export class AccountingService {
|
|||||||
return this.http.get('/journalentries/ledger', { params: httpParams });
|
return this.http.get('/journalentries/ledger', { params: httpParams });
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The whole transfer a ledger transaction belongs to, when it is one branch's part of a transfer between branches.
|
||||||
|
* An extra, informational lookup: if it fails the screen simply shows no banner, so a failure raises no error popup.
|
||||||
|
*/
|
||||||
|
getInterBranchTransfer(transactionId: string): Observable<any> {
|
||||||
|
return this.http.get(`/accounting/interbranchtransfers/${transactionId}`, {
|
||||||
|
context: new HttpContext().set(SILENT_HTTP_ERROR, true)
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* @param {string} transactionId Transaction ID to be reverted.
|
* @param {string} transactionId Transaction ID to be reverted.
|
||||||
* @param {string} comments Any comments.
|
* @param {string} comments Any comments.
|
||||||
|
|||||||
+6
-15
@@ -16,6 +16,7 @@ import { AccountingService } from '../../accounting.service';
|
|||||||
import { GLAccount } from 'app/shared/models/general.model';
|
import { GLAccount } from 'app/shared/models/general.model';
|
||||||
import { GlAccountSelectorComponent } from '../../../shared/accounting/gl-account-selector/gl-account-selector.component';
|
import { GlAccountSelectorComponent } from '../../../shared/accounting/gl-account-selector/gl-account-selector.component';
|
||||||
import { STANDALONE_SHARED_IMPORTS } from 'app/standalone-shared.module';
|
import { STANDALONE_SHARED_IMPORTS } from 'app/standalone-shared.module';
|
||||||
|
import { accountOptionsForActivity } from '../financial-activity-account-options';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Create financial activity mapping component.
|
* Create financial activity mapping component.
|
||||||
@@ -87,21 +88,11 @@ export class CreateFinancialActivityMappingComponent implements OnInit {
|
|||||||
*/
|
*/
|
||||||
setGlAccountData() {
|
setGlAccountData() {
|
||||||
this.financialActivityMappingForm.get('financialActivityId').valueChanges.subscribe((financialActivityId) => {
|
this.financialActivityMappingForm.get('financialActivityId').valueChanges.subscribe((financialActivityId) => {
|
||||||
switch (financialActivityId) {
|
this.glAccountData = accountOptionsForActivity(
|
||||||
case 100:
|
this.financialActivityData,
|
||||||
case 101:
|
financialActivityId,
|
||||||
case 102:
|
this.glAccountOptions
|
||||||
case 103:
|
);
|
||||||
this.glAccountData = this.glAccountOptions.assetAccountOptions;
|
|
||||||
break;
|
|
||||||
case 200:
|
|
||||||
case 201:
|
|
||||||
this.glAccountData = this.glAccountOptions.liabilityAccountOptions;
|
|
||||||
break;
|
|
||||||
case 300:
|
|
||||||
this.glAccountData = this.glAccountOptions.equityAccountOptions;
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
+6
-15
@@ -16,6 +16,7 @@ import { AccountingService } from '../../accounting.service';
|
|||||||
import { GLAccount } from 'app/shared/models/general.model';
|
import { GLAccount } from 'app/shared/models/general.model';
|
||||||
import { GlAccountSelectorComponent } from '../../../shared/accounting/gl-account-selector/gl-account-selector.component';
|
import { GlAccountSelectorComponent } from '../../../shared/accounting/gl-account-selector/gl-account-selector.component';
|
||||||
import { STANDALONE_SHARED_IMPORTS } from 'app/standalone-shared.module';
|
import { STANDALONE_SHARED_IMPORTS } from 'app/standalone-shared.module';
|
||||||
|
import { accountOptionsForActivity } from '../financial-activity-account-options';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Edit financial activity mapping component.
|
* Edit financial activity mapping component.
|
||||||
@@ -98,21 +99,11 @@ export class EditFinancialActivityMappingComponent implements OnInit {
|
|||||||
*/
|
*/
|
||||||
setGLAccountData() {
|
setGLAccountData() {
|
||||||
this.financialActivityMappingForm.get('financialActivityId').valueChanges.subscribe((financialActivityId) => {
|
this.financialActivityMappingForm.get('financialActivityId').valueChanges.subscribe((financialActivityId) => {
|
||||||
switch (financialActivityId) {
|
this.glAccountData = accountOptionsForActivity(
|
||||||
case 100:
|
this.financialActivityData,
|
||||||
case 101:
|
financialActivityId,
|
||||||
case 102:
|
this.glAccountOptions
|
||||||
case 103:
|
);
|
||||||
this.glAccountData = this.glAccountOptions.assetAccountOptions;
|
|
||||||
break;
|
|
||||||
case 200:
|
|
||||||
case 201:
|
|
||||||
this.glAccountData = this.glAccountOptions.liabilityAccountOptions;
|
|
||||||
break;
|
|
||||||
case 300:
|
|
||||||
this.glAccountData = this.glAccountOptions.equityAccountOptions;
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,38 @@
|
|||||||
|
/**
|
||||||
|
* Copyright since 2025 Mifos Initiative
|
||||||
|
*
|
||||||
|
* This Source Code Form is subject to the terms of the Mozilla Public
|
||||||
|
* License, v. 2.0. If a copy of the MPL was not distributed with this
|
||||||
|
* file, You can obtain one at http://mozilla.org/MPL/2.0/.
|
||||||
|
*/
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The GL accounts a financial activity may be mapped to.
|
||||||
|
*
|
||||||
|
* The activity itself says which kind of account it needs — the template returns `mappedGLAccountType` on every
|
||||||
|
* option — so the answer is read from the activity rather than worked out from its id. The screens used to switch on
|
||||||
|
* the id instead, listing the ones they knew:
|
||||||
|
*
|
||||||
|
* case 100, 101, 102, 103 → asset
|
||||||
|
* case 200, 201 → liability
|
||||||
|
* case 300 → equity
|
||||||
|
*
|
||||||
|
* Every activity added since fell through that switch and left the account dropdown empty, which is how dividendPaid
|
||||||
|
* (202), reserveForDividend (301), distributableProfit (302), retainedProfit (303) and dividendRoundOff (304) became
|
||||||
|
* unmappable — the screen offered no accounts and gave no reason. A list keyed on ids has to be extended by hand for
|
||||||
|
* each new activity, and is silently wrong until someone notices; asking the activity what it needs cannot fall out
|
||||||
|
* of step with the backend.
|
||||||
|
*
|
||||||
|
* @param activities the template's financialActivityOptions
|
||||||
|
* @param activityId the activity the user picked
|
||||||
|
* @param glAccountOptions the template's glAccountOptions, keyed assetAccountOptions / liabilityAccountOptions / …
|
||||||
|
*/
|
||||||
|
export function accountOptionsForActivity(activities: any[], activityId: number, glAccountOptions: any): any[] {
|
||||||
|
const activity = (activities || []).find((a) => a?.id === activityId);
|
||||||
|
const accountType = activity?.mappedGLAccountType;
|
||||||
|
if (!accountType || !glAccountOptions) {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
// ASSET → assetAccountOptions, and so on for LIABILITY, EQUITY, INCOME and EXPENSE.
|
||||||
|
return glAccountOptions[`${accountType.toLowerCase()}AccountOptions`] ?? [];
|
||||||
|
}
|
||||||
@@ -279,7 +279,7 @@
|
|||||||
<div class="gl-fgroup">
|
<div class="gl-fgroup">
|
||||||
<label class="gl-flabel"><fa-icon icon="calendar-alt"></fa-icon> From Date</label>
|
<label class="gl-flabel"><fa-icon icon="calendar-alt"></fa-icon> From Date</label>
|
||||||
<mat-form-field appearance="outline" class="gl-ffield">
|
<mat-form-field appearance="outline" class="gl-ffield">
|
||||||
<input matInput [matDatepicker]="dpFrom" [min]="minDate" [max]="maxDate" [formControl]="transactionDateFrom" />
|
<input matInput [matDatepicker]="dpFrom" [min]="minDate" [formControl]="transactionDateFrom" />
|
||||||
<mat-datepicker-toggle matSuffix [for]="dpFrom"></mat-datepicker-toggle>
|
<mat-datepicker-toggle matSuffix [for]="dpFrom"></mat-datepicker-toggle>
|
||||||
<mat-datepicker #dpFrom></mat-datepicker>
|
<mat-datepicker #dpFrom></mat-datepicker>
|
||||||
</mat-form-field>
|
</mat-form-field>
|
||||||
@@ -288,7 +288,7 @@
|
|||||||
<div class="gl-fgroup">
|
<div class="gl-fgroup">
|
||||||
<label class="gl-flabel"><fa-icon icon="calendar-alt"></fa-icon> To Date</label>
|
<label class="gl-flabel"><fa-icon icon="calendar-alt"></fa-icon> To Date</label>
|
||||||
<mat-form-field appearance="outline" class="gl-ffield">
|
<mat-form-field appearance="outline" class="gl-ffield">
|
||||||
<input matInput [matDatepicker]="dpTo" [min]="minDate" [max]="maxDate" [formControl]="transactionDateTo" />
|
<input matInput [matDatepicker]="dpTo" [min]="minDate" [formControl]="transactionDateTo" />
|
||||||
<mat-datepicker-toggle matSuffix [for]="dpTo"></mat-datepicker-toggle>
|
<mat-datepicker-toggle matSuffix [for]="dpTo"></mat-datepicker-toggle>
|
||||||
<mat-datepicker #dpTo></mat-datepicker>
|
<mat-datepicker #dpTo></mat-datepicker>
|
||||||
</mat-form-field>
|
</mat-form-field>
|
||||||
@@ -353,13 +353,7 @@
|
|||||||
<div class="gl-fgroup">
|
<div class="gl-fgroup">
|
||||||
<label class="gl-flabel"><fa-icon icon="calendar-check"></fa-icon> Submitted From</label>
|
<label class="gl-flabel"><fa-icon icon="calendar-check"></fa-icon> Submitted From</label>
|
||||||
<mat-form-field appearance="outline" class="gl-ffield">
|
<mat-form-field appearance="outline" class="gl-ffield">
|
||||||
<input
|
<input matInput [matDatepicker]="dpSubFrom" [min]="minDate" [formControl]="submittedOnDateFrom" />
|
||||||
matInput
|
|
||||||
[matDatepicker]="dpSubFrom"
|
|
||||||
[min]="minDate"
|
|
||||||
[max]="maxDate"
|
|
||||||
[formControl]="submittedOnDateFrom"
|
|
||||||
/>
|
|
||||||
<mat-datepicker-toggle matSuffix [for]="dpSubFrom"></mat-datepicker-toggle>
|
<mat-datepicker-toggle matSuffix [for]="dpSubFrom"></mat-datepicker-toggle>
|
||||||
<mat-datepicker #dpSubFrom></mat-datepicker>
|
<mat-datepicker #dpSubFrom></mat-datepicker>
|
||||||
</mat-form-field>
|
</mat-form-field>
|
||||||
@@ -368,7 +362,7 @@
|
|||||||
<div class="gl-fgroup">
|
<div class="gl-fgroup">
|
||||||
<label class="gl-flabel"><fa-icon icon="calendar-check"></fa-icon> Submitted To</label>
|
<label class="gl-flabel"><fa-icon icon="calendar-check"></fa-icon> Submitted To</label>
|
||||||
<mat-form-field appearance="outline" class="gl-ffield">
|
<mat-form-field appearance="outline" class="gl-ffield">
|
||||||
<input matInput [matDatepicker]="dpSubTo" [min]="minDate" [max]="maxDate" [formControl]="submittedOnDateTo" />
|
<input matInput [matDatepicker]="dpSubTo" [min]="minDate" [formControl]="submittedOnDateTo" />
|
||||||
<mat-datepicker-toggle matSuffix [for]="dpSubTo"></mat-datepicker-toggle>
|
<mat-datepicker-toggle matSuffix [for]="dpSubTo"></mat-datepicker-toggle>
|
||||||
<mat-datepicker #dpSubTo></mat-datepicker>
|
<mat-datepicker #dpSubTo></mat-datepicker>
|
||||||
</mat-form-field>
|
</mat-form-field>
|
||||||
@@ -539,7 +533,7 @@
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="gl-table-shell">
|
<div class="gl-table-shell">
|
||||||
<mat-table [dataSource]="dataSource" matSort class="gl-mat-table">
|
<mat-table [dataSource]="dataSource" matSort matSortActive="id" matSortDirection="desc" class="gl-mat-table">
|
||||||
<!-- #ID -->
|
<!-- #ID -->
|
||||||
<ng-container matColumnDef="id">
|
<ng-container matColumnDef="id">
|
||||||
<mat-header-cell *matHeaderCellDef mat-sort-header class="gl-th">#</mat-header-cell>
|
<mat-header-cell *matHeaderCellDef mat-sort-header class="gl-th">#</mat-header-cell>
|
||||||
|
|||||||
@@ -90,7 +90,7 @@ export class SearchJournalEntryComponent implements OnInit, OnDestroy, AfterView
|
|||||||
|
|
||||||
// ── Misc ──────────────────────────────────────────────────────────────────
|
// ── Misc ──────────────────────────────────────────────────────────────────
|
||||||
minDate = new Date(2000, 0, 1);
|
minDate = new Date(2000, 0, 1);
|
||||||
maxDate = new Date();
|
maxDate: Date = null;
|
||||||
/** Accounting features switched off for this installation — see disabled-features.ts. */
|
/** Accounting features switched off for this installation — see disabled-features.ts. */
|
||||||
disabledFeatures = DISABLED_ACCOUNTING_FEATURES;
|
disabledFeatures = DISABLED_ACCOUNTING_FEATURES;
|
||||||
|
|
||||||
@@ -208,7 +208,7 @@ export class SearchJournalEntryComponent implements OnInit, OnDestroy, AfterView
|
|||||||
}
|
}
|
||||||
|
|
||||||
ngOnInit() {
|
ngOnInit() {
|
||||||
this.maxDate = this.settingsService.businessDate;
|
this.maxDate = null;
|
||||||
this.paginator.pageSize = 10;
|
this.paginator.pageSize = 10;
|
||||||
this.restoreState();
|
this.restoreState();
|
||||||
this.setFilteredOffices();
|
this.setFilteredOffices();
|
||||||
|
|||||||
@@ -11,6 +11,14 @@ import { Routes, RouterModule } from '@angular/router';
|
|||||||
|
|
||||||
import { Route } from '../core/route/route.service';
|
import { Route } from '../core/route/route.service';
|
||||||
import { AccountsListComponent } from './accounts-list/accounts-list.component';
|
import { AccountsListComponent } from './accounts-list/accounts-list.component';
|
||||||
|
import { PartyMasterComponent } from '../deposits/party-master/party-master.component';
|
||||||
|
import { CreatePartyComponent } from '../deposits/party-master/create-party/create-party.component';
|
||||||
|
import { DueForClosureComponent } from '../deposits/party-master/due-for-closure/due-for-closure.component';
|
||||||
|
import { DailyTransactionEntryComponent } from '../deposits/daily-transaction-entry/daily-transaction-entry.component';
|
||||||
|
import { DdsReportsComponent } from '../deposits/dds-reports/dds-reports.component';
|
||||||
|
import { ReportViewerComponent } from '../deposits/dds-reports/report-viewer/report-viewer.component';
|
||||||
|
import { DdsAccountsComponent } from '../deposits/dds-accounts/dds-accounts.component';
|
||||||
|
import { DdsAccountViewComponent } from '../deposits/dds-accounts/dds-account-view/dds-account-view.component';
|
||||||
|
|
||||||
const routes: Routes = [
|
const routes: Routes = [
|
||||||
Route.withShell([
|
Route.withShell([
|
||||||
@@ -41,6 +49,59 @@ const routes: Routes = [
|
|||||||
title: 'Recurring Deposit Accounts',
|
title: 'Recurring Deposit Accounts',
|
||||||
breadcrumb: 'Recurring Deposit Accounts'
|
breadcrumb: 'Recurring Deposit Accounts'
|
||||||
}
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
path: 'dds',
|
||||||
|
data: { title: 'Daily Deposit Scheme — Party Master', breadcrumb: 'Party Master' },
|
||||||
|
children: [
|
||||||
|
{ path: '', component: PartyMasterComponent },
|
||||||
|
{
|
||||||
|
path: 'create',
|
||||||
|
component: CreatePartyComponent,
|
||||||
|
data: { title: 'Create Party', breadcrumb: 'Create' }
|
||||||
|
},
|
||||||
|
{
|
||||||
|
// Ahead of the :partyId routes, so "dueforclosure" is not read as a party id.
|
||||||
|
path: 'dueforclosure',
|
||||||
|
component: DueForClosureComponent,
|
||||||
|
data: { title: 'Accounts Due for Closure', breadcrumb: 'Due for Closure' }
|
||||||
|
},
|
||||||
|
{
|
||||||
|
path: ':partyId/edit',
|
||||||
|
component: CreatePartyComponent,
|
||||||
|
data: { title: 'Edit Party', breadcrumb: 'Edit' }
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
// The DDS accounts listed like Savings Accounts, each opening onto its transactions.
|
||||||
|
path: 'dds-accounts',
|
||||||
|
data: { title: 'DDS Accounts', breadcrumb: 'DDS Accounts' },
|
||||||
|
children: [
|
||||||
|
{ path: '', component: DdsAccountsComponent },
|
||||||
|
{
|
||||||
|
path: ':partyId',
|
||||||
|
component: DdsAccountViewComponent,
|
||||||
|
data: { title: 'DDS Account', breadcrumb: 'Account', routeParamBreadcrumb: false }
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
path: 'dds-collection',
|
||||||
|
component: DailyTransactionEntryComponent,
|
||||||
|
data: { title: 'Daily Transaction Entry', breadcrumb: 'Daily Transaction Entry' }
|
||||||
|
},
|
||||||
|
{
|
||||||
|
path: 'dds-reports',
|
||||||
|
data: { title: 'DDS Reports', breadcrumb: 'DDS Reports' },
|
||||||
|
children: [
|
||||||
|
{ path: '', component: DdsReportsComponent },
|
||||||
|
{
|
||||||
|
path: 'view/:reportName',
|
||||||
|
component: ReportViewerComponent,
|
||||||
|
data: { title: 'DDS Report', breadcrumb: 'reportName', routeParamBreadcrumb: 'reportName' }
|
||||||
|
}
|
||||||
|
]
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -74,6 +74,22 @@ export class AccountsService {
|
|||||||
return this.http.get('/fixeddepositaccounts', { params: httpParams });
|
return this.http.get('/fixeddepositaccounts', { params: httpParams });
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* FD Dashboard's data source: every ACTIVE FD, id/account/client/product identity, maturity date
|
||||||
|
* and deposit/maturity amounts only. Deliberately a separate, narrow endpoint rather than
|
||||||
|
* getFixedDepositAccounts() above -- that one's backend query joins in six more tables (audit
|
||||||
|
* who-did-what-when, currency, tax group) an unbounded dashboard fetch does not need, over columns
|
||||||
|
* that had no index until this feature added one (see backend migration 0571).
|
||||||
|
*/
|
||||||
|
getFixedDepositMaturityDashboard(): Observable<any> {
|
||||||
|
return this.http.get('/fixeddepositaccounts/maturity-dashboard');
|
||||||
|
}
|
||||||
|
|
||||||
|
/** RD Dashboard: running RDs with their installment position, closures and penalty income for the month. */
|
||||||
|
getRecurringDepositDashboard(): Observable<any> {
|
||||||
|
return this.http.get('/recurringdepositaccounts/dashboard');
|
||||||
|
}
|
||||||
|
|
||||||
getRecurringDepositAccounts(params: AccountListParams = {}): Observable<any> {
|
getRecurringDepositAccounts(params: AccountListParams = {}): Observable<any> {
|
||||||
let httpParams = new HttpParams()
|
let httpParams = new HttpParams()
|
||||||
.set('paged', 'true')
|
.set('paged', 'true')
|
||||||
|
|||||||
@@ -142,6 +142,22 @@ export class AmlService {
|
|||||||
return this.http.get<any[]>('/aml/ctr-reports/lines', { params });
|
return this.http.get<any[]>('/aml/ctr-reports/lines', { params });
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Cash transactions of the threshold or more that no generated report contains yet, shaped like the lines above.
|
||||||
|
* They show on the FIU screen as soon as they are posted; generating the month is what files them.
|
||||||
|
*/
|
||||||
|
getCtrPendingLines(): Observable<any[]> {
|
||||||
|
return this.http.get<any[]>('/aml/ctr-reports/pending-lines');
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* How much came into and went out of each current / Cash Credit account over the last `days` days, with the
|
||||||
|
* customer's declared turnover and the sanctioned limit alongside.
|
||||||
|
*/
|
||||||
|
getCcTracking(days = 30): Observable<any> {
|
||||||
|
return this.http.get<any>('/aml/cc-tracking', { params: new HttpParams().set('days', String(days)) });
|
||||||
|
}
|
||||||
|
|
||||||
/** month format: yyyy-MM */
|
/** month format: yyyy-MM */
|
||||||
generateCtrReport(month: string): Observable<any> {
|
generateCtrReport(month: string): Observable<any> {
|
||||||
return this.http.post('/aml/ctr-reports/generate', { month });
|
return this.http.post('/aml/ctr-reports/generate', { month });
|
||||||
|
|||||||
@@ -63,6 +63,8 @@ import { CfcfrmsModule } from './cfcfrms/cfcfrms.module';
|
|||||||
import { BankCalendarModule } from './bank-calendar/bank-calendar.module';
|
import { BankCalendarModule } from './bank-calendar/bank-calendar.module';
|
||||||
import { DeafModule } from './deaf/deaf.module';
|
import { DeafModule } from './deaf/deaf.module';
|
||||||
import { SavingsDailyInterestModule } from './savings-daily-interest/savings-daily-interest.module';
|
import { SavingsDailyInterestModule } from './savings-daily-interest/savings-daily-interest.module';
|
||||||
|
import { FdDashboardModule } from './fd-dashboard/fd-dashboard.module';
|
||||||
|
import { RdDashboardModule } from './rd-dashboard/rd-dashboard.module';
|
||||||
import { DicgcModule } from './dicgc/dicgc.module';
|
import { DicgcModule } from './dicgc/dicgc.module';
|
||||||
import { FiuModule } from './fiu/fiu.module';
|
import { FiuModule } from './fiu/fiu.module';
|
||||||
import { GlReportsModule } from './gl-reports/gl-reports.module';
|
import { GlReportsModule } from './gl-reports/gl-reports.module';
|
||||||
@@ -95,6 +97,7 @@ import { TokenInterceptor as ZitadelTokenInterceptor } from './zitadel/token.int
|
|||||||
import { AuthService } from './zitadel/auth.service';
|
import { AuthService } from './zitadel/auth.service';
|
||||||
import { environment } from '../environments/environment';
|
import { environment } from '../environments/environment';
|
||||||
import { CallbackComponent } from './zitadel/callback/callback.component';
|
import { CallbackComponent } from './zitadel/callback/callback.component';
|
||||||
|
import { LoadingScreenComponent } from './shared/loading-screen/loading-screen.component';
|
||||||
import { OAuthModule } from 'angular-oauth2-oidc';
|
import { OAuthModule } from 'angular-oauth2-oidc';
|
||||||
|
|
||||||
export class CustomMissingTranslationHandler implements MissingTranslationHandler {
|
export class CustomMissingTranslationHandler implements MissingTranslationHandler {
|
||||||
@@ -176,6 +179,8 @@ export function HttpLoaderFactory(http: HttpClient) {
|
|||||||
BankCalendarModule,
|
BankCalendarModule,
|
||||||
DeafModule,
|
DeafModule,
|
||||||
SavingsDailyInterestModule,
|
SavingsDailyInterestModule,
|
||||||
|
FdDashboardModule,
|
||||||
|
RdDashboardModule,
|
||||||
DicgcModule,
|
DicgcModule,
|
||||||
FiuModule,
|
FiuModule,
|
||||||
GlReportsModule,
|
GlReportsModule,
|
||||||
@@ -193,6 +198,7 @@ export function HttpLoaderFactory(http: HttpClient) {
|
|||||||
AppRoutingModule,
|
AppRoutingModule,
|
||||||
NotFoundComponent,
|
NotFoundComponent,
|
||||||
CallbackComponent,
|
CallbackComponent,
|
||||||
|
LoadingScreenComponent,
|
||||||
OAuthModule.forRoot()
|
OAuthModule.forRoot()
|
||||||
],
|
],
|
||||||
providers: [
|
providers: [
|
||||||
|
|||||||
@@ -44,8 +44,11 @@
|
|||||||
<div class="cal-stat sat2nd4th">
|
<div class="cal-stat sat2nd4th">
|
||||||
<fa-icon icon="calendar-times" class="cal-stat__icon"></fa-icon>
|
<fa-icon icon="calendar-times" class="cal-stat__icon"></fa-icon>
|
||||||
<div class="cal-stat__info">
|
<div class="cal-stat__info">
|
||||||
<div class="cal-stat__value">{{ overallStats.sat2nd4th }}</div>
|
<div class="cal-stat__value">{{ overallStats.sat2nd4th + overallStats.weeklyOff }}</div>
|
||||||
<div class="cal-stat__label">2nd & 4th Sat</div>
|
<div class="cal-stat__label">
|
||||||
|
{{ saturdayIsWorkingDay ? '2nd & 4th Sat' : 'Saturdays'
|
||||||
|
}}{{ overallStats.weeklyOff > 0 ? ' / Weekly Off' : '' }}
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="cal-stat holidays">
|
<div class="cal-stat holidays">
|
||||||
@@ -69,7 +72,11 @@
|
|||||||
<div class="cal-legend__items">
|
<div class="cal-legend__items">
|
||||||
<div class="cal-legend__item"><span class="cal-legend__dot working"></span> Working Day</div>
|
<div class="cal-legend__item"><span class="cal-legend__dot working"></span> Working Day</div>
|
||||||
<div class="cal-legend__item"><span class="cal-legend__dot sunday"></span> Sunday</div>
|
<div class="cal-legend__item"><span class="cal-legend__dot sunday"></span> Sunday</div>
|
||||||
<div class="cal-legend__item"><span class="cal-legend__dot sat2nd4th"></span> 2nd / 4th Saturday</div>
|
<div class="cal-legend__item">
|
||||||
|
<span class="cal-legend__dot sat2nd4th"></span>
|
||||||
|
{{ saturdayIsWorkingDay ? '2nd / 4th Saturday' : 'Saturday'
|
||||||
|
}}{{ overallStats.weeklyOff > 0 ? ' / Weekly Off' : '' }}
|
||||||
|
</div>
|
||||||
<div class="cal-legend__item"><span class="cal-legend__dot holiday"></span> Holiday</div>
|
<div class="cal-legend__item"><span class="cal-legend__dot holiday"></span> Holiday</div>
|
||||||
<div class="cal-legend__item"><span class="cal-legend__dot today"></span> Today</div>
|
<div class="cal-legend__item"><span class="cal-legend__dot today"></span> Today</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -319,7 +326,9 @@
|
|||||||
[class.cal-grid__day--other]="!day.isCurrentMonth"
|
[class.cal-grid__day--other]="!day.isCurrentMonth"
|
||||||
[class.cal-grid__day--today]="day.isToday"
|
[class.cal-grid__day--today]="day.isToday"
|
||||||
[class.cal-grid__day--sunday]="day.isSunday && day.isCurrentMonth"
|
[class.cal-grid__day--sunday]="day.isSunday && day.isCurrentMonth"
|
||||||
[class.cal-grid__day--sat2nd4th]="(day.is2ndSaturday || day.is4thSaturday) && day.isCurrentMonth"
|
[class.cal-grid__day--sat2nd4th]="
|
||||||
|
(day.is2ndSaturday || day.is4thSaturday || day.isWeeklyOff) && day.isCurrentMonth
|
||||||
|
"
|
||||||
[class.cal-grid__day--holiday]="day.isHoliday && day.isCurrentMonth"
|
[class.cal-grid__day--holiday]="day.isHoliday && day.isCurrentMonth"
|
||||||
[class.cal-grid__day--working]="day.isWorking && day.isCurrentMonth"
|
[class.cal-grid__day--working]="day.isWorking && day.isCurrentMonth"
|
||||||
[matTooltip]="
|
[matTooltip]="
|
||||||
@@ -328,6 +337,8 @@
|
|||||||
? '2nd Saturday Off'
|
? '2nd Saturday Off'
|
||||||
: day.is4thSaturday
|
: day.is4thSaturday
|
||||||
? '4th Saturday Off'
|
? '4th Saturday Off'
|
||||||
|
: day.isWeeklyOff && day.isCurrentMonth
|
||||||
|
? weekDays[day.dayOfWeek] + ' Off (Working Days)'
|
||||||
: day.isSunday && day.isCurrentMonth
|
: day.isSunday && day.isCurrentMonth
|
||||||
? 'Sunday'
|
? 'Sunday'
|
||||||
: '')
|
: '')
|
||||||
@@ -343,10 +354,15 @@
|
|||||||
@if (month.holidays > 0) {
|
@if (month.holidays > 0) {
|
||||||
<span class="summary-tag holiday">{{ month.holidays }} Holiday{{ month.holidays > 1 ? 's' : '' }}</span>
|
<span class="summary-tag holiday">{{ month.holidays }} Holiday{{ month.holidays > 1 ? 's' : '' }}</span>
|
||||||
}
|
}
|
||||||
|
@if (month.sundays > 0) {
|
||||||
<span class="summary-tag sun">{{ month.sundays }} Sun</span>
|
<span class="summary-tag sun">{{ month.sundays }} Sun</span>
|
||||||
|
}
|
||||||
@if (month.secondFourthSat > 0) {
|
@if (month.secondFourthSat > 0) {
|
||||||
<span class="summary-tag sat">{{ month.secondFourthSat }} Sat Off</span>
|
<span class="summary-tag sat">{{ month.secondFourthSat }} Sat Off</span>
|
||||||
}
|
}
|
||||||
|
@if (month.weeklyOff > 0) {
|
||||||
|
<span class="summary-tag sat">{{ month.weeklyOff }} Weekly Off</span>
|
||||||
|
}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -63,6 +63,8 @@ interface CalendarDay {
|
|||||||
isSunday: boolean;
|
isSunday: boolean;
|
||||||
is2ndSaturday: boolean;
|
is2ndSaturday: boolean;
|
||||||
is4thSaturday: boolean;
|
is4thSaturday: boolean;
|
||||||
|
/** Off every week because Admin > Working Days leaves this weekday out (Sunday aside). */
|
||||||
|
isWeeklyOff: boolean;
|
||||||
holidayName?: string;
|
holidayName?: string;
|
||||||
dayOfWeek: number; // 0=Sun, 6=Sat
|
dayOfWeek: number; // 0=Sun, 6=Sat
|
||||||
}
|
}
|
||||||
@@ -76,9 +78,23 @@ interface MonthData {
|
|||||||
nonWorkingDays: number;
|
nonWorkingDays: number;
|
||||||
holidays: number;
|
holidays: number;
|
||||||
sundays: number;
|
sundays: number;
|
||||||
|
/** Saturdays off: the 2nd and 4th when Saturday is a working day, every one when it is not. */
|
||||||
secondFourthSat: number;
|
secondFourthSat: number;
|
||||||
|
/** Other weekdays off every week because Working Days leaves them out. */
|
||||||
|
weeklyOff: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Weekday codes in Date#getDay() order, as used by the /workingdays recurrence (BYDAY=MO,TU,...). */
|
||||||
|
const DAY_CODES = [
|
||||||
|
'SU',
|
||||||
|
'MO',
|
||||||
|
'TU',
|
||||||
|
'WE',
|
||||||
|
'TH',
|
||||||
|
'FR',
|
||||||
|
'SA'
|
||||||
|
];
|
||||||
|
|
||||||
@Component({
|
@Component({
|
||||||
selector: 'mifosx-bank-calendar',
|
selector: 'mifosx-bank-calendar',
|
||||||
templateUrl: './bank-calendar.component.html',
|
templateUrl: './bank-calendar.component.html',
|
||||||
@@ -118,7 +134,7 @@ export class BankCalendarComponent implements OnInit {
|
|||||||
];
|
];
|
||||||
workingDaysConfig: string[] = [];
|
workingDaysConfig: string[] = [];
|
||||||
holidays: any[] = [];
|
holidays: any[] = [];
|
||||||
overallStats = { working: 0, nonWorking: 0, holidays: 0, sundays: 0, sat2nd4th: 0, totalDays: 0 };
|
overallStats = { working: 0, nonWorking: 0, holidays: 0, sundays: 0, sat2nd4th: 0, weeklyOff: 0, totalDays: 0 };
|
||||||
|
|
||||||
// ───── Holiday list (office-wise) ─────
|
// ───── Holiday list (office-wise) ─────
|
||||||
offices: OfficeLite[] = [];
|
offices: OfficeLite[] = [];
|
||||||
@@ -202,7 +218,8 @@ export class BankCalendarComponent implements OnInit {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private loadWorkingDays() {
|
private loadWorkingDays() {
|
||||||
// Default Indian banking working days: Mon-Sat (Sun off, 2nd & 4th Sat off)
|
// Default Indian banking working days: Mon-Sat (Sun off, 2nd & 4th Sat off), used until
|
||||||
|
// Admin > Working Days answers, or if it cannot be read.
|
||||||
this.workingDaysConfig = [
|
this.workingDaysConfig = [
|
||||||
'MO',
|
'MO',
|
||||||
'TU',
|
'TU',
|
||||||
@@ -215,6 +232,19 @@ export class BankCalendarComponent implements OnInit {
|
|||||||
this.buildCalendar();
|
this.buildCalendar();
|
||||||
this.rebuildHolidayRows();
|
this.rebuildHolidayRows();
|
||||||
|
|
||||||
|
// The calendar has to agree with Admin > Working Days: a weekday unticked there is off every
|
||||||
|
// week here too.
|
||||||
|
this.organizationService.getWorkingDays().subscribe({
|
||||||
|
next: (data: any) => {
|
||||||
|
const configured = this.parseWorkingDays(data?.recurrence);
|
||||||
|
if (configured.length > 0) {
|
||||||
|
this.workingDaysConfig = configured;
|
||||||
|
this.buildCalendar();
|
||||||
|
}
|
||||||
|
},
|
||||||
|
error: () => {}
|
||||||
|
});
|
||||||
|
|
||||||
// Pull offices then fan-out a per-office holiday fetch so each office's
|
// Pull offices then fan-out a per-office holiday fetch so each office's
|
||||||
// calendar entries are surfaced in the list panel (calendar grid still
|
// calendar entries are surfaced in the list panel (calendar grid still
|
||||||
// unions everything for the year-at-a-glance view).
|
// unions everything for the year-at-a-glance view).
|
||||||
@@ -273,6 +303,23 @@ export class BankCalendarComponent implements OnInit {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Weekday codes out of a recurrence such as "FREQ=WEEKLY;INTERVAL=1;BYDAY=MO,TU,WE,TH,FR". */
|
||||||
|
private parseWorkingDays(recurrence: string | undefined): string[] {
|
||||||
|
const byDay = /BYDAY=([^;]*)/.exec(recurrence || '');
|
||||||
|
if (!byDay) {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
return byDay[1]
|
||||||
|
.split(',')
|
||||||
|
.map((d) => d.trim().toUpperCase())
|
||||||
|
.filter((d) => DAY_CODES.includes(d));
|
||||||
|
}
|
||||||
|
|
||||||
|
/** True when Admin > Working Days counts Saturday as a working day (so only the 2nd and 4th are off). */
|
||||||
|
get saturdayIsWorkingDay(): boolean {
|
||||||
|
return this.workingDaysConfig.includes('SA');
|
||||||
|
}
|
||||||
|
|
||||||
/** Flatten the holidays array into a presentable list with office labels. */
|
/** Flatten the holidays array into a presentable list with office labels. */
|
||||||
private rebuildHolidayRows(): void {
|
private rebuildHolidayRows(): void {
|
||||||
this.holidayRows = this.holidays.map((h: any) => {
|
this.holidayRows = this.holidays.map((h: any) => {
|
||||||
@@ -511,7 +558,15 @@ export class BankCalendarComponent implements OnInit {
|
|||||||
|
|
||||||
buildCalendar() {
|
buildCalendar() {
|
||||||
this.months = [];
|
this.months = [];
|
||||||
this.overallStats = { working: 0, nonWorking: 0, holidays: 0, sundays: 0, sat2nd4th: 0, totalDays: 0 };
|
this.overallStats = {
|
||||||
|
working: 0,
|
||||||
|
nonWorking: 0,
|
||||||
|
holidays: 0,
|
||||||
|
sundays: 0,
|
||||||
|
sat2nd4th: 0,
|
||||||
|
weeklyOff: 0,
|
||||||
|
totalDays: 0
|
||||||
|
};
|
||||||
|
|
||||||
for (let m = 0; m < 12; m++) {
|
for (let m = 0; m < 12; m++) {
|
||||||
const monthData = this.buildMonth(m, this.currentYear);
|
const monthData = this.buildMonth(m, this.currentYear);
|
||||||
@@ -521,6 +576,7 @@ export class BankCalendarComponent implements OnInit {
|
|||||||
this.overallStats.holidays += monthData.holidays;
|
this.overallStats.holidays += monthData.holidays;
|
||||||
this.overallStats.sundays += monthData.sundays;
|
this.overallStats.sundays += monthData.sundays;
|
||||||
this.overallStats.sat2nd4th += monthData.secondFourthSat;
|
this.overallStats.sat2nd4th += monthData.secondFourthSat;
|
||||||
|
this.overallStats.weeklyOff += monthData.weeklyOff;
|
||||||
}
|
}
|
||||||
this.overallStats.totalDays = this.overallStats.working + this.overallStats.nonWorking;
|
this.overallStats.totalDays = this.overallStats.working + this.overallStats.nonWorking;
|
||||||
}
|
}
|
||||||
@@ -552,6 +608,7 @@ export class BankCalendarComponent implements OnInit {
|
|||||||
let holidayCount = 0;
|
let holidayCount = 0;
|
||||||
let sundayCount = 0;
|
let sundayCount = 0;
|
||||||
let sat2nd4thCount = 0;
|
let sat2nd4thCount = 0;
|
||||||
|
let weeklyOffCount = 0;
|
||||||
|
|
||||||
// Previous month padding
|
// Previous month padding
|
||||||
const prevMonthDays = new Date(year, month, 0).getDate();
|
const prevMonthDays = new Date(year, month, 0).getDate();
|
||||||
@@ -567,6 +624,7 @@ export class BankCalendarComponent implements OnInit {
|
|||||||
isSunday: false,
|
isSunday: false,
|
||||||
is2ndSaturday: false,
|
is2ndSaturday: false,
|
||||||
is4thSaturday: false,
|
is4thSaturday: false,
|
||||||
|
isWeeklyOff: false,
|
||||||
dayOfWeek: (startDayOfWeek - i - 1 + 7) % 7
|
dayOfWeek: (startDayOfWeek - i - 1 + 7) % 7
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@@ -576,24 +634,29 @@ export class BankCalendarComponent implements OnInit {
|
|||||||
for (let d = 1; d <= daysInMonth; d++) {
|
for (let d = 1; d <= daysInMonth; d++) {
|
||||||
const date = new Date(year, month, d);
|
const date = new Date(year, month, d);
|
||||||
const dow = date.getDay();
|
const dow = date.getDay();
|
||||||
const isSunday = dow === 0;
|
|
||||||
const isSaturday = dow === 6;
|
const isSaturday = dow === 6;
|
||||||
|
const isWorkingWeekday = this.workingDaysConfig.includes(DAY_CODES[dow]);
|
||||||
|
const isSunday = dow === 0 && !isWorkingWeekday;
|
||||||
|
|
||||||
if (isSaturday) saturdayCount++;
|
if (isSaturday) saturdayCount++;
|
||||||
|
|
||||||
const is2ndSat = isSaturday && saturdayCount === 2;
|
// RBI: when Saturday is a working day the 2nd and 4th are still off. When Working Days leaves
|
||||||
const is4thSat = isSaturday && saturdayCount === 4;
|
// Saturday out, every Saturday is a weekly off instead.
|
||||||
|
const is2ndSat = isSaturday && isWorkingWeekday && saturdayCount === 2;
|
||||||
|
const is4thSat = isSaturday && isWorkingWeekday && saturdayCount === 4;
|
||||||
|
const isWeeklyOff = dow !== 0 && !isWorkingWeekday;
|
||||||
const isToday = date.toDateString() === today.toDateString();
|
const isToday = date.toDateString() === today.toDateString();
|
||||||
|
|
||||||
// Check if holiday
|
// Check if holiday
|
||||||
const holiday = this.getHolidayForDate(year, month, d);
|
const holiday = this.getHolidayForDate(year, month, d);
|
||||||
const isHoliday = !!holiday;
|
const isHoliday = !!holiday;
|
||||||
|
|
||||||
// Non-working: Sunday, 2nd Sat, 4th Sat, or holiday
|
// Non-working: Sunday, 2nd Sat, 4th Sat, a weekly off from Working Days, or holiday
|
||||||
const isNonWorking = isSunday || is2ndSat || is4thSat || isHoliday;
|
const isNonWorking = isSunday || is2ndSat || is4thSat || isWeeklyOff || isHoliday;
|
||||||
|
|
||||||
if (isSunday) sundayCount++;
|
if (isSunday) sundayCount++;
|
||||||
if (is2ndSat || is4thSat) sat2nd4thCount++;
|
if (is2ndSat || is4thSat || (isSaturday && isWeeklyOff)) sat2nd4thCount++;
|
||||||
|
if (isWeeklyOff && !isSaturday) weeklyOffCount++;
|
||||||
if (isHoliday) holidayCount++;
|
if (isHoliday) holidayCount++;
|
||||||
|
|
||||||
if (isNonWorking) {
|
if (isNonWorking) {
|
||||||
@@ -613,6 +676,7 @@ export class BankCalendarComponent implements OnInit {
|
|||||||
isSunday,
|
isSunday,
|
||||||
is2ndSaturday: is2ndSat,
|
is2ndSaturday: is2ndSat,
|
||||||
is4thSaturday: is4thSat,
|
is4thSaturday: is4thSat,
|
||||||
|
isWeeklyOff,
|
||||||
holidayName: holiday?.name,
|
holidayName: holiday?.name,
|
||||||
dayOfWeek: dow
|
dayOfWeek: dow
|
||||||
});
|
});
|
||||||
@@ -632,6 +696,7 @@ export class BankCalendarComponent implements OnInit {
|
|||||||
isSunday: false,
|
isSunday: false,
|
||||||
is2ndSaturday: false,
|
is2ndSaturday: false,
|
||||||
is4thSaturday: false,
|
is4thSaturday: false,
|
||||||
|
isWeeklyOff: false,
|
||||||
dayOfWeek: days.length % 7
|
dayOfWeek: days.length % 7
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@@ -645,7 +710,8 @@ export class BankCalendarComponent implements OnInit {
|
|||||||
nonWorkingDays: nonWorkingCount,
|
nonWorkingDays: nonWorkingCount,
|
||||||
holidays: holidayCount,
|
holidays: holidayCount,
|
||||||
sundays: sundayCount,
|
sundays: sundayCount,
|
||||||
secondFourthSat: sat2nd4thCount
|
secondFourthSat: sat2nd4thCount,
|
||||||
|
weeklyOff: weeklyOffCount
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -17,7 +17,12 @@
|
|||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<button class="bor-btn bor-btn--xls" type="button" [disabled]="exporting || !filtered.length" (click)="exportExcel()">
|
<button
|
||||||
|
class="bor-btn bor-btn--xls"
|
||||||
|
type="button"
|
||||||
|
[disabled]="exporting || !filtered.length"
|
||||||
|
(click)="exportExcel()"
|
||||||
|
>
|
||||||
<fa-icon icon="file-excel"></fa-icon>
|
<fa-icon icon="file-excel"></fa-icon>
|
||||||
{{ exporting ? 'Exporting…' : 'Export' }}
|
{{ exporting ? 'Exporting…' : 'Export' }}
|
||||||
</button>
|
</button>
|
||||||
@@ -132,7 +137,9 @@
|
|||||||
<td class="num">{{ r.approvedShares | number }}</td>
|
<td class="num">{{ r.approvedShares | number }}</td>
|
||||||
<td class="num strong">{{ r.shareValue | number: '1.2-2' }}</td>
|
<td class="num strong">{{ r.shareValue | number: '1.2-2' }}</td>
|
||||||
<td class="num">{{ r.trancheCount }}</td>
|
<td class="num">{{ r.trancheCount }}</td>
|
||||||
<td><span [class]="statusClass(r.statusEnum)">{{ statusText(r.statusEnum) }}</span></td>
|
<td>
|
||||||
|
<span [class]="statusClass(r.statusEnum)">{{ statusText(r.statusEnum) }}</span>
|
||||||
|
</td>
|
||||||
<td class="mono">{{ r.activatedDate || '—' }}</td>
|
<td class="mono">{{ r.activatedDate || '—' }}</td>
|
||||||
</tr>
|
</tr>
|
||||||
} @empty {
|
} @empty {
|
||||||
|
|||||||
@@ -12,6 +12,7 @@ import { FormsModule } from '@angular/forms';
|
|||||||
import { MatIconModule } from '@angular/material/icon';
|
import { MatIconModule } from '@angular/material/icon';
|
||||||
import { catchError, of } from 'rxjs';
|
import { catchError, of } from 'rxjs';
|
||||||
import { CashierService } from '../cashier.service';
|
import { CashierService } from '../cashier.service';
|
||||||
|
import { businessToday } from 'app/core/utils/business-today';
|
||||||
|
|
||||||
interface RegisterRow {
|
interface RegisterRow {
|
||||||
accountId: number;
|
accountId: number;
|
||||||
@@ -83,7 +84,7 @@ export class AccountRegisterComponent implements OnInit {
|
|||||||
|
|
||||||
/** Local date — toISOString() would shift the day in any timezone behind UTC. */
|
/** Local date — toISOString() would shift the day in any timezone behind UTC. */
|
||||||
private today(): string {
|
private today(): string {
|
||||||
const d = new Date();
|
const d = businessToday();
|
||||||
return `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, '0')}-${String(d.getDate()).padStart(2, '0')}`;
|
return `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, '0')}-${String(d.getDate()).padStart(2, '0')}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,114 @@
|
|||||||
|
<!--
|
||||||
|
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">BRANCH OPERATIONS · BRANCH CASH POSITION</div>
|
||||||
|
<h1 class="pg-title">Branch Cash Position</h1>
|
||||||
|
<p class="pg-sub">
|
||||||
|
The notes and coins the branch holds, denomination by denomination: the vault plus every cashier's drawer. Cash
|
||||||
|
moving between the vault and a cashier nets out; deposits, withdrawals and exchanges change it.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<div class="pg-toolbar pg-toolbar--mat">
|
||||||
|
<mat-form-field appearance="outline" subscriptSizing="dynamic">
|
||||||
|
<mat-label>As of Date</mat-label>
|
||||||
|
<input matInput type="date" [value]="asOfDate" (change)="onDateChange($any($event.target).value)" />
|
||||||
|
</mat-form-field>
|
||||||
|
<button type="button" class="pg-btn pg-btn--primary" (click)="load()" [disabled]="loading">
|
||||||
|
<mat-icon>refresh</mat-icon> Refresh
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
@if (errorMsg) {
|
||||||
|
<div class="pg-empty bcp-error">{{ errorMsg }}</div>
|
||||||
|
}
|
||||||
|
@if (loading) {
|
||||||
|
<div class="pg-empty">Loading…</div>
|
||||||
|
}
|
||||||
|
|
||||||
|
@if (position; as p) {
|
||||||
|
<div class="bcp-totals">
|
||||||
|
<div class="bcp-total">
|
||||||
|
<span>Branch</span><strong>{{ p.officeName }}</strong>
|
||||||
|
</div>
|
||||||
|
<div class="bcp-total">
|
||||||
|
<span>Vault</span><strong>₹{{ p.vaultAmount | number: '1.2-2' }}</strong>
|
||||||
|
</div>
|
||||||
|
<div class="bcp-total">
|
||||||
|
<span>Cashiers' drawers</span><strong>₹{{ p.cashiersAmount | number: '1.2-2' }}</strong>
|
||||||
|
</div>
|
||||||
|
<div class="bcp-total bcp-total--main">
|
||||||
|
<span>Bank total</span><strong>₹{{ p.totalAmount | number: '1.2-2' }}</strong>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
@if (!p.vaultHasBreakdown) {
|
||||||
|
<div class="pg-empty bcp-note">
|
||||||
|
No vault denomination breakdown was recorded on or before {{ p.asOfDate }}, so the vault column reads 0. Open
|
||||||
|
the business day with a note/coin count to start tracking it.
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
|
||||||
|
<ng-template #table let-rows let-label="label" let-coin="coin">
|
||||||
|
@if (rows.length > 0) {
|
||||||
|
<div class="pos-section-label" [class.pos-section-label--coin]="coin">{{ label }}</div>
|
||||||
|
<table class="pg-table">
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th>Denomination</th>
|
||||||
|
<th class="num">Vault</th>
|
||||||
|
<th class="num">Cashiers</th>
|
||||||
|
<th class="num">Bank total (count)</th>
|
||||||
|
<th class="num">Bank total (₹)</th>
|
||||||
|
<th class="num">Exchange today</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
@for (d of rows; track d.denominationId) {
|
||||||
|
<tr>
|
||||||
|
<td>
|
||||||
|
<span class="pg-chip" [class.pg-chip--coin]="coin">₹{{ d.denominationValue }}</span>
|
||||||
|
</td>
|
||||||
|
<td class="num">{{ d.vaultCount }}</td>
|
||||||
|
<td class="num">{{ d.cashierCount }}</td>
|
||||||
|
<td class="num bcp-strong">{{ d.totalCount }}</td>
|
||||||
|
<td class="num">₹{{ d.totalAmount | number: '1.2-2' }}</td>
|
||||||
|
<td
|
||||||
|
class="num bcp-move"
|
||||||
|
[class.bcp-move--in]="d.exchangeNet > 0"
|
||||||
|
[class.bcp-move--out]="d.exchangeNet < 0"
|
||||||
|
>
|
||||||
|
{{ movement(d.exchangeNet) }}
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
}
|
||||||
|
</ng-template>
|
||||||
|
|
||||||
|
<div class="pg-card bcp-card">
|
||||||
|
<ng-container
|
||||||
|
*ngTemplateOutlet="table; context: { $implicit: notes(), label: 'Notes', coin: false }"
|
||||||
|
></ng-container>
|
||||||
|
<ng-container
|
||||||
|
*ngTemplateOutlet="table; context: { $implicit: coins(), label: 'Coins', coin: true }"
|
||||||
|
></ng-container>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<p class="bcp-foot">
|
||||||
|
<strong>Exchange today</strong> is the notes taken in less the notes given out by cash exchanges on
|
||||||
|
{{ p.asOfDate }}. It is already part of the Cashiers column; it is shown beside the total so a change in the mix
|
||||||
|
can be seen.
|
||||||
|
</p>
|
||||||
|
}
|
||||||
|
</div>
|
||||||
@@ -0,0 +1,82 @@
|
|||||||
|
/**
|
||||||
|
* Copyright since 2025 Mifos Initiative
|
||||||
|
*
|
||||||
|
* This Source Code Form is subject to the terms of the Mozilla Public
|
||||||
|
* License, v. 2.0. If a copy of the MPL was not distributed with this
|
||||||
|
* file, You can obtain one at http://mozilla.org/MPL/2.0/.
|
||||||
|
*/
|
||||||
|
|
||||||
|
.bcp-error {
|
||||||
|
color: #dc2626;
|
||||||
|
}
|
||||||
|
|
||||||
|
.bcp-note {
|
||||||
|
margin-bottom: 12px;
|
||||||
|
text-align: left;
|
||||||
|
}
|
||||||
|
|
||||||
|
.bcp-totals {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: 12px;
|
||||||
|
margin-bottom: 14px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.bcp-total {
|
||||||
|
flex: 1 1 180px;
|
||||||
|
padding: 12px 14px;
|
||||||
|
background: #fff;
|
||||||
|
border: 1px solid #e2e8f0;
|
||||||
|
border-radius: 12px;
|
||||||
|
|
||||||
|
span {
|
||||||
|
display: block;
|
||||||
|
font-size: 11px;
|
||||||
|
font-weight: 600;
|
||||||
|
letter-spacing: 0.04em;
|
||||||
|
text-transform: uppercase;
|
||||||
|
color: #64748b;
|
||||||
|
}
|
||||||
|
|
||||||
|
strong {
|
||||||
|
font-size: 18px;
|
||||||
|
font-weight: 700;
|
||||||
|
color: #0f172a;
|
||||||
|
}
|
||||||
|
|
||||||
|
&--main {
|
||||||
|
background: #e6f4ee;
|
||||||
|
border-color: #1f7a4d;
|
||||||
|
|
||||||
|
strong {
|
||||||
|
color: #0f4f3c;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.bcp-card {
|
||||||
|
margin-bottom: 14px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.bcp-strong {
|
||||||
|
font-weight: 700;
|
||||||
|
}
|
||||||
|
|
||||||
|
.bcp-move {
|
||||||
|
font-weight: 700;
|
||||||
|
color: #94a3b8;
|
||||||
|
|
||||||
|
&--in {
|
||||||
|
color: #16a34a;
|
||||||
|
}
|
||||||
|
|
||||||
|
&--out {
|
||||||
|
color: #dc2626;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.bcp-foot {
|
||||||
|
margin: 0;
|
||||||
|
font-size: 12px;
|
||||||
|
color: #64748b;
|
||||||
|
}
|
||||||
@@ -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 { MatIconModule } from '@angular/material/icon';
|
||||||
|
import { MatFormFieldModule } from '@angular/material/form-field';
|
||||||
|
import { MatInputModule } from '@angular/material/input';
|
||||||
|
import { of } from 'rxjs';
|
||||||
|
import { catchError } from 'rxjs/operators';
|
||||||
|
import { CashierService } from '../cashier.service';
|
||||||
|
import { businessToday } from 'app/core/utils/business-today';
|
||||||
|
|
||||||
|
interface BranchDenomRow {
|
||||||
|
denominationId: number;
|
||||||
|
denominationValue: number;
|
||||||
|
kind: string;
|
||||||
|
vaultCount: number;
|
||||||
|
cashierCount: number;
|
||||||
|
totalCount: number;
|
||||||
|
totalAmount: number;
|
||||||
|
/** Notes taken in less notes given out by exchanges on the day. */
|
||||||
|
exchangeNet: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface BranchPosition {
|
||||||
|
officeId: number;
|
||||||
|
officeName: string;
|
||||||
|
asOfDate: string;
|
||||||
|
vaultHasBreakdown: boolean;
|
||||||
|
vaultAmount: number;
|
||||||
|
cashiersAmount: number;
|
||||||
|
totalAmount: number;
|
||||||
|
denominations: BranchDenomRow[];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* What the branch holds, denomination by denomination: the vault plus every cashier's drawer. An exchange
|
||||||
|
* (a customer hands in 10 x ₹500 and leaves with 50 x ₹100) leaves the vault alone and changes a drawer, so
|
||||||
|
* it only shows up in this total -- +10 on ₹500, -50 on ₹100.
|
||||||
|
*/
|
||||||
|
@Component({
|
||||||
|
selector: 'mifosx-branch-cash-position',
|
||||||
|
templateUrl: './branch-cash-position.component.html',
|
||||||
|
styleUrls: [
|
||||||
|
'../cashier-shared.scss',
|
||||||
|
'../cashier-position/cashier-position.component.scss',
|
||||||
|
'./branch-cash-position.component.scss'
|
||||||
|
],
|
||||||
|
standalone: true,
|
||||||
|
imports: [
|
||||||
|
CommonModule,
|
||||||
|
MatIconModule,
|
||||||
|
MatFormFieldModule,
|
||||||
|
MatInputModule
|
||||||
|
]
|
||||||
|
})
|
||||||
|
export class BranchCashPositionComponent implements OnInit {
|
||||||
|
private service = inject(CashierService);
|
||||||
|
|
||||||
|
asOfDate = '';
|
||||||
|
loading = false;
|
||||||
|
errorMsg = '';
|
||||||
|
position: BranchPosition | null = null;
|
||||||
|
|
||||||
|
ngOnInit(): void {
|
||||||
|
const d = businessToday();
|
||||||
|
this.asOfDate = `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, '0')}-${String(d.getDate()).padStart(2, '0')}`;
|
||||||
|
this.load();
|
||||||
|
}
|
||||||
|
|
||||||
|
load(): void {
|
||||||
|
this.loading = true;
|
||||||
|
this.errorMsg = '';
|
||||||
|
this.position = null;
|
||||||
|
this.service
|
||||||
|
.branchCashPosition(this.asOfDate)
|
||||||
|
.pipe(catchError(() => of(null)))
|
||||||
|
.subscribe((data: BranchPosition | null) => {
|
||||||
|
this.loading = false;
|
||||||
|
if (!data) {
|
||||||
|
this.errorMsg = 'Could not load the branch cash position.';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
this.position = data;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
onDateChange(value: string): void {
|
||||||
|
this.asOfDate = value;
|
||||||
|
this.load();
|
||||||
|
}
|
||||||
|
|
||||||
|
notes(): BranchDenomRow[] {
|
||||||
|
return (this.position?.denominations || []).filter((d) => d.kind !== 'coin');
|
||||||
|
}
|
||||||
|
|
||||||
|
coins(): BranchDenomRow[] {
|
||||||
|
return (this.position?.denominations || []).filter((d) => d.kind === 'coin');
|
||||||
|
}
|
||||||
|
|
||||||
|
/** "+10", "-50", or a dash when the day's exchanges did not touch the denomination. */
|
||||||
|
movement(net: number): string {
|
||||||
|
if (!net) return '—';
|
||||||
|
return net > 0 ? `+${net}` : `${net}`;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -107,11 +107,26 @@
|
|||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
@if (canOperate) {
|
@if (canOperate) {
|
||||||
<button type="button" class="pg-btn pg-btn--primary" (click)="startClose(openDay)">
|
<button
|
||||||
|
type="button"
|
||||||
|
class="pg-btn pg-btn--primary"
|
||||||
|
(click)="startClose(openDay)"
|
||||||
|
[disabled]="interestPending.length > 0"
|
||||||
|
>
|
||||||
<mat-icon>lock</mat-icon> Close Day
|
<mat-icon>lock</mat-icon> Close Day
|
||||||
</button>
|
</button>
|
||||||
}
|
}
|
||||||
</div>
|
</div>
|
||||||
|
@if (interestPending.length > 0) {
|
||||||
|
<div class="bd-interest-alert">
|
||||||
|
<mat-icon>warning</mat-icon>
|
||||||
|
<span>
|
||||||
|
Savings interest posting report is not approved for <strong>{{ interestPending.join(', ') }}</strong
|
||||||
|
>. Approve it in <a routerLink="/reports/interest-posting">Reports → Interest Posting Reports</a> before
|
||||||
|
closing the day.
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
} @else if (loaded && canOperate) {
|
} @else if (loaded && canOperate) {
|
||||||
<div class="bd-current bd-current--shut">
|
<div class="bd-current bd-current--shut">
|
||||||
<div class="bd-current__main">
|
<div class="bd-current__main">
|
||||||
@@ -337,6 +352,51 @@
|
|||||||
</div>
|
</div>
|
||||||
}
|
}
|
||||||
|
|
||||||
|
<!-- Interest waiting to be posted as this branch closes. Posting runs as part of the close, so
|
||||||
|
anything unapproved stops it: the branch sees here what it has to approve and where, rather
|
||||||
|
than finding out from a refusal after filling in the whole count. -->
|
||||||
|
@if (preview?.fdInterestDue?.length) {
|
||||||
|
<div class="bd-calc bd-calc--fd">
|
||||||
|
<div class="bd-calc__head">
|
||||||
|
<mat-icon>savings</mat-icon>
|
||||||
|
<strong>Fixed deposit interest due today</strong>
|
||||||
|
</div>
|
||||||
|
@for (d of preview.fdInterestDue; track d.productId) {
|
||||||
|
<div class="bd-calc__row">
|
||||||
|
<span>
|
||||||
|
{{ d.productName }} · {{ d.fdType }} · {{ d.accountCount }} deposit(s)
|
||||||
|
@if (d.reportStatus === 'APPROVED' || d.reportStatus === 'POSTED') {
|
||||||
|
<span class="bd-fd-ok">{{ d.reportStatus }}</span>
|
||||||
|
} @else {
|
||||||
|
<span class="bd-fd-pending">{{ d.reportStatus === 'NONE' ? 'NO REPORT' : d.reportStatus }}</span>
|
||||||
|
}
|
||||||
|
</span>
|
||||||
|
<span class="bd-calc__amt">₹{{ d.totalInterest | number: '1.2-2' }}</span>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
@if (preview?.fdInterestPending?.length) {
|
||||||
|
<div class="bd-warn">
|
||||||
|
<mat-icon>warning</mat-icon>
|
||||||
|
<div>
|
||||||
|
<strong>The day cannot be closed yet.</strong> Fixed deposit interest is not approved for:
|
||||||
|
{{ preview.fdInterestPending.join(', ') }}. Approve it under Reports → FD Interest Posting Reports, then
|
||||||
|
close the day. The user who generated a report cannot approve it.
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
@if (preview?.savingsInterestPending?.length) {
|
||||||
|
<div class="bd-warn">
|
||||||
|
<mat-icon>warning</mat-icon>
|
||||||
|
<div>
|
||||||
|
<strong>The day cannot be closed yet.</strong> Savings interest is not approved for:
|
||||||
|
{{ preview.savingsInterestPending.join(', ') }}. Approve it under Reports → Interest Posting Reports, then
|
||||||
|
close the day.
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
|
||||||
<!-- What every cash point under this branch is actually holding right now, fetched
|
<!-- What every cash point under this branch is actually holding right now, fetched
|
||||||
automatically — so closing the day doesn't mean separately checking Vault Position and
|
automatically — so closing the day doesn't mean separately checking Vault Position and
|
||||||
every cashier's own Cashier Position screen by hand. -->
|
every cashier's own Cashier Position screen by hand. -->
|
||||||
|
|||||||
@@ -195,6 +195,75 @@
|
|||||||
font-size: 11px;
|
font-size: 11px;
|
||||||
color: #94a3b8;
|
color: #94a3b8;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* Names the block, for the interest panels that sit beside the cash arithmetic. */
|
||||||
|
&__head {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 6px;
|
||||||
|
margin-bottom: 6px;
|
||||||
|
padding-bottom: 6px;
|
||||||
|
border-bottom: 1px solid #e2e8f0;
|
||||||
|
font-size: 13px;
|
||||||
|
color: #0d1b2a;
|
||||||
|
|
||||||
|
mat-icon {
|
||||||
|
font-size: 18px;
|
||||||
|
width: 18px;
|
||||||
|
height: 18px;
|
||||||
|
color: #1d4ed8;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Interest is money leaving as the day closes, not cash counted in the drawer. */
|
||||||
|
.bd-calc--fd {
|
||||||
|
border-left: 3px solid #1d4ed8;
|
||||||
|
}
|
||||||
|
|
||||||
|
.bd-fd-ok,
|
||||||
|
.bd-fd-pending {
|
||||||
|
display: inline-block;
|
||||||
|
margin-left: 6px;
|
||||||
|
padding: 1px 7px;
|
||||||
|
border-radius: 999px;
|
||||||
|
font-size: 10px;
|
||||||
|
font-weight: 700;
|
||||||
|
letter-spacing: 0.02em;
|
||||||
|
}
|
||||||
|
|
||||||
|
.bd-fd-ok {
|
||||||
|
background: #dcfce7;
|
||||||
|
color: #15803d;
|
||||||
|
}
|
||||||
|
|
||||||
|
.bd-fd-pending {
|
||||||
|
background: #fef3c7;
|
||||||
|
color: #b45309;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Why the close will be refused, said before the count is filled in rather than after. */
|
||||||
|
.bd-warn {
|
||||||
|
display: flex;
|
||||||
|
align-items: flex-start;
|
||||||
|
gap: 8px;
|
||||||
|
max-width: 640px;
|
||||||
|
margin-bottom: 14px;
|
||||||
|
padding: 10px 14px;
|
||||||
|
background: #fffbeb;
|
||||||
|
border: 1px solid #fcd34d;
|
||||||
|
border-radius: 8px;
|
||||||
|
font-size: 12px;
|
||||||
|
line-height: 1.5;
|
||||||
|
color: #78350f;
|
||||||
|
|
||||||
|
mat-icon {
|
||||||
|
flex-shrink: 0;
|
||||||
|
font-size: 20px;
|
||||||
|
width: 20px;
|
||||||
|
height: 20px;
|
||||||
|
color: #b45309;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
.bd-hint {
|
.bd-hint {
|
||||||
@@ -372,3 +441,30 @@
|
|||||||
color: #d97706;
|
color: #d97706;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Shown under the open day while a savings interest posting report due today is unapproved.
|
||||||
|
.bd-interest-alert {
|
||||||
|
display: flex;
|
||||||
|
align-items: flex-start;
|
||||||
|
gap: 8px;
|
||||||
|
margin: 8px 0 12px;
|
||||||
|
padding: 10px 14px;
|
||||||
|
border: 1px solid #f59e0b;
|
||||||
|
border-radius: 8px;
|
||||||
|
background: #fef3c7;
|
||||||
|
color: #92400e;
|
||||||
|
font-size: 13px;
|
||||||
|
|
||||||
|
mat-icon {
|
||||||
|
flex-shrink: 0;
|
||||||
|
font-size: 20px;
|
||||||
|
width: 20px;
|
||||||
|
height: 20px;
|
||||||
|
}
|
||||||
|
|
||||||
|
a {
|
||||||
|
color: inherit;
|
||||||
|
font-weight: 600;
|
||||||
|
text-decoration: underline;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -189,6 +189,23 @@ export class BranchDayComponent implements OnInit {
|
|||||||
return this.totalOf(this.closeCounts);
|
return this.totalOf(this.closeCounts);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Savings products whose interest posting report is due today and not yet approved. The close is
|
||||||
|
* refused by the backend while any is pending; this only says so before the user tries.
|
||||||
|
*/
|
||||||
|
interestPending: string[] = [];
|
||||||
|
|
||||||
|
private checkInterestReports(): void {
|
||||||
|
if (!this.openDay?.businessDate) {
|
||||||
|
this.interestPending = [];
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
this.service
|
||||||
|
.interestDayCloseCheck(this.openDay.businessDate, this.openDay.officeId)
|
||||||
|
.pipe(catchError(() => of(null)))
|
||||||
|
.subscribe((res) => (this.interestPending = res?.pendingProducts ?? []));
|
||||||
|
}
|
||||||
|
|
||||||
load(): void {
|
load(): void {
|
||||||
this.loading = true;
|
this.loading = true;
|
||||||
this.errorMsg = '';
|
this.errorMsg = '';
|
||||||
@@ -210,6 +227,7 @@ export class BranchDayComponent implements OnInit {
|
|||||||
this.canOperate = !!res?.canOperate;
|
this.canOperate = !!res?.canOperate;
|
||||||
this.openDate = this.today;
|
this.openDate = this.today;
|
||||||
this.checkFirstOpenCashMigration();
|
this.checkFirstOpenCashMigration();
|
||||||
|
this.checkInterestReports();
|
||||||
});
|
});
|
||||||
|
|
||||||
this.service
|
this.service
|
||||||
|
|||||||
+2
-1
@@ -11,6 +11,7 @@ import { CommonModule } from '@angular/common';
|
|||||||
import { FormsModule } from '@angular/forms';
|
import { FormsModule } from '@angular/forms';
|
||||||
import { AuthenticationService } from '../../core/authentication/authentication.service';
|
import { AuthenticationService } from '../../core/authentication/authentication.service';
|
||||||
import { ExportSheetSpec, exportWorkbook } from '../cashier-export';
|
import { ExportSheetSpec, exportWorkbook } from '../cashier-export';
|
||||||
|
import { businessToday } from 'app/core/utils/business-today';
|
||||||
|
|
||||||
interface SummaryRow {
|
interface SummaryRow {
|
||||||
code: string;
|
code: string;
|
||||||
@@ -1316,7 +1317,7 @@ export class CashClearingTransferSummaryComponent {
|
|||||||
private dateTag = '2607';
|
private dateTag = '2607';
|
||||||
|
|
||||||
constructor() {
|
constructor() {
|
||||||
const d = new Date();
|
const d = businessToday();
|
||||||
this.asOnDate = `${String(d.getDate()).padStart(2, '0')}-${String(d.getMonth() + 1).padStart(2, '0')}-${d.getFullYear()}`;
|
this.asOnDate = `${String(d.getDate()).padStart(2, '0')}-${String(d.getMonth() + 1).padStart(2, '0')}-${d.getFullYear()}`;
|
||||||
this.dateTag = `${String(d.getFullYear()).slice(-2)}${String(d.getMonth() + 1).padStart(2, '0')}`;
|
this.dateTag = `${String(d.getFullYear()).slice(-2)}${String(d.getMonth() + 1).padStart(2, '0')}`;
|
||||||
|
|
||||||
|
|||||||
@@ -22,50 +22,255 @@
|
|||||||
</div>
|
</div>
|
||||||
</header>
|
</header>
|
||||||
|
|
||||||
<!-- ── Customer Info Bar ── -->
|
<!-- ── Cashier: whose drawer takes the customer's notes and gives the change ── -->
|
||||||
<div class="cx-customer-bar">
|
@if (cashier) {
|
||||||
<div
|
<section class="cx-cashier">
|
||||||
class="cx-customer-toggle"
|
<div class="cx-avatar cx-avatar--sm">{{ cashierInitials }}</div>
|
||||||
[class.cx-customer-toggle--active]="isAccountHolder"
|
<div class="cx-cashier__id">
|
||||||
(click)="toggleAccountHolder()"
|
<div class="cx-cashier__label">Cashier at the counter</div>
|
||||||
|
<div class="cx-cashier__name">{{ cashier.name || '—' }}</div>
|
||||||
|
<div class="cx-cashier__flow">
|
||||||
|
<span><mat-icon>south</mat-icon> Receives from customer</span>
|
||||||
|
<span><mat-icon>north</mat-icon> Gives to customer</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="cx-cashier__stats">
|
||||||
|
<div>
|
||||||
|
<span>Counter</span><strong>{{ cashier.tellerName || '—' }}</strong>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<span>Branch</span><strong>{{ cashier.officeName || '—' }}</strong>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<span>Allocated today</span
|
||||||
|
><strong>{{
|
||||||
|
cashier.allocatedAmount === null ? '—' : '₹' + (cashier.allocatedAmount | number: '1.2-2')
|
||||||
|
}}</strong>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<span>Cash in hand</span><strong>₹{{ cashier.totalAmount | number: '1.2-2' }}</strong>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
} @else if (noCashier) {
|
||||||
|
<section class="cx-cashier cx-cashier--none">
|
||||||
|
<mat-icon>person_off</mat-icon>
|
||||||
|
<div>
|
||||||
|
<div class="cx-cashier__name">Your login is not linked to a cashier</div>
|
||||||
|
<div class="cx-cashier__label">
|
||||||
|
A cash exchange moves notes in and out of a cashier's drawer, so it can only be recorded by a cashier. Log in
|
||||||
|
as a cashier to continue.
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
}
|
||||||
|
|
||||||
|
<!-- ── Customer: an existing client (details from their record) or a walk-in identified at the counter ── -->
|
||||||
|
<section class="cx-cust">
|
||||||
|
<header class="cx-cust__head">
|
||||||
|
<div class="cx-cust__title">
|
||||||
|
<span class="cx-cust__title-icon"><mat-icon>groups</mat-icon></span>
|
||||||
|
<div>
|
||||||
|
<div class="cx-cust__title-text">Customer</div>
|
||||||
|
<div class="cx-cust__title-sub">Who is exchanging cash at the counter</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="cx-cust__modes" role="tablist" aria-label="Customer type">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
role="tab"
|
||||||
|
class="cx-cust__mode"
|
||||||
|
[class.cx-cust__mode--active]="customerMode === 'existing'"
|
||||||
|
[attr.aria-selected]="customerMode === 'existing'"
|
||||||
|
(click)="setCustomerMode('existing')"
|
||||||
>
|
>
|
||||||
<mat-icon>{{ isAccountHolder ? 'check_box' : 'check_box_outline_blank' }}</mat-icon>
|
<mat-icon>badge</mat-icon> Existing Customer
|
||||||
<span>Account Holder</span>
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
role="tab"
|
||||||
|
class="cx-cust__mode"
|
||||||
|
[class.cx-cust__mode--active]="customerMode === 'walkin'"
|
||||||
|
[attr.aria-selected]="customerMode === 'walkin'"
|
||||||
|
(click)="setCustomerMode('walkin')"
|
||||||
|
>
|
||||||
|
<mat-icon>person_outline</mat-icon> Walk-in Customer
|
||||||
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
</header>
|
||||||
|
|
||||||
@if (isAccountHolder) {
|
<div class="cx-cust__body">
|
||||||
<label class="cx-field cx-field--inline">
|
<!-- Who -->
|
||||||
<span>Account No.</span>
|
<div class="cx-cust__who">
|
||||||
<input
|
@if (customerMode === 'existing') {
|
||||||
type="text"
|
@if (loadingCustomer) {
|
||||||
[value]="accountNo"
|
<div class="cx-cust__empty">
|
||||||
(input)="accountNo = $any($event.target).value"
|
<mat-icon class="cx-cust__empty-icon cx-spin">autorenew</mat-icon>
|
||||||
placeholder="e.g. 000101234567"
|
<div class="cx-cust__empty-title">Loading customer details…</div>
|
||||||
/>
|
</div>
|
||||||
</label>
|
} @else if (!selectedCustomer) {
|
||||||
|
<div class="cx-cust__empty">
|
||||||
|
<mat-icon class="cx-cust__empty-icon">person_search</mat-icon>
|
||||||
|
<div class="cx-cust__empty-title">No customer selected</div>
|
||||||
|
<div class="cx-cust__empty-sub">Search by name, account no., PAN or Aadhaar</div>
|
||||||
|
<button type="button" class="cx-btn cx-btn--primary" (click)="openCustomerSearch()">
|
||||||
|
<mat-icon>search</mat-icon> Search Customer
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
} @else {
|
} @else {
|
||||||
<span class="cx-walk-in"><mat-icon>person_outline</mat-icon> Walk-in Customer</span>
|
<div class="cx-cust__card">
|
||||||
|
<div class="cx-cust__card-top">
|
||||||
|
<div class="cx-avatar">{{ customerInitials }}</div>
|
||||||
|
<div class="cx-cust__card-id">
|
||||||
|
<div class="cx-cust__name">{{ selectedCustomer.displayName }}</div>
|
||||||
|
<div class="cx-cust__badges">
|
||||||
|
<span
|
||||||
|
class="cx-kyc"
|
||||||
|
[class.cx-kyc--ok]="selectedCustomer.kycStatus === 'Verified'"
|
||||||
|
[class.cx-kyc--warn]="selectedCustomer.kycStatus !== 'Verified'"
|
||||||
|
>
|
||||||
|
<mat-icon>{{
|
||||||
|
selectedCustomer.kycStatus === 'Verified' ? 'verified_user' : 'gpp_maybe'
|
||||||
|
}}</mat-icon>
|
||||||
|
KYC: {{ selectedCustomer.kycStatus }}
|
||||||
|
</span>
|
||||||
|
@if (selectedCustomer.accountType) {
|
||||||
|
<span class="cx-chip-tag">{{ selectedCustomer.accountType }}</span>
|
||||||
}
|
}
|
||||||
|
|
||||||
@if (needsIdProof) {
|
|
||||||
<div class="cx-pan-required">
|
|
||||||
<mat-icon>warning</mat-icon>
|
|
||||||
<span>Amount ≥ ₹50,000 — Customer PAN / Aadhaar required (RBI)</span>
|
|
||||||
</div>
|
</div>
|
||||||
<label class="cx-field cx-field--inline cx-field--pan">
|
</div>
|
||||||
<span>PAN / Aadhaar *</span>
|
<div class="cx-cust__card-actions">
|
||||||
|
<button type="button" class="cx-link-btn" (click)="openCustomerSearch()">
|
||||||
|
<mat-icon>swap_horiz</mat-icon> Change
|
||||||
|
</button>
|
||||||
|
<button type="button" class="cx-link-btn cx-link-btn--danger" (click)="clearCustomer()">
|
||||||
|
<mat-icon>close</mat-icon> Remove
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="cx-cust__grid">
|
||||||
|
<div>
|
||||||
|
<span>CIF No.</span><strong>{{ selectedCustomer.cifNumber || '—' }}</strong>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<span>Client ID</span><strong>{{ selectedCustomer.clientId }}</strong>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<span>Mobile</span><strong>{{ selectedCustomer.mobileNo || '—' }}</strong>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<span>Branch</span><strong>{{ selectedCustomer.officeName || '—' }}</strong>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<span>Account</span><strong>{{ selectedCustomer.accountNo || 'No account' }}</strong>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<span>PAN on record</span>
|
||||||
|
<strong>
|
||||||
|
{{ selectedCustomer.kycPan || 'Not on record' }}
|
||||||
|
@if (selectedCustomer.kycPan) {
|
||||||
|
<span
|
||||||
|
class="cx-kyc cx-kyc--sm"
|
||||||
|
[class.cx-kyc--ok]="selectedCustomer.panVerified"
|
||||||
|
[class.cx-kyc--warn]="!selectedCustomer.panVerified"
|
||||||
|
>{{ selectedCustomer.panVerified ? 'Verified' : 'Not verified' }}</span
|
||||||
|
>
|
||||||
|
}
|
||||||
|
</strong>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
} @else {
|
||||||
|
<div class="cx-cust__walkin">
|
||||||
|
<label class="cx-field">
|
||||||
|
<span>Customer Name{{ needsPan ? ' *' : '' }}</span>
|
||||||
<input
|
<input
|
||||||
type="text"
|
type="text"
|
||||||
[value]="customerIdProof"
|
[value]="walkInName"
|
||||||
(input)="customerIdProof = $any($event.target).value"
|
(input)="walkInName = $any($event.target).value"
|
||||||
placeholder="ABCDE1234F"
|
placeholder="Full name as on ID"
|
||||||
maxlength="20"
|
maxlength="150"
|
||||||
|
autocomplete="off"
|
||||||
/>
|
/>
|
||||||
</label>
|
</label>
|
||||||
|
<label class="cx-field">
|
||||||
|
<span>Mobile</span>
|
||||||
|
<input
|
||||||
|
type="tel"
|
||||||
|
inputmode="numeric"
|
||||||
|
[value]="walkInMobile"
|
||||||
|
(input)="walkInMobile = $any($event.target).value"
|
||||||
|
placeholder="10-digit mobile number"
|
||||||
|
maxlength="15"
|
||||||
|
autocomplete="off"
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
}
|
}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<!-- PAN -->
|
||||||
|
<div
|
||||||
|
class="cx-pan"
|
||||||
|
[class.cx-pan--required]="panMissing"
|
||||||
|
[class.cx-pan--invalid]="panFormatInvalid"
|
||||||
|
[class.cx-pan--valid]="panValue && !panFormatInvalid"
|
||||||
|
>
|
||||||
|
<div class="cx-pan__head">
|
||||||
|
<mat-icon>credit_card</mat-icon>
|
||||||
|
<span class="cx-pan__label">PAN Card</span>
|
||||||
|
<span class="cx-pan__tag" [class.cx-pan__tag--on]="needsPan">{{ needsPan ? 'Mandatory' : 'Optional' }}</span>
|
||||||
|
</div>
|
||||||
|
<div class="cx-pan__input">
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
[value]="panNumber"
|
||||||
|
(input)="panNumber = $any($event.target).value.toUpperCase()"
|
||||||
|
placeholder="ABCDE1234F"
|
||||||
|
maxlength="10"
|
||||||
|
autocomplete="off"
|
||||||
|
spellcheck="false"
|
||||||
|
aria-label="PAN number"
|
||||||
|
/>
|
||||||
|
@if (panValue && !panFormatInvalid) {
|
||||||
|
<mat-icon class="cx-pan__state cx-pan__state--ok">check_circle</mat-icon>
|
||||||
|
} @else if (panFormatInvalid) {
|
||||||
|
<mat-icon class="cx-pan__state cx-pan__state--bad">error</mat-icon>
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
<div class="cx-pan__hint">
|
||||||
|
@if (panFormatInvalid) {
|
||||||
|
PAN is 5 letters, 4 digits, 1 letter (e.g. ABCDE1234F).
|
||||||
|
} @else if (panFromKyc) {
|
||||||
|
<mat-icon>link</mat-icon> Fetched from the customer's KYC record.
|
||||||
|
} @else if (panMissing) {
|
||||||
|
Required: this exchange is above ₹50,000 (Income Tax Rule 114B / RBI).
|
||||||
|
} @else {
|
||||||
|
Mandatory for exchanges above ₹50,000.
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
<div class="cx-cols">
|
<div class="cx-cols">
|
||||||
|
<!-- The cashier's count of a denomination and what this exchange does to it, updated as counts are typed. -->
|
||||||
|
<ng-template #drawerCell let-d="d">
|
||||||
|
@if (availableFor(d) === null) {
|
||||||
|
—
|
||||||
|
} @else {
|
||||||
|
In drawer <strong>{{ availableFor(d) }}</strong>
|
||||||
|
@if (netFor(d) !== 0) {
|
||||||
|
<span class="cx-after">
|
||||||
|
<mat-icon>{{ netFor(d) > 0 ? 'trending_up' : 'trending_down' }}</mat-icon>
|
||||||
|
<strong>{{ drawerAfter(d) }}</strong>
|
||||||
|
<small>({{ netFor(d) > 0 ? '+' : '' }}{{ netFor(d) }})</small>
|
||||||
|
</span>
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</ng-template>
|
||||||
|
|
||||||
<!-- RECEIVED -->
|
<!-- RECEIVED -->
|
||||||
<section class="cx-col cx-col--received">
|
<section class="cx-col cx-col--received">
|
||||||
<header class="cx-col-head">
|
<header class="cx-col-head">
|
||||||
@@ -78,7 +283,7 @@
|
|||||||
<div class="cx-subhead">Bank Notes</div>
|
<div class="cx-subhead">Bank Notes</div>
|
||||||
}
|
}
|
||||||
@for (d of notes; track d.id) {
|
@for (d of notes; track d.id) {
|
||||||
<div class="cx-row">
|
<div class="cx-row cx-row--stock">
|
||||||
<span class="cx-chip">{{ d.label }}</span>
|
<span class="cx-chip">{{ d.label }}</span>
|
||||||
<div class="cx-step">
|
<div class="cx-step">
|
||||||
<button type="button" (click)="bump('r', d, -1)" [disabled]="countFor('r', d) === 0">−</button>
|
<button type="button" (click)="bump('r', d, -1)" [disabled]="countFor('r', d) === 0">−</button>
|
||||||
@@ -90,6 +295,9 @@
|
|||||||
/>
|
/>
|
||||||
<button type="button" (click)="bump('r', d, 1)">+</button>
|
<button type="button" (click)="bump('r', d, 1)">+</button>
|
||||||
</div>
|
</div>
|
||||||
|
<span class="cx-avail" [class.cx-avail--up]="netFor(d) > 0" [class.cx-avail--down]="netFor(d) < 0">
|
||||||
|
<ng-container *ngTemplateOutlet="drawerCell; context: { d: d }"></ng-container>
|
||||||
|
</span>
|
||||||
<span class="cx-amt" [class.cx-amt--zero]="countFor('r', d) === 0"
|
<span class="cx-amt" [class.cx-amt--zero]="countFor('r', d) === 0"
|
||||||
>₹{{ subtotal('r', d) | number: '1.2-2' }}</span
|
>₹{{ subtotal('r', d) | number: '1.2-2' }}</span
|
||||||
>
|
>
|
||||||
@@ -99,7 +307,7 @@
|
|||||||
<div class="cx-subhead">Coins</div>
|
<div class="cx-subhead">Coins</div>
|
||||||
}
|
}
|
||||||
@for (d of coins; track d.id) {
|
@for (d of coins; track d.id) {
|
||||||
<div class="cx-row">
|
<div class="cx-row cx-row--stock">
|
||||||
<span class="cx-chip cx-chip--coin">{{ d.label }}</span>
|
<span class="cx-chip cx-chip--coin">{{ d.label }}</span>
|
||||||
<div class="cx-step">
|
<div class="cx-step">
|
||||||
<button type="button" (click)="bump('r', d, -1)" [disabled]="countFor('r', d) === 0">−</button>
|
<button type="button" (click)="bump('r', d, -1)" [disabled]="countFor('r', d) === 0">−</button>
|
||||||
@@ -111,6 +319,9 @@
|
|||||||
/>
|
/>
|
||||||
<button type="button" (click)="bump('r', d, 1)">+</button>
|
<button type="button" (click)="bump('r', d, 1)">+</button>
|
||||||
</div>
|
</div>
|
||||||
|
<span class="cx-avail" [class.cx-avail--up]="netFor(d) > 0" [class.cx-avail--down]="netFor(d) < 0">
|
||||||
|
<ng-container *ngTemplateOutlet="drawerCell; context: { d: d }"></ng-container>
|
||||||
|
</span>
|
||||||
<span class="cx-amt" [class.cx-amt--zero]="countFor('r', d) === 0"
|
<span class="cx-amt" [class.cx-amt--zero]="countFor('r', d) === 0"
|
||||||
>₹{{ subtotal('r', d) | number: '1.2-2' }}</span
|
>₹{{ subtotal('r', d) | number: '1.2-2' }}</span
|
||||||
>
|
>
|
||||||
@@ -131,7 +342,7 @@
|
|||||||
<div class="cx-subhead">Bank Notes</div>
|
<div class="cx-subhead">Bank Notes</div>
|
||||||
}
|
}
|
||||||
@for (d of notes; track d.id) {
|
@for (d of notes; track d.id) {
|
||||||
<div class="cx-row">
|
<div class="cx-row cx-row--stock" [class.cx-row--over]="exceedsAvailable(d)">
|
||||||
<span class="cx-chip">{{ d.label }}</span>
|
<span class="cx-chip">{{ d.label }}</span>
|
||||||
<div class="cx-step">
|
<div class="cx-step">
|
||||||
<button type="button" (click)="bump('g', d, -1)" [disabled]="countFor('g', d) === 0">−</button>
|
<button type="button" (click)="bump('g', d, -1)" [disabled]="countFor('g', d) === 0">−</button>
|
||||||
@@ -141,8 +352,16 @@
|
|||||||
[value]="countFor('g', d)"
|
[value]="countFor('g', d)"
|
||||||
(input)="onChange('g', d, $any($event.target).value)"
|
(input)="onChange('g', d, $any($event.target).value)"
|
||||||
/>
|
/>
|
||||||
<button type="button" (click)="bump('g', d, 1)">+</button>
|
<button type="button" (click)="bump('g', d, 1)" [disabled]="!canGiveMore(d)">+</button>
|
||||||
</div>
|
</div>
|
||||||
|
<span
|
||||||
|
class="cx-avail"
|
||||||
|
[class.cx-avail--up]="netFor(d) > 0"
|
||||||
|
[class.cx-avail--down]="netFor(d) < 0"
|
||||||
|
[class.cx-avail--over]="exceedsAvailable(d)"
|
||||||
|
>
|
||||||
|
<ng-container *ngTemplateOutlet="drawerCell; context: { d: d }"></ng-container>
|
||||||
|
</span>
|
||||||
<span class="cx-amt" [class.cx-amt--zero]="countFor('g', d) === 0"
|
<span class="cx-amt" [class.cx-amt--zero]="countFor('g', d) === 0"
|
||||||
>₹{{ subtotal('g', d) | number: '1.2-2' }}</span
|
>₹{{ subtotal('g', d) | number: '1.2-2' }}</span
|
||||||
>
|
>
|
||||||
@@ -152,7 +371,7 @@
|
|||||||
<div class="cx-subhead">Coins</div>
|
<div class="cx-subhead">Coins</div>
|
||||||
}
|
}
|
||||||
@for (d of coins; track d.id) {
|
@for (d of coins; track d.id) {
|
||||||
<div class="cx-row">
|
<div class="cx-row cx-row--stock" [class.cx-row--over]="exceedsAvailable(d)">
|
||||||
<span class="cx-chip cx-chip--coin">{{ d.label }}</span>
|
<span class="cx-chip cx-chip--coin">{{ d.label }}</span>
|
||||||
<div class="cx-step">
|
<div class="cx-step">
|
||||||
<button type="button" (click)="bump('g', d, -1)" [disabled]="countFor('g', d) === 0">−</button>
|
<button type="button" (click)="bump('g', d, -1)" [disabled]="countFor('g', d) === 0">−</button>
|
||||||
@@ -162,8 +381,16 @@
|
|||||||
[value]="countFor('g', d)"
|
[value]="countFor('g', d)"
|
||||||
(input)="onChange('g', d, $any($event.target).value)"
|
(input)="onChange('g', d, $any($event.target).value)"
|
||||||
/>
|
/>
|
||||||
<button type="button" (click)="bump('g', d, 1)">+</button>
|
<button type="button" (click)="bump('g', d, 1)" [disabled]="!canGiveMore(d)">+</button>
|
||||||
</div>
|
</div>
|
||||||
|
<span
|
||||||
|
class="cx-avail"
|
||||||
|
[class.cx-avail--up]="netFor(d) > 0"
|
||||||
|
[class.cx-avail--down]="netFor(d) < 0"
|
||||||
|
[class.cx-avail--over]="exceedsAvailable(d)"
|
||||||
|
>
|
||||||
|
<ng-container *ngTemplateOutlet="drawerCell; context: { d: d }"></ng-container>
|
||||||
|
</span>
|
||||||
<span class="cx-amt" [class.cx-amt--zero]="countFor('g', d) === 0"
|
<span class="cx-amt" [class.cx-amt--zero]="countFor('g', d) === 0"
|
||||||
>₹{{ subtotal('g', d) | number: '1.2-2' }}</span
|
>₹{{ subtotal('g', d) | number: '1.2-2' }}</span
|
||||||
>
|
>
|
||||||
@@ -173,6 +400,30 @@
|
|||||||
</section>
|
</section>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<!-- What this exchange does to the cashier's drawer, denomination by denomination. -->
|
||||||
|
@if (impactRows.length) {
|
||||||
|
<div class="cx-impact">
|
||||||
|
<div class="cx-impact__title"><mat-icon>inventory_2</mat-icon> Drawer after this exchange</div>
|
||||||
|
<div class="cx-impact__chips">
|
||||||
|
@for (r of impactRows; track r.id) {
|
||||||
|
<span
|
||||||
|
class="cx-impact__chip"
|
||||||
|
[class.cx-impact__chip--up]="r.net > 0"
|
||||||
|
[class.cx-impact__chip--down]="r.net < 0"
|
||||||
|
>
|
||||||
|
<b>{{ r.label }}</b>
|
||||||
|
{{ r.before }} → {{ r.after }}
|
||||||
|
<em>({{ r.net > 0 ? '+' : '' }}{{ r.net }})</em>
|
||||||
|
</span>
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
<div class="cx-impact__note">
|
||||||
|
The branch's total for each denomination moves by the same amount; the vault is not touched (Branch Ops → Branch
|
||||||
|
Cash Position).
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
|
||||||
<div class="cx-summary">
|
<div class="cx-summary">
|
||||||
<div class="cx-sum">
|
<div class="cx-sum">
|
||||||
<span>Received</span><strong>₹{{ receivedTotal | number: '1.2-2' }}</strong>
|
<span>Received</span><strong>₹{{ receivedTotal | number: '1.2-2' }}</strong>
|
||||||
@@ -213,9 +464,15 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
}
|
}
|
||||||
@if (needsIdProof && !customerIdProof.trim()) {
|
@if (panMissing) {
|
||||||
<div class="cx-warn cx-warn--pan">
|
<div class="cx-warn cx-warn--pan">
|
||||||
<mat-icon>badge</mat-icon> Enter customer PAN / Aadhaar to proceed (amount ≥ ₹50,000).
|
<mat-icon>badge</mat-icon> Enter the customer's PAN to proceed (amount above ₹50,000).
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
@if (!customerIdentified && !loadingCustomer) {
|
||||||
|
<div class="cx-warn cx-warn--pan">
|
||||||
|
<mat-icon>person_search</mat-icon>
|
||||||
|
{{ customerMode === 'existing' ? 'Search and select the customer.' : "Enter the walk-in customer's name." }}
|
||||||
</div>
|
</div>
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -73,45 +73,510 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/* ── Customer info bar ── */
|
/* ── Customer: existing client or walk-in, plus PAN ── */
|
||||||
.cx-customer-bar {
|
.cx-cust {
|
||||||
|
background: #fff;
|
||||||
|
border: 1px solid #e2e8f0;
|
||||||
|
border-radius: 14px;
|
||||||
|
box-shadow: 0 1px 2px rgb(15 23 42 / 4%);
|
||||||
|
overflow: hidden;
|
||||||
|
|
||||||
|
&__head {
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
gap: 12px;
|
justify-content: space-between;
|
||||||
padding: 10px 14px;
|
|
||||||
background: #f8fafc;
|
|
||||||
border: 1px solid #e2e8f0;
|
|
||||||
border-radius: 10px;
|
|
||||||
flex-wrap: wrap;
|
flex-wrap: wrap;
|
||||||
|
gap: 12px;
|
||||||
|
padding: 12px 16px;
|
||||||
|
background: linear-gradient(180deg, #f8fafc, #f1f5f9);
|
||||||
|
border-bottom: 1px solid #e2e8f0;
|
||||||
}
|
}
|
||||||
|
|
||||||
.cx-customer-toggle {
|
&__title {
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
|
gap: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
&__title-icon {
|
||||||
|
display: grid;
|
||||||
|
place-items: center;
|
||||||
|
width: 36px;
|
||||||
|
height: 36px;
|
||||||
|
border-radius: 10px;
|
||||||
|
background: #e6f4ee;
|
||||||
|
color: #0f4f3c;
|
||||||
|
|
||||||
|
mat-icon {
|
||||||
|
font-size: 20px;
|
||||||
|
width: 20px;
|
||||||
|
height: 20px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
&__title-text {
|
||||||
|
font-size: 14px;
|
||||||
|
font-weight: 700;
|
||||||
|
color: #0f172a;
|
||||||
|
}
|
||||||
|
|
||||||
|
&__title-sub {
|
||||||
|
font-size: 12px;
|
||||||
|
color: #64748b;
|
||||||
|
}
|
||||||
|
|
||||||
|
&__modes {
|
||||||
|
display: inline-flex;
|
||||||
|
padding: 3px;
|
||||||
|
background: #e2e8f0;
|
||||||
|
border-radius: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
&__mode {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
gap: 6px;
|
gap: 6px;
|
||||||
|
height: 32px;
|
||||||
|
padding: 0 14px;
|
||||||
|
border: 0;
|
||||||
|
border-radius: 8px;
|
||||||
|
background: transparent;
|
||||||
font-size: 13px;
|
font-size: 13px;
|
||||||
font-weight: 600;
|
font-weight: 600;
|
||||||
color: #475569;
|
color: #475569;
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
user-select: none;
|
transition:
|
||||||
|
background 0.15s ease,
|
||||||
|
color 0.15s ease;
|
||||||
|
|
||||||
mat-icon {
|
mat-icon {
|
||||||
font-size: 20px;
|
font-size: 18px;
|
||||||
height: 20px;
|
width: 18px;
|
||||||
width: 20px;
|
height: 18px;
|
||||||
|
}
|
||||||
|
|
||||||
|
&:hover {
|
||||||
|
color: #0f172a;
|
||||||
|
}
|
||||||
|
|
||||||
|
&--active,
|
||||||
|
&--active:hover {
|
||||||
|
background: #fff;
|
||||||
|
color: #0f4f3c;
|
||||||
|
box-shadow: 0 1px 3px rgb(15 23 42 / 15%);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
&__body {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: minmax(0, 1.7fr) minmax(260px, 1fr);
|
||||||
|
gap: 16px;
|
||||||
|
padding: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
&__who {
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Nothing picked yet, or loading */
|
||||||
|
&__empty {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
gap: 4px;
|
||||||
|
min-height: 150px;
|
||||||
|
padding: 20px;
|
||||||
|
text-align: center;
|
||||||
|
background: #f8fafc;
|
||||||
|
border: 1.5px dashed #cbd5e1;
|
||||||
|
border-radius: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
&__empty-icon {
|
||||||
|
font-size: 36px;
|
||||||
|
width: 36px;
|
||||||
|
height: 36px;
|
||||||
color: #94a3b8;
|
color: #94a3b8;
|
||||||
}
|
}
|
||||||
|
|
||||||
&--active {
|
&__empty-title {
|
||||||
color: #1e40af;
|
font-size: 14px;
|
||||||
|
font-weight: 600;
|
||||||
|
color: #334155;
|
||||||
|
}
|
||||||
|
|
||||||
|
&__empty-sub {
|
||||||
|
margin-bottom: 8px;
|
||||||
|
font-size: 12px;
|
||||||
|
color: #64748b;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* A picked client */
|
||||||
|
&__card {
|
||||||
|
padding: 14px;
|
||||||
|
background: #fff;
|
||||||
|
border: 1px solid #e2e8f0;
|
||||||
|
border-radius: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
&__card-top {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
&__card-id {
|
||||||
|
flex: 1 1 180px;
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
&__name {
|
||||||
|
font-size: 16px;
|
||||||
|
font-weight: 700;
|
||||||
|
color: #0f172a;
|
||||||
|
overflow-wrap: anywhere;
|
||||||
|
}
|
||||||
|
|
||||||
|
&__badges {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: 6px;
|
||||||
|
margin-top: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
&__card-actions {
|
||||||
|
display: flex;
|
||||||
|
gap: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
&__grid {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(auto-fill, minmax(150px, 1fr));
|
||||||
|
gap: 12px 16px;
|
||||||
|
margin-top: 12px;
|
||||||
|
padding-top: 12px;
|
||||||
|
border-top: 1px dashed #e2e8f0;
|
||||||
|
|
||||||
|
span {
|
||||||
|
display: block;
|
||||||
|
margin-bottom: 2px;
|
||||||
|
font-size: 11px;
|
||||||
|
color: #64748b;
|
||||||
|
}
|
||||||
|
|
||||||
|
strong {
|
||||||
|
font-size: 13px;
|
||||||
|
font-weight: 600;
|
||||||
|
color: #0f172a;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/* A walk-in, identified at the counter */
|
||||||
|
&__walkin {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: 1fr 1fr;
|
||||||
|
gap: 12px;
|
||||||
|
|
||||||
|
.cx-field {
|
||||||
|
font-size: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.cx-field input {
|
||||||
|
width: 100%;
|
||||||
|
height: 40px;
|
||||||
|
box-sizing: border-box;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.cx-field input:focus {
|
||||||
|
outline: none;
|
||||||
|
border-color: #1f7a4d;
|
||||||
|
box-shadow: 0 0 0 3px rgb(31 122 77 / 15%);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.cx-avatar {
|
||||||
|
display: grid;
|
||||||
|
place-items: center;
|
||||||
|
flex: 0 0 auto;
|
||||||
|
width: 44px;
|
||||||
|
height: 44px;
|
||||||
|
border-radius: 50%;
|
||||||
|
background: linear-gradient(135deg, #1f7a4d, #0f4f3c);
|
||||||
|
font-size: 15px;
|
||||||
|
font-weight: 700;
|
||||||
|
letter-spacing: 0.02em;
|
||||||
|
color: #fff;
|
||||||
|
|
||||||
|
&--sm {
|
||||||
|
width: 40px;
|
||||||
|
height: 40px;
|
||||||
|
font-size: 14px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/* The cashier at the counter, or why there is none */
|
||||||
|
.cx-cashier {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: 14px 20px;
|
||||||
|
padding: 12px 16px;
|
||||||
|
background: #fff;
|
||||||
|
border: 1px solid #e2e8f0;
|
||||||
|
border-left: 4px solid #1f7a4d;
|
||||||
|
border-radius: 14px;
|
||||||
|
box-shadow: 0 1px 2px rgb(15 23 42 / 4%);
|
||||||
|
|
||||||
|
&__id {
|
||||||
|
flex: 1 1 220px;
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
&__label {
|
||||||
|
font-size: 11px;
|
||||||
|
font-weight: 600;
|
||||||
|
letter-spacing: 0.04em;
|
||||||
|
text-transform: uppercase;
|
||||||
|
color: #64748b;
|
||||||
|
}
|
||||||
|
|
||||||
|
&__name {
|
||||||
|
font-size: 16px;
|
||||||
|
font-weight: 700;
|
||||||
|
color: #0f172a;
|
||||||
|
}
|
||||||
|
|
||||||
|
&__flow {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: 4px 14px;
|
||||||
|
margin-top: 2px;
|
||||||
|
font-size: 12px;
|
||||||
|
color: #475569;
|
||||||
|
|
||||||
|
span {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 2px;
|
||||||
|
}
|
||||||
|
|
||||||
mat-icon {
|
mat-icon {
|
||||||
color: #1e40af;
|
font-size: 14px;
|
||||||
|
width: 14px;
|
||||||
|
height: 14px;
|
||||||
|
color: #94a3b8;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
&__stats {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: 10px 28px;
|
||||||
|
|
||||||
|
span {
|
||||||
|
display: block;
|
||||||
|
font-size: 11px;
|
||||||
|
color: #64748b;
|
||||||
|
}
|
||||||
|
|
||||||
|
strong {
|
||||||
|
font-size: 14px;
|
||||||
|
font-weight: 600;
|
||||||
|
color: #0f172a;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
&--none {
|
||||||
|
background: #fffbeb;
|
||||||
|
border-color: #fcd34d;
|
||||||
|
border-left-color: #d97706;
|
||||||
|
|
||||||
|
mat-icon {
|
||||||
|
color: #d97706;
|
||||||
|
}
|
||||||
|
|
||||||
|
.cx-cashier__label {
|
||||||
|
font-weight: 500;
|
||||||
|
letter-spacing: 0;
|
||||||
|
text-transform: none;
|
||||||
|
color: #92400e;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
.cx-walk-in {
|
.cx-kyc {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 4px;
|
||||||
|
padding: 2px 8px;
|
||||||
|
border-radius: 999px;
|
||||||
|
font-size: 11px;
|
||||||
|
font-weight: 600;
|
||||||
|
|
||||||
|
mat-icon {
|
||||||
|
font-size: 14px;
|
||||||
|
width: 14px;
|
||||||
|
height: 14px;
|
||||||
|
}
|
||||||
|
|
||||||
|
&--ok {
|
||||||
|
background: #dcfce7;
|
||||||
|
color: #166534;
|
||||||
|
}
|
||||||
|
|
||||||
|
&--warn {
|
||||||
|
background: #fef3c7;
|
||||||
|
color: #92400e;
|
||||||
|
}
|
||||||
|
|
||||||
|
&--sm {
|
||||||
|
margin-left: 6px;
|
||||||
|
padding: 1px 6px;
|
||||||
|
font-size: 10px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.cx-chip-tag {
|
||||||
|
padding: 2px 8px;
|
||||||
|
border-radius: 6px;
|
||||||
|
background: #eef2ff;
|
||||||
|
font-size: 11px;
|
||||||
|
font-weight: 600;
|
||||||
|
color: #3730a3;
|
||||||
|
}
|
||||||
|
|
||||||
|
.cx-link-btn {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 4px;
|
||||||
|
height: 30px;
|
||||||
|
padding: 0 10px;
|
||||||
|
border: 0;
|
||||||
|
border-radius: 8px;
|
||||||
|
background: transparent;
|
||||||
|
font-size: 12px;
|
||||||
|
font-weight: 600;
|
||||||
|
color: #1e40af;
|
||||||
|
cursor: pointer;
|
||||||
|
|
||||||
|
mat-icon {
|
||||||
|
font-size: 16px;
|
||||||
|
width: 16px;
|
||||||
|
height: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
&:hover {
|
||||||
|
background: #eff6ff;
|
||||||
|
}
|
||||||
|
|
||||||
|
&--danger {
|
||||||
|
color: #b91c1c;
|
||||||
|
|
||||||
|
&:hover {
|
||||||
|
background: #fef2f2;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/* PAN: neutral until needed, amber when required and missing, red when malformed, green when valid */
|
||||||
|
.cx-pan {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-self: start;
|
||||||
|
gap: 8px;
|
||||||
|
padding: 14px;
|
||||||
|
background: #f8fafc;
|
||||||
|
border: 1px solid #e2e8f0;
|
||||||
|
border-radius: 12px;
|
||||||
|
|
||||||
|
&__head {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
|
||||||
|
mat-icon {
|
||||||
|
font-size: 20px;
|
||||||
|
width: 20px;
|
||||||
|
height: 20px;
|
||||||
|
color: #475569;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
&__label {
|
||||||
|
font-size: 13px;
|
||||||
|
font-weight: 700;
|
||||||
|
color: #0f172a;
|
||||||
|
}
|
||||||
|
|
||||||
|
&__tag {
|
||||||
|
margin-left: auto;
|
||||||
|
padding: 2px 8px;
|
||||||
|
border-radius: 999px;
|
||||||
|
background: #e2e8f0;
|
||||||
|
font-size: 11px;
|
||||||
|
font-weight: 600;
|
||||||
|
color: #475569;
|
||||||
|
|
||||||
|
&--on {
|
||||||
|
background: #fef3c7;
|
||||||
|
color: #92400e;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
&__input {
|
||||||
|
position: relative;
|
||||||
|
|
||||||
|
input {
|
||||||
|
width: 100%;
|
||||||
|
height: 44px;
|
||||||
|
box-sizing: border-box;
|
||||||
|
padding: 0 40px 0 12px;
|
||||||
|
background: #fff;
|
||||||
|
border: 1.5px solid #cbd5e1;
|
||||||
|
border-radius: 10px;
|
||||||
|
font-family: ui-monospace, sfmono-regular, menlo, consolas, monospace;
|
||||||
|
font-size: 18px;
|
||||||
|
font-weight: 600;
|
||||||
|
letter-spacing: 0.18em;
|
||||||
|
text-transform: uppercase;
|
||||||
|
color: #0f172a;
|
||||||
|
|
||||||
|
&::placeholder {
|
||||||
|
font-weight: 500;
|
||||||
|
color: #cbd5e1;
|
||||||
|
}
|
||||||
|
|
||||||
|
&:focus {
|
||||||
|
outline: none;
|
||||||
|
border-color: #1f7a4d;
|
||||||
|
box-shadow: 0 0 0 3px rgb(31 122 77 / 15%);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
&__state {
|
||||||
|
position: absolute;
|
||||||
|
top: 50%;
|
||||||
|
right: 10px;
|
||||||
|
transform: translateY(-50%);
|
||||||
|
font-size: 22px;
|
||||||
|
width: 22px;
|
||||||
|
height: 22px;
|
||||||
|
|
||||||
|
&--ok {
|
||||||
|
color: #16a34a;
|
||||||
|
}
|
||||||
|
|
||||||
|
&--bad {
|
||||||
|
color: #dc2626;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
&__hint {
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
gap: 4px;
|
gap: 4px;
|
||||||
@@ -119,53 +584,73 @@
|
|||||||
color: #64748b;
|
color: #64748b;
|
||||||
|
|
||||||
mat-icon {
|
mat-icon {
|
||||||
font-size: 16px;
|
font-size: 14px;
|
||||||
height: 16px;
|
width: 14px;
|
||||||
width: 16px;
|
height: 14px;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
.cx-pan-required {
|
&--required {
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
gap: 6px;
|
|
||||||
padding: 4px 10px;
|
|
||||||
background: #fffbeb;
|
background: #fffbeb;
|
||||||
border: 1px solid #fcd34d;
|
|
||||||
border-radius: 6px;
|
|
||||||
font-size: 12px;
|
|
||||||
font-weight: 600;
|
|
||||||
color: #92400e;
|
|
||||||
|
|
||||||
mat-icon {
|
|
||||||
font-size: 16px;
|
|
||||||
height: 16px;
|
|
||||||
width: 16px;
|
|
||||||
color: #d97706;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
.cx-field--inline {
|
|
||||||
flex-direction: row;
|
|
||||||
align-items: center;
|
|
||||||
gap: 8px;
|
|
||||||
|
|
||||||
span {
|
|
||||||
white-space: nowrap;
|
|
||||||
}
|
|
||||||
|
|
||||||
input {
|
|
||||||
width: 180px;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
.cx-field--pan input {
|
|
||||||
width: 160px;
|
|
||||||
border-color: #fcd34d;
|
border-color: #fcd34d;
|
||||||
|
|
||||||
&:focus {
|
.cx-pan__input input {
|
||||||
outline: none;
|
border-color: #f59e0b;
|
||||||
border-color: #d97706;
|
}
|
||||||
|
|
||||||
|
.cx-pan__hint {
|
||||||
|
font-weight: 600;
|
||||||
|
color: #92400e;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
&--invalid {
|
||||||
|
background: #fef2f2;
|
||||||
|
border-color: #fecaca;
|
||||||
|
|
||||||
|
.cx-pan__input input {
|
||||||
|
border-color: #ef4444;
|
||||||
|
}
|
||||||
|
|
||||||
|
.cx-pan__hint {
|
||||||
|
font-weight: 600;
|
||||||
|
color: #b91c1c;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
&--valid {
|
||||||
|
background: #f0fdf4;
|
||||||
|
border-color: #bbf7d0;
|
||||||
|
|
||||||
|
.cx-pan__input input {
|
||||||
|
border-color: #22c55e;
|
||||||
|
}
|
||||||
|
|
||||||
|
.cx-pan__hint {
|
||||||
|
color: #166534;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.cx-spin {
|
||||||
|
animation: cx-spin 1s linear infinite;
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes cx-spin {
|
||||||
|
to {
|
||||||
|
transform: rotate(360deg);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (width <= 900px) {
|
||||||
|
.cx-cust__body {
|
||||||
|
grid-template-columns: 1fr;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (width <= 600px) {
|
||||||
|
.cx-cust__walkin {
|
||||||
|
grid-template-columns: 1fr;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -345,6 +830,129 @@
|
|||||||
border-bottom: 1px dotted #eef2f7;
|
border-bottom: 1px dotted #eef2f7;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* Both sides: the cashier's count of each denomination sits between the counter and the amount. */
|
||||||
|
.cx-row--stock {
|
||||||
|
grid-template-columns: 64px 120px 1fr 96px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.cx-row--over {
|
||||||
|
background: #fef2f2;
|
||||||
|
}
|
||||||
|
|
||||||
|
.cx-avail {
|
||||||
|
padding-left: 6px;
|
||||||
|
font-size: 12px;
|
||||||
|
color: #64748b;
|
||||||
|
|
||||||
|
strong {
|
||||||
|
font-weight: 700;
|
||||||
|
color: #0f172a;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* the count after this exchange: green when it goes up, red when it goes down */
|
||||||
|
.cx-after {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 3px;
|
||||||
|
margin-left: 6px;
|
||||||
|
font-weight: 600;
|
||||||
|
|
||||||
|
mat-icon {
|
||||||
|
font-size: 16px;
|
||||||
|
width: 16px;
|
||||||
|
height: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
small {
|
||||||
|
font-size: 11px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
&--up .cx-after,
|
||||||
|
&--up .cx-after strong {
|
||||||
|
color: #15803d;
|
||||||
|
}
|
||||||
|
|
||||||
|
&--down .cx-after,
|
||||||
|
&--down .cx-after strong {
|
||||||
|
color: #b45309;
|
||||||
|
}
|
||||||
|
|
||||||
|
&--over,
|
||||||
|
&--over strong,
|
||||||
|
&--over .cx-after,
|
||||||
|
&--over .cx-after strong {
|
||||||
|
color: #b91c1c;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/* The drawer after the whole exchange, one chip per denomination it changes */
|
||||||
|
.cx-impact {
|
||||||
|
padding: 12px 14px;
|
||||||
|
background: #fff;
|
||||||
|
border: 1px solid #e2e8f0;
|
||||||
|
border-radius: 12px;
|
||||||
|
|
||||||
|
&__title {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 6px;
|
||||||
|
margin-bottom: 8px;
|
||||||
|
font-size: 13px;
|
||||||
|
font-weight: 700;
|
||||||
|
color: #0f172a;
|
||||||
|
|
||||||
|
mat-icon {
|
||||||
|
font-size: 18px;
|
||||||
|
width: 18px;
|
||||||
|
height: 18px;
|
||||||
|
color: #475569;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
&__chips {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
&__chip {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 6px;
|
||||||
|
padding: 5px 10px;
|
||||||
|
border-radius: 8px;
|
||||||
|
background: #f1f5f9;
|
||||||
|
font-size: 12px;
|
||||||
|
color: #334155;
|
||||||
|
|
||||||
|
b {
|
||||||
|
font-weight: 700;
|
||||||
|
}
|
||||||
|
|
||||||
|
em {
|
||||||
|
font-style: normal;
|
||||||
|
font-weight: 700;
|
||||||
|
}
|
||||||
|
|
||||||
|
&--up {
|
||||||
|
background: #dcfce7;
|
||||||
|
color: #166534;
|
||||||
|
}
|
||||||
|
|
||||||
|
&--down {
|
||||||
|
background: #fef3c7;
|
||||||
|
color: #92400e;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
&__note {
|
||||||
|
margin-top: 8px;
|
||||||
|
font-size: 11px;
|
||||||
|
color: #64748b;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
.cx-chip {
|
.cx-chip {
|
||||||
display: inline-flex;
|
display: inline-flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
|
|||||||
@@ -11,7 +11,16 @@ import { CommonModule } from '@angular/common';
|
|||||||
import { FormsModule } from '@angular/forms';
|
import { FormsModule } from '@angular/forms';
|
||||||
import { MatIconModule } from '@angular/material/icon';
|
import { MatIconModule } from '@angular/material/icon';
|
||||||
import { MatSnackBar, MatSnackBarModule } from '@angular/material/snack-bar';
|
import { MatSnackBar, MatSnackBarModule } from '@angular/material/snack-bar';
|
||||||
|
import { MatDialog, MatDialogModule } from '@angular/material/dialog';
|
||||||
|
import { forkJoin, of } from 'rxjs';
|
||||||
|
import { catchError } from 'rxjs/operators';
|
||||||
import { CashierService } from '../cashier.service';
|
import { CashierService } from '../cashier.service';
|
||||||
|
import { ClientsService } from 'app/clients/clients.service';
|
||||||
|
import {
|
||||||
|
CustomerSearchDialogComponent,
|
||||||
|
CustomerSearchResult
|
||||||
|
} from 'app/shared/customer-search-dialog/customer-search-dialog.component';
|
||||||
|
import { businessToday } from 'app/core/utils/business-today';
|
||||||
|
|
||||||
interface Denom {
|
interface Denom {
|
||||||
id: number;
|
id: number;
|
||||||
@@ -20,6 +29,47 @@ interface Denom {
|
|||||||
label: string;
|
label: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** An existing client picked through Customer Search, with what the counter needs to identify them. */
|
||||||
|
interface SelectedCustomer {
|
||||||
|
clientId: number;
|
||||||
|
displayName: string;
|
||||||
|
cifNumber: string | null;
|
||||||
|
mobileNo: string | null;
|
||||||
|
officeName: string | null;
|
||||||
|
accountNo: string | null;
|
||||||
|
accountType: string | null;
|
||||||
|
/** PAN on the client's KYC record, if any. */
|
||||||
|
kycPan: string | null;
|
||||||
|
panVerified: boolean;
|
||||||
|
kycStatus: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The cashier at the counter: the one who takes the customer's notes and hands over the change. */
|
||||||
|
interface CashierInfo {
|
||||||
|
cashierId: number;
|
||||||
|
name: string;
|
||||||
|
tellerName: string | null;
|
||||||
|
officeName: string | null;
|
||||||
|
/** Cash the vault allocated to them for the day, once known. */
|
||||||
|
allocatedAmount: number | null;
|
||||||
|
/** Cash they hold now. */
|
||||||
|
totalAmount: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Same rule as ClientKycApiConstants.PAN_REGEX on the server: 5 letters, 4 digits, 1 letter. */
|
||||||
|
const PAN_PATTERN = /^[A-Z]{5}[0-9]{4}[A-Z]$/;
|
||||||
|
|
||||||
|
/** Income Tax Rule 114B / RBI: a cash exchange above this needs the customer's PAN. */
|
||||||
|
const PAN_MANDATORY_ABOVE = 50000;
|
||||||
|
|
||||||
|
const KYC_STATUS: Record<number, string> = {
|
||||||
|
100: 'Pending',
|
||||||
|
200: 'In Progress',
|
||||||
|
300: 'Verified',
|
||||||
|
400: 'Rejected',
|
||||||
|
500: 'Expired'
|
||||||
|
};
|
||||||
|
|
||||||
/** Cashier cash-exchange entry: customer gives some denominations and takes equal-value change (net 0). */
|
/** Cashier cash-exchange entry: customer gives some denominations and takes equal-value change (net 0). */
|
||||||
@Component({
|
@Component({
|
||||||
selector: 'mifosx-cash-exchange-entry',
|
selector: 'mifosx-cash-exchange-entry',
|
||||||
@@ -30,11 +80,14 @@ interface Denom {
|
|||||||
CommonModule,
|
CommonModule,
|
||||||
FormsModule,
|
FormsModule,
|
||||||
MatIconModule,
|
MatIconModule,
|
||||||
MatSnackBarModule
|
MatSnackBarModule,
|
||||||
|
MatDialogModule
|
||||||
]
|
]
|
||||||
})
|
})
|
||||||
export class CashExchangeEntryComponent implements OnInit {
|
export class CashExchangeEntryComponent implements OnInit {
|
||||||
private service = inject(CashierService);
|
private service = inject(CashierService);
|
||||||
|
private clientsService = inject(ClientsService);
|
||||||
|
private dialog = inject(MatDialog);
|
||||||
private snackBar = inject(MatSnackBar);
|
private snackBar = inject(MatSnackBar);
|
||||||
|
|
||||||
notes: Denom[] = [];
|
notes: Denom[] = [];
|
||||||
@@ -46,11 +99,17 @@ export class CashExchangeEntryComponent implements OnInit {
|
|||||||
saving = false;
|
saving = false;
|
||||||
|
|
||||||
// ── Customer info ──
|
// ── Customer info ──
|
||||||
isAccountHolder = false;
|
/** An existing client found through Customer Search, or a walk-in identified at the counter. */
|
||||||
accountNo = '';
|
customerMode: 'existing' | 'walkin' = 'walkin';
|
||||||
customerIdProof = '';
|
selectedCustomer: SelectedCustomer | null = null;
|
||||||
|
loadingCustomer = false;
|
||||||
|
walkInName = '';
|
||||||
|
walkInMobile = '';
|
||||||
|
panNumber = '';
|
||||||
|
|
||||||
// ── Cashier position (for denomination overdraft check) ──
|
// ── Cashier at the counter, and their position (for the denomination overdraft check) ──
|
||||||
|
/** The cashier linked to the logged-in user; null when the login is not a cashier's. */
|
||||||
|
cashier: CashierInfo | null = null;
|
||||||
private availableMap = new Map<number, number>();
|
private availableMap = new Map<number, number>();
|
||||||
positionLoaded = false;
|
positionLoaded = false;
|
||||||
|
|
||||||
@@ -63,7 +122,7 @@ export class CashExchangeEntryComponent implements OnInit {
|
|||||||
}
|
}
|
||||||
|
|
||||||
ngOnInit(): void {
|
ngOnInit(): void {
|
||||||
const d = new Date();
|
const d = businessToday();
|
||||||
this.exchangeDate = `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, '0')}-${String(d.getDate()).padStart(2, '0')}`;
|
this.exchangeDate = `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, '0')}-${String(d.getDate()).padStart(2, '0')}`;
|
||||||
this.service.getActiveDenominations().subscribe({
|
this.service.getActiveDenominations().subscribe({
|
||||||
next: (list) => {
|
next: (list) => {
|
||||||
@@ -87,7 +146,19 @@ export class CashExchangeEntryComponent implements OnInit {
|
|||||||
((res?.denominations || []) as any[]).forEach((d: any) => {
|
((res?.denominations || []) as any[]).forEach((d: any) => {
|
||||||
if (d.denominationId) this.availableMap.set(Number(d.denominationId), Number(d.count ?? 0));
|
if (d.denominationId) this.availableMap.set(Number(d.denominationId), Number(d.count ?? 0));
|
||||||
});
|
});
|
||||||
|
const cashierId = res?.cashierId ? Number(res.cashierId) : null;
|
||||||
|
this.cashier = cashierId
|
||||||
|
? {
|
||||||
|
cashierId,
|
||||||
|
name: res.cashierName || '',
|
||||||
|
tellerName: null,
|
||||||
|
officeName: null,
|
||||||
|
allocatedAmount: null,
|
||||||
|
totalAmount: Number(res.totalAmount ?? 0)
|
||||||
|
}
|
||||||
|
: null;
|
||||||
this.positionLoaded = true;
|
this.positionLoaded = true;
|
||||||
|
if (cashierId) this.loadCashierDetails(cashierId);
|
||||||
},
|
},
|
||||||
error: () => {
|
error: () => {
|
||||||
this.positionLoaded = true;
|
this.positionLoaded = true;
|
||||||
@@ -95,6 +166,31 @@ export class CashExchangeEntryComponent implements OnInit {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** The counter, branch and today's allocation; the card still shows name and cash in hand without them. */
|
||||||
|
private loadCashierDetails(cashierId: number): void {
|
||||||
|
this.service
|
||||||
|
.getInterCashierCashierSummary(cashierId, this.exchangeDate)
|
||||||
|
.pipe(catchError(() => of(null)))
|
||||||
|
.subscribe((r: any) => {
|
||||||
|
if (!r || this.cashier?.cashierId !== cashierId) return;
|
||||||
|
this.cashier = {
|
||||||
|
...this.cashier,
|
||||||
|
tellerName: r.tellerName || null,
|
||||||
|
officeName: r.officeName || null,
|
||||||
|
allocatedAmount: Number(r.allocatedAmount ?? 0)
|
||||||
|
};
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Up to two letters for the cashier's avatar. */
|
||||||
|
get cashierInitials(): string {
|
||||||
|
const words = (this.cashier?.name || '').trim().split(/\s+/).filter(Boolean);
|
||||||
|
if (words.length === 0) return '?';
|
||||||
|
const first = words[0].charAt(0);
|
||||||
|
const last = words.length > 1 ? words[words.length - 1].charAt(0) : '';
|
||||||
|
return (first + last).toUpperCase();
|
||||||
|
}
|
||||||
|
|
||||||
countFor(side: 'r' | 'g', d: Denom): number {
|
countFor(side: 'r' | 'g', d: Denom): number {
|
||||||
return (side === 'r' ? this.receivedCounts : this.givenCounts)[d.id] || 0;
|
return (side === 'r' ? this.receivedCounts : this.givenCounts)[d.id] || 0;
|
||||||
}
|
}
|
||||||
@@ -128,14 +224,102 @@ export class CashExchangeEntryComponent implements OnInit {
|
|||||||
return this.receivedTotal > 0 && this.receivedTotal === this.givenTotal;
|
return this.receivedTotal > 0 && this.receivedTotal === this.givenTotal;
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Above ₹50,000 PAN/ID is mandatory per RBI guideline */
|
/** Above ₹50,000 the customer's PAN is mandatory (Income Tax Rule 114B, followed under RBI guidelines). */
|
||||||
get needsIdProof(): boolean {
|
get needsPan(): boolean {
|
||||||
return this.receivedTotal >= 50000;
|
return this.receivedTotal > PAN_MANDATORY_ABOVE;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Up to two letters for the avatar: first and last word of the customer's name. */
|
||||||
|
get customerInitials(): string {
|
||||||
|
const words = (this.selectedCustomer?.displayName || '').trim().split(/\s+/).filter(Boolean);
|
||||||
|
if (words.length === 0) return '?';
|
||||||
|
const first = words[0].charAt(0);
|
||||||
|
const last = words.length > 1 ? words[words.length - 1].charAt(0) : '';
|
||||||
|
return (first + last).toUpperCase();
|
||||||
|
}
|
||||||
|
|
||||||
|
get panValue(): string {
|
||||||
|
return this.panNumber.trim().toUpperCase();
|
||||||
|
}
|
||||||
|
|
||||||
|
/** A PAN that is entered must be well-formed, required or not. */
|
||||||
|
get panFormatInvalid(): boolean {
|
||||||
|
return !!this.panValue && !PAN_PATTERN.test(this.panValue);
|
||||||
|
}
|
||||||
|
|
||||||
|
get panMissing(): boolean {
|
||||||
|
return this.needsPan && !this.panValue;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The PAN comes from the client's KYC record, so the cashier confirms it rather than types it. */
|
||||||
|
get panFromKyc(): boolean {
|
||||||
|
return !!this.selectedCustomer?.kycPan && this.panValue === this.selectedCustomer.kycPan.toUpperCase();
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Who the customer is has to be known: a picked client, or a walk-in's name once PAN is required. */
|
||||||
|
get customerIdentified(): boolean {
|
||||||
|
if (this.customerMode === 'existing') return !!this.selectedCustomer;
|
||||||
|
return !this.needsPan || this.walkInName.trim().length > 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** A login with no cashier behind it has no drawer to take notes into or give change from. */
|
||||||
|
get noCashier(): boolean {
|
||||||
|
return this.positionLoaded && !this.cashier;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** How many of the denomination the cashier holds, or null while that is not known (loading, or no cashier). */
|
||||||
|
availableFor(d: Denom): number | null {
|
||||||
|
if (!this.positionLoaded || !this.cashier) return null;
|
||||||
|
return this.availableMap.get(d.id) ?? 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The cashier is giving more of this denomination than they hold. */
|
||||||
|
exceedsAvailable(d: Denom): boolean {
|
||||||
|
const available = this.availableFor(d);
|
||||||
|
return available !== null && this.countFor('g', d) > available;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The + on the given side stops at what the cashier holds; a larger typed number is still caught and shown. */
|
||||||
|
canGiveMore(d: Denom): boolean {
|
||||||
|
const available = this.availableFor(d);
|
||||||
|
return available === null || this.countFor('g', d) < available;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** What this exchange does to the cashier's count of the denomination: notes taken in less notes given out. */
|
||||||
|
netFor(d: Denom): number {
|
||||||
|
return this.countFor('r', d) - this.countFor('g', d);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The cashier's count of the denomination once this exchange is recorded, or null while it is not known. */
|
||||||
|
drawerAfter(d: Denom): number | null {
|
||||||
|
const available = this.availableFor(d);
|
||||||
|
return available === null ? null : available + this.netFor(d);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The denominations this exchange changes, with the drawer's count before and after, so the effect shows
|
||||||
|
* while the counts are being typed. The branch's total for each denomination moves by the same amount, as
|
||||||
|
* the customer's notes go into the drawer and the change comes out of it; the vault is not involved.
|
||||||
|
*/
|
||||||
|
get impactRows(): Array<{ id: number; label: string; kind: string; net: number; before: number; after: number }> {
|
||||||
|
if (!this.positionLoaded || !this.cashier) return [];
|
||||||
|
return this.denoms
|
||||||
|
.filter((d) => this.netFor(d) !== 0)
|
||||||
|
.map((d) => ({
|
||||||
|
id: d.id,
|
||||||
|
label: d.label,
|
||||||
|
kind: d.kind,
|
||||||
|
net: this.netFor(d),
|
||||||
|
before: this.availableFor(d) ?? 0,
|
||||||
|
after: this.drawerAfter(d) ?? 0
|
||||||
|
}));
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Denominations where cashier is giving more than currently available. */
|
/** Denominations where cashier is giving more than currently available. */
|
||||||
get givenOverdraftItems(): Array<{ label: string; available: number; requested: number }> {
|
get givenOverdraftItems(): Array<{ label: string; available: number; requested: number }> {
|
||||||
if (!this.positionLoaded) return [];
|
// Without a cashier every denomination would read as "0 available"; the missing cashier is the
|
||||||
|
// thing to tell the user, not a stock shortage.
|
||||||
|
if (!this.positionLoaded || !this.cashier) return [];
|
||||||
return this.denoms
|
return this.denoms
|
||||||
.map((d) => ({ label: d.label, available: this.availableMap.get(d.id) ?? 0, requested: this.countFor('g', d) }))
|
.map((d) => ({ label: d.label, available: this.availableMap.get(d.id) ?? 0, requested: this.countFor('g', d) }))
|
||||||
.filter((item) => item.requested > 0 && item.requested > item.available);
|
.filter((item) => item.requested > 0 && item.requested > item.available);
|
||||||
@@ -146,21 +330,77 @@ export class CashExchangeEntryComponent implements OnInit {
|
|||||||
}
|
}
|
||||||
|
|
||||||
get valid(): boolean {
|
get valid(): boolean {
|
||||||
return this.matches && (!this.needsIdProof || this.customerIdProof.trim().length > 0) && !this.hasGivenOverdraft;
|
return (
|
||||||
|
this.matches &&
|
||||||
|
!!this.cashier &&
|
||||||
|
!this.hasGivenOverdraft &&
|
||||||
|
!this.panFormatInvalid &&
|
||||||
|
!this.panMissing &&
|
||||||
|
this.customerIdentified &&
|
||||||
|
!this.loadingCustomer
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
toggleAccountHolder(): void {
|
setCustomerMode(mode: 'existing' | 'walkin'): void {
|
||||||
this.isAccountHolder = !this.isAccountHolder;
|
if (this.customerMode === mode) return;
|
||||||
if (!this.isAccountHolder) this.accountNo = '';
|
this.customerMode = mode;
|
||||||
|
this.selectedCustomer = null;
|
||||||
|
this.walkInName = '';
|
||||||
|
this.walkInMobile = '';
|
||||||
|
this.panNumber = '';
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Pick an existing client through Customer Search, then load their details and KYC (PAN). */
|
||||||
|
openCustomerSearch(): void {
|
||||||
|
this.dialog
|
||||||
|
// An existing customer without an account is still a known customer, so the account is not required.
|
||||||
|
.open(CustomerSearchDialogComponent, { width: '960px', maxWidth: '95vw', data: { requireAccount: false } })
|
||||||
|
.afterClosed()
|
||||||
|
.subscribe((result: CustomerSearchResult | undefined) => {
|
||||||
|
if (!result?.clientId) return;
|
||||||
|
this.loadCustomer(result);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
private loadCustomer(result: CustomerSearchResult): void {
|
||||||
|
this.loadingCustomer = true;
|
||||||
|
const id = String(result.clientId);
|
||||||
|
forkJoin({
|
||||||
|
client: this.clientsService.getClientData(id, true).pipe(catchError(() => of(null))),
|
||||||
|
kyc: this.clientsService.getClientKyc(id, true).pipe(catchError(() => of(null)))
|
||||||
|
}).subscribe(({ client, kyc }: { client: any; kyc: any }) => {
|
||||||
|
const kycPan: string | null = kyc?.panNumber || result.panNumber || null;
|
||||||
|
this.selectedCustomer = {
|
||||||
|
clientId: result.clientId,
|
||||||
|
displayName: client?.displayName || result.displayName,
|
||||||
|
cifNumber: client?.cifNumber || null,
|
||||||
|
mobileNo: client?.mobileNo || result.mobileNo || null,
|
||||||
|
officeName: client?.officeName || result.officeName || null,
|
||||||
|
accountNo: result.accountNo || null,
|
||||||
|
accountType: result.accountType || null,
|
||||||
|
kycPan,
|
||||||
|
panVerified: !!kyc?.panVerified,
|
||||||
|
kycStatus: KYC_STATUS[Number(kyc?.kycStatus?.id ?? kyc?.kycStatus)] || 'Not on record'
|
||||||
|
};
|
||||||
|
this.panNumber = kycPan ? kycPan.toUpperCase() : '';
|
||||||
|
this.loadingCustomer = false;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
clearCustomer(): void {
|
||||||
|
this.selectedCustomer = null;
|
||||||
|
this.panNumber = '';
|
||||||
}
|
}
|
||||||
|
|
||||||
clear(): void {
|
clear(): void {
|
||||||
this.receivedCounts = {};
|
this.receivedCounts = {};
|
||||||
this.givenCounts = {};
|
this.givenCounts = {};
|
||||||
this.note = '';
|
this.note = '';
|
||||||
this.accountNo = '';
|
this.customerMode = 'walkin';
|
||||||
this.customerIdProof = '';
|
this.selectedCustomer = null;
|
||||||
this.isAccountHolder = false;
|
this.walkInName = '';
|
||||||
|
this.walkInMobile = '';
|
||||||
|
this.panNumber = '';
|
||||||
}
|
}
|
||||||
|
|
||||||
submit(): void {
|
submit(): void {
|
||||||
@@ -170,11 +410,17 @@ export class CashExchangeEntryComponent implements OnInit {
|
|||||||
this.denoms
|
this.denoms
|
||||||
.filter((d) => this.countFor(side, d) > 0)
|
.filter((d) => this.countFor(side, d) > 0)
|
||||||
.map((d) => ({ denominationId: d.id, denominationValue: d.value, count: this.countFor(side, d) }));
|
.map((d) => ({ denominationId: d.id, denominationValue: d.value, count: this.countFor(side, d) }));
|
||||||
|
const customer = this.customerMode === 'existing' ? this.selectedCustomer : null;
|
||||||
|
const pan = this.panValue || null;
|
||||||
const payload: any = {
|
const payload: any = {
|
||||||
exchangeDate: this.exchangeDate,
|
exchangeDate: this.exchangeDate,
|
||||||
note: this.note || null,
|
note: this.note || null,
|
||||||
customerIdProof: this.customerIdProof.trim() || null,
|
clientId: customer?.clientId ?? null,
|
||||||
accountNo: this.isAccountHolder && this.accountNo.trim() ? this.accountNo.trim() : null,
|
accountNo: customer?.accountNo ?? null,
|
||||||
|
customerName: customer ? customer.displayName : this.walkInName.trim() || null,
|
||||||
|
customerMobile: customer ? customer.mobileNo : this.walkInMobile.trim() || null,
|
||||||
|
panNumber: pan,
|
||||||
|
customerIdProof: pan,
|
||||||
received: build('r'),
|
received: build('r'),
|
||||||
given: build('g')
|
given: build('g')
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -11,25 +11,11 @@
|
|||||||
<div>
|
<div>
|
||||||
<div class="pg-eyebrow">CASHIER · HISTORY</div>
|
<div class="pg-eyebrow">CASHIER · HISTORY</div>
|
||||||
<h1 class="pg-title">Cash Movement History</h1>
|
<h1 class="pg-title">Cash Movement History</h1>
|
||||||
<p class="pg-sub">All transfers, settlements, and inter-cashier movements. Reverse any active transaction.</p>
|
<p class="pg-sub">All transfers, settlements, inter-cashier movements and cash exchanges.</p>
|
||||||
</div>
|
</div>
|
||||||
<button type="button" class="pg-btn pg-btn--ghost" (click)="load()"><mat-icon>refresh</mat-icon> Refresh</button>
|
<button type="button" class="pg-btn pg-btn--ghost" (click)="load()"><mat-icon>refresh</mat-icon> Refresh</button>
|
||||||
</header>
|
</header>
|
||||||
|
|
||||||
<!-- Reversal confirm panel -->
|
|
||||||
@if (reversingId !== null) {
|
|
||||||
<div class="reverse-panel">
|
|
||||||
<mat-icon class="reverse-icon">warning</mat-icon>
|
|
||||||
<span
|
|
||||||
>Reverse <strong>#{{ reversingId }}</strong
|
|
||||||
>? This cannot be undone.</span
|
|
||||||
>
|
|
||||||
<input class="reverse-reason" type="text" [(ngModel)]="reverseReason" placeholder="Reason (optional)" />
|
|
||||||
<button type="button" class="pg-btn pg-btn--danger" (click)="confirmReverse()">Confirm Reversal</button>
|
|
||||||
<button type="button" class="pg-btn pg-btn--ghost" (click)="cancelReverse()">Cancel</button>
|
|
||||||
</div>
|
|
||||||
}
|
|
||||||
|
|
||||||
@if (loading) {
|
@if (loading) {
|
||||||
<div class="pg-empty">Loading…</div>
|
<div class="pg-empty">Loading…</div>
|
||||||
}
|
}
|
||||||
@@ -134,11 +120,6 @@
|
|||||||
>
|
>
|
||||||
<mat-icon>visibility</mat-icon>
|
<mat-icon>visibility</mat-icon>
|
||||||
</button>
|
</button>
|
||||||
@if (!r.isReversed) {
|
|
||||||
<button type="button" class="pg-btn pg-btn--ghost btn-sm" title="Reverse" (click)="startReverse(r)">
|
|
||||||
<mat-icon>undo</mat-icon>
|
|
||||||
</button>
|
|
||||||
}
|
|
||||||
</div>
|
</div>
|
||||||
</td>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
@@ -187,6 +168,12 @@
|
|||||||
<span class="detail-meta__label">Date</span>
|
<span class="detail-meta__label">Date</span>
|
||||||
<span class="detail-meta__val">{{ detail.row.date }}</span>
|
<span class="detail-meta__val">{{ detail.row.date }}</span>
|
||||||
</div>
|
</div>
|
||||||
|
@if (detail.row.type === 'exchange') {
|
||||||
|
<div class="detail-meta__item">
|
||||||
|
<span class="detail-meta__label">Customer PAN</span>
|
||||||
|
<span class="detail-meta__val">{{ detail.row.panNumber || '—' }}</span>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
<div class="detail-meta__item">
|
<div class="detail-meta__item">
|
||||||
<span class="detail-meta__label">Total Amount</span>
|
<span class="detail-meta__label">Total Amount</span>
|
||||||
<span class="detail-meta__val detail-meta__val--amount">₹{{ detail.row.amount | number: '1.2-2' }}</span>
|
<span class="detail-meta__val detail-meta__val--amount">₹{{ detail.row.amount | number: '1.2-2' }}</span>
|
||||||
|
|||||||
@@ -47,38 +47,6 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
.reverse-panel {
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
gap: 12px;
|
|
||||||
padding: 12px 16px;
|
|
||||||
background: #fffbeb;
|
|
||||||
border: 1px solid #fcd34d;
|
|
||||||
border-radius: 10px;
|
|
||||||
flex-wrap: wrap;
|
|
||||||
font-size: 13px;
|
|
||||||
color: #92400e;
|
|
||||||
|
|
||||||
.reverse-icon {
|
|
||||||
color: #f59e0b;
|
|
||||||
font-size: 20px;
|
|
||||||
height: 20px;
|
|
||||||
width: 20px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.reverse-reason {
|
|
||||||
flex: 1 1 200px;
|
|
||||||
height: 34px;
|
|
||||||
padding: 0 10px;
|
|
||||||
border: 1px solid #fcd34d;
|
|
||||||
border-radius: 7px;
|
|
||||||
font-size: 13px;
|
|
||||||
font-family: inherit;
|
|
||||||
background: #fff;
|
|
||||||
color: #1a1a1a;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/* ── Action buttons cell ── */
|
/* ── Action buttons cell ── */
|
||||||
.action-btns {
|
.action-btns {
|
||||||
display: flex;
|
display: flex;
|
||||||
|
|||||||
@@ -8,9 +8,7 @@
|
|||||||
|
|
||||||
import { Component, OnInit, inject } from '@angular/core';
|
import { Component, OnInit, inject } from '@angular/core';
|
||||||
import { CommonModule } from '@angular/common';
|
import { CommonModule } from '@angular/common';
|
||||||
import { FormsModule } from '@angular/forms';
|
|
||||||
import { MatIconModule } from '@angular/material/icon';
|
import { MatIconModule } from '@angular/material/icon';
|
||||||
import { MatSnackBar, MatSnackBarModule } from '@angular/material/snack-bar';
|
|
||||||
import { MatTabsModule } from '@angular/material/tabs';
|
import { MatTabsModule } from '@angular/material/tabs';
|
||||||
import { MatPaginatorModule, PageEvent } from '@angular/material/paginator';
|
import { MatPaginatorModule, PageEvent } from '@angular/material/paginator';
|
||||||
import { forkJoin, of } from 'rxjs';
|
import { forkJoin, of } from 'rxjs';
|
||||||
@@ -31,6 +29,8 @@ interface TxnRow {
|
|||||||
reversedBy: number | null;
|
reversedBy: number | null;
|
||||||
reversedAt: string | null;
|
reversedAt: string | null;
|
||||||
reverseReason: string | null;
|
reverseReason: string | null;
|
||||||
|
/** Customer PAN recorded on a cash exchange. */
|
||||||
|
panNumber?: string | null;
|
||||||
type: 'transfer' | 'settlement' | 'inter' | 'exchange';
|
type: 'transfer' | 'settlement' | 'inter' | 'exchange';
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -52,7 +52,10 @@ interface DetailView {
|
|||||||
loading: boolean;
|
loading: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Unified history of all cash movements with reversal and detail view support. */
|
/**
|
||||||
|
* Unified history of all cash movements with a detail view. Read-only: cash movements cannot be reversed
|
||||||
|
* from here (an earlier reversal still shows as Reversed, with who, when and why).
|
||||||
|
*/
|
||||||
@Component({
|
@Component({
|
||||||
selector: 'mifosx-cash-history',
|
selector: 'mifosx-cash-history',
|
||||||
templateUrl: './cash-history.component.html',
|
templateUrl: './cash-history.component.html',
|
||||||
@@ -63,16 +66,13 @@ interface DetailView {
|
|||||||
standalone: true,
|
standalone: true,
|
||||||
imports: [
|
imports: [
|
||||||
CommonModule,
|
CommonModule,
|
||||||
FormsModule,
|
|
||||||
MatIconModule,
|
MatIconModule,
|
||||||
MatSnackBarModule,
|
|
||||||
MatTabsModule,
|
MatTabsModule,
|
||||||
MatPaginatorModule
|
MatPaginatorModule
|
||||||
]
|
]
|
||||||
})
|
})
|
||||||
export class CashHistoryComponent implements OnInit {
|
export class CashHistoryComponent implements OnInit {
|
||||||
private service = inject(CashierService);
|
private service = inject(CashierService);
|
||||||
private snackBar = inject(MatSnackBar);
|
|
||||||
|
|
||||||
transfers: TxnRow[] = [];
|
transfers: TxnRow[] = [];
|
||||||
settlements: TxnRow[] = [];
|
settlements: TxnRow[] = [];
|
||||||
@@ -99,10 +99,6 @@ export class CashHistoryComponent implements OnInit {
|
|||||||
|
|
||||||
private denomKindMap = new Map<number, 'note' | 'coin'>();
|
private denomKindMap = new Map<number, 'note' | 'coin'>();
|
||||||
|
|
||||||
reversingId: number | null = null;
|
|
||||||
reversingType: string | null = null;
|
|
||||||
reverseReason = '';
|
|
||||||
|
|
||||||
detail: DetailView | null = null;
|
detail: DetailView | null = null;
|
||||||
|
|
||||||
ngOnInit(): void {
|
ngOnInit(): void {
|
||||||
@@ -212,7 +208,8 @@ export class CashHistoryComponent implements OnInit {
|
|||||||
this.exchanges = (exchangeItems as any[]).map((e) => ({
|
this.exchanges = (exchangeItems as any[]).map((e) => ({
|
||||||
id: e.id,
|
id: e.id,
|
||||||
date: e.exchangeDate,
|
date: e.exchangeDate,
|
||||||
fromParty: e.accountNo ? `Account ${e.accountNo}` : 'Walk-in Customer',
|
fromParty: this.exchangeParty(e),
|
||||||
|
panNumber: e.panNumber ?? null,
|
||||||
toParty: e.performedByUsername || '—',
|
toParty: e.performedByUsername || '—',
|
||||||
amount: e.exchangeAmount,
|
amount: e.exchangeAmount,
|
||||||
note: e.note,
|
note: e.note,
|
||||||
@@ -310,7 +307,8 @@ export class CashHistoryComponent implements OnInit {
|
|||||||
this.exchanges = (res.pageItems ?? []).map((e: any) => ({
|
this.exchanges = (res.pageItems ?? []).map((e: any) => ({
|
||||||
id: e.id,
|
id: e.id,
|
||||||
date: e.exchangeDate,
|
date: e.exchangeDate,
|
||||||
fromParty: e.accountNo ? `Account ${e.accountNo}` : 'Walk-in Customer',
|
fromParty: this.exchangeParty(e),
|
||||||
|
panNumber: e.panNumber ?? null,
|
||||||
toParty: e.performedByUsername || '—',
|
toParty: e.performedByUsername || '—',
|
||||||
amount: e.exchangeAmount,
|
amount: e.exchangeAmount,
|
||||||
note: e.note,
|
note: e.note,
|
||||||
@@ -392,39 +390,12 @@ export class CashHistoryComponent implements OnInit {
|
|||||||
this.detail = null;
|
this.detail = null;
|
||||||
}
|
}
|
||||||
|
|
||||||
startReverse(row: TxnRow): void {
|
/** Who the exchange was with: the customer's name (and account, if one), else the account, else a walk-in. */
|
||||||
this.reversingId = row.id;
|
private exchangeParty(e: any): string {
|
||||||
this.reversingType = row.type;
|
if (e.customerName) {
|
||||||
this.reverseReason = '';
|
return e.accountNo ? `${e.customerName} (A/C ${e.accountNo})` : e.customerName;
|
||||||
}
|
}
|
||||||
|
return e.accountNo ? `Account ${e.accountNo}` : 'Walk-in Customer';
|
||||||
cancelReverse(): void {
|
|
||||||
this.reversingId = null;
|
|
||||||
this.reversingType = null;
|
|
||||||
this.reverseReason = '';
|
|
||||||
}
|
|
||||||
|
|
||||||
confirmReverse(): void {
|
|
||||||
if (!this.reversingId || !this.reversingType) return;
|
|
||||||
const id = this.reversingId;
|
|
||||||
const type = this.reversingType;
|
|
||||||
const reason = this.reverseReason;
|
|
||||||
this.cancelReverse();
|
|
||||||
|
|
||||||
let obs$;
|
|
||||||
if (type === 'transfer') obs$ = this.service.reverseCashTransfer(id, reason);
|
|
||||||
else if (type === 'settlement') obs$ = this.service.reverseCashSettlement(id, reason);
|
|
||||||
else if (type === 'exchange') obs$ = this.service.reverseCashExchange(id, reason);
|
|
||||||
else obs$ = this.service.reverseInterCashierTransfer(id, reason);
|
|
||||||
|
|
||||||
obs$.pipe(catchError(() => of(null))).subscribe((res: any) => {
|
|
||||||
if (res?.reversed) {
|
|
||||||
this.snackBar.open('Transaction reversed successfully', 'Close', { duration: 3000 });
|
|
||||||
this.load();
|
|
||||||
} else {
|
|
||||||
this.snackBar.open('Reversal failed', 'Close', { duration: 3000 });
|
|
||||||
}
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
|
|
||||||
typeLabel(type: string): string {
|
typeLabel(type: string): string {
|
||||||
|
|||||||
@@ -18,6 +18,7 @@ import { MatInputModule } from '@angular/material/input';
|
|||||||
import { forkJoin, of } from 'rxjs';
|
import { forkJoin, of } from 'rxjs';
|
||||||
import { catchError } from 'rxjs/operators';
|
import { catchError } from 'rxjs/operators';
|
||||||
import { CashierService } from '../cashier.service';
|
import { CashierService } from '../cashier.service';
|
||||||
|
import { businessToday } from 'app/core/utils/business-today';
|
||||||
|
|
||||||
type PageState = 'loading' | 'no-cashier' | 'needs-reconciliation' | 'already-settled' | 'form';
|
type PageState = 'loading' | 'no-cashier' | 'needs-reconciliation' | 'already-settled' | 'form';
|
||||||
|
|
||||||
@@ -90,7 +91,7 @@ export class CashSettlementComponent implements OnInit {
|
|||||||
saving = false;
|
saving = false;
|
||||||
|
|
||||||
ngOnInit(): void {
|
ngOnInit(): void {
|
||||||
const d = new Date();
|
const d = businessToday();
|
||||||
this.settlementDate = `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, '0')}-${String(d.getDate()).padStart(2, '0')}`;
|
this.settlementDate = `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, '0')}-${String(d.getDate()).padStart(2, '0')}`;
|
||||||
this.service
|
this.service
|
||||||
.getTellers()
|
.getTellers()
|
||||||
|
|||||||
@@ -0,0 +1,229 @@
|
|||||||
|
<!--
|
||||||
|
Copyright since 2025 Mifos Initiative
|
||||||
|
|
||||||
|
This Source Code Form is subject to the terms of the Mozilla Public
|
||||||
|
License, v. 2.0. If a copy of the MPL was not distributed with this
|
||||||
|
file, You can obtain one at http://mozilla.org/MPL/2.0/.
|
||||||
|
-->
|
||||||
|
|
||||||
|
<div class="cv-tab-content cta-tab">
|
||||||
|
<!-- ── TITLE BAR ── -->
|
||||||
|
<div class="cta-action-bar">
|
||||||
|
<div class="cta-action-bar__left">
|
||||||
|
<mat-icon class="cta-action-bar__icon">swap_horiz</mat-icon>
|
||||||
|
<div>
|
||||||
|
<div class="cta-action-bar__title">Cash Transfers</div>
|
||||||
|
<div class="cta-action-bar__sub">
|
||||||
|
Approve or reject an allocation of cash from the vault to a cashier. The cash leaves the vault only when it is
|
||||||
|
approved.
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="cta-counts">
|
||||||
|
<span class="cta-count"
|
||||||
|
><strong>{{ all.length }}</strong> waiting</span
|
||||||
|
>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- ── FILTER BAR ── -->
|
||||||
|
<div class="cta-filter-bar">
|
||||||
|
<div class="cta-search-box">
|
||||||
|
<mat-icon>search</mat-icon>
|
||||||
|
<input type="text" placeholder="Search by cashier, vault, user or note" [(ngModel)]="searchTerm" />
|
||||||
|
@if (searchTerm) {
|
||||||
|
<button type="button" class="cta-search-box__clear" (click)="searchTerm = ''">
|
||||||
|
<mat-icon>close</mat-icon>
|
||||||
|
</button>
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
<button type="button" class="cta-btn cta-btn--refresh" (click)="load()" [disabled]="loading">
|
||||||
|
<mat-icon>refresh</mat-icon>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- ── TABLE ── -->
|
||||||
|
@if (loading && !loaded) {
|
||||||
|
<div class="cta-empty"><p class="cta-empty__title">Loading…</p></div>
|
||||||
|
} @else if (rows.length === 0) {
|
||||||
|
<div class="cta-empty">
|
||||||
|
<mat-icon class="cta-empty__icon">inbox</mat-icon>
|
||||||
|
<p class="cta-empty__title">No cash transfer waiting</p>
|
||||||
|
<p class="cta-empty__text">There is no cash allocation waiting for approval.</p>
|
||||||
|
</div>
|
||||||
|
} @else {
|
||||||
|
<div class="cta-table-wrap">
|
||||||
|
<table class="cta-table">
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th>Date</th>
|
||||||
|
<th>From</th>
|
||||||
|
<th>To (Cashier)</th>
|
||||||
|
<th class="num">Amount</th>
|
||||||
|
<th>Note</th>
|
||||||
|
<th>Raised By</th>
|
||||||
|
<th>Raised At</th>
|
||||||
|
<th class="cta-th-action">Action</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
@for (r of rows; track r.id) {
|
||||||
|
<tr class="cta-row">
|
||||||
|
<td class="cta-dt">{{ r.transfer_date || '—' }}</td>
|
||||||
|
<td>{{ r.from_party || '—' }}</td>
|
||||||
|
<td class="cta-strong">{{ r.to_party || '—' }}</td>
|
||||||
|
<td class="num cta-amount">₹{{ r.total_amount | number: '1.2-2' }}</td>
|
||||||
|
<td class="cta-reason" [title]="r.note || ''">{{ r.note || '—' }}</td>
|
||||||
|
<td>{{ r.maker_name || '—' }}</td>
|
||||||
|
<td class="cta-dt">{{ formatDateTime(r.maker_at) }}</td>
|
||||||
|
<td class="cta-action-cell">
|
||||||
|
<button type="button" class="cta-btn cta-btn--approve" (click)="review(r)">
|
||||||
|
<mat-icon>rate_review</mat-icon> Review
|
||||||
|
</button>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- ─── Popup: review a request, every note and coin it asks for ───────────── -->
|
||||||
|
<ng-template #reviewDialog>
|
||||||
|
@if (reviewing) {
|
||||||
|
<div class="cta-dlg">
|
||||||
|
<header class="cta-dlg__head">
|
||||||
|
<mat-icon class="cta-dlg__ico cta-dlg__ico--ok">swap_horiz</mat-icon>
|
||||||
|
<div class="cta-dlg__titles">
|
||||||
|
<div class="cta-dlg__title">Review cash transfer</div>
|
||||||
|
<div class="cta-dlg__sub">
|
||||||
|
Raised by {{ reviewing.maker_name || '—' }} · {{ formatDateTime(reviewing.maker_at) }}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<button type="button" class="cta-dlg__x" aria-label="Close" (click)="closeReview()">
|
||||||
|
<mat-icon>close</mat-icon>
|
||||||
|
</button>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<div class="cta-dlg__body">
|
||||||
|
<div class="cta-total">
|
||||||
|
<span>Amount to release</span>
|
||||||
|
<strong>₹{{ reviewing.total_amount | number: '1.2-2' }}</strong>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="cta-grid">
|
||||||
|
<div class="cta-cell">
|
||||||
|
<div class="cta-label">Transfer date</div>
|
||||||
|
<div class="cta-value">{{ reviewing.transfer_date || '—' }}</div>
|
||||||
|
</div>
|
||||||
|
<div class="cta-cell">
|
||||||
|
<div class="cta-label">From</div>
|
||||||
|
<div class="cta-value">{{ reviewing.from_party || '—' }}</div>
|
||||||
|
</div>
|
||||||
|
<div class="cta-cell">
|
||||||
|
<div class="cta-label">To (cashier)</div>
|
||||||
|
<div class="cta-value">{{ reviewing.to_party || '—' }}</div>
|
||||||
|
</div>
|
||||||
|
<div class="cta-cell">
|
||||||
|
<div class="cta-label">Notes and coins</div>
|
||||||
|
<div class="cta-value">
|
||||||
|
{{ piecesOf(reviewing) }} pieces · {{ linesOf(reviewing).length }} denominations
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
@if (reviewing.note) {
|
||||||
|
<div class="cta-cell cta-cell--wide">
|
||||||
|
<div class="cta-label">Note</div>
|
||||||
|
<div class="cta-value">{{ reviewing.note }}</div>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="cta-label">Denomination breakdown</div>
|
||||||
|
<table class="cta-mini">
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th>Denomination</th>
|
||||||
|
<th class="num">Count</th>
|
||||||
|
<th class="num">Amount</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
@for (l of linesOf(reviewing); track l.value) {
|
||||||
|
<tr>
|
||||||
|
<td>₹{{ l.value }}</td>
|
||||||
|
<td class="num">{{ l.count }}</td>
|
||||||
|
<td class="num">₹{{ l.amount | number: '1.2-2' }}</td>
|
||||||
|
</tr>
|
||||||
|
}
|
||||||
|
</tbody>
|
||||||
|
<tfoot>
|
||||||
|
<tr>
|
||||||
|
<td>Total</td>
|
||||||
|
<td class="num">{{ piecesOf(reviewing) }}</td>
|
||||||
|
<td class="num">₹{{ reviewing.total_amount | number: '1.2-2' }}</td>
|
||||||
|
</tr>
|
||||||
|
</tfoot>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<footer class="cta-dlg__foot">
|
||||||
|
<button type="button" class="cta-btn cta-btn--ghost" (click)="closeReview()">Close</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="cta-btn cta-btn--reject"
|
||||||
|
[disabled]="busyId === reviewing.id"
|
||||||
|
(click)="rejectFromReview(reviewing)"
|
||||||
|
>
|
||||||
|
<mat-icon>close</mat-icon> Reject
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="cta-btn cta-btn--approve"
|
||||||
|
[disabled]="busyId === reviewing.id"
|
||||||
|
(click)="approve(reviewing)"
|
||||||
|
>
|
||||||
|
<mat-icon>check</mat-icon> Approve
|
||||||
|
</button>
|
||||||
|
</footer>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
</ng-template>
|
||||||
|
|
||||||
|
<!-- ─── Popup: reject with a reason ───────────────────────────────────────── -->
|
||||||
|
<ng-template #rejectDialog>
|
||||||
|
@if (rejecting) {
|
||||||
|
<div class="cta-dlg">
|
||||||
|
<header class="cta-dlg__head">
|
||||||
|
<mat-icon class="cta-dlg__ico">block</mat-icon>
|
||||||
|
<div class="cta-dlg__titles">
|
||||||
|
<div class="cta-dlg__title">Reject cash transfer</div>
|
||||||
|
<div class="cta-dlg__sub">₹{{ rejecting.total_amount | number: '1.2-2' }} to {{ rejecting.to_party }}</div>
|
||||||
|
</div>
|
||||||
|
<button type="button" class="cta-dlg__x" aria-label="Close" (click)="cancelReject()">
|
||||||
|
<mat-icon>close</mat-icon>
|
||||||
|
</button>
|
||||||
|
</header>
|
||||||
|
<div class="cta-dlg__body">
|
||||||
|
<label class="cta-label" for="cta-reason">Reason (required)</label>
|
||||||
|
<textarea
|
||||||
|
id="cta-reason"
|
||||||
|
rows="3"
|
||||||
|
placeholder="Why is this being rejected?"
|
||||||
|
[(ngModel)]="rejectReason"
|
||||||
|
></textarea>
|
||||||
|
</div>
|
||||||
|
<footer class="cta-dlg__foot">
|
||||||
|
<button type="button" class="cta-btn cta-btn--ghost" (click)="cancelReject()">Cancel</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="cta-btn cta-btn--reject-solid"
|
||||||
|
[disabled]="!rejectReason.trim() || busyId === rejecting.id"
|
||||||
|
(click)="confirmReject()"
|
||||||
|
>
|
||||||
|
Confirm reject
|
||||||
|
</button>
|
||||||
|
</footer>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
</ng-template>
|
||||||
@@ -0,0 +1,573 @@
|
|||||||
|
/**
|
||||||
|
* Copyright since 2025 Mifos Initiative
|
||||||
|
*
|
||||||
|
* This Source Code Form is subject to the terms of the Mozilla Public
|
||||||
|
* License, v. 2.0. If a copy of the MPL was not distributed with this
|
||||||
|
* file, You can obtain one at http://mozilla.org/MPL/2.0/.
|
||||||
|
*/
|
||||||
|
|
||||||
|
// Laid out like the other approval tabs (Withdrawal Requests): title bar, filter bar, one table.
|
||||||
|
:host {
|
||||||
|
display: block;
|
||||||
|
}
|
||||||
|
|
||||||
|
.cta-tab {
|
||||||
|
padding: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.cta-action-bar {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: 16px;
|
||||||
|
padding: 10px 16px;
|
||||||
|
background: #fff;
|
||||||
|
border-bottom: 1px solid #e2e8f0;
|
||||||
|
|
||||||
|
&__left {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
&__icon {
|
||||||
|
font-size: 22px;
|
||||||
|
width: 22px;
|
||||||
|
height: 22px;
|
||||||
|
color: #0f4f3c;
|
||||||
|
}
|
||||||
|
|
||||||
|
&__title {
|
||||||
|
font-size: 13px;
|
||||||
|
font-weight: 700;
|
||||||
|
color: #0d1b2a;
|
||||||
|
}
|
||||||
|
|
||||||
|
&__sub {
|
||||||
|
max-width: 640px;
|
||||||
|
margin-top: 1px;
|
||||||
|
font-size: 11px;
|
||||||
|
color: #64748b;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.cta-counts {
|
||||||
|
display: flex;
|
||||||
|
gap: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.cta-count {
|
||||||
|
padding: 2px 10px;
|
||||||
|
border-radius: 999px;
|
||||||
|
background: #f1f5f9;
|
||||||
|
font-size: 11px;
|
||||||
|
color: #475569;
|
||||||
|
|
||||||
|
strong {
|
||||||
|
color: #0f4f3c;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.cta-filter-bar {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 6px;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
padding: 6px 16px;
|
||||||
|
background: #f8fafc;
|
||||||
|
border-bottom: 1px solid #e2e8f0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.cta-fb-sep {
|
||||||
|
flex-shrink: 0;
|
||||||
|
width: 1px;
|
||||||
|
height: 22px;
|
||||||
|
margin: 0 4px;
|
||||||
|
background: #e2e8f0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.cta-search-box {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 6px;
|
||||||
|
height: 30px;
|
||||||
|
min-width: 240px;
|
||||||
|
padding: 0 10px;
|
||||||
|
border: 1px solid #e2e8f0;
|
||||||
|
border-radius: 8px;
|
||||||
|
background: #fff;
|
||||||
|
|
||||||
|
mat-icon {
|
||||||
|
flex-shrink: 0;
|
||||||
|
font-size: 17px;
|
||||||
|
width: 17px;
|
||||||
|
height: 17px;
|
||||||
|
color: #94a3b8;
|
||||||
|
}
|
||||||
|
|
||||||
|
&:hover {
|
||||||
|
border-color: #cbd5e1;
|
||||||
|
}
|
||||||
|
|
||||||
|
&:focus-within {
|
||||||
|
border-color: #0f4f3c;
|
||||||
|
box-shadow: 0 0 0 3px rgb(15 79 60 / 8%);
|
||||||
|
}
|
||||||
|
|
||||||
|
input {
|
||||||
|
flex: 1;
|
||||||
|
border: none;
|
||||||
|
outline: none;
|
||||||
|
background: transparent;
|
||||||
|
font-family: inherit;
|
||||||
|
font-size: 12px;
|
||||||
|
color: #0d1b2a;
|
||||||
|
|
||||||
|
&::placeholder {
|
||||||
|
color: #94a3b8;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
&__clear {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
flex-shrink: 0;
|
||||||
|
width: 20px;
|
||||||
|
height: 20px;
|
||||||
|
padding: 0;
|
||||||
|
border: none;
|
||||||
|
border-radius: 50%;
|
||||||
|
background: none;
|
||||||
|
color: #94a3b8;
|
||||||
|
cursor: pointer;
|
||||||
|
|
||||||
|
mat-icon {
|
||||||
|
font-size: 15px;
|
||||||
|
width: 15px;
|
||||||
|
height: 15px;
|
||||||
|
color: inherit;
|
||||||
|
}
|
||||||
|
|
||||||
|
&:hover {
|
||||||
|
color: #dc2626;
|
||||||
|
background: #fef2f2;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.cta-filter-select {
|
||||||
|
height: 30px;
|
||||||
|
min-width: 140px;
|
||||||
|
padding: 0 10px;
|
||||||
|
border: 1px solid #e2e8f0;
|
||||||
|
border-radius: 7px;
|
||||||
|
background: #fff;
|
||||||
|
font-family: inherit;
|
||||||
|
font-size: 12px;
|
||||||
|
color: #0d1b2a;
|
||||||
|
cursor: pointer;
|
||||||
|
|
||||||
|
&:focus {
|
||||||
|
outline: none;
|
||||||
|
border-color: #0f4f3c;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Buttons ─────────────────────────────────────────────────────────────────
|
||||||
|
.cta-btn {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 4px;
|
||||||
|
height: 26px;
|
||||||
|
padding: 0 10px;
|
||||||
|
border: 1px solid transparent;
|
||||||
|
border-radius: 7px;
|
||||||
|
font-family: inherit;
|
||||||
|
font-size: 11px;
|
||||||
|
font-weight: 600;
|
||||||
|
white-space: nowrap;
|
||||||
|
cursor: pointer;
|
||||||
|
|
||||||
|
mat-icon {
|
||||||
|
font-size: 14px;
|
||||||
|
width: 14px;
|
||||||
|
height: 14px;
|
||||||
|
}
|
||||||
|
|
||||||
|
&:disabled {
|
||||||
|
opacity: 0.45;
|
||||||
|
cursor: not-allowed;
|
||||||
|
}
|
||||||
|
|
||||||
|
&--refresh {
|
||||||
|
height: 30px;
|
||||||
|
width: 30px;
|
||||||
|
justify-content: center;
|
||||||
|
padding: 0;
|
||||||
|
margin-left: auto;
|
||||||
|
border-color: #e2e8f0;
|
||||||
|
background: #fff;
|
||||||
|
color: #475569;
|
||||||
|
}
|
||||||
|
|
||||||
|
&--approve {
|
||||||
|
background: #0f4f3c;
|
||||||
|
color: #fff;
|
||||||
|
|
||||||
|
&:hover:not(:disabled) {
|
||||||
|
background: #0a3d2e;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
&--reject {
|
||||||
|
border-color: #fecaca;
|
||||||
|
background: #fff;
|
||||||
|
color: #dc2626;
|
||||||
|
|
||||||
|
&:hover:not(:disabled) {
|
||||||
|
background: #fef2f2;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
&--reject-solid {
|
||||||
|
background: #dc2626;
|
||||||
|
color: #fff;
|
||||||
|
}
|
||||||
|
|
||||||
|
&--ghost {
|
||||||
|
border-color: #e2e8f0;
|
||||||
|
background: #fff;
|
||||||
|
color: #475569;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Table ───────────────────────────────────────────────────────────────────
|
||||||
|
.cta-table-wrap {
|
||||||
|
overflow-x: auto;
|
||||||
|
background: #fff;
|
||||||
|
}
|
||||||
|
|
||||||
|
.cta-table {
|
||||||
|
width: 100%;
|
||||||
|
border-collapse: collapse;
|
||||||
|
font-size: 11px;
|
||||||
|
|
||||||
|
th {
|
||||||
|
padding: 7px 10px;
|
||||||
|
border-bottom: 1px solid #e2e8f0;
|
||||||
|
background: #f8fafc;
|
||||||
|
font-size: 9.5px;
|
||||||
|
font-weight: 800;
|
||||||
|
letter-spacing: 0.06em;
|
||||||
|
text-align: left;
|
||||||
|
text-transform: uppercase;
|
||||||
|
color: #94a3b8;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
td {
|
||||||
|
padding: 5px 10px;
|
||||||
|
border-bottom: 1px solid #eef2f7;
|
||||||
|
vertical-align: middle;
|
||||||
|
color: #0d1b2a;
|
||||||
|
}
|
||||||
|
|
||||||
|
.num {
|
||||||
|
text-align: right;
|
||||||
|
font-variant-numeric: tabular-nums;
|
||||||
|
}
|
||||||
|
|
||||||
|
.cta-th-action {
|
||||||
|
text-align: right;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.cta-row:hover td {
|
||||||
|
background: #f8fafc;
|
||||||
|
}
|
||||||
|
|
||||||
|
.cta-mono {
|
||||||
|
font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace;
|
||||||
|
letter-spacing: 0.02em;
|
||||||
|
}
|
||||||
|
|
||||||
|
.cta-voucher {
|
||||||
|
font-weight: 700;
|
||||||
|
color: #0f4f3c;
|
||||||
|
}
|
||||||
|
|
||||||
|
.cta-strong {
|
||||||
|
font-weight: 700;
|
||||||
|
}
|
||||||
|
|
||||||
|
.cta-sub {
|
||||||
|
margin-top: 1px;
|
||||||
|
font-size: 11px;
|
||||||
|
color: #64748b;
|
||||||
|
}
|
||||||
|
|
||||||
|
.cta-none {
|
||||||
|
color: #cbd5e1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.cta-amount {
|
||||||
|
font-weight: 700;
|
||||||
|
}
|
||||||
|
|
||||||
|
.cta-reason {
|
||||||
|
max-width: 220px;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.cta-dt {
|
||||||
|
white-space: nowrap;
|
||||||
|
color: #475569;
|
||||||
|
}
|
||||||
|
|
||||||
|
.cta-kind {
|
||||||
|
display: inline-block;
|
||||||
|
padding: 2px 10px;
|
||||||
|
border-radius: 999px;
|
||||||
|
font-size: 11px;
|
||||||
|
font-weight: 700;
|
||||||
|
|
||||||
|
&--unpass {
|
||||||
|
background: #fef3c7;
|
||||||
|
color: #92400e;
|
||||||
|
}
|
||||||
|
|
||||||
|
&--correction {
|
||||||
|
background: #dbeafe;
|
||||||
|
color: #1e40af;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.cta-action-cell {
|
||||||
|
display: flex;
|
||||||
|
justify-content: flex-end;
|
||||||
|
gap: 6px;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Empty ───────────────────────────────────────────────────────────────────
|
||||||
|
.cta-empty {
|
||||||
|
padding: 28px 16px;
|
||||||
|
text-align: center;
|
||||||
|
color: #94a3b8;
|
||||||
|
|
||||||
|
&__icon {
|
||||||
|
display: block;
|
||||||
|
margin: 0 auto 6px;
|
||||||
|
font-size: 30px;
|
||||||
|
width: 30px;
|
||||||
|
height: 30px;
|
||||||
|
opacity: 0.6;
|
||||||
|
}
|
||||||
|
|
||||||
|
&__title {
|
||||||
|
margin: 0;
|
||||||
|
font-size: 12px;
|
||||||
|
font-weight: 700;
|
||||||
|
color: #64748b;
|
||||||
|
}
|
||||||
|
|
||||||
|
&__text {
|
||||||
|
margin: 3px 0 0;
|
||||||
|
font-size: 11px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Reject popup ────────────────────────────────────────────────────────────
|
||||||
|
.cta-dlg {
|
||||||
|
font-size: 12px;
|
||||||
|
|
||||||
|
&__head {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 10px;
|
||||||
|
padding: 10px 14px;
|
||||||
|
border-bottom: 1px solid #e2e8f0;
|
||||||
|
background: #f8fafc;
|
||||||
|
}
|
||||||
|
|
||||||
|
&__ico {
|
||||||
|
flex: 0 0 32px;
|
||||||
|
height: 32px;
|
||||||
|
width: 32px;
|
||||||
|
border-radius: 9px;
|
||||||
|
background: #fef2f2;
|
||||||
|
color: #dc2626;
|
||||||
|
font-size: 18px;
|
||||||
|
line-height: 32px;
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
&__titles {
|
||||||
|
flex: 1;
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
&__title {
|
||||||
|
font-size: 14px;
|
||||||
|
font-weight: 700;
|
||||||
|
color: #0f172a;
|
||||||
|
}
|
||||||
|
|
||||||
|
&__sub {
|
||||||
|
font-size: 11px;
|
||||||
|
color: #64748b;
|
||||||
|
}
|
||||||
|
|
||||||
|
&__x {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
height: 28px;
|
||||||
|
width: 28px;
|
||||||
|
border: 0;
|
||||||
|
border-radius: 50%;
|
||||||
|
background: transparent;
|
||||||
|
color: #64748b;
|
||||||
|
cursor: pointer;
|
||||||
|
|
||||||
|
&:hover {
|
||||||
|
background: #e2e8f0;
|
||||||
|
}
|
||||||
|
|
||||||
|
mat-icon {
|
||||||
|
font-size: 18px;
|
||||||
|
height: 18px;
|
||||||
|
width: 18px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
&__body {
|
||||||
|
padding: 12px 14px;
|
||||||
|
max-height: 64vh;
|
||||||
|
overflow-y: auto;
|
||||||
|
|
||||||
|
textarea {
|
||||||
|
width: 100%;
|
||||||
|
padding: 7px 10px;
|
||||||
|
border: 1px solid #cbd5e1;
|
||||||
|
border-radius: 8px;
|
||||||
|
font: inherit;
|
||||||
|
font-size: 12px;
|
||||||
|
resize: vertical;
|
||||||
|
|
||||||
|
&:focus {
|
||||||
|
outline: 0;
|
||||||
|
border-color: #0f4f3c;
|
||||||
|
box-shadow: 0 0 0 3px rgb(15 79 60 / 8%);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
&__foot {
|
||||||
|
display: flex;
|
||||||
|
justify-content: flex-end;
|
||||||
|
gap: 8px;
|
||||||
|
padding: 10px 14px;
|
||||||
|
border-top: 1px solid #e2e8f0;
|
||||||
|
background: #f8fafc;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.cta-label {
|
||||||
|
display: block;
|
||||||
|
margin-bottom: 4px;
|
||||||
|
font-size: 10px;
|
||||||
|
font-weight: 700;
|
||||||
|
letter-spacing: 0.05em;
|
||||||
|
text-transform: uppercase;
|
||||||
|
color: #94a3b8;
|
||||||
|
}
|
||||||
|
|
||||||
|
.cta-dlg__ico--ok {
|
||||||
|
background: #f0fdf4;
|
||||||
|
color: #1f7a4d;
|
||||||
|
}
|
||||||
|
|
||||||
|
.cta-total {
|
||||||
|
display: flex;
|
||||||
|
align-items: baseline;
|
||||||
|
justify-content: space-between;
|
||||||
|
margin-bottom: 10px;
|
||||||
|
padding: 8px 12px;
|
||||||
|
border-radius: 10px;
|
||||||
|
background: #ecfdf5;
|
||||||
|
border: 1px solid #a7f3d0;
|
||||||
|
color: #065f46;
|
||||||
|
font-size: 12px;
|
||||||
|
font-weight: 600;
|
||||||
|
|
||||||
|
strong {
|
||||||
|
font-size: 18px;
|
||||||
|
font-weight: 800;
|
||||||
|
font-variant-numeric: tabular-nums;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.cta-grid {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(auto-fit, minmax(150px, 1fr));
|
||||||
|
gap: 6px;
|
||||||
|
margin-bottom: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.cta-cell {
|
||||||
|
padding: 6px 10px;
|
||||||
|
border-radius: 8px;
|
||||||
|
background: #f8fafc;
|
||||||
|
border: 1px solid #eef2f7;
|
||||||
|
|
||||||
|
&--wide {
|
||||||
|
grid-column: 1 / -1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.cta-value {
|
||||||
|
font-size: 12px;
|
||||||
|
font-weight: 600;
|
||||||
|
color: #0f172a;
|
||||||
|
word-break: break-word;
|
||||||
|
}
|
||||||
|
|
||||||
|
.cta-mini {
|
||||||
|
width: 100%;
|
||||||
|
margin-top: 4px;
|
||||||
|
border-collapse: collapse;
|
||||||
|
font-size: 12px;
|
||||||
|
|
||||||
|
th {
|
||||||
|
padding: 6px 10px;
|
||||||
|
border-bottom: 1px solid #e2e8f0;
|
||||||
|
background: #f8fafc;
|
||||||
|
font-size: 10px;
|
||||||
|
font-weight: 800;
|
||||||
|
letter-spacing: 0.05em;
|
||||||
|
text-align: left;
|
||||||
|
text-transform: uppercase;
|
||||||
|
color: #94a3b8;
|
||||||
|
}
|
||||||
|
|
||||||
|
td {
|
||||||
|
padding: 5px 10px;
|
||||||
|
border-bottom: 1px solid #eef2f7;
|
||||||
|
}
|
||||||
|
|
||||||
|
.num {
|
||||||
|
text-align: right;
|
||||||
|
font-variant-numeric: tabular-nums;
|
||||||
|
}
|
||||||
|
|
||||||
|
tfoot td {
|
||||||
|
border-top: 2px solid #e2e8f0;
|
||||||
|
border-bottom: 0;
|
||||||
|
font-weight: 800;
|
||||||
|
background: #f8fafc;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,190 @@
|
|||||||
|
/**
|
||||||
|
* Copyright since 2025 Mifos Initiative
|
||||||
|
*
|
||||||
|
* This Source Code Form is subject to the terms of the Mozilla Public
|
||||||
|
* License, v. 2.0. If a copy of the MPL was not distributed with this
|
||||||
|
* file, You can obtain one at http://mozilla.org/MPL/2.0/.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { Component, OnInit, TemplateRef, ViewChild, inject } from '@angular/core';
|
||||||
|
import { CommonModule } from '@angular/common';
|
||||||
|
import { FormsModule } from '@angular/forms';
|
||||||
|
import { MatDialog, MatDialogModule, MatDialogRef } from '@angular/material/dialog';
|
||||||
|
import { MatIconModule } from '@angular/material/icon';
|
||||||
|
import { MatSnackBar, MatSnackBarModule } from '@angular/material/snack-bar';
|
||||||
|
import { of } from 'rxjs';
|
||||||
|
import { catchError } from 'rxjs/operators';
|
||||||
|
import { CashierService } from '../cashier.service';
|
||||||
|
|
||||||
|
/** A cash transfer request waiting for a checker, as the server keeps it (snake_case columns). */
|
||||||
|
interface CashTransferRequest {
|
||||||
|
id: number;
|
||||||
|
transfer_date: string | null;
|
||||||
|
from_party: string | null;
|
||||||
|
to_party: string | null;
|
||||||
|
note: string | null;
|
||||||
|
total_amount: number;
|
||||||
|
maker_name: string | null;
|
||||||
|
maker_at: string | null;
|
||||||
|
denominations: { denominationValue: number; count: number }[];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Checker side of Cash Transfer: approves or rejects an allocation of cash from the vault to a cashier. The cash
|
||||||
|
* leaves the vault only when it is approved, by someone other than the person who raised it.
|
||||||
|
*/
|
||||||
|
@Component({
|
||||||
|
selector: 'mifosx-cash-transfer-approvals',
|
||||||
|
templateUrl: './cash-transfer-approvals.component.html',
|
||||||
|
styleUrls: ['./cash-transfer-approvals.component.scss'],
|
||||||
|
standalone: true,
|
||||||
|
imports: [
|
||||||
|
CommonModule,
|
||||||
|
FormsModule,
|
||||||
|
MatDialogModule,
|
||||||
|
MatIconModule,
|
||||||
|
MatSnackBarModule
|
||||||
|
]
|
||||||
|
})
|
||||||
|
export class CashTransferApprovalsComponent implements OnInit {
|
||||||
|
private service = inject(CashierService);
|
||||||
|
private snackBar = inject(MatSnackBar);
|
||||||
|
private dialog = inject(MatDialog);
|
||||||
|
|
||||||
|
@ViewChild('rejectDialog') rejectDialog!: TemplateRef<unknown>;
|
||||||
|
@ViewChild('reviewDialog') reviewDialog!: TemplateRef<unknown>;
|
||||||
|
private dialogRef: MatDialogRef<unknown> | null = null;
|
||||||
|
private reviewRef: MatDialogRef<unknown> | null = null;
|
||||||
|
|
||||||
|
/** The request open in the review popup, with every note and coin it asks for. */
|
||||||
|
reviewing: CashTransferRequest | null = null;
|
||||||
|
|
||||||
|
all: CashTransferRequest[] = [];
|
||||||
|
loading = false;
|
||||||
|
loaded = false;
|
||||||
|
busyId: number | null = null;
|
||||||
|
searchTerm = '';
|
||||||
|
|
||||||
|
rejecting: CashTransferRequest | null = null;
|
||||||
|
rejectReason = '';
|
||||||
|
|
||||||
|
ngOnInit(): void {
|
||||||
|
this.load();
|
||||||
|
}
|
||||||
|
|
||||||
|
load(): void {
|
||||||
|
this.loading = true;
|
||||||
|
this.service
|
||||||
|
.getCashTransferRequests('PENDING')
|
||||||
|
.pipe(catchError(() => of([] as CashTransferRequest[])))
|
||||||
|
.subscribe((rows) => {
|
||||||
|
this.all = rows as CashTransferRequest[];
|
||||||
|
this.loading = false;
|
||||||
|
this.loaded = true;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
get rows(): CashTransferRequest[] {
|
||||||
|
const term = this.searchTerm.trim().toLowerCase();
|
||||||
|
if (!term) return this.all;
|
||||||
|
return this.all.filter((r) => [
|
||||||
|
r.from_party,
|
||||||
|
r.to_party,
|
||||||
|
r.maker_name,
|
||||||
|
r.note
|
||||||
|
].some((v) => (v ?? '').toLowerCase().includes(term)));
|
||||||
|
}
|
||||||
|
|
||||||
|
/** One line per denomination asked for, largest first, with what each line adds up to. */
|
||||||
|
linesOf(r: CashTransferRequest): { value: number; count: number; amount: number }[] {
|
||||||
|
return [...(r.denominations ?? [])]
|
||||||
|
.filter((d) => d.count > 0)
|
||||||
|
.sort((a, b) => b.denominationValue - a.denominationValue)
|
||||||
|
.map((d) => ({ value: d.denominationValue, count: d.count, amount: d.denominationValue * d.count }));
|
||||||
|
}
|
||||||
|
|
||||||
|
/** How many notes and coins the request asks for in all. */
|
||||||
|
piecesOf(r: CashTransferRequest): number {
|
||||||
|
return this.linesOf(r).reduce((sum, l) => sum + l.count, 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
review(r: CashTransferRequest): void {
|
||||||
|
this.reviewing = r;
|
||||||
|
this.reviewRef?.close();
|
||||||
|
this.reviewRef = this.dialog.open(this.reviewDialog, { width: '560px', maxWidth: '95vw', autoFocus: false });
|
||||||
|
this.reviewRef.afterClosed().subscribe(() => (this.reviewing = null));
|
||||||
|
}
|
||||||
|
|
||||||
|
closeReview(): void {
|
||||||
|
this.reviewRef?.close();
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Rejecting from the review popup: the review closes, the reason popup opens for the same request. */
|
||||||
|
rejectFromReview(r: CashTransferRequest): void {
|
||||||
|
this.reviewRef?.close();
|
||||||
|
this.startReject(r);
|
||||||
|
}
|
||||||
|
|
||||||
|
formatDateTime(value: string | null): string {
|
||||||
|
if (!value) return '—';
|
||||||
|
const d = new Date(value);
|
||||||
|
return isNaN(d.getTime())
|
||||||
|
? value
|
||||||
|
: d.toLocaleString('en-IN', { day: '2-digit', month: 'short', hour: '2-digit', minute: '2-digit' });
|
||||||
|
}
|
||||||
|
|
||||||
|
approve(r: CashTransferRequest): void {
|
||||||
|
if (this.busyId) return;
|
||||||
|
this.busyId = r.id;
|
||||||
|
this.service.approveCashTransferRequest(r.id).subscribe({
|
||||||
|
next: (res: any) => {
|
||||||
|
this.busyId = null;
|
||||||
|
this.reviewRef?.close();
|
||||||
|
this.snackBar.open(
|
||||||
|
res?.glWarning ? `Approved (GL warning: ${res.glWarning})` : 'Approved — cash allocated to the cashier',
|
||||||
|
'Close',
|
||||||
|
{ duration: 6000 }
|
||||||
|
);
|
||||||
|
this.load();
|
||||||
|
},
|
||||||
|
error: (err) => {
|
||||||
|
this.busyId = null;
|
||||||
|
this.fail(err, 'Could not approve');
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
startReject(r: CashTransferRequest): void {
|
||||||
|
this.rejecting = r;
|
||||||
|
this.rejectReason = '';
|
||||||
|
this.dialogRef = this.dialog.open(this.rejectDialog, { width: '440px', maxWidth: '95vw', autoFocus: false });
|
||||||
|
this.dialogRef.afterClosed().subscribe(() => (this.rejecting = null));
|
||||||
|
}
|
||||||
|
|
||||||
|
cancelReject(): void {
|
||||||
|
this.dialogRef?.close();
|
||||||
|
}
|
||||||
|
|
||||||
|
confirmReject(): void {
|
||||||
|
if (!this.rejecting || !this.rejectReason.trim() || this.busyId) return;
|
||||||
|
const id = this.rejecting.id;
|
||||||
|
this.busyId = id;
|
||||||
|
this.service.rejectCashTransferRequest(id, this.rejectReason.trim()).subscribe({
|
||||||
|
next: () => {
|
||||||
|
this.busyId = null;
|
||||||
|
this.dialogRef?.close();
|
||||||
|
this.snackBar.open('Rejected', 'Close', { duration: 4000 });
|
||||||
|
this.load();
|
||||||
|
},
|
||||||
|
error: (err) => {
|
||||||
|
this.busyId = null;
|
||||||
|
this.fail(err, 'Could not reject');
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
private fail(err: any, fallback: string): void {
|
||||||
|
const msg = err?.error?.errors?.[0]?.defaultUserMessage ?? err?.error?.defaultUserMessage ?? fallback;
|
||||||
|
this.snackBar.open(msg, 'Close', { duration: 8000 });
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -13,7 +13,15 @@
|
|||||||
<h1 class="pg-title">Cash Transfer</h1>
|
<h1 class="pg-title">Cash Transfer</h1>
|
||||||
<p class="pg-sub">Move cash denomination-wise from one party to another (e.g. Vault → Cashier for opening).</p>
|
<p class="pg-sub">Move cash denomination-wise from one party to another (e.g. Vault → Cashier for opening).</p>
|
||||||
</div>
|
</div>
|
||||||
|
<div class="ct-head-actions">
|
||||||
|
<button type="button" class="pg-btn pg-btn--ghost" (click)="openRequests()">
|
||||||
|
<mat-icon>pending_actions</mat-icon> My Requests
|
||||||
|
@if (pendingCount) {
|
||||||
|
<span class="ct-badge">{{ pendingCount }}</span>
|
||||||
|
}
|
||||||
|
</button>
|
||||||
<button type="button" class="pg-btn pg-btn--ghost" (click)="clear()"><mat-icon>undo</mat-icon> Clear</button>
|
<button type="button" class="pg-btn pg-btn--ghost" (click)="clear()"><mat-icon>undo</mat-icon> Clear</button>
|
||||||
|
</div>
|
||||||
</header>
|
</header>
|
||||||
|
|
||||||
<div class="pg-toolbar pg-toolbar--mat">
|
<div class="pg-toolbar pg-toolbar--mat">
|
||||||
@@ -155,7 +163,56 @@
|
|||||||
<div class="pg-total-bar">
|
<div class="pg-total-bar">
|
||||||
Total to transfer: <strong>₹{{ total | number: '1.2-2' }}</strong>
|
Total to transfer: <strong>₹{{ total | number: '1.2-2' }}</strong>
|
||||||
<button type="button" class="pg-btn pg-btn--primary" [disabled]="!valid || saving" (click)="submit()">
|
<button type="button" class="pg-btn pg-btn--primary" [disabled]="!valid || saving" (click)="submit()">
|
||||||
<mat-icon>swap_horiz</mat-icon> Record Transfer
|
<mat-icon>send</mat-icon> Send for approval
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<!-- Popup: this person's own cash transfer requests, and where each stands. The cash moves only once a manager approves. -->
|
||||||
|
<ng-template #requestsDialog>
|
||||||
|
<div class="ct-dlg">
|
||||||
|
<header class="ct-dlg__head">
|
||||||
|
<mat-icon class="ct-dlg__ico">pending_actions</mat-icon>
|
||||||
|
<div class="ct-dlg__titles">
|
||||||
|
<div class="ct-dlg__title">My transfer requests</div>
|
||||||
|
<div class="ct-dlg__sub">Cash leaves the vault only after a manager approves</div>
|
||||||
|
</div>
|
||||||
|
<button type="button" class="ct-dlg__x" aria-label="Close" (click)="closeRequests()">
|
||||||
|
<mat-icon>close</mat-icon>
|
||||||
|
</button>
|
||||||
|
</header>
|
||||||
|
<div class="ct-dlg__body">
|
||||||
|
@if (myRequests.length) {
|
||||||
|
<table class="pg-table">
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th>Date</th>
|
||||||
|
<th>To</th>
|
||||||
|
<th class="num">Amount</th>
|
||||||
|
<th>Status</th>
|
||||||
|
<th>Approved by</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
@for (r of myRequests; track r.id) {
|
||||||
|
<tr>
|
||||||
|
<td>{{ r.transfer_date || '—' }}</td>
|
||||||
|
<td>{{ r.to_party || '—' }}</td>
|
||||||
|
<td class="num">₹{{ r.total_amount | number: '1.2-2' }}</td>
|
||||||
|
<td>
|
||||||
|
<span class="ct-status" [class]="'ct-status ct-status--' + r.status">{{ r.status }}</span>
|
||||||
|
@if (r.rejection_reason) {
|
||||||
|
<div class="ct-requests__note">{{ r.rejection_reason }}</div>
|
||||||
|
}
|
||||||
|
</td>
|
||||||
|
<td>{{ r.checker_name || '—' }}</td>
|
||||||
|
</tr>
|
||||||
|
}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
} @else {
|
||||||
|
<div class="pg-empty">You have not raised any cash transfer request yet.</div>
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</ng-template>
|
||||||
|
|||||||
@@ -49,3 +49,131 @@
|
|||||||
font-size: 12px;
|
font-size: 12px;
|
||||||
color: #991b1b;
|
color: #991b1b;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.ct-requests {
|
||||||
|
padding: 0;
|
||||||
|
|
||||||
|
&__title {
|
||||||
|
padding: 10px 14px;
|
||||||
|
border-bottom: 1px solid #e2e8f0;
|
||||||
|
font-size: 13px;
|
||||||
|
font-weight: 700;
|
||||||
|
color: #0d1b2a;
|
||||||
|
}
|
||||||
|
|
||||||
|
&__note {
|
||||||
|
margin-top: 2px;
|
||||||
|
font-size: 11px;
|
||||||
|
color: #64748b;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.ct-status {
|
||||||
|
display: inline-block;
|
||||||
|
padding: 2px 10px;
|
||||||
|
border-radius: 999px;
|
||||||
|
background: #e2e8f0;
|
||||||
|
font-size: 11px;
|
||||||
|
font-weight: 700;
|
||||||
|
|
||||||
|
&--PENDING {
|
||||||
|
background: #fef3c7;
|
||||||
|
color: #92400e;
|
||||||
|
}
|
||||||
|
|
||||||
|
&--APPROVED {
|
||||||
|
background: #d1fae5;
|
||||||
|
color: #065f46;
|
||||||
|
}
|
||||||
|
|
||||||
|
&--REJECTED {
|
||||||
|
background: #fee2e2;
|
||||||
|
color: #991b1b;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.ct-head-actions {
|
||||||
|
display: flex;
|
||||||
|
gap: 8px;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ct-badge {
|
||||||
|
min-width: 18px;
|
||||||
|
padding: 0 6px;
|
||||||
|
border-radius: 999px;
|
||||||
|
background: #f59e0b;
|
||||||
|
color: #fff;
|
||||||
|
font-size: 11px;
|
||||||
|
font-weight: 700;
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ct-dlg {
|
||||||
|
font-size: 12px;
|
||||||
|
|
||||||
|
&__head {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 10px;
|
||||||
|
padding: 10px 14px;
|
||||||
|
border-bottom: 1px solid #e2e8f0;
|
||||||
|
background: #f8fafc;
|
||||||
|
}
|
||||||
|
|
||||||
|
&__ico {
|
||||||
|
flex: 0 0 32px;
|
||||||
|
height: 32px;
|
||||||
|
width: 32px;
|
||||||
|
border-radius: 9px;
|
||||||
|
background: #f0fdf4;
|
||||||
|
color: #1f7a4d;
|
||||||
|
font-size: 18px;
|
||||||
|
line-height: 32px;
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
&__titles {
|
||||||
|
flex: 1;
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
&__title {
|
||||||
|
font-size: 14px;
|
||||||
|
font-weight: 700;
|
||||||
|
color: #0f172a;
|
||||||
|
}
|
||||||
|
|
||||||
|
&__sub {
|
||||||
|
font-size: 11px;
|
||||||
|
color: #64748b;
|
||||||
|
}
|
||||||
|
|
||||||
|
&__x {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
height: 28px;
|
||||||
|
width: 28px;
|
||||||
|
border: 0;
|
||||||
|
border-radius: 50%;
|
||||||
|
background: transparent;
|
||||||
|
color: #64748b;
|
||||||
|
cursor: pointer;
|
||||||
|
|
||||||
|
&:hover {
|
||||||
|
background: #e2e8f0;
|
||||||
|
}
|
||||||
|
|
||||||
|
mat-icon {
|
||||||
|
font-size: 18px;
|
||||||
|
height: 18px;
|
||||||
|
width: 18px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
&__body {
|
||||||
|
max-height: 60vh;
|
||||||
|
overflow-y: auto;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -6,9 +6,10 @@
|
|||||||
* file, You can obtain one at http://mozilla.org/MPL/2.0/.
|
* file, You can obtain one at http://mozilla.org/MPL/2.0/.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import { Component, OnInit, inject } from '@angular/core';
|
import { Component, OnInit, TemplateRef, ViewChild, inject } from '@angular/core';
|
||||||
import { CommonModule } from '@angular/common';
|
import { CommonModule } from '@angular/common';
|
||||||
import { FormsModule } from '@angular/forms';
|
import { FormsModule } from '@angular/forms';
|
||||||
|
import { MatDialog, MatDialogModule, MatDialogRef } from '@angular/material/dialog';
|
||||||
import { MatIconModule } from '@angular/material/icon';
|
import { MatIconModule } from '@angular/material/icon';
|
||||||
import { MatSnackBar, MatSnackBarModule } from '@angular/material/snack-bar';
|
import { MatSnackBar, MatSnackBarModule } from '@angular/material/snack-bar';
|
||||||
import { MatFormFieldModule } from '@angular/material/form-field';
|
import { MatFormFieldModule } from '@angular/material/form-field';
|
||||||
@@ -17,6 +18,8 @@ import { MatInputModule } from '@angular/material/input';
|
|||||||
import { of } from 'rxjs';
|
import { of } from 'rxjs';
|
||||||
import { catchError } from 'rxjs/operators';
|
import { catchError } from 'rxjs/operators';
|
||||||
import { CashierService } from '../cashier.service';
|
import { CashierService } from '../cashier.service';
|
||||||
|
import { businessToday } from 'app/core/utils/business-today';
|
||||||
|
import { AuthenticationService } from '../../core/authentication/authentication.service';
|
||||||
|
|
||||||
interface Denom {
|
interface Denom {
|
||||||
id: number;
|
id: number;
|
||||||
@@ -38,6 +41,7 @@ interface Denom {
|
|||||||
CommonModule,
|
CommonModule,
|
||||||
FormsModule,
|
FormsModule,
|
||||||
MatIconModule,
|
MatIconModule,
|
||||||
|
MatDialogModule,
|
||||||
MatSnackBarModule,
|
MatSnackBarModule,
|
||||||
MatFormFieldModule,
|
MatFormFieldModule,
|
||||||
MatSelectModule,
|
MatSelectModule,
|
||||||
@@ -47,6 +51,11 @@ interface Denom {
|
|||||||
export class CashTransferComponent implements OnInit {
|
export class CashTransferComponent implements OnInit {
|
||||||
private service = inject(CashierService);
|
private service = inject(CashierService);
|
||||||
private snackBar = inject(MatSnackBar);
|
private snackBar = inject(MatSnackBar);
|
||||||
|
private dialog = inject(MatDialog);
|
||||||
|
|
||||||
|
@ViewChild('requestsDialog') requestsDialog!: TemplateRef<unknown>;
|
||||||
|
private requestsRef: MatDialogRef<unknown> | null = null;
|
||||||
|
private authenticationService = inject(AuthenticationService);
|
||||||
|
|
||||||
notes: Denom[] = [];
|
notes: Denom[] = [];
|
||||||
coins: Denom[] = [];
|
coins: Denom[] = [];
|
||||||
@@ -81,8 +90,35 @@ export class CashTransferComponent implements OnInit {
|
|||||||
vaultHasBreakdown = true;
|
vaultHasBreakdown = true;
|
||||||
loadingVault = false;
|
loadingVault = false;
|
||||||
|
|
||||||
|
/** The caller's own transfer requests, newest first, so a request can be followed to its approval. */
|
||||||
|
myRequests: any[] = [];
|
||||||
|
|
||||||
|
loadMyRequests(): void {
|
||||||
|
this.service
|
||||||
|
.getCashTransferRequests(undefined, true)
|
||||||
|
.pipe(catchError(() => of([] as any[])))
|
||||||
|
.subscribe((rows) => (this.myRequests = rows.slice(0, 20)));
|
||||||
|
}
|
||||||
|
|
||||||
|
/** How many of this person's requests are still waiting for a manager. */
|
||||||
|
get pendingCount(): number {
|
||||||
|
return this.myRequests.filter((r) => r.status === 'PENDING').length;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Opens the list of this person's requests, freshly read, so a request can be followed to its approval. */
|
||||||
|
openRequests(): void {
|
||||||
|
this.loadMyRequests();
|
||||||
|
this.requestsRef?.close();
|
||||||
|
this.requestsRef = this.dialog.open(this.requestsDialog, { width: '720px', maxWidth: '95vw', autoFocus: false });
|
||||||
|
}
|
||||||
|
|
||||||
|
closeRequests(): void {
|
||||||
|
this.requestsRef?.close();
|
||||||
|
}
|
||||||
|
|
||||||
ngOnInit(): void {
|
ngOnInit(): void {
|
||||||
const d = new Date();
|
this.loadMyRequests();
|
||||||
|
const d = businessToday();
|
||||||
this.transferDate = `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, '0')}-${String(d.getDate()).padStart(2, '0')}`;
|
this.transferDate = `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, '0')}-${String(d.getDate()).padStart(2, '0')}`;
|
||||||
this.service.getActiveDenominations().subscribe({
|
this.service.getActiveDenominations().subscribe({
|
||||||
next: (list) => {
|
next: (list) => {
|
||||||
@@ -105,7 +141,14 @@ export class CashTransferComponent implements OnInit {
|
|||||||
.getTellers()
|
.getTellers()
|
||||||
.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 }));
|
// The vault and its cash are physical, so a branch moves cash only from its own tellers. The
|
||||||
|
// backend lists every teller below the user's office (Head Office sees all branches), which is
|
||||||
|
// right for reports but wrong here.
|
||||||
|
const myOfficeId = this.authenticationService.getCredentials()?.officeId;
|
||||||
|
const own = (list || []).filter(
|
||||||
|
(t: any) => !myOfficeId || t.officeId == null || Number(t.officeId) === Number(myOfficeId)
|
||||||
|
);
|
||||||
|
this.tellers = own.map((t: any) => ({ id: t.id, name: t.name }));
|
||||||
if (this.tellers.length === 1) {
|
if (this.tellers.length === 1) {
|
||||||
this.onTellerSelect(this.tellers[0].id);
|
this.onTellerSelect(this.tellers[0].id);
|
||||||
}
|
}
|
||||||
@@ -244,16 +287,11 @@ export class CashTransferComponent implements OnInit {
|
|||||||
.filter((d) => this.countFor(d) > 0)
|
.filter((d) => this.countFor(d) > 0)
|
||||||
.map((d) => ({ denominationId: d.id, denominationValue: d.value, count: this.countFor(d) }))
|
.map((d) => ({ denominationId: d.id, denominationValue: d.value, count: this.countFor(d) }))
|
||||||
};
|
};
|
||||||
this.service.createCashTransfer(payload).subscribe({
|
this.service.createCashTransferRequest(payload).subscribe({
|
||||||
next: (res: any) => {
|
next: () => {
|
||||||
if (res?.glWarning) {
|
// Nothing has moved yet: a manager approves the allocation first, and only then does the cash leave the vault.
|
||||||
this.snackBar.open(`Transfer saved (GL warning: ${res.glWarning})`, 'Close', {
|
this.snackBar.open('Cash transfer sent for manager approval', 'Close', { duration: 5000 });
|
||||||
duration: 8000,
|
this.loadMyRequests();
|
||||||
panelClass: ['snack-warn']
|
|
||||||
});
|
|
||||||
} else {
|
|
||||||
this.snackBar.open('Cash transfer recorded', 'Close', { duration: 3000 });
|
|
||||||
}
|
|
||||||
this.clear();
|
this.clear();
|
||||||
this.note = '';
|
this.note = '';
|
||||||
this.fromParty = '';
|
this.fromParty = '';
|
||||||
|
|||||||
@@ -17,6 +17,7 @@ import { forkJoin, of } from 'rxjs';
|
|||||||
import { catchError } from 'rxjs/operators';
|
import { catchError } from 'rxjs/operators';
|
||||||
import { CashierService } from '../cashier.service';
|
import { CashierService } from '../cashier.service';
|
||||||
import { AuthenticationService } from '../../core/authentication/authentication.service';
|
import { AuthenticationService } from '../../core/authentication/authentication.service';
|
||||||
|
import { businessToday } from 'app/core/utils/business-today';
|
||||||
|
|
||||||
interface DenomRow {
|
interface DenomRow {
|
||||||
denominationId: number;
|
denominationId: number;
|
||||||
@@ -71,7 +72,7 @@ export class CashierPositionComponent implements OnInit {
|
|||||||
isCashierMode = false;
|
isCashierMode = false;
|
||||||
|
|
||||||
ngOnInit(): void {
|
ngOnInit(): void {
|
||||||
const d = new Date();
|
const d = businessToday();
|
||||||
this.asOfDate = `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, '0')}-${String(d.getDate()).padStart(2, '0')}`;
|
this.asOfDate = `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, '0')}-${String(d.getDate()).padStart(2, '0')}`;
|
||||||
|
|
||||||
this.isCashierMode = this.authService.isCashierUser();
|
this.isCashierMode = this.authService.isCashierUser();
|
||||||
|
|||||||
@@ -23,10 +23,10 @@ import { DayBookComponent } from './day-book/day-book.component';
|
|||||||
import { VoucherReportComponent } from './voucher-report/voucher-report.component';
|
import { VoucherReportComponent } from './voucher-report/voucher-report.component';
|
||||||
import { BranchDayComponent } from './branch-day/branch-day.component';
|
import { BranchDayComponent } from './branch-day/branch-day.component';
|
||||||
import { VaultPositionComponent } from './vault-position/vault-position.component';
|
import { VaultPositionComponent } from './vault-position/vault-position.component';
|
||||||
|
import { BranchCashPositionComponent } from './branch-cash-position/branch-cash-position.component';
|
||||||
import { AccountRegisterComponent } from './account-register/account-register.component';
|
import { AccountRegisterComponent } from './account-register/account-register.component';
|
||||||
import { ClosingCashReportComponent } from './closing-cash-report/closing-cash-report.component';
|
import { ClosingCashReportComponent } from './closing-cash-report/closing-cash-report.component';
|
||||||
import { ExcessTransactionReportComponent } from './excess-transaction-report/excess-transaction-report.component';
|
import { ExcessTransactionReportComponent } from './excess-transaction-report/excess-transaction-report.component';
|
||||||
import { ShareCollectionComponent } from './share-collection/share-collection.component';
|
|
||||||
|
|
||||||
const routes: Routes = [
|
const routes: Routes = [
|
||||||
Route.withShell([
|
Route.withShell([
|
||||||
@@ -83,6 +83,13 @@ const routes: Routes = [
|
|||||||
),
|
),
|
||||||
data: { title: 'My Share Requests', breadcrumb: 'My Share Requests' }
|
data: { title: 'My Share Requests', breadcrumb: 'My Share Requests' }
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
// The same page as the cashier's, reached from the clerk dashboard: a clerk unpasses vouchers here.
|
||||||
|
path: 'voucher-correction',
|
||||||
|
loadComponent: () =>
|
||||||
|
import('./voucher-correction/voucher-correction.component').then((m) => m.VoucherCorrectionComponent),
|
||||||
|
data: { title: 'Voucher Correction', breadcrumb: 'Voucher Correction' }
|
||||||
|
},
|
||||||
{
|
{
|
||||||
path: 'closure-requests',
|
path: 'closure-requests',
|
||||||
loadComponent: () =>
|
loadComponent: () =>
|
||||||
@@ -226,6 +233,13 @@ const routes: Routes = [
|
|||||||
component: VaultPositionComponent,
|
component: VaultPositionComponent,
|
||||||
data: { title: 'Vault Position', breadcrumb: 'Vault Position' }
|
data: { title: 'Vault Position', breadcrumb: 'Vault Position' }
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
// What the whole branch holds per denomination: the vault plus every cashier's drawer, so an
|
||||||
|
// exchange (which leaves the vault alone) is visible in the bank's total.
|
||||||
|
path: 'branch-position',
|
||||||
|
component: BranchCashPositionComponent,
|
||||||
|
data: { title: 'Branch Cash Position', breadcrumb: 'Branch Cash Position' }
|
||||||
|
},
|
||||||
{
|
{
|
||||||
// The same day book the clerk and cashier read, reached from where a principal officer
|
// The same day book the clerk and cashier read, reached from where a principal officer
|
||||||
// signing the day off actually looks. It carries the Officer Verification Sheet — each
|
// signing the day off actually looks. It carries the Officer Verification Sheet — each
|
||||||
@@ -284,6 +298,12 @@ const routes: Routes = [
|
|||||||
component: ReconciliationComponent,
|
component: ReconciliationComponent,
|
||||||
data: { title: 'Reconciliation', breadcrumb: 'Reconciliation' }
|
data: { title: 'Reconciliation', breadcrumb: 'Reconciliation' }
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
path: 'voucher-correction',
|
||||||
|
loadComponent: () =>
|
||||||
|
import('./voucher-correction/voucher-correction.component').then((m) => m.VoucherCorrectionComponent),
|
||||||
|
data: { title: 'Voucher Correction', breadcrumb: 'Voucher Correction' }
|
||||||
|
},
|
||||||
{
|
{
|
||||||
path: 'cash-history',
|
path: 'cash-history',
|
||||||
component: CashHistoryComponent,
|
component: CashHistoryComponent,
|
||||||
@@ -345,11 +365,6 @@ const routes: Routes = [
|
|||||||
component: ExcessTransactionReportComponent,
|
component: ExcessTransactionReportComponent,
|
||||||
data: { title: 'Excess Transaction Report', breadcrumb: 'Excess Transaction Report' }
|
data: { title: 'Excess Transaction Report', breadcrumb: 'Excess Transaction Report' }
|
||||||
},
|
},
|
||||||
{
|
|
||||||
path: 'share-collection',
|
|
||||||
component: ShareCollectionComponent,
|
|
||||||
data: { title: 'Share Collection', breadcrumb: 'Share Collection' }
|
|
||||||
},
|
|
||||||
{
|
{
|
||||||
path: 'transaction-entry',
|
path: 'transaction-entry',
|
||||||
canActivate: [CashierOnlyGuard],
|
canActivate: [CashierOnlyGuard],
|
||||||
|
|||||||
@@ -20,7 +20,6 @@ import { ReconciliationComponent } from './reconciliation/reconciliation.compone
|
|||||||
import { CashHistoryComponent } from './cash-history/cash-history.component';
|
import { CashHistoryComponent } from './cash-history/cash-history.component';
|
||||||
import { ClosingCashReportComponent } from './closing-cash-report/closing-cash-report.component';
|
import { ClosingCashReportComponent } from './closing-cash-report/closing-cash-report.component';
|
||||||
import { ExcessTransactionReportComponent } from './excess-transaction-report/excess-transaction-report.component';
|
import { ExcessTransactionReportComponent } from './excess-transaction-report/excess-transaction-report.component';
|
||||||
import { ShareCollectionComponent } from './share-collection/share-collection.component';
|
|
||||||
|
|
||||||
@NgModule({
|
@NgModule({
|
||||||
imports: [
|
imports: [
|
||||||
@@ -35,8 +34,7 @@ import { ShareCollectionComponent } from './share-collection/share-collection.co
|
|||||||
ReconciliationComponent,
|
ReconciliationComponent,
|
||||||
CashHistoryComponent,
|
CashHistoryComponent,
|
||||||
ClosingCashReportComponent,
|
ClosingCashReportComponent,
|
||||||
ExcessTransactionReportComponent,
|
ExcessTransactionReportComponent
|
||||||
ShareCollectionComponent
|
|
||||||
]
|
]
|
||||||
})
|
})
|
||||||
export class CashierModule {}
|
export class CashierModule {}
|
||||||
|
|||||||
@@ -32,9 +32,6 @@ export class CashierService {
|
|||||||
getCashExchange(id: number): Observable<any> {
|
getCashExchange(id: number): Observable<any> {
|
||||||
return this.http.get(`/cashexchange/${id}`);
|
return this.http.get(`/cashexchange/${id}`);
|
||||||
}
|
}
|
||||||
reverseCashExchange(id: number, reason: string): Observable<any> {
|
|
||||||
return this.http.post(`/cashexchange/${id}/reverse`, { reason });
|
|
||||||
}
|
|
||||||
|
|
||||||
// ── Tellers / Cashiers ──
|
// ── Tellers / Cashiers ──
|
||||||
getTellers(officeId?: number): Observable<any[]> {
|
getTellers(officeId?: number): Observable<any[]> {
|
||||||
@@ -50,8 +47,21 @@ export class CashierService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// ── Cash Transfer ──
|
// ── Cash Transfer ──
|
||||||
createCashTransfer(data: any): Observable<any> {
|
/** Cash leaves the vault only when a different person approves, so the screen raises a request, not a transfer. */
|
||||||
return this.http.post('/cashtransfer', data);
|
createCashTransferRequest(data: any): Observable<any> {
|
||||||
|
return this.http.post('/cashtransfer/requests', data);
|
||||||
|
}
|
||||||
|
/** Requests by status (comma separated); mine narrows them to the ones the caller raised. */
|
||||||
|
getCashTransferRequests(status?: string, mine = false): Observable<any[]> {
|
||||||
|
let params = new HttpParams().set('mine', String(mine));
|
||||||
|
if (status) params = params.set('status', status);
|
||||||
|
return this.http.get<any[]>('/cashtransfer/requests', { params });
|
||||||
|
}
|
||||||
|
approveCashTransferRequest(id: number): Observable<any> {
|
||||||
|
return this.http.post(`/cashtransfer/requests/${id}/approve`, {});
|
||||||
|
}
|
||||||
|
rejectCashTransferRequest(id: number, reason: string): Observable<any> {
|
||||||
|
return this.http.post(`/cashtransfer/requests/${id}/reject`, { reason });
|
||||||
}
|
}
|
||||||
getCashTransfers(offset = 0, limit = 20): Observable<any> {
|
getCashTransfers(offset = 0, limit = 20): Observable<any> {
|
||||||
const params = new HttpParams().set('offset', offset).set('limit', limit);
|
const params = new HttpParams().set('offset', offset).set('limit', limit);
|
||||||
@@ -60,9 +70,6 @@ export class CashierService {
|
|||||||
getCashTransferById(id: number): Observable<any> {
|
getCashTransferById(id: number): Observable<any> {
|
||||||
return this.http.get(`/cashtransfer/${id}`);
|
return this.http.get(`/cashtransfer/${id}`);
|
||||||
}
|
}
|
||||||
reverseCashTransfer(id: number, reason: string): Observable<any> {
|
|
||||||
return this.http.post(`/cashtransfer/${id}/reverse`, { reason });
|
|
||||||
}
|
|
||||||
|
|
||||||
// ── Cash Settlement ──
|
// ── Cash Settlement ──
|
||||||
createCashSettlement(data: any): Observable<any> {
|
createCashSettlement(data: any): Observable<any> {
|
||||||
@@ -75,9 +82,6 @@ export class CashierService {
|
|||||||
getCashSettlementById(id: number): Observable<any> {
|
getCashSettlementById(id: number): Observable<any> {
|
||||||
return this.http.get(`/cashsettlement/${id}`);
|
return this.http.get(`/cashsettlement/${id}`);
|
||||||
}
|
}
|
||||||
reverseCashSettlement(id: number, reason: string): Observable<any> {
|
|
||||||
return this.http.post(`/cashsettlement/${id}/reverse`, { reason });
|
|
||||||
}
|
|
||||||
|
|
||||||
// ── Inter-cashier Transfer ──
|
// ── Inter-cashier Transfer ──
|
||||||
createInterCashierTransfer(data: any): Observable<any> {
|
createInterCashierTransfer(data: any): Observable<any> {
|
||||||
@@ -90,8 +94,10 @@ export class CashierService {
|
|||||||
getInterCashierById(id: number): Observable<any> {
|
getInterCashierById(id: number): Observable<any> {
|
||||||
return this.http.get(`/cashintercashier/${id}`);
|
return this.http.get(`/cashintercashier/${id}`);
|
||||||
}
|
}
|
||||||
reverseInterCashierTransfer(id: number, reason: string): Observable<any> {
|
/** One cashier's details, today's vault allocation and denomination-wise cash, for either side of a transfer. */
|
||||||
return this.http.post(`/cashintercashier/${id}/reverse`, { reason });
|
getInterCashierCashierSummary(cashierId: number, date: string): Observable<any> {
|
||||||
|
const params = new HttpParams().set('cashierId', cashierId).set('date', date);
|
||||||
|
return this.http.get<any>('/cashintercashier/cashier-summary', { params });
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── Reconciliation ──
|
// ── Reconciliation ──
|
||||||
@@ -178,6 +184,13 @@ export class CashierService {
|
|||||||
return this.http.get('/vouchers/my-work', { params });
|
return this.http.get('/vouchers/my-work', { params });
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** One voucher and every debit/credit line it is made of — the full slip behind a summarised
|
||||||
|
* row (a "+2 more" badge in a register only ever shows the first side of each). */
|
||||||
|
voucherDetail(voucherNo: string): Observable<any> {
|
||||||
|
const params = new HttpParams().set('voucherNo', voucherNo);
|
||||||
|
return this.http.get('/vouchers/detail', { params });
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Account register for one date: what opened and closed that day, each account's balance as it
|
* Account register for one date: what opened and closed that day, each account's balance as it
|
||||||
* stood then, and what it holds now.
|
* stood then, and what it holds now.
|
||||||
@@ -198,6 +211,23 @@ export class CashierService {
|
|||||||
return this.http.get('/branchday', { params });
|
return this.http.get('/branchday', { params });
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The savings products whose interest posting report is due for the date and not yet approved —
|
||||||
|
* the backend refuses to close any branch's day while one is pending.
|
||||||
|
*/
|
||||||
|
interestDayCloseCheck(
|
||||||
|
date?: string,
|
||||||
|
officeId?: number
|
||||||
|
): Observable<{ businessDate: string; canClose: boolean; pendingProducts: string[] }> {
|
||||||
|
let params = new HttpParams();
|
||||||
|
if (date) params = params.set('date', date);
|
||||||
|
if (officeId) params = params.set('officeId', String(officeId));
|
||||||
|
return this.http.get<{ businessDate: string; canClose: boolean; pendingProducts: string[] }>(
|
||||||
|
'/savingsinterestpostingreports/day-close-check',
|
||||||
|
{ params }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
/** Days opened and never closed — today's own open day excluded. */
|
/** Days opened and never closed — today's own open day excluded. */
|
||||||
branchDayMissed(officeId?: number): Observable<any> {
|
branchDayMissed(officeId?: number): Observable<any> {
|
||||||
let params = new HttpParams();
|
let params = new HttpParams();
|
||||||
@@ -294,6 +324,16 @@ export class CashierService {
|
|||||||
if (asOfDate) params = params.set('asOfDate', asOfDate);
|
if (asOfDate) params = params.set('asOfDate', asOfDate);
|
||||||
return this.http.get('/cashreports/vault-position', { params });
|
return this.http.get('/cashreports/vault-position', { params });
|
||||||
}
|
}
|
||||||
|
/**
|
||||||
|
* Denomination-wise cash the whole branch holds: its vault(s) plus every cashier's drawer, with the day's
|
||||||
|
* exchange movement beside it. Not for a cashier -- the server refuses it.
|
||||||
|
*/
|
||||||
|
branchCashPosition(asOfDate?: string, officeId?: number): Observable<any> {
|
||||||
|
let params = new HttpParams();
|
||||||
|
if (asOfDate) params = params.set('asOfDate', asOfDate);
|
||||||
|
if (officeId) params = params.set('officeId', String(officeId));
|
||||||
|
return this.http.get('/cashreports/branch-position', { params });
|
||||||
|
}
|
||||||
excessTransactionReport(threshold: number, fromDate?: string, toDate?: string): Observable<any[]> {
|
excessTransactionReport(threshold: number, fromDate?: string, toDate?: string): Observable<any[]> {
|
||||||
let params = new HttpParams().set('threshold', String(threshold));
|
let params = new HttpParams().set('threshold', String(threshold));
|
||||||
if (fromDate) params = params.set('fromDate', fromDate);
|
if (fromDate) params = params.set('fromDate', fromDate);
|
||||||
@@ -357,19 +397,6 @@ export class CashierService {
|
|||||||
return this.http.get<any[]>('/paymenttypes');
|
return this.http.get<any[]>('/paymenttypes');
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── Share Collection ──
|
|
||||||
getShareAccountForCollection(accountId: string): Observable<any> {
|
|
||||||
return this.http.get(`/accounts/share/${accountId}`);
|
|
||||||
}
|
|
||||||
|
|
||||||
collectSharePayment(data: any): Observable<any> {
|
|
||||||
return this.http.post('/cashier/sharecollection', data);
|
|
||||||
}
|
|
||||||
|
|
||||||
getShareCollectionStatus(shareAccountId: number): Observable<any> {
|
|
||||||
return this.http.get(`/cashier/sharecollection/${shareAccountId}`);
|
|
||||||
}
|
|
||||||
|
|
||||||
/** One unapplied Share Sundry receipt, found by the voucher number the member quotes. */
|
/** One unapplied Share Sundry receipt, found by the voucher number the member quotes. */
|
||||||
getShareAdvanceByVoucher(voucherNo: string, glAccountNo: string, clientId?: number): Observable<any> {
|
getShareAdvanceByVoucher(voucherNo: string, glAccountNo: string, clientId?: number): Observable<any> {
|
||||||
// The voucher travels as a query parameter, not a path segment: numbers are formatted
|
// The voucher travels as a query parameter, not a path segment: numbers are formatted
|
||||||
@@ -396,10 +423,18 @@ export class CashierService {
|
|||||||
return this.http.get('/shareadvancecollection/requirement', { params: { shareAccountId: String(shareAccountId) } });
|
return this.http.get('/shareadvancecollection/requirement', { params: { shareAccountId: String(shareAccountId) } });
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Receipts still sitting in Share Sundry, optionally for a single member. */
|
/**
|
||||||
getUnappliedShareAdvances(clientId?: number): Observable<any> {
|
* Everything an Apply screen needs, in one call: `own` is this member's own unapplied receipts,
|
||||||
const suffix = clientId ? `?clientId=${clientId}` : '';
|
* ready to tick; `rows` is every other Share Collection receipt still available — unclaimed GL
|
||||||
return this.http.get(`/shareadvancecollection${suffix}`);
|
* deposits and receipts linked to another member — which attach by voucher instead.
|
||||||
|
*
|
||||||
|
* One request rather than two: both lists come from the same table under the same filters, and
|
||||||
|
* splitting them across endpoints only meant the same rows were fetched twice and, where they
|
||||||
|
* overlapped, drawn twice on the same screen.
|
||||||
|
*/
|
||||||
|
getShareCollectionReceipts(clientId?: number): Observable<any> {
|
||||||
|
const params = clientId ? new HttpParams().set('clientId', String(clientId)) : undefined;
|
||||||
|
return this.http.get('/shareadvancecollection/unattributed', { params });
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Moves a receipt out of Sundry into share capital for an activated account. */
|
/** Moves a receipt out of Sundry into share capital for an activated account. */
|
||||||
@@ -407,6 +442,12 @@ export class CashierService {
|
|||||||
return this.http.post(`/shareadvancecollection/${collectionId}/apply`, data);
|
return this.http.post(`/shareadvancecollection/${collectionId}/apply`, data);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** The receipt's own real Dr/Cr GL legs — never the GL account currently picked in a search box,
|
||||||
|
* which is unrelated screen state that can name a different account than this receipt sits in. */
|
||||||
|
getShareAdvanceGlEntries(collectionId: number): Observable<any> {
|
||||||
|
return this.http.get(`/shareadvancecollection/${collectionId}/gl-entries`);
|
||||||
|
}
|
||||||
|
|
||||||
/** Every additional-share tranche awaiting approval, across every share account. */
|
/** Every additional-share tranche awaiting approval, across every share account. */
|
||||||
getShareAdditionRequests(): Observable<any> {
|
getShareAdditionRequests(): Observable<any> {
|
||||||
return this.http.get('/shareadditionrequests');
|
return this.http.get('/shareadditionrequests');
|
||||||
@@ -419,4 +460,14 @@ export class CashierService {
|
|||||||
getShareAdditionRequest(transactionId: number): Observable<any> {
|
getShareAdditionRequest(transactionId: number): Observable<any> {
|
||||||
return this.http.get(`/shareadditionrequests/${transactionId}`);
|
return this.http.get(`/shareadditionrequests/${transactionId}`);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Entry Type options for a GL credit (General / Share Collection / DD Issue / …) — the
|
||||||
|
* "GLEntryType" code under Admin > System > Manage Codes, so an admin can add another type later
|
||||||
|
* without a deployment. Empty on failure rather than erroring, since a row missing this dropdown's
|
||||||
|
* options should still be usable for every other field.
|
||||||
|
*/
|
||||||
|
getEntryTypeOptions(): Observable<any[]> {
|
||||||
|
return this.http.get<any[]>('/codes/name/GLEntryType/codevalues').pipe(catchError(() => of([])));
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -51,6 +51,12 @@ export class ClerkShareWithdrawalService {
|
|||||||
return this.http.post(`/clerk/share-withdrawal-requests/${id}/validate`, {});
|
return this.http.post(`/clerk/share-withdrawal-requests/${id}/validate`, {});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Runs the exact same checklist approve() will run, against a draft that hasn't been submitted
|
||||||
|
* yet — so the clerk sees it in the confirm dialog, before committing to the request. */
|
||||||
|
previewValidate(data: any): Observable<any> {
|
||||||
|
return this.http.post('/clerk/share-withdrawal-requests/preview-validate', data);
|
||||||
|
}
|
||||||
|
|
||||||
approve(id: number): Observable<any> {
|
approve(id: number): Observable<any> {
|
||||||
return this.http.post(`/clerk/share-withdrawal-requests/${id}/approve`, {});
|
return this.http.post(`/clerk/share-withdrawal-requests/${id}/approve`, {});
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -12,6 +12,7 @@ import { FormsModule } from '@angular/forms';
|
|||||||
import { MatIconModule } from '@angular/material/icon';
|
import { MatIconModule } from '@angular/material/icon';
|
||||||
import { MatPaginatorModule, PageEvent } from '@angular/material/paginator';
|
import { MatPaginatorModule, PageEvent } from '@angular/material/paginator';
|
||||||
import { CashierService } from '../cashier.service';
|
import { CashierService } from '../cashier.service';
|
||||||
|
import { businessToday } from 'app/core/utils/business-today';
|
||||||
|
|
||||||
/** Day-wise denomination-wise closing cash collected from deposits. */
|
/** Day-wise denomination-wise closing cash collected from deposits. */
|
||||||
@Component({
|
@Component({
|
||||||
@@ -174,19 +175,24 @@ export class ClosingCashReportComponent implements OnInit {
|
|||||||
return this.noteRows.reduce((s, r) => s + Number(r.settledOutAmount ?? 0), 0);
|
return this.noteRows.reduce((s, r) => s + Number(r.settledOutAmount ?? 0), 0);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Summed from each row's own `netCount`, not re-derived from today's legs — a row's `netCount`
|
||||||
|
* already carries forward what a cashier was holding from before today (`openingCount`), for the
|
||||||
|
* force-closed-day/late-settlement case. Recomputing here from only today's vaultIn/deposit/
|
||||||
|
* withdrawal/settledOut dropped that carry-forward and put the Total row back into negative
|
||||||
|
* numbers a cashier had already reconciled.
|
||||||
|
*/
|
||||||
get noteNetCount(): number {
|
get noteNetCount(): number {
|
||||||
return this.noteVaultInCount + this.noteDepositCount - this.noteWithdrawalCount - this.noteSettledOutCount;
|
return this.noteRows.reduce((s, r) => s + Number(r.netCount ?? 0), 0);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* What is left in the drawer in notes: everything that came in, less everything that went out.
|
* What is left in the drawer in notes: everything that came in, less everything that went out —
|
||||||
*
|
* including cash carried forward from before today. See `noteNetCount` for why this sums each
|
||||||
* Was deposits minus withdrawals, which is the counter's movement rather than the drawer's
|
* row's own `netAmount` rather than re-deriving it from today's legs.
|
||||||
* contents — it left out the cash drawn from the vault, so a teller holding a ₹500 note saw a net
|
|
||||||
* of zero and had nothing to count against.
|
|
||||||
*/
|
*/
|
||||||
get noteNetAmount(): number {
|
get noteNetAmount(): number {
|
||||||
return this.noteVaultInAmount + this.noteDepositAmount - this.noteWithdrawalAmount - this.noteSettledOutAmount;
|
return this.noteRows.reduce((s, r) => s + Number(r.netAmount ?? 0), 0);
|
||||||
}
|
}
|
||||||
get coinRows(): any[] {
|
get coinRows(): any[] {
|
||||||
return this.rows.filter((r) => r.kind === 'coin');
|
return this.rows.filter((r) => r.kind === 'coin');
|
||||||
@@ -199,7 +205,7 @@ export class ClosingCashReportComponent implements OnInit {
|
|||||||
}
|
}
|
||||||
|
|
||||||
ngOnInit(): void {
|
ngOnInit(): void {
|
||||||
const d = new Date();
|
const d = businessToday();
|
||||||
this.date = `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, '0')}-${String(d.getDate()).padStart(2, '0')}`;
|
this.date = `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, '0')}-${String(d.getDate()).padStart(2, '0')}`;
|
||||||
this.load();
|
this.load();
|
||||||
this.service.getTellers().subscribe({ next: (t: any[]) => (this.tellers = t || []), error: () => {} });
|
this.service.getTellers().subscribe({ next: (t: any[]) => (this.tellers = t || []), error: () => {} });
|
||||||
|
|||||||
@@ -202,6 +202,7 @@
|
|||||||
<th class="num">Amount</th>
|
<th class="num">Amount</th>
|
||||||
<th>Payment</th>
|
<th>Payment</th>
|
||||||
<th>Status</th>
|
<th>Status</th>
|
||||||
|
<th>Correction</th>
|
||||||
<th>By</th>
|
<th>By</th>
|
||||||
<th>Approved By</th>
|
<th>Approved By</th>
|
||||||
<th>Time</th>
|
<th>Time</th>
|
||||||
@@ -231,6 +232,18 @@
|
|||||||
<td>
|
<td>
|
||||||
<span [class]="statusClass(r.status)">{{ r.status }}</span>
|
<span [class]="statusClass(r.status)">{{ r.status }}</span>
|
||||||
</td>
|
</td>
|
||||||
|
<td>
|
||||||
|
<!-- Where the voucher stands after a correction: unpassed, revoked, closed or corrected. -->
|
||||||
|
<mifosx-voucher-status
|
||||||
|
[status]="r.voucherStatus"
|
||||||
|
[revokedBy]="r.revokedByVoucherNo"
|
||||||
|
[replaces]="r.replacesVoucherNo"
|
||||||
|
[note]="r.correctionNote"
|
||||||
|
></mifosx-voucher-status>
|
||||||
|
@if (!r.voucherStatus || r.voucherStatus === 'ACTIVE') {
|
||||||
|
<span class="db-muted">—</span>
|
||||||
|
}
|
||||||
|
</td>
|
||||||
<td>{{ r.handledBy ?? '—' }}</td>
|
<td>{{ r.handledBy ?? '—' }}</td>
|
||||||
<td>
|
<td>
|
||||||
{{ r.approvedBy ?? '—' }}
|
{{ r.approvedBy ?? '—' }}
|
||||||
@@ -256,7 +269,7 @@
|
|||||||
—
|
—
|
||||||
}
|
}
|
||||||
</td>
|
</td>
|
||||||
<td colspan="5"></td>
|
<td colspan="6"></td>
|
||||||
</tr>
|
</tr>
|
||||||
</tfoot>
|
</tfoot>
|
||||||
</table>
|
</table>
|
||||||
|
|||||||
@@ -13,6 +13,8 @@ import { MatIconModule } from '@angular/material/icon';
|
|||||||
import { Router } from '@angular/router';
|
import { Router } from '@angular/router';
|
||||||
import { catchError, of } from 'rxjs';
|
import { catchError, of } from 'rxjs';
|
||||||
import { CashierService } from '../cashier.service';
|
import { CashierService } from '../cashier.service';
|
||||||
|
import { businessToday } from 'app/core/utils/business-today';
|
||||||
|
import { VoucherStatusComponent, VoucherStatus } from '../voucher-status/voucher-status.component';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Which part of the day's work a row came from. Set by the server, one per source register.
|
* Which part of the day's work a row came from. Set by the server, one per source register.
|
||||||
@@ -25,6 +27,11 @@ type DayBookSection = 'CASHIER' | 'CLERK' | 'SYSTEM';
|
|||||||
|
|
||||||
interface DayBookRow {
|
interface DayBookRow {
|
||||||
voucherNo: string | null;
|
voucherNo: string | null;
|
||||||
|
/** Where the voucher stands after a correction: active, revoked, under correction, or corrected. */
|
||||||
|
voucherStatus?: VoucherStatus;
|
||||||
|
revokedByVoucherNo?: string | null;
|
||||||
|
replacesVoucherNo?: string | null;
|
||||||
|
correctionNote?: string | null;
|
||||||
section: DayBookSection;
|
section: DayBookSection;
|
||||||
side: 'CR' | 'DR';
|
side: 'CR' | 'DR';
|
||||||
kind: string;
|
kind: string;
|
||||||
@@ -57,7 +64,8 @@ interface DayBookRow {
|
|||||||
imports: [
|
imports: [
|
||||||
CommonModule,
|
CommonModule,
|
||||||
FormsModule,
|
FormsModule,
|
||||||
MatIconModule
|
MatIconModule,
|
||||||
|
VoucherStatusComponent
|
||||||
]
|
]
|
||||||
})
|
})
|
||||||
export class DayBookComponent implements OnInit {
|
export class DayBookComponent implements OnInit {
|
||||||
@@ -95,7 +103,7 @@ export class DayBookComponent implements OnInit {
|
|||||||
|
|
||||||
/** Local date as yyyy-MM-dd — toISOString() would shift the day in any timezone behind UTC. */
|
/** Local date as yyyy-MM-dd — toISOString() would shift the day in any timezone behind UTC. */
|
||||||
private today(): string {
|
private today(): string {
|
||||||
const d = new Date();
|
const d = businessToday();
|
||||||
return `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, '0')}-${String(d.getDate()).padStart(2, '0')}`;
|
return `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, '0')}-${String(d.getDate()).padStart(2, '0')}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -11,41 +11,29 @@
|
|||||||
<div>
|
<div>
|
||||||
<div class="pg-eyebrow">CASHIER · INTER-CASHIER TRANSFER</div>
|
<div class="pg-eyebrow">CASHIER · INTER-CASHIER TRANSFER</div>
|
||||||
<h1 class="pg-title">Inter-Cashier Transfer</h1>
|
<h1 class="pg-title">Inter-Cashier Transfer</h1>
|
||||||
<p class="pg-sub">Move denomination-wise cash between two cashiers within the same teller.</p>
|
<p class="pg-sub">Hand over denomination-wise cash from your drawer to another cashier at your counter.</p>
|
||||||
</div>
|
</div>
|
||||||
<button type="button" class="pg-btn pg-btn--ghost" (click)="clear()"><mat-icon>undo</mat-icon> Clear</button>
|
<button type="button" class="pg-btn pg-btn--ghost" (click)="clear()"><mat-icon>undo</mat-icon> Clear</button>
|
||||||
</header>
|
</header>
|
||||||
|
|
||||||
<div class="pg-toolbar pg-toolbar--mat">
|
@if (ownCashierMissing) {
|
||||||
@if (tellers.length === 1) {
|
<div class="ict-alert">
|
||||||
<mat-form-field appearance="outline" subscriptSizing="dynamic">
|
<mat-icon>person_off</mat-icon>
|
||||||
<mat-label>Teller</mat-label>
|
Your login is not linked to a cashier. An inter-cashier transfer is made by the cashier handing the cash over, so
|
||||||
<input matInput type="text" [value]="tellers[0].name" readonly />
|
log in as that cashier.
|
||||||
</mat-form-field>
|
</div>
|
||||||
} @else {
|
|
||||||
<mat-form-field appearance="outline" subscriptSizing="dynamic">
|
|
||||||
<mat-label>Teller</mat-label>
|
|
||||||
<mat-select [value]="selectedTellerId" (selectionChange)="onTellerSelect($event.value)">
|
|
||||||
@for (t of tellers; track t.id) {
|
|
||||||
<mat-option [value]="t.id">{{ t.name }}</mat-option>
|
|
||||||
}
|
|
||||||
@if (tellers.length === 0) {
|
|
||||||
<mat-option [value]="null" disabled>No tellers configured</mat-option>
|
|
||||||
}
|
|
||||||
</mat-select>
|
|
||||||
</mat-form-field>
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
<div class="pg-toolbar pg-toolbar--mat">
|
||||||
<mat-form-field appearance="outline" subscriptSizing="dynamic">
|
<mat-form-field appearance="outline" subscriptSizing="dynamic">
|
||||||
<mat-label>From (Cashier)</mat-label>
|
<mat-label>Teller</mat-label>
|
||||||
<mat-select [value]="fromCashierId" (selectionChange)="onFromSelect($event.value)" [disabled]="!selectedTellerId">
|
<input matInput type="text" [value]="tellerName || '—'" readonly />
|
||||||
@for (c of availableFrom; track c.id) {
|
</mat-form-field>
|
||||||
<mat-option [value]="c.id">{{ c.label }}</mat-option>
|
|
||||||
}
|
<!-- The sender is always the cashier who is logged in; nothing to pick. -->
|
||||||
@if (selectedTellerId && allCashiers.length === 0 && !loadingCashiers) {
|
<mat-form-field appearance="outline" subscriptSizing="dynamic">
|
||||||
<mat-option [value]="null" disabled>No cashiers</mat-option>
|
<mat-label>From (You)</mat-label>
|
||||||
}
|
<input matInput type="text" [value]="fromParty || '—'" readonly />
|
||||||
</mat-select>
|
|
||||||
</mat-form-field>
|
</mat-form-field>
|
||||||
|
|
||||||
<mat-form-field appearance="outline" subscriptSizing="dynamic">
|
<mat-form-field appearance="outline" subscriptSizing="dynamic">
|
||||||
@@ -54,7 +42,7 @@
|
|||||||
@for (c of availableTo; track c.id) {
|
@for (c of availableTo; track c.id) {
|
||||||
<mat-option [value]="c.id">{{ c.label }}</mat-option>
|
<mat-option [value]="c.id">{{ c.label }}</mat-option>
|
||||||
}
|
}
|
||||||
@if (fromCashierId && availableTo.length === 0) {
|
@if (fromCashierId && !loadingCashiers && availableTo.length === 0) {
|
||||||
<mat-option [value]="null" disabled>No other cashiers</mat-option>
|
<mat-option [value]="null" disabled>No other cashiers</mat-option>
|
||||||
}
|
}
|
||||||
</mat-select>
|
</mat-select>
|
||||||
@@ -62,7 +50,7 @@
|
|||||||
|
|
||||||
<mat-form-field appearance="outline" subscriptSizing="dynamic">
|
<mat-form-field appearance="outline" subscriptSizing="dynamic">
|
||||||
<mat-label>Date</mat-label>
|
<mat-label>Date</mat-label>
|
||||||
<input matInput type="date" [value]="transferDate" (change)="transferDate = $any($event.target).value" />
|
<input matInput type="date" [value]="transferDate" (change)="onDateChange($any($event.target).value)" />
|
||||||
</mat-form-field>
|
</mat-form-field>
|
||||||
|
|
||||||
<mat-form-field appearance="outline" subscriptSizing="dynamic" class="pg-note-field">
|
<mat-form-field appearance="outline" subscriptSizing="dynamic" class="pg-note-field">
|
||||||
@@ -71,26 +59,102 @@
|
|||||||
</mat-form-field>
|
</mat-form-field>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<!-- Both sides of the transfer: who they are, whether the vault has allocated them cash today,
|
||||||
|
and what they hold now. -->
|
||||||
|
@if (fromCashierId || toCashierId) {
|
||||||
|
<div class="ict-parties">
|
||||||
|
<ng-container
|
||||||
|
*ngTemplateOutlet="
|
||||||
|
partyCard;
|
||||||
|
context: { title: 'From (Sending Cashier)', s: fromSummary, loading: loadingFrom, picked: !!fromCashierId }
|
||||||
|
"
|
||||||
|
></ng-container>
|
||||||
|
<mat-icon class="ict-parties__arrow">arrow_forward</mat-icon>
|
||||||
|
<ng-container
|
||||||
|
*ngTemplateOutlet="
|
||||||
|
partyCard;
|
||||||
|
context: { title: 'To (Receiving Cashier)', s: toSummary, loading: loadingTo, picked: !!toCashierId }
|
||||||
|
"
|
||||||
|
></ng-container>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
|
||||||
|
<ng-template #partyCard let-title="title" let-s="s" let-loading="loading" let-picked="picked">
|
||||||
|
<div class="ict-party" [class.ict-party--blocked]="s && !s.cashAllocated">
|
||||||
|
<div class="ict-party__title">{{ title }}</div>
|
||||||
|
@if (!picked) {
|
||||||
|
<div class="ict-party__empty">Select a cashier</div>
|
||||||
|
} @else if (loading) {
|
||||||
|
<div class="ict-party__empty">Loading…</div>
|
||||||
|
} @else if (!s) {
|
||||||
|
<div class="ict-party__empty">Could not load this cashier's details.</div>
|
||||||
|
} @else {
|
||||||
|
<div class="ict-party__name">{{ s.cashierName || '—' }}</div>
|
||||||
|
<div class="ict-party__meta">
|
||||||
|
<span><mat-icon>point_of_sale</mat-icon> {{ s.tellerName || '—' }}</span>
|
||||||
|
<span><mat-icon>account_balance</mat-icon> {{ s.officeName || '—' }}</span>
|
||||||
|
</div>
|
||||||
|
<div class="ict-party__figures">
|
||||||
|
<div>
|
||||||
|
<div class="ict-party__label">Allocated today</div>
|
||||||
|
<div class="ict-party__value">₹{{ s.allocatedAmount | number: '1.2-2' }}</div>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<div class="ict-party__label">Cash in hand</div>
|
||||||
|
<div class="ict-party__value">₹{{ s.totalAmount | number: '1.2-2' }}</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
@if (!s.cashAllocated) {
|
||||||
|
<div class="ict-party__warn">
|
||||||
|
<mat-icon>block</mat-icon> No cash allocated from the vault for this date — inter-cashier transfer is not
|
||||||
|
allowed.
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
</ng-template>
|
||||||
|
|
||||||
|
@if (fromSummary && toSummary && !bothAllocated) {
|
||||||
|
<div class="ict-alert">
|
||||||
|
<mat-icon>block</mat-icon>
|
||||||
|
Both cashiers must first be allocated cash from the vault (Cash Transfer) for {{ transferDate }} before an
|
||||||
|
inter-cashier transfer.
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
@if (anyExceedsHeld) {
|
||||||
|
<div class="ict-alert">
|
||||||
|
<mat-icon>error_outline</mat-icon>
|
||||||
|
The sending cashier does not hold enough of the highlighted denominations.
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
|
||||||
<div class="pg-card">
|
<div class="pg-card">
|
||||||
<table class="pg-table">
|
<table class="pg-table">
|
||||||
<thead>
|
<thead>
|
||||||
<tr>
|
<tr>
|
||||||
<th>Denomination</th>
|
<th>Denomination</th>
|
||||||
|
<th class="num">From holds</th>
|
||||||
<th style="text-align: center">Count</th>
|
<th style="text-align: center">Count</th>
|
||||||
<th class="num">Amount</th>
|
<th class="num">Amount</th>
|
||||||
|
<th class="num">To holds</th>
|
||||||
</tr>
|
</tr>
|
||||||
</thead>
|
</thead>
|
||||||
<tbody>
|
<tbody>
|
||||||
@if (notes.length) {
|
@if (notes.length) {
|
||||||
<tr class="pg-section">
|
<tr class="pg-section">
|
||||||
<td colspan="3">Bank Notes</td>
|
<td colspan="5">Bank Notes</td>
|
||||||
</tr>
|
</tr>
|
||||||
}
|
}
|
||||||
@for (d of notes; track d.id) {
|
@for (d of notes; track d.id) {
|
||||||
<tr>
|
<tr [class.ict-row--over]="exceedsHeld(d)">
|
||||||
<td>
|
<td>
|
||||||
<span class="pg-chip">{{ d.label }}</span>
|
<span class="pg-chip">{{ d.label }}</span>
|
||||||
</td>
|
</td>
|
||||||
|
<td class="num" [class.ict-held--over]="exceedsHeld(d)">
|
||||||
|
<ng-container
|
||||||
|
*ngTemplateOutlet="heldCell; context: { n: heldBy(fromSummary, d), delta: -countFor(d) }"
|
||||||
|
></ng-container>
|
||||||
|
</td>
|
||||||
<td style="display: flex; justify-content: center">
|
<td style="display: flex; justify-content: center">
|
||||||
<div class="pg-step">
|
<div class="pg-step">
|
||||||
<button type="button" (click)="bump(d, -1)" [disabled]="countFor(d) === 0">−</button>
|
<button type="button" (click)="bump(d, -1)" [disabled]="countFor(d) === 0">−</button>
|
||||||
@@ -99,18 +163,28 @@
|
|||||||
</div>
|
</div>
|
||||||
</td>
|
</td>
|
||||||
<td class="num">₹{{ subtotal(d) | number: '1.2-2' }}</td>
|
<td class="num">₹{{ subtotal(d) | number: '1.2-2' }}</td>
|
||||||
|
<td class="num">
|
||||||
|
<ng-container
|
||||||
|
*ngTemplateOutlet="heldCell; context: { n: heldBy(toSummary, d), delta: countFor(d) }"
|
||||||
|
></ng-container>
|
||||||
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
}
|
}
|
||||||
@if (coins.length) {
|
@if (coins.length) {
|
||||||
<tr class="pg-section">
|
<tr class="pg-section">
|
||||||
<td colspan="3">Coins</td>
|
<td colspan="5">Coins</td>
|
||||||
</tr>
|
</tr>
|
||||||
}
|
}
|
||||||
@for (d of coins; track d.id) {
|
@for (d of coins; track d.id) {
|
||||||
<tr>
|
<tr [class.ict-row--over]="exceedsHeld(d)">
|
||||||
<td>
|
<td>
|
||||||
<span class="pg-chip pg-chip--coin">{{ d.label }}</span>
|
<span class="pg-chip pg-chip--coin">{{ d.label }}</span>
|
||||||
</td>
|
</td>
|
||||||
|
<td class="num" [class.ict-held--over]="exceedsHeld(d)">
|
||||||
|
<ng-container
|
||||||
|
*ngTemplateOutlet="heldCell; context: { n: heldBy(fromSummary, d), delta: -countFor(d) }"
|
||||||
|
></ng-container>
|
||||||
|
</td>
|
||||||
<td style="display: flex; justify-content: center">
|
<td style="display: flex; justify-content: center">
|
||||||
<div class="pg-step">
|
<div class="pg-step">
|
||||||
<button type="button" (click)="bump(d, -1)" [disabled]="countFor(d) === 0">−</button>
|
<button type="button" (click)="bump(d, -1)" [disabled]="countFor(d) === 0">−</button>
|
||||||
@@ -119,17 +193,34 @@
|
|||||||
</div>
|
</div>
|
||||||
</td>
|
</td>
|
||||||
<td class="num">₹{{ subtotal(d) | number: '1.2-2' }}</td>
|
<td class="num">₹{{ subtotal(d) | number: '1.2-2' }}</td>
|
||||||
|
<td class="num">
|
||||||
|
<ng-container
|
||||||
|
*ngTemplateOutlet="heldCell; context: { n: heldBy(toSummary, d), delta: countFor(d) }"
|
||||||
|
></ng-container>
|
||||||
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
}
|
}
|
||||||
@if (denoms.length === 0) {
|
@if (denoms.length === 0) {
|
||||||
<tr>
|
<tr>
|
||||||
<td colspan="3" class="pg-empty">No active denominations. Add some in Cash Denomination.</td>
|
<td colspan="5" class="pg-empty">No active denominations. Add some in Cash Denomination.</td>
|
||||||
</tr>
|
</tr>
|
||||||
}
|
}
|
||||||
</tbody>
|
</tbody>
|
||||||
</table>
|
</table>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<!-- A held count, with what it becomes once this transfer is recorded. -->
|
||||||
|
<ng-template #heldCell let-n="n" let-delta="delta">
|
||||||
|
@if (n === null) {
|
||||||
|
—
|
||||||
|
} @else {
|
||||||
|
{{ n }}
|
||||||
|
@if (delta !== 0) {
|
||||||
|
<span class="ict-after">→ {{ n + delta }}</span>
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</ng-template>
|
||||||
|
|
||||||
<div class="pg-total-bar">
|
<div class="pg-total-bar">
|
||||||
Total to transfer: <strong>₹{{ total | number: '1.2-2' }}</strong>
|
Total to transfer: <strong>₹{{ total | number: '1.2-2' }}</strong>
|
||||||
<button type="button" class="pg-btn pg-btn--primary" [disabled]="!valid || saving" (click)="submit()">
|
<button type="button" class="pg-btn pg-btn--primary" [disabled]="!valid || saving" (click)="submit()">
|
||||||
|
|||||||
@@ -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/.
|
||||||
|
*/
|
||||||
|
|
||||||
|
.ict-parties {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: 1fr auto 1fr;
|
||||||
|
align-items: stretch;
|
||||||
|
gap: 12px;
|
||||||
|
margin-bottom: 12px;
|
||||||
|
|
||||||
|
&__arrow {
|
||||||
|
align-self: center;
|
||||||
|
color: #64748b;
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (width <= 760px) {
|
||||||
|
grid-template-columns: 1fr;
|
||||||
|
|
||||||
|
&__arrow {
|
||||||
|
justify-self: center;
|
||||||
|
transform: rotate(90deg);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.ict-party {
|
||||||
|
padding: 12px 14px;
|
||||||
|
background: #fff;
|
||||||
|
border: 1px solid #e2e8f0;
|
||||||
|
border-radius: 10px;
|
||||||
|
|
||||||
|
&--blocked {
|
||||||
|
background: #fef2f2;
|
||||||
|
border-color: #fecaca;
|
||||||
|
}
|
||||||
|
|
||||||
|
&__title {
|
||||||
|
font-size: 11px;
|
||||||
|
font-weight: 700;
|
||||||
|
letter-spacing: 0.04em;
|
||||||
|
text-transform: uppercase;
|
||||||
|
color: #64748b;
|
||||||
|
}
|
||||||
|
|
||||||
|
&__empty {
|
||||||
|
margin-top: 8px;
|
||||||
|
font-size: 13px;
|
||||||
|
color: #94a3b8;
|
||||||
|
}
|
||||||
|
|
||||||
|
&__name {
|
||||||
|
margin-top: 4px;
|
||||||
|
font-size: 16px;
|
||||||
|
font-weight: 700;
|
||||||
|
color: #0f172a;
|
||||||
|
}
|
||||||
|
|
||||||
|
&__meta {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: 14px;
|
||||||
|
margin-top: 4px;
|
||||||
|
font-size: 12px;
|
||||||
|
color: #475569;
|
||||||
|
|
||||||
|
span {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
mat-icon {
|
||||||
|
font-size: 14px;
|
||||||
|
width: 14px;
|
||||||
|
height: 14px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
&__figures {
|
||||||
|
display: flex;
|
||||||
|
gap: 24px;
|
||||||
|
margin-top: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
&__label {
|
||||||
|
font-size: 11px;
|
||||||
|
color: #64748b;
|
||||||
|
}
|
||||||
|
|
||||||
|
&__value {
|
||||||
|
font-size: 15px;
|
||||||
|
font-weight: 700;
|
||||||
|
color: #0f172a;
|
||||||
|
}
|
||||||
|
|
||||||
|
&__warn {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 6px;
|
||||||
|
margin-top: 10px;
|
||||||
|
font-size: 12px;
|
||||||
|
font-weight: 600;
|
||||||
|
color: #991b1b;
|
||||||
|
|
||||||
|
mat-icon {
|
||||||
|
font-size: 16px;
|
||||||
|
width: 16px;
|
||||||
|
height: 16px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.ict-alert {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
margin-bottom: 10px;
|
||||||
|
padding: 8px 14px;
|
||||||
|
background: #fef2f2;
|
||||||
|
border: 1px solid #fecaca;
|
||||||
|
border-radius: 8px;
|
||||||
|
font-size: 12px;
|
||||||
|
color: #991b1b;
|
||||||
|
|
||||||
|
mat-icon {
|
||||||
|
font-size: 16px;
|
||||||
|
width: 16px;
|
||||||
|
height: 16px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.ict-row--over {
|
||||||
|
background: #fef2f2;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ict-held--over {
|
||||||
|
color: #b91c1c;
|
||||||
|
font-weight: 700;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ict-after {
|
||||||
|
margin-left: 4px;
|
||||||
|
font-size: 11px;
|
||||||
|
color: #64748b;
|
||||||
|
}
|
||||||
@@ -17,6 +17,7 @@ import { MatInputModule } from '@angular/material/input';
|
|||||||
import { of } from 'rxjs';
|
import { of } from 'rxjs';
|
||||||
import { catchError } from 'rxjs/operators';
|
import { catchError } from 'rxjs/operators';
|
||||||
import { CashierService } from '../cashier.service';
|
import { CashierService } from '../cashier.service';
|
||||||
|
import { businessToday } from 'app/core/utils/business-today';
|
||||||
|
|
||||||
interface Denom {
|
interface Denom {
|
||||||
id: number;
|
id: number;
|
||||||
@@ -28,12 +29,27 @@ interface Cashier {
|
|||||||
id: number;
|
id: number;
|
||||||
label: string;
|
label: string;
|
||||||
}
|
}
|
||||||
|
/** One side of the transfer, from GET /cashintercashier/cashier-summary. */
|
||||||
|
interface CashierSummary {
|
||||||
|
cashierId: number;
|
||||||
|
cashierName: string;
|
||||||
|
tellerName: string;
|
||||||
|
officeName: string;
|
||||||
|
cashAllocated: boolean;
|
||||||
|
allocatedAmount: number;
|
||||||
|
totalAmount: number;
|
||||||
|
/** Count held per denomination id. */
|
||||||
|
held: Record<number, number>;
|
||||||
|
}
|
||||||
|
|
||||||
/** Move cash denomination-wise between two cashiers under the same teller. */
|
/** Move cash denomination-wise between two cashiers under the same teller. */
|
||||||
@Component({
|
@Component({
|
||||||
selector: 'mifosx-inter-cashier-transfer',
|
selector: 'mifosx-inter-cashier-transfer',
|
||||||
templateUrl: './inter-cashier-transfer.component.html',
|
templateUrl: './inter-cashier-transfer.component.html',
|
||||||
styleUrls: ['../cashier-shared.scss'],
|
styleUrls: [
|
||||||
|
'../cashier-shared.scss',
|
||||||
|
'./inter-cashier-transfer.component.scss'
|
||||||
|
],
|
||||||
standalone: true,
|
standalone: true,
|
||||||
imports: [
|
imports: [
|
||||||
CommonModule,
|
CommonModule,
|
||||||
@@ -56,7 +72,12 @@ export class InterCashierTransferComponent implements OnInit {
|
|||||||
note = '';
|
note = '';
|
||||||
saving = false;
|
saving = false;
|
||||||
|
|
||||||
tellers: { id: number; name: string }[] = [];
|
/**
|
||||||
|
* The sending cashier is always the one logged in: cash is handed over from their own drawer, so there is
|
||||||
|
* no one else to pick on that side. Only the receiving cashier is chosen.
|
||||||
|
*/
|
||||||
|
ownCashierMissing = false;
|
||||||
|
tellerName = '';
|
||||||
allCashiers: Cashier[] = [];
|
allCashiers: Cashier[] = [];
|
||||||
selectedTellerId: number | null = null;
|
selectedTellerId: number | null = null;
|
||||||
fromCashierId: number | null = null;
|
fromCashierId: number | null = null;
|
||||||
@@ -65,21 +86,25 @@ export class InterCashierTransferComponent implements OnInit {
|
|||||||
toParty = '';
|
toParty = '';
|
||||||
loadingCashiers = false;
|
loadingCashiers = false;
|
||||||
|
|
||||||
|
/** Details and denomination-wise cash of each side, so both are seen before the transfer is recorded. */
|
||||||
|
fromSummary: CashierSummary | null = null;
|
||||||
|
toSummary: CashierSummary | null = null;
|
||||||
|
loadingFrom = false;
|
||||||
|
loadingTo = false;
|
||||||
|
|
||||||
get denoms(): Denom[] {
|
get denoms(): Denom[] {
|
||||||
return [
|
return [
|
||||||
...this.notes,
|
...this.notes,
|
||||||
...this.coins
|
...this.coins
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
get availableFrom(): Cashier[] {
|
/** Everyone under the same teller except the sender. */
|
||||||
return this.allCashiers;
|
|
||||||
}
|
|
||||||
get availableTo(): Cashier[] {
|
get availableTo(): Cashier[] {
|
||||||
return this.allCashiers.filter((c) => c.id !== this.fromCashierId);
|
return this.allCashiers.filter((c) => c.id !== this.fromCashierId);
|
||||||
}
|
}
|
||||||
|
|
||||||
ngOnInit(): void {
|
ngOnInit(): void {
|
||||||
const d = new Date();
|
const d = businessToday();
|
||||||
this.transferDate = `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, '0')}-${String(d.getDate()).padStart(2, '0')}`;
|
this.transferDate = `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, '0')}-${String(d.getDate()).padStart(2, '0')}`;
|
||||||
this.service.getActiveDenominations().subscribe({
|
this.service.getActiveDenominations().subscribe({
|
||||||
next: (list) => {
|
next: (list) => {
|
||||||
@@ -97,25 +122,20 @@ export class InterCashierTransferComponent implements OnInit {
|
|||||||
this.coins = [];
|
this.coins = [];
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
this.service
|
// The sender is the cashier behind this login; their counter then gives the receivers to pick from.
|
||||||
.getTellers()
|
this.service.getCurrentCashierInfo().subscribe((info) => {
|
||||||
.pipe(catchError(() => of([] as any[])))
|
if (!info) {
|
||||||
.subscribe((list) => {
|
this.ownCashierMissing = true;
|
||||||
this.tellers = (list || []).map((t: any) => ({ id: t.id, name: t.name }));
|
return;
|
||||||
if (this.tellers.length === 1) {
|
|
||||||
this.onTellerSelect(this.tellers[0].id);
|
|
||||||
}
|
}
|
||||||
|
this.fromCashierId = info.cashierId;
|
||||||
|
this.fromParty = info.cashierName;
|
||||||
|
this.loadSummary('from');
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
onTellerSelect(tellerId: number): void {
|
/** The other cashiers at this counter, one of whom receives the cash. */
|
||||||
this.selectedTellerId = tellerId;
|
private loadReceivers(tellerId: number): void {
|
||||||
this.fromCashierId = null;
|
|
||||||
this.toCashierId = null;
|
|
||||||
this.fromParty = '';
|
|
||||||
this.toParty = '';
|
|
||||||
this.allCashiers = [];
|
|
||||||
if (!tellerId) return;
|
|
||||||
this.loadingCashiers = true;
|
this.loadingCashiers = true;
|
||||||
this.service
|
this.service
|
||||||
.getCashiersForTeller(tellerId)
|
.getCashiersForTeller(tellerId)
|
||||||
@@ -126,20 +146,90 @@ export class InterCashierTransferComponent implements OnInit {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
onFromSelect(cashierId: number): void {
|
|
||||||
this.fromCashierId = cashierId;
|
|
||||||
const c = this.allCashiers.find((x) => x.id === cashierId);
|
|
||||||
this.fromParty = c ? c.label : '';
|
|
||||||
if (this.toCashierId === cashierId) {
|
|
||||||
this.toCashierId = null;
|
|
||||||
this.toParty = '';
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
onToSelect(cashierId: number): void {
|
onToSelect(cashierId: number): void {
|
||||||
this.toCashierId = cashierId;
|
this.toCashierId = cashierId;
|
||||||
const c = this.allCashiers.find((x) => x.id === cashierId);
|
const c = this.allCashiers.find((x) => x.id === cashierId);
|
||||||
this.toParty = c ? c.label : '';
|
this.toParty = c ? c.label : '';
|
||||||
|
this.loadSummary('to');
|
||||||
|
}
|
||||||
|
|
||||||
|
onDateChange(value: string): void {
|
||||||
|
this.transferDate = value;
|
||||||
|
this.loadSummary('from');
|
||||||
|
this.loadSummary('to');
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Fetch one side's details, vault allocation and denomination-wise cash for the transfer date. */
|
||||||
|
private loadSummary(side: 'from' | 'to'): void {
|
||||||
|
const cashierId = side === 'from' ? this.fromCashierId : this.toCashierId;
|
||||||
|
if (side === 'from') {
|
||||||
|
this.fromSummary = null;
|
||||||
|
} else {
|
||||||
|
this.toSummary = null;
|
||||||
|
}
|
||||||
|
if (!cashierId || !this.transferDate) return;
|
||||||
|
this.setLoading(side, true);
|
||||||
|
this.service
|
||||||
|
.getInterCashierCashierSummary(cashierId, this.transferDate)
|
||||||
|
.pipe(catchError(() => of(null)))
|
||||||
|
.subscribe((r: any) => {
|
||||||
|
this.setLoading(side, false);
|
||||||
|
// A late answer for a cashier no longer selected on that side is dropped.
|
||||||
|
const current = side === 'from' ? this.fromCashierId : this.toCashierId;
|
||||||
|
if (!r || current !== cashierId) return;
|
||||||
|
// The sender's summary also tells which counter they are at, hence who they can hand cash to.
|
||||||
|
if (side === 'from' && r.tellerId && this.selectedTellerId !== Number(r.tellerId)) {
|
||||||
|
this.selectedTellerId = Number(r.tellerId);
|
||||||
|
this.tellerName = r.tellerName || '';
|
||||||
|
this.loadReceivers(this.selectedTellerId);
|
||||||
|
}
|
||||||
|
const held: Record<number, number> = {};
|
||||||
|
for (const d of r.denominations || []) {
|
||||||
|
held[d.denominationId] = (held[d.denominationId] || 0) + Number(d.count || 0);
|
||||||
|
}
|
||||||
|
const summary: CashierSummary = {
|
||||||
|
cashierId: r.cashierId,
|
||||||
|
cashierName: r.cashierName,
|
||||||
|
tellerName: r.tellerName,
|
||||||
|
officeName: r.officeName,
|
||||||
|
cashAllocated: !!r.cashAllocated,
|
||||||
|
allocatedAmount: Number(r.allocatedAmount || 0),
|
||||||
|
totalAmount: Number(r.totalAmount || 0),
|
||||||
|
held
|
||||||
|
};
|
||||||
|
if (side === 'from') {
|
||||||
|
this.fromSummary = summary;
|
||||||
|
} else {
|
||||||
|
this.toSummary = summary;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
private setLoading(side: 'from' | 'to', loading: boolean): void {
|
||||||
|
if (side === 'from') {
|
||||||
|
this.loadingFrom = loading;
|
||||||
|
} else {
|
||||||
|
this.loadingTo = loading;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Count of the denomination the cashier holds, or null while their figures are not loaded. */
|
||||||
|
heldBy(summary: CashierSummary | null, d: Denom): number | null {
|
||||||
|
return summary ? summary.held[d.id] || 0 : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The sending cashier is asked to hand on more of this denomination than they hold. */
|
||||||
|
exceedsHeld(d: Denom): boolean {
|
||||||
|
return !!this.fromSummary && this.countFor(d) > (this.fromSummary.held[d.id] || 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
get anyExceedsHeld(): boolean {
|
||||||
|
return this.denoms.some((d) => this.exceedsHeld(d));
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Both sides must have been allocated cash from the vault for the day before they can transfer. */
|
||||||
|
get bothAllocated(): boolean {
|
||||||
|
return !!this.fromSummary?.cashAllocated && !!this.toSummary?.cashAllocated;
|
||||||
}
|
}
|
||||||
|
|
||||||
countFor(d: Denom): number {
|
countFor(d: Denom): number {
|
||||||
@@ -159,7 +249,14 @@ export class InterCashierTransferComponent implements OnInit {
|
|||||||
return this.denoms.reduce((s, d) => s + this.subtotal(d), 0);
|
return this.denoms.reduce((s, d) => s + this.subtotal(d), 0);
|
||||||
}
|
}
|
||||||
get valid(): boolean {
|
get valid(): boolean {
|
||||||
return this.total > 0 && !!this.selectedTellerId && !!this.fromCashierId && !!this.toCashierId;
|
return (
|
||||||
|
this.total > 0 &&
|
||||||
|
!!this.selectedTellerId &&
|
||||||
|
!!this.fromCashierId &&
|
||||||
|
!!this.toCashierId &&
|
||||||
|
this.bothAllocated &&
|
||||||
|
!this.anyExceedsHeld
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
clear(): void {
|
clear(): void {
|
||||||
@@ -186,10 +283,12 @@ export class InterCashierTransferComponent implements OnInit {
|
|||||||
this.snackBar.open('Inter-cashier transfer recorded', 'Close', { duration: 3000 });
|
this.snackBar.open('Inter-cashier transfer recorded', 'Close', { duration: 3000 });
|
||||||
this.clear();
|
this.clear();
|
||||||
this.note = '';
|
this.note = '';
|
||||||
this.fromCashierId = null;
|
// The sender stays the logged-in cashier; the receiver is picked afresh, and the sender's holdings
|
||||||
|
// are read again now that part of them has gone.
|
||||||
this.toCashierId = null;
|
this.toCashierId = null;
|
||||||
this.fromParty = '';
|
|
||||||
this.toParty = '';
|
this.toParty = '';
|
||||||
|
this.toSummary = null;
|
||||||
|
this.loadSummary('from');
|
||||||
this.saving = false;
|
this.saving = false;
|
||||||
},
|
},
|
||||||
error: () => {
|
error: () => {
|
||||||
|
|||||||
@@ -146,15 +146,28 @@
|
|||||||
<th class="num">Amount</th>
|
<th class="num">Amount</th>
|
||||||
<th>My part</th>
|
<th>My part</th>
|
||||||
<th>Status</th>
|
<th>Status</th>
|
||||||
|
<th>Correction</th>
|
||||||
<th>Time</th>
|
<th>Time</th>
|
||||||
</tr>
|
</tr>
|
||||||
</thead>
|
</thead>
|
||||||
<tbody>
|
<tbody>
|
||||||
@for (r of g.rows; track r.voucherNo) {
|
@for (r of g.rows; track r.voucherNo) {
|
||||||
<!-- The left edge repeats the section's own colour, so a row still says which part it
|
<!-- The left edge repeats the section's own colour, so a row still says which part it
|
||||||
is when the heading has scrolled away. -->
|
is when the heading has scrolled away. Clicking anywhere on the row opens the full
|
||||||
<tr [class]="'mw-row mw-row--' + r.myRole">
|
slip -- every line, not just the first debit and first credit this table has room
|
||||||
<td class="mw-vno">{{ r.voucherNo }}</td>
|
to show. -->
|
||||||
|
<tr
|
||||||
|
[class]="'mw-row mw-row--' + r.myRole"
|
||||||
|
class="mw-row--clickable"
|
||||||
|
(click)="openDetail(r.voucherNo)"
|
||||||
|
[attr.aria-busy]="loadingDetailFor === r.voucherNo"
|
||||||
|
>
|
||||||
|
<td class="mw-vno">
|
||||||
|
{{ r.voucherNo }}
|
||||||
|
@if (loadingDetailFor === r.voucherNo) {
|
||||||
|
<mat-icon class="mw-vno__spinner" inline>hourglass_empty</mat-icon>
|
||||||
|
}
|
||||||
|
</td>
|
||||||
<td>
|
<td>
|
||||||
<span class="mw-badge" [class]="'mw-badge--' + (r.side || 'JV')">{{ sideLabel(r.side) }}</span>
|
<span class="mw-badge" [class]="'mw-badge--' + (r.side || 'JV')">{{ sideLabel(r.side) }}</span>
|
||||||
</td>
|
</td>
|
||||||
@@ -171,7 +184,7 @@
|
|||||||
<span class="mw-acct">{{ r.creditAccount }}</span>
|
<span class="mw-acct">{{ r.creditAccount }}</span>
|
||||||
}
|
}
|
||||||
@if (r.extraLines > 0) {
|
@if (r.extraLines > 0) {
|
||||||
<span class="mw-more">+{{ r.extraLines }} more</span>
|
<span class="mw-more" title="Click the row to see every line">+{{ r.extraLines }} more</span>
|
||||||
}
|
}
|
||||||
</td>
|
</td>
|
||||||
<td class="num">₹{{ r.amount | number: '1.2-2' }}</td>
|
<td class="num">₹{{ r.amount | number: '1.2-2' }}</td>
|
||||||
@@ -191,6 +204,18 @@
|
|||||||
{{ statusLabel(r.verificationStatus) }}
|
{{ statusLabel(r.verificationStatus) }}
|
||||||
</span>
|
</span>
|
||||||
</td>
|
</td>
|
||||||
|
<td>
|
||||||
|
<!-- Where the voucher stands after a correction: unpassed, revoked, closed or corrected. -->
|
||||||
|
<mifosx-voucher-status
|
||||||
|
[status]="r.voucherStatus"
|
||||||
|
[revokedBy]="r.revokedByVoucherNo"
|
||||||
|
[replaces]="r.replacesVoucherNo"
|
||||||
|
[note]="r.correctionNote"
|
||||||
|
></mifosx-voucher-status>
|
||||||
|
@if (!r.voucherStatus || r.voucherStatus === 'ACTIVE') {
|
||||||
|
<span class="mw-acct">—</span>
|
||||||
|
}
|
||||||
|
</td>
|
||||||
<td class="mw-time">{{ r.createdAt | date: 'HH:mm' }}</td>
|
<td class="mw-time">{{ r.createdAt | date: 'HH:mm' }}</td>
|
||||||
</tr>
|
</tr>
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -178,6 +178,21 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.mw-row--clickable {
|
||||||
|
cursor: pointer;
|
||||||
|
|
||||||
|
&:hover {
|
||||||
|
background: #f8fafc;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.mw-vno__spinner {
|
||||||
|
margin-left: 4px;
|
||||||
|
font-size: 14px;
|
||||||
|
vertical-align: middle;
|
||||||
|
color: #94a3b8;
|
||||||
|
}
|
||||||
|
|
||||||
/* One section per part played. The heading carries the section's own count and total, so a maker's
|
/* One section per part played. The heading carries the section's own count and total, so a maker's
|
||||||
tally and a checker's are each readable without adding rows up. */
|
tally and a checker's are each readable without adding rows up. */
|
||||||
.mw-group {
|
.mw-group {
|
||||||
|
|||||||
@@ -10,8 +10,12 @@ import { Component, OnInit, inject } from '@angular/core';
|
|||||||
import { CommonModule } from '@angular/common';
|
import { CommonModule } from '@angular/common';
|
||||||
import { FormsModule } from '@angular/forms';
|
import { FormsModule } from '@angular/forms';
|
||||||
import { MatIconModule } from '@angular/material/icon';
|
import { MatIconModule } from '@angular/material/icon';
|
||||||
import { catchError, of } from 'rxjs';
|
import { MatDialog, MatDialogModule } from '@angular/material/dialog';
|
||||||
|
import { catchError, finalize, of } from 'rxjs';
|
||||||
import { CashierService } from '../cashier.service';
|
import { CashierService } from '../cashier.service';
|
||||||
|
import { VoucherDetailDialogComponent } from './voucher-detail-dialog.component';
|
||||||
|
import { businessToday } from 'app/core/utils/business-today';
|
||||||
|
import { VoucherStatusComponent, VoucherStatus } from '../voucher-status/voucher-status.component';
|
||||||
|
|
||||||
/** What the signed-in user's part in a voucher was. */
|
/** What the signed-in user's part in a voucher was. */
|
||||||
export type MyRole = 'RAISED' | 'VERIFIED' | 'RAISED_AND_VERIFIED';
|
export type MyRole = 'RAISED' | 'VERIFIED' | 'RAISED_AND_VERIFIED';
|
||||||
@@ -21,6 +25,11 @@ export type VoucherSide = 'CR' | 'DR' | 'JV';
|
|||||||
|
|
||||||
interface MyWorkRow {
|
interface MyWorkRow {
|
||||||
voucherNo: string;
|
voucherNo: string;
|
||||||
|
/** Where the voucher stands after a correction: active, revoked, under correction, or corrected. */
|
||||||
|
voucherStatus?: VoucherStatus;
|
||||||
|
revokedByVoucherNo?: string | null;
|
||||||
|
replacesVoucherNo?: string | null;
|
||||||
|
correctionNote?: string | null;
|
||||||
side: VoucherSide | null;
|
side: VoucherSide | null;
|
||||||
voucherType: string | null;
|
voucherType: string | null;
|
||||||
amount: number | null;
|
amount: number | null;
|
||||||
@@ -72,11 +81,18 @@ interface MyWorkReport {
|
|||||||
imports: [
|
imports: [
|
||||||
CommonModule,
|
CommonModule,
|
||||||
FormsModule,
|
FormsModule,
|
||||||
MatIconModule
|
MatIconModule,
|
||||||
|
MatDialogModule,
|
||||||
|
VoucherStatusComponent
|
||||||
]
|
]
|
||||||
})
|
})
|
||||||
export class MyWorkComponent implements OnInit {
|
export class MyWorkComponent implements OnInit {
|
||||||
private service = inject(CashierService);
|
private service = inject(CashierService);
|
||||||
|
private dialog = inject(MatDialog);
|
||||||
|
|
||||||
|
/** Which voucher's detail is currently being fetched, so the row that was clicked can show its
|
||||||
|
* own spinner instead of a page-wide one for what is a small, single-row lookup. */
|
||||||
|
loadingDetailFor: string | null = null;
|
||||||
|
|
||||||
/** Day is the working default; a month is what a close or an audit asks for. */
|
/** Day is the working default; a month is what a close or an audit asks for. */
|
||||||
period: 'DAY' | 'MONTH' = 'DAY';
|
period: 'DAY' | 'MONTH' = 'DAY';
|
||||||
@@ -323,8 +339,33 @@ export class MyWorkComponent implements OnInit {
|
|||||||
return { from: first, to: lastIso };
|
return { from: first, to: lastIso };
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Opens the full slip for one voucher — every debit and credit line, not just the first side of
|
||||||
|
* each a table row has room to show. */
|
||||||
|
openDetail(voucherNo: string): void {
|
||||||
|
if (this.loadingDetailFor) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
this.loadingDetailFor = voucherNo;
|
||||||
|
this.service
|
||||||
|
.voucherDetail(voucherNo)
|
||||||
|
.pipe(
|
||||||
|
catchError(() => of(null)),
|
||||||
|
finalize(() => (this.loadingDetailFor = null))
|
||||||
|
)
|
||||||
|
.subscribe((detail: any) => {
|
||||||
|
if (!detail) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
this.dialog.open(VoucherDetailDialogComponent, {
|
||||||
|
data: { detail },
|
||||||
|
width: '600px',
|
||||||
|
autoFocus: false
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
private todayIso(): string {
|
private todayIso(): string {
|
||||||
const d = new Date();
|
const d = businessToday();
|
||||||
return `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, '0')}-${String(d.getDate()).padStart(2, '0')}`;
|
return `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, '0')}-${String(d.getDate()).padStart(2, '0')}`;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,334 @@
|
|||||||
|
/**
|
||||||
|
* Copyright since 2025 Mifos Initiative
|
||||||
|
*
|
||||||
|
* This Source Code Form is subject to the terms of the Mozilla Public
|
||||||
|
* License, v. 2.0. If a copy of the MPL was not distributed with this
|
||||||
|
* file, You can obtain one at http://mozilla.org/MPL/2.0/.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { Component, inject } from '@angular/core';
|
||||||
|
import { CommonModule } from '@angular/common';
|
||||||
|
import { MatDialogModule, MatDialogRef, MAT_DIALOG_DATA } from '@angular/material/dialog';
|
||||||
|
import { MatButtonModule } from '@angular/material/button';
|
||||||
|
import { MatIconModule } from '@angular/material/icon';
|
||||||
|
import { VoucherStatusComponent, VoucherStatus } from '../voucher-status/voucher-status.component';
|
||||||
|
|
||||||
|
export interface VoucherDetailDialogData {
|
||||||
|
/** Already-loaded detail from CashierService.voucherDetail() — the caller fetches, this dialog
|
||||||
|
* only renders, so one loading state lives in the row's own click handler rather than being
|
||||||
|
* duplicated inside every dialog that could ever show a voucher. */
|
||||||
|
detail: {
|
||||||
|
voucherNo: string;
|
||||||
|
voucherStatus?: VoucherStatus;
|
||||||
|
revokedByVoucherNo?: string | null;
|
||||||
|
replacesVoucherNo?: string | null;
|
||||||
|
correctionNote?: string | null;
|
||||||
|
voucherType: string | null;
|
||||||
|
amount: number | null;
|
||||||
|
branchName: string | null;
|
||||||
|
createdBy: string | null;
|
||||||
|
createdAt: string | null;
|
||||||
|
totalDebit: number;
|
||||||
|
totalCredit: number;
|
||||||
|
balanced: boolean;
|
||||||
|
lines: {
|
||||||
|
lineNo: number;
|
||||||
|
entryType: 'DR' | 'CR';
|
||||||
|
partyName: string | null;
|
||||||
|
accountNo: string | null;
|
||||||
|
amount: number;
|
||||||
|
description: string | null;
|
||||||
|
}[];
|
||||||
|
/** GL entries a native platform command posted for the same underlying event, outside this
|
||||||
|
* voucher entirely — a share redemption's Dr Share Capital / Cr Share Sundry pair, posted at
|
||||||
|
* manager approval, before this voucher's own Dr Sundry / Cr Cash at Vault settlement leg even
|
||||||
|
* exists. Empty for every voucher that already is the whole transaction on its own. */
|
||||||
|
relatedLines: {
|
||||||
|
stage: string;
|
||||||
|
entryType: 'DR' | 'CR';
|
||||||
|
partyName: string | null;
|
||||||
|
accountNo: string | null;
|
||||||
|
amount: number;
|
||||||
|
description: string | null;
|
||||||
|
}[];
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The full slip behind a summarised register row — every debit and credit line a voucher is
|
||||||
|
* made of, not just the first side of each that a table row has room for. */
|
||||||
|
@Component({
|
||||||
|
selector: 'mifosx-voucher-detail-dialog',
|
||||||
|
standalone: true,
|
||||||
|
imports: [
|
||||||
|
CommonModule,
|
||||||
|
MatDialogModule,
|
||||||
|
MatButtonModule,
|
||||||
|
MatIconModule,
|
||||||
|
VoucherStatusComponent
|
||||||
|
],
|
||||||
|
template: `
|
||||||
|
<div class="vd-shell">
|
||||||
|
<div class="vd-header">
|
||||||
|
<div class="vd-header__title">
|
||||||
|
<mat-icon class="vd-icon">receipt_long</mat-icon>
|
||||||
|
<div>
|
||||||
|
<h2>
|
||||||
|
{{ detail.voucherNo }}
|
||||||
|
<mifosx-voucher-status
|
||||||
|
[status]="detail.voucherStatus"
|
||||||
|
[revokedBy]="detail.revokedByVoucherNo"
|
||||||
|
[replaces]="detail.replacesVoucherNo"
|
||||||
|
[note]="detail.correctionNote"
|
||||||
|
></mifosx-voucher-status>
|
||||||
|
</h2>
|
||||||
|
<p>{{ detail.voucherType || 'Voucher' }} · {{ detail.branchName || 'Head Office' }}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<button mat-icon-button mat-dialog-close aria-label="Close">
|
||||||
|
<mat-icon>close</mat-icon>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="vd-meta">
|
||||||
|
<span
|
||||||
|
>Raised by <strong>{{ detail.createdBy || '—' }}</strong></span
|
||||||
|
>
|
||||||
|
@if (detail.createdAt) {
|
||||||
|
<span>{{ detail.createdAt | date: 'dd MMM yyyy, h:mm a' }}</span>
|
||||||
|
}
|
||||||
|
@if (!detail.balanced) {
|
||||||
|
<span class="vd-unbalanced">
|
||||||
|
<mat-icon inline>warning</mat-icon>
|
||||||
|
Debit and credit do not balance
|
||||||
|
</span>
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<table class="vd-table">
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th>Side</th>
|
||||||
|
<th>Party</th>
|
||||||
|
<th>Account No.</th>
|
||||||
|
<th class="num">Amount</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
@for (line of detail.lines; track line.lineNo) {
|
||||||
|
<tr>
|
||||||
|
<td>
|
||||||
|
<span
|
||||||
|
class="vd-side"
|
||||||
|
[class.vd-side--dr]="line.entryType === 'DR'"
|
||||||
|
[class.vd-side--cr]="line.entryType === 'CR'"
|
||||||
|
>
|
||||||
|
{{ line.entryType === 'DR' ? 'Debit' : 'Credit' }}
|
||||||
|
</span>
|
||||||
|
</td>
|
||||||
|
<td>{{ line.partyName || '—' }}</td>
|
||||||
|
<td class="vd-acct">{{ line.accountNo || '—' }}</td>
|
||||||
|
<td class="num">₹{{ line.amount | number: '1.2-2' }}</td>
|
||||||
|
</tr>
|
||||||
|
@if (line.description) {
|
||||||
|
<tr class="vd-desc-row">
|
||||||
|
<td></td>
|
||||||
|
<td colspan="3" class="vd-desc">{{ line.description }}</td>
|
||||||
|
</tr>
|
||||||
|
}
|
||||||
|
} @empty {
|
||||||
|
<tr>
|
||||||
|
<td colspan="4" class="vd-empty">This voucher's individual lines were not recorded.</td>
|
||||||
|
</tr>
|
||||||
|
}
|
||||||
|
</tbody>
|
||||||
|
@if (detail.lines.length > 0) {
|
||||||
|
<tfoot>
|
||||||
|
<tr>
|
||||||
|
<td colspan="3">Total</td>
|
||||||
|
<td class="num">
|
||||||
|
₹{{ detail.totalDebit | number: '1.2-2' }} / ₹{{ detail.totalCredit | number: '1.2-2' }}
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
</tfoot>
|
||||||
|
}
|
||||||
|
</table>
|
||||||
|
|
||||||
|
@if (detail.relatedLines.length > 0) {
|
||||||
|
<div class="vd-related">
|
||||||
|
<div class="vd-related__title">
|
||||||
|
<mat-icon inline>link</mat-icon>
|
||||||
|
Also posted for this transaction — not part of this voucher
|
||||||
|
</div>
|
||||||
|
<table class="vd-table vd-table--related">
|
||||||
|
<tbody>
|
||||||
|
@for (line of detail.relatedLines; track $index) {
|
||||||
|
<tr>
|
||||||
|
<td>
|
||||||
|
<span
|
||||||
|
class="vd-side"
|
||||||
|
[class.vd-side--dr]="line.entryType === 'DR'"
|
||||||
|
[class.vd-side--cr]="line.entryType === 'CR'"
|
||||||
|
>
|
||||||
|
{{ line.entryType === 'DR' ? 'Debit' : 'Credit' }}
|
||||||
|
</span>
|
||||||
|
</td>
|
||||||
|
<td>{{ line.partyName || '—' }}</td>
|
||||||
|
<td class="vd-acct">{{ line.accountNo || '—' }}</td>
|
||||||
|
<td class="num">₹{{ line.amount | number: '1.2-2' }}</td>
|
||||||
|
</tr>
|
||||||
|
}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
<p class="vd-related__hint">
|
||||||
|
{{ detail.relatedLines[0].stage }} — this and the lines above are the same transaction's full accounting.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
|
||||||
|
<div class="vd-actions">
|
||||||
|
<button mat-flat-button color="primary" mat-dialog-close>Close</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
`,
|
||||||
|
styles: [
|
||||||
|
`
|
||||||
|
.vd-shell {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 16px;
|
||||||
|
padding: 20px 24px 16px;
|
||||||
|
min-width: 420px;
|
||||||
|
max-width: 560px;
|
||||||
|
}
|
||||||
|
.vd-header {
|
||||||
|
display: flex;
|
||||||
|
align-items: flex-start;
|
||||||
|
justify-content: space-between;
|
||||||
|
}
|
||||||
|
.vd-header__title {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 12px;
|
||||||
|
}
|
||||||
|
.vd-header__title h2 {
|
||||||
|
margin: 0;
|
||||||
|
font-size: 18px;
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
.vd-header__title p {
|
||||||
|
margin: 2px 0 0;
|
||||||
|
font-size: 13px;
|
||||||
|
color: rgba(0, 0, 0, 0.6);
|
||||||
|
}
|
||||||
|
.vd-icon {
|
||||||
|
color: #1976d2;
|
||||||
|
}
|
||||||
|
.vd-meta {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: 6px 16px;
|
||||||
|
font-size: 13px;
|
||||||
|
color: rgba(0, 0, 0, 0.65);
|
||||||
|
}
|
||||||
|
.vd-unbalanced {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 4px;
|
||||||
|
color: #b91c1c;
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
.vd-table {
|
||||||
|
width: 100%;
|
||||||
|
border-collapse: collapse;
|
||||||
|
font-size: 13px;
|
||||||
|
}
|
||||||
|
.vd-table th {
|
||||||
|
text-align: left;
|
||||||
|
text-transform: uppercase;
|
||||||
|
font-size: 11px;
|
||||||
|
letter-spacing: 0.03em;
|
||||||
|
color: rgba(0, 0, 0, 0.5);
|
||||||
|
padding: 6px 8px;
|
||||||
|
border-bottom: 1px solid rgba(0, 0, 0, 0.1);
|
||||||
|
}
|
||||||
|
.vd-table td {
|
||||||
|
padding: 8px;
|
||||||
|
border-bottom: 1px solid rgba(0, 0, 0, 0.06);
|
||||||
|
}
|
||||||
|
.vd-table .num {
|
||||||
|
text-align: right;
|
||||||
|
font-variant-numeric: tabular-nums;
|
||||||
|
}
|
||||||
|
.vd-acct {
|
||||||
|
color: rgba(0, 0, 0, 0.55);
|
||||||
|
}
|
||||||
|
.vd-side {
|
||||||
|
display: inline-block;
|
||||||
|
padding: 2px 8px;
|
||||||
|
border-radius: 4px;
|
||||||
|
font-size: 11px;
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
.vd-side--dr {
|
||||||
|
background: #fee2e2;
|
||||||
|
color: #b91c1c;
|
||||||
|
}
|
||||||
|
.vd-side--cr {
|
||||||
|
background: #dcfce7;
|
||||||
|
color: #15803d;
|
||||||
|
}
|
||||||
|
.vd-desc-row td {
|
||||||
|
padding-top: 0;
|
||||||
|
border-bottom: 1px solid rgba(0, 0, 0, 0.06);
|
||||||
|
}
|
||||||
|
.vd-desc {
|
||||||
|
font-size: 12px;
|
||||||
|
color: rgba(0, 0, 0, 0.5);
|
||||||
|
font-style: italic;
|
||||||
|
}
|
||||||
|
.vd-empty {
|
||||||
|
text-align: center;
|
||||||
|
color: rgba(0, 0, 0, 0.5);
|
||||||
|
padding: 16px 8px;
|
||||||
|
}
|
||||||
|
.vd-table tfoot td {
|
||||||
|
font-weight: 600;
|
||||||
|
border-bottom: none;
|
||||||
|
border-top: 1px solid rgba(0, 0, 0, 0.1);
|
||||||
|
}
|
||||||
|
.vd-related {
|
||||||
|
border-top: 1px dashed rgba(0, 0, 0, 0.15);
|
||||||
|
padding-top: 12px;
|
||||||
|
}
|
||||||
|
.vd-related__title {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 6px;
|
||||||
|
font-size: 12px;
|
||||||
|
font-weight: 600;
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 0.03em;
|
||||||
|
color: #6d28d9;
|
||||||
|
margin-bottom: 8px;
|
||||||
|
}
|
||||||
|
.vd-table--related td {
|
||||||
|
background: #faf5ff;
|
||||||
|
}
|
||||||
|
.vd-related__hint {
|
||||||
|
margin: 6px 0 0;
|
||||||
|
font-size: 12px;
|
||||||
|
color: rgba(0, 0, 0, 0.5);
|
||||||
|
}
|
||||||
|
.vd-actions {
|
||||||
|
display: flex;
|
||||||
|
justify-content: flex-end;
|
||||||
|
}
|
||||||
|
`
|
||||||
|
]
|
||||||
|
})
|
||||||
|
export class VoucherDetailDialogComponent {
|
||||||
|
dialogRef = inject(MatDialogRef<VoucherDetailDialogComponent>);
|
||||||
|
private data = inject<VoucherDetailDialogData>(MAT_DIALOG_DATA);
|
||||||
|
|
||||||
|
detail = this.data.detail;
|
||||||
|
}
|
||||||
@@ -19,34 +19,21 @@
|
|||||||
<!-- ── Entry Tab ── -->
|
<!-- ── Entry Tab ── -->
|
||||||
<mat-tab label="New Reconciliation">
|
<mat-tab label="New Reconciliation">
|
||||||
<div class="pg-tab-body">
|
<div class="pg-tab-body">
|
||||||
|
@if (ownCashierMissing) {
|
||||||
|
<div class="pg-empty">
|
||||||
|
Your login is not linked to a cashier, so there is no cash drawer to reconcile. Ask an administrator to link
|
||||||
|
your user to a cashier.
|
||||||
|
</div>
|
||||||
|
}
|
||||||
<div class="pg-toolbar pg-toolbar--mat">
|
<div class="pg-toolbar pg-toolbar--mat">
|
||||||
@if (tellers.length === 1) {
|
<!-- A reconciliation is the cashier's own count of their own drawer: nothing to pick. -->
|
||||||
<mat-form-field appearance="outline" subscriptSizing="dynamic">
|
<mat-form-field appearance="outline" subscriptSizing="dynamic">
|
||||||
<mat-label>Teller</mat-label>
|
<mat-label>Teller</mat-label>
|
||||||
<input matInput type="text" [value]="tellers[0].name" readonly />
|
<input matInput type="text" [value]="ownTellerName || '—'" readonly />
|
||||||
</mat-form-field>
|
</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>Cashier (You)</mat-label>
|
||||||
<mat-select [value]="selectedTellerId" (selectionChange)="onTellerSelect($event.value)">
|
<input matInput type="text" [value]="cashierName || '—'" readonly />
|
||||||
@for (t of tellers; track t.id) {
|
|
||||||
<mat-option [value]="t.id">{{ t.name }}</mat-option>
|
|
||||||
}
|
|
||||||
</mat-select>
|
|
||||||
</mat-form-field>
|
|
||||||
}
|
|
||||||
|
|
||||||
<mat-form-field appearance="outline" subscriptSizing="dynamic">
|
|
||||||
<mat-label>Cashier</mat-label>
|
|
||||||
<mat-select
|
|
||||||
[value]="selectedCashierId"
|
|
||||||
(selectionChange)="onCashierSelect($event.value)"
|
|
||||||
[disabled]="!selectedTellerId"
|
|
||||||
>
|
|
||||||
@for (c of cashiers; track c.id) {
|
|
||||||
<mat-option [value]="c.id">{{ c.label }}</mat-option>
|
|
||||||
}
|
|
||||||
</mat-select>
|
|
||||||
</mat-form-field>
|
</mat-form-field>
|
||||||
|
|
||||||
<mat-form-field appearance="outline" subscriptSizing="dynamic">
|
<mat-form-field appearance="outline" subscriptSizing="dynamic">
|
||||||
|
|||||||
@@ -12,12 +12,12 @@ import { FormsModule } from '@angular/forms';
|
|||||||
import { MatIconModule } from '@angular/material/icon';
|
import { MatIconModule } from '@angular/material/icon';
|
||||||
import { MatSnackBar, MatSnackBarModule } from '@angular/material/snack-bar';
|
import { MatSnackBar, MatSnackBarModule } from '@angular/material/snack-bar';
|
||||||
import { MatFormFieldModule } from '@angular/material/form-field';
|
import { MatFormFieldModule } from '@angular/material/form-field';
|
||||||
import { MatSelectModule } from '@angular/material/select';
|
|
||||||
import { MatInputModule } from '@angular/material/input';
|
import { MatInputModule } from '@angular/material/input';
|
||||||
import { MatTabsModule } from '@angular/material/tabs';
|
import { MatTabsModule } from '@angular/material/tabs';
|
||||||
import { forkJoin, of } from 'rxjs';
|
import { forkJoin, of } from 'rxjs';
|
||||||
import { catchError } from 'rxjs/operators';
|
import { catchError } from 'rxjs/operators';
|
||||||
import { CashierService } from '../cashier.service';
|
import { CashierService } from '../cashier.service';
|
||||||
|
import { businessToday } from 'app/core/utils/business-today';
|
||||||
|
|
||||||
interface DenomRow {
|
interface DenomRow {
|
||||||
denominationId: number;
|
denominationId: number;
|
||||||
@@ -57,7 +57,6 @@ interface ReconRecord {
|
|||||||
MatIconModule,
|
MatIconModule,
|
||||||
MatSnackBarModule,
|
MatSnackBarModule,
|
||||||
MatFormFieldModule,
|
MatFormFieldModule,
|
||||||
MatSelectModule,
|
|
||||||
MatInputModule,
|
MatInputModule,
|
||||||
MatTabsModule
|
MatTabsModule
|
||||||
]
|
]
|
||||||
@@ -66,9 +65,14 @@ export class ReconciliationComponent implements OnInit {
|
|||||||
private service = inject(CashierService);
|
private service = inject(CashierService);
|
||||||
private snackBar = inject(MatSnackBar);
|
private snackBar = inject(MatSnackBar);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A reconciliation is the cashier's own count of their own drawer, so nobody else makes one and there is
|
||||||
|
* no cashier to pick: the screen is fixed to the logged-in cashier. The server refuses anything else.
|
||||||
|
*/
|
||||||
|
ownCashierMissing = false;
|
||||||
|
ownTellerName = '';
|
||||||
|
|
||||||
// ── Entry form ──
|
// ── Entry form ──
|
||||||
tellers: { id: number; name: string }[] = [];
|
|
||||||
cashiers: { id: number; label: string }[] = [];
|
|
||||||
selectedTellerId: number | null = null;
|
selectedTellerId: number | null = null;
|
||||||
selectedCashierId: number | null = null;
|
selectedCashierId: number | null = null;
|
||||||
cashierName = '';
|
cashierName = '';
|
||||||
@@ -85,44 +89,30 @@ export class ReconciliationComponent implements OnInit {
|
|||||||
loadingHistory = false;
|
loadingHistory = false;
|
||||||
|
|
||||||
ngOnInit(): void {
|
ngOnInit(): void {
|
||||||
const d = new Date();
|
const d = businessToday();
|
||||||
this.reconDate = `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, '0')}-${String(d.getDate()).padStart(2, '0')}`;
|
this.reconDate = `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, '0')}-${String(d.getDate()).padStart(2, '0')}`;
|
||||||
this.service
|
this.lockToOwnCashier();
|
||||||
.getTellers()
|
|
||||||
.pipe(catchError(() => of([] as any[])))
|
|
||||||
.subscribe((list) => {
|
|
||||||
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();
|
||||||
}
|
}
|
||||||
|
|
||||||
onTellerSelect(tellerId: number): void {
|
/** Take the logged-in cashier as the one being reconciled, with their counter for the record. */
|
||||||
this.selectedTellerId = tellerId;
|
private lockToOwnCashier(): void {
|
||||||
this.selectedCashierId = null;
|
this.service.getCurrentCashierInfo().subscribe((info) => {
|
||||||
this.cashierName = '';
|
if (!info) {
|
||||||
this.cashiers = [];
|
this.ownCashierMissing = true;
|
||||||
this.rows = [];
|
return;
|
||||||
if (!tellerId) return;
|
}
|
||||||
|
this.selectedCashierId = info.cashierId;
|
||||||
|
this.cashierName = info.cashierName;
|
||||||
this.service
|
this.service
|
||||||
.getCashiersForTeller(tellerId)
|
.getInterCashierCashierSummary(info.cashierId, this.reconDate)
|
||||||
.pipe(catchError(() => of({ cashiers: [] })))
|
.pipe(catchError(() => of(null)))
|
||||||
.subscribe((r: any) => {
|
.subscribe((r: any) => {
|
||||||
this.cashiers = (r?.cashiers || []).map((c: any) => ({ id: c.id, label: c.staffName }));
|
this.selectedTellerId = r?.tellerId ?? null;
|
||||||
if (this.cashiers.length === 1) {
|
this.ownTellerName = r?.tellerName || '';
|
||||||
this.onCashierSelect(this.cashiers[0].id);
|
|
||||||
}
|
|
||||||
});
|
});
|
||||||
}
|
|
||||||
|
|
||||||
onCashierSelect(cashierId: number): void {
|
|
||||||
this.selectedCashierId = cashierId;
|
|
||||||
const c = this.cashiers.find((x) => x.id === cashierId);
|
|
||||||
this.cashierName = c ? c.label : '';
|
|
||||||
this.rows = [];
|
|
||||||
this.fetchExpected();
|
this.fetchExpected();
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
fetchExpected(): void {
|
fetchExpected(): void {
|
||||||
|
|||||||
@@ -1,375 +0,0 @@
|
|||||||
<!--
|
|
||||||
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 ── -->
|
|
||||||
<header class="pg-head">
|
|
||||||
<div>
|
|
||||||
<div class="pg-eyebrow">CASHIER · SHARE COLLECTION</div>
|
|
||||||
<h1 class="pg-title">Share Collection</h1>
|
|
||||||
<p class="pg-sub">
|
|
||||||
Collect cash from member for share purchase — amounts are held in Sundry GL until the account is activated.
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
</header>
|
|
||||||
|
|
||||||
<!-- ── SEARCH BAR ── -->
|
|
||||||
@if (pageState !== 'success') {
|
|
||||||
<div class="sc-search-bar">
|
|
||||||
<div class="sc-search-bar__input-wrap">
|
|
||||||
<mat-icon class="sc-search-bar__icon">search</mat-icon>
|
|
||||||
<input
|
|
||||||
class="sc-search-bar__input"
|
|
||||||
type="text"
|
|
||||||
placeholder="Enter Share Account ID or Account No."
|
|
||||||
[(ngModel)]="accountInput"
|
|
||||||
(keyup.enter)="searchAccount()"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
class="pg-btn pg-btn--primary"
|
|
||||||
[disabled]="pageState === 'searching' || !accountInput.trim()"
|
|
||||||
(click)="searchAccount()"
|
|
||||||
>
|
|
||||||
<mat-icon>manage_search</mat-icon>
|
|
||||||
{{ pageState === 'searching' ? 'Searching…' : 'Search' }}
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
}
|
|
||||||
|
|
||||||
<!-- ── ERROR ── -->
|
|
||||||
@if (errorMsg) {
|
|
||||||
<div class="sc-alert sc-alert--error">
|
|
||||||
<mat-icon>error_outline</mat-icon>
|
|
||||||
<span>{{ errorMsg }}</span>
|
|
||||||
</div>
|
|
||||||
}
|
|
||||||
|
|
||||||
<!-- ── SEARCHING SPINNER ── -->
|
|
||||||
@if (pageState === 'searching') {
|
|
||||||
<div class="pg-empty">
|
|
||||||
<mat-icon class="sc-spin">hourglass_empty</mat-icon>
|
|
||||||
<p>Looking up account…</p>
|
|
||||||
</div>
|
|
||||||
}
|
|
||||||
|
|
||||||
<!-- ── ACCOUNT LOADED ── -->
|
|
||||||
@if (pageState === 'loaded') {
|
|
||||||
<!-- Member Card -->
|
|
||||||
<div class="sc-member-card">
|
|
||||||
<div class="sc-member-card__avatar">
|
|
||||||
<mat-icon>person</mat-icon>
|
|
||||||
</div>
|
|
||||||
<div class="sc-member-card__info">
|
|
||||||
<div class="sc-member-card__name">{{ account.clientName }}</div>
|
|
||||||
<div class="sc-member-card__meta">
|
|
||||||
<span class="sc-badge sc-badge--account">{{ account.accountNo }}</span>
|
|
||||||
<span class="sc-badge sc-badge--product">{{ account.productName }}</span>
|
|
||||||
<span
|
|
||||||
class="sc-badge"
|
|
||||||
[class.sc-badge--pending]="account.status?.code?.includes('pending')"
|
|
||||||
[class.sc-badge--active]="account.status?.code?.includes('active')"
|
|
||||||
>{{ account.status?.value }}</span
|
|
||||||
>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Editable Shares + Price -->
|
|
||||||
<div class="sc-member-card__shares">
|
|
||||||
<div class="sc-shares-inputs">
|
|
||||||
<div class="sc-shares-field">
|
|
||||||
<label class="sc-shares-label">No. of Shares</label>
|
|
||||||
<input
|
|
||||||
class="sc-shares-input"
|
|
||||||
type="number"
|
|
||||||
min="0"
|
|
||||||
[(ngModel)]="requestedSharesInput"
|
|
||||||
(input)="recalc()"
|
|
||||||
placeholder="0"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
<div class="sc-shares-sep">×</div>
|
|
||||||
<div class="sc-shares-field">
|
|
||||||
<label class="sc-shares-label">Price / Share (₹)</label>
|
|
||||||
<input
|
|
||||||
class="sc-shares-input"
|
|
||||||
type="number"
|
|
||||||
min="0"
|
|
||||||
[(ngModel)]="pricePerShare"
|
|
||||||
(input)="recalc()"
|
|
||||||
placeholder="0.00"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
<div class="sc-shares-sep">=</div>
|
|
||||||
<div class="sc-shares-result">
|
|
||||||
<div class="sc-shares-label">Share Amount</div>
|
|
||||||
<div class="sc-shares-total">{{ currencySymbol }}{{ shareAmount | number: '1.2-2' }}</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Amount Breakdown + GL Preview -->
|
|
||||||
<div class="sc-split">
|
|
||||||
<!-- LEFT: Amount Breakdown -->
|
|
||||||
<div class="pg-card sc-breakdown">
|
|
||||||
<div class="sc-breakdown__header">
|
|
||||||
<mat-icon>receipt_long</mat-icon>
|
|
||||||
<span>Amount Breakdown</span>
|
|
||||||
</div>
|
|
||||||
<table class="pg-table">
|
|
||||||
<thead>
|
|
||||||
<tr>
|
|
||||||
<th>Description</th>
|
|
||||||
<th class="num">Amount</th>
|
|
||||||
</tr>
|
|
||||||
</thead>
|
|
||||||
<tbody>
|
|
||||||
<tr>
|
|
||||||
<td>
|
|
||||||
Share Amount
|
|
||||||
<span class="sc-dim">
|
|
||||||
({{ requestedSharesInput }} shares × {{ currencySymbol }}{{ pricePerShare | number: '1.2-2' }})
|
|
||||||
</span>
|
|
||||||
</td>
|
|
||||||
<td class="num">{{ currencySymbol }}{{ shareAmount | number: '1.2-2' }}</td>
|
|
||||||
</tr>
|
|
||||||
@for (charge of charges; track charge.id) {
|
|
||||||
<tr>
|
|
||||||
<td>{{ charge.name }}</td>
|
|
||||||
<td class="num sc-charge-amount">
|
|
||||||
<input class="sc-charge-input" type="number" min="0" [(ngModel)]="charge.amount" (input)="recalc()" />
|
|
||||||
</td>
|
|
||||||
</tr>
|
|
||||||
}
|
|
||||||
@if (charges.length === 0) {
|
|
||||||
<tr>
|
|
||||||
<td colspan="2">
|
|
||||||
<span class="sc-dim">No charges applicable</span>
|
|
||||||
</td>
|
|
||||||
</tr>
|
|
||||||
}
|
|
||||||
</tbody>
|
|
||||||
<tfoot>
|
|
||||||
<tr class="sc-total-row">
|
|
||||||
<td><strong>Total Payable</strong></td>
|
|
||||||
<td class="num sc-total-amount">
|
|
||||||
<strong>{{ currencySymbol }}{{ totalAmount | number: '1.2-2' }}</strong>
|
|
||||||
</td>
|
|
||||||
</tr>
|
|
||||||
</tfoot>
|
|
||||||
</table>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- RIGHT: GL Preview -->
|
|
||||||
<div class="pg-card sc-gl-preview">
|
|
||||||
<div class="sc-breakdown__header">
|
|
||||||
<mat-icon>account_tree</mat-icon>
|
|
||||||
<span>GL Entries Preview</span>
|
|
||||||
</div>
|
|
||||||
<table class="pg-table">
|
|
||||||
<thead>
|
|
||||||
<tr>
|
|
||||||
<th>Account</th>
|
|
||||||
<th>Dr / Cr</th>
|
|
||||||
<th class="num">Amount</th>
|
|
||||||
</tr>
|
|
||||||
</thead>
|
|
||||||
<tbody>
|
|
||||||
<tr class="gl-row--dr">
|
|
||||||
<td class="gl-acname">Cash Vault GL</td>
|
|
||||||
<td><span class="sc-dr-badge">Dr</span></td>
|
|
||||||
<td class="num gl-amt--dr">{{ currencySymbol }}{{ totalAmount | number: '1.2-2' }}</td>
|
|
||||||
</tr>
|
|
||||||
<tr class="gl-row--cr">
|
|
||||||
<td class="gl-acname">Share Sundry GL</td>
|
|
||||||
<td><span class="sc-cr-badge">Cr</span></td>
|
|
||||||
<td class="num gl-amt--cr">{{ currencySymbol }}{{ shareAmount | number: '1.2-2' }}</td>
|
|
||||||
</tr>
|
|
||||||
@if (totalChargeAmount > 0) {
|
|
||||||
<tr class="gl-row--cr">
|
|
||||||
<td class="gl-acname">Charge Receivable GL</td>
|
|
||||||
<td><span class="sc-cr-badge">Cr</span></td>
|
|
||||||
<td class="num gl-amt--cr">{{ currencySymbol }}{{ totalChargeAmount | number: '1.2-2' }}</td>
|
|
||||||
</tr>
|
|
||||||
}
|
|
||||||
</tbody>
|
|
||||||
</table>
|
|
||||||
<div class="sc-gl-note">
|
|
||||||
<mat-icon>info_outline</mat-icon>
|
|
||||||
Funds move to Share GL & Income GL when account is <strong>activated</strong>.
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- ── DENOMINATION (Cash Only) ── -->
|
|
||||||
@if (isCashPayment && allDenominations.length > 0) {
|
|
||||||
<div class="pg-card sc-denom">
|
|
||||||
<div class="sc-breakdown__header">
|
|
||||||
<mat-icon>payments</mat-icon>
|
|
||||||
<span>Denomination Entry</span>
|
|
||||||
@if (denominationTotal > 0) {
|
|
||||||
<span
|
|
||||||
class="sc-denom-badge"
|
|
||||||
[class.sc-denom-badge--match]="denomMatchesTotal"
|
|
||||||
[class.sc-denom-badge--mismatch]="!denomMatchesTotal"
|
|
||||||
>
|
|
||||||
₹{{ denominationTotal | number: '1.2-2' }}
|
|
||||||
{{ denomMatchesTotal ? '✓ Matches' : '≠ Mismatch' }}
|
|
||||||
</span>
|
|
||||||
}
|
|
||||||
@if (denominationTotal === 0) {
|
|
||||||
<span class="sc-denom-required">⚠ Required for Cash</span>
|
|
||||||
}
|
|
||||||
</div>
|
|
||||||
<div class="sc-denom-body">
|
|
||||||
<table class="pg-table sc-denom-table">
|
|
||||||
<thead>
|
|
||||||
<tr>
|
|
||||||
<th>Denomination</th>
|
|
||||||
<th class="num">Count</th>
|
|
||||||
<th class="num">Amount</th>
|
|
||||||
</tr>
|
|
||||||
</thead>
|
|
||||||
<tbody>
|
|
||||||
@for (d of allDenominations; track d.id) {
|
|
||||||
<tr>
|
|
||||||
<td>
|
|
||||||
<span class="sc-denom-chip" [class.sc-denom-chip--coin]="d.kind === 'coin'">{{ d.label }}</span>
|
|
||||||
</td>
|
|
||||||
<td class="num">
|
|
||||||
<div class="sc-denom-stepper">
|
|
||||||
<button type="button" (click)="bumpDenom(d, -1)" [disabled]="countFor(d) === 0">−</button>
|
|
||||||
<input
|
|
||||||
type="number"
|
|
||||||
min="0"
|
|
||||||
[value]="countFor(d) || ''"
|
|
||||||
placeholder="0"
|
|
||||||
(input)="onDenomChange(d, $any($event.target).value)"
|
|
||||||
/>
|
|
||||||
<button type="button" (click)="bumpDenom(d, 1)">+</button>
|
|
||||||
</div>
|
|
||||||
</td>
|
|
||||||
<td class="num">{{ subtotalFor(d) > 0 ? (subtotalFor(d) | number: '1.2-2') : '—' }}</td>
|
|
||||||
</tr>
|
|
||||||
}
|
|
||||||
</tbody>
|
|
||||||
<tfoot>
|
|
||||||
<tr>
|
|
||||||
<td><strong>Total</strong></td>
|
|
||||||
<td></td>
|
|
||||||
<td class="num">
|
|
||||||
<strong>₹{{ denominationTotal | number: '1.2-2' }}</strong>
|
|
||||||
</td>
|
|
||||||
</tr>
|
|
||||||
</tfoot>
|
|
||||||
</table>
|
|
||||||
@if (!denomMatchesTotal && denominationTotal > 0) {
|
|
||||||
<div class="sc-denom-mismatch">
|
|
||||||
<mat-icon>warning</mat-icon>
|
|
||||||
Denomination ₹{{ denominationTotal | number: '1.2-2' }} must equal Total Payable ₹{{
|
|
||||||
totalAmount | number: '1.2-2'
|
|
||||||
}}.
|
|
||||||
</div>
|
|
||||||
}
|
|
||||||
@if (denominationTotal > 0) {
|
|
||||||
<div class="sc-denom-actions">
|
|
||||||
<button type="button" class="pg-btn pg-btn--ghost" (click)="clearDenominations()">
|
|
||||||
<mat-icon>clear</mat-icon> Clear
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
}
|
|
||||||
|
|
||||||
<!-- Transaction Details -->
|
|
||||||
<div class="pg-toolbar pg-toolbar--mat">
|
|
||||||
<mat-form-field appearance="outline" subscriptSizing="dynamic">
|
|
||||||
<mat-label>Transaction Date *</mat-label>
|
|
||||||
<input matInput type="date" [(ngModel)]="transactionDate" />
|
|
||||||
</mat-form-field>
|
|
||||||
|
|
||||||
<mat-form-field appearance="outline" subscriptSizing="dynamic">
|
|
||||||
<mat-label>Payment Mode</mat-label>
|
|
||||||
<mat-select [(ngModel)]="paymentTypeId">
|
|
||||||
<mat-option [value]="null">— Cash (Default) —</mat-option>
|
|
||||||
@for (pt of paymentTypes; track pt.id) {
|
|
||||||
<mat-option [value]="pt.id">{{ pt.name }}</mat-option>
|
|
||||||
}
|
|
||||||
</mat-select>
|
|
||||||
</mat-form-field>
|
|
||||||
|
|
||||||
<mat-form-field appearance="outline" subscriptSizing="dynamic" class="pg-note-field">
|
|
||||||
<mat-label>Note (Optional)</mat-label>
|
|
||||||
<input matInput type="text" [(ngModel)]="note" placeholder="e.g. Share purchase for 100 shares" />
|
|
||||||
</mat-form-field>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Footer Submit Bar -->
|
|
||||||
<div class="pg-total-bar sc-footer">
|
|
||||||
<div class="sc-footer__summary">
|
|
||||||
<span class="sc-footer__label">Total to Collect</span>
|
|
||||||
<span class="sc-footer__amount">{{ currencySymbol }}{{ totalAmount | number: '1.2-2' }}</span>
|
|
||||||
</div>
|
|
||||||
<div class="sc-footer__actions">
|
|
||||||
<button type="button" class="pg-btn pg-btn--ghost" (click)="reset()"><mat-icon>close</mat-icon> Cancel</button>
|
|
||||||
<button type="button" class="pg-btn pg-btn--primary" [disabled]="!isValid || saving" (click)="submit()">
|
|
||||||
<mat-icon>payments</mat-icon>
|
|
||||||
{{ saving ? 'Posting…' : 'Collect & Post' }}
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
}
|
|
||||||
|
|
||||||
<!-- ── SUCCESS ── -->
|
|
||||||
@if (pageState === 'success' && collectionResult) {
|
|
||||||
<div class="sc-success">
|
|
||||||
<div class="sc-success__icon-wrap">
|
|
||||||
<mat-icon>check_circle</mat-icon>
|
|
||||||
</div>
|
|
||||||
<h2 class="sc-success__title">Collection Posted Successfully</h2>
|
|
||||||
<p class="sc-success__sub">
|
|
||||||
Cash of
|
|
||||||
<strong>{{ collectionResult.currencyCode }} {{ collectionResult.totalAmount | number: '1.2-2' }}</strong>
|
|
||||||
collected from <strong>{{ collectionResult.clientName }}</strong> on
|
|
||||||
<strong>{{ collectionResult.collectionDate }}</strong> by <strong>{{ collectionResult.collectedBy }}</strong
|
|
||||||
>.
|
|
||||||
</p>
|
|
||||||
|
|
||||||
<!-- Transaction reference -->
|
|
||||||
<div class="sc-success__ref">
|
|
||||||
<span class="sc-success__ref-label">Transaction ID</span>
|
|
||||||
<span class="sc-success__ref-val">{{ collectionResult.transactionId }}</span>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- GL entries from backend -->
|
|
||||||
<div class="sc-success__gl">
|
|
||||||
@for (entry of collectionResult.glEntries; track entry.account) {
|
|
||||||
<div
|
|
||||||
class="sc-success__gl-row"
|
|
||||||
[class.sc-success__gl-row--dr]="entry.type === 'Dr'"
|
|
||||||
[class.sc-success__gl-row--cr]="entry.type === 'Cr'"
|
|
||||||
>
|
|
||||||
<span>{{ entry.type }}. {{ entry.account }}</span>
|
|
||||||
<span>{{ entry.currency }} {{ entry.amount | number: '1.2-2' }}</span>
|
|
||||||
</div>
|
|
||||||
}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<p class="sc-success__note">
|
|
||||||
Funds are held in Sundry GL. They will move to Share Equity GL when the account is activated.
|
|
||||||
</p>
|
|
||||||
|
|
||||||
<button type="button" class="pg-btn pg-btn--primary" (click)="reset()">
|
|
||||||
<mat-icon>add</mat-icon> New Collection
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
}
|
|
||||||
</div>
|
|
||||||
@@ -1,701 +0,0 @@
|
|||||||
/**
|
|
||||||
* 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/.
|
|
||||||
*/
|
|
||||||
|
|
||||||
// ── Search Bar ───────────────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
.sc-search-bar {
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
gap: 10px;
|
|
||||||
background: #fff;
|
|
||||||
border: 1px solid #e2e8f0;
|
|
||||||
border-radius: 12px;
|
|
||||||
padding: 10px 14px;
|
|
||||||
|
|
||||||
&__input-wrap {
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
gap: 8px;
|
|
||||||
flex: 1 1 auto;
|
|
||||||
}
|
|
||||||
|
|
||||||
&__icon {
|
|
||||||
color: #94a3b8;
|
|
||||||
font-size: 20px;
|
|
||||||
width: 20px;
|
|
||||||
height: 20px;
|
|
||||||
flex-shrink: 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
&__input {
|
|
||||||
flex: 1 1 auto;
|
|
||||||
border: none;
|
|
||||||
outline: none;
|
|
||||||
font-size: 14px;
|
|
||||||
font-family: inherit;
|
|
||||||
color: #0d1b2a;
|
|
||||||
background: transparent;
|
|
||||||
|
|
||||||
&::placeholder {
|
|
||||||
color: #94a3b8;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// ── Alert ────────────────────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
.sc-alert {
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
gap: 10px;
|
|
||||||
padding: 12px 16px;
|
|
||||||
border-radius: 10px;
|
|
||||||
font-size: 13px;
|
|
||||||
font-weight: 500;
|
|
||||||
|
|
||||||
mat-icon {
|
|
||||||
font-size: 20px;
|
|
||||||
width: 20px;
|
|
||||||
height: 20px;
|
|
||||||
flex-shrink: 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
&--error {
|
|
||||||
background: #fef2f2;
|
|
||||||
border: 1px solid #fecaca;
|
|
||||||
color: #dc2626;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// ── Spinner ──────────────────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
.sc-spin {
|
|
||||||
animation: sc-spin 1.2s linear infinite;
|
|
||||||
display: block;
|
|
||||||
margin: 0 auto 8px;
|
|
||||||
font-size: 32px !important;
|
|
||||||
width: 32px !important;
|
|
||||||
height: 32px !important;
|
|
||||||
color: #94a3b8;
|
|
||||||
}
|
|
||||||
|
|
||||||
@keyframes sc-spin {
|
|
||||||
from {
|
|
||||||
transform: rotate(0deg);
|
|
||||||
}
|
|
||||||
|
|
||||||
to {
|
|
||||||
transform: rotate(360deg);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// ── Member Card ──────────────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
.sc-member-card {
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
gap: 16px;
|
|
||||||
background: #fff;
|
|
||||||
border: 1px solid #e2e8f0;
|
|
||||||
border-radius: 12px;
|
|
||||||
padding: 16px 20px;
|
|
||||||
|
|
||||||
&__avatar {
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
justify-content: center;
|
|
||||||
width: 48px;
|
|
||||||
height: 48px;
|
|
||||||
border-radius: 50%;
|
|
||||||
background: #eff6ff;
|
|
||||||
flex-shrink: 0;
|
|
||||||
|
|
||||||
mat-icon {
|
|
||||||
color: #2563eb;
|
|
||||||
font-size: 24px;
|
|
||||||
width: 24px;
|
|
||||||
height: 24px;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
&__info {
|
|
||||||
flex: 1 1 auto;
|
|
||||||
min-width: 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
&__name {
|
|
||||||
font-size: 16px;
|
|
||||||
font-weight: 700;
|
|
||||||
color: #1e293b;
|
|
||||||
margin-bottom: 6px;
|
|
||||||
}
|
|
||||||
|
|
||||||
&__meta {
|
|
||||||
display: flex;
|
|
||||||
flex-wrap: wrap;
|
|
||||||
gap: 6px;
|
|
||||||
}
|
|
||||||
|
|
||||||
&__shares {
|
|
||||||
flex-shrink: 0;
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// ── Badges ───────────────────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
.sc-badge {
|
|
||||||
display: inline-flex;
|
|
||||||
align-items: center;
|
|
||||||
height: 22px;
|
|
||||||
padding: 0 8px;
|
|
||||||
border-radius: 5px;
|
|
||||||
font-size: 11px;
|
|
||||||
font-weight: 700;
|
|
||||||
background: #f1f5f9;
|
|
||||||
color: #475569;
|
|
||||||
border: 1px solid #e2e8f0;
|
|
||||||
|
|
||||||
&--account {
|
|
||||||
background: #eff6ff;
|
|
||||||
color: #1d4ed8;
|
|
||||||
border-color: #bfdbfe;
|
|
||||||
font-family: 'Roboto Mono', monospace;
|
|
||||||
}
|
|
||||||
|
|
||||||
&--product {
|
|
||||||
background: #f0fdf4;
|
|
||||||
color: #15803d;
|
|
||||||
border-color: #bbf7d0;
|
|
||||||
}
|
|
||||||
|
|
||||||
&--pending {
|
|
||||||
background: #fffbeb;
|
|
||||||
color: #d97706;
|
|
||||||
border-color: #fde68a;
|
|
||||||
}
|
|
||||||
|
|
||||||
&--active {
|
|
||||||
background: #f0fdf4;
|
|
||||||
color: #15803d;
|
|
||||||
border-color: #bbf7d0;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// ── Editable Share Inputs ────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
.sc-shares-inputs {
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
gap: 10px;
|
|
||||||
flex-wrap: wrap;
|
|
||||||
justify-content: flex-end;
|
|
||||||
}
|
|
||||||
|
|
||||||
.sc-shares-field {
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
gap: 3px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.sc-shares-label {
|
|
||||||
font-size: 10px;
|
|
||||||
font-weight: 700;
|
|
||||||
text-transform: uppercase;
|
|
||||||
letter-spacing: 0.07em;
|
|
||||||
color: #94a3b8;
|
|
||||||
}
|
|
||||||
|
|
||||||
.sc-shares-input {
|
|
||||||
width: 110px;
|
|
||||||
height: 38px;
|
|
||||||
border: 1.5px solid #e2e8f0;
|
|
||||||
border-radius: 8px;
|
|
||||||
padding: 0 10px;
|
|
||||||
font-size: 14px;
|
|
||||||
font-weight: 700;
|
|
||||||
font-family: inherit;
|
|
||||||
color: #1353a0;
|
|
||||||
text-align: right;
|
|
||||||
outline: none;
|
|
||||||
background: #f8fafc;
|
|
||||||
transition: border-color 0.15s;
|
|
||||||
|
|
||||||
&:focus {
|
|
||||||
border-color: #1353a0;
|
|
||||||
background: #fff;
|
|
||||||
}
|
|
||||||
|
|
||||||
&::-webkit-outer-spin-button,
|
|
||||||
&::-webkit-inner-spin-button {
|
|
||||||
appearance: none;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
.sc-shares-sep {
|
|
||||||
font-size: 20px;
|
|
||||||
font-weight: 300;
|
|
||||||
color: #94a3b8;
|
|
||||||
padding-top: 16px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.sc-shares-result {
|
|
||||||
text-align: right;
|
|
||||||
}
|
|
||||||
|
|
||||||
.sc-shares-total {
|
|
||||||
font-size: 20px;
|
|
||||||
font-weight: 800;
|
|
||||||
color: #0f4f3c;
|
|
||||||
font-variant-numeric: tabular-nums;
|
|
||||||
}
|
|
||||||
|
|
||||||
// ── Editable Charge Amount ───────────────────────────────────────────────────
|
|
||||||
|
|
||||||
.sc-charge-amount {
|
|
||||||
padding: 4px 14px !important;
|
|
||||||
}
|
|
||||||
|
|
||||||
.sc-charge-input {
|
|
||||||
width: 100px;
|
|
||||||
height: 32px;
|
|
||||||
border: 1.5px solid #e2e8f0;
|
|
||||||
border-radius: 6px;
|
|
||||||
padding: 0 8px;
|
|
||||||
font-size: 13px;
|
|
||||||
font-weight: 700;
|
|
||||||
font-family: inherit;
|
|
||||||
color: #1353a0;
|
|
||||||
text-align: right;
|
|
||||||
outline: none;
|
|
||||||
background: #f8fafc;
|
|
||||||
transition: border-color 0.15s;
|
|
||||||
|
|
||||||
&:focus {
|
|
||||||
border-color: #1353a0;
|
|
||||||
background: #fff;
|
|
||||||
}
|
|
||||||
|
|
||||||
&::-webkit-outer-spin-button,
|
|
||||||
&::-webkit-inner-spin-button {
|
|
||||||
appearance: none;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// ── Split Layout ─────────────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
.sc-split {
|
|
||||||
display: grid;
|
|
||||||
grid-template-columns: 1fr 1fr;
|
|
||||||
gap: 16px;
|
|
||||||
|
|
||||||
@media (width <= 900px) {
|
|
||||||
grid-template-columns: 1fr;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// ── Breakdown Card ───────────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
.sc-breakdown {
|
|
||||||
&__header {
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
gap: 8px;
|
|
||||||
padding: 12px 16px;
|
|
||||||
background: #f8fafc;
|
|
||||||
border-bottom: 1px solid #e2e8f0;
|
|
||||||
font-size: 13px;
|
|
||||||
font-weight: 700;
|
|
||||||
color: #334155;
|
|
||||||
|
|
||||||
mat-icon {
|
|
||||||
font-size: 18px;
|
|
||||||
width: 18px;
|
|
||||||
height: 18px;
|
|
||||||
color: #0f4f3c;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
.sc-gl-preview {
|
|
||||||
.sc-breakdown__header {
|
|
||||||
mat-icon {
|
|
||||||
color: #1d4ed8;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
.sc-gl-note {
|
|
||||||
display: flex;
|
|
||||||
align-items: flex-start;
|
|
||||||
gap: 6px;
|
|
||||||
padding: 10px 14px;
|
|
||||||
font-size: 12px;
|
|
||||||
color: #64748b;
|
|
||||||
background: #f8fafc;
|
|
||||||
border-top: 1px solid #e2e8f0;
|
|
||||||
|
|
||||||
mat-icon {
|
|
||||||
font-size: 16px;
|
|
||||||
width: 16px;
|
|
||||||
height: 16px;
|
|
||||||
flex-shrink: 0;
|
|
||||||
margin-top: 1px;
|
|
||||||
color: #94a3b8;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// ── Table helpers ────────────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
.sc-dim {
|
|
||||||
color: #94a3b8;
|
|
||||||
font-size: 12px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.sc-total-row td {
|
|
||||||
background: #f8fafc;
|
|
||||||
border-top: 2px solid #e2e8f0;
|
|
||||||
font-size: 14px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.sc-total-amount {
|
|
||||||
color: #0f4f3c;
|
|
||||||
}
|
|
||||||
|
|
||||||
// ── Dr / Cr badges ───────────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
.sc-dr-badge,
|
|
||||||
.sc-cr-badge {
|
|
||||||
display: inline-flex;
|
|
||||||
align-items: center;
|
|
||||||
justify-content: center;
|
|
||||||
width: 32px;
|
|
||||||
height: 20px;
|
|
||||||
border-radius: 4px;
|
|
||||||
font-size: 11px;
|
|
||||||
font-weight: 800;
|
|
||||||
letter-spacing: 0.04em;
|
|
||||||
}
|
|
||||||
|
|
||||||
.sc-dr-badge {
|
|
||||||
background: #fef2f2;
|
|
||||||
color: #dc2626;
|
|
||||||
border: 1px solid #fecaca;
|
|
||||||
}
|
|
||||||
|
|
||||||
.sc-cr-badge {
|
|
||||||
background: #f0fdf4;
|
|
||||||
color: #15803d;
|
|
||||||
border: 1px solid #bbf7d0;
|
|
||||||
}
|
|
||||||
|
|
||||||
// ── Footer ───────────────────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
.sc-footer {
|
|
||||||
justify-content: space-between !important;
|
|
||||||
|
|
||||||
&__summary {
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
gap: 2px;
|
|
||||||
}
|
|
||||||
|
|
||||||
&__label {
|
|
||||||
font-size: 11px;
|
|
||||||
font-weight: 700;
|
|
||||||
text-transform: uppercase;
|
|
||||||
letter-spacing: 0.07em;
|
|
||||||
color: #64748b;
|
|
||||||
}
|
|
||||||
|
|
||||||
&__amount {
|
|
||||||
font-size: 22px;
|
|
||||||
font-weight: 800;
|
|
||||||
color: #0f4f3c;
|
|
||||||
font-variant-numeric: tabular-nums;
|
|
||||||
}
|
|
||||||
|
|
||||||
&__actions {
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
gap: 10px;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// ── Denomination Panel ────────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
.sc-denom {
|
|
||||||
padding: 0;
|
|
||||||
overflow: hidden;
|
|
||||||
}
|
|
||||||
|
|
||||||
.sc-denom-body {
|
|
||||||
padding: 14px 16px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.sc-denom-table {
|
|
||||||
width: 100%;
|
|
||||||
|
|
||||||
th,
|
|
||||||
td {
|
|
||||||
padding: 7px 10px;
|
|
||||||
font-size: 13px;
|
|
||||||
}
|
|
||||||
|
|
||||||
tfoot td {
|
|
||||||
border-top: 2px solid #e2e8f0;
|
|
||||||
background: #f8fafc;
|
|
||||||
font-size: 14px;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
.sc-denom-chip {
|
|
||||||
display: inline-flex;
|
|
||||||
align-items: center;
|
|
||||||
height: 24px;
|
|
||||||
padding: 0 10px;
|
|
||||||
border-radius: 5px;
|
|
||||||
font-size: 12px;
|
|
||||||
font-weight: 700;
|
|
||||||
background: #eff6ff;
|
|
||||||
color: #1d4ed8;
|
|
||||||
border: 1px solid #bfdbfe;
|
|
||||||
|
|
||||||
&--coin {
|
|
||||||
background: #fffbeb;
|
|
||||||
color: #d97706;
|
|
||||||
border-color: #fde68a;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
.sc-denom-stepper {
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
gap: 4px;
|
|
||||||
justify-content: flex-end;
|
|
||||||
|
|
||||||
button {
|
|
||||||
width: 26px;
|
|
||||||
height: 26px;
|
|
||||||
border: 1.5px solid #e2e8f0;
|
|
||||||
border-radius: 5px;
|
|
||||||
background: #f8fafc;
|
|
||||||
color: #475569;
|
|
||||||
font-size: 16px;
|
|
||||||
line-height: 1;
|
|
||||||
cursor: pointer;
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
justify-content: center;
|
|
||||||
padding: 0;
|
|
||||||
|
|
||||||
&:hover:not(:disabled) {
|
|
||||||
background: #e0f2fe;
|
|
||||||
border-color: #7dd3fc;
|
|
||||||
color: #0369a1;
|
|
||||||
}
|
|
||||||
|
|
||||||
&:disabled {
|
|
||||||
opacity: 0.35;
|
|
||||||
cursor: not-allowed;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
input {
|
|
||||||
width: 62px;
|
|
||||||
height: 28px;
|
|
||||||
border: 1.5px solid #e2e8f0;
|
|
||||||
border-radius: 6px;
|
|
||||||
padding: 0 6px;
|
|
||||||
font-size: 13px;
|
|
||||||
font-weight: 600;
|
|
||||||
font-family: inherit;
|
|
||||||
text-align: right;
|
|
||||||
outline: none;
|
|
||||||
background: #fff;
|
|
||||||
|
|
||||||
&:focus {
|
|
||||||
border-color: #1353a0;
|
|
||||||
}
|
|
||||||
|
|
||||||
&::-webkit-outer-spin-button,
|
|
||||||
&::-webkit-inner-spin-button {
|
|
||||||
appearance: none;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
.sc-denom-badge {
|
|
||||||
margin-left: auto;
|
|
||||||
display: inline-flex;
|
|
||||||
align-items: center;
|
|
||||||
height: 22px;
|
|
||||||
padding: 0 10px;
|
|
||||||
border-radius: 5px;
|
|
||||||
font-size: 12px;
|
|
||||||
font-weight: 700;
|
|
||||||
|
|
||||||
&--match {
|
|
||||||
background: #f0fdf4;
|
|
||||||
color: #15803d;
|
|
||||||
border: 1px solid #bbf7d0;
|
|
||||||
}
|
|
||||||
|
|
||||||
&--mismatch {
|
|
||||||
background: #fef2f2;
|
|
||||||
color: #dc2626;
|
|
||||||
border: 1px solid #fecaca;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
.sc-denom-required {
|
|
||||||
margin-left: auto;
|
|
||||||
font-size: 12px;
|
|
||||||
font-weight: 700;
|
|
||||||
color: #d97706;
|
|
||||||
}
|
|
||||||
|
|
||||||
.sc-denom-mismatch {
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
gap: 6px;
|
|
||||||
margin-top: 10px;
|
|
||||||
padding: 9px 12px;
|
|
||||||
background: #fef2f2;
|
|
||||||
border: 1px solid #fecaca;
|
|
||||||
border-radius: 8px;
|
|
||||||
font-size: 12px;
|
|
||||||
font-weight: 600;
|
|
||||||
color: #dc2626;
|
|
||||||
|
|
||||||
mat-icon {
|
|
||||||
font-size: 16px;
|
|
||||||
width: 16px;
|
|
||||||
height: 16px;
|
|
||||||
flex-shrink: 0;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
.sc-denom-actions {
|
|
||||||
display: flex;
|
|
||||||
justify-content: flex-end;
|
|
||||||
margin-top: 10px;
|
|
||||||
}
|
|
||||||
|
|
||||||
// ── Success ──────────────────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
.sc-success {
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
align-items: center;
|
|
||||||
text-align: center;
|
|
||||||
padding: 40px 24px;
|
|
||||||
gap: 16px;
|
|
||||||
background: #fff;
|
|
||||||
border: 1px solid #e2e8f0;
|
|
||||||
border-radius: 16px;
|
|
||||||
|
|
||||||
&__icon-wrap {
|
|
||||||
width: 72px;
|
|
||||||
height: 72px;
|
|
||||||
border-radius: 50%;
|
|
||||||
background: #f0fdf4;
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
justify-content: center;
|
|
||||||
|
|
||||||
mat-icon {
|
|
||||||
font-size: 40px;
|
|
||||||
width: 40px;
|
|
||||||
height: 40px;
|
|
||||||
color: #16a34a;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
&__title {
|
|
||||||
font-size: 22px;
|
|
||||||
font-weight: 700;
|
|
||||||
color: #1e293b;
|
|
||||||
margin: 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
&__sub {
|
|
||||||
font-size: 14px;
|
|
||||||
color: #64748b;
|
|
||||||
max-width: 480px;
|
|
||||||
margin: 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
&__ref {
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
gap: 10px;
|
|
||||||
background: #f1f5f9;
|
|
||||||
border: 1px solid #e2e8f0;
|
|
||||||
border-radius: 8px;
|
|
||||||
padding: 8px 16px;
|
|
||||||
}
|
|
||||||
|
|
||||||
&__ref-label {
|
|
||||||
font-size: 11px;
|
|
||||||
font-weight: 700;
|
|
||||||
text-transform: uppercase;
|
|
||||||
letter-spacing: 0.07em;
|
|
||||||
color: #94a3b8;
|
|
||||||
white-space: nowrap;
|
|
||||||
}
|
|
||||||
|
|
||||||
&__ref-val {
|
|
||||||
font-family: 'Roboto Mono', 'Courier New', monospace;
|
|
||||||
font-size: 13px;
|
|
||||||
font-weight: 600;
|
|
||||||
color: #1353a0;
|
|
||||||
}
|
|
||||||
|
|
||||||
&__note {
|
|
||||||
font-size: 12px;
|
|
||||||
color: #94a3b8;
|
|
||||||
max-width: 420px;
|
|
||||||
margin: 0;
|
|
||||||
text-align: center;
|
|
||||||
}
|
|
||||||
|
|
||||||
&__gl {
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
gap: 6px;
|
|
||||||
width: 100%;
|
|
||||||
max-width: 400px;
|
|
||||||
background: #f8fafc;
|
|
||||||
border: 1px solid #e2e8f0;
|
|
||||||
border-radius: 10px;
|
|
||||||
padding: 14px 16px;
|
|
||||||
}
|
|
||||||
|
|
||||||
&__gl-row {
|
|
||||||
display: flex;
|
|
||||||
justify-content: space-between;
|
|
||||||
font-size: 13px;
|
|
||||||
font-weight: 600;
|
|
||||||
padding: 4px 0;
|
|
||||||
border-bottom: 1px dotted #e2e8f0;
|
|
||||||
|
|
||||||
&:last-child {
|
|
||||||
border-bottom: none;
|
|
||||||
}
|
|
||||||
|
|
||||||
&--dr {
|
|
||||||
color: #dc2626;
|
|
||||||
}
|
|
||||||
|
|
||||||
&--cr {
|
|
||||||
color: #15803d;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,282 +0,0 @@
|
|||||||
/**
|
|
||||||
* 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 { RouterModule } from '@angular/router';
|
|
||||||
import { MatIconModule } from '@angular/material/icon';
|
|
||||||
import { MatSnackBar, MatSnackBarModule } from '@angular/material/snack-bar';
|
|
||||||
import { MatFormFieldModule } from '@angular/material/form-field';
|
|
||||||
import { MatSelectModule } from '@angular/material/select';
|
|
||||||
import { MatInputModule } from '@angular/material/input';
|
|
||||||
import { catchError } from 'rxjs/operators';
|
|
||||||
import { of } from 'rxjs';
|
|
||||||
import { CashierService } from '../cashier.service';
|
|
||||||
|
|
||||||
type PageState = 'idle' | 'searching' | 'loaded' | 'submitting' | 'success';
|
|
||||||
|
|
||||||
interface Denomination {
|
|
||||||
id: number;
|
|
||||||
value: number;
|
|
||||||
kind: 'note' | 'coin';
|
|
||||||
label: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
interface ShareCharge {
|
|
||||||
id: number;
|
|
||||||
name: string;
|
|
||||||
amount: number;
|
|
||||||
currencySymbol: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
@Component({
|
|
||||||
selector: 'mifosx-share-collection',
|
|
||||||
templateUrl: './share-collection.component.html',
|
|
||||||
styleUrls: [
|
|
||||||
'../cashier-shared.scss',
|
|
||||||
'./share-collection.component.scss'
|
|
||||||
],
|
|
||||||
standalone: true,
|
|
||||||
imports: [
|
|
||||||
CommonModule,
|
|
||||||
FormsModule,
|
|
||||||
RouterModule,
|
|
||||||
MatIconModule,
|
|
||||||
MatSnackBarModule,
|
|
||||||
MatFormFieldModule,
|
|
||||||
MatSelectModule,
|
|
||||||
MatInputModule
|
|
||||||
]
|
|
||||||
})
|
|
||||||
export class ShareCollectionComponent implements OnInit {
|
|
||||||
private service = inject(CashierService);
|
|
||||||
private snackBar = inject(MatSnackBar);
|
|
||||||
|
|
||||||
pageState: PageState = 'idle';
|
|
||||||
accountInput = '';
|
|
||||||
transactionDate = '';
|
|
||||||
paymentTypeId: number | null = null;
|
|
||||||
paymentTypes: any[] = [];
|
|
||||||
note = '';
|
|
||||||
errorMsg = '';
|
|
||||||
saving = false;
|
|
||||||
|
|
||||||
account: any = null;
|
|
||||||
charges: ShareCharge[] = [];
|
|
||||||
collectionResult: any = null; // filled after successful POST
|
|
||||||
|
|
||||||
// Editable fields — cashier can override if API returns 0
|
|
||||||
requestedSharesInput = 0;
|
|
||||||
pricePerShare = 0;
|
|
||||||
|
|
||||||
// ── Denomination ────────────────────────────────────────────────────────────
|
|
||||||
notes: Denomination[] = [];
|
|
||||||
coins: Denomination[] = [];
|
|
||||||
denominationCounts: Record<string, number> = {};
|
|
||||||
|
|
||||||
get shareAmount(): number {
|
|
||||||
return this.requestedSharesInput * this.pricePerShare;
|
|
||||||
}
|
|
||||||
|
|
||||||
get totalChargeAmount(): number {
|
|
||||||
return this.charges.reduce((s, c) => s + c.amount, 0);
|
|
||||||
}
|
|
||||||
|
|
||||||
get totalAmount(): number {
|
|
||||||
return this.shareAmount + this.totalChargeAmount;
|
|
||||||
}
|
|
||||||
|
|
||||||
get currencySymbol(): string {
|
|
||||||
return this.account?.currency?.displaySymbol ?? '₹';
|
|
||||||
}
|
|
||||||
|
|
||||||
get isCashPayment(): boolean {
|
|
||||||
if (this.paymentTypeId === null) return true;
|
|
||||||
const pt = this.paymentTypes.find((p: any) => p.id === this.paymentTypeId);
|
|
||||||
return !!pt?.isCashPayment;
|
|
||||||
}
|
|
||||||
|
|
||||||
get allDenominations(): Denomination[] {
|
|
||||||
const desc = (a: Denomination, b: Denomination) => b.value - a.value;
|
|
||||||
return [
|
|
||||||
...[...this.notes].sort(desc),
|
|
||||||
...[...this.coins].sort(desc)
|
|
||||||
];
|
|
||||||
}
|
|
||||||
|
|
||||||
get denominationTotal(): number {
|
|
||||||
return this.allDenominations.reduce((s, d) => s + this.subtotalFor(d), 0);
|
|
||||||
}
|
|
||||||
|
|
||||||
get denomMatchesTotal(): boolean {
|
|
||||||
return this.denominationTotal === this.totalAmount;
|
|
||||||
}
|
|
||||||
|
|
||||||
get isValid(): boolean {
|
|
||||||
if (!this.account || this.totalAmount <= 0 || !this.transactionDate) return false;
|
|
||||||
if (this.isCashPayment && this.allDenominations.length > 0) {
|
|
||||||
return this.denomMatchesTotal;
|
|
||||||
}
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
|
|
||||||
ngOnInit(): void {
|
|
||||||
const d = new Date();
|
|
||||||
this.transactionDate = `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, '0')}-${String(d.getDate()).padStart(2, '0')}`;
|
|
||||||
this.service
|
|
||||||
.getPaymentTypes()
|
|
||||||
.pipe(catchError(() => of([])))
|
|
||||||
.subscribe((types: any[]) => (this.paymentTypes = types || []));
|
|
||||||
this.loadDenominations();
|
|
||||||
}
|
|
||||||
|
|
||||||
private loadDenominations(): void {
|
|
||||||
this.service
|
|
||||||
.getActiveDenominations()
|
|
||||||
.pipe(catchError(() => of([])))
|
|
||||||
.subscribe((list: any[]) => {
|
|
||||||
const rows: Denomination[] = (list || []).map((d: any) => ({
|
|
||||||
id: d.id,
|
|
||||||
value: d.denomination ?? d.value,
|
|
||||||
kind: d.type === 'coin' ? 'coin' : 'note',
|
|
||||||
label: '₹' + (d.denomination ?? d.value)
|
|
||||||
}));
|
|
||||||
const order = (a: Denomination, b: Denomination) => b.value - a.value;
|
|
||||||
this.notes = rows.filter((r) => r.kind === 'note').sort(order);
|
|
||||||
this.coins = rows.filter((r) => r.kind === 'coin').sort(order);
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
// ── Denomination helpers ──────────────────────────────────────────────────
|
|
||||||
|
|
||||||
denomKey(d: Denomination): string {
|
|
||||||
return `${d.kind}:${d.value}`;
|
|
||||||
}
|
|
||||||
|
|
||||||
countFor(d: Denomination): number {
|
|
||||||
return this.denominationCounts[this.denomKey(d)] || 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
subtotalFor(d: Denomination): number {
|
|
||||||
return this.countFor(d) * d.value;
|
|
||||||
}
|
|
||||||
|
|
||||||
onDenomChange(d: Denomination, raw: any): void {
|
|
||||||
this.denominationCounts[this.denomKey(d)] = Math.max(0, Math.floor(Number(raw) || 0));
|
|
||||||
}
|
|
||||||
|
|
||||||
bumpDenom(d: Denomination, delta: number): void {
|
|
||||||
this.denominationCounts[this.denomKey(d)] = Math.max(0, this.countFor(d) + delta);
|
|
||||||
}
|
|
||||||
|
|
||||||
clearDenominations(): void {
|
|
||||||
this.denominationCounts = {};
|
|
||||||
}
|
|
||||||
|
|
||||||
searchAccount(): void {
|
|
||||||
const query = this.accountInput.trim();
|
|
||||||
if (!query) return;
|
|
||||||
this.pageState = 'searching';
|
|
||||||
this.account = null;
|
|
||||||
this.charges = [];
|
|
||||||
this.errorMsg = '';
|
|
||||||
|
|
||||||
this.service
|
|
||||||
.getShareAccountForCollection(query)
|
|
||||||
.pipe(
|
|
||||||
catchError((err) => {
|
|
||||||
this.errorMsg =
|
|
||||||
err?.error?.errors?.[0]?.defaultUserMessage ||
|
|
||||||
err?.error?.developerMessage ||
|
|
||||||
'Account not found. Please check the account ID and try again.';
|
|
||||||
this.pageState = 'idle';
|
|
||||||
return of(null);
|
|
||||||
})
|
|
||||||
)
|
|
||||||
.subscribe((acc: any) => {
|
|
||||||
if (!acc) return;
|
|
||||||
this.account = acc;
|
|
||||||
|
|
||||||
// Auto-detect shares from summary or first pending purchasedShares entry
|
|
||||||
const pendingTx = (acc.purchasedShares || []).find(
|
|
||||||
(t: any) =>
|
|
||||||
t.status?.code === 'purchasedSharesStatusType.applied' && t.type?.code === 'purchasedSharesType.purchased'
|
|
||||||
);
|
|
||||||
this.requestedSharesInput = Number(
|
|
||||||
acc.summary?.totalPendingForApprovalShares || pendingTx?.numberOfShares || acc.requestedShares || 0
|
|
||||||
);
|
|
||||||
this.pricePerShare = Number(pendingTx?.purchasedPrice || acc.currentMarketPrice || acc.unitPrice || 0);
|
|
||||||
|
|
||||||
// Show ALL charges on the account — no type filtering
|
|
||||||
this.charges = (acc.charges || []).map((c: any) => ({
|
|
||||||
id: c.chargeId ?? c.id,
|
|
||||||
name: c.name,
|
|
||||||
amount: Number(c.amount ?? c.amountOutstanding ?? 0),
|
|
||||||
currencySymbol: c.currency?.displaySymbol ?? '₹'
|
|
||||||
}));
|
|
||||||
|
|
||||||
this.pageState = 'loaded';
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
recalc(): void {
|
|
||||||
// triggered by input changes — nothing extra needed, getters auto-recalculate
|
|
||||||
}
|
|
||||||
|
|
||||||
submit(): void {
|
|
||||||
if (!this.isValid || this.saving) return;
|
|
||||||
this.saving = true;
|
|
||||||
const payload = {
|
|
||||||
shareAccountId: this.account.id,
|
|
||||||
accountNo: this.account.accountNo,
|
|
||||||
clientName: this.account.clientName,
|
|
||||||
transactionDate: this.transactionDate,
|
|
||||||
requestedShares: this.requestedSharesInput,
|
|
||||||
pricePerShare: this.pricePerShare,
|
|
||||||
shareAmount: this.shareAmount,
|
|
||||||
chargeAmount: this.totalChargeAmount,
|
|
||||||
totalAmount: this.totalAmount,
|
|
||||||
paymentTypeId: this.paymentTypeId || null,
|
|
||||||
note: this.note || null
|
|
||||||
};
|
|
||||||
|
|
||||||
this.service.collectSharePayment(payload).subscribe({
|
|
||||||
next: (result: any) => {
|
|
||||||
this.collectionResult = result;
|
|
||||||
this.snackBar.open('Share collection posted successfully.', 'Close', { duration: 3000 });
|
|
||||||
this.pageState = 'success';
|
|
||||||
this.saving = false;
|
|
||||||
},
|
|
||||||
error: (err: any) => {
|
|
||||||
const msg =
|
|
||||||
err?.error?.errors?.[0]?.defaultUserMessage ||
|
|
||||||
err?.error?.developerMessage ||
|
|
||||||
err?.error?.defaultUserMessage ||
|
|
||||||
'Collection failed. Please try again.';
|
|
||||||
this.snackBar.open(msg, 'Close', { duration: 8000 });
|
|
||||||
this.saving = false;
|
|
||||||
}
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
reset(): void {
|
|
||||||
this.pageState = 'idle';
|
|
||||||
this.accountInput = '';
|
|
||||||
this.account = null;
|
|
||||||
this.charges = [];
|
|
||||||
this.errorMsg = '';
|
|
||||||
this.note = '';
|
|
||||||
this.paymentTypeId = null;
|
|
||||||
this.requestedSharesInput = 0;
|
|
||||||
this.pricePerShare = 0;
|
|
||||||
this.collectionResult = null;
|
|
||||||
this.saving = false;
|
|
||||||
this.clearDenominations();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -8,7 +8,7 @@
|
|||||||
|
|
||||||
import { Component, OnInit, inject } from '@angular/core';
|
import { Component, OnInit, inject } from '@angular/core';
|
||||||
import { CommonModule } from '@angular/common';
|
import { CommonModule } from '@angular/common';
|
||||||
import { FormsModule, ReactiveFormsModule, FormBuilder, FormGroup, Validators } from '@angular/forms';
|
import { FormsModule, ReactiveFormsModule, FormBuilder, FormGroup, FormArray, Validators } from '@angular/forms';
|
||||||
import { MatDialogModule, MatDialogRef, MAT_DIALOG_DATA } from '@angular/material/dialog';
|
import { MatDialogModule, MatDialogRef, MAT_DIALOG_DATA } from '@angular/material/dialog';
|
||||||
import { MatFormFieldModule } from '@angular/material/form-field';
|
import { MatFormFieldModule } from '@angular/material/form-field';
|
||||||
import { MatInputModule } from '@angular/material/input';
|
import { MatInputModule } from '@angular/material/input';
|
||||||
@@ -35,6 +35,35 @@ export interface CashierIssueDdDialogData {
|
|||||||
accountNo?: string;
|
accountNo?: string;
|
||||||
/** The account holder, for the same reason. */
|
/** The account holder, for the same reason. */
|
||||||
accountHolderName?: string;
|
accountHolderName?: string;
|
||||||
|
/**
|
||||||
|
* How many drafts this slip raises. Absent or 1 is the ordinary single draft and the dialog is
|
||||||
|
* unchanged; 2 or more turns it into a slip, asking for a face value, charge and beneficiary per
|
||||||
|
* draft and reporting the combined total the account will be debited for.
|
||||||
|
*/
|
||||||
|
ddCount?: number;
|
||||||
|
/**
|
||||||
|
* Whether the funding account is a GL account rather than a member's. Only affects wording: a GL
|
||||||
|
* account has no passbook, so it is not promised one.
|
||||||
|
*/
|
||||||
|
glFunded?: boolean;
|
||||||
|
/**
|
||||||
|
* The leaflets provisionally shown against each line of a slip.
|
||||||
|
*
|
||||||
|
* Only ever indicative. The server allocates the real numbers inside the locked transaction that
|
||||||
|
* submits the slip, so what is shown here is the next-available run at the moment the dialog was
|
||||||
|
* opened — accurate unless another clerk submits first, which is precisely why the numbers cannot
|
||||||
|
* be promised until submit returns them.
|
||||||
|
*/
|
||||||
|
previewDdNumbers?: string[];
|
||||||
|
}
|
||||||
|
|
||||||
|
/** One captured draft on a multi-DD slip. */
|
||||||
|
export interface DdSlipLineResult {
|
||||||
|
beneficiaryName: string;
|
||||||
|
payableAtBranch: string;
|
||||||
|
payableAtCity: string;
|
||||||
|
faceValue: number;
|
||||||
|
chargesAmount: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
@Component({
|
@Component({
|
||||||
@@ -56,10 +85,20 @@ export interface CashierIssueDdDialogData {
|
|||||||
<div class="dd-dialog-header__title">
|
<div class="dd-dialog-header__title">
|
||||||
<mat-icon class="dd-icon">receipt_long</mat-icon>
|
<mat-icon class="dd-icon">receipt_long</mat-icon>
|
||||||
<div>
|
<div>
|
||||||
<h2>{{ isAccountDebit ? 'Account-Debit Demand Draft (DD)' : 'Walk-in Cash Demand Draft (DD)' }}</h2>
|
<h2>
|
||||||
|
@if (isSlip) {
|
||||||
|
Multi-DD Slip · {{ lineCount }} Demand Drafts
|
||||||
|
} @else {
|
||||||
|
{{ isAccountDebit ? 'Account-Debit Demand Draft (DD)' : 'Walk-in Cash Demand Draft (DD)' }}
|
||||||
|
}
|
||||||
|
</h2>
|
||||||
<p>
|
<p>
|
||||||
|
@if (isSlip) {
|
||||||
|
One debit funds all {{ lineCount }} drafts · Enter each draft's beneficiary and amount
|
||||||
|
} @else {
|
||||||
Next available DD leaflet auto-selected ·
|
Next available DD leaflet auto-selected ·
|
||||||
{{ isAccountDebit ? 'Enter beneficiary details' : 'Enter purchaser & beneficiary details' }}
|
{{ isAccountDebit ? 'Enter beneficiary details' : 'Enter purchaser & beneficiary details' }}
|
||||||
|
}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -69,6 +108,55 @@ export interface CashierIssueDdDialogData {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<mat-dialog-content class="dd-dialog-body">
|
<mat-dialog-content class="dd-dialog-body">
|
||||||
|
<!--
|
||||||
|
Slip running total.
|
||||||
|
|
||||||
|
A slip's figures are not known until every line is typed — different drafts carry different
|
||||||
|
face values and different charges — so the total the account will be debited for is built
|
||||||
|
up here as the clerk works, rather than shown as a fixed figure up front. Answering "what
|
||||||
|
is this going to cost my member" without it would mean adding the lines by hand.
|
||||||
|
-->
|
||||||
|
@if (isSlip) {
|
||||||
|
<div class="dd-summary-card">
|
||||||
|
<div class="summary-col">
|
||||||
|
<span class="label">Drafts On This Slip</span>
|
||||||
|
<strong class="dd-number-highlight">{{ lineCount }}</strong>
|
||||||
|
<span class="sublabel">{{ data.sponsorBankName || 'Sponsor Bank' }} · {{ data.branchName }}</span>
|
||||||
|
</div>
|
||||||
|
<div class="summary-divider"></div>
|
||||||
|
<div class="summary-col">
|
||||||
|
<span class="label">Total Face Value</span>
|
||||||
|
<strong class="amount">₹{{ slipFaceValue | number: '1.2-2' }}</strong>
|
||||||
|
<span class="sublabel">+ Charges ₹{{ slipCharges | number: '1.2-2' }}</span>
|
||||||
|
</div>
|
||||||
|
<div class="summary-divider"></div>
|
||||||
|
<div class="summary-col">
|
||||||
|
<span class="label">GST ({{ data.gstPercentage }}%)</span>
|
||||||
|
<strong class="amount">₹{{ slipGst | number: '1.2-2' }}</strong>
|
||||||
|
<span class="sublabel">on charges only</span>
|
||||||
|
</div>
|
||||||
|
<div class="summary-divider"></div>
|
||||||
|
<div class="summary-col highlight-col">
|
||||||
|
<span class="label">Total To Debit</span>
|
||||||
|
<strong class="total-amount">₹{{ slipTotal | number: '1.2-2' }}</strong>
|
||||||
|
<span class="sublabel">{{ data.accountNo || 'Account' }} · one debit</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!--
|
||||||
|
Said plainly because the numbers on screen are the one thing in this dialog the clerk
|
||||||
|
cannot rely on. They are the next-available run as it stood when the dialog opened; the
|
||||||
|
binding allocation happens inside the submit transaction, so a slip filed after another
|
||||||
|
clerk's gets the following numbers instead.
|
||||||
|
-->
|
||||||
|
<div class="dd-preview-note">
|
||||||
|
<mat-icon>info</mat-icon>
|
||||||
|
<div>
|
||||||
|
DD numbers below are <strong>provisional</strong>. The final numbers are allocated when the slip is
|
||||||
|
submitted and will be confirmed on the receipt.
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
} @else {
|
||||||
<!-- Draft & Financials Summary Card -->
|
<!-- Draft & Financials Summary Card -->
|
||||||
<div class="dd-summary-card">
|
<div class="dd-summary-card">
|
||||||
<!-- The number the customer will actually see printed on the instrument. "Auto-Selected
|
<!-- The number the customer will actually see printed on the instrument. "Auto-Selected
|
||||||
@@ -104,6 +192,7 @@ export interface CashierIssueDdDialogData {
|
|||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
}
|
||||||
|
|
||||||
<!--
|
<!--
|
||||||
The account funding the draft, stated before the form.
|
The account funding the draft, stated before the form.
|
||||||
@@ -121,13 +210,121 @@ export interface CashierIssueDdDialogData {
|
|||||||
· {{ data.accountHolderName }}
|
· {{ data.accountHolderName }}
|
||||||
}
|
}
|
||||||
<div class="dd-funding-sub">
|
<div class="dd-funding-sub">
|
||||||
|
@if (isSlip) {
|
||||||
|
₹{{ slipTotal | number: '1.2-2' }} will be debited from this account as a
|
||||||
|
<strong>single transaction</strong> covering all {{ lineCount }} drafts, when a checker approves the
|
||||||
|
slip —
|
||||||
|
<!-- A GL account has no passbook, so the reassurance a member gets is phrased for
|
||||||
|
the ledger instead of promising a statement line that does not exist. -->
|
||||||
|
@if (isGlFunded) {
|
||||||
|
one journal entry against this GL account, not {{ lineCount }}.
|
||||||
|
} @else {
|
||||||
|
the member's passbook shows one entry, not {{ lineCount }}.
|
||||||
|
}
|
||||||
|
} @else {
|
||||||
₹{{ data.totalCashPayable | number: '1.2-2' }} will be debited from this account when a checker
|
₹{{ data.totalCashPayable | number: '1.2-2' }} will be debited from this account when a checker
|
||||||
approves. No cash is taken at the counter.
|
approves. No cash is taken at the counter.
|
||||||
|
}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
}
|
}
|
||||||
|
|
||||||
|
<!--
|
||||||
|
A slip's drafts, one card each.
|
||||||
|
|
||||||
|
Face value and charge sit on the line rather than on the slip because that is how the bank
|
||||||
|
actually uses this: three drafts to three institutions are three different amounts, and the
|
||||||
|
charge is levied per draft. The server stores and posts them per line for the same reason.
|
||||||
|
-->
|
||||||
|
@if (isSlip) {
|
||||||
|
<form [formGroup]="slipForm" class="dd-form">
|
||||||
|
<div formArrayName="lines">
|
||||||
|
@for (lineCtrl of slipLines.controls; track $index) {
|
||||||
|
<div class="dd-line-card" [formGroupName]="$index">
|
||||||
|
<div class="dd-line-head">
|
||||||
|
<span class="dd-line-badge">DD {{ $index + 1 }} of {{ lineCount }}</span>
|
||||||
|
@if (previewNumberAt($index)) {
|
||||||
|
<span class="dd-line-number">No. {{ previewNumberAt($index) }} <em>(provisional)</em></span>
|
||||||
|
}
|
||||||
|
<span class="dd-line-total"> Debit ₹{{ lineTotalAt($index) | number: '1.2-2' }} </span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<mat-form-field appearance="outline" class="full-width">
|
||||||
|
<mat-label>Beneficiary Name (In Favour Of) *</mat-label>
|
||||||
|
<input
|
||||||
|
matInput
|
||||||
|
formControlName="beneficiaryName"
|
||||||
|
placeholder="e.g. UNIVERSITY OF MUMBAI"
|
||||||
|
maxlength="120"
|
||||||
|
style="text-transform: uppercase;"
|
||||||
|
/>
|
||||||
|
<mat-error *ngIf="lineCtrl.get('beneficiaryName')?.hasError('required')"
|
||||||
|
>Beneficiary name is required</mat-error
|
||||||
|
>
|
||||||
|
<mat-error *ngIf="lineCtrl.get('beneficiaryName')?.hasError('maxlength')"
|
||||||
|
>Max 120 characters</mat-error
|
||||||
|
>
|
||||||
|
</mat-form-field>
|
||||||
|
|
||||||
|
<div class="form-row">
|
||||||
|
<mat-form-field appearance="outline" class="form-field-flex">
|
||||||
|
<mat-label>Payable At - Branch *</mat-label>
|
||||||
|
<input
|
||||||
|
matInput
|
||||||
|
formControlName="payableAtBranch"
|
||||||
|
placeholder="e.g. FORT BRANCH"
|
||||||
|
maxlength="150"
|
||||||
|
style="text-transform: uppercase;"
|
||||||
|
/>
|
||||||
|
<mat-error *ngIf="lineCtrl.get('payableAtBranch')?.hasError('required')"
|
||||||
|
>Payable Branch is required</mat-error
|
||||||
|
>
|
||||||
|
</mat-form-field>
|
||||||
|
|
||||||
|
<mat-form-field appearance="outline" class="form-field-flex">
|
||||||
|
<mat-label>Payable At - City *</mat-label>
|
||||||
|
<input
|
||||||
|
matInput
|
||||||
|
formControlName="payableAtCity"
|
||||||
|
placeholder="e.g. MUMBAI"
|
||||||
|
maxlength="100"
|
||||||
|
style="text-transform: uppercase;"
|
||||||
|
/>
|
||||||
|
<mat-error *ngIf="lineCtrl.get('payableAtCity')?.hasError('required')"
|
||||||
|
>Payable City is required</mat-error
|
||||||
|
>
|
||||||
|
</mat-form-field>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="form-row">
|
||||||
|
<mat-form-field appearance="outline" class="form-field-flex">
|
||||||
|
<mat-label>Face Value *</mat-label>
|
||||||
|
<input matInput type="number" formControlName="faceValue" placeholder="0.00" />
|
||||||
|
<span matPrefix>₹ </span>
|
||||||
|
<mat-error *ngIf="lineCtrl.get('faceValue')?.hasError('required')"
|
||||||
|
>Face value is required</mat-error
|
||||||
|
>
|
||||||
|
<mat-error *ngIf="lineCtrl.get('faceValue')?.hasError('min')"
|
||||||
|
>Face value must be greater than zero</mat-error
|
||||||
|
>
|
||||||
|
</mat-form-field>
|
||||||
|
|
||||||
|
<mat-form-field appearance="outline" class="form-field-flex">
|
||||||
|
<mat-label>Charges</mat-label>
|
||||||
|
<input matInput type="number" formControlName="chargesAmount" placeholder="0.00" />
|
||||||
|
<span matPrefix>₹ </span>
|
||||||
|
<mat-hint>GST ₹{{ lineGstAt($index) | number: '1.2-2' }} at {{ data.gstPercentage }}%</mat-hint>
|
||||||
|
<mat-error *ngIf="lineCtrl.get('chargesAmount')?.hasError('min')"
|
||||||
|
>Charges cannot be negative</mat-error
|
||||||
|
>
|
||||||
|
</mat-form-field>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
} @else {
|
||||||
<form [formGroup]="form" class="dd-form">
|
<form [formGroup]="form" class="dd-form">
|
||||||
<!--
|
<!--
|
||||||
Purchaser identity is asked for only when the bank has none: a walk-in paying cash. On an
|
Purchaser identity is asked for only when the bank has none: a walk-in paying cash. On an
|
||||||
@@ -232,12 +429,18 @@ export interface CashierIssueDdDialogData {
|
|||||||
</mat-form-field>
|
</mat-form-field>
|
||||||
</div>
|
</div>
|
||||||
</form>
|
</form>
|
||||||
|
}
|
||||||
</mat-dialog-content>
|
</mat-dialog-content>
|
||||||
|
|
||||||
<mat-dialog-actions class="dd-dialog-actions" align="end">
|
<mat-dialog-actions class="dd-dialog-actions" align="end">
|
||||||
|
@if (isSlip) {
|
||||||
|
<span class="dd-actions-total">
|
||||||
|
{{ lineCount }} drafts · <strong>₹{{ slipTotal | number: '1.2-2' }}</strong> to debit
|
||||||
|
</span>
|
||||||
|
}
|
||||||
<button mat-button type="button" (click)="onClose()">Cancel</button>
|
<button mat-button type="button" (click)="onClose()">Cancel</button>
|
||||||
<button mat-flat-button color="primary" [disabled]="form.invalid" (click)="onSubmit()">
|
<button mat-flat-button color="primary" [disabled]="activeForm.invalid" (click)="onSubmit()">
|
||||||
Save DD Details
|
{{ isSlip ? 'Save Slip Details' : 'Save DD Details' }}
|
||||||
</button>
|
</button>
|
||||||
</mat-dialog-actions>
|
</mat-dialog-actions>
|
||||||
</div>
|
</div>
|
||||||
@@ -395,6 +598,81 @@ export interface CashierIssueDdDialogData {
|
|||||||
line-height: 1.5;
|
line-height: 1.5;
|
||||||
color: #64748b;
|
color: #64748b;
|
||||||
}
|
}
|
||||||
|
/* One card per draft on a slip — the boundary is what stops a clerk typing line 2's
|
||||||
|
beneficiary against line 1's amount. */
|
||||||
|
.dd-line-card {
|
||||||
|
border: 1px solid #e2e8f0;
|
||||||
|
border-left: 3px solid #0369a1;
|
||||||
|
border-radius: 8px;
|
||||||
|
padding: 14px 16px 4px;
|
||||||
|
margin-bottom: 14px;
|
||||||
|
background: #fcfdff;
|
||||||
|
}
|
||||||
|
.dd-line-head {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 10px;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
margin-bottom: 12px;
|
||||||
|
}
|
||||||
|
.dd-line-badge {
|
||||||
|
font-size: 11px;
|
||||||
|
font-weight: 700;
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 0.4px;
|
||||||
|
color: #ffffff;
|
||||||
|
background: #0369a1;
|
||||||
|
padding: 3px 9px;
|
||||||
|
border-radius: 999px;
|
||||||
|
}
|
||||||
|
.dd-line-number {
|
||||||
|
font-family: monospace;
|
||||||
|
font-size: 12px;
|
||||||
|
color: #475569;
|
||||||
|
}
|
||||||
|
.dd-line-number em {
|
||||||
|
font-style: normal;
|
||||||
|
color: #94a3b8;
|
||||||
|
font-size: 11px;
|
||||||
|
}
|
||||||
|
.dd-line-total {
|
||||||
|
margin-left: auto;
|
||||||
|
font-size: 12px;
|
||||||
|
font-weight: 600;
|
||||||
|
color: #065f46;
|
||||||
|
background: #ecfdf5;
|
||||||
|
border: 1px solid #a7f3d0;
|
||||||
|
padding: 3px 10px;
|
||||||
|
border-radius: 6px;
|
||||||
|
}
|
||||||
|
.dd-preview-note {
|
||||||
|
display: flex;
|
||||||
|
gap: 8px;
|
||||||
|
align-items: flex-start;
|
||||||
|
margin: 0 0 16px;
|
||||||
|
padding: 9px 12px;
|
||||||
|
background: #fffbeb;
|
||||||
|
border: 1px solid #fde68a;
|
||||||
|
border-radius: 6px;
|
||||||
|
font-size: 12px;
|
||||||
|
line-height: 1.5;
|
||||||
|
color: #78350f;
|
||||||
|
}
|
||||||
|
.dd-preview-note mat-icon {
|
||||||
|
color: #b45309;
|
||||||
|
font-size: 18px;
|
||||||
|
width: 18px;
|
||||||
|
height: 18px;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
.dd-actions-total {
|
||||||
|
margin-right: auto;
|
||||||
|
font-size: 13px;
|
||||||
|
color: #475569;
|
||||||
|
}
|
||||||
|
.dd-actions-total strong {
|
||||||
|
color: #065f46;
|
||||||
|
}
|
||||||
`
|
`
|
||||||
]
|
]
|
||||||
})
|
})
|
||||||
@@ -404,13 +682,127 @@ export class CashierIssueDdDialogComponent implements OnInit {
|
|||||||
public data: CashierIssueDdDialogData = inject(MAT_DIALOG_DATA);
|
public data: CashierIssueDdDialogData = inject(MAT_DIALOG_DATA);
|
||||||
|
|
||||||
form!: FormGroup;
|
form!: FormGroup;
|
||||||
|
/** Built only in slip mode; `form` stays untouched so the single-draft path is unchanged. */
|
||||||
|
slipForm!: FormGroup;
|
||||||
|
|
||||||
/** Whether the draft is funded by debiting a member's account rather than by cash at the counter. */
|
/** Whether the draft is funded by debiting a member's account rather than by cash at the counter. */
|
||||||
get isAccountDebit(): boolean {
|
get isAccountDebit(): boolean {
|
||||||
return this.data.paymentMethod === 'ACCOUNT_DEBIT';
|
return this.data.paymentMethod === 'ACCOUNT_DEBIT';
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** How many drafts this dialog is capturing — 1 unless the clerk asked for a slip. */
|
||||||
|
get lineCount(): number {
|
||||||
|
const n = Number(this.data.ddCount) || 1;
|
||||||
|
return n > 1 ? n : 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Whether this dialog is capturing a multi-DD slip rather than a single draft. */
|
||||||
|
get isSlip(): boolean {
|
||||||
|
return this.lineCount > 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Whether the debit lands on a GL account rather than a member's — wording only. */
|
||||||
|
get isGlFunded(): boolean {
|
||||||
|
return !!this.data.glFunded;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The form the Save button validates against — whichever of the two was built. */
|
||||||
|
get activeForm(): FormGroup {
|
||||||
|
return this.isSlip ? this.slipForm : this.form;
|
||||||
|
}
|
||||||
|
|
||||||
|
get slipLines(): FormArray {
|
||||||
|
return this.slipForm.get('lines') as FormArray;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The provisional leaflet number shown against a line, if the caller supplied a run of them. */
|
||||||
|
previewNumberAt(index: number): string | null {
|
||||||
|
return this.data.previewDdNumbers?.[index] ?? null;
|
||||||
|
}
|
||||||
|
|
||||||
|
private lineValueAt(index: number, field: string): number {
|
||||||
|
return Number(this.slipLines.at(index)?.get(field)?.value) || 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* GST on one line's charge.
|
||||||
|
*
|
||||||
|
* Rounded per line, matching the server, which computes and stores GST against each draft rather
|
||||||
|
* than once for the slip. Summing rounded lines and rounding a summed charge can differ by a
|
||||||
|
* paisa, and it is the per-line figure that ends up on the draft.
|
||||||
|
*/
|
||||||
|
lineGstAt(index: number): number {
|
||||||
|
const charges = this.lineValueAt(index, 'chargesAmount');
|
||||||
|
if (charges <= 0) return 0;
|
||||||
|
return Math.round(charges * (Number(this.data.gstPercentage) || 0)) / 100;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** What one line adds to the debit — its face value, its charge and the GST on that charge. */
|
||||||
|
lineTotalAt(index: number): number {
|
||||||
|
return this.lineValueAt(index, 'faceValue') + this.lineValueAt(index, 'chargesAmount') + this.lineGstAt(index);
|
||||||
|
}
|
||||||
|
|
||||||
|
get slipFaceValue(): number {
|
||||||
|
return this.slipLines.controls.reduce((sum, _c, i) => sum + this.lineValueAt(i, 'faceValue'), 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
get slipCharges(): number {
|
||||||
|
return this.slipLines.controls.reduce((sum, _c, i) => sum + this.lineValueAt(i, 'chargesAmount'), 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
get slipGst(): number {
|
||||||
|
return this.slipLines.controls.reduce((sum, _c, i) => sum + this.lineGstAt(i), 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The figure the account is debited for — rounded up to the whole rupee, as the single flow does. */
|
||||||
|
get slipTotal(): number {
|
||||||
|
return Math.ceil(this.slipFaceValue + this.slipCharges + this.slipGst - 1e-9);
|
||||||
|
}
|
||||||
|
|
||||||
ngOnInit(): void {
|
ngOnInit(): void {
|
||||||
|
if (this.isSlip) {
|
||||||
|
this.slipForm = this.fb.group({
|
||||||
|
lines: this.fb.array(
|
||||||
|
Array.from({ length: this.lineCount }, () =>
|
||||||
|
this.fb.group({
|
||||||
|
beneficiaryName: [
|
||||||
|
'',
|
||||||
|
[
|
||||||
|
Validators.required,
|
||||||
|
Validators.maxLength(120)
|
||||||
|
]
|
||||||
|
],
|
||||||
|
payableAtBranch: [
|
||||||
|
'',
|
||||||
|
[Validators.required]
|
||||||
|
],
|
||||||
|
payableAtCity: [
|
||||||
|
'',
|
||||||
|
[Validators.required]
|
||||||
|
],
|
||||||
|
// Zero is rejected rather than merely warned about: the server refuses a line with a
|
||||||
|
// face value at or below zero, and a slip that fails there has already cost the clerk
|
||||||
|
// every other line they typed.
|
||||||
|
faceValue: [
|
||||||
|
null as
|
||||||
|
| number
|
||||||
|
| null,
|
||||||
|
[
|
||||||
|
Validators.required,
|
||||||
|
Validators.min(0.01)
|
||||||
|
]
|
||||||
|
],
|
||||||
|
chargesAmount: [
|
||||||
|
0,
|
||||||
|
[Validators.min(0)]
|
||||||
|
]
|
||||||
|
})
|
||||||
|
)
|
||||||
|
)
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
// Walk-in identity is required for cash and rejected outright for an account debit — the server
|
// Walk-in identity is required for cash and rejected outright for an account debit — the server
|
||||||
// refuses a walk-in name alongside an account — so the controls are only built when they apply.
|
// refuses a walk-in name alongside an account — so the controls are only built when they apply.
|
||||||
// Registering them unconditionally and merely hiding them would hold the form invalid on a
|
// Registering them unconditionally and merely hiding them would hold the form invalid on a
|
||||||
@@ -477,7 +869,25 @@ export class CashierIssueDdDialogComponent implements OnInit {
|
|||||||
}
|
}
|
||||||
|
|
||||||
onSubmit(): void {
|
onSubmit(): void {
|
||||||
if (this.form.invalid) return;
|
if (this.activeForm.invalid) return;
|
||||||
|
|
||||||
|
// A slip closes with its lines. There is no walk-in branch here: a slip is only ever offered on
|
||||||
|
// a member-account debit, where the purchaser is the account holder already on record.
|
||||||
|
if (this.isSlip) {
|
||||||
|
const lines: DdSlipLineResult[] = this.slipLines.controls.map((c) => {
|
||||||
|
const v = c.value;
|
||||||
|
return {
|
||||||
|
beneficiaryName: (v.beneficiaryName || '').trim().toUpperCase(),
|
||||||
|
payableAtBranch: (v.payableAtBranch || '').trim().toUpperCase(),
|
||||||
|
payableAtCity: (v.payableAtCity || '').trim().toUpperCase(),
|
||||||
|
faceValue: Number(v.faceValue) || 0,
|
||||||
|
chargesAmount: Number(v.chargesAmount) || 0
|
||||||
|
};
|
||||||
|
});
|
||||||
|
this.dialogRef.close({ lines });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
const val = this.form.value;
|
const val = this.form.value;
|
||||||
const beneficiary = {
|
const beneficiary = {
|
||||||
beneficiaryName: (val.beneficiaryName || '').trim().toUpperCase(),
|
beneficiaryName: (val.beneficiaryName || '').trim().toUpperCase(),
|
||||||
|
|||||||
@@ -117,6 +117,8 @@
|
|||||||
<select
|
<select
|
||||||
class="ws-f__ctl"
|
class="ws-f__ctl"
|
||||||
[(ngModel)]="row.typ"
|
[(ngModel)]="row.typ"
|
||||||
|
[disabled]="isTypLocked(row)"
|
||||||
|
[title]="isTypLocked(row) ? 'Token withdrawal - TYPE is fixed to Payment' : ''"
|
||||||
(click)="$event.stopPropagation()"
|
(click)="$event.stopPropagation()"
|
||||||
(change)="onTypChange(row)"
|
(change)="onTypChange(row)"
|
||||||
>
|
>
|
||||||
@@ -127,11 +129,16 @@
|
|||||||
</div>
|
</div>
|
||||||
<div class="ws-f ws-f--branch">
|
<div class="ws-f ws-f--branch">
|
||||||
<label class="ws-f__lbl" title="Branch">BRANCH</label>
|
<label class="ws-f__lbl" title="Branch">BRANCH</label>
|
||||||
<select class="ws-f__ctl" [(ngModel)]="row.br" (click)="$event.stopPropagation()">
|
<select
|
||||||
<option [ngValue]="1">001 · Main</option>
|
class="ws-f__ctl"
|
||||||
<option [ngValue]="2">002 · M.G. Road</option>
|
[(ngModel)]="row.br"
|
||||||
<option [ngValue]="14">014 · Station Rd</option>
|
[disabled]="branchSelectOptions.length <= 1"
|
||||||
<option [ngValue]="27">027 · Industrial</option>
|
[title]="branchSelectOptions.length <= 1 ? 'Your branch' : 'Branch'"
|
||||||
|
(click)="$event.stopPropagation()"
|
||||||
|
>
|
||||||
|
@for (b of branchSelectOptions; track b.id) {
|
||||||
|
<option [ngValue]="b.id">{{ b.name }}</option>
|
||||||
|
}
|
||||||
</select>
|
</select>
|
||||||
</div>
|
</div>
|
||||||
@if (row.typ === 'DD') {
|
@if (row.typ === 'DD') {
|
||||||
@@ -166,23 +173,44 @@
|
|||||||
</div>
|
</div>
|
||||||
<div class="ws-f ws-f--actyp">
|
<div class="ws-f ws-f--actyp">
|
||||||
<label class="ws-f__lbl" title="Account Type">AC. TP.</label>
|
<label class="ws-f__lbl" title="Account Type">AC. TP.</label>
|
||||||
|
@if (acTypLockedByToken && row === selectedRow) {
|
||||||
|
<span
|
||||||
|
class="ws-f__ctl ws-f__ctl--locked"
|
||||||
|
title="Locked — filled from a token-driven request, not a normal account entry"
|
||||||
|
>{{ acTypLockedLabel }}</span
|
||||||
|
>
|
||||||
|
} @else {
|
||||||
<select
|
<select
|
||||||
class="ws-f__ctl"
|
class="ws-f__ctl"
|
||||||
[(ngModel)]="row.acTyp"
|
[(ngModel)]="row.acTyp"
|
||||||
(click)="$event.stopPropagation()"
|
(click)="$event.stopPropagation()"
|
||||||
(change)="
|
(change)="isCreditOnly(row) && (row.dbCr = 'Cr')"
|
||||||
row.acTyp === 'GL' &&
|
|
||||||
row.typ !== 'Disbursement' &&
|
|
||||||
row.typ !== 'Fund Transfer' &&
|
|
||||||
row.typ !== 'Enter Bank Transfer' &&
|
|
||||||
(row.dbCr = 'Cr')
|
|
||||||
"
|
|
||||||
>
|
>
|
||||||
@for (a of acTypOptionsFor(row); track a) {
|
@for (a of acTypOptionsFor(row); track a) {
|
||||||
<option [value]="a">{{ a }}</option>
|
<option [value]="a">{{ a }}</option>
|
||||||
}
|
}
|
||||||
</select>
|
</select>
|
||||||
|
}
|
||||||
</div>
|
</div>
|
||||||
|
@if (showsEntryType(row)) {
|
||||||
|
<div class="ws-f ws-f--paytype">
|
||||||
|
<label
|
||||||
|
class="ws-f__lbl ws-f__lbl--accent"
|
||||||
|
title="What this money was actually collected for — checked before a share application or DD issuance can adopt this voucher"
|
||||||
|
>ENTRY TYPE</label
|
||||||
|
>
|
||||||
|
<select
|
||||||
|
class="ws-f__ctl ws-f__ctl--accent"
|
||||||
|
[(ngModel)]="row.entryTypeCvId"
|
||||||
|
(click)="$event.stopPropagation()"
|
||||||
|
>
|
||||||
|
<option [ngValue]="null">Select…</option>
|
||||||
|
@for (t of entryTypeOptions; track t.id) {
|
||||||
|
<option [ngValue]="t.id">{{ t.name }}</option>
|
||||||
|
}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -206,10 +234,16 @@
|
|||||||
? ddGlMapping?.ddPayableGlCode || 'Auto GL'
|
? ddGlMapping?.ddPayableGlCode || 'Auto GL'
|
||||||
: 'Account number'
|
: 'Account number'
|
||||||
"
|
"
|
||||||
[readonly]="row.typ === 'DD' && !isDdAccountDebitRow(row)"
|
[readonly]="
|
||||||
[class.ws-f__ctl--readonly]="row.typ === 'DD' && !isDdAccountDebitRow(row)"
|
(row.typ === 'DD' && !isDdAccountDebitRow(row)) || (acTypLockedByToken && row === selectedRow)
|
||||||
|
"
|
||||||
|
[class.ws-f__ctl--readonly]="
|
||||||
|
(row.typ === 'DD' && !isDdAccountDebitRow(row)) || (acTypLockedByToken && row === selectedRow)
|
||||||
|
"
|
||||||
[title]="
|
[title]="
|
||||||
isDdAccountDebitRow(row)
|
acTypLockedByToken && row === selectedRow
|
||||||
|
? 'Locked — filled from a token-driven request, not a normal account entry'
|
||||||
|
: isDdAccountDebitRow(row)
|
||||||
? 'Member account the draft is funded from'
|
? 'Member account the draft is funded from'
|
||||||
: row.typ === 'DD'
|
: row.typ === 'DD'
|
||||||
? 'Configured Sponsor DD Payable GL (Auto-selected)'
|
? 'Configured Sponsor DD Payable GL (Auto-selected)'
|
||||||
@@ -233,12 +267,7 @@
|
|||||||
type="button"
|
type="button"
|
||||||
class="ws-seg__btn ws-seg__btn--dr"
|
class="ws-seg__btn ws-seg__btn--dr"
|
||||||
[class.ws-seg__btn--active-dr]="row.dbCr === 'Dr'"
|
[class.ws-seg__btn--active-dr]="row.dbCr === 'Dr'"
|
||||||
[disabled]="
|
[disabled]="isCreditOnly(row)"
|
||||||
row.acTyp === 'GL' &&
|
|
||||||
row.typ !== 'Disbursement' &&
|
|
||||||
row.typ !== 'Fund Transfer' &&
|
|
||||||
row.typ !== 'Enter Bank Transfer'
|
|
||||||
"
|
|
||||||
(click)="row.dbCr = 'Dr'"
|
(click)="row.dbCr = 'Dr'"
|
||||||
>
|
>
|
||||||
DR
|
DR
|
||||||
@@ -247,6 +276,7 @@
|
|||||||
type="button"
|
type="button"
|
||||||
class="ws-seg__btn ws-seg__btn--cr"
|
class="ws-seg__btn ws-seg__btn--cr"
|
||||||
[class.ws-seg__btn--active-cr]="row.dbCr === 'Cr'"
|
[class.ws-seg__btn--active-cr]="row.dbCr === 'Cr'"
|
||||||
|
[disabled]="row.typ === 'Withdrawal Slip'"
|
||||||
(click)="row.dbCr = 'Cr'"
|
(click)="row.dbCr = 'Cr'"
|
||||||
>
|
>
|
||||||
CR
|
CR
|
||||||
@@ -286,9 +316,13 @@
|
|||||||
[value]="row.amount ?? ''"
|
[value]="row.amount ?? ''"
|
||||||
(input)="row.amount = +$any($event.target).value; syncShareLinkedAmounts()"
|
(input)="row.amount = +$any($event.target).value; syncShareLinkedAmounts()"
|
||||||
(click)="$event.stopPropagation()"
|
(click)="$event.stopPropagation()"
|
||||||
[placeholder]="row.acTyp === 'SHR' ? '0' : '0.00'"
|
[placeholder]="isMultiDdRow(row) ? 'Per draft' : row.acTyp === 'SHR' ? '0' : '0.00'"
|
||||||
[readonly]="!isClerk && !!(activeWithdrawalRequest || activeClosureRequest)"
|
[readonly]="
|
||||||
[class.ws-amt-input--locked]="!isClerk && !!(activeWithdrawalRequest || activeClosureRequest)"
|
isMultiDdRow(row) || (!isClerk && !!(activeWithdrawalRequest || activeClosureRequest))
|
||||||
|
"
|
||||||
|
[class.ws-amt-input--locked]="
|
||||||
|
isMultiDdRow(row) || (!isClerk && !!(activeWithdrawalRequest || activeClosureRequest))
|
||||||
|
"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
@if (row.acTyp === 'SHR' && rowSharePrices[row.acNo]) {
|
@if (row.acTyp === 'SHR' && rowSharePrices[row.acNo]) {
|
||||||
@@ -298,6 +332,19 @@
|
|||||||
}}/share</span
|
}}/share</span
|
||||||
>
|
>
|
||||||
}
|
}
|
||||||
|
<!-- On a slip the face value belongs to each draft, so the row total is the sum of
|
||||||
|
them and is filled in by the dialog rather than typed here. A clerk-typed
|
||||||
|
figure could disagree with the drafts, and the account would be debited for
|
||||||
|
a total the drafts do not add up to. -->
|
||||||
|
@if (isMultiDdRow(row)) {
|
||||||
|
<span class="ws-f__hint">
|
||||||
|
{{
|
||||||
|
row.ddLines?.length
|
||||||
|
? 'Total of ' + row.ddLines?.length + ' drafts'
|
||||||
|
: 'Set per draft in Fill DD Details'
|
||||||
|
}}
|
||||||
|
</span>
|
||||||
|
}
|
||||||
</div>
|
</div>
|
||||||
@if (row.acTyp === 'SHR' && (row.typ === 'Enter Bank Transfer' || row.typ === 'Fund Transfer')) {
|
@if (row.acTyp === 'SHR' && (row.typ === 'Enter Bank Transfer' || row.typ === 'Fund Transfer')) {
|
||||||
<div class="ws-f ws-f--cheqno">
|
<div class="ws-f ws-f--cheqno">
|
||||||
@@ -377,9 +424,36 @@
|
|||||||
<div class="ws-group ws-group--flex">
|
<div class="ws-group ws-group--flex">
|
||||||
<span class="ws-caption">DD Charges & GST — Admin Rate {{ ddGstPercentage }}%</span>
|
<span class="ws-caption">DD Charges & GST — Admin Rate {{ ddGstPercentage }}%</span>
|
||||||
<div class="ws-group__fields">
|
<div class="ws-group__fields">
|
||||||
|
<!-- How many drafts this one debit funds. Typed before the details are filled in,
|
||||||
|
because it is what decides how many beneficiary forms the dialog presents.
|
||||||
|
Offered only where the server can honour it: a member savings/current
|
||||||
|
account debit, never a GL or cash draft. -->
|
||||||
|
@if (canRowBeMultiDd(row)) {
|
||||||
|
<div class="ws-f ws-f--amt">
|
||||||
|
<label class="ws-f__lbl" title="Number of demand drafts to raise against this single debit"
|
||||||
|
>NO. OF DD</label
|
||||||
|
>
|
||||||
|
<div class="ws-amt-wrap">
|
||||||
|
<input
|
||||||
|
class="ws-f__ctl ws-amt-input"
|
||||||
|
type="number"
|
||||||
|
min="1"
|
||||||
|
[max]="maxDdSlipLines"
|
||||||
|
step="1"
|
||||||
|
[value]="ddCountFor(row)"
|
||||||
|
(input)="onDdCountChange(row, +$any($event.target).value)"
|
||||||
|
(click)="$event.stopPropagation()"
|
||||||
|
placeholder="1"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
@if (isMultiDdRow(row)) {
|
||||||
|
<span class="ws-f__hint">One debit · {{ ddCountFor(row) }} drafts</span>
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
}
|
||||||
<div class="ws-f ws-f--amt">
|
<div class="ws-f ws-f--amt">
|
||||||
<label class="ws-f__lbl" title="DD issuance charges / commission">CHARGES</label>
|
<label class="ws-f__lbl" title="DD issuance charges / commission">CHARGES</label>
|
||||||
<div class="ws-amt-wrap">
|
<div class="ws-amt-wrap" [class.ws-amt-wrap--locked]="isMultiDdRow(row)">
|
||||||
<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"
|
||||||
@@ -388,8 +462,15 @@
|
|||||||
(input)="row.ddCharges = +$any($event.target).value"
|
(input)="row.ddCharges = +$any($event.target).value"
|
||||||
(click)="$event.stopPropagation()"
|
(click)="$event.stopPropagation()"
|
||||||
placeholder="0.00"
|
placeholder="0.00"
|
||||||
|
[readonly]="isMultiDdRow(row)"
|
||||||
|
[class.ws-amt-input--locked]="isMultiDdRow(row)"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
<!-- Charges are levied per draft and can differ between them, so on a slip they
|
||||||
|
are entered against each draft and totalled here. -->
|
||||||
|
@if (isMultiDdRow(row)) {
|
||||||
|
<span class="ws-f__hint">Set per draft</span>
|
||||||
|
}
|
||||||
</div>
|
</div>
|
||||||
<div class="ws-f ws-f--amt">
|
<div class="ws-f ws-f--amt">
|
||||||
<label class="ws-f__lbl" title="GST on charges, at the admin-configured rate">
|
<label class="ws-f__lbl" title="GST on charges, at the admin-configured rate">
|
||||||
@@ -400,7 +481,7 @@
|
|||||||
<input
|
<input
|
||||||
class="ws-f__ctl ws-amt-input ws-amt-input--locked"
|
class="ws-f__ctl ws-amt-input ws-amt-input--locked"
|
||||||
type="text"
|
type="text"
|
||||||
[value]="ddGstAmountFor(row) | number: '1.2-2'"
|
[value]="ddGstDisplayFor(row)"
|
||||||
readonly
|
readonly
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
@@ -414,7 +495,7 @@
|
|||||||
<input
|
<input
|
||||||
class="ws-f__ctl ws-amt-input ws-amt-input--locked"
|
class="ws-f__ctl ws-amt-input ws-amt-input--locked"
|
||||||
type="text"
|
type="text"
|
||||||
[value]="ddTotalPayableFor(row)"
|
[value]="ddTotalDisplayFor(row)"
|
||||||
readonly
|
readonly
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
@@ -427,18 +508,27 @@
|
|||||||
[disabled]="saving"
|
[disabled]="saving"
|
||||||
(click)="openIssueWalkinDd(row); $event.stopPropagation()"
|
(click)="openIssueWalkinDd(row); $event.stopPropagation()"
|
||||||
[title]="
|
[title]="
|
||||||
isDdAccountDebitRow(row)
|
isMultiDdRow(row)
|
||||||
|
? 'Opens one beneficiary form per draft; DD numbers are allocated on submission'
|
||||||
|
: isDdAccountDebitRow(row)
|
||||||
? 'Auto-selects next available leaflet and opens the beneficiary form'
|
? 'Auto-selects next available leaflet and opens the beneficiary form'
|
||||||
: 'Auto-selects next available leaflet and opens walk-in purchaser form'
|
: 'Auto-selects next available leaflet and opens walk-in purchaser form'
|
||||||
"
|
"
|
||||||
>
|
>
|
||||||
<mat-icon style="font-size: 18px; width: 18px; height: 18px">{{
|
<mat-icon style="font-size: 18px; width: 18px; height: 18px">{{
|
||||||
row.ddDetails ? 'edit_note' : 'post_add'
|
row.ddDetails || row.ddLines?.length ? 'edit_note' : 'post_add'
|
||||||
}}</mat-icon>
|
}}</mat-icon>
|
||||||
<!-- Names the leaflet once details are captured, so the clerk can see at a
|
<!-- Names the leaflet once details are captured, so the clerk can see at a
|
||||||
glance which rows are ready before pressing Save. -->
|
glance which rows are ready before pressing Save. A slip names the count
|
||||||
|
instead: its numbers are not allocated until it is submitted. -->
|
||||||
<span>{{
|
<span>{{
|
||||||
row.ddDetails ? 'DD ' + row.ddDetails.ddNumber + ' · Edit Details' : 'Fill DD Details'
|
row.ddLines?.length
|
||||||
|
? row.ddLines?.length + ' Drafts Captured · Edit Slip'
|
||||||
|
: row.ddDetails
|
||||||
|
? 'DD ' + row.ddDetails.ddNumber + ' · Edit Details'
|
||||||
|
: isMultiDdRow(row)
|
||||||
|
? 'Fill ' + ddCountFor(row) + ' DD Details'
|
||||||
|
: 'Fill DD Details'
|
||||||
}}</span>
|
}}</span>
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
@@ -618,11 +708,38 @@
|
|||||||
<circle cx="12" cy="8" r="4" />
|
<circle cx="12" cy="8" r="4" />
|
||||||
<path d="M4 20c0-4 3.6-7 8-7s8 3 8 7" />
|
<path d="M4 20c0-4 3.6-7 8-7s8 3 8 7" />
|
||||||
</svg>
|
</svg>
|
||||||
Personal Information
|
{{ isGlAccount ? 'GL Account Information' : 'Personal Information' }}
|
||||||
</div>
|
</div>
|
||||||
|
@if (isGlAccount) {
|
||||||
|
<div class="te-panel__body">
|
||||||
|
@for (f of glInfo; track f.label) {
|
||||||
|
<div class="te-field">
|
||||||
|
<span class="te-label">{{ f.label }}</span
|
||||||
|
><span class="te-value te-value-wrap" [class.te-mono]="f.mono">{{ f.value }}</span>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
} @else {
|
||||||
<div class="te-panel__body">
|
<div class="te-panel__body">
|
||||||
<div class="te-field">
|
<div class="te-field">
|
||||||
<span class="te-label">Branch</span><span class="te-value">{{ officeName }}</span>
|
<span class="te-label">Branch</span
|
||||||
|
><span class="te-value">
|
||||||
|
@if (isOwnBranchAccount !== null) {
|
||||||
|
<span
|
||||||
|
class="te-branch-chip"
|
||||||
|
[class.te-branch-chip--mine]="isOwnBranchAccount"
|
||||||
|
[class.te-branch-chip--other]="!isOwnBranchAccount"
|
||||||
|
[title]="isOwnBranchAccount ? 'Held at your branch' : 'Held at another branch'"
|
||||||
|
>
|
||||||
|
<i class="fas fa-map-marker-alt"></i>{{ officeName }}
|
||||||
|
@if (!isOwnBranchAccount) {
|
||||||
|
<span class="te-branch-chip__tag">Other branch</span>
|
||||||
|
}
|
||||||
|
</span>
|
||||||
|
} @else {
|
||||||
|
{{ officeName }}
|
||||||
|
}
|
||||||
|
</span>
|
||||||
</div>
|
</div>
|
||||||
<div class="te-field">
|
<div class="te-field">
|
||||||
<span class="te-label">A/c No.</span
|
<span class="te-label">A/c No.</span
|
||||||
@@ -682,6 +799,7 @@
|
|||||||
</div>
|
</div>
|
||||||
}
|
}
|
||||||
</div>
|
</div>
|
||||||
|
}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Joint Holders + Nominees -->
|
<!-- Joint Holders + Nominees -->
|
||||||
@@ -836,6 +954,7 @@
|
|||||||
|
|
||||||
<!-- Other Info + KYC + Account Balances -->
|
<!-- Other Info + KYC + Account Balances -->
|
||||||
<div class="te-panel">
|
<div class="te-panel">
|
||||||
|
@if (!isGlAccount) {
|
||||||
<div class="te-panel__header">
|
<div class="te-panel__header">
|
||||||
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
<svg width="14" height="14" 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" />
|
||||||
@@ -904,8 +1023,9 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
</div>
|
</div>
|
||||||
|
}
|
||||||
|
|
||||||
<div class="te-panel__header te-panel__header--mt">
|
<div class="te-panel__header" [class.te-panel__header--mt]="!isGlAccount">
|
||||||
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
||||||
<line x1="12" y1="1" x2="12" y2="23" />
|
<line x1="12" y1="1" x2="12" y2="23" />
|
||||||
<path d="M17 5H9.5a3.5 3.5 0 0 0 0 7h5a3.5 3.5 0 0 1 0 7H6" />
|
<path d="M17 5H9.5a3.5 3.5 0 0 0 0 7h5a3.5 3.5 0 0 1 0 7H6" />
|
||||||
@@ -1098,7 +1218,24 @@
|
|||||||
: '—')
|
: '—')
|
||||||
}}</span
|
}}</span
|
||||||
>
|
>
|
||||||
<span class="te-acs-kv"><b>Branch</b> {{ officeName ?? '—' }}</span>
|
<span class="te-acs-kv"
|
||||||
|
><b>Branch</b>
|
||||||
|
@if (isOwnBranchAccount !== null) {
|
||||||
|
<span
|
||||||
|
class="te-branch-chip"
|
||||||
|
[class.te-branch-chip--mine]="isOwnBranchAccount"
|
||||||
|
[class.te-branch-chip--other]="!isOwnBranchAccount"
|
||||||
|
[title]="isOwnBranchAccount ? 'Held at your branch' : 'Held at another branch'"
|
||||||
|
>
|
||||||
|
<i class="fas fa-map-marker-alt"></i>{{ officeName }}
|
||||||
|
@if (!isOwnBranchAccount) {
|
||||||
|
<span class="te-branch-chip__tag">Other branch</span>
|
||||||
|
}
|
||||||
|
</span>
|
||||||
|
} @else {
|
||||||
|
{{ officeName }}
|
||||||
|
}
|
||||||
|
</span>
|
||||||
<span class="te-acs-kv"
|
<span class="te-acs-kv"
|
||||||
><b>Product</b>
|
><b>Product</b>
|
||||||
{{
|
{{
|
||||||
@@ -1474,6 +1611,8 @@
|
|||||||
<select
|
<select
|
||||||
class="ws-f__ctl"
|
class="ws-f__ctl"
|
||||||
[(ngModel)]="row.typ"
|
[(ngModel)]="row.typ"
|
||||||
|
[disabled]="isTypLocked(row)"
|
||||||
|
[title]="isTypLocked(row) ? 'Token withdrawal - TYPE is fixed to Payment' : ''"
|
||||||
(click)="$event.stopPropagation()"
|
(click)="$event.stopPropagation()"
|
||||||
(change)="onTypChange(row)"
|
(change)="onTypChange(row)"
|
||||||
>
|
>
|
||||||
@@ -1484,11 +1623,16 @@
|
|||||||
</div>
|
</div>
|
||||||
<div class="ws-f ws-f--branch">
|
<div class="ws-f ws-f--branch">
|
||||||
<label class="ws-f__lbl">BRANCH</label>
|
<label class="ws-f__lbl">BRANCH</label>
|
||||||
<select class="ws-f__ctl" [(ngModel)]="row.br" (click)="$event.stopPropagation()">
|
<select
|
||||||
<option [ngValue]="1">001 · Main</option>
|
class="ws-f__ctl"
|
||||||
<option [ngValue]="2">002 · M.G. Road</option>
|
[(ngModel)]="row.br"
|
||||||
<option [ngValue]="14">014 · Station Rd</option>
|
[disabled]="branchSelectOptions.length <= 1"
|
||||||
<option [ngValue]="27">027 · Industrial</option>
|
[title]="branchSelectOptions.length <= 1 ? 'Your branch' : 'Branch'"
|
||||||
|
(click)="$event.stopPropagation()"
|
||||||
|
>
|
||||||
|
@for (b of branchSelectOptions; track b.id) {
|
||||||
|
<option [ngValue]="b.id">{{ b.name }}</option>
|
||||||
|
}
|
||||||
</select>
|
</select>
|
||||||
</div>
|
</div>
|
||||||
@if (row.typ === 'DD') {
|
@if (row.typ === 'DD') {
|
||||||
@@ -1523,23 +1667,44 @@
|
|||||||
</div>
|
</div>
|
||||||
<div class="ws-f ws-f--actyp">
|
<div class="ws-f ws-f--actyp">
|
||||||
<label class="ws-f__lbl">AC. TP.</label>
|
<label class="ws-f__lbl">AC. TP.</label>
|
||||||
|
@if (acTypLockedByToken && row === selectedRow) {
|
||||||
|
<span
|
||||||
|
class="ws-f__ctl ws-f__ctl--locked"
|
||||||
|
title="Locked — filled from a token-driven request, not a normal account entry"
|
||||||
|
>{{ acTypLockedLabel }}</span
|
||||||
|
>
|
||||||
|
} @else {
|
||||||
<select
|
<select
|
||||||
class="ws-f__ctl"
|
class="ws-f__ctl"
|
||||||
[(ngModel)]="row.acTyp"
|
[(ngModel)]="row.acTyp"
|
||||||
(click)="$event.stopPropagation()"
|
(click)="$event.stopPropagation()"
|
||||||
(change)="
|
(change)="isCreditOnly(row) && (row.dbCr = 'Cr')"
|
||||||
row.acTyp === 'GL' &&
|
|
||||||
row.typ !== 'Disbursement' &&
|
|
||||||
row.typ !== 'Fund Transfer' &&
|
|
||||||
row.typ !== 'Enter Bank Transfer' &&
|
|
||||||
(row.dbCr = 'Cr')
|
|
||||||
"
|
|
||||||
>
|
>
|
||||||
@for (a of acTypOptionsFor(row); track a) {
|
@for (a of acTypOptionsFor(row); track a) {
|
||||||
<option [value]="a">{{ a }}</option>
|
<option [value]="a">{{ a }}</option>
|
||||||
}
|
}
|
||||||
</select>
|
</select>
|
||||||
|
}
|
||||||
</div>
|
</div>
|
||||||
|
@if (showsEntryType(row)) {
|
||||||
|
<div class="ws-f ws-f--paytype">
|
||||||
|
<label
|
||||||
|
class="ws-f__lbl ws-f__lbl--accent"
|
||||||
|
title="What this money was actually collected for — checked before a share application or DD issuance can adopt this voucher"
|
||||||
|
>ENTRY TYPE</label
|
||||||
|
>
|
||||||
|
<select
|
||||||
|
class="ws-f__ctl ws-f__ctl--accent"
|
||||||
|
[(ngModel)]="row.entryTypeCvId"
|
||||||
|
(click)="$event.stopPropagation()"
|
||||||
|
>
|
||||||
|
<option [ngValue]="null">Select…</option>
|
||||||
|
@for (t of entryTypeOptions; track t.id) {
|
||||||
|
<option [ngValue]="t.id">{{ t.name }}</option>
|
||||||
|
}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="ws-vdivider"></div>
|
<div class="ws-vdivider"></div>
|
||||||
@@ -1556,10 +1721,16 @@
|
|||||||
[placeholder]="
|
[placeholder]="
|
||||||
row.typ === 'DD' ? ddGlMapping?.ddPayableGlCode || 'Auto GL' : 'Account number'
|
row.typ === 'DD' ? ddGlMapping?.ddPayableGlCode || 'Auto GL' : 'Account number'
|
||||||
"
|
"
|
||||||
[readonly]="row.typ === 'DD'"
|
[readonly]="row.typ === 'DD' || (acTypLockedByToken && row === selectedRow)"
|
||||||
[class.ws-f__ctl--readonly]="row.typ === 'DD'"
|
[class.ws-f__ctl--readonly]="
|
||||||
|
row.typ === 'DD' || (acTypLockedByToken && row === selectedRow)
|
||||||
|
"
|
||||||
[title]="
|
[title]="
|
||||||
row.typ === 'DD' ? 'Configured Sponsor DD Payable GL (Auto-selected)' : 'Account number'
|
acTypLockedByToken && row === selectedRow
|
||||||
|
? 'Locked — filled from a token-driven request, not a normal account entry'
|
||||||
|
: row.typ === 'DD'
|
||||||
|
? 'Configured Sponsor DD Payable GL (Auto-selected)'
|
||||||
|
: 'Account number'
|
||||||
"
|
"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
@@ -1578,12 +1749,7 @@
|
|||||||
type="button"
|
type="button"
|
||||||
class="ws-seg__btn ws-seg__btn--dr"
|
class="ws-seg__btn ws-seg__btn--dr"
|
||||||
[class.ws-seg__btn--active-dr]="row.dbCr === 'Dr'"
|
[class.ws-seg__btn--active-dr]="row.dbCr === 'Dr'"
|
||||||
[disabled]="
|
[disabled]="isCreditOnly(row)"
|
||||||
row.acTyp === 'GL' &&
|
|
||||||
row.typ !== 'Disbursement' &&
|
|
||||||
row.typ !== 'Fund Transfer' &&
|
|
||||||
row.typ !== 'Enter Bank Transfer'
|
|
||||||
"
|
|
||||||
(click)="$event.stopPropagation(); row.dbCr = 'Dr'"
|
(click)="$event.stopPropagation(); row.dbCr = 'Dr'"
|
||||||
>
|
>
|
||||||
DR
|
DR
|
||||||
@@ -1592,6 +1758,7 @@
|
|||||||
type="button"
|
type="button"
|
||||||
class="ws-seg__btn ws-seg__btn--cr"
|
class="ws-seg__btn ws-seg__btn--cr"
|
||||||
[class.ws-seg__btn--active-cr]="row.dbCr === 'Cr'"
|
[class.ws-seg__btn--active-cr]="row.dbCr === 'Cr'"
|
||||||
|
[disabled]="row.typ === 'Withdrawal Slip'"
|
||||||
(click)="$event.stopPropagation(); row.dbCr = 'Cr'"
|
(click)="$event.stopPropagation(); row.dbCr = 'Cr'"
|
||||||
>
|
>
|
||||||
CR
|
CR
|
||||||
@@ -1628,10 +1795,12 @@
|
|||||||
[value]="row.amount ?? ''"
|
[value]="row.amount ?? ''"
|
||||||
(input)="row.amount = +$any($event.target).value; syncShareLinkedAmounts()"
|
(input)="row.amount = +$any($event.target).value; syncShareLinkedAmounts()"
|
||||||
(click)="$event.stopPropagation()"
|
(click)="$event.stopPropagation()"
|
||||||
[placeholder]="row.acTyp === 'SHR' ? '0' : '0.00'"
|
[placeholder]="isMultiDdRow(row) ? 'Per draft' : row.acTyp === 'SHR' ? '0' : '0.00'"
|
||||||
[readonly]="!isClerk && !!(activeWithdrawalRequest || activeClosureRequest)"
|
[readonly]="
|
||||||
|
isMultiDdRow(row) || (!isClerk && !!(activeWithdrawalRequest || activeClosureRequest))
|
||||||
|
"
|
||||||
[class.ws-amt-input--locked]="
|
[class.ws-amt-input--locked]="
|
||||||
!isClerk && !!(activeWithdrawalRequest || activeClosureRequest)
|
isMultiDdRow(row) || (!isClerk && !!(activeWithdrawalRequest || activeClosureRequest))
|
||||||
"
|
"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
@@ -1642,6 +1811,19 @@
|
|||||||
}}/share</span
|
}}/share</span
|
||||||
>
|
>
|
||||||
}
|
}
|
||||||
|
<!-- On a slip the face value belongs to each draft, so the row total is the
|
||||||
|
sum of them and is filled in by the dialog rather than typed here. A
|
||||||
|
clerk-typed figure could disagree with the drafts, and the account would
|
||||||
|
be debited for a total the drafts do not add up to. -->
|
||||||
|
@if (isMultiDdRow(row)) {
|
||||||
|
<span class="ws-f__hint">
|
||||||
|
{{
|
||||||
|
row.ddLines?.length
|
||||||
|
? 'Total of ' + row.ddLines?.length + ' drafts'
|
||||||
|
: 'Set per draft in Fill DD Details'
|
||||||
|
}}
|
||||||
|
</span>
|
||||||
|
}
|
||||||
</div>
|
</div>
|
||||||
@if (
|
@if (
|
||||||
row.acTyp === 'SHR' && (row.typ === 'Enter Bank Transfer' || row.typ === 'Fund Transfer')
|
row.acTyp === 'SHR' && (row.typ === 'Enter Bank Transfer' || row.typ === 'Fund Transfer')
|
||||||
@@ -1706,9 +1888,38 @@
|
|||||||
<div class="ws-group ws-group--flex">
|
<div class="ws-group ws-group--flex">
|
||||||
<span class="ws-caption">DD Charges & GST — Admin Rate {{ ddGstPercentage }}%</span>
|
<span class="ws-caption">DD Charges & GST — Admin Rate {{ ddGstPercentage }}%</span>
|
||||||
<div class="ws-group__fields">
|
<div class="ws-group__fields">
|
||||||
|
<!-- How many drafts this one debit funds. Typed before the details are filled
|
||||||
|
in, because it is what decides how many beneficiary forms the dialog
|
||||||
|
presents. Offered only where the server can honour it: a member
|
||||||
|
savings/current account debit, never a GL or cash draft. -->
|
||||||
|
@if (canRowBeMultiDd(row)) {
|
||||||
|
<div class="ws-f ws-f--amt">
|
||||||
|
<label
|
||||||
|
class="ws-f__lbl"
|
||||||
|
title="Number of demand drafts to raise against this single debit"
|
||||||
|
>NO. OF DD</label
|
||||||
|
>
|
||||||
|
<div class="ws-amt-wrap">
|
||||||
|
<input
|
||||||
|
class="ws-f__ctl ws-amt-input"
|
||||||
|
type="number"
|
||||||
|
min="1"
|
||||||
|
[max]="maxDdSlipLines"
|
||||||
|
step="1"
|
||||||
|
[value]="ddCountFor(row)"
|
||||||
|
(input)="onDdCountChange(row, +$any($event.target).value)"
|
||||||
|
(click)="$event.stopPropagation()"
|
||||||
|
placeholder="1"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
@if (isMultiDdRow(row)) {
|
||||||
|
<span class="ws-f__hint">One debit · {{ ddCountFor(row) }} drafts</span>
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
}
|
||||||
<div class="ws-f ws-f--amt">
|
<div class="ws-f ws-f--amt">
|
||||||
<label class="ws-f__lbl" title="DD issuance charges / commission">CHARGES</label>
|
<label class="ws-f__lbl" title="DD issuance charges / commission">CHARGES</label>
|
||||||
<div class="ws-amt-wrap">
|
<div class="ws-amt-wrap" [class.ws-amt-wrap--locked]="isMultiDdRow(row)">
|
||||||
<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"
|
||||||
@@ -1717,8 +1928,15 @@
|
|||||||
(input)="row.ddCharges = +$any($event.target).value"
|
(input)="row.ddCharges = +$any($event.target).value"
|
||||||
(click)="$event.stopPropagation()"
|
(click)="$event.stopPropagation()"
|
||||||
placeholder="0.00"
|
placeholder="0.00"
|
||||||
|
[readonly]="isMultiDdRow(row)"
|
||||||
|
[class.ws-amt-input--locked]="isMultiDdRow(row)"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
<!-- Charges are levied per draft and can differ between them, so on a slip
|
||||||
|
they are entered against each draft and totalled here. -->
|
||||||
|
@if (isMultiDdRow(row)) {
|
||||||
|
<span class="ws-f__hint">Set per draft</span>
|
||||||
|
}
|
||||||
</div>
|
</div>
|
||||||
<div class="ws-f ws-f--amt">
|
<div class="ws-f ws-f--amt">
|
||||||
<label class="ws-f__lbl" title="GST on charges, at the admin-configured rate">
|
<label class="ws-f__lbl" title="GST on charges, at the admin-configured rate">
|
||||||
@@ -1729,7 +1947,7 @@
|
|||||||
<input
|
<input
|
||||||
class="ws-f__ctl ws-amt-input ws-amt-input--locked"
|
class="ws-f__ctl ws-amt-input ws-amt-input--locked"
|
||||||
type="text"
|
type="text"
|
||||||
[value]="ddGstAmountFor(row) | number: '1.2-2'"
|
[value]="ddGstDisplayFor(row)"
|
||||||
readonly
|
readonly
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
@@ -1743,7 +1961,7 @@
|
|||||||
<input
|
<input
|
||||||
class="ws-f__ctl ws-amt-input ws-amt-input--locked"
|
class="ws-f__ctl ws-amt-input ws-amt-input--locked"
|
||||||
type="text"
|
type="text"
|
||||||
[value]="ddTotalPayableFor(row)"
|
[value]="ddTotalDisplayFor(row)"
|
||||||
readonly
|
readonly
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
@@ -1756,13 +1974,28 @@
|
|||||||
[disabled]="saving"
|
[disabled]="saving"
|
||||||
(click)="openIssueWalkinDd(row); $event.stopPropagation()"
|
(click)="openIssueWalkinDd(row); $event.stopPropagation()"
|
||||||
[title]="
|
[title]="
|
||||||
isDdAccountDebitRow(row)
|
isMultiDdRow(row)
|
||||||
|
? 'Opens one beneficiary form per draft; DD numbers are allocated on submission'
|
||||||
|
: isDdAccountDebitRow(row)
|
||||||
? 'Auto-selects next available leaflet and opens the beneficiary form'
|
? 'Auto-selects next available leaflet and opens the beneficiary form'
|
||||||
: 'Auto-selects next available leaflet and opens walk-in purchaser form'
|
: 'Auto-selects next available leaflet and opens walk-in purchaser form'
|
||||||
"
|
"
|
||||||
>
|
>
|
||||||
<mat-icon style="font-size: 18px; width: 18px; height: 18px">post_add</mat-icon>
|
<mat-icon style="font-size: 18px; width: 18px; height: 18px">{{
|
||||||
<span>Fill DD Details</span>
|
row.ddDetails || row.ddLines?.length ? 'edit_note' : 'post_add'
|
||||||
|
}}</mat-icon>
|
||||||
|
<!-- Names what has been captured, so the clerk can see at a glance which
|
||||||
|
rows are ready before pressing Save. A slip names its count instead:
|
||||||
|
its numbers are not allocated until it is submitted. -->
|
||||||
|
<span>{{
|
||||||
|
row.ddLines?.length
|
||||||
|
? row.ddLines?.length + ' Drafts Captured · Edit Slip'
|
||||||
|
: row.ddDetails
|
||||||
|
? 'DD ' + row.ddDetails.ddNumber + ' · Edit Details'
|
||||||
|
: isMultiDdRow(row)
|
||||||
|
? 'Fill ' + ddCountFor(row) + ' DD Details'
|
||||||
|
: 'Fill DD Details'
|
||||||
|
}}</span>
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -1851,7 +2084,13 @@
|
|||||||
<button class="ws-btn ws-btn--ghost" type="button" (click)="onSearch()">Search</button>
|
<button class="ws-btn ws-btn--ghost" type="button" (click)="onSearch()">Search</button>
|
||||||
<button class="ws-btn ws-btn--mode" type="button">Change Tr.Mode</button>
|
<button class="ws-btn ws-btn--mode" type="button">Change Tr.Mode</button>
|
||||||
@if (isClerk) {
|
@if (isClerk) {
|
||||||
<button class="ws-btn ws-btn--add" type="button" (click)="addRow()" title="Add another transaction row">
|
<button
|
||||||
|
class="ws-btn ws-btn--add"
|
||||||
|
type="button"
|
||||||
|
[disabled]="selectedRow?.typ === 'Withdrawal Slip'"
|
||||||
|
(click)="addRow()"
|
||||||
|
title="Add another transaction row"
|
||||||
|
>
|
||||||
<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5">
|
<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5">
|
||||||
<line x1="12" y1="5" x2="12" y2="19" />
|
<line x1="12" y1="5" x2="12" y2="19" />
|
||||||
<line x1="5" y1="12" x2="19" y2="12" />
|
<line x1="5" y1="12" x2="19" y2="12" />
|
||||||
|
|||||||
@@ -916,6 +916,20 @@ input.ws-f__ctl--readonly {
|
|||||||
font-size: 11px;
|
font-size: 11px;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* AC.TP shown as a locked label instead of the normal editable select, while the row is filled
|
||||||
|
from a token-driven request whose real account type row.acTyp cannot show (see
|
||||||
|
acTypLockedByToken in the component). */
|
||||||
|
span.ws-f__ctl--locked {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
background: #f1f5f9;
|
||||||
|
color: #475569;
|
||||||
|
cursor: not-allowed;
|
||||||
|
user-select: none;
|
||||||
|
font-size: 11px;
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
|
||||||
input.ws-amt-input--locked {
|
input.ws-amt-input--locked {
|
||||||
background: #fef9c3;
|
background: #fef9c3;
|
||||||
color: #713f12;
|
color: #713f12;
|
||||||
@@ -3448,3 +3462,39 @@ select.te-cell {
|
|||||||
box-shadow: none;
|
box-shadow: none;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* The branch an account is held at, coloured by where the signed-in user works: green for their own branch, red for any other. */
|
||||||
|
.te-branch-chip {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 6px;
|
||||||
|
padding: 2px 10px;
|
||||||
|
border: 1px solid transparent;
|
||||||
|
border-radius: 999px;
|
||||||
|
font-size: 12px;
|
||||||
|
font-weight: 700;
|
||||||
|
white-space: nowrap;
|
||||||
|
|
||||||
|
&--mine {
|
||||||
|
background: #dcfce7;
|
||||||
|
border-color: #4ade80;
|
||||||
|
color: #166534;
|
||||||
|
}
|
||||||
|
|
||||||
|
&--other {
|
||||||
|
background: #fca5a5;
|
||||||
|
border-color: #f87171;
|
||||||
|
color: #7f1d1d;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.te-branch-chip__tag {
|
||||||
|
padding: 0 6px;
|
||||||
|
border-radius: 8px;
|
||||||
|
background: rgb(255 255 255 / 80%);
|
||||||
|
color: #7f1d1d;
|
||||||
|
font-size: 10px;
|
||||||
|
font-weight: 800;
|
||||||
|
letter-spacing: 0.04em;
|
||||||
|
text-transform: uppercase;
|
||||||
|
}
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@@ -16,6 +16,7 @@ import { MatInputModule } from '@angular/material/input';
|
|||||||
import { of } from 'rxjs';
|
import { of } from 'rxjs';
|
||||||
import { catchError } from 'rxjs/operators';
|
import { catchError } from 'rxjs/operators';
|
||||||
import { CashierService } from '../cashier.service';
|
import { CashierService } from '../cashier.service';
|
||||||
|
import { businessToday } from 'app/core/utils/business-today';
|
||||||
|
|
||||||
interface DenomRow {
|
interface DenomRow {
|
||||||
denominationId: number;
|
denominationId: number;
|
||||||
@@ -67,7 +68,7 @@ export class VaultPositionComponent implements OnInit {
|
|||||||
errorMsg = '';
|
errorMsg = '';
|
||||||
|
|
||||||
ngOnInit(): void {
|
ngOnInit(): void {
|
||||||
const d = new Date();
|
const d = businessToday();
|
||||||
this.asOfDate = `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, '0')}-${String(d.getDate()).padStart(2, '0')}`;
|
this.asOfDate = `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, '0')}-${String(d.getDate()).padStart(2, '0')}`;
|
||||||
|
|
||||||
this.service
|
this.service
|
||||||
|
|||||||
+170
@@ -0,0 +1,170 @@
|
|||||||
|
<!--
|
||||||
|
Copyright since 2025 Mifos Initiative
|
||||||
|
|
||||||
|
This Source Code Form is subject to the terms of the Mozilla Public
|
||||||
|
License, v. 2.0. If a copy of the MPL was not distributed with this
|
||||||
|
file, You can obtain one at http://mozilla.org/MPL/2.0/.
|
||||||
|
-->
|
||||||
|
|
||||||
|
<div class="cv-tab-content vca-tab">
|
||||||
|
<!-- ── TITLE BAR ── -->
|
||||||
|
<div class="vca-action-bar">
|
||||||
|
<div class="vca-action-bar__left">
|
||||||
|
<mat-icon class="vca-action-bar__icon">undo</mat-icon>
|
||||||
|
<div>
|
||||||
|
<div class="vca-action-bar__title">Voucher Corrections</div>
|
||||||
|
<div class="vca-action-bar__sub">
|
||||||
|
Approve or reject a voucher unpass, and the correction entry that follows it. Only the correction approval
|
||||||
|
moves money.
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="vca-counts">
|
||||||
|
<span class="vca-count"
|
||||||
|
><strong>{{ unpassCount }}</strong> unpass</span
|
||||||
|
>
|
||||||
|
<span class="vca-count"
|
||||||
|
><strong>{{ correctionCount }}</strong> correction</span
|
||||||
|
>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- ── FILTER BAR ── -->
|
||||||
|
<div class="vca-filter-bar">
|
||||||
|
<div class="vca-search-box">
|
||||||
|
<mat-icon>search</mat-icon>
|
||||||
|
<input type="text" placeholder="Search by voucher, account, customer or user" [(ngModel)]="searchTerm" />
|
||||||
|
@if (searchTerm) {
|
||||||
|
<button type="button" class="vca-search-box__clear" (click)="searchTerm = ''">
|
||||||
|
<mat-icon>close</mat-icon>
|
||||||
|
</button>
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<span class="vca-fb-sep"></span>
|
||||||
|
|
||||||
|
<select class="vca-filter-select" [(ngModel)]="kindFilter">
|
||||||
|
@for (opt of kindOptions; track opt.value) {
|
||||||
|
<option [value]="opt.value">{{ opt.label }}</option>
|
||||||
|
}
|
||||||
|
</select>
|
||||||
|
|
||||||
|
<button type="button" class="vca-btn vca-btn--refresh" (click)="load()" [disabled]="loading">
|
||||||
|
<mat-icon>refresh</mat-icon>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- ── TABLE ── -->
|
||||||
|
@if (loading && !loaded) {
|
||||||
|
<div class="vca-empty"><p class="vca-empty__title">Loading…</p></div>
|
||||||
|
} @else if (rows.length === 0) {
|
||||||
|
<div class="vca-empty">
|
||||||
|
<mat-icon class="vca-empty__icon">inbox</mat-icon>
|
||||||
|
<p class="vca-empty__title">No voucher correction waiting</p>
|
||||||
|
<p class="vca-empty__text">There is no unpass request or correction entry waiting for review.</p>
|
||||||
|
</div>
|
||||||
|
} @else {
|
||||||
|
<div class="vca-table-wrap">
|
||||||
|
<table class="vca-table">
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th>Voucher No</th>
|
||||||
|
<th>Request</th>
|
||||||
|
<th>Posted To (Wrong)</th>
|
||||||
|
<th>Correct Account</th>
|
||||||
|
<th class="num">Amount</th>
|
||||||
|
<th>Reason</th>
|
||||||
|
<th>Raised By</th>
|
||||||
|
<th>Raised At</th>
|
||||||
|
<th class="vca-th-action">Action</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
@for (r of rows; track r.request.id) {
|
||||||
|
<tr class="vca-row">
|
||||||
|
<td class="vca-mono vca-voucher">{{ r.request.original_voucher_no }}</td>
|
||||||
|
<td>
|
||||||
|
<span class="vca-kind" [class]="'vca-kind vca-kind--' + r.kind">{{ kindLabel(r.kind) }}</span>
|
||||||
|
</td>
|
||||||
|
<td>
|
||||||
|
<div class="vca-mono">{{ r.request.wrong_account_no }}</div>
|
||||||
|
<div class="vca-sub">{{ r.request.wrong_client_name || '—' }}</div>
|
||||||
|
</td>
|
||||||
|
<td>
|
||||||
|
@if (r.kind === 'correction') {
|
||||||
|
<div class="vca-mono vca-strong">{{ r.request.correct_account_no }}</div>
|
||||||
|
<div class="vca-sub">{{ r.request.correct_client_name || '—' }}</div>
|
||||||
|
} @else {
|
||||||
|
<span class="vca-none">—</span>
|
||||||
|
}
|
||||||
|
</td>
|
||||||
|
<td class="num vca-amount">₹{{ r.request.amount | number: '1.2-2' }}</td>
|
||||||
|
<td class="vca-reason" [title]="r.request.reason || ''">{{ r.request.reason || '—' }}</td>
|
||||||
|
<td>{{ r.raisedBy || '—' }}</td>
|
||||||
|
<td class="vca-dt">{{ formatDateTime(r.raisedAt) }}</td>
|
||||||
|
<td class="vca-action-cell">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="vca-btn vca-btn--reject"
|
||||||
|
[disabled]="busyId === r.request.id"
|
||||||
|
(click)="startReject(r)"
|
||||||
|
>
|
||||||
|
<mat-icon>close</mat-icon> Reject
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="vca-btn vca-btn--approve"
|
||||||
|
[disabled]="busyId === r.request.id"
|
||||||
|
(click)="approve(r)"
|
||||||
|
>
|
||||||
|
<mat-icon>check</mat-icon>
|
||||||
|
{{ r.kind === 'unpass' ? 'Approve unpass' : 'Approve correction' }}
|
||||||
|
</button>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- ─── Popup: reject with a reason ───────────────────────────────────────── -->
|
||||||
|
<ng-template #rejectDialog>
|
||||||
|
@if (rejecting) {
|
||||||
|
<div class="vca-dlg">
|
||||||
|
<header class="vca-dlg__head">
|
||||||
|
<mat-icon class="vca-dlg__ico">block</mat-icon>
|
||||||
|
<div class="vca-dlg__titles">
|
||||||
|
<div class="vca-dlg__title">
|
||||||
|
Reject {{ rejecting.kind === 'unpass' ? 'unpass request' : 'correction entry' }}
|
||||||
|
</div>
|
||||||
|
<div class="vca-dlg__sub vca-mono">{{ rejecting.request.original_voucher_no }}</div>
|
||||||
|
</div>
|
||||||
|
<button type="button" class="vca-dlg__x" aria-label="Close" (click)="cancelReject()">
|
||||||
|
<mat-icon>close</mat-icon>
|
||||||
|
</button>
|
||||||
|
</header>
|
||||||
|
<div class="vca-dlg__body">
|
||||||
|
<label class="vca-label" for="vca-reason">Reason (required)</label>
|
||||||
|
<textarea
|
||||||
|
id="vca-reason"
|
||||||
|
rows="3"
|
||||||
|
placeholder="Why is this being rejected?"
|
||||||
|
[(ngModel)]="rejectReason"
|
||||||
|
></textarea>
|
||||||
|
</div>
|
||||||
|
<footer class="vca-dlg__foot">
|
||||||
|
<button type="button" class="vca-btn vca-btn--ghost" (click)="cancelReject()">Cancel</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="vca-btn vca-btn--reject-solid"
|
||||||
|
[disabled]="!rejectReason.trim() || busyId === rejecting.request.id"
|
||||||
|
(click)="confirmReject()"
|
||||||
|
>
|
||||||
|
Confirm reject
|
||||||
|
</button>
|
||||||
|
</footer>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
</ng-template>
|
||||||
+485
@@ -0,0 +1,485 @@
|
|||||||
|
/**
|
||||||
|
* Copyright since 2025 Mifos Initiative
|
||||||
|
*
|
||||||
|
* This Source Code Form is subject to the terms of the Mozilla Public
|
||||||
|
* License, v. 2.0. If a copy of the MPL was not distributed with this
|
||||||
|
* file, You can obtain one at http://mozilla.org/MPL/2.0/.
|
||||||
|
*/
|
||||||
|
|
||||||
|
// Laid out like the other approval tabs (Withdrawal Requests): title bar, filter bar, one table.
|
||||||
|
:host {
|
||||||
|
display: block;
|
||||||
|
}
|
||||||
|
|
||||||
|
.vca-tab {
|
||||||
|
padding: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.vca-action-bar {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: 16px;
|
||||||
|
padding: 10px 16px;
|
||||||
|
background: #fff;
|
||||||
|
border-bottom: 1px solid #e2e8f0;
|
||||||
|
|
||||||
|
&__left {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
&__icon {
|
||||||
|
font-size: 22px;
|
||||||
|
width: 22px;
|
||||||
|
height: 22px;
|
||||||
|
color: #0f4f3c;
|
||||||
|
}
|
||||||
|
|
||||||
|
&__title {
|
||||||
|
font-size: 13px;
|
||||||
|
font-weight: 700;
|
||||||
|
color: #0d1b2a;
|
||||||
|
}
|
||||||
|
|
||||||
|
&__sub {
|
||||||
|
max-width: 640px;
|
||||||
|
margin-top: 1px;
|
||||||
|
font-size: 11px;
|
||||||
|
color: #64748b;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.vca-counts {
|
||||||
|
display: flex;
|
||||||
|
gap: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.vca-count {
|
||||||
|
padding: 2px 10px;
|
||||||
|
border-radius: 999px;
|
||||||
|
background: #f1f5f9;
|
||||||
|
font-size: 11px;
|
||||||
|
color: #475569;
|
||||||
|
|
||||||
|
strong {
|
||||||
|
color: #0f4f3c;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.vca-filter-bar {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 6px;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
padding: 6px 16px;
|
||||||
|
background: #f8fafc;
|
||||||
|
border-bottom: 1px solid #e2e8f0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.vca-fb-sep {
|
||||||
|
flex-shrink: 0;
|
||||||
|
width: 1px;
|
||||||
|
height: 22px;
|
||||||
|
margin: 0 4px;
|
||||||
|
background: #e2e8f0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.vca-search-box {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 6px;
|
||||||
|
height: 30px;
|
||||||
|
min-width: 240px;
|
||||||
|
padding: 0 10px;
|
||||||
|
border: 1px solid #e2e8f0;
|
||||||
|
border-radius: 8px;
|
||||||
|
background: #fff;
|
||||||
|
|
||||||
|
mat-icon {
|
||||||
|
flex-shrink: 0;
|
||||||
|
font-size: 17px;
|
||||||
|
width: 17px;
|
||||||
|
height: 17px;
|
||||||
|
color: #94a3b8;
|
||||||
|
}
|
||||||
|
|
||||||
|
&:hover {
|
||||||
|
border-color: #cbd5e1;
|
||||||
|
}
|
||||||
|
|
||||||
|
&:focus-within {
|
||||||
|
border-color: #0f4f3c;
|
||||||
|
box-shadow: 0 0 0 3px rgb(15 79 60 / 8%);
|
||||||
|
}
|
||||||
|
|
||||||
|
input {
|
||||||
|
flex: 1;
|
||||||
|
border: none;
|
||||||
|
outline: none;
|
||||||
|
background: transparent;
|
||||||
|
font-family: inherit;
|
||||||
|
font-size: 12px;
|
||||||
|
color: #0d1b2a;
|
||||||
|
|
||||||
|
&::placeholder {
|
||||||
|
color: #94a3b8;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
&__clear {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
flex-shrink: 0;
|
||||||
|
width: 20px;
|
||||||
|
height: 20px;
|
||||||
|
padding: 0;
|
||||||
|
border: none;
|
||||||
|
border-radius: 50%;
|
||||||
|
background: none;
|
||||||
|
color: #94a3b8;
|
||||||
|
cursor: pointer;
|
||||||
|
|
||||||
|
mat-icon {
|
||||||
|
font-size: 15px;
|
||||||
|
width: 15px;
|
||||||
|
height: 15px;
|
||||||
|
color: inherit;
|
||||||
|
}
|
||||||
|
|
||||||
|
&:hover {
|
||||||
|
color: #dc2626;
|
||||||
|
background: #fef2f2;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.vca-filter-select {
|
||||||
|
height: 30px;
|
||||||
|
min-width: 140px;
|
||||||
|
padding: 0 10px;
|
||||||
|
border: 1px solid #e2e8f0;
|
||||||
|
border-radius: 7px;
|
||||||
|
background: #fff;
|
||||||
|
font-family: inherit;
|
||||||
|
font-size: 12px;
|
||||||
|
color: #0d1b2a;
|
||||||
|
cursor: pointer;
|
||||||
|
|
||||||
|
&:focus {
|
||||||
|
outline: none;
|
||||||
|
border-color: #0f4f3c;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Buttons ─────────────────────────────────────────────────────────────────
|
||||||
|
.vca-btn {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 4px;
|
||||||
|
height: 26px;
|
||||||
|
padding: 0 10px;
|
||||||
|
border: 1px solid transparent;
|
||||||
|
border-radius: 7px;
|
||||||
|
font-family: inherit;
|
||||||
|
font-size: 11px;
|
||||||
|
font-weight: 600;
|
||||||
|
white-space: nowrap;
|
||||||
|
cursor: pointer;
|
||||||
|
|
||||||
|
mat-icon {
|
||||||
|
font-size: 14px;
|
||||||
|
width: 14px;
|
||||||
|
height: 14px;
|
||||||
|
}
|
||||||
|
|
||||||
|
&:disabled {
|
||||||
|
opacity: 0.45;
|
||||||
|
cursor: not-allowed;
|
||||||
|
}
|
||||||
|
|
||||||
|
&--refresh {
|
||||||
|
height: 30px;
|
||||||
|
width: 30px;
|
||||||
|
justify-content: center;
|
||||||
|
padding: 0;
|
||||||
|
margin-left: auto;
|
||||||
|
border-color: #e2e8f0;
|
||||||
|
background: #fff;
|
||||||
|
color: #475569;
|
||||||
|
}
|
||||||
|
|
||||||
|
&--approve {
|
||||||
|
background: #0f4f3c;
|
||||||
|
color: #fff;
|
||||||
|
|
||||||
|
&:hover:not(:disabled) {
|
||||||
|
background: #0a3d2e;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
&--reject {
|
||||||
|
border-color: #fecaca;
|
||||||
|
background: #fff;
|
||||||
|
color: #dc2626;
|
||||||
|
|
||||||
|
&:hover:not(:disabled) {
|
||||||
|
background: #fef2f2;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
&--reject-solid {
|
||||||
|
background: #dc2626;
|
||||||
|
color: #fff;
|
||||||
|
}
|
||||||
|
|
||||||
|
&--ghost {
|
||||||
|
border-color: #e2e8f0;
|
||||||
|
background: #fff;
|
||||||
|
color: #475569;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Table ───────────────────────────────────────────────────────────────────
|
||||||
|
.vca-table-wrap {
|
||||||
|
overflow-x: auto;
|
||||||
|
background: #fff;
|
||||||
|
}
|
||||||
|
|
||||||
|
.vca-table {
|
||||||
|
width: 100%;
|
||||||
|
border-collapse: collapse;
|
||||||
|
font-size: 11px;
|
||||||
|
|
||||||
|
th {
|
||||||
|
padding: 7px 10px;
|
||||||
|
border-bottom: 1px solid #e2e8f0;
|
||||||
|
background: #f8fafc;
|
||||||
|
font-size: 9.5px;
|
||||||
|
font-weight: 800;
|
||||||
|
letter-spacing: 0.06em;
|
||||||
|
text-align: left;
|
||||||
|
text-transform: uppercase;
|
||||||
|
color: #94a3b8;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
td {
|
||||||
|
padding: 5px 10px;
|
||||||
|
border-bottom: 1px solid #eef2f7;
|
||||||
|
vertical-align: middle;
|
||||||
|
color: #0d1b2a;
|
||||||
|
}
|
||||||
|
|
||||||
|
.num {
|
||||||
|
text-align: right;
|
||||||
|
font-variant-numeric: tabular-nums;
|
||||||
|
}
|
||||||
|
|
||||||
|
.vca-th-action {
|
||||||
|
text-align: right;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.vca-row:hover td {
|
||||||
|
background: #f8fafc;
|
||||||
|
}
|
||||||
|
|
||||||
|
.vca-mono {
|
||||||
|
font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace;
|
||||||
|
letter-spacing: 0.02em;
|
||||||
|
}
|
||||||
|
|
||||||
|
.vca-voucher {
|
||||||
|
font-weight: 700;
|
||||||
|
color: #0f4f3c;
|
||||||
|
}
|
||||||
|
|
||||||
|
.vca-strong {
|
||||||
|
font-weight: 700;
|
||||||
|
}
|
||||||
|
|
||||||
|
.vca-sub {
|
||||||
|
margin-top: 1px;
|
||||||
|
font-size: 11px;
|
||||||
|
color: #64748b;
|
||||||
|
}
|
||||||
|
|
||||||
|
.vca-none {
|
||||||
|
color: #cbd5e1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.vca-amount {
|
||||||
|
font-weight: 700;
|
||||||
|
}
|
||||||
|
|
||||||
|
.vca-reason {
|
||||||
|
max-width: 220px;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.vca-dt {
|
||||||
|
white-space: nowrap;
|
||||||
|
color: #475569;
|
||||||
|
}
|
||||||
|
|
||||||
|
.vca-kind {
|
||||||
|
display: inline-block;
|
||||||
|
padding: 2px 10px;
|
||||||
|
border-radius: 999px;
|
||||||
|
font-size: 11px;
|
||||||
|
font-weight: 700;
|
||||||
|
|
||||||
|
&--unpass {
|
||||||
|
background: #fef3c7;
|
||||||
|
color: #92400e;
|
||||||
|
}
|
||||||
|
|
||||||
|
&--correction {
|
||||||
|
background: #dbeafe;
|
||||||
|
color: #1e40af;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.vca-action-cell {
|
||||||
|
display: flex;
|
||||||
|
justify-content: flex-end;
|
||||||
|
gap: 6px;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Empty ───────────────────────────────────────────────────────────────────
|
||||||
|
.vca-empty {
|
||||||
|
padding: 28px 16px;
|
||||||
|
text-align: center;
|
||||||
|
color: #94a3b8;
|
||||||
|
|
||||||
|
&__icon {
|
||||||
|
display: block;
|
||||||
|
margin: 0 auto 6px;
|
||||||
|
font-size: 30px;
|
||||||
|
width: 30px;
|
||||||
|
height: 30px;
|
||||||
|
opacity: 0.6;
|
||||||
|
}
|
||||||
|
|
||||||
|
&__title {
|
||||||
|
margin: 0;
|
||||||
|
font-size: 12px;
|
||||||
|
font-weight: 700;
|
||||||
|
color: #64748b;
|
||||||
|
}
|
||||||
|
|
||||||
|
&__text {
|
||||||
|
margin: 3px 0 0;
|
||||||
|
font-size: 11px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Reject popup ────────────────────────────────────────────────────────────
|
||||||
|
.vca-dlg {
|
||||||
|
font-size: 12px;
|
||||||
|
|
||||||
|
&__head {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 10px;
|
||||||
|
padding: 10px 14px;
|
||||||
|
border-bottom: 1px solid #e2e8f0;
|
||||||
|
background: #f8fafc;
|
||||||
|
}
|
||||||
|
|
||||||
|
&__ico {
|
||||||
|
flex: 0 0 32px;
|
||||||
|
height: 32px;
|
||||||
|
width: 32px;
|
||||||
|
border-radius: 9px;
|
||||||
|
background: #fef2f2;
|
||||||
|
color: #dc2626;
|
||||||
|
font-size: 18px;
|
||||||
|
line-height: 32px;
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
&__titles {
|
||||||
|
flex: 1;
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
&__title {
|
||||||
|
font-size: 14px;
|
||||||
|
font-weight: 700;
|
||||||
|
color: #0f172a;
|
||||||
|
}
|
||||||
|
|
||||||
|
&__sub {
|
||||||
|
font-size: 11px;
|
||||||
|
color: #64748b;
|
||||||
|
}
|
||||||
|
|
||||||
|
&__x {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
height: 28px;
|
||||||
|
width: 28px;
|
||||||
|
border: 0;
|
||||||
|
border-radius: 50%;
|
||||||
|
background: transparent;
|
||||||
|
color: #64748b;
|
||||||
|
cursor: pointer;
|
||||||
|
|
||||||
|
&:hover {
|
||||||
|
background: #e2e8f0;
|
||||||
|
}
|
||||||
|
|
||||||
|
mat-icon {
|
||||||
|
font-size: 18px;
|
||||||
|
height: 18px;
|
||||||
|
width: 18px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
&__body {
|
||||||
|
padding: 12px 14px;
|
||||||
|
|
||||||
|
textarea {
|
||||||
|
width: 100%;
|
||||||
|
padding: 7px 10px;
|
||||||
|
border: 1px solid #cbd5e1;
|
||||||
|
border-radius: 8px;
|
||||||
|
font: inherit;
|
||||||
|
font-size: 12px;
|
||||||
|
resize: vertical;
|
||||||
|
|
||||||
|
&:focus {
|
||||||
|
outline: 0;
|
||||||
|
border-color: #0f4f3c;
|
||||||
|
box-shadow: 0 0 0 3px rgb(15 79 60 / 8%);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
&__foot {
|
||||||
|
display: flex;
|
||||||
|
justify-content: flex-end;
|
||||||
|
gap: 8px;
|
||||||
|
padding: 10px 14px;
|
||||||
|
border-top: 1px solid #e2e8f0;
|
||||||
|
background: #f8fafc;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.vca-label {
|
||||||
|
display: block;
|
||||||
|
margin-bottom: 4px;
|
||||||
|
font-size: 10px;
|
||||||
|
font-weight: 700;
|
||||||
|
letter-spacing: 0.05em;
|
||||||
|
text-transform: uppercase;
|
||||||
|
color: #94a3b8;
|
||||||
|
}
|
||||||
+192
@@ -0,0 +1,192 @@
|
|||||||
|
/**
|
||||||
|
* Copyright since 2025 Mifos Initiative
|
||||||
|
*
|
||||||
|
* This Source Code Form is subject to the terms of the Mozilla Public
|
||||||
|
* License, v. 2.0. If a copy of the MPL was not distributed with this
|
||||||
|
* file, You can obtain one at http://mozilla.org/MPL/2.0/.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { Component, OnInit, TemplateRef, ViewChild, inject } from '@angular/core';
|
||||||
|
import { CommonModule } from '@angular/common';
|
||||||
|
import { FormsModule } from '@angular/forms';
|
||||||
|
import { MatDialog, MatDialogModule, MatDialogRef } from '@angular/material/dialog';
|
||||||
|
import { MatIconModule } from '@angular/material/icon';
|
||||||
|
import { MatSnackBar, MatSnackBarModule } from '@angular/material/snack-bar';
|
||||||
|
import { of } from 'rxjs';
|
||||||
|
import { catchError } from 'rxjs/operators';
|
||||||
|
import { VoucherCorrection, VoucherCorrectionService } from '../voucher-correction.service';
|
||||||
|
|
||||||
|
type Kind = 'unpass' | 'correction';
|
||||||
|
type KindFilter = 'ALL' | Kind;
|
||||||
|
|
||||||
|
/** One row of the approvals table: a request, and which of the two steps it is waiting at. */
|
||||||
|
interface ApprovalRow {
|
||||||
|
kind: Kind;
|
||||||
|
request: VoucherCorrection;
|
||||||
|
raisedBy: string | null;
|
||||||
|
raisedAt: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Manager side of the voucher correction, laid out like the other approval tabs: approves or rejects the unpass of a
|
||||||
|
* voucher, and later the correction entry the cashier made. Only the second approval moves money.
|
||||||
|
*/
|
||||||
|
@Component({
|
||||||
|
selector: 'mifosx-voucher-correction-approvals',
|
||||||
|
templateUrl: './voucher-correction-approvals.component.html',
|
||||||
|
styleUrls: ['./voucher-correction-approvals.component.scss'],
|
||||||
|
standalone: true,
|
||||||
|
imports: [
|
||||||
|
CommonModule,
|
||||||
|
FormsModule,
|
||||||
|
MatDialogModule,
|
||||||
|
MatIconModule,
|
||||||
|
MatSnackBarModule
|
||||||
|
]
|
||||||
|
})
|
||||||
|
export class VoucherCorrectionApprovalsComponent implements OnInit {
|
||||||
|
private service = inject(VoucherCorrectionService);
|
||||||
|
private snackBar = inject(MatSnackBar);
|
||||||
|
private dialog = inject(MatDialog);
|
||||||
|
|
||||||
|
@ViewChild('rejectDialog') rejectDialog!: TemplateRef<unknown>;
|
||||||
|
private dialogRef: MatDialogRef<unknown> | null = null;
|
||||||
|
|
||||||
|
all: ApprovalRow[] = [];
|
||||||
|
loading = false;
|
||||||
|
loaded = false;
|
||||||
|
busyId: number | null = null;
|
||||||
|
|
||||||
|
kindFilter: KindFilter = 'ALL';
|
||||||
|
searchTerm = '';
|
||||||
|
|
||||||
|
/** The row being rejected, and the reason typed for it. */
|
||||||
|
rejecting: ApprovalRow | null = null;
|
||||||
|
rejectReason = '';
|
||||||
|
|
||||||
|
readonly kindOptions: { value: KindFilter; label: string }[] = [
|
||||||
|
{ value: 'ALL', label: 'All requests' },
|
||||||
|
{ value: 'unpass', label: 'Unpass requests' },
|
||||||
|
{ value: 'correction', label: 'Correction entries' }
|
||||||
|
];
|
||||||
|
|
||||||
|
ngOnInit(): void {
|
||||||
|
this.load();
|
||||||
|
}
|
||||||
|
|
||||||
|
load(): void {
|
||||||
|
this.loading = true;
|
||||||
|
this.service
|
||||||
|
.list('UNPASS_PENDING,CORRECTION_PENDING')
|
||||||
|
.pipe(catchError(() => of([] as VoucherCorrection[])))
|
||||||
|
.subscribe((rows) => {
|
||||||
|
this.all = rows.map((r) =>
|
||||||
|
r.status === 'UNPASS_PENDING'
|
||||||
|
? { kind: 'unpass', request: r, raisedBy: r.unpass_by_name, raisedAt: r.unpass_at }
|
||||||
|
: { kind: 'correction', request: r, raisedBy: r.correction_by_name, raisedAt: r.correction_at }
|
||||||
|
);
|
||||||
|
this.loading = false;
|
||||||
|
this.loaded = true;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The table: the kind picked, narrowed by whatever is typed in the search box. */
|
||||||
|
get rows(): ApprovalRow[] {
|
||||||
|
const term = this.searchTerm.trim().toLowerCase();
|
||||||
|
return this.all.filter((r) => {
|
||||||
|
if (this.kindFilter !== 'ALL' && r.kind !== this.kindFilter) return false;
|
||||||
|
if (!term) return true;
|
||||||
|
const q = r.request;
|
||||||
|
return [
|
||||||
|
q.original_voucher_no,
|
||||||
|
q.wrong_account_no,
|
||||||
|
q.wrong_client_name,
|
||||||
|
q.correct_account_no,
|
||||||
|
q.correct_client_name,
|
||||||
|
r.raisedBy
|
||||||
|
].some((v) => (v ?? '').toLowerCase().includes(term));
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
get unpassCount(): number {
|
||||||
|
return this.all.filter((r) => r.kind === 'unpass').length;
|
||||||
|
}
|
||||||
|
|
||||||
|
get correctionCount(): number {
|
||||||
|
return this.all.filter((r) => r.kind === 'correction').length;
|
||||||
|
}
|
||||||
|
|
||||||
|
kindLabel(kind: Kind): string {
|
||||||
|
return kind === 'unpass' ? 'Unpass' : 'Correction';
|
||||||
|
}
|
||||||
|
|
||||||
|
formatDateTime(value: string | null): string {
|
||||||
|
if (!value) return '—';
|
||||||
|
const d = new Date(value);
|
||||||
|
return isNaN(d.getTime())
|
||||||
|
? value
|
||||||
|
: d.toLocaleString('en-IN', { day: '2-digit', month: 'short', hour: '2-digit', minute: '2-digit' });
|
||||||
|
}
|
||||||
|
|
||||||
|
approve(row: ApprovalRow): void {
|
||||||
|
if (this.busyId) return;
|
||||||
|
const id = row.request.id;
|
||||||
|
this.busyId = id;
|
||||||
|
const call = row.kind === 'unpass' ? this.service.approveUnpass(id) : this.service.approveCorrection(id);
|
||||||
|
call.subscribe({
|
||||||
|
next: (res: any) => {
|
||||||
|
this.busyId = null;
|
||||||
|
this.snackBar.open(
|
||||||
|
row.kind === 'unpass'
|
||||||
|
? `Voucher ${row.request.original_voucher_no} unpassed — the cashier can now enter the correct account`
|
||||||
|
: `Corrected. New voucher ${res?.newVoucherNo ?? ''}`,
|
||||||
|
'Close',
|
||||||
|
{ duration: 6000 }
|
||||||
|
);
|
||||||
|
this.load();
|
||||||
|
},
|
||||||
|
error: (err) => {
|
||||||
|
this.busyId = null;
|
||||||
|
this.fail(err, 'Could not approve');
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
startReject(row: ApprovalRow): void {
|
||||||
|
this.rejecting = row;
|
||||||
|
this.rejectReason = '';
|
||||||
|
this.dialogRef = this.dialog.open(this.rejectDialog, { width: '440px', maxWidth: '95vw', autoFocus: false });
|
||||||
|
this.dialogRef.afterClosed().subscribe(() => (this.rejecting = null));
|
||||||
|
}
|
||||||
|
|
||||||
|
cancelReject(): void {
|
||||||
|
this.dialogRef?.close();
|
||||||
|
}
|
||||||
|
|
||||||
|
confirmReject(): void {
|
||||||
|
if (!this.rejecting || !this.rejectReason.trim() || this.busyId) return;
|
||||||
|
const row = this.rejecting;
|
||||||
|
const id = row.request.id;
|
||||||
|
this.busyId = id;
|
||||||
|
const reason = this.rejectReason.trim();
|
||||||
|
const call =
|
||||||
|
row.kind === 'unpass' ? this.service.rejectUnpass(id, reason) : this.service.rejectCorrection(id, reason);
|
||||||
|
call.subscribe({
|
||||||
|
next: () => {
|
||||||
|
this.busyId = null;
|
||||||
|
this.dialogRef?.close();
|
||||||
|
this.snackBar.open('Rejected', 'Close', { duration: 4000 });
|
||||||
|
this.load();
|
||||||
|
},
|
||||||
|
error: (err) => {
|
||||||
|
this.busyId = null;
|
||||||
|
this.fail(err, 'Could not reject');
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
private fail(err: any, fallback: string): void {
|
||||||
|
const msg = err?.error?.errors?.[0]?.defaultUserMessage ?? err?.error?.defaultUserMessage ?? fallback;
|
||||||
|
this.snackBar.open(msg, 'Close', { duration: 8000 });
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,120 @@
|
|||||||
|
/**
|
||||||
|
* Copyright since 2025 Mifos Initiative
|
||||||
|
*
|
||||||
|
* This Source Code Form is subject to the terms of the Mozilla Public
|
||||||
|
* License, v. 2.0. If a copy of the MPL was not distributed with this
|
||||||
|
* file, You can obtain one at http://mozilla.org/MPL/2.0/.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { Injectable, inject } from '@angular/core';
|
||||||
|
import { HttpClient, HttpParams } from '@angular/common/http';
|
||||||
|
import { Observable } from 'rxjs';
|
||||||
|
|
||||||
|
/** One row of a voucher correction, as the server keeps it (snake_case columns). */
|
||||||
|
export interface VoucherCorrection {
|
||||||
|
id: number;
|
||||||
|
original_voucher_no: string;
|
||||||
|
original_txn_id: number;
|
||||||
|
wrong_savings_account_id: number;
|
||||||
|
wrong_account_no: string;
|
||||||
|
wrong_client_name: string | null;
|
||||||
|
amount: number;
|
||||||
|
txn_date: string | null;
|
||||||
|
status: 'UNPASS_PENDING' | 'UNPASSED' | 'UNPASS_REJECTED' | 'CORRECTION_PENDING' | 'COMPLETED' | 'CLOSED';
|
||||||
|
reason: string | null;
|
||||||
|
rejection_reason: string | null;
|
||||||
|
unpass_by_name: string | null;
|
||||||
|
unpass_at: string | null;
|
||||||
|
unpass_approved_by_name: string | null;
|
||||||
|
correct_account_no: string | null;
|
||||||
|
correct_client_name: string | null;
|
||||||
|
correction_by_name: string | null;
|
||||||
|
correction_at: string | null;
|
||||||
|
correction_approved_by_name: string | null;
|
||||||
|
new_voucher_no: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** A cash deposit voucher of today that can still be unpassed. */
|
||||||
|
export interface EligibleVoucher {
|
||||||
|
voucherNo: string;
|
||||||
|
amount: number;
|
||||||
|
createdAt: string;
|
||||||
|
cashier: string;
|
||||||
|
txnId: number;
|
||||||
|
savingsAccountId: number;
|
||||||
|
accountNo: string;
|
||||||
|
clientName: string | null;
|
||||||
|
paymentType: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** What the server knows about a savings account typed in as the correct one. */
|
||||||
|
export interface AccountLookup {
|
||||||
|
id: number;
|
||||||
|
accountNo: string;
|
||||||
|
clientId: number | null;
|
||||||
|
clientName: string | null;
|
||||||
|
balance: number;
|
||||||
|
currencyCode: string;
|
||||||
|
canReceive: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** HTTP service for the voucher correction workflow: unpass, approve, re-enter, approve. */
|
||||||
|
@Injectable({ providedIn: 'root' })
|
||||||
|
export class VoucherCorrectionService {
|
||||||
|
private http = inject(HttpClient);
|
||||||
|
private readonly base = '/vouchers/corrections';
|
||||||
|
|
||||||
|
eligible(search?: string): Observable<EligibleVoucher[]> {
|
||||||
|
let params = new HttpParams();
|
||||||
|
if (search) params = params.set('search', search);
|
||||||
|
return this.http.get<EligibleVoucher[]>(`${this.base}/eligible`, { params });
|
||||||
|
}
|
||||||
|
|
||||||
|
unpass(voucherNo: string, reason: string): Observable<any> {
|
||||||
|
return this.http.post(`${this.base}/unpass`, { voucherNo, reason });
|
||||||
|
}
|
||||||
|
|
||||||
|
list(status?: string, mine = false): Observable<VoucherCorrection[]> {
|
||||||
|
let params = new HttpParams().set('mine', String(mine));
|
||||||
|
if (status) params = params.set('status', status);
|
||||||
|
return this.http.get<VoucherCorrection[]>(this.base, { params });
|
||||||
|
}
|
||||||
|
|
||||||
|
get(id: number): Observable<VoucherCorrection> {
|
||||||
|
return this.http.get<VoucherCorrection>(`${this.base}/${id}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
accountLookup(accountNo: string): Observable<AccountLookup> {
|
||||||
|
return this.http.get<AccountLookup>(`${this.base}/account-lookup`, { params: { accountNo } });
|
||||||
|
}
|
||||||
|
|
||||||
|
submitCorrection(id: number, correctAccountNo: string): Observable<any> {
|
||||||
|
return this.http.post(`${this.base}/${id}/correction`, { correctAccountNo });
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The cashier gives up the correction, e.g. when the wrong account no longer holds the money. */
|
||||||
|
close(id: number, reason: string): Observable<any> {
|
||||||
|
return this.http.post(`${this.base}/${id}/close`, { reason });
|
||||||
|
}
|
||||||
|
|
||||||
|
approveUnpass(id: number): Observable<any> {
|
||||||
|
return this.http.post(`${this.base}/${id}/unpass/approve`, {});
|
||||||
|
}
|
||||||
|
|
||||||
|
rejectUnpass(id: number, reason: string): Observable<any> {
|
||||||
|
return this.http.post(`${this.base}/${id}/unpass/reject`, { reason });
|
||||||
|
}
|
||||||
|
|
||||||
|
approveCorrection(id: number): Observable<any> {
|
||||||
|
return this.http.post(`${this.base}/${id}/correction/approve`, {});
|
||||||
|
}
|
||||||
|
|
||||||
|
rejectCorrection(id: number, reason: string): Observable<any> {
|
||||||
|
return this.http.post(`${this.base}/${id}/correction/reject`, { reason });
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Ids of the transactions of this account that a completed correction removed. */
|
||||||
|
voidedTransactions(savingsAccountId: number): Observable<number[]> {
|
||||||
|
return this.http.get<number[]>(`${this.base}/voided-transactions`, { params: { savingsAccountId } });
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,446 @@
|
|||||||
|
<!--
|
||||||
|
Copyright since 2025 Mifos Initiative
|
||||||
|
|
||||||
|
This Source Code Form is subject to the terms of the Mozilla Public
|
||||||
|
License, v. 2.0. If a copy of the MPL was not distributed with this
|
||||||
|
file, You can obtain one at http://mozilla.org/MPL/2.0/.
|
||||||
|
-->
|
||||||
|
|
||||||
|
<div class="pg">
|
||||||
|
<header class="pg-head">
|
||||||
|
<div>
|
||||||
|
<div class="pg-eyebrow">CASHIER · VOUCHER CORRECTION</div>
|
||||||
|
<h1 class="pg-title">Voucher Correction</h1>
|
||||||
|
<p class="pg-sub">
|
||||||
|
Move a cash deposit that was posted to the wrong account. Nothing changes on any account until the final
|
||||||
|
approval.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<!-- The four steps, so it is always clear where a voucher is. -->
|
||||||
|
<ol class="vc-steps">
|
||||||
|
<li class="vc-step" [class.vc-step--on]="tab === 'today'">
|
||||||
|
<span class="vc-step__no">1</span>
|
||||||
|
<span class="vc-step__txt"><strong>Unpass</strong> the voucher</span>
|
||||||
|
</li>
|
||||||
|
<li class="vc-step">
|
||||||
|
<span class="vc-step__no">2</span>
|
||||||
|
<span class="vc-step__txt"><strong>Manager</strong> approves</span>
|
||||||
|
</li>
|
||||||
|
<li class="vc-step" [class.vc-step--on]="tab === 'unpassed'">
|
||||||
|
<span class="vc-step__no">3</span>
|
||||||
|
<span class="vc-step__txt">Enter the <strong>correct account</strong></span>
|
||||||
|
</li>
|
||||||
|
<li class="vc-step">
|
||||||
|
<span class="vc-step__no">4</span>
|
||||||
|
<span class="vc-step__txt"><strong>Manager</strong> approves, deposit moves</span>
|
||||||
|
</li>
|
||||||
|
</ol>
|
||||||
|
|
||||||
|
<nav class="vc-tabs">
|
||||||
|
@if (mayUnpass) {
|
||||||
|
<button type="button" class="vc-tab" [class.vc-tab--on]="tab === 'today'" (click)="setTab('today')">
|
||||||
|
<mat-icon>receipt_long</mat-icon> Today's Vouchers
|
||||||
|
</button>
|
||||||
|
}
|
||||||
|
@if (mayCorrect) {
|
||||||
|
<button type="button" class="vc-tab" [class.vc-tab--on]="tab === 'unpassed'" (click)="setTab('unpassed')">
|
||||||
|
<mat-icon>edit_note</mat-icon> Unpassed — Correct Entry
|
||||||
|
@if (unpassed.length) {
|
||||||
|
<span class="vc-count">{{ unpassed.length }}</span>
|
||||||
|
}
|
||||||
|
</button>
|
||||||
|
}
|
||||||
|
<button type="button" class="vc-tab" [class.vc-tab--on]="tab === 'mine'" (click)="setTab('mine')">
|
||||||
|
<mat-icon>history</mat-icon> My Requests
|
||||||
|
</button>
|
||||||
|
</nav>
|
||||||
|
|
||||||
|
<!-- ─── Today's vouchers ─────────────────────────────────────────────── -->
|
||||||
|
@if (tab === 'today') {
|
||||||
|
<div class="vc-toolbar">
|
||||||
|
<div class="vc-search">
|
||||||
|
<mat-icon>search</mat-icon>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
placeholder="Search by voucher, account or customer name"
|
||||||
|
[(ngModel)]="search"
|
||||||
|
(keyup.enter)="loadVouchers()"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<button type="button" class="pg-btn pg-btn--primary" (click)="loadVouchers()">
|
||||||
|
<mat-icon>search</mat-icon> Search
|
||||||
|
</button>
|
||||||
|
<button type="button" class="pg-btn pg-btn--ghost" (click)="search = ''; loadVouchers()">
|
||||||
|
<mat-icon>refresh</mat-icon> Refresh
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="pg-card">
|
||||||
|
<table class="pg-table vc-table">
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th>Voucher</th>
|
||||||
|
<th>Account</th>
|
||||||
|
<th>Customer</th>
|
||||||
|
<th>Mode</th>
|
||||||
|
<th>Cashier</th>
|
||||||
|
<th class="num">Amount</th>
|
||||||
|
<th class="vc-col-action"></th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
@for (v of vouchers; track v.voucherNo) {
|
||||||
|
<tr [class.vc-row--on]="selectedVoucher?.voucherNo === v.voucherNo" (click)="pick(v)">
|
||||||
|
<td>
|
||||||
|
<span class="pg-chip">{{ v.voucherNo }}</span>
|
||||||
|
</td>
|
||||||
|
<td class="vc-mono">{{ v.accountNo }}</td>
|
||||||
|
<td>{{ v.clientName || '—' }}</td>
|
||||||
|
<td>{{ v.paymentType }}</td>
|
||||||
|
<td>{{ v.cashier }}</td>
|
||||||
|
<td class="num">₹{{ v.amount | number: '1.2-2' }}</td>
|
||||||
|
<td class="vc-col-action">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="pg-btn pg-btn--ghost vc-btn-sm"
|
||||||
|
(click)="pick(v); $event.stopPropagation()"
|
||||||
|
>
|
||||||
|
<mat-icon>undo</mat-icon> Unpass
|
||||||
|
</button>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
}
|
||||||
|
@if (!vouchers.length) {
|
||||||
|
<tr>
|
||||||
|
<td colspan="7" class="vc-empty">
|
||||||
|
<mat-icon>{{ loadingVouchers ? 'hourglass_empty' : 'inbox' }}</mat-icon>
|
||||||
|
<div>{{ loadingVouchers ? 'Loading…' : 'No cash deposit voucher of today can be unpassed.' }}</div>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
|
||||||
|
<!-- ─── Unpassed: enter the correct account ──────────────────────────── -->
|
||||||
|
@if (tab === 'unpassed') {
|
||||||
|
<div class="pg-card">
|
||||||
|
<table class="pg-table vc-table">
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th>Voucher</th>
|
||||||
|
<th>Posted to</th>
|
||||||
|
<th>Customer</th>
|
||||||
|
<th>Reason</th>
|
||||||
|
<th class="num">Amount</th>
|
||||||
|
<th class="vc-col-action"></th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
@for (u of unpassed; track u.id) {
|
||||||
|
<tr [class.vc-row--on]="active?.id === u.id" (click)="open(u)">
|
||||||
|
<td>
|
||||||
|
<span class="pg-chip">{{ u.original_voucher_no }}</span>
|
||||||
|
</td>
|
||||||
|
<td class="vc-mono">{{ u.wrong_account_no }}</td>
|
||||||
|
<td>{{ u.wrong_client_name || '—' }}</td>
|
||||||
|
<td class="vc-clip">{{ u.reason }}</td>
|
||||||
|
<td class="num">₹{{ u.amount | number: '1.2-2' }}</td>
|
||||||
|
<td class="vc-col-action">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="pg-btn pg-btn--primary vc-btn-sm"
|
||||||
|
(click)="open(u); $event.stopPropagation()"
|
||||||
|
>
|
||||||
|
<mat-icon>edit</mat-icon> Correct
|
||||||
|
</button>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
}
|
||||||
|
@if (!unpassed.length) {
|
||||||
|
<tr>
|
||||||
|
<td colspan="6" class="vc-empty">
|
||||||
|
<mat-icon>{{ loadingUnpassed ? 'hourglass_empty' : 'task_alt' }}</mat-icon>
|
||||||
|
<div>
|
||||||
|
{{ loadingUnpassed ? 'Loading…' : 'No unpassed voucher is waiting for its correct account.' }}
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
|
||||||
|
<!-- ─── My requests ──────────────────────────────────────────────────── -->
|
||||||
|
@if (tab === 'mine') {
|
||||||
|
<div class="pg-card">
|
||||||
|
<table class="pg-table vc-table">
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th>Voucher</th>
|
||||||
|
<th>Posted to</th>
|
||||||
|
<th>Corrected to</th>
|
||||||
|
<th class="num">Amount</th>
|
||||||
|
<th>Status</th>
|
||||||
|
<th>New voucher</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
@for (m of mine; track m.id) {
|
||||||
|
<tr>
|
||||||
|
<td>
|
||||||
|
<span class="pg-chip">{{ m.original_voucher_no }}</span>
|
||||||
|
</td>
|
||||||
|
<td class="vc-mono">{{ m.wrong_account_no }}</td>
|
||||||
|
<td class="vc-mono">{{ m.correct_account_no || '—' }}</td>
|
||||||
|
<td class="num">₹{{ m.amount | number: '1.2-2' }}</td>
|
||||||
|
<td>
|
||||||
|
<span class="vc-status vc-status--{{ m.status }}">{{ statusLabel[m.status] }}</span>
|
||||||
|
@if (m.rejection_reason) {
|
||||||
|
<div class="vc-note">{{ m.rejection_reason }}</div>
|
||||||
|
}
|
||||||
|
</td>
|
||||||
|
<td>{{ m.new_voucher_no || '—' }}</td>
|
||||||
|
</tr>
|
||||||
|
}
|
||||||
|
@if (!mine.length) {
|
||||||
|
<tr>
|
||||||
|
<td colspan="6" class="vc-empty">
|
||||||
|
<mat-icon>{{ loadingMine ? 'hourglass_empty' : 'history' }}</mat-icon>
|
||||||
|
<div>{{ loadingMine ? 'Loading…' : 'You have no voucher corrections yet.' }}</div>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- ─── Popup: unpass a voucher ──────────────────────────────────────────── -->
|
||||||
|
<ng-template #unpassDialog>
|
||||||
|
@if (selectedVoucher) {
|
||||||
|
<div class="vc-dlg">
|
||||||
|
<header class="vc-dlg__head">
|
||||||
|
<mat-icon class="vc-dlg__ico">undo</mat-icon>
|
||||||
|
<div class="vc-dlg__titles">
|
||||||
|
<div class="vc-dlg__title">Unpass voucher</div>
|
||||||
|
<div class="vc-dlg__sub vc-mono">{{ selectedVoucher.voucherNo }}</div>
|
||||||
|
</div>
|
||||||
|
<button type="button" class="vc-dlg__x" aria-label="Close" (click)="closeUnpass()">
|
||||||
|
<mat-icon>close</mat-icon>
|
||||||
|
</button>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<div class="vc-dlg__body">
|
||||||
|
<div class="vc-dlg__amount">
|
||||||
|
<span>Deposit amount</span>
|
||||||
|
<strong>₹{{ selectedVoucher.amount | number: '1.2-2' }}</strong>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="vc-grid">
|
||||||
|
<div class="vc-cell">
|
||||||
|
<div class="vc-label">Voucher</div>
|
||||||
|
<div class="vc-value vc-mono">{{ selectedVoucher.voucherNo }}</div>
|
||||||
|
</div>
|
||||||
|
<div class="vc-cell">
|
||||||
|
<div class="vc-label">Created</div>
|
||||||
|
<div class="vc-value">{{ selectedVoucher.createdAt | date: 'dd MMM yyyy, hh:mm a' }}</div>
|
||||||
|
</div>
|
||||||
|
<div class="vc-cell">
|
||||||
|
<div class="vc-label">Cashier</div>
|
||||||
|
<div class="vc-value">{{ selectedVoucher.cashier }}</div>
|
||||||
|
</div>
|
||||||
|
<div class="vc-cell">
|
||||||
|
<div class="vc-label">Mode</div>
|
||||||
|
<div class="vc-value">{{ selectedVoucher.paymentType }}</div>
|
||||||
|
</div>
|
||||||
|
<div class="vc-cell">
|
||||||
|
<div class="vc-label">Account</div>
|
||||||
|
<div class="vc-value vc-mono">{{ selectedVoucher.accountNo }}</div>
|
||||||
|
</div>
|
||||||
|
<div class="vc-cell">
|
||||||
|
<div class="vc-label">Customer</div>
|
||||||
|
<div class="vc-value">{{ selectedVoucher.clientName || '—' }}</div>
|
||||||
|
</div>
|
||||||
|
<div class="vc-cell">
|
||||||
|
<div class="vc-label">Account balance now</div>
|
||||||
|
<div class="vc-value">
|
||||||
|
@if (voucherAccount) {
|
||||||
|
₹{{ voucherAccount.balance | number: '1.2-2' }}
|
||||||
|
} @else {
|
||||||
|
—
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="vc-cell">
|
||||||
|
<div class="vc-label">Account status</div>
|
||||||
|
<div class="vc-value">
|
||||||
|
@if (voucherAccount) {
|
||||||
|
{{ voucherAccount.canReceive ? 'Active' : 'Not active' }} · {{ voucherAccount.currencyCode }}
|
||||||
|
} @else {
|
||||||
|
—
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<label class="vc-label" for="vc-reason">Reason for unpassing</label>
|
||||||
|
<textarea
|
||||||
|
id="vc-reason"
|
||||||
|
class="vc-reason"
|
||||||
|
rows="2"
|
||||||
|
placeholder="Why is this voucher wrong? e.g. deposited in the wrong account (required)"
|
||||||
|
[(ngModel)]="unpassReason"
|
||||||
|
></textarea>
|
||||||
|
|
||||||
|
<div class="vc-info">
|
||||||
|
<mat-icon>info</mat-icon>
|
||||||
|
A manager approves the unpass first. Nothing changes on any account until the corrected entry is approved: the
|
||||||
|
deposit stays on {{ selectedVoucher.accountNo }} till then.
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<footer class="vc-dlg__foot">
|
||||||
|
<button type="button" class="pg-btn pg-btn--ghost" (click)="closeUnpass()">Cancel</button>
|
||||||
|
<button type="button" class="pg-btn pg-btn--primary" [disabled]="!canUnpass" (click)="unpass()">
|
||||||
|
<mat-icon>send</mat-icon> Send for approval
|
||||||
|
</button>
|
||||||
|
</footer>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
</ng-template>
|
||||||
|
|
||||||
|
<!-- ─── Popup: enter the correct account ─────────────────────────────────── -->
|
||||||
|
<ng-template #correctionDialog>
|
||||||
|
@if (active) {
|
||||||
|
<div class="vc-dlg">
|
||||||
|
<header class="vc-dlg__head">
|
||||||
|
<mat-icon class="vc-dlg__ico">edit_note</mat-icon>
|
||||||
|
<div class="vc-dlg__titles">
|
||||||
|
<div class="vc-dlg__title">Correction entry</div>
|
||||||
|
<div class="vc-dlg__sub vc-mono">{{ active.original_voucher_no }}</div>
|
||||||
|
</div>
|
||||||
|
<button type="button" class="vc-dlg__x" aria-label="Close" (click)="closeActive()">
|
||||||
|
<mat-icon>close</mat-icon>
|
||||||
|
</button>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<div class="vc-dlg__body">
|
||||||
|
<div class="vc-dlg__amount">
|
||||||
|
<span>Amount to move</span>
|
||||||
|
<strong>₹{{ active.amount | number: '1.2-2' }}</strong>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Everything but the account number is fixed. -->
|
||||||
|
<div class="vc-grid">
|
||||||
|
<div class="vc-cell">
|
||||||
|
<div class="vc-label">Transaction date</div>
|
||||||
|
<div class="vc-value">{{ active.txn_date || '—' }}</div>
|
||||||
|
</div>
|
||||||
|
<div class="vc-cell">
|
||||||
|
<div class="vc-label">Mode</div>
|
||||||
|
<div class="vc-value">Cash</div>
|
||||||
|
</div>
|
||||||
|
<div class="vc-cell">
|
||||||
|
<div class="vc-label">Unpassed by</div>
|
||||||
|
<div class="vc-value">{{ active.unpass_by_name || '—' }}</div>
|
||||||
|
</div>
|
||||||
|
<div class="vc-cell">
|
||||||
|
<div class="vc-label">Approved by</div>
|
||||||
|
<div class="vc-value">{{ active.unpass_approved_by_name || '—' }}</div>
|
||||||
|
</div>
|
||||||
|
<div class="vc-cell vc-cell--wide">
|
||||||
|
<div class="vc-label">Reason</div>
|
||||||
|
<div class="vc-value">{{ active.reason }}</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="vc-compare">
|
||||||
|
<!-- The account the deposit went to -->
|
||||||
|
<div class="vc-acct vc-acct--wrong">
|
||||||
|
<div class="vc-acct__tag">Posted to (wrong)</div>
|
||||||
|
<div class="vc-acct__no vc-mono">{{ active.wrong_account_no }}</div>
|
||||||
|
<div class="vc-acct__name">{{ active.wrong_client_name || '—' }}</div>
|
||||||
|
<div class="vc-acct__bal">
|
||||||
|
Balance:
|
||||||
|
@if (wrongAccount) {
|
||||||
|
<strong>₹{{ wrongAccount.balance | number: '1.2-2' }}</strong>
|
||||||
|
@if (wrongHasBalance) {
|
||||||
|
<span class="vc-ok"><mat-icon>check_circle</mat-icon> covers the deposit</span>
|
||||||
|
} @else {
|
||||||
|
<span class="vc-bad"
|
||||||
|
><mat-icon>cancel</mat-icon> does not cover ₹{{ active.amount | number: '1.2-2' }}</span
|
||||||
|
>
|
||||||
|
}
|
||||||
|
} @else {
|
||||||
|
—
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<mat-icon class="vc-arrow">arrow_forward</mat-icon>
|
||||||
|
|
||||||
|
<!-- The account it should have gone to -->
|
||||||
|
<div class="vc-acct" [class.vc-acct--ok]="correctOk" [class.vc-acct--wrong]="!!lookupError">
|
||||||
|
<div class="vc-acct__tag">Correct account</div>
|
||||||
|
<input
|
||||||
|
id="vc-correct"
|
||||||
|
class="vc-acct__input vc-mono"
|
||||||
|
type="text"
|
||||||
|
placeholder="Type the account number"
|
||||||
|
[ngModel]="correctAccountNo"
|
||||||
|
(ngModelChange)="onAccountInput($event)"
|
||||||
|
(blur)="lookup()"
|
||||||
|
/>
|
||||||
|
@if (lookingUp) {
|
||||||
|
<div class="vc-acct__bal">Looking up…</div>
|
||||||
|
}
|
||||||
|
@if (correctAccount) {
|
||||||
|
<div class="vc-acct__name">{{ correctAccount.clientName || '—' }}</div>
|
||||||
|
<div class="vc-acct__bal">
|
||||||
|
Balance: <strong>₹{{ correctAccount.balance | number: '1.2-2' }}</strong>
|
||||||
|
@if (correctOk) {
|
||||||
|
<span class="vc-ok"><mat-icon>check_circle</mat-icon> active, can receive</span>
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
@if (lookupError) {
|
||||||
|
<div class="vc-bad vc-acct__err"><mat-icon>error</mat-icon> {{ lookupError }}</div>
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
@if (wrongAccount && !wrongHasBalance) {
|
||||||
|
<div class="vc-warn">
|
||||||
|
<mat-icon>warning</mat-icon>
|
||||||
|
<div>
|
||||||
|
<strong>The customer has already used this money,</strong> so it cannot be taken back from
|
||||||
|
{{ active.wrong_account_no }}. Deposit ₹{{ active.amount | number: '1.2-2' }} into the correct account
|
||||||
|
from your own pocket as a fresh deposit (Transaction Entry), then press <strong>Close request</strong>.
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<footer class="vc-dlg__foot">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="pg-btn pg-btn--ghost vc-dlg__close-req"
|
||||||
|
[disabled]="sending"
|
||||||
|
(click)="closeRequest()"
|
||||||
|
>
|
||||||
|
<mat-icon>block</mat-icon> Close request
|
||||||
|
</button>
|
||||||
|
<button type="button" class="pg-btn pg-btn--ghost" (click)="closeActive()">Cancel</button>
|
||||||
|
<button type="button" class="pg-btn pg-btn--primary" [disabled]="!canSave" (click)="save()">
|
||||||
|
<mat-icon>check</mat-icon> Save & send for approval
|
||||||
|
</button>
|
||||||
|
</footer>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
</ng-template>
|
||||||
@@ -0,0 +1,599 @@
|
|||||||
|
/**
|
||||||
|
* Copyright since 2025 Mifos Initiative
|
||||||
|
*
|
||||||
|
* This Source Code Form is subject to the terms of the Mozilla Public
|
||||||
|
* License, v. 2.0. If a copy of the MPL was not distributed with this
|
||||||
|
* file, You can obtain one at http://mozilla.org/MPL/2.0/.
|
||||||
|
*/
|
||||||
|
|
||||||
|
// Compact sizing: the page has to fit a 100% browser window without scrolling for the common case.
|
||||||
|
.pg {
|
||||||
|
padding: 14px 20px;
|
||||||
|
gap: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.pg-title {
|
||||||
|
font-size: 20px;
|
||||||
|
margin: 2px 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.pg-sub {
|
||||||
|
font-size: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.pg-btn {
|
||||||
|
height: 30px;
|
||||||
|
padding: 0 12px;
|
||||||
|
font-size: 12px;
|
||||||
|
|
||||||
|
mat-icon {
|
||||||
|
font-size: 16px;
|
||||||
|
height: 16px;
|
||||||
|
width: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
&:disabled {
|
||||||
|
opacity: 0.45;
|
||||||
|
cursor: not-allowed;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.pg-table {
|
||||||
|
font-size: 12px;
|
||||||
|
|
||||||
|
th {
|
||||||
|
padding: 8px 12px;
|
||||||
|
font-size: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
td {
|
||||||
|
padding: 7px 12px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Steps ───────────────────────────────────────────────────────────────────
|
||||||
|
.vc-steps {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(auto-fit, minmax(170px, 1fr));
|
||||||
|
gap: 8px;
|
||||||
|
margin: 0;
|
||||||
|
padding: 0;
|
||||||
|
list-style: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.vc-step {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
padding: 6px 10px;
|
||||||
|
border: 1px solid #e2e8f0;
|
||||||
|
border-radius: 10px;
|
||||||
|
background: #fff;
|
||||||
|
font-size: 12px;
|
||||||
|
color: #64748b;
|
||||||
|
|
||||||
|
&__no {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
flex: 0 0 20px;
|
||||||
|
height: 20px;
|
||||||
|
border-radius: 50%;
|
||||||
|
background: #e2e8f0;
|
||||||
|
font-size: 11px;
|
||||||
|
font-weight: 700;
|
||||||
|
color: #475569;
|
||||||
|
}
|
||||||
|
|
||||||
|
&--on {
|
||||||
|
border-color: #1f7a4d;
|
||||||
|
background: #f0fdf4;
|
||||||
|
color: #0f4f3c;
|
||||||
|
|
||||||
|
.vc-step__no {
|
||||||
|
background: #1f7a4d;
|
||||||
|
color: #fff;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Tabs ────────────────────────────────────────────────────────────────────
|
||||||
|
.vc-tabs {
|
||||||
|
display: flex;
|
||||||
|
gap: 2px;
|
||||||
|
border-bottom: 1px solid #e2e8f0;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.vc-tab {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 5px;
|
||||||
|
padding: 7px 12px;
|
||||||
|
margin-bottom: -1px;
|
||||||
|
border: 0;
|
||||||
|
border-bottom: 2px solid transparent;
|
||||||
|
background: transparent;
|
||||||
|
font: inherit;
|
||||||
|
font-size: 12px;
|
||||||
|
font-weight: 600;
|
||||||
|
color: #64748b;
|
||||||
|
cursor: pointer;
|
||||||
|
|
||||||
|
mat-icon {
|
||||||
|
font-size: 16px;
|
||||||
|
height: 16px;
|
||||||
|
width: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
&:hover {
|
||||||
|
color: #0f4f3c;
|
||||||
|
}
|
||||||
|
|
||||||
|
&--on {
|
||||||
|
color: #0f4f3c;
|
||||||
|
border-bottom-color: #1f7a4d;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.vc-count {
|
||||||
|
padding: 0 7px;
|
||||||
|
border-radius: 999px;
|
||||||
|
background: #f59e0b;
|
||||||
|
color: #fff;
|
||||||
|
font-size: 10px;
|
||||||
|
font-weight: 700;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Search ──────────────────────────────────────────────────────────────────
|
||||||
|
.vc-toolbar {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.vc-search {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 6px;
|
||||||
|
flex: 1 1 280px;
|
||||||
|
max-width: 420px;
|
||||||
|
height: 30px;
|
||||||
|
padding: 0 10px;
|
||||||
|
border: 1px solid #cbd5e1;
|
||||||
|
border-radius: 999px;
|
||||||
|
background: #fff;
|
||||||
|
|
||||||
|
&:focus-within {
|
||||||
|
border-color: #1f7a4d;
|
||||||
|
box-shadow: 0 0 0 3px rgb(31 122 77 / 12%);
|
||||||
|
}
|
||||||
|
|
||||||
|
mat-icon {
|
||||||
|
font-size: 16px;
|
||||||
|
height: 16px;
|
||||||
|
width: 16px;
|
||||||
|
color: #94a3b8;
|
||||||
|
}
|
||||||
|
|
||||||
|
input {
|
||||||
|
flex: 1;
|
||||||
|
min-width: 0;
|
||||||
|
border: 0;
|
||||||
|
outline: 0;
|
||||||
|
background: transparent;
|
||||||
|
font: inherit;
|
||||||
|
font-size: 12px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Table ───────────────────────────────────────────────────────────────────
|
||||||
|
.vc-table {
|
||||||
|
tbody tr {
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
tr.vc-row--on td {
|
||||||
|
background: #f0fdf4;
|
||||||
|
|
||||||
|
&:first-child {
|
||||||
|
box-shadow: inset 3px 0 0 #1f7a4d;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.vc-col-action {
|
||||||
|
width: 1%;
|
||||||
|
text-align: right;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.vc-clip {
|
||||||
|
max-width: 240px;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.pg-chip {
|
||||||
|
font-size: 11px;
|
||||||
|
padding: 2px 8px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.vc-btn-sm {
|
||||||
|
height: 26px;
|
||||||
|
padding: 0 10px;
|
||||||
|
font-size: 11px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.vc-mono {
|
||||||
|
font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace;
|
||||||
|
letter-spacing: 0.02em;
|
||||||
|
}
|
||||||
|
|
||||||
|
.vc-empty {
|
||||||
|
padding: 24px 12px;
|
||||||
|
text-align: center;
|
||||||
|
color: #94a3b8;
|
||||||
|
font-size: 12px;
|
||||||
|
|
||||||
|
mat-icon {
|
||||||
|
display: block;
|
||||||
|
margin: 0 auto 4px;
|
||||||
|
font-size: 28px;
|
||||||
|
height: 28px;
|
||||||
|
width: 28px;
|
||||||
|
opacity: 0.6;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Popups ──────────────────────────────────────────────────────────────────
|
||||||
|
.vc-dlg {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
max-height: 86vh;
|
||||||
|
font-size: 12px;
|
||||||
|
|
||||||
|
&__head {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 10px;
|
||||||
|
padding: 10px 14px;
|
||||||
|
border-bottom: 1px solid #e2e8f0;
|
||||||
|
background: #f8fafc;
|
||||||
|
}
|
||||||
|
|
||||||
|
&__ico {
|
||||||
|
flex: 0 0 32px;
|
||||||
|
height: 32px;
|
||||||
|
width: 32px;
|
||||||
|
border-radius: 9px;
|
||||||
|
background: #f0fdf4;
|
||||||
|
color: #1f7a4d;
|
||||||
|
font-size: 18px;
|
||||||
|
line-height: 32px;
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
&__titles {
|
||||||
|
flex: 1;
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
&__title {
|
||||||
|
font-size: 14px;
|
||||||
|
font-weight: 700;
|
||||||
|
color: #0f172a;
|
||||||
|
}
|
||||||
|
|
||||||
|
&__sub {
|
||||||
|
font-size: 11px;
|
||||||
|
color: #64748b;
|
||||||
|
}
|
||||||
|
|
||||||
|
&__x {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
height: 28px;
|
||||||
|
width: 28px;
|
||||||
|
border: 0;
|
||||||
|
border-radius: 50%;
|
||||||
|
background: transparent;
|
||||||
|
color: #64748b;
|
||||||
|
cursor: pointer;
|
||||||
|
|
||||||
|
mat-icon {
|
||||||
|
font-size: 18px;
|
||||||
|
height: 18px;
|
||||||
|
width: 18px;
|
||||||
|
}
|
||||||
|
|
||||||
|
&:hover {
|
||||||
|
background: #e2e8f0;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
&__body {
|
||||||
|
padding: 12px 14px;
|
||||||
|
overflow-y: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
&__amount {
|
||||||
|
display: flex;
|
||||||
|
align-items: baseline;
|
||||||
|
justify-content: space-between;
|
||||||
|
margin-bottom: 10px;
|
||||||
|
padding: 8px 12px;
|
||||||
|
border-radius: 10px;
|
||||||
|
background: #ecfdf5;
|
||||||
|
border: 1px solid #a7f3d0;
|
||||||
|
color: #065f46;
|
||||||
|
font-size: 12px;
|
||||||
|
font-weight: 600;
|
||||||
|
|
||||||
|
strong {
|
||||||
|
font-size: 18px;
|
||||||
|
font-weight: 800;
|
||||||
|
font-variant-numeric: tabular-nums;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
&__foot {
|
||||||
|
display: flex;
|
||||||
|
justify-content: flex-end;
|
||||||
|
gap: 8px;
|
||||||
|
padding: 10px 14px;
|
||||||
|
border-top: 1px solid #e2e8f0;
|
||||||
|
background: #f8fafc;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.vc-warn {
|
||||||
|
display: flex;
|
||||||
|
align-items: flex-start;
|
||||||
|
gap: 8px;
|
||||||
|
margin-top: 10px;
|
||||||
|
padding: 8px 12px;
|
||||||
|
border-radius: 8px;
|
||||||
|
background: #fffbeb;
|
||||||
|
border: 1px solid #fde68a;
|
||||||
|
color: #92400e;
|
||||||
|
font-size: 11px;
|
||||||
|
|
||||||
|
mat-icon {
|
||||||
|
flex: 0 0 18px;
|
||||||
|
height: 18px;
|
||||||
|
width: 18px;
|
||||||
|
font-size: 18px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.vc-dlg__close-req {
|
||||||
|
margin-right: auto;
|
||||||
|
color: #b45309;
|
||||||
|
}
|
||||||
|
|
||||||
|
.vc-label {
|
||||||
|
display: block;
|
||||||
|
margin-bottom: 3px;
|
||||||
|
font-size: 10px;
|
||||||
|
font-weight: 700;
|
||||||
|
letter-spacing: 0.05em;
|
||||||
|
text-transform: uppercase;
|
||||||
|
color: #94a3b8;
|
||||||
|
}
|
||||||
|
|
||||||
|
.vc-reason {
|
||||||
|
width: 100%;
|
||||||
|
padding: 7px 10px;
|
||||||
|
border: 1px solid #cbd5e1;
|
||||||
|
border-radius: 8px;
|
||||||
|
font: inherit;
|
||||||
|
font-size: 12px;
|
||||||
|
resize: vertical;
|
||||||
|
|
||||||
|
&:focus {
|
||||||
|
outline: 0;
|
||||||
|
border-color: #1f7a4d;
|
||||||
|
box-shadow: 0 0 0 3px rgb(31 122 77 / 12%);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.vc-info {
|
||||||
|
display: flex;
|
||||||
|
align-items: flex-start;
|
||||||
|
gap: 6px;
|
||||||
|
margin-top: 8px;
|
||||||
|
padding: 7px 10px;
|
||||||
|
border-radius: 8px;
|
||||||
|
background: #eff6ff;
|
||||||
|
color: #1e40af;
|
||||||
|
font-size: 11px;
|
||||||
|
|
||||||
|
mat-icon {
|
||||||
|
flex: 0 0 16px;
|
||||||
|
height: 16px;
|
||||||
|
width: 16px;
|
||||||
|
font-size: 16px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.vc-grid {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(auto-fit, minmax(130px, 1fr));
|
||||||
|
gap: 6px;
|
||||||
|
margin-bottom: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.vc-cell {
|
||||||
|
padding: 6px 10px;
|
||||||
|
border-radius: 8px;
|
||||||
|
background: #f8fafc;
|
||||||
|
border: 1px solid #eef2f7;
|
||||||
|
}
|
||||||
|
|
||||||
|
.vc-cell--wide {
|
||||||
|
grid-column: 1 / -1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.vc-value {
|
||||||
|
font-size: 12px;
|
||||||
|
font-weight: 600;
|
||||||
|
color: #0f172a;
|
||||||
|
word-break: break-word;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Wrong → Correct ─────────────────────────────────────────────────────────
|
||||||
|
.vc-compare {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: 1fr auto 1fr;
|
||||||
|
align-items: stretch;
|
||||||
|
gap: 8px;
|
||||||
|
|
||||||
|
@media (width <= 640px) {
|
||||||
|
grid-template-columns: 1fr;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.vc-arrow {
|
||||||
|
align-self: center;
|
||||||
|
color: #94a3b8;
|
||||||
|
|
||||||
|
@media (width <= 640px) {
|
||||||
|
transform: rotate(90deg);
|
||||||
|
justify-self: center;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.vc-acct {
|
||||||
|
padding: 10px 12px;
|
||||||
|
border: 1px solid #e2e8f0;
|
||||||
|
border-radius: 10px;
|
||||||
|
background: #fff;
|
||||||
|
|
||||||
|
&--wrong {
|
||||||
|
border-color: #fecaca;
|
||||||
|
background: #fef2f2;
|
||||||
|
}
|
||||||
|
|
||||||
|
&--ok {
|
||||||
|
border-color: #a7f3d0;
|
||||||
|
background: #ecfdf5;
|
||||||
|
}
|
||||||
|
|
||||||
|
&__tag {
|
||||||
|
margin-bottom: 4px;
|
||||||
|
font-size: 10px;
|
||||||
|
font-weight: 700;
|
||||||
|
letter-spacing: 0.05em;
|
||||||
|
text-transform: uppercase;
|
||||||
|
color: #64748b;
|
||||||
|
}
|
||||||
|
|
||||||
|
&__no {
|
||||||
|
font-size: 14px;
|
||||||
|
font-weight: 700;
|
||||||
|
color: #0f172a;
|
||||||
|
}
|
||||||
|
|
||||||
|
&__input {
|
||||||
|
width: 100%;
|
||||||
|
height: 32px;
|
||||||
|
padding: 0 10px;
|
||||||
|
border: 1px solid #cbd5e1;
|
||||||
|
border-radius: 8px;
|
||||||
|
background: #fff;
|
||||||
|
font-size: 13px;
|
||||||
|
font-weight: 600;
|
||||||
|
|
||||||
|
&:focus {
|
||||||
|
outline: 0;
|
||||||
|
border-color: #1f7a4d;
|
||||||
|
box-shadow: 0 0 0 3px rgb(31 122 77 / 12%);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
&__name {
|
||||||
|
margin-top: 4px;
|
||||||
|
font-size: 12px;
|
||||||
|
font-weight: 600;
|
||||||
|
color: #1e293b;
|
||||||
|
}
|
||||||
|
|
||||||
|
&__bal {
|
||||||
|
margin-top: 3px;
|
||||||
|
font-size: 11px;
|
||||||
|
color: #475569;
|
||||||
|
}
|
||||||
|
|
||||||
|
&__err {
|
||||||
|
margin-top: 6px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.vc-ok,
|
||||||
|
.vc-bad {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 3px;
|
||||||
|
margin-left: 6px;
|
||||||
|
font-weight: 700;
|
||||||
|
|
||||||
|
mat-icon {
|
||||||
|
font-size: 14px;
|
||||||
|
height: 14px;
|
||||||
|
width: 14px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.vc-ok {
|
||||||
|
color: #059669;
|
||||||
|
}
|
||||||
|
|
||||||
|
.vc-bad {
|
||||||
|
color: #dc2626;
|
||||||
|
}
|
||||||
|
|
||||||
|
.vc-note {
|
||||||
|
margin-top: 3px;
|
||||||
|
font-size: 11px;
|
||||||
|
color: #64748b;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Status ──────────────────────────────────────────────────────────────────
|
||||||
|
.vc-status {
|
||||||
|
display: inline-block;
|
||||||
|
padding: 2px 8px;
|
||||||
|
border-radius: 999px;
|
||||||
|
background: #e2e8f0;
|
||||||
|
font-size: 11px;
|
||||||
|
font-weight: 700;
|
||||||
|
|
||||||
|
&--UNPASS_PENDING,
|
||||||
|
&--CORRECTION_PENDING {
|
||||||
|
background: #fef3c7;
|
||||||
|
color: #92400e;
|
||||||
|
}
|
||||||
|
|
||||||
|
&--UNPASSED {
|
||||||
|
background: #dbeafe;
|
||||||
|
color: #1e40af;
|
||||||
|
}
|
||||||
|
|
||||||
|
&--COMPLETED {
|
||||||
|
background: #d1fae5;
|
||||||
|
color: #065f46;
|
||||||
|
}
|
||||||
|
|
||||||
|
&--CLOSED {
|
||||||
|
background: #f1f5f9;
|
||||||
|
color: #475569;
|
||||||
|
}
|
||||||
|
|
||||||
|
&--UNPASS_REJECTED {
|
||||||
|
background: #fee2e2;
|
||||||
|
color: #991b1b;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,307 @@
|
|||||||
|
/**
|
||||||
|
* Copyright since 2025 Mifos Initiative
|
||||||
|
*
|
||||||
|
* This Source Code Form is subject to the terms of the Mozilla Public
|
||||||
|
* License, v. 2.0. If a copy of the MPL was not distributed with this
|
||||||
|
* file, You can obtain one at http://mozilla.org/MPL/2.0/.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { Component, OnInit, TemplateRef, ViewChild, inject } from '@angular/core';
|
||||||
|
import { CommonModule } from '@angular/common';
|
||||||
|
import { FormsModule } from '@angular/forms';
|
||||||
|
import { MatDialog, MatDialogModule, MatDialogRef } from '@angular/material/dialog';
|
||||||
|
import { MatIconModule } from '@angular/material/icon';
|
||||||
|
import { MatSnackBar, MatSnackBarModule } from '@angular/material/snack-bar';
|
||||||
|
import { of } from 'rxjs';
|
||||||
|
import { catchError } from 'rxjs/operators';
|
||||||
|
import { AuthenticationService } from '../../core/authentication/authentication.service';
|
||||||
|
import {
|
||||||
|
AccountLookup,
|
||||||
|
EligibleVoucher,
|
||||||
|
VoucherCorrection,
|
||||||
|
VoucherCorrectionService
|
||||||
|
} from '../voucher-correction.service';
|
||||||
|
|
||||||
|
type Tab = 'today' | 'unpassed' | 'mine';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Cashier side of correcting a cash deposit that went to the wrong account.
|
||||||
|
*
|
||||||
|
* Today vouchers: pick the voucher and unpass it (needs a manager approval; nothing moves).
|
||||||
|
* Unpassed: open the approved voucher, whose details are fixed, change only the account number and save
|
||||||
|
* (needs a manager approval again; only then do the accounts change).
|
||||||
|
*/
|
||||||
|
@Component({
|
||||||
|
selector: 'mifosx-voucher-correction',
|
||||||
|
templateUrl: './voucher-correction.component.html',
|
||||||
|
styleUrls: [
|
||||||
|
'../cashier-shared.scss',
|
||||||
|
'./voucher-correction.component.scss'
|
||||||
|
],
|
||||||
|
standalone: true,
|
||||||
|
imports: [
|
||||||
|
CommonModule,
|
||||||
|
FormsModule,
|
||||||
|
MatIconModule,
|
||||||
|
MatDialogModule,
|
||||||
|
MatSnackBarModule
|
||||||
|
]
|
||||||
|
})
|
||||||
|
export class VoucherCorrectionComponent implements OnInit {
|
||||||
|
private service = inject(VoucherCorrectionService);
|
||||||
|
private snackBar = inject(MatSnackBar);
|
||||||
|
private dialog = inject(MatDialog);
|
||||||
|
private auth = inject(AuthenticationService);
|
||||||
|
|
||||||
|
/** Unpassing is for the back office (an administrator or a clerk); re-entering the account is the cashier only. */
|
||||||
|
readonly mayUnpass = this.auth.isClerkUser() || !!this.auth.getCredentials()?.permissions?.includes('ALL_FUNCTIONS');
|
||||||
|
readonly mayCorrect = this.auth.isCashierUser();
|
||||||
|
|
||||||
|
@ViewChild('unpassDialog') unpassDialog!: TemplateRef<unknown>;
|
||||||
|
@ViewChild('correctionDialog') correctionDialog!: TemplateRef<unknown>;
|
||||||
|
private dialogRef: MatDialogRef<unknown> | null = null;
|
||||||
|
|
||||||
|
tab: Tab = this.mayUnpass ? 'today' : this.mayCorrect ? 'unpassed' : 'mine';
|
||||||
|
|
||||||
|
// Today vouchers
|
||||||
|
search = '';
|
||||||
|
vouchers: EligibleVoucher[] = [];
|
||||||
|
loadingVouchers = false;
|
||||||
|
selectedVoucher: EligibleVoucher | null = null;
|
||||||
|
/** The account the voucher was posted to, for the balance and status shown in the popup. */
|
||||||
|
voucherAccount: AccountLookup | null = null;
|
||||||
|
unpassReason = '';
|
||||||
|
sending = false;
|
||||||
|
|
||||||
|
// Unpassed vouchers waiting for the correct account
|
||||||
|
unpassed: VoucherCorrection[] = [];
|
||||||
|
loadingUnpassed = false;
|
||||||
|
active: VoucherCorrection | null = null;
|
||||||
|
wrongAccount: AccountLookup | null = null;
|
||||||
|
correctAccountNo = '';
|
||||||
|
correctAccount: AccountLookup | null = null;
|
||||||
|
lookupError = '';
|
||||||
|
lookingUp = false;
|
||||||
|
|
||||||
|
// The cashier own requests
|
||||||
|
mine: VoucherCorrection[] = [];
|
||||||
|
loadingMine = false;
|
||||||
|
|
||||||
|
readonly statusLabel: Record<string, string> = {
|
||||||
|
UNPASS_PENDING: 'Unpass awaiting approval',
|
||||||
|
UNPASSED: 'Unpassed — enter correct account',
|
||||||
|
UNPASS_REJECTED: 'Unpass rejected',
|
||||||
|
CORRECTION_PENDING: 'Correction awaiting approval',
|
||||||
|
COMPLETED: 'Corrected',
|
||||||
|
CLOSED: 'Closed — cashier deposits separately'
|
||||||
|
};
|
||||||
|
|
||||||
|
ngOnInit(): void {
|
||||||
|
if (this.mayUnpass) this.loadVouchers();
|
||||||
|
if (this.mayCorrect) this.loadUnpassed();
|
||||||
|
if (this.tab === 'mine') this.loadMine();
|
||||||
|
}
|
||||||
|
|
||||||
|
setTab(tab: Tab): void {
|
||||||
|
this.tab = tab;
|
||||||
|
if (tab === 'today') this.loadVouchers();
|
||||||
|
if (tab === 'unpassed') this.loadUnpassed();
|
||||||
|
if (tab === 'mine') this.loadMine();
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Today vouchers ─────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
loadVouchers(): void {
|
||||||
|
this.loadingVouchers = true;
|
||||||
|
this.service
|
||||||
|
.eligible(this.search.trim())
|
||||||
|
.pipe(catchError(() => of([] as EligibleVoucher[])))
|
||||||
|
.subscribe((rows) => {
|
||||||
|
this.vouchers = rows;
|
||||||
|
this.loadingVouchers = false;
|
||||||
|
if (this.selectedVoucher && !rows.some((r) => r.voucherNo === this.selectedVoucher?.voucherNo)) {
|
||||||
|
this.selectedVoucher = null;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
pick(v: EligibleVoucher): void {
|
||||||
|
this.selectedVoucher = v;
|
||||||
|
this.unpassReason = '';
|
||||||
|
this.voucherAccount = null;
|
||||||
|
this.service
|
||||||
|
.accountLookup(v.accountNo)
|
||||||
|
.pipe(catchError(() => of(null)))
|
||||||
|
.subscribe((a) => (this.voucherAccount = a));
|
||||||
|
this.showDialog(this.unpassDialog, () => (this.selectedVoucher = null));
|
||||||
|
}
|
||||||
|
|
||||||
|
closeUnpass(): void {
|
||||||
|
this.dialogRef?.close();
|
||||||
|
}
|
||||||
|
|
||||||
|
get canUnpass(): boolean {
|
||||||
|
return !!this.selectedVoucher && this.unpassReason.trim().length > 0 && !this.sending;
|
||||||
|
}
|
||||||
|
|
||||||
|
unpass(): void {
|
||||||
|
if (!this.selectedVoucher || !this.canUnpass) return;
|
||||||
|
this.sending = true;
|
||||||
|
this.service.unpass(this.selectedVoucher.voucherNo, this.unpassReason.trim()).subscribe({
|
||||||
|
next: () => {
|
||||||
|
this.sending = false;
|
||||||
|
this.snackBar.open(`Voucher ${this.selectedVoucher?.voucherNo} sent for unpass approval`, 'Close', {
|
||||||
|
duration: 4000
|
||||||
|
});
|
||||||
|
this.dialogRef?.close();
|
||||||
|
this.unpassReason = '';
|
||||||
|
this.loadVouchers();
|
||||||
|
},
|
||||||
|
error: (err) => {
|
||||||
|
this.sending = false;
|
||||||
|
this.fail(err, 'Could not unpass the voucher');
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Unpassed vouchers: enter the correct account ───────────────────────────
|
||||||
|
|
||||||
|
loadUnpassed(): void {
|
||||||
|
this.loadingUnpassed = true;
|
||||||
|
this.service
|
||||||
|
.list('UNPASSED')
|
||||||
|
.pipe(catchError(() => of([] as VoucherCorrection[])))
|
||||||
|
.subscribe((rows) => {
|
||||||
|
this.unpassed = rows;
|
||||||
|
this.loadingUnpassed = false;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
open(row: VoucherCorrection): void {
|
||||||
|
this.active = row;
|
||||||
|
this.correctAccountNo = '';
|
||||||
|
this.correctAccount = null;
|
||||||
|
this.lookupError = '';
|
||||||
|
this.wrongAccount = null;
|
||||||
|
// The account the deposit went to: its balance is what the "can this be taken back" check reads.
|
||||||
|
this.service
|
||||||
|
.accountLookup(row.wrong_account_no)
|
||||||
|
.pipe(catchError(() => of(null)))
|
||||||
|
.subscribe((a) => (this.wrongAccount = a));
|
||||||
|
this.showDialog(this.correctionDialog, () => (this.active = null));
|
||||||
|
}
|
||||||
|
|
||||||
|
closeActive(): void {
|
||||||
|
this.dialogRef?.close();
|
||||||
|
}
|
||||||
|
|
||||||
|
onAccountInput(value: string): void {
|
||||||
|
this.correctAccountNo = value;
|
||||||
|
this.correctAccount = null;
|
||||||
|
this.lookupError = '';
|
||||||
|
// Same rule as the Transaction Entry screen: searches on its own once six characters are typed.
|
||||||
|
if (value.trim().length >= 6) this.lookup();
|
||||||
|
}
|
||||||
|
|
||||||
|
lookup(): void {
|
||||||
|
const accountNo = this.correctAccountNo.trim();
|
||||||
|
if (!accountNo || this.lookingUp) return;
|
||||||
|
this.lookingUp = true;
|
||||||
|
this.service.accountLookup(accountNo).subscribe({
|
||||||
|
next: (a) => {
|
||||||
|
this.lookingUp = false;
|
||||||
|
// A late answer for a number no longer typed is dropped.
|
||||||
|
if (accountNo !== this.correctAccountNo.trim()) return;
|
||||||
|
this.correctAccount = a;
|
||||||
|
if (this.active && a.id === this.active.wrong_savings_account_id) {
|
||||||
|
this.lookupError = 'This is the account the deposit was already posted to.';
|
||||||
|
} else if (!a.canReceive) {
|
||||||
|
this.lookupError = 'This account is not active, so it cannot receive the deposit.';
|
||||||
|
}
|
||||||
|
},
|
||||||
|
error: (err) => {
|
||||||
|
this.lookingUp = false;
|
||||||
|
this.lookupError = err?.error?.errors?.[0]?.defaultUserMessage ?? 'Account not found.';
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The wrong account still holds the deposit, so it can be taken back without overdrawing it. */
|
||||||
|
get wrongHasBalance(): boolean {
|
||||||
|
return !!this.wrongAccount && !!this.active && this.wrongAccount.balance >= this.active.amount;
|
||||||
|
}
|
||||||
|
|
||||||
|
get correctOk(): boolean {
|
||||||
|
return !!this.correctAccount && this.correctAccount.canReceive && !this.lookupError;
|
||||||
|
}
|
||||||
|
|
||||||
|
get canSave(): boolean {
|
||||||
|
return !!this.active && this.correctOk && this.wrongHasBalance && !this.sending;
|
||||||
|
}
|
||||||
|
|
||||||
|
save(): void {
|
||||||
|
if (!this.active || !this.canSave) return;
|
||||||
|
this.sending = true;
|
||||||
|
this.service.submitCorrection(this.active.id, this.correctAccountNo.trim()).subscribe({
|
||||||
|
next: () => {
|
||||||
|
this.sending = false;
|
||||||
|
this.snackBar.open('Correction sent for approval', 'Close', { duration: 4000 });
|
||||||
|
this.dialogRef?.close();
|
||||||
|
this.loadUnpassed();
|
||||||
|
},
|
||||||
|
error: (err) => {
|
||||||
|
this.sending = false;
|
||||||
|
this.fail(err, 'Could not save the correction');
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Gives up this correction. The usual reason is that the wrong account no longer holds the money, so the cashier
|
||||||
|
* puts the amount into the right account from their own pocket as a fresh deposit instead.
|
||||||
|
*/
|
||||||
|
closeRequest(): void {
|
||||||
|
if (!this.active || this.sending) return;
|
||||||
|
const reason = this.wrongHasBalance
|
||||||
|
? 'Closed by the cashier'
|
||||||
|
: 'Wrong account no longer holds the amount; cashier deposits it into the correct account separately';
|
||||||
|
this.sending = true;
|
||||||
|
this.service.close(this.active.id, reason).subscribe({
|
||||||
|
next: () => {
|
||||||
|
this.sending = false;
|
||||||
|
this.snackBar.open('Request closed', 'Close', { duration: 4000 });
|
||||||
|
this.dialogRef?.close();
|
||||||
|
this.loadUnpassed();
|
||||||
|
},
|
||||||
|
error: (err) => {
|
||||||
|
this.sending = false;
|
||||||
|
this.fail(err, 'Could not close the request');
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── My requests ────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
loadMine(): void {
|
||||||
|
this.loadingMine = true;
|
||||||
|
this.service
|
||||||
|
.list(undefined, true)
|
||||||
|
.pipe(catchError(() => of([] as VoucherCorrection[])))
|
||||||
|
.subscribe((rows) => {
|
||||||
|
this.mine = rows;
|
||||||
|
this.loadingMine = false;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Opens one of the two popups; onClose runs however it is dismissed (button, backdrop or Esc). */
|
||||||
|
private showDialog(template: TemplateRef<unknown>, onClose: () => void): void {
|
||||||
|
this.dialogRef?.close();
|
||||||
|
this.dialogRef = this.dialog.open(template, { width: '600px', maxWidth: '95vw', autoFocus: false });
|
||||||
|
this.dialogRef.afterClosed().subscribe(onClose);
|
||||||
|
}
|
||||||
|
|
||||||
|
private fail(err: any, fallback: string): void {
|
||||||
|
const msg = err?.error?.errors?.[0]?.defaultUserMessage ?? err?.error?.defaultUserMessage ?? fallback;
|
||||||
|
this.snackBar.open(msg, 'Close', { duration: 6000 });
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -169,6 +169,7 @@
|
|||||||
<th>Debit</th>
|
<th>Debit</th>
|
||||||
<th>Credit</th>
|
<th>Credit</th>
|
||||||
<th class="num">Amount</th>
|
<th class="num">Amount</th>
|
||||||
|
<th>Correction</th>
|
||||||
<th>User</th>
|
<th>User</th>
|
||||||
<!-- Two dates, not one, on the system register.
|
<!-- Two dates, not one, on the system register.
|
||||||
A voucher's timestamp is the minute it was written; the posting behind it carries
|
A voucher's timestamp is the minute it was written; the posting behind it carries
|
||||||
@@ -217,6 +218,18 @@
|
|||||||
</td>
|
</td>
|
||||||
}
|
}
|
||||||
<td class="num">{{ row.amount | number: '1.2-2' }}</td>
|
<td class="num">{{ row.amount | number: '1.2-2' }}</td>
|
||||||
|
<!-- Where the voucher stands after a correction: unpassed, revoked, closed or corrected. -->
|
||||||
|
<td>
|
||||||
|
<mifosx-voucher-status
|
||||||
|
[status]="row.voucherStatus"
|
||||||
|
[revokedBy]="row.revokedByVoucherNo"
|
||||||
|
[replaces]="row.replacesVoucherNo"
|
||||||
|
[note]="row.correctionNote"
|
||||||
|
></mifosx-voucher-status>
|
||||||
|
@if (!row.voucherStatus || row.voucherStatus === 'ACTIVE') {
|
||||||
|
<span class="vr-active">—</span>
|
||||||
|
}
|
||||||
|
</td>
|
||||||
<td>{{ row.username || '—' }}</td>
|
<td>{{ row.username || '—' }}</td>
|
||||||
@if (systemOnly) {
|
@if (systemOnly) {
|
||||||
<td class="vr-ledgerdate" [class.vr-ledgerdate--prior]="row.postedAfterClose">
|
<td class="vr-ledgerdate" [class.vr-ledgerdate--prior]="row.postedAfterClose">
|
||||||
|
|||||||
@@ -92,3 +92,8 @@
|
|||||||
text-transform: uppercase;
|
text-transform: uppercase;
|
||||||
color: #7c3aed;
|
color: #7c3aed;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.vr-active {
|
||||||
|
font-size: 11px;
|
||||||
|
color: #94a3b8;
|
||||||
|
}
|
||||||
|
|||||||
@@ -13,6 +13,8 @@ import { ActivatedRoute } from '@angular/router';
|
|||||||
import { MatIconModule } from '@angular/material/icon';
|
import { MatIconModule } from '@angular/material/icon';
|
||||||
import { catchError, of } from 'rxjs';
|
import { catchError, of } from 'rxjs';
|
||||||
import { CashierService } from '../cashier.service';
|
import { CashierService } from '../cashier.service';
|
||||||
|
import { businessToday } from 'app/core/utils/business-today';
|
||||||
|
import { VoucherStatusComponent, VoucherStatus } from '../voucher-status/voucher-status.component';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Which side of the register a screen shows.
|
* Which side of the register a screen shows.
|
||||||
@@ -25,6 +27,11 @@ export type VoucherSide = 'CR' | 'DR' | 'ALL';
|
|||||||
/** One voucher as the register shows it: the number, the two sides, and who raised it. */
|
/** One voucher as the register shows it: the number, the two sides, and who raised it. */
|
||||||
interface VoucherRow {
|
interface VoucherRow {
|
||||||
voucherNo: string;
|
voucherNo: string;
|
||||||
|
/** Where the voucher stands after a correction: active, revoked, under correction, or corrected. */
|
||||||
|
voucherStatus?: VoucherStatus;
|
||||||
|
revokedByVoucherNo?: string | null;
|
||||||
|
replacesVoucherNo?: string | null;
|
||||||
|
correctionNote?: string | null;
|
||||||
voucherType: string | null;
|
voucherType: string | null;
|
||||||
amount: number | null;
|
amount: number | null;
|
||||||
createdAt: string | null;
|
createdAt: string | null;
|
||||||
@@ -81,7 +88,8 @@ interface VoucherReport {
|
|||||||
imports: [
|
imports: [
|
||||||
CommonModule,
|
CommonModule,
|
||||||
FormsModule,
|
FormsModule,
|
||||||
MatIconModule
|
MatIconModule,
|
||||||
|
VoucherStatusComponent
|
||||||
]
|
]
|
||||||
})
|
})
|
||||||
export class VoucherReportComponent implements OnInit {
|
export class VoucherReportComponent implements OnInit {
|
||||||
@@ -227,7 +235,7 @@ export class VoucherReportComponent implements OnInit {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private todayIso(): string {
|
private todayIso(): string {
|
||||||
const now = new Date();
|
const now = businessToday();
|
||||||
return `${now.getFullYear()}-${String(now.getMonth() + 1).padStart(2, '0')}-${String(now.getDate()).padStart(2, '0')}`;
|
return `${now.getFullYear()}-${String(now.getMonth() + 1).padStart(2, '0')}-${String(now.getDate()).padStart(2, '0')}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,137 @@
|
|||||||
|
/**
|
||||||
|
* Copyright since 2025 Mifos Initiative
|
||||||
|
*
|
||||||
|
* This Source Code Form is subject to the terms of the Mozilla Public
|
||||||
|
* License, v. 2.0. If a copy of the MPL was not distributed with this
|
||||||
|
* file, You can obtain one at http://mozilla.org/MPL/2.0/.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { Component, Input } from '@angular/core';
|
||||||
|
import { CommonModule } from '@angular/common';
|
||||||
|
|
||||||
|
/** Where a voucher stands after a voucher correction. */
|
||||||
|
export type VoucherStatus =
|
||||||
|
| 'ACTIVE'
|
||||||
|
| 'REVOKED'
|
||||||
|
| 'UNPASS_PENDING'
|
||||||
|
| 'UNPASSED'
|
||||||
|
| 'CORRECTION_PENDING'
|
||||||
|
| 'CORRECTED'
|
||||||
|
| 'CLOSED'
|
||||||
|
| null
|
||||||
|
| undefined;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A small badge saying whether a voucher was revoked, is under correction, or replaced another one.
|
||||||
|
* Nothing is shown for an ordinary (active) voucher, so the registers read as before.
|
||||||
|
*/
|
||||||
|
@Component({
|
||||||
|
selector: 'mifosx-voucher-status',
|
||||||
|
standalone: true,
|
||||||
|
imports: [CommonModule],
|
||||||
|
template: `
|
||||||
|
@if (label) {
|
||||||
|
<span class="vs" [class]="'vs vs--' + status" [title]="hint">{{ label }}</span>
|
||||||
|
@if (note) {
|
||||||
|
<div class="vs-note">{{ note }}</div>
|
||||||
|
}
|
||||||
|
}
|
||||||
|
`,
|
||||||
|
styles: [
|
||||||
|
`
|
||||||
|
.vs {
|
||||||
|
display: inline-block;
|
||||||
|
margin-left: 6px;
|
||||||
|
padding: 1px 8px;
|
||||||
|
border-radius: 999px;
|
||||||
|
background: #e2e8f0;
|
||||||
|
color: #334155;
|
||||||
|
font-size: 10px;
|
||||||
|
font-weight: 700;
|
||||||
|
white-space: nowrap;
|
||||||
|
vertical-align: middle;
|
||||||
|
}
|
||||||
|
|
||||||
|
.vs-note {
|
||||||
|
margin-top: 2px;
|
||||||
|
max-width: 280px;
|
||||||
|
font-size: 11px;
|
||||||
|
line-height: 1.3;
|
||||||
|
color: #64748b;
|
||||||
|
}
|
||||||
|
|
||||||
|
.vs--REVOKED {
|
||||||
|
background: #fee2e2;
|
||||||
|
color: #991b1b;
|
||||||
|
}
|
||||||
|
|
||||||
|
.vs--UNPASS_PENDING,
|
||||||
|
.vs--CORRECTION_PENDING {
|
||||||
|
background: #fef3c7;
|
||||||
|
color: #92400e;
|
||||||
|
}
|
||||||
|
|
||||||
|
.vs--UNPASSED {
|
||||||
|
background: #dbeafe;
|
||||||
|
color: #1e40af;
|
||||||
|
}
|
||||||
|
|
||||||
|
.vs--CLOSED {
|
||||||
|
background: #f1f5f9;
|
||||||
|
color: #475569;
|
||||||
|
}
|
||||||
|
|
||||||
|
.vs--CORRECTED {
|
||||||
|
background: #d1fae5;
|
||||||
|
color: #065f46;
|
||||||
|
}
|
||||||
|
`
|
||||||
|
]
|
||||||
|
})
|
||||||
|
export class VoucherStatusComponent {
|
||||||
|
@Input() status: VoucherStatus;
|
||||||
|
/** The voucher that replaced this one, when it is revoked. */
|
||||||
|
@Input() revokedBy: string | null | undefined;
|
||||||
|
/** The voucher this one replaced, when it is the corrected one. */
|
||||||
|
@Input() replaces: string | null | undefined;
|
||||||
|
/** Why: the reason the voucher was unpassed, or why the correction was closed. */
|
||||||
|
@Input() note: string | null | undefined;
|
||||||
|
|
||||||
|
get label(): string {
|
||||||
|
switch (this.status) {
|
||||||
|
case 'REVOKED':
|
||||||
|
return this.revokedBy ? `Revoked → ${this.revokedBy}` : 'Revoked';
|
||||||
|
case 'UNPASS_PENDING':
|
||||||
|
return 'Unpass pending';
|
||||||
|
case 'UNPASSED':
|
||||||
|
return 'Unpassed';
|
||||||
|
case 'CORRECTION_PENDING':
|
||||||
|
return 'Correction pending';
|
||||||
|
case 'CLOSED':
|
||||||
|
return 'Closed — cashier deposits separately';
|
||||||
|
case 'CORRECTED':
|
||||||
|
return this.replaces ? `Corrected (replaces ${this.replaces})` : 'Corrected';
|
||||||
|
default:
|
||||||
|
return '';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
get hint(): string {
|
||||||
|
switch (this.status) {
|
||||||
|
case 'REVOKED':
|
||||||
|
return 'This voucher was revoked: its deposit was moved to another account and a new voucher replaced it.';
|
||||||
|
case 'UNPASS_PENDING':
|
||||||
|
return 'A manager has not yet approved unpassing this voucher.';
|
||||||
|
case 'UNPASSED':
|
||||||
|
return 'Unpassed: waiting for the cashier to enter the correct account.';
|
||||||
|
case 'CORRECTION_PENDING':
|
||||||
|
return 'The correct account is entered; waiting for a manager to approve.';
|
||||||
|
case 'CLOSED':
|
||||||
|
return 'The correction was closed by the cashier; the voucher stays as it was.';
|
||||||
|
case 'CORRECTED':
|
||||||
|
return 'This voucher was raised by a correction and replaces the earlier one.';
|
||||||
|
default:
|
||||||
|
return '';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -19,6 +19,19 @@ import { ReportMuleComponent } from './report-mule/report-mule.component';
|
|||||||
import { ReportFraudTxnComponent } from './report-fraud-txn/report-fraud-txn.component';
|
import { ReportFraudTxnComponent } from './report-fraud-txn/report-fraud-txn.component';
|
||||||
import { FmrSubmissionComponent } from './fmr-submission/fmr-submission.component';
|
import { FmrSubmissionComponent } from './fmr-submission/fmr-submission.component';
|
||||||
import { CfrLookupComponent } from './cfr-lookup/cfr-lookup.component';
|
import { CfrLookupComponent } from './cfr-lookup/cfr-lookup.component';
|
||||||
|
import { I4cShellComponent } from './i4c/i4c-shell/i4c-shell.component';
|
||||||
|
import { I4cDashboardComponent } from './i4c/i4c-dashboard/i4c-dashboard.component';
|
||||||
|
import { I4cRequestsComponent } from './i4c/i4c-requests/i4c-requests.component';
|
||||||
|
import { I4cRequestDetailComponent } from './i4c/i4c-request-detail/i4c-request-detail.component';
|
||||||
|
import { I4cHoldsComponent } from './i4c/i4c-holds/i4c-holds.component';
|
||||||
|
import { I4cApprovalsComponent } from './i4c/i4c-approvals/i4c-approvals.component';
|
||||||
|
import { I4cOutboxComponent } from './i4c/i4c-outbox/i4c-outbox.component';
|
||||||
|
import { I4cBankMasterComponent } from './i4c/i4c-bank-master/i4c-bank-master.component';
|
||||||
|
import { I4cIntimationSettingsComponent } from './i4c/i4c-intimation-settings/i4c-intimation-settings.component';
|
||||||
|
import { I4cGrievancesComponent } from './i4c/i4c-grievances/i4c-grievances.component';
|
||||||
|
import { I4cRestorationsComponent } from './i4c/i4c-restorations/i4c-restorations.component';
|
||||||
|
import { I4cMrmReportComponent } from './i4c/i4c-mrm-report/i4c-mrm-report.component';
|
||||||
|
import { I4cRiskComponent } from './i4c/i4c-risk/i4c-risk.component';
|
||||||
|
|
||||||
const routes: Routes = [
|
const routes: Routes = [
|
||||||
Route.withShell([
|
Route.withShell([
|
||||||
@@ -76,6 +89,72 @@ const routes: Routes = [
|
|||||||
path: 'cfr-lookup',
|
path: 'cfr-lookup',
|
||||||
component: CfrLookupComponent,
|
component: CfrLookupComponent,
|
||||||
data: { title: 'CFR Lookup', breadcrumb: 'CFR Lookup' }
|
data: { title: 'CFR Lookup', breadcrumb: 'CFR Lookup' }
|
||||||
|
},
|
||||||
|
{
|
||||||
|
// Live I4C-MHA integration (NCRP-CFCFRMS API). Served by /v1/i4c/admin on the CBS; the demo mock
|
||||||
|
// interceptor does not match these paths.
|
||||||
|
path: 'i4c',
|
||||||
|
component: I4cShellComponent,
|
||||||
|
data: { title: 'I4C Live Integration', breadcrumb: 'I4C Live' },
|
||||||
|
children: [
|
||||||
|
{ path: '', redirectTo: 'dashboard', pathMatch: 'full' },
|
||||||
|
{
|
||||||
|
path: 'dashboard',
|
||||||
|
component: I4cDashboardComponent,
|
||||||
|
data: { title: 'I4C Dashboard', breadcrumb: 'Dashboard' }
|
||||||
|
},
|
||||||
|
{
|
||||||
|
path: 'requests',
|
||||||
|
component: I4cRequestsComponent,
|
||||||
|
data: { title: 'Complaints from I4C', breadcrumb: 'Complaints' }
|
||||||
|
},
|
||||||
|
{
|
||||||
|
path: 'requests/:id',
|
||||||
|
component: I4cRequestDetailComponent,
|
||||||
|
data: { title: 'I4C Complaint', breadcrumb: 'Complaint' }
|
||||||
|
},
|
||||||
|
{ path: 'holds', component: I4cHoldsComponent, data: { title: 'I4C Holds Register', breadcrumb: 'Holds' } },
|
||||||
|
{
|
||||||
|
path: 'approvals',
|
||||||
|
component: I4cApprovalsComponent,
|
||||||
|
data: { title: 'I4C Approvals', breadcrumb: 'Approvals' }
|
||||||
|
},
|
||||||
|
{
|
||||||
|
path: 'outbox',
|
||||||
|
component: I4cOutboxComponent,
|
||||||
|
data: { title: 'I4C Responses', breadcrumb: 'Responses' }
|
||||||
|
},
|
||||||
|
{
|
||||||
|
path: 'bank-master',
|
||||||
|
component: I4cBankMasterComponent,
|
||||||
|
data: { title: 'I4C Bank Code Master', breadcrumb: 'Bank Master' }
|
||||||
|
},
|
||||||
|
{
|
||||||
|
path: 'grievances',
|
||||||
|
component: I4cGrievancesComponent,
|
||||||
|
data: { title: 'I4C GRM Grievances', breadcrumb: 'GRM Grievances' }
|
||||||
|
},
|
||||||
|
{
|
||||||
|
path: 'restorations',
|
||||||
|
component: I4cRestorationsComponent,
|
||||||
|
data: { title: 'I4C MRM Restorations', breadcrumb: 'MRM Restorations' }
|
||||||
|
},
|
||||||
|
{
|
||||||
|
path: 'mrm-report',
|
||||||
|
component: I4cMrmReportComponent,
|
||||||
|
data: { title: 'I4C MRM Balance Report', breadcrumb: 'MRM Balance Report' }
|
||||||
|
},
|
||||||
|
{
|
||||||
|
path: 'risk',
|
||||||
|
component: I4cRiskComponent,
|
||||||
|
data: { title: 'I4C Risk Registers', breadcrumb: 'Risk Registers' }
|
||||||
|
},
|
||||||
|
{
|
||||||
|
path: 'intimation-settings',
|
||||||
|
component: I4cIntimationSettingsComponent,
|
||||||
|
data: { title: 'I4C Intimation Settings', breadcrumb: 'Intimation Settings' }
|
||||||
|
}
|
||||||
|
]
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -34,6 +34,17 @@
|
|||||||
<fa-icon icon="chart-line" class="cfc-tab__ic"></fa-icon>
|
<fa-icon icon="chart-line" class="cfc-tab__ic"></fa-icon>
|
||||||
<span>Dashboard</span>
|
<span>Dashboard</span>
|
||||||
</a>
|
</a>
|
||||||
|
<a
|
||||||
|
mat-tab-link
|
||||||
|
class="cfc-tab"
|
||||||
|
[routerLink]="['./i4c']"
|
||||||
|
routerLinkActive
|
||||||
|
#i4cLive="routerLinkActive"
|
||||||
|
[active]="i4cLive.isActive"
|
||||||
|
>
|
||||||
|
<fa-icon icon="satellite-dish" class="cfc-tab__ic"></fa-icon>
|
||||||
|
<span>I4C Live</span>
|
||||||
|
</a>
|
||||||
<a
|
<a
|
||||||
mat-tab-link
|
mat-tab-link
|
||||||
class="cfc-tab"
|
class="cfc-tab"
|
||||||
|
|||||||
@@ -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/.
|
||||||
|
*/
|
||||||
|
|
||||||
|
// Shared by the GRM grievance, MRM restoration and MRM report pages.
|
||||||
|
@mixin i4c-case-page {
|
||||||
|
.modal-title-row {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 10px;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.count-tag {
|
||||||
|
font-size: 11px;
|
||||||
|
font-weight: 700;
|
||||||
|
color: #64748b;
|
||||||
|
background: #f1f5f9;
|
||||||
|
padding: 2px 7px;
|
||||||
|
border-radius: 9999px;
|
||||||
|
border: 1px solid #e2e8f0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.search-input {
|
||||||
|
width: 260px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.footer-btn-close {
|
||||||
|
padding: 6px 14px;
|
||||||
|
border-radius: 8px;
|
||||||
|
background: #e2e8f0;
|
||||||
|
color: #334155;
|
||||||
|
border: none;
|
||||||
|
font-size: 11.5px;
|
||||||
|
font-weight: 600;
|
||||||
|
cursor: pointer;
|
||||||
|
|
||||||
|
&:hover {
|
||||||
|
background: #cbd5e1;
|
||||||
|
color: #0f172a;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.sub {
|
||||||
|
font-size: 11px;
|
||||||
|
color: #64748b;
|
||||||
|
}
|
||||||
|
|
||||||
|
.num {
|
||||||
|
text-align: right;
|
||||||
|
}
|
||||||
|
|
||||||
|
.wrap {
|
||||||
|
white-space: normal;
|
||||||
|
overflow-wrap: anywhere;
|
||||||
|
}
|
||||||
|
|
||||||
|
.step {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 10px;
|
||||||
|
|
||||||
|
&__title {
|
||||||
|
font-weight: 600;
|
||||||
|
font-size: 13px;
|
||||||
|
color: #0f172a;
|
||||||
|
}
|
||||||
|
|
||||||
|
&__row {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: 10px;
|
||||||
|
align-items: center;
|
||||||
|
|
||||||
|
mat-form-field,
|
||||||
|
mifosx-i4c-date-field {
|
||||||
|
width: 220px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
&__wide {
|
||||||
|
flex: 1 1 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
& + & {
|
||||||
|
margin-top: 14px;
|
||||||
|
padding-top: 14px;
|
||||||
|
border-top: 1px dashed #e2e8f0;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.flow {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: 4px;
|
||||||
|
align-items: center;
|
||||||
|
font-size: 11px;
|
||||||
|
|
||||||
|
&__stage {
|
||||||
|
padding: 3px 9px;
|
||||||
|
border-radius: 999px;
|
||||||
|
background: #f1f5f9;
|
||||||
|
color: #64748b;
|
||||||
|
font-weight: 600;
|
||||||
|
|
||||||
|
&--done {
|
||||||
|
background: #ecfdf5;
|
||||||
|
color: #047857;
|
||||||
|
}
|
||||||
|
|
||||||
|
&--now {
|
||||||
|
background: #2563eb;
|
||||||
|
color: #fff;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
&__arrow {
|
||||||
|
color: #cbd5e1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.deadline {
|
||||||
|
font-weight: 600;
|
||||||
|
|
||||||
|
&--late {
|
||||||
|
color: #dc2626;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.grid-2 {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||||
|
gap: 14px;
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (width <= 900px) {
|
||||||
|
.grid-2 {
|
||||||
|
grid-template-columns: minmax(0, 1fr);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,944 @@
|
|||||||
|
/**
|
||||||
|
* 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/.
|
||||||
|
*/
|
||||||
|
|
||||||
|
/* Modern design system for the I4C (NCRP-CFCFRMS) subsystem:
|
||||||
|
Glassmorphism, sleek modern cards, rich data tables, glowing status pills,
|
||||||
|
and animated popup window modals. */
|
||||||
|
|
||||||
|
@mixin i4c-skin {
|
||||||
|
.i4c-page {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 20px;
|
||||||
|
animation: i4c-fade-in 0.3s cubic-bezier(0.16, 1, 0.3, 1);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Modern Toolbar */
|
||||||
|
.i4c-toolbar {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
padding: 8px 14px;
|
||||||
|
background: #fff;
|
||||||
|
border: 1px solid #e2e8f0;
|
||||||
|
border-radius: 12px;
|
||||||
|
box-shadow: 0 2px 8px rgb(15 23 42 / 3%);
|
||||||
|
|
||||||
|
&__hint {
|
||||||
|
color: #64748b;
|
||||||
|
font-size: 11.5px;
|
||||||
|
font-weight: 500;
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 6px;
|
||||||
|
}
|
||||||
|
|
||||||
|
&__spacer {
|
||||||
|
flex: 1 1 auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
mat-form-field {
|
||||||
|
min-width: 180px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Extra filters and the date range, under the toolbar */
|
||||||
|
.i4c-filter-bar {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
align-items: center;
|
||||||
|
gap: 10px 14px;
|
||||||
|
padding: 10px 14px;
|
||||||
|
background: #fff;
|
||||||
|
border: 1px solid #e2e8f0;
|
||||||
|
border-radius: 12px;
|
||||||
|
|
||||||
|
mat-form-field {
|
||||||
|
width: 170px;
|
||||||
|
}
|
||||||
|
|
||||||
|
&__clear {
|
||||||
|
margin-left: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Inside a card, under its head
|
||||||
|
&--inset {
|
||||||
|
border: 0;
|
||||||
|
border-bottom: 1px solid #f1f5f9;
|
||||||
|
border-radius: 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Paginator at the foot of a list card */
|
||||||
|
.i4c-paginator {
|
||||||
|
border-top: 1px solid #f1f5f9;
|
||||||
|
background: transparent;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Filter Chip bar */
|
||||||
|
.i4c-filter-chips {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
align-items: center;
|
||||||
|
gap: 6px;
|
||||||
|
|
||||||
|
.filter-chip {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 5px;
|
||||||
|
padding: 4px 10px;
|
||||||
|
border-radius: 9999px;
|
||||||
|
font-size: 11px;
|
||||||
|
font-weight: 600;
|
||||||
|
color: #475569;
|
||||||
|
background: #f1f5f9;
|
||||||
|
border: 1px solid #e2e8f0;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: all 0.2s ease;
|
||||||
|
text-decoration: none;
|
||||||
|
|
||||||
|
&:hover {
|
||||||
|
background: #e2e8f0;
|
||||||
|
color: #0f172a;
|
||||||
|
transform: translateY(-1px);
|
||||||
|
}
|
||||||
|
|
||||||
|
&--active {
|
||||||
|
background: #2563eb;
|
||||||
|
color: #fff;
|
||||||
|
border-color: #2563eb;
|
||||||
|
box-shadow: 0 3px 8px rgb(37 99 235 / 25%);
|
||||||
|
|
||||||
|
&:hover {
|
||||||
|
background: #1d4ed8;
|
||||||
|
color: #fff;
|
||||||
|
}
|
||||||
|
|
||||||
|
.filter-chip-count {
|
||||||
|
background: rgb(255 255 255 / 25%);
|
||||||
|
color: #fff;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.filter-chip-count {
|
||||||
|
display: inline-block;
|
||||||
|
padding: 1px 5px;
|
||||||
|
border-radius: 9999px;
|
||||||
|
background: #e2e8f0;
|
||||||
|
font-size: 10px;
|
||||||
|
font-weight: 700;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Modern KPI Cards */
|
||||||
|
.i4c-kpis {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(auto-fill, minmax(180px, 1fr));
|
||||||
|
gap: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.i4c-kpi {
|
||||||
|
position: relative;
|
||||||
|
background: #fff;
|
||||||
|
border: 1px solid #e2e8f0;
|
||||||
|
border-radius: 12px;
|
||||||
|
padding: 12px 14px;
|
||||||
|
box-shadow: 0 4px 10px rgb(15 23 42 / 3%);
|
||||||
|
transition: all 0.25s cubic-bezier(0.16, 1, 0.3, 1);
|
||||||
|
overflow: hidden;
|
||||||
|
text-decoration: none;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 3px;
|
||||||
|
|
||||||
|
&::before {
|
||||||
|
content: '';
|
||||||
|
position: absolute;
|
||||||
|
top: 0;
|
||||||
|
left: 0;
|
||||||
|
right: 0;
|
||||||
|
height: 3px;
|
||||||
|
background: linear-gradient(90deg, #3b82f6, #60a5fa);
|
||||||
|
border-radius: 12px 12px 0 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
&:hover {
|
||||||
|
transform: translateY(-2px);
|
||||||
|
box-shadow: 0 8px 18px -4px rgb(15 23 42 / 8%);
|
||||||
|
border-color: #cbd5e1;
|
||||||
|
}
|
||||||
|
|
||||||
|
&__value {
|
||||||
|
font-size: 18px;
|
||||||
|
font-weight: 800;
|
||||||
|
color: #0f172a;
|
||||||
|
letter-spacing: -0.02em;
|
||||||
|
font-variant-numeric: tabular-nums;
|
||||||
|
}
|
||||||
|
|
||||||
|
&__label {
|
||||||
|
font-size: 11.5px;
|
||||||
|
font-weight: 600;
|
||||||
|
color: #475569;
|
||||||
|
}
|
||||||
|
|
||||||
|
&__sub {
|
||||||
|
font-size: 10px;
|
||||||
|
color: #94a3b8;
|
||||||
|
}
|
||||||
|
|
||||||
|
&--green::before {
|
||||||
|
background: linear-gradient(90deg, #10b981, #34d399);
|
||||||
|
}
|
||||||
|
|
||||||
|
&--amber::before {
|
||||||
|
background: linear-gradient(90deg, #f59e0b, #fbbf24);
|
||||||
|
}
|
||||||
|
|
||||||
|
&--red::before {
|
||||||
|
background: linear-gradient(90deg, #ef4444, #f87171);
|
||||||
|
}
|
||||||
|
|
||||||
|
&--purple::before {
|
||||||
|
background: linear-gradient(90deg, #8b5cf6, #a78bfa);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Modern Card Container */
|
||||||
|
.i4c-card {
|
||||||
|
background: #fff;
|
||||||
|
border: 1px solid #e2e8f0;
|
||||||
|
border-radius: 12px;
|
||||||
|
box-shadow: 0 4px 14px rgb(15 23 42 / 3%);
|
||||||
|
overflow: hidden;
|
||||||
|
transition: box-shadow 0.2s ease;
|
||||||
|
|
||||||
|
&:hover {
|
||||||
|
box-shadow: 0 6px 18px rgb(15 23 42 / 5%);
|
||||||
|
}
|
||||||
|
|
||||||
|
&__head {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
padding: 10px 16px;
|
||||||
|
background: linear-gradient(180deg, #fafbfd 0%, #f4f6fa 100%);
|
||||||
|
border-bottom: 1px solid #e2e8f0;
|
||||||
|
font-size: 13px;
|
||||||
|
font-weight: 700;
|
||||||
|
color: #1e293b;
|
||||||
|
letter-spacing: -0.01em;
|
||||||
|
|
||||||
|
fa-icon {
|
||||||
|
color: #2563eb;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
&__body {
|
||||||
|
padding: 14px 16px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.i4c-grid-2 {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(auto-fit, minmax(300px, 1fr));
|
||||||
|
gap: 14px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.i4c-table-wrap {
|
||||||
|
overflow-x: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Modern Data Table */
|
||||||
|
table.i4c-table {
|
||||||
|
width: 100%;
|
||||||
|
border-collapse: separate;
|
||||||
|
border-spacing: 0;
|
||||||
|
font-size: 11.5px;
|
||||||
|
|
||||||
|
th {
|
||||||
|
position: sticky;
|
||||||
|
top: 0;
|
||||||
|
z-index: 2;
|
||||||
|
text-align: left;
|
||||||
|
font-weight: 600;
|
||||||
|
color: #475569;
|
||||||
|
background: #f8fafc;
|
||||||
|
padding: 8px 10px;
|
||||||
|
border-bottom: 1px solid #e2e8f0;
|
||||||
|
white-space: nowrap;
|
||||||
|
font-size: 10.5px;
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 0.04em;
|
||||||
|
}
|
||||||
|
|
||||||
|
td {
|
||||||
|
padding: 8px 10px;
|
||||||
|
border-bottom: 1px solid #f1f5f9;
|
||||||
|
color: #1e293b;
|
||||||
|
vertical-align: middle;
|
||||||
|
transition: background-color 0.15s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
tr:last-child td {
|
||||||
|
border-bottom: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
tr.clickable {
|
||||||
|
cursor: pointer;
|
||||||
|
|
||||||
|
&:hover td {
|
||||||
|
background: #f8fafc;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
tr.selected td {
|
||||||
|
background: #eff6ff;
|
||||||
|
border-bottom-color: #dbeafe;
|
||||||
|
}
|
||||||
|
|
||||||
|
.num {
|
||||||
|
text-align: right;
|
||||||
|
font-variant-numeric: tabular-nums;
|
||||||
|
font-weight: 600;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Glowing Modern Status Pills */
|
||||||
|
.i4c-badge {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 5px;
|
||||||
|
padding: 2px 8px;
|
||||||
|
border-radius: 9999px;
|
||||||
|
font-size: 10px;
|
||||||
|
font-weight: 700;
|
||||||
|
letter-spacing: 0.02em;
|
||||||
|
background: #e2e8f0;
|
||||||
|
color: #334155;
|
||||||
|
white-space: nowrap;
|
||||||
|
border: 1px solid transparent;
|
||||||
|
|
||||||
|
&::before {
|
||||||
|
content: '';
|
||||||
|
display: inline-block;
|
||||||
|
width: 5px;
|
||||||
|
height: 5px;
|
||||||
|
border-radius: 50%;
|
||||||
|
background: currentcolor;
|
||||||
|
}
|
||||||
|
|
||||||
|
&--green {
|
||||||
|
background: #ecfdf5;
|
||||||
|
color: #059669;
|
||||||
|
border-color: #a7f3d0;
|
||||||
|
|
||||||
|
&::before {
|
||||||
|
box-shadow: 0 0 6px #10b981;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
&--amber {
|
||||||
|
background: #fffbeb;
|
||||||
|
color: #d97706;
|
||||||
|
border-color: #fde68a;
|
||||||
|
|
||||||
|
&::before {
|
||||||
|
box-shadow: 0 0 6px #f59e0b;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
&--red {
|
||||||
|
background: #fef2f2;
|
||||||
|
color: #dc2626;
|
||||||
|
border-color: #fecaca;
|
||||||
|
|
||||||
|
&::before {
|
||||||
|
box-shadow: 0 0 6px #ef4444;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
&--blue {
|
||||||
|
background: #eff6ff;
|
||||||
|
color: #2563eb;
|
||||||
|
border-color: #bfdbfe;
|
||||||
|
|
||||||
|
&::before {
|
||||||
|
box-shadow: 0 0 6px #3b82f6;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
&--purple {
|
||||||
|
background: #f5f3ff;
|
||||||
|
color: #7c3aed;
|
||||||
|
border-color: #ddd6fe;
|
||||||
|
|
||||||
|
&::before {
|
||||||
|
box-shadow: 0 0 6px #8b5cf6;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Key-Value details layout */
|
||||||
|
.i4c-kv {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: 160px 1fr;
|
||||||
|
gap: 8px 12px;
|
||||||
|
font-size: 11.5px;
|
||||||
|
margin: 0;
|
||||||
|
|
||||||
|
dt {
|
||||||
|
color: #64748b;
|
||||||
|
font-weight: 500;
|
||||||
|
}
|
||||||
|
|
||||||
|
dd {
|
||||||
|
margin: 0;
|
||||||
|
color: #0f172a;
|
||||||
|
font-weight: 600;
|
||||||
|
word-break: break-word;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.i4c-empty {
|
||||||
|
padding: 30px 20px;
|
||||||
|
text-align: center;
|
||||||
|
color: #64748b;
|
||||||
|
font-size: 12px;
|
||||||
|
|
||||||
|
.empty-icon {
|
||||||
|
font-size: 26px;
|
||||||
|
color: #cbd5e1;
|
||||||
|
margin-bottom: 6px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.i4c-error {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
padding: 10px 14px;
|
||||||
|
border-radius: 10px;
|
||||||
|
background: #fef2f2;
|
||||||
|
border: 1px solid #fecaca;
|
||||||
|
color: #991b1b;
|
||||||
|
font-size: 11.5px;
|
||||||
|
font-weight: 500;
|
||||||
|
}
|
||||||
|
|
||||||
|
.i4c-note {
|
||||||
|
padding: 10px 14px;
|
||||||
|
border-radius: 8px;
|
||||||
|
background: #f8fafc;
|
||||||
|
border: 1px solid #e2e8f0;
|
||||||
|
color: #475569;
|
||||||
|
font-size: 11.5px;
|
||||||
|
line-height: 1.5;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Sleek Dark JSON / Code Viewer */
|
||||||
|
pre.i4c-json {
|
||||||
|
margin: 0;
|
||||||
|
padding: 10px 14px 20px;
|
||||||
|
max-height: 380px;
|
||||||
|
overflow: auto;
|
||||||
|
background: #0f172a;
|
||||||
|
color: #38bdf8;
|
||||||
|
border: 1px solid #1e293b;
|
||||||
|
border-radius: 10px;
|
||||||
|
font-family: 'JetBrains Mono', Consolas, Monaco, monospace;
|
||||||
|
font-size: 10.5px;
|
||||||
|
line-height: 1.5;
|
||||||
|
box-shadow: inset 0 2px 4px rgb(0 0 0 / 40%);
|
||||||
|
box-sizing: border-box;
|
||||||
|
white-space: pre-wrap;
|
||||||
|
word-break: break-all;
|
||||||
|
|
||||||
|
&::-webkit-scrollbar {
|
||||||
|
width: 7px;
|
||||||
|
height: 7px;
|
||||||
|
}
|
||||||
|
|
||||||
|
&::-webkit-scrollbar-track {
|
||||||
|
background: #1e293b;
|
||||||
|
border-radius: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
&::-webkit-scrollbar-thumb {
|
||||||
|
background: #475569;
|
||||||
|
border-radius: 4px;
|
||||||
|
|
||||||
|
&:hover {
|
||||||
|
background: #64748b;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.i4c-form {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(auto-fit, minmax(200px, 1fr));
|
||||||
|
gap: 8px 14px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.i4c-actions {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Quick action button inside tables */
|
||||||
|
.table-action-btn {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 5px;
|
||||||
|
padding: 3px 8px;
|
||||||
|
border-radius: 6px;
|
||||||
|
font-size: 11px;
|
||||||
|
font-weight: 600;
|
||||||
|
color: #2563eb;
|
||||||
|
background: #eff6ff;
|
||||||
|
border: 1px solid #bfdbfe;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: all 0.2s ease;
|
||||||
|
|
||||||
|
&:hover {
|
||||||
|
background: #2563eb;
|
||||||
|
color: #fff;
|
||||||
|
transform: translateY(-1px);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Copy Button */
|
||||||
|
.copy-chip {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 4px;
|
||||||
|
padding: 2px 8px;
|
||||||
|
border-radius: 6px;
|
||||||
|
background: #f1f5f9;
|
||||||
|
color: #475569;
|
||||||
|
font-size: 11px;
|
||||||
|
cursor: pointer;
|
||||||
|
border: 1px solid #e2e8f0;
|
||||||
|
transition: all 0.15s ease;
|
||||||
|
|
||||||
|
&:hover {
|
||||||
|
background: #e2e8f0;
|
||||||
|
color: #0f172a;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Expandable JSON Row inside tables */
|
||||||
|
.finding-json-cell {
|
||||||
|
padding: 12px 16px !important;
|
||||||
|
background: #0b1329 !important;
|
||||||
|
border-bottom: 2px solid #1e293b !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.finding-json-box {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 8px;
|
||||||
|
|
||||||
|
.finding-json-head {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
font-size: 11px;
|
||||||
|
font-weight: 600;
|
||||||
|
color: #94a3b8;
|
||||||
|
padding-bottom: 6px;
|
||||||
|
border-bottom: 1px solid #1e293b;
|
||||||
|
|
||||||
|
fa-icon {
|
||||||
|
color: #60a5fa;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pre.i4c-json {
|
||||||
|
max-height: none;
|
||||||
|
overflow: auto visible;
|
||||||
|
border: none;
|
||||||
|
background: transparent;
|
||||||
|
padding: 6px 0 16px;
|
||||||
|
box-shadow: none;
|
||||||
|
white-space: pre-wrap;
|
||||||
|
word-break: break-all;
|
||||||
|
font-size: 11px;
|
||||||
|
line-height: 1.6;
|
||||||
|
color: #38bdf8;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ========================================================
|
||||||
|
MODERN POPUP WINDOW MODAL SYSTEM (Ultra Modern Backdrop & Window)
|
||||||
|
======================================================== */
|
||||||
|
.i4c-modal-backdrop {
|
||||||
|
position: fixed;
|
||||||
|
inset: 0;
|
||||||
|
background: rgb(15 23 42 / 65%);
|
||||||
|
backdrop-filter: blur(6px);
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
z-index: 9999;
|
||||||
|
padding: 16px;
|
||||||
|
animation: i4c-fade-in 0.2s ease-out;
|
||||||
|
}
|
||||||
|
|
||||||
|
.i4c-modal-window {
|
||||||
|
background: #fff;
|
||||||
|
border-radius: 14px;
|
||||||
|
border: 1px solid rgb(255 255 255 / 20%);
|
||||||
|
box-shadow: 0 20px 50px -15px rgb(15 23 42 / 35%);
|
||||||
|
width: 100%;
|
||||||
|
max-width: 660px;
|
||||||
|
max-height: 86vh;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
overflow: hidden;
|
||||||
|
animation: i4c-modal-pop 0.25s cubic-bezier(0.16, 1, 0.3, 1);
|
||||||
|
|
||||||
|
&--wide {
|
||||||
|
max-width: min(980px, 95vw);
|
||||||
|
}
|
||||||
|
|
||||||
|
&--full {
|
||||||
|
max-width: 90vw;
|
||||||
|
max-height: 88vh;
|
||||||
|
}
|
||||||
|
|
||||||
|
&--compact {
|
||||||
|
max-width: 440px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.i4c-modal-header {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
padding: 11px 16px;
|
||||||
|
background: linear-gradient(135deg, #0f172a 0%, #1e293b 100%);
|
||||||
|
color: #fff;
|
||||||
|
border-bottom: 1px solid rgb(255 255 255 / 10%);
|
||||||
|
flex-shrink: 0;
|
||||||
|
|
||||||
|
&__title {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 10px;
|
||||||
|
font-size: 14px;
|
||||||
|
font-weight: 700;
|
||||||
|
letter-spacing: -0.01em;
|
||||||
|
|
||||||
|
fa-icon {
|
||||||
|
color: #60a5fa;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
&__sub {
|
||||||
|
font-size: 11px;
|
||||||
|
color: #94a3b8;
|
||||||
|
font-weight: normal;
|
||||||
|
margin-top: 2px;
|
||||||
|
}
|
||||||
|
|
||||||
|
&__actions {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 6px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.modal-close-btn {
|
||||||
|
width: 26px;
|
||||||
|
height: 26px;
|
||||||
|
border-radius: 50%;
|
||||||
|
background: rgb(255 255 255 / 10%);
|
||||||
|
color: #fff;
|
||||||
|
border: none;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
cursor: pointer;
|
||||||
|
font-size: 11px;
|
||||||
|
transition: all 0.2s ease;
|
||||||
|
|
||||||
|
&:hover {
|
||||||
|
background: rgb(239 68 68 / 80%);
|
||||||
|
transform: rotate(90deg);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Modern Modal Tabs Bar */
|
||||||
|
.i4c-modal-tabs {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
align-items: center;
|
||||||
|
gap: 6px;
|
||||||
|
padding: 8px 14px;
|
||||||
|
background: linear-gradient(180deg, #f8fafc 0%, #f1f5f9 100%);
|
||||||
|
border-bottom: 1px solid #e2e8f0;
|
||||||
|
flex-shrink: 0;
|
||||||
|
|
||||||
|
.modal-tab {
|
||||||
|
position: relative;
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 6px;
|
||||||
|
padding: 6px 12px;
|
||||||
|
border-radius: 8px;
|
||||||
|
font-size: 12px;
|
||||||
|
font-weight: 600;
|
||||||
|
color: #475569;
|
||||||
|
background: transparent;
|
||||||
|
border: 1px solid transparent;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: all 0.2s cubic-bezier(0.16, 1, 0.3, 1);
|
||||||
|
white-space: nowrap;
|
||||||
|
user-select: none;
|
||||||
|
outline: none;
|
||||||
|
|
||||||
|
fa-icon {
|
||||||
|
font-size: 12px;
|
||||||
|
color: #64748b;
|
||||||
|
transition:
|
||||||
|
color 0.2s ease,
|
||||||
|
transform 0.2s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
&:hover {
|
||||||
|
color: #0f172a;
|
||||||
|
background: rgb(226 232 240 / 70%);
|
||||||
|
|
||||||
|
fa-icon {
|
||||||
|
color: #1e293b;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
&--active,
|
||||||
|
&--active:hover {
|
||||||
|
color: #1d4ed8;
|
||||||
|
background: #fff;
|
||||||
|
border-color: #cbd5e1;
|
||||||
|
box-shadow:
|
||||||
|
0 2px 6px -1px rgb(15 23 42 / 8%),
|
||||||
|
0 1px 3px -1px rgb(15 23 42 / 4%);
|
||||||
|
|
||||||
|
fa-icon {
|
||||||
|
color: #2563eb;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
&--has-risk {
|
||||||
|
&:not(.modal-tab--active) {
|
||||||
|
color: #991b1b;
|
||||||
|
background: rgb(254 242 242 / 65%);
|
||||||
|
border-color: rgb(254 202 202 / 70%);
|
||||||
|
|
||||||
|
fa-icon {
|
||||||
|
color: #dc2626;
|
||||||
|
}
|
||||||
|
|
||||||
|
&:hover {
|
||||||
|
background: #fee2e2;
|
||||||
|
color: #7f1d1d;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
&.modal-tab--active {
|
||||||
|
border-color: #fca5a5;
|
||||||
|
box-shadow:
|
||||||
|
0 2px 6px -1px rgb(220 38 38 / 15%),
|
||||||
|
0 1px 3px -1px rgb(15 23 42 / 4%);
|
||||||
|
|
||||||
|
fa-icon {
|
||||||
|
color: #dc2626;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/* When an existing i4c-badge is nested in a tab */
|
||||||
|
.i4c-badge {
|
||||||
|
font-size: 10px;
|
||||||
|
padding: 1.5px 6.5px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Sleek Tab Badges for Status & Risk Alerts */
|
||||||
|
.tab-badge {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 4px;
|
||||||
|
padding: 1.5px 7px;
|
||||||
|
border-radius: 9999px;
|
||||||
|
font-size: 10px;
|
||||||
|
font-weight: 700;
|
||||||
|
letter-spacing: 0.02em;
|
||||||
|
line-height: 1.2;
|
||||||
|
white-space: nowrap;
|
||||||
|
border: 1px solid transparent;
|
||||||
|
transition: all 0.2s ease;
|
||||||
|
|
||||||
|
&__dot {
|
||||||
|
width: 5px;
|
||||||
|
height: 5px;
|
||||||
|
border-radius: 50%;
|
||||||
|
background: currentcolor;
|
||||||
|
display: inline-block;
|
||||||
|
position: relative;
|
||||||
|
}
|
||||||
|
|
||||||
|
&--pulse &__dot::after {
|
||||||
|
content: '';
|
||||||
|
position: absolute;
|
||||||
|
inset: -2px;
|
||||||
|
border-radius: 50%;
|
||||||
|
background: currentcolor;
|
||||||
|
animation: tab-alert-pulse 1.8s infinite ease-out;
|
||||||
|
}
|
||||||
|
|
||||||
|
&--danger {
|
||||||
|
background: #fef2f2;
|
||||||
|
color: #dc2626;
|
||||||
|
border-color: #fecaca;
|
||||||
|
box-shadow: 0 1px 2px rgb(220 38 38 / 6%);
|
||||||
|
}
|
||||||
|
|
||||||
|
&--amber {
|
||||||
|
background: #fffbeb;
|
||||||
|
color: #b45309;
|
||||||
|
border-color: #fde68a;
|
||||||
|
}
|
||||||
|
|
||||||
|
&--green {
|
||||||
|
background: #ecfdf5;
|
||||||
|
color: #059669;
|
||||||
|
border-color: #a7f3d0;
|
||||||
|
}
|
||||||
|
|
||||||
|
&--blue {
|
||||||
|
background: #eff6ff;
|
||||||
|
color: #2563eb;
|
||||||
|
border-color: #bfdbfe;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Compact numeric pill inside tab buttons */
|
||||||
|
.tab-count {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
min-width: 18px;
|
||||||
|
height: 18px;
|
||||||
|
padding: 0 5px;
|
||||||
|
border-radius: 9999px;
|
||||||
|
font-size: 10px;
|
||||||
|
font-weight: 700;
|
||||||
|
background: #e2e8f0;
|
||||||
|
color: #475569;
|
||||||
|
line-height: 1;
|
||||||
|
transition: all 0.2s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.modal-tab--active .tab-count {
|
||||||
|
background: #dbeafe;
|
||||||
|
color: #1d4ed8;
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes tab-alert-pulse {
|
||||||
|
0% {
|
||||||
|
transform: scale(0.9);
|
||||||
|
opacity: 0.9;
|
||||||
|
}
|
||||||
|
|
||||||
|
70% {
|
||||||
|
transform: scale(2.4);
|
||||||
|
opacity: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
100% {
|
||||||
|
transform: scale(2.4);
|
||||||
|
opacity: 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.i4c-modal-body {
|
||||||
|
padding: 14px 16px;
|
||||||
|
overflow: hidden auto;
|
||||||
|
flex: 1 1 auto;
|
||||||
|
min-height: 0;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 12px;
|
||||||
|
overscroll-behavior: contain;
|
||||||
|
|
||||||
|
/* Content keeps its natural height and the body scrolls; a flex child would otherwise shrink to fit and
|
||||||
|
clip what is inside it (a card has overflow: hidden). */
|
||||||
|
> div {
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
&::-webkit-scrollbar {
|
||||||
|
width: 7px;
|
||||||
|
}
|
||||||
|
|
||||||
|
&::-webkit-scrollbar-track {
|
||||||
|
background: #f1f5f9;
|
||||||
|
border-radius: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
&::-webkit-scrollbar-thumb {
|
||||||
|
background: #cbd5e1;
|
||||||
|
border-radius: 4px;
|
||||||
|
|
||||||
|
&:hover {
|
||||||
|
background: #94a3b8;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.i4c-modal-footer {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: flex-end;
|
||||||
|
gap: 8px;
|
||||||
|
padding: 10px 16px;
|
||||||
|
background: #f8fafc;
|
||||||
|
border-top: 1px solid #e2e8f0;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Animations */
|
||||||
|
@keyframes i4c-fade-in {
|
||||||
|
from {
|
||||||
|
opacity: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
to {
|
||||||
|
opacity: 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes i4c-modal-pop {
|
||||||
|
from {
|
||||||
|
opacity: 0;
|
||||||
|
transform: scale(0.92) translateY(12px);
|
||||||
|
}
|
||||||
|
|
||||||
|
to {
|
||||||
|
opacity: 1;
|
||||||
|
transform: scale(1) translateY(0);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,343 @@
|
|||||||
|
<!--
|
||||||
|
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="i4c-page">
|
||||||
|
<!-- Modern Toolbar & Filter Chips -->
|
||||||
|
<div class="i4c-toolbar">
|
||||||
|
<div class="i4c-filter-chips">
|
||||||
|
@for (s of statuses; track s) {
|
||||||
|
<button class="filter-chip" [class.filter-chip--active]="status === s" (click)="setStatus(s)">
|
||||||
|
<span>{{ s || 'All Requests' }}</span>
|
||||||
|
</button>
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<span class="i4c-toolbar__spacer"></span>
|
||||||
|
|
||||||
|
<div class="maker-checker-pill">
|
||||||
|
<fa-icon icon="shield-alt"></fa-icon>
|
||||||
|
<span>Maker-Checker Enforced: you cannot approve a request you raised.</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<button mat-flat-button color="primary" class="refresh-btn" (click)="load()" [disabled]="loading">
|
||||||
|
<fa-icon icon="sync" [animation]="loading ? 'spin' : undefined"></fa-icon> Refresh
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="i4c-filter-bar">
|
||||||
|
<mat-form-field appearance="outline" subscriptSizing="dynamic" class="search-field">
|
||||||
|
<mat-label>Account / ack / order no. / officer</mat-label>
|
||||||
|
<input
|
||||||
|
matInput
|
||||||
|
[(ngModel)]="search"
|
||||||
|
(keyup.enter)="load(true)"
|
||||||
|
(blur)="load(true)"
|
||||||
|
placeholder="Type & hit enter..."
|
||||||
|
/>
|
||||||
|
</mat-form-field>
|
||||||
|
<mat-form-field appearance="outline" subscriptSizing="dynamic">
|
||||||
|
<mat-label>Action</mat-label>
|
||||||
|
<mat-select [(ngModel)]="action" (selectionChange)="load(true)">
|
||||||
|
<mat-option value="">All actions</mat-option>
|
||||||
|
<mat-option value="RELEASE">Release hold</mat-option>
|
||||||
|
<mat-option value="FREEZE">Freeze debits</mat-option>
|
||||||
|
<mat-option value="UNFREEZE">Unfreeze debits</mat-option>
|
||||||
|
</mat-select>
|
||||||
|
</mat-form-field>
|
||||||
|
<mat-form-field appearance="outline" subscriptSizing="dynamic">
|
||||||
|
<mat-label>Reason</mat-label>
|
||||||
|
<mat-select [(ngModel)]="reasonType" (selectionChange)="load(true)">
|
||||||
|
<mat-option value="">All reasons</mat-option>
|
||||||
|
@for (r of reasonTypes; track r.value) {
|
||||||
|
<mat-option [value]="r.value">{{ r.label }}</mat-option>
|
||||||
|
}
|
||||||
|
</mat-select>
|
||||||
|
</mat-form-field>
|
||||||
|
<mifosx-i4c-date-range label="Raised on" [range]="dates" (rangeChange)="onDates($event)"></mifosx-i4c-date-range>
|
||||||
|
<button mat-stroked-button class="i4c-filter-bar__clear" (click)="clearFilters()" [disabled]="!hasFilters">
|
||||||
|
<fa-icon icon="times"></fa-icon> Clear filters
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
@if (loading) {
|
||||||
|
<mat-progress-bar mode="indeterminate"></mat-progress-bar>
|
||||||
|
}
|
||||||
|
@if (error) {
|
||||||
|
<div class="i4c-error">
|
||||||
|
<fa-icon icon="exclamation-circle"></fa-icon>
|
||||||
|
<span>{{ error }}</span>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
|
||||||
|
<!-- Approvals Table Card -->
|
||||||
|
<div class="i4c-card">
|
||||||
|
<div class="i4c-card__head">
|
||||||
|
<fa-icon icon="user-check"></fa-icon>
|
||||||
|
<span>Checker Approval Queue (I4C Account Holds)</span>
|
||||||
|
<span class="head-hint">{{ total }} requests in current queue</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="i4c-table-wrap">
|
||||||
|
<table class="i4c-table">
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th>Raised On</th>
|
||||||
|
<th>Requested Action</th>
|
||||||
|
<th>Target Account / Complaint</th>
|
||||||
|
<th class="num">Held Amount (₹)</th>
|
||||||
|
<th>Reason & Legal Order</th>
|
||||||
|
<th>Maker Remarks</th>
|
||||||
|
<th>Status</th>
|
||||||
|
<th>Decision Actions</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
@for (r of rows; track r.id) {
|
||||||
|
<tr>
|
||||||
|
<td>
|
||||||
|
<span class="date-main">{{ r.maker_at | date: 'dd MMM yyyy, HH:mm' }}</span>
|
||||||
|
<div class="sub">
|
||||||
|
Raised by: <b>{{ r.maker_name }}</b>
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
<td>
|
||||||
|
<span
|
||||||
|
class="i4c-badge"
|
||||||
|
[class.i4c-badge--red]="r.action !== 'RELEASE'"
|
||||||
|
[class.i4c-badge--blue]="r.action === 'RELEASE'"
|
||||||
|
>
|
||||||
|
{{ r.action }}
|
||||||
|
</span>
|
||||||
|
</td>
|
||||||
|
<td>
|
||||||
|
<a [routerLink]="['../holds']" [queryParams]="{ view: '' }" class="acc-link font-semibold">
|
||||||
|
{{ r.account_no }}
|
||||||
|
</a>
|
||||||
|
<div class="sub">{{ r.account_type }} • Ack: {{ r.acknowledgement_no }}</div>
|
||||||
|
</td>
|
||||||
|
<td class="num">
|
||||||
|
<span class="font-bold text-accent">₹{{ r.amount_held | number: '1.2-2' }}</span>
|
||||||
|
<div class="sub">Disputed: ₹{{ r.disputed_amount | number: '1.2-2' }}</div>
|
||||||
|
</td>
|
||||||
|
<td>
|
||||||
|
<div class="reason-title">{{ reasonLabel(r.reason_type) }}</div>
|
||||||
|
<div class="sub">
|
||||||
|
Ref: {{ r.order_reference }} {{ r.order_date ? '(' + r.order_date + ')' : '' }}
|
||||||
|
{{ r.authority ? '• ' + r.authority : '' }}
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
<td>
|
||||||
|
<div class="remarks-text">{{ r.remarks || '-' }}</div>
|
||||||
|
</td>
|
||||||
|
<td>
|
||||||
|
<span
|
||||||
|
class="i4c-badge"
|
||||||
|
[class.i4c-badge--green]="r.status === 'APPROVED'"
|
||||||
|
[class.i4c-badge--red]="r.status === 'REJECTED' || r.status === 'FAILED'"
|
||||||
|
[class.i4c-badge--amber]="r.status === 'PENDING'"
|
||||||
|
>
|
||||||
|
{{ r.status }}
|
||||||
|
</span>
|
||||||
|
@if (r.checker_name) {
|
||||||
|
<div class="sub">Checked by {{ r.checker_name }}, {{ r.checker_at | date: 'dd MMM, HH:mm' }}</div>
|
||||||
|
}
|
||||||
|
@if (r.rejection_reason || r.failure_message) {
|
||||||
|
<div class="sub err-text">{{ r.rejection_reason || r.failure_message }}</div>
|
||||||
|
}
|
||||||
|
</td>
|
||||||
|
<td class="actions-cell">
|
||||||
|
@if (r.status === 'PENDING') {
|
||||||
|
<div class="i4c-actions">
|
||||||
|
<button class="approve-btn" (click)="startApprove(r)" [disabled]="busyId === r.id">
|
||||||
|
<fa-icon icon="check"></fa-icon> Approve
|
||||||
|
</button>
|
||||||
|
<button class="reject-btn" (click)="startReject(r)" [disabled]="busyId === r.id">
|
||||||
|
<fa-icon icon="times"></fa-icon> Reject
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
} @else {
|
||||||
|
<span class="text-muted">Decision Recorded</span>
|
||||||
|
}
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
} @empty {
|
||||||
|
<tr>
|
||||||
|
<td colspan="8" class="i4c-empty">
|
||||||
|
<div class="empty-icon"><fa-icon icon="user-check"></fa-icon></div>
|
||||||
|
<div>No approval requests in this category.</div>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
<mat-paginator
|
||||||
|
class="i4c-paginator"
|
||||||
|
[length]="total"
|
||||||
|
[pageIndex]="pageIndex"
|
||||||
|
[pageSize]="pageSize"
|
||||||
|
[pageSizeOptions]="pageSizes"
|
||||||
|
(page)="onPage($event)"
|
||||||
|
showFirstLastButtons
|
||||||
|
aria-label="Approval requests pages"
|
||||||
|
></mat-paginator>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- ========================================================
|
||||||
|
MODERN POPUP WINDOW: APPROVE CONFIRMATION MODAL
|
||||||
|
======================================================== -->
|
||||||
|
@if (approvingRow) {
|
||||||
|
<div class="i4c-modal-backdrop" (click)="closeApprove()">
|
||||||
|
<div class="i4c-modal-window i4c-modal-window--compact" (click)="$event.stopPropagation()">
|
||||||
|
<div class="i4c-modal-header approve-header">
|
||||||
|
<div class="i4c-modal-header__title">
|
||||||
|
<fa-icon icon="check-circle" class="text-green"></fa-icon>
|
||||||
|
<div>
|
||||||
|
<span>Confirm Checker Approval</span>
|
||||||
|
<div class="i4c-modal-header__sub">Executing live CBS account action</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<button class="modal-close-btn" (click)="closeApprove()" aria-label="Close">
|
||||||
|
<fa-icon icon="times"></fa-icon>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="i4c-modal-body">
|
||||||
|
<div class="approve-card">
|
||||||
|
<div class="approve-action-badge">
|
||||||
|
<span
|
||||||
|
>ACTION: <b>{{ approvingRow.action }}</b></span
|
||||||
|
>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<dl class="i4c-kv">
|
||||||
|
<dt>Account Number</dt>
|
||||||
|
<dd>
|
||||||
|
<b>{{ approvingRow.account_no }}</b>
|
||||||
|
</dd>
|
||||||
|
|
||||||
|
<dt>Held Amount</dt>
|
||||||
|
<dd>
|
||||||
|
<b class="text-accent">₹{{ approvingRow.amount_held | number: '1.2-2' }}</b>
|
||||||
|
</dd>
|
||||||
|
|
||||||
|
<dt>Reason</dt>
|
||||||
|
<dd>{{ reasonLabel(approvingRow.reason_type) }}</dd>
|
||||||
|
|
||||||
|
<dt>Order Reference</dt>
|
||||||
|
<dd>
|
||||||
|
{{ approvingRow.order_reference }}
|
||||||
|
{{ approvingRow.authority ? '(' + approvingRow.authority + ')' : '' }}
|
||||||
|
</dd>
|
||||||
|
|
||||||
|
<dt>Maker Details</dt>
|
||||||
|
<dd>{{ approvingRow.maker_name }} on {{ approvingRow.maker_at | date: 'dd MMM yyyy, HH:mm' }}</dd>
|
||||||
|
</dl>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="i4c-note warn-note">
|
||||||
|
<fa-icon icon="exclamation-triangle"></fa-icon>
|
||||||
|
<span
|
||||||
|
>Warning: Once confirmed, the hold change or freeze will be executed immediately on the core banking
|
||||||
|
system.</span
|
||||||
|
>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="i4c-modal-footer">
|
||||||
|
<button class="footer-btn-close" (click)="closeApprove()">Cancel</button>
|
||||||
|
<button
|
||||||
|
mat-flat-button
|
||||||
|
color="primary"
|
||||||
|
class="confirm-approve-btn"
|
||||||
|
(click)="confirmApprove()"
|
||||||
|
[disabled]="busyId === approvingRow.id"
|
||||||
|
>
|
||||||
|
<fa-icon
|
||||||
|
[icon]="busyId === approvingRow.id ? 'sync' : 'check'"
|
||||||
|
[animation]="busyId === approvingRow.id ? 'spin' : undefined"
|
||||||
|
></fa-icon>
|
||||||
|
Confirm & Approve
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
|
||||||
|
<!-- ========================================================
|
||||||
|
MODERN POPUP WINDOW: REJECT REASON MODAL
|
||||||
|
======================================================== -->
|
||||||
|
@if (rejectingRow) {
|
||||||
|
<div class="i4c-modal-backdrop" (click)="closeReject()">
|
||||||
|
<div class="i4c-modal-window" (click)="$event.stopPropagation()">
|
||||||
|
<div class="i4c-modal-header reject-header">
|
||||||
|
<div class="i4c-modal-header__title">
|
||||||
|
<fa-icon icon="times-circle" class="text-red"></fa-icon>
|
||||||
|
<div>
|
||||||
|
<span>Reject Request #{{ rejectingRow.id }}</span>
|
||||||
|
<div class="i4c-modal-header__sub">Provide the reason for rejecting this hold action</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<button class="modal-close-btn" (click)="closeReject()" aria-label="Close">
|
||||||
|
<fa-icon icon="times"></fa-icon>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="i4c-modal-body">
|
||||||
|
<div class="reject-summary">
|
||||||
|
<span
|
||||||
|
>Rejecting: <b>{{ rejectingRow.action }}</b> on Account <b>{{ rejectingRow.account_no }}</b></span
|
||||||
|
>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Preset Reason Chips -->
|
||||||
|
<div class="preset-box">
|
||||||
|
<div class="preset-title">Quick Preset Reasons:</div>
|
||||||
|
<div class="preset-chips">
|
||||||
|
@for (preset of presetReasons; track preset) {
|
||||||
|
<button type="button" class="preset-chip" (click)="applyPreset(preset)">
|
||||||
|
{{ preset }}
|
||||||
|
</button>
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Rejection Reason Input -->
|
||||||
|
<mat-form-field appearance="outline" class="w-full">
|
||||||
|
<mat-label>Rejection Reason (Required)</mat-label>
|
||||||
|
<textarea
|
||||||
|
matInput
|
||||||
|
rows="3"
|
||||||
|
[(ngModel)]="rejectReason"
|
||||||
|
placeholder="Explain why this request is rejected for the maker's audit log..."
|
||||||
|
maxlength="1000"
|
||||||
|
></textarea>
|
||||||
|
<mat-hint align="end">{{ rejectReason.length }}/1000</mat-hint>
|
||||||
|
</mat-form-field>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="i4c-modal-footer">
|
||||||
|
<button class="footer-btn-close" (click)="closeReject()">Cancel</button>
|
||||||
|
<button
|
||||||
|
mat-flat-button
|
||||||
|
color="warn"
|
||||||
|
class="confirm-reject-btn"
|
||||||
|
(click)="confirmReject()"
|
||||||
|
[disabled]="busyId === rejectingRow.id || !rejectReason.trim()"
|
||||||
|
>
|
||||||
|
<fa-icon
|
||||||
|
[icon]="busyId === rejectingRow.id ? 'sync' : 'times'"
|
||||||
|
[animation]="busyId === rejectingRow.id ? 'spin' : undefined"
|
||||||
|
></fa-icon>
|
||||||
|
Confirm Rejection
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
</div>
|
||||||
@@ -0,0 +1,244 @@
|
|||||||
|
/**
|
||||||
|
* 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/.
|
||||||
|
*/
|
||||||
|
|
||||||
|
@use '../i4c' as *;
|
||||||
|
|
||||||
|
@include i4c-skin;
|
||||||
|
|
||||||
|
.maker-checker-pill {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 6px;
|
||||||
|
padding: 4px 10px;
|
||||||
|
border-radius: 9999px;
|
||||||
|
background: #f1f5f9;
|
||||||
|
border: 1px solid #e2e8f0;
|
||||||
|
font-size: 11px;
|
||||||
|
font-weight: 600;
|
||||||
|
color: #475569;
|
||||||
|
|
||||||
|
fa-icon {
|
||||||
|
color: #2563eb;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.refresh-btn {
|
||||||
|
height: 36px;
|
||||||
|
border-radius: 8px;
|
||||||
|
font-weight: 600;
|
||||||
|
font-size: 11.5px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.head-hint {
|
||||||
|
margin-left: auto;
|
||||||
|
font-size: 11px;
|
||||||
|
color: #94a3b8;
|
||||||
|
font-weight: normal;
|
||||||
|
}
|
||||||
|
|
||||||
|
.date-main {
|
||||||
|
font-weight: 600;
|
||||||
|
color: #1e293b;
|
||||||
|
}
|
||||||
|
|
||||||
|
.acc-link {
|
||||||
|
color: #2563eb;
|
||||||
|
text-decoration: none;
|
||||||
|
|
||||||
|
&:hover {
|
||||||
|
text-decoration: underline;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.text-accent {
|
||||||
|
color: #0f172a;
|
||||||
|
}
|
||||||
|
|
||||||
|
.reason-title {
|
||||||
|
font-weight: 600;
|
||||||
|
color: #1e293b;
|
||||||
|
}
|
||||||
|
|
||||||
|
.remarks-text {
|
||||||
|
font-size: 12px;
|
||||||
|
color: #475569;
|
||||||
|
max-width: 220px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sub {
|
||||||
|
font-size: 11px;
|
||||||
|
color: #64748b;
|
||||||
|
margin-top: 2px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.err-text {
|
||||||
|
color: #dc2626;
|
||||||
|
font-size: 11px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.actions-cell {
|
||||||
|
min-width: 190px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.approve-btn {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 5px;
|
||||||
|
padding: 4px 10px;
|
||||||
|
border-radius: 6px;
|
||||||
|
background: #10b981;
|
||||||
|
color: #fff;
|
||||||
|
border: none;
|
||||||
|
font-size: 11px;
|
||||||
|
font-weight: 700;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: all 0.2s;
|
||||||
|
|
||||||
|
&:hover:not(:disabled) {
|
||||||
|
background: #059669;
|
||||||
|
box-shadow: 0 4px 10px rgb(16 185 129 / 30%);
|
||||||
|
transform: translateY(-1px);
|
||||||
|
}
|
||||||
|
|
||||||
|
&:disabled {
|
||||||
|
opacity: 0.6;
|
||||||
|
cursor: not-allowed;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.reject-btn {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 5px;
|
||||||
|
padding: 4px 10px;
|
||||||
|
border-radius: 6px;
|
||||||
|
background: #fef2f2;
|
||||||
|
color: #dc2626;
|
||||||
|
border: 1px solid #fecaca;
|
||||||
|
font-size: 11px;
|
||||||
|
font-weight: 700;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: all 0.2s;
|
||||||
|
|
||||||
|
&:hover:not(:disabled) {
|
||||||
|
background: #fee2e2;
|
||||||
|
transform: translateY(-1px);
|
||||||
|
}
|
||||||
|
|
||||||
|
&:disabled {
|
||||||
|
opacity: 0.6;
|
||||||
|
cursor: not-allowed;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Approve Modal styles */
|
||||||
|
.approve-card {
|
||||||
|
padding: 12px 14px;
|
||||||
|
border-radius: 10px;
|
||||||
|
background: #f8fafc;
|
||||||
|
border: 1px solid #e2e8f0;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.approve-action-badge {
|
||||||
|
font-size: 12px;
|
||||||
|
letter-spacing: 0.05em;
|
||||||
|
color: #1e40af;
|
||||||
|
background: #eff6ff;
|
||||||
|
padding: 4px 10px;
|
||||||
|
border-radius: 6px;
|
||||||
|
align-self: flex-start;
|
||||||
|
}
|
||||||
|
|
||||||
|
.warn-note {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 10px;
|
||||||
|
background: #fffbeb;
|
||||||
|
border-color: #fde68a;
|
||||||
|
color: #92400e;
|
||||||
|
}
|
||||||
|
|
||||||
|
.text-green {
|
||||||
|
color: #10b981;
|
||||||
|
}
|
||||||
|
|
||||||
|
.text-red {
|
||||||
|
color: #ef4444;
|
||||||
|
}
|
||||||
|
|
||||||
|
.text-muted {
|
||||||
|
color: #94a3b8;
|
||||||
|
font-size: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Preset reasons */
|
||||||
|
.preset-box {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 8px;
|
||||||
|
margin-bottom: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.preset-title {
|
||||||
|
font-size: 12px;
|
||||||
|
font-weight: 600;
|
||||||
|
color: #64748b;
|
||||||
|
}
|
||||||
|
|
||||||
|
.preset-chips {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: 6px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.preset-chip {
|
||||||
|
padding: 3px 8px;
|
||||||
|
border-radius: 9999px;
|
||||||
|
background: #f1f5f9;
|
||||||
|
border: 1px solid #e2e8f0;
|
||||||
|
font-size: 10.5px;
|
||||||
|
color: #334155;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: all 0.15s;
|
||||||
|
|
||||||
|
&:hover {
|
||||||
|
background: #e2e8f0;
|
||||||
|
color: #0f172a;
|
||||||
|
border-color: #cbd5e1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.reject-summary {
|
||||||
|
font-size: 11.5px;
|
||||||
|
color: #475569;
|
||||||
|
margin-bottom: 6px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.w-full {
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.footer-btn-close {
|
||||||
|
padding: 6px 14px;
|
||||||
|
border-radius: 8px;
|
||||||
|
background: #e2e8f0;
|
||||||
|
color: #334155;
|
||||||
|
border: none;
|
||||||
|
font-size: 11.5px;
|
||||||
|
font-weight: 600;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: all 0.2s;
|
||||||
|
|
||||||
|
&:hover {
|
||||||
|
background: #cbd5e1;
|
||||||
|
color: #0f172a;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,231 @@
|
|||||||
|
/**
|
||||||
|
* 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 { DatePipe, DecimalPipe } from '@angular/common';
|
||||||
|
import { FormsModule } from '@angular/forms';
|
||||||
|
import { RouterLink } from '@angular/router';
|
||||||
|
import { MatButtonModule } from '@angular/material/button';
|
||||||
|
import { MatFormFieldModule } from '@angular/material/form-field';
|
||||||
|
import { MatInputModule } from '@angular/material/input';
|
||||||
|
import { MatSelectModule } from '@angular/material/select';
|
||||||
|
import { MatProgressBarModule } from '@angular/material/progress-bar';
|
||||||
|
import { MatPaginatorModule, PageEvent } from '@angular/material/paginator';
|
||||||
|
import { MatSnackBar } from '@angular/material/snack-bar';
|
||||||
|
import { FaIconComponent } from '@fortawesome/angular-fontawesome';
|
||||||
|
import { HOLD_REASON_TYPES, I4cService, i4cErrorMessage } from '../i4c.service';
|
||||||
|
import { ALL_DATES, I4cDateRange, I4cDateRangeComponent } from '../i4c-date-range/i4c-date-range.component';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Checker's queue: release / freeze / unfreeze requests raised on I4C holds. Approving carries the change out on the
|
||||||
|
* account at once; the server refuses an approval by the officer who raised the request.
|
||||||
|
*/
|
||||||
|
@Component({
|
||||||
|
selector: 'mifosx-i4c-approvals',
|
||||||
|
templateUrl: './i4c-approvals.component.html',
|
||||||
|
styleUrls: ['./i4c-approvals.component.scss'],
|
||||||
|
imports: [
|
||||||
|
DatePipe,
|
||||||
|
DecimalPipe,
|
||||||
|
FormsModule,
|
||||||
|
RouterLink,
|
||||||
|
MatButtonModule,
|
||||||
|
MatFormFieldModule,
|
||||||
|
MatInputModule,
|
||||||
|
MatSelectModule,
|
||||||
|
MatProgressBarModule,
|
||||||
|
MatPaginatorModule,
|
||||||
|
I4cDateRangeComponent,
|
||||||
|
FaIconComponent
|
||||||
|
]
|
||||||
|
})
|
||||||
|
export class I4cApprovalsComponent implements OnInit {
|
||||||
|
private i4c = inject(I4cService);
|
||||||
|
private snackBar = inject(MatSnackBar);
|
||||||
|
|
||||||
|
readonly statuses = [
|
||||||
|
'PENDING',
|
||||||
|
'APPROVED',
|
||||||
|
'REJECTED',
|
||||||
|
'FAILED',
|
||||||
|
''
|
||||||
|
];
|
||||||
|
status = 'PENDING';
|
||||||
|
search = '';
|
||||||
|
action = '';
|
||||||
|
reasonType = '';
|
||||||
|
readonly reasonTypes = HOLD_REASON_TYPES;
|
||||||
|
dates: I4cDateRange = { ...ALL_DATES };
|
||||||
|
total = 0;
|
||||||
|
pageIndex = 0;
|
||||||
|
pageSize = 20;
|
||||||
|
readonly pageSizes = [
|
||||||
|
10,
|
||||||
|
20,
|
||||||
|
50,
|
||||||
|
100
|
||||||
|
];
|
||||||
|
rows: any[] = [];
|
||||||
|
loading = false;
|
||||||
|
busyId: number | null = null;
|
||||||
|
error = '';
|
||||||
|
|
||||||
|
// Modern Approval Popup Modal state
|
||||||
|
approvingRow: any = null;
|
||||||
|
|
||||||
|
// Modern Rejection Popup Modal state
|
||||||
|
rejectingRow: any = null;
|
||||||
|
rejectReason = '';
|
||||||
|
|
||||||
|
readonly presetReasons = [
|
||||||
|
'Insufficient court / IO documentation provided.',
|
||||||
|
'Invalid or unverified reference number.',
|
||||||
|
'Duplicate action request raised.',
|
||||||
|
'Wrong account number specified in order.',
|
||||||
|
'Investigation still in progress; hold must remain.'
|
||||||
|
];
|
||||||
|
|
||||||
|
ngOnInit(): void {
|
||||||
|
this.load();
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Reload the current page; a changed filter passes `fromFirstPage` so the officer lands on page one. */
|
||||||
|
load(fromFirstPage = false): void {
|
||||||
|
if (fromFirstPage) {
|
||||||
|
this.pageIndex = 0;
|
||||||
|
}
|
||||||
|
this.loading = true;
|
||||||
|
this.error = '';
|
||||||
|
this.i4c
|
||||||
|
.holdActions({
|
||||||
|
status: this.status,
|
||||||
|
search: this.search.trim(),
|
||||||
|
action: this.action,
|
||||||
|
reasonType: this.reasonType,
|
||||||
|
fromDate: this.dates.fromDate,
|
||||||
|
toDate: this.dates.toDate,
|
||||||
|
offset: this.pageIndex * this.pageSize,
|
||||||
|
limit: this.pageSize
|
||||||
|
})
|
||||||
|
.subscribe({
|
||||||
|
next: (page) => {
|
||||||
|
this.rows = page.pageItems;
|
||||||
|
this.total = page.totalFilteredRecords;
|
||||||
|
this.loading = false;
|
||||||
|
},
|
||||||
|
error: (e) => {
|
||||||
|
this.error = i4cErrorMessage(e);
|
||||||
|
this.loading = false;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
setStatus(s: string): void {
|
||||||
|
this.status = s;
|
||||||
|
this.load(true);
|
||||||
|
}
|
||||||
|
|
||||||
|
onPage(e: PageEvent): void {
|
||||||
|
this.pageIndex = e.pageIndex;
|
||||||
|
this.pageSize = e.pageSize;
|
||||||
|
this.load();
|
||||||
|
}
|
||||||
|
|
||||||
|
onDates(range: I4cDateRange): void {
|
||||||
|
this.dates = range;
|
||||||
|
this.load(true);
|
||||||
|
}
|
||||||
|
|
||||||
|
get hasFilters(): boolean {
|
||||||
|
return !!(this.search || this.action || this.reasonType || this.dates.preset !== 'ALL');
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Clears the extra filters; the status chip stays as chosen. */
|
||||||
|
clearFilters(): void {
|
||||||
|
this.search = '';
|
||||||
|
this.action = '';
|
||||||
|
this.reasonType = '';
|
||||||
|
this.dates = { ...ALL_DATES };
|
||||||
|
this.load(true);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Open modern approval popup modal
|
||||||
|
startApprove(row: any): void {
|
||||||
|
this.approvingRow = row;
|
||||||
|
}
|
||||||
|
|
||||||
|
closeApprove(): void {
|
||||||
|
this.approvingRow = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
confirmApprove(): void {
|
||||||
|
if (!this.approvingRow) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const row = this.approvingRow;
|
||||||
|
this.busyId = row.id;
|
||||||
|
this.i4c.approveHoldAction(row.id).subscribe({
|
||||||
|
next: () => {
|
||||||
|
this.busyId = null;
|
||||||
|
this.approvingRow = null;
|
||||||
|
this.snackBar.open('Approved! The action has been executed immediately on the CBS account.', 'OK', {
|
||||||
|
duration: 5000
|
||||||
|
});
|
||||||
|
this.load();
|
||||||
|
},
|
||||||
|
error: (e) => {
|
||||||
|
this.busyId = null;
|
||||||
|
this.snackBar.open(i4cErrorMessage(e), 'Close', { duration: 8000 });
|
||||||
|
this.load();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Open modern rejection popup modal
|
||||||
|
startReject(row: any): void {
|
||||||
|
this.rejectingRow = row;
|
||||||
|
this.rejectReason = '';
|
||||||
|
}
|
||||||
|
|
||||||
|
closeReject(): void {
|
||||||
|
this.rejectingRow = null;
|
||||||
|
this.rejectReason = '';
|
||||||
|
}
|
||||||
|
|
||||||
|
applyPreset(preset: string): void {
|
||||||
|
this.rejectReason = preset;
|
||||||
|
}
|
||||||
|
|
||||||
|
confirmReject(): void {
|
||||||
|
if (!this.rejectingRow) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (!this.rejectReason.trim()) {
|
||||||
|
this.snackBar.open('A reason is required to reject.', 'Close', { duration: 4000 });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const row = this.rejectingRow;
|
||||||
|
this.busyId = row.id;
|
||||||
|
this.i4c.rejectHoldAction(row.id, this.rejectReason.trim()).subscribe({
|
||||||
|
next: () => {
|
||||||
|
this.busyId = null;
|
||||||
|
this.rejectingRow = null;
|
||||||
|
this.snackBar.open('Request rejected.', 'OK', { duration: 3000 });
|
||||||
|
this.load();
|
||||||
|
},
|
||||||
|
error: (e) => {
|
||||||
|
this.busyId = null;
|
||||||
|
this.snackBar.open(i4cErrorMessage(e), 'Close', { duration: 6000 });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
reasonLabel(value: string): string {
|
||||||
|
return HOLD_REASON_TYPES.find((r) => r.value === value)?.label || value;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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="i4c-page">
|
||||||
|
<!-- Top Filters & Search Toolbar -->
|
||||||
|
<div class="i4c-toolbar">
|
||||||
|
<div class="i4c-filter-chips">
|
||||||
|
@for (t of types; track t.value) {
|
||||||
|
<button class="filter-chip" [class.filter-chip--active]="type === t.value" (click)="setType(t.value)">
|
||||||
|
<span>{{ t.label }}</span>
|
||||||
|
@if (type === t.value) {
|
||||||
|
<span class="filter-chip-count">{{ total }}</span>
|
||||||
|
}
|
||||||
|
</button>
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<span class="i4c-toolbar__spacer"></span>
|
||||||
|
|
||||||
|
<div class="search-box">
|
||||||
|
<fa-icon icon="search" class="search-icon"></fa-icon>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
[(ngModel)]="search"
|
||||||
|
placeholder="Filter by name, code or IFSC prefix..."
|
||||||
|
(keyup.enter)="load(true)"
|
||||||
|
class="search-input"
|
||||||
|
/>
|
||||||
|
@if (search) {
|
||||||
|
<button class="search-clear-btn" (click)="search = ''; load(true)" aria-label="Clear search">
|
||||||
|
<fa-icon icon="times"></fa-icon>
|
||||||
|
</button>
|
||||||
|
}
|
||||||
|
<button mat-flat-button color="primary" class="search-btn" (click)="load(true)" [disabled]="loading">
|
||||||
|
Search
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<mat-form-field appearance="outline" subscriptSizing="dynamic" class="ifsc-filter">
|
||||||
|
<mat-label>IFSC prefix</mat-label>
|
||||||
|
<mat-select [(ngModel)]="ifsc" (selectionChange)="load(true)">
|
||||||
|
<mat-option value="">Any</mat-option>
|
||||||
|
<mat-option value="KNOWN">Prefix known</mat-option>
|
||||||
|
<mat-option value="UNKNOWN">Prefix not known</mat-option>
|
||||||
|
</mat-select>
|
||||||
|
</mat-form-field>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
@if (loading) {
|
||||||
|
<mat-progress-bar mode="indeterminate"></mat-progress-bar>
|
||||||
|
}
|
||||||
|
@if (error) {
|
||||||
|
<div class="i4c-error">
|
||||||
|
<fa-icon icon="exclamation-circle"></fa-icon>
|
||||||
|
<span>{{ error }}</span>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
|
||||||
|
<!-- The bank's own IFSC -> I4C Bank Code map -->
|
||||||
|
<div class="i4c-card">
|
||||||
|
<div class="i4c-card-head">
|
||||||
|
<fa-icon icon="exchange-alt"></fa-icon>
|
||||||
|
<span>IFSC to I4C Bank Code map</span>
|
||||||
|
<span class="head-hint"
|
||||||
|
>When money moved to another bank, the response names it by this code so CFCFRMS can send that bank the next
|
||||||
|
notice (MHA SOP 6(g), 6(j)). A full IFSC wins over a prefix, which wins over the master.</span
|
||||||
|
>
|
||||||
|
</div>
|
||||||
|
<div class="i4c-form ifsc-map-form">
|
||||||
|
<mat-form-field appearance="outline" subscriptSizing="dynamic">
|
||||||
|
<mat-label>IFSC prefix or full IFSC</mat-label>
|
||||||
|
<input matInput [(ngModel)]="mapKey" maxlength="11" placeholder="HDFC or HDFC0CXYZ01" />
|
||||||
|
</mat-form-field>
|
||||||
|
<mat-form-field appearance="outline" subscriptSizing="dynamic">
|
||||||
|
<mat-label>I4C Bank Code</mat-label>
|
||||||
|
<input matInput type="number" [(ngModel)]="mapCode" />
|
||||||
|
</mat-form-field>
|
||||||
|
<mat-form-field appearance="outline" subscriptSizing="dynamic" class="ifsc-map-note">
|
||||||
|
<mat-label>Note</mat-label>
|
||||||
|
<input matInput [(ngModel)]="mapNote" maxlength="300" placeholder="e.g. co-operative bank on a sponsor IFSC" />
|
||||||
|
</mat-form-field>
|
||||||
|
<button mat-flat-button color="primary" (click)="addMapping()" [disabled]="mapBusy || !mapKey || !mapCode">
|
||||||
|
Add
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
@if (mapError) {
|
||||||
|
<div class="i4c-error">
|
||||||
|
<fa-icon icon="exclamation-circle"></fa-icon>
|
||||||
|
<span>{{ mapError }}</span>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
<div class="i4c-table-wrap">
|
||||||
|
<table class="i4c-table">
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th>IFSC / prefix</th>
|
||||||
|
<th class="num">Bank Code</th>
|
||||||
|
<th>Bank</th>
|
||||||
|
<th>Note</th>
|
||||||
|
<th>Added</th>
|
||||||
|
<th></th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
@for (m of mappings; track m.id) {
|
||||||
|
<tr>
|
||||||
|
<td class="font-mono">{{ m.ifsc_key }}</td>
|
||||||
|
<td class="num">{{ m.bank_code }}</td>
|
||||||
|
<td>{{ m.bank_name || '-' }}</td>
|
||||||
|
<td>{{ m.note || '-' }}</td>
|
||||||
|
<td>{{ m.added_at | date: 'dd MMM yyyy' }} · {{ m.added_by }}</td>
|
||||||
|
<td><button mat-button (click)="removeMapping(m.id)" [disabled]="mapBusy">Remove</button></td>
|
||||||
|
</tr>
|
||||||
|
} @empty {
|
||||||
|
<tr>
|
||||||
|
<td colspan="6" class="i4c-empty">No mappings. The master's IFSC prefixes are used.</td>
|
||||||
|
</tr>
|
||||||
|
}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
@if (unknown.length) {
|
||||||
|
<div class="i4c-card-head">
|
||||||
|
<span>Reported with no bank code (payee_bank_code 0)</span>
|
||||||
|
<span class="head-hint">Map these so later responses name the next bank.</span>
|
||||||
|
</div>
|
||||||
|
<div class="i4c-table-wrap">
|
||||||
|
<table class="i4c-table">
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th>IFSC</th>
|
||||||
|
<th class="num">Times</th>
|
||||||
|
<th>Last seen</th>
|
||||||
|
<th></th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
@for (u of unknown; track u.ifsc) {
|
||||||
|
<tr>
|
||||||
|
<td class="font-mono">{{ u.ifsc || '(none given)' }}</td>
|
||||||
|
<td class="num">{{ u.times }}</td>
|
||||||
|
<td>{{ u.last_seen | date: 'dd MMM yyyy' }}</td>
|
||||||
|
<td>
|
||||||
|
@if (u.ifsc) {
|
||||||
|
<button mat-button (click)="mapKey = u.ifsc">Map</button>
|
||||||
|
}
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Main Directory Card -->
|
||||||
|
<div class="i4c-card">
|
||||||
|
<div class="i4c-card-head">
|
||||||
|
<fa-icon icon="university"></fa-icon>
|
||||||
|
<span>I4C Intermediary & Bank Master Directory</span>
|
||||||
|
<span class="head-hint">Official MHA-I4C 2.0 routing table for beneficiary & payee bank mapping</span>
|
||||||
|
|
||||||
|
<span class="i4c-toolbar-spacer"></span>
|
||||||
|
<span class="entries-counter">{{ total }} routing codes</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="i4c-table-wrap">
|
||||||
|
<table class="i4c-table">
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th class="num">Bank Code</th>
|
||||||
|
<th>Institution / Intermediary Name</th>
|
||||||
|
<th>Entity Type</th>
|
||||||
|
<th>IFSC Prefix Pattern</th>
|
||||||
|
<th>Routing Standard</th>
|
||||||
|
<th>Action</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
@for (b of rows; track b.bank_code) {
|
||||||
|
<tr class="clickable" (click)="openModal(b)">
|
||||||
|
<td class="num">
|
||||||
|
<span class="code-pill font-mono">#{{ b.bank_code }}</span>
|
||||||
|
</td>
|
||||||
|
<td>
|
||||||
|
<div class="bank-name-cell">
|
||||||
|
<div class="entity-avatar entity-avatar--{{ b.entity_type?.toLowerCase() }}">
|
||||||
|
<fa-icon [icon]="getTypeIcon(b.entity_type)"></fa-icon>
|
||||||
|
</div>
|
||||||
|
<div class="bank-title-wrap">
|
||||||
|
<span class="bank-name font-semibold">{{ b.bank_name }}</span>
|
||||||
|
<span class="bank-code-sub">Reported as payee_bank_code</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
<td>
|
||||||
|
<span class="entity-type-badge entity-type-badge--{{ b.entity_type?.toLowerCase() }}">
|
||||||
|
{{ b.entity_type }}
|
||||||
|
</span>
|
||||||
|
</td>
|
||||||
|
<td>
|
||||||
|
@if (b.ifsc_prefix) {
|
||||||
|
<button
|
||||||
|
class="copy-chip copy-chip--ifsc"
|
||||||
|
(click)="copyText(b.ifsc_prefix, 'ifsc-' + b.bank_code, $event)"
|
||||||
|
title="Click to copy IFSC prefix"
|
||||||
|
>
|
||||||
|
<fa-icon [icon]="copiedKey === 'ifsc-' + b.bank_code ? 'check' : 'copy'"></fa-icon>
|
||||||
|
<span class="font-mono">{{ b.ifsc_prefix }}</span>
|
||||||
|
@if (copiedKey === 'ifsc-' + b.bank_code) {
|
||||||
|
<span class="copy-success-text">Copied!</span>
|
||||||
|
}
|
||||||
|
</button>
|
||||||
|
} @else {
|
||||||
|
<span class="text-muted text-xs">Dynamic / Non-IFSC</span>
|
||||||
|
}
|
||||||
|
</td>
|
||||||
|
<td>
|
||||||
|
<span class="standard-tag"> <fa-icon icon="shield-alt"></fa-icon> MHA-I4C 2.0 </span>
|
||||||
|
</td>
|
||||||
|
<td (click)="$event.stopPropagation()">
|
||||||
|
<button class="table-action-btn" (click)="openModal(b)"><fa-icon icon="eye"></fa-icon> Details</button>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
} @empty {
|
||||||
|
<tr>
|
||||||
|
<td colspan="6" class="i4c-empty">
|
||||||
|
<div class="empty-icon"><fa-icon icon="university"></fa-icon></div>
|
||||||
|
<div>No banking intermediaries found matching "{{ search }}".</div>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
<mat-paginator
|
||||||
|
class="i4c-paginator"
|
||||||
|
[length]="total"
|
||||||
|
[pageIndex]="pageIndex"
|
||||||
|
[pageSize]="pageSize"
|
||||||
|
[pageSizeOptions]="pageSizes"
|
||||||
|
(page)="onPage($event)"
|
||||||
|
showFirstLastButtons
|
||||||
|
aria-label="Bank codes pages"
|
||||||
|
></mat-paginator>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- ========================================================
|
||||||
|
MODERN POPUP WINDOW: BANK CODE & ROUTING DETAILS MODAL
|
||||||
|
======================================================== -->
|
||||||
|
@if (selectedBankModal) {
|
||||||
|
<div class="i4c-modal-backdrop" (click)="closeModal()">
|
||||||
|
<div class="i4c-modal-window i4c-modal-window--wide" (click)="$event.stopPropagation()">
|
||||||
|
<!-- Modal Header -->
|
||||||
|
<div class="i4c-modal-header">
|
||||||
|
<div class="i4c-modal-header-title">
|
||||||
|
<div
|
||||||
|
class="entity-avatar entity-avatar--lg entity-avatar--{{ selectedBankModal.entity_type?.toLowerCase() }}"
|
||||||
|
>
|
||||||
|
<fa-icon [icon]="getTypeIcon(selectedBankModal.entity_type)"></fa-icon>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<div class="modal-title-row">
|
||||||
|
<span>{{ selectedBankModal.bank_name }}</span>
|
||||||
|
<span class="entity-type-badge entity-type-badge--{{ selectedBankModal.entity_type?.toLowerCase() }}">
|
||||||
|
{{ selectedBankModal.entity_type }}
|
||||||
|
</span>
|
||||||
|
<span class="code-pill font-mono">Code #{{ selectedBankModal.bank_code }}</span>
|
||||||
|
</div>
|
||||||
|
<div class="modal-header-sub">MHA-I4C Intermediary Specification 2.0 Directory Routing Node</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<button class="modal-close-btn" (click)="closeModal()" aria-label="Close modal">
|
||||||
|
<fa-icon icon="times"></fa-icon>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Modal Body -->
|
||||||
|
<div class="i4c-modal-body">
|
||||||
|
<!-- Information Grid -->
|
||||||
|
<div class="details-grid">
|
||||||
|
<div class="detail-card">
|
||||||
|
<span class="detail-card-label">I4C Root Bank ID</span>
|
||||||
|
<div class="detail-card-val font-mono">
|
||||||
|
<span>{{ selectedBankModal.bank_code }}</span>
|
||||||
|
<button
|
||||||
|
class="copy-chip copy-chip--sm"
|
||||||
|
(click)="copyText(selectedBankModal.bank_code, 'bank-code-modal')"
|
||||||
|
>
|
||||||
|
<fa-icon [icon]="copiedKey === 'bank-code-modal' ? 'check' : 'copy'"></fa-icon>
|
||||||
|
{{ copiedKey === 'bank-code-modal' ? 'Copied' : 'Copy' }}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<span class="detail-card-sub">Used in root_bankid and payee_bank_code fields</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="detail-card">
|
||||||
|
<span class="detail-card-label">IFSC Prefix</span>
|
||||||
|
<div class="detail-card-val font-mono">
|
||||||
|
<span>{{ selectedBankModal.ifsc_prefix || 'Dynamic Routing' }}</span>
|
||||||
|
@if (selectedBankModal.ifsc_prefix) {
|
||||||
|
<button
|
||||||
|
class="copy-chip copy-chip--sm"
|
||||||
|
(click)="copyText(selectedBankModal.ifsc_prefix, 'ifsc-modal')"
|
||||||
|
>
|
||||||
|
<fa-icon [icon]="copiedKey === 'ifsc-modal' ? 'check' : 'copy'"></fa-icon>
|
||||||
|
{{ copiedKey === 'ifsc-modal' ? 'Copied' : 'Copy' }}
|
||||||
|
</button>
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
<span class="detail-card-sub">Matches starting characters of Indian Financial System Code</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="detail-card">
|
||||||
|
<span class="detail-card-label">Entity Classification</span>
|
||||||
|
<div class="detail-card-val">
|
||||||
|
<span class="entity-type-badge entity-type-badge--{{ selectedBankModal.entity_type?.toLowerCase() }}">
|
||||||
|
{{ selectedBankModal.entity_type }}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<span class="detail-card-sub">Classification recognized by Indian Cyber Crime Coordination Centre</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="detail-card">
|
||||||
|
<span class="detail-card-label">Supported Layer Depth</span>
|
||||||
|
<div class="detail-card-val font-semibold text-accent">Layer 1 to Layer 10+</div>
|
||||||
|
<span class="detail-card-sub">Valid for multi-layered money trail tracing</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Section 6 Integration Preview -->
|
||||||
|
<div class="payload-box">
|
||||||
|
<div class="payload-box-title">
|
||||||
|
<span>Section 6 Response Payload Example</span>
|
||||||
|
<button class="copy-chip" (click)="copyText(sampleJson(selectedBankModal), 'json-modal')">
|
||||||
|
<fa-icon [icon]="copiedKey === 'json-modal' ? 'check' : 'copy'"></fa-icon>
|
||||||
|
{{ copiedKey === 'json-modal' ? 'Copied!' : 'Copy Schema Snippet' }}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<pre class="i4c-json">{{ sampleJson(selectedBankModal) }}</pre>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Modal Footer -->
|
||||||
|
<div class="i4c-modal-footer">
|
||||||
|
<button class="footer-btn-close" (click)="closeModal()">Close</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
</div>
|
||||||
@@ -0,0 +1,372 @@
|
|||||||
|
/**
|
||||||
|
* 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/.
|
||||||
|
*/
|
||||||
|
|
||||||
|
@use '../i4c' as *;
|
||||||
|
|
||||||
|
@include i4c-skin;
|
||||||
|
|
||||||
|
.search-box {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
background: #fff;
|
||||||
|
border: 1px solid #cbd5e1;
|
||||||
|
border-radius: 9px;
|
||||||
|
padding: 3px 5px 3px 10px;
|
||||||
|
gap: 6px;
|
||||||
|
box-shadow: 0 1px 3px rgb(15 23 42 / 5%);
|
||||||
|
transition: all 0.2s ease;
|
||||||
|
|
||||||
|
&:focus-within {
|
||||||
|
border-color: #2563eb;
|
||||||
|
box-shadow: 0 0 0 3px rgb(37 99 235 / 15%);
|
||||||
|
}
|
||||||
|
|
||||||
|
.search-icon {
|
||||||
|
color: #94a3b8;
|
||||||
|
font-size: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.search-input {
|
||||||
|
border: none;
|
||||||
|
outline: none;
|
||||||
|
font-size: 11.5px;
|
||||||
|
color: #0f172a;
|
||||||
|
width: 220px;
|
||||||
|
background: transparent;
|
||||||
|
|
||||||
|
&::placeholder {
|
||||||
|
color: #94a3b8;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.search-clear-btn {
|
||||||
|
background: none;
|
||||||
|
border: none;
|
||||||
|
color: #94a3b8;
|
||||||
|
cursor: pointer;
|
||||||
|
padding: 3px;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
border-radius: 50%;
|
||||||
|
|
||||||
|
&:hover {
|
||||||
|
color: #0f172a;
|
||||||
|
background: #f1f5f9;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.search-btn {
|
||||||
|
height: 30px;
|
||||||
|
border-radius: 7px;
|
||||||
|
font-weight: 600;
|
||||||
|
font-size: 11px;
|
||||||
|
padding: 0 12px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.i4c-card-head {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
padding: 9px 14px;
|
||||||
|
background: #fff;
|
||||||
|
border-bottom: 1px solid #f1f5f9;
|
||||||
|
font-size: 13px;
|
||||||
|
font-weight: 700;
|
||||||
|
color: #0f172a;
|
||||||
|
|
||||||
|
fa-icon {
|
||||||
|
color: #2563eb;
|
||||||
|
}
|
||||||
|
|
||||||
|
.head-hint {
|
||||||
|
font-size: 11px;
|
||||||
|
color: #64748b;
|
||||||
|
font-weight: normal;
|
||||||
|
}
|
||||||
|
|
||||||
|
.i4c-toolbar-spacer {
|
||||||
|
flex: 1 1 auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.entries-counter {
|
||||||
|
font-size: 11px;
|
||||||
|
font-weight: 600;
|
||||||
|
color: #475569;
|
||||||
|
background: #f1f5f9;
|
||||||
|
padding: 2px 8px;
|
||||||
|
border-radius: 9999px;
|
||||||
|
border: 1px solid #e2e8f0;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.bank-name-cell {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 10px;
|
||||||
|
|
||||||
|
.bank-title-wrap {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 1px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.bank-name {
|
||||||
|
color: #0f172a;
|
||||||
|
font-size: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.bank-code-sub {
|
||||||
|
font-size: 10px;
|
||||||
|
color: #94a3b8;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.entity-avatar {
|
||||||
|
width: 28px;
|
||||||
|
height: 28px;
|
||||||
|
border-radius: 8px;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
font-size: 12px;
|
||||||
|
background: #f1f5f9;
|
||||||
|
color: #475569;
|
||||||
|
border: 1px solid #e2e8f0;
|
||||||
|
flex-shrink: 0;
|
||||||
|
|
||||||
|
&--lg {
|
||||||
|
width: 36px;
|
||||||
|
height: 36px;
|
||||||
|
font-size: 15px;
|
||||||
|
border-radius: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
&--bnk {
|
||||||
|
background: #eff6ff;
|
||||||
|
color: #1d4ed8;
|
||||||
|
border-color: #bfdbfe;
|
||||||
|
}
|
||||||
|
|
||||||
|
&--wlt {
|
||||||
|
background: #f5f3ff;
|
||||||
|
color: #7c3aed;
|
||||||
|
border-color: #ddd6fe;
|
||||||
|
}
|
||||||
|
|
||||||
|
&--mer {
|
||||||
|
background: #ecfdf5;
|
||||||
|
color: #059669;
|
||||||
|
border-color: #a7f3d0;
|
||||||
|
}
|
||||||
|
|
||||||
|
&--ppi {
|
||||||
|
background: #fffbeb;
|
||||||
|
color: #d97706;
|
||||||
|
border-color: #fde68a;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.entity-type-badge {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
padding: 3px 10px;
|
||||||
|
border-radius: 6px;
|
||||||
|
font-size: 11px;
|
||||||
|
font-weight: 700;
|
||||||
|
letter-spacing: 0.02em;
|
||||||
|
background: #f1f5f9;
|
||||||
|
color: #475569;
|
||||||
|
border: 1px solid #e2e8f0;
|
||||||
|
|
||||||
|
&--bnk {
|
||||||
|
background: #eff6ff;
|
||||||
|
color: #1d4ed8;
|
||||||
|
border-color: #bfdbfe;
|
||||||
|
}
|
||||||
|
|
||||||
|
&--wlt {
|
||||||
|
background: #f5f3ff;
|
||||||
|
color: #7c3aed;
|
||||||
|
border-color: #ddd6fe;
|
||||||
|
}
|
||||||
|
|
||||||
|
&--mer {
|
||||||
|
background: #ecfdf5;
|
||||||
|
color: #059669;
|
||||||
|
border-color: #a7f3d0;
|
||||||
|
}
|
||||||
|
|
||||||
|
&--ppi {
|
||||||
|
background: #fffbeb;
|
||||||
|
color: #d97706;
|
||||||
|
border-color: #fde68a;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.code-pill {
|
||||||
|
display: inline-block;
|
||||||
|
padding: 3px 8px;
|
||||||
|
border-radius: 6px;
|
||||||
|
background: #f8fafc;
|
||||||
|
border: 1px solid #e2e8f0;
|
||||||
|
color: #0f172a;
|
||||||
|
font-weight: 700;
|
||||||
|
font-size: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.copy-chip {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 6px;
|
||||||
|
padding: 4px 10px;
|
||||||
|
border-radius: 6px;
|
||||||
|
background: #f8fafc;
|
||||||
|
border: 1px solid #cbd5e1;
|
||||||
|
color: #334155;
|
||||||
|
font-size: 11px;
|
||||||
|
font-weight: 600;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: all 0.15s ease;
|
||||||
|
|
||||||
|
&:hover {
|
||||||
|
background: #e2e8f0;
|
||||||
|
color: #0f172a;
|
||||||
|
}
|
||||||
|
|
||||||
|
&--ifsc {
|
||||||
|
background: #f0fdf4;
|
||||||
|
border-color: #bbf7d0;
|
||||||
|
color: #166534;
|
||||||
|
|
||||||
|
&:hover {
|
||||||
|
background: #dcfce7;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
&--sm {
|
||||||
|
padding: 2px 8px;
|
||||||
|
font-size: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.copy-success-text {
|
||||||
|
color: #16a34a;
|
||||||
|
font-weight: 700;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.standard-tag {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 5px;
|
||||||
|
font-size: 11px;
|
||||||
|
color: #64748b;
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
|
||||||
|
.modal-title-row {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 10px;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.modal-header-sub {
|
||||||
|
font-size: 12px;
|
||||||
|
color: #94a3b8;
|
||||||
|
font-weight: normal;
|
||||||
|
margin-top: 2px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.details-grid {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(auto-fit, minmax(180px, 1fr));
|
||||||
|
gap: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.detail-card {
|
||||||
|
background: #f8fafc;
|
||||||
|
border: 1px solid #e2e8f0;
|
||||||
|
border-radius: 10px;
|
||||||
|
padding: 10px 12px;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 3px;
|
||||||
|
|
||||||
|
&-label {
|
||||||
|
font-size: 10px;
|
||||||
|
font-weight: 700;
|
||||||
|
color: #64748b;
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 0.03em;
|
||||||
|
}
|
||||||
|
|
||||||
|
&-val {
|
||||||
|
font-size: 13px;
|
||||||
|
font-weight: 700;
|
||||||
|
color: #0f172a;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 6px;
|
||||||
|
}
|
||||||
|
|
||||||
|
&-sub {
|
||||||
|
font-size: 10px;
|
||||||
|
color: #94a3b8;
|
||||||
|
margin-top: 1px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.payload-box {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 6px;
|
||||||
|
|
||||||
|
&-title {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
font-size: 12px;
|
||||||
|
font-weight: 700;
|
||||||
|
color: #1e293b;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.footer-btn-close {
|
||||||
|
padding: 6px 14px;
|
||||||
|
border-radius: 8px;
|
||||||
|
background: #e2e8f0;
|
||||||
|
color: #334155;
|
||||||
|
border: none;
|
||||||
|
font-size: 11.5px;
|
||||||
|
font-weight: 600;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: all 0.2s;
|
||||||
|
|
||||||
|
&:hover {
|
||||||
|
background: #cbd5e1;
|
||||||
|
color: #0f172a;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.ifsc-filter {
|
||||||
|
width: 170px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ifsc-map-form {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: 12px;
|
||||||
|
align-items: center;
|
||||||
|
padding: 12px 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ifsc-map-note {
|
||||||
|
flex: 1 1 240px;
|
||||||
|
}
|
||||||
@@ -0,0 +1,220 @@
|
|||||||
|
/**
|
||||||
|
* 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 { MatButtonModule } from '@angular/material/button';
|
||||||
|
import { MatFormFieldModule } from '@angular/material/form-field';
|
||||||
|
import { MatInputModule } from '@angular/material/input';
|
||||||
|
import { MatSelectModule } from '@angular/material/select';
|
||||||
|
import { MatProgressBarModule } from '@angular/material/progress-bar';
|
||||||
|
import { MatPaginatorModule, PageEvent } from '@angular/material/paginator';
|
||||||
|
import { FaIconComponent } from '@fortawesome/angular-fontawesome';
|
||||||
|
import { I4cService, i4cErrorMessage } from '../i4c.service';
|
||||||
|
|
||||||
|
/** The I4C Bank Code Master from the specification: codes the bank reports as root_bankid / payee_bank_code. */
|
||||||
|
@Component({
|
||||||
|
selector: 'mifosx-i4c-bank-master',
|
||||||
|
templateUrl: './i4c-bank-master.component.html',
|
||||||
|
styleUrls: ['./i4c-bank-master.component.scss'],
|
||||||
|
imports: [
|
||||||
|
CommonModule,
|
||||||
|
FormsModule,
|
||||||
|
MatButtonModule,
|
||||||
|
MatFormFieldModule,
|
||||||
|
MatInputModule,
|
||||||
|
MatSelectModule,
|
||||||
|
MatProgressBarModule,
|
||||||
|
MatPaginatorModule,
|
||||||
|
FaIconComponent
|
||||||
|
]
|
||||||
|
})
|
||||||
|
export class I4cBankMasterComponent implements OnInit {
|
||||||
|
private i4c = inject(I4cService);
|
||||||
|
|
||||||
|
readonly types = [
|
||||||
|
{ value: '', label: 'All Entities' },
|
||||||
|
{ value: 'BNK', label: 'Commercial Banks' },
|
||||||
|
{ value: 'WLT', label: 'Digital Wallets' },
|
||||||
|
{ value: 'MER', label: 'Payment Merchants' },
|
||||||
|
{ value: 'PPI', label: 'Prepaid Instruments' },
|
||||||
|
{ value: 'CBD', label: 'CBD' },
|
||||||
|
{ value: 'INS', label: 'Insurance' }
|
||||||
|
];
|
||||||
|
|
||||||
|
search = '';
|
||||||
|
type = '';
|
||||||
|
ifsc = '';
|
||||||
|
total = 0;
|
||||||
|
pageIndex = 0;
|
||||||
|
pageSize = 50;
|
||||||
|
readonly pageSizes = [
|
||||||
|
25,
|
||||||
|
50,
|
||||||
|
100,
|
||||||
|
200
|
||||||
|
];
|
||||||
|
rows: any[] = [];
|
||||||
|
loading = false;
|
||||||
|
error = '';
|
||||||
|
copiedKey = '';
|
||||||
|
selectedBankModal: any = null;
|
||||||
|
|
||||||
|
mappings: any[] = [];
|
||||||
|
unknown: any[] = [];
|
||||||
|
mapKey = '';
|
||||||
|
mapCode: number | null = null;
|
||||||
|
mapNote = '';
|
||||||
|
mapBusy = false;
|
||||||
|
mapError = '';
|
||||||
|
|
||||||
|
ngOnInit(): void {
|
||||||
|
this.load();
|
||||||
|
this.loadMap();
|
||||||
|
}
|
||||||
|
|
||||||
|
loadMap(): void {
|
||||||
|
this.i4c.ifscMap().subscribe({
|
||||||
|
next: (m) => {
|
||||||
|
this.mappings = m.mappings;
|
||||||
|
this.unknown = m.unknown;
|
||||||
|
},
|
||||||
|
error: (e) => (this.mapError = i4cErrorMessage(e))
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
addMapping(): void {
|
||||||
|
this.mapBusy = true;
|
||||||
|
this.mapError = '';
|
||||||
|
this.i4c
|
||||||
|
.mapIfsc({ ifscKey: this.mapKey.trim().toUpperCase(), bankCode: Number(this.mapCode), note: this.mapNote.trim() })
|
||||||
|
.subscribe({
|
||||||
|
next: () => {
|
||||||
|
this.mapKey = '';
|
||||||
|
this.mapCode = null;
|
||||||
|
this.mapNote = '';
|
||||||
|
this.mapBusy = false;
|
||||||
|
this.loadMap();
|
||||||
|
},
|
||||||
|
error: (e) => {
|
||||||
|
this.mapError = i4cErrorMessage(e);
|
||||||
|
this.mapBusy = false;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
removeMapping(id: number): void {
|
||||||
|
this.mapBusy = true;
|
||||||
|
this.i4c.unmapIfsc(id).subscribe({
|
||||||
|
next: () => {
|
||||||
|
this.mapBusy = false;
|
||||||
|
this.loadMap();
|
||||||
|
},
|
||||||
|
error: (e) => {
|
||||||
|
this.mapError = i4cErrorMessage(e);
|
||||||
|
this.mapBusy = false;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
setType(t: string): void {
|
||||||
|
if (this.type === t) return;
|
||||||
|
this.type = t;
|
||||||
|
this.load(true);
|
||||||
|
}
|
||||||
|
|
||||||
|
onPage(e: PageEvent): void {
|
||||||
|
this.pageIndex = e.pageIndex;
|
||||||
|
this.pageSize = e.pageSize;
|
||||||
|
this.load();
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Reload the current page; a changed filter passes `fromFirstPage` so the officer lands on page one. */
|
||||||
|
load(fromFirstPage = false): void {
|
||||||
|
if (fromFirstPage) {
|
||||||
|
this.pageIndex = 0;
|
||||||
|
}
|
||||||
|
this.loading = true;
|
||||||
|
this.error = '';
|
||||||
|
this.i4c
|
||||||
|
.bankMaster({
|
||||||
|
search: this.search.trim(),
|
||||||
|
type: this.type,
|
||||||
|
ifsc: this.ifsc,
|
||||||
|
offset: this.pageIndex * this.pageSize,
|
||||||
|
limit: this.pageSize
|
||||||
|
})
|
||||||
|
.subscribe({
|
||||||
|
next: (page) => {
|
||||||
|
this.rows = page.pageItems;
|
||||||
|
this.total = page.totalFilteredRecords;
|
||||||
|
this.loading = false;
|
||||||
|
},
|
||||||
|
error: (e) => {
|
||||||
|
this.error = i4cErrorMessage(e);
|
||||||
|
this.loading = false;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
openModal(bank: any): void {
|
||||||
|
this.selectedBankModal = bank;
|
||||||
|
}
|
||||||
|
|
||||||
|
closeModal(): void {
|
||||||
|
this.selectedBankModal = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
copyText(text: string, key: string, e?: MouseEvent): void {
|
||||||
|
if (e) {
|
||||||
|
e.stopPropagation();
|
||||||
|
}
|
||||||
|
if (!text) return;
|
||||||
|
navigator.clipboard?.writeText(text);
|
||||||
|
this.copiedKey = key;
|
||||||
|
setTimeout(() => {
|
||||||
|
if (this.copiedKey === key) {
|
||||||
|
this.copiedKey = '';
|
||||||
|
}
|
||||||
|
}, 2000);
|
||||||
|
}
|
||||||
|
|
||||||
|
getTypeIcon(type: string): string {
|
||||||
|
switch (type?.toUpperCase()) {
|
||||||
|
case 'BNK':
|
||||||
|
return 'university';
|
||||||
|
case 'WLT':
|
||||||
|
return 'mobile-alt';
|
||||||
|
case 'MER':
|
||||||
|
return 'coins';
|
||||||
|
case 'PPI':
|
||||||
|
return 'credit-card';
|
||||||
|
default:
|
||||||
|
return 'building';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
sampleJson(b: any): string {
|
||||||
|
if (!b) return '';
|
||||||
|
const sample = {
|
||||||
|
layer_number: 1,
|
||||||
|
transaction_details: {
|
||||||
|
payee_bank_code: b.bank_code,
|
||||||
|
payee_bank_name: b.bank_name,
|
||||||
|
payee_ifsc_prefix: b.ifsc_prefix || 'N/A',
|
||||||
|
entity_category: b.entity_type
|
||||||
|
},
|
||||||
|
mha_routing: {
|
||||||
|
root_bankid: b.bank_code,
|
||||||
|
standard: 'I4C-MHA-2.0'
|
||||||
|
}
|
||||||
|
};
|
||||||
|
return JSON.stringify(sample, null, 2);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,580 @@
|
|||||||
|
<!--
|
||||||
|
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="i4c-page">
|
||||||
|
<!-- Modern Toolbar -->
|
||||||
|
<div class="i4c-toolbar">
|
||||||
|
<div class="i4c-toolbar__hint">
|
||||||
|
<fa-icon icon="info-circle" class="hint-ic"></fa-icon>
|
||||||
|
<span
|
||||||
|
>Live NCRP-CFCFRMS Integration: automated complaint processing, targeted account holds & MHA
|
||||||
|
responses.</span
|
||||||
|
>
|
||||||
|
</div>
|
||||||
|
<span class="i4c-toolbar__spacer"></span>
|
||||||
|
<button mat-stroked-button class="action-btn" (click)="showConfigModal = true" [disabled]="!data">
|
||||||
|
<fa-icon icon="cog"></fa-icon> Integration Config
|
||||||
|
</button>
|
||||||
|
<button mat-flat-button color="primary" class="action-btn" (click)="load()" [disabled]="loading">
|
||||||
|
<fa-icon icon="sync" [animation]="loading ? 'spin' : undefined"></fa-icon> Refresh
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
@if (loading) {
|
||||||
|
<mat-progress-bar mode="indeterminate"></mat-progress-bar>
|
||||||
|
}
|
||||||
|
@if (error) {
|
||||||
|
<div class="i4c-error">
|
||||||
|
<fa-icon icon="exclamation-circle"></fa-icon>
|
||||||
|
<span>{{ error }}</span>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
|
||||||
|
@if (data) {
|
||||||
|
@if (setupGaps.length) {
|
||||||
|
<div class="setup-banner">
|
||||||
|
<div class="setup-banner__icon">
|
||||||
|
<fa-icon icon="exclamation-triangle"></fa-icon>
|
||||||
|
</div>
|
||||||
|
<div class="setup-banner__content">
|
||||||
|
<div class="setup-banner__title">Configuration & Production Setup Needed</div>
|
||||||
|
<ul class="setup-banner__list">
|
||||||
|
@for (gap of setupGaps; track gap) {
|
||||||
|
<li>{{ gap }}</li>
|
||||||
|
}
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
|
||||||
|
<!-- KPI Groups: Complaints, Holds, Needs Action -->
|
||||||
|
<div class="kpi-groups">
|
||||||
|
<!-- Section 1: Complaints -->
|
||||||
|
<section class="kpi-group">
|
||||||
|
<div class="kpi-group__head">
|
||||||
|
<fa-icon icon="inbox"></fa-icon>
|
||||||
|
<span>Complaints</span>
|
||||||
|
</div>
|
||||||
|
<div class="kpi-group__row">
|
||||||
|
<a class="modern-kpi" routerLink="../requests">
|
||||||
|
<div class="kpi-top">
|
||||||
|
<span class="kpi-icon-circle kpi-icon-circle--blue"><fa-icon icon="clock"></fa-icon></span>
|
||||||
|
<span class="kpi-trend">Last 24h</span>
|
||||||
|
</div>
|
||||||
|
<span class="modern-kpi__value">{{ data.requestsLast24h }}</span>
|
||||||
|
<span class="modern-kpi__label">Received (24 h)</span>
|
||||||
|
<span class="modern-kpi__sub">{{ data.requestsLast30d }} received in 30 days</span>
|
||||||
|
</a>
|
||||||
|
|
||||||
|
<a class="modern-kpi" routerLink="../requests" [class.modern-kpi--warn]="data.requestsPending > 0">
|
||||||
|
<div class="kpi-top">
|
||||||
|
<span class="kpi-icon-circle kpi-icon-circle--amber"><fa-icon icon="tasks"></fa-icon></span>
|
||||||
|
@if (data.requestsPending > 0) {
|
||||||
|
<span class="kpi-trend kpi-trend--warn">Needs processing</span>
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
<span class="modern-kpi__value">{{ data.requestsPending }}</span>
|
||||||
|
<span class="modern-kpi__label">Still Processing</span>
|
||||||
|
<span class="modern-kpi__sub">queued / retry queue</span>
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<!-- Section 2: Holds -->
|
||||||
|
<section class="kpi-group">
|
||||||
|
<div class="kpi-group__head">
|
||||||
|
<fa-icon icon="lock"></fa-icon>
|
||||||
|
<span>Holds Register</span>
|
||||||
|
</div>
|
||||||
|
<div class="kpi-group__row">
|
||||||
|
<a class="modern-kpi" routerLink="../holds" [queryParams]="{ view: 'ACTIVE' }">
|
||||||
|
<div class="kpi-top">
|
||||||
|
<span class="kpi-icon-circle kpi-icon-circle--green"><fa-icon icon="shield-alt"></fa-icon></span>
|
||||||
|
<span class="kpi-trend kpi-trend--green">Active</span>
|
||||||
|
</div>
|
||||||
|
<span class="modern-kpi__value">{{ data.activeHolds }}</span>
|
||||||
|
<span class="modern-kpi__label">Active Account Holds</span>
|
||||||
|
<span class="modern-kpi__sub">disputed amounts restricted</span>
|
||||||
|
</a>
|
||||||
|
|
||||||
|
<a class="modern-kpi" routerLink="../holds" [queryParams]="{ view: 'ACTIVE' }">
|
||||||
|
<div class="kpi-top">
|
||||||
|
<span class="kpi-icon-circle kpi-icon-circle--blue"><fa-icon icon="coins"></fa-icon></span>
|
||||||
|
</div>
|
||||||
|
<span class="modern-kpi__value">₹{{ data.amountHeld | number: '1.2-2' }}</span>
|
||||||
|
<span class="modern-kpi__label">Total Amount Held</span>
|
||||||
|
<span class="modern-kpi__sub">customer account balance</span>
|
||||||
|
</a>
|
||||||
|
|
||||||
|
<a
|
||||||
|
class="modern-kpi"
|
||||||
|
routerLink="../holds"
|
||||||
|
[queryParams]="{ view: 'PROSPECTIVE' }"
|
||||||
|
[class.modern-kpi--warn]="data.prospectiveShortfall > 0"
|
||||||
|
>
|
||||||
|
<div class="kpi-top">
|
||||||
|
<span class="kpi-icon-circle kpi-icon-circle--amber"><fa-icon icon="exclamation-circle"></fa-icon></span>
|
||||||
|
</div>
|
||||||
|
<span class="modern-kpi__value">₹{{ data.prospectiveShortfall | number: '1.2-2' }}</span>
|
||||||
|
<span class="modern-kpi__label">Prospective Shortfall</span>
|
||||||
|
<span class="modern-kpi__sub">funds pending deposit</span>
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<!-- Section 3: Needs Action -->
|
||||||
|
<section class="kpi-group kpi-group--action">
|
||||||
|
<div class="kpi-group__head">
|
||||||
|
<fa-icon icon="bell"></fa-icon>
|
||||||
|
<span>Needs Immediate Action</span>
|
||||||
|
</div>
|
||||||
|
<div class="kpi-group__row">
|
||||||
|
<a
|
||||||
|
class="modern-kpi"
|
||||||
|
routerLink="../holds"
|
||||||
|
[queryParams]="{ view: 'INTIMATION_DUE' }"
|
||||||
|
[class.modern-kpi--alert]="data.intimationOverdue > 0"
|
||||||
|
>
|
||||||
|
<div class="kpi-top">
|
||||||
|
<span class="kpi-icon-circle kpi-icon-circle--red"><fa-icon icon="envelope"></fa-icon></span>
|
||||||
|
@if (data.intimationOverdue > 0) {
|
||||||
|
<span class="kpi-trend kpi-trend--alert">Overdue</span>
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
<span class="modern-kpi__value">{{ data.intimationOverdue }}</span>
|
||||||
|
<span class="modern-kpi__label">Intimation Overdue</span>
|
||||||
|
<span class="modern-kpi__sub">RBI notification window</span>
|
||||||
|
</a>
|
||||||
|
|
||||||
|
<a
|
||||||
|
class="modern-kpi"
|
||||||
|
routerLink="../holds"
|
||||||
|
[queryParams]="{ view: 'REVIEW_DUE' }"
|
||||||
|
[class.modern-kpi--warn]="data.holdsReviewDue > 0"
|
||||||
|
>
|
||||||
|
<div class="kpi-top">
|
||||||
|
<span class="kpi-icon-circle kpi-icon-circle--amber"><fa-icon icon="calendar-check"></fa-icon></span>
|
||||||
|
</div>
|
||||||
|
<span class="modern-kpi__value">{{ data.holdsReviewDue }}</span>
|
||||||
|
<span class="modern-kpi__label">90-Day Review Due</span>
|
||||||
|
<span class="modern-kpi__sub">due within 15 days</span>
|
||||||
|
</a>
|
||||||
|
|
||||||
|
<a class="modern-kpi" routerLink="../approvals" [class.modern-kpi--warn]="data.pendingApprovals > 0">
|
||||||
|
<div class="kpi-top">
|
||||||
|
<span class="kpi-icon-circle kpi-icon-circle--purple"><fa-icon icon="user-check"></fa-icon></span>
|
||||||
|
@if (data.pendingApprovals > 0) {
|
||||||
|
<span class="kpi-trend kpi-trend--warn">Pending Checker</span>
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
<span class="modern-kpi__value">{{ data.pendingApprovals }}</span>
|
||||||
|
<span class="modern-kpi__label">Awaiting Approval</span>
|
||||||
|
<span class="modern-kpi__sub">hold release / freeze</span>
|
||||||
|
</a>
|
||||||
|
|
||||||
|
<a
|
||||||
|
class="modern-kpi"
|
||||||
|
routerLink="../outbox"
|
||||||
|
[queryParams]="{ status: 'FAILED' }"
|
||||||
|
[class.modern-kpi--alert]="data.outboxFailed > 0"
|
||||||
|
>
|
||||||
|
<div class="kpi-top">
|
||||||
|
<span class="kpi-icon-circle kpi-icon-circle--red"><fa-icon icon="paper-plane"></fa-icon></span>
|
||||||
|
@if (data.outboxFailed > 0) {
|
||||||
|
<span class="kpi-trend kpi-trend--alert">Outbox Fail</span>
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
<span class="modern-kpi__value">{{ data.outboxFailed }}</span>
|
||||||
|
<span class="modern-kpi__label">Delivery Failed</span>
|
||||||
|
<span class="modern-kpi__sub">{{ data.outboxPending }} queued to send</span>
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<!-- GRM / MRM -->
|
||||||
|
<section class="kpi-group">
|
||||||
|
<div class="kpi-group__head">
|
||||||
|
<fa-icon icon="balance-scale"></fa-icon>
|
||||||
|
<span>GRM & MRM</span>
|
||||||
|
</div>
|
||||||
|
<div class="kpi-group__row">
|
||||||
|
<a class="modern-kpi" routerLink="../grievances" [class.modern-kpi--warn]="data.grievancesOverdue > 0">
|
||||||
|
<div class="kpi-top">
|
||||||
|
<span class="kpi-icon-circle kpi-icon-circle--amber"><fa-icon icon="balance-scale"></fa-icon></span>
|
||||||
|
</div>
|
||||||
|
<span class="modern-kpi__value">{{ data.grievancesOpen }}</span>
|
||||||
|
<span class="modern-kpi__label">Open Grievances</span>
|
||||||
|
<span class="modern-kpi__sub"
|
||||||
|
>{{ data.grievancesOverdue }} past deadline · {{ data.grievancesToCarryOut }} orders to carry out</span
|
||||||
|
>
|
||||||
|
</a>
|
||||||
|
<a class="modern-kpi" routerLink="../restorations" [class.modern-kpi--warn]="data.restorationsOverdue > 0">
|
||||||
|
<div class="kpi-top">
|
||||||
|
<span class="kpi-icon-circle kpi-icon-circle--green"><fa-icon icon="hand-holding-usd"></fa-icon></span>
|
||||||
|
</div>
|
||||||
|
<span class="modern-kpi__value">{{ data.restorationsOpen }}</span>
|
||||||
|
<span class="modern-kpi__label">Open Restorations</span>
|
||||||
|
<span class="modern-kpi__sub"
|
||||||
|
>{{ data.restorationsOverdue }} past the 15-day credit · {{ data.restorationsMrmToUpdate }} to enter on
|
||||||
|
MRM</span
|
||||||
|
>
|
||||||
|
</a>
|
||||||
|
<a class="modern-kpi" routerLink="../restorations" [class.modern-kpi--warn]="data.restorationsInPool > 0">
|
||||||
|
<div class="kpi-top">
|
||||||
|
<span class="kpi-icon-circle kpi-icon-circle--blue"><fa-icon icon="exchange-alt"></fa-icon></span>
|
||||||
|
</div>
|
||||||
|
<span class="modern-kpi__value">{{ data.restorationsInPool }}</span>
|
||||||
|
<span class="modern-kpi__label">Restorations In Transit</span>
|
||||||
|
<span class="modern-kpi__sub"
|
||||||
|
>₹{{ data.amountInPool | number: '1.0-0' }} in payable / outward · ₹{{
|
||||||
|
data.amountRestored | number: '1.0-0'
|
||||||
|
}}
|
||||||
|
restored</span
|
||||||
|
>
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<!-- SOP & risk duties -->
|
||||||
|
<section class="kpi-group">
|
||||||
|
<div class="kpi-group__head">
|
||||||
|
<fa-icon icon="user-shield"></fa-icon>
|
||||||
|
<span>SOP & Risk</span>
|
||||||
|
</div>
|
||||||
|
<div class="kpi-group__row">
|
||||||
|
<a
|
||||||
|
class="modern-kpi"
|
||||||
|
routerLink="../holds"
|
||||||
|
[class.modern-kpi--warn]="data.leaNoticesDue > 0 || data.holdsLapsed > 0"
|
||||||
|
>
|
||||||
|
<div class="kpi-top">
|
||||||
|
<span class="kpi-icon-circle kpi-icon-circle--amber"><fa-icon icon="calendar-alt"></fa-icon></span>
|
||||||
|
</div>
|
||||||
|
<span class="modern-kpi__value">{{ data.leaNoticesDue }}</span>
|
||||||
|
<span class="modern-kpi__label">LEA Notices Due</span>
|
||||||
|
<span class="modern-kpi__sub">{{ data.holdsLapsed }} hold(s) lapsed without extension</span>
|
||||||
|
</a>
|
||||||
|
<a class="modern-kpi" routerLink="../risk" [class.modern-kpi--warn]="data.mulesSuspected > 0">
|
||||||
|
<div class="kpi-top">
|
||||||
|
<span class="kpi-icon-circle kpi-icon-circle--red"><fa-icon icon="user-shield"></fa-icon></span>
|
||||||
|
</div>
|
||||||
|
<span class="modern-kpi__value">{{ data.mulesSuspected }}</span>
|
||||||
|
<span class="modern-kpi__label">Suspected Mules</span>
|
||||||
|
<span class="modern-kpi__sub"
|
||||||
|
>{{ data.mulesConfirmed }} confirmed · {{ data.digitalSuspended }} digital suspensions</span
|
||||||
|
>
|
||||||
|
</a>
|
||||||
|
<a
|
||||||
|
class="modern-kpi"
|
||||||
|
routerLink="../risk"
|
||||||
|
[class.modern-kpi--warn]="
|
||||||
|
data.fraudReportsOverdue > 0 || data.linkedAlertsOpen > 0 || data.onwardUnknownBank > 0
|
||||||
|
"
|
||||||
|
>
|
||||||
|
<div class="kpi-top">
|
||||||
|
<span class="kpi-icon-circle kpi-icon-circle--red"><fa-icon icon="file-alt"></fa-icon></span>
|
||||||
|
</div>
|
||||||
|
<span class="modern-kpi__value">{{ data.fraudReportsOverdue }}</span>
|
||||||
|
<span class="modern-kpi__label">FMR / STR Overdue</span>
|
||||||
|
<span class="modern-kpi__sub"
|
||||||
|
>{{ data.linkedAlertsOpen }} linked alerts · {{ data.onwardUnknownBank || 0 }} onward transfers with no
|
||||||
|
bank code · {{ data.shadowCreditsDue }} shadow credits due · {{ data.bankHoldsOpen }} bank holds ({{
|
||||||
|
data.bankHoldDecisionsDue
|
||||||
|
}}
|
||||||
|
decisions due)</span
|
||||||
|
>
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Middle Row: API Performance + Complaints Status -->
|
||||||
|
<div class="dashboard-grid">
|
||||||
|
<!-- API Performance 30 Days -->
|
||||||
|
<div class="i4c-card card-perf">
|
||||||
|
<div class="i4c-card__head">
|
||||||
|
<fa-icon icon="tachometer-alt"></fa-icon>
|
||||||
|
<span>API Gateway Performance (Last 30 Days)</span>
|
||||||
|
<span class="rbi-badge"><fa-icon icon="shield-alt"></fa-icon> RBI Review Periodic Spec</span>
|
||||||
|
</div>
|
||||||
|
<div class="i4c-table-wrap">
|
||||||
|
<table class="i4c-table">
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th>Direction</th>
|
||||||
|
<th class="num">Calls</th>
|
||||||
|
<th class="num">Succeeded</th>
|
||||||
|
<th class="num">Failed</th>
|
||||||
|
<th class="num">Success Rate</th>
|
||||||
|
<th class="num">Avg (ms)</th>
|
||||||
|
<th class="num">Max (ms)</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
@for (row of data.apiPerformance30d; track row.direction) {
|
||||||
|
<tr>
|
||||||
|
<td>
|
||||||
|
<span class="direction-chip direction-chip--{{ row.direction?.toLowerCase() }}">
|
||||||
|
{{ row.direction === 'IN' ? '↓ Inbound (I4C → Bank)' : '↑ Outbound (Bank → I4C)' }}
|
||||||
|
</span>
|
||||||
|
</td>
|
||||||
|
<td class="num font-semibold">{{ row.calls }}</td>
|
||||||
|
<td class="num success-num">{{ row.succeeded }}</td>
|
||||||
|
<td class="num" [class.fail-num]="row.failed > 0">{{ row.failed }}</td>
|
||||||
|
<td class="num">
|
||||||
|
@if (successRate(row) === null) {
|
||||||
|
<span class="text-muted">-</span>
|
||||||
|
} @else {
|
||||||
|
<div class="modern-rate">
|
||||||
|
<div class="modern-rate__bar">
|
||||||
|
<div class="modern-rate__fill" [style.width.%]="successRate(row)"></div>
|
||||||
|
</div>
|
||||||
|
<span class="modern-rate__text">{{ successRate(row) | number: '1.0-1' }}%</span>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
</td>
|
||||||
|
<td class="num latency-pill">{{ row.avg_ms ? row.avg_ms + ' ms' : '-' }}</td>
|
||||||
|
<td class="num latency-pill">{{ row.max_ms ? row.max_ms + ' ms' : '-' }}</td>
|
||||||
|
</tr>
|
||||||
|
} @empty {
|
||||||
|
<tr>
|
||||||
|
<td colspan="7" class="i4c-empty">No API calls recorded in the last 30 days.</td>
|
||||||
|
</tr>
|
||||||
|
}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
<div class="card-footer-info">
|
||||||
|
<div class="footer-stat">
|
||||||
|
<fa-icon icon="clock"></fa-icon>
|
||||||
|
<span>Average time from complaint arrival to response delivery:</span>
|
||||||
|
<b class="stat-highlight">{{
|
||||||
|
data.avgAckToResponseSeconds === null ? '-' : data.avgAckToResponseSeconds + ' seconds'
|
||||||
|
}}</b>
|
||||||
|
</div>
|
||||||
|
<div class="footer-note">
|
||||||
|
Note: Failed inbound calls include unauthenticated requests and invalid signature payloads.
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Complaints by Status -->
|
||||||
|
<div class="i4c-card card-status">
|
||||||
|
<div class="i4c-card__head">
|
||||||
|
<fa-icon icon="tasks"></fa-icon>
|
||||||
|
<span>Complaints by Status</span>
|
||||||
|
</div>
|
||||||
|
<div class="status-grid">
|
||||||
|
@for (row of data.requestsByStatus; track row.status) {
|
||||||
|
<a class="status-tile" [routerLink]="['../requests']" [queryParams]="{ status: row.status }">
|
||||||
|
<div class="status-tile__left">
|
||||||
|
<span class="i4c-badge" [class]="statusClass(row.status)">{{ row.status }}</span>
|
||||||
|
<span class="status-tile__hint">View complaints</span>
|
||||||
|
</div>
|
||||||
|
<span class="status-tile__count">{{ row.count }}</span>
|
||||||
|
</a>
|
||||||
|
} @empty {
|
||||||
|
<div class="i4c-empty">No complaints received yet.</div>
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Bottom Row: Findings + Configuration Preview -->
|
||||||
|
<div class="dashboard-grid dashboard-grid--equal">
|
||||||
|
<!-- Reported to I4C -->
|
||||||
|
<div class="i4c-card">
|
||||||
|
<div class="i4c-card__head">
|
||||||
|
<fa-icon icon="list-alt"></fa-icon>
|
||||||
|
<span>Findings Reported to I4C (Section 6)</span>
|
||||||
|
</div>
|
||||||
|
<div class="i4c-table-wrap">
|
||||||
|
<table class="i4c-table">
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th>Transaction / Hold Type</th>
|
||||||
|
<th class="num">Entries</th>
|
||||||
|
<th class="num">Reported Amount</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
@for (row of data.findingsByType; track row.txn_type) {
|
||||||
|
<tr>
|
||||||
|
<td>
|
||||||
|
<b>{{ row.txn_type }}</b>
|
||||||
|
</td>
|
||||||
|
<td class="num">{{ row.count }}</td>
|
||||||
|
<td class="num highlight-amt">₹{{ row.amount | number: '1.2-2' }}</td>
|
||||||
|
</tr>
|
||||||
|
} @empty {
|
||||||
|
<tr>
|
||||||
|
<td colspan="3" class="i4c-empty">No findings reported to I4C yet.</td>
|
||||||
|
</tr>
|
||||||
|
}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Configuration Snapshot -->
|
||||||
|
<div class="i4c-card">
|
||||||
|
<div class="i4c-card__head">
|
||||||
|
<fa-icon icon="cog"></fa-icon>
|
||||||
|
<span>Live Gateway Configuration</span>
|
||||||
|
<button class="config-popup-trigger" (click)="showConfigModal = true">
|
||||||
|
<fa-icon icon="external-link-alt"></fa-icon> Details
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<div class="config-grid">
|
||||||
|
<div class="config-tile">
|
||||||
|
<div class="config-tile__label">Gateway Status</div>
|
||||||
|
<div class="config-tile__value">
|
||||||
|
<span class="i4c-badge" [class]="data.configuration.enabled ? 'i4c-badge--green' : 'i4c-badge--red'">
|
||||||
|
{{ data.configuration.enabled ? 'ENABLED' : 'DISABLED' }}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="config-tile">
|
||||||
|
<div class="config-tile__label">Tenant</div>
|
||||||
|
<div class="config-tile__value">{{ data.configuration.tenant }}</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="config-tile">
|
||||||
|
<div class="config-tile__label">Bank Code (I4C root)</div>
|
||||||
|
<div class="config-tile__value">
|
||||||
|
<b>{{ data.configuration.bankCode || 'Not configured' }}</b>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="config-tile">
|
||||||
|
<div class="config-tile__label">Encryption Mode</div>
|
||||||
|
<div class="config-tile__value">
|
||||||
|
<span class="crypto-chip">{{ data.configuration.cryptoMode }}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="config-tile config-tile--full">
|
||||||
|
<div class="config-tile__label">I4C Outbound API Base URL</div>
|
||||||
|
<div class="config-tile__value url-val">
|
||||||
|
<span>{{ data.configuration.outboundBaseUrl || 'Not configured' }}</span>
|
||||||
|
@if (data.configuration.outboundBaseUrl) {
|
||||||
|
<button class="copy-btn" (click)="copyText(data.configuration.outboundBaseUrl, 'url')">
|
||||||
|
{{ copiedKey === 'url' ? 'Copied!' : 'Copy' }}
|
||||||
|
</button>
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="config-tile">
|
||||||
|
<div class="config-tile__label">Review Window</div>
|
||||||
|
<div class="config-tile__value">{{ data.configuration.holdReviewDays }} Days (90-day cycle)</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="config-tile">
|
||||||
|
<div class="config-tile__label">Match Window</div>
|
||||||
|
<div class="config-tile__value">± {{ data.configuration.matchDateWindowDays }} Days</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
|
||||||
|
<!-- ========================================================
|
||||||
|
MODERN POPUP WINDOW: CONFIGURATION DETAILS MODAL
|
||||||
|
======================================================== -->
|
||||||
|
@if (showConfigModal && data?.configuration) {
|
||||||
|
<div class="i4c-modal-backdrop" (click)="showConfigModal = false">
|
||||||
|
<div class="i4c-modal-window i4c-modal-window--wide" (click)="$event.stopPropagation()">
|
||||||
|
<div class="i4c-modal-header">
|
||||||
|
<div class="i4c-modal-header__title">
|
||||||
|
<fa-icon icon="cog"></fa-icon>
|
||||||
|
<div>
|
||||||
|
<span>I4C-MHA Integration Configuration</span>
|
||||||
|
<div class="i4c-modal-header__sub">
|
||||||
|
NCRP-CFCFRMS Specification 2.0 Security & Connection Parameters
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<button class="modal-close-btn" (click)="showConfigModal = false" aria-label="Close">
|
||||||
|
<fa-icon icon="times"></fa-icon>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="i4c-modal-body">
|
||||||
|
<dl class="i4c-kv">
|
||||||
|
<dt>Integration Enabled</dt>
|
||||||
|
<dd>
|
||||||
|
<span class="i4c-badge" [class]="data.configuration.enabled ? 'i4c-badge--green' : 'i4c-badge--red'">
|
||||||
|
{{ data.configuration.enabled ? 'ACTIVE (Online)' : 'DISABLED' }}
|
||||||
|
</span>
|
||||||
|
</dd>
|
||||||
|
|
||||||
|
<dt>Tenant ID</dt>
|
||||||
|
<dd>{{ data.configuration.tenant }}</dd>
|
||||||
|
|
||||||
|
<dt>Bank Code (I4C ID)</dt>
|
||||||
|
<dd>
|
||||||
|
<b>{{ data.configuration.bankCode }}</b>
|
||||||
|
</dd>
|
||||||
|
|
||||||
|
<dt>Inbound Credentials</dt>
|
||||||
|
<dd>
|
||||||
|
{{ data.configuration.inboundCredentialsConfigured ? 'Configured' : 'Not configured' }}
|
||||||
|
@if (data.configuration.inboundPasswordHashed) {
|
||||||
|
<span class="badge-mini">bcrypt hash active</span>
|
||||||
|
}
|
||||||
|
</dd>
|
||||||
|
|
||||||
|
<dt>Allowed I4C IPs</dt>
|
||||||
|
<dd>{{ data.configuration.allowedIpCount || 'Any / All (UAT mode)' }}</dd>
|
||||||
|
|
||||||
|
<dt>Payload Encryption</dt>
|
||||||
|
<dd>
|
||||||
|
<span class="crypto-chip">{{ data.configuration.cryptoMode }}</span>
|
||||||
|
@if (data.configuration.cryptoPassphraseConfigured) {
|
||||||
|
<span class="badge-mini">passphrase active</span>
|
||||||
|
}
|
||||||
|
</dd>
|
||||||
|
|
||||||
|
<dt>Outbound Base URL</dt>
|
||||||
|
<dd class="font-mono">{{ data.configuration.outboundBaseUrl || 'Not set' }}</dd>
|
||||||
|
|
||||||
|
<dt>Automatic Hold Review</dt>
|
||||||
|
<dd>{{ data.configuration.holdReviewDays }} days</dd>
|
||||||
|
|
||||||
|
<dt>Transaction Match Window</dt>
|
||||||
|
<dd>± {{ data.configuration.matchDateWindowDays }} days</dd>
|
||||||
|
|
||||||
|
<dt>Simulator Status</dt>
|
||||||
|
<dd>
|
||||||
|
<span
|
||||||
|
class="i4c-badge"
|
||||||
|
[class]="data.configuration.simulatorEnabled ? 'i4c-badge--amber' : 'i4c-badge--green'"
|
||||||
|
>
|
||||||
|
{{ data.configuration.simulatorEnabled ? 'SIMULATOR ACTIVE (UAT)' : 'PRODUCTION' }}
|
||||||
|
</span>
|
||||||
|
</dd>
|
||||||
|
</dl>
|
||||||
|
|
||||||
|
<div class="i4c-note">
|
||||||
|
<fa-icon icon="shield-alt"></fa-icon>
|
||||||
|
<span
|
||||||
|
>All passwords, encryption keys, and tokens are stored in the server keystore and never revealed on the
|
||||||
|
UI.</span
|
||||||
|
>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="i4c-modal-footer">
|
||||||
|
<button class="footer-btn-close" (click)="showConfigModal = false">Close</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
</div>
|
||||||
@@ -0,0 +1,499 @@
|
|||||||
|
/**
|
||||||
|
* 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/.
|
||||||
|
*/
|
||||||
|
|
||||||
|
@use '../i4c' as *;
|
||||||
|
|
||||||
|
@include i4c-skin;
|
||||||
|
|
||||||
|
/* Setup Banner */
|
||||||
|
.setup-banner {
|
||||||
|
display: flex;
|
||||||
|
gap: 12px;
|
||||||
|
align-items: flex-start;
|
||||||
|
padding: 10px 14px;
|
||||||
|
border: 1px solid #fde68a;
|
||||||
|
background: linear-gradient(135deg, #fffbeb 0%, #fef3c7 100%);
|
||||||
|
border-radius: 10px;
|
||||||
|
box-shadow: 0 4px 10px rgb(245 158 11 / 8%);
|
||||||
|
|
||||||
|
&__icon {
|
||||||
|
font-size: 18px;
|
||||||
|
color: #d97706;
|
||||||
|
margin-top: 1px;
|
||||||
|
}
|
||||||
|
|
||||||
|
&__title {
|
||||||
|
font-weight: 700;
|
||||||
|
color: #92400e;
|
||||||
|
font-size: 12.5px;
|
||||||
|
}
|
||||||
|
|
||||||
|
&__list {
|
||||||
|
margin: 4px 0 0;
|
||||||
|
padding-left: 18px;
|
||||||
|
color: #92400e;
|
||||||
|
font-size: 11.5px;
|
||||||
|
line-height: 1.5;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/* KPI Groups */
|
||||||
|
.kpi-groups {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: 2fr 3fr 4fr;
|
||||||
|
gap: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (width <= 1280px) {
|
||||||
|
.kpi-groups {
|
||||||
|
grid-template-columns: 1fr 1fr;
|
||||||
|
|
||||||
|
.kpi-group--action {
|
||||||
|
grid-column: 1 / -1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (width <= 768px) {
|
||||||
|
.kpi-groups {
|
||||||
|
grid-template-columns: 1fr;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.kpi-group {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 8px;
|
||||||
|
min-width: 0;
|
||||||
|
|
||||||
|
&__head {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 6px;
|
||||||
|
font-size: 11px;
|
||||||
|
font-weight: 700;
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 0.05em;
|
||||||
|
color: #64748b;
|
||||||
|
|
||||||
|
fa-icon {
|
||||||
|
color: #2563eb;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
&__row {
|
||||||
|
display: grid;
|
||||||
|
grid-auto-flow: column;
|
||||||
|
grid-auto-columns: minmax(0, 1fr);
|
||||||
|
gap: 8px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (width <= 580px) {
|
||||||
|
.kpi-group__row {
|
||||||
|
grid-auto-flow: row;
|
||||||
|
grid-template-columns: 1fr;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Modern Fintech KPI Tile */
|
||||||
|
.modern-kpi {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 4px;
|
||||||
|
padding: 12px 14px;
|
||||||
|
background: #fff;
|
||||||
|
border: 1px solid #e2e8f0;
|
||||||
|
border-radius: 12px;
|
||||||
|
text-decoration: none;
|
||||||
|
color: inherit;
|
||||||
|
box-shadow: 0 3px 10px rgb(15 23 42 / 3%);
|
||||||
|
transition: all 0.25s cubic-bezier(0.16, 1, 0.3, 1);
|
||||||
|
position: relative;
|
||||||
|
overflow: hidden;
|
||||||
|
|
||||||
|
&:hover {
|
||||||
|
transform: translateY(-2px);
|
||||||
|
border-color: #93c5fd;
|
||||||
|
box-shadow: 0 8px 16px -3px rgb(37 99 235 / 12%);
|
||||||
|
}
|
||||||
|
|
||||||
|
.kpi-top {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
}
|
||||||
|
|
||||||
|
.kpi-icon-circle {
|
||||||
|
width: 28px;
|
||||||
|
height: 28px;
|
||||||
|
border-radius: 8px;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
font-size: 12px;
|
||||||
|
|
||||||
|
&--blue {
|
||||||
|
background: #eff6ff;
|
||||||
|
color: #2563eb;
|
||||||
|
}
|
||||||
|
|
||||||
|
&--green {
|
||||||
|
background: #ecfdf5;
|
||||||
|
color: #059669;
|
||||||
|
}
|
||||||
|
|
||||||
|
&--amber {
|
||||||
|
background: #fffbeb;
|
||||||
|
color: #d97706;
|
||||||
|
}
|
||||||
|
|
||||||
|
&--red {
|
||||||
|
background: #fef2f2;
|
||||||
|
color: #dc2626;
|
||||||
|
}
|
||||||
|
|
||||||
|
&--purple {
|
||||||
|
background: #f5f3ff;
|
||||||
|
color: #7c3aed;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.kpi-trend {
|
||||||
|
font-size: 9.5px;
|
||||||
|
font-weight: 700;
|
||||||
|
text-transform: uppercase;
|
||||||
|
padding: 1px 6px;
|
||||||
|
border-radius: 9999px;
|
||||||
|
background: #f1f5f9;
|
||||||
|
color: #64748b;
|
||||||
|
|
||||||
|
&--green {
|
||||||
|
background: #ecfdf5;
|
||||||
|
color: #059669;
|
||||||
|
}
|
||||||
|
|
||||||
|
&--warn {
|
||||||
|
background: #fffbeb;
|
||||||
|
color: #d97706;
|
||||||
|
}
|
||||||
|
|
||||||
|
&--alert {
|
||||||
|
background: #fef2f2;
|
||||||
|
color: #dc2626;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
&__value {
|
||||||
|
font-size: 18px;
|
||||||
|
font-weight: 800;
|
||||||
|
color: #0f172a;
|
||||||
|
letter-spacing: -0.02em;
|
||||||
|
font-variant-numeric: tabular-nums;
|
||||||
|
}
|
||||||
|
|
||||||
|
&__label {
|
||||||
|
font-size: 11px;
|
||||||
|
font-weight: 600;
|
||||||
|
color: #334155;
|
||||||
|
}
|
||||||
|
|
||||||
|
&__sub {
|
||||||
|
font-size: 10px;
|
||||||
|
color: #94a3b8;
|
||||||
|
}
|
||||||
|
|
||||||
|
&--warn {
|
||||||
|
border-color: #fde68a;
|
||||||
|
background: #fffdf5;
|
||||||
|
|
||||||
|
.modern-kpi__value {
|
||||||
|
color: #b45309;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
&--alert {
|
||||||
|
border-color: #fecaca;
|
||||||
|
background: #fffbfb;
|
||||||
|
|
||||||
|
.modern-kpi__value {
|
||||||
|
color: #b91c1c;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Dashboard Grid */
|
||||||
|
.dashboard-grid {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: 2fr 1fr;
|
||||||
|
gap: 18px;
|
||||||
|
|
||||||
|
&--equal {
|
||||||
|
grid-template-columns: 1fr 1fr;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (width <= 1024px) {
|
||||||
|
.dashboard-grid,
|
||||||
|
.dashboard-grid--equal {
|
||||||
|
grid-template-columns: 1fr;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/* API Performance */
|
||||||
|
.rbi-badge {
|
||||||
|
margin-left: auto;
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 6px;
|
||||||
|
font-size: 11px;
|
||||||
|
font-weight: 600;
|
||||||
|
color: #1e40af;
|
||||||
|
background: #dbeafe;
|
||||||
|
padding: 3px 10px;
|
||||||
|
border-radius: 9999px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.direction-chip {
|
||||||
|
display: inline-block;
|
||||||
|
padding: 3px 9px;
|
||||||
|
border-radius: 6px;
|
||||||
|
font-size: 11px;
|
||||||
|
font-weight: 700;
|
||||||
|
|
||||||
|
&--in {
|
||||||
|
background: #eff6ff;
|
||||||
|
color: #1d4ed8;
|
||||||
|
}
|
||||||
|
|
||||||
|
&--out {
|
||||||
|
background: #f0fdf4;
|
||||||
|
color: #15803d;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.success-num {
|
||||||
|
color: #059669;
|
||||||
|
font-weight: 700;
|
||||||
|
}
|
||||||
|
|
||||||
|
.fail-num {
|
||||||
|
color: #dc2626;
|
||||||
|
font-weight: 700;
|
||||||
|
}
|
||||||
|
|
||||||
|
.modern-rate {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
justify-content: flex-end;
|
||||||
|
|
||||||
|
&__bar {
|
||||||
|
width: 60px;
|
||||||
|
height: 7px;
|
||||||
|
background: #fee2e2;
|
||||||
|
border-radius: 9999px;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
&__fill {
|
||||||
|
display: block;
|
||||||
|
height: 100%;
|
||||||
|
background: linear-gradient(90deg, #10b981, #059669);
|
||||||
|
border-radius: 9999px;
|
||||||
|
}
|
||||||
|
|
||||||
|
&__text {
|
||||||
|
font-size: 12px;
|
||||||
|
font-weight: 700;
|
||||||
|
font-variant-numeric: tabular-nums;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.latency-pill {
|
||||||
|
color: #64748b;
|
||||||
|
font-size: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.card-footer-info {
|
||||||
|
padding: 12px 20px;
|
||||||
|
background: #f8fafc;
|
||||||
|
border-top: 1px solid #e2e8f0;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 4px;
|
||||||
|
font-size: 12px;
|
||||||
|
color: #475569;
|
||||||
|
|
||||||
|
.footer-stat {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
|
||||||
|
fa-icon {
|
||||||
|
color: #3b82f6;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.stat-highlight {
|
||||||
|
color: #0f172a;
|
||||||
|
font-weight: 700;
|
||||||
|
}
|
||||||
|
|
||||||
|
.footer-note {
|
||||||
|
font-size: 11px;
|
||||||
|
color: #94a3b8;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Status Tile List */
|
||||||
|
.status-grid {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 8px;
|
||||||
|
padding: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.status-tile {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
padding: 12px 14px;
|
||||||
|
border-radius: 12px;
|
||||||
|
border: 1px solid #f1f5f9;
|
||||||
|
background: #f8fafc;
|
||||||
|
text-decoration: none;
|
||||||
|
color: inherit;
|
||||||
|
transition: all 0.2s;
|
||||||
|
|
||||||
|
&:hover {
|
||||||
|
background: #fff;
|
||||||
|
border-color: #cbd5e1;
|
||||||
|
box-shadow: 0 4px 10px rgb(15 23 42 / 5%);
|
||||||
|
transform: translateY(-1px);
|
||||||
|
}
|
||||||
|
|
||||||
|
&__left {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
&__hint {
|
||||||
|
font-size: 11px;
|
||||||
|
color: #94a3b8;
|
||||||
|
}
|
||||||
|
|
||||||
|
&__count {
|
||||||
|
font-size: 18px;
|
||||||
|
font-weight: 800;
|
||||||
|
color: #0f172a;
|
||||||
|
font-variant-numeric: tabular-nums;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Configuration Grid */
|
||||||
|
.config-grid {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: 1fr 1fr;
|
||||||
|
gap: 12px;
|
||||||
|
padding: 16px 20px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.config-tile {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 4px;
|
||||||
|
|
||||||
|
&--full {
|
||||||
|
grid-column: 1 / -1;
|
||||||
|
}
|
||||||
|
|
||||||
|
&__label {
|
||||||
|
font-size: 11px;
|
||||||
|
font-weight: 600;
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 0.04em;
|
||||||
|
color: #94a3b8;
|
||||||
|
}
|
||||||
|
|
||||||
|
&__value {
|
||||||
|
font-size: 13px;
|
||||||
|
color: #0f172a;
|
||||||
|
font-weight: 500;
|
||||||
|
}
|
||||||
|
|
||||||
|
.url-val {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
font-family: monospace;
|
||||||
|
font-size: 12px;
|
||||||
|
word-break: break-all;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.config-popup-trigger {
|
||||||
|
margin-left: auto;
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 6px;
|
||||||
|
padding: 3px 10px;
|
||||||
|
border-radius: 8px;
|
||||||
|
background: #f1f5f9;
|
||||||
|
border: 1px solid #cbd5e1;
|
||||||
|
color: #2563eb;
|
||||||
|
font-size: 12px;
|
||||||
|
font-weight: 600;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: all 0.2s;
|
||||||
|
|
||||||
|
&:hover {
|
||||||
|
background: #e2e8f0;
|
||||||
|
color: #1d4ed8;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.crypto-chip {
|
||||||
|
padding: 2px 8px;
|
||||||
|
border-radius: 6px;
|
||||||
|
background: #eff6ff;
|
||||||
|
color: #1e40af;
|
||||||
|
font-size: 11px;
|
||||||
|
font-weight: 700;
|
||||||
|
}
|
||||||
|
|
||||||
|
.copy-btn {
|
||||||
|
padding: 2px 8px;
|
||||||
|
border-radius: 6px;
|
||||||
|
background: #2563eb;
|
||||||
|
color: #fff;
|
||||||
|
border: none;
|
||||||
|
font-size: 10px;
|
||||||
|
font-weight: 600;
|
||||||
|
cursor: pointer;
|
||||||
|
|
||||||
|
&:hover {
|
||||||
|
background: #1d4ed8;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.badge-mini {
|
||||||
|
display: inline-block;
|
||||||
|
padding: 1px 6px;
|
||||||
|
border-radius: 4px;
|
||||||
|
background: #f1f5f9;
|
||||||
|
font-size: 11px;
|
||||||
|
color: #64748b;
|
||||||
|
margin-left: 6px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.highlight-amt {
|
||||||
|
color: #0f172a;
|
||||||
|
font-weight: 700;
|
||||||
|
}
|
||||||
@@ -0,0 +1,126 @@
|
|||||||
|
/**
|
||||||
|
* 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 { DecimalPipe } from '@angular/common';
|
||||||
|
import { RouterLink } from '@angular/router';
|
||||||
|
import { MatButtonModule } from '@angular/material/button';
|
||||||
|
import { MatProgressBarModule } from '@angular/material/progress-bar';
|
||||||
|
import { FaIconComponent } from '@fortawesome/angular-fontawesome';
|
||||||
|
import { I4cService, i4cErrorMessage } from '../i4c.service';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Integration health at a glance, and the API performance figures the RBI letter (05 Dec 2025, para 4) asks to be
|
||||||
|
* reviewed periodically: calls, failures and response times in each direction.
|
||||||
|
*/
|
||||||
|
@Component({
|
||||||
|
selector: 'mifosx-i4c-dashboard',
|
||||||
|
templateUrl: './i4c-dashboard.component.html',
|
||||||
|
styleUrls: ['./i4c-dashboard.component.scss'],
|
||||||
|
imports: [
|
||||||
|
DecimalPipe,
|
||||||
|
RouterLink,
|
||||||
|
MatButtonModule,
|
||||||
|
MatProgressBarModule,
|
||||||
|
FaIconComponent
|
||||||
|
]
|
||||||
|
})
|
||||||
|
export class I4cDashboardComponent implements OnInit {
|
||||||
|
private i4c = inject(I4cService);
|
||||||
|
|
||||||
|
data: any = null;
|
||||||
|
loading = false;
|
||||||
|
error = '';
|
||||||
|
showConfigModal = false;
|
||||||
|
copiedKey = '';
|
||||||
|
|
||||||
|
ngOnInit(): void {
|
||||||
|
this.load();
|
||||||
|
}
|
||||||
|
|
||||||
|
load(): void {
|
||||||
|
this.loading = true;
|
||||||
|
this.error = '';
|
||||||
|
this.i4c.dashboard().subscribe({
|
||||||
|
next: (d) => {
|
||||||
|
this.data = d;
|
||||||
|
this.loading = false;
|
||||||
|
},
|
||||||
|
error: (e) => {
|
||||||
|
this.error = i4cErrorMessage(e);
|
||||||
|
this.loading = false;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
copyText(text: string, key: string): void {
|
||||||
|
if (!text) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
navigator.clipboard.writeText(text);
|
||||||
|
this.copiedKey = key;
|
||||||
|
setTimeout(() => {
|
||||||
|
if (this.copiedKey === key) {
|
||||||
|
this.copiedKey = '';
|
||||||
|
}
|
||||||
|
}, 2000);
|
||||||
|
}
|
||||||
|
|
||||||
|
statusClass(status: string): string {
|
||||||
|
switch (status) {
|
||||||
|
case 'PROCESSED':
|
||||||
|
return 'i4c-badge--green';
|
||||||
|
case 'RETRY':
|
||||||
|
return 'i4c-badge--red';
|
||||||
|
case 'RECEIVED':
|
||||||
|
case 'PROCESSING':
|
||||||
|
return 'i4c-badge--amber';
|
||||||
|
default:
|
||||||
|
return '';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Success rate of calls in one direction, as a percentage; null when there were none. */
|
||||||
|
successRate(row: any): number | null {
|
||||||
|
const calls = Number(row?.calls || 0);
|
||||||
|
return calls === 0 ? null : (Number(row?.succeeded || 0) * 100) / calls;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** What still stands between this bank and live traffic, worked out from the configuration. */
|
||||||
|
get setupGaps(): string[] {
|
||||||
|
const c = this.data?.configuration;
|
||||||
|
if (!c) {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
const gaps: string[] = [];
|
||||||
|
if (!c.enabled) {
|
||||||
|
gaps.push('Integration is switched off (fineract.i4c.enabled=false): I4C calls are refused with 503.');
|
||||||
|
}
|
||||||
|
if (!c.inboundCredentialsConfigured) {
|
||||||
|
gaps.push('No inbound username/password set for I4C to obtain a token.');
|
||||||
|
} else if (!c.inboundPasswordHashed) {
|
||||||
|
gaps.push('Inbound password is configured in plain text; set the bcrypt hash for production.');
|
||||||
|
}
|
||||||
|
if (!c.allowedIpCount) {
|
||||||
|
gaps.push('No I4C source IP allowlist: any address may call (UAT only).');
|
||||||
|
}
|
||||||
|
if (c.cryptoMode !== 'NONE' && !c.cryptoPassphraseConfigured) {
|
||||||
|
gaps.push('Encryption mode is ' + c.cryptoMode + ' but no passphrase is configured.');
|
||||||
|
}
|
||||||
|
if (!c.outboundConfigured) {
|
||||||
|
gaps.push('I4C response URL is not configured: replies wait in the outbox.');
|
||||||
|
}
|
||||||
|
if (!c.bankCode) {
|
||||||
|
gaps.push("This bank's I4C bank code is not set (fineract.i4c.bank-code); root_bankid is sent as 0.");
|
||||||
|
}
|
||||||
|
if (c.simulatorEnabled) {
|
||||||
|
gaps.push('The local I4C simulator is enabled; switch it off in production.');
|
||||||
|
}
|
||||||
|
return gaps;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,22 @@
|
|||||||
|
<!--
|
||||||
|
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/.
|
||||||
|
-->
|
||||||
|
|
||||||
|
<mat-form-field appearance="outline" subscriptSizing="dynamic">
|
||||||
|
<mat-label>{{ label }}</mat-label>
|
||||||
|
<input
|
||||||
|
matInput
|
||||||
|
[matDatepicker]="picker"
|
||||||
|
[value]="date"
|
||||||
|
[max]="maxDate"
|
||||||
|
(dateChange)="onChange($event)"
|
||||||
|
placeholder="dd/mm/yyyy"
|
||||||
|
autocomplete="off"
|
||||||
|
/>
|
||||||
|
<mat-datepicker-toggle matIconSuffix [for]="picker"></mat-datepicker-toggle>
|
||||||
|
<mat-datepicker #picker></mat-datepicker>
|
||||||
|
</mat-form-field>
|
||||||
@@ -0,0 +1,17 @@
|
|||||||
|
/**
|
||||||
|
* 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/.
|
||||||
|
*/
|
||||||
|
|
||||||
|
// Fills the width the parent form gives it; size tokens (field height, text size) are inherited from the parent.
|
||||||
|
:host {
|
||||||
|
display: block;
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
mat-form-field {
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
@@ -0,0 +1,67 @@
|
|||||||
|
/**
|
||||||
|
* 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, Output } from '@angular/core';
|
||||||
|
import { DateAdapter, MAT_DATE_FORMATS } from '@angular/material/core';
|
||||||
|
import { MatDatepickerInputEvent, MatDatepickerModule } from '@angular/material/datepicker';
|
||||||
|
import { MatFormFieldModule } from '@angular/material/form-field';
|
||||||
|
import { MatInputModule } from '@angular/material/input';
|
||||||
|
import { DdsDateAdapter, DDS_DATE_FORMATS } from '../../../organization/dds-batch-processing/dds-date-adapter';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A date field with a calendar picker for the I4C forms. Shown and typed day-first (dd/mm/yyyy); the value in and out is
|
||||||
|
* the yyyy-MM-dd string the I4C APIs take, or '' when empty, so it drops in where a plain date input bound a string.
|
||||||
|
*/
|
||||||
|
@Component({
|
||||||
|
selector: 'mifosx-i4c-date-field',
|
||||||
|
templateUrl: './i4c-date-field.component.html',
|
||||||
|
styleUrls: ['./i4c-date-field.component.scss'],
|
||||||
|
imports: [
|
||||||
|
MatFormFieldModule,
|
||||||
|
MatInputModule,
|
||||||
|
MatDatepickerModule
|
||||||
|
],
|
||||||
|
providers: [
|
||||||
|
{ provide: DateAdapter, useClass: DdsDateAdapter },
|
||||||
|
{ provide: MAT_DATE_FORMATS, useValue: DDS_DATE_FORMATS }
|
||||||
|
]
|
||||||
|
})
|
||||||
|
export class I4cDateFieldComponent {
|
||||||
|
@Input() label = '';
|
||||||
|
/** Latest date that can be picked, yyyy-MM-dd; defaults to none. */
|
||||||
|
@Input() max = '';
|
||||||
|
@Output() valueChange = new EventEmitter<string>();
|
||||||
|
|
||||||
|
date: Date | null = null;
|
||||||
|
|
||||||
|
/** yyyy-MM-dd, or '' for no date. */
|
||||||
|
@Input()
|
||||||
|
set value(value: string | null | undefined) {
|
||||||
|
this.date = parse(value);
|
||||||
|
}
|
||||||
|
|
||||||
|
get maxDate(): Date | null {
|
||||||
|
return parse(this.max);
|
||||||
|
}
|
||||||
|
|
||||||
|
onChange(event: MatDatepickerInputEvent<Date>): void {
|
||||||
|
this.date = event.value;
|
||||||
|
this.valueChange.emit(event.value ? iso(event.value) : '');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function parse(value: string | null | undefined): Date | null {
|
||||||
|
const match = value ? /^(\d{4})-(\d{2})-(\d{2})/.exec(value) : null;
|
||||||
|
return match ? new Date(Number(match[1]), Number(match[2]) - 1, Number(match[3])) : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** yyyy-MM-dd in the browser's own time zone (toISOString would shift to UTC). */
|
||||||
|
function iso(d: Date): string {
|
||||||
|
const pad = (n: number) => (n < 10 ? '0' + n : '' + n);
|
||||||
|
return `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())}`;
|
||||||
|
}
|
||||||
@@ -0,0 +1,49 @@
|
|||||||
|
<!--
|
||||||
|
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="date-range" role="group" [attr.aria-label]="label + ' filter'">
|
||||||
|
<span class="date-range__label"><fa-icon icon="calendar-alt"></fa-icon> {{ label }}</span>
|
||||||
|
<div class="date-range__presets">
|
||||||
|
@for (p of presets; track p.value) {
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="date-chip"
|
||||||
|
[class.date-chip--active]="range.preset === p.value"
|
||||||
|
[attr.aria-pressed]="range.preset === p.value"
|
||||||
|
(click)="pick(p.value)"
|
||||||
|
>
|
||||||
|
{{ p.label }}
|
||||||
|
</button>
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
@if (range.preset !== 'ALL') {
|
||||||
|
<div class="date-range__custom">
|
||||||
|
<label>
|
||||||
|
From
|
||||||
|
<input
|
||||||
|
type="date"
|
||||||
|
[max]="range.toDate || today"
|
||||||
|
[(ngModel)]="range.fromDate"
|
||||||
|
(change)="customChanged()"
|
||||||
|
[attr.aria-label]="label + ' from'"
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
<label>
|
||||||
|
To
|
||||||
|
<input
|
||||||
|
type="date"
|
||||||
|
[min]="range.fromDate"
|
||||||
|
[max]="today"
|
||||||
|
[(ngModel)]="range.toDate"
|
||||||
|
(change)="customChanged()"
|
||||||
|
[attr.aria-label]="label + ' to'"
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
</div>
|
||||||
@@ -0,0 +1,95 @@
|
|||||||
|
/**
|
||||||
|
* 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;
|
||||||
|
}
|
||||||
|
|
||||||
|
.date-range {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px 12px;
|
||||||
|
|
||||||
|
&__label {
|
||||||
|
font-size: 12px;
|
||||||
|
font-weight: 600;
|
||||||
|
color: #475569;
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 6px;
|
||||||
|
}
|
||||||
|
|
||||||
|
&__presets {
|
||||||
|
display: inline-flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: 4px;
|
||||||
|
padding: 3px;
|
||||||
|
border-radius: 999px;
|
||||||
|
background: #f1f5f9;
|
||||||
|
}
|
||||||
|
|
||||||
|
&__custom {
|
||||||
|
display: inline-flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: 8px;
|
||||||
|
|
||||||
|
label {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 6px;
|
||||||
|
font-size: 12px;
|
||||||
|
color: #475569;
|
||||||
|
}
|
||||||
|
|
||||||
|
input {
|
||||||
|
height: 32px;
|
||||||
|
padding: 0 8px;
|
||||||
|
border: 1px solid #cbd5e1;
|
||||||
|
border-radius: 8px;
|
||||||
|
font: inherit;
|
||||||
|
font-size: 13px;
|
||||||
|
color: #0f172a;
|
||||||
|
background: #fff;
|
||||||
|
|
||||||
|
&:focus-visible {
|
||||||
|
outline: 2px solid #2563eb;
|
||||||
|
outline-offset: 1px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.date-chip {
|
||||||
|
border: 0;
|
||||||
|
background: transparent;
|
||||||
|
border-radius: 999px;
|
||||||
|
padding: 4px 12px;
|
||||||
|
font-size: 12px;
|
||||||
|
font-weight: 600;
|
||||||
|
color: #475569;
|
||||||
|
cursor: pointer;
|
||||||
|
|
||||||
|
&:hover {
|
||||||
|
color: #1d4ed8;
|
||||||
|
}
|
||||||
|
|
||||||
|
&:focus-visible {
|
||||||
|
outline: 2px solid #2563eb;
|
||||||
|
outline-offset: 1px;
|
||||||
|
}
|
||||||
|
|
||||||
|
&--active {
|
||||||
|
background: #2563eb;
|
||||||
|
color: #fff;
|
||||||
|
|
||||||
|
&:hover {
|
||||||
|
color: #fff;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,102 @@
|
|||||||
|
/**
|
||||||
|
* 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, Output } from '@angular/core';
|
||||||
|
import { FormsModule } from '@angular/forms';
|
||||||
|
import { FaIconComponent } from '@fortawesome/angular-fontawesome';
|
||||||
|
|
||||||
|
export type I4cDatePreset = 'ALL' | 'TODAY' | 'YESTERDAY' | 'WEEK' | 'MONTH' | 'CUSTOM';
|
||||||
|
|
||||||
|
/** A date range as the list APIs take it: yyyy-MM-dd, both days included, empty for "no limit". */
|
||||||
|
export interface I4cDateRange {
|
||||||
|
preset: I4cDatePreset;
|
||||||
|
fromDate: string;
|
||||||
|
toDate: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const ALL_DATES: I4cDateRange = { preset: 'ALL', fromDate: '', toDate: '' };
|
||||||
|
|
||||||
|
/** yyyy-MM-dd in the browser's own time zone (toISOString would shift to UTC). */
|
||||||
|
function iso(d: Date): string {
|
||||||
|
const pad = (n: number) => String(n).padStart(2, '0');
|
||||||
|
return `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The days a preset stands for, worked out from today. */
|
||||||
|
export function presetRange(preset: I4cDatePreset): I4cDateRange {
|
||||||
|
const today = new Date();
|
||||||
|
const day = (offset: number) => new Date(today.getFullYear(), today.getMonth(), today.getDate() + offset);
|
||||||
|
switch (preset) {
|
||||||
|
case 'TODAY':
|
||||||
|
return { preset, fromDate: iso(today), toDate: iso(today) };
|
||||||
|
case 'YESTERDAY':
|
||||||
|
return { preset, fromDate: iso(day(-1)), toDate: iso(day(-1)) };
|
||||||
|
case 'WEEK': {
|
||||||
|
// The week starts on Monday.
|
||||||
|
const sinceMonday = (today.getDay() + 6) % 7;
|
||||||
|
return { preset, fromDate: iso(day(-sinceMonday)), toDate: iso(today) };
|
||||||
|
}
|
||||||
|
case 'MONTH':
|
||||||
|
return { preset, fromDate: iso(new Date(today.getFullYear(), today.getMonth(), 1)), toDate: iso(today) };
|
||||||
|
default:
|
||||||
|
return { ...ALL_DATES };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Date filter shared by the I4C lists: All, Today, Yesterday, This Week, This Month, or a custom From / To. Emits the
|
||||||
|
* new range whenever it changes; the page then reloads from page one.
|
||||||
|
*/
|
||||||
|
@Component({
|
||||||
|
selector: 'mifosx-i4c-date-range',
|
||||||
|
templateUrl: './i4c-date-range.component.html',
|
||||||
|
styleUrls: ['./i4c-date-range.component.scss'],
|
||||||
|
imports: [
|
||||||
|
FormsModule,
|
||||||
|
FaIconComponent
|
||||||
|
]
|
||||||
|
})
|
||||||
|
export class I4cDateRangeComponent {
|
||||||
|
@Input() label = 'Date';
|
||||||
|
@Input() range: I4cDateRange = { ...ALL_DATES };
|
||||||
|
@Output() rangeChange = new EventEmitter<I4cDateRange>();
|
||||||
|
|
||||||
|
readonly presets: { value: I4cDatePreset; label: string }[] = [
|
||||||
|
{ value: 'ALL', label: 'All' },
|
||||||
|
{ value: 'TODAY', label: 'Today' },
|
||||||
|
{ value: 'YESTERDAY', label: 'Yesterday' },
|
||||||
|
{ value: 'WEEK', label: 'This Week' },
|
||||||
|
{ value: 'MONTH', label: 'This Month' },
|
||||||
|
{ value: 'CUSTOM', label: 'Custom' }
|
||||||
|
];
|
||||||
|
|
||||||
|
readonly today = iso(new Date());
|
||||||
|
|
||||||
|
pick(preset: I4cDatePreset): void {
|
||||||
|
if (preset === 'CUSTOM') {
|
||||||
|
// Keep the days already shown so the officer can adjust them.
|
||||||
|
this.range = { ...this.range, preset };
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
this.emit(presetRange(preset));
|
||||||
|
}
|
||||||
|
|
||||||
|
customChanged(): void {
|
||||||
|
const { fromDate, toDate } = this.range;
|
||||||
|
if (fromDate && toDate && toDate < fromDate) {
|
||||||
|
// A reversed range would match nothing; read it the way it was meant.
|
||||||
|
this.range = { preset: 'CUSTOM', fromDate: toDate, toDate: fromDate };
|
||||||
|
}
|
||||||
|
this.emit({ ...this.range, preset: 'CUSTOM' });
|
||||||
|
}
|
||||||
|
|
||||||
|
private emit(range: I4cDateRange): void {
|
||||||
|
this.range = range;
|
||||||
|
this.rangeChange.emit(range);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,152 @@
|
|||||||
|
<!--
|
||||||
|
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="docs">
|
||||||
|
@if (missing.length) {
|
||||||
|
<div class="docs__missing">
|
||||||
|
<div class="docs__missing-head">
|
||||||
|
<fa-icon icon="exclamation-triangle"></fa-icon>
|
||||||
|
<span>Mandatory Documents Required (MHA SOP):</span>
|
||||||
|
</div>
|
||||||
|
<div class="docs__missing-chips">
|
||||||
|
@for (m of missing; track m) {
|
||||||
|
<span class="missing-chip">{{ label(m) }}</span>
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
|
||||||
|
<div class="docs__table-wrap">
|
||||||
|
<table class="i4c-table docs-table">
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th>Document Type</th>
|
||||||
|
<th>File Details</th>
|
||||||
|
<th>Remarks</th>
|
||||||
|
<th>Uploaded Info</th>
|
||||||
|
<th class="action-col"></th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
@for (d of rows; track d.id) {
|
||||||
|
<tr>
|
||||||
|
<td>
|
||||||
|
<span class="doc-type-badge">{{ label(d.doc_type) }}</span>
|
||||||
|
</td>
|
||||||
|
<td>
|
||||||
|
<div class="doc-file-info">
|
||||||
|
<span class="docs__name">{{ d.file_name }}</span>
|
||||||
|
<div class="docs__sub" [title]="'SHA-256: ' + d.sha256">
|
||||||
|
<span class="doc-size">{{ size(d.size_bytes) }}</span>
|
||||||
|
<span class="doc-hash-pill"><fa-icon icon="shield-alt"></fa-icon> {{ d.sha256.slice(0, 10) }}…</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
<td>
|
||||||
|
<span class="doc-remarks">{{ d.remarks || '-' }}</span>
|
||||||
|
</td>
|
||||||
|
<td>
|
||||||
|
<div class="doc-upload-meta">
|
||||||
|
<span class="doc-upload-date">{{ d.uploaded_at | date: 'dd MMM yyyy, HH:mm' }}</span>
|
||||||
|
<span class="doc-upload-by">by {{ d.uploaded_by }}</span>
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
<td class="action-col">
|
||||||
|
<button class="doc-download-btn" type="button" (click)="download(d)">
|
||||||
|
<fa-icon icon="download"></fa-icon> Download
|
||||||
|
</button>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
} @empty {
|
||||||
|
<tr>
|
||||||
|
<td colspan="5" class="docs-empty-row">
|
||||||
|
<div class="docs-empty-state">
|
||||||
|
<div class="docs-empty-state__icon">
|
||||||
|
<fa-icon icon="folder-open"></fa-icon>
|
||||||
|
</div>
|
||||||
|
<div class="docs-empty-state__title">No documents attached yet</div>
|
||||||
|
<div class="docs-empty-state__desc">
|
||||||
|
Upload police intimations, IO notices, court orders (freeze/unfreeze), or grievance evidence below.
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
@if (canUpload) {
|
||||||
|
<div class="docs__upload-card">
|
||||||
|
<div class="docs__upload-head">
|
||||||
|
<div class="docs__upload-title">
|
||||||
|
<fa-icon icon="cloud-upload-alt"></fa-icon>
|
||||||
|
<span>Attach Case Document</span>
|
||||||
|
</div>
|
||||||
|
<span class="docs__upload-badge">Secure Vault</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="docs__upload-body">
|
||||||
|
<div class="docs__upload-row">
|
||||||
|
<mat-form-field appearance="outline" subscriptSizing="dynamic" class="docs__type-field">
|
||||||
|
<mat-label>Document Type</mat-label>
|
||||||
|
<mat-select [(ngModel)]="docType">
|
||||||
|
@for (t of types; track t.value) {
|
||||||
|
<mat-option [value]="t.value">{{ t.label }}</mat-option>
|
||||||
|
}
|
||||||
|
</mat-select>
|
||||||
|
</mat-form-field>
|
||||||
|
|
||||||
|
<mat-form-field appearance="outline" subscriptSizing="dynamic" class="docs__remarks-field">
|
||||||
|
<mat-label>Remarks / Reference (Order no., Court, Date)</mat-label>
|
||||||
|
<input matInput [(ngModel)]="remarks" maxlength="500" placeholder="e.g. IO Order Ref #1234, FIR #56/26" />
|
||||||
|
</mat-form-field>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="docs__upload-action-row">
|
||||||
|
<input
|
||||||
|
#fileInput
|
||||||
|
type="file"
|
||||||
|
accept="application/pdf,image/jpeg,image/png"
|
||||||
|
(change)="pick($event)"
|
||||||
|
class="hidden-file-input"
|
||||||
|
/>
|
||||||
|
|
||||||
|
@if (!file) {
|
||||||
|
<button type="button" class="file-picker-trigger" (click)="fileInput.click()">
|
||||||
|
<fa-icon icon="paperclip"></fa-icon>
|
||||||
|
<span>Choose File (PDF, JPEG, PNG)</span>
|
||||||
|
</button>
|
||||||
|
} @else {
|
||||||
|
<div class="file-selected-chip">
|
||||||
|
<fa-icon icon="file-alt"></fa-icon>
|
||||||
|
<span class="file-selected-name" [title]="file.name">{{ file.name }}</span>
|
||||||
|
<span class="file-selected-size">({{ size(file.size) }})</span>
|
||||||
|
<button type="button" class="file-remove-btn" (click)="clearFile(fileInput)" title="Remove file">
|
||||||
|
<fa-icon icon="times"></fa-icon>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
|
||||||
|
<button class="doc-submit-btn" type="button" (click)="upload(fileInput)" [disabled]="uploading || !file">
|
||||||
|
<fa-icon icon="upload"></fa-icon>
|
||||||
|
<span>{{ uploading ? 'Uploading…' : 'Attach Document' }}</span>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="docs__security-footer">
|
||||||
|
<fa-icon icon="shield-alt"></fa-icon>
|
||||||
|
<span
|
||||||
|
>Maximum size 5 MB. Files are stored immutably with SHA-256 cryptographic hashing for regulatory
|
||||||
|
audit.</span
|
||||||
|
>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
</div>
|
||||||
@@ -0,0 +1,436 @@
|
|||||||
|
/**
|
||||||
|
* 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/.
|
||||||
|
*/
|
||||||
|
|
||||||
|
@use '../i4c' as *;
|
||||||
|
|
||||||
|
@include i4c-skin;
|
||||||
|
|
||||||
|
:host {
|
||||||
|
display: block;
|
||||||
|
}
|
||||||
|
|
||||||
|
.docs {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 12px;
|
||||||
|
|
||||||
|
/* Alert banner for required documents */
|
||||||
|
&__missing {
|
||||||
|
padding: 9px 12px;
|
||||||
|
border-radius: 8px;
|
||||||
|
border: 1px solid #fde68a;
|
||||||
|
background: #fffbeb;
|
||||||
|
color: #92400e;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 6px;
|
||||||
|
|
||||||
|
&-head {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 6px;
|
||||||
|
font-size: 11.5px;
|
||||||
|
font-weight: 700;
|
||||||
|
|
||||||
|
fa-icon {
|
||||||
|
color: #d97706;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
&-chips {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: 5px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.missing-chip {
|
||||||
|
padding: 2px 7px;
|
||||||
|
background: #fef3c7;
|
||||||
|
border: 1px solid #fcd34d;
|
||||||
|
border-radius: 4px;
|
||||||
|
font-size: 10.5px;
|
||||||
|
font-weight: 600;
|
||||||
|
color: #78350f;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Modern Documents Table */
|
||||||
|
&__table-wrap {
|
||||||
|
border: 1px solid #e2e8f0;
|
||||||
|
border-radius: 8px;
|
||||||
|
overflow: hidden;
|
||||||
|
background: #fff;
|
||||||
|
}
|
||||||
|
|
||||||
|
.docs-table {
|
||||||
|
width: 100%;
|
||||||
|
margin: 0;
|
||||||
|
border: none;
|
||||||
|
|
||||||
|
thead th {
|
||||||
|
background: #f8fafc;
|
||||||
|
color: #475569;
|
||||||
|
font-size: 10.5px;
|
||||||
|
font-weight: 700;
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 0.04em;
|
||||||
|
padding: 8px 12px;
|
||||||
|
border-bottom: 1px solid #e2e8f0;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
tbody td {
|
||||||
|
padding: 9px 12px;
|
||||||
|
border-bottom: 1px solid #f1f5f9;
|
||||||
|
font-size: 11.5px;
|
||||||
|
vertical-align: middle;
|
||||||
|
}
|
||||||
|
|
||||||
|
tbody tr:last-child td {
|
||||||
|
border-bottom: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
tbody tr:hover:not(.docs-empty-row) {
|
||||||
|
background: #f8fafc;
|
||||||
|
}
|
||||||
|
|
||||||
|
.action-col {
|
||||||
|
text-align: right;
|
||||||
|
width: 110px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.doc-type-badge {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
padding: 2px 7px;
|
||||||
|
border-radius: 4px;
|
||||||
|
background: #eff6ff;
|
||||||
|
color: #2563eb;
|
||||||
|
border: 1px solid #bfdbfe;
|
||||||
|
font-size: 10px;
|
||||||
|
font-weight: 700;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.doc-file-info {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 2px;
|
||||||
|
}
|
||||||
|
|
||||||
|
&__name {
|
||||||
|
font-weight: 600;
|
||||||
|
color: #0f172a;
|
||||||
|
font-size: 11.5px;
|
||||||
|
overflow-wrap: anywhere;
|
||||||
|
}
|
||||||
|
|
||||||
|
&__sub {
|
||||||
|
font-size: 10.5px;
|
||||||
|
color: #64748b;
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 6px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.doc-size {
|
||||||
|
font-weight: 500;
|
||||||
|
}
|
||||||
|
|
||||||
|
.doc-hash-pill {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 3px;
|
||||||
|
padding: 1px 4px;
|
||||||
|
background: #f1f5f9;
|
||||||
|
border-radius: 4px;
|
||||||
|
font-family: monospace;
|
||||||
|
font-size: 10px;
|
||||||
|
color: #475569;
|
||||||
|
|
||||||
|
fa-icon {
|
||||||
|
font-size: 9px;
|
||||||
|
color: #64748b;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.doc-remarks {
|
||||||
|
color: #334155;
|
||||||
|
font-size: 11px;
|
||||||
|
max-width: 220px;
|
||||||
|
overflow-wrap: anywhere;
|
||||||
|
}
|
||||||
|
|
||||||
|
.doc-upload-meta {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 1px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.doc-upload-date {
|
||||||
|
color: #0f172a;
|
||||||
|
font-weight: 600;
|
||||||
|
font-size: 11px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.doc-upload-by {
|
||||||
|
color: #64748b;
|
||||||
|
font-size: 10.5px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.doc-download-btn {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
gap: 5px;
|
||||||
|
height: 26px;
|
||||||
|
padding: 0 9px;
|
||||||
|
border-radius: 6px;
|
||||||
|
background: #f8fafc;
|
||||||
|
border: 1px solid #cbd5e1;
|
||||||
|
color: #334155;
|
||||||
|
font-size: 11px;
|
||||||
|
font-weight: 600;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: all 0.15s ease;
|
||||||
|
|
||||||
|
&:hover {
|
||||||
|
background: #eff6ff;
|
||||||
|
border-color: #93c5fd;
|
||||||
|
color: #1d4ed8;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Clean Empty State */
|
||||||
|
.docs-empty-state {
|
||||||
|
text-align: center;
|
||||||
|
padding: 22px 14px;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
|
||||||
|
&__icon {
|
||||||
|
width: 40px;
|
||||||
|
height: 40px;
|
||||||
|
border-radius: 10px;
|
||||||
|
background: #f1f5f9;
|
||||||
|
color: #94a3b8;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
font-size: 18px;
|
||||||
|
margin-bottom: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
&__title {
|
||||||
|
font-size: 12px;
|
||||||
|
font-weight: 700;
|
||||||
|
color: #334155;
|
||||||
|
}
|
||||||
|
|
||||||
|
&__desc {
|
||||||
|
font-size: 11px;
|
||||||
|
color: #64748b;
|
||||||
|
max-width: 420px;
|
||||||
|
margin-top: 3px;
|
||||||
|
line-height: 1.4;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Modern Upload Card */
|
||||||
|
&__upload-card {
|
||||||
|
background: #f8fafc;
|
||||||
|
border: 1px solid #e2e8f0;
|
||||||
|
border-radius: 10px;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
&__upload-head {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
padding: 8px 12px;
|
||||||
|
background: #fff;
|
||||||
|
border-bottom: 1px solid #e2e8f0;
|
||||||
|
}
|
||||||
|
|
||||||
|
&__upload-title {
|
||||||
|
font-size: 11.5px;
|
||||||
|
font-weight: 700;
|
||||||
|
color: #0f172a;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 6px;
|
||||||
|
|
||||||
|
fa-icon {
|
||||||
|
color: #2563eb;
|
||||||
|
font-size: 12px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
&__upload-badge {
|
||||||
|
font-size: 9.5px;
|
||||||
|
font-weight: 700;
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 0.03em;
|
||||||
|
background: #eff6ff;
|
||||||
|
color: #2563eb;
|
||||||
|
padding: 1px 6px;
|
||||||
|
border-radius: 9999px;
|
||||||
|
}
|
||||||
|
|
||||||
|
&__upload-body {
|
||||||
|
padding: 10px 12px;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
&__upload-row {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: 200px 1fr;
|
||||||
|
gap: 8px;
|
||||||
|
|
||||||
|
@media (width <= 640px) {
|
||||||
|
grid-template-columns: 1fr;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
&__upload-action-row {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.hidden-file-input {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Custom Styled File Picker Button */
|
||||||
|
.file-picker-trigger {
|
||||||
|
height: 32px;
|
||||||
|
padding: 0 12px;
|
||||||
|
border-radius: 6px;
|
||||||
|
border: 1px dashed #cbd5e1;
|
||||||
|
background: #fff;
|
||||||
|
color: #334155;
|
||||||
|
font-size: 11.5px;
|
||||||
|
font-weight: 600;
|
||||||
|
cursor: pointer;
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 6px;
|
||||||
|
transition: all 0.2s ease;
|
||||||
|
|
||||||
|
fa-icon {
|
||||||
|
color: #64748b;
|
||||||
|
font-size: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
&:hover {
|
||||||
|
border-color: #2563eb;
|
||||||
|
color: #2563eb;
|
||||||
|
background: #eff6ff;
|
||||||
|
|
||||||
|
fa-icon {
|
||||||
|
color: #2563eb;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.file-selected-chip {
|
||||||
|
height: 32px;
|
||||||
|
padding: 0 8px 0 10px;
|
||||||
|
border-radius: 6px;
|
||||||
|
border: 1px solid #bfdbfe;
|
||||||
|
background: #eff6ff;
|
||||||
|
color: #1e40af;
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 6px;
|
||||||
|
font-size: 11.5px;
|
||||||
|
|
||||||
|
fa-icon {
|
||||||
|
color: #2563eb;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.file-selected-name {
|
||||||
|
font-weight: 600;
|
||||||
|
max-width: 220px;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.file-selected-size {
|
||||||
|
font-size: 10.5px;
|
||||||
|
color: #64748b;
|
||||||
|
}
|
||||||
|
|
||||||
|
.file-remove-btn {
|
||||||
|
border: none;
|
||||||
|
background: transparent;
|
||||||
|
color: #dc2626;
|
||||||
|
cursor: pointer;
|
||||||
|
padding: 2px 4px;
|
||||||
|
border-radius: 4px;
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
transition: background 0.15s ease;
|
||||||
|
|
||||||
|
&:hover {
|
||||||
|
background: rgb(220 38 38 / 12%);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.doc-submit-btn {
|
||||||
|
height: 32px;
|
||||||
|
padding: 0 14px;
|
||||||
|
border-radius: 6px;
|
||||||
|
background: #2563eb;
|
||||||
|
color: #fff;
|
||||||
|
font-size: 11.5px;
|
||||||
|
font-weight: 600;
|
||||||
|
border: 1px solid transparent;
|
||||||
|
cursor: pointer;
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 6px;
|
||||||
|
box-shadow: 0 1px 2px rgb(37 99 235 / 20%);
|
||||||
|
transition: all 0.18s cubic-bezier(0.16, 1, 0.3, 1);
|
||||||
|
user-select: none;
|
||||||
|
|
||||||
|
&:disabled {
|
||||||
|
opacity: 0.5;
|
||||||
|
cursor: not-allowed;
|
||||||
|
}
|
||||||
|
|
||||||
|
&:hover:not(:disabled) {
|
||||||
|
background: #1d4ed8;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
&__security-footer {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 5px;
|
||||||
|
font-size: 10.5px;
|
||||||
|
color: #64748b;
|
||||||
|
margin-top: 2px;
|
||||||
|
|
||||||
|
fa-icon {
|
||||||
|
color: #059669;
|
||||||
|
font-size: 10px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,179 @@
|
|||||||
|
/**
|
||||||
|
* Copyright since 2025 Mifos Initiative
|
||||||
|
*
|
||||||
|
* This Source Code Form is subject to the terms of the Mozilla Public
|
||||||
|
* License, v. 2.0. If a copy of the MPL was not distributed with this
|
||||||
|
* file, You can obtain one at http://mozilla.org/MPL/2.0/.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { Component, EventEmitter, Input, OnChanges, Output, inject } from '@angular/core';
|
||||||
|
import { DatePipe } from '@angular/common';
|
||||||
|
import { FormsModule } from '@angular/forms';
|
||||||
|
import { MatButtonModule } from '@angular/material/button';
|
||||||
|
import { MatFormFieldModule } from '@angular/material/form-field';
|
||||||
|
import { MatInputModule } from '@angular/material/input';
|
||||||
|
import { MatSelectModule } from '@angular/material/select';
|
||||||
|
import { MatSnackBar } from '@angular/material/snack-bar';
|
||||||
|
import { FaIconComponent } from '@fortawesome/angular-fontawesome';
|
||||||
|
import { DOCUMENT_TYPES, I4cDocument, I4cDocumentOwner, I4cService, i4cErrorMessage, saveBlob } from '../i4c.service';
|
||||||
|
|
||||||
|
const MAX_BYTES = 5 * 1024 * 1024;
|
||||||
|
const ACCEPTED = [
|
||||||
|
'application/pdf',
|
||||||
|
'image/jpeg',
|
||||||
|
'image/png'
|
||||||
|
];
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Documents behind a hold, grievance or restoration: list, download, and upload (PDF / JPEG / PNG up to 5 MB). Shows
|
||||||
|
* which of the {@link required} types are still missing.
|
||||||
|
*/
|
||||||
|
@Component({
|
||||||
|
selector: 'mifosx-i4c-documents',
|
||||||
|
templateUrl: './i4c-documents.component.html',
|
||||||
|
styleUrls: ['./i4c-documents.component.scss'],
|
||||||
|
imports: [
|
||||||
|
DatePipe,
|
||||||
|
FormsModule,
|
||||||
|
MatButtonModule,
|
||||||
|
MatFormFieldModule,
|
||||||
|
MatInputModule,
|
||||||
|
MatSelectModule,
|
||||||
|
FaIconComponent
|
||||||
|
]
|
||||||
|
})
|
||||||
|
export class I4cDocumentsComponent implements OnChanges {
|
||||||
|
private i4c = inject(I4cService);
|
||||||
|
private snackBar = inject(MatSnackBar);
|
||||||
|
|
||||||
|
@Input({ required: true }) ownerType!: I4cDocumentOwner;
|
||||||
|
@Input({ required: true }) ownerId!: number;
|
||||||
|
/** Document types this record needs; missing ones are flagged. */
|
||||||
|
@Input() required: string[] = [];
|
||||||
|
/** Types offered first in the picker. */
|
||||||
|
@Input() suggested: string[] = [];
|
||||||
|
@Input() canUpload = true;
|
||||||
|
/** Documents already loaded with the record, to avoid a second call. */
|
||||||
|
@Input() documents: I4cDocument[] | null = null;
|
||||||
|
@Output() changed = new EventEmitter<void>();
|
||||||
|
|
||||||
|
rows: I4cDocument[] = [];
|
||||||
|
docType = '';
|
||||||
|
remarks = '';
|
||||||
|
file: File | null = null;
|
||||||
|
uploading = false;
|
||||||
|
|
||||||
|
ngOnChanges(): void {
|
||||||
|
if (this.documents) {
|
||||||
|
this.rows = this.documents;
|
||||||
|
} else {
|
||||||
|
this.reload();
|
||||||
|
}
|
||||||
|
if (!this.docType) {
|
||||||
|
this.docType = this.suggested[0] || this.missing[0] || 'OTHER';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
get types(): { value: string; label: string }[] {
|
||||||
|
const first = DOCUMENT_TYPES.filter((t) => this.suggested.includes(t.value) || this.required.includes(t.value));
|
||||||
|
return [
|
||||||
|
...first,
|
||||||
|
...DOCUMENT_TYPES.filter((t) => !first.includes(t))
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
get missing(): string[] {
|
||||||
|
return this.required.filter((t) => !this.rows.some((d) => d.doc_type === t));
|
||||||
|
}
|
||||||
|
|
||||||
|
label(type: string): string {
|
||||||
|
return DOCUMENT_TYPES.find((t) => t.value === type)?.label || type;
|
||||||
|
}
|
||||||
|
|
||||||
|
reload(): void {
|
||||||
|
if (!this.ownerId) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
this.i4c.documents(this.ownerType, this.ownerId).subscribe({
|
||||||
|
next: (rows) => (this.rows = rows),
|
||||||
|
error: (e) => this.snackBar.open(i4cErrorMessage(e), 'Close', { duration: 6000 })
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
pick(event: Event): void {
|
||||||
|
const input = event.target as HTMLInputElement;
|
||||||
|
const f = input.files?.[0] || null;
|
||||||
|
if (f && !ACCEPTED.includes(f.type)) {
|
||||||
|
this.snackBar.open('Only PDF, JPEG and PNG files can be attached.', 'Close', { duration: 5000 });
|
||||||
|
input.value = '';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (f && f.size > MAX_BYTES) {
|
||||||
|
this.snackBar.open('A document can be at most 5 MB.', 'Close', { duration: 5000 });
|
||||||
|
input.value = '';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
this.file = f;
|
||||||
|
}
|
||||||
|
|
||||||
|
clearFile(input: HTMLInputElement): void {
|
||||||
|
this.file = null;
|
||||||
|
input.value = '';
|
||||||
|
}
|
||||||
|
|
||||||
|
upload(input: HTMLInputElement): void {
|
||||||
|
if (!this.file || !this.docType) {
|
||||||
|
this.snackBar.open('Choose the document type and the file.', 'Close', { duration: 4000 });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const file = this.file;
|
||||||
|
this.uploading = true;
|
||||||
|
const reader = new FileReader();
|
||||||
|
reader.onload = () => {
|
||||||
|
const dataUrl = String(reader.result || '');
|
||||||
|
const content = dataUrl.substring(dataUrl.indexOf(',') + 1);
|
||||||
|
this.i4c
|
||||||
|
.uploadDocument({
|
||||||
|
ownerType: this.ownerType,
|
||||||
|
ownerId: this.ownerId,
|
||||||
|
docType: this.docType,
|
||||||
|
fileName: file.name,
|
||||||
|
contentType: file.type,
|
||||||
|
content,
|
||||||
|
remarks: this.remarks.trim() || undefined
|
||||||
|
})
|
||||||
|
.subscribe({
|
||||||
|
next: () => {
|
||||||
|
this.uploading = false;
|
||||||
|
this.file = null;
|
||||||
|
this.remarks = '';
|
||||||
|
input.value = '';
|
||||||
|
this.snackBar.open(this.label(this.docType) + ' attached.', 'OK', { duration: 3000 });
|
||||||
|
this.documents = null;
|
||||||
|
this.reload();
|
||||||
|
this.changed.emit();
|
||||||
|
},
|
||||||
|
error: (e) => {
|
||||||
|
this.uploading = false;
|
||||||
|
this.snackBar.open(i4cErrorMessage(e), 'Close', { duration: 6000 });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
};
|
||||||
|
reader.onerror = () => {
|
||||||
|
this.uploading = false;
|
||||||
|
this.snackBar.open('The file could not be read.', 'Close', { duration: 5000 });
|
||||||
|
};
|
||||||
|
reader.readAsDataURL(file);
|
||||||
|
}
|
||||||
|
|
||||||
|
download(d: I4cDocument): void {
|
||||||
|
this.i4c.downloadDocument(d.id).subscribe({
|
||||||
|
next: (blob) => saveBlob(blob, d.file_name),
|
||||||
|
error: (e) => this.snackBar.open(i4cErrorMessage(e), 'Close', { duration: 6000 })
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
size(bytes: number): string {
|
||||||
|
return bytes < 1024 * 1024 ? Math.ceil(bytes / 1024) + ' KB' : (bytes / 1024 / 1024).toFixed(1) + ' MB';
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,570 @@
|
|||||||
|
<!--
|
||||||
|
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="i4c-page">
|
||||||
|
<div class="i4c-toolbar">
|
||||||
|
<div class="i4c-filter-chips">
|
||||||
|
<button class="filter-chip" [class.filter-chip--active]="stage === 'OPEN'" (click)="setStage('OPEN')">
|
||||||
|
<span>All open</span>
|
||||||
|
</button>
|
||||||
|
@for (s of stages; track s.value) {
|
||||||
|
<button class="filter-chip" [class.filter-chip--active]="stage === s.value" (click)="setStage(s.value)">
|
||||||
|
<span>{{ s.label }}</span>
|
||||||
|
</button>
|
||||||
|
}
|
||||||
|
<button class="filter-chip" [class.filter-chip--active]="stage === ''" (click)="setStage('')">
|
||||||
|
<span>All</span>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<span class="i4c-toolbar__spacer"></span>
|
||||||
|
<span class="count-tag">{{ total }} grievances</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="i4c-filter-bar">
|
||||||
|
<mat-form-field appearance="outline" subscriptSizing="dynamic" class="search-input">
|
||||||
|
<mat-label>GR id / account / ack no. / customer</mat-label>
|
||||||
|
<input matInput [(ngModel)]="search" (keyup.enter)="load(true)" (blur)="load(true)" />
|
||||||
|
</mat-form-field>
|
||||||
|
<mat-form-field appearance="outline" subscriptSizing="dynamic">
|
||||||
|
<mat-label>Outcome</mat-label>
|
||||||
|
<mat-select [(ngModel)]="status" (selectionChange)="load(true)">
|
||||||
|
<mat-option value="">Any</mat-option>
|
||||||
|
<mat-option value="OPEN">Pending decision</mat-option>
|
||||||
|
<mat-option value="ALLOWED">Allowed (order given)</mat-option>
|
||||||
|
<mat-option value="REJECTED">Rejected</mat-option>
|
||||||
|
<mat-option value="COURT">Left to the court</mat-option>
|
||||||
|
<mat-option value="WITHDRAWN">Withdrawn</mat-option>
|
||||||
|
</mat-select>
|
||||||
|
</mat-form-field>
|
||||||
|
<mat-checkbox [(ngModel)]="overdueOnly" (change)="load(true)">Past deadline only</mat-checkbox>
|
||||||
|
<mifosx-i4c-date-range label="Raised" [range]="dates" (rangeChange)="onDates($event)"></mifosx-i4c-date-range>
|
||||||
|
<button mat-stroked-button class="i4c-filter-bar__clear" (click)="clearFilters()" [disabled]="!hasFilters">
|
||||||
|
<fa-icon icon="times"></fa-icon> Clear filters
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
@if (loading) {
|
||||||
|
<mat-progress-bar mode="indeterminate"></mat-progress-bar>
|
||||||
|
}
|
||||||
|
@if (error) {
|
||||||
|
<div class="i4c-error">
|
||||||
|
<fa-icon icon="exclamation-circle"></fa-icon> <span>{{ error }}</span>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
|
||||||
|
<div class="i4c-card">
|
||||||
|
<div class="i4c-card__head">
|
||||||
|
<fa-icon icon="balance-scale"></fa-icon>
|
||||||
|
<span>GRM Grievances (NCRP Grievance Redressal Module)</span>
|
||||||
|
<span class="head-hint"
|
||||||
|
>Raise a grievance from the hold (Holds Register → hold → GRM / MRM). Click a row to work on it.</span
|
||||||
|
>
|
||||||
|
</div>
|
||||||
|
<div class="i4c-table-wrap">
|
||||||
|
<table class="i4c-table">
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th>Raised</th>
|
||||||
|
<th>GR id</th>
|
||||||
|
<th>Account / Customer</th>
|
||||||
|
<th>Customer asks</th>
|
||||||
|
<th class="num">Held (₹)</th>
|
||||||
|
<th>Stage</th>
|
||||||
|
<th>Deadline</th>
|
||||||
|
<th>Outcome</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
@for (g of rows; track g.id) {
|
||||||
|
<tr class="clickable" (click)="open(g.id)">
|
||||||
|
<td>
|
||||||
|
{{ g.created_at | date: 'dd MMM yyyy' }}
|
||||||
|
<div class="sub">{{ g.created_by }}</div>
|
||||||
|
</td>
|
||||||
|
<td>
|
||||||
|
<b>{{ g.grievance_no || 'Draft #' + g.id }}</b>
|
||||||
|
</td>
|
||||||
|
<td>
|
||||||
|
<b>{{ g.account_no }}</b>
|
||||||
|
<div class="sub">{{ g.client_name }} · Ack {{ g.acknowledgement_no }}</div>
|
||||||
|
</td>
|
||||||
|
<td>{{ requestLabel(g.customer_request) }}</td>
|
||||||
|
<td class="num">{{ g.amount_held | number: '1.2-2' }}</td>
|
||||||
|
<td>
|
||||||
|
<span class="i4c-badge">{{ stageLabel(g.stage) }}</span>
|
||||||
|
</td>
|
||||||
|
<td>
|
||||||
|
@if (g.compliance_due_at && g.stage !== 'CLOSED') {
|
||||||
|
<span class="deadline" [class.deadline--late]="g.compliance_overdue">
|
||||||
|
Carry out by {{ g.compliance_due_at | date: 'dd MMM, HH:mm' }}
|
||||||
|
</span>
|
||||||
|
} @else if (g.due_at) {
|
||||||
|
<span class="deadline" [class.deadline--late]="g.overdue">{{ g.due_at | date: 'dd MMM yyyy' }}</span>
|
||||||
|
} @else {
|
||||||
|
-
|
||||||
|
}
|
||||||
|
</td>
|
||||||
|
<td>
|
||||||
|
<span
|
||||||
|
class="i4c-badge"
|
||||||
|
[class.i4c-badge--green]="g.status === 'ALLOWED'"
|
||||||
|
[class.i4c-badge--red]="g.status === 'REJECTED' || g.status === 'COURT'"
|
||||||
|
[class.i4c-badge--amber]="g.status === 'OPEN'"
|
||||||
|
>{{ g.status }}</span
|
||||||
|
>
|
||||||
|
@if (g.decision) {
|
||||||
|
<div class="sub">{{ g.decision_level }}: {{ decisionLabel(g.decision) }}</div>
|
||||||
|
}
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
} @empty {
|
||||||
|
<tr>
|
||||||
|
<td colspan="8" class="i4c-empty">No grievances match these filters.</td>
|
||||||
|
</tr>
|
||||||
|
}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
<mat-paginator
|
||||||
|
class="i4c-paginator"
|
||||||
|
[length]="total"
|
||||||
|
[pageIndex]="pageIndex"
|
||||||
|
[pageSize]="pageSize"
|
||||||
|
[pageSizeOptions]="pageSizes"
|
||||||
|
(page)="onPage($event)"
|
||||||
|
showFirstLastButtons
|
||||||
|
aria-label="Grievance pages"
|
||||||
|
></mat-paginator>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
@if (selected; as g) {
|
||||||
|
<div class="i4c-modal-backdrop" (click)="close()">
|
||||||
|
<div class="i4c-modal-window i4c-modal-window--wide" (click)="$event.stopPropagation()">
|
||||||
|
<div class="i4c-modal-header">
|
||||||
|
<div class="i4c-modal-header__title">
|
||||||
|
<fa-icon icon="balance-scale"></fa-icon>
|
||||||
|
<div>
|
||||||
|
<div class="modal-title-row">
|
||||||
|
<span>{{ g.grievance_no || 'Draft grievance #' + g.id }}</span>
|
||||||
|
<span class="i4c-badge">{{ stageLabel(g.stage) }}</span>
|
||||||
|
<span
|
||||||
|
class="i4c-badge"
|
||||||
|
[class.i4c-badge--green]="g.status === 'ALLOWED'"
|
||||||
|
[class.i4c-badge--red]="g.status === 'REJECTED' || g.status === 'COURT'"
|
||||||
|
>{{ g.status }}</span
|
||||||
|
>
|
||||||
|
@if (g.overdue || g.compliance_overdue) {
|
||||||
|
<span class="i4c-badge i4c-badge--red">PAST DEADLINE</span>
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
<div class="i4c-modal-header__sub">
|
||||||
|
Account <b>{{ g.account_no }}</b> ({{ g.client_name }}) · Ack {{ g.acknowledgement_no }} · Held ₹{{
|
||||||
|
g.amount_held | number: '1.2-2'
|
||||||
|
}}{{ g.debit_frozen ? ' · debits frozen' : '' }}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="i4c-modal-header__actions">
|
||||||
|
<button class="modal-close-btn" (click)="close()" aria-label="Close">
|
||||||
|
<fa-icon icon="times"></fa-icon>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="i4c-modal-tabs">
|
||||||
|
<button class="modal-tab" [class.modal-tab--active]="tab === 'workflow'" (click)="tab = 'workflow'">
|
||||||
|
<fa-icon icon="tasks"></fa-icon> Next step
|
||||||
|
</button>
|
||||||
|
<button class="modal-tab" [class.modal-tab--active]="tab === 'overview'" (click)="tab = 'overview'">
|
||||||
|
<fa-icon icon="info-circle"></fa-icon> Details & EDD
|
||||||
|
</button>
|
||||||
|
<button class="modal-tab" [class.modal-tab--active]="tab === 'documents'" (click)="tab = 'documents'">
|
||||||
|
<fa-icon icon="paperclip"></fa-icon> Documents ({{ g.documents?.length || 0 }})
|
||||||
|
</button>
|
||||||
|
<button class="modal-tab" [class.modal-tab--active]="tab === 'history'" (click)="tab = 'history'">
|
||||||
|
<fa-icon icon="history"></fa-icon> History ({{ g.events?.length || 0 }})
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="i4c-modal-body">
|
||||||
|
<div class="flow">
|
||||||
|
@for (f of flow(g); track f.value; let last = $last) {
|
||||||
|
<span
|
||||||
|
class="flow__stage"
|
||||||
|
[class.flow__stage--done]="f.state === 'done'"
|
||||||
|
[class.flow__stage--now]="f.state === 'now'"
|
||||||
|
>{{ f.label }}</span
|
||||||
|
>
|
||||||
|
@if (!last) {
|
||||||
|
<span class="flow__arrow">›</span>
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
@if (tab === 'workflow') {
|
||||||
|
<div class="i4c-card">
|
||||||
|
<div class="i4c-card__body">
|
||||||
|
@if (g.stage === 'DRAFT') {
|
||||||
|
@if (!g.edd_done) {
|
||||||
|
<div class="step">
|
||||||
|
<div class="step__title">1. Enhanced due diligence (before raising on GRM)</div>
|
||||||
|
<div class="step__row">
|
||||||
|
<mat-checkbox [(ngModel)]="edd.kycVerified">KYC re-verified</mat-checkbox>
|
||||||
|
<mat-form-field appearance="outline" subscriptSizing="dynamic">
|
||||||
|
<mat-label>Risk</mat-label>
|
||||||
|
<mat-select [(ngModel)]="edd.risk">
|
||||||
|
<mat-option value="LOW">Low</mat-option>
|
||||||
|
<mat-option value="MEDIUM">Medium</mat-option>
|
||||||
|
<mat-option value="HIGH">High</mat-option>
|
||||||
|
</mat-select>
|
||||||
|
</mat-form-field>
|
||||||
|
<mat-form-field appearance="outline" subscriptSizing="dynamic">
|
||||||
|
<mat-label>Bank's view</mat-label>
|
||||||
|
<mat-select [(ngModel)]="edd.recommendation">
|
||||||
|
<mat-option value="SUPPORT">Supports the request</mat-option>
|
||||||
|
<mat-option value="NOT_SUPPORT">Does not support it</mat-option>
|
||||||
|
</mat-select>
|
||||||
|
</mat-form-field>
|
||||||
|
</div>
|
||||||
|
<mat-form-field appearance="outline" class="step__wide">
|
||||||
|
<mat-label>Source of the disputed credit, as the customer explains it *</mat-label>
|
||||||
|
<textarea matInput rows="2" [(ngModel)]="edd.sourceOfFunds" maxlength="2000"></textarea>
|
||||||
|
</mat-form-field>
|
||||||
|
<mat-form-field appearance="outline" class="step__wide">
|
||||||
|
<mat-label>Customer's statement / notes</mat-label>
|
||||||
|
<textarea matInput rows="2" [(ngModel)]="edd.customerStatement" maxlength="2000"></textarea>
|
||||||
|
</mat-form-field>
|
||||||
|
<div class="i4c-actions">
|
||||||
|
<button mat-flat-button color="primary" (click)="saveEdd()" [disabled]="busy">
|
||||||
|
<fa-icon icon="user-shield"></fa-icon> Save due diligence
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
} @else {
|
||||||
|
<div class="step">
|
||||||
|
<div class="step__title">2. Raise it on GRM, then record the GR id</div>
|
||||||
|
<div class="sub">
|
||||||
|
GRM's entry form needs the KYC document, the customer's application, a photograph and the bank
|
||||||
|
statement. Attach them on the Documents tab (the statement can be downloaded from the hold).
|
||||||
|
</div>
|
||||||
|
@if (missingDocs(g).length) {
|
||||||
|
<div class="i4c-note">Still to attach: {{ missingDocs(g).join(', ') }}</div>
|
||||||
|
}
|
||||||
|
<div class="step__row">
|
||||||
|
<mat-form-field appearance="outline" subscriptSizing="dynamic">
|
||||||
|
<mat-label>GR id from GRM *</mat-label>
|
||||||
|
<input matInput [(ngModel)]="grievanceNo" maxlength="40" placeholder="GR003260316001" />
|
||||||
|
</mat-form-field>
|
||||||
|
<button
|
||||||
|
mat-flat-button
|
||||||
|
color="primary"
|
||||||
|
(click)="submit()"
|
||||||
|
[disabled]="busy || !grievanceNo.trim()"
|
||||||
|
>
|
||||||
|
<fa-icon icon="paper-plane"></fa-icon> Mark raised on GRM
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@if (g.stage === 'NODAL_REVIEW') {
|
||||||
|
<div class="step">
|
||||||
|
<div class="step__title">Bank GRM nodal officer's decision</div>
|
||||||
|
<mat-form-field appearance="outline" class="step__wide">
|
||||||
|
<mat-label>Remarks (required to reject)</mat-label>
|
||||||
|
<input matInput [(ngModel)]="remarks" maxlength="2000" />
|
||||||
|
</mat-form-field>
|
||||||
|
<div class="i4c-actions">
|
||||||
|
<button mat-flat-button color="primary" (click)="nodal(true)" [disabled]="busy">
|
||||||
|
<fa-icon icon="check"></fa-icon> Approved on GRM (to IO)
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
mat-stroked-button
|
||||||
|
color="warn"
|
||||||
|
(click)="nodal(false)"
|
||||||
|
[disabled]="busy || !remarks.trim()"
|
||||||
|
>
|
||||||
|
<fa-icon icon="times"></fa-icon> Rejected by nodal
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
|
||||||
|
@if (withOfficer && g.status === 'OPEN') {
|
||||||
|
<div class="step">
|
||||||
|
<div class="step__title">
|
||||||
|
Decision of the {{ level }}
|
||||||
|
@if (g.due_at) {
|
||||||
|
<span class="deadline" [class.deadline--late]="isLate(g.due_at)">
|
||||||
|
· due {{ g.due_at | date: 'dd MMM yyyy' }}</span
|
||||||
|
>
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
<div class="step__row">
|
||||||
|
<mat-form-field appearance="outline" subscriptSizing="dynamic" class="step__wide">
|
||||||
|
<mat-label>Decision on GRM</mat-label>
|
||||||
|
<mat-select [(ngModel)]="decision.decision">
|
||||||
|
@for (d of decisions; track d.value) {
|
||||||
|
<mat-option [value]="d.value">{{ d.label }}</mat-option>
|
||||||
|
}
|
||||||
|
</mat-select>
|
||||||
|
</mat-form-field>
|
||||||
|
@if (decision.decision === 'PARTIAL_HOLD') {
|
||||||
|
<mat-form-field appearance="outline" subscriptSizing="dynamic">
|
||||||
|
<mat-label>Amount to keep on hold (₹)</mat-label>
|
||||||
|
<input matInput type="number" min="0" [(ngModel)]="decision.keepAmount" />
|
||||||
|
</mat-form-field>
|
||||||
|
}
|
||||||
|
@if (decision.decision !== 'SEEK_INFO' && decision.decision !== 'REJECTED_CONTINUE') {
|
||||||
|
<mat-form-field appearance="outline" subscriptSizing="dynamic">
|
||||||
|
<mat-label>Order no. *</mat-label>
|
||||||
|
<input matInput [(ngModel)]="decision.orderRef" maxlength="200" />
|
||||||
|
</mat-form-field>
|
||||||
|
}
|
||||||
|
<mifosx-i4c-date-field
|
||||||
|
label="Order / decision date"
|
||||||
|
[(value)]="decision.orderDate"
|
||||||
|
></mifosx-i4c-date-field>
|
||||||
|
</div>
|
||||||
|
<mat-form-field appearance="outline" class="step__wide">
|
||||||
|
<mat-label
|
||||||
|
>Remarks{{ decision.decision === 'REJECTED_CONTINUE' ? ' (reason given) *' : '' }}</mat-label
|
||||||
|
>
|
||||||
|
<input matInput [(ngModel)]="decision.remarks" maxlength="2000" />
|
||||||
|
</mat-form-field>
|
||||||
|
@if (decision.decision !== 'SEEK_INFO' && decision.decision !== 'REJECTED_CONTINUE') {
|
||||||
|
<div class="sub">
|
||||||
|
Attach the signed and stamped order copy (GRM order or IO order) on the Documents tab first.
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
<div class="i4c-actions">
|
||||||
|
<button mat-flat-button color="primary" (click)="decide()" [disabled]="busy">
|
||||||
|
<fa-icon icon="gavel"></fa-icon> Record decision
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="step">
|
||||||
|
<div class="step__title">Something else happened on GRM</div>
|
||||||
|
<div class="step__row">
|
||||||
|
<mat-form-field appearance="outline" subscriptSizing="dynamic">
|
||||||
|
<mat-label>Event</mat-label>
|
||||||
|
<mat-select [(ngModel)]="noteType">
|
||||||
|
@for (n of noteEvents; track n.value) {
|
||||||
|
<mat-option [value]="n.value">{{ n.label }}</mat-option>
|
||||||
|
}
|
||||||
|
</mat-select>
|
||||||
|
</mat-form-field>
|
||||||
|
<mat-form-field appearance="outline" subscriptSizing="dynamic" class="step__wide">
|
||||||
|
<mat-label>Remarks</mat-label>
|
||||||
|
<input matInput [(ngModel)]="remarks" maxlength="2000" />
|
||||||
|
</mat-form-field>
|
||||||
|
</div>
|
||||||
|
<div class="i4c-actions">
|
||||||
|
<button mat-stroked-button (click)="note()" [disabled]="busy">
|
||||||
|
<fa-icon icon="plus"></fa-icon> Add to history
|
||||||
|
</button>
|
||||||
|
@if (g.stage === 'WITH_IO') {
|
||||||
|
<button mat-stroked-button (click)="investigation()" [disabled]="busy">
|
||||||
|
<fa-icon icon="search"></fa-icon> IO is investigating (+15 days)
|
||||||
|
</button>
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
|
||||||
|
@if (ioSilent(g)) {
|
||||||
|
<div class="step">
|
||||||
|
<div class="step__title err-text">The IO has not decided within 15 days</div>
|
||||||
|
<div class="sub">SOP 9.1: the grievance goes to the District Grievance Officer.</div>
|
||||||
|
<div class="i4c-actions">
|
||||||
|
<button mat-flat-button color="warn" (click)="escalate()" [disabled]="busy">
|
||||||
|
<fa-icon icon="arrow-up"></fa-icon> Escalate to District GRO
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
|
||||||
|
@if (g.status === 'REJECTED' && g.stage !== 'CLOSED') {
|
||||||
|
<div class="step">
|
||||||
|
<div class="step__title">Rejected by the {{ g.decision_level }}: {{ g.decision_remarks }}</div>
|
||||||
|
<div class="sub">
|
||||||
|
If the customer is not satisfied, the branch raises a review: IO → District GRO → State GRO (15
|
||||||
|
days each); after the State GRO the customer may approach the jurisdictional court.
|
||||||
|
</div>
|
||||||
|
<mat-form-field appearance="outline" class="step__wide">
|
||||||
|
<mat-label>Remarks</mat-label>
|
||||||
|
<input matInput [(ngModel)]="remarks" maxlength="2000" />
|
||||||
|
</mat-form-field>
|
||||||
|
<div class="i4c-actions">
|
||||||
|
<button mat-flat-button color="primary" (click)="escalate()" [disabled]="busy">
|
||||||
|
<fa-icon icon="arrow-up"></fa-icon> {{ escalateLabel }}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
|
||||||
|
@if (g.status === 'ALLOWED' && g.stage !== 'CLOSED') {
|
||||||
|
<div class="step">
|
||||||
|
<div class="step__title">
|
||||||
|
Order: {{ decisionLabel(g.decision) }}
|
||||||
|
@if (g.decision_keep_amount !== null) {
|
||||||
|
(keep ₹{{ g.decision_keep_amount | number: '1.2-2' }})
|
||||||
|
}
|
||||||
|
· {{ g.decision_order_ref }}
|
||||||
|
</div>
|
||||||
|
<div class="sub">
|
||||||
|
Carry it out on the account by
|
||||||
|
<span class="deadline" [class.deadline--late]="g.compliance_overdue">{{
|
||||||
|
g.compliance_due_at | date: 'dd MMM yyyy, HH:mm'
|
||||||
|
}}</span
|
||||||
|
>. The change goes to a second officer for approval; the grievance closes when it is approved.
|
||||||
|
</div>
|
||||||
|
@if (g.actionRequest?.length) {
|
||||||
|
<div class="sub">
|
||||||
|
Request #{{ g.actionRequest[0].id }} ({{ g.actionRequest[0].action }}) is
|
||||||
|
<b>{{ g.actionRequest[0].status }}</b>
|
||||||
|
{{ g.actionRequest[0].failure_message || g.actionRequest[0].rejection_reason || '' }}
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
@if (
|
||||||
|
!g.actionRequest?.length ||
|
||||||
|
g.actionRequest[0].status === 'REJECTED' ||
|
||||||
|
g.actionRequest[0].status === 'FAILED'
|
||||||
|
) {
|
||||||
|
<div class="i4c-actions">
|
||||||
|
<button mat-flat-button color="warn" (click)="raiseAction()" [disabled]="busy">
|
||||||
|
<fa-icon icon="hand-paper"></fa-icon> Raise the account change for approval
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
|
||||||
|
@if (g.stage === 'CLOSED') {
|
||||||
|
<div class="i4c-note">This grievance is closed ({{ g.status }}).</div>
|
||||||
|
} @else {
|
||||||
|
<div class="step">
|
||||||
|
<div class="i4c-actions">
|
||||||
|
<button mat-button color="warn" (click)="withdraw()" [disabled]="busy">
|
||||||
|
Customer withdrew the grievance
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
|
||||||
|
@if (tab === 'overview') {
|
||||||
|
<div class="grid-2">
|
||||||
|
<div class="i4c-card">
|
||||||
|
<div class="i4c-card__head"><fa-icon icon="user"></fa-icon> Customer's request</div>
|
||||||
|
<div class="i4c-card__body">
|
||||||
|
<dl class="i4c-kv">
|
||||||
|
<dt>Asks to</dt>
|
||||||
|
<dd>{{ requestLabel(g.customer_request) }}</dd>
|
||||||
|
<dt>Reason given</dt>
|
||||||
|
<dd class="wrap">{{ g.customer_reason || '-' }}</dd>
|
||||||
|
<dt>Opened</dt>
|
||||||
|
<dd>{{ g.created_at | date: 'dd MMM yyyy, HH:mm' }} by {{ g.created_by }}</dd>
|
||||||
|
<dt>Decision</dt>
|
||||||
|
<dd>
|
||||||
|
{{ g.decision ? g.decision_level + ': ' + decisionLabel(g.decision) : '-' }}
|
||||||
|
@if (g.decision_order_ref) {
|
||||||
|
<div class="sub">
|
||||||
|
Order {{ g.decision_order_ref }} {{ g.decision_date | date: 'dd MMM yyyy' }}
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
</dd>
|
||||||
|
</dl>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="i4c-card">
|
||||||
|
<div class="i4c-card__head"><fa-icon icon="user-shield"></fa-icon> Enhanced due diligence</div>
|
||||||
|
<div class="i4c-card__body">
|
||||||
|
@if (g.edd_done) {
|
||||||
|
<dl class="i4c-kv">
|
||||||
|
<dt>KYC re-verified</dt>
|
||||||
|
<dd>{{ g.edd_kyc_verified ? 'Yes' : 'No' }}</dd>
|
||||||
|
<dt>Source of funds</dt>
|
||||||
|
<dd class="wrap">{{ g.edd_source_of_funds }}</dd>
|
||||||
|
<dt>Statement</dt>
|
||||||
|
<dd class="wrap">{{ g.edd_customer_statement || '-' }}</dd>
|
||||||
|
<dt>Risk / view</dt>
|
||||||
|
<dd>
|
||||||
|
{{ g.edd_risk }} ·
|
||||||
|
{{ g.edd_recommendation === 'SUPPORT' ? 'Bank supports' : 'Bank does not support' }}
|
||||||
|
</dd>
|
||||||
|
<dt>By</dt>
|
||||||
|
<dd>{{ g.edd_by }}, {{ g.edd_at | date: 'dd MMM yyyy, HH:mm' }}</dd>
|
||||||
|
</dl>
|
||||||
|
} @else {
|
||||||
|
<div class="i4c-note">Not done yet.</div>
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
|
||||||
|
@if (tab === 'documents') {
|
||||||
|
<div class="i4c-card">
|
||||||
|
<div class="i4c-card__body">
|
||||||
|
<mifosx-i4c-documents
|
||||||
|
ownerType="GRIEVANCE"
|
||||||
|
[ownerId]="g.id"
|
||||||
|
[required]="g.requiredDocuments || []"
|
||||||
|
[suggested]="['GRM_ORDER', 'IO_ORDER', 'EDD_REPORT']"
|
||||||
|
[documents]="g.documents"
|
||||||
|
[canUpload]="g.stage !== 'CLOSED'"
|
||||||
|
(changed)="open(g.id)"
|
||||||
|
></mifosx-i4c-documents>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
|
||||||
|
@if (tab === 'history') {
|
||||||
|
<div class="i4c-card">
|
||||||
|
<div class="i4c-table-wrap">
|
||||||
|
<table class="i4c-table">
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th>When</th>
|
||||||
|
<th>Event</th>
|
||||||
|
<th>Stage</th>
|
||||||
|
<th>Remarks</th>
|
||||||
|
<th>By</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
@for (e of g.events; track e.id) {
|
||||||
|
<tr>
|
||||||
|
<td>{{ e.created_at | date: 'dd MMM yyyy, HH:mm' }}</td>
|
||||||
|
<td>
|
||||||
|
<span class="i4c-badge">{{ e.event_type }}</span>
|
||||||
|
</td>
|
||||||
|
<td>{{ stageLabel(e.stage) }}</td>
|
||||||
|
<td class="wrap">{{ e.remarks || '-' }}</td>
|
||||||
|
<td>{{ e.actor }}</td>
|
||||||
|
</tr>
|
||||||
|
}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="i4c-modal-footer">
|
||||||
|
<button class="footer-btn-close" (click)="close()">Close</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
</div>
|
||||||
@@ -0,0 +1,17 @@
|
|||||||
|
/**
|
||||||
|
* 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/.
|
||||||
|
*/
|
||||||
|
|
||||||
|
@use '../i4c' as *;
|
||||||
|
@use '../i4c-cases' as *;
|
||||||
|
|
||||||
|
@include i4c-skin;
|
||||||
|
@include i4c-case-page;
|
||||||
|
|
||||||
|
.err-text {
|
||||||
|
color: #dc2626;
|
||||||
|
}
|
||||||
@@ -0,0 +1,367 @@
|
|||||||
|
/**
|
||||||
|
* 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 { DatePipe, DecimalPipe } from '@angular/common';
|
||||||
|
import { FormsModule } from '@angular/forms';
|
||||||
|
import { ActivatedRoute } from '@angular/router';
|
||||||
|
import { MatButtonModule } from '@angular/material/button';
|
||||||
|
import { MatCheckboxModule } from '@angular/material/checkbox';
|
||||||
|
import { MatFormFieldModule } from '@angular/material/form-field';
|
||||||
|
import { MatInputModule } from '@angular/material/input';
|
||||||
|
import { MatSelectModule } from '@angular/material/select';
|
||||||
|
import { MatProgressBarModule } from '@angular/material/progress-bar';
|
||||||
|
import { MatPaginatorModule, PageEvent } from '@angular/material/paginator';
|
||||||
|
import { MatSnackBar } from '@angular/material/snack-bar';
|
||||||
|
import { FaIconComponent } from '@fortawesome/angular-fontawesome';
|
||||||
|
import { I4cService, i4cErrorMessage } from '../i4c.service';
|
||||||
|
import { I4cDateFieldComponent } from '../i4c-date-field/i4c-date-field.component';
|
||||||
|
import { ALL_DATES, I4cDateRange, I4cDateRangeComponent } from '../i4c-date-range/i4c-date-range.component';
|
||||||
|
import { I4cDocumentsComponent } from '../i4c-documents/i4c-documents.component';
|
||||||
|
|
||||||
|
export const GRM_STAGES: { value: string; label: string }[] = [
|
||||||
|
{ value: 'DRAFT', label: 'Draft (EDD)' },
|
||||||
|
{ value: 'NODAL_REVIEW', label: 'Bank nodal review' },
|
||||||
|
{ value: 'WITH_IO', label: 'With IO (7 days)' },
|
||||||
|
{ value: 'IO_INVESTIGATION', label: 'IO investigating (15 days)' },
|
||||||
|
{ value: 'WITH_DGRO', label: 'District GRO review (15 days)' },
|
||||||
|
{ value: 'WITH_SGRO', label: 'State GRO review (15 days)' },
|
||||||
|
{ value: 'CLOSED', label: 'Closed' }
|
||||||
|
];
|
||||||
|
|
||||||
|
export const GRM_DECISIONS: { value: string; label: string }[] = [
|
||||||
|
{ value: 'REMOVE_HOLD', label: 'Remove put on hold' },
|
||||||
|
{ value: 'UNFREEZE_HOLD_CONTINUES', label: 'Unfreeze the account, hold continues' },
|
||||||
|
{ value: 'UNFREEZE_AND_REMOVE_HOLD', label: 'Unfreeze and remove put on hold' },
|
||||||
|
{ value: 'PARTIAL_HOLD', label: 'Keep a smaller amount on hold' },
|
||||||
|
{ value: 'RESTORE_DIGITAL', label: 'Restore digital services' },
|
||||||
|
{ value: 'REJECTED_CONTINUE', label: 'Request rejected, hold / freeze continues' },
|
||||||
|
{ value: 'SEEK_INFO', label: 'Seek additional information' }
|
||||||
|
];
|
||||||
|
|
||||||
|
const REQUESTS: Record<string, string> = {
|
||||||
|
REMOVE_HOLD: 'Remove the hold',
|
||||||
|
UNFREEZE: 'Unfreeze the account',
|
||||||
|
RESTORE_DIGITAL_SERVICES: 'Restore digital services'
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* GRM grievances raised by the bank for account holders who contest an I4C hold: EDD, raising on GRM, the IO / DGRO /
|
||||||
|
* SGRO decision with its deadlines, escalation, and carrying an allowing order out on the account.
|
||||||
|
*/
|
||||||
|
@Component({
|
||||||
|
selector: 'mifosx-i4c-grievances',
|
||||||
|
templateUrl: './i4c-grievances.component.html',
|
||||||
|
styleUrls: ['./i4c-grievances.component.scss'],
|
||||||
|
imports: [
|
||||||
|
DatePipe,
|
||||||
|
DecimalPipe,
|
||||||
|
FormsModule,
|
||||||
|
MatButtonModule,
|
||||||
|
MatCheckboxModule,
|
||||||
|
MatFormFieldModule,
|
||||||
|
MatInputModule,
|
||||||
|
MatSelectModule,
|
||||||
|
MatProgressBarModule,
|
||||||
|
MatPaginatorModule,
|
||||||
|
FaIconComponent,
|
||||||
|
I4cDateRangeComponent,
|
||||||
|
I4cDocumentsComponent,
|
||||||
|
I4cDateFieldComponent
|
||||||
|
]
|
||||||
|
})
|
||||||
|
export class I4cGrievancesComponent implements OnInit {
|
||||||
|
private i4c = inject(I4cService);
|
||||||
|
private route = inject(ActivatedRoute);
|
||||||
|
private snackBar = inject(MatSnackBar);
|
||||||
|
|
||||||
|
readonly stages = GRM_STAGES;
|
||||||
|
readonly decisions = GRM_DECISIONS;
|
||||||
|
readonly noteEvents = [
|
||||||
|
{ value: 'VC_REQUESTED', label: 'IO requested a VC' },
|
||||||
|
{ value: 'VC_DONE', label: 'VC held' },
|
||||||
|
{ value: 'VC_NOT_REQUIRED', label: 'VC not required' },
|
||||||
|
{ value: 'INFO_SOUGHT', label: 'Additional information sought' },
|
||||||
|
{ value: 'INFO_SENT', label: 'Information sent' },
|
||||||
|
{ value: 'NOTE', label: 'Note' }
|
||||||
|
];
|
||||||
|
readonly pageSizes = [
|
||||||
|
10,
|
||||||
|
20,
|
||||||
|
50,
|
||||||
|
100
|
||||||
|
];
|
||||||
|
|
||||||
|
stage = 'OPEN';
|
||||||
|
status = '';
|
||||||
|
overdueOnly = false;
|
||||||
|
search = '';
|
||||||
|
dates: I4cDateRange = { ...ALL_DATES };
|
||||||
|
rows: any[] = [];
|
||||||
|
total = 0;
|
||||||
|
pageIndex = 0;
|
||||||
|
pageSize = 20;
|
||||||
|
loading = false;
|
||||||
|
error = '';
|
||||||
|
|
||||||
|
selected: any = null;
|
||||||
|
busy = false;
|
||||||
|
tab: 'overview' | 'workflow' | 'documents' | 'history' = 'workflow';
|
||||||
|
edd = { kycVerified: true, sourceOfFunds: '', customerStatement: '', risk: 'LOW', recommendation: 'SUPPORT' };
|
||||||
|
grievanceNo = '';
|
||||||
|
remarks = '';
|
||||||
|
noteType = 'VC_REQUESTED';
|
||||||
|
decision = { decision: 'REMOVE_HOLD', keepAmount: null as number | null, orderRef: '', orderDate: '', remarks: '' };
|
||||||
|
|
||||||
|
ngOnInit(): void {
|
||||||
|
const open = Number(this.route.snapshot.queryParamMap.get('open'));
|
||||||
|
this.load();
|
||||||
|
if (open) {
|
||||||
|
this.open(open);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
load(fromFirstPage = false): void {
|
||||||
|
if (fromFirstPage) {
|
||||||
|
this.pageIndex = 0;
|
||||||
|
}
|
||||||
|
this.loading = true;
|
||||||
|
this.error = '';
|
||||||
|
this.i4c
|
||||||
|
.grievances({
|
||||||
|
stage: this.stage,
|
||||||
|
status: this.status,
|
||||||
|
overdue: this.overdueOnly || null,
|
||||||
|
search: this.search.trim(),
|
||||||
|
fromDate: this.dates.fromDate,
|
||||||
|
toDate: this.dates.toDate,
|
||||||
|
offset: this.pageIndex * this.pageSize,
|
||||||
|
limit: this.pageSize
|
||||||
|
})
|
||||||
|
.subscribe({
|
||||||
|
next: (page) => {
|
||||||
|
this.rows = page.pageItems;
|
||||||
|
this.total = page.totalFilteredRecords;
|
||||||
|
this.loading = false;
|
||||||
|
},
|
||||||
|
error: (e) => {
|
||||||
|
this.error = i4cErrorMessage(e);
|
||||||
|
this.loading = false;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
setStage(s: string): void {
|
||||||
|
this.stage = s;
|
||||||
|
this.load(true);
|
||||||
|
}
|
||||||
|
|
||||||
|
onPage(e: PageEvent): void {
|
||||||
|
this.pageIndex = e.pageIndex;
|
||||||
|
this.pageSize = e.pageSize;
|
||||||
|
this.load();
|
||||||
|
}
|
||||||
|
|
||||||
|
onDates(range: I4cDateRange): void {
|
||||||
|
this.dates = range;
|
||||||
|
this.load(true);
|
||||||
|
}
|
||||||
|
|
||||||
|
get hasFilters(): boolean {
|
||||||
|
return !!(this.status || this.overdueOnly || this.search || this.dates.preset !== 'ALL' || this.stage !== 'OPEN');
|
||||||
|
}
|
||||||
|
|
||||||
|
clearFilters(): void {
|
||||||
|
this.stage = 'OPEN';
|
||||||
|
this.status = '';
|
||||||
|
this.overdueOnly = false;
|
||||||
|
this.search = '';
|
||||||
|
this.dates = { ...ALL_DATES };
|
||||||
|
this.load(true);
|
||||||
|
}
|
||||||
|
|
||||||
|
open(id: number): void {
|
||||||
|
this.i4c.grievance(id).subscribe({
|
||||||
|
next: (g) => {
|
||||||
|
const same = this.selected?.id === g.id;
|
||||||
|
this.selected = g;
|
||||||
|
if (!same) {
|
||||||
|
this.tab = 'workflow';
|
||||||
|
this.remarks = '';
|
||||||
|
this.grievanceNo = g.grievance_no || '';
|
||||||
|
this.decision = { decision: 'REMOVE_HOLD', keepAmount: null, orderRef: '', orderDate: '', remarks: '' };
|
||||||
|
}
|
||||||
|
},
|
||||||
|
error: (e) => this.snackBar.open(i4cErrorMessage(e), 'Close', { duration: 6000 })
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
close(): void {
|
||||||
|
this.selected = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
stageLabel(s: string): string {
|
||||||
|
return GRM_STAGES.find((x) => x.value === s)?.label || s;
|
||||||
|
}
|
||||||
|
|
||||||
|
decisionLabel(d: string): string {
|
||||||
|
return GRM_DECISIONS.find((x) => x.value === d)?.label || d;
|
||||||
|
}
|
||||||
|
|
||||||
|
requestLabel(r: string): string {
|
||||||
|
return REQUESTS[r] || r;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The stages on the way, for the little progress line; escalation stages show only once reached. */
|
||||||
|
flow(g: any): { value: string; label: string; state: 'done' | 'now' | 'todo' }[] {
|
||||||
|
const path = [
|
||||||
|
'DRAFT',
|
||||||
|
'NODAL_REVIEW',
|
||||||
|
'WITH_IO',
|
||||||
|
'IO_INVESTIGATION',
|
||||||
|
'WITH_DGRO',
|
||||||
|
'WITH_SGRO',
|
||||||
|
'CLOSED'
|
||||||
|
];
|
||||||
|
const at = path.indexOf(g.stage);
|
||||||
|
const visible = path.filter((s, i) => i <= Math.max(at, 3) || s === 'CLOSED' || i <= at);
|
||||||
|
return visible.map((s) => ({
|
||||||
|
value: s,
|
||||||
|
label: (GRM_STAGES.find((x) => x.value === s)?.label || s).replace(/ \(.*\)/, ''),
|
||||||
|
state: path.indexOf(s) < at ? 'done' : s === g.stage ? 'now' : 'todo'
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
|
get level(): string {
|
||||||
|
const s = this.selected?.stage;
|
||||||
|
return s === 'WITH_DGRO' ? 'DGRO' : s === 'WITH_SGRO' ? 'SGRO' : 'IO';
|
||||||
|
}
|
||||||
|
|
||||||
|
get withOfficer(): boolean {
|
||||||
|
return [
|
||||||
|
'WITH_IO',
|
||||||
|
'IO_INVESTIGATION',
|
||||||
|
'WITH_DGRO',
|
||||||
|
'WITH_SGRO'
|
||||||
|
].includes(this.selected?.stage);
|
||||||
|
}
|
||||||
|
|
||||||
|
isLate(ts: string | null): boolean {
|
||||||
|
return !!ts && new Date(ts).getTime() < Date.now();
|
||||||
|
}
|
||||||
|
|
||||||
|
step(step: string, body: Record<string, any>, done: string): void {
|
||||||
|
if (!this.selected) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
this.busy = true;
|
||||||
|
this.i4c.grievanceStep(this.selected.id, step, body).subscribe({
|
||||||
|
next: (g) => {
|
||||||
|
this.busy = false;
|
||||||
|
this.selected = g;
|
||||||
|
this.remarks = '';
|
||||||
|
this.snackBar.open(done, 'OK', { duration: 4000 });
|
||||||
|
this.load();
|
||||||
|
},
|
||||||
|
error: (e) => {
|
||||||
|
this.busy = false;
|
||||||
|
this.snackBar.open(i4cErrorMessage(e), 'Close', { duration: 7000 });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
saveEdd(): void {
|
||||||
|
this.step('edd', this.edd, 'Due diligence recorded.');
|
||||||
|
}
|
||||||
|
|
||||||
|
submit(): void {
|
||||||
|
this.step('submit', { grievanceNo: this.grievanceNo.trim() }, 'Grievance marked as raised on GRM.');
|
||||||
|
}
|
||||||
|
|
||||||
|
nodal(approve: boolean): void {
|
||||||
|
this.step(
|
||||||
|
'nodal',
|
||||||
|
{ approve, remarks: this.remarks },
|
||||||
|
approve ? 'Approved; now with the IO (7 days).' : 'Grievance rejected by the nodal officer.'
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
note(): void {
|
||||||
|
this.step('events', { eventType: this.noteType, remarks: this.remarks }, 'Noted.');
|
||||||
|
}
|
||||||
|
|
||||||
|
investigation(): void {
|
||||||
|
this.step('investigation', { remarks: this.remarks }, 'IO investigation recorded (15 days).');
|
||||||
|
}
|
||||||
|
|
||||||
|
decide(): void {
|
||||||
|
this.step(
|
||||||
|
'decision',
|
||||||
|
{
|
||||||
|
level: this.level,
|
||||||
|
decision: this.decision.decision,
|
||||||
|
keepAmount: this.decision.decision === 'PARTIAL_HOLD' ? this.decision.keepAmount : null,
|
||||||
|
orderRef: this.decision.orderRef,
|
||||||
|
orderDate: this.decision.orderDate || null,
|
||||||
|
remarks: this.decision.remarks
|
||||||
|
},
|
||||||
|
'Decision recorded.'
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
escalate(): void {
|
||||||
|
this.step('escalate', { remarks: this.remarks }, 'Escalated.');
|
||||||
|
}
|
||||||
|
|
||||||
|
withdraw(): void {
|
||||||
|
this.step('withdraw', { remarks: this.remarks }, 'Grievance withdrawn.');
|
||||||
|
}
|
||||||
|
|
||||||
|
raiseAction(): void {
|
||||||
|
if (!this.selected) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
this.busy = true;
|
||||||
|
this.i4c.raiseGrievanceAction(this.selected.id).subscribe({
|
||||||
|
next: (r) => {
|
||||||
|
this.busy = false;
|
||||||
|
this.snackBar.open(
|
||||||
|
r.actionRequestId
|
||||||
|
? 'Request ' + r.actionRequestId + ' raised. Another officer approves it on the Approvals page.'
|
||||||
|
: 'Nothing to change on the account; grievance closed.',
|
||||||
|
'OK',
|
||||||
|
{ duration: 6000 }
|
||||||
|
);
|
||||||
|
this.open(this.selected.id);
|
||||||
|
this.load();
|
||||||
|
},
|
||||||
|
error: (e) => {
|
||||||
|
this.busy = false;
|
||||||
|
this.snackBar.open(i4cErrorMessage(e), 'Close', { duration: 7000 });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/** SOP 9.1: the IO did not decide within 15 days. */
|
||||||
|
ioSilent(g: any): boolean {
|
||||||
|
return g.status === 'OPEN' && (g.stage === 'WITH_IO' || g.stage === 'IO_INVESTIGATION') && this.isLate(g.due_at);
|
||||||
|
}
|
||||||
|
|
||||||
|
missingDocs(g: any): string[] {
|
||||||
|
return (g.requiredDocuments || []).filter((t: string) => !(g.documents || []).some((d: any) => d.doc_type === t));
|
||||||
|
}
|
||||||
|
|
||||||
|
get escalateLabel(): string {
|
||||||
|
const s = this.selected?.stage;
|
||||||
|
return s === 'WITH_SGRO'
|
||||||
|
? 'Close: customer may approach the court'
|
||||||
|
: s === 'WITH_DGRO'
|
||||||
|
? 'Escalate to State GRO'
|
||||||
|
: 'Escalate to District GRO';
|
||||||
|
}
|
||||||
|
}
|
||||||
File diff suppressed because it is too large
Load Diff
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user