Compare commits
85
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
fca550f4c4 | ||
|
|
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 | ||
|
|
5ab4e9eaf0 | ||
|
|
4fc6784695 | ||
|
|
174a2e7b6b | ||
|
|
9f7d9d28fd | ||
|
|
1ffac4b1e5 | ||
|
|
4dee1db360 | ||
|
|
0c6ebfdc1a | ||
|
|
b6994c0b76 | ||
|
|
885b39d970 | ||
|
|
4f02f24aae | ||
|
|
73871a0993 | ||
|
|
7b0cb5bb63 | ||
|
|
74a51a0626 | ||
|
|
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 | ||
|
|
d47e0860f6 | ||
|
|
e2eff9914f | ||
|
|
cda7c52d11 | ||
|
|
d132bbe716 | ||
|
|
6eaf9b959f | ||
|
|
b184a0909c | ||
|
|
57a61b3057 |
+9
-2
@@ -31,6 +31,8 @@
|
||||
"@angular/localize/init"
|
||||
],
|
||||
"assets": [
|
||||
"src/favicon.ico",
|
||||
"src/apple-touch-icon.png",
|
||||
"src/assets/images/cbsnexora-favicon.svg",
|
||||
"src/robots.txt",
|
||||
"src/manifest.json",
|
||||
@@ -39,6 +41,11 @@
|
||||
"glob": "**/*",
|
||||
"input": "node_modules/tinymce",
|
||||
"output": "assets/tinymce"
|
||||
},
|
||||
{
|
||||
"glob": "lottie.min.js",
|
||||
"input": "node_modules/lottie-web/build/player",
|
||||
"output": "assets/js"
|
||||
}
|
||||
],
|
||||
"styles": [
|
||||
@@ -69,8 +76,8 @@
|
||||
"budgets": [
|
||||
{
|
||||
"type": "initial",
|
||||
"maximumWarning": "15mb",
|
||||
"maximumError": "17mb"
|
||||
"maximumWarning": "18mb",
|
||||
"maximumError": "20mb"
|
||||
},
|
||||
{
|
||||
"type": "anyComponentStyle",
|
||||
|
||||
Generated
+7
@@ -41,6 +41,7 @@
|
||||
"jspdf-autotable": "^5.0.2",
|
||||
"lightgallery": "^2.9.0",
|
||||
"lodash": "4.17.23",
|
||||
"lottie-web": "^5.13.0",
|
||||
"moment": "^2.29.4",
|
||||
"ngx-image-cropper": "^9.1.6",
|
||||
"ngx-mat-select-search": "^8.0.2",
|
||||
@@ -18380,6 +18381,12 @@
|
||||
"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": {
|
||||
"version": "5.1.1",
|
||||
"resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-5.1.1.tgz",
|
||||
|
||||
+2
-1
@@ -15,7 +15,7 @@
|
||||
"ng": "ng",
|
||||
"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/",
|
||||
"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: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",
|
||||
@@ -71,6 +71,7 @@
|
||||
"jspdf-autotable": "^5.0.2",
|
||||
"lightgallery": "^2.9.0",
|
||||
"lodash": "4.17.23",
|
||||
"lottie-web": "^5.13.0",
|
||||
"moment": "^2.29.4",
|
||||
"ngx-image-cropper": "^9.1.6",
|
||||
"ngx-mat-select-search": "^8.0.2",
|
||||
|
||||
@@ -7,16 +7,6 @@
|
||||
-->
|
||||
|
||||
<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-content>
|
||||
<div class="transfer-container">
|
||||
|
||||
@@ -279,7 +279,7 @@
|
||||
<div class="gl-fgroup">
|
||||
<label class="gl-flabel"><fa-icon icon="calendar-alt"></fa-icon> From Date</label>
|
||||
<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 #dpFrom></mat-datepicker>
|
||||
</mat-form-field>
|
||||
@@ -288,7 +288,7 @@
|
||||
<div class="gl-fgroup">
|
||||
<label class="gl-flabel"><fa-icon icon="calendar-alt"></fa-icon> To Date</label>
|
||||
<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 #dpTo></mat-datepicker>
|
||||
</mat-form-field>
|
||||
@@ -353,13 +353,7 @@
|
||||
<div class="gl-fgroup">
|
||||
<label class="gl-flabel"><fa-icon icon="calendar-check"></fa-icon> Submitted From</label>
|
||||
<mat-form-field appearance="outline" class="gl-ffield">
|
||||
<input
|
||||
matInput
|
||||
[matDatepicker]="dpSubFrom"
|
||||
[min]="minDate"
|
||||
[max]="maxDate"
|
||||
[formControl]="submittedOnDateFrom"
|
||||
/>
|
||||
<input matInput [matDatepicker]="dpSubFrom" [min]="minDate" [formControl]="submittedOnDateFrom" />
|
||||
<mat-datepicker-toggle matSuffix [for]="dpSubFrom"></mat-datepicker-toggle>
|
||||
<mat-datepicker #dpSubFrom></mat-datepicker>
|
||||
</mat-form-field>
|
||||
@@ -368,7 +362,7 @@
|
||||
<div class="gl-fgroup">
|
||||
<label class="gl-flabel"><fa-icon icon="calendar-check"></fa-icon> Submitted To</label>
|
||||
<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 #dpSubTo></mat-datepicker>
|
||||
</mat-form-field>
|
||||
@@ -539,7 +533,7 @@
|
||||
</div>
|
||||
|
||||
<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 -->
|
||||
<ng-container matColumnDef="id">
|
||||
<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 ──────────────────────────────────────────────────────────────────
|
||||
minDate = new Date(2000, 0, 1);
|
||||
maxDate = new Date();
|
||||
maxDate: Date = null;
|
||||
/** Accounting features switched off for this installation — see disabled-features.ts. */
|
||||
disabledFeatures = DISABLED_ACCOUNTING_FEATURES;
|
||||
|
||||
@@ -208,7 +208,7 @@ export class SearchJournalEntryComponent implements OnInit, OnDestroy, AfterView
|
||||
}
|
||||
|
||||
ngOnInit() {
|
||||
this.maxDate = this.settingsService.businessDate;
|
||||
this.maxDate = null;
|
||||
this.paginator.pageSize = 10;
|
||||
this.restoreState();
|
||||
this.setFilteredOffices();
|
||||
|
||||
@@ -11,6 +11,14 @@ import { Routes, RouterModule } from '@angular/router';
|
||||
|
||||
import { Route } from '../core/route/route.service';
|
||||
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 = [
|
||||
Route.withShell([
|
||||
@@ -41,6 +49,59 @@ const routes: Routes = [
|
||||
title: '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 });
|
||||
}
|
||||
|
||||
/**
|
||||
* 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> {
|
||||
let httpParams = new HttpParams()
|
||||
.set('paged', 'true')
|
||||
|
||||
@@ -63,6 +63,8 @@ import { CfcfrmsModule } from './cfcfrms/cfcfrms.module';
|
||||
import { BankCalendarModule } from './bank-calendar/bank-calendar.module';
|
||||
import { DeafModule } from './deaf/deaf.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 { FiuModule } from './fiu/fiu.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 { environment } from '../environments/environment';
|
||||
import { CallbackComponent } from './zitadel/callback/callback.component';
|
||||
import { LoadingScreenComponent } from './shared/loading-screen/loading-screen.component';
|
||||
import { OAuthModule } from 'angular-oauth2-oidc';
|
||||
|
||||
export class CustomMissingTranslationHandler implements MissingTranslationHandler {
|
||||
@@ -176,6 +179,8 @@ export function HttpLoaderFactory(http: HttpClient) {
|
||||
BankCalendarModule,
|
||||
DeafModule,
|
||||
SavingsDailyInterestModule,
|
||||
FdDashboardModule,
|
||||
RdDashboardModule,
|
||||
DicgcModule,
|
||||
FiuModule,
|
||||
GlReportsModule,
|
||||
@@ -193,6 +198,7 @@ export function HttpLoaderFactory(http: HttpClient) {
|
||||
AppRoutingModule,
|
||||
NotFoundComponent,
|
||||
CallbackComponent,
|
||||
LoadingScreenComponent,
|
||||
OAuthModule.forRoot()
|
||||
],
|
||||
providers: [
|
||||
|
||||
@@ -44,8 +44,11 @@
|
||||
<div class="cal-stat sat2nd4th">
|
||||
<fa-icon icon="calendar-times" class="cal-stat__icon"></fa-icon>
|
||||
<div class="cal-stat__info">
|
||||
<div class="cal-stat__value">{{ overallStats.sat2nd4th }}</div>
|
||||
<div class="cal-stat__label">2nd & 4th Sat</div>
|
||||
<div class="cal-stat__value">{{ overallStats.sat2nd4th + overallStats.weeklyOff }}</div>
|
||||
<div class="cal-stat__label">
|
||||
{{ saturdayIsWorkingDay ? '2nd & 4th Sat' : 'Saturdays'
|
||||
}}{{ overallStats.weeklyOff > 0 ? ' / Weekly Off' : '' }}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="cal-stat holidays">
|
||||
@@ -69,7 +72,11 @@
|
||||
<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 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 today"></span> Today</div>
|
||||
</div>
|
||||
@@ -319,7 +326,9 @@
|
||||
[class.cal-grid__day--other]="!day.isCurrentMonth"
|
||||
[class.cal-grid__day--today]="day.isToday"
|
||||
[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--working]="day.isWorking && day.isCurrentMonth"
|
||||
[matTooltip]="
|
||||
@@ -328,9 +337,11 @@
|
||||
? '2nd Saturday Off'
|
||||
: day.is4thSaturday
|
||||
? '4th Saturday Off'
|
||||
: day.isSunday && day.isCurrentMonth
|
||||
? 'Sunday'
|
||||
: '')
|
||||
: day.isWeeklyOff && day.isCurrentMonth
|
||||
? weekDays[day.dayOfWeek] + ' Off (Working Days)'
|
||||
: day.isSunday && day.isCurrentMonth
|
||||
? 'Sunday'
|
||||
: '')
|
||||
"
|
||||
>
|
||||
{{ day.isCurrentMonth ? day.date : '' }}
|
||||
@@ -343,10 +354,15 @@
|
||||
@if (month.holidays > 0) {
|
||||
<span class="summary-tag holiday">{{ month.holidays }} Holiday{{ month.holidays > 1 ? 's' : '' }}</span>
|
||||
}
|
||||
<span class="summary-tag sun">{{ month.sundays }} Sun</span>
|
||||
@if (month.sundays > 0) {
|
||||
<span class="summary-tag sun">{{ month.sundays }} Sun</span>
|
||||
}
|
||||
@if (month.secondFourthSat > 0) {
|
||||
<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>
|
||||
}
|
||||
|
||||
@@ -63,6 +63,8 @@ interface CalendarDay {
|
||||
isSunday: boolean;
|
||||
is2ndSaturday: boolean;
|
||||
is4thSaturday: boolean;
|
||||
/** Off every week because Admin > Working Days leaves this weekday out (Sunday aside). */
|
||||
isWeeklyOff: boolean;
|
||||
holidayName?: string;
|
||||
dayOfWeek: number; // 0=Sun, 6=Sat
|
||||
}
|
||||
@@ -76,9 +78,23 @@ interface MonthData {
|
||||
nonWorkingDays: number;
|
||||
holidays: number;
|
||||
sundays: number;
|
||||
/** Saturdays off: the 2nd and 4th when Saturday is a working day, every one when it is not. */
|
||||
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({
|
||||
selector: 'mifosx-bank-calendar',
|
||||
templateUrl: './bank-calendar.component.html',
|
||||
@@ -118,7 +134,7 @@ export class BankCalendarComponent implements OnInit {
|
||||
];
|
||||
workingDaysConfig: string[] = [];
|
||||
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) ─────
|
||||
offices: OfficeLite[] = [];
|
||||
@@ -202,7 +218,8 @@ export class BankCalendarComponent implements OnInit {
|
||||
}
|
||||
|
||||
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 = [
|
||||
'MO',
|
||||
'TU',
|
||||
@@ -215,6 +232,19 @@ export class BankCalendarComponent implements OnInit {
|
||||
this.buildCalendar();
|
||||
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
|
||||
// calendar entries are surfaced in the list panel (calendar grid still
|
||||
// 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. */
|
||||
private rebuildHolidayRows(): void {
|
||||
this.holidayRows = this.holidays.map((h: any) => {
|
||||
@@ -511,7 +558,15 @@ export class BankCalendarComponent implements OnInit {
|
||||
|
||||
buildCalendar() {
|
||||
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++) {
|
||||
const monthData = this.buildMonth(m, this.currentYear);
|
||||
@@ -521,6 +576,7 @@ export class BankCalendarComponent implements OnInit {
|
||||
this.overallStats.holidays += monthData.holidays;
|
||||
this.overallStats.sundays += monthData.sundays;
|
||||
this.overallStats.sat2nd4th += monthData.secondFourthSat;
|
||||
this.overallStats.weeklyOff += monthData.weeklyOff;
|
||||
}
|
||||
this.overallStats.totalDays = this.overallStats.working + this.overallStats.nonWorking;
|
||||
}
|
||||
@@ -552,6 +608,7 @@ export class BankCalendarComponent implements OnInit {
|
||||
let holidayCount = 0;
|
||||
let sundayCount = 0;
|
||||
let sat2nd4thCount = 0;
|
||||
let weeklyOffCount = 0;
|
||||
|
||||
// Previous month padding
|
||||
const prevMonthDays = new Date(year, month, 0).getDate();
|
||||
@@ -567,6 +624,7 @@ export class BankCalendarComponent implements OnInit {
|
||||
isSunday: false,
|
||||
is2ndSaturday: false,
|
||||
is4thSaturday: false,
|
||||
isWeeklyOff: false,
|
||||
dayOfWeek: (startDayOfWeek - i - 1 + 7) % 7
|
||||
});
|
||||
}
|
||||
@@ -576,24 +634,29 @@ export class BankCalendarComponent implements OnInit {
|
||||
for (let d = 1; d <= daysInMonth; d++) {
|
||||
const date = new Date(year, month, d);
|
||||
const dow = date.getDay();
|
||||
const isSunday = dow === 0;
|
||||
const isSaturday = dow === 6;
|
||||
const isWorkingWeekday = this.workingDaysConfig.includes(DAY_CODES[dow]);
|
||||
const isSunday = dow === 0 && !isWorkingWeekday;
|
||||
|
||||
if (isSaturday) saturdayCount++;
|
||||
|
||||
const is2ndSat = isSaturday && saturdayCount === 2;
|
||||
const is4thSat = isSaturday && saturdayCount === 4;
|
||||
// RBI: when Saturday is a working day the 2nd and 4th are still off. When Working Days leaves
|
||||
// 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();
|
||||
|
||||
// Check if holiday
|
||||
const holiday = this.getHolidayForDate(year, month, d);
|
||||
const isHoliday = !!holiday;
|
||||
|
||||
// Non-working: Sunday, 2nd Sat, 4th Sat, or holiday
|
||||
const isNonWorking = isSunday || is2ndSat || is4thSat || isHoliday;
|
||||
// Non-working: Sunday, 2nd Sat, 4th Sat, a weekly off from Working Days, or holiday
|
||||
const isNonWorking = isSunday || is2ndSat || is4thSat || isWeeklyOff || isHoliday;
|
||||
|
||||
if (isSunday) sundayCount++;
|
||||
if (is2ndSat || is4thSat) sat2nd4thCount++;
|
||||
if (is2ndSat || is4thSat || (isSaturday && isWeeklyOff)) sat2nd4thCount++;
|
||||
if (isWeeklyOff && !isSaturday) weeklyOffCount++;
|
||||
if (isHoliday) holidayCount++;
|
||||
|
||||
if (isNonWorking) {
|
||||
@@ -613,6 +676,7 @@ export class BankCalendarComponent implements OnInit {
|
||||
isSunday,
|
||||
is2ndSaturday: is2ndSat,
|
||||
is4thSaturday: is4thSat,
|
||||
isWeeklyOff,
|
||||
holidayName: holiday?.name,
|
||||
dayOfWeek: dow
|
||||
});
|
||||
@@ -632,6 +696,7 @@ export class BankCalendarComponent implements OnInit {
|
||||
isSunday: false,
|
||||
is2ndSaturday: false,
|
||||
is4thSaturday: false,
|
||||
isWeeklyOff: false,
|
||||
dayOfWeek: days.length % 7
|
||||
});
|
||||
}
|
||||
@@ -645,7 +710,8 @@ export class BankCalendarComponent implements OnInit {
|
||||
nonWorkingDays: nonWorkingCount,
|
||||
holidays: holidayCount,
|
||||
sundays: sundayCount,
|
||||
secondFourthSat: sat2nd4thCount
|
||||
secondFourthSat: sat2nd4thCount,
|
||||
weeklyOff: weeklyOffCount
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -17,7 +17,12 @@
|
||||
</p>
|
||||
</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>
|
||||
{{ exporting ? 'Exporting…' : 'Export' }}
|
||||
</button>
|
||||
@@ -132,7 +137,9 @@
|
||||
<td class="num">{{ r.approvedShares | number }}</td>
|
||||
<td class="num strong">{{ r.shareValue | number: '1.2-2' }}</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>
|
||||
</tr>
|
||||
} @empty {
|
||||
|
||||
@@ -12,6 +12,7 @@ import { FormsModule } from '@angular/forms';
|
||||
import { MatIconModule } from '@angular/material/icon';
|
||||
import { catchError, of } from 'rxjs';
|
||||
import { CashierService } from '../cashier.service';
|
||||
import { businessToday } from 'app/core/utils/business-today';
|
||||
|
||||
interface RegisterRow {
|
||||
accountId: number;
|
||||
@@ -83,7 +84,7 @@ export class AccountRegisterComponent implements OnInit {
|
||||
|
||||
/** Local date — toISOString() would shift the day in any timezone behind UTC. */
|
||||
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')}`;
|
||||
}
|
||||
|
||||
|
||||
@@ -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>
|
||||
</div>
|
||||
@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
|
||||
</button>
|
||||
}
|
||||
</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) {
|
||||
<div class="bd-current bd-current--shut">
|
||||
<div class="bd-current__main">
|
||||
@@ -337,6 +352,51 @@
|
||||
</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
|
||||
automatically — so closing the day doesn't mean separately checking Vault Position and
|
||||
every cashier's own Cashier Position screen by hand. -->
|
||||
|
||||
@@ -195,6 +195,75 @@
|
||||
font-size: 11px;
|
||||
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 {
|
||||
@@ -372,3 +441,30 @@
|
||||
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);
|
||||
}
|
||||
|
||||
/**
|
||||
* 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 {
|
||||
this.loading = true;
|
||||
this.errorMsg = '';
|
||||
@@ -210,6 +227,7 @@ export class BranchDayComponent implements OnInit {
|
||||
this.canOperate = !!res?.canOperate;
|
||||
this.openDate = this.today;
|
||||
this.checkFirstOpenCashMigration();
|
||||
this.checkInterestReports();
|
||||
});
|
||||
|
||||
this.service
|
||||
|
||||
+2
-1
@@ -11,6 +11,7 @@ import { CommonModule } from '@angular/common';
|
||||
import { FormsModule } from '@angular/forms';
|
||||
import { AuthenticationService } from '../../core/authentication/authentication.service';
|
||||
import { ExportSheetSpec, exportWorkbook } from '../cashier-export';
|
||||
import { businessToday } from 'app/core/utils/business-today';
|
||||
|
||||
interface SummaryRow {
|
||||
code: string;
|
||||
@@ -1316,7 +1317,7 @@ export class CashClearingTransferSummaryComponent {
|
||||
private dateTag = '2607';
|
||||
|
||||
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.dateTag = `${String(d.getFullYear()).slice(-2)}${String(d.getMonth() + 1).padStart(2, '0')}`;
|
||||
|
||||
|
||||
@@ -22,50 +22,255 @@
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<!-- ── Customer Info Bar ── -->
|
||||
<div class="cx-customer-bar">
|
||||
<div
|
||||
class="cx-customer-toggle"
|
||||
[class.cx-customer-toggle--active]="isAccountHolder"
|
||||
(click)="toggleAccountHolder()"
|
||||
>
|
||||
<mat-icon>{{ isAccountHolder ? 'check_box' : 'check_box_outline_blank' }}</mat-icon>
|
||||
<span>Account Holder</span>
|
||||
</div>
|
||||
|
||||
@if (isAccountHolder) {
|
||||
<label class="cx-field cx-field--inline">
|
||||
<span>Account No.</span>
|
||||
<input
|
||||
type="text"
|
||||
[value]="accountNo"
|
||||
(input)="accountNo = $any($event.target).value"
|
||||
placeholder="e.g. 000101234567"
|
||||
/>
|
||||
</label>
|
||||
} @else {
|
||||
<span class="cx-walk-in"><mat-icon>person_outline</mat-icon> Walk-in Customer</span>
|
||||
}
|
||||
|
||||
@if (needsIdProof) {
|
||||
<div class="cx-pan-required">
|
||||
<mat-icon>warning</mat-icon>
|
||||
<span>Amount ≥ ₹50,000 — Customer PAN / Aadhaar required (RBI)</span>
|
||||
<!-- ── Cashier: whose drawer takes the customer's notes and gives the change ── -->
|
||||
@if (cashier) {
|
||||
<section class="cx-cashier">
|
||||
<div class="cx-avatar cx-avatar--sm">{{ cashierInitials }}</div>
|
||||
<div class="cx-cashier__id">
|
||||
<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>
|
||||
<label class="cx-field cx-field--inline cx-field--pan">
|
||||
<span>PAN / Aadhaar *</span>
|
||||
<input
|
||||
type="text"
|
||||
[value]="customerIdProof"
|
||||
(input)="customerIdProof = $any($event.target).value"
|
||||
placeholder="ABCDE1234F"
|
||||
maxlength="20"
|
||||
/>
|
||||
</label>
|
||||
}
|
||||
</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>badge</mat-icon> Existing Customer
|
||||
</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>
|
||||
</header>
|
||||
|
||||
<div class="cx-cust__body">
|
||||
<!-- Who -->
|
||||
<div class="cx-cust__who">
|
||||
@if (customerMode === 'existing') {
|
||||
@if (loadingCustomer) {
|
||||
<div class="cx-cust__empty">
|
||||
<mat-icon class="cx-cust__empty-icon cx-spin">autorenew</mat-icon>
|
||||
<div class="cx-cust__empty-title">Loading customer details…</div>
|
||||
</div>
|
||||
} @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 {
|
||||
<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>
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
<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
|
||||
type="text"
|
||||
[value]="walkInName"
|
||||
(input)="walkInName = $any($event.target).value"
|
||||
placeholder="Full name as on ID"
|
||||
maxlength="150"
|
||||
autocomplete="off"
|
||||
/>
|
||||
</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>
|
||||
|
||||
<!-- 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">
|
||||
<!-- 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 -->
|
||||
<section class="cx-col cx-col--received">
|
||||
<header class="cx-col-head">
|
||||
@@ -78,7 +283,7 @@
|
||||
<div class="cx-subhead">Bank Notes</div>
|
||||
}
|
||||
@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>
|
||||
<div class="cx-step">
|
||||
<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>
|
||||
</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"
|
||||
>₹{{ subtotal('r', d) | number: '1.2-2' }}</span
|
||||
>
|
||||
@@ -99,7 +307,7 @@
|
||||
<div class="cx-subhead">Coins</div>
|
||||
}
|
||||
@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>
|
||||
<div class="cx-step">
|
||||
<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>
|
||||
</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"
|
||||
>₹{{ subtotal('r', d) | number: '1.2-2' }}</span
|
||||
>
|
||||
@@ -131,7 +342,7 @@
|
||||
<div class="cx-subhead">Bank Notes</div>
|
||||
}
|
||||
@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>
|
||||
<div class="cx-step">
|
||||
<button type="button" (click)="bump('g', d, -1)" [disabled]="countFor('g', d) === 0">−</button>
|
||||
@@ -141,8 +352,16 @@
|
||||
[value]="countFor('g', d)"
|
||||
(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>
|
||||
<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"
|
||||
>₹{{ subtotal('g', d) | number: '1.2-2' }}</span
|
||||
>
|
||||
@@ -152,7 +371,7 @@
|
||||
<div class="cx-subhead">Coins</div>
|
||||
}
|
||||
@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>
|
||||
<div class="cx-step">
|
||||
<button type="button" (click)="bump('g', d, -1)" [disabled]="countFor('g', d) === 0">−</button>
|
||||
@@ -162,8 +381,16 @@
|
||||
[value]="countFor('g', d)"
|
||||
(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>
|
||||
<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"
|
||||
>₹{{ subtotal('g', d) | number: '1.2-2' }}</span
|
||||
>
|
||||
@@ -173,6 +400,30 @@
|
||||
</section>
|
||||
</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-sum">
|
||||
<span>Received</span><strong>₹{{ receivedTotal | number: '1.2-2' }}</strong>
|
||||
@@ -213,9 +464,15 @@
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
@if (needsIdProof && !customerIdProof.trim()) {
|
||||
@if (panMissing) {
|
||||
<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>
|
||||
}
|
||||
|
||||
|
||||
@@ -73,99 +73,584 @@
|
||||
}
|
||||
}
|
||||
|
||||
/* ── Customer info bar ── */
|
||||
.cx-customer-bar {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
padding: 10px 14px;
|
||||
background: #f8fafc;
|
||||
/* ── Customer: existing client or walk-in, plus PAN ── */
|
||||
.cx-cust {
|
||||
background: #fff;
|
||||
border: 1px solid #e2e8f0;
|
||||
border-radius: 10px;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
border-radius: 14px;
|
||||
box-shadow: 0 1px 2px rgb(15 23 42 / 4%);
|
||||
overflow: hidden;
|
||||
|
||||
.cx-customer-toggle {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
font-size: 13px;
|
||||
font-weight: 600;
|
||||
color: #475569;
|
||||
cursor: pointer;
|
||||
user-select: none;
|
||||
&__head {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
flex-wrap: wrap;
|
||||
gap: 12px;
|
||||
padding: 12px 16px;
|
||||
background: linear-gradient(180deg, #f8fafc, #f1f5f9);
|
||||
border-bottom: 1px solid #e2e8f0;
|
||||
}
|
||||
|
||||
mat-icon {
|
||||
font-size: 20px;
|
||||
height: 20px;
|
||||
width: 20px;
|
||||
&__title {
|
||||
display: flex;
|
||||
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;
|
||||
height: 32px;
|
||||
padding: 0 14px;
|
||||
border: 0;
|
||||
border-radius: 8px;
|
||||
background: transparent;
|
||||
font-size: 13px;
|
||||
font-weight: 600;
|
||||
color: #475569;
|
||||
cursor: pointer;
|
||||
transition:
|
||||
background 0.15s ease,
|
||||
color 0.15s ease;
|
||||
|
||||
mat-icon {
|
||||
font-size: 18px;
|
||||
width: 18px;
|
||||
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;
|
||||
}
|
||||
|
||||
&--active {
|
||||
color: #1e40af;
|
||||
&__empty-title {
|
||||
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 {
|
||||
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 {
|
||||
display: flex;
|
||||
.cx-kyc {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
font-size: 12px;
|
||||
color: #64748b;
|
||||
padding: 2px 8px;
|
||||
border-radius: 999px;
|
||||
font-size: 11px;
|
||||
font-weight: 600;
|
||||
|
||||
mat-icon {
|
||||
font-size: 16px;
|
||||
height: 16px;
|
||||
width: 16px;
|
||||
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-pan-required {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
padding: 4px 10px;
|
||||
background: #fffbeb;
|
||||
border: 1px solid #fcd34d;
|
||||
.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: #92400e;
|
||||
color: #1e40af;
|
||||
cursor: pointer;
|
||||
|
||||
mat-icon {
|
||||
font-size: 16px;
|
||||
height: 16px;
|
||||
width: 16px;
|
||||
color: #d97706;
|
||||
height: 16px;
|
||||
}
|
||||
|
||||
&:hover {
|
||||
background: #eff6ff;
|
||||
}
|
||||
|
||||
&--danger {
|
||||
color: #b91c1c;
|
||||
|
||||
&:hover {
|
||||
background: #fef2f2;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.cx-field--inline {
|
||||
flex-direction: row;
|
||||
align-items: center;
|
||||
/* 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;
|
||||
|
||||
span {
|
||||
white-space: nowrap;
|
||||
&__head {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
|
||||
mat-icon {
|
||||
font-size: 20px;
|
||||
width: 20px;
|
||||
height: 20px;
|
||||
color: #475569;
|
||||
}
|
||||
}
|
||||
|
||||
input {
|
||||
width: 180px;
|
||||
&__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;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
font-size: 12px;
|
||||
color: #64748b;
|
||||
|
||||
mat-icon {
|
||||
font-size: 14px;
|
||||
width: 14px;
|
||||
height: 14px;
|
||||
}
|
||||
}
|
||||
|
||||
&--required {
|
||||
background: #fffbeb;
|
||||
border-color: #fcd34d;
|
||||
|
||||
.cx-pan__input input {
|
||||
border-color: #f59e0b;
|
||||
}
|
||||
|
||||
.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-field--pan input {
|
||||
width: 160px;
|
||||
border-color: #fcd34d;
|
||||
.cx-spin {
|
||||
animation: cx-spin 1s linear infinite;
|
||||
}
|
||||
|
||||
&:focus {
|
||||
outline: none;
|
||||
border-color: #d97706;
|
||||
@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;
|
||||
}
|
||||
|
||||
/* 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 {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
|
||||
@@ -11,7 +11,16 @@ import { CommonModule } from '@angular/common';
|
||||
import { FormsModule } from '@angular/forms';
|
||||
import { MatIconModule } from '@angular/material/icon';
|
||||
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 { 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 {
|
||||
id: number;
|
||||
@@ -20,6 +29,47 @@ interface Denom {
|
||||
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). */
|
||||
@Component({
|
||||
selector: 'mifosx-cash-exchange-entry',
|
||||
@@ -30,11 +80,14 @@ interface Denom {
|
||||
CommonModule,
|
||||
FormsModule,
|
||||
MatIconModule,
|
||||
MatSnackBarModule
|
||||
MatSnackBarModule,
|
||||
MatDialogModule
|
||||
]
|
||||
})
|
||||
export class CashExchangeEntryComponent implements OnInit {
|
||||
private service = inject(CashierService);
|
||||
private clientsService = inject(ClientsService);
|
||||
private dialog = inject(MatDialog);
|
||||
private snackBar = inject(MatSnackBar);
|
||||
|
||||
notes: Denom[] = [];
|
||||
@@ -46,11 +99,17 @@ export class CashExchangeEntryComponent implements OnInit {
|
||||
saving = false;
|
||||
|
||||
// ── Customer info ──
|
||||
isAccountHolder = false;
|
||||
accountNo = '';
|
||||
customerIdProof = '';
|
||||
/** An existing client found through Customer Search, or a walk-in identified at the counter. */
|
||||
customerMode: 'existing' | 'walkin' = 'walkin';
|
||||
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>();
|
||||
positionLoaded = false;
|
||||
|
||||
@@ -63,7 +122,7 @@ export class CashExchangeEntryComponent implements OnInit {
|
||||
}
|
||||
|
||||
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.service.getActiveDenominations().subscribe({
|
||||
next: (list) => {
|
||||
@@ -87,7 +146,19 @@ export class CashExchangeEntryComponent implements OnInit {
|
||||
((res?.denominations || []) as any[]).forEach((d: any) => {
|
||||
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;
|
||||
if (cashierId) this.loadCashierDetails(cashierId);
|
||||
},
|
||||
error: () => {
|
||||
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 {
|
||||
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;
|
||||
}
|
||||
|
||||
/** Above ₹50,000 PAN/ID is mandatory per RBI guideline */
|
||||
get needsIdProof(): boolean {
|
||||
return this.receivedTotal >= 50000;
|
||||
/** Above ₹50,000 the customer's PAN is mandatory (Income Tax Rule 114B, followed under RBI guidelines). */
|
||||
get needsPan(): boolean {
|
||||
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. */
|
||||
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
|
||||
.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);
|
||||
@@ -146,21 +330,77 @@ export class CashExchangeEntryComponent implements OnInit {
|
||||
}
|
||||
|
||||
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 {
|
||||
this.isAccountHolder = !this.isAccountHolder;
|
||||
if (!this.isAccountHolder) this.accountNo = '';
|
||||
setCustomerMode(mode: 'existing' | 'walkin'): void {
|
||||
if (this.customerMode === mode) return;
|
||||
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 {
|
||||
this.receivedCounts = {};
|
||||
this.givenCounts = {};
|
||||
this.note = '';
|
||||
this.accountNo = '';
|
||||
this.customerIdProof = '';
|
||||
this.isAccountHolder = false;
|
||||
this.customerMode = 'walkin';
|
||||
this.selectedCustomer = null;
|
||||
this.walkInName = '';
|
||||
this.walkInMobile = '';
|
||||
this.panNumber = '';
|
||||
}
|
||||
|
||||
submit(): void {
|
||||
@@ -170,11 +410,17 @@ export class CashExchangeEntryComponent implements OnInit {
|
||||
this.denoms
|
||||
.filter((d) => this.countFor(side, d) > 0)
|
||||
.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 = {
|
||||
exchangeDate: this.exchangeDate,
|
||||
note: this.note || null,
|
||||
customerIdProof: this.customerIdProof.trim() || null,
|
||||
accountNo: this.isAccountHolder && this.accountNo.trim() ? this.accountNo.trim() : null,
|
||||
clientId: customer?.clientId ?? 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'),
|
||||
given: build('g')
|
||||
};
|
||||
|
||||
@@ -11,25 +11,11 @@
|
||||
<div>
|
||||
<div class="pg-eyebrow">CASHIER · HISTORY</div>
|
||||
<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>
|
||||
<button type="button" class="pg-btn pg-btn--ghost" (click)="load()"><mat-icon>refresh</mat-icon> Refresh</button>
|
||||
</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) {
|
||||
<div class="pg-empty">Loading…</div>
|
||||
}
|
||||
@@ -134,11 +120,6 @@
|
||||
>
|
||||
<mat-icon>visibility</mat-icon>
|
||||
</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>
|
||||
</td>
|
||||
</tr>
|
||||
@@ -187,6 +168,12 @@
|
||||
<span class="detail-meta__label">Date</span>
|
||||
<span class="detail-meta__val">{{ detail.row.date }}</span>
|
||||
</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">
|
||||
<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>
|
||||
|
||||
@@ -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-btns {
|
||||
display: flex;
|
||||
|
||||
@@ -8,9 +8,7 @@
|
||||
|
||||
import { Component, OnInit, inject } from '@angular/core';
|
||||
import { CommonModule } from '@angular/common';
|
||||
import { FormsModule } from '@angular/forms';
|
||||
import { MatIconModule } from '@angular/material/icon';
|
||||
import { MatSnackBar, MatSnackBarModule } from '@angular/material/snack-bar';
|
||||
import { MatTabsModule } from '@angular/material/tabs';
|
||||
import { MatPaginatorModule, PageEvent } from '@angular/material/paginator';
|
||||
import { forkJoin, of } from 'rxjs';
|
||||
@@ -31,6 +29,8 @@ interface TxnRow {
|
||||
reversedBy: number | null;
|
||||
reversedAt: string | null;
|
||||
reverseReason: string | null;
|
||||
/** Customer PAN recorded on a cash exchange. */
|
||||
panNumber?: string | null;
|
||||
type: 'transfer' | 'settlement' | 'inter' | 'exchange';
|
||||
}
|
||||
|
||||
@@ -52,7 +52,10 @@ interface DetailView {
|
||||
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({
|
||||
selector: 'mifosx-cash-history',
|
||||
templateUrl: './cash-history.component.html',
|
||||
@@ -63,16 +66,13 @@ interface DetailView {
|
||||
standalone: true,
|
||||
imports: [
|
||||
CommonModule,
|
||||
FormsModule,
|
||||
MatIconModule,
|
||||
MatSnackBarModule,
|
||||
MatTabsModule,
|
||||
MatPaginatorModule
|
||||
]
|
||||
})
|
||||
export class CashHistoryComponent implements OnInit {
|
||||
private service = inject(CashierService);
|
||||
private snackBar = inject(MatSnackBar);
|
||||
|
||||
transfers: TxnRow[] = [];
|
||||
settlements: TxnRow[] = [];
|
||||
@@ -99,10 +99,6 @@ export class CashHistoryComponent implements OnInit {
|
||||
|
||||
private denomKindMap = new Map<number, 'note' | 'coin'>();
|
||||
|
||||
reversingId: number | null = null;
|
||||
reversingType: string | null = null;
|
||||
reverseReason = '';
|
||||
|
||||
detail: DetailView | null = null;
|
||||
|
||||
ngOnInit(): void {
|
||||
@@ -212,7 +208,8 @@ export class CashHistoryComponent implements OnInit {
|
||||
this.exchanges = (exchangeItems as any[]).map((e) => ({
|
||||
id: e.id,
|
||||
date: e.exchangeDate,
|
||||
fromParty: e.accountNo ? `Account ${e.accountNo}` : 'Walk-in Customer',
|
||||
fromParty: this.exchangeParty(e),
|
||||
panNumber: e.panNumber ?? null,
|
||||
toParty: e.performedByUsername || '—',
|
||||
amount: e.exchangeAmount,
|
||||
note: e.note,
|
||||
@@ -310,7 +307,8 @@ export class CashHistoryComponent implements OnInit {
|
||||
this.exchanges = (res.pageItems ?? []).map((e: any) => ({
|
||||
id: e.id,
|
||||
date: e.exchangeDate,
|
||||
fromParty: e.accountNo ? `Account ${e.accountNo}` : 'Walk-in Customer',
|
||||
fromParty: this.exchangeParty(e),
|
||||
panNumber: e.panNumber ?? null,
|
||||
toParty: e.performedByUsername || '—',
|
||||
amount: e.exchangeAmount,
|
||||
note: e.note,
|
||||
@@ -392,39 +390,12 @@ export class CashHistoryComponent implements OnInit {
|
||||
this.detail = null;
|
||||
}
|
||||
|
||||
startReverse(row: TxnRow): void {
|
||||
this.reversingId = row.id;
|
||||
this.reversingType = row.type;
|
||||
this.reverseReason = '';
|
||||
}
|
||||
|
||||
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 });
|
||||
}
|
||||
});
|
||||
/** Who the exchange was with: the customer's name (and account, if one), else the account, else a walk-in. */
|
||||
private exchangeParty(e: any): string {
|
||||
if (e.customerName) {
|
||||
return e.accountNo ? `${e.customerName} (A/C ${e.accountNo})` : e.customerName;
|
||||
}
|
||||
return e.accountNo ? `Account ${e.accountNo}` : 'Walk-in Customer';
|
||||
}
|
||||
|
||||
typeLabel(type: string): string {
|
||||
|
||||
@@ -18,6 +18,7 @@ import { MatInputModule } from '@angular/material/input';
|
||||
import { forkJoin, of } from 'rxjs';
|
||||
import { catchError } from 'rxjs/operators';
|
||||
import { CashierService } from '../cashier.service';
|
||||
import { businessToday } from 'app/core/utils/business-today';
|
||||
|
||||
type PageState = 'loading' | 'no-cashier' | 'needs-reconciliation' | 'already-settled' | 'form';
|
||||
|
||||
@@ -90,7 +91,7 @@ export class CashSettlementComponent implements OnInit {
|
||||
saving = false;
|
||||
|
||||
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.service
|
||||
.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>
|
||||
<p class="pg-sub">Move cash denomination-wise from one party to another (e.g. Vault → Cashier for opening).</p>
|
||||
</div>
|
||||
<button type="button" class="pg-btn pg-btn--ghost" (click)="clear()"><mat-icon>undo</mat-icon> Clear</button>
|
||||
<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>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<div class="pg-toolbar pg-toolbar--mat">
|
||||
@@ -155,7 +163,56 @@
|
||||
<div class="pg-total-bar">
|
||||
Total to transfer: <strong>₹{{ total | number: '1.2-2' }}</strong>
|
||||
<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>
|
||||
</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;
|
||||
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/.
|
||||
*/
|
||||
|
||||
import { Component, OnInit, inject } from '@angular/core';
|
||||
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 { MatFormFieldModule } from '@angular/material/form-field';
|
||||
@@ -17,6 +18,7 @@ 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 Denom {
|
||||
id: number;
|
||||
@@ -38,6 +40,7 @@ interface Denom {
|
||||
CommonModule,
|
||||
FormsModule,
|
||||
MatIconModule,
|
||||
MatDialogModule,
|
||||
MatSnackBarModule,
|
||||
MatFormFieldModule,
|
||||
MatSelectModule,
|
||||
@@ -47,6 +50,10 @@ interface Denom {
|
||||
export class CashTransferComponent implements OnInit {
|
||||
private service = inject(CashierService);
|
||||
private snackBar = inject(MatSnackBar);
|
||||
private dialog = inject(MatDialog);
|
||||
|
||||
@ViewChild('requestsDialog') requestsDialog!: TemplateRef<unknown>;
|
||||
private requestsRef: MatDialogRef<unknown> | null = null;
|
||||
|
||||
notes: Denom[] = [];
|
||||
coins: Denom[] = [];
|
||||
@@ -81,8 +88,35 @@ export class CashTransferComponent implements OnInit {
|
||||
vaultHasBreakdown = true;
|
||||
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 {
|
||||
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.service.getActiveDenominations().subscribe({
|
||||
next: (list) => {
|
||||
@@ -244,16 +278,11 @@ export class CashTransferComponent implements OnInit {
|
||||
.filter((d) => this.countFor(d) > 0)
|
||||
.map((d) => ({ denominationId: d.id, denominationValue: d.value, count: this.countFor(d) }))
|
||||
};
|
||||
this.service.createCashTransfer(payload).subscribe({
|
||||
next: (res: any) => {
|
||||
if (res?.glWarning) {
|
||||
this.snackBar.open(`Transfer saved (GL warning: ${res.glWarning})`, 'Close', {
|
||||
duration: 8000,
|
||||
panelClass: ['snack-warn']
|
||||
});
|
||||
} else {
|
||||
this.snackBar.open('Cash transfer recorded', 'Close', { duration: 3000 });
|
||||
}
|
||||
this.service.createCashTransferRequest(payload).subscribe({
|
||||
next: () => {
|
||||
// Nothing has moved yet: a manager approves the allocation first, and only then does the cash leave the vault.
|
||||
this.snackBar.open('Cash transfer sent for manager approval', 'Close', { duration: 5000 });
|
||||
this.loadMyRequests();
|
||||
this.clear();
|
||||
this.note = '';
|
||||
this.fromParty = '';
|
||||
|
||||
@@ -17,6 +17,7 @@ import { forkJoin, of } from 'rxjs';
|
||||
import { catchError } from 'rxjs/operators';
|
||||
import { CashierService } from '../cashier.service';
|
||||
import { AuthenticationService } from '../../core/authentication/authentication.service';
|
||||
import { businessToday } from 'app/core/utils/business-today';
|
||||
|
||||
interface DenomRow {
|
||||
denominationId: number;
|
||||
@@ -71,7 +72,7 @@ export class CashierPositionComponent implements OnInit {
|
||||
isCashierMode = false;
|
||||
|
||||
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.isCashierMode = this.authService.isCashierUser();
|
||||
|
||||
@@ -23,6 +23,7 @@ import { DayBookComponent } from './day-book/day-book.component';
|
||||
import { VoucherReportComponent } from './voucher-report/voucher-report.component';
|
||||
import { BranchDayComponent } from './branch-day/branch-day.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 { ClosingCashReportComponent } from './closing-cash-report/closing-cash-report.component';
|
||||
import { ExcessTransactionReportComponent } from './excess-transaction-report/excess-transaction-report.component';
|
||||
@@ -82,6 +83,13 @@ const routes: Routes = [
|
||||
),
|
||||
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',
|
||||
loadComponent: () =>
|
||||
@@ -225,6 +233,13 @@ const routes: Routes = [
|
||||
component: VaultPositionComponent,
|
||||
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
|
||||
// signing the day off actually looks. It carries the Officer Verification Sheet — each
|
||||
@@ -283,6 +298,12 @@ const routes: Routes = [
|
||||
component: ReconciliationComponent,
|
||||
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',
|
||||
component: CashHistoryComponent,
|
||||
|
||||
@@ -32,9 +32,6 @@ export class CashierService {
|
||||
getCashExchange(id: number): Observable<any> {
|
||||
return this.http.get(`/cashexchange/${id}`);
|
||||
}
|
||||
reverseCashExchange(id: number, reason: string): Observable<any> {
|
||||
return this.http.post(`/cashexchange/${id}/reverse`, { reason });
|
||||
}
|
||||
|
||||
// ── Tellers / Cashiers ──
|
||||
getTellers(officeId?: number): Observable<any[]> {
|
||||
@@ -50,8 +47,21 @@ export class CashierService {
|
||||
}
|
||||
|
||||
// ── Cash Transfer ──
|
||||
createCashTransfer(data: any): Observable<any> {
|
||||
return this.http.post('/cashtransfer', data);
|
||||
/** Cash leaves the vault only when a different person approves, so the screen raises a request, not a transfer. */
|
||||
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> {
|
||||
const params = new HttpParams().set('offset', offset).set('limit', limit);
|
||||
@@ -60,9 +70,6 @@ export class CashierService {
|
||||
getCashTransferById(id: number): Observable<any> {
|
||||
return this.http.get(`/cashtransfer/${id}`);
|
||||
}
|
||||
reverseCashTransfer(id: number, reason: string): Observable<any> {
|
||||
return this.http.post(`/cashtransfer/${id}/reverse`, { reason });
|
||||
}
|
||||
|
||||
// ── Cash Settlement ──
|
||||
createCashSettlement(data: any): Observable<any> {
|
||||
@@ -75,9 +82,6 @@ export class CashierService {
|
||||
getCashSettlementById(id: number): Observable<any> {
|
||||
return this.http.get(`/cashsettlement/${id}`);
|
||||
}
|
||||
reverseCashSettlement(id: number, reason: string): Observable<any> {
|
||||
return this.http.post(`/cashsettlement/${id}/reverse`, { reason });
|
||||
}
|
||||
|
||||
// ── Inter-cashier Transfer ──
|
||||
createInterCashierTransfer(data: any): Observable<any> {
|
||||
@@ -90,8 +94,10 @@ export class CashierService {
|
||||
getInterCashierById(id: number): Observable<any> {
|
||||
return this.http.get(`/cashintercashier/${id}`);
|
||||
}
|
||||
reverseInterCashierTransfer(id: number, reason: string): Observable<any> {
|
||||
return this.http.post(`/cashintercashier/${id}/reverse`, { reason });
|
||||
/** One cashier's details, today's vault allocation and denomination-wise cash, for either side of a transfer. */
|
||||
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 ──
|
||||
@@ -205,6 +211,23 @@ export class CashierService {
|
||||
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. */
|
||||
branchDayMissed(officeId?: number): Observable<any> {
|
||||
let params = new HttpParams();
|
||||
@@ -301,6 +324,16 @@ export class CashierService {
|
||||
if (asOfDate) params = params.set('asOfDate', asOfDate);
|
||||
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[]> {
|
||||
let params = new HttpParams().set('threshold', String(threshold));
|
||||
if (fromDate) params = params.set('fromDate', fromDate);
|
||||
|
||||
@@ -51,6 +51,12 @@ export class ClerkShareWithdrawalService {
|
||||
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> {
|
||||
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 { MatPaginatorModule, PageEvent } from '@angular/material/paginator';
|
||||
import { CashierService } from '../cashier.service';
|
||||
import { businessToday } from 'app/core/utils/business-today';
|
||||
|
||||
/** Day-wise denomination-wise closing cash collected from deposits. */
|
||||
@Component({
|
||||
@@ -204,7 +205,7 @@ export class ClosingCashReportComponent implements OnInit {
|
||||
}
|
||||
|
||||
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.load();
|
||||
this.service.getTellers().subscribe({ next: (t: any[]) => (this.tellers = t || []), error: () => {} });
|
||||
|
||||
@@ -202,6 +202,7 @@
|
||||
<th class="num">Amount</th>
|
||||
<th>Payment</th>
|
||||
<th>Status</th>
|
||||
<th>Correction</th>
|
||||
<th>By</th>
|
||||
<th>Approved By</th>
|
||||
<th>Time</th>
|
||||
@@ -231,6 +232,18 @@
|
||||
<td>
|
||||
<span [class]="statusClass(r.status)">{{ r.status }}</span>
|
||||
</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.approvedBy ?? '—' }}
|
||||
@@ -256,7 +269,7 @@
|
||||
—
|
||||
}
|
||||
</td>
|
||||
<td colspan="5"></td>
|
||||
<td colspan="6"></td>
|
||||
</tr>
|
||||
</tfoot>
|
||||
</table>
|
||||
|
||||
@@ -13,6 +13,8 @@ import { MatIconModule } from '@angular/material/icon';
|
||||
import { Router } from '@angular/router';
|
||||
import { catchError, of } from 'rxjs';
|
||||
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.
|
||||
@@ -25,6 +27,11 @@ type DayBookSection = 'CASHIER' | 'CLERK' | 'SYSTEM';
|
||||
|
||||
interface DayBookRow {
|
||||
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;
|
||||
side: 'CR' | 'DR';
|
||||
kind: string;
|
||||
@@ -57,7 +64,8 @@ interface DayBookRow {
|
||||
imports: [
|
||||
CommonModule,
|
||||
FormsModule,
|
||||
MatIconModule
|
||||
MatIconModule,
|
||||
VoucherStatusComponent
|
||||
]
|
||||
})
|
||||
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. */
|
||||
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')}`;
|
||||
}
|
||||
|
||||
|
||||
@@ -11,41 +11,29 @@
|
||||
<div>
|
||||
<div class="pg-eyebrow">CASHIER · INTER-CASHIER TRANSFER</div>
|
||||
<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>
|
||||
<button type="button" class="pg-btn pg-btn--ghost" (click)="clear()"><mat-icon>undo</mat-icon> Clear</button>
|
||||
</header>
|
||||
|
||||
<div class="pg-toolbar pg-toolbar--mat">
|
||||
@if (tellers.length === 1) {
|
||||
<mat-form-field appearance="outline" subscriptSizing="dynamic">
|
||||
<mat-label>Teller</mat-label>
|
||||
<input matInput type="text" [value]="tellers[0].name" readonly />
|
||||
</mat-form-field>
|
||||
} @else {
|
||||
<mat-form-field appearance="outline" subscriptSizing="dynamic">
|
||||
<mat-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>
|
||||
}
|
||||
@if (ownCashierMissing) {
|
||||
<div class="ict-alert">
|
||||
<mat-icon>person_off</mat-icon>
|
||||
Your login is not linked to a cashier. An inter-cashier transfer is made by the cashier handing the cash over, so
|
||||
log in as that cashier.
|
||||
</div>
|
||||
}
|
||||
|
||||
<div class="pg-toolbar pg-toolbar--mat">
|
||||
<mat-form-field appearance="outline" subscriptSizing="dynamic">
|
||||
<mat-label>From (Cashier)</mat-label>
|
||||
<mat-select [value]="fromCashierId" (selectionChange)="onFromSelect($event.value)" [disabled]="!selectedTellerId">
|
||||
@for (c of availableFrom; track c.id) {
|
||||
<mat-option [value]="c.id">{{ c.label }}</mat-option>
|
||||
}
|
||||
@if (selectedTellerId && allCashiers.length === 0 && !loadingCashiers) {
|
||||
<mat-option [value]="null" disabled>No cashiers</mat-option>
|
||||
}
|
||||
</mat-select>
|
||||
<mat-label>Teller</mat-label>
|
||||
<input matInput type="text" [value]="tellerName || '—'" readonly />
|
||||
</mat-form-field>
|
||||
|
||||
<!-- The sender is always the cashier who is logged in; nothing to pick. -->
|
||||
<mat-form-field appearance="outline" subscriptSizing="dynamic">
|
||||
<mat-label>From (You)</mat-label>
|
||||
<input matInput type="text" [value]="fromParty || '—'" readonly />
|
||||
</mat-form-field>
|
||||
|
||||
<mat-form-field appearance="outline" subscriptSizing="dynamic">
|
||||
@@ -54,7 +42,7 @@
|
||||
@for (c of availableTo; track c.id) {
|
||||
<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-select>
|
||||
@@ -62,7 +50,7 @@
|
||||
|
||||
<mat-form-field appearance="outline" subscriptSizing="dynamic">
|
||||
<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 appearance="outline" subscriptSizing="dynamic" class="pg-note-field">
|
||||
@@ -71,26 +59,102 @@
|
||||
</mat-form-field>
|
||||
</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">
|
||||
<table class="pg-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Denomination</th>
|
||||
<th class="num">From holds</th>
|
||||
<th style="text-align: center">Count</th>
|
||||
<th class="num">Amount</th>
|
||||
<th class="num">To holds</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
@if (notes.length) {
|
||||
<tr class="pg-section">
|
||||
<td colspan="3">Bank Notes</td>
|
||||
<td colspan="5">Bank Notes</td>
|
||||
</tr>
|
||||
}
|
||||
@for (d of notes; track d.id) {
|
||||
<tr>
|
||||
<tr [class.ict-row--over]="exceedsHeld(d)">
|
||||
<td>
|
||||
<span class="pg-chip">{{ d.label }}</span>
|
||||
</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">
|
||||
<div class="pg-step">
|
||||
<button type="button" (click)="bump(d, -1)" [disabled]="countFor(d) === 0">−</button>
|
||||
@@ -99,18 +163,28 @@
|
||||
</div>
|
||||
</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>
|
||||
}
|
||||
@if (coins.length) {
|
||||
<tr class="pg-section">
|
||||
<td colspan="3">Coins</td>
|
||||
<td colspan="5">Coins</td>
|
||||
</tr>
|
||||
}
|
||||
@for (d of coins; track d.id) {
|
||||
<tr>
|
||||
<tr [class.ict-row--over]="exceedsHeld(d)">
|
||||
<td>
|
||||
<span class="pg-chip pg-chip--coin">{{ d.label }}</span>
|
||||
</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">
|
||||
<div class="pg-step">
|
||||
<button type="button" (click)="bump(d, -1)" [disabled]="countFor(d) === 0">−</button>
|
||||
@@ -119,17 +193,34 @@
|
||||
</div>
|
||||
</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>
|
||||
}
|
||||
@if (denoms.length === 0) {
|
||||
<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>
|
||||
}
|
||||
</tbody>
|
||||
</table>
|
||||
</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">
|
||||
Total to transfer: <strong>₹{{ total | number: '1.2-2' }}</strong>
|
||||
<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 { catchError } from 'rxjs/operators';
|
||||
import { CashierService } from '../cashier.service';
|
||||
import { businessToday } from 'app/core/utils/business-today';
|
||||
|
||||
interface Denom {
|
||||
id: number;
|
||||
@@ -28,12 +29,27 @@ interface Cashier {
|
||||
id: number;
|
||||
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. */
|
||||
@Component({
|
||||
selector: 'mifosx-inter-cashier-transfer',
|
||||
templateUrl: './inter-cashier-transfer.component.html',
|
||||
styleUrls: ['../cashier-shared.scss'],
|
||||
styleUrls: [
|
||||
'../cashier-shared.scss',
|
||||
'./inter-cashier-transfer.component.scss'
|
||||
],
|
||||
standalone: true,
|
||||
imports: [
|
||||
CommonModule,
|
||||
@@ -56,7 +72,12 @@ export class InterCashierTransferComponent implements OnInit {
|
||||
note = '';
|
||||
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[] = [];
|
||||
selectedTellerId: number | null = null;
|
||||
fromCashierId: number | null = null;
|
||||
@@ -65,21 +86,25 @@ export class InterCashierTransferComponent implements OnInit {
|
||||
toParty = '';
|
||||
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[] {
|
||||
return [
|
||||
...this.notes,
|
||||
...this.coins
|
||||
];
|
||||
}
|
||||
get availableFrom(): Cashier[] {
|
||||
return this.allCashiers;
|
||||
}
|
||||
/** Everyone under the same teller except the sender. */
|
||||
get availableTo(): Cashier[] {
|
||||
return this.allCashiers.filter((c) => c.id !== this.fromCashierId);
|
||||
}
|
||||
|
||||
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.service.getActiveDenominations().subscribe({
|
||||
next: (list) => {
|
||||
@@ -97,25 +122,20 @@ export class InterCashierTransferComponent implements OnInit {
|
||||
this.coins = [];
|
||||
}
|
||||
});
|
||||
this.service
|
||||
.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);
|
||||
}
|
||||
});
|
||||
// The sender is the cashier behind this login; their counter then gives the receivers to pick from.
|
||||
this.service.getCurrentCashierInfo().subscribe((info) => {
|
||||
if (!info) {
|
||||
this.ownCashierMissing = true;
|
||||
return;
|
||||
}
|
||||
this.fromCashierId = info.cashierId;
|
||||
this.fromParty = info.cashierName;
|
||||
this.loadSummary('from');
|
||||
});
|
||||
}
|
||||
|
||||
onTellerSelect(tellerId: number): void {
|
||||
this.selectedTellerId = tellerId;
|
||||
this.fromCashierId = null;
|
||||
this.toCashierId = null;
|
||||
this.fromParty = '';
|
||||
this.toParty = '';
|
||||
this.allCashiers = [];
|
||||
if (!tellerId) return;
|
||||
/** The other cashiers at this counter, one of whom receives the cash. */
|
||||
private loadReceivers(tellerId: number): void {
|
||||
this.loadingCashiers = true;
|
||||
this.service
|
||||
.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 {
|
||||
this.toCashierId = cashierId;
|
||||
const c = this.allCashiers.find((x) => x.id === cashierId);
|
||||
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 {
|
||||
@@ -159,7 +249,14 @@ export class InterCashierTransferComponent implements OnInit {
|
||||
return this.denoms.reduce((s, d) => s + this.subtotal(d), 0);
|
||||
}
|
||||
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 {
|
||||
@@ -186,10 +283,12 @@ export class InterCashierTransferComponent implements OnInit {
|
||||
this.snackBar.open('Inter-cashier transfer recorded', 'Close', { duration: 3000 });
|
||||
this.clear();
|
||||
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.fromParty = '';
|
||||
this.toParty = '';
|
||||
this.toSummary = null;
|
||||
this.loadSummary('from');
|
||||
this.saving = false;
|
||||
},
|
||||
error: () => {
|
||||
|
||||
@@ -146,6 +146,7 @@
|
||||
<th class="num">Amount</th>
|
||||
<th>My part</th>
|
||||
<th>Status</th>
|
||||
<th>Correction</th>
|
||||
<th>Time</th>
|
||||
</tr>
|
||||
</thead>
|
||||
@@ -203,6 +204,18 @@
|
||||
{{ statusLabel(r.verificationStatus) }}
|
||||
</span>
|
||||
</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>
|
||||
</tr>
|
||||
}
|
||||
|
||||
@@ -14,6 +14,8 @@ import { MatDialog, MatDialogModule } from '@angular/material/dialog';
|
||||
import { catchError, finalize, of } from 'rxjs';
|
||||
import { CashierService } from '../cashier.service';
|
||||
import { VoucherDetailDialogComponent } from './voucher-detail-dialog.component';
|
||||
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. */
|
||||
export type MyRole = 'RAISED' | 'VERIFIED' | 'RAISED_AND_VERIFIED';
|
||||
@@ -23,6 +25,11 @@ export type VoucherSide = 'CR' | 'DR' | 'JV';
|
||||
|
||||
interface MyWorkRow {
|
||||
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;
|
||||
voucherType: string | null;
|
||||
amount: number | null;
|
||||
@@ -75,7 +82,8 @@ interface MyWorkReport {
|
||||
CommonModule,
|
||||
FormsModule,
|
||||
MatIconModule,
|
||||
MatDialogModule
|
||||
MatDialogModule,
|
||||
VoucherStatusComponent
|
||||
]
|
||||
})
|
||||
export class MyWorkComponent implements OnInit {
|
||||
@@ -357,7 +365,7 @@ export class MyWorkComponent implements OnInit {
|
||||
}
|
||||
|
||||
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')}`;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -11,6 +11,7 @@ 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
|
||||
@@ -18,6 +19,10 @@ export interface VoucherDetailDialogData {
|
||||
* 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;
|
||||
@@ -58,7 +63,8 @@ export interface VoucherDetailDialogData {
|
||||
CommonModule,
|
||||
MatDialogModule,
|
||||
MatButtonModule,
|
||||
MatIconModule
|
||||
MatIconModule,
|
||||
VoucherStatusComponent
|
||||
],
|
||||
template: `
|
||||
<div class="vd-shell">
|
||||
@@ -66,7 +72,15 @@ export interface VoucherDetailDialogData {
|
||||
<div class="vd-header__title">
|
||||
<mat-icon class="vd-icon">receipt_long</mat-icon>
|
||||
<div>
|
||||
<h2>{{ detail.voucherNo }}</h2>
|
||||
<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>
|
||||
|
||||
@@ -19,34 +19,21 @@
|
||||
<!-- ── Entry Tab ── -->
|
||||
<mat-tab label="New Reconciliation">
|
||||
<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">
|
||||
@if (tellers.length === 1) {
|
||||
<mat-form-field appearance="outline" subscriptSizing="dynamic">
|
||||
<mat-label>Teller</mat-label>
|
||||
<input matInput type="text" [value]="tellers[0].name" readonly />
|
||||
</mat-form-field>
|
||||
} @else {
|
||||
<mat-form-field appearance="outline" subscriptSizing="dynamic">
|
||||
<mat-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>
|
||||
}
|
||||
</mat-select>
|
||||
</mat-form-field>
|
||||
}
|
||||
|
||||
<!-- A reconciliation is the cashier's own count of their own drawer: nothing to pick. -->
|
||||
<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-label>Teller</mat-label>
|
||||
<input matInput type="text" [value]="ownTellerName || '—'" readonly />
|
||||
</mat-form-field>
|
||||
<mat-form-field appearance="outline" subscriptSizing="dynamic">
|
||||
<mat-label>Cashier (You)</mat-label>
|
||||
<input matInput type="text" [value]="cashierName || '—'" readonly />
|
||||
</mat-form-field>
|
||||
|
||||
<mat-form-field appearance="outline" subscriptSizing="dynamic">
|
||||
|
||||
@@ -12,12 +12,12 @@ import { FormsModule } from '@angular/forms';
|
||||
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 { MatTabsModule } from '@angular/material/tabs';
|
||||
import { forkJoin, of } from 'rxjs';
|
||||
import { catchError } from 'rxjs/operators';
|
||||
import { CashierService } from '../cashier.service';
|
||||
import { businessToday } from 'app/core/utils/business-today';
|
||||
|
||||
interface DenomRow {
|
||||
denominationId: number;
|
||||
@@ -57,7 +57,6 @@ interface ReconRecord {
|
||||
MatIconModule,
|
||||
MatSnackBarModule,
|
||||
MatFormFieldModule,
|
||||
MatSelectModule,
|
||||
MatInputModule,
|
||||
MatTabsModule
|
||||
]
|
||||
@@ -66,9 +65,14 @@ export class ReconciliationComponent implements OnInit {
|
||||
private service = inject(CashierService);
|
||||
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 ──
|
||||
tellers: { id: number; name: string }[] = [];
|
||||
cashiers: { id: number; label: string }[] = [];
|
||||
selectedTellerId: number | null = null;
|
||||
selectedCashierId: number | null = null;
|
||||
cashierName = '';
|
||||
@@ -85,44 +89,30 @@ export class ReconciliationComponent implements OnInit {
|
||||
loadingHistory = false;
|
||||
|
||||
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.service
|
||||
.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.lockToOwnCashier();
|
||||
this.loadHistory();
|
||||
}
|
||||
|
||||
onTellerSelect(tellerId: number): void {
|
||||
this.selectedTellerId = tellerId;
|
||||
this.selectedCashierId = null;
|
||||
this.cashierName = '';
|
||||
this.cashiers = [];
|
||||
this.rows = [];
|
||||
if (!tellerId) return;
|
||||
this.service
|
||||
.getCashiersForTeller(tellerId)
|
||||
.pipe(catchError(() => of({ cashiers: [] })))
|
||||
.subscribe((r: any) => {
|
||||
this.cashiers = (r?.cashiers || []).map((c: any) => ({ id: c.id, label: c.staffName }));
|
||||
if (this.cashiers.length === 1) {
|
||||
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();
|
||||
/** Take the logged-in cashier as the one being reconciled, with their counter for the record. */
|
||||
private lockToOwnCashier(): void {
|
||||
this.service.getCurrentCashierInfo().subscribe((info) => {
|
||||
if (!info) {
|
||||
this.ownCashierMissing = true;
|
||||
return;
|
||||
}
|
||||
this.selectedCashierId = info.cashierId;
|
||||
this.cashierName = info.cashierName;
|
||||
this.service
|
||||
.getInterCashierCashierSummary(info.cashierId, this.reconDate)
|
||||
.pipe(catchError(() => of(null)))
|
||||
.subscribe((r: any) => {
|
||||
this.selectedTellerId = r?.tellerId ?? null;
|
||||
this.ownTellerName = r?.tellerName || '';
|
||||
});
|
||||
this.fetchExpected();
|
||||
});
|
||||
}
|
||||
|
||||
fetchExpected(): void {
|
||||
|
||||
@@ -117,6 +117,8 @@
|
||||
<select
|
||||
class="ws-f__ctl"
|
||||
[(ngModel)]="row.typ"
|
||||
[disabled]="isTypLocked(row)"
|
||||
[title]="isTypLocked(row) ? 'Token withdrawal - TYPE is fixed to Payment' : ''"
|
||||
(click)="$event.stopPropagation()"
|
||||
(change)="onTypChange(row)"
|
||||
>
|
||||
@@ -127,11 +129,16 @@
|
||||
</div>
|
||||
<div class="ws-f ws-f--branch">
|
||||
<label class="ws-f__lbl" title="Branch">BRANCH</label>
|
||||
<select class="ws-f__ctl" [(ngModel)]="row.br" (click)="$event.stopPropagation()">
|
||||
<option [ngValue]="1">001 · Main</option>
|
||||
<option [ngValue]="2">002 · M.G. Road</option>
|
||||
<option [ngValue]="14">014 · Station Rd</option>
|
||||
<option [ngValue]="27">027 · Industrial</option>
|
||||
<select
|
||||
class="ws-f__ctl"
|
||||
[(ngModel)]="row.br"
|
||||
[disabled]="branches.length <= 1"
|
||||
[title]="branches.length <= 1 ? 'Your branch' : 'Branch'"
|
||||
(click)="$event.stopPropagation()"
|
||||
>
|
||||
@for (b of branches; track b.id) {
|
||||
<option [ngValue]="b.id">{{ b.label }}</option>
|
||||
}
|
||||
</select>
|
||||
</div>
|
||||
@if (row.typ === 'DD') {
|
||||
@@ -177,13 +184,7 @@
|
||||
class="ws-f__ctl"
|
||||
[(ngModel)]="row.acTyp"
|
||||
(click)="$event.stopPropagation()"
|
||||
(change)="
|
||||
row.acTyp === 'GL' &&
|
||||
row.typ !== 'Disbursement' &&
|
||||
row.typ !== 'Fund Transfer' &&
|
||||
row.typ !== 'Enter Bank Transfer' &&
|
||||
(row.dbCr = 'Cr')
|
||||
"
|
||||
(change)="isCreditOnly(row) && (row.dbCr = 'Cr')"
|
||||
>
|
||||
@for (a of acTypOptionsFor(row); track a) {
|
||||
<option [value]="a">{{ a }}</option>
|
||||
@@ -266,12 +267,7 @@
|
||||
type="button"
|
||||
class="ws-seg__btn ws-seg__btn--dr"
|
||||
[class.ws-seg__btn--active-dr]="row.dbCr === 'Dr'"
|
||||
[disabled]="
|
||||
row.acTyp === 'GL' &&
|
||||
row.typ !== 'Disbursement' &&
|
||||
row.typ !== 'Fund Transfer' &&
|
||||
row.typ !== 'Enter Bank Transfer'
|
||||
"
|
||||
[disabled]="isCreditOnly(row)"
|
||||
(click)="row.dbCr = 'Dr'"
|
||||
>
|
||||
DR
|
||||
@@ -280,6 +276,7 @@
|
||||
type="button"
|
||||
class="ws-seg__btn ws-seg__btn--cr"
|
||||
[class.ws-seg__btn--active-cr]="row.dbCr === 'Cr'"
|
||||
[disabled]="row.typ === 'Withdrawal Slip'"
|
||||
(click)="row.dbCr = 'Cr'"
|
||||
>
|
||||
CR
|
||||
@@ -711,70 +708,81 @@
|
||||
<circle cx="12" cy="8" r="4" />
|
||||
<path d="M4 20c0-4 3.6-7 8-7s8 3 8 7" />
|
||||
</svg>
|
||||
Personal Information
|
||||
{{ isGlAccount ? 'GL Account Information' : 'Personal Information' }}
|
||||
</div>
|
||||
<div class="te-panel__body">
|
||||
<div class="te-field">
|
||||
<span class="te-label">Branch</span><span class="te-value">{{ officeName }}</span>
|
||||
@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>
|
||||
<div class="te-field">
|
||||
<span class="te-label">A/c No.</span
|
||||
><span class="te-value te-mono">{{ accountDetails.accountNo }}</span>
|
||||
</div>
|
||||
<div class="te-field">
|
||||
<span class="te-label">Name</span><span class="te-value te-value-name">{{ clientName }}</span>
|
||||
</div>
|
||||
<div class="te-field">
|
||||
<span class="te-label">S/D/W/H/O</span><span class="te-value">{{ guardianName }}</span>
|
||||
</div>
|
||||
<div class="te-field">
|
||||
<span class="te-label">Address</span><span class="te-value te-value-wrap">{{ clientAddress }}</span>
|
||||
</div>
|
||||
<div class="te-field">
|
||||
<span class="te-label">Email</span><span class="te-value">{{ clientEmail }}</span>
|
||||
</div>
|
||||
<div class="te-field-row">
|
||||
} @else {
|
||||
<div class="te-panel__body">
|
||||
<div class="te-field">
|
||||
<span class="te-label">Last Txn</span><span class="te-value">{{ lastTransactionDate }}</span>
|
||||
<span class="te-label">Branch</span><span class="te-value">{{ officeName }}</span>
|
||||
</div>
|
||||
<div class="te-field">
|
||||
<span class="te-label">Type</span
|
||||
><span class="te-value">{{ accountDetails.depositType?.value ?? selectedRow?.acTyp ?? '—' }}</span>
|
||||
<span class="te-label">A/c No.</span
|
||||
><span class="te-value te-mono">{{ accountDetails.accountNo }}</span>
|
||||
</div>
|
||||
<div class="te-field">
|
||||
<span class="te-label">Name</span><span class="te-value te-value-name">{{ clientName }}</span>
|
||||
</div>
|
||||
<div class="te-field">
|
||||
<span class="te-label">S/D/W/H/O</span><span class="te-value">{{ guardianName }}</span>
|
||||
</div>
|
||||
<div class="te-field">
|
||||
<span class="te-label">Address</span><span class="te-value te-value-wrap">{{ clientAddress }}</span>
|
||||
</div>
|
||||
<div class="te-field">
|
||||
<span class="te-label">Email</span><span class="te-value">{{ clientEmail }}</span>
|
||||
</div>
|
||||
<div class="te-field-row">
|
||||
<div class="te-field">
|
||||
<span class="te-label">Last Txn</span><span class="te-value">{{ lastTransactionDate }}</span>
|
||||
</div>
|
||||
<div class="te-field">
|
||||
<span class="te-label">Type</span
|
||||
><span class="te-value">{{ accountDetails.depositType?.value ?? selectedRow?.acTyp ?? '—' }}</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="te-field-row">
|
||||
<div class="te-field">
|
||||
<span class="te-label">Opening Dt</span><span class="te-value">{{ openingDate }}</span>
|
||||
</div>
|
||||
<div class="te-field">
|
||||
<span class="te-label">Status</span
|
||||
><span class="te-value">{{ accountDetails.status?.value ?? '—' }}</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="te-field">
|
||||
<span class="te-label">CIF No.</span><span class="te-value te-mono">{{ cifNumber }}</span>
|
||||
</div>
|
||||
@if (isNonIndividual) {
|
||||
<div class="te-field">
|
||||
<span class="te-label">Constitution</span>
|
||||
<span class="te-value">{{
|
||||
nonPersonDetails?.constitution?.name ?? nonPersonDetails?.constitutionType?.name ?? '—'
|
||||
}}</span>
|
||||
</div>
|
||||
<div class="te-field">
|
||||
<span class="te-label">Incorp. No.</span>
|
||||
<span class="te-value te-mono">{{
|
||||
nonPersonDetails?.incorpNo ?? nonPersonDetails?.incorporationNo ?? '—'
|
||||
}}</span>
|
||||
</div>
|
||||
<div class="te-field">
|
||||
<span class="te-label">Main Business</span>
|
||||
<span class="te-value">{{
|
||||
nonPersonDetails?.mainBusinessLine?.name ?? nonPersonDetails?.mainBusinessLineType?.name ?? '—'
|
||||
}}</span>
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
<div class="te-field-row">
|
||||
<div class="te-field">
|
||||
<span class="te-label">Opening Dt</span><span class="te-value">{{ openingDate }}</span>
|
||||
</div>
|
||||
<div class="te-field">
|
||||
<span class="te-label">Status</span
|
||||
><span class="te-value">{{ accountDetails.status?.value ?? '—' }}</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="te-field">
|
||||
<span class="te-label">CIF No.</span><span class="te-value te-mono">{{ cifNumber }}</span>
|
||||
</div>
|
||||
@if (isNonIndividual) {
|
||||
<div class="te-field">
|
||||
<span class="te-label">Constitution</span>
|
||||
<span class="te-value">{{
|
||||
nonPersonDetails?.constitution?.name ?? nonPersonDetails?.constitutionType?.name ?? '—'
|
||||
}}</span>
|
||||
</div>
|
||||
<div class="te-field">
|
||||
<span class="te-label">Incorp. No.</span>
|
||||
<span class="te-value te-mono">{{
|
||||
nonPersonDetails?.incorpNo ?? nonPersonDetails?.incorporationNo ?? '—'
|
||||
}}</span>
|
||||
</div>
|
||||
<div class="te-field">
|
||||
<span class="te-label">Main Business</span>
|
||||
<span class="te-value">{{
|
||||
nonPersonDetails?.mainBusinessLine?.name ?? nonPersonDetails?.mainBusinessLineType?.name ?? '—'
|
||||
}}</span>
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
|
||||
<!-- Joint Holders + Nominees -->
|
||||
@@ -929,76 +937,78 @@
|
||||
|
||||
<!-- Other Info + KYC + Account Balances -->
|
||||
<div class="te-panel">
|
||||
<div class="te-panel__header">
|
||||
<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" />
|
||||
<line x1="16" y1="2" x2="16" y2="6" />
|
||||
<line x1="8" y1="2" x2="8" y2="6" />
|
||||
<line x1="3" y1="10" x2="21" y2="10" />
|
||||
</svg>
|
||||
Other Information
|
||||
</div>
|
||||
<div class="te-panel__body">
|
||||
<div class="te-field">
|
||||
<span class="te-label">Phone No.</span><span class="te-value">{{ clientPhone }}</span>
|
||||
@if (!isGlAccount) {
|
||||
<div class="te-panel__header">
|
||||
<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" />
|
||||
<line x1="16" y1="2" x2="16" y2="6" />
|
||||
<line x1="8" y1="2" x2="8" y2="6" />
|
||||
<line x1="3" y1="10" x2="21" y2="10" />
|
||||
</svg>
|
||||
Other Information
|
||||
</div>
|
||||
<div class="te-field">
|
||||
<span class="te-label">Client ID</span
|
||||
><span class="te-value te-mono">{{ accountDetails.clientId }}</span>
|
||||
</div>
|
||||
<div class="te-field">
|
||||
<span class="te-label">PAN</span><span class="te-value te-mono">{{ panNumber }}</span>
|
||||
</div>
|
||||
<div class="te-field">
|
||||
<span class="te-label">KYC Status</span>
|
||||
<span class="te-value" [style.color]="kycDetails?.kycStatus === 300 ? '#059669' : '#f59e0b'">{{
|
||||
kycStatus
|
||||
}}</span>
|
||||
</div>
|
||||
<div class="te-field">
|
||||
<span class="te-label">CKYC No.</span><span class="te-value te-mono">{{ ckycNumber }}</span>
|
||||
</div>
|
||||
<div class="te-field">
|
||||
<span class="te-label">Aadhaar</span><span class="te-value te-mono">{{ aadhaarNumber }}</span>
|
||||
</div>
|
||||
<div class="te-field">
|
||||
<span class="te-label">Product</span>
|
||||
<span class="te-value">{{
|
||||
accountDetails.savingsProductName ||
|
||||
accountDetails.depositProductName ||
|
||||
accountDetails.productName ||
|
||||
'—'
|
||||
}}</span>
|
||||
</div>
|
||||
@if (selectedRow?.acTyp === 'SHR') {
|
||||
<div class="te-panel__body">
|
||||
<div class="te-field">
|
||||
<span class="te-label">No. of Shares Held</span
|
||||
><span class="te-value te-mono">{{ accountDetails.summary?.totalApprovedShares ?? 0 }}</span>
|
||||
<span class="te-label">Phone No.</span><span class="te-value">{{ clientPhone }}</span>
|
||||
</div>
|
||||
<div class="te-field">
|
||||
<span class="te-label">Market Price / Share</span
|
||||
><span class="te-value te-mono"
|
||||
>{{ currencySymbol }}{{ accountDetails.currentMarketPrice | number: '1.2-2' }}</span
|
||||
>
|
||||
<span class="te-label">Client ID</span
|
||||
><span class="te-value te-mono">{{ accountDetails.clientId }}</span>
|
||||
</div>
|
||||
<div class="te-field">
|
||||
<span class="te-label">Resolution No.</span
|
||||
><span class="te-value te-mono">{{ accountDetails.resolutionNumber || '—' }}</span>
|
||||
<span class="te-label">PAN</span><span class="te-value te-mono">{{ panNumber }}</span>
|
||||
</div>
|
||||
<div class="te-field">
|
||||
<span class="te-label">Linked Savings A/C No.</span
|
||||
><span class="te-value te-mono">{{ accountDetails.savingsAccountNumber || '—' }}</span>
|
||||
<span class="te-label">KYC Status</span>
|
||||
<span class="te-value" [style.color]="kycDetails?.kycStatus === 300 ? '#059669' : '#f59e0b'">{{
|
||||
kycStatus
|
||||
}}</span>
|
||||
</div>
|
||||
@if (accountDetails.linkedLoanAccountNo) {
|
||||
<div class="te-field">
|
||||
<span class="te-label">CKYC No.</span><span class="te-value te-mono">{{ ckycNumber }}</span>
|
||||
</div>
|
||||
<div class="te-field">
|
||||
<span class="te-label">Aadhaar</span><span class="te-value te-mono">{{ aadhaarNumber }}</span>
|
||||
</div>
|
||||
<div class="te-field">
|
||||
<span class="te-label">Product</span>
|
||||
<span class="te-value">{{
|
||||
accountDetails.savingsProductName ||
|
||||
accountDetails.depositProductName ||
|
||||
accountDetails.productName ||
|
||||
'—'
|
||||
}}</span>
|
||||
</div>
|
||||
@if (selectedRow?.acTyp === 'SHR') {
|
||||
<div class="te-field">
|
||||
<span class="te-label">Linked Loan A/C No.</span
|
||||
><span class="te-value te-mono">{{ accountDetails.linkedLoanAccountNo }}</span>
|
||||
<span class="te-label">No. of Shares Held</span
|
||||
><span class="te-value te-mono">{{ accountDetails.summary?.totalApprovedShares ?? 0 }}</span>
|
||||
</div>
|
||||
<div class="te-field">
|
||||
<span class="te-label">Market Price / Share</span
|
||||
><span class="te-value te-mono"
|
||||
>{{ currencySymbol }}{{ accountDetails.currentMarketPrice | number: '1.2-2' }}</span
|
||||
>
|
||||
</div>
|
||||
<div class="te-field">
|
||||
<span class="te-label">Resolution No.</span
|
||||
><span class="te-value te-mono">{{ accountDetails.resolutionNumber || '—' }}</span>
|
||||
</div>
|
||||
<div class="te-field">
|
||||
<span class="te-label">Linked Savings A/C No.</span
|
||||
><span class="te-value te-mono">{{ accountDetails.savingsAccountNumber || '—' }}</span>
|
||||
</div>
|
||||
@if (accountDetails.linkedLoanAccountNo) {
|
||||
<div class="te-field">
|
||||
<span class="te-label">Linked Loan A/C No.</span
|
||||
><span class="te-value te-mono">{{ accountDetails.linkedLoanAccountNo }}</span>
|
||||
</div>
|
||||
}
|
||||
}
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
|
||||
<div class="te-panel__header te-panel__header--mt">
|
||||
<div class="te-panel__header" [class.te-panel__header--mt]="!isGlAccount">
|
||||
<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" />
|
||||
<path d="M17 5H9.5a3.5 3.5 0 0 0 0 7h5a3.5 3.5 0 0 1 0 7H6" />
|
||||
@@ -1567,6 +1577,8 @@
|
||||
<select
|
||||
class="ws-f__ctl"
|
||||
[(ngModel)]="row.typ"
|
||||
[disabled]="isTypLocked(row)"
|
||||
[title]="isTypLocked(row) ? 'Token withdrawal - TYPE is fixed to Payment' : ''"
|
||||
(click)="$event.stopPropagation()"
|
||||
(change)="onTypChange(row)"
|
||||
>
|
||||
@@ -1577,11 +1589,16 @@
|
||||
</div>
|
||||
<div class="ws-f ws-f--branch">
|
||||
<label class="ws-f__lbl">BRANCH</label>
|
||||
<select class="ws-f__ctl" [(ngModel)]="row.br" (click)="$event.stopPropagation()">
|
||||
<option [ngValue]="1">001 · Main</option>
|
||||
<option [ngValue]="2">002 · M.G. Road</option>
|
||||
<option [ngValue]="14">014 · Station Rd</option>
|
||||
<option [ngValue]="27">027 · Industrial</option>
|
||||
<select
|
||||
class="ws-f__ctl"
|
||||
[(ngModel)]="row.br"
|
||||
[disabled]="branches.length <= 1"
|
||||
[title]="branches.length <= 1 ? 'Your branch' : 'Branch'"
|
||||
(click)="$event.stopPropagation()"
|
||||
>
|
||||
@for (b of branches; track b.id) {
|
||||
<option [ngValue]="b.id">{{ b.label }}</option>
|
||||
}
|
||||
</select>
|
||||
</div>
|
||||
@if (row.typ === 'DD') {
|
||||
@@ -1627,13 +1644,7 @@
|
||||
class="ws-f__ctl"
|
||||
[(ngModel)]="row.acTyp"
|
||||
(click)="$event.stopPropagation()"
|
||||
(change)="
|
||||
row.acTyp === 'GL' &&
|
||||
row.typ !== 'Disbursement' &&
|
||||
row.typ !== 'Fund Transfer' &&
|
||||
row.typ !== 'Enter Bank Transfer' &&
|
||||
(row.dbCr = 'Cr')
|
||||
"
|
||||
(change)="isCreditOnly(row) && (row.dbCr = 'Cr')"
|
||||
>
|
||||
@for (a of acTypOptionsFor(row); track a) {
|
||||
<option [value]="a">{{ a }}</option>
|
||||
@@ -1704,12 +1715,7 @@
|
||||
type="button"
|
||||
class="ws-seg__btn ws-seg__btn--dr"
|
||||
[class.ws-seg__btn--active-dr]="row.dbCr === 'Dr'"
|
||||
[disabled]="
|
||||
row.acTyp === 'GL' &&
|
||||
row.typ !== 'Disbursement' &&
|
||||
row.typ !== 'Fund Transfer' &&
|
||||
row.typ !== 'Enter Bank Transfer'
|
||||
"
|
||||
[disabled]="isCreditOnly(row)"
|
||||
(click)="$event.stopPropagation(); row.dbCr = 'Dr'"
|
||||
>
|
||||
DR
|
||||
@@ -1718,6 +1724,7 @@
|
||||
type="button"
|
||||
class="ws-seg__btn ws-seg__btn--cr"
|
||||
[class.ws-seg__btn--active-cr]="row.dbCr === 'Cr'"
|
||||
[disabled]="row.typ === 'Withdrawal Slip'"
|
||||
(click)="$event.stopPropagation(); row.dbCr = 'Cr'"
|
||||
>
|
||||
CR
|
||||
@@ -2043,7 +2050,13 @@
|
||||
<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>
|
||||
@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">
|
||||
<line x1="12" y1="5" x2="12" y2="19" />
|
||||
<line x1="5" y1="12" x2="19" y2="12" />
|
||||
|
||||
@@ -11,7 +11,7 @@ import { CommonModule } from '@angular/common';
|
||||
import { DomSanitizer, SafeResourceUrl } from '@angular/platform-browser';
|
||||
import { FormsModule } from '@angular/forms';
|
||||
import { HttpClient, HttpParams } from '@angular/common/http';
|
||||
import { catchError, debounceTime, distinctUntilChanged, map } from 'rxjs/operators';
|
||||
import { catchError, debounceTime, distinctUntilChanged, map, timeout } from 'rxjs/operators';
|
||||
import { forkJoin, of, Subject } from 'rxjs';
|
||||
import { MatDialog, MatDialogModule, MAT_DIALOG_DATA, MatDialogRef } from '@angular/material/dialog';
|
||||
import { MatIconModule } from '@angular/material/icon';
|
||||
@@ -33,6 +33,7 @@ import {
|
||||
import { SponsorDDService } from '../../sponsor-dd/sponsor-dd.service';
|
||||
import { OrganizationService } from '../../organization/organization.service';
|
||||
import { CashierIssueDdDialogComponent } from './cashier-issue-dd-dialog.component';
|
||||
import { businessToday, businessTodayIso } from 'app/core/utils/business-today';
|
||||
|
||||
interface TxnRow {
|
||||
typ: string;
|
||||
@@ -621,7 +622,7 @@ export class AccountStatementDialogComponent implements OnInit {
|
||||
|
||||
@ViewChild('statementContent') statementContent!: ElementRef;
|
||||
|
||||
today = new Date().toISOString().slice(0, 10);
|
||||
today = businessTodayIso();
|
||||
fromDate = '';
|
||||
toDate = this.today;
|
||||
|
||||
@@ -1657,6 +1658,8 @@ export class TransactionEntryComponent implements OnInit, OnDestroy {
|
||||
|
||||
// Full account + client data
|
||||
accountDetails: any = null;
|
||||
/** Parent (header) GL of the selected GL account, shown in the GL Account Information panel. */
|
||||
glParent: { glCode?: string; name?: string } | null = null;
|
||||
clientDetails: any = null;
|
||||
kycDetails: any = null;
|
||||
nominees: any[] = [];
|
||||
@@ -1684,10 +1687,12 @@ export class TransactionEntryComponent implements OnInit, OnDestroy {
|
||||
private searchToken = 0;
|
||||
private lastLoadedAcNo = '';
|
||||
private acNoInput$ = new Subject<{ row: TxnRow; index: number; value: string }>();
|
||||
/** How many characters of the A/C NO. start the automatic account search. */
|
||||
private static readonly AC_NO_AUTO_SEARCH_MIN_LENGTH = 6;
|
||||
cashVaultGlId: number | null = null;
|
||||
/** The Cash Vault GL account's own code and name — the denomination breakdown for a manual GL
|
||||
* entry has to be filed against THIS account, not whichever account the teller typed in, since
|
||||
* that account is never the one physical notes actually moved through (see submitGlJournalEntry).
|
||||
* that account is never the one physical notes actually moved through.
|
||||
* Both resolved dynamically off whatever this tenant's own financial-activity mapping names as
|
||||
* its cash/vault account (see loadCashVaultGl) — never a literal account name or code. */
|
||||
cashVaultGlCode: string | null = null;
|
||||
@@ -1870,8 +1875,9 @@ export class TransactionEntryComponent implements OnInit, OnDestroy {
|
||||
saving = false;
|
||||
|
||||
ngOnInit(): void {
|
||||
const d = new Date();
|
||||
const d = businessToday();
|
||||
this.today = `${String(d.getDate()).padStart(2, '0')}/${String(d.getMonth() + 1).padStart(2, '0')}/${d.getFullYear()}`;
|
||||
this.loadBranches();
|
||||
this.addRow();
|
||||
this.loadDenominations();
|
||||
this.loadOpeningPosition();
|
||||
@@ -1902,7 +1908,8 @@ export class TransactionEntryComponent implements OnInit, OnDestroy {
|
||||
distinctUntilChanged((a, b) => a.value === b.value && a.row === b.row)
|
||||
)
|
||||
.subscribe(({ row, index, value }) => {
|
||||
if (value.trim().length >= 4) {
|
||||
// Searches on its own once six characters are typed, for clerk and cashier alike.
|
||||
if (value.trim().length >= TransactionEntryComponent.AC_NO_AUTO_SEARCH_MIN_LENGTH) {
|
||||
this.selectedRow = row;
|
||||
this.loadAccountDetails(value.trim(), row.acTyp);
|
||||
}
|
||||
@@ -2103,7 +2110,7 @@ export class TransactionEntryComponent implements OnInit, OnDestroy {
|
||||
}
|
||||
|
||||
private loadOpeningPosition(): void {
|
||||
const d = new Date();
|
||||
const d = businessToday();
|
||||
const asOfDate = `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, '0')}-${String(d.getDate()).padStart(2, '0')}`;
|
||||
this.cashierService
|
||||
.getCashierPositionForMe(asOfDate)
|
||||
@@ -2303,7 +2310,7 @@ export class TransactionEntryComponent implements OnInit, OnDestroy {
|
||||
// anyway, just without the extra click to get there.
|
||||
const row: TxnRow = {
|
||||
typ: this.isClerk ? 'Withdrawal Slip' : 'Receipt',
|
||||
br: 1,
|
||||
br: this.defaultBranchId,
|
||||
trTp: '',
|
||||
acTyp: 'SD',
|
||||
acNo: '',
|
||||
@@ -2322,8 +2329,54 @@ export class TransactionEntryComponent implements OnInit, OnDestroy {
|
||||
this.rows.push(row);
|
||||
}
|
||||
|
||||
/**
|
||||
* A GL row is credit-only (money received into the account) except on the transfer types, which pick
|
||||
* either side, and on a Withdrawal Slip, which is always a debit: the GL is debited and the cash
|
||||
* vault credited when cash is paid out against it (a clerk's request, approved, then paid by the cashier).
|
||||
*/
|
||||
isGlCreditOnly(row: TxnRow): boolean {
|
||||
return (
|
||||
row.acTyp === 'GL' &&
|
||||
row.typ !== 'Disbursement' &&
|
||||
row.typ !== 'Fund Transfer' &&
|
||||
row.typ !== 'Enter Bank Transfer' &&
|
||||
row.typ !== 'Withdrawal Slip'
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* A Fixed Deposit row is credit-only on both the clerk and the cashier screens: an FD is funded once, with
|
||||
* exactly its principal, and money leaves it only through its maturity or premature closure. The one
|
||||
* exception is the clerk's own "Close this account" flow, which raises a closure request.
|
||||
*/
|
||||
isFdCreditOnly(row: TxnRow): boolean {
|
||||
return row.acTyp === 'FD' && !this.pendingClosure;
|
||||
}
|
||||
|
||||
/** DR is not offered for this row (see isGlCreditOnly and isFdCreditOnly). */
|
||||
isCreditOnly(row: TxnRow): boolean {
|
||||
return this.isGlCreditOnly(row) || this.isFdCreditOnly(row);
|
||||
}
|
||||
|
||||
/** Called when the TYPE dropdown changes — sets sensible defaults for TXN TP and DR/CR */
|
||||
/**
|
||||
* A cashier row that was filled from an approved token (withdrawal / account-closure) is cash
|
||||
* leaving the branch to the member, so its TYPE is fixed to Payment and cannot be changed.
|
||||
* Never true for the clerk screen or for rows keyed in by hand.
|
||||
*/
|
||||
isTypLocked(row: TxnRow): boolean {
|
||||
return (
|
||||
!this.isClerk &&
|
||||
!!row.tokNo?.trim() &&
|
||||
!!(this.activeWithdrawalRequest || this.activeDividendClaimRequest || this.activeClosureRequest)
|
||||
);
|
||||
}
|
||||
|
||||
onTypChange(row: TxnRow): void {
|
||||
if (this.isTypLocked(row)) {
|
||||
row.typ = 'Payment';
|
||||
return;
|
||||
}
|
||||
switch (row.typ) {
|
||||
case 'Withdrawal Slip':
|
||||
row.dbCr = 'Dr';
|
||||
@@ -2439,7 +2492,7 @@ export class TransactionEntryComponent implements OnInit, OnDestroy {
|
||||
* such ambiguity — the account itself says what it's for.
|
||||
*/
|
||||
showsEntryType(row: TxnRow): boolean {
|
||||
return row.acTyp === 'GL' && row.dbCr === 'Cr';
|
||||
return row.acTyp === 'GL';
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -2617,14 +2670,47 @@ export class TransactionEntryComponent implements OnInit, OnDestroy {
|
||||
return !!this.selectedRow && Math.abs(this.denominationTotal - this.ddTargetTotal) < 0.005;
|
||||
}
|
||||
|
||||
/**
|
||||
* The branches an entry can be posted against: the real offices of this installation. Read from the offices
|
||||
* themselves rather than from a fixed list, so a branch that is added or renamed shows up here without a code change.
|
||||
*/
|
||||
branches: { id: number; label: string }[] = [];
|
||||
|
||||
private loadBranches(): void {
|
||||
const own = this.authService.getCredentials();
|
||||
const ownBranch = own?.officeId
|
||||
? [{ id: own.officeId, label: this.branchLabel(own.officeId, own.officeName) }]
|
||||
: [];
|
||||
// The office list is only asked for by a login allowed to read it. A cashier or clerk is not, and asking anyway
|
||||
// makes the server refuse and the screen raise an "Unauthorized Request" popup; such a login works in its own
|
||||
// office, which is the one branch it is offered.
|
||||
const permissions = own?.permissions ?? [];
|
||||
const mayReadOffices = permissions.includes('ALL_FUNCTIONS') || permissions.includes('READ_OFFICE');
|
||||
if (!mayReadOffices) {
|
||||
this.branches = ownBranch;
|
||||
return;
|
||||
}
|
||||
this.http
|
||||
.get('/offices')
|
||||
.pipe(catchError(() => of([])))
|
||||
.subscribe((list: any) => {
|
||||
const offices: any[] = Array.isArray(list) ? list : [];
|
||||
this.branches =
|
||||
offices.length > 0 ? offices.map((o) => ({ id: o.id, label: this.branchLabel(o.id, o.name) })) : ownBranch;
|
||||
});
|
||||
}
|
||||
|
||||
private branchLabel(id: number, name: string | null | undefined): string {
|
||||
return `${String(id).padStart(3, '0')} · ${name ?? 'Branch ' + id}`;
|
||||
}
|
||||
|
||||
/** The office the signed-in user works in, which a new entry is posted against unless they pick another. */
|
||||
get defaultBranchId(): number {
|
||||
return this.authService.getCredentials()?.officeId ?? 1;
|
||||
}
|
||||
|
||||
branchNameFor(branchId: number): string {
|
||||
const map: Record<number, string> = {
|
||||
1: '001 · Main',
|
||||
2: '002 · M.G. Road',
|
||||
14: '014 · Station Rd',
|
||||
27: '027 · Industrial'
|
||||
};
|
||||
return map[branchId] || `Branch ${branchId}`;
|
||||
return this.branches.find((b) => b.id === branchId)?.label ?? `Branch ${branchId}`;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -2837,7 +2923,7 @@ export class TransactionEntryComponent implements OnInit, OnDestroy {
|
||||
fundingAccountId: number,
|
||||
fundingClientId: number | null
|
||||
): void {
|
||||
const d = new Date();
|
||||
const d = businessToday();
|
||||
const issuedDate = `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, '0')}-${String(d.getDate()).padStart(2, '0')}`;
|
||||
const isCurrent = row.acTyp === 'CA' || row.acTyp === 'CB';
|
||||
const isGl = row.acTyp === 'GL';
|
||||
@@ -2969,7 +3055,7 @@ export class TransactionEntryComponent implements OnInit, OnDestroy {
|
||||
return;
|
||||
}
|
||||
|
||||
const d = new Date();
|
||||
const d = businessToday();
|
||||
const issuedDate = `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, '0')}-${String(d.getDate()).padStart(2, '0')}`;
|
||||
const isCurrent = row.acTyp === 'CA' || row.acTyp === 'CB';
|
||||
|
||||
@@ -3068,7 +3154,7 @@ export class TransactionEntryComponent implements OnInit, OnDestroy {
|
||||
return;
|
||||
}
|
||||
|
||||
const d = new Date();
|
||||
const d = businessToday();
|
||||
const issuedDate = `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, '0')}-${String(d.getDate()).padStart(2, '0')}`;
|
||||
|
||||
const payload = {
|
||||
@@ -3391,7 +3477,7 @@ export class TransactionEntryComponent implements OnInit, OnDestroy {
|
||||
return;
|
||||
}
|
||||
|
||||
const today = new Date();
|
||||
const today = businessToday();
|
||||
const requestedDate = `${today.getFullYear()}-${String(today.getMonth() + 1).padStart(2, '0')}-${String(today.getDate()).padStart(2, '0')}`;
|
||||
|
||||
// Full snapshot of the CR (settlement) row exactly as the clerk filled it in — saved verbatim
|
||||
@@ -3425,6 +3511,7 @@ export class TransactionEntryComponent implements OnInit, OnDestroy {
|
||||
meetingDate: shareRow.meetingDate,
|
||||
description: shareRow.description || null,
|
||||
settlementPaymentTypeId: savingsRow.paymentTypeId,
|
||||
settlementMode: 'SAVINGS',
|
||||
settlementRowSnapshot
|
||||
};
|
||||
|
||||
@@ -3455,11 +3542,60 @@ export class TransactionEntryComponent implements OnInit, OnDestroy {
|
||||
? 'Pending Manager approval — redemption, settlement and account closure all post together on approval.'
|
||||
: 'Pending Manager approval — redemption and settlement both post together on approval.'
|
||||
];
|
||||
this.showConfirmAlert('info', 'Confirm Share Withdrawal Request', confirmLines, 'Submit for Approval', () =>
|
||||
this.showShareWithdrawalConfirm(payload, confirmLines, () =>
|
||||
this.doSubmitShareRedemptionTransfer(payload, shareRow, shares, linkedAcNo)
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Shows the confirm dialog with the same checklist the Manager will see when approving this
|
||||
* request — fetched live from the backend (`previewValidate`) rather than re-implemented here,
|
||||
* so it can never show the clerk something different from what the approver actually sees. A
|
||||
* failing check is shown, not blocked: approve() enforces its own hard rules separately (e.g.
|
||||
* Share Advance Collection applied), this list is advisory so problems surface to whoever can
|
||||
* still fix them, before a manager's time is spent on a request that was always going to fail.
|
||||
*/
|
||||
private showShareWithdrawalConfirm(
|
||||
payload: Record<string, unknown>,
|
||||
baseLines: string[],
|
||||
onConfirm: () => void
|
||||
): void {
|
||||
this.clerkShareWithdrawalService
|
||||
.previewValidate(payload)
|
||||
.pipe(
|
||||
// A request that never resolves (slow network, server busy) must not leave the Submit
|
||||
// button looking dead — the dialog still has to open, just without the checklist.
|
||||
timeout(5000),
|
||||
catchError(() => of({ __previewFailed: true }))
|
||||
)
|
||||
.subscribe((res: any) => {
|
||||
const checks: { label: string; pass: boolean; detail: string }[] = res?.checks ?? [];
|
||||
// A failed call (most likely the backend not yet carrying the /preview-validate endpoint)
|
||||
// says so explicitly rather than just omitting the section — a silently empty checklist
|
||||
// looks identical to "everything passed, nothing to show", which is the one reading this
|
||||
// must never give.
|
||||
const checkLines = res?.__previewFailed ? [
|
||||
'',
|
||||
'⚠ Validation checklist unavailable right now — the request can still be submitted.'
|
||||
] : checks.length ? [
|
||||
'',
|
||||
'Validation Checks (same as Manager approval):',
|
||||
...checks.map((c) => `${c.pass ? '✓' : '⚠'} ${c.label}: ${c.detail}`)
|
||||
] : [];
|
||||
// baseLines ends with a blank line followed by the "Pending Manager approval…" footer
|
||||
// sentence, which must stay last so the modal renders it as a footnote rather than a
|
||||
// key/value row — so the checklist is spliced in just before that pair, not appended after.
|
||||
const footerStart =
|
||||
baseLines.length >= 2 && baseLines[baseLines.length - 2] === '' ? baseLines.length - 2 : baseLines.length;
|
||||
const lines = [
|
||||
...baseLines.slice(0, footerStart),
|
||||
...checkLines,
|
||||
...baseLines.slice(footerStart)
|
||||
];
|
||||
this.showConfirmAlert('info', 'Confirm Share Withdrawal Request', lines, 'Submit for Approval', onConfirm);
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Share redemption settled to a GL account the clerk picks on the CR row, instead of the
|
||||
* member's linked savings account — mirrors submitShareRedemptionTransfer() above, but the
|
||||
@@ -3512,7 +3648,7 @@ export class TransactionEntryComponent implements OnInit, OnDestroy {
|
||||
return;
|
||||
}
|
||||
|
||||
const today = new Date();
|
||||
const today = businessToday();
|
||||
const requestedDate = `${today.getFullYear()}-${String(today.getMonth() + 1).padStart(2, '0')}-${String(today.getDate()).padStart(2, '0')}`;
|
||||
|
||||
// Full snapshot of the CR (settlement) row exactly as the clerk filled it in — saved verbatim
|
||||
@@ -3571,7 +3707,7 @@ export class TransactionEntryComponent implements OnInit, OnDestroy {
|
||||
? 'Pending Manager approval — redemption, settlement and account closure all post together on approval.'
|
||||
: 'Pending Manager approval — redemption and settlement both post together on approval.'
|
||||
];
|
||||
this.showConfirmAlert('info', 'Confirm Share Withdrawal Request', confirmLines, 'Submit for Approval', () =>
|
||||
this.showShareWithdrawalConfirm(payload, confirmLines, () =>
|
||||
this.doSubmitShareGlSettlementTransfer(payload, shareRow, shares, glRow.acNo)
|
||||
);
|
||||
}
|
||||
@@ -3867,7 +4003,8 @@ export class TransactionEntryComponent implements OnInit, OnDestroy {
|
||||
SAVINGS: 'SD',
|
||||
CURRENT: 'CA',
|
||||
FIXED_DEPOSIT: 'FD',
|
||||
RECURRING_DEPOSIT: 'RD'
|
||||
RECURRING_DEPOSIT: 'RD',
|
||||
GL: 'GL'
|
||||
};
|
||||
|
||||
openCustomerSearch(): void {
|
||||
@@ -3923,8 +4060,11 @@ export class TransactionEntryComponent implements OnInit, OnDestroy {
|
||||
.pipe(catchError(() => of(null)))
|
||||
.subscribe((res: any) => {
|
||||
if (token !== this.searchToken) return; // stale result — newer search started
|
||||
const allItems: any[] = res?.pageItems ?? (Array.isArray(res) ? res : []);
|
||||
const found: any[] = res?.pageItems ?? (Array.isArray(res) ? res : []);
|
||||
const f = cfg.matchField;
|
||||
// SD, FD and RD share one list that returns any kind of deposit account: keep only the kind chosen in A/C TP.
|
||||
const allItems =
|
||||
cfg.depositTypeId != null ? found.filter((x: any) => x?.depositType?.id === cfg.depositTypeId) : found;
|
||||
// Exact match first, then suffix/partial match. Some account types (e.g. Share — its
|
||||
// list endpoint only accepts offset/limit, no accountNo filter at all) always return the
|
||||
const exactMatch = allItems.find((x: any) => String(x[f] ?? '').trim() === String(acNo).trim());
|
||||
@@ -3954,7 +4094,18 @@ export class TransactionEntryComponent implements OnInit, OnDestroy {
|
||||
return;
|
||||
}
|
||||
this.loadingAccount = false;
|
||||
this.accountError = 'Account not found for: ' + acNo;
|
||||
// The number may exist as another kind of account (an FD typed under SD, say): say so, so the clerk can fix A/C TP.
|
||||
const otherKind =
|
||||
cfg.depositTypeId != null
|
||||
? found.find(
|
||||
(x: any) =>
|
||||
x?.depositType?.id !== cfg.depositTypeId &&
|
||||
(String(x[f] ?? '').trim() === String(acNo).trim() || String(x[f] ?? '').endsWith(acNo))
|
||||
)
|
||||
: null;
|
||||
this.accountError = otherKind
|
||||
? `${acTyp} account not found for: ${acNo} (this number is a ${otherKind.depositType?.value ?? 'different kind of'} account - change A/C TP)`
|
||||
: `${acTyp} account not found for: ${acNo}`;
|
||||
});
|
||||
}
|
||||
|
||||
@@ -4018,6 +4169,10 @@ export class TransactionEntryComponent implements OnInit, OnDestroy {
|
||||
* account type — used to keep Header GL accounts out of the results a clerk/cashier can pick
|
||||
* for a transaction; a Header can never receive a posting, so it should never be offered. */
|
||||
extraListQueryParams?: string;
|
||||
/** SD, FD and RD are all served by the one /savingsaccounts list, which answers with a deposit
|
||||
* account of any kind for a number. This is the depositType.id the chosen A/C type accepts
|
||||
* (100 savings, 200 fixed, 300 recurring), so a number of another kind is "not found" here. */
|
||||
depositTypeId?: number;
|
||||
} {
|
||||
const t = acTyp?.toUpperCase();
|
||||
if (t === 'LA') {
|
||||
@@ -4098,6 +4253,7 @@ export class TransactionEntryComponent implements OnInit, OnDestroy {
|
||||
return {
|
||||
list: '/savingsaccounts',
|
||||
detail: '/fixeddepositaccounts',
|
||||
depositTypeId: 200,
|
||||
nominee: 'FD',
|
||||
search: 'fixeddeposit',
|
||||
siAccountType: 2,
|
||||
@@ -4115,6 +4271,7 @@ export class TransactionEntryComponent implements OnInit, OnDestroy {
|
||||
return {
|
||||
list: '/savingsaccounts',
|
||||
detail: '/recurringdepositaccounts',
|
||||
depositTypeId: 300,
|
||||
nominee: 'RD',
|
||||
search: 'recurringdeposit',
|
||||
siAccountType: 2,
|
||||
@@ -4132,6 +4289,7 @@ export class TransactionEntryComponent implements OnInit, OnDestroy {
|
||||
return {
|
||||
list: '/savingsaccounts',
|
||||
detail: '/savingsaccounts',
|
||||
depositTypeId: 100,
|
||||
nominee: 'SB',
|
||||
search: 'saving',
|
||||
siAccountType: 2,
|
||||
@@ -4173,7 +4331,7 @@ export class TransactionEntryComponent implements OnInit, OnDestroy {
|
||||
): void {
|
||||
const accountUrl = `${cfg.detail}/${accountId}${cfg.detailParams ?? '?associations=all'}`;
|
||||
const todayStr = (() => {
|
||||
const d = new Date();
|
||||
const d = businessToday();
|
||||
return `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, '0')}-${String(d.getDate()).padStart(2, '0')}`;
|
||||
})();
|
||||
|
||||
@@ -4192,6 +4350,17 @@ export class TransactionEntryComponent implements OnInit, OnDestroy {
|
||||
}
|
||||
this.accountDetails = acc;
|
||||
this.jointHolders = acc.jointHolders ?? [];
|
||||
// GL only: look up the parent (header) GL's name. Never runs for any other account type.
|
||||
this.glParent = null;
|
||||
if (acTyp?.toUpperCase() === 'GL' && acc.parentId) {
|
||||
this.http
|
||||
.get<any>(`/glaccounts/${acc.parentId}`)
|
||||
.pipe(catchError(() => of(null)))
|
||||
.subscribe((p: any) => {
|
||||
if (token !== this.searchToken) return;
|
||||
this.glParent = p ? { glCode: p.glCode, name: p.name } : null;
|
||||
});
|
||||
}
|
||||
// Cash Hold / Clearing Hold breakdown only exists for plain savings accounts (m_savings_account)
|
||||
// — FD/RD/Current/Share/GL have no such row, so there's nothing to ask for.
|
||||
if (acTyp?.toUpperCase() === 'SD' && acc.id) {
|
||||
@@ -4549,7 +4718,7 @@ export class TransactionEntryComponent implements OnInit, OnDestroy {
|
||||
}
|
||||
|
||||
get 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')}`;
|
||||
}
|
||||
|
||||
@@ -4762,6 +4931,40 @@ export class TransactionEntryComponent implements OnInit, OnDestroy {
|
||||
return this.accountDetails?.officeName ?? this.clientDetails?.officeName ?? '—';
|
||||
}
|
||||
|
||||
/** True when the loaded account is a General Ledger account (/glaccounts), which has no client behind it. */
|
||||
get isGlAccount(): boolean {
|
||||
return !!this.accountDetails?.glCode && this.selectedRow?.acTyp === 'GL';
|
||||
}
|
||||
|
||||
/** The GL account's own details, shown in place of the client panels. */
|
||||
get glInfo(): { label: string; value: string; mono?: boolean }[] {
|
||||
const gl = this.accountDetails;
|
||||
if (!gl) return [];
|
||||
const type = gl.type?.value ?? '—';
|
||||
const nature = [
|
||||
'ASSET',
|
||||
'EXPENSE'
|
||||
].includes(String(type).toUpperCase()) ? 'Debit' : 'Credit';
|
||||
return [
|
||||
{ label: 'GL Code', value: gl.glCode ?? '—', mono: true },
|
||||
{ label: 'GL Name', value: gl.name ?? '—' },
|
||||
{ label: 'Account Type', value: type },
|
||||
{ label: 'Usage', value: gl.usage?.value ?? '—' },
|
||||
{ label: 'Normal Balance', value: nature },
|
||||
{
|
||||
label: 'Parent GL',
|
||||
value: this.glParent?.name
|
||||
? `${this.glParent.glCode ? this.glParent.glCode.trim() + ' - ' : ''}${this.glParent.name.trim()}`
|
||||
: gl.parentId != null
|
||||
? `ID ${gl.parentId}`
|
||||
: '—'
|
||||
},
|
||||
{ label: 'Manual Entries', value: gl.manualEntriesAllowed ? 'Allowed' : 'Not allowed' },
|
||||
{ label: 'Status', value: gl.disabled ? 'Disabled' : 'Enabled' },
|
||||
{ label: 'Description', value: gl.description || '—' }
|
||||
];
|
||||
}
|
||||
|
||||
get openingDate(): string {
|
||||
const raw =
|
||||
this.accountDetails?.timeline?.submittedOnDate ??
|
||||
@@ -5071,7 +5274,7 @@ export class TransactionEntryComponent implements OnInit, OnDestroy {
|
||||
this.sameDayChequeClearing = 0;
|
||||
return;
|
||||
}
|
||||
const today = new Date();
|
||||
const today = businessToday();
|
||||
const sameDay = (d: any) => {
|
||||
if (!d) return false;
|
||||
const t = Array.isArray(d) && d.length >= 3 ? new Date(d[0], d[1] - 1, d[2]) : new Date(d);
|
||||
@@ -5104,6 +5307,20 @@ export class TransactionEntryComponent implements OnInit, OnDestroy {
|
||||
}
|
||||
|
||||
onSave(): void {
|
||||
// Money never leaves a Fixed Deposit from this screen, clerk or cashier: no withdrawal slip, payment,
|
||||
// collection or transfer debits an FD. It is paid out only through its maturity or premature closure
|
||||
// (the clerk's "Close this account" flow). The DR button is already disabled for FD rows (isFdCreditOnly);
|
||||
// this also catches a row set to DR by its TYPE, such as a Withdrawal Slip.
|
||||
const fdDebitRows = this.rows.filter((r) => this.isFdCreditOnly(r) && r.dbCr === 'Dr');
|
||||
if (fdDebitRows.length > 0) {
|
||||
this.showAlert('error', 'Fixed Deposit Cannot Be Debited', [
|
||||
`FD account(s) ${fdDebitRows.map((r) => r.acNo).join(', ')} cannot be debited or withdrawn from.`,
|
||||
'Money leaves a Fixed Deposit only through its maturity or premature closure.',
|
||||
'An FD can only be credited — once, with exactly its principal.'
|
||||
]);
|
||||
return;
|
||||
}
|
||||
|
||||
// Demand drafts commit here like everything else on the worksheet. Checked first because a DD
|
||||
// row shares its shape with the ordinary entries below — without this it would fall through and
|
||||
// post as a plain transaction, with the draft never raised and the charges silently dropped.
|
||||
@@ -5181,7 +5398,9 @@ export class TransactionEntryComponent implements OnInit, OnDestroy {
|
||||
return;
|
||||
}
|
||||
|
||||
// Bank transfer: only savings (SD) or current (CA/CB) accounts allowed
|
||||
// Bank transfer: savings (SD) or current (CA/CB) accounts on either side, plus a Fixed Deposit (FD) on
|
||||
// the CR side only -- funding it from the customer's own account. The server checks the funding rules
|
||||
// (first and only funding, exactly the principal, the source account's owner and available balance).
|
||||
const allowedTypes = new Set([
|
||||
'SD',
|
||||
'CA',
|
||||
@@ -5189,13 +5408,14 @@ export class TransactionEntryComponent implements OnInit, OnDestroy {
|
||||
]);
|
||||
const invalidAccounts = [
|
||||
...drRows,
|
||||
...crRows
|
||||
...crRows.filter((r) => r.acTyp !== 'FD')
|
||||
].filter((r) => !allowedTypes.has(r.acTyp));
|
||||
if (invalidAccounts.length > 0) {
|
||||
this.showAlert('error', 'Invalid Account Type', [
|
||||
`Account type(s) not allowed in Bank Transfer: ${invalidAccounts.map((r) => r.acTyp + ' (' + r.acNo + ')').join(', ')}`,
|
||||
'Only Savings (SD) and Current (CA) accounts are permitted.',
|
||||
'GL, FD, RD, SHR accounts are not allowed — use Disbursement/Collection instead.'
|
||||
'Debit side: only Savings (SD) and Current (CA) accounts. Credit side: SD, CA or a Fixed Deposit (FD).',
|
||||
'GL and SHR accounts are not allowed — use Disbursement/Collection instead.',
|
||||
'RD installments are paid through a cashier Receipt or Auto Debit.'
|
||||
]);
|
||||
return;
|
||||
}
|
||||
@@ -5249,6 +5469,19 @@ export class TransactionEntryComponent implements OnInit, OnDestroy {
|
||||
this.rows.length >= 2 &&
|
||||
this.rows.every((r) => r.typ === 'Disbursement' || r.typ === 'Fund Transfer')
|
||||
) {
|
||||
// A Recurring Deposit is never paid this way: these requests post straight into the account, so the
|
||||
// installment schedule never hears of the money -- installments stay unpaid, Auto Debit collects them
|
||||
// again and the maturity amount comes out wrong. RD installments come in through the cashier's
|
||||
// Receipt or the RD's own Auto Debit.
|
||||
const rdRows = this.rows.filter((r) => r.acTyp === 'RD');
|
||||
if (rdRows.length > 0) {
|
||||
this.showAlert('error', 'Recurring Deposit Not Allowed', [
|
||||
`RD account(s) ${rdRows.map((r) => r.acNo).join(', ')} cannot be used in a Disbursement, Collection or Fund Transfer.`,
|
||||
'Pay RD installments through a cashier Receipt, or by Auto Debit from the linked savings account.'
|
||||
]);
|
||||
return;
|
||||
}
|
||||
|
||||
const drRows = this.rows.filter((r) => r.dbCr === 'Dr');
|
||||
const crRows = this.rows.filter((r) => r.dbCr === 'Cr');
|
||||
|
||||
@@ -5782,10 +6015,14 @@ export class TransactionEntryComponent implements OnInit, OnDestroy {
|
||||
return;
|
||||
}
|
||||
|
||||
// GL withdrawal → direct journal entry (no maker-checker)
|
||||
// GL deposit → falls through to maker-checker block below
|
||||
if (row.acTyp === 'GL' && !isDeposit) {
|
||||
this.submitGlJournalEntry(row, amount, isDeposit);
|
||||
// A GL withdrawal slip is raised by the clerk and paid by the cashier against its token, exactly like a
|
||||
// savings withdrawal (the clerk branch and the token branch below). Nothing posts from this screen directly:
|
||||
// manual journal entries are switched off on the server.
|
||||
if (row.acTyp === 'GL' && !isDeposit && !this.clerkMode && !this.activeWithdrawalRequest) {
|
||||
this.showAlert('error', 'GL Withdrawal', [
|
||||
'A GL withdrawal is raised by a clerk and paid by the cashier against its token.',
|
||||
'Enter the token number of the approved request to pay it.'
|
||||
]);
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -5959,7 +6196,7 @@ export class TransactionEntryComponent implements OnInit, OnDestroy {
|
||||
return;
|
||||
}
|
||||
|
||||
const today = new Date();
|
||||
const today = businessToday();
|
||||
const txnDate = `${String(today.getDate()).padStart(2, '0')}/${String(today.getMonth() + 1).padStart(2, '0')}/${today.getFullYear()}`;
|
||||
|
||||
const payload: any = {
|
||||
@@ -6041,7 +6278,12 @@ export class TransactionEntryComponent implements OnInit, OnDestroy {
|
||||
this.editRequest = req;
|
||||
}
|
||||
|
||||
const acTyp = 'SD';
|
||||
// Same bug, same fix as the token-driven withdrawal-slip lookup above (see its comment): the
|
||||
// request carries its own real account_type (SAVINGS, CURRENT, FIXED_DEPOSIT or
|
||||
// RECURRING_DEPOSIT), and hardcoding 'SD' here searched every non-savings account on the
|
||||
// wrong endpoint, so reviewing a Current-account withdrawal always failed with "Account not
|
||||
// found".
|
||||
const acTyp = TransactionEntryComponent.SEARCH_ACCOUNT_TYPE_TO_AC_TYP[req.account_type || ''] || 'SD';
|
||||
const row = this.rows[0];
|
||||
row.acNo = req.account_no ?? '';
|
||||
row.acTyp = acTyp;
|
||||
@@ -6149,21 +6391,38 @@ export class TransactionEntryComponent implements OnInit, OnDestroy {
|
||||
|
||||
/** Build the withdrawal-request payload from the current row + loaded account. */
|
||||
private buildWithdrawalPayload(row: TxnRow, amount: number): any {
|
||||
const today = new Date();
|
||||
const today = businessToday();
|
||||
const txnDateIso = `${today.getFullYear()}-${String(today.getMonth() + 1).padStart(2, '0')}-${String(today.getDate()).padStart(2, '0')}`;
|
||||
const selectedPt = this.paymentTypes.find((p: any) => p.id === row.paymentTypeId);
|
||||
|
||||
const isGl = row.acTyp === 'GL';
|
||||
// The Entry Type picked for a GL slip has no column of its own on the request, so it rides on the narration.
|
||||
const entryTypeName = isGl ? this.entryTypeOptions.find((e) => e.id === row.entryTypeCvId)?.name : undefined;
|
||||
const description = [
|
||||
row.description,
|
||||
entryTypeName ? `[Entry Type: ${entryTypeName}]` : ''
|
||||
]
|
||||
.filter(Boolean)
|
||||
.join(' ');
|
||||
|
||||
return {
|
||||
accountNo: this.accountDetails.accountNo,
|
||||
accountNo: isGl ? (this.accountDetails.glCode ?? this.accountDetails.name) : this.accountDetails.accountNo,
|
||||
accountType: this.creditEntryAccountType(row.acTyp),
|
||||
savingsAccountId: this.accountDetails.id,
|
||||
clientId: this.accountDetails.clientId ?? this.clientDetails?.id ?? null,
|
||||
clientName: this.clientDetails?.displayName ?? this.accountDetails?.clientName ?? null,
|
||||
savingsAccountId: isGl ? null : this.accountDetails.id,
|
||||
// GL-specific fields: the GL being paid out of and the cash GL it is paid from (null for other types)
|
||||
glAccountId: isGl ? this.accountDetails.id : null,
|
||||
contraGlId: isGl ? this.cashVaultGlId : null,
|
||||
officeId: isGl ? row.br || 1 : null,
|
||||
currencyCode: isGl ? (this.accountDetails?.currency?.code ?? 'INR') : null,
|
||||
clientId: isGl ? null : (this.accountDetails.clientId ?? this.clientDetails?.id ?? null),
|
||||
clientName: isGl
|
||||
? (this.accountDetails?.name ?? null)
|
||||
: (this.clientDetails?.displayName ?? this.accountDetails?.clientName ?? null),
|
||||
amount,
|
||||
txnDate: txnDateIso,
|
||||
paymentTypeId: row.paymentTypeId || null,
|
||||
paymentTypeName: selectedPt?.name ?? 'Cash',
|
||||
description: row.description || null,
|
||||
description: description || null,
|
||||
chequeNo: row.chequeNo || null,
|
||||
bankName: row.bankName || null,
|
||||
tokNo: row.tokNo?.trim() || null,
|
||||
@@ -6254,7 +6513,7 @@ export class TransactionEntryComponent implements OnInit, OnDestroy {
|
||||
|
||||
/** Build the deposit-request payload from the current row + loaded account. */
|
||||
private buildDepositPayload(row: TxnRow, amount: number): any {
|
||||
const today = new Date();
|
||||
const today = businessToday();
|
||||
const txnDateIso = `${today.getFullYear()}-${String(today.getMonth() + 1).padStart(2, '0')}-${String(today.getDate()).padStart(2, '0')}`;
|
||||
const selectedPt = this.paymentTypes.find((p: any) => p.id === row.paymentTypeId);
|
||||
// Send denominations only for cash payments — stale counts on a cheque
|
||||
@@ -6355,106 +6614,6 @@ export class TransactionEntryComponent implements OnInit, OnDestroy {
|
||||
this.addRow();
|
||||
}
|
||||
|
||||
private submitGlJournalEntry(row: TxnRow, amount: number, isDeposit: boolean): void {
|
||||
if (!this.cashVaultGlId) {
|
||||
this.showAlert('error', 'Cash Vault GL Not Configured', [
|
||||
'Financial Activity Account (CASH_AT_TELLER) is not configured.',
|
||||
'Please configure it in System → Financial Activity Accounts.'
|
||||
]);
|
||||
this.saving = false;
|
||||
return;
|
||||
}
|
||||
|
||||
const today = new Date();
|
||||
const txnDate = `${String(today.getDate()).padStart(2, '0')}/${String(today.getMonth() + 1).padStart(2, '0')}/${today.getFullYear()}`;
|
||||
const txnDateIso = `${today.getFullYear()}-${String(today.getMonth() + 1).padStart(2, '0')}-${String(today.getDate()).padStart(2, '0')}`;
|
||||
|
||||
const selectedGlId = this.accountDetails.id;
|
||||
const contraGlId = this.cashVaultGlId;
|
||||
|
||||
// isDeposit (CR button) = cashier receives cash → credit selected GL, debit contra (cash vault)
|
||||
// isWithdrawal (DR button) = cashier pays out cash → debit selected GL, credit contra (cash vault)
|
||||
const credits = isDeposit ? [{ glAccountId: selectedGlId, amount }] : [{ glAccountId: contraGlId, amount }];
|
||||
const debits = isDeposit ? [{ glAccountId: contraGlId, amount }] : [{ glAccountId: selectedGlId, amount }];
|
||||
|
||||
const payload: any = {
|
||||
officeId: row.br || 1,
|
||||
transactionDate: txnDate,
|
||||
dateFormat: 'dd/MM/yyyy',
|
||||
locale: 'en',
|
||||
currencyCode: this.accountDetails?.currency?.code ?? 'INR',
|
||||
comments: row.description || null,
|
||||
credits,
|
||||
debits,
|
||||
paymentTypeId: row.paymentTypeId
|
||||
};
|
||||
|
||||
// Check if cash payment so we can record denomination after journal entry
|
||||
const selectedPt = this.paymentTypes.find((p: any) => p.id === row.paymentTypeId);
|
||||
const isCashPayment = !!selectedPt?.isCashPayment;
|
||||
|
||||
this.saving = true;
|
||||
this.http
|
||||
.post('/journalentries', payload)
|
||||
.pipe(
|
||||
catchError((err: any) => {
|
||||
const msg =
|
||||
err?.error?.errors?.[0]?.defaultUserMessage ??
|
||||
err?.error?.developerMessage ??
|
||||
'Journal entry failed. Please try again.';
|
||||
this.showAlert('error', 'Transaction Failed', [msg]);
|
||||
this.saving = false;
|
||||
return of(null);
|
||||
})
|
||||
)
|
||||
.subscribe((res: any) => {
|
||||
this.saving = false;
|
||||
if (res) {
|
||||
// Record denomination so cashier position stays accurate
|
||||
// Same condition as savings: cash + denom entered + denom matches posted amount
|
||||
if (isCashPayment && this.denominationTotal > 0 && this.denominationTotal === amount) {
|
||||
const denomBreakdown = [
|
||||
...this.notes,
|
||||
...this.coins
|
||||
]
|
||||
.filter((d) => this.countFor(d) > 0)
|
||||
.map((d) => ({
|
||||
denominationId: d.id,
|
||||
denominationValue: d.value,
|
||||
kind: d.kind,
|
||||
count: this.countFor(d),
|
||||
subtotal: this.subtotalFor(d)
|
||||
}));
|
||||
// Filed against the Cash Vault GL, not the account the teller selected on screen: that
|
||||
// selected account (e.g. Sundry Creditors) is the other leg of the journal entry, and
|
||||
// never the one whose physical notes actually changed hands. Tagging the breakdown to it
|
||||
// instead of the vault is why this cash never reached the cashier/vault position.
|
||||
this.http
|
||||
.post('/cashier/gl-denomination', {
|
||||
txnId: res.transactionId ?? res.resourceId,
|
||||
accountNo: this.cashVaultGlCode ?? row.acNo,
|
||||
accountName: this.cashVaultGlName ?? 'Cash at Vault',
|
||||
txnType: isDeposit ? 'DEPOSIT' : 'WITHDRAWAL',
|
||||
txnDate: txnDateIso,
|
||||
paymentTypeName: selectedPt?.name ?? 'Cash',
|
||||
denominationBreakdown: denomBreakdown
|
||||
})
|
||||
.pipe(catchError(() => of(null)))
|
||||
.subscribe();
|
||||
}
|
||||
|
||||
const ref = res.transactionId ?? res.resourceId ?? 'N/A';
|
||||
this.showAlert('success', 'GL Transaction Posted', [
|
||||
`Journal entry of ₹${amount.toLocaleString('en-IN', { minimumFractionDigits: 2 })} posted successfully.`,
|
||||
`Transaction ID: ${ref}`
|
||||
]);
|
||||
this.clearDenominations();
|
||||
this.loadOpeningPosition();
|
||||
this.loadAccountDetails(row.acNo.trim(), row.acTyp);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
onSearch(): void {
|
||||
if (this.selectedRow?.acNo.trim()) {
|
||||
this.loadAccountDetails(this.selectedRow.acNo.trim(), this.selectedRow.acTyp);
|
||||
|
||||
@@ -16,6 +16,7 @@ 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 DenomRow {
|
||||
denominationId: number;
|
||||
@@ -67,7 +68,7 @@ export class VaultPositionComponent implements OnInit {
|
||||
errorMsg = '';
|
||||
|
||||
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.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>Credit</th>
|
||||
<th class="num">Amount</th>
|
||||
<th>Correction</th>
|
||||
<th>User</th>
|
||||
<!-- Two dates, not one, on the system register.
|
||||
A voucher's timestamp is the minute it was written; the posting behind it carries
|
||||
@@ -217,6 +218,18 @@
|
||||
</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>
|
||||
@if (systemOnly) {
|
||||
<td class="vr-ledgerdate" [class.vr-ledgerdate--prior]="row.postedAfterClose">
|
||||
|
||||
@@ -92,3 +92,8 @@
|
||||
text-transform: uppercase;
|
||||
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 { catchError, of } from 'rxjs';
|
||||
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.
|
||||
@@ -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. */
|
||||
interface VoucherRow {
|
||||
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;
|
||||
amount: number | null;
|
||||
createdAt: string | null;
|
||||
@@ -81,7 +88,8 @@ interface VoucherReport {
|
||||
imports: [
|
||||
CommonModule,
|
||||
FormsModule,
|
||||
MatIconModule
|
||||
MatIconModule,
|
||||
VoucherStatusComponent
|
||||
]
|
||||
})
|
||||
export class VoucherReportComponent implements OnInit {
|
||||
@@ -227,7 +235,7 @@ export class VoucherReportComponent implements OnInit {
|
||||
}
|
||||
|
||||
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')}`;
|
||||
}
|
||||
|
||||
|
||||
@@ -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,18 @@ import { ReportMuleComponent } from './report-mule/report-mule.component';
|
||||
import { ReportFraudTxnComponent } from './report-fraud-txn/report-fraud-txn.component';
|
||||
import { FmrSubmissionComponent } from './fmr-submission/fmr-submission.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';
|
||||
|
||||
const routes: Routes = [
|
||||
Route.withShell([
|
||||
@@ -76,6 +88,67 @@ const routes: Routes = [
|
||||
path: 'cfr-lookup',
|
||||
component: CfrLookupComponent,
|
||||
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: '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>
|
||||
<span>Dashboard</span>
|
||||
</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
|
||||
mat-tab-link
|
||||
class="cfc-tab"
|
||||
|
||||
@@ -0,0 +1,144 @@
|
||||
/**
|
||||
* 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 {
|
||||
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,789 @@
|
||||
/**
|
||||
* 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: 860px;
|
||||
}
|
||||
|
||||
&--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);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.i4c-modal-tabs {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
padding: 6px 16px;
|
||||
background: #f8fafc;
|
||||
border-bottom: 1px solid #e2e8f0;
|
||||
overflow-x: auto;
|
||||
flex-shrink: 0;
|
||||
|
||||
.modal-tab {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 5px;
|
||||
padding: 4px 10px;
|
||||
border-radius: 6px;
|
||||
font-size: 11px;
|
||||
font-weight: 600;
|
||||
color: #64748b;
|
||||
background: transparent;
|
||||
border: 1px solid transparent;
|
||||
cursor: pointer;
|
||||
transition: all 0.2s ease;
|
||||
white-space: nowrap;
|
||||
|
||||
&:hover {
|
||||
color: #0f172a;
|
||||
background: #e2e8f0;
|
||||
}
|
||||
|
||||
&--active {
|
||||
color: #2563eb;
|
||||
background: #fff;
|
||||
border-color: #e2e8f0;
|
||||
box-shadow: 0 1px 3px rgb(15 23 42 / 5%);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.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,258 @@
|
||||
<!--
|
||||
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>
|
||||
}
|
||||
|
||||
<!-- 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,360 @@
|
||||
/**
|
||||
* 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;
|
||||
}
|
||||
@@ -0,0 +1,167 @@
|
||||
/**
|
||||
* 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;
|
||||
|
||||
ngOnInit(): void {
|
||||
this.load();
|
||||
}
|
||||
|
||||
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,484 @@
|
||||
<!--
|
||||
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>
|
||||
</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,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,83 @@
|
||||
<!--
|
||||
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">
|
||||
<fa-icon icon="exclamation-triangle"></fa-icon>
|
||||
Still needed:
|
||||
@for (m of missing; track m; let last = $last) {
|
||||
<b>{{ label(m) }}</b
|
||||
>{{ last ? '' : ', ' }}
|
||||
}
|
||||
</div>
|
||||
}
|
||||
|
||||
<table class="i4c-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Type</th>
|
||||
<th>File</th>
|
||||
<th>Remarks</th>
|
||||
<th>Uploaded</th>
|
||||
<th></th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
@for (d of rows; track d.id) {
|
||||
<tr>
|
||||
<td>{{ label(d.doc_type) }}</td>
|
||||
<td>
|
||||
<span class="docs__name">{{ d.file_name }}</span>
|
||||
<div class="docs__sub" [title]="'SHA-256 ' + d.sha256">
|
||||
{{ size(d.size_bytes) }} · {{ d.sha256.slice(0, 12) }}…
|
||||
</div>
|
||||
</td>
|
||||
<td>{{ d.remarks || '-' }}</td>
|
||||
<td>
|
||||
{{ d.uploaded_at | date: 'dd MMM yyyy, HH:mm' }}
|
||||
<div class="docs__sub">{{ d.uploaded_by }}</div>
|
||||
</td>
|
||||
<td>
|
||||
<button mat-stroked-button type="button" (click)="download(d)">
|
||||
<fa-icon icon="download"></fa-icon> Download
|
||||
</button>
|
||||
</td>
|
||||
</tr>
|
||||
} @empty {
|
||||
<tr>
|
||||
<td colspan="5" class="i4c-empty">No documents attached yet.</td>
|
||||
</tr>
|
||||
}
|
||||
</tbody>
|
||||
</table>
|
||||
|
||||
@if (canUpload) {
|
||||
<div class="docs__upload">
|
||||
<mat-form-field appearance="outline" subscriptSizing="dynamic">
|
||||
<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">
|
||||
<mat-label>Remarks (order no., date…)</mat-label>
|
||||
<input matInput [(ngModel)]="remarks" maxlength="500" />
|
||||
</mat-form-field>
|
||||
<label class="docs__file">
|
||||
<input #fileInput type="file" accept="application/pdf,image/jpeg,image/png" (change)="pick($event)" />
|
||||
</label>
|
||||
<button mat-flat-button color="primary" type="button" (click)="upload(fileInput)" [disabled]="uploading || !file">
|
||||
<fa-icon icon="upload"></fa-icon> {{ uploading ? 'Uploading…' : 'Attach' }}
|
||||
</button>
|
||||
<span class="docs__sub">PDF, JPEG or PNG, up to 5 MB. Stored with a SHA-256; never changed once attached.</span>
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
@@ -0,0 +1,66 @@
|
||||
/**
|
||||
* 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: 10px;
|
||||
|
||||
&__missing {
|
||||
padding: 8px 12px;
|
||||
border-radius: 8px;
|
||||
border: 1px solid #fde68a;
|
||||
background: #fffbeb;
|
||||
color: #92400e;
|
||||
font-size: 12.5px;
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 4px;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
&__name {
|
||||
font-weight: 600;
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
|
||||
&__sub {
|
||||
font-size: 11px;
|
||||
color: #64748b;
|
||||
}
|
||||
|
||||
&__upload {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
padding-top: 10px;
|
||||
border-top: 1px solid #f1f5f9;
|
||||
|
||||
mat-form-field {
|
||||
width: 200px;
|
||||
}
|
||||
}
|
||||
|
||||
&__remarks {
|
||||
flex: 1 1 220px;
|
||||
}
|
||||
|
||||
&__file input {
|
||||
font-size: 12px;
|
||||
max-width: 240px;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,174 @@
|
||||
/**
|
||||
* 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;
|
||||
}
|
||||
|
||||
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,558 @@
|
||||
<!--
|
||||
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>
|
||||
}
|
||||
<mat-form-field appearance="outline" subscriptSizing="dynamic">
|
||||
<mat-label>Order / decision date</mat-label>
|
||||
<input matInput type="date" [(ngModel)]="decision.orderDate" />
|
||||
</mat-form-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 (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>
|
||||
+5
-3
@@ -6,6 +6,8 @@
|
||||
* file, You can obtain one at http://mozilla.org/MPL/2.0/.
|
||||
*/
|
||||
|
||||
.container {
|
||||
max-width: 37rem;
|
||||
}
|
||||
@use '../i4c' as *;
|
||||
@use '../i4c-cases' as *;
|
||||
|
||||
@include i4c-skin;
|
||||
@include i4c-case-page;
|
||||
@@ -0,0 +1,359 @@
|
||||
/**
|
||||
* 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 { 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: '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
|
||||
]
|
||||
})
|
||||
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 });
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
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';
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,794 @@
|
||||
<!--
|
||||
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 (v of views; track v.value) {
|
||||
<button class="filter-chip" [class.filter-chip--active]="view === v.value" (click)="setView(v.value)">
|
||||
<span>{{ v.label }}</span>
|
||||
</button>
|
||||
}
|
||||
</div>
|
||||
|
||||
<span class="i4c-toolbar__spacer"></span>
|
||||
|
||||
<mat-form-field appearance="outline" subscriptSizing="dynamic" class="search-input">
|
||||
<mat-label>Account / acknowledgement / customer</mat-label>
|
||||
<input matInput [(ngModel)]="search" (keyup.enter)="load(true)" placeholder="Type & hit enter..." />
|
||||
</mat-form-field>
|
||||
|
||||
<button mat-flat-button color="primary" class="search-btn" (click)="load(true)">
|
||||
<fa-icon icon="search"></fa-icon> Search
|
||||
</button>
|
||||
|
||||
<span class="count-tag">{{ total }} holds</span>
|
||||
</div>
|
||||
|
||||
<div class="i4c-filter-bar">
|
||||
<mat-form-field appearance="outline" subscriptSizing="dynamic">
|
||||
<mat-label>Account type</mat-label>
|
||||
<mat-select [(ngModel)]="accountType" (selectionChange)="load(true)">
|
||||
<mat-option value="">All accounts</mat-option>
|
||||
<mat-option value="SAVINGS">Savings</mat-option>
|
||||
<mat-option value="CURRENT">Current</mat-option>
|
||||
</mat-select>
|
||||
</mat-form-field>
|
||||
<mat-form-field appearance="outline" subscriptSizing="dynamic">
|
||||
<mat-label>Customer intimation</mat-label>
|
||||
<mat-select [(ngModel)]="intimationFilter" (selectionChange)="load(true)">
|
||||
<mat-option value="">Any</mat-option>
|
||||
<mat-option value="DONE">Customer told</mat-option>
|
||||
<mat-option value="PENDING">Not yet told</mat-option>
|
||||
</mat-select>
|
||||
</mat-form-field>
|
||||
<mat-form-field appearance="outline" subscriptSizing="dynamic">
|
||||
<mat-label>Layer</mat-label>
|
||||
<mat-select [(ngModel)]="layer" (selectionChange)="load(true)">
|
||||
<mat-option value="">All layers</mat-option>
|
||||
<mat-option value="0">Layer 0</mat-option>
|
||||
<mat-option value="1">Layer 1+</mat-option>
|
||||
</mat-select>
|
||||
</mat-form-field>
|
||||
<mat-checkbox [(ngModel)]="frozenOnly" (change)="load(true)">Debits frozen only</mat-checkbox>
|
||||
<mifosx-i4c-date-range label="Held 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 || busy) {
|
||||
<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>
|
||||
}
|
||||
|
||||
<!-- Holds Register Table -->
|
||||
<div class="i4c-card">
|
||||
<div class="i4c-card__head">
|
||||
<fa-icon icon="lock"></fa-icon>
|
||||
<span>I4C Account Holds Register</span>
|
||||
<span class="head-hint">Only disputed amounts are restricted; remaining balance stays usable by customer.</span>
|
||||
</div>
|
||||
|
||||
<div class="i4c-table-wrap">
|
||||
<table class="i4c-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Customer Account</th>
|
||||
<th>Account Holder / Branch</th>
|
||||
<th>Ack No.</th>
|
||||
<th class="num">Layer</th>
|
||||
<th class="num">Disputed (₹)</th>
|
||||
<th class="num">Held (₹)</th>
|
||||
<th class="num">Short (₹)</th>
|
||||
<th>Hold Status</th>
|
||||
<th>Customer Intimation</th>
|
||||
<th>90-Day Review</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
@for (h of rows; track h.id) {
|
||||
<tr class="clickable" (click)="select(h.id)">
|
||||
<td>
|
||||
<div class="acc-cell">
|
||||
<b>{{ h.account_no }}</b>
|
||||
<span class="type-tag">{{ h.account_type }}</span>
|
||||
</div>
|
||||
</td>
|
||||
<td>
|
||||
<div class="client-name">{{ h.client_name || 'N/A' }}</div>
|
||||
<div class="sub">{{ h.office_name }}</div>
|
||||
</td>
|
||||
<td>
|
||||
<div class="ack-cell">
|
||||
<span class="ack-no">{{ h.acknowledgement_no }}</span>
|
||||
<button
|
||||
class="copy-chip"
|
||||
(click)="copyText(h.acknowledgement_no, 'ack-' + h.id, $event)"
|
||||
title="Copy Ack No."
|
||||
>
|
||||
<fa-icon icon="copy"></fa-icon>
|
||||
</button>
|
||||
</div>
|
||||
</td>
|
||||
<td class="num font-semibold">{{ h.layer }}</td>
|
||||
<td class="num">₹{{ h.disputed_amount | number: '1.2-2' }}</td>
|
||||
<td class="num font-bold text-accent">₹{{ h.amount_held | number: '1.2-2' }}</td>
|
||||
<td class="num">
|
||||
@if (h.shortfall > 0) {
|
||||
<span class="short-pill">₹{{ h.shortfall | number: '1.2-2' }}</span>
|
||||
} @else {
|
||||
<span class="text-muted">-</span>
|
||||
}
|
||||
</td>
|
||||
<td>
|
||||
<span class="i4c-badge" [class]="statusClass(h)">{{ h.status }}</span>
|
||||
@if (h.debit_frozen) {
|
||||
<span class="i4c-badge i4c-badge--red">DEBIT FROZEN</span>
|
||||
}
|
||||
@if (h.pending_actions) {
|
||||
<span class="i4c-badge i4c-badge--purple">APPROVAL PENDING</span>
|
||||
}
|
||||
</td>
|
||||
<td>
|
||||
@if (h.customer_intimated_at) {
|
||||
<span class="intimation-ok">
|
||||
<fa-icon icon="check-circle"></fa-icon>
|
||||
<span>{{ h.customer_intimated_at | date: 'dd MMM' }} ({{ h.intimation_mode }})</span>
|
||||
</span>
|
||||
} @else if (isOverdue(h)) {
|
||||
<span class="i4c-badge i4c-badge--red">OVERDUE</span>
|
||||
} @else if (h.amount_held > 0 && h.status === 'ACTIVE') {
|
||||
<span class="i4c-badge i4c-badge--amber">INTIMATION DUE</span>
|
||||
} @else {
|
||||
<span class="text-muted">-</span>
|
||||
}
|
||||
</td>
|
||||
<td>
|
||||
<span [class.due-alert]="isReviewDue(h)" class="review-date">
|
||||
@if (isReviewDue(h)) {
|
||||
<fa-icon icon="exclamation-circle"></fa-icon>
|
||||
}
|
||||
{{ h.review_due_on }}
|
||||
</span>
|
||||
</td>
|
||||
</tr>
|
||||
} @empty {
|
||||
<tr>
|
||||
<td colspan="10" class="i4c-empty">
|
||||
<div class="empty-icon"><fa-icon icon="lock"></fa-icon></div>
|
||||
<div>No holds found in this view.</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="Holds pages"
|
||||
></mat-paginator>
|
||||
</div>
|
||||
|
||||
<!-- ========================================================
|
||||
MODERN POPUP WINDOW: HOLD DETAILS & MAKER ACTIONS MODAL
|
||||
======================================================== -->
|
||||
@if (selected; as h) {
|
||||
<div class="i4c-modal-backdrop" (click)="close()">
|
||||
<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">
|
||||
<fa-icon icon="lock"></fa-icon>
|
||||
<div>
|
||||
<div class="modal-title-row">
|
||||
<span>Hold #{{ h.id }} on Account {{ h.account_no }}</span>
|
||||
<span class="i4c-badge" [class]="statusClass(h)">{{ h.status }}</span>
|
||||
@if (h.debit_frozen) {
|
||||
<span class="i4c-badge i4c-badge--red">DEBIT FROZEN</span>
|
||||
}
|
||||
@if (hasPendingAction) {
|
||||
<span class="i4c-badge i4c-badge--purple">APPROVAL PENDING</span>
|
||||
}
|
||||
</div>
|
||||
<div class="i4c-modal-header__sub">
|
||||
Customer: <b>{{ h.client_name || 'N/A' }}</b> ({{ h.office_name }}) • Ack: {{ h.acknowledgement_no }}
|
||||
</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>
|
||||
|
||||
<!-- Navigation Tabs inside the Modal -->
|
||||
<div class="i4c-modal-tabs">
|
||||
<button class="modal-tab" [class.modal-tab--active]="modalTab === 'overview'" (click)="modalTab = 'overview'">
|
||||
<fa-icon icon="info-circle"></fa-icon> Overview & Compliance
|
||||
</button>
|
||||
<button class="modal-tab" [class.modal-tab--active]="modalTab === 'txns'" (click)="modalTab = 'txns'">
|
||||
<fa-icon icon="table"></fa-icon> Hold Transactions ({{ h.transactions?.length || 0 }})
|
||||
</button>
|
||||
@if ((h.status === 'ACTIVE' && h.amount_held > 0) || h.intimationLog?.length) {
|
||||
<button
|
||||
class="modal-tab"
|
||||
[class.modal-tab--active]="modalTab === 'intimation'"
|
||||
(click)="modalTab = 'intimation'"
|
||||
>
|
||||
<fa-icon icon="bell"></fa-icon> Customer Intimation
|
||||
@if (!h.customer_intimated_at && h.status === 'ACTIVE' && h.amount_held > 0) {
|
||||
<span class="i4c-badge i4c-badge--amber">Pending</span>
|
||||
}
|
||||
</button>
|
||||
}
|
||||
@if (allowedActions.length > 0) {
|
||||
<button class="modal-tab" [class.modal-tab--active]="modalTab === 'action'" (click)="modalTab = 'action'">
|
||||
<fa-icon icon="hand-paper"></fa-icon> Request Action (Maker)
|
||||
</button>
|
||||
}
|
||||
<button class="modal-tab" [class.modal-tab--active]="modalTab === 'cases'" (click)="modalTab = 'cases'">
|
||||
<fa-icon icon="balance-scale"></fa-icon> GRM / MRM & Documents
|
||||
@if (openGrievance || openRestoration) {
|
||||
<span class="i4c-badge i4c-badge--amber">Open</span>
|
||||
}
|
||||
</button>
|
||||
@if (h.actions?.length) {
|
||||
<button class="modal-tab" [class.modal-tab--active]="modalTab === 'history'" (click)="modalTab = 'history'">
|
||||
<fa-icon icon="history"></fa-icon> Request Audit ({{ h.actions.length }})
|
||||
</button>
|
||||
}
|
||||
</div>
|
||||
|
||||
<!-- Modal Body -->
|
||||
<div class="i4c-modal-body">
|
||||
<!-- TAB 1: OVERVIEW -->
|
||||
@if (modalTab === 'overview') {
|
||||
<div class="i4c-grid-2">
|
||||
<div class="i4c-card">
|
||||
<div class="i4c-card__head"><fa-icon icon="shield-alt"></fa-icon> Hold Balances & Account</div>
|
||||
<div class="i4c-card__body">
|
||||
<dl class="i4c-kv">
|
||||
<dt>Customer Name</dt>
|
||||
<dd>{{ h.client_name || '-' }} ({{ h.office_name }})</dd>
|
||||
|
||||
<dt>Account Number</dt>
|
||||
<dd>
|
||||
<b>{{ h.account_no }}</b> ({{ h.account_type }})
|
||||
</dd>
|
||||
|
||||
<dt>I4C Complaint Ack</dt>
|
||||
<dd>
|
||||
<a [routerLink]="['../requests', h.request_id]" (click)="close()">{{ h.acknowledgement_no }}</a>
|
||||
(Layer {{ h.layer }})
|
||||
</dd>
|
||||
|
||||
<dt>Disputed Amount</dt>
|
||||
<dd>₹{{ h.disputed_amount | number: '1.2-2' }}</dd>
|
||||
|
||||
<dt>Amount Held</dt>
|
||||
<dd>
|
||||
<b class="amount-held-text">₹{{ h.amount_held | number: '1.2-2' }}</b>
|
||||
</dd>
|
||||
|
||||
<dt>Shortfall</dt>
|
||||
<dd>
|
||||
@if (h.shortfall > 0) {
|
||||
<span class="err-text">₹{{ h.shortfall | number: '1.2-2' }} (Prospective hold)</span>
|
||||
} @else {
|
||||
<span>Fully covered (₹0.00)</span>
|
||||
}
|
||||
</dd>
|
||||
</dl>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="i4c-card">
|
||||
<div class="i4c-card__head"><fa-icon icon="calendar-check"></fa-icon> Compliance & Directions</div>
|
||||
<div class="i4c-card__body">
|
||||
<dl class="i4c-kv">
|
||||
<dt>Hold Placed On</dt>
|
||||
<dd>{{ h.held_at | date: 'dd MMM yyyy, HH:mm:ss' }} (System automated)</dd>
|
||||
|
||||
<dt>90-Day Review Due</dt>
|
||||
<dd>
|
||||
<span [class.due-alert]="isReviewDue(h)">{{ h.review_due_on }}</span>
|
||||
</dd>
|
||||
|
||||
<dt>Debit Freeze Status</dt>
|
||||
<dd>
|
||||
@if (h.debit_frozen) {
|
||||
<span class="i4c-badge i4c-badge--red">FROZEN</span>
|
||||
} @else {
|
||||
<span>Active (Debit Not Frozen)</span>
|
||||
}
|
||||
</dd>
|
||||
|
||||
<dt>Customer Intimation</dt>
|
||||
<dd>
|
||||
@if (h.customer_intimated_at) {
|
||||
<span class="intimation-ok">
|
||||
{{ h.customer_intimated_at | date: 'dd MMM yyyy, HH:mm' }} by {{ h.intimated_by }} ({{
|
||||
h.intimation_mode
|
||||
}}{{ h.intimation_reference ? ', ' + h.intimation_reference : '' }})
|
||||
</span>
|
||||
} @else {
|
||||
<span class="err-text">
|
||||
Not told yet{{
|
||||
h.intimation_due_by ? ' - due by ' + (h.intimation_due_by | date: 'dd MMM yyyy, HH:mm') : ''
|
||||
}}
|
||||
</span>
|
||||
}
|
||||
</dd>
|
||||
|
||||
@if (h.status === 'RELEASED') {
|
||||
<dt>Released On</dt>
|
||||
<dd>
|
||||
{{ h.released_at | date: 'dd MMM yyyy, HH:mm' }} -
|
||||
{{ reasonLabel(h.release_reason_type || '') }}
|
||||
{{ h.release_reference }}
|
||||
</dd>
|
||||
}
|
||||
</dl>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
|
||||
<!-- TAB 2: TRANSACTIONS -->
|
||||
@if (modalTab === 'txns') {
|
||||
<div class="i4c-card">
|
||||
<div class="i4c-card__head"><fa-icon icon="table"></fa-icon> Account Hold Transactions</div>
|
||||
<div class="i4c-card__body i4c-table-wrap">
|
||||
<table class="i4c-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Account Txn ID</th>
|
||||
<th class="num">Held Amount (₹)</th>
|
||||
<th>Placed Timestamp</th>
|
||||
<th>Released Timestamp</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
@for (t of h.transactions; track t.id) {
|
||||
<tr>
|
||||
<td>
|
||||
<b>{{ t.account_txn_id }}</b>
|
||||
</td>
|
||||
<td class="num">₹{{ t.amount | number: '1.2-2' }}</td>
|
||||
<td>{{ t.placed_at | date: 'dd MMM yyyy, HH:mm' }}</td>
|
||||
<td>{{ t.released_at ? (t.released_at | date: 'dd MMM yyyy, HH:mm') : '-' }}</td>
|
||||
</tr>
|
||||
} @empty {
|
||||
<tr>
|
||||
<td colspan="4" class="i4c-empty">
|
||||
No money held yet (prospective hold waiting for funds deposit).
|
||||
</td>
|
||||
</tr>
|
||||
}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
|
||||
<!-- TAB 3: CUSTOMER INTIMATION (automatic channels, manual record, history) -->
|
||||
@if (modalTab === 'intimation') {
|
||||
@if (h.customer_intimated_at) {
|
||||
<div class="intim-status intim-status--done">
|
||||
<fa-icon icon="check"></fa-icon>
|
||||
<div>
|
||||
<b>Customer told</b> on {{ h.customer_intimated_at | date: 'dd MMM yyyy, HH:mm' }} by
|
||||
{{ channelLabel(h.intimation_mode || '')
|
||||
}}{{ h.intimation_reference ? ' - ' + h.intimation_reference : '' }} ({{ h.intimated_by }})
|
||||
</div>
|
||||
</div>
|
||||
} @else if (h.automaticChannels?.length) {
|
||||
<div class="intim-status intim-status--auto">
|
||||
<fa-icon icon="paper-plane"></fa-icon>
|
||||
<div>
|
||||
<b>Automatic intimation is on</b> ({{ autoChannelsText(h) }}).
|
||||
@if (h.intimationLog?.length) {
|
||||
The last automatic attempt did not reach the customer; the system retries up to 3 times. You can
|
||||
send again now, or call the customer and record it below.
|
||||
} @else {
|
||||
The notice is sent as soon as the hold is placed. If it has not gone yet, send it now.
|
||||
}
|
||||
</div>
|
||||
<button mat-flat-button color="primary" (click)="sendIntimationNow()" [disabled]="busy">
|
||||
<fa-icon icon="paper-plane"></fa-icon> Send now
|
||||
</button>
|
||||
</div>
|
||||
} @else {
|
||||
<div class="intim-status intim-status--manual">
|
||||
<fa-icon icon="phone"></fa-icon>
|
||||
<div>
|
||||
<b>No automatic channel is configured.</b> Call the customer (or send a letter / meet in person), then
|
||||
record it below. Channels are switched on under Intimation Settings.
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
|
||||
@if (!h.customer_intimated_at && h.status === 'ACTIVE' && h.amount_held > 0) {
|
||||
<div class="i4c-card">
|
||||
<div class="i4c-card__head"><fa-icon icon="bell"></fa-icon> Record Customer Intimation (manual)</div>
|
||||
<div class="i4c-card__body">
|
||||
<div class="i4c-note">
|
||||
RBI draft directions mandate that the account holder be notified of an account hold by the end of
|
||||
the next day, including reason, restoration options, and Grievance Officer contacts.
|
||||
</div>
|
||||
<div class="i4c-form">
|
||||
<mat-form-field appearance="outline">
|
||||
<mat-label>How the customer was told</mat-label>
|
||||
<mat-select [(ngModel)]="intimation.mode">
|
||||
@for (m of intimationModes; track m) {
|
||||
<mat-option [value]="m">{{ channelLabel(m) }}</mat-option>
|
||||
}
|
||||
</mat-select>
|
||||
</mat-form-field>
|
||||
|
||||
<mat-form-field appearance="outline">
|
||||
<mat-label>Reference (call note, letter no.)</mat-label>
|
||||
<input
|
||||
matInput
|
||||
[(ngModel)]="intimation.reference"
|
||||
maxlength="200"
|
||||
placeholder="e.g. Called 98xxxx1234, customer informed"
|
||||
/>
|
||||
</mat-form-field>
|
||||
</div>
|
||||
<button mat-flat-button color="primary" (click)="submitIntimation()" [disabled]="busy">
|
||||
<fa-icon icon="bell"></fa-icon> Record Customer Intimation
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
|
||||
<div class="i4c-card">
|
||||
<div class="i4c-card__head">
|
||||
<fa-icon icon="history"></fa-icon> Intimation History ({{ h.intimationLog?.length || 0 }})
|
||||
</div>
|
||||
<div class="i4c-card__body">
|
||||
<table class="i4c-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>When</th>
|
||||
<th>Channel</th>
|
||||
<th>Type</th>
|
||||
<th>Status</th>
|
||||
<th>Sent to</th>
|
||||
<th>Reference / Reason</th>
|
||||
<th>By</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
@for (l of h.intimationLog || []; track l.id) {
|
||||
<tr>
|
||||
<td>{{ l.created_at | date: 'dd MMM yyyy, HH:mm' }}</td>
|
||||
<td>{{ channelLabel(l.channel) }}</td>
|
||||
<td>
|
||||
{{ l.mode === 'AUTO' ? 'Automatic' : l.mode === 'RESEND' ? 'Sent by officer' : 'Recorded' }}
|
||||
</td>
|
||||
<td>
|
||||
<span
|
||||
class="i4c-badge"
|
||||
[class.i4c-badge--green]="
|
||||
l.status === 'SENT' || l.status === 'DELIVERED' || l.status === 'RECORDED'
|
||||
"
|
||||
[class.i4c-badge--red]="l.status === 'FAILED'"
|
||||
[class.i4c-badge--amber]="l.status === 'SKIPPED_NO_DESTINATION'"
|
||||
>{{ l.status }}</span
|
||||
>
|
||||
</td>
|
||||
<td>{{ l.destination_masked || '-' }}</td>
|
||||
<td class="wrap">{{ l.failure_reason || l.provider_reference || '-' }}</td>
|
||||
<td>{{ l.actor || '-' }}</td>
|
||||
</tr>
|
||||
} @empty {
|
||||
<tr>
|
||||
<td colspan="7" class="i4c-empty">No notice sent or recorded yet.</td>
|
||||
</tr>
|
||||
}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
|
||||
<!-- TAB 4: MAKER CHANGE REQUEST FORM -->
|
||||
@if (modalTab === 'action') {
|
||||
<div class="i4c-card">
|
||||
<div class="i4c-card__head"><fa-icon icon="hand-paper"></fa-icon> Raise Request on Hold (Maker)</div>
|
||||
<div class="i4c-card__body">
|
||||
@if (hasPendingAction) {
|
||||
<div class="i4c-note">
|
||||
A request on this hold is already waiting for checker approval on the Approvals page.
|
||||
</div>
|
||||
} @else {
|
||||
<div class="i4c-form">
|
||||
<mat-form-field appearance="outline">
|
||||
<mat-label>Requested Action</mat-label>
|
||||
<mat-select [(ngModel)]="action.action">
|
||||
@for (a of allowedActions; track a.value) {
|
||||
<mat-option [value]="a.value">{{ a.label }}</mat-option>
|
||||
}
|
||||
</mat-select>
|
||||
</mat-form-field>
|
||||
|
||||
@if (action.action === 'RELEASE') {
|
||||
<mat-form-field appearance="outline">
|
||||
<mat-label>Amount to release (₹, empty = all)</mat-label>
|
||||
<input matInput type="number" min="0" [max]="h.amount_held" [(ngModel)]="action.amount" />
|
||||
@if (remainingAfterRelease !== null) {
|
||||
<mat-hint
|
||||
>₹{{ remainingAfterRelease | number: '1.2-2' }} stays on hold (order names the amount to
|
||||
keep)</mat-hint
|
||||
>
|
||||
}
|
||||
</mat-form-field>
|
||||
}
|
||||
|
||||
<mat-form-field appearance="outline">
|
||||
<mat-label>Reason</mat-label>
|
||||
<mat-select [(ngModel)]="action.reasonType">
|
||||
@for (r of reasonTypes; track r.value) {
|
||||
<mat-option [value]="r.value">{{ r.label }}</mat-option>
|
||||
}
|
||||
</mat-select>
|
||||
</mat-form-field>
|
||||
|
||||
<mat-form-field appearance="outline">
|
||||
<mat-label>Order / Reference No.{{ orderRequired ? ' *' : '' }}</mat-label>
|
||||
<input
|
||||
matInput
|
||||
[(ngModel)]="action.orderReference"
|
||||
maxlength="200"
|
||||
placeholder="IO / Court reference..."
|
||||
/>
|
||||
</mat-form-field>
|
||||
|
||||
<mat-form-field appearance="outline">
|
||||
<mat-label>Order Date</mat-label>
|
||||
<input matInput type="date" [(ngModel)]="action.orderDate" />
|
||||
</mat-form-field>
|
||||
|
||||
<mat-form-field appearance="outline">
|
||||
<mat-label>Issuing Authority (IO, Court, DGRO...)</mat-label>
|
||||
<input
|
||||
matInput
|
||||
[(ngModel)]="action.authority"
|
||||
maxlength="200"
|
||||
placeholder="e.g. Cyber Cell IO..."
|
||||
/>
|
||||
</mat-form-field>
|
||||
|
||||
<mat-form-field appearance="outline">
|
||||
<mat-label>Maker Remarks</mat-label>
|
||||
<input
|
||||
matInput
|
||||
[(ngModel)]="action.remarks"
|
||||
maxlength="1000"
|
||||
placeholder="Notes for checker..."
|
||||
/>
|
||||
</mat-form-field>
|
||||
</div>
|
||||
<button mat-flat-button color="warn" (click)="submitAction()" [disabled]="busy">
|
||||
<fa-icon icon="hand-paper"></fa-icon> Submit for Checker Approval
|
||||
</button>
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
|
||||
<!-- TAB: GRM grievance, MRM restoration, statement and documents of this hold -->
|
||||
@if (modalTab === 'cases') {
|
||||
<div class="cases-grid">
|
||||
<div class="i4c-card">
|
||||
<div class="i4c-card__head">
|
||||
<fa-icon icon="balance-scale"></fa-icon> GRM grievance (customer contests the hold)
|
||||
</div>
|
||||
<div class="i4c-card__body">
|
||||
@for (g of h.grievances || []; track g.id) {
|
||||
<div
|
||||
class="case-row"
|
||||
(click)="goTo('grievances', g.id)"
|
||||
(keyup.enter)="goTo('grievances', g.id)"
|
||||
tabindex="0"
|
||||
>
|
||||
<b>{{ g.grievance_no || 'Draft #' + g.id }}</b>
|
||||
<span class="i4c-badge">{{ g.stage }}</span>
|
||||
<span
|
||||
class="i4c-badge"
|
||||
[class.i4c-badge--green]="g.status === 'ALLOWED'"
|
||||
[class.i4c-badge--red]="g.status === 'REJECTED'"
|
||||
>{{ g.status }}</span
|
||||
>
|
||||
<span class="sub">{{ g.created_at | date: 'dd MMM yyyy' }}</span>
|
||||
</div>
|
||||
}
|
||||
@if (h.status === 'ACTIVE' && !openGrievance) {
|
||||
<div class="i4c-form">
|
||||
<mat-form-field appearance="outline">
|
||||
<mat-label>Customer asks to</mat-label>
|
||||
<mat-select [(ngModel)]="grievanceForm.customerRequest">
|
||||
<mat-option value="REMOVE_HOLD">Remove the hold</mat-option>
|
||||
<mat-option value="UNFREEZE">Unfreeze the account</mat-option>
|
||||
<mat-option value="RESTORE_DIGITAL_SERVICES">Restore digital services</mat-option>
|
||||
</mat-select>
|
||||
</mat-form-field>
|
||||
<mat-form-field appearance="outline">
|
||||
<mat-label>Customer's reason</mat-label>
|
||||
<input matInput [(ngModel)]="grievanceForm.customerReason" maxlength="2000" />
|
||||
</mat-form-field>
|
||||
</div>
|
||||
<button mat-flat-button color="primary" (click)="raiseGrievance()" [disabled]="busy">
|
||||
<fa-icon icon="plus"></fa-icon> Open grievance (EDD next)
|
||||
</button>
|
||||
} @else if (!h.grievances?.length) {
|
||||
<div class="sub">No grievance on this hold.</div>
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="i4c-card">
|
||||
<div class="i4c-card__head"><fa-icon icon="hand-holding-usd"></fa-icon> MRM money restoration</div>
|
||||
<div class="i4c-card__body">
|
||||
@for (r of h.restorations || []; track r.id) {
|
||||
<div
|
||||
class="case-row"
|
||||
(click)="goTo('restorations', r.id)"
|
||||
(keyup.enter)="goTo('restorations', r.id)"
|
||||
tabindex="0"
|
||||
>
|
||||
<b>{{ r.mrm_request_no || 'Restoration #' + r.id }}</b>
|
||||
<span class="i4c-badge">{{ r.status }}</span>
|
||||
<span class="sub"
|
||||
>{{ r.basis }}
|
||||
{{ r.restorable_amount ? '₹' + (r.restorable_amount | number: '1.2-2') : '' }}</span
|
||||
>
|
||||
</div>
|
||||
}
|
||||
@if (h.status === 'ACTIVE' && !openRestoration) {
|
||||
<div class="i4c-form">
|
||||
<mat-form-field appearance="outline">
|
||||
<mat-label>Restoration</mat-label>
|
||||
<mat-select [(ngModel)]="assessForm.basis">
|
||||
@for (b of mrmBases; track b.value) {
|
||||
<mat-option [value]="b.value">{{ b.label }}</mat-option>
|
||||
}
|
||||
</mat-select>
|
||||
</mat-form-field>
|
||||
@if (assessForm.basis === 'ATTRIBUTION' || assessForm.basis === 'PRO_RATA') {
|
||||
<mat-form-field appearance="outline">
|
||||
<mat-label>Restorable amount (₹, at most {{ h.amount_held | number: '1.2-2' }})</mat-label>
|
||||
<input matInput type="number" min="0" [(ngModel)]="assessForm.restorableAmount" />
|
||||
</mat-form-field>
|
||||
}
|
||||
<mat-form-field appearance="outline">
|
||||
<mat-label>Remarks</mat-label>
|
||||
<input matInput [(ngModel)]="assessForm.remarks" maxlength="1000" />
|
||||
</mat-form-field>
|
||||
</div>
|
||||
<button mat-flat-button color="primary" (click)="assessForMrm()" [disabled]="busy">
|
||||
<fa-icon icon="check"></fa-icon> Assess for MRM
|
||||
</button>
|
||||
} @else if (!h.restorations?.length) {
|
||||
<div class="sub">No restoration on this hold.</div>
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="i4c-card">
|
||||
<div class="i4c-card__head">
|
||||
<fa-icon icon="file-csv"></fa-icon> Bank statement (GRM / MRM ask for it)
|
||||
</div>
|
||||
<div class="i4c-card__body">
|
||||
<div class="i4c-form">
|
||||
<mat-form-field appearance="outline">
|
||||
<mat-label>From (default 90 days back)</mat-label>
|
||||
<input matInput type="date" [(ngModel)]="statement.fromDate" />
|
||||
</mat-form-field>
|
||||
<mat-form-field appearance="outline">
|
||||
<mat-label>To (default today)</mat-label>
|
||||
<input matInput type="date" [(ngModel)]="statement.toDate" />
|
||||
</mat-form-field>
|
||||
</div>
|
||||
<button mat-stroked-button (click)="downloadStatement()">
|
||||
<fa-icon icon="download"></fa-icon> Download statement (CSV)
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="i4c-card">
|
||||
<div class="i4c-card__head">
|
||||
<fa-icon icon="paperclip"></fa-icon> Documents on this hold (IO / court orders...)
|
||||
</div>
|
||||
<div class="i4c-card__body">
|
||||
<mifosx-i4c-documents
|
||||
ownerType="HOLD"
|
||||
[ownerId]="h.id"
|
||||
[suggested]="['IO_ORDER', 'COURT_ORDER', 'OTHER']"
|
||||
[documents]="h.documents || null"
|
||||
(changed)="select(h.id)"
|
||||
></mifosx-i4c-documents>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
|
||||
<!-- TAB 5: REQUEST AUDIT HISTORY -->
|
||||
@if (modalTab === 'history') {
|
||||
<div class="i4c-card">
|
||||
<div class="i4c-card__head"><fa-icon icon="history"></fa-icon> Requests Audit History on this Hold</div>
|
||||
<div class="i4c-card__body i4c-table-wrap">
|
||||
<table class="i4c-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Action</th>
|
||||
<th>Reason / Order</th>
|
||||
<th>Status</th>
|
||||
<th>Maker Details</th>
|
||||
<th>Checker Details</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
@for (a of h.actions; track a.id) {
|
||||
<tr>
|
||||
<td>
|
||||
<b>{{ a.action }}</b>
|
||||
</td>
|
||||
<td>
|
||||
{{ reasonLabel(a.reason_type) }}
|
||||
<div class="sub">{{ a.order_reference }} {{ a.authority ? '- ' + a.authority : '' }}</div>
|
||||
</td>
|
||||
<td>
|
||||
<span class="i4c-badge">{{ a.status }}</span>
|
||||
@if (a.rejection_reason || a.failure_message) {
|
||||
<div class="sub err-text">{{ a.rejection_reason || a.failure_message }}</div>
|
||||
}
|
||||
</td>
|
||||
<td>
|
||||
{{ a.maker_name }}
|
||||
<div class="sub">{{ a.maker_at | date: 'dd MMM, HH:mm' }}</div>
|
||||
</td>
|
||||
<td>
|
||||
{{ a.checker_name || '-' }}
|
||||
@if (a.checker_at) {
|
||||
<div class="sub">{{ a.checker_at | date: 'dd MMM, HH:mm' }}</div>
|
||||
}
|
||||
</td>
|
||||
</tr>
|
||||
}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
|
||||
<!-- Modal Footer -->
|
||||
<div class="i4c-modal-footer">
|
||||
<button class="footer-btn-close" (click)="close()">Close</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
@@ -0,0 +1,236 @@
|
||||
/**
|
||||
* Copyright since 2025 Mifos Initiative
|
||||
*
|
||||
* This Source Code Form is subject to the terms of the Mozilla Public
|
||||
* License, v. 2.0. If a copy of the MPL was not distributed with this
|
||||
* file, You can obtain one at http://mozilla.org/MPL/2.0/.
|
||||
*/
|
||||
|
||||
@use '../i4c' as *;
|
||||
|
||||
@include i4c-skin;
|
||||
|
||||
.search-input {
|
||||
width: 240px;
|
||||
}
|
||||
|
||||
.search-btn {
|
||||
height: 38px;
|
||||
border-radius: 8px;
|
||||
font-weight: 600;
|
||||
font-size: 11.5px;
|
||||
}
|
||||
|
||||
.count-tag {
|
||||
font-size: 11px;
|
||||
font-weight: 700;
|
||||
color: #64748b;
|
||||
background: #f1f5f9;
|
||||
padding: 2px 7px;
|
||||
border-radius: 9999px;
|
||||
border: 1px solid #e2e8f0;
|
||||
}
|
||||
|
||||
.head-hint {
|
||||
margin-left: auto;
|
||||
font-size: 11px;
|
||||
color: #94a3b8;
|
||||
font-weight: normal;
|
||||
}
|
||||
|
||||
.acc-cell {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
|
||||
b {
|
||||
color: #0f172a;
|
||||
font-size: 12px;
|
||||
}
|
||||
}
|
||||
|
||||
.type-tag {
|
||||
padding: 1px 6px;
|
||||
border-radius: 4px;
|
||||
background: #f1f5f9;
|
||||
font-size: 10px;
|
||||
font-weight: 700;
|
||||
color: #64748b;
|
||||
}
|
||||
|
||||
.client-name {
|
||||
font-weight: 600;
|
||||
color: #1e293b;
|
||||
}
|
||||
|
||||
.ack-cell {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
|
||||
.ack-no {
|
||||
font-weight: 600;
|
||||
color: #334155;
|
||||
font-size: 12px;
|
||||
}
|
||||
}
|
||||
|
||||
.short-pill {
|
||||
display: inline-block;
|
||||
padding: 2px 6px;
|
||||
border-radius: 6px;
|
||||
background: #fef2f2;
|
||||
color: #dc2626;
|
||||
font-weight: 700;
|
||||
font-size: 11px;
|
||||
}
|
||||
|
||||
.intimation-ok {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 5px;
|
||||
color: #059669;
|
||||
font-weight: 600;
|
||||
font-size: 12px;
|
||||
|
||||
fa-icon {
|
||||
font-size: 13px;
|
||||
}
|
||||
}
|
||||
|
||||
.review-date {
|
||||
font-size: 12px;
|
||||
color: #475569;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.due-alert {
|
||||
color: #d97706;
|
||||
font-weight: 700;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
}
|
||||
|
||||
.modal-title-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.sub {
|
||||
font-size: 11px;
|
||||
color: #64748b;
|
||||
margin-top: 2px;
|
||||
}
|
||||
|
||||
.err-text {
|
||||
color: #dc2626;
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.text-accent {
|
||||
color: #2563eb;
|
||||
}
|
||||
|
||||
.amount-held-text {
|
||||
color: #0f172a;
|
||||
font-size: 13.5px;
|
||||
}
|
||||
|
||||
.text-muted {
|
||||
color: #94a3b8;
|
||||
}
|
||||
|
||||
.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;
|
||||
}
|
||||
}
|
||||
|
||||
.intim-status {
|
||||
display: flex;
|
||||
gap: 12px;
|
||||
align-items: center;
|
||||
padding: 12px 16px;
|
||||
border-radius: 10px;
|
||||
margin-bottom: 12px;
|
||||
font-size: 13px;
|
||||
line-height: 1.5;
|
||||
border: 1px solid;
|
||||
|
||||
> div {
|
||||
flex: 1 1 auto;
|
||||
}
|
||||
|
||||
&--done {
|
||||
border-color: #bbf7d0;
|
||||
background: #f0fdf4;
|
||||
color: #166534;
|
||||
}
|
||||
|
||||
&--auto {
|
||||
border-color: #bfdbfe;
|
||||
background: #eff6ff;
|
||||
color: #1e3a8a;
|
||||
}
|
||||
|
||||
&--manual {
|
||||
border-color: #fde68a;
|
||||
background: #fffbeb;
|
||||
color: #92400e;
|
||||
}
|
||||
}
|
||||
|
||||
.wrap {
|
||||
white-space: normal;
|
||||
overflow-wrap: anywhere;
|
||||
max-width: 320px;
|
||||
}
|
||||
|
||||
.cases-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
gap: 14px;
|
||||
}
|
||||
|
||||
@media (width <= 900px) {
|
||||
.cases-grid {
|
||||
grid-template-columns: minmax(0, 1fr);
|
||||
}
|
||||
}
|
||||
|
||||
.case-row {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
padding: 8px 10px;
|
||||
margin-bottom: 8px;
|
||||
border: 1px solid #e2e8f0;
|
||||
border-radius: 8px;
|
||||
cursor: pointer;
|
||||
|
||||
&:hover,
|
||||
&:focus-visible {
|
||||
border-color: #2563eb;
|
||||
outline: none;
|
||||
}
|
||||
|
||||
.sub {
|
||||
font-size: 11px;
|
||||
color: #64748b;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,455 @@
|
||||
/**
|
||||
* 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, Router, 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 { MatCheckboxModule } from '@angular/material/checkbox';
|
||||
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, HoldActionRequest, I4cHold, I4cService, i4cErrorMessage, saveBlob } from '../i4c.service';
|
||||
import { I4cDocumentsComponent } from '../i4c-documents/i4c-documents.component';
|
||||
import { MRM_BASES } from '../i4c-restorations/i4c-restorations.component';
|
||||
import { ALL_DATES, I4cDateRange, I4cDateRangeComponent } from '../i4c-date-range/i4c-date-range.component';
|
||||
|
||||
/**
|
||||
* The register of every hold placed for I4C. Placing is automatic; from here an officer asks for a release or a
|
||||
* debit freeze/unfreeze (approved by someone else on the Approvals page), and records that the customer was told
|
||||
* about the hold.
|
||||
*/
|
||||
@Component({
|
||||
selector: 'mifosx-i4c-holds',
|
||||
templateUrl: './i4c-holds.component.html',
|
||||
styleUrls: ['./i4c-holds.component.scss'],
|
||||
imports: [
|
||||
DatePipe,
|
||||
DecimalPipe,
|
||||
FormsModule,
|
||||
RouterLink,
|
||||
MatButtonModule,
|
||||
MatFormFieldModule,
|
||||
MatInputModule,
|
||||
MatSelectModule,
|
||||
MatCheckboxModule,
|
||||
MatProgressBarModule,
|
||||
MatPaginatorModule,
|
||||
I4cDateRangeComponent,
|
||||
I4cDocumentsComponent,
|
||||
FaIconComponent
|
||||
]
|
||||
})
|
||||
export class I4cHoldsComponent implements OnInit {
|
||||
private i4c = inject(I4cService);
|
||||
private route = inject(ActivatedRoute);
|
||||
private router = inject(Router);
|
||||
private snackBar = inject(MatSnackBar);
|
||||
|
||||
readonly views = [
|
||||
{ value: 'ACTIVE', label: 'Active' },
|
||||
{ value: 'PROSPECTIVE', label: 'Prospective (still short)' },
|
||||
{ value: 'INTIMATION_DUE', label: 'Customer not yet told' },
|
||||
{ value: 'REVIEW_DUE', label: '90-day review due' },
|
||||
{ value: 'NOT_HOLDABLE', label: 'Not holdable' },
|
||||
{ value: 'RELEASED', label: 'Released' },
|
||||
{ value: '', label: 'All' }
|
||||
];
|
||||
readonly reasonTypes = HOLD_REASON_TYPES;
|
||||
/** Ways an officer can tell the customer and record it by hand; phone call comes first. */
|
||||
readonly intimationModes = [
|
||||
'PHONE',
|
||||
'LETTER',
|
||||
'IN_PERSON',
|
||||
'SMS',
|
||||
'EMAIL'
|
||||
];
|
||||
|
||||
view = 'ACTIVE';
|
||||
search = '';
|
||||
accountType = '';
|
||||
intimationFilter = '';
|
||||
layer = '';
|
||||
frozenOnly = false;
|
||||
dates: I4cDateRange = { ...ALL_DATES };
|
||||
total = 0;
|
||||
pageIndex = 0;
|
||||
pageSize = 20;
|
||||
readonly pageSizes = [
|
||||
10,
|
||||
20,
|
||||
50,
|
||||
100
|
||||
];
|
||||
rows: I4cHold[] = [];
|
||||
selected: I4cHold | null = null;
|
||||
loading = false;
|
||||
busy = false;
|
||||
error = '';
|
||||
modalTab: 'overview' | 'txns' | 'intimation' | 'action' | 'cases' | 'history' = 'overview';
|
||||
readonly mrmBases = MRM_BASES;
|
||||
grievanceForm = { customerRequest: 'REMOVE_HOLD', customerReason: '' };
|
||||
assessForm = { basis: 'ATTRIBUTION', restorableAmount: null as number | null, remarks: '' };
|
||||
statement = { fromDate: '', toDate: '' };
|
||||
copiedKey = '';
|
||||
|
||||
action: HoldActionRequest = this.emptyAction();
|
||||
intimation = { mode: 'PHONE', reference: '' };
|
||||
|
||||
ngOnInit(): void {
|
||||
const view = this.route.snapshot.queryParamMap.get('view');
|
||||
if (view !== null) {
|
||||
this.view = view;
|
||||
}
|
||||
this.load();
|
||||
}
|
||||
|
||||
setView(v: string): void {
|
||||
this.view = v;
|
||||
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.accountType ||
|
||||
this.intimationFilter ||
|
||||
this.layer ||
|
||||
this.frozenOnly ||
|
||||
this.dates.preset !== 'ALL'
|
||||
);
|
||||
}
|
||||
|
||||
/** Clears the extra filters; the view chip (Active, Released, ...) stays as chosen. */
|
||||
clearFilters(): void {
|
||||
this.search = '';
|
||||
this.accountType = '';
|
||||
this.intimationFilter = '';
|
||||
this.layer = '';
|
||||
this.frozenOnly = false;
|
||||
this.dates = { ...ALL_DATES };
|
||||
this.load(true);
|
||||
}
|
||||
|
||||
/** 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
|
||||
.holds({
|
||||
view: this.view,
|
||||
search: this.search.trim(),
|
||||
accountType: this.accountType,
|
||||
intimation: this.intimationFilter,
|
||||
layer: this.layer,
|
||||
frozen: this.frozenOnly || null,
|
||||
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;
|
||||
if (this.selected) {
|
||||
this.select(this.selected.id);
|
||||
}
|
||||
},
|
||||
error: (e) => {
|
||||
this.error = i4cErrorMessage(e);
|
||||
this.loading = false;
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
select(id: number): void {
|
||||
this.i4c.hold(id).subscribe({
|
||||
next: (hold) => {
|
||||
this.selected = hold;
|
||||
this.action = this.emptyAction();
|
||||
this.intimation = { mode: 'PHONE', reference: '' };
|
||||
this.modalTab = 'overview';
|
||||
},
|
||||
error: (e) => this.snackBar.open(i4cErrorMessage(e), 'Close', { duration: 6000 })
|
||||
});
|
||||
}
|
||||
|
||||
copyText(text: string, key: string, event?: Event): void {
|
||||
if (event) {
|
||||
event.stopPropagation();
|
||||
}
|
||||
if (!text) {
|
||||
return;
|
||||
}
|
||||
navigator.clipboard.writeText(text);
|
||||
this.copiedKey = key;
|
||||
this.snackBar.open(`Copied: ${text}`, 'OK', { duration: 2500 });
|
||||
setTimeout(() => {
|
||||
if (this.copiedKey === key) {
|
||||
this.copiedKey = '';
|
||||
}
|
||||
}, 2000);
|
||||
}
|
||||
|
||||
close(): void {
|
||||
this.selected = null;
|
||||
}
|
||||
|
||||
/** What a maker may ask for on this hold, given its state. */
|
||||
get allowedActions(): { value: string; label: string }[] {
|
||||
const h = this.selected;
|
||||
if (!h || h.status === 'RELEASED') {
|
||||
return [];
|
||||
}
|
||||
const list = [{ value: 'RELEASE', label: 'Release hold' }];
|
||||
if (h.account_type === 'SAVINGS') {
|
||||
list.push(
|
||||
h.debit_frozen
|
||||
? { value: 'UNFREEZE', label: 'Unfreeze debits, hold continues' }
|
||||
: { value: 'FREEZE', label: 'Freeze all debits (on explicit LEA / court direction only)' }
|
||||
);
|
||||
}
|
||||
if (h.debit_frozen) {
|
||||
list.push({ value: 'UNFREEZE_AND_RELEASE', label: 'Unfreeze and remove the hold' });
|
||||
}
|
||||
return list;
|
||||
}
|
||||
|
||||
get orderRequired(): boolean {
|
||||
return (
|
||||
(this.action.action === 'RELEASE' || this.action.action === 'UNFREEZE_AND_RELEASE') &&
|
||||
this.action.reasonType !== 'REVIEW_90_DAYS'
|
||||
);
|
||||
}
|
||||
|
||||
/** What stays on hold after a partial release; null when the whole hold is released. */
|
||||
get remainingAfterRelease(): number | null {
|
||||
const h = this.selected;
|
||||
const amount = Number(this.action.amount);
|
||||
if (!h || this.action.action !== 'RELEASE' || !amount) {
|
||||
return null;
|
||||
}
|
||||
return Math.max(0, Number(h.amount_held) - amount);
|
||||
}
|
||||
|
||||
get openGrievance(): any {
|
||||
return (this.selected?.grievances || []).find((g: any) => g.stage !== 'CLOSED');
|
||||
}
|
||||
|
||||
get openRestoration(): any {
|
||||
return (this.selected?.restorations || []).find((r: any) => r.status !== 'CLOSED');
|
||||
}
|
||||
|
||||
raiseGrievance(): void {
|
||||
if (!this.selected) {
|
||||
return;
|
||||
}
|
||||
this.busy = true;
|
||||
this.i4c.createGrievance(this.selected.id, this.grievanceForm).subscribe({
|
||||
next: (r) => {
|
||||
this.busy = false;
|
||||
this.router.navigate(['/cfcfrms/i4c/grievances'], { queryParams: { open: r.resourceId } });
|
||||
},
|
||||
error: (e) => {
|
||||
this.busy = false;
|
||||
this.snackBar.open(i4cErrorMessage(e), 'Close', { duration: 7000 });
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
assessForMrm(): void {
|
||||
if (!this.selected) {
|
||||
return;
|
||||
}
|
||||
const possible = this.assessForm.basis === 'ATTRIBUTION' || this.assessForm.basis === 'PRO_RATA';
|
||||
this.busy = true;
|
||||
this.i4c
|
||||
.assessRestoration(this.selected.id, {
|
||||
basis: this.assessForm.basis,
|
||||
restorableAmount: possible ? this.assessForm.restorableAmount : null,
|
||||
remarks: this.assessForm.remarks
|
||||
})
|
||||
.subscribe({
|
||||
next: (r) => {
|
||||
this.busy = false;
|
||||
this.router.navigate(['/cfcfrms/i4c/restorations'], { queryParams: { open: r.resourceId } });
|
||||
},
|
||||
error: (e) => {
|
||||
this.busy = false;
|
||||
this.snackBar.open(i4cErrorMessage(e), 'Close', { duration: 7000 });
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
goTo(page: 'grievances' | 'restorations', id: number): void {
|
||||
this.router.navigate(['/cfcfrms/i4c/' + page], { queryParams: { open: id } });
|
||||
}
|
||||
|
||||
downloadStatement(): void {
|
||||
if (!this.selected) {
|
||||
return;
|
||||
}
|
||||
const h = this.selected;
|
||||
this.i4c.statementCsv(h.id, this.statement.fromDate || undefined, this.statement.toDate || undefined).subscribe({
|
||||
next: (blob) => saveBlob(blob, 'statement-' + h.account_no + '.csv'),
|
||||
error: (e) => this.snackBar.open(i4cErrorMessage(e), 'Close', { duration: 6000 })
|
||||
});
|
||||
}
|
||||
|
||||
get hasPendingAction(): boolean {
|
||||
return (this.selected?.actions || []).some((a: any) => a.status === 'PENDING');
|
||||
}
|
||||
|
||||
submitAction(): void {
|
||||
if (!this.selected) {
|
||||
return;
|
||||
}
|
||||
if (!this.action.reasonType) {
|
||||
this.snackBar.open('Choose the reason.', 'Close', { duration: 4000 });
|
||||
return;
|
||||
}
|
||||
if (this.orderRequired && !this.action.orderReference?.trim()) {
|
||||
this.snackBar.open('An order / reference number is required to release a hold.', 'Close', { duration: 5000 });
|
||||
return;
|
||||
}
|
||||
this.busy = true;
|
||||
this.i4c.requestHoldAction(this.selected.id, this.action).subscribe({
|
||||
next: () => {
|
||||
this.busy = false;
|
||||
this.snackBar.open('Request raised. Another officer must approve it on the Approvals page.', 'OK', {
|
||||
duration: 5000
|
||||
});
|
||||
this.load();
|
||||
},
|
||||
error: (e) => {
|
||||
this.busy = false;
|
||||
this.snackBar.open(i4cErrorMessage(e), 'Close', { duration: 6000 });
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
submitIntimation(): void {
|
||||
if (!this.selected) {
|
||||
return;
|
||||
}
|
||||
this.busy = true;
|
||||
this.i4c.recordIntimation(this.selected.id, this.intimation.mode, this.intimation.reference).subscribe({
|
||||
next: () => {
|
||||
this.busy = false;
|
||||
this.snackBar.open('Customer intimation recorded.', 'OK', { duration: 3000 });
|
||||
this.load();
|
||||
},
|
||||
error: (e) => {
|
||||
this.busy = false;
|
||||
this.snackBar.open(i4cErrorMessage(e), 'Close', { duration: 6000 });
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/** Send the hold notice now on the switched-on automatic channels (Email today). */
|
||||
sendIntimationNow(): void {
|
||||
if (!this.selected) {
|
||||
return;
|
||||
}
|
||||
this.busy = true;
|
||||
this.i4c.sendIntimation(this.selected.id).subscribe({
|
||||
next: (r) => {
|
||||
this.busy = false;
|
||||
this.snackBar.open(r.message, 'OK', { duration: 6000 });
|
||||
this.load();
|
||||
},
|
||||
error: (e) => {
|
||||
this.busy = false;
|
||||
this.snackBar.open(i4cErrorMessage(e), 'Close', { duration: 6000 });
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
channelLabel(channel: string): string {
|
||||
return (
|
||||
(
|
||||
{
|
||||
EMAIL: 'Email',
|
||||
SMS: 'SMS',
|
||||
WHATSAPP: 'WhatsApp',
|
||||
PHONE: 'Phone call',
|
||||
LETTER: 'Letter',
|
||||
IN_PERSON: 'In person'
|
||||
} as Record<string, string>
|
||||
)[channel] ?? channel
|
||||
);
|
||||
}
|
||||
|
||||
autoChannelsText(hold: I4cHold): string {
|
||||
return (hold.automaticChannels || []).map((c) => this.channelLabel(c)).join(', then ');
|
||||
}
|
||||
|
||||
isOverdue(hold: I4cHold): boolean {
|
||||
return (
|
||||
hold.status === 'ACTIVE' &&
|
||||
!hold.customer_intimated_at &&
|
||||
!!hold.intimation_due_by &&
|
||||
new Date(hold.intimation_due_by).getTime() < Date.now()
|
||||
);
|
||||
}
|
||||
|
||||
isReviewDue(hold: I4cHold): boolean {
|
||||
if (hold.status !== 'ACTIVE' || !hold.review_due_on) {
|
||||
return false;
|
||||
}
|
||||
const due = new Date(hold.review_due_on + 'T00:00:00').getTime();
|
||||
return due - Date.now() <= 15 * 24 * 3600 * 1000;
|
||||
}
|
||||
|
||||
statusClass(hold: I4cHold): string {
|
||||
if (hold.status === 'RELEASED') {
|
||||
return 'i4c-badge--blue';
|
||||
}
|
||||
if (hold.status === 'NOT_HOLDABLE') {
|
||||
return 'i4c-badge--red';
|
||||
}
|
||||
return hold.shortfall > 0 ? 'i4c-badge--amber' : 'i4c-badge--green';
|
||||
}
|
||||
|
||||
reasonLabel(value: string): string {
|
||||
return this.reasonTypes.find((r) => r.value === value)?.label || value;
|
||||
}
|
||||
|
||||
private emptyAction(): HoldActionRequest {
|
||||
return {
|
||||
action: 'RELEASE',
|
||||
amount: null,
|
||||
reasonType: '',
|
||||
orderReference: '',
|
||||
orderDate: '',
|
||||
authority: '',
|
||||
remarks: ''
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,148 @@
|
||||
<!--
|
||||
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">
|
||||
@if (loading) {
|
||||
<mat-progress-bar mode="indeterminate"></mat-progress-bar>
|
||||
}
|
||||
@if (error) {
|
||||
<div class="i4c-error">{{ error }}</div>
|
||||
}
|
||||
|
||||
<div class="summary" [class.summary--manual]="!enabledChannels.length">
|
||||
<fa-icon [icon]="enabledChannels.length ? 'check' : 'exclamation-triangle'" class="summary__icon"></fa-icon>
|
||||
<div>
|
||||
@if (enabledChannels.length) {
|
||||
<div class="summary__title">
|
||||
Customers are told automatically by
|
||||
@for (c of enabledChannels; track c.channel; let last = $last) {
|
||||
<b>{{ info(c.channel).label }}</b
|
||||
>{{ last ? '' : ', then ' }}
|
||||
}
|
||||
</div>
|
||||
<div class="summary__text">
|
||||
As soon as money is held, the channels are tried in this order. The first that reaches the customer records
|
||||
the intimation on the hold. If none does (no email on file, gateway down), the hold stays "customer not yet
|
||||
told" for an officer to call and record by hand.
|
||||
</div>
|
||||
} @else {
|
||||
<div class="summary__title">No automatic channel is switched on</div>
|
||||
<div class="summary__text">
|
||||
Every hold waits for an officer to contact the customer (phone, letter, in person) and record it on the hold's
|
||||
Record Intimation tab.
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@for (c of channels; track c.channel) {
|
||||
<div class="i4c-card channel" [class.channel--off]="!c.enabled">
|
||||
<div class="i4c-card__head">
|
||||
<fa-icon [icon]="info(c.channel).icon"></fa-icon>
|
||||
<span>{{ info(c.channel).label }}</span>
|
||||
<span class="i4c-badge" [class.i4c-badge--green]="c.enabled">{{ c.enabled ? 'ON' : 'OFF' }}</span>
|
||||
<span class="i4c-toolbar__spacer"></span>
|
||||
<mat-slide-toggle
|
||||
[id]="'toggle-' + c.channel"
|
||||
[(ngModel)]="c.enabled"
|
||||
(change)="toggle(c)"
|
||||
[disabled]="c.saving"
|
||||
[attr.aria-label]="'Send automatically by ' + info(c.channel).label"
|
||||
>
|
||||
Send automatically
|
||||
</mat-slide-toggle>
|
||||
</div>
|
||||
<div class="i4c-card__body">
|
||||
<div class="gateway">Sent through: {{ info(c.channel).gateway }}</div>
|
||||
|
||||
<div class="grid">
|
||||
<div class="edit">
|
||||
<div class="row">
|
||||
<mat-form-field appearance="outline" class="priority">
|
||||
<mat-label>Order</mat-label>
|
||||
<input
|
||||
matInput
|
||||
type="number"
|
||||
min="1"
|
||||
max="99"
|
||||
[id]="'priority-' + c.channel"
|
||||
[(ngModel)]="c.priority"
|
||||
(ngModelChange)="c.dirty = true"
|
||||
/>
|
||||
</mat-form-field>
|
||||
@if (c.channel === 'EMAIL') {
|
||||
<mat-form-field appearance="outline" class="subject">
|
||||
<mat-label>Subject</mat-label>
|
||||
<input
|
||||
matInput
|
||||
maxlength="300"
|
||||
[id]="'subject-' + c.channel"
|
||||
[(ngModel)]="c.subject"
|
||||
(ngModelChange)="c.dirty = true"
|
||||
/>
|
||||
</mat-form-field>
|
||||
}
|
||||
</div>
|
||||
<mat-form-field appearance="outline" class="full">
|
||||
<mat-label>Message</mat-label>
|
||||
<textarea
|
||||
matInput
|
||||
rows="9"
|
||||
[id]="'body-' + c.channel"
|
||||
[(ngModel)]="c.body"
|
||||
(ngModelChange)="c.dirty = true"
|
||||
></textarea>
|
||||
</mat-form-field>
|
||||
<div class="chips">
|
||||
<span class="chips__label">Insert:</span>
|
||||
@for (p of placeholders; track p) {
|
||||
<button type="button" class="chip" (click)="insert(c, p)">{{ p }}</button>
|
||||
}
|
||||
</div>
|
||||
<div class="i4c-actions">
|
||||
<button mat-flat-button color="primary" (click)="saveText(c)" [disabled]="!c.dirty || c.saving">
|
||||
<fa-icon icon="check"></fa-icon> Save {{ info(c.channel).label }} message
|
||||
</button>
|
||||
@if (c.updated_at) {
|
||||
<span class="muted"
|
||||
>Last changed {{ c.updated_at | date: 'dd MMM yyyy, HH:mm' }} by {{ c.updated_by }}</span
|
||||
>
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="preview">
|
||||
<div class="preview__label">Preview (sample values)</div>
|
||||
@if (c.channel === 'EMAIL') {
|
||||
<div class="preview__subject">{{ preview(c.subject) }}</div>
|
||||
}
|
||||
<div class="preview__body">{{ preview(c.body) }}</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@if (c.channel === 'EMAIL') {
|
||||
<div class="test">
|
||||
<mat-form-field appearance="outline" class="test__field" subscriptSizing="dynamic">
|
||||
<mat-label>Send a test to</mat-label>
|
||||
<input matInput type="email" id="test-email-to" [(ngModel)]="testTo" placeholder="you@bank.in" />
|
||||
</mat-form-field>
|
||||
<button mat-stroked-button (click)="sendTest()" [disabled]="testing">
|
||||
<fa-icon icon="paper-plane"></fa-icon> Send test email
|
||||
</button>
|
||||
<span class="muted">Uses the saved message with sample values; the subject starts with [TEST].</span>
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
|
||||
<div class="i4c-note">
|
||||
Phone, letter and in-person intimation are always recorded by an officer on the hold's Record Intimation tab; the
|
||||
CBS cannot send them itself. Every notice, automatic or recorded, is listed in the hold's intimation history.
|
||||
</div>
|
||||
</div>
|
||||
@@ -0,0 +1,172 @@
|
||||
/**
|
||||
* Copyright since 2025 Mifos Initiative
|
||||
*
|
||||
* This Source Code Form is subject to the terms of the Mozilla Public
|
||||
* License, v. 2.0. If a copy of the MPL was not distributed with this
|
||||
* file, You can obtain one at http://mozilla.org/MPL/2.0/.
|
||||
*/
|
||||
|
||||
@use '../i4c' as *;
|
||||
|
||||
@include i4c-skin;
|
||||
|
||||
.summary {
|
||||
display: flex;
|
||||
gap: 12px;
|
||||
align-items: flex-start;
|
||||
padding: 12px 16px;
|
||||
border-radius: 10px;
|
||||
border: 1px solid #bbf7d0;
|
||||
background: #f0fdf4;
|
||||
color: #166534;
|
||||
|
||||
&--manual {
|
||||
border-color: #fde68a;
|
||||
background: #fffbeb;
|
||||
color: #92400e;
|
||||
}
|
||||
|
||||
&__icon {
|
||||
margin-top: 2px;
|
||||
}
|
||||
|
||||
&__title {
|
||||
font-weight: 600;
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
&__text {
|
||||
font-size: 13px;
|
||||
margin-top: 2px;
|
||||
line-height: 1.5;
|
||||
}
|
||||
}
|
||||
|
||||
.channel--off {
|
||||
opacity: 0.85;
|
||||
}
|
||||
|
||||
.gateway {
|
||||
font-size: 12px;
|
||||
color: #64748b;
|
||||
margin-bottom: 10px;
|
||||
}
|
||||
|
||||
.grid {
|
||||
display: grid;
|
||||
grid-template-columns: minmax(0, 3fr) minmax(0, 2fr);
|
||||
gap: 16px;
|
||||
}
|
||||
|
||||
@media (width <= 1024px) {
|
||||
.grid {
|
||||
grid-template-columns: minmax(0, 1fr);
|
||||
}
|
||||
}
|
||||
|
||||
.edit {
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.row {
|
||||
display: flex;
|
||||
gap: 12px;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.priority {
|
||||
width: 110px;
|
||||
}
|
||||
|
||||
.subject {
|
||||
flex: 1 1 320px;
|
||||
}
|
||||
|
||||
.full {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.chips {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 6px;
|
||||
align-items: center;
|
||||
margin-bottom: 12px;
|
||||
|
||||
&__label {
|
||||
font-size: 12px;
|
||||
color: #64748b;
|
||||
}
|
||||
}
|
||||
|
||||
.chip {
|
||||
border: 1px solid #cbd5e1;
|
||||
background: #f8fafc;
|
||||
border-radius: 999px;
|
||||
padding: 2px 10px;
|
||||
font-size: 11px;
|
||||
font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace;
|
||||
color: #334155;
|
||||
cursor: pointer;
|
||||
|
||||
&:hover {
|
||||
border-color: #2563eb;
|
||||
color: #1d4ed8;
|
||||
}
|
||||
|
||||
&:focus-visible {
|
||||
outline: 2px solid #2563eb;
|
||||
outline-offset: 1px;
|
||||
}
|
||||
}
|
||||
|
||||
.preview {
|
||||
min-width: 0;
|
||||
border: 1px solid #e2e8f0;
|
||||
border-radius: 10px;
|
||||
background: #f8fafc;
|
||||
padding: 12px 14px;
|
||||
align-self: start;
|
||||
|
||||
&__label {
|
||||
font-size: 11px;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.05em;
|
||||
color: #94a3b8;
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
|
||||
&__subject {
|
||||
font-weight: 600;
|
||||
color: #0f172a;
|
||||
font-size: 13px;
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
|
||||
&__body {
|
||||
white-space: pre-wrap;
|
||||
font-size: 13px;
|
||||
line-height: 1.55;
|
||||
color: #1e293b;
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
}
|
||||
|
||||
.test {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
margin-top: 14px;
|
||||
padding-top: 14px;
|
||||
border-top: 1px solid #f1f5f9;
|
||||
|
||||
&__field {
|
||||
width: 280px;
|
||||
}
|
||||
}
|
||||
|
||||
.muted {
|
||||
font-size: 12px;
|
||||
color: #64748b;
|
||||
}
|
||||
@@ -0,0 +1,185 @@
|
||||
/**
|
||||
* 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 } 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 { MatSlideToggleModule } from '@angular/material/slide-toggle';
|
||||
import { MatProgressBarModule } from '@angular/material/progress-bar';
|
||||
import { MatSnackBar } from '@angular/material/snack-bar';
|
||||
import { FaIconComponent } from '@fortawesome/angular-fontawesome';
|
||||
import { I4cService, IntimationChannel, i4cErrorMessage } from '../i4c.service';
|
||||
|
||||
/** Sample values for the live preview; the server fills the real ones per hold. */
|
||||
const SAMPLE: Record<string, string> = {
|
||||
customerName: 'Anshul Rathore',
|
||||
accountNumber: 'XXXXXXXXXXX0006',
|
||||
amountHeld: '1500.00',
|
||||
disputedAmount: '1500.00',
|
||||
ackNo: '31610201726120',
|
||||
holdDate: '20-10-2026',
|
||||
reviewDate: '18-01-2027',
|
||||
bankName: 'LincPay Demo Co-operative Bank',
|
||||
branchName: 'Head Office',
|
||||
grievanceEmail: 'grievance@lpdb.example.in',
|
||||
customerCare: '1800-200-4570'
|
||||
};
|
||||
|
||||
const CHANNEL_INFO: Record<string, { label: string; icon: string; gateway: string }> = {
|
||||
EMAIL: {
|
||||
label: 'Email',
|
||||
icon: 'envelope',
|
||||
gateway: "CBS mail server (spring.mail settings), to the customer's email on file"
|
||||
},
|
||||
SMS: {
|
||||
label: 'SMS',
|
||||
icon: 'mobile-alt',
|
||||
gateway: "SMS gateway (fineract.notification.sms), to the customer's mobile on file"
|
||||
},
|
||||
WHATSAPP: {
|
||||
label: 'WhatsApp',
|
||||
icon: 'phone',
|
||||
gateway: "WhatsApp gateway (fineract.notification.whatsapp), to the customer's mobile"
|
||||
}
|
||||
};
|
||||
|
||||
interface EditableChannel extends IntimationChannel {
|
||||
dirty?: boolean;
|
||||
saving?: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* Which channels tell the account holder about an I4C hold on their own. Switched-on channels are tried in priority
|
||||
* order as soon as money is held; the first that reaches the customer records the intimation. With none switched on,
|
||||
* or none reaching the customer, the hold waits for an officer to phone or write and record it by hand.
|
||||
*/
|
||||
@Component({
|
||||
selector: 'mifosx-i4c-intimation-settings',
|
||||
templateUrl: './i4c-intimation-settings.component.html',
|
||||
styleUrls: ['./i4c-intimation-settings.component.scss'],
|
||||
imports: [
|
||||
DatePipe,
|
||||
FormsModule,
|
||||
MatButtonModule,
|
||||
MatFormFieldModule,
|
||||
MatInputModule,
|
||||
MatSlideToggleModule,
|
||||
MatProgressBarModule,
|
||||
FaIconComponent
|
||||
]
|
||||
})
|
||||
export class I4cIntimationSettingsComponent implements OnInit {
|
||||
private i4c = inject(I4cService);
|
||||
private snackBar = inject(MatSnackBar);
|
||||
|
||||
channels: EditableChannel[] = [];
|
||||
placeholders: string[] = [];
|
||||
loading = false;
|
||||
error = '';
|
||||
testTo = '';
|
||||
testing = false;
|
||||
|
||||
ngOnInit(): void {
|
||||
this.load();
|
||||
}
|
||||
|
||||
load(): void {
|
||||
this.loading = true;
|
||||
this.error = '';
|
||||
this.i4c.intimationChannels().subscribe({
|
||||
next: (s) => {
|
||||
this.channels = s.channels.map((c) => ({ ...c }));
|
||||
this.placeholders = s.placeholders;
|
||||
this.loading = false;
|
||||
},
|
||||
error: (e) => {
|
||||
this.error = i4cErrorMessage(e);
|
||||
this.loading = false;
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
get enabledChannels(): EditableChannel[] {
|
||||
return this.channels.filter((c) => c.enabled).sort((a, b) => a.priority - b.priority);
|
||||
}
|
||||
|
||||
info(channel: string) {
|
||||
return CHANNEL_INFO[channel] ?? { label: channel, icon: 'bell', gateway: '' };
|
||||
}
|
||||
|
||||
preview(text: string | undefined): string {
|
||||
return (text ?? '').replace(/\{(\w+)\}/g, (match, key) => SAMPLE[key] ?? match);
|
||||
}
|
||||
|
||||
insert(channel: EditableChannel, placeholder: string): void {
|
||||
channel.body = (channel.body ?? '') + placeholder;
|
||||
channel.dirty = true;
|
||||
}
|
||||
|
||||
/** The switch saves at once: it decides whether customers get told automatically. */
|
||||
toggle(channel: EditableChannel): void {
|
||||
this.save(channel, { enabled: channel.enabled });
|
||||
}
|
||||
|
||||
saveText(channel: EditableChannel): void {
|
||||
if (!channel.body?.trim()) {
|
||||
this.snackBar.open('The message text cannot be empty.', 'Close', { duration: 4000 });
|
||||
return;
|
||||
}
|
||||
if (channel.channel === 'EMAIL' && !channel.subject?.trim()) {
|
||||
this.snackBar.open('An email needs a subject.', 'Close', { duration: 4000 });
|
||||
return;
|
||||
}
|
||||
this.save(channel, { priority: channel.priority, subject: channel.subject, body: channel.body });
|
||||
}
|
||||
|
||||
private save(
|
||||
channel: EditableChannel,
|
||||
body: { enabled?: boolean; priority?: number; subject?: string; body?: string }
|
||||
): void {
|
||||
channel.saving = true;
|
||||
this.i4c.updateIntimationChannel(channel.channel, body).subscribe({
|
||||
next: (rows) => {
|
||||
const fresh = rows.find((r) => r.channel === channel.channel);
|
||||
if (fresh) {
|
||||
Object.assign(channel, fresh, { dirty: false });
|
||||
}
|
||||
channel.saving = false;
|
||||
this.snackBar.open(this.info(channel.channel).label + ' settings saved.', 'OK', { duration: 3000 });
|
||||
},
|
||||
error: (e) => {
|
||||
channel.saving = false;
|
||||
this.snackBar.open(i4cErrorMessage(e), 'Close', { duration: 6000 });
|
||||
this.load();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
sendTest(): void {
|
||||
if (!this.testTo.trim()) {
|
||||
this.snackBar.open('Enter an email address for the test.', 'Close', { duration: 4000 });
|
||||
return;
|
||||
}
|
||||
this.testing = true;
|
||||
this.i4c.testIntimationChannel('EMAIL', this.testTo.trim()).subscribe({
|
||||
next: (r) => {
|
||||
this.testing = false;
|
||||
this.snackBar.open(r.outcome === 'SENT' ? 'Test email sent to ' + this.testTo + '.' : r.message, 'OK', {
|
||||
duration: 6000
|
||||
});
|
||||
},
|
||||
error: (e) => {
|
||||
this.testing = false;
|
||||
this.snackBar.open(i4cErrorMessage(e), 'Close', { duration: 6000 });
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,149 @@
|
||||
<!--
|
||||
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">
|
||||
<mat-form-field appearance="outline" subscriptSizing="dynamic" class="search-input">
|
||||
<mat-label>Filter account / customer / branch / ack</mat-label>
|
||||
<input matInput [(ngModel)]="filter" />
|
||||
</mat-form-field>
|
||||
<span class="i4c-toolbar__spacer"></span>
|
||||
<span class="count-tag"
|
||||
>{{ shown.length }} accounts · {{ totals.complaints }} complaints · ₹{{
|
||||
totals.held | number: '1.2-2'
|
||||
}}
|
||||
held</span
|
||||
>
|
||||
<button mat-stroked-button (click)="load()" [disabled]="loading"><fa-icon icon="sync"></fa-icon> Refresh</button>
|
||||
<button mat-flat-button color="primary" (click)="download()">
|
||||
<fa-icon icon="file-csv"></fa-icon> Download for MRM (CSV)
|
||||
</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="university"></fa-icon>
|
||||
<span>MRM: Update Account Balance</span>
|
||||
<span class="head-hint">
|
||||
Every account with an active I4C hold. Enter these balances on MRM → Money Distribution Process (one by one or
|
||||
with MRM's bulk format). Click an account for its pro-rata split.
|
||||
</span>
|
||||
</div>
|
||||
<div class="i4c-table-wrap">
|
||||
<table class="i4c-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Account</th>
|
||||
<th>Holder / Branch</th>
|
||||
<th>Status</th>
|
||||
<th class="num">Ledger balance</th>
|
||||
<th class="num">Available</th>
|
||||
<th class="num">On hold</th>
|
||||
<th class="num">Disputed</th>
|
||||
<th class="num">Complaints</th>
|
||||
<th>Acknowledgement nos.</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
@for (r of shown; track r.account_type + r.account_id) {
|
||||
<tr class="clickable" (click)="pick(r)">
|
||||
<td>
|
||||
<b>{{ r.account_no }}</b>
|
||||
<div class="sub">{{ r.account_type }}{{ r.debit_frozen ? ' · debits frozen' : '' }}</div>
|
||||
</td>
|
||||
<td>
|
||||
{{ r.client_name }}
|
||||
<div class="sub">{{ r.office_name }}</div>
|
||||
</td>
|
||||
<td>{{ r.account_status || '-' }}</td>
|
||||
<td class="num">{{ r.ledger_balance | number: '1.2-2' }}</td>
|
||||
<td class="num">{{ r.available_balance | number: '1.2-2' }}</td>
|
||||
<td class="num">
|
||||
<b>{{ r.held | number: '1.2-2' }}</b>
|
||||
</td>
|
||||
<td class="num">{{ r.disputed | number: '1.2-2' }}</td>
|
||||
<td class="num">{{ r.complaints }}</td>
|
||||
<td class="wrap">{{ r.acknowledgement_nos }}</td>
|
||||
</tr>
|
||||
} @empty {
|
||||
<tr>
|
||||
<td colspan="9" class="i4c-empty">No accounts with active I4C holds.</td>
|
||||
</tr>
|
||||
}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="i4c-card">
|
||||
<div class="i4c-card__head">
|
||||
<fa-icon icon="calculator"></fa-icon>
|
||||
<span>Pro-rata distribution</span>
|
||||
<span class="head-hint">
|
||||
MRM: when money from several complaints is mixed in one account, it is shared in proportion to the disputed
|
||||
amounts. Leave the amount empty to share everything held.
|
||||
</span>
|
||||
</div>
|
||||
<div class="i4c-card__body">
|
||||
<div class="i4c-filter-bar i4c-filter-bar--inset">
|
||||
<mat-form-field appearance="outline" subscriptSizing="dynamic">
|
||||
<mat-label>Account no.</mat-label>
|
||||
<input matInput [(ngModel)]="proRataAccount" (keyup.enter)="calculate()" />
|
||||
</mat-form-field>
|
||||
<mat-form-field appearance="outline" subscriptSizing="dynamic">
|
||||
<mat-label>Amount to share (₹)</mat-label>
|
||||
<input matInput type="number" min="0" [(ngModel)]="proRataAmount" (keyup.enter)="calculate()" />
|
||||
</mat-form-field>
|
||||
<button mat-flat-button color="primary" (click)="calculate()" [disabled]="!proRataAccount.trim()">
|
||||
<fa-icon icon="calculator"></fa-icon> Calculate
|
||||
</button>
|
||||
</div>
|
||||
@if (proRata) {
|
||||
<table class="i4c-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Complaint (ack)</th>
|
||||
<th class="num">Layer</th>
|
||||
<th class="num">Disputed</th>
|
||||
<th class="num">Held for it</th>
|
||||
<th class="num">Share</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
@for (h of proRata.holds; track h.id) {
|
||||
<tr>
|
||||
<td>{{ h.acknowledgement_no }}</td>
|
||||
<td class="num">{{ h.layer }}</td>
|
||||
<td class="num">{{ h.disputed_amount | number: '1.2-2' }}</td>
|
||||
<td class="num">{{ h.amount_held | number: '1.2-2' }}</td>
|
||||
<td class="num">
|
||||
<b>{{ h.share | number: '1.2-2' }}</b>
|
||||
</td>
|
||||
</tr>
|
||||
}
|
||||
</tbody>
|
||||
</table>
|
||||
<div class="sub">
|
||||
₹{{ proRata.amount | number: '1.2-2' }} of ₹{{ proRata.totalHeld | number: '1.2-2' }} held on
|
||||
{{ proRata.accountNo }}, by disputed amount (total ₹{{ proRata.totalDisputed | number: '1.2-2' }}). A share
|
||||
never exceeds what that complaint's hold has; use the figure as the restorable amount of that complaint's
|
||||
restoration.
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -0,0 +1,13 @@
|
||||
/**
|
||||
* 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;
|
||||
@@ -0,0 +1,123 @@
|
||||
/**
|
||||
* 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 { FormsModule } from '@angular/forms';
|
||||
import { MatButtonModule } from '@angular/material/button';
|
||||
import { MatFormFieldModule } from '@angular/material/form-field';
|
||||
import { MatInputModule } from '@angular/material/input';
|
||||
import { MatProgressBarModule } from '@angular/material/progress-bar';
|
||||
import { MatSnackBar } from '@angular/material/snack-bar';
|
||||
import { FaIconComponent } from '@fortawesome/angular-fontawesome';
|
||||
import { I4cService, i4cErrorMessage, saveBlob } from '../i4c.service';
|
||||
|
||||
/**
|
||||
* MRM "Update Account Balance": every account with an active I4C hold and its balances now, for the bank to update on
|
||||
* MRM (one by one or with the bulk sheet), plus the pro-rata calculator for accounts with several complaints.
|
||||
*/
|
||||
@Component({
|
||||
selector: 'mifosx-i4c-mrm-report',
|
||||
templateUrl: './i4c-mrm-report.component.html',
|
||||
styleUrls: ['./i4c-mrm-report.component.scss'],
|
||||
imports: [
|
||||
DecimalPipe,
|
||||
FormsModule,
|
||||
MatButtonModule,
|
||||
MatFormFieldModule,
|
||||
MatInputModule,
|
||||
MatProgressBarModule,
|
||||
FaIconComponent
|
||||
]
|
||||
})
|
||||
export class I4cMrmReportComponent implements OnInit {
|
||||
private i4c = inject(I4cService);
|
||||
private snackBar = inject(MatSnackBar);
|
||||
|
||||
rows: any[] = [];
|
||||
loading = false;
|
||||
error = '';
|
||||
filter = '';
|
||||
proRataAccount = '';
|
||||
proRataAmount: number | null = null;
|
||||
proRata: any = null;
|
||||
|
||||
ngOnInit(): void {
|
||||
this.load();
|
||||
}
|
||||
|
||||
load(): void {
|
||||
this.loading = true;
|
||||
this.error = '';
|
||||
this.i4c.mrmBalanceReport().subscribe({
|
||||
next: (rows) => {
|
||||
this.rows = rows;
|
||||
this.loading = false;
|
||||
},
|
||||
error: (e) => {
|
||||
this.error = i4cErrorMessage(e);
|
||||
this.loading = false;
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
get shown(): any[] {
|
||||
const f = this.filter.trim().toLowerCase();
|
||||
return f
|
||||
? this.rows.filter((r) =>
|
||||
[
|
||||
r.account_no,
|
||||
r.client_name,
|
||||
r.office_name,
|
||||
r.acknowledgement_nos
|
||||
].some((v) =>
|
||||
String(v || '')
|
||||
.toLowerCase()
|
||||
.includes(f)
|
||||
)
|
||||
)
|
||||
: this.rows;
|
||||
}
|
||||
|
||||
get totals(): { held: number; ledger: number; complaints: number } {
|
||||
return this.shown.reduce(
|
||||
(t, r) => ({
|
||||
held: t.held + Number(r.held || 0),
|
||||
ledger: t.ledger + Number(r.ledger_balance || 0),
|
||||
complaints: t.complaints + Number(r.complaints || 0)
|
||||
}),
|
||||
{ held: 0, ledger: 0, complaints: 0 }
|
||||
);
|
||||
}
|
||||
|
||||
download(): void {
|
||||
this.i4c.mrmBalanceReportCsv().subscribe({
|
||||
next: (blob) => saveBlob(blob, 'mrm-balance-report-' + new Date().toISOString().slice(0, 10) + '.csv'),
|
||||
error: (e) => this.snackBar.open(i4cErrorMessage(e), 'Close', { duration: 6000 })
|
||||
});
|
||||
}
|
||||
|
||||
pick(r: any): void {
|
||||
this.proRataAccount = r.account_no;
|
||||
this.proRataAmount = null;
|
||||
this.calculate();
|
||||
}
|
||||
|
||||
calculate(): void {
|
||||
if (!this.proRataAccount.trim()) {
|
||||
return;
|
||||
}
|
||||
this.i4c.proRata(this.proRataAccount.trim(), this.proRataAmount).subscribe({
|
||||
next: (p) => (this.proRata = p),
|
||||
error: (e) => {
|
||||
this.proRata = null;
|
||||
this.snackBar.open(i4cErrorMessage(e), 'Close', { duration: 6000 });
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,497 @@
|
||||
<!--
|
||||
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">
|
||||
<!-- View Switcher & Toolbar -->
|
||||
<div class="i4c-toolbar">
|
||||
<div class="i4c-filter-chips">
|
||||
<button class="filter-chip" [class.filter-chip--active]="activeSection === 'all'" (click)="activeSection = 'all'">
|
||||
<fa-icon icon="list-ul"></fa-icon> <span>All (Outbox & Log)</span>
|
||||
</button>
|
||||
<button
|
||||
class="filter-chip"
|
||||
[class.filter-chip--active]="activeSection === 'outbox'"
|
||||
(click)="activeSection = 'outbox'"
|
||||
>
|
||||
<fa-icon icon="paper-plane"></fa-icon> <span>Responses to I4C ({{ outboxTotal }})</span>
|
||||
</button>
|
||||
<button class="filter-chip" [class.filter-chip--active]="activeSection === 'log'" (click)="activeSection = 'log'">
|
||||
<fa-icon icon="history"></fa-icon> <span>API Audit Log ({{ logTotal }})</span>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<span class="i4c-toolbar__spacer"></span>
|
||||
|
||||
<button mat-flat-button color="primary" class="refresh-btn" (click)="loadOutbox(); loadLog()" [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>
|
||||
}
|
||||
|
||||
<!-- SECTION 1: RESPONSES TO I4C (OUTBOX) -->
|
||||
@if (activeSection === 'all' || activeSection === 'outbox') {
|
||||
<div class="i4c-card">
|
||||
<div class="i4c-card__head">
|
||||
<fa-icon icon="paper-plane"></fa-icon>
|
||||
<span>Outbox Queue: Delivery to I4C</span>
|
||||
<span class="head-hint">Automated HTTP callbacks carrying bank findings to I4C</span>
|
||||
|
||||
<span class="i4c-toolbar__spacer"></span>
|
||||
|
||||
<!-- Status Filter Chips -->
|
||||
<div class="mini-chips">
|
||||
@for (s of outboxStatuses; track s) {
|
||||
<button class="mini-chip" [class.mini-chip--active]="outboxStatus === s" (click)="setOutboxStatus(s)">
|
||||
{{ s || 'All' }}
|
||||
</button>
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="i4c-filter-bar i4c-filter-bar--inset">
|
||||
<mat-form-field appearance="outline" subscriptSizing="dynamic">
|
||||
<mat-label>Ack no. / job id</mat-label>
|
||||
<input matInput [(ngModel)]="outboxSearch" (keyup.enter)="loadOutbox(true)" (blur)="loadOutbox(true)" />
|
||||
</mat-form-field>
|
||||
<mat-form-field appearance="outline" subscriptSizing="dynamic">
|
||||
<mat-label>Type</mat-label>
|
||||
<mat-select [(ngModel)]="outboxType" (selectionChange)="loadOutbox(true)">
|
||||
<mat-option value="">ACK and RESPONSE</mat-option>
|
||||
<mat-option value="ACK">ACK only</mat-option>
|
||||
<mat-option value="RESPONSE">RESPONSE only</mat-option>
|
||||
</mat-select>
|
||||
</mat-form-field>
|
||||
<mifosx-i4c-date-range
|
||||
label="Queued"
|
||||
[range]="outboxDates"
|
||||
(rangeChange)="onOutboxDates($event)"
|
||||
></mifosx-i4c-date-range>
|
||||
<button
|
||||
mat-stroked-button
|
||||
class="i4c-filter-bar__clear"
|
||||
(click)="clearOutboxFilters()"
|
||||
[disabled]="!outboxHasFilters"
|
||||
>
|
||||
<fa-icon icon="times"></fa-icon> Clear filters
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div class="i4c-table-wrap">
|
||||
<table class="i4c-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Created At</th>
|
||||
<th>Complaint Ack No.</th>
|
||||
<th title="ACK: separate acknowledgement callback. RESPONSE: the Section 6 findings.">Type</th>
|
||||
<th>Delivery Status</th>
|
||||
<th class="num">Attempts</th>
|
||||
<th>Next Retry</th>
|
||||
<th>HTTP Code</th>
|
||||
<th>Sent Timestamp</th>
|
||||
<th>Error</th>
|
||||
<th>Actions</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
@for (o of outbox; track o.id) {
|
||||
<tr class="clickable" (click)="openOutboxModal(o)">
|
||||
<td>
|
||||
<span class="date-main">{{ o.created_at | date: 'dd MMM, HH:mm:ss' }}</span>
|
||||
</td>
|
||||
<td>
|
||||
<a
|
||||
[routerLink]="['../requests', o.request_id]"
|
||||
class="font-semibold text-accent"
|
||||
(click)="$event.stopPropagation()"
|
||||
>
|
||||
{{ o.acknowledgement_no }}
|
||||
</a>
|
||||
</td>
|
||||
<td>
|
||||
<span class="i4c-badge" [class.i4c-badge--blue]="o.message_type === 'ACK'">{{
|
||||
o.message_type === 'ACK' ? 'ACK' : 'RESPONSE'
|
||||
}}</span>
|
||||
</td>
|
||||
<td>
|
||||
<span
|
||||
class="i4c-badge"
|
||||
[class.i4c-badge--green]="o.status === 'SENT'"
|
||||
[class.i4c-badge--red]="o.status === 'FAILED'"
|
||||
[class.i4c-badge--amber]="o.status === 'PENDING' || o.status === 'SENDING'"
|
||||
>
|
||||
{{ o.status }}
|
||||
</span>
|
||||
</td>
|
||||
<td class="num font-semibold">{{ o.attempts }}</td>
|
||||
<td>{{ o.status === 'PENDING' ? (o.next_attempt_at | date: 'dd MMM, HH:mm:ss') : '-' }}</td>
|
||||
<td>
|
||||
<span class="font-mono">{{ o.http_status ?? '-' }}</span>
|
||||
</td>
|
||||
<td>{{ o.sent_at ? (o.sent_at | date: 'dd MMM, HH:mm:ss') : '-' }}</td>
|
||||
<td class="err-text">{{ o.last_error || '-' }}</td>
|
||||
<td (click)="$event.stopPropagation()">
|
||||
<div class="i4c-actions">
|
||||
<button class="table-action-btn" (click)="openOutboxModal(o)">
|
||||
<fa-icon icon="eye"></fa-icon> Payload
|
||||
</button>
|
||||
@if (o.status === 'FAILED') {
|
||||
<button class="retry-btn" (click)="retry(o)" [disabled]="busyId === o.id">
|
||||
<fa-icon icon="redo" [animation]="busyId === o.id ? 'spin' : undefined"></fa-icon> Retry
|
||||
</button>
|
||||
}
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
} @empty {
|
||||
<tr>
|
||||
<td colspan="10" class="i4c-empty">
|
||||
<div class="empty-icon"><fa-icon icon="paper-plane"></fa-icon></div>
|
||||
<div>No responses in the outbox queue matching current filter.</div>
|
||||
</td>
|
||||
</tr>
|
||||
}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
<mat-paginator
|
||||
class="i4c-paginator"
|
||||
[length]="outboxTotal"
|
||||
[pageIndex]="outboxPageIndex"
|
||||
[pageSize]="outboxPageSize"
|
||||
[pageSizeOptions]="pageSizes"
|
||||
(page)="onOutboxPage($event)"
|
||||
showFirstLastButtons
|
||||
aria-label="Outbox pages"
|
||||
></mat-paginator>
|
||||
</div>
|
||||
}
|
||||
|
||||
<!-- SECTION 2: API LOG -->
|
||||
@if (activeSection === 'all' || activeSection === 'log') {
|
||||
<div class="i4c-card">
|
||||
<div class="i4c-card__head">
|
||||
<fa-icon icon="history"></fa-icon>
|
||||
<span>API Gateway Audit Trail (Masked)</span>
|
||||
<span class="head-hint">Every call to and from I4C. Click a row to see its request and response.</span>
|
||||
|
||||
<span class="i4c-toolbar__spacer"></span>
|
||||
|
||||
<!-- Direction Filters -->
|
||||
<div class="mini-chips">
|
||||
<button class="mini-chip" [class.mini-chip--active]="direction === ''" (click)="setDirection('')">
|
||||
Both
|
||||
</button>
|
||||
<button class="mini-chip" [class.mini-chip--active]="direction === 'IN'" (click)="setDirection('IN')">
|
||||
↓ Inbound
|
||||
</button>
|
||||
<button class="mini-chip" [class.mini-chip--active]="direction === 'OUT'" (click)="setDirection('OUT')">
|
||||
↑ Outbound
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<mat-checkbox [(ngModel)]="failuresOnly" (change)="loadLog(true)" class="failures-chk">
|
||||
Failures Only
|
||||
</mat-checkbox>
|
||||
</div>
|
||||
|
||||
<div class="i4c-filter-bar i4c-filter-bar--inset">
|
||||
<mat-form-field appearance="outline" subscriptSizing="dynamic">
|
||||
<mat-label>Endpoint / ack no. / IP</mat-label>
|
||||
<input matInput [(ngModel)]="logSearch" (keyup.enter)="loadLog(true)" (blur)="loadLog(true)" />
|
||||
</mat-form-field>
|
||||
<mat-form-field appearance="outline" subscriptSizing="dynamic">
|
||||
<mat-label>HTTP status</mat-label>
|
||||
<mat-select [(ngModel)]="logHttpStatus" (selectionChange)="loadLog(true)">
|
||||
<mat-option value="">Any</mat-option>
|
||||
<mat-option value="200">200 OK</mat-option>
|
||||
<mat-option value="400">400 Bad request</mat-option>
|
||||
<mat-option value="401">401 Not authorised</mat-option>
|
||||
<mat-option value="403">403 Forbidden</mat-option>
|
||||
<mat-option value="409">409 Duplicate</mat-option>
|
||||
<mat-option value="500">500 Server error</mat-option>
|
||||
</mat-select>
|
||||
</mat-form-field>
|
||||
<mat-form-field appearance="outline" subscriptSizing="dynamic">
|
||||
<mat-label>Response code</mat-label>
|
||||
<input
|
||||
matInput
|
||||
[(ngModel)]="logResponseCode"
|
||||
(keyup.enter)="loadLog(true)"
|
||||
(blur)="loadLog(true)"
|
||||
placeholder="e.g. 00, 11"
|
||||
/>
|
||||
</mat-form-field>
|
||||
<mifosx-i4c-date-range
|
||||
label="Call time"
|
||||
[range]="logDates"
|
||||
(rangeChange)="onLogDates($event)"
|
||||
></mifosx-i4c-date-range>
|
||||
<button
|
||||
mat-stroked-button
|
||||
class="i4c-filter-bar__clear"
|
||||
(click)="clearLogFilters()"
|
||||
[disabled]="!logHasFilters"
|
||||
>
|
||||
<fa-icon icon="times"></fa-icon> Clear filters
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div class="i4c-table-wrap">
|
||||
<table class="i4c-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Time</th>
|
||||
<th>Direction</th>
|
||||
<th>Endpoint</th>
|
||||
<th>HTTP Status</th>
|
||||
<th>Response Code</th>
|
||||
<th>Ack No.</th>
|
||||
<th class="num">Latency (ms)</th>
|
||||
<th>Caller IP</th>
|
||||
<th>Error Details</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
@for (l of log; track l.id) {
|
||||
<tr class="clickable" (click)="openLogModal(l)">
|
||||
<td>{{ l.created_at | date: 'dd MMM, HH:mm:ss' }}</td>
|
||||
<td>
|
||||
<span class="direction-badge direction-badge--{{ l.direction?.toLowerCase() }}">
|
||||
{{ l.direction === 'IN' ? '↓ I4C → Bank' : '↑ Bank → I4C' }}
|
||||
</span>
|
||||
</td>
|
||||
<td>
|
||||
<span class="font-mono text-xs">{{ l.endpoint }}</span>
|
||||
</td>
|
||||
<td>
|
||||
<span
|
||||
class="i4c-badge"
|
||||
[class.i4c-badge--green]="ok(l.http_status)"
|
||||
[class.i4c-badge--red]="!ok(l.http_status)"
|
||||
>
|
||||
{{ l.http_status ?? 'N/A' }}
|
||||
</span>
|
||||
</td>
|
||||
<td>{{ l.response_code || '-' }}</td>
|
||||
<td>
|
||||
<b>{{ l.acknowledgement_no || '-' }}</b>
|
||||
</td>
|
||||
<td class="num">{{ l.duration_ms }} ms</td>
|
||||
<td>{{ l.client_ip || '-' }}</td>
|
||||
<td [class.err-text]="isFailedCall(l)" [class.note-text]="!isFailedCall(l)">
|
||||
{{ logNote(l.error) || '-' }}
|
||||
</td>
|
||||
</tr>
|
||||
} @empty {
|
||||
<tr>
|
||||
<td colspan="9" class="i4c-empty">
|
||||
<div class="empty-icon"><fa-icon icon="history"></fa-icon></div>
|
||||
<div>No API calls recorded.</div>
|
||||
</td>
|
||||
</tr>
|
||||
}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
<mat-paginator
|
||||
class="i4c-paginator"
|
||||
[length]="logTotal"
|
||||
[pageIndex]="logPageIndex"
|
||||
[pageSize]="logPageSize"
|
||||
[pageSizeOptions]="pageSizes"
|
||||
(page)="onLogPage($event)"
|
||||
showFirstLastButtons
|
||||
aria-label="API log pages"
|
||||
></mat-paginator>
|
||||
</div>
|
||||
}
|
||||
|
||||
<!-- ========================================================
|
||||
MODERN POPUP WINDOW: API LOG JSON INSPECTION MODAL
|
||||
======================================================== -->
|
||||
@if (selectedLogModal) {
|
||||
<div class="i4c-modal-backdrop" (click)="closeLogModal()">
|
||||
<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">
|
||||
<fa-icon icon="code"></fa-icon>
|
||||
<div>
|
||||
<div class="modal-title-row">
|
||||
<span
|
||||
>{{ selectedLogModal.direction === 'IN' ? 'Inbound Call' : 'Outbound Call' }}:
|
||||
{{ selectedLogModal.endpoint }}</span
|
||||
>
|
||||
<span
|
||||
class="i4c-badge"
|
||||
[class.i4c-badge--green]="ok(selectedLogModal.http_status)"
|
||||
[class.i4c-badge--red]="!ok(selectedLogModal.http_status)"
|
||||
>
|
||||
HTTP {{ selectedLogModal.http_status ?? 'N/A' }}
|
||||
</span>
|
||||
</div>
|
||||
<div class="i4c-modal-header__sub">
|
||||
Time: {{ selectedLogModal.created_at | date: 'dd MMM yyyy, HH:mm:ss' }} • Latency:
|
||||
{{ selectedLogModal.duration_ms }} ms • Client IP: {{ selectedLogModal.client_ip }}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<button class="modal-close-btn" (click)="closeLogModal()" aria-label="Close">
|
||||
<fa-icon icon="times"></fa-icon>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- Navigation Tabs inside the Modal -->
|
||||
<div class="i4c-modal-tabs">
|
||||
<button class="modal-tab" [class.modal-tab--active]="logModalTab === 'req'" (click)="logModalTab = 'req'">
|
||||
<fa-icon icon="arrow-up"></fa-icon> Request Payload
|
||||
</button>
|
||||
<button class="modal-tab" [class.modal-tab--active]="logModalTab === 'res'" (click)="logModalTab = 'res'">
|
||||
<fa-icon icon="arrow-down"></fa-icon> Response Payload
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- Modal Body -->
|
||||
<div class="i4c-modal-body">
|
||||
@if (logModalTab === 'req') {
|
||||
<div class="payload-box">
|
||||
<div class="payload-box__title">
|
||||
<span>HTTP Request Payload (Masked)</span>
|
||||
<button class="copy-chip" (click)="copyPayload(pretty(selectedLogModal.request_body), 'req-body')">
|
||||
<fa-icon icon="copy"></fa-icon> {{ copiedKey === 'req-body' ? 'Copied!' : 'Copy Request' }}
|
||||
</button>
|
||||
</div>
|
||||
<pre class="i4c-json">{{ pretty(selectedLogModal.request_body) || 'No request body sent.' }}</pre>
|
||||
</div>
|
||||
}
|
||||
|
||||
@if (logModalTab === 'res') {
|
||||
<div class="payload-box">
|
||||
<div class="payload-box__title">
|
||||
<span>HTTP Response Payload</span>
|
||||
<button class="copy-chip" (click)="copyPayload(pretty(selectedLogModal.response_body), 'res-body')">
|
||||
<fa-icon icon="copy"></fa-icon> {{ copiedKey === 'res-body' ? 'Copied!' : 'Copy Response' }}
|
||||
</button>
|
||||
</div>
|
||||
<pre class="i4c-json">{{ pretty(selectedLogModal.response_body) || 'No response body received.' }}</pre>
|
||||
</div>
|
||||
}
|
||||
|
||||
@if (selectedLogModal.error) {
|
||||
@if (isFailedCall(selectedLogModal)) {
|
||||
<div class="i4c-error">
|
||||
<fa-icon icon="exclamation-circle"></fa-icon>
|
||||
<span>Error logged: {{ logNote(selectedLogModal.error) }}</span>
|
||||
</div>
|
||||
} @else {
|
||||
<div class="i4c-note">
|
||||
<fa-icon icon="info-circle"></fa-icon>
|
||||
<span>Note: {{ logNote(selectedLogModal.error) }}</span>
|
||||
</div>
|
||||
}
|
||||
}
|
||||
</div>
|
||||
|
||||
<!-- Modal Footer -->
|
||||
<div class="i4c-modal-footer">
|
||||
<button class="footer-btn-close" (click)="closeLogModal()">Close</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
|
||||
<!-- ========================================================
|
||||
MODERN POPUP WINDOW: OUTBOX RESPONSE INSPECTION MODAL
|
||||
======================================================== -->
|
||||
@if (selectedOutboxModal) {
|
||||
<div class="i4c-modal-backdrop" (click)="closeOutboxModal()">
|
||||
<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="paper-plane"></fa-icon>
|
||||
<div>
|
||||
<div class="modal-title-row">
|
||||
<span
|
||||
>Outbox Response #{{ selectedOutboxModal.id }} (Ack #{{
|
||||
selectedOutboxModal.acknowledgement_no
|
||||
}})</span
|
||||
>
|
||||
<span
|
||||
class="i4c-badge"
|
||||
[class.i4c-badge--green]="selectedOutboxModal.status === 'SENT'"
|
||||
[class.i4c-badge--red]="selectedOutboxModal.status === 'FAILED'"
|
||||
>
|
||||
{{ selectedOutboxModal.status }}
|
||||
</span>
|
||||
</div>
|
||||
<div class="i4c-modal-header__sub">
|
||||
Created: {{ selectedOutboxModal.created_at | date: 'dd MMM yyyy, HH:mm:ss' }} • Attempts:
|
||||
{{ selectedOutboxModal.attempts }}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<button class="modal-close-btn" (click)="closeOutboxModal()" aria-label="Close">
|
||||
<fa-icon icon="times"></fa-icon>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div class="i4c-modal-body">
|
||||
<div class="payload-box">
|
||||
<div class="payload-box__title">
|
||||
<span>Report Payload Prepared for I4C Delivery</span>
|
||||
<button class="copy-chip" (click)="copyPayload(pretty(selectedOutboxModal.payload), 'outbox-payload')">
|
||||
<fa-icon icon="copy"></fa-icon> {{ copiedKey === 'outbox-payload' ? 'Copied!' : 'Copy Payload' }}
|
||||
</button>
|
||||
</div>
|
||||
<pre class="i4c-json">{{ pretty(selectedOutboxModal.payload) || 'No payload recorded.' }}</pre>
|
||||
</div>
|
||||
|
||||
@if (selectedOutboxModal.response_body) {
|
||||
<div class="payload-box">
|
||||
<div class="payload-box__title">
|
||||
<span>Response Received from I4C Gateway</span>
|
||||
<button
|
||||
class="copy-chip"
|
||||
(click)="copyPayload(pretty(selectedOutboxModal.response_body), 'outbox-res')"
|
||||
>
|
||||
<fa-icon icon="copy"></fa-icon> {{ copiedKey === 'outbox-res' ? 'Copied!' : 'Copy Response' }}
|
||||
</button>
|
||||
</div>
|
||||
<pre class="i4c-json">{{ pretty(selectedOutboxModal.response_body) }}</pre>
|
||||
</div>
|
||||
}
|
||||
|
||||
@if (selectedOutboxModal.last_error) {
|
||||
<div class="i4c-error">
|
||||
<fa-icon icon="exclamation-circle"></fa-icon>
|
||||
<span>Last Delivery Error: {{ selectedOutboxModal.last_error }}</span>
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
|
||||
<div class="i4c-modal-footer">
|
||||
@if (selectedOutboxModal.status === 'FAILED') {
|
||||
<button mat-flat-button color="warn" (click)="retry(selectedOutboxModal); closeOutboxModal()">
|
||||
<fa-icon icon="redo"></fa-icon> Retry Send Now
|
||||
</button>
|
||||
}
|
||||
<button class="footer-btn-close" (click)="closeOutboxModal()">Close</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
@@ -0,0 +1,191 @@
|
||||
/**
|
||||
* 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;
|
||||
|
||||
.compact {
|
||||
width: 160px;
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
.sub {
|
||||
font-size: 11px;
|
||||
color: #64748b;
|
||||
margin-bottom: 4px;
|
||||
}
|
||||
|
||||
.err-text {
|
||||
color: #dc2626;
|
||||
font-size: 11px;
|
||||
max-width: 240px;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.head-hint {
|
||||
font-size: 11px;
|
||||
color: #94a3b8;
|
||||
font-weight: normal;
|
||||
margin-left: 8px;
|
||||
}
|
||||
|
||||
.date-main {
|
||||
font-weight: 600;
|
||||
color: #1e293b;
|
||||
}
|
||||
|
||||
.mini-chips {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 5px;
|
||||
|
||||
.mini-chip {
|
||||
padding: 2px 8px;
|
||||
border-radius: 9999px;
|
||||
font-size: 10.5px;
|
||||
font-weight: 600;
|
||||
color: #475569;
|
||||
background: #f1f5f9;
|
||||
border: 1px solid #e2e8f0;
|
||||
cursor: pointer;
|
||||
transition: all 0.15s ease;
|
||||
|
||||
&:hover {
|
||||
background: #e2e8f0;
|
||||
color: #0f172a;
|
||||
}
|
||||
|
||||
&--active {
|
||||
background: #2563eb;
|
||||
color: #fff;
|
||||
border-color: #2563eb;
|
||||
box-shadow: 0 2px 6px rgb(37 99 235 / 25%);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.failures-chk {
|
||||
margin-left: 10px;
|
||||
font-size: 11px;
|
||||
font-weight: 600;
|
||||
color: #475569;
|
||||
}
|
||||
|
||||
.direction-badge {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
padding: 2px 7px;
|
||||
border-radius: 5px;
|
||||
font-size: 10px;
|
||||
font-weight: 700;
|
||||
|
||||
&--in {
|
||||
background: #eff6ff;
|
||||
color: #1d4ed8;
|
||||
border: 1px solid #bfdbfe;
|
||||
}
|
||||
|
||||
&--out {
|
||||
background: #f5f3ff;
|
||||
color: #6d28d9;
|
||||
border: 1px solid #ddd6fe;
|
||||
}
|
||||
}
|
||||
|
||||
.retry-btn {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 5px;
|
||||
padding: 3px 8px;
|
||||
border-radius: 5px;
|
||||
font-size: 10.5px;
|
||||
font-weight: 700;
|
||||
background: #fef2f2;
|
||||
color: #dc2626;
|
||||
border: 1px solid #fecaca;
|
||||
cursor: pointer;
|
||||
transition: all 0.15s ease;
|
||||
|
||||
&:hover:not(:disabled) {
|
||||
background: #fee2e2;
|
||||
border-color: #fca5a5;
|
||||
}
|
||||
|
||||
&:disabled {
|
||||
opacity: 0.6;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
}
|
||||
|
||||
.modal-title-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.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;
|
||||
}
|
||||
}
|
||||
|
||||
.copy-chip {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 5px;
|
||||
padding: 3px 8px;
|
||||
border-radius: 5px;
|
||||
background: #f1f5f9;
|
||||
border: 1px solid #cbd5e1;
|
||||
color: #334155;
|
||||
font-size: 10.5px;
|
||||
font-weight: 600;
|
||||
cursor: pointer;
|
||||
transition: all 0.15s ease;
|
||||
|
||||
&:hover {
|
||||
background: #e2e8f0;
|
||||
color: #0f172a;
|
||||
}
|
||||
}
|
||||
|
||||
.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;
|
||||
}
|
||||
}
|
||||
|
||||
.note-text {
|
||||
color: #64748b;
|
||||
font-size: 12px;
|
||||
}
|
||||
@@ -0,0 +1,306 @@
|
||||
/**
|
||||
* 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 } from '@angular/common';
|
||||
import { FormsModule } from '@angular/forms';
|
||||
import { ActivatedRoute, 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 { MatCheckboxModule } from '@angular/material/checkbox';
|
||||
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 { ALL_DATES, I4cDateRange, I4cDateRangeComponent } from '../i4c-date-range/i4c-date-range.component';
|
||||
|
||||
/**
|
||||
* Responses waiting for, or delivered to, I4C, with a retry for the failed ones; and the log of every call in
|
||||
* either direction, which is what the periodic review of the API integration reads.
|
||||
*/
|
||||
@Component({
|
||||
selector: 'mifosx-i4c-outbox',
|
||||
templateUrl: './i4c-outbox.component.html',
|
||||
styleUrls: ['./i4c-outbox.component.scss'],
|
||||
imports: [
|
||||
DatePipe,
|
||||
FormsModule,
|
||||
RouterLink,
|
||||
MatButtonModule,
|
||||
MatFormFieldModule,
|
||||
MatInputModule,
|
||||
MatSelectModule,
|
||||
MatCheckboxModule,
|
||||
MatProgressBarModule,
|
||||
MatPaginatorModule,
|
||||
I4cDateRangeComponent,
|
||||
FaIconComponent
|
||||
]
|
||||
})
|
||||
export class I4cOutboxComponent implements OnInit {
|
||||
private i4c = inject(I4cService);
|
||||
private route = inject(ActivatedRoute);
|
||||
private snackBar = inject(MatSnackBar);
|
||||
|
||||
readonly outboxStatuses = [
|
||||
'',
|
||||
'PENDING',
|
||||
'SENDING',
|
||||
'SENT',
|
||||
'FAILED'
|
||||
];
|
||||
readonly pageSizes = [
|
||||
10,
|
||||
20,
|
||||
50,
|
||||
100
|
||||
];
|
||||
|
||||
outboxStatus = '';
|
||||
outboxType = '';
|
||||
outboxSearch = '';
|
||||
outboxDates: I4cDateRange = { ...ALL_DATES };
|
||||
outboxTotal = 0;
|
||||
outboxPageIndex = 0;
|
||||
outboxPageSize = 20;
|
||||
outbox: any[] = [];
|
||||
|
||||
direction = '';
|
||||
failuresOnly = false;
|
||||
logSearch = '';
|
||||
logHttpStatus = '';
|
||||
logResponseCode = '';
|
||||
logDates: I4cDateRange = { ...ALL_DATES };
|
||||
logTotal = 0;
|
||||
logPageIndex = 0;
|
||||
logPageSize = 20;
|
||||
log: any[] = [];
|
||||
logEntry: any = null;
|
||||
|
||||
loading = false;
|
||||
busyId: number | null = null;
|
||||
error = '';
|
||||
|
||||
activeSection: 'all' | 'outbox' | 'log' = 'all';
|
||||
selectedLogModal: any = null;
|
||||
selectedOutboxModal: any = null;
|
||||
logModalTab: 'req' | 'res' = 'req';
|
||||
copiedKey = '';
|
||||
|
||||
ngOnInit(): void {
|
||||
this.outboxStatus = this.route.snapshot.queryParamMap.get('status') || '';
|
||||
this.loadOutbox();
|
||||
this.loadLog();
|
||||
}
|
||||
|
||||
setOutboxStatus(s: string): void {
|
||||
this.outboxStatus = s;
|
||||
this.loadOutbox(true);
|
||||
}
|
||||
|
||||
setDirection(d: string): void {
|
||||
this.direction = d;
|
||||
this.loadLog(true);
|
||||
}
|
||||
|
||||
onOutboxPage(e: PageEvent): void {
|
||||
this.outboxPageIndex = e.pageIndex;
|
||||
this.outboxPageSize = e.pageSize;
|
||||
this.loadOutbox();
|
||||
}
|
||||
|
||||
onOutboxDates(range: I4cDateRange): void {
|
||||
this.outboxDates = range;
|
||||
this.loadOutbox(true);
|
||||
}
|
||||
|
||||
get outboxHasFilters(): boolean {
|
||||
return !!(this.outboxStatus || this.outboxType || this.outboxSearch || this.outboxDates.preset !== 'ALL');
|
||||
}
|
||||
|
||||
clearOutboxFilters(): void {
|
||||
this.outboxStatus = '';
|
||||
this.outboxType = '';
|
||||
this.outboxSearch = '';
|
||||
this.outboxDates = { ...ALL_DATES };
|
||||
this.loadOutbox(true);
|
||||
}
|
||||
|
||||
onLogPage(e: PageEvent): void {
|
||||
this.logPageIndex = e.pageIndex;
|
||||
this.logPageSize = e.pageSize;
|
||||
this.loadLog();
|
||||
}
|
||||
|
||||
onLogDates(range: I4cDateRange): void {
|
||||
this.logDates = range;
|
||||
this.loadLog(true);
|
||||
}
|
||||
|
||||
get logHasFilters(): boolean {
|
||||
return !!(
|
||||
this.direction ||
|
||||
this.failuresOnly ||
|
||||
this.logSearch ||
|
||||
this.logHttpStatus ||
|
||||
this.logResponseCode ||
|
||||
this.logDates.preset !== 'ALL'
|
||||
);
|
||||
}
|
||||
|
||||
clearLogFilters(): void {
|
||||
this.direction = '';
|
||||
this.failuresOnly = false;
|
||||
this.logSearch = '';
|
||||
this.logHttpStatus = '';
|
||||
this.logResponseCode = '';
|
||||
this.logDates = { ...ALL_DATES };
|
||||
this.loadLog(true);
|
||||
}
|
||||
|
||||
/** Reload the current page; a changed filter passes `fromFirstPage` so the officer lands on page one. */
|
||||
loadOutbox(fromFirstPage = false): void {
|
||||
if (fromFirstPage) {
|
||||
this.outboxPageIndex = 0;
|
||||
}
|
||||
this.loading = true;
|
||||
this.error = '';
|
||||
this.i4c
|
||||
.outbox({
|
||||
status: this.outboxStatus,
|
||||
type: this.outboxType,
|
||||
search: this.outboxSearch.trim(),
|
||||
fromDate: this.outboxDates.fromDate,
|
||||
toDate: this.outboxDates.toDate,
|
||||
offset: this.outboxPageIndex * this.outboxPageSize,
|
||||
limit: this.outboxPageSize
|
||||
})
|
||||
.subscribe({
|
||||
next: (page) => {
|
||||
this.outbox = page.pageItems;
|
||||
this.outboxTotal = page.totalFilteredRecords;
|
||||
this.loading = false;
|
||||
},
|
||||
error: (e) => {
|
||||
this.error = i4cErrorMessage(e);
|
||||
this.loading = false;
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
loadLog(fromFirstPage = false): void {
|
||||
if (fromFirstPage) {
|
||||
this.logPageIndex = 0;
|
||||
}
|
||||
this.i4c
|
||||
.apiLog({
|
||||
direction: this.direction,
|
||||
failures: this.failuresOnly || null,
|
||||
search: this.logSearch.trim(),
|
||||
httpStatus: this.logHttpStatus,
|
||||
responseCode: this.logResponseCode.trim(),
|
||||
fromDate: this.logDates.fromDate,
|
||||
toDate: this.logDates.toDate,
|
||||
offset: this.logPageIndex * this.logPageSize,
|
||||
limit: this.logPageSize
|
||||
})
|
||||
.subscribe({
|
||||
next: (page) => {
|
||||
this.log = page.pageItems;
|
||||
this.logTotal = page.totalFilteredRecords;
|
||||
},
|
||||
error: (e) => (this.error = i4cErrorMessage(e))
|
||||
});
|
||||
}
|
||||
|
||||
retry(row: any): void {
|
||||
this.busyId = row.id;
|
||||
this.i4c.retryOutbox(row.id).subscribe({
|
||||
next: (r) => {
|
||||
this.busyId = null;
|
||||
this.snackBar.open(r?.sent ? 'Delivered to I4C.' : 'Queued; it will be sent again shortly.', 'OK', {
|
||||
duration: 4000
|
||||
});
|
||||
this.loadOutbox();
|
||||
this.loadLog();
|
||||
},
|
||||
error: (e) => {
|
||||
this.busyId = null;
|
||||
this.snackBar.open(i4cErrorMessage(e), 'Close', { duration: 6000 });
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
openLogModal(row: any): void {
|
||||
this.selectedLogModal = { ...row };
|
||||
this.logModalTab = 'req';
|
||||
this.i4c.apiLogEntry(row.id).subscribe({
|
||||
next: (entry) => (this.selectedLogModal = entry),
|
||||
error: (e) => this.snackBar.open(i4cErrorMessage(e), 'Close', { duration: 6000 })
|
||||
});
|
||||
}
|
||||
|
||||
closeLogModal(): void {
|
||||
this.selectedLogModal = null;
|
||||
}
|
||||
|
||||
openOutboxModal(row: any): void {
|
||||
this.selectedOutboxModal = row;
|
||||
}
|
||||
|
||||
closeOutboxModal(): void {
|
||||
this.selectedOutboxModal = null;
|
||||
}
|
||||
|
||||
copyPayload(text: string, key: string): void {
|
||||
if (!text) {
|
||||
return;
|
||||
}
|
||||
navigator.clipboard.writeText(text);
|
||||
this.copiedKey = key;
|
||||
this.snackBar.open('Payload copied to clipboard.', 'OK', { duration: 2500 });
|
||||
setTimeout(() => {
|
||||
if (this.copiedKey === key) {
|
||||
this.copiedKey = '';
|
||||
}
|
||||
}, 2000);
|
||||
}
|
||||
|
||||
/** A call failed when HTTP says so or I4C/the bank answered with a code other than 00. */
|
||||
isFailedCall(log: any): boolean {
|
||||
return (log?.http_status ?? 0) >= 400 || (!!log?.response_code && log.response_code !== '00');
|
||||
}
|
||||
|
||||
/** Successful calls keep a short note in the error column, e.g. how I4C logged in; spell it out. */
|
||||
logNote(text: string): string {
|
||||
const modes: Record<string, string> = {
|
||||
TOKEN: 'I4C logged in with a Bearer token',
|
||||
BASIC: 'I4C logged in with Basic authentication',
|
||||
BODY: 'I4C sent its username and password in the request body'
|
||||
};
|
||||
return (text || '').replace(/auth (TOKEN|BASIC|BODY)/g, (m, mode) => modes[mode] ?? m);
|
||||
}
|
||||
|
||||
pretty(text: string): string {
|
||||
if (!text) {
|
||||
return '';
|
||||
}
|
||||
try {
|
||||
return JSON.stringify(JSON.parse(text), null, 2);
|
||||
} catch {
|
||||
return text;
|
||||
}
|
||||
}
|
||||
|
||||
ok(status: number | null): boolean {
|
||||
return status !== null && status !== undefined && status >= 200 && status < 300;
|
||||
}
|
||||
}
|
||||
@@ -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/.
|
||||
-->
|
||||
|
||||
<div class="i4c-page">
|
||||
<!-- Top Action Toolbar -->
|
||||
<div class="i4c-toolbar">
|
||||
<a mat-stroked-button routerLink="../" class="back-btn"> <fa-icon icon="arrow-left"></fa-icon> All Complaints </a>
|
||||
|
||||
@if (data) {
|
||||
<div class="ack-badge-strip">
|
||||
<span class="label">Ack No:</span>
|
||||
<b class="val">{{ data.acknowledgement_no }}</b>
|
||||
<button class="copy-chip" (click)="copyAck(data.acknowledgement_no)">
|
||||
<fa-icon icon="copy"></fa-icon> Copy
|
||||
</button>
|
||||
</div>
|
||||
}
|
||||
|
||||
<span class="i4c-toolbar__spacer"></span>
|
||||
|
||||
@if (canReprocess) {
|
||||
<button mat-flat-button color="primary" (click)="reprocess()" [disabled]="busy">
|
||||
<fa-icon icon="redo" [animation]="busy ? 'spin' : undefined"></fa-icon> Reprocess Now
|
||||
</button>
|
||||
}
|
||||
<button mat-stroked-button (click)="load()" [disabled]="loading">
|
||||
<fa-icon icon="sync" [animation]="loading ? 'spin' : undefined"></fa-icon> Refresh
|
||||
</button>
|
||||
</div>
|
||||
|
||||
@if (loading || busy) {
|
||||
<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) {
|
||||
<!-- Main Top Info: Customer Target Account + Processing -->
|
||||
<div class="i4c-grid-2">
|
||||
<!-- Target Account Info -->
|
||||
<div class="i4c-card">
|
||||
<div class="i4c-card__head">
|
||||
<fa-icon icon="inbox"></fa-icon>
|
||||
<span>Complaint Details (From I4C)</span>
|
||||
</div>
|
||||
<div class="i4c-card__body">
|
||||
<dl class="i4c-kv">
|
||||
<dt>Acknowledgement No.</dt>
|
||||
<dd>
|
||||
<b class="text-accent">{{ data.acknowledgement_no }}</b>
|
||||
</dd>
|
||||
|
||||
<dt>CBS Internal Job ID</dt>
|
||||
<dd>{{ data.job_id }}</dd>
|
||||
|
||||
<dt>Sub-Category</dt>
|
||||
<dd>
|
||||
<span class="cat-chip">{{ data.sub_category }}</span>
|
||||
</dd>
|
||||
|
||||
<dt>Account in this Bank</dt>
|
||||
<dd>
|
||||
<b>{{ data.payer_account_number }}</b>
|
||||
</dd>
|
||||
|
||||
<dt>Complainant Bank</dt>
|
||||
<dd>{{ data.payer_bank }} {{ data.payer_bank_code ? '(' + data.payer_bank_code + ')' : '' }}</dd>
|
||||
|
||||
<dt>Payment Mode / Type</dt>
|
||||
<dd>{{ data.mode_of_payment || '-' }} {{ data.transaction_type ? '/ ' + data.transaction_type : '' }}</dd>
|
||||
|
||||
<dt>Wallet Reference</dt>
|
||||
<dd>{{ data.wallet || 'None' }}</dd>
|
||||
|
||||
<dt>State & District</dt>
|
||||
<dd>{{ data.state }} / {{ data.district }}</dd>
|
||||
|
||||
<dt>Received At</dt>
|
||||
<dd>{{ data.received_at | date: 'dd MMM yyyy, HH:mm:ss' }} from {{ data.source_ip }}</dd>
|
||||
</dl>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Processing State Info -->
|
||||
<div class="i4c-card">
|
||||
<div class="i4c-card__head">
|
||||
<fa-icon icon="tasks"></fa-icon>
|
||||
<span>Automation & Processing Outcome</span>
|
||||
</div>
|
||||
<div class="i4c-card__body">
|
||||
<dl class="i4c-kv">
|
||||
<dt>Processing Status</dt>
|
||||
<dd>
|
||||
<span class="i4c-badge">{{ data.status }}</span>
|
||||
</dd>
|
||||
|
||||
<dt>Processing Attempts</dt>
|
||||
<dd>{{ data.processing_attempts }}</dd>
|
||||
|
||||
<dt>Processed On</dt>
|
||||
<dd>{{ data.processed_at ? (data.processed_at | date: 'dd MMM yyyy, HH:mm:ss') : 'Not yet processed' }}</dd>
|
||||
|
||||
<dt>Total Amount Held</dt>
|
||||
<dd>
|
||||
<b class="text-accent">₹{{ data.amount_held | number: '1.2-2' }}</b>
|
||||
</dd>
|
||||
|
||||
<dt>Outbox Response Status</dt>
|
||||
<dd>
|
||||
<span class="i4c-badge">{{ data.response_status || 'NOT YET SENT' }}</span>
|
||||
</dd>
|
||||
|
||||
@if (data.processing_error) {
|
||||
<dt>Last Processing Error</dt>
|
||||
<dd class="err-text">{{ data.processing_error }}</dd>
|
||||
}
|
||||
</dl>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Incidents Table -->
|
||||
<div class="i4c-card">
|
||||
<div class="i4c-card__head">
|
||||
<fa-icon icon="list-alt"></fa-icon>
|
||||
<span>Reported Incidents & CBS Matching Engine Outcomes</span>
|
||||
</div>
|
||||
<div class="i4c-card__body i4c-table-wrap">
|
||||
<table class="i4c-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>#</th>
|
||||
<th>RRN</th>
|
||||
<th>Date & Time</th>
|
||||
<th class="num">Amount (₹)</th>
|
||||
<th class="num">Disputed (₹)</th>
|
||||
<th class="num">Layer</th>
|
||||
<th>Match Method</th>
|
||||
<th>Outcome</th>
|
||||
<th class="num">Held Amount</th>
|
||||
<th>Remarks</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
@for (inc of data.incidents; track inc.id) {
|
||||
<tr>
|
||||
<td>
|
||||
<b>{{ inc.seq_no }}</b>
|
||||
</td>
|
||||
<td>
|
||||
<span class="font-mono">{{ inc.rrn }}</span>
|
||||
</td>
|
||||
<td>{{ inc.transaction_date }} {{ inc.transaction_time }}</td>
|
||||
<td class="num">{{ inc.amount | number: '1.2-2' }}</td>
|
||||
<td class="num">{{ inc.disputed_amount | number: '1.2-2' }}</td>
|
||||
<td class="num font-semibold">{{ inc.layer }}</td>
|
||||
<td>
|
||||
<span>{{ methodLabel(inc.match_method) }}</span>
|
||||
@if (inc.matched_txn_id) {
|
||||
<div class="sub">Txn #{{ inc.matched_txn_id }}</div>
|
||||
}
|
||||
</td>
|
||||
<td>
|
||||
<span class="i4c-badge" [class]="outcomeClass(inc.outcome)">{{ inc.outcome || 'PENDING' }}</span>
|
||||
<div class="sub">Code: {{ inc.status_code || '-' }}</div>
|
||||
</td>
|
||||
<td class="num">
|
||||
@if (holdFor(inc.id); as hold) {
|
||||
₹{{ hold.amount_held | number: '1.2-2' }}
|
||||
@if (hold.shortfall > 0) {
|
||||
<div class="sub err-text">Short: ₹{{ hold.shortfall | number: '1.2-2' }}</div>
|
||||
}
|
||||
} @else {
|
||||
-
|
||||
}
|
||||
</td>
|
||||
<td>{{ inc.remarks || '-' }}</td>
|
||||
</tr>
|
||||
}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Section 6 Findings -->
|
||||
<div class="i4c-card">
|
||||
<div class="i4c-card__head">
|
||||
<fa-icon icon="paper-plane"></fa-icon>
|
||||
<span>Entries Reported to I4C (Section 6 Compliance)</span>
|
||||
</div>
|
||||
<div class="i4c-card__body i4c-table-wrap">
|
||||
<table class="i4c-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Incident</th>
|
||||
<th>Transaction Type</th>
|
||||
<th class="num">Amount (₹)</th>
|
||||
<th class="num">Disputed (₹)</th>
|
||||
<th>Status Code</th>
|
||||
<th>Actions</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
@for (f of data.findings; track f.id) {
|
||||
<tr>
|
||||
<td>
|
||||
<b>{{ f.incident_id }}</b>
|
||||
</td>
|
||||
<td>{{ f.txn_type }} ({{ f.txn_type_id }})</td>
|
||||
<td class="num">₹{{ f.amount | number: '1.2-2' }}</td>
|
||||
<td class="num">₹{{ f.disputed_amount | number: '1.2-2' }}</td>
|
||||
<td>
|
||||
<span class="i4c-badge">{{ f.status_code }}</span>
|
||||
</td>
|
||||
<td>
|
||||
<button class="table-action-btn" (click)="openEntry = openEntry === f.id ? null : f.id">
|
||||
<fa-icon [icon]="openEntry === f.id ? 'eye-slash' : 'eye'"></fa-icon>
|
||||
<span>{{ openEntry === f.id ? 'Hide JSON' : 'View JSON' }}</span>
|
||||
</button>
|
||||
</td>
|
||||
</tr>
|
||||
@if (openEntry === f.id) {
|
||||
<tr class="finding-json-row">
|
||||
<td colspan="6" class="finding-json-cell">
|
||||
<div class="finding-json-box">
|
||||
<div class="finding-json-head">
|
||||
<span> <fa-icon icon="code"></fa-icon> Finding #{{ f.id }} Payload </span>
|
||||
<button type="button" class="copy-chip" (click)="copyAck(pretty(f.entry_json))">
|
||||
<fa-icon icon="copy"></fa-icon>
|
||||
<span>Copy</span>
|
||||
</button>
|
||||
</div>
|
||||
<pre class="i4c-json">{{ pretty(f.entry_json) }}</pre>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
}
|
||||
} @empty {
|
||||
<tr>
|
||||
<td colspan="6" class="i4c-empty">No section 6 findings reported yet.</td>
|
||||
</tr>
|
||||
}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Outbox Delivery -->
|
||||
<div class="i4c-card">
|
||||
<div class="i4c-card__head">
|
||||
<fa-icon icon="history"></fa-icon>
|
||||
<span>Delivery to I4C Outbox Queue</span>
|
||||
</div>
|
||||
<div class="i4c-card__body i4c-table-wrap">
|
||||
<table class="i4c-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Created At</th>
|
||||
<th>Status</th>
|
||||
<th class="num">Attempts</th>
|
||||
<th>HTTP Code</th>
|
||||
<th>Sent Timestamp</th>
|
||||
<th>Error</th>
|
||||
<th></th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
@for (o of data.outbox; track o.id) {
|
||||
<tr>
|
||||
<td>{{ o.created_at | date: 'dd MMM, HH:mm:ss' }}</td>
|
||||
<td>
|
||||
<span class="i4c-badge">{{ o.status }}</span>
|
||||
</td>
|
||||
<td class="num">{{ o.attempts }}</td>
|
||||
<td>
|
||||
<span class="font-mono">{{ o.http_status ?? '-' }}</span>
|
||||
</td>
|
||||
<td>{{ o.sent_at ? (o.sent_at | date: 'dd MMM, HH:mm:ss') : '-' }}</td>
|
||||
<td class="err-text">{{ o.last_error || '-' }}</td>
|
||||
<td>
|
||||
@if (o.status === 'FAILED') {
|
||||
<button mat-stroked-button color="warn" (click)="retry(o.id)" [disabled]="busy">
|
||||
<fa-icon icon="redo"></fa-icon> Retry Send
|
||||
</button>
|
||||
}
|
||||
</td>
|
||||
</tr>
|
||||
} @empty {
|
||||
<tr>
|
||||
<td colspan="7" class="i4c-empty">No response written to outbox yet.</td>
|
||||
</tr>
|
||||
}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Raw Payloads Accordion -->
|
||||
<div class="i4c-card">
|
||||
<div class="i4c-card__head">
|
||||
<fa-icon icon="code"></fa-icon>
|
||||
<span>Cryptographic & Raw Payloads Inspection</span>
|
||||
<span class="i4c-toolbar__spacer"></span>
|
||||
<button mat-button (click)="showRaw = !showRaw">
|
||||
<fa-icon [icon]="showRaw ? 'eye-slash' : 'eye'"></fa-icon>
|
||||
<span>{{ showRaw ? 'Hide Payloads' : 'Inspect Payloads' }}</span>
|
||||
</button>
|
||||
</div>
|
||||
@if (showRaw) {
|
||||
<div class="i4c-card__body payloads">
|
||||
<div>
|
||||
<div class="sub-title">Received from I4C Gateway (Sensitive credentials and PAN masked)</div>
|
||||
<pre class="i4c-json">{{ pretty(data.raw_payload) }}</pre>
|
||||
</div>
|
||||
@for (o of data.outbox; track o.id) {
|
||||
<div>
|
||||
<div class="sub-title">Response #{{ o.id }} Sent to I4C</div>
|
||||
<pre class="i4c-json">{{ pretty(o.payload) }}</pre>
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
@@ -0,0 +1,69 @@
|
||||
/**
|
||||
* Copyright since 2025 Mifos Initiative
|
||||
*
|
||||
* This Source Code Form is subject to the terms of the Mozilla Public
|
||||
* License, v. 2.0. If a copy of the MPL was not distributed with this
|
||||
* file, You can obtain one at http://mozilla.org/MPL/2.0/.
|
||||
*/
|
||||
|
||||
@use '../i4c' as *;
|
||||
|
||||
@include i4c-skin;
|
||||
|
||||
.ack-badge-strip {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
padding: 3px 10px;
|
||||
border-radius: 9999px;
|
||||
background: #f1f5f9;
|
||||
border: 1px solid #e2e8f0;
|
||||
|
||||
.label {
|
||||
font-size: 10px;
|
||||
font-weight: 600;
|
||||
color: #64748b;
|
||||
}
|
||||
|
||||
.val {
|
||||
font-size: 12px;
|
||||
color: #0f172a;
|
||||
}
|
||||
}
|
||||
|
||||
.text-accent {
|
||||
color: #2563eb;
|
||||
}
|
||||
|
||||
.cat-chip {
|
||||
display: inline-block;
|
||||
padding: 1px 6px;
|
||||
border-radius: 4px;
|
||||
background: #f1f5f9;
|
||||
font-size: 10px;
|
||||
color: #475569;
|
||||
}
|
||||
|
||||
.sub {
|
||||
font-size: 10.5px;
|
||||
color: #64748b;
|
||||
margin-top: 1px;
|
||||
}
|
||||
|
||||
.sub-title {
|
||||
font-size: 11.5px;
|
||||
font-weight: 600;
|
||||
color: #475569;
|
||||
margin-bottom: 4px;
|
||||
}
|
||||
|
||||
.err-text {
|
||||
color: #dc2626;
|
||||
font-size: 11px;
|
||||
}
|
||||
|
||||
.payloads {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 12px;
|
||||
}
|
||||
@@ -0,0 +1,166 @@
|
||||
/**
|
||||
* 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 { ActivatedRoute, RouterLink } from '@angular/router';
|
||||
import { MatButtonModule } from '@angular/material/button';
|
||||
import { MatProgressBarModule } from '@angular/material/progress-bar';
|
||||
import { MatSnackBar } from '@angular/material/snack-bar';
|
||||
import { FaIconComponent } from '@fortawesome/angular-fontawesome';
|
||||
import { I4cService, i4cErrorMessage } from '../i4c.service';
|
||||
|
||||
/**
|
||||
* One complaint from I4C end to end: what I4C sent, what the bank matched in its books, the holds it placed, every
|
||||
* entry it reported back, and each attempt to deliver that report.
|
||||
*/
|
||||
@Component({
|
||||
selector: 'mifosx-i4c-request-detail',
|
||||
templateUrl: './i4c-request-detail.component.html',
|
||||
styleUrls: ['./i4c-request-detail.component.scss'],
|
||||
imports: [
|
||||
DatePipe,
|
||||
DecimalPipe,
|
||||
RouterLink,
|
||||
MatButtonModule,
|
||||
MatProgressBarModule,
|
||||
FaIconComponent
|
||||
]
|
||||
})
|
||||
export class I4cRequestDetailComponent implements OnInit {
|
||||
private i4c = inject(I4cService);
|
||||
private route = inject(ActivatedRoute);
|
||||
private snackBar = inject(MatSnackBar);
|
||||
|
||||
data: any = null;
|
||||
loading = false;
|
||||
busy = false;
|
||||
error = '';
|
||||
showRaw = false;
|
||||
openEntry: number | null = null;
|
||||
|
||||
ngOnInit(): void {
|
||||
this.load();
|
||||
}
|
||||
|
||||
get id(): number {
|
||||
return Number(this.route.snapshot.paramMap.get('id'));
|
||||
}
|
||||
|
||||
load(): void {
|
||||
this.loading = true;
|
||||
this.error = '';
|
||||
this.i4c.request(this.id).subscribe({
|
||||
next: (d) => {
|
||||
this.data = d;
|
||||
this.loading = false;
|
||||
},
|
||||
error: (e) => {
|
||||
this.error = i4cErrorMessage(e);
|
||||
this.loading = false;
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
get canReprocess(): boolean {
|
||||
return this.data?.status === 'RETRY' || this.data?.status === 'RECEIVED';
|
||||
}
|
||||
|
||||
reprocess(): void {
|
||||
this.busy = true;
|
||||
this.i4c.reprocess(this.id).subscribe({
|
||||
next: (d) => {
|
||||
this.data = d;
|
||||
this.busy = false;
|
||||
this.snackBar.open('Complaint processed again.', 'OK', { duration: 3000 });
|
||||
},
|
||||
error: (e) => {
|
||||
this.busy = false;
|
||||
this.snackBar.open(i4cErrorMessage(e), 'Close', { duration: 6000 });
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
retry(outboxId: number): void {
|
||||
this.busy = true;
|
||||
this.i4c.retryOutbox(outboxId).subscribe({
|
||||
next: (r) => {
|
||||
this.busy = false;
|
||||
this.snackBar.open(
|
||||
r?.sent ? 'Response delivered to I4C.' : 'Retry queued; it will be sent again shortly.',
|
||||
'OK',
|
||||
{
|
||||
duration: 4000
|
||||
}
|
||||
);
|
||||
this.load();
|
||||
},
|
||||
error: (e) => {
|
||||
this.busy = false;
|
||||
this.snackBar.open(i4cErrorMessage(e), 'Close', { duration: 6000 });
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
findingsFor(incidentId: number): any[] {
|
||||
return (this.data?.findings || []).filter((f: any) => f.incident_id === incidentId);
|
||||
}
|
||||
|
||||
holdFor(incidentId: number): any {
|
||||
return (this.data?.holds || []).find((h: any) => h.incident_id === incidentId);
|
||||
}
|
||||
|
||||
pretty(json: string): string {
|
||||
try {
|
||||
return JSON.stringify(JSON.parse(json), null, 2);
|
||||
} catch {
|
||||
return json;
|
||||
}
|
||||
}
|
||||
|
||||
outcomeClass(outcome: string): string {
|
||||
switch (outcome) {
|
||||
case 'HELD':
|
||||
case 'TRACED':
|
||||
return 'i4c-badge--green';
|
||||
case 'PARTIALLY_HELD':
|
||||
case 'PROSPECTIVE_HOLD':
|
||||
return 'i4c-badge--amber';
|
||||
case 'NOT_FOUND':
|
||||
case 'NO_MATCH':
|
||||
case 'NOT_HOLDABLE':
|
||||
case 'ERROR':
|
||||
return 'i4c-badge--red';
|
||||
default:
|
||||
return '';
|
||||
}
|
||||
}
|
||||
|
||||
methodLabel(method: string): string {
|
||||
switch (method) {
|
||||
case 'RRN_FUND_TRANSFER':
|
||||
return 'RRN on outward transfer';
|
||||
case 'RRN_REFERENCE_TEXT':
|
||||
return 'RRN in transaction narration';
|
||||
case 'AMOUNT_DATE':
|
||||
return 'Amount + date (no RRN in CBS)';
|
||||
case 'NONE':
|
||||
return 'Not matched';
|
||||
default:
|
||||
return method || '-';
|
||||
}
|
||||
}
|
||||
|
||||
copyAck(text: string): void {
|
||||
if (!text) {
|
||||
return;
|
||||
}
|
||||
navigator.clipboard.writeText(text);
|
||||
this.snackBar.open(`Copied: ${text}`, 'OK', { duration: 2500 });
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,566 @@
|
||||
<!--
|
||||
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">
|
||||
<button class="filter-chip" [class.filter-chip--active]="status === ''" (click)="setStatus('')">
|
||||
<span>All Complaints</span>
|
||||
</button>
|
||||
@for (s of statuses; track s) {
|
||||
<button class="filter-chip" [class.filter-chip--active]="status === s" (click)="setStatus(s)">
|
||||
<span>{{ s }}</span>
|
||||
</button>
|
||||
}
|
||||
</div>
|
||||
|
||||
<span class="i4c-toolbar__spacer"></span>
|
||||
|
||||
<mat-form-field appearance="outline" subscriptSizing="dynamic" class="search-input">
|
||||
<mat-label>Search acknowledgement / account / job id</mat-label>
|
||||
<input matInput [(ngModel)]="search" (keyup.enter)="load(true)" placeholder="Type & hit enter..." />
|
||||
</mat-form-field>
|
||||
|
||||
<button mat-flat-button color="primary" class="search-btn" (click)="load(true)">
|
||||
<fa-icon icon="search"></fa-icon> Search
|
||||
</button>
|
||||
|
||||
<span class="count-tag">{{ total }} records</span>
|
||||
</div>
|
||||
|
||||
<div class="i4c-filter-bar">
|
||||
<mat-form-field appearance="outline" subscriptSizing="dynamic">
|
||||
<mat-label>Sub-category</mat-label>
|
||||
<input matInput [(ngModel)]="subCategory" (keyup.enter)="load(true)" (blur)="load(true)" placeholder="e.g. UPI" />
|
||||
</mat-form-field>
|
||||
<mat-form-field appearance="outline" subscriptSizing="dynamic">
|
||||
<mat-label>Layer</mat-label>
|
||||
<mat-select [(ngModel)]="layer" (selectionChange)="load(true)">
|
||||
<mat-option value="">All layers</mat-option>
|
||||
<mat-option value="0">Layer 0 (victim's account)</mat-option>
|
||||
<mat-option value="1">Layer 1+ (money landed here)</mat-option>
|
||||
</mat-select>
|
||||
</mat-form-field>
|
||||
<mat-form-field appearance="outline" subscriptSizing="dynamic">
|
||||
<mat-label>Response to I4C</mat-label>
|
||||
<mat-select [(ngModel)]="response" (selectionChange)="load(true)">
|
||||
<mat-option value="">Any</mat-option>
|
||||
<mat-option value="SENT">Sent</mat-option>
|
||||
<mat-option value="PENDING">Pending / sending</mat-option>
|
||||
<mat-option value="FAILED">Failed</mat-option>
|
||||
<mat-option value="NONE">Not yet created</mat-option>
|
||||
</mat-select>
|
||||
</mat-form-field>
|
||||
<mifosx-i4c-date-range label="Received" [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>
|
||||
}
|
||||
|
||||
<!-- Complaints Data Table -->
|
||||
<div class="i4c-card">
|
||||
<div class="i4c-card__head">
|
||||
<fa-icon icon="inbox"></fa-icon>
|
||||
<span>I4C-MHA Cyber Fraud Complaints Log</span>
|
||||
<span class="head-hint">Click any complaint to open modern detail popup window</span>
|
||||
</div>
|
||||
|
||||
<div class="i4c-table-wrap">
|
||||
<table class="i4c-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Received</th>
|
||||
<th>Acknowledgement No.</th>
|
||||
<th>Sub-Category</th>
|
||||
<th>Target Account</th>
|
||||
<th class="num">Layer</th>
|
||||
<th class="num">Incidents</th>
|
||||
<th class="num">Held (₹)</th>
|
||||
<th>Status</th>
|
||||
<th>I4C Response</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
@for (row of rows; track row.id) {
|
||||
<tr class="clickable" (click)="openModal(row)">
|
||||
<td>
|
||||
<span class="date-main">{{ row.received_at | date: 'dd MMM yyyy' }}</span>
|
||||
<div class="sub">{{ row.received_at | date: 'HH:mm:ss' }}</div>
|
||||
</td>
|
||||
<td>
|
||||
<div class="ack-cell">
|
||||
<span class="ack-no">{{ row.acknowledgement_no }}</span>
|
||||
<button
|
||||
class="copy-chip"
|
||||
(click)="copyAck(row.acknowledgement_no, 'ack-' + row.id, $event)"
|
||||
title="Copy Ack No."
|
||||
>
|
||||
<fa-icon icon="copy"></fa-icon>
|
||||
<span>{{ copiedId === 'ack-' + row.id ? 'Copied' : 'Copy' }}</span>
|
||||
</button>
|
||||
</div>
|
||||
<div class="sub">Job: {{ row.job_id }}</div>
|
||||
</td>
|
||||
<td>
|
||||
<span class="cat-pill">{{ row.sub_category || 'N/A' }}</span>
|
||||
</td>
|
||||
<td>
|
||||
<b>{{ row.payer_account_number }}</b>
|
||||
</td>
|
||||
<td class="num font-semibold">{{ row.max_layer }}</td>
|
||||
<td class="num">{{ row.incidents }}</td>
|
||||
<td class="num font-bold">₹{{ row.amount_held | number: '1.2-2' }}</td>
|
||||
<td>
|
||||
<span class="i4c-badge" [class]="statusClass(row.status)">{{ row.status }}</span>
|
||||
@if (row.processing_error) {
|
||||
<div class="sub err-text" [title]="row.processing_error">{{ row.processing_error }}</div>
|
||||
}
|
||||
</td>
|
||||
<td>
|
||||
<span class="i4c-badge" [class]="responseClass(row.response_status)">
|
||||
{{ row.response_status || 'NOT YET' }}
|
||||
</span>
|
||||
</td>
|
||||
</tr>
|
||||
} @empty {
|
||||
<tr>
|
||||
<td colspan="9" class="i4c-empty">
|
||||
<div class="empty-icon"><fa-icon icon="inbox"></fa-icon></div>
|
||||
<div>No complaints from I4C matched your query.</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="Complaints pages"
|
||||
></mat-paginator>
|
||||
</div>
|
||||
|
||||
<!-- ========================================================
|
||||
MODERN POPUP WINDOW: COMPLAINT DETAILS MODAL
|
||||
======================================================== -->
|
||||
@if (selectedComplaint) {
|
||||
<div class="i4c-modal-backdrop" (click)="closeModal()">
|
||||
<div class="i4c-modal-window i4c-modal-window--wide" (click)="$event.stopPropagation()">
|
||||
<!-- Header -->
|
||||
<div class="i4c-modal-header">
|
||||
<div class="i4c-modal-header__title">
|
||||
<fa-icon icon="inbox"></fa-icon>
|
||||
<div>
|
||||
<div class="modal-title-row">
|
||||
<span>Ack #{{ selectedComplaint.acknowledgement_no }}</span>
|
||||
<span class="i4c-badge" [class]="statusClass(selectedComplaint.status)">{{
|
||||
selectedComplaint.status
|
||||
}}</span>
|
||||
<button class="copy-chip" (click)="copyAck(selectedComplaint.acknowledgement_no, 'modal-ack')">
|
||||
<fa-icon icon="copy"></fa-icon>
|
||||
<span>{{ copiedId === 'modal-ack' ? 'Copied' : 'Copy Ack' }}</span>
|
||||
</button>
|
||||
</div>
|
||||
<div class="i4c-modal-header__sub">
|
||||
Job ID: {{ selectedComplaint.job_id }} • Received:
|
||||
{{ selectedComplaint.received_at | date: 'dd MMM yyyy, HH:mm:ss' }}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="i4c-modal-header__actions">
|
||||
<button class="modal-close-btn" (click)="closeModal()" aria-label="Close">
|
||||
<fa-icon icon="times"></fa-icon>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Navigation Tabs inside the Modal -->
|
||||
<div class="i4c-modal-tabs">
|
||||
<button class="modal-tab" [class.modal-tab--active]="modalTab === 'overview'" (click)="modalTab = 'overview'">
|
||||
<fa-icon icon="info-circle"></fa-icon> Overview & Account
|
||||
</button>
|
||||
<button
|
||||
class="modal-tab"
|
||||
[class.modal-tab--active]="modalTab === 'incidents'"
|
||||
(click)="modalTab = 'incidents'"
|
||||
>
|
||||
<fa-icon icon="tasks"></fa-icon> Incidents ({{
|
||||
selectedComplaint.incidents?.length || selectedComplaint.incidents || 0
|
||||
}})
|
||||
</button>
|
||||
<button class="modal-tab" [class.modal-tab--active]="modalTab === 'findings'" (click)="modalTab = 'findings'">
|
||||
<fa-icon icon="list-alt"></fa-icon> Section 6 Findings ({{ selectedComplaint.findings?.length || 0 }})
|
||||
</button>
|
||||
<button class="modal-tab" [class.modal-tab--active]="modalTab === 'outbox'" (click)="modalTab = 'outbox'">
|
||||
<fa-icon icon="paper-plane"></fa-icon> Outbox Responses ({{ selectedComplaint.outbox?.length || 0 }})
|
||||
</button>
|
||||
<button class="modal-tab" [class.modal-tab--active]="modalTab === 'payloads'" (click)="modalTab = 'payloads'">
|
||||
<fa-icon icon="code"></fa-icon> Raw JSON
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- Modal Body -->
|
||||
<div class="i4c-modal-body">
|
||||
@if (detailLoading) {
|
||||
<mat-progress-bar mode="indeterminate"></mat-progress-bar>
|
||||
}
|
||||
@if (detailError) {
|
||||
<div class="i4c-error">{{ detailError }}</div>
|
||||
}
|
||||
|
||||
<!-- TAB 1: OVERVIEW -->
|
||||
@if (modalTab === 'overview') {
|
||||
<div class="i4c-grid-2">
|
||||
<div class="i4c-card">
|
||||
<div class="i4c-card__head"><fa-icon icon="user-shield"></fa-icon> Target Customer Account</div>
|
||||
<div class="i4c-card__body">
|
||||
<dl class="i4c-kv">
|
||||
<dt>Account Number</dt>
|
||||
<dd>
|
||||
<b>{{ selectedComplaint.payer_account_number }}</b>
|
||||
</dd>
|
||||
|
||||
<dt>Sub-Category</dt>
|
||||
<dd>{{ selectedComplaint.sub_category }}</dd>
|
||||
|
||||
<dt>Complainant Bank</dt>
|
||||
<dd>
|
||||
{{ selectedComplaint.payer_bank }}
|
||||
{{ selectedComplaint.payer_bank_code ? '(' + selectedComplaint.payer_bank_code + ')' : '' }}
|
||||
</dd>
|
||||
|
||||
<dt>Payment Mode</dt>
|
||||
<dd>
|
||||
{{ selectedComplaint.mode_of_payment || 'N/A' }}
|
||||
{{ selectedComplaint.transaction_type ? '/ ' + selectedComplaint.transaction_type : '' }}
|
||||
</dd>
|
||||
|
||||
<dt>Wallet Reference</dt>
|
||||
<dd>{{ selectedComplaint.wallet || 'None' }}</dd>
|
||||
|
||||
<dt>State / District</dt>
|
||||
<dd>{{ selectedComplaint.state }} / {{ selectedComplaint.district }}</dd>
|
||||
|
||||
<dt>Source IP</dt>
|
||||
<dd>{{ selectedComplaint.source_ip || '-' }}</dd>
|
||||
</dl>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="i4c-card">
|
||||
<div class="i4c-card__head"><fa-icon icon="sync"></fa-icon> Processing Status</div>
|
||||
<div class="i4c-card__body">
|
||||
<dl class="i4c-kv">
|
||||
<dt>Processing Status</dt>
|
||||
<dd>
|
||||
<span class="i4c-badge" [class]="statusClass(selectedComplaint.status)">{{
|
||||
selectedComplaint.status
|
||||
}}</span>
|
||||
</dd>
|
||||
|
||||
<dt>Processing Attempts</dt>
|
||||
<dd>{{ selectedComplaint.processing_attempts ?? 1 }}</dd>
|
||||
|
||||
<dt>Processed Timestamp</dt>
|
||||
<dd>
|
||||
{{
|
||||
selectedComplaint.processed_at
|
||||
? (selectedComplaint.processed_at | date: 'dd MMM yyyy, HH:mm:ss')
|
||||
: 'Pending processing'
|
||||
}}
|
||||
</dd>
|
||||
|
||||
<dt>Total Held Amount</dt>
|
||||
<dd>
|
||||
<b class="amount-accent">₹{{ selectedComplaint.amount_held | number: '1.2-2' }}</b>
|
||||
</dd>
|
||||
|
||||
<dt>Response to I4C</dt>
|
||||
<dd>
|
||||
<span class="i4c-badge" [class]="responseClass(selectedComplaint.response_status)">
|
||||
{{ selectedComplaint.response_status || 'NOT YET SENT' }}
|
||||
</span>
|
||||
</dd>
|
||||
|
||||
@if (selectedComplaint.processing_error) {
|
||||
<dt>Last Error</dt>
|
||||
<dd class="err-text">{{ selectedComplaint.processing_error }}</dd>
|
||||
}
|
||||
</dl>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
|
||||
<!-- TAB 2: INCIDENTS -->
|
||||
@if (modalTab === 'incidents') {
|
||||
<div class="i4c-card">
|
||||
<div class="i4c-table-wrap">
|
||||
<table class="i4c-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>#</th>
|
||||
<th>RRN</th>
|
||||
<th>Date</th>
|
||||
<th class="num">Amount (₹)</th>
|
||||
<th class="num">Disputed (₹)</th>
|
||||
<th class="num">Layer</th>
|
||||
<th>Matched By</th>
|
||||
<th>Outcome</th>
|
||||
<th class="num">Held (₹)</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
@for (inc of selectedComplaint.incidents; track inc.id) {
|
||||
<tr>
|
||||
<td>
|
||||
<b>{{ inc.seq_no }}</b>
|
||||
</td>
|
||||
<td>{{ inc.rrn }}</td>
|
||||
<td>{{ inc.transaction_date }} {{ inc.transaction_time }}</td>
|
||||
<td class="num">{{ inc.amount | number: '1.2-2' }}</td>
|
||||
<td class="num">{{ inc.disputed_amount | number: '1.2-2' }}</td>
|
||||
<td class="num">{{ inc.layer }}</td>
|
||||
<td>
|
||||
<span>{{ inc.match_method || 'EXACT' }}</span>
|
||||
@if (inc.matched_txn_id) {
|
||||
<div class="sub">Txn #{{ inc.matched_txn_id }}</div>
|
||||
}
|
||||
</td>
|
||||
<td>
|
||||
<span class="i4c-badge" [class]="outcomeClass(inc.outcome)">{{
|
||||
inc.outcome || 'PENDING'
|
||||
}}</span>
|
||||
<div class="sub">Code: {{ inc.status_code || '-' }}</div>
|
||||
</td>
|
||||
<td class="num">
|
||||
@if (holdFor(inc.id); as hold) {
|
||||
₹{{ hold.amount_held | number: '1.2-2' }}
|
||||
@if (hold.shortfall > 0) {
|
||||
<div class="sub err-text">Short ₹{{ hold.shortfall | number: '1.2-2' }}</div>
|
||||
}
|
||||
} @else {
|
||||
-
|
||||
}
|
||||
</td>
|
||||
</tr>
|
||||
} @empty {
|
||||
<tr>
|
||||
<td colspan="9" class="i4c-empty">No incident details found.</td>
|
||||
</tr>
|
||||
}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
|
||||
<!-- TAB 3: FINDINGS (SECTION 6) -->
|
||||
@if (modalTab === 'findings') {
|
||||
<div class="i4c-card">
|
||||
<div class="i4c-table-wrap">
|
||||
<table class="i4c-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Incident ID</th>
|
||||
<th>Transaction Type</th>
|
||||
<th class="num">Amount (₹)</th>
|
||||
<th class="num">Disputed (₹)</th>
|
||||
<th>Status Code</th>
|
||||
<th>Raw JSON</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
@for (f of selectedComplaint.findings; track f.id) {
|
||||
<tr>
|
||||
<td>{{ f.incident_id }}</td>
|
||||
<td>{{ f.txn_type }} ({{ f.txn_type_id }})</td>
|
||||
<td class="num">₹{{ f.amount | number: '1.2-2' }}</td>
|
||||
@if (f.disputed_amount === null || f.disputed_amount === undefined) {
|
||||
<td
|
||||
class="num muted-cell"
|
||||
title="The specification has no disputed amount in a Transaction Put on Hold entry"
|
||||
>
|
||||
-
|
||||
</td>
|
||||
} @else {
|
||||
<td class="num">₹{{ f.disputed_amount | number: '1.2-2' }}</td>
|
||||
}
|
||||
<td>
|
||||
<span
|
||||
class="i4c-badge"
|
||||
[class]="statusCodeClass(f.status_code)"
|
||||
[title]="statusCodeMeaning(f.status_code)"
|
||||
>
|
||||
{{ f.status_code }} · {{ statusCodeMeaning(f.status_code) }}
|
||||
</span>
|
||||
</td>
|
||||
<td>
|
||||
<button class="table-action-btn" (click)="toggleFindingJson(f.id)">
|
||||
{{ openFindingJsonId === f.id ? 'Hide JSON' : 'View JSON' }}
|
||||
</button>
|
||||
</td>
|
||||
</tr>
|
||||
@if (openFindingJsonId === f.id) {
|
||||
<tr class="finding-json-row">
|
||||
<td colspan="6" class="finding-json-cell">
|
||||
<div class="finding-json-box">
|
||||
<div class="finding-json-head">
|
||||
<span> <fa-icon icon="code"></fa-icon> Finding #{{ f.id }} Payload </span>
|
||||
<button
|
||||
type="button"
|
||||
class="copy-chip"
|
||||
(click)="copyAck(prettyJson(f.entry_json), 'finding-' + f.id, $event)"
|
||||
>
|
||||
<fa-icon icon="copy"></fa-icon>
|
||||
<span>{{ copiedId === 'finding-' + f.id ? 'Copied!' : 'Copy' }}</span>
|
||||
</button>
|
||||
</div>
|
||||
<pre class="i4c-json">{{ prettyJson(f.entry_json) }}</pre>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
}
|
||||
} @empty {
|
||||
<tr>
|
||||
<td colspan="6" class="i4c-empty">No section 6 findings reported yet.</td>
|
||||
</tr>
|
||||
}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
|
||||
<!-- TAB 4: OUTBOX RESPONSES -->
|
||||
@if (modalTab === 'outbox') {
|
||||
<div class="i4c-card">
|
||||
<div class="i4c-table-wrap">
|
||||
<table class="i4c-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Created</th>
|
||||
<th title="ACK: separate acknowledgement callback. RESPONSE: the Section 6 findings.">Type</th>
|
||||
<th>Status</th>
|
||||
<th class="num">Attempts</th>
|
||||
<th>HTTP Code</th>
|
||||
<th>Sent Time</th>
|
||||
<th>Error</th>
|
||||
<th>Action</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
@for (o of selectedComplaint.outbox; track o.id) {
|
||||
<tr>
|
||||
<td>{{ o.created_at | date: 'dd MMM, HH:mm:ss' }}</td>
|
||||
<td>
|
||||
<span class="i4c-badge" [class.i4c-badge--blue]="o.message_type === 'ACK'">{{
|
||||
o.message_type === 'ACK' ? 'ACK' : 'RESPONSE'
|
||||
}}</span>
|
||||
</td>
|
||||
<td>
|
||||
<span class="i4c-badge" [class]="responseClass(o.status)">{{ o.status }}</span>
|
||||
</td>
|
||||
<td class="num">{{ o.attempts }}</td>
|
||||
<td>{{ o.http_status ?? '-' }}</td>
|
||||
<td>{{ o.sent_at ? (o.sent_at | date: 'dd MMM, HH:mm:ss') : '-' }}</td>
|
||||
<td class="err-text">{{ o.last_error }}</td>
|
||||
<td>
|
||||
@if (o.status === 'FAILED') {
|
||||
<button
|
||||
class="table-action-btn"
|
||||
(click)="retryOutboxItem(o.id)"
|
||||
[disabled]="retryingOutboxId === o.id"
|
||||
>
|
||||
<fa-icon
|
||||
icon="redo"
|
||||
[animation]="retryingOutboxId === o.id ? 'spin' : undefined"
|
||||
></fa-icon>
|
||||
Retry
|
||||
</button>
|
||||
}
|
||||
</td>
|
||||
</tr>
|
||||
} @empty {
|
||||
<tr>
|
||||
<td colspan="8" class="i4c-empty">No outbox delivery records yet.</td>
|
||||
</tr>
|
||||
}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
|
||||
<!-- TAB 5: RAW PAYLOADS -->
|
||||
@if (modalTab === 'payloads') {
|
||||
<div class="payloads-view">
|
||||
<div class="payload-box">
|
||||
<div class="payload-box__title">
|
||||
<fa-icon icon="inbox"></fa-icon>
|
||||
<span>Payload Received from I4C Gateway (Masked)</span>
|
||||
<button class="copy-chip" (click)="copyAck(prettyJson(selectedComplaint.raw_payload), 'payload-in')">
|
||||
<fa-icon icon="copy"></fa-icon> Copy
|
||||
</button>
|
||||
</div>
|
||||
<pre class="i4c-json">{{ prettyJson(selectedComplaint.raw_payload) }}</pre>
|
||||
</div>
|
||||
|
||||
@for (o of selectedComplaint.outbox; track o.id) {
|
||||
<div class="payload-box">
|
||||
<div class="payload-box__title">
|
||||
<fa-icon icon="paper-plane"></fa-icon>
|
||||
<span>Response #{{ o.id }} Delivered to I4C</span>
|
||||
<button class="copy-chip" (click)="copyAck(prettyJson(o.payload), 'payload-out-' + o.id)">
|
||||
<fa-icon icon="copy"></fa-icon> Copy
|
||||
</button>
|
||||
</div>
|
||||
<pre class="i4c-json">{{ prettyJson(o.payload) }}</pre>
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
|
||||
<!-- Footer -->
|
||||
<div class="i4c-modal-footer">
|
||||
@if (selectedComplaint.status === 'RETRY' || selectedComplaint.status === 'PROCESSING') {
|
||||
<button
|
||||
mat-flat-button
|
||||
color="primary"
|
||||
(click)="reprocessModal(selectedComplaint.id)"
|
||||
[disabled]="reprocessing"
|
||||
>
|
||||
<fa-icon icon="redo" [animation]="reprocessing ? 'spin' : undefined"></fa-icon> Process Now
|
||||
</button>
|
||||
}
|
||||
<button mat-stroked-button (click)="openFullPage(selectedComplaint.id)">
|
||||
<fa-icon icon="external-link-alt"></fa-icon> Open in Full Page
|
||||
</button>
|
||||
<button class="footer-btn-close" (click)="closeModal()">Close</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user