diff --git a/angular.json b/angular.json index 341741725..51c068055 100644 --- a/angular.json +++ b/angular.json @@ -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", diff --git a/package-lock.json b/package-lock.json index 592a9c0cf..066a39243 100644 --- a/package-lock.json +++ b/package-lock.json @@ -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", diff --git a/package.json b/package.json index aad5a8105..ae0ef07c2 100644 --- a/package.json +++ b/package.json @@ -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", diff --git a/src/app/account-transfers/view-account-transfer/view-account-transfer.component.html b/src/app/account-transfers/view-account-transfer/view-account-transfer.component.html index 9d1b9ae34..cac9ad40d 100644 --- a/src/app/account-transfers/view-account-transfer/view-account-transfer.component.html +++ b/src/app/account-transfers/view-account-transfer/view-account-transfer.component.html @@ -7,16 +7,6 @@ -->
-
- @if (!viewAccountTransferData.reversed) { - - - - } -
-
diff --git a/src/app/accounts/accounts-routing.module.ts b/src/app/accounts/accounts-routing.module.ts index 3654ae7de..228a5357f 100644 --- a/src/app/accounts/accounts-routing.module.ts +++ b/src/app/accounts/accounts-routing.module.ts @@ -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' } + } + ] } ] } diff --git a/src/app/accounts/accounts.service.ts b/src/app/accounts/accounts.service.ts index ddc9efadc..2f25c2917 100644 --- a/src/app/accounts/accounts.service.ts +++ b/src/app/accounts/accounts.service.ts @@ -85,6 +85,11 @@ export class AccountsService { return this.http.get('/fixeddepositaccounts/maturity-dashboard'); } + /** RD Dashboard: running RDs with their installment position, closures and penalty income for the month. */ + getRecurringDepositDashboard(): Observable { + return this.http.get('/recurringdepositaccounts/dashboard'); + } + getRecurringDepositAccounts(params: AccountListParams = {}): Observable { let httpParams = new HttpParams() .set('paged', 'true') diff --git a/src/app/app.module.ts b/src/app/app.module.ts index b95fd0ad1..eba0b2e26 100644 --- a/src/app/app.module.ts +++ b/src/app/app.module.ts @@ -64,6 +64,7 @@ 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'; @@ -96,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 { @@ -178,6 +180,7 @@ export function HttpLoaderFactory(http: HttpClient) { DeafModule, SavingsDailyInterestModule, FdDashboardModule, + RdDashboardModule, DicgcModule, FiuModule, GlReportsModule, @@ -195,6 +198,7 @@ export function HttpLoaderFactory(http: HttpClient) { AppRoutingModule, NotFoundComponent, CallbackComponent, + LoadingScreenComponent, OAuthModule.forRoot() ], providers: [ diff --git a/src/app/bank-calendar/bank-calendar.component.html b/src/app/bank-calendar/bank-calendar.component.html index b7f912e0f..d4996f7fa 100644 --- a/src/app/bank-calendar/bank-calendar.component.html +++ b/src/app/bank-calendar/bank-calendar.component.html @@ -44,8 +44,11 @@
-
{{ overallStats.sat2nd4th }}
-
2nd & 4th Sat
+
{{ overallStats.sat2nd4th + overallStats.weeklyOff }}
+
+ {{ saturdayIsWorkingDay ? '2nd & 4th Sat' : 'Saturdays' + }}{{ overallStats.weeklyOff > 0 ? ' / Weekly Off' : '' }} +
@@ -69,7 +72,11 @@
Working Day
Sunday
-
2nd / 4th Saturday
+
+ + {{ saturdayIsWorkingDay ? '2nd / 4th Saturday' : 'Saturday' + }}{{ overallStats.weeklyOff > 0 ? ' / Weekly Off' : '' }} +
Holiday
Today
@@ -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) { {{ month.holidays }} Holiday{{ month.holidays > 1 ? 's' : '' }} } - {{ month.sundays }} Sun + @if (month.sundays > 0) { + {{ month.sundays }} Sun + } @if (month.secondFourthSat > 0) { {{ month.secondFourthSat }} Sat Off } + @if (month.weeklyOff > 0) { + {{ month.weeklyOff }} Weekly Off + }
} diff --git a/src/app/bank-calendar/bank-calendar.component.ts b/src/app/bank-calendar/bank-calendar.component.ts index bfcacb185..9102f10d6 100644 --- a/src/app/bank-calendar/bank-calendar.component.ts +++ b/src/app/bank-calendar/bank-calendar.component.ts @@ -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 }; } diff --git a/src/app/cashier/branch-cash-position/branch-cash-position.component.html b/src/app/cashier/branch-cash-position/branch-cash-position.component.html new file mode 100644 index 000000000..d8f476a2e --- /dev/null +++ b/src/app/cashier/branch-cash-position/branch-cash-position.component.html @@ -0,0 +1,114 @@ + + +
+
+
+
BRANCH OPERATIONS · BRANCH CASH POSITION
+

Branch Cash Position

+

+ 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. +

+
+
+ +
+ + As of Date + + + +
+ + @if (errorMsg) { +
{{ errorMsg }}
+ } + @if (loading) { +
Loading…
+ } + + @if (position; as p) { +
+
+ Branch{{ p.officeName }} +
+
+ Vault₹{{ p.vaultAmount | number: '1.2-2' }} +
+
+ Cashiers' drawers₹{{ p.cashiersAmount | number: '1.2-2' }} +
+
+ Bank total₹{{ p.totalAmount | number: '1.2-2' }} +
+
+ + @if (!p.vaultHasBreakdown) { +
+ 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. +
+ } + + + @if (rows.length > 0) { + + + + + + + + + + + + + + @for (d of rows; track d.denominationId) { + + + + + + + + + } + +
DenominationVaultCashiersBank total (count)Bank total (₹)Exchange today
+ ₹{{ d.denominationValue }} + {{ d.vaultCount }}{{ d.cashierCount }}{{ d.totalCount }}₹{{ d.totalAmount | number: '1.2-2' }} + {{ movement(d.exchangeNet) }} +
+ } +
+ +
+ + +
+ +

+ Exchange today 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. +

+ } +
diff --git a/src/app/cashier/branch-cash-position/branch-cash-position.component.scss b/src/app/cashier/branch-cash-position/branch-cash-position.component.scss new file mode 100644 index 000000000..d86edfbbc --- /dev/null +++ b/src/app/cashier/branch-cash-position/branch-cash-position.component.scss @@ -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; +} diff --git a/src/app/cashier/branch-cash-position/branch-cash-position.component.ts b/src/app/cashier/branch-cash-position/branch-cash-position.component.ts new file mode 100644 index 000000000..ef5e64330 --- /dev/null +++ b/src/app/cashier/branch-cash-position/branch-cash-position.component.ts @@ -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}`; + } +} diff --git a/src/app/cashier/branch-day/branch-day.component.html b/src/app/cashier/branch-day/branch-day.component.html index 57d49000e..9c13b940d 100644 --- a/src/app/cashier/branch-day/branch-day.component.html +++ b/src/app/cashier/branch-day/branch-day.component.html @@ -352,6 +352,51 @@
} + + @if (preview?.fdInterestDue?.length) { +
+
+ savings + Fixed deposit interest due today +
+ @for (d of preview.fdInterestDue; track d.productId) { +
+ + {{ d.productName }} · {{ d.fdType }} · {{ d.accountCount }} deposit(s) + @if (d.reportStatus === 'APPROVED' || d.reportStatus === 'POSTED') { + {{ d.reportStatus }} + } @else { + {{ d.reportStatus === 'NONE' ? 'NO REPORT' : d.reportStatus }} + } + + ₹{{ d.totalInterest | number: '1.2-2' }} +
+ } +
+ } + @if (preview?.fdInterestPending?.length) { +
+ warning +
+ The day cannot be closed yet. 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. +
+
+ } + @if (preview?.savingsInterestPending?.length) { +
+ warning +
+ The day cannot be closed yet. Savings interest is not approved for: + {{ preview.savingsInterestPending.join(', ') }}. Approve it under Reports → Interest Posting Reports, then + close the day. +
+
+ } + diff --git a/src/app/cashier/branch-day/branch-day.component.scss b/src/app/cashier/branch-day/branch-day.component.scss index 449dfced1..e8d2b209a 100644 --- a/src/app/cashier/branch-day/branch-day.component.scss +++ b/src/app/cashier/branch-day/branch-day.component.scss @@ -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 { diff --git a/src/app/cashier/cash-exchange-entry/cash-exchange-entry.component.html b/src/app/cashier/cash-exchange-entry/cash-exchange-entry.component.html index e08dc73b0..d46a2d0d6 100644 --- a/src/app/cashier/cash-exchange-entry/cash-exchange-entry.component.html +++ b/src/app/cashier/cash-exchange-entry/cash-exchange-entry.component.html @@ -22,50 +22,255 @@ - -
-
- {{ isAccountHolder ? 'check_box' : 'check_box_outline_blank' }} - Account Holder -
- - @if (isAccountHolder) { - - } @else { - person_outline Walk-in Customer - } - - @if (needsIdProof) { -
- warning - Amount ≥ ₹50,000 — Customer PAN / Aadhaar required (RBI) + + @if (cashier) { +
+
{{ cashierInitials }}
+
+
Cashier at the counter
+
{{ cashier.name || '—' }}
+
+ south Receives from customer + north Gives to customer +
- - } -
+
+
+ Counter{{ cashier.tellerName || '—' }} +
+
+ Branch{{ cashier.officeName || '—' }} +
+
+ Allocated today{{ + cashier.allocatedAmount === null ? '—' : '₹' + (cashier.allocatedAmount | number: '1.2-2') + }} +
+
+ Cash in hand₹{{ cashier.totalAmount | number: '1.2-2' }} +
+
+ + } @else if (noCashier) { +
+ person_off +
+
Your login is not linked to a cashier
+
+ 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. +
+
+
+ } + + +
+
+
+ groups +
+
Customer
+
Who is exchanging cash at the counter
+
+
+
+ + +
+
+ +
+ +
+ @if (customerMode === 'existing') { + @if (loadingCustomer) { +
+ autorenew +
Loading customer details…
+
+ } @else if (!selectedCustomer) { +
+ person_search +
No customer selected
+
Search by name, account no., PAN or Aadhaar
+ +
+ } @else { +
+
+
{{ customerInitials }}
+
+
{{ selectedCustomer.displayName }}
+
+ + {{ + selectedCustomer.kycStatus === 'Verified' ? 'verified_user' : 'gpp_maybe' + }} + KYC: {{ selectedCustomer.kycStatus }} + + @if (selectedCustomer.accountType) { + {{ selectedCustomer.accountType }} + } +
+
+
+ + +
+
+
+
+ CIF No.{{ selectedCustomer.cifNumber || '—' }} +
+
+ Client ID{{ selectedCustomer.clientId }} +
+
+ Mobile{{ selectedCustomer.mobileNo || '—' }} +
+
+ Branch{{ selectedCustomer.officeName || '—' }} +
+
+ Account{{ selectedCustomer.accountNo || 'No account' }} +
+
+ PAN on record + + {{ selectedCustomer.kycPan || 'Not on record' }} + @if (selectedCustomer.kycPan) { + {{ selectedCustomer.panVerified ? 'Verified' : 'Not verified' }} + } + +
+
+
+ } + } @else { +
+ + +
+ } +
+ + +
+
+ credit_card + PAN Card + {{ needsPan ? 'Mandatory' : 'Optional' }} +
+
+ + @if (panValue && !panFormatInvalid) { + check_circle + } @else if (panFormatInvalid) { + error + } +
+
+ @if (panFormatInvalid) { + PAN is 5 letters, 4 digits, 1 letter (e.g. ABCDE1234F). + } @else if (panFromKyc) { + link 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. + } +
+
+
+
+ + + @if (availableFor(d) === null) { + — + } @else { + In drawer {{ availableFor(d) }} + @if (netFor(d) !== 0) { + + {{ netFor(d) > 0 ? 'trending_up' : 'trending_down' }} + {{ drawerAfter(d) }} + ({{ netFor(d) > 0 ? '+' : '' }}{{ netFor(d) }}) + + } + } + +
@@ -78,7 +283,7 @@
Bank Notes
} @for (d of notes; track d.id) { -
+
{{ d.label }}
@@ -90,6 +295,9 @@ />
+ + + ₹{{ subtotal('r', d) | number: '1.2-2' }} @@ -99,7 +307,7 @@
Coins
} @for (d of coins; track d.id) { -
+
{{ d.label }}
@@ -111,6 +319,9 @@ />
+ + + ₹{{ subtotal('r', d) | number: '1.2-2' }} @@ -131,7 +342,7 @@
Bank Notes
} @for (d of notes; track d.id) { -
+
{{ d.label }}
@@ -141,8 +352,16 @@ [value]="countFor('g', d)" (input)="onChange('g', d, $any($event.target).value)" /> - +
+ + + ₹{{ subtotal('g', d) | number: '1.2-2' }} @@ -152,7 +371,7 @@
Coins
} @for (d of coins; track d.id) { -
+
{{ d.label }}
@@ -162,8 +381,16 @@ [value]="countFor('g', d)" (input)="onChange('g', d, $any($event.target).value)" /> - +
+ + + ₹{{ subtotal('g', d) | number: '1.2-2' }} @@ -173,6 +400,30 @@
+ + @if (impactRows.length) { +
+
inventory_2 Drawer after this exchange
+
+ @for (r of impactRows; track r.id) { + + {{ r.label }} + {{ r.before }} → {{ r.after }} + ({{ r.net > 0 ? '+' : '' }}{{ r.net }}) + + } +
+
+ The branch's total for each denomination moves by the same amount; the vault is not touched (Branch Ops → Branch + Cash Position). +
+
+ } +
Received₹{{ receivedTotal | number: '1.2-2' }} @@ -213,9 +464,15 @@
} - @if (needsIdProof && !customerIdProof.trim()) { + @if (panMissing) {
- badge Enter customer PAN / Aadhaar to proceed (amount ≥ ₹50,000). + badge Enter the customer's PAN to proceed (amount above ₹50,000). +
+ } + @if (!customerIdentified && !loadingCustomer) { +
+ person_search + {{ customerMode === 'existing' ? 'Search and select the customer.' : "Enter the walk-in customer's name." }}
} diff --git a/src/app/cashier/cash-exchange-entry/cash-exchange-entry.component.scss b/src/app/cashier/cash-exchange-entry/cash-exchange-entry.component.scss index 9c4277912..88903f1a0 100644 --- a/src/app/cashier/cash-exchange-entry/cash-exchange-entry.component.scss +++ b/src/app/cashier/cash-exchange-entry/cash-exchange-entry.component.scss @@ -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; diff --git a/src/app/cashier/cash-exchange-entry/cash-exchange-entry.component.ts b/src/app/cashier/cash-exchange-entry/cash-exchange-entry.component.ts index bede34b4b..98916e2bc 100644 --- a/src/app/cashier/cash-exchange-entry/cash-exchange-entry.component.ts +++ b/src/app/cashier/cash-exchange-entry/cash-exchange-entry.component.ts @@ -11,7 +11,15 @@ 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 { @@ -21,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 = { + 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', @@ -31,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[] = []; @@ -47,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(); positionLoaded = false; @@ -88,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; @@ -96,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; } @@ -129,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); @@ -147,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 { @@ -171,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') }; diff --git a/src/app/cashier/cash-history/cash-history.component.html b/src/app/cashier/cash-history/cash-history.component.html index 4e85528dd..943ff9323 100644 --- a/src/app/cashier/cash-history/cash-history.component.html +++ b/src/app/cashier/cash-history/cash-history.component.html @@ -11,25 +11,11 @@
CASHIER · HISTORY

Cash Movement History

-

All transfers, settlements, and inter-cashier movements. Reverse any active transaction.

+

All transfers, settlements, inter-cashier movements and cash exchanges.

- - @if (reversingId !== null) { -
- warning - Reverse #{{ reversingId }}? This cannot be undone. - - - -
- } - @if (loading) {
Loading…
} @@ -134,11 +120,6 @@ > visibility - @if (!r.isReversed) { - - }
@@ -187,6 +168,12 @@ Date {{ detail.row.date }} + @if (detail.row.type === 'exchange') { +
+ Customer PAN + {{ detail.row.panNumber || '—' }} +
+ }
Total Amount ₹{{ detail.row.amount | number: '1.2-2' }} diff --git a/src/app/cashier/cash-history/cash-history.component.scss b/src/app/cashier/cash-history/cash-history.component.scss index 1db02c17d..17ae05de0 100644 --- a/src/app/cashier/cash-history/cash-history.component.scss +++ b/src/app/cashier/cash-history/cash-history.component.scss @@ -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; diff --git a/src/app/cashier/cash-history/cash-history.component.ts b/src/app/cashier/cash-history/cash-history.component.ts index 6f49e8d53..281513c46 100644 --- a/src/app/cashier/cash-history/cash-history.component.ts +++ b/src/app/cashier/cash-history/cash-history.component.ts @@ -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(); - 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 { diff --git a/src/app/cashier/cashier-routing.module.ts b/src/app/cashier/cashier-routing.module.ts index 045252339..76947f25b 100644 --- a/src/app/cashier/cashier-routing.module.ts +++ b/src/app/cashier/cashier-routing.module.ts @@ -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'; @@ -225,6 +226,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 diff --git a/src/app/cashier/cashier.service.ts b/src/app/cashier/cashier.service.ts index f3700e20c..cfa8def18 100644 --- a/src/app/cashier/cashier.service.ts +++ b/src/app/cashier/cashier.service.ts @@ -32,9 +32,6 @@ export class CashierService { getCashExchange(id: number): Observable { return this.http.get(`/cashexchange/${id}`); } - reverseCashExchange(id: number, reason: string): Observable { - return this.http.post(`/cashexchange/${id}/reverse`, { reason }); - } // ── Tellers / Cashiers ── getTellers(officeId?: number): Observable { @@ -60,9 +57,6 @@ export class CashierService { getCashTransferById(id: number): Observable { return this.http.get(`/cashtransfer/${id}`); } - reverseCashTransfer(id: number, reason: string): Observable { - return this.http.post(`/cashtransfer/${id}/reverse`, { reason }); - } // ── Cash Settlement ── createCashSettlement(data: any): Observable { @@ -75,9 +69,6 @@ export class CashierService { getCashSettlementById(id: number): Observable { return this.http.get(`/cashsettlement/${id}`); } - reverseCashSettlement(id: number, reason: string): Observable { - return this.http.post(`/cashsettlement/${id}/reverse`, { reason }); - } // ── Inter-cashier Transfer ── createInterCashierTransfer(data: any): Observable { @@ -90,8 +81,10 @@ export class CashierService { getInterCashierById(id: number): Observable { return this.http.get(`/cashintercashier/${id}`); } - reverseInterCashierTransfer(id: number, reason: string): Observable { - 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 { + const params = new HttpParams().set('cashierId', cashierId).set('date', date); + return this.http.get('/cashintercashier/cashier-summary', { params }); } // ── Reconciliation ── @@ -318,6 +311,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 { + 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 { let params = new HttpParams().set('threshold', String(threshold)); if (fromDate) params = params.set('fromDate', fromDate); diff --git a/src/app/cashier/inter-cashier-transfer/inter-cashier-transfer.component.html b/src/app/cashier/inter-cashier-transfer/inter-cashier-transfer.component.html index b87a43472..45166d6e6 100644 --- a/src/app/cashier/inter-cashier-transfer/inter-cashier-transfer.component.html +++ b/src/app/cashier/inter-cashier-transfer/inter-cashier-transfer.component.html @@ -11,41 +11,29 @@
CASHIER · INTER-CASHIER TRANSFER

Inter-Cashier Transfer

-

Move denomination-wise cash between two cashiers within the same teller.

+

Hand over denomination-wise cash from your drawer to another cashier at your counter.

-
- @if (tellers.length === 1) { - - Teller - - - } @else { - - Teller - - @for (t of tellers; track t.id) { - {{ t.name }} - } - @if (tellers.length === 0) { - No tellers configured - } - - - } + @if (ownCashierMissing) { +
+ person_off + 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. +
+ } +
- From (Cashier) - - @for (c of availableFrom; track c.id) { - {{ c.label }} - } - @if (selectedTellerId && allCashiers.length === 0 && !loadingCashiers) { - No cashiers - } - + Teller + + + + + + From (You) + @@ -54,7 +42,7 @@ @for (c of availableTo; track c.id) { {{ c.label }} } - @if (fromCashierId && availableTo.length === 0) { + @if (fromCashierId && !loadingCashiers && availableTo.length === 0) { No other cashiers } @@ -62,7 +50,7 @@ Date - + @@ -71,26 +59,102 @@
+ + @if (fromCashierId || toCashierId) { +
+ + arrow_forward + +
+ } + + +
+
{{ title }}
+ @if (!picked) { +
Select a cashier
+ } @else if (loading) { +
Loading…
+ } @else if (!s) { +
Could not load this cashier's details.
+ } @else { +
{{ s.cashierName || '—' }}
+
+ point_of_sale {{ s.tellerName || '—' }} + account_balance {{ s.officeName || '—' }} +
+
+
+
Allocated today
+
₹{{ s.allocatedAmount | number: '1.2-2' }}
+
+
+
Cash in hand
+
₹{{ s.totalAmount | number: '1.2-2' }}
+
+
+ @if (!s.cashAllocated) { +
+ block No cash allocated from the vault for this date — inter-cashier transfer is not + allowed. +
+ } + } +
+
+ + @if (fromSummary && toSummary && !bothAllocated) { +
+ block + Both cashiers must first be allocated cash from the vault (Cash Transfer) for {{ transferDate }} before an + inter-cashier transfer. +
+ } + @if (anyExceedsHeld) { +
+ error_outline + The sending cashier does not hold enough of the highlighted denominations. +
+ } +
+ + @if (notes.length) { - + } @for (d of notes; track d.id) { - + + + } @if (coins.length) { - + } @for (d of coins; track d.id) { - + + + } @if (denoms.length === 0) { - + }
DenominationFrom holds Count AmountTo holds
Bank NotesBank Notes
{{ d.label }} + +
@@ -99,18 +163,28 @@
₹{{ subtotal(d) | number: '1.2-2' }} + +
CoinsCoins
{{ d.label }} + +
@@ -119,17 +193,34 @@
₹{{ subtotal(d) | number: '1.2-2' }} + +
No active denominations. Add some in Cash Denomination.No active denominations. Add some in Cash Denomination.
+ + + @if (n === null) { + — + } @else { + {{ n }} + @if (delta !== 0) { + → {{ n + delta }} + } + } + +
Total to transfer: ₹{{ total | number: '1.2-2' }} -
- @if (!showClosedRecurringAccounts) { + + @if (savingAccounts) {
- +
+ + + + @@ -638,7 +644,7 @@ } - @if (!element.status.submittedAndPendingApproval && !element.status.active) { + @if (element.status.approved) { - + -
{{ 'labels.inputs.Account No' | translate }} @@ -614,6 +611,15 @@ Status + {{ rdStatusLabel(element) }} + @if (element.timeline?.closedOnDate) { +
on {{ element.timeline.closedOnDate | dateFormat }}
+ } +
{{ 'labels.inputs.Last Active' | translate }} {{ element.lastActiveTransactionDate | dateFormat }} +

@@ -674,67 +680,17 @@

-
- } - - - @if (showClosedRecurringAccounts) { -
- - - - - - - - - - - - - - - - - - - - -
{{ 'labels.inputs.Account No' | translate }} - - - {{ 'labels.inputs.Recurring Deposit Product' | translate }} - - {{ 'labels.inputs.Closed Date' | translate }}{{ element.timeline.closedOnDate | dateFormat }} -
- -

- {{ 'labels.messages.No Closed Recurring Deposit Accounts Available' | translate }} -

-
-
diff --git a/src/app/clients/clients-view/general-tab/general-tab.component.scss b/src/app/clients/clients-view/general-tab/general-tab.component.scss index f913a9808..33fd52124 100644 --- a/src/app/clients/clients-view/general-tab/general-tab.component.scss +++ b/src/app/clients/clients-view/general-tab/general-tab.component.scss @@ -419,3 +419,24 @@ table[mat-table] { font-size: 24px; z-index: 1; } + +.rd-status { + display: inline-block; + padding: 2px 8px; + border-radius: 10px; + font-size: 12px; + font-weight: 600; + background: #dcfce7; + color: #166534; + + &--ended { + background: #e2e8f0; + color: #475569; + } +} + +.rd-status__date { + margin-top: 2px; + font-size: 11px; + color: #64748b; +} diff --git a/src/app/clients/clients-view/general-tab/general-tab.component.ts b/src/app/clients/clients-view/general-tab/general-tab.component.ts index 333c43931..5382d6c2a 100644 --- a/src/app/clients/clients-view/general-tab/general-tab.component.ts +++ b/src/app/clients/clients-view/general-tab/general-tab.component.ts @@ -187,6 +187,15 @@ export class GeneralTabComponent implements OnDestroy { 'Saving Account', 'Closed Date' ]; + /** Closed Recurring Deposit Accounts Columns - with how each one ended, for tracking. */ + recurringColumns: string[] = [ + 'Account No', + 'Saving Account', + 'Status', + 'Last Active', + 'Balance', + 'Actions' + ]; /** Open Shares Accounts Columns */ openSharesColumns: string[] = [ 'Account No', @@ -475,6 +484,30 @@ export class GeneralTabComponent implements OnDestroy { ); } + /** Where a recurring deposit stands - for one that has ended, how it ended. */ + rdStatusLabel(account: any): string { + switch (account?.status?.code) { + case 'savingsAccountStatusType.active': + return 'Active'; + case 'savingsAccountStatusType.matured': + return 'Matured (not closed)'; + case 'savingsAccountStatusType.submitted.and.pending.approval': + return 'Pending Approval'; + case 'savingsAccountStatusType.approved': + return 'Approved'; + case 'savingsAccountStatusType.closed': + return 'Matured & Closed'; + case 'savingsAccountStatusType.pre.mature.closure': + return 'Premature Closed'; + case 'savingsAccountStatusType.rejected': + return 'Rejected'; + case 'savingsAccountStatusType.withdrawn.by.applicant': + return 'Withdrawn by Client'; + default: + return account?.status?.value ?? ''; + } + } + viewAccountsLabel(closed: boolean): string { if (closed) { return 'labels.buttons.View Active Accounts'; diff --git a/src/app/clients/edit-client/edit-client.component.html b/src/app/clients/edit-client/edit-client.component.html index 01d6bb123..b3915a723 100644 --- a/src/app/clients/edit-client/edit-client.component.html +++ b/src/app/clients/edit-client/edit-client.component.html @@ -377,6 +377,8 @@ } @if (selectedTabIndex < totalTabs - 1) { + + + } @if (selectedTabIndex === totalTabs - 1) {
@@ -583,14 +572,16 @@ [matMenuTriggerFor]="reconciliationMenu" *mifosxHasPermission="'MENU_CONFIG_WIZARD'" > - Reconciliation + ReconciliationRecon Cheque Book - Documents Hub + Documents HubDocs Hub
@@ -617,77 +608,89 @@ - - - - - - - - - - - - - - - - - - - - - - - - - + +
+ +
+
Core Reports
+ + + + + + + +
+ + +
+
Operations & Interest
+ + + + + + + +
+ + +
+
Compliance & Registers
+ + + + + + + + +
+
- - - - - - - - + +
+
+
Day Operations
+ + + + +
+
+
Verification & Reports
+ + +
+
- - - - - - - - - - - - - - - - + + +
+ +
+
Clerk Desk
+ + + + +
+ + +
+
Vouchers
+ + + + +
+ + +
+
Counter & Reports
+ + + +
+
- - - - - - - - - - - - - - + + +
+ +
+
Cash & Counter
+ + + +
+ + +
+
Transfers & Settlement
+ + + + +
+ + +
+
Reports & Audit
+ + + +
+
-
+
Accounts
- + +
Agent
-
Reports
+ @@ -1220,6 +1269,9 @@ + @@ -1236,34 +1288,54 @@
- - - - - - - - - - + +
+ +
+
CTS Clearing
+ + + +
+ + +
+
Settlement & Recon
+ + + +
+ + +
+
NACH & Direct
+ + + +
+
-
+
Payroll
@@ -1408,7 +1480,9 @@ - + @@ -1570,23 +1644,33 @@
- - - - - - - + +
+ +
+
KYC Operations
+ + +
+ + +
+
Reports & Screening
+ + + +
+
+
+
Schemes & Social
+ + + + +
+ + +
+
Institutional & Loans
+ + + + +
+
- - - - - - - + +
+ +
+
Payments
+ + + +
+ + +
+
Cards & Mandates
+ + + +
+
diff --git a/src/app/core/shell/shell.component.scss b/src/app/core/shell/shell.component.scss index 1dc00244e..95d3060f9 100644 --- a/src/app/core/shell/shell.component.scss +++ b/src/app/core/shell/shell.component.scss @@ -244,6 +244,44 @@ $color: #e2e4ec; } } +/* + * The cashier's bar is meant to be one row at a normal window size: it carries about eleven items, and at + * the usual padding the last one wrapped onto a second row. The items are tightened so they fit, and spread + * across the row. As the window narrows they tighten further, then drop their icons, and only wrap in a very + * narrow window. The clerk's bar (cashier-nav--clerk) has its own dropdowns and is left as it was. + */ +.cashier-nav:not(.cashier-nav--clerk) { + flex-wrap: nowrap; + justify-content: space-between; + gap: 0; + padding: 4px 8px; + + .cashier-nav__item { + flex: 0 0 auto; + gap: 4px; + padding: 5px 8px; + font-size: 11.5px; + } + + @media (width <= 1240px) { + .cashier-nav__item { + padding: 5px 6px; + font-size: 11px; + } + } + + @media (width <= 1120px) { + .cashier-nav__item fa-icon { + display: none; + } + } + + @media (width <= 900px) { + flex-wrap: wrap; + justify-content: flex-start; + } +} + .sidenav { overflow-x: hidden; position: relative; @@ -266,7 +304,7 @@ $color: #e2e4ec; max-width: 100%; overflow-x: hidden; box-sizing: border-box; - padding: 16px 28px; + padding: 10px 18px; } } @@ -282,21 +320,15 @@ $gray-700: #334155; /* ============================================================ Main header (top 2-row navigation) -------------------------------------------------------------- - Redesigned 2026 — clean, modern enterprise SaaS feel: - • Both rows live on the same white surface separated only by a - hairline; r2 has a barely-there blue-50 wash so frequent vs - secondary menus read at a glance without dark/light contrast. - • Items are now equal-width "tabs" with NO vertical dividers - between them (the old cell borders made it look spreadsheet-y). - • Hover / active = a 3 px brand-coloured TOP bar + brand text + - coloured icon. Reads instantly without colour-flooding the row. - • Icons larger (12.5 px) and full-opacity always — easier to scan - during demos. Caret is subtle and rotates on open. + Compact, modern enterprise SaaS navigation: + • Height reduced to 32px per row (64px total) to save screen space. + • Smooth horizontal scrolling prevents menu items from getting cut off. + • Compact typography, icons and paddings fit all banking modules cleanly. ============================================================ */ .main-header { background: #fff; - border-bottom: 2.5px solid $brand; - box-shadow: 0 1px 4px rgb(0 0 0 / 7%); + border-bottom: 2px solid $brand; + box-shadow: 0 1px 3px rgb(0 0 0 / 6%); position: sticky; /* Sit directly under the sticky toolbar (32px tall) so both stay visible. */ @@ -306,19 +338,19 @@ $gray-700: #334155; } .nav-row { - height: 44px; + height: 32px; display: flex; align-items: stretch; - overflow: visible; + overflow: auto visible; position: relative; - - /* Outer frame; vertical dividers between cells are drawn as `border-right` - on each `.ni` below. Switched away from the equal-column grid so each - cell can size to its own label — long names (General Ledger, Documents - Hub) no longer overflow and short ones (Approvals, HRM) no longer waste - space. */ border: 1px solid $gray-200; background: #fff; + scrollbar-width: none; + -ms-overflow-style: none; + + &::-webkit-scrollbar { + display: none; + } } .r2.nav-row { @@ -334,25 +366,17 @@ $gray-700: #334155; background: #f8fafc; } -// ===== Nav Item — modern tab style ===== -// Typography spec — same family / size / weight / color for BOTH rows. +// ===== Nav Item — compact modern tab style ===== .ni { position: relative; display: inline-flex; align-items: center; justify-content: center; - gap: 7px; + gap: 2.5px; height: 100%; - - /* Each cell sizes to its own label (min-width: max-content) and grows - to share any leftover row width. Short labels stay compact, long ones - get the room they need — no overlap, no wasted gap. */ flex: 1 0 auto; - min-width: max-content; - padding: 0 14px; - - /* Vertical divider between adjacent cells; outer frame is the row's - own border, and the last cell drops its right border below. */ + min-width: 0; + padding: 0 4px; border-right: 1px solid $gray-200; cursor: pointer; white-space: nowrap; @@ -360,11 +384,11 @@ $gray-700: #334155; 'IBM Plex Sans', -apple-system, sans-serif; - font-size: 12px; + font-size: 10px; font-weight: 500; color: #0a1a2e; - line-height: 1.3; - letter-spacing: 0.01em; + line-height: 1.2; + letter-spacing: -0.015em; text-decoration: none; user-select: none; transition: @@ -373,17 +397,15 @@ $gray-700: #334155; box-shadow 0.15s ease; border-radius: 0; - /* 3px accent strip that fills on hover / active (uses ::before so we - don't reflow the row when toggling). */ &::before { content: ''; position: absolute; - left: 4px; - right: 4px; + left: 2px; + right: 2px; top: 0; - height: 3px; + height: 2px; background: transparent; - border-radius: 0 0 3px 3px; + border-radius: 0 0 2px 2px; transition: background 0.15s ease; } @@ -410,17 +432,19 @@ $gray-700: #334155; outline-offset: -2px; } - /* Last cell in a row sits flush against the row's own right edge, so - drop its divider to avoid a double 2 px line. */ &:last-child { border-right: none; } + + .ni-short { + display: none; + } } .ni-ico { - width: 12px; - height: 12px; - font-size: 12px; + width: 9.5px; + height: 9.5px; + font-size: 9.5px; color: $gray-500; flex-shrink: 0; transition: color 0.15s ease; @@ -432,11 +456,12 @@ $gray-700: #334155; } .ni-caret { - width: 7px; - height: 7px; - font-size: 7px; + width: 5px; + height: 5px; + font-size: 5px; color: $gray-400; flex-shrink: 0; + margin-left: 0.5px; transition: transform 0.18s ease, color 0.18s ease; @@ -447,17 +472,89 @@ $gray-700: #334155; } } -@media (width <= 1200px) { +@media (width >= 1400px) { .ni { - padding: 0 8px; - gap: 5px; + padding: 0 6px; + gap: 3.5px; + font-size: 10.8px; + letter-spacing: 0; + } + + .ni-ico { + width: 10px; + height: 10px; + font-size: 10px; + } + + .ni-caret { + width: 5.5px; + height: 5.5px; + font-size: 5.5px; + margin-left: 1px; } } -@media (width <= 980px) { +@media (width <= 1200px) { .ni { + padding: 0 3px; + gap: 2px; + font-size: 9.8px; + letter-spacing: -0.02em; + } + + .ni-ico { + width: 9px; + height: 9px; + font-size: 9px; + } + + .ni-caret { + width: 4.5px; + height: 4.5px; + font-size: 4.5px; + } +} + +@media (width <= 1080px) { + .ni { + padding: 0 2.5px; + gap: 1.5px; + font-size: 9.4px; + letter-spacing: -0.025em; + + .ni-full { + display: none; + } + + .ni-short { + display: inline; + } + } + + .ni-ico { + width: 8.5px; + height: 8.5px; + font-size: 8.5px; + } + + .ni-caret { + width: 4px; + height: 4px; + font-size: 4px; + } +} + +@media (width <= 880px) { + .nav-row { + overflow-x: auto; + scrollbar-width: thin; + } + + .ni { + flex: 0 0 auto; padding: 0 6px; - gap: 4px; + gap: 3px; + font-size: 10px; } } @@ -699,21 +796,29 @@ $gray-700: #334155; // ===== Nav Dropdown Menus ===== ::ng-deep .nav-dropdown { - border-radius: 8px !important; + border-radius: 6px !important; border: 1px solid $gray-200 !important; - box-shadow: 0 6px 24px rgb(0 0 0 / 10%) !important; - min-width: 180px !important; - padding: 4px 0 !important; - margin-top: 2px !important; + box-shadow: 0 4px 16px rgb(0 0 0 / 12%) !important; + min-width: 170px !important; + padding: 3px 0 !important; + margin-top: 1px !important; .mat-mdc-menu-item { - font-size: 12.5px !important; + font-size: 10.5px !important; font-weight: 500 !important; - height: 34px !important; - min-height: 34px !important; - padding: 0 14px !important; + height: 25px !important; + min-height: 25px !important; + padding: 0 6px !important; + line-height: 25px !important; color: $gray-700 !important; - font-family: 'DM Sans', Roboto, sans-serif !important; + font-family: 'IBM Plex Sans', Roboto, sans-serif !important; + white-space: nowrap !important; + + .menu-item-icon { + font-size: 10.5px !important; + margin-right: 5px !important; + opacity: 0.8; + } &:hover { background: $brand-hover !important; @@ -728,39 +833,44 @@ $gray-700: #334155; } /* Deposits Mega Panel (inside mat-menu overlay) */ -.deposit-menu-grid { +::ng-deep .deposit-menu-grid { display: grid; - grid-template-columns: repeat(3, 1fr); - gap: 8px 24px; - padding: 8px 12px; - min-width: 600px; + gap: 4px 12px; + padding: 6px 10px; + min-width: 0; + width: 100%; + box-sizing: border-box; + overflow-x: hidden; } -.deposit-menu-group { +::ng-deep .deposit-menu-group { .deposit-group-title { font-weight: 700; - font-size: 11px; + font-size: 9.5px; text-transform: uppercase; - letter-spacing: 0.6px; + letter-spacing: 0.5px; color: #1074b9; - padding: 4px 16px 6px; + padding: 2px 6px 3px; margin-bottom: 2px; - border-bottom: 2px solid #1074b9; + border-bottom: 1.5px solid #1074b9; + white-space: nowrap; } } -/* NPA Mega Panel — 4 columns (Reports / Analysis / Classification / Recovery Management) */ -.npa-grid { - grid-template-columns: repeat(4, minmax(200px, 1fr)); - min-width: 880px; - max-width: 96vw; +/* NPA Mega Panel — 3 columns */ +::ng-deep .npa-grid { + grid-template-columns: repeat(3, minmax(210px, 1fr)); + min-width: 0; + max-width: 100%; + overflow-x: hidden; } -/* Reconciliation Mega Panel — 5 columns, teal accent to match the icon palette */ -.reconciliation-grid { - grid-template-columns: repeat(5, minmax(190px, 1fr)); - min-width: 1040px; - max-width: 96vw; +/* Reconciliation Mega Panel — 5 columns */ +::ng-deep .reconciliation-grid { + grid-template-columns: repeat(5, minmax(170px, 1fr)); + min-width: 0; + max-width: 100%; + overflow-x: hidden; .deposit-group-title { color: #0d9488; @@ -780,47 +890,49 @@ $gray-700: #334155; .gl-mega-menu { position: absolute; - top: 100px; + top: 96px; left: 50%; transform: translateX(-50%); background: #fff; - width: 860px; - max-width: 95vw; - padding: 20px 24px; - box-shadow: 0 6px 24px rgb(0 0 0 / 22%); - border-radius: 0 0 10px 10px; + width: 760px; + max-width: 94vw; + max-height: 72vh; + overflow-y: auto; + padding: 12px 16px; + box-shadow: 0 6px 20px rgb(0 0 0 / 18%); + border-radius: 0 0 8px 8px; z-index: 1000; - border-top: 3px solid #0d47a1; + border-top: 2.5px solid #0d47a1; } .gl-mega-menu-header { display: flex; align-items: center; - gap: 10px; - margin-bottom: 14px; - padding-bottom: 12px; + gap: 8px; + margin-bottom: 8px; + padding-bottom: 6px; border-bottom: 1px solid #e5e7eb; span { - font-size: 18px; + font-size: 14px; font-weight: 700; color: #0d47a1; - letter-spacing: 0.02em; + letter-spacing: 0.01em; } } .gl-mega-menu-header-icon { - font-size: 18px; + font-size: 14px; color: #0d47a1; } .gl-search { width: 100%; - padding: 10px 14px; - margin-bottom: 16px; + padding: 6px 10px; + margin-bottom: 10px; border: 1px solid #d0d5dd; - border-radius: 6px; - font-size: 14px; + border-radius: 5px; + font-size: 12px; box-sizing: border-box; background: #f9fafb; @@ -828,36 +940,37 @@ $gray-700: #334155; outline: none; border-color: #0d47a1; background: #fff; - box-shadow: 0 0 0 3px rgb(13 71 161 / 10%); + box-shadow: 0 0 0 2px rgb(13 71 161 / 10%); } } .gl-columns { display: grid; grid-template-columns: repeat(4, 1fr); - gap: 24px; + gap: 10px 16px; } .gl-group-title { font-weight: 700; - font-size: 11px; + font-size: 10px; text-transform: uppercase; - letter-spacing: 0.8px; + letter-spacing: 0.6px; color: #0d47a1; - margin-bottom: 10px; - padding-bottom: 6px; - border-bottom: 2px solid #0d47a1; + margin-bottom: 5px; + padding-bottom: 3px; + border-bottom: 1.5px solid #0d47a1; } .gl-link { display: flex; align-items: center; - padding: 7px 6px; + padding: 3.5px 5px; text-decoration: none; color: #344054; - font-size: 13px; + font-size: 11.5px; + line-height: 1.3; cursor: pointer; - border-radius: 5px; + border-radius: 4px; transition: background 0.15s, color 0.15s; @@ -873,23 +986,21 @@ $gray-700: #334155; } .gl-icon { - width: 18px; - margin-right: 8px; + width: 14px; + margin-right: 5px; color: #667085; - font-size: 13px; + font-size: 11px; + flex-shrink: 0; .gl-link:hover & { color: #0d47a1; } } -// Marks a menu link as actually wired to a working screen/dialog (vs a still-unwired -// placeholder) — a small green dot at the end of the row, so a reviewer can tell at a glance -// which items in a mega-menu are functional without having to click every one of them. .gl-link--live::after { content: ''; - width: 6px; - height: 6px; + width: 5px; + height: 5px; margin-left: auto; flex-shrink: 0; border-radius: 50%; @@ -899,9 +1010,9 @@ $gray-700: #334155; /* RBI Mega Menu */ .rbi-mega-menu { - width: 1100px; - max-width: 97vw; - max-height: 80vh; + width: 880px; + max-width: 94vw; + max-height: 72vh; overflow-y: auto; border-top-color: #b71c1c; } @@ -909,7 +1020,7 @@ $gray-700: #334155; .rbi-columns { display: grid; grid-template-columns: repeat(4, 1fr); - gap: 20px; + gap: 10px 16px; } @keyframes move { diff --git a/src/app/core/shell/toolbar/toolbar.component.html b/src/app/core/shell/toolbar/toolbar.component.html index 37321f09d..450624b69 100644 --- a/src/app/core/shell/toolbar/toolbar.component.html +++ b/src/app/core/shell/toolbar/toolbar.component.html @@ -20,7 +20,9 @@
 LIVE
-
Branch: {{ officeLabel }}
+
+ Branch: {{ officeLabel }} +
Date: {{ today | date: 'dd MMM yyyy' }}
@@ -522,7 +524,7 @@
- + - @if (!transaction.reversed) { - - - - } - - }
diff --git a/src/app/deposits/daily-transaction-entry/apply-dds-receipt-dialog/apply-dds-receipt-dialog.component.html b/src/app/deposits/daily-transaction-entry/apply-dds-receipt-dialog/apply-dds-receipt-dialog.component.html new file mode 100644 index 000000000..6de1335d7 --- /dev/null +++ b/src/app/deposits/daily-transaction-entry/apply-dds-receipt-dialog/apply-dds-receipt-dialog.component.html @@ -0,0 +1,381 @@ + + +
+
+
+ account_balance_wallet +
+
+

Apply DDS Receipt

+

+ Agent {{ data.agentCode }} · {{ data.agentName }} + @if (data.glCode) { + · GL {{ data.glCode }} + } +

+
+ +
+ + + @if (loading) { +
+ + Loading entries and receipts… +
+ } @else { + +
+
+
+ Collections up to {{ data.entryDate | date: 'dd/MM/yyyy' }} + {{ pickedEntryCount }} {{ pickedEntryCount === 1 ? 'entry' : 'entries' }} to settle +
+
+ {{ required | number: '1.2-2' }} +
+
+
+
+ Vouchers picked + {{ pickedReceipts.size }} {{ pickedReceipts.size === 1 ? 'voucher' : 'vouchers' }} +
+
+ + {{ received | number: '1.2-2' }} + + @if (pickedReceipts.size > 0 && pickedDays.size > 0 && difference < 0) { + Short {{ -difference | number: '1.2-2' }} + } @else if (pickedReceipts.size > 0 && pickedDays.size > 0 && difference > 0) { + Over {{ difference | number: '1.2-2' }} + } @else if (canApply) { + ✓ Matches + } +
+
+ +
+ Total to Apply +
+ {{ required | number: '1.2-2' }} + → + {{ received | number: '1.2-2' }} + + {{ canApply ? '✓ Ready' : 'Pending' }} + +
+
+
+ + +
+
+ 1 +
+

Outstanding up to {{ data.entryDate | date: 'dd/MM/yyyy' }}

+

Everything the agent wrote down and has not handed in, day by day. Open a day to see its lines.

+
+
+ + @if (days.length) { +
    + @for (day of days; track day.date) { +
  • +
    + {{ day.date | date: 'dd/MM/yyyy' }} + {{ day.entries.length }} {{ day.entries.length === 1 ? 'entry' : 'entries' }} +
    + {{ day.total | number: '1.2-2' }} + +
  • + @if (openDay === day.date) { +
  • + @if (day.entries.length > 8) { + + } +
    + + + @for (entry of visibleEntries(day); track entry.id) { + + + + + + + } @empty { + + + + } + +
    {{ entry.partyCode }}{{ entry.partyName }} + {{ entry.transactionMode }} + @if (entry.advanceDays) { + · {{ entry.advanceDays }}d advance + } + @if (entry.arrearsDays) { + · {{ entry.arrearsDays }}d previous + } + {{ entry.amount | number: '1.2-2' }}
    No line matches.
    +
    +
  • + } + } +
+ } @else { +

+ {{ data.agentName }} has nothing outstanding for {{ data.entryDate | date: 'dd/MM/yyyy' }}. Change the date + on the screen to the day the entries were written. +

+ } +
+ + +
+
+ 2 +
+

DDS Cash Receipts

+

Tick the vouchers. Posts Dr Agent GL / Cr DDS Customer Deposits.

+
+ +
+ + @if (available.length) { +
    + @for (option of available; track option.id) { +
  • + {{ + pickedReceipts.has(option.id) ? 'check_box' : 'check_box_outline_blank' + }} +
    + {{ option.voucherNo }} + {{ option.txnDate }} · {{ glLabel(option) }} + @if (option.description) { + {{ option.description }} + } +
    + {{ option.amount | number: '1.2-2' }} + +
  • + } +
+ } @else { +

+ No DDS receipt is waiting on this agent's GL account. Collect the cash at + Cashier › Transaction Entry with entry type DDS and have the manager approve it, + or find an earlier voucher by number. +

+ } + + @if (pickedReceipts.size > 0 && pickedDays.size > 0 && difference !== 0) { +

+ The vouchers ({{ received | number: '1.2-2' }}) have to match the day's entries ({{ + required | number: '1.2-2' + }}) exactly, so every customer is credited what the agent wrote down. +

+ } +
+ + @if (errorMsg && !searchOpen) { +
+ error_outline + {{ errorMsg }} +
+ } + } +
+ + + + + +
+ + +@if (searchOpen) { +
+ +
+} + + +@if (viewing) { +
+ +
+} diff --git a/src/app/deposits/daily-transaction-entry/apply-dds-receipt-dialog/apply-dds-receipt-dialog.component.scss b/src/app/deposits/daily-transaction-entry/apply-dds-receipt-dialog/apply-dds-receipt-dialog.component.scss new file mode 100644 index 000000000..37ec50b55 --- /dev/null +++ b/src/app/deposits/daily-transaction-entry/apply-dds-receipt-dialog/apply-dds-receipt-dialog.component.scss @@ -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/. + */ + +// The same look as Apply Additional Shares, on purpose: both apply a cashier's receipt to what it was +// collected for, and the counter should meet one screen, not two. Used as it is, never changed here. +@use '../../../shares/share-dialog.shared' as *; + +:host { + display: block; + width: 100%; +} + +// The agent's outstanding lines - read only, what the receipt has to cover. +.entries { + width: 100%; + border-collapse: collapse; + font-size: 13px; + + td { + padding: 7px 4px; + border-bottom: 1px solid #eef2f7; + } + + tr:last-child td { + border-bottom: none; + } + + .entry-date { + color: #64748b; + white-space: nowrap; + } + + .entry-code { + font-weight: 600; + white-space: nowrap; + } + + .entry-name { + color: #475569; + } + + .num { + text-align: right; + white-space: nowrap; + } +} + +// Days and vouchers are ticked, any number of each - a check box where the share dialog has a radio. +.option-check { + color: #94a3b8; + flex-shrink: 0; +} + +.option-picked { + border-color: #2563eb !important; + background: #eff6ff !important; + + .option-check { + color: #2563eb; + } +} + +// A day's own lines, opened under it. Scrolls inside itself, so a thousand customers do not make the +// dialog a thousand lines tall. +.day-lines { + list-style: none; + padding: 6px 10px 10px 42px; +} + +.day-search { + width: 100%; + margin-bottom: 6px; + padding: 6px 10px; + border: 1px solid #e2e8f0; + border-radius: 6px; + font: inherit; + font-size: 13px; +} + +.day-scroll { + max-height: 240px; + overflow-y: auto; +} + +.field-readonly { + padding: 8px 10px; + border: 1px solid #e2e8f0; + border-radius: 6px; + background: #f8fafc; +} diff --git a/src/app/deposits/daily-transaction-entry/apply-dds-receipt-dialog/apply-dds-receipt-dialog.component.ts b/src/app/deposits/daily-transaction-entry/apply-dds-receipt-dialog/apply-dds-receipt-dialog.component.ts new file mode 100644 index 000000000..1993d96ab --- /dev/null +++ b/src/app/deposits/daily-transaction-entry/apply-dds-receipt-dialog/apply-dds-receipt-dialog.component.ts @@ -0,0 +1,351 @@ +/** + * 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 { MAT_DIALOG_DATA, MatDialogRef, MatDialogModule } from '@angular/material/dialog'; +import { MatButtonModule } from '@angular/material/button'; +import { MatIconModule } from '@angular/material/icon'; +import { MatProgressSpinnerModule } from '@angular/material/progress-spinner'; +import { catchError } from 'rxjs/operators'; +import { of } from 'rxjs'; +import { OrganizationService } from 'app/organization/organization.service'; +import { SettingsService } from 'app/settings/settings.service'; + +/** One day of an agent's outstanding entries, as the dialog lists them. */ +export interface EntryDay { + date: string; + entries: any[]; + total: number; +} + +/** + * Applies the cashier's DDS receipts to an agent's outstanding collections. + * + * The agent's cash is already in the bank: the cashier took it at the counter into the agent's own GL + * account, tagged DDS, and the manager approved it. Applying it here hands the money on to the + * customers the agent wrote down - Dr the agent's GL, Cr DDS Customer Deposits - and each entry is + * settled against the application. The same screen as Apply Additional Shares. + * + * Everything outstanding up to the day chosen on the screen, shown a day at a time - nothing to tick, + * because what the agent wrote down is settled whole, and an agent who comes in after several days + * brings several days' cash. The vouchers are ticked, since a day's + * cash can come in more than one visit. They have to agree exactly: what the agent wrote down is what + * each customer is owed, so vouchers short of it leave somebody uncredited and vouchers over it leave + * money nobody can place. + * + * The day is shown totalled and its lines opened only when asked for - an agent with a thousand + * customers is a thousand lines, and the day's total is what the counter is checking. + */ +@Component({ + selector: 'mifosx-apply-dds-receipt-dialog', + templateUrl: './apply-dds-receipt-dialog.component.html', + styleUrls: ['./apply-dds-receipt-dialog.component.scss'], + standalone: true, + imports: [ + CommonModule, + FormsModule, + MatDialogModule, + MatButtonModule, + MatIconModule, + MatProgressSpinnerModule + ] +}) +export class ApplyDdsReceiptDialogComponent implements OnInit { + private organizationService = inject(OrganizationService); + private settingsService = inject(SettingsService); + + public dialogRef = inject(MatDialogRef); + public data = inject<{ + agentId: number; + agentCode: string; + agentName: string; + glCode: string | null; + entryDate: string; + }>(MAT_DIALOG_DATA); + + loadingEntries = true; + loadingReceipts = true; + submitting = false; + errorMsg = ''; + + /** The chosen day's outstanding entries - one day, or none when nothing is outstanding on it. */ + days: EntryDay[] = []; + + /** DDS receipts on the agent's GL account, approved and not yet applied. */ + available: any[] = []; + + /** + * The day is settled whole, so it is always included. The vouchers are the clerk's choice, and none + * is picked for them even when only one is listed. + */ + pickedDays = new Set(); + pickedReceipts = new Set(); + + /** The day whose lines are open, and the search within it. */ + openDay: string | null = null; + daySearch = ''; + + get loading(): boolean { + return this.loadingEntries || this.loadingReceipts; + } + + ngOnInit(): void { + // Everything outstanding up to the chosen date, whatever day it was written - an agent's cash for + // several days arrives in one visit, and all of it is settled together. + this.organizationService + .getAgentPendingCollections(this.data.agentId) + .pipe( + catchError((err) => { + this.errorMsg = this.readError(err, "Could not load the agent's entries."); + return of(null); + }) + ) + .subscribe((res: any) => { + this.days = groupByDay( + (res?.entries || []).filter( + (entry: any) => entry.status === 'PENDING' && String(entry.entryDate) <= this.data.entryDate + ) + ); + this.pickedDays = new Set(this.days.map((day) => day.date)); + this.loadingEntries = false; + }); + + this.organizationService + .getAgentCashReceipts(this.data.agentId) + .pipe( + catchError((err) => { + this.errorMsg = this.readError(err, 'Could not load the available receipts.'); + return of([]); + }) + ) + .subscribe((rows: any) => { + this.available = Array.isArray(rows) ? rows : []; + this.loadingReceipts = false; + }); + } + + // ── Picking ───────────────────────────────────────────────────────────────────────────────── + + toggleReceipt(receipt: any): void { + if (this.submitting) { + return; + } + this.pickedReceipts.has(receipt.id) ? this.pickedReceipts.delete(receipt.id) : this.pickedReceipts.add(receipt.id); + } + + toggleOpen(day: EntryDay, event: Event): void { + event.stopPropagation(); + this.openDay = this.openDay === day.date ? null : day.date; + this.daySearch = ''; + } + + /** The open day's lines, narrowed by party code or name. */ + visibleEntries(day: EntryDay): any[] { + const term = this.daySearch.trim().toLowerCase(); + if (!term) { + return day.entries; + } + return day.entries.filter( + (entry: any) => + String(entry.partyCode || '') + .toLowerCase() + .includes(term) || + String(entry.partyName || '') + .toLowerCase() + .includes(term) + ); + } + + // ── What has to be covered ────────────────────────────────────────────────────────────────── + + get entryCount(): number { + return this.days.reduce((sum, day) => sum + day.entries.length, 0); + } + + get outstandingTotal(): number { + return this.days.reduce((sum, day) => sum + day.total, 0); + } + + get pickedEntryCount(): number { + return this.days.filter((day) => this.pickedDays.has(day.date)).reduce((sum, day) => sum + day.entries.length, 0); + } + + get required(): number { + return round2(this.days.filter((day) => this.pickedDays.has(day.date)).reduce((sum, day) => sum + day.total, 0)); + } + + get received(): number { + return round2( + this.available.filter((r) => this.pickedReceipts.has(r.id)).reduce((sum, r) => sum + Number(r.amount || 0), 0) + ); + } + + /** Vouchers less entries: below zero they are short, above zero they are over. */ + get difference(): number { + return round2(this.received - this.required); + } + + get canApply(): boolean { + return this.pickedDays.size > 0 && this.pickedReceipts.size > 0 && this.difference === 0 && !this.submitting; + } + + glLabel(option: any): string { + return [ + option?.glCode, + option?.glName + ] + .filter(Boolean) + .join(' — '); + } + + // ── Voucher search, for a receipt the list does not carry ─────────────────────────────────── + + searching = false; + searchOpen = false; + searchVoucherNo = ''; + + openSearch(): void { + this.searchOpen = true; + this.searchVoucherNo = ''; + this.errorMsg = ''; + } + + closeSearch(): void { + this.searchOpen = false; + this.errorMsg = ''; + } + + /** + * Finds one voucher on the agent's GL account and, when it cannot be used, says why - not taken as + * DDS, not approved yet, or already applied - rather than simply not finding it. + */ + runSearch(): void { + const voucher = this.searchVoucherNo.trim(); + if (!voucher || this.searching) { + return; + } + this.searching = true; + this.errorMsg = ''; + this.organizationService + .getAgentCashReceipts(this.data.agentId, voucher) + .pipe( + catchError((err) => { + this.errorMsg = this.readError(err, `Voucher ${voucher} could not be looked up.`); + return of(null); + }) + ) + .subscribe((rows: any) => { + this.searching = false; + if (rows === null) { + return; + } + const found = (Array.isArray(rows) ? rows : [])[0]; + if (!found) { + this.errorMsg = `Voucher ${voucher} is not on ${this.data.agentCode}'s GL account ${this.data.glCode || ''}.`; + } else if (found.entryType !== 'DDS') { + this.errorMsg = `Voucher ${voucher} was taken as "${found.entryType || 'untagged'}", not DDS. Ask the cashier to correct the entry type.`; + } else if (found.status !== 'APPROVED') { + this.errorMsg = `Voucher ${voucher} is ${found.status}. It can be applied once the manager approves it.`; + } else if (found.applied) { + this.errorMsg = `Voucher ${voucher} has already been applied.`; + } else { + this.available = [ + found, + ...this.available.filter((entry) => entry.id !== found.id) + ]; + this.pickedReceipts.add(found.id); + this.searchOpen = false; + } + }); + } + + // ── A receipt's own GL entries ────────────────────────────────────────────────────────────── + + viewing: any = null; + viewingGlEntries: any[] | null = null; + loadingGlEntries = false; + + openDetail(option: any, event: Event): void { + event.stopPropagation(); + this.viewing = option; + this.viewingGlEntries = null; + this.loadingGlEntries = true; + this.organizationService + .getCashReceiptGlEntries(option.id) + .pipe(catchError(() => of([]))) + .subscribe((rows: any) => { + this.viewingGlEntries = Array.isArray(rows) ? rows : []; + this.loadingGlEntries = false; + }); + } + + closeDetail(): void { + this.viewing = null; + this.viewingGlEntries = null; + } + + // ── Submission ────────────────────────────────────────────────────────────────────────────── + + apply(): void { + if (!this.canApply) { + return; + } + this.submitting = true; + this.errorMsg = ''; + const vouchers = this.available.filter((r) => this.pickedReceipts.has(r.id)).map((r) => r.voucherNo); + const entries = this.pickedEntryCount; + const amount = this.required; + this.organizationService + .settleAgentCollections({ + agentId: Number(this.data.agentId), + cashierDepositRequestIds: Array.from(this.pickedReceipts), + entryDates: Array.from(this.pickedDays), + locale: this.settingsService.language.code + }) + .pipe( + catchError((err) => { + this.errorMsg = this.readError(err, 'The receipts could not be applied.'); + this.submitting = false; + return of(null); + }) + ) + .subscribe((res: any) => { + if (res) { + this.dialogRef.close({ vouchers, entries, amount, response: res }); + } + }); + } + + private readError(err: any, fallback: string): string { + return ( + err?.error?.errors?.[0]?.defaultUserMessage ?? + err?.error?.defaultUserMessage ?? + err?.error?.developerMessage ?? + fallback + ); + } +} + +/** Entries grouped by the day they were collected, oldest first, each day totalled. */ +export function groupByDay(entries: any[]): EntryDay[] { + const byDay = new Map(); + for (const entry of entries) { + const date = String(entry.entryDate); + const day = byDay.get(date) ?? { date, entries: [], total: 0 }; + day.entries.push(entry); + day.total = round2(day.total + Number(entry.amount || 0)); + byDay.set(date, day); + } + return Array.from(byDay.values()).sort((a, b) => a.date.localeCompare(b.date)); +} + +function round2(value: number): number { + return Math.round(value * 100) / 100; +} diff --git a/src/app/deposits/daily-transaction-entry/daily-transaction-entry.component.html b/src/app/deposits/daily-transaction-entry/daily-transaction-entry.component.html new file mode 100644 index 000000000..e8ae7d13b --- /dev/null +++ b/src/app/deposits/daily-transaction-entry/daily-transaction-entry.component.html @@ -0,0 +1,468 @@ + + +
+
+ +

Daily Transaction Detail

+
+ +
+
+
+
+
Agent Information
+
+
Name
+
{{ agentInfo.name || '—' }}
+
Address
+
{{ agentInfo.address || '—' }}
+
City
+
{{ agentInfo.city || '—' }}
+
A/C
+
{{ agentInfo.accountNo || '—' }}
+
Guarantor
+
{{ agentInfo.guarantor1 || '—' }}
+
Guarantor
+
{{ agentInfo.guarantor2 || '—' }}
+
Curr Bal
+
{{ agentInfo.currentBalance | number: '1.2-2' }}
+
+
+ +
+
Party Information
+ @if (partyInfo) { +
+
Client Id
+
{{ partyInfo.clientId }}
+
Name
+
{{ partyInfo.partyName }}
+
Address
+
{{ partyInfo.address1 || '—' }}
+
City
+
{{ partyInfo.city || '—' }}
+
Op. Date
+
{{ partyInfo.openingDate ? (partyInfo.openingDate | dateFormat) : '—' }}
+
Maturity
+
{{ partyInfo.maturityDate ? (partyInfo.maturityDate | dateFormat) : '—' }}
+
Daily Depo
+
{{ partyInfo.dailyDepositAmount | number: '1.2-2' }}
+
Ledger
+
{{ partyInfo.ledgerNo || '—' }}
+
Aadhar No
+
{{ partyInfo.aadharNo || '—' }}
+
Curr Bal
+
{{ partyInfo.currentBalance | number: '1.2-2' }}
+ +
Due So Far
+
{{ partyInfo.expectedToDate | number: '1.2-2' }}
+
Collected
+
{{ partyInfo.collectedToDate | number: '1.2-2' }}
+ @if (schedule(partyInfo); as sched) { +
Schedule
+
{{ sched.text }}
+ } +
Bal. After This Tr
+
{{ partyBalanceAfter | number: '1.2-2' }}
+
+ } @else { +

+ Pick a party in a collection row below — this panel shows whose account it is and what the balance + becomes. +

+ } +
+
+ +
+ + Agent + + @for (agent of selectableAgents; track agent.id) { + + {{ agent.agentCode }} · {{ agent.agentNameId }} + @if (!agent.active) { + — not collecting + } + + } + + @if (selectableAgents.length === 0) { + No active agent on file + } @else if (pickedAgentIsInactive) { + + This agent no longer collects — settling held cash only + } @else if (agentInfo.name) { + + {{ agentParties.length }} {{ agentParties.length === 1 ? 'party' : 'parties' }} to collect from + + } + + + + Date + + + + + + + + Tran. Type + + @for (type of transactionTypes; track type.label) { + + {{ type.label }} + @if (!type.supported) { + — not available yet + } + + } + + +
+ + + @if (isPartyTran && agentInfo.name) { +
+
+ +

Collections up to {{ entryForm.get('transactionDate')?.value | dateFormat }}

+ +
+ + @if (pendingLoading) { +

Reading this agent's entries...

+ } @else if (pendingEntries.length === 0) { +

{{ agentInfo.name }} has nothing outstanding up to this date.

+ } @else { + @for (entry of pendingEntries; track entry.id) { +
+ + {{ entry.entryDate | dateFormat }} + {{ entry.partyCode }} + {{ entry.partyName }} + {{ entry.transactionMode }} + @if (entry.advanceDays) { + · {{ entry.advanceDays }}d advance + } + @if (entry.arrearsDays) { + · {{ entry.arrearsDays }}d previous + } + + {{ entry.amount | number: '1.2-2' }} +
+ } + +
+ {{ pendingEntries.length }} {{ pendingEntries.length === 1 ? 'entry' : 'entries' }} + {{ pendingTotal | number: '1.2-2' }} +
+ + +

+ Everything {{ agentInfo.name }} wrote down and has not handed in yet, up to this date, customer by + customer. The cash and its receipts are under Agent Tran. +

+ } +
+ } + + + @if (isAgentTran && agentInfo.name) { +
+
+ +

Agent's cash

+ + +
+ + @if (!agentGlCode) { +

+ {{ agentInfo.name }} has no GL account, so the cashier has nowhere to take their cash. Set one on Agent + Master. +

+ } @else { + +

+ Agent GL: {{ agentGlCode }}{{ agentGlName ? ' · ' + agentGlName : '' }} — the cashier + takes {{ agentInfo.name }}'s cash into this GL (Entry Type DDS). +

+ } + +
+
+ To take + {{ agentOwed | number: '1.2-2' }} +
+
+ Received (approved) + {{ agentReceived | number: '1.2-2' }} +
+
+ Still to take + {{ agentBalance | number: '1.2-2' }} +
+
+ @if (agentReceivedBeyondDay > 0) { +

+ {{ agentReceivedBeyondDay | number: '1.2-2' }} received at the counter is more than everything outstanding + up to {{ entryForm.get('transactionDate')?.value | dateFormat }}. Check the voucher was taken for the + right agent. +

+ } + @if (agentWaiting > 0) { +

+ {{ agentWaiting | number: '1.2-2' }} more is at the counter awaiting the manager's approval. +

+ } + +

+ Outstanding entries up to {{ entryForm.get('transactionDate')?.value | dateFormat }} +

+ @if (agentMoneyLoading) { +

Reading this agent's entries...

+ } @else if (agentDays.length === 0) { +

{{ agentInfo.name }} has nothing outstanding.

+ } @else { + @for (day of agentDays; track day.date) { +
+ {{ day.date | dateFormat }} + + {{ day.entries.length }} {{ day.entries.length === 1 ? 'entry' : 'entries' }} + + {{ day.total | number: '1.2-2' }} + +
+ @if (agentOpenDay === day.date) { +
+ @for (entry of day.entries; track entry.id) { +
+ {{ entry.partyCode }} + {{ entry.partyName }} + {{ entry.transactionMode }} + @if (entry.advanceDays) { + · {{ entry.advanceDays }}d advance + } + @if (entry.arrearsDays) { + · {{ entry.arrearsDays }}d previous + } + + {{ entry.amount | number: '1.2-2' }} +
+ } +
+ } + } + } + +

Cashier receipts

+ @if (agentReceipts.length === 0) { +

+ No DDS receipt on {{ agentInfo.name }}'s GL account{{ agentGlCode ? ' ' + agentGlCode : '' }} waiting to + be applied. +

+ } @else { + @for (receipt of agentReceipts; track receipt.id) { +
+ {{ receipt.voucherNo }} + {{ receipt.txnDate | dateFormat }} + {{ receipt.cashierName || '—' }} + + {{ receipt.status === 'APPROVED' ? 'Approved' : 'Awaiting manager' }} + + {{ receipt.amount | number: '1.2-2' }} +
+ } + } +
+ } + + @if (isWithdrawDeposite) { +
+
+ +

Withdraw Deposite

+
+
+ + Creditor Party Code + + arrow_drop_down + + @for (party of transferPartyOptions('creditor'); track party.id) { + + {{ party.partyCode }} · {{ party.partyName }} · ₹{{ party.currentBalance | number: '1.2-2' }} + + } + @if (transferPartyOptions('creditor').length === 0) { + No open DDS account matches + } + + Account the money goes into + + + Creditor Party Name + + + + Creditor Curr Bal + + + + + Debit Party Code + + arrow_drop_down + + @for (party of transferPartyOptions('debit'); track party.id) { + + {{ party.partyCode }} · {{ party.partyName }} · ₹{{ party.currentBalance | number: '1.2-2' }} + + } + @if (transferPartyOptions('debit').length === 0) { + No open DDS account matches + } + + @if (transferToItself) { + Pick a different account - it cannot transfer to itself + } @else { + Account the money is taken from + } + + + Debit Party Name + + + + Debit Curr Bal + + + + + Amount + + + + Description + + +
+
+ } + + @if (!isPartyTran && !isWithdrawDeposite && !isAgentTran) { +
+

+ "{{ entryForm.get('transactionType').value }}" entry screen — same shape as Party Tran, to be filled in once + the backend distinguishes these transaction types. +

+
+ } + +
+ + + + + + + @if (!isPartyTran && !isAgentTran) { + + } +
+
+
+
diff --git a/src/app/deposits/daily-transaction-entry/daily-transaction-entry.component.scss b/src/app/deposits/daily-transaction-entry/daily-transaction-entry.component.scss new file mode 100644 index 000000000..5a05ca32b --- /dev/null +++ b/src/app/deposits/daily-transaction-entry/daily-transaction-entry.component.scss @@ -0,0 +1,488 @@ +/** + * 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/. + */ + +$primary: #0f766e; + +.dte-container { + // Wider than a normal form page: a collection row carries six fields side by side, and a clerk + // keying a round should see the whole of it. + max-width: 1280px; + margin: 0 auto; + padding: 20px 16px; +} + +.dte-header { + display: flex; + align-items: center; + gap: 16px; + margin-bottom: 20px; + + &__title { + margin: 0; + font-size: 22px; + font-weight: 500; + } +} + +.dte-back { + color: $primary; +} + +.dte-card { + background: var(--md-sys-color-surface, #fff); + border-radius: 8px; + box-shadow: 0 2px 8px rgb(0 0 0 / 8%); + padding: 20px 24px 24px; +} + +.dte-info-panels { + display: grid; + grid-template-columns: 1fr 1fr; + gap: 16px; + margin-bottom: 20px; +} + +.dte-info-panel { + border: 1px solid rgb(0 0 0 / 10%); + border-radius: 8px; + padding: 12px 16px; + background: rgb(15 118 110 / 4%); + + &__title { + font-weight: 600; + font-size: 13px; + text-transform: uppercase; + letter-spacing: 0.04em; + color: $primary; + margin-bottom: 8px; + } + + &__hint { + margin: 0; + color: rgb(0 0 0 / 50%); + font-size: 13px; + } + + dl { + display: grid; + grid-template-columns: auto 1fr; + gap: 4px 12px; + margin: 0; + } + + dt { + font-weight: 500; + color: rgb(0 0 0 / 60%); + font-size: 13px; + } + + dd { + margin: 0; + font-size: 13px; + } +} + +.dte-controls { + display: flex; + align-items: flex-start; + gap: 12px; + flex-wrap: wrap; + margin-bottom: 12px; +} + +// A sensible floor for the fields laid out with flex in .dte-controls, where there is room to spare. +.dte-field { + min-width: 180px; + + &--wide { + grid-column: span 2; + } +} + +// Inside the collection grid the track decides the width, not the field. Left to its own 180px +// floor a field refuses to shrink into a narrower track and overruns the one beside it. +.dte-collection-row .dte-field { + width: 100%; + min-width: 0; +} + +.dte-section { + padding: 16px 0; + border-top: 1px solid rgb(0 0 0 / 8%); + // A very narrow window scrolls the grid sideways rather than the whole page. + overflow-x: auto; + + &__header { + display: flex; + align-items: center; + gap: 10px; + margin-bottom: 16px; + + i { + color: $primary; + } + + h3 { + margin: 0; + font-size: 16px; + font-weight: 600; + flex: 1; + } + } +} + +.dte-add-row { + color: $primary; +} + +.dte-empty { + color: rgb(0 0 0 / 50%); + font-size: 14px; + margin: 0; +} + +.dte-grid { + display: grid; + grid-template-columns: repeat(3, 1fr); + gap: 8px 16px; +} + +/* + * One collection per row. Fixed columns rather than an even split, so the row holds its shape as + * fields come and go - Cheque No and Token No only appear for a non-cash entry - and the party + * picker gets the width its "code · name · daily amount" needs. + */ +.dte-collection-row { + display: grid; + grid-template-columns: + minmax(0, 2.2fr) /* Party */ + minmax(0, 1fr) /* TRTP */ + minmax(0, 1fr) /* AC Type */ + minmax(0, 1.4fr) /* A/C No. */ + minmax(0, 1fr) /* Amount */ + minmax(0, 1.6fr) /* Description */ + auto; /* remove */ + + gap: 8px 12px; + align-items: start; + padding: 10px 0; + border-top: 1px dashed rgb(0 0 0 / 10%); + + &:first-of-type { + border-top: none; + } + + // Cheque No and Token No push Description and the remove button onto a second line, which is + // what a non-cash entry should look like rather than a row running off the screen. + &:has(.dte-field--instrument) { + grid-template-columns: + minmax(0, 2.2fr) minmax(0, 1fr) minmax(0, 1fr) minmax(0, 1.4fr) minmax(0, 1fr) + minmax(0, 1fr) minmax(0, 1fr) minmax(0, 1.6fr) auto; + } +} + +.dte-collection-row .dte-field--wide { + grid-column: span 2; +} + +@media (width <= 1200px) { + .dte-collection-row, + .dte-collection-row:has(.dte-field--instrument) { + grid-template-columns: repeat(3, minmax(0, 1fr)) auto; + } +} + +.dte-remove-row { + color: #b91c1c; + margin-top: 8px; +} + +.dte-action-bar { + display: flex; + justify-content: flex-end; + gap: 12px; + margin-top: 20px; + padding-top: 16px; + border-top: 1px solid rgb(0 0 0 / 8%); + flex-wrap: wrap; +} + +@media (width <= 1000px) { + .dte-info-panels { + grid-template-columns: 1fr; + } + + .dte-grid { + grid-template-columns: repeat(2, 1fr); + } + + .dte-collection-row { + grid-template-columns: repeat(2, 1fr); + } +} + +/* Resolved name under an Agent / Party Code field — the check that the right record was found. */ +.dte-hint--found { + color: #15803d; + font-weight: 500; +} + +.dte-hint--missing { + color: #b91c1c; + font-weight: 500; +} + +/* Marks an agent in the list who is no longer collecting - only offered for settlement. */ +.dte-agent-off { + color: #b45309; + font-size: 12px; + font-style: italic; + margin-left: 6px; +} + +/* The figure the clerk actually verifies against the passbook before saving. */ +.dte-after { + font-weight: 600; + color: #0f766e; +} + +/* What the agent should be handing over for this round. */ +.dte-total { + display: flex; + justify-content: flex-end; + align-items: baseline; + gap: 14px; + padding: 10px 4px 2px; + border-top: 1px solid rgb(0 0 0 / 10%); + font-size: 0.88rem; + color: rgb(0 0 0 / 60%); + + strong { + font-size: 1.1rem; + color: rgb(0 0 0 / 85%); + font-variant-numeric: tabular-nums; + } +} + +/* Marks a transaction type the system does not handle yet, so it reads as pending rather than broken. */ +.dte-soon { + font-size: 0.78rem; + font-style: italic; + color: rgb(0 0 0 / 45%); +} + +/* + * Marks a party this agent has already collected from today, on the row where it is picked. + * + * Amber, not red: a customer paying twice in a day is legitimate - arrears, or two days at once - + * so this is a second look, not an error. Blocking it would stop real work; saying nothing lets a + * whole round be credited twice. + */ +.dte-hint--again { + color: #b45309; + font-weight: 500; +} + +/* + * Where a party stands against its instalments. Three states rather than one colour, because they + * call for three different things from the clerk: ahead means take nothing, behind means take more + * than the daily amount, level means take the daily amount as usual. + * + * Behind is amber, not red - falling a few days behind is ordinary in daily collection and is the + * agent's job to recover, not an error. Red here would cry wolf on most rows. + */ +.dte-hint--ahead, +.dte-sched--ahead { + color: #15803d; + font-weight: 500; +} + +.dte-hint--behind, +.dte-sched--behind { + color: #b45309; + font-weight: 500; +} + +.dte-hint--level, +.dte-sched--level { + color: rgb(0 0 0 / 55%); +} + +/* In the party panel the schedule line carries more weight than the fields around it. */ +.dte-sched { + font-weight: 500; +} + +/* Says why a party in the list cannot be picked, beside the party rather than in a tooltip. */ +.dte-covered { + color: #15803d; + font-size: 0.8rem; +} + +/* + * An amount that looks like a slip. Red rather than the amber used for arrears: arrears are ordinary + * and the agent's job to recover, while this is a figure that probably was not handed over at all, + * and once saved it cannot be taken back. It still does not block - the clerk decides. + */ +.dte-hint--overpay { + color: #b91c1c; + font-weight: 500; +} + +/* + The agent's written-down round, as the counter reads it. + + Laid out as lines rather than as the input grid this replaced: there is nothing to fill in here, + only a list to check a bag of cash against, and controls would invite editing something the clerk + has no business editing. +*/ +.dte-pending-row { + display: flex; + align-items: baseline; + gap: 12px; + padding: 10px 2px; + border-bottom: 1px solid rgb(0 0 0 / 8%); + + &:last-of-type { + border-bottom: none; + } + + /* First, and in the quieter weight: the date is context for the line, not what is being read. */ + &__date { + min-width: 96px; + opacity: 0.7; + font-variant-numeric: tabular-nums; + } + + &__code { + font-weight: 600; + min-width: 84px; + } + + &__name { + flex: 1; + min-width: 0; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + opacity: 0.75; + } + + &__mode { + font-size: 12px; + text-transform: uppercase; + letter-spacing: 0.5px; + opacity: 0.6; + } + + /* Amounts are read down the column and compared, so the digits have to line up. */ + &__amount { + font-variant-numeric: tabular-nums; + font-weight: 600; + min-width: 96px; + text-align: right; + } +} + +/* The one figure the clerk acts on, said as a sentence so it is not read as another column total. */ +.dte-expected { + margin: 14px 2px 0; + font-size: 14px; + line-height: 1.5; + + strong { + font-variant-numeric: tabular-nums; + } +} + +/* Quieter than the figure above it: a note about how the screen behaves, not part of the count. */ +.dte-expected--note { + font-size: 13px; + opacity: 0.65; + margin-top: 4px; +} + +.dte-apply-receipt { + margin-left: 8px; +} + +/* Agent Tran: what the agent owes, what the cashier has taken in, and what is left. */ +.dte-money { + display: flex; + gap: 12px; + margin: 8px 0 12px; + flex-wrap: wrap; + + &__box { + flex: 1; + min-width: 160px; + padding: 10px 14px; + border: 1px solid rgb(0 0 0 / 10%); + border-radius: 8px; + display: flex; + flex-direction: column; + gap: 4px; + font-variant-numeric: tabular-nums; + + strong { + font-size: 18px; + } + + &--due { + border-color: rgb(198 40 40 / 40%); + background: rgb(198 40 40 / 4%); + } + } + + &__label { + font-size: 12px; + opacity: 0.7; + text-transform: uppercase; + letter-spacing: 0.04em; + } +} + +.dte-subhead { + margin: 16px 0 4px; + font-size: 14px; + font-weight: 600; +} + +.dte-pending-row--day { + cursor: pointer; +} + +.dte-day-lines { + margin-left: 24px; + max-height: 280px; + overflow-y: auto; + border-left: 3px solid rgb(25 118 210 / 30%); + padding-left: 10px; +} + +.dte-status { + font-size: 12px; + padding: 2px 8px; + border-radius: 10px; + background: rgb(237 108 2 / 12%); + color: #b45309; + + &--ok { + background: rgb(46 125 50 / 12%); + color: #2e7d32; + } +} + +.dte-list-arrow { + cursor: pointer; +} + +.dte-same-party { + color: #c62828; + font-weight: 600; +} diff --git a/src/app/deposits/daily-transaction-entry/daily-transaction-entry.component.ts b/src/app/deposits/daily-transaction-entry/daily-transaction-entry.component.ts new file mode 100644 index 000000000..dce4d07d8 --- /dev/null +++ b/src/app/deposits/daily-transaction-entry/daily-transaction-entry.component.ts @@ -0,0 +1,1100 @@ +/** + * Copyright since 2025 Mifos Initiative + * + * This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. + */ + +/** Angular Imports */ +import { Component, OnInit, inject } from '@angular/core'; +import { UntypedFormBuilder, UntypedFormGroup, UntypedFormArray, Validators } from '@angular/forms'; +import { Router } from '@angular/router'; +import { MatDialog } from '@angular/material/dialog'; +import { STANDALONE_SHARED_IMPORTS } from 'app/standalone-shared.module'; +import { MatAutocompleteModule } from '@angular/material/autocomplete'; +import { MatIconModule } from '@angular/material/icon'; +import { ConfirmationDialogComponent } from 'app/shared/confirmation-dialog/confirmation-dialog.component'; + +/** Custom Components */ +import { DayCollectionDialogComponent } from './day-collection-dialog/day-collection-dialog.component'; +import { NoticeDialogComponent } from './notice-dialog/notice-dialog.component'; +import { + ApplyDdsReceiptDialogComponent, + EntryDay, + groupByDay +} from './apply-dds-receipt-dialog/apply-dds-receipt-dialog.component'; + +/** Custom Services */ +import { OrganizationService } from 'app/organization/organization.service'; +import { AlertService } from 'app/core/alert/alert.service'; +import { SettingsService } from 'app/settings/settings.service'; +import { Dates } from 'app/core/utils/dates'; +import { announceHeldForApproval, heldForApproval } from 'app/deposits/dds-approvals/dds-approval-notice'; + +/** + * Daily Transaction Entry component. + * + * The screen a collection agent uses at the end of a day's round: pick a + * transaction type, then batch-enter that day's collections against + * assigned parties (Party Tran), settle collected cash to the branch + * (Agent Tran), or record a withdrawal/transfer between accounts (Withdraw + * Deposite). Modelled directly on the legacy Daily Transaction Detail + * screen this replaces. + * + * Backed by the DDS Party Transaction API (`/dds/partytransactions`) for + * Party Tran, Agent Tran and Withdraw Deposite - the three types this + * screen actually collects fields for; Party Payment / OD Entry / OD + * Payment remain placeholders until the backend distinguishes them too. + * "Retrieve" (a ready-made list of today's assigned parties) isn't wired - + * that needs a dedicated per-agent Party lookup this MVP doesn't have yet. + */ +@Component({ + selector: 'mifosx-daily-transaction-entry', + templateUrl: './daily-transaction-entry.component.html', + styleUrls: ['./daily-transaction-entry.component.scss'], + imports: [ + ...STANDALONE_SHARED_IMPORTS, + MatAutocompleteModule, + MatIconModule + ] +}) +export class DailyTransactionEntryComponent implements OnInit { + private formBuilder = inject(UntypedFormBuilder); + private router = inject(Router); + private organizationService = inject(OrganizationService); + private alertService = inject(AlertService); + private dialog = inject(MatDialog); + private settingsService = inject(SettingsService); + private dateUtils = inject(Dates); + + private static readonly TRANSACTION_TYPE_CODES: Record = { + 'Party Tran': 'PARTY_TRAN', + 'Agent Tran': 'AGENT_TRAN', + 'Withdraw Deposite': 'WITHDRAW_DEPOSITE' + }; + + /** + * The legacy screen's six types. Only the three the backend actually distinguishes can be picked - + * the rest are shown but not selectable, so the screen stays honest about what the system does + * today instead of accepting an entry it will reject on save. + */ + transactionTypes = [ + { label: 'Party Tran', supported: true }, + { label: 'Agent Tran', supported: true }, + { label: 'Withdraw Deposite', supported: true }, + { label: 'Party Payment', supported: false }, + { label: 'OD Entry', supported: false }, + { label: 'OD Payment', supported: false } + ]; + + transactionModes = [ + 'Cash', + 'Transfer', + 'Clearing' + ]; + + entryForm: UntypedFormGroup = this.formBuilder.group({ + agentId: [ + '', + Validators.required + ], + transactionType: [ + 'Party Tran', + Validators.required + ], + transactionDate: [ + new Date(), + Validators.required + ], + // Withdraw Deposite fields + creditorPartyCode: [''], + creditorPartyName: [ + { + value: '', + disabled: true + } + ], + creditorPartyBalance: [ + { + value: 0, + disabled: true + } + ], + debitPartyCode: [''], + debitPartyName: [ + { + value: '', + disabled: true + } + ], + debitPartyBalance: [ + { + value: 0, + disabled: true + } + ], + withdrawAmount: [0], + withdrawDescription: [''], + // Agent Tran fields + settlementAmount: [0], + settlementMode: ['Cash'], + settlementAccountType: [''], + settlementAccountNo: [''], + settlementDescription: [''], + collections: this.formBuilder.array([]) + }); + + agentInfo = { + name: '', + address: '', + city: '', + accountNo: '', + guarantor1: '', + guarantor2: '', + currentBalance: 0 + }; + + agentLookupFailed = false; + + /** Every agent still on the books - picked from a list rather than keyed by id. */ + agents: any[] = []; + + /** + * The picked agent's parties, fetched once when the agent is chosen. Every party code typed into + * the grid is resolved from here, so a clerk keying a round of thirty collections waits on the + * network once rather than thirty times - and a code belonging to another agent's party simply + * will not resolve, which is the check that matters. + */ + agentParties: any[] = []; + + /** + * The party behind the row being worked on, shown in the panel beside the agent's - who this + * collection is for, what they owe daily, and what their balance becomes once it is saved. + */ + partyInfo: any = null; + + /** Which grid row the party panel is showing. */ + activeRow = -1; + + /** + * What has already been keyed for this agent on the chosen date. + * + * The real risk on this screen is not a wrong figure, it is the same collection book being keyed + * twice - an interruption mid-round, and every customer in it gets credited again while the agent + * appears to be holding cash he already handed over. Showing the round back removes the guesswork. + */ + todaysEntries: any[] = []; + + get todaysTotal(): number { + return this.todaysEntries.reduce((sum, entry) => sum + Number(entry.amount || 0), 0); + } + + /** + * What this party has already been collected for on this date, if anything. + * + * Shown on the row itself rather than in a summary above it. A customer paying twice in a day is + * legitimate - arrears, or two days at once - so this never blocks the entry. It only has to be + * where the clerk is already looking at the moment they pick the party, which a panel at the top + * of the screen is not. + */ + alreadyCollected(index: number): number { + const partyCode = String(this.collections.at(index)?.get('partyCode')?.value || ''); + if (!partyCode) { + return 0; + } + return this.todaysEntries + .filter((entry) => String(entry.partyCode) === partyCode) + .reduce((sum, entry) => sum + Number(entry.amount || 0), 0); + } + + /** + * Where a party stands against the instalments it owes, in the words the branch uses. + * + * The running balance says how much has been paid, never whether it was paid on time - a customer + * four days ahead and one eleven days behind can hold the same figure. This is what the clerk needs + * at the moment they pick the party, so it is worked out per row rather than shown in a summary. + * + * A shortfall is split in two because the bank treats the halves differently: what is still inside + * the catch-up window can be collected today, and what has fallen outside it is gone. Putting them + * together would have the clerk asking for money the bank has already written off. + */ + schedule(party: any): { state: 'ahead' | 'behind' | 'level'; text: string } | null { + const daily = Number(party?.dailyDepositAmount || 0); + if (!party || daily <= 0) { + return null; + } + + const advance = Number(party.advanceAmount || 0); + if (advance > 0) { + return { state: 'ahead', text: `${this.money(advance)} already paid ahead` }; + } + + // What to ask for today is the recoverable shortfall exactly as it stands - today's own + // instalment is already inside it, because the schedule counts the first instalment on the + // opening day itself. Adding the daily on top would charge today twice, which on an account + // opened this morning reads as two days owed on its first day. + // + // Instalments past the catch-up window are deliberately left out of both the figure and the + // words - they cannot be collected, so naming them would only have the clerk asking for money + // the bank has already written off, and reading a shortfall the amount does not cover. + const recoverable = Number(party.recoverableAmount || 0); + if (recoverable > 0) { + return { state: 'behind', text: `Take ${this.money(recoverable)} to complete today` }; + } + + return { state: 'level', text: 'Up to date' }; + } + + private money(value: number): string { + return value.toLocaleString('en-IN', { minimumFractionDigits: 2, maximumFractionDigits: 2 }); + } + + /** The party on a given collection row, so the row can show where that party stands. */ + partyOfRow(index: number): any { + const partyCode = String(this.collections.at(index)?.get('partyCode')?.value || ''); + return partyCode ? this.agentParties.find((p) => String(p.partyCode) === partyCode) : null; + } + + /** + * Says so plainly when the agent just picked has nobody to collect from. + * + * An empty party list looks identical to a list that has not loaded, and a clerk who reads it as + * the second will sit waiting for names that are never coming. Raised as a dialog rather than a + * line under the field because it means this agent cannot be used for a round at all - there is + * nothing to key, and the clerk should go back and pick another. + */ + private warnIfAgentHasNoParties(): void { + if (this.agentParties.length > 0) { + return; + } + const agentId = this.entryForm.get('agentId')?.value; + const agent = this.agents.find((a: any) => String(a.id) === String(agentId)); + this.dialog.open(NoticeDialogComponent, { + data: { + heading: 'No customers for this agent', + // Named for what is actually missing. The agent's own settlement account is a different + // thing and is usually there - saying "no accounts" sends whoever reads it to check the + // wrong record. + message: + `No customer accounts are assigned to ${agent?.agentCode || 'this agent'}, so there is nothing ` + + 'to collect. Assign customers to this agent first, or choose a different agent.' + }, + width: '420px', + autoFocus: false + }); + } + + /** + * Adds a row for days other than today - paid ahead, or owed from days gone by. + * + * Kept off the ordinary row and put behind its own button, because most collections are simply + * today's instalment: a box asking about days would sit empty on nearly every row, and a stray + * value left in one would charge a customer for days they did not pay for. + * + * The row it adds is filled in and left in the list rather than posted, so it is saved with the + * rest of the round and the clerk sees it beside the others before committing. + */ + addDayBasedRow(mode: 'advance' | 'previous'): void { + const dialogRef = this.dialog.open(DayCollectionDialogComponent, { + data: { mode, parties: this.agentParties }, + width: '460px', + autoFocus: false + }); + + dialogRef.afterClosed().subscribe((picked: any) => { + if (!picked) { + return; + } + this.addCollectionRow(); + const row = this.collections.at(this.collections.length - 1); + row.get('partyCode')?.setValue(picked.partyCode); + this.loadPartyForRow(this.collections.length - 1); + row.get('amount')?.setValue(picked.amount); + row.get('advanceDays')?.setValue(picked.advanceDays ?? ''); + row.get('arrearsDays')?.setValue(picked.arrearsDays ?? ''); + row + .get('description') + ?.setValue( + `${picked.days} ${picked.days === 1 ? 'day' : 'days'} ${mode === 'advance' ? 'in advance' : 'arrears'}` + ); + this.recalculateRowBalance(this.collections.length - 1); + }); + } + + /** + * Questions an amount on an ordinary row that is not the day's instalment. + * + * Paying for other days now has its own buttons, and each works out the amount from the scheme + * itself. What is left in the plain Amount box is one thing: today. Anything else is either a + * mistyped figure - a zero too many turns a hundred rupees into a thousand - or days being keyed by + * hand, which is the very thing the buttons exist to stop. + * + * It warns and does not block. A customer can hand over an odd amount, and the clerk is the one who + * saw them do it; what they need is to be told before saving, because a collection cannot be taken + * back afterwards - it can only be carried into the days that follow. + */ + overpayWarning(index: number): string | null { + const party = this.partyOfRow(index); + const daily = Number(party?.dailyDepositAmount || 0); + const amount = Number(this.collections.at(index)?.get('amount')?.value || 0); + if (!party || daily <= 0 || amount <= 0) { + return null; + } + + // Never raised on a row the Advance or Previous Days button filled in: those figures are the + // scheme's own and the server checks them, so they cannot be the slip this is looking for. + const row = this.collections.at(index); + if (row?.get('advanceDays')?.value || row?.get('arrearsDays')?.value) { + return null; + } + + if (amount === daily) { + return null; + } + if (amount > daily) { + return ( + `${this.money(amount)} is more than the daily ${this.money(daily)}. ` + + 'Use Advance to pay ahead, or Previous Days to clear arrears.' + ); + } + return `${this.money(amount)} is less than the daily ${this.money(daily)}.`; + } + + /** + * Whether this party is already paid ahead far enough that today is covered. + * + * The agent still calls - the round does not skip a door - but there is nothing to collect, so the + * row is closed to entry rather than hidden. A party that vanished from the list would have the + * clerk wondering what had happened to it. + */ + isCoveredByAdvance(party: any): boolean { + return Number(party?.advanceDays || 0) > 0; + } + + /** + * The agent's own written-down collections for the chosen date, and whether they are being read. + * + * Held rather than typed. A Party Tran is no longer something the clerk composes - the agent + * composed it on their round, and this screen shows it back so the cash can be counted against it. + */ + pendingEntries: any[] = []; + pendingLoading = false; + + /** What the agent should be handing over: their own entries, added up. */ + get pendingTotal(): number { + return this.pendingEntries.reduce((sum: number, entry: any) => sum + Number(entry.amount || 0), 0); + } + + /** + * Reads everything this agent has outstanding up to the date on the form, customer by customer. + * + * Not only that day's: what was written on earlier days and not yet handed in is still owed, and a + * clerk checking the round should see it in one list rather than open every date in turn. Each line + * carries its own date. Asked again whenever the agent or the date changes. + */ + loadPendingEntries(): void { + const agentId = this.entryForm.get('agentId')?.value; + const date = this.entryForm.get('transactionDate')?.value; + if (!agentId || !date) { + this.pendingEntries = []; + return; + } + + this.pendingLoading = true; + const upTo = this.dateUtils.formatDate(date, 'yyyy-MM-dd'); + this.organizationService.getAgentPendingCollections(agentId).subscribe({ + next: (summary: any) => { + this.pendingEntries = (summary?.entries || []).filter( + (entry: any) => entry.status === 'PENDING' && String(entry.entryDate) <= upTo + ); + this.pendingLoading = false; + }, + error: () => { + this.pendingEntries = []; + this.pendingLoading = false; + } + }); + } + + /** The picked agent's own GL account code - where the cashier receipts their cash. */ + agentGlCode: string | null = null; + agentGlName: string | null = null; + + // ── Agent Tran: the agent's money, owed and received ────────────────────────────────────── + // + // The agent's cash reaches the bank at the cashier's counter, never on this screen. What Agent Tran + // shows is where that stands: what the agent owes on everything they wrote down, what the cashier has + // taken in against it, and what is left - and from here the receipts are applied to the customers. + + /** What the agent still has outstanding up to the date on the form, a day at a time. */ + agentDays: EntryDay[] = []; + + /** DDS receipts on the agent's GL account not yet applied, including those still with the manager. */ + agentReceipts: any[] = []; + + agentMoneyLoading = false; + + /** The day whose lines are open in the Agent Tran list. */ + agentOpenDay: string | null = null; + + get agentOwed(): number { + return this.agentDays.reduce((sum, day) => sum + day.total, 0); + } + + get agentReceived(): number { + return this.agentReceipts.filter((r) => r.status === 'APPROVED').reduce((sum, r) => sum + Number(r.amount || 0), 0); + } + + get agentWaiting(): number { + return this.agentReceipts.filter((r) => r.status !== 'APPROVED').reduce((sum, r) => sum + Number(r.amount || 0), 0); + } + + /** + * What is still to be taken from the agent for this date - never below nothing. The receipts are + * not tied to a date (cash often reaches the counter the day after the round), so more received than + * this date's entries is not a debt the other way; it is cash for another day, said so separately. + */ + get agentBalance(): number { + return Math.max(0, Math.round((this.agentOwed - this.agentReceived) * 100) / 100); + } + + /** Received cash beyond this date's entries - most likely for another day's. */ + get agentReceivedBeyondDay(): number { + return Math.max(0, Math.round((this.agentReceived - this.agentOwed) * 100) / 100); + } + + loadAgentMoney(): void { + const agentId = this.entryForm.get('agentId')?.value; + const date = this.entryForm.get('transactionDate')?.value; + this.agentOpenDay = null; + if (!agentId || !date) { + this.agentDays = []; + this.agentReceipts = []; + return; + } + this.agentMoneyLoading = true; + // Everything still outstanding up to the date on the screen, whatever day it was written: an agent + // who comes in after three days brings three days' cash, and the counter has to see all of it to + // know what to take. + const upTo = this.dateUtils.formatDate(date, 'yyyy-MM-dd'); + this.organizationService.getAgentPendingCollections(agentId).subscribe({ + next: (summary: any) => { + this.agentDays = groupByDay( + (summary?.entries || []).filter((entry: any) => entry.status === 'PENDING' && String(entry.entryDate) <= upTo) + ); + this.agentMoneyLoading = false; + }, + error: () => { + this.agentDays = []; + this.agentMoneyLoading = false; + } + }); + this.organizationService.getAgentCashReceipts(agentId, undefined, true).subscribe({ + next: (rows: any[]) => (this.agentReceipts = rows || []), + error: () => (this.agentReceipts = []) + }); + } + + toggleAgentDay(date: string): void { + this.agentOpenDay = this.agentOpenDay === date ? null : date; + } + + /** + * Opens Apply DDS Receipt: the cashier's DDS receipt on this agent's GL account, applied to what the + * agent still has outstanding. Its own screen, like Apply Additional Shares, so the list above stays + * what it is - what to take from the agent - and the applying is done in one deliberate place. + */ + openApplyReceipt(): void { + const agentId = this.entryForm.get('agentId')?.value; + if (!agentId) { + return; + } + const picked = this.agents.find((agent: any) => agent.id === agentId); + this.dialog + .open(ApplyDdsReceiptDialogComponent, { + width: '780px', + maxWidth: '96vw', + panelClass: 'apply-collection-dialog-panel', + data: { + agentId, + agentCode: picked?.agentCode || '', + agentName: this.agentInfo.name || picked?.agentNameId || '', + glCode: this.agentGlCode, + // The day chosen on the screen: its entries are what the receipts settle. + entryDate: this.dateUtils.formatDate(this.entryForm.get('transactionDate')?.value, 'yyyy-MM-dd') + } + }) + .afterClosed() + .subscribe((result: any) => { + if (!result) { + return; + } + const money = Number(result.amount || 0).toLocaleString('en-IN', { minimumFractionDigits: 2 }); + const vouchers: string[] = result.vouchers || []; + if (heldForApproval(result.response)) { + announceHeldForApproval(result.response, `Applying ${money}`, this.organizationService, this.alertService); + this.loadPendingEntries(); + return; + } + this.alertService.alert({ + type: 'Success', + message: + `${vouchers.length === 1 ? 'Voucher' : 'Vouchers'} ${vouchers.join(', ')} applied - ` + + `${result.entries} ${result.entries === 1 ? 'entry' : 'entries'}, ${money}` + }); + this.loadPendingEntries(); + this.loadTodaysEntries(); + this.loadAgentMoney(); + }); + } + + get isPartyTran(): boolean { + return this.entryForm.get('transactionType').value === 'Party Tran'; + } + + get isWithdrawDeposite(): boolean { + return this.entryForm.get('transactionType').value === 'Withdraw Deposite'; + } + + get isAgentTran(): boolean { + return this.entryForm.get('transactionType').value === 'Agent Tran'; + } + + get collections(): UntypedFormArray { + return this.entryForm.get('collections') as UntypedFormArray; + } + + private buildCollectionRow(): UntypedFormGroup { + const row = this.formBuilder.group({ + partyCode: [ + '', + Validators.required + ], + transactionMode: [ + 'Cash', + Validators.required + ], + accountType: [''], + accountNo: [ + '', + Validators.required + ], + amount: [ + 0, + Validators.required + ], + /* + * Days taken in advance, when the customer is paying ahead rather than for today. Empty on an + * ordinary row. The amount follows from it - the scheme's daily instalment times the days - so + * the clerk picks days, not rupees, and cannot quote a figure the scheme does not charge. + */ + advanceDays: [''], + /* Days of arrears being cleared. Same idea as advanceDays, the other way round. */ + arrearsDays: [''], + chequeNo: [''], + tokenNo: [''], + description: [''], + partyName: [ + { + value: '', + disabled: true + } + ], + balanceAfterTransaction: [ + { + value: 0, + disabled: true + } + ] + }); + + // Cash has no instrument behind it, so switching back to cash drops anything typed against a + // cheque or clearing entry rather than saving a stale number with it. + row.get('transactionMode')?.valueChanges.subscribe((mode) => { + if (mode === 'Cash') { + row.get('chequeNo')?.setValue('', { emitEvent: false }); + row.get('tokenNo')?.setValue('', { emitEvent: false }); + } + }); + + return row; + } + + addCollectionRow(): void { + this.collections.push(this.buildCollectionRow()); + } + + removeCollectionRow(index: number): void { + this.collections.removeAt(index); + } + + ngOnInit(): void { + // Closed agents are off the books entirely; everyone else is kept, and which of them can be + // picked depends on the transaction type - see selectableAgents. + this.organizationService.getAgents().subscribe({ + next: (agents: any[]) => (this.agents = (agents || []).filter((agent: any) => !agent.closeDate)), + error: () => (this.agents = []) + }); + + // Withdraw Deposite: the open accounts either side can be picked from, and a code typed or picked + // fills in whose account it is and what it holds, so the clerk checks the transfer against both. + this.organizationService.getParties().subscribe({ + next: (parties: any) => { + const all = Array.isArray(parties) ? parties : parties?.pageItems || []; + this.openParties = all.filter((party: any) => party.status === 'ACTIVE'); + this.fillTransferParty('creditor'); + this.fillTransferParty('debit'); + }, + error: () => (this.openParties = []) + }); + this.entryForm.get('creditorPartyCode')?.valueChanges.subscribe(() => this.fillTransferParty('creditor')); + this.entryForm.get('debitPartyCode')?.valueChanges.subscribe(() => this.fillTransferParty('debit')); + } + + /** Open DDS accounts, for the two sides of a Withdraw Deposite. */ + openParties: any[] = []; + + /** + * The open accounts either side can be picked from. The server moves money only between two + * accounts of the agent on the entry, so once an agent is chosen only that agent's accounts are offered. + */ + private get transferableParties(): any[] { + const agentId = Number(this.entryForm.get('agentId')?.value) || null; + return agentId ? this.openParties.filter((party: any) => Number(party.agentId) === agentId) : this.openParties; + } + + /** The open accounts matching what has been typed on one side - by code, name or account number, any case. */ + transferPartyOptions(side: 'creditor' | 'debit'): any[] { + const typed = String(this.entryForm.get(side + 'PartyCode')?.value || '') + .trim() + .toLowerCase(); + const other = String(this.entryForm.get(this.otherSide(side) + 'PartyCode')?.value || '') + .trim() + .toUpperCase(); + // Once an account is picked the whole list comes back, so another can be chosen without clearing the box. + const picked = this.transferableParties.some((party: any) => String(party.partyCode).toLowerCase() === typed); + return this.transferableParties + .filter((party: any) => String(party.partyCode).toUpperCase() !== other) + .filter( + (party: any) => + !typed || + picked || + String(party.partyCode).toLowerCase().includes(typed) || + String(party.partyName || '') + .toLowerCase() + .includes(typed) || + String(party.accountNo || '') + .toLowerCase() + .includes(typed) + ) + .slice(0, 50); + } + + /** True when the same account has been put on both sides. */ + get transferToItself(): boolean { + const creditor = String(this.entryForm.get('creditorPartyCode')?.value || '') + .trim() + .toUpperCase(); + const debit = String(this.entryForm.get('debitPartyCode')?.value || '') + .trim() + .toUpperCase(); + return this.isWithdrawDeposite && !!creditor && creditor === debit; + } + + /** + * When the box is left with a name or part of one typed in and only one account matches it, that + * account is taken - so typing "amit" and moving on picks Amit's account without opening the list. + */ + settleTransferParty(side: 'creditor' | 'debit'): void { + const control = this.entryForm.get(side + 'PartyCode'); + const typed = String(control?.value || '').trim(); + if (!typed || this.partyByCode(typed)) { + return; + } + const matches = this.transferPartyOptions(side); + if (matches.length === 1) { + control?.setValue(matches[0].partyCode); + } + } + + private otherSide(side: 'creditor' | 'debit'): 'creditor' | 'debit' { + return side === 'creditor' ? 'debit' : 'creditor'; + } + + private partyByCode(code: string): any { + const wanted = String(code || '') + .trim() + .toUpperCase(); + return wanted ? this.openParties.find((each: any) => String(each.partyCode).toUpperCase() === wanted) : undefined; + } + + /** + * Fills a side's name and balance from the code on it, and clears them when it matches no account. + * The code is matched in any case and put back as the account has it, so "pt0002" becomes PT0002. + */ + private fillTransferParty(side: 'creditor' | 'debit'): void { + const control = this.entryForm.get(side + 'PartyCode'); + const party = this.partyByCode(control?.value); + if (party && control?.value !== party.partyCode) { + control?.setValue(party.partyCode, { emitEvent: false }); + } + this.entryForm.get(side + 'PartyName')?.setValue(party ? party.partyName : '', { emitEvent: false }); + this.entryForm + .get(side + 'PartyBalance') + ?.setValue(party ? Number(party.currentBalance || 0) : 0, { emitEvent: false }); + } + + /** + * The agents that can be picked for the transaction type currently chosen. + * + * A deactivated agent no longer collects, so they are not offered for a collection or a transfer. + * Settlement is the exception: an agent switched off mid-round is still holding the cash they + * already took, and that cash has to be able to reach a branch. Leaving them out of the settlement + * list would strand it with no way to hand it in. + */ + get selectableAgents(): any[] { + if (this.isAgentTran) { + return this.agents; + } + return this.agents.filter((agent: any) => agent.active); + } + + /** Flags a picked agent that is no longer collecting, so the entry is not made by mistake. */ + get pickedAgentIsInactive(): boolean { + const agentId = this.entryForm.get('agentId')?.value; + if (!agentId) { + return false; + } + const picked = this.agents.find((agent: any) => agent.id === agentId); + return !!picked && !picked.active; + } + + /** Whether this row needs a cheque or token number - a cash collection has neither. */ + needsInstrument(index: number): boolean { + return this.collections.at(index)?.get('transactionMode')?.value !== 'Cash'; + } + + loadAgent(): void { + const agentId = this.entryForm.get('agentId')?.value; + this.agentLookupFailed = false; + this.partyInfo = null; + this.activeRow = -1; + if (!agentId) { + this.agentParties = []; + this.pendingEntries = []; + this.agentGlCode = null; + this.agentDays = []; + this.agentReceipts = []; + return; + } + + this.organizationService.getAgent(agentId).subscribe({ + next: (agent: any) => { + const guarantors = agent.guarantors || []; + this.agentInfo = { + name: agent.agentNameId || '', + address: agent.agentAddress1 || '', + city: agent.agentCity || '', + accountNo: agent.agentSavingAccountNo || '', + guarantor1: guarantors[0]?.guarantorName || '', + guarantor2: guarantors[1]?.guarantorName || '', + currentBalance: agent.agentCurrentBalance || 0 + }; + this.agentGlCode = agent.glAccountCode || null; + this.agentGlName = agent.glAccountName || null; + }, + error: () => { + this.agentLookupFailed = true; + this.agentInfo = { + name: '', + address: '', + city: '', + accountNo: '', + guarantor1: '', + guarantor2: '', + currentBalance: 0 + }; + } + }); + + this.organizationService.getPartiesOfAgent(agentId).subscribe({ + next: (parties: any[]) => { + this.agentParties = parties || []; + this.warnIfAgentHasNoParties(); + }, + error: () => (this.agentParties = []) + }); + + this.loadTodaysEntries(); + this.loadPendingEntries(); + this.loadAgentMoney(); + } + + /** This agent's collections already recorded for the date on the screen. */ + loadTodaysEntries(): void { + const agentId = this.entryForm.get('agentId')?.value; + if (!agentId) { + this.todaysEntries = []; + return; + } + const onDate = this.dateUtils.formatDate(this.entryForm.get('transactionDate')?.value, 'yyyy-MM-dd'); + + this.organizationService.getPartyTransactionsOfAgent(agentId).subscribe({ + next: (transactions: any[]) => { + this.todaysEntries = (transactions || []).filter( + (transaction: any) => + transaction.transactionType === 'PARTY_TRAN' && + this.dateUtils.formatDate(transaction.transactionDate, 'yyyy-MM-dd') === onDate + ); + }, + error: () => (this.todaysEntries = []) + }); + } + + /** + * Resolves the party code typed into a row. A code this agent does not collect for is left + * unresolved rather than fetched anyway - an agent posting against someone else's party is the + * mistake this screen most needs to catch. + */ + loadPartyForRow(index: number): void { + const row = this.collections.at(index); + if (!row) { + return; + } + const partyCode = String(row.get('partyCode')?.value || '').trim(); + this.activeRow = index; + + const party = this.agentParties.find((candidate) => String(candidate.partyCode) === partyCode); + this.partyInfo = party || null; + + row.get('partyName')?.setValue(party?.partyName || '', { emitEvent: false }); + // The account credited is the party's own linked savings account, never keyed by hand. + row.get('accountNo')?.setValue(party?.creditToAccount || party?.partyCode || '', { emitEvent: false }); + row.get('accountType')?.setValue(party?.creditToAccountType || 'Savings', { emitEvent: false }); + + this.recalculateRowBalance(index); + } + + /** The party's balance once this row is saved - what the legacy screen called Bal. After This Tr. */ + recalculateRowBalance(index: number): void { + const row = this.collections.at(index); + if (!row) { + return; + } + const party = this.agentParties.find( + (candidate) => String(candidate.partyCode) === String(row.get('partyCode')?.value || '').trim() + ); + const current = Number(party?.currentBalance || 0); + const amount = Number(row.get('amount')?.value || 0); + row.get('balanceAfterTransaction')?.setValue(current + amount, { emitEvent: false }); + } + + /** The balance the party panel shows - live against whatever the active row currently holds. */ + get partyBalanceAfter(): number { + if (!this.partyInfo || this.activeRow < 0) { + return 0; + } + const row = this.collections.at(this.activeRow); + return Number(this.partyInfo.currentBalance || 0) + Number(row?.get('amount')?.value || 0); + } + + /** Total of the round being keyed in - what the agent should be handing over. */ + get collectionTotal(): number { + return this.collections.controls.reduce((sum, row) => sum + Number(row.get('amount')?.value || 0), 0); + } + + /** Re-reads what has already been keyed for this agent on the chosen date. */ + retrieve(): void { + this.loadTodaysEntries(); + } + + viewLedger(): void { + // No ledger view exists yet for this agent. + } + + signature(): void { + // No signature capture/verification flow exists yet. + } + + cancel(): void { + this.entryForm.reset({ + transactionType: 'Party Tran', + transactionDate: new Date() + }); + this.collections.clear(); + } + + exit(): void { + this.router.navigate(['/accounts/dds']); + } + + /** + * Save, asking first when what is about to happen is a settlement. + * + * A settlement credits every customer on the list and records the cash as the agent's, in one + * act that cannot be taken back - so the screen says the figure out loud and waits. The question + * is the one the clerk is actually answering at the counter: is this much really in front of me. + * + * The double-entry guard this used to carry has moved to where the entries are now made. Two + * lines for one account on one day are refused on the agent's own screen, and refused again by + * the server when the day is settled; by the time a clerk sees the list there is nothing left to + * warn about. + */ + save(): void { + // A Party Tran cannot be saved from here at all. The screen shows the agent's own entries so + // the cash can be counted against them; taking that cash in is done where the branch takes cash + // in, and two ways to credit the same customers is one way too many. The button is not rendered + // for this type either - this is the same rule said again where it cannot be bypassed. + // Agent Tran is not saved from here either any more. The agent's cash is taken in at the cashier's + // counter, and a second way to take the same cash in is one way too many; Agent Tran shows where it + // stands and applies the cashier's receipts. + if (this.isPartyTran || this.isAgentTran) { + return; + } + + this.postTransaction(); + } + + private postTransaction(): void { + const raw = this.entryForm.getRawValue(); + + // A Party Tran is no longer a transaction this screen composes. The agent wrote the round down + // on their own; what happens here is that it is settled, all of it at once, through a call of + // its own. Everything else on this screen still posts a transaction the way it always did. + if (this.isPartyTran) { + this.settleCollections(); + return; + } + + const dateFormat = this.settingsService.dateFormat; + const locale = this.settingsService.language.code; + const transactionType = DailyTransactionEntryComponent.TRANSACTION_TYPE_CODES[raw.transactionType]; + + const payload: any = { + agentId: Number(raw.agentId), + transactionType, + transactionDate: this.dateUtils.formatDate(raw.transactionDate, dateFormat), + dateFormat, + locale + }; + + if (this.isWithdrawDeposite) { + payload.creditorPartyCode = raw.creditorPartyCode; + payload.debitPartyCode = raw.debitPartyCode; + payload.withdrawAmount = raw.withdrawAmount; + payload.withdrawDescription = raw.withdrawDescription || undefined; + } else if (this.isAgentTran) { + payload.amount = raw.settlementAmount; + payload.transactionMode = raw.settlementMode; + payload.accountType = raw.settlementAccountType || undefined; + payload.accountNo = raw.settlementAccountNo || undefined; + payload.description = raw.settlementDescription || undefined; + } + + // What was just posted, captured before the form is cleared, so the confirmation can name it. + const posted = this.postedSummary(raw); + + this.organizationService.createPartyTransaction(payload).subscribe((response: any) => { + // On a money screen a silent save is worse than no save: with nothing to confirm it, a clerk + // who looks away reasonably assumes it did not go through and posts the whole round again. + // + // With maker-checker on, the request comes back with a command id and nothing done: it is + // waiting for a checker, and saying it was posted would be telling the clerk the money moved. + const awaitingApproval = !!response?.commandId && response?.resourceId == null; + this.alertService.alert({ + type: 'Success', + message: awaitingApproval ? `Sent for approval: ${posted}. Nothing moves until a checker approves it.` : posted + }); + + this.collections.clear(); + this.partyInfo = null; + this.activeRow = -1; + // The round just saved becomes part of what the screen shows back. + this.loadTodaysEntries(); + this.entryForm.patchValue({ + creditorPartyCode: '', + debitPartyCode: '', + withdrawAmount: 0, + withdrawDescription: '', + settlementAmount: 0, + settlementAccountType: '', + settlementAccountNo: '', + settlementDescription: '' + }); + this.loadAgent(); + }); + } + + /** + * Hands the agent's day over: every entry they wrote down for this date becomes a real credit. + * + * No amount is sent and none is asked for on screen. What settles is what the entries say - a + * figure typed at the counter would be a second opinion about a total the entries already give, + * and the two could differ. The clerk's part is to count the cash against that total and agree. + * + * All or none. A part-settled round would leave the branch holding cash against a list that no + * longer matched it, which is a worse position than simply running it again. + */ + private settleCollections(): void { + const raw = this.entryForm.getRawValue(); + if (this.pendingEntries.length === 0) { + return; + } + + const count = this.pendingEntries.length; + const total = this.pendingTotal; + const agent = this.agentInfo.name || 'agent'; + + this.organizationService + .settleAgentCollections({ + agentId: Number(raw.agentId), + entryDate: this.dateUtils.formatDate(raw.transactionDate, 'yyyy-MM-dd'), + dateFormat: 'yyyy-MM-dd', + locale: this.settingsService.language.code + }) + .subscribe((response: any) => { + const money = Number(total).toLocaleString('en-IN', { minimumFractionDigits: 2 }); + if (heldForApproval(response)) { + announceHeldForApproval( + response, + `${count} ${count === 1 ? 'collection' : 'collections'} for ${agent} (${money})`, + this.organizationService, + this.alertService + ); + this.loadPendingEntries(); + return; + } + this.alertService.alert({ + type: 'Success', + message: count + (count === 1 ? ' collection' : ' collections') + ' posted for ' + agent + ' - ' + money + }); + + // The list empties because those entries are settled now, and the agent's cash goes up by + // what was just taken - both are read again rather than worked out on screen. + this.loadPendingEntries(); + this.loadTodaysEntries(); + this.loadAgent(); + }); + } + + /** + * Says what was actually posted, in the terms the clerk was working in - a count and a total for a + * round of collections, the amount and who it came from for anything else. "Saved successfully" + * would confirm that something happened without confirming that the right thing happened. + */ + private postedSummary(raw: any): string { + const money = (value: any) => Number(value || 0).toLocaleString('en-IN', { minimumFractionDigits: 2 }); + const agent = this.agentInfo.name || 'agent'; + + if (this.isPartyTran) { + const rows = raw.collections || []; + const total = rows.reduce((sum: number, row: any) => sum + Number(row.amount || 0), 0); + return `${rows.length} ${rows.length === 1 ? 'collection' : 'collections'} posted for ${agent} — ${money(total)}`; + } + if (this.isAgentTran) { + return `${agent} settled ${money(raw.settlementAmount)} to the branch`; + } + return `${money(raw.withdrawAmount)} transferred from ${raw.debitPartyCode} to ${raw.creditorPartyCode}`; + } +} diff --git a/src/app/deposits/daily-transaction-entry/day-collection-dialog/day-collection-dialog.component.html b/src/app/deposits/daily-transaction-entry/day-collection-dialog/day-collection-dialog.component.html new file mode 100644 index 000000000..2d2306918 --- /dev/null +++ b/src/app/deposits/daily-transaction-entry/day-collection-dialog/day-collection-dialog.component.html @@ -0,0 +1,56 @@ + + +

{{ title }}

+ + + + Party + + @for (p of eligibleParties; track p.id) { + + {{ p.partyCode }} · {{ p.partyName }} · Daily {{ p.dailyDepositAmount | number: '1.0-0' }} + + } + + @if (eligibleParties.length === 0) { + + No party has recoverable days + } + + + + Days + + @for (d of dayOptions; track d) { + {{ d }} {{ d === 1 ? 'day' : 'days' }} + } + + @if (isAdvance) { + Up to 5 days + } @else if (partyCode) { + Only days still within the catch-up window + } + + + + @if (amount > 0) { +
+ {{ amount | number: '1.2-2' }} + {{ days }} × {{ party?.dailyDepositAmount | number: '1.2-2' }} daily +
+ } +
+ + + + + diff --git a/src/app/deposits/daily-transaction-entry/day-collection-dialog/day-collection-dialog.component.scss b/src/app/deposits/daily-transaction-entry/day-collection-dialog/day-collection-dialog.component.scss new file mode 100644 index 000000000..3f31a5163 --- /dev/null +++ b/src/app/deposits/daily-transaction-entry/day-collection-dialog/day-collection-dialog.component.scss @@ -0,0 +1,42 @@ +/** + * 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/. + */ + +.dcd-field { + width: 100%; +} + +.dcd-none { + color: #b45309; +} + +/* + * The figure the clerk quotes at the counter. Larger than the fields above it because it is the one + * thing the customer is told, and it is shown with its basis so the clerk can say where it came from + * rather than just naming a number. + */ +.dcd-amount { + display: flex; + align-items: baseline; + gap: 10px; + margin-top: 6px; + padding: 12px 14px; + border-radius: 6px; + background: rgb(15 118 110 / 8%); + + &__figure { + font-size: 1.3rem; + font-weight: 600; + color: #0f766e; + font-variant-numeric: tabular-nums; + } + + &__basis { + font-size: 0.8rem; + color: rgb(0 0 0 / 60%); + } +} diff --git a/src/app/deposits/daily-transaction-entry/day-collection-dialog/day-collection-dialog.component.ts b/src/app/deposits/daily-transaction-entry/day-collection-dialog/day-collection-dialog.component.ts new file mode 100644 index 000000000..e5af9b496 --- /dev/null +++ b/src/app/deposits/daily-transaction-entry/day-collection-dialog/day-collection-dialog.component.ts @@ -0,0 +1,115 @@ +/** + * Copyright since 2025 Mifos Initiative + * + * This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. + */ + +/** Angular Imports */ +import { Component, inject } from '@angular/core'; +import { CommonModule } from '@angular/common'; +import { FormsModule } from '@angular/forms'; +import { MAT_DIALOG_DATA, MatDialogModule, MatDialogRef } from '@angular/material/dialog'; +import { MatButtonModule } from '@angular/material/button'; +import { MatSelectModule } from '@angular/material/select'; +import { MatFormFieldModule } from '@angular/material/form-field'; +import { MatInputModule } from '@angular/material/input'; + +/** + * Collecting for days other than today - either paid ahead, or owed from days gone by. + * + * Both are the same act from the counter's side: the customer is paying for a number of days, and + * what that comes to is the scheme's business, not the clerk's. So the clerk picks days and the + * amount follows. Typing an amount instead would let the daily figure be worked around a little at a + * time, and would quote the customer something the scheme does not charge. + * + * The two differ only in how many days are on offer. Paying ahead is capped by the bank's allowance. + * Paying arrears is capped by what is still inside the catch-up window - older instalments are gone, + * and offering them would have the clerk asking for money the bank has already written off. + */ +@Component({ + selector: 'mifosx-day-collection-dialog', + standalone: true, + imports: [ + CommonModule, + FormsModule, + MatDialogModule, + MatButtonModule, + MatSelectModule, + MatFormFieldModule, + MatInputModule + ], + templateUrl: './day-collection-dialog.component.html', + styleUrls: ['./day-collection-dialog.component.scss'] +}) +export class DayCollectionDialogComponent { + private dialogRef = inject(MatDialogRef); + /** { mode: 'advance' | 'previous', parties: [] } - the agent's parties, as the screen already has them. */ + data = inject(MAT_DIALOG_DATA); + + partyCode = ''; + days: number | '' = ''; + + get isAdvance(): boolean { + return this.data?.mode === 'advance'; + } + + get title(): string { + return this.isAdvance ? 'Collect in advance' : 'Collect previous days'; + } + + get party(): any { + return (this.data?.parties || []).find((p: any) => String(p.partyCode) === this.partyCode) || null; + } + + /** + * The parties this can be used on. + * + * Paying ahead is offered to anyone. Paying arrears is offered only where there are arrears still + * inside the window - on an account that is up to date there is nothing to collect for past days, + * and listing it would invite an entry that is really an advance in disguise. + */ + get eligibleParties(): any[] { + const parties = this.data?.parties || []; + return this.isAdvance ? parties : parties.filter((p: any) => Number(p.recoverableAmount || 0) > 0); + } + + /** + * How many days may be picked. Five ahead, matching the bank's allowance and the server's own + * check; for arrears, only what has not yet fallen out of the catch-up window. + */ + get dayOptions(): number[] { + const daily = Number(this.party?.dailyDepositAmount || 0); + if (!this.party || daily <= 0) { + return []; + } + const limit = this.isAdvance ? 5 : Math.floor(Number(this.party.recoverableAmount || 0) / daily); + return Array.from({ length: Math.max(0, limit) }, (_, index) => index + 1); + } + + get amount(): number { + return Number(this.party?.dailyDepositAmount || 0) * Number(this.days || 0); + } + + /** Clearing the days when the party changes - a count valid for one account is not for another. */ + partyChanged(): void { + this.days = ''; + } + + get canAdd(): boolean { + return !!this.partyCode && Number(this.days) > 0 && this.amount > 0; + } + + add(): void { + this.dialogRef.close({ + partyCode: this.partyCode, + days: Number(this.days), + amount: this.amount, + // Sent as days, not just an amount: the server recomputes what they come to and checks it + // against this account, so the figure quoted here is the one that gets posted. + advanceDays: this.isAdvance ? Number(this.days) : undefined, + arrearsDays: this.isAdvance ? undefined : Number(this.days) + }); + } +} diff --git a/src/app/deposits/daily-transaction-entry/notice-dialog/notice-dialog.component.html b/src/app/deposits/daily-transaction-entry/notice-dialog/notice-dialog.component.html new file mode 100644 index 000000000..ee905a5bc --- /dev/null +++ b/src/app/deposits/daily-transaction-entry/notice-dialog/notice-dialog.component.html @@ -0,0 +1,21 @@ + + +
+ +

{{ data.heading }}

+
+ + +

{{ data.message }}

+
+ + + + + diff --git a/src/app/deposits/daily-transaction-entry/notice-dialog/notice-dialog.component.scss b/src/app/deposits/daily-transaction-entry/notice-dialog/notice-dialog.component.scss new file mode 100644 index 000000000..1965dd040 --- /dev/null +++ b/src/app/deposits/daily-transaction-entry/notice-dialog/notice-dialog.component.scss @@ -0,0 +1,44 @@ +/** + * 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/. + */ + +.nd-head { + display: flex; + align-items: center; + gap: 12px; + padding: 18px 24px 4px; + + /* + * Teal rather than the amber or red the platform's other dialogs use. Nothing has gone wrong here + * and nothing is at risk - the clerk is being told a fact about the agent they picked, and a + * warning colour would send them looking for a fault that is not there. + */ + &__ic { + display: grid; + place-items: center; + width: 34px; + height: 34px; + border-radius: 8px; + background: rgb(15 118 110 / 10%); + color: #0f766e; + flex: none; + } + + &__title { + margin: 0; + font-size: 1.05rem; + font-weight: 600; + } +} + +.nd-message { + margin: 0; + max-width: 44ch; /* stylelint-disable-line unit-allowed-list */ + font-size: 0.9rem; + line-height: 1.55; + color: rgb(0 0 0 / 70%); +} diff --git a/src/app/deposits/daily-transaction-entry/notice-dialog/notice-dialog.component.ts b/src/app/deposits/daily-transaction-entry/notice-dialog/notice-dialog.component.ts new file mode 100644 index 000000000..906d5b209 --- /dev/null +++ b/src/app/deposits/daily-transaction-entry/notice-dialog/notice-dialog.component.ts @@ -0,0 +1,39 @@ +/** + * Copyright since 2025 Mifos Initiative + * + * This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. + */ + +/** Angular Imports */ +import { Component, inject } from '@angular/core'; +import { MAT_DIALOG_DATA, MatDialogModule } from '@angular/material/dialog'; +import { MatButtonModule } from '@angular/material/button'; +import { FaIconComponent } from '@fortawesome/angular-fontawesome'; + +/** + * Something the clerk needs told, with nothing to decide. + * + * The platform's confirmation dialog asks a question - Cancel or Confirm - and its buttons imply a + * choice. Used for a plain statement it leaves the clerk hunting for what they are agreeing to, and + * "Cancel" reads as though the thing being described could be undone. The error dialog has the right + * single button but dresses the message in red, which turns "this agent has no parties" into a fault. + * + * So: one button, and the message stands on its own. + */ +@Component({ + selector: 'mifosx-notice-dialog', + standalone: true, + imports: [ + MatDialogModule, + MatButtonModule, + FaIconComponent + ], + templateUrl: './notice-dialog.component.html', + styleUrls: ['./notice-dialog.component.scss'] +}) +export class NoticeDialogComponent { + /** { heading, message } */ + data = inject(MAT_DIALOG_DATA); +} diff --git a/src/app/deposits/dds-accounts/dds-account-view/dds-account-view.component.html b/src/app/deposits/dds-accounts/dds-account-view/dds-account-view.component.html new file mode 100644 index 000000000..888501fe1 --- /dev/null +++ b/src/app/deposits/dds-accounts/dds-account-view/dds-account-view.component.html @@ -0,0 +1,557 @@ + + +
+ @if (isLoading) { +
+ + Loading account… +
+ } @else if (!account) { +
+ + DDS account not found + +
+ } @else { + +
+
+
+ ● {{ account.status }} +

{{ account.subType }}

+
+ Account Number + {{ account.accountNo }} + Client Name + {{ account.clientName || account.partyName }} +
+
+
+
+ Current Balance + ₹{{ account.currentBalance || 0 | formatNumber }} +
+
+ Maturity Amount + ₹{{ account.maturityAmount || 0 | formatNumber }} +
+
+
+ + +
+
+ + +
+ + + + + @if (account.status === 'CLOSED') { + + } +
+ + + @if (tab === 'closure') { + @if (!closure) { +
+
Loading closure details…
+
+ } @else { +
+
+
+
How It Closed
+
+ Type + + {{ closureLabels[closure.closureType] || closure.closureType }} + @if (closure.withinLockin) { + within lock-in + } + +
+
+ Closed On{{ closure.closeDate | date: 'dd MMMM yyyy' }} +
+ @if (closure.paidOutBy) { +
+ Paid Out By{{ closure.paidOutBy }} +
+ } +
+ Closed By{{ closure.closedBy || '—' }} +
+
+ Approved By{{ closure.approvedBy || '—' }} +
+ @if (closure.lockinOverrideReason) { +
+ Reason{{ closure.lockinOverrideReason }} +
+ } + @if (closure.shortfallBorneBy) { +
+ Cost Borne By + {{ + closure.shortfallBorneBy === 'AGENT' ? 'Agent — ' + closure.agentCode : 'Customer' + }} +
+ } +
+ +
+
The Money
+
+ Balance at Close₹{{ closure.balanceAtClose || 0 | formatNumber }} +
+ @if (closure.interestRecovered > 0) { +
+ Interest Recovered− ₹{{ closure.interestRecovered | formatNumber }} +
+ } + @if (closure.interestToppedUpByAgent > 0) { +
+ Interest Funded by Agent+ ₹{{ closure.interestToppedUpByAgent | formatNumber }} +
+ } +
+ Paid Out₹{{ closure.paidOut || 0 | formatNumber }} +
+ @if (closure.closureType === 'PREMATURE') { +
+ Agent Penalty ({{ closure.agentCode }})₹{{ closure.agentCommissionPenalty || 0 | formatNumber }} +
+ } +
+
+ +
+
+
Where It Went
+ @if (closure.destination === 'OWN_ACCOUNT') { +
Paid IntoCustomer's own account
+
+ Account{{ closure.creditedAccount?.accountNo }} +
+ @if (closure.creditedAccount?.productName) { +
+ Product{{ closure.creditedAccount.productName }} +
+ } + @if (closure.creditedAccount?.owner) { +
+ Holder{{ closure.creditedAccount.owner }} +
+ } + } @else if (closure.destination === 'SUNDRY') { +
Parked InSundry (customer has no account here)
+
+ GL Account{{ closure.sundryGl?.glCode }} · {{ closure.sundryGl?.glName }} +
+ } @else { +
Paid OutNothing - the balance was nil
+ } + @if (closure.voucherNo) { +
+ Voucher{{ closure.voucherNo }} +
+ } + @if (closure.payoutDate) { +
+ Paid On{{ closure.payoutDate | date: 'dd MMMM yyyy' }} +
+ } + @if (closure.passbookNote) { +
+ Passbook Note{{ closure.passbookNote }} +
+ } +
+ + @if (closure.ledgerEntries?.length) { +
+
Ledger Entry
+
+ Transaction ID{{ closure.ledgerTransactionId }} +
+ @for (leg of closure.ledgerEntries; track $index) { +
+ {{ leg.glCode }} · {{ leg.glName }} + + {{ leg.side }} ₹{{ leg.amount | formatNumber }} + +
+ } +
+ } +
+
+ } + } + + + @if (tab === 'general') { +
+
+
+
DDS Details
+
+ Account Number{{ account.accountNo }} +
+
+ Party Code{{ account.partyCode }} +
+
+ Client Name{{ account.clientName }} ({{ account.clientId }}) +
+
+ Agent{{ account.agentCode }} · {{ account.agentName }} +
+
+ Branch{{ account.officeName }} +
+
+ Product{{ account.subType }} +
+
+ Opened On{{ account.openingDate | date: 'dd MMMM yyyy' }} +
+
+ Matures On{{ account.maturityDate | date: 'dd MMMM yyyy' }} +
+
+ Daily Deposit₹{{ account.dailyDepositAmount || 0 | formatNumber }} +
+
+ Term{{ account.month }} months · {{ account.days }} days +
+
+ Interest Rate{{ account.interestRate }} % +
+
+ CurrencyIndian Rupee [INR] +
+
+ +
+
Performance History
+
+ Total Collected₹{{ totalOf('PARTY_TRAN') | formatNumber }} +
+
+ Total Interest Earned₹{{ interestEarned | formatNumber }} +
+
+ Total Paid Out₹{{ totalDebitsAll | formatNumber }} +
+
+
+ +
+
+
Account Summary
+
+ Collected to Date₹{{ account.collectedToDate || 0 | formatNumber }} +
+
+ Expected to Date₹{{ account.expectedToDate || 0 | formatNumber }} +
+
+ Standing + + @if (account.advanceDays > 0) { + {{ account.advanceDays }} days ahead + } @else if (account.arrearsDays > 0) { + {{ account.arrearsDays }} days behind + } @else { + Up to date + } + +
+
+ Instalments Due{{ account.instalmentsDue ?? 0 }} +
+
+ Interest Earned₹{{ interestEarned | formatNumber }} +
+
+ Interest Calculated, Not Posted₹{{ account.interestCalc || 0 | formatNumber }} +
+
Interest Calculated UsingDaily Balance
+
Days in Year365 Days
+
+ Last Interest Date{{ account.interestDate ? (account.interestDate | date: 'dd MMMM yyyy') : '—' }} +
+
+ Last Transaction Date{{ lastTransactionDate ? (lastTransactionDate | date: 'dd MMMM yyyy') : '—' }} +
+
+ Maturity Amount₹{{ account.maturityAmount || 0 | formatNumber }} +
+
+ Lien + {{ account.lien ? 'Yes — ₹' + (account.amountHeldUnderLien || 0) : 'No' }} +
+
+ Freeze{{ account.freezeCode || 'Normal' }} +
+ @if (account.closeDate) { +
+ Closed On{{ account.closeDate | date: 'dd MMMM yyyy' }} +
+ } +
+
+
+ } + + + @if (tab === 'transactions') { +
+
+
+ From + +
+
+ To + +
+ + @if (hasDateFilters) { + + } +
+ Credits ₹{{ totalCredits | formatNumber }} + Debits ₹{{ totalDebits | formatNumber }} +
+ +
+ + @if (shownTransactions.length === 0) { +
+ + {{ + hasDateFilters + ? 'Nothing moved on this account in these dates' + : 'Nothing has been credited to this account yet' + }} +
+ } @else { +
+ + + + + + + + + + + + + + + + @for (txn of shownTransactions; track txn.id; let i = $index) { + + + + + + + + + + + + } + +
#IDTransaction DateTransaction TypeVoucherDescriptionDebitCreditBalance
{{ i + 1 }}{{ txn.id }}{{ txn.transactionDate | date: 'dd MMMM yyyy' }}{{ label(txn.transactionType) }}{{ txn.voucherNo || '—' }}{{ txn.description }} + {{ txn.direction === 'DEBIT' ? '₹' + (txn.amount | formatNumber) : 'N/A' }} + + {{ txn.direction === 'CREDIT' ? '₹' + (txn.amount | formatNumber) : 'N/A' }} + + ₹{{ txn.balanceAfterTransaction || 0 | formatNumber }} +
+
+ } +
+ } + + + @if (tab === 'passbook') { +
+
+
+ From + +
+
+ To + +
+ @if (hasDateFilters) { + + } + +
+ +
+
+
+
DDS Passbook
+
{{ account.officeName }}
+
+
+ Account No{{ account.accountNo }} Party Code{{ account.partyCode }} Name{{ account.clientName || account.partyName }} Product{{ account.subType }} · {{ account.interestRate }} % Opened On{{ account.openingDate | date: 'dd/MM/yyyy' }} Agent{{ account.agentCode }} +
+
+ + @if (passbookEntries.length === 0) { +
+ {{ hasDateFilters ? 'No entries in these dates' : 'No entries yet' }} +
+ } @else { + + + + + + + + + + + + + @for (txn of passbookEntries; track txn.id) { + + + + + + + + + } + + + + + + + + + +
DateVoucherParticularsWithdrawalsDepositsBalance
{{ txn.transactionDate | date: 'dd/MM/yyyy' }}{{ txn.voucherNo || '' }} + {{ label(txn.transactionType) }} + @if (txn.description) { + {{ txn.description }} + } + {{ txn.direction === 'DEBIT' ? (txn.amount | formatNumber) : '' }}{{ txn.direction === 'CREDIT' ? (txn.amount | formatNumber) : '' }} + {{ txn.balanceAfterTransaction || 0 | formatNumber }} +
Total + {{ totalDebits | formatNumber }} + + {{ totalCredits | formatNumber }} + + {{ passbookClosingBalance | formatNumber }} +
+ } +
+
+ } + + + @if (tab === 'nominees') { +
+ @if (!account.nominees || account.nominees.length === 0) { +
+ + No nominee on this account +
+ } @else { +
+ + + + + + + + + + + + + + + + @for (nominee of account.nominees; track nominee.id; let i = $index) { + + + + + + + + + + + + } + +
#NameRelationGenderMobileBirth DateNominated OnGuardian{{ account.nomineeSplitMode === 'Sequence' ? 'Order' : 'Share %' }}
{{ i + 1 }} + {{ nominee.name }} + {{ nominee.relation }}{{ nominee.gender }}{{ nominee.mobileNo || '—' }}{{ nominee.birthDate ? (nominee.birthDate | date: 'dd MMM yyyy') : '—' }}{{ nominee.nominationDate ? (nominee.nominationDate | date: 'dd MMM yyyy') : '—' }}{{ nominee.guardianName || '—' }} + {{ account.nomineeSplitMode === 'Sequence' ? nominee.sequenceNo : (nominee.sharePercent ?? '—') }} +
+
+ } +
+ } + } +
diff --git a/src/app/deposits/dds-accounts/dds-account-view/dds-account-view.component.scss b/src/app/deposits/dds-accounts/dds-account-view/dds-account-view.component.scss new file mode 100644 index 000000000..d0da71aa8 --- /dev/null +++ b/src/app/deposits/dds-accounts/dds-account-view/dds-account-view.component.scss @@ -0,0 +1,459 @@ +/** + * 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/. + */ + +/* One DDS account's page, laid out the way the savings account page is: a banner, tabs, cards. */ + +.dav-page { + display: flex; + flex-direction: column; + gap: 16px; + padding: 24px 32px; +} + +.dav-mono { + font-family: ui-monospace, 'SF Mono', 'Cascadia Mono', monospace; + font-style: normal; +} + +.dav-cr { + color: #047857; +} + +.dav-dr { + color: #b91c1c; +} + +.dav-right { + text-align: right !important; +} + +/* ── Banner ───────────────────────────────────────────────────────────── */ + +.dav-banner { + display: flex; + align-items: center; + flex-wrap: wrap; + gap: 16px; + padding: 16px 24px; + border-radius: 16px; + color: #fff; + background: linear-gradient(90deg, #1e3a8a 0%, #2d3fb4 55%, #5b47d6 100%); + box-shadow: 0 8px 24px rgb(30 58 138 / 25%); +} + +.dav-banner__icon { + width: 48px; + height: 48px; + border-radius: 12px; + display: grid; + place-items: center; + font-size: 1.3rem; + background: rgb(255 255 255 / 16%); + border: 1px solid rgb(255 255 255 / 25%); +} + +.dav-banner__text { + display: flex; + flex-direction: column; + gap: 4px; + min-width: 0; +} + +.dav-pill { + align-self: flex-start; + padding: 2px 10px; + border-radius: 999px; + font-size: 0.7rem; + font-weight: 700; + letter-spacing: 0.04em; + background: rgb(16 185 129 / 25%); + color: #a7f3d0; + border: 1px solid rgb(167 243 208 / 40%); +} + +.dav-pill--closed { + background: rgb(148 163 184 / 25%); + color: #e2e8f0; + border-color: rgb(226 232 240 / 40%); +} + +.dav-banner__title { + margin: 0; + font-size: 1.25rem; + font-weight: 700; +} + +.dav-banner__meta { + display: flex; + flex-wrap: wrap; + align-items: baseline; + gap: 6px 12px; +} + +.dav-banner__label { + font-size: 0.7rem; + font-weight: 700; + letter-spacing: 0.06em; + text-transform: uppercase; + color: rgb(255 255 255 / 60%); +} + +.dav-banner__value { + font-size: 0.9rem; + font-weight: 600; +} + +.dav-banner__boxes { + margin-left: auto; + display: flex; + gap: 12px; +} + +.dav-box { + display: flex; + flex-direction: column; + gap: 4px; + padding: 10px 18px; + border-radius: 12px; + background: rgb(255 255 255 / 12%); + border: 1px solid rgb(255 255 255 / 25%); +} + +.dav-box__label { + font-size: 0.68rem; + font-weight: 700; + letter-spacing: 0.06em; + text-transform: uppercase; + color: rgb(255 255 255 / 70%); +} + +.dav-box__value { + font-size: 1.15rem; + font-weight: 700; + font-variant-numeric: tabular-nums; +} + +.dav-banner__actions { + display: flex; + gap: 8px; +} + +.dav-icon-btn { + width: 38px; + height: 38px; + border-radius: 50%; + border: 1px solid rgb(255 255 255 / 35%); + background: rgb(255 255 255 / 12%); + color: #fff; + cursor: pointer; + + &:hover { + background: rgb(255 255 255 / 22%); + } +} + +/* ── Tabs ─────────────────────────────────────────────────────────────── */ + +.dav-tabs { + display: flex; + gap: 8px; + padding: 8px; + border-radius: 14px; + background: #fff; + border: 1px solid rgb(0 0 0 / 7%); +} + +.dav-tab { + flex: 1; + display: flex; + align-items: center; + justify-content: center; + gap: 6px; + padding: 10px 16px; + border: 0; + border-radius: 10px; + background: transparent; + font-size: 0.95rem; + font-weight: 600; + color: #475569; + cursor: pointer; + + &:hover { + background: #f1f5f9; + } +} + +.dav-tab--on { + color: #fff; + background: linear-gradient(90deg, #1d4ed8, #2563eb); + box-shadow: 0 4px 12px rgb(37 99 235 / 30%); + + &:hover { + background: linear-gradient(90deg, #1d4ed8, #2563eb); + } +} + +/* ── Cards ────────────────────────────────────────────────────────────── */ + +.dav-columns { + display: grid; + grid-template-columns: repeat(auto-fit, minmax(380px, 1fr)); + gap: 16px; + align-items: start; +} + +.dav-column { + display: flex; + flex-direction: column; + gap: 16px; +} + +.dav-card { + border-radius: 14px; + background: #fff; + border: 1px solid rgb(0 0 0 / 7%); + overflow: hidden; +} + +.dav-card__head { + display: flex; + align-items: center; + gap: 12px; + padding: 14px 20px; + font-size: 1rem; + font-weight: 700; + background: #f8fafc; + border-bottom: 1px solid rgb(0 0 0 / 7%); +} + +.dav-card__dot { + width: 30px; + height: 30px; + border-radius: 8px; + background: linear-gradient(135deg, #2563eb, #1e40af); +} + +.dav-row { + display: flex; + justify-content: space-between; + gap: 16px; + padding: 12px 20px; + border-bottom: 1px dashed rgb(0 0 0 / 7%); + font-size: 0.92rem; + + &:last-child { + border-bottom: 0; + } + + span { + color: #334155; + } + + strong { + text-align: right; + font-weight: 600; + } +} + +.dav-state { + display: flex; + flex-direction: column; + align-items: center; + gap: 10px; + padding: 48px 16px; + color: rgb(0 0 0 / 50%); +} + +/* ── Transactions ─────────────────────────────────────────────────────── */ + +.dav-toolbar { + display: flex; + align-items: center; + flex-wrap: wrap; + gap: 12px; + padding: 14px 20px; + background: #f8fafc; + border-bottom: 1px solid rgb(0 0 0 / 7%); +} + +.dav-datepick { + display: flex; + flex-direction: column; + gap: 2px; + + span { + font-size: 0.68rem; + font-weight: 700; + letter-spacing: 0.06em; + text-transform: uppercase; + color: rgb(0 0 0 / 45%); + } + + input { + padding: 6px 10px; + border-radius: 8px; + border: 1px solid rgb(0 0 0 / 15%); + font: inherit; + } +} + +.dav-btn { + display: inline-flex; + align-items: center; + gap: 6px; + padding: 8px 14px; + border-radius: 8px; + border: 1px solid rgb(0 0 0 / 15%); + background: #fff; + font-weight: 600; + cursor: pointer; + + &:disabled { + opacity: 0.5; + cursor: default; + } +} + +.dav-btn--primary { + color: #fff; + border-color: transparent; + background: linear-gradient(90deg, #1d4ed8, #2563eb); +} + +.dav-totals { + margin-left: auto; + display: flex; + gap: 16px; + font-size: 0.88rem; + font-weight: 700; + font-variant-numeric: tabular-nums; +} + +.dav-table-wrap { + overflow-x: auto; +} + +.dav-table { + width: 100%; + border-collapse: collapse; + font-size: 0.92rem; + + th { + padding: 14px 16px; + text-align: left; + font-size: 0.72rem; + font-weight: 700; + letter-spacing: 0.06em; + text-transform: uppercase; + color: #475569; + border-bottom: 1px solid rgb(0 0 0 / 8%); + white-space: nowrap; + } + + td { + padding: 16px; + border-bottom: 1px solid rgb(0 0 0 / 5%); + font-variant-numeric: tabular-nums; + } + + tbody tr:nth-child(odd) { + background: #f8fafc; + } +} + +.dav-desc { + max-width: 320px; + color: rgb(0 0 0 / 65%); +} + +@media (width <= 720px) { + .dav-page { + padding: 16px; + } + + .dav-banner__boxes { + margin-left: 0; + width: 100%; + } + + .dav-tab { + font-size: 0.85rem; + padding: 8px; + } +} + +/* ── Passbook ─────────────────────────────────────────────────────────── */ + +.dav-push { + margin-left: auto; +} + +.dav-passbook { + padding: 20px; +} + +.dav-pb-head { + display: flex; + justify-content: space-between; + flex-wrap: wrap; + gap: 16px 32px; + margin-bottom: 16px; + padding-bottom: 16px; + border-bottom: 2px solid #1e3a8a; +} + +.dav-pb-title { + font-size: 1.2rem; + font-weight: 700; + color: #1e3a8a; +} + +.dav-pb-sub { + font-size: 0.85rem; + color: rgb(0 0 0 / 55%); +} + +.dav-pb-grid { + display: grid; + grid-template-columns: auto auto; + gap: 4px 16px; + font-size: 0.88rem; + + span { + color: rgb(0 0 0 / 55%); + } +} + +.dav-pb-table { + tfoot td { + padding: 14px 16px; + border-top: 2px solid rgb(0 0 0 / 15%); + } +} + +.dav-pb-note { + display: block; + font-size: 0.78rem; + color: rgb(0 0 0 / 50%); +} + +/* Closure tab */ +.dav-closure-tag { + margin-left: 8px; + padding: 1px 8px; + border-radius: 999px; + background: #fff3e0; + color: #e65100; + font-size: 0.75rem; + font-style: normal; +} + +.dav-closure-total { + border-top: 1px solid rgb(0 0 0 / 10%); + font-weight: 600; +} diff --git a/src/app/deposits/dds-accounts/dds-account-view/dds-account-view.component.ts b/src/app/deposits/dds-accounts/dds-account-view/dds-account-view.component.ts new file mode 100644 index 000000000..33c3c817b --- /dev/null +++ b/src/app/deposits/dds-accounts/dds-account-view/dds-account-view.component.ts @@ -0,0 +1,297 @@ +/** + * 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 { ActivatedRoute, Router } from '@angular/router'; +import { FormControl, ReactiveFormsModule } from '@angular/forms'; +import { NgClass } from '@angular/common'; +import { MatTooltipModule } from '@angular/material/tooltip'; +import { MatProgressSpinnerModule } from '@angular/material/progress-spinner'; +import { FaIconComponent } from '@fortawesome/angular-fontawesome'; +import { forkJoin, of } from 'rxjs'; +import { catchError } from 'rxjs/operators'; +import { STANDALONE_SHARED_IMPORTS } from 'app/standalone-shared.module'; +import { FormatNumberPipe } from 'app/pipes/format-number.pipe'; +import { OrganizationService } from 'app/organization/organization.service'; + +/** What each DDS transaction type is called on screen. */ +const TRANSACTION_LABELS: Record = { + PARTY_TRAN: 'Collection', + INTEREST_POST: 'Interest', + OD_INTEREST_POST: 'OD Interest', + MATURITY_PAYOUT: 'Maturity Payout', + PREMATURE_PAYOUT: 'Premature Payout', + PREMATURE_INT_RECOVERY: 'Interest Recovered (Premature)', + PREMATURE_AGENT_TOP_UP: 'Interest Funded by Agent', + WITHDRAW_DEPOSITE: 'Transfer (Withdraw Deposite)', + TRANSFER: 'Transfer' +}; + +/** + * One DDS account and everything that has moved on it, as the savings account page shows a savings + * account: the account's terms and where it stands, then its transactions with the balance after + * each, narrowed to a day or a range. + * + * Read-only. The account is changed from DDS Party Master; its money moves through collections, the + * cashier and the batch runs. + */ +@Component({ + selector: 'mifosx-dds-account-view', + templateUrl: './dds-account-view.component.html', + styleUrls: ['./dds-account-view.component.scss'], + standalone: true, + imports: [ + ...STANDALONE_SHARED_IMPORTS, + ReactiveFormsModule, + NgClass, + FaIconComponent, + MatTooltipModule, + MatProgressSpinnerModule, + FormatNumberPipe + ] +}) +export class DdsAccountViewComponent implements OnInit { + private route = inject(ActivatedRoute); + private router = inject(Router); + private organizationService = inject(OrganizationService); + + isLoading = true; + account: any = null; + private transactions: any[] = []; + + /** The page's tabs, as the savings account page has them - only those that apply to DDS. */ + tab: 'general' | 'transactions' | 'passbook' | 'nominees' | 'closure' = 'general'; + + /** How a closed account was closed - read only for closed accounts, for the Closure tab. */ + closure: any = null; + + /** What each kind of closure is called on screen. */ + readonly closureLabels: Record = { + PREMATURE: 'Premature Close', + MATURITY_PAYOUT_AND_CLOSE: 'Maturity Payout & Close', + MATURITY_PAYOUT_THEN_CLOSE: 'Maturity Payout, then Closed', + MATURITY_AUTO_CLOSE: 'Maturity Auto Close (night job)', + CLOSED_NIL_BALANCE: 'Closed with nil balance' + }; + + /** The passbook reads oldest first, as it is written up. */ + get passbookEntries(): any[] { + return [...this.shownTransactions].reverse(); + } + + /** The balance after the last entry shown. */ + get passbookClosingBalance(): number { + const entries = this.passbookEntries; + return entries.length > 0 ? Number(entries[entries.length - 1].balanceAfterTransaction || 0) : 0; + } + + /** + * Prints the passbook alone. It is copied into a window of its own with only its own styles, so the + * page's menus, banner and tabs do not come out on the paper. + */ + printPassbook(): void { + const passbook = document.getElementById('dds-passbook'); + const popup = window.open('', '_blank', 'width=900,height=700'); + if (!passbook || !popup) { + return; + } + popup.document.write(`${this.account?.accountNo || 'DDS'} Passbook${passbook.innerHTML}`); + popup.document.close(); + popup.focus(); + popup.print(); + } + + fromDateControl = new FormControl(''); + toDateControl = new FormControl(''); + + readonly today = new Date().toISOString().split('T')[0]; + + ngOnInit(): void { + const partyId = this.route.snapshot.paramMap.get('partyId'); + if (!partyId) { + this.isLoading = false; + return; + } + forkJoin({ + account: this.organizationService.getParty(partyId).pipe(catchError(() => of(null))), + transactions: this.organizationService.getPartyTransactionsOfParty(partyId).pipe(catchError(() => of([]))) + }).subscribe(({ account, transactions }) => { + this.account = account; + if (account?.status === 'CLOSED') { + this.organizationService.getPartyClosure(partyId).subscribe({ + next: (closure: any) => (this.closure = closure), + error: () => (this.closure = null) + }); + } + // Newest first, and within a day the later entry first - the order a passbook is read back in. + this.transactions = (Array.isArray(transactions) ? transactions : []).sort( + (a: any, b: any) => + String(b.transactionDate).localeCompare(String(a.transactionDate)) || Number(b.id) - Number(a.id) + ); + this.isLoading = false; + }); + } + + /** The transactions in the chosen range - all of them when no range is set. */ + get shownTransactions(): any[] { + const from = this.fromDateControl.value; + const to = this.toDateControl.value; + return this.transactions.filter((txn: any) => { + const day = String(txn.transactionDate || ''); + if (from && day < String(from)) { + return false; + } + if (to && day > String(to)) { + return false; + } + return true; + }); + } + + get totalCredits(): number { + return this.sumOf('CREDIT'); + } + + get totalDebits(): number { + return this.sumOf('DEBIT'); + } + + private sumOf(direction: string): number { + return this.shownTransactions + .filter((txn: any) => txn.direction === direction) + .reduce((sum: number, txn: any) => sum + Number(txn.amount || 0), 0); + } + + /** Everything of one type ever credited, whatever the date filter says - for the summary cards. */ + totalOf(type: string): number { + return this.transactions + .filter((txn: any) => txn.transactionType === type && txn.direction === 'CREDIT') + .reduce((sum: number, txn: any) => sum + Number(txn.amount || 0), 0); + } + + /** Interest credited, less any taken back on an early closure. */ + get interestEarned(): number { + const recovered = this.transactions + .filter((txn: any) => txn.transactionType === 'PREMATURE_INT_RECOVERY') + .reduce((sum: number, txn: any) => sum + Number(txn.amount || 0), 0); + return ( + this.totalOf('INTEREST_POST') + + this.totalOf('OD_INTEREST_POST') + + this.totalOf('PREMATURE_AGENT_TOP_UP') - + recovered + ); + } + + /** Money that has left the account - payouts, withdrawals, transfers out. */ + get totalDebitsAll(): number { + return this.transactions + .filter((txn: any) => txn.direction === 'DEBIT' && txn.transactionType !== 'PREMATURE_INT_RECOVERY') + .reduce((sum: number, txn: any) => sum + Number(txn.amount || 0), 0); + } + + get lastTransactionDate(): string | null { + return this.transactions.length > 0 ? this.transactions[0].transactionDate : null; + } + + /** The rows on screen, as a spreadsheet the branch can keep or send on. */ + exportCsv(): void { + const header = [ + '#', + 'ID', + 'Transaction Date', + 'Transaction Type', + 'Voucher', + 'Description', + 'Account', + 'Debit', + 'Credit', + 'Balance' + ]; + const quote = (value: any) => `"${String(value ?? '').replace(/"/g, '""')}"`; + const lines = this.shownTransactions.map((txn: any, index: number) => + [ + index + 1, + txn.id, + txn.transactionDate, + this.label(txn.transactionType), + txn.voucherNo, + txn.description, + txn.accountNo, + txn.direction === 'DEBIT' ? txn.amount : '', + txn.direction === 'CREDIT' ? txn.amount : '', + txn.balanceAfterTransaction + ] + .map(quote) + .join(',') + ); + const blob = new Blob( + [[ + header.map(quote).join(','), + ...lines + ].join('\r\n')], + { type: 'text/csv;charset=utf-8' } + ); + const link = document.createElement('a'); + link.href = URL.createObjectURL(blob); + link.download = `${this.account?.accountNo || 'dds-account'}-transactions.csv`; + link.click(); + URL.revokeObjectURL(link.href); + } + + get hasDateFilters(): boolean { + return !!(this.fromDateControl.value || this.toDateControl.value); + } + + showToday(): void { + this.fromDateControl.setValue(this.today); + this.toDateControl.setValue(this.today); + } + + clearDateFilters(): void { + this.fromDateControl.setValue(''); + this.toDateControl.setValue(''); + } + + label(type: string): string { + return TRANSACTION_LABELS[type] || String(type || '').replace(/_/g, ' '); + } + + statusClass(): string { + switch (this.account?.status) { + case 'ACTIVE': + return 'status--active'; + case 'CLOSED': + return 'status--closed'; + default: + return 'status--default'; + } + } + + back(): void { + this.router.navigate(['/accounts/dds-accounts']); + } + + openInPartyMaster(): void { + this.router.navigate([ + '/accounts/dds', + this.account.id, + 'edit' + ]); + } +} diff --git a/src/app/deposits/dds-accounts/dds-accounts.component.html b/src/app/deposits/dds-accounts/dds-accounts.component.html new file mode 100644 index 000000000..e1edbc9c7 --- /dev/null +++ b/src/app/deposits/dds-accounts/dds-accounts.component.html @@ -0,0 +1,211 @@ + + +
+ +
+
+ +
+
+

DDS Accounts

+ + @if (isLoading) { + Loading... + } @else { + {{ allCount }} accounts · {{ activeCount }} active · {{ closedCount }} closed + } + +
+
+ + +
+ + + +
+ + +
+ + + +
+ + +
+
+
+ Opened From + +
+
+ +
+
+ To + +
+
+ +
+ +
+ + + As On Date + + +
+ + @if (hasDateFilters) { + + } +
+ + +
+ @if (isLoading) { +
+ + Loading accounts… +
+ } + + @if (!isLoading && rows.length === 0) { +
+
+ +
+

No DDS accounts found

+

Try adjusting your search

+
+ } + + @if (!isLoading && rows.length > 0) { +
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
Account No + {{ row.accountNo }} + Type + DDS + Client +
+
{{ (row.clientName || row.partyName || '?')[0] }}
+ {{ row.clientName || row.partyName }} +
+
Product + {{ row.subType }} + Balance + ₹ {{ row.currentBalance || 0 | formatNumber }} + Status + {{ row.status }} + Actions + +
+
+ + + } +
+
diff --git a/src/app/deposits/dds-accounts/dds-accounts.component.scss b/src/app/deposits/dds-accounts/dds-accounts.component.scss new file mode 100644 index 000000000..80e7d5189 --- /dev/null +++ b/src/app/deposits/dds-accounts/dds-accounts.component.scss @@ -0,0 +1,56 @@ +/** + * 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/. + */ + +/* All / Active / Closed - which DDS accounts the list shows. */ +.dds-status-filter { + display: flex; + flex-wrap: wrap; + gap: 8px; + margin: 0 0 12px; + + &__btn { + display: inline-flex; + align-items: center; + gap: 8px; + padding: 6px 14px; + border: 1px solid rgb(0 0 0 / 12%); + border-radius: 999px; + background: #fff; + color: rgb(0 0 0 / 70%); + font-size: 0.85rem; + font-weight: 500; + cursor: pointer; + + &.is-on { + border-color: #1565c0; + background: #e3f2fd; + color: #0d47a1; + } + + &--active.is-on { + border-color: #2e7d32; + background: #e8f5e9; + color: #1b5e20; + } + + &--closed.is-on { + border-color: #c62828; + background: #ffebee; + color: #b71c1c; + } + } + + &__count { + min-width: 22px; + padding: 0 6px; + border-radius: 999px; + background: rgb(0 0 0 / 8%); + font-size: 0.75rem; + text-align: center; + } +} diff --git a/src/app/deposits/dds-accounts/dds-accounts.component.ts b/src/app/deposits/dds-accounts/dds-accounts.component.ts new file mode 100644 index 000000000..811e5e9a3 --- /dev/null +++ b/src/app/deposits/dds-accounts/dds-accounts.component.ts @@ -0,0 +1,265 @@ +/** + * 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, OnDestroy, OnInit, inject } from '@angular/core'; +import { Router } from '@angular/router'; +import { FormControl, ReactiveFormsModule } from '@angular/forms'; +import { NgClass } from '@angular/common'; +import { MatPaginator, PageEvent } from '@angular/material/paginator'; +import { + MatCell, + MatCellDef, + MatColumnDef, + MatHeaderCell, + MatHeaderCellDef, + MatHeaderRow, + MatHeaderRowDef, + MatRow, + MatRowDef, + MatTable +} from '@angular/material/table'; +import { MatTooltipModule } from '@angular/material/tooltip'; +import { MatProgressSpinnerModule } from '@angular/material/progress-spinner'; +import { FaIconComponent } from '@fortawesome/angular-fontawesome'; +import { Subject, merge } from 'rxjs'; +import { debounceTime, distinctUntilChanged, takeUntil } from 'rxjs/operators'; +import { STANDALONE_SHARED_IMPORTS } from 'app/standalone-shared.module'; +import { FormatNumberPipe } from 'app/pipes/format-number.pipe'; +import { OrganizationService } from 'app/organization/organization.service'; + +/** + * The DDS accounts, listed the way Savings Accounts lists savings: account number, client, product, + * balance and status, searched by account number or client and narrowed by opening date. Each opens + * onto the account's own page and its transactions. + * + * Read-only. Accounts are opened and changed from DDS Party Master; money moves through collections, + * the cashier and the batch runs. + */ +@Component({ + selector: 'mifosx-dds-accounts', + templateUrl: './dds-accounts.component.html', + // The Savings Accounts list's own look, so the two screens read as one family. + styleUrls: [ + '../../accounts/accounts-list/accounts-list.component.scss', + './dds-accounts.component.scss' + ], + standalone: true, + imports: [ + ...STANDALONE_SHARED_IMPORTS, + ReactiveFormsModule, + NgClass, + FaIconComponent, + MatTable, + MatColumnDef, + MatHeaderCellDef, + MatHeaderCell, + MatCellDef, + MatCell, + MatHeaderRowDef, + MatHeaderRow, + MatRowDef, + MatRow, + MatPaginator, + MatTooltipModule, + MatProgressSpinnerModule, + FormatNumberPipe + ] +}) +export class DdsAccountsComponent implements OnInit, OnDestroy { + private organizationService = inject(OrganizationService); + private router = inject(Router); + private destroy$ = new Subject(); + + displayedColumns = [ + 'accountNo', + 'depositType', + 'clientName', + 'productName', + 'balance', + 'status', + 'actions' + ]; + + /** Every DDS account, fetched once; search, dates and paging work on this. */ + private allAccounts: any[] = []; + /** The accounts matching the search and dates, whatever their status - what the status counts read. */ + private beforeStatus: any[] = []; + /** The accounts matching the search, dates and status, before paging. */ + private matching: any[] = []; + + /** Which accounts are shown: all of them, only the open ones, or only the closed. */ + statusFilter: 'ALL' | 'ACTIVE' | 'CLOSED' = 'ALL'; + /** The page on screen. */ + rows: any[] = []; + + pageSize = 20; + pageIndex = 0; + isLoading = false; + + searchControl = new FormControl(''); + searchType: 'accountNo' | 'clientName' = 'accountNo'; + fromDateControl = new FormControl(''); + toDateControl = new FormControl(''); + asOnDateControl = new FormControl(''); + + readonly today = new Date().toISOString().split('T')[0]; + + get totalRecords(): number { + return this.matching.length; + } + + get activeCount(): number { + return this.beforeStatus.filter((account) => account.status === 'ACTIVE').length; + } + + get closedCount(): number { + return this.beforeStatus.filter((account) => account.status === 'CLOSED').length; + } + + get allCount(): number { + return this.beforeStatus.length; + } + + setStatusFilter(status: 'ALL' | 'ACTIVE' | 'CLOSED'): void { + this.statusFilter = status; + this.pageIndex = 0; + this.applyFilters(); + } + + get hasDateFilters(): boolean { + return !!((this.fromDateControl.value && this.toDateControl.value) || this.asOnDateControl.value); + } + + ngOnInit(): void { + this.loadAccounts(); + + this.searchControl.valueChanges + .pipe(debounceTime(300), distinctUntilChanged(), takeUntil(this.destroy$)) + .subscribe(() => { + this.pageIndex = 0; + this.applyFilters(); + }); + merge(this.fromDateControl.valueChanges, this.toDateControl.valueChanges, this.asOnDateControl.valueChanges) + .pipe(debounceTime(300), takeUntil(this.destroy$)) + .subscribe(() => { + this.pageIndex = 0; + this.applyFilters(); + }); + } + + ngOnDestroy(): void { + this.destroy$.next(); + this.destroy$.complete(); + } + + loadAccounts(): void { + this.isLoading = true; + this.organizationService + .getParties() + .pipe(takeUntil(this.destroy$)) + .subscribe({ + next: (parties: any) => { + // Newest first, as the savings list shows them. + this.allAccounts = (Array.isArray(parties) ? parties : parties?.pageItems || []).sort( + (a: any, b: any) => Number(b.id) - Number(a.id) + ); + this.isLoading = false; + this.applyFilters(); + }, + error: () => { + this.allAccounts = []; + this.isLoading = false; + this.applyFilters(); + } + }); + } + + /** Search by account number or client, and by the day the account was opened. */ + private applyFilters(): void { + const term = String(this.searchControl.value || '') + .trim() + .toLowerCase(); + const fromVal = this.fromDateControl.value; + const toVal = this.toDateControl.value; + const bothDates = !!(fromVal && toVal); + const asOn = this.asOnDateControl.value; + + this.beforeStatus = this.allAccounts.filter((account: any) => { + if (term) { + const haystack = + this.searchType === 'accountNo' + ? `${account.accountNo || ''} ${account.partyCode || ''}` + : `${account.clientName || ''} ${account.partyName || ''}`; + if (!haystack.toLowerCase().includes(term)) { + return false; + } + } + const opened = String(account.openingDate || ''); + if (bothDates && opened && (opened < String(fromVal) || opened > String(toVal))) { + return false; + } + // As on a date: the accounts that had been opened by then. + if (asOn && opened && opened > String(asOn)) { + return false; + } + return true; + }); + this.matching = + this.statusFilter === 'ALL' + ? this.beforeStatus + : this.beforeStatus.filter((account: any) => account.status === this.statusFilter); + this.showPage(); + } + + private showPage(): void { + const start = this.pageIndex * this.pageSize; + this.rows = this.matching.slice(start, start + this.pageSize); + } + + onPageChange(event: PageEvent): void { + this.pageIndex = event.pageIndex; + this.pageSize = event.pageSize; + this.showPage(); + } + + toggleSearchType(): void { + this.searchType = this.searchType === 'accountNo' ? 'clientName' : 'accountNo'; + this.pageIndex = 0; + this.applyFilters(); + } + + clearSearch(): void { + this.searchControl.setValue(''); + } + + clearDateFilters(): void { + this.fromDateControl.setValue('', { emitEvent: false }); + this.toDateControl.setValue('', { emitEvent: false }); + this.asOnDateControl.setValue('', { emitEvent: false }); + this.pageIndex = 0; + this.applyFilters(); + } + + viewAccount(account: any): void { + this.router.navigate([ + '/accounts/dds-accounts', + account.id + ]); + } + + getStatusClass(account: any): string { + switch (account?.status) { + case 'ACTIVE': + return 'status--active'; + case 'CLOSED': + return 'status--closed'; + default: + return 'status--default'; + } + } +} diff --git a/src/app/deposits/dds-approvals/dds-approval-notice.ts b/src/app/deposits/dds-approvals/dds-approval-notice.ts new file mode 100644 index 000000000..34caac788 --- /dev/null +++ b/src/app/deposits/dds-approvals/dds-approval-notice.ts @@ -0,0 +1,46 @@ +/** + * Copyright since 2025 Mifos Initiative + * + * This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. + */ + +import { OrganizationService } from 'app/organization/organization.service'; +import { AlertService } from 'app/core/alert/alert.service'; + +/** + * Whether the server held a DDS entry for a checker rather than carrying it out. The reply then + * names the entry (commandId) and asks for the work to be rolled back, and carries no result of its own. + */ +export function heldForApproval(response: any): boolean { + return !!response?.rollbackTransaction || (!!response?.commandId && response?.resourceId == null); +} + +/** + * Tells the clerk their entry went to a checker - with the voucher it will move under - instead of + * saying it was done. Nothing has moved yet, and a message that read as though it had would send them + * looking for money that is still waiting on a manager. + */ +export function announceHeldForApproval( + response: any, + what: string, + organizationService: OrganizationService, + alertService: AlertService +): void { + const say = (voucherNo?: string) => + alertService.alert({ + type: 'Sent for Approval', + message: + `${what} sent for approval${voucherNo ? ' under voucher ' + voucherNo : ''}. ` + + 'It takes effect once a checker approves it in Approval Center › DDS Approvals.' + }); + if (!response?.commandId) { + say(); + return; + } + organizationService.getDdsApproval(response.commandId).subscribe({ + next: (entry: any) => say(entry?.voucherNo), + error: () => say() + }); +} diff --git a/src/app/deposits/dds-approvals/dds-approval-review/dds-approval-review.component.html b/src/app/deposits/dds-approvals/dds-approval-review/dds-approval-review.component.html new file mode 100644 index 000000000..9a1b3412e --- /dev/null +++ b/src/app/deposits/dds-approvals/dds-approval-review/dds-approval-review.component.html @@ -0,0 +1,265 @@ + + + +
+ + + @if (loading) { +
+ hourglass_empty + Loading entry… +
+ } @else { +
+ +
+
+
+ {{ voucherNo || '—' }} + + {{ entry.status }}{{ entry.voucherCancelled ? ' · VOUCHER CANCELLED' : '' }} + +
+
+ + person + {{ entry.makerName || entry.maker }} ({{ entry.maker }}) + + + schedule + {{ formatDateTime(entry.madeOn) }} + + @if (entry.status !== 'PENDING' && entry.checker) { + + check_circle + Actioned by: {{ entry.checkerName || entry.checker }} + + } +
+
+
+ + +
+
+ swap_horiz + {{ entry.type }} Summary +
+
+
+ Total Amount + + {{ entry.amount !== null && entry.amount !== undefined ? '₹' + (entry.amount | number: '1.2-2') : '—' }} + +
+
+ DR Accounts + {{ debitLines.length }} account{{ debitLines.length !== 1 ? 's' : '' }} +
+
+ CR Accounts + {{ creditLines.length }} account{{ creditLines.length !== 1 ? 's' : '' }} +
+
+ Details + {{ entry.details || '—' }} +
+ @if (detail?.description) { +
+ Description + {{ detail.description }} +
+ } +
+
+ + + @if (debitLines.length) { +
+
+ arrow_upward + Debit Entries ({{ debitLines.length }} account{{ debitLines.length !== 1 ? 's' : '' }} — funds out) +
+ + + + + + + + + + + + @for (line of debitLines; track $index; let i = $index) { + + + + + + + + } + + + + + +
#Account NoAccount NameTypeAmount (DR)
{{ i + 1 }}{{ line.accountNo || '—' }}{{ line.accountName || '—' }} + {{ line.type || '—' }} + ₹{{ line.amount | number: '1.2-2' }}
Total Debit₹{{ total(debitLines) | number: '1.2-2' }}
+
+ } + + + @if (creditLines.length) { +
+
+ arrow_downward + Credit Entries ({{ creditLines.length }} account{{ creditLines.length !== 1 ? 's' : '' }} — funds in) +
+ + + + + + + + + + + + @for (line of creditLines; track $index; let i = $index) { + + + + + + + + } + + + + + +
#Account NoAccount NameTypeAmount (CR)
{{ i + 1 }}{{ line.accountNo || '—' }}{{ line.accountName || '—' }} + {{ line.type || '—' }} + ₹{{ line.amount | number: '1.2-2' }}
Total Credit₹{{ total(creditLines) | number: '1.2-2' }}
+
+ } + + @if (entry.status === 'PENDING' && isMine && !done) { +
+
+ info + Your own entry +
+

+ You made this entry, so another user with checker rights has to approve or reject it. +

+
+ } + + @if (entry.status === 'REJECTED') { +
+
+ cancel + Rejected +
+

+ Nothing was done{{ voucherNo ? '. Voucher ' + voucherNo + ' is cancelled and will not be used' : '' }}. +

+ @if (entry.checker) { +
+ manage_accounts + {{ entry.checker }} +
+ } +
+ } + + @if (entry.status === 'APPROVED') { +
+
+ check_circle + Approved and carried out +
+

+ {{ + voucherNo + ? 'Voucher ' + voucherNo + : entry.entity === 'DDSBATCH' + ? 'Posting is running - refresh for its voucher' + : '—' + }} +

+
+ manage_accounts + Approved by: {{ entry.checker || '—' }} +
+
+ } + + @if (error) { +
+
+ error + Could not complete +
+

{{ error }}

+
+ } + + @if (entry.status === 'PENDING' && !done) { +
+ @if (!confirmReject) { + + + } @else { +
+ +
+ + +
+
+ } +
+ } +
+ } +
diff --git a/src/app/deposits/dds-approvals/dds-approval-review/dds-approval-review.component.ts b/src/app/deposits/dds-approvals/dds-approval-review/dds-approval-review.component.ts new file mode 100644 index 000000000..4cc45f2e6 --- /dev/null +++ b/src/app/deposits/dds-approvals/dds-approval-review/dds-approval-review.component.ts @@ -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/. + */ + +import { Component, OnInit, inject } from '@angular/core'; +import { CommonModule } from '@angular/common'; +import { MAT_DIALOG_DATA, MatDialogModule, MatDialogRef } from '@angular/material/dialog'; +import { MatIconModule } from '@angular/material/icon'; + +import { OrganizationService } from 'app/organization/organization.service'; + +/** + * One DDS entry, laid out the way Disbursement & Transfers reviews a request - voucher and status on + * top, who made it and when, then the debit and credit entries it moves - and styled by the same + * sheet, so a checker reads both queues the same way. + * + * The checker approves it, which carries it out under the voucher numbered when it was sent, or + * rejects it, which leaves nothing done and cancels that number. Neither is offered to the user who + * made the entry, and the server refuses it as well. + */ +@Component({ + selector: 'mifosx-dds-approval-review', + templateUrl: './dds-approval-review.component.html', + styleUrls: ['../../../clerk/disbursement-review/disbursement-review.component.scss'], + standalone: true, + imports: [ + CommonModule, + MatDialogModule, + MatIconModule + ] +}) +export class DdsApprovalReviewComponent implements OnInit { + private organizationService = inject(OrganizationService); + private dialogRef = inject(MatDialogRef); + data = inject(MAT_DIALOG_DATA); + + entry: any = this.data.entry; + /** The entry in full, with the debit and credit sides it moves. */ + detail: any = null; + loading = true; + working = false; + confirmReject = false; + done = false; + error = ''; + /** Set by an approval, in case the list row did not carry the number yet. */ + approvedVoucher = ''; + + ngOnInit(): void { + this.load(); + } + + private load(): void { + this.organizationService.getDdsApproval(this.entry.id).subscribe({ + next: (detail: any) => { + this.detail = detail; + this.entry = { ...this.entry, ...detail }; + this.loading = false; + }, + error: () => (this.loading = false) + }); + } + + get voucherNo(): string { + return this.approvedVoucher || this.entry?.voucherNo || ''; + } + + get debitLines(): any[] { + return (this.detail?.lines || []).filter((line: any) => line.side === 'DR'); + } + + get creditLines(): any[] { + return (this.detail?.lines || []).filter((line: any) => line.side === 'CR'); + } + + total(lines: any[]): number { + return lines.reduce((sum: number, line: any) => sum + Number(line.amount || 0), 0); + } + + get isMine(): boolean { + return !!this.data.currentUserId && Number(this.entry.makerId) === Number(this.data.currentUserId); + } + + get canAct(): boolean { + return this.entry.status === 'PENDING' && !this.isMine && !this.done && !this.working; + } + + statusClass(status: string): string { + const map: Record = { + PENDING: 'dri-status--pending', + APPROVED: 'dri-status--approved', + REJECTED: 'dri-status--rejected' + }; + return map[status] ?? ''; + } + + act(command: 'approve' | 'reject'): void { + this.working = true; + this.error = ''; + this.organizationService.actOnDdsApproval(this.entry.id, command).subscribe({ + next: (result: any) => { + this.working = false; + this.done = true; + this.approvedVoucher = result?.voucherNo || ''; + this.entry = { + ...this.entry, + status: command === 'approve' ? 'APPROVED' : 'REJECTED', + voucherCancelled: command === 'reject' && !!this.voucherNo + }; + // The approved entry's sides are now the voucher's own lines. + this.load(); + }, + error: (err: any) => { + this.working = false; + const body = err?.error; + this.error = + body?.errors?.[0]?.defaultUserMessage || + body?.defaultUserMessage || + body?.developerMessage || + 'Could not complete this.'; + } + }); + } + + /** The same form Disbursement & Transfers prints its times in: 29 Sept 2026, 01:25 pm. */ + formatDateTime(val: any): string { + if (!val) { + return '—'; + } + const d = new Date(String(val).replace(' ', 'T')); + return isNaN(d.getTime()) + ? String(val) + : d.toLocaleString('en-IN', { + day: '2-digit', + month: 'short', + year: 'numeric', + hour: '2-digit', + minute: '2-digit' + }); + } + + close(): void { + this.dialogRef.close(this.done); + } +} diff --git a/src/app/deposits/dds-approvals/dds-approvals.component.html b/src/app/deposits/dds-approvals/dds-approvals.component.html new file mode 100644 index 000000000..b8e4016c7 --- /dev/null +++ b/src/app/deposits/dds-approvals/dds-approvals.component.html @@ -0,0 +1,176 @@ + + +
+
+
+ savings +
+
DDS Approvals
+
+ Review, approve or reject DDS entries - transfers, payouts, early closures, receipts applied and postings. +
+
+
+
+ +
+ + + + +
+ @for (p of datePresets; track p.key) { + + } +
+ + + +
+
+ From + + + +
+ — +
+ To + + + +
+
+ + +
+ + @if (loading) { +
+

Loading…

+
+ } @else if (loaded && entries.length === 0) { +
+ +

No DDS entries

+

+ @if (filterStatus === 'PENDING') { + There are no DDS entries waiting for approval. + } @else { + No {{ filterStatus | lowercase }} DDS entries found. + } +

+
+ } @else { +
+ + + + + + + + + + + + + + + + + @for (e of pagedEntries; track e.id) { + + + + + + + + + + + + + } + +
Voucher NoTypeAccount / DetailsAmountEntriesClerkReviewed BySubmitted AtStatusAction
+ {{ e.voucherNo || '—' }} + @if (e.voucherCancelled) { + (cancelled) + } + + {{ e.type }} + + {{ e.details || '—' }} + + {{ e.amount !== null && e.amount !== undefined ? '₹' + (e.amount | number: '1.2-2') : '—' }} + + @if (e.entries) { + {{ e.entries }} account{{ e.entries !== 1 ? 's' : '' }} + } @else { + — + } + + {{ e.makerName || e.maker || '—' }} + @if (isMine(e) && e.status === 'PENDING') { + (you) + } + {{ e.status === 'PENDING' ? '—' : e.checkerName || e.checker || '—' }}{{ formatDateTime(e.madeOn) }} + {{ e.status | titlecase }} + + +
+
+ + + } +
diff --git a/src/app/deposits/dds-approvals/dds-approvals.component.ts b/src/app/deposits/dds-approvals/dds-approvals.component.ts new file mode 100644 index 000000000..1c699ec1c --- /dev/null +++ b/src/app/deposits/dds-approvals/dds-approvals.component.ts @@ -0,0 +1,215 @@ +/** + * 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 { MatIconModule } from '@angular/material/icon'; +import { MatPaginatorModule, PageEvent } from '@angular/material/paginator'; +import { MatDialog } from '@angular/material/dialog'; +import { MatNativeDateModule } from '@angular/material/core'; +import { MatDatepickerModule } from '@angular/material/datepicker'; +import { catchError } from 'rxjs/operators'; +import { of } from 'rxjs'; + +import { OrganizationService } from 'app/organization/organization.service'; +import { SettingsService } from 'app/settings/settings.service'; +import { AuthenticationService } from 'app/core/authentication/authentication.service'; +import { DdsApprovalReviewComponent } from './dds-approval-review/dds-approval-review.component'; + +/** + * Approval Center › DDS Approvals: every DDS entry waiting on a checker, in the clerk's terms - what + * it does, on which accounts, for how much - and, once approved, the voucher it moved under. + * + * Laid out like Disbursement & Transfers beside it, and sharing its styles, so the two queues read the + * same way. The entries are the platform's own maker-checker entries; approving one here carries it out. + */ +@Component({ + selector: 'mifosx-dds-approvals', + templateUrl: './dds-approvals.component.html', + styleUrls: ['../../tasks/checker-inbox-and-tasks-tabs/disbursement-requests/disbursement-requests.component.scss'], + standalone: true, + imports: [ + CommonModule, + FormsModule, + MatIconModule, + MatPaginatorModule, + MatNativeDateModule, + MatDatepickerModule + ] +}) +export class DdsApprovalsComponent implements OnInit { + private organizationService = inject(OrganizationService); + private settingsService = inject(SettingsService); + private authenticationService = inject(AuthenticationService); + private dialog = inject(MatDialog); + + entries: any[] = []; + pageSize = 20; + pageIndex = 0; + readonly pageSizeOptions = [ + 10, + 20, + 50 + ]; + + /** The page of entries on screen. */ + get pagedEntries(): any[] { + const start = this.pageIndex * this.pageSize; + return this.entries.slice(start, start + this.pageSize); + } + + onPageChange(event: PageEvent): void { + this.pageSize = event.pageSize; + this.pageIndex = event.pageIndex; + } + filterStatus = 'PENDING'; + fromDate: Date | null = null; + toDate: Date | null = null; + maxDate!: Date; + activePreset = 'today'; + loading = false; + loaded = false; + + readonly currentUserId = this.authenticationService.getCredentials()?.userId; + + readonly statusOptions = [ + { value: 'PENDING', label: 'Pending' }, + { value: 'APPROVED', label: 'Approved' }, + { value: 'REJECTED', label: 'Rejected' } + ]; + + readonly datePresets = [ + { key: 'today', label: 'Today' }, + { key: 'week', label: 'This Week' }, + { key: 'month', label: 'This Month' }, + { key: 'all', label: 'All' } + ]; + + ngOnInit(): void { + const today = new Date(this.settingsService.businessDate || new Date()); + today.setHours(0, 0, 0, 0); + this.maxDate = today; + this.applyPreset('today'); + } + + load(): void { + this.loading = true; + this.organizationService + .getDdsApprovals(this.filterStatus, this.isoDate(this.fromDate), this.isoDate(this.toDate)) + .pipe(catchError(() => of([]))) + .subscribe((rows: any) => { + this.entries = Array.isArray(rows) ? rows : []; + this.pageIndex = 0; + this.loading = false; + this.loaded = true; + }); + } + + onFilterChange(): void { + this.load(); + } + + onFromDateChange(): void { + this.activePreset = ''; + if (this.fromDate && this.toDate && this.fromDate > this.toDate) { + this.toDate = new Date(this.fromDate); + } + this.load(); + } + + onToDateChange(): void { + this.activePreset = ''; + this.load(); + } + + applyPreset(key: string): void { + this.activePreset = key; + const today = new Date(this.settingsService.businessDate || new Date()); + today.setHours(0, 0, 0, 0); + if (key === 'today') { + this.fromDate = new Date(today); + this.toDate = new Date(today); + } else if (key === 'week') { + const start = new Date(today); + start.setDate(today.getDate() - today.getDay()); + this.fromDate = start; + this.toDate = new Date(today); + } else if (key === 'month') { + this.fromDate = new Date(today.getFullYear(), today.getMonth(), 1); + this.toDate = new Date(today); + } else { + this.fromDate = null; + this.toDate = null; + } + this.load(); + } + + review(entry: any): void { + this.dialog + .open(DdsApprovalReviewComponent, { data: { entry, currentUserId: this.currentUserId }, autoFocus: false }) + .afterClosed() + // However the dialog was closed - its Close button, a click outside, Esc - the list is read + // again, so an entry just approved or rejected leaves the Pending list at once. + .subscribe(() => this.load()); + } + + /** Whose entry it is - a checker cannot pass their own. */ + isMine(entry: any): boolean { + return !!this.currentUserId && Number(entry.makerId) === Number(this.currentUserId); + } + + statusClass(status: string): string { + const map: Record = { + PENDING: 'wri-status--pending', + APPROVED: 'wri-status--approved', + REJECTED: 'wri-status--rejected' + }; + return map[status] ?? ''; + } + + typeColor(entry: any): string { + const map: Record = { + Transfer: '#e3f2fd', + 'Apply Receipt': '#fce4ec' + }; + if (String(entry.type).startsWith('Premature')) { + return '#fff3e0'; + } + if (String(entry.type).startsWith('Maturity')) { + return '#e8f5e9'; + } + if (String(entry.type).startsWith('Post')) { + return '#ede7f6'; + } + return map[entry.type] ?? '#f5f5f5'; + } + + formatDateTime(val: any): string { + if (!val) { + return '—'; + } + const d = new Date(String(val).replace(' ', 'T')); + return isNaN(d.getTime()) + ? String(val) + : d.toLocaleString('en-IN', { + day: '2-digit', + month: 'short', + year: 'numeric', + hour: '2-digit', + minute: '2-digit' + }); + } + + private isoDate(d: Date | null): string | undefined { + if (!d) { + return undefined; + } + return `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, '0')}-${String(d.getDate()).padStart(2, '0')}`; + } +} diff --git a/src/app/deposits/dds-reports/dds-reports.component.html b/src/app/deposits/dds-reports/dds-reports.component.html new file mode 100644 index 000000000..befe2fe1f --- /dev/null +++ b/src/app/deposits/dds-reports/dds-reports.component.html @@ -0,0 +1,33 @@ + + +
+
+ +

DDS Reports

+
+ + @for (group of reportGroups; track group.title) { +
+
{{ group.title }}
+
+ @for (report of group.reports; track report) { + +
+ +
+
{{ report }}
+ +
+ } +
+
+ } +
diff --git a/src/app/deposits/dds-reports/dds-reports.component.scss b/src/app/deposits/dds-reports/dds-reports.component.scss new file mode 100644 index 000000000..56830830a --- /dev/null +++ b/src/app/deposits/dds-reports/dds-reports.component.scss @@ -0,0 +1,97 @@ +/** + * 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/. + */ + +$primary: #0f766e; + +.dr-container { + max-width: 1080px; + margin: 0 auto; + padding: 24px 16px; +} + +.dr-header { + display: flex; + align-items: center; + gap: 16px; + margin-bottom: 24px; + + &__title { + margin: 0; + font-size: 22px; + font-weight: 500; + } +} + +.dr-back { + color: $primary; +} + +.dr-group { + margin-bottom: 24px; + + &__title { + font-size: 13px; + text-transform: uppercase; + letter-spacing: 0.05em; + color: $primary; + font-weight: 700; + margin-bottom: 10px; + } +} + +.dr-grid { + display: grid; + grid-template-columns: repeat(3, 1fr); + gap: 12px; +} + +.dr-card { + display: flex; + align-items: center; + gap: 12px; + background: var(--md-sys-color-surface, #fff); + border: 1px solid rgb(0 0 0 / 8%); + border-radius: 8px; + padding: 12px 14px; + text-decoration: none; + color: inherit; + transition: box-shadow 0.15s ease; + + &:hover { + box-shadow: 0 2px 8px rgb(0 0 0 / 10%); + } + + &__icon { + color: $primary; + font-size: 16px; + flex: none; + } + + &__title { + flex: 1; + font-size: 13px; + font-weight: 500; + } + + &__arrow { + color: rgb(0 0 0 / 30%); + font-size: 12px; + } +} + +@media (width <= 900px) { + .dr-grid { + grid-template-columns: repeat(2, 1fr); + } +} + +@media (width <= 600px) { + .dr-grid { + grid-template-columns: 1fr; + } +} diff --git a/src/app/deposits/dds-reports/dds-reports.component.ts b/src/app/deposits/dds-reports/dds-reports.component.ts new file mode 100644 index 000000000..63af55d01 --- /dev/null +++ b/src/app/deposits/dds-reports/dds-reports.component.ts @@ -0,0 +1,73 @@ +/** + * Copyright since 2025 Mifos Initiative + * + * This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. + */ + +/** Angular Imports */ +import { Component } from '@angular/core'; +import { STANDALONE_SHARED_IMPORTS } from 'app/standalone-shared.module'; + +/** + * DDS Reports hub component. + * + * Lists every DDS report from the legacy Reports menu, grouped the same + * way (As-On-Date, Interest, Daily/Balance/Master, Transaction). Each + * links to the shared report viewer, parameterised by report name. + * + * UI only for now: no report actually queries the backend yet — see + * ReportViewerComponent. + */ +@Component({ + selector: 'mifosx-dds-reports', + templateUrl: './dds-reports.component.html', + styleUrls: ['./dds-reports.component.scss'], + imports: [...STANDALONE_SHARED_IMPORTS] +}) +export class DdsReportsComponent { + reportGroups = [ + { + title: 'As On Date', + reports: [ + 'Supplementary-DDS (As On)', + 'Agent Check List', + 'Party Check List-As On', + 'Party Balance', + 'Agent Wise Balance', + 'Agent Wise Monthly Collection', + 'Commission Register', + 'Collection Summary Agent Wise', + 'Close Party Ac Register', + 'Agent Partywise Daily Collection', + 'Daily Collection Balance', + 'Interest Certificate', + 'Mature Date Wise Report' + ] + }, + { + title: 'Interest', + reports: [ + 'Party Int. Register', + 'Commission Register' + ] + }, + { + title: 'Daily / Balance / Master', + reports: [ + 'Daily Collection Report', + 'Balance Summary', + 'Master Data Export' + ] + }, + { + title: 'Transaction', + reports: ['Transaction Register'] + }, + { + title: 'Statements', + reports: ['Agent Commission Statement'] + } + ]; +} diff --git a/src/app/deposits/dds-reports/report-viewer/report-viewer.component.html b/src/app/deposits/dds-reports/report-viewer/report-viewer.component.html new file mode 100644 index 000000000..28d38a424 --- /dev/null +++ b/src/app/deposits/dds-reports/report-viewer/report-viewer.component.html @@ -0,0 +1,146 @@ + + +
+
+ +

{{ reportName }}

+
+ +
+
+ + @if (!hasNoFilters) { +
+ @if (usesDate) { + + {{ dateLabel }} — from + + + + + + + {{ dateLabel }} — to + + + + + } + + @if (usesAgent) { + + Agent From + + + + + Agent To + + + } + + @if (usesParty) { + + Party From + + + + + Party To + + + } +
+ } + +
+ + + +
+ + @if (hasRun) { +
+ @if (!available) { +

{{ unavailableMessage }}

+ } @else if (rows.length === 0) { +

No rows matched these filters.

+ } @else { + +
+
+

{{ reportName }}

+ @if (usesDate) { +

+ {{ dateLabel | lowercase }} {{ ranFromDate | dateFormat }} and + {{ ranToDate | dateFormat }} +

+ } @else { +

as on {{ ranOnDate | dateFormat }}

+ } + +
+ +
+ + + + + @for (column of columns; track column) { + + } + + + + @for (row of rows; track $index) { + + + @for (column of columns; track column) { + + } + + } + + @if (hasTotalledColumn) { + + + + @for (column of columns; track column; let first = $first) { + + } + + + } +
#{{ column }}
{{ $index + 1 }}{{ cell(row, column) }}
+ @if (first) { + Total : + } @else if (isTotalledColumn(column)) { + {{ columnTotal(column) }} + } +
+
+ +

{{ rows.length }} {{ rows.length === 1 ? 'row' : 'rows' }}

+
+ } +
+ } +
+
+
diff --git a/src/app/deposits/dds-reports/report-viewer/report-viewer.component.scss b/src/app/deposits/dds-reports/report-viewer/report-viewer.component.scss new file mode 100644 index 000000000..5fd19d364 --- /dev/null +++ b/src/app/deposits/dds-reports/report-viewer/report-viewer.component.scss @@ -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/. + */ + +$primary: #0f766e; + +.rv-container { + max-width: 1100px; + margin: 0 auto; + padding: 24px 16px; +} + +.rv-header { + display: flex; + align-items: center; + gap: 16px; + margin-bottom: 20px; + + &__title { + margin: 0; + font-size: 22px; + font-weight: 500; + } +} + +.rv-back { + color: $primary; +} + +.rv-card { + background: var(--md-sys-color-surface, #fff); + border-radius: 8px; + box-shadow: 0 2px 8px rgb(0 0 0 / 8%); + padding: 20px 24px 24px; +} + +.rv-grid { + display: grid; + grid-template-columns: repeat(3, 1fr); + gap: 8px 16px; +} + +.rv-field { + width: 100%; +} + +.rv-action-bar { + display: flex; + justify-content: flex-end; + gap: 12px; + margin-top: 20px; + padding-top: 16px; + border-top: 1px solid rgb(0 0 0 / 8%); +} + +.rv-results { + margin-top: 20px; + padding-top: 16px; + border-top: 1px solid rgb(0 0 0 / 8%); +} + +.rv-empty { + color: rgb(0 0 0 / 50%); + font-size: 14px; + margin: 0; + text-align: center; + padding: 24px 0; +} + +.rv-table-scroll { + overflow-x: auto; +} + +.rv-table { + width: 100%; + border-collapse: collapse; + white-space: nowrap; + + th { + text-align: left; + font-size: 13px; + text-transform: uppercase; + letter-spacing: 0.04em; + color: rgb(0 0 0 / 55%); + padding: 8px 10px; + border-bottom: 2px solid rgb(0 0 0 / 10%); + } + + td { + padding: 8px 10px; + border-bottom: 1px solid rgb(0 0 0 / 6%); + } + + /* Figures line up column-wise so a wrong order of magnitude is visible at a glance. */ + .rv-num { + text-align: right; + font-variant-numeric: tabular-nums; + } + + /* The row number down the left, as the printed statement carried it. */ + .rv-sr { + width: 1%; + color: rgb(0 0 0 / 45%); + font-style: italic; + font-variant-numeric: tabular-nums; + } + + tfoot td { + border-bottom: none; + border-top: 2px solid rgb(0 0 0 / 20%); + font-weight: 600; + padding-top: 10px; + } +} + +/* ---- the statement block: heading, table, footer count ---- */ + +.rv-action-bar i { + margin-right: 6px; +} + +.rv-statement__head { + display: flex; + align-items: baseline; + flex-wrap: wrap; + gap: 4px 12px; + margin-bottom: 14px; +} + +.rv-statement__title { + margin: 0; + font-size: 17px; + font-weight: 600; + flex: 0 0 auto; +} + +.rv-statement__period { + margin: 0; + font-size: 13px; + color: rgb(0 0 0 / 55%); +} + +.rv-print { + margin-left: auto; + + i { + margin-right: 6px; + } +} + +.rv-statement__count { + margin: 10px 0 0; + font-size: 12px; + color: rgb(0 0 0 / 45%); + text-align: right; +} + +/* Printing a statement should give the statement, not the screen around it. */ +@media print { + .rv-header, + .rv-grid, + .rv-action-bar, + .rv-print { + display: none !important; + } + + .rv-card { + box-shadow: none; + padding: 0; + } + + .rv-table-scroll { + overflow-x: visible; + } + + .rv-table { + white-space: normal; + font-size: 11px; + } +} + +@media (width <= 900px) { + .rv-grid { + grid-template-columns: repeat(2, 1fr); + } +} diff --git a/src/app/deposits/dds-reports/report-viewer/report-viewer.component.ts b/src/app/deposits/dds-reports/report-viewer/report-viewer.component.ts new file mode 100644 index 000000000..6bcf14dcc --- /dev/null +++ b/src/app/deposits/dds-reports/report-viewer/report-viewer.component.ts @@ -0,0 +1,344 @@ +/** + * Copyright since 2025 Mifos Initiative + * + * This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. + */ + +/** Angular Imports */ +import { Component, OnInit, inject } from '@angular/core'; +import { ActivatedRoute, Router } from '@angular/router'; +import { UntypedFormBuilder, UntypedFormGroup, Validators } from '@angular/forms'; +import { STANDALONE_SHARED_IMPORTS } from 'app/standalone-shared.module'; + +/** Custom Services */ +import { OrganizationService } from 'app/organization/organization.service'; +import { Dates } from 'app/core/utils/dates'; + +/** + * Generic DDS report viewer. + * + * One shared screen for all 13+ DDS reports — the report name comes from + * the route and is mapped to a backend report key. Every report in the + * legacy system shared the same basic filter shape (date, agent range, + * party range), so this avoids building a bespoke screen per report - the + * backend returns a generic {columns, rows} shape this renders as one + * table regardless of which report it is. + * + * One legacy report (Close Party Ac Register) has no corresponding data in + * the backend yet - Party accounts have no closed/open status at all. Run + * Report still calls through, and the backend's `available: false` + + * message is shown instead of a table. + */ +@Component({ + selector: 'mifosx-dds-report-viewer', + templateUrl: './report-viewer.component.html', + styleUrls: ['./report-viewer.component.scss'], + imports: [...STANDALONE_SHARED_IMPORTS] +}) +export class ReportViewerComponent implements OnInit { + private route = inject(ActivatedRoute); + private router = inject(Router); + private formBuilder = inject(UntypedFormBuilder); + private organizationService = inject(OrganizationService); + private dateUtils = inject(Dates); + + private static readonly REPORT_KEYS: Record = { + 'Supplementary-DDS (As On)': 'SUPPLEMENTARY_DDS', + 'Agent Check List': 'AGENT_CHECK_LIST', + 'Party Check List-As On': 'PARTY_CHECK_LIST', + 'Party Balance': 'PARTY_BALANCE', + 'Agent Wise Balance': 'AGENT_WISE_BALANCE', + 'Agent Wise Monthly Collection': 'AGENT_WISE_MONTHLY_COLLECTION', + 'Commission Register': 'COMMISSION_REGISTER', + 'Collection Summary Agent Wise': 'COLLECTION_SUMMARY_AGENT_WISE', + 'Close Party Ac Register': 'CLOSE_PARTY_AC_REGISTER', + 'Agent Partywise Daily Collection': 'AGENT_PARTYWISE_DAILY_COLLECTION', + 'Daily Collection Balance': 'DAILY_COLLECTION_REPORT', + 'Interest Certificate': 'INTEREST_CERTIFICATE', + 'Mature Date Wise Report': 'MATURE_DATE_WISE', + 'Party Int. Register': 'PARTY_INTEREST_REGISTER', + 'Daily Collection Report': 'DAILY_COLLECTION_REPORT', + 'Balance Summary': 'BALANCE_SUMMARY', + 'Master Data Export': 'MASTER_DATA_EXPORT', + 'Transaction Register': 'TRANSACTION_REGISTER', + 'Agent Commission Statement': 'AGENT_COMMISSION_STATEMENT' + }; + + /** + * Which filters each report actually uses, and what its date range means. + * + * Every report used to be offered the same six filters whether it read them or not — so a date + * range could be typed on the Agent Check List, which has no date in its query at all, and the + * same rows would come back however it was set. A filter that changes nothing is worse than no + * filter: it reads as a report that ignores what it was asked. + * + * Kept in step with the backend by hand; the source of truth is the switch in + * `DdsReportReadPlatformServiceImpl.runReport`. + */ + private static readonly REPORT_FILTERS: Record = { + AGENT_CHECK_LIST: {}, + PARTY_CHECK_LIST: { agent: true, party: true }, + PARTY_BALANCE: { agent: true, party: true }, + AGENT_WISE_BALANCE: { agent: true }, + BALANCE_SUMMARY: { agent: true, party: true }, + MASTER_DATA_EXPORT: { agent: true, party: true }, + + AGENT_WISE_MONTHLY_COLLECTION: { date: 'Collected between', agent: true }, + COLLECTION_SUMMARY_AGENT_WISE: { date: 'Collected between', agent: true }, + DAILY_COLLECTION_REPORT: { date: 'Collected between', agent: true }, + AGENT_PARTYWISE_DAILY_COLLECTION: { date: 'Collected between', agent: true, party: true }, + SUPPLEMENTARY_DDS: { date: 'Collected between', agent: true, party: true }, + + COMMISSION_REGISTER: { date: 'Posted between', agent: true }, + AGENT_COMMISSION_STATEMENT: { date: 'Posted between' }, + PARTY_INTEREST_REGISTER: { date: 'Posted between', party: true }, + INTEREST_CERTIFICATE: { date: 'Interest earned between', agent: true, party: true }, + + TRANSACTION_REGISTER: { date: 'Transacted between', agent: true, party: true }, + CLOSE_PARTY_AC_REGISTER: { date: 'Closed between', agent: true, party: true }, + MATURE_DATE_WISE: { date: 'Maturing between', agent: true, party: true } + }; + + reportName: string = this.route.snapshot.paramMap.get('reportName') || 'Report'; + + hasRun = false; + available = true; + unavailableMessage = ''; + columns: string[] = []; + rows: Array> = []; + + /** The range the rows on screen were actually fetched for — not whatever the filter says now. */ + ranFromDate: Date | null = null; + ranToDate: Date | null = null; + + /** When a report carries no date range, the moment it was run is what dates its figures. */ + ranOnDate: Date | null = null; + + private static readonly MONEY = new Intl.NumberFormat('en-IN', { + minimumFractionDigits: 2, + maximumFractionDigits: 2 + }); + + private static readonly DATE = new Intl.DateTimeFormat('en-GB', { + day: '2-digit', + month: '2-digit', + year: 'numeric' + }); + + /** + * One cell, rendered the way a printed statement shows it. + * + * Dates arrive from the platform as `[yyyy, m, d]` and numbers as raw decimals, so printing them + * as they come gives "2026,9,2" and "10052.6" — unreadable on a statement someone checks figures + * against. Amounts get Indian grouping and two decimals; identifiers that merely look numeric + * (account numbers, codes) are left alone, since grouping them would corrupt them. + */ + cell(row: Record, column: string): string { + const value = row[column]; + + if (value === null || value === undefined || value === '') { + return '—'; + } + if (Array.isArray(value) && value.length >= 3) { + return ReportViewerComponent.DATE.format(new Date(value[0], value[1] - 1, value[2])); + } + if (this.isAmountColumn(column) && typeof value === 'number') { + return ReportViewerComponent.MONEY.format(value); + } + return String(value); + } + + /** + * Whether a column holds money, and so should be right-aligned with two decimals. + * + * Decided from the column's name rather than its values: a count column and an account-number + * column are both numbers, and neither reads as an amount. + */ + isAmountColumn(column: string): boolean { + const name = column.toLowerCase(); + + // Ruled out first, because several of these also contain a money word: a rate is a percentage, + // "Total Accounts" is a count that happens to say total, and an account number is an identifier + // that would be corrupted by grouping. + // + // "agents" is matched exactly rather than as a substring: the two count columns are named + // "Total Agents" and "Active Agents", but "Cash With Agents" and "Commission Due To Agents" + // are money, and a substring test swept those two up and printed them ungrouped. + // + // A "type" column names a kind, never a quantity — "Interest Type" holds the word "Interest", + // which is enough for the money test below to claim it and put a meaningless total under it. + if ( + name.includes('a/c no') || + name.includes('code') || + name.includes('rate') || + name.includes('%') || + name.includes('count') || + name.includes('entries') || + name.includes('accounts') || + name === 'total agents' || + name === 'active agents' || + name.includes('type') || + name.includes('pan') + ) { + return false; + } + + return ( + name.includes('amount') || + name.includes('balance') || + name.includes('collect') || + name.includes('commission') || + name.includes('deduction') || + name.includes('deposit') || + name.includes('advance') || + name.includes('interest') || + name.includes('tds') || + name.includes('total') || + name.includes('diff') || + name.includes('cash') || + name.includes('debit') || + name.includes('credit') || + name.includes('settled') || + name.includes('shortfall') || + name.includes('due') + ); + } + + /** + * Whether a total under this column would mean anything. + * + * Not every amount is worth adding up. A "Balance After" on a transaction register is the running + * balance *at that moment*, so its column holds several snapshots of the same account at different + * times — summing them produces a figure that corresponds to nothing, and a reader would sooner or + * later try to reconcile it against something. + * + * A plain balance on a listing report is different: there each row is one account's position at + * one moment, and the sum is the real "total held", which is exactly what a branch wants to see. + */ + isTotalledColumn(column: string): boolean { + if (!this.isAmountColumn(column)) { + return false; + } + return !column.toLowerCase().includes('balance after'); + } + + /** The figure printed under a column, as the legacy statement's "Total :" row does. */ + columnTotal(column: string): string { + const sum = this.rows.reduce((running, row) => { + const value = row[column]; + return running + (typeof value === 'number' ? value : 0); + }, 0); + return ReportViewerComponent.MONEY.format(sum); + } + + /** + * Whether to print a Total row at all. + * + * A single-row report is already its own total - Balance Summary is one line of figures for the + * whole scheme, and repeating it underneath itself only invites the reader to look for a difference + * between the two. + */ + get hasTotalledColumn(): boolean { + return this.rows.length > 1 && this.columns.some((column) => this.isTotalledColumn(column)); + } + + print(): void { + window.print(); + } + + filterForm: UntypedFormGroup = this.formBuilder.group({ + fromDate: [''], + toDate: [''], + agentFrom: [1], + agentTo: [99999], + partyFrom: [1], + partyTo: [99999999] + }); + + private get filters(): { date?: string; agent?: boolean; party?: boolean } { + return ReportViewerComponent.REPORT_FILTERS[ReportViewerComponent.REPORT_KEYS[this.reportName]] ?? {}; + } + + /** True when this report's query actually reads a date range - only then is one asked for. */ + get usesDate(): boolean { + return !!this.filters.date; + } + + /** What this report's date range means, e.g. "Posted between" rather than a bare "From Date". */ + get dateLabel(): string { + return this.filters.date ?? ''; + } + + get usesAgent(): boolean { + return !!this.filters.agent; + } + + get usesParty(): boolean { + return !!this.filters.party; + } + + /** True when this report takes no filters at all, so there is nothing for anyone to fill in. */ + get hasNoFilters(): boolean { + return !this.usesDate && !this.usesAgent && !this.usesParty; + } + + ngOnInit(): void { + // A date is required exactly for the reports that read one, so a report without dates can be run + // straight away instead of demanding two values it will ignore. + if (this.usesDate) { + this.filterForm.get('fromDate')?.setValidators([Validators.required]); + this.filterForm.get('toDate')?.setValidators([Validators.required]); + this.filterForm.get('fromDate')?.updateValueAndValidity(); + this.filterForm.get('toDate')?.updateValueAndValidity(); + } + + // Nothing to narrow it by means nothing to decide, so the report simply opens showing its + // figures. Making someone press Run on an empty form asks a question with only one answer. + if (this.hasNoFilters) { + this.runReport(); + } + } + + runReport(): void { + const reportKey = ReportViewerComponent.REPORT_KEYS[this.reportName]; + if (!reportKey) { + return; + } + const raw = this.filterForm.getRawValue(); + + // Only what this report reads is sent. The backend defaults the rest to the full range, so a + // report that ignores a filter is never handed a value that looks like it was applied. + const params: Record = {}; + if (this.usesDate) { + params['fromDate'] = this.dateUtils.getDate(raw.fromDate); + params['toDate'] = this.dateUtils.getDate(raw.toDate); + } + if (this.usesAgent) { + params['agentFrom'] = raw.agentFrom; + params['agentTo'] = raw.agentTo; + } + if (this.usesParty) { + params['partyFrom'] = raw.partyFrom; + params['partyTo'] = raw.partyTo; + } + + this.organizationService.runDdsReport(reportKey, params).subscribe((result: any) => { + this.available = result.available; + this.unavailableMessage = result.message || ''; + this.columns = result.columns || []; + this.rows = result.rows || []; + // Captured here so the printed heading names the range these rows came from, and does not + // silently change if the filter is edited without re-running. + this.ranFromDate = raw.fromDate; + this.ranToDate = raw.toDate; + this.ranOnDate = new Date(); + this.hasRun = true; + }); + } + + exit(): void { + this.router.navigate(['../../'], { relativeTo: this.route }); + } +} diff --git a/src/app/deposits/fixed-deposits/fixed-deposit-account-view/transactions-tab/transactions-tab.component.html b/src/app/deposits/fixed-deposits/fixed-deposit-account-view/transactions-tab/transactions-tab.component.html index 6e447a59c..bef30de6c 100644 --- a/src/app/deposits/fixed-deposits/fixed-deposit-account-view/transactions-tab/transactions-tab.component.html +++ b/src/app/deposits/fixed-deposits/fixed-deposit-account-view/transactions-tab/transactions-tab.component.html @@ -130,12 +130,6 @@ {{ 'labels.text.View Transaction' | translate }} - @if (!isFirstTransaction(idx)) { - - } - - } -
+
diff --git a/src/app/deposits/party-master/client-search.ts b/src/app/deposits/party-master/client-search.ts new file mode 100644 index 000000000..a898f8590 --- /dev/null +++ b/src/app/deposits/party-master/client-search.ts @@ -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/. + */ + +import { Observable, forkJoin, of } from 'rxjs'; +import { catchError, map } from 'rxjs/operators'; +import { ClientsService } from 'app/clients/clients.service'; + +/** + * Finds clients by name whatever case the officer types it in. + * + * The platform's name filter matches anywhere in the name but only in the exact case typed, so + * "sneha" finds nobody while "Sneha" finds Ms. Sneha Iyer. Names are stored capitalised, so the term + * is tried as typed, capitalised word by word, and in upper and lower case, and the results merged. + * Done here rather than by changing the platform's search, which every other screen relies on. + */ +export function searchClientsAnyCase(clientsService: ClientsService, term: string, officeId?: any): Observable { + const variants = Array.from( + new Set([ + term, + term.toLowerCase().replace(/(^|[\s.])(\p{L})/gu, (_match, lead, letter) => lead + letter.toUpperCase()), + term.toUpperCase(), + term.toLowerCase() + ]) + ); + return forkJoin( + variants.map((variant) => + clientsService.getFilteredClients('displayName', 'ASC', false, variant, officeId).pipe( + map((response: any) => { + const found = response?.pageItems || response || []; + return Array.isArray(found) ? found : []; + }), + catchError(() => of([])) + ) + ) + ).pipe( + map((lists: any[][]) => { + const byId = new Map(); + for (const client of lists.flat()) { + byId.set(client.id, client); + } + return Array.from(byId.values()).sort((a, b) => String(a.displayName).localeCompare(String(b.displayName))); + }) + ); +} diff --git a/src/app/deposits/party-master/create-party/create-party.component.html b/src/app/deposits/party-master/create-party/create-party.component.html new file mode 100644 index 000000000..45f765ff1 --- /dev/null +++ b/src/app/deposits/party-master/create-party/create-party.component.html @@ -0,0 +1,1051 @@ + + +
+
+ +

{{ partyId ? 'Edit Party' : 'Party Master Entry' }}

+ @if (isClosed) { + Closed — view only + } +
+ +
+
+ + +
+
+
+ +

Customer & Scheme

+ Who this account is for, which scheme it runs under, and which agent collects for it +
+
+
+ + + + + + @for (client of clientOptions; track client.id) { + +
+ {{ client.id }} + {{ client.displayName }} + {{ client.officeName }} +
+
+ } + + @if (clientListIsPartial) { + + Showing {{ clientOptions.length }} of {{ clientTotal }} — type a name to find the rest + + } +
+ @if (loadedClientName) { + {{ loadedClientName }} + } @else if (clientLookupFailed) { + No client with this ID + } +
+
+ +
+ + + + + + @for (type of subTypeOptions; track type.id) { + +
+ {{ type.accountTypeCode }} + {{ type.accountTypeDescription }} + {{ type.accountTypeGroup }} +
+
+ } + @if (subTypeOptions.length === 0) { + No scheme matches that. + } +
+ @if (subTypes.length === 0) { + No active account type — create one first + } +
+
+ +
+ + + + + + @for (agent of agentOptions; track agent.id) { + +
+ {{ agent.agentCode }} + {{ agent.agentNameId }} + + + {{ agent.active ? agent.homeBranch : 'not collecting' }} + +
+
+ } + @if (agentOptions.length === 0) { + No agent matches that. + } +
+ @if (loadedAgentName && !agentLookupFailed) { + {{ loadedAgentName }} + } @else if (loadedAgentName && agentLookupFailed) { + {{ loadedAgentName }} is deactivated + } @else if (agentLookupFailed) { + No agent with this ID + } +
+
+ + +
+ + + + @for (office of offices; track office.id) { + {{ office.name }} + } + + @if (officesError) { + {{ officesError }} + } @else { + The branch this account belongs to + } + +
+ + + @if (accountNo) { + +
+ + +
+ } @else if (savingsAccountNo) { +
+ + +
+ } @else if (partyId) { +
+ + +
+ } @else { +
+ + +
+ } + +
+ + + + +
+ +
+ + + + +
+ +
+ + + + Yes + No + + +
+ +
+ + + + +
+ +
+ + + + +
+ +
+ + + + +
+ +
+ + + + +
+ +
+ + + + +
+ +
+ + + + +
+ +
+ + + + +
+ +
+ + + + Male + Female + Other + + +
+ +
+ + + + +
+ +
+ + + + +
+ +
+ + + + +
+ +
+ + + + +
+ +
+ + + + +
+ +
+ + + + @for (op of operations; track op) { + {{ op }} + } + + +
+ +
+ + + + + + +
+ +
+ + + + @for (occ of occupations; track occ) { + {{ occ }} + } + + +
+ +
+ + + + @for (r of riskLevels; track r) { + {{ r }} + } + + +
+
+
+ +
+
+ +

Minor / Guardian

+ Only for a minor's account — the guardian who operates it until they come of age +
+
+
+ + + + @for (opt of adultMinorOptions; track opt) { + {{ opt }} + } + + +
+ + @if (isMinorAccount) { +
+ + + + + + Turns 18 on + +
+ +
+ + + + + + @for (client of guardianOptions; track client.id) { + +
+ {{ client.id }} + {{ client.displayName }} + {{ client.officeName }} +
+
+ } +
+ @if (loadedGuardianName) { + {{ loadedGuardianName }} + } +
+
+ +
+ + + + +
+ +
+ + + + To the minor + +
+ } +
+ @if (!isMinorAccount) { +

An adult's account needs no guardian details.

+ } +
+ +
+
+ +

Scheme Terms

+ How much is collected daily, for how long, and what it matures to +
+
+
+ + + + + + + +
+ +
+ + + + +
+ +
+ + + + + Calculated + +
+ +
+ + + + + + +
+ +
+ + + + +
+ +
+ + + + +
+ +
+ + + + @if (partyForm.get('days')?.hasError('exceedsTerm')) { + Only {{ termDays }} days in this term + } @else if (termDays) { + Term is {{ termDays }} days + } + +
+ +
+ + + + Collection book no. + +
+ +
+ + + + Calculated + +
+ + + +
+
+ +
+
+ +

Balances & Status

+ Kept by collections and the nightly batch — shown here, never typed +
+
+
+ OD1 Flag +
+
+ OD2 Flag +
+ +
+ + + + Yes + No + + @if (lienHeld) { + Nothing can leave this account until it is released + } + +
+ + + @if (lienHeld) { +
+ + + + @for (reason of lienReasons; track reason) { + {{ reason }} + } + + What this money is being held for + +
+ + + @if (lienReasonIsOther) { +
+ + + + Write the reason in your own words + +
+ } + } + +
+ + + + + + +
+ +
+ + + + +
+ +
+ + + + +
+ +
+ + + + +
+ +
+ Is SMS Active +
+
+ Is Email Active +
+ +
+ + + + +
+ +
+ + + + + + +
+ +
+ + + + +
+ +
+ + + + @for (code of freezeCodes; track code) { + {{ code }} + } + + +
+ +
+
+ + @if (creditToRequired) { +
+
+ +

Maturity Payout Routing

+
+

Transfer To Saving — say where the maturity proceeds should land.

+
+
+ + + + @for (office of offices; track office.id) { + {{ office.name }} + } + + @if (officesError) { + {{ officesError }} + } + +
+ + +
+ + + + @for (account of creditToAccounts; track account.id) { + + {{ account.accountNo }} · {{ account.productName }} + + } + + @if (creditToAccounts.length === 0) { + + {{ loadedClientName || 'This customer' }} has no other savings account to credit + + } + +
+ + +
+ + + + +
+
+
+ } +
+
+ + +
+
+
+
+ + + + Yes + No + + +
+ + @if (nomineesRequired) { + + Percentage + Sequence + + + + + @if (splitByPercentage && nominees.length > 0) { + + } @else if (splitBySequence && nominees.length > 0) { + + } + } +
+ + @if (!nomineesRequired) { +

Set Nominee to Yes to nominate someone for this account.

+ } + + @for (nominee of nominees.controls; track nominee; let i = $index) { +
+
+ + + + + + +
+
+ + + + +
+
+ + + + +
+
+ + + + + + +
+
+ + + + + From date of birth + +
+
+ + + + +
+
+ + + + Male + Female + Other + + +
+
+ + + + @if (isNomineeMinor(nominee)) { + Required for a minor + } + +
+ @if (splitByPercentage) { +
+ + + + +
+ } @else { +
+ + + + +
+ } + +
+ } +
+
+
+
+ + @if (isClosed) { +
+ + This account is closed. No further collections or edits are possible. +
+ } + + + @if (minorTurnedMajor) { +
+ + + This account was opened for a minor who came of age on + {{ majorOn | dateFormat }}. The guardian can no longer operate it — take fresh KYC, a signature and a new mandate from the account + holder. + +
+ } + +
+ + + + @if (partyId && !isClosed && canCloseEarly) { + + } + @if (partyId && !isClosed) { + + } + +
+
+
+
diff --git a/src/app/deposits/party-master/create-party/create-party.component.scss b/src/app/deposits/party-master/create-party/create-party.component.scss new file mode 100644 index 000000000..fd737f278 --- /dev/null +++ b/src/app/deposits/party-master/create-party/create-party.component.scss @@ -0,0 +1,528 @@ +/** + * 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/. + */ + +$primary: #0f766e; + +.pm-container { + // Wider than the usual form page: the extra width is what keeps this many fields off a + // second screen. + max-width: 1280px; + margin: 0 auto; + padding: 12px 16px 20px; +} + +.pm-header { + display: flex; + align-items: center; + gap: 14px; + margin-bottom: 10px; + + &__title { + margin: 0; + font-size: 18px; + font-weight: 500; + } +} + +.pm-back { + color: $primary; +} + +.pm-card { + background: var(--md-sys-color-surface, #fff); + border-radius: 8px; + box-shadow: 0 2px 8px rgb(0 0 0 / 8%); +} + +.pm-tabs { + ::ng-deep .mat-mdc-tab-body-wrapper { + min-height: 300px; + } +} + +.pm-tab-content { + padding: 20px 24px; +} + +.pm-section { + padding: 16px 0; + border-bottom: 1px solid rgb(0 0 0 / 8%); + + &:last-of-type { + border-bottom: none; + } + + &__header { + display: flex; + align-items: center; + // The subtitle sits beside the title where there is room and drops below it where there is not. + flex-wrap: wrap; + gap: 2px 10px; + margin-bottom: 16px; + + i { + color: $primary; + } + + h3 { + margin: 0; + font-size: 16px; + font-weight: 600; + // Held at its natural width: letting it shrink breaks a two-word heading onto three lines. + flex: 0 0 auto; + white-space: nowrap; + } + } +} + +.pm-grid { + display: grid; + grid-template-columns: repeat(4, 1fr); + gap: 2px 18px; + align-items: start; +} + +/* + * Label to the left of its field, the way the screen this replaces laid them out. A stacked label + * costs a whole row of height per field, and with 60-odd fields that is the difference between an + * operator seeing the form at once and scrolling through three screens of it. + */ +.pm-f { + display: grid; + grid-template-columns: 104px minmax(0, 1fr); + align-items: start; + column-gap: 7px; + + &--wide { + grid-column: span 2; + } +} + +.pm-f-lbl { + font-size: 11.5px; + line-height: 1.25; + text-align: right; + color: rgb(0 0 0 / 68%); + padding-top: 7px; + + // Required marker, in the same red the legacy screen used. + b { + color: #c1121f; + font-weight: 400; + margin-left: 2px; + } +} + +.pm-field { + width: 100%; +} + +.pm-checkbox-field { + display: flex; + align-items: center; + height: 56px; +} + +.pm-radio-group { + display: flex; + align-items: center; + gap: 16px; + height: 56px; +} + +.pm-add-nominee { + color: $primary; + height: fit-content; + align-self: center; +} + +/* + * The running total of the shares entered, beside the button that adds another. Reads as ordinary + * text while the split still adds up and turns to the warning colour when it does not, so the clerk + * sees the state of the whole nomination without having to try to save it. + */ +.pm-share-total { + align-self: center; + margin-left: 12px; + font-size: 13px; + color: rgb(0 0 0 / 60%); +} + +.pm-share-total-bad { + color: #b3261e; + font-weight: 500; +} + +.pm-empty { + color: rgb(0 0 0 / 50%); + font-size: 14px; + margin: 0 0 8px; +} + +/* + * One nominee, laid out as fields that wrap rather than columns that must all fit. + * + * A fixed column per field only works while the count is fixed, and it is not - the Share % field + * appears and disappears with the split mode, and a tenth column pushed the remove button onto a + * line of its own. Each field instead states the width it needs to stay readable and the row breaks + * where it runs out of space, so nothing is squeezed and nothing is orphaned. + */ +.pm-nominee-row { + display: flex; + flex-wrap: wrap; + gap: 4px 12px; + align-items: start; + padding: 12px 0; + border-top: 1px dashed rgb(0 0 0 / 10%); + + &:first-of-type { + border-top: none; + } + + /* Names and addresses need room; a date, Yes/No or a percentage does not. */ + .pm-f { + flex: 1 1 170px; + min-width: 0; + } + + .pm-f--date { + flex: 0 1 140px; + } + + .pm-f--tight { + flex: 0 1 100px; + } +} + +.pm-remove-nominee { + color: #b91c1c; + margin-top: 8px; +} + +.pm-action-bar { + display: flex; + justify-content: flex-end; + gap: 12px; + padding: 16px 24px; + border-top: 1px solid rgb(0 0 0 / 8%); +} + +.pm-callout { + display: flex; + align-items: center; + gap: 10px; + margin: 0 24px; + padding: 12px 16px; + background: rgb(185 28 28 / 8%); + border: 1px solid rgb(185 28 28 / 25%); + border-radius: 6px; + color: #b91c1c; + font-size: 0.9rem; +} + +@media (width <= 1000px) { + .pm-grid { + grid-template-columns: repeat(2, 1fr); + } +} + +/* Resolved name shown under a Client / Agent / Guardian id, as the legacy screen did in red. */ +.pm-hint--found { + color: #15803d; + font-weight: 500; +} + +.pm-hint--missing { + color: #b91c1c; + font-weight: 500; +} + +/* Explains why a block of fields is absent, so the screen does not just look unfinished. */ +.pm-note { + margin: 4px 0 0; + font-size: 0.8rem; + color: rgb(0 0 0 / 55%); + font-style: italic; +} + +.pm-closed-badge { + margin-left: 12px; + padding: 3px 11px; + border-radius: 11px; + font-size: 0.72rem; + font-weight: 500; + background: rgb(0 0 0 / 7%); + color: rgb(0 0 0 / 60%); +} + +/* Says whose details a block of fields is for, so the screen explains itself. */ +.pm-section__sub { + flex: 1 1 auto; + min-width: 0; + font-size: 0.78rem; + font-weight: 400; + line-height: 1.45; + color: rgb(0 0 0 / 55%); +} + +.pm-search-ic { + color: rgb(0 0 0 / 38%); + font-size: 0.85rem; +} + +/* + * Money reads right to left, so a column of figures only lines up when it is right-aligned; the + * tabular figures keep every digit the same width so the decimal points stack. + */ +input.pm-money { + text-align: right; + font-variant-numeric: tabular-nums; +} + +/* One row of a Client / Sub Type search list: the id, the name, and where it belongs. */ +.pm-opt { + display: flex; + align-items: baseline; + gap: 10px; + overflow: hidden; +} + +/* The "there are more" footer row. Quieter than a real option, since it is not one. */ +.pm-opt-more { + font-size: 0.75rem; + font-style: italic; + opacity: 0.75; +} + +.pm-opt-id { + flex: 0 0 auto; + min-width: 52px; + font-family: ui-monospace, 'SF Mono', 'Cascadia Mono', monospace; + font-size: 0.78rem; + color: var(--pl-accent, #b45309); +} + +/* + The name is the reason the row exists, so it gets whatever width is going and is the last thing + to be cut. Before this the branch was fixed-width and the name carried the ellipsis, so in the + narrow panel every customer read as a single letter. +*/ + +.pm-opt-main { + flex: 1 1 auto; + min-width: 8rem; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.pm-opt-sub { + flex: 0 1 auto; + min-width: 0; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + font-size: 0.75rem; + color: rgb(0 0 0 / 50%); +} + +/* stylelint-disable no-duplicate-selectors -- the dense layout below deliberately re-targets the base selectors above to override them. */ + +/* + * Dense data-entry layout. + * + * The screen this replaces fitted the whole form on one page - an officer opening accounts all day + * reads it at a glance and never scrolls. Material's default field height and spacing turn the same + * 56 fields into three screens' worth, so the whole card is tightened: shorter fields, smaller type, + * and just enough room under each field for the hint that names the client or agent it resolved. + */ + +/* + * With the label moved outside, the box only has to hold the value - so it loses the height Material + * reserves for a floating label, and the form fits a screen instead of three. + */ +.pm-card { + ::ng-deep { + .mat-mdc-form-field { + font-size: 12.5px; + } + + .mat-mdc-form-field-infix { + min-height: 26px; + padding-top: 3px !important; + padding-bottom: 3px !important; + border-top: 0; + } + + .mat-mdc-text-field-wrapper { + padding-left: 8px; + padding-right: 8px; + } + + .mat-mdc-input-element, + .mat-mdc-select-value-text { + font-size: 12.5px; + } + + // Room for one line of hint - the resolved client/agent name that confirms the right record. + .mat-mdc-form-field-subscript-wrapper { + height: 14px; + min-height: 14px; + } + + .mat-mdc-form-field-hint { + font-size: 10.5px; + line-height: 14px; + } + + // Suffix icons and the datepicker toggle default to a 48px touch target, which reopens all the + // height just saved. + .mat-mdc-icon-button.mat-mdc-button-base { + width: 26px; + height: 26px; + padding: 3px; + } + + .mat-mdc-form-field-icon-suffix { + padding-left: 2px; + + .mat-datepicker-toggle svg { + width: 15px; + height: 15px; + } + } + + .mat-mdc-tab-body-wrapper { + min-height: 0; + } + } +} + +.pm-tab-content { + padding: 8px 16px; +} + +/* + * Blocks drawn the way the screen this replaces drew them: a hairline between groups of fields, + * with the group's name as a quiet caption above it rather than a heading bar of its own. An + * operator who has filled this form a hundred times navigates by field position; one filling their + * first still gets told which block is which. + */ +.pm-section { + padding: 8px 0 2px; + border-bottom: 1px solid rgb(0 0 0 / 11%); + + &__header { + margin-bottom: 6px; + gap: 0 10px; + + // Decoration the legacy screen did without, and the row is tighter for losing it. + i { + display: none; + } + + h3 { + font-size: 11px; + font-weight: 700; + letter-spacing: 0.07em; + text-transform: uppercase; + color: $primary; + } + } +} + +.pm-section__sub { + font-size: 0.72rem; + color: rgb(0 0 0 / 45%); +} + +.pm-grid { + gap: 0 12px; +} + +.pm-checkbox-field, +.pm-radio-group { + height: 28px; + + ::ng-deep label { + font-size: 12.5px; + } +} + +// The nominee grid is its own row of fields, so its labels sit above rather than beside - there is +// no room for a label column eight fields across. +.pm-nominee-row { + gap: 0 10px; + padding: 3px 0; + + .pm-f { + grid-template-columns: minmax(0, 1fr); + row-gap: 1px; + } + + .pm-f-lbl { + text-align: left; + padding-top: 0; + font-size: 10.5px; + } +} + +.pm-note { + margin: 0; + font-size: 0.74rem; +} + +.pm-action-bar { + padding: 10px 18px; +} + +/* + The customer's real deposit account. Not a form field - nothing here is typed, it is what the + bank's books say - so it reads as a fact rather than sitting in an input box pretending to be one. +*/ +.pm-account { + display: flex; + flex-direction: column; + gap: 2px; + padding: 3px 0; + min-height: 26px; + justify-content: center; +} + +.pm-account-no { + font-family: ui-monospace, 'SF Mono', 'Cascadia Mono', monospace; + font-size: 0.85rem; + letter-spacing: 0.02em; +} + +.pm-account-meta { + font-size: 0.75rem; + color: rgb(0 0 0 / 55%); +} + +.pm-account-live { + color: #15803d; + font-weight: 500; +} + +/* Said plainly: this party has no account behind it, which is a real gap, not a blank field. */ +.pm-account-missing { + font-size: 0.75rem; + font-style: italic; + color: #9b2c2c; +} + +/* + Something the branch has to do, rather than something that has gone wrong - so it reads as an + instruction in the warning colour, not an error in red like the closed-account notice. +*/ +.pm-callout--action { + background: rgb(180 83 9 / 8%); + border-color: rgb(180 83 9 / 25%); + color: #b45309; +} diff --git a/src/app/deposits/party-master/create-party/create-party.component.ts b/src/app/deposits/party-master/create-party/create-party.component.ts new file mode 100644 index 000000000..81ac3fc07 --- /dev/null +++ b/src/app/deposits/party-master/create-party/create-party.component.ts @@ -0,0 +1,1944 @@ +/** + * Copyright since 2025 Mifos Initiative + * + * This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. + */ + +/** Angular Imports */ +import { Component, OnInit, inject } from '@angular/core'; +import { + UntypedFormBuilder, + UntypedFormGroup, + UntypedFormArray, + Validators, + ValidatorFn, + ValidationErrors, + AbstractControl +} from '@angular/forms'; +import { ActivatedRoute, Router } from '@angular/router'; +import { MatTabsModule } from '@angular/material/tabs'; +import { MatCheckbox } from '@angular/material/checkbox'; +import { MatRadioModule } from '@angular/material/radio'; +import { MatAutocompleteModule } from '@angular/material/autocomplete'; +import { MatDialog } from '@angular/material/dialog'; +import { debounceTime, distinctUntilChanged } from 'rxjs/operators'; +import { STANDALONE_SHARED_IMPORTS } from 'app/standalone-shared.module'; + +/** Custom Services */ +import { OrganizationService } from 'app/organization/organization.service'; +import { ProductsService } from 'app/products/products.service'; +import { ClientsService } from 'app/clients/clients.service'; +import { SettingsService } from 'app/settings/settings.service'; +import { Dates } from 'app/core/utils/dates'; +import { AlertService } from 'app/core/alert/alert.service'; +import { searchClientsAnyCase } from '../client-search'; + +/** Custom Components */ +import { PrematureCloseDialogComponent } from '../premature-close-dialog/premature-close-dialog.component'; + +/** Custom Directives */ +import { RestrictInputDirective } from 'app/directives/restrict-input.directive'; + +/** + * Party Master Entry form. + * + * The customer's DDS account - opened under a scheme (Sub Type, chosen from the DDS Account Type + * master) and assigned to the agent who will collect for it daily. Carries its own KYC, scheme + * terms, two-stage overdue flags and a nominee list. Modelled on the legacy Party Master Entry + * screen, field for field. + * + * The legacy screen filled the identity block from the Client ID and showed the agent's name beside + * the agent field; both are reproduced here, and the same lookup fills the guardian's name for a + * minor's account. That field read "Sub Agent" in the legacy system, which had a tier this one does + * not - SubAgent was folded into Agent, so it is named for what it actually points at. Fields the legacy screen greyed out are either client-derived, calculated, or + * written by collections and the batch job - none of them are typed here. + * + * "Close Account" (command=close) permanently closes the Party once its balance is zero - the + * backend rejects the command otherwise. A closed Party can no longer be edited or receive new + * Party Tran collections. + * + * savingsAccountNo optionally links this Party to an existing, non-closed real Savings Account of + * the same client (resolved by account number, since that's what an officer has to hand) - + * clarified with the business (2026-08-24) as "link it if the client already has one, otherwise + * leave it". There is no unlink once linked. + */ +@Component({ + selector: 'mifosx-create-party', + templateUrl: './create-party.component.html', + styleUrls: ['./create-party.component.scss'], + imports: [ + ...STANDALONE_SHARED_IMPORTS, + MatTabsModule, + MatCheckbox, + MatRadioModule, + MatAutocompleteModule, + // Holds the identifier fields to the shape their kind takes, at the keystroke. Imported here + // because a standalone component silently ignores a directive it has not imported - the + // attribute stays on the element and simply does nothing. + RestrictInputDirective + ] +}) +export class CreatePartyComponent implements OnInit { + private formBuilder = inject(UntypedFormBuilder); + private router = inject(Router); + private route = inject(ActivatedRoute); + private organizationService = inject(OrganizationService); + private productsService = inject(ProductsService); + private alertService = inject(AlertService); + private clientsService = inject(ClientsService); + private settingsService = inject(SettingsService); + private dateUtils = inject(Dates); + private dialog = inject(MatDialog); + + /* + * What each identifier has to look like - the same shapes the server holds them to. + * + * Checked here as well so the clerk is told while the customer is still at the counter, rather + * than after a save that comes back rejected. The server checks them again regardless: a screen + * is a convenience, not a guarantee. + */ + private static readonly MOBILE_RE = /^[6-9][0-9]{9}$/; + private static readonly TELEPHONE_RE = /^[0-9+()\-\s]{6,20}$/; + private static readonly PINCODE_RE = /^[1-9][0-9]{5}$/; + /** Twelve digits, or the masked form CKYC hands back. */ + private static readonly AADHAAR_RE = /^([0-9]{12}|[Xx]{8}[0-9]{4})$/; + private static readonly PAN_RE = /^[A-Z]{5}[0-9]{4}[A-Z]$/; + private static readonly CKYC_RE = /^[0-9]{14}$/; + + partyId: number | null = null; + + /** + * The earliest opening date the picker will offer on a new account - today, so the past is greyed + * out. The server refuses a past date on creation too; this only stops the clerk reaching for one. + * + * Null while editing. An account already on the books opened in the past by definition, so a floor + * of today would put every existing party into a state its own form would not accept. + */ + get minOpeningDate(): Date | null { + return this.partyId === null ? new Date() : null; + } + + selectedTabIndex = 0; + partyStatus = 'ACTIVE'; + linkedSavingsAccountId: number | null = null; + + /** + * The customer's real deposit account, as the bank's own books hold it. Read from the account + * itself rather than copied onto the party, so what shows here cannot drift from what is true. + */ + savingsAccountNo: string | null = null; + savingsProductName: string | null = null; + savingsAccountStatus: string | null = null; + savingsAccountBalance: number | null = null; + + /** The DDS account's own number, scheme and balance - the party is the account. */ + accountNo: string | null = null; + accountSchemeName: string | null = null; + accountBalance: number | null = null; + + /** + * A minor account holder who has come of age. The guardian's authority ended on that day, so the + * branch has to take fresh KYC and a new mandate - shown as a call to action rather than a field. + */ + majorOn: string | null = null; + minorTurnedMajor = false; + + /** Shown beside the id fields once the record behind them has been resolved. */ + loadedClientName = ''; + clientLookupFailed = false; + loadedAgentName = ''; + agentLookupFailed = false; + loadedGuardianName = ''; + + /** Clients matching what has been typed - an officer knows the customer's name, rarely their id. */ + clientOptions: any[] = []; + + /** + * The first page of clients, shown the moment the field is focused so the officer can pick from a + * list instead of having to know something to type. Fetched once and kept. + * + * Capped rather than "all": it is a page of names on a branch with 28 customers today and a bank + * with far more later, and the field has to stay usable in both. Typing still searches every + * client, not just these - see searchClients. + */ + private initialClients: any[] = []; + private static readonly CLIENT_LIST_SIZE = 50; + + /** How many clients exist in total, to say so when the list below is only part of them. */ + clientTotal = 0; + + /** True while the options being shown are the opening list rather than search results. */ + showingClientList = false; + + get clientListIsPartial(): boolean { + return this.showingClientList && this.clientTotal > this.clientOptions.length; + } + guardianOptions: any[] = []; + /** The account types matching what has been typed into Sub Type. */ + subTypeOptions: any[] = []; + + /** + * The agents on offer. Every agent is fetched once and filtered here rather than asked of the + * server per keystroke - there is a handful of them, not a branch's worth of customers. + */ + agentOptions: any[] = []; + private allAgents: any[] = []; + + get isClosed(): boolean { + return this.partyStatus === 'CLOSED'; + } + + /** The DDS Account Type master - what the legacy screen's Sub Type dropdown was fed from. */ + subTypes: any[] = []; + + operations = [ + 'Self', + 'Guardian', + 'Joint' + ]; + occupations = [ + 'Business', + 'Service', + 'Agriculture', + 'Homemaker', + 'Student', + 'Retired', + 'Other' + ]; + riskLevels = [ + 'Low', + 'Medium', + 'High' + ]; + adultMinorOptions = [ + 'Adult', + 'Minor' + ]; + withdrawTypes = [ + 'Cash', + 'Transfer', + 'Clearing' + ]; + freezeCodes = [ + 'Normal', + 'Debit Freeze', + 'Credit Freeze', + 'Total Freeze' + ]; + advCodes = [ + 'None', + 'Transfer To Saving', + 'Renew' + ]; + + /** The bank's own branches - where maturity proceeds can be credited. */ + /** + * Why a deposit gets held, offered as a list rather than typed. + * + * Free text meant the same reason arrived four ways - "Loan 1234", "loan no 1234", "LOAN#1234" - + * and anything counting them read four different reasons. Picked from a list they group properly, + * and the clerk types only where the list genuinely does not cover it. + */ + lienReasons = [ + 'Loan security', + 'Court order', + 'Tax attachment', + 'Deceased — claim pending', + 'Under investigation', + 'Other' + ]; + + offices: any[] = []; + officesError = ''; + + /** + * The customer's own savings accounts, for Credit To A/C. + * + * Picked rather than typed. The whole matured amount lands here years after somebody fills this + * in, and the live data showed what free text costs: one party carried "1234567890111", which is + * no account at all. Only savings accounts are offered - a fixed or recurring deposit cannot take + * the credit - and only this customer's own, which is what a maturity instruction means in any + * core system. The backend refuses anything else, whatever the screen sends. + */ + creditToAccounts: any[] = []; + + /** The Adv Code that makes the account say where its maturity proceeds should land. */ + private static readonly TRANSFER_TO_SAVING = 'Transfer To Saving'; + + /** Filled from the Client ID, as on the legacy screen. */ + private static readonly CLIENT_DERIVED_FIELDS = [ + 'partyName', + 'partyName1', + 'address1', + 'address2', + 'address3', + 'city', + 'pincode', + 'gender', + 'birthDate', + 'telephone', + 'mobileNoSms', + 'occupation', + 'risk', + 'aadharNo', + 'panCardNo', + 'ckycId', + 'relation', + 'minorDate', + 'guardianClientId' + ]; + + /** Fields holding a value from the client record, shown read-only - see fillFromClientRecord. */ + private filledFromClient = new Set(); + + private static readonly MINOR_FIELDS = [ + 'minorDate', + 'guardianClientId', + 'minAccountOpt', + 'relation' + ]; + + private static readonly CREDIT_TO_FIELDS = [ + 'creditToBranch', + 'creditToAccountType', + 'creditToAccount' + ]; + + partyForm: UntypedFormGroup = this.formBuilder.group({ + clientId: [ + '', + Validators.required + ], + subType: [ + '', + Validators.required + ], + agentId: [ + '', + Validators.required + ], + // The branch this account belongs to. Its own, not the collecting agent's - an agent of one + // branch may hold accounts for customers of another, so it is asked for rather than inherited. + officeId: [ + '', + Validators.required + ], + savingsAccountNo: [''], + lastAccount: [ + { + value: '', + disabled: true + } + ], + partyCode: [ + { + value: '', + disabled: true + } + ], + nominal: [ + 'No', + Validators.required + ], + aadharNo: [ + '', + Validators.pattern(CreatePartyComponent.AADHAAR_RE) + ], + partyName: [ + '', + Validators.required + ], + partyName1: [''], + address1: [ + '', + Validators.required + ], + address2: [''], + address3: [''], + city: [ + '', + Validators.required + ], + pincode: [ + '', + [ + Validators.required, + Validators.pattern(CreatePartyComponent.PINCODE_RE) + ] + ], + gender: [''], + panCardNo: [ + '', + Validators.pattern(CreatePartyComponent.PAN_RE) + ], + telephone: [ + '', + Validators.pattern(CreatePartyComponent.TELEPHONE_RE) + ], + mobileNoSms: [ + '', + [ + Validators.required, + Validators.pattern(CreatePartyComponent.MOBILE_RE) + ] + ], + operation: [ + 'Self', + Validators.required + ], + birthDate: [''], + occupation: [''], + risk: [''], + ckycId: [ + '', + Validators.pattern(CreatePartyComponent.CKYC_RE) + ], + adultMinor: [ + 'Adult', + Validators.required + ], + // Enabled only for a minor's account - see applyAdultMinorRules(). + minorDate: [ + { + value: '', + disabled: true + } + ], + guardianClientId: [ + { + value: '', + disabled: true + } + ], + minAccountOpt: [ + { + value: '', + disabled: true + } + ], + relation: [ + { + value: '', + disabled: true + } + ], + openingDate: [ + '', + Validators.required + ], + month: [''], + // Worked out from the opening date and the tenure - never typed. + maturityDate: [ + { + value: '', + disabled: true + } + ], + reopenDate: [''], + depositAmount: [0], + dailyDepositAmount: [ + 0, + Validators.required + ], + days: [ + '', + Validators.required + ], + ledgerNo: [''], + maturityAmount: [ + { + value: 0, + disabled: true + } + ], + interestRate: [0], + withdrawType: [''], + // The overdue flags, balances and interest figures below are written by collections and the + // DDS batch job. They are shown so an officer can see where the account stands, never typed. + od1Flag: [ + { + value: false, + disabled: true + } + ], + od2Flag: [ + { + value: false, + disabled: true + } + ], + lien: ['No'], + // Asked for only while the lien is set - the rule is applied from the flag rather + // than stated here, so switching it off does not leave a required field behind. + lienReason: [''], + // What the clerk types when the list does not cover it. Kept apart from lienReason so the list + // stays a list - the one value the server stores is worked out at save. + lienReasonOther: [''], + payDate: [ + { + value: '', + disabled: true + } + ], + paymentAmount: [ + { + value: 0, + disabled: true + } + ], + currentBalance: [ + { + value: 0, + disabled: true + } + ], + openingBalance: [ + { + value: 0, + disabled: true + } + ], + isSmsActive: [true], + isEmailActive: [false], + interestCalc: [ + { + value: 0, + disabled: true + } + ], + interestDate: [ + { + value: '', + disabled: true + } + ], + spDescription: [''], + freezeCode: ['Normal'], + advCode: ['None'], + // Mandatory only when Adv Code is Transfer To Saving - see applyAdvCodeRules(). + creditToBranch: [''], + creditToAccountType: [''], + creditToAccount: [''], + nomineeApplicable: ['No'], + nomineeSplitMode: ['Percentage'], + nominees: this.formBuilder.array([]) + }); + + ngOnInit(): void { + this.productsService.getDdsAccountTypes().subscribe((accountTypes: any[]) => { + // A retired scheme takes no new accounts, but one already on it must still show when edited. + this.subTypes = (accountTypes || []).filter((accountType) => accountType.active); + this.subTypeOptions = this.subTypes; + }); + + // Branches are picked, never typed: a free-text branch cannot be matched back to a real office + // when the proceeds are actually moved. If the list cannot be fetched the reason is shown on the + // field - an empty dropdown with no explanation looks like the bank has no branches. + this.organizationService.getOffices().subscribe({ + next: (offices: any) => { + this.offices = Array.isArray(offices) ? offices : offices?.pageItems || []; + this.officesError = this.offices.length === 0 ? 'No branches on file' : ''; + }, + error: (error: any) => { + this.officesError = + error?.error?.defaultUserMessage || + error?.error?.developerMessage || + `Could not load branches (${error?.status || 'no response'})`; + } + }); + + const routeId = this.route.snapshot.paramMap.get('partyId'); + if (routeId) { + this.organizationService.getParty(routeId).subscribe((party: any) => this.patchFormFromParty(party)); + } + + this.partyForm.get('days')?.addValidators(this.withinTerm()); + + this.wireClientLookup(); + this.wireSubTypeSearch(); + this.wireAgentLookup(); + this.wireGuardianLookup(); + this.wireConditionalRules(); + this.wireMaturityCalculation(); + } + + // ---------------------------------------------------------------- lookups + + /** + * Fills the identity block from the customer, the way the legacy screen did - except that the + * customer can be found by name as well as by id, since an officer at the counter is told a name. + * The control holds the picked client object, or whatever has been typed so far. + */ + private wireClientLookup(): void { + this.partyForm + .get('clientId') + ?.valueChanges.pipe(debounceTime(400), distinctUntilChanged()) + .subscribe((value) => { + // Always replace: changing the customer must not leave the previous one's details behind. + for (const controlName of CreatePartyComponent.CLIENT_DERIVED_FIELDS) { + this.partyForm.get(controlName)?.setValue(''); + } + this.filledFromClient.clear(); + this.partyForm.get('adultMinor')?.enable({ emitEvent: false }); + this.loadedClientName = ''; + this.clientLookupFailed = false; + + // Picked from the list. + if (value && typeof value === 'object') { + this.clientOptions = []; + this.fillFromClient(value.id, value.displayName); + return; + } + + const typed = String(value ?? '').trim(); + if (!typed) { + // Emptying the field puts the opening list back, rather than leaving nothing to pick from. + this.showClientList(); + return; + } + + // A bare number is an id typed straight in; anything else is a name to search for. A number + // can be both, so both are tried and whichever matches wins. + if (/^\d+$/.test(typed)) { + this.fillFromClient(typed, ''); + } + this.searchClients(typed); + }); + } + + private searchClients(term: string): void { + searchClientsAnyCase(this.clientsService, term).subscribe((found: any[]) => { + this.showingClientList = false; + this.clientOptions = found.slice(0, 25); + }); + } + + /** + * Opens the customer list when the field is focused. + * + * Without this the officer has to know something before anything appears, which is backwards: the + * point of the field is to find a customer, and a blank box is no help in doing that. A customer + * already picked is left alone - reopening the list over a chosen name only invites a misclick. + */ + onClientFocus(): void { + if (this.partyForm.get('clientId')?.value) { + return; + } + this.showClientList(); + } + + /** Shows the opening list, fetching it the first time it is asked for. */ + private showClientList(): void { + if (this.initialClients.length > 0) { + this.showingClientList = true; + this.clientOptions = this.initialClients; + return; + } + + this.clientsService.getClients('displayName', 'ASC', 0, CreatePartyComponent.CLIENT_LIST_SIZE).subscribe({ + next: (response: any) => { + const found = response?.pageItems || response || []; + this.initialClients = Array.isArray(found) ? found : []; + this.clientTotal = response?.totalFilteredRecords ?? this.initialClients.length; + this.showingClientList = true; + this.clientOptions = this.initialClients; + }, + // A failed list is not a failed screen - the officer can still type a name or an id. + error: () => (this.clientOptions = []) + }); + } + + private fillFromClient(clientId: string | number, knownName: string): void { + this.clientsService.getClientData(String(clientId), true).subscribe({ + next: (client: any) => { + this.loadedClientName = client?.displayName || knownName || ''; + this.clientLookupFailed = false; + this.fillFromClientRecord('partyName', client?.displayName); + this.fillFromClientRecord('mobileNoSms', client?.mobileNo); + this.fillFromClientRecord('telephone', client?.alternateMobileNo); + // Alerts go where the client can actually be reached: SMS with a mobile on record, email with + // an email address on record. + this.partyForm.get('isSmsActive')?.setValue(!!client?.mobileNo); + this.partyForm.get('isEmailActive')?.setValue(!!client?.emailAddress); + // The client record says FEMALE; this form's list says Female. + this.fillFromClientRecord('gender', titleCase(client?.gender?.name || client?.gender)); + const dateOfBirth = this.toDate(client?.dateOfBirth); + if (dateOfBirth) { + this.partyForm.get('birthDate')?.setValue(dateOfBirth); + this.filledFromClient.add('birthDate'); + } + // The client's own occupation, offered even when this form's short list does not carry it. + const occupation = client?.occupation?.name || client?.occupationCategory?.name; + if (occupation) { + if (!this.occupations.includes(occupation)) { + this.occupations = [ + ...this.occupations, + occupation + ]; + } + this.fillFromClientRecord('occupation', occupation); + } + // A minor on the client record is a minor account here, with the same guardian. + this.partyForm.get('adultMinor')?.setValue(client?.isMinor ? 'Minor' : 'Adult'); + // Adult or minor is the client record's, checked when the client was opened - not changed here. + this.partyForm.get('adultMinor')?.disable({ emitEvent: false }); + this.filledFromClient.add('adultMinor'); + if (client?.isMinor) { + if (dateOfBirth) { + // The day they come of age - eighteen is majority in India. + const majority = new Date(dateOfBirth); + majority.setFullYear(majority.getFullYear() + 18); + this.partyForm.get('minorDate')?.setValue(majority); + this.filledFromClient.add('minorDate'); + } + if (client?.guardianClientId) { + this.partyForm + .get('guardianClientId') + ?.setValue({ id: client.guardianClientId, displayName: client.guardianClientDisplayName || '' }); + this.filledFromClient.add('guardianClientId'); + } + this.fillFromClientRecord('relation', titleCase(client?.guardianRelation)); + } + this.loadClientRisk(String(clientId)); + // Nominees are not brought across: the officer records them for this account. + // Proceeds usually go back to an account at the customer's own branch, so that is offered + // first - any branch can still be picked, since the account may well be at another. + const creditToBranch = this.partyForm.get('creditToBranch'); + if (client?.officeName && !creditToBranch?.value) { + creditToBranch?.setValue(client.officeName, { emitEvent: false }); + } + + this.loadClientAddress(String(clientId)); + this.loadClientIdentifiers(String(clientId)); + this.loadCreditToAccounts(String(clientId)); + }, + error: () => { + this.clientLookupFailed = true; + } + }); + } + + /** + * The customer's own accounts a maturity may be credited to. + * + * Worked out by the server, which applies the same conditions the payout enforces years later: an + * active savings account of theirs, not a fixed or recurring deposit, and not a DDS scheme account + * - those are collected on daily and pay out at their own maturity, so naming one here would send + * this maturity into another collection account instead of back to the customer. + * + * Deciding it here instead let the list and the refusal drift apart, and the accounts on offer + * came to include a scheme account of a party that had already closed. + */ + private loadCreditToAccounts(clientId: string): void { + this.organizationService.getCreditableAccounts(clientId).subscribe({ + next: (accounts: any) => (this.creditToAccounts = accounts || []), + error: () => (this.creditToAccounts = []) + }); + } + + /** + * Fills the account type from the account chosen. An account already is of a type, and asking for + * both lets the two disagree - the account is the half that is actually true. + */ + onCreditToAccountPicked(accountNo: string): void { + const account = this.creditToAccounts.find((each: any) => each.accountNo === accountNo); + if (account?.productName) { + this.partyForm.get('creditToAccountType')?.setValue(account.productName); + } + } + + /** + * Aadhar, PAN and CKYC are not plain client fields - they are KYC identifiers filed against the + * client, each tagged with a document type. They are matched by that type's name so the screen + * shows the same numbers the client record already holds, rather than asking for them twice. + */ + private loadClientIdentifiers(clientId: string): void { + this.clientsService.getClientIdentifiers(clientId).subscribe({ + next: (identifiers: any) => { + for (const identifier of Array.isArray(identifiers) ? identifiers : []) { + const words = this.wordsOf(identifier?.documentType?.name); + const documentKey = identifier?.documentKey; + if (!documentKey) { + continue; + } + if (words.includes('aadhaar') || words.includes('aadhar')) { + this.fillFromClientRecord('aadharNo', documentKey); + } else if (words.includes('pan')) { + this.fillFromClientRecord('panCardNo', documentKey); + } else if (words.includes('ckyc')) { + this.fillFromClientRecord('ckycId', documentKey); + } + } + }, + // A client with no identifiers on file is normal - the officer just types them. + error: () => undefined + }); + } + + /** What the Client ID box shows for a picked client, rather than "[object Object]". */ + displayClient = (value: any): string => { + if (!value) { + return ''; + } + return typeof value === 'object' ? `${value.id} — ${value.displayName}` : String(value); + }; + + /** + * Takes a value from the client record, which was checked when the client was opened, and locks + * the field so it is not typed - and checked - a second time. A value the client does not have is + * left for the officer to enter. + */ + private fillFromClientRecord(controlName: string, value: any): void { + if (value === null || value === undefined || String(value).trim() === '') { + return; + } + this.setField(controlName, value); + this.filledFromClient.add(controlName); + } + + /** Whether a field holds a value taken from the client record, and so is shown read-only. */ + isFromClient(controlName: string): boolean { + return this.filledFromClient.has(controlName); + } + + /** The client's risk category, from their KYC record. */ + private loadClientRisk(clientId: string): void { + this.clientsService.getClientKyc(clientId, true).subscribe({ + next: (kyc: any) => { + // kycRiskCategory.medium -> Medium + const code = String(kyc?.riskCategoryCode || '') + .split('.') + .pop(); + this.fillFromClientRecord('risk', titleCase(code)); + }, + // A client without a KYC record just leaves the risk to be chosen. + error: () => undefined + }); + } + + private loadClientAddress(clientId: string): void { + this.clientsService.getClientAddressData(clientId, true).subscribe({ + next: (addresses: any) => { + const address = Array.isArray(addresses) ? addresses[0] : addresses; + if (!address) { + return; + } + this.fillFromClientRecord('address1', address.addressLine1 || address.street); + this.fillFromClientRecord('address2', address.addressLine2); + this.fillFromClientRecord('address3', address.addressLine3); + this.fillFromClientRecord('city', address.city); + this.fillFromClientRecord('pincode', address.postalCode); + }, + // A client with no address on file is normal - the officer just types it. + error: () => undefined + }); + } + + /** Shows the collecting agent's name beside the id, as the legacy screen did in red. */ + private wireAgentLookup(): void { + this.partyForm + .get('agentId') + ?.valueChanges.pipe(debounceTime(400), distinctUntilChanged()) + .subscribe((value) => { + this.loadedAgentName = ''; + this.agentLookupFailed = false; + + // Picked from the list - the agent is already in hand, nothing to look up. + if (value && typeof value === 'object') { + this.agentOptions = []; + this.loadedAgentName = value.agentNameId || value.clientName || ''; + this.agentLookupFailed = !value.active; + return; + } + + const typed = String(value ?? '').trim(); + if (!typed) { + this.showAgentList(); + return; + } + + this.filterAgents(typed); + + // A bare number is an id typed straight in, the way this field always worked. + if (!/^\d+$/.test(typed)) { + return; + } + this.organizationService.getAgent(typed).subscribe({ + next: (agent: any) => { + this.loadedAgentName = agent?.agentNameId || agent?.clientName || ''; + this.agentLookupFailed = !agent?.active; + }, + error: () => { + this.agentLookupFailed = true; + } + }); + }); + } + + /** + * Opens the agent list on focus, so the field can be answered by picking rather than by knowing + * an id. A chosen agent is left alone - reopening over it only invites a misclick. + */ + onAgentFocus(): void { + if (this.partyForm.get('agentId')?.value) { + return; + } + this.showAgentList(); + } + + private showAgentList(): void { + if (this.allAgents.length > 0) { + this.agentOptions = this.allAgents; + return; + } + this.organizationService.getAgents().subscribe({ + next: (agents: any[]) => { + this.allAgents = agents || []; + this.agentOptions = this.allAgents; + }, + // A failed list is not a failed screen - an id can still be typed. + error: () => (this.agentOptions = []) + }); + } + + /** Matches on code or name, since an officer may know either. */ + private filterAgents(term: string): void { + const needle = term.toLowerCase(); + const match = (agent: any) => + String(agent.agentCode || '') + .toLowerCase() + .includes(needle) || + String(agent.agentNameId || '') + .toLowerCase() + .includes(needle) || + String(agent.id) === term; + + if (this.allAgents.length > 0) { + this.agentOptions = this.allAgents.filter(match); + return; + } + this.organizationService.getAgents().subscribe({ + next: (agents: any[]) => { + this.allAgents = agents || []; + this.agentOptions = this.allAgents.filter(match); + }, + error: () => (this.agentOptions = []) + }); + } + + displayAgent = (value: any): string => { + if (!value) { + return ''; + } + return typeof value === 'object' ? `${value.agentCode} — ${value.agentNameId}` : String(value); + }; + + /** A minor's account names a guardian, found the same way the customer is. */ + private wireGuardianLookup(): void { + this.partyForm + .get('guardianClientId') + ?.valueChanges.pipe(debounceTime(400), distinctUntilChanged()) + .subscribe((value) => { + this.loadedGuardianName = ''; + + if (value && typeof value === 'object') { + this.guardianOptions = []; + this.loadedGuardianName = value.displayName || ''; + return; + } + + const typed = String(value ?? '').trim(); + if (!typed) { + this.guardianOptions = []; + return; + } + + if (/^\d+$/.test(typed)) { + this.clientsService.getClientData(typed, true).subscribe({ + next: (client: any) => (this.loadedGuardianName = client?.displayName || ''), + error: () => undefined + }); + } + this.clientsService.getFilteredClients('displayName', 'ASC', false, typed).subscribe({ + next: (response: any) => { + const found = response?.pageItems || response || []; + this.guardianOptions = Array.isArray(found) ? found.slice(0, 25) : []; + }, + error: () => (this.guardianOptions = []) + }); + }); + } + + /** + * Sub Type is picked from the DDS Account Type master. Once a bank runs a dozen schemes a plain + * dropdown is a scroll, so it filters on either the code or the scheme's name as you type. + */ + private wireSubTypeSearch(): void { + this.partyForm.get('subType')?.valueChanges.subscribe((value) => { + // A new account takes its product's rate, which the maturity preview is worked out on. The + // server sets the same rate when it saves; an account being edited already has it. + const scheme = this.subTypes.find((type) => type.accountTypeCode === value); + if (scheme && !this.partyId) { + this.partyForm.get('interestRate')?.setValue(Number(scheme.interestRate) || 0); + } + const term = String(value ?? '') + .toLowerCase() + .trim(); + this.subTypeOptions = !term + ? this.subTypes + : this.subTypes.filter( + (type) => + String(type.accountTypeCode || '') + .toLowerCase() + .includes(term) || + String(type.accountTypeDescription || '') + .toLowerCase() + .includes(term) || + String(type.accountTypeGroup || '') + .toLowerCase() + .includes(term) + ); + }); + } + + /** Opening the box should offer everything, not just what the current value happens to match. */ + showAllSubTypes(): void { + this.subTypeOptions = this.subTypes; + } + + displaySubType = (code: any): string => { + if (!code) { + return ''; + } + const found = this.subTypes.find((type) => type.accountTypeCode === code); + return found ? `${found.accountTypeDescription} (${found.accountTypeCode})` : String(code); + }; + + // ----------------------------------------------------------- conditionals + + private wireConditionalRules(): void { + this.partyForm.get('adultMinor')?.valueChanges.subscribe(() => this.applyAdultMinorRules()); + this.partyForm.get('advCode')?.valueChanges.subscribe(() => this.applyAdvCodeRules()); + this.partyForm.get('lien')?.valueChanges.subscribe(() => this.applyLienRules()); + this.partyForm.get('lienReason')?.valueChanges.subscribe(() => this.applyLienRules()); + this.partyForm.get('nomineeApplicable')?.valueChanges.subscribe(() => this.applyNomineeRules()); + // Switching the split re-rules every nominee already on the form, not just the ones added after + // - a clerk who names three nominees and then picks Percentage must still be asked for shares. + this.partyForm.get('nomineeSplitMode')?.valueChanges.subscribe((mode) => { + this.nominees.controls.forEach((group) => this.applySplitModeTo(group, mode)); + }); + this.applyAdultMinorRules(); + this.applyAdvCodeRules(); + } + + /** + * A minor's account has to say when the holder comes of age and who operates it until then; on an + * adult's account those four fields are meaningless, so they stay disabled and empty. + */ + private applyAdultMinorRules(): void { + const isMinor = this.partyForm.get('adultMinor')?.value === 'Minor'; + for (const controlName of CreatePartyComponent.MINOR_FIELDS) { + const control = this.partyForm.get(controlName); + if (!control) { + continue; + } + if (isMinor) { + control.enable({ emitEvent: false }); + // minAccountOpt is an optional note on how the account may be operated. + if (controlName !== 'minAccountOpt') { + control.setValidators(Validators.required); + } + } else { + control.clearValidators(); + control.setValue('', { emitEvent: false }); + control.disable({ emitEvent: false }); + this.loadedGuardianName = ''; + } + control.updateValueAndValidity({ emitEvent: false }); + } + } + + /** + * Transfer To Saving means the maturity proceeds land in a savings account, so the account has to + * name which one. Any other Adv Code leaves nothing to credit, and the three fields go away. + */ + private applyAdvCodeRules(): void { + const transfers = this.partyForm.get('advCode')?.value === CreatePartyComponent.TRANSFER_TO_SAVING; + for (const controlName of CreatePartyComponent.CREDIT_TO_FIELDS) { + const control = this.partyForm.get(controlName); + if (!control) { + continue; + } + if (transfers) { + control.setValidators(Validators.required); + } else { + control.clearValidators(); + control.setValue('', { emitEvent: false }); + } + control.updateValueAndValidity({ emitEvent: false }); + } + } + + get creditToRequired(): boolean { + return this.partyForm.get('advCode')?.value === CreatePartyComponent.TRANSFER_TO_SAVING; + } + + get isMinorAccount(): boolean { + return this.partyForm.get('adultMinor')?.value === 'Minor'; + } + + get nomineesRequired(): boolean { + return this.partyForm.get('nomineeApplicable')?.value === 'Yes'; + } + + /** + * Which entry in the list a stored reason belongs to. + * + * Anything not on the list was typed under "Other", including reasons saved before there was a + * list - so it comes back as "Other" with the original words intact rather than being lost to a + * dropdown that has no room for it. + */ + private listedLienReason(stored: string | null | undefined): string { + if (!stored) { + return ''; + } + return this.lienReasons.includes(stored) ? stored : 'Other'; + } + + /** True while this account's money is being held, which is what asks for a reason. */ + get lienHeld(): boolean { + return this.partyForm?.get('lien')?.value === 'Yes'; + } + + /** + * A held account has to say what it is held for; a free one has nothing to explain. + * + * The reason is cleared when the hold is lifted rather than left behind, so the form never reads + * "Lien: No" beside an explanation of a hold that no longer exists - the server clears it for the + * same reason. + */ + private applyLienRules(): void { + const reason = this.partyForm.get('lienReason'); + const other = this.partyForm.get('lienReasonOther'); + if (!reason || !other) { + return; + } + + if (this.lienHeld) { + reason.setValidators(Validators.required); + } else { + reason.clearValidators(); + reason.setValue('', { emitEvent: false }); + } + reason.updateValueAndValidity({ emitEvent: false }); + + // Only "Other" asks for words of its own - picking it and leaving it blank says no more than + // leaving the whole thing blank did. + if (this.lienHeld && this.lienReasonIsOther) { + other.setValidators([ + Validators.required, + Validators.maxLength(200) + ]); + } else { + other.clearValidators(); + other.setValue('', { emitEvent: false }); + } + other.updateValueAndValidity({ emitEvent: false }); + } + + /** True while the clerk has chosen to write the reason themselves. */ + get lienReasonIsOther(): boolean { + return this.partyForm?.get('lienReason')?.value === 'Other'; + } + + /** Saying an account has a nominee but naming none would leave the nomination incomplete. */ + private applyNomineeRules(): void { + if (this.nomineesRequired && this.nominees.length === 0) { + this.addNominee(); + } + if (!this.nomineesRequired) { + this.nominees.clear(); + } + } + + // ----------------------------------------------------------- calculations + + /** + * Maturity date and amount follow from the opening date and the tenure, so they are recomputed + * whenever one of those changes rather than being typed - which is why the legacy screen greyed + * them out. + */ + private wireMaturityCalculation(): void { + // The term drives everything downstream, so changing it refills Days as well. + for (const controlName of [ + 'openingDate', + 'month' + ]) { + this.partyForm.get(controlName)?.valueChanges.subscribe(() => this.recalculateTerm()); + } + // Days typed by hand stands: it only moves the maturity date when no month was given. + this.partyForm.get('days')?.valueChanges.subscribe(() => { + this.recalculateMaturityDate(); + this.recalculateMaturityAmount(); + }); + for (const controlName of [ + 'dailyDepositAmount', + 'interestRate' + ]) { + this.partyForm.get(controlName)?.valueChanges.subscribe(() => this.recalculateMaturityAmount()); + } + } + + private recalculateTerm(): void { + this.recalculateMaturityDate(); + // Days is not refilled from the term: whether collection runs every day or skips Sundays and + // holidays is the bank's own rule, and guessing it would quietly put a wrong instalment count - + // and so a wrong maturity amount - in front of the customer. It is only checked, below. + this.partyForm.get('days')?.updateValueAndValidity({ emitEvent: false }); + this.recalculateMaturityAmount(); + } + + private recalculateMaturityDate(): void { + const raw = this.partyForm.getRawValue(); + const openingDate = this.toDate(raw.openingDate); + const months = Number(raw.month) || 0; + const days = Number(raw.days) || 0; + + if (!openingDate || (months <= 0 && days <= 0)) { + this.partyForm.get('maturityDate')?.setValue('', { emitEvent: false }); + return; + } + + const maturity = new Date(openingDate.getTime()); + if (months > 0) { + maturity.setMonth(maturity.getMonth() + months); + } else { + maturity.setDate(maturity.getDate() + days); + } + this.partyForm.get('maturityDate')?.setValue(maturity, { emitEvent: false }); + } + + /** Calendar days the term covers, once a term in months has been given. */ + get termDays(): number | null { + const raw = this.partyForm.getRawValue(); + const openingDate = this.toDate(raw.openingDate); + const months = Number(raw.month) || 0; + if (!openingDate || months <= 0) { + return null; + } + const maturity = new Date(openingDate.getTime()); + maturity.setMonth(maturity.getMonth() + months); + return Math.round((maturity.getTime() - openingDate.getTime()) / (24 * 60 * 60 * 1000)); + } + + /** + * More instalments than there are days in the term is not a bank rule to be checked, it is + * arithmetic: 365 daily collections cannot happen inside three months. Left unchecked it puts a + * three-month maturity date beside a maturity amount worked out over a year, and the customer is + * quoted a figure the account will never reach. + * + * Fewer instalments than days is normal and passes silently - that is just a bank that does not + * collect on Sundays and holidays. + */ + private withinTerm(): ValidatorFn { + return (control: AbstractControl): ValidationErrors | null => { + const term = this.termDays; + const days = Number(control.value) || 0; + if (term === null || days <= 0 || days <= term) { + return null; + } + return { exceedsTerm: { term } }; + }; + } + + private recalculateMaturityAmount(): void { + const raw = this.partyForm.getRawValue(); + this.partyForm + .get('maturityAmount') + ?.setValue( + this.maturityAmountOf( + Number(raw.dailyDepositAmount) || 0, + Number(raw.days) || 0, + Number(raw.interestRate) || 0 + ), + { emitEvent: false } + ); + } + + /** + * Standard recurring-deposit simple interest: each daily instalment earns for the time left to + * maturity, so the first earns on the full term and the last on none. That averages out to + * n(n+1)/2 instalment-days of interest on top of the n instalments themselves. + * + * At a zero rate this is simply the total of the instalments. + */ + private maturityAmountOf(dailyAmount: number, days: number, ratePercent: number): number { + if (dailyAmount <= 0 || days <= 0) { + return 0; + } + const principal = dailyAmount * days; + const interest = ((dailyAmount * days * (days + 1)) / 2) * (ratePercent / 100) * (1 / 365); + return Math.round((principal + interest) * 100) / 100; + } + + // ---------------------------------------------------------------- nominees + + get nominees(): UntypedFormArray { + return this.partyForm.get('nominees') as UntypedFormArray; + } + + private buildNomineeGroup(): UntypedFormGroup { + const group = this.formBuilder.group({ + nominationDate: [ + '', + Validators.required + ], + name: [ + '', + Validators.required + ], + // A nominee the bank cannot reach, or whose age it does not know, is a name and nothing more - + // and the age is what decides whether a guardian has to be named at all. + mobileNo: [ + '', + [ + Validators.required, + Validators.pattern(CreatePartyComponent.MOBILE_RE) + ] + ], + birthDate: [ + '', + Validators.required + ], + relation: [ + '', + Validators.required + ], + gender: [ + '', + Validators.required + ], + guardianName: [''], + // One of these belongs to each split and neither to both: a share under Percentage, a place + // in the order under Sequence. Required-ness is set from the split mode rather than here, so + // switching the mode does not leave a stale rule behind. + sharePercent: [''], + sequenceNo: [''] + }); + + this.applySplitModeTo(group, this.partyForm?.get('nomineeSplitMode')?.value); + + // A nominee who is a minor cannot receive the proceeds themselves, so a guardian is named. + // Driven by the date of birth, which is also what decides whether they are a minor at all. The + // clerk is asked for the date once and the rest follows, rather than being asked a second time + // in a way that can contradict the first. + const applyGuardianRule = () => { + const guardian = group.get('guardianName'); + if (!guardian) { + return; + } + if (this.isNomineeMinor(group)) { + guardian.setValidators(Validators.required); + } else { + guardian.clearValidators(); + guardian.setValue('', { emitEvent: false }); + } + guardian.updateValueAndValidity({ emitEvent: false }); + }; + + // Both dates decide it: being under age is the gap between them, so a correction to either can + // turn a nominee into a minor or out of one. + group.get('birthDate')?.valueChanges.subscribe(applyGuardianRule); + group.get('nominationDate')?.valueChanges.subscribe(applyGuardianRule); + + return group; + } + + addNominee(): void { + this.nominees.push(this.buildNomineeGroup()); + } + + removeNominee(index: number): void { + this.nominees.removeAt(index); + } + + /** True while the deposit is being split by share rather than by order of entitlement. */ + get splitByPercentage(): boolean { + return this.partyForm?.get('nomineeSplitMode')?.value === 'Percentage'; + } + + /** What the shares entered so far come to, so the clerk can see the remainder as they type. */ + get nomineeShareTotal(): number { + return this.nominees.controls.reduce((sum, group) => sum + Number(group.get('sharePercent')?.value || 0), 0); + } + + /** + * A nomination that does not account for the whole deposit names nobody for the remainder, so the + * form will not submit one. Shown as a running total rather than only on save, because a clerk + * who has typed four shares wants to know which one to change, not that the set is wrong. + * + * Silent until at least one share has been typed. A blank form has nothing wrong with it yet, and + * opening the tab to a red warning tells the clerk off for work they have not had a chance to do. + */ + get nomineeShareError(): string | null { + if (!this.splitByPercentage || this.nominees.length === 0 || !this.anyShareEntered) { + return null; + } + const total = this.nomineeShareTotal; + if (total === 100) { + return null; + } + const shortBy = Math.round((100 - total) * 100) / 100; + return shortBy > 0 ? `${shortBy}% still to allocate` : `${Math.abs(shortBy)}% over`; + } + + /** Whether the clerk has started allocating at all, which is what turns the total into a warning. */ + private get anyShareEntered(): boolean { + return this.nominees.controls.some((group) => String(group.get('sharePercent')?.value ?? '').trim() !== ''); + } + + /** True while the deposit passes down an order of entitlement rather than being split by share. */ + get splitBySequence(): boolean { + return this.partyForm?.get('nomineeSplitMode')?.value === 'Sequence'; + } + + /** + * That every place in the order is filled exactly once. + * + * Two nominees given the same place leaves the bank to choose between them, and a gap leaves + * nobody at that place - both are the order failing to say what it exists to say. Checked across + * the whole list, because 1 and 3 are each perfectly valid on their own. + */ + get nomineeOrderError(): string | null { + if (!this.splitBySequence || this.nominees.length === 0) { + return null; + } + const entered = this.nominees.controls + .map((group) => String(group.get('sequenceNo')?.value ?? '').trim()) + .filter((value) => value !== '') + .map(Number); + + if (entered.length === 0) { + return null; + } + const duplicate = entered.find((value, index) => entered.indexOf(value) !== index); + if (duplicate !== undefined) { + return `Order ${duplicate} is used twice.`; + } + if (entered.length < this.nominees.length) { + return 'Every nominee needs a place in the order.'; + } + const missing = Array.from({ length: this.nominees.length }, (_, i) => i + 1).filter((n) => !entered.includes(n)); + return missing.length > 0 ? `Order ${missing.join(', ')} is missing.` : null; + } + + /** + * Turns on whichever of the two the split actually uses, and clears the other. + * + * Leaving the unused one populated would send the server a share on a Sequence nomination, or an + * order on a Percentage one - a second answer to a question that split does not ask. + */ + private applySplitModeTo(group: AbstractControl, splitMode: string): void { + const share = group.get('sharePercent'); + const order = group.get('sequenceNo'); + const byPercentage = splitMode === 'Percentage'; + + if (share) { + if (byPercentage) { + share.setValidators([ + Validators.required, + Validators.min(0.01), + Validators.max(100) + ]); + } else { + share.clearValidators(); + share.setValue('', { emitEvent: false }); + } + share.updateValueAndValidity({ emitEvent: false }); + } + + if (order) { + if (byPercentage) { + order.clearValidators(); + order.setValue('', { emitEvent: false }); + } else { + order.setValidators([ + Validators.required, + Validators.min(1) + ]); + } + order.updateValueAndValidity({ emitEvent: false }); + } + } + + /** + * Whether this nominee was under age on the day they were nominated, worked out from their date + * of birth rather than from a separate Yes/No. + * + * Measured against the nomination date, not today, so the form agrees with what the server + * stores: a nominee who has since come of age was still a minor on the day they were named, and + * the guardian named beside them belongs to that day. + */ + isNomineeMinor(control: AbstractControl): boolean { + const birth = control.get('birthDate')?.value; + const nominated = control.get('nominationDate')?.value; + if (!birth || !nominated) { + return false; + } + const birthDate = new Date(birth); + const majorityOn = new Date(nominated); + majorityOn.setFullYear(majorityOn.getFullYear() - 18); + return birthDate > majorityOn; + } + + // ------------------------------------------------------------------ actions + + cancel(): void { + this.partyId = null; + this.partyStatus = 'ACTIVE'; + this.linkedSavingsAccountId = null; + this.savingsAccountNo = null; + this.savingsProductName = null; + this.savingsAccountStatus = null; + this.savingsAccountBalance = null; + this.accountNo = null; + this.accountSchemeName = null; + this.accountBalance = null; + this.majorOn = null; + this.minorTurnedMajor = false; + this.loadedClientName = ''; + this.loadedAgentName = ''; + this.loadedGuardianName = ''; + this.clientLookupFailed = false; + this.agentLookupFailed = false; + this.partyForm.reset(); + this.partyForm.patchValue({ + nominal: 'No', + operation: 'Self', + adultMinor: 'Adult', + lien: 'No', + lienReason: '', + isSmsActive: true, + isEmailActive: false, + freezeCode: 'Normal', + advCode: 'None', + nomineeApplicable: 'No', + nomineeSplitMode: 'Percentage', + currentBalance: 0, + openingBalance: 0, + maturityAmount: 0, + interestRate: 0 + }); + this.nominees.clear(); + this.applyAdultMinorRules(); + this.applyAdvCodeRules(); + } + + exit(): void { + this.router.navigate(['/accounts/dds']); + } + + save(): void { + const raw = this.partyForm.getRawValue(); + const dateFormat = this.settingsService.dateFormat; + const locale = this.settingsService.language.code; + const asDate = (value: any) => (value ? this.dateUtils.formatDate(value, dateFormat) : undefined); + + const payload: any = { + // Either a client picked from the search list or an id typed straight in. + clientId: this.idOf(raw.clientId), + // idOf, not Number: the control now holds the picked agent object, and Number({}) is NaN. + agentId: this.idOf(raw.agentId), + officeId: raw.officeId, + savingsAccountNo: this.linkedSavingsAccountId ? undefined : raw.savingsAccountNo || undefined, + subType: raw.subType, + lastAccount: raw.lastAccount || undefined, + nominal: raw.nominal === 'Yes', + aadharNo: raw.aadharNo || undefined, + partyName: raw.partyName, + partyName1: raw.partyName1 || undefined, + address1: raw.address1, + address2: raw.address2 || undefined, + address3: raw.address3 || undefined, + city: raw.city, + pincode: raw.pincode, + gender: raw.gender || undefined, + panCardNo: raw.panCardNo || undefined, + telephone: raw.telephone || undefined, + mobileNoSms: raw.mobileNoSms, + operation: raw.operation, + birthDate: asDate(raw.birthDate), + occupation: raw.occupation || undefined, + risk: raw.risk || undefined, + ckycId: raw.ckycId || undefined, + adultMinor: raw.adultMinor, + minorDate: asDate(raw.minorDate), + guardianClientId: raw.guardianClientId ? this.idOf(raw.guardianClientId) : undefined, + minAccountOpt: raw.minAccountOpt || undefined, + relation: raw.relation || undefined, + openingDate: asDate(raw.openingDate), + month: raw.month || undefined, + reopenDate: asDate(raw.reopenDate), + depositAmount: raw.depositAmount || undefined, + dailyDepositAmount: raw.dailyDepositAmount, + days: raw.days, + ledgerNo: raw.ledgerNo || undefined, + // No interest rate: the server gives the account its product's rate. + withdrawType: raw.withdrawType || undefined, + lien: raw.lien === 'Yes', + // Sent only when the money is actually held. Releasing it clears the reason on the + // server, so a released account does not carry an explanation of a hold it no + // longer has. + lienReason: raw.lien === 'Yes' ? (raw.lienReason === 'Other' ? raw.lienReasonOther : raw.lienReason) : undefined, + isSmsActive: !!raw.isSmsActive, + isEmailActive: !!raw.isEmailActive, + spDescription: raw.spDescription || undefined, + freezeCode: raw.freezeCode || undefined, + advCode: raw.advCode || undefined, + creditToBranch: raw.creditToBranch || undefined, + creditToAccountType: raw.creditToAccountType || undefined, + creditToAccount: raw.creditToAccount || undefined, + nomineeApplicable: raw.nomineeApplicable === 'Yes', + nomineeSplitMode: raw.nomineeSplitMode || undefined, + dateFormat, + locale, + nominees: (raw.nominees || []).map((nominee: any) => ({ + // Sent through the same asDate as every other date on this form. A payload states one + // dateFormat and the server reads all of it that way, so a nominee date written in a second + // format is one the server is right to refuse. + nominationDate: asDate(nominee.nominationDate), + name: nominee.name, + mobileNo: nominee.mobileNo || undefined, + birthDate: asDate(nominee.birthDate), + relation: nominee.relation, + gender: nominee.gender, + guardianName: nominee.guardianName || undefined, + // Only the one its split actually uses is sent. The other is left out entirely, so the + // record says it was not stated rather than carrying a zero the server would have to tell + // apart from a real entitlement or a real place in the order. + sharePercent: this.splitByPercentage ? Number(nominee.sharePercent) : undefined, + sequenceNo: this.splitBySequence ? Number(nominee.sequenceNo) : undefined + })) + }; + + const partyLabel = raw.partyCode || raw.partyName; + const done = (response: any) => { + // Under maker-checker the command is only recorded, and nothing has been saved yet. + if (!!response?.commandId && response?.resourceId == null) { + this.alertService.alert({ + type: 'Sent for Approval', + message: this.partyId + ? `Changes to party ${partyLabel} were sent for a manager's approval and will take effect once approved.` + : `New party ${partyLabel} was sent for a manager's approval and will be created once approved.` + }); + } + this.exit(); + }; + + if (this.partyId) { + this.organizationService.updateParty(this.partyId, payload).subscribe(done); + } else { + this.organizationService.createParty(payload).subscribe(done); + } + } + + closeAccount(): void { + if (!this.partyId) { + return; + } + const dateFormat = this.settingsService.dateFormat; + const locale = this.settingsService.language.code; + this.organizationService + .closeParty(this.partyId, { + closeDate: this.dateUtils.formatDate(this.settingsService.businessDate || new Date(), dateFormat), + dateFormat, + locale + }) + .subscribe(() => this.exit()); + } + + /** + * Whether this account can still be closed before its term is up. + * + * Only while the maturity date is ahead: once it has passed the account is paid out and closed on + * its own terms, and there is no shortfall to settle. The scheme's lock-in is deliberately not + * checked here - that period lives on the scheme, and a copy of the rule on this screen would drift + * from the one the server applies. An account inside its lock-in is offered the button and refused + * with the date it becomes closable, which tells the officer more than a missing button would. + */ + /** + * The client and agent as ids the API understands. Both fields are autocompletes, so each holds + * either the object picked from the list or the raw text typed into it - the dialog needs the + * client to look up which accounts can take the payout, and the agent's code only to name who is + * being charged. + */ + private get clientIdOfForm(): any { + const value = this.partyForm.get('clientId')?.value; + return value && typeof value === 'object' ? value.id : value; + } + + private get agentCodeOfForm(): string { + const value = this.partyForm.get('agentId')?.value; + if (value && typeof value === 'object') { + return value.agentCode; + } + // The form holds the agent's id; the dialog names them by code. + const agent = (this.allAgents || []).find((option: any) => String(option.id) === String(value)); + return agent?.agentCode ?? String(value ?? ''); + } + + get canCloseEarly(): boolean { + const maturity = this.partyForm.get('maturityDate')?.value; + if (!maturity) { + return false; + } + const matured = maturity instanceof Date ? maturity : new Date(maturity); + return !isNaN(matured.getTime()) && matured.getTime() > Date.now(); + } + + /** + * Closing early. The dialog asks the one thing the bank leaves open - whether the customer or the + * agent carries the cost - and shows what the scheme charges for each. Everything else, including + * the lock-in, is applied by the server. + */ + closeEarly(): void { + if (!this.partyId) { + return; + } + const dialogRef = this.dialog.open(PrematureCloseDialogComponent, { + data: { + partyCode: this.partyForm.get('partyCode')?.value, + partyName: this.partyForm.get('partyName')?.value, + subType: this.partyForm.get('subType')?.value, + interestRate: this.partyForm.get('interestRate')?.value, + maturityDate: this.partyForm.get('maturityDate')?.value, + currentBalance: this.partyForm.get('currentBalance')?.value, + openingDate: this.partyForm.get('openingDate')?.value, + clientId: this.clientIdOfForm, + agentCode: this.agentCodeOfForm + }, + width: '560px', + autoFocus: false + }); + + dialogRef.afterClosed().subscribe((closeCommand: any) => { + if (!closeCommand) { + return; + } + const partyCode = this.partyForm.get('partyCode')?.value; + // Inside the lock-in it is a request with a reason, on its own permission, for a manager to pass. + const withinLockin = !!closeCommand.lockinOverrideReason; + // The date goes up the way every other date on this system does - formatted to the user's own + // setting, with that setting and their locale alongside so the server reads it back the same + // way. An ordinary close sends it like this too; two ways of closing an account should not + // disagree about something as plain as what day it is. + const dateFormat = this.settingsService.dateFormat; + const locale = this.settingsService.language.code; + const request = { + ...closeCommand, + closeDate: this.dateUtils.formatDate(this.settingsService.businessDate || new Date(), dateFormat), + dateFormat, + locale + }; + (withinLockin + ? this.organizationService.prematureCloseOverrideParty(this.partyId as number, request) + : this.organizationService.prematureCloseParty(this.partyId as number, request) + ).subscribe((response: any) => { + // Under maker-checker the command is only recorded, and nothing has closed yet. + const awaitingApproval = !!response?.commandId && response?.resourceId == null; + this.alertService.alert({ + type: awaitingApproval ? 'Sent for Approval' : 'Party Closed', + message: awaitingApproval + ? `Early closure of ${partyCode} is waiting for a manager's approval. The account stays open until then.` + : `${partyCode} is closed.` + }); + this.exit(); + }); + // A refusal - inside the lock-in, held under lien, KYC outstanding - is shown by the platform's + // own error handler, which names the reason. Repeating it here would say the same thing twice. + }); + } + + // ------------------------------------------------------------------ helpers + + /** + * Splits a document type's name into lower-case words, so an identifier can be recognised by a + * whole word rather than a substring. + * + * Banks name these types freely - the records here carry "PAN Card" and "Aadhaar Card", while the + * code list also offers bare "PAN" and "AADHAAR" - so an exact-name match misses them. A substring + * match is worse: "COMPANY EMP ID" contains "pan", and would be filed as the customer's PAN. + */ + private wordsOf(name: any): string[] { + return String(name || '') + .toLowerCase() + .split(/[^a-z0-9]+/) + .filter((word) => word.length > 0); + } + + /** + * Indian digit grouping - 12,34,567.89 rather than 1,234,567.89. An officer reading the first + * says "twelve lakh" at a glance; the second has to be counted. + */ + private static readonly MONEY = new Intl.NumberFormat('en-IN', { + minimumFractionDigits: 2, + maximumFractionDigits: 2 + }); + + /** + * A figure the system keeps rather than the officer types, shown as money: grouped, always to + * paise, and right-aligned so a column of them lines up. The control keeps the plain number. + */ + money(controlName: string): string { + const value = Number(this.partyForm.get(controlName)?.value); + return Number.isFinite(value) ? CreatePartyComponent.MONEY.format(value) : ''; + } + + /** A client field holds either the record picked from the search list or an id typed straight in. */ + private idOf(value: any): number { + return Number(value && typeof value === 'object' ? value.id : value); + } + + private setField(controlName: string, value: any): void { + if (value === null || value === undefined || value === '') { + return; + } + this.partyForm.get(controlName)?.setValue(String(value)); + } + + /** Fineract hands dates back as [yyyy, mm, dd] in places and as a string in others. */ + private toDate(value: any): Date | null { + if (!value) { + return null; + } + if (value instanceof Date) { + return value; + } + if (Array.isArray(value) && value.length >= 3) { + return new Date(value[0], value[1] - 1, value[2]); + } + const parsed = new Date(value); + return isNaN(parsed.getTime()) ? null : parsed; + } + + private patchFormFromParty(party: any): void { + this.partyId = party.id; + this.partyStatus = party.status || 'ACTIVE'; + this.linkedSavingsAccountId = party.savingsAccountId || null; + this.savingsAccountNo = party.savingsAccountNo || null; + this.savingsProductName = party.savingsProductName || null; + this.savingsAccountStatus = party.savingsAccountStatus || null; + this.savingsAccountBalance = party.savingsAccountBalance ?? null; + this.accountNo = party.accountNo || null; + this.accountSchemeName = party.subTypeDescription || party.subType || null; + this.accountBalance = party.currentBalance ?? null; + this.majorOn = party.majorOn || null; + this.minorTurnedMajor = party.minorTurnedMajor === true; + this.loadedClientName = party.clientName || ''; + + // Needed for the Credit To A/C dropdown to show the saved account as chosen rather than blank, + // so it is fetched on edit as well as when a client is first looked up. + if (party.clientId) { + this.loadCreditToAccounts(String(party.clientId)); + } + this.loadedAgentName = party.agentName || ''; + this.partyForm.patchValue({ + clientId: party.clientId, + subType: party.subType, + agentId: party.agentId, + officeId: party.officeId, + partyCode: party.partyCode, + lastAccount: party.lastAccount, + nominal: party.nominal ? 'Yes' : 'No', + aadharNo: party.aadharNo, + partyName: party.partyName, + partyName1: party.partyName1, + address1: party.address1, + address2: party.address2, + address3: party.address3, + city: party.city, + pincode: party.pincode, + gender: party.gender, + panCardNo: party.panCardNo, + telephone: party.telephone, + mobileNoSms: party.mobileNoSms, + operation: party.operation, + birthDate: this.toDate(party.birthDate), + occupation: party.occupation, + risk: party.risk, + ckycId: party.ckycId, + adultMinor: party.adultMinor, + minorDate: this.toDate(party.minorDate), + guardianClientId: party.guardianClientId, + minAccountOpt: party.minAccountOpt, + relation: party.relation, + openingDate: this.toDate(party.openingDate), + month: party.month, + maturityDate: this.toDate(party.maturityDate), + reopenDate: this.toDate(party.reopenDate), + depositAmount: party.depositAmount, + dailyDepositAmount: party.dailyDepositAmount, + days: party.days, + ledgerNo: party.ledgerNo, + maturityAmount: party.maturityAmount, + interestRate: party.interestRate, + withdrawType: party.withdrawType, + od1Flag: party.od1Flag, + od2Flag: party.od2Flag, + lien: party.lien ? 'Yes' : 'No', + lienReason: this.listedLienReason(party.lienReason), + lienReasonOther: this.listedLienReason(party.lienReason) === 'Other' ? party.lienReason : '', + payDate: this.toDate(party.payDate), + paymentAmount: party.paymentAmount, + currentBalance: party.currentBalance, + openingBalance: party.openingBalance, + isSmsActive: party.isSmsActive, + isEmailActive: party.isEmailActive, + interestCalc: party.interestCalc, + interestDate: this.toDate(party.interestDate), + spDescription: party.spDescription, + freezeCode: party.freezeCode, + advCode: party.advCode, + creditToBranch: party.creditToBranch, + creditToAccountType: party.creditToAccountType, + creditToAccount: party.creditToAccount, + nomineeApplicable: party.nomineeApplicable ? 'Yes' : 'No', + nomineeSplitMode: party.nomineeSplitMode + }); + + this.nominees.clear(); + (party.nominees || []).forEach((nominee: any) => { + const group = this.buildNomineeGroup(); + group.patchValue({ + nominationDate: this.toDate(nominee.nominationDate), + name: nominee.name, + mobileNo: nominee.mobileNo, + birthDate: this.toDate(nominee.birthDate), + relation: nominee.relation, + gender: nominee.gender, + guardianName: nominee.guardianName, + sharePercent: nominee.sharePercent, + sequenceNo: nominee.sequenceNo + }); + this.nominees.push(group); + }); + + this.applyAdultMinorRules(); + this.applyAdvCodeRules(); + + // A closed party is a record to look at, not to change. + if (this.isClosed) { + this.partyForm.disable({ emitEvent: false }); + } + } +} + +/** FEMALE or female -> Female; each word, so "SELF EMPLOYED" -> "Self Employed". */ +function titleCase(value: any): string { + return String(value ?? '') + .toLowerCase() + .replace(/(^|\s)(\S)/g, (_match, space, letter) => space + letter.toUpperCase()) + .trim(); +} diff --git a/src/app/deposits/party-master/due-for-closure/due-for-closure.component.html b/src/app/deposits/party-master/due-for-closure/due-for-closure.component.html new file mode 100644 index 000000000..8ff3cbce6 --- /dev/null +++ b/src/app/deposits/party-master/due-for-closure/due-for-closure.component.html @@ -0,0 +1,166 @@ + + +
+
+
+
+
+

Accounts Due for Closure

+

Matured DDS accounts still open — pay the customer, then close the account.

+
+
+
+ +
+
+ + @if (!loading && parties.length > 0) { +
+
+
+
+
{{ parties.length | number }}
+
Outstanding
+
+
+
+
+
+
{{ totalPayable | number: '1.2-2' }}
+
Still to Pay
+
+
+ + @if (awaitingCloseCount > 0) { +
+
+
+
{{ awaitingCloseCount | number }}
+
Ready to Close
+
+
+ } +
+ } + +
+ @if (loading) { +

Loading…

+ } @else if (parties.length === 0) { + +
+
+

Nothing to close

+

No DDS account has matured and been left open. Every matured account has been paid out and closed.

+
+ } @else { +
+ + + + + + + + + + + + + @for (party of parties; track party.id) { + + + + + + + + + } + +
PartyCustomerCollected ByMatured OnPayableAction
+
+
{{ party.partyCode }}
+ +
+
{{ party.partyName }} +
+
{{ party.agentName || '—' }}
+
{{ party.agentCode }}
+
+
+
+
{{ party.maturityDate | dateFormat }}
+ +
+ {{ daysOverdue(party) }} {{ daysOverdue(party) === 1 ? 'day' : 'days' }} ago +
+
+
+ @if (awaitingPayout(party)) { + {{ party.currentBalance | number: '1.2-2' }} + } @else { + + Paid out + } + + + @if (heldUnderLien(party)) { + + + + Held under lien + + } @else if (awaitingPayout(party)) { + + } @else { + + } +
+
+ } +
+
diff --git a/src/app/deposits/party-master/due-for-closure/due-for-closure.component.scss b/src/app/deposits/party-master/due-for-closure/due-for-closure.component.scss new file mode 100644 index 000000000..473e99186 --- /dev/null +++ b/src/app/deposits/party-master/due-for-closure/due-for-closure.component.scss @@ -0,0 +1,97 @@ +/** + * 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 'assets/styles/product-list' as pl; + +@include pl.product-list-styles; + +/* An empty list here means the work is done, so the mark is the reassuring colour, not the grey one. */ +.dfc-empty-ic { + color: #15803d; +} + +.dfc-account { + font-family: ui-monospace, 'SF Mono', 'Cascadia Mono', monospace; + font-size: 0.75rem; +} + +/* + * A maturity the bank has been sitting on for over a month. Called out because the date alone reads + * the same whether it passed yesterday or in the spring, and the two are not the same problem. + */ +.dfc-stale { + color: #b45309; + font-weight: 500; +} + +/* + * An account whose money has already gone out. Words rather than 0.00, so a settled account is not + * read as one that was never collected on - both would show the same figure. + */ +.dfc-paid { + color: #15803d; + font-weight: 600; + font-size: 0.78rem; +} + +/* + * The close action. Green rather than the list's usual blue because it is the step that finishes the + * account, and it only ever appears on a row already paid. + */ +.dfc-close-btn { + color: #15803d; + border-color: rgb(21 128 61 / 30%); + background: rgb(21 128 61 / 6%); + + &:hover { + background: rgb(21 128 61 / 12%); + border-color: rgb(21 128 61 / 45%); + } +} + +/* + * A deposit pledged against a loan. Not a button, because there is nothing for the clerk to do here + * until the loan is cleared — showing one would only lead to a refusal. + */ +.dfc-lien { + display: inline-flex; + align-items: center; + gap: 6px; + padding: 4px 10px; + border: 1px solid rgb(90 74 156 / 30%); + border-radius: 4px; + background: rgb(90 74 156 / 8%); + color: #5a4a9c; + font-size: 0.78rem; + font-weight: 600; + cursor: help; +} + +/* Plain table rather than mat-table: this list is short by design and needs no sort or paging. */ +.dfc-table { + th, + td { + padding: 10px 14px; + text-align: left; + border-bottom: 1px solid rgb(0 0 0 / 6%); + } + + th { + font-size: 0.72rem; + font-weight: 600; + letter-spacing: 0.04em; + text-transform: uppercase; + color: rgb(0 0 0 / 55%); + background: rgb(0 0 0 / 2%); + } + + .num { + text-align: right; + font-variant-numeric: tabular-nums; + } +} diff --git a/src/app/deposits/party-master/due-for-closure/due-for-closure.component.ts b/src/app/deposits/party-master/due-for-closure/due-for-closure.component.ts new file mode 100644 index 000000000..7b210a9bc --- /dev/null +++ b/src/app/deposits/party-master/due-for-closure/due-for-closure.component.ts @@ -0,0 +1,214 @@ +/** + * Copyright since 2025 Mifos Initiative + * + * This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. + */ + +/** Angular Imports */ +import { Component, OnInit, inject } from '@angular/core'; +import { MatDialog } from '@angular/material/dialog'; +import { FaIconComponent } from '@fortawesome/angular-fontawesome'; +import { STANDALONE_SHARED_IMPORTS } from 'app/standalone-shared.module'; + +/** Custom Services */ +import { OrganizationService } from 'app/organization/organization.service'; +import { AlertService } from 'app/core/alert/alert.service'; +import { SettingsService } from 'app/settings/settings.service'; +import { Dates } from 'app/core/utils/dates'; + +/** Custom Components */ +import { PayoutDialogComponent } from '../payout-dialog/payout-dialog.component'; +import { ConfirmationDialogComponent } from 'app/shared/confirmation-dialog/confirmation-dialog.component'; + +/** + * The DDS accounts whose term has run out and which are still open - the branch's list of closures + * waiting to be done. + * + * A list of work, not a report. Nothing closes itself: a matured account is money the bank now owes + * the customer, and where it goes has to be agreed with them, so each one waits here until an + * officer deals with it. Before this existed the maturity date simply passed and nobody was told - + * the bank held the money and the customer had to think to ask for it. + * + * Two steps, not one, and an account stays on this list through both: + * + * balance owing pay it out - to another account of theirs, or into matured-deposits payable + * balance nil paid; close it + * + * Splitting them is what a branch actually does. The officer pays, sees the balance fall to nil and + * the money land where it was meant to, and only then closes. One click doing both would close + * accounts whose payment nobody had checked, and a closed account is far harder to put right. + * + * Oldest maturity first, because that is the one the bank has been holding longest. + */ +@Component({ + selector: 'mifosx-due-for-closure', + templateUrl: './due-for-closure.component.html', + styleUrls: ['./due-for-closure.component.scss'], + imports: [ + ...STANDALONE_SHARED_IMPORTS, + FaIconComponent + ] +}) +export class DueForClosureComponent implements OnInit { + private organizationService = inject(OrganizationService); + private dialog = inject(MatDialog); + private alertService = inject(AlertService); + private settingsService = inject(SettingsService); + private dateUtils = inject(Dates); + + parties: any[] = []; + loading = true; + + ngOnInit(): void { + this.load(); + } + + private load(): void { + this.loading = true; + this.organizationService.getPartiesDueForClosure().subscribe({ + next: (parties: any) => { + this.parties = Array.isArray(parties) ? parties : parties?.pageItems || []; + this.loading = false; + }, + error: () => { + this.parties = []; + this.loading = false; + } + }); + } + + /** What the bank is holding in total across every matured account still open. */ + get totalPayable(): number { + return this.parties.reduce((sum, party) => sum + Number(party.currentBalance || 0), 0); + } + + /** + * Whether this one is still waiting on its payment. Anything left on the account is the customer's + * money, so it is paid before the account can be closed. + */ + awaitingPayout(party: any): boolean { + return Number(party.currentBalance || 0) > 0; + } + + /** + * Whether this deposit is standing as security for a loan. + * + * The term has ended and the money is owed, but it cannot leave while it is pledged - so the row + * says so rather than offering a Pay Out the server would refuse. A customer who needed cash before + * their term ended borrowed against the deposit instead of breaking it; the deposit is paid when + * that loan is cleared. + */ + heldUnderLien(party: any): boolean { + return Number(party.amountHeldUnderLien || 0) > 0; + } + + /** How many are already paid and now only need closing - the quicker half of the list. */ + get awaitingCloseCount(): number { + return this.parties.filter((party) => !this.awaitingPayout(party)).length; + } + + /** + * How long the bank has been sitting on this one. Shown because a maturity from three months ago + * is a different problem from one from yesterday, and the list alone would not say so. + */ + daysOverdue(party: any): number { + const matured = this.toDate(party.maturityDate); + if (!matured) { + return 0; + } + return Math.max(0, Math.floor((Date.now() - matured.getTime()) / 86400000)); + } + + /** The API sends dates as [year, month, day]; month is 1-based there and 0-based in Date. */ + private toDate(value: any): Date | null { + if (Array.isArray(value) && value.length >= 3) { + return new Date(value[0], value[1] - 1, value[2]); + } + return value ? new Date(value) : null; + } + + payOut(party: any): void { + const dialogRef = this.dialog.open(PayoutDialogComponent, { + data: party, + width: '520px', + autoFocus: false + }); + + dialogRef.afterClosed().subscribe((payoutCommand: any) => { + if (!payoutCommand) { + return; + } + this.organizationService.payOutParty(party.id, payoutCommand).subscribe({ + next: (result: any) => { + if (awaitingApproval(result)) { + this.alertService.alert({ + type: 'Sent for Approval', + message: `Payout of ${party.partyCode} is waiting for a manager's approval. Nothing has been paid yet.` + }); + this.load(); + return; + } + this.alertService.alert({ + type: 'Party Paid Out', + message: payoutCommand.toSavingsAccountNo + ? `${party.partyCode} paid into ${payoutCommand.toSavingsAccountNo} and the account is closed.` + : `${party.partyCode} parked in Sundry in the customer's name and the account is closed.` + }); + this.load(); + } + // A refusal is shown by the platform's own error handler, which names the reason - repeating + // it here would say the same thing twice. + }); + }); + } + + /** + * Closes an account whose money has already gone out. The server refuses one still carrying a + * balance, so this cannot close an account out from under an unpaid customer. + * + * Asked for first, because closing is the one step here with no way back. + */ + closeAccount(party: any): void { + const confirmation = this.dialog.open(ConfirmationDialogComponent, { + data: { + heading: `Close ${party.partyCode}?`, + dialogContext: `${party.partyName}'s account has been paid out in full and can be closed. It cannot be reopened.`, + type: 'warn' + } + }); + + confirmation.afterClosed().subscribe((response: any) => { + if (!response?.confirm) { + return; + } + const dateFormat = this.settingsService.dateFormat; + const locale = this.settingsService.language.code; + this.organizationService + .closeParty(party.id, { + closeDate: this.dateUtils.formatDate(this.settingsService.businessDate || new Date(), dateFormat), + dateFormat, + locale + }) + .subscribe({ + next: (result: any) => { + this.alertService.alert( + awaitingApproval(result) + ? { + type: 'Sent for Approval', + message: `Closing ${party.partyCode} is waiting for a manager's approval. It stays open until then.` + } + : { type: 'Party Closed', message: `${party.partyCode} is closed.` } + ); + this.load(); + } + }); + }); + } +} + +/** Under maker-checker the command is only recorded - it has a command id but no resource yet. */ +function awaitingApproval(result: any): boolean { + return !!result?.commandId && result?.resourceId == null; +} diff --git a/src/app/deposits/party-master/nominee-list-dialog/nominee-list-dialog.component.html b/src/app/deposits/party-master/nominee-list-dialog/nominee-list-dialog.component.html new file mode 100644 index 000000000..52305780e --- /dev/null +++ b/src/app/deposits/party-master/nominee-list-dialog/nominee-list-dialog.component.html @@ -0,0 +1,79 @@ + + +

+ Nominees + {{ data.partyCode }} · {{ data.partyName }} +

+ + + @if (nominees.length === 0) { +

Nobody is nominated on this account.

+ } @else { +

+ @if (bySequence) { + Paid in the order listed — the first named who can be found receives the deposit. + } @else { + Split by share of the deposit. + } +

+ + + + + + + + + @if (!bySequence) { + + } + + + + @for (nominee of nominees; track nominee.id; let i = $index) { + + + + + + + @if (!bySequence) { + + } + + } + +
+ @if (bySequence) { + Order + } @else { + # + } + NameRelationNominatedShare
{{ nominee.sequenceNo ?? i + 1 }} + {{ nominee.name }} + @if (nominee.minor) { + minor · guardian {{ nominee.guardianName || '—' }} + } + {{ nominee.relation }}{{ nominee.nominationDate | dateFormat }} + @if (nominee.sharePercent !== null && nominee.sharePercent !== undefined) { + {{ nominee.sharePercent }}% + } @else { + — + } +
+ + @if (shareWarning) { +

{{ shareWarning }}

+ } + } +
+ + + + diff --git a/src/app/deposits/party-master/nominee-list-dialog/nominee-list-dialog.component.scss b/src/app/deposits/party-master/nominee-list-dialog/nominee-list-dialog.component.scss new file mode 100644 index 000000000..cfcbc78ea --- /dev/null +++ b/src/app/deposits/party-master/nominee-list-dialog/nominee-list-dialog.component.scss @@ -0,0 +1,84 @@ +/** + * Copyright since 2025 Mifos Initiative + * + * This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. + */ + +.nd-title { + display: flex; + flex-direction: column; + gap: 2px; + margin-bottom: 0; +} + +.nd-party { + font-size: 13px; + font-weight: 400; + color: rgb(0 0 0 / 55%); +} + +.nd-body { + min-width: 420px; + padding-top: 8px; +} + +.nd-mode { + margin: 0 0 12px; + font-size: 13px; + color: rgb(0 0 0 / 60%); +} + +.nd-empty { + margin: 8px 0; + color: rgb(0 0 0 / 55%); +} + +.nd-table { + width: 100%; + border-collapse: collapse; + font-size: 14px; + + th { + text-align: left; + font-weight: 500; + font-size: 12px; + letter-spacing: 0.04em; + text-transform: uppercase; + color: rgb(0 0 0 / 50%); + padding: 0 8px 6px 0; + border-bottom: 1px solid rgb(0 0 0 / 10%); + } + + td { + padding: 8px 8px 8px 0; + border-bottom: 1px solid rgb(0 0 0 / 6%); + vertical-align: top; + } +} + +/* Shares line up to be read down the column, not across the row. */ +.nd-num { + text-align: right; + font-variant-numeric: tabular-nums; + padding-right: 0; +} + +.nd-seq { + width: 32px; + color: rgb(0 0 0 / 45%); + font-variant-numeric: tabular-nums; +} + +.nd-minor { + display: block; + font-size: 12px; + color: rgb(0 0 0 / 50%); +} + +.nd-warn { + margin: 12px 0 0; + font-size: 13px; + color: #b3261e; +} diff --git a/src/app/deposits/party-master/nominee-list-dialog/nominee-list-dialog.component.ts b/src/app/deposits/party-master/nominee-list-dialog/nominee-list-dialog.component.ts new file mode 100644 index 000000000..e6e5b4c10 --- /dev/null +++ b/src/app/deposits/party-master/nominee-list-dialog/nominee-list-dialog.component.ts @@ -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/. + */ + +/** Angular Imports */ +import { Component, inject } from '@angular/core'; +import { CommonModule } from '@angular/common'; +import { MAT_DIALOG_DATA, MatDialogModule } from '@angular/material/dialog'; +import { MatButtonModule } from '@angular/material/button'; + +/** Custom Pipes */ +import { DateFormatPipe } from 'app/pipes/date-format.pipe'; + +/** + * Who this account is nominated to, read without opening it for editing. + * + * A clerk asked "who is on this account" has one question, not an intention to change anything, and + * the edit form answers it only after a load, a tab and a scroll - with every field live the whole + * time. This states it and closes. + * + * What is shown follows the split the account was nominated under: a share per nominee under + * Percentage, and the order they are paid in under Sequence, because that ordering is the whole of + * what Sequence records. + */ +@Component({ + selector: 'mifosx-nominee-list-dialog', + standalone: true, + imports: [ + CommonModule, + MatDialogModule, + MatButtonModule, + DateFormatPipe + ], + templateUrl: './nominee-list-dialog.component.html', + styleUrls: ['./nominee-list-dialog.component.scss'] +}) +export class NomineeListDialogComponent { + /** { partyCode, partyName, splitMode, nominees } */ + data = inject(MAT_DIALOG_DATA); + + get bySequence(): boolean { + return this.data?.splitMode === 'Sequence'; + } + + get nominees(): any[] { + return this.data?.nominees || []; + } + + /** + * What the shares come to. Shown only when it is not the whole deposit - a nomination recorded + * before shares were captured reads as complete otherwise, and the remainder has nobody named + * to receive it. + */ + get shareTotal(): number | null { + if (this.bySequence) { + return null; + } + const stated = this.nominees.filter((n) => n?.sharePercent !== null && n?.sharePercent !== undefined); + if (stated.length === 0) { + return null; + } + return stated.reduce((sum, n) => sum + Number(n.sharePercent || 0), 0); + } + + get shareWarning(): string | null { + if (this.bySequence) { + // Under Sequence the order is the whole of the nomination, so an order nobody stated is the + // same gap a missing share is under Percentage. + const stated = this.nominees.filter((n) => n?.sequenceNo !== null && n?.sequenceNo !== undefined); + return stated.length === this.nominees.length ? null : 'No order was recorded against these nominees.'; + } + const total = this.shareTotal; + if (total === null) { + return 'No shares were recorded against these nominees.'; + } + return total === 100 ? null : `The shares come to ${total}%, not 100%.`; + } +} diff --git a/src/app/deposits/party-master/party-master.component.html b/src/app/deposits/party-master/party-master.component.html new file mode 100644 index 000000000..711260aff --- /dev/null +++ b/src/app/deposits/party-master/party-master.component.html @@ -0,0 +1,240 @@ + + +
+
+
+
+
+

Party Master

+

The DDS accounts on file — each one a customer collected from daily.

+
+
+
+
+ + {{ totalCount }} + {{ totalCount === 1 ? 'party' : 'parties' }} +
+ +
+
+ + + @if (dueForClosureCount > 0) { + + + + {{ dueForClosureCount }} + {{ dueForClosureCount === 1 ? 'account has' : 'accounts have' }} matured and + {{ dueForClosureCount === 1 ? 'is' : 'are' }} still open — + + @if (dueForClosurePayable > 0) { + {{ dueForClosurePayable | number: '1.2-2' }} still owed to customers. + } @else { + paid in full, waiting to be closed. + } + + Settle {{ dueForClosureCount === 1 ? 'it' : 'them' }} + + } + +
+
+
+
+
{{ activeCount | number }}
+
Open Accounts
+
+
+
+
+
+
{{ totalDailyDue | number: '1.2-2' }}
+
Daily Collection Due
+
+
+
+
+
+
{{ totalCurrentBalance | number: '1.2-2' }}
+
Total Deposits Held
+
+
+
+ +
+
+ +
+ + @if (totalCount === 0) { +
+
+

No parties yet

+

Open one so an agent can start collecting daily deposits against it.

+ +
+ } @else { +
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
Party Code +
+
+
+
{{ p.partyCode }}
+
{{ p.city || '—' }}
+
+
+
Party Name{{ p.partyName }}Sub Type + {{ p.subTypeDescription || p.subType }} + Collected By +
+
{{ p.agentName || '—' }}
+
{{ p.agentCode }}
+
+
DDS Account + @if (p.accountNo) { +
+ +
{{ p.subTypeDescription || p.subType }}
+
+ } @else if (p.savingsAccountNo) { +
+ +
{{ p.savingsAccountStatus }}
+
+ } @else { + + } +
Daily Deposit{{ p.dailyDepositAmount | number: '1.2-2' }}Current Balance + {{ p.currentBalance | number: '1.2-2' }} + Status + + {{ p.status === 'CLOSED' ? 'Closed' : 'Active' }} + + Nominees + @if (nomineeCount(p) > 0) { + + } @else { + None + } + Actions + +
+ + @if (filteredCount === 0 && totalCount > 0) { +
+ + No parties match your filter. +
+ } + + +
+ } +
+
diff --git a/src/app/deposits/party-master/party-master.component.scss b/src/app/deposits/party-master/party-master.component.scss new file mode 100644 index 000000000..b235f0f44 --- /dev/null +++ b/src/app/deposits/party-master/party-master.component.scss @@ -0,0 +1,133 @@ +/** + * 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 'assets/styles/product-list' as pl; + +@include pl.product-list-styles; + +/* Party-specific amber accent */ +:host { + --pl-accent: #b45309; + --pl-accent-l: #fef3c7; +} + +/* A closed party stays listed - its collections and interest are still on the books. */ +.pm-row--closed td:not(.num) { + opacity: 0.55; +} + +.pm-pill { + display: inline-block; + padding: 3px 11px; + border-radius: 11px; + font-size: 0.72rem; + font-weight: 500; + line-height: 1.7; + background: #dcfce7; + color: #15803d; + white-space: nowrap; +} + +.pm-pill--off { + background: rgb(0 0 0 / 7%); + color: rgb(0 0 0 / 52%); +} + +/* The customer's real deposit account number, in the list. */ +.pm-list-account { + font-family: ui-monospace, 'SF Mono', 'Cascadia Mono', monospace; + font-size: 0.8rem; +} + +/* + A party with no account behind it. Called out rather than left blank: the balance beside it is + money the bank's own books have no record of, which is the thing worth noticing here. +*/ +.pm-list-no-account { + font-size: 0.75rem; + font-style: italic; + color: #9b2c2c; +} + +/* + Matured accounts still open. Deliberately loud: it is money the bank owes a customer who has not + been told, and every day it sits there is a day the bank is holding somebody else's money. Styled + as a link to the work rather than a notice about it - reading it should be the same click as + starting on it. +*/ +.pm-due-banner { + display: flex; + align-items: center; + gap: 12px; + margin: 0 0 14px; + padding: 12px 16px; + background: rgb(180 83 9 / 8%); + border: 1px solid rgb(180 83 9 / 28%); + border-radius: 8px; + color: #92400e; + text-decoration: none; + transition: background 0.15s ease; + + &:hover { + background: rgb(180 83 9 / 13%); + } + + &__ic { + font-size: 1.05rem; + flex: 0 0 auto; + } + + &__text { + flex: 1 1 auto; + font-size: 0.88rem; + line-height: 1.45; + } + + &__cta { + flex: 0 0 auto; + display: inline-flex; + align-items: center; + gap: 6px; + font-size: 0.82rem; + font-weight: 600; + white-space: nowrap; + } +} + +@media (width <= 700px) { + .pm-due-banner { + flex-wrap: wrap; + } +} + +/* + * The nominee count, which opens the list of names rather than the account itself. + * + * Styled as text that can be clicked rather than as a second action button: the row already has one + * action, and a pair of equal-looking buttons would put "look at this" beside "change this" as + * though the two were the same kind of thing. + */ +.pl-nominee-btn { + border: 0; + background: none; + padding: 0; + font: inherit; + color: var(--pl-accent); + font-weight: 500; + cursor: pointer; + text-decoration: underline; + text-underline-offset: 3px; + + &:hover { + text-decoration-thickness: 2px; + } +} + +.pl-muted { + color: rgb(0 0 0 / 40%); +} diff --git a/src/app/deposits/party-master/party-master.component.ts b/src/app/deposits/party-master/party-master.component.ts new file mode 100644 index 000000000..7ec5f2c8a --- /dev/null +++ b/src/app/deposits/party-master/party-master.component.ts @@ -0,0 +1,198 @@ +/** + * Copyright since 2025 Mifos Initiative + * + * This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. + */ + +/** Angular Imports */ +import { Component, OnInit, ViewChild, inject } from '@angular/core'; +import { MatPaginator } from '@angular/material/paginator'; +import { MatSort, MatSortHeader } from '@angular/material/sort'; +import { + MatTable, + MatTableDataSource, + MatColumnDef, + MatHeaderCellDef, + MatHeaderCell, + MatCellDef, + MatCell, + MatHeaderRowDef, + MatHeaderRow, + MatRowDef, + MatRow +} from '@angular/material/table'; +import { MatTooltip } from '@angular/material/tooltip'; +import { FaIconComponent } from '@fortawesome/angular-fontawesome'; +import { STANDALONE_SHARED_IMPORTS } from 'app/standalone-shared.module'; + +import { MatDialog } from '@angular/material/dialog'; + +/** Custom Services */ +import { OrganizationService } from 'app/organization/organization.service'; + +/** Custom Components */ +import { NomineeListDialogComponent } from './nominee-list-dialog/nominee-list-dialog.component'; + +/** + * Party Master list. + * + * The DDS accounts on file - each one a customer's daily deposit account, opened under a scheme + * (Sub Type) and assigned to the agent who collects for it. Follows the same list -> create/edit + * shape as Agent Master and the product lists, and shares their stylesheet. + */ +@Component({ + selector: 'mifosx-party-master', + templateUrl: './party-master.component.html', + styleUrls: ['./party-master.component.scss'], + imports: [ + ...STANDALONE_SHARED_IMPORTS, + FaIconComponent, + MatTable, + MatSort, + MatColumnDef, + MatHeaderCellDef, + MatHeaderCell, + MatSortHeader, + MatCellDef, + MatCell, + MatHeaderRowDef, + MatHeaderRow, + MatRowDef, + MatRow, + MatPaginator, + MatTooltip + ] +}) +export class PartyMasterComponent implements OnInit { + private organizationService = inject(OrganizationService); + private dialog = inject(MatDialog); + + displayedColumns: string[] = [ + 'partyCode', + 'partyName', + 'subType', + 'agentName', + 'savingsAccountNo', + 'dailyDepositAmount', + 'currentBalance', + 'nominees', + 'status', + 'actions' + ]; + + /** + * Who a party is nominated to, without opening it for editing. + * + * Asked often enough to belong on the list, and answered from what the list already holds - the + * parties come back with their nominees on them, so this opens rather than fetches. + */ + showNominees(party: any): void { + this.dialog.open(NomineeListDialogComponent, { + data: { + partyCode: party.partyCode, + partyName: party.partyName, + splitMode: party.nomineeSplitMode, + nominees: party.nominees || [] + }, + autoFocus: false + }); + } + + /** How many people are named on an account, which is what the list column shows. */ + nomineeCount(party: any): number { + return (party?.nominees || []).length; + } + + /** + * Matured accounts still open, and what they add up to. Shown here because this is the screen an + * officer already opens - money the bank owes a customer should not wait on somebody thinking to + * run a report. + */ + dueForClosureCount = 0; + dueForClosurePayable = 0; + + dataSource: MatTableDataSource = new MatTableDataSource([]); + parties: any[] = []; + filteredCount = 0; + + paginator: MatPaginator; + sort: MatSort; + + // The table sits inside an @if, so these only exist once the list has loaded. Read statically they + // were never there, the table was never paged, and the paginator read "0 of 0" over a full list. + @ViewChild(MatPaginator) set matPaginator(paginator: MatPaginator) { + this.paginator = paginator; + if (paginator) { + this.dataSource.paginator = paginator; + } + } + + @ViewChild(MatSort) set matSort(sort: MatSort) { + this.sort = sort; + if (sort) { + this.dataSource.sort = sort; + } + } + + get totalCount(): number { + return this.parties.length; + } + + get activeCount(): number { + return this.parties.filter((party) => party.status !== 'CLOSED').length; + } + + /** What the whole book is worth today - every open account's running balance added up. */ + get totalCurrentBalance(): number { + return this.parties.reduce((sum, party) => sum + Number(party.currentBalance || 0), 0); + } + + /** What the agents should be collecting between them on a full collection day. */ + get totalDailyDue(): number { + return this.parties + .filter((party) => party.status !== 'CLOSED') + .reduce((sum, party) => sum + Number(party.dailyDepositAmount || 0), 0); + } + + ngOnInit(): void { + this.organizationService.getParties().subscribe((parties: any[]) => { + this.parties = parties || []; + this.dataSource = new MatTableDataSource(this.parties); + this.dataSource.paginator = this.paginator; + this.dataSource.sort = this.sort; + this.filteredCount = this.parties.length; + this.dataSource.connect().subscribe((rows) => (this.filteredCount = rows.length)); + }); + + this.loadDueForClosure(); + } + + /** + * Matured accounts still open. A separate call rather than filtered out of the list above, because + * the server decides what counts as matured - comparing dates here would drift from it the first + * time either changed. + */ + private loadDueForClosure(): void { + this.organizationService.getPartiesDueForClosure().subscribe({ + next: (due: any) => { + const rows = Array.isArray(due) ? due : due?.pageItems || []; + this.dueForClosureCount = rows.length; + this.dueForClosurePayable = rows.reduce( + (sum: number, party: any) => sum + Number(party.currentBalance || 0), + 0 + ); + }, + // The party list is the point of this screen; a failed banner must not take it down with it. + error: () => { + this.dueForClosureCount = 0; + this.dueForClosurePayable = 0; + } + }); + } + + applyFilter(filterValue: string): void { + this.dataSource.filter = filterValue.trim().toLowerCase(); + } +} diff --git a/src/app/deposits/party-master/payout-dialog/payout-dialog.component.html b/src/app/deposits/party-master/payout-dialog/payout-dialog.component.html new file mode 100644 index 000000000..7553845f3 --- /dev/null +++ b/src/app/deposits/party-master/payout-dialog/payout-dialog.component.html @@ -0,0 +1,164 @@ + + +

Pay out {{ data.partyCode }}

+ + + +
+
+ Customer + {{ data.partyName }} +
+
+ Matured on + {{ data.maturityDate | dateFormat }} +
+
+ Opened on + {{ data.openingDate | dateFormat }} +
+ + @if (breakdown) { +
+
+ Deposits collected + {{ breakdown.deposits | number: '1.2-2' }} +
+ @if (breakdown.transfersIn > 0) { +
+ Transferred in + + {{ breakdown.transfersIn | number: '1.2-2' }} +
+ } + @if (breakdown.transfersOut > 0) { +
+ Transferred out + − {{ breakdown.transfersOut | number: '1.2-2' }} +
+ } + @if (breakdown.other !== 0) { +
+ Other (opening balance / adjustments) + {{ breakdown.other | number: '1.2-2' }} +
+ } +
+ + Interest @ {{ data.interestRate | number: '1.2-2' }}% + {{ data.openingDate | dateFormat }} to {{ data.interestDate | dateFormat }} + + + {{ breakdown.interest | number: '1.2-2' }} +
+ @if (breakdown.interestToPost > 0) { +
+ Interest calculated, posted with this payout + + {{ breakdown.interestToPost | number: '1.2-2' }} +
+ } +
+ } + +
+ Total payable + {{ (breakdown ? breakdown.total : data.currentBalance) | number: '1.2-2' }} +
+ @if (interestStillToCome) { +

+ Interest from {{ data.interestDate | dateFormat }} to {{ data.maturityDate | dateFormat }} is not worked out yet + - it is added when this is paid out, so the amount paid will be a little more. +

+ } +
+

+ Paying out closes {{ data.partyCode }} - the balance is nil afterwards and it takes nothing more. +

+ + @if (loading) { + + } @else { +

Where should this go?

+ + + + Into another account of theirs here + @if (ownAccounts.length === 0) { + {{ data.partyName }} has no other savings account at this bank + } @else { + Moved across today + } + + + @if (destination === 'own' && ownAccounts.length > 0) { + + } + + + Park in Sundry + When the customer has no account here - held in the bank's Sundry account in their name + + + + @if (destination === 'other') { + + } + + @if (destination === 'other') { + +
+ The money comes off {{ data.partyCode }} today and is held in the Sundry account chosen above against + {{ data.partyName }} until it is paid to them - so it is accounted for the whole time and nothing is lost. +
+ } + } +
+ + + + + diff --git a/src/app/deposits/party-master/payout-dialog/payout-dialog.component.scss b/src/app/deposits/party-master/payout-dialog/payout-dialog.component.scss new file mode 100644 index 000000000..7e62e8ee6 --- /dev/null +++ b/src/app/deposits/party-master/payout-dialog/payout-dialog.component.scss @@ -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/. + */ + +/* What is being paid and to whom. Read against the customer standing there, so it comes first. */ +.po-summary { + display: flex; + flex-direction: column; + gap: 6px; + padding: 12px 14px; + margin-bottom: 18px; + background: rgb(0 0 0 / 3%); + border-radius: 6px; + + &__row { + display: flex; + justify-content: space-between; + gap: 16px; + font-size: 0.85rem; + + /* The figure the officer is about to hand over - given the weight that deserves. */ + &--amount { + padding-top: 6px; + border-top: 1px solid rgb(0 0 0 / 8%); + font-size: 1rem; + } + } + + &__label { + color: rgb(0 0 0 / 55%); + } + + &__value { + font-weight: 500; + font-variant-numeric: tabular-nums; + } + + &__note { + display: block; + margin: 0; + font-size: 0.75rem; + color: rgb(0 0 0 / 45%); + } +} + +/* How the amount payable is made up - one line per source, adding up to the total below. */ +.po-breakdown { + display: flex; + flex-direction: column; + gap: 6px; + padding-top: 6px; + border-top: 1px dashed rgb(0 0 0 / 10%); +} + +.po-question { + margin: 0 0 10px; + font-size: 0.9rem; + font-weight: 500; +} + +.po-choices { + display: flex; + flex-direction: column; + gap: 4px; +} + +/* + * Each choice carries its own consequence underneath it. Two radio labels alone would not tell an + * officer that one keeps the money inside the bank and the other starts it on its way out. + */ +.po-choice { + display: block; + padding: 4px 0; + + &__title { + display: block; + font-size: 0.88rem; + } + + &__note { + display: block; + font-size: 0.75rem; + color: rgb(0 0 0 / 55%); + line-height: 1.4; + } +} + +.po-account { + width: 100%; + margin: 8px 0 4px 32px; + max-width: calc(100% - 32px); +} + +/* What will actually happen, in the words the customer will ask about. */ +.po-callout { + margin-top: 12px; + padding: 10px 12px; + background: rgb(180 83 9 / 8%); + border: 1px solid rgb(180 83 9 / 22%); + border-radius: 6px; + color: #92400e; + font-size: 0.78rem; + line-height: 1.5; +} + +.po-hint-warn { + color: #c62828; +} diff --git a/src/app/deposits/party-master/payout-dialog/payout-dialog.component.ts b/src/app/deposits/party-master/payout-dialog/payout-dialog.component.ts new file mode 100644 index 000000000..0ca81b9c1 --- /dev/null +++ b/src/app/deposits/party-master/payout-dialog/payout-dialog.component.ts @@ -0,0 +1,230 @@ +/** + * Copyright since 2025 Mifos Initiative + * + * This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. + */ + +/** Angular Imports */ +import { Component, OnInit, inject } from '@angular/core'; +import { CommonModule } from '@angular/common'; +import { FormsModule } from '@angular/forms'; +import { MAT_DIALOG_DATA, MatDialogModule, MatDialogRef } from '@angular/material/dialog'; +import { MatButtonModule } from '@angular/material/button'; +import { MatRadioModule } from '@angular/material/radio'; +import { MatSelectModule } from '@angular/material/select'; +import { MatAutocompleteModule } from '@angular/material/autocomplete'; +import { MatFormFieldModule } from '@angular/material/form-field'; +import { MatInputModule } from '@angular/material/input'; +import { MatProgressBarModule } from '@angular/material/progress-bar'; + +/** Custom Services */ +import { OrganizationService } from 'app/organization/organization.service'; + +/** Custom Pipes */ +import { DateFormatPipe } from 'app/pipes/date-format.pipe'; + +/** + * Where a matured DDS account's money should go. + * + * Two destinations, and the difference is whether the money leaves the bank. Another account of the + * customer's here is a move across the books. Anywhere else means a transfer out, which clears later + * or fails - so the money is parked in the matured-deposits payable account first and the branch + * sends it on from there, rather than being launched into a gap where it has left one account and + * arrived at none. + * + * The amount is not asked for. It is whatever the account holds, worked out by the server: a figure + * typed here could pay out more than the customer has, or quietly leave some behind. + */ +@Component({ + selector: 'mifosx-payout-dialog', + standalone: true, + imports: [ + CommonModule, + FormsModule, + MatDialogModule, + MatButtonModule, + MatRadioModule, + MatSelectModule, + MatAutocompleteModule, + MatFormFieldModule, + MatInputModule, + MatProgressBarModule, + DateFormatPipe + ], + templateUrl: './payout-dialog.component.html', + styleUrls: ['./payout-dialog.component.scss'] +}) +export class PayoutDialogComponent implements OnInit { + private organizationService = inject(OrganizationService); + private dialogRef = inject(MatDialogRef); + data = inject(MAT_DIALOG_DATA); + + /** 'own' moves it to another account here; 'other' parks it for a transfer to another bank. */ + destination: 'own' | 'other' = 'own'; + + /** + * The customer's accounts this maturity can be paid into. + * + * Worked out by the server, which applies the same conditions the payout enforces: an active + * savings account of theirs, not a fixed or recurring deposit, and not one of their DDS scheme + * accounts. Filtering here instead let the two drift - the list once offered a scheme account + * belonging to a party that had already closed, which would have stranded the money. + */ + ownAccounts: any[] = []; + selectedAccountNo = ''; + + /** Where the balance is parked when the customer has no account here. */ + sundryAccounts: any[] = []; + sundryGlAccountId: number | null = null; + /** What is typed in the GL box - a code or part of a name. */ + sundrySearch = ''; + + /** The accounts matching what is typed, by GL code or name, in any case. */ + get sundryOptions(): any[] { + const typed = this.sundrySearch.trim().toLowerCase(); + if (!typed || typed === this.sundryLabel(this.chosenSundry).toLowerCase()) { + return this.sundryAccounts; + } + return this.sundryAccounts.filter( + (account: any) => + String(account.glCode).toLowerCase().includes(typed) || + String(account.name || '') + .toLowerCase() + .includes(typed) + ); + } + + get chosenSundry(): any { + return this.sundryAccounts.find((account: any) => account.id === this.sundryGlAccountId); + } + + /** Shows a picked account in the box as its code and name. */ + readonly sundryDisplay = (value: any): string => + value && typeof value === 'object' ? this.sundryLabel(value) : value || ''; + + sundryLabel(account: any): string { + return account ? `${account.glCode} · ${account.name}` : ''; + } + + pickSundry(account: any): void { + this.sundryGlAccountId = account ? account.id : null; + this.sundrySearch = this.sundryLabel(account); + } + + /** Typing again clears the choice until an account is picked, so a half-typed code is never sent. */ + onSundryTyped(value: any): void { + // Picking from the list hands over the account itself rather than text. + if (value && typeof value === 'object') { + this.pickSundry(value); + return; + } + value = String(value ?? ''); + this.sundrySearch = value; + if (value !== this.sundryLabel(this.chosenSundry)) { + const exact = this.sundryAccounts.find( + (account: any) => String(account.glCode).toLowerCase() === value.trim().toLowerCase() + ); + this.sundryGlAccountId = exact ? exact.id : null; + } + } + loading = true; + + /** + * How the amount payable was made up - what was deposited, what came in or went out by transfer, + * and the interest - read from the account's own transactions so the officer can explain the + * figure to the customer line by line. Null until the transactions have arrived. + */ + breakdown: { + deposits: number; + transfersIn: number; + transfersOut: number; + interest: number; + other: number; + interestToPost: number; + total: number; + } | null = null; + + ngOnInit(): void { + // The accounts the balance can be parked in. None is chosen: each bank has its own Sundry account, + // and the clerk searches for it and picks it. + this.organizationService.getDdsSundryAccounts().subscribe({ + next: (accounts: any) => (this.sundryAccounts = accounts || []), + error: () => (this.sundryAccounts = []) + }); + this.organizationService.getPartyTransactionsOfParty(this.data.id).subscribe({ + next: (transactions: any) => (this.breakdown = this.breakdownOf(Array.isArray(transactions) ? transactions : [])), + error: () => (this.breakdown = null) + }); + + this.organizationService.getCreditableAccounts(this.data.clientId).subscribe({ + next: (accounts: any) => { + this.ownAccounts = accounts || []; + + // Nothing here to pay into, so the only real choice is a transfer out - offered as the + // default rather than leaving the officer on an option that cannot be completed. + if (this.ownAccounts.length === 0) { + this.destination = 'other'; + } else if (this.ownAccounts.length === 1) { + this.selectedAccountNo = this.ownAccounts[0].accountNo; + } + this.loading = false; + }, + error: () => { + this.ownAccounts = []; + this.destination = 'other'; + this.loading = false; + } + }); + } + + /** Interest is still to be worked out for part of the term, and the payout will add it. */ + get interestStillToCome(): boolean { + return ( + !!this.data.interestDate && + !!this.data.maturityDate && + String(this.data.interestDate) < String(this.data.maturityDate) + ); + } + + private breakdownOf(transactions: any[]) { + const sum = (test: (txn: any) => boolean) => + transactions.filter(test).reduce((total: number, txn: any) => total + Number(txn.amount || 0), 0); + const signed = (type: string) => + sum((txn) => txn.transactionType === type && txn.direction === 'CREDIT') - + sum((txn) => txn.transactionType === type && txn.direction === 'DEBIT'); + const isTransfer = (txn: any) => txn.transactionType === 'WITHDRAW_DEPOSITE' || txn.transactionType === 'TRANSFER'; + + const deposits = signed('PARTY_TRAN'); + const transfersIn = sum((txn) => isTransfer(txn) && txn.direction === 'CREDIT'); + const transfersOut = sum((txn) => isTransfer(txn) && txn.direction === 'DEBIT'); + const interest = + signed('INTEREST_POST') + + signed('OD_INTEREST_POST') + + signed('PREMATURE_AGENT_TOP_UP') + + signed('PREMATURE_INT_RECOVERY'); + const balance = Number(this.data.currentBalance || 0); + // Anything the four lines above do not cover - an opening balance brought over, say - so the lines + // still add up to the balance rather than leaving the officer a gap to explain. + const other = Math.round((balance - (deposits + transfersIn - transfersOut + interest)) * 100) / 100; + // Interest already worked out but not yet credited is posted by the payout itself, first. + const interestToPost = Number(this.data.interestCalc || 0); + return { deposits, transfersIn, transfersOut, interest, other, interestToPost, total: balance + interestToPost }; + } + + get canPayOut(): boolean { + if (this.loading) { + return false; + } + return this.destination === 'other' ? !!this.sundryGlAccountId : !!this.selectedAccountNo; + } + + payOut(): void { + this.dialogRef.close( + this.destination === 'own' + ? { toSavingsAccountNo: this.selectedAccountNo } + : { sundryGlAccountId: this.sundryGlAccountId } + ); + } +} diff --git a/src/app/deposits/party-master/premature-close-dialog/premature-close-dialog.component.html b/src/app/deposits/party-master/premature-close-dialog/premature-close-dialog.component.html new file mode 100644 index 000000000..7fc420782 --- /dev/null +++ b/src/app/deposits/party-master/premature-close-dialog/premature-close-dialog.component.html @@ -0,0 +1,175 @@ + + +

Close {{ data.partyCode }} early

+ + + @if (loading) { + + } + + +
+
+ Customer + {{ data.partyName }} +
+
+ Balance + {{ data.currentBalance | number: '1.2-2' }} +
+
+ Matures on + {{ data.maturityDate | dateFormat }} +
+
+ Scheme rate + {{ data.interestRate | number: '1.0-2' }}% +
+
+ +

Who bears the cost of closing early?

+ + + + + + + + + @if (scheme && scheme.prematureAgentCommissionPenalty > 0) { +

+ Either way, {{ data.agentCode }} is charged + {{ scheme.prematureAgentCommissionPenalty | number: '1.0-2' }}% + of the commission owed to them — the round was not kept going. +

+ } + +

Where should the balance go?

+ + + + Into another account of theirs at this bank + + Park in Sundry (no account here) + + + @if (destination === 'own' && ownAccounts.length > 0) { + + Credit to account + + @for (account of ownAccounts; track account.accountNo) { + {{ account.accountNo }} — {{ account.productName }} + } + + + } + + @if (destination === 'other') { + + Park in GL account + + + @for (account of sundryOptions; track account.id) { + {{ account.glCode }} · {{ account.name }} + } + @if (sundryOptions.length === 0) { + No liability GL matches + } + + @if (!sundryGlAccountId) { + Search and pick your bank's Sundry GL account + } @else { + The balance is held here in the customer's name until it is paid to them + } + + } + + @if (ownAccounts.length === 0 && !loading) { +

+ This customer has no other account here, so the balance is parked in the Sundry account chosen above, in their + name until it is paid to them. +

+ } + + + @if (withinLockin) { +
+

Within lock-in until {{ closableFrom | dateFormat }}

+

+ {{ data.partyCode }} opened on {{ data.openingDate | dateFormat }} and the scheme locks it in for + {{ scheme?.prematureLockinMonths }} months. It can be closed early only if a manager approves it. +

+ + Reason for closing within lock-in + + {{ lockinReason.length }}/250 + +
+ } + +

+ @if (withinLockin) { + Once approved, this closes the account. It cannot be reopened. + } @else { + This closes the account. It cannot be reopened. + } +

+
+ + + + + diff --git a/src/app/deposits/party-master/premature-close-dialog/premature-close-dialog.component.scss b/src/app/deposits/party-master/premature-close-dialog/premature-close-dialog.component.scss new file mode 100644 index 000000000..7d88e158a --- /dev/null +++ b/src/app/deposits/party-master/premature-close-dialog/premature-close-dialog.component.scss @@ -0,0 +1,132 @@ +/** + * Copyright since 2025 Mifos Initiative + * + * This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. + */ + +/* The account as it stands, so the choice below is made against figures rather than from memory. */ +.pcd-summary { + margin: 4px 0 20px; + padding: 12px 14px; + border-radius: 6px; + background: rgb(0 0 0 / 3%); +} + +.pcd-row { + display: flex; + justify-content: space-between; + gap: 16px; + padding: 3px 0; + font-size: 0.85rem; + + span { + color: rgb(0 0 0 / 60%); + } +} + +.pcd-amount { + font-variant-numeric: tabular-nums; +} + +.pcd-question { + margin: 20px 0 10px; + font-size: 0.9rem; + font-weight: 600; +} + +.pcd-choices { + display: flex; + flex-direction: column; + gap: 10px; +} + +/* + * Each answer is a card rather than a bare radio, because the two are not interchangeable - they take + * money from different people, and the consequence has to be readable before the choice is made. + */ +.pcd-choice { + display: flex; + align-items: flex-start; + gap: 4px; + padding: 10px 12px 12px; + border: 1px solid rgb(0 0 0 / 12%); + border-radius: 6px; + cursor: pointer; + + &:hover { + background: rgb(0 0 0 / 2%); + } +} + +.pcd-choice--on { + border-color: #b45309; + background: rgb(180 83 9 / 5%); +} + +.pcd-choice-body { + padding-top: 2px; +} + +.pcd-choice-title { + font-weight: 600; + font-size: 0.88rem; +} + +.pcd-choice-detail { + margin-top: 3px; + font-size: 0.8rem; + line-height: 1.5; + color: rgb(0 0 0 / 65%); +} + +.pcd-destinations { + display: flex; + flex-direction: column; + gap: 6px; +} + +.pcd-field { + width: 100%; + margin-top: 12px; +} + +.pcd-note { + margin: 12px 0 0; + font-size: 0.8rem; + line-height: 1.5; + color: rgb(0 0 0 / 65%); +} + +/* The one step here with no way back, so it is said plainly rather than left to the button colour. */ +.pcd-warn { + margin: 18px 0 0; + padding: 9px 12px; + border-radius: 6px; + border: 1px solid rgb(185 28 28 / 25%); + background: rgb(185 28 28 / 6%); + color: #b91c1c; + font-size: 0.8rem; + font-weight: 500; +} + +/* Inside the lock-in: a request for a manager, not a closure, so it is set apart from the terms above. */ +.pcd-lockin { + margin-top: 18px; + padding: 12px 14px 4px; + border-radius: 8px; + border: 1px solid rgb(180 83 9 / 30%); + background: rgb(245 158 11 / 8%); +} + +.pcd-lockin-title { + margin: 0; + font-size: 0.85rem; + font-weight: 600; + color: #b45309; +} + +.po-hint-warn { + color: #c62828; +} diff --git a/src/app/deposits/party-master/premature-close-dialog/premature-close-dialog.component.ts b/src/app/deposits/party-master/premature-close-dialog/premature-close-dialog.component.ts new file mode 100644 index 000000000..53db816e0 --- /dev/null +++ b/src/app/deposits/party-master/premature-close-dialog/premature-close-dialog.component.ts @@ -0,0 +1,228 @@ +/** + * Copyright since 2025 Mifos Initiative + * + * This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. + */ + +/** Angular Imports */ +import { Component, OnInit, inject } from '@angular/core'; +import { CommonModule } from '@angular/common'; +import { FormsModule } from '@angular/forms'; +import { MAT_DIALOG_DATA, MatDialogModule, MatDialogRef } from '@angular/material/dialog'; +import { MatButtonModule } from '@angular/material/button'; +import { MatRadioModule } from '@angular/material/radio'; +import { MatSelectModule } from '@angular/material/select'; +import { MatAutocompleteModule } from '@angular/material/autocomplete'; +import { MatFormFieldModule } from '@angular/material/form-field'; +import { MatInputModule } from '@angular/material/input'; +import { MatProgressBarModule } from '@angular/material/progress-bar'; + +/** Custom Services */ +import { OrganizationService } from 'app/organization/organization.service'; +import { ProductsService } from 'app/products/products.service'; + +/** Custom Pipes */ +import { DateFormatPipe } from 'app/pipes/date-format.pipe'; + +/** + * Closing a DDS account before its term is up. + * + * A customer who stops saving early costs the bank the interest it had promised on money it no longer + * has for as long as it expected. Somebody has to carry that, and the bank allows two answers - so + * this asks which, and it is the only thing it asks. + * + * What each answer costs is the scheme's, not the clerk's: the reduced rate and the agent's share are + * set once when the scheme is created and shown here as fact. A figure typed at the counter is a + * figure that can be typed differently for the next customer, which is exactly what a published term + * is meant to prevent. + * + * The destination works as it does on a maturity payout - another account of theirs here, or parked + * in matured-deposits payable for a transfer out - because what is left over is still their money and + * has to reach them the same way. + */ +@Component({ + selector: 'mifosx-premature-close-dialog', + standalone: true, + imports: [ + CommonModule, + FormsModule, + MatDialogModule, + MatButtonModule, + MatRadioModule, + MatSelectModule, + MatAutocompleteModule, + MatFormFieldModule, + MatInputModule, + MatProgressBarModule, + DateFormatPipe + ], + templateUrl: './premature-close-dialog.component.html', + styleUrls: ['./premature-close-dialog.component.scss'] +}) +export class PrematureCloseDialogComponent implements OnInit { + private organizationService = inject(OrganizationService); + private productsService = inject(ProductsService); + private dialogRef = inject(MatDialogRef); + data = inject(MAT_DIALOG_DATA); + + /** Who carries the shortfall. No default - the two answers take money from different people. */ + borneBy: 'CUSTOMER' | 'AGENT' | '' = ''; + + /** 'own' moves what is left to another account here; 'other' parks it for a transfer out. */ + destination: 'own' | 'other' = 'own'; + + ownAccounts: any[] = []; + selectedAccountNo = ''; + + /** Where the balance is parked when the customer has no account here. */ + sundryAccounts: any[] = []; + sundryGlAccountId: number | null = null; + /** What is typed in the GL box - a code or part of a name. */ + sundrySearch = ''; + + /** The accounts matching what is typed, by GL code or name, in any case. */ + get sundryOptions(): any[] { + const typed = this.sundrySearch.trim().toLowerCase(); + if (!typed || typed === this.sundryLabel(this.chosenSundry).toLowerCase()) { + return this.sundryAccounts; + } + return this.sundryAccounts.filter( + (account: any) => + String(account.glCode).toLowerCase().includes(typed) || + String(account.name || '') + .toLowerCase() + .includes(typed) + ); + } + + get chosenSundry(): any { + return this.sundryAccounts.find((account: any) => account.id === this.sundryGlAccountId); + } + + /** Shows a picked account in the box as its code and name. */ + readonly sundryDisplay = (value: any): string => + value && typeof value === 'object' ? this.sundryLabel(value) : value || ''; + + sundryLabel(account: any): string { + return account ? `${account.glCode} · ${account.name}` : ''; + } + + pickSundry(account: any): void { + this.sundryGlAccountId = account ? account.id : null; + this.sundrySearch = this.sundryLabel(account); + } + + /** Typing again clears the choice until an account is picked, so a half-typed code is never sent. */ + onSundryTyped(value: any): void { + // Picking from the list hands over the account itself rather than text. + if (value && typeof value === 'object') { + this.pickSundry(value); + return; + } + value = String(value ?? ''); + this.sundrySearch = value; + if (value !== this.sundryLabel(this.chosenSundry)) { + const exact = this.sundryAccounts.find( + (account: any) => String(account.glCode).toLowerCase() === value.trim().toLowerCase() + ); + this.sundryGlAccountId = exact ? exact.id : null; + } + } + loading = true; + + /** + * The scheme's terms, shown so the clerk can tell the customer what each answer costs before + * choosing. Null while loading, and left null if the scheme cannot be read - the choice is then + * still offered, because the server applies the terms either way and a screen that could not + * display them should not block a closure the customer has asked for. + */ + scheme: any = null; + + ngOnInit(): void { + // The accounts the balance can be parked in. None is chosen: each bank has its own Sundry account, + // and the clerk searches for it and picks it. + this.organizationService.getDdsSundryAccounts().subscribe({ + next: (accounts: any) => (this.sundryAccounts = accounts || []), + error: () => (this.sundryAccounts = []) + }); + this.organizationService.getCreditableAccounts(this.data.clientId).subscribe({ + next: (accounts: any) => { + this.ownAccounts = accounts || []; + if (this.ownAccounts.length === 0) { + this.destination = 'other'; + } else if (this.ownAccounts.length === 1) { + this.selectedAccountNo = this.ownAccounts[0].accountNo; + } + this.loading = false; + }, + error: () => { + this.ownAccounts = []; + this.destination = 'other'; + this.loading = false; + } + }); + + // The terms are read from the scheme the account runs on, so what is shown here is what the + // server will actually charge rather than a second copy that can drift from it. Matched on the + // code the party carries, which is the only handle it has on its scheme. + this.productsService.getDdsAccountTypes().subscribe({ + next: (schemes: any) => { + const all = Array.isArray(schemes) ? schemes : schemes?.pageItems || []; + this.scheme = all.find((s: any) => s.accountTypeCode === this.data.subType) || null; + }, + error: () => (this.scheme = null) + }); + } + + /** The rate the customer would be left on if they carry it. Never below nothing. */ + get reducedRate(): number { + const rate = Number(this.data.interestRate || 0); + const reduction = Number(this.scheme?.prematureInterestReduction || 0); + return Math.max(0, rate - reduction); + } + + /** Why the lock-in should be waived for this account - required when it is inside it. */ + lockinReason = ''; + + /** + * The day the scheme's lock-in ends for this account, or null when the scheme has none. Inside it + * the account cannot be closed early on the scheme's terms; it can only be asked for, with a reason, + * for a manager to approve - the scheme's lock-in itself is never changed to let one account through. + */ + get closableFrom(): Date | null { + const months = Number(this.scheme?.prematureLockinMonths || 0); + const opened = this.data.openingDate ? new Date(this.data.openingDate) : null; + if (months <= 0 || !opened || isNaN(opened.getTime())) { + return null; + } + const closable = new Date(opened); + closable.setMonth(closable.getMonth() + months); + return closable; + } + + get withinLockin(): boolean { + const closable = this.closableFrom; + return !!closable && closable.getTime() > Date.now(); + } + + get canClose(): boolean { + if (this.loading || !this.borneBy) { + return false; + } + if (this.withinLockin && !this.lockinReason.trim()) { + return false; + } + return this.destination === 'other' ? !!this.sundryGlAccountId : !!this.selectedAccountNo; + } + + close(): void { + this.dialogRef.close({ + shortfallBorneBy: this.borneBy, + ...(this.destination === 'own' && this.selectedAccountNo ? { toSavingsAccountNo: this.selectedAccountNo } : {}), + ...(this.destination === 'other' && this.sundryGlAccountId ? { sundryGlAccountId: this.sundryGlAccountId } : {}), + ...(this.withinLockin ? { lockinOverrideReason: this.lockinReason.trim() } : {}) + }); + } +} diff --git a/src/app/deposits/recurring-deposits/create-recurring-deposits-account/create-recurring-deposits-account.component.ts b/src/app/deposits/recurring-deposits/create-recurring-deposits-account/create-recurring-deposits-account.component.ts index d68fd0229..f7aae019d 100644 --- a/src/app/deposits/recurring-deposits/create-recurring-deposits-account/create-recurring-deposits-account.component.ts +++ b/src/app/deposits/recurring-deposits/create-recurring-deposits-account/create-recurring-deposits-account.component.ts @@ -195,6 +195,13 @@ export class CreateRecurringDepositsAccountComponent { }); return; } + if (this.recurringDepositsAccountProductTemplate?.introducerRequired && !detailsStep?.introducerId) { + this.alertService.alert({ + type: 'Error', + message: 'This product requires an introducer. Please choose an existing customer as introducer before submitting.' + }); + return; + } if (!this.selectedTenureValue || this.selectedTenureFrequencyId === null) { this.alertService.alert({ type: 'Error', @@ -209,6 +216,39 @@ export class CreateRecurringDepositsAccountComponent { }); return; } + // Same limits the server enforces - checked here so the message names the numbers before anything is sent. + const minInstallment = Number(this.recurringDepositsAccountProductTemplate?.minDepositAmount) || 0; + const maxInstallment = Number(this.recurringDepositsAccountProductTemplate?.maxDepositAmount) || 0; + if (minInstallment > 0 && this.selectedDepositAmount < minInstallment) { + this.alertService.alert({ + type: 'Error', + message: `Installment ${this.selectedDepositAmount} is below this product's minimum of ${minInstallment}.` + }); + return; + } + if (maxInstallment > 0 && this.selectedDepositAmount > maxInstallment) { + this.alertService.alert({ + type: 'Error', + message: `Installment ${this.selectedDepositAmount} is above this product's maximum of ${maxInstallment}.` + }); + return; + } + if ( + this.recurringDepositsAccountCurrencyStep?.autoDebitFacility && + detailsStep?.selectedLinkedAccount?.accountType !== 'SAVINGS' + ) { + this.alertService.alert({ + type: 'Error', + message: + 'Auto Debit collects each installment from the linked account. Link a Savings account under Deposit Account Link, or turn Auto Debit off.' + }); + return; + } + const lienError = this.recurringDepositsAccountCurrencyStep?.lienPercentageError; + if (lienError) { + this.alertService.alert({ type: 'Error', message: `Loan against this RD: ${lienError}` }); + return; + } if (!detailsStep?.selectedCustomerSegment) { this.alertService.alert({ type: 'Error', message: 'Please select a Customer Segment before submitting.' }); return; @@ -255,8 +295,16 @@ export class CreateRecurringDepositsAccountComponent { isCalendarInherited: pt?.isCalendarInherited ?? false, isMandatoryDeposit: pt?.isMandatoryDeposit ?? false, allowWithdrawal: pt?.allowWithdrawal ?? false, - adjustAdvanceTowardsFuturePayments: pt?.adjustAdvanceTowardsFuturePayments ?? false, - autoDebitFacility: currencyStep?.autoDebitFacility ?? pt?.autoDebitFacility ?? false + // Money paid ahead of a due date counts towards the next installments. Without this an advance cash + // payment stayed unallocated: the schedule kept showing the installment unpaid, so Auto Debit collected it + // a second time and the missed-installment penalty charged it. The RD product form has no such option, so + // the product's value was always false. + adjustAdvanceTowardsFuturePayments: true, + autoDebitFacility: currencyStep?.autoDebitFacility ?? pt?.autoDebitFacility ?? false, + // Lien Marked products only - the server ignores it otherwise and caps it at the product's limit. + ...(currencyStep?.lienAllowed && currencyStep?.lienPercentage != null + ? { lienPercentage: Number(currencyStep.lienPercentage) } + : {}) }; // Charges from product template diff --git a/src/app/deposits/recurring-deposits/edit-recurring-deposit-account/edit-recurring-deposit-account.component.ts b/src/app/deposits/recurring-deposits/edit-recurring-deposit-account/edit-recurring-deposit-account.component.ts index 7b5bb33f3..e5662cebe 100644 --- a/src/app/deposits/recurring-deposits/edit-recurring-deposit-account/edit-recurring-deposit-account.component.ts +++ b/src/app/deposits/recurring-deposits/edit-recurring-deposit-account/edit-recurring-deposit-account.component.ts @@ -233,6 +233,11 @@ export class EditRecurringDepositAccountComponent { }); return; } + const lienError = this.recurringDepositsAccountCurrencyStep?.lienPercentageError; + if (lienError) { + this.alertService.alert({ type: 'Error', message: `Loan against this RD: ${lienError}` }); + return; + } if (!detailsStep?.selectedCustomerSegment) { this.alertService.alert({ type: 'Error', message: 'Please select a Customer Segment before submitting.' }); return; @@ -275,8 +280,13 @@ export class EditRecurringDepositAccountComponent { isCalendarInherited: pt?.isCalendarInherited ?? false, isMandatoryDeposit: pt?.isMandatoryDeposit ?? false, allowWithdrawal: pt?.allowWithdrawal ?? false, - adjustAdvanceTowardsFuturePayments: pt?.adjustAdvanceTowardsFuturePayments ?? false, - autoDebitFacility: currencyStep?.autoDebitFacility ?? pt?.autoDebitFacility ?? false + // See create-recurring-deposits-account: advance payments must count towards the next installments. + adjustAdvanceTowardsFuturePayments: true, + autoDebitFacility: currencyStep?.autoDebitFacility ?? pt?.autoDebitFacility ?? false, + // Lien Marked products only - the server ignores it otherwise and caps it at the product's limit. + ...(currencyStep?.lienAllowed && currencyStep?.lienPercentage != null + ? { lienPercentage: Number(currencyStep.lienPercentage) } + : {}) }; const rawCharges: any[] = pt?.charges ?? []; diff --git a/src/app/deposits/recurring-deposits/recurring-deposits-account-actions/activate-recurring-deposits-account/activate-recurring-deposits-account.component.ts b/src/app/deposits/recurring-deposits/recurring-deposits-account-actions/activate-recurring-deposits-account/activate-recurring-deposits-account.component.ts index 018aa2090..99ff4923d 100644 --- a/src/app/deposits/recurring-deposits/recurring-deposits-account-actions/activate-recurring-deposits-account/activate-recurring-deposits-account.component.ts +++ b/src/app/deposits/recurring-deposits/recurring-deposits-account-actions/activate-recurring-deposits-account/activate-recurring-deposits-account.component.ts @@ -70,8 +70,9 @@ export class ActivateRecurringDepositsAccountComponent implements OnInit { */ createActivateRecurringDepositsAccountForm() { this.activateRecurringDepositsAccountForm = this.formBuilder.group({ + // Defaults to the business date (the branch's working day), which is what the account is normally activated on. activatedOnDate: [ - '', + this.settingsService.businessDate, Validators.required ] }); diff --git a/src/app/deposits/recurring-deposits/recurring-deposits-account-actions/approve-recurring-deposits-account/approve-recurring-deposits-account.component.ts b/src/app/deposits/recurring-deposits/recurring-deposits-account-actions/approve-recurring-deposits-account/approve-recurring-deposits-account.component.ts index 847aa80e5..c01022d29 100644 --- a/src/app/deposits/recurring-deposits/recurring-deposits-account-actions/approve-recurring-deposits-account/approve-recurring-deposits-account.component.ts +++ b/src/app/deposits/recurring-deposits/recurring-deposits-account-actions/approve-recurring-deposits-account/approve-recurring-deposits-account.component.ts @@ -72,8 +72,9 @@ export class ApproveRecurringDepositsAccountComponent implements OnInit { */ createApproveRecurringDepositsAccountForm() { this.approveRecurringDepositsAccountForm = this.formBuilder.group({ + // Defaults to the business date (the branch's working day), which is what the account is normally approved on. approvedOnDate: [ - '', + this.settingsService.businessDate, Validators.required ], note: [''] diff --git a/src/app/deposits/recurring-deposits/recurring-deposits-account-actions/close-recurring-deposits-account/close-recurring-deposits-account.component.html b/src/app/deposits/recurring-deposits/recurring-deposits-account-actions/close-recurring-deposits-account/close-recurring-deposits-account.component.html index 4608b5edd..2eb3956b4 100644 --- a/src/app/deposits/recurring-deposits/recurring-deposits-account-actions/close-recurring-deposits-account/close-recurring-deposits-account.component.html +++ b/src/app/deposits/recurring-deposits/recurring-deposits-account-actions/close-recurring-deposits-account/close-recurring-deposits-account.component.html @@ -13,6 +13,16 @@

{{ title }}

+ @if (totalLienAmount > 0) { +

+ This deposit is pledged as loan security (lien {{ totalLienAmount | number: '1.2-2' }} - + @for (lien of liens; track lien.loanId; let last = $last) { + loan {{ lien.loanAccountNo }}{{ last ? '' : ', ' }} + } + ). It cannot be closed until the loan is repaid or the guarantee is released. +

+ } + {{ 'labels.inputs.Closed On' | translate }} {{ 'labels.buttons.Cancel' | translate }} - diff --git a/src/app/deposits/recurring-deposits/recurring-deposits-account-actions/close-recurring-deposits-account/close-recurring-deposits-account.component.scss b/src/app/deposits/recurring-deposits/recurring-deposits-account-actions/close-recurring-deposits-account/close-recurring-deposits-account.component.scss index 6190cc4ca..973742d68 100644 --- a/src/app/deposits/recurring-deposits/recurring-deposits-account-actions/close-recurring-deposits-account/close-recurring-deposits-account.component.scss +++ b/src/app/deposits/recurring-deposits/recurring-deposits-account-actions/close-recurring-deposits-account/close-recurring-deposits-account.component.scss @@ -10,6 +10,12 @@ max-width: 37rem; } +.lien-warn { + margin: 0 0 16px; + font-size: 13px; + color: #c62828; +} + .expandcollapsebutton { margin-top: -7px; margin-left: 2%; diff --git a/src/app/deposits/recurring-deposits/recurring-deposits-account-actions/close-recurring-deposits-account/close-recurring-deposits-account.component.ts b/src/app/deposits/recurring-deposits/recurring-deposits-account-actions/close-recurring-deposits-account/close-recurring-deposits-account.component.ts index 05fcb06a5..29b166089 100644 --- a/src/app/deposits/recurring-deposits/recurring-deposits-account-actions/close-recurring-deposits-account/close-recurring-deposits-account.component.ts +++ b/src/app/deposits/recurring-deposits/recurring-deposits-account-actions/close-recurring-deposits-account/close-recurring-deposits-account.component.ts @@ -64,6 +64,9 @@ export class CloseRecurringDepositsAccountComponent implements OnInit { maxDate = new Date(); /** Close Recurring Deposit Account form. */ closeRecurringDepositForm: UntypedFormGroup; + /** Loans this deposit secures. While any remain, the server refuses to close it. */ + liens: any[] = []; + totalLienAmount = 0; /** * Retrieves action details template data from `resolve` @@ -79,6 +82,11 @@ export class CloseRecurringDepositsAccountComponent implements OnInit { this.maturityAmount = data.recurringDepositsAccountActionData.maturityAmount; this.onAccountClosureOptions = data.recurringDepositsAccountActionData.onAccountClosureOptions; this.paymentTypes = data.recurringDepositsAccountActionData.paymentTypeOptions; + // A matured deposit closes on or after its maturity date - the server refuses anything earlier. + const maturityDate = data.recurringDepositsAccountActionData.maturityDate; + if (Array.isArray(maturityDate) && maturityDate.length >= 3) { + this.minDate = new Date(maturityDate[0], maturityDate[1] - 1, maturityDate[2]); + } if (data.recurringDepositsAccountActionData.maturityAmount) { this.title = 'Recurring Deposit Closure on Maturity'; } else { @@ -92,6 +100,10 @@ export class CloseRecurringDepositsAccountComponent implements OnInit { this.maxDate = this.settingsService.businessDate; this.createcloseRecurringDepositForm(); this.addTransferDetails(); + this.recurringDepositsService.getRecurringDepositLiens(this.accountId).subscribe((response: any) => { + this.liens = response?.liens ?? []; + this.totalLienAmount = Number(response?.totalLienAmount) || 0; + }); } /** @@ -99,8 +111,10 @@ export class CloseRecurringDepositsAccountComponent implements OnInit { */ createcloseRecurringDepositForm() { this.closeRecurringDepositForm = this.formBuilder.group({ + // The business date, not the computer's clock: the branch's working day can differ from the calendar day, + // and closing on the clock's date (before maturity, or before the deposit's last installment) fails. closedOnDate: [ - new Date(), + this.settingsService.businessDate, Validators.required ], maturityAmount: [{ value: this.maturityAmount, disabled: true }], diff --git a/src/app/deposits/recurring-deposits/recurring-deposits-account-actions/premature-close-recurring-deposit-account/premature-close-recurring-deposit-account.component.html b/src/app/deposits/recurring-deposits/recurring-deposits-account-actions/premature-close-recurring-deposit-account/premature-close-recurring-deposit-account.component.html index 1abafde16..5ecee58b8 100644 --- a/src/app/deposits/recurring-deposits/recurring-deposits-account-actions/premature-close-recurring-deposit-account/premature-close-recurring-deposit-account.component.html +++ b/src/app/deposits/recurring-deposits/recurring-deposits-account-actions/premature-close-recurring-deposit-account/premature-close-recurring-deposit-account.component.html @@ -11,6 +11,51 @@
+ @if (account) { +
+
+ Account{{ account.accountNo }} +
+
+ Current balance{{ currencySymbol }} {{ balance | number: '1.2-2' }} +
+ @if (account.maturityDate) { +
+ Maturity date{{ account.maturityDate | dateFormat }} +
+ } + @if (account.maturityAmount != null) { +
+ Amount at maturity{{ currencySymbol }} {{ account.maturityAmount | number: '1.2-2' }} +
+ } +
+ Pre-closure penalty + + @if (account.preClosurePenalApplicable) { + {{ account.preClosurePenalInterest }}% less interest + @if (account.preClosurePenalInterestOnType?.value) { + ({{ account.preClosurePenalInterestOnType.value }}) + } + } @else { + None + } + +
+
+ } + + @if (totalLienAmount > 0) { +

+ This deposit is pledged as loan security (lien {{ currencySymbol }} + {{ totalLienAmount | number: '1.2-2' }} - + @for (lien of liens; track lien.loanId; let last = $last) { + loan {{ lien.loanAccountNo }}{{ last ? '' : ', ' }} + } + ). It cannot be closed until the loan is repaid or the guarantee is released. +

+ } + {{ 'labels.inputs.Closed On' | translate }} + Must be before the maturity date - on or after it, use Close instead. @if (prematureCloseRecurringDepositsAccountForm.controls.closedOnDate.hasError('required')) { {{ 'labels.inputs.Closed On Date' | translate }} {{ 'labels.commons.is' | translate }} @@ -30,6 +76,89 @@ } + + @if (reasonOptions.length > 1) { + + Closure reason + + @for (reason of reasonOptions; track reason.id) { + {{ reason.label }} + } + + {{ selectedReasonHint }} + + } + +
+ +
+ + @if (preview) { +
+
+ Payable on premature closure + {{ currencySymbol }} {{ preview.payoutAmount | number: '1.2-2' }} +
+ @if (shortfallVsMaturity != null && shortfallVsMaturity > 0) { +
+ Less than at maturity by + {{ currencySymbol }} {{ shortfallVsMaturity | number: '1.2-2' }} +
+ } +

+ Paid by transfer to the client's savings account - a recurring deposit cannot be broken for cash. +

+
+ + @if (destinationAccounts.length === 0) { +

+ This client has no active savings account to receive the proceeds. Open one first, then close this + deposit. +

+ } @else { + + {{ 'labels.inputs.Transfer to Savings' | translate }} + + @for (savings of destinationAccounts; track savings.id) { + + {{ savings.accountNo }} - {{ savings.productName }} + @if (savings.owner) { + (nominee: {{ savings.owner }}) + } @else if (savings.id === account?.linkedAccountId) { + (linked) + } + + } + + @if (prematureCloseRecurringDepositsAccountForm.controls.toSavingsAccountId.hasError('required')) { + + {{ 'labels.inputs.Transfer to savings account' | translate }} {{ 'labels.commons.is' | translate }} + {{ 'labels.commons.required' | translate }} + + } + + + + {{ 'labels.inputs.Transfer Description' | translate }} + + + + + {{ 'labels.inputs.Note' | translate }} + + + } + }
@@ -37,8 +166,8 @@ -
diff --git a/src/app/deposits/recurring-deposits/recurring-deposits-account-actions/premature-close-recurring-deposit-account/premature-close-recurring-deposit-account.component.scss b/src/app/deposits/recurring-deposits/recurring-deposits-account-actions/premature-close-recurring-deposit-account/premature-close-recurring-deposit-account.component.scss index 7c3f1555b..e63e5c2b3 100644 --- a/src/app/deposits/recurring-deposits/recurring-deposits-account-actions/premature-close-recurring-deposit-account/premature-close-recurring-deposit-account.component.scss +++ b/src/app/deposits/recurring-deposits/recurring-deposits-account-actions/premature-close-recurring-deposit-account/premature-close-recurring-deposit-account.component.scss @@ -9,3 +9,45 @@ .container { max-width: 37rem; } + +.pc-context, +.pc-result { + display: flex; + flex-direction: column; + gap: 6px; + padding: 12px 14px; + margin-bottom: 16px; + border: 1px solid rgb(128 128 128 / 30%); + border-radius: 6px; +} + +.pc-row { + display: flex; + justify-content: space-between; + gap: 12px; + font-size: 13px; + + &--total { + font-size: 15px; + } + + &--muted { + opacity: 0.75; + } +} + +.pc-calc { + margin: 4px 0 16px; +} + +.pc-note { + margin: 4px 0 0; + font-size: 12px; + opacity: 0.75; +} + +.pc-warn { + margin: 0 0 16px; + font-size: 13px; + color: #c62828; +} diff --git a/src/app/deposits/recurring-deposits/recurring-deposits-account-actions/premature-close-recurring-deposit-account/premature-close-recurring-deposit-account.component.ts b/src/app/deposits/recurring-deposits/recurring-deposits-account-actions/premature-close-recurring-deposit-account/premature-close-recurring-deposit-account.component.ts index 0590a2ba0..fdaa472d2 100644 --- a/src/app/deposits/recurring-deposits/recurring-deposits-account-actions/premature-close-recurring-deposit-account/premature-close-recurring-deposit-account.component.ts +++ b/src/app/deposits/recurring-deposits/recurring-deposits-account-actions/premature-close-recurring-deposit-account/premature-close-recurring-deposit-account.component.ts @@ -8,25 +8,41 @@ /** Angular Imports */ import { Component, OnInit, inject } from '@angular/core'; -import { UntypedFormGroup, UntypedFormBuilder, Validators, ReactiveFormsModule } from '@angular/forms'; -import { ActivatedRoute, Router, RouterLink } from '@angular/router'; +import { UntypedFormGroup, UntypedFormBuilder, Validators } from '@angular/forms'; +import { ActivatedRoute, Router } from '@angular/router'; +import { CdkTextareaAutosize } from '@angular/cdk/text-field'; /** Custom Services */ import { RecurringDepositsService } from '../../recurring-deposits.service'; +import { ClientsService } from 'app/clients/clients.service'; import { SettingsService } from 'app/settings/settings.service'; import { Dates } from 'app/core/utils/dates'; import { STANDALONE_SHARED_IMPORTS } from 'app/standalone-shared.module'; +/** DepositAccountOnClosureType.TRANSFER_TO_SAVINGS - the only way a broken RD may be paid out. */ +const TRANSFER_TO_SAVINGS = 200; + +/** PrematureClosureReasonType values the RD closure understands. */ +const REASON_NORMAL = 100; +const REASON_DECEASED = 400; +const REASON_NOMINEE_HEIR = 500; + /** * Premature Close Recurring Deposits Account Component + * + * The server will only break a recurring deposit early by transferring the proceeds to one of the client's savings + * accounts (no cash payout, no reinvest), and it needs to be told so: onAccountClosureId and toSavingsAccountId are + * mandatory. This screen used to send only the closure date, so every premature closure was rejected. It now asks the + * server what the deposit would pay on the chosen date (calculatePrematureAmount, which applies the product's + * pre-closure penalty) and only lets the closure be confirmed against that exact figure. */ - @Component({ selector: 'mifosx-premature-close-recurring-deposit-account', templateUrl: './premature-close-recurring-deposit-account.component.html', styleUrls: ['./premature-close-recurring-deposit-account.component.scss'], imports: [ - ...STANDALONE_SHARED_IMPORTS + ...STANDALONE_SHARED_IMPORTS, + CdkTextareaAutosize ] }) export class PrematureCloseRecurringDepositAccountComponent implements OnInit { @@ -36,72 +52,263 @@ export class PrematureCloseRecurringDepositAccountComponent implements OnInit { private route = inject(ActivatedRoute); private router = inject(Router); private settingsService = inject(SettingsService); + private clientsService = inject(ClientsService); - /** Minimum date allowed. */ + /** Savings accounts of this deposit's nominees - offered as destinations only for a deceased holder. */ + nomineeSavingsAccounts: any[] = []; + + /** Minimum date allowed: the deposit's activation date once loaded. */ minDate = new Date(2000, 0, 1); - /** Maximum date allowed. */ + /** Maximum date allowed: the business date, and never the maturity date or later (that is a normal Close). */ maxDate = new Date(); /** premature close Recurring Deposits Account form. */ prematureCloseRecurringDepositsAccountForm: UntypedFormGroup; /** Recurring Deposits Account Id */ accountId: any; + /** The deposit being closed, for the context panel and the default destination account. */ + account: any; + + /** Result of the last calculatePrematureAmount call, and the date it was calculated for. */ + preview: { payoutAmount: number; savingsAccounts: any[]; closureOptionId: number } | null = null; + previewedFor: string | null = null; + calculating = false; + submitting = false; + /** Loans this deposit secures. While any remain, the server refuses to close it. */ + liens: any[] = []; + totalLienAmount = 0; - /** - * @param {FormBuilder} formBuilder Form Builder - * @param {RecurringDepositsService} recurringDepositsService Recurring Deposits Service - * @param {Dates} dateUtils Date Utils - * @param {ActivatedRoute} route Activated Route - * @param {Router} router Router - * @param {SettingsService} settingsService Settings Service - */ constructor() { this.accountId = this.route.parent.snapshot.params['recurringDepositAccountId']; } - /** - * Creates the premature close recurring deposits form. - */ ngOnInit() { this.maxDate = this.settingsService.businessDate; this.createprematureCloseRecurringDepositsAccountForm(); - } - - /** - * Creates the premature close recurring deposits account form. - */ - createprematureCloseRecurringDepositsAccountForm() { - this.prematureCloseRecurringDepositsAccountForm = this.formBuilder.group({ - closedOnDate: [ - '', - Validators.required - ] + this.recurringDepositsService.getRecurringDepositsAccountData(this.accountId).subscribe((account: any) => { + this.account = account; + const activatedOn = this.toDate(account?.timeline?.activatedOnDate); + if (activatedOn) { + this.minDate = activatedOn; + } + const maturity = this.toDate(account?.maturityDate); + if (maturity) { + const dayBeforeMaturity = new Date(maturity); + dayBeforeMaturity.setDate(dayBeforeMaturity.getDate() - 1); + if (dayBeforeMaturity < this.maxDate) { + this.maxDate = dayBeforeMaturity; + } + } + }); + this.recurringDepositsService.getRecurringDepositLiens(this.accountId).subscribe((response: any) => { + this.liens = response?.liens ?? []; + this.totalLienAmount = Number(response?.totalLienAmount) || 0; + }); + // A different date or reason is a different payout - the old preview no longer describes what would happen. + this.prematureCloseRecurringDepositsAccountForm.get('closedOnDate').valueChanges.subscribe(() => { + this.preview = null; + this.previewedFor = null; + }); + this.prematureCloseRecurringDepositsAccountForm.get('prematureClosureReasonId').valueChanges.subscribe((reason: number) => { + this.preview = null; + this.previewedFor = null; + if (reason === REASON_DECEASED) { + this.loadNomineeSavingsAccounts(); + } }); } - /** - * Submits the form and premature closes the recurring deposit account, - * if successful redirects to the recurring deposit account. - */ - submit() { - const prematureCloseRecurringDepositsAccountFormData = this.prematureCloseRecurringDepositsAccountForm.value; - const locale = this.settingsService.language.code; - const dateFormat = this.settingsService.dateFormat; - const prevClosedOnDate: Date = this.prematureCloseRecurringDepositsAccountForm.value.closedOnDate; - if (prematureCloseRecurringDepositsAccountFormData.closedOnDate instanceof Date) { - prematureCloseRecurringDepositsAccountFormData.closedOnDate = this.dateUtils.formatDate( - prevClosedOnDate, - dateFormat - ); + /** Reasons this deposit's product allows; Normal is always there. */ + get reasonOptions(): Array<{ id: number; label: string; hint: string }> { + const options = [{ id: REASON_NORMAL, label: 'Normal', hint: "Pre-closure penalty as per the deposit's terms." }]; + if (this.account?.noPenaltyNomineeHeirClosure) { + options.push({ id: REASON_NOMINEE_HEIR, label: 'Nominee / legal heir claim', hint: 'No pre-closure penalty.' }); } - const data = { - ...prematureCloseRecurringDepositsAccountFormData, - dateFormat, - locale - }; - this.recurringDepositsService - .executeRecurringDepositsAccountCommand(this.accountId, 'prematureClose', data) - .subscribe(() => { - this.router.navigate(['../../'], { relativeTo: this.route }); + if (this.account?.deceasedSettlement) { + options.push({ + id: REASON_DECEASED, + label: 'Account holder deceased', + hint: "No penalty, allowed inside lock-in; may be paid to a nominee's savings account." }); + } + return options; + } + + get selectedReasonHint(): string { + const reason = this.prematureCloseRecurringDepositsAccountForm.value.prematureClosureReasonId; + return this.reasonOptions.find((option) => option.id === reason)?.hint ?? ''; + } + + /** The client's own savings accounts, plus - for a deceased holder - the nominees' savings accounts. */ + get destinationAccounts(): any[] { + const own = (this.preview?.savingsAccounts ?? []).map((acc: any) => ({ ...acc, owner: null })); + if (this.prematureCloseRecurringDepositsAccountForm.value.prematureClosureReasonId !== REASON_DECEASED) { + return own; + } + const ownIds = new Set(own.map((acc: any) => acc.id)); + return [ + ...own, + ...this.nomineeSavingsAccounts.filter((acc: any) => !ownIds.has(acc.id)) + ]; + } + + private loadNomineeSavingsAccounts() { + this.nomineeSavingsAccounts = []; + const clientId = this.account?.clientId; + if (!clientId || !this.account?.accountNo) { + return; + } + this.clientsService.getClientNominees(clientId, this.account.accountNo, 'RD').subscribe((nominees: any) => { + const list: any[] = Array.isArray(nominees) ? nominees : (nominees?.pageItems ?? []); + list + .filter((nominee: any) => nominee?.nomineeClientId && nominee.isActive !== false) + .forEach((nominee: any) => { + this.clientsService.getClientAccountData(nominee.nomineeClientId).subscribe((accounts: any) => { + const savings = (accounts?.savingsAccounts ?? []).filter( + (acc: any) => acc?.depositType?.id === 100 && acc?.status?.active + ); + this.nomineeSavingsAccounts = [ + ...this.nomineeSavingsAccounts, + ...savings.map((acc: any) => ({ + id: acc.id, + accountNo: acc.accountNo, + productName: acc.productName, + owner: nominee.nomineeDisplayName + })) + ]; + }); + }); + }); + } + + createprematureCloseRecurringDepositsAccountForm() { + this.prematureCloseRecurringDepositsAccountForm = this.formBuilder.group({ + closedOnDate: [ + this.settingsService.businessDate, + Validators.required + ], + prematureClosureReasonId: [REASON_NORMAL], + toSavingsAccountId: [ + null, + Validators.required + ], + transferDescription: ['Premature closure of recurring deposit'], + note: [''] + }); + } + + get balance(): number | null { + return this.account?.summary?.accountBalance ?? null; + } + + get currencySymbol(): string { + return this.account?.currency?.displaySymbol || this.account?.currency?.code || ''; + } + + /** What breaking early costs compared with letting the deposit run to maturity, when both are known. */ + get shortfallVsMaturity(): number | null { + const maturityAmount = this.account?.maturityAmount; + if (!this.preview || maturityAmount == null) { + return null; + } + return Number(maturityAmount) - Number(this.preview.payoutAmount); + } + + get canConfirm(): boolean { + return ( + this.totalLienAmount <= 0 && + !!this.preview && + this.previewedFor === this.formattedClosedOnDate && + this.prematureCloseRecurringDepositsAccountForm.valid && + !this.submitting + ); + } + + private get formattedClosedOnDate(): string | null { + const value = this.prematureCloseRecurringDepositsAccountForm.value.closedOnDate; + if (!value) { + return null; + } + return value instanceof Date ? this.dateUtils.formatDate(value, this.settingsService.dateFormat) : value; + } + + /** Asks the server what the deposit pays if broken on the chosen date. */ + calculate() { + const closedOnDate = this.formattedClosedOnDate; + if (!closedOnDate) { + return; + } + this.calculating = true; + const data = { + closedOnDate, + prematureClosureReasonId: this.prematureCloseRecurringDepositsAccountForm.value.prematureClosureReasonId, + dateFormat: this.settingsService.dateFormat, + locale: this.settingsService.language.code + }; + this.recurringDepositsService.executeRecurringDepositsAccountCommand(this.accountId, 'calculatePrematureAmount', data).subscribe({ + next: (response: any) => { + const savingsAccounts = response?.savingsAccounts ?? []; + const closureOptionId = + (response?.onAccountClosureOptions ?? []).find((option: any) => option.id === TRANSFER_TO_SAVINGS)?.id ?? + TRANSFER_TO_SAVINGS; + this.preview = { payoutAmount: response?.maturityAmount, savingsAccounts, closureOptionId }; + this.previewedFor = closedOnDate; + this.calculating = false; + this.selectDefaultDestination(savingsAccounts); + }, + error: () => { + this.calculating = false; + } + }); + } + + /** The deposit's own linked savings account, when it has one the client can still receive into. */ + private selectDefaultDestination(savingsAccounts: any[]) { + const control = this.prematureCloseRecurringDepositsAccountForm.get('toSavingsAccountId'); + if (control.value && savingsAccounts.some((acc: any) => acc.id === control.value)) { + return; + } + const linkedId = this.account?.linkedAccountId; + const linked = savingsAccounts.find((acc: any) => acc.id === linkedId); + control.setValue(linked?.id ?? (savingsAccounts.length === 1 ? savingsAccounts[0].id : null)); + } + + submit() { + if (!this.canConfirm) { + return; + } + const formValue = this.prematureCloseRecurringDepositsAccountForm.value; + const data: any = { + closedOnDate: this.formattedClosedOnDate, + onAccountClosureId: this.preview.closureOptionId, + prematureClosureReasonId: formValue.prematureClosureReasonId, + toSavingsAccountId: formValue.toSavingsAccountId, + transferDescription: formValue.transferDescription, + dateFormat: this.settingsService.dateFormat, + locale: this.settingsService.language.code + }; + if (formValue.note) { + data.note = formValue.note; + } + this.submitting = true; + this.recurringDepositsService.executeRecurringDepositsAccountCommand(this.accountId, 'prematureClose', data).subscribe({ + next: () => { + this.router.navigate(['../../'], { relativeTo: this.route }); + }, + error: () => { + this.submitting = false; + } + }); + } + + /** API dates arrive as [yyyy, m, d] arrays or ISO strings. */ + private toDate(value: any): Date | null { + if (!value) { + return null; + } + if (Array.isArray(value)) { + return new Date(value[0], value[1] - 1, value[2]); + } + const parsed = new Date(value); + return isNaN(parsed.getTime()) ? null : parsed; } } diff --git a/src/app/deposits/recurring-deposits/recurring-deposits-account-actions/recurring-deposits-account-actions.component.html b/src/app/deposits/recurring-deposits/recurring-deposits-account-actions/recurring-deposits-account-actions.component.html index cd9a11499..faa045a8d 100644 --- a/src/app/deposits/recurring-deposits/recurring-deposits-account-actions/recurring-deposits-account-actions.component.html +++ b/src/app/deposits/recurring-deposits/recurring-deposits-account-actions/recurring-deposits-account-actions.component.html @@ -30,6 +30,14 @@ @if (actions['Close']) { } -@if (actions['Deposit'] || actions['Withdrawal']) { + +@if (actions['Deposit']) { +

+ RD installments cannot be deposited from the account screen. Use a cashier Receipt, or Auto Debit from the linked + savings account. +

+} +@if (actions['Withdrawal']) { } diff --git a/src/app/deposits/recurring-deposits/recurring-deposits-account-stepper/recurring-deposits-account-currency-step/recurring-deposits-account-currency-step.component.html b/src/app/deposits/recurring-deposits/recurring-deposits-account-stepper/recurring-deposits-account-currency-step/recurring-deposits-account-currency-step.component.html index 37228451c..481f7843a 100644 --- a/src/app/deposits/recurring-deposits/recurring-deposits-account-stepper/recurring-deposits-account-currency-step/recurring-deposits-account-currency-step.component.html +++ b/src/app/deposits/recurring-deposits/recurring-deposits-account-stepper/recurring-deposits-account-currency-step/recurring-deposits-account-currency-step.component.html @@ -30,11 +30,50 @@

Auto Debit Facility

-

Enable automatic deduction of funds for recurring payments, utility bills, or EMI obligations.

+ @if (recurringDepositsAccountProductTemplate?.autoDebitFacility) { +

+ Collect each installment automatically on its due date from the linked Savings account (Deposit Account + Link, Details tab). A short balance leaves it overdue and it is retried on the next run. +

+ } @else { +

This product does not offer Auto Debit.

+ }
- +
+ @if (lienAllowed) { +
+
+

Loan against this RD (Lien)

+

+ How much of the RD balance can be given as loan, checked on the balance at the time the loan is taken. + Product limit: {{ productLienLimit }}%. +

+ @if (lienPercentageError) { +

{{ lienPercentageError }}

+ } +
+ + Max loan (%) + + % + +
+ } +

Dormancy Alert

diff --git a/src/app/deposits/recurring-deposits/recurring-deposits-account-stepper/recurring-deposits-account-currency-step/recurring-deposits-account-currency-step.component.scss b/src/app/deposits/recurring-deposits/recurring-deposits-account-stepper/recurring-deposits-account-currency-step/recurring-deposits-account-currency-step.component.scss index 6f722d291..c84d1edb3 100644 --- a/src/app/deposits/recurring-deposits/recurring-deposits-account-stepper/recurring-deposits-account-currency-step/recurring-deposits-account-currency-step.component.scss +++ b/src/app/deposits/recurring-deposits/recurring-deposits-account-stepper/recurring-deposits-account-currency-step/recurring-deposits-account-currency-step.component.scss @@ -126,6 +126,15 @@ $gray-900: #202124; } } +.racc-lien-field { + width: 140px; + flex-shrink: 0; +} + +.racc-lien-error { + color: #c62828; +} + .racc-alert--editable { border-color: #bae6fd; background: #f0f9ff; diff --git a/src/app/deposits/recurring-deposits/recurring-deposits-account-stepper/recurring-deposits-account-currency-step/recurring-deposits-account-currency-step.component.ts b/src/app/deposits/recurring-deposits/recurring-deposits-account-stepper/recurring-deposits-account-currency-step/recurring-deposits-account-currency-step.component.ts index cb46ab05c..80771e8b4 100644 --- a/src/app/deposits/recurring-deposits/recurring-deposits-account-stepper/recurring-deposits-account-currency-step/recurring-deposits-account-currency-step.component.ts +++ b/src/app/deposits/recurring-deposits/recurring-deposits-account-stepper/recurring-deposits-account-currency-step/recurring-deposits-account-currency-step.component.ts @@ -6,8 +6,9 @@ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ -import { Component, Input, OnChanges, SimpleChanges, ViewChild } from '@angular/core'; +import { Component, Input, OnChanges, SimpleChanges, ViewChild, inject } from '@angular/core'; import { STANDALONE_SHARED_IMPORTS } from 'app/standalone-shared.module'; +import { RecurringDepositsService } from '../../recurring-deposits.service'; import { NomineeFormComponent } from 'app/shared/nominee-form/nominee-form.component'; import { DormancyAlertPanelComponent } from 'app/shared/dormancy-alert-panel/dormancy-alert-panel.component'; import { MatSlideToggle } from '@angular/material/slide-toggle'; @@ -36,7 +37,14 @@ export class RecurringDepositsAccountCurrencyStepComponent implements OnChanges showNomineeForm = false; nomineeError = ''; + private recurringDepositsService = inject(RecurringDepositsService); + autoDebitFacility = false; + /** + * Lien Marked products only: how much of this RD may be given as loan, as % of its balance when the loan is taken. + * Starts at the product's percentage; may be lowered, never raised above it. + */ + lienPercentage: number | null = null; private existingAccountApplied = false; @@ -50,13 +58,44 @@ export class RecurringDepositsAccountCurrencyStepComponent implements OnChanges if (Array.isArray(ea.nominees) && ea.nominees.length > 0) { this.nominees = [...ea.nominees]; } + // The account response does not carry the deposit's own lien percentage; the lien endpoint does. + if (ea.id) { + this.recurringDepositsService.getRecurringDepositLiens(ea.id).subscribe((response: any) => { + this.lienPercentage = response?.lienPercentage ?? response?.productLienPercentage ?? null; + }); + } } if (changes['recurringDepositsAccountProductTemplate']?.currentValue && !this.existingAccountApplied) { const pt = changes['recurringDepositsAccountProductTemplate'].currentValue; this.autoDebitFacility = !!pt.autoDebitFacility; + this.lienPercentage = pt.lienMarked ? (pt.lienPercentage ?? 100) : null; } } + get lienAllowed(): boolean { + return !!this.recurringDepositsAccountProductTemplate?.lienMarked; + } + + /** The product's ceiling; a product with no percentage allows the whole balance. */ + get productLienLimit(): number { + return Number(this.recurringDepositsAccountProductTemplate?.lienPercentage) || 100; + } + + /** Why the entered percentage would be refused, or null when it is fine. */ + get lienPercentageError(): string | null { + if (!this.lienAllowed) { + return null; + } + if (this.lienPercentage === null || this.lienPercentage === undefined || `${this.lienPercentage}` === '') { + return 'Enter how much of the RD balance can be given as loan.'; + } + const value = Number(this.lienPercentage); + if (!(value > 0) || value > this.productLienLimit) { + return `Must be more than 0% and at most ${this.productLienLimit}% (product limit).`; + } + return null; + } + get totalShare(): number { return this.nominees.reduce((sum, n) => sum + (+n.percentageShare || 0), 0); } @@ -112,7 +151,8 @@ export class RecurringDepositsAccountCurrencyStepComponent implements OnChanges get currencyStepData() { return { - autoDebitFacility: this.autoDebitFacility + autoDebitFacility: this.autoDebitFacility, + ...(this.lienAllowed && this.lienPercentage != null ? { lienPercentage: Number(this.lienPercentage) } : {}) }; } } diff --git a/src/app/deposits/recurring-deposits/recurring-deposits-account-stepper/recurring-deposits-account-details-step/recurring-deposits-account-details-step.component.html b/src/app/deposits/recurring-deposits/recurring-deposits-account-stepper/recurring-deposits-account-details-step/recurring-deposits-account-details-step.component.html index 5fd042184..df36a57ef 100644 --- a/src/app/deposits/recurring-deposits/recurring-deposits-account-stepper/recurring-deposits-account-details-step/recurring-deposits-account-details-step.component.html +++ b/src/app/deposits/recurring-deposits/recurring-deposits-account-stepper/recurring-deposits-account-details-step/recurring-deposits-account-details-step.component.html @@ -121,11 +121,30 @@ {{ productTemplate.shareLinkingEnabled ? 'Yes' : 'No' }}
+ +
+ Introducer Required + + {{ productTemplate.introducerRequired ? 'Yes' : 'No' }} + +
+ + @if (productTemplate?.introducerRequired) { + + } + - @if (productTemplate?.shareLinkingEnabled) { + + @if (productTemplate?.shareLinkingEnabled || productTemplate?.autoDebitFacility) {
diff --git a/src/app/deposits/recurring-deposits/recurring-deposits-account-stepper/recurring-deposits-account-details-step/recurring-deposits-account-details-step.component.ts b/src/app/deposits/recurring-deposits/recurring-deposits-account-stepper/recurring-deposits-account-details-step/recurring-deposits-account-details-step.component.ts index 18035cd4b..6db0d9284 100644 --- a/src/app/deposits/recurring-deposits/recurring-deposits-account-stepper/recurring-deposits-account-details-step/recurring-deposits-account-details-step.component.ts +++ b/src/app/deposits/recurring-deposits/recurring-deposits-account-stepper/recurring-deposits-account-details-step/recurring-deposits-account-details-step.component.ts @@ -24,6 +24,7 @@ import { Dates } from 'app/core/utils/dates'; import { MatDialog } from '@angular/material/dialog'; /** Custom Services */ +import { ProductsService } from 'app/products/products.service'; import { RecurringDepositsService } from '../../recurring-deposits.service'; import { ClientsService } from 'app/clients/clients.service'; import { @@ -62,6 +63,7 @@ import { IntroducerSearchComponent } from 'app/shared/introducer-search/introduc export class RecurringDepositsAccountDetailsStepComponent implements OnInit, OnChanges { private formBuilder = inject(UntypedFormBuilder); private recurringDepositsService = inject(RecurringDepositsService); + private productsService = inject(ProductsService); private settingsService = inject(SettingsService); private dateUtils = inject(Dates); private cdr = inject(ChangeDetectorRef); @@ -117,6 +119,10 @@ export class RecurringDepositsAccountDetailsStepComponent implements OnInit, OnC primaryClientName = ''; primaryClientCif = ''; primaryClientId: number | null = null; + /** Introducer - asked for only when the product has "Introducer Required" on. */ + introducerId: number | null = null; + introducerName: string | null = null; + introducerCif: string | null = null; primaryHolderShare = 50; // ── Status of Account (RBI) ──────────────────────────────────────────── @@ -453,6 +459,14 @@ export class RecurringDepositsAccountDetailsStepComponent implements OnInit, OnC } } this.productTemplate = response; + // The account template leaves out the product's installment limits; the calculator and the submit check + // read them from this template, so they are taken from the product itself. + if (response.minDepositAmount == null && response.maxDepositAmount == null) { + this.productsService.getRecurringDepositProduct(String(productId)).subscribe((product: any) => { + response.minDepositAmount = product?.minDepositAmount ?? null; + response.maxDepositAmount = product?.maxDepositAmount ?? null; + }); + } this.accountTypeOptions = this.extractOptions(response.accountTypeOptions, response.accountType); this.customerSegmentOptions = this.extractOptions(response.customerSegmentOptions, response.customerSegment); this.selectedAccountType = response.accountType ?? null; @@ -463,7 +477,7 @@ export class RecurringDepositsAccountDetailsStepComponent implements OnInit, OnC this.recurringDepositsAccountProductTemplate.emit(response); this.fieldOfficerData = response.fieldOfficerOptions; this.isProductSelected = true; - if (response.shareLinkingEnabled) { + if (response.shareLinkingEnabled || response.autoDebitFacility) { this.loadClientAccounts(); } this.recurringDepositAccountDetailsForm.patchValue({ @@ -610,6 +624,12 @@ export class RecurringDepositsAccountDetailsStepComponent implements OnInit, OnC return this.dateUtils.formatDate(dateValue, 'M/d/yyyy') || ''; } + onIntroducerChange(event: { clientId: number; displayName: string; cifNumber?: string } | null): void { + this.introducerId = event?.clientId ?? null; + this.introducerName = event?.displayName ?? null; + this.introducerCif = event?.cifNumber ?? null; + } + onGuardianChange(event: { clientId: number; displayName: string; cifNumber?: string } | null): void { this.guardianClientId = event?.clientId ?? null; this.guardianName = event?.displayName ?? ''; @@ -795,6 +815,12 @@ export class RecurringDepositsAccountDetailsStepComponent implements OnInit, OnC value.minorDateOfBirth = this.minorDob ? this.dateUtils.formatDate(this.minorDob, 'dd MMMM yyyy') : null; } + if (this.productTemplate?.introducerRequired && this.introducerId) { + value.introducerId = this.introducerId; + value.introducerName = this.introducerName; + value.introducerCifNumber = this.introducerCif; + } + if (this.selectedLinkedAccount) { value.linkedAccountId = this.selectedLinkedAccount.id; value.linkedAccountType = this.selectedLinkedAccount.accountType; diff --git a/src/app/deposits/recurring-deposits/recurring-deposits-account-stepper/recurring-deposits-account-interest-rate-chart-step/recurring-deposits-account-interest-rate-chart-step.component.html b/src/app/deposits/recurring-deposits/recurring-deposits-account-stepper/recurring-deposits-account-interest-rate-chart-step/recurring-deposits-account-interest-rate-chart-step.component.html index 237abd8dc..02a49835c 100644 --- a/src/app/deposits/recurring-deposits/recurring-deposits-account-stepper/recurring-deposits-account-interest-rate-chart-step/recurring-deposits-account-interest-rate-chart-step.component.html +++ b/src/app/deposits/recurring-deposits/recurring-deposits-account-stepper/recurring-deposits-account-interest-rate-chart-step/recurring-deposits-account-interest-rate-chart-step.component.html @@ -93,7 +93,13 @@
₹ @@ -257,6 +263,21 @@ {{ matchResult.appliedRate | number: '1.2-2' }}% p.a.
+ @if (matchResult.interestCalculation) { +
+ Interest Type + + {{ matchResult.interestCalculation }} + @if (matchResult.interestCalculation === 'Compounding') { + ({{ interestPeriodLabel(matchResult.compoundingPeriod) }}) + } + +
+
+ Interest Credited + {{ interestPeriodLabel(matchResult.postingPeriod) }} +
+ }
Estimated Interest Earned ₹ {{ matchResult.interestEarned | number: '1.2-2' }} @@ -267,6 +288,23 @@ >₹ {{ matchResult.maturityAmount | number: '1.2-2' }}
+ @if (matchResult.postingSchedule?.length) { +
+ Interest credits to the RD ({{ matchResult.postingSchedule.length }}) + + + + + + @for (p of matchResult.postingSchedule; track p.date) { + + + + + } +
Credited onInterest
{{ p.date }}₹ {{ p.interest | number: '1.2-2' }}
+
+ }
} diff --git a/src/app/deposits/recurring-deposits/recurring-deposits-account-stepper/recurring-deposits-account-interest-rate-chart-step/recurring-deposits-account-interest-rate-chart-step.component.scss b/src/app/deposits/recurring-deposits/recurring-deposits-account-stepper/recurring-deposits-account-interest-rate-chart-step/recurring-deposits-account-interest-rate-chart-step.component.scss index 17f2808e8..7ceee8904 100644 --- a/src/app/deposits/recurring-deposits/recurring-deposits-account-stepper/recurring-deposits-account-interest-rate-chart-step/recurring-deposits-account-interest-rate-chart-step.component.scss +++ b/src/app/deposits/recurring-deposits/recurring-deposits-account-stepper/recurring-deposits-account-interest-rate-chart-step/recurring-deposits-account-interest-rate-chart-step.component.scss @@ -1127,3 +1127,31 @@ $red-light: #fee2e2; font-size: 14px; } } + +.fdrc-credits { + margin-top: 8px; + font-size: 12px; + + summary { + cursor: pointer; + font-weight: 600; + color: #2563eb; + } + + &__table { + width: 100%; + margin-top: 6px; + border-collapse: collapse; + + th, + td { + padding: 3px 6px; + border-bottom: 1px solid #e2e8f0; + text-align: left; + } + + .text-right { + text-align: right; + } + } +} diff --git a/src/app/deposits/recurring-deposits/recurring-deposits-account-stepper/recurring-deposits-account-interest-rate-chart-step/recurring-deposits-account-interest-rate-chart-step.component.ts b/src/app/deposits/recurring-deposits/recurring-deposits-account-stepper/recurring-deposits-account-interest-rate-chart-step/recurring-deposits-account-interest-rate-chart-step.component.ts index 554d9d1ee..a2c5a7411 100644 --- a/src/app/deposits/recurring-deposits/recurring-deposits-account-stepper/recurring-deposits-account-interest-rate-chart-step/recurring-deposits-account-interest-rate-chart-step.component.ts +++ b/src/app/deposits/recurring-deposits/recurring-deposits-account-stepper/recurring-deposits-account-interest-rate-chart-step/recurring-deposits-account-interest-rate-chart-step.component.ts @@ -42,6 +42,7 @@ import { FormatNumberPipe } from '../../../../pipes/format-number.pipe'; import { YesnoPipe } from '../../../../pipes/yesno.pipe'; import { STANDALONE_SHARED_IMPORTS } from 'app/standalone-shared.module'; import { FormsModule } from '@angular/forms'; +import { buildRdChart, tenureInMonths } from 'app/products/recurring-deposit-products/rd-product-chart'; export type RdPeriodUnit = 'Days' | 'Months' | 'Years'; @@ -53,6 +54,12 @@ export interface SlabMatchResult { interestEarned?: number; tenureDays?: number; daysInYear?: number; + /** From the product, as the server calculated it: Simple / Compounding, and the period names. */ + interestCalculation?: string; + compoundingPeriod?: string; + postingPeriod?: string; + /** Each interest credit the deposit will receive, if every installment is paid on time. */ + postingSchedule?: { date: string; interest: number }[]; } /** @@ -123,20 +130,62 @@ export class RecurringDepositsAccountInterestRateChartStepComponent implements O return (this.cbsBaseRates || []).find((r) => r.fdType === 'DOUBLE' && r.isActive); } + /** + * The product's own chart - the one the server prices the RD from - with each slab's senior-citizen rate taken + * from the CBS base chart it was built from. Falls back to the base chart only when the product has no chart. + */ get interestRateChartData(): any[] { - if (this.activeBase?.slabs?.length) { - return this.normalizeCbsSlabs(this.activeBase.slabs); - } + const cbsSlabs: any[] = this.activeBase?.slabs || []; const template = this.recurringDepositsAccountProductTemplate || this.recurringDepositsAccountTemplate; - if (!template) return []; - return ( - template.accountChart?.chartSlabs ?? - template.activeChart?.chartSlabs ?? - template.interestRateCharts?.[0]?.chartSlabs ?? - template.interestRateChart?.chartSlabs ?? - template.chartSlabs ?? - [] - ); + const productSlabs: any[] = + template?.accountChart?.chartSlabs ?? + template?.activeChart?.chartSlabs ?? + template?.interestRateCharts?.[0]?.chartSlabs ?? + template?.interestRateChart?.chartSlabs ?? + template?.chartSlabs ?? + []; + if (this.chartCache?.cbsSlabs !== cbsSlabs || this.chartCache?.productSlabs !== productSlabs) { + this.chartCache = { cbsSlabs, productSlabs, slabs: this.mergeChart(cbsSlabs, productSlabs) }; + } + return this.chartCache.slabs; + } + + private chartCache: { cbsSlabs: any[]; productSlabs: any[]; slabs: any[] } | null = null; + + /** The product's minimum deposit term in months - the server refuses anything shorter. Null when it sets none. */ + /** The product's installment limits (0 = no limit). */ + get minInstallment(): number { + return Number(this.recurringDepositsAccountProductTemplate?.minDepositAmount) || 0; + } + + get maxInstallment(): number { + return Number(this.recurringDepositsAccountProductTemplate?.maxDepositAmount) || 0; + } + + get productMinTenureMonths(): number | null { + const template = this.recurringDepositsAccountProductTemplate || this.recurringDepositsAccountTemplate; + return tenureInMonths(template?.minDepositTerm, template?.minDepositTermType?.id); + } + + private mergeChart(cbsSlabs: any[], productSlabs: any[]): any[] { + const baseSlabs = this.normalizeCbsSlabs(buildRdChart(cbsSlabs, [], null).slabs); + if (!productSlabs.length) { + return baseSlabs; + } + // Products made before RD charts moved to months still hold the base chart's own year slabs + const candidates = [...baseSlabs, ...this.normalizeCbsSlabs(cbsSlabs)]; + return productSlabs.map((slab: any) => { + if (slab.seniorCitizenRate != null) { + return slab; + } + const base = candidates.find( + (candidate: any) => + candidate.fromPeriod === slab.fromPeriod && + (candidate.toPeriod ?? null) === (slab.toPeriod ?? null) && + this.periodLabel(candidate) === this.periodLabel(slab) + ); + return base?.seniorCitizenRate != null ? { ...slab, seniorCitizenRate: base.seniorCitizenRate } : slab; + }); } private normalizeCbsSlabs(slabs: any[]): any[] { @@ -146,6 +195,10 @@ export class RecurringDepositsAccountInterestRateChartStepComponent implements O })); } + private periodLabel(slab: any): string { + return String(slab.periodType?.value ?? slab.periodTypeLabel ?? '').toLowerCase(); + } + chartSlabsDisplayedColumns: string[] = [ 'period', 'amountRange', @@ -253,6 +306,28 @@ export class RecurringDepositsAccountInterestRateChartStepComponent implements O return period; // days } + /** + * Whether the tenure falls in the slab, counted the way the server counts it: in whole units of the slab's own + * period type, so a 30-month RD is "2 years" against a year slab and lands in "1-2 Years" rather than between + * slabs. Day and week slabs keep the day comparison. + */ + private isTenureInSlab(slab: any, tenureMonths: number, tenureDays: number): boolean { + const label = this.periodLabel(slab) || 'days'; + let tenure: number; + let from = slab.fromPeriod ?? 0; + let to = slab.toPeriod ?? Infinity; + if (label.includes('month')) { + tenure = tenureMonths; + } else if (label.includes('year')) { + tenure = Math.floor(tenureMonths / 12); + } else { + tenure = tenureDays; + from = this.slabPeriodToDays(from, label); + to = to === Infinity ? Infinity : this.slabPeriodToDays(to, label); + } + return tenure >= from && tenure <= to; + } + calculateRate() { this.validationWarning = null; this.matchResult = null; @@ -263,6 +338,26 @@ export class RecurringDepositsAccountInterestRateChartStepComponent implements O } const enteredDays = this.toDays(this.tenureValue, this.tenurePeriodUnit); + const enteredMonths = this.tenurePeriodUnit === 'Years' ? this.tenureValue * 12 : this.tenureValue; + const minimumMonths = this.productMinTenureMonths; + if (minimumMonths != null && enteredMonths < minimumMonths) { + this.validationWarning = `Minimum RD tenure for this product is ${minimumMonths} months.`; + return; + } + // The installment must be within the product's limits - the same check the submit and the server make, shown + // here so a wrong amount is caught before the rest of the form is filled. + if (!this.principalAmount || this.principalAmount <= 0) { + this.validationWarning = 'Please enter the monthly installment amount.'; + return; + } + if (this.minInstallment > 0 && this.principalAmount < this.minInstallment) { + this.validationWarning = `Minimum monthly installment for this product is ₹${this.minInstallment}.`; + return; + } + if (this.maxInstallment > 0 && this.principalAmount > this.maxInstallment) { + this.validationWarning = `Maximum monthly installment for this product is ₹${this.maxInstallment}.`; + return; + } const slabs: any[] = this.interestRateChartData || []; if (!slabs.length) { @@ -275,11 +370,7 @@ export class RecurringDepositsAccountInterestRateChartStepComponent implements O for (let i = 0; i < slabs.length; i++) { const slab = slabs[i]; - const periodTypeValue = slab.periodType?.value || 'Days'; - const fromDays = this.slabPeriodToDays(slab.fromPeriod ?? 0, periodTypeValue); - const toDays = slab.toPeriod != null ? this.slabPeriodToDays(slab.toPeriod, periodTypeValue) : Infinity; - - if (enteredDays >= fromDays && enteredDays <= toDays) { + if (this.isTenureInSlab(slab, enteredMonths, enteredDays)) { matchedSlab = slab; matchedIndex = i; break; @@ -329,10 +420,20 @@ export class RecurringDepositsAccountInterestRateChartStepComponent implements O tenureInMonths: tenureInMonths, interestCompoundingPeriodInMonths: compoundingMonths }; + // With the product named, the server calculates with the product's own interest settings (simple/compounding, + // compounding and posting periods, days in year) - the same figure the opened account will carry. + const template = this.recurringDepositsAccountProductTemplate || this.recurringDepositsAccountTemplate; + const productId = template?.depositProductId ?? template?.productId; + if (productId) { + calculationData.productId = productId; + } if (this.submittedOnDate) { const date = new Date(this.submittedOnDate); - calculationData.submittedOnDate = date.toISOString().split('T')[0]; + // The local calendar date - toISOString() is UTC, which in IST turns midnight of the 9th into the 8th and + // starts the projection a day early (a different maturity amount from the one the account gets). + const pad = (n: number) => String(n).padStart(2, '0'); + calculationData.submittedOnDate = `${date.getFullYear()}-${pad(date.getMonth() + 1)}-${pad(date.getDate())}`; calculationData.dateFormat = 'yyyy-MM-dd'; // Standard ISO for API calculationData.locale = 'en'; } @@ -341,6 +442,10 @@ export class RecurringDepositsAccountInterestRateChartStepComponent implements O result.maturityAmount = apiResult.maturityAmount; result.interestEarned = apiResult.totalInterest; result.daysInYear = apiResult.daysInYear; + result.interestCalculation = apiResult.interestCalculation; + result.compoundingPeriod = apiResult.compoundingPeriod; + result.postingPeriod = apiResult.postingPeriod; + result.postingSchedule = apiResult.postingSchedule; this.matchResult = { ...result }; this.emitTenureSelection(); }); @@ -350,6 +455,18 @@ export class RecurringDepositsAccountInterestRateChartStepComponent implements O } } + /** "QUATERLY" / "BIANNUAL" / "MONTHLY" (server enum names) as a person would say them. */ + interestPeriodLabel(name?: string): string { + const labels: Record = { + DAILY: 'Daily', + MONTHLY: 'Monthly', + QUATERLY: 'Quarterly', + BIANNUAL: 'Half-yearly', + ANNUAL: 'Yearly' + }; + return name ? (labels[name] ?? name) : ''; + } + private emitTenureSelection() { const frequencyId = this.resolveFrequencyId(this.tenurePeriodUnit); if (frequencyId !== null && this.matchResult) { @@ -441,76 +558,54 @@ export class RecurringDepositsAccountInterestRateChartStepComponent implements O // ── Header Getters ────────────────────────────────────────────────────── - get chartName(): string { - if (this.activeBase) { - return this.activeBase.versionLabel || 'CBS Base Interest Rate'; - } + /** + * The RD product's own chart - the one the deposit is actually priced on. The CBS base chart (an FD master the + * product's chart was built from) is shown only when the product has no chart of its own. + */ + private get productChart(): any { const template = this.recurringDepositsAccountProductTemplate || this.recurringDepositsAccountTemplate; return ( - template?.accountChart?.name || - template?.activeChart?.name || - template?.interestRateCharts?.[0]?.name || - template?.interestRateChart?.name || - template?.name || - '' + template?.accountChart || + template?.activeChart || + template?.interestRateCharts?.[0] || + template?.interestRateChart || + null ); } + get chartName(): string { + if (this.productChart) { + return this.productChart.name || ''; + } + return this.activeBase?.versionLabel || 'CBS Base Interest Rate'; + } + get chartFromDate(): any { - if (this.activeBase) { - return this.activeBase.effectiveFromDate; + if (this.productChart) { + return this.productChart.fromDate; } - const template = this.recurringDepositsAccountProductTemplate || this.recurringDepositsAccountTemplate; - return ( - template?.accountChart?.fromDate || - template?.activeChart?.fromDate || - template?.interestRateCharts?.[0]?.fromDate || - template?.interestRateChart?.fromDate || - template?.fromDate - ); + return this.activeBase?.effectiveFromDate; } get chartEndDate(): any { - if (this.activeBase) { - return null; + if (this.productChart) { + return this.productChart.endDate; } - const template = this.recurringDepositsAccountProductTemplate || this.recurringDepositsAccountTemplate; - return ( - template?.accountChart?.endDate || - template?.activeChart?.endDate || - template?.interestRateCharts?.[0]?.endDate || - template?.interestRateChart?.endDate || - template?.endDate - ); + return null; } get chartDescription(): string { - if (this.activeBase) { - return this.activeBase.remarks || ''; + if (this.productChart) { + return this.productChart.description || ''; } - const template = this.recurringDepositsAccountProductTemplate || this.recurringDepositsAccountTemplate; - return ( - template?.accountChart?.description || - template?.activeChart?.description || - template?.interestRateCharts?.[0]?.description || - template?.interestRateChart?.description || - template?.description || - '' - ); + return this.activeBase?.remarks || ''; } get isActive(): boolean { - if (this.activeBase) { - return this.activeBase.isActive; + if (this.productChart) { + return this.productChart.isActive ?? true; } - const template = this.recurringDepositsAccountProductTemplate || this.recurringDepositsAccountTemplate; - return ( - template?.accountChart?.isActive ?? - template?.activeChart?.isActive ?? - template?.interestRateCharts?.[0]?.isActive ?? - template?.interestRateChart?.isActive ?? - true - ); + return this.activeBase?.isActive ?? true; } get isPrimaryGroupingByAmount(): boolean { diff --git a/src/app/deposits/recurring-deposits/recurring-deposits-account-stepper/recurring-deposits-account-maturity-terms-step/recurring-deposits-account-maturity-terms-step.component.html b/src/app/deposits/recurring-deposits/recurring-deposits-account-stepper/recurring-deposits-account-maturity-terms-step/recurring-deposits-account-maturity-terms-step.component.html index 17d6f00d7..f5d550240 100644 --- a/src/app/deposits/recurring-deposits/recurring-deposits-account-stepper/recurring-deposits-account-maturity-terms-step/recurring-deposits-account-maturity-terms-step.component.html +++ b/src/app/deposits/recurring-deposits/recurring-deposits-account-stepper/recurring-deposits-account-maturity-terms-step/recurring-deposits-account-maturity-terms-step.component.html @@ -84,25 +84,17 @@
} - - {{ 'labels.inputs.Apply Penal Interest (less)' | translate }} - - - @if (closureSettingsForm.value.preClosurePenalApplicable) { - - {{ 'labels.inputs.Penal Interest' | translate }} (%) - - - - - {{ 'labels.inputs.Period' | translate }} - - @for (preClosurePenalInterestOnType of preClosurePenalInterestOnTypeData; track preClosurePenalInterestOnType) { - - {{ preClosurePenalInterestOnType.value | translateKey: 'catalogs' }} - - } - - - } + +
+
{{ 'labels.inputs.Apply Penal Interest (less)' | translate }} From Product
+ @if (closureSettingsForm.value.preClosurePenalApplicable) { +
+ Penal Interest: {{ closureSettingsForm.value.preClosurePenalInterest }}% + Period: {{ penalPeriodLabel }} +
+
On premature closure the rate is reduced by this much.
+ } @else { +
No - no penal interest is charged on premature closure of this product.
+ } +
diff --git a/src/app/deposits/recurring-deposits/recurring-deposits-account-stepper/recurring-deposits-account-maturity-terms-step/recurring-deposits-account-maturity-terms-step.component.scss b/src/app/deposits/recurring-deposits/recurring-deposits-account-stepper/recurring-deposits-account-maturity-terms-step/recurring-deposits-account-maturity-terms-step.component.scss index 1f807bcce..b36e1d103 100644 --- a/src/app/deposits/recurring-deposits/recurring-deposits-account-stepper/recurring-deposits-account-maturity-terms-step/recurring-deposits-account-maturity-terms-step.component.scss +++ b/src/app/deposits/recurring-deposits/recurring-deposits-account-stepper/recurring-deposits-account-maturity-terms-step/recurring-deposits-account-maturity-terms-step.component.scss @@ -150,3 +150,37 @@ } } } + +.rdmt-penal { + padding: 10px 12px; + border: 1px solid #e2e8f0; + border-radius: 8px; + background: #f8fafc; + font-size: 13px; + + &__title { + font-weight: 600; + margin-bottom: 6px; + } + + &__tag { + margin-left: 6px; + padding: 1px 6px; + border-radius: 8px; + background: #e0f2fe; + color: #0369a1; + font-size: 10px; + font-weight: 700; + } + + &__row { + display: flex; + gap: 24px; + } + + &__hint { + margin-top: 4px; + font-size: 11px; + color: #64748b; + } +} diff --git a/src/app/deposits/recurring-deposits/recurring-deposits-account-stepper/recurring-deposits-account-maturity-terms-step/recurring-deposits-account-maturity-terms-step.component.ts b/src/app/deposits/recurring-deposits/recurring-deposits-account-stepper/recurring-deposits-account-maturity-terms-step/recurring-deposits-account-maturity-terms-step.component.ts index d32140665..bf02c8325 100644 --- a/src/app/deposits/recurring-deposits/recurring-deposits-account-stepper/recurring-deposits-account-maturity-terms-step/recurring-deposits-account-maturity-terms-step.component.ts +++ b/src/app/deposits/recurring-deposits/recurring-deposits-account-stepper/recurring-deposits-account-maturity-terms-step/recurring-deposits-account-maturity-terms-step.component.ts @@ -82,7 +82,7 @@ export class RecurringDepositsAccountMaturityTermsStepComponent implements OnCha ); this.savingsAccountsData = template.savingsAccounts ?? []; this.preClosurePenalInterestOnTypeData = template.preClosurePenalInterestOnTypeOptions ?? []; - if (!this.closureSettingsForm.get('preClosurePenalApplicable').dirty) { + { this.closureSettingsForm.patchValue({ preClosurePenalApplicable: template.preClosurePenalApplicable ?? false, preClosurePenalInterest: template.preClosurePenalInterest ?? '', @@ -103,9 +103,21 @@ export class RecurringDepositsAccountMaturityTermsStepComponent implements OnCha this.closureSettingsForm.get('transferToSavingsId')?.setValue(''); } - /** Chosen maturity/closure settings, ready to merge into the submit payload. */ + /** "Whole Term" / "Till Premature Withdrawal", as the product has it. */ + get penalPeriodLabel(): string { + const id = this.closureSettingsForm.value.preClosurePenalInterestOnTypeId; + return this.preClosurePenalInterestOnTypeData.find((option: any) => option.id === id)?.value ?? '-'; + } + + /** + * Chosen maturity/closure settings, ready to merge into the submit payload. The premature-closure penalty is left + * out: it is the product's, which the submit already carries and the server applies regardless. + */ get closureSettings() { const settings = this.closureSettingsForm.getRawValue(); + delete settings.preClosurePenalApplicable; + delete settings.preClosurePenalInterest; + delete settings.preClosurePenalInterestOnTypeId; for (const key in settings) { if (settings[key] === '') { delete settings[key]; diff --git a/src/app/deposits/recurring-deposits/recurring-deposits-account-stepper/recurring-deposits-account-preview-step/recurring-deposits-account-preview-step.component.html b/src/app/deposits/recurring-deposits/recurring-deposits-account-stepper/recurring-deposits-account-preview-step/recurring-deposits-account-preview-step.component.html index 0a12ed363..28f4bba40 100644 --- a/src/app/deposits/recurring-deposits/recurring-deposits-account-stepper/recurring-deposits-account-preview-step/recurring-deposits-account-preview-step.component.html +++ b/src/app/deposits/recurring-deposits/recurring-deposits-account-stepper/recurring-deposits-account-preview-step/recurring-deposits-account-preview-step.component.html @@ -878,12 +878,12 @@ }
{{ 'labels.inputs.Apply Penal Interest (less)' | translate }} - {{ closureSettings?.preClosurePenalApplicable | yesNo }} + {{ productPenal.applicable | yesNo }}
- @if (closureSettings?.preClosurePenalApplicable) { + @if (productPenal.applicable) {
{{ 'labels.inputs.Penal Interest' | translate }} (%) - {{ closureSettings?.preClosurePenalInterest }}% + {{ productPenal.interest }}%
{{ 'labels.inputs.Period' | translate }} diff --git a/src/app/deposits/recurring-deposits/recurring-deposits-account-stepper/recurring-deposits-account-preview-step/recurring-deposits-account-preview-step.component.ts b/src/app/deposits/recurring-deposits/recurring-deposits-account-stepper/recurring-deposits-account-preview-step/recurring-deposits-account-preview-step.component.ts index a5855be60..c03a76925 100644 --- a/src/app/deposits/recurring-deposits/recurring-deposits-account-stepper/recurring-deposits-account-preview-step/recurring-deposits-account-preview-step.component.ts +++ b/src/app/deposits/recurring-deposits/recurring-deposits-account-stepper/recurring-deposits-account-preview-step/recurring-deposits-account-preview-step.component.ts @@ -218,10 +218,20 @@ export class RecurringDepositsAccountPreviewStepComponent implements OnChanges { return match ? `(${match.accountNo}) ${match.savingsProductName}` : '—'; } + /** The premature-closure penalty is the product's (the account cannot change it), so it is shown from the product. */ + get productPenal(): { applicable: boolean; interest: any; typeId: any } { + const template = this.recurringDepositsAccountProductTemplate || this.recurringDepositsAccountTemplate; + return { + applicable: !!template?.preClosurePenalApplicable, + interest: template?.preClosurePenalInterest, + typeId: template?.preClosurePenalInterestOnType?.id + }; + } + get closurePenalInterestOnTypeLabel(): string { const template = this.recurringDepositsAccountProductTemplate || this.recurringDepositsAccountTemplate; const options = template?.preClosurePenalInterestOnTypeOptions ?? []; - const match = options.find((o: any) => o.id === this.closureSettings?.preClosurePenalInterestOnTypeId); + const match = options.find((o: any) => o.id === this.productPenal.typeId); return match?.value ?? '—'; } diff --git a/src/app/deposits/recurring-deposits/recurring-deposits-account-view/general-tab/general-tab.component.html b/src/app/deposits/recurring-deposits/recurring-deposits-account-view/general-tab/general-tab.component.html index 7a476f42e..7c3a57848 100644 --- a/src/app/deposits/recurring-deposits/recurring-deposits-account-view/general-tab/general-tab.component.html +++ b/src/app/deposits/recurring-deposits/recurring-deposits-account-view/general-tab/general-tab.component.html @@ -200,6 +200,27 @@ }}
+ @if (lienLimit) { +
+ Loan against RD ({{ lienLimit.percentage }}% of balance) + + up to {{ lienLimit.maxLoanAmount | currency: currency.code : 'symbol-narrow' : '1.2-2' }}, available + {{ lienLimit.availableForLoan | currency: currency.code : 'symbol-narrow' : '1.2-2' }} + +
+ } + @if (totalLienAmount > 0) { +
+ Lien Marked (loan security) + + {{ totalLienAmount | currency: currency.code : 'symbol-narrow' : '1.2-2' }} + - + @for (lien of liens; track lien.loanId; let last = $last) { + Loan {{ lien.loanAccountNo }}{{ last ? '' : ', ' }} + } + +
+ }
{{ 'labels.inputs.Recurring Deposit Amount' | translate }} diff --git a/src/app/deposits/recurring-deposits/recurring-deposits-account-view/general-tab/general-tab.component.ts b/src/app/deposits/recurring-deposits/recurring-deposits-account-view/general-tab/general-tab.component.ts index 05da72274..11fd8779c 100644 --- a/src/app/deposits/recurring-deposits/recurring-deposits-account-view/general-tab/general-tab.component.ts +++ b/src/app/deposits/recurring-deposits/recurring-deposits-account-view/general-tab/general-tab.component.ts @@ -15,6 +15,7 @@ import { DateFormatPipe } from '../../../../pipes/date-format.pipe'; import { FormatNumberPipe } from '../../../../pipes/format-number.pipe'; import { FaIconComponent } from '@fortawesome/angular-fontawesome'; import { STANDALONE_SHARED_IMPORTS } from 'app/standalone-shared.module'; +import { RecurringDepositsService } from '../../recurring-deposits.service'; @Component({ selector: 'mifosx-general-tab', @@ -34,14 +35,22 @@ export class GeneralTabComponent { private route = inject(ActivatedRoute); private router = inject(Router); + private recurringDepositsService = inject(RecurringDepositsService); + recurringDepositsAccountData: any; isprematureAllowed = false; entityType: string; currency: Currency; + /** Loans this deposit secures; empty when it is free of lien. */ + liens: any[] = []; + totalLienAmount = 0; + /** Loan limit today (Lien Marked products only): % that applies, what it comes to, and what is left. */ + lienLimit: { percentage: number; maxLoanAmount: number; availableForLoan: number } | null = null; constructor() { this.route.parent.data.subscribe((data: { recurringDepositsAccountData: any }) => { this.recurringDepositsAccountData = data.recurringDepositsAccountData; + this.loadLiens(this.recurringDepositsAccountData?.id); this.currency = this.recurringDepositsAccountData.currency; this.isprematureAllowed = data.recurringDepositsAccountData.maturityDate != null; if (this.router.url.includes('clients')) { @@ -53,4 +62,24 @@ export class GeneralTabComponent { } }); } + + private loadLiens(accountId: any) { + this.liens = []; + this.totalLienAmount = 0; + this.lienLimit = null; + if (!accountId) { + return; + } + this.recurringDepositsService.getRecurringDepositLiens(accountId).subscribe((response: any) => { + this.liens = response?.liens ?? []; + this.totalLienAmount = Number(response?.totalLienAmount) || 0; + this.lienLimit = response?.lienAllowed + ? { + percentage: Number(response.effectiveLienPercentage) || 0, + maxLoanAmount: Number(response.maxLoanAmount) || 0, + availableForLoan: Number(response.availableForLoan) || 0 + } + : null; + }); + } } diff --git a/src/app/deposits/recurring-deposits/recurring-deposits-account-view/recurring-deposits-account-view.component.html b/src/app/deposits/recurring-deposits/recurring-deposits-account-view/recurring-deposits-account-view.component.html index fda86a309..5f7a36354 100644 --- a/src/app/deposits/recurring-deposits/recurring-deposits-account-view/recurring-deposits-account-view.component.html +++ b/src/app/deposits/recurring-deposits/recurring-deposits-account-view/recurring-deposits-account-view.component.html @@ -82,6 +82,20 @@ {{ 'labels.inputs.Deposit Period' | translate }} {{ recurringDepositsAccountData.depositPeriod | number }}
+ +
+ Interest Posted + + {{ + (recurringDepositsAccountData.summary.totalInterestPosted || 0) + | currency: currency.code : 'symbol-narrow' : '1.2-2' + }} + + @if (recurringDepositsAccountData.interestPostingPeriodType?.value) { + Credited {{ recurringDepositsAccountData.interestPostingPeriodType.value }} + } +
} diff --git a/src/app/deposits/recurring-deposits/recurring-deposits-account-view/recurring-deposits-account-view.component.scss b/src/app/deposits/recurring-deposits/recurring-deposits-account-view/recurring-deposits-account-view.component.scss index 22f751908..bf058b248 100644 --- a/src/app/deposits/recurring-deposits/recurring-deposits-account-view/recurring-deposits-account-view.component.scss +++ b/src/app/deposits/recurring-deposits/recurring-deposits-account-view/recurring-deposits-account-view.component.scss @@ -212,6 +212,17 @@ $border: #e2e8f0; background: rgb(139 92 246 / 22%); border-color: rgb(139 92 246 / 35%); } + + &--interest { + background: rgb(16 185 129 / 22%); + border-color: rgb(16 185 129 / 35%); + } + + &__sub { + margin-top: 1px; + font-size: 9.5px; + color: rgb(255 255 255 / 75%); + } } /* Status pill */ diff --git a/src/app/deposits/recurring-deposits/recurring-deposits-account-view/recurring-deposits-account-view.component.ts b/src/app/deposits/recurring-deposits/recurring-deposits-account-view/recurring-deposits-account-view.component.ts index 22d3aa3da..862c73f30 100644 --- a/src/app/deposits/recurring-deposits/recurring-deposits-account-view/recurring-deposits-account-view.component.ts +++ b/src/app/deposits/recurring-deposits/recurring-deposits-account-view/recurring-deposits-account-view.component.ts @@ -40,6 +40,10 @@ import { MatTabNav, MatTabLink, MatTabNavPanel } from '@angular/material/tabs'; import { StatusLookupPipe } from '../../../pipes/status-lookup.pipe'; import { STANDALONE_SHARED_IMPORTS } from 'app/standalone-shared.module'; import { DormancyAlertPanelComponent } from 'app/shared/dormancy-alert-panel/dormancy-alert-panel.component'; +import { ProductsService } from 'app/products/products.service'; +import { FormDialogComponent } from 'app/shared/form-dialog/form-dialog.component'; +import { InputBase } from 'app/shared/form-dialog/formfield/model/input-base'; +import { AlertService } from 'app/core/alert/alert.service'; /** * RecurringDeposits Account View Component @@ -79,6 +83,8 @@ export class RecurringDepositsAccountViewComponent implements OnInit { private settingsService = inject(SettingsService); private dateUtils = inject(Dates); private authenticationService = inject(AuthenticationService); + private productsService = inject(ProductsService); + private alertService = inject(AlertService); dialog = inject(MatDialog); private translateService = inject(TranslateService); @@ -105,6 +111,7 @@ export class RecurringDepositsAccountViewComponent implements OnInit { 'Enable Withhold Tax': 'interest', 'Disable Withhold Tax': 'interest', 'Premature Close': 'danger', + 'Extend Tenure': 'admin', 'Undo Activation': 'admin', Close: 'danger', 'Modify Application': 'admin', @@ -128,6 +135,7 @@ export class RecurringDepositsAccountViewComponent implements OnInit { 'Enable Withhold Tax': 'percentage', 'Disable Withhold Tax': 'percentage', 'Premature Close': 'times-circle', + 'Extend Tenure': 'calendar-alt', 'Undo Activation': 'undo', Close: 'times-circle', 'Modify Application': 'edit', @@ -184,6 +192,30 @@ export class RecurringDepositsAccountViewComponent implements OnInit { ngOnInit() { this.setConditionalButtons(); + this.applyProductRules(); + } + + /** + * Buttons the deposit's product decides: no Premature Close unless the product has "Premature Withdrawal option" on, + * and an Extend Tenure action when it has "Tenure Flexibility" on. The server enforces both as well. + */ + private applyProductRules() { + const productId = this.recurringDepositsAccountData.depositProductId; + if (!productId) { + return; + } + this.productsService.getRecurringDepositProduct(String(productId)).subscribe((product: any) => { + if (!product?.prematureWithdrawalOption) { + this.buttonConfig.buttonsArray = this.buttonConfig.buttonsArray.filter((btn) => btn.name !== 'Premature Close'); + this.buttonConfig.optionArray = this.buttonConfig.optionArray.filter((opt) => opt.name !== 'Premature Close'); + } + if (product?.tenureFlexibility && this.recurringDepositsAccountData.status?.value === 'Active') { + this.buttonConfig.addOption({ + name: 'Extend Tenure', + taskPermissionName: 'EXTENDTENURE_RECURRINGDEPOSITACCOUNT' + }); + } + }); } /** @@ -312,9 +344,61 @@ export class RecurringDepositsAccountViewComponent implements OnInit { case 'Terminate': this.terminateRecurringDepositsAccount(); break; + case 'Extend Tenure': + this.extendTenure(); + break; } } + /** + * Lengthens the deposit's tenure (product has Tenure Flexibility on). New installments are added after the current + * last one, so Auto Debit keeps collecting through the added months; the rate becomes the chart's rate for the new + * tenure and the maturity date and amount are worked out again. + */ + private extendTenure() { + const current = Number(this.recurringDepositsAccountData.depositPeriod) || 0; + const unit = this.recurringDepositsAccountData.depositPeriodFrequency?.value || 'Months'; + const data = { + title: 'Extend Tenure', + layout: { addButtonText: 'Extend' }, + formfields: [ + new InputBase({ + controlName: 'depositPeriod', + label: `New tenure in ${unit} (now ${current})`, + value: current + 1, + type: 'number', + required: true, + min: current + 1, + order: 1 + }) + ] + }; + this.dialog + .open(FormDialogComponent, { data }) + .afterClosed() + .subscribe((response: any) => { + const newPeriod = Number(response?.data?.value?.depositPeriod); + if (!response?.data) { + return; + } + if (!newPeriod || newPeriod <= current) { + this.alertService.alert({ type: 'Extend Tenure', message: `The new tenure must be more than ${current} ${unit}.` }); + return; + } + this.recurringDepositsService + .executeRecurringDepositsAccountCommand(this.recurringDepositsAccountData.id, 'extendTenure', { + depositPeriod: newPeriod, + locale: this.settingsService.language.code + }) + .subscribe((result: any) => { + if (result?.commandId && !result?.changes) { + this.alertService.alert({ type: 'Extend Tenure', message: 'Tenure extension submitted for approval.' }); + } + this.reload(); + }); + }); + } + /** * Deletes Recurring Deposits Account. */ diff --git a/src/app/deposits/recurring-deposits/recurring-deposits-account-view/recurring-deposits-buttons.config.ts b/src/app/deposits/recurring-deposits/recurring-deposits-account-view/recurring-deposits-buttons.config.ts index 7e2f5dd5f..118544ca7 100644 --- a/src/app/deposits/recurring-deposits/recurring-deposits-account-view/recurring-deposits-buttons.config.ts +++ b/src/app/deposits/recurring-deposits/recurring-deposits-account-view/recurring-deposits-buttons.config.ts @@ -36,12 +36,9 @@ export class RecurringDepositsButtonsConfiguration { setButtons(status: string) { switch (status) { case 'Active': + // No Deposit button: a direct deposit here skips the manager's approval. RD installments come in + // through the cashier's Receipt (maker-checker) or Auto Debit. this.buttonsArray = [ - { - name: 'Deposit', - icon: 'fa fa-arrow-up', - taskPermissionName: 'DEPOSIT_RECURRINGDEPOSITACCOUNT' - }, { name: 'Withdrawal', icon: 'fa fa-arrow-down', @@ -103,12 +100,9 @@ export class RecurringDepositsButtonsConfiguration { name: 'Calculate Interest', icon: 'fa fa-calculator', taskPermissionName: 'CALCULATEINTEREST_RECURRINGDEPOSITACCOUNT' - }, - { - name: 'Post Interest', - icon: 'fa fa-money', - taskPermissionName: 'POSTINTEREST_RECURRINGDEPOSITACCOUNT' } + // No Post Interest: the term's interest is credited by the server on the maturity date, and again only at + // closure for any days after it. ]; break; case 'Rejected': diff --git a/src/app/deposits/recurring-deposits/recurring-deposits-account-view/transactions-tab/view-transaction/view-transaction.component.html b/src/app/deposits/recurring-deposits/recurring-deposits-account-view/transactions-tab/view-transaction/view-transaction.component.html index ec66cc45a..e488fdd63 100644 --- a/src/app/deposits/recurring-deposits/recurring-deposits-account-view/transactions-tab/view-transaction/view-transaction.component.html +++ b/src/app/deposits/recurring-deposits/recurring-deposits-account-view/transactions-tab/view-transaction/view-transaction.component.html @@ -15,14 +15,6 @@ } -
} diff --git a/src/app/deposits/recurring-deposits/recurring-deposits.service.ts b/src/app/deposits/recurring-deposits/recurring-deposits.service.ts index db9af6cc8..cb399e41d 100644 --- a/src/app/deposits/recurring-deposits/recurring-deposits.service.ts +++ b/src/app/deposits/recurring-deposits/recurring-deposits.service.ts @@ -31,6 +31,14 @@ export class RecurringDepositsService { return this.http.get(`/recurringdepositaccounts/${recurringDepositAccountId}`, { params: httpParams }); } + /** + * Loans this recurring deposit currently secures (lien), with the amount each one holds. + * Returns { totalLienAmount, liens: [{ loanId, loanAccountNo, loanStatus, amount }] }. + */ + getRecurringDepositLiens(recurringDepositAccountId: any): Observable { + return this.http.get(`/recurringdepositaccounts/${recurringDepositAccountId}/liens`); + } + /** * @param clientId Client Id * @param clientName Client Name diff --git a/src/app/directives/directives.module.ts b/src/app/directives/directives.module.ts index 9b64b94da..81b0494d4 100644 --- a/src/app/directives/directives.module.ts +++ b/src/app/directives/directives.module.ts @@ -14,6 +14,7 @@ import { CommonModule } from '@angular/common'; import { HasPermissionDirective } from './has-permission/has-permission.directive'; import { FormatAmountDirective } from './format-amount.directive'; import { ValidateOnFocusDirective } from './validate-on-focus.directive'; +import { RestrictInputDirective } from './restrict-input.directive'; /** * Directives Module @@ -25,12 +26,14 @@ import { ValidateOnFocusDirective } from './validate-on-focus.directive'; CommonModule, HasPermissionDirective, FormatAmountDirective, - ValidateOnFocusDirective + ValidateOnFocusDirective, + RestrictInputDirective ], exports: [ HasPermissionDirective, FormatAmountDirective, - ValidateOnFocusDirective + ValidateOnFocusDirective, + RestrictInputDirective ] }) export class DirectivesModule {} diff --git a/src/app/directives/restrict-input.directive.ts b/src/app/directives/restrict-input.directive.ts new file mode 100644 index 000000000..176b32cbc --- /dev/null +++ b/src/app/directives/restrict-input.directive.ts @@ -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 { Directive, HostListener, Input, ElementRef, inject } from '@angular/core'; +import { NgControl } from '@angular/forms'; + +/** The kinds of field this knows how to hold to a shape. */ +export type RestrictKind = 'digits' | 'phone' | 'pan' | 'aadhaar'; + +interface Rule { + /** Characters this kind will accept at all. */ + allowed: RegExp; + /** How many of them the field can hold, so it cannot be overfilled. */ + maxLength: number; + /** Whether what is typed is forced to upper case as it is entered. */ + upper?: boolean; +} + +const RULES: Record = { + digits: { allowed: /[0-9]/, maxLength: 20 }, + phone: { allowed: /[0-9+()\-\s]/, maxLength: 20 }, + // A PAN is five letters, four digits, one letter - and is always written in capitals. + pan: { allowed: /[A-Za-z0-9]/, maxLength: 10, upper: true }, + // Twelve digits, or the masked form CKYC returns, which is Xs and the last four digits. + aadhaar: { allowed: /[0-9Xx]/, maxLength: 12, upper: true } +}; + +/** + * Stops a field being filled with something it could never hold. + * + * A mobile number typed as letters, or a PAN of fifteen characters, is not a mistake worth + * discovering on save - the customer is at the counter now, and the clerk is looking at the field + * they just typed. Refusing the keystroke says so at the moment it happens, with nothing to read + * and nothing to undo. + * + * Pasting is handled too, and is where this earns its place: a number copied from a spreadsheet + * arrives with spaces, dashes or a +91 in front, and pasting is the one way into a field that + * bypasses every keystroke rule. What can be salvaged is kept, the rest dropped. + * + * This only refuses characters the field cannot contain. Whether what is left is a valid number is + * a separate question, answered by the form's own validators and again by the server - a screen + * restriction is a convenience, not a guarantee. + */ +@Directive({ + selector: '[mifosxRestrictInput]', + standalone: true +}) +export class RestrictInputDirective { + private el = inject(ElementRef); + private control = inject(NgControl, { optional: true }); + + @Input('mifosxRestrictInput') kind: RestrictKind = 'digits'; + + private get rule(): Rule { + return RULES[this.kind] ?? RULES.digits; + } + + /** + * Keys that move around or edit rather than enter a character, plus the usual copy/paste and + * select-all chords. Blocking these would leave the clerk unable to correct their own typing. + */ + private isEditingKey(event: KeyboardEvent): boolean { + const navigation = [ + 'Backspace', + 'Delete', + 'Tab', + 'Escape', + 'Enter', + 'Home', + 'End', + 'ArrowLeft', + 'ArrowRight', + 'ArrowUp', + 'ArrowDown' + ]; + return navigation.includes(event.key) || event.ctrlKey || event.metaKey; + } + + @HostListener('keydown', ['$event']) + onKeydown(event: KeyboardEvent): void { + if (this.isEditingKey(event) || event.key.length > 1) { + return; + } + const input = this.el.nativeElement as HTMLInputElement; + const hasSelection = input.selectionStart !== input.selectionEnd; + + if (!this.rule.allowed.test(event.key)) { + event.preventDefault(); + return; + } + // Replacing a selection does not make the field any longer, so only a plain insert can overfill. + if (!hasSelection && input.value.length >= this.rule.maxLength) { + event.preventDefault(); + } + } + + @HostListener('paste', ['$event']) + onPaste(event: ClipboardEvent): void { + event.preventDefault(); + const pasted = event.clipboardData?.getData('text') ?? ''; + const kept = Array.from(pasted) + .filter((ch) => this.rule.allowed.test(ch)) + .join('') + .slice(0, this.rule.maxLength); + this.write(this.rule.upper ? kept.toUpperCase() : kept); + } + + /** Upper case is applied as the field is left, so the caret does not jump while typing. */ + @HostListener('blur') + onBlur(): void { + if (!this.rule.upper) { + return; + } + const current = String(this.el.nativeElement.value ?? ''); + if (current !== current.toUpperCase()) { + this.write(current.toUpperCase()); + } + } + + /** + * Writes through the form control where there is one, so the value the form holds and the value + * on screen stay the same thing - setting only the element would leave the form with the old one. + */ + private write(value: string): void { + this.el.nativeElement.value = value; + if (this.control?.control) { + this.control.control.setValue(value); + } else { + this.el.nativeElement.dispatchEvent(new Event('input')); + } + } +} diff --git a/src/app/fd-interest-posting-report/fd-interest-posting-report.component.html b/src/app/fd-interest-posting-report/fd-interest-posting-report.component.html new file mode 100644 index 000000000..aa980ed35 --- /dev/null +++ b/src/app/fd-interest-posting-report/fd-interest-posting-report.component.html @@ -0,0 +1,646 @@ + + +
+
+
+

FD Interest Posting Reports

+
+ Each branch gets its own report for each fixed deposit scheme, generated automatically at 12:00 noon on the + posting date. One user puts the figures up and a second approves them — whoever generated a report cannot + approve it. Approved interest posts when the branch closes its day, and it cannot close until then. +
+
+
+ + + + + @if (isHeadOffice) { + + } +
+
+ + @if (!approvalEnabled) { +
+ +
+ Fixed deposit interest is currently posted directly by the nightly job, so there is nothing to approve here. + Turn on fd-interest-posting-approval under System → Configurations to put deposits behind + this report. +
+
+ } + + @if (isHeadOffice) { +
+ + +
+ @if (tab === 'others') { +
+ +
+ Each branch generates and approves its own reports. The head office can act here only for a branch that has + not opened the day, and only once every other open branch has closed. Anything it approves is marked (HO). +
+
+ } + } + + @if (errorMessage) { +
+ +
{{ errorMessage }}
+
+ } + @if (notice) { +
+ +
{{ notice }}
+
+ } + @if (pendingCount > 0) { +
+ +
+ {{ pendingCount }} report(s) waiting for approval. The day cannot be closed until they are + approved, and the user who generated a report cannot be the one to approve it. +
+
+ } + + @if (due.length > 0) { +
+ +
+
Fixed deposit interest is due but no report has been generated yet for:
+ @for (d of due; track d.productId + '-' + d.officeId) { +
+ {{ d.productName }} + {{ fdTypeShort(d.fdType) }} + + · {{ d.officeName }} · {{ d.postingDate }} · {{ d.accountCount }} deposit(s) · + ₹{{ d.totalInterest | number: '1.2-2' }} + + @if (d.canAct) { + + } @else if (d.blockedReason) { + {{ d.blockedReason }} + } +
+ } +
+
+ } + + +
+
+
Interest due, no report
+
₹{{ dueTotal | number: '1.2-2' }}
+
{{ dueAccountTotal }} deposit(s) · {{ due.length }} product(s)
+
+
+
Awaiting approval
+
₹{{ awaitingTotal | number: '1.2-2' }}
+
{{ awaitingReports.length }} report(s) · blocking day close
+
+
+
Approved — posts at close
+
₹{{ approvedTotal | number: '1.2-2' }}
+
{{ approvedReports.length }} report(s)
+
+
+
Posted today
+
₹{{ postedTotal | number: '1.2-2' }}
+
{{ postedReports.length }} report(s)
+
+
+ + @if (showPreview) { +
+
+
+

FD interest preview — earned up to {{ previewAsOf }}

+
+ What each deposit would be paid if interest were posted today. View only — nothing is saved, approved + or posted. +
+
+
+ Total so far + ₹{{ previewTotal | number: '1.2-2' }} + {{ previewRows.length }} deposit(s) +
+
+
+ + + +
+ @if (previewLoading) { +
Working out what each deposit has earned up to today…
+ } @else { +
+ + + + + + + + + + + + @for (g of previewGroups; track g.officeName + g.productName) { + + + + + + + + } @empty { + + + + } + +
BranchProductSchemeDepositsInterest so far
{{ g.officeName }} + {{ g.productName }} + + {{ fdTypeShort(g.fdType) }} + {{ g.accounts }}₹{{ g.interest | number: '1.2-2' }}
No fixed deposit has interest waiting to be posted.
+
+ +
+ + + + + + + + + + + + + + + + @for (r of previewRows; track r.fdAccountId + '-' + r.interestPostingDate) { + + + + + + + + + + + + } + + + + + + + +
Account noCustomerSchemeInterest forDepositRate %MaturityPays intoInterest so far
{{ r.accountNo }}{{ r.clientName }} + {{ fdTypeShort(r.fdType) }} + {{ r.interestPeriodFrom || '—' }} → {{ r.interestPostingDate }}₹{{ r.depositAmount | number: '1.2-2' }}{{ r.interestRate | number: '1.2-2' }}{{ r.maturityDate || '—' }}{{ r.payoutAccountNo || 'Stays in the deposit' }}₹{{ r.interest | number: '1.2-2' }}
Total + ₹{{ previewTotal | number: '1.2-2' }} +
+
+ } +
+ } + + @if (showOutsideApproval) { +
+
+
+

Fixed deposit interest posted outside this report

+
+ A deposit that matured before its own posting cycle completed has its last period settled directly, the + moment it matures — before any report here could be generated or approved for it. Listed here so + nothing paid to a customer is invisible, approved or not. +
+
+
+ Total + ₹{{ outsideApprovalTotal | number: '1.2-2' }} + {{ outsideApprovalRows.length }} posting(s) +
+
+
+ + + + +
+ @if (outsideApprovalLoading) { +
Looking for deposit interest posted outside this report…
+ } @else { +
+ + + + + + + + + + + @for (g of outsideApprovalGroups; track g.productName) { + + + + + + + } @empty { + + + + } + +
ProductSchemeDepositsInterest
+ {{ g.productName }} + + {{ fdTypeShort(g.fdType) }} + {{ g.accounts }}₹{{ g.interest | number: '1.2-2' }}
+ No fixed deposit interest was posted outside this report in this period. +
+
+ + @if (outsideApprovalRows.length > 0) { +
+ + + + + + + + + + + + + + @for (r of outsideApprovalRows; track r.transactionId) { + + + + + + + + + + } + + + + + + + + +
Account noCustomerProductSchemeInterest forInterestPosted at
{{ r.accountNo }}{{ r.clientName }}{{ r.productName }} + {{ fdTypeShort(r.fdType) }} + {{ r.interestFor }}₹{{ r.interestAmount | number: '1.2-2' }}{{ r.postedAt | date: 'dd MMM yyyy, HH:mm' }}
Total + ₹{{ outsideApprovalTotal | number: '1.2-2' }} +
+
+ } + } +
+ } + + @if (showProductSummary) { +
+
+
+

Interest by product

+
+ Posted through this report, still awaiting approval or posting, and posted directly at maturity instead — + added up to what the product has paid out in total over the period. +
+
+
+ Total, all products + ₹{{ productSummaryGrandTotal.total | number: '1.2-2' }} +
+
+
+ + + + +
+ @if (productSummaryLoading) { +
Adding up interest by product…
+ } @else { +
+ + + + + + + + + + + + + + @for (row of productSummaryRows; track row.productId) { + + + + + + + + + + } @empty { + + + + } + + + + + + + + + + +
ProductSchemePosting periodPosted (report)Pending approvalPosted at maturityTotal
+ {{ row.productName }} + + {{ fdTypeShort(row.fdType) }} + {{ row.postingPeriodLabel || '—' }}₹{{ row.posted | number: '1.2-2' }}₹{{ row.pending | number: '1.2-2' }}₹{{ row.maturityPosted | number: '1.2-2' }} + ₹{{ row.total | number: '1.2-2' }} +
No fixed deposit interest in this period.
Total + ₹{{ productSummaryGrandTotal.posted | number: '1.2-2' }} + + ₹{{ productSummaryGrandTotal.pending | number: '1.2-2' }} + + ₹{{ productSummaryGrandTotal.maturityPosted | number: '1.2-2' }} + + ₹{{ productSummaryGrandTotal.total | number: '1.2-2' }} +
+
+ } +
+ } + +
+
+

Reports{{ postingDate ? ' for ' + postingDate : '' }}

+ One report per scheme, per branch +
+
+ + + + + + + + + + + + + + + + + @for (r of reports; track r.id) { + + + + + + + + + + + + + } @empty { + + + + } + +
BranchSchemeTypePosting dateDepositsTotal interestStatusMakerChecker
{{ r.officeName }} + {{ r.productName }} + + {{ fdTypeShort(r.fdType) }} + {{ r.postingDate }}{{ r.accountCount }}₹{{ r.totalInterest | number: '1.2-2' }} + {{ r.status }} + @if (r.failureReason) { +
Posting failed: {{ r.failureReason }}
+ } +
+ {{ r.generatedBy || 'System' }} · {{ r.generationMode }}
{{ + r.generatedAt | date: 'dd MMM, HH:mm' + }} +
+ @if (r.approvedAt) { + {{ r.approvedBy }} + @if (r.approvedOnBehalf) { + (HO) + } +
{{ r.approvedAt | date: 'dd MMM, HH:mm' }} + } @else { + — + } +
+
+ @if (canRegenerate(r)) { + + } + @if (canApprove(r)) { + + } @else if (r.status === 'GENERATED' && r.approveBlockedReason) { + {{ r.approveBlockedReason }} + } +
+
+ @if (loading) { + Loading… + } @else { + No fixed deposit interest posting report for {{ postingDate || 'this date' }}. Reports are generated + at 12:00 noon on a product's posting date. + } +
+
+
+ + @if (selected) { +
+
+
+

+ {{ selected.officeName }} · {{ selected.productName }} — {{ selected.postingDate }} + {{ fdTypeShort(selected.fdType) }} + {{ selected.postingPeriodLabel }} + {{ selected.status }} +

+
{{ fdTypeLabel(selected.fdType) }}
+
+ Dr Interest Expense → Cr {{ selected.payableGlCode }} {{ selected.payableGlName }}; Dr + {{ selected.payableGlCode }} → Cr {{ selected.paidGlCode }} {{ selected.paidGlName }}; then Dr + {{ selected.paidGlCode }} → Cr each deposit below +
+
+
+ Total interest + ₹{{ selected.totalInterest | number: '1.2-2' }} + {{ selected.accountCount }} deposit(s) +
+
+
+ + + + + + + + + + + + + + + + + + @for (l of selected.lines; track l.fdAccountId + '-' + l.interestPostingDate) { + + + + + + + + + + + + + + } + + + + + + + + +
Account noAccount holderInterest forDepositRate %MaturityInterestBalance beforeBalance afterPays intoPosted txn
{{ l.accountNo }}{{ l.clientName }}{{ l.interestPeriodFrom || '—' }} → {{ l.interestPostingDate }}₹{{ l.depositAmount | number: '1.2-2' }}{{ l.interestRate | number: '1.2-2' }}{{ l.maturityDate || '—' }}₹{{ l.interestAmount | number: '1.2-2' }}₹{{ l.balanceBefore | number: '1.2-2' }}₹{{ l.balanceAfter | number: '1.2-2' }}{{ l.payoutAccountNo || 'Stays in the deposit' }}{{ l.fdTransactionId ? '#' + l.fdTransactionId : '—' }}
Total + ₹{{ selected.totalInterest | number: '1.2-2' }} +
+
+
+ } +
diff --git a/src/app/fd-interest-posting-report/fd-interest-posting-report.component.scss b/src/app/fd-interest-posting-report/fd-interest-posting-report.component.scss new file mode 100644 index 000000000..1900de118 --- /dev/null +++ b/src/app/fd-interest-posting-report/fd-interest-posting-report.component.scss @@ -0,0 +1,559 @@ +/** + * 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 { + --fd-navy: #101723; + --fd-accent: #1b4d8f; + --fd-accent-dark: #123c70; + --fd-ground: #f7f8fa; + --fd-border: #dfe4eb; + --fd-border-soft: #edf0f4; + --fd-muted: #5b6676; + --fd-text: #3c4757; + --fd-amber: #6b4600; + --fd-amber-dark: #5a3b00; + --fd-amber-tint: #fdf3dc; + --fd-amber-border: #efd9a3; + --fd-amber-wash: #fffcf4; + --fd-green: #185e38; + --fd-green-dark: #1d6f42; + --fd-green-tint: #e2f3e8; + --fd-green-wash: #f3faf5; + --fd-blue-tint: #e6eef8; + --fd-slate-tint: #edf0f4; + + display: block; + font-family: 'IBM Plex Sans', system-ui, sans-serif; + color: var(--fd-navy); +} + +.fd { + max-width: 1320px; + margin: 0 auto; + padding: 20px 4px 56px; +} + +// ── Header ────────────────────────────────────────────────────────────── + +.fd-head { + display: flex; + flex-wrap: wrap; + gap: 24px; + align-items: flex-end; + justify-content: space-between; + margin-bottom: 22px; +} + +.fd-head-t { + margin: 0; + font-family: 'IBM Plex Serif', serif; + font-weight: 600; + font-size: 26px; + letter-spacing: -0.01em; + display: flex; + align-items: center; + gap: 10px; + + fa-icon { + color: var(--fd-accent); + font-size: 20px; + } +} + +.fd-head-s { + max-width: 720px; + margin-top: 8px; + font-size: 13.5px; + line-height: 1.6; + color: var(--fd-muted); +} + +.fd-head-a { + display: flex; + flex-wrap: wrap; + align-items: flex-end; + gap: 10px; +} + +.fd-date { + display: flex; + flex-direction: column; + gap: 5px; + font-size: 11px; + font-weight: 600; + letter-spacing: 0.06em; + text-transform: uppercase; + color: var(--fd-muted); + + input { + font-family: 'IBM Plex Mono', monospace; + font-size: 13px; + padding: 9px 11px; + min-height: 44px; + border: 1px solid #cdd5df; + border-radius: 7px; + background: #fff; + color: var(--fd-navy); + } +} + +// ── Buttons ───────────────────────────────────────────────────────────── + +.fd-btn { + font-family: inherit; + font-size: 12.5px; + font-weight: 500; + padding: 8px 14px; + min-height: 44px; + border-radius: 7px; + border: 1px solid #cdd5df; + background: #fff; + color: var(--fd-navy); + cursor: pointer; + white-space: nowrap; + + fa-icon { + margin-right: 6px; + } + + &:hover:not([disabled]) { + background: #f2f5f9; + } + + &[disabled] { + opacity: 0.5; + cursor: not-allowed; + } + + &--primary { + background: var(--fd-accent); + border-color: var(--fd-accent); + color: #fff; + + &:hover:not([disabled]) { + background: var(--fd-accent-dark); + } + } + + &--slim { + min-height: 34px; + padding: 6px 11px; + font-size: 12px; + } +} + +// ── Tabs ──────────────────────────────────────────────────────────────── + +.fd-tabs { + display: flex; + gap: 4px; + margin-bottom: 20px; + border-bottom: 1px solid var(--fd-border); +} + +.fd-tab { + font-family: inherit; + font-size: 13px; + font-weight: 500; + padding: 10px 6px; + min-height: 44px; + border: 0; + border-bottom: 2px solid transparent; + background: transparent; + color: var(--fd-muted); + cursor: pointer; + + fa-icon { + margin-right: 7px; + } + + &.is-active { + border-bottom-color: var(--fd-accent); + color: var(--fd-accent); + font-weight: 600; + } + + & + .fd-tab { + margin-left: 18px; + } +} + +// ── Alerts ────────────────────────────────────────────────────────────── + +.fd-alert { + display: flex; + gap: 12px; + align-items: flex-start; + margin-bottom: 18px; + padding: 14px 18px; + border-radius: 10px; + font-size: 13px; + line-height: 1.6; + + fa-icon { + margin-top: 2px; + flex-shrink: 0; + } + + &--info { + background: #eef3fa; + border: 1px solid #c8d8ec; + color: #1f3a5c; + } + + &--warn { + background: var(--fd-amber-tint); + border: 1px solid var(--fd-amber-border); + color: var(--fd-amber-dark); + + strong { + color: var(--fd-amber-dark); + } + } + + &--ok { + background: var(--fd-green-wash); + border: 1px solid #bfe3cc; + color: var(--fd-green); + } + + &--error { + background: #fdecea; + border: 1px solid #f3b8b1; + color: #8a2b22; + } +} + +.fd-due-body { + display: flex; + flex-direction: column; + gap: 10px; + width: 100%; +} + +.fd-due-row { + display: flex; + flex-wrap: wrap; + align-items: center; + gap: 8px; + padding: 8px 0; + + & + .fd-due-row { + border-top: 1px dashed var(--fd-amber-border); + } +} + +.fd-blocked { + font-size: 11.5px; + color: var(--fd-muted); +} + +// ── Summary strip ─────────────────────────────────────────────────────── + +.fd-summary { + display: grid; + grid-template-columns: repeat(4, minmax(0, 1fr)); + gap: 16px; + margin-bottom: 20px; + + @media (width <= 860px) { + grid-template-columns: repeat(2, minmax(0, 1fr)); + } +} + +.fd-stat { + background: #fff; + border: 1px solid var(--fd-border); + border-radius: 12px; + padding: 18px 20px; + + &__label { + font-size: 11px; + font-weight: 600; + letter-spacing: 0.06em; + text-transform: uppercase; + color: var(--fd-muted); + } + + &__value { + font-family: 'IBM Plex Mono', monospace; + font-variant-numeric: tabular-nums; + font-size: 25px; + font-weight: 500; + letter-spacing: -0.02em; + margin-top: 9px; + } + + &__sub { + font-size: 11.5px; + color: var(--fd-muted); + margin-top: 4px; + } + + &--warn { + border-color: var(--fd-amber-border); + + .fd-stat__label, + .fd-stat__sub { + color: var(--fd-amber); + } + + .fd-stat__value { + color: var(--fd-amber-dark); + } + } + + &--ok { + .fd-stat__label { + color: var(--fd-green); + } + + .fd-stat__value { + color: var(--fd-green); + } + } + + &--posted { + .fd-stat__label { + color: var(--fd-accent); + } + + .fd-stat__value { + color: var(--fd-accent); + } + } +} + +// ── Cards ─────────────────────────────────────────────────────────────── + +.fd-card { + background: #fff; + border: 1px solid var(--fd-border); + border-radius: 12px; + margin-bottom: 20px; + overflow: hidden; +} + +.fd-card-h { + display: flex; + flex-wrap: wrap; + gap: 10px; + align-items: baseline; + padding: 16px 20px; + border-bottom: 1px solid var(--fd-border); + + h2, + h3 { + margin: 0; + font-size: 15px; + font-weight: 600; + } +} + +.fd-card-sub { + font-size: 12px; + color: var(--fd-muted); +} + +// ── Tables ────────────────────────────────────────────────────────────── + +.fd-table-wrap { + overflow-x: auto; +} + +.fd-table { + border-collapse: collapse; + width: 100%; + + th { + font-size: 11px; + font-weight: 600; + letter-spacing: 0.06em; + text-transform: uppercase; + color: var(--fd-muted); + text-align: left; + padding: 10px 14px; + border-bottom: 1px solid var(--fd-border); + white-space: nowrap; + } + + td { + font-size: 13px; + padding: 12px 14px; + border-bottom: 1px solid var(--fd-border-soft); + vertical-align: top; + } + + tbody tr:last-child td { + border-bottom: 0; + } + + tbody tr.is-clickable { + cursor: pointer; + + &:hover td { + background: #fafbfd; + } + } + + tbody tr.is-selected td { + background: var(--fd-blue-tint); + } + + tfoot tr { + background: var(--fd-ground); + } + + .num { + text-align: right; + font-family: 'IBM Plex Mono', monospace; + font-variant-numeric: tabular-nums; + white-space: nowrap; + } +} + +.fd-mono { + font-family: 'IBM Plex Mono', monospace; + font-variant-numeric: tabular-nums; +} + +.fd-sub { + font-size: 11.5px; + color: var(--fd-muted); + margin-top: 3px; +} + +.fd-empty { + text-align: center; + padding: 30px 16px; + color: var(--fd-muted); + font-size: 13px; +} + +// ── Chips & badges ────────────────────────────────────────────────────── + +.fd-chip { + display: inline-block; + padding: 2px 9px; + border-radius: 999px; + font-size: 11px; + font-weight: 600; + letter-spacing: 0.02em; + white-space: nowrap; + + &--single { + background: var(--fd-slate-tint); + color: var(--fd-text); + } + + &--double { + background: var(--fd-blue-tint); + color: var(--fd-accent); + } + + &--special { + background: var(--fd-amber-tint); + color: var(--fd-amber); + } +} + +.fd-badge { + display: inline-block; + padding: 2px 10px; + border-radius: 999px; + font-size: 11px; + font-weight: 700; + letter-spacing: 0.02em; + white-space: nowrap; + + &--pending { + background: var(--fd-amber-tint); + color: var(--fd-amber); + } + + &--approved { + background: var(--fd-green-tint); + color: var(--fd-green); + } + + &--posted { + background: var(--fd-blue-tint); + color: var(--fd-accent); + } + + &--muted { + background: var(--fd-slate-tint); + color: var(--fd-muted); + } +} + +.fd-behalf { + font-size: 10.5px; + font-weight: 700; + color: var(--fd-accent); + margin-left: 4px; +} + +.fd-fail { + margin-top: 4px; + font-size: 11px; + color: #a32b22; +} + +// ── Detail panel ──────────────────────────────────────────────────────── + +.fd-detail-head { + display: flex; + flex-wrap: wrap; + gap: 20px; + justify-content: space-between; + align-items: flex-start; + padding: 22px 20px; + border-bottom: 1px solid var(--fd-border-soft); + background: #fbfcfd; + + h3 { + margin: 0; + font-family: 'IBM Plex Serif', serif; + font-weight: 600; + font-size: 19px; + display: flex; + flex-wrap: wrap; + align-items: center; + gap: 9px; + } +} + +.fd-total { + text-align: right; + + span:first-child { + display: block; + font-size: 11px; + font-weight: 600; + letter-spacing: 0.06em; + text-transform: uppercase; + color: var(--fd-muted); + } + + strong { + display: block; + font-family: 'IBM Plex Mono', monospace; + font-variant-numeric: tabular-nums; + font-size: 26px; + font-weight: 500; + margin-top: 6px; + letter-spacing: -0.02em; + } +} + +.muted { + color: var(--fd-muted); + font-size: 12.5px; + margin-top: 4px; +} diff --git a/src/app/fd-interest-posting-report/fd-interest-posting-report.component.ts b/src/app/fd-interest-posting-report/fd-interest-posting-report.component.ts new file mode 100644 index 000000000..38ff43ae1 --- /dev/null +++ b/src/app/fd-interest-posting-report/fd-interest-posting-report.component.ts @@ -0,0 +1,686 @@ +/** + * 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 { FormsModule } from '@angular/forms'; +import { FaIconComponent } from '@fortawesome/angular-fontawesome'; +import { STANDALONE_SHARED_IMPORTS } from 'app/standalone-shared.module'; +import { SettingsService } from 'app/settings/settings.service'; +import { exportWorkbook } from '../cashier/cashier-export'; +import { + FdInterestDue, + FdInterestPreviewRow, + FdInterestReport, + FdInterestReportDetail, + FdInterestPostingReportService, + FdInterestSummaryRow, + FdOutsideApprovalRow, + FdReportScope, + FdType +} from './fd-interest-posting-report.service'; + +/** + * Fixed deposit interest posting reports. + * + * On a product's posting date the system generates, at noon, the list of what each deposit will be + * paid. One user of the branch reviews it and a second approves it — the maker cannot be the checker, + * because posting interest moves money to customers and, for a special deposit, straight out to their + * savings account. The approved amounts are posted when the branch closes the day, and until then it + * cannot close. Regenerating replaces the report and needs approving again. + */ +@Component({ + selector: 'mifosx-fd-interest-posting-report', + templateUrl: './fd-interest-posting-report.component.html', + styleUrls: ['./fd-interest-posting-report.component.scss'], + imports: [ + ...STANDALONE_SHARED_IMPORTS, + FormsModule, + FaIconComponent + ] +}) +export class FdInterestPostingReportComponent implements OnInit { + private reportService = inject(FdInterestPostingReportService); + private settingsService = inject(SettingsService); + + postingDate = ''; + loading = false; + busy = false; + errorMessage = ''; + notice = ''; + + reports: FdInterestReport[] = []; + selected: FdInterestReportDetail | null = null; + /** Products with interest due and no report yet — the noon job ran before they had anything due, or failed. */ + due: FdInterestDue[] = []; + + /** 'own' — the user's own branch; 'others' — every other branch, a head-office-only tab. */ + tab: FdReportScope = 'own'; + isHeadOffice = false; + myBranch = ''; + /** False where the bank has deposits on the old direct-posting path; every list here is then empty by design. */ + approvalEnabled = true; + + ngOnInit(): void { + this.postingDate = this.businessDateIso(); + this.reportService.context().subscribe({ + next: (ctx) => { + this.isHeadOffice = !!ctx?.headOffice; + this.myBranch = ctx?.officeName ?? ''; + this.approvalEnabled = ctx?.approvalEnabled !== false; + }, + error: () => (this.isHeadOffice = false) + }); + this.load(); + } + + switchTab(tab: FdReportScope): void { + if (this.tab === tab) { + return; + } + this.tab = tab; + this.selected = null; + this.notice = ''; + this.load(); + } + + /** Acting for another branch is the head office's exception, so it is spelt out before it happens. */ + private confirmOnBehalf(officeName: string, action: string): boolean { + if (this.tab !== 'others') { + return true; + } + return confirm( + `${officeName} has not opened today. You are about to ${action} its fixed deposit interest posting report on its ` + + 'behalf, from the head office. Continue?' + ); + } + + load(): void { + this.loading = true; + this.errorMessage = ''; + this.reportService.list(this.postingDate || undefined, undefined, undefined, this.tab).subscribe({ + next: (reports) => { + this.reports = reports ?? []; + this.loading = false; + const stillThere = this.selected && this.reports.find((r) => r.id === this.selected?.id); + if (stillThere) { + this.open(stillThere); + } else { + this.selected = null; + } + }, + error: (err) => { + this.loading = false; + this.errorMessage = this.messageOf(err, 'Could not load the fixed deposit interest posting reports.'); + } + }); + this.reportService.dueWithoutReport(this.postingDate || undefined, this.tab).subscribe({ + next: (due) => (this.due = due ?? []), + error: () => (this.due = []) + }); + } + + generate(item: FdInterestDue): void { + if (!this.confirmOnBehalf(item.officeName, 'generate')) { + return; + } + this.busy = true; + this.notice = ''; + this.errorMessage = ''; + this.reportService.regenerate(item.productId, item.officeId, item.postingDate).subscribe({ + next: (res) => { + this.busy = false; + this.notice = `Report generated for ${item.productName} — ${item.officeName}. Another user of the branch has to approve it.`; + this.load(); + this.reportService.detail(res.reportId).subscribe((detail) => (this.selected = detail)); + }, + error: (err) => { + this.busy = false; + this.errorMessage = this.messageOf(err, 'Could not generate the report.'); + } + }); + } + + open(report: FdInterestReport): void { + this.errorMessage = ''; + this.reportService.detail(report.id).subscribe({ + next: (detail) => (this.selected = detail), + error: (err) => (this.errorMessage = this.messageOf(err, 'Could not load the report.')) + }); + } + + regenerate(report: FdInterestReport): void { + if ( + !confirm( + `Regenerate the fixed deposit interest posting report for ${report.productName} — ${report.officeName}? ` + + 'It will need approving again.' + ) || + !this.confirmOnBehalf(report.officeName, 'regenerate') + ) { + return; + } + this.busy = true; + this.notice = ''; + this.errorMessage = ''; + this.reportService.regenerate(report.productId, report.officeId, report.postingDate).subscribe({ + next: (res) => { + this.busy = false; + this.notice = `Report regenerated for ${report.productName}. Another user of the branch has to approve it.`; + this.selected = null; + this.load(); + this.reportService.detail(res.reportId).subscribe((detail) => (this.selected = detail)); + }, + error: (err) => { + this.busy = false; + this.errorMessage = this.messageOf(err, 'Could not regenerate the report.'); + } + }); + } + + approve(report: FdInterestReport): void { + const payout = + report.fdType === 'SPECIAL' + ? ' This is a monthly-income product, so the interest is paid out to the customers’ linked savings accounts.' + : ''; + if ( + !confirm( + `Approve interest of ₹${report.totalInterest} across ${report.accountCount} deposit(s) for ${report.productName}?` + + `${payout} It will be posted when the branch closes the day.` + ) || + !this.confirmOnBehalf(report.officeName, 'approve') + ) { + return; + } + this.busy = true; + this.notice = ''; + this.errorMessage = ''; + this.reportService.approve(report.id).subscribe({ + next: () => { + this.busy = false; + this.notice = `Approved. Interest for ${report.productName} — ${report.officeName} will be posted when the branch closes the day.`; + this.load(); + }, + error: (err) => { + this.busy = false; + this.errorMessage = this.messageOf(err, 'Could not approve the report.'); + } + }); + } + + canRegenerate(report: FdInterestReport): boolean { + return report.canAct && (report.status === 'GENERATED' || report.status === 'APPROVED'); + } + + /** + * The server decides this, not the screen: it alone knows who generated the report, and the maker + * cannot be the checker. + */ + canApprove(report: FdInterestReport): boolean { + return !!report.canApprove; + } + + statusClass(status: string): string { + return ( + { + GENERATED: 'fd-badge--pending', + APPROVED: 'fd-badge--approved', + POSTED: 'fd-badge--posted', + SUPERSEDED: 'fd-badge--muted' + }[status] ?? 'fd-badge--muted' + ); + } + + /** What the scheme means for the customer, spelt out rather than left as a code. */ + fdTypeLabel(fdType: FdType | string): string { + return ( + { + SINGLE: 'Single — interest paid at maturity', + DOUBLE: 'Double — interest compounded into the deposit', + SPECIAL: 'Special — interest paid monthly to linked savings' + }[fdType] ?? String(fdType ?? '') + ); + } + + fdTypeShort(fdType: FdType | string): string { + return ( + { + SINGLE: 'Single', + DOUBLE: 'Double', + SPECIAL: 'Special' + }[fdType] ?? String(fdType ?? '') + ); + } + + fdTypeClass(fdType: FdType | string): string { + return ( + { + SINGLE: 'fd-chip--single', + DOUBLE: 'fd-chip--double', + SPECIAL: 'fd-chip--special' + }[fdType] ?? 'fd-chip--single' + ); + } + + /** Pending reports that block this user's own day close; another branch's pending reports block only that branch. */ + get pendingCount(): number { + return this.tab === 'own' ? this.reports.filter((r) => r.status === 'GENERATED').length : 0; + } + + // ── Summary strip: the day's reports, at a glance ── + + get dueTotal(): number { + return this.due.reduce((sum, d) => sum + Number(d.totalInterest || 0), 0); + } + + get dueAccountTotal(): number { + return this.due.reduce((sum, d) => sum + Number(d.accountCount || 0), 0); + } + + private reportsWithStatus(status: string): FdInterestReport[] { + return this.reports.filter((r) => r.status === status); + } + + private totalOf(list: FdInterestReport[]): number { + return list.reduce((sum, r) => sum + Number(r.totalInterest || 0), 0); + } + + get awaitingReports(): FdInterestReport[] { + return this.reportsWithStatus('GENERATED'); + } + + get approvedReports(): FdInterestReport[] { + return this.reportsWithStatus('APPROVED'); + } + + get postedReports(): FdInterestReport[] { + return this.reportsWithStatus('POSTED'); + } + + get awaitingTotal(): number { + return this.totalOf(this.awaitingReports); + } + + get approvedTotal(): number { + return this.totalOf(this.approvedReports); + } + + get postedTotal(): number { + return this.totalOf(this.postedReports); + } + + // ── Preview: what each deposit has earned so far, as if posted today. View only. ── + + showPreview = false; + previewLoading = false; + previewRows: FdInterestPreviewRow[] = []; + previewAsOf = ''; + + openPreview(): void { + this.showPreview = true; + this.previewLoading = true; + this.errorMessage = ''; + this.previewAsOf = this.businessDateIso(); + this.reportService.preview().subscribe({ + next: (rows) => { + this.previewRows = rows ?? []; + this.previewLoading = false; + }, + error: (err) => { + this.previewLoading = false; + this.previewRows = []; + this.errorMessage = this.messageOf(err, 'Could not load the fixed deposit interest preview.'); + } + }); + } + + closePreview(): void { + this.showPreview = false; + } + + get previewTotal(): number { + return this.previewRows.reduce((sum, r) => sum + Number(r.interest || 0), 0); + } + + /** Totals by branch, product and scheme, for the summary above the deposit list and the export. */ + get previewGroups(): { + officeName: string; + productName: string; + fdType: string; + accounts: number; + interest: number; + }[] { + const map = new Map< + string, + { officeName: string; productName: string; fdType: string; accounts: number; interest: number } + >(); + this.previewRows.forEach((r) => { + const key = `${r.officeName}|${r.productName}`; + const g = map.get(key) ?? { + officeName: r.officeName, + productName: r.productName, + fdType: r.fdType, + accounts: 0, + interest: 0 + }; + g.accounts += 1; + g.interest += Number(r.interest || 0); + map.set(key, g); + }); + return [...map.values()]; + } + + exportPreview(): void { + const stamp = `Fixed deposit interest earned to ${this.previewAsOf} — preview, not posted`; + exportWorkbook( + [ + { + sheetName: 'By deposit', + title: 'Fixed Deposit Interest Preview', + subtitle: stamp, + headers: [ + 'Branch', + 'Product', + 'Scheme', + 'Account No', + 'Customer', + 'Interest For', + 'Deposit', + 'Rate %', + 'Maturity', + 'Balance', + 'Pays Into', + 'Interest So Far' + ], + widths: [ + 18, + 22, + 10, + 20, + 26, + 12, + 16, + 8, + 12, + 16, + 18, + 16 + ], + rows: [ + ...this.previewRows.map((r) => [ + r.officeName, + r.productName, + this.fdTypeShort(r.fdType), + r.accountNo, + r.clientName, + r.interestPostingDate ?? '', + Number(r.depositAmount || 0), + Number(r.interestRate || 0), + r.maturityDate ?? '', + Number(r.balance || 0), + r.payoutAccountNo ?? '', + Number(r.interest || 0) + ]), + [ + 'TOTAL', + '', + '', + '', + '', + '', + '', + '', + '', + '', + '', + this.previewTotal + ] + ] + }, + { + sheetName: 'By branch & product', + title: 'Fixed Deposit Interest Preview — Totals', + subtitle: stamp, + headers: [ + 'Branch', + 'Product', + 'Scheme', + 'Deposits', + 'Interest So Far' + ], + widths: [ + 18, + 22, + 10, + 10, + 16 + ], + rows: [ + ...this.previewGroups.map((g) => [ + g.officeName, + g.productName, + this.fdTypeShort(g.fdType), + g.accounts, + g.interest + ]), + [ + 'TOTAL', + '', + '', + this.previewRows.length, + this.previewTotal + ] + ] + } + ], + `fd-interest-preview-${this.previewAsOf}` + ); + } + + // ── Outside approval: deposit interest posted without this report, chiefly at maturity ── + + showOutsideApproval = false; + outsideApprovalLoading = false; + outsideApprovalRows: FdOutsideApprovalRow[] = []; + outsideApprovalFrom = ''; + outsideApprovalTo = ''; + + openOutsideApproval(): void { + this.showOutsideApproval = true; + if (!this.outsideApprovalTo) { + this.outsideApprovalTo = this.businessDateIso(); + this.outsideApprovalFrom = `${this.outsideApprovalTo.slice(0, 7)}-01`; + } + this.loadOutsideApproval(); + } + + loadOutsideApproval(): void { + this.outsideApprovalLoading = true; + this.errorMessage = ''; + this.reportService + .outsideApproval(this.outsideApprovalFrom || undefined, this.outsideApprovalTo || undefined, undefined, this.tab) + .subscribe({ + next: (rows) => { + this.outsideApprovalRows = rows ?? []; + this.outsideApprovalLoading = false; + }, + error: (err) => { + this.outsideApprovalLoading = false; + this.outsideApprovalRows = []; + this.errorMessage = this.messageOf(err, 'Could not load deposit interest posted outside this report.'); + } + }); + } + + closeOutsideApproval(): void { + this.showOutsideApproval = false; + } + + get outsideApprovalTotal(): number { + return this.outsideApprovalRows.reduce((sum, r) => sum + Number(r.interestAmount || 0), 0); + } + + /** Totals by product and scheme, so a branch can see at a glance where its unapproved interest sits. */ + get outsideApprovalGroups(): { productName: string; fdType: string; accounts: number; interest: number }[] { + const map = new Map(); + this.outsideApprovalRows.forEach((r) => { + const g = map.get(r.productName) ?? { productName: r.productName, fdType: r.fdType, accounts: 0, interest: 0 }; + g.accounts += 1; + g.interest += Number(r.interestAmount || 0); + map.set(r.productName, g); + }); + return [...map.values()]; + } + + // ── Product summary: due for the product vs posted through this report vs posted at maturity ── + // Head office only, same as the branch-wise summary it reuses — a branch reads its own due and + // posted totals off the strip and tables above, which already cover it. + + showProductSummary = false; + productSummaryLoading = false; + productSummaryFrom = ''; + productSummaryTo = ''; + private summaryRows: FdInterestSummaryRow[] = []; + private productOutsideRows: FdOutsideApprovalRow[] = []; + + openProductSummary(): void { + this.showProductSummary = true; + if (!this.productSummaryTo) { + this.productSummaryTo = this.businessDateIso(); + this.productSummaryFrom = `${this.productSummaryTo.slice(0, 7)}-01`; + } + this.loadProductSummary(); + } + + loadProductSummary(): void { + this.productSummaryLoading = true; + this.errorMessage = ''; + let pending = 2; + const done = () => { + pending -= 1; + if (pending === 0) this.productSummaryLoading = false; + }; + this.reportService.summary(this.productSummaryFrom || undefined, this.productSummaryTo || undefined).subscribe({ + next: (rows) => { + this.summaryRows = rows ?? []; + done(); + }, + error: (err) => { + this.summaryRows = []; + this.errorMessage = this.messageOf(err, 'Could not load the product summary.'); + done(); + } + }); + this.reportService + .outsideApproval(this.productSummaryFrom || undefined, this.productSummaryTo || undefined) + .subscribe({ + next: (rows) => { + this.productOutsideRows = rows ?? []; + done(); + }, + error: () => { + this.productOutsideRows = []; + done(); + } + }); + } + + closeProductSummary(): void { + this.showProductSummary = false; + } + + /** + * One row per product and scheme: what posted through this report (and what is still pending + * approval or posting), plus what posted outside it — chiefly a deposit's last interest, paid the + * moment it matured rather than through a periodic report. + */ + get productSummaryRows(): { + productId: number; + productName: string; + fdType: string; + postingPeriodLabel: string; + posted: number; + pending: number; + maturityPosted: number; + total: number; + }[] { + const map = new Map< + number, + { + productId: number; + productName: string; + fdType: string; + postingPeriodLabel: string; + posted: number; + pending: number; + maturityPosted: number; + total: number; + } + >(); + const get = (productId: number, productName: string, fdType: string, label: string) => { + const existing = map.get(productId); + if (existing) return existing; + const created = { + productId, + productName, + fdType, + postingPeriodLabel: label, + posted: 0, + pending: 0, + maturityPosted: 0, + total: 0 + }; + map.set(productId, created); + return created; + }; + this.summaryRows.forEach((r) => { + const row = get(r.productId, r.productName, r.fdType, r.postingPeriodLabel); + const amount = Number(r.totalInterest || 0); + if (r.status === 'POSTED') { + row.posted += amount; + } else if (r.status === 'GENERATED' || r.status === 'APPROVED') { + row.pending += amount; + } + }); + this.productOutsideRows.forEach((r) => { + const row = get(r.productId, r.productName, r.fdType, ''); + row.maturityPosted += Number(r.interestAmount || 0); + }); + map.forEach((row) => { + row.total = row.posted + row.pending + row.maturityPosted; + }); + return [...map.values()].sort((a, b) => a.productName.localeCompare(b.productName)); + } + + get productSummaryGrandTotal(): { posted: number; pending: number; maturityPosted: number; total: number } { + return this.productSummaryRows.reduce( + (sum, r) => ({ + posted: sum.posted + r.posted, + pending: sum.pending + r.pending, + maturityPosted: sum.maturityPosted + r.maturityPosted, + total: sum.total + r.total + }), + { posted: 0, pending: 0, maturityPosted: 0, total: 0 } + ); + } + + private businessDateIso(): string { + const businessDate = this.settingsService.businessDate as any; + const parsed = businessDate ? new Date(businessDate) : new Date(); + if (isNaN(parsed.getTime())) { + return new Date().toISOString().slice(0, 10); + } + const month = String(parsed.getMonth() + 1).padStart(2, '0'); + const day = String(parsed.getDate()).padStart(2, '0'); + return `${parsed.getFullYear()}-${month}-${day}`; + } + + private messageOf(err: any, fallback: string): string { + return ( + err?.error?.errors?.[0]?.defaultUserMessage ?? err?.error?.defaultUserMessage ?? err?.error?.message ?? fallback + ); + } +} diff --git a/src/app/fd-interest-posting-report/fd-interest-posting-report.service.ts b/src/app/fd-interest-posting-report/fd-interest-posting-report.service.ts new file mode 100644 index 000000000..72d12f397 --- /dev/null +++ b/src/app/fd-interest-posting-report/fd-interest-posting-report.service.ts @@ -0,0 +1,280 @@ +/** + * 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'; + +export type FdInterestReportStatus = 'GENERATED' | 'APPROVED' | 'POSTED' | 'SUPERSEDED'; + +/** Which branches a list covers: the user's own, or (head office only) every other branch. */ +export type FdReportScope = 'own' | 'others'; + +/** The deposit scheme, which decides where a posting of interest ends up. */ +export type FdType = 'SINGLE' | 'DOUBLE' | 'SPECIAL'; + +export interface FdInterestReport { + id: number; + productId: number; + productName: string; + fdType: FdType; + officeId: number; + officeName: string; + postingDate: string; + status: FdInterestReportStatus; + totalInterest: number; + accountCount: number; + generationMode: 'AUTO' | 'MANUAL'; + generatedAt: string; + generatedBy: string | null; + approvedAt: string | null; + approvedBy: string | null; + approvedOnBehalf: boolean; + postedAt: string | null; + failureReason: string | null; + /** The product's posting cycle in words — Monthly, Quarterly, Half-yearly, Yearly. */ + postingPeriodLabel: string; + /** Whether this user may regenerate it: their own branch, or the head office for a branch that did not open. */ + canAct: boolean; + blockedReason: string | null; + /** Whether this user may approve it — false for the user who generated it, who cannot be its checker. */ + canApprove: boolean; + approveBlockedReason: string | null; +} + +export interface FdInterestReportLine { + fdAccountId: number; + accountNo: string; + clientName: string; + /** + * The day after the deposit's last paid period ended, or its activation date for a first-ever + * posting — null for a line posted before this was tracked. Paired with interestPostingDate, it is + * the span the interest was earned over. + */ + interestPeriodFrom: string | null; + /** + * The period this interest is for, as the interest engine dated it — not the day the branch closes. + * A deposit owed two unpaid periods appears on two lines, one per date. + */ + interestPostingDate: string; + interestAmount: number; + balanceBefore: number; + balanceAfter: number; + depositAmount: number | null; + interestRate: number | null; + maturityDate: string | null; + /** SPECIAL only: the linked savings account this interest is swept to after posting. */ + payoutSavingsAccountId: number | null; + payoutAccountNo: string | null; + payoutTransactionId: number | null; + fdTransactionId: number | null; +} + +export interface FdInterestReportDetail extends FdInterestReport { + payableGlCode: string | null; + payableGlName: string | null; + paidGlCode: string | null; + paidGlName: string | null; + lines: FdInterestReportLine[]; +} + +export interface FdInterestDue { + productId: number; + productName: string; + fdType: FdType; + officeId: number; + officeName: string; + postingDate: string; + accountCount: number; + totalInterest: number; + canAct: boolean; + blockedReason: string | null; +} + +export interface FdInterestSummaryRow { + id: number; + postingDate: string; + officeId: number; + officeName: string; + productId: number; + productName: string; + fdType: FdType; + accountCount: number; + totalInterest: number; + status: FdInterestReportStatus; + approvedBy: string | null; + approvedOnBehalf: boolean; + approvedAt: string | null; + postedAt: string | null; + postingPeriodLabel: string; +} + +/** + * One fixed deposit interest transaction that reached the customer's account without this report's + * approval — chiefly a deposit that matured before its own posting cycle completed, whose last period + * is settled directly by the maturity job. View only, for visibility. + */ +export interface FdOutsideApprovalRow { + transactionId: number; + fdAccountId: number; + accountNo: string; + clientName: string; + officeId: number; + officeName: string; + productId: number; + productName: string; + fdType: FdType; + interestFor: string; + interestAmount: number; + /** When the posting actually reached the account — not the period it is for. */ + postedAt: string; +} + +export interface FdInterestPreviewRow { + fdAccountId: number; + accountNo: string; + clientName: string; + officeId: number; + officeName: string; + productId: number; + productName: string; + fdType: FdType; + /** The day after the deposit's last paid period ended, or its activation date for the first one. */ + interestPeriodFrom: string | null; + /** The period this interest belongs to, as the engine dated it. */ + interestPostingDate: string; + depositAmount: number | null; + interestRate: number | null; + maturityDate: string | null; + balance: number; + interest: number; + payoutAccountNo: string | null; +} + +/** One product's deposits due on the day being closed, as the day-close screen shows them. */ +export interface FdDayCloseDueRow { + productId: number; + productName: string; + fdType: FdType; + accountCount: number; + totalInterest: number; + reportStatus: FdInterestReportStatus | 'NONE'; +} + +export interface FdDayCloseCheck { + businessDate: string; + canClose: boolean; + pendingProducts: string[]; + due: FdDayCloseDueRow[]; +} + +/** + * The fixed deposit interest posting reports, one per branch and product: generated at noon on a + * posting date, approved by a second user of the branch, posted when the branch closes the day. + */ +@Injectable({ providedIn: 'root' }) +export class FdInterestPostingReportService { + private http = inject(HttpClient); + private readonly base = '/fixeddepositinterestpostingreports'; + + /** scope 'own' is the user's own branch; 'others' (head office only) every other branch. */ + list( + postingDate?: string, + status?: string, + officeId?: number, + scope?: FdReportScope + ): Observable { + let params = new HttpParams(); + if (scope) params = params.set('scope', scope); + if (postingDate) params = params.set('postingDate', postingDate); + if (status) params = params.set('status', status); + if (officeId) params = params.set('officeId', String(officeId)); + return this.http.get(this.base, { params }); + } + + /** Branch and product pairs with deposit interest due by the date and no report yet. */ + dueWithoutReport(postingDate?: string, scope?: FdReportScope): Observable { + let params = new HttpParams(); + if (scope) params = params.set('scope', scope); + if (postingDate) params = params.set('postingDate', postingDate); + return this.http.get(`${this.base}/due-without-report`, { params }); + } + + /** + * The signed-in user's branch, whether it is the head office, and whether deposits are on the + * approval path at all — with that switched off these screens are empty by design. + */ + context(): Observable<{ officeId: number; officeName: string; headOffice: boolean; approvalEnabled: boolean }> { + return this.http.get<{ officeId: number; officeName: string; headOffice: boolean; approvalEnabled: boolean }>( + `${this.base}/context` + ); + } + + detail(reportId: number): Observable { + return this.http.get(`${this.base}/${reportId}`); + } + + regenerate( + productId: number, + officeId: number, + postingDate: string + ): Observable<{ reportId: number; status: string }> { + return this.http.post<{ reportId: number; status: string }>( + this.base, + { productId, officeId, postingDate }, + { params: new HttpParams().set('command', 'regenerate') } + ); + } + + approve(reportId: number): Observable<{ reportId: number; status: string }> { + return this.http.post<{ reportId: number; status: string }>( + `${this.base}/${reportId}`, + {}, + { params: new HttpParams().set('command', 'approve') } + ); + } + + /** What each deposit has earned and not been paid, as if posted today. View only. */ + preview(officeId?: number): Observable { + let params = new HttpParams(); + if (officeId) params = params.set('officeId', String(officeId)); + return this.http.get(`${this.base}/preview`, { params }); + } + + /** What stops a branch closing its day, and the deposits behind it. */ + dayCloseCheck(date?: string, officeId?: number): Observable { + let params = new HttpParams(); + if (date) params = params.set('date', date); + if (officeId) params = params.set('officeId', String(officeId)); + return this.http.get(`${this.base}/day-close-check`, { params }); + } + + /** The head office's branch-wise view over a date range. */ + summary(fromDate?: string, toDate?: string, productId?: number): Observable { + let params = new HttpParams(); + if (fromDate) params = params.set('fromDate', fromDate); + if (toDate) params = params.set('toDate', toDate); + if (productId) params = params.set('productId', String(productId)); + return this.http.get(`${this.base}/summary`, { params }); + } + + /** Deposit interest posted without this report's approval — a deposit maturing mid-cycle, chiefly. */ + outsideApproval( + fromDate?: string, + toDate?: string, + officeId?: number, + scope?: FdReportScope + ): Observable { + let params = new HttpParams(); + if (scope) params = params.set('scope', scope); + if (fromDate) params = params.set('fromDate', fromDate); + if (toDate) params = params.set('toDate', toDate); + if (officeId) params = params.set('officeId', String(officeId)); + return this.http.get(`${this.base}/outside-approval`, { params }); + } +} diff --git a/src/app/home/dashboard/crr-slr-dialog/crr-slr-dialog.component.html b/src/app/home/dashboard/crr-slr-dialog/crr-slr-dialog.component.html index 20d63ccac..e331d938c 100644 --- a/src/app/home/dashboard/crr-slr-dialog/crr-slr-dialog.component.html +++ b/src/app/home/dashboard/crr-slr-dialog/crr-slr-dialog.component.html @@ -74,9 +74,7 @@ (3%)" while Admin holds 4% states a rate the bank is not on. -->
CRR Required{{ crrRate === null ? '' : ' (' + crrRate + '%)' }}
{{ crore(crrRequired) }}Cr
-
- SLR Req: {{ crore(slrRequired) }}{{ slrRate === null ? '' : ' Cr (' + slrRate + '%)' }} -
+
SLR Req: {{ crore(slrRequired) }}{{ slrRate === null ? '' : ' Cr (' + slrRate + '%)' }}
@@ -143,21 +141,22 @@ - } - @empty { + } @empty { - No accounts are mapped to this head, so the return reports nothing under it. Map them in - Admin → RBI Reserve Configuration. + No accounts are mapped to this head, so the return reports nothing under it. Map them in Admin → + RBI Reserve Configuration. } - Total Demand Liabilities ({{ demandLiabilities.length }} accounts) - {{ totalOf(demandLiabilities, "openingValue") }} - {{ totalOf(demandLiabilities, "currentValue") }} + + Total Demand Liabilities ({{ demandLiabilities.length }} accounts) + + {{ totalOf(demandLiabilities, 'openingValue') }} + {{ totalOf(demandLiabilities, 'currentValue') }} {{ totalChange(demandLiabilities) }} @@ -206,21 +205,22 @@ - } - @empty { + } @empty { - No accounts are mapped to this head, so the return reports nothing under it. Map them in - Admin → RBI Reserve Configuration. + No accounts are mapped to this head, so the return reports nothing under it. Map them in Admin → + RBI Reserve Configuration. } - Total Time Liabilities ({{ timeLiabilities.length }} accounts) - {{ totalOf(timeLiabilities, "openingValue") }} - {{ totalOf(timeLiabilities, "currentValue") }} + + Total Time Liabilities ({{ timeLiabilities.length }} accounts) + + {{ totalOf(timeLiabilities, 'openingValue') }} + {{ totalOf(timeLiabilities, 'currentValue') }} {{ totalChange(timeLiabilities) }} @@ -263,12 +263,11 @@ {{ item.status }} - } - @empty { + } @empty { - No accounts are mapped to this head, so the return reports nothing under it. Map them in - Admin → RBI Reserve Configuration. + No accounts are mapped to this head, so the return reports nothing under it. Map them in Admin → + RBI Reserve Configuration. } @@ -278,8 +277,8 @@ Total Assets with Banking System ( Deducted from NDTL ) - {{ totalOf(bankingAssets, "openingValue") }} - {{ totalOf(bankingAssets, "currentValue") }} + {{ totalOf(bankingAssets, 'openingValue') }} + {{ totalOf(bankingAssets, 'currentValue') }} {{ totalChange(bankingAssets) }} @@ -328,12 +327,11 @@ {{ item.status }} - } - @empty { + } @empty { - No accounts are mapped to this head, so the return reports nothing under it. Map them in - Admin → RBI Reserve Configuration. + No accounts are mapped to this head, so the return reports nothing under it. Map them in Admin → + RBI Reserve Configuration. } @@ -341,8 +339,8 @@ Total Liquid Assets (SLR Eligible) - {{ totalOf(liquidAssets, "openingValue") }} - {{ totalOf(liquidAssets, "currentValue") }} + {{ totalOf(liquidAssets, 'openingValue') }} + {{ totalOf(liquidAssets, 'currentValue') }} {{ totalChange(liquidAssets) }} diff --git a/src/app/home/dashboard/crr-slr-dialog/crr-slr-dialog.component.ts b/src/app/home/dashboard/crr-slr-dialog/crr-slr-dialog.component.ts index 04770829f..6344659ad 100644 --- a/src/app/home/dashboard/crr-slr-dialog/crr-slr-dialog.component.ts +++ b/src/app/home/dashboard/crr-slr-dialog/crr-slr-dialog.component.ts @@ -128,8 +128,7 @@ export class CrrSlrDialogComponent implements OnInit { if (!columns.length) { this.unavailable = - data?.unmetRequirements?.[0] ?? - 'Form 1 could not be computed. Check the CRR/SLR configuration in Admin.'; + data?.unmetRequirements?.[0] ?? 'Form 1 could not be computed. Check the CRR/SLR configuration in Admin.'; return; } // The return computes but says something would make it wrong to file — a rate never set, a @@ -175,7 +174,6 @@ export class CrrSlrDialogComponent implements OnInit { }); } - /** * The four tabs, filled from the return's own lines. * diff --git a/src/app/home/dashboard/dashboard.component.html b/src/app/home/dashboard/dashboard.component.html index 371434f2f..f0889bd9d 100644 --- a/src/app/home/dashboard/dashboard.component.html +++ b/src/app/home/dashboard/dashboard.component.html @@ -33,11 +33,15 @@ rate Admin was on. -->
- CRR Required{{ crrSlr.crrRate === null ? '' : ' (' + crrSlr.crrRate + '%)' }} + CRR Required{{ crrSlr.crrRate === null ? '' : ' (' + crrSlr.crrRate + '%)' }} {{ crrSlr.crrRequired }}
- SLR Required{{ crrSlr.slrRate === null ? '' : ' (' + crrSlr.slrRate + '%)' }} + SLR Required{{ crrSlr.slrRate === null ? '' : ' (' + crrSlr.slrRate + '%)' }} {{ crrSlr.slrRequired }}
diff --git a/src/app/home/dashboard/dashboard.component.ts b/src/app/home/dashboard/dashboard.component.ts index 4b92acef6..c0605e700 100644 --- a/src/app/home/dashboard/dashboard.component.ts +++ b/src/app/home/dashboard/dashboard.component.ts @@ -116,7 +116,12 @@ export class DashboardComponent implements OnInit { const crrNeed = data?.partB?.required?.[latest]; const slrHeld = data?.partC?.maintained?.[latest]; const slrNeed = data?.partC?.required?.[latest]; - if ([crrHeld, crrNeed, slrHeld, slrNeed].every((v) => v !== null && v !== undefined)) { + if ([ + crrHeld, + crrNeed, + slrHeld, + slrNeed + ].every((v) => v !== null && v !== undefined)) { this.crrSlr.status = crrHeld >= crrNeed && slrHeld >= slrNeed ? 'ok' : 'short'; } }, diff --git a/src/app/loans/loans-account-stepper/loan-product-template-card/loan-product-template-card.component.html b/src/app/loans/loans-account-stepper/loan-product-template-card/loan-product-template-card.component.html new file mode 100644 index 000000000..20170e15f --- /dev/null +++ b/src/app/loans/loans-account-stepper/loan-product-template-card/loan-product-template-card.component.html @@ -0,0 +1,43 @@ + + +@if (product) { +
+
+ {{ product.name }} + {{ category }} + {{ type }} +
+
+
+ Amount + + @if (product.minPrincipal != null || product.maxPrincipal != null) { + {{ product.minPrincipal | number: '1.0-0' }} – {{ product.maxPrincipal | number: '1.0-0' }} + } @else { + — + } + +
+
+ Tenure (repayments) + + @if (product.minNumberOfRepayments != null || product.maxNumberOfRepayments != null) { + {{ product.minNumberOfRepayments }} – {{ product.maxNumberOfRepayments }} + } @else { + — + } + +
+
+ Short name + {{ product.shortName || '—' }} +
+
+
+} diff --git a/src/app/loans/loans-account-stepper/loan-product-template-card/loan-product-template-card.component.scss b/src/app/loans/loans-account-stepper/loan-product-template-card/loan-product-template-card.component.scss new file mode 100644 index 000000000..3e4824e67 --- /dev/null +++ b/src/app/loans/loans-account-stepper/loan-product-template-card/loan-product-template-card.component.scss @@ -0,0 +1,52 @@ +/** + * 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/. + */ + +.lptc { + margin: 4px 0 16px; + padding: 12px 16px; + border: 1px solid #bae6fd; + border-radius: 8px; + background: #f0f9ff; + + &__head { + display: flex; + align-items: center; + gap: 8px; + margin-bottom: 8px; + } + + &__chip { + padding: 1px 10px; + border-radius: 999px; + background: #e0f2fe; + color: #075985; + font-size: 12px; + font-weight: 600; + + &--type { + background: #ede9fe; + color: #5b21b6; + } + } + + &__grid { + display: grid; + grid-template-columns: repeat(3, minmax(0, 1fr)); + gap: 8px 16px; + font-size: 13px; + color: #1e293b; + } + + &__label { + display: block; + font-size: 11px; + letter-spacing: 0.04em; + text-transform: uppercase; + color: #64748b; + } +} diff --git a/src/app/loans/loans-account-stepper/loan-product-template-card/loan-product-template-card.component.ts b/src/app/loans/loans-account-stepper/loan-product-template-card/loan-product-template-card.component.ts new file mode 100644 index 000000000..be03d6c85 --- /dev/null +++ b/src/app/loans/loans-account-stepper/loan-product-template-card/loan-product-template-card.component.ts @@ -0,0 +1,37 @@ +/** + * 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 { DecimalPipe } from '@angular/common'; +import { productCategoryLabel, productTypeLabel } from 'app/products/loan-products/loan-product-types'; + +/** + * The product picked on the loan account form, summarised with its category, type and the main limits, so the + * officer can see what the account will be opened under before going on. + */ +@Component({ + selector: 'mifosx-loan-product-template-card', + templateUrl: './loan-product-template-card.component.html', + styleUrls: ['./loan-product-template-card.component.scss'], + imports: [DecimalPipe] +}) +export class LoanProductTemplateCardComponent { + /** A product from the catalogue (the list shape), or undefined when none is picked. */ + @Input() product: any; + + get category(): string { + return productCategoryLabel(this.product?.advanceExtensions?.productTypeGroup); + } + + get type(): string { + return productTypeLabel( + this.product?.advanceExtensions?.productTypeGroup, + this.product?.advanceExtensions?.productTypeSubGroup + ); + } +} diff --git a/src/app/loans/loans-account-stepper/loans-account-details-step/loans-account-details-step.component.ts b/src/app/loans/loans-account-stepper/loans-account-details-step/loans-account-details-step.component.ts index 8d649088c..e60f519d1 100644 --- a/src/app/loans/loans-account-stepper/loans-account-details-step/loans-account-details-step.component.ts +++ b/src/app/loans/loans-account-stepper/loans-account-details-step/loans-account-details-step.component.ts @@ -32,6 +32,9 @@ import { MatCheckbox } from '@angular/material/checkbox'; import { MatStepperPrevious, MatStepperNext } from '@angular/material/stepper'; import { FaIconComponent } from '@fortawesome/angular-fontawesome'; import { STANDALONE_SHARED_IMPORTS } from 'app/standalone-shared.module'; +import { LoanProductCatalogService } from '../../services/loan-product-catalog.service'; +import { LoanProductTemplateCardComponent } from '../loan-product-template-card/loan-product-template-card.component'; +import { PRODUCT_SUB_TYPES, PRODUCT_TYPE_GROUPS } from 'app/products/loan-products/loan-product-types'; /** * Loans Account Details Step @@ -49,7 +52,8 @@ import { STANDALONE_SHARED_IMPORTS } from 'app/standalone-shared.module'; MatStepperPrevious, FaIconComponent, MatStepperNext, - AsyncPipe + AsyncPipe, + LoanProductTemplateCardComponent ] }) export class LoansAccountDetailsStepComponent implements OnInit, OnDestroy { @@ -59,6 +63,7 @@ export class LoansAccountDetailsStepComponent implements OnInit, OnDestroy { private translateService = inject(TranslateService); private settingsService = inject(SettingsService); private commons = inject(Commons); + private catalog = inject(LoanProductCatalogService); //** Defining PlaceHolders for the search bar */ placeHolderLabel = ''; @@ -88,6 +93,27 @@ export class LoansAccountDetailsStepComponent implements OnInit, OnDestroy { loanId: any = null; + /** Category and product type narrow the product picker to the active products of that kind. */ + readonly categories = PRODUCT_TYPE_GROUPS; + categoryFilter = ''; + typeFilter = ''; + /** Ids of the products active today; null until read, or if the read failed (then nothing is hidden). */ + private activeProductIds: Set | null = null; + /** Every product from the template, before any filter. */ + private allProducts: any[] = []; + + /** Types offered for the chosen category; every type when no category is chosen. */ + get typeOptions() { + return this.categoryFilter + ? (PRODUCT_SUB_TYPES[this.categoryFilter] ?? []) + : Object.values(PRODUCT_SUB_TYPES).flat(); + } + + /** The product picked in the form, with its category and type, for the template card. */ + get selectedProduct(): any { + return this.catalog.get(this.loansAccountDetailsForm?.get('productId')?.value); + } + loanProductSelected = false; /** Currency data. */ protected productData: ReplaySubject = new ReplaySubject(1); @@ -115,7 +141,8 @@ export class LoansAccountDetailsStepComponent implements OnInit, OnDestroy { this.maxDate = this.settingsService.maxFutureDate; this.buildDependencies(); if (this.loansAccountTemplate) { - this.productList = this.loansAccountTemplate.productOptions.sort(this.commons.dynamicSort('name')); + this.allProducts = this.loansAccountTemplate.productOptions.sort(this.commons.dynamicSort('name')); + this.productList = this.allProducts; if (this.loansAccountTemplate.loanProductId) { this.loansAccountDetailsForm.patchValue({ productId: this.loansAccountTemplate.loanProductId, @@ -136,6 +163,44 @@ export class LoansAccountDetailsStepComponent implements OnInit, OnDestroy { this.searchItem(); }); this.productData.next(this.productList.slice()); + this.loadActiveProducts(); + } + + /** + * A new account can only be opened on a product that is active today, and the picker can be narrowed by category + * and type. Editing an existing account keeps every product, since its own product may have closed since. + */ + private loadActiveProducts(): void { + if (this.loanId) { + return; + } + this.catalog.activeProducts().subscribe((products) => { + this.activeProductIds = products.length > 0 ? new Set(products.map((product) => Number(product.id))) : null; + this.applyProductFilters(); + }); + } + + onCategoryChange(): void { + // A type belongs to one category, so a type from another category cannot stay selected. + if (this.typeFilter && !this.typeOptions.some((type) => type.id === this.typeFilter)) { + this.typeFilter = ''; + } + this.applyProductFilters(); + } + + /** Rebuilds the product list from the template: active only, then category and type, then the search text. */ + applyProductFilters(): void { + this.productList = this.allProducts.filter((product: any) => { + if (this.activeProductIds && !this.activeProductIds.has(Number(product.id))) { + return false; + } + const ext = this.catalog.classification(product.id); + if (this.categoryFilter && ext['productTypeGroup'] !== this.categoryFilter) { + return false; + } + return !this.typeFilter || ext['productTypeSubGroup'] === this.typeFilter; + }); + this.searchItem(); } ngOnDestroy(): void { diff --git a/src/app/loans/loans-view/transactions-tab/transactions-tab.component.html b/src/app/loans/loans-view/transactions-tab/transactions-tab.component.html index 00bf57722..9a138bac5 100644 --- a/src/app/loans/loans-view/transactions-tab/transactions-tab.component.html +++ b/src/app/loans/loans-view/transactions-tab/transactions-tab.component.html @@ -177,10 +177,6 @@ } @if (allowUndoTransaction(transaction)) { - - + + + +
+
+
+
+
{{ totalCount | number }}
+
Total Agents
+
+
+
+
+
+
{{ activeCount | number }}
+
Active Agents
+
+
+
+
+
+
{{ totalCurrentBalance | number: '1.2-2' }}
+
Cash With Agents (Unsettled)
+
+
+
+ +
+
+ +
+ + @if (totalCount === 0) { +
+
+

No agents yet

+

Create one so daily collections can be recorded against it.

+ +
+ } @else { +
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
Agent Code +
+
+
+
{{ a.agentCode }}
+
+
+
Agent Name{{ a.agentNameId }}GL Account + @if (a.glAccountCode) { + {{ a.glAccountCode }} · {{ a.glAccountName }} + } @else { + Not set + } + Branch + @if (a.homeBranch) { + {{ a.homeBranch }} + } @else { + Not set + } + City{{ a.agentCity }}Saving A/C No + {{ a.agentSavingAccountNo }} + Current Balance + {{ a.agentCurrentBalance | number: '1.2-2' }} + Status + + {{ a.active ? 'Active' : 'Inactive' }} + + Created + {{ a.createdDate ? (a.createdDate | dateFormat: 'DD MMM YYYY, hh:mm A') : '—' }} + Actions + + @if (a.closeDate) { + Closed {{ a.closeDate | dateFormat }} + } @else { + + } +
+ + @if (filteredCount === 0 && totalCount > 0) { +
+ + No agents match your filter. +
+ } + + +
+ } +
+ diff --git a/src/app/organization/agent-master/agent-master.component.scss b/src/app/organization/agent-master/agent-master.component.scss new file mode 100644 index 000000000..4f6cd4cfe --- /dev/null +++ b/src/app/organization/agent-master/agent-master.component.scss @@ -0,0 +1,59 @@ +/** + * Copyright since 2025 Mifos Initiative + * + * This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. + */ + +@use 'assets/styles/product-list' as pl; + +@include pl.product-list-styles; + +/* Agent-specific indigo accent */ +:host { + --pl-accent: #4338ca; + --pl-accent-l: #e0e7ff; +} + +/* A retired agent stays listed but reads as switched off. */ +.am-row--inactive td:not(.num) { + opacity: 0.55; +} + +/* Closing is the last, irreversible step, so it does not read like the buttons beside it. */ +.pl-action-btn--close { + color: #9b2c2c; +} + +/* A closed agent shows when, in place of the actions it no longer has. */ +.am-closed-on { + font-size: 12px; + font-style: italic; + color: #9b2c2c; +} + +.am-pill { + display: inline-block; + padding: 3px 11px; + border-radius: 11px; + font-size: 0.72rem; + font-weight: 500; + line-height: 1.7; + background: #dcfce7; + color: #15803d; + white-space: nowrap; +} + +.am-pill--off { + background: rgb(0 0 0 / 7%); + color: rgb(0 0 0 / 52%); +} + +/* An agent with no branch is an agent no branch's batch run will collect for - say so plainly + rather than showing an em dash that reads like "nothing to see here". */ +.am-no-branch { + font-size: 12px; + font-style: italic; + color: #9b2c2c; +} diff --git a/src/app/organization/agent-master/agent-master.component.ts b/src/app/organization/agent-master/agent-master.component.ts new file mode 100644 index 000000000..88d7675b2 --- /dev/null +++ b/src/app/organization/agent-master/agent-master.component.ts @@ -0,0 +1,147 @@ +/** + * Copyright since 2025 Mifos Initiative + * + * This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. + */ + +/** Angular Imports */ +import { Component, OnInit, ViewChild, inject } from '@angular/core'; +import { MatPaginator } from '@angular/material/paginator'; +import { MatSort, MatSortHeader } from '@angular/material/sort'; +import { + MatTable, + MatTableDataSource, + MatColumnDef, + MatHeaderCellDef, + MatHeaderCell, + MatCellDef, + MatCell, + MatHeaderRowDef, + MatHeaderRow, + MatRowDef, + MatRow +} from '@angular/material/table'; +import { MatTooltip } from '@angular/material/tooltip'; +import { FaIconComponent } from '@fortawesome/angular-fontawesome'; +import { STANDALONE_SHARED_IMPORTS } from 'app/standalone-shared.module'; + +/** Custom Services */ +import { OrganizationService } from 'app/organization/organization.service'; +import { AuthenticationService } from 'app/core/authentication/authentication.service'; +import { forkJoin, of } from 'rxjs'; +import { catchError } from 'rxjs/operators'; +import { officesUnder } from './offices-under'; + +/** + * Agent Master list. + * + * The DDS collection agents on file. Follows the same list -> create/edit shape as the product lists + * and shares their stylesheet. + */ +@Component({ + selector: 'mifosx-agent-master', + templateUrl: './agent-master.component.html', + styleUrls: ['./agent-master.component.scss'], + imports: [ + ...STANDALONE_SHARED_IMPORTS, + FaIconComponent, + MatTable, + MatSort, + MatColumnDef, + MatHeaderCellDef, + MatHeaderCell, + MatSortHeader, + MatCellDef, + MatCell, + MatHeaderRowDef, + MatHeaderRow, + MatRowDef, + MatRow, + MatPaginator, + MatTooltip + ] +}) +export class AgentMasterComponent implements OnInit { + private organizationService = inject(OrganizationService); + private authenticationService = inject(AuthenticationService); + + displayedColumns: string[] = [ + 'agentCode', + 'clientName', + 'homeBranch', + 'glAccount', + 'agentCity', + 'agentSavingAccountNo', + 'agentCurrentBalance', + 'status', + 'createdDate', + 'actions' + ]; + + dataSource: MatTableDataSource = new MatTableDataSource([]); + agents: any[] = []; + filteredCount = 0; + + @ViewChild(MatPaginator, { static: true }) paginator: MatPaginator; + @ViewChild(MatSort, { static: true }) sort: MatSort; + + get totalCount(): number { + return this.agents.length; + } + + get activeCount(): number { + return this.agents.filter((agent) => agent.active).length; + } + + /** + * Cash the agents are collectively holding but have not settled to a branch yet — an agent's + * balance rises on every party collection and falls when it hands the money over. Inactive agents + * count too: money still out with a retired agent is exactly what this figure is for. + */ + get totalCurrentBalance(): number { + return this.agents.reduce((sum, agent) => sum + Number(agent.agentCurrentBalance || 0), 0); + } + + ngOnInit(): void { + this.loadAgents(); + } + + private loadAgents(): void { + const loginOfficeId = this.authenticationService.getCredentials()?.officeId; + forkJoin({ + agents: this.organizationService.getAgents(), + offices: this.organizationService.getOffices().pipe(catchError(() => of([]))) + }).subscribe(({ agents, offices }: any) => { + // The branch logged in to sees its own agents and those of every branch under it - Head Office, + // at the top, sees them all. + const visible = new Set( + officesUnder(Array.isArray(offices) ? offices : offices?.pageItems || [], loginOfficeId).map( + (office: any) => office.id + ) + ); + this.agents = (agents || []).filter( + (agent: any) => !loginOfficeId || visible.size === 0 || visible.has(agent.officeId) + ); + this.dataSource = new MatTableDataSource(this.agents); + this.dataSource.paginator = this.paginator; + this.dataSource.sort = this.sort; + this.filteredCount = this.agents.length; + this.dataSource.connect().subscribe((rows) => (this.filteredCount = rows.length)); + }); + } + + applyFilter(filterValue: string): void { + this.dataSource.filter = filterValue.trim().toLowerCase(); + } + + private static readonly MONEY = new Intl.NumberFormat('en-IN', { + minimumFractionDigits: 2, + maximumFractionDigits: 2 + }); + + money(value: any): string { + return AgentMasterComponent.MONEY.format(Number(value || 0)); + } +} diff --git a/src/app/organization/agent-master/create-agent/create-agent.component.html b/src/app/organization/agent-master/create-agent/create-agent.component.html new file mode 100644 index 000000000..807a9b050 --- /dev/null +++ b/src/app/organization/agent-master/create-agent/create-agent.component.html @@ -0,0 +1,318 @@ + + +
+
+ +

Agent Master Entry

+
+ +
+
+ +
+
+ +

Agent Details

+
+
+ + Client ID or Name + + + + + @for (client of clientOptions; track client.id) { + +
+ {{ client.id }} + {{ client.displayName }} + {{ client.officeName }} +
+
+ } + @if (clientListIsPartial) { + + Showing {{ clientOptions.length }} of {{ clientTotal }} — type a name to find the rest + + } +
+ @if (agentForm.get('clientId')?.hasError('pattern')) { + Pick a client from the list + } @else if (clientAlreadyAgent) { + This client is already agent {{ clientAlreadyAgent }} + } @else if (clientAgeProblem) { + {{ clientAgeProblem }} + } @else if (clientOtherBranch) { + This client belongs to another branch + } @else if (clientLookupFailed) { + No client found with this ID + } @else if (loadedClientName) { + {{ loadedClientName }} + } +
+ + + Agent Code + + + + + Agent Name ID + + + + + Agent Address (Line 1) + + + + + Agent Address (Line 2) + + + + + Agent City + + + + + Agent PinCode + + + + + Agent Telephone + + + + + + Home Branch + + @for (office of offices; track office.id) { + {{ office.name }} + } + + The branch this agent's collections belong to + + + + + GL Account + + @for (account of glAccounts; track account.id) { + {{ account.glCode }} · {{ account.name }} + } + + This agent's collections pass through it. One agent to an account. + + + + + Agent Saving A/C Type + + @for (accountType of savingsAccountTypes; track accountType) { + {{ savingsAccountTypeCodes[accountType] || accountType }} + } + + + @if (savingsAccountTypes.length === 0) { + @if (loadedClientName) { + + {{ loadedClientName }} has no savings account — commission can only be paid into one + + } @else { + Enter a Client ID first — types come from that client's own accounts + } + } + + + + + Agent Saving A/C No + + @for (account of accountsOfChosenType; track account.id) { + {{ account.accountNo }} + } + + @if (accountsOfChosenType.length === 0) { + Choose an A/C Type first + } + + + + Agent Opening Date + + + + + + + + Agent Birth Date + + + From the client's record + +
+
+ +
+
+ +

Guarantor's Detail

+ +
+ + @if (guarantors.length === 0) { +

No guarantors added yet.

+ } + + @for (guarantor of guarantors.controls; track guarantor; let i = $index) { +
+ + Client ID or Name + + + + @for (client of guarantorOptionsFor(guarantor); track client.id) { + +
+ {{ client.id }} + {{ client.displayName }} + {{ client.officeName }} +
+
+ } +
+ + @if (guarantor.get('clientId')?.hasError('selfGuarantor')) { + The agent cannot be their own guarantor + } @else if (guarantor.get('clientId')?.hasError('duplicateGuarantor')) { + This client is already a guarantor above + } @else if (guarantor.get('clientId')?.hasError('pattern')) { + Pick a client from the list + } +
+ + Guarantor Name + + + + Address 1 + + + + Address 2 + + + + City + + + + Pin Code + + + + Telephone + + + +
+ } +
+ +
+
+ +

Balances

+
+
+ + Agent Current Balance + + + + + Agent Opening Balance + + +
+
+ +
+ + + + +
+
+
+
diff --git a/src/app/organization/agent-master/create-agent/create-agent.component.scss b/src/app/organization/agent-master/create-agent/create-agent.component.scss new file mode 100644 index 000000000..e1852abe2 --- /dev/null +++ b/src/app/organization/agent-master/create-agent/create-agent.component.scss @@ -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/. + */ + +$primary: #0f766e; + +.am-container { + max-width: 1040px; + margin: 0 auto; + padding: 24px 16px; +} + +.am-header { + display: flex; + align-items: center; + gap: 16px; + margin-bottom: 20px; + + &__title { + margin: 0; + font-size: 22px; + font-weight: 500; + } +} + +.am-back { + color: $primary; +} + +.am-card { + background: var(--md-sys-color-surface, #fff); + border-radius: 8px; + box-shadow: 0 2px 8px rgb(0 0 0 / 8%); + padding: 8px 24px 24px; +} + +.am-section { + padding: 20px 0; + border-bottom: 1px solid rgb(0 0 0 / 8%); + + &:last-of-type { + border-bottom: none; + } + + &__header { + display: flex; + align-items: center; + gap: 10px; + margin-bottom: 16px; + + i { + color: $primary; + } + + h3 { + margin: 0; + font-size: 16px; + font-weight: 600; + flex: 1; + } + } +} + +.am-grid { + display: grid; + grid-template-columns: repeat(3, 1fr); + gap: 8px 16px; +} + +.am-field { + width: 100%; + + &--wide { + grid-column: span 2; + } +} + +.am-add-guarantor { + color: $primary; +} + +.am-empty-guarantors { + color: rgb(0 0 0 / 50%); + font-size: 14px; + margin: 0 0 8px; +} + +.am-guarantor-row { + display: grid; + grid-template-columns: repeat(7, 1fr) auto; + gap: 8px 12px; + align-items: start; + padding: 10px 0; + border-top: 1px dashed rgb(0 0 0 / 10%); + + &:first-of-type { + border-top: none; + } +} + +.am-limit-row { + display: grid; + grid-template-columns: repeat(3, 1fr) auto; + gap: 8px 12px; + align-items: start; + padding: 10px 0; + border-top: 1px dashed rgb(0 0 0 / 10%); + + &:first-of-type { + border-top: none; + } +} + +.am-remove-guarantor { + color: #b91c1c; + margin-top: 8px; +} + +.am-action-bar { + display: flex; + justify-content: flex-end; + gap: 12px; + margin-top: 20px; + padding-top: 16px; + flex-wrap: wrap; +} + +/* Client ID lookup came back empty — the officer typed an id that doesn't exist. */ +.am-hint--error { + color: #b3261e; +} + +@media (width <= 900px) { + .am-grid { + grid-template-columns: repeat(2, 1fr); + } + + .am-guarantor-row, + .am-limit-row { + grid-template-columns: repeat(2, 1fr); + } +} + +/* One row of the client search list: the id, the name, and the branch - as on the Party form. */ +.am-opt { + display: flex; + align-items: baseline; + gap: 10px; + overflow: hidden; +} + +.am-opt-more { + font-size: 0.75rem; + font-style: italic; + opacity: 0.75; +} + +.am-opt-id { + flex: 0 0 auto; + min-width: 52px; + font-family: ui-monospace, 'SF Mono', 'Cascadia Mono', monospace; + font-size: 0.78rem; + color: #0f766e; +} + +.am-opt-main { + flex: 1 1 auto; + min-width: 8rem; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.am-opt-sub { + flex: 0 1 auto; + min-width: 0; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + font-size: 0.75rem; + color: rgb(0 0 0 / 50%); +} + +.am-search-ic { + margin-right: 8px; + color: rgb(0 0 0 / 40%); +} diff --git a/src/app/organization/agent-master/create-agent/create-agent.component.ts b/src/app/organization/agent-master/create-agent/create-agent.component.ts new file mode 100644 index 000000000..0847ae6a6 --- /dev/null +++ b/src/app/organization/agent-master/create-agent/create-agent.component.ts @@ -0,0 +1,929 @@ +/** + * Copyright since 2025 Mifos Initiative + * + * This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. + */ + +/** Angular Imports */ +import { ChangeDetectorRef, Component, ElementRef, OnInit, ViewChild, inject } from '@angular/core'; +import { + UntypedFormBuilder, + UntypedFormGroup, + UntypedFormArray, + Validators, + ValidatorFn, + AbstractControl, + ValidationErrors +} from '@angular/forms'; +import { ActivatedRoute, Router } from '@angular/router'; +import { debounceTime, distinctUntilChanged } from 'rxjs/operators'; +import { STANDALONE_SHARED_IMPORTS } from 'app/standalone-shared.module'; + +/** Custom Services */ +import { OrganizationService } from 'app/organization/organization.service'; +import { AccountingService } from 'app/accounting/accounting.service'; +import { SystemService } from 'app/system/system.service'; +import { ClientsService } from 'app/clients/clients.service'; +import { SettingsService } from 'app/settings/settings.service'; +import { Dates } from 'app/core/utils/dates'; +import { AlertService } from 'app/core/alert/alert.service'; +import { AuthenticationService } from 'app/core/authentication/authentication.service'; + +/** Custom Directives */ +import { RestrictInputDirective } from 'app/directives/restrict-input.directive'; +import { MatAutocompleteModule, MatAutocompleteTrigger } from '@angular/material/autocomplete'; +import { searchClientsAnyCase } from 'app/deposits/party-master/client-search'; +import { officesUnder } from '../offices-under'; + +/** Writes a value into a form group only when there is something to write. */ +function setGroupField(group: UntypedFormGroup, controlName: string, value: any): void { + if (value === null || value === undefined || value === '') { + return; + } + group.get(controlName)?.setValue(String(value)); +} + +/** + * Agent Master Entry component. + * + * A DDS collection agent — either a person or an institution — registered with its own settlement + * savings account and one or more guarantors. Modelled on the legacy Agent Master Entry screen, field + * for field. + * + * Entering a Client ID fills the name, address, city, pincode and telephone from that client, the way + * the legacy screen did, defaults the home branch to that client's office, and stocks the account + * type and account number dropdowns from that client's own accounts. Filled fields stay editable. + * + * Backed by its own DDS Agent API (`/dds/agents`) — independent of Staff/Client CRUD, per the DDS + * agent/commission hierarchy being genuinely new feature work. + */ +@Component({ + selector: 'mifosx-create-agent', + templateUrl: './create-agent.component.html', + styleUrls: ['./create-agent.component.scss'], + imports: [ + ...STANDALONE_SHARED_IMPORTS, + // See the Party form: an unimported directive is ignored without a word. + RestrictInputDirective, + MatAutocompleteModule + ] +}) +export class CreateAgentComponent implements OnInit { + private formBuilder = inject(UntypedFormBuilder); + private router = inject(Router); + private route = inject(ActivatedRoute); + private organizationService = inject(OrganizationService); + private clientsService = inject(ClientsService); + private accountingService = inject(AccountingService); + private systemService = inject(SystemService); + private settingsService = inject(SettingsService); + private dateUtils = inject(Dates); + private alertService = inject(AlertService); + private authenticationService = inject(AuthenticationService); + private changeDetector = inject(ChangeDetectorRef); + + /** The agent's client field and its list, so a list that arrives late can still be opened. */ + @ViewChild('clientTrigger') clientTrigger?: MatAutocompleteTrigger; + @ViewChild('clientInput') clientInput?: ElementRef; + + /** + * The branch the officer is logged in to. An agent is opened there and nowhere else: the Home + * Branch list offers only it, and only its clients can be picked. + */ + private readonly loginOfficeId: number | null = this.authenticationService.getCredentials()?.officeId ?? null; + + /** + * The branch the agent is being opened for - the one picked in Home Branch, the login branch until + * then. Its clients are the ones offered, since an agent collects for the branch they belong to. + */ + private get branchOfficeId(): number | null { + return Number(this.agentForm?.get('officeId')?.value) || this.loginOfficeId; + } + + /** Set when the client keyed belongs to another branch, which cannot be made an agent here. */ + clientOtherBranch = false; + + /** + * The code of the agent the keyed client already is, if any. One client is one agent, so they are + * left out of the list and refused when typed. + */ + clientAlreadyAgent = ''; + private agentCodeByClientId: Record = {}; + + /** Why the picked client's age rules them out as an agent, or empty when it does not. */ + clientAgeProblem = ''; + /** The ages an agent is taken on at, matching the rule the server applies. */ + private static readonly MIN_AGENT_AGE = 16; + private static readonly MAX_AGENT_AGE = 60; + + /** The agent's client list without the clients who are already agents. */ + private notYetAgents(clients: any[]): any[] { + return clients.filter((client: any) => !this.agentCodeByClientId[String(client.id)]); + } + + agentId: number | null = null; + + /** Shown under the Client ID field once a client has been resolved. */ + loadedClientName = ''; + clientLookupFailed = false; + + /** + * Clients to pick from, as on the Party form: the first page when the field is focused, then + * whatever matches what has been typed. An officer knows the agent's name, rarely their client id. + */ + clientOptions: any[] = []; + private initialClients: any[] = []; + private static readonly CLIENT_LIST_SIZE = 50; + clientTotal = 0; + showingClientList = false; + + get clientListIsPartial(): boolean { + return this.showingClientList && this.clientTotal > this.clientOptions.length; + } + + /** + * The client's own open savings accounts, fetched once the client is resolved. The commission + * account is picked from these rather than typed, so it is always an account that exists and + * always belongs to this agent. + */ + clientSavingsAccounts: any[] = []; + + /** + * The account types this client actually holds, taken from their own accounts rather than a fixed + * list. A fixed list would offer types the client has nothing under, and its wording would have to + * match the product names by hand for the filter below to work at all. + */ + savingsAccountTypes: string[] = []; + + /** + * Each type's product code, which is what the dropdown shows. The type is still saved by its name, + * as before, so what other screens and reports read is unchanged. + */ + savingsAccountTypeCodes: Record = {}; + + /** Fineract's deposit-account kinds: 100 savings, 200 fixed deposit, 300 recurring deposit. */ + private static readonly SAVINGS_DEPOSIT_TYPE = 100; + + /** Branches, for the Home Branch field. Defaulted from the client's own office. */ + offices: any[] = []; + + /** + * The ledger accounts an agent's collections may pass through. Header accounts are left out: they + * are headings over other accounts and carry no entries, so offering one only leads to a refusal. + */ + glAccounts: any[] = []; + + /** + * The savings product a DDS customer's own scheme account is opened under, read from the setting + * that names it rather than guessed from a product name a bank is free to change. + * + * Held so those accounts can be kept out of the agent's own list. They are savings accounts and + * pass every other test, but they belong to a customer's scheme: commission paid into one would be + * counted as that customer's deposits by every interest run afterwards. The server refuses it + * either way - this is so the choice is never offered rather than taken and then thrown back. + */ + private ddsProductId: number | null = null; + + /** + * The accounts on offer once a type has been chosen. Type is asked first, as the legacy screen + * did, so the officer narrows to a kind of account and then picks one of that kind. + */ + get accountsOfChosenType(): any[] { + const chosenType = this.agentForm?.get('agentSavingAccountType')?.value; + if (!chosenType) { + return []; + } + return this.clientSavingsAccounts.filter((account: any) => account.productName === chosenType); + } + + agentForm: UntypedFormGroup = this.formBuilder.group({ + clientId: [ + '', + // A name typed to search with is not a client; only an id picked or keyed saves. + [ + Validators.required, + Validators.pattern(/^\d+$/) + ] + ], + agentCode: [ + { + value: '', + disabled: true + } + ], + agentNameId: [ + '', + Validators.required + ], + agentAddress1: [ + '', + Validators.required + ], + agentAddress2: [''], + agentCity: [ + '', + Validators.required + ], + agentPinCode: [ + '', + Validators.required + ], + agentTelephone: [''], + // Which branch this agent collects out of. Holds the office's id, not its name - the name is + // only what the dropdown shows. The DDS batch runs filter by it. + officeId: [ + '', + Validators.required + ], + // Asked before the account number, which is then narrowed to accounts of this type. + agentSavingAccountType: [ + '', + Validators.required + ], + agentSavingAccountNo: [ + '', + Validators.required + ], + agentOpeningDate: [ + '', + Validators.required + ], + // Filled from the client, whose date of birth was checked when they were opened. + agentBirthDate: [''], + agentCurrentBalance: [ + { + value: 0, + disabled: true + } + ], + agentOpeningBalance: [0], + /* + * Required. An agent sharing the scheme's one account leaves a balance that says money is + * outstanding without saying whose - which is exactly what this field was added to answer. + */ + glAccountId: [ + '', + Validators.required + ], + guarantors: this.formBuilder.array([]) + }); + + ngOnInit(): void { + // A different branch has different clients: the list is read again, and a client already keyed + // is checked against the new branch. + this.agentForm + .get('officeId') + ?.valueChanges.pipe(distinctUntilChanged()) + .subscribe(() => { + this.initialClients = []; + if (!this.agentId && this.agentForm.get('clientId')?.value) { + this.loadClient(); + } + }); + // Who is already an agent, so they are neither offered nor accepted as a new one. + this.organizationService.getAgents().subscribe({ + next: (agents: any[]) => { + this.agentCodeByClientId = {}; + for (const agent of agents || []) { + if (agent.active !== false && agent.clientId != null) { + this.agentCodeByClientId[String(agent.clientId)] = agent.agentCode; + } + } + }, + error: () => (this.agentCodeByClientId = {}) + }); + + this.organizationService.getOffices().subscribe({ + next: (offices: any) => { + const all = Array.isArray(offices) ? offices : offices?.pageItems || []; + // The branch logged in to and every branch under it - Head Office, at the top, sees them all. + this.offices = officesUnder(all, this.loginOfficeId); + if (!this.agentId && this.offices.some((office: any) => office.id === this.loginOfficeId)) { + this.agentForm.get('officeId')?.setValue(this.loginOfficeId); + } + }, + error: () => (this.offices = []) + }); + + this.systemService.getConfigurationByName('dds-deposit-product').subscribe({ + next: (config: any) => { + this.ddsProductId = config?.enabled && config?.value ? Number(config.value) : null; + // The client may already have been keyed while this was in flight, so the list is rebuilt + // rather than left showing an account this now knows to exclude. + const clientId = this.agentForm.get('clientId')?.value; + if (clientId) { + this.loadClientSavingsAccounts(String(clientId)); + } + }, + error: () => (this.ddsProductId = null) + }); + + this.accountingService.getChartOfAccounts().subscribe({ + next: (accounts: any) => { + const rows = Array.isArray(accounts) ? accounts : accounts?.pageItems || []; + // usage 1 is a detail account, 2 a header. Disabled accounts refuse every posting, so an + // agent pointed at one would fail on their first collection rather than here. + this.glAccounts = rows.filter((a: any) => a.usage?.id === 1 && !a.disabled); + }, + error: () => (this.glAccounts = []) + }); + + // An id in the route means this is an edit; load that agent into the form. + const routeId = this.route.snapshot.paramMap.get('agentId'); + if (routeId) { + this.organizationService.getAgent(routeId).subscribe((agent: any) => this.patchFormFromAgent(agent)); + } + + // Fill the client-derived fields as soon as typing settles - no need to tab out of the field. + this.agentForm + .get('clientId') + ?.valueChanges.pipe(debounceTime(500), distinctUntilChanged()) + .subscribe((value) => { + const typed = String(value ?? '').trim(); + // A name is something to search for, not a client yet - the fields stay until one is picked. + if (typed && !/^\d+$/.test(typed)) { + this.searchClients(typed); + return; + } + if (!typed) { + this.showClientList(); + } + this.loadClient(); + // A guarantor row that was fine may now be the agent itself, or the other way round. + for (const row of this.guarantors.controls) { + row.get('clientId')?.updateValueAndValidity(); + // And the agent's own client leaves (or rejoins) each row's list. + if (!row.get('clientId')?.value) { + this.setGuarantorOptions(row, this.eligibleGuarantors(row, this.initialClients)); + } + } + }); + } + + get guarantors(): UntypedFormArray { + return this.agentForm.get('guarantors') as UntypedFormArray; + } + + private buildGuarantorGroup(): UntypedFormGroup { + const group = this.formBuilder.group({ + clientId: [ + '', + [ + Validators.required, + Validators.pattern(/^\d+$/), + this.notTheAgentItself(), + this.notAlreadyAGuarantor() + ] + ], + guarantorName: [ + '', + Validators.required + ], + address1: [ + '', + Validators.required + ], + address2: [''], + city: [ + '', + Validators.required + ], + pinCode: [ + '', + Validators.required + ], + telephone: [''] + }); + + // A guarantor is a client too, so its row fills from the client the same way the agent's does. + // Searched by name like the agent's own client field; a picked or keyed id fills the row. + group + .get('clientId') + ?.valueChanges.pipe(debounceTime(500), distinctUntilChanged()) + .subscribe((value) => { + const typed = String(value ?? '').trim(); + if (typed && !/^\d+$/.test(typed)) { + searchClientsAnyCase(this.clientsService, typed).subscribe((found: any[]) => { + this.setGuarantorOptions(group, this.eligibleGuarantors(group, found).slice(0, 25)); + const panel = (group as any).panel; + this.openWhenFocused(panel?.trigger, panel?.input); + }); + return; + } + this.setGuarantorOptions(group, []); + this.loadGuarantorClient(group, typed); + if (!typed) { + // Emptied: the list comes back rather than leaving nothing to pick from. + this.onGuarantorFocus(group); + } + // Two rows naming the same client: the other row's check has to be run again too. + for (const row of this.guarantors.controls) { + if (row !== group) { + row.get('clientId')?.updateValueAndValidity({ emitEvent: false }); + } + } + }); + + return group; + } + + /** + * Each guarantor row's list lives on the row itself, as a plain array the template reads - the same + * way the agent's own list works - so one row's typing never fills another's. + */ + private setGuarantorOptions(group: AbstractControl, options: any[]): void { + (group as any).clientOptions = options; + } + + guarantorOptionsFor(group: AbstractControl): any[] { + return (group as any).clientOptions || GUARANTOR_NO_OPTIONS; + } + + /** + * Opens the client list on a guarantor row as soon as it is focused, as the agent's field does. + * The list is filled before the panel is asked to open, since a panel opened empty stays shut. + */ + onGuarantorFocus(group: AbstractControl, trigger?: MatAutocompleteTrigger, input?: HTMLElement): void { + if (trigger) { + // Kept so a search answered later can open this row's list too. + (group as any).panel = { trigger, input }; + } + if (group.get('clientId')?.value) { + return; + } + const show = () => { + this.setGuarantorOptions(group, this.eligibleGuarantors(group, this.initialClients)); + const panel = (group as any).panel; + this.openWhenFocused(panel?.trigger, panel?.input); + }; + if (this.initialClients.length > 0) { + show(); + return; + } + this.clientsService.getFilteredClients('displayName', 'ASC', false, '', this.branchOfficeId).subscribe({ + next: (response: any) => { + const found = response?.pageItems || response || []; + this.initialClients = Array.isArray(found) ? found.slice(0, CreateAgentComponent.CLIENT_LIST_SIZE) : []; + this.clientTotal = response?.totalFilteredRecords ?? this.initialClients.length; + show(); + }, + error: () => undefined + }); + } + + /** Clients who can stand guarantor on this row: not the agent, and not already on another row. */ + private eligibleGuarantors(group: AbstractControl, found: any[]): any[] { + const agentClientId = String(this.agentForm.get('clientId')?.value ?? ''); + const taken = new Set( + this.guarantors.controls.filter((row) => row !== group).map((row) => String(row.get('clientId')?.value ?? '')) + ); + return found.filter((client: any) => String(client.id) !== agentClientId && !taken.has(String(client.id))); + } + + /** The same client cannot guarantee one agent twice - it counts as one guarantee, not two. */ + private notAlreadyAGuarantor(): ValidatorFn { + return (control: AbstractControl): ValidationErrors | null => { + if (!control.value || !this.agentForm) { + return null; + } + const repeated = this.guarantors.controls.some( + (row) => row.get('clientId') !== control && String(row.get('clientId')?.value) === String(control.value) + ); + return repeated ? { duplicateGuarantor: true } : null; + }; + } + + /** + * An agent cannot stand guarantor for itself - a guarantee is only worth something if it comes + * from someone else. + */ + private notTheAgentItself(): ValidatorFn { + return (control: AbstractControl): ValidationErrors | null => { + const agentClientId = this.agentForm?.get('clientId')?.value; + if (!control.value || !agentClientId) { + return null; + } + return String(control.value) === String(agentClientId) ? { selfGuarantor: true } : null; + }; + } + + /** Refills a guarantor row from its Client ID, mirroring the agent's own client lookup. */ + private loadGuarantorClient(group: UntypedFormGroup, clientId: any): void { + for (const controlName of [ + 'guarantorName', + 'telephone', + 'address1', + 'address2', + 'city', + 'pinCode' + ]) { + group.get(controlName)?.setValue(''); + } + if (!clientId) { + return; + } + + this.clientsService.getClientData(String(clientId), true).subscribe({ + next: (client: any) => { + setGroupField(group, 'guarantorName', client?.displayName); + setGroupField(group, 'telephone', client?.mobileNo); + this.clientsService.getClientAddressData(String(clientId), true).subscribe({ + next: (addresses: any) => { + const address = Array.isArray(addresses) ? addresses[0] : addresses; + if (!address) { + return; + } + setGroupField(group, 'address1', address.addressLine1 || address.street); + setGroupField(group, 'address2', address.addressLine2); + setGroupField(group, 'city', address.city); + setGroupField(group, 'pinCode', address.postalCode); + }, + error: () => undefined + }); + }, + // An id that matches no client just leaves the row for the officer to fill in. + error: () => undefined + }); + } + + addGuarantor(): void { + const group = this.buildGuarantorGroup(); + this.guarantors.push(group); + // Ready before the field is ever focused, so the list is there the moment it is clicked. + this.setGuarantorOptions(group, this.eligibleGuarantors(group, this.initialClients)); + } + + removeGuarantor(index: number): void { + this.guarantors.removeAt(index); + // A row that clashed with the removed one no longer does. + for (const row of this.guarantors.controls) { + row.get('clientId')?.updateValueAndValidity({ emitEvent: false }); + } + } + + /** The fields the legacy screen filled from the client, rather than asking the officer for. */ + private static readonly CLIENT_DERIVED_FIELDS = [ + 'agentNameId', + 'agentTelephone', + 'agentAddress1', + 'agentAddress2', + 'agentCity', + 'agentPinCode' + ]; + + /** Opens the client list when the field is focused, unless a client is already in it. */ + onClientFocus(): void { + if (this.agentId || this.agentForm.get('clientId')?.value) { + return; + } + this.showClientList(); + } + + /** Shows the first page of clients, fetching it the first time it is asked for. */ + private showClientList(): void { + if (this.agentId) { + return; + } + if (this.initialClients.length > 0) { + this.showingClientList = true; + this.clientOptions = this.notYetAgents(this.initialClients); + this.openWhenFocused(this.clientTrigger, this.clientInput?.nativeElement); + return; + } + // The chosen branch's clients only, since an agent is opened for the branch they belong to. + this.clientsService.getFilteredClients('displayName', 'ASC', false, '', this.branchOfficeId).subscribe({ + next: (response: any) => { + const found = response?.pageItems || response || []; + this.initialClients = Array.isArray(found) ? found.slice(0, CreateAgentComponent.CLIENT_LIST_SIZE) : []; + this.clientTotal = response?.totalFilteredRecords ?? this.initialClients.length; + this.showingClientList = true; + this.clientOptions = this.notYetAgents(this.initialClients); + this.openWhenFocused(this.clientTrigger, this.clientInput?.nativeElement); + }, + // A failed list is not a failed screen - the id can still be typed. + error: () => (this.clientOptions = []) + }); + } + + private searchClients(term: string): void { + searchClientsAnyCase(this.clientsService, term, this.branchOfficeId).subscribe((found: any[]) => { + this.showingClientList = false; + this.clientOptions = this.notYetAgents(found).slice(0, 25); + this.openWhenFocused(this.clientTrigger, this.clientInput?.nativeElement); + }); + } + + /** + * Shows a list that has just arrived. An autocomplete opened while its list was still empty stays + * shut when the options come in, so the screen is refreshed and the panel opened again - but only + * on the field still being typed in, never one the officer has moved on from. + */ + private openWhenFocused(trigger?: MatAutocompleteTrigger, input?: HTMLElement): void { + this.changeDetector.detectChanges(); + if (trigger && input && document.activeElement === input) { + trigger.openPanel(); + } + } + + /** + * Refills the client-derived fields whenever the Client ID changes, as the legacy screen did. + * They always show the client currently entered: changing the id replaces them, clearing it + * empties them. + */ + loadClient(): void { + if (this.agentId) { + return; + } + this.clearClientDerivedFields(); + this.agentForm.get('agentBirthDate')?.setValue(''); + this.loadedClientName = ''; + this.clientLookupFailed = false; + // A different client has different accounts, so anything picked for the previous one is stale. + this.clientSavingsAccounts = []; + this.savingsAccountTypes = []; + this.agentForm.get('agentSavingAccountType')?.setValue(''); + this.agentForm.get('agentSavingAccountNo')?.setValue(''); + + this.clientOtherBranch = false; + this.clientAlreadyAgent = ''; + this.clientAgeProblem = ''; + const clientId = this.agentForm.get('clientId')?.value; + if (!clientId) { + return; + } + + // One client is one agent: someone already on the books is not made a second agent. + this.clientAlreadyAgent = this.agentCodeByClientId[String(clientId)] || ''; + if (this.clientAlreadyAgent) { + return; + } + this.clientsService.getClientData(String(clientId), true).subscribe({ + next: (client: any) => { + // An agent is opened at the branch chosen, from that branch's own clients. + if (this.branchOfficeId && client?.officeId && Number(client.officeId) !== this.branchOfficeId) { + this.clientOtherBranch = true; + this.loadedClientName = ''; + return; + } + this.loadedClientName = client?.displayName || ''; + this.setField('agentNameId', client?.displayName); + this.setField('agentTelephone', client?.mobileNo); + // The client's own date of birth, already taken and checked when the client was opened - + // copied across rather than keyed again. + const birthDate = asDate(client?.dateOfBirth); + this.agentForm.get('agentBirthDate')?.setValue(birthDate); + // Agents are taken on at 16 to 59 - said here, before the rest is filled in, not on Save. + const age = ageOn(birthDate, new Date()); + this.clientAgeProblem = + age === null + ? 'This client has no date of birth on record' + : age < CreateAgentComponent.MIN_AGENT_AGE || age >= CreateAgentComponent.MAX_AGENT_AGE + ? `Age is ${age}. An agent must be ${CreateAgentComponent.MIN_AGENT_AGE} to ${CreateAgentComponent.MAX_AGENT_AGE - 1} years old` + : ''; + this.loadClientAddress(String(clientId)); + this.loadClientSavingsAccounts(String(clientId)); + }, + error: () => { + this.clientLookupFailed = true; + } + }); + } + + /** + * The client's own open savings accounts, to be picked from rather than typed. + * + * The account number used to be free text, and the live data showed the cost: agent AG0001 carried + * "1234567890", which matches no account at all - so every commission recorded against it had + * nowhere to go. Offering only the client's real accounts makes that impossible to key, and the + * account number then carries its correct product prefix by construction rather than by memory. + */ + private loadClientSavingsAccounts(clientId: string): void { + this.clientsService.getClientAccountData(clientId).subscribe({ + next: (accounts: any) => { + this.clientSavingsAccounts = (accounts?.savingsAccounts || []).filter( + (account: any) => + // Savings only. Commission is credited whenever a run posts, and a fixed deposit is a + // single sum placed for a term while a recurring one takes only its own instalments - + // neither accepts the credit, so the posting would fail rather than the choice. + // A DDS scheme account is a savings account too and so passes the test above. It is + // dropped by the product the setting names - not by a product called "DDS", which is a + // name the bank may change - so the list offers only accounts the agent can be paid in. + account.depositType?.id === CreateAgentComponent.SAVINGS_DEPOSIT_TYPE && + (this.ddsProductId === null || account.productId !== this.ddsProductId) && + (account.status?.active || account.status?.submittedAndPendingApproval || account.status?.approved) + ); + this.savingsAccountTypes = [ + ...new Set(this.clientSavingsAccounts.map((account: any) => account.productName).filter(Boolean)) + ]; + for (const account of this.clientSavingsAccounts) { + if (account.productName && account.shortProductName) { + this.savingsAccountTypeCodes[account.productName] = account.shortProductName; + } + } + }, + error: () => { + this.clientSavingsAccounts = []; + this.savingsAccountTypes = []; + } + }); + } + + /** + * Clears the account number when the type changes. + * + * Without this the previously picked account stays in the field while the list below it now shows + * a different type - the form would submit an account that contradicts its own type. + */ + onSavingsAccountTypePicked(): void { + this.agentForm.get('agentSavingAccountNo')?.setValue(''); + } + + private loadClientAddress(clientId: string): void { + this.clientsService.getClientAddressData(clientId, true).subscribe({ + next: (addresses: any) => { + const address = Array.isArray(addresses) ? addresses[0] : addresses; + if (!address) { + return; + } + this.setField('agentAddress1', address.addressLine1 || address.street); + this.setField('agentAddress2', address.addressLine2); + this.setField('agentCity', address.city); + this.setField('agentPinCode', address.postalCode); + }, + // A client with no address on file is normal — the officer just types it. + error: () => undefined + }); + } + + private clearClientDerivedFields(): void { + for (const controlName of CreateAgentComponent.CLIENT_DERIVED_FIELDS) { + this.agentForm.get(controlName)?.setValue(''); + } + } + + private setField(controlName: string, value: any): void { + if (value === null || value === undefined || value === '') { + return; + } + this.agentForm.get(controlName)?.setValue(String(value)); + } + + retrieve(): void { + const clientId = this.agentForm.get('clientId')?.value; + if (!clientId) { + return; + } + this.organizationService.getAgents().subscribe((agents: any[]) => { + const match = (agents || []).find((agent) => String(agent.clientId) === String(clientId)); + if (match) { + this.patchFormFromAgent(match); + } + }); + } + + cancel(): void { + this.agentId = null; + this.loadedClientName = ''; + this.clientLookupFailed = false; + this.agentForm.reset(); + this.agentForm.patchValue({ + agentCurrentBalance: 0, + agentOpeningBalance: 0 + }); + this.guarantors.clear(); + } + + exit(): void { + this.router.navigate(['/organization/agent-master']); + } + + save(): void { + const raw = this.agentForm.getRawValue(); + const dateFormat = this.settingsService.dateFormat; + const locale = this.settingsService.language.code; + + const payload: any = { + clientId: Number(raw.clientId), + agentNameId: raw.agentNameId, + agentAddress1: raw.agentAddress1, + agentAddress2: raw.agentAddress2 || undefined, + agentCity: raw.agentCity, + agentPinCode: raw.agentPinCode, + agentTelephone: raw.agentTelephone || undefined, + officeId: Number(raw.officeId), + glAccountId: raw.glAccountId ? Number(raw.glAccountId) : undefined, + agentSavingAccountType: raw.agentSavingAccountType, + agentSavingAccountNo: raw.agentSavingAccountNo, + agentOpeningDate: this.dateUtils.formatDate(raw.agentOpeningDate, dateFormat), + agentBirthDate: this.dateUtils.formatDate(raw.agentBirthDate, dateFormat), + agentOpeningBalance: raw.agentOpeningBalance || 0, + dateFormat, + locale, + guarantors: (raw.guarantors || []).map((guarantor: any) => ({ + clientId: Number(guarantor.clientId), + guarantorName: guarantor.guarantorName, + address1: guarantor.address1, + address2: guarantor.address2 || undefined, + city: guarantor.city, + pinCode: guarantor.pinCode, + telephone: guarantor.telephone || undefined + })) + }; + + const done = (response: any) => { + // Under maker-checker the command is only recorded, and nothing has been saved yet. + if (!!response?.commandId && response?.resourceId == null) { + this.alertService.alert({ + type: 'Sent for Approval', + message: this.agentId + ? `Changes to agent ${raw.agentNameId} were sent for a manager's approval and will take effect once approved.` + : `New agent ${raw.agentNameId} was sent for a manager's approval and will be created once approved.` + }); + } + this.exit(); + }; + + if (this.agentId) { + this.organizationService.updateAgent(this.agentId, payload).subscribe(done); + } else { + this.organizationService.createAgent(payload).subscribe(done); + } + } + + private patchFormFromAgent(agent: any): void { + this.agentId = agent.id; + this.loadedClientName = agent.clientName || ''; + this.clientLookupFailed = false; + this.agentForm.patchValue({ + clientId: agent.clientId, + agentCode: agent.agentCode, + agentNameId: agent.agentNameId, + agentAddress1: agent.agentAddress1, + agentAddress2: agent.agentAddress2, + agentCity: agent.agentCity, + agentPinCode: agent.agentPinCode, + agentTelephone: agent.agentTelephone, + officeId: agent.officeId ? Number(agent.officeId) : '', + glAccountId: agent.glAccountId ? Number(agent.glAccountId) : '', + agentSavingAccountType: agent.agentSavingAccountType, + agentSavingAccountNo: agent.agentSavingAccountNo, + agentOpeningDate: agent.agentOpeningDate ? new Date(agent.agentOpeningDate) : null, + agentBirthDate: agent.agentBirthDate ? new Date(agent.agentBirthDate) : null, + agentCurrentBalance: agent.agentCurrentBalance, + agentOpeningBalance: agent.agentOpeningBalance + }); + + // The account list is needed for the dropdown to show the already-saved account as chosen + // rather than blank, so it is fetched on edit as well as on the client lookup. + if (agent.clientId) { + this.loadClientSavingsAccounts(String(agent.clientId)); + } + + this.guarantors.clear(); + for (const guarantor of agent.guarantors || []) { + const group = this.buildGuarantorGroup(); + group.patchValue({ + clientId: guarantor.clientId, + guarantorName: guarantor.guarantorName, + address1: guarantor.address1, + address2: guarantor.address2, + city: guarantor.city, + pinCode: guarantor.pinCode, + telephone: guarantor.telephone + }); + this.guarantors.push(group); + } + } +} + +/** A date as the platform sends it - [yyyy, mm, dd] or an ISO string - or null when there is none. */ +function asDate(value: any): Date | null { + if (Array.isArray(value) && value.length >= 3) { + return new Date(value[0], value[1] - 1, value[2]); + } + if (value) { + const parsed = new Date(value); + return isNaN(parsed.getTime()) ? null : parsed; + } + return null; +} + +/** One shared empty list, so a row with nothing to offer hands the template the same array each time. */ +const GUARANTOR_NO_OPTIONS: any[] = []; + +/** Full years between a date of birth and a day, or null without a date of birth. */ +function ageOn(birthDate: Date | null, on: Date): number | null { + if (!birthDate) { + return null; + } + let age = on.getFullYear() - birthDate.getFullYear(); + const birthdayPassed = + on.getMonth() > birthDate.getMonth() || + (on.getMonth() === birthDate.getMonth() && on.getDate() >= birthDate.getDate()); + if (!birthdayPassed) { + age--; + } + return age; +} diff --git a/src/app/organization/agent-master/offices-under.ts b/src/app/organization/agent-master/offices-under.ts new file mode 100644 index 000000000..1129ce228 --- /dev/null +++ b/src/app/organization/agent-master/offices-under.ts @@ -0,0 +1,27 @@ +/** + * Copyright since 2025 Mifos Initiative + * + * This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. + */ + +/** + * The given office and every office under it, read from the office hierarchy (".", ".2.", ".2.5."): + * an office's own branches are the ones whose hierarchy starts with its own. Head Office, at the + * top, has them all. Falls back to the office alone when its hierarchy is not known. + */ +export function officesUnder(offices: any[], officeId: number | null | undefined): any[] { + if (!officeId) { + return offices; + } + const own = offices.find((office: any) => office.id === officeId); + if (!own) { + return []; + } + const hierarchy = String(own.hierarchy || ''); + if (!hierarchy) { + return [own]; + } + return offices.filter((office: any) => String(office.hierarchy || '').startsWith(hierarchy)); +} diff --git a/src/app/organization/commission-slab-master/commission-slab-master.component.html b/src/app/organization/commission-slab-master/commission-slab-master.component.html new file mode 100644 index 000000000..2a56de10a --- /dev/null +++ b/src/app/organization/commission-slab-master/commission-slab-master.component.html @@ -0,0 +1,157 @@ + + +
+
+ +

Commission Slab Master

+
+ +
+ + + + + + + + + + + + @for (slab of slabs; track slab; let i = $index) { + @if (editingIndex === i) { + + + + + + + + + } @else { + + + + + + + + } + } + + + + + + + + + +
Slab TypeSlab FromSlab ToComm Per
+ + + @for (type of slabTypes; track type) { + {{ type }} + } + + + + + + + + + + + + + + + + + +
{{ slab.slabType }}{{ slab.slabFrom }}{{ slab.slabTo }}{{ slab.commissionPercent }}% + @if (slab.id) { + + + } @else { + + + } +
+ + + @for (type of slabTypes; track type) { + {{ type }} + } + + + + + + + + + + + + + + + + + +
+ + @if (slabs.length === 0) { +

No commission slabs defined yet — add one using the row above.

+ } + + @if (coverage.length > 0) { +
+ @for (entry of coverage; track entry.slabType) { +
+ {{ entry.slabType }} + covers {{ entry.from | number }} to {{ entry.to | number }} + @if (entry.gaps.length > 0) { + + nothing set for {{ entry.gaps.join(', ') }} — an agent landing there earns no commission + + } +
+ } +
+ } + +
+ + +
+
+
diff --git a/src/app/organization/commission-slab-master/commission-slab-master.component.scss b/src/app/organization/commission-slab-master/commission-slab-master.component.scss new file mode 100644 index 000000000..33b830b27 --- /dev/null +++ b/src/app/organization/commission-slab-master/commission-slab-master.component.scss @@ -0,0 +1,163 @@ +/** + * 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/. + */ + +$primary: #0f766e; + +.csm-container { + // Four inputs sit side by side in the entry row; 760px squeezed them out of the card. + max-width: 980px; + margin: 0 auto; + padding: 24px 16px; +} + +.csm-header { + display: flex; + align-items: center; + gap: 16px; + margin-bottom: 20px; + + &__title { + margin: 0; + font-size: 22px; + font-weight: 500; + } +} + +.csm-back { + color: $primary; +} + +.csm-card { + background: var(--md-sys-color-surface, #fff); + border-radius: 8px; + box-shadow: 0 2px 8px rgb(0 0 0 / 8%); + padding: 16px 24px 24px; +} + +.csm-table { + width: 100%; + border-collapse: collapse; + table-layout: fixed; + + // Sized rather than left to the browser, so the entry row keeps its shape whatever is typed in. + th:nth-child(1), + td:nth-child(1) { + width: 26%; + } + + th:nth-child(2), + td:nth-child(2) { + width: 22%; + } + + th:nth-child(3), + td:nth-child(3) { + width: 22%; + } + + th:nth-child(4), + td:nth-child(4) { + width: 18%; + } + + th:nth-child(5), + td:nth-child(5) { + width: 12%; + } + + th { + text-align: left; + font-size: 13px; + text-transform: uppercase; + letter-spacing: 0.04em; + color: rgb(0 0 0 / 55%); + padding: 8px 10px; + border-bottom: 2px solid rgb(0 0 0 / 10%); + } + + td { + padding: 6px 10px; + border-bottom: 1px solid rgb(0 0 0 / 6%); + vertical-align: middle; + } +} + +.csm-inline-field { + width: 100%; + // Without this the field holds its own 180px floor and pushes the next column out of the card. + min-width: 0; + + ::ng-deep .mat-mdc-text-field-wrapper { + height: 44px; + } +} + +.csm-row-actions { + text-align: center; + width: 48px; +} + +.csm-new-row { + background: rgb(15 118 110 / 4%); +} + +.csm-empty { + color: rgb(0 0 0 / 50%); + font-size: 14px; + margin: 12px 0 0; +} + +.csm-action-bar { + display: flex; + justify-content: flex-end; + gap: 12px; + margin-top: 20px; + padding-top: 16px; + border-top: 1px solid rgb(0 0 0 / 8%); +} + +/* + * What the slabs actually cover, and any figure between them that none of them claims. + * + * A gap is legitimate - a bank may set no commission below some floor - so this reports rather than + * warns. But it has to be said out loud: the commission run finds no slab for a volume in a gap and + * moves on, so the agent is simply paid nothing that month and nothing on screen explains it. + */ +.csm-coverage { + margin-top: 14px; + padding: 12px 16px; + border: 1px solid rgb(0 0 0 / 12%); + border-radius: 6px; + background: rgb(0 0 0 / 2.5%); + display: flex; + flex-direction: column; + gap: 6px; + + &__row { + display: flex; + align-items: baseline; + gap: 10px; + flex-wrap: wrap; + font-size: 0.86rem; + } + + &__type { + font-weight: 600; + min-width: 90px; + } + + &__range { + color: rgb(0 0 0 / 62%); + font-variant-numeric: tabular-nums; + } + + &__gap { + color: #b45309; + font-variant-numeric: tabular-nums; + } +} diff --git a/src/app/organization/commission-slab-master/commission-slab-master.component.ts b/src/app/organization/commission-slab-master/commission-slab-master.component.ts new file mode 100644 index 000000000..6519a1193 --- /dev/null +++ b/src/app/organization/commission-slab-master/commission-slab-master.component.ts @@ -0,0 +1,337 @@ +/** + * Copyright since 2025 Mifos Initiative + * + * This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. + */ + +/** Angular Imports */ +import { Component, OnInit, inject } from '@angular/core'; +import { UntypedFormBuilder, UntypedFormGroup, Validators } from '@angular/forms'; +import { Router } from '@angular/router'; +import { STANDALONE_SHARED_IMPORTS } from 'app/standalone-shared.module'; + +/** Custom Services */ +import { OrganizationService } from 'app/organization/organization.service'; +import { AlertService } from 'app/core/alert/alert.service'; +import { announceHeldForApproval, heldForApproval } from 'app/deposits/dds-approvals/dds-approval-notice'; + +/** + * Commission Slab Master component. + * + * Two kinds of slab: ACCOUNT (commission percentage on collection volume) + * and COMMISSION (a senior-agent override on another agent's commission - + * deferred: there is no agent hierarchy modelled yet, see the DDS punch + * list). Modelled directly on the legacy Commission Slab Master screen this + * replaces. + * + * Backed by its own DDS Commission Slab API (`/dds/commissionslabs`) - an + * append-only chart, same as the legacy screen: existing rows load on open, + * newly-added rows post on Save, and there is no edit/delete of a + * previously-saved row. + */ +@Component({ + selector: 'mifosx-commission-slab-master', + templateUrl: './commission-slab-master.component.html', + styleUrls: ['./commission-slab-master.component.scss'], + imports: [...STANDALONE_SHARED_IMPORTS] +}) +export class CommissionSlabMasterComponent implements OnInit { + private formBuilder = inject(UntypedFormBuilder); + private router = inject(Router); + private organizationService = inject(OrganizationService); + private alertService = inject(AlertService); + + private static readonly SLAB_TYPE_CODES: Record = { + Account: 'ACCOUNT', + Commission: 'COMMISSION' + }; + private static readonly SLAB_TYPE_LABELS: Record = { + ACCOUNT: 'Account', + COMMISSION: 'Commission' + }; + + slabTypes = [ + 'Account', + 'Commission' + ]; + + displayedColumns: string[] = [ + 'slabType', + 'slabFrom', + 'slabTo', + 'commissionPercent', + 'actions' + ]; + + slabs: Array<{ id?: number; slabType: string; slabFrom: number; slabTo: number; commissionPercent: number }> = []; + + newSlabForm: UntypedFormGroup = this.formBuilder.group({ + slabType: [ + '', + Validators.required + ], + slabFrom: [ + 0, + Validators.required + ], + slabTo: [ + 0, + Validators.required + ], + commissionPercent: [ + 0, + Validators.required + ] + }); + + ngOnInit(): void { + this.loadSlabs(); + } + + private loadSlabs(): void { + this.organizationService.getCommissionSlabs().subscribe((slabs: any[]) => { + this.slabs = (slabs || []).map((slab) => ({ + id: slab.id, + slabType: CommissionSlabMasterComponent.SLAB_TYPE_LABELS[slab.slabType] || slab.slabType, + slabFrom: slab.slabFrom, + slabTo: slab.slabTo, + commissionPercent: slab.commissionPercent + })); + }); + } + + /** + * Where each slab type's coverage begins and ends, and any figure in between that no slab claims. + * + * A gap is not an error - a bank may well decide there is no commission below some floor - but it + * has to be visible. The commission run simply finds no slab for a volume that falls in a gap and + * moves on, so the agent is paid nothing for that month with nothing on screen to say why. + */ + get coverage(): Array<{ slabType: string; from: number; to: number; gaps: string[] }> { + return this.slabTypes + .map((slabType) => { + const rows = this.slabs + .filter((slab) => slab.slabType === slabType) + .map((slab) => ({ from: Number(slab.slabFrom), to: Number(slab.slabTo) })) + .sort((a, b) => a.from - b.from); + + if (rows.length === 0) { + return null; + } + + const gaps: string[] = []; + for (let i = 1; i < rows.length; i++) { + const previousEnd = rows[i - 1].to; + const nextStart = rows[i].from; + if (nextStart > previousEnd + 1) { + gaps.push(`${previousEnd + 1} - ${nextStart - 1}`); + } + } + + return { slabType, from: rows[0].from, to: rows[rows.length - 1].to, gaps }; + }) + .filter((entry): entry is { slabType: string; from: number; to: number; gaps: string[] } => entry !== null); + } + + /** + * The slab already on the table that this range would collide with, if any. + * + * The backend refuses overlapping slabs, but only when Save is pressed - by then the officer may + * have keyed several rows and has to work out which one it objected to. Catching it as the row is + * added names the clash while it is still the row in front of them. + */ + private clashingSlab(candidate: { slabType: string; slabFrom: number; slabTo: number }): any { + return this.slabs.find( + (slab) => + slab.slabType === candidate.slabType && + Number(candidate.slabFrom) <= Number(slab.slabTo) && + Number(candidate.slabTo) >= Number(slab.slabFrom) + ); + } + + addSlab(): void { + if (this.newSlabForm.invalid) { + // Marking the controls touched is what makes Material show which one is missing; without it + // the button simply does nothing and the officer has no idea what the screen is waiting for. + this.newSlabForm.markAllAsTouched(); + this.alertService.alert({ type: 'Error', message: 'Fill the slab type, range and percentage first' }); + return; + } + + const candidate = this.newSlabForm.getRawValue(); + + if (Number(candidate.slabFrom) > Number(candidate.slabTo)) { + this.alertService.alert({ type: 'Error', message: 'Slab From cannot be greater than Slab To' }); + return; + } + if (Number(candidate.commissionPercent) > 100) { + this.alertService.alert({ type: 'Error', message: 'Commission cannot be more than 100%' }); + return; + } + + const clash = this.clashingSlab(candidate); + if (clash) { + this.alertService.alert({ + type: 'Error', + message: `Overlaps the existing ${clash.slabType} slab ${clash.slabFrom} to ${clash.slabTo}` + }); + return; + } + + this.slabs.push(candidate); + this.newSlabForm.reset({ + slabType: '', + slabFrom: 0, + slabTo: 0, + commissionPercent: 0 + }); + } + + /** Only ever an unsaved row - a saved slab is corrected, not taken away. */ + removeSlab(index: number): void { + this.slabs.splice(index, 1); + } + + /** Which saved row is currently open for correction, or -1 when none is. */ + editingIndex = -1; + + editSlabForm: UntypedFormGroup = this.formBuilder.group({ + slabType: [ + '', + Validators.required + ], + slabFrom: [ + 0, + Validators.required + ], + slabTo: [ + 0, + Validators.required + ], + commissionPercent: [ + 0, + Validators.required + ] + }); + + startEdit(index: number): void { + const slab = this.slabs[index]; + this.editingIndex = index; + this.editSlabForm.setValue({ + slabType: slab.slabType, + slabFrom: slab.slabFrom, + slabTo: slab.slabTo, + commissionPercent: slab.commissionPercent + }); + } + + cancelEdit(): void { + this.editingIndex = -1; + } + + saveEdit(): void { + const slab = this.slabs[this.editingIndex]; + const corrected = this.editSlabForm.getRawValue(); + + if (this.editSlabForm.invalid) { + this.editSlabForm.markAllAsTouched(); + this.alertService.alert({ type: 'Error', message: 'Fill the slab type, range and percentage' }); + return; + } + if (Number(corrected.slabFrom) > Number(corrected.slabTo)) { + this.alertService.alert({ type: 'Error', message: 'Slab From cannot be greater than Slab To' }); + return; + } + if (Number(corrected.commissionPercent) > 100) { + this.alertService.alert({ type: 'Error', message: 'Commission cannot be more than 100%' }); + return; + } + + this.organizationService + .updateCommissionSlab(slab.id, { + slabType: CommissionSlabMasterComponent.SLAB_TYPE_CODES[corrected.slabType] || corrected.slabType, + slabFrom: corrected.slabFrom, + slabTo: corrected.slabTo, + commissionPercent: corrected.commissionPercent, + locale: 'en' + }) + .subscribe({ + next: (response: any) => { + if (heldForApproval(response)) { + announceHeldForApproval(response, 'The slab correction', this.organizationService, this.alertService); + } else { + this.alertService.alert({ type: 'Success', message: 'Slab corrected' }); + } + this.editingIndex = -1; + this.loadSlabs(); + }, + // The backend refuses a correction that overlaps another slab; the reason has to reach the + // officer, not vanish. + error: (error: any) => { + this.alertService.alert({ + type: 'Error', + message: + error?.error?.errors?.[0]?.defaultUserMessage || + error?.error?.defaultUserMessage || + 'Could not correct the slab' + }); + } + }); + } + + exit(): void { + this.router.navigate(['/organization']); + } + + save(): void { + const newSlabs = this.slabs.filter((slab) => !slab.id); + if (newSlabs.length === 0) { + // Save used to return here without a word, so a slab typed into the entry row but never added + // looked exactly like a slab that had been saved. + this.alertService.alert({ + type: 'Error', + message: 'Nothing new to save — add the slab to the table with + first' + }); + return; + } + + const payload = newSlabs.map((slab) => ({ + slabType: CommissionSlabMasterComponent.SLAB_TYPE_CODES[slab.slabType] || slab.slabType, + slabFrom: slab.slabFrom, + slabTo: slab.slabTo, + commissionPercent: slab.commissionPercent + })); + + this.organizationService.createCommissionSlabs(payload).subscribe({ + next: (response: any) => { + if (heldForApproval(response)) { + announceHeldForApproval( + response, + `${newSlabs.length} ${newSlabs.length === 1 ? 'slab' : 'slabs'}`, + this.organizationService, + this.alertService + ); + } else { + this.alertService.alert({ + type: 'Success', + message: `${newSlabs.length} ${newSlabs.length === 1 ? 'slab' : 'slabs'} saved` + }); + } + this.loadSlabs(); + }, + // The backend refuses an overlapping range; the officer needs to read why, not watch nothing + // happen and assume the save went through. + error: (error: any) => { + this.alertService.alert({ + type: 'Error', + message: + error?.error?.errors?.[0]?.defaultUserMessage || + error?.error?.defaultUserMessage || + 'Could not save the slabs' + }); + } + }); + } +} diff --git a/src/app/organization/dds-batch-processing/dds-batch-processing.component.html b/src/app/organization/dds-batch-processing/dds-batch-processing.component.html new file mode 100644 index 000000000..7570ffad9 --- /dev/null +++ b/src/app/organization/dds-batch-processing/dds-batch-processing.component.html @@ -0,0 +1,299 @@ + + +
+
+ +

DDS Interest & Commission

+
+ +
+
+
+ + Job Type + + @for (type of jobTypes; track type.code) { + {{ type.label }} + } + + + + + Branch + + @for (scope of branchScopes; track scope) { + {{ scope }} + } + + + + @if (isBranchSelected) { +
+ @for (office of offices; track office.id) { + + {{ office.name }} + + } + @if (offices.length === 0) { + Loading branches... + } +
+ } + + + {{ isPostingJob ? 'Posting Date' : 'Interest Date' }} + + + + + + + Agent From + + + + + Agent To + + + + + @if (!isCommissionJob) { + + Party From + + + + + Party To + + + } +
+ +
+ + + @if (isPostingJob) { + {{ jobTypeLabel }} moves what has already been calculated to where it belongs — into the + customer's balance, or out to the agent. Generate lists it first; nothing moves until you + press Post. + } @else { + {{ jobTypeLabel }} works out what has been earned since the last run and records it against + each account. It does not become spendable until the matching Posting job runs. + Generate previews it; nothing is recorded until you press Post. + } + +
+ + @if (generatedRun) { +
+
+ +

{{ labelOf(generatedRun.jobType) }} — preview

+ Run #{{ generatedRun.id }} · nothing applied yet +
+ + @if (previewRows.length === 0) { +

+ Nothing to apply. Either this period has already been processed, or there was no activity in it. +

+ } @else { +
+ @if (showsCommissionBreakdown) { + + + + + + + + + + + + + + + @for (row of previewRows; track row.id) { + + + + + + + + + + + } + + + + + + + +
AgentPeriodCollectionRateCommissionTDSDeductionsPayable
+ {{ row.agentCode }} + {{ row.agentName }} + {{ row.fromDate | dateFormat }} – {{ row.toDate | dateFormat }}{{ money(row.baseAmount) }}{{ row.ratePercent }}%{{ money(row.grossAmount) }}{{ money(row.tdsAmount) }}{{ money(row.deductionAmount) }}{{ money(row.amount) }}
Total payable{{ money(previewTotal) }}
+ } @else { + + + + + @if (!isCommissionJob) { + + } + + + + + + @for (row of previewRows; track row.id) { + + + @if (!isCommissionJob) { + + } + + + + } + + + + + + + +
{{ isCommissionJob ? 'Agent' : 'Party' }}AgentPeriodAmount
+ @if (isCommissionJob) { + {{ row.agentCode }} + {{ row.agentName }} + } @else { + {{ row.partyCode }} + {{ row.partyName }} + } + {{ row.agentCode }}{{ row.fromDate | dateFormat }} – {{ row.toDate | dateFormat }}{{ money(row.amount) }}
Total{{ money(previewTotal) }}
+ } +
+ } +
+ } + +
+ + @if (generatedRun) { + + } + + +
+
+
+ + + @if (postingRunId) { +
+ + + Posting is running. + It continues even if you leave this screen — the list below updates when it finishes. + +
+ } + + +
+
+ +

Previous runs

+
+ + @if (recentRuns.length === 0) { +

No batch has been run yet.

+ } @else { +
+ + + + + + + + + + + + + + @for (run of recentRuns; track run.id) { + + + + + + + + + + + } + +
#JobDateEntriesAmountStatusPosted On
{{ run.id }}{{ labelOf(run.jobType) }}{{ run.jobDate | dateFormat }}{{ run.totalRows }}{{ money(run.totalAmount) }} + @switch (run.status) { + @case ('POSTED') { + Posted + } + @case ('POSTING') { + + Running + + } + @case ('FAILED') { + + Failed + + } + @default { + Not posted + } + } + {{ run.postedOnDate ? (run.postedOnDate | dateFormat) : '—' }}
+
+ } +
+
diff --git a/src/app/organization/dds-batch-processing/dds-batch-processing.component.scss b/src/app/organization/dds-batch-processing/dds-batch-processing.component.scss new file mode 100644 index 000000000..26dbefd94 --- /dev/null +++ b/src/app/organization/dds-batch-processing/dds-batch-processing.component.scss @@ -0,0 +1,255 @@ +/** + * 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/. + */ + +$primary: #0f766e; + +.bp-container { + // The commission breakdown carries eight columns; anything narrower makes it scroll on every + // screen rather than only on small ones. + max-width: 1180px; + margin: 0 auto; + padding: 24px 16px; + display: flex; + flex-direction: column; + gap: 20px; +} + +.bp-header { + display: flex; + align-items: center; + gap: 16px; + + &__title { + margin: 0; + font-size: 22px; + font-weight: 500; + } +} + +.bp-back { + color: $primary; +} + +.bp-card { + background: var(--md-sys-color-surface, #fff); + border-radius: 8px; + box-shadow: 0 2px 8px rgb(0 0 0 / 8%); + padding: 20px 24px 24px; +} + +.bp-grid { + display: grid; + grid-template-columns: repeat(3, 1fr); + gap: 8px 16px; +} + +.bp-field { + width: 100%; + + &--wide { + grid-column: span 3; + } +} + +.bp-branch-checklist { + display: flex; + flex-wrap: wrap; + gap: 8px 20px; + align-items: center; + padding: 8px 0; +} + +.bp-callout { + display: flex; + gap: 10px; + align-items: flex-start; + background: rgb(15 118 110 / 6%); + border-left: 3px solid $primary; + border-radius: 0 8px 8px 0; + padding: 12px 16px; + margin: 16px 0; + font-size: 13px; + + i { + color: $primary; + margin-top: 2px; + } +} + +.bp-section { + padding: 16px 0; + border-top: 1px solid rgb(0 0 0 / 8%); + + &__header { + display: flex; + align-items: center; + gap: 10px; + margin-bottom: 12px; + + i { + color: $primary; + } + + h3 { + margin: 0; + font-size: 16px; + font-weight: 600; + flex: 0 0 auto; + } + } + + &__note { + margin-left: auto; + font-size: 12px; + color: rgb(0 0 0 / 45%); + } +} + +.bp-empty { + color: rgb(0 0 0 / 50%); + font-size: 14px; + margin: 0; +} + +// A wide table scrolls inside its own box rather than pushing the whole page sideways. +.bp-table-scroll { + overflow-x: auto; +} + +.bp-table { + width: 100%; + border-collapse: collapse; + font-size: 13px; + + th { + text-align: left; + font-size: 12px; + text-transform: uppercase; + letter-spacing: 0.04em; + color: rgb(0 0 0 / 55%); + padding: 8px 10px; + border-bottom: 2px solid rgb(0 0 0 / 10%); + white-space: nowrap; + } + + td { + padding: 7px 10px; + border-bottom: 1px solid rgb(0 0 0 / 6%); + vertical-align: top; + } + + tfoot td { + border-bottom: none; + border-top: 2px solid rgb(0 0 0 / 10%); + font-weight: 600; + } + + // Figures line up column-wise so a wrong order of magnitude is visible at a glance. + .bp-num { + text-align: right; + font-variant-numeric: tabular-nums; + white-space: nowrap; + } + + .bp-minus { + color: #b91c1c; + } + + .bp-total { + font-weight: 600; + } + + .bp-period { + white-space: nowrap; + color: rgb(0 0 0 / 60%); + } +} + +.bp-code { + display: block; + font-weight: 600; +} + +.bp-sub { + display: block; + font-size: 12px; + color: rgb(0 0 0 / 55%); +} + +.bp-status { + display: inline-block; + padding: 2px 8px; + border-radius: 10px; + font-size: 12px; + font-weight: 600; + background: rgb(180 83 9 / 12%); + color: #92400e; + + &--posted { + background: rgb(15 118 110 / 12%); + color: $primary; + } + + /* Still crediting. Blue rather than the amber of a run nobody has posted — one is work in hand, + the other is work forgotten, and a clerk who reads them the same way runs it twice. */ + &--running { + background: rgb(22 84 143 / 12%); + color: #16548f; + display: inline-flex; + align-items: center; + gap: 5px; + } + + &--failed { + background: rgb(163 42 28 / 12%); + color: #a32a1c; + cursor: help; + } +} + +/* Says a run is still going after the clerk has left the button behind. */ +.bp-running { + display: flex; + align-items: center; + gap: 10px; + margin-bottom: 16px; + padding: 12px 16px; + border: 1px solid rgb(22 84 143 / 25%); + border-left: 3px solid #16548f; + border-radius: 6px; + background: rgb(22 84 143 / 6%); + color: #16548f; + font-size: 13px; + + strong { + font-weight: 600; + } +} + +.bp-action-bar { + display: flex; + justify-content: flex-end; + gap: 12px; + margin-top: 20px; + padding-top: 16px; + border-top: 1px solid rgb(0 0 0 / 8%); + + i { + margin-right: 6px; + } +} + +@media (width <= 900px) { + .bp-grid { + grid-template-columns: repeat(2, 1fr); + } + + .bp-field--wide { + grid-column: span 2; + } +} diff --git a/src/app/organization/dds-batch-processing/dds-batch-processing.component.ts b/src/app/organization/dds-batch-processing/dds-batch-processing.component.ts new file mode 100644 index 000000000..37a2b5ee5 --- /dev/null +++ b/src/app/organization/dds-batch-processing/dds-batch-processing.component.ts @@ -0,0 +1,400 @@ +/** + * Copyright since 2025 Mifos Initiative + * + * This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. + */ + +/** Angular Imports */ +import { Component, OnInit, inject } from '@angular/core'; +import { UntypedFormBuilder, UntypedFormGroup, Validators } from '@angular/forms'; +import { Router } from '@angular/router'; +import { MatDialog } from '@angular/material/dialog'; +import { MatTooltip } from '@angular/material/tooltip'; +import { DateAdapter, MAT_DATE_FORMATS } from '@angular/material/core'; +import { STANDALONE_SHARED_IMPORTS } from 'app/standalone-shared.module'; +import { ConfirmationDialogComponent } from 'app/shared/confirmation-dialog/confirmation-dialog.component'; +import { DdsDateAdapter, DDS_DATE_FORMATS } from './dds-date-adapter'; + +/** Custom Services */ +import { OrganizationService } from 'app/organization/organization.service'; +import { AlertService } from 'app/core/alert/alert.service'; +import { SettingsService } from 'app/settings/settings.service'; +import { Dates } from 'app/core/utils/dates'; +import { announceHeldForApproval, heldForApproval } from 'app/deposits/dds-approvals/dds-approval-notice'; + +/** + * DDS Interest & Commission batch processing — the legacy Interest Part menu. + * + * Eight menu entries, which are four Calculate/Post pairs. A *calculation* job works out what has + * been earned since the last one and parks it: interest against the customer's account, commission + * against the agent. Neither is spendable yet. The matching *posting* job then moves that parked + * figure where it belongs — into the customer's balance, or out to the agent. + * + * Every job is itself a two-step Generate then Post. Generate saves what it worked out and changes + * nothing else, so a figure that looks wrong is discarded by simply not posting it. + */ +@Component({ + selector: 'mifosx-dds-batch-processing', + templateUrl: './dds-batch-processing.component.html', + styleUrls: ['./dds-batch-processing.component.scss'], + imports: [ + ...STANDALONE_SHARED_IMPORTS, + // The reason a run failed is too long to sit in the column, and too useful to leave out. + MatTooltip + ], + // Day-first dates, as the legacy screen showed them. Declared here rather than application-wide: + // a @Component's providers reach only this component, so no other screen's datepicker changes. + providers: [ + { provide: DateAdapter, useClass: DdsDateAdapter }, + { provide: MAT_DATE_FORMATS, useValue: DDS_DATE_FORMATS } + ] +}) +export class DdsBatchProcessingComponent implements OnInit { + private formBuilder = inject(UntypedFormBuilder); + private router = inject(Router); + private organizationService = inject(OrganizationService); + private alertService = inject(AlertService); + private dialog = inject(MatDialog); + private settingsService = inject(SettingsService); + private dateUtils = inject(Dates); + + /** + * The menu as the legacy system lists it, paired with what the API calls each job. Ordered as + * calculate-then-post within each pair, matching the numbering on the legacy screen. + */ + jobTypes: Array<{ label: string; code: string }> = [ + { label: 'Interest Calculation', code: 'INTEREST_CALCULATION' }, + { label: 'Interest Posting', code: 'INTEREST_POSTING' }, + { label: 'OD Interest Calculation', code: 'OD_INTEREST_CALCULATION' }, + { label: 'OD Interest Posting', code: 'OD_INTEREST_POSTING' }, + { label: 'Commission Calculation', code: 'COMMISSION_CALCULATION' }, + { label: 'Commission Posting', code: 'COMMISSION_POSTING' }, + // Last, and on its own: the others move money onto accounts that stay open, while this one pays + // an account out and shuts it. Listing it beside a calculate/post pair would suggest it is the + // second half of one. + { label: 'Maturity Auto Close', code: 'MATURITY_AUTO_CLOSE' } + ]; + + branchScopes = [ + 'All', + 'Selected' + ]; + + private static readonly MONEY = new Intl.NumberFormat('en-IN', { + minimumFractionDigits: 2, + maximumFractionDigits: 2 + }); + + offices: any[] = []; + selectedOfficeNames = new Set(); + + /** The run currently previewed on screen, or null once it has been posted or discarded. */ + generatedRun: any = null; + previewRows: any[] = []; + + recentRuns: any[] = []; + + batchForm: UntypedFormGroup = this.formBuilder.group({ + jobType: [ + 'INTEREST_CALCULATION', + Validators.required + ], + branchScope: [ + 'All', + Validators.required + ], + jobDate: [ + new Date(), + Validators.required + ], + agentFrom: [ + 1, + Validators.required + ], + agentTo: [ + 99999, + Validators.required + ], + partyFrom: [ + 1, + Validators.required + ], + partyTo: [ + 99999999, + Validators.required + ] + }); + + get jobType(): string { + return this.batchForm.get('jobType').value; + } + + get jobTypeLabel(): string { + return this.jobTypes.find((type) => type.code === this.jobType)?.label ?? this.jobType; + } + + /** Commission is per agent, so the party range does not narrow it. */ + get isCommissionJob(): boolean { + return this.jobType.startsWith('COMMISSION'); + } + + get isPostingJob(): boolean { + return this.jobType.endsWith('_POSTING'); + } + + get isBranchSelected(): boolean { + return this.batchForm.get('branchScope')?.value === 'Selected'; + } + + /** + * Only a commission calculation has a breakdown worth showing: it is the one job where the figure + * applied is not the figure worked out, because TDS and the standing deductions come off it. + */ + get showsCommissionBreakdown(): boolean { + return this.jobType === 'COMMISSION_CALCULATION'; + } + + /** What Generate found, in words — the same figure the Post button is about to apply. */ + get previewTotal(): number { + return this.previewRows.reduce((total, row) => total + Number(row.amount || 0), 0); + } + + ngOnInit(): void { + this.organizationService.getOffices().subscribe((offices: any[]) => { + this.offices = offices || []; + }); + this.loadRecentRuns(); + } + + money(value: any): string { + if (value === null || value === undefined || value === '') { + return '—'; + } + return DdsBatchProcessingComponent.MONEY.format(Number(value)); + } + + labelOf(code: string): string { + return this.jobTypes.find((type) => type.code === code)?.label ?? code; + } + + toggleOffice(officeName: string): void { + if (this.selectedOfficeNames.has(officeName)) { + this.selectedOfficeNames.delete(officeName); + } else { + this.selectedOfficeNames.add(officeName); + } + } + + /** + * What every past run did and whether it was applied. Without this a generated-but-unposted run is + * invisible the moment the page is left, and there is no way to tell whether a month's interest + * was ever actually posted. + */ + private loadRecentRuns(): void { + this.organizationService.getDdsBatchRuns().subscribe((runs: any[]) => { + this.recentRuns = runs || []; + }); + } + + generate(): void { + const raw = this.batchForm.getRawValue(); + const dateFormat = this.settingsService.dateFormat; + + const payload: any = { + jobType: raw.jobType, + branchScope: raw.branchScope, + jobDate: this.dateUtils.formatDate(raw.jobDate, dateFormat), + agentFrom: raw.agentFrom, + agentTo: raw.agentTo, + dateFormat, + locale: this.settingsService.language.code + }; + + // The party range narrows an interest job to particular accounts; commission is worked out per + // agent on their whole collection, so sending a range there would only imply a filter that has + // no effect. + if (!this.isCommissionJob) { + payload.partyFrom = raw.partyFrom; + payload.partyTo = raw.partyTo; + } + + if (raw.branchScope === 'Selected') { + payload.officeNames = Array.from(this.selectedOfficeNames); + } + + this.organizationService.generateDdsBatchRun(payload).subscribe((result: any) => { + this.organizationService.getDdsBatchRun(result.resourceId).subscribe((batchRun: any) => { + this.generatedRun = batchRun; + this.previewRows = batchRun.rows || []; + + // A run that found nothing is the normal outcome of a period already processed, not a + // failure — but it has to say so, or an empty table reads as the screen being broken. + if (this.previewRows.length === 0) { + this.alertService.alert({ + type: 'Information', + message: `${this.jobTypeLabel} found nothing to apply. Either this period has already been processed, or there was no activity in it.` + }); + } else { + this.alertService.alert({ + type: 'Success', + message: `${this.jobTypeLabel}: ${this.previewRows.length} ${this.previewRows.length === 1 ? 'entry' : 'entries'} totalling ${this.money(this.previewTotal)} calculated. Nothing has been applied yet — review and press Post.` + }); + } + + this.loadRecentRuns(); + }); + }); + } + + post(): void { + if (!this.generatedRun || this.previewRows.length === 0) { + return; + } + + const count = this.previewRows.length; + const total = this.money(this.previewTotal); + const isPosting = this.isPostingJob; + + // Post is the step that moves money, and it cannot be undone from this screen — so it names + // exactly what is about to change rather than asking a generic "are you sure". + this.dialog + .open(ConfirmationDialogComponent, { + data: { + type: 'warn', + heading: `Post ${this.jobTypeLabel}?`, + dialogContext: + `${count} ${count === 1 ? 'entry' : 'entries'} totalling ${total} will be applied. ` + + (isPosting + ? 'This credits the customers or pays the agents, and appears on the ledger. ' + : 'This records what has been earned against each account. ') + + 'It cannot be undone from this screen.' + } + }) + .afterClosed() + .subscribe((response: any) => { + if (response?.confirm) { + this.applyRun(count, total); + } + }); + } + + /** + * Applies the run, and says what actually happened rather than assuming it finished. + * + * A crediting run costs what a savings deposit costs on every account it touches, so the server + * takes it off the request and does it in the background - the reply comes back in a moment while + * the work is still going on. Reporting that as "posted" would tell the clerk a run had finished + * seconds after it began, and they would go looking for credits that were still being written. + * + * So the run is followed until it settles. A calculation run is done by the time the reply lands + * and says so at once; a crediting run says it is under way, and the list beneath refreshes itself + * until the server marks it posted - or failed, with the reason the server recorded. + */ + private applyRun(count: number, total: string): void { + const label = this.jobTypeLabel; + const runId = this.generatedRun.id; + const crediting = this.isPostingJob; + + this.organizationService.postDdsBatchRun(runId).subscribe((response: any) => { + this.generatedRun = null; + this.previewRows = []; + this.loadRecentRuns(); + + if (heldForApproval(response)) { + announceHeldForApproval( + response, + `${label} (${count} ${count === 1 ? 'entry' : 'entries'}, ${total})`, + this.organizationService, + this.alertService + ); + return; + } + + if (!crediting) { + this.alertService.alert({ + type: 'Success', + message: `${label} posted — ${count} ${count === 1 ? 'entry' : 'entries'}, ${total}.` + }); + return; + } + + this.postingRunId = runId; + this.alertService.alert({ + type: 'Posting', + message: + `${label} is running — ${count} ${count === 1 ? 'entry' : 'entries'}, ${total}. ` + + 'It continues in the background; this list will update when it is done.' + }); + this.followRun(runId, label); + }); + } + + /** The crediting run being followed, if one is. The screen reads this to show it is under way. */ + postingRunId: number | null = null; + + /** + * Watches a background run until the server marks it settled. + * + * Polled rather than pushed: a batch that takes minutes needs no faster news than this, and a poll + * costs nothing when the answer is one row. It gives up after a while rather than asking forever - + * the run is still on the list either way, and a clerk can read its status there. + */ + private followRun(runId: number, label: string): void { + let checksLeft = DdsBatchProcessingComponent.MAX_STATUS_CHECKS; + + const check = () => { + this.organizationService.getDdsBatchRuns().subscribe({ + next: (runs: any[]) => { + const run = (runs || []).find((each: any) => each.id === runId); + const status = run?.status; + + if (status === 'POSTED' || status === 'FAILED') { + this.postingRunId = null; + this.loadRecentRuns(); + this.alertService.alert( + status === 'POSTED' + ? { type: 'Success', message: `${label} posted — ${run.rowsPosted ?? run.totalRows} entries applied.` } + : { type: 'Error', message: `${label} failed: ${run.failureReason || 'see the run for details'}` } + ); + return; + } + + if (--checksLeft > 0) { + setTimeout(check, DdsBatchProcessingComponent.STATUS_CHECK_INTERVAL_MS); + } else { + // Still going after the wait. Left on the list rather than reported either way - saying + // "failed" for a run that is merely long would be worse than saying nothing. + this.postingRunId = null; + this.loadRecentRuns(); + } + }, + // A failed status check says nothing about the run itself, which carries on regardless. + error: () => { + if (--checksLeft > 0) { + setTimeout(check, DdsBatchProcessingComponent.STATUS_CHECK_INTERVAL_MS); + } else { + this.postingRunId = null; + } + } + }); + }; + + setTimeout(check, DdsBatchProcessingComponent.STATUS_CHECK_INTERVAL_MS); + } + + private static readonly STATUS_CHECK_INTERVAL_MS = 4000; + /** Twenty minutes of checking, which is longer than any run measured here has taken. */ + private static readonly MAX_STATUS_CHECKS = 300; + + /** Drops the preview without applying it — the safe way out of a calculation that looks wrong. */ + discard(): void { + this.generatedRun = null; + this.previewRows = []; + } + + exit(): void { + this.router.navigate(['/organization']); + } +} diff --git a/src/app/organization/dds-batch-processing/dds-date-adapter.ts b/src/app/organization/dds-batch-processing/dds-date-adapter.ts new file mode 100644 index 000000000..bb6947f53 --- /dev/null +++ b/src/app/organization/dds-batch-processing/dds-date-adapter.ts @@ -0,0 +1,70 @@ +/** + * 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 } from '@angular/core'; +import { NativeDateAdapter, MatDateFormats } from '@angular/material/core'; + +/** + * Day-first dates for the DDS batch screen. + * + * Angular Material's stock adapter shows and reads dates the American way — 12/31/2026 — and its + * parser hands typed input to `Date.parse`, which cannot read a day-first date at all: typing + * 31/12/2026 yields no date, so the field silently empties and reports itself invalid. + * + * On a screen where the date decides which period's interest is calculated, both halves matter: a + * clerk who reads 12/31 as the 12th of some month picks the wrong period, and one who types the date + * they are used to gets an error with nothing to explain it. + * + * Scoped to this component rather than provided application-wide. Every other datepicker in the app + * has the same behaviour, and changing it globally would alter screens outside DDS. + */ +@Injectable() +export class DdsDateAdapter extends NativeDateAdapter { + /** Matches d/m/yyyy, with or without leading zeros, on / . or - separators. */ + private static readonly DAY_FIRST = /^(\d{1,2})[/.-](\d{1,2})[/.-](\d{4})$/; + + override parse(value: any): Date | null { + if (typeof value === 'string') { + const match = DdsDateAdapter.DAY_FIRST.exec(value.trim()); + if (match) { + const day = Number(match[1]); + const month = Number(match[2]); + const year = Number(match[3]); + + const parsed = new Date(year, month - 1, day); + // JavaScript rolls an out-of-range day into the next month rather than rejecting it, so + // 31/02/2026 would quietly become the 3rd of March. Reading the parts back catches that. + const isRealDate = parsed.getFullYear() === year && parsed.getMonth() === month - 1 && parsed.getDate() === day; + return isRealDate ? parsed : null; + } + } + return super.parse(value); + } + + override format(date: Date, displayFormat: any): string { + if (displayFormat === 'input') { + return `${this.pad(date.getDate())}/${this.pad(date.getMonth() + 1)}/${date.getFullYear()}`; + } + return super.format(date, displayFormat); + } + + private pad(value: number): string { + return value < 10 ? `0${value}` : `${value}`; + } +} + +/** Tells the datepicker to render the field itself day-first; the calendar keeps its usual labels. */ +export const DDS_DATE_FORMATS: MatDateFormats = { + parse: { dateInput: 'input' }, + display: { + dateInput: 'input', + monthYearLabel: { year: 'numeric', month: 'short' }, + dateA11yLabel: { year: 'numeric', month: 'long', day: 'numeric' }, + monthYearA11yLabel: { year: 'numeric', month: 'long' } + } +}; diff --git a/src/app/organization/organization-routing.module.ts b/src/app/organization/organization-routing.module.ts index 50723ef0e..22b411aff 100644 --- a/src/app/organization/organization-routing.module.ts +++ b/src/app/organization/organization-routing.module.ts @@ -17,6 +17,10 @@ import { Route } from '../core/route/route.service'; import { OrganizationComponent } from './organization.component'; import { LoanProvisioningCriteriaComponent } from './loan-provisioning-criteria/loan-provisioning-criteria.component'; import { OfficesComponent } from './offices/offices.component'; +import { AgentMasterComponent } from './agent-master/agent-master.component'; +import { CreateAgentComponent } from './agent-master/create-agent/create-agent.component'; +import { CommissionSlabMasterComponent } from './commission-slab-master/commission-slab-master.component'; +import { DdsBatchProcessingComponent } from './dds-batch-processing/dds-batch-processing.component'; import { EmployeesComponent } from './employees/employees.component'; import { CreateEmployeeComponent } from './employees/create-employee/create-employee.component'; import { ViewEmployeeComponent } from './employees/view-employee/view-employee.component'; @@ -237,6 +241,36 @@ const routes: Routes = [ } ] }, + { + path: 'agent-master', + data: { title: 'Agent Master', breadcrumb: 'Agent Master' }, + children: [ + { + path: '', + component: AgentMasterComponent + }, + { + path: 'create', + component: CreateAgentComponent, + data: { title: 'Create Agent', breadcrumb: 'Create' } + }, + { + path: ':agentId/edit', + component: CreateAgentComponent, + data: { title: 'Edit Agent', breadcrumb: 'Edit', routeParamBreadcrumb: false } + } + ] + }, + { + path: 'commission-slab-master', + component: CommissionSlabMasterComponent, + data: { title: 'Commission Slab Master', breadcrumb: 'Commission Slab Master' } + }, + { + path: 'dds-batch-processing', + component: DdsBatchProcessingComponent, + data: { title: 'DDS Interest & Commission Batch', breadcrumb: 'DDS Batch Processing' } + }, { // Bank's own profile — name, legal name, code, registration, address/contact. A // singleton, unlike every other screen under Organization, so it has no create/list step. diff --git a/src/app/organization/organization.component.html b/src/app/organization/organization.component.html index 6531fe8b2..c19767a54 100644 --- a/src/app/organization/organization.component.html +++ b/src/app/organization/organization.component.html @@ -190,6 +190,47 @@ + + + + + +
diff --git a/src/app/organization/organization.service.ts b/src/app/organization/organization.service.ts index 5090e143b..c89ee7739 100644 --- a/src/app/organization/organization.service.ts +++ b/src/app/organization/organization.service.ts @@ -233,6 +233,343 @@ export class OrganizationService { return this.http.get('/currencies'); } + /** + * @returns {Observable} DDS Agents data + */ + getAgents(): Observable { + return this.http.get('/dds/agents'); + } + + /** + * @param {string | number} agentId Agent ID. + * @returns {Observable} DDS Agent. + */ + getAgent(agentId: string | number): Observable { + return this.http.get(`/dds/agents/${agentId}`); + } + + /** + * @param {any} agent Agent to be created. + * @returns {Observable} + */ + createAgent(agent: any): Observable { + return this.http.post('/dds/agents', agent); + } + + /** + * @param {string | number} agentId Agent ID. + * @param {any} agent Agent changes to apply. + * @returns {Observable} + */ + updateAgent(agentId: string | number, agent: any): Observable { + return this.http.put(`/dds/agents/${agentId}`, agent); + } + + /** + * @returns {Observable} DDS Parties data + */ + getParties(): Observable { + return this.http.get('/dds/parties'); + } + + /** + * The parties a given agent collects for — what the daily transaction screen works from, since + * it handles one agent's round at a time. + * + * @param {string | number} agentId Agent ID. + * @returns {Observable} That agent's DDS Parties. + */ + getPartiesOfAgent(agentId: string | number): Observable { + return this.http.get('/dds/parties', { params: new HttpParams().set('agentId', String(agentId)) }); + } + + /** + * @param {string | number} partyId Party ID. + * @returns {Observable} DDS Party. + */ + getParty(partyId: string | number): Observable { + return this.http.get(`/dds/parties/${partyId}`); + } + + /** + * @param {any} party Party to be created. + * @returns {Observable} + */ + createParty(party: any): Observable { + return this.http.post('/dds/parties', party); + } + + /** + * @param {string | number} partyId Party ID. + * @param {any} party Party changes to apply. + * @returns {Observable} + */ + updateParty(partyId: string | number, party: any): Observable { + return this.http.put(`/dds/parties/${partyId}`, party); + } + + /** + * @param {string | number} partyId Party ID. + * @param {any} closeCommand { closeDate } + * @returns {Observable} + */ + closeParty(partyId: string | number, closeCommand: any): Observable { + return this.http.post(`/dds/parties/${partyId}?command=close`, closeCommand); + } + + /** + * The DDS accounts whose term has run out and which are still open - the branch's list of closures + * waiting to be done. Nothing closes itself: a matured account is money the bank owes the customer, + * and where it goes has to be agreed with them. + * + * @returns {Observable} Accounts due for closure, oldest maturity first. + */ + getPartiesDueForClosure(): Observable { + return this.http.get('/dds/parties/dueforclosure'); + } + + /** + * The customer's own accounts a DDS maturity can be paid into - active savings accounts of theirs, + * with fixed and recurring deposits and their other DDS scheme accounts left out. + * + * The server applies the same conditions the payout itself does, so this list can never offer a + * choice that is then refused on save. Filtering here instead meant the two drifted apart: the + * accounts on offer once included a scheme account of a party that had already closed. + * + * @param {string | number} clientId Client ID. + * @returns {Observable} id, accountNo, productName and balance for each. + */ + /** The liability GL accounts a DDS balance can be parked in, for the clerk to search and pick. */ + getDdsSundryAccounts(): Observable { + return this.http.get('/dds/parties/sundryaccounts'); + } + + /** DDS entries waiting on (or already past) a checker - PENDING, APPROVED or REJECTED. */ + getDdsApprovals(status: string, fromDate?: string, toDate?: string): Observable { + const params: any = { status }; + if (fromDate) { + params.fromDate = fromDate; + } + if (toDate) { + params.toDate = toDate; + } + return this.http.get('/dds/approvals', { params }); + } + + getDdsApproval(commandId: string | number): Observable { + return this.http.get(`/dds/approvals/${commandId}`); + } + + /** Approves or rejects a DDS entry; an approval returns the voucher it moved under. */ + actOnDdsApproval(commandId: string | number, command: 'approve' | 'reject'): Observable { + return this.http.post(`/dds/approvals/${commandId}`, {}, { params: { command } }); + } + + /** How a closed DDS account was closed: by whom, what it came to and where the money went. */ + getPartyClosure(partyId: string | number): Observable { + return this.http.get(`/dds/parties/${partyId}/closure`); + } + + getCreditableAccounts(clientId: string | number): Observable { + return this.http.get('/dds/parties/creditaccounts', { params: { clientId: String(clientId) } }); + } + + /** + * Pays a matured DDS account out and closes it. What is paid is what the account holds, not the + * figure quoted at opening. + * + * @param {string | number} partyId Party ID. + * @param {any} payoutCommand { toSavingsAccountNo } - another savings account of the same customer + * to move the money into. Omit it when they have none here: the money is then parked in the + * matured-deposits payable account for the branch to send on to their account at another bank. + * @returns {Observable} + */ + payOutParty(partyId: string | number, payoutCommand: any): Observable { + return this.http.post(`/dds/parties/${partyId}?command=payout`, payoutCommand); + } + + /** + * Closes a DDS account before its term is up, and pays out what is left - one act, unlike a + * maturity payout, which leaves the account open for the clerk to close separately. + * + * @param {string | number} partyId Party ID. + * @param {any} closeCommand { shortfallBorneBy, closeDate, dateFormat, locale, toSavingsAccountNo? }. + * closeDate, dateFormat and locale go up exactly as they do on an ordinary close. + * shortfallBorneBy is mandatory + * and is CUSTOMER (their interest is recalculated at the scheme's reduced rate and the + * difference taken back) or AGENT (the customer keeps a full month's interest and the agent + * funds the days left in it). The agent is charged a share of their commission either way. + * toSavingsAccountNo works as it does for a payout. The lock-in, the rate reduction and the + * agent's share come from the scheme and are applied by the server. + * @returns {Observable} + */ + prematureCloseParty(partyId: string | number, closeCommand: any): Observable { + return this.http.post(`/dds/parties/${partyId}?command=prematureclose`, closeCommand); + } + + /** + * Closes a DDS account early inside its scheme's lock-in, for the reason given. Its own permission, + * so it goes to a manager for approval; the scheme's lock-in is not changed. + */ + prematureCloseOverrideParty(partyId: string | number, closeCommand: any): Observable { + return this.http.post(`/dds/parties/${partyId}?command=prematurecloseoverride`, closeCommand); + } + + /** + * @param {any} transaction Daily transaction batch (PARTY_TRAN collections or a WITHDRAW_DEPOSITE transfer) to post. + * @returns {Observable} + */ + createPartyTransaction(transaction: any): Observable { + return this.http.post('/dds/partytransactions', transaction); + } + + /** + * What an agent wrote down on their round for a date, with what it adds up to. + * + * The counter reads it to count cash against; the agent's own screen shows it back as the record + * of their round. One call for both, so the two can never disagree about the same day. + */ + getAgentCollectionDay(agentId: any, entryDate: string): Observable { + return this.http.get(`/dds/collectionentries?agentId=${agentId}&entryDate=${entryDate}`); + } + + /** + * Turns an agent's outstanding entries for a date into real credits - all of them, together. + * + * No amount is sent. What settles is what the entries say, to the rupee: a figure typed at the + * counter would be a second opinion about a total the entries already give. + */ + settleAgentCollections(settlement: any): Observable { + return this.http.post('/dds/collectionentries/settle', settlement); + } + + /** Everything the agent still has outstanding, whatever day it was written - what a receipt is applied against. */ + getAgentPendingCollections(agentId: any): Observable { + return this.http.get(`/dds/collectionentries/pending?agentId=${agentId}`); + } + + /** + * The cashier's receipts into the agent's GL account: the DDS receipts ready to apply, or - with a + * voucher number - that one voucher in whatever state, so the screen can say why it cannot be used. + */ + getAgentCashReceipts(agentId: any, voucherNo?: string, includeWaiting = false): Observable { + const voucher = voucherNo ? `&voucherNo=${encodeURIComponent(voucherNo)}` : ''; + const waiting = includeWaiting ? '&includeWaiting=true' : ''; + return this.http.get(`/dds/collectionentries/cashreceipts?agentId=${agentId}${voucher}${waiting}`); + } + + /** A cashier receipt's own Dr/Cr lines. */ + getCashReceiptGlEntries(receiptId: any): Observable { + return this.http.get(`/dds/collectionentries/cashreceipts/${receiptId}/glentries`); + } + + /** + * An agent's DDS transactions. The daily entry screen uses this to show what has already been + * keyed for the agent today, so the same collection book is not entered twice. + * + * @param {string | number} agentId Agent ID. + * @returns {Observable} That agent's transactions. + */ + getPartyTransactionsOfAgent(agentId: string | number): Observable { + return this.http.get('/dds/partytransactions', { + params: new HttpParams().set('agentId', String(agentId)) + }); + } + + /** + * @param {string | number} partyId DDS account (party) id. + * @returns {Observable} Everything posted to that DDS account, most recent first. + */ + getPartyTransactionsOfParty(partyId: string | number): Observable { + return this.http.get('/dds/partytransactions', { + params: new HttpParams().set('partyId', String(partyId)) + }); + } + + /** + * @returns {Observable} DDS Commission Slabs data + */ + getCommissionSlabs(): Observable { + return this.http.get('/dds/commissionslabs'); + } + + /** + * @param {any[]} slabs New commission slab rows to add to the chart. + * @returns {Observable} + */ + createCommissionSlabs(slabs: any[]): Observable { + return this.http.post('/dds/commissionslabs', { slabs }); + } + + /** + * Corrects one saved commission slab. The backend re-checks the corrected range against the other + * slabs of its type and refuses an overlap. + * + * @param {string | number} slabId Commission slab ID. + * @param {any} slab The corrected slab. + * @returns {Observable} Command result. + */ + updateCommissionSlab(slabId: string | number, slab: any): Observable { + return this.http.put(`/dds/commissionslabs/${slabId}`, slab); + } + + /** + * @param {any} batch Batch run filters (jobType, branchScope, jobDate, agentFrom/To, partyFrom/To) to generate a preview for. + * @returns {Observable} + */ + generateDdsBatchRun(batch: any): Observable { + return this.http.post('/dds/batchruns', batch); + } + + /** + * @param {string | number} batchRunId Batch run ID. + * @returns {Observable} Batch run, including its previewed/posted rows. + */ + getDdsBatchRun(batchRunId: string | number): Observable { + return this.http.get(`/dds/batchruns/${batchRunId}`); + } + + /** + * @returns {Observable} Every batch run, most recent first — who ran what, and whether it was posted. + */ + getDdsBatchRuns(): Observable { + return this.http.get('/dds/batchruns'); + } + + /** + * Ends the relationship with an agent. Refused while they still have accounts assigned, hold + * collected cash, or are owed commission — distinct from deactivating, which is never refused. + * + * @param {string | number} agentId Agent ID. + * @returns {Observable} + */ + closeAgent(agentId: string | number): Observable { + return this.http.post(`/dds/agents/${agentId}`, {}); + } + + /** + * @param {string | number} batchRunId Batch run ID to post - applies exactly the rows it previewed. + * @returns {Observable} + */ + postDdsBatchRun(batchRunId: string | number): Observable { + return this.http.post(`/dds/batchruns/${batchRunId}`, {}); + } + + /** + * @param {string} reportKey One of the backend DDS report keys. + * @param {any} filters fromDate, toDate (ISO strings), agentFrom/To, partyFrom/To. + * @returns {Observable} { reportKey, available, message, columns, rows } + */ + runDdsReport(reportKey: string, filters: any): Observable { + let httpParams = new HttpParams(); + Object.keys(filters || {}).forEach((key) => { + if (filters[key] !== undefined && filters[key] !== null && filters[key] !== '') { + httpParams = httpParams.set(key, filters[key]); + } + }); + return this.http.get(`/dds/reports/${reportKey}`, { params: httpParams }); + } + /** * @param {any[]} currencies * @returns {Observable} Currencies data diff --git a/src/app/organization/rbi-reserve-config/rbi-reserve-config.component.html b/src/app/organization/rbi-reserve-config/rbi-reserve-config.component.html index 46124ece9..eb165f58c 100644 --- a/src/app/organization/rbi-reserve-config/rbi-reserve-config.component.html +++ b/src/app/organization/rbi-reserve-config/rbi-reserve-config.component.html @@ -52,7 +52,9 @@ } } @else { Not set - {{ type === 'CRR' ? 'Form 1 Part B' : 'Form 1 Part C' }} cannot compute + {{ type === 'CRR' ? 'Form 1 Part B' : 'Form 1 Part C' }} cannot compute }
} @@ -230,11 +232,29 @@ - @for (mapping of selection[role.configKey]; track $index; let i = $index) { + + @for (mapping of selection[role.configKey]; track mapping.rowId; let i = $index) { - + + + + + @for (o of availableAccounts(role, i); track o.id) { {{ o.glCode }} · {{ o.name }} } diff --git a/src/app/organization/rbi-reserve-config/rbi-reserve-config.component.ts b/src/app/organization/rbi-reserve-config/rbi-reserve-config.component.ts index 4edfb90f0..a9bed2898 100644 --- a/src/app/organization/rbi-reserve-config/rbi-reserve-config.component.ts +++ b/src/app/organization/rbi-reserve-config/rbi-reserve-config.component.ts @@ -7,8 +7,9 @@ */ import { Component, OnInit, inject } from '@angular/core'; -import { FormsModule } from '@angular/forms'; +import { FormsModule, ReactiveFormsModule } from '@angular/forms'; import { catchError, forkJoin, of } from 'rxjs'; +import { NgxMatSelectSearchModule } from 'ngx-mat-select-search'; import { STANDALONE_SHARED_IMPORTS } from 'app/standalone-shared.module'; import { OrganizationService } from '../organization.service'; @@ -20,6 +21,15 @@ export interface ReserveMapping { glAccountId: number; weightPercentage: number; liabilityClass: string | null; + /** + * Identity for the row, so the template can track rows by it rather than by position. + * + * Tracking by index made deletion impossible: removing a row shifts every row after it up one, so + * Angular reused the inputs that were bound to the old positions and ngModel wrote their values + * straight back into the shortened array. The row reappeared, the draft matched what was stored + * again, and Save stayed grey. Never sent to the backend. + */ + rowId: number; } /** @@ -43,7 +53,9 @@ export interface ReserveMapping { styleUrls: ['./rbi-reserve-config.component.scss'], imports: [ ...STANDALONE_SHARED_IMPORTS, - FormsModule + FormsModule, + ReactiveFormsModule, + NgxMatSelectSearchModule ] }) export class RbiReserveConfigComponent implements OnInit { @@ -80,9 +92,33 @@ export class RbiReserveConfigComponent implements OnInit { /** Every reserve role the backend returned, before the form filter narrows it. */ private allRoles: any[] = []; - /** Re-slices what is on screen. No round trip - the full set is already held. */ + /** Hands each mapping row an identity the template can track it by. See ReserveMapping.rowId. */ + private nextRowId = 1; + + /** + * What is typed into the search box inside an open account dropdown. + * + * One field for the whole screen rather than one per row: only one dropdown is open at a time, and + * it is cleared as each one opens, so a term left behind cannot narrow the next list a user opens + * without them seeing why it is short. + */ + accountSearch = ''; + + /** + * Re-slices what is on screen. No round trip - the full set is already held. + * + * The rows are rebuilt for whatever the new slice brings in. They used not to be: selection was + * filled once, for the heads the first slice happened to contain, so switching the filter showed + * the newly admitted heads with no rows at all - a card with a name and an empty body, reading as + * a head that had nothing mapped when it was simply never read. + */ onFormFilterChange(): void { this.glRoles = this.rolesForScreen(); + for (const role of this.glRoles) { + if (!this.selection[role.configKey]) { + this.reloadSelection(role); + } + } this.rebuildGroups(); } @@ -230,11 +266,7 @@ export class RbiReserveConfigComponent implements OnInit { for (const role of this.glRoles) { // Read back what is stored rather than re-derived: a weight or a class the administrator set // deliberately would otherwise be replaced by a default on the next save. - this.selection[role.configKey] = (role.accounts || []).map((a: any) => ({ - glAccountId: a.glAccountId, - weightPercentage: a.weightPercentage === null || a.weightPercentage === undefined ? 100 : +a.weightPercentage, - liabilityClass: a.liabilityClass ?? (role.classified ? 'TIME' : null) - })); + this.reloadSelection(role); } this.rebuildGroups(); } @@ -288,9 +320,16 @@ export class RbiReserveConfigComponent implements OnInit { ); } - /** A role with nothing mapped makes its return refuse to compute — surfaced, not hidden. */ + /** + * A role with nothing mapped makes its return refuse to compute — surfaced, not hidden. + * + * Read from what is STORED, not from the draft on screen. The returns run off the saved mapping, + * so a row cleared but not yet saved changes nothing for them; counting it as unmapped made the + * banner announce that returns could not compute while they were still computing perfectly well. + * The unsaved edit is already shown by Save turning active. + */ isUnmapped(role: any): boolean { - return !(this.selection[role.configKey] || []).length; + return !(role.accounts || []).length; } get unmappedCount(): number { @@ -327,11 +366,29 @@ export class RbiReserveConfigComponent implements OnInit { // a bank look compliant when it is not. this.selection[role.configKey] = [ ...(this.selection[role.configKey] || []), - { glAccountId: null as any, weightPercentage: 100, liabilityClass: role.classified ? 'TIME' : null }]; + { + glAccountId: null as any, + weightPercentage: 100, + liabilityClass: role.classified ? 'TIME' : null, + rowId: this.nextRowId++ + }]; } + /** + * Drops a row and saves the head straight away. + * + * The bin used to clear the row on screen and leave it to Save, which read as the mapping having + * been removed when nothing had reached the server - the row came back on the next refresh. A bin + * is understood to delete, so it deletes: a row with nothing chosen in it never existed on the + * server and only has to go from the screen, and anything else is written through at once. + */ removeMapping(role: any, index: number): void { - this.selection[role.configKey] = (this.selection[role.configKey] || []).filter((_, i) => i !== index); + const rows = this.selection[role.configKey] || []; + const removed = rows[index]; + this.selection[role.configKey] = rows.filter((_, i) => i !== index); + if (removed?.glAccountId) { + this.saveRole(role); + } } /** @@ -343,7 +400,23 @@ export class RbiReserveConfigComponent implements OnInit { const rows = this.selection[role.configKey] || []; const taken = new Set(rows.filter((_, i) => i !== index).map((m) => `${m.glAccountId}/${m.liabilityClass ?? ''}`)); const current = rows[index]; - return this.glAccountOptions.filter((o) => !taken.has(`${o.id}/${current?.liabilityClass ?? ''}`)); + const free = this.glAccountOptions.filter((o) => !taken.has(`${o.id}/${current?.liabilityClass ?? ''}`)); + + // Typed into the search box above the list. Matched on code AND name, because an administrator + // looking for a head knows one or the other - the GL code off a trial balance, or the name off + // the balance sheet - and should not have to know which this list is sorted by. + const term = (this.accountSearch || '').trim().toLowerCase(); + if (!term) { + return free; + } + // The chosen account stays in the list whatever is typed. Dropping it would leave mat-select + // with a value that matches no option, and the row would read as empty. + return free.filter( + (o) => + o.id === current?.glAccountId || + `${o.glCode}`.toLowerCase().includes(term) || + `${o.name}`.toLowerCase().includes(term) + ); } /** A row that cannot be saved - no account chosen, or a portion outside 0-100. */ @@ -379,6 +452,16 @@ export class RbiReserveConfigComponent implements OnInit { .filter((e) => Math.abs(e.total - 100) > 0.0001); } + /** Rebuilds one head's rows from what the server last returned, discarding the draft. */ + private reloadSelection(role: any): void { + this.selection[role.configKey] = (role.accounts || []).map((a: any) => ({ + glAccountId: a.glAccountId, + weightPercentage: a.weightPercentage === null || a.weightPercentage === undefined ? 100 : +a.weightPercentage, + liabilityClass: a.liabilityClass ?? (role.classified ? 'TIME' : null), + rowId: this.nextRowId++ + })); + } + saveRole(role: any): void { this.savingKey = role.configKey; this.errorMsg = ''; @@ -398,6 +481,11 @@ export class RbiReserveConfigComponent implements OnInit { catchError((err: any) => { this.savingKey = null; this.errorMsg = err?.error?.errors?.[0]?.defaultUserMessage ?? 'Could not save this mapping.'; + // Put the screen back to what the server holds. A failed save that left the draft showing + // would say the change had gone through while the server still had the old mapping - and + // the bin deletes immediately, so this is the only thing between a rejected delete and a + // row that looks gone but is not. + this.reloadSelection(role); return of(null); }) ) diff --git a/src/app/pipes/accounts-filter.pipe.ts b/src/app/pipes/accounts-filter.pipe.ts index 3b7363383..3d7ec3213 100644 --- a/src/app/pipes/accounts-filter.pipe.ts +++ b/src/app/pipes/accounts-filter.pipe.ts @@ -62,7 +62,7 @@ export class AccountsFilterPipe implements PipeTransform { account.status.code === 'savingsAccountStatusType.rejected' ); }); - } else { + } else if (status !== 'all') { accounts = accounts.filter((account: any) => { return ( account.status.code !== 'savingsAccountStatusType.withdrawn.by.applicant' && diff --git a/src/app/products/dds-account-types/create-dds-account-type/create-dds-account-type.component.html b/src/app/products/dds-account-types/create-dds-account-type/create-dds-account-type.component.html new file mode 100644 index 000000000..3e0eff78f --- /dev/null +++ b/src/app/products/dds-account-types/create-dds-account-type/create-dds-account-type.component.html @@ -0,0 +1,145 @@ + + +
+
+
+
+
+

DDS Product Creation

+

{{ isEdit ? 'Update this Daily Deposit product.' : 'Register a Daily Deposit product.' }}

+
+
+ +
+
+ + Product Group + + @for (group of accountTypeGroups; track group) { + {{ group }} + } + + + + + Product Code + + + + + Product Description + + + + + Interest Rate (%) + + Interest is worked out daily on the balance (rate ÷ 365) + @if (accountTypeForm.get('interestRate')?.hasError('required')) { + Interest rate is required + } + + + + Auto Interest Post + + Yes + No + + + + + Auto Interest Post When + + @for (option of autoInterestPostWhenOptions; track option) { + {{ option }} + } + + + + + @if (autoPostOn) { + + Auto Interest Post Day + + + } + + + + Maturity Auto Close + + Yes + No + + Closes the account on its maturity date, if it names a savings account to be paid into + +
+ + +

Premature Closure

+

+ Applied when an account on this scheme is closed before its term ends. The branch chooses at the time whether + the customer or the agent bears the cost; these set what that costs. +

+ +
+ + Minimum Lock-in (months) + + Before this, early closure is refused outright. 0 allows it from day one. + + + + Customer: interest rate reduced by (%) + + Taken off the rate for the whole period, not only the part left to run. + + + + Agent: commission reduced by (%) + + Charged whoever bears the interest — the round was not kept going. + +
+ +
+ + +
+
+
+
diff --git a/src/app/products/dds-account-types/create-dds-account-type/create-dds-account-type.component.scss b/src/app/products/dds-account-types/create-dds-account-type/create-dds-account-type.component.scss new file mode 100644 index 000000000..a6a629710 --- /dev/null +++ b/src/app/products/dds-account-types/create-dds-account-type/create-dds-account-type.component.scss @@ -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/. + */ + +.dat-root { + padding: 20px 24px 40px; +} + +.dat-card { + background: #fff; + border: 1px solid rgb(0 0 0 / 8%); + border-radius: 10px; + box-shadow: 0 1px 3px rgb(0 0 0 / 6%); + overflow: hidden; + max-width: 880px; + margin: 0 auto; +} + +.dat-head { + display: flex; + align-items: center; + gap: 14px; + padding: 20px 24px; + border-bottom: 1px solid rgb(0 0 0 / 7%); + + h2 { + margin: 0; + font-size: 1.2rem; + font-weight: 600; + } + + p { + margin: 2px 0 0; + font-size: 0.83rem; + color: rgb(0 0 0 / 55%); + } +} + +.dat-head__ic { + width: 46px; + height: 46px; + border-radius: 12px; + display: grid; + place-items: center; + font-size: 1.05rem; + color: #fff; + background: linear-gradient(135deg, #0f766e, #134e4a); + flex: none; +} + +.dat-grid { + display: grid; + grid-template-columns: repeat(2, minmax(220px, 1fr)); + gap: 8px 20px; + padding: 26px 24px 8px; +} + +.dat-field { + width: 100%; +} + +.dat-field--wide { + grid-column: 1 / -1; +} + +/* + * Divides the scheme's own settings from the terms it charges for closing early. A rule rather than a + * second card, because it is the same record being filled in - the break is to stop three money + * figures reading as more of the posting settings above them. + */ +.dat-section { + margin: 20px 0 0; + padding: 18px 24px 0; + border-top: 1px solid rgb(0 0 0 / 7%); + font-size: 0.82rem; + font-weight: 600; + letter-spacing: 0.04em; + text-transform: uppercase; + color: #0f766e; +} + +.dat-section-note { + margin: 6px 0 0; + padding: 0 24px; + max-width: 68ch; /* stylelint-disable-line unit-allowed-list */ + font-size: 0.8rem; + line-height: 1.5; + color: rgb(0 0 0 / 60%); +} + +/* The note already carries the spacing, so the grid under it does not add its own again. */ +.dat-section-note + .dat-grid { + padding-top: 14px; +} + +.dat-actions { + display: flex; + justify-content: flex-end; + gap: 12px; + padding: 16px 24px; + border-top: 1px solid rgb(0 0 0 / 7%); +} + +@media (width <= 720px) { + .dat-grid { + grid-template-columns: 1fr; + } +} diff --git a/src/app/products/dds-account-types/create-dds-account-type/create-dds-account-type.component.ts b/src/app/products/dds-account-types/create-dds-account-type/create-dds-account-type.component.ts new file mode 100644 index 000000000..0cdbcf188 --- /dev/null +++ b/src/app/products/dds-account-types/create-dds-account-type/create-dds-account-type.component.ts @@ -0,0 +1,234 @@ +/** + * Copyright since 2025 Mifos Initiative + * + * This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. + */ + +/** Angular Imports */ +import { Component, OnInit, inject } from '@angular/core'; +import { UntypedFormBuilder, UntypedFormGroup, Validators } from '@angular/forms'; +import { ActivatedRoute, Router } from '@angular/router'; +import { FaIconComponent } from '@fortawesome/angular-fontawesome'; +import { STANDALONE_SHARED_IMPORTS } from 'app/standalone-shared.module'; + +/** Custom Services */ +import { ProductsService } from 'app/products/products.service'; +import { AlertService } from 'app/core/alert/alert.service'; +import { OrganizationService } from 'app/organization/organization.service'; +import { SettingsService } from 'app/settings/settings.service'; +import { announceHeldForApproval, heldForApproval } from 'app/deposits/dds-approvals/dds-approval-notice'; + +/** + * Account Type Creation — the legacy DDS screen this replaces, field for field: group, code, + * description, and whether interest posts automatically and how often. Nothing else: DDS never used + * the deposit-product engine, so there are no terms, charges or accounting tabs behind this. + * + * The same form handles create and edit; an `accountTypeId` in the route switches it to edit. + */ +@Component({ + selector: 'mifosx-create-dds-account-type', + templateUrl: './create-dds-account-type.component.html', + styleUrls: ['./create-dds-account-type.component.scss'], + imports: [ + ...STANDALONE_SHARED_IMPORTS, + FaIconComponent + ] +}) +export class CreateDdsAccountTypeComponent implements OnInit { + private formBuilder = inject(UntypedFormBuilder); + private router = inject(Router); + private route = inject(ActivatedRoute); + private productsService = inject(ProductsService); + private alertService = inject(AlertService); + private organizationService = inject(OrganizationService); + private settingsService = inject(SettingsService); + + accountTypeGroups = ['DDS']; + + autoInterestPostWhenOptions = [ + 'Monthly', + 'Quarterly', + 'Half Yearly', + 'Yearly' + ]; + + /** Set when the route carries an id — the form is then editing rather than creating. */ + accountTypeId: number | null = null; + + accountTypeForm: UntypedFormGroup = this.formBuilder.group({ + accountTypeGroup: [ + 'DDS', + Validators.required + ], + accountTypeCode: [ + '', + Validators.required + ], + accountTypeDescription: [ + '', + Validators.required + ], + autoInterestPost: [ + true, + Validators.required + ], + autoInterestPostWhen: [ + 'Monthly', + Validators.required + ], + autoInterestPostDay: [''], + /* + * Defaults to No, so a scheme created without a thought about it keeps behaving the way every + * scheme behaves today - the clerk pays out matured accounts by hand. Turning it on is a + * decision somebody makes, not one they inherit. + */ + maturityAutoClose: [ + false, + Validators.required + ], + // Mandatory: every account opened on this scheme takes this rate, rather than one typed per customer. + interestRate: [ + '', + [ + Validators.required, + Validators.min(0), + Validators.max(100) + ] + ], + /* + * What this scheme charges when an account is closed before its term is up. Set here rather than + * asked of the clerk at closing time: the terms are the bank's, and the same for every account on + * the scheme. Defaults are the bank's stated terms - 12 months, 1% and 1%. + */ + prematureLockinMonths: [ + 12, + [ + Validators.required, + Validators.min(0), + Validators.max(600) + ] + ], + prematureInterestReduction: [ + 1, + [ + Validators.required, + Validators.min(0), + Validators.max(100) + ] + ], + prematureAgentCommissionPenalty: [ + 1, + [ + Validators.required, + Validators.min(0), + Validators.max(100) + ] + ] + }); + + get isEdit(): boolean { + return this.accountTypeId !== null; + } + + /** + * "Auto Interest Post Day" only means anything while auto-posting is on, and the legacy screen + * hid it the same way. "When" stays visible either way, as it did there. + */ + get autoPostOn(): boolean { + return !!this.accountTypeForm.get('autoInterestPost')?.value; + } + + ngOnInit(): void { + // The posting day is required exactly while auto-posting is on, and is cleared when it is off. + const dayControl = this.accountTypeForm.get('autoInterestPostDay'); + const applyDayRule = (autoPostOn: boolean) => { + if (autoPostOn) { + dayControl?.setValidators([Validators.required]); + } else { + dayControl?.clearValidators(); + dayControl?.setValue(''); + } + dayControl?.updateValueAndValidity(); + }; + applyDayRule(this.autoPostOn); + this.accountTypeForm.get('autoInterestPost')?.valueChanges.subscribe((value) => applyDayRule(!!value)); + + const routeId = this.route.snapshot.paramMap.get('accountTypeId'); + if (!routeId) { + return; + } + this.accountTypeId = Number(routeId); + this.productsService.getDdsAccountType(this.accountTypeId).subscribe((accountType: any) => { + this.accountTypeForm.patchValue({ + accountTypeGroup: accountType.accountTypeGroup, + accountTypeCode: accountType.accountTypeCode, + accountTypeDescription: accountType.accountTypeDescription, + autoInterestPost: !!accountType.autoInterestPost, + autoInterestPostWhen: accountType.autoInterestPostWhen || 'Monthly', + autoInterestPostDay: accountType.autoInterestPostDay ?? '', + maturityAutoClose: !!accountType.maturityAutoClose, + interestRate: accountType.interestRate ?? '', + // ?? rather than ||, because zero is a real answer here - a scheme with no lock-in, or one + // that charges nothing - and must not be replaced by the default. + prematureLockinMonths: accountType.prematureLockinMonths ?? 12, + prematureInterestReduction: accountType.prematureInterestReduction ?? 1, + prematureAgentCommissionPenalty: accountType.prematureAgentCommissionPenalty ?? 1 + }); + }); + } + + cancel(): void { + this.backToList(); + } + + submit(): void { + if (this.accountTypeForm.invalid) { + return; + } + const raw = this.accountTypeForm.getRawValue(); + const payload = { + ...raw, + // The posting day only applies while auto-posting is on; send nothing for it otherwise. + autoInterestPostDay: + raw.autoInterestPost && raw.autoInterestPostDay ? Number(raw.autoInterestPostDay) : undefined, + // Sent as numbers, not the strings a text input yields - the backend reads them as decimals. + interestRate: Number(raw.interestRate), + prematureLockinMonths: Number(raw.prematureLockinMonths), + prematureInterestReduction: Number(raw.prematureInterestReduction), + prematureAgentCommissionPenalty: Number(raw.prematureAgentCommissionPenalty), + // Numeric parameters have to carry the locale they were entered in. + locale: this.settingsService.language.code + }; + + const done = (response: any) => { + if (heldForApproval(response)) { + announceHeldForApproval( + response, + `DDS product ${payload.accountTypeCode} ${this.isEdit ? 'change' : ''}`.trim(), + this.organizationService, + this.alertService + ); + } else { + this.alertService.alert({ + type: 'Success', + message: this.isEdit + ? `Account type ${payload.accountTypeCode} updated` + : `Account type ${payload.accountTypeCode} created` + }); + } + this.backToList(); + }; + + if (this.accountTypeId !== null) { + this.productsService.updateDdsAccountType(this.accountTypeId, payload).subscribe(done); + } else { + this.productsService.createDdsAccountType(payload).subscribe(done); + } + } + + private backToList(): void { + this.router.navigate(['/products/dds-account-types']); + } +} diff --git a/src/app/products/dds-account-types/dds-account-types.component.html b/src/app/products/dds-account-types/dds-account-types.component.html new file mode 100644 index 000000000..cea8c4202 --- /dev/null +++ b/src/app/products/dds-account-types/dds-account-types.component.html @@ -0,0 +1,141 @@ + + +
+
+
+
+
+

DDS Product

+

The Daily Deposit products a customer account can be opened under.

+
+
+
+
+ + {{ totalCount }} + {{ totalCount === 1 ? 'type' : 'types' }} +
+ +
+
+ +
+
+
+
+
{{ totalCount | number }}
+
Total DDS Products
+
+
+
+
+
+
{{ filteredCount | number }}
+
Showing after filter
+
+
+
+ +
+
+ +
+ + @if (totalCount === 0) { +
+
+

No account types yet

+

Create one so Daily Deposit accounts can be opened against it.

+ +
+ } @else { +
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + +
Product +
+
+
+
{{ t.accountTypeCode }}
+
{{ t.accountTypeGroup }}
+
+
+
Description{{ t.accountTypeDescription }}Auto Interest Post + @if (t.autoInterestPost) { + {{ t.autoInterestPostWhen || 'Yes' }} + @if (t.autoInterestPostDay) { + day {{ t.autoInterestPostDay }} + } + } @else { + No + } + Created + {{ t.createdDate ? (t.createdDate | dateFormat: 'DD MMM YYYY, hh:mm A') : '—' }} + Actions + +
+ + @if (filteredCount === 0 && totalCount > 0) { +
+ + No account types match your filter. +
+ } + + +
+ } +
+
diff --git a/src/app/products/dds-account-types/dds-account-types.component.scss b/src/app/products/dds-account-types/dds-account-types.component.scss new file mode 100644 index 000000000..9d6517e68 --- /dev/null +++ b/src/app/products/dds-account-types/dds-account-types.component.scss @@ -0,0 +1,39 @@ +/** + * Copyright since 2025 Mifos Initiative + * + * This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. + */ + +@use 'assets/styles/product-list' as pl; + +@include pl.product-list-styles; + +/* DDS-specific teal accent */ +:host { + --pl-accent: #0f766e; + --pl-accent-l: #ccfbf1; +} + +/* A retired account type stays listed but reads as switched off. */ +.dat-row--inactive td:not(.num) { + opacity: 0.55; +} + +.dat-pill { + display: inline-block; + padding: 3px 11px; + border-radius: 11px; + font-size: 0.72rem; + font-weight: 500; + line-height: 1.7; + background: #dcfce7; + color: #15803d; + white-space: nowrap; +} + +.dat-pill--off { + background: rgb(0 0 0 / 7%); + color: rgb(0 0 0 / 52%); +} diff --git a/src/app/products/dds-account-types/dds-account-types.component.ts b/src/app/products/dds-account-types/dds-account-types.component.ts new file mode 100644 index 000000000..9b3e13e57 --- /dev/null +++ b/src/app/products/dds-account-types/dds-account-types.component.ts @@ -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/. + */ + +/** Angular Imports */ +import { Component, OnInit, ViewChild, inject } from '@angular/core'; +import { MatPaginator } from '@angular/material/paginator'; +import { MatSort, MatSortHeader } from '@angular/material/sort'; +import { + MatTable, + MatTableDataSource, + MatColumnDef, + MatHeaderCellDef, + MatHeaderCell, + MatCellDef, + MatCell, + MatHeaderRowDef, + MatHeaderRow, + MatRowDef, + MatRow +} from '@angular/material/table'; +import { MatTooltip } from '@angular/material/tooltip'; +import { FaIconComponent } from '@fortawesome/angular-fontawesome'; +import { STANDALONE_SHARED_IMPORTS } from 'app/standalone-shared.module'; + +/** Custom Services */ +import { ProductsService } from 'app/products/products.service'; + +/** + * DDS Account Types list. + * + * The scheme variants a Party account can be opened under - the legacy "Account Type Creation" + * record. Master data only, deliberately not a deposit product: DDS never used the deposit-product + * engine, and interest is accrued off each Party's own rate by the DDS batch job. + * + * Follows the same list -> create/edit shape as every other product list in this section, and + * shares their stylesheet. + */ +@Component({ + selector: 'mifosx-dds-account-types', + templateUrl: './dds-account-types.component.html', + styleUrls: ['./dds-account-types.component.scss'], + imports: [ + ...STANDALONE_SHARED_IMPORTS, + FaIconComponent, + MatTable, + MatSort, + MatColumnDef, + MatHeaderCellDef, + MatHeaderCell, + MatSortHeader, + MatCellDef, + MatCell, + MatHeaderRowDef, + MatHeaderRow, + MatRowDef, + MatRow, + MatPaginator, + MatTooltip + ] +}) +export class DdsAccountTypesComponent implements OnInit { + private productsService = inject(ProductsService); + + displayedColumns: string[] = [ + 'accountTypeCode', + 'accountTypeDescription', + 'autoInterestPost', + 'createdDate', + 'actions' + ]; + + dataSource: MatTableDataSource = new MatTableDataSource([]); + accountTypes: any[] = []; + filteredCount = 0; + + @ViewChild(MatPaginator, { static: true }) paginator: MatPaginator; + @ViewChild(MatSort, { static: true }) sort: MatSort; + + get totalCount(): number { + return this.accountTypes.length; + } + + ngOnInit(): void { + this.loadAccountTypes(); + } + + private loadAccountTypes(): void { + this.productsService.getDdsAccountTypes().subscribe((accountTypes: any[]) => { + this.accountTypes = accountTypes || []; + this.dataSource = new MatTableDataSource(this.accountTypes); + this.dataSource.paginator = this.paginator; + this.dataSource.sort = this.sort; + this.filteredCount = this.accountTypes.length; + this.dataSource.connect().subscribe((rows) => (this.filteredCount = rows.length)); + }); + } + + applyFilter(filterValue: string): void { + this.dataSource.filter = filterValue.trim().toLowerCase(); + } + + /** + * Retires or restores an account type. Never a delete: Parties are opened against an account + * type, so removing it would orphan every account already using it. Deactivating takes it out of + * the pick lists for new accounts and leaves existing ones untouched. + */ +} diff --git a/src/app/products/loan-interest-rate-register/cibil-adjustment/cibil-adjustment.component.html b/src/app/products/loan-interest-rate-register/cibil-adjustment/cibil-adjustment.component.html new file mode 100644 index 000000000..25a387ffa --- /dev/null +++ b/src/app/products/loan-interest-rate-register/cibil-adjustment/cibil-adjustment.component.html @@ -0,0 +1,96 @@ + + +
+
+ +
+
Read-only — {{ register.status }}
+
{{ readOnlyReason() }}
+
+
+ +
+ +
+
CS
+
+

CIBIL / credit score adjustment

+

Applied on top of the chart rate at loan sanction · reused across all loan products linked to this register

+
+ +
+ +
+
+
+
Credit score slabs
+
+ Only the Adjustment % is editable · changes apply to new sanctions after the register is approved +
+
+ +
+ + + + + + + + + + + + + + + + + + + + + + + + +
#Score fromScore toRisk tierTypeAdjustment
{{ i + 1 }}{{ slab.from }}{{ slab.to }} + {{ slab.tier }} + {{ slab.type }} +
+ + % +
+
+ +
+
+ + +
diff --git a/src/app/products/loan-interest-rate-register/cibil-adjustment/cibil-adjustment.component.scss b/src/app/products/loan-interest-rate-register/cibil-adjustment/cibil-adjustment.component.scss new file mode 100644 index 000000000..19f0c1489 --- /dev/null +++ b/src/app/products/loan-interest-rate-register/cibil-adjustment/cibil-adjustment.component.scss @@ -0,0 +1,414 @@ +/** + * 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/. + */ + +.ca { + background: #f8fafc; + min-height: calc(100vh - 160px); + padding: 24px 32px; + display: flex; + flex-direction: column; + gap: 16px; + + &__active-strip { + display: flex; + align-items: center; + gap: 10px; + padding: 10px 18px; + background: #ecfdf5; + border: 1px solid #a7f3d0; + border-radius: 10px; + font-size: 12px; + color: #065f46; + } + + &__active-dot { + color: #059669; + font-size: 14px; + line-height: 1; + } + + &__active-label { + font-size: 10px; + letter-spacing: 0.5px; + font-weight: 700; + color: #065f46; + } + + &__active-name { + font-weight: 600; + color: #064e3b; + } + + &__active-sub { + color: #047857; + } + + &__active-flag { + margin-left: auto; + font-size: 11px; + font-weight: 600; + color: #047857; + background: #d1fae5; + padding: 3px 10px; + border-radius: 999px; + } + + &__active-link { + margin-left: auto; + font-size: 12px; + font-weight: 600; + color: #047857; + cursor: pointer; + + &:hover { + text-decoration: underline; + } + } + + &__readonly-banner { + display: flex; + align-items: center; + gap: 12px; + padding: 12px 18px; + background: #fef3c7; + border: 1px solid #fcd34d; + border-left: 4px solid #d97706; + border-radius: 10px; + color: #78350f; + + i { + font-size: 16px; + color: #d97706; + } + } + + &__readonly-title { + font-size: 13px; + font-weight: 700; + color: #78350f; + } + + &__readonly-sub { + margin-top: 2px; + font-size: 12px; + color: #92400e; + } + + &__header { + display: flex; + align-items: center; + gap: 14px; + padding: 20px 24px; + background: white; + border: 1px solid #e2e8f0; + border-radius: 12px; + } + + &__header-icon { + width: 42px; + height: 42px; + border-radius: 10px; + background: #dbeafe; + color: #1d4ed8; + display: flex; + align-items: center; + justify-content: center; + font-size: 14px; + font-weight: 700; + letter-spacing: 0.3px; + } + + &__header-text { + flex: 1; + + h2 { + margin: 0; + font-size: 20px; + font-weight: 600; + color: #0f172a; + } + + p { + margin: 3px 0 0; + font-size: 12px; + color: #64748b; + } + } + + &__card { + background: white; + border: 1px solid #e2e8f0; + border-radius: 12px; + overflow: hidden; + } + + &__card-head { + padding: 16px 20px; + border-bottom: 1px solid #f1f5f9; + display: flex; + align-items: flex-start; + justify-content: space-between; + gap: 12px; + } + + &__card-title { + font-size: 15px; + font-weight: 600; + color: #0f172a; + } + + &__card-sub { + margin-top: 2px; + font-size: 12px; + color: #64748b; + } + + &__dash-btn { + height: 32px; + font-size: 12px; + font-weight: 600; + color: #1d4ed8; + border: 1px dashed #1d4ed8; + padding: 0 14px; + + i { + margin-right: 6px; + } + } + + &__num { + text-align: right; + } + + &__col-ix { + width: 44px; + text-align: center; + color: #94a3b8; + font-weight: 600; + } + + &__col-del { + width: 48px; + text-align: center; + } + + &__cell-muted { + color: #64748b; + } + + &__cell-readonly { + color: #0f172a; + font-variant-numeric: tabular-nums; + padding: 0 22px; + } + + &__footer { + display: flex; + justify-content: flex-end; + gap: 10px; + padding: 4px 2px; + } +} + +/* ---- Editable grid ---------------------------------------------------- */ + +.ca__editgrid { + width: 100%; + border-collapse: collapse; + font-size: 13px; + + thead tr { + background: #f8fafc; + + th { + text-align: left; + padding: 10px 16px; + font-size: 10px; + text-transform: uppercase; + letter-spacing: 0.4px; + color: #64748b; + font-weight: 600; + border-bottom: 1px solid #e2e8f0; + + &.ca__num { + text-align: right; + } + } + } + + tbody tr { + border-bottom: 1px solid #f1f5f9; + transition: background 0.12s ease; + + &:hover { + background: #f8fafc; + + .ca__delete-btn { + opacity: 1; + } + } + + &:last-child { + border-bottom: 0; + } + + td { + padding: 8px 12px; + vertical-align: middle; + } + } +} + +/* Borderless cell input — gets a visible border on hover/focus. */ +.ca__ghost-input { + width: 100%; + height: 32px; + border: 1px solid transparent; + background: transparent; + border-radius: 6px; + padding: 0 10px; + font-size: 13px; + color: #0f172a; + font-family: inherit; + box-sizing: border-box; + + &:hover { + border-color: #e2e8f0; + background: white; + } + + &:focus { + outline: none; + border-color: #1d4ed8; + background: white; + box-shadow: 0 0 0 3px rgb(29 78 216 / 10%); + } + + &--num { + text-align: right; + font-variant-numeric: tabular-nums; + } + + /* Hide number input spinners for a cleaner look. */ + &[type='number']::-webkit-inner-spin-button, + &[type='number']::-webkit-outer-spin-button { + appearance: none; + margin: 0; + } + + &[type='number'] { + appearance: textfield; + } +} + +/* Adjustment input — coloured by sign, with trailing % suffix. */ +.ca__adj-wrap { + display: inline-flex; + align-items: center; + justify-content: flex-end; + gap: 2px; + width: 100%; +} + +.ca__adj-input { + width: 72px; + font-weight: 700; + + &--negative { + color: #059669; + } + + &--positive { + color: #d97706; + } + + &--high { + color: #dc2626; + } +} + +.ca__adj-suffix { + color: #94a3b8; + font-size: 12px; + font-weight: 600; +} + +/* Chip-shaped tier select — background matches the current tier. */ +.ca__tier-select { + appearance: none; + background-image: url("data:image/svg+xml;utf8,"); + background-repeat: no-repeat; + background-position: right 10px center; + padding: 4px 26px 4px 12px; + height: 28px; + border: 1px solid transparent; + border-radius: 999px; + font-size: 12px; + font-weight: 600; + cursor: pointer; + font-family: inherit; + + &:focus { + outline: none; + box-shadow: 0 0 0 3px rgb(29 78 216 / 15%); + } + + &--vpoor { + background-color: #fecaca; + color: #7f1d1d; + } + + &--poor { + background-color: #fee2e2; + color: #991b1b; + } + + &--fair { + background-color: #fef3c7; + color: #92400e; + } + + &--good { + background-color: #dbeafe; + color: #1e40af; + } + + &--excellent { + background-color: #d1fae5; + color: #065f46; + } +} + +/* Trash button — hidden until row hover. */ +.ca__delete-btn { + width: 28px; + height: 28px; + border: 1px solid transparent; + background: transparent; + color: #94a3b8; + border-radius: 6px; + font-size: 12px; + line-height: 1; + cursor: pointer; + opacity: 0; + transition: + opacity 0.12s ease, + color 0.12s ease, + border-color 0.12s ease, + background 0.12s ease; + + &:hover { + border-color: #fecaca; + background: #fef2f2; + color: #dc2626; + } + + &:focus-visible { + opacity: 1; + } +} diff --git a/src/app/products/loan-interest-rate-register/cibil-adjustment/cibil-adjustment.component.ts b/src/app/products/loan-interest-rate-register/cibil-adjustment/cibil-adjustment.component.ts new file mode 100644 index 000000000..701b5667c --- /dev/null +++ b/src/app/products/loan-interest-rate-register/cibil-adjustment/cibil-adjustment.component.ts @@ -0,0 +1,139 @@ +/** + * Copyright since 2025 Mifos Initiative + * + * This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. + */ + +import { Component, OnInit, inject } from '@angular/core'; +import { CommonModule } from '@angular/common'; +import { ActivatedRoute, Router } from '@angular/router'; +import { FormsModule } from '@angular/forms'; +import { MatButtonModule } from '@angular/material/button'; +import { RateRegisterService } from '../rate-register.service'; +import { CibilSlab, RateRegister } from '../models/rate-register.model'; + +/** + * CIBIL / credit-score slab editor. + * Score range, tier and type are fixed per slab; only the Adjustment % is editable. + * New slabs can be appended (and removed) via Add slab / row trash. + */ +@Component({ + selector: 'mifosx-cibil-adjustment', + templateUrl: './cibil-adjustment.component.html', + styleUrls: ['./cibil-adjustment.component.scss'], + imports: [ + CommonModule, + FormsModule, + MatButtonModule + ] +}) +export class CibilAdjustmentComponent implements OnInit { + private readonly service = inject(RateRegisterService); + private readonly route = inject(ActivatedRoute); + private readonly router = inject(Router); + + register!: RateRegister; + active?: RateRegister; + + ngOnInit(): void { + const idParam = this.route.snapshot.queryParamMap.get('id'); + const id = idParam ? Number(idParam) : null; + this.service.list().subscribe((all) => { + const found = (id ? all.find((r) => r.id === id) : undefined) ?? this.service.getActiveOrFirstDraft(); + if (!found) { + this.router.navigate(['/products/loan-interest-rate-register']); + return; + } + this.register = found; + this.active = this.service.getActive(); + }); + } + + isActiveRegister(): boolean { + return !!this.active && this.active.id === this.register?.id; + } + + viewActive(): void { + if (this.active) { + this.router.navigate(['/products/loan-interest-rate-register/cibil'], { queryParams: { id: this.active.id } }); + } + } + + /** Only a Draft can be edited. Pending Approval / Active / Expired open read-only. */ + get canEdit(): boolean { + return this.register?.status === 'Draft'; + } + + readOnlyReason(): string { + switch (this.register?.status) { + case 'Pending Approval': + return 'This version has been submitted and is awaiting checker approval. No further edits allowed.'; + case 'Active': + return 'This is the current Active register. Click "Edit (new version)" on the list page to propose changes.'; + case 'Expired': + return 'This is an inactive historical version. Read-only.'; + default: + return 'Read-only.'; + } + } + + tierClass(tier: CibilSlab['tier']): string { + const map: Record = { + 'V. Poor': 'tier tier--vpoor', + Poor: 'tier tier--poor', + Fair: 'tier tier--fair', + Good: 'tier tier--good', + Excellent: 'tier tier--excellent' + }; + return map[tier] ?? 'tier'; + } + + /** Classes to colour the adjustment number by sign/magnitude. */ + adjustInputClass(pct: number): string { + const base = 'ca__ghost-input ca__ghost-input--num ca__adj-input'; + if (pct < 0) { + return `${base} ca__adj-input--negative`; + } + if (pct >= 1) { + return `${base} ca__adj-input--high`; + } + if (pct > 0) { + return `${base} ca__adj-input--positive`; + } + return base; + } + + /** Append a new slab continuing from the current highest "score to". */ + addSlab(): void { + if (!this.canEdit) { + return; + } + const lastTo = this.register.cibilSlabs.length ? Math.max(...this.register.cibilSlabs.map((s) => s.to)) : 0; + const from = Math.min(lastTo + 1, 900); + this.register.cibilSlabs.push({ + from, + to: Math.min(from + 50, 900), + tier: 'Fair', + type: 'Flat', + adjustmentPct: 0 + }); + } + + deleteSlab(index: number): void { + if (!this.canEdit) { + return; + } + this.register.cibilSlabs.splice(index, 1); + } + + /** Edits are kept in the in-memory service (the register reference is shared). */ + save(): void { + this.router.navigate(['/products/loan-interest-rate-register/edit'], { queryParams: { id: this.register.id } }); + } + + backToRegister(): void { + this.save(); + } +} diff --git a/src/app/products/loan-interest-rate-register/edit-register/edit-register.component.html b/src/app/products/loan-interest-rate-register/edit-register/edit-register.component.html new file mode 100644 index 000000000..35c9f7843 --- /dev/null +++ b/src/app/products/loan-interest-rate-register/edit-register/edit-register.component.html @@ -0,0 +1,199 @@ + + +
+
+ ● + CURRENT ACTIVE + {{ active.name }} + · effective {{ active.effectiveFrom }} → {{ active.effectiveTo || '—' }} + You are viewing the Active register + View active → +
+ +
+ +
+
Read-only — {{ register.status }}
+
{{ readOnlyReason() }}
+
+
+ +
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+ + +
+ +
+
+
Rate chart grid
+
+ {{ rows.length }} products × {{ slabs.length }} amount slabs + · click any cell to edit + · read-only view + · {{ unmappedCount() }} without GL — map every product's GL before submitting +
+
+ + +
+
+
+
Add product
+
+
+ + +
+
+ + +
+ + +
+
{{ addProductError }}
+
+
+
Add amount slab
+
+
+ + +
+
+ + +
+ + +
+
The new slab is added as the last column; fill in its rates for each product.
+
{{ addSlabError }}
+
+
+ + + + + + + + + + + + + +
Particulars{{ slab.label }}
+ {{ row.srNo }}{{ row.particulars }} +
+ {{ row.glAccountId == null ? 'GL not mapped' : glLabel(row) }} +
+ +
+ + {{ display(cell) }} +
+
+
+ +
+
CS
+
+
CIBIL / credit score adjustment
+
+ {{ register.cibilSlabs.length }} slabs · +2.00% (very poor) to base (above 700) · applied on top of chart rate +
+
+ Edit → +
+ + +
diff --git a/src/app/products/loan-interest-rate-register/edit-register/edit-register.component.scss b/src/app/products/loan-interest-rate-register/edit-register/edit-register.component.scss new file mode 100644 index 000000000..25e958ccc --- /dev/null +++ b/src/app/products/loan-interest-rate-register/edit-register/edit-register.component.scss @@ -0,0 +1,496 @@ +/** + * 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/. + */ + +.er { + background: #f8fafc; + min-height: calc(100vh - 160px); + padding: 20px 24px; + display: flex; + flex-direction: column; + gap: 12px; + + &__active-strip { + display: flex; + align-items: center; + gap: 10px; + padding: 10px 18px; + background: #ecfdf5; + border: 1px solid #a7f3d0; + border-radius: 9px; + font-size: 12px; + color: #065f46; + } + + &__active-dot { + color: #059669; + font-size: 14px; + line-height: 1; + } + + &__active-label { + font-size: 10px; + letter-spacing: 0.5px; + font-weight: 700; + color: #065f46; + } + + &__active-name { + font-weight: 600; + color: #064e3b; + } + + &__active-sub { + color: #047857; + } + + &__active-flag { + margin-left: auto; + font-size: 11px; + font-weight: 600; + color: #047857; + background: #d1fae5; + padding: 3px 10px; + border-radius: 999px; + } + + &__active-link { + margin-left: auto; + font-size: 12px; + font-weight: 600; + color: #047857; + cursor: pointer; + + &:hover { + text-decoration: underline; + } + } + + &__gl { + margin: 3px 0 0 30px; + } + + &__gl-tag { + display: inline-block; + font-size: 10px; + font-weight: 600; + color: #475569; + background: #f1f5f9; + border-radius: 999px; + padding: 1px 8px; + white-space: nowrap; + + &--unmapped { + color: #92400e; + background: #fef3c7; + } + + &--editable { + cursor: pointer; + + &:hover { + outline: 1px solid #94a3b8; + } + } + } + + &__gl-select { + display: block; + margin: 4px 0 0 30px; + max-width: 340px; + } + + &__gl-warn { + color: #b45309; + font-weight: 600; + } + + &__add-panel { + padding: 14px 18px; + border-bottom: 1px solid #f1f5f9; + background: #f8fafc; + } + + &__add-title { + font-size: 13px; + font-weight: 600; + color: #0f172a; + margin-bottom: 10px; + } + + &__add-fields { + display: flex; + align-items: flex-end; + gap: 12px; + flex-wrap: wrap; + + .er__field { + min-width: 240px; + } + + .er__field--gl { + min-width: 320px; + } + } + + &__add-hint { + margin-top: 8px; + font-size: 12px; + color: #64748b; + } + + &__add-error { + margin-top: 8px; + font-size: 12px; + color: #b91c1c; + } + + &__readonly-banner { + display: flex; + align-items: center; + gap: 12px; + padding: 12px 18px; + background: #fef3c7; + border: 1px solid #fcd34d; + border-left: 4px solid #d97706; + border-radius: 9px; + color: #78350f; + + i { + font-size: 16px; + color: #d97706; + } + } + + &__readonly-title { + font-size: 13px; + font-weight: 700; + color: #78350f; + } + + &__readonly-sub { + margin-top: 2px; + font-size: 12px; + color: #92400e; + } + + &__meta-bar { + background: white; + border: 1px solid #e2e8f0; + border-radius: 9px; + padding: 12px 14px; + display: flex; + gap: 10px; + align-items: flex-end; + } + + &__field { + display: flex; + flex-direction: column; + flex: 1.5; + + label { + font-size: 10px; + color: #64748b; + font-weight: 600; + letter-spacing: 0.3px; + margin-bottom: 3px; + } + + input, + select { + height: 32px; + border: 1px solid #cbd5e1; + background: white; + border-radius: 6px; + padding: 0 10px; + font-size: 13px; + font-family: inherit; + color: #0f172a; + + &:focus { + outline: 2px solid #1d4ed8; + outline-offset: -1px; + } + } + + &--date { + flex: 0.9; + } + + &--curr { + flex: 0.5; + } + } + + &__meta-btn { + height: 32px; + font-size: 11px; + + i { + margin-right: 4px; + } + } + + &__grid-card { + background: white; + border: 1px solid #e2e8f0; + border-radius: 9px; + overflow: hidden; + display: flex; + flex-direction: column; + flex: 1; + min-height: 500px; + } + + &__grid-head { + padding: 8px 14px; + background: #f8fafc; + border-bottom: 1px solid #e2e8f0; + display: flex; + align-items: center; + gap: 10px; + } + + &__grid-title { + font-size: 12px; + font-weight: 600; + } + + &__grid-sub { + font-size: 10px; + color: #64748b; + } + + &__grid-actions { + margin-left: auto; + display: flex; + gap: 6px; + } + + &__dash-btn { + height: 26px; + font-size: 10px; + font-weight: 600; + color: #1d4ed8; + border: 1px dashed #1d4ed8; + + i { + margin-right: 3px; + } + } + + &__grid-scroll { + overflow: auto; + flex: 1; + } + + &__grid { + width: 100%; + border-collapse: separate; + border-spacing: 0; + font-size: 11px; + + thead tr { + background: #f8fafc; + color: #475569; + font-size: 9px; + text-transform: uppercase; + letter-spacing: 0.3px; + + th { + padding: 8px 7px; + font-weight: 600; + position: sticky; + top: 0; + background: #f8fafc; + border-bottom: 1px solid #e2e8f0; + z-index: 2; + white-space: nowrap; + } + } + + tbody tr { + border-top: 1px solid #f1f5f9; + } + + td { + padding: 7px; + text-align: right; + cursor: text; + + &:first-child { + text-align: left; + } + } + } + + &__col-particulars { + position: sticky; + left: 0; + background: white; + border-right: 1px solid #e2e8f0; + font-weight: 500; + min-width: 240px; + text-align: left !important; + padding-left: 12px !important; + } + + &__row-alt &__col-particulars { + background: #fafbfc; + } + + &__row-alt { + background: #fafbfc; + } + + &__row--formula { + .er__col-particulars { + background: #eff6ff; + color: #1d4ed8; + font-weight: 600; + } + } + + &__srno { + color: #94a3b8; + margin-right: 6px; + } + + &__cell { + cursor: cell; + transition: background-color 0.1s; + + &:hover { + background: #f1f5f9; + outline: 1px solid #cbd5e1; + outline-offset: -1px; + } + + &--readonly { + cursor: default; + + &:hover { + background: transparent; + outline: none; + } + } + + &--na { + color: #cbd5e1; + } + + &--formula { + background: #eff6ff; + color: #1d4ed8; + font-weight: 600; + } + + &--editing { + padding: 0 !important; + background: #fef9c3 !important; + outline: 2px solid #1d4ed8 !important; + outline-offset: -2px; + } + } + + &__cell-input { + width: 100%; + height: 100%; + min-height: 28px; + border: none; + outline: none; + background: transparent; + padding: 7px; + font-size: 11px; + text-align: right; + font-family: inherit; + color: #0f172a; + box-sizing: border-box; + } + + &__cibil-strip { + background: white; + border: 1px solid #e2e8f0; + border-radius: 9px; + padding: 9px 14px; + display: flex; + align-items: center; + gap: 10px; + cursor: pointer; + transition: background-color 0.1s; + + &:hover { + background: #f8fafc; + } + } + + &__cibil-badge { + width: 24px; + height: 24px; + border-radius: 12px; + background: #dbeafe; + color: #1d4ed8; + display: flex; + align-items: center; + justify-content: center; + font-size: 11px; + font-weight: 700; + } + + &__cibil-text { + flex: 1; + } + + &__cibil-title { + font-size: 12px; + font-weight: 600; + } + + &__cibil-sub { + font-size: 10px; + color: #64748b; + margin-top: 1px; + } + + &__cibil-edit { + font-size: 11px; + font-weight: 600; + color: #1d4ed8; + cursor: pointer; + } + + &__footer { + background: white; + border: 1px solid #e2e8f0; + border-radius: 9px; + padding: 10px 14px; + display: flex; + align-items: center; + justify-content: space-between; + } + + &__footer-note { + font-size: 11px; + color: #64748b; + } + + &__footer-actions { + display: flex; + gap: 6px; + } + + &__submit { + background: #059669 !important; + color: white !important; + font-weight: 600; + + i { + margin-right: 4px; + } + } +} diff --git a/src/app/products/loan-interest-rate-register/edit-register/edit-register.component.ts b/src/app/products/loan-interest-rate-register/edit-register/edit-register.component.ts new file mode 100644 index 000000000..1900d114c --- /dev/null +++ b/src/app/products/loan-interest-rate-register/edit-register/edit-register.component.ts @@ -0,0 +1,363 @@ +/** + * Copyright since 2025 Mifos Initiative + * + * This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. + */ + +import { Component, ElementRef, OnDestroy, OnInit, ViewChild, inject } from '@angular/core'; +import { MatSnackBar } from '@angular/material/snack-bar'; +import { CommonModule } from '@angular/common'; +import { ActivatedRoute, Router, RouterLink } from '@angular/router'; +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 { MatIconModule } from '@angular/material/icon'; +import { MatDatepickerModule } from '@angular/material/datepicker'; +import { MatNativeDateModule } from '@angular/material/core'; +import { RateRegisterService } from '../rate-register.service'; +import { GlOption, RateCell, RateChartRow, RateRegister, SlabColumn } from '../models/rate-register.model'; +import { GlPickerComponent } from '../gl-picker/gl-picker.component'; + +/** + * Grid editor for one Loan Interest Rate Register — Excel-shaped: 34 products as rows and 10 + * amount slabs as columns. Cells can hold a plain rate, N.A., or a formula ("1% + FD"). All + * data today comes from {@link RateRegisterService}; wire-up to the real API comes later. + */ +@Component({ + selector: 'mifosx-edit-register', + templateUrl: './edit-register.component.html', + styleUrls: ['./edit-register.component.scss'], + imports: [ + CommonModule, + FormsModule, + RouterLink, + MatButtonModule, + MatFormFieldModule, + MatInputModule, + MatSelectModule, + MatIconModule, + MatDatepickerModule, + MatNativeDateModule, + GlPickerComponent + ] +}) +export class EditRegisterComponent implements OnInit, OnDestroy { + private readonly snack = inject(MatSnackBar); + + private readonly service = inject(RateRegisterService); + private readonly route = inject(ActivatedRoute); + private readonly router = inject(Router); + + register!: RateRegister; + active?: RateRegister; + slabs: SlabColumn[] = []; + rows: RateChartRow[] = []; + + /** The cell currently being edited (row/column indices into the grid), or null when nothing is open. */ + editing: { row: number; col: number } | null = null; + editValue = ''; + + @ViewChild('cellInput') cellInput?: ElementRef; + + /** Leaving the page keeps the draft: edits are saved so a reload or a trip to Compare does not lose them. */ + ngOnDestroy(): void { + if (this.canEdit) { + this.service.saveDrafts(); + } + } + + saveDraft(): void { + this.service.saveDrafts(); + this.snack.open('Draft saved', 'OK', { duration: 2000 }); + } + + /** Posting GL accounts for the particular → GL mapping. */ + glOptions: GlOption[] = []; + + /** "Add product" panel state. A new product needs a name and a GL before it can be added. */ + addingProduct = false; + newProductName = ''; + newProductGl: number | null = null; + addProductError = ''; + + /** Index of the row whose GL is being changed inline, or null. */ + glEditRow: number | null = null; + + ngOnInit(): void { + this.service.getGlOptions().subscribe((options) => (this.glOptions = options)); + const idParam = this.route.snapshot.queryParamMap.get('id'); + const id = idParam ? Number(idParam) : null; + this.service.list().subscribe((all) => { + const found = (id ? all.find((r) => r.id === id) : undefined) ?? this.service.getActiveOrFirstDraft(); + if (!found) { + // e.g. a reload on an unsaved local draft: nothing to edit, go back to the list. + this.router.navigate(['/products/loan-interest-rate-register']); + return; + } + this.register = found; + this.active = this.service.getActive(); + this.slabs = this.register.slabs; + this.rows = this.register.rows; + }); + } + + isActiveRegister(): boolean { + return !!this.active && this.active.id === this.register?.id; + } + + /** Only a Draft can be edited. Pending Approval / Active / Expired open read-only. */ + get canEdit(): boolean { + return this.register?.status === 'Draft'; + } + + /** Status label shown on the read-only banner. */ + readOnlyReason(): string { + switch (this.register?.status) { + case 'Pending Approval': + return 'This version has been submitted and is awaiting checker approval. No further edits allowed.'; + case 'Active': + return 'This is the current Active register. Click "Edit (new version)" on the list page to propose changes as a new Draft.'; + case 'Expired': + return 'This is an inactive historical version. Read-only.'; + default: + return 'Read-only.'; + } + } + + viewActive(): void { + if (this.active) { + this.router.navigate(['/products/loan-interest-rate-register/edit'], { queryParams: { id: this.active.id } }); + } + } + + /** Formatted display for a cell. */ + display(cell: RateCell): string { + if (cell === null) { + return 'N.A.'; + } + if (typeof cell === 'string') { + return cell; + } + return cell.toFixed(2); + } + + isFormulaRow(row: RateChartRow): boolean { + return !!row.isFormula; + } + + isNa(cell: RateCell): boolean { + return cell === null; + } + + /** Click on a cell → open it as an editable input, pre-filled with the current value. */ + startEdit(rowIdx: number, colIdx: number, current: RateCell): void { + if (!this.canEdit) { + return; + } + this.editing = { row: rowIdx, col: colIdx }; + if (current === null) { + this.editValue = ''; + } else if (typeof current === 'string') { + this.editValue = current; + } else { + this.editValue = current.toString(); + } + // Let the view render the input, then focus and select its contents. + setTimeout(() => { + const el = this.cellInput?.nativeElement; + if (el) { + el.focus(); + el.select(); + } + }, 0); + } + + isEditing(rowIdx: number, colIdx: number): boolean { + return !!this.editing && this.editing.row === rowIdx && this.editing.col === colIdx; + } + + /** + * Save the cell: + * - empty / "N.A." / "NA" → the slab is marked unavailable + * - value with letters, "%" or "+" → persisted as a formula string ("1% + FD") + * - plain number → stored as a number so it formats as a % + */ + saveEdit(rowIdx: number, colIdx: number): void { + if (!this.editing) { + return; + } + const raw = this.editValue.trim(); + let next: RateCell; + if (!raw || raw.toLowerCase() === 'n.a.' || raw.toLowerCase() === 'na') { + next = null; + } else if (/[a-zA-Z+%]/.test(raw)) { + next = raw; + } else { + const num = Number(raw); + next = Number.isFinite(num) ? num : this.rows[rowIdx].rates[colIdx]; + } + this.rows[rowIdx].rates[colIdx] = next; + this.rows[rowIdx].isFormula = this.rows[rowIdx].rates.some((c) => typeof c === 'string'); + this.editing = null; + } + + cancelEdit(): void { + this.editing = null; + } + + glLabel(row: RateChartRow): string | null { + return this.service.glLabel(row.glAccountId); + } + + unmappedCount(): number { + return this.rows.filter((r) => r.glAccountId == null).length; + } + + /** Submit is blocked until every product has a GL. */ + get canSubmit(): boolean { + return this.unmappedCount() === 0; + } + + /** Opens the Add product panel. */ + addProduct(): void { + if (!this.canEdit) { + return; + } + this.addingSlab = false; + this.addingProduct = true; + this.newProductName = ''; + this.newProductGl = null; + this.addProductError = ''; + } + + cancelAddProduct(): void { + this.addingProduct = false; + this.addProductError = ''; + } + + /** Append the new product row at the bottom of the grid. Name and GL are both mandatory. */ + confirmAddProduct(): void { + const name = this.newProductName.trim(); + if (!name) { + this.addProductError = 'Enter the product name.'; + return; + } + if (this.rows.some((r) => r.particulars.trim().toLowerCase() === name.toLowerCase())) { + this.addProductError = 'A product with this name already exists.'; + return; + } + if (this.newProductGl == null) { + this.addProductError = 'Select the GL account for this product.'; + return; + } + const next = this.rows.length > 0 ? Math.max(...this.rows.map((r) => r.srNo)) + 1 : 1; + this.rows.push({ + srNo: next, + particulars: name, + glAccountId: this.newProductGl, + rates: Array(this.slabs.length).fill(null), + isFormula: false + }); + this.cancelAddProduct(); + } + + /** Toggle the inline GL selector on an existing row (Draft only). */ + editGl(rowIdx: number): void { + if (this.canEdit) { + this.glEditRow = this.glEditRow === rowIdx ? null : rowIdx; + } + } + + /** "Add slab" panel state. A slab is an amount band; at least a label or an upper limit is needed. */ + addingSlab = false; + newSlabLabel = ''; + newSlabAmount: number | null = null; + addSlabError = ''; + + /** Opens the Add slab panel. */ + addSlab(): void { + if (!this.canEdit) { + return; + } + this.addingProduct = false; + this.addingSlab = true; + this.newSlabLabel = ''; + this.newSlabAmount = null; + this.addSlabError = ''; + } + + cancelAddSlab(): void { + this.addingSlab = false; + this.addSlabError = ''; + } + + /** Label that is filled in for a slab when only the upper limit is given, e.g. "Upto 50,00,000". */ + suggestedSlabLabel(): string { + const amount = this.newSlabAmount; + return amount != null && Number.isFinite(amount) && amount > 0 ? `Upto ${amount.toLocaleString('en-IN')}` : ''; + } + + /** Append the new amount slab as the last column; every existing row grows by one blank cell. */ + confirmAddSlab(): void { + const amount = this.newSlabAmount; + const hasAmount = amount != null && String(amount) !== ''; + if (hasAmount && (!Number.isFinite(amount) || (amount as number) <= 0)) { + this.addSlabError = 'The upper limit must be a number greater than 0.'; + return; + } + const label = this.newSlabLabel.trim() || this.suggestedSlabLabel(); + if (!label) { + this.addSlabError = 'Enter a label, or an upper limit to build one from.'; + return; + } + if (this.slabs.some((s) => s.label.trim().toLowerCase() === label.toLowerCase())) { + this.addSlabError = 'A slab with this label already exists.'; + return; + } + if (hasAmount && this.slabs.some((s) => s.amountUpto === amount)) { + this.addSlabError = 'A slab with this upper limit already exists.'; + return; + } + if (!hasAmount && this.slabs.some((s) => s.amountUpto === null)) { + this.addSlabError = 'An open-ended slab (no upper limit) already exists.'; + return; + } + this.slabs.push({ + key: `s_${Date.now()}`, + label, + amountUpto: hasAmount ? (amount as number) : null + }); + this.rows.forEach((r) => r.rates.push(null)); + this.cancelAddSlab(); + } + + editCibil(): void { + this.router.navigate(['/products/loan-interest-rate-register/cibil'], { queryParams: { id: this.register.id } }); + } + + backToList(): void { + this.router.navigate(['/products/loan-interest-rate-register']); + } + + /** Opens the Compare screen; the actual submit happens from there once the maker has reviewed the diff. */ + submitForApproval(): void { + this.router.navigate(['/products/loan-interest-rate-register/preview'], { queryParams: { id: this.register.id } }); + } + + statusChipClass(): string { + switch (this.register?.status) { + case 'Active': + return 'chip chip--active'; + case 'Draft': + return 'chip chip--draft'; + case 'Pending Approval': + return 'chip chip--pending'; + default: + return 'chip chip--expired'; + } + } +} diff --git a/src/app/products/loan-interest-rate-register/gl-picker/gl-picker.component.ts b/src/app/products/loan-interest-rate-register/gl-picker/gl-picker.component.ts new file mode 100644 index 000000000..c58959fb2 --- /dev/null +++ b/src/app/products/loan-interest-rate-register/gl-picker/gl-picker.component.ts @@ -0,0 +1,135 @@ +/** + * 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 { + AfterViewInit, + Component, + EventEmitter, + Input, + OnChanges, + OnDestroy, + OnInit, + Output, + SimpleChanges, + ViewChild +} from '@angular/core'; +import { CommonModule } from '@angular/common'; +import { ReactiveFormsModule, UntypedFormControl } from '@angular/forms'; +import { MatFormFieldModule } from '@angular/material/form-field'; +import { MatSelect, MatSelectModule } from '@angular/material/select'; +import { NgxMatSelectSearchModule } from 'ngx-mat-select-search'; +import { Subject, takeUntil } from 'rxjs'; +import { GlOption } from '../models/rate-register.model'; + +/** + * Compact searchable GL account dropdown. Typing in the search box filters by GL code or name. + * Emits the chosen GL id; with allowClear a "GL not mapped" entry emits null. + */ +@Component({ + selector: 'mifosx-gl-picker', + imports: [ + CommonModule, + ReactiveFormsModule, + MatFormFieldModule, + MatSelectModule, + NgxMatSelectSearchModule + ], + template: ` + + + + + + GL not mapped + {{ o.label }} + + + `, + styles: [ + ` + .gl-picker { + width: 100%; + min-width: 260px; + font-size: 12px; + } + ` + ] +}) +export class GlPickerComponent implements OnInit, OnChanges, AfterViewInit, OnDestroy { + @Input() options: GlOption[] = []; + @Input() value: number | null = null; + @Input() placeholder = 'Select GL account'; + @Input() allowClear = false; + /** Open the dropdown as soon as it is rendered (used for inline row editing). */ + @Input() openOnInit = false; + + @Output() valueChange = new EventEmitter(); + /** Fired when the dropdown panel closes, whether or not a value was picked. */ + @Output() closed = new EventEmitter(); + + @ViewChild(MatSelect) private select?: MatSelect; + + filterCtrl = new UntypedFormControl(''); + filtered: GlOption[] = []; + + private readonly destroy$ = new Subject(); + + ngOnInit(): void { + this.filterCtrl.valueChanges.pipe(takeUntil(this.destroy$)).subscribe(() => this.applyFilter()); + } + + ngOnChanges(changes: SimpleChanges): void { + if (changes['options'] || changes['value']) { + this.applyFilter(); + } + } + + ngAfterViewInit(): void { + if (this.openOnInit) { + setTimeout(() => this.select?.open()); + } + } + + ngOnDestroy(): void { + this.destroy$.next(); + this.destroy$.complete(); + } + + onOpenedChange(opened: boolean): void { + if (opened) { + this.filterCtrl.setValue(''); + } else { + this.closed.emit(); + } + } + + /** Filter by code or name; the currently selected option always stays in the list so its label shows. */ + private applyFilter(): void { + const q = ((this.filterCtrl.value as string) ?? '').trim().toLowerCase(); + const all = this.options ?? []; + if (!q) { + this.filtered = all.slice(); + return; + } + const matches = all.filter((o) => o.label.toLowerCase().includes(q)); + const selected = all.find((o) => o.id === this.value); + this.filtered = selected && !matches.includes(selected) ? [ + selected, + ...matches + ] : matches; + } +} diff --git a/src/app/products/loan-interest-rate-register/loan-interest-rate-register.component.html b/src/app/products/loan-interest-rate-register/loan-interest-rate-register.component.html new file mode 100644 index 000000000..cf9e44f33 --- /dev/null +++ b/src/app/products/loan-interest-rate-register/loan-interest-rate-register.component.html @@ -0,0 +1,183 @@ + + +
+
+
+
+

Loan Interest Rate Register

+

+ Manage the central CIBIL-based rate chart used across all loan products. Submitting a new version routes through + maker-checker — once approved, the current Active version is deactivated and the new one + activated. +

+
+ +
+ +
+
+
+
ACTIVE REGISTER
+
{{ active?.name ?? 'None' }}
+
Effective {{ activeWindow() }}
+
+
+
SLABS
+
10 amount slabs
+
₹10k → > ₹25L
+
+
+
CIBIL ADJUSTMENTS
+
{{ active?.cibilSlabs?.length ?? 0 }} slabs
+
+2.00% (poor) → base (above 700)
+
+
+ +
+
+
+
Loan Interest Rate Register — Version History
+
All versions, newest first · click a version to expand its rate chart
+
+
+ +
+
+
No rate register versions yet
+
+ Create the first version to publish rates for all loan products. It starts with the standard loan types (rates + blank) and 5 CIBIL bands — fill in the rates, add any bank-specific products, and submit for approval. +
+ +
+ + + + + + {{ r.name || '(Untitled draft)' }} + {{ statusLabel(r.status) }} + + + {{ effectiveRange(r) }} + + + + + Awaiting checker · action from Approvals tab + + + + + + + + + + + + + + + +
+
{{ r.note }}
+
+ Created by {{ r.createdBy }} · Last modified {{ r.lastModified | date: 'dd MMM yyyy' }} +
+ +

Rate chart ({{ r.rows.length }} products × {{ r.slabs.length }} slabs)

+
+ + + + + + + + + + + + + +
Particulars{{ s.label }}
+ {{ row.srNo }}{{ row.particulars }} + {{ + row.glAccountId == null ? 'GL not mapped' : glLabel(row.glAccountId) + }} + {{ display(cell) }}
+
+ +

CIBIL / credit-score slabs

+ + + + + + + + + + + + + + + + + + + + + +
#Score fromScore toRisk tierTypeAdjustment
{{ i + 1 }}{{ slab.from }}{{ slab.to }}{{ slab.tier }}{{ slab.type }}{{ formatPct(slab.adjustmentPct) }}
+
+
+
+ +
Showing {{ registers.length }} versions
+
+
+
diff --git a/src/app/products/loan-interest-rate-register/loan-interest-rate-register.component.scss b/src/app/products/loan-interest-rate-register/loan-interest-rate-register.component.scss new file mode 100644 index 000000000..13cad796d --- /dev/null +++ b/src/app/products/loan-interest-rate-register/loan-interest-rate-register.component.scss @@ -0,0 +1,524 @@ +/** + * 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/. + */ + +.lirr { + background: #f8fafc; + min-height: calc(100vh - 160px); + + &__header { + display: flex; + align-items: center; + gap: 14px; + padding: 22px 32px 14px; + background: white; + border-bottom: 1px solid #e2e8f0; + } + + &__header-icon { + width: 40px; + height: 40px; + border-radius: 9px; + background: #eff6ff; + color: #1d4ed8; + display: flex; + align-items: center; + justify-content: center; + font-size: 18px; + } + + &__header-text { + flex: 1; + + h2 { + margin: 0; + font-size: 20px; + font-weight: 600; + } + + p { + margin: 3px 0 0; + font-size: 12px; + color: #64748b; + } + } + + &__excel-btn, + &__create-btn { + i { + margin-right: 6px; + } + } + + &__body { + padding: 20px 32px; + } + + &__kpi-grid { + display: grid; + grid-template-columns: 1fr 1fr 1fr; + gap: 12px; + margin-bottom: 18px; + } + + &__kpi { + background: white; + border: 1px solid #e2e8f0; + border-radius: 10px; + padding: 14px 16px; + } + + &__kpi-label { + font-size: 11px; + color: #64748b; + font-weight: 600; + letter-spacing: 0.3px; + } + + &__kpi-value { + font-size: 18px; + font-weight: 600; + margin-top: 4px; + } + + &__kpi-sub { + font-size: 11px; + color: #64748b; + margin-top: 4px; + + &--ok { + color: #059669; + } + } + + &__table-card { + background: white; + border: 1px solid #e2e8f0; + border-radius: 10px; + overflow: hidden; + } + + &__table-head { + padding: 12px 20px; + border-bottom: 1px solid #f1f5f9; + display: flex; + align-items: center; + } + + &__table-title { + font-size: 14px; + font-weight: 600; + } + + &__table { + width: 100%; + border-collapse: collapse; + font-size: 13px; + + thead { + background: #f8fafc; + color: #64748b; + font-size: 11px; + text-transform: uppercase; + letter-spacing: 0.3px; + + th { + text-align: left; + padding: 10px 12px; + font-weight: 600; + + &:first-child { + padding-left: 20px; + } + } + } + + tbody tr { + border-top: 1px solid #f1f5f9; + cursor: pointer; + transition: background-color 0.1s; + + &:hover { + background: #f8fafc; + } + + td { + padding: 12px; + + &:first-child { + padding-left: 20px; + } + } + } + } + + &__row--active { + background: #f0fdf4; + } + + &__name { + font-weight: 600; + } + + &__name-sub { + font-size: 11px; + color: #64748b; + margin-top: 2px; + } + + &__row-actions { + text-align: right; + color: #64748b; + padding-right: 20px !important; + white-space: nowrap; + } + + &__row-btn { + height: 28px; + font-size: 11px; + font-weight: 600; + padding: 0 10px; + margin-left: 6px; + border-radius: 6px; + + &--neutral { + color: #334155; + border-color: #cbd5e1; + background: white; + + &:hover { + background: #f8fafc; + } + } + + &--approve { + color: #065f46; + border-color: #a7f3d0; + background: #ecfdf5; + + &:hover { + background: #d1fae5; + } + } + + &--reject { + color: #991b1b; + border-color: #fecaca; + background: #fef2f2; + + &:hover { + background: #fee2e2; + } + } + } + + &__row-note { + font-size: 11px; + color: #64748b; + font-style: italic; + margin-left: 10px; + } + + &__table-foot { + padding: 10px 20px; + border-top: 1px solid #f1f5f9; + font-size: 12px; + color: #64748b; + } + + /* ---- Version History accordion (CBS BIR style) ------------------------ */ + + &__history { + background: white; + border: 1px solid #e2e8f0; + border-radius: 12px; + overflow: hidden; + } + + &__history-head { + padding: 16px 20px; + border-bottom: 1px solid #f1f5f9; + display: flex; + align-items: center; + justify-content: space-between; + gap: 12px; + } + + &__history-title { + font-size: 15px; + font-weight: 600; + color: #0f172a; + } + + &__history-sub { + margin-top: 2px; + font-size: 12px; + color: #64748b; + } + + &__accordion { + display: block; + padding: 8px 12px 16px; + } + + &__panel { + border: 1px solid #e2e8f0 !important; + border-radius: 10px !important; + margin-bottom: 10px; + box-shadow: none !important; + overflow: hidden; + + &.mat-expanded { + border-color: #cbd5e1 !important; + } + + &--active { + border-left: 3px solid #10b981 !important; + } + } + + &__panel-header { + padding: 0 20px !important; + height: 56px; + } + + &__panel-title { + display: flex !important; + align-items: center !important; + gap: 12px !important; + flex: 0 0 auto !important; + margin-right: 24px !important; + } + + &__panel-name { + font-size: 14px; + font-weight: 600; + color: #0f172a; + } + + &__panel-desc { + display: flex !important; + align-items: center !important; + justify-content: space-between !important; + gap: 12px; + flex: 1 1 auto !important; + color: #475569 !important; + } + + &__panel-range { + font-size: 12px; + color: #475569; + font-variant-numeric: tabular-nums; + } + + &__panel-actions { + display: inline-flex; + gap: 6px; + align-items: center; + } + + &__panel-body { + padding: 8px 20px 20px; + font-size: 12px; + color: #334155; + display: flex; + flex-direction: column; + gap: 10px; + } + + &__panel-meta { + font-size: 12px; + color: #64748b; + } + + &__panel-section { + margin: 10px 0 4px; + font-size: 11px; + text-transform: uppercase; + letter-spacing: 0.5px; + color: #64748b; + font-weight: 700; + } + + &__panel-scroll { + overflow: auto; + max-height: 360px; + border: 1px solid #f1f5f9; + border-radius: 8px; + } + + &__panel-grid { + width: 100%; + border-collapse: collapse; + font-size: 12px; + + thead th { + position: sticky; + top: 0; + background: #f8fafc; + color: #64748b; + font-size: 10px; + text-transform: uppercase; + letter-spacing: 0.3px; + font-weight: 600; + padding: 8px 10px; + border-bottom: 1px solid #e2e8f0; + white-space: nowrap; + } + + tbody td { + padding: 7px 10px; + border-bottom: 1px solid #f1f5f9; + } + } + + &__panel-grid-first { + text-align: left; + max-width: 240px; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + font-weight: 500; + } + + &__panel-grid-num { + text-align: right; + font-variant-numeric: tabular-nums; + } + + &__panel-grid-srno { + display: inline-block; + min-width: 22px; + color: #94a3b8; + font-weight: 600; + margin-right: 6px; + } + + &__panel-cibil { + width: 100%; + border-collapse: collapse; + font-size: 12px; + border: 1px solid #f1f5f9; + border-radius: 8px; + overflow: hidden; + + thead th { + background: #f8fafc; + color: #64748b; + font-size: 10px; + text-transform: uppercase; + letter-spacing: 0.3px; + font-weight: 600; + padding: 8px 12px; + text-align: left; + border-bottom: 1px solid #e2e8f0; + } + + tbody td { + padding: 8px 12px; + border-bottom: 1px solid #f1f5f9; + } + } + + &__gl-tag { + display: inline-block; + margin-left: 8px; + font-size: 10px; + font-weight: 600; + color: #475569; + background: #f1f5f9; + border-radius: 999px; + padding: 1px 8px; + + &--unmapped { + color: #92400e; + background: #fef3c7; + } + } + + &__history-foot { + padding: 10px 20px; + border-top: 1px solid #f1f5f9; + font-size: 12px; + color: #64748b; + } + + &__empty { + display: flex; + flex-direction: column; + align-items: center; + gap: 12px; + padding: 56px 24px 48px; + text-align: center; + } + + &__empty-icon { + width: 56px; + height: 56px; + border-radius: 50%; + background: #ede9fe; + color: #6d28d9; + display: flex; + align-items: center; + justify-content: center; + font-size: 22px; + margin-bottom: 4px; + } + + &__empty-title { + font-size: 16px; + font-weight: 600; + color: #0f172a; + } + + &__empty-sub { + font-size: 13px; + color: #64748b; + max-width: 560px; + line-height: 1.5; + } + + &__empty-btn { + margin-top: 8px; + + i { + margin-right: 6px; + } + } +} + +.chip { + padding: 3px 10px; + border-radius: 10px; + font-size: 11px; + font-weight: 600; + + &--active { + background: #dcfce7; + color: #166534; + } + + &--draft { + background: #fef3c7; + color: #92400e; + } + + &--pending { + background: #ede9fe; + color: #5b21b6; + } + + &--expired { + background: #f1f5f9; + color: #475569; + } + + &--scheduled { + background: #e0e7ff; + color: #3730a3; + } +} diff --git a/src/app/products/loan-interest-rate-register/loan-interest-rate-register.component.ts b/src/app/products/loan-interest-rate-register/loan-interest-rate-register.component.ts new file mode 100644 index 000000000..2fc9f859b --- /dev/null +++ b/src/app/products/loan-interest-rate-register/loan-interest-rate-register.component.ts @@ -0,0 +1,197 @@ +/** + * 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 { Router } from '@angular/router'; +import { MatSnackBar } from '@angular/material/snack-bar'; +import { MatExpansionModule } from '@angular/material/expansion'; +import { STANDALONE_SHARED_IMPORTS } from 'app/standalone-shared.module'; +import { RateRegisterService } from './rate-register.service'; +import { CibilSlab, RateCell, RateChartRow, RateRegister, SlabColumn } from './models/rate-register.model'; + +/** + * List screen for the central Loan Interest Rate Register — three KPI cards followed by the + * registers table (name, effective window, status, author, last modified). + */ +@Component({ + selector: 'mifosx-loan-interest-rate-register', + templateUrl: './loan-interest-rate-register.component.html', + styleUrls: ['./loan-interest-rate-register.component.scss'], + imports: [ + ...STANDALONE_SHARED_IMPORTS, + DatePipe, + MatExpansionModule + ] +}) +export class LoanInterestRateRegisterComponent implements OnInit { + private readonly service = inject(RateRegisterService); + private readonly router = inject(Router); + private readonly snack = inject(MatSnackBar); + + registers: RateRegister[] = []; + active?: RateRegister; + + ngOnInit(): void { + // Load the GL list once so mapped particulars show a readable label instead of "GL #id". + this.service.getGlOptions().subscribe(); + this.reload(); + } + + glLabel(id: number | null | undefined): string | null { + return this.service.glLabel(id); + } + + private reload(): void { + this.service.list().subscribe((rows) => { + this.registers = rows; + this.active = rows.find((r) => r.status === 'Active'); + }); + } + + edit(register: RateRegister): void { + this.router.navigate(['/products/loan-interest-rate-register/edit'], { queryParams: { id: register.id } }); + } + + preview(register: RateRegister): void { + this.router.navigate(['/products/loan-interest-rate-register/preview'], { queryParams: { id: register.id } }); + } + + /** Route by status: Pending → Compare screen (checker view); otherwise the editor. */ + openRow(register: RateRegister): void { + if (register.status === 'Pending Approval') { + this.preview(register); + } else { + this.edit(register); + } + } + + /** Checker approves the pending register. The previously-Active one is deactivated. */ + approve(register: RateRegister, event: Event): void { + event.stopPropagation(); + this.service.approve(register.id).subscribe((updated) => { + if (updated) { + this.snack.open(`${updated.name} activated`, 'OK', { duration: 3000 }); + this.reload(); + } + }); + } + + /** Checker rejects the pending register — it goes back to Draft. */ + reject(register: RateRegister, event: Event): void { + event.stopPropagation(); + this.service.reject(register.id).subscribe((updated) => { + if (updated) { + this.snack.open(`${updated.name} sent back to Draft`, 'OK', { duration: 3000 }); + this.reload(); + } + }); + } + + statusChipClass(status: RateRegister['status']): string { + switch (status) { + case 'Active': + return 'chip chip--active'; + case 'Draft': + return 'chip chip--draft'; + case 'Pending Approval': + return 'chip chip--pending'; + case 'Expired': + return 'chip chip--expired'; + default: + return 'chip chip--scheduled'; + } + } + + statusLabel(status: RateRegister['status']): string { + switch (status) { + case 'Active': + return '● Active'; + case 'Pending Approval': + return '⏳ Pending Approval'; + default: + return status; + } + } + + activeWindow(): string { + if (!this.active) { + return '—'; + } + return `${this.formatShort(this.active.effectiveFrom)} → ${this.formatShort(this.active.effectiveTo)}`; + } + + /** Panel header subtitle — effective date range, with "Current" for the active one. */ + effectiveRange(r: RateRegister): string { + const from = this.formatShort(r.effectiveFrom); + const to = r.status === 'Active' ? 'Current' : this.formatShort(r.effectiveTo); + return `From ${from} To ${to}`; + } + + /** Compact display of a rate cell (for the read-only expanded view). */ + display(cell: RateCell): string { + if (cell === null) { + return 'N.A.'; + } + if (typeof cell === 'string') { + return cell; + } + return cell.toFixed(2); + } + + formatPct(pct: number): string { + if (pct === 0) { + return '0.00%'; + } + const sign = pct > 0 ? '+' : '−'; + return `${sign}${Math.abs(pct).toFixed(2)}%`; + } + + /** Open this register's full editor (grid) — stops the row click from toggling the panel. */ + openEditor(r: RateRegister, event: Event): void { + event.stopPropagation(); + this.edit(r); + } + + /** + * "Edit (new version)" on Active: duplicate the Active register as a new Draft first, then + * open the Draft in the editor. The live Active is never mutated this way. + */ + editAsNewVersion(r: RateRegister, event: Event): void { + event.stopPropagation(); + const draft = this.service.duplicateAsDraft(r.id); + if (draft) { + this.snack.open('New draft created from Active — editing the copy', 'OK', { duration: 2500 }); + this.reload(); + this.edit(draft); + } + } + + /** Scaffold the very first version — used only when the register has no versions at all. */ + createFirstVersion(): void { + this.service.createFirstVersion().subscribe((draft) => { + this.snack.open('Draft created — fill in rates and submit for approval', 'OK', { duration: 3000 }); + this.reload(); + this.edit(draft); + }); + } + + /** Open the Compare screen for a pending register (checker view). */ + openCompare(r: RateRegister, event: Event): void { + event.stopPropagation(); + this.preview(r); + } + + private formatShort(iso: string | null): string { + if (!iso) { + return '—'; + } + const d = new Date(iso); + return d.toLocaleDateString('en-GB', { day: '2-digit', month: 'short', year: '2-digit' }); + } +} diff --git a/src/app/products/loan-interest-rate-register/models/rate-register.model.ts b/src/app/products/loan-interest-rate-register/models/rate-register.model.ts new file mode 100644 index 000000000..8f25e359f --- /dev/null +++ b/src/app/products/loan-interest-rate-register/models/rate-register.model.ts @@ -0,0 +1,63 @@ +/** + * 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/. + */ + +export type RegisterStatus = 'Active' | 'Draft' | 'Pending Approval' | 'Expired' | 'Scheduled'; + +export interface SlabColumn { + key: string; + label: string; + amountUpto: number | null; +} + +/** A single rate cell: number for a %, string for a formula ("1% + FD"), null for N.A. */ +export type RateCell = number | string | null; + +export interface RateChartRow { + srNo: number; + particulars: string; + isFormula?: boolean; + /** GL account this particular is mapped to. null/undefined = "GL not mapped". */ + glAccountId?: number | null; + rates: RateCell[]; +} + +/** One selectable GL account for the particular-to-GL mapping. */ +export interface GlOption { + id: number; + label: string; +} + +export type RiskTier = 'V. Poor' | 'Poor' | 'Fair' | 'Good' | 'Excellent'; + +export interface CibilSlab { + from: number; + to: number; + tier: RiskTier; + type: 'Flat'; + adjustmentPct: number; +} + +export interface RateRegister { + id: number; + name: string; + effectiveFrom: string; + effectiveTo: string | null; + currency: string; + status: RegisterStatus; + createdBy: string; + lastModified: string; + note?: string; + slabs: SlabColumn[]; + rows: RateChartRow[]; + cibilSlabs: CibilSlab[]; + /** Immutable snapshot of the last-approved state. Compare screen diffs against this. */ + baselineRows: RateChartRow[]; + baselineCibilSlabs: CibilSlab[]; + fallbackNoCibilSlab: number; + fallbackBelowMin: 'use-min-slab' | 'block'; +} diff --git a/src/app/products/loan-interest-rate-register/preview-register/preview-register.component.html b/src/app/products/loan-interest-rate-register/preview-register/preview-register.component.html new file mode 100644 index 000000000..8f258ebdf --- /dev/null +++ b/src/app/products/loan-interest-rate-register/preview-register/preview-register.component.html @@ -0,0 +1,83 @@ + + +
+
+
⇄
+
+

Compare changes

+

Review the diff before submitting · each changed cell shows OLD → NEW with yellow highlight

+
+ +
+ +
+
+
Draft
+
{{ draft.name }}
+
+
+
Rate cells changed
+
{{ changedCellCount() }}
+
+
+
CIBIL slabs changed
+
{{ changedCibilCount() }}
+
+
+ + +
+
+
OLD · WILL BE DEACTIVATED
+
{{ active?.name ?? 'No active register' }}
+
+ Effective {{ active.effectiveFrom }} → {{ active.effectiveTo || '—' }} +
+
+
→
+
+
NEW · WILL BE ACTIVATED
+
{{ draft.name }}
+
Effective {{ draft.effectiveFrom }} → {{ draft.effectiveTo || '—' }}
+
+
+ + + + +
diff --git a/src/app/products/loan-interest-rate-register/preview-register/preview-register.component.scss b/src/app/products/loan-interest-rate-register/preview-register/preview-register.component.scss new file mode 100644 index 000000000..70d019be0 --- /dev/null +++ b/src/app/products/loan-interest-rate-register/preview-register/preview-register.component.scss @@ -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/. + */ + +.pr { + background: #f8fafc; + min-height: calc(100vh - 160px); + padding: 24px 32px; + display: flex; + flex-direction: column; + gap: 16px; + + &__header { + display: flex; + align-items: center; + gap: 14px; + padding: 20px 24px; + background: white; + border: 1px solid #e2e8f0; + border-radius: 12px; + } + + &__header-icon { + width: 42px; + height: 42px; + border-radius: 10px; + background: #ede9fe; + color: #6d28d9; + display: flex; + align-items: center; + justify-content: center; + font-size: 18px; + font-weight: 700; + } + + &__header-text { + flex: 1; + + h2 { + margin: 0; + font-size: 20px; + font-weight: 600; + color: #0f172a; + } + + p { + margin: 3px 0 0; + font-size: 12px; + color: #64748b; + } + } + + &__summary { + display: grid; + grid-template-columns: 2fr 1fr 1fr; + gap: 12px; + padding: 14px 20px; + background: white; + border: 1px solid #e2e8f0; + border-radius: 12px; + } + + &__summary-item { + display: flex; + flex-direction: column; + gap: 4px; + } + + &__summary-label { + font-size: 10px; + text-transform: uppercase; + letter-spacing: 0.4px; + color: #64748b; + font-weight: 600; + } + + &__summary-value { + font-size: 14px; + font-weight: 600; + color: #0f172a; + + &--count { + font-size: 20px; + font-weight: 700; + color: #6d28d9; + } + } + + // ---- OLD → NEW version strip ------------------------------------------ + + &__version-strip { + display: grid; + grid-template-columns: 1fr auto 1fr; + gap: 16px; + align-items: stretch; + padding: 14px 20px; + background: white; + border: 1px solid #e2e8f0; + border-radius: 12px; + } + + &__version { + display: flex; + flex-direction: column; + gap: 6px; + padding: 10px 14px; + border-radius: 8px; + + &--old { + background: #fef2f2; + border-left: 3px solid #dc2626; + } + + &--new { + background: #f0fdf4; + border-left: 3px solid #059669; + } + } + + &__version-pill { + display: inline-block; + width: fit-content; + font-size: 10px; + font-weight: 700; + letter-spacing: 0.5px; + padding: 3px 10px; + border-radius: 999px; + + &--old { + background: #fee2e2; + color: #991b1b; + } + + &--new { + background: #d1fae5; + color: #065f46; + } + } + + &__version-name { + font-size: 13px; + font-weight: 600; + color: #0f172a; + } + + &__version-sub { + font-size: 11px; + color: #64748b; + } + + &__version-arrow { + align-self: center; + font-size: 24px; + color: #94a3b8; + font-weight: 700; + } + + // ---- Footer ----------------------------------------------------------- + + &__footer { + display: flex; + align-items: center; + gap: 12px; + padding: 14px 20px; + background: white; + border: 1px solid #e2e8f0; + border-radius: 12px; + } + + &__footer-note { + flex: 1; + font-size: 12px; + color: #64748b; + } + + &__submit { + i { + margin-right: 6px; + } + } +} diff --git a/src/app/products/loan-interest-rate-register/preview-register/preview-register.component.ts b/src/app/products/loan-interest-rate-register/preview-register/preview-register.component.ts new file mode 100644 index 000000000..71e7c00b5 --- /dev/null +++ b/src/app/products/loan-interest-rate-register/preview-register/preview-register.component.ts @@ -0,0 +1,161 @@ +/** + * Copyright since 2025 Mifos Initiative + * + * This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. + */ + +import { Component, OnInit, inject } from '@angular/core'; +import { CommonModule } from '@angular/common'; +import { ActivatedRoute, Router } from '@angular/router'; +import { MatButtonModule } from '@angular/material/button'; +import { MatSnackBar } from '@angular/material/snack-bar'; +import { MatDialog } from '@angular/material/dialog'; +import { + SubmitDetailsData, + SubmitDetailsDialogComponent, + SubmitDetailsResult +} from '../submit-details-dialog/submit-details-dialog.component'; +import { RateRegisterService } from '../rate-register.service'; +import { CibilSlab, RateChartRow, RateRegister, SlabColumn } from '../models/rate-register.model'; +import { RateRegisterDiffComponent } from '../rate-register-diff/rate-register-diff.component'; +import { countChangedCells, countChangedCibil } from '../rate-register-diff.util'; + +/** + * Inline-diff Compare screen for a Loan Interest Rate Register draft. + * + * Shows a single consolidated matrix where every cell renders either its unchanged value or an + * "OLD → NEW" diff with highlight. A "Show only changed rows" toggle filters out rows with no + * changes so the reviewer can focus on what moved. + * + * Submit calls the mock backend; a `rollbackTransaction:true` response means the row has landed + * in the checker inbox and effective activation waits for approval. + */ +@Component({ + selector: 'mifosx-preview-register', + templateUrl: './preview-register.component.html', + styleUrls: ['./preview-register.component.scss'], + imports: [ + CommonModule, + MatButtonModule, + RateRegisterDiffComponent + ] +}) +export class PreviewRegisterComponent implements OnInit { + private readonly service = inject(RateRegisterService); + private readonly route = inject(ActivatedRoute); + private readonly router = inject(Router); + private readonly snack = inject(MatSnackBar); + private readonly dialog = inject(MatDialog); + + draft!: RateRegister; + active?: RateRegister; + + slabs: SlabColumn[] = []; + draftRows: RateChartRow[] = []; + baselineRows: RateChartRow[] = []; + draftCibil: CibilSlab[] = []; + baselineCibil: CibilSlab[] = []; + + submitting = false; + + /** Submit button is live only for a Draft. A Pending Approval or Active register is review-only here. */ + get canSubmit(): boolean { + return this.draft?.status === 'Draft'; + } + + /** Products still without a GL; the backend rejects the save while any remain. */ + unmappedCount(): number { + return this.draftRows.filter((r) => r.glAccountId == null).length; + } + + ngOnInit(): void { + const idParam = this.route.snapshot.queryParamMap.get('id'); + const id = idParam ? Number(idParam) : null; + this.service.list().subscribe((all) => { + const found = (id ? all.find((r) => r.id === id) : undefined) ?? this.service.getActiveOrFirstDraft(); + if (!found) { + this.router.navigate(['/products/loan-interest-rate-register']); + return; + } + this.draft = found; + this.active = this.service.getActive(); + this.slabs = this.draft.slabs; + this.draftRows = this.draft.rows; + this.draftCibil = this.draft.cibilSlabs; + // Compare always diffs against a frozen baseline — the currently-Active register's + // last-approved snapshot, OR the draft's own baseline when editing Active in place. + const base = this.draft.id === this.active?.id ? this.draft : (this.active ?? this.draft); + this.baselineRows = base.baselineRows; + this.baselineCibil = base.baselineCibilSlabs; + }); + } + + changedCellCount(): number { + return countChangedCells(this.draftRows, this.baselineRows, this.slabs.length); + } + + changedCibilCount(): number { + return countChangedCibil(this.draftCibil, this.baselineCibil); + } + + /** Opens the mock backend call. `rollbackTransaction:true` → row sits in checker inbox. */ + submit(): void { + if (this.submitting) { + return; + } + const ref = this.dialog.open( + SubmitDetailsDialogComponent, + { + width: '520px', + maxWidth: '95vw', + data: { + name: this.draft.name, + effectiveFrom: this.draft.effectiveFrom, + effectiveTo: this.draft.effectiveTo ?? '', + activeName: this.active && this.active.id !== this.draft.id ? this.active.name : undefined, + activeFrom: this.active && this.active.id !== this.draft.id ? this.active.effectiveFrom : undefined + } + } + ); + ref.afterClosed().subscribe((details) => { + if (!details) { + return; + } + this.draft.name = details.name; + this.draft.effectiveFrom = details.effectiveFrom; + this.draft.effectiveTo = details.effectiveTo || null; + this.service.saveDrafts(); + this.send(); + }); + } + + private send(): void { + this.submitting = true; + this.service.submitForApproval(this.draft.id).subscribe({ + next: (res) => { + this.submitting = false; + const msg = res.rollbackTransaction ? 'Submitted — awaiting checker approval' : 'Changes activated'; + this.snack.open(msg, 'OK', { duration: 3500 }); + this.router.navigate(['/products/loan-interest-rate-register']); + }, + error: (err) => { + // Belt-and-braces guard — the service already catches, but if anything slips through + // we still unlock the UI so the user can retry instead of staring at a spinner forever. + this.submitting = false; + console.error('[PreviewRegister] submit failed', err); + const reason = + err?.error?.errors?.[0]?.defaultUserMessage || + err?.error?.defaultUserMessage || + err?.error?.message || + 'check backend status and try again'; + this.snack.open(`Submit failed — ${reason}`, 'Dismiss', { duration: 8000 }); + } + }); + } + + backToEdit(): void { + this.router.navigate(['/products/loan-interest-rate-register/edit'], { queryParams: { id: this.draft.id } }); + } +} diff --git a/src/app/products/loan-interest-rate-register/rate-register-diff.util.ts b/src/app/products/loan-interest-rate-register/rate-register-diff.util.ts new file mode 100644 index 000000000..6a8ac8920 --- /dev/null +++ b/src/app/products/loan-interest-rate-register/rate-register-diff.util.ts @@ -0,0 +1,48 @@ +/** + * 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 { CibilSlab, RateCell, RateChartRow } from './models/rate-register.model'; + +/** Two chart cells are the same when both are N.A., both the same formula, or numerically equal. */ +export function sameCell(a: RateCell, b: RateCell): boolean { + if (a === null && b === null) { + return true; + } + if (typeof a === 'number' && typeof b === 'number') { + return Math.abs(a - b) < 1e-9; + } + return a === b; +} + +/** Number of {product, amount-slab} cells whose value differs between the draft and the baseline. */ +export function countChangedCells(draft: RateChartRow[], baseline: RateChartRow[], slabCount: number): number { + let n = 0; + for (const row of draft) { + const base = baseline.find((r) => r.srNo === row.srNo); + for (let j = 0; j < slabCount; j++) { + if (!sameCell(base?.rates[j] ?? null, row.rates[j] ?? null)) { + n++; + } + } + } + return n; +} + +/** Number of CIBIL bands that differ (by position) between the draft and the baseline. */ +export function countChangedCibil(draft: CibilSlab[], baseline: CibilSlab[]): number { + let n = 0; + const max = Math.max(draft.length, baseline.length); + for (let i = 0; i < max; i++) { + const a = baseline[i]; + const d = draft[i]; + if (!a || !d || a.from !== d.from || a.to !== d.to || a.tier !== d.tier || a.adjustmentPct !== d.adjustmentPct) { + n++; + } + } + return n; +} diff --git a/src/app/products/loan-interest-rate-register/rate-register-diff/rate-register-diff.component.html b/src/app/products/loan-interest-rate-register/rate-register-diff/rate-register-diff.component.html new file mode 100644 index 000000000..7e421085b --- /dev/null +++ b/src/app/products/loan-interest-rate-register/rate-register-diff/rate-register-diff.component.html @@ -0,0 +1,118 @@ + + + +
+
+
+
Rate chart — inline diff
+
Changed cells highlighted · unchanged cells show single value
+
+ + Show only changed rows + +
+ +
+ +
No changes detected
+
The proposed version is identical to the current Active register.
+
+ +
+ + + + + + + + + + + + + +
Particulars{{ s.label }}
+ {{ row.srNo }}{{ row.particulars }} +
+ {{ glText(row) }} +
+
+ + {{ display(baselineCell(row.srNo, j)) }} + → + {{ display(draftCell(row.srNo, j)) }} + + {{ display(draftCell(row.srNo, j)) }} +
+
+
+ + +
+
+
+
CIBIL slabs — inline diff
+
Changed fields shown as OLD → NEW · unchanged fields render once
+
+
+ + + + + + + + + + + + + + + + + + + +
#Score fromScore toTierAdjustment
{{ i + 1 }} + + {{ baselineCibil[i]?.from ?? '—' }} + → + {{ slab.from }} + + {{ slab.from }} + + + {{ baselineCibil[i]?.to ?? '—' }} + → + {{ slab.to }} + + {{ slab.to }} + + + {{ baselineCibil[i]?.tier ?? '—' }} + → + {{ slab.tier }} + + {{ slab.tier }} + + + {{ formatPct(baselineCibil[i]?.adjustmentPct ?? 0) }} + → + {{ formatPct(slab.adjustmentPct) }} + + {{ formatPct(slab.adjustmentPct) }} +
+
diff --git a/src/app/products/loan-interest-rate-register/rate-register-diff/rate-register-diff.component.scss b/src/app/products/loan-interest-rate-register/rate-register-diff/rate-register-diff.component.scss new file mode 100644 index 000000000..9cb68adaf --- /dev/null +++ b/src/app/products/loan-interest-rate-register/rate-register-diff/rate-register-diff.component.scss @@ -0,0 +1,206 @@ +/** + * 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: flex; + flex-direction: column; + gap: 16px; +} + +.rd { + &__card { + background: white; + border: 1px solid #e2e8f0; + border-radius: 12px; + overflow: hidden; + } + + &__card-head { + display: flex; + align-items: flex-start; + justify-content: space-between; + gap: 12px; + padding: 16px 20px; + border-bottom: 1px solid #f1f5f9; + } + + &__card-title { + font-size: 15px; + font-weight: 600; + color: #0f172a; + } + + &__card-sub { + margin-top: 2px; + font-size: 12px; + color: #64748b; + } + + &__toggle { + font-size: 12px; + font-weight: 500; + } + + // ---- Empty state (no changes) ----------------------------------------- + + &__empty { + display: flex; + flex-direction: column; + align-items: center; + gap: 8px; + padding: 48px 20px; + color: #64748b; + + i { + font-size: 28px; + color: #94a3b8; + } + } + + &__empty-title { + font-size: 15px; + font-weight: 600; + color: #0f172a; + } + + &__empty-sub { + font-size: 12px; + text-align: center; + max-width: 420px; + } + + // ---- Scrollable rate chart -------------------------------------------- + + &__scroll { + overflow: auto; + max-height: 520px; + } + + &__grid { + width: 100%; + border-collapse: collapse; + font-size: 12px; + + thead th { + position: sticky; + top: 0; + background: #f8fafc; + color: #64748b; + font-size: 10px; + text-transform: uppercase; + font-weight: 600; + letter-spacing: 0.3px; + padding: 10px; + text-align: right; + border-bottom: 1px solid #e2e8f0; + white-space: nowrap; + } + + thead th.rd__col-particulars { + text-align: left; + } + + tbody td { + padding: 8px 10px; + border-bottom: 1px solid #f1f5f9; + text-align: right; + font-variant-numeric: tabular-nums; + color: #0f172a; + white-space: nowrap; + } + + tbody td.rd__col-particulars { + text-align: left; + font-weight: 500; + max-width: 240px; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + } + } + + &__srno { + display: inline-block; + min-width: 24px; + color: #94a3b8; + font-weight: 600; + margin-right: 6px; + } + + &__gl-tag { + display: inline-block; + margin-left: 30px; + font-size: 10px; + font-weight: 600; + color: #475569; + background: #f1f5f9; + border-radius: 999px; + padding: 1px 8px; + + &--unmapped { + color: #92400e; + background: #fef3c7; + } + } + + // ---- Row & cell highlights -------------------------------------------- + + &__row--changed { + background: #fffbeb; + } + + &__cell-changed { + background: #fef3c7 !important; + font-weight: 700; + } + + &__old { + color: #991b1b; + font-weight: 500; + text-decoration: line-through; + text-decoration-color: #dc2626; + text-decoration-thickness: 1px; + } + + &__arrow { + color: #94a3b8; + margin: 0 4px; + font-weight: 700; + } + + &__new { + color: #065f46; + font-weight: 700; + } + + // ---- CIBIL inline-diff table ------------------------------------------ + + &__cibil { + width: 100%; + border-collapse: collapse; + font-size: 12px; + + thead th { + background: #f8fafc; + color: #64748b; + font-size: 10px; + text-transform: uppercase; + font-weight: 600; + letter-spacing: 0.3px; + padding: 10px 14px; + text-align: left; + border-bottom: 1px solid #e2e8f0; + } + + tbody td { + padding: 10px 14px; + border-bottom: 1px solid #f1f5f9; + color: #0f172a; + } + } +} diff --git a/src/app/products/loan-interest-rate-register/rate-register-diff/rate-register-diff.component.ts b/src/app/products/loan-interest-rate-register/rate-register-diff/rate-register-diff.component.ts new file mode 100644 index 000000000..9df948b0f --- /dev/null +++ b/src/app/products/loan-interest-rate-register/rate-register-diff/rate-register-diff.component.ts @@ -0,0 +1,121 @@ +/** + * 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, OnInit, inject } from '@angular/core'; +import { CommonModule } from '@angular/common'; +import { FormsModule } from '@angular/forms'; +import { MatSlideToggleModule } from '@angular/material/slide-toggle'; +import { RateRegisterService } from '../rate-register.service'; +import { CibilSlab, RateCell, RateChartRow, SlabColumn } from '../models/rate-register.model'; +import { countChangedCells, countChangedCibil, sameCell } from '../rate-register-diff.util'; + +/** + * Inline-diff view of a proposed Loan Interest Rate Register version against a baseline: one rate + * chart where every changed cell reads OLD -> NEW, plus the CIBIL bands. Shared by the Compare + * screen (before submitting) and the Approvals dialog (before approving), so both show the same thing. + */ +@Component({ + selector: 'mifosx-rate-register-diff', + templateUrl: './rate-register-diff.component.html', + styleUrls: ['./rate-register-diff.component.scss'], + imports: [ + CommonModule, + FormsModule, + MatSlideToggleModule + ] +}) +export class RateRegisterDiffComponent implements OnInit { + private readonly service = inject(RateRegisterService); + + @Input() slabs: SlabColumn[] = []; + @Input() draftRows: RateChartRow[] = []; + @Input() baselineRows: RateChartRow[] = []; + @Input() draftCibil: CibilSlab[] = []; + @Input() baselineCibil: CibilSlab[] = []; + + /** User toggle to collapse the chart to only the rows that changed. */ + showOnlyChanged = true; + + ngOnInit(): void { + // Loads the GL list so mapped particulars show a readable label; the cache is shared. + this.service.getGlOptions().subscribe(); + } + + glText(row: RateChartRow): string { + return row.glAccountId == null ? 'GL not mapped' : (this.service.glLabel(row.glAccountId) ?? 'GL not mapped'); + } + + display(cell: RateCell): string { + if (cell === null) { + return 'N.A.'; + } + if (typeof cell === 'string') { + return cell; + } + return cell.toFixed(2); + } + + /** The baseline (OLD) value of a cell; null when the row did not exist in the baseline. */ + baselineCell(srNo: number, colIdx: number): RateCell { + return this.baselineRows.find((r) => r.srNo === srNo)?.rates[colIdx] ?? null; + } + + draftCell(srNo: number, colIdx: number): RateCell { + return this.draftRows.find((r) => r.srNo === srNo)?.rates[colIdx] ?? null; + } + + cellChanged(srNo: number, colIdx: number): boolean { + return !sameCell(this.baselineCell(srNo, colIdx), this.draftCell(srNo, colIdx)); + } + + rowHasChanges(srNo: number): boolean { + for (let j = 0; j < this.slabs.length; j++) { + if (this.cellChanged(srNo, j)) { + return true; + } + } + return false; + } + + /** Rows to render: all of them, or only those with a change when the toggle is on. */ + get visibleRows(): RateChartRow[] { + if (!this.showOnlyChanged || this.changedCellCount() === 0) { + return this.draftRows; + } + return this.draftRows.filter((r) => this.rowHasChanges(r.srNo)); + } + + changedCellCount(): number { + return countChangedCells(this.draftRows, this.baselineRows, this.slabs.length); + } + + changedCibilCount(): number { + return countChangedCibil(this.draftCibil, this.baselineCibil); + } + + cibilChanged(index: number): boolean { + const a = this.baselineCibil[index]; + const d = this.draftCibil[index]; + return !a || !d || a.from !== d.from || a.to !== d.to || a.tier !== d.tier || a.adjustmentPct !== d.adjustmentPct; + } + + /** Per-field highlight inside a CIBIL row. */ + cibilFieldChanged(index: number, field: 'from' | 'to' | 'tier' | 'adjustmentPct'): boolean { + const a = this.baselineCibil[index]; + const d = this.draftCibil[index]; + return !a || !d || a[field] !== d[field]; + } + + formatPct(pct: number): string { + if (pct === 0) { + return '0.00%'; + } + const sign = pct > 0 ? '+' : '−'; + return `${sign}${Math.abs(pct).toFixed(2)}%`; + } +} diff --git a/src/app/products/loan-interest-rate-register/rate-register.service.ts b/src/app/products/loan-interest-rate-register/rate-register.service.ts new file mode 100644 index 000000000..16dfa332c --- /dev/null +++ b/src/app/products/loan-interest-rate-register/rate-register.service.ts @@ -0,0 +1,647 @@ +/** + * 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 { inject, Injectable } from '@angular/core'; +import { Observable, catchError, forkJoin, map, of, switchMap, tap } from 'rxjs'; +import { ProductsService } from '../products.service'; +import { + CibilSlab, + GlOption, + RateCell, + RateChartRow, + RateRegister, + RegisterStatus, + SlabColumn +} from './models/rate-register.model'; + +/** + * Central Loan Interest Rate Register service. + * + * Data comes from HTTP (/loanproducts/baseinterestrates); there is no built-in sample data. If the + * backend cannot be reached the list is empty apart from local Drafts (not-yet-submitted new + * versions), which live only in memory and are merged in front of server data on every list(). + */ +@Injectable({ providedIn: 'root' }) +export class RateRegisterService { + private readonly productsService = inject(ProductsService); + + /** True while the HTTP backend is reachable. Flipped to false on first HTTP failure (dev mode fallback). */ + private useHttp = true; + + /** Snapshot of the last list() result — getActive() / getActiveOrFirstDraft() read from this. */ + private cache: RateRegister[] = []; + + /** In-memory drafts the maker has created but not yet submitted. Preserved across list() refreshes. */ + private localDrafts: RateRegister[] = this.loadDrafts(); + + private static readonly DRAFTS_KEY = 'cbsLoanRateRegisterDrafts'; + + /** Drafts are kept in this browser's localStorage so a reload does not lose the maker's work. */ + private loadDrafts(): RateRegister[] { + try { + const raw = localStorage.getItem(RateRegisterService.DRAFTS_KEY); + const parsed = raw ? (JSON.parse(raw) as RateRegister[]) : []; + return Array.isArray(parsed) ? parsed.filter((d) => d?.status === 'Draft') : []; + } catch { + return []; + } + } + + /** Persist the current drafts (call after any edit the user should not lose). */ + saveDrafts(): void { + try { + localStorage.setItem( + RateRegisterService.DRAFTS_KEY, + JSON.stringify(this.localDrafts.filter((d) => d.status === 'Draft')) + ); + } catch { + /* storage unavailable (private mode / quota): the draft just stays in memory */ + } + } + + /** Base for the synthetic ids of pending server entries: -(base + commandId). Local drafts count down from -1. */ + private static readonly PENDING_ID_BASE = 1_000_000; + + /** One maker-checker entry (with its commandAsJson) as a read-only register, e.g. for the Approvals dialog. */ + registerFromPendingEntry(entry: any): RateRegister | null { + return this.pendingToRegisters([entry])[0] ?? null; + } + + /** A version as the backend returns it, as a UI register; used as the "old" side of a comparison. */ + registerFromServerDto(dto: any): RateRegister { + return this.fromBackend(dto, new Map()); + } + + /** Turn the maker-checker entries awaiting a checker into read-only "Pending Approval" registers. */ + private pendingToRegisters(entries: any): RateRegister[] { + const items: any[] = Array.isArray(entries) ? entries : (entries?.pageItems ?? []); + const out: RateRegister[] = []; + for (const e of items) { + if ((e?.entityName ?? '').toUpperCase() !== 'LOANBASEINTERESTRATE' || !e?.commandAsJson) { + continue; + } + let payload: any; + try { + payload = JSON.parse(e.commandAsJson); + } catch { + continue; + } + const glBySrNo = new Map(); + for (const p of payload.products ?? []) { + glBySrNo.set(p.srNo, p.glAccountId ?? null); + } + const madeOn = + typeof e.madeOnDate === 'number' + ? new Date(e.madeOnDate < 1e12 ? e.madeOnDate * 1000 : e.madeOnDate).toISOString().slice(0, 10) + : String(e.madeOnDate ?? '').slice(0, 10); + const reg = this.fromBackend( + { + id: -(RateRegisterService.PENDING_ID_BASE + e.id), + name: payload.name, + effectiveFromDate: payload.effectiveFromDate ?? madeOn, + effectiveToDate: null, + currencyCode: payload.currencyCode, + isActive: false, + fallbackNoCibilSlab: payload.fallbackNoCibilSlab, + fallbackBelowMin: payload.fallbackBelowMin, + note: payload.note, + createdDate: madeOn, + slabs: payload.slabs, + cibilSlabs: payload.cibilSlabs + }, + glBySrNo + ); + reg.status = 'Pending Approval'; + reg.createdBy = e.maker ?? 'Maker'; + reg.lastModified = madeOn; + // With no Active register to diff against, the Compare screen falls back to this blank baseline. + reg.baselineRows = reg.rows.map((r) => ({ + srNo: r.srNo, + particulars: r.particulars, + glAccountId: r.glAccountId ?? null, + rates: new Array(r.rates.length).fill(null) + })); + reg.baselineCibilSlabs = []; + out.push(reg); + } + return out; + } + + private readonly slabs: SlabColumn[] = [ + { key: 's1', label: 'Upto 10,000', amountUpto: 10000 }, + { key: 's2', label: 'Upto 25,000', amountUpto: 25000 }, + { key: 's3', label: 'Upto 1,00,000', amountUpto: 100000 }, + { key: 's4', label: 'Upto 2,00,000', amountUpto: 200000 }, + { key: 's5', label: 'Upto 5,00,000', amountUpto: 500000 }, + { key: 's6', label: 'Upto 10,00,000', amountUpto: 1000000 }, + { key: 's7', label: 'Upto 15,00,000', amountUpto: 1500000 }, + { key: 's8', label: 'Upto 20,00,000', amountUpto: 2000000 }, + { key: 's9', label: 'Upto 25,00,000', amountUpto: 2500000 }, + { key: 's10', label: '> 25,00,000', amountUpto: null } + ]; + + // Credit-score bands per the bank's approved register: + // above 700 = base rate (no change), below that stepped up by 0.5% per band. + // No discount band — the register never gives a negative adjustment. + private readonly cibilSlabs: CibilSlab[] = [ + { from: 0, to: 500, tier: 'V. Poor', type: 'Flat', adjustmentPct: 2.0 }, + { from: 501, to: 600, tier: 'Poor', type: 'Flat', adjustmentPct: 1.5 }, + { from: 601, to: 650, tier: 'Fair', type: 'Flat', adjustmentPct: 1.0 }, + { from: 651, to: 700, tier: 'Good', type: 'Flat', adjustmentPct: 0.5 }, + { from: 701, to: 900, tier: 'Excellent', type: 'Flat', adjustmentPct: 0 } + ]; + + /** + * The standard loan types every bank offers. Same list the backend seeds into + * m_loan_rate_register_product; used only if that endpoint cannot be reached when the first + * version is created. Any other particular is added by the bank itself. + */ + private readonly defaultParticulars: { srNo: number; name: string }[] = [ + { srNo: 1, name: 'Housing Loan (Purchase/Construction)' }, + { srNo: 2, name: 'Home Repair/Renovation' }, + { srNo: 3, name: 'Personal Loan (Unsecured)' }, + { srNo: 4, name: 'Personal Loan (Secured/Property)' }, + { srNo: 5, name: 'Two Wheeler Loan' }, + { srNo: 6, name: 'Car Loan (New)' }, + { srNo: 7, name: 'Used Car/Vehicle Loan' }, + { srNo: 8, name: 'Commercial Vehicle Loan' }, + { srNo: 9, name: 'Gold Loan' }, + { srNo: 10, name: 'Business Term Loan' }, + { srNo: 11, name: 'Machinery & Equipment Loan' }, + { srNo: 12, name: 'Cash Credit' }, + { srNo: 13, name: 'Overdraft' }, + { srNo: 14, name: 'Agriculture Loan' }, + { srNo: 15, name: 'Loan against FDR' }, + { srNo: 16, name: 'Loan against RD' } + ]; + + /** + * List all registers (local drafts + server versions). If the backend cannot be reached only the + * local drafts are returned: there is no built-in sample data. A successful but empty response is a + * genuinely empty register and shows the "Create first version" state. + */ + list(): Observable { + if (!this.useHttp) { + this.cache = [...this.localDrafts]; + return of(this.cache); + } + // Approved versions, the particulars master (each row's GL) and the versions still awaiting a + // checker. A failure of the master or the pending lookup must not hide the approved versions. + return forkJoin({ + versions: this.productsService.getLoanBaseInterestRates(), + products: this.productsService.getLoanRateRegisterProducts().pipe(catchError(() => of([]))), + pending: this.productsService.getPendingLoanRateRegisters().pipe( + // The list omits each entry's payload, so fetch the detail of every pending entry. + switchMap((entries: any) => { + const items: any[] = (Array.isArray(entries) ? entries : (entries?.pageItems ?? [])).filter( + (e: any) => (e?.entityName ?? '').toUpperCase() === 'LOANBASEINTERESTRATE' + ); + if (items.length === 0) { + return of([] as any[]); + } + return forkJoin( + items.map((e) => + this.productsService.getLoanRateRegisterAuditDetail(e.id).pipe( + map((detail: any) => ({ ...detail, ...e })), + catchError(() => of(null)) + ) + ) + ).pipe(map((all) => all.filter((x) => x !== null))); + }), + catchError(() => of([] as any[])) + ) + }).pipe( + map(({ versions, products, pending }) => { + const glBySrNo = new Map(); + for (const p of (products as any[]) ?? []) { + glBySrNo.set(p.srNo, p.glAccountId ?? null); + } + const approved = ((versions as any[]) ?? []).map((d) => this.fromBackend(d, glBySrNo)); + return [ + ...this.pendingToRegisters(pending), + ...approved + ]; + }), + catchError((err) => { + console.warn('[RateRegisterService] Backend unavailable —', err?.message ?? err); + this.useHttp = false; + return of([] as RateRegister[]); + }), + tap((effectiveVersions) => { + this.cache = [ + ...this.localDrafts, + ...effectiveVersions + ]; + }), + map(() => this.cache) + ); + } + + get(id: number): Observable { + const cached = this.cache.find((r) => r.id === id); + if (cached) { + return of(cached); + } + // Local draft lookup (negative ids never hit the server). + if (id < 0) { + return of(this.localDrafts.find((r) => r.id === id)); + } + if (!this.useHttp) { + return of(undefined); + } + return this.productsService.getLoanBaseInterestRateById(id).pipe( + map((d: any) => (d ? this.fromBackend(d, new Map()) : undefined)), + catchError(() => of(undefined)) + ); + } + + /** The one currently in force. Used by the Compare screen as the "left-hand" baseline. */ + getActive(): RateRegister | undefined { + return this.cache.find((r) => r.status === 'Active'); + } + + /** The one shown by default on list → edit navigation. */ + getActiveOrFirstDraft(): RateRegister | undefined { + return ( + this.cache.find((r) => r.status === 'Draft') ?? this.cache.find((r) => r.status === 'Active') ?? this.cache[0] + ); + } + + getSlabColumns(): SlabColumn[] { + return this.slabs; + } + + getCibilSlabs(): CibilSlab[] { + return this.cibilSlabs; + } + + // ---- maker-checker (real HTTP when backend is up, mock when not) ----------------- + + /** + * Submit a Draft for approval. HTTP path POSTs to /loanproducts/baseinterestrates — on a + * maker-checker-gated response ({@code rollbackTransaction=true}) the Draft stays visible as + * "Pending Approval" until the checker acts. On an un-gated response (direct commit) the Draft + * is removed and the register list is re-fetched so the new Active appears. + */ + submitForApproval(id: number): Observable<{ rollbackTransaction: boolean; commandId?: number }> { + const draft = this.localDrafts.find((x) => x.id === id) ?? this.cache.find((x) => x.id === id); + if (!draft) { + return of({ rollbackTransaction: false }); + } + + const payload = this.toBackend(draft); + return this.productsService.saveLoanBaseInterestRate(payload).pipe( + map((res: any) => { + const rollback = res?.rollbackTransaction === true; + const commandId = res?.commandId; + // Either way the server now owns this version (a pending maker-checker entry, or the new + // Active one), so the local draft goes. list() brings the server copy back on its next call. + this.localDrafts = this.localDrafts.filter((d) => d.id !== id); + this.cache = this.cache.filter((c) => c.id !== id); + this.saveDrafts(); + return { rollbackTransaction: rollback, commandId }; + }) + // No catchError: a rejected submit (e.g. a product without GL, backend down) must reach the + // caller so it can show the reason instead of pretending the request was sent. + ); + } + + /** + * Checker approves a Pending register — it becomes Active and the previously-Active one is + * deactivated. In real use the Approvals tab calls Fineract's /makercheckers/{id}?command=approve; + * this method remains as a local-only stub for the dev-fallback path. + */ + approve(id: number): Observable { + const pending = this.cache.find((x) => x.id === id); + if (!pending || pending.status !== 'Pending Approval') { + return of(undefined); + } + const today = new Date().toISOString().slice(0, 10); + const previouslyActive = this.cache.find((x) => x.status === 'Active'); + if (previouslyActive) { + previouslyActive.status = 'Expired'; + previouslyActive.effectiveTo = today; + previouslyActive.lastModified = today; + } + pending.status = 'Active'; + pending.effectiveFrom = today; + pending.lastModified = today; + pending.baselineRows = this.cloneRows(pending.rows); + pending.baselineCibilSlabs = this.cloneCibilSlabs(pending.cibilSlabs); + // Promote the local draft to the server-shaped view by moving it out of localDrafts. + this.localDrafts = this.localDrafts.filter((d) => d.id !== id); + return of(pending); + } + + /** + * Scaffold the first-ever Draft. Rows come from the backend's master list of particulars + * (/loanproducts/baseinterestrates/products) with every rate blank, so the bank fills in its own + * rates. If the backend is unreachable, the same standard loan types come from a built-in list. + * Carries a negative id like other local drafts. + */ + createFirstVersion(): Observable { + const fromTemplate = (): RateRegister => + this.scaffoldFirstDraft( + this.defaultParticulars.map( + (p): RateChartRow => ({ + srNo: p.srNo, + particulars: p.name, + glAccountId: null, + rates: new Array(this.slabs.length).fill(null) + }) + ), + 'First version — standard loan types, rates to be filled' + ); + + if (!this.useHttp) { + return of(fromTemplate()); + } + return this.productsService.getLoanRateRegisterProducts().pipe( + map((products: any[]) => { + const rows: RateChartRow[] = (products ?? []).map( + (p): RateChartRow => ({ + srNo: p.srNo, + particulars: p.name, + glAccountId: p.glAccountId ?? null, + rates: new Array(this.slabs.length).fill(null) + }) + ); + return rows.length > 0 + ? this.scaffoldFirstDraft(rows, 'First version — standard loan types, rates to be filled') + : fromTemplate(); + }), + catchError(() => of(fromTemplate())) + ); + } + + private scaffoldFirstDraft(rows: RateChartRow[], note: string): RateRegister { + const cibilTemplate = this.cibilSlabs; + const today = new Date().toISOString().slice(0, 10); + const draft: RateRegister = { + id: this.nextLocalDraftId(), + name: 'Interest Rate of Loans — new version', + effectiveFrom: today, + effectiveTo: null, + currency: 'INR', + status: 'Draft', + createdBy: 'Current User', + lastModified: today, + note, + slabs: this.slabs, + rows, + cibilSlabs: this.cloneCibilSlabs(cibilTemplate), + // Baseline is blank for a first version, so Compare shows every filled cell as new. + baselineRows: rows.map((r) => ({ + srNo: r.srNo, + particulars: r.particulars, + rates: new Array(r.rates.length).fill(null) + })), + baselineCibilSlabs: [], + fallbackNoCibilSlab: 2, + fallbackBelowMin: 'block' + }; + this.localDrafts.unshift(draft); + this.cache.unshift(draft); + this.saveDrafts(); + return draft; + } + + /** + * Duplicate the current Active (or any register) as a new local Draft so the user can propose + * changes without touching the live Active. Local Drafts carry a negative id to never collide + * with server-assigned ids. + */ + duplicateAsDraft(sourceId: number): RateRegister | undefined { + const source = this.cache.find((r) => r.id === sourceId); + if (!source) { + return undefined; + } + const newId = this.nextLocalDraftId(); + const today = new Date().toISOString().slice(0, 10); + const copy: RateRegister = { + id: newId, + // Left blank on purpose: the maker enters name and dates in the submit popup. + name: '', + effectiveFrom: '', + effectiveTo: null, + currency: source.currency, + status: 'Draft', + createdBy: 'Current User', + lastModified: today, + note: `Draft derived from ${source.name}`, + slabs: source.slabs, + rows: this.cloneRows(source.rows), + cibilSlabs: this.cloneCibilSlabs(source.cibilSlabs), + baselineRows: this.cloneRows(source.rows), + baselineCibilSlabs: this.cloneCibilSlabs(source.cibilSlabs), + fallbackNoCibilSlab: source.fallbackNoCibilSlab, + fallbackBelowMin: source.fallbackBelowMin + }; + this.localDrafts.unshift(copy); + this.cache.unshift(copy); + this.saveDrafts(); + return copy; + } + + /** Checker rejects the pending register — it goes back to Draft (local-only fallback). */ + reject(id: number): Observable { + const pending = this.cache.find((x) => x.id === id); + if (!pending || pending.status !== 'Pending Approval') { + return of(undefined); + } + pending.status = 'Draft'; + pending.lastModified = new Date().toISOString().slice(0, 10); + return of(pending); + } + + private nextLocalDraftId(): number { + const minId = this.localDrafts.length ? Math.min(...this.localDrafts.map((d) => d.id)) : 0; + return (minId <= 0 ? minId : 0) - 1; + } + + private cloneRows(src: RateChartRow[]): RateChartRow[] { + return src.map((r) => ({ + srNo: r.srNo, + particulars: r.particulars, + isFormula: r.isFormula, + glAccountId: r.glAccountId ?? null, + rates: [...r.rates] + })); + } + + private cloneCibilSlabs(src: CibilSlab[]): CibilSlab[] { + return src.map((s) => ({ ...s })); + } + + // ---- backend <-> UI mapping ----------------------------------------------- + // Backend sends/receives slabs as a flat list of {productSrNo, slabOrder, cell} rows; the UI + // works with a matrix: SlabColumn[] + RateChartRow[] (34 × 10). The two mappers below bridge + // that gap. + + /** Map a backend {@code LoanRateRegisterData} payload into a UI RateRegister. */ + private fromBackend(d: any, glBySrNo: Map): RateRegister { + // Rebuild the slab columns from unique slabOrders. + const slabByOrder = new Map(); + for (const cell of d.slabs ?? []) { + if (!slabByOrder.has(cell.slabOrder)) { + slabByOrder.set(cell.slabOrder, { + key: `s${cell.slabOrder + 1}`, + label: cell.slabLabel, + amountUpto: cell.amountUpto ?? null + }); + } + } + const slabs = [...slabByOrder.entries()] + .sort((a, b) => a[0] - b[0]) + .map( + ([ + , + s + ]) => s + ); + + // Group cells by product to build rows. + const rowByProduct = new Map(); + for (const cell of d.slabs ?? []) { + let row = rowByProduct.get(cell.productSrNo); + if (!row) { + row = { + srNo: cell.productSrNo, + particulars: cell.productName, + glAccountId: glBySrNo.get(cell.productSrNo) ?? null, + rates: new Array(slabs.length).fill(null) + }; + rowByProduct.set(cell.productSrNo, row); + } + let value: RateCell; + if (cell.isNa) { + value = null; + } else if (cell.isFormula && cell.rateFormula) { + value = cell.rateFormula; + row.isFormula = true; + } else { + value = cell.rateValue ?? null; + } + row.rates[cell.slabOrder] = value; + } + const rows = [...rowByProduct.values()].sort((a, b) => a.srNo - b.srNo); + + const cibilSlabs: CibilSlab[] = (d.cibilSlabs ?? []) + .slice() + .sort((a: any, b: any) => a.displayOrder - b.displayOrder) + .map((c: any) => ({ + from: c.scoreFrom, + to: c.scoreTo, + tier: c.tier, + type: c.slabType ?? 'Flat', + adjustmentPct: Number(c.adjustmentPct) + })); + + const status: RegisterStatus = d.isActive ? 'Active' : 'Expired'; + return { + id: d.id, + name: d.name, + effectiveFrom: d.effectiveFromDate, + effectiveTo: d.effectiveToDate ?? null, + currency: d.currencyCode ?? 'INR', + status, + createdBy: d.createdById != null ? `User #${d.createdById}` : 'System', + lastModified: (d.createdDate ?? d.effectiveFromDate ?? '').toString().slice(0, 10), + note: d.note, + slabs, + rows, + cibilSlabs, + baselineRows: this.cloneRows(rows), + baselineCibilSlabs: this.cloneCibilSlabs(cibilSlabs), + fallbackNoCibilSlab: d.fallbackNoCibilSlab ?? 2, + fallbackBelowMin: (d.fallbackBelowMin as 'use-min-slab' | 'block') ?? 'block' + }; + } + + /** Serialise a UI RateRegister into the JSON shape POST /loanproducts/baseinterestrates expects. */ + private toBackend(r: RateRegister): any { + const slabs: any[] = []; + for (const row of r.rows) { + for (let j = 0; j < r.slabs.length; j++) { + const cell = row.rates[j]; + const slabCol = r.slabs[j]; + const isNa = cell === null; + const isFormula = typeof cell === 'string'; + slabs.push({ + productSrNo: row.srNo, + productName: row.particulars, + slabOrder: j, + slabLabel: slabCol.label, + amountUpto: slabCol.amountUpto, + rateValue: isNa || isFormula ? null : cell, + rateFormula: isFormula ? cell : null, + isFormula, + isNa + }); + } + } + const cibilSlabs = r.cibilSlabs.map((c, i) => ({ + scoreFrom: c.from, + scoreTo: c.to, + tier: c.tier, + slabType: c.type, + adjustmentPct: c.adjustmentPct, + displayOrder: i + })); + return { + name: r.name, + effectiveFromDate: r.effectiveFrom, + currencyCode: r.currency, + fallbackNoCibilSlab: r.fallbackNoCibilSlab, + fallbackBelowMin: r.fallbackBelowMin, + note: r.note, + products: r.rows.map((row) => ({ + srNo: row.srNo, + name: row.particulars, + glAccountId: row.glAccountId ?? null + })), + slabs, + cibilSlabs + }; + } + + // ---- GL accounts for the particular → GL mapping ------------------------------ + + private glOptionsCache: GlOption[] | null = null; + + /** Posting GL accounts the user can map a particular to. Cached after the first successful load. */ + getGlOptions(): Observable { + if (this.glOptionsCache) { + return of(this.glOptionsCache); + } + return this.productsService.getLoanRegisterGlAccounts().pipe( + map((accounts: any[]) => + (accounts ?? []) + // The server filters on type=1 (ASSET); this keeps asset-only even if that param is ignored. + .filter((a) => a.type == null || a.type.value === 'ASSET' || a.type.id === 1) + .sort((a, b) => String(a.glCode ?? '').localeCompare(String(b.glCode ?? ''))) + .map((a) => ({ + id: a.id, + label: a.glCode ? `${a.glCode} - ${a.name}` : a.name + })) + ), + tap((options) => { + this.glOptionsCache = options; + }), + catchError(() => of([] as GlOption[])) + ); + } + + /** Display label for a mapped GL id, or null when unmapped or the account list is not loaded. */ + glLabel(id: number | null | undefined): string | null { + if (id == null) { + return null; + } + return this.glOptionsCache?.find((o) => o.id === id)?.label ?? `GL #${id}`; + } +} diff --git a/src/app/products/loan-interest-rate-register/submit-details-dialog/submit-details-dialog.component.ts b/src/app/products/loan-interest-rate-register/submit-details-dialog/submit-details-dialog.component.ts new file mode 100644 index 000000000..e815d80f3 --- /dev/null +++ b/src/app/products/loan-interest-rate-register/submit-details-dialog/submit-details-dialog.component.ts @@ -0,0 +1,139 @@ +/** + * Copyright since 2025 Mifos Initiative + * + * This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. + */ + +import { Component, inject } from '@angular/core'; +import { CommonModule } from '@angular/common'; +import { FormsModule } from '@angular/forms'; +import { MAT_DIALOG_DATA, MatDialogModule, MatDialogRef } from '@angular/material/dialog'; +import { MatButtonModule } from '@angular/material/button'; + +export interface SubmitDetailsData { + name: string; + effectiveFrom: string; + effectiveTo: string; + /** Active version this one replaces, shown as context. */ + activeName?: string; + activeFrom?: string; +} + +export interface SubmitDetailsResult { + name: string; + effectiveFrom: string; + effectiveTo: string; +} + +/** Asks the maker for the version details (name, effective from / to) just before sending for approval. */ +@Component({ + selector: 'mifosx-submit-details-dialog', + imports: [ + CommonModule, + FormsModule, + MatDialogModule, + MatButtonModule + ], + template: ` +

Version details

+ +

+ Confirm these details before sending the rate register for approval. + It will replace {{ data.activeName }} (effective {{ data.activeFrom }}) once approved. +

+
+ + +
+
+
+ + +
+
+ + +
+
+
{{ error }}
+
+ + + + + `, + styles: [ + ` + .sd { + min-width: 460px; + display: flex; + flex-direction: column; + gap: 12px; + } + .sd__hint { + margin: 0; + font-size: 12px; + color: #64748b; + } + .sd__row { + display: flex; + gap: 12px; + } + .sd__field { + display: flex; + flex-direction: column; + flex: 1; + } + .sd__field label { + font-size: 10px; + font-weight: 600; + color: #64748b; + letter-spacing: 0.3px; + margin-bottom: 3px; + } + .sd__field input { + height: 34px; + border: 1px solid #cbd5e1; + border-radius: 6px; + padding: 0 10px; + font-size: 13px; + font-family: inherit; + } + .sd__error { + font-size: 12px; + color: #b91c1c; + } + ` + ] +}) +export class SubmitDetailsDialogComponent { + readonly ref = inject>(MatDialogRef); + readonly data = inject(MAT_DIALOG_DATA); + + name = this.data.name; + effectiveFrom = this.data.effectiveFrom; + effectiveTo = this.data.effectiveTo; + error = ''; + + confirm(): void { + const name = this.name.trim(); + if (!name) { + this.error = 'Enter the register name.'; + return; + } + if (!this.effectiveFrom) { + this.error = 'Select the effective from date.'; + return; + } + if (this.effectiveTo && this.effectiveTo < this.effectiveFrom) { + this.error = 'Effective to cannot be before effective from.'; + return; + } + this.ref.close({ name, effectiveFrom: this.effectiveFrom, effectiveTo: this.effectiveTo }); + } +} diff --git a/src/app/products/loan-products/create-loan-product/create-loan-product.component.html b/src/app/products/loan-products/create-loan-product/create-loan-product.component.html index 260e066d8..d900c8414 100644 --- a/src/app/products/loan-products/create-loan-product/create-loan-product.component.html +++ b/src/app/products/loan-products/create-loan-product/create-loan-product.component.html @@ -42,19 +42,168 @@ - - - - {{ 'labels.inputs.SETTINGS' | translate }} - -
- -
-
+ @if (isAdvanceProduct) { + + + + SECURITY & LIEN + +
+ +
+
+ + + + + LIMIT & TENURE + +
+ +
+
+ + + + + INTEREST + +
+ +
+
+ + + + + PENAL & NPA + +
+ +
+
+ + + + + RENEWAL & CLOSURE + +
+ +
+
+ + @if (isOverdraftFacility) { + + + + CHEQUE BOOK + +
+ +
+
+ } + } + + @if (isLoanAccount) { + + + + SECURITY & COLLATERAL + +
+ +
+
+ + + + + AMOUNT & TENURE + +
+ +
+
+ + + + + REPAYMENT & EMI + +
+ +
+
+ + + + + INTEREST + +
+ +
+
+ + + + + PENAL & NPA + +
+ +
+
+ + + + + PREPAYMENT & CLOSURE + +
+ +
+
+ } + + @@ -109,11 +263,12 @@ + diff --git a/src/app/products/loan-products/create-loan-product/create-loan-product.component.ts b/src/app/products/loan-products/create-loan-product/create-loan-product.component.ts index 93785c36a..005e57c45 100644 --- a/src/app/products/loan-products/create-loan-product/create-loan-product.component.ts +++ b/src/app/products/loan-products/create-loan-product/create-loan-product.component.ts @@ -13,11 +13,22 @@ import { ActivatedRoute, Router } from '@angular/router'; /** Custom Components */ import { LoanProductDetailsStepComponent } from '../loan-product-stepper/loan-product-details-step/loan-product-details-step.component'; import { LoanProductCurrencyStepComponent } from '../loan-product-stepper/loan-product-currency-step/loan-product-currency-step.component'; +import { LoanProductSecurityLienStepComponent } from '../loan-product-stepper/loan-product-security-lien-step/loan-product-security-lien-step.component'; +import { LoanProductAdvanceLimitTenureStepComponent } from '../loan-product-stepper/loan-product-advance-limit-tenure-step/loan-product-advance-limit-tenure-step.component'; +import { LoanProductAdvanceInterestStepComponent } from '../loan-product-stepper/loan-product-advance-interest-step/loan-product-advance-interest-step.component'; +import { LoanProductPenalNpaStepComponent } from '../loan-product-stepper/loan-product-penal-npa-step/loan-product-penal-npa-step.component'; +import { LoanProductRenewalClosureStepComponent } from '../loan-product-stepper/loan-product-renewal-closure-step/loan-product-renewal-closure-step.component'; +import { LoanProductChequeBookStepComponent } from '../loan-product-stepper/loan-product-cheque-book-step/loan-product-cheque-book-step.component'; import { LoanProductTermsStepComponent } from '../loan-product-stepper/loan-product-terms-step/loan-product-terms-step.component'; import { LoanProductSettingsStepComponent } from '../loan-product-stepper/loan-product-settings-step/loan-product-settings-step.component'; import { LoanProductChargesStepComponent } from '../loan-product-stepper/loan-product-charges-step/loan-product-charges-step.component'; import { LoanProductAccountingStepComponent } from '../loan-product-stepper/loan-product-accounting-step/loan-product-accounting-step.component'; import { LoanProductInterestRefundStepComponent } from '../loan-product-stepper/loan-product-interest-refund-step/loan-product-interest-refund-step.component'; +import { LoanProductLoanSecurityStepComponent } from '../loan-product-stepper/loan-product-loan-security-step/loan-product-loan-security-step.component'; +import { LoanProductLoanAmountTenureStepComponent } from '../loan-product-stepper/loan-product-loan-amount-tenure-step/loan-product-loan-amount-tenure-step.component'; +import { LoanProductLoanRepaymentStepComponent } from '../loan-product-stepper/loan-product-loan-repayment-step/loan-product-loan-repayment-step.component'; +import { LoanProductLoanInterestStepComponent } from '../loan-product-stepper/loan-product-loan-interest-step/loan-product-loan-interest-step.component'; +import { LoanProductLoanPrepaymentStepComponent } from '../loan-product-stepper/loan-product-loan-prepayment-step/loan-product-loan-prepayment-step.component'; /** Custom Services */ import { ProductsService } from 'app/products/products.service'; @@ -34,7 +45,7 @@ import { import { Accounting } from 'app/core/utils/accounting'; import { StringEnumOptionData } from '../../../shared/models/option-data.model'; import { LoanProductDeferredIncomeRecognitionStepComponent } from '../loan-product-stepper/loan-product-capitalized-income-step/loan-product-deferred-income-recognition-step.component'; -import { UntypedFormGroup } from '@angular/forms'; +import { UntypedFormControl, UntypedFormGroup } from '@angular/forms'; import { FaIconComponent } from '@fortawesome/angular-fontawesome'; import { LoanProductPaymentStrategyStepComponent } from '../loan-product-stepper/loan-product-payment-strategy-step/loan-product-payment-strategy-step.component'; import { LoanProductPreviewStepComponent } from '../loan-product-stepper/loan-product-preview-step/loan-product-preview-step.component'; @@ -51,12 +62,24 @@ import { STANDALONE_SHARED_IMPORTS } from 'app/standalone-shared.module'; FaIconComponent, LoanProductDetailsStepComponent, LoanProductCurrencyStepComponent, - LoanProductSettingsStepComponent, - LoanProductInterestRefundStepComponent, - LoanProductPaymentStrategyStepComponent, - LoanProductTermsStepComponent, + LoanProductSecurityLienStepComponent, + LoanProductAdvanceLimitTenureStepComponent, + LoanProductAdvanceInterestStepComponent, + LoanProductPenalNpaStepComponent, + LoanProductRenewalClosureStepComponent, + LoanProductChequeBookStepComponent, + LoanProductLoanSecurityStepComponent, + LoanProductLoanAmountTenureStepComponent, + LoanProductLoanRepaymentStepComponent, + LoanProductLoanInterestStepComponent, + LoanProductLoanPrepaymentStepComponent, + // Old Loans A/C path — commented out for now (its tabs are commented out in the template): + // LoanProductSettingsStepComponent, + // LoanProductInterestRefundStepComponent, + // LoanProductPaymentStrategyStepComponent, + // LoanProductTermsStepComponent, + // LoanProductDeferredIncomeRecognitionStepComponent, LoanProductChargesStepComponent, - LoanProductDeferredIncomeRecognitionStepComponent, LoanProductAccountingStepComponent, LoanProductPreviewStepComponent ] @@ -73,12 +96,39 @@ export class CreateLoanProductComponent implements OnInit { @ViewChild(LoanProductDetailsStepComponent, { static: true }) loanProductDetailsStep: LoanProductDetailsStepComponent; @ViewChild(LoanProductCurrencyStepComponent, { static: true }) loanProductCurrencyStep: LoanProductCurrencyStepComponent; + // Conditional tabs (Advances A/C only): static false — resolved after the @if renders them. + @ViewChild(LoanProductSecurityLienStepComponent) + loanProductSecurityLienStep: LoanProductSecurityLienStepComponent; + @ViewChild(LoanProductAdvanceLimitTenureStepComponent) + loanProductAdvanceLimitTenureStep: LoanProductAdvanceLimitTenureStepComponent; + @ViewChild(LoanProductAdvanceInterestStepComponent) + loanProductAdvanceInterestStep: LoanProductAdvanceInterestStepComponent; + @ViewChild(LoanProductPenalNpaStepComponent) loanProductPenalNpaStep: LoanProductPenalNpaStepComponent; + @ViewChild(LoanProductRenewalClosureStepComponent) + loanProductRenewalClosureStep: LoanProductRenewalClosureStepComponent; + @ViewChild(LoanProductChequeBookStepComponent) loanProductChequeBookStep: LoanProductChequeBookStepComponent; + + // Conditional tabs (Loans A/C only): resolved after the @if renders them. + @ViewChild(LoanProductLoanSecurityStepComponent) + loanProductLoanSecurityStep: LoanProductLoanSecurityStepComponent; + @ViewChild(LoanProductLoanAmountTenureStepComponent) + loanProductLoanAmountTenureStep: LoanProductLoanAmountTenureStepComponent; + @ViewChild(LoanProductLoanRepaymentStepComponent) + loanProductLoanRepaymentStep: LoanProductLoanRepaymentStepComponent; + @ViewChild(LoanProductLoanInterestStepComponent) + loanProductLoanInterestStep: LoanProductLoanInterestStepComponent; + @ViewChild(LoanProductLoanPrepaymentStepComponent) + loanProductLoanPrepaymentStep: LoanProductLoanPrepaymentStepComponent; + + /** Charges step needs a real control; Settings is absent on the Advances path, so fall back to this. */ + readonly multiDisburseFallback = new UntypedFormControl(false); @ViewChild(LoanProductInterestRefundStepComponent, { static: true }) loanProductInterestRefundStep: LoanProductInterestRefundStepComponent; @ViewChild(LoanProductDeferredIncomeRecognitionStepComponent, { static: true }) loanProductDeferredIncomeRecognitionStep: LoanProductDeferredIncomeRecognitionStepComponent; - @ViewChild(LoanProductTermsStepComponent, { static: true }) loanProductTermsStep: LoanProductTermsStepComponent; - @ViewChild(LoanProductSettingsStepComponent, { static: true }) + // Terms & Settings tabs are now conditional (Loans path only), so resolve them non-statically. + @ViewChild(LoanProductTermsStepComponent) loanProductTermsStep: LoanProductTermsStepComponent; + @ViewChild(LoanProductSettingsStepComponent) loanProductSettingsStep: LoanProductSettingsStepComponent; @ViewChild(LoanProductChargesStepComponent, { static: true }) loanProductChargesStep: LoanProductChargesStepComponent; @ViewChild(LoanProductAccountingStepComponent, { static: true }) @@ -143,7 +193,23 @@ export class CreateLoanProductComponent implements OnInit { } get loanProductTermsForm() { - return this.loanProductTermsStep.loanProductTermsForm; + // Absent on the Advances path (tab hidden), so guard for callers. + return this.loanProductTermsStep?.loanProductTermsForm; + } + + /** True once the Advance Limit & Tenure step reports an Overdraft (running) facility. */ + get isOverdraftFacility(): boolean { + return this.isAdvanceProduct && !!this.loanProductAdvanceLimitTenureStep?.isOverdraft; + } + + /** The GL account chosen on the Details step; the rate register row can be matched by it. */ + get productGlAccount(): number | string | null { + return this.loanProductDetailsStep?.loanProductDetailsForm?.get('glAccount')?.value ?? null; + } + + /** Sub type chosen on the Details step; drives the FD 12–15 month tenure policy downstream. */ + get advanceSubType(): string { + return this.loanProductDetailsStep?.loanProductDetailsForm?.get('productTypeSubGroup')?.value ?? ''; } advancePaymentStrategy(value: string) { @@ -231,47 +297,103 @@ export class CreateLoanProductComponent implements OnInit { } get loanProductSettingsForm() { - return this.loanProductSettingsStep.loanProductSettingsForm; + // Absent on the Advances path (tab hidden), so guard for callers. + return this.loanProductSettingsStep?.loanProductSettingsForm; } get loanProductAccountingForm() { return this.loanProductAccountingStep.loanProductAccountingForm; } + /** The Details step's chosen product type; drives the Advances-only tabs. */ + get isAdvanceProduct(): boolean { + return this.loanProductDetailsForm?.get('productTypeGroup')?.value === 'advances'; + } + + /** The Details step's chosen product type; drives the Loans A/C (EMI) tabs. */ + get isLoanAccount(): boolean { + return this.loanProductDetailsForm?.get('productTypeGroup')?.value === 'loans'; + } + get totalTabs(): number { - let tabs = 6; // details, currency, settings, terms, charges, accounting - if (this.isAdvancedPaymentStrategy) tabs += 3; // interest refund, payment allocation, deferred income + let tabs = 4; // details, currency, charges, accounting (always) + if (this.isAdvanceProduct) { + // security & lien, limit & tenure, interest, penal & npa, renewal & closure + tabs += 5; + if (this.isOverdraftFacility) tabs += 1; // cheque book + } else if (this.isLoanAccount) { + // security & collateral, amount & tenure, repayment & emi, interest, penal & npa, prepayment & closure + tabs += 6; + } + // Old Loans A/C path (Settings, Terms, Interest Refund, Payment Allocation, Deferred Income) — commented out: + // else { + // tabs += 2; // settings, terms + // if (this.isAdvancedPaymentStrategy) tabs += 3; // interest refund, payment allocation, deferred income + // } if (this.loanProductFormValid) tabs += 1; // preview return tabs; } get loanProductFormValid() { - if (this.isAdvancedPaymentStrategy) { + const base = + this.loanProductDetailsForm.valid && this.loanProductCurrencyForm.valid && this.loanProductAccountingForm.valid; + + // Advances A/C: the curated Figma steps gate the form; Settings/Terms tabs are hidden. + if (this.isAdvanceProduct) { return ( - this.loanProductDetailsForm.valid && - this.loanProductCurrencyForm.valid && - this.loanProductTermsForm.valid && - this.loanProductSettingsForm.valid && - this.loanIncomeCapitalizationForm.valid && - this.loanProductAccountingForm.valid - ); - } else { - return ( - this.loanProductDetailsForm.valid && - this.loanProductCurrencyForm.valid && - this.loanProductTermsForm.valid && - this.loanProductSettingsForm.valid && - this.loanProductAccountingForm.valid + base && + !!this.loanProductSecurityLienStep?.loanProductSecurityLienForm?.valid && + !!this.loanProductAdvanceLimitTenureStep?.loanProductAdvanceLimitTenureForm?.valid && + !!this.loanProductAdvanceInterestStep?.loanProductAdvanceInterestForm?.valid && + !!this.loanProductPenalNpaStep?.loanProductPenalNpaForm?.valid && + !!this.loanProductRenewalClosureStep?.loanProductRenewalClosureForm?.valid && + (!this.isOverdraftFacility || !!this.loanProductChequeBookStep?.loanProductChequeBookForm?.valid) ); } + + // Loans A/C (EMI): the curated Loans steps gate the form; Settings/Terms tabs are commented out. + if (this.isLoanAccount) { + return ( + base && + !!this.loanProductLoanSecurityStep?.loanProductLoanSecurityForm?.valid && + !!this.loanProductLoanAmountTenureStep?.loanProductLoanAmountTenureForm?.valid && + !!this.loanProductLoanRepaymentStep?.loanProductLoanRepaymentForm?.valid && + !!this.loanProductLoanInterestStep?.loanProductLoanInterestForm?.valid && + !!this.loanProductPenalNpaStep?.loanProductPenalNpaForm?.valid && + !!this.loanProductLoanPrepaymentStep?.loanProductLoanPrepaymentForm?.valid + ); + } + + // No product type chosen yet. + return false; + + // Old Loans A/C path (Settings + Terms) — commented out for now: + // const loansValid = !!this.loanProductTermsForm?.valid && !!this.loanProductSettingsForm?.valid; + // if (this.isAdvancedPaymentStrategy) { + // return base && loansValid && !!this.loanIncomeCapitalizationForm?.valid; + // } + // return base && loansValid; } get loanProduct() { + // Advances A/C builds its own payload: the curated steps map onto Fineract's mandatory core fields + // (principal, tenure, rate, amortization defaults) plus the whitelisted satellite params. + if (this.isAdvanceProduct) { + return this.assembleAdvanceProduct(); + } + + // Loans A/C (EMI): builds its own payload from the Loans steps, mapped onto Fineract's core fields. + if (this.isLoanAccount) { + return this.assembleLoanProduct(); + } + return {}; + + /* OLD LOANS A/C PATH (Settings/Terms composition) — commented out for now. const loanProduct = { ...this.loanProductDetailsStep.loanProductDetails, ...this.loanProductCurrencyStep.loanProductCurrency, - ...this.loanProductTermsStep.loanProductTerms, - ...this.loanProductSettingsStep.loanProductSettings, + ...(this.loanProductTermsStep?.loanProductTerms ?? {}), + ...(this.loanProductSettingsStep?.loanProductSettings ?? {}), ...this.loanProductChargesStep.loanProductCharges, ...this.loanProductAccountingStep.loanProductAccounting }; @@ -302,6 +424,340 @@ export class CreateLoanProductComponent implements OnInit { } } return loanProduct; + */ + } + + /** + * Builds the create payload for a Loans A/C (EMI) product: the Loans steps mapped onto Fineract's core + * product fields (so the standard product saves). The Loans-only options that have no core field yet + * (eligibility, collateral rules, prepayment, repayment mode, floating reset, register link) stay UI-only + * and are not sent — an unknown key would be rejected by checkForUnsupportedParameters. + */ + private assembleLoanProduct(): any { + const num = (v: any): number | undefined => (v === '' || v === null || v === undefined ? undefined : Number(v)); + + const d = this.loanProductDetailsStep.loanProductDetails; + const cur = this.loanProductCurrencyStep.loanProductCurrency; + const at = this.loanProductLoanAmountTenureStep.loanProductLoanAmountTenure; + const rp = this.loanProductLoanRepaymentStep.loanProductLoanRepayment; + const intr = this.loanProductLoanInterestStep.loanProductLoanInterest; + const sec = this.loanProductLoanSecurityStep.loanProductLoanSecurity; + const pp = this.loanProductLoanPrepaymentStep.loanProductLoanPrepayment; + const pn = this.loanProductPenalNpaStep.loanProductPenalNpa; + + const stepper = this.loanProductLoanRepaymentStep; + const interestStep = this.loanProductLoanInterestStep; + const frequency = stepper.frequencyOptions.find((f) => f.id === rp.frequency)?.fineractId ?? 2; + const daysInYear = interestStep.daysInYearOptions.find((o) => o.id === intr.daysInYear)?.fineractId ?? 1; + const daysInMonth = interestStep.daysInMonthOptions.find((o) => o.id === intr.daysInMonth)?.fineractId ?? 1; + + const defaultStrategy = this.loanProductsTemplate?.transactionProcessingStrategyOptions?.[0]?.code; + const minTenure = num(at.minTenure); + const minRate = num(intr.minRate); + const multiTranche = at.disbursementType === 'multiple'; + const moratorium = num(rp.moratoriumMonths); + + const payload: any = { + // identity + currency + name: d.name, + shortName: d.shortName, + description: d.description, + startDate: d.startDate, + closeDate: d.closeDate, + currencyCode: cur.currencyCode, + digitsAfterDecimal: cur.digitsAfterDecimal, + inMultiplesOf: cur.inMultiplesOf ?? 1, + + // amount -> principal + minPrincipal: num(at.minAmount), + principal: num(at.defaultAmount) ?? num(at.minAmount), + maxPrincipal: num(at.maxAmount), + + // tenure (months) -> number of repayments + minNumberOfRepayments: minTenure, + numberOfRepayments: num(at.defaultTenure) ?? minTenure, + maxNumberOfRepayments: num(at.maxTenure), + repaymentEvery: num(rp.repaidEvery) ?? 1, + repaymentFrequencyType: frequency, + graceOnPrincipalPayment: moratorium && moratorium > 0 ? moratorium : undefined, + + // rate + minInterestRatePerPeriod: minRate, + interestRatePerPeriod: num(intr.defaultRate) ?? minRate, + maxInterestRatePerPeriod: num(intr.maxRate), + interestRateFrequencyType: 3, // per year + + // amortization / calculation + amortizationType: rp.repaymentType === 'equal_principal' ? 0 : 1, // 0 equal principal, 1 equal installments + interestType: intr.calcMethod === 'flat' ? 1 : 0, // 0 declining balance, 1 flat + interestCalculationPeriodType: intr.calcPeriod === 'daily' ? 0 : 1, // 0 daily, 1 same as repayment period + transactionProcessingStrategyCode: defaultStrategy, + daysInYearType: daysInYear, + daysInMonthType: daysInMonth, + isInterestRecalculationEnabled: false, + + // disbursement + multiDisburseLoan: multiTranche, + maxTrancheCount: multiTranche ? num(at.maxTranches) : undefined, + + // NPA / delinquency map onto core product fields + overdueDaysForNPA: num(pn.npaDays), + graceOnArrearsAgeing: num(pn.graceOnArrearsAgeing), + inArrearsTolerance: num(pn.inArrearsTolerance), + delinquencyBucketId: pn.delinquencyBucketId || undefined, + + // accounting rule + GL mappings + charges (all standard, whitelisted) + ...this.loanProductAccountingStep.loanProductAccounting, + ...this.loanProductChargesStep.loanProductCharges, + + // Details-step satellite params (same whitelist the Advances path uses) + productTypeGroup: d.productTypeGroup, + productTypeSubGroup: d.productTypeSubGroup, + glAccount: d.glAccount || undefined, + loanProductCategory: d.loanProductCategory, + prioritySector: d.prioritySector, + cibilAccountType: d.cibilAccountType, + kfsEnabled: d.kfsEnabled, + aprEnabled: d.aprEnabled, + + // Loans A/C (EMI) fields kept on the product satellite (same whitelist as the Advances fields) + margin: num(sec.margin), + ltv: sec.ltv, + securityType: sec.securityType, + repaymentType: rp.repaymentType, + appropriationOrder: rp.appropriationOrder, + interestCalcMethod: intr.calcMethod, + spread: num(intr.spread), + lockInMonths: num(pp.lockInMonths), + interestOnPreclosure: pp.interestOnPreclosure, + penalApplicable: pn.penalApplicable, + penalRate: num(pn.penalRate), + penalAppliedOn: pn.penalAppliedOn, + penalGraceDays: num(pn.penalGraceDays), + npaTrigger: pn.npaTrigger, + interestAfterNpa: pn.interestAfterNpa, + npaUpgradeOnlyAllArrears: pn.upgradeOnlyAllArrears, + npaKeepStandardIfDepositCovers: pn.keepStandardIfDepositCovers, + securityMandatory: sec.securityMandatory, + guarantorsRequired: num(sec.guarantorsRequired), + coApplicant: sec.coApplicant, + thirdPartySecurity: sec.thirdPartySecurity, + legalOpinionRequired: sec.legalOpinionRequired, + propertyInsuranceRequired: sec.propertyInsuranceRequired, + registerCharge: sec.registerCharge, + releaseSecurityOnClosure: sec.autoReleaseOnClosure, + allowMultipleSecurities: sec.allowMultipleSecurities, + allowThirdPartyDeposit: sec.allowThirdPartyDeposit, + minAge: num(at.minAge), + maxAgeAtMaturity: num(at.maxAgeAtMaturity), + minCibil: num(at.minCibil), + minMonthlyIncome: num(at.minMonthlyIncome), + maxFoir: num(at.maxFoir), + maxLoansPerCustomer: num(at.maxLoansPerCustomer), + disbursementType: at.disbursementType, + disbursementMode: at.disbursementMode, + undisbursedInterest: at.undisbursedInterest, + preEmiInterest: at.preEmiInterest, + emiDueDay: rp.emiDueDay, + holidayRule: rp.holidayRule, + moratoriumMonths: num(rp.moratoriumMonths), + moratoriumInterest: rp.moratoriumInterest, + variableInstallments: rp.variableInstallments, + repaymentMode: rp.repaymentMode, + linkedAccountType: rp.linkedAccountType, + pdcCollection: rp.pdcCollection, + mandateRequired: rp.mandateRequired, + advanceEmi: rp.advanceEmi, + rateType: intr.interestType, + registerParticular: intr.registerParticular, + applyCibilAdjustment: intr.applyCibilAdjustment, + rateResolvedAt: intr.rateResolvedAt, + calcPeriod: intr.calcPeriod, + interestStartsFrom: intr.interestStartsFrom, + compounding: intr.compounding, + benchmark: intr.benchmark, + resetFrequency: intr.resetFrequency, + resetChange: intr.resetChange, + partPrepaymentAllowed: pp.partPrepaymentAllowed, + minPartPayment: num(pp.minPartPayment), + allowedOn: pp.allowedOn, + partPaymentsPerYear: pp.partPaymentsPerYear, + partPrepaymentMode: pp.partPrepaymentMode, + foreclosureFloatingIndividual: num(pp.foreclosureFloatingIndividual), + foreclosureFixedIndividual: num(pp.foreclosureFixedIndividual), + foreclosureNonIndividual: num(pp.foreclosureNonIndividual), + foreclosureTakeover: num(pp.foreclosureTakeover), + foreclosureQuoteValidDays: num(pp.foreclosureQuoteValidDays), + autoCloseOnZeroBalance: pp.autoCloseOnZeroBalance, + nocLetter: pp.nocLetter, + restructuring: pp.restructuring, + releaseCollateralOnClosure: pp.releaseCollateralOnClosure, + topUpAllowed: pp.topUpAllowed, + balanceTransferOutAllowed: pp.balanceTransferOutAllowed + }; + + // Drop undefined keys so we never send an unresolved value. + Object.keys(payload).forEach((k) => payload[k] === undefined && delete payload[k]); + return payload; + } + + /** + * Builds the create payload for an Advances A/C product: the curated wizard steps mapped onto Fineract's + * mandatory core fields (so the standard product saves) plus the whitelisted satellite params (persisted by + * LoanProductAdvanceExtService). Only keys the backend accepts are emitted — an unknown key would be + * rejected by checkForUnsupportedParameters. + */ + private assembleAdvanceProduct(): any { + const num = (v: any): number | undefined => (v === '' || v === null || v === undefined ? undefined : Number(v)); + + const d = this.loanProductDetailsStep.loanProductDetails; + const cur = this.loanProductCurrencyStep.loanProductCurrency; + const sec = this.loanProductSecurityLienStep.loanProductSecurityLien; + const lt = this.loanProductAdvanceLimitTenureStep.loanProductAdvanceLimitTenure; + const intr = this.loanProductAdvanceInterestStep.loanProductAdvanceInterest; + const pn = this.loanProductPenalNpaStep.loanProductPenalNpa; + const rc = this.loanProductRenewalClosureStep.loanProductRenewalClosure; + const cb = this.isOverdraftFacility ? this.loanProductChequeBookStep?.loanProductChequeBook : null; + + const defaultStrategy = this.loanProductsTemplate?.transactionProcessingStrategyOptions?.[0]?.code; + const minTenure = num(lt.minTenure); + const minRate = num(intr.minRate); + + const payload: any = { + // identity + currency + name: d.name, + shortName: d.shortName, + description: d.description, + startDate: d.startDate, + closeDate: d.closeDate, + currencyCode: cur.currencyCode, + digitsAfterDecimal: cur.digitsAfterDecimal, + inMultiplesOf: 1, + + // limit -> principal + minPrincipal: num(lt.minLimit), + principal: num(lt.defaultLimit) ?? num(lt.minLimit), + maxPrincipal: num(lt.maxLimit), + + // tenure -> repayments (monthly) + minNumberOfRepayments: minTenure, + numberOfRepayments: num(lt.defaultTenure) ?? minTenure, + maxNumberOfRepayments: num(lt.maxTenure), + repaymentEvery: 1, + repaymentFrequencyType: 2, // months + + // rate + minInterestRatePerPeriod: minRate, + // A deposit-linked row ("1% + FD") has no plain minimum or maximum, so the register step supplies the default. + interestRatePerPeriod: num(intr.defaultRate) ?? minRate, + maxInterestRatePerPeriod: num(intr.maxRate), + interestRateFrequencyType: 3, // per year + + // Fineract mandatory engine defaults for an Advance (no EMI schedule shown to the officer) + amortizationType: 1, // equal installments + interestType: 0, // declining balance + interestCalculationPeriodType: 1, // same as repayment period + transactionProcessingStrategyCode: defaultStrategy, + daysInYearType: 1, // actual + daysInMonthType: 1, // actual + isInterestRecalculationEnabled: false, + + // NPA / delinquency map onto core product fields + overdueDaysForNPA: num(pn.npaDays), + graceOnArrearsAgeing: num(pn.graceOnArrearsAgeing), + inArrearsTolerance: num(pn.inArrearsTolerance), + delinquencyBucketId: pn.delinquencyBucketId || undefined, + + // accounting rule + GL mappings + charges (all standard, whitelisted) + ...this.loanProductAccountingStep.loanProductAccounting, + ...this.loanProductChargesStep.loanProductCharges, + + // ---- satellite params (must match LoanProductAdvanceExtService.ADVANCE_PARAMETERS) ---- + productTypeGroup: d.productTypeGroup, + productTypeSubGroup: d.productTypeSubGroup, + glAccount: d.glAccount || undefined, + loanProductCategory: d.loanProductCategory, + prioritySector: d.prioritySector, + cibilAccountType: d.cibilAccountType, + kfsEnabled: d.kfsEnabled, + aprEnabled: d.aprEnabled, + facilityType: lt.facilityType, + securityType: sec.securityType, + eligibleAmountBasis: sec.eligibleAmountBasis, + margin: num(sec.margin), + ltv: sec.ltv, + dpMethod: 'security_value_x_ltv', + lienRequired: this.loanProductSecurityLienStep.isLienSubType, + lienMarkingStage: sec.lienMarkingStage, + lienCertificateRequired: sec.lienCertificateRequired, + lienCertificatePrefix: sec.lienCertificatePrefix, + printCertificateOnDisbursement: sec.printOnDisbursement, + interestBasis: intr.interestBasis, + registerParticular: intr.registerParticular || undefined, + spread: num(intr.spread), + interestChargedOn: intr.interestChargedOn, + interestPostingFrequency: intr.postingFrequency, + penalApplicable: pn.penalApplicable, + penalRate: num(pn.penalRate), + penalAppliedOn: pn.penalAppliedOn, + penalGraceDays: num(pn.penalGraceDays), + npaTrigger: pn.npaTrigger, + interestAfterNpa: pn.interestAfterNpa, + renewalAllowed: rc.renewalAllowed, + renewalPeriodMonths: num(rc.renewalPeriodMonths), + renewalGraceDays: num(rc.renewalGraceDays), + preclosureAllowed: rc.preclosureAllowed, + lockInMonths: num(rc.lockInMonths), + interestOnPreclosure: rc.interestOnPreclosure, + chequeBookEnabled: this.isOverdraftFacility, + chequeType: cb?.chequeType, + defaultChequeBookSize: num(cb?.defaultBookSize), + ppsThreshold: num(cb?.ppsThreshold), + ppsMandatoryAbove: num(cb?.ppsMandatoryAbove), + + // ---- remaining wizard fields ---- + // security & lien extras (deposit list joined; rule checkboxes) + eligibleDepositProducts: (sec.eligibleDepositProducts ?? []).join('|'), + lienTenureNotExceedMaturity: sec.lienRules?.tenureNotExceedMaturity, + lienAutoReleaseOnClosure: sec.lienRules?.autoReleaseOnClosure, + lienAllowMultipleDeposits: sec.lienRules?.allowMultipleDeposits, + lienAdjustFromMaturity: sec.lienRules?.adjustFromMaturity, + lienAllowThirdParty: sec.lienRules?.allowThirdPartyDeposit, + lienAllowRenewalUnderLien: sec.lienRules?.allowRenewalUnderLien, + // limit & tenure extras + repaymentType: lt.repaymentType, + appropriationOrder: lt.appropriationOrder, + debitBalanceAllow: lt.debitBalanceAllow, + allowDebitAfterDueDate: lt.allowDebitTransAfterDueDate, + branchLevelAccountType: lt.branchLevelAccountType, + // interest extras + interestCalcMethod: intr.calcMethod, + interestOnCreditBalance: intr.interestOnCreditBalance, + interestPostingDay: intr.postingDay, + // NPA upgrade rules + npaUpgradeOnlyAllArrears: pn.upgradeOnlyAllArrears, + npaKeepStandardIfDepositCovers: pn.keepStandardIfDepositCovers, + // cheque book full config + chequeBookSizes: (cb?.bookSizes ?? []).join('|'), + chequeMaxBooksOutstanding: num(cb?.maxBooksOutstanding), + chequeReissueWhenLeaves: num(cb?.reissueWhenLeaves), + chequeFirstBookFree: cb?.firstBookFree, + chequeFreeBooksPerYear: num(cb?.freeBooksPerYear), + chequeNamePrinted: cb?.namePrinted, + chequePrintDescription: cb?.chequePrintDescription, + chequeStopPaymentAllowed: cb?.stopPaymentAllowed, + chequeMaintainStockRegister: cb?.maintainStockRegister, + chequeAllowWithdrawalSlip: cb?.allowWithdrawalSlip, + chequeBlockNpa: cb?.blockNpa, + chequeBlockLimitExpired: cb?.blockLimitExpired, + chequeBlockOverDp: cb?.blockOverDp, + chequeBlockFrozen: cb?.blockFrozen, + chequeBlockLienReleased: cb?.blockLienReleased + }; + + // Drop undefined keys so we never send an unresolved value. + Object.keys(payload).forEach((k) => payload[k] === undefined && delete payload[k]); + return payload; } submit() { diff --git a/src/app/products/loan-products/loan-product-stepper/advance-steps.scss b/src/app/products/loan-products/loan-product-stepper/advance-steps.scss new file mode 100644 index 000000000..062f2f8fe --- /dev/null +++ b/src/app/products/loan-products/loan-product-stepper/advance-steps.scss @@ -0,0 +1,216 @@ +/** + * 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 styling for the Advances A/C wizard steps (Security & Lien, Limit & Tenure, + * Interest, Penal & NPA, Renewal & Closure, Cheque Book). + */ + +$accent: #0ea5e9; +$border: #e2e8f0; +$gray-50: #f8f9fa; +$gray-200: #e8eaed; +$gray-900: #202124; + +.adv-section { + border: 1px solid $border; + border-radius: 10px; + overflow: hidden; + background: #fff; + + & + & { + margin-top: 16px; + } + + &__header { + display: flex; + align-items: center; + gap: 10px; + padding: 12px 24px; + background: $gray-50; + border-bottom: 1px solid $gray-200; + + i { + font-size: 14px; + color: $accent; + } + + h3 { + margin: 0; + font-size: 14px; + font-weight: 600; + color: $gray-900; + } + } + + &__hint { + margin-left: auto; + font-size: 12px; + color: #5f6368; + } +} + +.adv-subsection { + padding: 16px 24px; + + & + & { + border-top: 1px solid $border; + } +} + +.adv-group { + margin: 0 0 12px; + font-size: 12px; + font-weight: 600; + letter-spacing: 0.06em; + text-transform: uppercase; + color: #5f6368; +} + +.adv-grid { + display: grid; + grid-template-columns: repeat(2, 1fr); + gap: 4px 16px; + padding: 16px 24px; + + @media (width <= 600px) { + grid-template-columns: 1fr; + } +} + +.adv-grid--3 { + grid-template-columns: repeat(3, 1fr); + + @media (width <= 900px) { + grid-template-columns: repeat(2, 1fr); + } + + @media (width <= 600px) { + grid-template-columns: 1fr; + } +} + +.adv-subsection .adv-grid { + padding: 0; +} + +.adv-field { + width: 100%; + + &--full { + grid-column: 1 / -1; + } +} + +.adv-checks { + display: grid; + grid-template-columns: repeat(2, 1fr); + gap: 12px 32px; + + @media (width <= 600px) { + grid-template-columns: 1fr; + } +} + +.adv-type-options { + display: grid; + grid-template-columns: repeat(2, 1fr); + gap: 12px; + padding: 16px 24px 0; + + @media (width <= 600px) { + grid-template-columns: 1fr; + } +} + +.adv-type-option { + border: 1px solid #cbd5e1; + border-radius: 8px; + padding: 10px 14px; + + &__label { + display: block; + font-weight: 600; + color: $gray-900; + } + + &__hint { + display: block; + font-size: 12px; + color: #5f6368; + } +} + +.adv-note { + display: flex; + align-items: flex-start; + gap: 8px; + margin-top: 16px; + padding: 12px 24px; + border: 1px dashed $border; + border-radius: 10px; + font-size: 13px; + color: #5f6368; + + i { + color: $accent; + margin-top: 2px; + } +} + +.adv-info { + padding: 12px 16px; + border-radius: 8px; + background: #f0f9ff; + border: 1px solid #bae6fd; + font-size: 13px; + color: #075985; + line-height: 1.5; +} + +/* Read-only reference tables (Loans A/C steps): collateral rules, foreclosure charges. */ +.adv-table { + width: 100%; + border-collapse: collapse; + + th { + text-align: left; + font-size: 12px; + font-weight: 600; + letter-spacing: 0.05em; + text-transform: uppercase; + color: #475569; + padding: 12px 24px; + background: $gray-50; + border-bottom: 1px solid $border; + } + + td { + font-size: 14px; + padding: 10px 24px; + border-bottom: 1px solid #eef2f6; + color: #1e293b; + } + + .adv-table__no { + color: #94a3b8; + } + + .adv-table__yes { + color: #047857; + font-weight: 600; + } +} + +.adv-type-options--3 { + grid-template-columns: repeat(3, 1fr); + + @media (width <= 900px) { + grid-template-columns: 1fr; + } +} diff --git a/src/app/products/loan-products/loan-product-stepper/loan-product-advance-interest-step/loan-product-advance-interest-step.component.html b/src/app/products/loan-products/loan-product-stepper/loan-product-advance-interest-step/loan-product-advance-interest-step.component.html new file mode 100644 index 000000000..2c616c118 --- /dev/null +++ b/src/app/products/loan-products/loan-product-stepper/loan-product-advance-interest-step/loan-product-advance-interest-step.component.html @@ -0,0 +1,98 @@ + + +
+
+
+ +

Rate

+ Taken from the Loan Interest Rate Register +
+
+ +
+
+ +
+
+ +

Calculation

+
+
+ + Calculation Method + + @for (opt of calcMethodOptions; track opt.id) { + {{ opt.label }} + } + + + + Days in Year + + @for (opt of daysInYearOptions; track opt) { + {{ opt }} + } + + + + Days in Month + + @for (opt of daysInMonthOptions; track opt) { + {{ opt }} + } + + + + Interest Charged On + + @for (opt of chargedOnOptions; track opt.id) { + {{ opt.label }} + } + + + + Interest on Credit Balance + + @for (opt of yesNoOptions; track opt) { + {{ opt }} + } + + +
+
+ +
+
+ +

Posting

+
+
+ + Posting Frequency + + @for (opt of postingFrequencyOptions; track opt) { + {{ opt }} + } + + + + Posting Day + + @for (opt of postingDayOptions; track opt) { + {{ opt }} + } + + +
+
+
diff --git a/src/app/products/loan-products/loan-product-stepper/loan-product-advance-interest-step/loan-product-advance-interest-step.component.ts b/src/app/products/loan-products/loan-product-stepper/loan-product-advance-interest-step/loan-product-advance-interest-step.component.ts new file mode 100644 index 000000000..4aa3d2dda --- /dev/null +++ b/src/app/products/loan-products/loan-product-stepper/loan-product-advance-interest-step/loan-product-advance-interest-step.component.ts @@ -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/. + */ + +import { Component, Input, inject } from '@angular/core'; +import { UntypedFormGroup, UntypedFormBuilder, Validators, ReactiveFormsModule } from '@angular/forms'; +import { STANDALONE_SHARED_IMPORTS } from 'app/standalone-shared.module'; +import { RegisterRateSelection, RegisterRateViewComponent } from '../register-rate-view/register-rate-view.component'; + +/** + * Interest step for an Advances A/C product (Figma step 5). Interest basis + spread, rate bounds, + * daily calculation and monthly posting. UI-only for now — not sent in the create payload. + */ +@Component({ + selector: 'mifosx-loan-product-advance-interest-step', + templateUrl: './loan-product-advance-interest-step.component.html', + styleUrls: ['../advance-steps.scss'], + imports: [ + ...STANDALONE_SHARED_IMPORTS, + RegisterRateViewComponent + ] +}) +export class LoanProductAdvanceInterestStepComponent { + private formBuilder = inject(UntypedFormBuilder); + + loanProductAdvanceInterestForm: UntypedFormGroup; + + /** Sub type and GL account picked on the Details step; they choose the Loan Interest Rate Register row. */ + @Input() productSubType = ''; + @Input() glAccountId: number | string | null = null; + + /** The register row the product takes its rate from; null while the register has no row for it. */ + registerSelection: RegisterRateSelection | null = null; + + readonly interestBasisOptions = [ + { id: 'deposit_spread', label: 'Deposit Rate + Spread' }, + { id: 'fixed', label: 'Fixed rate' }, + { id: 'floating', label: 'Floating (EBLR-linked)' } + ]; + + readonly calcMethodOptions = [ + { id: 'daily', label: 'Daily product (daily outstanding)' }, + { id: 'monthly', label: 'Monthly average balance' } + ]; + + readonly daysInYearOptions = [ + 'Actual (365 / 366)', + '365', + '360' + ]; + readonly daysInMonthOptions = [ + 'Actual', + '30' + ]; + + readonly chargedOnOptions = [ + { id: 'utilised', label: 'Utilised (debit) balance' }, + { id: 'limit', label: 'Sanctioned limit' } + ]; + + readonly postingFrequencyOptions = [ + 'Monthly', + 'Quarterly', + 'Half-yearly', + 'At Maturity' + ]; + readonly postingDayOptions = [ + 'Last day of month', + '1st of month', + 'Anniversary day' + ]; + readonly yesNoOptions = [ + 'YES', + 'NO' + ]; + + constructor() { + this.loanProductAdvanceInterestForm = this.formBuilder.group({ + interestBasis: ['deposit_spread'], + spread: [ + 2.0, + [ + Validators.min(0), + Validators.max(20) + ] + ], + // Filled from the register row; never typed in. + minRate: [''], + maxRate: [''], + defaultRate: [''], + // The rate comes from the Loan Interest Rate Register, so a product with no register row cannot be created. + registerParticular: [ + '', + Validators.required + ], + calcMethod: ['daily'], + daysInYear: ['Actual (365 / 366)'], + daysInMonth: ['Actual'], + interestChargedOn: ['utilised'], + interestOnCreditBalance: ['NO'], + postingFrequency: ['Monthly'], + postingDay: ['Last day of month'] + }); + } + + /** + * The register row decides the rate. A row of plain rates fixes the minimum and maximum; a deposit row + * ("1% + FD") is the deposit rate plus a spread, which has no plain minimum or maximum. With no row for the + * product the register particular stays empty, which keeps this step, and so the whole product, invalid until + * the product has been added to the register. + */ + onRegisterSelection(selection: RegisterRateSelection | null): void { + this.registerSelection = selection; + const form = this.loanProductAdvanceInterestForm; + if (selection) { + form.patchValue( + { + interestBasis: selection.isFormula ? 'deposit_spread' : 'fixed', + spread: selection.isFormula ? selection.minSpread : 0, + minRate: selection.minRate ?? '', + maxRate: selection.maxRate ?? '', + defaultRate: selection.defaultRate ?? '', + registerParticular: selection.particular + }, + { emitEvent: false } + ); + } else { + form.patchValue({ minRate: '', maxRate: '', defaultRate: '', registerParticular: '' }, { emitEvent: false }); + } + form.updateValueAndValidity({ emitEvent: false }); + } + + get showSpread(): boolean { + return this.loanProductAdvanceInterestForm.get('interestBasis').value === 'deposit_spread'; + } + + get loanProductAdvanceInterest() { + return this.loanProductAdvanceInterestForm.getRawValue(); + } +} diff --git a/src/app/products/loan-products/loan-product-stepper/loan-product-advance-limit-tenure-step/loan-product-advance-limit-tenure-step.component.html b/src/app/products/loan-products/loan-product-stepper/loan-product-advance-limit-tenure-step/loan-product-advance-limit-tenure-step.component.html new file mode 100644 index 000000000..ed9f6e540 --- /dev/null +++ b/src/app/products/loan-products/loan-product-stepper/loan-product-advance-limit-tenure-step/loan-product-advance-limit-tenure-step.component.html @@ -0,0 +1,155 @@ + + +
+
+
+ +

Facility Type

+
+ + + Demand Loan + Paid out once; repaid at maturity. No cheque book. + + + Overdraft (Running) + Draw and repay any time within the limit. Cheque book allowed. + + +
+
+ This choice decides whether the Cheque Book step appears. CC / OD sub types are always running. +
+
+
+ +
+
+ +

Limit

+
+
+ + Minimum Limit (₹) + + Minimum Limit is required + + + Default Limit (₹) + + + + Maximum Limit (₹) + + Maximum Limit is required + +
+
+ +
+
+ +

Tenure & Repayment

+
+
+ + Minimum Tenure (months) + + @if (isFdTenureLocked) { + FD policy: 12–15 months only + } + + @if (isFdTenureLocked) { + Must be between 12 and 15 months + } @else { + Minimum Tenure is required + } + + + + Default Tenure (months) + + @if (isFdTenureLocked) { + Between 12 and 15 + Must be between 12 and 15 months + } + + + Maximum Tenure (months) + + @if (isFdTenureLocked) { + FD policy: max 15 months (also capped at deposit maturity) + } @else { + Also capped at the deposit's maturity (Lien rule) + } + + @if (isFdTenureLocked) { + Must be between 12 and 15 months + } @else { + Maximum Tenure is required + } + + + + Repayment Type + + @for (opt of repaymentTypeOptions; track opt.id) { + {{ opt.label }} + } + + + + Appropriation Order + + @for (opt of appropriationOrderOptions; track opt.id) { + {{ opt.label }} + } + + +
+
+ +
+
+ +

Account Behaviour

+
+
+ + Debit Balance Allow + + @for (opt of yesNoOptions; track opt) { + {{ opt }} + } + + + + Allow Debit Trans After Due Date + + @for (opt of yesNoOptions; track opt) { + {{ opt }} + } + + + + Branch Level A/c Type + + @for (opt of yesNoOptions; track opt) { + {{ opt }} + } + + +
+
+ +
+ + EMI fields (Number of Repayments, Repaid Every, Variable Installments) are hidden for an Advance. +
+
diff --git a/src/app/products/loan-products/loan-product-stepper/loan-product-advance-limit-tenure-step/loan-product-advance-limit-tenure-step.component.ts b/src/app/products/loan-products/loan-product-stepper/loan-product-advance-limit-tenure-step/loan-product-advance-limit-tenure-step.component.ts new file mode 100644 index 000000000..ddb60f62b --- /dev/null +++ b/src/app/products/loan-products/loan-product-stepper/loan-product-advance-limit-tenure-step/loan-product-advance-limit-tenure-step.component.ts @@ -0,0 +1,171 @@ +/** + * Copyright since 2025 Mifos Initiative + * + * This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. + */ + +import { Component, inject, Input } from '@angular/core'; +import { UntypedFormGroup, UntypedFormBuilder, Validators, ReactiveFormsModule } from '@angular/forms'; +import { MatRadioButton, MatRadioGroup } from '@angular/material/radio'; +import { STANDALONE_SHARED_IMPORTS } from 'app/standalone-shared.module'; + +/** + * Limit & Tenure step for an Advances A/C product (Figma step 4). + * + * Facility Type here decides the rest of the flow: an Overdraft (running) facility gets a cheque book, + * a Demand Loan does not. UI-only for now — not sent in the create payload. + */ +@Component({ + selector: 'mifosx-loan-product-advance-limit-tenure-step', + templateUrl: './loan-product-advance-limit-tenure-step.component.html', + styleUrls: ['../advance-steps.scss'], + imports: [ + ...STANDALONE_SHARED_IMPORTS, + MatRadioGroup, + MatRadioButton + ] +}) +export class LoanProductAdvanceLimitTenureStepComponent { + private formBuilder = inject(UntypedFormBuilder); + + loanProductAdvanceLimitTenureForm: UntypedFormGroup; + + /** + * Product sub type picked on the Details step. "Loan against FD" is bank-policy capped to a + * 12–15 month tenure, so the officer cannot create an FD product outside that band. Other + * advance sub types (Gold, RD, CC, …) stay open. + */ + private _productSubType = ''; + @Input() + set productSubType(value: string) { + const next = value || ''; + if (next === this._productSubType) { + return; + } + this._productSubType = next; + this.applyTenurePolicy(); + } + get productSubType(): string { + return this._productSubType; + } + + readonly repaymentTypeOptions = [ + { id: 'bullet', label: 'Bullet – principal + interest at maturity' }, + { id: 'running', label: 'Running – any time; clear by limit expiry' } + ]; + + readonly appropriationOrderOptions = [ + { id: 'charges_penal_interest_principal', label: 'Charges → Penal → Interest → Principal' }, + { id: 'principal_interest_penal_charges', label: 'Principal → Interest → Penal → Charges' } + ]; + + readonly yesNoOptions = [ + 'YES', + 'NO' + ]; + + constructor() { + this.loanProductAdvanceLimitTenureForm = this.formBuilder.group({ + facilityType: ['demand'], + minLimit: [ + '', + Validators.required + ], + defaultLimit: [''], + maxLimit: [ + '', + Validators.required + ], + minTenure: [ + 12, + [ + Validators.required, + Validators.min(1) + ] + ], + defaultTenure: [12], + maxTenure: [ + 15, + [ + Validators.required, + Validators.min(1) + ] + ], + repaymentType: ['bullet'], + appropriationOrder: ['charges_penal_interest_principal'], + debitBalanceAllow: ['YES'], + allowDebitTransAfterDueDate: ['NO'], + branchLevelAccountType: ['NO'] + }); + } + + /** Read by the wizard to decide whether the Cheque Book step applies. */ + get isOverdraft(): boolean { + return this.loanProductAdvanceLimitTenureForm.get('facilityType').value === 'overdraft'; + } + + get loanProductAdvanceLimitTenure() { + return this.loanProductAdvanceLimitTenureForm.getRawValue(); + } + + /** True when the FD 12–15 month cap is in force (drives the hints/errors in the template). */ + get isFdTenureLocked(): boolean { + return this._productSubType === 'fd'; + } + + get tenureMin(): number { + return this.isFdTenureLocked ? 12 : 1; + } + + get tenureMax(): number | null { + return this.isFdTenureLocked ? 15 : null; + } + + /** + * FD advances are bank-policy capped to 12–15 months; every other sub type stays open (min 1). + * Called whenever the sub type changes so the validators — and thus the wizard's validity gate — + * match the selected product. + */ + private applyTenurePolicy(): void { + const form = this.loanProductAdvanceLimitTenureForm; + if (!form) { + return; + } + const min = form.get('minTenure'); + const def = form.get('defaultTenure'); + const max = form.get('maxTenure'); + if (this._productSubType === 'fd') { + const bounds = [ + Validators.min(12), + Validators.max(15) + ]; + min.setValidators([ + Validators.required, + ...bounds + ]); + def.setValidators(bounds); + max.setValidators([ + Validators.required, + ...bounds + ]); + min.setValue(12, { emitEvent: false }); + def.setValue(12, { emitEvent: false }); + max.setValue(15, { emitEvent: false }); + } else { + min.setValidators([ + Validators.required, + Validators.min(1) + ]); + def.setValidators([Validators.min(1)]); + max.setValidators([ + Validators.required, + Validators.min(1) + ]); + } + min.updateValueAndValidity({ emitEvent: false }); + def.updateValueAndValidity({ emitEvent: false }); + max.updateValueAndValidity({ emitEvent: false }); + } +} diff --git a/src/app/products/loan-products/loan-product-stepper/loan-product-cheque-book-step/loan-product-cheque-book-step.component.html b/src/app/products/loan-products/loan-product-stepper/loan-product-cheque-book-step/loan-product-cheque-book-step.component.html new file mode 100644 index 000000000..cbac09492 --- /dev/null +++ b/src/app/products/loan-products/loan-product-stepper/loan-product-cheque-book-step/loan-product-cheque-book-step.component.html @@ -0,0 +1,144 @@ + + +
+
+
+ +

Cheque Book Facility

+ Overdraft (running) only +
+
+ + Cheque Type + + @for (opt of chequeTypeOptions; track opt) { + {{ opt }} + } + + + + Book Sizes Allowed (leaves) + + @for (opt of bookSizeOptions; track opt) { + {{ opt }} + } + + + + Default Book Size (leaves) + + @for (opt of bookSizeOptions; track opt) { + {{ opt }} + } + + + + Max Books Outstanding + + + + Reissue When Unused Leaves ≤ + + + + First Cheque Book Free + + @for (opt of yesNoOptions; track opt) { + {{ opt }} + } + + + + Free Cheque Books per Year + + + + Name Printed on Cheque + + @for (opt of namePrintedOptions; track opt) { + {{ opt }} + } + + + + Cheque Print Description + + +
+
+ +
+
+ +

Validation & Security

+
+
+ + Positive Pay (PPS) Threshold (₹) + + Issuer confirms cheques at or above this + + + PPS Mandatory Above (₹) + + + + Cheque Validity + + + + Stop Payment Allowed + + @for (opt of yesNoOptions; track opt) { + {{ opt }} + } + + + + Maintain Cheque Stock Register + + @for (opt of yesNoOptions; track opt) { + {{ opt }} + } + + + + Allow Withdrawal Slip + + @for (opt of yesNoOptions; track opt) { + {{ opt }} + } + + +
+
+ +
+
+ +

Block Cheque Issue & Clearing When

+
+
+
+ Account is NPA or out of order + Limit has expired (renewal due) + Debit would exceed the limit / drawing power + Account is frozen or inoperative + + Pledged deposit has matured or its lien was released + +
+
+
+ +
+ + Cheque book fees (issue, per leaf, return, stop payment) are set on the Charges step. +
+
diff --git a/src/app/products/loan-products/loan-product-stepper/loan-product-cheque-book-step/loan-product-cheque-book-step.component.ts b/src/app/products/loan-products/loan-product-stepper/loan-product-cheque-book-step/loan-product-cheque-book-step.component.ts new file mode 100644 index 000000000..3eade52c6 --- /dev/null +++ b/src/app/products/loan-products/loan-product-stepper/loan-product-cheque-book-step/loan-product-cheque-book-step.component.ts @@ -0,0 +1,92 @@ +/** + * Copyright since 2025 Mifos Initiative + * + * This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. + */ + +import { Component, inject } from '@angular/core'; +import { UntypedFormGroup, UntypedFormBuilder, Validators, ReactiveFormsModule } from '@angular/forms'; +import { MatCheckbox } from '@angular/material/checkbox'; +import { STANDALONE_SHARED_IMPORTS } from 'app/standalone-shared.module'; + +/** + * Cheque Book step (Figma step 8), shown only when the Facility Type is Overdraft (running). + * UI-only for now — not sent in the create payload. + */ +@Component({ + selector: 'mifosx-loan-product-cheque-book-step', + templateUrl: './loan-product-cheque-book-step.component.html', + styleUrls: ['../advance-steps.scss'], + imports: [ + ...STANDALONE_SHARED_IMPORTS, + MatCheckbox + ] +}) +export class LoanProductChequeBookStepComponent { + private formBuilder = inject(UntypedFormBuilder); + + loanProductChequeBookForm: UntypedFormGroup; + + readonly chequeTypeOptions = [ + 'CTS-2010 · Payable at par (multi-city)', + 'CTS-2010 · Local', + 'Non-CTS' + ]; + readonly bookSizeOptions = [ + '10', + '25', + '50', + '100' + ]; + readonly namePrintedOptions = [ + 'Account holder name', + 'Firm / entity name', + 'As per instruction' + ]; + readonly yesNoOptions = [ + 'YES', + 'NO' + ]; + + constructor() { + this.loanProductChequeBookForm = this.formBuilder.group({ + chequeType: ['CTS-2010 · Payable at par (multi-city)'], + bookSizes: [ + [ + '10', + '25', + '50' + ] + ], + defaultBookSize: ['25'], + maxBooksOutstanding: [1], + reissueWhenLeaves: [5], + firstBookFree: ['YES'], + freeBooksPerYear: [1], + namePrinted: ['Account holder name'], + chequePrintDescription: [''], + ppsThreshold: [ + 50000, + [ + Validators.required, + Validators.min(0) + ] + ], + ppsMandatoryAbove: [500000], + stopPaymentAllowed: ['YES'], + maintainStockRegister: ['YES'], + allowWithdrawalSlip: ['NO'], + blockNpa: [true], + blockLimitExpired: [true], + blockOverDp: [true], + blockFrozen: [true], + blockLienReleased: [true] + }); + } + + get loanProductChequeBook() { + return this.loanProductChequeBookForm.getRawValue(); + } +} diff --git a/src/app/products/loan-products/loan-product-stepper/loan-product-details-step/loan-product-details-step.component.html b/src/app/products/loan-products/loan-product-stepper/loan-product-details-step/loan-product-details-step.component.html index 49df9f6d2..e35ab7664 100644 --- a/src/app/products/loan-products/loan-product-stepper/loan-product-details-step/loan-product-details-step.component.html +++ b/src/app/products/loan-products/loan-product-stepper/loan-product-details-step/loan-product-details-step.component.html @@ -7,178 +7,190 @@ -->
-
-
- -

{{ 'labels.inputs.Product Name' | translate }} & {{ 'labels.inputs.Description' | translate }}

-
-
- - {{ 'labels.inputs.Product Name' | translate }} - - - {{ 'labels.inputs.Product Name' | translate }} {{ 'labels.commons.is' | translate }} - {{ 'labels.commons.required' | translate }} - - - - - {{ 'labels.inputs.Short Name' | translate }} - - - {{ 'labels.inputs.Short Name' | translate }} {{ 'labels.commons.is' | translate }} - {{ 'labels.commons.required' | translate }} - - -
-
- -
-
- -

{{ 'labels.inputs.Availability' | translate }}

-
-
- - {{ 'labels.inputs.Start Date' | translate }} - - - - - - - {{ 'labels.inputs.Close Date' | translate }} - - - - - - - {{ 'labels.inputs.Description' | translate }} - - -
-
- - -
-
- -

Interest Information

-
-
- - Debit Int. Calculation - - @for (opt of yesNoOptions; track opt.value) { - {{ opt.label }} - } - - - - - Separate Penal Int. - - @for (opt of yesNoOptions; track opt.value) { - {{ opt.label }} - } - - - - - Credit Int. Calculation - - @for (opt of yesNoOptions; track opt.value) { - {{ opt.label }} - } - - - - - Sub A/c Type Balance - - @for (opt of yesNoOptions; track opt.value) { - {{ opt.label }} - } - - - - - Final Int. Rounding - - @for (opt of yesNoOptions; track opt.value) { - {{ opt.label }} - } - - - - @if (loanProductDetailsForm.get('finalIntRounding')?.value) { - - Rounding UpTo - - - } -
-
- - +
-

Product Classification

+

Product Type

-
- - Product Type Group - - @for (group of productTypeGroups; track group.id) { - {{ group.label }} - } - - + + @for (group of productTypeGroups; track group.id) { + + {{ group.label }} + {{ group.hint }} + + } + - @if (loanProductDetailsForm.get('productTypeGroup')?.value) { + @if (loanProductDetailsForm.get('productTypeGroup')?.value) { +
- GL Account ({{ glClassificationLabel }}) - - @for (acc of availableGlAccounts; track acc.id) { - ({{ acc.glCode }}) {{ acc.name }} + Product Sub Type + + @for (subGroup of availableSubGroups; track subGroup.id) { + {{ subGroup.label }} } - GL Account is required + Product Sub Type is required - } -
+ + + GL Account (Asset) + + @for (acc of assetAccountOptions; track acc.id) { + ({{ acc.glCode }}) {{ acc.name }} + } + + GL Account is required + +
+ }
+ + @if (!isProductTypeChosen) { +
+ + Select a Product Type and Sub Type to continue. +
+ } + + @if (isProductTypeChosen) { +
+
+ +

{{ 'labels.inputs.Product Name' | translate }} & {{ 'labels.inputs.Description' | translate }}

+
+
+ + {{ 'labels.inputs.Product Name' | translate }} + + + {{ 'labels.inputs.Product Name' | translate }} {{ 'labels.commons.is' | translate }} + {{ 'labels.commons.required' | translate }} + + + + + {{ 'labels.inputs.Short Name' | translate }} + + @if (loanProductDetailsForm.get('shortName').hasError('pattern')) { + Short Name must be exactly 2 capital letters (A–Z) + } @else { + + {{ 'labels.inputs.Short Name' | translate }} {{ 'labels.commons.is' | translate }} + {{ 'labels.commons.required' | translate }} + + } + + + + {{ 'labels.inputs.Start Date' | translate }} + + + + + + + {{ 'labels.inputs.Close Date' | translate }} + + + + + + + {{ 'labels.inputs.Description' | translate }} + + +
+
+ +
+
+ +

Regulatory Classification

+
+
+ + Loan Product Category (RBI) + + @for (opt of loanProductCategoryOptions; track opt.id) { + {{ opt.label }} + } + + + + + Priority Sector + + @for (opt of prioritySectorOptions; track opt.id) { + {{ opt.label }} + } + + + + + CIBIL Account Type + + @for (opt of cibilAccountTypeOptions; track opt.id) { + {{ opt.label }} + } + + + + + Key Fact Statement (KFS) + + @for (opt of yesNoOptions; track opt) { + {{ opt }} + } + + + + + APR Calculation + + @for (opt of yesNoOptions; track opt) { + {{ opt }} + } + + +
+
+ }
diff --git a/src/app/products/loan-products/loan-product-stepper/loan-product-details-step/loan-product-details-step.component.scss b/src/app/products/loan-products/loan-product-stepper/loan-product-details-step/loan-product-details-step.component.scss index f6a3f62d5..a5eecb505 100644 --- a/src/app/products/loan-products/loan-product-stepper/loan-product-details-step/loan-product-details-step.component.scss +++ b/src/app/products/loan-products/loan-product-stepper/loan-product-details-step/loan-product-details-step.component.scss @@ -61,6 +61,57 @@ $gray-900: #202124; &--full { grid-column: 1 / -1; } + + // Starts a new row, so Start / Close Date sit together instead of one of them beside Short Name. + &--row-start { + grid-column-start: 1; + } +} + +// Product Type — first section +.lpd-type-options { + display: grid; + grid-template-columns: repeat(2, 1fr); + gap: 12px; + padding: 16px 24px 0; + + @media (width <= 600px) { + grid-template-columns: 1fr; + } +} + +.lpd-type-option { + border: 1px solid $border; + border-radius: 8px; + padding: 8px 12px; + + &__label { + display: block; + font-weight: 600; + color: $gray-900; + } + + &__hint { + display: block; + font-size: 12px; + color: #5f6368; + } +} + +.lpd-type-pending { + display: flex; + align-items: center; + gap: 8px; + margin-top: 16px; + padding: 12px 24px; + border: 1px dashed $border; + border-radius: 10px; + font-size: 13px; + color: #5f6368; + + i { + color: $accent; + } } .lpd-checkbox-row { diff --git a/src/app/products/loan-products/loan-product-stepper/loan-product-details-step/loan-product-details-step.component.ts b/src/app/products/loan-products/loan-product-stepper/loan-product-details-step/loan-product-details-step.component.ts index ded56834c..6eb65abd2 100644 --- a/src/app/products/loan-products/loan-product-stepper/loan-product-details-step/loan-product-details-step.component.ts +++ b/src/app/products/loan-products/loan-product-stepper/loan-product-details-step/loan-product-details-step.component.ts @@ -15,7 +15,9 @@ import { Dates } from 'app/core/utils/dates'; import { SettingsService } from 'app/settings/settings.service'; import { MatTooltip } from '@angular/material/tooltip'; import { CdkTextareaAutosize } from '@angular/cdk/text-field'; +import { MatRadioButton, MatRadioGroup } from '@angular/material/radio'; import { STANDALONE_SHARED_IMPORTS } from 'app/standalone-shared.module'; +import { PRODUCT_SUB_TYPES, PRODUCT_TYPE_GROUPS } from '../../loan-product-types'; @Component({ selector: 'mifosx-loan-product-details-step', @@ -24,7 +26,9 @@ import { STANDALONE_SHARED_IMPORTS } from 'app/standalone-shared.module'; imports: [ ...STANDALONE_SHARED_IMPORTS, MatTooltip, - CdkTextareaAutosize + CdkTextareaAutosize, + MatRadioGroup, + MatRadioButton ] }) export class LoanProductDetailsStepComponent implements OnInit { @@ -39,6 +43,14 @@ export class LoanProductDetailsStepComponent implements OnInit { minDate = new Date(2000, 0, 1); maxDate = new Date(new Date().setFullYear(new Date().getFullYear() + 10)); + // Start Date policy: a new product must start on the current day — the picker allows only today + // (neither past nor future). Widened for an existing product so its saved start date stays valid. + startDateMin: Date = new Date(); + startDateMax: Date = new Date(); + + // Close (End) Date accepts only future dates — default min is tomorrow; widened for an existing product. + closeDateMin: Date = new Date(); + /** * @param {FormBuilder} formBuilder Form Builder. * @param {Dates} dateUtils Date Utils. @@ -50,90 +62,133 @@ export class LoanProductDetailsStepComponent implements OnInit { } ngOnInit() { + // Product Type is not persisted by the backend yet, so an existing product comes back without one. + // Only a new product is made to pick it first; editing must not hide or block the rest of the form. + this.isExistingProduct = !!this.loanProductsTemplate?.id; + if (!this.isExistingProduct) { + this.loanProductDetailsForm.get('productTypeGroup').setValidators(Validators.required); + this.loanProductDetailsForm.get('productTypeSubGroup').setValidators(Validators.required); + this.loanProductDetailsForm.get('glAccount').setValidators(Validators.required); + // A new product's short name is exactly two capital letters. Saved products keep the old 4-character + // rule, so an existing short name such as 'PW16' does not stop the product from being edited. + this.loanProductDetailsForm.get('shortName').setValidators([ + Validators.required, + Validators.pattern(/^[A-Z]{2}$/) + ]); + this.loanProductDetailsForm.get('productTypeGroup').updateValueAndValidity({ emitEvent: false }); + this.loanProductDetailsForm.get('productTypeSubGroup').updateValueAndValidity({ emitEvent: false }); + this.loanProductDetailsForm.get('glAccount').updateValueAndValidity({ emitEvent: false }); + this.loanProductDetailsForm.get('shortName').updateValueAndValidity({ emitEvent: false }); + } + + // A loan / advance is an asset of the bank, so its ledger GL is always an asset account. + this.assetAccountOptions = this.loanProductsTemplate?.accountingMappingOptions?.assetAccountOptions ?? []; + + // Start Date: default to today for a new product and lock the picker to the current day. + // An existing product keeps the wider range so its saved date remains selectable/valid. + if (this.isExistingProduct) { + this.startDateMin = this.minDate; + this.startDateMax = this.maxDate; + this.closeDateMin = this.minDate; + } else { + this.startDateMin = new Date(); + this.startDateMax = new Date(); + const tomorrow = new Date(); + tomorrow.setDate(tomorrow.getDate() + 1); + this.closeDateMin = tomorrow; + } + this.loanProductDetailsForm.patchValue({ name: this.loanProductsTemplate.name, shortName: this.loanProductsTemplate.shortName, description: this.loanProductsTemplate.description, - startDate: this.loanProductsTemplate.startDate && new Date(this.loanProductsTemplate.startDate), + startDate: this.loanProductsTemplate.startDate ? new Date(this.loanProductsTemplate.startDate) : new Date(), closeDate: this.loanProductsTemplate.closeDate && new Date(this.loanProductsTemplate.closeDate) }); - - const mappingOptions = this.loanProductsTemplate?.accountingMappingOptions ?? {}; - this.assetAccountOptions = mappingOptions.assetAccountOptions ?? []; - this.liabilityAccountOptions = mappingOptions.liabilityAccountOptions ?? []; - - this.loanProductDetailsForm.patchValue({ - debitIntCalculation: this.loanProductsTemplate.debitIntCalculation ?? false, - separatePenalInterest: this.loanProductsTemplate.separatePenalInterest ?? false, - creditIntCalculation: this.loanProductsTemplate.creditIntCalculation ?? false, - subAccountTypeBalance: this.loanProductsTemplate.subAccountTypeBalance ?? false, - finalIntRounding: this.loanProductsTemplate.finalIntRounding ?? false, - roundingUpTo: this.loanProductsTemplate.roundingUpTo ?? null - }); } - /** Product Classification data */ - readonly productTypeGroups = [ - { id: 'loans', label: 'Loan' }, - { id: 'advanced', label: 'Advanced' } + /** True when editing a saved product; see ngOnInit. */ + isExistingProduct = false; + + /** Product Type -- picked first, since it decides which sections the rest of the wizard shows. */ + /** Category and type lists are shared with the product list and the loan account product picker. */ + readonly productTypeGroups = PRODUCT_TYPE_GROUPS; + + readonly productTypeSubGroups = PRODUCT_SUB_TYPES; + + /** Regulatory Classification options (UI-only; RBI / bureau reporting). */ + readonly loanProductCategoryOptions = [ + { id: 'advances_deposits', label: 'Advances against deposits' }, + { id: 'gold_loan', label: 'Gold loan' }, + { id: 'agriculture', label: 'Agriculture' }, + { id: 'msme', label: 'MSME' }, + { id: 'personal', label: 'Personal / retail' }, + { id: 'housing', label: 'Housing' }, + { id: 'other', label: 'Other' } ]; - readonly productTypeSubGroups: Record = { - loans: [ - { id: 'emi', label: 'EMI' }, - { id: 'cc', label: 'CC (Cash Credit)' }, - { id: 'overdraft', label: 'Overdraft' }, - { id: 'warehouse', label: 'Warehouse' }, - { id: 'gold', label: 'Gold' } - ], - advanced: [ - { id: 'fd', label: 'FD (Fixed Deposit)' }, - { id: 'rd', label: 'RD (Recurring Deposit)' } - ] - }; + readonly prioritySectorOptions = [ + { id: 'yes', label: 'Yes' }, + { id: 'no', label: 'No' } + ]; - /** Default GL per product type group */ - private readonly glDefaultMap: Record = { - loans: 'asset', - advanced: 'asset' - }; + readonly cibilAccountTypeOptions = [ + { id: 'loan_bank_deposits', label: 'Loan against Bank Deposits' }, + { id: 'gold_loan', label: 'Gold Loan' }, + { id: 'overdraft', label: 'Overdraft' }, + { id: 'business_loan', label: 'Business Loan' }, + { id: 'other', label: 'Other' } + ]; + + readonly yesNoOptions = [ + 'YES', + 'NO' + ]; + + /** The rest of the Details form stays hidden on a new product until its type and sub type are picked. */ + get isProductTypeChosen(): boolean { + if (this.isExistingProduct) { + return true; + } + const value = this.loanProductDetailsForm.value; + return !!value.productTypeGroup && !!value.productTypeSubGroup; + } get availableSubGroups() { const group = this.loanProductDetailsForm.get('productTypeGroup')?.value; return group ? (this.productTypeSubGroups[group] ?? []) : []; } - get glOptions(): string[] { - return [ - 'asset', - 'liability' - ]; - } - + /** + * Asset GLs offered for the product's ledger GL -- the GL the product's loan account numbers are + * generated from. Separate from the Accounting tab mappings, which drive the journal entries. + */ assetAccountOptions: any[] = []; - liabilityAccountOptions: any[] = []; - - get availableGlAccounts(): any[] { - const type = this.loanProductDetailsForm.get('glClassification')?.value; - if (type === 'asset') return this.assetAccountOptions; - if (type === 'liability') return this.liabilityAccountOptions; - return []; - } - - get glClassificationLabel(): string { - const type = this.loanProductDetailsForm.get('glClassification')?.value; - return type === 'liability' ? 'Liability' : 'Asset'; - } get selectedGroupLabel(): string { const group = this.loanProductDetailsForm.get('productTypeGroup')?.value; return this.productTypeGroups.find((g) => g.id === group)?.label ?? ''; } - readonly yesNoOptions = [ - { value: true, label: 'YES' }, - { value: false, label: 'NO' } - ]; + /** + * A new product's short name is two capital letters, so what is typed or pasted is upper-cased, anything + * that is not a letter is dropped, and only the first two letters are kept. Trimming here rather than with + * maxlength means a paste like 'g1old' still yields 'GO' instead of being cut to 'g1' first. + */ + normalizeShortName(event: Event) { + if (this.isExistingProduct) { + return; + } + // Read from the input itself: this handler can run before the form control has taken the new value. + const typed = (event.target as HTMLInputElement).value ?? ''; + const cleaned = typed + .toUpperCase() + .replace(/[^A-Z]/g, '') + .slice(0, 2); + if (cleaned !== typed) { + this.loanProductDetailsForm.get('shortName').setValue(cleaned); + } + } createLoanProductDetailsForm() { this.loanProductDetailsForm = this.formBuilder.group({ @@ -150,21 +205,26 @@ export class LoanProductDetailsStepComponent implements OnInit { closeDate: [''], productTypeGroup: [''], productTypeSubGroup: [''], - glClassification: ['asset'], glAccount: [''], - debitIntCalculation: [false], - separatePenalInterest: [false], - creditIntCalculation: [false], - subAccountTypeBalance: [false], - finalIntRounding: [false], - roundingUpTo: [null] + // Regulatory Classification (UI-only; stripped in buildPayload) + loanProductCategory: ['advances_deposits'], + prioritySector: ['no'], + cibilAccountType: ['loan_bank_deposits'], + kfsEnabled: ['YES'], + aprEnabled: ['YES'] }); - // Auto-set GL based on product type group - this.loanProductDetailsForm.get('productTypeGroup')?.valueChanges.subscribe((group: string) => { - const defaultVal = this.glDefaultMap[group] ?? 'asset'; - this.loanProductDetailsForm.get('glClassification')?.setValue(defaultVal); - this.loanProductDetailsForm.get('glAccount')?.setValue(''); + // A sub type belongs to one group, so changing the group clears it. + this.loanProductDetailsForm.get('productTypeGroup')?.valueChanges.subscribe(() => { + this.loanProductDetailsForm.get('productTypeSubGroup')?.setValue(''); + }); + + // Picking a sub type pre-fills the Short Name with its code; the user can still change it. + this.loanProductDetailsForm.get('productTypeSubGroup')?.valueChanges.subscribe((subGroupId: string) => { + const subGroup = this.availableSubGroups.find((s) => s.id === subGroupId); + if (subGroup) { + this.loanProductDetailsForm.get('shortName').setValue(subGroup.shortName); + } }); } diff --git a/src/app/products/loan-products/loan-product-stepper/loan-product-loan-amount-tenure-step/loan-product-loan-amount-tenure-step.component.html b/src/app/products/loan-products/loan-product-stepper/loan-product-loan-amount-tenure-step/loan-product-loan-amount-tenure-step.component.html new file mode 100644 index 000000000..72b1d72d7 --- /dev/null +++ b/src/app/products/loan-products/loan-product-stepper/loan-product-loan-amount-tenure-step/loan-product-loan-amount-tenure-step.component.html @@ -0,0 +1,150 @@ + + +
+
+
+ +

Loan Amount

+
+
+ + Minimum Amount (₹) + + Minimum Amount is required + + + Default Amount (₹) + + + + Maximum Amount (₹) + + Maximum Amount is required + +
+
+
+ Each loan is sanctioned within these bounds. The amount is fixed at sanction. It is not a limit that can be + drawn and repaid again. +
+
+
+ +
+
+ +

Tenure

+
+
+ + Minimum Tenure (months) + + Minimum Tenure is required + + + Default Tenure (months) + + + + Maximum Tenure (months) + + Age at maturity cap comes from Borrower Eligibility below + Maximum Tenure is required + +
+
+ +
+
+ +

Borrower Eligibility

+
+
+ + Minimum Age (years) + + + + Maximum Age at Maturity (years) + + + + Minimum CIBIL Score + + + + Minimum Monthly Income (₹) + + + + Maximum FOIR / DBR (%) + + + + Maximum Loans per Customer + + +
+
+ +
+
+ +

Disbursement

+
+ + + Single disbursement + Full amount paid out once. + + + Multiple tranches + Paid out in stages, for example as construction progresses. + + +
+ @if (isMultiTranche) { + + Maximum Tranches + + At least 2 tranches + + } + + Disbursement Mode + + @for (opt of disbursementModeOptions; track opt.id) { + {{ opt.label }} + } + + + + Interest on Undisbursed Amount + + @for (opt of undisbursedInterestOptions; track opt.id) { + {{ opt.label }} + } + + +
+
+ + Pre-EMI interest is charged on each disbursed tranche until the first full EMI + +
+
+ +
+ + + Hidden for Loans A/C: Facility Type (Demand / Overdraft), Drawing power, Limit renewal, Cheque + book. + +
+
diff --git a/src/app/products/loan-products/loan-product-stepper/loan-product-loan-amount-tenure-step/loan-product-loan-amount-tenure-step.component.ts b/src/app/products/loan-products/loan-product-stepper/loan-product-loan-amount-tenure-step/loan-product-loan-amount-tenure-step.component.ts new file mode 100644 index 000000000..ff547810f --- /dev/null +++ b/src/app/products/loan-products/loan-product-stepper/loan-product-loan-amount-tenure-step/loan-product-loan-amount-tenure-step.component.ts @@ -0,0 +1,106 @@ +/** + * Copyright since 2025 Mifos Initiative + * + * This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. + */ + +import { Component, inject } from '@angular/core'; +import { UntypedFormGroup, UntypedFormBuilder, Validators } from '@angular/forms'; +import { MatCheckbox } from '@angular/material/checkbox'; +import { MatRadioButton, MatRadioGroup } from '@angular/material/radio'; +import { STANDALONE_SHARED_IMPORTS } from 'app/standalone-shared.module'; + +/** + * Amount & Tenure step for a Loans A/C (EMI) product (step 4): fixed sanctioned amount, tenure, + * borrower eligibility and disbursement. Amount, tenure and tranches feed Fineract's core product + * fields; eligibility is UI-only for now. + */ +@Component({ + selector: 'mifosx-loan-product-loan-amount-tenure-step', + templateUrl: './loan-product-loan-amount-tenure-step.component.html', + styleUrls: ['../advance-steps.scss'], + imports: [ + ...STANDALONE_SHARED_IMPORTS, + MatCheckbox, + MatRadioGroup, + MatRadioButton + ] +}) +export class LoanProductLoanAmountTenureStepComponent { + private formBuilder = inject(UntypedFormBuilder); + + loanProductLoanAmountTenureForm: UntypedFormGroup; + + readonly disbursementModeOptions = [ + { id: 'transfer_dd_vendor', label: 'Transfer to A/C, DD, Direct to vendor' }, + { id: 'transfer', label: 'Transfer to A/C only' }, + { id: 'dd', label: 'Demand draft only' } + ]; + + readonly undisbursedInterestOptions = [ + { id: 'none', label: 'None — interest only on disbursed' }, + { id: 'commitment', label: 'Commitment charge on undisbursed' } + ]; + + constructor() { + this.loanProductLoanAmountTenureForm = this.formBuilder.group({ + minAmount: [ + '', + [ + Validators.required, + Validators.min(0) + ] + ], + defaultAmount: [''], + maxAmount: [ + '', + [ + Validators.required, + Validators.min(0) + ] + ], + minTenure: [ + 12, + [ + Validators.required, + Validators.min(1) + ] + ], + defaultTenure: [ + '', + Validators.min(1) + ], + maxTenure: [ + 360, + [ + Validators.required, + Validators.min(1) + ] + ], + minAge: [21], + maxAgeAtMaturity: [70], + minCibil: [650], + minMonthlyIncome: [''], + maxFoir: [50], + maxLoansPerCustomer: [1], + disbursementType: ['single'], + maxTranches: [ + 2, + Validators.min(2) + ], + disbursementMode: ['transfer_dd_vendor'], + undisbursedInterest: ['none'], + preEmiInterest: [true] + }); + } + + get isMultiTranche(): boolean { + return this.loanProductLoanAmountTenureForm.get('disbursementType').value === 'multiple'; + } + + get loanProductLoanAmountTenure() { + return this.loanProductLoanAmountTenureForm.getRawValue(); + } +} diff --git a/src/app/products/loan-products/loan-product-stepper/loan-product-loan-interest-step/loan-product-loan-interest-step.component.html b/src/app/products/loan-products/loan-product-stepper/loan-product-loan-interest-step/loan-product-loan-interest-step.component.html new file mode 100644 index 000000000..0a08d2380 --- /dev/null +++ b/src/app/products/loan-products/loan-product-stepper/loan-product-loan-interest-step/loan-product-loan-interest-step.component.html @@ -0,0 +1,161 @@ + + +
+
+
+ +

Interest Type

+
+ + + Fixed + Same rate for the whole tenure + + + Floating + Linked to a benchmark; resets periodically + + + Hybrid + Fixed for a period, then floating + + +
+
+ +
+
+ +

Rate Source

+ Centralised — changes flow to all loans on this product +
+
+ +
+
+ + Apply CIBIL Adjustment + + @for (opt of yesNoOptions; track opt) { + {{ opt }} + } + + + + Spread over Register Rate (%) + + + + Rate Resolved At + + @for (opt of rateResolvedAtOptions; track opt.id) { + {{ opt.label }} + } + + +
+
+ +
+
+ +

Calculation

+
+
+ + Calculation Method + + @for (opt of calcMethodOptions; track opt.id) { + {{ opt.label }} + } + + + + Days in Year + + @for (opt of daysInYearOptions; track opt.id) { + {{ opt.label }} + } + + + + Days in Month + + @for (opt of daysInMonthOptions; track opt.id) { + {{ opt.label }} + } + + + + Calculation Period + + @for (opt of calcPeriodOptions; track opt.id) { + {{ opt.label }} + } + + + + Interest Starts From + + @for (opt of interestStartOptions; track opt.id) { + {{ opt.label }} + } + + + + Compounding + + @for (opt of compoundingOptions; track opt.id) { + {{ opt.label }} + } + + +
+
+ + @if (isFloatingOrHybrid) { +
+
+ +

Floating Rate Reset

+ Shown only when the type is Floating or Hybrid +
+
+ + Benchmark + + @for (opt of benchmarkOptions; track opt.id) { + {{ opt.label }} + } + + + + Reset Frequency + + @for (opt of resetFrequencyOptions; track opt.id) { + {{ opt.label }} + } + + + + On Reset, Change + + @for (opt of resetChangeOptions; track opt.id) { + {{ opt.label }} + } + + +
+
+ } +
diff --git a/src/app/products/loan-products/loan-product-stepper/loan-product-loan-interest-step/loan-product-loan-interest-step.component.ts b/src/app/products/loan-products/loan-product-stepper/loan-product-loan-interest-step/loan-product-loan-interest-step.component.ts new file mode 100644 index 000000000..a29bc3534 --- /dev/null +++ b/src/app/products/loan-products/loan-product-stepper/loan-product-loan-interest-step/loan-product-loan-interest-step.component.ts @@ -0,0 +1,188 @@ +/** + * 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, inject } from '@angular/core'; +import { UntypedFormGroup, UntypedFormBuilder, Validators } from '@angular/forms'; +import { MatRadioButton, MatRadioGroup } from '@angular/material/radio'; +import { STANDALONE_SHARED_IMPORTS } from 'app/standalone-shared.module'; +import { RegisterRateSelection, RegisterRateViewComponent } from '../register-rate-view/register-rate-view.component'; + +/** + * Interest step for a Loans A/C (EMI) product (step 6). The rate normally comes from the central + * Loan Interest Rate Register (amount slab + CIBIL adjustment); the min / default / max rate and the + * calculation method feed Fineract's core product fields. Register link, spread and floating reset + * are UI-only for now. + */ +@Component({ + selector: 'mifosx-loan-product-loan-interest-step', + templateUrl: './loan-product-loan-interest-step.component.html', + styleUrls: ['../advance-steps.scss'], + imports: [ + ...STANDALONE_SHARED_IMPORTS, + MatRadioGroup, + MatRadioButton, + RegisterRateViewComponent + ] +}) +export class LoanProductLoanInterestStepComponent { + private formBuilder = inject(UntypedFormBuilder); + + loanProductLoanInterestForm: UntypedFormGroup; + + /** Sub type and GL account picked on the Details step; they choose the Loan Interest Rate Register row. */ + @Input() productSubType = ''; + @Input() glAccountId: number | string | null = null; + + /** The register row the product takes its rate from; null while the register has no row for it. */ + registerSelection: RegisterRateSelection | null = null; + + /** The 16 default particulars of the Loan Interest Rate Register. */ + readonly registerParticulars = [ + 'Housing Loan (Purchase/Construction)', + 'Home Repair/Renovation', + 'Personal Loan (Unsecured)', + 'Personal Loan (Secured/Property)', + 'Two Wheeler Loan', + 'Car Loan (New)', + 'Used Car/Vehicle Loan', + 'Commercial Vehicle Loan', + 'Gold Loan', + 'Business Term Loan', + 'Machinery & Equipment Loan', + 'Cash Credit', + 'Overdraft', + 'Agriculture Loan', + 'Loan against FDR', + 'Loan against RD' + ]; + + readonly yesNoOptions = [ + 'YES', + 'NO' + ]; + + readonly rateResolvedAtOptions = [ + { id: 'sanction', label: 'Sanction' }, + { id: 'disbursement', label: 'First disbursement' } + ]; + + readonly calcMethodOptions = [ + { id: 'declining', label: 'Declining Balance' }, + { id: 'flat', label: 'Flat' } + ]; + + /** `fineractId` maps to Fineract's daysInYearType (1 = actual). */ + readonly daysInYearOptions = [ + { id: 'actual', label: 'Actual (365 / 366)', fineractId: 1 }, + { id: '365', label: '365', fineractId: 365 }, + { id: '360', label: '360', fineractId: 360 } + ]; + + /** `fineractId` maps to Fineract's daysInMonthType (1 = actual). */ + readonly daysInMonthOptions = [ + { id: 'actual', label: 'Actual', fineractId: 1 }, + { id: '30', label: '30', fineractId: 30 } + ]; + + readonly calcPeriodOptions = [ + { id: 'same', label: 'Same as repayment period' }, + { id: 'daily', label: 'Daily' } + ]; + + readonly interestStartOptions = [ + { id: 'disbursement', label: 'Disbursement date' }, + { id: 'first_emi', label: 'First EMI date' } + ]; + + readonly compoundingOptions = [ + { id: 'none', label: 'None (simple on outstanding)' }, + { id: 'monthly', label: 'Monthly' } + ]; + + readonly benchmarkOptions = [ + { id: 'repo', label: 'Repo / MCLR / Bank base rate' }, + { id: 'eblr', label: 'External benchmark (EBLR)' } + ]; + + readonly resetFrequencyOptions = [ + { id: 'quarterly', label: 'Quarterly' }, + { id: 'half_yearly', label: 'Half-yearly' }, + { id: 'yearly', label: 'Yearly' } + ]; + + readonly resetChangeOptions = [ + { id: 'tenure', label: 'Tenure (EMI stays constant)' }, + { id: 'emi', label: 'EMI (tenure stays constant)' } + ]; + + constructor() { + this.loanProductLoanInterestForm = this.formBuilder.group({ + interestType: ['fixed'], + // The rate comes from the Loan Interest Rate Register, so a product with no register row cannot be created. + registerParticular: [ + '', + Validators.required + ], + applyCibilAdjustment: ['YES'], + spread: [ + 0, + [ + Validators.min(0), + Validators.max(20) + ] + ], + rateResolvedAt: ['sanction'], + // Filled from the register row; never typed in. + minRate: [''], + defaultRate: [''], + maxRate: [''], + calcMethod: ['declining'], + daysInYear: ['actual'], + daysInMonth: ['actual'], + calcPeriod: ['same'], + interestStartsFrom: ['disbursement'], + compounding: ['none'], + benchmark: ['repo'], + resetFrequency: ['quarterly'], + resetChange: ['tenure'] + }); + } + + /** + * The register row decides the rate limits. With no row for the product the register particular stays empty, + * which keeps this step, and so the whole product, invalid until the product has been added to the register. + */ + onRegisterSelection(selection: RegisterRateSelection | null): void { + this.registerSelection = selection; + const form = this.loanProductLoanInterestForm; + if (selection) { + form.patchValue( + { + registerParticular: selection.particular, + minRate: selection.minRate ?? '', + maxRate: selection.maxRate ?? '', + defaultRate: selection.defaultRate ?? '' + }, + { emitEvent: false } + ); + } else { + form.patchValue({ registerParticular: '', minRate: '', maxRate: '', defaultRate: '' }, { emitEvent: false }); + } + form.updateValueAndValidity({ emitEvent: false }); + } + + /** Reset options only apply to a floating or hybrid rate. */ + get isFloatingOrHybrid(): boolean { + const type = this.loanProductLoanInterestForm.get('interestType').value; + return type === 'floating' || type === 'hybrid'; + } + + get loanProductLoanInterest() { + return this.loanProductLoanInterestForm.getRawValue(); + } +} diff --git a/src/app/products/loan-products/loan-product-stepper/loan-product-loan-prepayment-step/loan-product-loan-prepayment-step.component.html b/src/app/products/loan-products/loan-product-stepper/loan-product-loan-prepayment-step/loan-product-loan-prepayment-step.component.html new file mode 100644 index 000000000..b17ca654c --- /dev/null +++ b/src/app/products/loan-products/loan-product-stepper/loan-product-loan-prepayment-step/loan-product-loan-prepayment-step.component.html @@ -0,0 +1,144 @@ + + +
+
+
+ +

Part-prepayment

+
+
+ Part-prepayment allowed +
+
+ + Minimum Part-payment (₹) + + + + Allowed Only On + + @for (opt of allowedOnOptions; track opt.id) { + {{ opt.label }} + } + + + + Part-payments per Year + + @for (opt of perYearOptions; track opt.id) { + {{ opt.label }} + } + + +
+ + + Reduce tenure + EMI stays the same + + + Reduce EMI + Tenure stays the same + + + Borrower chooses + Picked at payment time + + +
+
+ +
+
+ +

Pre-closure (Foreclosure)

+ Charge on outstanding principal +
+
+ + Individual · floating rate (%) + + Nil as per RBI + + + Individual · fixed rate (%) + + + + Non-individual / business (%) + + + + Takeover by another bank (%) + + +
+
+ + Lock-in Period (months) + + + + Interest on Pre-closure + + @for (opt of interestOnPreclosureOptions; track opt.id) { + {{ opt.label }} + } + + + + Foreclosure Quote Valid (days) + + +
+
+ +
+
+ +

Closure & Restructuring

+
+
+ + Auto-close on Zero Balance + + @for (opt of autoCloseOptions; track opt) { + {{ opt }} + } + + + + NOC / Closure Letter + + @for (opt of nocOptions; track opt.id) { + {{ opt.label }} + } + + + + Restructuring / Re-schedule + + @for (opt of restructuringOptions; track opt.id) { + {{ opt.label }} + } + + +
+
+
+ Release collateral and lien on closure + Top-up loan on the same account + Allow balance transfer (takeover) out +
+
+
+
Unlike an Advance, there is no renewal. A loan ends at maturity or on pre-closure.
+
+
+
diff --git a/src/app/products/loan-products/loan-product-stepper/loan-product-loan-prepayment-step/loan-product-loan-prepayment-step.component.ts b/src/app/products/loan-products/loan-product-stepper/loan-product-loan-prepayment-step/loan-product-loan-prepayment-step.component.ts new file mode 100644 index 000000000..e5c6487e8 --- /dev/null +++ b/src/app/products/loan-products/loan-product-stepper/loan-product-loan-prepayment-step/loan-product-loan-prepayment-step.component.ts @@ -0,0 +1,117 @@ +/** + * Copyright since 2025 Mifos Initiative + * + * This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. + */ + +import { Component, inject } from '@angular/core'; +import { UntypedFormGroup, UntypedFormBuilder, Validators } from '@angular/forms'; +import { MatCheckbox } from '@angular/material/checkbox'; +import { MatRadioButton, MatRadioGroup } from '@angular/material/radio'; +import { STANDALONE_SHARED_IMPORTS } from 'app/standalone-shared.module'; + +/** + * Prepayment & Closure step for a Loans A/C (EMI) product (step 8). A loan has no renewal; it ends at + * maturity or on pre-closure. UI-only for now: not sent in the create payload. + */ +@Component({ + selector: 'mifosx-loan-product-loan-prepayment-step', + templateUrl: './loan-product-loan-prepayment-step.component.html', + styleUrls: ['../advance-steps.scss'], + imports: [ + ...STANDALONE_SHARED_IMPORTS, + MatCheckbox, + MatRadioGroup, + MatRadioButton + ] +}) +export class LoanProductLoanPrepaymentStepComponent { + private formBuilder = inject(UntypedFormBuilder); + + loanProductLoanPrepaymentForm: UntypedFormGroup; + + readonly allowedOnOptions = [ + { id: 'emi_date', label: 'EMI due date' }, + { id: 'any_day', label: 'Any day' } + ]; + + readonly perYearOptions = [ + { id: 'unlimited', label: 'Unlimited' }, + { id: 'one', label: 'Once a year' }, + { id: 'two', label: 'Twice a year' } + ]; + + readonly interestOnPreclosureOptions = [ + { id: 'till_date', label: 'Till date of payment' }, + { id: 'full_month', label: 'Full current month' } + ]; + + readonly autoCloseOptions = [ + 'YES', + 'NO' + ]; + + readonly nocOptions = [ + { id: 'auto', label: 'Auto-generate' }, + { id: 'manual', label: 'On request' } + ]; + + readonly restructuringOptions = [ + { id: 'approval', label: 'Allowed with approval' }, + { id: 'no', label: 'Not allowed' } + ]; + + constructor() { + const percent = [ + Validators.min(0), + Validators.max(100) + ]; + this.loanProductLoanPrepaymentForm = this.formBuilder.group({ + partPrepaymentAllowed: [true], + minPartPayment: [ + '', + Validators.min(0) + ], + allowedOn: ['emi_date'], + partPaymentsPerYear: ['unlimited'], + partPrepaymentMode: ['reduce_tenure'], + foreclosureFloatingIndividual: [ + 0, + percent + ], + foreclosureFixedIndividual: [ + 2, + percent + ], + foreclosureNonIndividual: [ + 3, + percent + ], + foreclosureTakeover: [ + 2, + percent + ], + lockInMonths: [ + 0, + Validators.min(0) + ], + interestOnPreclosure: ['till_date'], + foreclosureQuoteValidDays: [ + 7, + Validators.min(1) + ], + autoCloseOnZeroBalance: ['YES'], + nocLetter: ['auto'], + restructuring: ['approval'], + releaseCollateralOnClosure: [true], + topUpAllowed: [false], + balanceTransferOutAllowed: [true] + }); + } + + get loanProductLoanPrepayment() { + return this.loanProductLoanPrepaymentForm.getRawValue(); + } +} diff --git a/src/app/products/loan-products/loan-product-stepper/loan-product-loan-repayment-step/loan-product-loan-repayment-step.component.html b/src/app/products/loan-products/loan-product-stepper/loan-product-loan-repayment-step/loan-product-loan-repayment-step.component.html new file mode 100644 index 000000000..4972737f7 --- /dev/null +++ b/src/app/products/loan-products/loan-product-stepper/loan-product-loan-repayment-step/loan-product-loan-repayment-step.component.html @@ -0,0 +1,155 @@ + + +
+
+
+ +

Repayment Type

+
+ + @for (opt of repaymentTypeOptions; track opt.id) { + + {{ opt.label }} + {{ opt.hint }} + + } + +
+
+ This is the main difference from an Advance. Repayment follows a fixed schedule, not a running balance. +
+
+
+ +
+
+ +

Schedule

+
+
+ + Repaid Every + + Repaid Every is required + + + Frequency + + @for (opt of frequencyOptions; track opt.id) { + {{ opt.label }} + } + + + + Minimum Days to First Repayment + + + + EMI Due Day + + @for (opt of emiDueDayOptions; track opt.id) { + {{ opt.label }} + } + + + + If Due Date is a Holiday + + @for (opt of holidayRuleOptions; track opt.id) { + {{ opt.label }} + } + + + + Moratorium / Grace (months) + + + + Interest during Moratorium + + @for (opt of moratoriumInterestOptions; track opt.id) { + {{ opt.label }} + } + + +
+
+ Variable installments allowed +
+
+ +
+
+ +

Repayment Mode

+
+
+ + Repayment Mode + + @for (opt of repaymentModeOptions; track opt.id) { + {{ opt.label }} + } + + + + Linked Account Type + + @for (opt of linkedAccountOptions; track opt.id) { + {{ opt.label }} + } + + + + PDC Collection + + @for (opt of pdcOptions; track opt.id) { + {{ opt.label }} + } + + +
+
+ + Mandate registration is required before the first disbursement + +
+
+
+ Cheques are used here to repay the EMI as post-dated cheques, not to withdraw from the loan. A cheque book is + therefore not part of a Loan A/C. +
+
+
+ +
+
+ +

Appropriation Order

+
+
+ + Appropriation Order + + @for (opt of appropriationOrderOptions; track opt.id) { + {{ opt.label }} + } + + + + Advance EMI Payment + + @for (opt of advanceEmiOptions; track opt.id) { + {{ opt.label }} + } + + +
+
+
diff --git a/src/app/products/loan-products/loan-product-stepper/loan-product-loan-repayment-step/loan-product-loan-repayment-step.component.ts b/src/app/products/loan-products/loan-product-stepper/loan-product-loan-repayment-step/loan-product-loan-repayment-step.component.ts new file mode 100644 index 000000000..162f2cd8d --- /dev/null +++ b/src/app/products/loan-products/loan-product-stepper/loan-product-loan-repayment-step/loan-product-loan-repayment-step.component.ts @@ -0,0 +1,129 @@ +/** + * Copyright since 2025 Mifos Initiative + * + * This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. + */ + +import { Component, inject } from '@angular/core'; +import { UntypedFormGroup, UntypedFormBuilder, Validators } from '@angular/forms'; +import { MatCheckbox } from '@angular/material/checkbox'; +import { MatRadioButton, MatRadioGroup } from '@angular/material/radio'; +import { STANDALONE_SHARED_IMPORTS } from 'app/standalone-shared.module'; + +/** + * Repayment & EMI step for a Loans A/C (EMI) product (step 5) — the step an Advance does not have. + * Repayment type and frequency feed Fineract's core product fields; the repayment mode and the + * rest of the schedule options are UI-only for now. + */ +@Component({ + selector: 'mifosx-loan-product-loan-repayment-step', + templateUrl: './loan-product-loan-repayment-step.component.html', + styleUrls: ['../advance-steps.scss'], + imports: [ + ...STANDALONE_SHARED_IMPORTS, + MatCheckbox, + MatRadioGroup, + MatRadioButton + ] +}) +export class LoanProductLoanRepaymentStepComponent { + private formBuilder = inject(UntypedFormBuilder); + + loanProductLoanRepaymentForm: UntypedFormGroup; + + readonly repaymentTypeOptions = [ + { id: 'emi', label: 'EMI', hint: 'Reducing balance, equal instalments' }, + { id: 'equal_principal', label: 'Equal principal', hint: 'Falling instalments' }, + { id: 'flat', label: 'Flat rate', hint: 'Interest on original amount' }, + { id: 'bullet', label: 'Bullet', hint: 'Pay everything at maturity' }, + { id: 'step', label: 'Step-up / Balloon', hint: 'Custom schedule' } + ]; + + /** `fineractId` is Fineract's repaymentFrequencyType (0 days, 1 weeks, 2 months). */ + readonly frequencyOptions = [ + { id: 'months', label: 'Months', fineractId: 2 }, + { id: 'weeks', label: 'Weeks', fineractId: 1 }, + { id: 'days', label: 'Days', fineractId: 0 } + ]; + + readonly emiDueDayOptions = [ + { id: 'disbursement_day', label: 'Same day as disbursement' }, + { id: 'fixed_day', label: 'Fixed day of month (5th)' } + ]; + + readonly holidayRuleOptions = [ + { id: 'next', label: 'Next working day' }, + { id: 'previous', label: 'Previous working day' }, + { id: 'same', label: 'Same day' } + ]; + + readonly moratoriumInterestOptions = [ + { id: 'pre_emi', label: 'Pay monthly (Pre-EMI)' }, + { id: 'capitalise', label: 'Capitalise into loan' } + ]; + + readonly repaymentModeOptions = [ + { id: 'nach', label: 'Auto-debit (NACH / ECS)' }, + { id: 'pdc', label: 'Post-dated cheques (PDC)' }, + { id: 'cash', label: 'Cash / transfer at branch' } + ]; + + readonly linkedAccountOptions = [ + { id: 'savings_current', label: 'Savings / Current' }, + { id: 'savings', label: 'Savings only' } + ]; + + readonly pdcOptions = [ + { id: 'mandatory', label: 'Mandatory — 3 cheques' }, + { id: 'optional', label: 'Optional' }, + { id: 'none', label: 'Not collected' } + ]; + + readonly appropriationOrderOptions = [ + { id: 'charges_penal_interest_principal', label: 'Charges → Penal → Interest → Principal' }, + { id: 'principal_interest_penal_charges', label: 'Principal → Interest → Penal → Charges' } + ]; + + readonly advanceEmiOptions = [ + { id: 'adjust_next', label: 'Allowed — adjust next EMI' }, + { id: 'not_allowed', label: 'Not allowed' } + ]; + + constructor() { + this.loanProductLoanRepaymentForm = this.formBuilder.group({ + repaymentType: ['emi'], + repaidEvery: [ + 1, + [ + Validators.required, + Validators.min(1) + ] + ], + frequency: ['months'], + minDaysToFirstRepayment: [ + '', + Validators.min(0) + ], + emiDueDay: ['fixed_day'], + holidayRule: ['next'], + moratoriumMonths: [ + 0, + Validators.min(0) + ], + moratoriumInterest: ['pre_emi'], + variableInstallments: [false], + repaymentMode: ['nach'], + linkedAccountType: ['savings_current'], + pdcCollection: ['mandatory'], + mandateRequired: [true], + appropriationOrder: ['charges_penal_interest_principal'], + advanceEmi: ['adjust_next'] + }); + } + + get loanProductLoanRepayment() { + return this.loanProductLoanRepaymentForm.getRawValue(); + } +} diff --git a/src/app/products/loan-products/loan-product-stepper/loan-product-loan-security-step/loan-product-loan-security-step.component.html b/src/app/products/loan-products/loan-product-stepper/loan-product-loan-security-step/loan-product-loan-security-step.component.html new file mode 100644 index 000000000..6650b8f5a --- /dev/null +++ b/src/app/products/loan-products/loan-product-stepper/loan-product-loan-security-step/loan-product-loan-security-step.component.html @@ -0,0 +1,130 @@ + + +
+
+
+ +

Security

+
+
+ + Security Type + + + + Security Mandatory + + @for (opt of securityMandatoryOptions; track opt.id) { + {{ opt.label }} + } + + + + Margin (%) + + Margin must be between 0 and 100 + + + Maximum LTV (%) + + = 100 − Margin + +
+
+ +
+
+ +

Collateral Rules by Security Type

+ Which security this product may accept +
+ + + + + + + + + + + + + @for (rule of collateralRules; track rule.security) { + + + + + + + + + } + +
SecurityAllowedMax LTV %ValuationInsuranceCharge / lien
{{ rule.security }} + {{ rule.allowed ? 'Yes' : 'No' }} + {{ rule.ltv }}{{ rule.valuation }}{{ rule.insurance }}{{ rule.charge }}
+
+ +
+
+ +

Guarantor & Co-applicant

+
+
+ + Guarantors Required (min) + + + + Co-applicant + + @for (opt of coApplicantOptions; track opt.id) { + {{ opt.label }} + } + + + + Third-party Security + + @for (opt of thirdPartyOptions; track opt.id) { + {{ opt.label }} + } + + +
+
+ +
+
+ +

Conditions

+
+
+
+ + Legal opinion / title search mandatory before sanction + + + Property insurance mandatory for the whole tenure + + Register charge (CERSAI) before disbursement + Release security automatically on closure + Allow multiple securities for one loan + Allow third-party deposit as security +
+
+
+
+ Unlike an Advance, the security does not decide a running limit. It only decides eligibility at sanction. + Disbursement happens once, up to the sanctioned amount. +
+
+
+
diff --git a/src/app/products/loan-products/loan-product-stepper/loan-product-loan-security-step/loan-product-loan-security-step.component.ts b/src/app/products/loan-products/loan-product-stepper/loan-product-loan-security-step/loan-product-loan-security-step.component.ts new file mode 100644 index 000000000..39caf70e0 --- /dev/null +++ b/src/app/products/loan-products/loan-product-stepper/loan-product-loan-security-step/loan-product-loan-security-step.component.ts @@ -0,0 +1,127 @@ +/** + * Copyright since 2025 Mifos Initiative + * + * This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. + */ + +import { Component, inject } from '@angular/core'; +import { UntypedFormGroup, UntypedFormBuilder, Validators } from '@angular/forms'; +import { MatCheckbox } from '@angular/material/checkbox'; +import { STANDALONE_SHARED_IMPORTS } from 'app/standalone-shared.module'; + +/** + * Security & Collateral step for a Loans A/C (EMI) product (step 3). + * + * Unlike an Advance, the security only decides eligibility at sanction — it does not set a running + * limit. UI-only for now: not sent in the create payload. + */ +@Component({ + selector: 'mifosx-loan-product-loan-security-step', + templateUrl: './loan-product-loan-security-step.component.html', + styleUrls: ['../advance-steps.scss'], + imports: [ + ...STANDALONE_SHARED_IMPORTS, + MatCheckbox + ] +}) +export class LoanProductLoanSecurityStepComponent { + private formBuilder = inject(UntypedFormBuilder); + + loanProductLoanSecurityForm: UntypedFormGroup; + + readonly securityMandatoryOptions = [ + { id: 'yes', label: 'Yes — no sanction without security' }, + { id: 'no', label: 'No — unsecured' } + ]; + + readonly coApplicantOptions = [ + { id: 'joint_property', label: 'Mandatory for joint property' }, + { id: 'always', label: 'Always mandatory' }, + { id: 'optional', label: 'Optional' } + ]; + + readonly thirdPartyOptions = [ + { id: 'consent', label: 'Allowed with owner consent' }, + { id: 'no', label: 'Not allowed' } + ]; + + /** Reference table: which security a loan on this product may accept. */ + readonly collateralRules = [ + { + security: 'Residential property', + allowed: true, + ltv: '80', + valuation: 'Empanelled valuer', + insurance: 'Mandatory', + charge: 'Mortgage + CERSAI' + }, + { + security: 'Commercial property', + allowed: true, + ltv: '70', + valuation: 'Empanelled valuer', + insurance: 'Mandatory', + charge: 'Mortgage + CERSAI' + }, + { + security: 'Fixed / Recurring Deposit', + allowed: true, + ltv: '90', + valuation: 'Not needed', + insurance: 'Not needed', + charge: 'Lien marked' + }, + { + security: 'LIC policy', + allowed: true, + ltv: '90', + valuation: 'Surrender value', + insurance: 'Not needed', + charge: 'Assignment' + }, + { security: 'Vehicle', allowed: false, ltv: '—', valuation: '—', insurance: '—', charge: '—' }, + { security: 'Gold ornaments', allowed: false, ltv: '—', valuation: '—', insurance: '—', charge: '—' } + ]; + + constructor() { + this.loanProductLoanSecurityForm = this.formBuilder.group({ + securityType: ['Immovable property (mortgage)'], + securityMandatory: ['yes'], + margin: [ + 20, + [ + Validators.required, + Validators.min(0), + Validators.max(100) + ] + ], + guarantorsRequired: [ + 1, + Validators.min(0) + ], + coApplicant: ['joint_property'], + thirdPartySecurity: ['consent'], + legalOpinionRequired: [true], + propertyInsuranceRequired: [true], + registerCharge: [true], + autoReleaseOnClosure: [true], + allowMultipleSecurities: [false], + allowThirdPartyDeposit: [false] + }); + } + + /** Maximum LTV is derived from the margin (100 − margin) and shown read-only. */ + get ltv(): number { + const margin = Number(this.loanProductLoanSecurityForm.get('margin').value); + return Number.isFinite(margin) ? Math.max(0, 100 - margin) : 100; + } + + get loanProductLoanSecurity() { + return { + ...this.loanProductLoanSecurityForm.getRawValue(), + ltv: this.ltv + }; + } +} diff --git a/src/app/products/loan-products/loan-product-stepper/loan-product-penal-npa-step/loan-product-penal-npa-step.component.html b/src/app/products/loan-products/loan-product-stepper/loan-product-penal-npa-step/loan-product-penal-npa-step.component.html new file mode 100644 index 000000000..30c8eda8e --- /dev/null +++ b/src/app/products/loan-products/loan-product-stepper/loan-product-penal-npa-step/loan-product-penal-npa-step.component.html @@ -0,0 +1,106 @@ + + +
+
+
+ +

Penal Charges

+
+
+ + Penal Charges Applicable + + @for (opt of yesNoOptions; track opt) { + {{ opt }} + } + + + + Penal Rate (% p.a.) + + + + Applied On + + @for (opt of appliedOnOptions; track opt.id) { + {{ opt.label }} + } + + + + Penal Grace Days + + +
+
+ RBI: penal charges are kept separate from interest, never capitalised, and shown in the Key Fact Statement. +
+
+
+
+ +
+
+ +

Asset Classification (SMA / NPA)

+
+
+ + Delinquency Bucket (SMA) + + — None — + @for (bucket of delinquencyBucketOptions; track bucket.id) { + {{ bucket.name }} + } + + SMA 0–30 / 31–60 / 61–90 + + + NPA After (days overdue) + + + + NPA Trigger + + @for (opt of npaTriggerOptions; track opt.id) { + {{ opt.label }} + } + + + + Interest after NPA + + @for (opt of interestAfterNpaOptions; track opt.id) { + {{ opt.label }} + } + + + + Grace on Arrears Ageing (days) + + Days before an amount counts as overdue + + + In-Arrears Tolerance (₹) + + Small balance ignored for arrears + +
+
+
+ + Upgrade from NPA only when all arrears are cleared + + + Keep standard while the pledged deposit fully covers the dues + +
+
+
+
diff --git a/src/app/products/loan-products/loan-product-stepper/loan-product-penal-npa-step/loan-product-penal-npa-step.component.ts b/src/app/products/loan-products/loan-product-stepper/loan-product-penal-npa-step/loan-product-penal-npa-step.component.ts new file mode 100644 index 000000000..0df4451de --- /dev/null +++ b/src/app/products/loan-products/loan-product-stepper/loan-product-penal-npa-step/loan-product-penal-npa-step.component.ts @@ -0,0 +1,90 @@ +/** + * Copyright since 2025 Mifos Initiative + * + * This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. + */ + +import { Component, Input, inject } from '@angular/core'; +import { UntypedFormGroup, UntypedFormBuilder, Validators, ReactiveFormsModule } from '@angular/forms'; +import { MatCheckbox } from '@angular/material/checkbox'; +import { STANDALONE_SHARED_IMPORTS } from 'app/standalone-shared.module'; + +/** + * Penal charges & asset-classification (SMA / NPA) step for an Advances A/C product (Figma step 6). + * UI-only for now — not sent in the create payload. + */ +@Component({ + selector: 'mifosx-loan-product-penal-npa-step', + templateUrl: './loan-product-penal-npa-step.component.html', + styleUrls: ['../advance-steps.scss'], + imports: [ + ...STANDALONE_SHARED_IMPORTS, + MatCheckbox + ] +}) +export class LoanProductPenalNpaStepComponent { + private formBuilder = inject(UntypedFormBuilder); + + @Input() loanProductsTemplate: any; + + loanProductPenalNpaForm: UntypedFormGroup; + + readonly yesNoOptions = [ + 'YES', + 'NO' + ]; + + readonly appliedOnOptions = [ + { id: 'overdue', label: 'Overdue amount' }, + { id: 'outstanding', label: 'Full outstanding' } + ]; + + readonly npaTriggerOptions = [ + { id: 'maturity', label: 'Dues overdue after maturity' }, + { id: 'out_of_order', label: 'Out of order 90 days (CC/OD)' } + ]; + + readonly interestAfterNpaOptions = [ + { id: 'undebited', label: 'Move to Undebited Interest GL' }, + { id: 'suspense', label: 'Move to Interest Suspense GL' } + ]; + + get delinquencyBucketOptions(): any[] { + return this.loanProductsTemplate?.delinquencyBucketOptions ?? []; + } + + constructor() { + this.loanProductPenalNpaForm = this.formBuilder.group({ + penalApplicable: ['YES'], + penalRate: [ + 2.0, + [ + Validators.min(0), + Validators.max(100) + ] + ], + penalAppliedOn: ['overdue'], + penalGraceDays: [0], + delinquencyBucketId: [''], + npaDays: [ + 90, + [ + Validators.required, + Validators.min(0) + ] + ], + npaTrigger: ['maturity'], + interestAfterNpa: ['undebited'], + graceOnArrearsAgeing: [0], + inArrearsTolerance: [0], + upgradeOnlyAllArrears: [true], + keepStandardIfDepositCovers: [true] + }); + } + + get loanProductPenalNpa() { + return this.loanProductPenalNpaForm.getRawValue(); + } +} diff --git a/src/app/products/loan-products/loan-product-stepper/loan-product-renewal-closure-step/loan-product-renewal-closure-step.component.html b/src/app/products/loan-products/loan-product-stepper/loan-product-renewal-closure-step/loan-product-renewal-closure-step.component.html new file mode 100644 index 000000000..3a062eb6a --- /dev/null +++ b/src/app/products/loan-products/loan-product-stepper/loan-product-renewal-closure-step/loan-product-renewal-closure-step.component.html @@ -0,0 +1,69 @@ + + +
+
+
+ +

Limit Renewal

+
+
+ + Renewal Allowed + + @for (opt of yesNoOptions; track opt) { + {{ opt }} + } + + + + Renewal Period (months) + + + + Renewal Grace Days + + Days allowed after the renewal date + +
+
+ +
+
+ +

Pre-closure

+
+
+ + Pre-closure Allowed + + @for (opt of preclosureOptions; track opt.id) { + {{ opt.label }} + } + + + + Lock-in Period (months) + + + + Interest on Pre-closure + + @for (opt of preclosureInterestOptions; track opt.id) { + {{ opt.label }} + } + + +
+
+ +
+ + Renewal and pre-closure fees are set once, on the Charges step. +
+
diff --git a/src/app/products/loan-products/loan-product-stepper/loan-product-renewal-closure-step/loan-product-renewal-closure-step.component.ts b/src/app/products/loan-products/loan-product-stepper/loan-product-renewal-closure-step/loan-product-renewal-closure-step.component.ts new file mode 100644 index 000000000..984ade3bc --- /dev/null +++ b/src/app/products/loan-products/loan-product-stepper/loan-product-renewal-closure-step/loan-product-renewal-closure-step.component.ts @@ -0,0 +1,64 @@ +/** + * Copyright since 2025 Mifos Initiative + * + * This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. + */ + +import { Component, inject } from '@angular/core'; +import { UntypedFormGroup, UntypedFormBuilder, Validators, ReactiveFormsModule } from '@angular/forms'; +import { STANDALONE_SHARED_IMPORTS } from 'app/standalone-shared.module'; + +/** + * Limit renewal & pre-closure rules for an Advances A/C product (Figma step 7). Fees live on the + * Charges step. UI-only for now — not sent in the create payload. + */ +@Component({ + selector: 'mifosx-loan-product-renewal-closure-step', + templateUrl: './loan-product-renewal-closure-step.component.html', + styleUrls: ['../advance-steps.scss'], + imports: [...STANDALONE_SHARED_IMPORTS] +}) +export class LoanProductRenewalClosureStepComponent { + private formBuilder = inject(UntypedFormBuilder); + + loanProductRenewalClosureForm: UntypedFormGroup; + + readonly yesNoOptions = [ + 'YES', + 'NO' + ]; + + readonly preclosureOptions = [ + { id: 'anytime', label: 'Anytime' }, + { id: 'after_lockin', label: 'After lock-in period' }, + { id: 'not_allowed', label: 'Not allowed' } + ]; + + readonly preclosureInterestOptions = [ + { id: 'till_closing', label: 'Till closing date (broken period)' }, + { id: 'full_period', label: 'Full current period' } + ]; + + constructor() { + this.loanProductRenewalClosureForm = this.formBuilder.group({ + renewalAllowed: ['YES'], + renewalPeriodMonths: [ + 12, + [ + Validators.required, + Validators.min(1) + ] + ], + renewalGraceDays: [30], + preclosureAllowed: ['anytime'], + lockInMonths: [0], + interestOnPreclosure: ['till_closing'] + }); + } + + get loanProductRenewalClosure() { + return this.loanProductRenewalClosureForm.getRawValue(); + } +} diff --git a/src/app/products/loan-products/loan-product-stepper/loan-product-security-lien-step/loan-product-security-lien-step.component.html b/src/app/products/loan-products/loan-product-stepper/loan-product-security-lien-step/loan-product-security-lien-step.component.html new file mode 100644 index 000000000..c2410aea5 --- /dev/null +++ b/src/app/products/loan-products/loan-product-stepper/loan-product-security-lien-step/loan-product-security-lien-step.component.html @@ -0,0 +1,160 @@ + + +
+ @if (!isAdvance) { +
+ + Security & Lien applies to an Advances A/C product. Choose it on the Details step. +
+ } + + @if (isAdvance) { +
+
+ +

Security & Margin

+
+
+ + Security Type + + From Sub Type — not chosen again + + + + Eligible Amount Basis + + @for (opt of eligibleAmountBasisOptions; track opt.id) { + {{ opt.label }} + } + + Eligible Amount Basis is required + + + + Margin (%) + + 0 – 100. Drawing Power = security value × LTV + @if (loanProductSecurityLienForm.get('margin').hasError('required')) { + Margin is required + } @else { + Margin must be between 0 and 100 + } + + + + LTV (%) + + = 100 − Margin + +
+
+ + @if (isLienSubType) { +
+
+ +

Lien Settings

+ FD · RD · DDS · LIC +
+ +
+

Lien marking

+
+ + Lien Required + + + + + Lien Marking Stage + + @for (opt of lienMarkingStageOptions; track opt.id) { + {{ opt.label }} + } + + + + + Eligible Deposit Products + + @for (product of depositProductOptions; track product) { + {{ product }} + } + + Only accounts of these deposit products can be pledged for this advance + Pick at least one deposit product + +
+
+ +
+

Rules

+
+ + Tenure cannot exceed deposit maturity date + + + Auto-release lien when the advance is closed + + + Allow multiple deposits for one advance + + + Adjust advance from deposit proceeds at maturity + + + Allow third-party deposit (consent letter required) + + + Allow deposit renewal under lien (lien carries forward) + +
+
+ +
+

Lien certificate

+
+ + Lien Certificate Required + + @for (opt of yesNoOptions; track opt) { + {{ opt }} + } + + + + @if (loanProductSecurityLienForm.get('lienCertificateRequired').value === 'YES') { + + Certificate No. Prefix + + Prefix is required + + + + Print on Disbursement + + @for (opt of yesNoOptions; track opt) { + {{ opt }} + } + + + } +
+
+
+ +
+ + Interest basis and spread are set on the Interest step; the lien-certificate fee on the + Charges step. +
+ } + } +
diff --git a/src/app/products/loan-products/loan-product-stepper/loan-product-security-lien-step/loan-product-security-lien-step.component.scss b/src/app/products/loan-products/loan-product-stepper/loan-product-security-lien-step/loan-product-security-lien-step.component.scss new file mode 100644 index 000000000..7d57dfc5e --- /dev/null +++ b/src/app/products/loan-products/loan-product-stepper/loan-product-security-lien-step/loan-product-security-lien-step.component.scss @@ -0,0 +1,117 @@ +/** + * 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/. + */ + +$accent: #0ea5e9; +$border: #e2e8f0; +$gray-50: #f8f9fa; +$gray-200: #e8eaed; +$gray-900: #202124; + +.lsl-section { + border: 1px solid $border; + border-radius: 10px; + overflow: hidden; + background: #fff; + + & + & { + margin-top: 16px; + } + + &__header { + display: flex; + align-items: center; + gap: 10px; + padding: 12px 24px; + background: $gray-50; + border-bottom: 1px solid $gray-200; + + i { + font-size: 14px; + color: $accent; + } + + h3 { + margin: 0; + font-size: 14px; + font-weight: 600; + color: $gray-900; + } + } + + &__hint { + margin-left: auto; + font-size: 12px; + color: #5f6368; + } +} + +.lsl-subsection { + padding: 16px 24px; + + & + & { + border-top: 1px solid $border; + } +} + +.lsl-group { + margin: 0 0 12px; + font-size: 12px; + font-weight: 600; + letter-spacing: 0.06em; + text-transform: uppercase; + color: #5f6368; +} + +.lsl-grid { + display: grid; + grid-template-columns: repeat(2, 1fr); + gap: 4px 16px; + padding: 16px 24px; + + @media (width <= 600px) { + grid-template-columns: 1fr; + } +} + +.lsl-subsection .lsl-grid { + padding: 0; +} + +.lsl-field { + width: 100%; + + &--full { + grid-column: 1 / -1; + } +} + +.lsl-checks { + display: grid; + grid-template-columns: repeat(2, 1fr); + gap: 12px 32px; + + @media (width <= 600px) { + grid-template-columns: 1fr; + } +} + +.lsl-note { + display: flex; + align-items: center; + gap: 8px; + margin-top: 16px; + padding: 12px 24px; + border: 1px dashed $border; + border-radius: 10px; + font-size: 13px; + color: #5f6368; + + i { + color: $accent; + } +} diff --git a/src/app/products/loan-products/loan-product-stepper/loan-product-security-lien-step/loan-product-security-lien-step.component.ts b/src/app/products/loan-products/loan-product-stepper/loan-product-security-lien-step/loan-product-security-lien-step.component.ts new file mode 100644 index 000000000..d0327c94a --- /dev/null +++ b/src/app/products/loan-products/loan-product-stepper/loan-product-security-lien-step/loan-product-security-lien-step.component.ts @@ -0,0 +1,196 @@ +/** + * Copyright since 2025 Mifos Initiative + * + * This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. + */ + +import { Component, OnInit, Input, inject } from '@angular/core'; +import { AbstractControl, UntypedFormGroup, UntypedFormBuilder, Validators, ReactiveFormsModule } from '@angular/forms'; +import { MatTooltip } from '@angular/material/tooltip'; +import { MatCheckbox } from '@angular/material/checkbox'; +import { STANDALONE_SHARED_IMPORTS } from 'app/standalone-shared.module'; + +/** + * Security & Lien step, shown only for an "Advances A/C" product. + * + * For a deposit-backed advance (Loan against FD / RD / DDS / LIC) the bank marks a lien on the + * customer's own deposit. This step captures the product-level rules for that: the margin (from which + * the LTV and drawing power follow), which deposit products may be pledged, and the lien certificate. + * + * UI-only for now: its values are NOT sent in the create payload, because the backend has no column to + * store them yet. Once the satellite table lands, wire `loanProductSecurityLien` into the payload. + */ +@Component({ + selector: 'mifosx-loan-product-security-lien-step', + templateUrl: './loan-product-security-lien-step.component.html', + styleUrls: ['./loan-product-security-lien-step.component.scss'], + imports: [ + ...STANDALONE_SHARED_IMPORTS, + MatTooltip, + MatCheckbox + ] +}) +export class LoanProductSecurityLienStepComponent implements OnInit { + private formBuilder = inject(UntypedFormBuilder); + + @Input() loanProductsTemplate: any; + /** The Details step's Product Type controls, so this step reacts to the chosen group / sub type. */ + @Input() productTypeGroup: AbstractControl; + @Input() productTypeSubGroup: AbstractControl; + + loanProductSecurityLienForm: UntypedFormGroup; + + /** Sub type -> the security it is backed by. Only deposit / policy sub types get a lien section. */ + private readonly securityTypeBySubGroup: Record = { + fd: 'Fixed Deposit', + rd: 'Recurring Deposit', + dds: 'Daily Deposit Scheme', + lic: 'LIC Policy', + gold: 'Gold Ornaments', + warehouse: 'Warehouse Receipt', + cc: 'Stock / Book Debts', + overdraft: 'Deposit / Property' + }; + + /** Sub types whose security is lien-marked on a deposit (rather than pledged / hypothecated). */ + private readonly lienSubGroups = [ + 'fd', + 'rd', + 'dds', + 'lic' + ]; + + /** Default margin % per sub type (bank policy in real use); LTV is derived from it. */ + private readonly defaultMarginBySubGroup: Record = { + fd: 10, + rd: 10, + dds: 10, + lic: 40 + }; + + readonly eligibleAmountBasisOptions = [ + { id: 'balance_accrued', label: 'Deposit balance + accrued interest' }, + { id: 'balance', label: 'Deposit balance only' }, + { id: 'surrender', label: 'Surrender value (LIC)' } + ]; + + readonly lienMarkingStageOptions = [ + { id: 'disbursement', label: 'At Disbursement' }, + { id: 'sanction', label: 'At Sanction' } + ]; + + readonly depositProductOptions = [ + 'Fixed Deposit – Regular', + 'Fixed Deposit – Senior Citizen', + 'Recurring Deposit – Regular', + 'Daily Deposit Scheme' + ]; + + readonly yesNoOptions = [ + 'YES', + 'NO' + ]; + + constructor() { + this.createForm(); + } + + ngOnInit() { + this.applySubGroup(this.productTypeSubGroup?.value); + // Keep Security Type and the default margin in step with the sub type chosen on the Details step. + this.productTypeSubGroup?.valueChanges.subscribe((subGroup: string) => this.applySubGroup(subGroup)); + } + + private applySubGroup(subGroup: string): void { + this.loanProductSecurityLienForm + .get('securityType') + .setValue(this.securityTypeBySubGroup[subGroup] ?? '', { emitEvent: false }); + const margin = this.defaultMarginBySubGroup[subGroup]; + if (margin != null && !this.loanProductSecurityLienForm.get('margin').dirty) { + this.loanProductSecurityLienForm.get('margin').setValue(margin, { emitEvent: false }); + } + } + + /** This whole step is only relevant to an Advances A/C product. */ + get isAdvance(): boolean { + return this.productTypeGroup?.value === 'advances'; + } + + /** The lien block shows only for deposit / policy sub types; gold, warehouse, CC etc. are pledged. */ + get isLienSubType(): boolean { + return this.lienSubGroups.includes(this.productTypeSubGroup?.value); + } + + /** LTV = 100 − margin, shown read-only next to the margin. */ + get ltv(): number | null { + const margin = this.loanProductSecurityLienForm.get('margin').value; + if (margin === null || margin === '' || isNaN(margin)) { + return null; + } + return 100 - Number(margin); + } + + createForm() { + this.loanProductSecurityLienForm = this.formBuilder.group({ + securityType: [{ value: '', disabled: true }], + eligibleAmountBasis: [ + 'balance_accrued', + Validators.required + ], + margin: [ + 10, + [ + Validators.required, + Validators.min(0), + Validators.max(100) + ] + ], + lienMarkingStage: [ + 'disbursement', + Validators.required + ], + eligibleDepositProducts: [ + ['Fixed Deposit – Regular'], + Validators.required + ], + tenureNotExceedMaturity: [true], + autoReleaseOnClosure: [true], + allowMultipleDeposits: [false], + adjustFromMaturity: [true], + allowThirdPartyDeposit: [false], + allowRenewalUnderLien: [true], + lienCertificateRequired: ['YES'], + lienCertificatePrefix: [ + 'LC/FD/', + Validators.required + ], + printOnDisbursement: ['YES'] + }); + } + + /** Flat values for the payload. Not wired into submit yet — see the class comment. */ + get loanProductSecurityLien() { + const value = this.loanProductSecurityLienForm.getRawValue(); + return { + securityType: value.securityType, + eligibleAmountBasis: value.eligibleAmountBasis, + margin: value.margin, + ltv: this.ltv, + lienMarkingStage: value.lienMarkingStage, + eligibleDepositProducts: value.eligibleDepositProducts, + lienRules: { + tenureNotExceedMaturity: value.tenureNotExceedMaturity, + autoReleaseOnClosure: value.autoReleaseOnClosure, + allowMultipleDeposits: value.allowMultipleDeposits, + adjustFromMaturity: value.adjustFromMaturity, + allowThirdPartyDeposit: value.allowThirdPartyDeposit, + allowRenewalUnderLien: value.allowRenewalUnderLien + }, + lienCertificateRequired: value.lienCertificateRequired, + lienCertificatePrefix: value.lienCertificatePrefix, + printOnDisbursement: value.printOnDisbursement + }; + } +} diff --git a/src/app/products/loan-products/loan-product-stepper/loan-product-settings-step/loan-product-settings-step.component.html b/src/app/products/loan-products/loan-product-stepper/loan-product-settings-step/loan-product-settings-step.component.html index 5853a8895..c255df105 100644 --- a/src/app/products/loan-products/loan-product-stepper/loan-product-settings-step/loan-product-settings-step.component.html +++ b/src/app/products/loan-products/loan-product-stepper/loan-product-settings-step/loan-product-settings-step.component.html @@ -7,1100 +7,1212 @@ -->
- -
-
- -

- {{ 'labels.inputs.products.loan.Amortization' | translate }} & - {{ 'labels.inputs.Interest Method' | translate }} -

-
-
- - {{ 'labels.inputs.products.loan.Amortization' | translate }} - - @for (amortizationType of amortizationTypeData; track amortizationType) { - - {{ 'labels.inputs.products.loan.amortizationType.' + amortizationType.value | translate }} - - } - - - {{ 'labels.inputs.products.loan.Amortization' | translate }} {{ 'labels.commons.is' | translate }} - {{ 'labels.commons.required' | translate }} - - - - - {{ 'labels.inputs.Interest Method' | translate }} - - @for (interestType of interestTypeData; track interestType) { - - {{ interestType.value | translateKey: 'catalogs' }} - - } - - - {{ 'labels.inputs.Interest Method' | translate }} {{ 'labels.commons.is' | translate }} - {{ 'labels.commons.required' | translate }} - - - - - {{ 'labels.inputs.Interest calculation period' | translate }} - - @for ( - interestCalculationPeriodType of interestCalculationPeriodTypeData; - track interestCalculationPeriodType - ) { - - {{ interestCalculationPeriodType.value | translateKey: 'catalogs' }} - - } - - - {{ 'labels.inputs.Interest calculation period' | translate }} {{ 'labels.commons.is' | translate }} - {{ 'labels.commons.required' | translate }} - - - -
- - {{ 'labels.inputs.products.loan.Is Equal Amortization?' | translate }} - -
- - @if (loanProductSettingsForm.value.interestCalculationPeriodType === 1) { -
- - {{ 'labels.inputs.Calculate interest for exact days in partial period' | translate }} - -
- } -
-
- - -
-
- -

{{ 'labels.inputs.Loan Schedule' | translate }}

-
-
- - {{ 'labels.inputs.Loan Schedule Type' | translate }} - - @for (loanScheduleType of loanScheduleTypeData; track loanScheduleType) { - - {{ loanScheduleType.value | translateKey: 'catalogs' }} - - } - - - - - {{ 'labels.inputs.Repayment strategy' | translate }} - - @for ( - transactionProcessingStrategy of transactionProcessingStrategyData; - track transactionProcessingStrategy - ) { - - {{ transactionProcessingStrategy.name | translateKey: 'catalogs' }} - - } - - - {{ 'labels.inputs.Repayment strategy' | translate }} {{ 'labels.commons.is' | translate }} - {{ 'labels.commons.required' | translate }} - - - - @if (isAdvancedTransactionProcessingStrategy) { + + + + + + + {{ 'labels.inputs.products.loan.Amortization' | translate }} & + {{ 'labels.inputs.Interest Method' | translate }} + + +
- {{ 'labels.inputs.Loan Schedule Processing Type' | translate }} - - @for (loanScheduleProcessingType of loanScheduleProcessingTypeData; track loanScheduleProcessingType) { - - {{ loanScheduleProcessingType.value | translateKey: 'catalogs' }} - - } - - - } - - - {{ 'labels.inputs.Interest free period' | translate }} - - - - - Allowed pending EMI amount - - - - - {{ 'labels.inputs.Days in year' | translate }} - - @for (daysInYearType of daysInYearTypeData; track daysInYearType) { - - {{ daysInYearType.value | translateKey: 'catalogs' }} - - } - - - {{ 'labels.inputs.Days in year' | translate }} {{ 'labels.commons.is' | translate }} - {{ 'labels.commons.required' | translate }} - - - - @if (useDaysInYearCustomStrategy) { - - {{ 'labels.inputs.Days in year custom strategy' | translate }} + {{ 'labels.inputs.products.loan.Amortization' | translate }} - @for (daysInYearCustomStrategy of daysInYearCustomStrategyOptions; track daysInYearCustomStrategy) { - - {{ daysInYearCustomStrategy.value | translateKey: 'catalogs' }} + @for (amortizationType of amortizationTypeData; track amortizationType) { + + {{ 'labels.inputs.products.loan.amortizationType.' + amortizationType.value | translate }} } + + {{ 'labels.inputs.products.loan.Amortization' | translate }} {{ 'labels.commons.is' | translate }} + {{ 'labels.commons.required' | translate }} + - } - - {{ 'labels.inputs.Days in month' | translate }} - - @for (daysInMonthType of daysInMonthTypeData; track daysInMonthType) { - - {{ daysInMonthType.value | translateKey: 'catalogs' }} - - } - - - {{ 'labels.inputs.Days in month' | translate }} {{ 'labels.commons.is' | translate }} - {{ 'labels.commons.required' | translate }} - - - -
- - {{ 'labels.inputs.Allow fixing of the installment amount' | translate }} - -
-
-
- - -
-
- -

Cheque Book Settings

-
-
- - Cheque Book Issue Enabled - - YES - NO - - - - - Cheque Validation Required - - YES - NO - - - - - Is First Cheque Book Free - - YES - NO - - - - - Cheque Printing Account Type - - Savings Bank - Current Account - Cash Credit - Overdraft - - - - - Allow Withdrawal Slip Usage - - YES - NO - - - - - Maintain Cheque Book Stock Registry - - YES - NO - - - - - Free Cheque Books Per Year - - - - - Allow Credit By Cheque Number - - YES - NO - - - - - Credit Cheque Validation Required - - YES - NO - - - - - Grace Days For Stock Statement - - -
-
- - -
-
- -

Additional Product Settings

-
-
- - Account Type Active For Transactions - - YES - NO - - - - - Sub Type Activity Enabled - - YES - NO - - - - - Account Opening Charges - - - - - Maximum Gold Loan Limit - - - - - Last Account Number Opened - - - - - Customer Termination Allowed - - YES - NO - - - - - Charges For Cash Credit Above Limit - - - - - Death Interest GL Account - - - - - Starting Ledger Number - - - - - Ending Ledger Number - - - - - Current Ledger Number - - - - - Remittance Account Number - - - - - Is Non Resident Indian Account - - YES - NO - - - - - Debit Clearing GL Account - - - - - Missed Call Alert Service Active - - YES - NO - - - - - Standing Instruction Charge GL Account - - - - - Standing Instruction Debit Charge Skip Option - - Skip Always - Skip on First Failure - Never Skip - Retry after 24 Hours - - - - - Opening Balance Multiplier For Remittance Charges - - -
-
- - -
-
- -

Interest Related Parameters

-
-
- - Drawing Power And Limit Generation Procedure - - Normal D.P. Entry Is Considered - Against F.D. Interest with Certificate Wise Interest Rate + {{ 'labels.inputs.Interest Method' | translate }} + - Based on Asset Value - Monthly Stock Statement Basis - - + @for (interestType of interestTypeData; track interestType) { + + {{ interestType.value | translateKey: 'catalogs' }} + + } + + + {{ 'labels.inputs.Interest Method' | translate }} {{ 'labels.commons.is' | translate }} + {{ 'labels.commons.required' | translate }} + + - - Advances Interest Calculation Method - - Debit Closing Balance Considered as Product + {{ 'labels.inputs.Interest calculation period' | translate }} + - - + @for ( + interestCalculationPeriodType of interestCalculationPeriodTypeData; + track interestCalculationPeriodType + ) { + + {{ interestCalculationPeriodType.value | translateKey: 'catalogs' }} + + } + + + {{ 'labels.inputs.Interest calculation period' | translate }} {{ 'labels.commons.is' | translate }} + {{ 'labels.commons.required' | translate }} + + - - Against Interest Calculation Enabled - - YES - NO - - - - - Day Wise Against Clearing Interest Rounding - - YES - NO - - - - - Interest Calculation One Fourth Method - - 0 (Standard) - 1 (Custom A) - 2 (Custom B) - - - - - Against Clearing Interest Procedure Type - - Normal Against Clearing Interest - - - - - Consider Debit Closing Amount For Against Clearing - - YES - NO - - - - - Interest Rounding Method - - All Int. Round - Nearest Rupee - Round Up - Round Down - - - - - Penalty Interest Calculation Enabled - - YES - NO - - - - - Penalty Interest Calculation Method - - Daywise ( 36500 ) - - - - - Penalty Interest Procedure Number - - On Debit, Penalty Product is Closing Balance - Limit is Considered for Penalty Product - - - - - Overdrawn Interest Calculation Method - - Overdrawn = Balance - Limit - Overdrawn = Balance - D.P. - Don't Calculate - - - - - Calculate Overdrawn Interest After Renewal - - YES - NO - - - - - Interest On Credit Balance - - YES - NO - - - - - Penalty Interest Date Different Than Regular Interest - - YES - NO - - - - - Fixed Deposit Overdraft Certificate Sort Order - - Order on Interest Rate - Order on Drawing Power - - - - - Use Document Submission Date For Interest - - YES - NO - - - - - Loan Management System Renewal Penalty Active - - YES - NO - - - - - Minimum Normal Interest Amount - - - - - Minimum Interest Calculation Method - - Flat Amount - Percentage of Balance - Tier Based - - - - - Minimum Against Clearing Interest Amount - - - - - Limit Increase Upto Interest Amount In Interest Calculation - - YES - NO - - - - - Minimum Penalty Interest Amount - - - - - Days For Limit Increase Upto Interest Amount - - - - - Additional Interest Rate For Fixed Deposit Overdraft - - - - - Consider DD/Payorder In Against Clearing Interest Calculation - - YES - NO - - - - - Grace Days After Renewal Date - - - - - Apply Undebited Interest Concept Enabled - - YES - NO - - - - - Interest Register Print Format Type - - Interest Amount with Opening Balance - Interest Only - Full Ledger Format - - - - - Normal Grace Days After Overdue - - - - - Allow Against Clearing Entry - - YES - NO - - - - - Allow No Balance Entry - - YES - NO - - - - - Fixed Deposit Overdraft Penalty Interest Combined With Normal Interest - - YES - NO - - - - - Allow No Balance Entry With Password - - YES - NO - - - - - Undebited Simple Interest Account Wise - - YES - NO - - - - - Daily Interest Calculation Enabled - - YES - NO - - - - - Daily Interest Calculation Start Date - - - - - Non Performing Asset Penal Interest Rate Update - - YES - NO - - - - - Non Performing Asset Penal Interest Rate - - - - - Fixed Deposit Overdraft Penal Interest Rate Update In Branch Process - - YES - NO - - - - - Add Penal Interest Rate - - -
-
- - -
-
- -

RBI Compliance & KFS Settings

-
-
- - Penal Charge Method - - RBI Compliant Penal Charges (Non-Compounding) - Legacy Penal Interest (Compounding) - - - -
- - Is Floating Rate Loan? (Individual) - -
- - - Foreclosure Charges Applicable - - YES - NO - - RBI: No charges for Floating Rate Loans to Individuals - - -
- - Enable Key Fact Statement (KFS) - -
- -
- - Calculate APR - -
-
-
- - -
-
- -

Credit Bureau Settings (CIBIL/Highmark)

-
-
- - Loan Product Category (RBI Reporting) - - Personal/Consumer Loan (Code 02) - Housing/Mortgage Loan (Code 15) - Business/Commercial Loan (Code 50) - Vehicle/Auto Loan (Code 04) - - Selecting a category will auto-fill the credit bureau account types below. - - - CIBIL Account Type - - - - - CIBIL Commercial Account Type - - - - - Highmark Account Type - - - - - Highmark Commercial Account Type - - - - - Experian Account Type - - - - - Experian Commercial Account Type - - - - - Equifax Account Type - - - - - Equifax Commercial Account Type - - -
-
- - -
-
- -

- {{ 'labels.inputs.Moratorium' | translate }} - -

-
-
- - {{ 'labels.inputs.Grace on principal payment' | translate }} - - - - - {{ 'labels.inputs.Grace on interest payment' | translate }} - - - - - {{ 'labels.inputs.Delinquency Bucket' | translate }} - - @for (delinquencyBucket of delinquencyBucketData; track delinquencyBucket) { - - {{ delinquencyBucket.name }} - - } - - @if (loanProductSettingsForm.controls.delinquencyBucketId) { - - } - - - @if (loanProductSettingsForm.value.delinquencyBucketId) { -
- - {{ 'labels.inputs.Enable installment level Delinquency' | translate }} +
+ + {{ 'labels.inputs.products.loan.Is Equal Amortization?' | translate }}
- } -
-
- -
-
- -

- {{ 'labels.inputs.Number of days a loan may be overdue before moving into arrears' | translate }} -

-
-
- - {{ 'labels.inputs.On arrears ageing' | translate }} - - - - - {{ 'labels.inputs.Overdue days for NPA' | translate }} - - - -
- - {{ 'labels.inputs.Account moves out of NPA only after all arrears have been cleared' | translate }} - + @if (loanProductSettingsForm.value.interestCalculationPeriodType === 1) { +
+ + {{ 'labels.inputs.Calculate interest for exact days in partial period' | translate }} + +
+ }
+ - - {{ 'labels.inputs.Principal Threshold (%) for Last Instalment' | translate }} - - -
-
- - -
-
- -

{{ 'labels.inputs.Variable Installments' | translate }}

-
-
-
- - {{ 'labels.inputs.Are Variable Installments allowed?' | translate }} - -
- - @if (loanProductSettingsForm.value.allowVariableInstallments) { + + + + + + {{ 'labels.inputs.Loan Schedule' | translate }} + + +
- {{ 'labels.inputs.Minimum gap between Installments' | translate }} - - - {{ 'labels.inputs.Minimum gap between Installments' | translate }} {{ 'labels.commons.is' | translate }} - {{ 'labels.commons.required' | translate }} - + {{ 'labels.inputs.Loan Schedule Type' | translate }} + + @for (loanScheduleType of loanScheduleTypeData; track loanScheduleType) { + + {{ loanScheduleType.value | translateKey: 'catalogs' }} + + } + + - {{ 'labels.inputs.Maximum gap between Installments' | translate }} - - - {{ 'labels.inputs.Maximum gap between Installments' | translate }} {{ 'labels.commons.is' | translate }} - {{ 'labels.commons.required' | translate }} - - - } - -
- - {{ 'labels.inputs.Allowed to be used for providing Topup Loans' | translate }} - -
-
-
- - -
-
- -

{{ 'labels.heading.Interest Recalculation' | translate }}

-
-
-
- - {{ 'labels.inputs.Recalculate Interest' | translate }} - -
- - @if (loanProductSettingsForm.value.isInterestRecalculationEnabled) { - - {{ 'labels.inputs.Pre-closure interest calculation rule' | translate }} - + {{ 'labels.inputs.Repayment strategy' | translate }} + @for ( - preClosureInterestCalculationStrategy of preClosureInterestCalculationStrategyData; - track preClosureInterestCalculationStrategy + transactionProcessingStrategy of transactionProcessingStrategyData; + track transactionProcessingStrategy ) { - - {{ preClosureInterestCalculationStrategy.value | translateKey: 'catalogs' }} + + {{ transactionProcessingStrategy.name | translateKey: 'catalogs' }} } - {{ 'labels.inputs.Pre-closure interest calculation rule' | translate }} - {{ 'labels.commons.is' | translate }} + {{ 'labels.inputs.Repayment strategy' | translate }} {{ 'labels.commons.is' | translate }} {{ 'labels.commons.required' | translate }} - - {{ 'labels.inputs.Advance payments adjustment type' | translate }} - - @for (rescheduleStrategyType of rescheduleStrategyTypeData; track rescheduleStrategyType) { - - {{ rescheduleStrategyType.value | translateKey: 'catalogs' }} - - } - - - {{ 'labels.inputs.Advance payments adjustment type' | translate }} {{ 'labels.commons.is' | translate }} - {{ 'labels.commons.required' | translate }} - - - - - {{ 'labels.inputs.Interest recalculation compounding on' | translate }} - - @for ( - interestRecalculationCompoundingType of interestRecalculationCompoundingTypeData; - track interestRecalculationCompoundingType - ) { - - {{ interestRecalculationCompoundingType.value | translateKey: 'catalogs' }} - - } - - - {{ 'labels.inputs.Interest recalculation compounding on' | translate }} - {{ 'labels.commons.is' | translate }} - {{ 'labels.commons.required' | translate }} - - - - - @if ( - loanProductSettingsForm.value.interestRecalculationCompoundingMethod !== 0 && - loanProductSettingsForm.value.recalculationCompoundingFrequencyType !== 1 - ) { + @if (isAdvancedTransactionProcessingStrategy) { - {{ 'labels.inputs.Frequency Interval for compounding' | translate }} - + {{ 'labels.inputs.Loan Schedule Processing Type' | translate }} + + @for (loanScheduleProcessingType of loanScheduleProcessingTypeData; track loanScheduleProcessingType) { + + {{ loanScheduleProcessingType.value | translateKey: 'catalogs' }} + + } + + + } + + + {{ 'labels.inputs.Interest free period' | translate }} + + + + + Allowed pending EMI amount + + + + @if (!isAdvanceAccount) { + + {{ 'labels.inputs.Days in year' | translate }} + + @for (daysInYearType of daysInYearTypeData; track daysInYearType) { + + {{ daysInYearType.value | translateKey: 'catalogs' }} + + } + - {{ 'labels.inputs.Frequency Interval for compounding' | translate }} - {{ 'labels.commons.is' | translate }} + {{ 'labels.inputs.Days in year' | translate }} {{ 'labels.commons.is' | translate }} + {{ 'labels.commons.required' | translate }} + + + + @if (useDaysInYearCustomStrategy) { + + {{ 'labels.inputs.Days in year custom strategy' | translate }} + + @for (daysInYearCustomStrategy of daysInYearCustomStrategyOptions; track daysInYearCustomStrategy) { + + {{ daysInYearCustomStrategy.value | translateKey: 'catalogs' }} + + } + + + } + + + {{ 'labels.inputs.Days in month' | translate }} + + @for (daysInMonthType of daysInMonthTypeData; track daysInMonthType) { + + {{ daysInMonthType.value | translateKey: 'catalogs' }} + + } + + + {{ 'labels.inputs.Days in month' | translate }} {{ 'labels.commons.is' | translate }} {{ 'labels.commons.required' | translate }} } - @if (loanProductSettingsForm.value.recalculationRestFrequencyType !== 1) { + + @if (isAdvanceAccount) { - {{ 'labels.inputs.Frequency Interval for recalculation' | translate }} - + {{ 'labels.inputs.Loan Period' | translate }} + + @for (loanPeriod of loanPeriodOptions; track loanPeriod.value) { + + {{ loanPeriod.label }} + + } + - {{ 'labels.inputs.Frequency Interval for recalculation' | translate }} - {{ 'labels.commons.is' | translate }} {{ 'labels.commons.required' | translate }} + {{ 'labels.inputs.Loan Period' | translate }} {{ 'labels.commons.is' | translate }} + {{ 'labels.commons.required' | translate }} } - - @if (loanProductSettingsForm.value.interestRecalculationCompoundingMethod !== 0) { + @if (!isAdvanceAccount) { +
+ + {{ 'labels.inputs.Allow fixing of the installment amount' | translate }} + +
+ } +
+ + + + + + + + Cheque Book Settings + + +
+ + Cheque Book Issue Enabled + + YES + NO + + + + + Cheque Validation Required + + YES + NO + + + + + Is First Cheque Book Free + + YES + NO + + + + + Cheque Printing Account Type + + Savings Bank + Current Account + Cash Credit + Overdraft + + + + + Allow Withdrawal Slip Usage + + YES + NO + + + + + Maintain Cheque Book Stock Registry + + YES + NO + + + + + Free Cheque Books Per Year + + + + + Allow Credit By Cheque Number + + YES + NO + + + + + Credit Cheque Validation Required + + YES + NO + + + + + Grace Days For Stock Statement + + +
+
+ + + + + + + Additional Product Settings + + +
+ + Account Type Active For Transactions + + YES + NO + + + + + Sub Type Activity Enabled + + YES + NO + + + + + Account Opening Charges + + + + + Maximum Gold Loan Limit + + + + + Last Account Number Opened + + + + + Customer Termination Allowed + + YES + NO + + + + + Charges For Cash Credit Above Limit + + + + + Death Interest GL Account + + + + + Starting Ledger Number + + + + + Ending Ledger Number + + + + + Current Ledger Number + + + + + Remittance Account Number + + + + + Is Non Resident Indian Account + + YES + NO + + + + + Debit Clearing GL Account + + + + + Missed Call Alert Service Active + + YES + NO + + + + + Standing Instruction Charge GL Account + + + + + Standing Instruction Debit Charge Skip Option + + Skip Always + Skip on First Failure + Never Skip + Retry after 24 Hours + + + + + Opening Balance Multiplier For Remittance Charges + + +
+
+ + + + + + + Interest Related Parameters + + +
+ + Drawing Power And Limit Generation Procedure + + Normal D.P. Entry Is Considered + Against F.D. Interest with Certificate Wise Interest Rate + Based on Asset Value + Monthly Stock Statement Basis + + + + + Advances Interest Calculation Method + + Debit Closing Balance Considered as Product + + + + + Against Interest Calculation Enabled + + YES + NO + + + + + Day Wise Against Clearing Interest Rounding + + YES + NO + + + + + Interest Calculation One Fourth Method + + 0 (Standard) + 1 (Custom A) + 2 (Custom B) + + + + + Against Clearing Interest Procedure Type + + Normal Against Clearing Interest + + + + + Consider Debit Closing Amount For Against Clearing + + YES + NO + + + + + Interest Rounding Method + + All Int. Round + Nearest Rupee + Round Up + Round Down + + + + + Penalty Interest Calculation Enabled + + YES + NO + + + + + Penalty Interest Calculation Method + + Daywise ( 36500 ) + + + + + Penalty Interest Procedure Number + + On Debit, Penalty Product is Closing Balance + Limit is Considered for Penalty Product + + + + + Overdrawn Interest Calculation Method + + Overdrawn = Balance - Limit + Overdrawn = Balance - D.P. + Don't Calculate + + + + + Calculate Overdrawn Interest After Renewal + + YES + NO + + + + + Interest On Credit Balance + + YES + NO + + + + + Penalty Interest Date Different Than Regular Interest + + YES + NO + + + + + Fixed Deposit Overdraft Certificate Sort Order + + Order on Interest Rate + Order on Drawing Power + + + + + Use Document Submission Date For Interest + + YES + NO + + + + + Loan Management System Renewal Penalty Active + + YES + NO + + + + + Minimum Normal Interest Amount + + + + + Minimum Interest Calculation Method + + Flat Amount + Percentage of Balance + Tier Based + + + + + Minimum Against Clearing Interest Amount + + + + + Limit Increase Upto Interest Amount In Interest Calculation + + YES + NO + + + + + Minimum Penalty Interest Amount + + + + + Days For Limit Increase Upto Interest Amount + + + + + Additional Interest Rate For Fixed Deposit Overdraft + + + + + Consider DD/Payorder In Against Clearing Interest Calculation + + YES + NO + + + + + Grace Days After Renewal Date + + + + + Apply Undebited Interest Concept Enabled + + YES + NO + + + + + Interest Register Print Format Type + + Interest Amount with Opening Balance + Interest Only + Full Ledger Format + + + + + Normal Grace Days After Overdue + + + + + Allow Against Clearing Entry + + YES + NO + + + + + Allow No Balance Entry + + YES + NO + + + + + Fixed Deposit Overdraft Penalty Interest Combined With Normal Interest + + YES + NO + + + + + Allow No Balance Entry With Password + + YES + NO + + + + + Undebited Simple Interest Account Wise + + YES + NO + + + + + Daily Interest Calculation Enabled + + YES + NO + + + + + Daily Interest Calculation Start Date + + + + + Non Performing Asset Penal Interest Rate Update + + YES + NO + + + + + Non Performing Asset Penal Interest Rate + + + + + Fixed Deposit Overdraft Penal Interest Rate Update In Branch Process + + YES + NO + + + + + Add Penal Interest Rate + + +
+
+ + + + + + + RBI Compliance & KFS Settings + + +
+ + Penal Charge Method + + RBI Compliant Penal Charges (Non-Compounding) + Legacy Penal Interest (Compounding) + + + +
+ + Is Floating Rate Loan? (Individual) + +
+ + + Foreclosure Charges Applicable + + YES + NO + + RBI: No charges for Floating Rate Loans to Individuals + + +
+ + Enable Key Fact Statement (KFS) + +
+ +
+ + Calculate APR + +
+
+
+ + + + + + + Credit Bureau Settings (CIBIL/Highmark) + + +
+ + Loan Product Category (RBI Reporting) + + Personal/Consumer Loan (Code 02) + Housing/Mortgage Loan (Code 15) + Business/Commercial Loan (Code 50) + Vehicle/Auto Loan (Code 04) + + Selecting a category will auto-fill the credit bureau account types below. + + + CIBIL Account Type + + + + + CIBIL Commercial Account Type + + + + + Highmark Account Type + + + + + Highmark Commercial Account Type + + + + + Experian Account Type + + + + + Experian Commercial Account Type + + + + + Equifax Account Type + + + + + Equifax Commercial Account Type + + +
+
+ + + + + + + {{ 'labels.inputs.Moratorium' | translate }} + + + +
+ + {{ 'labels.inputs.Grace on principal payment' | translate }} + + + + + {{ 'labels.inputs.Grace on interest payment' | translate }} + + + + + {{ 'labels.inputs.Delinquency Bucket' | translate }} + + @for (delinquencyBucket of delinquencyBucketData; track delinquencyBucket) { + + {{ delinquencyBucket.name }} + + } + + @if (loanProductSettingsForm.controls.delinquencyBucketId) { + + } + + + @if (loanProductSettingsForm.value.delinquencyBucketId) { +
+ + {{ 'labels.inputs.Enable installment level Delinquency' | translate }} + +
+ } +
+
+ + + + + + + {{ 'labels.inputs.Number of days a loan may be overdue before moving into arrears' | translate }} + + +
+ + {{ 'labels.inputs.On arrears ageing' | translate }} + + + + + {{ 'labels.inputs.Overdue days for NPA' | translate }} + + + +
+ + {{ 'labels.inputs.Account moves out of NPA only after all arrears have been cleared' | translate }} + +
+ + + {{ 'labels.inputs.Principal Threshold (%) for Last Instalment' | translate }} + + +
+
+ + + + + + + {{ 'labels.inputs.Variable Installments' | translate }} + + +
+
+ + {{ 'labels.inputs.Are Variable Installments allowed?' | translate }} + +
+ + @if (loanProductSettingsForm.value.allowVariableInstallments) { - {{ 'labels.inputs.Frequency for compounding' | translate }} - + {{ 'labels.inputs.Minimum gap between Installments' | translate }} + + + {{ 'labels.inputs.Minimum gap between Installments' | translate }} {{ 'labels.commons.is' | translate }} + {{ 'labels.commons.required' | translate }} + + + + {{ 'labels.inputs.Maximum gap between Installments' | translate }} + + + {{ 'labels.inputs.Maximum gap between Installments' | translate }} {{ 'labels.commons.is' | translate }} + {{ 'labels.commons.required' | translate }} + + + } + +
+ + {{ 'labels.inputs.Allowed to be used for providing Topup Loans' | translate }} + +
+
+
+ + + + + + + {{ 'labels.heading.Interest Recalculation' | translate }} + + +
+
+ + {{ 'labels.inputs.Recalculate Interest' | translate }} + +
+ + @if (loanProductSettingsForm.value.isInterestRecalculationEnabled) { + + {{ 'labels.inputs.Pre-closure interest calculation rule' | translate }} + + @for ( + preClosureInterestCalculationStrategy of preClosureInterestCalculationStrategyData; + track preClosureInterestCalculationStrategy + ) { + + {{ preClosureInterestCalculationStrategy.value | translateKey: 'catalogs' }} + + } + + + {{ 'labels.inputs.Pre-closure interest calculation rule' | translate }} + {{ 'labels.commons.is' | translate }} + {{ 'labels.commons.required' | translate }} + + + + + {{ 'labels.inputs.Advance payments adjustment type' | translate }} + + @for (rescheduleStrategyType of rescheduleStrategyTypeData; track rescheduleStrategyType) { + + {{ rescheduleStrategyType.value | translateKey: 'catalogs' }} + + } + + + {{ 'labels.inputs.Advance payments adjustment type' | translate }} {{ 'labels.commons.is' | translate }} + {{ 'labels.commons.required' | translate }} + + + + + {{ 'labels.inputs.Interest recalculation compounding on' | translate }} + + @for ( + interestRecalculationCompoundingType of interestRecalculationCompoundingTypeData; + track interestRecalculationCompoundingType + ) { + + {{ interestRecalculationCompoundingType.value | translateKey: 'catalogs' }} + + } + + + {{ 'labels.inputs.Interest recalculation compounding on' | translate }} + {{ 'labels.commons.is' | translate }} + {{ 'labels.commons.required' | translate }} + + + + + @if ( + loanProductSettingsForm.value.interestRecalculationCompoundingMethod !== 0 && + loanProductSettingsForm.value.recalculationCompoundingFrequencyType !== 1 + ) { + + {{ 'labels.inputs.Frequency Interval for compounding' | translate }} + + + {{ 'labels.inputs.Frequency Interval for compounding' | translate }} + {{ 'labels.commons.is' | translate }} + {{ 'labels.commons.required' | translate }} + + + } + @if (loanProductSettingsForm.value.recalculationRestFrequencyType !== 1) { + + {{ 'labels.inputs.Frequency Interval for recalculation' | translate }} + + + {{ 'labels.inputs.Frequency Interval for recalculation' | translate }} + {{ 'labels.commons.is' | translate }} {{ 'labels.commons.required' | translate }} + + + } + + + @if (loanProductSettingsForm.value.interestRecalculationCompoundingMethod !== 0) { + + {{ 'labels.inputs.Frequency for compounding' | translate }} + + @for ( + interestRecalculationFrequencyType of interestRecalculationFrequencyTypeData; + track interestRecalculationFrequencyType + ) { + + {{ interestRecalculationFrequencyType.value }} + + } + + + + @if (loanProductSettingsForm.value.recalculationCompoundingFrequencyType === 4) { + + + @for ( + interestRecalculationNthDayType of interestRecalculationNthDayTypeData; + track interestRecalculationNthDayType + ) { + + {{ interestRecalculationNthDayType.value | translateKey: 'catalogs' }} + + } + + + } + + @if ( + (loanProductSettingsForm.value.recalculationCompoundingFrequencyType === 4 && + loanProductSettingsForm.value.recalculationCompoundingFrequencyNthDayType !== -2) || + loanProductSettingsForm.value.recalculationCompoundingFrequencyType === 3 + ) { + + + @for ( + interestRecalculationDayOfWeekType of interestRecalculationDayOfWeekTypeData; + track interestRecalculationDayOfWeekType + ) { + + {{ interestRecalculationDayOfWeekType.value | translateKey: 'catalogs' }} + + } + + + } + + @if ( + loanProductSettingsForm.value.recalculationCompoundingFrequencyType === 4 && + loanProductSettingsForm.value.recalculationCompoundingFrequencyNthDayType === -2 + ) { + + + @for ( + interestRecalculationOnDayType of interestRecalculationOnDayTypeData; + track interestRecalculationOnDayType + ) { + + {{ interestRecalculationOnDayType }} + + } + + + } + } + + + + {{ 'labels.inputs.Frequency for recalculate Outstanding Principal' | translate }} + @for ( interestRecalculationFrequencyType of interestRecalculationFrequencyTypeData; track interestRecalculationFrequencyType ) { - {{ interestRecalculationFrequencyType.value }} + {{ interestRecalculationFrequencyType.value | translateKey: 'catalogs' }} } + + {{ 'labels.inputs.Frequency for recalculate Outstanding Principal' | translate }} + {{ 'labels.commons.is' | translate }} {{ 'labels.commons.required' | translate }} + - @if (loanProductSettingsForm.value.recalculationCompoundingFrequencyType === 4) { + @if (loanProductSettingsForm.value.recalculationRestFrequencyType === 4) { - + @for ( interestRecalculationNthDayType of interestRecalculationNthDayTypeData; track interestRecalculationNthDayType @@ -1114,12 +1226,12 @@ } @if ( - (loanProductSettingsForm.value.recalculationCompoundingFrequencyType === 4 && - loanProductSettingsForm.value.recalculationCompoundingFrequencyNthDayType !== -2) || - loanProductSettingsForm.value.recalculationCompoundingFrequencyType === 3 + (loanProductSettingsForm.value.recalculationRestFrequencyType === 4 && + loanProductSettingsForm.value.recalculationRestFrequencyNthDayType !== -2) || + loanProductSettingsForm.value.recalculationRestFrequencyType === 3 ) { - + @for ( interestRecalculationDayOfWeekType of interestRecalculationDayOfWeekTypeData; track interestRecalculationDayOfWeekType @@ -1133,11 +1245,11 @@ } @if ( - loanProductSettingsForm.value.recalculationCompoundingFrequencyType === 4 && - loanProductSettingsForm.value.recalculationCompoundingFrequencyNthDayType === -2 + loanProductSettingsForm.value.recalculationRestFrequencyType === 4 && + loanProductSettingsForm.value.recalculationRestFrequencyNthDayType === -2 ) { - + @for ( interestRecalculationOnDayType of interestRecalculationOnDayTypeData; track interestRecalculationOnDayType @@ -1149,205 +1261,138 @@ } - } - - - {{ 'labels.inputs.Frequency for recalculate Outstanding Principal' | translate }} - - @for ( - interestRecalculationFrequencyType of interestRecalculationFrequencyTypeData; - track interestRecalculationFrequencyType - ) { - - {{ interestRecalculationFrequencyType.value | translateKey: 'catalogs' }} - - } - - - {{ 'labels.inputs.Frequency for recalculate Outstanding Principal' | translate }} - {{ 'labels.commons.is' | translate }} {{ 'labels.commons.required' | translate }} - - - - @if (loanProductSettingsForm.value.recalculationRestFrequencyType === 4) { - - - @for ( - interestRecalculationNthDayType of interestRecalculationNthDayTypeData; - track interestRecalculationNthDayType - ) { - - {{ interestRecalculationNthDayType.value | translateKey: 'catalogs' }} - - } - - - } - - @if ( - (loanProductSettingsForm.value.recalculationRestFrequencyType === 4 && - loanProductSettingsForm.value.recalculationRestFrequencyNthDayType !== -2) || - loanProductSettingsForm.value.recalculationRestFrequencyType === 3 - ) { - - - @for ( - interestRecalculationDayOfWeekType of interestRecalculationDayOfWeekTypeData; - track interestRecalculationDayOfWeekType - ) { - - {{ interestRecalculationDayOfWeekType.value | translateKey: 'catalogs' }} - - } - - - } - - @if ( - loanProductSettingsForm.value.recalculationRestFrequencyType === 4 && - loanProductSettingsForm.value.recalculationRestFrequencyNthDayType === -2 - ) { - - - @for ( - interestRecalculationOnDayType of interestRecalculationOnDayTypeData; - track interestRecalculationOnDayType - ) { - - {{ interestRecalculationOnDayType }} - - } - - - } - -
- - {{ 'labels.inputs.Is Arrears recognization based on original schedule' | translate }}? - -
- - @if (loanProductSettingsForm.value.loanScheduleType === 'PROGRESSIVE') {
- - {{ 'labels.inputs.Do not calculate interest on past due principal balances' | translate }} + + {{ 'labels.inputs.Is Arrears recognization based on original schedule' | translate }}? + +
+ + @if (loanProductSettingsForm.value.loanScheduleType === 'PROGRESSIVE') { +
+ + {{ 'labels.inputs.Do not calculate interest on past due principal balances' | translate }} + +
+ } + +
+ + {{ 'labels.inputs.Allow accrual posting in arrears' | translate }} + +
+ +
+ + {{ 'labels.inputs.Allow compounding on EOD' | translate }} + +
+ +
+ + {{ 'labels.inputs.Post compounding as transaction' | translate }}
} - -
- - {{ 'labels.inputs.Allow accrual posting in arrears' | translate }} - -
- -
- - {{ 'labels.inputs.Allow compounding on EOD' | translate }} - -
- -
- - {{ 'labels.inputs.Post compounding as transaction' | translate }} - -
- } -
-
- - -
-
- -

{{ 'labels.heading.Guarantee Requirements' | translate }}

-
-
-
- - {{ 'labels.inputs.Place Guarantee Funds On-Hold' | translate }} -
+ - @if (loanProductSettingsForm.value.holdGuaranteeFunds) { - - {{ 'labels.inputs.Mandatory Guarantee(%)' | translate }} - - - {{ 'labels.inputs.Mandatory Guarantee(%)' | translate }} {{ 'labels.commons.is' | translate }} - {{ 'labels.commons.required' | translate }} - - - - {{ 'labels.inputs.Minimum Guarantee from Own Funds(%)' | translate }} - - - - {{ 'labels.inputs.Minimum Guarantee from Guarantor Funds(%)' | translate }} - - - } -
-
- - -
-
- -

{{ 'labels.inputs.Configurable Terms and Settings' | translate }}

-
-
-
- - {{ 'labels.inputs.Allow overriding select terms and settings in loan accounts' | translate }} - -
- - @if (loanProductSettingsForm.value.allowAttributeConfiguration) { -
-
- - {{ 'labels.inputs.Amortization' | translate }} - -
-
- - {{ 'labels.inputs.Interest method' | translate }} - -
-
- - {{ 'labels.inputs.Repayment strategy' | translate }} - -
-
- - {{ 'labels.inputs.Interest calculation period' | translate }} - -
-
- - Allowed pending EMI amount - -
-
- - {{ 'labels.inputs.Repaid every' | translate }} - -
-
- - {{ 'labels.inputs.Moratorium' | translate }} - -
-
- - {{ 'labels.inputs.Number of days a loan may be overdue before moving into arrears' | translate }} - -
+ + + + + + {{ 'labels.heading.Guarantee Requirements' | translate }} + + +
+
+ + {{ 'labels.inputs.Place Guarantee Funds On-Hold' | translate }} +
- } -
-
+ + @if (loanProductSettingsForm.value.holdGuaranteeFunds) { + + {{ 'labels.inputs.Mandatory Guarantee(%)' | translate }} + + + {{ 'labels.inputs.Mandatory Guarantee(%)' | translate }} {{ 'labels.commons.is' | translate }} + {{ 'labels.commons.required' | translate }} + + + + {{ 'labels.inputs.Minimum Guarantee from Own Funds(%)' | translate }} + + + + {{ 'labels.inputs.Minimum Guarantee from Guarantor Funds(%)' | translate }} + + + } +
+ + + + + + + + {{ 'labels.inputs.Configurable Terms and Settings' | translate }} + + +
+
+ + {{ 'labels.inputs.Allow overriding select terms and settings in loan accounts' | translate }} + +
+ + @if (loanProductSettingsForm.value.allowAttributeConfiguration) { +
+
+ + {{ 'labels.inputs.Amortization' | translate }} + +
+
+ + {{ 'labels.inputs.Interest method' | translate }} + +
+
+ + {{ 'labels.inputs.Repayment strategy' | translate }} + +
+
+ + {{ 'labels.inputs.Interest calculation period' | translate }} + +
+
+ + Allowed pending EMI amount + +
+
+ + {{ 'labels.inputs.Repaid every' | translate }} + +
+
+ + {{ 'labels.inputs.Moratorium' | translate }} + +
+
+ + {{ 'labels.inputs.Number of days a loan may be overdue before moving into arrears' | translate }} + +
+
+ } +
+
+ diff --git a/src/app/products/loan-products/loan-product-stepper/loan-product-settings-step/loan-product-settings-step.component.scss b/src/app/products/loan-products/loan-product-stepper/loan-product-settings-step/loan-product-settings-step.component.scss index 50934aecc..c14c4b170 100644 --- a/src/app/products/loan-products/loan-product-stepper/loan-product-settings-step/loan-product-settings-step.component.scss +++ b/src/app/products/loan-products/loan-product-stepper/loan-product-settings-step/loan-product-settings-step.component.scss @@ -71,6 +71,16 @@ } } +.lps-panel { + & + & { + margin-top: 12px; + } + + .lps-section__body { + padding-top: 8px; + } +} + .lps-full-width { grid-column: 1 / -1; } diff --git a/src/app/products/loan-products/loan-product-stepper/loan-product-settings-step/loan-product-settings-step.component.ts b/src/app/products/loan-products/loan-product-stepper/loan-product-settings-step/loan-product-settings-step.component.ts index d230afc79..5153727bf 100644 --- a/src/app/products/loan-products/loan-product-stepper/loan-product-settings-step/loan-product-settings-step.component.ts +++ b/src/app/products/loan-products/loan-product-stepper/loan-product-settings-step/loan-product-settings-step.component.ts @@ -22,6 +22,12 @@ import { MatTooltip } from '@angular/material/tooltip'; import { MatCheckbox } from '@angular/material/checkbox'; import { MatIconButton } from '@angular/material/button'; import { FaIconComponent } from '@fortawesome/angular-fontawesome'; +import { + MatAccordion, + MatExpansionPanel, + MatExpansionPanelHeader, + MatExpansionPanelTitle +} from '@angular/material/expansion'; import { STANDALONE_SHARED_IMPORTS } from 'app/standalone-shared.module'; @Component({ @@ -35,7 +41,11 @@ import { STANDALONE_SHARED_IMPORTS } from 'app/standalone-shared.module'; MatCheckbox, MatIconButton, FaIconComponent, - ReactiveFormsModule + ReactiveFormsModule, + MatAccordion, + MatExpansionPanel, + MatExpansionPanelHeader, + MatExpansionPanelTitle ] }) export class LoanProductSettingsStepComponent implements OnInit { @@ -44,9 +54,35 @@ export class LoanProductSettingsStepComponent implements OnInit { @Input() toEdit: boolean; @Input() loanProductsTemplate: any; - @Input() isLinkedToFloatingInterestRates: UntypedFormControl; + @Input() productTypeGroup: UntypedFormControl; @Output() advancePaymentStrategy = new EventEmitter(); + /** + * Provided from the Terms step's control. It can arrive after this component's ngOnInit (the Terms tab + * resolves later now that the tabs are conditional), so subscribe from the setter when it is present + * rather than assuming it in ngOnInit — otherwise a null control blanks the whole wizard. + */ + private _isLinkedToFloatingInterestRates: UntypedFormControl; + private isLinkedSubscribed = false; + + @Input() + set isLinkedToFloatingInterestRates(control: UntypedFormControl) { + this._isLinkedToFloatingInterestRates = control; + if (control && !this.isLinkedSubscribed) { + this.isLinkedSubscribed = true; + control.valueChanges.subscribe((isLinked: any) => { + if (isLinked) { + this.loanProductSettingsForm.get('isInterestRecalculationEnabled').setValue(true); + this.loanProductSettingsForm.get('allowPartialPeriodInterestCalculation').setValue(true); + } + }); + } + } + + get isLinkedToFloatingInterestRates(): UntypedFormControl { + return this._isLinkedToFloatingInterestRates; + } + loanProductSettingsForm: UntypedFormGroup; amortizationTypeData: any; @@ -74,6 +110,15 @@ export class LoanProductSettingsStepComponent implements OnInit { daysInYearCustomStrategyOptions: OptionData[] = []; useDaysInYearCustomStrategy = false; + /** True when the product being created is an "Advances A/C" (no-EMI, limit-based) product. */ + isAdvanceAccount = false; + + /** UI-only for now; not yet wired into the submitted payload (see loan-products.ts buildPayload). */ + readonly loanPeriodOptions = [ + { value: 12, label: '12 Months' }, + { value: 15, label: '15 Months' } + ]; + /** Values to Days for Repayments */ defaultConfigValues: GlobalConfiguration[] = []; @@ -84,12 +129,13 @@ export class LoanProductSettingsStepComponent implements OnInit { ngOnInit() { this.defaultConfigValues = this.loanProductsTemplate['itemsByDefault']; - this.isLinkedToFloatingInterestRates.valueChanges.subscribe((isLinkedToFloatingInterestRates: any) => { - if (isLinkedToFloatingInterestRates) { - this.loanProductSettingsForm.get('isInterestRecalculationEnabled').setValue(true); - this.loanProductSettingsForm.get('allowPartialPeriodInterestCalculation').setValue(true); - } - }); + + if (this.productTypeGroup) { + this.applyAdvanceAccountRules(this.productTypeGroup.value === 'advances'); + this.productTypeGroup.valueChanges.subscribe((productTypeGroup: string) => { + this.applyAdvanceAccountRules(productTypeGroup === 'advances'); + }); + } this.amortizationTypeData = this.loanProductsTemplate.amortizationTypeOptions; this.interestTypeData = this.loanProductsTemplate.interestTypeOptions; @@ -334,6 +380,8 @@ export class LoanProductSettingsStepComponent implements OnInit { Validators.required ], allowAccrualPostingInArrears: [false], + // Advances A/C only; UI-only for now, stripped from the payload in loan-products.ts buildPayload(). + loanPeriod: [''], // Cheque Book Settings chequeBookIssueEnabled: ['NO'], @@ -453,6 +501,28 @@ export class LoanProductSettingsStepComponent implements OnInit { } } + /** + * Advances A/C has no EMI, so the Loan Schedule section is simplified: schedule type is locked to + * Cumulative (Progressive only makes sense with Advanced Payment Allocation, which is an EMI concept), + * installment-amount fixing doesn't apply, and Days in year/month are replaced by a placeholder + * Loan Period pick. loanScheduleType/canDefineInstallmentAmount stay enabled (not `.disable()`d) so they + * remain in `loanProductSettingsForm.value` and the submitted payload is unaffected by this UI change. + */ + private applyAdvanceAccountRules(isAdvance: boolean): void { + this.isAdvanceAccount = isAdvance; + const loanPeriodControl = this.loanProductSettingsForm.get('loanPeriod'); + if (isAdvance) { + this.loanProductSettingsForm.patchValue({ + loanScheduleType: LoanProducts.LOAN_SCHEDULE_TYPE_CUMULATIVE, + canDefineInstallmentAmount: false + }); + loanPeriodControl.setValidators(Validators.required); + } else { + loanPeriodControl.clearValidators(); + } + loanPeriodControl.updateValueAndValidity(); + } + setConditionalControls() { const allowAttributeOverrides = this.loanProductSettingsForm.get('allowAttributeOverrides'); diff --git a/src/app/products/loan-products/loan-product-stepper/loan-product-terms-step/loan-product-terms-step.component.html b/src/app/products/loan-products/loan-product-stepper/loan-product-terms-step/loan-product-terms-step.component.html index 6e765fbe3..7b68bec2d 100644 --- a/src/app/products/loan-products/loan-product-stepper/loan-product-terms-step/loan-product-terms-step.component.html +++ b/src/app/products/loan-products/loan-product-stepper/loan-product-terms-step/loan-product-terms-step.component.html @@ -264,6 +264,62 @@ }
+ +
+
+ +

Interest Information

+
+
+ + Debit Int. Calculation + + YES + NO + + + + + Separate Penal Int. + + YES + NO + + + + + Credit Int. Calculation + + YES + NO + + + + + Sub A/c Type Balance + + YES + NO + + + + + Final Int. Rounding + + YES + NO + + + + @if (loanProductTermsForm.value.finalIntRounding) { + + Rounding UpTo + + + } +
+
+
diff --git a/src/app/products/loan-products/loan-product-stepper/loan-product-terms-step/loan-product-terms-step.component.ts b/src/app/products/loan-products/loan-product-stepper/loan-product-terms-step/loan-product-terms-step.component.ts index 9e457b6d8..92f7e797b 100644 --- a/src/app/products/loan-products/loan-product-stepper/loan-product-terms-step/loan-product-terms-step.component.ts +++ b/src/app/products/loan-products/loan-product-stepper/loan-product-terms-step/loan-product-terms-step.component.ts @@ -111,7 +111,13 @@ export class LoanProductTermsStepComponent implements OnInit, OnChanges { repaymentStartDateType: this.loanProductsTemplate.repaymentStartDateType.id || 1, debitBalanceAllow: this.loanProductsTemplate.debitBalanceAllow ?? false, allowDebitTransAfterDueDate: this.loanProductsTemplate.allowDebitTransAfterDueDate ?? false, - branchLevelAccountType: this.loanProductsTemplate.branchLevelAccountType ?? false + branchLevelAccountType: this.loanProductsTemplate.branchLevelAccountType ?? false, + debitIntCalculation: this.loanProductsTemplate.debitIntCalculation ?? false, + separatePenalInterest: this.loanProductsTemplate.separatePenalInterest ?? false, + creditIntCalculation: this.loanProductsTemplate.creditIntCalculation ?? false, + subAccountTypeBalance: this.loanProductsTemplate.subAccountTypeBalance ?? false, + finalIntRounding: this.loanProductsTemplate.finalIntRounding ?? false, + roundingUpTo: this.loanProductsTemplate.roundingUpTo ?? null }); if (this.loanProductsTemplate.allowApprovedDisbursedAmountsOverApplied) { @@ -213,7 +219,14 @@ export class LoanProductTermsStepComponent implements OnInit, OnChanges { fixedLength: [null], debitBalanceAllow: [false], allowDebitTransAfterDueDate: [false], - branchLevelAccountType: [false] + branchLevelAccountType: [false], + // Interest Information (moved here from the Details tab) + debitIntCalculation: [false], + separatePenalInterest: [false], + creditIntCalculation: [false], + subAccountTypeBalance: [false], + finalIntRounding: [false], + roundingUpTo: [null] }); } diff --git a/src/app/products/loan-products/loan-product-stepper/register-rate-view/register-rate-view.component.html b/src/app/products/loan-products/loan-product-stepper/register-rate-view/register-rate-view.component.html new file mode 100644 index 000000000..377661f29 --- /dev/null +++ b/src/app/products/loan-products/loan-product-stepper/register-rate-view/register-rate-view.component.html @@ -0,0 +1,97 @@ + + +@switch (state) { + @case ('loading') { +
Reading the Loan Interest Rate Register…
+ } + @case ('none') { +
+ There is no active Loan Interest Rate Register yet. + + A product takes its interest rate from the register, so it cannot be created until a register is created and + approved. + + Open the register → +
+ } + @case ('ready') { + @if (selectedRow) { +
+ + Interest comes from the active register {{ register?.name }}, effective {{ register?.effectiveFrom }}. + + Open register → +
+ } + + @if (!selectedRow) { +
+ This product has no interest rate in the Loan Interest Rate Register yet. + {{ blockReason }} + Add it in the register → +
+ } + + @if (selectedRow) { +
+ Register row + {{ selectedRow.srNo }}. {{ selectedRow.particulars }} + @if (glLabel(selectedRow); as gl) { + {{ gl }} + } + {{ matchNote }} +
+ +
+ + + + + + + + + @for (slab of slabRates; track slab.label) { + + + + + } + +
Amount slabRate
{{ slab.label }}{{ slab.text }}
+ + + + + + + + + + + @for (slab of cibilSlabs; track slab.from) { + + + + + + } + +
CIBIL scoreBandAdded to the rate
{{ slab.from }} – {{ slab.to }}{{ slab.tier }}{{ slab.adjustmentPct > 0 ? '+' + slab.adjustmentPct.toFixed(2) + '%' : 'No change' }}
+
+ @if (selectedRow.isFormula || slabRates[0]?.formula) { +
+ This row is the deposit's own rate plus a spread, so the final rate is worked out on each account from its + deposit. +
+ } + } + } +} diff --git a/src/app/products/loan-products/loan-product-stepper/register-rate-view/register-rate-view.component.scss b/src/app/products/loan-products/loan-product-stepper/register-rate-view/register-rate-view.component.scss new file mode 100644 index 000000000..4347cf852 --- /dev/null +++ b/src/app/products/loan-products/loan-product-stepper/register-rate-view/register-rate-view.component.scss @@ -0,0 +1,106 @@ +/** + * 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; +} + +.rrv__source { + display: flex; + align-items: center; + justify-content: space-between; + gap: 12px; + + a { + font-weight: 600; + white-space: nowrap; + } +} + +.rrv__block { + display: flex; + flex-wrap: wrap; + align-items: center; + gap: 4px 16px; + margin: 16px 24px 0; + padding: 12px 16px; + border: 1px solid #fecaca; + border-radius: 8px; + background: #fef2f2; + color: #991b1b; + font-size: 13px; + line-height: 1.5; + + strong { + flex: 1 1 100%; + font-size: 14px; + } + + span { + flex: 1 1 360px; + } +} + +.rrv__block-btn { + font-weight: 600; + color: #991b1b; + white-space: nowrap; +} + +.rrv__row { + display: flex; + flex-wrap: wrap; + align-items: center; + gap: 6px 14px; + padding: 14px 24px 4px; + font-size: 14px; + color: #1e293b; +} + +.rrv__row-label { + font-size: 12px; + letter-spacing: 0.05em; + text-transform: uppercase; + color: #5f6368; +} + +.rrv__row-gl { + padding: 1px 10px; + border-radius: 999px; + background: #e0f2fe; + color: #075985; + font-size: 12px; + font-weight: 600; +} + +.rrv__row-note { + font-size: 12px; + color: #5f6368; +} + +.rrv__tables { + display: grid; + grid-template-columns: repeat(2, minmax(0, 1fr)); + gap: 16px; + padding: 8px 24px 16px; + + @media (width <= 900px) { + grid-template-columns: 1fr; + } +} + +.rrv__formula { + color: #075985; + font-weight: 600; +} + +.rrv__note { + padding: 0 24px 16px; + font-size: 13px; + color: #5f6368; +} diff --git a/src/app/products/loan-products/loan-product-stepper/register-rate-view/register-rate-view.component.ts b/src/app/products/loan-products/loan-product-stepper/register-rate-view/register-rate-view.component.ts new file mode 100644 index 000000000..44d9a35cc --- /dev/null +++ b/src/app/products/loan-products/loan-product-stepper/register-rate-view/register-rate-view.component.ts @@ -0,0 +1,220 @@ +/** + * Copyright since 2025 Mifos Initiative + * + * This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. + */ + +import { Component, EventEmitter, Input, OnChanges, OnInit, Output, SimpleChanges, inject } from '@angular/core'; +import { RouterLink } from '@angular/router'; +import { RateRegisterService } from 'app/products/loan-interest-rate-register/rate-register.service'; +import { + CibilSlab, + RateCell, + RateChartRow, + RateRegister +} from 'app/products/loan-interest-rate-register/models/rate-register.model'; + +/** How the register row was found for the product. */ +export type RegisterMatch = 'gl' | 'type'; + +/** What the product takes from the register row, handed to the step that owns the rate fields. */ +export interface RegisterRateSelection { + particular: string; + srNo: number; + glAccountId: number | null; + /** True when the row holds formulas such as "1% + FD": the rate is the deposit's own rate plus a spread. */ + isFormula: boolean; + /** Lowest and highest plain rate in the row, the top one including the largest CIBIL adjustment. Null for a formula row. */ + minRate: number | null; + maxRate: number | null; + /** The rate the product is created with: the lowest plain rate, or the lowest spread for a formula row. */ + defaultRate: number | null; + /** Lowest and highest spread over the deposit rate; only for a formula row. */ + minSpread: number | null; + maxSpread: number | null; + matchedBy: RegisterMatch; + registerName: string; +} + +/** + * Product sub type id -> a word of the register particular that goes with it. This only covers the register's + * default rows. Any other product is found through its GL account, which every register row carries. + */ +const PARTICULAR_BY_SUB_TYPE: Record = { + fd: 'against fdr', + rd: 'against rd', + gold: 'gold loan', + cc: 'cash credit', + overdraft: 'overdraft', + kcc: 'agriculture', + housing: 'housing loan', + personal: 'personal loan (unsecured)', + vehicle: 'car loan (new)', + business: 'business term loan', + term: 'business term loan', + gold_emi: 'gold loan' +}; + +/** + * Shows the interest of a product as the Loan Interest Rate Register defines it: the rate of the product's own + * register row for each amount slab, and the CIBIL adjustment. Nothing is chosen or typed here. + * + * The row is found from the product's GL account first (the register maps each row to a GL), then from its sub + * type. The row found is reported through `selectionChange`, or null when there is none, which blocks the product. + */ +@Component({ + selector: 'mifosx-register-rate-view', + templateUrl: './register-rate-view.component.html', + styleUrls: [ + '../advance-steps.scss', + './register-rate-view.component.scss' + ], + imports: [RouterLink] +}) +export class RegisterRateViewComponent implements OnInit, OnChanges { + private registerService = inject(RateRegisterService); + + /** Sub type id picked on the Details step, e.g. `fd`. */ + @Input() productSubType = ''; + /** The product's GL account id from the Details step. */ + @Input() glAccountId: number | string | null = null; + @Output() selectionChange = new EventEmitter(); + + state: 'loading' | 'ready' | 'none' = 'loading'; + register?: RateRegister; + selectedSrNo: number | null = null; + matchedBy: RegisterMatch | null = null; + /** More than one register row fits the product and nothing tells them apart. */ + ambiguous = false; + + ngOnInit(): void { + this.registerService.getGlOptions().subscribe(); + this.registerService.list().subscribe(() => { + this.register = this.registerService.getActive(); + this.state = this.register && this.register.rows.length > 0 ? 'ready' : 'none'; + this.autoSelect(); + }); + } + + ngOnChanges(changes: SimpleChanges): void { + // The Details step can change after this one has opened, and a new type or GL means a new row. + if ((changes['productSubType'] || changes['glAccountId']) && this.state === 'ready') { + this.autoSelect(); + } + } + + get rows(): RateChartRow[] { + return this.register?.rows ?? []; + } + + get selectedRow(): RateChartRow | undefined { + return this.rows.find((row) => row.srNo === this.selectedSrNo); + } + + get cibilSlabs(): CibilSlab[] { + return this.register?.cibilSlabs ?? []; + } + + glLabel(row: RateChartRow): string | null { + return this.registerService.glLabel(row.glAccountId); + } + + /** The row's rate for every amount slab, ready to show. */ + get slabRates(): { label: string; text: string; formula: boolean; na: boolean }[] { + const row = this.selectedRow; + if (!row || !this.register) { + return []; + } + return this.register.slabs.map((slab, index) => { + const cell: RateCell = row.rates[index] ?? null; + return { + label: slab.label, + text: cell === null ? 'N.A.' : typeof cell === 'string' ? cell : `${cell.toFixed(2)}%`, + formula: typeof cell === 'string', + na: cell === null + }; + }); + } + + /** Why the product cannot go on: what the officer has to do in the register first. */ + get blockReason(): string { + const hasGl = this.glAccountId !== null && this.glAccountId !== ''; + if (!this.productSubType && !hasGl) { + return 'Choose the product type and the GL account on the Details step first.'; + } + const gl = hasGl ? this.registerService.glLabel(Number(this.glAccountId)) : null; + if (this.ambiguous) { + return `More than one register row uses the GL account${gl ? ` (${gl})` : ''}. Give each row its own GL account in the register so this product can be matched to one of them.`; + } + const forGl = gl ? ` with its GL account (${gl})` : ' with its GL account'; + return `Add this product${forGl} and its interest rates in the register and get the register approved. The product can be created only after that.`; + } + + /** How the row was found, shown beside it. */ + get matchNote(): string { + return this.matchedBy === 'gl' + ? `Found through the product's GL account (${this.glLabel(this.selectedRow!) ?? 'GL'}).` + : 'Found through the product type.'; + } + + private autoSelect(): void { + const rows = this.rows; + const word = PARTICULAR_BY_SUB_TYPE[this.productSubType]?.toLowerCase(); + const byType = word ? rows.filter((row) => row.particulars.toLowerCase().includes(word)) : []; + const gl = this.glAccountId === null || this.glAccountId === '' ? null : Number(this.glAccountId); + const byGl = gl === null ? [] : rows.filter((row) => row.glAccountId === gl); + + let chosen: RateChartRow | undefined; + let by: RegisterMatch | null = null; + this.ambiguous = false; + if (byGl.length === 1) { + chosen = byGl[0]; + by = 'gl'; + } else if (byGl.length > 1) { + // Several rows share the GL: the product type tells them apart, or nothing does. + chosen = byGl.find((row) => byType.includes(row)); + by = chosen ? 'type' : null; + this.ambiguous = !chosen; + } else if (byType.length === 1) { + chosen = byType[0]; + by = 'type'; + } else if (byType.length > 1) { + this.ambiguous = true; + } + this.selectedSrNo = chosen?.srNo ?? null; + this.matchedBy = by; + this.emit(); + } + + private emit(): void { + const row = this.selectedRow; + if (!row || !this.register) { + this.selectionChange.emit(null); + return; + } + const plain = row.rates.filter((cell): cell is number => typeof cell === 'number'); + const spreads = row.rates + .filter((cell): cell is string => typeof cell === 'string') + .map((text) => /(\d+(?:\.\d+)?)\s*%/.exec(text)?.[1]) + .filter((value): value is string => !!value) + .map(Number); + const topAdjustment = Math.max(0, ...this.cibilSlabs.map((slab) => Number(slab.adjustmentPct) || 0)); + const isFormula = spreads.length > 0 && plain.length === 0; + const minRate = plain.length > 0 ? Math.min(...plain) : null; + this.selectionChange.emit({ + particular: row.particulars, + srNo: row.srNo, + glAccountId: row.glAccountId ?? null, + isFormula, + minRate, + maxRate: plain.length > 0 ? Math.round((Math.max(...plain) + topAdjustment) * 100) / 100 : null, + defaultRate: isFormula ? Math.min(...spreads) : minRate, + minSpread: isFormula ? Math.min(...spreads) : null, + maxSpread: isFormula ? Math.max(...spreads) : null, + matchedBy: this.matchedBy ?? 'type', + registerName: this.register.name + }); + } +} diff --git a/src/app/products/loan-products/loan-product-types.ts b/src/app/products/loan-products/loan-product-types.ts new file mode 100644 index 000000000..f310cef62 --- /dev/null +++ b/src/app/products/loan-products/loan-product-types.ts @@ -0,0 +1,64 @@ +/** + * Copyright since 2025 Mifos Initiative + * + * This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. + */ + +/** + * The category ("product type") and sub type of a loan product. One list shared by the create-product wizard, the + * product list filters and the loan account product picker, so a type added here appears in all three. + */ + +export interface ProductTypeGroup { + id: string; + label: string; + hint: string; +} + +export interface ProductSubType { + id: string; + label: string; + /** The two-letter code the product's Short Name is pre-filled with when the sub type is picked. */ + shortName: string; +} + +export const PRODUCT_TYPE_GROUPS: ProductTypeGroup[] = [ + { id: 'advances', label: 'Advances A/C', hint: 'No EMI. Limit / drawing power against security.' }, + { id: 'loans', label: 'Loans A/C', hint: 'Fixed disbursement, fixed tenure, EMI repayment.' } +]; + +export const PRODUCT_SUB_TYPES: Record = { + advances: [ + { id: 'fd', label: 'Loan against FD', shortName: 'FD' }, + { id: 'rd', label: 'Loan against RD', shortName: 'RD' }, + { id: 'dds', label: 'Loan against DDS', shortName: 'DS' }, + { id: 'lic', label: 'Loan against LIC Policy', shortName: 'LI' }, + { id: 'gold', label: 'Loan against Gold', shortName: 'GD' }, + { id: 'warehouse', label: 'Warehouse Receipt', shortName: 'WR' }, + { id: 'cc', label: 'Cash Credit (CC)', shortName: 'CC' }, + { id: 'overdraft', label: 'Overdraft (OD)', shortName: 'OD' }, + { id: 'kcc', label: 'Kisan Credit Card (KCC)', shortName: 'KC' }, + { id: 'bills', label: 'Bills Purchased / Discounted', shortName: 'BP' } + ], + loans: [ + { id: 'term', label: 'Term Loan', shortName: 'TL' }, + { id: 'personal', label: 'Personal Loan', shortName: 'PL' }, + { id: 'housing', label: 'Housing Loan', shortName: 'HL' }, + { id: 'vehicle', label: 'Vehicle Loan', shortName: 'VL' }, + { id: 'education', label: 'Education Loan', shortName: 'EL' }, + { id: 'business', label: 'Business / MSME Loan', shortName: 'BL' }, + { id: 'gold_emi', label: 'Gold EMI Loan', shortName: 'GE' } + ] +}; + +/** Label of a category id, or a dash for a product that carries none (created before categories existed). */ +export function productCategoryLabel(group: string | null | undefined): string { + return PRODUCT_TYPE_GROUPS.find((g) => g.id === group)?.label ?? '—'; +} + +/** Label of a sub type id within its category, or a dash when unknown. */ +export function productTypeLabel(group: string | null | undefined, subGroup: string | null | undefined): string { + return (PRODUCT_SUB_TYPES[group ?? ''] ?? []).find((s) => s.id === subGroup)?.label ?? '—'; +} diff --git a/src/app/products/loan-products/loan-products.component.scss b/src/app/products/loan-products/loan-products.component.scss index e15399058..48f1f175d 100644 --- a/src/app/products/loan-products/loan-products.component.scss +++ b/src/app/products/loan-products/loan-products.component.scss @@ -15,3 +15,49 @@ --pl-accent: #b45309; --pl-accent-l: #fef3c7; } + +/* Category / product type / active-only filters beside the search box. */ +.pl-filters { + display: flex; + align-items: flex-end; + gap: 12px; + flex-wrap: wrap; +} + +.pl-filter { + display: flex; + flex-direction: column; + gap: 2px; + font-size: 11px; + color: #64748b; + + select { + height: 34px; + min-width: 160px; + padding: 0 8px; + border: 1px solid #cbd5e1; + border-radius: 6px; + background: #fff; + font-size: 13px; + color: #1e293b; + } + + &--check { + flex-direction: row; + align-items: center; + gap: 6px; + height: 34px; + font-size: 13px; + color: #1e293b; + } +} + +.pl-tag--advances { + background: #e0f2fe; + color: #075985; +} + +.pl-tag--loans { + background: #ede9fe; + color: #5b21b6; +} diff --git a/src/app/products/loan-products/loan-products.component.ts b/src/app/products/loan-products/loan-products.component.ts index f16a3bff1..f316a73ab 100644 --- a/src/app/products/loan-products/loan-products.component.ts +++ b/src/app/products/loan-products/loan-products.component.ts @@ -42,6 +42,7 @@ import { MatTooltip } from '@angular/material/tooltip'; import { StatusLookupPipe } from '../../pipes/status-lookup.pipe'; import { DateFormatPipe } from '../../pipes/date-format.pipe'; import { STANDALONE_SHARED_IMPORTS } from 'app/standalone-shared.module'; +import { PRODUCT_SUB_TYPES, PRODUCT_TYPE_GROUPS, productCategoryLabel, productTypeLabel } from './loan-product-types'; @Component({ selector: 'mifosx-loan-products', @@ -82,6 +83,8 @@ export class LoanProductsComponent implements OnInit, AfterViewInit { displayedColumns: string[] = [ 'name', 'shortName', + 'category', + 'productType', 'closeDate', 'status', 'actions' @@ -90,6 +93,31 @@ export class LoanProductsComponent implements OnInit, AfterViewInit { filteredCount = 0; + /** Category and type filters, fed by the classification each product carries. */ + readonly categories = PRODUCT_TYPE_GROUPS; + categoryFilter = ''; + typeFilter = ''; + activeOnly = false; + textFilter = ''; + + /** Types offered for the chosen category; every type when no category is chosen. */ + get typeOptions() { + return this.categoryFilter + ? (PRODUCT_SUB_TYPES[this.categoryFilter] ?? []) + : Object.values(PRODUCT_SUB_TYPES).flat(); + } + + categoryOf(product: any): string { + return productCategoryLabel(product?.advanceExtensions?.productTypeGroup); + } + + typeOf(product: any): string { + return productTypeLabel( + product?.advanceExtensions?.productTypeGroup, + product?.advanceExtensions?.productTypeSubGroup + ); + } + get totalCount(): number { return this.loanProductsData?.length || 0; } @@ -130,12 +158,52 @@ export class LoanProductsComponent implements OnInit, AfterViewInit { this.dataSource = new MatTableDataSource(this.loanProductsData); this.dataSource.paginator = this.paginator; this.dataSource.sort = this.sort; + this.dataSource.filterPredicate = (product: any) => this.matchesFilters(product); this.filteredCount = this.totalCount; this.dataSource.connect().subscribe((rows) => (this.filteredCount = rows.length)); } applyFilter(filterValue: string) { - this.dataSource.filter = (filterValue || '').trim().toLowerCase(); + this.textFilter = (filterValue || '').trim().toLowerCase(); + this.refreshFilter(); + } + + onCategoryChange(): void { + // A type belongs to one category, so a type from another category cannot stay selected. + if (this.typeFilter && !this.typeOptions.some((type) => type.id === this.typeFilter)) { + this.typeFilter = ''; + } + this.refreshFilter(); + } + + /** The filter value itself is unused (the predicate reads the fields above); changing it re-runs the predicate. */ + refreshFilter(): void { + this.dataSource.filter = `${this.categoryFilter}|${this.typeFilter}|${this.activeOnly}|${this.textFilter}`; + } + + private matchesFilters(product: any): boolean { + const ext = product?.advanceExtensions ?? {}; + if (this.categoryFilter && ext.productTypeGroup !== this.categoryFilter) { + return false; + } + if (this.typeFilter && ext.productTypeSubGroup !== this.typeFilter) { + return false; + } + if (this.activeOnly && product?.status !== 'loanProduct.active') { + return false; + } + if (this.textFilter) { + const haystack = [ + product?.name, + product?.shortName, + this.categoryOf(product), + this.typeOf(product) + ] + .join(' ') + .toLowerCase(); + return haystack.includes(this.textFilter); + } + return true; } /** diff --git a/src/app/products/loan-products/loan-products.ts b/src/app/products/loan-products/loan-products.ts index 884b0a087..b097b9b43 100644 --- a/src/app/products/loan-products/loan-products.ts +++ b/src/app/products/loan-products/loan-products.ts @@ -45,9 +45,48 @@ export class LoanProducts { dateFormat, locale }; - // Remove unnecessary properties + // Remove UI-only flags that are never valid loan-product parameters (both paths). delete loanProduct.allowAttributeConfiguration; delete loanProduct.advancedAccountingRules; + delete loanProduct.loanPeriod; + + // The Accounting step carries CBS-specific GL mappings that the backend does not accept yet. They are + // sent as '' when the officer leaves them blank, and the backend rejects the whole product for an + // unsupported parameter, so drop the ones that are empty. A mapping the officer actually selects is still + // sent and gets a clear "not supported" answer rather than being silently ignored. + [ + 'intReceivablesGlAccountId', + 'intPayableGlAccountId', + 'deafIntPayableGlAccountId', + 'intOverdueReserveAccountId', + 'undebitedInterestAccountId', + 'penalIntCrGlAccountId', + 'rdMaturedGlAccountId' + ].forEach((key) => { + if (loanProduct[key] === '' || loanProduct[key] === null || loanProduct[key] === undefined) { + delete loanProduct[key]; + } + }); + + // The Advances and Loans A/C (EMI) payloads are assembled explicitly in the create component and already + // carry only whitelisted params (core Fineract fields + satellite params), so they are NOT stripped. + // For any other product (the old Settings/Terms path), the Details/Security fields below are not backend + // params, so drop them. + if (loanProduct.productTypeGroup !== 'advances' && loanProduct.productTypeGroup !== 'loans') { + delete loanProduct.productTypeGroup; + delete loanProduct.productTypeSubGroup; + delete loanProduct.glAccount; + delete loanProduct.loanProductCategory; + delete loanProduct.prioritySector; + delete loanProduct.cibilAccountType; + delete loanProduct.kfsEnabled; + delete loanProduct.aprEnabled; + delete loanProduct.securityType; + delete loanProduct.lienCertificateRequired; + delete loanProduct.securityMargin; + delete loanProduct.dpType; + delete loanProduct.dpValue; + } // In Fineract, the POST and PUT endpoints for /v1/loanproducts have a typo in the field // allowPartialPeriodInterestCalculation. Until that is fixed, we need to replace the field name in the payload. diff --git a/src/app/products/manage-delinquency-buckets/delinquency-bucket/create-bucket/create-bucket.component.ts b/src/app/products/manage-delinquency-buckets/delinquency-bucket/create-bucket/create-bucket.component.ts index 9e3e84583..fb5046977 100644 --- a/src/app/products/manage-delinquency-buckets/delinquency-bucket/create-bucket/create-bucket.component.ts +++ b/src/app/products/manage-delinquency-buckets/delinquency-bucket/create-bucket/create-bucket.component.ts @@ -12,6 +12,7 @@ import { MatDialog } from '@angular/material/dialog'; import { ActivatedRoute, Router, RouterLink } from '@angular/router'; import { TranslateService } from '@ngx-translate/core'; import { ProductsService } from 'app/products/products.service'; +import { AlertService } from 'app/core/alert/alert.service'; import { DeleteDialogComponent } from 'app/shared/delete-dialog/delete-dialog.component'; import { FormDialogComponent } from 'app/shared/form-dialog/form-dialog.component'; import { FormfieldBase } from 'app/shared/form-dialog/formfield/model/formfield-base'; @@ -59,6 +60,7 @@ import { STANDALONE_SHARED_IMPORTS } from 'app/standalone-shared.module'; export class CreateBucketComponent implements OnInit { private formBuilder = inject(UntypedFormBuilder); private productsService = inject(ProductsService); + private alertService = inject(AlertService); private router = inject(Router); private route = inject(ActivatedRoute); dialog = inject(MatDialog); @@ -174,6 +176,15 @@ export class CreateBucketComponent implements OnInit { }; this.productsService.createDelinquencyBucket(data).subscribe((response: any) => { + // Maker-checker: the request waits in the Checker Inbox, so there is no bucket to open yet. + if (response?.rollbackTransaction) { + this.alertService.alert({ + type: 'Success', + message: 'Delinquency bucket request submitted for approval.' + }); + this.router.navigate(['../'], { relativeTo: this.route }); + return; + } this.router.navigate( [ '../', diff --git a/src/app/products/manage-delinquency-buckets/delinquency-bucket/delinquency-bucket.component.html b/src/app/products/manage-delinquency-buckets/delinquency-bucket/delinquency-bucket.component.html index 1088a50ab..7a26b5f79 100644 --- a/src/app/products/manage-delinquency-buckets/delinquency-bucket/delinquency-bucket.component.html +++ b/src/app/products/manage-delinquency-buckets/delinquency-bucket/delinquency-bucket.component.html @@ -7,6 +7,8 @@ -->
+ +

diff --git a/src/app/products/manage-delinquency-buckets/delinquency-bucket/delinquency-bucket.component.ts b/src/app/products/manage-delinquency-buckets/delinquency-bucket/delinquency-bucket.component.ts index 00e11022e..24059b71f 100644 --- a/src/app/products/manage-delinquency-buckets/delinquency-bucket/delinquency-bucket.component.ts +++ b/src/app/products/manage-delinquency-buckets/delinquency-bucket/delinquency-bucket.component.ts @@ -24,6 +24,7 @@ import { } from '@angular/material/table'; import { ActivatedRoute, RouterLink } from '@angular/router'; import { FaIconComponent } from '@fortawesome/angular-fontawesome'; +import { DelinquencyPendingRequestsComponent } from '../pending-requests/delinquency-pending-requests.component'; import { STANDALONE_SHARED_IMPORTS } from 'app/standalone-shared.module'; @Component({ @@ -33,6 +34,7 @@ import { STANDALONE_SHARED_IMPORTS } from 'app/standalone-shared.module'; imports: [ ...STANDALONE_SHARED_IMPORTS, FaIconComponent, + DelinquencyPendingRequestsComponent, MatTable, MatSort, MatColumnDef, diff --git a/src/app/products/manage-delinquency-buckets/delinquency-bucket/edit-bucket/edit-bucket.component.ts b/src/app/products/manage-delinquency-buckets/delinquency-bucket/edit-bucket/edit-bucket.component.ts index 7272967f4..716396d55 100644 --- a/src/app/products/manage-delinquency-buckets/delinquency-bucket/edit-bucket/edit-bucket.component.ts +++ b/src/app/products/manage-delinquency-buckets/delinquency-bucket/edit-bucket/edit-bucket.component.ts @@ -12,6 +12,7 @@ import { MatDialog } from '@angular/material/dialog'; import { ActivatedRoute, Router, RouterLink } from '@angular/router'; import { TranslateService } from '@ngx-translate/core'; import { ProductsService } from 'app/products/products.service'; +import { AlertService } from 'app/core/alert/alert.service'; import { DeleteDialogComponent } from 'app/shared/delete-dialog/delete-dialog.component'; import { FormDialogComponent } from 'app/shared/form-dialog/form-dialog.component'; import { FormfieldBase } from 'app/shared/form-dialog/formfield/model/formfield-base'; @@ -59,6 +60,7 @@ import { STANDALONE_SHARED_IMPORTS } from 'app/standalone-shared.module'; export class EditBucketComponent implements OnInit { private formBuilder = inject(UntypedFormBuilder); private productsService = inject(ProductsService); + private alertService = inject(AlertService); private router = inject(Router); private route = inject(ActivatedRoute); dialog = inject(MatDialog); @@ -191,7 +193,14 @@ export class EditBucketComponent implements OnInit { ranges: ranges }; - this.productsService.updateDelinquencyBucket(this.delinquencyBucketId, data).subscribe(() => { + this.productsService.updateDelinquencyBucket(this.delinquencyBucketId, data).subscribe((response: any) => { + // Maker-checker: the change is applied only after a checker approves it. + if (response?.rollbackTransaction) { + this.alertService.alert({ + type: 'Success', + message: 'Delinquency bucket change submitted for approval.' + }); + } this.router.navigate(['../'], { relativeTo: this.route }); }); } diff --git a/src/app/products/manage-delinquency-buckets/delinquency-bucket/view-bucket/view-bucket.component.ts b/src/app/products/manage-delinquency-buckets/delinquency-bucket/view-bucket/view-bucket.component.ts index ae086e225..08abc1ad2 100644 --- a/src/app/products/manage-delinquency-buckets/delinquency-bucket/view-bucket/view-bucket.component.ts +++ b/src/app/products/manage-delinquency-buckets/delinquency-bucket/view-bucket/view-bucket.component.ts @@ -10,6 +10,7 @@ import { Component, inject } from '@angular/core'; import { MatDialog } from '@angular/material/dialog'; import { ActivatedRoute, Router, RouterLink } from '@angular/router'; import { ProductsService } from 'app/products/products.service'; +import { AlertService } from 'app/core/alert/alert.service'; import { DeleteDialogComponent } from 'app/shared/delete-dialog/delete-dialog.component'; import { FaIconComponent } from '@fortawesome/angular-fontawesome'; import { STANDALONE_SHARED_IMPORTS } from 'app/standalone-shared.module'; @@ -28,6 +29,7 @@ export class ViewBucketComponent { private router = inject(Router); private dialog = inject(MatDialog); private productsService = inject(ProductsService); + private alertService = inject(AlertService); /** Delinquency Bucket Data. */ delinquencyBucketData: any; @@ -47,7 +49,14 @@ export class ViewBucketComponent { }); dialogRef.afterClosed().subscribe((response: any) => { if (response.delete) { - this.productsService.deleteDelinquencyBucket(this.delinquencyBucketData.id).subscribe(() => { + this.productsService.deleteDelinquencyBucket(this.delinquencyBucketData.id).subscribe((result: any) => { + // Maker-checker: the bucket is deleted only after a checker approves. + if (result?.rollbackTransaction) { + this.alertService.alert({ + type: 'Success', + message: 'Delinquency bucket delete request submitted for approval.' + }); + } this.router.navigate(['../'], { relativeTo: this.route }); }); } diff --git a/src/app/products/manage-delinquency-buckets/delinquency-range/create-range/create-range.component.ts b/src/app/products/manage-delinquency-buckets/delinquency-range/create-range/create-range.component.ts index 8c0e5ce92..a58a558da 100644 --- a/src/app/products/manage-delinquency-buckets/delinquency-range/create-range/create-range.component.ts +++ b/src/app/products/manage-delinquency-buckets/delinquency-range/create-range/create-range.component.ts @@ -10,6 +10,7 @@ import { Component, OnInit, inject } from '@angular/core'; import { UntypedFormBuilder, UntypedFormGroup, Validators, ReactiveFormsModule } from '@angular/forms'; import { ActivatedRoute, Router, RouterLink } from '@angular/router'; import { ProductsService } from 'app/products/products.service'; +import { AlertService } from 'app/core/alert/alert.service'; import { SettingsService } from 'app/settings/settings.service'; import { STANDALONE_SHARED_IMPORTS } from 'app/standalone-shared.module'; @@ -24,6 +25,7 @@ import { STANDALONE_SHARED_IMPORTS } from 'app/standalone-shared.module'; export class CreateRangeComponent implements OnInit { private formBuilder = inject(UntypedFormBuilder); private productsService = inject(ProductsService); + private alertService = inject(AlertService); private route = inject(ActivatedRoute); private router = inject(Router); private settingsService = inject(SettingsService); @@ -71,6 +73,15 @@ export class CreateRangeComponent implements OnInit { locale }; this.productsService.createDelinquencyRange(data).subscribe((response: any) => { + // Maker-checker: the request waits in the Checker Inbox, so there is no range to open yet. + if (response?.rollbackTransaction) { + this.alertService.alert({ + type: 'Success', + message: 'Delinquency range request submitted for approval.' + }); + this.router.navigate(['../'], { relativeTo: this.route }); + return; + } this.router.navigate( [ '../', diff --git a/src/app/products/manage-delinquency-buckets/delinquency-range/delinquency-range.component.html b/src/app/products/manage-delinquency-buckets/delinquency-range/delinquency-range.component.html index 4e56b3d84..3e2cdf8a5 100644 --- a/src/app/products/manage-delinquency-buckets/delinquency-range/delinquency-range.component.html +++ b/src/app/products/manage-delinquency-buckets/delinquency-range/delinquency-range.component.html @@ -7,6 +7,8 @@ -->
+ +

diff --git a/src/app/products/manage-delinquency-buckets/delinquency-range/delinquency-range.component.ts b/src/app/products/manage-delinquency-buckets/delinquency-range/delinquency-range.component.ts index 41d218989..706f2c846 100644 --- a/src/app/products/manage-delinquency-buckets/delinquency-range/delinquency-range.component.ts +++ b/src/app/products/manage-delinquency-buckets/delinquency-range/delinquency-range.component.ts @@ -24,6 +24,7 @@ import { } from '@angular/material/table'; import { ActivatedRoute, RouterLink } from '@angular/router'; import { FaIconComponent } from '@fortawesome/angular-fontawesome'; +import { DelinquencyPendingRequestsComponent } from '../pending-requests/delinquency-pending-requests.component'; import { STANDALONE_SHARED_IMPORTS } from 'app/standalone-shared.module'; @Component({ @@ -33,6 +34,7 @@ import { STANDALONE_SHARED_IMPORTS } from 'app/standalone-shared.module'; imports: [ ...STANDALONE_SHARED_IMPORTS, FaIconComponent, + DelinquencyPendingRequestsComponent, MatTable, MatSort, MatColumnDef, diff --git a/src/app/products/manage-delinquency-buckets/delinquency-range/edit-range/edit-range.component.ts b/src/app/products/manage-delinquency-buckets/delinquency-range/edit-range/edit-range.component.ts index a83a58af3..2aacfa487 100644 --- a/src/app/products/manage-delinquency-buckets/delinquency-range/edit-range/edit-range.component.ts +++ b/src/app/products/manage-delinquency-buckets/delinquency-range/edit-range/edit-range.component.ts @@ -10,6 +10,7 @@ import { Component, OnInit, inject } from '@angular/core'; import { UntypedFormBuilder, UntypedFormGroup, Validators, ReactiveFormsModule } from '@angular/forms'; import { ActivatedRoute, Router, RouterLink } from '@angular/router'; import { ProductsService } from 'app/products/products.service'; +import { AlertService } from 'app/core/alert/alert.service'; import { SettingsService } from 'app/settings/settings.service'; import { STANDALONE_SHARED_IMPORTS } from 'app/standalone-shared.module'; @@ -24,6 +25,7 @@ import { STANDALONE_SHARED_IMPORTS } from 'app/standalone-shared.module'; export class EditRangeComponent implements OnInit { private formBuilder = inject(UntypedFormBuilder); private productsService = inject(ProductsService); + private alertService = inject(AlertService); private route = inject(ActivatedRoute); private router = inject(Router); private settingsService = inject(SettingsService); @@ -78,6 +80,15 @@ export class EditRangeComponent implements OnInit { locale }; this.productsService.updateDelinquencyRange(this.delinquencyRangeData.id, data).subscribe((response: any) => { + // Maker-checker: the change is applied only after a checker approves it. + if (response?.rollbackTransaction) { + this.alertService.alert({ + type: 'Success', + message: 'Delinquency range change submitted for approval.' + }); + this.router.navigate(['../../'], { relativeTo: this.route }); + return; + } this.router.navigate( [ '../../', diff --git a/src/app/products/manage-delinquency-buckets/delinquency-range/view-range/view-range.component.ts b/src/app/products/manage-delinquency-buckets/delinquency-range/view-range/view-range.component.ts index 85a6a1e6c..810b80da9 100644 --- a/src/app/products/manage-delinquency-buckets/delinquency-range/view-range/view-range.component.ts +++ b/src/app/products/manage-delinquency-buckets/delinquency-range/view-range/view-range.component.ts @@ -10,6 +10,7 @@ import { Component, inject } from '@angular/core'; import { MatDialog } from '@angular/material/dialog'; import { ActivatedRoute, Router, RouterLink } from '@angular/router'; import { ProductsService } from 'app/products/products.service'; +import { AlertService } from 'app/core/alert/alert.service'; import { DeleteDialogComponent } from 'app/shared/delete-dialog/delete-dialog.component'; import { FaIconComponent } from '@fortawesome/angular-fontawesome'; import { STANDALONE_SHARED_IMPORTS } from 'app/standalone-shared.module'; @@ -28,6 +29,7 @@ export class ViewRangeComponent { private router = inject(Router); private dialog = inject(MatDialog); private productsService = inject(ProductsService); + private alertService = inject(AlertService); /** Delinquency Range Data. */ delinquencyRangeData: any; @@ -44,7 +46,14 @@ export class ViewRangeComponent { }); dialogRef.afterClosed().subscribe((response: any) => { if (response.delete) { - this.productsService.deleteDelinquencyRange(this.delinquencyRangeData.id).subscribe(() => { + this.productsService.deleteDelinquencyRange(this.delinquencyRangeData.id).subscribe((result: any) => { + // Maker-checker: the range is deleted only after a checker approves. + if (result?.rollbackTransaction) { + this.alertService.alert({ + type: 'Success', + message: 'Delinquency range delete request submitted for approval.' + }); + } this.router.navigate(['../'], { relativeTo: this.route }); }); } diff --git a/src/app/products/manage-delinquency-buckets/pending-requests/delinquency-pending-requests.component.html b/src/app/products/manage-delinquency-buckets/pending-requests/delinquency-pending-requests.component.html new file mode 100644 index 000000000..537a2e6a2 --- /dev/null +++ b/src/app/products/manage-delinquency-buckets/pending-requests/delinquency-pending-requests.component.html @@ -0,0 +1,55 @@ + + +@if (rows.length > 0) { +
+
+ + {{ rows.length }} request{{ rows.length > 1 ? 's' : '' }} awaiting approval + These changes take effect only after a checker approves them. + Open Approvals → +
+ + + + + + + + + + + + @for (row of rows; track row.id) { + + + + + + + + } + +
ActionRecordWhat changesRequested byRequested on
+ {{ row.action }} + {{ row.subject }} + @if (row.changes.length > 0) { + @for (change of row.changes; track change.label) { +
+ {{ change.label }} + {{ change.oldValue }} + → + {{ change.newValue }} +
+ } + } @else { + {{ row.detail }} + } +
{{ row.maker }}{{ row.madeOn | date: 'dd MMM yyyy, hh:mm a' }}
+
+} diff --git a/src/app/products/manage-delinquency-buckets/pending-requests/delinquency-pending-requests.component.scss b/src/app/products/manage-delinquency-buckets/pending-requests/delinquency-pending-requests.component.scss new file mode 100644 index 000000000..e913e9bad --- /dev/null +++ b/src/app/products/manage-delinquency-buckets/pending-requests/delinquency-pending-requests.component.scss @@ -0,0 +1,108 @@ +/** + * 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/. + */ + +.dpr { + margin-bottom: 16px; + border: 1px solid #fcd34d; + border-radius: 10px; + background: #fffbeb; + overflow: hidden; + + &__head { + display: flex; + align-items: center; + gap: 10px; + padding: 10px 16px; + font-size: 13px; + color: #78350f; + + i { + color: #d97706; + } + } + + &__sub { + color: #92400e; + } + + &__link { + margin-left: auto; + font-weight: 600; + color: #92400e; + text-decoration: none; + + &:hover { + text-decoration: underline; + } + } + + &__table { + width: 100%; + border-collapse: collapse; + font-size: 13px; + + th { + text-align: left; + padding: 8px 16px; + font-size: 11px; + letter-spacing: 0.05em; + text-transform: uppercase; + color: #92400e; + border-top: 1px solid #fde68a; + } + + td { + padding: 8px 16px; + border-top: 1px solid #fde68a; + color: #1e293b; + } + } + + &__change { + display: flex; + align-items: baseline; + gap: 8px; + padding: 2px 0; + } + + &__field { + min-width: 90px; + font-size: 12px; + color: #92400e; + } + + &__old { + padding: 0 6px; + border-radius: 4px; + background: #fee2e2; + color: #991b1b; + text-decoration: line-through; + } + + &__arrow { + color: #92400e; + } + + &__new { + padding: 0 6px; + border-radius: 4px; + background: #dcfce7; + color: #166534; + font-weight: 600; + } + + &__chip { + display: inline-block; + padding: 1px 10px; + border-radius: 999px; + background: #fef3c7; + color: #92400e; + font-size: 11px; + font-weight: 600; + } +} diff --git a/src/app/products/manage-delinquency-buckets/pending-requests/delinquency-pending-requests.component.ts b/src/app/products/manage-delinquency-buckets/pending-requests/delinquency-pending-requests.component.ts new file mode 100644 index 000000000..34d471f4e --- /dev/null +++ b/src/app/products/manage-delinquency-buckets/pending-requests/delinquency-pending-requests.component.ts @@ -0,0 +1,210 @@ +/** + * Copyright since 2025 Mifos Initiative + * + * This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. + */ + +import { Component, Input, OnInit, inject } from '@angular/core'; +import { DatePipe } from '@angular/common'; +import { RouterLink } from '@angular/router'; +import { Observable, forkJoin, of } from 'rxjs'; +import { catchError, map, switchMap } from 'rxjs/operators'; +import { ProductsService } from 'app/products/products.service'; + +/** One field that a pending request would change: what it is now, and what it would become. */ +interface PendingChange { + label: string; + oldValue: string; + newValue: string; +} + +interface PendingRow { + id: number; + action: string; + /** What the request is about, e.g. the range or bucket name. */ + subject: string; + /** UPDATE: the fields that move, old -> new. */ + changes: PendingChange[]; + /** CREATE / DELETE: a plain description of what is being added or removed. */ + detail: string; + maker: string; + madeOn: Date | null; +} + +/** + * Lists the Delinquency Range / Bucket requests that a maker has submitted and a checker has not yet + * approved. Without this the list pages look unchanged after a submit, so the maker cannot tell the + * request exists, what it would change, or where to approve it. + */ +@Component({ + selector: 'mifosx-delinquency-pending-requests', + templateUrl: './delinquency-pending-requests.component.html', + styleUrls: ['./delinquency-pending-requests.component.scss'], + imports: [ + DatePipe, + RouterLink + ] +}) +export class DelinquencyPendingRequestsComponent implements OnInit { + private productsService = inject(ProductsService); + + /** DELINQUENCY_RANGE or DELINQUENCY_BUCKET. */ + @Input({ required: true }) entityName: string; + + rows: PendingRow[] = []; + + ngOnInit(): void { + this.productsService + .getPendingMakerCheckerByEntity(this.entityName) + .pipe( + catchError(() => of([])), + switchMap((entries: any[]) => { + if (!entries || entries.length === 0) { + return of([] as PendingRow[]); + } + // The list call has no payload and no current values. The payload comes from the audit entry, + // and the current range or bucket from the normal read calls, so an UPDATE can show old -> new. + return forkJoin(entries.map((entry) => this.buildRow(entry))); + }) + ) + .subscribe((rows) => (this.rows = rows)); + } + + private buildRow(entry: any): Observable { + const isBucket = this.entityName === 'DELINQUENCY_BUCKET'; + const action = (entry.actionName ?? '').toUpperCase(); + const hasTarget = action !== 'CREATE' && entry.resourceId !== undefined && entry.resourceId !== null; + + const audit$ = this.productsService.getLoanRateRegisterAuditDetail(entry.id).pipe(catchError(() => of(null))); + const current$ = hasTarget + ? (isBucket + ? this.productsService.getDelinquencyBucket(entry.resourceId) + : this.productsService.getDelinquencyRange(entry.resourceId) + ).pipe(catchError(() => of(null))) + : of(null); + // Bucket ranges are stored and submitted as ids; the names come from the range list. + const ranges$ = isBucket ? this.productsService.getDelinquencyRanges().pipe(catchError(() => of([]))) : of([]); + + return forkJoin({ audit: audit$, current: current$, ranges: ranges$ }).pipe( + map(({ audit, current, ranges }) => { + let payload: any = null; + try { + payload = audit?.commandAsJson ? JSON.parse(audit.commandAsJson) : null; + } catch { + payload = null; + } + const rangesById = new Map( + (ranges ?? []).map((range: any) => [ + Number(range.id), + range + ]) + ); + const row: PendingRow = { + id: entry.id, + action, + subject: '', + changes: [], + detail: '', + maker: entry.maker, + madeOn: entry.madeOnDate ? new Date(Number(entry.madeOnDate) * 1000) : null + }; + if (isBucket) { + this.fillBucket(row, action, payload, current, rangesById); + } else { + this.fillRange(row, action, payload, current); + } + if (!row.subject) { + row.subject = entry.resourceId ? `#${entry.resourceId}` : '—'; + } + return row; + }) + ); + } + + private fillRange(row: PendingRow, action: string, payload: any, current: any): void { + const describe = (range: any) => + `${range.classification} (${range.minimumAgeDays} - ${range.maximumAgeDays ?? 'no limit'} days)`; + if (action === 'CREATE' && payload) { + row.subject = payload.classification ?? ''; + row.detail = `New range: ${describe(payload)}`; + } else if (action === 'DELETE' && current) { + row.subject = current.classification ?? ''; + row.detail = `Range to be deleted: ${describe(current)}`; + } else if (action === 'UPDATE' && current) { + row.subject = current.classification ?? ''; + const fields: [string, string][] = [ + [ + 'classification', + 'Classification' + ], + [ + 'minimumAgeDays', + 'Days from' + ], + [ + 'maximumAgeDays', + 'Days till' + ] + ]; + row.changes = this.diff(fields, payload, current); + if (row.changes.length === 0) { + row.detail = 'No field value changes'; + } + } + } + + private fillBucket(row: PendingRow, action: string, payload: any, current: any, rangesById: Map): void { + const names = (ids: any[]) => + (ids ?? []) + .map((entry) => rangesById.get(Number(entry?.id ?? entry)) ?? { id: Number(entry?.id ?? entry) }) + .sort((a, b) => (a.minimumAgeDays ?? 0) - (b.minimumAgeDays ?? 0)) + .map((range) => range.classification ?? `Range #${range.id}`) + .join(', '); + if (action === 'CREATE' && payload) { + row.subject = payload.name ?? ''; + row.detail = `New bucket with ranges: ${names(payload.ranges) || '—'}`; + } else if (action === 'DELETE' && current) { + row.subject = current.name ?? ''; + row.detail = `Bucket to be deleted (ranges: ${names(current.ranges) || '—'})`; + } else if (action === 'UPDATE' && current && payload) { + row.subject = current.name ?? ''; + const before = { name: current.name, ranges: names(current.ranges) }; + const after = { name: payload.name ?? current.name, ranges: names(payload.ranges ?? current.ranges) }; + row.changes = this.diff( + [ + [ + 'name', + 'Name' + ], + [ + 'ranges', + 'Ranges' + ] + ], + after, + before + ); + if (row.changes.length === 0) { + row.detail = 'No field value changes'; + } + } + } + + /** The fields whose submitted value differs from today's. */ + private diff(fields: [string, string][], submitted: any, current: any): PendingChange[] { + return fields + .filter(([key]) => submitted && submitted[key] !== undefined && String(submitted[key]) !== String(current?.[key])) + .map( + ([ + key, + label + ]) => ({ + label, + oldValue: String(current?.[key] ?? '—'), + newValue: String(submitted[key]) + }) + ); + } +} diff --git a/src/app/products/products-routing.module.ts b/src/app/products/products-routing.module.ts index cacec80f1..888d4de1c 100644 --- a/src/app/products/products-routing.module.ts +++ b/src/app/products/products-routing.module.ts @@ -30,6 +30,8 @@ import { EditShareProductComponent } from './share-products/edit-share-product/e import { ManageTaxConfigurationsComponent } from './manage-tax-configurations/manage-tax-configurations.component'; import { RecurringDepositProductsComponent } from './recurring-deposit-products/recurring-deposit-products.component'; import { ViewRecurringDepositProductComponent } from './recurring-deposit-products/view-recurring-deposit-product/view-recurring-deposit-product.component'; +import { DdsAccountTypesComponent } from './dds-account-types/dds-account-types.component'; +import { CreateDdsAccountTypeComponent } from './dds-account-types/create-dds-account-type/create-dds-account-type.component'; import { ChargesComponent } from './charges/charges.component'; import { ViewChargeComponent } from './charges/view-charge/view-charge.component'; import { CreateChargeComponent } from './charges/create-charge/create-charge.component'; @@ -106,6 +108,10 @@ import { ChargesTemplateAndResolver } from './charges/charges-template-and-resol import { EditFixedDepositProductComponent } from './fixed-deposit-products/edit-fixed-deposit-product/edit-fixed-deposit-product.component'; import { FixedDepositProductAndTemplateResolver } from './fixed-deposit-products/edit-fixed-deposit-product/fixed-deposit-product-and-template.resolver'; import { BaseFdInterestRateChartComponent } from './fixed-deposit-products/base-fd-interest-rate-chart/base-fd-interest-rate-chart.component'; +import { LoanInterestRateRegisterComponent } from './loan-interest-rate-register/loan-interest-rate-register.component'; +import { EditRegisterComponent } from './loan-interest-rate-register/edit-register/edit-register.component'; +import { CibilAdjustmentComponent } from './loan-interest-rate-register/cibil-adjustment/cibil-adjustment.component'; +import { PreviewRegisterComponent } from './loan-interest-rate-register/preview-register/preview-register.component'; import { CollateralResolver } from './collaterals/collateral.resolver'; import { CollateralsResolver } from './collaterals/collaterals.resolver'; import { CollateralTemplateResolver } from './collaterals/collaterals-template.resolver'; @@ -733,6 +739,26 @@ const routes: Routes = [ } ] }, + { + path: 'dds-account-types', + data: { title: 'DDS Product', breadcrumb: 'DDS Product' }, + children: [ + { + path: '', + component: DdsAccountTypesComponent + }, + { + path: 'create', + component: CreateDdsAccountTypeComponent, + data: { title: 'Create DDS Product', breadcrumb: 'Create' } + }, + { + path: ':accountTypeId/edit', + component: CreateDdsAccountTypeComponent, + data: { title: 'Edit DDS Product', breadcrumb: 'Edit', routeParamBreadcrumb: false } + } + ] + }, { path: 'fixed-deposit-products', data: { title: 'Fixed Deposit Products', breadcrumb: 'Fixed Deposit Products' }, @@ -894,6 +920,31 @@ const routes: Routes = [ } ] }, + { + path: 'loan-interest-rate-register', + data: { title: 'Loan Interest Rate Register', breadcrumb: 'Loan Interest Rate Register' }, + children: [ + { + path: '', + component: LoanInterestRateRegisterComponent + }, + { + path: 'edit', + component: EditRegisterComponent, + data: { title: 'Edit Register', breadcrumb: 'Edit' } + }, + { + path: 'cibil', + component: CibilAdjustmentComponent, + data: { title: 'CIBIL Adjustment', breadcrumb: 'CIBIL Adjustment' } + }, + { + path: 'preview', + component: PreviewRegisterComponent, + data: { title: 'Compare Changes', breadcrumb: 'Compare' } + } + ] + }, { path: 'charges', data: { title: 'Charges', breadcrumb: 'Charges' }, diff --git a/src/app/products/products.component.html b/src/app/products/products.component.html index f07c81fe7..9476f53db 100644 --- a/src/app/products/products.component.html +++ b/src/app/products/products.component.html @@ -106,6 +106,19 @@

+ + + +
diff --git a/src/app/products/products.component.scss b/src/app/products/products.component.scss index b7997935e..a52eb7707 100644 --- a/src/app/products/products.component.scss +++ b/src/app/products/products.component.scss @@ -15,43 +15,43 @@ $gray-900: #0f172a; // ── Container ── .pd-container { - max-width: 1100px; + max-width: 1280px; margin: 0 auto; - padding: 24px 20px; + padding: 12px 16px; } // ── Page Header ── .pd-header { display: flex; align-items: center; - gap: 16px; - margin-bottom: 28px; + gap: 10px; + margin-bottom: 14px; &__icon { - width: 44px; - height: 44px; - border-radius: 10px; + width: 34px; + height: 34px; + border-radius: 7px; background: linear-gradient(135deg, #0d3d78, #1a5276); display: flex; align-items: center; justify-content: center; color: #fff; - font-size: 18px; + font-size: 14px; flex-shrink: 0; } &__title { - font-size: 20px; + font-size: 16px; font-weight: 700; color: $gray-900; margin: 0; - letter-spacing: -0.3px; + letter-spacing: -0.2px; } &__sub { - font-size: 13px; + font-size: 11.5px; color: $gray-500; - margin: 2px 0 0; + margin: 1px 0 0; } } @@ -59,7 +59,7 @@ $gray-900: #0f172a; .pd-grid { display: grid; grid-template-columns: repeat(2, 1fr); - gap: 12px; + gap: 8px; @media (width <= 768px) { grid-template-columns: 1fr; @@ -70,11 +70,11 @@ $gray-900: #0f172a; .pd-card { display: flex; align-items: center; - gap: 14px; - padding: 14px 16px; + gap: 10px; + padding: 8px 12px; background: #fff; border: 1px solid $border; - border-radius: 10px; + border-radius: 7px; text-decoration: none; cursor: pointer; transition: all 0.15s ease; @@ -82,7 +82,7 @@ $gray-900: #0f172a; &:hover { border-color: #cbd5e1; background: $gray-50; - box-shadow: 0 2px 8px rgb(0 0 0 / 4%); + box-shadow: 0 1px 6px rgb(0 0 0 / 4%); .pd-card__arrow { color: $gray-700; @@ -91,13 +91,13 @@ $gray-900: #0f172a; } &__icon { - width: 40px; - height: 40px; - border-radius: 9px; + width: 30px; + height: 30px; + border-radius: 6px; display: flex; align-items: center; justify-content: center; - font-size: 15px; + font-size: 12.5px; flex-shrink: 0; &--blue { @@ -167,25 +167,25 @@ $gray-900: #0f172a; } &__title { - font-size: 13.5px; + font-size: 12px; font-weight: 600; color: $gray-900; margin: 0; - line-height: 1.3; + line-height: 1.25; } &__desc { - font-size: 11.5px; + font-size: 10.5px; color: $gray-400; - margin: 3px 0 0; - line-height: 1.35; + margin: 1px 0 0; + line-height: 1.3; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; } &__arrow { - font-size: 11px; + font-size: 10px; color: #cbd5e1; flex-shrink: 0; transition: all 0.15s ease; diff --git a/src/app/products/products.service.ts b/src/app/products/products.service.ts index ea06b334b..28645524c 100644 --- a/src/app/products/products.service.ts +++ b/src/app/products/products.service.ts @@ -8,7 +8,8 @@ /** Angular Imports */ import { Injectable, inject } from '@angular/core'; -import { HttpClient, HttpParams } from '@angular/common/http'; +import { HttpClient, HttpContext, HttpParams } from '@angular/common/http'; +import { SILENT_HTTP_ERROR } from 'app/core/http/silent-error.token'; /** rxjs Imports */ import { Observable } from 'rxjs'; @@ -348,6 +349,42 @@ export class ProductsService { return this.http.get('/recurringdepositproducts/template'); } + /** + * DDS Account Types — the legacy "Account Type Creation" master (group / code / description / + * auto-interest-post). Master data only, deliberately not a deposit product: DDS accrues + * interest off each Party's own rate, so there is no product engine behind this. + * + * @returns {Observable} DDS account types + */ + getDdsAccountTypes(): Observable { + return this.http.get('/dds/accounttypes'); + } + + /** + * @param {string} accountTypeId DDS account type id + * @returns {Observable} A single DDS account type + */ + getDdsAccountType(accountTypeId: string | number): Observable { + return this.http.get(`/dds/accounttypes/${accountTypeId}`); + } + + /** + * @param {any} accountType DDS account type to create + * @returns {Observable} + */ + createDdsAccountType(accountType: any): Observable { + return this.http.post('/dds/accounttypes', accountType); + } + + /** + * @param {string} accountTypeId DDS account type id + * @param {any} accountType Changes to apply + * @returns {Observable} + */ + updateDdsAccountType(accountTypeId: string | number, accountType: any): Observable { + return this.http.put(`/dds/accounttypes/${accountTypeId}`, accountType); + } + /** * @returns {Observable} Charges data. */ @@ -800,6 +837,63 @@ export class ProductsService { return this.http.post('/fixeddepositproducts/baseinterestrates', payload); } + // ---- Loan Interest Rate Register (CBS Loan Base Interest Rate) ----------- + // Reads carry SILENT_HTTP_ERROR so a backend not-yet-deployed (404) doesn't surface the global + // error modal — rate-register.service.ts has its own catchError that falls back to seeded data. + + private silentCtx(): { context: HttpContext } { + return { context: new HttpContext().set(SILENT_HTTP_ERROR, true) }; + } + + /** Returns all Loan Rate Register versions, newest-effective first (for Version History accordion). */ + getLoanBaseInterestRates(): Observable { + return this.http.get('/loanproducts/baseinterestrates', this.silentCtx()); + } + + /** Returns the currently in-force Loan Rate Register version (isActive=true), or null when none exists. */ + getActiveLoanBaseInterestRate(): Observable { + return this.http.get('/loanproducts/baseinterestrates/active', this.silentCtx()); + } + + /** Returns a specific Loan Rate Register version by id. */ + getLoanBaseInterestRateById(id: number): Observable { + return this.http.get(`/loanproducts/baseinterestrates/${id}`, this.silentCtx()); + } + + /** Submitted Loan Rate Register versions still awaiting a checker (maker-checker entries with their payload). */ + getPendingLoanRateRegisters(): Observable { + const params = new HttpParams().set('entityName', 'LOANBASEINTERESTRATE'); + return this.http.get('/makercheckers', { params, context: new HttpContext().set(SILENT_HTTP_ERROR, true) }); + } + + /** Requests of one entity (e.g. DELINQUENCY_RANGE) still awaiting a checker. */ + getPendingMakerCheckerByEntity(entityName: string): Observable { + const params = new HttpParams().set('entityName', entityName); + return this.http.get('/makercheckers', { params, context: new HttpContext().set(SILENT_HTTP_ERROR, true) }); + } + + /** Full maker-checker entry, including the submitted payload (commandAsJson), which the list call omits. */ + getLoanRateRegisterAuditDetail(id: number): Observable { + return this.http.get(`/audits/${id}`, { context: new HttpContext().set(SILENT_HTTP_ERROR, true) }); + } + + /** GL accounts a particular can be mapped to: asset (type 1) posting (detail) accounts that are not disabled. */ + getLoanRegisterGlAccounts(): Observable { + const params = new HttpParams().set('type', '1').set('usage', '1').set('disabled', 'false'); + return this.http.get('/glaccounts', { params, context: new HttpContext().set(SILENT_HTTP_ERROR, true) }); + } + + /** Returns the master list of particulars (loan types) that make up the Loan Rate Register chart rows. */ + getLoanRateRegisterProducts(): Observable { + return this.http.get('/loanproducts/baseinterestrates/products', this.silentCtx()); + } + + /** Submits a new Loan Rate Register version. If maker-checker is enabled, the response's + * `rollbackTransaction` flag is true and the row sits in the Approvals tab until a checker approves. */ + saveLoanBaseInterestRate(payload: any): Observable { + return this.http.post('/loanproducts/baseinterestrates', payload, this.silentCtx()); + } + /** Returns loan interest rate categories. Pass activeOnly=true to get only active ones. */ getLoanInterestRateCategories(activeOnly = false): Observable { const url = activeOnly diff --git a/src/app/products/recurring-deposit-products/create-recurring-deposit-product/create-recurring-deposit-product.component.html b/src/app/products/recurring-deposit-products/create-recurring-deposit-product/create-recurring-deposit-product.component.html index bf3559643..bc9302911 100644 --- a/src/app/products/recurring-deposit-products/create-recurring-deposit-product/create-recurring-deposit-product.component.html +++ b/src/app/products/recurring-deposit-products/create-recurring-deposit-product/create-recurring-deposit-product.component.html @@ -89,7 +89,18 @@

{{ activeBaseVersion.remarks }}

} - +

+ RD tenure is counted in months, from {{ rdChart.minTenureMonths ?? '-' }} months (the Minimum Tenure on the + Terms tab). A rate below the first slab comes from the RD CBS base chart; the rest from this chart. +

+ @if (rdChart.shortTermRateMissing) { +

+ The RD CBS base chart has no rate for {{ configuredMinTenureMonths }} months, so this product can only be + opened from {{ rdChart.minTenureMonths }} months. +

+ } + +
diff --git a/src/app/products/recurring-deposit-products/create-recurring-deposit-product/create-recurring-deposit-product.component.ts b/src/app/products/recurring-deposit-products/create-recurring-deposit-product/create-recurring-deposit-product.component.ts index e67d1bf8b..c27a58bf8 100644 --- a/src/app/products/recurring-deposit-products/create-recurring-deposit-product/create-recurring-deposit-product.component.ts +++ b/src/app/products/recurring-deposit-products/create-recurring-deposit-product/create-recurring-deposit-product.component.ts @@ -35,6 +35,8 @@ import { ProductsService } from 'app/products/products.service'; import { SettingsService } from 'app/settings/settings.service'; import { Accounting } from 'app/core/utils/accounting'; import { Dates } from 'app/core/utils/dates'; +import { AlertService } from 'app/core/alert/alert.service'; +import { buildRdChart, RdChart, tenureInMonths } from '../rd-product-chart'; import { FaIconComponent } from '@fortawesome/angular-fontawesome'; import { RecurringDepositProductPreviewStepComponent } from '../recurring-deposit-product-stepper/recurring-deposit-product-preview-step/recurring-deposit-product-preview-step.component'; import { MatTabsModule } from '@angular/material/tabs'; @@ -74,6 +76,7 @@ export class CreateRecurringDepositProductComponent { private settingsService = inject(SettingsService); private accounting = inject(Accounting); private dateUtils = inject(Dates); + private alertService = inject(AlertService); @ViewChild(RecurringDepositProductDetailsStepComponent, { static: true }) recurringDepositProductDetailsStep: RecurringDepositProductDetailsStepComponent; @@ -92,6 +95,9 @@ export class CreateRecurringDepositProductComponent { accountingRuleData: string[] = []; selectedTabIndex = 0; rdInterestRates: any[] = []; + rdBaseSlabs: any[] = []; + private rdChartCache: { baseSlabs: any[]; rdBaseSlabs: any[]; minTenureMonths: number | null; chart: RdChart } | null = + null; readonly rdSlabColumns = [ 'srNo', 'description', @@ -122,6 +128,17 @@ export class CreateRecurringDepositProductComponent { this.rdInterestRates = []; } }); + // The RD base chart supplies the rate for 6 months to below 1 year, which the FD chart does not cover + this.productsService.getRdBaseInterestRates().subscribe({ + next: (rates: any) => { + const versions: any[] = Array.isArray(rates) ? rates : rates ? [rates] : []; + const active = versions.find((version: any) => version.isActive) ?? versions[0]; + this.rdBaseSlabs = active?.slabs || []; + }, + error: () => { + this.rdBaseSlabs = []; + } + }); } get recurringDepositProductDetailsForm() { @@ -167,11 +184,37 @@ export class CreateRecurringDepositProductComponent { return null; } + /** The product's chart in months, from 6 months up - see rd-product-chart.ts. */ + /** The minimum tenure entered on the Terms tab, in months. */ + get configuredMinTenureMonths(): number | null { + const terms = this.recurringDepositProductTermsForm?.value; + return tenureInMonths(terms?.minDepositTerm, terms?.minDepositTermTypeId); + } + + get rdChart(): RdChart { + const baseSlabs = this.activeBaseVersion?.slabs || []; + const minTenureMonths = this.configuredMinTenureMonths; + if ( + this.rdChartCache?.baseSlabs !== baseSlabs || + this.rdChartCache?.rdBaseSlabs !== this.rdBaseSlabs || + this.rdChartCache?.minTenureMonths !== minTenureMonths + ) { + this.rdChartCache = { + baseSlabs, + rdBaseSlabs: this.rdBaseSlabs, + minTenureMonths, + chart: buildRdChart(baseSlabs, this.rdBaseSlabs, minTenureMonths) + }; + } + return this.rdChartCache.chart; + } + get activeBaseSlabs() { - return this.activeBaseVersion?.slabs || []; + return this.rdChart.slabs; } get recurringDepositProduct() { + const rdChart = this.rdChart; return { ...this.recurringDepositProductDetailsStep.recurringDepositProductDetails, ...this.recurringDepositProductCurrencyStep.recurringDepositProductCurrency, @@ -179,14 +222,20 @@ export class CreateRecurringDepositProductComponent { ...this.recurringDepositProductSettingsStep.recurringDepositProductSettings, ...this.recurringDepositProductChargesStep.recurringDepositProductCharges, ...this.recurringDepositProductAccountingStep.recurringDepositProductAccounting, + // The minimum comes from the Terms tab. Only when the chart has no rate that short is it raised to where the + // chart's rates begin, so no account can be opened for a tenure the chart cannot price. + ...(rdChart.minTenureMonths != null && rdChart.minTenureMonths !== this.configuredMinTenureMonths + ? { minDepositTerm: rdChart.minTenureMonths, minDepositTermTypeId: 2 } + : {}), charts: this.recurringDepositProductCharts, - rdSlabs: this.activeBaseSlabs + rdSlabs: rdChart.slabs }; } get recurringDepositProductCharts() { const activeBaseVersion = this.activeBaseVersion; - if (!activeBaseVersion?.slabs?.length) { + const slabs = this.rdChart.slabs; + if (!activeBaseVersion || !slabs.length) { return []; } const dateFormat = this.settingsService.dateFormat; @@ -208,7 +257,7 @@ export class CreateRecurringDepositProductComponent { isPrimaryGroupingByAmount: false, locale: this.settingsService.language.code, dateFormat, - chartSlabs: activeBaseVersion.slabs.map((slab: any) => { + chartSlabs: slabs.map((slab: any) => { const chartSlab: any = { periodType: slab.periodTypeEnum, fromPeriod: slab.fromPeriod, @@ -232,7 +281,28 @@ export class CreateRecurringDepositProductComponent { ] as any[]; } + /** + * Terms (min/max installment) and Settings (lien percentage) have rules of their own that the Next buttons do not + * enforce. Opens the first tab that breaks one, with its errors shown, instead of submitting. + */ + private focusFirstInvalidStep(): boolean { + const steps = [ + { form: this.recurringDepositProductTermsForm, tab: 2 }, + { form: this.recurringDepositProductSettingsForm, tab: 3 } + ]; + const invalid = steps.find((step) => step.form?.invalid); + if (!invalid) { + return false; + } + invalid.form.markAllAsTouched(); + this.selectedTabIndex = invalid.tab; + return true; + } + submit() { + if (this.focusFirstInvalidStep()) { + return; + } const recurringDepositProduct: any = this.pruneEmptyValues({ ...this.recurringDepositProduct, charts: this.recurringDepositProductCharts, @@ -243,14 +313,24 @@ export class CreateRecurringDepositProductComponent { recurringDepositProduct.description = ''; } delete recurringDepositProduct.advancedAccountingRules; - this.productsService.createRecurringDepositProduct(recurringDepositProduct).subscribe((response: any) => { - this.router.navigate( - [ - '../', - response.resourceId - ], - { relativeTo: this.route } - ); + this.productsService.createRecurringDepositProduct(recurringDepositProduct).subscribe({ + next: (response: any) => { + if (response?.rollbackTransaction) { + this.alertService.alert({ + type: 'Success', + message: 'Recurring deposit product creation request submitted successfully for approval.' + }); + this.router.navigate(['../'], { relativeTo: this.route }); + } else { + this.router.navigate( + [ + '../', + response.resourceId + ], + { relativeTo: this.route } + ); + } + } }); } diff --git a/src/app/products/recurring-deposit-products/edit-recurring-deposit-product/edit-recurring-deposit-product.component.ts b/src/app/products/recurring-deposit-products/edit-recurring-deposit-product/edit-recurring-deposit-product.component.ts index b3efe62b1..f6affcdb4 100644 --- a/src/app/products/recurring-deposit-products/edit-recurring-deposit-product/edit-recurring-deposit-product.component.ts +++ b/src/app/products/recurring-deposit-products/edit-recurring-deposit-product/edit-recurring-deposit-product.component.ts @@ -241,7 +241,28 @@ export class EditRecurringDepositProductComponent { ] as any[]; } + /** + * Terms (min/max installment) and Settings (lien percentage) have rules of their own that the Next buttons do not + * enforce. Opens the first tab that breaks one, with its errors shown, instead of submitting. + */ + private focusFirstInvalidStep(): boolean { + const steps = [ + { form: this.recurringDepositProductTermsForm, tab: 2 }, + { form: this.recurringDepositProductSettingsForm, tab: 3 } + ]; + const invalid = steps.find((step) => step.form?.invalid); + if (!invalid) { + return false; + } + invalid.form.markAllAsTouched(); + this.selectedTabIndex = invalid.tab; + return true; + } + submit() { + if (this.focusFirstInvalidStep()) { + return; + } const recurringDepositProduct: any = this.pruneEmptyValues({ ...this.recurringDepositProduct, charges: (this.recurringDepositProduct.charges ?? []).map((charge: any) => ({ id: charge.id })), diff --git a/src/app/products/recurring-deposit-products/rd-product-chart.ts b/src/app/products/recurring-deposit-products/rd-product-chart.ts new file mode 100644 index 000000000..c26f4431e --- /dev/null +++ b/src/app/products/recurring-deposit-products/rd-product-chart.ts @@ -0,0 +1,207 @@ +/** + * Copyright since 2025 Mifos Initiative + * + * This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. + */ + +/** + * Builds a recurring deposit product's interest chart in months. + * + * RD products take their rates from the FD "DOUBLE" CBS base chart, which is kept in years and starts at 1 year, so + * on its own it leaves every tenure under 12 months without a rate. When the product's minimum tenure is shorter than + * that, the band from the minimum up to just below the first slab takes its rate from the RD CBS base chart - the slab + * covering the shortest possible span of that many months. + * + * Every slab is expressed in months because the backend matches a tenure to a slab by counting whole units of the + * slab's own period type: a 30-month RD is "2 years" against a year slab, so "1-2 Years" really covers 12-35 months. + * Writing that out as months keeps the chart identical in meaning while letting a single chart hold the sub-year band. + */ + +/** SavingsPeriodFrequencyType ids. */ +const MONTHS_PERIOD_TYPE = 2; +const YEARS_PERIOD_TYPE = 3; + +export interface RdChartSlab { + srNo: number; + description: string; + periodTypeEnum: number; + periodTypeLabel: string; + fromPeriod: number; + toPeriod: number | null; + annualInterestRate: number; + seniorCitizenRate?: number; + amountRangeFrom?: number; + amountRangeTo?: number; +} + +export interface RdChart { + slabs: RdChartSlab[]; + /** Shortest tenure, in months, the product opens - its minimum deposit term. */ + minTenureMonths: number | null; + /** Set when the RD base chart has no rate for the minimum tenure, so the product can only start at its first slab. */ + shortTermRateMissing: boolean; +} + +/** A tenure given as a count of months or years (SavingsPeriodFrequencyType id), in months. */ +export function tenureInMonths(term: number | string | null | undefined, termTypeId: number | null | undefined): number | null { + const value = Number(term); + if (term === null || term === undefined || term === '' || !Number.isFinite(value) || value <= 0) { + return null; + } + return termTypeId === YEARS_PERIOD_TYPE ? value * 12 : value; +} + +function unitInMonths(slab: any): number | null { + const label = String(slab.periodTypeLabel ?? slab.periodType?.value ?? '').toLowerCase(); + if (label.startsWith('year')) { + return 12; + } + if (label.startsWith('month')) { + return 1; + } + return null; +} + +function inDays(slab: any, period: number): number | null { + const label = String(slab.periodTypeLabel ?? slab.periodType?.value ?? '').toLowerCase(); + if (label.startsWith('day')) { + return period; + } + if (label.startsWith('week')) { + return period * 7; + } + if (label.startsWith('month')) { + return period * 30; + } + if (label.startsWith('year')) { + return period * 365; + } + return null; +} + +/** The fewest calendar days any run of {@code months} months can span (6 months -> 181, January to July). */ +function shortestSpanInDays(months: number): number { + let shortest = Infinity; + for (let start = 0; start < 12; start++) { + const from = Date.UTC(2025, start, 1); + const to = Date.UTC(2025, start + months, 1); + shortest = Math.min(shortest, Math.round((to - from) / 86400000)); + } + return shortest; +} + +/** The RD base chart rate for an RD of {@code months} months, or null when that chart has no slab covering it. */ +function shortTermRate(rdBaseSlabs: any[], months: number): number | null { + const days = shortestSpanInDays(months); + const slab = (rdBaseSlabs || []).find((candidate: any) => { + const from = inDays(candidate, candidate.fromPeriod ?? 0); + const to = candidate.toPeriod == null ? Infinity : inDays(candidate, candidate.toPeriod); + return from != null && to != null && from <= days && days <= to; + }); + return slab?.annualInterestRate ?? null; +} + +/** + * Month-wise slabs from {@code fromMonths} to {@code toMonths}, each month priced from the RD base chart and runs of + * months at the same rate merged into one slab - so a 3-month minimum against a chart with day bands for 46-90, + * 91-180 and 181-364 days becomes 3, 4-5 and 6-11 months rather than one rate for the lot. Empty when the base chart + * has no rate for the first month; a later month without one carries the previous month's rate. + */ +function shortTermBands(rdBaseSlabs: any[], fromMonths: number, toMonths: number): RdChartSlab[] { + const bands: RdChartSlab[] = []; + let previousRate: number | null = null; + let bandStart = fromMonths; + for (let month = fromMonths; month <= toMonths; month++) { + const rate = shortTermRate(rdBaseSlabs, month) ?? previousRate; + if (rate == null) { + return []; + } + const last = bands[bands.length - 1]; + if (last && last.annualInterestRate === rate) { + last.toPeriod = month; + last.description = monthsLabel(bandStart, month); + } else { + bandStart = month; + bands.push({ + srNo: 0, + description: monthsLabel(month, month), + periodTypeEnum: MONTHS_PERIOD_TYPE, + periodTypeLabel: 'Months', + fromPeriod: month, + toPeriod: month, + annualInterestRate: rate + }); + } + previousRate = rate; + } + return bands; +} + +function monthsLabel(from: number, to: number | null): string { + return to == null ? `${from}+ Months` : `${from} to ${to} Months`; +} + +/** + * @param minTenureMonths the product's minimum tenure in months; null when not yet entered, in which case the chart + * simply starts at its first slab + */ +export function buildRdChart(baseSlabs: any[], rdBaseSlabs: any[], minTenureMonths: number | null): RdChart { + const slabs: RdChartSlab[] = []; + const sorted = [...(baseSlabs || [])].sort((a: any, b: any) => (a.fromPeriod ?? 0) - (b.fromPeriod ?? 0)); + for (const slab of sorted) { + const unit = unitInMonths(slab); + if (unit == null) { + continue; + } + const from = (slab.fromPeriod ?? 0) * unit; + const to = slab.toPeriod == null ? null : (slab.toPeriod + 1) * unit - 1; + const converted: RdChartSlab = { + srNo: 0, + description: monthsLabel(from, to), + periodTypeEnum: MONTHS_PERIOD_TYPE, + periodTypeLabel: 'Months', + fromPeriod: from, + toPeriod: to, + annualInterestRate: slab.annualInterestRate + }; + if (slab.seniorCitizenRate != null) { + converted.seniorCitizenRate = slab.seniorCitizenRate; + } + if (slab.amountRangeFrom != null && slab.amountRangeFrom !== '') { + converted.amountRangeFrom = slab.amountRangeFrom; + } + if (slab.amountRangeTo != null && slab.amountRangeTo !== '') { + converted.amountRangeTo = slab.amountRangeTo; + } + slabs.push(converted); + } + + // The server only accepts a chart whose first slab starts at 0 or 1, so the chart always opens at 1 month and the + // product's minimum deposit term - not the chart - is what keeps an RD from being opened below the minimum. + let shortTermRateMissing = false; + let effectiveMinimum: number | null = null; + const firstFrom = slabs.length ? slabs[0].fromPeriod : null; + if (firstFrom != null) { + effectiveMinimum = minTenureMonths == null ? firstFrom : Math.max(minTenureMonths, 1); + if (firstFrom > 1) { + const needsShortTermBand = minTenureMonths != null && minTenureMonths < firstFrom; + const shortTermSlabs = needsShortTermBand ? shortTermBands(rdBaseSlabs, minTenureMonths, firstFrom - 1) : []; + if (shortTermSlabs.length) { + // The first band is opened down to 1 month for the server; its label still names the real minimum. + shortTermSlabs[0].fromPeriod = 1; + slabs.unshift(...shortTermSlabs); + } else { + // No rate below the first slab: stretch it down to 1 month to satisfy the server, but keep the minimum at + // where that slab really starts so nothing shorter can be opened at its rate. + shortTermRateMissing = needsShortTermBand; + slabs[0].fromPeriod = 1; + effectiveMinimum = Math.max(effectiveMinimum, firstFrom); + } + } + } + + slabs.forEach((slab, index) => (slab.srNo = index + 1)); + return { slabs, minTenureMonths: effectiveMinimum, shortTermRateMissing }; +} diff --git a/src/app/products/recurring-deposit-products/recurring-deposit-product-stepper/recurring-deposit-product-accounting-step/recurring-deposit-product-accounting-step.component.ts b/src/app/products/recurring-deposit-products/recurring-deposit-product-stepper/recurring-deposit-product-accounting-step/recurring-deposit-product-accounting-step.component.ts index 6d3332921..7f20525e7 100644 --- a/src/app/products/recurring-deposit-products/recurring-deposit-product-stepper/recurring-deposit-product-accounting-step/recurring-deposit-product-accounting-step.component.ts +++ b/src/app/products/recurring-deposit-products/recurring-deposit-product-stepper/recurring-deposit-product-accounting-step/recurring-deposit-product-accounting-step.component.ts @@ -95,11 +95,14 @@ export class RecurringDepositProductAccountingStepComponent implements OnInit { otherGlMappingsMap: this.recurringDepositProductsTemplate?.otherGlMappingsMap }; const accountingMappingOptions = this.recurringDepositProductsTemplate?.accountingMappingOptions || {}; - // Defaults to Cash (2) for a brand-new product (no existing accountingRule yet), but preserves - // the product's own current accounting rule when editing - previously this was hardcoded to 2 - // unconditionally, which silently forced every edited product onto Cash-based accounting (and - // its required GL mappings) even for products that were and should remain "None". - const accountingRuleId = this.recurringDepositProductsTemplate?.accountingRule?.id ?? 2; + // Cash (2) for a brand-new product, but the product's own current accounting rule when editing - + // previously this was hardcoded to 2 unconditionally, which silently forced every edited product + // onto Cash-based accounting (and its required GL mappings) even for products that were and + // should remain "None". Only an existing product (one with an id) has a rule of its own: the + // create template always reports accountingRule = None, so reading it there submitted every new + // product as None while this screen showed "Cash", and hid the GL mappings it needs. + const isEditing = !!this.recurringDepositProductsTemplate?.id; + const accountingRuleId = isEditing ? (this.recurringDepositProductsTemplate?.accountingRule?.id ?? 2) : 2; this.assetAccountOptions = accountingMappingOptions.assetAccountOptions || []; this.liabilityAccountOptions = accountingMappingOptions.liabilityAccountOptions || []; @@ -172,11 +175,20 @@ export class RecurringDepositProductAccountingStepComponent implements OnInit { if (this.isCashOrAccrualAccounting()) { const glMappings = mappings.otherGlMappingsMap || mappings.otherGlMappings; this.setOtherGlMappings('assetsOtherGlMappings', glMappings?.assets || [], this.assetAccountOptions); - this.setOtherGlMappings( - 'liabilitiesOtherGlMappings', - glMappings?.liabilities || [], + // Interest Paid is a liability clearing account (Dr Payable / Cr Paid, then Dr Paid / Cr RD), saved under + // liabilities. Products saved before that kept it under expenses, so both groups are read. + const fromLiabilities = this.extractNamedGlMapping( + this.extractNamedGlMapping( + glMappings?.liabilities || [], + RECURRING_DEPOSIT_INTEREST_PAID_ACCOUNT_NAME, + 'savingsInterestPaidAccountId', + this.liabilityAccountOptions + ), + SAVINGS_INTEREST_PAID_ACCOUNT_NAME, + 'savingsInterestPaidAccountId', this.liabilityAccountOptions ); + this.setOtherGlMappings('liabilitiesOtherGlMappings', fromLiabilities, this.liabilityAccountOptions); const fromExpenses = this.extractNamedGlMapping( this.extractNamedGlMapping( glMappings?.expenses || [], @@ -265,15 +277,15 @@ export class RecurringDepositProductAccountingStepComponent implements OnInit { const otherGlMappings = { assets: map('assetsOtherGlMappings', this.assetAccountOptions), - liabilities: map('liabilitiesOtherGlMappings', this.liabilityAccountOptions), - expenses: [ + liabilities: [ ...namedAsOtherGlMapping( 'savingsInterestPaidAccountId', RECURRING_DEPOSIT_INTEREST_PAID_ACCOUNT_NAME, - this.expenseAccountOptions + this.liabilityAccountOptions ), - ...map('expensesOtherGlMappings', this.expenseAccountOptions) + ...map('liabilitiesOtherGlMappings', this.liabilityAccountOptions) ], + expenses: map('expensesOtherGlMappings', this.expenseAccountOptions), income: map('incomeOtherGlMappings', this.incomeAccountOptions) }; return Object.values(otherGlMappings).some((v) => v.length) ? otherGlMappings : null; @@ -312,6 +324,7 @@ export class RecurringDepositProductAccountingStepComponent implements OnInit { setRequired('incomeFromPenaltyAccountId', cashOrAccrual); setRequired('feesReceivableAccountId', accrualOnly); setRequired('penaltiesReceivableAccountId', accrualOnly); + // Required on accrual (the accrual job credits it); optional on cash, where it only routes the closure posting. setRequired('interestPayableAccountId', accrualOnly); setRequired('escheatLiabilityId', cashOrAccrual && this.isDormancyTrackingEnabled()); @@ -330,8 +343,8 @@ export class RecurringDepositProductAccountingStepComponent implements OnInit { rows: [ { key: 'savingsReferenceAccountId', - label: 'Savings Reference Account', - inputLabel: 'Savings reference account', + label: 'Recurring Deposit Reference Account', + inputLabel: 'Recurring deposit reference account', controlName: 'savingsReferenceAccountId', options: this.assetAccountOptions, required: true @@ -364,8 +377,8 @@ export class RecurringDepositProductAccountingStepComponent implements OnInit { rows: [ { key: 'savingsControlAccountId', - label: 'Savings Control Account', - inputLabel: 'Savings control account', + label: 'Recurring Deposit Control Account', + inputLabel: 'Recurring deposit control account', controlName: 'savingsControlAccountId', options: this.liabilityAccountOptions, required: true @@ -378,13 +391,22 @@ export class RecurringDepositProductAccountingStepComponent implements OnInit { options: this.liabilityAccountOptions, required: true }, + // Offered on cash as well as accrual: the interest credited at maturity or premature closure is routed + // Dr Interest On Recurring Deposits / Cr Payable, Dr Payable / Cr Interest Paid, Dr Interest Paid / Cr + // the RD. Optional - a product naming neither posts straight to the control account as before. { key: 'interestPayableAccountId', label: 'Interest Payable Account', inputLabel: 'Interest payable account', controlName: 'interestPayableAccountId', - options: this.liabilityAccountOptions, - accrualOnly: true + options: this.liabilityAccountOptions + }, + { + key: 'savingsInterestPaidAccountId', + label: 'Recurring Deposit Interest Paid', + inputLabel: 'Recurring deposit interest paid', + controlName: 'savingsInterestPaidAccountId', + options: this.liabilityAccountOptions }, { key: 'escheatLiabilityId', @@ -411,13 +433,6 @@ export class RecurringDepositProductAccountingStepComponent implements OnInit { controlName: 'interestOnSavingsAccountId', options: this.expenseAccountOptions, required: true - }, - { - key: 'savingsInterestPaidAccountId', - label: 'Recurring Deposit Interest Paid', - inputLabel: 'Recurring deposit interest paid', - controlName: 'savingsInterestPaidAccountId', - options: this.expenseAccountOptions } ], otherMappingsControlName: 'expensesOtherGlMappings', diff --git a/src/app/products/recurring-deposit-products/recurring-deposit-product-stepper/recurring-deposit-product-currency-step/recurring-deposit-product-currency-step.component.html b/src/app/products/recurring-deposit-products/recurring-deposit-product-stepper/recurring-deposit-product-currency-step/recurring-deposit-product-currency-step.component.html index 0114b7dea..7668031cd 100644 --- a/src/app/products/recurring-deposit-products/recurring-deposit-product-stepper/recurring-deposit-product-currency-step/recurring-deposit-product-currency-step.component.html +++ b/src/app/products/recurring-deposit-products/recurring-deposit-product-stepper/recurring-deposit-product-currency-step/recurring-deposit-product-currency-step.component.html @@ -66,7 +66,10 @@

Nomination Facility

-

Allow account holders to designate individuals to receive funds in the event of account holder's death.

+

+ Yes: every RD under this product must have at least one nominee (100% share) before it can be opened. No: + nominees cannot be recorded on its RDs. +

yes @@ -77,7 +80,11 @@

Auto Debit Facility

-

Enable automatic deduction of funds for recurring payments, utility bills, or EMI obligations.

+

+ Yes: an RD can opt in to having each installment collected automatically on its due date from its linked + Savings account (needs Deposit Account Link). If the balance is short, the installment stays overdue and is + retried on the next run. +

yes diff --git a/src/app/products/recurring-deposit-products/recurring-deposit-product-stepper/recurring-deposit-product-details-step/recurring-deposit-product-details-step.component.html b/src/app/products/recurring-deposit-products/recurring-deposit-product-stepper/recurring-deposit-product-details-step/recurring-deposit-product-details-step.component.html index 7940e9c35..b09f60b0e 100644 --- a/src/app/products/recurring-deposit-products/recurring-deposit-product-stepper/recurring-deposit-product-details-step/recurring-deposit-product-details-step.component.html +++ b/src/app/products/recurring-deposit-products/recurring-deposit-product-stepper/recurring-deposit-product-details-step/recurring-deposit-product-details-step.component.html @@ -68,37 +68,41 @@
- + Account Type @for (accountType of accountTypeData; track accountType) { {{ accountType }} } + Default account type pre-selected when an RD is opened under this product. Account Type is required - + GL Account (Liability) @for (account of liabilityAccountOptions; track account.id) { {{ account.name }} ({{ account.glCode }}) } + Its GL code forms part of every new RD account number. Postings use the Accounting tab. - + Effective To + Last day new RDs can be opened. Existing RDs run to maturity. - + End Date + Product wind-up date. No RD may mature after it. @if (recurringDepositProductDetailsForm.get('endDate')?.hasError('matDatepickerMin')) { End Date must be after Effective To date } @@ -115,6 +119,10 @@
Deposit Account Link + + Every RD must be linked to the customer's Savings/Current account - the default account for maturity payout + and the account Auto Debit collects installments from. +
diff --git a/src/app/products/recurring-deposit-products/recurring-deposit-product-stepper/recurring-deposit-product-details-step/recurring-deposit-product-details-step.component.scss b/src/app/products/recurring-deposit-products/recurring-deposit-product-stepper/recurring-deposit-product-details-step/recurring-deposit-product-details-step.component.scss index ca87b04f4..c04a7c293 100644 --- a/src/app/products/recurring-deposit-products/recurring-deposit-product-stepper/recurring-deposit-product-details-step/recurring-deposit-product-details-step.component.scss +++ b/src/app/products/recurring-deposit-products/recurring-deposit-product-stepper/recurring-deposit-product-details-step/recurring-deposit-product-details-step.component.scss @@ -76,6 +76,13 @@ $gray-900: #202124; .rdpd-checkbox-row { display: flex; + flex-wrap: wrap; align-items: center; + gap: 4px 12px; min-height: 56px; } + +.rdpd-checkbox-hint { + font-size: 12px; + opacity: 0.7; +} diff --git a/src/app/products/recurring-deposit-products/recurring-deposit-product-stepper/recurring-deposit-product-preview-step/recurring-deposit-product-preview-step.component.html b/src/app/products/recurring-deposit-products/recurring-deposit-product-stepper/recurring-deposit-product-preview-step/recurring-deposit-product-preview-step.component.html index a5a5d73a7..cc2b91926 100644 --- a/src/app/products/recurring-deposit-products/recurring-deposit-product-stepper/recurring-deposit-product-preview-step/recurring-deposit-product-preview-step.component.html +++ b/src/app/products/recurring-deposit-products/recurring-deposit-product-stepper/recurring-deposit-product-preview-step/recurring-deposit-product-preview-step.component.html @@ -150,7 +150,7 @@
- {{ 'labels.inputs.Nominal Annual Interest Rate' | translate }} (%): + Post-Maturity Interest Rate (%): {{ recurringDepositProduct.nominalAnnualInterestRate | formatNumber }} %
@@ -199,41 +199,11 @@ @if (recurringDepositProduct.effectiveDate) {
- Effective Date: + Product Effective From: {{ recurringDepositProduct.effectiveDate | dateFormat }}
} - @if (recurringDepositProduct.averagePeriod) { -
- Average Period: - {{ recurringDepositProduct.averagePeriod }} -
- } - -
- Auto Interest Post: - {{ recurringDepositProduct.autoPost | yesNo }} -
- - @if ( - recurringDepositProduct.frequencyFixDay !== undefined && - recurringDepositProduct.frequencyFixDay !== null && - recurringDepositProduct.frequencyFixDay !== '' - ) { -
- Frequency (Fix Day): - {{ recurringDepositProduct.frequencyFixDay }} -
- } - - @if (recurringDepositProduct.lastInterestDate) { -
- Last Interest Date: - {{ recurringDepositProduct.lastInterestDate | dateFormat }} -
- } -

{{ 'labels.heading.Settings' | translate }}

@@ -258,10 +228,13 @@ {{ recurringDepositProduct.lienMarked | yesNo }} -
- {{ 'labels.inputs.Apply Withdrawal Fee for Transfers' | translate }}: - {{ recurringDepositProduct.withdrawalFeeForTransfers | yesNo }} -
+ @if (recurringDepositProduct.lienMarked && recurringDepositProduct.lienPercentage) { +
+ Max loan against RD: + {{ recurringDepositProduct.lienPercentage }}% of balance +
+ } +
Enable Lock-in Period: @@ -279,23 +252,24 @@ }
- Maturity Instructions: + Execute Maturity Instruction Automatically: {{ recurringDepositProduct.maturityInstructions | yesNo }}
- Credit Maturity Proceeds: - {{ recurringDepositProduct.creditMaturityProceeds | yesNo }} -
- -
- Penalty Grace Period (Missed Installments): - {{ recurringDepositProduct.penaltyGracePeriodMissedInstallments | yesNo }} -
- -
- Panel Request Applicable: - {{ recurringDepositProduct.panelRequestApplicable | yesNo }} + Missed Installment Penalty: + + @if (recurringDepositProduct.penaltyGracePeriodMissedInstallments) { + {{ + recurringDepositProduct.missedInstallmentPenaltyType === 2 + ? recurringDepositProduct.missedInstallmentPenaltyAmount + '% of installment' + : recurringDepositProduct.missedInstallmentPenaltyAmount + ' per installment' + }} + after {{ recurringDepositProduct.missedInstallmentGraceDays ?? 0 }} grace day(s) + } @else { + No + } +
@@ -347,7 +321,7 @@
- + @@ -406,7 +380,7 @@
Assets
- Savings Reference Account + Recurring Deposit Reference Account {{ formatGlAccountById( recurringDepositProduct.savingsReferenceAccountId, @@ -439,7 +413,7 @@
Liabilities
- Savings Control Account + Recurring Deposit Control Account {{ formatGlAccountById( recurringDepositProduct.savingsControlAccountId, @@ -472,7 +446,7 @@
Expenses
- Interest on Savings Account + Interest on Recurring Deposits {{ formatGlAccountById( recurringDepositProduct.interestOnSavingsAccountId, diff --git a/src/app/products/recurring-deposit-products/recurring-deposit-product-stepper/recurring-deposit-product-settings-step/recurring-deposit-product-settings-step.component.html b/src/app/products/recurring-deposit-products/recurring-deposit-product-stepper/recurring-deposit-product-settings-step/recurring-deposit-product-settings-step.component.html index 674879dc3..7d8a73540 100644 --- a/src/app/products/recurring-deposit-products/recurring-deposit-product-stepper/recurring-deposit-product-settings-step/recurring-deposit-product-settings-step.component.html +++ b/src/app/products/recurring-deposit-products/recurring-deposit-product-stepper/recurring-deposit-product-settings-step/recurring-deposit-product-settings-step.component.html @@ -18,10 +18,34 @@ Loan Facility Premature Withdrawal option Tenure Flexibility - Lien Marked - Apply Withdrawal Fee for Transfers + Lien Marked (can be pledged for a loan)
+ @if (recurringDepositProductSettingsForm.value.lienMarked) { +
+ + Max loan against RD (% of balance) + + + e.g. 90 = loan up to 90% of the RD balance. Lien is checked when the RD is added as a loan guarantor. + + @if (recurringDepositProductSettingsForm.get('lienPercentage').hasError('required')) { + Enter the percentage of the RD balance that can be given as loan + } + @if ( + recurringDepositProductSettingsForm.get('lienPercentage').hasError('min') || + recurringDepositProductSettingsForm.get('lienPercentage').hasError('max') + ) { + Must be between 1 and 100 + } + +
+ } + {{ 'labels.inputs.Apply Penal Interest (less)' | translate }} @@ -93,34 +117,85 @@
- Maturity Instructions + + Execute maturity instruction automatically + + On maturity day the instruction chosen when the RD was opened runs by itself - Transfer to Savings (to + the chosen or linked savings account) or Reinvest. Off: the RD stays Matured, earning the post-maturity + rate, until staff close it. Never runs while the RD has a lien. + +
- Option to credit maturity proceeds directly to the linked account or reinvest in another product (like - FD). - -
-
- Penalty / Grace Period for Missed Installments + + Penalty / Grace Period for Missed Installments + + An installment still unpaid after the grace days is charged a penalty once, collected from the RD + balance (Income From Penalties). + +
+ @if (recurringDepositProductSettingsForm.value.penaltyGracePeriodMissedInstallments) { +
+ + Grace days + + Due 10th + 5 grace days = penalty from the 16th + @if (recurringDepositProductSettingsForm.get('missedInstallmentGraceDays').invalid) { + Whole days, 0 or more + } + + + Penalty type + + Flat amount per installment + % of the installment + + + + {{ + recurringDepositProductSettingsForm.value.missedInstallmentPenaltyType === 2 ? 'Penalty (%)' : 'Penalty amount' + }} + + @if (recurringDepositProductSettingsForm.get('missedInstallmentPenaltyAmount').invalid) { + {{ + recurringDepositProductSettingsForm.value.missedInstallmentPenaltyType === 2 + ? 'Between 0.01 and 100' + : 'Must be more than 0' + }} + } + +
+ }
- Panel Request Applicable - -
-
- No penalty for nominee / legal heir closure + + No penalty for nominee / legal heir closure + + Premature close with reason "Nominee / legal heir claim" pays the full slab rate - no pre-closure penalty. + +
- Deceased Settlement + + Deceased Settlement + + Premature close with reason "Account holder deceased": no penalty, allowed even inside lock-in, and the + proceeds may go to a nominee's savings account. + +
- Allow Debit Txn after due date + + Allow Debit Txn after due date + + For RDs on Auto Debit. On: a missed installment keeps being collected on later days. Off: it is debited + only on its due date; a missed one stays overdue. + +
diff --git a/src/app/products/recurring-deposit-products/recurring-deposit-product-stepper/recurring-deposit-product-settings-step/recurring-deposit-product-settings-step.component.scss b/src/app/products/recurring-deposit-products/recurring-deposit-product-stepper/recurring-deposit-product-settings-step/recurring-deposit-product-settings-step.component.scss index a21434a6b..f52049e12 100644 --- a/src/app/products/recurring-deposit-products/recurring-deposit-product-stepper/recurring-deposit-product-settings-step/recurring-deposit-product-settings-step.component.scss +++ b/src/app/products/recurring-deposit-products/recurring-deposit-product-stepper/recurring-deposit-product-settings-step/recurring-deposit-product-settings-step.component.scss @@ -112,6 +112,17 @@ $gray-900: #202124; padding: 0 24px; } +.rdps-row-hint { + display: block; + margin-top: 2px; + font-size: 12px; + opacity: 0.7; +} + +.rdps-penalty-fields { + padding: 12px 0 4px; +} + .rdps-list-row { display: flex; align-items: center; diff --git a/src/app/products/recurring-deposit-products/recurring-deposit-product-stepper/recurring-deposit-product-settings-step/recurring-deposit-product-settings-step.component.ts b/src/app/products/recurring-deposit-products/recurring-deposit-product-stepper/recurring-deposit-product-settings-step/recurring-deposit-product-settings-step.component.ts index 6a336eed0..d463c62fc 100644 --- a/src/app/products/recurring-deposit-products/recurring-deposit-product-stepper/recurring-deposit-product-settings-step/recurring-deposit-product-settings-step.component.ts +++ b/src/app/products/recurring-deposit-products/recurring-deposit-product-stepper/recurring-deposit-product-settings-step/recurring-deposit-product-settings-step.component.ts @@ -7,7 +7,7 @@ */ import { Component, OnInit, Input, inject } from '@angular/core'; -import { UntypedFormGroup, UntypedFormBuilder, ReactiveFormsModule } from '@angular/forms'; +import { UntypedFormGroup, UntypedFormBuilder, ReactiveFormsModule, Validators } from '@angular/forms'; import { MatCheckbox } from '@angular/material/checkbox'; import { STANDALONE_SHARED_IMPORTS } from 'app/standalone-shared.module'; @@ -44,7 +44,9 @@ export class RecurringDepositProductSettingsStepComponent implements OnInit { prematureWithdrawalOption: this.recurringDepositProductsTemplate.prematureWithdrawalOption ?? false, tenureFlexibility: this.recurringDepositProductsTemplate.tenureFlexibility ?? false, lienMarked: this.recurringDepositProductsTemplate.lienMarked ?? false, - withdrawalFeeForTransfers: this.recurringDepositProductsTemplate.withdrawalFeeForTransfers ?? false, + lienPercentage: this.recurringDepositProductsTemplate.lienPercentage ?? '', + // Not offered for recurring deposits - no withdrawal fee is ever charged on one - so it is always off. + withdrawalFeeForTransfers: false, enableLockinPeriod: this.recurringDepositProductsTemplate.enableLockinPeriod ?? false, lockinPeriodFrequency: this.recurringDepositProductsTemplate.lockinPeriodFrequency ?? '', lockinPeriodFrequencyType: this.recurringDepositProductsTemplate.lockinPeriodFrequencyType?.id ?? '', @@ -52,10 +54,11 @@ export class RecurringDepositProductSettingsStepComponent implements OnInit { preClosurePenalInterest: this.recurringDepositProductsTemplate.preClosurePenalInterest ?? '', preClosurePenalInterestOnTypeId: this.recurringDepositProductsTemplate.preClosurePenalInterestOnType?.id ?? '', maturityInstructions: this.recurringDepositProductsTemplate.maturityInstructions ?? false, - creditMaturityProceeds: this.recurringDepositProductsTemplate.creditMaturityProceeds ?? false, penaltyGracePeriodMissedInstallments: this.recurringDepositProductsTemplate.penaltyGracePeriodMissedInstallments ?? false, - panelRequestApplicable: this.recurringDepositProductsTemplate.panelRequestApplicable ?? false, + missedInstallmentGraceDays: this.recurringDepositProductsTemplate.missedInstallmentGraceDays ?? 0, + missedInstallmentPenaltyType: this.recurringDepositProductsTemplate.missedInstallmentPenaltyType ?? 1, + missedInstallmentPenaltyAmount: this.recurringDepositProductsTemplate.missedInstallmentPenaltyAmount ?? '', noPenaltyNomineeHeirClosure: this.recurringDepositProductsTemplate.noPenaltyNomineeHeirClosure ?? false, deceasedSettlement: this.recurringDepositProductsTemplate.deceasedSettlement ?? false, allowDebitTxnAfterDueDate: this.recurringDepositProductsTemplate.allowDebitTxnAfterDueDate ?? false, @@ -72,6 +75,7 @@ export class RecurringDepositProductSettingsStepComponent implements OnInit { prematureWithdrawalOption: [false], tenureFlexibility: [false], lienMarked: [false], + lienPercentage: [''], withdrawalFeeForTransfers: [false], enableLockinPeriod: [false], lockinPeriodFrequency: [''], @@ -80,21 +84,71 @@ export class RecurringDepositProductSettingsStepComponent implements OnInit { preClosurePenalInterest: [''], preClosurePenalInterestOnTypeId: [''], maturityInstructions: [false], - creditMaturityProceeds: [false], penaltyGracePeriodMissedInstallments: [false], - panelRequestApplicable: [false], + missedInstallmentGraceDays: [0], + missedInstallmentPenaltyType: [1], + missedInstallmentPenaltyAmount: [''], noPenaltyNomineeHeirClosure: [false], deceasedSettlement: [false], allowDebitTxnAfterDueDate: [false], accountClosedZeroBalanceInDayClosing: [false], minBalanceForInterestCalculation: [''] }); + // The loan limit only means something - and is only required - while the product allows a lien. + const lienMarked = this.recurringDepositProductSettingsForm.get('lienMarked'); + const lienPercentage = this.recurringDepositProductSettingsForm.get('lienPercentage'); + lienMarked.valueChanges.subscribe((marked: boolean) => { + if (marked) { + lienPercentage.setValidators([ + Validators.required, + Validators.min(1), + Validators.max(100) + ]); + } else { + lienPercentage.clearValidators(); + } + lienPercentage.updateValueAndValidity(); + }); + // Missed-installment penalty: its three fields are only required - and only sent - while it is switched on. + const penaltyOn = this.recurringDepositProductSettingsForm.get('penaltyGracePeriodMissedInstallments'); + const graceDays = this.recurringDepositProductSettingsForm.get('missedInstallmentGraceDays'); + const penaltyType = this.recurringDepositProductSettingsForm.get('missedInstallmentPenaltyType'); + const penaltyAmount = this.recurringDepositProductSettingsForm.get('missedInstallmentPenaltyAmount'); + const syncPenaltyValidators = () => { + if (penaltyOn.value) { + graceDays.setValidators([ + Validators.required, + Validators.min(0), + Validators.pattern(/^\d+$/) + ]); + penaltyType.setValidators([Validators.required]); + penaltyAmount.setValidators([ + Validators.required, + Validators.min(0.01), + ...(penaltyType.value === 2 ? [Validators.max(100)] : []) + ]); + } else { + graceDays.clearValidators(); + penaltyType.clearValidators(); + penaltyAmount.clearValidators(); + } + graceDays.updateValueAndValidity({ emitEvent: false }); + penaltyType.updateValueAndValidity({ emitEvent: false }); + penaltyAmount.updateValueAndValidity({ emitEvent: false }); + }; + penaltyOn.valueChanges.subscribe(syncPenaltyValidators); + penaltyType.valueChanges.subscribe(syncPenaltyValidators); } get recurringDepositProductSettings() { const settings = { ...this.recurringDepositProductSettingsForm.value }; + if (!settings.penaltyGracePeriodMissedInstallments) { + delete settings.missedInstallmentGraceDays; + delete settings.missedInstallmentPenaltyType; + delete settings.missedInstallmentPenaltyAmount; + } for (const key in settings) { - if (settings[key] === '') { + if (settings[key] === '' || settings[key] === null) { delete settings[key]; } } diff --git a/src/app/products/recurring-deposit-products/recurring-deposit-product-stepper/recurring-deposit-product-terms-step/recurring-deposit-product-terms-step.component.html b/src/app/products/recurring-deposit-products/recurring-deposit-product-stepper/recurring-deposit-product-terms-step/recurring-deposit-product-terms-step.component.html index 5ff5154a4..b6c9ca1af 100644 --- a/src/app/products/recurring-deposit-products/recurring-deposit-product-stepper/recurring-deposit-product-terms-step/recurring-deposit-product-terms-step.component.html +++ b/src/app/products/recurring-deposit-products/recurring-deposit-product-stepper/recurring-deposit-product-terms-step/recurring-deposit-product-terms-step.component.html @@ -10,18 +10,62 @@
- Deposit Amount + Installment Amount (per period)
- + Minimum + Smallest installment an account may commit to. Blank = no limit. + @if (recurringDepositProductTermsForm.get('minDepositAmount').hasError('min')) { + Minimum must be zero or greater + } - + Maximum + Largest installment an account may commit to. Blank = no limit. + @if (recurringDepositProductTermsForm.get('maxDepositAmount').hasError('min')) { + Maximum must be zero or greater + } + +
+ @if (recurringDepositProductTermsForm.hasError('maxBelowMin')) { +
+ ⚠️ + Maximum installment cannot be less than the minimum. +
+ } +
+
+
+
+ + Tenure +
+
+
+ + Minimum Tenure + + Shortest RD this product opens. The rate chart starts here. + @if (recurringDepositProductTermsForm.get('minDepositTerm').hasError('required')) { + Minimum Tenure is required + } + @if (recurringDepositProductTermsForm.get('minDepositTerm').hasError('min')) { + Minimum Tenure must be at least 1 + } + + + + Minimum Tenure In + + @for (unit of tenureUnitOptions; track unit.id) { + {{ unit.value }} + } +
@@ -35,51 +79,54 @@
ℹ️ - Recurring Deposit base rate: - {{ recurringDepositProductTermsForm.get('nominalAnnualInterestRate').value }}% + The rate an RD earns during its term always comes from the CBS Base Chart slab for its tenure. The + rate below applies only after maturity.
- - {{ 'labels.inputs.Nominal Annual Interest' | translate }} + + Post-Maturity Interest Rate (%) + Paid from maturity date to closure date if collected late. 0 = nothing extra. @if (recurringDepositProductTermsForm.get('nominalAnnualInterestRate').hasError('required')) { - {{ 'labels.inputs.Nominal Annual Interest' | translate }} {{ 'labels.commons.is' | translate }} - {{ 'labels.commons.required' | translate }} + Post-Maturity Interest Rate {{ 'labels.commons.is' | translate }} + {{ 'labels.commons.required' | translate }} (enter 0 for none) } @if (recurringDepositProductTermsForm.get('nominalAnnualInterestRate').hasError('min')) { - {{ 'labels.inputs.Nominal Annual Interest' | translate }} must be zero or greater + Post-Maturity Interest Rate must be zero or greater } - - Effective Date + + Product Effective From + No RD can be opened under this product before this date. Blank = from today. - + {{ 'labels.inputs.Interest Calculation Type' | translate }} @for (opt of interestCalculationTypeOptionData; track opt.id) { {{ opt.value }} } + {{ interestCalculationTypeHint }}
@@ -117,17 +164,6 @@ Days in Year is required - @if (recurringDepositProductTermsForm.value.interestCalculationType === 2) { - - Average Period - - @for (period of periodFrequencyData; track period.id) { - {{ period.value }} - } - - - } - {{ 'labels.inputs.Interest Posting Period' | translate }} - @if (recurringDepositProduct.averagePeriod) { -
- Average Period - {{ recurringDepositProduct.averagePeriod }} -
- }
{{ 'labels.inputs.Interest Posting Period' | translate }} {{ recurringDepositProduct.interestPostingPeriodType.value }} @@ -229,30 +223,6 @@ {{ 'labels.inputs.Days in Year' | translate }} {{ recurringDepositProduct.interestCalculationDaysInYearType.value }}
-
- Auto Post - - - {{ recurringDepositProduct.autoPost | yesNo }} - - -
- @if (recurringDepositProduct.frequencyFixDay) { -
- Frequency (Fix Day) - {{ recurringDepositProduct.frequencyFixDay }} -
- } - @if (recurringDepositProduct.lastInterestDate) { -
- Last Interest Date - {{ recurringDepositProduct.lastInterestDate | dateFormat }} -
- }
@@ -313,18 +283,12 @@
-
- {{ 'labels.inputs.Apply Withdrawal Fee for Transfers' | translate }} - - - {{ recurringDepositProduct.withdrawalFeeForTransfers | yesNo }} - - -
+ @if (recurringDepositProduct.lienMarked && recurringDepositProduct.lienPercentage) { +
+ Max loan against RD + {{ recurringDepositProduct.lienPercentage }}% of balance +
+ }

{{ 'labels.inputs.Lock-in Period' | translate }}

@@ -355,7 +319,7 @@

Maturity Instructions

- Maturity Instructions + Execute Maturity Instruction Automatically
- Credit Maturity Proceeds + Missed Installment Penalty - - {{ recurringDepositProduct.creditMaturityProceeds | yesNo }} - - -
-
- Penalty Grace Period (Missed Installments) - - - {{ recurringDepositProduct.penaltyGracePeriodMissedInstallments | yesNo }} - - -
-
- Panel Request Applicable - - - {{ recurringDepositProduct.panelRequestApplicable | yesNo }} - + @if (recurringDepositProduct.penaltyGracePeriodMissedInstallments) { + {{ + recurringDepositProduct.missedInstallmentPenaltyType === 2 + ? recurringDepositProduct.missedInstallmentPenaltyAmount + '% of installment' + : recurringDepositProduct.missedInstallmentPenaltyAmount + ' per installment' + }} + after {{ recurringDepositProduct.missedInstallmentGraceDays ?? 0 }} grace day(s) + } @else { + {{ false | yesNo }} + }
@@ -752,6 +695,7 @@ diff --git a/src/app/rbi-circulars/reserve-return/form1-statutory.component.scss b/src/app/rbi-circulars/reserve-return/form1-statutory.component.scss index b4cd0e3d4..938ff68c2 100644 --- a/src/app/rbi-circulars/reserve-return/form1-statutory.component.scss +++ b/src/app/rbi-circulars/reserve-return/form1-statutory.component.scss @@ -340,7 +340,6 @@ $red: #c5221f; } // ── Day by day ── -// // Outside the form's own sheet, because it is not part of what gets filed: the form reports two // closes, and this is the working behind them. .f1s-daily { diff --git a/src/app/rbi-circulars/reserve-return/reserve-return.base.ts b/src/app/rbi-circulars/reserve-return/reserve-return.base.ts index 8d7727fd9..cf3c439e1 100644 --- a/src/app/rbi-circulars/reserve-return/reserve-return.base.ts +++ b/src/app/rbi-circulars/reserve-return/reserve-return.base.ts @@ -531,8 +531,12 @@ export abstract class ReserveReturnBase { // A half-typed year parses as a real date — "30-09-0026" is year 26 — and then fails the // ordering check below with a message about column order, which is not what went wrong. // Caught first so the filer is told about the year rather than sent looking at the dates. - const halfTyped = [this.statutoryFirstColumn, this.statutorySecondColumn, this.statutoryFirstBase, this.statutorySecondBase] - .filter((d) => d && Number(d.slice(0, 4)) < 1900); + const halfTyped = [ + this.statutoryFirstColumn, + this.statutorySecondColumn, + this.statutoryFirstBase, + this.statutorySecondBase + ].filter((d) => d && Number(d.slice(0, 4)) < 1900); if (halfTyped.length) { this.statutoryError = `The year in ${halfTyped[0]} looks incomplete — enter all four digits.`; return; @@ -1362,7 +1366,9 @@ export abstract class ReserveReturnBase { private addCimsGeneralInformation(wb: ExcelJS.Workbook): void { const sheet = wb.addWorksheet('General Information'); - sheet.columns = [{ width: 40 }, { width: 52 }]; + sheet.columns = [ + { width: 40 }, + { width: 52 }]; addBanner(sheet, 'General Information', 'B'); const start = sheet.rowCount + 1; @@ -1401,7 +1407,10 @@ export abstract class ReserveReturnBase { /** Part A — the liabilities and assets, Roman-numbered I through VIII, across the dated columns. */ private addCimsPartA(wb: ExcelJS.Workbook): void { const sheet = wb.addWorksheet('Part A'); - sheet.columns = [{ width: 72 }, ...this.cimsDates.map(() => ({ width: 22 }))]; + sheet.columns = [ + { width: 72 }, + ...this.cimsDates.map(() => ({ width: 22 })) + ]; addBanner(sheet, 'Part A', this.cimsLastColumn()); sheet.addRow([]); this.addCimsEndDates(sheet, 'End Date'); @@ -1410,7 +1419,10 @@ export abstract class ReserveReturnBase { for (const line of this.statutory?.partA?.lines ?? []) { // The form's own indentation carries its numbering — (a), then (i) — so a flattened sheet // would lose which line sits under which head. - const row = sheet.addRow([`${' '.repeat(line.indent ?? 0)}${line.label}`, ...(line.values ?? [])]); + const row = sheet.addRow([ + `${' '.repeat(line.indent ?? 0)}${line.label}`, + ...(line.values ?? []) + ]); row.getCell(1).alignment = { wrapText: true }; if (line.heading || line.total) { row.font = { bold: true }; @@ -1429,7 +1441,11 @@ export abstract class ReserveReturnBase { */ private addCimsOthersSheet(wb: ExcelJS.Workbook): void { const sheet = wb.addWorksheet('Part A.II.c'); - sheet.columns = [{ width: 10 }, { width: 50 }, ...this.cimsDates.map(() => ({ width: 22 }))]; + sheet.columns = [ + { width: 10 }, + { width: 50 }, + ...this.cimsDates.map(() => ({ width: 22 })) + ]; addBanner(sheet, 'Part A.II.c', this.columnLetter(this.cimsDates.length + 2)); sheet.addRow([]); @@ -1437,7 +1453,11 @@ export abstract class ReserveReturnBase { const last = this.cimsDates.length + 2; // "As at the close of Business on" heads BOTH dated columns as one caption, as the template has // it — written once and merged across them, not repeated over each. - const head = sheet.addRow(['Sr.No.', 'Particulars', 'As at the close of Business on']); + const head = sheet.addRow([ + 'Sr.No.', + 'Particulars', + 'As at the close of Business on' + ]); head.font = { bold: true }; head.alignment = { wrapText: true, horizontal: 'center' }; head.eachCell((c) => (c.fill = solidFill(XL.headerGrey))); @@ -1448,7 +1468,11 @@ export abstract class ReserveReturnBase { head.getCell(c).fill = solidFill(XL.headerGrey); } - const dates = sheet.addRow(['', '', ...this.cimsDates]); + const dates = sheet.addRow([ + '', + '', + ...this.cimsDates + ]); dates.font = { bold: true }; dates.alignment = { horizontal: 'center' }; for (let c = 3; c <= last; c++) { @@ -1457,12 +1481,20 @@ export abstract class ReserveReturnBase { // Sr.No. 1, hatched, as the template carries it: the break-up is written in by hand, so the row // has to be there to be written in. An empty sheet with only a Total says the bank reported // nothing; a hatched row says nobody has filled it in yet. - const entry = sheet.addRow([1, '', ...this.cimsDates.map((): null => null)]); + const entry = sheet.addRow([ + 1, + '', + ...this.cimsDates.map((): null => null) + ]); for (let c = 2; c <= last; c++) { entry.getCell(c).fill = hatchFill(); } - const total = sheet.addRow(['', 'Total', ...this.cimsDates.map(() => 0)]); + const total = sheet.addRow([ + '', + 'Total', + ...this.cimsDates.map(() => 0) + ]); total.font = { bold: true }; total.getCell(1).fill = solidFill(XL.headerGrey); total.getCell(2).alignment = { horizontal: 'center' }; @@ -1477,7 +1509,10 @@ export abstract class ReserveReturnBase { /** Parts B and C on one sheet, as the template has them. */ private addCimsPartBandC(wb: ExcelJS.Workbook): void { const sheet = wb.addWorksheet('Part B and C'); - sheet.columns = [{ width: 72 }, ...this.cimsDates.map(() => ({ width: 22 }))]; + sheet.columns = [ + { width: 72 }, + ...this.cimsDates.map(() => ({ width: 22 })) + ]; addBanner(sheet, 'Part B and C', this.cimsLastColumn()); sheet.addRow([]); this.addCimsEndDates(sheet, 'End Date'); @@ -1524,7 +1559,10 @@ export abstract class ReserveReturnBase { */ private addCimsPartD(wb: ExcelJS.Workbook): void { const sheet = wb.addWorksheet('Part D'); - sheet.columns = [{ width: 72 }, ...this.cimsDates.map(() => ({ width: 22 }))]; + sheet.columns = [ + { width: 72 }, + ...this.cimsDates.map(() => ({ width: 22 })) + ]; addBanner(sheet, 'Part D', this.cimsLastColumn()); sheet.addRow([]); this.addCimsEndDates(sheet, 'End Date'); @@ -1568,11 +1606,22 @@ export abstract class ReserveReturnBase { const perDate = 4; const dates = this.cimsDates; const sheet = wb.addWorksheet(name); - sheet.columns = [{ width: 38 }, ...dates.flatMap(() => Array(perDate).fill({ width: 19 }))]; + sheet.columns = [ + { width: 38 }, + ...dates.flatMap(() => Array(perDate).fill({ width: 19 })) + ]; addBanner(sheet, name, this.columnLetter(dates.length * perDate + 1)); sheet.addRow([]); - const dateRow = sheet.addRow(['', ...dates.flatMap((d) => [d, '', '', ''])]); + const dateRow = sheet.addRow([ + '', + ...dates.flatMap((d) => [ + d, + '', + '', + '' + ]) + ]); dateRow.font = { bold: true }; dates.forEach((_, i) => { const from = 2 + i * perDate; @@ -1584,12 +1633,25 @@ export abstract class ReserveReturnBase { const start = sheet.rowCount + 1; const head = sheet.addRow([ '', - ...dates.flatMap(() => ['Face Value', 'Book Value', 'Depreciation', 'Net Value for SLR purpose (2-3)']) + ...dates.flatMap(() => [ + 'Face Value', + 'Book Value', + 'Depreciation', + 'Net Value for SLR purpose (2-3)' + ]) ]); head.font = { bold: true }; head.alignment = { wrapText: true, horizontal: 'center' }; head.eachCell((c) => (c.fill = solidFill(XL.headerGrey))); - const numbers = sheet.addRow(['', ...dates.flatMap(() => [1, 2, 3, 4])]); + const numbers = sheet.addRow([ + '', + ...dates.flatMap(() => [ + 1, + 2, + 3, + 4 + ]) + ]); numbers.font = { bold: true }; numbers.alignment = { horizontal: 'center' }; @@ -1600,7 +1662,7 @@ export abstract class ReserveReturnBase { const movement = (label: string, carries: boolean, closing = false) => { const cells: (string | number)[] = [label]; for (let i = 0; i < dates.length; i++) { - const value = carries ? held[i] ?? 0 : 0; + const value = carries ? (held[i] ?? 0) : 0; cells.push(value, value, 0, value); } const row = sheet.addRow(cells); @@ -1654,8 +1716,7 @@ export abstract class ReserveReturnBase { { width: 21 }, { width: 21 }, { width: 19 }, - { width: 26 } - ]; + { width: 26 }]; addBanner( sheet, isCrr @@ -1671,7 +1732,12 @@ export abstract class ReserveReturnBase { const title = isCrr ? 'Cash Reserve Ratio Amount [Applicable to only Non-Scheduled UCBs]' : 'Statutory Liquidity Ratio Amount [Applicable to both Scheduled and Non-Scheduled UCBs]'; - const band = sheet.addRow(['', '', '', title]); + const band = sheet.addRow([ + '', + '', + '', + title + ]); band.font = { bold: true }; band.alignment = { wrapText: true, horizontal: 'center' }; sheet.mergeCells(band.number, 4, band.number, 9); @@ -1694,7 +1760,11 @@ export abstract class ReserveReturnBase { head.alignment = { wrapText: true, horizontal: 'center' }; head.eachCell((c) => (c.fill = solidFill(XL.headerGrey))); // The first three columns are one heading spanning both rows, as the template has them. - for (const c of [1, 2, 3]) { + for (const c of [ + 1, + 2, + 3 + ]) { sheet.mergeCells(band.number, c, head.number, c); } @@ -1750,20 +1820,39 @@ export abstract class ReserveReturnBase { private addCimsSignatory(wb: ExcelJS.Workbook): void { const sheet = wb.addWorksheet('Signatory Information'); - sheet.columns = [{ width: 26 }, { width: 36 }, { width: 36 }]; + sheet.columns = [ + { width: 26 }, + { width: 36 }, + { width: 36 }]; addBanner(sheet, 'Signatory Information', 'C'); sheet.addRow([]); const start = sheet.rowCount + 1; - const head = sheet.addRow(['', 'Authorised Reporting Official', 'Countersigned By']); + const head = sheet.addRow([ + '', + 'Authorised Reporting Official', + 'Countersigned By' + ]); head.font = { bold: true }; head.eachCell((c) => (c.fill = solidFill(XL.headerGrey))); // Left to be signed, and hatched the way the template hatches it so the blank reads as "fill // this in" rather than as a field the return had nothing to say about. Who signs a filed return, // and on what date, is settled when it is filed — a name pre-filled here would put a signature // on a form nobody had agreed to sign. - for (const field of ['Name', 'Designation', 'e-mail ID', 'Tel. No. (O)', 'Tel. No. (R)', 'Place', 'Date']) { - const row = sheet.addRow([field, '', '']); + for (const field of [ + 'Name', + 'Designation', + 'e-mail ID', + 'Tel. No. (O)', + 'Tel. No. (R)', + 'Place', + 'Date' + ]) { + const row = sheet.addRow([ + field, + '', + '' + ]); row.getCell(1).font = { bold: true }; row.getCell(1).fill = solidFill(XL.keyCell); row.getCell(2).fill = hatchFill(); @@ -1779,7 +1868,10 @@ export abstract class ReserveReturnBase { * thing not shaded like the figures below it. */ private addCimsEndDates(sheet: ExcelJS.Worksheet, label: string): void { - const row = sheet.addRow([label, ...this.cimsDates]); + const row = sheet.addRow([ + label, + ...this.cimsDates + ]); row.font = { bold: true }; row.getCell(1).alignment = { horizontal: 'right' }; for (let c = 2; c <= this.cimsDates.length + 1; c++) { @@ -1826,7 +1918,10 @@ export abstract class ReserveReturnBase { * that part at all. */ private addCimsFigures(sheet: ExcelJS.Worksheet, label: string, values?: number[], bold = false): void { - const row = sheet.addRow([label, ...(values ?? this.cimsDates.map((): null => null))]); + const row = sheet.addRow([ + label, + ...(values ?? this.cimsDates.map((): null => null)) + ]); row.getCell(1).alignment = { wrapText: true }; if (bold) { row.font = { bold: true }; @@ -1934,8 +2029,7 @@ export abstract class ReserveReturnBase { { width: 20 }, { width: 18 }, { width: 18 }, - { width: 20 } - ]; + { width: 20 }]; addBanner(sheet, `Day by day — ${daily.from} to ${daily.to}`, 'J'); // A day short is a breach. It goes above the table, where it is read before the figures rather @@ -1979,7 +2073,16 @@ export abstract class ReserveReturnBase { day.slrSurplus ]); // Column 3 is a date, so the figures run 2, then 4 onward. - for (const c of [2, 4, 5, 6, 7, 8, 9, 10]) { + for (const c of [ + 2, + 4, + 5, + 6, + 7, + 8, + 9, + 10 + ]) { row.getCell(c).numFmt = '#,##0.00'; row.getCell(c).alignment = { horizontal: 'right' }; } diff --git a/src/app/rbi-circulars/reserve-return/reserve-return.component.html b/src/app/rbi-circulars/reserve-return/reserve-return.component.html index 5202e731a..10555ff73 100644 --- a/src/app/rbi-circulars/reserve-return/reserve-return.component.html +++ b/src/app/rbi-circulars/reserve-return/reserve-return.component.html @@ -592,8 +592,8 @@ @if (statutoryFirstColumn && statutorySecondColumn) {

Made up to {{ statutorySecondColumn }}. The base dates above are the matching close one month earlier — change either - one if the bank files on a different base. + >. The base dates above are the matching close one month earlier — change either one if the bank files on + a different base.

} @else {

Enter both fortnight closing dates — the base dates fill in from them.

@@ -715,16 +715,14 @@
} - - @if (line.baseDates?.length) { - - - @for (d of line.baseDates; track $index) { - - } - - } + } } @if (part.surplus?.length) { diff --git a/src/app/rd-dashboard/rd-dashboard-routing.module.ts b/src/app/rd-dashboard/rd-dashboard-routing.module.ts new file mode 100644 index 000000000..3f9222e0a --- /dev/null +++ b/src/app/rd-dashboard/rd-dashboard-routing.module.ts @@ -0,0 +1,29 @@ +/** + * Copyright since 2025 Mifos Initiative + * + * This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. + */ + +import { NgModule } from '@angular/core'; +import { Routes, RouterModule } from '@angular/router'; + +import { Route } from '../core/route/route.service'; +import { RdDashboardComponent } from './rd-dashboard.component'; + +const routes: Routes = [ + Route.withShell([ + { + path: 'rd-dashboard', + component: RdDashboardComponent, + data: { title: 'RD Dashboard', breadcrumb: 'RD Dashboard' } + } + ]) +]; + +@NgModule({ + imports: [RouterModule.forChild(routes)], + exports: [RouterModule] +}) +export class RdDashboardRoutingModule {} diff --git a/src/app/rd-dashboard/rd-dashboard.component.html b/src/app/rd-dashboard/rd-dashboard.component.html new file mode 100644 index 000000000..f10a18d99 --- /dev/null +++ b/src/app/rd-dashboard/rd-dashboard.component.html @@ -0,0 +1,256 @@ + + +
+ +
+
+
+ +
+
+

RD Dashboard

+

+ All running Recurring Deposits as of {{ businessDate || '—' }} (business date). Click any + card to see those RDs in the list below. +

+
+
+ +
+ + @if (errorMessage) { +
+ + {{ errorMessage }} +
+ } + + +
+

RD Book at a Glance

+
+ + + + +
+ Closed This Month + {{ maturedClosedThisMonth + prematureClosedThisMonth }} + On maturity {{ maturedClosedThisMonth }} · Premature {{ prematureClosedThisMonth }} +
+ +
+ This Month's Installments + {{ summary.collectionPercent }}% +
+
+
+ Received ₹{{ summary.receivedThisMonth | number: '1.0-0' }} of ₹{{ + summary.dueThisMonth | number: '1.0-0' + }} +
+ + + + +
+
+ + +
+

Installment Health

+
+
+ + + +
+ + Penalty income + ₹{{ penaltyIncomeThisMonth | number: '1.0-0' }} + This month · ₹{{ penaltyIncomeTotal | number: '1.0-0' }} in all +
+
+ +
+
How late are overdue RDs?
+ @for (b of ageingBuckets; track b.key) { + + } +
+
+
+ + +
+

RD List – What to Do Next

+
+ + + + + @if (anyFilter) { + + } + + {{ visibleRows.length }} of {{ rows.length }} RDs +
+ + @if (loading) { +
Loading RDs...
+ } @else if (visibleRows.length === 0) { +
+ + {{ rows.length ? 'No RDs match these filters.' : 'No running Recurring Deposits.' }} +
+ } @else { +
+
Sr. {{ slab.srNo }} To{{ slab.toPeriod ?? '—' }}{{ slab.toPeriod ?? '—' }} Period{{ amount(v) }}
Base dates — the matching close one month earlier{{ d }}
+ + + + + + + + + + + + + + + + + @for (r of visibleRows; track r.id) { + + + + + + + + + + + + + + } + +
AccountClientProductInstallmentNext DueOverdueMaturityStatusAuto DebitLienAction
+ {{ r.accountNo }} +
{{ r.officeName }}
+
{{ r.clientName }}{{ r.productName }} + ₹{{ r.installmentAmount | number: '1.2-2' }} +
{{ r.paidInstallments }} of {{ r.totalInstallments }} paid
+
+ {{ r.nextDueDate || '—' }} +
{{ dueText(r) }}
+
+ @if (r.overdueInstallments > 0) { + ₹{{ r.overdueAmount | number: '1.2-2' }} +
+ {{ r.overdueInstallments }} installment{{ r.overdueInstallments === 1 ? '' : 's' }} +
+ } @else { + — + } +
+ {{ r.maturityDate || '—' }} +
{{ maturityText(r) }}
+
+ {{ r.meta.label }} + @if (r.penaltyCount > 0) { +
Penalty ₹{{ r.penaltyAmount | number: '1.0-0' }} ({{ r.penaltyCount }}x)
+ } +
+ @if (r.autoDebit) { + On + } @else { + Off + } + + @if (r.lienAmount > 0) { + ₹{{ r.lienAmount | number: '1.0-0' }} + } @else { + — + } + + @if (r.stage === 'MATURED') { + + } + +
+
+ } + +
diff --git a/src/app/rd-dashboard/rd-dashboard.component.scss b/src/app/rd-dashboard/rd-dashboard.component.scss new file mode 100644 index 000000000..8bf347148 --- /dev/null +++ b/src/app/rd-dashboard/rd-dashboard.component.scss @@ -0,0 +1,620 @@ +/** + * 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/. + */ + +/** RD Dashboard - same light, compact look as the FD Dashboard. */ + +:host { + display: block; + font-family: + 'DM Sans', + system-ui, + -apple-system, + sans-serif; + color: #0f172a; + background: #f8fafc; + min-height: 100vh; + padding: 12px 16px; + box-sizing: border-box; +} + +.rdd-container { + max-width: 1400px; + margin: 0 auto; + display: flex; + flex-direction: column; + gap: 14px; +} + +/* ── Header ─────────────────────────────────────────────────────────────── */ + +.rdd-header { + background: #fff; + border: 1px solid #e2e8f0; + border-radius: 12px; + padding: 10px 16px; + display: flex; + align-items: center; + justify-content: space-between; + gap: 16px; + box-shadow: 0 1px 3px rgb(0 0 0 / 4%); + + &__left { + display: flex; + align-items: center; + gap: 12px; + } + + &__icon { + width: 36px; + height: 36px; + border-radius: 8px; + background: #ecfdf5; + color: #059669; + display: flex; + align-items: center; + justify-content: center; + font-size: 18px; + flex-shrink: 0; + } + + &__title { + margin: 0; + font-size: 16px; + font-weight: 700; + } + + &__sub { + margin: 2px 0 0; + font-size: 11.5px; + color: #64748b; + } +} + +.rdd-alert { + display: flex; + align-items: center; + gap: 8px; + padding: 8px 12px; + border-radius: 8px; + font-size: 12px; + font-weight: 600; + background: #fef2f2; + border: 1px solid #fca5a5; + color: #dc2626; +} + +/* ── Sections ───────────────────────────────────────────────────────────── */ + +.rdd-section { + display: flex; + flex-direction: column; + gap: 8px; + + &__title { + margin: 0; + font-size: 13px; + font-weight: 700; + color: #334155; + } +} + +/* ── 1. Summary cards ───────────────────────────────────────────────────── */ + +.rdd-cards { + display: grid; + grid-template-columns: repeat(auto-fit, minmax(190px, 1fr)); + gap: 10px; +} + +.rdd-card { + display: flex; + flex-direction: column; + gap: 3px; + background: #fff; + border: 1px solid #e2e8f0; + border-left: 4px solid #cbd5e1; + border-radius: 10px; + padding: 10px 14px; + text-align: left; + font-family: inherit; + cursor: pointer; + transition: all 0.15s ease; + + &:hover:not(.rdd-card--static) { + box-shadow: 0 4px 12px rgb(15 23 42 / 6%); + } + + &--static { + cursor: default; + } + + &__label { + display: flex; + align-items: center; + gap: 6px; + font-size: 12px; + font-weight: 700; + color: #334155; + } + + &__val { + font-size: 22px; + font-weight: 800; + line-height: 1.15; + } + + &__sub { + font-size: 11px; + color: #64748b; + } + + &--success { + border-left-color: #10b981; + + .rdd-card__label fa-icon { + color: #10b981; + } + + &.rdd-card--active { + background: #ecfdf5; + border-color: #6ee7b7; + } + } + + &--info { + border-left-color: #3b82f6; + + .rdd-card__label fa-icon { + color: #3b82f6; + } + + &.rdd-card--active { + background: #eff6ff; + border-color: #93c5fd; + } + } + + &--danger { + border-left-color: #ef4444; + + .rdd-card__label fa-icon { + color: #ef4444; + } + + &.rdd-card--active { + background: #fef2f2; + border-color: #fca5a5; + } + } + + &--warn { + border-left-color: #f59e0b; + + .rdd-card__label fa-icon { + color: #f59e0b; + } + + &.rdd-card--active { + background: #fffbeb; + border-color: #fcd34d; + } + } + + &--neutral { + border-left-color: #64748b; + + .rdd-card__label fa-icon { + color: #64748b; + } + } +} + +.rdd-progress { + height: 6px; + border-radius: 3px; + background: #e2e8f0; + overflow: hidden; + + &__bar { + height: 100%; + background: #10b981; + border-radius: 3px; + } +} + +/* ── 2. Installment health ──────────────────────────────────────────────── */ + +.rdd-health { + display: grid; + grid-template-columns: 2fr 1fr; + gap: 10px; + + @media (width <= 900px) { + grid-template-columns: 1fr; + } +} + +.rdd-tiles { + display: grid; + grid-template-columns: repeat(auto-fit, minmax(150px, 1fr)); + gap: 10px; +} + +.rdd-tile { + display: grid; + grid-template-columns: 10px 1fr; + column-gap: 8px; + row-gap: 2px; + align-items: center; + background: #fff; + border: 1px solid #e2e8f0; + border-radius: 10px; + padding: 10px 12px; + text-align: left; + font-family: inherit; + cursor: pointer; + transition: all 0.15s ease; + + &:hover:not(.rdd-tile--static) { + box-shadow: 0 4px 12px rgb(15 23 42 / 6%); + } + + &--active { + border-color: #2563eb; + background: #f8fafc; + } + + &--static { + cursor: default; + } + + &__dot { + width: 10px; + height: 10px; + border-radius: 50%; + + &--neutral { + background: #64748b; + } + + &--warn { + background: #f59e0b; + } + + &--danger { + background: #ef4444; + } + + &--success { + background: #10b981; + } + } + + &__label { + font-size: 12px; + font-weight: 700; + color: #334155; + } + + &__val, + &__sub { + grid-column: 2; + } + + &__val { + font-size: 20px; + font-weight: 800; + } + + &__sub { + font-size: 10.5px; + color: #64748b; + } +} + +.rdd-ageing { + background: #fff; + border: 1px solid #e2e8f0; + border-radius: 10px; + padding: 10px 12px; + display: flex; + flex-direction: column; + gap: 4px; + + &__title { + font-size: 12px; + font-weight: 700; + color: #334155; + margin-bottom: 2px; + } + + &__row { + display: grid; + grid-template-columns: 74px 1fr 28px; + align-items: center; + gap: 8px; + border: none; + background: transparent; + padding: 3px 4px; + border-radius: 6px; + font-family: inherit; + cursor: pointer; + + &:hover, + &--active { + background: #f1f5f9; + } + } + + &__label { + font-size: 11px; + color: #475569; + text-align: left; + } + + &__track { + height: 8px; + background: #f1f5f9; + border-radius: 4px; + overflow: hidden; + } + + &__bar { + display: block; + height: 100%; + background: #f97316; + border-radius: 4px; + } + + &__count { + font-size: 12px; + font-weight: 700; + text-align: right; + } +} + +/* ── 3. Action list ─────────────────────────────────────────────────────── */ + +.rdd-toolbar { + background: #fff; + border: 1px solid #e2e8f0; + border-radius: 10px; + padding: 6px 10px; + display: flex; + flex-wrap: wrap; + align-items: center; + gap: 8px; + + &__spacer { + flex: 1; + } +} + +.rdd-search { + position: relative; + display: flex; + align-items: center; + width: 240px; + + &__icon { + position: absolute; + left: 9px; + color: #94a3b8; + font-size: 12px; + } + + &__input { + width: 100%; + padding: 5px 8px 5px 27px; + border: 1px solid #cbd5e1; + border-radius: 6px; + font-size: 12px; + font-family: inherit; + + &:focus { + outline: none; + border-color: #2563eb; + } + } +} + +.rdd-select { + padding: 5px 8px; + border: 1px solid #cbd5e1; + border-radius: 6px; + font-size: 12px; + font-family: inherit; + background: #fff; + color: #0f172a; +} + +.rdd-count { + font-size: 11.5px; + color: #64748b; +} + +.rdd-btn { + display: inline-flex; + align-items: center; + gap: 6px; + padding: 6px 12px; + border-radius: 6px; + font-family: inherit; + font-size: 12px; + font-weight: 600; + cursor: pointer; + border: 1px solid #cbd5e1; + background: #fff; + color: #334155; + + &:hover:not(:disabled) { + border-color: #2563eb; + color: #2563eb; + } + + &--slim { + padding: 4px 10px; + font-size: 11.5px; + } + + &--xs { + padding: 3px 9px; + font-size: 11px; + } + + &--primary { + background: #2563eb; + border-color: #2563eb; + color: #fff; + + &:hover:not(:disabled) { + background: #1d4ed8; + color: #fff; + } + } + + &:disabled { + opacity: 0.55; + cursor: not-allowed; + } +} + +.rdd-state { + background: #fff; + border: 1px solid #e2e8f0; + border-radius: 10px; + padding: 24px; + text-align: center; + font-size: 12px; + color: #64748b; +} + +.rdd-table-card { + background: #fff; + border: 1px solid #e2e8f0; + border-radius: 10px; + overflow-x: auto; +} + +.rdd-table { + width: 100%; + border-collapse: collapse; + font-size: 12px; + + th { + padding: 8px 10px; + background: #f1f5f9; + color: #475569; + font-size: 10.5px; + font-weight: 700; + text-transform: uppercase; + letter-spacing: 0.04em; + text-align: left; + white-space: nowrap; + border-bottom: 1px solid #e2e8f0; + } + + td { + padding: 7px 10px; + border-bottom: 1px solid #f1f5f9; + vertical-align: middle; + } + + .text-right { + text-align: right; + } + + .text-center { + text-align: center; + } +} + +.rdd-row { + cursor: pointer; + + &:hover { + background: #f8fafc; + } +} + +.rdd-mono { + font-family: 'JetBrains Mono', monospace; + font-size: 11.5px; + font-weight: 600; +} + +.rdd-strong { + font-weight: 600; +} + +.rdd-muted { + color: #64748b; + font-size: 11.5px; +} + +.rdd-danger { + color: #dc2626; +} + +.rdd-sub { + font-size: 10.5px; + color: #64748b; + + &--danger { + color: #dc2626; + font-weight: 700; + } +} + +.rdd-yes { + font-size: 11px; + font-weight: 700; + color: #059669; +} + +.rdd-actions { + white-space: nowrap; + + .rdd-btn + .rdd-btn { + margin-left: 4px; + } +} + +.rdd-badge { + display: inline-block; + padding: 2px 8px; + border-radius: 12px; + font-size: 10.5px; + font-weight: 700; + white-space: nowrap; + + &--success { + background: #ecfdf5; + color: #047857; + } + + &--info { + background: #eff6ff; + color: #2563eb; + } + + &--warn { + background: #fffbeb; + color: #b45309; + } + + &--danger { + background: #fef2f2; + color: #dc2626; + } + + &--neutral { + background: #f1f5f9; + color: #475569; + } +} diff --git a/src/app/rd-dashboard/rd-dashboard.component.ts b/src/app/rd-dashboard/rd-dashboard.component.ts new file mode 100644 index 000000000..92856a44d --- /dev/null +++ b/src/app/rd-dashboard/rd-dashboard.component.ts @@ -0,0 +1,256 @@ +/** + * Copyright since 2025 Mifos Initiative + * + * This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. + */ + +/** Angular Imports */ +import { Component, OnInit, inject } from '@angular/core'; +import { FormsModule } from '@angular/forms'; +import { Router } from '@angular/router'; + +/** Custom Modules */ +import { STANDALONE_SHARED_IMPORTS } from 'app/standalone-shared.module'; +import { FaIconComponent } from '@fortawesome/angular-fontawesome'; + +/** Custom Services */ +import { AccountsService } from '../accounts/accounts.service'; + +/** Dashboard data + computation */ +import { + RD_AGEING_BUCKETS, + RD_STAGES, + RdDashboardResponse, + RdRow, + RdStage, + RdSummary, + buildRdRow, + rdSummary, + sortRdRows +} from './rd-dashboard.compute'; + +/** + * RD Dashboard: every running Recurring Deposit, with its installment position as of the business date. + * + * Three parts, top to bottom: the summary cards (how the RD book stands), installment health (who is late and how + * badly), and the action list (each RD with what to do next). Every figure comes from one call to + * `/recurringdepositaccounts/dashboard`, which works out paid/late installments the same way the nightly penalty job + * does, so this screen and the penalties never disagree. + */ +@Component({ + selector: 'mifosx-rd-dashboard', + templateUrl: './rd-dashboard.component.html', + styleUrls: ['./rd-dashboard.component.scss'], + imports: [ + ...STANDALONE_SHARED_IMPORTS, + FormsModule, + FaIconComponent + ] +}) +export class RdDashboardComponent implements OnInit { + private accountsService = inject(AccountsService); + private router = inject(Router); + + loading = false; + errorMessage = ''; + + businessDate = ''; + maturedClosedThisMonth = 0; + prematureClosedThisMonth = 0; + penaltyIncomeThisMonth = 0; + penaltyIncomeTotal = 0; + + rows: RdRow[] = []; + summary: RdSummary = rdSummary([]); + + /** Filters */ + stageFilter: RdStage | '' = ''; + /** Narrower than a stage: 'overdue' (any late installment), 'lien', or an ageing bucket key. */ + extraFilter = ''; + officeFilter: number | '' = ''; + productFilter: number | '' = ''; + search = ''; + + offices: { id: number; name: string }[] = []; + products: { id: number; name: string }[] = []; + + readonly stages = RD_STAGES; + readonly stageKeys: RdStage[] = [ + 'MATURED', + 'PAST_GRACE', + 'IN_GRACE', + 'DUE_TODAY', + 'REGULAR' + ]; + readonly ageingBuckets = RD_AGEING_BUCKETS; + + ngOnInit(): void { + this.load(); + } + + load(): void { + this.loading = true; + this.errorMessage = ''; + this.accountsService.getRecurringDepositDashboard().subscribe({ + next: (res: RdDashboardResponse) => { + this.businessDate = res?.businessDate || ''; + this.maturedClosedThisMonth = Number(res?.maturedClosedThisMonth) || 0; + this.prematureClosedThisMonth = Number(res?.prematureClosedThisMonth) || 0; + this.penaltyIncomeThisMonth = Number(res?.penaltyIncomeThisMonth) || 0; + this.penaltyIncomeTotal = Number(res?.penaltyIncomeTotal) || 0; + const accounts = Array.isArray(res?.accounts) ? res.accounts : []; + this.rows = sortRdRows(accounts.map((a) => buildRdRow(a, this.businessDate))); + this.summary = rdSummary(this.rows); + this.offices = this.distinct(this.rows.map((r) => ({ id: r.officeId, name: r.officeName }))); + this.products = this.distinct(this.rows.map((r) => ({ id: r.productId, name: r.productName }))); + this.loading = false; + }, + error: (err: any) => { + this.rows = []; + this.summary = rdSummary([]); + this.loading = false; + this.errorMessage = + err?.error?.defaultUserMessage || err?.error?.developerMessage || 'Could not load the RD dashboard.'; + } + }); + } + + private distinct(items: { id: number; name: string }[]): { id: number; name: string }[] { + const seen = new Map(); + items.forEach((i) => seen.set(i.id, i.name)); + return [...seen.entries()].map(([id, name]) => ({ id, name })).sort((a, b) => a.name.localeCompare(b.name)); + } + + // ── Filtering ────────────────────────────────────────────────────────────── + + get visibleRows(): RdRow[] { + const needle = this.search.trim().toLowerCase(); + return this.rows.filter((r) => { + if (this.stageFilter && r.stage !== this.stageFilter) { + return false; + } + if (this.extraFilter === 'overdue' && !(r.overdueInstallments > 0 && r.stage !== 'MATURED')) { + return false; + } + if (this.extraFilter === 'lien' && !(r.lienAmount > 0)) { + return false; + } + if (this.isAgeingKey(this.extraFilter) && r.ageingKey !== this.extraFilter) { + return false; + } + if (this.officeFilter !== '' && r.officeId !== this.officeFilter) { + return false; + } + if (this.productFilter !== '' && r.productId !== this.productFilter) { + return false; + } + if (needle && !`${r.accountNo} ${r.clientName}`.toLowerCase().includes(needle)) { + return false; + } + return true; + }); + } + + private isAgeingKey(key: string): boolean { + return this.ageingBuckets.some((b) => b.key === key); + } + + /** Clicking a card or tile shows just those RDs in the list below; clicking it again shows all. */ + showStage(stage: RdStage): void { + const same = this.stageFilter === stage && !this.extraFilter; + this.stageFilter = same ? '' : stage; + this.extraFilter = ''; + } + + showExtra(key: string): void { + const same = this.extraFilter === key && !this.stageFilter; + this.extraFilter = same ? '' : key; + this.stageFilter = ''; + } + + get anyFilter(): boolean { + return !!(this.stageFilter || this.extraFilter || this.officeFilter !== '' || this.productFilter !== '' || this.search); + } + + resetFilters(): void { + this.stageFilter = ''; + this.extraFilter = ''; + this.officeFilter = ''; + this.productFilter = ''; + this.search = ''; + } + + /** Width of an ageing bar, relative to the largest bucket. */ + ageingWidth(key: string): number { + const max = Math.max(1, ...this.ageingBuckets.map((b) => this.summary.ageing[b.key] || 0)); + return Math.round(((this.summary.ageing[key] || 0) / max) * 100); + } + + // ── Navigation ───────────────────────────────────────────────────────────── + + openAccount(row: RdRow): void { + this.router.navigate([ + '/clients', + row.clientId, + 'recurring-deposits-accounts', + row.id, + 'general' + ]); + } + + closeAccount(row: RdRow): void { + this.router.navigate([ + '/clients', + row.clientId, + 'recurring-deposits-accounts', + row.id, + 'actions', + 'Close' + ]); + } + + // ── Presentation ─────────────────────────────────────────────────────────── + + stageClass(stage: RdStage): string { + return `rdd-badge rdd-badge--${RD_STAGES[stage].tone}`; + } + + /** "5 days late" / "Due today" / "Due in 12 days", as a branch officer would say it. */ + dueText(row: RdRow): string { + if (row.stage === 'MATURED') { + return 'Term complete'; + } + if (!row.nextDueDate) { + return 'All installments paid'; + } + if (row.daysLate > 0) { + return `${row.daysLate} day${row.daysLate === 1 ? '' : 's'} late`; + } + const days = this.daysUntil(row.nextDueDate); + if (days === 0) { + return 'Due today'; + } + return `Due in ${days} day${days === 1 ? '' : 's'}`; + } + + maturityText(row: RdRow): string { + if (row.daysToMaturity == null) { + return ''; + } + if (row.daysToMaturity < 0) { + return `Matured ${-row.daysToMaturity} day${row.daysToMaturity === -1 ? '' : 's'} ago`; + } + if (row.daysToMaturity === 0) { + return 'Matures today'; + } + return `In ${row.daysToMaturity} day${row.daysToMaturity === 1 ? '' : 's'}`; + } + + private daysUntil(iso: string): number { + const a = new Date(`${this.businessDate}T00:00:00Z`).getTime(); + const b = new Date(`${iso}T00:00:00Z`).getTime(); + return Math.round((b - a) / 86400000); + } +} diff --git a/src/app/rd-dashboard/rd-dashboard.compute.ts b/src/app/rd-dashboard/rd-dashboard.compute.ts new file mode 100644 index 000000000..09a61a548 --- /dev/null +++ b/src/app/rd-dashboard/rd-dashboard.compute.ts @@ -0,0 +1,214 @@ +/** + * 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/. + */ + +/** + * Where a running Recurring Deposit stands today, most urgent first: + * - MATURED: the term is over - the deposit is waiting to be closed and paid out. + * - PAST_GRACE: an installment is late beyond the product's grace days - the penalty applies. + * - IN_GRACE: an installment is late but still inside the grace days - no penalty yet. + * - DUE_TODAY: today's installment has not come in yet. + * - REGULAR: nothing due or late. + */ +export type RdStage = 'MATURED' | 'PAST_GRACE' | 'IN_GRACE' | 'DUE_TODAY' | 'REGULAR'; + +export interface RdStageMeta { + label: string; + tone: 'danger' | 'warn' | 'info' | 'success' | 'neutral'; + hint: string; +} + +export const RD_STAGES: Record = { + MATURED: { label: 'Matured – Close Pending', tone: 'info', hint: 'Term complete; close it to pay the client.' }, + PAST_GRACE: { label: 'Overdue – Penalty', tone: 'danger', hint: 'Late beyond the grace days; penalty applies.' }, + IN_GRACE: { label: 'Late – In Grace', tone: 'warn', hint: 'Late, but still within the grace days.' }, + DUE_TODAY: { label: 'Due Today', tone: 'neutral', hint: "Today's installment has not come in yet." }, + REGULAR: { label: 'Regular', tone: 'success', hint: 'All installments paid on time.' } +}; + +/** Late-installment ageing buckets, by days since the oldest unpaid installment fell due. */ +export const RD_AGEING_BUCKETS = [ + { key: '1-30', label: '1–30 days', from: 1, to: 30 }, + { key: '31-60', label: '31–60 days', from: 31, to: 60 }, + { key: '61-90', label: '61–90 days', from: 61, to: 90 }, + { key: '90+', label: '90+ days', from: 91, to: Infinity } +]; + +/** SavingsAccountStatusType.MATURED. */ +const STATUS_MATURED = 800; + +/** One account as `/recurringdepositaccounts/dashboard` returns it. */ +export interface RdDashboardAccount { + id: number; + accountNo: string; + clientId: number; + clientName: string; + officeId: number; + officeName: string; + productId: number; + productName: string; + status: number; + balance: number; + installmentAmount: number; + maturityDate: string | null; + maturityAmount: number | null; + autoDebit: boolean; + linkedAccountNo: string | null; + graceDays: number; + penaltyEnabled: boolean; + totalInstallments: number; + paidInstallments: number; + nextDueDate: string | null; + overdueInstallments: number; + overdueAmount: number; + dueTodayCount: number; + dueTodayPaid: number; + dueThisMonth: number; + receivedThisMonth: number; + paidThisMonth: number; + penaltyCount: number; + penaltyAmount: number; + lienAmount: number; +} + +export interface RdDashboardResponse { + businessDate: string; + maturedClosedThisMonth: number; + prematureClosedThisMonth: number; + penaltyIncomeThisMonth: number; + penaltyIncomeTotal: number; + accounts: RdDashboardAccount[]; +} + +export interface RdRow extends RdDashboardAccount { + stage: RdStage; + meta: RdStageMeta; + /** Days since the oldest unpaid installment fell due; 0 when nothing is late. */ + daysLate: number; + /** Days left until maturity; negative once past it. */ + daysToMaturity: number | null; + ageingKey: string | null; +} + +function daysBetween(earlier: string, later: string): number { + const a = new Date(`${earlier}T00:00:00Z`).getTime(); + const b = new Date(`${later}T00:00:00Z`).getTime(); + return Math.round((b - a) / 86400000); +} + +export function buildRdRow(a: RdDashboardAccount, today: string): RdRow { + const num = (v: any) => Number(v) || 0; + const account: RdDashboardAccount = { + ...a, + balance: num(a.balance), + installmentAmount: num(a.installmentAmount), + maturityAmount: a.maturityAmount == null ? null : num(a.maturityAmount), + graceDays: num(a.graceDays), + totalInstallments: num(a.totalInstallments), + paidInstallments: num(a.paidInstallments), + overdueInstallments: num(a.overdueInstallments), + overdueAmount: num(a.overdueAmount), + dueTodayCount: num(a.dueTodayCount), + dueTodayPaid: num(a.dueTodayPaid), + dueThisMonth: num(a.dueThisMonth), + receivedThisMonth: num(a.receivedThisMonth), + paidThisMonth: num(a.paidThisMonth), + penaltyCount: num(a.penaltyCount), + penaltyAmount: num(a.penaltyAmount), + lienAmount: num(a.lienAmount) + }; + // When anything is late, the earliest unpaid installment is the oldest late one. + const daysLate = account.overdueInstallments > 0 && account.nextDueDate ? daysBetween(account.nextDueDate, today) : 0; + const daysToMaturity = account.maturityDate ? daysBetween(today, account.maturityDate) : null; + + let stage: RdStage; + if (account.status === STATUS_MATURED || (daysToMaturity != null && daysToMaturity <= 0)) { + stage = 'MATURED'; + } else if (daysLate > 0) { + stage = daysLate > account.graceDays ? 'PAST_GRACE' : 'IN_GRACE'; + } else if (account.dueTodayCount > account.dueTodayPaid) { + stage = 'DUE_TODAY'; + } else { + stage = 'REGULAR'; + } + const bucket = daysLate > 0 ? RD_AGEING_BUCKETS.find((b) => daysLate >= b.from && daysLate <= b.to) : null; + return { + ...account, + stage, + meta: RD_STAGES[stage], + daysLate, + daysToMaturity, + ageingKey: bucket ? bucket.key : null + }; +} + +export interface RdSummary { + activeCount: number; + activeBalance: number; + maturedCount: number; + maturedAmount: number; + dueThisMonth: number; + receivedThisMonth: number; + collectionPercent: number; + overdueCount: number; + overdueAmount: number; + lienCount: number; + lienAmount: number; + dueTodayCount: number; + dueTodayPaidCount: number; + inGraceCount: number; + inGraceAmount: number; + pastGraceCount: number; + pastGraceAmount: number; + ageing: Record; +} + +export function rdSummary(rows: RdRow[]): RdSummary { + const sum = (list: RdRow[], f: (r: RdRow) => number) => list.reduce((s, r) => s + f(r), 0); + const running = rows.filter((r) => r.stage !== 'MATURED'); + const matured = rows.filter((r) => r.stage === 'MATURED'); + const overdue = running.filter((r) => r.overdueInstallments > 0); + const inGrace = rows.filter((r) => r.stage === 'IN_GRACE'); + const pastGrace = rows.filter((r) => r.stage === 'PAST_GRACE'); + const lien = rows.filter((r) => r.lienAmount > 0); + const dueThisMonth = sum(running, (r) => r.dueThisMonth); + const receivedThisMonth = sum(running, (r) => r.receivedThisMonth); + const ageing: Record = {}; + RD_AGEING_BUCKETS.forEach((b) => (ageing[b.key] = running.filter((r) => r.ageingKey === b.key).length)); + return { + activeCount: running.length, + activeBalance: sum(running, (r) => r.balance), + maturedCount: matured.length, + maturedAmount: sum(matured, (r) => r.maturityAmount ?? r.balance), + dueThisMonth, + receivedThisMonth, + collectionPercent: dueThisMonth > 0 ? Math.round((receivedThisMonth / dueThisMonth) * 100) : 100, + overdueCount: overdue.length, + overdueAmount: sum(overdue, (r) => r.overdueAmount), + lienCount: lien.length, + lienAmount: sum(lien, (r) => r.lienAmount), + dueTodayCount: running.filter((r) => r.dueTodayCount > 0).length, + dueTodayPaidCount: running.filter((r) => r.dueTodayCount > 0 && r.dueTodayPaid >= r.dueTodayCount).length, + inGraceCount: inGrace.length, + inGraceAmount: sum(inGrace, (r) => r.overdueAmount), + pastGraceCount: pastGrace.length, + pastGraceAmount: sum(pastGrace, (r) => r.overdueAmount), + ageing + }; +} + +/** Most urgent first: matured, then the longest late, then due today, then the rest by next due date. */ +export function sortRdRows(rows: RdRow[]): RdRow[] { + const rank: Record = { MATURED: 0, PAST_GRACE: 1, IN_GRACE: 2, DUE_TODAY: 3, REGULAR: 4 }; + return [...rows].sort( + (a, b) => + rank[a.stage] - rank[b.stage] || + b.daysLate - a.daysLate || + (a.nextDueDate || '9999').localeCompare(b.nextDueDate || '9999') || + a.accountNo.localeCompare(b.accountNo) + ); +} diff --git a/src/app/rd-dashboard/rd-dashboard.module.ts b/src/app/rd-dashboard/rd-dashboard.module.ts new file mode 100644 index 000000000..29c428691 --- /dev/null +++ b/src/app/rd-dashboard/rd-dashboard.module.ts @@ -0,0 +1,22 @@ +/** + * Copyright since 2025 Mifos Initiative + * + * This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. + */ + +import { NgModule } from '@angular/core'; + +import { SharedModule } from '../shared/shared.module'; +import { RdDashboardRoutingModule } from './rd-dashboard-routing.module'; +import { RdDashboardComponent } from './rd-dashboard.component'; + +@NgModule({ + imports: [ + SharedModule, + RdDashboardRoutingModule, + RdDashboardComponent + ] +}) +export class RdDashboardModule {} diff --git a/src/app/reports/reports-routing.module.ts b/src/app/reports/reports-routing.module.ts index af43e1032..0fb1f75c1 100644 --- a/src/app/reports/reports-routing.module.ts +++ b/src/app/reports/reports-routing.module.ts @@ -92,6 +92,14 @@ const routes: Routes = [ (m) => m.InterestPostingReportComponent ) }, + { + path: 'fd-interest-posting', + data: { title: 'FD Interest Posting Reports', breadcrumb: 'FD Interest Posting Reports' }, + loadComponent: () => + import('../fd-interest-posting-report/fd-interest-posting-report.component').then( + (m) => m.FdInterestPostingReportComponent + ) + }, { path: 'dormancy', data: { title: 'Inactive, Dormant & DEAF', breadcrumb: 'Inactive, Dormant & DEAF' }, diff --git a/src/app/risk-review/risk-review-report.component.scss b/src/app/risk-review/risk-review-report.component.scss index db236d881..ff2758f25 100644 --- a/src/app/risk-review/risk-review-report.component.scss +++ b/src/app/risk-review/risk-review-report.component.scss @@ -907,11 +907,13 @@ } /* Where the rating should land, read straight off the Current Risk column beside it. */ +/* stylelint-disable-next-line no-duplicate-selectors */ .rr-move-cell { min-width: 150px; max-width: 190px; } +/* stylelint-disable-next-line no-duplicate-selectors */ .rr-move { display: inline-flex; align-items: center; @@ -922,12 +924,14 @@ font-weight: 700; white-space: nowrap; + /* stylelint-disable-next-line no-duplicate-selectors */ &--up { background: var(--r-red-l); color: var(--r-red); } /* Coming down is a relief, not an alarm — same green the verdict uses. */ + /* stylelint-disable-next-line no-duplicate-selectors */ &--down { background: var(--r-green-l); color: var(--r-green); diff --git a/src/app/savings/savings-account-view/transactions-tab/transactions-tab.component.html b/src/app/savings/savings-account-view/transactions-tab/transactions-tab.component.html index d740b195e..55716a443 100644 --- a/src/app/savings/savings-account-view/transactions-tab/transactions-tab.component.html +++ b/src/app/savings/savings-account-view/transactions-tab/transactions-tab.component.html @@ -128,18 +128,6 @@ {{ 'labels.text.View Transaction' | translate }} - @if (!transaction.reversed) { - - - - } } - @if (allowUndo()) { - - }

diff --git a/src/app/settings/settings.service.ts b/src/app/settings/settings.service.ts index 6a540eec1..482a0ffc5 100644 --- a/src/app/settings/settings.service.ts +++ b/src/app/settings/settings.service.ts @@ -226,7 +226,15 @@ export class SettingsService { * Returns current Business date server */ get businessDate(): Date { - return this.dateUtils.convertToDate(localStorage.getItem('mifosXServerDate'), SettingsService.businessDateFormat); + // With business dates switched off the server works on today's date, so the screens must too - the date + // stored from when it was on would otherwise linger. Nothing stored (or unreadable) also falls back to today + // instead of an Invalid Date. + if (this.businessDateConfig === 'false') { + return new Date(); + } + const stored = localStorage.getItem('mifosXServerDate'); + const date = stored ? this.dateUtils.convertToDate(stored, SettingsService.businessDateFormat) : null; + return date && !isNaN(date.getTime()) ? date : new Date(); } /** diff --git a/src/app/shared/accounting/view-journal-entry-transaction/view-journal-entry-transaction.component.html b/src/app/shared/accounting/view-journal-entry-transaction/view-journal-entry-transaction.component.html index 3f6ee9887..4fecc1971 100644 --- a/src/app/shared/accounting/view-journal-entry-transaction/view-journal-entry-transaction.component.html +++ b/src/app/shared/accounting/view-journal-entry-transaction/view-journal-entry-transaction.component.html @@ -24,24 +24,6 @@ }
-
- @if (dataSource?.data[0].manualEntry) { - - } -
diff --git a/src/app/shared/accounting/view-journal-entry-transaction/view-journal-entry-transaction.component.ts b/src/app/shared/accounting/view-journal-entry-transaction/view-journal-entry-transaction.component.ts index fa6a999a2..2b3f5c753 100644 --- a/src/app/shared/accounting/view-journal-entry-transaction/view-journal-entry-transaction.component.ts +++ b/src/app/shared/accounting/view-journal-entry-transaction/view-journal-entry-transaction.component.ts @@ -8,10 +8,8 @@ import { Component, OnInit, ViewChild, inject } from '@angular/core'; import { ViewJournalEntryComponent } from '../view-journal-entry/view-journal-entry.component'; -import { RevertTransactionComponent } from 'app/accounting/revert-transaction/revert-transaction.component'; -import { AccountingService } from 'app/accounting/accounting.service'; import { MatDialog } from '@angular/material/dialog'; -import { ActivatedRoute, Router } from '@angular/router'; +import { ActivatedRoute } from '@angular/router'; import { MatSort, MatSortHeader } from '@angular/material/sort'; import { MatPaginator } from '@angular/material/paginator'; import { @@ -65,9 +63,7 @@ import { MatTooltipModule } from '@angular/material/tooltip'; ] }) export class ViewJournalEntryTransactionComponent implements OnInit { - private accountingService = inject(AccountingService); private route = inject(ActivatedRoute); - private router = inject(Router); dialog = inject(MatDialog); private location = inject(Location); @@ -147,30 +143,6 @@ export class ViewJournalEntryTransactionComponent implements OnInit { this.dialog.open(ViewJournalEntryComponent, { data: { journalEntry } }); } - revertTransaction(transactionId?: string) { - const revertTransactionDialogRef = this.dialog.open(RevertTransactionComponent, { - data: { reverted: this.dataSource.data[0].reversed, transactionId } - }); - revertTransactionDialogRef.afterClosed().subscribe((response: any) => { - if (response.revert) { - this.accountingService - .revertTransaction(this.transactionId, response.comments) - .subscribe((reversedTransaction: any) => { - this.dataSource.data[0].reversed = true; - this.revertTransaction(reversedTransaction.transactionId); - }); - } else if (response.redirect) { - this.router.navigate( - [ - '../', - transactionId - ], - { relativeTo: this.route } - ); - } - }); - } - goBack(): void { this.location.back(); } diff --git a/src/app/shared/customer-search-dialog/customer-search-dialog.component.ts b/src/app/shared/customer-search-dialog/customer-search-dialog.component.ts index 6b3aa53ce..4f47b4539 100644 --- a/src/app/shared/customer-search-dialog/customer-search-dialog.component.ts +++ b/src/app/shared/customer-search-dialog/customer-search-dialog.component.ts @@ -10,12 +10,12 @@ import { Component, OnInit, inject } from '@angular/core'; import { CommonModule } from '@angular/common'; import { HttpClient, HttpParams } from '@angular/common/http'; import { FormsModule, ReactiveFormsModule, FormBuilder, FormGroup } from '@angular/forms'; -import { MatDialogModule, MatDialogRef } from '@angular/material/dialog'; +import { MAT_DIALOG_DATA, MatDialogModule, MatDialogRef } from '@angular/material/dialog'; import { MatFormFieldModule } from '@angular/material/form-field'; import { MatInputModule } from '@angular/material/input'; import { MatButtonModule } from '@angular/material/button'; -import { MatTableModule } from '@angular/material/table'; -import { MatCheckboxModule } from '@angular/material/checkbox'; +import { MatIconModule } from '@angular/material/icon'; +import { MatProgressBarModule } from '@angular/material/progress-bar'; import { MatSnackBar, MatSnackBarModule } from '@angular/material/snack-bar'; export interface CustomerSearchResult { @@ -30,6 +30,17 @@ export interface CustomerSearchResult { address: string | null; } +/** Optional data for the dialog. */ +export interface CustomerSearchDialogData { + /** + * Whether a customer must have an account to be picked. True by default, because the account screens + * need one; a screen that only needs the customer (cash exchange) passes false. + */ + requireAccount?: boolean; +} + +type AccountTypeKey = 'savings' | 'current' | 'fixedDeposit' | 'recurringDeposit'; + @Component({ selector: 'mifosx-customer-search-dialog', standalone: true, @@ -41,173 +52,479 @@ export interface CustomerSearchResult { MatFormFieldModule, MatInputModule, MatButtonModule, - MatTableModule, - MatCheckboxModule, + MatIconModule, + MatProgressBarModule, MatSnackBarModule ], template: ` -

Search Customer

- -

- Fill in any one field, or combine several to narrow the search (e.g. name + account number). -

-
- - Name - - +
+
person_search
+
+

Search Customer

+

+ Fill in any one field, or combine several to narrow the search (e.g. name + account number). +

+
+ +
- - Account Number - - + @if (loading) { + + } - - Aadhaar Number - - + + +
+ + Name + person + + - - PAN Number - - + + Account Number + account_balance_wallet + + - - Address - - + + Aadhaar Number + fingerprint + + + + + PAN Number + credit_card + + + + + Address + place + + +
+ +
+ Account Type +
+ @for (t of accountTypeOptions; track t.key) { + + } +
+ Leave all unselected to search every account type +
- + @if (searched) { +
+
{{ results.length }} {{ results.length === 1 ? 'result' : 'results' }}
-
-

No matching customer found.

+ @if (!loading && results.length === 0) { +
+ search_off +
No matching customer found
+
Check the spelling, or try fewer fields.
+
+ } - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - -
Name{{ r.displayName }}Account - {{ r.accountNo }} ({{ accountTypeLabel(r.accountType) }}) - No account - Mobile{{ r.mobileNo || '-' }}Branch{{ r.officeName || '-' }}PAN{{ r.panNumber || '-' }}Aadhaar{{ r.aadhaarNumber || '-' }}Address{{ r.address || '-' }} - -
-
+ @for (r of results; track $index) { +
+
{{ initials(r.displayName) }}
+
+
+ {{ r.displayName }} + @if (r.accountNo) { + {{ accountTypeLabel(r.accountType) }} + {{ r.accountNo }} + } @else { + No account + } +
+
+ call{{ r.mobileNo || '—' }} + account_balance{{ r.officeName || '—' }} + credit_cardPAN {{ r.panNumber || '—' }} + fingerprintAadhaar {{ r.aadhaarNumber || '—' }} +
+ @if (r.address) { +
place{{ r.address }}
+ } +
+ +
+ } +
+ }
- - - + + + + + `, styles: [ ` - .hint-text { - color: #64748b; - font-size: 13px; - margin: 0 0 12px 0; + :host { + display: block; } - .search-form { + + .csd-head { display: flex; - flex-wrap: wrap; - gap: 12px 16px; + align-items: center; + gap: 14px; + padding: 20px 16px 12px 24px; } - .field { - flex: 1 1 220px; + + .csd-head__icon { + display: grid; + place-items: center; + flex: 0 0 auto; + width: 44px; + height: 44px; + border-radius: 12px; + background: #e6f4ee; + color: #0f4f3c; } - .field--wide { - flex-basis: 100%; + + .csd-head__icon mat-icon { + font-size: 26px; + width: 26px; + height: 26px; } - .account-type-filter { + + .csd-head__text { + flex: 1 1 auto; + min-width: 0; + } + + .csd-head__title { + margin: 0; + font-size: 20px; + font-weight: 700; + color: #0f172a; + } + + .csd-head__sub { + margin: 2px 0 0; + font-size: 13px; + color: #64748b; + } + + .csd-head__close { + flex: 0 0 auto; + color: #64748b; + } + + .csd-content { + padding-top: 12px; + } + + .csd-grid { + display: grid; + grid-template-columns: repeat(4, minmax(0, 1fr)); + gap: 12px 14px; + } + + .csd-field { + width: 100%; + } + + .csd-field--name { + grid-column: span 2; + } + + .csd-field--address { + grid-column: span 3; + } + + .csd-upper { + text-transform: uppercase; + } + + .csd-types { display: flex; align-items: center; flex-wrap: wrap; - gap: 8px 16px; - margin: 4px 0 16px 0; + gap: 8px 12px; + margin-top: 14px; } - .account-type-filter__label { + + .csd-types__label { font-size: 13px; font-weight: 600; color: #475569; } - .account-type-filter__hint { + + .csd-types__pills { + display: flex; + flex-wrap: wrap; + gap: 8px; + } + + .csd-types__hint { font-size: 12px; color: #94a3b8; } - .results { - margin-top: 16px; - overflow-x: auto; - } - table { - width: 100%; - } - th { - font-weight: 600; + + .csd-pill { + display: inline-flex; + align-items: center; + gap: 6px; + height: 32px; + padding: 0 14px 0 10px; + border: 1px solid #cbd5e1; + border-radius: 999px; + background: #fff; + font: inherit; + font-size: 13px; + font-weight: 500; color: #475569; - background-color: #f8fafc; + cursor: pointer; + transition: + background 0.15s ease, + border-color 0.15s ease, + color 0.15s ease; } - td { - color: #334155; - } - .muted { + + .csd-pill mat-icon { + font-size: 18px; + width: 18px; + height: 18px; color: #94a3b8; } - .no-results { - text-align: center; + + .csd-pill:hover { + border-color: #94a3b8; + background: #f8fafc; + } + + .csd-pill--on, + .csd-pill--on:hover { + border-color: #1f7a4d; + background: #e6f4ee; + font-weight: 600; + color: #0f4f3c; + } + + .csd-pill--on mat-icon { + color: #1f7a4d; + } + + .csd-results { + margin-top: 20px; + } + + .csd-results__head { + margin-bottom: 8px; + font-size: 12px; + font-weight: 700; + letter-spacing: 0.04em; + text-transform: uppercase; color: #64748b; - padding: 16px 0; + } + + .csd-result { + display: flex; + align-items: center; + gap: 14px; + margin-bottom: 8px; + padding: 12px 14px; + border: 1px solid #e2e8f0; + border-radius: 12px; + background: #fff; + transition: + border-color 0.15s ease, + box-shadow 0.15s ease; + } + + .csd-result:hover { + border-color: #1f7a4d; + box-shadow: 0 2px 8px rgb(15 23 42 / 8%); + } + + .csd-result--noacc { + background: #f8fafc; + } + + .csd-avatar { + display: grid; + place-items: center; + flex: 0 0 auto; + width: 42px; + height: 42px; + border-radius: 50%; + background: linear-gradient(135deg, #1f7a4d, #0f4f3c); + font-size: 14px; + font-weight: 700; + color: #fff; + } + + .csd-result__main { + flex: 1 1 auto; + min-width: 0; + } + + .csd-result__name { + display: flex; + align-items: center; + flex-wrap: wrap; + gap: 8px; + font-size: 15px; + font-weight: 700; + color: #0f172a; + } + + .csd-result__acc { + font-family: ui-monospace, 'SFMono-Regular', Menlo, Consolas, monospace; + font-size: 12px; + font-weight: 600; + color: #475569; + } + + .csd-badge { + padding: 2px 8px; + border-radius: 6px; + font-size: 11px; + font-weight: 600; + } + + .csd-badge--type { + background: #eef2ff; + color: #3730a3; + } + + .csd-badge--muted { + background: #f1f5f9; + color: #64748b; + } + + .csd-result__meta { + display: flex; + flex-wrap: wrap; + gap: 4px 16px; + margin-top: 4px; + font-size: 12px; + color: #475569; + } + + .csd-result__meta span, + .csd-result__addr { + display: inline-flex; + align-items: center; + gap: 4px; + } + + .csd-result__meta mat-icon, + .csd-result__addr mat-icon { + font-size: 14px; + width: 14px; + height: 14px; + color: #94a3b8; + } + + .csd-result__addr { + display: flex; + align-items: flex-start; + margin-top: 4px; + font-size: 12px; + color: #64748b; + } + + .csd-empty { + display: flex; + flex-direction: column; + align-items: center; + gap: 2px; + padding: 28px 16px; + border: 1.5px dashed #cbd5e1; + border-radius: 12px; + background: #f8fafc; + text-align: center; + } + + .csd-empty mat-icon { + font-size: 36px; + width: 36px; + height: 36px; + color: #94a3b8; + } + + .csd-empty__title { + font-size: 14px; + font-weight: 600; + color: #334155; + } + + .csd-empty__sub { + font-size: 12px; + color: #64748b; + } + + .csd-actions { + gap: 8px; + padding: 12px 24px 20px; + } + + @media (max-width: 720px) { + .csd-grid { + grid-template-columns: repeat(2, minmax(0, 1fr)); + } + + .csd-field--address { + grid-column: span 2; + } + + .csd-result { + flex-wrap: wrap; + } + } + + @media (max-width: 480px) { + .csd-grid { + grid-template-columns: 1fr; + } + + .csd-field--name, + .csd-field--address { + grid-column: span 1; + } } ` ] @@ -217,20 +534,18 @@ export class CustomerSearchDialogComponent implements OnInit { private http = inject(HttpClient); private dialogRef = inject(MatDialogRef); private snackBar = inject(MatSnackBar); + private data = inject(MAT_DIALOG_DATA, { optional: true }); form!: FormGroup; loading = false; searched = false; results: CustomerSearchResult[] = []; - displayedColumns = [ - 'displayName', - 'account', - 'mobileNo', - 'officeName', - 'panNumber', - 'aadhaarNumber', - 'address', - 'select' + + readonly accountTypeOptions: Array<{ key: AccountTypeKey; label: string; icon: string }> = [ + { key: 'savings', label: 'Savings', icon: 'savings' }, + { key: 'current', label: 'Current', icon: 'account_balance_wallet' }, + { key: 'fixedDeposit', label: 'Fixed Deposit', icon: 'lock' }, + { key: 'recurringDeposit', label: 'Recurring Deposit', icon: 'autorenew' } ]; ngOnInit(): void { @@ -247,6 +562,48 @@ export class CustomerSearchDialogComponent implements OnInit { }); } + /** The account screens need an account to open; a caller that only needs the customer opts out. */ + private get requireAccount(): boolean { + return this.data?.requireAccount !== false; + } + + canSelect(r: CustomerSearchResult): boolean { + return !this.requireAccount || !!r.accountNo; + } + + isTypeOn(key: AccountTypeKey): boolean { + return !!this.form?.get(key)?.value; + } + + toggleType(key: AccountTypeKey): void { + const control = this.form.get(key); + control?.setValue(!control.value); + } + + upperCase(name: string): void { + const control = this.form.get(name); + const value = String(control?.value ?? ''); + const upper = value.toUpperCase(); + if (value !== upper) { + control?.setValue(upper, { emitEvent: false }); + } + } + + /** Enter in a text box runs the search; on a button it keeps its own meaning. */ + onEnter(event: Event): void { + if ((event.target as HTMLElement)?.tagName !== 'INPUT') return; + event.preventDefault(); + if (!this.loading) this.onSearch(); + } + + initials(name: string | null): string { + const words = (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(); + } + accountTypeLabel(type: string | null): string { switch (type) { case 'SAVINGS': @@ -297,6 +654,22 @@ export class CustomerSearchDialogComponent implements OnInit { }); } + onClear(): void { + this.form.reset({ + name: '', + accountNumber: '', + aadhaarNumber: '', + panNumber: '', + address: '', + savings: false, + current: false, + fixedDeposit: false, + recurringDeposit: false + }); + this.results = []; + this.searched = false; + } + onSelect(result: CustomerSearchResult): void { this.dialogRef.close(result); } diff --git a/src/app/shared/loading-screen/loading-screen.component.html b/src/app/shared/loading-screen/loading-screen.component.html new file mode 100644 index 000000000..1a53bec62 --- /dev/null +++ b/src/app/shared/loading-screen/loading-screen.component.html @@ -0,0 +1,80 @@ + + +@if (active) { + @if (currentMode === 'overlay') { + +
+ +
+ } + + @if (currentMode === 'fullscreen') { + +
+
+ CBS Nexora + Core Banking System +
+ +
+ @if (!lottieLoaded) { +
+
+
+
+
+ } +
+ +
+
+
+ +

{{ message || 'Connecting to Core Banking Engine...' }}

+
+ } + + @if (currentMode === 'inline') { + +
+
+ @if (!lottieLoaded) { +
+
+
+
+
+ } +
+ @if (message) { +

{{ message }}

+ } +
+ } +} diff --git a/src/app/shared/loading-screen/loading-screen.component.scss b/src/app/shared/loading-screen/loading-screen.component.scss new file mode 100644 index 000000000..345478be2 --- /dev/null +++ b/src/app/shared/loading-screen/loading-screen.component.scss @@ -0,0 +1,349 @@ +/** + * 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; +} + +/* ========================================================================== + Overlay Mode (MifosProgressIndicatorOverlay / MifosLoadingDialog) + ========================================================================== */ +.cbs-loading-backdrop { + position: fixed; + inset: 0; + z-index: 99999; + display: flex; + align-items: center; + justify-content: center; + background: rgb(9 13 22 / 72%); + backdrop-filter: blur(8px); + animation: backdrop-fade-in 0.25s ease-out forwards; +} + +.cbs-loading-dialog { + position: relative; + width: 90%; + max-width: 340px; + padding: 28px 24px; + background: #111827; + color: #f8fafc; + border-radius: 20px; + border: 1px solid rgb(255 255 255 / 12%); + box-shadow: + 0 25px 60px -15px rgb(0 0 0 / 65%), + 0 0 35px rgb(37 99 235 / 18%); + text-align: center; + overflow: hidden; + animation: dialog-scale-in 0.3s cubic-bezier(0.16, 1, 0.3, 1) forwards; +} + +:host-context([data-theme='light']) .cbs-loading-dialog, +:host-context(.light-theme) .cbs-loading-dialog { + background: #fff; + color: #0f172a; + border-color: rgb(0 0 0 / 8%); + box-shadow: + 0 25px 50px -12px rgb(0 0 0 / 25%), + 0 0 25px rgb(37 99 235 / 12%); +} + +.cbs-loading-dialog__content { + margin-top: 10px; + margin-bottom: 16px; +} + +.cbs-loading-dialog__title { + font-size: 1.05rem; + font-weight: 700; + letter-spacing: -0.01em; + margin: 0 0 6px; +} + +.cbs-loading-dialog__message { + font-size: 0.82rem; + color: #94a3b8; + line-height: 1.45; + margin: 0; +} + +:host-context([data-theme='light']) .cbs-loading-dialog__message, +:host-context(.light-theme) .cbs-loading-dialog__message { + color: #64748b; +} + +/* Pulse bar at dialog bottom */ +.cbs-loading-pulse-bar { + width: 100%; + height: 3px; + background: rgb(255 255 255 / 8%); + border-radius: 99px; + overflow: hidden; + position: relative; + margin-top: 6px; +} + +.cbs-pulse-bar__indicator { + position: absolute; + top: 0; + left: 0; + height: 100%; + width: 40%; + background: linear-gradient(90deg, #2563eb, #38bdf8); + border-radius: 99px; + animation: pulse-bar-slide 1.4s infinite ease-in-out; +} + +/* ========================================================================== + Full-Screen Mode (App Boot / Splash) + ========================================================================== */ +.cbs-fullscreen-loader { + position: fixed; + inset: 0; + z-index: 99999; + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + background: radial-gradient(circle at center, #0f172a 0%, #090d16 100%); + color: #f8fafc; + padding: 24px; + text-align: center; +} + +:host-context([data-theme='light']) .cbs-fullscreen-loader, +:host-context(.light-theme) .cbs-fullscreen-loader { + background: radial-gradient(circle at center, #fff 0%, #f1f5f9 100%); + color: #0f172a; +} + +.cbs-fullscreen-loader__brand { + display: flex; + flex-direction: column; + align-items: center; + gap: 8px; + margin-bottom: 16px; +} + +.cbs-fullscreen-loader__logo-img { + height: 52px; + width: auto; + max-width: 260px; + object-fit: contain; + filter: drop-shadow(0 6px 18px rgb(0 0 0 / 45%)); +} + +.cbs-brand-icon { + width: 64px; + height: 64px; + border-radius: 16px; + background: linear-gradient(135deg, #1e3a8a 0%, #0284c7 100%); + display: flex; + align-items: center; + justify-content: center; + box-shadow: 0 10px 24px rgb(14 165 233 / 35%); + margin-bottom: 6px; +} + +.cbs-brand-name { + font-size: 1.5rem; + font-weight: 800; + letter-spacing: -0.02em; + margin: 0; +} + +.cbs-brand-sub { + font-size: 0.8rem; + color: #94a3b8; + font-weight: 500; + letter-spacing: 0.04em; + text-transform: uppercase; +} + +.cbs-progress-track { + width: 240px; + height: 5px; + background: rgb(255 255 255 / 10%); + border-radius: 99px; + overflow: hidden; + margin: 18px 0 12px; + position: relative; +} + +.cbs-progress-fill { + position: absolute; + top: 0; + left: 0; + height: 100%; + width: 45%; + background: linear-gradient(90deg, #2563eb, #38bdf8); + border-radius: 99px; + box-shadow: 0 0 12px rgb(56 189 248 / 50%); + animation: pulse-bar-slide 1.5s infinite ease-in-out; +} + +.cbs-fullscreen-loader__status { + font-size: 0.82rem; + color: #94a3b8; + font-family: inherit; + margin: 0; +} + +/* ========================================================================== + Inline Mode + ========================================================================== */ +.cbs-inline-loader { + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + padding: 32px 16px; + text-align: center; +} + +.cbs-inline-loader__message { + font-size: 0.82rem; + color: #94a3b8; + margin-top: 10px; +} + +/* ========================================================================== + Lottie & Vector Spinner Dimensions + ========================================================================== */ +.cbs-lottie-container { + width: 110px; + height: 110px; + margin: 0 auto; + display: flex; + align-items: center; + justify-content: center; +} + +.cbs-lottie-container--large { + width: 140px; + height: 140px; +} + +.cbs-lottie-container--compact { + width: 64px; + height: 64px; +} + +/* Vector Spinner Fallback (CSS Multi-Ring Loader) */ +.cbs-vector-spinner { + width: 72px; + height: 72px; + position: relative; +} + +.cbs-vector-spinner--large { + width: 96px; + height: 96px; +} + +.cbs-vector-spinner--compact { + width: 44px; + height: 44px; +} + +.spinner-ring { + position: absolute; + inset: 0; + border-radius: 50%; + border: 3px solid transparent; +} + +.ring-outer { + border-top-color: #2563eb; + border-right-color: #2563eb; + animation: spin-outer 1.2s cubic-bezier(0.5, 0, 0.5, 1) infinite; +} + +.ring-mid { + inset: 6px; + border-bottom-color: #0ea5e9; + border-left-color: #0ea5e9; + animation: spin-mid 1.6s cubic-bezier(0.5, 0, 0.5, 1) infinite reverse; +} + +.ring-inner { + inset: 13px; + border-top-color: #10b981; + animation: spin-inner 0.9s cubic-bezier(0.5, 0, 0.5, 1) infinite; +} + +/* ========================================================================== + Keyframe Animations + ========================================================================== */ +@keyframes backdrop-fade-in { + from { + opacity: 0; + } + + to { + opacity: 1; + } +} + +@keyframes dialog-scale-in { + from { + opacity: 0; + transform: scale(0.92); + } + + to { + opacity: 1; + transform: scale(1); + } +} + +@keyframes pulse-bar-slide { + 0% { + left: -40%; + width: 30%; + } + + 50% { + left: 35%; + width: 50%; + } + + 100% { + left: 100%; + width: 30%; + } +} + +@keyframes spin-outer { + from { + transform: rotate(0deg); + } + + to { + transform: rotate(360deg); + } +} + +@keyframes spin-mid { + from { + transform: rotate(0deg); + } + + to { + transform: rotate(360deg); + } +} + +@keyframes spin-inner { + from { + transform: rotate(0deg); + } + + to { + transform: rotate(360deg); + } +} diff --git a/src/app/shared/loading-screen/loading-screen.component.ts b/src/app/shared/loading-screen/loading-screen.component.ts new file mode 100644 index 000000000..34f0f5b0d --- /dev/null +++ b/src/app/shared/loading-screen/loading-screen.component.ts @@ -0,0 +1,168 @@ +/** + * 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, + OnDestroy, + Input, + ElementRef, + ViewChild, + AfterViewInit, + inject, + ChangeDetectorRef +} from '@angular/core'; +import { CommonModule } from '@angular/common'; +import { Subscription } from 'rxjs'; +import { LoadingScreenService, LoadingConfig } from './loading-screen.service'; + +declare const lottie: any; + +@Component({ + selector: 'mifosx-loading-screen', + standalone: true, + imports: [CommonModule], + templateUrl: './loading-screen.component.html', + styleUrls: ['./loading-screen.component.scss'] +}) +export class LoadingScreenComponent implements OnInit, AfterViewInit, OnDestroy { + private readonly loadingService = inject(LoadingScreenService); + private readonly cdr = inject(ChangeDetectorRef); + + @Input() mode: 'overlay' | 'inline' | 'fullscreen' = 'overlay'; + @Input() title: string = 'CBS Nexora'; + @Input() message: string = 'Processing transaction...'; + @Input() active: boolean = false; + @Input() isGlobalOverlay: boolean = true; + + @ViewChild('lottieContainer') lottieContainerRef?: ElementRef; + + currentMode: 'overlay' | 'inline' | 'fullscreen' = 'overlay'; + lottieLoaded: boolean = false; + private animInstance: any = null; + private subscription?: Subscription; + + ngOnInit(): void { + this.currentMode = this.mode; + + if (this.isGlobalOverlay) { + this.subscription = new Subscription(); + this.subscription.add( + this.loadingService.isVisible$.subscribe((visible) => { + this.active = visible; + this.cdr.markForCheck(); + if (visible) { + setTimeout(() => this.initLottie(), 50); + } else { + this.destroyLottie(); + } + }) + ); + + this.subscription.add( + this.loadingService.config$.subscribe((cfg: LoadingConfig) => { + if (cfg.title !== undefined) this.title = cfg.title; + if (cfg.message !== undefined) this.message = cfg.message; + if (cfg.mode !== undefined) this.currentMode = cfg.mode; + this.cdr.markForCheck(); + }) + ); + } + } + + ngAfterViewInit(): void { + if (this.active) { + this.initLottie(); + } + } + + ngOnDestroy(): void { + this.subscription?.unsubscribe(); + this.destroyLottie(); + } + + preventDismiss(event: MouseEvent): void { + event.stopPropagation(); + } + + private initLottie(): void { + if (!this.lottieContainerRef?.nativeElement) return; + this.destroyLottie(); + + const container = this.lottieContainerRef.nativeElement; + + // Check if lottie-web global is available + if (typeof lottie !== 'undefined') { + try { + this.animInstance = lottie.loadAnimation({ + container, + renderer: 'svg', + loop: true, + autoplay: true, + path: 'assets/animations/loading_animation.json' + }); + this.lottieLoaded = true; + this.cdr.markForCheck(); + return; + } catch (err) { + console.warn('Could not load Lottie animation, using vector fallback', err); + } + } + + // Try dynamic script load of local asset if not loaded yet + this.loadLottieScript() + .then(() => { + if (typeof lottie !== 'undefined' && this.lottieContainerRef?.nativeElement) { + this.animInstance = lottie.loadAnimation({ + container: this.lottieContainerRef.nativeElement, + renderer: 'svg', + loop: true, + autoplay: true, + path: 'assets/animations/loading_animation.json' + }); + this.lottieLoaded = true; + this.cdr.markForCheck(); + } + }) + .catch(() => { + this.lottieLoaded = false; + this.cdr.markForCheck(); + }); + } + + private loadLottieScript(): Promise { + return new Promise((resolve, reject) => { + if (typeof lottie !== 'undefined') { + resolve(); + return; + } + + const existingScript = document.getElementById('cbs-lottie-script'); + if (existingScript) { + existingScript.addEventListener('load', () => resolve()); + return; + } + + const script = document.createElement('script'); + script.id = 'cbs-lottie-script'; + script.src = 'assets/js/lottie.min.js'; + script.onload = () => resolve(); + script.onerror = () => reject(); + document.head.appendChild(script); + }); + } + + private destroyLottie(): void { + if (this.animInstance) { + try { + this.animInstance.destroy(); + } catch (_) {} + this.animInstance = null; + } + } +} diff --git a/src/app/shared/loading-screen/loading-screen.service.ts b/src/app/shared/loading-screen/loading-screen.service.ts new file mode 100644 index 000000000..7952a3ea6 --- /dev/null +++ b/src/app/shared/loading-screen/loading-screen.service.ts @@ -0,0 +1,61 @@ +/** + * 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 } from '@angular/core'; +import { BehaviorSubject, Observable } from 'rxjs'; + +export interface LoadingConfig { + title?: string; + message?: string; + mode?: 'overlay' | 'inline' | 'fullscreen'; +} + +@Injectable({ + providedIn: 'root' +}) +export class LoadingScreenService { + private readonly visibleSubject = new BehaviorSubject(false); + private readonly configSubject = new BehaviorSubject({ + title: 'CBS Nexora', + message: 'Processing transaction...', + mode: 'overlay' + }); + + readonly isVisible$: Observable = this.visibleSubject.asObservable(); + readonly config$: Observable = this.configSubject.asObservable(); + + /** + * Show the loading screen/overlay + * @param message Optional message to display (e.g. "Processing Transfer...") + * @param title Optional title to display (defaults to "CBS Nexora") + * @param mode Optional display mode ('overlay' | 'fullscreen') + */ + show(message?: string, title?: string, mode: 'overlay' | 'fullscreen' = 'overlay'): void { + this.configSubject.next({ + title: title || 'CBS Nexora', + message: message || 'Please wait...', + mode + }); + this.visibleSubject.next(true); + } + + /** + * Hide the loading screen/overlay + */ + hide(): void { + this.visibleSubject.next(false); + } + + /** + * Update message while loading screen is active + */ + setMessage(message: string): void { + const current = this.configSubject.getValue(); + this.configSubject.next({ ...current, message }); + } +} diff --git a/src/app/shared/shared.module.ts b/src/app/shared/shared.module.ts index 7135ffff2..46dc2d16e 100644 --- a/src/app/shared/shared.module.ts +++ b/src/app/shared/shared.module.ts @@ -30,6 +30,7 @@ import { SearchToolComponent } from './search-tool/search-tool.component'; import { KeyboardShortcutsDialogComponent } from './keyboard-shortcuts-dialog/keyboard-shortcuts-dialog.component'; import { ServerSelectorComponent } from './server-selector/server-selector.component'; import { TenantSelectorComponent } from './tenant-selector/tenant-selector.component'; +import { LoadingScreenComponent } from './loading-screen/loading-screen.component'; /** Custom Modules */ import { IconsModule } from './icons.module'; @@ -116,9 +117,11 @@ import { DatetimePickerStubComponent } from './datetime-picker-stub/datetime-pic DropdownComponent, InputAmountComponent, InputPasswordComponent, + LoadingScreenComponent, DatetimePickerStubComponent // temporarily added stub for datetime picker ], exports: [ + LoadingScreenComponent, FileUploadComponent, FooterComponent, LanguageSelectorComponent, diff --git a/src/app/system/configurations/global-configurations-tab/global-configurations-tab.component.html b/src/app/system/configurations/global-configurations-tab/global-configurations-tab.component.html index c4c147847..4128299e6 100644 --- a/src/app/system/configurations/global-configurations-tab/global-configurations-tab.component.html +++ b/src/app/system/configurations/global-configurations-tab/global-configurations-tab.component.html @@ -53,7 +53,18 @@ {{ 'labels.inputs.Value' | translate }} - {{ configuration.value }} + + {{ configuration.value }} + + @if (glAccountLabel(configuration); as glAccount) { + {{ glAccount }} + } + diff --git a/src/app/system/configurations/global-configurations-tab/global-configurations-tab.component.scss b/src/app/system/configurations/global-configurations-tab/global-configurations-tab.component.scss index 4561ef8a6..9141035e4 100644 --- a/src/app/system/configurations/global-configurations-tab/global-configurations-tab.component.scss +++ b/src/app/system/configurations/global-configurations-tab/global-configurations-tab.component.scss @@ -9,3 +9,14 @@ table { width: 100%; } + +/* + * The ledger account a setting points at. Quieter than the value itself, because the id is what the + * setting actually holds and what gets typed when it is edited - the name is here to say what that + * id means, not to replace it. + */ +.gc-gl-name { + margin-left: 8px; + color: rgb(0 0 0 / 60%); + font-size: 0.82rem; +} diff --git a/src/app/system/configurations/global-configurations-tab/global-configurations-tab.component.ts b/src/app/system/configurations/global-configurations-tab/global-configurations-tab.component.ts index 4af539e36..8a338308b 100644 --- a/src/app/system/configurations/global-configurations-tab/global-configurations-tab.component.ts +++ b/src/app/system/configurations/global-configurations-tab/global-configurations-tab.component.ts @@ -29,6 +29,7 @@ import { ActivatedRoute, Router, RouterLink } from '@angular/router'; /** Custom Services */ import { SystemService } from '../../system.service'; +import { AccountingService } from 'app/accounting/accounting.service'; import { PopoverService } from '../../../configuration-wizard/popover/popover.service'; import { ConfigurationWizardService } from '../../../configuration-wizard/configuration-wizard.service'; import { FaIconComponent } from '@fortawesome/angular-fontawesome'; @@ -71,9 +72,22 @@ export class GlobalConfigurationsTabComponent implements OnInit, AfterViewInit { private router = inject(Router); private configurationWizardService = inject(ConfigurationWizardService); private popoverService = inject(PopoverService); + private accountingService = inject(AccountingService); /** Configuration data. */ configurationData: any; + + /** + * The chart of accounts, so a setting that holds a ledger account's id can be shown by its name. + * + * Several settings store nothing but a number - "44" says which account the money moves through, + * and says it only to the database. An officer checking that the scheme is posting where it should + * would otherwise have to carry the number to the Chart of Accounts and look it up. + * + * Left empty if the list cannot be read - a user without permission on the chart of accounts still + * sees every setting and its value, just without the name beside it. + */ + private glAccountsById = new Map(); /** Columns to be displayed in configurations table. */ displayedColumns: string[] = [ 'name', @@ -119,6 +133,36 @@ export class GlobalConfigurationsTabComponent implements OnInit, AfterViewInit { */ ngOnInit() { this.setConfigurationData(); + this.loadGlAccounts(); + } + + private loadGlAccounts(): void { + this.accountingService.getGlAccounts().subscribe({ + next: (accounts: any) => { + for (const account of accounts || []) { + this.glAccountsById.set(Number(account.id), account); + } + }, + // The settings are the point of this screen; a chart of accounts that will not load must not + // take them down with it. The values still show, only the names beside them are missing. + error: () => this.glAccountsById.clear() + }); + } + + /** + * The ledger account a setting points at, as "name (code)" - the same way the product screens show + * one. Empty for every setting that does not hold a ledger account id, which is most of them. + * + * Only settings whose name marks them as an account mapping are resolved. Reading every numeric + * setting as an account id would put a name against figures that are nothing of the sort - a + * retry count of 3 would come back as whatever account 3 happens to be. + */ + glAccountLabel(configuration: any): string { + if (!configuration?.name?.endsWith('-account')) { + return ''; + } + const account = this.glAccountsById.get(Number(configuration.value)); + return account ? `${account.name} (${account.glCode})` : ''; } /** diff --git a/src/app/tasks/checker-inbox-and-tasks-tabs/checker-inbox/checker-inbox.component.ts b/src/app/tasks/checker-inbox-and-tasks-tabs/checker-inbox/checker-inbox.component.ts index e05e8aa64..6d64536f9 100644 --- a/src/app/tasks/checker-inbox-and-tasks-tabs/checker-inbox/checker-inbox.component.ts +++ b/src/app/tasks/checker-inbox-and-tasks-tabs/checker-inbox/checker-inbox.component.ts @@ -343,10 +343,13 @@ export class CheckerInboxComponent implements OnInit { // resourceId is absent (CREATE rows: entity does not exist yet until approved), // or entityName is unmapped — fall back to the detail dialog so the checker // can still view the command payload and action the entry. + // A Loan Base Interest Rate request carries a 10-column rate chart, which needs a wide dialog. + const wide = (row.entityName ?? '').toUpperCase() === 'LOANBASEINTERESTRATE'; this.dialog .open(ViewCheckerInboxComponent, { data: { checkerId: row.id }, - autoFocus: false + autoFocus: false, + ...(wide ? { width: '1200px', maxWidth: '95vw' } : {}) }) .afterClosed() .subscribe((result: any) => { diff --git a/src/app/tasks/checker-inbox-and-tasks/checker-inbox-and-tasks.component.html b/src/app/tasks/checker-inbox-and-tasks/checker-inbox-and-tasks.component.html index 182b1f6c9..65f82890f 100644 --- a/src/app/tasks/checker-inbox-and-tasks/checker-inbox-and-tasks.component.html +++ b/src/app/tasks/checker-inbox-and-tasks/checker-inbox-and-tasks.component.html @@ -189,6 +189,10 @@ Disbursement & Transfers
+ + + DDS Approvals + `${c.productSrNo}|${c.slabOrder}`; + const oldCells = new Map( + (active?.slabs ?? []).map((c: any): [string, any] => [ + cellKey(c), + c + ]) + ); + const newCells = new Map( + (submitted?.slabs ?? []).map((c: any): [string, any] => [ + cellKey(c), + c + ]) + ); + const names = new Map(); + for (const c of active?.slabs ?? []) { + if (c.productName) { + names.set(c.productSrNo, c.productName); + } + } + for (const p of submitted?.products ?? []) { + names.set(p.srNo, p.name); + } + + const allKeys = new Set([ + ...newCells.keys(), + ...oldCells.keys() + ]); + const cellChanges = [...allKeys] + .map((k) => { + const before = oldCells.get(k); + const after = newCells.get(k); + const ref = after ?? before; + return { + srNo: Number(ref.productSrNo), + slabOrder: Number(ref.slabOrder), + label: `${names.get(ref.productSrNo) ?? `Loan type ${ref.productSrNo}`} — ${ref.slabLabel ?? `Slab ${ref.slabOrder + 1}`}`, + oldText: before ? cellText(before) : EMPTY, + newText: after ? cellText(after) : EMPTY + }; + }) + // A cell that is N.A. in the submission and was never in the old version is not a change. + .filter((c) => c.oldText !== c.newText && !(c.oldText === EMPTY && c.newText === NA)) + .sort((a, b) => a.srNo - b.srNo || a.slabOrder - b.slabOrder); + for (const c of cellChanges) { + changes.push({ + label: c.label, + kind: kindOf(c.oldText, c.newText, [ + EMPTY, + NA + ]), + oldValue: c.oldText, + newValue: c.newText + }); + } + + // CIBIL bands, matched by their score range, so a re-ordered list is not reported as a change. + const bandKey = (b: any) => `${b.scoreFrom}-${b.scoreTo}`; + const bandText = (b: any) => `${Number(b.adjustmentPct)}% (${b.slabType ?? 'Flat'})`; + const oldBands = new Map( + (active?.cibilSlabs ?? []).map((b: any): [string, any] => [ + bandKey(b), + b + ]) + ); + const newBands = new Map( + (submitted?.cibilSlabs ?? []).map((b: any): [string, any] => [ + bandKey(b), + b + ]) + ); + for (const k of new Set([ + ...newBands.keys(), + ...oldBands.keys() + ])) { + const before = oldBands.get(k); + const after = newBands.get(k); + const oldText = before ? bandText(before) : EMPTY; + const newText = after ? bandText(after) : EMPTY; + if (oldText !== newText) { + const ref = after ?? before; + changes.push({ + label: `CIBIL ${ref.scoreFrom}–${ref.scoreTo}${ref.tier ? ` (${ref.tier})` : ''} — Adjustment`, + kind: kindOf(oldText, newText, [EMPTY]), + oldValue: oldText, + newValue: newText + }); + } + } + + return changes; +} diff --git a/src/app/tasks/maker-checker-entity-fetch.ts b/src/app/tasks/maker-checker-entity-fetch.ts index bc0814747..a9d7845e2 100644 --- a/src/app/tasks/maker-checker-entity-fetch.ts +++ b/src/app/tasks/maker-checker-entity-fetch.ts @@ -63,6 +63,9 @@ const ENTITY_FETCHERS: Record Ob FIXEDDEPOSITPRODUCT: (id, s) => s.products.getFixedDepositProductAndTemplate(id), RECURRINGDEPOSITPRODUCT: (id, s) => s.products.getRecurringDepositProduct(id, true), CHARGE: (id, s) => s.products.getCharge(id, true), + // SMA / NPA classification master. The bucket is read as-is; the dialog resolves its range ids to names. + DELINQUENCY_RANGE: (id, s) => s.products.getDelinquencyRange(id), + DELINQUENCY_BUCKET: (id, s) => s.products.getDelinquencyBucket(id), // ---- Accounts ---------------------------------------------------------------- SAVINGSACCOUNT: (id, s) => s.savings.getSavingsAccountData(id), @@ -199,6 +202,12 @@ const DELETE_SUMMARY_KEYS: Record = { 'email', 'officeName' ], + DELINQUENCY_RANGE: [ + 'classification', + 'minimumAgeDays', + 'maximumAgeDays' + ], + DELINQUENCY_BUCKET: ['name'], GLACCOUNT: [ 'name', 'glCode', diff --git a/src/app/tasks/tasks-routing.module.ts b/src/app/tasks/tasks-routing.module.ts index 17282bf54..362f35311 100644 --- a/src/app/tasks/tasks-routing.module.ts +++ b/src/app/tasks/tasks-routing.module.ts @@ -55,6 +55,7 @@ import { GetPendingSavingsAccountEdits } from './common-resolvers/getPendingSavi import { GetPendingCurrentAccountEdits } from './common-resolvers/getPendingCurrentAccountEdits.resolver'; import { GetPendingShareAccountEdits } from './common-resolvers/getPendingShareAccountEdits.resolver'; import { GetPendingShareAccounts } from './common-resolvers/getPendingShareAccounts.resolver'; +import { DdsApprovalsComponent } from '../deposits/dds-approvals/dds-approvals.component'; /** Tasks Routes */ const routes: Routes = [ @@ -213,6 +214,11 @@ const routes: Routes = [ component: DisbursementRequestsComponent, data: { title: 'Disbursement Requests' } }, + { + path: 'dds-approvals', + component: DdsApprovalsComponent, + data: { title: 'DDS Approvals' } + }, { path: 'chequebook-requests', component: ChequebookRequestsComponent, diff --git a/src/app/tasks/view-checker-inbox/view-checker-inbox.component.html b/src/app/tasks/view-checker-inbox/view-checker-inbox.component.html index cf694305e..f7a2f89cd 100644 --- a/src/app/tasks/view-checker-inbox/view-checker-inbox.component.html +++ b/src/app/tasks/view-checker-inbox/view-checker-inbox.component.html @@ -6,7 +6,7 @@ file, You can obtain one at http://mozilla.org/MPL/2.0/. --> -
+
{{ headingTitle }} @@ -114,10 +114,30 @@
} + + @if (rateDiff) { +
+
Rate Chart Comparison
+ @if (changesBaseline) { +

{{ changesBaseline }}

+ } + +
+ } + @if (showChanges) {
Changes Requested ({{ changes.length }})
+ @if (changesBaseline) { +

{{ changesBaseline }}

+ } @if (changes.length === 0) {

This request does not change any value.

} @else { @@ -143,7 +163,7 @@ } - @if (!showChanges && createSections.length > 0) { + @if (!showChanges && !rateDiff && !loanRateLoading && createSections.length > 0) { @for (section of createSections; track section.title) {
{{ section.title }}
@@ -157,7 +177,7 @@ } - @if (!showChanges && createSections.length === 0 && displayJSONData) { + @if (!showChanges && !rateDiff && !loanRateLoading && createSections.length === 0 && displayJSONData) {
{{ deletingEntity ? 'Record To Be Deleted' : ('labels.heading.Command' | translate) }} diff --git a/src/app/tasks/view-checker-inbox/view-checker-inbox.component.scss b/src/app/tasks/view-checker-inbox/view-checker-inbox.component.scss index 9bb961c1c..2dce81707 100644 --- a/src/app/tasks/view-checker-inbox/view-checker-inbox.component.scss +++ b/src/app/tasks/view-checker-inbox/view-checker-inbox.component.scss @@ -32,6 +32,11 @@ $bg-page: #f8fafc; border-radius: 10px; } + /* A wide payload (the Loan Base Interest Rate chart) fills the dialog the caller sized for it. */ + &--dialog#{&}--wide { + width: 100%; + } + &:not(&--dialog) { max-width: 900px; margin: 20px auto; diff --git a/src/app/tasks/view-checker-inbox/view-checker-inbox.component.ts b/src/app/tasks/view-checker-inbox/view-checker-inbox.component.ts index ade448188..22a4a07b5 100644 --- a/src/app/tasks/view-checker-inbox/view-checker-inbox.component.ts +++ b/src/app/tasks/view-checker-inbox/view-checker-inbox.component.ts @@ -10,6 +10,7 @@ import { Component, inject } from '@angular/core'; import { ActivatedRoute, Router } from '@angular/router'; import * as _ from 'lodash'; +import { forkJoin } from 'rxjs'; import { MatDialog, MatDialogRef, MAT_DIALOG_DATA } from '@angular/material/dialog'; /** Custom Services */ @@ -31,6 +32,7 @@ import { groupCreateFields, humanizeKey } from '../command-diff.util'; +import { diffLoanRateRegister, isLoanBaseInterestRateCreate } from '../loan-rate-register-diff.util'; import { deleteSummaryKeys, EntityFetchServices, fetchCurrentEntity } from '../maker-checker-entity-fetch'; /** Services the entity lookup table needs; assembled once and handed to the shared fetcher. */ @@ -42,6 +44,22 @@ import { UsersService } from 'app/users/users.service'; import { FixedDepositsService } from 'app/deposits/fixed-deposits/fixed-deposits.service'; import { RecurringDepositsService } from 'app/deposits/recurring-deposits/recurring-deposits.service'; import { OrganizationService } from 'app/organization/organization.service'; +import { RateRegisterService } from 'app/products/loan-interest-rate-register/rate-register.service'; +import { RateRegisterDiffComponent } from 'app/products/loan-interest-rate-register/rate-register-diff/rate-register-diff.component'; +import { + CibilSlab, + RateChartRow, + SlabColumn +} from 'app/products/loan-interest-rate-register/models/rate-register.model'; + +/** Inputs of the shared rate-register comparison view (see RateRegisterDiffComponent). */ +interface RateDiffView { + slabs: SlabColumn[]; + draftRows: RateChartRow[]; + baselineRows: RateChartRow[]; + draftCibil: CibilSlab[]; + baselineCibil: CibilSlab[]; +} /** * One row of the rendered Command payload. The dialog turns the parsed command JSON into a tree of @@ -132,7 +150,8 @@ const ENTRY_LABEL_KEYS = [ ...STANDALONE_SHARED_IMPORTS, FaIconComponent, NgTemplateOutlet, - DateFormatPipe + DateFormatPipe, + RateRegisterDiffComponent ] }) export class ViewCheckerInboxComponent { @@ -150,6 +169,7 @@ export class ViewCheckerInboxComponent { private fixedDepositsService = inject(FixedDepositsService); private recurringDepositsService = inject(RecurringDepositsService); private organizationService = inject(OrganizationService); + private rateRegisterService = inject(RateRegisterService); /** Bundle handed to the shared entity lookup, so that table stays free of Angular injection. */ private get entityFetchServices(): EntityFetchServices { @@ -196,10 +216,22 @@ export class ViewCheckerInboxComponent { /** UPDATE review: the fields this command actually moves, as OLD -> NEW. */ changes: CommandChange[] = []; + /** Note naming the version a Loan Base Interest Rate request is compared against. */ + changesBaseline = ''; + /** Loan Base Interest Rate request: the chart-style comparison (same view as the Compare screen). */ + rateDiff: RateDiffView | null = null; /** True once the current entity was read and a real comparison is on screen. */ showChanges = false; /** True while the current entity is being fetched for comparison. */ comparing = false; + + /** A Loan Base Interest Rate request still waiting for its chart: hides the raw payload meanwhile. */ + get loanRateLoading(): boolean { + return ( + this.comparing && + isLoanBaseInterestRateCreate(this.checkerInboxDetail?.entityName, this.checkerInboxDetail?.actionName) + ); + } /** True when the screen is describing a record that is about to be deleted. */ deletingEntity = false; /** CREATE review: submitted data bucketed into readable sections. */ @@ -354,6 +386,8 @@ export class ViewCheckerInboxComponent { this.comparing = false; this.deletingEntity = false; this.createSections = []; + this.changesBaseline = ''; + this.rateDiff = null; // commandAsJson is absent on some entries (a DELETE carries no payload), and JSON.parse(undefined) // throws — which as a route only broke the page, but in a dialog would leave an empty shell open. try { @@ -377,10 +411,16 @@ export class ViewCheckerInboxComponent { } else if (action === 'DELETE') { this.loadDeleteSummary(detail); } else if (action === 'CREATE') { + if (isLoanBaseInterestRateCreate(detail?.entityName, detail?.actionName)) { + this.loadLoanRateRegisterChanges(detail); + } this.createSections = this.buildCreateSections(this.jsondata); if ((detail?.entityName ?? '').toUpperCase() === 'GLACCOUNT') { this.resolveGlAccountCreateLabels(detail, this.jsondata); } + if ((detail?.entityName ?? '').toUpperCase() === 'DELINQUENCY_BUCKET') { + this.resolveDelinquencyBucketCreateRanges(detail); + } } } @@ -393,6 +433,10 @@ export class ViewCheckerInboxComponent { * entity showed before. */ private loadUpdateChanges(detail: any): void { + if ((detail?.entityName ?? '').toUpperCase() === 'DELINQUENCY_BUCKET') { + this.loadDelinquencyBucketChanges(detail); + return; + } const request$ = fetchCurrentEntity(detail?.entityName, detail?.resourceId, this.entityFetchServices); if (!request$) { return; @@ -415,6 +459,131 @@ export class ViewCheckerInboxComponent { }); } + /** One line per range for a delinquency bucket, e.g. 'SMA-1 (31 - 60 days)', oldest range first. */ + private delinquencyRangeNames(ids: any[], rangesById: Map): string { + return (ids ?? []) + .map((entry) => rangesById.get(Number(entry?.id ?? entry)) ?? { id: Number(entry?.id ?? entry) }) + .sort((a, b) => (a.minimumAgeDays ?? 0) - (b.minimumAgeDays ?? 0)) + .map((range) => + range.classification + ? `${range.classification} (${range.minimumAgeDays} - ${range.maximumAgeDays ?? 'no limit'} days)` + : `Range #${range.id}` + ) + .join(', '); + } + + /** + * Delinquency bucket UPDATE: the payload lists range ids, which mean nothing to a checker, and the stored + * bucket lists range objects, so a plain field diff would show two unreadable lists. Both sides are turned + * into the same readable range names first, so only a real difference is reported as OLD -> NEW. + */ + private loadDelinquencyBucketChanges(detail: any): void { + this.comparing = true; + forkJoin({ + current: this.productsService.getDelinquencyBucket(detail?.resourceId), + ranges: this.productsService.getDelinquencyRanges() + }).subscribe({ + next: ({ current, ranges }: { current: any; ranges: any }) => { + if (!this.isCurrent(detail)) { + return; + } + this.comparing = false; + const rangesById = new Map( + (ranges ?? []).map((range: any) => [ + Number(range.id), + range + ]) + ); + const submitted = { + ...(this.jsondata ?? {}), + ranges: this.delinquencyRangeNames(this.jsondata?.ranges, rangesById) + }; + const existing = { ...(current ?? {}), ranges: this.delinquencyRangeNames(current?.ranges, rangesById) }; + this.changes = diffCommandAgainstEntity(submitted, existing); + this.showChanges = true; + }, + error: () => { + this.comparing = false; + } + }); + } + + /** Delinquency bucket CREATE: show the chosen ranges by name instead of their ids. */ + private resolveDelinquencyBucketCreateRanges(detail: any): void { + this.productsService.getDelinquencyRanges().subscribe({ + next: (ranges: any) => { + if (!this.isCurrent(detail)) { + return; + } + const rangesById = new Map( + (ranges ?? []).map((range: any) => [ + Number(range.id), + range + ]) + ); + const resolved = { + ...(this.jsondata ?? {}), + ranges: this.delinquencyRangeNames(this.jsondata?.ranges, rangesById) + }; + this.createSections = this.buildCreateSections(resolved); + this.commandFields = this.buildCommandFields(resolved); + } + }); + } + + /** + * Loan Base Interest Rate: every submission is a CREATE of a whole new version (16 loan types x 10 + * slabs plus CIBIL bands), so the raw payload hides the handful of rates the maker actually moved. + * Compares it with the version in force today and shows only the differences as OLD -> NEW. With no + * active version (first ever submission) the grouped CREATE view stays on screen. + */ + private loadLoanRateRegisterChanges(detail: any): void { + this.comparing = true; + this.productsService.getActiveLoanBaseInterestRate().subscribe({ + next: (active: any) => { + if (!this.isCurrent(detail)) { + return; + } + this.comparing = false; + this.changes = diffLoanRateRegister(this.jsondata ?? {}, active ?? {}); + this.rateDiff = this.buildRateDiff(detail, active); + if (active) { + const effective = active.effectiveFromDate ? `, effective ${active.effectiveFromDate}` : ''; + this.changesBaseline = `Compared with active version: ${active.name ?? 'Version ' + active.id}${effective}`; + } else { + this.changesBaseline = + 'No active version exists yet - this is the first version, so every filled value is new.'; + } + // The chart comparison already shows every change, so the flat list is only the fallback for a + // payload the chart could not be built from. + this.showChanges = !this.rateDiff; + }, + error: () => { + this.comparing = false; + } + }); + } + + /** + * The chart-style view of a Loan Base Interest Rate request: the submitted version against the active + * one (or against a blank baseline when there is no active version yet). Null if the payload cannot be + * read, in which case only the change list is shown. + */ + private buildRateDiff(detail: any, active: any): RateDiffView | null { + const submitted = this.rateRegisterService.registerFromPendingEntry(detail); + if (!submitted) { + return null; + } + const base = active ? this.rateRegisterService.registerFromServerDto(active) : null; + return { + slabs: submitted.slabs, + draftRows: submitted.rows, + baselineRows: base ? base.rows : submitted.baselineRows, + draftCibil: submitted.cibilSlabs, + baselineCibil: base ? base.cibilSlabs : submitted.baselineCibilSlabs + }; + } + /** * DELETE: the payload is empty for every entity, so the command alone tells the checker nothing. * Reads the record instead and shows the few fields that identify it. diff --git a/src/app/tds-reports/tds-interest-export.spec.ts b/src/app/tds-reports/tds-interest-export.spec.ts index 0b4335614..dbb9cbc9b 100644 --- a/src/app/tds-reports/tds-interest-export.spec.ts +++ b/src/app/tds-reports/tds-interest-export.spec.ts @@ -17,7 +17,12 @@ import { tdsInterestRows, tdsInterestSummary } from './tds-reports.compute'; installTdsConfigFixture(); const FY = '2025-26'; -const Q = ['2025-06-30', '2025-09-30', '2025-12-31', '2026-03-31']; +const Q = [ + '2025-06-30', + '2025-09-30', + '2025-12-31', + '2026-03-31' +]; const BOOK: TdsDeductee[] = [ { @@ -94,13 +99,21 @@ function build() { describe('TDS register workbook', () => { it('has the four sheets a reader expects, in order', () => { - expect(build().worksheets.map((w) => w.name)).toEqual(['Summary', 'Customers', 'Accounts', 'Form 121 given']); + expect(build().worksheets.map((w) => w.name)).toEqual([ + 'Summary', + 'Customers', + 'Accounts', + 'Form 121 given' + ]); }); it('lists one row per customer, with the figures as numbers and a live total', () => { const ws = build().getWorksheet('Customers')!; expect(ws.getRow(3).getCell(2).value).toBe('Customer name'); - const at = [4, 5].find((n) => ws.getRow(n).getCell(2).value === 'Ramesh')!; + const at = [ + 4, + 5 + ].find((n) => ws.getRow(n).getCell(2).value === 'Ramesh')!; const ramesh = ws.getRow(at); expect(ramesh.getCell(2).value).toBe('Ramesh'); expect(ramesh.getCell(7).value).toBe(60_000); @@ -115,8 +128,21 @@ describe('TDS register workbook', () => { it('lists every account behind the figures, saying which ones count', () => { const ws = build().getWorksheet('Accounts')!; - const byAccount = new Map([4, 5, 6].map((n) => [ws.getRow(n).getCell(4).value, ws.getRow(n)])); - expect([...byAccount.keys()].sort()).toEqual(['FD11', 'FD41', 'SB12']); + const byAccount = new Map( + [ + 4, + 5, + 6 + ].map((n) => [ + ws.getRow(n).getCell(4).value, + ws.getRow(n) + ]) + ); + expect([...byAccount.keys()].sort()).toEqual([ + 'FD11', + 'FD41', + 'SB12' + ]); expect(byAccount.get('SB12')!.getCell(6).value).toBe('No'); expect(byAccount.get('FD11')!.getCell(6).value).toBe('Yes'); expect(byAccount.get('FD11')!.getCell(10).value).toBeInstanceOf(Date); diff --git a/src/app/tds-reports/tds-interest-export.ts b/src/app/tds-reports/tds-interest-export.ts index 2a55151fe..f4f3c657b 100644 --- a/src/app/tds-reports/tds-interest-export.ts +++ b/src/app/tds-reports/tds-interest-export.ts @@ -70,11 +70,12 @@ function summarySheet(wb: ExcelJS.Workbook, input: TdsExportInput): void { const ws = wb.addWorksheet('Summary', { views: [{ showGridLines: false }] }); ws.columns = [ { width: 46 }, - { width: 22 } - ]; + { width: 22 }]; title(ws, `Deposit Interest & TDS — FY ${input.fy}`, 2); ws.addRow([`Branch: ${input.scopeLabel}`]); - ws.addRow([`Prepared on: ${new Date().toLocaleDateString('en-IN', { day: '2-digit', month: 'short', year: 'numeric' })}`]); + ws.addRow([ + `Prepared on: ${new Date().toLocaleDateString('en-IN', { day: '2-digit', month: 'short', year: 'numeric' })}` + ]); ws.getRow(2).font = { color: { argb: 'FF555555' } }; ws.getRow(3).font = { color: { argb: 'FF555555' } }; ws.addRow([]); @@ -82,20 +83,67 @@ function summarySheet(wb: ExcelJS.Workbook, input: TdsExportInput): void { const s = input.summary; section(ws, 'Key figures'); const figures: [string, number, string?][] = [ - ['Customers in this report', s.deductees], - [' of whom have an FD / RD being tracked', s.trackedCustomers], - ['FD / RD interest received this year', s.applicableInterest, MONEY], - ['Savings interest (shown only — no TDS)', s.exemptInterest, MONEY], - ['Customers over the TDS limit', s.crossed], - ['TDS due by the rules', s.totalTds, MONEY], - ['TDS actually deducted from accounts', s.actualTds, MONEY], - ['Difference (due − deducted) — should be 0', s.totalTds - s.actualTds, MONEY], - ['Customers with Form 121 / 15G / 15H', s.declarations], - ['TDS not deducted because of those forms', s.tdsSaved, MONEY], - ['Interest paid without TDS (report in the return)', s.paidWithoutDeduction, MONEY] + [ + 'Customers in this report', + s.deductees + ], + [ + ' of whom have an FD / RD being tracked', + s.trackedCustomers + ], + [ + 'FD / RD interest received this year', + s.applicableInterest, + MONEY + ], + [ + 'Savings interest (shown only — no TDS)', + s.exemptInterest, + MONEY + ], + [ + 'Customers over the TDS limit', + s.crossed + ], + [ + 'TDS due by the rules', + s.totalTds, + MONEY + ], + [ + 'TDS actually deducted from accounts', + s.actualTds, + MONEY + ], + [ + 'Difference (due − deducted) — should be 0', + s.totalTds - s.actualTds, + MONEY + ], + [ + 'Customers with Form 121 / 15G / 15H', + s.declarations + ], + [ + 'TDS not deducted because of those forms', + s.tdsSaved, + MONEY + ], + [ + 'Interest paid without TDS (report in the return)', + s.paidWithoutDeduction, + MONEY + ] ]; - for (const [label, value, fmt] of figures) { - const r = ws.addRow([label, value]); + for (const [ + label, + value, + fmt + ] of figures) { + const r = ws.addRow([ + label, + value + ]); r.getCell(2).numFmt = fmt ?? '#,##0'; r.getCell(2).alignment = { horizontal: 'right' }; boxRow(r, 2); @@ -110,13 +158,31 @@ function summarySheet(wb: ExcelJS.Workbook, input: TdsExportInput): void { if (input.rules) { section(ws, 'Rules for this year'); const rules: [string, string][] = [ - ['TDS limit — below 60', rupees(input.rules.general)], - ['TDS limit — senior citizen (60+)', rupees(input.rules.senior)], - ['Rate with PAN', `${input.rules.rate}%`], - ['Rate without PAN', `${input.rules.rateNoPan}%`] + [ + 'TDS limit — below 60', + rupees(input.rules.general) + ], + [ + 'TDS limit — senior citizen (60+)', + rupees(input.rules.senior) + ], + [ + 'Rate with PAN', + `${input.rules.rate}%` + ], + [ + 'Rate without PAN', + `${input.rules.rateNoPan}%` + ] ]; - for (const [label, value] of rules) { - const r = ws.addRow([label, value]); + for (const [ + label, + value + ] of rules) { + const r = ws.addRow([ + label, + value + ]); r.getCell(2).alignment = { horizontal: 'right' }; boxRow(r, 2); } @@ -253,7 +319,12 @@ function declarationsSheet(wb: ExcelJS.Workbook, input: TdsExportInput): void { { header: 'TDS not deducted because of the form (₹)', width: 18, fmt: MONEY, sum: true }, { header: 'TDS deducted before the form (₹)', width: 16, fmt: MONEY, sum: true } ]; - const ws = table(wb, 'Form 121 given', `Customers with Form 121 / 15G / 15H — FY ${input.fy} · ${input.scopeLabel}`, cols); + const ws = table( + wb, + 'Form 121 given', + `Customers with Form 121 / 15G / 15H — FY ${input.fy} · ${input.scopeLabel}`, + cols + ); input.declaredRows.forEach((r, i) => { const line = ws.addRow([ @@ -276,7 +347,10 @@ function declarationsSheet(wb: ExcelJS.Workbook, input: TdsExportInput): void { } }); if (input.declaredRows.length === 0) { - const r = ws.addRow(['', 'No customer has a declaration on record for this year.']); + const r = ws.addRow([ + '', + 'No customer has a declaration on record for this year.' + ]); r.font = { italic: true, color: { argb: 'FF777777' } }; } else { totals(ws, cols, input.declaredRows.length); diff --git a/src/app/tds-reports/tds-interest-report.component.html b/src/app/tds-reports/tds-interest-report.component.html index d553115f6..70117543e 100644 --- a/src/app/tds-reports/tds-interest-report.component.html +++ b/src/app/tds-reports/tds-interest-report.component.html @@ -49,11 +49,12 @@ account is which, and why.
  • - Only the interest from fixed and recurring deposits that reaches the customer's savings account + Only the interest from + fixed and recurring deposits that reaches the customer's savings account is tracked — counted on the day it arrives. The savings account's own interest is shown for information but never attracts TDS. Interest still inside an FD/RD counts when it is paid out at maturity or closure — to - savings, in cash or to a GL (then the TDS comes out of the payout). A full reinvestment into a new FD/RD is not - taxed. + savings, in cash or to a GL (then the TDS comes out of the payout). A full reinvestment into a new FD/RD is + not taxed.
  • @if (rules) {
  • @@ -102,8 +103,11 @@
    ₹{{ summary.totalTds | number: '1.0-0' }}
    TDS due by the rules
  • -
    +
    ₹{{ summary.actualTds | number: '1.0-0' }}
    Actually deducted from accounts @@ -332,8 +336,11 @@ } @else if (row.schedule.totalTds || row.actualTds) { ₹{{ row.schedule.totalTds | number: '1.0-0' }}
    due @ {{ row.rateApplied * 100 | number: '1.0-0' }}%
    -
    +
    ₹{{ row.actualTds | number: '1.0-0' }} deducted
    } @else if (row.tdsSaved) { @@ -499,8 +506,8 @@ } @else if (selectedTab === 'paying') { No customer needs TDS deducted for these filters. Check the "No TDS" tab. } @else if (selectedTab === 'declared') { - No customer has a Form 121 / 15G / 15H on record for this year. Record one from the customer's - Form 121 tab. + No customer has a Form 121 / 15G / 15H on record for this year. Record one from the customer's Form + 121 tab. } @else { No customers match these filters. } diff --git a/src/app/tds-reports/tds-interest-report.component.ts b/src/app/tds-reports/tds-interest-report.component.ts index 618066c16..ca781d191 100644 --- a/src/app/tds-reports/tds-interest-report.component.ts +++ b/src/app/tds-reports/tds-interest-report.component.ts @@ -226,7 +226,10 @@ export class TdsInterestReportComponent implements OnInit { if (row.hasDeclaration) { const beforeForm = Math.round(row.schedule.totalTds - row.actualTds); if (beforeForm > 0) { - return { label: `Form ${row.form} — ₹${beforeForm.toLocaleString('en-IN')} due from before the form, not yet deducted`, tone: 'warn' }; + return { + label: `Form ${row.form} — ₹${beforeForm.toLocaleString('en-IN')} due from before the form, not yet deducted`, + tone: 'warn' + }; } if (row.tdsSaved > 0) { return { label: `Form ${row.form} — TDS not deducted`, tone: 'ok' }; @@ -244,7 +247,10 @@ export class TdsInterestReportComponent implements OnInit { return { label: 'Over the limit — TDS deducted', tone: 'ok' }; } if (row.actualTds > 0) { - return { label: `₹${Math.round(row.actualTds).toLocaleString('en-IN')} deducted but none due — check`, tone: 'warn' }; + return { + label: `₹${Math.round(row.actualTds).toLocaleString('en-IN')} deducted but none due — check`, + tone: 'warn' + }; } if (row.hasDeclaration && row.crossed) { return { label: 'Over the limit — 15G/15H, no TDS', tone: 'ok' }; diff --git a/src/app/tds-reports/tds-remittance-report.component.html b/src/app/tds-reports/tds-remittance-report.component.html index 48dd34e94..27c8cda5b 100644 --- a/src/app/tds-reports/tds-remittance-report.component.html +++ b/src/app/tds-reports/tds-remittance-report.component.html @@ -45,8 +45,8 @@
    - This report is for Head Office. TDS is paid to the Government by Head Office for the whole bank, - under one TAN. Your branch's customers and the TDS deducted from them are on + This report is for Head Office. TDS is paid to the Government by Head Office for the whole + bank, under one TAN. Your branch's customers and the TDS deducted from them are on Reports › Deposit Interest & TDS.
    @@ -123,7 +123,9 @@
    Reconciled. - ₹{{ summary.totalDeducted | number: '1.0-0' }} deducted, ₹{{ summary.totalDeposited | number: '1.0-0' }} + ₹{{ summary.totalDeducted | number: '1.0-0' }} deducted, ₹{{ + summary.totalDeposited | number: '1.0-0' + }} paid to the Government, ₹{{ summary.pendingDeposit | number: '1.0-0' }} still within its due date.
    @@ -144,9 +146,10 @@
    - By the TDS rules ₹{{ summary.calculatedTds | number: '1.0-0' }} was due this year, but - ₹{{ summary.totalDeducted | number: '1.0-0' }} was actually deducted from accounts. Open - Deposit Interest & TDS to see which customers differ. + By the TDS rules ₹{{ summary.calculatedTds | number: '1.0-0' }} was due this year, but ₹{{ + summary.totalDeducted | number: '1.0-0' + }} + was actually deducted from accounts. Open Deposit Interest & TDS to see which customers differ.
    } @@ -198,12 +201,16 @@ @for (row of rows; track trackRow($index, row)) { - {{ quarterLabel[row.quarter] }} + + {{ quarterLabel[row.quarter] }} + {{ row.month + '-01' | date: 'MMM y' }}
    {{ row.postings }} deduction{{ row.postings === 1 ? '' : 's' }}
    - ₹{{ row.deducted | number: '1.0-2' }} + + ₹{{ row.deducted | number: '1.0-2' }} + {{ row.dueDate | date: 'd MMM y' }} @for (c of row.challans; track c.id) { @@ -330,9 +337,9 @@ }
    - The "without deduction" line is not optional — interest paid gross against a declaration must be reported in the - quarterly {{ statute.statement }} return with the appropriate remark code, and the consolidated statement of - declarations received is uploaded {{ statute.frequency }}. Confirm the remark code against the current + The "without deduction" line is not optional — interest paid gross against a declaration must be reported in + the quarterly {{ statute.statement }} return with the appropriate remark code, and the consolidated statement + of declarations received is uploaded {{ statute.frequency }}. Confirm the remark code against the current specification before generating the file.
    diff --git a/src/app/tds-reports/tds-remittance-report.component.ts b/src/app/tds-reports/tds-remittance-report.component.ts index d24ffc6be..ee29ee6b5 100644 --- a/src/app/tds-reports/tds-remittance-report.component.ts +++ b/src/app/tds-reports/tds-remittance-report.component.ts @@ -153,7 +153,8 @@ export class TdsRemittanceReportComponent implements OnInit { }, error: (err: any) => { this.saving = false; - this.formError = err?.error?.errors?.[0]?.defaultUserMessage || err?.error?.defaultUserMessage || 'Could not save.'; + this.formError = + err?.error?.errors?.[0]?.defaultUserMessage || err?.error?.defaultUserMessage || 'Could not save.'; } }); } @@ -172,7 +173,16 @@ export class TdsRemittanceReportComponent implements OnInit { } private blankForm(): NewTdsChallan { - return { fy: this.fy, month: '', amount: 0, challanNo: '', bsrCode: '', depositedOn: '', paidFromGlAccountId: 0, remarks: '' }; + return { + fy: this.fy, + month: '', + amount: 0, + challanNo: '', + bsrCode: '', + depositedOn: '', + paidFromGlAccountId: 0, + remarks: '' + }; } trackRow(_: number, row: TdsRemittanceRow): string { @@ -228,19 +238,58 @@ export class TdsRemittanceReportComponent implements OnInit { recon.addRow([`${this.statute.statement} year-end position — FY ${this.fy}`]); recon.addRow([]); const lines: [string, number | string][] = [ - ['Deductees', s.deductees], - ['Total interest paid on time deposits', s.totalInterestPaid], - [' — with tax deducted', s.interestWithDeduction], - [' — without deduction (declaration on record)', s.interestWithoutDeduction], - ['TDS at standard rate (PAN on record)', s.tdsAtStandardRate], - ['TDS at higher rate (no PAN)', s.tdsAtHigherRate], - ['TDS due by the rules', s.calculatedTds], - ['Total TDS actually deducted', s.totalDeducted], - ['Total deposited with Government', s.totalDeposited], - ['Still within due date', s.pendingDeposit], - ['Overdue', s.overdueAmount], - ['DIFFERENCE (must be zero)', s.difference], - ['Interest payable for late deposit', s.defaultInterest] + [ + 'Deductees', + s.deductees + ], + [ + 'Total interest paid on time deposits', + s.totalInterestPaid + ], + [ + ' — with tax deducted', + s.interestWithDeduction + ], + [ + ' — without deduction (declaration on record)', + s.interestWithoutDeduction + ], + [ + 'TDS at standard rate (PAN on record)', + s.tdsAtStandardRate + ], + [ + 'TDS at higher rate (no PAN)', + s.tdsAtHigherRate + ], + [ + 'TDS due by the rules', + s.calculatedTds + ], + [ + 'Total TDS actually deducted', + s.totalDeducted + ], + [ + 'Total deposited with Government', + s.totalDeposited + ], + [ + 'Still within due date', + s.pendingDeposit + ], + [ + 'Overdue', + s.overdueAmount + ], + [ + 'DIFFERENCE (must be zero)', + s.difference + ], + [ + 'Interest payable for late deposit', + s.defaultInterest + ] ]; for (const line of lines) { recon.addRow(line); diff --git a/src/app/tds-reports/tds-reports.compute.spec.ts b/src/app/tds-reports/tds-reports.compute.spec.ts index ee0aea6e5..fd8fd919d 100644 --- a/src/app/tds-reports/tds-reports.compute.spec.ts +++ b/src/app/tds-reports/tds-reports.compute.spec.ts @@ -25,7 +25,12 @@ installTdsConfigFixture(); * shapes ("some row crossed") because nobody could know the numbers. */ const FY = '2025-26'; -const Q = ['2025-06-30', '2025-09-30', '2025-12-31', '2026-03-31']; +const Q = [ + '2025-06-30', + '2025-09-30', + '2025-12-31', + '2026-03-31' +]; const fd = (id: number, amounts: number[], extra: object = {}) => ({ id, @@ -68,23 +73,70 @@ function person( const BOOK: TdsDeductee[] = [ // Over the limit in Q4, plus a savings account that must stay outside the figure. person(1, 'Ramesh', '1980-05-01', 'ABCPR1234K', 'Head Office', [ - { ...fd(11, [15_000, 15_000, 15_000, 15_000]), deductions: [{ date: Q[3], amount: 6_000 }] }, + { + ...fd( + 11, + [ + 15_000, + 15_000, + 15_000, + 15_000 + ] + ), + deductions: [{ date: Q[3], amount: 6_000 }] + }, sb(12, 1_200) ]), // Senior citizen, no PAN: higher threshold, higher rate - and the platform deducted too little. person(2, 'Sunita', '1955-01-01', '', 'bhopal', [ - { ...fd(21, [30_000, 30_000, 30_000, 30_000]), deductions: [{ date: Q[3], amount: 20_000 }] } + { + ...fd( + 21, + [ + 30_000, + 30_000, + 30_000, + 30_000 + ] + ), + deductions: [{ date: Q[3], amount: 20_000 }] + } ]), // ₹6,000 short of the limit: the customer to ask for a declaration. - person(3, 'Vitthal', '1985-03-03', 'ABCPV5678L', 'bhopal', [fd(31, [22_000, 22_000])]), + person(3, 'Vitthal', '1985-03-03', 'ABCPV5678L', 'bhopal', [ + fd( + 31, + [ + 22_000, + 22_000 + ] + ) + ]), // Declaration accepted mid-year: interest before it stays liable, interest after it is paid gross. - person(4, 'Kamala', '1979-07-07', 'ABCPK9012M', 'Head Office', [fd(41, [20_000, 20_000, 20_000, 20_000])], { - [FY]: { kind: '15G', acceptedOn: '2025-10-15', fy: FY, uin: 'UIN0001' } - }), + person( + 4, + 'Kamala', + '1979-07-07', + 'ABCPK9012M', + 'Head Office', + [ + fd( + 41, + [ + 20_000, + 20_000, + 20_000, + 20_000 + ] + ) + ], + { + [FY]: { kind: '15G', acceptedOn: '2025-10-15', fy: FY, uin: 'UIN0001' } + } + ), // A minor's deposit, already listed under the guardian by the platform. person(5, 'Ganpat', '1975-02-02', 'ABCPG3456N', 'Head Office', [ - { ...fd(51, [5_000]), holderClientId: 99, holderName: 'Baby Ganpat' } - ]), + { ...fd(51, [5_000]), holderClientId: 99, holderName: 'Baby Ganpat' }]), // Savings only, no PAN: never deducted, so never counted as a no-PAN problem. person(6, 'Shobha', '1990-09-09', '', 'Head Office', [sb(61, 900)]) ]; @@ -201,7 +253,10 @@ describe('the summary', () => { describe('filters', () => { it('narrows to a branch by office name, whatever its case, without re-aggregating', () => { const scoped = tdsInterestRows(filter({ branchCode: 'BHOPAL' })); - expect(scoped.map((r) => r.deductee.name).sort()).toEqual(['Sunita', 'Vitthal']); + expect(scoped.map((r) => r.deductee.name).sort()).toEqual([ + 'Sunita', + 'Vitthal' + ]); expect(scoped.find((r) => r.deductee.name === 'Sunita')!.schedule.totalTds).toBe(24_000); }); @@ -211,7 +266,11 @@ describe('filters', () => { tdsInterestRows(filter({ crossedOnly: true })) .map((r) => r.deductee.name) .sort() - ).toEqual(['Kamala', 'Ramesh', 'Sunita']); + ).toEqual([ + 'Kamala', + 'Ramesh', + 'Sunita' + ]); }); it('searches on name, customer ID and PAN', () => { @@ -242,7 +301,12 @@ describe('remittance', () => { }); it('marks a month paid in full and on time', () => { - const [r] = tdsRemittanceRows(FY, [{ month: '2026-03', amount: 26_000, postings: 2 }], [challan({ amount: 26_000 })], '2026-06-01'); + const [r] = tdsRemittanceRows( + FY, + [{ month: '2026-03', amount: 26_000, postings: 2 }], + [challan({ amount: 26_000 })], + '2026-06-01' + ); expect(r.quarter).toBe(4); expect(r.outstanding).toBe(0); expect(r.late).toBe(false); diff --git a/src/app/tds-reports/tds-reports.compute.ts b/src/app/tds-reports/tds-reports.compute.ts index de0e0e568..547d0a933 100644 --- a/src/app/tds-reports/tds-reports.compute.ts +++ b/src/app/tds-reports/tds-reports.compute.ts @@ -446,7 +446,12 @@ export function tdsRemittanceRows( asAt: string ): TdsRemittanceRow[] { const live = challans.filter((c) => !c.reversed && c.fy === fy); - const months = [...new Set([...deducted.map((d) => d.month), ...live.map((c) => c.month)])].sort(); + const months = [ + ...new Set([ + ...deducted.map((d) => d.month), + ...live.map((c) => c.month) + ]) + ].sort(); return months.map((month) => { const d = deducted.find((x) => x.month === month); @@ -533,9 +538,7 @@ export function tds26qSummary(fy: string, remittance: TdsRemittanceRow[], asAt: const higher = rows.filter((r) => !r.panOnRecord); const totalDeducted = round2(remittance.reduce((s, r) => s + r.deducted, 0)); const totalDeposited = round2(remittance.reduce((s, r) => s + r.deposited, 0)); - const pendingDeposit = round2( - remittance.filter((r) => r.dueDate >= asAt).reduce((s, r) => s + r.outstanding, 0) - ); + const pendingDeposit = round2(remittance.filter((r) => r.dueDate >= asAt).reduce((s, r) => s + r.outstanding, 0)); const overdueAmount = round2(remittance.filter((r) => r.overdue).reduce((s, r) => s + r.outstanding, 0)); return { diff --git a/src/app/tds-reports/tds-reports.service.ts b/src/app/tds-reports/tds-reports.service.ts index 89d50c912..27b7730d4 100644 --- a/src/app/tds-reports/tds-reports.service.ts +++ b/src/app/tds-reports/tds-reports.service.ts @@ -120,7 +120,9 @@ export class TdsReportsService { } createChallan(challan: NewTdsChallan): Observable { - return this.http.post('/tds/challans', challan).pipe(map((rows) => (rows || []).map((r) => this.toChallan(r)))); + return this.http + .post('/tds/challans', challan) + .pipe(map((rows) => (rows || []).map((r) => this.toChallan(r)))); } reverseChallan(id: number): Observable { diff --git a/src/app/web-app.component.html b/src/app/web-app.component.html index 7b7f1edc1..c9a9e5647 100644 --- a/src/app/web-app.component.html +++ b/src/app/web-app.component.html @@ -7,3 +7,4 @@ --> + diff --git a/src/app/web-app.component.ts b/src/app/web-app.component.ts index 5645ad422..f91f850c1 100644 --- a/src/app/web-app.component.ts +++ b/src/app/web-app.component.ts @@ -319,6 +319,11 @@ export class WebAppComponent implements OnInit, OnDestroy { }); }); } + + // Dismiss initial application boot splash screen smoothly + if (typeof (window as any).hideAppSplash === 'function') { + (window as any).hideAppSplash(); + } } /** Stores the server's "today" where every date default reads it (see core/utils/business-today). */ diff --git a/src/assets/animations/error_animation.json b/src/assets/animations/error_animation.json new file mode 100644 index 000000000..25ba45887 --- /dev/null +++ b/src/assets/animations/error_animation.json @@ -0,0 +1,2755 @@ +{ + "v": "4.6.3", + "fr": 24, + "ip": 0, + "op": 21, + "w": 320, + "h": 320, + "nm": "checklist", + "ddd": 0, + "assets": [], + "layers": [ + { + "ddd": 0, + "ind": 2, + "ty": 4, + "nm": "Shape Layer 13", + "ks": { "o": { "a": 0, "k": 100 }, "r": { "a": 0, "k": 300 }, "p": { "a": 0, "k": [ + 160, + 159.5, + 0 + ] }, "a": { "a": 0, "k": [ + 0, + -34, + 0 + ] }, "s": { "a": 0, "k": [ + 100, + 100, + 100 + ] } }, + "ao": 0, + "shapes": [ + { + "ty": "gr", + "it": [ + { "d": 1, "ty": "el", "s": { "a": 1, "k": [ + { "i": { "x": [ + 0.667, + 0.667 + ], "y": [ + 1, + 1 + ] }, "o": { "x": [ + 0.167, + 0.167 + ], "y": [ + 0.167, + 0.167 + ] }, "n": [ + "0p667_1_0p167_0p167", + "0p667_1_0p167_0p167" + ], "t": 6, "s": [ + 15.021, + 15.021 + ], "e": [ + 0, + 0 + ] }, + { "t": 17 }] }, "p": { "a": 0, "k": [ + 0, + 0 + ] }, "nm": "Ellipse Path 1", "mn": "ADBE Vector Shape - Ellipse" }, + { + "ty": "fl", + "c": { + "a": 1, + "k": [ + { + "i": { "x": [0.667], "y": [1] }, + "o": { "x": [0.333], "y": [0] }, + "n": ["0p667_1_0p333_0"], + "t": 6, + "s": [ + 0.8274509, + 0.1843137, + 0.1843137, + 1 + ], + "e": [ + 0.8274509, + 0.1843137, + 0.1843137, + 1 + ] + }, + { + "t": 17 + } + ] + }, + "o": { "a": 0, "k": 100 }, + "r": 1, + "nm": "Fill 1", + "mn": "ADBE Vector Graphic - Fill" + }, + { + "ty": "tr", + "p": { + "a": 1, + "k": [ + { + "i": { "x": 0.667, "y": 1 }, + "o": { "x": 0.167, "y": 0.167 }, + "n": "0p667_1_0p167_0p167", + "t": 6, + "s": [ + -8.142, + -92.147 + ], + "e": [ + -7.675, + -162.544 + ], + "to": [ + 0.07779947668314, + -11.7327470779419 + ], + "ti": [ + -0.07779947668314, + 11.7327470779419 + ] + }, + { "t": 17 } + ], + "ix": 2 + }, + "a": { "a": 0, "k": [ + 0, + 0 + ], "ix": 1 }, + "s": { "a": 0, "k": [ + 83.981, + 100 + ], "ix": 3 }, + "r": { + "a": 1, + "k": [ + { + "i": { "x": [0.667], "y": [1] }, + "o": { "x": [0.167], "y": [0.167] }, + "n": ["0p667_1_0p167_0p167"], + "t": 6, + "s": [20.367], + "e": [6.367] + }, + { + "t": 17 + } + ], + "ix": 6 + }, + "o": { "a": 0, "k": 100, "ix": 7 }, + "sk": { "a": 0, "k": 0, "ix": 4 }, + "sa": { "a": 0, "k": 0, "ix": 5 }, + "nm": "Transform" + } + ], + "nm": "Ellipse 2", + "np": 3, + "cix": 2, + "ix": 1, + "mn": "ADBE Vector Group" + }, + { + "ty": "gr", + "it": [ + { "d": 1, "ty": "el", "s": { "a": 1, "k": [ + { "i": { "x": [ + 0.833, + 0.833 + ], "y": [ + 0.833, + 0.833 + ] }, "o": { "x": [ + 0.167, + 0.167 + ], "y": [ + 0.167, + 0.167 + ] }, "n": [ + "0p833_0p833_0p167_0p167", + "0p833_0p833_0p167_0p167" + ], "t": 6, "s": [ + 15.021, + 15.021 + ], "e": [ + 0, + 0 + ] }, + { "t": 21 }] }, "p": { "a": 0, "k": [ + 0, + 0 + ] }, "nm": "Ellipse Path 1", "mn": "ADBE Vector Shape - Ellipse" }, + { + "ty": "fl", + "c": { + "a": 1, + "k": [ + { + "i": { "x": [0.667], "y": [1] }, + "o": { "x": [0.333], "y": [0] }, + "n": ["0p667_1_0p333_0"], + "t": 6, + "s": [ + 0.8274509, + 0.1843137, + 0.1843137, + 1 + ], + "e": [ + 0.8274509, + 0.1843137, + 0.1843137, + 1 + ] + }, + { + "t": 17 + } + ] + }, + "o": { "a": 0, "k": 100 }, + "r": 1, + "nm": "Fill 1", + "mn": "ADBE Vector Graphic - Fill" + }, + { + "ty": "tr", + "p": { + "a": 1, + "k": [ + { + "i": { "x": 0.833, "y": 0.833 }, + "o": { "x": 0.167, "y": 0.167 }, + "n": "0p833_0p833_0p167_0p167", + "t": 6, + "s": [ + 16.585, + -99.759 + ], + "e": [ + 28.521, + -187.495 + ], + "to": [ + 1.9892578125, + -14.6227216720581 + ], + "ti": [ + -1.9892578125, + 14.6227216720581 + ] + }, + { "t": 21 } + ], + "ix": 2 + }, + "a": { "a": 0, "k": [ + 0, + 0 + ], "ix": 1 }, + "s": { "a": 0, "k": [ + 97.419, + 116 + ], "ix": 3 }, + "r": { + "a": 1, + "k": [ + { + "i": { "x": [0.833], "y": [0.833] }, + "o": { "x": [0.167], "y": [0.167] }, + "n": ["0p833_0p833_0p167_0p167"], + "t": 6, + "s": [14.733], + "e": [8.733] + }, + { + "t": 21 + } + ], + "ix": 6 + }, + "o": { "a": 0, "k": 100, "ix": 7 }, + "sk": { "a": 0, "k": 0, "ix": 4 }, + "sa": { "a": 0, "k": 0, "ix": 5 }, + "nm": "Transform" + } + ], + "nm": "Ellipse 1", + "np": 3, + "cix": 2, + "ix": 2, + "mn": "ADBE Vector Group" + } + ], + "ip": 6, + "op": 22, + "st": -21, + "bm": 0, + "sr": 1 + }, + { + "ddd": 0, + "ind": 3, + "ty": 4, + "nm": "Shape Layer 12", + "ks": { "o": { "a": 0, "k": 100 }, "r": { "a": 0, "k": 250 }, "p": { "a": 0, "k": [ + 160, + 159.5, + 0 + ] }, "a": { "a": 0, "k": [ + 0, + -34, + 0 + ] }, "s": { "a": 0, "k": [ + 100, + 100, + 100 + ] } }, + "ao": 0, + "shapes": [ + { + "ty": "gr", + "it": [ + { "d": 1, "ty": "el", "s": { "a": 1, "k": [ + { "i": { "x": [ + 0.667, + 0.667 + ], "y": [ + 1, + 1 + ] }, "o": { "x": [ + 0.167, + 0.167 + ], "y": [ + 0.167, + 0.167 + ] }, "n": [ + "0p667_1_0p167_0p167", + "0p667_1_0p167_0p167" + ], "t": 6, "s": [ + 15.021, + 15.021 + ], "e": [ + 0, + 0 + ] }, + { "t": 17 }] }, "p": { "a": 0, "k": [ + 0, + 0 + ] }, "nm": "Ellipse Path 1", "mn": "ADBE Vector Shape - Ellipse" }, + { + "ty": "fl", + "c": { + "a": 1, + "k": [ + { + "i": { "x": [0.667], "y": [1] }, + "o": { "x": [0.333], "y": [0] }, + "n": ["0p667_1_0p333_0"], + "t": 6, + "s": [ + 0.8274509, + 0.1843137, + 0.1843137, + 1 + ], + "e": [ + 0.8274509, + 0.1843137, + 0.1843137, + 1 + ] + }, + { + "t": 17 + } + ] + }, + "o": { "a": 0, "k": 100 }, + "r": 1, + "nm": "Fill 1", + "mn": "ADBE Vector Graphic - Fill" + }, + { + "ty": "tr", + "p": { + "a": 1, + "k": [ + { + "i": { "x": 0.667, "y": 1 }, + "o": { "x": 0.167, "y": 0.167 }, + "n": "0p667_1_0p167_0p167", + "t": 6, + "s": [ + -8.142, + -92.147 + ], + "e": [ + -7.675, + -162.544 + ], + "to": [ + 0.07779947668314, + -11.7327470779419 + ], + "ti": [ + -0.07779947668314, + 11.7327470779419 + ] + }, + { "t": 17 } + ], + "ix": 2 + }, + "a": { "a": 0, "k": [ + 0, + 0 + ], "ix": 1 }, + "s": { "a": 0, "k": [ + 83.981, + 100 + ], "ix": 3 }, + "r": { + "a": 1, + "k": [ + { + "i": { "x": [0.667], "y": [1] }, + "o": { "x": [0.167], "y": [0.167] }, + "n": ["0p667_1_0p167_0p167"], + "t": 6, + "s": [20.367], + "e": [6.367] + }, + { + "t": 17 + } + ], + "ix": 6 + }, + "o": { "a": 0, "k": 100, "ix": 7 }, + "sk": { "a": 0, "k": 0, "ix": 4 }, + "sa": { "a": 0, "k": 0, "ix": 5 }, + "nm": "Transform" + } + ], + "nm": "Ellipse 2", + "np": 3, + "cix": 2, + "ix": 1, + "mn": "ADBE Vector Group" + }, + { + "ty": "gr", + "it": [ + { "d": 1, "ty": "el", "s": { "a": 1, "k": [ + { "i": { "x": [ + 0.833, + 0.833 + ], "y": [ + 0.833, + 0.833 + ] }, "o": { "x": [ + 0.167, + 0.167 + ], "y": [ + 0.167, + 0.167 + ] }, "n": [ + "0p833_0p833_0p167_0p167", + "0p833_0p833_0p167_0p167" + ], "t": 6, "s": [ + 15.021, + 15.021 + ], "e": [ + 0, + 0 + ] }, + { "t": 21 }] }, "p": { "a": 0, "k": [ + 0, + 0 + ] }, "nm": "Ellipse Path 1", "mn": "ADBE Vector Shape - Ellipse" }, + { + "ty": "fl", + "c": { + "a": 1, + "k": [ + { + "i": { "x": [0.667], "y": [1] }, + "o": { "x": [0.333], "y": [0] }, + "n": ["0p667_1_0p333_0"], + "t": 6, + "s": [ + 0.8274509, + 0.1843137, + 0.1843137, + 1 + ], + "e": [ + 0.8274509, + 0.1843137, + 0.1843137, + 1 + ] + }, + { + "t": 17 + } + ] + }, + "o": { "a": 0, "k": 100 }, + "r": 1, + "nm": "Fill 1", + "mn": "ADBE Vector Graphic - Fill" + }, + { + "ty": "tr", + "p": { + "a": 1, + "k": [ + { + "i": { "x": 0.833, "y": 0.833 }, + "o": { "x": 0.167, "y": 0.167 }, + "n": "0p833_0p833_0p167_0p167", + "t": 6, + "s": [ + 16.585, + -99.759 + ], + "e": [ + 28.521, + -187.495 + ], + "to": [ + 1.9892578125, + -14.6227216720581 + ], + "ti": [ + -1.9892578125, + 14.6227216720581 + ] + }, + { "t": 21 } + ], + "ix": 2 + }, + "a": { "a": 0, "k": [ + 0, + 0 + ], "ix": 1 }, + "s": { "a": 0, "k": [ + 97.419, + 116 + ], "ix": 3 }, + "r": { + "a": 1, + "k": [ + { + "i": { "x": [0.833], "y": [0.833] }, + "o": { "x": [0.167], "y": [0.167] }, + "n": ["0p833_0p833_0p167_0p167"], + "t": 6, + "s": [14.733], + "e": [8.733] + }, + { + "t": 21 + } + ], + "ix": 6 + }, + "o": { "a": 0, "k": 100, "ix": 7 }, + "sk": { "a": 0, "k": 0, "ix": 4 }, + "sa": { "a": 0, "k": 0, "ix": 5 }, + "nm": "Transform" + } + ], + "nm": "Ellipse 1", + "np": 3, + "cix": 2, + "ix": 2, + "mn": "ADBE Vector Group" + } + ], + "ip": 6, + "op": 22, + "st": -21, + "bm": 0, + "sr": 1 + }, + { + "ddd": 0, + "ind": 4, + "ty": 4, + "nm": "Shape Layer 11", + "ks": { "o": { "a": 0, "k": 100 }, "r": { "a": 0, "k": 200 }, "p": { "a": 0, "k": [ + 160, + 159.5, + 0 + ] }, "a": { "a": 0, "k": [ + 0, + -34, + 0 + ] }, "s": { "a": 0, "k": [ + 100, + 100, + 100 + ] } }, + "ao": 0, + "shapes": [ + { + "ty": "gr", + "it": [ + { "d": 1, "ty": "el", "s": { "a": 1, "k": [ + { "i": { "x": [ + 0.667, + 0.667 + ], "y": [ + 1, + 1 + ] }, "o": { "x": [ + 0.167, + 0.167 + ], "y": [ + 0.167, + 0.167 + ] }, "n": [ + "0p667_1_0p167_0p167", + "0p667_1_0p167_0p167" + ], "t": 6, "s": [ + 15.021, + 15.021 + ], "e": [ + 0, + 0 + ] }, + { "t": 17 }] }, "p": { "a": 0, "k": [ + 0, + 0 + ] }, "nm": "Ellipse Path 1", "mn": "ADBE Vector Shape - Ellipse" }, + { + "ty": "fl", + "c": { + "a": 1, + "k": [ + { + "i": { "x": [0.667], "y": [1] }, + "o": { "x": [0.333], "y": [0] }, + "n": ["0p667_1_0p333_0"], + "t": 6, + "s": [ + 0.8274509, + 0.1843137, + 0.1843137, + 1 + ], + "e": [ + 0.8274509, + 0.1843137, + 0.1843137, + 1 + ] + }, + { + "t": 17 + } + ] + }, + "o": { "a": 0, "k": 100 }, + "r": 1, + "nm": "Fill 1", + "mn": "ADBE Vector Graphic - Fill" + }, + { + "ty": "tr", + "p": { + "a": 1, + "k": [ + { + "i": { "x": 0.667, "y": 1 }, + "o": { "x": 0.167, "y": 0.167 }, + "n": "0p667_1_0p167_0p167", + "t": 6, + "s": [ + -8.142, + -92.147 + ], + "e": [ + -7.675, + -162.544 + ], + "to": [ + 0.07779947668314, + -11.7327470779419 + ], + "ti": [ + -0.07779947668314, + 11.7327470779419 + ] + }, + { "t": 17 } + ], + "ix": 2 + }, + "a": { "a": 0, "k": [ + 0, + 0 + ], "ix": 1 }, + "s": { "a": 0, "k": [ + 83.981, + 100 + ], "ix": 3 }, + "r": { + "a": 1, + "k": [ + { + "i": { "x": [0.667], "y": [1] }, + "o": { "x": [0.167], "y": [0.167] }, + "n": ["0p667_1_0p167_0p167"], + "t": 6, + "s": [20.367], + "e": [6.367] + }, + { + "t": 17 + } + ], + "ix": 6 + }, + "o": { "a": 0, "k": 100, "ix": 7 }, + "sk": { "a": 0, "k": 0, "ix": 4 }, + "sa": { "a": 0, "k": 0, "ix": 5 }, + "nm": "Transform" + } + ], + "nm": "Ellipse 2", + "np": 3, + "cix": 2, + "ix": 1, + "mn": "ADBE Vector Group" + }, + { + "ty": "gr", + "it": [ + { "d": 1, "ty": "el", "s": { "a": 1, "k": [ + { "i": { "x": [ + 0.833, + 0.833 + ], "y": [ + 0.833, + 0.833 + ] }, "o": { "x": [ + 0.167, + 0.167 + ], "y": [ + 0.167, + 0.167 + ] }, "n": [ + "0p833_0p833_0p167_0p167", + "0p833_0p833_0p167_0p167" + ], "t": 6, "s": [ + 15.021, + 15.021 + ], "e": [ + 0, + 0 + ] }, + { "t": 21 }] }, "p": { "a": 0, "k": [ + 0, + 0 + ] }, "nm": "Ellipse Path 1", "mn": "ADBE Vector Shape - Ellipse" }, + { + "ty": "fl", + "c": { + "a": 1, + "k": [ + { + "i": { "x": [0.667], "y": [1] }, + "o": { "x": [0.333], "y": [0] }, + "n": ["0p667_1_0p333_0"], + "t": 6, + "s": [ + 0.8274509, + 0.1843137, + 0.1843137, + 1 + ], + "e": [ + 0.8274509, + 0.1843137, + 0.1843137, + 1 + ] + }, + { + "t": 17 + } + ] + }, + "o": { "a": 0, "k": 100 }, + "r": 1, + "nm": "Fill 1", + "mn": "ADBE Vector Graphic - Fill" + }, + { + "ty": "tr", + "p": { + "a": 1, + "k": [ + { + "i": { "x": 0.833, "y": 0.833 }, + "o": { "x": 0.167, "y": 0.167 }, + "n": "0p833_0p833_0p167_0p167", + "t": 6, + "s": [ + 16.585, + -99.759 + ], + "e": [ + 28.521, + -187.495 + ], + "to": [ + 1.9892578125, + -14.6227216720581 + ], + "ti": [ + -1.9892578125, + 14.6227216720581 + ] + }, + { "t": 21 } + ], + "ix": 2 + }, + "a": { "a": 0, "k": [ + 0, + 0 + ], "ix": 1 }, + "s": { "a": 0, "k": [ + 97.419, + 116 + ], "ix": 3 }, + "r": { + "a": 1, + "k": [ + { + "i": { "x": [0.833], "y": [0.833] }, + "o": { "x": [0.167], "y": [0.167] }, + "n": ["0p833_0p833_0p167_0p167"], + "t": 6, + "s": [14.733], + "e": [8.733] + }, + { + "t": 21 + } + ], + "ix": 6 + }, + "o": { "a": 0, "k": 100, "ix": 7 }, + "sk": { "a": 0, "k": 0, "ix": 4 }, + "sa": { "a": 0, "k": 0, "ix": 5 }, + "nm": "Transform" + } + ], + "nm": "Ellipse 1", + "np": 3, + "cix": 2, + "ix": 2, + "mn": "ADBE Vector Group" + } + ], + "ip": 6, + "op": 22, + "st": -21, + "bm": 0, + "sr": 1 + }, + { + "ddd": 0, + "ind": 5, + "ty": 4, + "nm": "Shape Layer 10", + "ks": { "o": { "a": 0, "k": 100 }, "r": { "a": 0, "k": 150 }, "p": { "a": 0, "k": [ + 160, + 159.5, + 0 + ] }, "a": { "a": 0, "k": [ + 0, + -34, + 0 + ] }, "s": { "a": 0, "k": [ + 100, + 100, + 100 + ] } }, + "ao": 0, + "shapes": [ + { + "ty": "gr", + "it": [ + { "d": 1, "ty": "el", "s": { "a": 1, "k": [ + { "i": { "x": [ + 0.667, + 0.667 + ], "y": [ + 1, + 1 + ] }, "o": { "x": [ + 0.167, + 0.167 + ], "y": [ + 0.167, + 0.167 + ] }, "n": [ + "0p667_1_0p167_0p167", + "0p667_1_0p167_0p167" + ], "t": 6, "s": [ + 15.021, + 15.021 + ], "e": [ + 0, + 0 + ] }, + { "t": 17 }] }, "p": { "a": 0, "k": [ + 0, + 0 + ] }, "nm": "Ellipse Path 1", "mn": "ADBE Vector Shape - Ellipse" }, + { + "ty": "fl", + "c": { + "a": 1, + "k": [ + { + "i": { "x": [0.667], "y": [1] }, + "o": { "x": [0.333], "y": [0] }, + "n": ["0p667_1_0p333_0"], + "t": 6, + "s": [ + 0.8274509, + 0.1843137, + 0.1843137, + 1 + ], + "e": [ + 0.8274509, + 0.1843137, + 0.1843137, + 1 + ] + }, + { + "t": 17 + } + ] + }, + "o": { "a": 0, "k": 100 }, + "r": 1, + "nm": "Fill 1", + "mn": "ADBE Vector Graphic - Fill" + }, + { + "ty": "tr", + "p": { + "a": 1, + "k": [ + { + "i": { "x": 0.667, "y": 1 }, + "o": { "x": 0.167, "y": 0.167 }, + "n": "0p667_1_0p167_0p167", + "t": 6, + "s": [ + -8.142, + -92.147 + ], + "e": [ + -7.675, + -162.544 + ], + "to": [ + 0.07779947668314, + -11.7327470779419 + ], + "ti": [ + -0.07779947668314, + 11.7327470779419 + ] + }, + { "t": 17 } + ], + "ix": 2 + }, + "a": { "a": 0, "k": [ + 0, + 0 + ], "ix": 1 }, + "s": { "a": 0, "k": [ + 83.981, + 100 + ], "ix": 3 }, + "r": { + "a": 1, + "k": [ + { + "i": { "x": [0.667], "y": [1] }, + "o": { "x": [0.167], "y": [0.167] }, + "n": ["0p667_1_0p167_0p167"], + "t": 6, + "s": [20.367], + "e": [6.367] + }, + { + "t": 17 + } + ], + "ix": 6 + }, + "o": { "a": 0, "k": 100, "ix": 7 }, + "sk": { "a": 0, "k": 0, "ix": 4 }, + "sa": { "a": 0, "k": 0, "ix": 5 }, + "nm": "Transform" + } + ], + "nm": "Ellipse 2", + "np": 3, + "cix": 2, + "ix": 1, + "mn": "ADBE Vector Group" + }, + { + "ty": "gr", + "it": [ + { "d": 1, "ty": "el", "s": { "a": 1, "k": [ + { "i": { "x": [ + 0.833, + 0.833 + ], "y": [ + 0.833, + 0.833 + ] }, "o": { "x": [ + 0.167, + 0.167 + ], "y": [ + 0.167, + 0.167 + ] }, "n": [ + "0p833_0p833_0p167_0p167", + "0p833_0p833_0p167_0p167" + ], "t": 6, "s": [ + 15.021, + 15.021 + ], "e": [ + 0, + 0 + ] }, + { "t": 21 }] }, "p": { "a": 0, "k": [ + 0, + 0 + ] }, "nm": "Ellipse Path 1", "mn": "ADBE Vector Shape - Ellipse" }, + { + "ty": "fl", + "c": { + "a": 1, + "k": [ + { + "i": { "x": [0.667], "y": [1] }, + "o": { "x": [0.333], "y": [0] }, + "n": ["0p667_1_0p333_0"], + "t": 6, + "s": [ + 0.8274509, + 0.1843137, + 0.1843137, + 1 + ], + "e": [ + 0.8274509, + 0.1843137, + 0.1843137, + 1 + ] + }, + { + "t": 17 + } + ] + }, + "o": { "a": 0, "k": 100 }, + "r": 1, + "nm": "Fill 1", + "mn": "ADBE Vector Graphic - Fill" + }, + { + "ty": "tr", + "p": { + "a": 1, + "k": [ + { + "i": { "x": 0.833, "y": 0.833 }, + "o": { "x": 0.167, "y": 0.167 }, + "n": "0p833_0p833_0p167_0p167", + "t": 6, + "s": [ + 16.585, + -99.759 + ], + "e": [ + 28.521, + -187.495 + ], + "to": [ + 1.9892578125, + -14.6227216720581 + ], + "ti": [ + -1.9892578125, + 14.6227216720581 + ] + }, + { "t": 21 } + ], + "ix": 2 + }, + "a": { "a": 0, "k": [ + 0, + 0 + ], "ix": 1 }, + "s": { "a": 0, "k": [ + 97.419, + 116 + ], "ix": 3 }, + "r": { + "a": 1, + "k": [ + { + "i": { "x": [0.833], "y": [0.833] }, + "o": { "x": [0.167], "y": [0.167] }, + "n": ["0p833_0p833_0p167_0p167"], + "t": 6, + "s": [14.733], + "e": [8.733] + }, + { + "t": 21 + } + ], + "ix": 6 + }, + "o": { "a": 0, "k": 100, "ix": 7 }, + "sk": { "a": 0, "k": 0, "ix": 4 }, + "sa": { "a": 0, "k": 0, "ix": 5 }, + "nm": "Transform" + } + ], + "nm": "Ellipse 1", + "np": 3, + "cix": 2, + "ix": 2, + "mn": "ADBE Vector Group" + } + ], + "ip": 6, + "op": 22, + "st": -21, + "bm": 0, + "sr": 1 + }, + { + "ddd": 0, + "ind": 6, + "ty": 4, + "nm": "Shape Layer 9", + "ks": { "o": { "a": 0, "k": 100 }, "r": { "a": 0, "k": 100 }, "p": { "a": 0, "k": [ + 160, + 159.5, + 0 + ] }, "a": { "a": 0, "k": [ + 0, + -34, + 0 + ] }, "s": { "a": 0, "k": [ + 100, + 100, + 100 + ] } }, + "ao": 0, + "shapes": [ + { + "ty": "gr", + "it": [ + { "d": 1, "ty": "el", "s": { "a": 1, "k": [ + { "i": { "x": [ + 0.667, + 0.667 + ], "y": [ + 1, + 1 + ] }, "o": { "x": [ + 0.167, + 0.167 + ], "y": [ + 0.167, + 0.167 + ] }, "n": [ + "0p667_1_0p167_0p167", + "0p667_1_0p167_0p167" + ], "t": 6, "s": [ + 15.021, + 15.021 + ], "e": [ + 0, + 0 + ] }, + { "t": 17 }] }, "p": { "a": 0, "k": [ + 0, + 0 + ] }, "nm": "Ellipse Path 1", "mn": "ADBE Vector Shape - Ellipse" }, + { + "ty": "fl", + "c": { + "a": 1, + "k": [ + { + "i": { "x": [0.667], "y": [1] }, + "o": { "x": [0.333], "y": [0] }, + "n": ["0p667_1_0p333_0"], + "t": 6, + "s": [ + 0.8274509, + 0.1843137, + 0.1843137, + 1 + ], + "e": [ + 0.8274509, + 0.1843137, + 0.1843137, + 1 + ] + }, + { + "t": 17 + } + ] + }, + "o": { "a": 0, "k": 100 }, + "r": 1, + "nm": "Fill 1", + "mn": "ADBE Vector Graphic - Fill" + }, + { + "ty": "tr", + "p": { + "a": 1, + "k": [ + { + "i": { "x": 0.667, "y": 1 }, + "o": { "x": 0.167, "y": 0.167 }, + "n": "0p667_1_0p167_0p167", + "t": 6, + "s": [ + -8.142, + -92.147 + ], + "e": [ + -7.675, + -162.544 + ], + "to": [ + 0.07779947668314, + -11.7327470779419 + ], + "ti": [ + -0.07779947668314, + 11.7327470779419 + ] + }, + { "t": 17 } + ], + "ix": 2 + }, + "a": { "a": 0, "k": [ + 0, + 0 + ], "ix": 1 }, + "s": { "a": 0, "k": [ + 83.981, + 100 + ], "ix": 3 }, + "r": { + "a": 1, + "k": [ + { + "i": { "x": [0.667], "y": [1] }, + "o": { "x": [0.167], "y": [0.167] }, + "n": ["0p667_1_0p167_0p167"], + "t": 6, + "s": [20.367], + "e": [6.367] + }, + { + "t": 17 + } + ], + "ix": 6 + }, + "o": { "a": 0, "k": 100, "ix": 7 }, + "sk": { "a": 0, "k": 0, "ix": 4 }, + "sa": { "a": 0, "k": 0, "ix": 5 }, + "nm": "Transform" + } + ], + "nm": "Ellipse 2", + "np": 3, + "cix": 2, + "ix": 1, + "mn": "ADBE Vector Group" + }, + { + "ty": "gr", + "it": [ + { "d": 1, "ty": "el", "s": { "a": 1, "k": [ + { "i": { "x": [ + 0.833, + 0.833 + ], "y": [ + 0.833, + 0.833 + ] }, "o": { "x": [ + 0.167, + 0.167 + ], "y": [ + 0.167, + 0.167 + ] }, "n": [ + "0p833_0p833_0p167_0p167", + "0p833_0p833_0p167_0p167" + ], "t": 6, "s": [ + 15.021, + 15.021 + ], "e": [ + 0, + 0 + ] }, + { "t": 21 }] }, "p": { "a": 0, "k": [ + 0, + 0 + ] }, "nm": "Ellipse Path 1", "mn": "ADBE Vector Shape - Ellipse" }, + { + "ty": "fl", + "c": { + "a": 1, + "k": [ + { + "i": { "x": [0.667], "y": [1] }, + "o": { "x": [0.333], "y": [0] }, + "n": ["0p667_1_0p333_0"], + "t": 6, + "s": [ + 0.8274509, + 0.1843137, + 0.1843137, + 1 + ], + "e": [ + 0.8274509, + 0.1843137, + 0.1843137, + 1 + ] + }, + { + "t": 17 + } + ] + }, + "o": { "a": 0, "k": 100 }, + "r": 1, + "nm": "Fill 1", + "mn": "ADBE Vector Graphic - Fill" + }, + { + "ty": "tr", + "p": { + "a": 1, + "k": [ + { + "i": { "x": 0.833, "y": 0.833 }, + "o": { "x": 0.167, "y": 0.167 }, + "n": "0p833_0p833_0p167_0p167", + "t": 6, + "s": [ + 16.585, + -99.759 + ], + "e": [ + 28.521, + -187.495 + ], + "to": [ + 1.9892578125, + -14.6227216720581 + ], + "ti": [ + -1.9892578125, + 14.6227216720581 + ] + }, + { "t": 21 } + ], + "ix": 2 + }, + "a": { "a": 0, "k": [ + 0, + 0 + ], "ix": 1 }, + "s": { "a": 0, "k": [ + 97.419, + 116 + ], "ix": 3 }, + "r": { + "a": 1, + "k": [ + { + "i": { "x": [0.833], "y": [0.833] }, + "o": { "x": [0.167], "y": [0.167] }, + "n": ["0p833_0p833_0p167_0p167"], + "t": 6, + "s": [14.733], + "e": [8.733] + }, + { + "t": 21 + } + ], + "ix": 6 + }, + "o": { "a": 0, "k": 100, "ix": 7 }, + "sk": { "a": 0, "k": 0, "ix": 4 }, + "sa": { "a": 0, "k": 0, "ix": 5 }, + "nm": "Transform" + } + ], + "nm": "Ellipse 1", + "np": 3, + "cix": 2, + "ix": 2, + "mn": "ADBE Vector Group" + } + ], + "ip": 6, + "op": 22, + "st": -21, + "bm": 0, + "sr": 1 + }, + { + "ddd": 0, + "ind": 7, + "ty": 4, + "nm": "Shape Layer 8", + "ks": { "o": { "a": 0, "k": 100 }, "r": { "a": 0, "k": 50 }, "p": { "a": 0, "k": [ + 160, + 159.5, + 0 + ] }, "a": { "a": 0, "k": [ + 0, + -34, + 0 + ] }, "s": { "a": 0, "k": [ + 100, + 100, + 100 + ] } }, + "ao": 0, + "shapes": [ + { + "ty": "gr", + "it": [ + { "d": 1, "ty": "el", "s": { "a": 1, "k": [ + { "i": { "x": [ + 0.667, + 0.667 + ], "y": [ + 1, + 1 + ] }, "o": { "x": [ + 0.167, + 0.167 + ], "y": [ + 0.167, + 0.167 + ] }, "n": [ + "0p667_1_0p167_0p167", + "0p667_1_0p167_0p167" + ], "t": 6, "s": [ + 15.021, + 15.021 + ], "e": [ + 0, + 0 + ] }, + { "t": 17 }] }, "p": { "a": 0, "k": [ + 0, + 0 + ] }, "nm": "Ellipse Path 1", "mn": "ADBE Vector Shape - Ellipse" }, + { + "ty": "fl", + "c": { + "a": 1, + "k": [ + { + "i": { "x": [0.667], "y": [1] }, + "o": { "x": [0.333], "y": [0] }, + "n": ["0p667_1_0p333_0"], + "t": 6, + "s": [ + 0.8274509, + 0.1843137, + 0.1843137, + 1 + ], + "e": [ + 0.8274509, + 0.1843137, + 0.1843137, + 1 + ] + }, + { + "t": 17 + } + ] + }, + "o": { "a": 0, "k": 100 }, + "r": 1, + "nm": "Fill 1", + "mn": "ADBE Vector Graphic - Fill" + }, + { + "ty": "tr", + "p": { + "a": 1, + "k": [ + { + "i": { "x": 0.667, "y": 1 }, + "o": { "x": 0.167, "y": 0.167 }, + "n": "0p667_1_0p167_0p167", + "t": 6, + "s": [ + -8.142, + -92.147 + ], + "e": [ + -7.675, + -162.544 + ], + "to": [ + 0.07779947668314, + -11.7327470779419 + ], + "ti": [ + -0.07779947668314, + 11.7327470779419 + ] + }, + { "t": 17 } + ], + "ix": 2 + }, + "a": { "a": 0, "k": [ + 0, + 0 + ], "ix": 1 }, + "s": { "a": 0, "k": [ + 83.981, + 100 + ], "ix": 3 }, + "r": { + "a": 1, + "k": [ + { + "i": { "x": [0.667], "y": [1] }, + "o": { "x": [0.167], "y": [0.167] }, + "n": ["0p667_1_0p167_0p167"], + "t": 6, + "s": [20.367], + "e": [6.367] + }, + { + "t": 17 + } + ], + "ix": 6 + }, + "o": { "a": 0, "k": 100, "ix": 7 }, + "sk": { "a": 0, "k": 0, "ix": 4 }, + "sa": { "a": 0, "k": 0, "ix": 5 }, + "nm": "Transform" + } + ], + "nm": "Ellipse 2", + "np": 3, + "cix": 2, + "ix": 1, + "mn": "ADBE Vector Group" + }, + { + "ty": "gr", + "it": [ + { "d": 1, "ty": "el", "s": { "a": 1, "k": [ + { "i": { "x": [ + 0.833, + 0.833 + ], "y": [ + 0.833, + 0.833 + ] }, "o": { "x": [ + 0.167, + 0.167 + ], "y": [ + 0.167, + 0.167 + ] }, "n": [ + "0p833_0p833_0p167_0p167", + "0p833_0p833_0p167_0p167" + ], "t": 6, "s": [ + 15.021, + 15.021 + ], "e": [ + 0, + 0 + ] }, + { "t": 21 }] }, "p": { "a": 0, "k": [ + 0, + 0 + ] }, "nm": "Ellipse Path 1", "mn": "ADBE Vector Shape - Ellipse" }, + { + "ty": "fl", + "c": { + "a": 1, + "k": [ + { + "i": { "x": [0.667], "y": [1] }, + "o": { "x": [0.333], "y": [0] }, + "n": ["0p667_1_0p333_0"], + "t": 6, + "s": [ + 0.8274509, + 0.1843137, + 0.1843137, + 1 + ], + "e": [ + 0.8274509, + 0.1843137, + 0.1843137, + 1 + ] + }, + { + "t": 17 + } + ] + }, + "o": { "a": 0, "k": 100 }, + "r": 1, + "nm": "Fill 1", + "mn": "ADBE Vector Graphic - Fill" + }, + { + "ty": "tr", + "p": { + "a": 1, + "k": [ + { + "i": { "x": 0.833, "y": 0.833 }, + "o": { "x": 0.167, "y": 0.167 }, + "n": "0p833_0p833_0p167_0p167", + "t": 6, + "s": [ + 16.585, + -99.759 + ], + "e": [ + 28.521, + -187.495 + ], + "to": [ + 1.9892578125, + -14.6227216720581 + ], + "ti": [ + -1.9892578125, + 14.6227216720581 + ] + }, + { "t": 21 } + ], + "ix": 2 + }, + "a": { "a": 0, "k": [ + 0, + 0 + ], "ix": 1 }, + "s": { "a": 0, "k": [ + 97.419, + 116 + ], "ix": 3 }, + "r": { + "a": 1, + "k": [ + { + "i": { "x": [0.833], "y": [0.833] }, + "o": { "x": [0.167], "y": [0.167] }, + "n": ["0p833_0p833_0p167_0p167"], + "t": 6, + "s": [14.733], + "e": [8.733] + }, + { + "t": 21 + } + ], + "ix": 6 + }, + "o": { "a": 0, "k": 100, "ix": 7 }, + "sk": { "a": 0, "k": 0, "ix": 4 }, + "sa": { "a": 0, "k": 0, "ix": 5 }, + "nm": "Transform" + } + ], + "nm": "Ellipse 1", + "np": 3, + "cix": 2, + "ix": 2, + "mn": "ADBE Vector Group" + } + ], + "ip": 6, + "op": 22, + "st": -21, + "bm": 0, + "sr": 1 + }, + { + "ddd": 0, + "ind": 8, + "ty": 4, + "nm": "Shape Layer 7", + "ks": { "o": { "a": 0, "k": 100 }, "r": { "a": 0, "k": 0 }, "p": { "a": 0, "k": [ + 160, + 159.5, + 0 + ] }, "a": { "a": 0, "k": [ + 0, + -34, + 0 + ] }, "s": { "a": 0, "k": [ + 100, + 100, + 100 + ] } }, + "ao": 0, + "shapes": [ + { + "ty": "gr", + "it": [ + { "d": 1, "ty": "el", "s": { "a": 1, "k": [ + { "i": { "x": [ + 0.667, + 0.667 + ], "y": [ + 1, + 1 + ] }, "o": { "x": [ + 0.167, + 0.167 + ], "y": [ + 0.167, + 0.167 + ] }, "n": [ + "0p667_1_0p167_0p167", + "0p667_1_0p167_0p167" + ], "t": 6, "s": [ + 15.021, + 15.021 + ], "e": [ + 0, + 0 + ] }, + { "t": 17 }] }, "p": { "a": 0, "k": [ + 0, + 0 + ] }, "nm": "Ellipse Path 1", "mn": "ADBE Vector Shape - Ellipse" }, + { + "ty": "fl", + "c": { + "a": 1, + "k": [ + { + "i": { "x": [0.667], "y": [1] }, + "o": { "x": [0.333], "y": [0] }, + "n": ["0p667_1_0p333_0"], + "t": 6, + "s": [ + 0.8274509, + 0.1843137, + 0.1843137, + 1 + ], + "e": [ + 0.8274509, + 0.1843137, + 0.1843137, + 1 + ] + }, + { + "t": 17 + } + ] + }, + "o": { "a": 0, "k": 100 }, + "r": 1, + "nm": "Fill 1", + "mn": "ADBE Vector Graphic - Fill" + }, + { + "ty": "tr", + "p": { + "a": 1, + "k": [ + { + "i": { "x": 0.667, "y": 1 }, + "o": { "x": 0.167, "y": 0.167 }, + "n": "0p667_1_0p167_0p167", + "t": 6, + "s": [ + -8.142, + -92.147 + ], + "e": [ + -7.675, + -162.544 + ], + "to": [ + 0.07779947668314, + -11.7327470779419 + ], + "ti": [ + -0.07779947668314, + 11.7327470779419 + ] + }, + { "t": 17 } + ], + "ix": 2 + }, + "a": { "a": 0, "k": [ + 0, + 0 + ], "ix": 1 }, + "s": { "a": 0, "k": [ + 83.981, + 100 + ], "ix": 3 }, + "r": { + "a": 1, + "k": [ + { + "i": { "x": [0.667], "y": [1] }, + "o": { "x": [0.167], "y": [0.167] }, + "n": ["0p667_1_0p167_0p167"], + "t": 6, + "s": [20.367], + "e": [6.367] + }, + { + "t": 17 + } + ], + "ix": 6 + }, + "o": { "a": 0, "k": 100, "ix": 7 }, + "sk": { "a": 0, "k": 0, "ix": 4 }, + "sa": { "a": 0, "k": 0, "ix": 5 }, + "nm": "Transform" + } + ], + "nm": "Ellipse 2", + "np": 3, + "cix": 2, + "ix": 1, + "mn": "ADBE Vector Group" + }, + { + "ty": "gr", + "it": [ + { "d": 1, "ty": "el", "s": { "a": 1, "k": [ + { "i": { "x": [ + 0.833, + 0.833 + ], "y": [ + 0.833, + 0.833 + ] }, "o": { "x": [ + 0.167, + 0.167 + ], "y": [ + 0.167, + 0.167 + ] }, "n": [ + "0p833_0p833_0p167_0p167", + "0p833_0p833_0p167_0p167" + ], "t": 6, "s": [ + 15.021, + 15.021 + ], "e": [ + 0, + 0 + ] }, + { "t": 21 }] }, "p": { "a": 0, "k": [ + 0, + 0 + ] }, "nm": "Ellipse Path 1", "mn": "ADBE Vector Shape - Ellipse" }, + { + "ty": "fl", + "c": { + "a": 1, + "k": [ + { + "i": { "x": [0.667], "y": [1] }, + "o": { "x": [0.333], "y": [0] }, + "n": ["0p667_1_0p333_0"], + "t": 6, + "s": [ + 0.8274509, + 0.1843137, + 0.1843137, + 1 + ], + "e": [ + 0.8274509, + 0.1843137, + 0.1843137, + 1 + ] + }, + { + "t": 17 + } + ] + }, + "o": { "a": 0, "k": 100 }, + "r": 1, + "nm": "Fill 1", + "mn": "ADBE Vector Graphic - Fill" + }, + { + "ty": "tr", + "p": { + "a": 1, + "k": [ + { + "i": { "x": 0.833, "y": 0.833 }, + "o": { "x": 0.167, "y": 0.167 }, + "n": "0p833_0p833_0p167_0p167", + "t": 6, + "s": [ + 16.585, + -99.759 + ], + "e": [ + 28.521, + -187.495 + ], + "to": [ + 1.9892578125, + -14.6227216720581 + ], + "ti": [ + -1.9892578125, + 14.6227216720581 + ] + }, + { "t": 21 } + ], + "ix": 2 + }, + "a": { "a": 0, "k": [ + 0, + 0 + ], "ix": 1 }, + "s": { "a": 0, "k": [ + 97.419, + 116 + ], "ix": 3 }, + "r": { + "a": 1, + "k": [ + { + "i": { "x": [0.833], "y": [0.833] }, + "o": { "x": [0.167], "y": [0.167] }, + "n": ["0p833_0p833_0p167_0p167"], + "t": 6, + "s": [14.733], + "e": [8.733] + }, + { + "t": 21 + } + ], + "ix": 6 + }, + "o": { "a": 0, "k": 100, "ix": 7 }, + "sk": { "a": 0, "k": 0, "ix": 4 }, + "sa": { "a": 0, "k": 0, "ix": 5 }, + "nm": "Transform" + } + ], + "nm": "Ellipse 1", + "np": 3, + "cix": 2, + "ix": 2, + "mn": "ADBE Vector Group" + } + ], + "ip": 6, + "op": 22, + "st": -21, + "bm": 0, + "sr": 1 + }, + { + "ddd": 0, + "ind": 9, + "ty": 4, + "nm": "Shape Layer 5", + "parent": 11, + "ks": { "o": { "a": 0, "k": 100 }, "r": { "a": 0, "k": 0 }, "p": { "a": 0, "k": [ + 0.8, + -0.641, + 0 + ] }, "a": { "a": 0, "k": [ + 0, + 0, + 0 + ] }, "s": { "a": 0, "k": [ + 7.39, + 7.39, + 100 + ] } }, + "ao": 0, + "shapes": [ + { + "ty": "gr", + "it": [ + { + "ind": 0, + "ty": "sh", + "ix": 1, + "ks": { + "a": 0, + "k": { + "i": [ + [ + 0, + 0 + ], + [ + 0, + 0 + ] + ], + "o": [ + [ + 0, + 0 + ], + [ + 0, + 0 + ] + ], + "v": [ + [ + -6.977, + 7 + ], + [ + 6.973, + -7 + ] + ], + "c": false + }, + "ix": 2 + }, + "nm": "Path 1", + "mn": "ADBE Vector Shape - Group", + "hd": false + }, + { + "ty": "gr", + "it": [ + { + "ind": 0, + "ty": "sh", + "ix": 1, + "ks": { + "a": 0, + "k": { + "i": [ + [ + 0, + 0 + ], + [ + 0, + 0 + ] + ], + "o": [ + [ + 0, + 0 + ], + [ + 0, + 0 + ] + ], + "v": [ + [ + -6.977, + 7 + ], + [ + 6.973, + -7 + ] + ], + "c": false + }, + "ix": 2 + }, + "nm": "Path 1", + "mn": "ADBE Vector Shape - Group", + "hd": false + }, + { "ty": "tr", "p": { "a": 0, "k": [ + 0, + 0 + ], "ix": 2 }, "a": { "a": 0, "k": [ + 0, + 0 + ], "ix": 1 }, "s": { "a": 0, "k": [ + 100, + 100 + ], "ix": 3 }, "r": { "a": 0, "k": -90, "ix": 6 }, "o": { "a": 0, "k": 100, "ix": 7 }, "sk": { "a": 0, "k": 0, "ix": 4 }, "sa": { "a": 0, "k": 0, "ix": 5 }, "nm": "Transform" } + ], + "nm": "Group 1", + "np": 1, + "cix": 2, + "bm": 0, + "ix": 2, + "mn": "ADBE Vector Group", + "hd": false + }, + { "ty": "mm", "mm": 2, "nm": "Merge Paths 1", "mn": "ADBE Vector Filter - Merge", "hd": false }, + { "ty": "st", "c": { "a": 0, "k": [ + 1, + 1, + 1, + 1 + ], "ix": 3 }, "o": { "a": 0, "k": 100, "ix": 4 }, "w": { "a": 0, "k": 4, "ix": 5 }, "lc": 2, "lj": 2, "bm": 0, "nm": "Stroke 1", "mn": "ADBE Vector Graphic - Stroke", "hd": false }, + { "ty": "tr", "p": { "a": 0, "k": [ + 0, + 0 + ], "ix": 2 }, "a": { "a": 0, "k": [ + 0, + 0 + ], "ix": 1 }, "s": { "a": 0, "k": [ + 200, + 200 + ], "ix": 3 }, "r": { "a": 0, "k": 0, "ix": 6 }, "o": { "a": 0, "k": 100, "ix": 7 }, "sk": { "a": 0, "k": 0, "ix": 4 }, "sa": { "a": 0, "k": 0, "ix": 5 }, "nm": "Transform" } + ], + "nm": "Combined Shape", + "np": 4, + "cix": 2, + "bm": 0, + "ix": 1, + "mn": "ADBE Vector Group", + "hd": false + } + ], + "ip": 7, + "op": 22, + "st": -21, + "bm": 0, + "sr": 1 + }, + { + "ddd": 0, + "ind": 10, + "ty": 4, + "nm": "Shape Layer 6", + "ks": { + "o": { + "a": 1, + "k": [ + { + "i": { "x": [0.667], "y": [1] }, + "o": { "x": [0.333], "y": [0] }, + "n": ["0p667_1_0p333_0"], + "t": 4, + "s": [50], + "e": [0] + }, + { "t": 14 } + ] + }, + "r": { "a": 0, "k": 0 }, + "p": { "a": 0, "k": [ + 160, + 160, + 0 + ] }, + "a": { "a": 0, "k": [ + 0, + 0, + 0 + ] }, + "s": { "a": 1, "k": [ + { "i": { "x": [ + 0.667, + 0.667, + 0.667 + ], "y": [ + 1, + 1, + 0.667 + ] }, "o": { "x": [ + 0.333, + 0.333, + 0.333 + ], "y": [ + 0, + 0, + 0.333 + ] }, "n": [ + "0p667_1_0p333_0", + "0p667_1_0p333_0", + "0p667_0p667_0p333_0p333" + ], "t": 4, "s": [ + 100, + 100, + 100 + ], "e": [ + 1085, + 1085, + 100 + ] }, + { "t": 14 }] } + }, + "ao": 0, + "shapes": [ + { + "ty": "gr", + "it": [ + { "d": 1, "ty": "el", "s": { "a": 0, "k": [ + 19.779, + 19.779 + ] }, "p": { "a": 0, "k": [ + 0, + 0 + ] }, "nm": "Ellipse Path 1", "mn": "ADBE Vector Shape - Ellipse" }, + { "ty": "fl", "c": { "a": 0, "k": [ + 0.8274509, + 0.1843137, + 0.1843137, + 1 + ] }, "o": { "a": 0, "k": 100 }, "r": 1, "nm": "Fill 1", "mn": "ADBE Vector Graphic - Fill" }, + { "ty": "tr", "p": { "a": 0, "k": [ + -0.068, + 0.036 + ], "ix": 2 }, "a": { "a": 0, "k": [ + 0, + 0 + ], "ix": 1 }, "s": { "a": 0, "k": [ + 100, + 100 + ], "ix": 3 }, "r": { "a": 0, "k": 0, "ix": 6 }, "o": { "a": 0, "k": 100, "ix": 7 }, "sk": { "a": 0, "k": 0, "ix": 4 }, "sa": { "a": 0, "k": 0, "ix": 5 }, "nm": "Transform" } + ], + "nm": "Ellipse 1", + "np": 3, + "cix": 2, + "ix": 1, + "mn": "ADBE Vector Group" + } + ], + "ip": 4, + "op": 22, + "st": -23, + "bm": 0, + "sr": 1 + }, + { + "ddd": 0, + "ind": 11, + "ty": 4, + "nm": "Shape Layer 4", + "ks": { + "o": { + "a": 1, + "k": [ + { + "i": { "x": [0.667], "y": [1] }, + "o": { "x": [0.333], "y": [0] }, + "n": ["0p667_1_0p333_0"], + "t": 6, + "s": [30], + "e": [100] + }, + { "t": 9 } + ] + }, + "r": { "a": 0, "k": 0 }, + "p": { "a": 0, "k": [ + 160.312, + 161.188, + 0 + ] }, + "a": { "a": 0, "k": [ + 0.812, + -0.562, + 0 + ] }, + "s": { "a": 1, "k": [ + { "i": { "x": [ + 0.667, + 0.667, + 0.667 + ], "y": [ + 1, + 1, + 0.667 + ] }, "o": { "x": [ + 0.333, + 0.333, + 0.333 + ], "y": [ + 0, + 0, + 0.333 + ] }, "n": [ + "0p667_1_0p333_0", + "0p667_1_0p333_0", + "0p667_0p667_0p333_0p333" + ], "t": 6, "s": [ + 100, + 100, + 100 + ], "e": [ + 1087, + 1087, + 100 + ] }, + { "i": { "x": [ + 0.667, + 0.667, + 0.667 + ], "y": [ + 1, + 1, + 0.667 + ] }, "o": { "x": [ + 0.333, + 0.333, + 0.333 + ], "y": [ + 0, + 0, + 0.333 + ] }, "n": [ + "0p667_1_0p333_0", + "0p667_1_0p333_0", + "0p667_0p667_0p333_0p333" + ], "t": 11, "s": [ + 1087, + 1087, + 100 + ], "e": [ + 866, + 866, + 100 + ] }, + { "i": { "x": [ + 0.833, + 0.833, + 0.833 + ], "y": [ + 0.833, + 0.833, + 0.833 + ] }, "o": { "x": [ + 0.333, + 0.333, + 0.333 + ], "y": [ + 0, + 0, + 0.333 + ] }, "n": [ + "0p833_0p833_0p333_0", + "0p833_0p833_0p333_0", + "0p833_0p833_0p333_0p333" + ], "t": 13, "s": [ + 866, + 866, + 100 + ], "e": [ + 878, + 878, + 100 + ] }, + { "t": 16 }] } + }, + "ao": 0, + "shapes": [ + { + "ty": "gr", + "it": [ + { "d": 1, "ty": "el", "s": { "a": 0, "k": [ + 10.068, + 10.068 + ] }, "p": { "a": 0, "k": [ + 0, + 0 + ] }, "nm": "Ellipse Path 1", "mn": "ADBE Vector Shape - Ellipse" }, + { "ty": "fl", "c": { "a": 0, "k": [ + 0.8274509, + 0.1843137, + 0.1843137, + 1 + ] }, "o": { "a": 0, "k": 100 }, "r": 1, "nm": "Fill 1", "mn": "ADBE Vector Graphic - Fill" }, + { "ty": "tr", "p": { "a": 0, "k": [ + 0.784, + -0.716 + ], "ix": 2 }, "a": { "a": 0, "k": [ + 0, + 0 + ], "ix": 1 }, "s": { "a": 0, "k": [ + 100, + 100 + ], "ix": 3 }, "r": { "a": 0, "k": 0, "ix": 6 }, "o": { "a": 0, "k": 100, "ix": 7 }, "sk": { "a": 0, "k": 0, "ix": 4 }, "sa": { "a": 0, "k": 0, "ix": 5 }, "nm": "Transform" } + ], + "nm": "Ellipse 1", + "np": 3, + "cix": 2, + "ix": 1, + "mn": "ADBE Vector Group" + } + ], + "ip": 6, + "op": 22, + "st": -19, + "bm": 0, + "sr": 1 + }, + { + "ddd": 0, + "ind": 12, + "ty": 4, + "nm": "Shape Layer 3", + "ks": { "o": { "a": 0, "k": 100 }, "r": { "a": 0, "k": 0 }, "p": { "a": 0, "k": [ + 161, + 160, + 0 + ] }, "a": { "a": 0, "k": [ + 0, + 0, + 0 + ] }, "s": { "a": 1, "k": [ + { "i": { "x": [ + 0.667, + 0.667, + 0.667 + ], "y": [ + 1, + 1, + 0.667 + ] }, "o": { "x": [ + 0.333, + 0.333, + 0.333 + ], "y": [ + 0, + 0, + 0.333 + ] }, "n": [ + "0p667_1_0p333_0", + "0p667_1_0p333_0", + "0p667_0p667_0p333_0p333" + ], "t": 3, "s": [ + 100, + 100, + 100 + ], "e": [ + 224, + 224, + 100 + ] }, + { "i": { "x": [ + 0.667, + 0.667, + 0.667 + ], "y": [ + 1, + 1, + 0.667 + ] }, "o": { "x": [ + 0.333, + 0.333, + 0.333 + ], "y": [ + 0, + 0, + 0.333 + ] }, "n": [ + "0p667_1_0p333_0", + "0p667_1_0p333_0", + "0p667_0p667_0p333_0p333" + ], "t": 4, "s": [ + 224, + 224, + 100 + ], "e": [ + 476, + 476, + 100 + ] }, + { "t": 8 }] } }, + "ao": 0, + "shapes": [ + { + "ty": "gr", + "it": [ + { "d": 1, "ty": "el", "s": { "a": 0, "k": [ + 6.009, + 6.009 + ] }, "p": { "a": 0, "k": [ + 0, + 0 + ] }, "nm": "Ellipse Path 1", "mn": "ADBE Vector Shape - Ellipse" }, + { + "ty": "st", + "c": { + "a": 1, + "k": [ + { + "i": { "x": [0.667], "y": [1] }, + "o": { "x": [0.333], "y": [0] }, + "n": ["0p667_1_0p333_0"], + "t": 4, + "s": [ + 0.8274509, + 0.1843137, + 0.1843137, + 1 + ], + "e": [ + 0.8274509, + 0.1843137, + 0.1843137, + 1 + ] + }, + { "t": 8 } + ] + }, + "o": { + "a": 1, + "k": [ + { + "i": { "x": [0.667], "y": [1] }, + "o": { "x": [0.333], "y": [0] }, + "n": ["0p667_1_0p333_0"], + "t": 4, + "s": [0], + "e": [100] + }, + { "t": 5 } + ] + }, + "w": { + "a": 1, + "k": [ + { + "i": { "x": [0.667], "y": [1] }, + "o": { "x": [0.333], "y": [0] }, + "n": ["0p667_1_0p333_0"], + "t": 4, + "s": [3], + "e": [0] + }, + { "t": 8 } + ] + }, + "lc": 1, + "lj": 1, + "ml": 4, + "nm": "Stroke 1", + "mn": "ADBE Vector Graphic - Stroke" + }, + { + "ty": "fl", + "c": { "a": 0, "k": [ + 0.8274509, + 0.1843137, + 0.1843137, + 1 + ] }, + "o": { + "a": 1, + "k": [ + { + "i": { "x": [0.667], "y": [1] }, + "o": { "x": [0.333], "y": [0] }, + "n": ["0p667_1_0p333_0"], + "t": 3, + "s": [100], + "e": [99] + }, + { + "i": { "x": [0.667], "y": [1] }, + "o": { "x": [0.333], "y": [0] }, + "n": ["0p667_1_0p333_0"], + "t": 4, + "s": [99], + "e": [0] + }, + { "t": 5 } + ] + }, + "r": 1, + "nm": "Fill 1", + "mn": "ADBE Vector Graphic - Fill" + }, + { "ty": "tr", "p": { "a": 0, "k": [ + -0.338, + 0.065 + ], "ix": 2 }, "a": { "a": 0, "k": [ + 0, + 0 + ], "ix": 1 }, "s": { "a": 0, "k": [ + 649.112, + 649.112 + ], "ix": 3 }, "r": { "a": 0, "k": 0, "ix": 6 }, "o": { "a": 0, "k": 100, "ix": 7 }, "sk": { "a": 0, "k": 0, "ix": 4 }, "sa": { "a": 0, "k": 0, "ix": 5 }, "nm": "Transform" } + ], + "nm": "Ellipse 2", + "np": 3, + "cix": 2, + "ix": 1, + "mn": "ADBE Vector Group" + } + ], + "ip": 3, + "op": 22, + "st": -21, + "bm": 0, + "sr": 1 + }, + { + "ddd": 0, + "ind": 13, + "ty": 4, + "nm": "Shape Layer 2", + "ks": { "o": { "a": 0, "k": 100 }, "r": { "a": 0, "k": 0 }, "p": { "a": 0, "k": [ + 160.142, + 159.987, + 0 + ] }, "a": { "a": 0, "k": [ + 0, + 0, + 0 + ] }, "s": { "a": 0, "k": [ + 377.603, + 377.603, + 100 + ] } }, + "ao": 0, + "shapes": [ + { + "ty": "gr", + "it": [ + { "d": 1, "ty": "el", "s": { "a": 0, "k": [ + 22.315, + 22.315 + ] }, "p": { "a": 0, "k": [ + 0, + 0 + ] }, "nm": "Ellipse Path 1", "mn": "ADBE Vector Shape - Ellipse" }, + { + "ty": "st", + "c": { "a": 0, "k": [ + 0.8352941, + 0.8352941, + 0.8352941, + 1 + ] }, + "o": { + "a": 0, + "k": 100 + }, + "w": { "a": 0, "k": 1 }, + "lc": 1, + "lj": 1, + "ml": 4, + "nm": "Stroke 1", + "mn": "ADBE Vector Graphic - Stroke" + }, + { "ty": "fl", "c": { "a": 0, "k": [ + 1, + 1, + 1, + 1 + ] }, "o": { "a": 0, "k": 100 }, "r": 1, "nm": "Fill 1", "mn": "ADBE Vector Graphic - Fill" }, + { "ty": "tr", "p": { "a": 0, "k": [ + -0.038, + 0.003 + ], "ix": 2 }, "a": { "a": 0, "k": [ + 0, + 0 + ], "ix": 1 }, "s": { "a": 0, "k": [ + 100, + 100 + ], "ix": 3 }, "r": { "a": 0, "k": 0, "ix": 6 }, "o": { "a": 0, "k": 100, "ix": 7 }, "sk": { "a": 0, "k": 0, "ix": 4 }, "sa": { "a": 0, "k": 0, "ix": 5 }, "nm": "Transform" } + ], + "nm": "Ellipse 1", + "np": 3, + "cix": 2, + "ix": 1, + "mn": "ADBE Vector Group" + } + ], + "ip": -21, + "op": 22, + "st": -21, + "bm": 0, + "sr": 1 + } + ] +} diff --git a/src/assets/animations/loading_animation.json b/src/assets/animations/loading_animation.json new file mode 100644 index 000000000..9255cda72 --- /dev/null +++ b/src/assets/animations/loading_animation.json @@ -0,0 +1,3168 @@ +{ + "nm": "Main Scene", + "ddd": 0, + "h": 500, + "w": 500, + "meta": { "g": "@lottiefiles/creator 1.47.1" }, + "layers": [ + { + "ty": 0, + "nm": "Nested Scene 1", + "sr": 1, + "st": 0, + "op": 92, + "ip": 0, + "hd": false, + "ddd": 0, + "bm": 0, + "hasMask": false, + "ao": 0, + "ks": { "a": { "a": 0, "k": [ + 32.00010198974633, + 32.00128392491024 + ] }, "s": { "a": 0, "k": [ + 214.1158, + 214.1158 + ] }, "sk": { "a": 0, "k": 0 }, "p": { "a": 0, "k": [ + 250, + 250 + ] }, "r": { "a": 0, "k": 0 }, "sa": { "a": 0, "k": 0 }, "o": { "a": 0, "k": 100 } }, + "w": 500, + "h": 500, + "refId": "precomp_Shape Layer - SVG_9a65cf1e-7dbf-4e06-85a4-5dc45595fbad", + "ind": 1 + }, + { + "ty": 4, + "nm": "Shape Layer 5", + "sr": 1, + "st": 0, + "op": 387.000015762833, + "ip": 0, + "hd": false, + "ddd": 0, + "bm": 0, + "hasMask": false, + "ao": 0, + "ks": { "a": { "a": 0, "k": [ + -17.052704811096184, + -15.0467209815979 + ], "ix": 1 }, "s": { "a": 0, "k": [ + 362.228, + 362.228, + 100 + ], "ix": 6 }, "sk": { "a": 0, "k": 0 }, "p": { "a": 0, "k": [ + 188.2303284168625, + 195.49656352277756 + ], "ix": 2 }, "r": { "a": 0, "k": 0, "ix": 10 }, "sa": { "a": 0, "k": 0 }, "o": { "a": 0, "k": 100, "ix": 11 } }, + "shapes": [ + { + "ty": "gr", + "bm": 0, + "hd": false, + "mn": "ADBE Vector Group", + "nm": "Shape 1", + "ix": 1, + "cix": 2, + "np": 3, + "it": [ + { + "ty": "sh", + "bm": 0, + "hd": false, + "mn": "ADBE Vector Shape - Group", + "nm": "Path 1", + "ix": 1, + "d": 1, + "ks": { + "a": 0, + "k": { + "c": true, + "i": [ + [ + -2.003, + 0 + ], + [ + -0.231, + -6.654 + ], + [ + -4.034, + -2.519 + ], + [ + 0, + -4.262 + ], + [ + 3.615, + -2.257 + ], + [ + 0.165, + -4.753 + ], + [ + 6.655, + 0 + ], + [ + 1.814, + 0.965 + ], + [ + 2.33, + 0 + ], + [ + 2.057, + -1.094 + ], + [ + 2.003, + 0 + ], + [ + 0.231, + 6.654 + ], + [ + 4.034, + 2.519 + ], + [ + 0, + 4.262 + ], + [ + -3.615, + 2.257 + ], + [ + -0.165, + 4.753 + ], + [ + -6.655, + 0 + ], + [ + -1.814, + -0.965 + ], + [ + -2.33, + 0 + ], + [ + -2.057, + 1.094 + ] + ], + "o": [ + [ + 6.655, + 0 + ], + [ + 0.165, + 4.753 + ], + [ + 3.615, + 2.257 + ], + [ + 0, + 4.262 + ], + [ + -4.034, + 2.519 + ], + [ + -0.231, + 6.654 + ], + [ + -2.003, + 0 + ], + [ + -2.057, + -1.094 + ], + [ + -2.33, + 0 + ], + [ + -1.814, + 0.965 + ], + [ + -6.655, + 0 + ], + [ + -0.165, + -4.753 + ], + [ + -3.615, + -2.257 + ], + [ + 0, + -4.262 + ], + [ + 4.034, + -2.519 + ], + [ + 0.231, + -6.654 + ], + [ + 2.003, + 0 + ], + [ + 2.057, + 1.094 + ], + [ + 2.33, + 0 + ], + [ + 1.814, + -0.965 + ] + ], + "v": [ + [ + 12.457, + -33.889 + ], + [ + 24.737, + -22.024 + ], + [ + 31.441, + -10.411 + ], + [ + 37.214, + 0 + ], + [ + 31.442, + 10.411 + ], + [ + 24.737, + 22.024 + ], + [ + 12.457, + 33.889 + ], + [ + 6.705, + 32.435 + ], + [ + 0, + 30.762 + ], + [ + -6.705, + 32.435 + ], + [ + -12.457, + 33.889 + ], + [ + -24.737, + 22.024 + ], + [ + -31.441, + 10.411 + ], + [ + -37.214, + 0 + ], + [ + -31.441, + -10.411 + ], + [ + -24.737, + -22.024 + ], + [ + -12.457, + -33.889 + ], + [ + -6.705, + -32.434 + ], + [ + 0, + -30.762 + ], + [ + 6.705, + -32.434 + ] + ] + }, + "ix": 2 + } + }, + { + "ty": "gs", + "bm": 0, + "hd": false, + "mn": "ADBE Vector Graphic - G-Stroke", + "nm": "Gradient Stroke 1", + "e": { "a": 0, "k": [ + 100, + 0 + ], "ix": 5 }, + "g": { "p": 3, "k": { "a": 0, "k": [ + 0, + 1, + 0.4470588235294118, + 0, + 0.485, + 0.9725490196078431, + 0.2235294117647059, + 0.5019607843137255, + 1, + 0.9411764705882353, + 0, + 1 + ], "ix": 8 } }, + "t": 1, + "a": { "a": 0, "k": 0 }, + "h": { "a": 0, "k": 0 }, + "s": { "a": 0, "k": [ + 0, + 0 + ], "ix": 4 }, + "lc": 2, + "lj": 1, + "ml": 4, + "o": { "a": 0, "k": 100, "ix": 9 }, + "w": { "a": 0, "k": 2, "ix": 10 } + }, + { + "ty": "fl", + "bm": 0, + "hd": false, + "mn": "ADBE Vector Graphic - Fill", + "nm": "Fill 1", + "c": { "a": 0, "k": [ + 0.0941, + 0.5294, + 0.6863 + ], "ix": 4 }, + "r": 1, + "o": { "a": 0, "k": 0, "ix": 5 } + }, + { "ty": "tr", "a": { "a": 0, "k": [ + 0, + 0 + ], "ix": 1 }, "s": { "a": 0, "k": [ + 100, + 100 + ], "ix": 3 }, "sk": { "a": 0, "k": 0, "ix": 4 }, "p": { "a": 0, "k": [ + 0, + 0 + ], "ix": 2 }, "r": { "a": 0, "k": 0, "ix": 6 }, "sa": { "a": 0, "k": 0, "ix": 5 }, "o": { "a": 0, "k": 100, "ix": 7 } } + ] + }, + { + "ty": "tm", + "bm": 0, + "hd": false, + "mn": "ADBE Vector Filter - Trim", + "nm": "Trim Paths 1", + "ix": 2, + "e": { "a": 0, "k": 28, "ix": 2 }, + "o": { + "a": 1, + "k": [ + { "o": { "x": 0.167, "y": 0.167 }, "i": { "x": 0.833, "y": 0.833 }, "s": [0], "t": 0 }, + { "s": [360], "t": 90.0000036657751 } + ], + "ix": 3 + }, + "s": { "a": 0, "k": 0, "ix": 1 }, + "m": 1 + } + ], + "ind": 2 + }, + { + "ty": 4, + "nm": "Shape Layer 4", + "sr": 1, + "st": 0, + "op": 387.000015762833, + "ip": 0, + "hd": false, + "ddd": 0, + "bm": 0, + "hasMask": false, + "ao": 0, + "ks": { "a": { "a": 0, "k": [ + 0, + 0, + 0 + ], "ix": 1 }, "s": { "a": 0, "k": [ + 362.228, + 362.228, + 100 + ], "ix": 6 }, "sk": { "a": 0, "k": 0 }, "p": { "a": 0, "k": [ + 250, + 250, + 0 + ], "ix": 2 }, "r": { "a": 0, "k": 60, "ix": 10 }, "sa": { "a": 0, "k": 0 }, "o": { "a": 0, "k": 100, "ix": 11 } }, + "shapes": [ + { + "ty": "gr", + "bm": 0, + "hd": false, + "mn": "ADBE Vector Group", + "nm": "Shape 1", + "ix": 1, + "cix": 2, + "np": 3, + "it": [ + { + "ty": "sh", + "bm": 0, + "hd": false, + "mn": "ADBE Vector Shape - Group", + "nm": "Path 1", + "ix": 1, + "d": 1, + "ks": { + "a": 0, + "k": { + "c": true, + "i": [ + [ + -2.003, + 0 + ], + [ + -0.231, + -6.654 + ], + [ + -4.034, + -2.519 + ], + [ + 0, + -4.262 + ], + [ + 3.615, + -2.257 + ], + [ + 0.165, + -4.753 + ], + [ + 6.655, + 0 + ], + [ + 1.814, + 0.965 + ], + [ + 2.33, + 0 + ], + [ + 2.057, + -1.094 + ], + [ + 2.003, + 0 + ], + [ + 0.231, + 6.654 + ], + [ + 4.034, + 2.519 + ], + [ + 0, + 4.262 + ], + [ + -3.615, + 2.257 + ], + [ + -0.165, + 4.753 + ], + [ + -6.655, + 0 + ], + [ + -1.814, + -0.965 + ], + [ + -2.33, + 0 + ], + [ + -2.057, + 1.094 + ] + ], + "o": [ + [ + 6.655, + 0 + ], + [ + 0.165, + 4.753 + ], + [ + 3.615, + 2.257 + ], + [ + 0, + 4.262 + ], + [ + -4.034, + 2.519 + ], + [ + -0.231, + 6.654 + ], + [ + -2.003, + 0 + ], + [ + -2.057, + -1.094 + ], + [ + -2.33, + 0 + ], + [ + -1.814, + 0.965 + ], + [ + -6.655, + 0 + ], + [ + -0.165, + -4.753 + ], + [ + -3.615, + -2.257 + ], + [ + 0, + -4.262 + ], + [ + 4.034, + -2.519 + ], + [ + 0.231, + -6.654 + ], + [ + 2.003, + 0 + ], + [ + 2.057, + 1.094 + ], + [ + 2.33, + 0 + ], + [ + 1.814, + -0.965 + ] + ], + "v": [ + [ + 12.457, + -33.889 + ], + [ + 24.737, + -22.024 + ], + [ + 31.441, + -10.411 + ], + [ + 37.214, + 0 + ], + [ + 31.442, + 10.411 + ], + [ + 24.737, + 22.024 + ], + [ + 12.457, + 33.889 + ], + [ + 6.705, + 32.435 + ], + [ + 0, + 30.762 + ], + [ + -6.705, + 32.435 + ], + [ + -12.457, + 33.889 + ], + [ + -24.737, + 22.024 + ], + [ + -31.441, + 10.411 + ], + [ + -37.214, + 0 + ], + [ + -31.441, + -10.411 + ], + [ + -24.737, + -22.024 + ], + [ + -12.457, + -33.889 + ], + [ + -6.705, + -32.434 + ], + [ + 0, + -30.762 + ], + [ + 6.705, + -32.434 + ] + ] + }, + "ix": 2 + } + }, + { + "ty": "gs", + "bm": 0, + "hd": false, + "mn": "ADBE Vector Graphic - G-Stroke", + "nm": "Gradient Stroke 1", + "e": { "a": 0, "k": [ + 100, + 0 + ], "ix": 5 }, + "g": { "p": 3, "k": { "a": 0, "k": [ + 0, + 0, + 0.7764705882352941, + 1, + 0.485, + 0.24705882352941178, + 0.38823529411764707, + 1, + 1, + 0.49411764705882355, + 0, + 1 + ], "ix": 8 } }, + "t": 1, + "a": { "a": 0, "k": 0 }, + "h": { "a": 0, "k": 0 }, + "s": { "a": 0, "k": [ + 0, + 0 + ], "ix": 4 }, + "lc": 2, + "lj": 1, + "ml": 4, + "o": { "a": 0, "k": 100, "ix": 9 }, + "w": { "a": 0, "k": 0.5, "ix": 10 } + }, + { + "ty": "fl", + "bm": 0, + "hd": false, + "mn": "ADBE Vector Graphic - Fill", + "nm": "Fill 1", + "c": { "a": 0, "k": [ + 0.4118, + 0.6784, + 0.7765 + ], "ix": 4 }, + "r": 1, + "o": { "a": 0, "k": 0, "ix": 5 } + }, + { "ty": "tr", "a": { "a": 0, "k": [ + 0, + 0 + ], "ix": 1 }, "s": { "a": 0, "k": [ + 100, + 100 + ], "ix": 3 }, "sk": { "a": 0, "k": 0, "ix": 4 }, "p": { "a": 0, "k": [ + 0, + 0 + ], "ix": 2 }, "r": { "a": 0, "k": 0, "ix": 6 }, "sa": { "a": 0, "k": 0, "ix": 5 }, "o": { "a": 0, "k": 100, "ix": 7 } } + ] + }, + { + "ty": "tm", + "bm": 0, + "hd": false, + "mn": "ADBE Vector Filter - Trim", + "nm": "Trim Paths 1", + "ix": 2, + "e": { "a": 0, "k": 28, "ix": 2 }, + "o": { + "a": 1, + "k": [ + { "o": { "x": 0.167, "y": 0.167 }, "i": { "x": 0.833, "y": 0.833 }, "s": [0], "t": 0 }, + { "s": [360], "t": 90.0000036657751 } + ], + "ix": 3 + }, + "s": { "a": 0, "k": 0, "ix": 1 }, + "m": 1 + } + ], + "ind": 3 + }, + { + "ty": 4, + "nm": "Shape Layer 3", + "sr": 1, + "st": 0, + "op": 387.000015762833, + "ip": 0, + "hd": false, + "ddd": 0, + "bm": 0, + "hasMask": false, + "ao": 0, + "ks": { "a": { "a": 0, "k": [ + 0, + 0, + 0 + ], "ix": 1 }, "s": { "a": 0, "k": [ + 362.228, + 362.228, + 100 + ], "ix": 6 }, "sk": { "a": 0, "k": 0 }, "p": { "a": 0, "k": [ + 250, + 250, + 0 + ], "ix": 2 }, "r": { "a": 0, "k": 30, "ix": 10 }, "sa": { "a": 0, "k": 0 }, "o": { "a": 0, "k": 100, "ix": 11 } }, + "shapes": [ + { + "ty": "gr", + "bm": 0, + "hd": false, + "mn": "ADBE Vector Group", + "nm": "Shape 1", + "ix": 1, + "cix": 2, + "np": 3, + "it": [ + { + "ty": "sh", + "bm": 0, + "hd": false, + "mn": "ADBE Vector Shape - Group", + "nm": "Path 1", + "ix": 1, + "d": 1, + "ks": { + "a": 0, + "k": { + "c": true, + "i": [ + [ + -2.003, + 0 + ], + [ + -0.231, + -6.654 + ], + [ + -4.034, + -2.519 + ], + [ + 0, + -4.262 + ], + [ + 3.615, + -2.257 + ], + [ + 0.165, + -4.753 + ], + [ + 6.655, + 0 + ], + [ + 1.814, + 0.965 + ], + [ + 2.33, + 0 + ], + [ + 2.057, + -1.094 + ], + [ + 2.003, + 0 + ], + [ + 0.231, + 6.654 + ], + [ + 4.034, + 2.519 + ], + [ + 0, + 4.262 + ], + [ + -3.615, + 2.257 + ], + [ + -0.165, + 4.753 + ], + [ + -6.655, + 0 + ], + [ + -1.814, + -0.965 + ], + [ + -2.33, + 0 + ], + [ + -2.057, + 1.094 + ] + ], + "o": [ + [ + 6.655, + 0 + ], + [ + 0.165, + 4.753 + ], + [ + 3.615, + 2.257 + ], + [ + 0, + 4.262 + ], + [ + -4.034, + 2.519 + ], + [ + -0.231, + 6.654 + ], + [ + -2.003, + 0 + ], + [ + -2.057, + -1.094 + ], + [ + -2.33, + 0 + ], + [ + -1.814, + 0.965 + ], + [ + -6.655, + 0 + ], + [ + -0.165, + -4.753 + ], + [ + -3.615, + -2.257 + ], + [ + 0, + -4.262 + ], + [ + 4.034, + -2.519 + ], + [ + 0.231, + -6.654 + ], + [ + 2.003, + 0 + ], + [ + 2.057, + 1.094 + ], + [ + 2.33, + 0 + ], + [ + 1.814, + -0.965 + ] + ], + "v": [ + [ + 12.457, + -33.889 + ], + [ + 24.737, + -22.024 + ], + [ + 31.441, + -10.411 + ], + [ + 37.214, + 0 + ], + [ + 31.442, + 10.411 + ], + [ + 24.737, + 22.024 + ], + [ + 12.457, + 33.889 + ], + [ + 6.705, + 32.435 + ], + [ + 0, + 30.762 + ], + [ + -6.705, + 32.435 + ], + [ + -12.457, + 33.889 + ], + [ + -24.737, + 22.024 + ], + [ + -31.441, + 10.411 + ], + [ + -37.214, + 0 + ], + [ + -31.441, + -10.411 + ], + [ + -24.737, + -22.024 + ], + [ + -12.457, + -33.889 + ], + [ + -6.705, + -32.434 + ], + [ + 0, + -30.762 + ], + [ + 6.705, + -32.434 + ] + ] + }, + "ix": 2 + } + }, + { + "ty": "gs", + "bm": 0, + "hd": false, + "mn": "ADBE Vector Graphic - G-Stroke", + "nm": "Gradient Stroke 1", + "e": { "a": 0, "k": [ + 100, + 0 + ], "ix": 5 }, + "g": { "p": 3, "k": { "a": 0, "k": [ + 0, + 1, + 0.8235294117647058, + 0, + 0.485, + 0.5019607843137255, + 0.8117647058823529, + 0.5019607843137255, + 1, + 0, + 0.8, + 1 + ], "ix": 8 } }, + "t": 1, + "a": { "a": 0, "k": 0 }, + "h": { "a": 0, "k": 0 }, + "s": { "a": 0, "k": [ + 0, + 0 + ], "ix": 4 }, + "lc": 2, + "lj": 1, + "ml": 4, + "o": { "a": 0, "k": 100, "ix": 9 }, + "w": { "a": 0, "k": 0.5, "ix": 10 } + }, + { + "ty": "fl", + "bm": 0, + "hd": false, + "mn": "ADBE Vector Graphic - Fill", + "nm": "Fill 1", + "c": { "a": 0, "k": [ + 0.4118, + 0.6784, + 0.7765 + ], "ix": 4 }, + "r": 1, + "o": { "a": 0, "k": 0, "ix": 5 } + }, + { "ty": "tr", "a": { "a": 0, "k": [ + 0, + 0 + ], "ix": 1 }, "s": { "a": 0, "k": [ + 100, + 100 + ], "ix": 3 }, "sk": { "a": 0, "k": 0, "ix": 4 }, "p": { "a": 0, "k": [ + 0, + 0 + ], "ix": 2 }, "r": { "a": 0, "k": 0, "ix": 6 }, "sa": { "a": 0, "k": 0, "ix": 5 }, "o": { "a": 0, "k": 100, "ix": 7 } } + ] + }, + { + "ty": "tm", + "bm": 0, + "hd": false, + "mn": "ADBE Vector Filter - Trim", + "nm": "Trim Paths 1", + "ix": 2, + "e": { "a": 0, "k": 28, "ix": 2 }, + "o": { + "a": 1, + "k": [ + { "o": { "x": 0.167, "y": 0.167 }, "i": { "x": 0.833, "y": 0.833 }, "s": [0], "t": 0 }, + { "s": [360], "t": 90.0000036657751 } + ], + "ix": 3 + }, + "s": { "a": 0, "k": 0, "ix": 1 }, + "m": 1 + } + ], + "ind": 4 + }, + { + "ty": 4, + "nm": "Shape Layer 1", + "sr": 1, + "st": 0, + "op": 387.000015762833, + "ip": 0, + "hd": false, + "ddd": 0, + "bm": 0, + "hasMask": false, + "ao": 0, + "ks": { "a": { "a": 0, "k": [ + 0, + 0, + 0 + ], "ix": 1 }, "s": { "a": 0, "k": [ + 362.228, + 362.228, + 100 + ], "ix": 6 }, "sk": { "a": 0, "k": 0 }, "p": { "a": 0, "k": [ + 250, + 250, + 0 + ], "ix": 2 }, "r": { "a": 0, "k": 0, "ix": 10 }, "sa": { "a": 0, "k": 0 }, "o": { "a": 0, "k": 8, "ix": 11 } }, + "shapes": [ + { + "ty": "gr", + "bm": 0, + "hd": false, + "mn": "ADBE Vector Group", + "nm": "Shape 1", + "ix": 1, + "cix": 2, + "np": 3, + "it": [ + { + "ty": "sh", + "bm": 0, + "hd": false, + "mn": "ADBE Vector Shape - Group", + "nm": "Path 1", + "ix": 1, + "d": 1, + "ks": { + "a": 0, + "k": { + "c": true, + "i": [ + [ + -2.003, + 0 + ], + [ + -0.231, + -6.654 + ], + [ + -4.034, + -2.519 + ], + [ + 0, + -4.262 + ], + [ + 3.615, + -2.257 + ], + [ + 0.165, + -4.753 + ], + [ + 6.655, + 0 + ], + [ + 1.814, + 0.965 + ], + [ + 2.33, + 0 + ], + [ + 2.057, + -1.094 + ], + [ + 2.003, + 0 + ], + [ + 0.231, + 6.654 + ], + [ + 4.034, + 2.519 + ], + [ + 0, + 4.262 + ], + [ + -3.615, + 2.257 + ], + [ + -0.165, + 4.753 + ], + [ + -6.655, + 0 + ], + [ + -1.814, + -0.965 + ], + [ + -2.33, + 0 + ], + [ + -2.057, + 1.094 + ] + ], + "o": [ + [ + 6.655, + 0 + ], + [ + 0.165, + 4.753 + ], + [ + 3.615, + 2.257 + ], + [ + 0, + 4.262 + ], + [ + -4.034, + 2.519 + ], + [ + -0.231, + 6.654 + ], + [ + -2.003, + 0 + ], + [ + -2.057, + -1.094 + ], + [ + -2.33, + 0 + ], + [ + -1.814, + 0.965 + ], + [ + -6.655, + 0 + ], + [ + -0.165, + -4.753 + ], + [ + -3.615, + -2.257 + ], + [ + 0, + -4.262 + ], + [ + 4.034, + -2.519 + ], + [ + 0.231, + -6.654 + ], + [ + 2.003, + 0 + ], + [ + 2.057, + 1.094 + ], + [ + 2.33, + 0 + ], + [ + 1.814, + -0.965 + ] + ], + "v": [ + [ + 12.457, + -33.889 + ], + [ + 24.737, + -22.024 + ], + [ + 31.441, + -10.411 + ], + [ + 37.214, + 0 + ], + [ + 31.442, + 10.411 + ], + [ + 24.737, + 22.024 + ], + [ + 12.457, + 33.889 + ], + [ + 6.705, + 32.435 + ], + [ + 0, + 30.762 + ], + [ + -6.705, + 32.435 + ], + [ + -12.457, + 33.889 + ], + [ + -24.737, + 22.024 + ], + [ + -31.441, + 10.411 + ], + [ + -37.214, + 0 + ], + [ + -31.441, + -10.411 + ], + [ + -24.737, + -22.024 + ], + [ + -12.457, + -33.889 + ], + [ + -6.705, + -32.434 + ], + [ + 0, + -30.762 + ], + [ + 6.705, + -32.434 + ] + ] + }, + "ix": 2 + } + }, + { + "ty": "st", + "bm": 0, + "hd": false, + "mn": "ADBE Vector Graphic - Stroke", + "nm": "Stroke 1", + "lc": 2, + "lj": 1, + "ml": 4, + "o": { "a": 0, "k": 100, "ix": 4 }, + "w": { "a": 0, "k": 4, "ix": 5 }, + "c": { "a": 0, "k": [ + 0.8157, + 0.8157, + 0.8157 + ], "ix": 3 } + }, + { + "ty": "fl", + "bm": 0, + "hd": false, + "mn": "ADBE Vector Graphic - Fill", + "nm": "Fill 1", + "c": { "a": 0, "k": [ + 0.4118, + 0.6784, + 0.7765 + ], "ix": 4 }, + "r": 1, + "o": { "a": 0, "k": 0, "ix": 5 } + }, + { "ty": "tr", "a": { "a": 0, "k": [ + 0, + 0 + ], "ix": 1 }, "s": { "a": 0, "k": [ + 100, + 100 + ], "ix": 3 }, "sk": { "a": 0, "k": 0, "ix": 4 }, "p": { "a": 0, "k": [ + 0, + 0 + ], "ix": 2 }, "r": { "a": 0, "k": 0, "ix": 6 }, "sa": { "a": 0, "k": 0, "ix": 5 }, "o": { "a": 0, "k": 100, "ix": 7 } } + ] + }, + { + "ty": "tm", + "bm": 0, + "hd": false, + "mn": "ADBE Vector Filter - Trim", + "nm": "Trim Paths 1", + "ix": 2, + "e": { "a": 0, "k": 100, "ix": 2 }, + "o": { "a": 0, "k": 0, "ix": 3 }, + "s": { "a": 0, "k": 0, "ix": 1 }, + "m": 1 + } + ], + "ind": 5 + } + ], + "v": "5.7.0", + "fr": 29.9700012207031, + "op": 92, + "ip": 0, + "assets": [ + { + "nm": "Nested Scene 1", + "id": "precomp_Shape Layer - SVG_9a65cf1e-7dbf-4e06-85a4-5dc45595fbad", + "fr": 29.9700012207031, + "layers": [ + { + "ty": 4, + "nm": "Shape Layer - SVG", + "sr": 1, + "st": 0, + "op": 92, + "ip": 0, + "hd": false, + "ddd": 0, + "bm": 0, + "hasMask": false, + "ao": 0, + "ks": { "a": { "a": 0, "k": [ + 0, + 0 + ] }, "s": { "a": 0, "k": [ + 100, + 100 + ] }, "sk": { "a": 0, "k": 0 }, "p": { "a": 0, "k": [ + 0, + 0 + ] }, "r": { "a": 0, "k": 0 }, "sa": { "a": 0, "k": 0 }, "o": { "a": 0, "k": 100 } }, + "shapes": [ + { + "ty": "gr", + "bm": 0, + "hd": false, + "nm": "Group 1", + "it": [ + { + "ty": "sh", + "bm": 0, + "hd": false, + "nm": "Path 1", + "d": 1, + "ks": { + "a": 0, + "k": { + "c": true, + "i": [ + [ + 0, + 0 + ], + [ + -0.4866000000000028, + -2.1432 + ], + [ + 0, + 0 + ], + [ + 0, + 0 + ], + [ + -3.7177000000000007, + 4.1865999999999985 + ], + [ + 0, + 0 + ], + [ + 0, + 0 + ], + [ + 1.5194000000000045, + 0.4987999999999957 + ], + [ + -0.10560000000000258, + -0.7130999999999972 + ] + ], + "o": [ + [ + 0.36659999999999826, + 2.4802999999999997 + ], + [ + 0, + 0 + ], + [ + 0, + 0 + ], + [ + 3.657899999999998, + -0.33209999999999695 + ], + [ + 0, + 0 + ], + [ + 0, + 0 + ], + [ + -4.331400000000002, + 0.12060000000000315 + ], + [ + -0.2796999999999983, + -0.091700000000003 + ], + [ + 0, + 0 + ] + ], + "v": [ + [ + 37.8934, + 52.3959 + ], + [ + 39.3607, + 60.3476 + ], + [ + 39.9165, + 62.7956 + ], + [ + 40.4902, + 62.7433 + ], + [ + 55.8807, + 53.3144 + ], + [ + 56.5203, + 52.5945 + ], + [ + 53.2122, + 52.6864 + ], + [ + 38.086, + 51.7085 + ], + [ + 37.8934, + 52.3959 + ] + ] + } + } + }, + { "ty": "fl", "bm": 0, "hd": false, "nm": "Fill", "c": { "a": 0, "k": [ + 0.4627, + 0.7686, + 0.4784 + ] }, "r": 1, "o": { "a": 0, "k": 100 } }, + { "ty": "tr", "a": { "a": 0, "k": [ + 0, + 0 + ] }, "s": { "a": 0, "k": [ + 100, + 100 + ] }, "sk": { "a": 0, "k": 0 }, "p": { "a": 0, "k": [ + 0, + 0 + ] }, "r": { "a": 0, "k": 0 }, "sa": { "a": 0, "k": 0 }, "o": { "a": 0, "k": 100 } } + ] + }, + { + "ty": "gr", + "bm": 0, + "hd": false, + "nm": "Group 2", + "it": [ + { + "ty": "sh", + "bm": 0, + "hd": false, + "nm": "Path 2", + "d": 1, + "ks": { + "a": 0, + "k": { + "c": true, + "i": [ + [ + 0, + 0 + ], + [ + -0.15738999999999947, + -0.445600000000006 + ], + [ + -3.0069, + -1.603900000000003 + ], + [ + -5.0671, + 0.9211000000000027 + ], + [ + 0, + 0 + ], + [ + 0, + 0 + ], + [ + 0.38759999999999906, + 2.5437999999999974 + ], + [ + 0, + 0 + ], + [ + 0, + 0 + ], + [ + 5.086, + 3.870999999999995 + ], + [ + 0.07519999999999882, + 0 + ], + [ + 0.6423999999999985, + -2.1193000000000026 + ] + ], + "o": [ + [ + -1.3162400000000005, + 4.343699999999998 + ], + [ + 0.38225000000000087, + 1.083199999999998 + ], + [ + 5.5031, + 2.9361999999999995 + ], + [ + 0, + 0 + ], + [ + 0, + 0 + ], + [ + -0.5576000000000008, + -2.1340000000000003 + ], + [ + 0, + 0 + ], + [ + 0, + 0 + ], + [ + -6.5581, + -2.023600000000002 + ], + [ + -0.9616000000000007, + -0.7317999999999998 + ], + [ + -0.07540000000000013, + 0 + ], + [ + 0, + 0 + ] + ], + "v": [ + [ + 10.3213, + 43.5292 + ], + [ + 7.99771, + 53.13 + ], + [ + 16.8523, + 60.1514 + ], + [ + 35.6616, + 63.737 + ], + [ + 36.4296, + 63.5977 + ], + [ + 36.1118, + 62.3806 + ], + [ + 34.2685, + 53.2599 + ], + [ + 33.8928, + 50.7962 + ], + [ + 31.2208, + 49.9716 + ], + [ + 13.5113, + 41.0065 + ], + [ + 11.6262, + 39.6758 + ], + [ + 10.3213, + 43.5292 + ] + ] + } + } + }, + { "ty": "fl", "bm": 0, "hd": false, "nm": "Fill", "c": { "a": 0, "k": [ + 0.0275, + 0.451, + 0.7333 + ] }, "r": 1, "o": { "a": 0, "k": 100 } }, + { "ty": "tr", "a": { "a": 0, "k": [ + 0, + 0 + ] }, "s": { "a": 0, "k": [ + 100, + 100 + ] }, "sk": { "a": 0, "k": 0 }, "p": { "a": 0, "k": [ + 0, + 0 + ] }, "r": { "a": 0, "k": 0 }, "sa": { "a": 0, "k": 0 }, "o": { "a": 0, "k": 100 } } + ] + }, + { + "ty": "gr", + "bm": 0, + "hd": false, + "nm": "Group 3", + "it": [ + { + "ty": "sh", + "bm": 0, + "hd": false, + "nm": "Path 3", + "d": 1, + "ks": { + "a": 0, + "k": { + "c": true, + "i": [ + [ + 0, + 0 + ], + [ + -3.384233, + -5.6932000000000045 + ], + [ + -0.3778100000000002, + 1.6189999999999998 + ], + [ + -0.9020400000000004, + 2.518900000000002 + ], + [ + 0.00140000000000029, + 0.06499999999999773 + ], + [ + 0.8331499999999998, + 0.9473999999999947 + ], + [ + 1.5369500000000003, + 2.5269000000000013 + ], + [ + 0.06304700000000008, + 0.08149999999999835 + ], + [ + 0.10811399999999999, + -0.7799000000000014 + ] + ], + "o": [ + [ + -0.9809669999999999, + 7.0760000000000005 + ], + [ + 0.6531199999999995, + 1.0985000000000014 + ], + [ + 0.51349, + -2.1987000000000023 + ], + [ + 0.4948000000000006, + -1.3810000000000002 + ], + [ + -0.0014099999999999113, + -0.06500000000000483 + ], + [ + -2.0102700000000002, + -2.286300000000004 + ], + [ + -0.6974799999999999, + -1.1469999999999985 + ], + [ + -0.06281399999999993, + -0.08170000000000144 + ], + [ + 0, + 0 + ] + ], + "v": [ + [ + 0.299989, + 27.289 + ], + [ + 4.16338, + 47.8151 + ], + [ + 5.20085, + 47.2916 + ], + [ + 7.55905, + 39.4327 + ], + [ + 8.45619, + 36.8034 + ], + [ + 6.93862, + 34.9626 + ], + [ + 1.99385, + 28.2532 + ], + [ + 0.611022, + 26.0196 + ], + [ + 0.299989, + 27.289 + ] + ] + } + } + }, + { "ty": "fl", "bm": 0, "hd": false, "nm": "Fill", "c": { "a": 0, "k": [ + 0.1333, + 0.698, + 0.298 + ] }, "r": 1, "o": { "a": 0, "k": 100 } }, + { "ty": "tr", "a": { "a": 0, "k": [ + 0, + 0 + ] }, "s": { "a": 0, "k": [ + 100, + 100 + ] }, "sk": { "a": 0, "k": 0 }, "p": { "a": 0, "k": [ + 0, + 0 + ] }, "r": { "a": 0, "k": 0 }, "sa": { "a": 0, "k": 0 }, "o": { "a": 0, "k": 100 } } + ] + }, + { + "ty": "gr", + "bm": 0, + "hd": false, + "nm": "Group 4", + "it": [ + { + "ty": "sh", + "bm": 0, + "hd": false, + "nm": "Path 4", + "d": 1, + "ks": { + "a": 0, + "k": { + "c": true, + "i": [ + [ + 0, + 0 + ], + [ + -0.5378000000000043, + -0.5847000000000051 + ], + [ + -2.363100000000003, + -0.1734000000000009 + ], + [ + -0.9472999999999985, + 0.6635999999999953 + ], + [ + 0, + 5.052500000000002 + ], + [ + 0, + 0 + ], + [ + 0, + 0 + ], + [ + 7.270699999999998, + 2.3749000000000002 + ], + [ + 0.05420000000000158, + -0.2012999999999998 + ] + ], + "o": [ + [ + -0.5215999999999994, + 1.9314 + ], + [ + 0.12959999999999638, + 0.14089999999999492 + ], + [ + 4.624599999999994, + 0.33910000000000196 + ], + [ + 1.7532000000000068, + -1.2280000000000015 + ], + [ + 0, + 0 + ], + [ + 0, + 0 + ], + [ + -6.882399999999997, + 1.0878000000000014 + ], + [ + -0.17260000000000275, + -0.056400000000000006 + ], + [ + 0, + 0 + ] + ], + "v": [ + [ + 37.2526, + 29.286 + ], + [ + 37.3708, + 47.7764 + ], + [ + 45.1527, + 48.7571 + ], + [ + 59.6112, + 47.9155 + ], + [ + 64, + 32.1945 + ], + [ + 64, + 31.0465 + ], + [ + 62.3739, + 31.3035 + ], + [ + 37.6188, + 29.0519 + ], + [ + 37.2526, + 29.286 + ] + ] + } + } + }, + { "ty": "fl", "bm": 0, "hd": false, "nm": "Fill", "c": { "a": 0, "k": [ + 0.1569, + 0.6745, + 0.8863 + ] }, "r": 1, "o": { "a": 0, "k": 100 } }, + { "ty": "tr", "a": { "a": 0, "k": [ + 0, + 0 + ] }, "s": { "a": 0, "k": [ + 100, + 100 + ] }, "sk": { "a": 0, "k": 0 }, "p": { "a": 0, "k": [ + 0, + 0 + ] }, "r": { "a": 0, "k": 0 }, "sa": { "a": 0, "k": 0 }, "o": { "a": 0, "k": 100 } } + ] + }, + { + "ty": "gr", + "bm": 0, + "hd": false, + "nm": "Group 5", + "it": [ + { + "ty": "sh", + "bm": 0, + "hd": false, + "nm": "Path 5", + "d": 1, + "ks": { + "a": 0, + "k": { + "c": true, + "i": [ + [ + 0, + 0 + ], + [ + 1.3225999999999996, + -3.450000000000003 + ], + [ + 0, + 0 + ], + [ + 0, + 0 + ], + [ + -5.736600000000003, + -1.8301000000000016 + ], + [ + -0.17549999999999955, + -0.05740000000000123 + ], + [ + -0.17549999999999955, + 0.1676000000000002 + ], + [ + 0.06490000000000151, + 0.7081000000000017 + ], + [ + 0, + 0 + ], + [ + 0.01559999999999917, + 0.19009999999999394 + ], + [ + -0.46280000000000143, + 2.0993999999999993 + ], + [ + 1.0622000000000007, + 0.5338999999999992 + ], + [ + 2.0352999999999994, + 1.5974000000000004 + ], + [ + 0.1661999999999999, + 0.1280000000000001 + ], + [ + 1.0383999999999993, + -1.343 + ] + ], + "o": [ + [ + -2.991699999999998, + 3.869200000000003 + ], + [ + 0, + 0 + ], + [ + 0, + 0 + ], + [ + 4.527899999999999, + 3.899700000000003 + ], + [ + 0.2134999999999998, + 0.06810000000000116 + ], + [ + 0.9068999999999967, + 0.2961999999999989 + ], + [ + 0.15290000000000248, + -0.14610000000000412 + ], + [ + 0, + 0 + ], + [ + -0.014400000000001967, + -0.15669999999999362 + ], + [ + -0.3248000000000033, + -3.954800000000006 + ], + [ + 0.017600000000001614, + -0.07900000000000063 + ], + [ + -2.2921000000000014, + -1.1525999999999996 + ], + [ + -0.8074000000000012, + -0.6334000000000017 + ], + [ + -0.27069999999999794, + -0.2079000000000022 + ], + [ + 0, + 0 + ] + ], + "v": [ + [ + 21.1862, + 21.7642 + ], + [ + 13.3941, + 34.984 + ], + [ + 13.1426, + 35.6405 + ], + [ + 14.5119, + 36.82 + ], + [ + 31.2098, + 46.141 + ], + [ + 31.7925, + 46.3298 + ], + [ + 33.2972, + 46.5964 + ], + [ + 33.3505, + 45.364 + ], + [ + 33.3504, + 45.3627 + ], + [ + 33.3046, + 44.843 + ], + [ + 33.6959, + 27.6811 + ], + [ + 31.7966, + 26.5666 + ], + [ + 24.4205, + 21.8794 + ], + [ + 22.6501, + 20.4948 + ], + [ + 21.1862, + 21.7642 + ] + ] + } + } + }, + { "ty": "fl", "bm": 0, "hd": false, "nm": "Fill", "c": { "a": 0, "k": [ + 0.0275, + 0.451, + 0.7333 + ] }, "r": 1, "o": { "a": 0, "k": 100 } }, + { "ty": "tr", "a": { "a": 0, "k": [ + 0, + 0 + ] }, "s": { "a": 0, "k": [ + 100, + 100 + ] }, "sk": { "a": 0, "k": 0 }, "p": { "a": 0, "k": [ + 0, + 0 + ] }, "r": { "a": 0, "k": 0 }, "sa": { "a": 0, "k": 0 }, "o": { "a": 0, "k": 100 } } + ] + }, + { + "ty": "gr", + "bm": 0, + "hd": false, + "nm": "Group 6", + "it": [ + { + "ty": "sh", + "bm": 0, + "hd": false, + "nm": "Path 6", + "d": 1, + "ks": { + "a": 0, + "k": { + "c": true, + "i": [ + [ + 0, + 0 + ], + [ + 1.6971300000000005, + -3.8272999999999993 + ], + [ + 0, + 0 + ], + [ + 0, + 0 + ], + [ + -2.9274900000000006, + -3.475900000000003 + ], + [ + 0, + 0 + ], + [ + 0, + 0 + ], + [ + -2.8207000000000004, + 3.688200000000002 + ], + [ + 0, + 0 + ], + [ + 0, + 0 + ], + [ + 1.5810999999999993, + 2.36097 + ], + [ + 0.11470000000000091, + 0.17483000000000004 + ], + [ + 0.2865000000000002, + 0.03232999999999997 + ], + [ + 0.6156000000000006, + -0.6015199999999998 + ], + [ + 0.1765699999999999, + -0.16886000000000045 + ] + ], + "o": [ + [ + -2.967649999999999, + 2.8382400000000008 + ], + [ + 0, + 0 + ], + [ + 0, + 0 + ], + [ + 2.05276, + 3.5570999999999984 + ], + [ + 0, + 0 + ], + [ + 0, + 0 + ], + [ + 1.872300000000001, + -3.720200000000002 + ], + [ + 0, + 0 + ], + [ + 0, + 0 + ], + [ + -1.805299999999999, + -1.9300999999999995 + ], + [ + -0.14390000000000036, + -0.21494999999999997 + ], + [ + -0.5118999999999989, + -0.7804499999999992 + ], + [ + -0.27949999999999875, + -0.03155000000000019 + ], + [ + -0.14119999999999955, + 0.13804999999999978 + ], + [ + 0, + 0 + ] + ], + "v": [ + [ + 9.77953, + 8.90166 + ], + [ + 2.72246, + 18.985 + ], + [ + 1.80664, + 21.05 + ], + [ + 2.237, + 21.7956 + ], + [ + 9.2392, + 31.6839 + ], + [ + 10.2195, + 32.8482 + ], + [ + 11.2128, + 30.8746 + ], + [ + 18.2271, + 19.8019 + ], + [ + 19.7674, + 17.788 + ], + [ + 18.4287, + 16.3567 + ], + [ + 12.8987, + 9.34923 + ], + [ + 12.5124, + 8.7648 + ], + [ + 11.4613, + 7.56662 + ], + [ + 10.2546, + 8.44107 + ], + [ + 9.77953, + 8.90166 + ] + ] + } + } + }, + { "ty": "fl", "bm": 0, "hd": false, "nm": "Fill", "c": { "a": 0, "k": [ + 0.1569, + 0.6745, + 0.8863 + ] }, "r": 1, "o": { "a": 0, "k": 100 } }, + { "ty": "tr", "a": { "a": 0, "k": [ + 0, + 0 + ] }, "s": { "a": 0, "k": [ + 100, + 100 + ] }, "sk": { "a": 0, "k": 0 }, "p": { "a": 0, "k": [ + 0, + 0 + ] }, "r": { "a": 0, "k": 0 }, "sa": { "a": 0, "k": 0 }, "o": { "a": 0, "k": 100 } } + ] + }, + { + "ty": "gr", + "bm": 0, + "hd": false, + "nm": "Group 7", + "it": [ + { + "ty": "sh", + "bm": 0, + "hd": false, + "nm": "Path 7", + "d": 1, + "ks": { + "a": 0, + "k": { + "c": true, + "i": [ + [ + 0, + 0 + ], + [ + 1.3486999999999938, + -6.994300000000001 + ], + [ + 0.03300000000000125, + -0.1656000000000013 + ], + [ + -0.17560000000000286, + -0.32069999999999865 + ], + [ + -1.0641999999999996, + -0.28910000000000124 + ], + [ + -0.1992000000000047, + -0.05529999999999902 + ], + [ + -6.824000000000005, + 0.6976000000000013 + ], + [ + -0.19290000000000163, + 0.01770000000000138 + ], + [ + -0.12379999999999569, + 0.20589999999999975 + ], + [ + 0.113900000000001, + 0.5217999999999989 + ], + [ + 0.038699999999998624, + 0.19280000000000186 + ], + [ + 8.160399999999996, + 4.55716 + ], + [ + 0, + 0 + ], + [ + 0, + 0 + ] + ], + "o": [ + [ + -2.9772999999999996, + 4.436579999999999 + ], + [ + -0.03750000000000142, + 0.1944999999999979 + ], + [ + -0.1910000000000025, + 0.9592999999999989 + ], + [ + 0.1897999999999982, + 0.34690000000000154 + ], + [ + 0.17009999999999792, + 0.04619999999999891 + ], + [ + 5.970399999999998, + 1.6580000000000013 + ], + [ + 0.2458999999999989, + -0.025099999999998346 + ], + [ + 0.811399999999999, + -0.07430000000000092 + ], + [ + 0.10180000000000433, + -0.16949999999999932 + ], + [ + -0.03289999999999793, + -0.15060000000000073 + ], + [ + -1.8084999999999951, + -8.997599999999998 + ], + [ + 0, + 0 + ], + [ + 0, + 0 + ], + [ + 0, + 0 + ] + ], + "v": [ + [ + 45.3084, + 3.81117 + ], + [ + 38.2214, + 22.5377 + ], + [ + 38.1152, + 23.0771 + ], + [ + 38.0005, + 24.8556 + ], + [ + 39.7658, + 25.6305 + ], + [ + 40.3192, + 25.7821 + ], + [ + 61.5626, + 27.3769 + ], + [ + 62.2194, + 27.3141 + ], + [ + 63.5209, + 27.0004 + ], + [ + 63.4463, + 26.0229 + ], + [ + 63.3375, + 25.5092 + ], + [ + 47.4917, + 3.96349 + ], + [ + 45.8291, + 3.0351 + ], + [ + 45.3084, + 3.81117 + ] + ] + } + } + }, + { "ty": "fl", "bm": 0, "hd": false, "nm": "Fill", "c": { "a": 0, "k": [ + 0.1333, + 0.698, + 0.298 + ] }, "r": 1, "o": { "a": 0, "k": 100 } }, + { "ty": "tr", "a": { "a": 0, "k": [ + 0, + 0 + ] }, "s": { "a": 0, "k": [ + 100, + 100 + ] }, "sk": { "a": 0, "k": 0 }, "p": { "a": 0, "k": [ + 0, + 0 + ] }, "r": { "a": 0, "k": 0 }, "sa": { "a": 0, "k": 0 }, "o": { "a": 0, "k": 100 } } + ] + }, + { + "ty": "gr", + "bm": 0, + "hd": false, + "nm": "Group 8", + "it": [ + { + "ty": "sh", + "bm": 0, + "hd": false, + "nm": "Path 8", + "d": 1, + "ks": { + "a": 0, + "k": { + "c": true, + "i": [ + [ + 0, + 0 + ], + [ + 1.8596000000000004, + -2.0977000000000015 + ], + [ + 0, + 0 + ], + [ + 0, + 0 + ], + [ + -0.2785999999999973, + 0.34830000000000183 + ], + [ + -0.30169999999999675, + 1.3917000000000002 + ], + [ + -2.1699999999999946, + 4.5597199999999996 + ], + [ + 0.06470000000000198, + -0.0043199999999998795 + ], + [ + 1.8033999999999963, + -1.3320400000000001 + ] + ], + "o": [ + [ + -4.951500000000003, + 3.65771 + ], + [ + 0, + 0 + ], + [ + 0, + 0 + ], + [ + 2.5614999999999988, + 2.0771000000000015 + ], + [ + 0.06560000000000343, + -0.08200000000000074 + ], + [ + 1.0449000000000055, + -4.822699999999999 + ], + [ + 0.7236000000000047, + -1.5209200000000003 + ], + [ + -0.06459999999999866, + 0.0043199999999998795 + ], + [ + 0, + 0 + ] + ], + "v": [ + [ + 37.4538, + 6.75399 + ], + [ + 25.594, + 16.7754 + ], + [ + 24.9658, + 17.4839 + ], + [ + 26.1738, + 18.4636 + ], + [ + 34.3064, + 23.4148 + ], + [ + 34.974, + 20.7352 + ], + [ + 39.6523, + 7.08148 + ], + [ + 40.8504, + 4.32423 + ], + [ + 37.4538, + 6.75399 + ] + ] + } + } + }, + { "ty": "fl", "bm": 0, "hd": false, "nm": "Fill", "c": { "a": 0, "k": [ + 0.0235, + 0.451, + 0.7294 + ] }, "r": 1, "o": { "a": 0, "k": 100 } }, + { "ty": "tr", "a": { "a": 0, "k": [ + 0, + 0 + ] }, "s": { "a": 0, "k": [ + 100, + 100 + ] }, "sk": { "a": 0, "k": 0 }, "p": { "a": 0, "k": [ + 0, + 0 + ] }, "r": { "a": 0, "k": 0 }, "sa": { "a": 0, "k": 0 }, "o": { "a": 0, "k": 100 } } + ] + }, + { + "ty": "gr", + "bm": 0, + "hd": false, + "nm": "Group 9", + "it": [ + { + "ty": "sh", + "bm": 0, + "hd": false, + "nm": "Path 9", + "d": 1, + "ks": { + "a": 0, + "k": { + "c": true, + "i": [ + [ + 0, + 0 + ], + [ + 0, + -1.0842699999999996 + ], + [ + -2.1457000000000015, + -2.427200000000001 + ], + [ + 0, + 0 + ], + [ + 0, + 0 + ], + [ + -5.013399999999997, + 3.6241999999999996 + ], + [ + 0, + 0 + ], + [ + 0, + 0 + ], + [ + 3.0864999999999974, + -0.32317300000000004 + ] + ], + "o": [ + [ + -4.299600000000002, + 0.450614 + ], + [ + 0, + 0.5206800000000005 + ], + [ + 0, + 0 + ], + [ + 0, + 0 + ], + [ + 3.7058, + -3.7046599999999987 + ], + [ + 0, + 0 + ], + [ + 0, + 0 + ], + [ + -2.9074000000000026, + -0.5727236099999999 + ], + [ + 0, + 0 + ] + ], + "v": [ + [ + 28.4216, + 0.166472 + ], + [ + 14.792, + 5.03107 + ], + [ + 20.7359, + 13.1972 + ], + [ + 22.26, + 14.9212 + ], + [ + 23.9051, + 13.2769 + ], + [ + 36.2065, + 2.93718 + ], + [ + 39.1905, + 0.780068 + ], + [ + 38.1246, + 0.57012 + ], + [ + 28.4216, + 0.166472 + ] + ] + } + } + }, + { "ty": "fl", "bm": 0, "hd": false, "nm": "Fill", "c": { "a": 0, "k": [ + 0.4627, + 0.7686, + 0.4784 + ] }, "r": 2, "o": { "a": 0, "k": 100 } }, + { "ty": "tr", "a": { "a": 0, "k": [ + 0, + 0 + ] }, "s": { "a": 0, "k": [ + 100, + 100 + ] }, "sk": { "a": 0, "k": 0 }, "p": { "a": 0, "k": [ + 0, + 0 + ] }, "r": { "a": 0, "k": 0 }, "sa": { "a": 0, "k": 0 }, "o": { "a": 0, "k": 100 } } + ] + } + ], + "ind": 1 + } + ] + } + ] +} diff --git a/src/assets/env.js b/src/assets/env.js index e278f8d6f..934ea9a63 100644 --- a/src/assets/env.js +++ b/src/assets/env.js @@ -21,7 +21,7 @@ // in the tenants table of the tenant-store database, or the platform rejects the request as an // unknown tenant. window["env"]["fineractPlatformTenantId"] = 'utkarshbank'; - window["env"]["fineractPlatformTenantIds"] = 'utkarshbank,lincpay'; + window["env"]["fineractPlatformTenantIds"] = 'utkarshbank,lincpay,utkarshnagari,testbank,humarwork,default,mybank'; window['env']['tenantLogoUrl'] = ''; window['env']['tenantLogoUrlDark'] = ''; diff --git a/src/assets/images/mifos_splash_screen_logo.png b/src/assets/images/mifos_splash_screen_logo.png new file mode 100644 index 000000000..4a515337a Binary files /dev/null and b/src/assets/images/mifos_splash_screen_logo.png differ diff --git a/src/assets/styles/_loader.scss b/src/assets/styles/_loader.scss index c5c410894..ff3e124e9 100644 --- a/src/assets/styles/_loader.scss +++ b/src/assets/styles/_loader.scss @@ -6,53 +6,95 @@ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ +/* ========================================================================== + Modernized Loader Wrapper (Inspired by openMF/mifos-mobile) + Supports full-screen and embedded containers with smooth vector physics. + ========================================================================== */ .loader-wrapper { - height: 100vh; - width: 100vw; + position: relative; + display: flex; + align-items: center; + justify-content: center; + width: 100%; + min-height: 280px; + height: 100%; + background: transparent; + /* Modern orbital rings that replace legacy rigid triangles smoothly */ .triangle { - width: 0; - height: 0; - border-style: solid; position: absolute; - animation: rotate 0.8s infinite 0.2s; + border-radius: 50%; + border-style: solid; + box-sizing: border-box; + transition: all 0.3s ease; } .bottom { - border-width: 0 90px 90px; - border-color: transparent transparent #1fb050; - bottom: calc(50% - 100px); - left: calc(50% - 90px); + width: 72px; + height: 72px; + border-width: 3px; + border-color: #2563eb transparent transparent; + animation: cbs-spin-ring 1.1s cubic-bezier(0.5, 0, 0.5, 1) infinite; + box-shadow: 0 0 16px rgb(37 99 235 / 35%); } .top { - border-width: 90px 90px 0; - border-color: #b4d575 transparent transparent; - top: calc(50% - 100px); - left: calc(50% - 90px); + width: 54px; + height: 54px; + border-width: 3px; + border-color: transparent transparent #0ea5e9; + animation: cbs-spin-ring 1.4s cubic-bezier(0.5, 0, 0.5, 1) infinite reverse; + box-shadow: 0 0 14px rgb(14 165 233 / 35%); } .left { - border-width: 90px 0 90px 90px; - border-color: transparent transparent transparent #1074b9; - top: calc(50% - 90px); - left: calc(50% - 100px); + width: 36px; + height: 36px; + border-width: 2.5px; + border-color: #10b981 transparent transparent; + animation: cbs-spin-ring 0.9s cubic-bezier(0.5, 0, 0.5, 1) infinite; + box-shadow: 0 0 10px rgb(16 185 129 / 35%); } .right { - border-width: 90px 90px 90px 0; - border-color: transparent #1daeec transparent transparent; - top: calc(50% - 90px); - right: calc(50% - 100px); + width: 18px; + height: 18px; + border-width: 2px; + border-color: transparent #38bdf8 transparent transparent; + animation: cbs-spin-ring 0.7s cubic-bezier(0.5, 0, 0.5, 1) infinite reverse; } - @keyframes rotate { - from { - transform: rotate(0deg); - } - - to { - transform: rotate(360deg); - } + /* Optional central glowing core */ + &::after { + content: ''; + width: 8px; + height: 8px; + border-radius: 50%; + background: #38bdf8; + box-shadow: 0 0 12px #38bdf8; + animation: cbs-pulse-core 1.5s ease-in-out infinite; + } +} + +@keyframes cbs-spin-ring { + from { + transform: rotate(0deg); + } + + to { + transform: rotate(360deg); + } +} + +@keyframes cbs-pulse-core { + 0%, + 100% { + transform: scale(0.85); + opacity: 0.6; + } + + 50% { + transform: scale(1.3); + opacity: 1; } } diff --git a/src/assets/translations/en-US.json b/src/assets/translations/en-US.json index f5fd8d159..89e1ef8a9 100644 --- a/src/assets/translations/en-US.json +++ b/src/assets/translations/en-US.json @@ -749,7 +749,7 @@ "Daily": "Daily", "Monthly": "Monthly", "Weekly": "Weekly", - "Quarterly": "Quartely", + "Quarterly": "Quarterly", "Semi-Annual": "Semi-Annual", "SemiWeeks": "SemiWeeks", "Annually": "Annually", diff --git a/src/environments/.env.ts b/src/environments/.env.ts index 888a7a52b..5cf0d7b56 100644 --- a/src/environments/.env.ts +++ b/src/environments/.env.ts @@ -9,8 +9,8 @@ /* tslint:disable */ export default { 'mifos_x': { - 'version': '261006', - 'hash': '5ab4e9ea' + 'version': '261012', + 'hash': '6b0f218b' }, 'allow_switching_backend_instance': true }; diff --git a/src/environments/environment.prod.ts b/src/environments/environment.prod.ts index 7e1be2f0b..107944c83 100644 --- a/src/environments/environment.prod.ts +++ b/src/environments/environment.prod.ts @@ -20,7 +20,8 @@ export const environment = { hash: env.mifos_x.hash, // For connecting to server running elsewhere update the tenant identifier fineractPlatformTenantId: loadedEnv['fineractPlatformTenantId'] || 'utkarshbank', - fineractPlatformTenantIds: loadedEnv['fineractPlatformTenantIds'] || 'utkarshbank,lincpay', + fineractPlatformTenantIds: + loadedEnv['fineractPlatformTenantIds'] || 'utkarshbank,lincpay,utkarshnagari,testbank,humarwork,default,mybank', // For connecting to others servers running elsewhere update the base API URL baseApiUrls: loadedEnv['fineractApiUrls'] || 'https://demo.mifos.community,https://localhost:8443,' + window.location.origin, diff --git a/src/environments/environment.ts b/src/environments/environment.ts index dabb366fb..6db2d3da6 100644 --- a/src/environments/environment.ts +++ b/src/environments/environment.ts @@ -23,7 +23,8 @@ export const environment = { hash: env.mifos_x.hash, // For connecting to server running elsewhere update the tenant identifier fineractPlatformTenantId: loadedEnv.fineractPlatformTenantId || 'utkarshbank', - fineractPlatformTenantIds: loadedEnv.fineractPlatformTenantIds || 'utkarshbank,lincpay', + fineractPlatformTenantIds: + loadedEnv.fineractPlatformTenantIds || 'utkarshbank,lincpay,utkarshnagari,testbank,humarwork,default,mybank', // For connecting to others servers running elsewhere update the base API URL baseApiUrls: loadedEnv.fineractApiUrls || 'https://demo.mifos.community,https://localhost:8443,' + window.location.origin, diff --git a/src/index.html b/src/index.html index 129b49a38..b63e3f055 100644 --- a/src/index.html +++ b/src/index.html @@ -11,7 +11,7 @@ - {{'APP_NAME' | translate}} + CBS Nexora @@ -27,7 +27,10 @@ rel="stylesheet" /> - + - -
    -
    -
    -
    -
    +
    + +
    +
    + CBS Nexora + +
    + Next-Generation Core Banking
    - + +
    +
    +
    +
    +
    +
    +
    + +
    +
    +
    + +

    Connecting to Secure Banking Gateway...

    +
    + + + + diff --git a/src/main.scss b/src/main.scss index c74b84479..a82ed67a8 100644 --- a/src/main.scss +++ b/src/main.scss @@ -81,66 +81,332 @@ // ===== Sub-menu Mega Panels (rendered in CDK overlay) ===== .deposits-mega-panel, -.hrm-mega-panel { - max-width: 700px !important; +.hrm-mega-panel, +.cashier-mega-panel, +.clerk-mega-panel, +.clearing-mega-panel, +.npa-mega-panel, +.advances-mega-panel, +.admin-mega-panel { + min-width: 680px !important; + max-width: 740px !important; +} + +.branch-ops-mega-panel, +.insurance-mega-panel, +.digital-mega-panel, +.ckyc-mega-panel { + min-width: 440px !important; + max-width: 480px !important; + overflow-x: hidden !important; } .share-mega-panel, .others-mega-panel { - max-width: 850px !important; + min-width: 780px !important; + max-width: 840px !important; } -.share-grid, -.others-grid { - grid-template-columns: repeat(4, 1fr) !important; -} - -.others-grid, -.npa-grid { - grid-template-columns: repeat(3, 1fr) !important; -} - -.npa-mega-panel, -.advances-mega-panel { +.reports-mega-panel { + min-width: 740px !important; max-width: 800px !important; + border-top: 2.5px solid #0369a1 !important; } -.advances-grid { - grid-template-columns: repeat(3, 1fr) !important; +.letter-mega-panel, +.reconciliation-mega-panel { + min-width: 900px !important; + max-width: 960px !important; +} + +/* ========================================================================== + Mega Menu Panel CDK Overlay Rules (Anchor naturally under triggers) + ========================================================================== */ +.cdk-overlay-pane:has(.mat-mdc-menu-panel) { + max-width: 96vw !important; +} + +/* Individual Top Accent Border for Mega Panels */ +.deposits-mega-panel, +.advances-mega-panel { + border-top: 2.5px solid #1074b9 !important; +} + +.cashier-mega-panel { + border-top: 2.5px solid #0284c7 !important; +} + +.clerk-mega-panel { + border-top: 2.5px solid #2563eb !important; +} + +.branch-ops-mega-panel { + border-top: 2.5px solid #4f46e5 !important; +} + +.clearing-mega-panel, +.reconciliation-mega-panel { + border-top: 2.5px solid #0d9488 !important; +} + +.hrm-mega-panel { + border-top: 2.5px solid #0284c7 !important; +} + +.admin-mega-panel, +.digital-mega-panel { + border-top: 2.5px solid #7c3aed !important; +} + +.share-mega-panel { + border-top: 2.5px solid #d97706 !important; +} + +.others-mega-panel { + border-top: 2.5px solid #0284c7 !important; +} + +.npa-mega-panel { + border-top: 2.5px solid #dc2626 !important; } .letter-mega-panel { - max-width: 1100px !important; + border-top: 2.5px solid #15803d !important; } -.letter-grid { - grid-template-columns: repeat(5, minmax(190px, 1fr)) !important; - min-width: 1040px; +.insurance-mega-panel { + border-top: 2.5px solid #c2410c !important; } -.reconciliation-mega-panel { - max-width: 1100px !important; +.ckyc-mega-panel { + border-top: 2.5px solid #be185d !important; } +/* Universal Mega Panel Grid styling inside Material Menu Overlays */ +.deposit-menu-grid { + display: grid !important; + gap: 4px 12px !important; + padding: 6px 10px !important; + box-sizing: border-box !important; + min-width: 0 !important; + width: 100% !important; + max-width: 100% !important; + overflow-x: hidden !important; +} + +/* Mega Panel Grids with exact column definitions */ +.deposit-menu-grid.cashier-grid, +.deposit-menu-grid.clerk-grid, +.deposit-menu-grid.clearing-grid, +.deposit-menu-grid.advances-grid, +.deposit-menu-grid.deposits-grid, +.deposit-menu-grid.hrm-grid, +.deposit-menu-grid.admin-grid, +.deposit-menu-grid.npa-grid, +.cashier-grid, +.clerk-grid, +.clearing-grid, +.advances-grid, +.deposits-grid, +.hrm-grid, +.admin-grid, +.npa-grid { + grid-template-columns: repeat(3, minmax(210px, 1fr)) !important; +} + +.deposit-menu-grid.reports-grid, +.reports-grid { + grid-template-columns: repeat(3, minmax(230px, 1fr)) !important; +} + +.deposit-menu-grid.branch-ops-grid, +.deposit-menu-grid.insurance-grid, +.deposit-menu-grid.digital-grid, +.deposit-menu-grid.ckyc-grid, +.branch-ops-grid, +.insurance-grid, +.digital-grid, +.ckyc-grid { + grid-template-columns: repeat(2, minmax(200px, 1fr)) !important; +} + +.deposit-menu-grid.share-grid, +.deposit-menu-grid.others-grid, +.share-grid, +.others-grid { + grid-template-columns: repeat(4, minmax(185px, 1fr)) !important; +} + +.deposit-menu-grid.letter-grid, +.deposit-menu-grid.reconciliation-grid, +.letter-grid, .reconciliation-grid { - grid-template-columns: repeat(5, minmax(190px, 1fr)) !important; - min-width: 1040px; + grid-template-columns: repeat(5, minmax(170px, 1fr)) !important; } -/* Admin Mega Menu — 3-column rectangular panel (Setup · Catalog · - Audit & Compliance). Reuses the deposit-mega-menu grid + group chrome - and retints the group-title accent to Admin purple. */ -.admin-mega-panel { - max-width: 760px !important; -} - -.admin-grid { - grid-template-columns: repeat(3, minmax(220px, 1fr)) !important; - min-width: 680px; +.deposit-menu-group { + display: flex !important; + flex-direction: column !important; + gap: 1px !important; + min-width: 0 !important; .deposit-group-title { - color: #7c3aed !important; - border-bottom-color: #7c3aed !important; + font-weight: 700 !important; + font-size: 9.5px !important; + text-transform: uppercase !important; + letter-spacing: 0.6px !important; + color: #1074b9 !important; + padding: 3px 6px !important; + margin-bottom: 2px !important; + border-bottom: 1.5px solid #1074b9 !important; + display: flex !important; + align-items: center !important; + line-height: 1.2 !important; + white-space: nowrap !important; + } +} + +/* Specific Accent Colors for Mega Panel Group Titles */ +.cashier-grid .deposit-group-title { + color: #0284c7 !important; + border-bottom-color: #0284c7 !important; +} + +.clerk-grid .deposit-group-title { + color: #2563eb !important; + border-bottom-color: #2563eb !important; +} + +.clearing-grid .deposit-group-title { + color: #0d9488 !important; + border-bottom-color: #0d9488 !important; +} + +.branch-ops-grid .deposit-group-title { + color: #4f46e5 !important; + border-bottom-color: #4f46e5 !important; +} + +.reports-grid .deposit-group-title { + color: #0369a1 !important; + border-bottom-color: #0369a1 !important; +} + +.insurance-grid .deposit-group-title { + color: #c2410c !important; + border-bottom-color: #c2410c !important; +} + +.digital-grid .deposit-group-title { + color: #7c3aed !important; + border-bottom-color: #7c3aed !important; +} + +.ckyc-grid .deposit-group-title { + color: #be185d !important; + border-bottom-color: #be185d !important; +} + +.admin-grid .deposit-group-title { + color: #7c3aed !important; + border-bottom-color: #7c3aed !important; +} + +.reconciliation-grid .deposit-group-title { + color: #0d9488 !important; + border-bottom-color: #0d9488 !important; +} + +/* Compact, RBI-Style Menu Item Overrides in CDK Overlays */ + +/* Compact, RBI-Style Menu Item Overrides for Navbar Mega Panels ONLY */ +.mat-mdc-menu-panel:has(.deposit-menu-grid) { + --mat-menu-item-label-text-size: 10.5px !important; + --mat-menu-item-label-text-line-height: 14px !important; + + border-radius: 8px !important; + box-shadow: + 0 10px 28px rgb(15 23 42 / 14%), + 0 2px 6px rgb(15 23 42 / 8%) !important; + border: 1px solid #d1d5db !important; + background: #fff !important; + max-height: 75vh !important; + overflow: hidden auto !important; + + .mat-mdc-menu-content { + padding: 4px 6px !important; + overflow-x: hidden !important; + } + + .deposit-menu-grid .mat-mdc-menu-item { + font-size: 10.5px !important; + font-weight: 500 !important; + height: 25px !important; + min-height: 25px !important; + line-height: 25px !important; + padding: 0 6px !important; + border-radius: 4px !important; + color: #334155 !important; + font-family: + 'IBM Plex Sans', + -apple-system, + BlinkMacSystemFont, + 'Segoe UI', + Roboto, + sans-serif !important; + transition: + background-color 0.12s ease, + color 0.12s ease !important; + white-space: nowrap !important; + overflow: hidden !important; + text-overflow: ellipsis !important; + min-width: 0 !important; + width: 100% !important; + box-sizing: border-box !important; + + .mat-mdc-menu-item-text, + .mdc-list-item__primary-text, + .mdc-list-item__content { + font-size: 10.5px !important; + font-weight: 500 !important; + line-height: 1.25 !important; + color: #334155 !important; + white-space: nowrap !important; + overflow: hidden !important; + text-overflow: ellipsis !important; + display: inline-flex !important; + align-items: center !important; + width: 100% !important; + } + + .menu-item-icon, + fa-icon, + i { + font-size: 10.5px !important; + width: 13px !important; + min-width: 13px !important; + text-align: center !important; + margin-right: 5px !important; + opacity: 0.8 !important; + flex-shrink: 0 !important; + } + + &:hover { + background-color: #f0f7ff !important; + color: #0d47a1 !important; + + .mat-mdc-menu-item-text, + .mdc-list-item__primary-text { + color: #0d47a1 !important; + } + + .menu-item-icon, + fa-icon, + i { + opacity: 1 !important; + color: #0d47a1 !important; + } + } } } @@ -2012,3 +2278,43 @@ mifosx-client-datatable-step * { } } } + +// ===== CBS Compact Enterprise Density Polish ===== +// Reduces excessive whitespace and oversized elements across the banking UI +.mat-mdc-table { + th.mat-mdc-header-cell { + padding: 8px 12px !important; + font-size: 11px !important; + font-weight: 700 !important; + letter-spacing: 0.04em !important; + } + + td.mat-mdc-cell { + padding: 6px 12px !important; + font-size: 12px !important; + line-height: 1.35 !important; + } + + tr.mat-mdc-row, + tr.mat-mdc-header-row { + height: 36px !important; + min-height: 36px !important; + } +} + +// Compact Material Cards +.mat-mdc-card { + padding: 12px 16px !important; +} + +// Breadcrumb & Page Titles Compact Spacing +.breadcrumb-container { + margin-bottom: 8px !important; + padding: 4px 0 !important; +} + +.mifosx-page-title, +.page-title { + font-size: 17px !important; + margin-bottom: 8px !important; +} diff --git a/src/theme/mifosx-theme.scss b/src/theme/mifosx-theme.scss index 212aecd0e..0654f165f 100644 --- a/src/theme/mifosx-theme.scss +++ b/src/theme/mifosx-theme.scss @@ -53,7 +53,7 @@ $mifosx-typography: mat.m2-define-typography-config( $button: $mifosx-typography-level ); -$mifosx-density: 0; +$mifosx-density: -1; $mifosx-app-theme: mat.m2-define-light-theme( (