From ff781e50e3112cf3a94352180e09f8452383040f Mon Sep 17 00:00:00 2001 From: Nitesh Yadav Date: Wed, 19 Aug 2026 10:39:52 +0530 Subject: [PATCH 01/64] feat(savings,deposits,current-accounts): streamline account activation flow Matches the backend change that stopped auto-depositing money at activation: removes the opening-balance field from every Activate screen (Savings, Current, FD, RD) since funding now happens as a separate deposit afterward, and removes the Digital-Facility step from every Create wizard since every facility starts disabled by default and gets turned on later through Edit Nominee & Features. Also merges Approve+Activate into a single action for Savings, Current, FD and RD accounts -- one click takes the account straight to Active instead of two separate screens. --- .../create-current-account.component.html | 33 ++----------------- .../create-current-account.component.ts | 4 +-- .../activate-current-account.component.html | 12 ------- .../activate-current-account.component.ts | 12 +------ .../approve-current-account.component.html | 1 + .../approve-current-account.component.ts | 16 +++++++-- .../current-account-actions.component.html | 4 +-- ...urrent-account-digital-step.component.html | 1 - .../current-account-digital-step.component.ts | 9 +++-- .../current-account-preview-step.component.ts | 9 +++-- ...rove-fixed-deposits-account.component.html | 1 + ...pprove-fixed-deposits-account.component.ts | 16 +++++++-- ...-recurring-deposits-account.component.html | 1 + ...ve-recurring-deposits-account.component.ts | 16 +++++++-- .../create-savings-account.component.html | 32 ++---------------- .../create-savings-account.component.ts | 4 +-- .../activate-savings-account.component.html | 16 ++------- .../activate-savings-account.component.ts | 15 +-------- .../approve-savings-account.component.html | 1 + .../approve-savings-account.component.ts | 14 ++++++-- .../saving-account-actions.component.html | 4 +-- ...avings-account-digital-step.component.html | 1 - .../savings-account-digital-step.component.ts | 9 +++-- .../savings-account-preview-step.component.ts | 10 +++--- 24 files changed, 85 insertions(+), 156 deletions(-) diff --git a/src/app/current-accounts/create-current-account/create-current-account.component.html b/src/app/current-accounts/create-current-account/create-current-account.component.html index 900db006a..7a79e519a 100644 --- a/src/app/current-accounts/create-current-account/create-current-account.component.html +++ b/src/app/current-accounts/create-current-account/create-current-account.component.html @@ -12,9 +12,7 @@

Create Current Account

-
- Pick a product, confirm currency, set digital channels and review before submit. -
+
Pick a product, confirm currency and review before submit.
@@ -78,34 +76,7 @@ - - - - - Digital - -
- @if (currentAccountProductTemplate) { - - } - -
- - - -
-
-
- - + diff --git a/src/app/current-accounts/create-current-account/create-current-account.component.ts b/src/app/current-accounts/create-current-account/create-current-account.component.ts index 9aa74d548..6c682a919 100644 --- a/src/app/current-accounts/create-current-account/create-current-account.component.ts +++ b/src/app/current-accounts/create-current-account/create-current-account.component.ts @@ -57,8 +57,8 @@ export class CreateCurrentAccountComponent { currentAccountProductTemplate: any; /** Currently visible wizard tab (0-indexed). */ selectedTabIndex = 0; - /** Total wizard tabs — Product, Currency, Digital, Preview. */ - private readonly totalTabs = 4; + /** Total wizard tabs — Product, Currency, Preview. */ + private readonly totalTabs = 3; @ViewChild(CurrentAccountDetailsStepComponent, { static: true }) currentAccountDetailsStep: CurrentAccountDetailsStepComponent; diff --git a/src/app/current-accounts/current-account-actions/activate-current-account/activate-current-account.component.html b/src/app/current-accounts/current-account-actions/activate-current-account/activate-current-account.component.html index 7b9e69031..19abc5fdc 100644 --- a/src/app/current-accounts/current-account-actions/activate-current-account/activate-current-account.component.html +++ b/src/app/current-accounts/current-account-actions/activate-current-account/activate-current-account.component.html @@ -30,18 +30,6 @@ } - - - Opening Balance - - ₹  - @if (minRequiredOpeningBalance) { - Minimum required: ₹{{ minRequiredOpeningBalance }} - } - @if (activateCurrentAccountForm.controls.openingBalance.hasError('min')) { - Opening balance must be at least ₹{{ minRequiredOpeningBalance ?? 0 }} - } - diff --git a/src/app/current-accounts/current-account-actions/activate-current-account/activate-current-account.component.ts b/src/app/current-accounts/current-account-actions/activate-current-account/activate-current-account.component.ts index 98701b637..ae85c6d92 100644 --- a/src/app/current-accounts/current-account-actions/activate-current-account/activate-current-account.component.ts +++ b/src/app/current-accounts/current-account-actions/activate-current-account/activate-current-account.component.ts @@ -7,7 +7,7 @@ */ /** Angular Imports */ -import { Component, OnInit, Input, inject } from '@angular/core'; +import { Component, OnInit, inject } from '@angular/core'; import { UntypedFormGroup, UntypedFormBuilder, Validators, ReactiveFormsModule } from '@angular/forms'; import { ActivatedRoute, Router, RouterLink } from '@angular/router'; import { Dates } from 'app/core/utils/dates'; @@ -36,8 +36,6 @@ export class ActivateCurrentAccountComponent implements OnInit { private router = inject(Router); private settingsService = inject(SettingsService); - @Input() minRequiredOpeningBalance: number | null = null; - /** Minimum date allowed. */ minDate = new Date(2000, 0, 1); /** Maximum date allowed. */ @@ -71,18 +69,10 @@ export class ActivateCurrentAccountComponent implements OnInit { * Creates the activate current account form. */ createActivateCurrentAccountForm() { - const minBalance = this.minRequiredOpeningBalance ?? 0; this.activateCurrentAccountForm = this.formBuilder.group({ activatedOnDate: [ '', Validators.required - ], - openingBalance: [ - minBalance, - [ - Validators.required, - Validators.min(minBalance) - ] ] }); } diff --git a/src/app/current-accounts/current-account-actions/approve-current-account/approve-current-account.component.html b/src/app/current-accounts/current-account-actions/approve-current-account/approve-current-account.component.html index 78322c36e..cfb7b6a87 100644 --- a/src/app/current-accounts/current-account-actions/approve-current-account/approve-current-account.component.html +++ b/src/app/current-accounts/current-account-actions/approve-current-account/approve-current-account.component.html @@ -10,6 +10,7 @@
+

This approves and activates the account in one step -- it goes straight to Active.

{{ 'labels.inputs.Approved On Date' | translate }} diff --git a/src/app/current-accounts/current-account-actions/approve-current-account/approve-current-account.component.ts b/src/app/current-accounts/current-account-actions/approve-current-account/approve-current-account.component.ts index ca8791c73..f71db9650 100644 --- a/src/app/current-accounts/current-account-actions/approve-current-account/approve-current-account.component.ts +++ b/src/app/current-accounts/current-account-actions/approve-current-account/approve-current-account.component.ts @@ -81,8 +81,9 @@ export class ApproveCurrentAccountComponent implements OnInit { } /** - * Submits the form and approves the current account, - * if successful redirects to the current account. + * Submits the form, approves the current account, then immediately activates it on the same + * date -- Approve and Activate are still two separate Fineract commands underneath (each shows + * up in the audit trail), but the officer only sees and clicks through one step. */ submit() { const approveCurrentAccountFormData = this.approveCurrentAccountForm.value; @@ -98,7 +99,16 @@ export class ApproveCurrentAccountComponent implements OnInit { locale }; this.currentAccountsService.executeCurrentAccountCommand(this.accountId, 'approve', data).subscribe(() => { - this.router.navigate(['../../transactions'], { relativeTo: this.route }); + const activationData = { + activatedOnDate: approveCurrentAccountFormData.approvedOnDate, + dateFormat, + locale + }; + this.currentAccountsService + .executeCurrentAccountCommand(this.accountId, 'activate', activationData) + .subscribe(() => { + this.router.navigate(['../../transactions'], { relativeTo: this.route }); + }); }); } } diff --git a/src/app/current-accounts/current-account-actions/current-account-actions.component.html b/src/app/current-accounts/current-account-actions/current-account-actions.component.html index b89775310..b1442a445 100644 --- a/src/app/current-accounts/current-account-actions/current-account-actions.component.html +++ b/src/app/current-accounts/current-account-actions/current-account-actions.component.html @@ -13,9 +13,7 @@ } @if (actions['Activate']) { - + } @if (actions['Undo Approval']) { diff --git a/src/app/current-accounts/current-account-stepper/current-account-digital-step/current-account-digital-step.component.html b/src/app/current-accounts/current-account-stepper/current-account-digital-step/current-account-digital-step.component.html index ae448a85a..f13275c06 100644 --- a/src/app/current-accounts/current-account-stepper/current-account-digital-step/current-account-digital-step.component.html +++ b/src/app/current-accounts/current-account-stepper/current-account-digital-step/current-account-digital-step.component.html @@ -15,7 +15,6 @@

Digital & Account Features

Toggle the facilities available for this account (as per RBI guidelines)

- FROM PRODUCT
diff --git a/src/app/current-accounts/current-account-stepper/current-account-digital-step/current-account-digital-step.component.ts b/src/app/current-accounts/current-account-stepper/current-account-digital-step/current-account-digital-step.component.ts index 6e5cc09d5..bcfb111da 100644 --- a/src/app/current-accounts/current-account-stepper/current-account-digital-step/current-account-digital-step.component.ts +++ b/src/app/current-accounts/current-account-stepper/current-account-digital-step/current-account-digital-step.component.ts @@ -52,12 +52,11 @@ export class CurrentAccountDigitalStepComponent { } } + // Note: a new account always starts with every digital facility off, even if the product has + // its own defaults configured -- those are enabled deliberately, one at a time, after the + // account is created and active, not inherited automatically at opening. @Input() - set currentAccountProductTemplate(value: any) { - if (!this.hasExistingAccount && value?.accountFacilityConfig) { - this.facilityConfig = { ...DEFAULT_FACILITY_CONFIG, ...value.accountFacilityConfig }; - } - } + currentAccountProductTemplate: any; get currentAccountDigital() { return { accountFacilityConfig: { ...this.facilityConfig } }; diff --git a/src/app/current-accounts/current-account-stepper/current-account-preview-step/current-account-preview-step.component.ts b/src/app/current-accounts/current-account-stepper/current-account-preview-step/current-account-preview-step.component.ts index dbddadaa1..0683c968d 100644 --- a/src/app/current-accounts/current-account-stepper/current-account-preview-step/current-account-preview-step.component.ts +++ b/src/app/current-accounts/current-account-stepper/current-account-preview-step/current-account-preview-step.component.ts @@ -136,12 +136,11 @@ export class CurrentAccountPreviewStepComponent implements OnChanges { return this.currentAccountSettingsForm?.getRawValue?.() || this.currentAccount || {}; } + /** Digital facility config as actually captured by a digital step in this wizard -- no fallback + * to the product's own defaults, since account creation no longer has a digital step at all and + * must not preview facilities the officer never actually chose. */ get digitalFacilityConfig(): any { - return ( - this.currentAccountDigital?.accountFacilityConfig ?? - this.currentAccountProductTemplate?.accountFacilityConfig ?? - null - ); + return this.currentAccountDigital?.accountFacilityConfig ?? null; } get hasDigitalSection(): boolean { diff --git a/src/app/deposits/fixed-deposits/fixed-deposits-account-actions/approve-fixed-deposits-account/approve-fixed-deposits-account.component.html b/src/app/deposits/fixed-deposits/fixed-deposits-account-actions/approve-fixed-deposits-account/approve-fixed-deposits-account.component.html index 81998d8c9..5547c958e 100644 --- a/src/app/deposits/fixed-deposits/fixed-deposits-account-actions/approve-fixed-deposits-account/approve-fixed-deposits-account.component.html +++ b/src/app/deposits/fixed-deposits/fixed-deposits-account-actions/approve-fixed-deposits-account/approve-fixed-deposits-account.component.html @@ -10,6 +10,7 @@ +

This approves and activates the account in one step -- it goes straight to Active.

{{ 'labels.inputs.Approved On Date' | translate }} diff --git a/src/app/deposits/fixed-deposits/fixed-deposits-account-actions/approve-fixed-deposits-account/approve-fixed-deposits-account.component.ts b/src/app/deposits/fixed-deposits/fixed-deposits-account-actions/approve-fixed-deposits-account/approve-fixed-deposits-account.component.ts index a7ae6c9b2..a517f98b5 100644 --- a/src/app/deposits/fixed-deposits/fixed-deposits-account-actions/approve-fixed-deposits-account/approve-fixed-deposits-account.component.ts +++ b/src/app/deposits/fixed-deposits/fixed-deposits-account-actions/approve-fixed-deposits-account/approve-fixed-deposits-account.component.ts @@ -81,8 +81,9 @@ export class ApproveFixedDepositsAccountComponent implements OnInit { } /** - * Submits the form and approves the fixed deposit account, - * if successful redirects to the fixed deposit account. + * Submits the form, approves the fixed deposit account, then immediately activates it on the + * same date -- Approve and Activate are still two separate Fineract commands underneath (each + * shows up in the audit trail), but the officer only sees and clicks through one step. */ submit() { const approveFixedDepositsAccountFormData = this.approveFixedDepositsAccountForm.value; @@ -98,7 +99,16 @@ export class ApproveFixedDepositsAccountComponent implements OnInit { locale }; this.fixedDepositsService.executeFixedDepositsAccountCommand(this.accountId, 'approve', data).subscribe(() => { - this.router.navigate(['../../'], { relativeTo: this.route }); + const activationData = { + activatedOnDate: approveFixedDepositsAccountFormData.approvedOnDate, + dateFormat, + locale + }; + this.fixedDepositsService + .executeFixedDepositsAccountCommand(this.accountId, 'activate', activationData) + .subscribe(() => { + this.router.navigate(['../../'], { relativeTo: this.route }); + }); }); } } diff --git a/src/app/deposits/recurring-deposits/recurring-deposits-account-actions/approve-recurring-deposits-account/approve-recurring-deposits-account.component.html b/src/app/deposits/recurring-deposits/recurring-deposits-account-actions/approve-recurring-deposits-account/approve-recurring-deposits-account.component.html index 381399099..1519ff51f 100644 --- a/src/app/deposits/recurring-deposits/recurring-deposits-account-actions/approve-recurring-deposits-account/approve-recurring-deposits-account.component.html +++ b/src/app/deposits/recurring-deposits/recurring-deposits-account-actions/approve-recurring-deposits-account/approve-recurring-deposits-account.component.html @@ -10,6 +10,7 @@ +

This approves and activates the account in one step -- it goes straight to Active.

{{ 'labels.inputs.Approved On Date' | translate }} 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 8dc8c427c..847aa80e5 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 @@ -81,8 +81,9 @@ export class ApproveRecurringDepositsAccountComponent implements OnInit { } /** - * Submits the form and approves the recurring deposit account, - * if successful redirects to the recurring deposit account. + * Submits the form, approves the recurring deposit account, then immediately activates it on + * the same date -- Approve and Activate are still two separate Fineract commands underneath + * (each shows up in the audit trail), but the officer only sees and clicks through one step. */ submit() { const approveRecurringDepositsAccountFormData = this.approveRecurringDepositsAccountForm.value; @@ -103,7 +104,16 @@ export class ApproveRecurringDepositsAccountComponent implements OnInit { this.recurringDepositsService .executeRecurringDepositsAccountCommand(this.accountId, 'approve', data) .subscribe(() => { - this.router.navigate(['../../'], { relativeTo: this.route }); + const activationData = { + activatedOnDate: approveRecurringDepositsAccountFormData.approvedOnDate, + dateFormat, + locale + }; + this.recurringDepositsService + .executeRecurringDepositsAccountCommand(this.accountId, 'activate', activationData) + .subscribe(() => { + this.router.navigate(['../../'], { relativeTo: this.route }); + }); }); } } diff --git a/src/app/savings/create-savings-account/create-savings-account.component.html b/src/app/savings/create-savings-account/create-savings-account.component.html index 7bdf5abed..dfbf67e7c 100644 --- a/src/app/savings/create-savings-account/create-savings-account.component.html +++ b/src/app/savings/create-savings-account/create-savings-account.component.html @@ -12,9 +12,7 @@

Create Savings Account

-
- Pick a product, confirm currency, set digital channels and review before submit. -
+
Pick a product, confirm currency and review before submit.
@@ -76,33 +74,7 @@
- - - - - Digital - -
- @if (savingsAccountProductTemplate) { - - - } - -
- - - -
-
-
- - + diff --git a/src/app/savings/create-savings-account/create-savings-account.component.ts b/src/app/savings/create-savings-account/create-savings-account.component.ts index e5d7c2989..2ab7e094e 100644 --- a/src/app/savings/create-savings-account/create-savings-account.component.ts +++ b/src/app/savings/create-savings-account/create-savings-account.component.ts @@ -62,8 +62,8 @@ export class CreateSavingsAccountComponent { savingsAccountProductTemplate: any; /** Currently visible wizard tab (0-indexed). */ selectedTabIndex = 0; - /** Total wizard tabs — Product, Currency, Digital, Preview. */ - private readonly totalTabs = 4; + /** Total wizard tabs — Product, Currency, Preview. */ + private readonly totalTabs = 3; /** Guards against duplicate submissions from a double-click while the create request is in flight. */ submitting = false; diff --git a/src/app/savings/saving-account-actions/activate-savings-account/activate-savings-account.component.html b/src/app/savings/saving-account-actions/activate-savings-account/activate-savings-account.component.html index c38a3f304..3d92c8391 100644 --- a/src/app/savings/saving-account-actions/activate-savings-account/activate-savings-account.component.html +++ b/src/app/savings/saving-account-actions/activate-savings-account/activate-savings-account.component.html @@ -14,7 +14,9 @@

Activate Savings Account

-

Set the activation date and opening balance to bring this account live.

+

+ Set the activation date to bring this account live -- funding happens afterwards, as a separate deposit. +

@@ -45,18 +47,6 @@ } - - - Opening Balance - - ₹  - @if (minRequiredOpeningBalance) { - Minimum required: ₹{{ minRequiredOpeningBalance }} - } - @if (activateSavingsAccountForm.controls.openingBalance.hasError('min')) { - Opening balance must be at least ₹{{ minRequiredOpeningBalance ?? 0 }} - } -
diff --git a/src/app/savings/saving-account-actions/activate-savings-account/activate-savings-account.component.ts b/src/app/savings/saving-account-actions/activate-savings-account/activate-savings-account.component.ts index e9add4f43..d7a41c94a 100644 --- a/src/app/savings/saving-account-actions/activate-savings-account/activate-savings-account.component.ts +++ b/src/app/savings/saving-account-actions/activate-savings-account/activate-savings-account.component.ts @@ -7,7 +7,7 @@ */ /** Angular Imports */ -import { Component, OnInit, Input, inject } from '@angular/core'; +import { Component, OnInit, inject } from '@angular/core'; import { UntypedFormGroup, UntypedFormBuilder, Validators, ReactiveFormsModule } from '@angular/forms'; import { ActivatedRoute, Router, RouterLink } from '@angular/router'; import { Dates } from 'app/core/utils/dates'; @@ -41,8 +41,6 @@ export class ActivateSavingsAccountComponent implements OnInit { private router = inject(Router); private settingsService = inject(SettingsService); - @Input() minRequiredOpeningBalance: number | null = null; - /** Minimum date allowed. */ minDate = new Date(2000, 0, 1); /** Maximum date allowed. */ @@ -76,18 +74,10 @@ export class ActivateSavingsAccountComponent implements OnInit { * Creates the activate savings account form. */ createActivateSavingsAccountForm() { - const minBalance = this.minRequiredOpeningBalance ?? 0; this.activateSavingsAccountForm = this.formBuilder.group({ activatedOnDate: [ '', Validators.required - ], - openingBalance: [ - minBalance, - [ - Validators.required, - Validators.min(minBalance) - ] ] }); } @@ -109,9 +99,6 @@ export class ActivateSavingsAccountComponent implements OnInit { dateFormat, locale }; - if (data.openingBalance === 0 || data.openingBalance === null) { - delete data.openingBalance; - } this.savingsService.executeSavingsAccountCommand(this.accountId, 'activate', data).subscribe(() => { this.router.navigate(['../../transactions'], { relativeTo: this.route }); }); diff --git a/src/app/savings/saving-account-actions/approve-savings-account/approve-savings-account.component.html b/src/app/savings/saving-account-actions/approve-savings-account/approve-savings-account.component.html index 8f61d900d..b6d897fb1 100644 --- a/src/app/savings/saving-account-actions/approve-savings-account/approve-savings-account.component.html +++ b/src/app/savings/saving-account-actions/approve-savings-account/approve-savings-account.component.html @@ -10,6 +10,7 @@ +

This approves and activates the account in one step -- it goes straight to Active.

{{ 'labels.inputs.Approved On Date' | translate }} diff --git a/src/app/savings/saving-account-actions/approve-savings-account/approve-savings-account.component.ts b/src/app/savings/saving-account-actions/approve-savings-account/approve-savings-account.component.ts index 167f6cb2c..d1654136d 100644 --- a/src/app/savings/saving-account-actions/approve-savings-account/approve-savings-account.component.ts +++ b/src/app/savings/saving-account-actions/approve-savings-account/approve-savings-account.component.ts @@ -81,8 +81,9 @@ export class ApproveSavingsAccountComponent implements OnInit { } /** - * Submits the form and approves the saving account, - * if successful redirects to the saving account. + * Submits the form, approves the saving account, then immediately activates it on the same + * date -- Approve and Activate are still two separate Fineract commands underneath (each shows + * up in the audit trail), but the officer only sees and clicks through one step. */ submit() { const approveSavingsAccountFormData = this.approveSavingsAccountForm.value; @@ -98,7 +99,14 @@ export class ApproveSavingsAccountComponent implements OnInit { locale }; this.savingsService.executeSavingsAccountCommand(this.accountId, 'approve', data).subscribe(() => { - this.router.navigate(['../../transactions'], { relativeTo: this.route }); + const activationData = { + activatedOnDate: approveSavingsAccountFormData.approvedOnDate, + dateFormat, + locale + }; + this.savingsService.executeSavingsAccountCommand(this.accountId, 'activate', activationData).subscribe(() => { + this.router.navigate(['../../transactions'], { relativeTo: this.route }); + }); }); } } diff --git a/src/app/savings/saving-account-actions/saving-account-actions.component.html b/src/app/savings/saving-account-actions/saving-account-actions.component.html index 45d3df9ca..aaf4f6fc4 100644 --- a/src/app/savings/saving-account-actions/saving-account-actions.component.html +++ b/src/app/savings/saving-account-actions/saving-account-actions.component.html @@ -13,9 +13,7 @@ } @if (actions['Activate']) { - + } @if (actions['Undo Approval']) { diff --git a/src/app/savings/savings-account-stepper/savings-account-digital-step/savings-account-digital-step.component.html b/src/app/savings/savings-account-stepper/savings-account-digital-step/savings-account-digital-step.component.html index 5b0970996..8229ab305 100644 --- a/src/app/savings/savings-account-stepper/savings-account-digital-step/savings-account-digital-step.component.html +++ b/src/app/savings/savings-account-stepper/savings-account-digital-step/savings-account-digital-step.component.html @@ -15,7 +15,6 @@

Digital & Account Features

Toggle the facilities available for this account (as per RBI guidelines)

- FROM PRODUCT
diff --git a/src/app/savings/savings-account-stepper/savings-account-digital-step/savings-account-digital-step.component.ts b/src/app/savings/savings-account-stepper/savings-account-digital-step/savings-account-digital-step.component.ts index 30da85e32..6add60514 100644 --- a/src/app/savings/savings-account-stepper/savings-account-digital-step/savings-account-digital-step.component.ts +++ b/src/app/savings/savings-account-stepper/savings-account-digital-step/savings-account-digital-step.component.ts @@ -50,12 +50,11 @@ export class SavingsAccountDigitalStepComponent { } } + // Note: a new account always starts with every digital facility off, even if the product has + // its own defaults configured -- those are enabled deliberately, one at a time, after the + // account is created and active, not inherited automatically at opening. @Input() - set savingsAccountProductTemplate(value: any) { - if (!this.hasExistingAccount && value?.accountFacilityConfig) { - this.facilityConfig = { ...DEFAULT_FACILITY_CONFIG, ...value.accountFacilityConfig }; - } - } + savingsAccountProductTemplate: any; get savingsAccountDigital() { return { accountFacilityConfig: { ...this.facilityConfig } }; diff --git a/src/app/savings/savings-account-stepper/savings-account-preview-step/savings-account-preview-step.component.ts b/src/app/savings/savings-account-stepper/savings-account-preview-step/savings-account-preview-step.component.ts index f8837abfc..32b227bcd 100644 --- a/src/app/savings/savings-account-stepper/savings-account-preview-step/savings-account-preview-step.component.ts +++ b/src/app/savings/savings-account-stepper/savings-account-preview-step/savings-account-preview-step.component.ts @@ -124,13 +124,11 @@ export class SavingsAccountPreviewStepComponent implements OnChanges { } } - /** Returns user-modified digital config (from digital step), falling back to product template */ + /** Digital facility config as actually captured by a digital step in this wizard -- no fallback + * to the product's own defaults, since account creation no longer has a digital step at all and + * must not preview facilities the officer never actually chose. */ get digitalFacilityConfig(): any { - return ( - this.savingsAccountDigital?.accountFacilityConfig ?? - this.savingsAccountProductTemplate?.accountFacilityConfig ?? - null - ); + return this.savingsAccountDigital?.accountFacilityConfig ?? null; } get hasDigitalSection(): boolean { From b953afe2da8866e182bd43e8ca3bba237b01ba54 Mon Sep 17 00:00:00 2001 From: Nitesh Yadav Date: Wed, 19 Aug 2026 10:43:00 +0530 Subject: [PATCH 02/64] fix(clients): remove leftover unresolved git conflict markers on Last Name field An unresolved git stash conflict had been committed as-is, leaving literal <<<<<<< Updated upstream / ======= / >>>>>>> Stashed changes markers inside the template. Angular rendered them as plain text right in the create-client form. Kept the blur-triggered capitalizeControl() version (matches First/Middle Name) and dropped the now-unused formatName() left behind by the other side of the conflict. --- .../client-general-step.component.html | 4 ---- .../client-general-step.component.ts | 22 ------------------- 2 files changed, 26 deletions(-) diff --git a/src/app/clients/client-stepper/client-general-step/client-general-step.component.html b/src/app/clients/client-stepper/client-general-step/client-general-step.component.html index f5ad0590e..d892f9368 100644 --- a/src/app/clients/client-stepper/client-general-step/client-general-step.component.html +++ b/src/app/clients/client-stepper/client-general-step/client-general-step.component.html @@ -263,7 +263,6 @@ {{ 'labels.inputs.Last Name' | translate }} person - <<<<<<< Updated upstream - ======= - - >>>>>>> Stashed changes @if (createClientForm.controls.lastname.hasError('required')) { Last name is required } @else if (createClientForm.controls.lastname.hasError('pattern')) { diff --git a/src/app/clients/client-stepper/client-general-step/client-general-step.component.ts b/src/app/clients/client-stepper/client-general-step/client-general-step.component.ts index 8ea5bf5e5..910651bb5 100644 --- a/src/app/clients/client-stepper/client-general-step/client-general-step.component.ts +++ b/src/app/clients/client-stepper/client-general-step/client-general-step.component.ts @@ -2590,28 +2590,6 @@ export class ClientGeneralStepComponent implements OnInit, OnDestroy, OnChanges dormantFlag: 'ACTIVE' }; } - - /** - * Automatically capitalizes the first letter of each word and lowercases the rest. - * e.g., "sri rAm" -> "Sri Ram" - */ - formatName(controlName: string): void { - const control = this.createClientForm.get(controlName); - if (!control || typeof control.value !== 'string') return; - - const val = control.value; - const formatted = val - .split(' ') - .map((word: string) => { - if (!word) return ''; - return word.charAt(0).toUpperCase() + word.slice(1).toLowerCase(); - }) - .join(' '); - - if (formatted !== val) { - control.setValue(formatted, { emitEvent: false }); - } - } } /** From e58fd3c79a82add03b6614d07b598506c0857008 Mon Sep 17 00:00:00 2001 From: Nitesh Yadav Date: Thu, 20 Aug 2026 11:05:25 +0530 Subject: [PATCH 03/64] feat(deposits): sync linked account with maturity terms, add deposit certificate printing Fixed and Recurring Deposit wizards now bidirectionally sync the Product tab's linked-account selection with the new Terms tab's Transfer-to-Savings account, disable the Withdraw Deposit maturity instruction end-to-end, and card-style the Transfer to Savings picker to match the existing linked-account UI. Adds a printable FD/RD deposit certificate from the account view page, following the existing letter-mgmt certificate pattern. Co-Authored-By: Claude Sonnet 5 --- ...reate-fixed-deposit-account.component.html | 37 +- .../create-fixed-deposit-account.component.ts | 11 +- .../edit-fixed-deposit-account.component.html | 37 +- .../edit-fixed-deposit-account.component.ts | 9 +- ...-deposit-account-details-step.component.ts | 34 +- ...account-maturity-terms-step.component.html | 108 ++++ ...account-maturity-terms-step.component.scss | 152 ++++++ ...t-account-maturity-terms-step.component.ts | 117 ++++ ...eposit-account-preview-step.component.html | 35 ++ ...-deposit-account-preview-step.component.ts | 21 +- .../fixed-deposit-account-view.component.html | 10 + .../fixed-deposit-account-view.component.ts | 15 + ...-recurring-deposits-account.component.html | 37 +- ...te-recurring-deposits-account.component.ts | 11 +- ...t-recurring-deposit-account.component.html | 37 +- ...dit-recurring-deposit-account.component.ts | 11 +- ...deposits-account-details-step.component.ts | 35 +- ...account-maturity-terms-step.component.html | 108 ++++ ...account-maturity-terms-step.component.scss | 152 ++++++ ...s-account-maturity-terms-step.component.ts | 116 ++++ ...posits-account-preview-step.component.html | 35 ++ ...deposits-account-preview-step.component.ts | 24 +- ...eposits-account-settings-step.component.ts | 19 + ...rring-deposits-account-view.component.html | 13 + ...curring-deposits-account-view.component.ts | 15 + ...posit-product-settings-step.component.html | 27 + ...posit-product-settings-step.component.scss | 9 + ...deposit-product-settings-step.component.ts | 11 + ...posit-product-settings-step.component.html | 27 + ...posit-product-settings-step.component.scss | 9 + ...deposit-product-settings-step.component.ts | 8 + .../deposit-certificate-dialog.component.html | 160 ++++++ .../deposit-certificate-dialog.component.scss | 512 ++++++++++++++++++ .../deposit-certificate-dialog.component.ts | 132 +++++ 34 files changed, 2079 insertions(+), 15 deletions(-) create mode 100644 src/app/deposits/fixed-deposits/fixed-deposit-account-stepper/fixed-deposit-account-maturity-terms-step/fixed-deposit-account-maturity-terms-step.component.html create mode 100644 src/app/deposits/fixed-deposits/fixed-deposit-account-stepper/fixed-deposit-account-maturity-terms-step/fixed-deposit-account-maturity-terms-step.component.scss create mode 100644 src/app/deposits/fixed-deposits/fixed-deposit-account-stepper/fixed-deposit-account-maturity-terms-step/fixed-deposit-account-maturity-terms-step.component.ts create mode 100644 src/app/deposits/recurring-deposits/recurring-deposits-account-stepper/recurring-deposits-account-maturity-terms-step/recurring-deposits-account-maturity-terms-step.component.html create mode 100644 src/app/deposits/recurring-deposits/recurring-deposits-account-stepper/recurring-deposits-account-maturity-terms-step/recurring-deposits-account-maturity-terms-step.component.scss create mode 100644 src/app/deposits/recurring-deposits/recurring-deposits-account-stepper/recurring-deposits-account-maturity-terms-step/recurring-deposits-account-maturity-terms-step.component.ts create mode 100644 src/app/shared/deposit-certificate-dialog/deposit-certificate-dialog.component.html create mode 100644 src/app/shared/deposit-certificate-dialog/deposit-certificate-dialog.component.scss create mode 100644 src/app/shared/deposit-certificate-dialog/deposit-certificate-dialog.component.ts diff --git a/src/app/deposits/fixed-deposits/create-fixed-deposit-account/create-fixed-deposit-account.component.html b/src/app/deposits/fixed-deposits/create-fixed-deposit-account/create-fixed-deposit-account.component.html index a0b81c891..d0ac260e9 100644 --- a/src/app/deposits/fixed-deposits/create-fixed-deposit-account/create-fixed-deposit-account.component.html +++ b/src/app/deposits/fixed-deposits/create-fixed-deposit-account/create-fixed-deposit-account.component.html @@ -33,6 +33,7 @@ @@ -107,7 +108,40 @@
- + + + + + Terms + +
+ @if (fixedDepositsAccountProductTemplate) { + + } + +
+ + + +
+
+
+ + @@ -121,6 +155,7 @@ [fixedDepositAccountData]="fixedDepositAccount" [selectedTenure]="selectedTenure" [fdNominees]="fixedDepositAccountCurrencyStep?.nominees ?? []" + [closureSettings]="fixedDepositAccountMaturityTermsStep?.closureSettings ?? {}" [hideButtons]="true" (submitEvent)="submit()" > diff --git a/src/app/deposits/fixed-deposits/create-fixed-deposit-account/create-fixed-deposit-account.component.ts b/src/app/deposits/fixed-deposits/create-fixed-deposit-account/create-fixed-deposit-account.component.ts index 99e4fcfef..d076cbf67 100644 --- a/src/app/deposits/fixed-deposits/create-fixed-deposit-account/create-fixed-deposit-account.component.ts +++ b/src/app/deposits/fixed-deposits/create-fixed-deposit-account/create-fixed-deposit-account.component.ts @@ -24,6 +24,7 @@ import { MatTabsModule } from '@angular/material/tabs'; import { FaIconComponent } from '@fortawesome/angular-fontawesome'; import { FixedDepositAccountInterestRateChartStepComponent } from '../fixed-deposit-account-stepper/fixed-deposit-account-interest-rate-chart-step/fixed-deposit-account-interest-rate-chart-step.component'; import { FixedDepositAccountPreviewStepComponent } from '../fixed-deposit-account-stepper/fixed-deposit-account-preview-step/fixed-deposit-account-preview-step.component'; +import { FixedDepositAccountMaturityTermsStepComponent } from '../fixed-deposit-account-stepper/fixed-deposit-account-maturity-terms-step/fixed-deposit-account-maturity-terms-step.component'; import { STANDALONE_SHARED_IMPORTS } from 'app/standalone-shared.module'; /** @@ -40,6 +41,7 @@ import { STANDALONE_SHARED_IMPORTS } from 'app/standalone-shared.module'; FixedDepositAccountDetailsStepComponent, FixedDepositAccountInterestRateChartStepComponent, FixedDepositAccountCurrencyStepComponent, + FixedDepositAccountMaturityTermsStepComponent, FixedDepositAccountPreviewStepComponent ] }) @@ -56,6 +58,10 @@ export class CreateFixedDepositAccountComponent { fixedDepositsAccountDetailsStep: FixedDepositAccountDetailsStepComponent; @ViewChild(FixedDepositAccountCurrencyStepComponent) fixedDepositAccountCurrencyStep: FixedDepositAccountCurrencyStepComponent; + @ViewChild(FixedDepositAccountPreviewStepComponent) + fixedDepositAccountPreviewStep: FixedDepositAccountPreviewStepComponent; + @ViewChild(FixedDepositAccountMaturityTermsStepComponent) + fixedDepositAccountMaturityTermsStep: FixedDepositAccountMaturityTermsStepComponent; /** Fixed Deposits Account Template */ fixedDepositsAccountTemplate: any; @@ -63,8 +69,8 @@ export class CreateFixedDepositAccountComponent { fixedDepositsAccountProductTemplate: any; /** Currently visible wizard tab (0-indexed). */ selectedTabIndex = 0; - /** Total wizard tabs — Product, Currency, Chart, Preview. */ - private readonly totalTabs = 4; + /** Total wizard tabs — Product, Currency, Chart, Terms, Preview. */ + private readonly totalTabs = 5; /** Active CBS base interest rate versions for the selected product's FD type */ cbsBaseRates: any[] = []; /** Tenure selected from the interest rate chart step */ @@ -243,6 +249,7 @@ export class CreateFixedDepositAccountComponent { ...this.fixedDepositAccount, ...terms, ...settings, + ...(this.fixedDepositAccountMaturityTermsStep?.closureSettings ?? {}), ...(this.selectedTenure ? { depositPeriod: this.selectedTenure.depositPeriod, diff --git a/src/app/deposits/fixed-deposits/edit-fixed-deposit-account/edit-fixed-deposit-account.component.html b/src/app/deposits/fixed-deposits/edit-fixed-deposit-account/edit-fixed-deposit-account.component.html index 8cbf3f2df..c7e98d743 100644 --- a/src/app/deposits/fixed-deposits/edit-fixed-deposit-account/edit-fixed-deposit-account.component.html +++ b/src/app/deposits/fixed-deposits/edit-fixed-deposit-account/edit-fixed-deposit-account.component.html @@ -35,6 +35,7 @@ @@ -111,7 +112,40 @@ - + + + + + Terms + +
+ @if (fixedDepositsAccountProductTemplate) { + + } + +
+ + + +
+
+
+ + @@ -125,6 +159,7 @@ [fixedDepositAccountData]="fixedDepositAccount" [selectedTenure]="selectedTenure" [fdNominees]="fixedDepositAccountCurrencyStep?.nominees ?? []" + [closureSettings]="fixedDepositAccountMaturityTermsStep?.closureSettings ?? {}" [hideButtons]="true" (submitEvent)="submit()" > diff --git a/src/app/deposits/fixed-deposits/edit-fixed-deposit-account/edit-fixed-deposit-account.component.ts b/src/app/deposits/fixed-deposits/edit-fixed-deposit-account/edit-fixed-deposit-account.component.ts index 7399b0efa..41c63cb0f 100644 --- a/src/app/deposits/fixed-deposits/edit-fixed-deposit-account/edit-fixed-deposit-account.component.ts +++ b/src/app/deposits/fixed-deposits/edit-fixed-deposit-account/edit-fixed-deposit-account.component.ts @@ -25,6 +25,7 @@ import { MatTabsModule } from '@angular/material/tabs'; import { FaIconComponent } from '@fortawesome/angular-fontawesome'; import { FixedDepositAccountInterestRateChartStepComponent } from '../fixed-deposit-account-stepper/fixed-deposit-account-interest-rate-chart-step/fixed-deposit-account-interest-rate-chart-step.component'; import { FixedDepositAccountPreviewStepComponent } from '../fixed-deposit-account-stepper/fixed-deposit-account-preview-step/fixed-deposit-account-preview-step.component'; +import { FixedDepositAccountMaturityTermsStepComponent } from '../fixed-deposit-account-stepper/fixed-deposit-account-maturity-terms-step/fixed-deposit-account-maturity-terms-step.component'; import { STANDALONE_SHARED_IMPORTS } from 'app/standalone-shared.module'; /** @@ -41,6 +42,7 @@ import { STANDALONE_SHARED_IMPORTS } from 'app/standalone-shared.module'; FixedDepositAccountDetailsStepComponent, FixedDepositAccountInterestRateChartStepComponent, FixedDepositAccountCurrencyStepComponent, + FixedDepositAccountMaturityTermsStepComponent, FixedDepositAccountPreviewStepComponent ] }) @@ -58,6 +60,10 @@ export class EditFixedDepositAccountComponent { fixedDepositsAccountDetailsStep: FixedDepositAccountDetailsStepComponent; @ViewChild(FixedDepositAccountCurrencyStepComponent) fixedDepositAccountCurrencyStep: FixedDepositAccountCurrencyStepComponent; + @ViewChild(FixedDepositAccountPreviewStepComponent) + fixedDepositAccountPreviewStep: FixedDepositAccountPreviewStepComponent; + @ViewChild(FixedDepositAccountMaturityTermsStepComponent) + fixedDepositAccountMaturityTermsStep: FixedDepositAccountMaturityTermsStepComponent; /** Full account data + template from resolver */ fixedDepositsAccountAndTemplate: any; @@ -65,7 +71,7 @@ export class EditFixedDepositAccountComponent { fixedDepositsAccountProductTemplate: any; /** Currently visible wizard tab (0-indexed). */ selectedTabIndex = 0; - private readonly totalTabs = 4; + private readonly totalTabs = 5; /** Active CBS base interest rate versions for the selected product's FD type */ cbsBaseRates: any[] = []; /** Tenure selected from the interest rate chart step */ @@ -236,6 +242,7 @@ export class EditFixedDepositAccountComponent { ...this.fixedDepositAccount, ...terms, ...settings, + ...(this.fixedDepositAccountMaturityTermsStep?.closureSettings ?? {}), ...(this.selectedTenure ? { depositPeriod: this.selectedTenure.depositPeriod, diff --git a/src/app/deposits/fixed-deposits/fixed-deposit-account-stepper/fixed-deposit-account-details-step/fixed-deposit-account-details-step.component.ts b/src/app/deposits/fixed-deposits/fixed-deposit-account-stepper/fixed-deposit-account-details-step/fixed-deposit-account-details-step.component.ts index c4a42c210..7aa178b82 100644 --- a/src/app/deposits/fixed-deposits/fixed-deposit-account-stepper/fixed-deposit-account-details-step/fixed-deposit-account-details-step.component.ts +++ b/src/app/deposits/fixed-deposits/fixed-deposit-account-stepper/fixed-deposit-account-details-step/fixed-deposit-account-details-step.component.ts @@ -7,7 +7,17 @@ */ /** Angular Imports */ -import { Component, OnInit, Input, Output, EventEmitter, inject, ChangeDetectorRef } from '@angular/core'; +import { + Component, + OnInit, + OnChanges, + SimpleChanges, + Input, + Output, + EventEmitter, + inject, + ChangeDetectorRef +} from '@angular/core'; import { UntypedFormGroup, UntypedFormBuilder, Validators, FormsModule } from '@angular/forms'; import { SettingsService } from 'app/settings/settings.service'; import { Dates } from 'app/core/utils/dates'; @@ -43,7 +53,7 @@ import { IntroducerSearchComponent } from 'app/shared/introducer-search/introduc IntroducerSearchComponent ] }) -export class FixedDepositAccountDetailsStepComponent implements OnInit { +export class FixedDepositAccountDetailsStepComponent implements OnInit, OnChanges { private formBuilder = inject(UntypedFormBuilder); private fixedDepositsService = inject(FixedDepositsService); private settingsService = inject(SettingsService); @@ -56,6 +66,9 @@ export class FixedDepositAccountDetailsStepComponent implements OnInit { /** Fixed Deposits Account Template */ @Input() fixedDepositsAccountTemplate: any; @Input() hideButtons = false; + /** Linked-account id picked elsewhere in the wizard (e.g. the Terms step's Transfer to Savings + * account) -- pre-fills this card so the officer doesn't have to pick the same account twice. */ + @Input() externalLinkedAccountId: number | null = null; /** Minimum date allowed. */ minDate = new Date(2000, 0, 1); @@ -222,6 +235,23 @@ export class FixedDepositAccountDetailsStepComponent implements OnInit { this.dalSearch = ''; } + ngOnChanges(changes: SimpleChanges): void { + if (!changes.externalLinkedAccountId || this.selectedLinkedAccount) { + return; + } + const id = this.externalLinkedAccountId; + if (!id) return; + const savings = this.dalAllSavings.find((a: any) => a.id === id); + const current = this.dalAllCurrent.find((a: any) => a.id === id); + if (savings) { + this.dalSelectedType = 'SAVINGS'; + this.selectedLinkedAccount = savings; + } else if (current) { + this.dalSelectedType = 'CURRENT'; + this.selectedLinkedAccount = current; + } + } + private loadClientAccounts(): void { const clientId = String(this.fixedDepositsAccountTemplate?.clientId); if (!clientId) return; diff --git a/src/app/deposits/fixed-deposits/fixed-deposit-account-stepper/fixed-deposit-account-maturity-terms-step/fixed-deposit-account-maturity-terms-step.component.html b/src/app/deposits/fixed-deposits/fixed-deposit-account-stepper/fixed-deposit-account-maturity-terms-step/fixed-deposit-account-maturity-terms-step.component.html new file mode 100644 index 000000000..17d6f00d7 --- /dev/null +++ b/src/app/deposits/fixed-deposits/fixed-deposit-account-stepper/fixed-deposit-account-maturity-terms-step/fixed-deposit-account-maturity-terms-step.component.html @@ -0,0 +1,108 @@ + + + +

{{ 'labels.heading.For Pre-mature closure' | translate }}

+ + + {{ 'labels.inputs.Maturity Instructions' | translate }} + + @for (maturityInstruction of maturityInstructionOptions; track maturityInstruction) { + + {{ maturityInstruction.value | translateKey: 'catalogs' }} + + } + + + + @if (closureSettingsForm.contains('transferToSavingsId')) { +
+
+
+
+ savings +
+
+

{{ 'labels.inputs.Transfer to savings account' | translate }}

+ Maturity proceeds will be credited to this account +
+
+
+ +
+ @if (selectedTransferAccount) { +
+
+ + savings + Savings + +
+ {{ selectedTransferAccount.accountNo }} + {{ selectedTransferAccount.savingsProductName }} +
+ @if (selectedTransferAccount.summary?.accountBalance != null) { +
+ Balance + + {{ selectedTransferAccount.currency?.code }} + {{ selectedTransferAccount.summary.accountBalance | number: '1.2-2' }} + +
+ } +
+ +
+ } @else { + + {{ 'labels.inputs.Transfer to savings account' | translate }} + + @for (account of savingsAccountsData; track account) { + + ({{ account.accountNo }}) {{ account.savingsProductName }} + + } + + Transfer to savings account is required + + } +
+
+ } + + + {{ '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' }} + + } + + + } + diff --git a/src/app/deposits/fixed-deposits/fixed-deposit-account-stepper/fixed-deposit-account-maturity-terms-step/fixed-deposit-account-maturity-terms-step.component.scss b/src/app/deposits/fixed-deposits/fixed-deposit-account-stepper/fixed-deposit-account-maturity-terms-step/fixed-deposit-account-maturity-terms-step.component.scss new file mode 100644 index 000000000..1f807bcce --- /dev/null +++ b/src/app/deposits/fixed-deposits/fixed-deposit-account-stepper/fixed-deposit-account-maturity-terms-step/fixed-deposit-account-maturity-terms-step.component.scss @@ -0,0 +1,152 @@ +/** + * Copyright since 2025 Mifos Initiative + * + * This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. + */ + +// ── Transfer-to-Savings account card (mirrors the Deposit Account Link card) ── + +.dal-card { + margin-top: 16px; + border: 1px solid #e2e8f0; + border-radius: 12px; + background: #fff; + overflow: hidden; + + &__header { + display: flex; + align-items: center; + gap: 14px; + padding: 16px 24px; + border-bottom: 1px solid #f1f5f9; + background: linear-gradient(135deg, #f0f9ff 0%, #e0f2fe 100%); + } + + &__header-left { + display: flex; + align-items: center; + gap: 14px; + } + + &__icon { + width: 44px; + height: 44px; + border-radius: 10px; + background: #0ea5e9; + display: flex; + align-items: center; + justify-content: center; + color: #fff; + } + + &__title { + margin: 0; + font-size: 15px; + font-weight: 700; + color: #0c4a6e; + } + + &__sub { + display: block; + font-size: 12px; + color: #64748b; + margin-top: 2px; + } + + &__body { + padding: 20px 24px; + } +} + +.dal-selected { + display: flex; + align-items: center; + gap: 16px; + padding: 16px 20px; + border: 2px solid #0ea5e9; + border-radius: 12px; + background: #f0f9ff; + + &__info { + display: flex; + align-items: center; + gap: 14px; + flex: 1; + flex-wrap: wrap; + } + + &__details { + display: flex; + flex-direction: column; + gap: 2px; + flex: 1; + } + + &__accno { + font-size: 15px; + font-weight: 700; + color: #0c4a6e; + font-family: monospace; + } + + &__product { + font-size: 12px; + color: #64748b; + } + + &__balance { + display: flex; + flex-direction: column; + align-items: flex-end; + gap: 2px; + } + + &__bal-label { + font-size: 10px; + color: #94a3b8; + text-transform: uppercase; + letter-spacing: 0.5px; + } + + &__bal-amount { + font-size: 15px; + font-weight: 700; + color: #0c4a6e; + } +} + +.dal-change-btn { + white-space: nowrap; +} + +.dal-type-badge { + display: inline-flex; + align-items: center; + gap: 4px; + padding: 4px 12px; + border-radius: 999px; + font-size: 12px; + font-weight: 700; + white-space: nowrap; + background: #f1f5f9; + color: #475569; + border: 1px solid #e2e8f0; + + mat-icon { + font-size: 14px; + width: 14px; + height: 14px; + } + + &--savings { + background: #eff6ff; + color: #1d4ed8; + border-color: #bfdbfe; + + mat-icon { + color: #2563eb; + } + } +} diff --git a/src/app/deposits/fixed-deposits/fixed-deposit-account-stepper/fixed-deposit-account-maturity-terms-step/fixed-deposit-account-maturity-terms-step.component.ts b/src/app/deposits/fixed-deposits/fixed-deposit-account-stepper/fixed-deposit-account-maturity-terms-step/fixed-deposit-account-maturity-terms-step.component.ts new file mode 100644 index 000000000..bce8468a1 --- /dev/null +++ b/src/app/deposits/fixed-deposits/fixed-deposit-account-stepper/fixed-deposit-account-maturity-terms-step/fixed-deposit-account-maturity-terms-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/. + */ + +/** Angular Imports */ +import { Component, Input, OnChanges, SimpleChanges, inject } from '@angular/core'; +import { UntypedFormGroup, UntypedFormBuilder, UntypedFormControl, Validators } from '@angular/forms'; +import { MatCheckbox } from '@angular/material/checkbox'; +import { MatIcon } from '@angular/material/icon'; +import { STANDALONE_SHARED_IMPORTS } from 'app/standalone-shared.module'; + +/** + * Fixed Deposit Account Maturity Terms Step -- what happens to this account at maturity + * (Withdraw / Transfer to Savings / Reinvest) and the pre-mature-closure penalty, decided + * per account rather than silently inherited from the product. + */ +@Component({ + selector: 'mifosx-fixed-deposit-account-maturity-terms-step', + templateUrl: './fixed-deposit-account-maturity-terms-step.component.html', + styleUrls: ['./fixed-deposit-account-maturity-terms-step.component.scss'], + imports: [ + ...STANDALONE_SHARED_IMPORTS, + MatCheckbox, + MatIcon + ] +}) +export class FixedDepositAccountMaturityTermsStepComponent implements OnChanges { + private formBuilder = inject(UntypedFormBuilder); + + @Input() fixedDepositsAccountTemplate: any; + @Input() fixedDepositsAccountProductTemplate: any; + /** Account already linked elsewhere in the wizard (the Details step's Deposit Account Link) -- + * pre-fills Transfer to Savings so the officer doesn't have to pick the same account twice. */ + @Input() linkedSavingsAccountId: number | null = null; + + closureSettingsForm: UntypedFormGroup; + maturityInstructionOptions: any[] = []; + savingsAccountsData: any[] = []; + preClosurePenalInterestOnTypeData: any[] = []; + + constructor() { + this.closureSettingsForm = this.formBuilder.group({ + maturityInstructionId: [''], + preClosurePenalApplicable: [false], + preClosurePenalInterest: [ + '', + Validators.min(0) + ], + preClosurePenalInterestOnTypeId: [''] + }); + this.closureSettingsForm.get('maturityInstructionId').valueChanges.subscribe((value: number) => { + // 200 = Transfer to Savings -- Withdraw Deposit (100) needs no target account, and both + // Reinvest options (300/400) open a brand-new FD instead, so they ignore this field too. + if (value === 200) { + this.closureSettingsForm.addControl( + 'transferToSavingsId', + new UntypedFormControl(this.linkedSavingsAccountId ?? '', Validators.required) + ); + } else { + this.closureSettingsForm.removeControl('transferToSavingsId'); + } + }); + } + + ngOnChanges(changes: SimpleChanges) { + if (changes.linkedSavingsAccountId) { + const control = this.closureSettingsForm.get('transferToSavingsId'); + if (control && !control.value && this.linkedSavingsAccountId) { + control.setValue(this.linkedSavingsAccountId); + } + } + if (this.fixedDepositsAccountProductTemplate) { + // Withdraw Deposit (id 100) books the maturity proceeds out of the bank's cash/vault the moment + // the account matures, before the customer has actually collected it -- disabled everywhere. + this.maturityInstructionOptions = ( + this.fixedDepositsAccountProductTemplate.maturityInstructionOptions ?? [] + ).filter((option: any) => option.id !== 100); + this.savingsAccountsData = this.fixedDepositsAccountProductTemplate.savingsAccounts ?? []; + this.preClosurePenalInterestOnTypeData = + this.fixedDepositsAccountProductTemplate.preClosurePenalInterestOnTypeOptions ?? []; + if (!this.closureSettingsForm.get('preClosurePenalApplicable').dirty) { + this.closureSettingsForm.patchValue({ + preClosurePenalApplicable: this.fixedDepositsAccountProductTemplate.preClosurePenalApplicable ?? false, + preClosurePenalInterest: this.fixedDepositsAccountProductTemplate.preClosurePenalInterest ?? '', + preClosurePenalInterestOnTypeId: + this.fixedDepositsAccountProductTemplate.preClosurePenalInterestOnType?.id ?? '' + }); + } + } + } + + /** The linked-savings account for the transfer-to-savings maturity instruction, once picked. */ + get selectedTransferAccount(): any { + const id = this.closureSettingsForm.value.transferToSavingsId; + if (!id) return null; + return this.savingsAccountsData.find((account: any) => account.id === id) ?? null; + } + + clearTransferAccount(): void { + this.closureSettingsForm.get('transferToSavingsId')?.setValue(''); + } + + /** Chosen maturity/closure settings, ready to merge into the submit payload. */ + get closureSettings() { + const settings = this.closureSettingsForm.getRawValue(); + for (const key in settings) { + if (settings[key] === '') { + delete settings[key]; + } + } + return settings; + } +} diff --git a/src/app/deposits/fixed-deposits/fixed-deposit-account-stepper/fixed-deposit-account-preview-step/fixed-deposit-account-preview-step.component.html b/src/app/deposits/fixed-deposits/fixed-deposit-account-stepper/fixed-deposit-account-preview-step/fixed-deposit-account-preview-step.component.html index 7abd9466a..98644bdb4 100644 --- a/src/app/deposits/fixed-deposits/fixed-deposit-account-stepper/fixed-deposit-account-preview-step/fixed-deposit-account-preview-step.component.html +++ b/src/app/deposits/fixed-deposits/fixed-deposit-account-stepper/fixed-deposit-account-preview-step/fixed-deposit-account-preview-step.component.html @@ -683,6 +683,41 @@ +
+
+ + Maturity & Pre-mature Closure +
+
+
+
+ {{ 'labels.inputs.Maturity Instructions' | translate }} + {{ closureMaturityInstructionLabel }} +
+ @if (closureSettings?.transferToSavingsId) { +
+ {{ 'labels.inputs.Transfer to savings account' | translate }} + {{ closureTransferToSavingsLabel }} +
+ } +
+ {{ 'labels.inputs.Apply Penal Interest (less)' | translate }} + {{ closureSettings?.preClosurePenalApplicable | yesNo }} +
+ @if (closureSettings?.preClosurePenalApplicable) { +
+ {{ 'labels.inputs.Penal Interest' | translate }} (%) + {{ closureSettings?.preClosurePenalInterest }}% +
+
+ {{ 'labels.inputs.Period' | translate }} + {{ closurePenalInterestOnTypeLabel }} +
+ } +
+
+
+ @if (!hideButtons) {
+ } diff --git a/src/app/deposits/fixed-deposits/fixed-deposit-account-view/fixed-deposit-account-view.component.ts b/src/app/deposits/fixed-deposits/fixed-deposit-account-view/fixed-deposit-account-view.component.ts index 7715b65c5..0e550afb3 100644 --- a/src/app/deposits/fixed-deposits/fixed-deposit-account-view/fixed-deposit-account-view.component.ts +++ b/src/app/deposits/fixed-deposits/fixed-deposit-account-view/fixed-deposit-account-view.component.ts @@ -16,6 +16,7 @@ import { ConfirmationDialogComponent } from 'app/shared/confirmation-dialog/conf import { CalculateInterestDialogComponent } from './custom-dialogs/calculate-interest-dialog/calculate-interest-dialog.component'; import { PostInterestDialogComponent } from './custom-dialogs/post-interest-dialog/post-interest-dialog.component'; import { ToggleWithholdTaxDialogComponent } from './custom-dialogs/toggle-withhold-tax-dialog/toggle-withhold-tax-dialog.component'; +import { DepositCertificateDialogComponent } from 'app/shared/deposit-certificate-dialog/deposit-certificate-dialog.component'; /** Custom Button Config. */ import { FixedDepositsButtonsConfiguration } from './fixed-deposits-buttons.config'; @@ -358,6 +359,20 @@ export class FixedDepositAccountViewComponent implements OnInit { }); } + /** + * Opens a printable deposit certificate for this account. + */ + printCertificate() { + this.dialog.open(DepositCertificateDialogComponent, { + width: '900px', + data: { + accountType: 'FD', + accountData: this.fixedDepositsAccountData, + clientId: this.fixedDepositsAccountData.clientId + } + }); + } + /** * Disables withhold tax for fixed deposits account * Fixed deposits endpoint is not supported so using Savings endpoint. diff --git a/src/app/deposits/recurring-deposits/create-recurring-deposits-account/create-recurring-deposits-account.component.html b/src/app/deposits/recurring-deposits/create-recurring-deposits-account/create-recurring-deposits-account.component.html index d4f2d0b6c..7b55c02bd 100644 --- a/src/app/deposits/recurring-deposits/create-recurring-deposits-account/create-recurring-deposits-account.component.html +++ b/src/app/deposits/recurring-deposits/create-recurring-deposits-account/create-recurring-deposits-account.component.html @@ -33,6 +33,7 @@ @@ -111,7 +112,40 @@
- + + + + + Terms + +
+ @if (recurringDepositsAccountProductTemplate) { + + } + +
+ + + +
+
+
+ + @@ -132,6 +166,7 @@ [rdDaysInYear]="rdDaysInYear" [rdAppliedRate]="rdAppliedRate" [rdIsSeniorCitizen]="rdIsSeniorCitizen" + [closureSettings]="recurringDepositsAccountMaturityTermsStep?.closureSettings ?? {}" [hideButtons]="true" (submitEvent)="submit()" > 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 8ab2a000c..d68fd0229 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 @@ -24,6 +24,7 @@ import { FaIconComponent } from '@fortawesome/angular-fontawesome'; import { RecurringDepositsAccountCurrencyStepComponent } from '../recurring-deposits-account-stepper/recurring-deposits-account-currency-step/recurring-deposits-account-currency-step.component'; import { RecurringDepositsAccountInterestRateChartStepComponent } from '../recurring-deposits-account-stepper/recurring-deposits-account-interest-rate-chart-step/recurring-deposits-account-interest-rate-chart-step.component'; import { RecurringDepositsAccountPreviewStepComponent } from '../recurring-deposits-account-stepper/recurring-deposits-account-preview-step/recurring-deposits-account-preview-step.component'; +import { RecurringDepositsAccountMaturityTermsStepComponent } from '../recurring-deposits-account-stepper/recurring-deposits-account-maturity-terms-step/recurring-deposits-account-maturity-terms-step.component'; import { STANDALONE_SHARED_IMPORTS } from 'app/standalone-shared.module'; /** @@ -40,6 +41,7 @@ import { STANDALONE_SHARED_IMPORTS } from 'app/standalone-shared.module'; RecurringDepositsAccountDetailsStepComponent, RecurringDepositsAccountCurrencyStepComponent, RecurringDepositsAccountInterestRateChartStepComponent, + RecurringDepositsAccountMaturityTermsStepComponent, RecurringDepositsAccountPreviewStepComponent ] }) @@ -61,6 +63,12 @@ export class CreateRecurringDepositsAccountComponent { @ViewChild(RecurringDepositsAccountInterestRateChartStepComponent) recurringDepositsAccountChartStep: RecurringDepositsAccountInterestRateChartStepComponent; + @ViewChild(RecurringDepositsAccountPreviewStepComponent) + recurringDepositsAccountPreviewStep: RecurringDepositsAccountPreviewStepComponent; + + @ViewChild(RecurringDepositsAccountMaturityTermsStepComponent) + recurringDepositsAccountMaturityTermsStep: RecurringDepositsAccountMaturityTermsStepComponent; + /** Recurring Deposits Account Template */ recurringDepositsAccountTemplate: any; /** Recurring Deposit Account Product Template */ @@ -68,7 +76,7 @@ export class CreateRecurringDepositsAccountComponent { /** Currently visible wizard tab (0-indexed). */ selectedTabIndex = 0; /** Total wizard tabs — Product, Currency, Chart, Preview. */ - private readonly totalTabs = 4; + private readonly totalTabs = 5; /** Active CBS base interest rate versions */ cbsBaseRates: any[] = []; /** Selected tenure value and frequency from calculator */ @@ -258,6 +266,7 @@ export class CreateRecurringDepositsAccountComponent { ...details, ...terms, ...settings, + ...(this.recurringDepositsAccountMaturityTermsStep?.closureSettings ?? {}), ...productDefaults, clientId: this.recurringDepositsAccountTemplate.clientId, mandatoryRecommendedDepositAmount: pt?.minDepositAmount ?? 0, diff --git a/src/app/deposits/recurring-deposits/edit-recurring-deposit-account/edit-recurring-deposit-account.component.html b/src/app/deposits/recurring-deposits/edit-recurring-deposit-account/edit-recurring-deposit-account.component.html index f3d7d5225..e03d3542d 100644 --- a/src/app/deposits/recurring-deposits/edit-recurring-deposit-account/edit-recurring-deposit-account.component.html +++ b/src/app/deposits/recurring-deposits/edit-recurring-deposit-account/edit-recurring-deposit-account.component.html @@ -35,6 +35,7 @@ @@ -115,7 +116,40 @@ - + + + + + Terms + +
+ @if (recurringDepositsAccountProductTemplate) { + + } + +
+ + + +
+
+
+ + @@ -136,6 +170,7 @@ [rdDaysInYear]="rdDaysInYear" [rdAppliedRate]="rdAppliedRate" [rdIsSeniorCitizen]="rdIsSeniorCitizen" + [closureSettings]="recurringDepositsAccountMaturityTermsStep?.closureSettings ?? {}" [hideButtons]="true" (submitEvent)="submit()" > 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 4076b4661..7b5bb33f3 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 @@ -25,6 +25,7 @@ import { FaIconComponent } from '@fortawesome/angular-fontawesome'; import { RecurringDepositsAccountCurrencyStepComponent } from '../recurring-deposits-account-stepper/recurring-deposits-account-currency-step/recurring-deposits-account-currency-step.component'; import { RecurringDepositsAccountInterestRateChartStepComponent } from '../recurring-deposits-account-stepper/recurring-deposits-account-interest-rate-chart-step/recurring-deposits-account-interest-rate-chart-step.component'; import { RecurringDepositsAccountPreviewStepComponent } from '../recurring-deposits-account-stepper/recurring-deposits-account-preview-step/recurring-deposits-account-preview-step.component'; +import { RecurringDepositsAccountMaturityTermsStepComponent } from '../recurring-deposits-account-stepper/recurring-deposits-account-maturity-terms-step/recurring-deposits-account-maturity-terms-step.component'; import { STANDALONE_SHARED_IMPORTS } from 'app/standalone-shared.module'; /** @@ -41,6 +42,7 @@ import { STANDALONE_SHARED_IMPORTS } from 'app/standalone-shared.module'; RecurringDepositsAccountDetailsStepComponent, RecurringDepositsAccountCurrencyStepComponent, RecurringDepositsAccountInterestRateChartStepComponent, + RecurringDepositsAccountMaturityTermsStepComponent, RecurringDepositsAccountPreviewStepComponent ] }) @@ -63,13 +65,19 @@ export class EditRecurringDepositAccountComponent { @ViewChild(RecurringDepositsAccountInterestRateChartStepComponent) recurringDepositsAccountChartStep: RecurringDepositsAccountInterestRateChartStepComponent; + @ViewChild(RecurringDepositsAccountPreviewStepComponent) + recurringDepositsAccountPreviewStep: RecurringDepositsAccountPreviewStepComponent; + + @ViewChild(RecurringDepositsAccountMaturityTermsStepComponent) + recurringDepositsAccountMaturityTermsStep: RecurringDepositsAccountMaturityTermsStepComponent; + /** Full account data + template from resolver */ recurringDepositsAccountAndTemplate: any; /** Product template emitted by details step */ recurringDepositsAccountProductTemplate: any; /** Currently visible wizard tab (0-indexed). */ selectedTabIndex = 0; - private readonly totalTabs = 4; + private readonly totalTabs = 5; /** Active CBS base interest rate versions */ cbsBaseRates: any[] = []; /** Selected tenure value and frequency from calculator */ @@ -277,6 +285,7 @@ export class EditRecurringDepositAccountComponent { ...details, ...terms, ...settings, + ...(this.recurringDepositsAccountMaturityTermsStep?.closureSettings ?? {}), ...productDefaults, clientId: this.recurringDepositsAccountAndTemplate.clientId, mandatoryRecommendedDepositAmount: pt?.minDepositAmount ?? 0, 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 6ba4bea37..f7bb9d68f 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 @@ -7,7 +7,17 @@ */ /** Angular Imports */ -import { Component, OnInit, Input, Output, EventEmitter, inject, ChangeDetectorRef } from '@angular/core'; +import { + Component, + OnInit, + OnChanges, + SimpleChanges, + Input, + Output, + EventEmitter, + inject, + ChangeDetectorRef +} from '@angular/core'; import { UntypedFormGroup, UntypedFormBuilder, Validators, FormsModule } from '@angular/forms'; import { SettingsService } from 'app/settings/settings.service'; import { Dates } from 'app/core/utils/dates'; @@ -43,7 +53,7 @@ import { IntroducerSearchComponent } from 'app/shared/introducer-search/introduc IntroducerSearchComponent ] }) -export class RecurringDepositsAccountDetailsStepComponent implements OnInit { +export class RecurringDepositsAccountDetailsStepComponent implements OnInit, OnChanges { private formBuilder = inject(UntypedFormBuilder); private recurringDepositsService = inject(RecurringDepositsService); private settingsService = inject(SettingsService); @@ -56,6 +66,10 @@ export class RecurringDepositsAccountDetailsStepComponent implements OnInit { /** Recurring Deposits Account Template */ @Input() recurringDepositsAccountTemplate: any; + /** Linked-account id picked elsewhere in the wizard (e.g. the Terms step's Transfer to Savings + * account) -- pre-fills this card so the officer doesn't have to pick the same account twice. */ + @Input() externalLinkedAccountId: number | null = null; + /** Minimum date allowed. */ minDate = new Date(2000, 0, 1); /** Maximum date allowed. */ @@ -198,6 +212,23 @@ export class RecurringDepositsAccountDetailsStepComponent implements OnInit { this.dalSearch = ''; } + ngOnChanges(changes: SimpleChanges): void { + if (!changes.externalLinkedAccountId || this.selectedLinkedAccount) { + return; + } + const id = this.externalLinkedAccountId; + if (!id) return; + const savings = this.dalAllSavings.find((a: any) => a.id === id); + const current = this.dalAllCurrent.find((a: any) => a.id === id); + if (savings) { + this.dalSelectedType = 'SAVINGS'; + this.selectedLinkedAccount = savings; + } else if (current) { + this.dalSelectedType = 'CURRENT'; + this.selectedLinkedAccount = current; + } + } + private loadClientAccounts(): void { const clientId = String(this.recurringDepositsAccountTemplate?.clientId); if (!clientId) return; 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 new file mode 100644 index 000000000..17d6f00d7 --- /dev/null +++ b/src/app/deposits/recurring-deposits/recurring-deposits-account-stepper/recurring-deposits-account-maturity-terms-step/recurring-deposits-account-maturity-terms-step.component.html @@ -0,0 +1,108 @@ + + +
+

{{ 'labels.heading.For Pre-mature closure' | translate }}

+ + + {{ 'labels.inputs.Maturity Instructions' | translate }} + + @for (maturityInstruction of maturityInstructionOptions; track maturityInstruction) { + + {{ maturityInstruction.value | translateKey: 'catalogs' }} + + } + + + + @if (closureSettingsForm.contains('transferToSavingsId')) { +
+
+
+
+ savings +
+
+

{{ 'labels.inputs.Transfer to savings account' | translate }}

+ Maturity proceeds will be credited to this account +
+
+
+ +
+ @if (selectedTransferAccount) { +
+
+ + savings + Savings + +
+ {{ selectedTransferAccount.accountNo }} + {{ selectedTransferAccount.savingsProductName }} +
+ @if (selectedTransferAccount.summary?.accountBalance != null) { +
+ Balance + + {{ selectedTransferAccount.currency?.code }} + {{ selectedTransferAccount.summary.accountBalance | number: '1.2-2' }} + +
+ } +
+ +
+ } @else { + + {{ 'labels.inputs.Transfer to savings account' | translate }} + + @for (account of savingsAccountsData; track account) { + + ({{ account.accountNo }}) {{ account.savingsProductName }} + + } + + Transfer to savings account is required + + } +
+
+ } + + + {{ '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' }} + + } + + + } +
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 new file mode 100644 index 000000000..1f807bcce --- /dev/null +++ b/src/app/deposits/recurring-deposits/recurring-deposits-account-stepper/recurring-deposits-account-maturity-terms-step/recurring-deposits-account-maturity-terms-step.component.scss @@ -0,0 +1,152 @@ +/** + * Copyright since 2025 Mifos Initiative + * + * This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. + */ + +// ── Transfer-to-Savings account card (mirrors the Deposit Account Link card) ── + +.dal-card { + margin-top: 16px; + border: 1px solid #e2e8f0; + border-radius: 12px; + background: #fff; + overflow: hidden; + + &__header { + display: flex; + align-items: center; + gap: 14px; + padding: 16px 24px; + border-bottom: 1px solid #f1f5f9; + background: linear-gradient(135deg, #f0f9ff 0%, #e0f2fe 100%); + } + + &__header-left { + display: flex; + align-items: center; + gap: 14px; + } + + &__icon { + width: 44px; + height: 44px; + border-radius: 10px; + background: #0ea5e9; + display: flex; + align-items: center; + justify-content: center; + color: #fff; + } + + &__title { + margin: 0; + font-size: 15px; + font-weight: 700; + color: #0c4a6e; + } + + &__sub { + display: block; + font-size: 12px; + color: #64748b; + margin-top: 2px; + } + + &__body { + padding: 20px 24px; + } +} + +.dal-selected { + display: flex; + align-items: center; + gap: 16px; + padding: 16px 20px; + border: 2px solid #0ea5e9; + border-radius: 12px; + background: #f0f9ff; + + &__info { + display: flex; + align-items: center; + gap: 14px; + flex: 1; + flex-wrap: wrap; + } + + &__details { + display: flex; + flex-direction: column; + gap: 2px; + flex: 1; + } + + &__accno { + font-size: 15px; + font-weight: 700; + color: #0c4a6e; + font-family: monospace; + } + + &__product { + font-size: 12px; + color: #64748b; + } + + &__balance { + display: flex; + flex-direction: column; + align-items: flex-end; + gap: 2px; + } + + &__bal-label { + font-size: 10px; + color: #94a3b8; + text-transform: uppercase; + letter-spacing: 0.5px; + } + + &__bal-amount { + font-size: 15px; + font-weight: 700; + color: #0c4a6e; + } +} + +.dal-change-btn { + white-space: nowrap; +} + +.dal-type-badge { + display: inline-flex; + align-items: center; + gap: 4px; + padding: 4px 12px; + border-radius: 999px; + font-size: 12px; + font-weight: 700; + white-space: nowrap; + background: #f1f5f9; + color: #475569; + border: 1px solid #e2e8f0; + + mat-icon { + font-size: 14px; + width: 14px; + height: 14px; + } + + &--savings { + background: #eff6ff; + color: #1d4ed8; + border-color: #bfdbfe; + + mat-icon { + color: #2563eb; + } + } +} 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 new file mode 100644 index 000000000..d32140665 --- /dev/null +++ b/src/app/deposits/recurring-deposits/recurring-deposits-account-stepper/recurring-deposits-account-maturity-terms-step/recurring-deposits-account-maturity-terms-step.component.ts @@ -0,0 +1,116 @@ +/** + * 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, Input, OnChanges, SimpleChanges, inject } from '@angular/core'; +import { UntypedFormGroup, UntypedFormBuilder, UntypedFormControl, Validators } from '@angular/forms'; +import { MatCheckbox } from '@angular/material/checkbox'; +import { MatIcon } from '@angular/material/icon'; +import { STANDALONE_SHARED_IMPORTS } from 'app/standalone-shared.module'; + +/** + * Recurring Deposit Account Maturity Terms Step -- what happens to this account at maturity + * (Withdraw / Transfer to Savings / Reinvest) and the pre-mature-closure penalty, decided + * per account rather than silently inherited from the product. + */ +@Component({ + selector: 'mifosx-recurring-deposits-account-maturity-terms-step', + templateUrl: './recurring-deposits-account-maturity-terms-step.component.html', + styleUrls: ['./recurring-deposits-account-maturity-terms-step.component.scss'], + imports: [ + ...STANDALONE_SHARED_IMPORTS, + MatCheckbox, + MatIcon + ] +}) +export class RecurringDepositsAccountMaturityTermsStepComponent implements OnChanges { + private formBuilder = inject(UntypedFormBuilder); + + @Input() recurringDepositsAccountTemplate: any; + @Input() recurringDepositsAccountProductTemplate: any; + /** Account already linked elsewhere in the wizard (the Details step's Deposit Account Link) -- + * pre-fills Transfer to Savings so the officer doesn't have to pick the same account twice. */ + @Input() linkedSavingsAccountId: number | null = null; + + closureSettingsForm: UntypedFormGroup; + maturityInstructionOptions: any[] = []; + savingsAccountsData: any[] = []; + preClosurePenalInterestOnTypeData: any[] = []; + + constructor() { + this.closureSettingsForm = this.formBuilder.group({ + maturityInstructionId: [''], + preClosurePenalApplicable: [false], + preClosurePenalInterest: [ + '', + Validators.min(0) + ], + preClosurePenalInterestOnTypeId: [''] + }); + this.closureSettingsForm.get('maturityInstructionId').valueChanges.subscribe((value: number) => { + // 200 = Transfer to Savings -- Withdraw Deposit (100) needs no target account, and both + // Reinvest options (300/400) open a brand-new RD instead, so they ignore this field too. + if (value === 200) { + this.closureSettingsForm.addControl( + 'transferToSavingsId', + new UntypedFormControl(this.linkedSavingsAccountId ?? '', Validators.required) + ); + } else { + this.closureSettingsForm.removeControl('transferToSavingsId'); + } + }); + } + + ngOnChanges(changes: SimpleChanges) { + if (changes.linkedSavingsAccountId) { + const control = this.closureSettingsForm.get('transferToSavingsId'); + if (control && !control.value && this.linkedSavingsAccountId) { + control.setValue(this.linkedSavingsAccountId); + } + } + const template = this.recurringDepositsAccountProductTemplate || this.recurringDepositsAccountTemplate; + if (template) { + // Withdraw Deposit (id 100) books the maturity proceeds out of the bank's cash/vault the moment + // the account matures, before the customer has actually collected it -- disabled everywhere. + this.maturityInstructionOptions = (template.maturityInstructionOptions ?? []).filter( + (option: any) => option.id !== 100 + ); + this.savingsAccountsData = template.savingsAccounts ?? []; + this.preClosurePenalInterestOnTypeData = template.preClosurePenalInterestOnTypeOptions ?? []; + if (!this.closureSettingsForm.get('preClosurePenalApplicable').dirty) { + this.closureSettingsForm.patchValue({ + preClosurePenalApplicable: template.preClosurePenalApplicable ?? false, + preClosurePenalInterest: template.preClosurePenalInterest ?? '', + preClosurePenalInterestOnTypeId: template.preClosurePenalInterestOnType?.id ?? '' + }); + } + } + } + + /** The linked-savings account for the transfer-to-savings maturity instruction, once picked. */ + get selectedTransferAccount(): any { + const id = this.closureSettingsForm.value.transferToSavingsId; + if (!id) return null; + return this.savingsAccountsData.find((account: any) => account.id === id) ?? null; + } + + clearTransferAccount(): void { + this.closureSettingsForm.get('transferToSavingsId')?.setValue(''); + } + + /** Chosen maturity/closure settings, ready to merge into the submit payload. */ + get closureSettings() { + const settings = this.closureSettingsForm.getRawValue(); + for (const key in settings) { + if (settings[key] === '') { + delete settings[key]; + } + } + return settings; + } +} 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 2ff42a561..4abb78a1a 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 @@ -853,6 +853,41 @@ } +
+
+ + Maturity & Pre-mature Closure +
+
+
+
+ {{ 'labels.inputs.Maturity Instructions' | translate }} + {{ closureMaturityInstructionLabel }} +
+ @if (closureSettings?.transferToSavingsId) { +
+ {{ 'labels.inputs.Transfer to savings account' | translate }} + {{ closureTransferToSavingsLabel }} +
+ } +
+ {{ 'labels.inputs.Apply Penal Interest (less)' | translate }} + {{ closureSettings?.preClosurePenalApplicable | yesNo }} +
+ @if (closureSettings?.preClosurePenalApplicable) { +
+ {{ 'labels.inputs.Penal Interest' | translate }} (%) + {{ closureSettings?.preClosurePenalInterest }}% +
+
+ {{ 'labels.inputs.Period' | translate }} + {{ closurePenalInterestOnTypeLabel }} +
+ } +
+
+
+ @if (!hideButtons) {
+ } 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 24ca434e9..22d3aa3da 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 @@ -22,6 +22,7 @@ import { RecurringDepositsButtonsConfiguration } from './recurring-deposits-butt import { DeleteDialogComponent } from 'app/shared/delete-dialog/delete-dialog.component'; import { ConfirmationDialogComponent } from 'app/shared/confirmation-dialog/confirmation-dialog.component'; import { RecurringDepositConfirmationDialogComponent } from './custom-dialogs/recurring-deposit-confirmation-dialog/recurring-deposit-confirmation-dialog.component'; +import { DepositCertificateDialogComponent } from 'app/shared/deposit-certificate-dialog/deposit-certificate-dialog.component'; import { Currency } from 'app/shared/models/general.model'; import { TranslateService } from '@ngx-translate/core'; import { AccountsService } from 'app/accounts/accounts.service'; @@ -426,6 +427,20 @@ export class RecurringDepositsAccountViewComponent implements OnInit { }); } + /** + * Opens a printable deposit certificate for this account. + */ + printCertificate() { + this.dialog.open(DepositCertificateDialogComponent, { + width: '900px', + data: { + accountType: 'RD', + accountData: this.recurringDepositsAccountData, + clientId: this.recurringDepositsAccountData.clientId + } + }); + } + /** * Disables withhold tax for recurring deposits account * Recurring deposits endpoint is not supported so using Savings endpoint. diff --git a/src/app/products/fixed-deposit-products/fixed-deposit-product-stepper/fixed-deposit-product-settings-step/fixed-deposit-product-settings-step.component.html b/src/app/products/fixed-deposit-products/fixed-deposit-product-stepper/fixed-deposit-product-settings-step/fixed-deposit-product-settings-step.component.html index 7843f2654..201b48241 100644 --- a/src/app/products/fixed-deposit-products/fixed-deposit-product-stepper/fixed-deposit-product-settings-step/fixed-deposit-product-settings-step.component.html +++ b/src/app/products/fixed-deposit-products/fixed-deposit-product-stepper/fixed-deposit-product-settings-step/fixed-deposit-product-settings-step.component.html @@ -21,6 +21,33 @@ Lien Marked Apply Withdrawal Fee for Transfers
+ + + {{ 'labels.inputs.Apply Penal Interest (less)' | translate }} + + + @if (fixedDepositProductSettingsForm.value.preClosurePenalApplicable) { +
+ + {{ 'labels.inputs.Penal Interest' | translate }} (%) + + + + + {{ 'labels.inputs.Period' | translate }} + + @for ( + preClosurePenalInterestOnType of preClosurePenalInterestOnTypeData; + track preClosurePenalInterestOnType + ) { + + {{ preClosurePenalInterestOnType.value | translateKey: 'catalogs' }} + + } + + +
+ } diff --git a/src/app/products/fixed-deposit-products/fixed-deposit-product-stepper/fixed-deposit-product-settings-step/fixed-deposit-product-settings-step.component.scss b/src/app/products/fixed-deposit-products/fixed-deposit-product-stepper/fixed-deposit-product-settings-step/fixed-deposit-product-settings-step.component.scss index 8310cf654..05386d6d6 100644 --- a/src/app/products/fixed-deposit-products/fixed-deposit-product-stepper/fixed-deposit-product-settings-step/fixed-deposit-product-settings-step.component.scss +++ b/src/app/products/fixed-deposit-products/fixed-deposit-product-stepper/fixed-deposit-product-settings-step/fixed-deposit-product-settings-step.component.scss @@ -130,6 +130,15 @@ $gray-900: #202124; } } +.fdps-preclosure-toggle { + display: block; + margin: 4px 0 16px; + padding-bottom: 16px; + border-bottom: 1px solid $gray-200; + font-size: 14px; + color: $gray-900; +} + .fdps-layout { display: grid; gap: 16px; diff --git a/src/app/products/fixed-deposit-products/fixed-deposit-product-stepper/fixed-deposit-product-settings-step/fixed-deposit-product-settings-step.component.ts b/src/app/products/fixed-deposit-products/fixed-deposit-product-stepper/fixed-deposit-product-settings-step/fixed-deposit-product-settings-step.component.ts index 33933731c..fadc1ce65 100644 --- a/src/app/products/fixed-deposit-products/fixed-deposit-product-stepper/fixed-deposit-product-settings-step/fixed-deposit-product-settings-step.component.ts +++ b/src/app/products/fixed-deposit-products/fixed-deposit-product-stepper/fixed-deposit-product-settings-step/fixed-deposit-product-settings-step.component.ts @@ -30,6 +30,7 @@ export class FixedDepositProductSettingsStepComponent implements OnInit { fixedDepositProductSettingsForm: UntypedFormGroup; lockinPeriodFrequencyTypeData: any; + preClosurePenalInterestOnTypeData: any; constructor() { this.createFixedDepositProductSettingsForm(); @@ -37,6 +38,7 @@ export class FixedDepositProductSettingsStepComponent implements OnInit { ngOnInit() { this.lockinPeriodFrequencyTypeData = this.fixedDepositProductsTemplate.lockinPeriodFrequencyTypeOptions; + this.preClosurePenalInterestOnTypeData = this.fixedDepositProductsTemplate.preClosurePenalInterestOnTypeOptions; if (this.fixedDepositProductsTemplate?.id) { this.fixedDepositProductSettingsForm.patchValue({ @@ -48,6 +50,9 @@ export class FixedDepositProductSettingsStepComponent implements OnInit { enableLockinPeriod: this.fixedDepositProductsTemplate.enableLockinPeriod ?? false, lockinPeriodFrequency: this.fixedDepositProductsTemplate.lockinPeriodFrequency ?? '', lockinPeriodFrequencyType: this.fixedDepositProductsTemplate.lockinPeriodFrequencyType?.id ?? '', + preClosurePenalApplicable: this.fixedDepositProductsTemplate.preClosurePenalApplicable ?? false, + preClosurePenalInterest: this.fixedDepositProductsTemplate.preClosurePenalInterest ?? '', + preClosurePenalInterestOnTypeId: this.fixedDepositProductsTemplate.preClosurePenalInterestOnType?.id ?? '', maturityInstructions: this.fixedDepositProductsTemplate.maturityInstructions ?? false, creditMaturityProceeds: this.fixedDepositProductsTemplate.creditMaturityProceeds ?? false, penaltyGracePeriodMissedInstallments: @@ -76,6 +81,12 @@ export class FixedDepositProductSettingsStepComponent implements OnInit { Validators.min(0) ], lockinPeriodFrequencyType: [''], + preClosurePenalApplicable: [false], + preClosurePenalInterest: [ + '', + Validators.min(0) + ], + preClosurePenalInterestOnTypeId: [''], maturityInstructions: [false], creditMaturityProceeds: [false], penaltyGracePeriodMissedInstallments: [false], 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 ca817dbd2..674879dc3 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 @@ -21,6 +21,33 @@ Lien Marked Apply Withdrawal Fee for Transfers + + + {{ 'labels.inputs.Apply Penal Interest (less)' | translate }} + + + @if (recurringDepositProductSettingsForm.value.preClosurePenalApplicable) { +
+ + {{ 'labels.inputs.Penal Interest' | translate }} (%) + + + + + {{ 'labels.inputs.Period' | translate }} + + @for ( + preClosurePenalInterestOnType of preClosurePenalInterestOnTypeData; + track preClosurePenalInterestOnType + ) { + + {{ preClosurePenalInterestOnType.value | translateKey: 'catalogs' }} + + } + + +
+ } 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 3aefca054..a21434a6b 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 @@ -162,3 +162,12 @@ $gray-900: #202124; } } } + +.rdps-preclosure-toggle { + display: block; + margin: 4px 0 16px; + padding-bottom: 16px; + border-bottom: 1px solid $gray-200; + font-size: 14px; + color: $gray-900; +} 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 74c8ca844..6a336eed0 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 @@ -28,6 +28,7 @@ export class RecurringDepositProductSettingsStepComponent implements OnInit { recurringDepositProductSettingsForm: UntypedFormGroup; lockinPeriodFrequencyTypeData: any; + preClosurePenalInterestOnTypeData: any; constructor() { this.createrecurringDepositProductSettingsForm(); @@ -35,6 +36,7 @@ export class RecurringDepositProductSettingsStepComponent implements OnInit { ngOnInit() { this.lockinPeriodFrequencyTypeData = this.recurringDepositProductsTemplate.lockinPeriodFrequencyTypeOptions; + this.preClosurePenalInterestOnTypeData = this.recurringDepositProductsTemplate.preClosurePenalInterestOnTypeOptions; if (this.recurringDepositProductsTemplate?.id) { this.recurringDepositProductSettingsForm.patchValue({ @@ -46,6 +48,9 @@ export class RecurringDepositProductSettingsStepComponent implements OnInit { enableLockinPeriod: this.recurringDepositProductsTemplate.enableLockinPeriod ?? false, lockinPeriodFrequency: this.recurringDepositProductsTemplate.lockinPeriodFrequency ?? '', lockinPeriodFrequencyType: this.recurringDepositProductsTemplate.lockinPeriodFrequencyType?.id ?? '', + preClosurePenalApplicable: this.recurringDepositProductsTemplate.preClosurePenalApplicable ?? false, + preClosurePenalInterest: this.recurringDepositProductsTemplate.preClosurePenalInterest ?? '', + preClosurePenalInterestOnTypeId: this.recurringDepositProductsTemplate.preClosurePenalInterestOnType?.id ?? '', maturityInstructions: this.recurringDepositProductsTemplate.maturityInstructions ?? false, creditMaturityProceeds: this.recurringDepositProductsTemplate.creditMaturityProceeds ?? false, penaltyGracePeriodMissedInstallments: @@ -71,6 +76,9 @@ export class RecurringDepositProductSettingsStepComponent implements OnInit { enableLockinPeriod: [false], lockinPeriodFrequency: [''], lockinPeriodFrequencyType: [''], + preClosurePenalApplicable: [false], + preClosurePenalInterest: [''], + preClosurePenalInterestOnTypeId: [''], maturityInstructions: [false], creditMaturityProceeds: [false], penaltyGracePeriodMissedInstallments: [false], diff --git a/src/app/shared/deposit-certificate-dialog/deposit-certificate-dialog.component.html b/src/app/shared/deposit-certificate-dialog/deposit-certificate-dialog.component.html new file mode 100644 index 000000000..e2862f74f --- /dev/null +++ b/src/app/shared/deposit-certificate-dialog/deposit-certificate-dialog.component.html @@ -0,0 +1,160 @@ + +

+ {{ accountType === 'FD' ? 'Fixed Deposit' : 'Recurring Deposit' }} Certificate + +

+ +
+ +
+
+
+ +
+ {{ accountType === 'FD' ? 'savings' : 'event_repeat' }} +
+ +
+
Nexora Co-operative Bank Ltd.
+
Mumbai, Maharashtra, India
+
+ {{ accountType === 'FD' ? 'FIXED DEPOSIT CERTIFICATE' : 'RECURRING DEPOSIT CERTIFICATE' }} +
+
Issued under the Common Seal of the Bank
+
+ +
+
+ Certificate No. + {{ certificateNo }} +
+
+ Account No. + {{ accountData.accountNo }} +
+
+ Date of Issue + {{ issueDate | dateFormat }} +
+
+ +
+

+ This is to certify that + {{ accountData.clientName || accountData.groupName }} + holds a {{ accountType === 'FD' ? 'Fixed Deposit' : 'Recurring Deposit' }} account bearing account number + {{ accountData.accountNo }} + with Nexora Co-operative Bank Ltd., as per the particulars set out + below. +

+ +
+
ISSUED TO:
+
{{ accountData.clientName || accountData.groupName }}
+
+ Customer ID: {{ accountData.clientAccountNo }} + + · PAN: {{ panNumber }} +
+
+ {{ clientAddress }} +
+
+
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
Product{{ accountData.depositProductName }}{{ accountType === 'FD' ? 'Deposit Date' : 'First Deposit Date' }}{{ depositDate | dateFormat }}
Deposit Amount + {{ accountData.depositAmount | currency: accountData.currency.code : 'symbol-narrow' }} + Tenure{{ tenureLabel }}
Installment Amount + {{ + accountData.mandatoryRecommendedDepositAmount | currency: accountData.currency.code : 'symbol-narrow' + }} + Installment Frequency + Every {{ accountData.recurringFrequency }} {{ accountData.recurringFrequencyType?.value }} +
Tenure{{ tenureLabel }}Interest Rate (p.a.){{ accountData.nominalAnnualInterestRate }}%
Interest Rate (p.a.){{ accountData.nominalAnnualInterestRate }}%Interest Compounding{{ accountData.interestCompoundingPeriodType?.value }}
Maturity Date{{ accountData.maturityDate | dateFormat }}Current Balance + {{ accountData.summary?.accountBalance | currency: accountData.currency.code : 'symbol-narrow' }} +
MATURITY AMOUNT + {{ accountData.maturityAmount | currency: accountData.currency.code : 'symbol-narrow' }} +
+ + +
+
+
+
+ + + + + diff --git a/src/app/shared/deposit-certificate-dialog/deposit-certificate-dialog.component.scss b/src/app/shared/deposit-certificate-dialog/deposit-certificate-dialog.component.scss new file mode 100644 index 000000000..0db7334cb --- /dev/null +++ b/src/app/shared/deposit-certificate-dialog/deposit-certificate-dialog.component.scss @@ -0,0 +1,512 @@ +/** + * 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/. + */ + +.dialog-header { + display: flex; + align-items: center; + border-bottom: 1px solid #e0e0e0; + padding-bottom: 12px; + margin-bottom: 16px; + font-weight: 500; + color: #1a252c; +} + +.dialog-content-container { + max-height: 80vh; + overflow-y: auto; + padding: 10px; +} + +/* Certificate Outer Container */ +.certificate-container { + background-color: #faf8f5; + padding: 20px; + font-family: Georgia, 'Times New Roman', Times, serif; + color: #2c2520; + box-shadow: 0 4px 20px rgb(0 0 0 / 8%); + border-radius: 8px; + max-width: 800px; + margin: 0 auto; +} + +/* Classic Ornate Border System */ +.certificate-border-outer { + border: 5px double #c5a059; /* Gold double border */ + padding: 8px; + background-color: #fdfcf7; /* Textured light cream */ + border-radius: 4px; + position: relative; +} + +.certificate-border-inner { + border: 1px solid #dcd1bd; + padding: 30px; + border-radius: 2px; + position: relative; + overflow: hidden; +} + +/* Watermark */ +.certificate-watermark { + position: absolute; + top: 50%; + left: 50%; + transform: translate(-50%, -50%); + opacity: 0.03; + pointer-events: none; + z-index: 1; + + .watermark-icon { + font-size: 280px; + width: 280px; + height: 280px; + color: #c5a059; + } +} + +.certificate-header, +.cert-meta-row, +.cert-body-section, +.certificate-details-table, +.cert-footer { + position: relative; + z-index: 2; /* Ensure content is above watermark */ +} + +/* Header styling */ +.certificate-header { + text-align: center; + margin-bottom: 30px; + border-bottom: 2px solid #f2edd5; + padding-bottom: 20px; + + .bank-name { + font-size: 26px; + font-weight: bold; + color: #0b3c5d; /* Dark Premium Blue */ + text-transform: uppercase; + letter-spacing: 1.5px; + margin-bottom: 4px; + } + + .bank-sub { + font-size: 13px; + color: #7b6f63; + font-style: italic; + letter-spacing: 1px; + margin-bottom: 20px; + } + + .cert-title { + font-size: 28px; + font-weight: 400; + color: #c5a059; /* Classic Gold */ + letter-spacing: 4px; + margin-bottom: 5px; + font-family: Georgia, serif; + } + + .cert-subtitle { + font-size: 11px; + color: #7b6f63; + text-transform: uppercase; + letter-spacing: 2px; + } +} + +/* Meta Data Grid (Top of cert) */ +.cert-meta-row { + display: flex; + justify-content: space-between; + margin-bottom: 30px; + border-bottom: 1px solid #f2edd5; + padding-bottom: 15px; + + .meta-item { + display: flex; + flex-direction: column; + + .meta-label { + font-size: 11px; + text-transform: uppercase; + color: #928374; + letter-spacing: 1px; + margin-bottom: 3px; + } + + .meta-value { + font-size: 15px; + font-weight: bold; + color: #322f2b; + } + } +} + +/* Main Body text */ +.cert-body-section { + margin-bottom: 35px; + + .cert-main-text { + font-size: 15px; + line-height: 1.8; + text-align: center; + color: #4a453f; + margin-bottom: 25px; + + .highlight-text { + color: #1a202c; + font-weight: 600; + border-bottom: 1px dotted #c5a059; + padding-bottom: 1px; + } + } +} + +/* Issued To Details Block */ +.issued-to-details { + border: 1px solid #f0e6d2; + background-color: #fbfaf5; + padding: 15px 20px; + border-radius: 4px; + margin: 20px auto; + max-width: 90%; + text-align: left; + + .issued-to-title { + font-size: 10px; + font-weight: bold; + color: #c5a059; + letter-spacing: 1.5px; + margin-bottom: 6px; + } + + .client-name-details { + font-size: 18px; + font-weight: bold; + color: #0b3c5d; + margin-bottom: 4px; + } + + .client-ids { + font-size: 12px; + color: #666; + margin-bottom: 6px; + } + + .client-address { + font-size: 12px; + color: #7b6f63; + line-height: 1.4; + } +} + +/* Certificate Data Table */ +.certificate-details-table { + width: 100%; + border-collapse: collapse; + margin-bottom: 40px; + + td { + padding: 10px 12px; + border: 1px solid #ede8db; + font-size: 13px; + } + + .lbl-col { + background-color: #f7f5ee; + font-weight: bold; + color: #7b6f63; + width: 25%; + } + + .val-col { + color: #322f2b; + width: 25%; + } + + .highlight-row { + background-color: #f5f2e9; + border-top: 2px solid #c5a059; + } + + .lbl-col-highlight { + font-weight: bold; + color: #0b3c5d; + font-size: 14px; + letter-spacing: 1px; + text-align: right; + padding-right: 20px; + } + + .val-col-highlight { + font-size: 16px; + font-weight: bold; + color: #b8860b; /* Dark Goldenrod */ + } +} + +/* Certificate Footer Seal / Signatures */ +.cert-footer { + display: flex; + justify-content: space-between; + align-items: center; + margin-top: 20px; + + .seal-container { + width: 25%; + + .seal-ring { + width: 90px; + height: 90px; + border: 2px dashed #b8860b; + border-radius: 50%; + display: flex; + align-items: center; + justify-content: center; + margin: 0 auto; + transform: rotate(-15deg); + + .seal-text { + font-size: 10px; + font-weight: bold; + color: #b8860b; + text-align: center; + line-height: 1.2; + letter-spacing: 1px; + } + } + } + + .closing-text { + width: 45%; + font-size: 12px; + color: #7b6f63; + font-style: italic; + line-height: 1.5; + text-align: center; + } + + .signatures-container { + width: 30%; + text-align: center; + + .signature-block { + display: inline-block; + width: 80%; + + .signature-line { + border-bottom: 1px solid #7b6f63; + margin-bottom: 8px; + height: 40px; /* Space for signature */ + } + + .signatory-title { + font-size: 12px; + color: #7b6f63; + font-weight: bold; + text-transform: uppercase; + letter-spacing: 0.5px; + } + } + } +} + +/* Print CSS */ +@media print { + /* Everything below `::ng-deep` reaches outside this component's own template -- html/body and + the whole Angular Material CDK overlay chain never carry this component's scoping attribute + under the default emulated encapsulation, so a plain (scoped) selector would silently never + match any of them. `::ng-deep` is deprecated but still the correct tool for a component that + genuinely needs to override its host application's shell during print. */ + + /* The app shell pins html/body to a fixed viewport height with overflow hidden (its inner + panels handle their own scrolling on screen) -- left in place, that caps every printed page + at one viewport's worth of content and silently cuts off the rest instead of paginating. */ + ::ng-deep html, + ::ng-deep body { + height: auto !important; + overflow: visible !important; + } + + /* Hide non-overlay contents during print */ + ::ng-deep body > :not(.cdk-overlay-container) { + display: none !important; + } + + /* The dialog's own scroll region clips anything past 80vh -- printing only captures what a + scrollable/max-height box shows on screen, so both constraints must be lifted or the + certificate gets cut off after its first visible slice. */ + .dialog-content-container { + max-height: none !important; + overflow: visible !important; + } + + /* CDK's overlay wrapper/backdrop/pane are all `position: fixed` (needed to center the dialog + on screen) and the wrapper flex-centers its content -- during print, a fixed-position + ancestor is clipped to one page's height, so anything taller than that gets cut off and only + a thin cross-section (here, just the certificate's own border) survives. Every ancestor in + that chain has to become a normal, statically-flowing block for the full certificate to print. */ + ::ng-deep .cdk-overlay-backdrop { + display: none !important; + } + + ::ng-deep .cdk-overlay-container, + ::ng-deep .cdk-global-overlay-wrapper, + ::ng-deep .cdk-overlay-pane, + ::ng-deep .mat-mdc-dialog-container, + ::ng-deep .mdc-dialog__container, + ::ng-deep .mdc-dialog__surface { + position: static !important; + display: block !important; + max-height: none !important; + max-width: none !important; + height: auto !important; + width: auto !important; + overflow: visible !important; + transform: none !important; + } + + /* Make certificate fill page */ + #deposit-certificate-print { + position: static !important; + width: 100% !important; + height: auto !important; + padding: 0 !important; + margin: 0 !important; + background-color: #fff !important; + box-shadow: none !important; + } + + .certificate-border-outer { + border-width: 8px !important; + background-color: #fff !important; + } + + .no-print { + display: none !important; + } + + ::ng-deep .mat-mdc-dialog-container { + padding: 0 !important; + } + + /* Compact every spacing/font value so the whole certificate fits a single printed page -- + the on-screen sizing is generous by design, but that same height doesn't fit one page. */ + .certificate-container { + padding: 6px !important; + } + + .certificate-border-inner { + padding: 14px !important; + } + + .certificate-header { + margin-bottom: 10px !important; + padding-bottom: 10px !important; + + .bank-name { + font-size: 19px !important; + margin-bottom: 2px !important; + } + + .bank-sub { + font-size: 10px !important; + margin-bottom: 8px !important; + } + + .cert-title { + font-size: 19px !important; + letter-spacing: 2px !important; + margin-bottom: 2px !important; + } + + .cert-subtitle { + font-size: 9px !important; + } + } + + .cert-meta-row { + margin-bottom: 10px !important; + padding-bottom: 8px !important; + + .meta-label { + font-size: 9px !important; + margin-bottom: 1px !important; + } + + .meta-value { + font-size: 12px !important; + } + } + + .cert-body-section { + margin-bottom: 10px !important; + + .cert-main-text { + font-size: 11px !important; + line-height: 1.4 !important; + margin-bottom: 10px !important; + } + } + + .issued-to-details { + padding: 8px 14px !important; + margin: 8px auto !important; + + .client-name-details { + font-size: 14px !important; + } + + .client-ids, + .client-address { + font-size: 10px !important; + } + } + + .certificate-details-table { + margin-bottom: 12px !important; + + td { + padding: 5px 10px !important; + font-size: 10px !important; + } + + .lbl-col-highlight { + font-size: 12px !important; + } + + .val-col-highlight { + font-size: 13px !important; + } + } + + .cert-footer { + margin-top: 8px !important; + + .seal-ring { + width: 60px !important; + height: 60px !important; + } + + .seal-text { + font-size: 8px !important; + } + + .closing-text { + font-size: 10px !important; + } + + .signature-line { + height: 22px !important; + } + + .signatory-title { + font-size: 10px !important; + } + } +} diff --git a/src/app/shared/deposit-certificate-dialog/deposit-certificate-dialog.component.ts b/src/app/shared/deposit-certificate-dialog/deposit-certificate-dialog.component.ts new file mode 100644 index 000000000..66f924cd5 --- /dev/null +++ b/src/app/shared/deposit-certificate-dialog/deposit-certificate-dialog.component.ts @@ -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/. + */ + +import { Component, OnInit, inject } from '@angular/core'; +import { + MAT_DIALOG_DATA, + MatDialogRef, + MatDialogTitle, + MatDialogContent, + MatDialogActions, + MatDialogClose +} from '@angular/material/dialog'; +import { ClientsService } from 'app/clients/clients.service'; +import { STANDALONE_SHARED_IMPORTS } from 'app/standalone-shared.module'; +import { DateFormatPipe } from '../../pipes/date-format.pipe'; +import { CurrencyPipe } from '@angular/common'; +import { MatButton } from '@angular/material/button'; +import { MatIcon } from '@angular/material/icon'; + +/** + * Printable deposit certificate for a Fixed Deposit or Recurring Deposit account -- handed to the + * customer as physical proof of their deposit. Shared between FD and RD since both account shapes + * carry the same core fields (depositAmount, nominalAnnualInterestRate, depositPeriod, maturityDate, + * maturityAmount); RD additionally shows its installment/frequency details. + */ +@Component({ + selector: 'mifosx-deposit-certificate-dialog', + templateUrl: './deposit-certificate-dialog.component.html', + styleUrls: ['./deposit-certificate-dialog.component.scss'], + imports: [ + ...STANDALONE_SHARED_IMPORTS, + MatDialogTitle, + MatDialogContent, + MatDialogActions, + MatDialogClose, + DateFormatPipe, + CurrencyPipe, + MatButton, + MatIcon + ] +}) +export class DepositCertificateDialogComponent implements OnInit { + dialogRef = inject>(MatDialogRef); + data = inject(MAT_DIALOG_DATA); + clientsService = inject(ClientsService); + + /** 'FD' or 'RD' -- controls the title and which extra rows show. */ + accountType: 'FD' | 'RD'; + accountData: any; + + clientData: any; + clientAddress = ''; + panNumber = 'Not Available'; + + certificateNo = ''; + issueDate: Date = new Date(); + + ngOnInit() { + this.accountType = this.data.accountType; + this.accountData = this.data.accountData; + const clientId = this.data.clientId != null ? String(this.data.clientId) : null; + + this.certificateNo = `${this.accountType}-CERT-${this.accountData.accountNo}`; + + if (!clientId) { + return; + } + + this.clientsService.getClientData(clientId).subscribe({ + next: (client: any) => { + this.clientData = client; + }, + error: () => {} + }); + + this.clientsService.getClientAddressData(clientId).subscribe({ + next: (addresses: any[]) => { + if (addresses && addresses.length > 0) { + const permanent = addresses.find((a) => /permanent/i.test(a?.addressType || '')) || addresses[0]; + this.clientAddress = this.formatAddress(permanent); + } + }, + error: () => {} + }); + + this.clientsService.getClientIdentifiers(clientId).subscribe({ + next: (identifiers: any[]) => { + const idList = Array.isArray(identifiers) ? identifiers : (identifiers as any)?.pageItems || []; + const panId = idList.find((id: any) => /pan/i.test(id?.documentType?.name || '')); + if (panId) { + this.panNumber = panId.documentKey; + } + }, + error: () => {} + }); + } + + formatAddress(addr: any): string { + if (!addr) return ''; + return [ + addr.addressLine1, + addr.addressLine2, + addr.townVillage, + addr.city, + addr.stateName, + addr.countryName, + addr.postalCode + ] + .filter((p) => !!p && String(p).trim()) + .join(', '); + } + + get depositDate(): any { + return this.accountData?.timeline?.activatedOnDate ?? this.accountData?.timeline?.submittedOnDate ?? null; + } + + get tenureLabel(): string { + const period = this.accountData?.depositPeriod; + const freq = this.accountData?.depositPeriodFrequency?.value; + if (period == null) return 'N/A'; + return freq ? `${period} ${freq}` : String(period); + } + + printCertificate() { + window.print(); + } +} From 5c76aa6b272d080dee9e14e91788517795a2a262 Mon Sep 17 00:00:00 2001 From: Nitesh Yadav Date: Fri, 21 Aug 2026 13:00:25 +0530 Subject: [PATCH 04/64] feat(tasks,deposits): review product edits in a dialog showing only what changed The pending-product-edit comparison listed every field the edit form posted -- around fifty, nearly all unchanged -- as an inline panel under the table, which buried the handful a checker actually needs to look at. It is now a dialog listing only fields whose value differs. Values are resolved to what the maker saw rather than what the payload carries: enum and GL-account ids become their labels, dates in either API shape read the same, and empty values no longer register as changes. Without that, option fields compared an expanded {id, value} object against a bare id and reported every one of them as changed. Fixed Deposit accounts created by a reinvestment now show the account they came from, and the matured account shows the one it went into, each linking to the other. Co-Authored-By: Claude Opus 5 (1M context) --- .../general-tab/general-tab.component.html | 16 ++ .../general-tab/general-tab.component.ts | 21 ++ .../pending-savings-products.component.html | 40 +-- .../pending-savings-products.component.scss | 58 ---- .../pending-savings-products.component.ts | 61 ++-- .../product-edit-diff-dialog.component.html | 53 ++++ .../product-edit-diff-dialog.component.scss | 115 ++++++++ .../product-edit-diff-dialog.component.ts | 261 ++++++++++++++++++ 8 files changed, 504 insertions(+), 121 deletions(-) create mode 100644 src/app/tasks/checker-inbox-and-tasks-tabs/pending-savings-products/product-edit-diff-dialog/product-edit-diff-dialog.component.html create mode 100644 src/app/tasks/checker-inbox-and-tasks-tabs/pending-savings-products/product-edit-diff-dialog/product-edit-diff-dialog.component.scss create mode 100644 src/app/tasks/checker-inbox-and-tasks-tabs/pending-savings-products/product-edit-diff-dialog/product-edit-diff-dialog.component.ts diff --git a/src/app/deposits/fixed-deposits/fixed-deposit-account-view/general-tab/general-tab.component.html b/src/app/deposits/fixed-deposits/fixed-deposit-account-view/general-tab/general-tab.component.html index 9ddcc0a99..e353a802e 100644 --- a/src/app/deposits/fixed-deposits/fixed-deposit-account-view/general-tab/general-tab.component.html +++ b/src/app/deposits/fixed-deposits/fixed-deposit-account-view/general-tab/general-tab.component.html @@ -83,6 +83,22 @@ }} } + @if (fixedDepositsAccountData.reinvestedFromAccountNo) { +
+ {{ 'labels.inputs.Reinvested From' | translate }} + + {{ fixedDepositsAccountData.reinvestedFromAccountNo }} + +
+ } + @if (fixedDepositsAccountData.reinvestedIntoAccountNo) { +
+ {{ 'labels.inputs.Reinvested Into' | translate }} + + {{ fixedDepositsAccountData.reinvestedIntoAccountNo }} + +
+ } diff --git a/src/app/deposits/fixed-deposits/fixed-deposit-account-view/general-tab/general-tab.component.ts b/src/app/deposits/fixed-deposits/fixed-deposit-account-view/general-tab/general-tab.component.ts index a6175a18d..7da833264 100644 --- a/src/app/deposits/fixed-deposits/fixed-deposit-account-view/general-tab/general-tab.component.ts +++ b/src/app/deposits/fixed-deposits/fixed-deposit-account-view/general-tab/general-tab.component.ts @@ -48,4 +48,25 @@ export class GeneralTabComponent { this.entityType = 'Center'; } } + + /** + * A reinvested FD always keeps the same client/group/center as the account it came from, so the + * counterpart account's view page sits at the same route -- only the account-id segment differs. + */ + private siblingAccountLink(targetAccountId: number): string | null { + const segments = this.router.url.split('?')[0].split('/').filter(Boolean); + const idx = segments.indexOf('fixed-deposits-accounts'); + if (idx === -1) return null; + return '/' + segments.slice(0, idx + 1).join('/') + '/' + targetAccountId + '/general'; + } + + get reinvestedFromLink(): string | null { + const id = this.fixedDepositsAccountData?.reinvestedFromAccountId; + return id ? this.siblingAccountLink(id) : null; + } + + get reinvestedIntoLink(): string | null { + const id = this.fixedDepositsAccountData?.reinvestedIntoAccountId; + return id ? this.siblingAccountLink(id) : null; + } } diff --git a/src/app/tasks/checker-inbox-and-tasks-tabs/pending-savings-products/pending-savings-products.component.html b/src/app/tasks/checker-inbox-and-tasks-tabs/pending-savings-products/pending-savings-products.component.html index 5016189b8..ffccfe7d7 100644 --- a/src/app/tasks/checker-inbox-and-tasks-tabs/pending-savings-products/pending-savings-products.component.html +++ b/src/app/tasks/checker-inbox-and-tasks-tabs/pending-savings-products/pending-savings-products.component.html @@ -49,8 +49,13 @@ Actions - - - @if (diffRows.length === 0) { -

Loading comparison…

- } @else { - - - - - - - - - - @for (row of diffRows; track row.label) { - - - - - - } - -
FieldOriginalProposed
{{ row.label }}{{ row.original }}{{ row.proposed }}
- } - - } } @if (!showData) { diff --git a/src/app/tasks/checker-inbox-and-tasks-tabs/pending-savings-products/pending-savings-products.component.scss b/src/app/tasks/checker-inbox-and-tasks-tabs/pending-savings-products/pending-savings-products.component.scss index 892bb22f2..cfc9860dd 100644 --- a/src/app/tasks/checker-inbox-and-tasks-tabs/pending-savings-products/pending-savings-products.component.scss +++ b/src/app/tasks/checker-inbox-and-tasks-tabs/pending-savings-products/pending-savings-products.component.scss @@ -87,64 +87,6 @@ $text-muted: #64748b; } } -.psp-diff-panel { - margin-top: 20px; - border: 1px solid $border; - border-radius: 12px; - background: #fff; - padding: 16px; -} - -.psp-diff-header { - display: flex; - align-items: center; - justify-content: space-between; - - h3 { - margin: 0; - font-size: 14px; - font-weight: 700; - } -} - -.psp-diff-loading { - color: $text-muted; - font-size: 13px; -} - -.psp-diff-table { - width: 100%; - border-collapse: collapse; - margin-top: 12px; - font-size: 12.5px; - - th, - td { - text-align: left; - padding: 8px 10px; - border-bottom: 1px solid $border; - } - - th { - color: $text-muted; - font-weight: 600; - } -} - -.psp-diff-row--changed { - background: #fffbeb; - - .psp-diff-original { - color: #b91c1c; - text-decoration: line-through; - } - - .psp-diff-proposed { - color: #15803d; - font-weight: 600; - } -} - .empty-state { display: flex; flex-direction: column; diff --git a/src/app/tasks/checker-inbox-and-tasks-tabs/pending-savings-products/pending-savings-products.component.ts b/src/app/tasks/checker-inbox-and-tasks-tabs/pending-savings-products/pending-savings-products.component.ts index db9554f9d..cf4276918 100644 --- a/src/app/tasks/checker-inbox-and-tasks-tabs/pending-savings-products/pending-savings-products.component.ts +++ b/src/app/tasks/checker-inbox-and-tasks-tabs/pending-savings-products/pending-savings-products.component.ts @@ -23,10 +23,10 @@ import { } from '@angular/material/table'; import { MatDialog } from '@angular/material/dialog'; import { MatIcon } from '@angular/material/icon'; -import { MatIconButton } from '@angular/material/button'; import { FaIconComponent } from '@fortawesome/angular-fontawesome'; import { ProductsService } from 'app/products/products.service'; +import { ProductEditDiffDialogComponent } from './product-edit-diff-dialog/product-edit-diff-dialog.component'; import { ConfirmationDialogComponent } from 'app/shared/confirmation-dialog/confirmation-dialog.component'; import { FormDialogComponent } from 'app/shared/form-dialog/form-dialog.component'; import { FormfieldBase } from 'app/shared/form-dialog/formfield/model/formfield-base'; @@ -50,8 +50,7 @@ import { STANDALONE_SHARED_IMPORTS } from 'app/standalone-shared.module'; MatHeaderRow, MatRowDef, MatRow, - MatIcon, - MatIconButton + MatIcon ] }) export class PendingSavingsProductsComponent { @@ -72,9 +71,8 @@ export class PendingSavingsProductsComponent { 'actions' ]; - selectedEdit: any = null; - originalProduct: any = null; - diffRows: { label: string; original: any; proposed: any; changed: boolean }[] = []; + /** Set only while the original product is being fetched, so the button can show progress. */ + loadingDiffFor: any = null; constructor() { this.route.data.subscribe((data: { pendingSavingsProductsData: any }) => { @@ -96,18 +94,23 @@ export class PendingSavingsProductsComponent { return isNaN(d.getTime()) ? String(date) : d.toLocaleString('en-IN'); } + /** + * Fetched with template=true so the response carries the option lists (accountingRuleOptions, + * interestCompoundingPeriodTypeOptions, ...) alongside the product. The submitted edit stores enum + * fields as bare ids, so the diff needs those lists to show a readable name instead of a raw number. + */ private getOriginalProduct$(edit: any) { switch (edit.productType) { case 'CURRENT': - return this.productsService.getCurrentProduct(edit.savingsProductId); + return this.productsService.getCurrentProduct(edit.savingsProductId, true); case 'SHARE': - return this.productsService.getShareProduct(edit.savingsProductId); + return this.productsService.getShareProduct(edit.savingsProductId, true); case 'FD': - return this.productsService.getFixedDepositProduct(edit.savingsProductId); + return this.productsService.getFixedDepositProductAndTemplate(edit.savingsProductId); case 'RD': - return this.productsService.getRecurringDepositProduct(edit.savingsProductId); + return this.productsService.getRecurringDepositProductAndTemplate(edit.savingsProductId); default: - return this.productsService.getSavingProduct(edit.savingsProductId); + return this.productsService.getSavingProduct(edit.savingsProductId, true); } } @@ -142,29 +145,27 @@ export class PendingSavingsProductsComponent { } viewDiff(edit: any): void { - this.selectedEdit = edit; - this.diffRows = []; - this.getOriginalProduct$(edit).subscribe((original: any) => { - this.originalProduct = original; - const proposed = edit.proposedChanges || {}; - const keys = Object.keys(proposed); - this.diffRows = keys.map((key) => { - const originalValue = original ? original[key] : undefined; - const proposedValue = proposed[key]; - return { - label: key, - original: originalValue, - proposed: proposedValue, - changed: JSON.stringify(originalValue) !== JSON.stringify(proposedValue) - }; - }); + this.loadingDiffFor = edit; + this.getOriginalProduct$(edit).subscribe({ + next: (original: any) => { + this.loadingDiffFor = null; + this.dialog.open(ProductEditDiffDialogComponent, { + width: '820px', + data: { + productName: edit.savingsProductName, + original: original ?? {}, + proposed: edit.proposedChanges ?? {} + } + }); + }, + error: () => { + this.loadingDiffFor = null; + } }); } closeDiff(): void { - this.selectedEdit = null; - this.originalProduct = null; - this.diffRows = []; + this.loadingDiffFor = null; } approve(edit: any): void { diff --git a/src/app/tasks/checker-inbox-and-tasks-tabs/pending-savings-products/product-edit-diff-dialog/product-edit-diff-dialog.component.html b/src/app/tasks/checker-inbox-and-tasks-tabs/pending-savings-products/product-edit-diff-dialog/product-edit-diff-dialog.component.html new file mode 100644 index 000000000..4e143f412 --- /dev/null +++ b/src/app/tasks/checker-inbox-and-tasks-tabs/pending-savings-products/product-edit-diff-dialog/product-edit-diff-dialog.component.html @@ -0,0 +1,53 @@ + +

+ difference + + Proposed changes — {{ productName }} + +

+ +
+ @if (rows.length === 0) { +
+ check_circle +

No changes to review

+

+ This edit was submitted without changing any product setting — every submitted value matches the current + product. +

+
+ } @else { +

+ {{ rows.length }} field{{ rows.length === 1 ? '' : 's' }} changed. Unchanged fields are hidden. +

+ + + + + + + + + + + @for (row of rows; track row.label) { + + + + + + } + +
FieldCurrentProposed
{{ row.label }}{{ row.original }}{{ row.proposed }}
+ } +
+ + + + diff --git a/src/app/tasks/checker-inbox-and-tasks-tabs/pending-savings-products/product-edit-diff-dialog/product-edit-diff-dialog.component.scss b/src/app/tasks/checker-inbox-and-tasks-tabs/pending-savings-products/product-edit-diff-dialog/product-edit-diff-dialog.component.scss new file mode 100644 index 000000000..3a82f2792 --- /dev/null +++ b/src/app/tasks/checker-inbox-and-tasks-tabs/pending-savings-products/product-edit-diff-dialog/product-edit-diff-dialog.component.scss @@ -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/. + */ + +$border: #e2e8f0; +$muted: #64748b; + +.ped-title { + display: flex; + align-items: center; + gap: 10px; + font-size: 15px; + font-weight: 600; + color: #0f172a; + border-bottom: 1px solid $border; + padding-bottom: 12px; + margin-bottom: 0; + + &__icon { + color: #0ea5e9; + } +} + +.ped-content { + max-height: 70vh; + overflow-y: auto; + padding-top: 16px; +} + +.ped-summary { + margin: 0 0 14px; + font-size: 12.5px; + color: $muted; +} + +.ped-table { + width: 100%; + border-collapse: collapse; + font-size: 12.5px; + + th, + td { + text-align: left; + padding: 9px 12px; + border-bottom: 1px solid $border; + vertical-align: top; + } + + th { + color: $muted; + font-weight: 600; + text-transform: uppercase; + font-size: 11px; + letter-spacing: 0.4px; + background: #f8fafc; + position: sticky; + top: 0; + } + + tbody tr:hover { + background: #fffbeb; + } + + &__label { + font-weight: 600; + color: #1e293b; + width: 34%; + } + + &__old { + color: #b91c1c; + text-decoration: line-through; + width: 33%; + } + + &__new { + color: #15803d; + font-weight: 600; + width: 33%; + } +} + +.ped-empty { + display: flex; + flex-direction: column; + align-items: center; + text-align: center; + padding: 32px 16px; + + &__icon { + font-size: 40px; + width: 40px; + height: 40px; + color: #16a34a; + margin-bottom: 10px; + } + + &__title { + margin: 0 0 6px; + font-size: 14px; + font-weight: 600; + color: #1e293b; + } + + &__text { + margin: 0; + font-size: 12.5px; + color: $muted; + max-width: 420px; + } +} diff --git a/src/app/tasks/checker-inbox-and-tasks-tabs/pending-savings-products/product-edit-diff-dialog/product-edit-diff-dialog.component.ts b/src/app/tasks/checker-inbox-and-tasks-tabs/pending-savings-products/product-edit-diff-dialog/product-edit-diff-dialog.component.ts new file mode 100644 index 000000000..43e1e7dea --- /dev/null +++ b/src/app/tasks/checker-inbox-and-tasks-tabs/pending-savings-products/product-edit-diff-dialog/product-edit-diff-dialog.component.ts @@ -0,0 +1,261 @@ +/** + * 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 { + MAT_DIALOG_DATA, + MatDialogRef, + MatDialogTitle, + MatDialogContent, + MatDialogActions, + MatDialogClose +} from '@angular/material/dialog'; +import { MatButton } from '@angular/material/button'; +import { MatIcon } from '@angular/material/icon'; +import { STANDALONE_SHARED_IMPORTS } from 'app/standalone-shared.module'; + +interface DiffRow { + label: string; + original: string; + proposed: string; +} + +/** + * Shows what a pending product edit actually changes: only the fields whose value differs, with the + * original and proposed values side by side. The submitted payload carries every field the edit form + * posted, most of them unchanged, so listing all of them buries the handful a checker needs to look at. + */ +@Component({ + selector: 'mifosx-product-edit-diff-dialog', + templateUrl: './product-edit-diff-dialog.component.html', + styleUrls: ['./product-edit-diff-dialog.component.scss'], + imports: [ + ...STANDALONE_SHARED_IMPORTS, + MatDialogTitle, + MatDialogContent, + MatDialogActions, + MatDialogClose, + MatButton, + MatIcon + ] +}) +export class ProductEditDiffDialogComponent implements OnInit { + dialogRef = inject>(MatDialogRef); + data = inject(MAT_DIALOG_DATA); + + productName = ''; + rows: DiffRow[] = []; + + /** Request plumbing the edit form has to send, but which isn't a product setting a checker reviews. */ + private static readonly IGNORED_KEYS = new Set([ + 'locale', + 'dateFormat', + 'monthDayFormat', + 'id', + 'productId', + 'savingsProductId' + ]); + + /** + * Payload fields the stored product exposes under a different path, so the "current" side has + * something to compare against instead of always reading as newly set. + */ + private static readonly FIELD_ALIASES: Record = { + currencyCode: 'currency.code', + digitsAfterDecimal: 'currency.decimalPlaces', + inMultiplesOf: 'currency.inMultiplesOf', + taxGroupId: 'taxGroup.id' + }; + + /** + * GL-account fields, keyed by their payload name, mapped to where the stored product keeps the + * expanded account under `accountingMappings`. Their option lists live pooled under + * `accountingMappingOptions` rather than a per-field `Options` key, so both sides of the + * diff need this table to turn an account id into the account's name. + */ + private static readonly GL_FIELD_TO_MAPPING: Record = { + savingsReferenceAccountId: 'savingsReferenceAccount', + savingsControlAccountId: 'savingsControlAccount', + transfersInSuspenseAccountId: 'transfersInSuspenseAccount', + interestOnSavingsAccountId: 'interestOnSavingsAccount', + incomeFromFeeAccountId: 'incomeFromFeeAccount', + incomeFromPenaltyAccountId: 'incomeFromPenaltyAccount', + incomeFromInterestId: 'incomeFromInterest', + overdraftPortfolioControlId: 'overdraftPortfolioControl', + writeOffAccountId: 'writeOffAccount', + feesReceivableAccountId: 'feeReceivableAccount', + penaltiesReceivableAccountId: 'penaltyReceivableAccount', + interestPayableAccountId: 'interestPayableAccount', + interestReceivableAccountId: 'interestReceivableAccount', + escheatLiabilityId: 'escheatLiabilityAccount' + }; + + private glAccountNamesById = new Map(); + + ngOnInit(): void { + this.productName = this.data.productName ?? ''; + const original = this.data.original ?? {}; + const proposed = this.data.proposed ?? {}; + this.buildGlAccountLookup(original); + + this.rows = Object.keys(proposed) + .filter((key) => !ProductEditDiffDialogComponent.IGNORED_KEYS.has(key)) + .map((key) => ({ key, originalValue: this.originalValueFor(key, original) })) + .filter(({ key, originalValue }) => !this.sameValue(originalValue, proposed[key])) + .map(({ key, originalValue }) => ({ + label: this.humanizeKey(key), + original: this.displayValue(originalValue, key, original), + proposed: this.displayValue(proposed[key], key, original) + })) + .sort((a, b) => a.label.localeCompare(b.label)); + } + + /** Pools every GL account the template offers so an account id can be shown as its name. */ + private buildGlAccountLookup(template: any): void { + const optionGroups = template?.accountingMappingOptions ?? {}; + for (const group of Object.values(optionGroups)) { + if (!Array.isArray(group)) { + continue; + } + for (const account of group) { + if (account?.id === undefined) { + continue; + } + const label = account.name ?? account.glCode ?? null; + if (label) { + this.glAccountNamesById.set(String(account.id), account.glCode ? `${label} (${account.glCode})` : label); + } + } + } + } + + /** Reads the current value for a payload field, following an alias or GL mapping when needed. */ + private originalValueFor(key: string, original: any): any { + const glMappingKey = ProductEditDiffDialogComponent.GL_FIELD_TO_MAPPING[key]; + if (glMappingKey) { + return original?.accountingMappings?.[glMappingKey] ?? null; + } + const alias = ProductEditDiffDialogComponent.FIELD_ALIASES[key]; + if (alias) { + return alias.split('.').reduce((acc: any, part: string) => (acc == null ? acc : acc[part]), original); + } + return original?.[key]; + } + + /** + * Enum/option fields arrive from the edit as a bare id, which reads as a meaningless number in the + * table. The product fetched with template=true carries the matching option list under a predictable + * `Options` key (accountingRule -> accountingRuleOptions), so the id is resolved back to the + * label the maker actually picked. Returns null when there is no such list to resolve against. + */ + private resolveOptionLabel(fieldKey: string, id: any, template: any): string | null { + if (ProductEditDiffDialogComponent.GL_FIELD_TO_MAPPING[fieldKey]) { + return this.glAccountNamesById.get(String(id)) ?? null; + } + const options = template?.[`${fieldKey}Options`]; + if (!Array.isArray(options)) { + return null; + } + const match = options.find((option: any) => String(option?.id) === String(id)); + if (!match) { + return null; + } + return match.value ?? match.name ?? match.code ?? null; + } + + /** + * The stored product returns enum/option fields as objects ({id, value}) while the submitted payload + * carries just the id, so a raw comparison reports every such field as changed. Comparing on the id + * (and coercing numeric strings) keeps the list to fields whose value genuinely moved. + */ + private sameValue(a: any, b: any): boolean { + return JSON.stringify(this.normalize(a)) === JSON.stringify(this.normalize(b)); + } + + private normalize(value: any): any { + if (value === null || value === undefined || value === '') { + return null; + } + // Dates come back from the product as [yyyy, m, d] but are submitted as "yyyy-MM-dd"; without + // folding both to one form every date field reads as changed on every edit. + const asDate = this.toIsoDate(value); + if (asDate !== null) { + return asDate; + } + if (Array.isArray(value)) { + // An empty array and an absent value mean the same thing here. + return value.length === 0 ? null : value.map((item) => this.normalize(item)); + } + if (typeof value === 'object') { + if (value.id !== undefined) { + return this.normalize(value.id); + } + return Object.keys(value).length === 0 ? null : JSON.stringify(value); + } + if (typeof value === 'boolean') { + return value; + } + const asNumber = Number(value); + return Number.isNaN(asNumber) ? value : asNumber; + } + + /** Recognises both date shapes the API uses and returns "yyyy-MM-dd", or null if not a date. */ + private toIsoDate(value: any): string | null { + if (Array.isArray(value) && value.length === 3 && value.every((part) => typeof part === 'number')) { + const [ + year, + month, + day + ] = value; + return `${year}-${String(month).padStart(2, '0')}-${String(day).padStart(2, '0')}`; + } + if (typeof value === 'string' && /^\d{4}-\d{2}-\d{2}$/.test(value)) { + return value; + } + return null; + } + + private displayValue(value: any, fieldKey: string, template: any): string { + if (value === null || value === undefined || value === '') { + return '—'; + } + const asDate = this.toIsoDate(value); + if (asDate !== null) { + return asDate; + } + if (Array.isArray(value)) { + return value.length === 0 ? '—' : `${value.length} item${value.length === 1 ? '' : 's'}`; + } + if (typeof value === 'object') { + if (Object.keys(value).length === 0) { + return '—'; + } + // An expanded GL account -- shown the same way as the resolved id on the other side. + if (value.name && value.glCode) { + return `${value.name} (${value.glCode})`; + } + // Already an expanded {id, value} option from the stored product. + return value.value ?? value.name ?? value.code ?? (value.id !== undefined ? String(value.id) : '—'); + } + if (typeof value === 'boolean') { + return value ? 'Yes' : 'No'; + } + // A bare id that names an option -- show the option's label, not the number. + return this.resolveOptionLabel(fieldKey, value, template) ?? String(value); + } + + /** camelCase / PascalCase field name to a spaced, capitalised label. */ + private humanizeKey(key: string): string { + const spaced = key + .replace(/([a-z0-9])([A-Z])/g, '$1 $2') + .replace(/([A-Z]+)([A-Z][a-z])/g, '$1 $2') + .replace(/[_-]+/g, ' ') + .trim(); + return spaced.charAt(0).toUpperCase() + spaced.slice(1); + } +} From f0e059c6211fb2f5590df966fd4ec7fcdba25228 Mon Sep 17 00:00:00 2001 From: Nitesh Yadav Date: Sun, 23 Aug 2026 16:46:22 +0530 Subject: [PATCH 05/64] feat(deposits): FD Renewal History tab, redesigned Payment action, and renewal-aware Transactions/certificate WHAT THIS CHANGES ================== Frontend counterpart to the backend FD in-place renewal, senior-citizen rate, and Payment action work. 1. RENEWAL HISTORY TAB (new) -- fixed-deposit-account-view/renewal-history-tab/. A vertical timeline, one card per term of the FD, oldest first. Backend data arrives as a list of lowercase-keyed maps (Postgres unquoted SQL aliases), which the component normalises. Includes term-rate-chart-dialog, a popup showing one term's full rate-by-tenure chart, including the senior-citizen column, with an "Applied" badge on the rate that was actually used. Route added in fixed-deposits-routing.module.ts, tab link added in fixed-deposit-account-view.component.html (shown only when the account has renewal history). 2. PAYMENT DIALOG (new) -- fixed-deposit-account-view/custom-dialogs/payment-dialog/. Card-based picker for the four supported closure types (Transfer to Savings, Re-Invest Principal and Interest, Re-Invest Principal Only, Partial Re-Invest), with Withdraw Deposit filtered out client-side. Fetches its own template via a new fixedDepositsService method (getFixedDepositsAccountPaymentTemplate, command=payment) rather than reusing the Close template, since the Payment template is the only one the backend includes PARTIAL_REINVEST in. The Partial Re-Invest amount cap is date-dependent: the backend bounds it against the account balance AFTER posting interest to the chosen closed-on date, which lands on the maturity amount on or after maturity and on the current balance before it. The dialog mirrors this (reinvestCap / processingOnOrAfterMaturity) so the shown split actually adds up and the client-side validator agrees with the server. This was wrong in an earlier pass of this work (compared against a fixed figure regardless of date) and was caught and fixed via a real reconciliation check (77000 + 23000 did not equal the displayed total) before this commit. Added: a loading-failure state (previously the spinner ran forever on a failed template fetch), an inline submit-error message (previously a failed POST left the dialog open with no feedback), an empty-savings- accounts hint, and accessibility attributes on the option cards (role="radio", aria-checked, a visible check icon, focus-visible outline) since selection was previously colour-only. 3. TRANSACTIONS TAB -- merges synthetic "FD Renewed" marker rows into the real transaction list, since an in-place renewal posts no actual ledger transaction and would otherwise look like the balance changed for no reason. 4. INTEREST RATE CHART TAB -- added the senior-citizen rate column. 5. DEPOSIT CERTIFICATE DIALOG -- lists the full renewal history on FD certificates. renewalCount is renewalHistory.length - 1 (the first row is the original term, not a renewal); an earlier version of this used the raw row count and overstated the renewal count by one on a customer-facing document, caught and fixed before this commit. 6. CLIENTS GENERAL TAB -- fixedDepositAccountsForDisplay folds a renewed FD's superseded terms out of the account list (each renewal used to show as two unrelated accounts, one Closed and one Active). Memoised on the source array's identity so it does not rebuild, and rebind the table, on every change-detection tick. Only rewrites FD rows; Savings, RD, Current, and Share account rows pass through this getter untouched, and the RD/Savings sections of the template were left bound directly to the original savingAccounts array. KNOWN CAVEATS ============= - This depends on the paired backend commit (same date, "feat(deposits): FD in-place renewal, senior-citizen rate persistence, and Payment action"). Deploying this frontend without that backend change means the Payment dialog and Renewal History tab will call endpoints that do not exist yet. - Renewal-history rows rely on the backend returning lowercase column keys (a Postgres-specific behaviour of unquoted SQL aliases via JdbcTemplate). Confirmed correct against this project's Postgres dev database; if this project ever runs on a different database engine, that assumption would need re-checking. - src/environments/.env.ts is intentionally excluded from this commit; it is a local build-version stamp file that should never be committed. Co-Authored-By: Claude Sonnet 5 --- .../general-tab/general-tab.component.html | 21 +- .../general-tab/general-tab.component.scss | 14 + .../general-tab/general-tab.component.ts | 52 ++- .../payment-dialog.component.html | 191 +++++++++ .../payment-dialog.component.scss | 296 ++++++++++++++ .../payment-dialog.component.ts | 288 ++++++++++++++ .../fixed-deposit-account-view.component.html | 15 +- .../fixed-deposit-account-view.component.ts | 29 ++ .../fixed-deposits-buttons.config.ts | 3 + .../interest-rate-chart-tab.component.html | 11 + .../interest-rate-chart-tab.component.ts | 1 + .../renewal-history-tab.component.html | 139 +++++++ .../renewal-history-tab.component.scss | 364 ++++++++++++++++++ .../renewal-history-tab.component.ts | 235 +++++++++++ .../term-rate-chart-dialog.component.html | 77 ++++ .../term-rate-chart-dialog.component.scss | 159 ++++++++ .../term-rate-chart-dialog.component.ts | 93 +++++ .../transactions-tab.component.html | 70 ++-- .../transactions-tab.component.scss | 28 ++ .../transactions-tab.component.ts | 68 +++- .../fixed-deposits-routing.module.ts | 10 + .../fixed-deposits/fixed-deposits.service.ts | 11 + .../deposit-certificate-dialog.component.html | 46 ++- .../deposit-certificate-dialog.component.scss | 44 +++ .../deposit-certificate-dialog.component.ts | 27 +- 25 files changed, 2256 insertions(+), 36 deletions(-) create mode 100644 src/app/deposits/fixed-deposits/fixed-deposit-account-view/custom-dialogs/payment-dialog/payment-dialog.component.html create mode 100644 src/app/deposits/fixed-deposits/fixed-deposit-account-view/custom-dialogs/payment-dialog/payment-dialog.component.scss create mode 100644 src/app/deposits/fixed-deposits/fixed-deposit-account-view/custom-dialogs/payment-dialog/payment-dialog.component.ts create mode 100644 src/app/deposits/fixed-deposits/fixed-deposit-account-view/renewal-history-tab/renewal-history-tab.component.html create mode 100644 src/app/deposits/fixed-deposits/fixed-deposit-account-view/renewal-history-tab/renewal-history-tab.component.scss create mode 100644 src/app/deposits/fixed-deposits/fixed-deposit-account-view/renewal-history-tab/renewal-history-tab.component.ts create mode 100644 src/app/deposits/fixed-deposits/fixed-deposit-account-view/renewal-history-tab/term-rate-chart-dialog/term-rate-chart-dialog.component.html create mode 100644 src/app/deposits/fixed-deposits/fixed-deposit-account-view/renewal-history-tab/term-rate-chart-dialog/term-rate-chart-dialog.component.scss create mode 100644 src/app/deposits/fixed-deposits/fixed-deposit-account-view/renewal-history-tab/term-rate-chart-dialog/term-rate-chart-dialog.component.ts diff --git a/src/app/clients/clients-view/general-tab/general-tab.component.html b/src/app/clients/clients-view/general-tab/general-tab.component.html index 2123fb7ae..242346b4e 100644 --- a/src/app/clients/clients-view/general-tab/general-tab.component.html +++ b/src/app/clients/clients-view/general-tab/general-tab.component.html @@ -435,12 +435,17 @@ @if (!showClosedFixedAccounts) {
- +
@@ -524,12 +529,20 @@ @if (showClosedFixedAccounts) {
-
{{ 'labels.inputs.Account No' | translate }} - + + @if (element.renewalCycleNo) { + Renewal {{ element.renewalCycleNo }} + }
+
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 11848e360..f913a9808 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 @@ -209,6 +209,20 @@ $red: #d93025; gap: 0; } +// Marks an FD row that is a renewal of an earlier term, since the row shows the original FD number +// rather than this term's own account number. +.fd-renewal-badge { + display: inline-block; + margin-left: 8px; + padding: 1px 7px; + border-radius: 10px; + background-color: $gray-200; + color: $gray-700; + font-size: 11px; + font-weight: 500; + white-space: nowrap; +} + // ── Table Wrapper ── .table-wrapper { border-radius: 0 0 8px 8px; 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 d957b00e2..333c43931 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 @@ -234,7 +234,57 @@ export class GeneralTabComponent implements OnDestroy { } get fixedDepositCount(): number { - return this.savingAccounts.filter((a: any) => a.depositType?.code === 'depositAccountType.fixedDeposit').length; + return this.fixedDepositAccountsForDisplay.filter( + (a: any) => a.depositType?.code === 'depositAccountType.fixedDeposit' + ).length; + } + + /** + * Accounts for the Fixed Deposit sections, with superseded renewal cycles folded away. + * + * An FD renewal closes one account and opens the next term as its own account row, so a renewed FD + * would otherwise appear twice on this page -- the matured term under Closed and the new term under + * Active -- reading as two unrelated deposits. A closed term is therefore dropped whenever a later + * cycle of the same FD (same `fdAccountNumber`) exists, leaving one row per FD that shows its + * current term. A chain that has genuinely ended keeps its final term, so it still lists as closed. + * + * Only FD rows are rewritten; savings, RD, current and share accounts pass through untouched. + */ + get fixedDepositAccountsForDisplay(): any[] { + // Memoised on the source array's identity. This getter is bound straight to two [dataSource] inputs, so + // returning a freshly built array on every change-detection pass would make the table re-bind and + // re-render its rows on every tick -- mouse moves included. + if (this.fdDisplayCacheSource !== this.savingAccounts) { + this.fdDisplayCacheSource = this.savingAccounts; + this.fdDisplayCache = this.foldSupersededFdCycles(this.savingAccounts); + } + return this.fdDisplayCache; + } + + private fdDisplayCacheSource: any[] = null; + private fdDisplayCache: any[] = []; + + private foldSupersededFdCycles(accounts: any[]): any[] { + const latestCycleByFd = new Map(); + for (const account of accounts) { + if (account?.depositType?.code !== 'depositAccountType.fixedDeposit') { + continue; + } + const fdNumber = account.fdAccountNumber ?? account.accountNo; + const cycle = account.renewalCycleNo ?? 0; + if (cycle > (latestCycleByFd.get(fdNumber) ?? -1)) { + latestCycleByFd.set(fdNumber, cycle); + } + } + + return accounts.filter((account: any) => { + if (account?.depositType?.code !== 'depositAccountType.fixedDeposit') { + return true; + } + const fdNumber = account.fdAccountNumber ?? account.accountNo; + const cycle = account.renewalCycleNo ?? 0; + return cycle === latestCycleByFd.get(fdNumber); + }); } get recurringDepositCount(): number { diff --git a/src/app/deposits/fixed-deposits/fixed-deposit-account-view/custom-dialogs/payment-dialog/payment-dialog.component.html b/src/app/deposits/fixed-deposits/fixed-deposit-account-view/custom-dialogs/payment-dialog/payment-dialog.component.html new file mode 100644 index 000000000..659f79528 --- /dev/null +++ b/src/app/deposits/fixed-deposits/fixed-deposit-account-view/custom-dialogs/payment-dialog/payment-dialog.component.html @@ -0,0 +1,191 @@ + + +
+
+ +
+

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

+

Process this Fixed Deposit now -- works whether it's Active or Matured.

+
+
+ +
+ @if (loadingTemplate) { +
+ +
+ } @else if (loadFailed) { + + } @else { +
+ @if (reinvestCap !== null && reinvestCap !== undefined) { +
+ + {{ + remainderIsApproximate + ? 'Amount available on the selected date' + : ('labels.inputs.Maturity Amount' | translate) + }} + + {{ reinvestCap | formatNumber }} +
+ } + + + {{ 'labels.inputs.Closed On Date' | translate }} + + + + @if (paymentForm.controls.closedOnDate.hasError('required')) { + + {{ 'labels.inputs.Closed On Date' | translate }} {{ 'labels.commons.is' | translate }} + {{ 'labels.commons.required' | translate }} + + } + + + +
+ @for (closureOption of onAccountClosureOptions; track closureOption) { + + } +
+ @if ( + paymentForm.controls.onAccountClosureId.hasError('required') && + paymentForm.controls.onAccountClosureId.touched + ) { +
+ {{ 'labels.inputs.Account action' | translate }} {{ 'labels.commons.is' | translate }} + {{ 'labels.commons.required' | translate }} +
+ } + + @if (isPartialReinvest) { +
+ + {{ 'labels.inputs.Reinvest Amount' | translate }} + + @if (paymentForm.controls.reinvestAmount?.hasError('required')) { + + {{ 'labels.inputs.Reinvest Amount' | translate }} {{ 'labels.commons.is' | translate }} + {{ 'labels.commons.required' | translate }} + + } + @if (paymentForm.controls.reinvestAmount?.hasError('min')) { + {{ 'labels.inputs.Reinvest Amount must be greater than zero' | translate }} + } + @if (paymentForm.controls.reinvestAmount?.hasError('belowMaturityAmount')) { + {{ + 'labels.inputs.Reinvest Amount must be less than the maturity amount' | translate + }} + } + + +
+ {{ 'labels.inputs.Remainder to Transfer' | translate }} + {{ + remainderAmount !== null ? (remainderAmount | formatNumber) : '—' + }} +
+
+ @if (remainderIsApproximate) { +

+ + The selected date is before maturity. Interest up to that date is posted first, so the amount actually + transferred will be at least the figure shown. +

+ } + } + + @if (noDestinationAccounts) { + + } + + @if (paymentForm.contains('toSavingsAccountId')) { + + {{ 'labels.inputs.Transfer to Savings' | translate }} + + @for (account of savingsAccountsData; track account) { + + {{ account.accountNo }} + + } + + @if (paymentForm.controls.toSavingsAccountId?.hasError('required')) { + + {{ 'labels.inputs.Transfer to savings account' | translate }} {{ 'labels.commons.is' | translate }} + {{ 'labels.commons.required' | translate }} + + } + + } + + @if (paymentForm.contains('transferDescription')) { + + {{ 'labels.inputs.Transfer Description' | translate }} + + + } + + + {{ 'labels.inputs.Note' | translate }} + + + + @if (submitError) { + + } + + } +
+ +
+ + +
+
diff --git a/src/app/deposits/fixed-deposits/fixed-deposit-account-view/custom-dialogs/payment-dialog/payment-dialog.component.scss b/src/app/deposits/fixed-deposits/fixed-deposit-account-view/custom-dialogs/payment-dialog/payment-dialog.component.scss new file mode 100644 index 000000000..48967437c --- /dev/null +++ b/src/app/deposits/fixed-deposits/fixed-deposit-account-view/custom-dialogs/payment-dialog/payment-dialog.component.scss @@ -0,0 +1,296 @@ +/** + * 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/. + */ + +$navy: #0d1b2a; +$slate: #475569; +$muted: #64748b; +$border: #e2e8f0; +$soft: #f8fafc; +$blue: #1e40af; +$blue-soft: #eff6ff; +$green: #166534; +$green-soft: #f0fdf4; + +.pmt { + display: flex; + flex-direction: column; + min-height: 320px; + max-height: 85vh; + overflow: hidden; + margin: -24px; +} + +.pmt__header { + display: flex; + align-items: flex-start; + gap: 14px; + padding: 22px 28px 18px; + background: linear-gradient(135deg, #1e3a8a 0%, #1e40af 55%, #4338ca 100%); + color: #fff; + flex: 0 0 auto; +} + +.pmt__header-ico { + width: 40px; + height: 40px; + min-width: 40px; + border-radius: 10px; + background: rgb(255 255 255 / 15%); + display: flex; + align-items: center; + justify-content: center; + font-size: 18px; +} + +.pmt__title { + margin: 0; + font-size: 19px; + font-weight: 700; +} + +.pmt__subtitle { + margin: 4px 0 0; + font-size: 12.5px; + color: rgb(255 255 255 / 82%); + line-height: 1.4; +} + +.pmt__content { + padding: 22px 28px 8px !important; + overflow-y: auto; + flex: 1 1 auto; +} + +.pmt__loading { + display: flex; + justify-content: center; + align-items: center; + min-height: 260px; +} + +.pmt__stat { + display: flex; + flex-direction: column; + gap: 2px; + background: $blue-soft; + border: 1px solid #dbeafe; + border-radius: 10px; + padding: 12px 16px; + margin-bottom: 18px; +} + +.pmt__stat-label { + font-size: 11px; + font-weight: 700; + text-transform: uppercase; + letter-spacing: 0.05em; + color: $blue; +} + +.pmt__stat-value { + font-size: 22px; + font-weight: 800; + color: $navy; + font-variant-numeric: tabular-nums; +} + +.pmt__date { + width: 100%; +} + +.pmt__section-label { + font-size: 11.5px; + font-weight: 700; + text-transform: uppercase; + letter-spacing: 0.05em; + color: $slate; + margin: 4px 0 10px; +} + +.pmt__options { + display: grid; + grid-template-columns: repeat(auto-fit, minmax(190px, 1fr)); + gap: 10px; + margin-bottom: 6px; +} + +.pmt__option { + display: flex; + align-items: flex-start; + gap: 10px; + text-align: left; + background: #fff; + border: 1.5px solid $border; + border-radius: 10px; + padding: 12px 14px; + cursor: pointer; + font: inherit; + transition: + border-color 0.15s, + background 0.15s, + box-shadow 0.15s; +} + +.pmt__option:hover { + border-color: #93c5fd; + background: $blue-soft; +} + +.pmt__option--active { + border-color: $blue; + background: $blue-soft; + box-shadow: 0 0 0 1px $blue; +} + +.pmt__option-ico { + width: 30px; + height: 30px; + min-width: 30px; + border-radius: 8px; + background: $soft; + color: $blue; + display: flex; + align-items: center; + justify-content: center; + font-size: 13px; +} + +.pmt__option--active .pmt__option-ico { + background: $blue; + color: #fff; +} + +.pmt__option-text { + display: flex; + flex-direction: column; + gap: 2px; +} + +.pmt__option-title { + font-size: 13px; + font-weight: 700; + color: $navy; +} + +.pmt__option-desc { + font-size: 11px; + color: $muted; + line-height: 1.35; +} + +.pmt__option:focus-visible { + outline: 2px solid $blue; + outline-offset: 2px; +} + +.pmt__option-check { + margin-left: auto; + color: $blue; + font-size: 13px; +} + +.pmt__option-error { + color: #b91c1c; + font-size: 12px; + margin: 4px 0 14px; +} + +.pmt__error { + display: flex; + align-items: flex-start; + gap: 4px; + background: #fef2f2; + border: 1px solid #fecaca; + border-radius: 8px; + color: #b91c1c; + font-size: 12.5px; + line-height: 1.45; + padding: 10px 14px; + margin: 4px 0 8px; +} + +.pmt__hint { + display: flex; + align-items: flex-start; + gap: 4px; + background: $soft; + border: 1px solid $border; + border-radius: 8px; + color: $slate; + font-size: 12px; + line-height: 1.45; + padding: 9px 13px; + margin: 0 0 10px; +} + +.pmt__hint--warn { + background: #fffbeb; + border-color: #fde68a; + color: #92400e; +} + +.pmt__split { + display: flex; + align-items: center; + gap: 12px; + background: $soft; + border: 1px dashed #cbd5e1; + border-radius: 10px; + padding: 14px 16px 0; + margin: 4px 0 8px; + flex-wrap: wrap; +} + +.pmt__split-input { + flex: 1 1 180px; + min-width: 160px; +} + +.pmt__split-arrow { + color: $muted; + font-size: 14px; + margin-bottom: 20px; +} + +.pmt__split-remainder { + display: flex; + flex-direction: column; + gap: 2px; + background: $green-soft; + border: 1px solid #bbf7d0; + border-radius: 8px; + padding: 8px 14px; + margin-bottom: 14px; + flex: 1 1 180px; + min-width: 160px; +} + +.pmt__split-remainder-label { + font-size: 10.5px; + font-weight: 700; + text-transform: uppercase; + letter-spacing: 0.04em; + color: $green; +} + +.pmt__split-remainder-value { + font-size: 16px; + font-weight: 800; + color: $green; + font-variant-numeric: tabular-nums; +} + +.pmt__content mat-form-field { + width: 100%; +} + +.pmt__actions { + padding: 14px 28px 20px !important; + border-top: 1px solid $border; + flex: 0 0 auto; +} diff --git a/src/app/deposits/fixed-deposits/fixed-deposit-account-view/custom-dialogs/payment-dialog/payment-dialog.component.ts b/src/app/deposits/fixed-deposits/fixed-deposit-account-view/custom-dialogs/payment-dialog/payment-dialog.component.ts new file mode 100644 index 000000000..cd433f83c --- /dev/null +++ b/src/app/deposits/fixed-deposits/fixed-deposit-account-view/custom-dialogs/payment-dialog/payment-dialog.component.ts @@ -0,0 +1,288 @@ +/** + * 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 { + MAT_DIALOG_DATA, + MatDialogRef, + MatDialogContent, + MatDialogActions, + MatDialogClose +} from '@angular/material/dialog'; +import { CdkScrollable } from '@angular/cdk/scrolling'; +import { CdkTextareaAutosize } from '@angular/cdk/text-field'; +import { MatProgressSpinnerModule } from '@angular/material/progress-spinner'; +import { FaIconComponent } from '@fortawesome/angular-fontawesome'; +import { UntypedFormBuilder, UntypedFormGroup, UntypedFormControl, ValidatorFn, Validators } from '@angular/forms'; + +/** Custom Services */ +import { FixedDepositsService } from '../../../fixed-deposits.service'; +import { SettingsService } from 'app/settings/settings.service'; +import { Dates } from 'app/core/utils/dates'; +import { FormatNumberPipe } from '../../../../../pipes/format-number.pipe'; +import { STANDALONE_SHARED_IMPORTS } from 'app/standalone-shared.module'; + +/** + * "Payment" dialog -- an always-available alternative to Premature Close (Active-only, no reinvest) and + * Close (Matured-only, no partial option). Works on an FD in either state and offers a fourth choice, + * Partial Reinvest, alongside the ones the backend's `DepositAccountOnClosureType` already had: the + * entered amount renews on this same account, the rest transfers to a savings account picked here. + */ +@Component({ + selector: 'mifosx-payment-dialog', + templateUrl: './payment-dialog.component.html', + styleUrls: ['./payment-dialog.component.scss'], + imports: [ + ...STANDALONE_SHARED_IMPORTS, + CdkScrollable, + MatDialogContent, + MatDialogActions, + MatDialogClose, + CdkTextareaAutosize, + MatProgressSpinnerModule, + FaIconComponent, + FormatNumberPipe + ] +}) +export class PaymentDialogComponent implements OnInit { + /** The `onAccountClosureId` codes each closure type is registered under on the backend. */ + private static readonly WITHDRAW_DEPOSIT_ID = 100; + private static readonly TRANSFER_TO_SAVINGS_ID = 200; + private static readonly REINVEST_FULL_ID = 300; + private static readonly REINVEST_PRINCIPAL_ID = 400; + private static readonly PARTIAL_REINVEST_ID = 500; + + private static readonly ACTION_ICONS: Record = { + [PaymentDialogComponent.WITHDRAW_DEPOSIT_ID]: 'money-bill-wave', + [PaymentDialogComponent.TRANSFER_TO_SAVINGS_ID]: 'wallet', + [PaymentDialogComponent.REINVEST_FULL_ID]: 'sync-alt', + [PaymentDialogComponent.REINVEST_PRINCIPAL_ID]: 'piggy-bank', + [PaymentDialogComponent.PARTIAL_REINVEST_ID]: 'arrows-alt-h' + }; + + private static readonly ACTION_DESCRIPTIONS: Record = { + [PaymentDialogComponent.WITHDRAW_DEPOSIT_ID]: 'Pay out the full balance in cash', + [PaymentDialogComponent.TRANSFER_TO_SAVINGS_ID]: 'Move the full balance to another savings account', + [PaymentDialogComponent.REINVEST_FULL_ID]: 'Renew the full amount, same account number', + [PaymentDialogComponent.REINVEST_PRINCIPAL_ID]: + 'Renew only the principal; interest transferred to a savings account', + [PaymentDialogComponent.PARTIAL_REINVEST_ID]: 'Renew part of it here; transfer the rest' + }; + + dialogRef = inject>(MatDialogRef); + data: { accountId: number | string; accountBalance?: number; maturityDate?: any } = inject(MAT_DIALOG_DATA); + private formBuilder = inject(UntypedFormBuilder); + private fixedDepositsService = inject(FixedDepositsService); + private settingsService = inject(SettingsService); + private dateUtils = inject(Dates); + + paymentForm: UntypedFormGroup; + /** Loaded once, on open, from the same closure template the existing Close action already uses. */ + onAccountClosureOptions: any[] = []; + savingsAccountsData: any[] = []; + maturityAmount: number = null; + loadingTemplate = true; + loadFailed = false; + submitting = false; + submitError: string = null; + maxDate: Date; + + ngOnInit(): void { + this.maxDate = this.settingsService.businessDate; + this.paymentForm = this.formBuilder.group({ + closedOnDate: [ + '', + Validators.required + ], + onAccountClosureId: [ + '', + Validators.required + ], + note: [''] + }); + this.paymentForm.get('onAccountClosureId').valueChanges.subscribe((id: number) => this.onClosureTypeChange(id)); + // The cap depends on whether the chosen date is on or after maturity, so a date change can make an + // already-entered amount valid or invalid. Without this the amount keeps its verdict from the old date. + this.paymentForm + .get('closedOnDate') + .valueChanges.subscribe(() => + this.paymentForm.get('reinvestAmount')?.updateValueAndValidity({ emitEvent: false }) + ); + + this.fixedDepositsService.getFixedDepositsAccountPaymentTemplate(this.data.accountId as string).subscribe({ + next: (response: any) => { + // Withdraw Deposit books cash out of the vault immediately, which isn't appropriate for a + // staff-triggered Payment that may run well before or after the actual counter transaction -- + // Transfer to Savings or a Reinvest option covers every real case instead. + this.onAccountClosureOptions = (response.onAccountClosureOptions ?? []).filter( + (option: any) => option.id !== PaymentDialogComponent.WITHDRAW_DEPOSIT_ID + ); + this.savingsAccountsData = response.savingsAccounts ?? []; + this.maturityAmount = response.maturityAmount; + this.loadingTemplate = false; + }, + // Without this the spinner would run forever on a failed load, leaving Cancel as the only way out + // and no indication of why the form never appeared. + error: () => { + this.loadingTemplate = false; + this.loadFailed = true; + } + }); + } + + /** + * The amount being split, and the bound the entered partial amount is checked against. + *

+ * The server derives this from the account's balance AFTER it posts interest up to the chosen date, so which figure + * matches depends on that date. On or after maturity it posts the remaining full-rate interest, landing on the + * maturity amount. Before maturity it posts penalty-aware interest instead, which lands somewhere between the current + * balance and the maturity amount -- unknowable here, so the current balance is used as a floor that can never accept + * an amount the server would reject, and {@link remainderIsApproximate} tells the user the transfer will be larger. + */ + get reinvestCap(): number | null { + if (this.processingOnOrAfterMaturity) { + return this.maturityAmount ?? this.data.accountBalance ?? null; + } + return this.data.accountBalance ?? this.maturityAmount ?? null; + } + + /** Whether the chosen closure date is on or after the deposit's maturity date. */ + private get processingOnOrAfterMaturity(): boolean { + const chosen = this.paymentForm?.get('closedOnDate')?.value; + if (!chosen || !this.data.maturityDate) { + return false; + } + return this.toTime(chosen) >= this.toTime(this.data.maturityDate); + } + + /** Dates arrive here either as a Date from the picker or as the API's [yyyy, mm, dd] array. */ + private toTime(value: any): number { + if (value instanceof Date) { + return new Date(value.getFullYear(), value.getMonth(), value.getDate()).getTime(); + } + if (Array.isArray(value)) { + const [ + year, + month, + day + ] = value; + return new Date(year, (month ?? 1) - 1, day ?? 1).getTime(); + } + return new Date(value).getTime(); + } + + /** True when the cap is the pre-maturity balance, so the figures shown are a floor rather than exact. */ + get remainderIsApproximate(): boolean { + return !this.processingOnOrAfterMaturity; + } + + iconFor(id: number): string { + return PaymentDialogComponent.ACTION_ICONS[id] ?? 'circle'; + } + + descriptionFor(id: number): string { + return PaymentDialogComponent.ACTION_DESCRIPTIONS[id] ?? ''; + } + + selectClosureType(id: number): void { + this.paymentForm.get('onAccountClosureId').setValue(id); + this.paymentForm.get('onAccountClosureId').markAsTouched(); + } + + /** Adds/removes the destination-account and partial-amount controls to match what the chosen type needs. */ + private onClosureTypeChange(id: number): void { + const needsDestinationAccount = + id === PaymentDialogComponent.TRANSFER_TO_SAVINGS_ID || + id === PaymentDialogComponent.PARTIAL_REINVEST_ID || + id === PaymentDialogComponent.REINVEST_PRINCIPAL_ID; + if (needsDestinationAccount) { + if (!this.paymentForm.contains('toSavingsAccountId')) { + this.paymentForm.addControl('toSavingsAccountId', new UntypedFormControl('', Validators.required)); + this.paymentForm.addControl('transferDescription', new UntypedFormControl('')); + } + } else { + this.paymentForm.removeControl('toSavingsAccountId'); + this.paymentForm.removeControl('transferDescription'); + } + + if (id === PaymentDialogComponent.PARTIAL_REINVEST_ID) { + if (!this.paymentForm.contains('reinvestAmount')) { + this.paymentForm.addControl( + 'reinvestAmount', + new UntypedFormControl('', [ + Validators.required, + Validators.min(0.01), + this.belowMaturityAmountValidator() + ]) + ); + } + } else { + this.paymentForm.removeControl('reinvestAmount'); + } + } + + /** Mirrors the backend's own `0 < reinvestAmount < accountBalance` rule, so a bad amount fails before submit. */ + private belowMaturityAmountValidator(): ValidatorFn { + return (control) => { + const cap = Number(this.reinvestCap); + if (!cap || control.value === null || control.value === '') { + return null; + } + return Number(control.value) >= cap ? { belowMaturityAmount: true } : null; + }; + } + + get isPartialReinvest(): boolean { + return this.paymentForm?.get('onAccountClosureId')?.value === PaymentDialogComponent.PARTIAL_REINVEST_ID; + } + + /** True when the chosen action needs a destination account but the customer has none to pick. */ + get noDestinationAccounts(): boolean { + return ( + !this.loadingTemplate && this.paymentForm?.contains('toSavingsAccountId') && this.savingsAccountsData.length === 0 + ); + } + + /** What leaves this FD when the entered partial amount is kept behind; null until both figures are known. */ + get remainderAmount(): number | null { + const reinvestAmount = this.paymentForm?.get('reinvestAmount')?.value; + if (!this.isPartialReinvest || reinvestAmount === null || reinvestAmount === '' || !this.reinvestCap) { + return null; + } + return Number(this.reinvestCap) - Number(reinvestAmount); + } + + submit(): void { + const formValue = { ...this.paymentForm.value }; + const locale = this.settingsService.language.code; + const dateFormat = this.settingsService.dateFormat; + if (formValue.closedOnDate instanceof Date) { + formValue.closedOnDate = this.dateUtils.formatDate(formValue.closedOnDate, dateFormat); + } + const data = { ...formValue, dateFormat, locale }; + this.submitting = true; + this.submitError = null; + this.fixedDepositsService + .executeFixedDepositsAccountCommand(this.data.accountId as string, 'payment', data) + .subscribe({ + next: () => { + this.dialogRef.close({ confirm: true }); + }, + // Keep the dialog open with the entered values and say what went wrong, rather than leaving a + // permanently disabled Confirm behind whatever modal the global interceptor threw up. + error: (error: any) => { + this.submitting = false; + this.submitError = + error?.error?.defaultUserMessage ?? + error?.error?.errors?.[0]?.defaultUserMessage ?? + 'This payment could not be processed. Please review the details and try again.'; + } + }); + } +} diff --git a/src/app/deposits/fixed-deposits/fixed-deposit-account-view/fixed-deposit-account-view.component.html b/src/app/deposits/fixed-deposits/fixed-deposit-account-view/fixed-deposit-account-view.component.html index 793cce9cb..0ac452a8e 100644 --- a/src/app/deposits/fixed-deposits/fixed-deposit-account-view/fixed-deposit-account-view.component.html +++ b/src/app/deposits/fixed-deposits/fixed-deposit-account-view/fixed-deposit-account-view.component.html @@ -37,7 +37,7 @@ {{ 'labels.inputs.Account Number' | translate }} @@ -230,6 +230,19 @@ {{ 'labels.inputs.Transactions' | translate }} } + @if (fixedDepositsAccountData.renewalHistory?.length) { + + + {{ 'labels.inputs.Renewal History' | translate }} + + } { + if (response?.confirm) { + this.reload(); + } + }); + } + /** * Deletes Fixed Deposits Account. */ diff --git a/src/app/deposits/fixed-deposits/fixed-deposit-account-view/fixed-deposits-buttons.config.ts b/src/app/deposits/fixed-deposits/fixed-deposit-account-view/fixed-deposits-buttons.config.ts index 271987c9e..3146a730c 100644 --- a/src/app/deposits/fixed-deposits/fixed-deposit-account-view/fixed-deposits-buttons.config.ts +++ b/src/app/deposits/fixed-deposits/fixed-deposit-account-view/fixed-deposits-buttons.config.ts @@ -116,6 +116,9 @@ export class FixedDepositsButtonsConfiguration { }, { name: 'Add Charge' + }, + { + name: 'Payment' } ]; break; diff --git a/src/app/deposits/fixed-deposits/fixed-deposit-account-view/interest-rate-chart-tab/interest-rate-chart-tab.component.html b/src/app/deposits/fixed-deposits/fixed-deposit-account-view/interest-rate-chart-tab/interest-rate-chart-tab.component.html index 5e9de8301..bf81fc96f 100644 --- a/src/app/deposits/fixed-deposits/fixed-deposit-account-view/interest-rate-chart-tab/interest-rate-chart-tab.component.html +++ b/src/app/deposits/fixed-deposits/fixed-deposit-account-view/interest-rate-chart-tab/interest-rate-chart-tab.component.html @@ -36,6 +36,17 @@

+ + + + + diff --git a/src/app/deposits/fixed-deposits/fixed-deposit-account-view/interest-rate-chart-tab/interest-rate-chart-tab.component.ts b/src/app/deposits/fixed-deposits/fixed-deposit-account-view/interest-rate-chart-tab/interest-rate-chart-tab.component.ts index 7934d41b5..f400aa9f4 100644 --- a/src/app/deposits/fixed-deposits/fixed-deposit-account-view/interest-rate-chart-tab/interest-rate-chart-tab.component.ts +++ b/src/app/deposits/fixed-deposits/fixed-deposit-account-view/interest-rate-chart-tab/interest-rate-chart-tab.component.ts @@ -71,6 +71,7 @@ export class InterestRateChartTabComponent { 'period', 'amountRange', 'interest', + 'seniorCitizen', 'description', 'actions' ]; diff --git a/src/app/deposits/fixed-deposits/fixed-deposit-account-view/renewal-history-tab/renewal-history-tab.component.html b/src/app/deposits/fixed-deposits/fixed-deposit-account-view/renewal-history-tab/renewal-history-tab.component.html new file mode 100644 index 000000000..3648e2ee1 --- /dev/null +++ b/src/app/deposits/fixed-deposits/fixed-deposit-account-view/renewal-history-tab/renewal-history-tab.component.html @@ -0,0 +1,139 @@ + + +
+ @if (renewalHistory.length) { + +
+
+ FD Number + {{ fdNumber }} +
+
+ Terms So Far + {{ renewalHistory.length }} +
+
+ Original Principal + {{ originalPrincipal | currency: currency.code : 'symbol-narrow' }} +
+
+ Current Value + {{ currentValue | currency: currency.code : 'symbol-narrow' }} +
+
+ Lifetime Interest + {{ lifetimeInterestEarned | currency: currency.code : 'symbol-narrow' }} +
+
+ + +
+ } @else { +
+ +

{{ 'labels.messages.No Renewal History Available' | translate }}

+
+ } +
diff --git a/src/app/deposits/fixed-deposits/fixed-deposit-account-view/renewal-history-tab/renewal-history-tab.component.scss b/src/app/deposits/fixed-deposits/fixed-deposit-account-view/renewal-history-tab/renewal-history-tab.component.scss new file mode 100644 index 000000000..bb6e474cd --- /dev/null +++ b/src/app/deposits/fixed-deposits/fixed-deposit-account-view/renewal-history-tab/renewal-history-tab.component.scss @@ -0,0 +1,364 @@ +/** + * 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/. + */ + +$navy: #0d1b2a; +$slate: #475569; +$muted: #64748b; +$border: #e2e8f0; +$soft: #f8fafc; +$positive: #047857; +$positive-soft: #ecfdf5; +$accent: #2563eb; +$accent-soft: #eff6ff; + +.rh-container { + max-width: 860px; + margin: 0 auto; + padding: 4px 0 24px; +} + +/* ─── Summary strip ─────────────────────────────────────────────────── */ +.rh-summary { + display: grid; + grid-template-columns: repeat(5, minmax(0, 1fr)); + gap: 1px; + background: $border; + border: 1px solid $border; + border-radius: 14px; + overflow: hidden; + margin-bottom: 28px; + box-shadow: 0 1px 3px rgb(15 23 42 / 4%); + + &__item { + display: flex; + flex-direction: column; + gap: 4px; + padding: 14px 16px; + background: #fff; + } + + &__label { + font-size: 10.5px; + font-weight: 600; + color: $muted; + text-transform: uppercase; + letter-spacing: 0.04em; + } + + &__value { + font-size: 15px; + font-weight: 700; + color: $navy; + font-variant-numeric: tabular-nums; + } + + &__item--highlight { + background: $accent-soft; + + .rh-summary__value { + color: $accent; + } + } + + &__item--positive { + background: $positive-soft; + + .rh-summary__value { + color: $positive; + } + } +} + +@media (width <= 720px) { + .rh-summary { + grid-template-columns: repeat(2, minmax(0, 1fr)); + } +} + +/* ─── Timeline rail ──────────────────────────────────────────────────── */ +.rh-timeline { + display: flex; + flex-direction: column; +} + +.rh-node { + display: flex; + gap: 18px; +} + +.rh-node__rail { + flex: none; + width: 20px; + display: flex; + flex-direction: column; + align-items: center; +} + +.rh-node__dot { + position: relative; + width: 16px; + height: 16px; + margin-top: 22px; + border-radius: 50%; + background: #cbd5e1; + border: 3px solid #fff; + box-shadow: 0 0 0 2px #cbd5e1; + flex: none; + + &--active { + background: $positive; + box-shadow: 0 0 0 2px $positive; + } +} + +.rh-node__pulse { + position: absolute; + inset: -6px; + border-radius: 50%; + border: 2px solid $positive; + opacity: 0.6; + animation: rh-pulse 1.8s ease-out infinite; +} + +@keyframes rh-pulse { + 0% { + transform: scale(0.6); + opacity: 0.7; + } + + 100% { + transform: scale(1.6); + opacity: 0; + } +} + +.rh-node__line { + flex: 1; + width: 2px; + min-height: 24px; + background: $border; + margin: 4px 0; +} + +/* ─── Term card ──────────────────────────────────────────────────────── */ +.rh-card { + flex: 1; + min-width: 0; + background: #fff; + border: 1px solid $border; + border-radius: 14px; + box-shadow: 0 1px 3px rgb(15 23 42 / 4%); + padding: 16px 18px; + margin-bottom: 20px; + transition: box-shadow 0.15s ease; + + &--active { + border-color: rgb(4 120 87 / 20%); + box-shadow: 0 2px 10px rgb(4 120 87 / 8%); + } + + &:hover { + box-shadow: 0 4px 14px rgb(15 23 42 / 8%); + } + + &__head { + display: flex; + align-items: flex-start; + justify-content: space-between; + gap: 12px; + flex-wrap: wrap; + } + + &__title-group { + display: flex; + align-items: center; + gap: 8px; + flex-wrap: wrap; + } + + &__title { + margin: 0; + font-size: 15px; + font-weight: 700; + color: $navy; + } + + &__period { + display: flex; + align-items: center; + gap: 8px; + margin-top: 8px; + font-size: 12.5px; + color: $slate; + font-weight: 500; + + &-ico { + color: $muted; + font-size: 11px; + } + + &-arrow { + color: $muted; + font-size: 10px; + } + } + + &__tenure { + color: $muted; + font-weight: 400; + } + + &__foot { + display: flex; + align-items: center; + gap: 6px; + margin-top: 12px; + padding-top: 10px; + border-top: 1px dashed $border; + font-size: 11.5px; + color: $muted; + + &-ico { + font-size: 10px; + } + + a { + color: $accent; + font-weight: 600; + text-decoration: none; + + &:hover { + text-decoration: underline; + } + } + } +} + +/* ─── Status badge / auto-renewed tag ───────────────────────────────── */ +.rh-badge { + display: inline-flex; + align-items: center; + gap: 5px; + padding: 2px 9px; + border-radius: 999px; + background: $soft; + color: $muted; + font-size: 10.5px; + font-weight: 700; + letter-spacing: 0.02em; + text-transform: uppercase; + + &--active { + background: $positive-soft; + color: $positive; + } +} + +.rh-tag { + display: inline-flex; + align-items: center; + gap: 5px; + padding: 2px 9px; + border-radius: 999px; + background: $accent-soft; + color: $accent; + font-size: 10.5px; + font-weight: 600; +} + +/* ─── View Rate Chart button ─────────────────────────────────────────── */ +.rh-chart-btn { + display: inline-flex; + align-items: center; + gap: 7px; + padding: 6px 14px; + border: 1px solid $border; + border-radius: 999px; + background: #fff; + color: $accent; + font-size: 12px; + font-weight: 600; + cursor: pointer; + transition: all 0.15s ease; + white-space: nowrap; + + &:hover { + background: $accent-soft; + border-color: $accent; + } +} + +/* ─── Stat grid ──────────────────────────────────────────────────────── */ +.rh-stats { + display: grid; + grid-template-columns: repeat(4, minmax(0, 1fr)); + gap: 10px; + margin-top: 14px; +} + +@media (width <= 640px) { + .rh-stats { + grid-template-columns: repeat(2, minmax(0, 1fr)); + } +} + +.rh-stat { + display: flex; + flex-direction: column; + gap: 3px; + padding: 9px 12px; + background: $soft; + border-radius: 10px; + + &__label { + font-size: 10px; + font-weight: 600; + color: $muted; + text-transform: uppercase; + letter-spacing: 0.03em; + } + + &__value { + font-size: 13.5px; + font-weight: 700; + color: $navy; + font-variant-numeric: tabular-nums; + + &--positive { + color: $positive; + } + } + + &--highlight { + background: $accent-soft; + + .rh-stat__value { + color: $accent; + } + } +} + +/* ─── Empty state ────────────────────────────────────────────────────── */ +.rh-empty { + display: flex; + flex-direction: column; + align-items: center; + gap: 10px; + padding: 64px 16px; + color: $muted; + + &__icon { + font-size: 30px; + opacity: 0.4; + } + + &__text { + margin: 0; + font-size: 13px; + } +} diff --git a/src/app/deposits/fixed-deposits/fixed-deposit-account-view/renewal-history-tab/renewal-history-tab.component.ts b/src/app/deposits/fixed-deposits/fixed-deposit-account-view/renewal-history-tab/renewal-history-tab.component.ts new file mode 100644 index 000000000..34bd1bb2b --- /dev/null +++ b/src/app/deposits/fixed-deposits/fixed-deposit-account-view/renewal-history-tab/renewal-history-tab.component.ts @@ -0,0 +1,235 @@ +/** + * 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 { MatDialog } from '@angular/material/dialog'; +import { Currency } from 'app/shared/models/general.model'; +import { CurrencyPipe } from '@angular/common'; +import { DateFormatPipe } from '../../../../pipes/date-format.pipe'; +import { FaIconComponent } from '@fortawesome/angular-fontawesome'; +import { STANDALONE_SHARED_IMPORTS } from 'app/standalone-shared.module'; +import { ProductsService } from 'app/products/products.service'; +import { TermRateChartDialogComponent } from './term-rate-chart-dialog/term-rate-chart-dialog.component'; + +/** + * Every term of one FD, oldest first, as a vertical timeline -- one card per renewal cycle. Its own tab + * rather than a card on General, since a long-lived FD can carry many terms and each one's full detail + * (dates, rate, principal, interest, and its own rate chart) needs real room to read comfortably. + */ +@Component({ + selector: 'mifosx-renewal-history-tab', + templateUrl: './renewal-history-tab.component.html', + styleUrls: ['./renewal-history-tab.component.scss'], + imports: [ + ...STANDALONE_SHARED_IMPORTS, + FaIconComponent, + CurrencyPipe, + DateFormatPipe + ] +}) +export class RenewalHistoryTabComponent implements OnInit { + private route = inject(ActivatedRoute); + private router = inject(Router); + private dialog = inject(MatDialog); + private productsService = inject(ProductsService); + + fixedDepositsAccountData: any; + currency: Currency; + + /** + * The bank's currently published senior-citizen premium per tenure band, for this FD's type. Fetched once + * from the same CBS Base Interest Rates screen staff maintain, so a term's rate-chart popup can show the + * premium alongside the standard rate -- neither the account's own chart snapshot nor the archived history + * carries a senior-citizen column, since that concept only exists on the base rate master. + */ + private activeBaseSlabs: any[] = []; + + constructor() { + this.route.parent.data.subscribe((data: { fixedDepositsAccountData: any }) => { + this.fixedDepositsAccountData = data.fixedDepositsAccountData; + this.currency = this.fixedDepositsAccountData.currency; + }); + } + + ngOnInit(): void { + const fdType = this.fixedDepositsAccountData?.fdType; + if (!fdType) { + return; + } + this.productsService.getFdBaseInterestRates(fdType).subscribe({ + next: (rates: any[]) => { + const active = (rates || []).find((r: any) => r.isActive); + this.activeBaseSlabs = active?.slabs ?? []; + }, + error: () => {} + }); + } + + /** + * The published senior-citizen rate for a tenure band, matched by period type and range against the active + * base rate card. Returns null when nothing matches (no published base version for this FD type, or this + * particular band isn't on it, or the matching band simply carries no premium) -- displayed as a blank + * rather than a misleading zero. + */ + private seniorCitizenRateFor(periodTypeEnum: number, fromPeriod: number, toPeriod: number | null): number | null { + const match = this.activeBaseSlabs.find( + (s) => + s.periodTypeEnum === periodTypeEnum && + s.fromPeriod === fromPeriod && + (s.toPeriod ?? null) === (toPeriod ?? null) + ); + return match?.seniorCitizenRate > 0 ? match.seniorCitizenRate : null; + } + + /** + * A reinvested FD always keeps the same client/group/center as the account it came from, so the + * counterpart account's view page sits at the same route -- only the account-id segment differs. + */ + private siblingAccountLink(targetAccountId: number): string | null { + const segments = this.router.url.split('?')[0].split('/').filter(Boolean); + const idx = segments.indexOf('fixed-deposits-accounts'); + if (idx === -1) return null; + return '/' + segments.slice(0, idx + 1).join('/') + '/' + targetAccountId + '/general'; + } + + siblingLink(targetAccountId: number): string | null { + return this.siblingAccountLink(targetAccountId); + } + + /** + * Every term of this FD, oldest first, as resolved by the backend. Keys are lower-cased because they + * arrive straight from the SQL result-set column labels. + */ + get renewalHistory(): any[] { + return this.fixedDepositsAccountData?.renewalHistory ?? []; + } + + get fdNumber(): string { + return this.fixedDepositsAccountData?.fdAccountNumber ?? this.fixedDepositsAccountData?.accountNo; + } + + /** The very first term's opening principal -- what the customer originally deposited. */ + get originalPrincipal(): number | null { + return this.renewalHistory[0]?.depositamount ?? null; + } + + /** The latest term's settled or projected value -- what the FD is worth right now. */ + get currentValue(): number | null { + const latest = this.renewalHistory[this.renewalHistory.length - 1]; + return latest ? this.maturityFor(latest) : null; + } + + /** + * Total interest earned across every term, settled terms plus the running term's interest-to-date. Null + * only when there is nothing to sum, so the summary strip can tell "no data yet" apart from "zero". + */ + get lifetimeInterestEarned(): number | null { + if (!this.renewalHistory.length) { + return null; + } + let total = 0; + for (const term of this.renewalHistory) { + if (term.interestearned != null) { + total += Number(term.interestearned); + } else { + const projected = this.maturityFor(term); + if (projected != null && term.depositamount != null) { + total += Number(projected) - Number(term.depositamount); + } + } + } + return total; + } + + /** + * A term's settled maturity amount, or its projection while it is still running. Resolved here rather + * than inline so the choice isn't left to operator precedence between `??` and the currency pipe. + */ + maturityFor(term: any): number | null { + return term?.maturedamount ?? term?.projectedmaturityamount ?? null; + } + + /** periodTypeEnum values as archived -- same encoding as SavingsPeriodFrequencyType (DAYS=0 ... YEARS=3). */ + private static readonly PERIOD_TYPE_LABELS: Record = { + 0: 'Days', + 1: 'Weeks', + 2: 'Months', + 3: 'Years' + }; + + /** "10 Years" rather than a bare number, using the same period-type encoding the archived charts use. */ + tenureLabel(term: any): string { + if (term?.tenure == null) { + return ''; + } + const unit = RenewalHistoryTabComponent.PERIOD_TYPE_LABELS[term.tenurefrequencyenum] ?? ''; + return unit ? `${term.tenure} ${unit}` : `${term.tenure}`; + } + + /** How the renewal happened, for display as a small tag -- null when the term hasn't renewed (yet). */ + renewalTypeLabel(term: any): string | null { + if (term?.renewaltype === 'AUTO') return 'Auto-Renewed'; + if (term?.renewaltype === 'MANUAL') return 'Renewed by Staff'; + return null; + } + + renewalTypeIcon(term: any): string { + return term?.renewaltype === 'MANUAL' ? 'user-cog' : 'robot'; + } + + /** + * The rate-by-tenure chart for one term of this FD's history, normalised to one shape regardless of source. + * + * A term's chart is only archived once it closes via an in-place renewal, so the term still running has no + * archived rows -- for that one term (recognisable as the one matching this account's own id), the + * account's own live chart is shown instead, since it is the same data. The two sources use different key + * casing and represent the period type differently (a raw enum int here, an expanded {value} object on the + * live chart), which this maps into one consistent shape the dialog can render without knowing which source + * it came from. + */ + private chartSlabsFor(term: any): any[] { + const raw = term?.chartSlabs?.length + ? term.chartSlabs + : term?.accountid === this.fixedDepositsAccountData?.id + ? (this.fixedDepositsAccountData?.accountChart?.chartSlabs ?? []) + : []; + return raw.map((slab: any) => { + const periodTypeEnum = slab.periodType?.id ?? slab.periodtypeenum; + const fromPeriod = slab.fromperiod ?? slab.fromPeriod; + const toPeriod = slab.toperiod ?? slab.toPeriod; + // The archived/live row's own senior_citizen_rate is the true figure as published when this term + // was actually priced -- preferred whenever present. The live lookup against today's base rate + // card is only a fallback, for the currently-running term (not archived yet) or an older archive + // row written before that column existed -- flagged as such so the dialog can say so. + const persistedSeniorRate = slab.seniorcitizenrate ?? slab.seniorCitizenRate ?? null; + return { + description: slab.description, + fromPeriod, + toPeriod, + periodTypeLabel: slab.periodType?.value ?? RenewalHistoryTabComponent.PERIOD_TYPE_LABELS[periodTypeEnum] ?? '', + annualInterestRate: slab.annualinterestrate ?? slab.annualInterestRate, + seniorCitizenRate: + persistedSeniorRate ?? this.seniorCitizenRateFor(periodTypeEnum, fromPeriod, toPeriod ?? null), + seniorCitizenRateIsLive: persistedSeniorRate == null + }; + }); + } + + /** Opens the full rate chart for one term in a popup, rather than pushing every later term down the page. */ + openRateChart(term: any): void { + this.dialog.open(TermRateChartDialogComponent, { + width: '560px', + data: { + term, + slabs: this.chartSlabsFor(term), + isSeniorCitizen: !!this.fixedDepositsAccountData?.isSeniorCitizen + } + }); + } +} diff --git a/src/app/deposits/fixed-deposits/fixed-deposit-account-view/renewal-history-tab/term-rate-chart-dialog/term-rate-chart-dialog.component.html b/src/app/deposits/fixed-deposits/fixed-deposit-account-view/renewal-history-tab/term-rate-chart-dialog/term-rate-chart-dialog.component.html new file mode 100644 index 000000000..7eadb1179 --- /dev/null +++ b/src/app/deposits/fixed-deposits/fixed-deposit-account-view/renewal-history-tab/term-rate-chart-dialog/term-rate-chart-dialog.component.html @@ -0,0 +1,77 @@ + + +
+

+ + + {{ termLabel }} — Rate Chart + {{ term.activatedondate | dateFormat }} → {{ term.maturitydate | dateFormat }} + +

+ +
+ @if (slabs.length) { +
{{ 'labels.inputs.Account No' | translate }} - + + @if (element.renewalCycleNo) { + Renewal {{ element.renewalCycleNo }} + } {{ chartSlab.annualInterestRate | formatNumber }} % Senior Citizen + {{ + chartSlab.seniorCitizenRate !== null && chartSlab.seniorCitizenRate !== undefined + ? (chartSlab.seniorCitizenRate | formatNumber) + ' %' + : '-' + }} + {{ 'labels.inputs.Description' | translate }} {{ chartSlab.description }}
+ + + + + + + + + + @for (slab of slabs; track $index) { + + + + + + + } + +
DescriptionTenureRate + + Senior Citizen +
{{ slab.description }}{{ slab.fromPeriod }} – {{ slab.toPeriod || '∞' }} {{ slab.periodTypeLabel }} + {{ slab.annualInterestRate }}% + @if (isAppliedSlab(slab)) { + Applied + } + + {{ + slab.seniorCitizenRate !== null && slab.seniorCitizenRate !== undefined + ? slab.seniorCitizenRate + '%' + : '—' + }} + @if (isAppliedSeniorSlab(slab)) { + Applied + } +
+ @if (hasLiveSeniorRate) { +

+ + Senior citizen rate for this term wasn't recorded at the time; the figure shown is today's published rate, for + reference. +

+ } + } @else { +
+ +

No rate chart was archived for this term.

+
+ } +
+ +
+ +
+ diff --git a/src/app/deposits/fixed-deposits/fixed-deposit-account-view/renewal-history-tab/term-rate-chart-dialog/term-rate-chart-dialog.component.scss b/src/app/deposits/fixed-deposits/fixed-deposit-account-view/renewal-history-tab/term-rate-chart-dialog/term-rate-chart-dialog.component.scss new file mode 100644 index 000000000..a0f4ec851 --- /dev/null +++ b/src/app/deposits/fixed-deposits/fixed-deposit-account-view/renewal-history-tab/term-rate-chart-dialog/term-rate-chart-dialog.component.scss @@ -0,0 +1,159 @@ +/** + * 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/. + */ + +$navy: #0d1b2a; +$slate: #475569; +$muted: #64748b; +$border: #e2e8f0; +$soft: #f8fafc; +$positive: #047857; +$positive-soft: #ecfdf5; + +$accent: #b8860b; + +.trc-dialog { + min-width: 420px; + max-width: 540px; +} + +.trc-header { + display: flex; + align-items: center; + gap: 12px; + margin: 0 0 4px; + + &__icon { + flex: none; + width: 38px; + height: 38px; + display: inline-flex; + align-items: center; + justify-content: center; + border-radius: 10px; + background: linear-gradient(135deg, #3b82f6 0%, #1e40af 100%); + color: #fff; + font-size: 15px; + } + + &__text { + display: flex; + flex-direction: column; + min-width: 0; + } + + &__title { + font-size: 15px; + font-weight: 700; + color: $navy; + line-height: 1.3; + } + + &__sub { + font-size: 12px; + color: $muted; + font-weight: 500; + } +} + +.trc-content { + padding-top: 4px; +} + +.trc-table { + width: 100%; + border-collapse: collapse; + font-size: 13px; + + th { + padding: 8px 10px; + background: $soft; + color: $muted; + font-weight: 600; + text-align: left; + border-bottom: 1px solid $border; + } + + td { + padding: 9px 10px; + color: $slate; + border-bottom: 1px solid $border; + font-variant-numeric: tabular-nums; + } + + &__rate-col { + text-align: right; + } + + &__row--applied { + background: $positive-soft; + + td { + color: $positive; + font-weight: 700; + } + } + + &__sc-ico { + color: $accent; + margin-right: 4px; + } + + &__sc-col { + color: $accent; + font-weight: 600; + } +} + +.trc-applied-badge { + display: inline-block; + margin-left: 6px; + padding: 1px 7px; + border-radius: 10px; + background: $positive; + color: #fff; + font-size: 9.5px; + font-weight: 700; + letter-spacing: 0.03em; + text-transform: uppercase; + vertical-align: middle; +} + +.trc-note { + display: flex; + align-items: flex-start; + gap: 6px; + margin: 10px 2px 0; + color: $muted; + font-size: 11px; + font-style: italic; + line-height: 1.4; +} + +.trc-empty { + display: flex; + flex-direction: column; + align-items: center; + gap: 8px; + padding: 32px 8px; + color: $muted; + text-align: center; + + &__icon { + font-size: 22px; + opacity: 0.5; + } + + p { + margin: 0; + font-size: 13px; + } +} + +.trc-actions { + padding-top: 6px; +} diff --git a/src/app/deposits/fixed-deposits/fixed-deposit-account-view/renewal-history-tab/term-rate-chart-dialog/term-rate-chart-dialog.component.ts b/src/app/deposits/fixed-deposits/fixed-deposit-account-view/renewal-history-tab/term-rate-chart-dialog/term-rate-chart-dialog.component.ts new file mode 100644 index 000000000..3ce7957f9 --- /dev/null +++ b/src/app/deposits/fixed-deposits/fixed-deposit-account-view/renewal-history-tab/term-rate-chart-dialog/term-rate-chart-dialog.component.ts @@ -0,0 +1,93 @@ +/** + * Copyright since 2025 Mifos Initiative + * + * This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. + */ + +import { Component, inject } from '@angular/core'; +import { + MAT_DIALOG_DATA, + MatDialogRef, + MatDialogTitle, + MatDialogContent, + MatDialogActions, + MatDialogClose +} from '@angular/material/dialog'; +import { MatButton } from '@angular/material/button'; +import { FaIconComponent } from '@fortawesome/angular-fontawesome'; +import { STANDALONE_SHARED_IMPORTS } from 'app/standalone-shared.module'; + +/** + * The full rate-by-tenure chart one term of an FD was priced on, in a popup rather than inline in the + * timeline: a chart can run to several tenure bands, and showing it in place would push every later term + * down the page every time one is opened. + */ +@Component({ + selector: 'mifosx-term-rate-chart-dialog', + templateUrl: './term-rate-chart-dialog.component.html', + styleUrls: ['./term-rate-chart-dialog.component.scss'], + imports: [ + ...STANDALONE_SHARED_IMPORTS, + MatDialogTitle, + MatDialogContent, + MatDialogActions, + MatDialogClose, + MatButton, + FaIconComponent + ] +}) +export class TermRateChartDialogComponent { + dialogRef = inject>(MatDialogRef); + data = inject(MAT_DIALOG_DATA); + + get term(): any { + return this.data.term; + } + + get slabs(): any[] { + return this.data.slabs ?? []; + } + + get termLabel(): string { + return this.term?.renewalcycleno === 0 ? 'Original Term' : 'Renewal ' + this.term?.renewalcycleno; + } + + /** Whether the DEPOSITOR (not just the tenure band) was a senior citizen for this term. */ + get isSeniorCitizen(): boolean { + return !!this.data.isSeniorCitizen; + } + + /** + * True when at least one senior-citizen figure shown is a live lookup against today's base rate card rather than the + * true rate persisted for this specific term -- only possible for the currently-running term (not archived yet) or an + * older archive row that predates the persisted column. Drives whether the disclaimer note is shown at all. + */ + get hasLiveSeniorRate(): boolean { + return this.slabs.some((s) => s.seniorCitizenRateIsLive && s.seniorCitizenRate != null); + } + + /** + * True for the STANDARD-rate cell that was actually applied to this term. Never true for a senior-citizen + * term, since that term was priced off the senior column instead -- see {@link isAppliedSeniorSlab}. + * Compared as numbers since one side arrives as a string from the currency-agnostic archive row. + */ + isAppliedSlab(slab: any): boolean { + return ( + !this.isSeniorCitizen && + this.term?.interestrate != null && + Number(slab.annualInterestRate) === Number(this.term.interestrate) + ); + } + + /** True for the SENIOR-CITIZEN cell that was actually applied, for a term held by a senior-citizen depositor. */ + isAppliedSeniorSlab(slab: any): boolean { + return ( + this.isSeniorCitizen && + slab.seniorCitizenRate != null && + this.term?.interestrate != null && + Number(slab.seniorCitizenRate) === Number(this.term.interestrate) + ); + } +} 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 ae4dafdf5..6e447a59c 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 @@ -36,7 +36,11 @@ [ngClass]="savingsTransactionColor(transaction)" (click)="showTransactions(transaction)" > - {{ transaction.id }} + @if (transaction.isRenewalEvent) { + + } @else { + {{ transaction.id }} + }
@@ -72,7 +76,13 @@ [ngClass]="savingsTransactionColor(transaction)" (click)="showTransactions(transaction)" > - {{ isDebit(transaction.transactionType) ? (transaction.amount | formatNumber) : 'N/A' }} + {{ + transaction.isRenewalEvent + ? '—' + : isDebit(transaction.transactionType) + ? (transaction.amount | formatNumber) + : 'N/A' + }} @@ -84,7 +94,13 @@ [ngClass]="savingsTransactionColor(transaction)" (click)="showTransactions(transaction)" > - {{ !isDebit(transaction.transactionType) ? (transaction.amount | formatNumber) : 'N/A' }} + {{ + transaction.isRenewalEvent + ? '—' + : !isDebit(transaction.transactionType) + ? (transaction.amount | formatNumber) + : 'N/A' + }} @@ -96,36 +112,40 @@ [ngClass]="savingsTransactionColor(transaction)" (click)="showTransactions(transaction)" > - {{ transaction.runningBalance | formatNumber }} + {{ transaction.isRenewalEvent ? '—' : (transaction.runningBalance | formatNumber) }} {{ 'labels.inputs.Actions' | translate }} - - - - @if (!isFirstTransaction(idx)) { - - } - - + @if (!isFirstTransaction(idx)) { + + } + + + } diff --git a/src/app/deposits/fixed-deposits/fixed-deposit-account-view/transactions-tab/transactions-tab.component.scss b/src/app/deposits/fixed-deposits/fixed-deposit-account-view/transactions-tab/transactions-tab.component.scss index 44062e84c..57358a351 100644 --- a/src/app/deposits/fixed-deposits/fixed-deposit-account-view/transactions-tab/transactions-tab.component.scss +++ b/src/app/deposits/fixed-deposits/fixed-deposit-account-view/transactions-tab/transactions-tab.component.scss @@ -97,4 +97,32 @@ table { color: $muted; font-style: italic; } + + .mat-mdc-cell.renewal-marker { + background: #eff6ff; + color: $blue; + font-style: italic; + cursor: default; + } + + .mat-mdc-row:has(.renewal-marker):hover { + background: #eff6ff; + cursor: default; + } +} + +.txn-tab__renewal-ico { + color: $blue; +} + +.txn-tab__renewal-tag { + display: inline-block; + padding: 2px 8px; + border-radius: 10px; + background: #dbeafe; + color: $blue; + font-size: 10.5px; + font-weight: 700; + text-transform: uppercase; + letter-spacing: 0.04em; } diff --git a/src/app/deposits/fixed-deposits/fixed-deposit-account-view/transactions-tab/transactions-tab.component.ts b/src/app/deposits/fixed-deposits/fixed-deposit-account-view/transactions-tab/transactions-tab.component.ts index 5caa3e47e..07691eaa6 100644 --- a/src/app/deposits/fixed-deposits/fixed-deposit-account-view/transactions-tab/transactions-tab.component.ts +++ b/src/app/deposits/fixed-deposits/fixed-deposit-account-view/transactions-tab/transactions-tab.component.ts @@ -113,13 +113,71 @@ export class TransactionsTabComponent implements OnInit { */ constructor() { this.route.parent.data.subscribe((data: { fixedDepositsAccountData: any }) => { - this.transactionsData = data.fixedDepositsAccountData.transactions; + this.transactionsData = this.withRenewalMarkers( + data.fixedDepositsAccountData.transactions, + data.fixedDepositsAccountData.renewalHistory + ); this.clientId = this.route.parent.snapshot.params['clientId']; this.accountId = this.route.parent.snapshot.params['fixedDepositAccountId']; this.status = data.fixedDepositsAccountData.status.value; }); } + /** + * An in-place renewal moves no money -- the matured balance simply becomes the new term's opening + * principal -- so it posts no withdrawal, deposit, or journal entry (see + * DepositAccountDomainServiceJpa#handleFDAccountMaturityClosure). Without something here, a renewal + * would be invisible in this list even though the account's rate and term just changed. These rows + * are synthesized purely for display from the renewal history already loaded for the Renewal History + * tab -- never written anywhere, never matched against a real transaction id -- so undo, journal-entry + * lookup, and every real transaction's running balance stay exactly as they were. + */ + private withRenewalMarkers(transactions: any[], renewalHistory: any[]): any[] { + const markers: any[] = (renewalHistory ?? []) + .filter((term: any) => term.renewalcycleno > 0) + .map((term: any) => { + const marker: any = { + isRenewalEvent: true, + id: null, + date: term.activatedondate, + amount: null, + runningBalance: null, + reversed: false, + transfer: null, + renewalCycleNo: term.renewalcycleno, + transactionType: { + value: term.renewaltype === 'MANUAL' ? 'FD Renewed (Staff)' : 'FD Auto-Renewed', + accrual: false, + withdrawal: false, + feeDeduction: false, + overdraftInterest: false, + withholdTax: false + } + }; + return marker; + }); + return [ + ...(transactions ?? []), + ...markers + ].sort((a, b) => this.toEpoch(b.date) - this.toEpoch(a.date)); + } + + /** Same [yyyy, MM, dd]-or-ISO-string shape DateFormatPipe already handles, resolved to a sortable epoch. */ + private toEpoch(value: any): number { + if (!value) { + return 0; + } + if (Array.isArray(value)) { + const [ + year, + month, + day + ] = value; + return new Date(year, (month ?? 1) - 1, day ?? 1).getTime(); + } + return new Date(value).getTime(); + } + ngOnInit() { this.hideAccrualsParam = new UntypedFormControl(false); this.hideReversedParam = new UntypedFormControl(false); @@ -161,6 +219,9 @@ export class TransactionsTabComponent implements OnInit { * @param {any} transactionsData Transactions Data */ showTransactions(transactionsData: any) { + if (transactionsData.isRenewalEvent) { + return; + } if (transactionsData.transfer) { this.router.navigate([`account-transfers/account-transfers/${transactionsData.transfer.id}`], { relativeTo: this.route @@ -191,7 +252,10 @@ export class TransactionsTabComponent implements OnInit { this.dataSource.sort = this.sort; } - savingsTransactionColor(transaction: SavingsAccountTransaction): string { + savingsTransactionColor(transaction: any): string { + if (transaction.isRenewalEvent) { + return 'renewal-marker'; + } if (transaction.reversed) { return 'strike'; } else if (transaction.transfer) { diff --git a/src/app/deposits/fixed-deposits/fixed-deposits-routing.module.ts b/src/app/deposits/fixed-deposits/fixed-deposits-routing.module.ts index 68861fff9..eb2f6e5c5 100644 --- a/src/app/deposits/fixed-deposits/fixed-deposits-routing.module.ts +++ b/src/app/deposits/fixed-deposits/fixed-deposits-routing.module.ts @@ -31,6 +31,7 @@ import { FixedDepositsAccountActionsResolver } from './common-resolvers/fixed-de import { FixedDepositsAccountTemplateResolver } from './common-resolvers/fixed-deposit-account-template.resolver'; import { FixedDepositsAccountAndTemplateResolver } from './common-resolvers/fixed-deposit-account-and-template.resolver'; import { GeneralTabComponent } from './fixed-deposit-account-view/general-tab/general-tab.component'; +import { RenewalHistoryTabComponent } from './fixed-deposit-account-view/renewal-history-tab/renewal-history-tab.component'; import { AccountNomineesTabComponent } from 'app/shared/account-nominees-tab/account-nominees-tab.component'; const routes: Routes = [ @@ -80,6 +81,15 @@ const routes: Routes = [ routeParamBreadcrumb: false } }, + { + path: 'renewal-history', + component: RenewalHistoryTabComponent, + data: { + title: 'Fixed Deposit Account Renewal History', + breadcrumb: 'Renewal History', + routeParamBreadcrumb: false + } + }, { path: 'transactions', component: TransactionsTabComponent, diff --git a/src/app/deposits/fixed-deposits/fixed-deposits.service.ts b/src/app/deposits/fixed-deposits/fixed-deposits.service.ts index 6d20ed276..dd82ede92 100644 --- a/src/app/deposits/fixed-deposits/fixed-deposits.service.ts +++ b/src/app/deposits/fixed-deposits/fixed-deposits.service.ts @@ -87,6 +87,17 @@ export class FixedDepositsService { return this.http.get(`/fixeddepositaccounts/${accountId}/template`, { params: httpParams }); } + /** + * Template for the Payment action. Same shape as the closure template, but its `onAccountClosureOptions` also + * include Partial Re-Invest, which the closure commands exclude because only Payment implements it. + * @param {string} accountId Fixed Deposits Account Id + * @returns {Observable} + */ + getFixedDepositsAccountPaymentTemplate(accountId: string): Observable { + const httpParams = new HttpParams().set('command', 'payment'); + return this.http.get(`/fixeddepositaccounts/${accountId}/template`, { params: httpParams }); + } + /** * @param {string} accountId Fixed Deposits Account Id * @returns {Observable} diff --git a/src/app/shared/deposit-certificate-dialog/deposit-certificate-dialog.component.html b/src/app/shared/deposit-certificate-dialog/deposit-certificate-dialog.component.html index e2862f74f..f5d32347b 100644 --- a/src/app/shared/deposit-certificate-dialog/deposit-certificate-dialog.component.html +++ b/src/app/shared/deposit-certificate-dialog/deposit-certificate-dialog.component.html @@ -36,7 +36,7 @@
Account No. - {{ accountData.accountNo }} + {{ accountData.fdAccountNumber || accountData.accountNo }}
Date of Issue @@ -49,9 +49,13 @@ This is to certify that {{ accountData.clientName || accountData.groupName }} holds a {{ accountType === 'FD' ? 'Fixed Deposit' : 'Recurring Deposit' }} account bearing account number - {{ accountData.accountNo }} + {{ accountData.fdAccountNumber || accountData.accountNo }} with Nexora Co-operative Bank Ltd., as per the particulars set out below. + + This account was renewed on maturity {{ renewalCount }} {{ renewalCount === 1 ? 'time' : 'times' }}; the + full renewal history is set out below. +

@@ -129,6 +133,44 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + +
RENEWAL HISTORY
TermFromToRatePrincipalInterestMaturity Amount
{{ term.renewalcycleno === 0 ? 'Original' : 'Renewal ' + term.renewalcycleno }}{{ term.activatedondate | dateFormat }}{{ term.maturitydate | dateFormat }}{{ term.interestrate }}%{{ term.depositamount | currency: accountData.currency.code : 'symbol-narrow' }} + {{ + term.interestearned === null || term.interestearned === undefined + ? '—' + : (term.interestearned | currency: accountData.currency.code : 'symbol-narrow') + }} + {{ maturityFor(term) | currency: accountData.currency.code : 'symbol-narrow' }}
+ +
+ } @else { +
+ +
+ }
+ + + +
+ + +
+
+ + +

{{ data.title }}

+
+
+
+ Shortcut Name:
+ {{ data.title }} +
+
+ Key Combination:
+ {{ formatShortcut(data) }} +
+
+ Action:
+ {{ data.action === 'navigate' ? 'Navigate' : data.action || data.id || 'Predefined' }} +
+ @if (data.route) { +
+ URL / Route:
+ {{ data.route }} +
+ } +
+ Description:
+ {{ getShortcutDescription(data) }} +
+
+
+ + + + + +
diff --git a/src/app/shared/keyboard-shortcuts-dialog/keyboard-shortcuts-dialog.component.scss b/src/app/shared/keyboard-shortcuts-dialog/keyboard-shortcuts-dialog.component.scss index 9ae3dc36b..4160a3a6e 100644 --- a/src/app/shared/keyboard-shortcuts-dialog/keyboard-shortcuts-dialog.component.scss +++ b/src/app/shared/keyboard-shortcuts-dialog/keyboard-shortcuts-dialog.component.scss @@ -6,15 +6,157 @@ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ -.modifier-key { - border: 1px solid #0000004d; - padding: 0.3rem; - background: #f8f8f8; - color: #00000086; - font-weight: bold; - border-radius: 0.3rem; -} +$primary: #1a73e8; +$gray-50: #f8f9fa; +$gray-100: #f1f3f4; +$gray-200: #e8eaed; +$gray-500: #9aa0a6; +$gray-700: #5f6368; +$gray-900: #202124; +$red: #ef4444; +$navy: #0d1b2a; +$white: #fff; .mat-divider.mat-divider-inset { margin-left: 0; } + +/* --- Header (from form-dialog) --- */ +.ks-header { + display: flex; + align-items: center; + justify-content: space-between; + padding: 16px 24px 12px; + border-bottom: 1px solid $gray-200; +} + +.ks-title { + margin: 0 !important; + padding: 0 !important; + font-size: 17px !important; + font-weight: 600 !important; + color: $gray-900; + letter-spacing: -0.2px; +} + +/* --- Table Rows (from bank-calendar) --- */ +.ks-table { + width: 100%; + border-collapse: collapse; + font-size: 12.5px; + + thead th { + position: sticky; + top: 0; + z-index: 1; + background: #f1f5f9; + color: #475569; + text-align: left; + padding: 10px 14px; + font-size: 10.5px; + font-weight: 700; + text-transform: uppercase; + letter-spacing: 0.05em; + border-bottom: 1px solid #e2e8f0; + } + + tbody td { + padding: 9px 14px; + border-bottom: 1px solid #f1f5f9; + vertical-align: middle; + color: $navy; + } + + tbody tr { + transition: background 0.12s; + + &:hover { + background: #f8fafc; + } + } +} + +/* --- Inputs/Forms (from form-dialog) --- */ +.ks-field { + min-width: 0; + + ::ng-deep { + .mat-mdc-form-field { + width: 100%; + } + + .mat-mdc-text-field-wrapper { + background: $gray-50; + border-radius: 8px; + } + + .mdc-text-field--outlined .mdc-notched-outline__leading, + .mdc-text-field--outlined .mdc-notched-outline__trailing, + .mdc-text-field--outlined .mdc-notched-outline__notch { + border-color: $gray-200; + } + + .mdc-text-field--outlined:not(.mdc-text-field--disabled):hover .mdc-notched-outline__leading, + .mdc-text-field--outlined:not(.mdc-text-field--disabled):hover .mdc-notched-outline__trailing, + .mdc-text-field--outlined:not(.mdc-text-field--disabled):hover .mdc-notched-outline__notch { + border-color: $primary; + } + + .mat-mdc-floating-label { + font-size: 13px; + color: $gray-700; + } + + input.mat-mdc-input-element { + font-size: 14px; + color: $gray-900; + } + } +} + +/* --- Dialog Footer & Buttons (from confirmation-dialog) --- */ +.ks-actions { + padding: 14px 24px 20px !important; + border-top: 1px solid $gray-200; + margin: 0 !important; + display: flex !important; + justify-content: space-between !important; + align-items: center !important; + gap: 10px !important; + + .ks-actions__right { + display: flex; + gap: 10px; + align-items: center; + } + + &__cancel { + border-radius: 8px !important; + font-size: 13px !important; + font-weight: 600 !important; + padding: 0 20px !important; + height: 38px !important; + color: $gray-700 !important; + border-color: $gray-200 !important; + } + + &__confirm { + border-radius: 8px !important; + font-size: 13px !important; + font-weight: 600 !important; + padding: 0 24px !important; + height: 38px !important; + letter-spacing: 0.2px; + color: $white !important; + + &--basic { + background: $primary !important; + box-shadow: 0 1px 3px rgb(26 115 232 / 30%) !important; + } + + &--warn { + background: $red !important; + box-shadow: 0 1px 3px rgb(239 68 68 / 30%) !important; + } + } +} diff --git a/src/app/shared/keyboard-shortcuts-dialog/keyboard-shortcuts-dialog.component.ts b/src/app/shared/keyboard-shortcuts-dialog/keyboard-shortcuts-dialog.component.ts index 5ed5ef70e..d4c0ca1b5 100644 --- a/src/app/shared/keyboard-shortcuts-dialog/keyboard-shortcuts-dialog.component.ts +++ b/src/app/shared/keyboard-shortcuts-dialog/keyboard-shortcuts-dialog.component.ts @@ -6,16 +6,31 @@ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ -/** Angular Imports */ -import { Component, OnInit, inject } from '@angular/core'; -import { MatDialogRef, MAT_DIALOG_DATA, MatDialogTitle, MatDialogContent } from '@angular/material/dialog'; -import { KeyboardShortcutsConfiguration } from '../../keyboards-shortcut-config'; +import { Component, OnInit, inject, ViewChild } from '@angular/core'; +import { + MatDialogRef, + MatDialogTitle, + MatDialogContent, + MatDialogActions, + MatDialog, + MatDialogClose, + MAT_DIALOG_DATA +} from '@angular/material/dialog'; import { CdkScrollable } from '@angular/cdk/scrolling'; import { MatDivider } from '@angular/material/divider'; import { STANDALONE_SHARED_IMPORTS } from 'app/standalone-shared.module'; -/** - * Delete dialog component. - */ +import { KeyboardShortcutService, ShortcutItem } from '../../core/services/keyboard-shortcut.service'; +import { FormsModule } from '@angular/forms'; +import { NgClass } from '@angular/common'; +import { MatButton } from '@angular/material/button'; +import { ConfirmationDialogComponent } from '../confirmation-dialog/confirmation-dialog.component'; +import { ShortcutUsageDialogComponent } from '../shortcut-usage-dialog/shortcut-usage-dialog.component'; +import { MatIcon } from '@angular/material/icon'; +import { MatFormField, MatLabel } from '@angular/material/form-field'; +import { MatInput } from '@angular/material/input'; +import { MatSelect } from '@angular/material/select'; +import { MatOption } from '@angular/material/core'; + @Component({ selector: 'mifosx-keyboard-shortcuts-dialog', templateUrl: './keyboard-shortcuts-dialog.component.html', @@ -25,16 +40,293 @@ import { STANDALONE_SHARED_IMPORTS } from 'app/standalone-shared.module'; MatDialogTitle, CdkScrollable, MatDialogContent, - MatDivider + MatDialogActions, + MatDialogClose, + MatDivider, + MatButton, + FormsModule, + NgClass, + MatIcon, + MatFormField, + MatLabel, + MatInput, + MatSelect, + MatOption ] }) export class KeyboardShortcutsDialogComponent implements OnInit { dialogRef = inject>(MatDialogRef); + private keyboardShortcutService = inject(KeyboardShortcutService); + public data = inject(MAT_DIALOG_DATA, { optional: true }); - buttonConfig: KeyboardShortcutsConfiguration; + shortcuts: ShortcutItem[] = []; + validationError: string | null = null; + activeInputItem: ShortcutItem | null = null; + displayedColumns: string[] = [ + 'title', + 'keyCombination' + ]; + + isAddingNew = false; + newShortcut: Partial = {}; + activeNewInput = false; + + editingShortcutId: string | null = null; + editingShortcutData: Partial = {}; + activeEditInput = false; ngOnInit() { - this.dialogRef.updateSize(`800px`); - this.buttonConfig = new KeyboardShortcutsConfiguration(); + this.dialogRef.updateSize('800px'); + this.keyboardShortcutService.activeShortcuts$.subscribe((shortcuts) => { + // Deep copy to allow editing without affecting the global state immediately + this.shortcuts = JSON.parse(JSON.stringify(shortcuts)); + }); + + if (this.data && this.data.prefill) { + this.startAddShortcut(); + this.newShortcut = { + ...this.newShortcut, + ...this.data.prefill + }; + } + } + + formatShortcut(item: Partial): string { + if (!item || (!item.key && !item.ctrlKey && !item.shiftKey && !item.altKey)) return ''; + const parts = []; + if (item.ctrlKey) parts.push('Ctrl'); + if (item.shiftKey) parts.push('Shift'); + if (item.altKey) parts.push('Alt'); + if (item.key) parts.push(item.key.toUpperCase()); + return parts.join(' + '); + } + + onKeyDown(event: KeyboardEvent, item: Partial, isNewOrEdit: boolean = false) { + event.preventDefault(); + event.stopPropagation(); + + // Ignore if only modifier keys are pressed + if ([ + 'Control', + 'Shift', + 'Alt', + 'Meta' + ].includes(event.key)) { + return; + } + + item.ctrlKey = event.ctrlKey; + item.shiftKey = event.shiftKey; + item.altKey = event.altKey; + item.key = event.key.toLowerCase(); + + if (!isNewOrEdit) { + this.activeInputItem = null; + this.validateShortcuts(); + } else { + this.activeNewInput = false; + this.activeEditInput = false; + } + } + + validateShortcuts() { + this.validationError = null; + const seen = new Set(); + + for (const item of this.shortcuts) { + if (!item.key) { + this.validationError = `Invalid shortcut for ${item.title}: Key is required`; + return; + } + + const keyStr = `${!!item.ctrlKey}-${!!item.shiftKey}-${!!item.altKey}-${item.key.toLowerCase()}`; + if (seen.has(keyStr)) { + this.validationError = `Duplicate shortcut detected: ${this.formatShortcut(item)}`; + return; + } + seen.add(keyStr); + } + } + + startAddShortcut() { + this.isAddingNew = true; + this.newShortcut = { + title: '', + route: '', + key: '', + ctrlKey: false, + shiftKey: false, + altKey: false, + action: 'navigate', + custom: true, + id: 'custom_' + Date.now() + }; + this.validationError = null; + } + + cancelAddShortcut() { + this.isAddingNew = false; + this.validationError = null; + } + + private dialog = inject(MatDialog); + + @ViewChild('shortcutDetailsTemplate') shortcutDetailsTemplate: any; + private detailsDialogRef: MatDialogRef | null = null; + + showShortcutDetails(item: ShortcutItem) { + this.detailsDialogRef = this.dialog.open(this.shortcutDetailsTemplate, { + data: item, + width: '400px' + }); + } + + openUsagePopup() { + this.dialog.open(ShortcutUsageDialogComponent, { + width: '600px' + }); + } + + getShortcutDescription(item: Partial): string { + if (item.custom) { + if (item.action === 'navigate') { + return `Navigates to the ${item.title} page.`; + } + return `Executes the ${item.action} action.`; + } else { + switch (item.id) { + case 'logout': + return 'Logs you out of the application securely.'; + case 'help': + return 'Opens the user manual and help documentation.'; + case 'runReport': + return 'Executes the currently selected report.'; + case 'cancel': + return 'Cancels the current operation or form.'; + case 'submit': + return 'Submits the current form or dialog.'; + default: + return `Navigates to the predefined ${item.title} feature.`; + } + } + } + + saveNewShortcut() { + if (this.newShortcut.action === 'navigate' && this.newShortcut.route) { + this.newShortcut.route = this.keyboardShortcutService.normalizeRouteStr(this.newShortcut.route); + } + if (this.validateSingleShortcut(this.newShortcut, null)) { + this.shortcuts.push(this.newShortcut as ShortcutItem); + this.isAddingNew = false; + this.validateShortcuts(); + if (!this.validationError) { + this.keyboardShortcutService.updateShortcuts(this.shortcuts); + } + } + } + + startEditShortcut(item: ShortcutItem) { + if (this.detailsDialogRef) { + this.detailsDialogRef.close(); + this.detailsDialogRef = null; + } + this.editingShortcutId = item.id || null; + this.editingShortcutData = { ...item }; + this.validationError = null; + } + + cancelEditShortcut() { + this.editingShortcutId = null; + this.validationError = null; + } + + saveEditShortcut() { + if (this.editingShortcutData.action === 'navigate' && this.editingShortcutData.route) { + this.editingShortcutData.route = this.keyboardShortcutService.normalizeRouteStr(this.editingShortcutData.route); + } + if (this.validateSingleShortcut(this.editingShortcutData, this.editingShortcutId)) { + const index = this.shortcuts.findIndex((s) => s.id === this.editingShortcutId); + if (index > -1) { + this.shortcuts[index] = { ...this.editingShortcutData } as ShortcutItem; + } + this.editingShortcutId = null; + this.validateShortcuts(); + if (!this.validationError) { + this.keyboardShortcutService.updateShortcuts(this.shortcuts); + } + } + } + + deleteShortcut(item: ShortcutItem) { + if (this.detailsDialogRef) { + this.detailsDialogRef.close(); + this.detailsDialogRef = null; + } + this.shortcuts = this.shortcuts.filter((s) => (s.id ? s.id !== item.id : s.title !== item.title)); + this.validateShortcuts(); + if (!this.validationError) { + this.keyboardShortcutService.updateShortcuts(this.shortcuts); + } + } + + validateSingleShortcut(item: Partial, excludeId: string | null): boolean { + if (!item.title || item.title.trim() === '') { + this.validationError = 'Shortcut Name is required'; + return false; + } + if (!item.key) { + this.validationError = 'Key combination is required'; + return false; + } + if (item.action === 'navigate' && (!item.route || item.route.trim() === '')) { + this.validationError = 'URL / Route is required'; + return false; + } + + const keyStr = `${!!item.ctrlKey}-${!!item.shiftKey}-${!!item.altKey}-${item.key.toLowerCase()}`; + const seen = new Set(); + + for (const s of this.shortcuts) { + if (excludeId && s.id === excludeId) continue; + seen.add(`${!!s.ctrlKey}-${!!s.shiftKey}-${!!s.altKey}-${s.key.toLowerCase()}`); + } + + if (seen.has(keyStr)) { + this.validationError = `Duplicate shortcut detected: ${this.formatShortcut(item)}`; + return false; + } + + this.validationError = null; + return true; + } + + save() { + this.validateShortcuts(); + if (!this.validationError) { + this.keyboardShortcutService.updateShortcuts(this.shortcuts); + this.dialogRef.close(true); + } + } + + resetToDefaults() { + const dialogRef = this.dialog.open(ConfirmationDialogComponent, { + data: { + heading: 'Reset Keyboard Shortcuts', + dialogContext: + 'Are you sure you want to reset all keyboard shortcuts to their default configuration? This will remove all custom shortcuts and restore default keys.', + type: 'Warn' + } + }); + + dialogRef.afterClosed().subscribe((confirm: boolean) => { + if (confirm) { + this.keyboardShortcutService.resetToDefaults(); + this.dialogRef.close(true); + } + }); + } + + cancel() { + this.dialogRef.close(false); } } diff --git a/src/app/shared/shortcut-suggestion-snackbar/shortcut-suggestion-snackbar.component.html b/src/app/shared/shortcut-suggestion-snackbar/shortcut-suggestion-snackbar.component.html new file mode 100644 index 000000000..a4f23b40c --- /dev/null +++ b/src/app/shared/shortcut-suggestion-snackbar/shortcut-suggestion-snackbar.component.html @@ -0,0 +1,22 @@ + + +
+
+ 💡 You frequently use {{ data.pageName }}
+ Would you like to create a keyboard shortcut for faster access? +
+
+ + +
+
diff --git a/src/app/shared/shortcut-suggestion-snackbar/shortcut-suggestion-snackbar.component.scss b/src/app/shared/shortcut-suggestion-snackbar/shortcut-suggestion-snackbar.component.scss new file mode 100644 index 000000000..56165bf4f --- /dev/null +++ b/src/app/shared/shortcut-suggestion-snackbar/shortcut-suggestion-snackbar.component.scss @@ -0,0 +1,19 @@ +/** + * 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/. + */ + +.suggestion-container { + display: flex; + flex-direction: column; + gap: 10px; +} + +.suggestion-text { + font-size: 14px; + line-height: 1.4; + color: #fff; +} diff --git a/src/app/shared/shortcut-suggestion-snackbar/shortcut-suggestion-snackbar.component.ts b/src/app/shared/shortcut-suggestion-snackbar/shortcut-suggestion-snackbar.component.ts new file mode 100644 index 000000000..86eeddafd --- /dev/null +++ b/src/app/shared/shortcut-suggestion-snackbar/shortcut-suggestion-snackbar.component.ts @@ -0,0 +1,34 @@ +/** + * Copyright since 2025 Mifos Initiative + * + * This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. + */ + +import { Component, inject } from '@angular/core'; +import { MAT_SNACK_BAR_DATA, MatSnackBarRef } from '@angular/material/snack-bar'; +import { MatButton } from '@angular/material/button'; +import { STANDALONE_SHARED_IMPORTS } from 'app/standalone-shared.module'; + +@Component({ + selector: 'mifosx-shortcut-suggestion-snackbar', + templateUrl: './shortcut-suggestion-snackbar.component.html', + styleUrls: ['./shortcut-suggestion-snackbar.component.scss'], + imports: [ + ...STANDALONE_SHARED_IMPORTS, + MatButton + ] +}) +export class ShortcutSuggestionSnackbarComponent { + snackBarRef = inject(MatSnackBarRef); + data: { pageName: string; route: string } = inject(MAT_SNACK_BAR_DATA); + + createShortcut() { + this.snackBarRef.dismissWithAction(); + } + + dismiss() { + this.snackBarRef.dismiss(); + } +} diff --git a/src/app/shared/shortcut-usage-dialog/inline-create-shortcut-dialog.component.html b/src/app/shared/shortcut-usage-dialog/inline-create-shortcut-dialog.component.html new file mode 100644 index 000000000..492d555ac --- /dev/null +++ b/src/app/shared/shortcut-usage-dialog/inline-create-shortcut-dialog.component.html @@ -0,0 +1,55 @@ + + +
+

Create Shortcut

+
+ +
+ @if (validationError) { +
+ {{ validationError }} +
+ } + +
+ + Shortcut Name + + + + + Key Combination + + + + + Route + + + + + Action + + +
+
+ + +
+
+ + +
+
diff --git a/src/app/shared/shortcut-usage-dialog/inline-create-shortcut-dialog.component.ts b/src/app/shared/shortcut-usage-dialog/inline-create-shortcut-dialog.component.ts new file mode 100644 index 000000000..741a0c065 --- /dev/null +++ b/src/app/shared/shortcut-usage-dialog/inline-create-shortcut-dialog.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 { + MAT_DIALOG_DATA, + MatDialogRef, + MatDialogTitle, + MatDialogContent, + MatDialogActions +} from '@angular/material/dialog'; +import { KeyboardShortcutService, ShortcutItem } from '../../core/services/keyboard-shortcut.service'; +import { STANDALONE_SHARED_IMPORTS } from 'app/standalone-shared.module'; +import { FormsModule } from '@angular/forms'; +import { MatButton } from '@angular/material/button'; +import { MatFormField, MatLabel, MatError } from '@angular/material/form-field'; +import { MatInput } from '@angular/material/input'; +import { take } from 'rxjs/operators'; + +@Component({ + selector: 'mifosx-inline-create-shortcut-dialog', + templateUrl: './inline-create-shortcut-dialog.component.html', + styleUrls: ['../keyboard-shortcuts-dialog/keyboard-shortcuts-dialog.component.scss'], + imports: [ + ...STANDALONE_SHARED_IMPORTS, + MatDialogTitle, + MatDialogContent, + MatDialogActions, + MatButton, + FormsModule, + MatFormField, + MatLabel, + MatInput, + MatError + ] +}) +export class InlineCreateShortcutDialogComponent { + dialogRef = inject>(MatDialogRef); + data = inject(MAT_DIALOG_DATA); + private keyboardShortcutService = inject(KeyboardShortcutService); + + newShortcut: Partial = { + title: this.data.pageName, + route: this.data.route, + action: 'navigate', + key: '', + ctrlKey: false, + shiftKey: false, + altKey: false, + custom: true, + id: 'custom_' + Date.now() + }; + + validationError: string | null = null; + + formatShortcut(item: Partial): string { + if (!item || (!item.key && !item.ctrlKey && !item.shiftKey && !item.altKey)) return ''; + const parts = []; + if (item.ctrlKey) parts.push('Ctrl'); + if (item.shiftKey) parts.push('Shift'); + if (item.altKey) parts.push('Alt'); + if (item.key) parts.push(item.key.toUpperCase()); + return parts.join(' + '); + } + + onKeyDown(event: KeyboardEvent) { + event.preventDefault(); + event.stopPropagation(); + + // Ignore if only modifier keys are pressed + if ([ + 'Control', + 'Shift', + 'Alt', + 'Meta' + ].includes(event.key)) { + return; + } + + this.newShortcut.ctrlKey = event.ctrlKey; + this.newShortcut.shiftKey = event.shiftKey; + this.newShortcut.altKey = event.altKey; + this.newShortcut.key = event.key.toLowerCase(); + this.validationError = null; + } + + save() { + if (!this.newShortcut.title || this.newShortcut.title.trim() === '') { + this.validationError = 'Shortcut Name is required'; + return; + } + if (!this.newShortcut.key) { + this.validationError = 'Key combination is required'; + return; + } + + const keyStr = `${!!this.newShortcut.ctrlKey}-${!!this.newShortcut.shiftKey}-${!!this.newShortcut.altKey}-${this.newShortcut.key.toLowerCase()}`; + + this.keyboardShortcutService.activeShortcuts$.pipe(take(1)).subscribe((shortcuts) => { + // Check for duplicates + const seen = new Set(); + for (const s of shortcuts) { + seen.add(`${!!s.ctrlKey}-${!!s.shiftKey}-${!!s.altKey}-${s.key.toLowerCase()}`); + } + + if (seen.has(keyStr)) { + this.validationError = `Duplicate shortcut detected: ${this.formatShortcut(this.newShortcut)}`; + return; + } + + // Add and save + if (this.newShortcut.route) { + this.newShortcut.route = this.keyboardShortcutService.normalizeRouteStr(this.newShortcut.route); + } + shortcuts.push(this.newShortcut as ShortcutItem); + this.keyboardShortcutService.updateShortcuts(shortcuts); + this.dialogRef.close(true); + }); + } + + cancel() { + this.dialogRef.close(false); + } +} diff --git a/src/app/shared/shortcut-usage-dialog/shortcut-usage-dialog.component.html b/src/app/shared/shortcut-usage-dialog/shortcut-usage-dialog.component.html new file mode 100644 index 000000000..68ab24c2f --- /dev/null +++ b/src/app/shared/shortcut-usage-dialog/shortcut-usage-dialog.component.html @@ -0,0 +1,58 @@ + + +

Shortcut Usage

+ +
+ + + + + + + + + + + + + + + + + + + + + +
Page +
{{ item.pageName }}
+
{{ item.route }}
+
Visits + {{ item.visits }} + Status / Action + @if (item.hasShortcut) { + ✓ Shortcut exists + } @else if (item.visits >= 5) { + Frequently Used + + } @else { + Not enough visits + } +
+ + @if (usageData.length === 0) { +
+ No usage data collected yet. +
+ } +
+ + + + diff --git a/src/app/shared/shortcut-usage-dialog/shortcut-usage-dialog.component.scss b/src/app/shared/shortcut-usage-dialog/shortcut-usage-dialog.component.scss new file mode 100644 index 000000000..67af7e8fd --- /dev/null +++ b/src/app/shared/shortcut-usage-dialog/shortcut-usage-dialog.component.scss @@ -0,0 +1,9 @@ +/** + * 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/. + */ + +// Usage dialog styling diff --git a/src/app/shared/shortcut-usage-dialog/shortcut-usage-dialog.component.ts b/src/app/shared/shortcut-usage-dialog/shortcut-usage-dialog.component.ts new file mode 100644 index 000000000..8f8db38ba --- /dev/null +++ b/src/app/shared/shortcut-usage-dialog/shortcut-usage-dialog.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, OnInit, inject } from '@angular/core'; +import { + MatDialogRef, + MatDialogTitle, + MatDialogContent, + MatDialogActions, + MatDialogClose, + MatDialog +} from '@angular/material/dialog'; +import { ShortcutSuggestionService } from '../../core/services/shortcut-suggestion.service'; +import { STANDALONE_SHARED_IMPORTS } from 'app/standalone-shared.module'; +import { MatButton } from '@angular/material/button'; +import { + MatTable, + MatColumnDef, + MatHeaderCellDef, + MatHeaderCell, + MatCellDef, + MatCell, + MatHeaderRowDef, + MatHeaderRow, + MatRowDef, + MatRow +} from '@angular/material/table'; +import { InlineCreateShortcutDialogComponent } from './inline-create-shortcut-dialog.component'; + +@Component({ + selector: 'mifosx-shortcut-usage-dialog', + templateUrl: './shortcut-usage-dialog.component.html', + styleUrls: ['./shortcut-usage-dialog.component.scss'], + imports: [ + ...STANDALONE_SHARED_IMPORTS, + MatDialogTitle, + MatDialogContent, + MatDialogActions, + MatDialogClose, + MatButton, + MatTable, + MatColumnDef, + MatHeaderCellDef, + MatHeaderCell, + MatCellDef, + MatCell, + MatHeaderRowDef, + MatHeaderRow, + MatRowDef, + MatRow + ] +}) +export class ShortcutUsageDialogComponent implements OnInit { + dialogRef = inject>(MatDialogRef); + private shortcutSuggestionService = inject(ShortcutSuggestionService); + private dialog = inject(MatDialog); + + usageData: { route: string; pageName: string; visits: number; hasShortcut: boolean }[] = []; + displayedColumns: string[] = [ + 'page', + 'visits', + 'status' + ]; + + ngOnInit() { + this.refreshData(); + } + + refreshData() { + this.usageData = this.shortcutSuggestionService.getUsageData(); + } + + createShortcut(item: { pageName: string; route: string }) { + const inlineDialogRef = this.dialog.open(InlineCreateShortcutDialogComponent, { + data: item, + width: '400px', + disableClose: true + }); + + inlineDialogRef.afterClosed().subscribe((saved) => { + if (saved) { + this.shortcutSuggestionService.markAsDismissed(item.route); + this.refreshData(); // update the table so the checkmark appears! + } + }); + } +} 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 8f7ec6d2c..6800f4e61 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 @@ -123,7 +123,9 @@ export class CheckerInboxComponent implements OnInit { ]; private excludeProductEdits(items: any[]): any[] { - return (items || []).filter((item: any) => !this.productEditEntities.includes(item?.entityName)); + return (items || []).filter( + (item: any) => !(this.productEditEntities.includes(item?.entityName) && item?.actionName === 'UPDATE') + ); } /** 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 44ebee780..5bef1b3e1 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 @@ -65,7 +65,10 @@ export class ViewCheckerInboxComponent { ]; get isProductEditEntry(): boolean { - return this.productEditEntities.includes(this.checkerInboxDetail?.entityName); + return ( + this.productEditEntities.includes(this.checkerInboxDetail?.entityName) && + this.checkerInboxDetail?.actionName === 'UPDATE' + ); } /** diff --git a/src/app/web-app.component.ts b/src/app/web-app.component.ts index 43ad9a332..c27a3984f 100644 --- a/src/app/web-app.component.ts +++ b/src/app/web-app.component.ts @@ -37,11 +37,12 @@ import { ErrorDialogComponent } from './shared/error-dialog/error-dialog.compone /** Custom Items */ import { Alert } from './core/alert/alert.model'; -import { KeyboardShortcutsConfiguration } from './keyboards-shortcut-config'; import { Dates } from './core/utils/dates'; import { animate, style, transition, trigger } from '@angular/animations'; import { I18nService } from './core/i18n/i18n.service'; import { ThemingService } from './shared/theme-toggle/theming.service'; +import { KeyboardShortcutService } from './core/services/keyboard-shortcut.service'; +import { ShortcutSuggestionService } from './core/services/shortcut-suggestion.service'; /** Initialize Logger */ const log = new Logger('MifosX'); @@ -97,8 +98,6 @@ registerLocaleData(localeSW); standalone: false }) export class WebAppComponent implements OnInit, OnDestroy { - buttonConfig: KeyboardShortcutsConfiguration; - i18nService: I18nService; private authSubscription: Subscription; @@ -132,7 +131,9 @@ export class WebAppComponent implements OnInit, OnDestroy { private dateUtils: Dates, private idle: IdleTimeoutService, private dialog: MatDialog, - private documentationLinks: DocumentationLinksService + private documentationLinks: DocumentationLinksService, + private keyboardShortcutService: KeyboardShortcutService, + private shortcutSuggestionService: ShortcutSuggestionService ) {} @HostBinding('class') public cssClass: string; @@ -246,7 +247,6 @@ export class WebAppComponent implements OnInit, OnDestroy { verticalPosition: 'top' }); }); - this.buttonConfig = new KeyboardShortcutsConfiguration(); // initialize language and date format if they are null. if (!localStorage.getItem('mifosXLanguage')) { @@ -269,6 +269,8 @@ export class WebAppComponent implements OnInit, OnDestroy { this.authSubscription = this.authenticationService.isAuthenticated$.subscribe((loggedIn) => { if (loggedIn) { this.idle.start(); + this.keyboardShortcutService.initShortcuts(); + this.shortcutSuggestionService.init(); } else { this.idle.stop(); } @@ -311,46 +313,4 @@ export class WebAppComponent implements OnInit, OnDestroy { help() { this.documentationLinks.open('userManual'); } - - // Monitor all keyboard events and excute keyboard shortcuts - @HostListener('window:keydown', ['$event']) - onKeydownHandler(event: KeyboardEvent) { - const routeD = this.buttonConfig.buttonCombinations.find( - (x) => - x.ctrlKey === event.ctrlKey && x.shiftKey === event.shiftKey && x.altKey === event.altKey && x.key === event.key - ); - if (!(routeD === undefined)) { - switch (routeD.id) { - case 'logout': - this.logout(); - break; - case 'help': - this.help(); - break; - case 'runReport': - document.getElementById('runReport').click(); - break; - case 'cancel': - const cancelButtons = document.querySelectorAll('button'); - const filteredcancelButtons = Array.prototype.filter.call(cancelButtons, function (el: any) { - return el.textContent.trim() === 'Cancel'; - }); - if (filteredcancelButtons.length > 0) { - filteredcancelButtons[0].click(); - } - break; - case 'submit': - const submitButton = document.querySelectorAll('button'); - const filteredSubmitButton = Array.prototype.filter.call(submitButton, function (el: any) { - return el.textContent.trim() === 'Submit'; - }); - if (filteredSubmitButton.length > 0) { - filteredSubmitButton[0].click(); - } - break; - default: - this.router.navigate([routeD.route], { relativeTo: this.activatedRoute }); - } - } - } } diff --git a/src/environments/.env.ts b/src/environments/.env.ts index fd0d2a130..4c756f68e 100644 --- a/src/environments/.env.ts +++ b/src/environments/.env.ts @@ -9,8 +9,8 @@ /* tslint:disable */ export default { 'mifos_x': { - 'version': '260813', - 'hash': '5a6fb1f9' + 'version': '260825', + 'hash': '1b2ef512' }, 'allow_switching_backend_instance': true }; diff --git a/tsconfig.json b/tsconfig.json index f505ac664..31444082c 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -8,6 +8,9 @@ ], "app/*": [ "src/app/*" + ], + "environments/*": [ + "src/environments/*" ] }, "importHelpers": true, From 395fb8ace84c082fd84e0361e7f5b921f873e863 Mon Sep 17 00:00:00 2001 From: Nitesh Yadav Date: Wed, 26 Aug 2026 18:56:41 +0530 Subject: [PATCH 08/64] feat(clients,deposits): auto-derive senior-citizen/customer-segment, fix disbursement display and reversed-transaction balances Four independent, self-contained changes bundled from this session's work: 1. Auto-derive Senior Citizen / Minor / Customer Segment instead of manual entry Senior Citizen was a manual, staff-editable checkbox on FD/RD account creation with no validation against the client's actual age -- this let non-senior clients get the senior-citizen interest rate by mistake (found twice on live test accounts, where m_savings_account.is_senior_citizen was true despite the client being confirmed non-senior at the client record level). Likewise "minor" client classification was hardcoded to the age-18 threshold with no single source of truth, and FD/RD account creation's "Customer Segment" (Individual / Non-Individual) had no connection to the Individual/Non-Individual Legal Form already captured at client creation. - New src/app/clients/shared/client-age.util.ts centralises age/DOB derivation: MINOR_AGE_THRESHOLD (now 10, was 18), SENIOR_CITIZEN_AGE_ THRESHOLD (60), AGE_OF_MAJORITY (18, kept separate from the minor threshold -- majority date math is unrelated to the minor/adult classification threshold), calculateAge/isMinorAge/isSeniorCitizenAge/ isIndividualClient/deriveSeniorCitizenStatus. - FD/RD interest-rate-chart steps: Senior Citizen checkbox is now derived from the client's DOB (age >= 60, individual clients only) and disabled for manual editing; a hint explains why it is checked/ unchecked. - Client general step and all four account-details-steps (savings, current, FD, RD): replaced hardcoded 18/60 literals with the shared helpers; "Must be below 18" hints now read the actual threshold. - FD/RD account-details-steps: Customer Segment auto-selects to match the client's Legal Form (exact match, not substring -- a substring match would misclassify "Individual" as matching "Non-Individual"). - RD preview step: fixed a pre-existing display bug that always showed the product's default Customer Segment instead of what was actually selected on the account. 2. Fix wrong Account Name / Account Type on Disbursement Review screens Clerk disbursement review and "My Disbursement Requests" showed the account NUMBER instead of the holder's name, and always labelled the account type "SAVINGS" regardless of the real account type (FD/RD/ Share/Loan), because transaction-entry.component.ts's account-type mapping only checked for CA/CB and its account-name fallback chain never tried clientName. Added creditEntryAccountType() covering GL/CA/CB/FD/RD/SHR/LA, and accountTypeLabel() display helpers on both review screens. 3. Stop reversed transactions from distorting displayed running balances Passbook and Transactions tabs were including reversed transactions in their client-side running-balance recalculation, so a reversed posting (visible struck-through) still shifted the balance shown for every transaction after it. Both now filter out txn.reversed before computing running balances; Transactions tab also defaults "Hide Reversed" to on. 4. New Interest Schedule dialog on the FD account view Added custom-dialogs/interest-schedule-dialog, opened from the FD General tab, showing the account's period-by-period interest schedule. Excludes src/environments/.env.ts (auto-generated build version/hash stamp, regenerated on every build -- not meaningful to hand-commit). Co-Authored-By: Claude Sonnet 5 --- .../transaction-entry.component.ts | 46 +++++- .../disbursement-review.component.html | 6 +- .../disbursement-review.component.ts | 18 +++ .../my-disbursement-requests.component.html | 4 +- .../my-disbursement-requests.component.ts | 17 +++ .../client-general-step.component.html | 2 +- .../client-general-step.component.ts | 17 ++- .../client-preview-step.component.html | 2 +- .../personal-data-tab.component.html | 2 +- src/app/clients/shared/client-age.util.ts | 97 ++++++++++++ ...urrent-account-details-step.component.html | 2 +- .../current-account-details-step.component.ts | 24 ++- ...eposit-account-details-step.component.html | 2 +- ...-deposit-account-details-step.component.ts | 66 ++++++-- ...nt-interest-rate-chart-step.component.html | 4 +- ...ount-interest-rate-chart-step.component.ts | 80 +++++++++- .../interest-schedule-dialog.component.html | 71 +++++++++ .../interest-schedule-dialog.component.scss | 142 ++++++++++++++++++ .../interest-schedule-dialog.component.ts | 68 +++++++++ .../general-tab/general-tab.component.html | 81 +++++++++- .../general-tab/general-tab.component.scss | 10 ++ .../general-tab/general-tab.component.ts | 57 +++++++ ...posits-account-details-step.component.html | 2 +- ...deposits-account-details-step.component.ts | 53 ++++++- ...nt-interest-rate-chart-step.component.html | 4 +- ...ount-interest-rate-chart-step.component.ts | 78 +++++++++- ...posits-account-preview-step.component.html | 10 +- ...eposit-product-details-step.component.html | 6 + ...eposit-product-details-step.component.scss | 12 +- ...-deposit-product-details-step.component.ts | 21 ++- ...ed-deposit-product-terms-step.component.ts | 36 +++-- ...avings-account-details-step.component.html | 2 +- .../savings-account-details-step.component.ts | 24 ++- .../passbook-tab/passbook-tab.component.ts | 12 +- .../transactions-tab.component.ts | 10 +- 35 files changed, 997 insertions(+), 91 deletions(-) create mode 100644 src/app/clients/shared/client-age.util.ts create mode 100644 src/app/deposits/fixed-deposits/fixed-deposit-account-view/custom-dialogs/interest-schedule-dialog/interest-schedule-dialog.component.html create mode 100644 src/app/deposits/fixed-deposits/fixed-deposit-account-view/custom-dialogs/interest-schedule-dialog/interest-schedule-dialog.component.scss create mode 100644 src/app/deposits/fixed-deposits/fixed-deposit-account-view/custom-dialogs/interest-schedule-dialog/interest-schedule-dialog.component.ts diff --git a/src/app/cashier/transaction-entry/transaction-entry.component.ts b/src/app/cashier/transaction-entry/transaction-entry.component.ts index df16141b4..29522bbcc 100644 --- a/src/app/cashier/transaction-entry/transaction-entry.component.ts +++ b/src/app/cashier/transaction-entry/transaction-entry.component.ts @@ -1401,7 +1401,7 @@ export class TransactionEntryComponent implements OnInit, OnDestroy { dbCr: 'Cr', accountNo: cr.acNo, accountName: this.rowAccountNames[cr.acNo] ?? cr.acNo, - accountType: cr.acTyp === 'CA' || cr.acTyp === 'CB' ? 'CURRENT' : cr.acTyp === 'GL' ? 'GL' : 'SAVINGS', + accountType: this.creditEntryAccountType(cr.acTyp), savingsAccountId: cr.acTyp !== 'GL' ? (this.rowAccountIds[cr.acNo] ?? null) : null, glAccountId: cr.acTyp === 'GL' ? (this.rowAccountIds[cr.acNo] ?? null) : null, amount: String(cr.amount), @@ -1421,7 +1421,7 @@ export class TransactionEntryComponent implements OnInit, OnDestroy { dbCr: r.dbCr, accountNo: r.acNo, accountName: this.rowAccountNames[r.acNo] ?? r.acNo, - accountType: r.acTyp === 'CA' || r.acTyp === 'CB' ? 'CURRENT' : r.acTyp === 'GL' ? 'GL' : 'SAVINGS', + accountType: this.creditEntryAccountType(r.acTyp), savingsAccountId: r.acTyp !== 'GL' ? (this.rowAccountIds[r.acNo] ?? null) : null, glAccountId: r.acTyp === 'GL' ? (this.rowAccountIds[r.acNo] ?? null) : null, amount: String(r.amount), @@ -1490,7 +1490,7 @@ export class TransactionEntryComponent implements OnInit, OnDestroy { dbCr, accountNo: r.acNo, accountName: this.rowAccountNames[r.acNo] ?? r.acNo, - accountType: r.acTyp === 'CA' || r.acTyp === 'CB' ? 'CURRENT' : 'SAVINGS', + accountType: this.creditEntryAccountType(r.acTyp), savingsAccountId: this.rowAccountIds[r.acNo], amount: String(r.amount), description: r.description ?? null @@ -1937,6 +1937,36 @@ export class TransactionEntryComponent implements OnInit, OnDestroy { } /** Maps UI account type → { listEndpoint, detailEndpoint, nomineeType, searchResource } */ + /** + * The account-type label carried on a disbursement request's credit/debit entry. + * + * The backend (ClerkDisbursementRequestApiResource) only ever branches on 'GL' and 'CURRENT'; every + * other value takes the same path, and in the disbursement flow it resolves current-vs-savings from + * the database rather than from this string. So naming a fixed or recurring deposit accurately here + * changes only what the approving checker sees, never what gets posted. Previously anything that + * wasn't current or GL was labelled 'SAVINGS', which showed a checker an FD as a savings account -- + * exactly the wrong information to approve a disbursement against. + */ + private creditEntryAccountType(acTyp: string | undefined | null): string { + switch ((acTyp ?? '').toUpperCase()) { + case 'GL': + return 'GL'; + case 'CA': + case 'CB': + return 'CURRENT'; + case 'FD': + return 'FIXED_DEPOSIT'; + case 'RD': + return 'RECURRING_DEPOSIT'; + case 'SHR': + return 'SHARE'; + case 'LA': + return 'LOAN'; + default: + return 'SAVINGS'; + } + } + private accountTypeConfig(acTyp: string): { list: string; detail: string; @@ -2136,8 +2166,12 @@ export class TransactionEntryComponent implements OnInit, OnDestroy { // Track per-row account id/name for multi-row disbursement if (this.selectedRow?.acNo) { this.rowAccountIds[this.selectedRow.acNo] = acc.id; + // clientName must be in this chain: a deposit account's detail response carries the holder + // under clientName, not name/displayName (only GL accounts have `name`). Without it every FD + // and RD fell through to accountNo, so a disbursement request showed the checker an account + // number where the account holder's name belongs. this.rowAccountNames[this.selectedRow.acNo] = - acc.name ?? acc.displayName ?? acc.glAccountName ?? acc.accountNo ?? ''; + acc.name ?? acc.displayName ?? acc.clientName ?? acc.glAccountName ?? acc.accountNo ?? ''; this.rowClientIds[this.selectedRow.acNo] = acc.clientId ?? null; } if (this.selectedRow) { @@ -3696,7 +3730,7 @@ export class TransactionEntryComponent implements OnInit, OnDestroy { return { accountNo: this.accountDetails.accountNo, - accountType: 'SAVINGS', + accountType: this.creditEntryAccountType(row.acTyp), savingsAccountId: this.accountDetails.id, clientId: this.accountDetails.clientId ?? this.clientDetails?.id ?? null, clientName: this.clientDetails?.displayName ?? this.accountDetails?.clientName ?? null, @@ -3805,7 +3839,7 @@ export class TransactionEntryComponent implements OnInit, OnDestroy { const isGl = row.acTyp === 'GL'; return { accountNo: isGl ? (this.accountDetails.glCode ?? this.accountDetails.name) : this.accountDetails.accountNo, - accountType: isGl ? 'GL' : row.acTyp === 'CA' || row.acTyp === 'CB' ? 'CURRENT' : 'SAVINGS', + accountType: this.creditEntryAccountType(row.acTyp), savingsAccountId: isGl ? null : this.accountDetails.id, // GL-specific fields — ignored by backend for non-GL types glAccountId: isGl ? this.accountDetails.id : null, diff --git a/src/app/clerk/disbursement-review/disbursement-review.component.html b/src/app/clerk/disbursement-review/disbursement-review.component.html index b62e16e3a..f9aa574ce 100644 --- a/src/app/clerk/disbursement-review/disbursement-review.component.html +++ b/src/app/clerk/disbursement-review/disbursement-review.component.html @@ -159,7 +159,7 @@ {{ entry.accountNo }} {{ entry.accountName }} - {{ entry.accountType }} + {{ accountTypeLabel(entry.accountType) }} ₹{{ entry.amount | number: '1.2-2' }} @@ -197,7 +197,7 @@ {{ entry.accountNo }} {{ entry.accountName }} - {{ entry.accountType }} + {{ accountTypeLabel(entry.accountType) }} ₹{{ entry.amount | number: '1.2-2' }} @@ -233,7 +233,7 @@ {{ entry.accountNo }} {{ entry.accountName }} - {{ entry.accountType }} + {{ accountTypeLabel(entry.accountType) }} ₹{{ entry.amount | number: '1.2-2' }} diff --git a/src/app/clerk/disbursement-review/disbursement-review.component.ts b/src/app/clerk/disbursement-review/disbursement-review.component.ts index b37ba7f94..82d37434e 100644 --- a/src/app/clerk/disbursement-review/disbursement-review.component.ts +++ b/src/app/clerk/disbursement-review/disbursement-review.component.ts @@ -143,6 +143,24 @@ export class DisbursementReviewComponent implements OnInit { return this.drEntries.reduce((sum, e) => sum + (e.amount ?? 0), 0); } + /** + * Human-readable account type for the badge. The stored value is the backend's own code + * (SAVINGS / CURRENT / FIXED_DEPOSIT / RECURRING_DEPOSIT / SHARE / LOAN / GL), so this only + * changes presentation -- a checker should read "Fixed Deposit", not "FIXED_DEPOSIT". + */ + accountTypeLabel(accountType: string): string { + const labels: Record = { + SAVINGS: 'Savings', + CURRENT: 'Current', + FIXED_DEPOSIT: 'Fixed Deposit', + RECURRING_DEPOSIT: 'Recurring Deposit', + SHARE: 'Share', + LOAN: 'Loan', + GL: 'GL' + }; + return labels[(accountType ?? '').toUpperCase()] ?? accountType; + } + statusClass(status: string): string { const map: Record = { PENDING: 'dri-status--pending', diff --git a/src/app/clerk/my-disbursement-requests/my-disbursement-requests.component.html b/src/app/clerk/my-disbursement-requests/my-disbursement-requests.component.html index a5dc03299..6171928f5 100644 --- a/src/app/clerk/my-disbursement-requests/my-disbursement-requests.component.html +++ b/src/app/clerk/my-disbursement-requests/my-disbursement-requests.component.html @@ -170,7 +170,9 @@ {{ ce.accountNo || '—' }} {{ ce.accountName || '—' }} - {{ ce.accountType || '—' }} + {{ + ce.accountType ? accountTypeLabel(ce.accountType) : '—' + }} {{ ce.amount | number: '1.2-2' }} diff --git a/src/app/clerk/my-disbursement-requests/my-disbursement-requests.component.ts b/src/app/clerk/my-disbursement-requests/my-disbursement-requests.component.ts index c75d6cac9..ed333c1e3 100644 --- a/src/app/clerk/my-disbursement-requests/my-disbursement-requests.component.ts +++ b/src/app/clerk/my-disbursement-requests/my-disbursement-requests.component.ts @@ -187,6 +187,23 @@ export class MyDisbursementRequestsComponent implements OnInit { return this.getCreditEntries(req).length; } + /** + * Human-readable account type for the badge. The stored value is the backend's own code + * (SAVINGS / CURRENT / FIXED_DEPOSIT / RECURRING_DEPOSIT / SHARE / LOAN / GL); presentation only. + */ + accountTypeLabel(accountType: string): string { + const labels: Record = { + SAVINGS: 'Savings', + CURRENT: 'Current', + FIXED_DEPOSIT: 'Fixed Deposit', + RECURRING_DEPOSIT: 'Recurring Deposit', + SHARE: 'Share', + LOAN: 'Loan', + GL: 'GL' + }; + return labels[(accountType ?? '').toUpperCase()] ?? accountType; + } + statusClass(status: string): string { const map: Record = { PENDING: 'mdr-status--pending', diff --git a/src/app/clients/client-stepper/client-general-step/client-general-step.component.html b/src/app/clients/client-stepper/client-general-step/client-general-step.component.html index d892f9368..dc8109323 100644 --- a/src/app/clients/client-stepper/client-general-step/client-general-step.component.html +++ b/src/app/clients/client-stepper/client-general-step/client-general-step.component.html @@ -394,7 +394,7 @@ } - + @if (isMinor && createClientForm.get('legalFormId')?.value === LegalFormId.PERSON) {
diff --git a/src/app/clients/client-stepper/client-general-step/client-general-step.component.ts b/src/app/clients/client-stepper/client-general-step/client-general-step.component.ts index 910651bb5..7f1cab462 100644 --- a/src/app/clients/client-stepper/client-general-step/client-general-step.component.ts +++ b/src/app/clients/client-stepper/client-general-step/client-general-step.component.ts @@ -56,6 +56,12 @@ import { ContactOtpBlockComponent, ContactOtpState } from 'app/clients/edit-client/contact-otp-block/contact-otp-block.component'; +import { + MINOR_AGE_THRESHOLD, + SENIOR_CITIZEN_AGE_THRESHOLD, + isMinorAge, + isSeniorCitizenAge +} from 'app/clients/shared/client-age.util'; /** * Create Client Component @@ -299,10 +305,13 @@ export class ClientGeneralStepComponent implements OnInit, OnDestroy, OnChanges ]; /** Calculated age detail string (Y/M/D) */ calculatedAgeDetail: string | null = null; - /** Whether client is a minor (age < 18) */ + /** Whether client is a minor — threshold from the shared age util, derived from Date of Birth */ isMinor = false; - /** Whether client is a senior citizen (age >= 60) — auto-detected from Date of Birth */ + /** Whether client is a senior citizen — threshold from the shared age util, derived from DOB */ isSeniorCitizen = false; + /** Exposed for template hints so the displayed rule can never drift from the applied rule. */ + readonly minorAgeThreshold = MINOR_AGE_THRESHOLD; + readonly seniorCitizenAgeThreshold = SENIOR_CITIZEN_AGE_THRESHOLD; /** Age breakdown for DB storage */ ageBreakdown = { years: 0, months: 0, days: 0 }; @@ -1237,8 +1246,8 @@ export class ClientGeneralStepComponent implements OnInit, OnDestroy, OnChanges this.calculatedAgeDetail = `${years} yr${years !== 1 ? 's' : ''} ${months} mo${months !== 1 ? 's' : ''} ${days} day${days !== 1 ? 's' : ''}`; this.ageBreakdown = { years, months, days }; const wasMinor = this.isMinor; - this.isMinor = years < 18; - this.isSeniorCitizen = years >= 60; + this.isMinor = isMinorAge(years); + this.isSeniorCitizen = isSeniorCitizenAge(years); // Add/remove guardian form group based on minor status if (this.isMinor && !wasMinor) { this.createClientForm.addControl( diff --git a/src/app/clients/client-stepper/client-preview-step/client-preview-step.component.html b/src/app/clients/client-stepper/client-preview-step/client-preview-step.component.html index 564f7f25b..f290c2be2 100644 --- a/src/app/clients/client-stepper/client-preview-step/client-preview-step.component.html +++ b/src/app/clients/client-stepper/client-preview-step/client-preview-step.component.html @@ -195,7 +195,7 @@ client.isMinor ? 'preview-badge preview-badge--danger' : 'preview-badge preview-badge--verified' " > - {{ client.isMinor ? 'Yes (Age < 18)' : 'No' }} + {{ client.isMinor ? 'Yes' : 'No' }}
diff --git a/src/app/clients/clients-view/personal-data-tab/personal-data-tab.component.html b/src/app/clients/clients-view/personal-data-tab/personal-data-tab.component.html index 287371407..02ce124f1 100644 --- a/src/app/clients/clients-view/personal-data-tab/personal-data-tab.component.html +++ b/src/app/clients/clients-view/personal-data-tab/personal-data-tab.component.html @@ -105,7 +105,7 @@ [class.pd-verify-badge--verified]="!clientViewData.isMinor" [class.pd-verify-badge--pending]="clientViewData.isMinor" > - {{ clientViewData.isMinor ? 'Yes (Age < 18)' : 'No' }} + {{ clientViewData.isMinor ? 'Yes' : 'No' }} diff --git a/src/app/clients/shared/client-age.util.ts b/src/app/clients/shared/client-age.util.ts new file mode 100644 index 000000000..9f2931f06 --- /dev/null +++ b/src/app/clients/shared/client-age.util.ts @@ -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/. + */ + +/** + * Single source of truth for every age-derived client classification. + * + * These rules were previously re-implemented as bare literals in ten separate components (the client + * stepper plus each product's account-details step), which is how a client could end up classified one + * way on the client screen and another way on the account screen -- and, in the FD case, how a + * 24-year-old ended up with a senior-citizen interest rate. Anything that needs to know whether a + * client is a minor or a senior citizen must derive it from here rather than comparing ages inline. + */ + +/** Below this age a client is a minor: guardian mapping required, restricted KYC/OVD document set. */ +export const MINOR_AGE_THRESHOLD = 10; + +/** At or above this age a client qualifies for senior-citizen benefits (higher deposit rates). */ +export const SENIOR_CITIZEN_AGE_THRESHOLD = 60; + +/** + * Age at which a minor account must be converted to a regular one. Deliberately independent of + * {@link MINOR_AGE_THRESHOLD}: legal majority in India is 18 regardless of the age at which this CBS + * chooses to stop applying minor-specific onboarding rules. + */ +export const AGE_OF_MAJORITY = 18; + +/** Parses the backend's date-of-birth representation -- either a `[yyyy, m, d]` array or a date string. */ +export function parseClientDob(dob: any): Date | null { + if (!dob) return null; + if (Array.isArray(dob) && dob.length >= 3) { + return new Date(dob[0], dob[1] - 1, dob[2]); + } + const parsed = new Date(dob); + return isNaN(parsed.getTime()) ? null : parsed; +} + +/** + * Completed years between `dob` and `asOf`. Pass the tenant's business date as `asOf` wherever it is + * available, so a back-dated or forward-dated business date classifies the client the same way the + * rest of the system does. + */ +export function calculateAge(dob: Date, asOf?: Date | null): number { + const today = asOf ?? new Date(); + let age = today.getFullYear() - dob.getFullYear(); + const monthDiff = today.getMonth() - dob.getMonth(); + if (monthDiff < 0 || (monthDiff === 0 && today.getDate() < dob.getDate())) { + age--; + } + return age; +} + +/** True when the age qualifies as a minor. `null` (unknown DOB) is never a minor. */ +export function isMinorAge(age: number | null | undefined): boolean { + return age !== null && age !== undefined && age < MINOR_AGE_THRESHOLD; +} + +/** True when the age qualifies for senior-citizen benefits. `null` (unknown DOB) never qualifies. */ +export function isSeniorCitizenAge(age: number | null | undefined): boolean { + return age !== null && age !== undefined && age >= SENIOR_CITIZEN_AGE_THRESHOLD; +} + +/** + * Whether a client record represents a natural person. Only a natural person can be a minor or a + * senior citizen -- an entity/company has no age, so age-derived benefits must never apply to one + * even if some incorporation date happens to be stored against it. + */ +export function isIndividualClient(client: any): boolean { + const legalForm = client?.legalForm; + if (!legalForm) return true; // absent legal form: treat as individual, matching the backend default + const code = String(legalForm.code ?? '').toLowerCase(); + if (code) return code.includes('individual') && !code.includes('nonindividual'); + const value = String(legalForm.value ?? legalForm.name ?? '').toLowerCase(); + return value === 'individual'; +} + +/** + * The senior-citizen decision for a whole client record: individual, known DOB, and old enough. + * Returns the derived age alongside the verdict so callers can show "why" without recomputing it. + */ +export function deriveSeniorCitizenStatus( + client: any, + asOf?: Date | null +): { isSeniorCitizen: boolean; age: number | null; isIndividual: boolean } { + const isIndividual = isIndividualClient(client); + const dob = parseClientDob(client?.dateOfBirth); + const age = dob ? calculateAge(dob, asOf) : null; + return { + isSeniorCitizen: isIndividual && isSeniorCitizenAge(age), + age, + isIndividual + }; +} diff --git a/src/app/current-accounts/current-account-stepper/current-account-details-step/current-account-details-step.component.html b/src/app/current-accounts/current-account-stepper/current-account-details-step/current-account-details-step.component.html index 1cf4ac389..69528ae98 100644 --- a/src/app/current-accounts/current-account-stepper/current-account-details-step/current-account-details-step.component.html +++ b/src/app/current-accounts/current-account-stepper/current-account-details-step/current-account-details-step.component.html @@ -529,7 +529,7 @@ Age - Must be below 18 years + Must be below {{ minorAgeThreshold }} years diff --git a/src/app/current-accounts/current-account-stepper/current-account-details-step/current-account-details-step.component.ts b/src/app/current-accounts/current-account-stepper/current-account-details-step/current-account-details-step.component.ts index 8c5592d1a..770117e66 100644 --- a/src/app/current-accounts/current-account-stepper/current-account-details-step/current-account-details-step.component.ts +++ b/src/app/current-accounts/current-account-stepper/current-account-details-step/current-account-details-step.component.ts @@ -15,6 +15,12 @@ import { Dates } from 'app/core/utils/dates'; import { CurrentAccountsService } from 'app/current-accounts/current-accounts.service'; import { SettingsService } from 'app/settings/settings.service'; import { ClientsService } from 'app/clients/clients.service'; +import { + AGE_OF_MAJORITY, + MINOR_AGE_THRESHOLD, + isMinorAge, + isSeniorCitizenAge +} from 'app/clients/shared/client-age.util'; import { MatDialog } from '@angular/material/dialog'; import { MatStepperPrevious, MatStepperNext } from '@angular/material/stepper'; import { MatIcon } from '@angular/material/icon'; @@ -99,6 +105,8 @@ export class CurrentAccountDetailsStepComponent implements OnInit { // ── Client info (auto-fetched) ───────────────────────────────────────── clientDob: Date | null = null; clientAge: number | null = null; + /** Exposed for tooltips so the displayed rule can never drift from the applied rule. */ + readonly minorAgeThreshold = MINOR_AGE_THRESHOLD; clientGender: string | null = null; primaryClientName = ''; primaryClientCif = ''; @@ -444,10 +452,10 @@ export class CurrentAccountDetailsStepComponent implements OnInit { return; } if (this.clientAge === null) return; - if (this.clientAge < 18) { + if (isMinorAge(this.clientAge)) { this.selectedAccountStatus = 'MINOR'; this.minorDob = this.clientDob; - } else if (this.clientAge >= 60) { + } else if (isSeniorCitizenAge(this.clientAge)) { this.selectedAccountStatus = 'SENIOR_CITIZEN'; } else if (this.clientGender === 'female') { this.selectedAccountStatus = 'INDIVIDUAL_FEMALE'; @@ -458,15 +466,15 @@ export class CurrentAccountDetailsStepComponent implements OnInit { isTileDisabled(value: string): boolean { if (this.readOnly) return true; - if (value === 'MINOR') return this.clientAge === null || this.clientAge >= 18; - if (value === 'SENIOR_CITIZEN') return this.clientAge === null || this.clientAge < 60; + if (value === 'MINOR') return !isMinorAge(this.clientAge); + if (value === 'SENIOR_CITIZEN') return !isSeniorCitizenAge(this.clientAge); return false; } getTileTooltip(value: string): string { - if (value === 'MINOR' && this.clientAge !== null && this.clientAge >= 18) - return `Age ${this.clientAge} yrs — Minor only allowed below 18 years`; - if (value === 'SENIOR_CITIZEN' && this.clientAge !== null && this.clientAge < 60) + if (value === 'MINOR' && !isMinorAge(this.clientAge)) + return `Age ${this.clientAge} yrs — Minor only allowed below years`; + if (value === 'SENIOR_CITIZEN' && !isSeniorCitizenAge(this.clientAge)) return `Age ${this.clientAge} yrs — Senior Citizen only allowed at 60+ years`; if (value === 'MINOR' && this.clientAge === null) return 'DOB not available — cannot verify age'; if (value === 'SENIOR_CITIZEN' && this.clientAge === null) return 'DOB not available — cannot verify age'; @@ -609,7 +617,7 @@ export class CurrentAccountDetailsStepComponent implements OnInit { const dob = this.minorDob ?? this.clientDob; if (!dob) return ''; const d = new Date(dob); - d.setFullYear(d.getFullYear() + 18); + d.setFullYear(d.getFullYear() + AGE_OF_MAJORITY); return this.dateUtils.formatDate(d, 'M/d/yyyy') || ''; } diff --git a/src/app/deposits/fixed-deposits/fixed-deposit-account-stepper/fixed-deposit-account-details-step/fixed-deposit-account-details-step.component.html b/src/app/deposits/fixed-deposits/fixed-deposit-account-stepper/fixed-deposit-account-details-step/fixed-deposit-account-details-step.component.html index b5f865cbd..07482706b 100644 --- a/src/app/deposits/fixed-deposits/fixed-deposit-account-stepper/fixed-deposit-account-details-step/fixed-deposit-account-details-step.component.html +++ b/src/app/deposits/fixed-deposits/fixed-deposit-account-stepper/fixed-deposit-account-details-step/fixed-deposit-account-details-step.component.html @@ -618,7 +618,7 @@ Age - Must be below 18 years + Must be below {{ minorAgeThreshold }} years diff --git a/src/app/deposits/fixed-deposits/fixed-deposit-account-stepper/fixed-deposit-account-details-step/fixed-deposit-account-details-step.component.ts b/src/app/deposits/fixed-deposits/fixed-deposit-account-stepper/fixed-deposit-account-details-step/fixed-deposit-account-details-step.component.ts index 7aa178b82..146e5b877 100644 --- a/src/app/deposits/fixed-deposits/fixed-deposit-account-stepper/fixed-deposit-account-details-step/fixed-deposit-account-details-step.component.ts +++ b/src/app/deposits/fixed-deposits/fixed-deposit-account-stepper/fixed-deposit-account-details-step/fixed-deposit-account-details-step.component.ts @@ -26,6 +26,12 @@ import { MatDialog } from '@angular/material/dialog'; /** Custom Services */ import { FixedDepositsService } from '../../fixed-deposits.service'; import { ClientsService } from 'app/clients/clients.service'; +import { + AGE_OF_MAJORITY, + MINOR_AGE_THRESHOLD, + isMinorAge, + isSeniorCitizenAge +} from 'app/clients/shared/client-age.util'; import { MatTooltip } from '@angular/material/tooltip'; import { MatStepperPrevious, MatStepperNext } from '@angular/material/stepper'; import { FaIconComponent } from '@fortawesome/angular-fontawesome'; @@ -107,8 +113,16 @@ export class FixedDepositAccountDetailsStepComponent implements OnInit, OnChange selectedCustomerSegment: string | null = null; // ── Client info (auto-fetched) ───────────────────────────────────────── + /** + * The client's legal form ("Individual" / "Non-Individual") as captured at client creation. Customer + * Segment is derived from this rather than picked by hand, so an account can never disagree with the + * CIF about what kind of customer it belongs to. + */ + clientLegalForm: string | null = null; clientDob: Date | null = null; clientAge: number | null = null; + /** Exposed for tooltips so the displayed rule can never drift from the applied rule. */ + readonly minorAgeThreshold = MINOR_AGE_THRESHOLD; clientGender: string | null = null; primaryClientName = ''; primaryClientCif = ''; @@ -279,7 +293,9 @@ export class FixedDepositAccountDetailsStepComponent implements OnInit, OnChange currencyCode: a.currency?.code ?? '' })); this.dalLoading = false; - // Pre-select linked account in edit mode + // Pre-select linked account in edit mode. The GET response nests the Fineract association as + // linkedAccount.id, and separately carries the CBS column as linkedAccountId -- the latter is the + // fallback for accounts that have the CBS field set but no association row. const linkedId = this.fixedDepositsAccountTemplate?.linkedAccount?.id ?? this.fixedDepositsAccountTemplate?.linkedAccountId; if (linkedId) { @@ -359,6 +375,8 @@ export class FixedDepositAccountDetailsStepComponent implements OnInit, OnChange this.autoDetectAccountStatus(); } } + this.clientLegalForm = client?.legalForm?.value ?? client?.legalForm?.name ?? null; + this.applyInferredCustomerSegment(); this.cdr.detectChanges(); }); } @@ -386,8 +404,8 @@ export class FixedDepositAccountDetailsStepComponent implements OnInit, OnChange } isTileDisabled(value: string): boolean { - if (value === 'MINOR') return this.clientAge === null || this.clientAge >= 18; - if (value === 'SENIOR_CITIZEN') return this.clientAge === null || this.clientAge < 60; + if (value === 'MINOR') return !isMinorAge(this.clientAge); + if (value === 'SENIOR_CITIZEN') return !isSeniorCitizenAge(this.clientAge); return false; } @@ -412,10 +430,10 @@ export class FixedDepositAccountDetailsStepComponent implements OnInit, OnChange } getTileTooltip(value: string): string { - if (value === 'MINOR' && this.clientAge !== null && this.clientAge >= 18) { - return `Age ${this.clientAge} yrs — Minor only allowed below 18 years`; + if (value === 'MINOR' && !isMinorAge(this.clientAge)) { + return `Age ${this.clientAge} yrs — Minor only allowed below years`; } - if (value === 'SENIOR_CITIZEN' && this.clientAge !== null && this.clientAge < 60) { + if (value === 'SENIOR_CITIZEN' && !isSeniorCitizenAge(this.clientAge)) { return `Age ${this.clientAge} yrs — Senior Citizen only allowed at 60+ years`; } if (value === 'MINOR' && this.clientAge === null) return 'DOB not available — cannot verify age'; @@ -428,10 +446,10 @@ export class FixedDepositAccountDetailsStepComponent implements OnInit, OnChange return; } if (this.clientAge === null) return; - if (this.clientAge < 18) { + if (isMinorAge(this.clientAge)) { this.selectedAccountStatus = 'MINOR'; this.minorDob = this.clientDob; - } else if (this.clientAge >= 60) { + } else if (isSeniorCitizenAge(this.clientAge)) { this.selectedAccountStatus = 'SENIOR_CITIZEN'; } else if (this.clientGender === 'female') { this.selectedAccountStatus = 'INDIVIDUAL_FEMALE'; @@ -481,6 +499,9 @@ export class FixedDepositAccountDetailsStepComponent implements OnInit, OnChange this.customerSegmentOptions = this.extractOptions(response.customerSegmentOptions, response.customerSegment); this.selectedAccountType = response.accountType ?? null; this.selectedCustomerSegment = response.customerSegment ?? null; + // The product default above is only a fallback -- the client's own legal form wins. Re-applied + // here as well as in the client callback because the two responses can arrive in either order. + this.applyInferredCustomerSegment(); this.fixedDepositsAccountProductTemplate.emit(response); this.fieldOfficerData = response.fieldOfficerOptions; this.isProductSelected = true; @@ -591,6 +612,24 @@ export class FixedDepositAccountDetailsStepComponent implements OnInit, OnChange return fallback ? [fallback] : []; } + /** + * Selects the Customer Segment that matches the client's legal form. The segment code values + * ("Individual" / "Non-Individual") are seeded to be byte-identical to the legal-form labels, so this + * is an exact, case-insensitive comparison on purpose -- a substring match would resolve + * "Non-Individual" to the "Individual" option, since one contains the other. Leaves the product + * default untouched when the client's form is unknown or has no matching option. + */ + private applyInferredCustomerSegment(): void { + if (!this.clientLegalForm || !this.customerSegmentOptions.length) { + return; + } + const target = this.clientLegalForm.trim().toLowerCase(); + const match = this.customerSegmentOptions.find((opt) => String(opt).trim().toLowerCase() === target); + if (match) { + this.selectedCustomerSegment = match; + } + } + formatTemplateDate(value: any): string { if (!value) return ''; let dateValue: Date | null = null; @@ -733,7 +772,7 @@ export class FixedDepositAccountDetailsStepComponent implements OnInit, OnChange const dob = this.minorDob ?? this.clientDob; if (!dob) return ''; const d = new Date(dob); - d.setFullYear(d.getFullYear() + 18); + d.setFullYear(d.getFullYear() + AGE_OF_MAJORITY); return this.dateUtils.formatDate(d, 'M/d/yyyy') || ''; } @@ -797,6 +836,15 @@ export class FixedDepositAccountDetailsStepComponent implements OnInit, OnChange } if (this.selectedLinkedAccount) { + // The same picked account has to be sent under BOTH names -- they are two different backend + // parameters feeding two different things, not a duplicate: + // linkAccountId (DepositsApiConstants.linkedAccountParamName) -> creates the Fineract + // AccountAssociations row that the monthly interest-transfer job reads, and that + // FD Special's linked-account requirement validates against. + // linkedAccountId (SavingsApiConstants.linkedAccountIdParamName) -> populates the CBS-specific + // m_savings_account.linked_account_id column via applyCbsFields. + // Only linkedAccountId was being sent before, so the association was never created. + value.linkAccountId = this.selectedLinkedAccount.id; value.linkedAccountId = this.selectedLinkedAccount.id; value.linkedAccountType = this.selectedLinkedAccount.accountType; value.linkedAccountNo = this.selectedLinkedAccount.accountNo; diff --git a/src/app/deposits/fixed-deposits/fixed-deposit-account-stepper/fixed-deposit-account-interest-rate-chart-step/fixed-deposit-account-interest-rate-chart-step.component.html b/src/app/deposits/fixed-deposits/fixed-deposit-account-stepper/fixed-deposit-account-interest-rate-chart-step/fixed-deposit-account-interest-rate-chart-step.component.html index 51bf1b47f..8eab0d0bc 100644 --- a/src/app/deposits/fixed-deposits/fixed-deposit-account-stepper/fixed-deposit-account-interest-rate-chart-step/fixed-deposit-account-interest-rate-chart-step.component.html +++ b/src/app/deposits/fixed-deposits/fixed-deposit-account-stepper/fixed-deposit-account-interest-rate-chart-step/fixed-deposit-account-interest-rate-chart-step.component.html @@ -170,12 +170,12 @@
diff --git a/src/app/deposits/fixed-deposits/fixed-deposit-account-stepper/fixed-deposit-account-interest-rate-chart-step/fixed-deposit-account-interest-rate-chart-step.component.ts b/src/app/deposits/fixed-deposits/fixed-deposit-account-stepper/fixed-deposit-account-interest-rate-chart-step/fixed-deposit-account-interest-rate-chart-step.component.ts index a54cf9e1d..beff3f51d 100644 --- a/src/app/deposits/fixed-deposits/fixed-deposit-account-stepper/fixed-deposit-account-interest-rate-chart-step/fixed-deposit-account-interest-rate-chart-step.component.ts +++ b/src/app/deposits/fixed-deposits/fixed-deposit-account-stepper/fixed-deposit-account-interest-rate-chart-step/fixed-deposit-account-interest-rate-chart-step.component.ts @@ -6,7 +6,17 @@ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ -import { Component, Input, OnChanges, SimpleChanges, Output, EventEmitter, ViewChild, inject } from '@angular/core'; +import { + Component, + Input, + OnChanges, + SimpleChanges, + Output, + EventEmitter, + ViewChild, + ChangeDetectorRef, + inject +} from '@angular/core'; import { MatTableDataSource, MatTable, @@ -26,6 +36,9 @@ import { FormsModule } from '@angular/forms'; import { DateFormatPipe } from '../../../../pipes/date-format.pipe'; import { STANDALONE_SHARED_IMPORTS } from 'app/standalone-shared.module'; import { FixedDepositsService } from '../../fixed-deposits.service'; +import { ClientsService } from 'app/clients/clients.service'; +import { SettingsService } from 'app/settings/settings.service'; +import { SENIOR_CITIZEN_AGE_THRESHOLD, deriveSeniorCitizenStatus } from 'app/clients/shared/client-age.util'; export type FdPeriodUnit = 'Days' | 'Months' | 'Years'; @@ -71,6 +84,9 @@ export interface SlabMatchResult { }) export class FixedDepositAccountInterestRateChartStepComponent implements OnChanges { private fixedDepositsService = inject(FixedDepositsService); + private clientsService = inject(ClientsService); + private settingsService = inject(SettingsService); + private cdr = inject(ChangeDetectorRef); @Input() fixedDepositsAccountTemplate: any; @Input() fixedDepositsAccountProductTemplate: any; @@ -110,7 +126,19 @@ export class FixedDepositAccountInterestRateChartStepComponent implements OnChan 'actions' ]; + /** + * Derived from the client's own date of birth -- never staff-editable. Setting this by hand is what + * let a 24-year-old's FD get priced at the senior-citizen rate, so the checkbox is display-only and + * this value comes solely from {@link deriveSeniorCitizenStatus}. + */ isSeniorCitizen = false; + readonly seniorCitizenAgeThreshold = SENIOR_CITIZEN_AGE_THRESHOLD; + /** Client age in completed years, or null when the DOB is unknown / the client is an entity. */ + clientAge: number | null = null; + /** False for a company/entity client, which has no age and so can never qualify. */ + clientIsIndividual = true; + /** True once the client lookup has come back, so the UI can explain the locked checkbox honestly. */ + clientAgeResolved = false; incentivesDisplayedColumns: string[] = [ 'entityType', 'attributeName', @@ -171,6 +199,9 @@ export class FixedDepositAccountInterestRateChartStepComponent implements OnChan } ngOnChanges(changes: SimpleChanges) { + if (changes['fixedDepositsAccountTemplate']) { + this.resolveSeniorCitizenFromClient(); + } // Only reset/re-apply when TEMPLATE or cbsBaseRates changes, NOT when initialTenure/submittedOnDate changes alone const templateChanged = changes['fixedDepositsAccountProductTemplate'] || changes['cbsBaseRates']; if (templateChanged && this.fixedDepositsAccountProductTemplate) { @@ -206,10 +237,10 @@ export class FixedDepositAccountInterestRateChartStepComponent implements OnChan if (this.initialTenure.depositAmount) { this.principalAmount = this.initialTenure.depositAmount; } - // Pre-check senior citizen if it was set during create - if (this.initialTenure.isSeniorCitizen) { - this.isSeniorCitizen = true; - } + // Deliberately NOT restoring initialTenure.isSeniorCitizen: the flag is derived from the client's + // date of birth in resolveSeniorCitizenFromClient(). Re-applying a stored value here would carry a + // previously mis-set flag forward on every edit, which is exactly how a 24-year-old's FD kept its + // senior-citizen rate. // Auto-calculate when tenure is pre-filled (edit mode) setTimeout(() => this.calculateRate(), 0); } @@ -435,6 +466,45 @@ export class FixedDepositAccountInterestRateChartStepComponent implements OnChan this.validationWarning = null; } + /** + * Derives the senior-citizen flag from the client's date of birth. Runs whenever the account template + * arrives (create and edit alike), so the flag always reflects the customer on record rather than + * whatever was ticked -- or previously saved -- by hand. + */ + private resolveSeniorCitizenFromClient(): void { + const clientId = this.fixedDepositsAccountTemplate?.clientId ?? this.fixedDepositsAccountTemplate?.id; + if (!clientId) { + return; + } + this.clientsService.getClientData(String(clientId)).subscribe((client: any) => { + const status = deriveSeniorCitizenStatus(client, this.settingsService.businessDate); + this.clientAge = status.age; + this.clientIsIndividual = status.isIndividual; + this.isSeniorCitizen = status.isSeniorCitizen; + this.clientAgeResolved = true; + // Re-price against the now-known status if a tenure was already entered. + this.onSeniorCitizenChange(); + this.cdr.detectChanges(); + }); + } + + /** Explains the locked checkbox to staff, so a missing benefit never looks like a system fault. */ + get seniorCitizenHint(): string { + if (!this.clientAgeResolved) { + return 'Determined from the client’s date of birth'; + } + if (!this.clientIsIndividual) { + return 'Not applicable to a non-individual client'; + } + if (this.clientAge === null) { + return 'Client has no date of birth on record'; + } + if (this.isSeniorCitizen) { + return `Client is ${this.clientAge} years old — higher rate applied`; + } + return `Client is ${this.clientAge} — applies from age ${this.seniorCitizenAgeThreshold}`; + } + onSeniorCitizenChange() { if (this.tenureValue && this.tenureValue > 0) { this.calculateRate(); diff --git a/src/app/deposits/fixed-deposits/fixed-deposit-account-view/custom-dialogs/interest-schedule-dialog/interest-schedule-dialog.component.html b/src/app/deposits/fixed-deposits/fixed-deposit-account-view/custom-dialogs/interest-schedule-dialog/interest-schedule-dialog.component.html new file mode 100644 index 000000000..de34802f2 --- /dev/null +++ b/src/app/deposits/fixed-deposits/fixed-deposit-account-view/custom-dialogs/interest-schedule-dialog/interest-schedule-dialog.component.html @@ -0,0 +1,71 @@ + + +
+

+ + + {{ 'labels.heading.Monthly Interest Payout Schedule' | translate }} + {{ paidCount }} of {{ schedule.length }} periods paid so far + +

+ +
+

+ + Computed by the actual interest engine for this account's full term -- the exact amount posted each month can vary + slightly with the number of days in that month. +

+ +
+ + + + + + + + + + + @for (period of schedule; track $index) { + + + + + + + } + + + + + + + + +
{{ 'labels.inputs.From Date' | translate }}{{ 'labels.inputs.To Date' | translate }}{{ 'labels.inputs.Interest Amount' | translate }}{{ 'labels.inputs.Status' | translate }}
{{ period.fromDate | dateFormat }}{{ period.toDate | dateFormat }} + {{ period.interestAmount | currency: currencyCode : 'symbol-narrow' : '1.2-2' }} + + @if (period.paid) { + + Paid + + } @else { + Pending + } +
{{ 'labels.inputs.Total Interest Payable' | translate }} + {{ totalInterest | currency: currencyCode : 'symbol-narrow' : '1.2-2' }} +
+
+
+ +
+ +
+
diff --git a/src/app/deposits/fixed-deposits/fixed-deposit-account-view/custom-dialogs/interest-schedule-dialog/interest-schedule-dialog.component.scss b/src/app/deposits/fixed-deposits/fixed-deposit-account-view/custom-dialogs/interest-schedule-dialog/interest-schedule-dialog.component.scss new file mode 100644 index 000000000..f7784f675 --- /dev/null +++ b/src/app/deposits/fixed-deposits/fixed-deposit-account-view/custom-dialogs/interest-schedule-dialog/interest-schedule-dialog.component.scss @@ -0,0 +1,142 @@ +/** + * 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/. + */ + +$navy: #0d1b2a; +$slate: #475569; +$muted: #64748b; +$border: #e2e8f0; +$soft: #f8fafc; +$green: #047857; +$green-bg: #ecfdf5; +$amber: #92400e; +$amber-bg: #fffbeb; + +.isd-dialog { + min-width: 560px; + max-width: 720px; +} + +.isd-header { + display: flex; + align-items: center; + gap: 10px; + margin: 0; + + &__icon { + width: 30px; + height: 30px; + display: inline-flex; + align-items: center; + justify-content: center; + border-radius: 8px; + background: linear-gradient(135deg, #3b82f6 0%, #1e40af 100%); + color: #fff; + font-size: 13px; + flex-shrink: 0; + } + + &__text { + display: flex; + flex-direction: column; + } + + &__title { + font-size: 15px; + font-weight: 700; + color: $navy; + } + + &__sub { + font-size: 12px; + color: $muted; + } +} + +.isd-content { + max-height: 60vh; +} + +.isd-note { + margin: 4px 0 12px; + font-size: 12px; + color: $muted; + font-style: italic; +} + +.isd-scroll { + overflow-x: auto; +} + +.isd-table { + width: 100%; + border-collapse: collapse; + font-size: 13px; + + th, + td { + padding: 8px 12px; + text-align: left; + border-bottom: 1px dashed #eef2f7; + white-space: nowrap; + } + + th { + color: $slate; + font-weight: 600; + font-size: 12px; + text-transform: uppercase; + letter-spacing: 0.03em; + background: $soft; + position: sticky; + top: 0; + } + + tbody tr:hover { + background: $soft; + } + + &__row--paid { + background: rgb(4 120 87 / 4%); + } + + tfoot td { + font-weight: 700; + color: $navy; + border-bottom: none; + border-top: 2px solid $border; + } + + &__amount-col { + text-align: right; + font-variant-numeric: tabular-nums; + } + + &__status-col { + text-align: center; + } +} + +.isd-badge { + display: inline-flex; + align-items: center; + padding: 3px 10px; + border-radius: 999px; + font-size: 11px; + font-weight: 700; + letter-spacing: 0.02em; + + &--paid { + background: $green-bg; + color: $green; + } + + &--pending { + background: $amber-bg; + color: $amber; + } +} diff --git a/src/app/deposits/fixed-deposits/fixed-deposit-account-view/custom-dialogs/interest-schedule-dialog/interest-schedule-dialog.component.ts b/src/app/deposits/fixed-deposits/fixed-deposit-account-view/custom-dialogs/interest-schedule-dialog/interest-schedule-dialog.component.ts new file mode 100644 index 000000000..612cc78f3 --- /dev/null +++ b/src/app/deposits/fixed-deposits/fixed-deposit-account-view/custom-dialogs/interest-schedule-dialog/interest-schedule-dialog.component.ts @@ -0,0 +1,68 @@ +/** + * Copyright since 2025 Mifos Initiative + * + * This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. + */ + +import { Component, inject } from '@angular/core'; +import { + MAT_DIALOG_DATA, + MatDialogRef, + MatDialogTitle, + MatDialogContent, + MatDialogActions, + MatDialogClose +} from '@angular/material/dialog'; +import { MatButton } from '@angular/material/button'; +import { CurrencyPipe } from '@angular/common'; +import { FaIconComponent } from '@fortawesome/angular-fontawesome'; +import { DateFormatPipe } from '../../../../../pipes/date-format.pipe'; +import { STANDALONE_SHARED_IMPORTS } from 'app/standalone-shared.module'; + +/** + * FD Special's full month-wise interest schedule, in a popup rather than always inline on General -- + * a 24+ month term makes for a long table that would otherwise push every other card on the tab down + * the page. Each row also shows whether that period's interest has actually posted yet, not just what + * the engine projects it to be. + */ +@Component({ + selector: 'mifosx-interest-schedule-dialog', + templateUrl: './interest-schedule-dialog.component.html', + styleUrls: ['./interest-schedule-dialog.component.scss'], + imports: [ + ...STANDALONE_SHARED_IMPORTS, + MatDialogTitle, + MatDialogContent, + MatDialogActions, + MatDialogClose, + MatButton, + FaIconComponent, + CurrencyPipe, + DateFormatPipe + ] +}) +export class InterestScheduleDialogComponent { + dialogRef = inject>(MatDialogRef); + data: { + schedule: Array<{ fromDate: any; toDate: any; interestAmount: number; paid: boolean }>; + currencyCode: string; + } = inject(MAT_DIALOG_DATA); + + get schedule(): Array<{ fromDate: any; toDate: any; interestAmount: number; paid: boolean }> { + return this.data.schedule ?? []; + } + + get currencyCode(): string { + return this.data.currencyCode; + } + + get totalInterest(): number { + return this.schedule.reduce((sum, row) => sum + (row.interestAmount ?? 0), 0); + } + + get paidCount(): number { + return this.schedule.filter((row) => row.paid).length; + } +} diff --git a/src/app/deposits/fixed-deposits/fixed-deposit-account-view/general-tab/general-tab.component.html b/src/app/deposits/fixed-deposits/fixed-deposit-account-view/general-tab/general-tab.component.html index e353a802e..29491a554 100644 --- a/src/app/deposits/fixed-deposits/fixed-deposit-account-view/general-tab/general-tab.component.html +++ b/src/app/deposits/fixed-deposits/fixed-deposit-account-view/general-tab/general-tab.component.html @@ -167,11 +167,36 @@
- {{ 'labels.inputs.Maturity Amount' | translate }} - + + {{ + (isSpecialFixedDeposit + ? 'labels.inputs.Principal Returned at Maturity' + : 'labels.inputs.Maturity Amount' + ) | translate + }} + + {{ fixedDepositsAccountData.maturityAmount | currency: currency.code : 'symbol-narrow' : '1.2-2' }}
+ @if (isSpecialFixedDeposit && nextInterestPayout !== null) { +
+ {{ 'labels.inputs.Next Interest Payout' | translate }} + + {{ nextInterestPayout | currency: currency.code : 'symbol-narrow' : '1.2-2' }} + +
+ } + @if (isSpecialFixedDeposit && totalValueAtMaturity !== null) { +
+ {{ + 'labels.inputs.Total Value at Maturity (Principal + Interest)' | translate + }} + + {{ totalValueAtMaturity | currency: currency.code : 'symbol-narrow' : '1.2-2' }} + +
+ } @if (fixedDepositsAccountData.summary.totalDeposits) {
{{ 'labels.inputs.Total Deposits' | translate }} @@ -221,13 +246,63 @@
- {{ 'labels.inputs.Maturity Amount' | translate }} + + {{ + (isSpecialFixedDeposit + ? 'labels.inputs.Principal Returned at Maturity' + : 'labels.inputs.Maturity Amount' + ) | translate + }} + {{ fixedDepositsAccountData.maturityAmount | currency: currency.code : 'symbol-narrow' : '1.2-2' }}
+ @if (isSpecialFixedDeposit && nextInterestPayout !== null) { +
+ {{ 'labels.inputs.Next Interest Payout' | translate }} + + {{ nextInterestPayout | currency: currency.code : 'symbol-narrow' : '1.2-2' }} + +
+ } + @if (isSpecialFixedDeposit && totalValueAtMaturity !== null) { +
+ {{ + 'labels.inputs.Total Value at Maturity (Principal + Interest)' | translate + }} + + {{ totalValueAtMaturity | currency: currency.code : 'symbol-narrow' : '1.2-2' }} + +
+ } } + +@if (isSpecialFixedDeposit && projectedInterestSchedule.length) { +
+
+ +

{{ 'labels.heading.Monthly Interest Payout Schedule' | translate }}

+
+
+
+ {{ 'labels.inputs.Total Interest Payable' | translate }} + + {{ totalProjectedInterest | currency: currency.code : 'symbol-narrow' : '1.2-2' }} + +
+
+ {{ 'labels.inputs.Periods Paid So Far' | translate }} + {{ paidPeriodsCount }} / {{ projectedInterestSchedule.length }} +
+ +
+
+} diff --git a/src/app/deposits/fixed-deposits/fixed-deposit-account-view/general-tab/general-tab.component.scss b/src/app/deposits/fixed-deposits/fixed-deposit-account-view/general-tab/general-tab.component.scss index f1b16485e..a205c1c24 100644 --- a/src/app/deposits/fixed-deposits/fixed-deposit-account-view/general-tab/general-tab.component.scss +++ b/src/app/deposits/fixed-deposits/fixed-deposit-account-view/general-tab/general-tab.component.scss @@ -118,3 +118,13 @@ $soft: #f8fafc; font-weight: 500; font-style: italic; } + +/* ─── Monthly Interest Payout Schedule summary (FD Special only) ─────── */ +.gt-schedule { + margin-top: 16px; + + &__view-btn { + width: 100%; + margin-top: 8px; + } +} diff --git a/src/app/deposits/fixed-deposits/fixed-deposit-account-view/general-tab/general-tab.component.ts b/src/app/deposits/fixed-deposits/fixed-deposit-account-view/general-tab/general-tab.component.ts index 7da833264..083888a2f 100644 --- a/src/app/deposits/fixed-deposits/fixed-deposit-account-view/general-tab/general-tab.component.ts +++ b/src/app/deposits/fixed-deposits/fixed-deposit-account-view/general-tab/general-tab.component.ts @@ -8,12 +8,14 @@ import { Component, inject } from '@angular/core'; import { ActivatedRoute, Router } from '@angular/router'; +import { MatDialog } from '@angular/material/dialog'; import { Currency } from 'app/shared/models/general.model'; import { CurrencyPipe } from '@angular/common'; import { ExternalIdentifierComponent } from '../../../../shared/external-identifier/external-identifier.component'; import { DateFormatPipe } from '../../../../pipes/date-format.pipe'; import { FaIconComponent } from '@fortawesome/angular-fontawesome'; import { STANDALONE_SHARED_IMPORTS } from 'app/standalone-shared.module'; +import { InterestScheduleDialogComponent } from '../custom-dialogs/interest-schedule-dialog/interest-schedule-dialog.component'; @Component({ selector: 'mifosx-general-tab', @@ -30,6 +32,7 @@ import { STANDALONE_SHARED_IMPORTS } from 'app/standalone-shared.module'; export class GeneralTabComponent { private route = inject(ActivatedRoute); private router = inject(Router); + private dialog = inject(MatDialog); fixedDepositsAccountData: any; entityType: string; @@ -69,4 +72,58 @@ export class GeneralTabComponent { const id = this.fixedDepositsAccountData?.reinvestedIntoAccountId; return id ? this.siblingAccountLink(id) : null; } + + /** FD Special never compounds -- every month's interest is transferred out to the linked savings + * account instead of growing this account's own balance, so its "Maturity Amount" is just the + * principal, not principal-plus-interest (see FixedDepositAccount#updateMaturityDateAndAmount). + */ + get isSpecialFixedDeposit(): boolean { + return this.fixedDepositsAccountData?.fdType === 'SPECIAL'; + } + + /** Real, engine-computed period-by-period interest schedule for FD Special (see FixedDepositAccountsApiResource#retrieveOne). */ + get projectedInterestSchedule(): Array<{ fromDate: any; toDate: any; interestAmount: number; paid: boolean }> { + return this.fixedDepositsAccountData?.projectedInterestSchedule ?? []; + } + + get paidPeriodsCount(): number { + return this.projectedInterestSchedule.filter((period) => period.paid).length; + } + + /** Opens the full month-wise schedule in a popup rather than always inline -- a 24+ month term makes + * for a long table that would otherwise push every other card on this tab down the page. */ + openInterestSchedule(): void { + this.dialog.open(InterestScheduleDialogComponent, { + width: '680px', + data: { + schedule: this.projectedInterestSchedule, + currencyCode: this.currency?.code + } + }); + } + + /** Backend-computed sum of the schedule (see FixedDepositAccountsApiResource#retrieveOne) -- the single + * source of truth, not a client-side re-sum of the schedule array. */ + get totalProjectedInterest(): number | null { + return this.fixedDepositsAccountData?.totalProjectedInterest ?? null; + } + + /** + * The first NOT-YET-PAID period's real amount -- not just index 0, which would keep showing an + * already-posted-and-transferred period's amount as "next" forever once the account has any real + * history. Deliberately has NO rate/12 fallback: that would quietly present an inaccurate money figure + * as if it were the real one. If the schedule is missing, or every period is already paid, the row is + * hidden instead. + */ + get nextInterestPayout(): number | null { + return this.projectedInterestSchedule.find((period) => !period.paid)?.interestAmount ?? null; + } + + /** What the customer ends up with overall across the FD's whole life: the principal returned by the FD + * itself at maturity, PLUS every month's interest already paid out to the linked savings account along + * the way. Backend-computed (see FixedDepositAccountsApiResource#retrieveOne), same as maturityAmount + * itself, rather than derived here from the schedule array. */ + get totalValueAtMaturity(): number | null { + return this.fixedDepositsAccountData?.totalValueAtMaturity ?? null; + } } 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 702defd3a..5fd042184 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 @@ -613,7 +613,7 @@ Age - Must be below 18 years + Must be below {{ minorAgeThreshold }} years 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 f7bb9d68f..18035cd4b 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 @@ -26,6 +26,12 @@ import { MatDialog } from '@angular/material/dialog'; /** Custom Services */ import { RecurringDepositsService } from '../../recurring-deposits.service'; import { ClientsService } from 'app/clients/clients.service'; +import { + AGE_OF_MAJORITY, + MINOR_AGE_THRESHOLD, + isMinorAge, + isSeniorCitizenAge +} from 'app/clients/shared/client-age.util'; import { MatTooltip } from '@angular/material/tooltip'; import { MatStepperPrevious, MatStepperNext } from '@angular/material/stepper'; import { FaIconComponent } from '@fortawesome/angular-fontawesome'; @@ -97,8 +103,16 @@ export class RecurringDepositsAccountDetailsStepComponent implements OnInit, OnC selectedCustomerSegment: string | null = null; // ── Client info (auto-fetched) ───────────────────────────────────────── + /** + * The client's legal form ("Individual" / "Non-Individual") as captured at client creation. Customer + * Segment is derived from this rather than picked by hand, so an account can never disagree with the + * CIF about what kind of customer it belongs to. + */ + clientLegalForm: string | null = null; clientDob: Date | null = null; clientAge: number | null = null; + /** Exposed for tooltips so the displayed rule can never drift from the applied rule. */ + readonly minorAgeThreshold = MINOR_AGE_THRESHOLD; clientGender: string | null = null; primaryClientName = ''; primaryClientCif = ''; @@ -337,6 +351,8 @@ export class RecurringDepositsAccountDetailsStepComponent implements OnInit, OnC this.autoDetectAccountStatus(); } } + this.clientLegalForm = client?.legalForm?.value ?? client?.legalForm?.name ?? null; + this.applyInferredCustomerSegment(); this.cdr.detectChanges(); }); } @@ -364,16 +380,16 @@ export class RecurringDepositsAccountDetailsStepComponent implements OnInit, OnC } isTileDisabled(value: string): boolean { - if (value === 'MINOR') return this.clientAge === null || this.clientAge >= 18; - if (value === 'SENIOR_CITIZEN') return this.clientAge === null || this.clientAge < 60; + if (value === 'MINOR') return !isMinorAge(this.clientAge); + if (value === 'SENIOR_CITIZEN') return !isSeniorCitizenAge(this.clientAge); return false; } getTileTooltip(value: string): string { - if (value === 'MINOR' && this.clientAge !== null && this.clientAge >= 18) { - return `Age ${this.clientAge} yrs — Minor only allowed below 18 years`; + if (value === 'MINOR' && !isMinorAge(this.clientAge)) { + return `Age ${this.clientAge} yrs — Minor only allowed below years`; } - if (value === 'SENIOR_CITIZEN' && this.clientAge !== null && this.clientAge < 60) { + if (value === 'SENIOR_CITIZEN' && !isSeniorCitizenAge(this.clientAge)) { return `Age ${this.clientAge} yrs — Senior Citizen only allowed at 60+ years`; } if (value === 'MINOR' && this.clientAge === null) return 'DOB not available — cannot verify age'; @@ -386,10 +402,10 @@ export class RecurringDepositsAccountDetailsStepComponent implements OnInit, OnC return; } if (this.clientAge === null) return; - if (this.clientAge < 18) { + if (isMinorAge(this.clientAge)) { this.selectedAccountStatus = 'MINOR'; this.minorDob = this.clientDob; - } else if (this.clientAge >= 60) { + } else if (isSeniorCitizenAge(this.clientAge)) { this.selectedAccountStatus = 'SENIOR_CITIZEN'; } else if (this.clientGender === 'female') { this.selectedAccountStatus = 'INDIVIDUAL_FEMALE'; @@ -441,6 +457,9 @@ export class RecurringDepositsAccountDetailsStepComponent implements OnInit, OnC this.customerSegmentOptions = this.extractOptions(response.customerSegmentOptions, response.customerSegment); this.selectedAccountType = response.accountType ?? null; this.selectedCustomerSegment = response.customerSegment ?? null; + // The product default above is only a fallback -- the client's own legal form wins. Re-applied + // here as well as in the client callback because the two responses can arrive in either order. + this.applyInferredCustomerSegment(); this.recurringDepositsAccountProductTemplate.emit(response); this.fieldOfficerData = response.fieldOfficerOptions; this.isProductSelected = true; @@ -557,6 +576,24 @@ export class RecurringDepositsAccountDetailsStepComponent implements OnInit, OnC return fallback ? [fallback] : []; } + /** + * Selects the Customer Segment that matches the client's legal form. The segment code values + * ("Individual" / "Non-Individual") are seeded to be byte-identical to the legal-form labels, so this + * is an exact, case-insensitive comparison on purpose -- a substring match would resolve + * "Non-Individual" to the "Individual" option, since one contains the other. Leaves the product + * default untouched when the client's form is unknown or has no matching option. + */ + private applyInferredCustomerSegment(): void { + if (!this.clientLegalForm || !this.customerSegmentOptions.length) { + return; + } + const target = this.clientLegalForm.trim().toLowerCase(); + const match = this.customerSegmentOptions.find((opt) => String(opt).trim().toLowerCase() === target); + if (match) { + this.selectedCustomerSegment = match; + } + } + formatTemplateDate(value: any): string { if (!value) return ''; let dateValue: Date | null = null; @@ -695,7 +732,7 @@ export class RecurringDepositsAccountDetailsStepComponent implements OnInit, OnC const dob = this.minorDob ?? this.clientDob; if (!dob) return ''; const d = new Date(dob); - d.setFullYear(d.getFullYear() + 18); + d.setFullYear(d.getFullYear() + AGE_OF_MAJORITY); return this.dateUtils.formatDate(d, 'M/d/yyyy') || ''; } 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 7fa51c3e8..237abd8dc 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 @@ -110,12 +110,12 @@
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 46cf19aa6..554d9d1ee 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 @@ -6,8 +6,21 @@ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ -import { Component, Input, OnChanges, SimpleChanges, ViewChild, Output, EventEmitter, inject } from '@angular/core'; +import { + Component, + Input, + OnChanges, + SimpleChanges, + ViewChild, + Output, + EventEmitter, + ChangeDetectorRef, + inject +} from '@angular/core'; import { RecurringDepositsService } from '../../recurring-deposits.service'; +import { ClientsService } from 'app/clients/clients.service'; +import { SettingsService } from 'app/settings/settings.service'; +import { SENIOR_CITIZEN_AGE_THRESHOLD, deriveSeniorCitizenStatus } from 'app/clients/shared/client-age.util'; import { MatTable, MatColumnDef, @@ -142,7 +155,18 @@ export class RecurringDepositsAccountInterestRateChartStepComponent implements O 'actions' ]; + /** + * Derived from the client's own date of birth -- never staff-editable, so a client who is not + * actually 60+ can never be priced at the senior-citizen rate by a mis-ticked box. + */ isSeniorCitizen = false; + readonly seniorCitizenAgeThreshold = SENIOR_CITIZEN_AGE_THRESHOLD; + /** Client age in completed years, or null when the DOB is unknown / the client is an entity. */ + clientAge: number | null = null; + /** False for a company/entity client, which has no age and so can never qualify. */ + clientIsIndividual = true; + /** True once the client lookup has come back, so the UI can explain the locked checkbox honestly. */ + clientAgeResolved = false; incentivesDisplayedColumns: string[] = [ 'entityType', 'attributeName', @@ -172,8 +196,14 @@ export class RecurringDepositsAccountInterestRateChartStepComponent implements O ]; private recurringDepositsService = inject(RecurringDepositsService); + private clientsService = inject(ClientsService); + private settingsService = inject(SettingsService); + private cdr = inject(ChangeDetectorRef); ngOnChanges(changes: SimpleChanges) { + if (changes['recurringDepositsAccountTemplate']) { + this.resolveSeniorCitizenFromClient(); + } // Only reset/re-apply when the TEMPLATE or cbsBaseRates changes, NOT when initialTenure changes alone const templateChanged = changes['recurringDepositsAccountProductTemplate'] || @@ -201,10 +231,9 @@ export class RecurringDepositsAccountInterestRateChartStepComponent implements O if (this.initialTenure.depositAmount) { this.principalAmount = this.initialTenure.depositAmount; } - // Pre-check senior citizen if it was set during create - if (this.initialTenure.isSeniorCitizen) { - this.isSeniorCitizen = true; - } + // Deliberately NOT restoring initialTenure.isSeniorCitizen: the flag is derived from the client's + // date of birth in resolveSeniorCitizenFromClient(). Re-applying a stored value here would carry a + // previously mis-set flag forward on every edit. // Auto-calculate when tenure is pre-filled (edit mode) setTimeout(() => this.calculateRate(), 0); } @@ -359,6 +388,45 @@ export class RecurringDepositsAccountInterestRateChartStepComponent implements O this.validationWarning = null; } + /** + * Derives the senior-citizen flag from the client's date of birth. Runs whenever the account template + * arrives (create and edit alike), so the flag always reflects the customer on record rather than + * whatever was ticked -- or previously saved -- by hand. + */ + private resolveSeniorCitizenFromClient(): void { + const clientId = this.recurringDepositsAccountTemplate?.clientId ?? this.recurringDepositsAccountTemplate?.id; + if (!clientId) { + return; + } + this.clientsService.getClientData(String(clientId)).subscribe((client: any) => { + const status = deriveSeniorCitizenStatus(client, this.settingsService.businessDate); + this.clientAge = status.age; + this.clientIsIndividual = status.isIndividual; + this.isSeniorCitizen = status.isSeniorCitizen; + this.clientAgeResolved = true; + // Re-price against the now-known status if a tenure was already entered. + this.onSeniorCitizenChange(); + this.cdr.detectChanges(); + }); + } + + /** Explains the locked checkbox to staff, so a missing benefit never looks like a system fault. */ + get seniorCitizenHint(): string { + if (!this.clientAgeResolved) { + return 'Determined from the client’s date of birth'; + } + if (!this.clientIsIndividual) { + return 'Not applicable to a non-individual client'; + } + if (this.clientAge === null) { + return 'Client has no date of birth on record'; + } + if (this.isSeniorCitizen) { + return `Client is ${this.clientAge} years old — higher rate applied`; + } + return `Client is ${this.clientAge} — applies from age ${this.seniorCitizenAgeThreshold}`; + } + onSeniorCitizenChange() { if (this.tenureValue && this.tenureValue > 0) { this.calculateRate(); 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 4abb78a1a..0a12ed363 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 @@ -298,10 +298,16 @@ {{ recurringDepositsAccountProductTemplate.accountType }} } - @if (recurringDepositsAccountProductTemplate?.customerSegment) { + + @if ( + recurringDepositAccountData?.customerSegment || recurringDepositsAccountProductTemplate?.customerSegment + ) {
Customer Segment - {{ recurringDepositsAccountProductTemplate.customerSegment }} + {{ + recurringDepositAccountData?.customerSegment ?? recurringDepositsAccountProductTemplate.customerSegment + }}
} @if (recurringDepositsAccountProductTemplate?.productCode) { diff --git a/src/app/products/fixed-deposit-products/fixed-deposit-product-stepper/fixed-deposit-product-details-step/fixed-deposit-product-details-step.component.html b/src/app/products/fixed-deposit-products/fixed-deposit-product-stepper/fixed-deposit-product-details-step/fixed-deposit-product-details-step.component.html index bf0e0f6b2..6cceb7ba6 100644 --- a/src/app/products/fixed-deposit-products/fixed-deposit-product-stepper/fixed-deposit-product-details-step/fixed-deposit-product-details-step.component.html +++ b/src/app/products/fixed-deposit-products/fixed-deposit-product-stepper/fixed-deposit-product-details-step/fixed-deposit-product-details-step.component.html @@ -116,6 +116,12 @@
Deposit Account Link + @if (fixedDepositProductDetailsForm.get('fdType')?.value === 'SPECIAL') { + + Required for FD Special -- every account will need a linked savings account so its monthly interest, and its + principal at maturity, can be paid out automatically. + + }
diff --git a/src/app/products/fixed-deposit-products/fixed-deposit-product-stepper/fixed-deposit-product-details-step/fixed-deposit-product-details-step.component.scss b/src/app/products/fixed-deposit-products/fixed-deposit-product-stepper/fixed-deposit-product-details-step/fixed-deposit-product-details-step.component.scss index 2e487792e..6ed6d51eb 100644 --- a/src/app/products/fixed-deposit-products/fixed-deposit-product-stepper/fixed-deposit-product-details-step/fixed-deposit-product-details-step.component.scss +++ b/src/app/products/fixed-deposit-products/fixed-deposit-product-stepper/fixed-deposit-product-details-step/fixed-deposit-product-details-step.component.scss @@ -76,6 +76,16 @@ $gray-900: #202124; .fdpd-checkbox-row { display: flex; - align-items: center; + flex-direction: column; + align-items: flex-start; + justify-content: center; + gap: 4px; min-height: 56px; } + +.fdpd-hint { + font-size: 12px; + color: $gray-900; + opacity: 0.65; + margin-left: 32px; +} diff --git a/src/app/products/fixed-deposit-products/fixed-deposit-product-stepper/fixed-deposit-product-details-step/fixed-deposit-product-details-step.component.ts b/src/app/products/fixed-deposit-products/fixed-deposit-product-stepper/fixed-deposit-product-details-step/fixed-deposit-product-details-step.component.ts index 8df6052c6..7ee45d938 100644 --- a/src/app/products/fixed-deposit-products/fixed-deposit-product-stepper/fixed-deposit-product-details-step/fixed-deposit-product-details-step.component.ts +++ b/src/app/products/fixed-deposit-products/fixed-deposit-product-stepper/fixed-deposit-product-details-step/fixed-deposit-product-details-step.component.ts @@ -43,6 +43,24 @@ export class FixedDepositProductDetailsStepComponent implements OnInit, OnChange constructor() { this.createFixedDepositProductDetailsForm(); + // FD Special is a non-cumulative, monthly-interest-payout product: the backend requires a linked + // savings account for every SPECIAL-type FD account (it's where the monthly interest and, at + // maturity, the principal both get transferred to automatically), so force this on here rather than + // let an admin publish a SPECIAL product that later fails every account creation for missing config. + this.fixedDepositProductDetailsForm + .get('fdType') + ?.valueChanges.subscribe((fdType: string) => this.applyFdTypeConstraints(fdType)); + } + + private applyFdTypeConstraints(fdType: string | null) { + const linkCtrl = this.fixedDepositProductDetailsForm.get('shareLinkingEnabled'); + if (!linkCtrl) return; + if (fdType === 'SPECIAL') { + linkCtrl.setValue(true); + linkCtrl.disable(); + } else { + linkCtrl.enable(); + } } ngOnInit() { @@ -124,10 +142,11 @@ export class FixedDepositProductDetailsStepComponent implements OnInit, OnChange endDate: this.parseDate(template.endDate), shareLinkingEnabled: !!template.shareLinkingEnabled }); + this.applyFdTypeConstraints(this.fixedDepositProductDetailsForm.get('fdType')?.value ?? null); } get fixedDepositProductDetails() { - const formValue = { ...this.fixedDepositProductDetailsForm.value }; + const formValue = { ...this.fixedDepositProductDetailsForm.getRawValue() }; const dateFormat = this.settingsService.dateFormat; if (formValue.effectiveTo instanceof Date) { formValue.effectiveTo = this.dateUtils.formatDate(formValue.effectiveTo, dateFormat) || ''; diff --git a/src/app/products/fixed-deposit-products/fixed-deposit-product-stepper/fixed-deposit-product-terms-step/fixed-deposit-product-terms-step.component.ts b/src/app/products/fixed-deposit-products/fixed-deposit-product-stepper/fixed-deposit-product-terms-step/fixed-deposit-product-terms-step.component.ts index efe589523..11061126a 100644 --- a/src/app/products/fixed-deposit-products/fixed-deposit-product-stepper/fixed-deposit-product-terms-step/fixed-deposit-product-terms-step.component.ts +++ b/src/app/products/fixed-deposit-products/fixed-deposit-product-stepper/fixed-deposit-product-terms-step/fixed-deposit-product-terms-step.component.ts @@ -114,21 +114,37 @@ export class FixedDepositProductTermsStepComponent implements OnInit, OnChanges this.applyFdTypeConstraints(); } + /** + * FD Special is a non-cumulative, monthly-interest-payout product -- the backend forces both the + * posting AND compounding period to Monthly for any account opened under a SPECIAL-type FD product + * (see DepositAccountAssembler#assembleFrom on the backend), so the product itself is locked to the + * same values here to avoid an admin configuring a combination that the backend will silently override + * at account-creation time. + */ private applyFdTypeConstraints() { - if (!this.fixedDepositProductTermsForm || !this.interestPostingPeriodTypeData) return; + if (!this.fixedDepositProductTermsForm) return; const postingCtrl = this.fixedDepositProductTermsForm.get('interestPostingPeriodType'); - if (!postingCtrl) return; + const compoundingCtrl = this.fixedDepositProductTermsForm.get('interestCompoundingPeriodType'); if (this.fdType === 'SPECIAL') { - // Monthly posting = id 4 (MONTHLY in Fineract enum) - const monthlyOption = this.interestPostingPeriodTypeData?.find( - (o: any) => o.value?.toLowerCase() === 'monthly' || o.id === 4 - ); - if (monthlyOption) { - postingCtrl.setValue(monthlyOption.id); - postingCtrl.disable(); + // Monthly = id 4 (MONTHLY in Fineract's SavingsPostingInterestPeriodType/SavingsCompoundingInterestPeriodType enums) + const findMonthly = (data: any) => data?.find((o: any) => o.value?.toLowerCase() === 'monthly' || o.id === 4); + if (postingCtrl && this.interestPostingPeriodTypeData) { + const monthlyPosting = findMonthly(this.interestPostingPeriodTypeData); + if (monthlyPosting) { + postingCtrl.setValue(monthlyPosting.id); + postingCtrl.disable(); + } + } + if (compoundingCtrl && this.interestCompoundingPeriodTypeData) { + const monthlyCompounding = findMonthly(this.interestCompoundingPeriodTypeData); + if (monthlyCompounding) { + compoundingCtrl.setValue(monthlyCompounding.id); + compoundingCtrl.disable(); + } } } else { - postingCtrl.enable(); + postingCtrl?.enable(); + compoundingCtrl?.enable(); } } diff --git a/src/app/savings/savings-account-stepper/savings-account-details-step/savings-account-details-step.component.html b/src/app/savings/savings-account-stepper/savings-account-details-step/savings-account-details-step.component.html index 1f8ce8870..31b97c866 100644 --- a/src/app/savings/savings-account-stepper/savings-account-details-step/savings-account-details-step.component.html +++ b/src/app/savings/savings-account-stepper/savings-account-details-step/savings-account-details-step.component.html @@ -491,7 +491,7 @@ Age - Must be below 18 years + Must be below {{ minorAgeThreshold }} years diff --git a/src/app/savings/savings-account-stepper/savings-account-details-step/savings-account-details-step.component.ts b/src/app/savings/savings-account-stepper/savings-account-details-step/savings-account-details-step.component.ts index cb93fd19e..ebdea0abe 100644 --- a/src/app/savings/savings-account-stepper/savings-account-details-step/savings-account-details-step.component.ts +++ b/src/app/savings/savings-account-stepper/savings-account-details-step/savings-account-details-step.component.ts @@ -16,6 +16,12 @@ import { MatDialog } from '@angular/material/dialog'; /** Custom Services */ import { SavingsService } from 'app/savings/savings.service'; import { ClientsService } from 'app/clients/clients.service'; +import { + AGE_OF_MAJORITY, + MINOR_AGE_THRESHOLD, + isMinorAge, + isSeniorCitizenAge +} from 'app/clients/shared/client-age.util'; import { MatStepperPrevious, MatStepperNext } from '@angular/material/stepper'; import { FaIconComponent } from '@fortawesome/angular-fontawesome'; import { STANDALONE_SHARED_IMPORTS } from 'app/standalone-shared.module'; @@ -80,6 +86,8 @@ export class SavingsAccountDetailsStepComponent implements OnInit { // ── Client info (auto-fetched) ───────────────────────────────────────── clientDob: Date | null = null; clientAge: number | null = null; + /** Exposed for tooltips so the displayed rule can never drift from the applied rule. */ + readonly minorAgeThreshold = MINOR_AGE_THRESHOLD; clientGender: string | null = null; primaryClientName = ''; primaryClientCif = ''; @@ -282,16 +290,16 @@ export class SavingsAccountDetailsStepComponent implements OnInit { isTileDisabled(value: string): boolean { if (this.readOnly) return true; - if (value === 'MINOR') return this.clientAge === null || this.clientAge >= 18; - if (value === 'SENIOR_CITIZEN') return this.clientAge === null || this.clientAge < 60; + if (value === 'MINOR') return !isMinorAge(this.clientAge); + if (value === 'SENIOR_CITIZEN') return !isSeniorCitizenAge(this.clientAge); return false; } getTileTooltip(value: string): string { - if (value === 'MINOR' && this.clientAge !== null && this.clientAge >= 18) { - return `Age ${this.clientAge} yrs — Minor only allowed below 18 years`; + if (value === 'MINOR' && !isMinorAge(this.clientAge)) { + return `Age ${this.clientAge} yrs — Minor only allowed below years`; } - if (value === 'SENIOR_CITIZEN' && this.clientAge !== null && this.clientAge < 60) { + if (value === 'SENIOR_CITIZEN' && !isSeniorCitizenAge(this.clientAge)) { return `Age ${this.clientAge} yrs — Senior Citizen only allowed at 60+ years`; } if (value === 'MINOR' && this.clientAge === null) return 'DOB not available — cannot verify age'; @@ -306,10 +314,10 @@ export class SavingsAccountDetailsStepComponent implements OnInit { } if (this.clientAge === null) return; - if (this.clientAge < 18) { + if (isMinorAge(this.clientAge)) { this.selectedAccountStatus = 'MINOR'; this.minorDob = this.clientDob; - } else if (this.clientAge >= 60) { + } else if (isSeniorCitizenAge(this.clientAge)) { this.selectedAccountStatus = 'SENIOR_CITIZEN'; } else if (this.clientGender === 'female') { this.selectedAccountStatus = 'INDIVIDUAL_FEMALE'; @@ -597,7 +605,7 @@ export class SavingsAccountDetailsStepComponent implements OnInit { const dob = this.minorDob ?? this.clientDob; if (!dob) return ''; const d = new Date(dob); - d.setFullYear(d.getFullYear() + 18); + d.setFullYear(d.getFullYear() + AGE_OF_MAJORITY); return this.dateUtils.formatDate(d, 'M/d/yyyy') || ''; } diff --git a/src/app/savings/savings-account-view/passbook-tab/passbook-tab.component.ts b/src/app/savings/savings-account-view/passbook-tab/passbook-tab.component.ts index a368f824d..6be1eca86 100644 --- a/src/app/savings/savings-account-view/passbook-tab/passbook-tab.component.ts +++ b/src/app/savings/savings-account-view/passbook-tab/passbook-tab.component.ts @@ -84,7 +84,10 @@ export class PassbookTabComponent implements OnInit, OnDestroy { this.accountBalance = account.summary?.accountBalance || 0; this.availableBalance = account.summary?.availableBalance || 0; this.activatedOnDate = account.timeline?.activatedOnDate || null; - this.allTransactions = this.recalculateRunningBalances(this.sortAscending(account.transactions || [])); + const nonReversedTransactions = (account.transactions || []).filter( + (txn: SavingsAccountTransaction) => !txn.reversed + ); + this.allTransactions = this.recalculateRunningBalances(this.sortAscending(nonReversedTransactions)); this.filteredTransactions = [...this.allTransactions]; }); @@ -182,9 +185,12 @@ export class PassbookTabComponent implements OnInit, OnDestroy { private recalculateRunningBalances(transactions: SavingsAccountTransaction[]): SavingsAccountTransaction[] { let balance = 0; return transactions.map((txn) => { - // Accruals and on-hold entries don't affect actual account balance + // Accruals, on-hold entries, and reversed transactions don't affect actual account balance const affectsBalance = - !txn.transactionType.accrual && !txn.transactionType.amountHold && !txn.transactionType.amountRelease; + !txn.reversed && + !txn.transactionType.accrual && + !txn.transactionType.amountHold && + !txn.transactionType.amountRelease; if (affectsBalance) { if (this.isDebit(txn.transactionType)) { diff --git a/src/app/savings/savings-account-view/transactions-tab/transactions-tab.component.ts b/src/app/savings/savings-account-view/transactions-tab/transactions-tab.component.ts index 2b79720cd..f15cc9f5a 100644 --- a/src/app/savings/savings-account-view/transactions-tab/transactions-tab.component.ts +++ b/src/app/savings/savings-account-view/transactions-tab/transactions-tab.component.ts @@ -132,8 +132,8 @@ export class TransactionsTabComponent implements OnInit { ngOnInit() { this.hideAccrualsParam = new UntypedFormControl(false); - this.hideReversedParam = new UntypedFormControl(false); - this.setTransactions(); + this.hideReversedParam = new UntypedFormControl(true); + this.filterTransactions(this.hideReversedParam.value, this.hideAccrualsParam.value); } setTransactions(): void { @@ -222,6 +222,7 @@ export class TransactionsTabComponent implements OnInit { }); } this.dataSource = new MatTableDataSource(transactions); + this.accountWithTransactions = this.transactionsData && this.transactionsData.length > 0; this.dataSource.paginator = this.paginator; this.dataSource.sort = this.sort; } @@ -263,7 +264,10 @@ export class TransactionsTabComponent implements OnInit { let balance = 0; return transactions.map((txn) => { const affectsBalance = - !txn.transactionType.accrual && !txn.transactionType.amountHold && !txn.transactionType.amountRelease; + !txn.reversed && + !txn.transactionType.accrual && + !txn.transactionType.amountHold && + !txn.transactionType.amountRelease; if (affectsBalance) { if (this.isDebit(txn.transactionType)) { balance -= txn.amount; From 83e58376cb0db5470d14a11f4bcfd1e54da9eee6 Mon Sep 17 00:00:00 2001 From: keshav Date: Thu, 27 Aug 2026 10:23:25 +0530 Subject: [PATCH 09/64] feat: implement share account actions, view components, and pending checker tasks --- src/app/shares/share-transactions.util.ts | 34 ++ .../activate-shares-account.component.ts | 3 +- .../apply-shares/apply-shares.component.html | 227 ++++++++++- .../apply-shares/apply-shares.component.scss | 201 ++++++++++ .../apply-shares/apply-shares.component.ts | 367 ++++++++++++++++-- .../approve-shares-account.component.ts | 5 +- .../approve-shares.component.html | 23 ++ .../approve-shares.component.scss | 8 + .../approve-shares.component.ts | 63 ++- .../close-shares-account.component.ts | 3 +- .../redeem-shares/redeem-shares.component.ts | 3 +- .../reject-shares-account.component.ts | 3 +- .../shares-account-details-step.component.ts | 5 +- .../apply-collection-dialog.component.html | 43 +- .../apply-collection-dialog.component.scss | 33 +- .../apply-collection-dialog.component.ts | 65 +++- .../passbook-tab/passbook-tab.component.html | 10 +- .../passbook-tab/passbook-tab.component.ts | 5 +- .../share-certificate-dialog.component.html | 42 ++ .../share-certificate-dialog.component.scss | 48 +++ .../share-certificate-dialog.component.ts | 75 ++++ .../transactions-tab.component.html | 10 +- .../transactions-tab.component.ts | 3 +- src/app/shares/shares.service.ts | 28 ++ .../pending-share-accounts.component.html | 107 +++++ .../pending-share-accounts.component.scss | 128 ++++++ .../pending-share-accounts.component.ts | 192 +++++++++ .../checker-inbox-and-tasks.component.html | 11 + .../getPendingShareAccounts.resolver.ts | 29 ++ src/app/tasks/tasks-routing.module.ts | 13 +- src/assets/translations/en-US.json | 1 + src/environments/.env.ts | 4 +- 32 files changed, 1699 insertions(+), 93 deletions(-) create mode 100644 src/app/shares/share-transactions.util.ts create mode 100644 src/app/tasks/checker-inbox-and-tasks-tabs/pending-share-accounts/pending-share-accounts.component.html create mode 100644 src/app/tasks/checker-inbox-and-tasks-tabs/pending-share-accounts/pending-share-accounts.component.scss create mode 100644 src/app/tasks/checker-inbox-and-tasks-tabs/pending-share-accounts/pending-share-accounts.component.ts create mode 100644 src/app/tasks/common-resolvers/getPendingShareAccounts.resolver.ts diff --git a/src/app/shares/share-transactions.util.ts b/src/app/shares/share-transactions.util.ts new file mode 100644 index 000000000..94dfe0750 --- /dev/null +++ b/src/app/shares/share-transactions.util.ts @@ -0,0 +1,34 @@ +/** + * Copyright since 2025 Mifos Initiative + * + * This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. + */ + +/** + * Which of an account's `purchasedShares` rows belong in a record of money that has moved. + * + * `purchasedShares` is not a transaction list — it is every row in `m_share_account_transactions`, + * which is also where a request *awaiting* approval is parked. A first application and an + * additional-share request are stored identically: same table, same APPLIED status, differing only + * in when they were raised. So a single predicate covers both, and neither needs special-casing. + * + * Excluded: + * + * - **Charge payments** — a fee, not a share movement; the Charges tab is where those belong. + * - **Applied (pending approval)** — nobody has approved it and, on the additional-share route, the + * money is still sitting in Share Sundry. Listing it as a transaction reads as though the member + * has paid and holds the shares when they hold neither. Pending requests appear under Approve + * Additional Shares, which is the view that exists to act on them. + * - **Rejected** — a request that was refused never became a movement. + */ +export function isSettledShareTransaction(transaction: any): boolean { + const type = transaction?.type?.value; + const statusCode = transaction?.status?.code; + + if (type === 'Charge Payment') { + return false; + } + return statusCode !== 'purchasedSharesStatusType.applied' && statusCode !== 'purchasedSharesStatusType.rejected'; +} diff --git a/src/app/shares/shares-account-actions/activate-shares-account/activate-shares-account.component.ts b/src/app/shares/shares-account-actions/activate-shares-account/activate-shares-account.component.ts index b712925f4..a323085be 100644 --- a/src/app/shares/shares-account-actions/activate-shares-account/activate-shares-account.component.ts +++ b/src/app/shares/shares-account-actions/activate-shares-account/activate-shares-account.component.ts @@ -70,8 +70,9 @@ export class ActivateSharesAccountComponent implements OnInit { */ createActivateSharesAccountForm() { this.activateSharesAccountForm = this.formBuilder.group({ + // Opens on the business date; see the note in ApproveSharesAccountComponent. activatedDate: [ - '', + this.settingsService.businessDate, Validators.required ] }); diff --git a/src/app/shares/shares-account-actions/apply-shares/apply-shares.component.html b/src/app/shares/shares-account-actions/apply-shares/apply-shares.component.html index 1a59850e2..d2127951f 100644 --- a/src/app/shares/shares-account-actions/apply-shares/apply-shares.component.html +++ b/src/app/shares/shares-account-actions/apply-shares/apply-shares.component.html @@ -31,9 +31,42 @@ } + + + {{ 'labels.inputs.Resolution Number' | translate }} + + @if (applySharesForm.controls.resolutionNumber.hasError('required')) { + + {{ 'labels.inputs.Resolution Number' | translate }} {{ 'labels.commons.is' | translate }} + {{ 'labels.commons.required' | translate }} + + } + + + + {{ 'labels.inputs.Resolution Date' | translate }} + + + + @if (applySharesForm.controls.resolutionDate.hasError('required')) { + + {{ 'labels.inputs.Resolution Date' | translate }} {{ 'labels.commons.is' | translate }} + {{ 'labels.commons.required' | translate }} + + } + + {{ 'labels.inputs.Total No. of Shares' | translate }} - + @if (applySharesForm.controls.requestedShares.hasError('required')) { {{ 'labels.inputs.Requested Shares' | translate }} {{ 'labels.commons.is' | translate }} @@ -44,8 +77,194 @@ {{ 'labels.inputs.Current Price' | translate }} - + + + + + {{ 'labels.inputs.Amount' | translate }} + + {{ 'labels.inputs.Total No. of Shares' | translate }} × + {{ 'labels.inputs.Current Price' | translate }} + + + +
+
{{ 'labels.inputs.Payment Option' | translate }}
+ + Apply Collection (Cash) + Debit from Savings + +
+ + @if (paymentMode === 'CASH') { +
+

+ The member has already paid at the counter. Tick the receipts that make up this payment, or enter a + voucher number to find one — tick more than one where they paid in instalments. The cash is sitting in + Share Sundry against them, and approving this request moves it into share capital. +

+ +
+ + +
+ + @if (voucherError) { +
+ error_outline + {{ voucherError }} +
+ } + + @if (loadingVouchers) { +
+ + Loading the member's receipts… +
+ } @else if (vouchers.length === 0) { +
+ info_outline +
+ No unapplied receipt for this member. +
+ Collect the cash first from Cashier › Transaction Entry as a deposit to the Share Sundry + GL. Once it is approved, the voucher appears here. +
+
+
+ } @else { + + + + + + + + + + + + @for (v of vouchers; track v.id) { + + + + + + + } + + @if (selectedVoucherIds.length > 0) { + + + + + + + + } +
Voucher / ReceiptDateAmount
+ + {{ v.voucher_no || v.receipt_no }}{{ v.collection_date }} + {{ v.amount | number: '1.2-2' }} +
+ {{ selectedVoucherIds.length }} selected, against {{ amount | number: '1.2-2' }} + + {{ selectedVoucherTotal | number: '1.2-2' }} +
+ + + @if (selectedVoucherIds.length > 0 && voucherShortfall > 0) { +
+ error_outline + + The selected receipts are {{ voucherShortfall | number: '1.2-2' }} short of + {{ amount | number: '1.2-2' }}. Tick another receipt, or collect the balance at the counter first. + +
+ } + } +
+ } + + @if (paymentMode === 'SAVINGS') { +
+

+ The amount is debited from the member's linked savings into Share Sundry when this is submitted, so + their balance drops now. Approving the request then moves it into share capital. +

+ + @if (checkingSavings) { +
+ + Checking the linked savings account… +
+ } @else if (savingsEligibility) { +
+ @if (savingsEligibility.savingsAccountNo) { +
+ Savings account + {{ savingsEligibility.savingsAccountNo }} +
+
+ Available balance + {{ savingsEligibility.savingsBalance | number: '1.2-2' }} +
+ } +
+ To debit + {{ amount | number: '1.2-2' }} +
+
+ + @if (!savingsEligibility.eligible) { +
+ error_outline + {{ savingsEligibility.reason }} +
+ } + } +
+ } + + @if (errorMsg) { +
+ error_outline + {{ errorMsg }} +
+ } + + +

+ Submitting records a request for approval. The shares are issued, and the money moved out of Share Sundry, + only once it is approved — until then it appears under pending, not in the account's transactions. +

@@ -53,8 +272,8 @@ - diff --git a/src/app/shares/shares-account-actions/apply-shares/apply-shares.component.scss b/src/app/shares/shares-account-actions/apply-shares/apply-shares.component.scss index 7c3f1555b..49017d355 100644 --- a/src/app/shares/shares-account-actions/apply-shares/apply-shares.component.scss +++ b/src/app/shares/shares-account-actions/apply-shares/apply-shares.component.scss @@ -6,6 +6,207 @@ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ +$primary: #1a3a6b; +$border: #d8e0ec; +$muted: #6b7a90; +$danger: #b3261e; + .container { max-width: 37rem; } + +.payment-mode { + margin: 4px 0 16px; +} + +.payment-mode-label { + font-size: 12px; + text-transform: uppercase; + letter-spacing: 0.4px; + color: $muted; + margin-bottom: 8px; +} + +.payment-mode-options { + display: flex; + flex-direction: column; + gap: 4px; +} + +/* The funding route's own detail, boxed off from the share fields above it: which voucher, or + which savings account, is what the clerk checks before submitting. */ +.funding-panel { + border: 1px solid $border; + border-radius: 6px; + padding: 14px; + margin-bottom: 16px; + background: #fbfcfe; +} + +.funding-intro { + font-size: 13px; + color: $muted; + margin: 0 0 12px; +} + +.voucher-lookup { + display: flex; + gap: 8px; + align-items: center; + margin: 0 0 12px; +} + +.voucher-input { + flex: 1; + min-width: 0; + padding: 9px 11px; + border: 1px solid $border; + border-radius: 6px; + font-family: 'Roboto Mono', monospace; + font-size: 13px; + letter-spacing: 0.4px; + color: $primary; + + &:focus { + outline: none; + border-color: $primary; + } + + &:disabled { + background: #f4f7fb; + color: $muted; + } +} + +.funding-loading { + display: flex; + align-items: center; + gap: 10px; + padding: 8px 0; + font-size: 13px; + color: $muted; +} + +.funding-summary { + border: 1px solid $border; + border-radius: 6px; + background: #fff; + padding: 10px 12px; + font-size: 13px; +} + +.funding-row { + display: flex; + justify-content: space-between; + gap: 12px; + padding: 4px 0; + color: $primary; + + > span:first-child { + color: $muted; + } +} + +.funding-row--total { + border-top: 1px solid #eceff4; + margin-top: 4px; + padding-top: 8px; + font-weight: 500; +} + +.mono { + font-family: 'Courier New', monospace; +} + +.funding-empty { + display: flex; + gap: 10px; + align-items: flex-start; + background: #f4f7fb; + border: 1px solid $border; + border-radius: 6px; + padding: 12px; + font-size: 13px; +} + +.funding-empty-hint { + color: $muted; + margin-top: 4px; +} + +.receipt-table { + width: 100%; + border-collapse: collapse; + font-size: 13px; + background: #fff; + border: 1px solid $border; + border-radius: 6px; + + th { + background: #eef2f8; + color: $primary; + font-size: 11px; + text-transform: uppercase; + letter-spacing: 0.4px; + padding: 8px; + } + + td { + padding: 8px; + border-bottom: 1px solid #eceff4; + } + + tbody tr { + cursor: pointer; + + &:hover { + background: #f4f7fb; + } + + &.selected { + background: #e7effb; + } + } + + /* The running total against the tranche amount — what decides whether the request is accepted, + and not something to be added up by eye once a second receipt is in play. */ + tfoot .total-row { + border-top: 2px solid #cbd5e1; + + td { + padding-top: 10px; + color: #1f3d7a; + } + } +} + +.ta-left { + text-align: left; +} + +.ta-right { + text-align: right; +} + +.ta-center { + text-align: center; +} + +.alert-error { + display: flex; + align-items: center; + gap: 8px; + background: #fdecea; + color: $danger; + border: 1px solid #f5c6c2; + border-radius: 6px; + padding: 10px 12px; + font-size: 13px; + margin-bottom: 12px; +} + +.pending-hint { + font-size: 12px; + color: $muted; + margin: 0; +} diff --git a/src/app/shares/shares-account-actions/apply-shares/apply-shares.component.ts b/src/app/shares/shares-account-actions/apply-shares/apply-shares.component.ts index 2a4bde0c2..751167c6d 100644 --- a/src/app/shares/shares-account-actions/apply-shares/apply-shares.component.ts +++ b/src/app/shares/shares-account-actions/apply-shares/apply-shares.component.ts @@ -7,30 +7,63 @@ */ /** Angular Imports */ -import { Component, OnInit, Input, inject } from '@angular/core'; -import { UntypedFormGroup, UntypedFormBuilder, Validators, ReactiveFormsModule } from '@angular/forms'; -import { ActivatedRoute, Router, RouterLink } from '@angular/router'; +import { Component, OnInit, inject } from '@angular/core'; +import { UntypedFormGroup, UntypedFormBuilder, Validators } from '@angular/forms'; +import { ActivatedRoute, Router } from '@angular/router'; +import { MatRadioModule } from '@angular/material/radio'; +import { MatProgressSpinnerModule } from '@angular/material/progress-spinner'; +import { MatIconModule } from '@angular/material/icon'; +import { catchError, debounceTime, distinctUntilChanged } from 'rxjs/operators'; +import { of } from 'rxjs'; /** Custom Services */ import { SharesService } from 'app/shares/shares.service'; +import { CashierService } from 'app/cashier/cashier.service'; import { SettingsService } from 'app/settings/settings.service'; import { Dates } from 'app/core/utils/dates'; import { STANDALONE_SHARED_IMPORTS } from 'app/standalone-shared.module'; /** - * Apply Shares Component + * Apply Additional Shares. + * + * A member buying a further tranche of shares, which — like the account's first application — is + * only a request until it is approved. Two things have to be captured that the bare share count + * does not carry: + * + *
    + *
  • the board resolution authorising this particular tranche, which is its own meeting and its + * own number, not the one the account was opened under;
  • + *
  • where the money is coming from, because a tranche nobody has paid for cannot be approved.
  • + *
+ * + * Both funding routes converge on the Share Sundry holding account, so approval posts the same + * Dr Sundry / Cr Share Capital pair either way: + * + *
    + *
  • Cash — the member already paid at the counter; the cashier's receipts put the money + * into Sundry at that point. Submitting only reserves those receipts against this tranche. + * More than one can be ticked, because a member often pays in instalments and no single + * receipt covers the tranche. They are checked here rather than at approval, while the member + * is still at the counter to make up any shortfall.
  • + *
  • Savings — the member's savings are debited into Sundry on submit, so their balance + * drops immediately. Approval then moves it out to share capital.
  • + *
*/ @Component({ selector: 'mifosx-apply-shares', templateUrl: './apply-shares.component.html', styleUrls: ['./apply-shares.component.scss'], imports: [ - ...STANDALONE_SHARED_IMPORTS + ...STANDALONE_SHARED_IMPORTS, + MatRadioModule, + MatProgressSpinnerModule, + MatIconModule ] }) export class ApplySharesComponent implements OnInit { private formBuilder = inject(UntypedFormBuilder); private sharesService = inject(SharesService); + private cashierService = inject(CashierService); private dateUtils = inject(Dates); private route = inject(ActivatedRoute); private router = inject(Router); @@ -48,14 +81,33 @@ export class ApplySharesComponent implements OnInit { /** Shares Account Id */ accountId: any; + /** Submission in flight, so the form cannot be posted twice. */ + submitting = false; + /** Anything that stopped the submission, shown above the buttons. */ + errorMsg = ''; + /** - * @param {FormBuilder} formBuilder Form Builder - * @param {SharesService} sharesService Shares Service - * @param {Dates} dateUtils Date Utils - * @param {ActivatedRoute} route Activated Route - * @param {Router} router Router - * @param {SettingsService} settingsService Settings Service + * The member's unapplied Share Sundry receipts, and which of them fund this tranche. + * + * Several, because a member often pays one tranche in instalments — ₹600 today, the ₹400 balance + * tomorrow — and neither receipt covers it alone. They are submitted together as one settlement + * rather than sending the member back to the counter for a single combined receipt. */ + vouchers: any[] = []; + selectedVoucherIds: number[] = []; + loadingVouchers = false; + lookingUpVoucher = false; + voucherError = ''; + + /** What the member's savings can cover, for the savings route. */ + savingsEligibility: { + eligible: boolean; + reason: string | null; + savingsAccountNo: string | null; + savingsBalance: number | null; + } | null = null; + checkingSavings = false; + constructor() { this.accountId = this.route.parent.snapshot.params['shareAccountId']; this.route.data.subscribe((data: { shareAccountActionData: any }) => { @@ -63,54 +115,311 @@ export class ApplySharesComponent implements OnInit { }); } - /** - * Creates the apply shares form. - * Fetching data from service as action buttons malfunction - * in clients view upon using a common resolver. - */ ngOnInit() { this.maxDate = this.settingsService.businessDate; this.createApplySharesAccountForm(); this.applySharesForm.get('unitPrice').patchValue(this.sharesAccountData.currentMarketPrice || ''); + this.applySharesForm.get('amount').patchValue(this.amount || ''); } /** * Creates the apply shares form. + * + * The request date opens on the business date rather than blank: an additional-share request is + * recorded the day it is taken, and a clerk who has to fill that in by hand is being asked to + * retype what the system already knows. */ createApplySharesAccountForm() { this.applySharesForm = this.formBuilder.group({ requestedDate: [ - '', + this.settingsService.businessDate, Validators.required ], requestedShares: [ '', Validators.required ], - unitPrice: [{ value: '', disabled: true }] + unitPrice: [{ value: '', disabled: true }], + // Disabled, not readonly: the amount is derived from shares x price and must never be a + // figure somebody typed, or the tranche's value and its GL posting could disagree. + amount: [{ value: '', disabled: true }], + resolutionNumber: [ + '', + Validators.required + ], + resolutionDate: [ + '', + Validators.required + ], + paymentMode: [ + '', + Validators.required + ] + }); + + // Recompute as the share count is typed, so the clerk can read the amount back to the member + // before committing to a funding route. + this.applySharesForm.get('requestedShares').valueChanges.subscribe(() => { + this.applySharesForm.get('amount').patchValue(this.amount || ''); + // A savings verdict is cleared the moment the amount changes rather than when the re-check + // returns, so a stale "can cover it" is never on screen beside a larger amount. The ticked + // vouchers stay: they are the receipts the member actually holds, and the running total below + // shows against the new amount whether they still cover it. + this.voucherError = ''; + this.savingsEligibility = null; + }); + + // Debounced separately from the clearing above: the check is a server round-trip, and firing it + // on every keystroke of "1000" asks four times and lets the answers land out of order. + this.applySharesForm + .get('requestedShares') + .valueChanges.pipe(debounceTime(400), distinctUntilChanged()) + .subscribe(() => { + if (this.paymentMode === 'SAVINGS') { + this.checkSavings(); + } + }); + + this.applySharesForm.get('paymentMode').valueChanges.subscribe((mode: string) => { + this.vouchers = []; + this.selectedVoucherIds = []; + this.voucherError = ''; + this.errorMsg = ''; + this.savingsEligibility = null; + if (mode === 'SAVINGS') { + this.checkSavings(); + } else if (mode === 'CASH') { + this.loadVouchers(); + } }); } + /** Shares x unit price — what this tranche is worth, and what has to be funded. */ + get amount(): number { + const shares = Number(this.applySharesForm?.get('requestedShares')?.value) || 0; + const price = Number(this.applySharesForm?.get('unitPrice')?.value) || 0; + return shares * price; + } + + get paymentMode(): string { + return this.applySharesForm?.get('paymentMode')?.value || ''; + } + /** - * Submits the form and applies additional shares to the share account, - * if successful redirects to the share account. + * Loads the member's unapplied Share Sundry receipts so they can be ticked off. + * + * A single receipt is ticked for them: with only one candidate there is nothing to choose, and + * making the clerk tick it adds a step that can only be got wrong by forgetting it. + */ + private loadVouchers(): void { + this.loadingVouchers = true; + this.cashierService + .getUnappliedShareAdvances(this.sharesAccountData.clientId) + .pipe( + catchError((err: any) => { + this.voucherError = this.readError(err, 'Could not load the member’s receipts.'); + this.loadingVouchers = false; + return of(null); + }) + ) + .subscribe((res: any) => { + this.loadingVouchers = false; + if (!res) { + return; + } + this.vouchers = res.unappliedCollections || []; + if (this.vouchers.length === 1) { + this.selectedVoucherIds = [this.vouchers[0].id]; + } + }); + } + + isVoucherSelected(id: number): boolean { + return this.selectedVoucherIds.includes(id); + } + + toggleVoucher(id: number): void { + this.selectedVoucherIds = this.isVoucherSelected(id) ? this.selectedVoucherIds.filter((x) => x !== id) : [ + ...this.selectedVoucherIds, + id + ]; + this.voucherError = ''; + } + + /** + * What the ticked receipts come to. + * + * Shown live, because the submission only goes through when the total covers the tranche, and + * adding the rows up by eye is exactly what leads to a rejected submission. + */ + get selectedVoucherTotal(): number { + return this.vouchers.filter((v) => this.isVoucherSelected(v.id)).reduce((sum, v) => sum + Number(v.amount || 0), 0); + } + + /** + * Resolves a voucher number the member quotes and adds it to the list. + * + * Kept alongside the tick list rather than replacing it: the member usually quotes the number on + * the slip in their hand, which is faster than finding it among their receipts. The lookup is + * deliberately not restricted to this member, so a voucher belonging to somebody else is + * surfaced here as a plain mismatch rather than silently ignored. + */ + lookupVoucher(voucherNo: string): void { + const trimmed = (voucherNo || '').trim(); + if (!trimmed || this.lookingUpVoucher) { + return; + } + this.lookingUpVoucher = true; + this.voucherError = ''; + + this.cashierService + .getShareAdvanceByVoucher(trimmed, this.sharesAccountData.clientId) + .pipe( + catchError((err: any) => { + this.voucherError = this.readError(err, `Voucher ${trimmed} could not be found.`); + this.lookingUpVoucher = false; + return of(null); + }) + ) + .subscribe((res: any) => { + this.lookingUpVoucher = false; + if (!res) { + return; + } + if (Number(res.client_id) !== Number(this.sharesAccountData.clientId)) { + this.voucherError = `Voucher ${trimmed} belongs to ${res.client_name}, not the holder of this share account.`; + return; + } + // Added to the selection rather than replacing it — a looked-up voucher is usually the + // second half of a part-payment, not a change of mind about the first. No amount check + // here: one instalment is not meant to cover the tranche on its own. + this.vouchers = [ + res, + ...this.vouchers.filter((v) => v.id !== res.id) + ]; + if (!this.isVoucherSelected(res.id)) { + this.selectedVoucherIds = [ + ...this.selectedVoucherIds, + res.id + ]; + } + }); + } + + /** + * Whether the member's savings can cover this tranche. + * + * Asks the tranche-scoped endpoint rather than judging it here: the server prices the shares from + * the product's market price, so the figure the balance is checked against is the same one the + * submission will be valued at. Deciding it in the browser would let the two drift apart. + */ + private checkSavings(): void { + const shares = Number(this.applySharesForm.get('requestedShares').value) || 0; + this.checkingSavings = true; + this.sharesService + .getAdditionalSharesSavingsEligibility(this.accountId, shares) + .pipe(catchError(() => of(null))) + .subscribe((res: any) => { + this.checkingSavings = false; + if (!res) { + this.savingsEligibility = { + eligible: false, + reason: 'Could not check the savings account linked to this share account.', + savingsAccountNo: null, + savingsBalance: null + }; + return; + } + this.savingsEligibility = { + eligible: !!res.eligible, + reason: res.reason ?? null, + savingsAccountNo: res.savingsAccountNo ?? null, + savingsBalance: res.savingsBalance == null ? null : Number(res.savingsBalance) + }; + }); + } + + /** + * Whether the chosen funding route is ready — receipts that between them cover the tranche, or + * savings that can cover it. + * + * The total is checked here rather than only on submit, so a part-paid tranche is visibly short + * at the counter instead of being refused after the member has left. + */ + get fundingReady(): boolean { + if (this.paymentMode === 'CASH') { + return this.selectedVoucherIds.length > 0 && this.selectedVoucherTotal >= this.amount; + } + if (this.paymentMode === 'SAVINGS') { + return !!this.savingsEligibility?.eligible; + } + return false; + } + + /** The shortfall to tell the member about, or zero when the ticked receipts cover the tranche. */ + get voucherShortfall(): number { + const short = this.amount - this.selectedVoucherTotal; + return short > 0 ? short : 0; + } + + get canSubmit(): boolean { + return this.applySharesForm.valid && this.amount > 0 && this.fundingReady && !this.submitting; + } + + /** + * Records the request. Nothing is issued here — the tranche sits pending until it is approved, + * which is what moves the money out of Sundry into share capital. */ submit() { - const applySharesFormData = this.applySharesForm.value; + if (!this.canSubmit) { + return; + } + this.submitting = true; + this.errorMsg = ''; + + const formValue = this.applySharesForm.value; const locale = this.settingsService.language.code; const dateFormat = this.settingsService.dateFormat; - const prevRequestedDate: Date = this.applySharesForm.value.requestedDate; - if (applySharesFormData.requestedDate instanceof Date) { - applySharesFormData.requestedDate = this.dateUtils.formatDate(prevRequestedDate, dateFormat); - } - const data = { - ...applySharesFormData, + + const data: any = { + requestedShares: formValue.requestedShares, + requestedDate: this.dateUtils.formatDate(formValue.requestedDate, dateFormat), unitPrice: this.applySharesForm.get('unitPrice').value, + resolutionNumber: formValue.resolutionNumber, + meetingDate: this.dateUtils.formatDate(formValue.resolutionDate, dateFormat), + fundingSource: formValue.paymentMode, dateFormat, locale }; - this.sharesService.executeSharesAccountCommand(this.accountId, 'applyadditionalshares', data).subscribe(() => { - this.router.navigate(['../../'], { relativeTo: this.route }); + if (formValue.paymentMode === 'CASH') { + // First receipt leads, the rest ride along; the server settles them as one funding. + const [ + leadId, + ...additionalCollectionIds + ] = this.selectedVoucherIds; + data.advanceCollectionId = leadId; + if (additionalCollectionIds.length > 0) { + data.additionalCollectionIds = additionalCollectionIds; + } + } + + this.sharesService.executeSharesAccountCommand(this.accountId, 'applyadditionalshares', data).subscribe({ + next: () => { + this.router.navigate(['../../'], { relativeTo: this.route }); + }, + error: (err: any) => { + this.submitting = false; + this.errorMsg = this.readError(err, 'The additional-share request could not be submitted.'); + } }); } + + private readError(err: any, fallback: string): string { + return ( + err?.error?.errors?.[0]?.defaultUserMessage || + err?.error?.defaultUserMessage || + err?.error?.developerMessage || + fallback + ); + } } diff --git a/src/app/shares/shares-account-actions/approve-shares-account/approve-shares-account.component.ts b/src/app/shares/shares-account-actions/approve-shares-account/approve-shares-account.component.ts index 2d5db8a6f..331634ca7 100644 --- a/src/app/shares/shares-account-actions/approve-shares-account/approve-shares-account.component.ts +++ b/src/app/shares/shares-account-actions/approve-shares-account/approve-shares-account.component.ts @@ -72,8 +72,11 @@ export class ApproveSharesAccountComponent implements OnInit { */ createApproveSharesAccountForm() { this.approveSharesAccountForm = this.formBuilder.group({ + // Opens on the business date rather than blank: an approval is recorded the day it is given, + // so this is what the clerk would type anyway. Still editable, and still capped at the + // business date, for an approval being entered after the fact. approvedDate: [ - '', + this.settingsService.businessDate, Validators.required ], note: [''] diff --git a/src/app/shares/shares-account-actions/approve-shares/approve-shares.component.html b/src/app/shares/shares-account-actions/approve-shares/approve-shares.component.html index 02dc30eba..a7871caa2 100644 --- a/src/app/shares/shares-account-actions/approve-shares/approve-shares.component.html +++ b/src/app/shares/shares-account-actions/approve-shares/approve-shares.component.html @@ -26,6 +26,29 @@ {{ share.purchasedPrice }} + + {{ 'labels.inputs.Amount' | translate }} + {{ trancheAmount(share) | number: '1.2-2' }} + + + + + {{ 'labels.inputs.Resolution Number' | translate }} + + {{ share.resolutionNumber || '—' }} + @if (share.meetingDate) { + ({{ share.meetingDate | dateFormat }}) + } + + + + + + {{ 'labels.inputs.Payment Option' | translate }} + {{ fundingLabel(share) || '—' }} + + {{ 'labels.inputs.Status' | translate }} diff --git a/src/app/shares/shares-account-actions/approve-shares/approve-shares.component.scss b/src/app/shares/shares-account-actions/approve-shares/approve-shares.component.scss index 3c8ff780e..c0253f07f 100644 --- a/src/app/shares/shares-account-actions/approve-shares/approve-shares.component.scss +++ b/src/app/shares/shares-account-actions/approve-shares/approve-shares.component.scss @@ -19,4 +19,12 @@ table { margin: 4px; line-height: 25px; } + + /* The meeting date rides alongside the resolution number rather than taking a column of its + own — it is only ever read together with the number it belongs to. */ + .resolution-date { + color: #6b7a90; + font-size: 12px; + margin-left: 4px; + } } diff --git a/src/app/shares/shares-account-actions/approve-shares/approve-shares.component.ts b/src/app/shares/shares-account-actions/approve-shares/approve-shares.component.ts index c05c36151..ab79888d2 100644 --- a/src/app/shares/shares-account-actions/approve-shares/approve-shares.component.ts +++ b/src/app/shares/shares-account-actions/approve-shares/approve-shares.component.ts @@ -32,7 +32,7 @@ import { ApproveShareDialogComponent } from './approve-share-dialog/approve-shar /** Custom Serices */ import { SharesService } from 'app/shares/shares.service'; import { SettingsService } from 'app/settings/settings.service'; -import { NgClass } from '@angular/common'; +import { DecimalPipe, NgClass } from '@angular/common'; import { MatTooltip } from '@angular/material/tooltip'; import { StatusLookupPipe } from '../../../pipes/status-lookup.pipe'; import { DateFormatPipe } from '../../../pipes/date-format.pipe'; @@ -63,7 +63,8 @@ import { STANDALONE_SHARED_IMPORTS } from 'app/standalone-shared.module'; MatRow, MatPaginator, StatusLookupPipe, - DateFormatPipe + DateFormatPipe, + DecimalPipe ] }) export class ApproveSharesComponent implements OnInit { @@ -79,11 +80,22 @@ export class ApproveSharesComponent implements OnInit { accountId: any; /** Shares account data. */ sharesData: any[]; - /** Columns to be displayed in shares table. */ + /** + * Columns to be displayed in shares table. + * + * Pending tranches appear here and nowhere else — the Transactions and Passbook tabs list only + * settled movements — so this table has to carry everything the approval turns on: what the + * tranche is worth, the board resolution authorising it, and how it was funded. Approving posts + * Dr Share Sundry / Cr Share Capital, and an approver who cannot see the amount or the funding + * route is being asked to authorise a posting they cannot check. + */ displayedColumns: string[] = [ 'transactionDate', 'totalShares', 'redeemedPrice', + 'amount', + 'resolution', + 'fundingSource', 'status', 'approve' ]; @@ -120,6 +132,51 @@ export class ApproveSharesComponent implements OnInit { this.setShares(); } + /** + * What the tranche is worth. Taken from the transaction's own `amount` where the server sends + * one, falling back to shares x price — the same product the apply form showed the member. + */ + trancheAmount(share: any): number { + const amount = Number(share?.amount); + if (!Number.isNaN(amount) && amount > 0) { + return amount; + } + return (Number(share?.numberOfShares) || 0) * (Number(share?.purchasedPrice) || 0); + } + + /** + * How this tranche was funded, in the words the approver chose on the apply form. + * + * Blank for tranches raised before funding was captured, which is honest: those genuinely have + * no recorded route, and inventing one here would misrepresent them. + */ + fundingLabel(share: any): string { + switch (share?.fundingSource) { + case 'CASH': { + // Says how many receipts when a member paid in instalments, so the approver knows to expect + // several vouchers behind one tranche rather than suspecting a duplicate. + const count = this.voucherCount(share); + return count > 1 ? `Cash (${count} vouchers)` : 'Cash'; + } + case 'SAVINGS': + return 'Savings'; + default: + return ''; + } + } + + /** How many receipts fund this tranche — the lead one plus any further instalments. */ + private voucherCount(share: any): number { + if (!share?.advanceCollectionId) { + return 0; + } + const extra = (share.additionalCollectionIds || '') + .split(',') + .map((part: string) => part.trim()) + .filter((part: string) => part.length > 0); + return 1 + extra.length; + } + /** * Initializes the data source, paginator and sorter for shares table. */ diff --git a/src/app/shares/shares-account-actions/close-shares-account/close-shares-account.component.ts b/src/app/shares/shares-account-actions/close-shares-account/close-shares-account.component.ts index f897cc28f..2ab559776 100644 --- a/src/app/shares/shares-account-actions/close-shares-account/close-shares-account.component.ts +++ b/src/app/shares/shares-account-actions/close-shares-account/close-shares-account.component.ts @@ -72,8 +72,9 @@ export class CloseSharesAccountComponent implements OnInit { */ createCloseSharesAccountForm() { this.closeSharesAccountForm = this.formBuilder.group({ + // Opens on the business date; see the note in ApproveSharesAccountComponent. closedDate: [ - '', + this.settingsService.businessDate, Validators.required ], note: [''] diff --git a/src/app/shares/shares-account-actions/redeem-shares/redeem-shares.component.ts b/src/app/shares/shares-account-actions/redeem-shares/redeem-shares.component.ts index 53b607175..3983a9db4 100644 --- a/src/app/shares/shares-account-actions/redeem-shares/redeem-shares.component.ts +++ b/src/app/shares/shares-account-actions/redeem-shares/redeem-shares.component.ts @@ -79,8 +79,9 @@ export class RedeemSharesComponent implements OnInit { */ createRedeemSharesAccountForm() { this.redeemSharesForm = this.formBuilder.group({ + // Opens on the business date; see the note in ApproveSharesAccountComponent. requestedDate: [ - '', + this.settingsService.businessDate, Validators.required ], requestedShares: [ diff --git a/src/app/shares/shares-account-actions/reject-shares-account/reject-shares-account.component.ts b/src/app/shares/shares-account-actions/reject-shares-account/reject-shares-account.component.ts index 4960dcee7..5cef0f437 100644 --- a/src/app/shares/shares-account-actions/reject-shares-account/reject-shares-account.component.ts +++ b/src/app/shares/shares-account-actions/reject-shares-account/reject-shares-account.component.ts @@ -72,8 +72,9 @@ export class RejectSharesAccountComponent implements OnInit { */ createRejectSharesAccountForm() { this.rejectSharesAccountForm = this.formBuilder.group({ + // Opens on the business date; see the note in ApproveSharesAccountComponent. rejectedDate: [ - '', + this.settingsService.businessDate, Validators.required ], note: [''] diff --git a/src/app/shares/shares-account-stepper/shares-account-details-step/shares-account-details-step.component.ts b/src/app/shares/shares-account-stepper/shares-account-details-step/shares-account-details-step.component.ts index 89c6f9bf3..cc944caa5 100644 --- a/src/app/shares/shares-account-stepper/shares-account-details-step/shares-account-details-step.component.ts +++ b/src/app/shares/shares-account-stepper/shares-account-details-step/shares-account-details-step.component.ts @@ -96,8 +96,11 @@ export class SharesAccountDetailsStepComponent implements OnInit { '', Validators.required ], + // Opens on the business date rather than blank: an application is recorded the day it is + // taken, and a clerk who has to fill that in by hand is being asked to retype what the system + // already knows. Still editable, for an application being entered after the fact. submittedDate: [ - '', + this.settingsService.businessDate, Validators.required ], externalId: [''], diff --git a/src/app/shares/shares-account-view/apply-collection-dialog/apply-collection-dialog.component.html b/src/app/shares/shares-account-view/apply-collection-dialog/apply-collection-dialog.component.html index a0f7778d3..542808e3d 100644 --- a/src/app/shares/shares-account-view/apply-collection-dialog/apply-collection-dialog.component.html +++ b/src/app/shares/shares-account-view/apply-collection-dialog/apply-collection-dialog.component.html @@ -10,17 +10,18 @@

- Enter the voucher number the member was given, or tick the receipts below, to settle against share account - {{ data.accountNo }}. Tick more than one where the member paid in instalments. The amount is moved out of Share Sundry into share - capital, and any entry fee is posted to fee income. + Tick the receipts that make up this payment to settle share account {{ data.accountNo }}, then apply. Today's unapplied receipts for this member are listed below — tick more than one where they paid in + instalments. The amount moves out of Share Sundry into share capital, and any entry fee is posted to fee income.

+
info_outline
- No unapplied receipt for this member. + No unapplied receipt for this member today.
- Collect the cash first from Cashier › Transaction Entry as a deposit to the Share Sundry GL. Once a - manager approves it, enter the voucher number above. + If the cash was taken on an earlier day, enter that voucher number above. Otherwise collect it first from + Cashier › Transaction Entry as a deposit to the Share Sundry GL — once a manager approves it, the + voucher appears here.
@@ -62,7 +64,7 @@ - + @@ -71,7 +73,7 @@ @for (r of receipts; track r.id) { - - + } - @if (selectedIds.length > 1) { + + @if (selectedIds.length > 0) { - - - + + }
Voucher / Receipt Date Amount
+ {{ r.voucher_no || r.receipt_no }} {{ r.collection_date }} - {{ r.amount }} - {{ r.amount | number: '1.2-2' }}
{{ selectedIds.length }} receipts selected - {{ selectedTotal | number: '1.2-2' }} + + {{ selectedIds.length }} {{ selectedIds.length === 1 ? 'receipt' : 'receipts' }} selected {{ selectedTotal | number: '1.2-2' }}
- Select every receipt that makes up this payment. Their total must cover the account's share value plus any entry - fee — anything paid over that is credited to the member's savings account. + Their total must cover the account's share value plus any entry fee — anything paid over that is credited to the + member's savings account.
}
diff --git a/src/app/shares/shares-account-view/apply-collection-dialog/apply-collection-dialog.component.scss b/src/app/shares/shares-account-view/apply-collection-dialog/apply-collection-dialog.component.scss index ba7e9deeb..014f0c3c8 100644 --- a/src/app/shares/shares-account-view/apply-collection-dialog/apply-collection-dialog.component.scss +++ b/src/app/shares/shares-account-view/apply-collection-dialog/apply-collection-dialog.component.scss @@ -75,19 +75,35 @@ $danger: #b3261e; width: 100%; border-collapse: collapse; font-size: 13px; + border: 1px solid $border; + border-radius: 6px; + overflow: hidden; th { background: #eef2f8; color: $primary; font-size: 11px; + font-weight: 600; text-transform: uppercase; letter-spacing: 0.4px; - padding: 8px; + padding: 9px 12px; + white-space: nowrap; } td { - padding: 8px; + padding: 10px 12px; border-bottom: 1px solid #eceff4; + color: #1f2937; + } + + tbody tr:last-child td { + border-bottom: none; + } + + /* Just wide enough for the box, so the voucher number starts near the left edge instead of + after a column of empty space. */ + .col-tick { + width: 36px; } tbody tr { @@ -102,15 +118,24 @@ $danger: #b3261e; } } - /* Only appears once a second receipt is ticked, when adding the rows up by eye stops being - reliable — and the total is what decides whether the settlement is accepted. */ + /* Tabular figures so the amounts line up digit for digit down the column and against the total + below them — the whole point of the column is that it can be read as a sum. */ + .amount-cell { + font-variant-numeric: tabular-nums; + font-weight: 500; + white-space: nowrap; + } + tfoot .total-row { + background: #f4f7fb; border-top: 2px solid #cbd5e1; td { padding-top: 10px; font-size: 13px; + font-weight: 600; color: #1f3d7a; + border-bottom: none; } } } diff --git a/src/app/shares/shares-account-view/apply-collection-dialog/apply-collection-dialog.component.ts b/src/app/shares/shares-account-view/apply-collection-dialog/apply-collection-dialog.component.ts index 5546e11e3..a947c6ce5 100644 --- a/src/app/shares/shares-account-view/apply-collection-dialog/apply-collection-dialog.component.ts +++ b/src/app/shares/shares-account-view/apply-collection-dialog/apply-collection-dialog.component.ts @@ -6,7 +6,7 @@ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ -import { Component, OnInit, Inject, inject } from '@angular/core'; +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'; @@ -16,6 +16,8 @@ import { MatProgressSpinnerModule } from '@angular/material/progress-spinner'; import { catchError } from 'rxjs/operators'; import { of } from 'rxjs'; import { CashierService } from 'app/cashier/cashier.service'; +import { SettingsService } from 'app/settings/settings.service'; +import { Dates } from 'app/core/utils/dates'; /** * Applies a Share Advance receipt to an activated share account. @@ -23,6 +25,14 @@ import { CashierService } from 'app/cashier/cashier.service'; * The member's cash is already sitting in the Share Sundry liability from the cashier's advance * collection. Applying a receipt here posts Dr Sundry / Cr Share Capital (+ Cr Income from Fees * when an entry fee applies) and reveals the purchase entry in the transactions and passbook. + * + * The list shows the member's unapplied receipts for the business date, since a member settling an + * application at the counter has almost always paid for it today, and an older unapplied receipt of + * theirs is more likely money taken for something else. Nothing in it is ticked for the clerk — see + * {@link ngOnInit}. + * + * A receipt outside that narrow list is reached by voucher number through {@link lookupVoucher} — + * cash taken on an earlier day, or a slip the member is holding that is quicker to type than to find. */ @Component({ selector: 'mifosx-apply-collection-dialog', @@ -40,6 +50,8 @@ import { CashierService } from 'app/cashier/cashier.service'; }) export class ApplyCollectionDialogComponent implements OnInit { private cashierService = inject(CashierService); + private settingsService = inject(SettingsService); + private dateUtils = inject(Dates); loading = true; submitting = false; @@ -56,7 +68,7 @@ export class ApplyCollectionDialogComponent implements OnInit { */ selectedIds: number[] = []; - /** Voucher number quoted by the member, as an alternative to picking from the list. */ + /** Voucher number quoted by the member, for a receipt the list does not show. */ voucherNo = ''; lookingUp = false; @@ -75,15 +87,39 @@ export class ApplyCollectionDialogComponent implements OnInit { ) .subscribe((res: any) => { if (res) { - this.receipts = res.unappliedCollections || []; - if (this.receipts.length === 1) { - this.selectedIds = [this.receipts[0].id]; - } + const today = this.dateUtils.formatDate(this.settingsService.businessDate, 'yyyy-MM-dd'); + this.receipts = (res.unappliedCollections || []).filter( + (receipt: any) => this.collectionDateOf(receipt) === today + ); + // Deliberately nothing ticked, not even when a single receipt is listed. Applying a + // collection turns the member's money into share capital, so which receipt it comes out of + // should be a choice the clerk made rather than one they inherited from a pre-ticked box + // and never looked at. } this.loading = false; }); } + /** + * A receipt's collection date as {@code yyyy-MM-dd}. + * + * Normalises both shapes the API can send — that string, or Fineract's {@code [yyyy, m, d]} array + * — rather than assuming one, so the filter cannot silently match nothing if the serialiser + * behind this endpoint changes. + */ + private collectionDateOf(receipt: any): string { + const raw = receipt?.collection_date ?? receipt?.collectionDate; + if (Array.isArray(raw) && raw.length >= 3) { + const [ + year, + month, + day + ] = raw; + return `${year}-${String(month).padStart(2, '0')}-${String(day).padStart(2, '0')}`; + } + return typeof raw === 'string' ? raw.slice(0, 10) : ''; + } + isSelected(id: number): boolean { return this.selectedIds.includes(id); } @@ -107,9 +143,13 @@ export class ApplyCollectionDialogComponent implements OnInit { } /** - * Resolves the voucher number the member quotes into a receipt. The lookup is not restricted to - * this member, so a voucher belonging to somebody else is surfaced here as a clear mismatch - * rather than failing later inside apply(). + * Resolves the voucher number the member quotes into a receipt and adds it to the list. + * + * The list is deliberately narrow — this member, today, unapplied — so the search is how a + * receipt outside that gets settled: one taken yesterday, or one the member holds a slip for that + * is quicker to type than to find. The lookup itself is not restricted to this member, so a + * voucher belonging to somebody else is surfaced here as a clear mismatch rather than failing + * later inside apply(). */ lookupVoucher(): void { const voucher = this.voucherNo.trim(); @@ -137,9 +177,9 @@ export class ApplyCollectionDialogComponent implements OnInit { this.errorMsg = `Voucher ${voucher} belongs to ${res.client_name}, not the holder of this share account.`; return; } - // Show it in the table (replacing any stale copy) and add it to the selection, rather than - // replacing what is already ticked — a looked-up voucher is usually the second half of a - // part-payment, not a change of mind about the first. + // Added to the table (replacing any stale copy) and ticked, unlike the listed receipts which + // are left for the clerk: typing a voucher number IS the deliberate choice of that receipt, + // so making them tick it again would be asking the same question twice. this.receipts = [ res, ...this.receipts.filter((r) => r.id !== res.id) @@ -150,6 +190,7 @@ export class ApplyCollectionDialogComponent implements OnInit { res.id ]; } + this.voucherNo = ''; }); } diff --git a/src/app/shares/shares-account-view/passbook-tab/passbook-tab.component.html b/src/app/shares/shares-account-view/passbook-tab/passbook-tab.component.html index c21ac09b1..17523631d 100644 --- a/src/app/shares/shares-account-view/passbook-tab/passbook-tab.component.html +++ b/src/app/shares/shares-account-view/passbook-tab/passbook-tab.component.html @@ -178,14 +178,16 @@ {{ txn.amount | currency: currency?.code : 'symbol-narrow' : '1.2-2' }} - @if (isRedeem(txn)) { - {{ txn.resolutionNumber || '—' }} + + @if (txn.resolutionNumber) { + {{ txn.resolutionNumber }} @if (txn.meetingDate) { ({{ txn.meetingDate | dateFormat }}) } } @else { - {{ resolutionNumber || '—' }} @if (resolutionDate) { ({{ resolutionDate | dateFormat }}) diff --git a/src/app/shares/shares-account-view/passbook-tab/passbook-tab.component.ts b/src/app/shares/shares-account-view/passbook-tab/passbook-tab.component.ts index f9cca00b8..da843b90a 100644 --- a/src/app/shares/shares-account-view/passbook-tab/passbook-tab.component.ts +++ b/src/app/shares/shares-account-view/passbook-tab/passbook-tab.component.ts @@ -14,6 +14,7 @@ import { CurrencyPipe } from '@angular/common'; import { MatIcon } from '@angular/material/icon'; import { DateFormatPipe } from '../../../pipes/date-format.pipe'; import { STANDALONE_SHARED_IMPORTS } from 'app/standalone-shared.module'; +import { isSettledShareTransaction } from 'app/shares/share-transactions.util'; /** * Share Account Passbook — a printable, chronological (oldest-first) ledger of every @@ -81,9 +82,7 @@ export class SharesPassbookTabComponent implements OnInit { this.totalApprovedShares = account.summary?.totalApprovedShares || 0; this.currentMarketPrice = account.currentMarketPrice || 0; - const transactions = (account.purchasedShares || []).filter( - (transaction: any) => transaction.type.value !== 'Charge Payment' - ); + const transactions = (account.purchasedShares || []).filter(isSettledShareTransaction); this.allTransactions = this.recalculateRunningBalances(this.sortAscending(transactions)); this.filteredTransactions = [...this.allTransactions]; }); diff --git a/src/app/shares/shares-account-view/share-certificate-dialog/share-certificate-dialog.component.html b/src/app/shares/shares-account-view/share-certificate-dialog/share-certificate-dialog.component.html index 790c375ad..130f27404 100644 --- a/src/app/shares/shares-account-view/share-certificate-dialog/share-certificate-dialog.component.html +++ b/src/app/shares/shares-account-view/share-certificate-dialog/share-certificate-dialog.component.html @@ -99,6 +99,48 @@ {{ cumulativeDividend | currency: sharesAccountData.currency.code : 'symbol-narrow' }} + + + + + @if (allotments.length > 0) { + + + + + + + + + + + + @for (row of allotments; track $index) { + + + + + + + + } + +
Date of AllotmentSharesFace ValueShare NumbersResolution No. / Meeting Date
{{ row.date | dateFormat }}{{ row.shares }} + {{ row.faceValue | currency: sharesAccountData.currency.code : 'symbol-narrow' }} + + {{ row.shareNumbersFrom.toLocaleString() }} – {{ row.shareNumbersTo.toLocaleString() }} + + {{ row.resolutionNumber || '—' }} + @if (row.meetingDate) { + ({{ row.meetingDate | dateFormat }}) + } +
+ } + + +
TOTAL FACE VALUE diff --git a/src/app/shares/shares-account-view/share-certificate-dialog/share-certificate-dialog.component.scss b/src/app/shares/shares-account-view/share-certificate-dialog/share-certificate-dialog.component.scss index ddfd4101c..97e7362ac 100644 --- a/src/app/shares/shares-account-view/share-certificate-dialog/share-certificate-dialog.component.scss +++ b/src/app/shares/shares-account-view/share-certificate-dialog/share-certificate-dialog.component.scss @@ -206,6 +206,13 @@ } /* Certificate Data Table */ + +/* The details grid and the allotment schedule read as one block, so the gap that used to sit under + the details table now sits under whichever table comes last. */ +.certificate-details-table:not(:last-of-type) { + margin-bottom: 0; +} + .certificate-details-table { width: 100%; border-collapse: collapse; @@ -250,6 +257,47 @@ } } +/* The allotment schedule — one row per block of shares allotted, in the same paper-and-ink palette + as the details grid so it reads as part of the certificate rather than a report pasted into it. */ +.allotment-table { + position: relative; + z-index: 2; /* Above the watermark, like the other certificate content. */ + width: 100%; + border-collapse: collapse; + margin-bottom: 40px; + + th { + padding: 8px 12px; + border: 1px solid #ede8db; + background-color: #f7f5ee; + font-size: 11px; + font-weight: bold; + color: #7b6f63; + text-transform: uppercase; + letter-spacing: 0.6px; + } + + td { + padding: 9px 12px; + border: 1px solid #ede8db; + font-size: 13px; + color: #322f2b; + } + + .allotment-meeting { + color: #7b6f63; + font-size: 12px; + } + + .ta-left { + text-align: left; + } + + .ta-right { + text-align: right; + } +} + /* Certificate Footer Seal / Signatures */ .cert-footer { display: flex; diff --git a/src/app/shares/shares-account-view/share-certificate-dialog/share-certificate-dialog.component.ts b/src/app/shares/shares-account-view/share-certificate-dialog/share-certificate-dialog.component.ts index 1f5ea5703..2b4c517b9 100644 --- a/src/app/shares/shares-account-view/share-certificate-dialog/share-certificate-dialog.component.ts +++ b/src/app/shares/shares-account-view/share-certificate-dialog/share-certificate-dialog.component.ts @@ -61,6 +61,27 @@ export class ShareCertificateDialogComponent implements OnInit { issueDate: Date = new Date(); todayDate: Date = new Date(); + /** + * One row per approved purchase, oldest first — the allotments this certificate covers. + * + * A member's holding is built up over time: the opening subscription, then each further tranche, + * every one authorised by its own board resolution on its own date. Collapsing that into a single + * total and one "Date of Issue" loses exactly what a share certificate exists to record — when + * each block of shares was allotted, under which resolution, and which share numbers it covers. + * The running range is what lets a member's 20 shares be read as 10 allotted in August and 10 in + * September rather than as one undated block. + */ + allotments: { + date: any; + shares: number; + unitPrice: number; + faceValue: number; + resolutionNumber: string; + meetingDate: any; + shareNumbersFrom: number; + shareNumbersTo: number; + }[] = []; + ngOnInit() { this.sharesAccountData = this.data.sharesAccountData; this.clientId = String(this.data.clientId); @@ -82,6 +103,18 @@ export class ShareCertificateDialogComponent implements OnInit { const endNum = startNum + approvedShares - 1; this.shareNumbersRange = approvedShares > 0 ? `${startNum.toLocaleString()} – ${endNum.toLocaleString()}` : 'None'; + this.buildAllotments(startNum); + + // The certificate is issued as of the most recent allotment, not the account's activation: a + // certificate reprinted after a further tranche covers those shares too, and dating it from + // activation would say the later shares were issued before they existed. + if (this.allotments.length > 0) { + const latest = this.allotments[this.allotments.length - 1]; + if (latest.date) { + this.issueDate = new Date(latest.date); + } + } + // Calculate cumulative dividends if (this.sharesAccountData.dividends) { this.cumulativeDividend = this.sharesAccountData.dividends.reduce( @@ -124,6 +157,48 @@ export class ShareCertificateDialogComponent implements OnInit { }); } + /** + * Builds the allotment rows from the account's approved purchases. + * + * Only approved purchases count. A pending tranche has not been allotted — the board has not + * approved it and, on the cash and savings routes alike, the money is still sitting in Share + * Sundry — so putting it on a certificate would certify shares the member does not yet hold. + * Rejected ones and charge payments are not allotments at all. + * + * Share numbers run consecutively in purchase order, so each row's range picks up where the last + * left off and the ranges together reconcile to the account's total holding. + * + * @param startNum the first share number of the account's holding. + */ + private buildAllotments(startNum: number): void { + const purchases = (this.sharesAccountData.purchasedShares || []) + .filter( + (txn: any) => txn?.type?.value === 'Purchase' && txn?.status?.code === 'purchasedSharesStatusType.approved' + ) + .slice() + .sort((a: any, b: any) => new Date(a.purchasedDate).getTime() - new Date(b.purchasedDate).getTime()); + + let nextShareNumber = startNum; + this.allotments = purchases.map((txn: any) => { + const shares = Number(txn.numberOfShares) || 0; + const unitPrice = Number(txn.purchasedPrice) || 0; + const from = nextShareNumber; + nextShareNumber += shares; + return { + date: txn.purchasedDate, + shares, + unitPrice, + faceValue: shares * unitPrice, + // The account's own resolution stands in for the opening subscription, which carries none of + // its own; each additional tranche has the resolution captured when it was applied for. + resolutionNumber: txn.resolutionNumber || this.sharesAccountData.resolutionNumber || '', + meetingDate: txn.meetingDate || this.sharesAccountData.resolutionDate || null, + shareNumbersFrom: from, + shareNumbersTo: from + shares - 1 + }; + }); + } + formatAddress(addr: any): string { if (!addr) return ''; return [ diff --git a/src/app/shares/shares-account-view/transactions-tab/transactions-tab.component.html b/src/app/shares/shares-account-view/transactions-tab/transactions-tab.component.html index cb819bf7e..8b133df93 100644 --- a/src/app/shares/shares-account-view/transactions-tab/transactions-tab.component.html +++ b/src/app/shares/shares-account-view/transactions-tab/transactions-tab.component.html @@ -62,14 +62,16 @@ Resolution No. / Meeting Date - @if (transaction.type.value === 'Redeem') { - {{ transaction.resolutionNumber || '—' }} + + @if (transaction.resolutionNumber) { + {{ transaction.resolutionNumber }} @if (transaction.meetingDate) { ({{ transaction.meetingDate | dateFormat }}) } } @else { - {{ shareAccountData.resolutionNumber || '—' }} @if (shareAccountData.resolutionDate) { ({{ shareAccountData.resolutionDate | dateFormat }}) diff --git a/src/app/shares/shares-account-view/transactions-tab/transactions-tab.component.ts b/src/app/shares/shares-account-view/transactions-tab/transactions-tab.component.ts index 46a7e3db0..3a6fb578d 100644 --- a/src/app/shares/shares-account-view/transactions-tab/transactions-tab.component.ts +++ b/src/app/shares/shares-account-view/transactions-tab/transactions-tab.component.ts @@ -24,6 +24,7 @@ import { import { ActivatedRoute } from '@angular/router'; import { DateFormatPipe } from '../../../pipes/date-format.pipe'; import { STANDALONE_SHARED_IMPORTS } from 'app/standalone-shared.module'; +import { isSettledShareTransaction } from 'app/shares/share-transactions.util'; /** * Transactions Tab Component. @@ -78,7 +79,7 @@ export class TransactionsTabComponent implements OnInit { this.route.parent.data.subscribe((data: { sharesAccountData: any }) => { this.shareAccountData = data.sharesAccountData; this.transactionsData = this.shareAccountData.purchasedShares - .filter((transaction: any) => transaction.type.value !== 'Charge Payment') + .filter(isSettledShareTransaction) .slice() .sort((a: any, b: any) => new Date(a.purchasedDate).getTime() - new Date(b.purchasedDate).getTime()); diff --git a/src/app/shares/shares.service.ts b/src/app/shares/shares.service.ts index 956093a20..0bf0f7d71 100644 --- a/src/app/shares/shares.service.ts +++ b/src/app/shares/shares.service.ts @@ -122,6 +122,21 @@ export class SharesService { return this.http.post(`/accounts/share/${accountId}`, data, { params: httpParams }); } + /** + * Whether a new additional-share tranche can be funded from the member's linked savings. + * + * Tranche-scoped, unlike `/sharesavingsdebit/eligibility`: that one values an account's first + * application by summing every purchase on the account, which for a further tranche would quote + * the member's whole holding history and call an already-settled account ineligible. + * + * @returns {Observable} `{ eligible, reason, requestedShares, unitPrice, amount, + * savingsAccountNo, savingsBalance }`. + */ + getAdditionalSharesSavingsEligibility(accountId: string, requestedShares: number): Observable { + const httpParams = new HttpParams().set('requestedShares', String(requestedShares)); + return this.http.get(`/shareaccounts/${accountId}/additionalshares/savings-eligibility`, { params: httpParams }); + } + /** * @param {string} accountId Shares Account Id. * @param {any} data Details edit payload. @@ -146,6 +161,19 @@ export class SharesService { return this.http.get(`/shareaccounts/edit/pending`); } + /** + * Share accounts submitted and awaiting a checker's approval. + * + * Its own endpoint rather than `/accounts/share`, which only lists ACTIVE accounts and returns the + * whole account graph per row; this returns just the member, product, requested shares and value a + * checker decides on. + * + * @returns {Observable} One row per pending account. + */ + getPendingShareAccounts(): Observable { + return this.http.get('/shareaccounts/pending'); + } + /** * @param {string} accountId Shares Account Id. * @returns {Observable} diff --git a/src/app/tasks/checker-inbox-and-tasks-tabs/pending-share-accounts/pending-share-accounts.component.html b/src/app/tasks/checker-inbox-and-tasks-tabs/pending-share-accounts/pending-share-accounts.component.html new file mode 100644 index 000000000..6e15d8f66 --- /dev/null +++ b/src/app/tasks/checker-inbox-and-tasks-tabs/pending-share-accounts/pending-share-accounts.component.html @@ -0,0 +1,107 @@ + + +
+ @if (showData) { +
+ + search + + +
+ +
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
Account No.{{ account.accountNo }}Member + {{ account.clientName }} + @if (account.officeName) { +
{{ account.officeName }}
+ } +
Product{{ account.productName }}Shares{{ account.requestedShares }}Amount + {{ account.requestedAmount | number: '1.2-2' }} + Resolution + {{ account.resolutionNumber || '—' }} + @if (account.resolutionDate) { +
{{ formatDate(account.resolutionDate) }}
+ } +
Submitted + {{ formatDate(account.submittedDate) }} + @if (account.submittedByUsername) { +
by {{ account.submittedByUsername }}
+ } +
Actions + + + +
+
+ } + + @if (!showData) { +
+ +

No pending share accounts

+

There are no share accounts awaiting approval at this time.

+
+ } +
diff --git a/src/app/tasks/checker-inbox-and-tasks-tabs/pending-share-accounts/pending-share-accounts.component.scss b/src/app/tasks/checker-inbox-and-tasks-tabs/pending-share-accounts/pending-share-accounts.component.scss new file mode 100644 index 000000000..3e8e2c212 --- /dev/null +++ b/src/app/tasks/checker-inbox-and-tasks-tabs/pending-share-accounts/pending-share-accounts.component.scss @@ -0,0 +1,128 @@ +/** + * Copyright since 2025 Mifos Initiative + * + * This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. + */ + +$border: #e2e8f0; +$text-muted: #64748b; + +.psa-tab-content { + padding: 16px 0; +} + +.psa-action-bar { + display: flex; + align-items: center; + gap: 8px; + margin-bottom: 16px; + flex-wrap: wrap; +} + +.psa-search-field { + flex: 1; + min-width: 200px; + max-width: 340px; +} + +.psa-btn { + font-size: 11.5px; + font-weight: 600; + border-radius: 8px !important; + height: 32px; + padding: 0 10px; + margin-right: 6px; + + fa-icon { + margin-right: 4px; + font-size: 11px; + } +} + +.psa-btn-view { + border-color: #2563eb; + color: #2563eb; + + &:hover { + background: #eff6ff; + } +} + +.psa-btn-approve { + border-color: #16a34a; + color: #16a34a; + + &:hover { + background: #f0fdf4; + } +} + +.psa-btn-reject { + border-color: #dc2626; + color: #dc2626; + + &:hover { + background: #fef2f2; + } +} + +.psa-mono { + font-family: 'Courier New', monospace; +} + +/* Tabular figures so share counts and amounts line up digit for digit down their columns — a queue + is scanned, and two subscriptions of very different sizes should look different at a glance. */ +.psa-num { + font-variant-numeric: tabular-nums; + font-weight: 500; + white-space: nowrap; +} + +/* The second line in a cell — branch under the member, meeting date under the resolution number, + who submitted it under the date. Kept with the value it qualifies rather than given a column of + its own, which would make an already wide table wider for something read together anyway. */ +.psa-sub { + font-size: 11px; + color: $text-muted; + margin-top: 2px; +} + +.ta-right { + text-align: right; +} + +.empty-state { + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + padding: 60px 24px; + background: #fff; + border: 1px dashed $border; + border-radius: 14px; + text-align: center; + margin: 16px 0; + + .empty-icon { + font-size: 42px; + color: #cbd5e1; + margin-bottom: 14px; + } + + .empty-title { + margin: 0 0 4px; + font-size: 15px; + font-weight: 700; + color: #0d1b2a; + } + + .empty-text { + margin: 0; + font-size: 12.5px; + color: $text-muted; + max-width: 380px; + line-height: 1.5; + } +} diff --git a/src/app/tasks/checker-inbox-and-tasks-tabs/pending-share-accounts/pending-share-accounts.component.ts b/src/app/tasks/checker-inbox-and-tasks-tabs/pending-share-accounts/pending-share-accounts.component.ts new file mode 100644 index 000000000..03cf94472 --- /dev/null +++ b/src/app/tasks/checker-inbox-and-tasks-tabs/pending-share-accounts/pending-share-accounts.component.ts @@ -0,0 +1,192 @@ +/** + * Copyright since 2025 Mifos Initiative + * + * This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. + */ + +import { Component, inject } from '@angular/core'; +import { ActivatedRoute, Router } from '@angular/router'; +import { DecimalPipe } from '@angular/common'; +import { + MatTableDataSource, + MatTable, + MatColumnDef, + MatHeaderCellDef, + MatHeaderCell, + MatCellDef, + MatCell, + MatHeaderRowDef, + MatHeaderRow, + MatRowDef, + MatRow +} from '@angular/material/table'; +import { MatDialog } from '@angular/material/dialog'; +import { MatIcon } from '@angular/material/icon'; +import { FaIconComponent } from '@fortawesome/angular-fontawesome'; + +import { SharesService } from 'app/shares/shares.service'; +import { SettingsService } from 'app/settings/settings.service'; +import { Dates } from 'app/core/utils/dates'; +import { ConfirmationDialogComponent } from 'app/shared/confirmation-dialog/confirmation-dialog.component'; +import { FormDialogComponent } from 'app/shared/form-dialog/form-dialog.component'; +import { FormfieldBase } from 'app/shared/form-dialog/formfield/model/formfield-base'; +import { InputBase } from 'app/shared/form-dialog/formfield/model/input-base'; +import { STANDALONE_SHARED_IMPORTS } from 'app/standalone-shared.module'; + +/** + * Checker-facing list of share accounts submitted and awaiting approval. + * + * Mirrors PendingShareAccountEditsComponent's list + approve/reject pattern, but for whole accounts + * rather than edits to one. Shows what the decision turns on — the member, the product, how many + * shares they have subscribed for and what that comes to, and the board resolution behind it — so a + * checker can act from the list instead of opening every account to find out what it is. + */ +@Component({ + selector: 'mifosx-pending-share-accounts', + templateUrl: './pending-share-accounts.component.html', + styleUrls: ['./pending-share-accounts.component.scss'], + imports: [ + ...STANDALONE_SHARED_IMPORTS, + FaIconComponent, + MatTable, + MatColumnDef, + MatHeaderCellDef, + MatHeaderCell, + MatCellDef, + MatCell, + MatHeaderRowDef, + MatHeaderRow, + MatRowDef, + MatRow, + MatIcon, + DecimalPipe + ] +}) +export class PendingShareAccountsComponent { + private route = inject(ActivatedRoute); + private router = inject(Router); + private dialog = inject(MatDialog); + private sharesService = inject(SharesService); + private settingsService = inject(SettingsService); + private dateUtils = inject(Dates); + + dataSource: MatTableDataSource; + showData = false; + + displayedColumns: string[] = [ + 'accountNo', + 'clientName', + 'productName', + 'requestedShares', + 'requestedAmount', + 'resolution', + 'submittedDate', + 'actions' + ]; + + constructor() { + this.route.data.subscribe((data: { pendingShareAccountsData: any }) => { + const items = data.pendingShareAccountsData || []; + this.showData = items.length > 0; + this.dataSource = new MatTableDataSource(items); + }); + } + + applyFilter(filterValue: string = '') { + this.dataSource.filter = filterValue.trim().toLowerCase(); + } + + /** + * A date from the API rendered readably, whether it arrives as a string or as Fineract's + * {@code [yyyy, m, d]} array. + */ + formatDate(date: any): string { + if (!date) { + return ''; + } + const d = Array.isArray(date) ? new Date(date[0], date[1] - 1, date[2]) : new Date(date); + return isNaN(d.getTime()) ? String(date) : d.toLocaleDateString('en-IN'); + } + + /** Opens the account itself, for a checker who wants more than the row shows. */ + viewAccount(account: any): void { + this.router.navigate([ + '/clients', + account.clientId, + 'shares-accounts', + account.shareAccountId + ]); + } + + approve(account: any): void { + const dialogRef = this.dialog.open(ConfirmationDialogComponent, { + data: { + heading: 'Approve Share Account', + dialogContext: + `Approve share account "${account.accountNo}" for ${account.clientName} — ` + + `${account.requestedShares} shares at ${account.requestedAmount}?`, + type: 'basic' + } + }); + dialogRef.afterClosed().subscribe((response: any) => { + if (response?.confirm) { + // The approval date is the business date rather than the checker's choice: this queue is for + // clearing a submission, and a date field per row would be four extra clicks to type what + // the system already knows. + const data = { + approvedDate: this.dateUtils.formatDate(this.settingsService.businessDate, this.settingsService.dateFormat), + dateFormat: this.settingsService.dateFormat, + locale: this.settingsService.language.code + }; + this.sharesService + .executeSharesAccountCommand(account.shareAccountId, 'approve', data) + .subscribe(() => this.reload()); + } + }); + } + + reject(account: any): void { + const formfields: FormfieldBase[] = [ + new InputBase({ + controlName: 'rejectReason', + label: 'Reason for rejection', + value: '', + type: 'text', + required: false + }) + ]; + const dialogRef = this.dialog.open(FormDialogComponent, { + data: { + title: `Reject Share Account ${account.accountNo}`, + formfields + } + }); + dialogRef.afterClosed().subscribe((response: any) => { + if (response?.data) { + // The reason is recorded as a note rather than dropped: a rejected subscription is the one a + // member is most likely to come back and ask about. + const data: any = { + rejectedDate: this.dateUtils.formatDate(this.settingsService.businessDate, this.settingsService.dateFormat), + dateFormat: this.settingsService.dateFormat, + locale: this.settingsService.language.code + }; + const reason = response.data.value?.rejectReason?.trim(); + if (reason) { + data.note = reason; + } + this.sharesService + .executeSharesAccountCommand(account.shareAccountId, 'reject', data) + .subscribe(() => this.reload()); + } + }); + } + + reload(): void { + const url = this.router.url; + this.router + .navigateByUrl('/checker-inbox-and-tasks', { skipLocationChange: true }) + .then(() => this.router.navigate([url])); + } +} 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 ffcc701fa..8e5a2f0af 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 @@ -90,6 +90,17 @@ Pending Account Edits + + + + Pending Share Accounts + { + private sharesService = inject(SharesService); + + resolve(): Observable { + return this.sharesService.getPendingShareAccounts().pipe(catchError(() => of([]))); + } +} diff --git a/src/app/tasks/tasks-routing.module.ts b/src/app/tasks/tasks-routing.module.ts index 416cf3017..7b8701aa4 100644 --- a/src/app/tasks/tasks-routing.module.ts +++ b/src/app/tasks/tasks-routing.module.ts @@ -33,6 +33,7 @@ import { PendingSavingsProductsComponent } from './checker-inbox-and-tasks-tabs/ import { ChequebookRequestsComponent } from './checker-inbox-and-tasks-tabs/chequebook-requests/chequebook-requests.component'; import { PendingSavingsAccountEditsComponent } from './checker-inbox-and-tasks-tabs/pending-savings-account-edits/pending-savings-account-edits.component'; import { PendingShareAccountEditsComponent } from './checker-inbox-and-tasks-tabs/pending-share-account-edits/pending-share-account-edits.component'; +import { PendingShareAccountsComponent } from './checker-inbox-and-tasks-tabs/pending-share-accounts/pending-share-accounts.component'; import { PendingClientEditsComponent } from './checker-inbox-and-tasks-tabs/pending-client-edits/pending-client-edits.component'; import { ViewCheckerInboxComponent } from './view-checker-inbox/view-checker-inbox.component'; @@ -51,6 +52,7 @@ import { GetPendingSavingsProducts } from './common-resolvers/getPendingSavingsP import { GetPendingSavingsAccountEdits } from './common-resolvers/getPendingSavingsAccountEdits.resolver'; import { GetPendingCurrentAccountEdits } from './common-resolvers/getPendingCurrentAccountEdits.resolver'; import { GetPendingShareAccountEdits } from './common-resolvers/getPendingShareAccountEdits.resolver'; +import { GetPendingShareAccounts } from './common-resolvers/getPendingShareAccounts.resolver'; /** Tasks Routes */ const routes: Routes = [ @@ -144,6 +146,14 @@ const routes: Routes = [ path: 'pending-current-account-edits', redirectTo: 'pending-savings-account-edits' }, + { + path: 'pending-share-accounts', + component: PendingShareAccountsComponent, + data: { title: 'Pending Share Accounts' }, + resolve: { + pendingShareAccountsData: GetPendingShareAccounts + } + }, { path: 'pending-share-account-edits', component: PendingShareAccountEditsComponent, @@ -264,7 +274,8 @@ const routes: Routes = [ GetPendingSavingsProducts, GetPendingSavingsAccountEdits, GetPendingCurrentAccountEdits, - GetPendingShareAccountEdits + GetPendingShareAccountEdits, + GetPendingShareAccounts ] }) export class TasksRoutingModule {} diff --git a/src/assets/translations/en-US.json b/src/assets/translations/en-US.json index 46110091b..9160611c2 100644 --- a/src/assets/translations/en-US.json +++ b/src/assets/translations/en-US.json @@ -2598,6 +2598,7 @@ "Payment Allocation Rule": "Payment Allocation Rule", "Payment Details": "Payment Details", "Payment Due At": "Payment Due At", + "Payment Option": "Payment Option", "Payment Type": "Payment Type", "Payment Type Id": "Payment Type Id", "Payment due as of": "Payment due as of", diff --git a/src/environments/.env.ts b/src/environments/.env.ts index 6c6244804..3e9cc6755 100644 --- a/src/environments/.env.ts +++ b/src/environments/.env.ts @@ -9,8 +9,8 @@ /* tslint:disable */ export default { 'mifos_x': { - 'version': '260825', - 'hash': '7ef75c90' + 'version': '260827', + 'hash': '1273bdbe' }, 'allow_switching_backend_instance': true }; From 0d145c72a6801d1e949223084269ea3404c3b873 Mon Sep 17 00:00:00 2001 From: Sonali Likhitkar Date: Thu, 27 Aug 2026 17:38:06 +0530 Subject: [PATCH 10/64] printing modification are done --- .../passbook-tab/passbook-tab.component.ts | 300 ++++++++++++++++-- .../passbook-tab/passbook-tab.component.ts | 300 ++++++++++++++++-- .../passbook-tab/passbook-tab.component.ts | 280 +++++++++++++++- 3 files changed, 812 insertions(+), 68 deletions(-) diff --git a/src/app/current-accounts/current-account-view/passbook-tab/passbook-tab.component.ts b/src/app/current-accounts/current-account-view/passbook-tab/passbook-tab.component.ts index 71517bdfd..697d53a5c 100644 --- a/src/app/current-accounts/current-account-view/passbook-tab/passbook-tab.component.ts +++ b/src/app/current-accounts/current-account-view/passbook-tab/passbook-tab.component.ts @@ -152,46 +152,294 @@ export class PassbookTabComponent implements OnInit, OnDestroy { } printPassbook(): void { - const content = this.passbookContent?.nativeElement?.innerHTML; - if (!content) return; + const source = this.passbookContent?.nativeElement as HTMLElement; + if (!source) return; + + /** Work on a copy so the on-screen statement is untouched */ + const clone = source.cloneNode(true) as HTMLElement; + clone.querySelectorAll('.no-print').forEach((el: Element) => el.remove()); + + /** Material icon fonts are not available in the popup, so swap them for inline SVG */ + clone.querySelectorAll('mat-icon, .mat-icon').forEach((el: Element) => { + const insideLogo = !!el.closest('.bank-logo-circle'); + if (insideLogo) { + el.outerHTML = PassbookTabComponent.BANK_LOGO_SVG; + } else { + el.remove(); + } + }); + + const printWindow = window.open('', 'Passbook', 'height=800,width=1100'); + if (!printWindow) return; - const printWindow = window.open('', 'Passbook', 'height=700,width=900'); printWindow.document.write(` + Current Account Passbook - ${this.accountNo} - + - ${content} +
${clone.outerHTML}
+ `); printWindow.document.close(); printWindow.focus(); - printWindow.print(); - printWindow.close(); } + /** Material "account_balance" glyph, inlined so the print window needs no icon font */ + private static readonly BANK_LOGO_SVG = ` + `; + + /** + * Mirrors passbook-tab.component.scss so the printed statement looks like the + * on-screen one. Kept in sync manually - component styles are scoped and are + * not reachable from the popup window. + */ + private static readonly PRINT_STYLES = ` + @page { size: A4 portrait; margin: 10mm; } + + * { margin: 0; padding: 0; box-sizing: border-box; -webkit-print-color-adjust: exact; print-color-adjust: exact; } + + body { + font-family: 'Segoe UI', Arial, sans-serif; + color: #333; + background: #fff; + } + + .statement-print-page { padding: 0; } + + .statement-wrapper { + background: #fff; + border: 1px solid #d0d7e3; + border-radius: 6px; + overflow: hidden; + } + + /* Bank header */ + .bank-header { + background: #1a3a6b; + padding: 14px 20px; + display: flex; + justify-content: space-between; + align-items: center; + border-bottom: 4px solid #c8a951; + } + + .bank-logo-area { display: flex; align-items: center; gap: 12px; } + + .bank-logo-circle { + width: 44px; + height: 44px; + border-radius: 50%; + background: #c8a951; + display: flex; + align-items: center; + justify-content: center; + } + + .bank-logo-svg { fill: #1a3a6b; display: block; } + + .bank-name-block .bank-title { + color: #fff; + font-size: 16px; + font-weight: 700; + letter-spacing: 0.3px; + } + + .bank-name-block .bank-subtitle { + color: #c8a951; + font-size: 10px; + font-weight: 500; + letter-spacing: 1.5px; + text-transform: uppercase; + margin-top: 2px; + } + + .statement-label { text-align: right; } + + .statement-label .stmt-label-text { + color: #c8a951; + font-size: 12px; + font-weight: 700; + letter-spacing: 2px; + text-transform: uppercase; + } + + .statement-label .stmt-period { + color: rgba(255, 255, 255, 0.75); + font-size: 10px; + margin-top: 3px; + } + + /* Account details band */ + .account-details-band { + background: #f0f3f9; + border-bottom: 1px solid #d0d7e3; + padding: 12px 20px; + } + + .account-details-grid { + display: grid; + grid-template-columns: repeat(3, 1fr); + gap: 10px 24px; + } + + .detail-block .detail-label { + font-size: 8.5px; + font-weight: 700; + color: #7a8aaa; + text-transform: uppercase; + letter-spacing: 0.8px; + margin-bottom: 2px; + } + + .detail-block .detail-value { font-size: 12px; color: #222; } + .detail-block .detail-value.bold { font-weight: 600; } + .detail-block .detail-value.mono { font-family: 'Courier New', monospace; letter-spacing: 1px; } + + .detail-block .detail-value.status-badge { + display: inline-block; + padding: 1px 8px; + border-radius: 10px; + font-size: 10px; + font-weight: 600; + background: #eee; + color: #555; + } + + .detail-block .detail-value.status-badge.active { background: #d4edda; color: #155724; } + + /* Balance summary strip */ + .balance-summary-strip { + display: flex; + align-items: stretch; + background: #1a3a6b; + } + + .balance-box { flex: 1; padding: 10px 18px; text-align: center; } + + .balance-box .balance-box-label { + font-size: 9px; + font-weight: 600; + color: rgba(255, 255, 255, 0.65); + text-transform: uppercase; + letter-spacing: 1px; + margin-bottom: 3px; + } + + .balance-box .balance-box-value { font-size: 15px; font-weight: 700; color: #c8a951; } + + .balance-divider { width: 1px; background: rgba(255, 255, 255, 0.15); margin: 6px 0; } + + /* Transactions table */ + .statement-table { width: 100%; border-collapse: collapse; font-size: 11px; } + + .statement-table thead { display: table-header-group; } + .statement-table tfoot { display: table-row-group; } + .statement-table tr { page-break-inside: avoid; break-inside: avoid; } + + .statement-table thead tr { + background: #e8edf5; + border-top: 1px solid #d0d7e3; + border-bottom: 2px solid #1a3a6b; + } + + .statement-table thead th { + padding: 8px 9px; + font-size: 10px; + font-weight: 700; + color: #1a3a6b; + text-transform: uppercase; + letter-spacing: 0.4px; + white-space: nowrap; + text-align: left; + } + + .statement-table tbody tr { border-bottom: 1px solid #e8ecf3; } + .statement-table tbody tr:nth-child(even) { background: #f4f7fb; } + .statement-table tbody tr.reversed-row { opacity: 0.55; text-decoration: line-through; } + .statement-table tbody td { padding: 6px 9px; vertical-align: middle; color: #333; } + + .statement-table tfoot .total-row { background: #e8edf5; border-top: 2px solid #1a3a6b; } + .statement-table tfoot .total-row td { padding: 8px 9px; } + + /* Column widths */ + .col-sr { width: 34px; } + .col-date { width: 100px; white-space: nowrap; } + .col-desc { min-width: 150px; } + .col-ref { width: 74px; } + .col-amount { width: 100px; } + .col-balance { width: 110px; } + + /* Amount colors */ + .debit-amount { color: #c0392b; font-weight: 500; } + .credit-amount { color: #1a7a3f; font-weight: 500; } + .balance-amount { color: #1a3a6b; font-weight: 600; font-family: 'Courier New', monospace; } + + .total-label { + font-size: 11px; + font-weight: 700; + color: #1a3a6b; + text-transform: uppercase; + letter-spacing: 0.5px; + } + + .closing-balance { + font-size: 13px; + font-weight: 700; + color: #1a3a6b; + font-family: 'Courier New', monospace; + } + + /* Badges & misc */ + .txn-desc { font-weight: 500; } + + .reversed-badge { + display: inline-block; + margin-left: 6px; + font-size: 8px; + font-weight: 700; + color: #fff; + background: #e74c3c; + padding: 1px 5px; + border-radius: 3px; + letter-spacing: 0.5px; + } + + .text-right { text-align: right; } + .text-center { text-align: center; } + .text-muted { color: #999; } + .mono { font-family: 'Courier New', monospace; font-size: 10px; } + + /* No data */ + .no-data-block { padding: 40px; text-align: center; color: #aaa; font-size: 13px; } + + /* Footer */ + .statement-footer { + background: #f0f3f9; + border-top: 1px solid #d0d7e3; + padding: 10px 20px; + text-align: center; + } + + .statement-footer .footer-note { + font-size: 9px; + color: #888; + margin-bottom: 3px; + font-style: italic; + } + + .statement-footer .footer-meta { font-size: 9px; color: #aaa; letter-spacing: 0.3px; } + `; + ngOnDestroy(): void { this.destroy$.next(); this.destroy$.complete(); diff --git a/src/app/savings/savings-account-view/passbook-tab/passbook-tab.component.ts b/src/app/savings/savings-account-view/passbook-tab/passbook-tab.component.ts index 6be1eca86..be2127a28 100644 --- a/src/app/savings/savings-account-view/passbook-tab/passbook-tab.component.ts +++ b/src/app/savings/savings-account-view/passbook-tab/passbook-tab.component.ts @@ -142,46 +142,294 @@ export class PassbookTabComponent implements OnInit, OnDestroy { } printPassbook(): void { - const content = this.passbookContent?.nativeElement?.innerHTML; - if (!content) return; + const source = this.passbookContent?.nativeElement as HTMLElement; + if (!source) return; + + /** Work on a copy so the on-screen statement is untouched */ + const clone = source.cloneNode(true) as HTMLElement; + clone.querySelectorAll('.no-print').forEach((el: Element) => el.remove()); + + /** Material icon fonts are not available in the popup, so swap them for inline SVG */ + clone.querySelectorAll('mat-icon, .mat-icon').forEach((el: Element) => { + const insideLogo = !!el.closest('.bank-logo-circle'); + if (insideLogo) { + el.outerHTML = PassbookTabComponent.BANK_LOGO_SVG; + } else { + el.remove(); + } + }); + + const printWindow = window.open('', 'Passbook', 'height=800,width=1100'); + if (!printWindow) return; - const printWindow = window.open('', 'Passbook', 'height=700,width=900'); printWindow.document.write(` + Savings Account Passbook - ${this.accountNo} - + - ${content} +
${clone.outerHTML}
+ `); printWindow.document.close(); printWindow.focus(); - printWindow.print(); - printWindow.close(); } + /** Material "account_balance" glyph, inlined so the print window needs no icon font */ + private static readonly BANK_LOGO_SVG = ` + `; + + /** + * Mirrors passbook-tab.component.scss so the printed statement looks like the + * on-screen one. Kept in sync manually - component styles are scoped and are + * not reachable from the popup window. + */ + private static readonly PRINT_STYLES = ` + @page { size: A4 portrait; margin: 10mm; } + + * { margin: 0; padding: 0; box-sizing: border-box; -webkit-print-color-adjust: exact; print-color-adjust: exact; } + + body { + font-family: 'Segoe UI', Arial, sans-serif; + color: #333; + background: #fff; + } + + .statement-print-page { padding: 0; } + + .statement-wrapper { + background: #fff; + border: 1px solid #d0d7e3; + border-radius: 6px; + overflow: hidden; + } + + /* Bank header */ + .bank-header { + background: #1a3a6b; + padding: 14px 20px; + display: flex; + justify-content: space-between; + align-items: center; + border-bottom: 4px solid #c8a951; + } + + .bank-logo-area { display: flex; align-items: center; gap: 12px; } + + .bank-logo-circle { + width: 44px; + height: 44px; + border-radius: 50%; + background: #c8a951; + display: flex; + align-items: center; + justify-content: center; + } + + .bank-logo-svg { fill: #1a3a6b; display: block; } + + .bank-name-block .bank-title { + color: #fff; + font-size: 16px; + font-weight: 700; + letter-spacing: 0.3px; + } + + .bank-name-block .bank-subtitle { + color: #c8a951; + font-size: 10px; + font-weight: 500; + letter-spacing: 1.5px; + text-transform: uppercase; + margin-top: 2px; + } + + .statement-label { text-align: right; } + + .statement-label .stmt-label-text { + color: #c8a951; + font-size: 12px; + font-weight: 700; + letter-spacing: 2px; + text-transform: uppercase; + } + + .statement-label .stmt-period { + color: rgba(255, 255, 255, 0.75); + font-size: 10px; + margin-top: 3px; + } + + /* Account details band */ + .account-details-band { + background: #f0f3f9; + border-bottom: 1px solid #d0d7e3; + padding: 12px 20px; + } + + .account-details-grid { + display: grid; + grid-template-columns: repeat(3, 1fr); + gap: 10px 24px; + } + + .detail-block .detail-label { + font-size: 8.5px; + font-weight: 700; + color: #7a8aaa; + text-transform: uppercase; + letter-spacing: 0.8px; + margin-bottom: 2px; + } + + .detail-block .detail-value { font-size: 12px; color: #222; } + .detail-block .detail-value.bold { font-weight: 600; } + .detail-block .detail-value.mono { font-family: 'Courier New', monospace; letter-spacing: 1px; } + + .detail-block .detail-value.status-badge { + display: inline-block; + padding: 1px 8px; + border-radius: 10px; + font-size: 10px; + font-weight: 600; + background: #eee; + color: #555; + } + + .detail-block .detail-value.status-badge.active { background: #d4edda; color: #155724; } + + /* Balance summary strip */ + .balance-summary-strip { + display: flex; + align-items: stretch; + background: #1a3a6b; + } + + .balance-box { flex: 1; padding: 10px 18px; text-align: center; } + + .balance-box .balance-box-label { + font-size: 9px; + font-weight: 600; + color: rgba(255, 255, 255, 0.65); + text-transform: uppercase; + letter-spacing: 1px; + margin-bottom: 3px; + } + + .balance-box .balance-box-value { font-size: 15px; font-weight: 700; color: #c8a951; } + + .balance-divider { width: 1px; background: rgba(255, 255, 255, 0.15); margin: 6px 0; } + + /* Transactions table */ + .statement-table { width: 100%; border-collapse: collapse; font-size: 11px; } + + .statement-table thead { display: table-header-group; } + .statement-table tfoot { display: table-row-group; } + .statement-table tr { page-break-inside: avoid; break-inside: avoid; } + + .statement-table thead tr { + background: #e8edf5; + border-top: 1px solid #d0d7e3; + border-bottom: 2px solid #1a3a6b; + } + + .statement-table thead th { + padding: 8px 9px; + font-size: 10px; + font-weight: 700; + color: #1a3a6b; + text-transform: uppercase; + letter-spacing: 0.4px; + white-space: nowrap; + text-align: left; + } + + .statement-table tbody tr { border-bottom: 1px solid #e8ecf3; } + .statement-table tbody tr:nth-child(even) { background: #f4f7fb; } + .statement-table tbody tr.reversed-row { opacity: 0.55; text-decoration: line-through; } + .statement-table tbody td { padding: 6px 9px; vertical-align: middle; color: #333; } + + .statement-table tfoot .total-row { background: #e8edf5; border-top: 2px solid #1a3a6b; } + .statement-table tfoot .total-row td { padding: 8px 9px; } + + /* Column widths */ + .col-sr { width: 34px; } + .col-date { width: 100px; white-space: nowrap; } + .col-desc { min-width: 150px; } + .col-ref { width: 74px; } + .col-amount { width: 100px; } + .col-balance { width: 110px; } + + /* Amount colors */ + .debit-amount { color: #c0392b; font-weight: 500; } + .credit-amount { color: #1a7a3f; font-weight: 500; } + .balance-amount { color: #1a3a6b; font-weight: 600; font-family: 'Courier New', monospace; } + + .total-label { + font-size: 11px; + font-weight: 700; + color: #1a3a6b; + text-transform: uppercase; + letter-spacing: 0.5px; + } + + .closing-balance { + font-size: 13px; + font-weight: 700; + color: #1a3a6b; + font-family: 'Courier New', monospace; + } + + /* Badges & misc */ + .txn-desc { font-weight: 500; } + + .reversed-badge { + display: inline-block; + margin-left: 6px; + font-size: 8px; + font-weight: 700; + color: #fff; + background: #e74c3c; + padding: 1px 5px; + border-radius: 3px; + letter-spacing: 0.5px; + } + + .text-right { text-align: right; } + .text-center { text-align: center; } + .text-muted { color: #999; } + .mono { font-family: 'Courier New', monospace; font-size: 10px; } + + /* No data */ + .no-data-block { padding: 40px; text-align: center; color: #aaa; font-size: 13px; } + + /* Footer */ + .statement-footer { + background: #f0f3f9; + border-top: 1px solid #d0d7e3; + padding: 10px 20px; + text-align: center; + } + + .statement-footer .footer-note { + font-size: 9px; + color: #888; + margin-bottom: 3px; + font-style: italic; + } + + .statement-footer .footer-meta { font-size: 9px; color: #aaa; letter-spacing: 0.3px; } + `; + private recalculateRunningBalances(transactions: SavingsAccountTransaction[]): SavingsAccountTransaction[] { let balance = 0; return transactions.map((txn) => { diff --git a/src/app/shares/shares-account-view/passbook-tab/passbook-tab.component.ts b/src/app/shares/shares-account-view/passbook-tab/passbook-tab.component.ts index da843b90a..e2aa7545e 100644 --- a/src/app/shares/shares-account-view/passbook-tab/passbook-tab.component.ts +++ b/src/app/shares/shares-account-view/passbook-tab/passbook-tab.component.ts @@ -133,36 +133,284 @@ export class SharesPassbookTabComponent implements OnInit { } printPassbook(): void { - const content = this.passbookContent?.nativeElement?.innerHTML; - if (!content) return; + const source = this.passbookContent?.nativeElement as HTMLElement; + if (!source) return; + + /** Work on a copy so the on-screen passbook is untouched */ + const clone = source.cloneNode(true) as HTMLElement; + clone.querySelectorAll('.no-print').forEach((el: Element) => el.remove()); + + /** Material icon fonts are not available in the popup, so swap them for inline SVG */ + clone.querySelectorAll('mat-icon, .mat-icon').forEach((el: Element) => { + const insideLogo = !!el.closest('.bank-logo-circle'); + if (insideLogo) { + el.outerHTML = SharesPassbookTabComponent.BANK_LOGO_SVG; + } else { + el.remove(); + } + }); + + const printWindow = window.open('', 'SharePassbook', 'height=800,width=1200'); + if (!printWindow) return; - const printWindow = window.open('', 'SharePassbook', 'height=700,width=900'); printWindow.document.write(` + Share Account Passbook - ${this.accountNo} - + - ${content} +
${clone.outerHTML}
+ `); printWindow.document.close(); printWindow.focus(); - printWindow.print(); - printWindow.close(); } + /** Material "account_balance" glyph, inlined so the print window needs no icon font */ + private static readonly BANK_LOGO_SVG = ` + `; + + /** + * Mirrors passbook-tab.component.scss so the printed passbook looks like the + * on-screen one. Kept in sync manually - component styles are scoped and are + * not reachable from the popup window. Landscape, because the share ledger + * carries nine columns including the Resolution No. / Meeting column. + */ + private static readonly PRINT_STYLES = ` + @page { size: A4 landscape; margin: 8mm; } + + * { margin: 0; padding: 0; box-sizing: border-box; -webkit-print-color-adjust: exact; print-color-adjust: exact; } + + body { + font-family: 'Segoe UI', Arial, sans-serif; + color: #333; + background: #fff; + } + + .statement-print-page { padding: 0; } + + .statement-wrapper { + background: #fff; + border: 1px solid #d0d7e3; + border-radius: 6px; + overflow: hidden; + } + + /* Bank header */ + .bank-header { + background: #1a3a6b; + padding: 14px 20px; + display: flex; + justify-content: space-between; + align-items: center; + border-bottom: 4px solid #c8a951; + } + + .bank-logo-area { display: flex; align-items: center; gap: 12px; } + + .bank-logo-circle { + width: 44px; + height: 44px; + border-radius: 50%; + background: #c8a951; + display: flex; + align-items: center; + justify-content: center; + } + + .bank-logo-svg { fill: #1a3a6b; display: block; } + + .bank-name-block .bank-title { + color: #fff; + font-size: 16px; + font-weight: 700; + letter-spacing: 0.3px; + } + + .bank-name-block .bank-subtitle { + color: #c8a951; + font-size: 10px; + font-weight: 500; + letter-spacing: 1.5px; + text-transform: uppercase; + margin-top: 2px; + } + + .statement-label { text-align: right; } + + .statement-label .stmt-label-text { + color: #c8a951; + font-size: 12px; + font-weight: 700; + letter-spacing: 2px; + text-transform: uppercase; + } + + .statement-label .stmt-period { + color: rgba(255, 255, 255, 0.75); + font-size: 10px; + margin-top: 3px; + } + + /* Account details band */ + .account-details-band { + background: #f0f3f9; + border-bottom: 1px solid #d0d7e3; + padding: 12px 20px; + } + + .account-details-grid { + display: grid; + grid-template-columns: repeat(4, 1fr); + gap: 10px 24px; + } + + .detail-block .detail-label { + font-size: 8.5px; + font-weight: 700; + color: #7a8aaa; + text-transform: uppercase; + letter-spacing: 0.8px; + margin-bottom: 2px; + } + + .detail-block .detail-value { font-size: 12px; color: #222; } + .detail-block .detail-value.bold { font-weight: 600; } + .detail-block .detail-value.mono { font-family: 'Courier New', monospace; letter-spacing: 1px; } + + .detail-block .detail-value.status-badge { + display: inline-block; + padding: 1px 8px; + border-radius: 10px; + font-size: 10px; + font-weight: 600; + background: #eee; + color: #555; + } + + .detail-block .detail-value.status-badge.active { background: #d4edda; color: #155724; } + + /* Share summary strip */ + .balance-summary-strip { + display: flex; + align-items: stretch; + background: #1a3a6b; + } + + .balance-box { flex: 1; padding: 10px 18px; text-align: center; } + + .balance-box .balance-box-label { + font-size: 9px; + font-weight: 600; + color: rgba(255, 255, 255, 0.65); + text-transform: uppercase; + letter-spacing: 1px; + margin-bottom: 3px; + } + + .balance-box .balance-box-value { font-size: 15px; font-weight: 700; color: #c8a951; } + + .balance-divider { width: 1px; background: rgba(255, 255, 255, 0.15); margin: 6px 0; } + + /* Ledger table - no horizontal scroll on paper, the landscape page holds every column */ + .statement-table-scroll { width: 100%; overflow: visible; } + + .statement-table { width: 100%; border-collapse: collapse; font-size: 10.5px; } + + .statement-table thead { display: table-header-group; } + .statement-table tfoot { display: table-row-group; } + .statement-table tr { page-break-inside: avoid; break-inside: avoid; } + + .statement-table thead tr { + background: #e8edf5; + border-top: 1px solid #d0d7e3; + border-bottom: 2px solid #1a3a6b; + } + + .statement-table thead th { + padding: 8px 9px; + font-size: 9.5px; + font-weight: 700; + color: #1a3a6b; + text-transform: uppercase; + letter-spacing: 0.4px; + white-space: nowrap; + } + + .statement-table tbody tr { border-bottom: 1px solid #e8ecf3; } + .statement-table tbody tr:nth-child(even) { background: #f4f7fb; } + .statement-table tbody td { padding: 6px 9px; vertical-align: middle; color: #333; } + + .statement-table tfoot .total-row { background: #e8edf5; border-top: 2px solid #1a3a6b; } + .statement-table tfoot .total-row td { padding: 8px 9px; } + + /* Column widths */ + .col-sr { width: 32px; } + .col-date { width: 96px; white-space: nowrap; } + .col-desc { width: auto; min-width: 140px; } + .col-ref { width: 70px; white-space: nowrap; } + .col-amount { width: 108px; white-space: nowrap; } + .col-balance { width: 96px; white-space: nowrap; } + .col-resolution { width: 170px; white-space: nowrap; padding-left: 14px; } + + /* Share movement colors */ + .debit-amount { color: #c0392b; font-weight: 500; } + .credit-amount { color: #1a7a3f; font-weight: 500; } + .balance-amount { color: #1a3a6b; font-weight: 600; font-family: 'Courier New', monospace; } + + .total-label { + font-size: 10.5px; + font-weight: 700; + color: #1a3a6b; + text-transform: uppercase; + letter-spacing: 0.5px; + } + + .closing-balance { + font-size: 12.5px; + font-weight: 700; + color: #1a3a6b; + font-family: 'Courier New', monospace; + } + + /* Misc */ + .txn-desc { font-weight: 500; } + .text-right { text-align: right; } + .text-center { text-align: center; } + .text-left { text-align: left; } + .text-muted { color: #999; } + .mono { font-family: 'Courier New', monospace; font-size: 10px; } + + /* No data */ + .no-data-block { padding: 40px; text-align: center; color: #aaa; font-size: 13px; } + + /* Footer */ + .statement-footer { + background: #f0f3f9; + border-top: 1px solid #d0d7e3; + padding: 10px 20px; + text-align: center; + } + + .statement-footer .footer-note { + font-size: 9px; + color: #888; + margin-bottom: 3px; + font-style: italic; + } + + .statement-footer .footer-meta { font-size: 9px; color: #aaa; letter-spacing: 0.3px; } + `; + private recalculateRunningBalances(transactions: any[]): any[] { let balance = 0; return transactions.map((txn) => { From abc966d507ebf46fed28f6d609aa4a5dc9499836 Mon Sep 17 00:00:00 2001 From: anshul Date: Thu, 27 Aug 2026 18:30:45 +0530 Subject: [PATCH 11/64] implement user, role, product, and task management components along with associated services and UI updates --- .../create-fixed-deposit-product.component.ts | 28 +- .../edit-fixed-deposit-product.component.ts | 12 +- .../share-product-general-tab.component.html | 336 ++++++++++-------- .../share-product-general-tab.component.scss | 114 +++++- .../add-role/add-role.component.ts | 9 +- .../edit-role/edit-role.component.ts | 11 +- .../view-role/view-role.component.ts | 36 +- .../pending-user-edits.component.html | 161 +++++++++ .../pending-user-edits.component.scss | 221 ++++++++++++ .../pending-user-edits.component.ts | 323 +++++++++++++++++ .../user-edit-compare-dialog.component.html | 75 ++++ .../user-edit-compare-dialog.component.scss | 141 ++++++++ .../user-edit-compare-dialog.component.ts | 57 +++ .../checker-inbox-and-tasks.component.html | 9 + src/app/tasks/tasks-routing.module.ts | 6 + .../create-user/create-user.component.ts | 8 + .../users/edit-user/edit-user.component.ts | 52 +-- src/app/users/users.service.ts | 58 +++ .../users/view-user/view-user.component.ts | 10 +- src/assets/styles/_product-list.scss | 6 + 20 files changed, 1471 insertions(+), 202 deletions(-) create mode 100644 src/app/tasks/checker-inbox-and-tasks-tabs/pending-user-edits/pending-user-edits.component.html create mode 100644 src/app/tasks/checker-inbox-and-tasks-tabs/pending-user-edits/pending-user-edits.component.scss create mode 100644 src/app/tasks/checker-inbox-and-tasks-tabs/pending-user-edits/pending-user-edits.component.ts create mode 100644 src/app/tasks/checker-inbox-and-tasks-tabs/pending-user-edits/user-edit-compare-dialog/user-edit-compare-dialog.component.html create mode 100644 src/app/tasks/checker-inbox-and-tasks-tabs/pending-user-edits/user-edit-compare-dialog/user-edit-compare-dialog.component.scss create mode 100644 src/app/tasks/checker-inbox-and-tasks-tabs/pending-user-edits/user-edit-compare-dialog/user-edit-compare-dialog.component.ts diff --git a/src/app/products/fixed-deposit-products/create-fixed-deposit-product/create-fixed-deposit-product.component.ts b/src/app/products/fixed-deposit-products/create-fixed-deposit-product/create-fixed-deposit-product.component.ts index e9517e10e..30ba4bd6f 100644 --- a/src/app/products/fixed-deposit-products/create-fixed-deposit-product/create-fixed-deposit-product.component.ts +++ b/src/app/products/fixed-deposit-products/create-fixed-deposit-product/create-fixed-deposit-product.component.ts @@ -33,6 +33,7 @@ import { /** Custom Services */ import { ProductsService } from 'app/products/products.service'; import { SettingsService } from 'app/settings/settings.service'; +import { AlertService } from 'app/core/alert/alert.service'; import { Dates } from 'app/core/utils/dates'; import { Accounting } from 'app/core/utils/accounting'; import { FaIconComponent } from '@fortawesome/angular-fontawesome'; @@ -70,6 +71,7 @@ import { STANDALONE_SHARED_IMPORTS } from 'app/standalone-shared.module'; export class CreateFixedDepositProductComponent { private route = inject(ActivatedRoute); private productsService = inject(ProductsService); + private alertService = inject(AlertService); private router = inject(Router); private settingsService = inject(SettingsService); private dateUtils = inject(Dates); @@ -265,14 +267,24 @@ export class CreateFixedDepositProductComponent { locale: this.settingsService.language.code }); delete fixedDepositProduct.advancedAccountingRules; - this.productsService.createFixedDepositProduct(fixedDepositProduct).subscribe((response: any) => { - this.router.navigate( - [ - '../', - response.resourceId - ], - { relativeTo: this.route } - ); + this.productsService.createFixedDepositProduct(fixedDepositProduct).subscribe({ + next: (response: any) => { + if (response?.rollbackTransaction) { + this.alertService.alert({ + type: 'Success', + message: 'Fixed 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/fixed-deposit-products/edit-fixed-deposit-product/edit-fixed-deposit-product.component.ts b/src/app/products/fixed-deposit-products/edit-fixed-deposit-product/edit-fixed-deposit-product.component.ts index 7bad586e3..3b9fd2f91 100644 --- a/src/app/products/fixed-deposit-products/edit-fixed-deposit-product/edit-fixed-deposit-product.component.ts +++ b/src/app/products/fixed-deposit-products/edit-fixed-deposit-product/edit-fixed-deposit-product.component.ts @@ -21,6 +21,7 @@ import { FixedDepositProductAccountingStepComponent } from '../fixed-deposit-pro /** Custom Services */ import { ProductsService } from 'app/products/products.service'; import { SettingsService } from 'app/settings/settings.service'; +import { AlertService } from 'app/core/alert/alert.service'; import { MatTabsModule } from '@angular/material/tabs'; import { MatTable, @@ -70,6 +71,7 @@ import { STANDALONE_SHARED_IMPORTS } from 'app/standalone-shared.module'; export class EditFixedDepositProductComponent { private route = inject(ActivatedRoute); private productsService = inject(ProductsService); + private alertService = inject(AlertService); private router = inject(Router); private settingsService = inject(SettingsService); private accounting = inject(Accounting); @@ -280,8 +282,14 @@ export class EditFixedDepositProductComponent { delete fixedDepositProduct.advancedAccountingRules; this.productsService .updateFixedDepositProduct(this.fixedDepositProductsTemplate.id, fixedDepositProduct) - .subscribe((response: any) => { - this.router.navigate(['../'], { relativeTo: this.route }); + .subscribe({ + next: () => { + this.alertService.alert({ + type: 'Success', + message: 'Fixed deposit product update submitted successfully for Checker approval.' + }); + this.router.navigate(['../'], { relativeTo: this.route }); + } }); } diff --git a/src/app/products/share-products/view-share-product/share-product-general-tab/share-product-general-tab.component.html b/src/app/products/share-products/view-share-product/share-product-general-tab/share-product-general-tab.component.html index d1f16779a..5d64cc0ba 100644 --- a/src/app/products/share-products/view-share-product/share-product-general-tab/share-product-general-tab.component.html +++ b/src/app/products/share-products/view-share-product/share-product-general-tab/share-product-general-tab.component.html @@ -28,7 +28,7 @@ } -
+
-
-

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

- - - -
- {{ 'labels.inputs.Short Name' | translate }}: - {{ shareProduct.shortName }} + +
+
+
+

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

- - @if (shareProduct.description) { -
- {{ 'labels.inputs.Description' | translate }}: - {{ shareProduct.description }} -
- } - -

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

- - - -
- {{ 'labels.inputs.Currency' | translate }}: - {{ shareProduct.currency.name }} -
- -
- {{ 'labels.inputs.Decimal Places' | translate }}: - {{ shareProduct.currency.decimalPlaces }} -
- -
- {{ 'labels.inputs.Currency in multiples of' | translate }}: - {{ shareProduct.currency.inMultiplesOf }} -
- -

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

- - - -
- {{ 'labels.inputs.Total Number of Shares' | translate }}: - {{ shareProduct.totalShares | formatNumber }} -
- -
- {{ 'labels.inputs.Shares to be Issued' | translate }}: - {{ shareProduct.totalSharesIssued | formatNumber }} -
- -
- {{ 'labels.inputs.Nominal/Unit Price' | translate }}: - {{ shareProduct.unitPrice | formatNumber }} -
- -
- {{ 'labels.inputs.Capital Value' | translate }}: - {{ shareProduct.shareCapital | formatNumber }} -
- -

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

- - - -
- {{ 'labels.inputs.Shares per Client' | translate }}: - {{ - shareProduct.nominalShares + - ' (Min: ' + - (shareProduct.minimumShares ? shareProduct.minimumShares : shareProduct.nominalShares) + - ', Max: ' + - (shareProduct.maximumShares ? shareProduct.maximumShares : shareProduct.nominalShares) + - ')' - }} -
- - @if (shareProduct.minimumActivePeriod) { -
- {{ 'labels.inputs.Minimum Active Period' | translate }}: - {{ shareProduct.minimumActivePeriod }} - {{ shareProduct.minimumActivePeriodForDividendsTypeEnum.value | translateKey: 'catalogs' }} +
+ {{ 'labels.inputs.Short Name' | translate }} + {{ shareProduct.shortName }}
- } + @if (shareProduct.description) { +
+ {{ 'labels.inputs.Description' | translate }} + {{ shareProduct.description }} +
+ } +
+
- @if (shareProduct.lockinPeriod) { -
- {{ 'labels.inputs.Lock-in Period' | translate }}: - {{ shareProduct.lockinPeriod }} {{ shareProduct.lockPeriodTypeEnum.value | translateKey: 'catalogs' }} +
+
+
+

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

+
+
+
+ {{ 'labels.inputs.Currency' | translate }} + {{ shareProduct.currency.name }}
- } - -
- {{ 'labels.inputs.Allow dividends for inactive clients' | translate }}: - {{ shareProduct.allowDividendCalculationForInactiveClients | yesNo }} +
+ {{ 'labels.inputs.Decimal Places' | translate }} + {{ shareProduct.currency.decimalPlaces }} +
+
+ {{ 'labels.inputs.Currency in multiples of' | translate }} + {{ shareProduct.currency.inMultiplesOf }} +
+
- @if (shareProduct.marketPrice?.length) { -
-

{{ 'labels.inputs.Market Price Period' | translate }}

- - + +
+
+
+

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

+
+
+
+ {{ 'labels.inputs.Total Number of Shares' | translate }} + {{ shareProduct.totalShares | formatNumber }} +
+
+ {{ 'labels.inputs.Shares to be Issued' | translate }} + {{ shareProduct.totalSharesIssued | formatNumber }} +
+
+ {{ 'labels.inputs.Nominal/Unit Price' | translate }} + {{ shareProduct.unitPrice | formatNumber }} +
+
+ {{ 'labels.inputs.Capital Value' | translate }} + {{ shareProduct.shareCapital | formatNumber }} +
+
+
+ + +
+
+
+

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

+
+
+
+ {{ 'labels.inputs.Shares per Client' | translate }} + {{ + shareProduct.nominalShares + + ' (Min: ' + + (shareProduct.minimumShares ? shareProduct.minimumShares : shareProduct.nominalShares) + + ', Max: ' + + (shareProduct.maximumShares ? shareProduct.maximumShares : shareProduct.nominalShares) + + ')' + }} +
+ + @if (shareProduct.minimumActivePeriod) { +
+ {{ 'labels.inputs.Minimum Active Period' | translate }} + {{ shareProduct.minimumActivePeriod }} + {{ shareProduct.minimumActivePeriodForDividendsTypeEnum.value | translateKey: 'catalogs' }} +
+ } + + @if (shareProduct.lockinPeriod) { +
+ {{ 'labels.inputs.Lock-in Period' | translate }} + {{ shareProduct.lockinPeriod }} + {{ shareProduct.lockPeriodTypeEnum.value | translateKey: 'catalogs' }} +
+ } + +
+ {{ 'labels.inputs.Allow dividends for inactive clients' | translate }} + + + {{ shareProduct.allowDividendCalculationForInactiveClients | yesNo }} + + +
+
+
+ + + @if (shareProduct.marketPrice?.length) { +
+
+
+

{{ 'labels.inputs.Market Price Period' | translate }}

+
+
+
{{ 'labels.inputs.From Date' | translate }} @@ -164,13 +194,18 @@
- } + + } - @if (shareProduct.charges.length) { -
-

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

- - + + @if (shareProduct.charges.length) { +
+
+
+

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

+
+
+
{{ 'labels.inputs.Name' | translate }} @@ -199,62 +234,57 @@
- } + + } -

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

+ +
+
+
+

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

+
- - -
- {{ 'labels.inputs.Type' | translate }}: - {{ +
+ {{ 'labels.inputs.Type' | translate }} + {{ 'labels.accounting.' + getAccountingRuleName(shareProduct.accountingRule.value) | translate }}
@if (shareProduct.accountingRule.id === 2) { -
-

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

-
- - -
-

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

-
- - -
-

{{ 'labels.heading.Share Equity' | translate }}

-
- - -
-

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

-
- - -
+
+

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

+ + + +

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

+ + + +

{{ 'labels.heading.Share Equity' | translate }}

+ + + +

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

+ +
} -
+
diff --git a/src/app/products/share-products/view-share-product/share-product-general-tab/share-product-general-tab.component.scss b/src/app/products/share-products/view-share-product/share-product-general-tab/share-product-general-tab.component.scss index c6be20032..9dcaba7ca 100644 --- a/src/app/products/share-products/view-share-product/share-product-general-tab/share-product-general-tab.component.scss +++ b/src/app/products/share-products/view-share-product/share-product-general-tab/share-product-general-tab.component.scss @@ -6,8 +6,6 @@ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ -@use 'assets/styles/helper'; -@use 'assets/styles/colours' as *; @use 'assets/styles/product-view' as pv; @include pv.product-view-styles; @@ -18,13 +16,111 @@ --pv-accent-l: #fce7f3; } -.tab-container { - padding: 1%; - margin: 1%; +.pv-hero-actions { + display: flex; + gap: 10px; + flex-shrink: 0; + flex-wrap: wrap; + align-items: flex-start; } -.mat-h3 { - margin-top: 10px !important; - font-weight: 300; - color: $status-approved; +/* ───────── COMPACT ACCOUNTING SECTION ───────── */ +.pv-accounting { + .pv-accounting-row { + display: grid; + grid-template-columns: minmax(220px, 40%) 1fr; + gap: 14px; + align-items: baseline; + padding: 8px 0; + border-bottom: 1px dashed var(--pv-g100); + min-height: unset; + line-height: 1.4; + } + + .pv-accounting-label { + font-size: 10.5px; + font-weight: 700; + text-transform: uppercase; + letter-spacing: 0.06em; + color: var(--pv-g500); + margin: 0; + flex: none; + } + + .pv-accounting-value { + font-size: 13.5px; + color: var(--pv-g900); + font-weight: 500; + word-break: break-word; + margin: 0; + flex: none; + } + + .pv-accounting-details { + margin-top: 0; + + h4.pv-sub-h { + margin: 16px 0 8px; + font-size: 12px; + font-weight: 700; + color: var(--pv-g700); + text-transform: uppercase; + letter-spacing: 0.05em; + line-height: 1.2; + } + + mifosx-gl-account-display { + display: block; + padding: 8px 0; + min-height: unset; + line-height: 1.4; + border-bottom: 1px dashed var(--pv-g100); + + &:last-child { + border-bottom: 0; + } + + ::ng-deep .layout-row { + display: grid; + grid-template-columns: minmax(220px, 40%) 1fr; + gap: 14px; + align-items: baseline; + margin: 0; + } + + ::ng-deep div { + margin: 0 !important; + line-height: 1.4; + } + + ::ng-deep .header { + font-size: 10.5px; + font-weight: 700; + text-transform: uppercase; + letter-spacing: 0.06em; + color: var(--pv-g500); + margin: 0 !important; + max-width: 100%; + flex: none; + } + + ::ng-deep div:not(.header) { + font-size: 13.5px; + color: var(--pv-g900); + font-weight: 500; + word-break: break-word; + margin: 0 !important; + max-width: 100%; + flex: none; + } + } + } + + @media (width <= 600px) { + .pv-accounting-row, + .pv-accounting-details mifosx-gl-account-display ::ng-deep .layout-row { + grid-template-columns: 1fr; + gap: 4px; + } + } } diff --git a/src/app/system/roles-and-permissions/add-role/add-role.component.ts b/src/app/system/roles-and-permissions/add-role/add-role.component.ts index 844cf31fa..9b39f32d2 100644 --- a/src/app/system/roles-and-permissions/add-role/add-role.component.ts +++ b/src/app/system/roles-and-permissions/add-role/add-role.component.ts @@ -13,6 +13,7 @@ import { Router, ActivatedRoute, RouterLink } from '@angular/router'; /** Custom Services */ import { SystemService } from '../../system.service'; +import { AlertService } from 'app/core/alert/alert.service'; import { STANDALONE_SHARED_IMPORTS } from 'app/standalone-shared.module'; /** Custom Service Zitadel */ @@ -36,6 +37,7 @@ export class AddRoleComponent implements OnInit { private route = inject(ActivatedRoute); private router = inject(Router); private authService = inject(AuthService); + private alertService = inject(AlertService); /** Role form. */ roleForm: UntypedFormGroup; @@ -69,7 +71,12 @@ export class AddRoleComponent implements OnInit { */ submit() { this.systemService.createRole(this.roleForm.value).subscribe((response: any) => { - if (environment.OIDC.oidcServerEnabled) { + if (response?.rollbackTransaction) { + this.alertService.alert({ + type: 'Success', + message: 'Role creation request submitted successfully for approval.' + }); + } else if (environment.OIDC.oidcServerEnabled) { this.authService.createRole(response.resourceId, this.roleForm.value.name, this.roleForm.value.description); } this.router.navigate(['../'], { relativeTo: this.route }); diff --git a/src/app/system/roles-and-permissions/edit-role/edit-role.component.ts b/src/app/system/roles-and-permissions/edit-role/edit-role.component.ts index 668419223..d27ead0c7 100644 --- a/src/app/system/roles-and-permissions/edit-role/edit-role.component.ts +++ b/src/app/system/roles-and-permissions/edit-role/edit-role.component.ts @@ -13,6 +13,7 @@ import { ActivatedRoute, Router, RouterLink } from '@angular/router'; /** Custom Services */ import { SystemService } from '../../system.service'; +import { AlertService } from 'app/core/alert/alert.service'; import { STANDALONE_SHARED_IMPORTS } from 'app/standalone-shared.module'; /** Custom Service Zitadel */ @@ -36,6 +37,7 @@ export class EditRoleComponent implements OnInit { private route = inject(ActivatedRoute); private router = inject(Router); private authService = inject(AuthService); + private alertService = inject(AlertService); /** Role Form */ roleForm: UntypedFormGroup; @@ -83,8 +85,13 @@ export class EditRoleComponent implements OnInit { * if successful redirects to view updated roles and permissions. */ submit() { - this.systemService.updateRole(this.roleForm.value, this.roleData.id).subscribe(() => { - if (environment.OIDC.oidcServerEnabled) { + this.systemService.updateRole(this.roleForm.value, this.roleData.id).subscribe((response: any) => { + if (response?.rollbackTransaction) { + this.alertService.alert({ + type: 'Success', + message: 'Role update request submitted successfully for approval.' + }); + } else if (environment.OIDC.oidcServerEnabled) { this.authService.updateRole( this.roleData.id, this.roleForm.get('name')?.value, diff --git a/src/app/system/roles-and-permissions/view-role/view-role.component.ts b/src/app/system/roles-and-permissions/view-role/view-role.component.ts index 5ec5a8c6a..9ebe68463 100644 --- a/src/app/system/roles-and-permissions/view-role/view-role.component.ts +++ b/src/app/system/roles-and-permissions/view-role/view-role.component.ts @@ -22,6 +22,7 @@ import { EnableDialogComponent } from '../../../shared/enable-dialog/enable-dial import { FaIconComponent } from '@fortawesome/angular-fontawesome'; import { MatCheckbox } from '@angular/material/checkbox'; import { STANDALONE_SHARED_IMPORTS } from 'app/standalone-shared.module'; +import { AlertService } from 'app/core/alert/alert.service'; /** Custom Service Zitadel */ import { environment } from '../../../../environments/environment'; @@ -48,6 +49,7 @@ export class ViewRoleComponent implements OnInit { private translateService = inject(TranslateService); dialog = inject(MatDialog); private authService = inject(AuthService); + private alertService = inject(AlertService); /** Role Permissions Data */ rolePermissionService: any; @@ -720,7 +722,14 @@ export class ViewRoleComponent implements OnInit { this.formGroup.controls.roster.disable(); this.checkboxesChanged = false; this.isDisabled = true; - this.systemService.updateRolePermission(this.roleId, permissionData).subscribe((response: any) => {}); + this.systemService.updateRolePermission(this.roleId, permissionData).subscribe((response: any) => { + if (response?.rollbackTransaction) { + this.alertService.alert({ + type: 'Success', + message: 'Role permissions update request submitted successfully for approval.' + }); + } + }); } /** @@ -756,8 +765,13 @@ export class ViewRoleComponent implements OnInit { }); deleteRoleDialogRef.afterClosed().subscribe((response: any) => { if (response.delete) { - this.systemService.deleteRole(this.roleId).subscribe(() => { - if (environment.OIDC.oidcServerEnabled) { + this.systemService.deleteRole(this.roleId).subscribe((res: any) => { + if (res?.rollbackTransaction) { + this.alertService.alert({ + type: 'Success', + message: 'Role deletion request submitted successfully for approval.' + }); + } else if (environment.OIDC.oidcServerEnabled) { this.authService.deleteRole(this.roleId); } this.router.navigate(['/system/roles-and-permissions']); @@ -776,7 +790,13 @@ export class ViewRoleComponent implements OnInit { }); enableRoleDialogRef.afterClosed().subscribe((response: any) => { if (response.enable) { - this.systemService.enableRole(this.roleId).subscribe(() => { + this.systemService.enableRole(this.roleId).subscribe((res: any) => { + if (res?.rollbackTransaction) { + this.alertService.alert({ + type: 'Success', + message: 'Role enablement request submitted successfully for approval.' + }); + } this.router.navigate(['/system/roles-and-permissions']); }); } else { @@ -793,7 +813,13 @@ export class ViewRoleComponent implements OnInit { }); deleteRoleDialogRef.afterClosed().subscribe((response: any) => { if (response.disable) { - this.systemService.disableRole(this.roleId).subscribe(() => { + this.systemService.disableRole(this.roleId).subscribe((res: any) => { + if (res?.rollbackTransaction) { + this.alertService.alert({ + type: 'Success', + message: 'Role disablement request submitted successfully for approval.' + }); + } this.router.navigate(['/system/roles-and-permissions']); }); } else { diff --git a/src/app/tasks/checker-inbox-and-tasks-tabs/pending-user-edits/pending-user-edits.component.html b/src/app/tasks/checker-inbox-and-tasks-tabs/pending-user-edits/pending-user-edits.component.html new file mode 100644 index 000000000..c75bdee8c --- /dev/null +++ b/src/app/tasks/checker-inbox-and-tasks-tabs/pending-user-edits/pending-user-edits.component.html @@ -0,0 +1,161 @@ + + +
+
+ + + + + + +
+ @for (p of datePresets; track p.key) { + + } +
+ + +
+ + @if (loading) { +
+

Loading…

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

No {{ filterStatus | lowercase }} user requests

+

No pending user operations found for this filter.

+
+ } @else { +
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
#{{ req.id }}Action + + {{ req.requestType === 'CREATE_USER' ? 'Create User' : 'Update User' }} + + Username{{ req.username || '—' }}Name{{ req.firstname || '' }} {{ req.lastname || '' }}Email{{ req.email || '—' }}Office ID{{ req.officeId || '—' }}Submitted By{{ req.makerUsername }}Submitted On{{ formatDate(req.submittedDate) }}Status + {{ req.status }} + Actions +
+ + + @if (req.status === 'PENDING') { + + + + } +
+
+ + +
+ } +
diff --git a/src/app/tasks/checker-inbox-and-tasks-tabs/pending-user-edits/pending-user-edits.component.scss b/src/app/tasks/checker-inbox-and-tasks-tabs/pending-user-edits/pending-user-edits.component.scss new file mode 100644 index 000000000..26817ce7d --- /dev/null +++ b/src/app/tasks/checker-inbox-and-tasks-tabs/pending-user-edits/pending-user-edits.component.scss @@ -0,0 +1,221 @@ +/** + * 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/. + */ + +.pce-tab-content { + padding: 16px; +} + +.pce-action-bar { + display: flex; + align-items: center; + gap: 12px; + margin-bottom: 16px; + flex-wrap: wrap; +} + +.pce-search-box { + display: flex; + align-items: center; + background: #fff; + border: 1px solid #cbd5e1; + border-radius: 6px; + padding: 4px 10px; + gap: 6px; + + mat-icon { + font-size: 18px; + color: #64748b; + } + + input { + border: none; + outline: none; + font-size: 0.88rem; + } +} + +.pce-filter-select { + padding: 6px 12px; + border-radius: 6px; + border: 1px solid #cbd5e1; + background: #fff; + font-size: 0.88rem; +} + +.pce-fb-sep { + height: 24px; + width: 1px; + background: #e2e8f0; +} + +.pce-presets { + display: flex; + gap: 6px; +} + +.pce-preset-chip { + padding: 4px 10px; + border-radius: 16px; + border: 1px solid #cbd5e1; + background: #f8fafc; + font-size: 0.8rem; + cursor: pointer; + transition: all 0.2s ease; + + &--active { + background: #2563eb; + color: #fff; + border-color: #2563eb; + } +} + +.pce-btn-refresh { + padding: 6px 12px; + border-radius: 6px; + border: 1px solid #cbd5e1; + background: #fff; + cursor: pointer; +} + +.table-container { + background: #fff; + border-radius: 8px; + border: 1px solid #e2e8f0; + overflow: hidden; +} + +.modern-table { + width: 100%; + + th { + background: #f8fafc; + color: #475569; + font-weight: 600; + font-size: 0.82rem; + padding: 12px 14px; + border-bottom: 2px solid #e2e8f0; + } + + td { + padding: 10px 14px; + font-size: 0.85rem; + border-bottom: 1px solid #f1f5f9; + } +} + +.type-badge { + display: inline-block; + padding: 2px 8px; + border-radius: 4px; + font-size: 0.72rem; + font-weight: 600; + + &.type-create { + background-color: #e0f2fe; + color: #0369a1; + } + + &.type-update { + background-color: #f3e8ff; + color: #7e22ce; + } +} + +.status-badge { + display: inline-block; + padding: 2px 8px; + border-radius: 4px; + font-size: 0.75rem; + font-weight: 600; + + &--pending { + background-color: #fef3c7; + color: #b45309; + } + + &--approved { + background-color: #d1fae5; + color: #065f46; + } + + &--rejected { + background-color: #fee2e2; + color: #991b1b; + } +} + +.action-buttons { + display: flex; + align-items: center; + gap: 6px; +} + +.btn-action { + display: inline-flex; + align-items: center; + gap: 4px; + padding: 4px 8px; + border-radius: 4px; + border: 1px solid transparent; + font-size: 0.75rem; + font-weight: 500; + cursor: pointer; + transition: background-color 0.15s; + + &--compare { + background: #f1f5f9; + color: #334155; + border-color: #cbd5e1; + + &:hover { + background: #e2e8f0; + } + } + + &--approve { + background: #ecfdf5; + color: #059669; + border-color: #a7f3d0; + + &:hover { + background: #d1fae5; + } + } + + &--reject { + background: #fef2f2; + color: #dc2626; + border-color: #fecaca; + + &:hover { + background: #fee2e2; + } + } +} + +.empty-state { + text-align: center; + padding: 48px 16px; + color: #64748b; + + .empty-icon { + font-size: 32px; + margin-bottom: 8px; + } + + .empty-title { + font-size: 1.1rem; + font-weight: 600; + margin: 0; + } + + .empty-text { + font-size: 0.88rem; + margin-top: 4px; + } +} diff --git a/src/app/tasks/checker-inbox-and-tasks-tabs/pending-user-edits/pending-user-edits.component.ts b/src/app/tasks/checker-inbox-and-tasks-tabs/pending-user-edits/pending-user-edits.component.ts new file mode 100644 index 000000000..7facd46f0 --- /dev/null +++ b/src/app/tasks/checker-inbox-and-tasks-tabs/pending-user-edits/pending-user-edits.component.ts @@ -0,0 +1,323 @@ +/** + * 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 { + MatTable, + MatColumnDef, + MatHeaderCellDef, + MatHeaderCell, + MatCellDef, + MatCell, + MatHeaderRowDef, + MatHeaderRow, + MatRowDef, + MatRow +} from '@angular/material/table'; +import { MatPaginatorModule, PageEvent } from '@angular/material/paginator'; +import { MatDialog } from '@angular/material/dialog'; +import { MatIcon } from '@angular/material/icon'; +import { FaIconComponent } from '@fortawesome/angular-fontawesome'; + +import { UsersService } from 'app/users/users.service'; +import { SettingsService } from 'app/settings/settings.service'; +import { ConfirmationDialogComponent } from 'app/shared/confirmation-dialog/confirmation-dialog.component'; +import { FormDialogComponent } from 'app/shared/form-dialog/form-dialog.component'; +import { FormfieldBase } from 'app/shared/form-dialog/formfield/model/formfield-base'; +import { InputBase } from 'app/shared/form-dialog/formfield/model/input-base'; +import { STANDALONE_SHARED_IMPORTS } from 'app/standalone-shared.module'; +import { + UserEditCompareDialogComponent, + UserEditCompareRow, + UserEditCompareDialogData +} from './user-edit-compare-dialog/user-edit-compare-dialog.component'; + +const USER_FIELD_LABELS: Record = { + username: 'Username', + firstname: 'First Name', + lastname: 'Last Name', + email: 'Email Address', + mobileNo: 'Mobile Number', + officeId: 'Office', + officeName: 'Office Name', + staffId: 'Staff Member', + roles: 'Assigned Roles', + roleNames: 'Assigned Roles', + otpDeliveryMethod: '2FA / OTP Channel', + sendPasswordToEmail: 'Send Password To Email', + passwordNeverExpires: 'Password Never Expires', + password: 'Password' +}; + +@Component({ + selector: 'mifosx-pending-user-edits', + templateUrl: './pending-user-edits.component.html', + styleUrls: ['./pending-user-edits.component.scss'], + imports: [ + ...STANDALONE_SHARED_IMPORTS, + FormsModule, + FaIconComponent, + MatTable, + MatColumnDef, + MatHeaderCellDef, + MatHeaderCell, + MatCellDef, + MatCell, + MatHeaderRowDef, + MatHeaderRow, + MatRowDef, + MatRow, + MatIcon, + MatPaginatorModule + ] +}) +export class PendingUserEditsComponent implements OnInit { + private usersService = inject(UsersService); + private settingsService = inject(SettingsService); + private dialog = inject(MatDialog); + + requests: any[] = []; + loading = false; + loaded = false; + + filterStatus = 'PENDING'; + filterUsername = ''; + fromDate: Date | null = null; + toDate: Date | null = null; + maxDate = new Date(); + activePreset = 'all'; + + pageIndex = 0; + pageSize = 20; + totalRecords = 0; + + displayedColumns = [ + 'id', + 'requestType', + 'username', + 'fullname', + 'email', + 'office', + 'makerUsername', + 'submittedDate', + 'status', + 'actions' + ]; + + statusOptions = [ + { value: 'PENDING', label: 'Pending Approval' }, + { value: 'APPROVED', label: 'Approved' }, + { value: 'REJECTED', label: 'Rejected' }, + { value: '', label: 'All Statuses' } + ]; + + datePresets = [ + { key: 'today', label: 'Today' }, + { key: '7d', label: 'Last 7 Days' }, + { key: '30d', label: 'Last 30 Days' }, + { key: 'all', label: 'All Dates' } + ]; + + ngOnInit(): void { + this.load(); + } + + load(): void { + this.loading = true; + const fromStr = this.fromDate ? this.formatIsoDate(this.fromDate) : undefined; + const toStr = this.toDate ? this.formatIsoDate(this.toDate) : undefined; + const offset = this.pageIndex * this.pageSize; + + this.usersService + .getPendingUsers(this.filterStatus, this.filterUsername, fromStr, toStr, offset, this.pageSize) + .subscribe({ + next: (response: any) => { + this.requests = response.pageItems || []; + this.totalRecords = response.totalFilteredRecords || this.requests.length; + this.loading = false; + this.loaded = true; + }, + error: () => { + this.requests = []; + this.loading = false; + this.loaded = true; + } + }); + } + + onFilterChange(): void { + this.pageIndex = 0; + this.load(); + } + + applyFilter(query: string): void { + this.filterUsername = query; + this.pageIndex = 0; + this.load(); + } + + applyPreset(key: string): void { + this.activePreset = key; + const now = new Date(); + if (key === 'today') { + this.fromDate = new Date(now.getFullYear(), now.getMonth(), now.getDate()); + this.toDate = new Date(now.getFullYear(), now.getMonth(), now.getDate()); + } else if (key === '7d') { + const past = new Date(); + past.setDate(past.getDate() - 7); + this.fromDate = past; + this.toDate = now; + } else if (key === '30d') { + const past = new Date(); + past.setDate(past.getDate() - 30); + this.fromDate = past; + this.toDate = now; + } else { + this.fromDate = null; + this.toDate = null; + } + this.pageIndex = 0; + this.load(); + } + + onPageChange(event: PageEvent): void { + this.pageIndex = event.pageIndex; + this.pageSize = event.pageSize; + this.load(); + } + + formatDate(val: any): string { + if (!val) { + return '—'; + } + const d = new Date(val); + return isNaN(d.getTime()) ? String(val) : d.toLocaleString(); + } + + private formatIsoDate(d: Date): string { + const yyyy = d.getFullYear(); + const mm = String(d.getMonth() + 1).padStart(2, '0'); + const dd = String(d.getDate()).padStart(2, '0'); + return `${yyyy}-${mm}-${dd}`; + } + + statusClass(status: string): string { + if (status === 'PENDING') { + return 'status-badge--pending'; + } + if (status === 'APPROVED') { + return 'status-badge--approved'; + } + if (status === 'REJECTED') { + return 'status-badge--rejected'; + } + return ''; + } + + compare(req: any): void { + this.usersService.getPendingUser(req.id).subscribe((detail: any) => { + const rows: UserEditCompareRow[] = []; + const proposed = detail.proposedValues || {}; + const original = detail.originalValues || {}; + + const allKeys = Array.from( + new Set([ + ...Object.keys(original), + ...Object.keys(proposed) + ]) + ); + for (const k of allKeys) { + if (k === 'repeatPassword' || k === 'pendingLockKey' || k === 'id') { + continue; + } + const label = USER_FIELD_LABELS[k] || k; + let curVal = original[k]; + let propVal = proposed[k]; + + if (Array.isArray(curVal)) { + curVal = curVal.join(', '); + } + if (Array.isArray(propVal)) { + propVal = propVal.join(', '); + } + + rows.push({ + key: k, + label, + current: curVal, + value: propVal + }); + } + + const data: UserEditCompareDialogData = { + requestId: detail.id, + requestType: detail.requestType, + username: detail.username || req.username, + rows, + status: detail.status, + makerUsername: detail.makerUsername, + submittedDate: this.formatDate(detail.submittedDate), + checkerUsername: detail.checkerUsername, + checkedDate: this.formatDate(detail.checkedDate), + rejectReason: detail.rejectReason + }; + + this.dialog.open(UserEditCompareDialogComponent, { + data, + width: '650px' + }); + }); + } + + approve(req: any): void { + const actionLabel = req.requestType === 'CREATE_USER' ? 'creation' : 'update'; + const dialogRef = this.dialog.open(ConfirmationDialogComponent, { + data: { + heading: `Approve User ${actionLabel}`, + dialogContext: `Are you sure you want to approve this user ${actionLabel} for '${req.username}'?` + } + }); + + dialogRef.afterClosed().subscribe((confirmed: boolean) => { + if (confirmed) { + this.usersService.approvePendingUser(req.id).subscribe(() => { + this.load(); + }); + } + }); + } + + reject(req: any): void { + const formfields: FormfieldBase[] = [ + new InputBase({ + controlType: 'input', + key: 'reason', + label: 'Rejection Reason', + required: true, + order: 1 + }) + ]; + + const dialogRef = this.dialog.open(FormDialogComponent, { + data: { + title: `Reject User Request`, + layout: { addButtonText: 'Reject' }, + formfields + } + }); + + dialogRef.afterClosed().subscribe((result: any) => { + if (result && result.data && result.data.reason) { + this.usersService.rejectPendingUser(req.id, result.data.reason).subscribe(() => { + this.load(); + }); + } + }); + } +} diff --git a/src/app/tasks/checker-inbox-and-tasks-tabs/pending-user-edits/user-edit-compare-dialog/user-edit-compare-dialog.component.html b/src/app/tasks/checker-inbox-and-tasks-tabs/pending-user-edits/user-edit-compare-dialog/user-edit-compare-dialog.component.html new file mode 100644 index 000000000..9fe40c175 --- /dev/null +++ b/src/app/tasks/checker-inbox-and-tasks-tabs/pending-user-edits/user-edit-compare-dialog/user-edit-compare-dialog.component.html @@ -0,0 +1,75 @@ + + +

+ {{ data.requestType === 'CREATE_USER' ? 'Create User' : 'Update User' }} Request + — {{ data.username }} +

+ + +
+
+ Request ID + #{{ data.requestId }} +
+
+ Submitted By + {{ data.makerUsername }} +
+
+ Submitted On + {{ data.submittedDate }} +
+
+ Status + + {{ data.status }} + +
+
+ + @if (data.rejectReason) { +
Rejection Reason: {{ data.rejectReason }}
+ } + + + + + + @if (data.requestType === 'UPDATE_USER') { + + } + + + + + @for (r of data.rows; track r.key) { + + + @if (data.requestType === 'UPDATE_USER') { + + } + + + } + +
FieldCurrent ValueProposed Value
{{ r.label }}{{ r.current != null && r.current !== '' ? r.current : '—' }} + {{ r.value != null && r.value !== '' ? r.value : '—' }} +
+
+ + + + diff --git a/src/app/tasks/checker-inbox-and-tasks-tabs/pending-user-edits/user-edit-compare-dialog/user-edit-compare-dialog.component.scss b/src/app/tasks/checker-inbox-and-tasks-tabs/pending-user-edits/user-edit-compare-dialog/user-edit-compare-dialog.component.scss new file mode 100644 index 000000000..f71168f8a --- /dev/null +++ b/src/app/tasks/checker-inbox-and-tasks-tabs/pending-user-edits/user-edit-compare-dialog/user-edit-compare-dialog.component.scss @@ -0,0 +1,141 @@ +/** + * 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/. + */ + +.dialog-title { + display: flex; + align-items: center; + gap: 8px; + font-size: 1.15rem; + font-weight: 600; + color: #1e293b; + margin: 0; + padding: 16px 20px; + border-bottom: 1px solid #e2e8f0; + + &__action { + color: #2563eb; + } + + &__user { + color: #475569; + } +} + +.dialog-body { + padding: 16px 20px; + max-height: 70vh; +} + +.meta-banner { + display: flex; + flex-wrap: wrap; + gap: 16px; + padding: 12px 16px; + background-color: #f8fafc; + border-radius: 8px; + border: 1px solid #e2e8f0; + margin-bottom: 16px; +} + +.meta-item { + display: flex; + flex-direction: column; + gap: 2px; +} + +.meta-label { + font-size: 0.72rem; + text-transform: uppercase; + color: #64748b; + font-weight: 600; + letter-spacing: 0.04em; +} + +.meta-val { + font-size: 0.88rem; + font-weight: 500; + color: #1e293b; +} + +.status-badge { + display: inline-block; + padding: 2px 8px; + border-radius: 4px; + font-size: 0.75rem; + font-weight: 600; + + &.badge-pending { + background-color: #fef3c7; + color: #b45309; + } + + &.badge-approved { + background-color: #d1fae5; + color: #065f46; + } + + &.badge-rejected { + background-color: #fee2e2; + color: #991b1b; + } +} + +.reject-banner { + background-color: #fff1f2; + border: 1px solid #fecdd3; + color: #9f1239; + padding: 10px 14px; + border-radius: 6px; + font-size: 0.85rem; + margin-bottom: 16px; +} + +.compare-table { + width: 100%; + border-collapse: collapse; + font-size: 0.85rem; + + th { + text-align: left; + padding: 8px 12px; + background: #f1f5f9; + color: #475569; + font-weight: 600; + border-bottom: 2px solid #cbd5e1; + } + + td { + padding: 8px 12px; + border-bottom: 1px solid #e2e8f0; + } + + .col-field { + font-weight: 500; + color: #334155; + width: 28%; + } + + .col-curr { + color: #64748b; + } + + .col-prop { + color: #0f172a; + font-weight: 500; + + &.highlight-change { + background-color: #f0fdf4; + color: #15803d; + font-weight: 600; + } + } +} + +.btn-close { + color: #64748b; +} diff --git a/src/app/tasks/checker-inbox-and-tasks-tabs/pending-user-edits/user-edit-compare-dialog/user-edit-compare-dialog.component.ts b/src/app/tasks/checker-inbox-and-tasks-tabs/pending-user-edits/user-edit-compare-dialog/user-edit-compare-dialog.component.ts new file mode 100644 index 000000000..6e8f95f80 --- /dev/null +++ b/src/app/tasks/checker-inbox-and-tasks-tabs/pending-user-edits/user-edit-compare-dialog/user-edit-compare-dialog.component.ts @@ -0,0 +1,57 @@ +/** + * Copyright since 2025 Mifos Initiative + * + * This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. + */ + +import { Component, inject } from '@angular/core'; +import { + MatDialogRef, + MAT_DIALOG_DATA, + MatDialogTitle, + MatDialogContent, + MatDialogActions, + MatDialogClose +} from '@angular/material/dialog'; +import { CdkScrollable } from '@angular/cdk/scrolling'; +import { STANDALONE_SHARED_IMPORTS } from 'app/standalone-shared.module'; + +export interface UserEditCompareRow { + key: string; + label: string; + current: any; + value: any; +} + +export interface UserEditCompareDialogData { + requestId: string | number; + requestType: string; + username: string; + rows: UserEditCompareRow[]; + status?: string; + makerUsername?: string; + submittedDate?: string | null; + checkerUsername?: string; + checkedDate?: string | null; + rejectReason?: string; +} + +@Component({ + selector: 'mifosx-user-edit-compare-dialog', + templateUrl: './user-edit-compare-dialog.component.html', + styleUrls: ['./user-edit-compare-dialog.component.scss'], + imports: [ + ...STANDALONE_SHARED_IMPORTS, + MatDialogTitle, + CdkScrollable, + MatDialogContent, + MatDialogActions, + MatDialogClose + ] +}) +export class UserEditCompareDialogComponent { + dialogRef = inject>(MatDialogRef); + data = inject(MAT_DIALOG_DATA); +} 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 e47b8257a..433e1d7da 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 @@ -108,6 +108,15 @@ Pending Client Edits
+ + + Pending User Approvals + { - // Fineract sometimes omits resourceId in PUT responses — fall back to - // the user id we're editing so navigation never silently no-ops. - const targetId = response?.resourceId ?? this.userData.id; - const dialogRef = this.dialog.open(SuccessDialogComponent, { - data: { - title: 'User updated', - message: `Changes to ${this.userData.username} have been saved successfully.`, - buttonLabel: 'View user' - }, - panelClass: 'sd-panel', - disableClose: true - }); - dialogRef.afterClosed().subscribe(() => { - this.router.navigate( - [ - '../../', - targetId - ], - { relativeTo: this.route } - ); - }); + if (response?.rollbackTransaction) { + this.alertService.alert({ + type: 'Success', + message: 'User update request submitted successfully for approval.' + }); + this.router.navigate(['../../'], { relativeTo: this.route }); + } else { + // Fineract sometimes omits resourceId in PUT responses — fall back to + // the user id we're editing so navigation never silently no-ops. + const targetId = response?.resourceId ?? this.userData.id; + const dialogRef = this.dialog.open(SuccessDialogComponent, { + data: { + title: 'User updated', + message: `Changes to ${this.userData.username} have been saved successfully.`, + buttonLabel: 'View user' + }, + panelClass: 'sd-panel', + disableClose: true + }); + dialogRef.afterClosed().subscribe(() => { + this.router.navigate( + [ + '../../', + targetId + ], + { relativeTo: this.route } + ); + }); + } }, error: (err) => { console.error('[EditUser] update failed', err); diff --git a/src/app/users/users.service.ts b/src/app/users/users.service.ts index 32c395a6b..41cf1348b 100644 --- a/src/app/users/users.service.ts +++ b/src/app/users/users.service.ts @@ -87,4 +87,62 @@ export class UsersService { const httpParams = new HttpParams().set('officeId', officeId.toString()).set('status', 'all'); return this.http.get('/staff', { params: httpParams }); } + + /** + * @param {string} status Status filter (e.g. 'PENDING') + * @param {string} username Username filter + * @param {string} fromDate From date filter + * @param {string} toDate To date filter + * @param {number} offset Offset + * @param {number} limit Limit + * @returns {Observable} Pending users + */ + getPendingUsers( + status?: string, + username?: string, + fromDate?: string, + toDate?: string, + offset: number = 0, + limit: number = 20 + ): Observable { + let httpParams = new HttpParams().set('offset', offset.toString()).set('limit', limit.toString()); + if (status) { + httpParams = httpParams.set('status', status); + } + if (username) { + httpParams = httpParams.set('username', username); + } + if (fromDate) { + httpParams = httpParams.set('fromDate', fromDate); + } + if (toDate) { + httpParams = httpParams.set('toDate', toDate); + } + return this.http.get('/users/pending', { params: httpParams }); + } + + /** + * @param {string | number} id Pending request ID + * @returns {Observable} + */ + getPendingUser(id: string | number): Observable { + return this.http.get(`/users/pending/${id}`); + } + + /** + * @param {string | number} id Pending request ID + * @returns {Observable} + */ + approvePendingUser(id: string | number): Observable { + return this.http.post(`/users/pending/${id}/approve`, {}); + } + + /** + * @param {string | number} id Pending request ID + * @param {string} reason Rejection reason + * @returns {Observable} + */ + rejectPendingUser(id: string | number, reason: string): Observable { + return this.http.post(`/users/pending/${id}/reject`, { reason }); + } } diff --git a/src/app/users/view-user/view-user.component.ts b/src/app/users/view-user/view-user.component.ts index 0b224ee6e..00da13d08 100644 --- a/src/app/users/view-user/view-user.component.ts +++ b/src/app/users/view-user/view-user.component.ts @@ -15,6 +15,7 @@ import { MatIconButton } from '@angular/material/button'; /** Custom Services */ import { UsersService } from '../users.service'; +import { AlertService } from 'app/core/alert/alert.service'; /** Custom Components */ import { DeleteDialogComponent } from 'app/shared/delete-dialog/delete-dialog.component'; @@ -42,6 +43,7 @@ export class ViewUserComponent { private route = inject(ActivatedRoute); private router = inject(Router); private dialog = inject(MatDialog); + private alertService = inject(AlertService); /** User Data. */ userData: any; @@ -68,7 +70,13 @@ export class ViewUserComponent { }); deleteUserDialogRef.afterClosed().subscribe((response: any) => { if (response.delete) { - this.usersService.deleteUser(this.userData.id).subscribe(() => { + this.usersService.deleteUser(this.userData.id).subscribe((res: any) => { + if (res?.rollbackTransaction) { + this.alertService.alert({ + type: 'Success', + message: 'User deletion request submitted successfully for approval.' + }); + } this.router.navigate(['/appusers']); }); } diff --git a/src/assets/styles/_product-list.scss b/src/assets/styles/_product-list.scss index d1cfc0934..5f8cea6d6 100644 --- a/src/assets/styles/_product-list.scss +++ b/src/assets/styles/_product-list.scss @@ -357,6 +357,8 @@ &.num { text-align: right; + white-space: nowrap; + width: 1%; } } @@ -369,6 +371,8 @@ &.num { text-align: right; + white-space: nowrap; + width: 1%; } } } @@ -496,8 +500,10 @@ .pl-action-btn { display: inline-flex; align-items: center; + justify-content: center; gap: 5px; height: 30px; + min-width: 80px; padding: 0 12px; border-radius: 8px; border: 1px solid; From ce840d73d724f1858b39ed04147bfefc7dd2b4d9 Mon Sep 17 00:00:00 2001 From: Nitesh Yadav Date: Thu, 27 Aug 2026 18:46:44 +0530 Subject: [PATCH 12/64] feat(tasks,savings,cashier): facility charge mapping screen, chained approve+activate, popup diff dialog, cash/transfer-only teller flow Adds a Products > Facility Charge Mappings screen so each of the 16 facility toggles can be pointed at a real charge, backing the mapped-charge validation added on the backend. Bulk-approve on the pending accounts screen now chains straight into activation (matching the single-account approve flow) instead of leaving accounts sitting in Approved; the same screen's Confirm button no longer stays disabled on pre-filled but untouched dialogs. Add Charge is hidden on not-yet-active savings accounts, and Transfer Funds is disabled entirely pending a proper transfer flow. The pending savings-account edit "Proposed Changes" panel is now a MatDialog showing only the fields that actually changed, replacing the old always-open inline diff table. The teller transaction-entry Payment Type dropdown is filtered to Cash/Transfer only, mirroring the backend's retirement of the other system-defined payment types so share withdrawal and GL transfers now settle the same way real transfers do. Co-Authored-By: Claude Sonnet 5 --- .../transaction-entry.component.ts | 28 ++-- ...ility-charge-mapping-dialog.component.html | 53 +++++++ ...ility-charge-mapping-dialog.component.scss | 40 +++++ ...acility-charge-mapping-dialog.component.ts | 71 +++++++++ .../facility-charge-mappings.component.html | 76 +++++++++ .../facility-charge-mappings.component.scss | 35 +++++ .../facility-charge-mappings.component.ts | 144 +++++++++++++++++ .../facility-charge-mappings.resolver.ts | 32 ++++ src/app/products/products-routing.module.ts | 11 ++ src/app/products/products.component.html | 16 ++ src/app/products/products.module.ts | 4 + src/app/products/products.service.ts | 23 +++ .../savings-account-view.component.ts | 24 ++- .../savings-buttons.config.ts | 4 - .../pending-account.component.ts | 84 +++++++--- ...nding-savings-account-edits.component.html | 70 +-------- ...nding-savings-account-edits.component.scss | 73 --------- ...pending-savings-account-edits.component.ts | 46 ++++-- ...account-edit-compare-dialog.component.html | 73 +++++++++ ...account-edit-compare-dialog.component.scss | 132 ++++++++++++++++ ...s-account-edit-compare-dialog.component.ts | 145 ++++++++++++++++++ src/app/tasks/tasks.service.ts | 32 ++++ src/assets/env.js | 2 +- src/environments/.env.ts | 2 +- 24 files changed, 1017 insertions(+), 203 deletions(-) create mode 100644 src/app/products/facility-charge-mappings/edit-facility-charge-mapping-dialog/edit-facility-charge-mapping-dialog.component.html create mode 100644 src/app/products/facility-charge-mappings/edit-facility-charge-mapping-dialog/edit-facility-charge-mapping-dialog.component.scss create mode 100644 src/app/products/facility-charge-mappings/edit-facility-charge-mapping-dialog/edit-facility-charge-mapping-dialog.component.ts create mode 100644 src/app/products/facility-charge-mappings/facility-charge-mappings.component.html create mode 100644 src/app/products/facility-charge-mappings/facility-charge-mappings.component.scss create mode 100644 src/app/products/facility-charge-mappings/facility-charge-mappings.component.ts create mode 100644 src/app/products/facility-charge-mappings/facility-charge-mappings.resolver.ts create mode 100644 src/app/tasks/checker-inbox-and-tasks-tabs/pending-savings-account-edits/savings-account-edit-compare-dialog/savings-account-edit-compare-dialog.component.html create mode 100644 src/app/tasks/checker-inbox-and-tasks-tabs/pending-savings-account-edits/savings-account-edit-compare-dialog/savings-account-edit-compare-dialog.component.scss create mode 100644 src/app/tasks/checker-inbox-and-tasks-tabs/pending-savings-account-edits/savings-account-edit-compare-dialog/savings-account-edit-compare-dialog.component.ts diff --git a/src/app/cashier/transaction-entry/transaction-entry.component.ts b/src/app/cashier/transaction-entry/transaction-entry.component.ts index 29522bbcc..d9ae66adb 100644 --- a/src/app/cashier/transaction-entry/transaction-entry.component.ts +++ b/src/app/cashier/transaction-entry/transaction-entry.component.ts @@ -778,23 +778,33 @@ export class TransactionEntryComponent implements OnInit, OnDestroy { loadingAccount = false; accountError = ''; - /** Share redemption settlement is always a book-transfer to the linked savings account — never a - * cash handover. A Share (SHR) row's own TXN TP. never offers a cash payment type, and neither - * does its paired CR (savings) row in "Enter Bank Transfer" mode — that CR row's payment type is - * the one that actually governs how the settlement credit posts (see submitShareRedemptionTransfer()). - * Every other account type/mode keeps the full, unfiltered list. */ + /** + * A teller only ever picks Cash or Transfer — which specific internal settlement it actually is + * (a share redemption, a dividend payout, a GL-to-GL or GL-to-bank book entry) is business logic + * the backend already threads through from whichever payment type the clerk picked here (see + * ClerkShareWithdrawalRequestApiResource#creditLinkedSavingsAccount on the backend), not a separate + * teller-facing choice. System-defined payment types (Share Redemption Transfer, Dividend Claim + * Settlement, the loan repayment-adjustment types, ...) exist purely as internal audit labels and + * are never offered here. + * + * Share redemption settlement is always a book-transfer to the linked savings account — never a + * cash handover. A Share (SHR) row's own TXN TP. never offers a cash payment type, and neither + * does its paired CR (savings) row in "Enter Bank Transfer" mode — that CR row's payment type is + * the one that actually governs how the settlement credit posts (see submitShareRedemptionTransfer()). + */ paymentTypesForRow(row: TxnRow): any[] { + const tellerFacing = this.paymentTypes.filter((pt: any) => !pt.isSystemDefined); if (row.acTyp === 'SHR') { - return this.paymentTypes.filter((pt: any) => !pt.isCashPayment); + return tellerFacing.filter((pt: any) => !pt.isCashPayment); } const isShareSettlementCredit = row.typ === 'Enter Bank Transfer' && row.dbCr === 'Cr' && this.rows.some((r) => r.typ === 'Enter Bank Transfer' && r.dbCr === 'Dr' && r.acTyp === 'SHR'); if (isShareSettlementCredit) { - return this.paymentTypes.filter((pt: any) => !pt.isCashPayment); + return tellerFacing.filter((pt: any) => !pt.isCashPayment); } - return this.paymentTypes; + return tellerFacing; } // Per-row account id/name map: acNo → Fineract id/name (used for multi-row disbursement) @@ -1563,7 +1573,7 @@ export class TransactionEntryComponent implements OnInit, OnDestroy { if (settlementPaymentType?.isCashPayment) { this.showAlert('error', 'Cash Not Allowed for Share Transactions', [ 'Share redemption settlement is always a book-transfer to the linked savings account, never cash.', - 'Select a non-cash Payment Type on the CR row (e.g. Transfer or Share Redemption Transfer).' + 'Select Transfer as the Payment Type on the CR row.' ]); return; } diff --git a/src/app/products/facility-charge-mappings/edit-facility-charge-mapping-dialog/edit-facility-charge-mapping-dialog.component.html b/src/app/products/facility-charge-mappings/edit-facility-charge-mapping-dialog/edit-facility-charge-mapping-dialog.component.html new file mode 100644 index 000000000..4655ebf8b --- /dev/null +++ b/src/app/products/facility-charge-mappings/edit-facility-charge-mapping-dialog/edit-facility-charge-mapping-dialog.component.html @@ -0,0 +1,53 @@ + + +

{{ 'labels.heading.Edit Facility Charge Mapping' | translate }}

+ + +
+ + {{ 'labels.inputs.Charge' | translate }} + + @for (charge of data.chargeOptions; track charge.id) { + {{ charge.name }} ({{ charge.amount }}) + } + + @if (editMappingForm.controls.chargeId.hasError('required')) { + + {{ 'labels.inputs.Charge' | translate }} {{ 'labels.commons.is' | translate }} + {{ 'labels.commons.required' | translate }} + + } + + +
+ {{ 'labels.inputs.Amount' | translate }} + + {{ selectedChargeAmount !== null ? selectedChargeAmount : '-' }} + +
+ +
+ + {{ 'labels.inputs.Active' | translate }} + +
+
+
+ + + + + diff --git a/src/app/products/facility-charge-mappings/edit-facility-charge-mapping-dialog/edit-facility-charge-mapping-dialog.component.scss b/src/app/products/facility-charge-mappings/edit-facility-charge-mapping-dialog/edit-facility-charge-mapping-dialog.component.scss new file mode 100644 index 000000000..68c54d0dd --- /dev/null +++ b/src/app/products/facility-charge-mappings/edit-facility-charge-mapping-dialog/edit-facility-charge-mapping-dialog.component.scss @@ -0,0 +1,40 @@ +/** + * 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/. + */ +.efcm-form { + display: flex; + flex-direction: column; + gap: 8px; + min-width: 340px; +} + +.efcm-field { + width: 100%; +} + +.efcm-checkbox-row { + margin: 4px 0 12px; +} + +.efcm-amount-readonly { + display: flex; + align-items: baseline; + gap: 8px; + padding: 4px 0 12px; + color: rgb(0 0 0 / 60%); + font-size: 13px; + + &__label { + font-weight: 500; + } + + &__value { + font-size: 15px; + font-weight: 600; + color: rgb(0 0 0 / 87%); + } +} diff --git a/src/app/products/facility-charge-mappings/edit-facility-charge-mapping-dialog/edit-facility-charge-mapping-dialog.component.ts b/src/app/products/facility-charge-mappings/edit-facility-charge-mapping-dialog/edit-facility-charge-mapping-dialog.component.ts new file mode 100644 index 000000000..5e00124a3 --- /dev/null +++ b/src/app/products/facility-charge-mappings/edit-facility-charge-mapping-dialog/edit-facility-charge-mapping-dialog.component.ts @@ -0,0 +1,71 @@ +/** + * 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 { + MAT_DIALOG_DATA, + MatDialogRef, + MatDialogTitle, + MatDialogContent, + MatDialogActions, + MatDialogClose +} from '@angular/material/dialog'; +import { UntypedFormGroup, UntypedFormBuilder, Validators } from '@angular/forms'; +import { CdkScrollable } from '@angular/cdk/scrolling'; +import { STANDALONE_SHARED_IMPORTS } from 'app/standalone-shared.module'; + +/** + * Lets an admin change which charge a single facility (e.g. ATM_FACILITY) maps to. The amount charged is + * always the selected charge's own, live amount (see FacilityChargeAutoApplyService, which reads + * charge.getAmount() when applying it) -- shown here read-only for reference, not independently editable. + * To change the amount, edit the Charge itself under Products > Charges. + */ +@Component({ + selector: 'mifosx-edit-facility-charge-mapping-dialog', + templateUrl: './edit-facility-charge-mapping-dialog.component.html', + styleUrls: ['./edit-facility-charge-mapping-dialog.component.scss'], + imports: [ + ...STANDALONE_SHARED_IMPORTS, + MatDialogTitle, + CdkScrollable, + MatDialogContent, + MatDialogActions, + MatDialogClose + ] +}) +export class EditFacilityChargeMappingDialogComponent implements OnInit { + dialogRef = inject>(MatDialogRef); + private formBuilder = inject(UntypedFormBuilder); + data = inject(MAT_DIALOG_DATA); + + editMappingForm: UntypedFormGroup; + + ngOnInit(): void { + this.editMappingForm = this.formBuilder.group({ + chargeId: [ + this.data.mapping.chargeId, + Validators.required + ], + // A facility with no charge mapped yet is being configured for the first time here -- default + // Active to checked so picking a charge and submitting is enough to make it usable immediately. + isActive: [this.data.mapping.chargeId ? this.data.mapping.isActive : true] + }); + } + + /** Amount of whichever charge is currently selected in the form -- updates live as the selection changes. */ + get selectedChargeAmount(): number | null { + const selectedId = this.editMappingForm?.get('chargeId')?.value; + const selected = (this.data.chargeOptions || []).find((charge: any) => charge.id === selectedId); + return selected ? selected.amount : null; + } + + submit(): void { + this.dialogRef.close(this.editMappingForm.value); + } +} diff --git a/src/app/products/facility-charge-mappings/facility-charge-mappings.component.html b/src/app/products/facility-charge-mappings/facility-charge-mappings.component.html new file mode 100644 index 000000000..5dab50d9c --- /dev/null +++ b/src/app/products/facility-charge-mappings/facility-charge-mappings.component.html @@ -0,0 +1,76 @@ + + +
+

{{ 'labels.heading.Facility Charge Mappings' | translate }}

+

+ {{ + 'labels.text.Which charge is applied when a facility (ATM, UPI, Cheque Book, ...) is enabled on an account' + | translate + }} +

+
+ +
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + +
{{ 'labels.inputs.Facility' | translate }}{{ facilityLabel(mapping.facilityType) }}{{ 'labels.inputs.Mapped Charge' | translate }} + {{ mapping.chargeName || '—' }} + @if (mapping.chargeId && !mapping.chargeAvailable) { + + + {{ 'labels.text.Deleted' | translate }} + + } + {{ 'labels.inputs.Amount' | translate }}{{ mapping.defaultAmount !== null ? mapping.defaultAmount : '—' }}{{ 'labels.inputs.Active' | translate }} + {{ (mapping.isActive ? 'labels.commons.Yes' : 'labels.commons.No') | translate }} + {{ 'labels.inputs.Actions' | translate }} + +
+ + +
diff --git a/src/app/products/facility-charge-mappings/facility-charge-mappings.component.scss b/src/app/products/facility-charge-mappings/facility-charge-mappings.component.scss new file mode 100644 index 000000000..72564daa7 --- /dev/null +++ b/src/app/products/facility-charge-mappings/facility-charge-mappings.component.scss @@ -0,0 +1,35 @@ +/** + * 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/. + */ +.fcm-header { + margin-bottom: 20px; + + h1 { + margin: 0 0 4px; + font-size: 20px; + font-weight: 600; + } + + &__sub { + margin: 0; + color: rgb(0 0 0 / 60%); + font-size: 13px; + } +} + +.fcm-broken-badge { + display: inline-flex; + align-items: center; + gap: 4px; + margin-left: 8px; + padding: 2px 8px; + border-radius: 10px; + background: rgb(211 47 47 / 10%); + color: #d32f2f; + font-size: 11px; + font-weight: 600; +} diff --git a/src/app/products/facility-charge-mappings/facility-charge-mappings.component.ts b/src/app/products/facility-charge-mappings/facility-charge-mappings.component.ts new file mode 100644 index 000000000..185350bc1 --- /dev/null +++ b/src/app/products/facility-charge-mappings/facility-charge-mappings.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/. + */ + +/** Angular Imports */ +import { Component, OnInit, ViewChild, inject } from '@angular/core'; +import { MatPaginator } from '@angular/material/paginator'; +import { MatSort, MatSortHeader } from '@angular/material/sort'; +import { + MatTableDataSource, + MatTable, + MatColumnDef, + MatHeaderCellDef, + MatHeaderCell, + MatCellDef, + MatCell, + MatHeaderRowDef, + MatHeaderRow, + MatRowDef, + MatRow +} from '@angular/material/table'; +import { ActivatedRoute } from '@angular/router'; +import { MatDialog } from '@angular/material/dialog'; +import { MatTooltip } from '@angular/material/tooltip'; + +/** rxjs Imports */ +import { EMPTY, Observable } from 'rxjs'; +import { switchMap } from 'rxjs/operators'; + +/** Custom Services */ +import { ProductsService } from '../products.service'; +import { AlertService } from 'app/core/alert/alert.service'; +import { EditFacilityChargeMappingDialogComponent } from './edit-facility-charge-mapping-dialog/edit-facility-charge-mapping-dialog.component'; +import { STANDALONE_SHARED_IMPORTS } from 'app/standalone-shared.module'; + +/** + * Lists which charge is applied when each of the 16 named account facilities (ATM Facility, UPI + * Facility, Cheque Book, ...) is enabled on a savings/current account, and lets an admin change it. + */ +@Component({ + selector: 'mifosx-facility-charge-mappings', + templateUrl: './facility-charge-mappings.component.html', + styleUrls: ['./facility-charge-mappings.component.scss'], + imports: [ + ...STANDALONE_SHARED_IMPORTS, + MatTable, + MatSort, + MatColumnDef, + MatHeaderCellDef, + MatHeaderCell, + MatSortHeader, + MatCellDef, + MatCell, + MatHeaderRowDef, + MatHeaderRow, + MatRowDef, + MatRow, + MatPaginator, + MatTooltip + ] +}) +export class FacilityChargeMappingsComponent implements OnInit { + private route = inject(ActivatedRoute); + private productsService = inject(ProductsService); + private dialog = inject(MatDialog); + private alertService = inject(AlertService); + + facilityChargeMappingsData: any[] = []; + displayedColumns: string[] = [ + 'facilityType', + 'chargeName', + 'defaultAmount', + 'isActive', + 'actions' + ]; + dataSource: MatTableDataSource; + + @ViewChild(MatPaginator, { static: true }) paginator: MatPaginator; + @ViewChild(MatSort, { static: true }) sort: MatSort; + + constructor() { + this.route.data.subscribe((data: { facilityChargeMappings: any }) => { + this.facilityChargeMappingsData = data.facilityChargeMappings; + }); + } + + ngOnInit() { + this.setFacilityChargeMappings(); + } + + setFacilityChargeMappings() { + this.dataSource = new MatTableDataSource(this.facilityChargeMappingsData); + this.dataSource.paginator = this.paginator; + this.dataSource.sort = this.sort; + } + + /** Human-readable label for a facility_type code, e.g. ATM_FACILITY -> ATM Facility. */ + facilityLabel(facilityType: string): string { + if (!facilityType) { + return ''; + } + return facilityType + .toLowerCase() + .split('_') + .map((word) => word.charAt(0).toUpperCase() + word.slice(1)) + .join(' '); + } + + editMapping(mapping: any): void { + this.productsService + .getFacilityChargeMappingsTemplate() + .pipe( + switchMap((template: any) => + this.dialog + .open(EditFacilityChargeMappingDialogComponent, { + data: { mapping, chargeOptions: template.chargeOptions || [] } + }) + .afterClosed() + ), + switchMap((formValue: any): Observable => { + if (!formValue) { + return EMPTY; + } + return this.productsService.updateFacilityChargeMapping(mapping.id, formValue); + }) + ) + .subscribe((result: any) => { + if (result) { + this.alertService.alert({ + type: 'Facility Charge Mapping', + message: `${this.facilityLabel(mapping.facilityType)} mapping updated successfully.` + }); + this.productsService.getFacilityChargeMappings().subscribe((mappings: any) => { + this.facilityChargeMappingsData = mappings; + this.setFacilityChargeMappings(); + }); + } + }); + } +} diff --git a/src/app/products/facility-charge-mappings/facility-charge-mappings.resolver.ts b/src/app/products/facility-charge-mappings/facility-charge-mappings.resolver.ts new file mode 100644 index 000000000..cafbd6226 --- /dev/null +++ b/src/app/products/facility-charge-mappings/facility-charge-mappings.resolver.ts @@ -0,0 +1,32 @@ +/** + * Copyright since 2025 Mifos Initiative + * + * This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. + */ + +/** Angular Imports */ +import { Injectable, inject } from '@angular/core'; + +/** rxjs Imports */ +import { Observable } from 'rxjs'; + +/** Custom Services */ +import { ProductsService } from '../products.service'; + +/** + * Facility charge mappings data resolver. + */ +@Injectable() +export class FacilityChargeMappingsResolver { + private productsService = inject(ProductsService); + + /** + * Returns all 16 facility -> charge mappings. + * @returns {Observable} + */ + resolve(): Observable { + return this.productsService.getFacilityChargeMappings(); + } +} diff --git a/src/app/products/products-routing.module.ts b/src/app/products/products-routing.module.ts index d724358fa..cacec80f1 100644 --- a/src/app/products/products-routing.module.ts +++ b/src/app/products/products-routing.module.ts @@ -33,6 +33,7 @@ import { ViewRecurringDepositProductComponent } from './recurring-deposit-produc import { ChargesComponent } from './charges/charges.component'; import { ViewChargeComponent } from './charges/view-charge/view-charge.component'; import { CreateChargeComponent } from './charges/create-charge/create-charge.component'; +import { FacilityChargeMappingsComponent } from './facility-charge-mappings/facility-charge-mappings.component'; import { FixedDepositProductsComponent } from './fixed-deposit-products/fixed-deposit-products.component'; import { CreateFixedDepositProductComponent } from './fixed-deposit-products/create-fixed-deposit-product/create-fixed-deposit-product.component'; import { ProductsMixComponent } from './products-mix/products-mix.component'; @@ -76,6 +77,7 @@ import { ShareProductAndTemplateResolver } from './share-products/edit-share-pro import { RecurringDepositProductsResolver } from './recurring-deposit-products/recurring-deposit-products.resolver'; import { RecurringDepositProductResolver } from './recurring-deposit-products/recurring-deposit-product.resolver'; import { ChargesResolver } from './charges/charges.resolver'; +import { FacilityChargeMappingsResolver } from './facility-charge-mappings/facility-charge-mappings.resolver'; import { ChargeResolver } from './charges/charge.resolver'; import { ChargesTemplateResolver } from './charges/charges-template.resolver'; import { FixedDepositProductsResolver } from './fixed-deposit-products/fixed-deposit-products.resolver'; @@ -937,6 +939,14 @@ const routes: Routes = [ } ] }, + { + path: 'facility-charge-mappings', + data: { title: 'Facility Charge Mappings', breadcrumb: 'Facility Charge Mappings' }, + component: FacilityChargeMappingsComponent, + resolve: { + facilityChargeMappings: FacilityChargeMappingsResolver + } + }, { path: 'collaterals', data: { title: 'Collaterals', breadcrumb: 'Collaterals' }, @@ -1015,6 +1025,7 @@ const routes: Routes = [ ChargeResolver, ChargesTemplateAndResolver, ChargesTemplateResolver, + FacilityChargeMappingsResolver, FixedDepositProductsResolver, FixedDepositProductsTemplateResolver, ProductsMixResolver, diff --git a/src/app/products/products.component.html b/src/app/products/products.component.html index 3345cdb55..f07c81fe7 100644 --- a/src/app/products/products.component.html +++ b/src/app/products/products.component.html @@ -151,6 +151,22 @@
+ +
+ +
+
+

{{ 'labels.heading.Facility Charge Mappings' | translate }}

+

+ {{ + 'labels.text.Choose which charge applies when an account facility (ATM, UPI, Cheque Book, ...) is enabled' + | translate + }} +

+
+ +
+
diff --git a/src/app/products/products.module.ts b/src/app/products/products.module.ts index 27675d51e..5828bd3c7 100644 --- a/src/app/products/products.module.ts +++ b/src/app/products/products.module.ts @@ -30,6 +30,8 @@ import { CreateLoanProductComponent } from './loan-products/create-loan-product/ import { ManageTaxConfigurationsComponent } from './manage-tax-configurations/manage-tax-configurations.component'; import { RecurringDepositProductsComponent } from './recurring-deposit-products/recurring-deposit-products.component'; import { ChargesComponent } from './charges/charges.component'; +import { FacilityChargeMappingsComponent } from './facility-charge-mappings/facility-charge-mappings.component'; +import { EditFacilityChargeMappingDialogComponent } from './facility-charge-mappings/edit-facility-charge-mapping-dialog/edit-facility-charge-mapping-dialog.component'; import { ViewChargeComponent } from './charges/view-charge/view-charge.component'; import { FixedDepositProductsComponent } from './fixed-deposit-products/fixed-deposit-products.component'; import { ProductsMixComponent } from './products-mix/products-mix.component'; @@ -159,6 +161,8 @@ import { AdvancedAccountingMappingRuleComponent } from './loan-products/loan-pro RecurringDepositProductsComponent, ChargesComponent, ViewChargeComponent, + FacilityChargeMappingsComponent, + EditFacilityChargeMappingDialogComponent, FixedDepositProductsComponent, ManageTaxComponentsComponent, ProductsMixComponent, diff --git a/src/app/products/products.service.ts b/src/app/products/products.service.ts index f29376e79..c15139d14 100644 --- a/src/app/products/products.service.ts +++ b/src/app/products/products.service.ts @@ -347,6 +347,29 @@ export class ProductsService { return this.http.get('/charges/template'); } + /** + * @returns {Observable} All 16 facility -> charge mappings. + */ + getFacilityChargeMappings(): Observable { + return this.http.get('/facilitychargemappings'); + } + + /** + * @returns {Observable} Charge options available to map a facility to. + */ + getFacilityChargeMappingsTemplate(): Observable { + return this.http.get('/facilitychargemappings/template'); + } + + /** + * @param {string} mappingId Facility charge mapping ID to update. + * @param {any} mapping New chargeId (and optionally defaultAmount/isActive). + * @returns {Observable} + */ + updateFacilityChargeMapping(mappingId: string, mapping: any): Observable { + return this.http.put(`/facilitychargemappings/${mappingId}`, mapping); + } + /** * @param {string} chargeId Charge ID of charge. * @returns {Observable} Charge. diff --git a/src/app/savings/savings-account-view/savings-account-view.component.ts b/src/app/savings/savings-account-view/savings-account-view.component.ts index 2d0b9f17b..b9e92ffdf 100644 --- a/src/app/savings/savings-account-view/savings-account-view.component.ts +++ b/src/app/savings/savings-account-view/savings-account-view.component.ts @@ -28,6 +28,7 @@ import { AccountsService } from 'app/accounts/accounts.service'; import { SettingsService } from 'app/settings/settings.service'; import { Dates } from 'app/core/utils/dates'; import { AuthenticationService } from '../../core/authentication/authentication.service'; +import { AlertService } from 'app/core/alert/alert.service'; /** Environment Configuration */ import { environment } from '../../../environments/environment'; @@ -85,6 +86,7 @@ export class SavingsAccountViewComponent implements OnInit { private settingsService = inject(SettingsService); private dateUtils = inject(Dates); private authenticationService = inject(AuthenticationService); + private alertService = inject(AlertService); dialog = inject(MatDialog); /** Savings Account Data */ @@ -225,12 +227,8 @@ export class SavingsAccountViewComponent implements OnInit { this.isActive = status === 'Active'; const subStatus = this.savingsAccountData.subStatus; this.buttonConfig = new SavingsButtonsConfiguration(status, subStatus); - if (this.savingsAccountData.clientId) { - this.buttonConfig.addOption({ - name: 'Transfer Funds', - taskPermissionName: 'CREATE_ACCOUNTTRANSFER' - }); - } + // Transfer Funds is temporarily disabled for savings accounts (pending and active alike) -- + // deliberately not added to the menu here; see the guarded 'Transfer Funds' switch case below. if (this.savingsAccountData.externalId && environment.mifosInterbankTransfersEnabled) { this.buttonConfig.addOption({ name: 'Interbank Transfer', @@ -347,15 +345,11 @@ export class SavingsAccountViewComponent implements OnInit { this.disableWithHoldTax(); break; case 'Transfer Funds': - const queryParams: any = { - interbank: false, - savingsId: this.savingsAccountData.id, - accountType: 'fromsavings' - }; - this.router.navigate(['transfer-funds/make-account-transfer'], { - relativeTo: this.route, - queryParams: queryParams, - state: { balance: this.savingsAccountData.summary.availableBalance } + // Temporarily disabled for savings accounts -- blocked here too, not just left out of the + // menu, so a stale menu render or direct invocation can't reach the transfer screen. + this.alertService.alert({ + type: 'Transfer Funds', + message: 'Transfer Funds is temporarily unavailable for savings accounts.' }); break; case 'Interbank Transfer': { diff --git a/src/app/savings/savings-account-view/savings-buttons.config.ts b/src/app/savings/savings-account-view/savings-buttons.config.ts index 930167fb5..2caaf3f0e 100644 --- a/src/app/savings/savings-account-view/savings-buttons.config.ts +++ b/src/app/savings/savings-account-view/savings-buttons.config.ts @@ -189,10 +189,6 @@ export class SavingsButtonsConfiguration { name: 'Withdrawn by Client', taskPermissionName: 'WITHDRAW_SAVINGSACCOUNT' }, - { - name: 'Add Charge', - taskPermissionName: 'CREATE_SAVINGSACCOUNTCHARGE' - }, { name: 'Delete', taskPermissionName: 'DELETE_SAVINGSACCOUNT' diff --git a/src/app/tasks/checker-inbox-and-tasks-tabs/pending-account/pending-account.component.ts b/src/app/tasks/checker-inbox-and-tasks-tabs/pending-account/pending-account.component.ts index 8fa43a3fe..aea0233d3 100644 --- a/src/app/tasks/checker-inbox-and-tasks-tabs/pending-account/pending-account.component.ts +++ b/src/app/tasks/checker-inbox-and-tasks-tabs/pending-account/pending-account.component.ts @@ -9,6 +9,7 @@ import { Component, inject } from '@angular/core'; import { ActivatedRoute, Router } from '@angular/router'; import { SelectionModel } from '@angular/cdk/collections'; +import { switchMap } from 'rxjs/operators'; import { MatTableDataSource, MatTable, @@ -141,7 +142,8 @@ export class PendingAccountComponent { data: { title: 'Enter Approval Date', layout: { addButtonText: 'Confirm' }, - formfields + formfields, + pristine: false } }); dialogRef.afterClosed().subscribe((response: any) => { @@ -166,7 +168,8 @@ export class PendingAccountComponent { data: { title: 'Enter Rejection Date', layout: { addButtonText: 'Confirm' }, - formfields + formfields, + pristine: false } }); dialogRef.afterClosed().subscribe((response: any) => { @@ -183,7 +186,7 @@ export class PendingAccountComponent { const payload = { rejectedOnDate, dateFormat, locale }; const selected = this.selection.selected; - let done = 0; + let settled = 0; selected.forEach((account: any) => { const depositTypeId = account.depositType?.id; let reject$; @@ -196,39 +199,72 @@ export class PendingAccountComponent { } else { reject$ = this.tasksService.rejectSavingsAccount(account.id, payload); } - reject$.subscribe(() => { - done++; - if (done === selected.length) { - this.reload(); + reject$.subscribe({ + next: () => { + settled++; + if (settled === selected.length) { + this.reload(); + } + }, + error: () => { + settled++; + if (settled === selected.length) { + this.reload(); + } } }); }); } + /** + * Approves an account, then immediately activates it on the same date -- Approve and Activate + * are still two separate Fineract commands underneath (each shows up in the audit trail), but + * the checker only sees and clicks through the one bulk action, matching what happens when + * approving from inside an individual account (see e.g. ApproveSavingsAccountComponent). + */ + private approveAndActivate$(account: any, approvedOnDate: string, dateFormat: string, locale: string) { + const approvePayload = { approvedOnDate, dateFormat, locale }; + const activatePayload = { activatedOnDate: approvedOnDate, dateFormat, locale }; + const depositTypeId = account.depositType?.id; + + let approve$; + let activate$; + if (account.accountType === 'Current') { + approve$ = this.tasksService.approveCurrentAccount(account.id, approvePayload); + activate$ = () => this.tasksService.activateCurrentAccount(account.id, activatePayload); + } else if (depositTypeId === 200) { + approve$ = this.tasksService.approveFDAccount(account.id, approvePayload); + activate$ = () => this.tasksService.activateFDAccount(account.id, activatePayload); + } else if (depositTypeId === 300) { + approve$ = this.tasksService.approveRDAccount(account.id, approvePayload); + activate$ = () => this.tasksService.activateRDAccount(account.id, activatePayload); + } else { + approve$ = this.tasksService.approveSavingsAccount(account.id, approvePayload); + activate$ = () => this.tasksService.activateSavingsAccount(account.id, activatePayload); + } + return approve$.pipe(switchMap(activate$)); + } + private bulkApprove(submittedData: any) { const dateFormat = this.settingsService.dateFormat; const locale = this.settingsService.language.code; const approvedOnDate = this.dateUtils.formatDate(submittedData.value.approvedOnDate, dateFormat); - const payload = { approvedOnDate, dateFormat, locale }; const selected = this.selection.selected; - let done = 0; + let settled = 0; selected.forEach((account: any) => { - const depositTypeId = account.depositType?.id; - let approve$; - if (account.accountType === 'Current') { - approve$ = this.tasksService.approveCurrentAccount(account.id, payload); - } else if (depositTypeId === 200) { - approve$ = this.tasksService.approveFDAccount(account.id, payload); - } else if (depositTypeId === 300) { - approve$ = this.tasksService.approveRDAccount(account.id, payload); - } else { - approve$ = this.tasksService.approveSavingsAccount(account.id, payload); - } - approve$.subscribe(() => { - done++; - if (done === selected.length) { - this.reload(); + this.approveAndActivate$(account, approvedOnDate, dateFormat, locale).subscribe({ + next: () => { + settled++; + if (settled === selected.length) { + this.reload(); + } + }, + error: () => { + settled++; + if (settled === selected.length) { + this.reload(); + } } }); }); diff --git a/src/app/tasks/checker-inbox-and-tasks-tabs/pending-savings-account-edits/pending-savings-account-edits.component.html b/src/app/tasks/checker-inbox-and-tasks-tabs/pending-savings-account-edits/pending-savings-account-edits.component.html index d9aaafb86..e11ff7345 100644 --- a/src/app/tasks/checker-inbox-and-tasks-tabs/pending-savings-account-edits/pending-savings-account-edits.component.html +++ b/src/app/tasks/checker-inbox-and-tasks-tabs/pending-savings-account-edits/pending-savings-account-edits.component.html @@ -68,8 +68,13 @@
Actions -
- - @if (selectedEdit) { -
-
-

Proposed Changes — Account "{{ selectedEdit.accountNo }}"

- -
- - @if (selectedEdit.facilityConfigSubmitted) { -
-

Digital & Account Features

- - - - - - - - - @for (row of entries(selectedEdit.proposedFacilityConfig); track row.key) { - - - - - } - -
FeatureProposed Value
{{ row.key }}{{ row.value }}
-
- } - - @if (selectedEdit.nomineeSubmitted) { -
-

Nominee Change

- - - - - - - - - @for (row of entries(selectedEdit.proposedNominee); track row.key) { - - - - - } - -
FieldProposed Value
{{ row.key }}{{ row.value }}
-
- } - - @if (!selectedEdit.facilityConfigSubmitted && !selectedEdit.nomineeSubmitted) { -

No proposed changes recorded on this edit.

- } - - @if (selectedEdit.rejectReason) { -

Reject Reason: {{ selectedEdit.rejectReason }}

- } -
- } } @if (!showData) { diff --git a/src/app/tasks/checker-inbox-and-tasks-tabs/pending-savings-account-edits/pending-savings-account-edits.component.scss b/src/app/tasks/checker-inbox-and-tasks-tabs/pending-savings-account-edits/pending-savings-account-edits.component.scss index 81f4fff23..8645ecba1 100644 --- a/src/app/tasks/checker-inbox-and-tasks-tabs/pending-savings-account-edits/pending-savings-account-edits.component.scss +++ b/src/app/tasks/checker-inbox-and-tasks-tabs/pending-savings-account-edits/pending-savings-account-edits.component.scss @@ -107,79 +107,6 @@ $text-muted: #64748b; } } -.psae-diff-panel { - margin-top: 20px; - border: 1px solid $border; - border-radius: 12px; - background: #fff; - padding: 16px; -} - -.psae-diff-header { - display: flex; - align-items: center; - justify-content: space-between; - - h3 { - margin: 0; - font-size: 14px; - font-weight: 700; - } -} - -.psae-diff-block { - margin-top: 16px; - - h4 { - margin: 0 0 6px; - font-size: 12.5px; - font-weight: 700; - color: #0d1b2a; - text-transform: uppercase; - letter-spacing: 0.03em; - } -} - -.psae-diff-loading { - color: $text-muted; - font-size: 13px; -} - -.psae-diff-table { - width: 100%; - border-collapse: collapse; - margin-top: 4px; - font-size: 12.5px; - - th, - td { - text-align: left; - padding: 8px 10px; - border-bottom: 1px solid $border; - } - - th { - color: $text-muted; - font-weight: 600; - } -} - -.psae-diff-label { - font-weight: 600; - color: #0d1b2a; -} - -.psae-diff-proposed { - color: #15803d; - font-weight: 600; -} - -.psae-reject-reason { - margin-top: 14px; - font-size: 12.5px; - color: #b91c1c; -} - .empty-state { display: flex; flex-direction: column; diff --git a/src/app/tasks/checker-inbox-and-tasks-tabs/pending-savings-account-edits/pending-savings-account-edits.component.ts b/src/app/tasks/checker-inbox-and-tasks-tabs/pending-savings-account-edits/pending-savings-account-edits.component.ts index 78bd3c1fa..e1f307f0c 100644 --- a/src/app/tasks/checker-inbox-and-tasks-tabs/pending-savings-account-edits/pending-savings-account-edits.component.ts +++ b/src/app/tasks/checker-inbox-and-tasks-tabs/pending-savings-account-edits/pending-savings-account-edits.component.ts @@ -32,6 +32,7 @@ import { ConfirmationDialogComponent } from 'app/shared/confirmation-dialog/conf import { FormDialogComponent } from 'app/shared/form-dialog/form-dialog.component'; import { FormfieldBase } from 'app/shared/form-dialog/formfield/model/formfield-base'; import { InputBase } from 'app/shared/form-dialog/formfield/model/input-base'; +import { SavingsAccountEditCompareDialogComponent } from './savings-account-edit-compare-dialog/savings-account-edit-compare-dialog.component'; import { STANDALONE_SHARED_IMPORTS } from 'app/standalone-shared.module'; /** @@ -79,7 +80,8 @@ export class PendingSavingsAccountEditsComponent { 'actions' ]; - selectedEdit: any = null; + /** Set only while the current account is being fetched, so the button can show progress. */ + loadingCompareFor: any = null; constructor() { this.route.data.subscribe((data: { pendingSavingsAccountEditsData: any; pendingCurrentAccountEditsData: any }) => { @@ -120,18 +122,37 @@ export class PendingSavingsAccountEditsComponent { return isNaN(d.getTime()) ? String(date) : d.toLocaleString('en-IN'); } - /** Readable list of "key: value" pairs for the proposed facility/nominee changes. */ - entries(obj: any): { key: string; value: any }[] { - if (!obj) return []; - return Object.keys(obj).map((key) => ({ key, value: obj[key] })); - } - + /** + * Fetches the account's current Digital & Account Feature config so the popup can show it next to + * what's proposed, then opens the compare dialog. The maker's edit form already submits only the + * toggles it actually changed (see EditSavingsAccountFeaturesComponent#buildFacilityDiff), so + * proposedFacilityConfig only ever carries changed fields -- the dialog still diffs against this + * current config defensively, so an unchanged value can never appear in the popup. + */ viewCompare(edit: any): void { - this.selectedEdit = this.selectedEdit === edit ? null : edit; - } - - closeCompare(): void { - this.selectedEdit = null; + this.loadingCompareFor = edit; + const isCurrent = edit.accountType === 'Current'; + const account$ = isCurrent + ? this.currentAccountsService.getCurrentAccountAndTemplate(edit.currentAccountId, false) + : this.savingsService.getSavingsAccountAndTemplate(edit.savingsAccountId, false); + account$.subscribe({ + next: (account: any) => { + this.loadingCompareFor = null; + this.dialog.open(SavingsAccountEditCompareDialogComponent, { + width: '760px', + data: { + accountNo: edit.accountNo, + currentFacilityConfig: account?.accountFacilityConfig ?? {}, + proposedFacilityConfig: edit.proposedFacilityConfig ?? {}, + proposedNominee: edit.proposedNominee ?? {}, + rejectReason: edit.rejectReason + } + }); + }, + error: () => { + this.loadingCompareFor = null; + } + }); } approve(edit: any): void { @@ -189,7 +210,6 @@ export class PendingSavingsAccountEditsComponent { } reload(): void { - this.closeCompare(); const url = this.router.url; this.router .navigateByUrl('/checker-inbox-and-tasks', { skipLocationChange: true }) diff --git a/src/app/tasks/checker-inbox-and-tasks-tabs/pending-savings-account-edits/savings-account-edit-compare-dialog/savings-account-edit-compare-dialog.component.html b/src/app/tasks/checker-inbox-and-tasks-tabs/pending-savings-account-edits/savings-account-edit-compare-dialog/savings-account-edit-compare-dialog.component.html new file mode 100644 index 000000000..761528db8 --- /dev/null +++ b/src/app/tasks/checker-inbox-and-tasks-tabs/pending-savings-account-edits/savings-account-edit-compare-dialog/savings-account-edit-compare-dialog.component.html @@ -0,0 +1,73 @@ + +

+ difference + + Proposed changes — {{ accountNo }} + +

+ +
+ @if (facilityRows.length === 0 && nomineeRows.length === 0) { +
+ check_circle +

No changes to review

+

This edit was submitted without changing any feature or nominee value.

+
+ } + + @if (facilityRows.length > 0) { +

Digital & Account Features

+ + + + + + + + + + @for (row of facilityRows; track row.label) { + + + + + + } + +
FeatureCurrentProposed
{{ row.label }}{{ row.current }}{{ row.proposed }}
+ } + + @if (nomineeRows.length > 0) { +

Nominee Change

+ + + + + + + + + @for (row of nomineeRows; track row.label) { + + + + + } + +
FieldProposed
{{ row.label }}{{ row.proposed }}
+ } + + @if (rejectReason) { +

Reject Reason: {{ rejectReason }}

+ } +
+ + + + diff --git a/src/app/tasks/checker-inbox-and-tasks-tabs/pending-savings-account-edits/savings-account-edit-compare-dialog/savings-account-edit-compare-dialog.component.scss b/src/app/tasks/checker-inbox-and-tasks-tabs/pending-savings-account-edits/savings-account-edit-compare-dialog/savings-account-edit-compare-dialog.component.scss new file mode 100644 index 000000000..f946c19d6 --- /dev/null +++ b/src/app/tasks/checker-inbox-and-tasks-tabs/pending-savings-account-edits/savings-account-edit-compare-dialog/savings-account-edit-compare-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/. + */ + +$border: #e2e8f0; +$muted: #64748b; + +.saecd-title { + display: flex; + align-items: center; + gap: 10px; + font-size: 15px; + font-weight: 600; + color: #0f172a; + border-bottom: 1px solid $border; + padding-bottom: 12px; + margin-bottom: 0; + + &__icon { + color: #0ea5e9; + } +} + +.saecd-content { + max-height: 70vh; + overflow-y: auto; + padding-top: 16px; +} + +.saecd-section { + margin: 20px 0 10px; + font-size: 12.5px; + font-weight: 700; + text-transform: uppercase; + letter-spacing: 0.4px; + color: $muted; + + &:first-child { + margin-top: 0; + } +} + +.saecd-table { + width: 100%; + border-collapse: collapse; + font-size: 12.5px; + + th, + td { + text-align: left; + padding: 9px 12px; + border-bottom: 1px solid $border; + vertical-align: top; + } + + th { + color: $muted; + font-weight: 600; + text-transform: uppercase; + font-size: 11px; + letter-spacing: 0.4px; + background: #f8fafc; + position: sticky; + top: 0; + } + + tbody tr:hover { + background: #fffbeb; + } + + &__label { + font-weight: 600; + color: #1e293b; + width: 34%; + } + + &__old { + color: #b91c1c; + text-decoration: line-through; + width: 33%; + } + + &__new { + color: #15803d; + font-weight: 600; + width: 33%; + } +} + +.saecd-empty { + display: flex; + flex-direction: column; + align-items: center; + text-align: center; + padding: 32px 16px; + + &__icon { + font-size: 40px; + width: 40px; + height: 40px; + color: #16a34a; + margin-bottom: 10px; + } + + &__title { + margin: 0 0 6px; + font-size: 14px; + font-weight: 600; + color: #1e293b; + } + + &__text { + margin: 0; + font-size: 12.5px; + color: $muted; + max-width: 420px; + } +} + +.saecd-reject-reason { + margin: 20px 0 0; + padding: 10px 12px; + background: #fef2f2; + border: 1px solid #fecaca; + border-radius: 6px; + font-size: 12.5px; + color: #991b1b; +} diff --git a/src/app/tasks/checker-inbox-and-tasks-tabs/pending-savings-account-edits/savings-account-edit-compare-dialog/savings-account-edit-compare-dialog.component.ts b/src/app/tasks/checker-inbox-and-tasks-tabs/pending-savings-account-edits/savings-account-edit-compare-dialog/savings-account-edit-compare-dialog.component.ts new file mode 100644 index 000000000..0b3b1f84c --- /dev/null +++ b/src/app/tasks/checker-inbox-and-tasks-tabs/pending-savings-account-edits/savings-account-edit-compare-dialog/savings-account-edit-compare-dialog.component.ts @@ -0,0 +1,145 @@ +/** + * Copyright since 2025 Mifos Initiative + * + * This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. + */ + +import { Component, OnInit, inject } from '@angular/core'; +import { + MAT_DIALOG_DATA, + MatDialogRef, + MatDialogTitle, + MatDialogContent, + MatDialogActions, + MatDialogClose +} from '@angular/material/dialog'; +import { MatButton } from '@angular/material/button'; +import { MatIcon } from '@angular/material/icon'; +import { STANDALONE_SHARED_IMPORTS } from 'app/standalone-shared.module'; + +interface DiffRow { + label: string; + current: string; + proposed: string; +} + +/** Human-readable labels for the Digital & Account Feature toggles, matching the maker's edit form. */ +const FACILITY_LABELS: Record = { + atmFacility: 'ATM Facility', + atmWithdrawalFacility: 'ATM Withdrawal', + upiFacility: 'UPI Facility', + posTransactionsFacility: 'POS Transactions', + chequeBook: 'Cheque Book', + impsFacility: 'IMPS Facility', + smsAlerts: 'SMS Alerts', + bbpsFacility: 'BBPS Facility', + insuranceCover: 'Insurance Cover', + bepgFacility: 'BEPG Facility', + neftFacility: 'NEFT Facility', + rtgsFacility: 'RTGS Facility', + interBankTotalCapFacility: 'Inter-Bank Total Cap', + withdrawalFacility: 'Withdrawal Facility', + depositFacilityKyc: 'Deposit Facility (KYC)', + virtualDebitCard: 'Virtual Debit Card' +}; + +const NOMINEE_LABELS: Record = { + operation: 'Operation', + nomineeId: 'Nominee Id', + nomineeClientId: 'Nominee Client Id', + relationshipId: 'Relationship Id', + percentageShare: 'Percentage Share', + isActive: 'Active', + nomineeIsMinor: 'Nominee Is Minor', + guardianClientId: 'Guardian Client Id', + guardianRelationshipId: 'Guardian Relationship Id', + effectiveDate: 'Effective Date', + remarks: 'Remarks' +}; + +/** + * Shows what a pending savings/current account Nominee & Digital-Feature edit actually changes, as a + * popup instead of an inline panel. The maker's edit form already submits only the toggles that were + * actually flipped (see EditSavingsAccountFeaturesComponent#buildFacilityDiff), so proposedFacilityConfig + * only ever carries changed fields -- this still diffs against the account's current config defensively, + * so an unchanged value can never appear here even if that ever stops being true upstream. + */ +@Component({ + selector: 'mifosx-savings-account-edit-compare-dialog', + templateUrl: './savings-account-edit-compare-dialog.component.html', + styleUrls: ['./savings-account-edit-compare-dialog.component.scss'], + imports: [ + ...STANDALONE_SHARED_IMPORTS, + MatDialogTitle, + MatDialogContent, + MatDialogActions, + MatDialogClose, + MatButton, + MatIcon + ] +}) +export class SavingsAccountEditCompareDialogComponent implements OnInit { + dialogRef = inject>(MatDialogRef); + data = inject(MAT_DIALOG_DATA); + + accountNo = ''; + facilityRows: DiffRow[] = []; + nomineeRows: DiffRow[] = []; + rejectReason: string | null = null; + + ngOnInit(): void { + this.accountNo = this.data.accountNo ?? ''; + this.rejectReason = this.data.rejectReason ?? null; + + const currentFacilityConfig = this.data.currentFacilityConfig ?? {}; + const proposedFacilityConfig = this.data.proposedFacilityConfig ?? {}; + this.facilityRows = Object.keys(proposedFacilityConfig) + .filter((key) => !this.sameValue(currentFacilityConfig[key], proposedFacilityConfig[key])) + .map((key) => ({ + label: FACILITY_LABELS[key] ?? this.humanizeKey(key), + current: this.displayValue(currentFacilityConfig[key]), + proposed: this.displayValue(proposedFacilityConfig[key]) + })) + .sort((a, b) => a.label.localeCompare(b.label)); + + const proposedNominee = this.data.proposedNominee ?? {}; + this.nomineeRows = Object.keys(proposedNominee) + .filter((key) => proposedNominee[key] !== null && proposedNominee[key] !== undefined) + .map((key) => ({ + label: NOMINEE_LABELS[key] ?? this.humanizeKey(key), + current: '—', + proposed: this.displayValue(proposedNominee[key]) + })); + } + + private sameValue(a: any, b: any): boolean { + if (a === undefined) { + a = null; + } + if (b === undefined) { + b = null; + } + return a === b; + } + + private displayValue(value: any): string { + if (value === null || value === undefined || value === '') { + return '—'; + } + if (typeof value === 'boolean') { + return value ? 'Yes' : 'No'; + } + return String(value); + } + + /** camelCase field name to a spaced, capitalised label -- fallback for anything not in the label maps. */ + private humanizeKey(key: string): string { + const spaced = key + .replace(/([a-z0-9])([A-Z])/g, '$1 $2') + .replace(/([A-Z]+)([A-Z][a-z])/g, '$1 $2') + .trim(); + return spaced.charAt(0).toUpperCase() + spaced.slice(1); + } +} diff --git a/src/app/tasks/tasks.service.ts b/src/app/tasks/tasks.service.ts index 5f17d6809..d4acb38f2 100644 --- a/src/app/tasks/tasks.service.ts +++ b/src/app/tasks/tasks.service.ts @@ -199,6 +199,38 @@ export class TasksService { return this.http.post(`/currentaccounts/${accountId}`, data, { params: httpParams }); } + /** + * Activate a savings account + */ + activateSavingsAccount(accountId: any, data: any): Observable { + const httpParams = new HttpParams().set('command', 'activate'); + return this.http.post(`/savingsaccounts/${accountId}`, data, { params: httpParams }); + } + + /** + * Activate a fixed deposit account + */ + activateFDAccount(accountId: any, data: any): Observable { + const httpParams = new HttpParams().set('command', 'activate'); + return this.http.post(`/fixeddepositaccounts/${accountId}`, data, { params: httpParams }); + } + + /** + * Activate a recurring deposit account + */ + activateRDAccount(accountId: any, data: any): Observable { + const httpParams = new HttpParams().set('command', 'activate'); + return this.http.post(`/recurringdepositaccounts/${accountId}`, data, { params: httpParams }); + } + + /** + * Activate a current account + */ + activateCurrentAccount(accountId: any, data: any): Observable { + const httpParams = new HttpParams().set('command', 'activate'); + return this.http.post(`/currentaccounts/${accountId}`, data, { params: httpParams }); + } + /** * Reject a savings account */ diff --git a/src/assets/env.js b/src/assets/env.js index beb7fb517..8493889f7 100644 --- a/src/assets/env.js +++ b/src/assets/env.js @@ -18,7 +18,7 @@ window["env"]["apiActuator"] = ''; window["env"]["fineractPlatformTenantId"] = ''; - window["env"]["fineractPlatformTenantIds"] = ''; + window["env"]["fineractPlatformTenantIds"] = 'default,socity_bank'; window['env']['tenantLogoUrl'] = ''; window['env']['tenantLogoUrlDark'] = ''; diff --git a/src/environments/.env.ts b/src/environments/.env.ts index 3e9cc6755..ea6d58ac9 100644 --- a/src/environments/.env.ts +++ b/src/environments/.env.ts @@ -10,7 +10,7 @@ export default { 'mifos_x': { 'version': '260827', - 'hash': '1273bdbe' + 'hash': '79ffde51' }, 'allow_switching_backend_instance': true }; From da1f57c3b667cd4e942c4b4bfb2440b4a021b793 Mon Sep 17 00:00:00 2001 From: Nitesh Yadav Date: Fri, 28 Aug 2026 14:40:13 +0530 Subject: [PATCH 13/64] feat(cashier,organization): denomination-aware vault UI, Bank/Branch Profile screens, popup disbursement review Branch Day open/close now captures a denomination-wise count (carried forward automatically to the next day), and shows an auto-fetched EOD summary at close time -- vault + every cashier's position, a branch-total reconciliation against the book figure, and each cashier's reconciliation/settlement status and transaction count -- so closing the day no longer means separately checking Vault Position and every cashier's own screen by hand. New Vault Position screen (branch-ops menu) mirrors Cashier Position for the teller/vault. Cash Transfer now shows the vault's live per-denomination availability while counting a handout, with a client-side warning before it can be over-counted. New Organization screens for Bank Profile, Branch Profile and Clearing GL Configuration, wired into the Organization landing page and the previously-dead "Bank Profile" admin menu button. Cashier Transaction Entry: TYPE is now Receipt/Payment only (cash-only TXN TP.) on the cashier side, with no change to the clerk dashboard's own Withdrawal Slip/Cheque/Disbursement/Enter Bank Transfer/DD flow beyond adding a new Fund Transfer type alongside Disbursement for a GL credit that isn't a loan payout. Neither TYPE nor TXN TP. offers a blank placeholder option anymore -- a new row starts on a real default instead. Footer no longer polls the business-date config for cashier/clerk roles, who never have permission to read it. Disbursement & Transfer Requests: "Review" now opens the review screen as a popup instead of navigating to a separate route, refreshing the list on close; the review screen recognises the new Fund Transfer request type too, and its non-functional "+ Disbursement Review" tab-styled label is now a plain heading. Co-Authored-By: Claude Sonnet 5 --- .../branch-day/branch-day.component.html | 303 +++++++++++++- .../branch-day/branch-day.component.scss | 110 +++++ .../branch-day/branch-day.component.ts | 215 +++++++++- .../cash-transfer.component.html | 46 +- .../cash-transfer.component.scss | 51 +++ .../cash-transfer/cash-transfer.component.ts | 74 +++- src/app/cashier/cashier-routing.module.ts | 8 + src/app/cashier/cashier.service.ts | 34 +- .../transaction-entry.component.html | 36 +- .../transaction-entry.component.ts | 113 ++++- .../vault-position.component.html | 134 ++++++ .../vault-position.component.ts | 115 +++++ .../disbursement-review.component.html | 23 +- .../disbursement-review.component.scss | 39 +- .../disbursement-review.component.ts | 51 ++- src/app/core/shell/shell.component.html | 5 +- .../branch-profile.component.html | 163 ++++++++ .../branch-profile.component.scss | 175 ++++++++ .../branch-profile.component.ts | 151 +++++++ .../clearing-gl-config.component.html | 70 ++++ .../clearing-gl-config.component.scss | 137 ++++++ .../clearing-gl-config.component.ts | 75 ++++ .../institution-profile.component.html | 392 ++++++++++++++++++ .../institution-profile.component.scss | 226 ++++++++++ .../institution-profile.component.ts | 208 ++++++++++ .../organization-routing.module.ts | 24 ++ .../organization/organization.component.html | 42 ++ src/app/organization/organization.service.ts | 47 +++ src/app/shared/footer/footer.component.ts | 16 +- .../disbursement-requests.component.html | 19 +- .../disbursement-requests.component.ts | 36 +- src/environments/.env.ts | 4 +- 32 files changed, 3006 insertions(+), 136 deletions(-) create mode 100644 src/app/cashier/cash-transfer/cash-transfer.component.scss create mode 100644 src/app/cashier/vault-position/vault-position.component.html create mode 100644 src/app/cashier/vault-position/vault-position.component.ts create mode 100644 src/app/organization/branch-profile/branch-profile.component.html create mode 100644 src/app/organization/branch-profile/branch-profile.component.scss create mode 100644 src/app/organization/branch-profile/branch-profile.component.ts create mode 100644 src/app/organization/clearing-gl-config/clearing-gl-config.component.html create mode 100644 src/app/organization/clearing-gl-config/clearing-gl-config.component.scss create mode 100644 src/app/organization/clearing-gl-config/clearing-gl-config.component.ts create mode 100644 src/app/organization/institution-profile/institution-profile.component.html create mode 100644 src/app/organization/institution-profile/institution-profile.component.scss create mode 100644 src/app/organization/institution-profile/institution-profile.component.ts diff --git a/src/app/cashier/branch-day/branch-day.component.html b/src/app/cashier/branch-day/branch-day.component.html index fad8bf35e..9b97f565d 100644 --- a/src/app/cashier/branch-day/branch-day.component.html +++ b/src/app/cashier/branch-day/branch-day.component.html @@ -125,13 +125,102 @@

- Left blank, yesterday's closing figure carries forward automatically — which is what keeps the opening balance - from drifting. + Left blank with no notes/coins counted below, yesterday's closing figure carries forward automatically — which + is what keeps the opening balance from drifting. Counting the drawer below is what actually records the + note/coin stock the branch can check a vault handout against later.

+ +
+ + + + + + + + + + @if (notes.length) { + + + + } + @for (d of notes; track d.id) { + + + + + + } + @if (coins.length) { + + + + } + @for (d of coins; track d.id) { + + + + + + } + +
DenominationCountAmount
Bank Notes
+ {{ d.label }} + +
+ + + +
+
₹{{ subtotalFor(openCounts, d) | number: '1.2-2' }}
Coins
+ {{ d.label }} + +
+ + + +
+
₹{{ subtotalFor(openCounts, d) | number: '1.2-2' }}
+
+ @if (openDenomTotal > 0) { +

+ Denomination total: ₹{{ openDenomTotal | number: '1.2-2' }} +

+ } +
-
- + Cash drawn from vault - ₹{{ preview.vaultIn | number: '1.2-2' }} -
− Cash paid out (approved withdrawals) ₹{{ preview.withdrawals | number: '1.2-2' }}
-
- − Cash settled back to vault - ₹{{ preview.settledOut | number: '1.2-2' }} -
Cash the branch should be holding ₹{{ preview.expectedCash | number: '1.2-2' }}
+ @if (preview.vaultIn > 0 || preview.settledOut > 0) { +
+ Vault activity today — moves cash within the branch, not counted in the total above: ₹{{ + preview.vaultIn | number: '1.2-2' + }} + handed to a cashier, ₹{{ preview.settledOut | number: '1.2-2' }} settled back to the vault. +
+ } + + } + + + @if (eodLoading) { +

Fetching vault and cashier positions…

+ } + + + @if (branchTotalSummary; as bt) { +
+
+ account_balance_wallet + Branch Total (Vault + All Cashiers) +
+
+ Vault ₹{{ bt.vaultTotal | number: '1.2-2' }} + + Cashiers ₹{{ bt.cashierTotal | number: '1.2-2' }} + = ₹{{ bt.grandTotal | number: '1.2-2' }} + vs. book ₹{{ bt.expected | number: '1.2-2' }} +
+ @if (bt.difference === 0) { + Branch Tallied + } @else { + + {{ bt.difference > 0 ? 'Excess' : 'Short' }} ₹{{ absValue(bt.difference) | number: '1.2-2' }} + + } +
+ } + + @for (t of eodTellers; track t.tellerId) { +
+
+ account_balance + {{ t.tellerName }} + + vault holds + {{ t.vault?.hasBreakdown ? '₹' + (t.vault.totalAmount | number: '1.2-2') : 'no breakdown recorded' }} + +
+ + @if (t.vault?.hasBreakdown && t.vault.denominations.length > 0) { +
+ @for (d of t.vault.denominations; track d.denominationId) { + ₹{{ d.denominationValue }} × {{ d.count }} + } +
+ } + + @if (t.cashiers.length === 0) { +

No cashiers allocated under this vault.

+ } + @for (c of t.cashiers; track c.cashierId) { +
+
+ {{ c.cashierName }} + + morning ₹{{ c.morningTotal | number: '1.2-2' }} → current ₹{{ c.currentTotal | number: '1.2-2' }} · + {{ c.txnCount ?? '—' }} txn{{ c.txnCount === 1 ? '' : 's' }} today + +
+
+ @if (c.reconciled === true) { + Reconciled + } @else if (c.reconciled === false) { + Not Reconciled + } + @if (c.alreadySettled === true) { + Settled to Vault + } @else if (c.alreadySettled === false) { + Not Settled + } +
+ @if (c.denominations.length > 0) { +
+ @for (d of c.denominations; track d.denominationId) { + + ₹{{ d.denominationValue }}: {{ d.morningCount }} → {{ d.count }} + @if (d.delta !== 0) { + ({{ d.delta > 0 ? '+' : '' }}{{ d.delta }}) + } + + } +
+ } @else { + No balance. + } +
+ }
}
@if (preview && liveDifference !== null) { @if (liveDifference === 0) { @@ -222,9 +415,91 @@

Pre-filled with what the books expect — change it only if the drawer counted something different, and that - difference is then recorded against the day. + difference is then recorded against the day. Counting the denominations below overrides the typed figure with + what was actually counted, note by note.

+
+ + + + + + + + + + @if (notes.length) { + + + + } + @for (d of notes; track d.id) { + + + + + + } + @if (coins.length) { + + + + } + @for (d of coins; track d.id) { + + + + + + } + +
DenominationCountAmount
Bank Notes
+ {{ d.label }} + +
+ + + +
+
₹{{ subtotalFor(closeCounts, d) | number: '1.2-2' }}
Coins
+ {{ d.label }} + +
+ + + +
+
₹{{ subtotalFor(closeCounts, d) | number: '1.2-2' }}
+
+ @if (closeDenomTotal > 0) { +

+ Denomination total: ₹{{ closeDenomTotal | number: '1.2-2' }} +

+ } + @if (isLateClose) {
+ + @if (fromTellerId && !loadingVault) { + @if (vaultHasBreakdown) { +
+ account_balance + Vault available: ₹{{ vaultTotalAvailable | number: '1.2-2' }} +
+ } @else { +
+ warning + No denomination breakdown recorded for this vault — per-note availability can't be checked, only the total + opening balance. +
+ } + } +
+ @@ -71,14 +88,17 @@ @if (notes.length) { - + } @for (d of notes; track d.id) { - + + - + } @for (d of coins; track d.id) { - + + - + }
DenominationAvailable Count Amount
Bank NotesBank Notes
{{ d.label }} + {{ vaultHasBreakdown ? availableFor(d) : '—' }} +
@@ -91,14 +111,17 @@ } @if (coins.length) {
CoinsCoins
{{ d.label }} + {{ vaultHasBreakdown ? availableFor(d) : '—' }} +
@@ -111,13 +134,24 @@ } @if (denoms.length === 0) {
No active denominations. Add some in Cash Denomination.No active denominations. Add some in Cash Denomination.
+ @if (anyDenomExceedsAvailable) { +
+ One or more notes/coins above are counted higher than the vault currently holds — reduce them before recording the + transfer. +
+ } @else if (exceedsVaultTotal) { +
+ Total exceeds what the vault has available (₹{{ vaultTotalAvailable | number: '1.2-2' }}). +
+ } +
Total to transfer: ₹{{ total | number: '1.2-2' }}
@@ -145,7 +142,6 @@ (click)="$event.stopPropagation()" [class.ws-f__ctl--req]="!row.paymentTypeId" > - @for (pt of paymentTypesForRow(row); track pt.id) { } @@ -160,6 +156,7 @@ (change)=" row.acTyp === 'GL' && row.typ !== 'Disbursement' && + row.typ !== 'Fund Transfer' && row.typ !== 'Enter Bank Transfer' && (row.dbCr = 'Cr') " @@ -205,7 +202,10 @@ class="ws-seg__btn ws-seg__btn--dr" [class.ws-seg__btn--active-dr]="row.dbCr === 'Dr'" [disabled]=" - row.acTyp === 'GL' && row.typ !== 'Disbursement' && row.typ !== 'Enter Bank Transfer' + row.acTyp === 'GL' && + row.typ !== 'Disbursement' && + row.typ !== 'Fund Transfer' && + row.typ !== 'Enter Bank Transfer' " (click)="row.dbCr = 'Dr'" > @@ -286,7 +286,7 @@ (click)="$event.stopPropagation()" />
- } @else if (row.typ !== 'Withdrawal Slip') { + } @else if (isClerk && row.typ !== 'Withdrawal Slip') {
@@ -1351,7 +1348,6 @@ [(ngModel)]="row.paymentTypeId" [class.ws-f__ctl--req]="!row.paymentTypeId" > - @for (pt of paymentTypesForRow(row); track pt.id) { } @@ -1365,6 +1361,7 @@ (change)=" row.acTyp === 'GL' && row.typ !== 'Disbursement' && + row.typ !== 'Fund Transfer' && row.typ !== 'Enter Bank Transfer' && (row.dbCr = 'Cr') " @@ -1406,7 +1403,10 @@ class="ws-seg__btn ws-seg__btn--dr" [class.ws-seg__btn--active-dr]="row.dbCr === 'Dr'" [disabled]=" - row.acTyp === 'GL' && row.typ !== 'Disbursement' && row.typ !== 'Enter Bank Transfer' + row.acTyp === 'GL' && + row.typ !== 'Disbursement' && + row.typ !== 'Fund Transfer' && + row.typ !== 'Enter Bank Transfer' " (click)="$event.stopPropagation(); row.dbCr = 'Dr'" > @@ -1476,7 +1476,7 @@
- } @else { + } @else if (isClerk) {
diff --git a/src/app/cashier/transaction-entry/transaction-entry.component.ts b/src/app/cashier/transaction-entry/transaction-entry.component.ts index d9ae66adb..e1bfb4d24 100644 --- a/src/app/cashier/transaction-entry/transaction-entry.component.ts +++ b/src/app/cashier/transaction-entry/transaction-entry.component.ts @@ -794,6 +794,11 @@ export class TransactionEntryComponent implements OnInit, OnDestroy { */ paymentTypesForRow(row: TxnRow): any[] { const tellerFacing = this.paymentTypes.filter((pt: any) => !pt.isSystemDefined); + if (!this.isClerk) { + // Cashier counter takes cash only — cheque/transfer/DD instruments go through their own + // clerk/back-office approval flows, never straight through the cash drawer. + return tellerFacing.filter((pt: any) => !!pt.isCashPayment); + } if (row.acTyp === 'SHR') { return tellerFacing.filter((pt: any) => !pt.isCashPayment); } @@ -914,6 +919,34 @@ export class TransactionEntryComponent implements OnInit, OnDestroy { 'T' ]; + private readonly clerkTypOptions: { value: string; label: string }[] = [ + { value: 'Withdrawal Slip', label: 'Withdrawal Slip' }, + { value: 'Cheque', label: 'Cheque' }, + { value: 'Disbursement', label: 'Disbursement' }, + // Mechanically identical to Disbursement (1 GL debit -> N account credits, same maker-checker + // double-entry flow) but labelled separately: "Disbursement" reads as a loan payout, which is + // wrong for crediting a GL amount into a savings/current account for any other reason (e.g. + // funding a newly-activated account's opening balance from a GL source). + { value: 'Fund Transfer', label: 'Fund Transfer' }, + // NOTE: the exact same TxnRow.typ string is used at every dispatch/default-setting site below — + // search for 'Disbursement' whenever this needs to change again, since 'Fund Transfer' always + // rides alongside it in every one of those checks rather than having its own separate case. + { value: 'Enter Bank Transfer', label: 'Enter Bank Transfer' }, + { value: 'DD', label: 'DD (Demand Draft)' } + ]; + + /** A cashier only ever takes cash in (Receipt) or pays cash out (Payment) — Cheque, DD, + * Disbursement and Bank Transfer are clerk/back-office instruments with their own approval + * flows and never appear at the cashier counter. */ + private readonly cashierTypOptions: { value: string; label: string }[] = [ + { value: 'Receipt', label: 'Receipt' }, + { value: 'Payment', label: 'Payment' } + ]; + + get typOptions(): { value: string; label: string }[] { + return this.isClerk ? this.clerkTypOptions : this.cashierTypOptions; + } + // ── Denomination ────────────────────────────────────────────────────────── notes: Denomination[] = []; coins: Denomination[] = []; @@ -943,7 +976,22 @@ export class TransactionEntryComponent implements OnInit, OnDestroy { this.cashierService .getPaymentTypes() .pipe(catchError(() => of([]))) - .subscribe((types: any[]) => (this.paymentTypes = types ?? [])); + .subscribe((types: any[]) => { + this.paymentTypes = types ?? []; + // addRow() ran before this resolved, so the very first row's default TXN TP. (Cash, for + // Withdrawal Slip / Receipt / Payment) couldn't be looked up yet — backfill it now that + // the payment types are actually loaded. Only ever touches a row still sitting on the + // null it was created with, so this never overwrites anything already picked on screen. + for (const row of this.rows) { + if (!row.paymentTypeId && [ + 'Withdrawal Slip', + 'Receipt', + 'Payment' + ].includes(row.typ)) { + row.paymentTypeId = this.findCashPaymentTypeId(); + } + } + }); this.acNoInput$ .pipe( @@ -1234,8 +1282,11 @@ export class TransactionEntryComponent implements OnInit, OnDestroy { addRow(): void { const active = this.selectedRow; - // Disbursement/Bank Transfer: auto-add mirror CR row when active row is DR - if ((active?.typ === 'Disbursement' || active?.typ === 'Enter Bank Transfer') && active.dbCr === 'Dr') { + // Disbursement/Fund Transfer/Bank Transfer: auto-add mirror CR row when active row is DR + if ( + (active?.typ === 'Disbursement' || active?.typ === 'Fund Transfer' || active?.typ === 'Enter Bank Transfer') && + active.dbCr === 'Dr' + ) { const mirror: TxnRow = { typ: active.typ, br: active.br ?? 1, @@ -1261,20 +1312,26 @@ export class TransactionEntryComponent implements OnInit, OnDestroy { } return; } + // Neither TYPE nor TXN TP. ever offers a blank/placeholder option any more, so a fresh row + // can't start in an unselected state either — it needs a real, valid TYPE from the moment it + // exists. Cashier: Receipt (money in) is the far more common starting point than Payment + // (money out, which needs an approved token first). Clerk: Withdrawal Slip, the plainest of + // the five original types — same TYPE the old blank-then-choose flow most often ended on + // anyway, just without the extra click to get there. const row: TxnRow = { - typ: '~', + typ: this.isClerk ? 'Withdrawal Slip' : 'Receipt', br: 1, trTp: '', acTyp: 'SD', acNo: '', ecsAcNo: '', - dbCr: '', + dbCr: this.isClerk ? 'Dr' : 'Cr', chequeNo: '', cheqDate: '', amount: null, description: '', tokNo: '', - paymentTypeId: null, + paymentTypeId: this.findCashPaymentTypeId(), bankName: '', routingCode: '', ddCharges: null @@ -1296,6 +1353,7 @@ export class TransactionEntryComponent implements OnInit, OnDestroy { row.paymentTypeId = this.findCashPaymentTypeId(); break; case 'Disbursement': + case 'Fund Transfer': if (!row.dbCr) row.dbCr = 'Dr'; row.paymentTypeId = this.findTransferPaymentTypeId(); break; @@ -1303,6 +1361,21 @@ export class TransactionEntryComponent implements OnInit, OnDestroy { if (!row.dbCr) row.dbCr = 'Dr'; row.paymentTypeId = this.findTransferPaymentTypeId(); break; + // Cashier-only: Receipt = cash coming in (Cr), Payment = cash going out (Dr). The cashier + // TXN TP. dropdown only ever offers Cash (see paymentTypesForRow()), so that's the only + // sensible default here. + case 'Receipt': + row.dbCr = 'Cr'; + row.paymentTypeId = this.findCashPaymentTypeId(); + row.chequeNo = ''; + row.cheqDate = ''; + break; + case 'Payment': + row.dbCr = 'Dr'; + row.paymentTypeId = this.findCashPaymentTypeId(); + row.chequeNo = ''; + row.cheqDate = ''; + break; } } @@ -1358,6 +1431,9 @@ export class TransactionEntryComponent implements OnInit, OnDestroy { private submitDisbursementDoubleEntry(): void { const drRows = this.rows.filter((r) => r.dbCr === 'Dr'); const crRows = this.rows.filter((r) => r.dbCr === 'Cr'); + // Captured now, not re-read from this.rows inside the async success callback below — the rows + // array gets reset to a fresh blank row as soon as that callback runs. + const isFundTransfer = this.rows[0]?.typ === 'Fund Transfer'; const isCollectionMode = drRows.every((r) => r.acTyp !== 'GL') && crRows.length === 1 && crRows[0].acTyp === 'GL'; const isDisbursementMode = drRows.length === 1 && drRows[0].acTyp === 'GL'; @@ -1450,7 +1526,11 @@ export class TransactionEntryComponent implements OnInit, OnDestroy { paymentTypeId: firstRow?.paymentTypeId, paymentTypeName: this.paymentTypes.find((p: any) => p.id === firstRow?.paymentTypeId)?.name ?? null, description: firstRow?.description || null, - creditEntries: JSON.stringify(entries) + creditEntries: JSON.stringify(entries), + // Without this the backend defaults every request from this screen to DISBURSEMENT — a + // Collection or Fund Transfer submitted here was being saved (and shown in the checker's + // inbox) as a plain Disbursement. + requestType: isCollectionMode ? 'COLLECTION' : isFundTransfer ? 'FUND_TRANSFER' : 'DISBURSEMENT' }; this.saving = true; @@ -1470,7 +1550,7 @@ export class TransactionEntryComponent implements OnInit, OnDestroy { .subscribe((res: any) => { this.saving = false; if (res) { - const modeLabel = isCollectionMode ? 'Collection' : 'Disbursement'; + const modeLabel = isCollectionMode ? 'Collection' : isFundTransfer ? 'Fund Transfer' : 'Disbursement'; const summaryLine = isCollectionMode ? `DR: ${drRows.length} account(s) → CR: GL ${crRows[0].acNo}` : `DR: GL ${drRows[0].acNo} → CR: ${crRows.length} account(s)`; @@ -2953,8 +3033,14 @@ export class TransactionEntryComponent implements OnInit, OnDestroy { return; } - // Clerk multi-row disbursement — handle before single-row guard (selected row may be incomplete) - if (this.isClerk && this.rows.length >= 2 && this.rows.every((r) => r.typ === 'Disbursement')) { + // Clerk multi-row disbursement — handle before single-row guard (selected row may be incomplete). + // Fund Transfer rides the exact same double-entry flow as Disbursement (see the TYPE options + // comment above) — only the label differs. + if ( + this.isClerk && + this.rows.length >= 2 && + this.rows.every((r) => r.typ === 'Disbursement' || r.typ === 'Fund Transfer') + ) { const drRows = this.rows.filter((r) => r.dbCr === 'Dr'); const crRows = this.rows.filter((r) => r.dbCr === 'Cr'); @@ -2996,7 +3082,12 @@ export class TransactionEntryComponent implements OnInit, OnDestroy { return; } - const title = isCollectionMode ? 'Confirm Collection Request' : 'Confirm Disbursement Request'; + const isFundTransfer = this.rows[0]?.typ === 'Fund Transfer'; + const title = isCollectionMode + ? 'Confirm Collection Request' + : isFundTransfer + ? 'Confirm Fund Transfer Request' + : 'Confirm Disbursement Request'; const confirmLines = isCollectionMode ? [ `DR (${drRows.length} account${drRows.length !== 1 ? 's' : ''} to collect):`, ...drRows.map( diff --git a/src/app/cashier/vault-position/vault-position.component.html b/src/app/cashier/vault-position/vault-position.component.html new file mode 100644 index 000000000..4d2337845 --- /dev/null +++ b/src/app/cashier/vault-position/vault-position.component.html @@ -0,0 +1,134 @@ + + +
+
+
+
BRANCH OPERATIONS · VAULT POSITION
+

Vault Position

+

+ Denomination-wise stock a vault currently holds — the branch's own opening breakdown, plus whatever has been + settled back into it, minus whatever it has handed out to cashiers since. +

+
+
+ +
+ @if (tellers.length === 1) { + + Vault + + + } @else { + + Vault (optional) + + All Vaults + @for (t of tellers; track t.id) { + {{ t.name }} + } + + + } + + + As of Date + + + + +
+ + @if (errorMsg) { +
{{ errorMsg }}
+ } + + @if (loading) { +
Loading…
+ } + + @for (pos of results; track pos.tellerId) { +
+
+ {{ pos.tellerName || labelForTeller(pos.tellerId) }} + ₹{{ pos.totalAmount | number: '1.2-2' }} +
+ + @if (!pos.hasBreakdown) { +
+ No denomination breakdown was recorded for this vault's opening day on or before {{ pos.asOfDate }}. Open (or + re-open) the business day with a note/coin count to start tracking its stock. +
+ } @else { + @if (pos.anchorDate && pos.anchorDate !== pos.asOfDate) { +
+ Carried forward from the opening breakdown recorded on {{ pos.anchorDate }}. +
+ } + + @if (pos.denominations.length === 0) { +
No stock — everything opened for this vault has been handed out.
+ } + + @if (notes(pos).length > 0) { + + + + + + + + + + + @for (d of notes(pos); track d.denominationId) { + + + + + + } + +
DenominationCurrent CountAmount
+ ₹{{ d.denominationValue }} + {{ d.count }}₹{{ d.amount | number: '1.2-2' }}
+ } + + @if (coins(pos).length > 0) { + + + + + + + + + + + @for (d of coins(pos); track d.denominationId) { + + + + + + } + +
DenominationCurrent CountAmount
+ ₹{{ d.denominationValue }} + {{ d.count }}₹{{ d.amount | number: '1.2-2' }}
+ } + } +
+ } + + @if (!loading && results.length === 0 && !errorMsg) { +
Select filters and click "Load Position".
+ } +
diff --git a/src/app/cashier/vault-position/vault-position.component.ts b/src/app/cashier/vault-position/vault-position.component.ts new file mode 100644 index 000000000..d91cbdb2d --- /dev/null +++ b/src/app/cashier/vault-position/vault-position.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/. + */ + +import { Component, OnInit, inject } from '@angular/core'; +import { CommonModule } from '@angular/common'; +import { FormsModule } from '@angular/forms'; +import { MatIconModule } from '@angular/material/icon'; +import { MatFormFieldModule } from '@angular/material/form-field'; +import { MatSelectModule } from '@angular/material/select'; +import { MatInputModule } from '@angular/material/input'; +import { of } from 'rxjs'; +import { catchError } from 'rxjs/operators'; +import { CashierService } from '../cashier.service'; + +interface DenomRow { + denominationId: number; + denominationValue: number; + kind: string; + count: number; + amount: number; +} +interface VaultPos { + tellerId: number; + tellerName?: string; + asOfDate: string; + anchorDate?: string; + hasBreakdown: boolean; + totalAmount: number; + denominations: DenomRow[]; +} + +/** + * Denomination-wise stock a vault (teller) is currently holding — "how much of what does the bank + * have", the same question cashier-position answers for a cashier's own float. + */ +@Component({ + selector: 'mifosx-vault-position', + templateUrl: './vault-position.component.html', + styleUrls: [ + '../cashier-shared.scss', + '../cashier-position/cashier-position.component.scss' + ], + standalone: true, + imports: [ + CommonModule, + FormsModule, + MatIconModule, + MatFormFieldModule, + MatSelectModule, + MatInputModule + ] +}) +export class VaultPositionComponent implements OnInit { + private service = inject(CashierService); + + tellers: { id: number; name: string }[] = []; + selectedTellerId: number | null = null; + asOfDate = ''; + loading = false; + + results: VaultPos[] = []; + errorMsg = ''; + + ngOnInit(): void { + const d = new Date(); + this.asOfDate = `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, '0')}-${String(d.getDate()).padStart(2, '0')}`; + + this.service + .getTellers() + .pipe(catchError(() => of([] as any[]))) + .subscribe((list) => { + this.tellers = (list || []).map((t: any) => ({ id: t.id, name: t.name })); + if (this.tellers.length === 1) { + this.selectedTellerId = this.tellers[0].id; + } + this.load(); + }); + } + + load(): void { + this.loading = true; + this.results = []; + this.errorMsg = ''; + this.service + .vaultPosition(this.selectedTellerId ?? undefined, this.asOfDate) + .pipe(catchError(() => of(null))) + .subscribe((data: any) => { + this.loading = false; + if (!data) { + this.errorMsg = 'Failed to load vault position.'; + return; + } + const arr: any[] = Array.isArray(data) ? data : [data]; + this.results = arr; + }); + } + + labelForTeller(id: number): string { + const t = this.tellers.find((x) => x.id === id); + return t ? t.name : `Vault #${id}`; + } + + notes(pos: VaultPos): DenomRow[] { + return pos.denominations.filter((d) => d.kind !== 'coin'); + } + + coins(pos: VaultPos): DenomRow[] { + return pos.denominations.filter((d) => d.kind === 'coin'); + } +} diff --git a/src/app/clerk/disbursement-review/disbursement-review.component.html b/src/app/clerk/disbursement-review/disbursement-review.component.html index f9aa574ce..7ad308fd8 100644 --- a/src/app/clerk/disbursement-review/disbursement-review.component.html +++ b/src/app/clerk/disbursement-review/disbursement-review.component.html @@ -6,16 +6,21 @@ file, You can obtain one at http://mozilla.org/MPL/2.0/. --> -
- +
+ diff --git a/src/app/clerk/disbursement-review/disbursement-review.component.scss b/src/app/clerk/disbursement-review/disbursement-review.component.scss index c3a42906e..a95e0293a 100644 --- a/src/app/clerk/disbursement-review/disbursement-review.component.scss +++ b/src/app/clerk/disbursement-review/disbursement-review.component.scss @@ -36,6 +36,16 @@ $shadow-md: 0 4px 16px rgb(0 0 0 / 9%); flex-direction: column; min-height: 100vh; background: $bg-page; + + /* Opened as a popup rather than a full page — sizes to its own content (capped so a long + entries table still scrolls inside the dialog) instead of stretching to the viewport. */ + &--dialog { + min-height: 0; + max-height: 85vh; + overflow-y: auto; + width: min(760px, 92vw); + border-radius: 10px; + } } // ── Nav bar ──────────────────────────────────────────────────────────────── @@ -48,29 +58,6 @@ $shadow-md: 0 4px 16px rgb(0 0 0 / 9%); border-bottom: 1px solid $border; box-shadow: $shadow-sm; - &__item { - display: inline-flex; - align-items: center; - gap: 7px; - padding: 7px 16px; - border-radius: 8px; - font-size: 13px; - font-weight: 600; - border: none; - cursor: pointer; - transition: all 0.15s; - - &--primary { - background: $brand-light; - color: $brand; - } - - &--active { - background: $brand; - color: #fff; - } - } - &__esc-hint { margin-left: auto; font-size: 12px; @@ -88,6 +75,12 @@ $shadow-md: 0 4px 16px rgb(0 0 0 / 9%); } } +.dri-header-title { + font-size: 14px; + font-weight: 700; + color: $text-mid; +} + // ── Loading / empty ──────────────────────────────────────────────────────── .dri-loading { display: flex; diff --git a/src/app/clerk/disbursement-review/disbursement-review.component.ts b/src/app/clerk/disbursement-review/disbursement-review.component.ts index 82d37434e..82d96762f 100644 --- a/src/app/clerk/disbursement-review/disbursement-review.component.ts +++ b/src/app/clerk/disbursement-review/disbursement-review.component.ts @@ -12,6 +12,7 @@ import { FormsModule } from '@angular/forms'; import { ActivatedRoute, Router } from '@angular/router'; import { MatSnackBar, MatSnackBarModule } from '@angular/material/snack-bar'; import { MatIconModule } from '@angular/material/icon'; +import { MatDialogRef, MAT_DIALOG_DATA, MatDialogModule } from '@angular/material/dialog'; import { catchError } from 'rxjs/operators'; import { of } from 'rxjs'; @@ -33,7 +34,8 @@ interface CreditEntry { CommonModule, FormsModule, MatSnackBarModule, - MatIconModule + MatIconModule, + MatDialogModule ], templateUrl: './disbursement-review.component.html', styleUrl: './disbursement-review.component.scss' @@ -43,6 +45,11 @@ export class DisbursementReviewComponent implements OnInit { private router = inject(Router); private service = inject(ClerkDisbursementService); private snackBar = inject(MatSnackBar); + /** Present only when opened as a popup from the requests list; absent when reached by direct + * route (e.g. a bookmarked/shared link). Both entry points stay supported — this only changes + * how "done" is signalled: closing the dialog vs. navigating back to the inbox route. */ + private dialogRef = inject(MatDialogRef, { optional: true }); + private dialogData = inject<{ requestId: number } | null>(MAT_DIALOG_DATA, { optional: true }); requestId: number | null = null; request: any = null; @@ -53,9 +60,17 @@ export class DisbursementReviewComponent implements OnInit { showRejectForm = false; rejectionReason = ''; + get isDialog(): boolean { + return !!this.dialogRef; + } + ngOnInit(): void { - const id = this.route.snapshot.params['id']; - this.requestId = id != null ? Number(id) : null; + if (this.dialogData?.requestId) { + this.requestId = this.dialogData.requestId; + } else { + const id = this.route.snapshot.params['id']; + this.requestId = id != null ? Number(id) : null; + } if (this.requestId) this.loadRequest(); } @@ -93,9 +108,11 @@ export class DisbursementReviewComponent implements OnInit { } else { const msg = this.isBankTransferMode ? 'Bank transfer approved successfully.' - : 'Disbursement approved successfully.'; + : this.isFundTransferMode + ? 'Fund transfer approved successfully.' + : 'Disbursement approved successfully.'; this.snackBar.open(msg, 'Close', { duration: 3000 }); - this.router.navigate(['/checker-inbox-and-tasks/disbursement-requests']); + this.finish(); } }); } @@ -112,15 +129,31 @@ export class DisbursementReviewComponent implements OnInit { this.snackBar.open(res.error, 'Close', { duration: 5000 }); } else { this.snackBar.open('Request rejected.', 'Close', { duration: 3000 }); - this.router.navigate(['/checker-inbox-and-tasks/disbursement-requests']); + this.finish(); } }); } + /** "Done" for this screen — closes the popup (telling the list to refresh) when opened as a + * dialog, or returns to the inbox route otherwise. */ + private finish(): void { + if (this.dialogRef) { + this.dialogRef.close({ refreshed: true }); + } else { + this.router.navigate(['/checker-inbox-and-tasks/disbursement-requests']); + } + } + get isBankTransferMode(): boolean { return this.request?.request_type === 'BANK_TRANSFER'; } + /** Mechanically identical to a Disbursement (same 1 GL debit -> N account credits flow) — only + * the label differs, so a checker doesn't read this as a loan payout. */ + get isFundTransferMode(): boolean { + return this.request?.request_type === 'FUND_TRANSFER'; + } + get isCollectionMode(): boolean { if (this.isBankTransferMode) return false; return this.creditEntries.some((e) => e.dbCr === 'Dr'); @@ -189,6 +222,10 @@ export class DisbursementReviewComponent implements OnInit { } backToInbox(): void { - this.router.navigate(['/checker-inbox-and-tasks/disbursement-requests']); + if (this.dialogRef) { + this.dialogRef.close(); + } else { + this.router.navigate(['/checker-inbox-and-tasks/disbursement-requests']); + } } } diff --git a/src/app/core/shell/shell.component.html b/src/app/core/shell/shell.component.html index d124305c7..95f7c3741 100644 --- a/src/app/core/shell/shell.component.html +++ b/src/app/core/shell/shell.component.html @@ -700,7 +700,7 @@
Audit & Compliance
- @@ -1032,6 +1032,9 @@ + + +
diff --git a/src/app/organization/branch-profile/branch-profile.component.scss b/src/app/organization/branch-profile/branch-profile.component.scss new file mode 100644 index 000000000..200f99b04 --- /dev/null +++ b/src/app/organization/branch-profile/branch-profile.component.scss @@ -0,0 +1,175 @@ +/** + * 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: #1353a0; +$accent: #0ea5e9; +$border: #e2e8f0; +$gray-50: #f8f9fa; +$gray-200: #e8eaed; +$gray-500: #9aa0a6; +$gray-900: #202124; + +.bp-container { + max-width: 760px; + margin: 0 auto; + padding: 20px 16px; +} + +.bp-header { + display: flex; + align-items: center; + gap: 14px; + margin-bottom: 20px; + + &__title { + font-size: 18px; + font-weight: 600; + color: $gray-900; + margin: 0; + } +} + +.bp-back { + border-radius: 8px !important; + font-size: 12px !important; + font-weight: 600 !important; + height: 32px !important; + border-color: $border !important; + color: $gray-500 !important; + + i { + margin-right: 6px; + } + + &:hover { + color: $primary !important; + border-color: $primary !important; + } +} + +.bp-alert { + padding: 10px 14px; + margin-bottom: 14px; + border-radius: 8px; + font-size: 13px; + + &--error { + background: #fef2f2; + border: 1px solid #fecaca; + color: #991b1b; + } + + &--success { + background: #f0fdf4; + border: 1px solid #bbf7d0; + color: #14532d; + } +} + +.bp-muted { + color: $gray-500; + font-size: 13px; +} + +.bp-loading { + padding: 24px; + text-align: center; + color: $gray-500; +} + +.bp-card { + background: #fff; + border: 1px solid $border; + border-radius: 10px; + overflow: hidden; +} + +.bp-section { + &__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; + } + } +} + +.bp-body { + padding: 20px 24px; + display: flex; + flex-wrap: wrap; + gap: 0 16px; +} + +.bp-field { + width: 100%; + margin-bottom: 4px; + + &--half { + width: calc(50% - 8px); + } + + &--third { + width: calc(33.33% - 11px); + } +} + +.bp-note { + margin: 0 24px 16px; + padding: 8px 12px; + background: #eff6ff; + border: 1px solid #bfdbfe; + border-radius: 6px; + font-size: 11px; + color: #1e40af; + + a { + color: $primary; + font-weight: 600; + } +} + +.bp-lastupdated { + padding: 0 24px 10px; + font-size: 11px; + color: $gray-500; +} + +.bp-actions { + display: flex; + justify-content: flex-end; + gap: 10px; + padding: 14px 24px; + border-top: 1px solid $gray-200; + background: $gray-50; + + &__submit { + border-radius: 8px !important; + font-size: 13px !important; + font-weight: 600 !important; + height: 36px !important; + box-shadow: 0 1px 3px rgba($primary, 0.3) !important; + + i { + margin-right: 6px; + font-size: 12px; + } + } +} diff --git a/src/app/organization/branch-profile/branch-profile.component.ts b/src/app/organization/branch-profile/branch-profile.component.ts new file mode 100644 index 000000000..af14910ff --- /dev/null +++ b/src/app/organization/branch-profile/branch-profile.component.ts @@ -0,0 +1,151 @@ +/** + * 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 { UntypedFormBuilder, UntypedFormGroup } from '@angular/forms'; +import { catchError, of } from 'rxjs'; +import { STANDALONE_SHARED_IMPORTS } from 'app/standalone-shared.module'; +import { OrganizationService } from '../organization.service'; + +const BRANCH_TYPES = [ + { value: 'HEAD_OFFICE', label: 'Head Office' }, + { value: 'BRANCH', label: 'Branch' }, + { value: 'REGIONAL_OFFICE', label: 'Regional Office' }, + { value: 'ZONAL_OFFICE', label: 'Zonal Office' } +]; + +/** + * The real-world operational fields behind a branch — code, address, contact, type and working + * hours — kept as their own screen on top of the stock Manage Offices flow, since m_office (the + * core Fineract entity) was deliberately left untouched. Pick an office, fill in what real-world + * UCB branch setup expects, save. + */ +@Component({ + selector: 'mifosx-branch-profile', + templateUrl: './branch-profile.component.html', + styleUrls: ['./branch-profile.component.scss'], + imports: [...STANDALONE_SHARED_IMPORTS] +}) +export class BranchProfileComponent implements OnInit { + private formBuilder = inject(UntypedFormBuilder); + private organizationService = inject(OrganizationService); + + branchTypes = BRANCH_TYPES; + + offices: any[] = []; + selectedOfficeId: number | null = null; + form: UntypedFormGroup; + + loadingOffices = true; + loadingProfile = false; + saving = false; + errorMsg = ''; + successMsg = ''; + lastUpdated: any = null; + + ngOnInit(): void { + this.form = this.formBuilder.group({ + branchCode: [''], + branchType: [null], + address: [''], + contactPhone: [''], + contactEmail: [''], + workingHoursStart: [''], + workingHoursEnd: [''], + workingDays: [''], + ifscCode: [''], + micrCode: [''], + latitude: [null], + longitude: [null] + }); + + this.organizationService + .getBranchProfiles() + .pipe( + catchError((err: any) => { + this.errorMsg = err?.error?.errors?.[0]?.defaultUserMessage ?? 'Could not load branches.'; + return of([]); + }) + ) + .subscribe((res: any[]) => { + this.loadingOffices = false; + this.offices = res || []; + if (this.offices.length === 1) { + this.selectOffice(this.offices[0].officeId); + } + }); + } + + selectOffice(officeId: number): void { + this.selectedOfficeId = officeId; + this.loadingProfile = true; + this.errorMsg = ''; + this.successMsg = ''; + this.organizationService + .getBranchProfile(officeId) + .pipe( + catchError((err: any) => { + this.loadingProfile = false; + this.errorMsg = err?.error?.errors?.[0]?.defaultUserMessage ?? 'Could not load this branch.'; + return of(null); + }) + ) + .subscribe((res: any) => { + this.loadingProfile = false; + if (!res) return; + this.form.patchValue({ + branchCode: res.branchCode ?? '', + branchType: res.branchType ?? null, + address: res.address ?? '', + contactPhone: res.contactPhone ?? '', + contactEmail: res.contactEmail ?? '', + workingHoursStart: res.workingHoursStart ?? '', + workingHoursEnd: res.workingHoursEnd ?? '', + workingDays: res.workingDays ?? '', + ifscCode: res.ifscCode ?? '', + micrCode: res.micrCode ?? '', + latitude: res.latitude ?? null, + longitude: res.longitude ?? null + }); + this.lastUpdated = res.updatedByUsername ? { by: res.updatedByUsername, at: res.updatedAt } : null; + this.form.markAsPristine(); + // Reflect the just-loaded office in the list so the selector stays accurate without a reload. + const idx = this.offices.findIndex((o) => o.officeId === officeId); + if (idx >= 0) this.offices[idx] = res; + }); + } + + officeLabel(o: any): string { + return o.branchCode ? `${o.officeName} (${o.branchCode})` : o.officeName; + } + + submit(): void { + if (this.saving || !this.selectedOfficeId) return; + this.saving = true; + this.errorMsg = ''; + this.successMsg = ''; + this.organizationService + .updateBranchProfile(this.selectedOfficeId, this.form.value) + .pipe( + catchError((err: any) => { + this.saving = false; + this.errorMsg = err?.error?.errors?.[0]?.defaultUserMessage ?? 'Could not save this branch.'; + return of(null); + }) + ) + .subscribe((res: any) => { + this.saving = false; + if (!res) return; + this.successMsg = 'Branch profile saved.'; + this.form.markAsPristine(); + this.lastUpdated = res.updatedByUsername ? { by: res.updatedByUsername, at: res.updatedAt } : null; + const idx = this.offices.findIndex((o) => o.officeId === this.selectedOfficeId); + if (idx >= 0) this.offices[idx] = res; + }); + } +} diff --git a/src/app/organization/clearing-gl-config/clearing-gl-config.component.html b/src/app/organization/clearing-gl-config/clearing-gl-config.component.html new file mode 100644 index 000000000..cca3e2d3a --- /dev/null +++ b/src/app/organization/clearing-gl-config/clearing-gl-config.component.html @@ -0,0 +1,70 @@ + + +
+
+ +

Clearing GL Configuration

+
+

+ Which GL account backs each clearing role. An unmapped role posts nowhere — map it here before that clearing channel + goes live. +

+ + @if (errorMsg) { +
{{ errorMsg }}
+ } + @if (successMsg) { +
{{ successMsg }}
+ } + + @if (loading) { +
Loading…
+ } @else { +
+ + + + + + + + + + @for (row of rows; track row.configKey) { + + + + + + } + +
Clearing RoleDescriptionMapped GL Account
+ {{ row.configKey }} + {{ row.description }} + + + — Not mapped — + @for (o of glAccountOptions; track o.id) { + {{ o.glCode }} · {{ o.name }} + } + + + @if (!row.glAccountId) { + Unmapped + } +
+
+ } +
diff --git a/src/app/organization/clearing-gl-config/clearing-gl-config.component.scss b/src/app/organization/clearing-gl-config/clearing-gl-config.component.scss new file mode 100644 index 000000000..a231ce25e --- /dev/null +++ b/src/app/organization/clearing-gl-config/clearing-gl-config.component.scss @@ -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/. + */ +$primary: #1353a0; +$border: #e2e8f0; +$gray-50: #f8f9fa; +$gray-200: #e8eaed; +$gray-500: #9aa0a6; +$gray-900: #202124; + +.cgc-container { + max-width: 900px; + margin: 0 auto; + padding: 20px 16px; +} + +.cgc-header { + display: flex; + align-items: center; + gap: 14px; + margin-bottom: 6px; + + &__title { + font-size: 18px; + font-weight: 600; + color: $gray-900; + margin: 0; + } +} + +.cgc-sub { + margin: 0 0 16px; + font-size: 12px; + color: $gray-500; +} + +.cgc-back { + border-radius: 8px !important; + font-size: 12px !important; + font-weight: 600 !important; + height: 32px !important; + border-color: $border !important; + color: $gray-500 !important; + + i { + margin-right: 6px; + } + + &:hover { + color: $primary !important; + border-color: $primary !important; + } +} + +.cgc-alert { + padding: 10px 14px; + margin-bottom: 14px; + border-radius: 8px; + font-size: 13px; + + &--error { + background: #fef2f2; + border: 1px solid #fecaca; + color: #991b1b; + } + + &--success { + background: #f0fdf4; + border: 1px solid #bbf7d0; + color: #14532d; + } +} + +.cgc-loading { + padding: 24px; + text-align: center; + color: $gray-500; +} + +.cgc-card { + background: #fff; + border: 1px solid $border; + border-radius: 10px; + overflow: hidden; +} + +.cgc-table { + width: 100%; + border-collapse: collapse; + font-size: 13px; + + th { + text-align: left; + padding: 10px 16px; + background: $gray-50; + border-bottom: 1px solid $gray-200; + font-size: 11px; + text-transform: uppercase; + letter-spacing: 0.04em; + color: $gray-500; + } + + td { + padding: 10px 16px; + border-bottom: 1px solid $gray-200; + vertical-align: middle; + } + + tr:last-child td { + border-bottom: none; + } +} + +.cgc-key { + font-family: monospace; + font-weight: 700; + color: $primary; +} + +.cgc-desc { + color: #475569; +} + +.cgc-select { + min-width: 260px; +} + +.cgc-unmapped { + margin-left: 8px; + font-size: 11px; + font-weight: 700; + color: #b45309; +} diff --git a/src/app/organization/clearing-gl-config/clearing-gl-config.component.ts b/src/app/organization/clearing-gl-config/clearing-gl-config.component.ts new file mode 100644 index 000000000..1bb04a305 --- /dev/null +++ b/src/app/organization/clearing-gl-config/clearing-gl-config.component.ts @@ -0,0 +1,75 @@ +/** + * 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 { catchError, forkJoin, of } from 'rxjs'; +import { STANDALONE_SHARED_IMPORTS } from 'app/standalone-shared.module'; +import { OrganizationService } from '../organization.service'; + +/** + * Which GL account backs each clearing role (CTS inward/outward suspense, NACH suspense, RTGS + * settlement, ...). The roles themselves are fixed; this only changes which GL account each one + * points at. + */ +@Component({ + selector: 'mifosx-clearing-gl-config', + templateUrl: './clearing-gl-config.component.html', + styleUrls: ['./clearing-gl-config.component.scss'], + imports: [...STANDALONE_SHARED_IMPORTS] +}) +export class ClearingGlConfigComponent implements OnInit { + private organizationService = inject(OrganizationService); + + rows: any[] = []; + glAccountOptions: any[] = []; + loading = true; + savingKey: string | null = null; + errorMsg = ''; + successMsg = ''; + + ngOnInit(): void { + this.load(); + } + + load(): void { + this.loading = true; + this.errorMsg = ''; + forkJoin({ + rows: this.organizationService.getClearingGlConfig().pipe(catchError(() => of([]))), + template: this.organizationService.getClearingGlConfigTemplate().pipe(catchError(() => of(null))) + }).subscribe(({ rows, template }: any) => { + this.loading = false; + this.rows = rows || []; + this.glAccountOptions = template?.glAccountOptions || []; + if (!rows || rows.length === 0) { + this.errorMsg = 'Could not load clearing GL configuration.'; + } + }); + } + + onSelect(row: any, glAccountId: number | null): void { + this.savingKey = row.configKey; + this.errorMsg = ''; + this.successMsg = ''; + this.organizationService + .updateClearingGlConfig(row.configKey, glAccountId) + .pipe( + catchError((err: any) => { + this.savingKey = null; + this.errorMsg = err?.error?.errors?.[0]?.defaultUserMessage ?? 'Could not save this mapping.'; + return of(null); + }) + ) + .subscribe((res: any[]) => { + this.savingKey = null; + if (!res) return; + this.rows = res; + this.successMsg = `${row.configKey} updated.`; + }); + } +} diff --git a/src/app/organization/institution-profile/institution-profile.component.html b/src/app/organization/institution-profile/institution-profile.component.html new file mode 100644 index 000000000..4732fdd0d --- /dev/null +++ b/src/app/organization/institution-profile/institution-profile.component.html @@ -0,0 +1,392 @@ + + +
+
+ +

Bank Profile

+
+ + @if (errorMsg) { +
{{ errorMsg }}
+ } + @if (successMsg) { +
{{ successMsg }}
+ } + + @if (loading) { +
Loading…
+ } @else { +
+
+ +
+
+ +

Basic Information

+
+
+ + Bank / Institution Name + + + + Short Name / Abbreviation + + + + Legal / Registered Name + + + + Bank Code + + + + Institution Code + + + + Institution Type + + @for (t of bankTypes; track t.value) { + {{ t.label }} + } + + + + Ownership Type + + @for (o of ownershipTypes; track o.value) { + {{ o.label }} + } + + + + Parent Institution (if any) + + + + Established Date + + + + + + Status + + @for (s of statuses; track s.value) { + {{ s.label }} + } + + + + Country + + + + Currency + + Set once for the whole system, not editable here + +
+
+ + +
+
+ +

Regulatory Information

+
+
+ + Regulatory Authority + + + + Registration Date + + + + + + Registration Number + + + + License Number + + + + License Status + + @for (l of licenseStatuses; track l.value) { + {{ l.label }} + } + + + + License Expiry Date + + + + + + PAN + + + + TAN + + + + GSTIN + + +
+
+ + +
+
+ +

Registered Office Address

+
+
+ + Address Line 1 + + + + Address Line 2 + + + + City + + + + District + + + + State + + + + PIN Code + + +
+
+ +
+
+ +

Head Office / Corporate Address

+
+
+ + Same as Registered Office Address + + @if (!form.value.headOfficeSameAsRegistered) { + + Head Office Address + + + } +
+
+ +
+
+ +

Communication Address

+
+
+ + Same as Registered Office Address + + @if (!form.value.communicationSameAsRegistered) { + + Communication Address + + + } +
+
+ + +
+
+ +

Contact Details

+
+
+ + Official Email + + + + Phone Number + + + + Customer Care Number + + + + Grievance / Support Email + + + + Website + + +
+
+ + +
+ + +
+
+ +

Banking Identifiers

+
+
+ + SWIFT / BIC + + +
+

+ IFSC and MICR are branch-level identifiers, not institution-level — a bank with multiple branches has a + different IFSC/MICR per branch, so those are set on each branch's own + Branch Profile instead. +

+
+ + +
+
+ +

Document Headers

+
+
+ + Statement Header Name + + + + Passbook Header Name + + + + Receipt Header Name + + + + Terms & Conditions + + +
+
+ + +
+
+ +

Authorized Representative

+
+
+ + Name + + + + Designation + + + + Contact Number + + + + Official Email + + +
+
+ + + @if (audit) { +
+ @if (audit.createdBy) { + Created by {{ audit.createdBy }} · {{ audit.createdAt | dateFormat }} + } + @if (audit.updatedBy) { + Last updated by {{ audit.updatedBy }} · {{ audit.updatedAt | dateFormat }} + } + @if (audit.activationDate) { + Activated {{ audit.activationDate }} + } + @if (audit.deactivationDate) { + Deactivated {{ audit.deactivationDate }} + } +
+ } + +
+ +
+
+
+ } +
diff --git a/src/app/organization/institution-profile/institution-profile.component.scss b/src/app/organization/institution-profile/institution-profile.component.scss new file mode 100644 index 000000000..7b30182a4 --- /dev/null +++ b/src/app/organization/institution-profile/institution-profile.component.scss @@ -0,0 +1,226 @@ +/** + * Copyright since 2025 Mifos Initiative + * + * This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. + */ +$primary: #1353a0; +$accent: #0ea5e9; +$border: #e2e8f0; +$gray-50: #f8f9fa; +$gray-200: #e8eaed; +$gray-500: #9aa0a6; +$gray-700: #5f6368; +$gray-900: #202124; + +.ip-container { + max-width: 760px; + margin: 0 auto; + padding: 20px 16px; +} + +.ip-header { + display: flex; + align-items: center; + gap: 14px; + margin-bottom: 20px; + + &__title { + font-size: 18px; + font-weight: 600; + color: $gray-900; + margin: 0; + } +} + +.ip-back { + border-radius: 8px !important; + font-size: 12px !important; + font-weight: 600 !important; + height: 32px !important; + border-color: $border !important; + color: $gray-500 !important; + + i { + margin-right: 6px; + } + + &:hover { + color: $primary !important; + border-color: $primary !important; + } +} + +.ip-alert { + padding: 10px 14px; + margin-bottom: 14px; + border-radius: 8px; + font-size: 13px; + + &--error { + background: #fef2f2; + border: 1px solid #fecaca; + color: #991b1b; + } + + &--success { + background: #f0fdf4; + border: 1px solid #bbf7d0; + color: #14532d; + } +} + +.ip-loading { + padding: 24px; + text-align: center; + color: $gray-500; +} + +.ip-card { + background: #fff; + border: 1px solid $border; + border-radius: 10px; + overflow: hidden; +} + +.ip-section { + &__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; + } + } +} + +.ip-body { + padding: 20px 24px; + display: flex; + flex-wrap: wrap; + gap: 0 16px; +} + +.ip-field { + width: 100%; + margin-bottom: 4px; + + &--half { + width: calc(50% - 8px); + } + + &--third { + width: calc(33.33% - 11px); + } + + &--quarter { + width: calc(25% - 12px); + } +} + +.ip-note { + margin: 0 24px 16px; + padding: 8px 12px; + background: #fffbeb; + border: 1px solid #fde68a; + border-radius: 6px; + font-size: 11px; + color: #92400e; + + a { + color: $primary; + font-weight: 600; + } +} + +.ip-checkbox { + display: block; + margin-bottom: 8px; + font-size: 13px; +} + +.ip-refnote { + margin: 0 0 10px; + font-size: 12px; + color: #64748b; +} + +.ip-reflinks { + display: flex; + flex-wrap: wrap; + gap: 8px; +} + +.ip-reflink { + display: inline-flex; + align-items: center; + gap: 6px; + padding: 6px 12px; + background: #fff; + border: 1px solid $border; + border-radius: 999px; + font-size: 12px; + font-weight: 600; + color: $primary; + text-decoration: none; + + &:hover { + background: #eff6ff; + border-color: $primary; + } + + i { + font-size: 11px; + color: $accent; + } +} + +.ip-lastupdated { + padding: 0 24px 10px; + font-size: 11px; + color: $gray-500; +} + +.ip-audit { + display: flex; + flex-wrap: wrap; + gap: 4px 16px; + padding: 8px 24px 10px; + font-size: 11px; + color: $gray-500; +} + +.ip-actions { + display: flex; + justify-content: flex-end; + gap: 10px; + padding: 14px 24px; + border-top: 1px solid $gray-200; + background: $gray-50; + + &__submit { + border-radius: 8px !important; + font-size: 13px !important; + font-weight: 600 !important; + height: 36px !important; + box-shadow: 0 1px 3px rgba($primary, 0.3) !important; + + i { + margin-right: 6px; + font-size: 12px; + } + } +} diff --git a/src/app/organization/institution-profile/institution-profile.component.ts b/src/app/organization/institution-profile/institution-profile.component.ts new file mode 100644 index 000000000..41e8b41e5 --- /dev/null +++ b/src/app/organization/institution-profile/institution-profile.component.ts @@ -0,0 +1,208 @@ +/** + * 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 { UntypedFormBuilder, UntypedFormGroup } from '@angular/forms'; +import { catchError, of } from 'rxjs'; +import { STANDALONE_SHARED_IMPORTS } from 'app/standalone-shared.module'; +import { OrganizationService } from '../organization.service'; +import { SettingsService } from 'app/settings/settings.service'; + +const BANK_TYPES = [ + { value: 'COOPERATIVE', label: 'Co-operative Bank' }, + { value: 'COMMERCIAL', label: 'Commercial Bank' }, + { value: 'RRB', label: 'Regional Rural Bank' }, + { value: 'NBFC', label: 'NBFC' }, + { value: 'OTHER', label: 'Other' } +]; + +const OWNERSHIP_TYPES = [ + { value: 'GOVERNMENT', label: 'Government' }, + { value: 'PRIVATE', label: 'Private' }, + { value: 'COOPERATIVE', label: 'Cooperative' }, + { value: 'OTHER', label: 'Other' } +]; + +const LICENSE_STATUSES = [ + { value: 'ACTIVE', label: 'Active' }, + { value: 'EXPIRED', label: 'Expired' }, + { value: 'SUSPENDED', label: 'Suspended' }, + { value: 'NOT_APPLICABLE', label: 'Not Applicable' } +]; + +const STATUSES = [ + { value: 'ACTIVE', label: 'Active' }, + { value: 'INACTIVE', label: 'Inactive' } +]; + +/** + * The bank's own "Bank Master" profile — identity/classification, regulatory details, address + * (registered, with optional head-office/communication overrides), contact, the institution-level + * banking identifier (SWIFT/BIC), document header names and terms, the authorised representative, + * and audit fields. A singleton screen: there is only ever one institution running this tenant. + * + * IFSC/MICR are deliberately NOT here — those identify a specific branch, not the institution, and + * live on Branch Profile instead. Business Date, Financial Year, Time Zone, Working Days and Holiday + * Calendar are likewise not stored here — they already exist as their own configuration, and this + * screen only links to them (see the Operational Configuration section) instead of holding a second, + * driftable copy. + */ +@Component({ + selector: 'mifosx-institution-profile', + templateUrl: './institution-profile.component.html', + styleUrls: ['./institution-profile.component.scss'], + imports: [...STANDALONE_SHARED_IMPORTS] +}) +export class InstitutionProfileComponent implements OnInit { + private formBuilder = inject(UntypedFormBuilder); + private organizationService = inject(OrganizationService); + private settingsService = inject(SettingsService); + + bankTypes = BANK_TYPES; + ownershipTypes = OWNERSHIP_TYPES; + licenseStatuses = LICENSE_STATUSES; + statuses = STATUSES; + + form: UntypedFormGroup; + loading = true; + saving = false; + errorMsg = ''; + successMsg = ''; + currencyCode = ''; + audit: any = null; + + private static readonly DATE_FIELDS = [ + 'establishedDate', + 'registrationDate', + 'licenseExpiryDate' + ]; + + ngOnInit(): void { + this.form = this.formBuilder.group({ + bankName: [''], + legalName: [''], + shortName: [''], + bankCode: [''], + institutionCode: [''], + bankType: [null], + ownershipType: [null], + parentInstitutionName: [''], + establishedDate: [null], + status: ['ACTIVE'], + country: ['India'], + + registrationNumber: [''], + licenseNumber: [''], + regulatoryAuthority: [''], + registrationDate: [null], + licenseStatus: [null], + licenseExpiryDate: [null], + pan: [''], + tan: [''], + gstin: [''], + + addressLine1: [''], + addressLine2: [''], + city: [''], + district: [''], + state: [''], + pincode: [''], + headOfficeSameAsRegistered: [true], + headOfficeAddress: [''], + communicationSameAsRegistered: [true], + communicationAddress: [''], + + contactPhone: [''], + contactEmail: [''], + customerCareNumber: [''], + grievanceEmail: [''], + website: [''], + + swiftBic: [''], + + statementHeaderName: [''], + passbookHeaderName: [''], + receiptHeaderName: [''], + termsAndConditions: [''], + + authorizedPersonName: [''], + authorizedPersonDesignation: [''], + authorizedPersonContact: [''], + authorizedPersonEmail: [''] + }); + this.load(); + } + + load(): void { + this.loading = true; + this.errorMsg = ''; + this.organizationService + .getInstitutionProfile() + .pipe( + catchError((err: any) => { + this.errorMsg = err?.error?.errors?.[0]?.defaultUserMessage ?? 'Could not load the bank profile.'; + return of(null); + }) + ) + .subscribe((res: any) => { + this.loading = false; + if (!res) return; + this.applyResult(res); + this.form.markAsPristine(); + }); + } + + submit(): void { + if (this.saving) return; + this.saving = true; + this.errorMsg = ''; + this.successMsg = ''; + const raw = this.form.value; + const payload: any = { ...raw, locale: this.settingsService.language.code }; + for (const field of InstitutionProfileComponent.DATE_FIELDS) { + payload[field] = raw[field] ? this.formatDate(raw[field]) : null; + } + this.organizationService + .updateInstitutionProfile(payload) + .pipe( + catchError((err: any) => { + this.saving = false; + this.errorMsg = err?.error?.errors?.[0]?.defaultUserMessage ?? 'Could not save the bank profile.'; + return of(null); + }) + ) + .subscribe((res: any) => { + this.saving = false; + if (!res) return; + this.successMsg = 'Bank profile saved.'; + this.applyResult(res); + this.form.markAsPristine(); + }); + } + + private applyResult(res: any): void { + const patch: any = { ...res }; + for (const field of InstitutionProfileComponent.DATE_FIELDS) { + patch[field] = res[field] ? new Date(res[field]) : null; + } + this.form.patchValue(patch); + this.currencyCode = res.currencyCode ?? ''; + this.audit = { + createdBy: res.createdByUsername, + createdAt: res.createdAt, + updatedBy: res.updatedByUsername, + updatedAt: res.updatedAt, + activationDate: res.activationDate, + deactivationDate: res.deactivationDate + }; + } + + private formatDate(d: Date): string { + return `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, '0')}-${String(d.getDate()).padStart(2, '0')}`; + } +} diff --git a/src/app/organization/organization-routing.module.ts b/src/app/organization/organization-routing.module.ts index 58bd89ba6..0c35f6ceb 100644 --- a/src/app/organization/organization-routing.module.ts +++ b/src/app/organization/organization-routing.module.ts @@ -237,6 +237,30 @@ const routes: Routes = [ } ] }, + { + // 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. + path: 'institution-profile', + loadComponent: () => + import('./institution-profile/institution-profile.component').then((m) => m.InstitutionProfileComponent), + data: { title: 'Bank Profile', breadcrumb: 'Bank Profile' } + }, + { + // The real-world fields (code, address, contact, type, working hours) behind a branch — + // kept as its own screen rather than folded into Manage Offices, since the underlying + // m_office record is a stock Fineract entity this CBS deliberately left untouched. + path: 'branch-profile', + loadComponent: () => + import('./branch-profile/branch-profile.component').then((m) => m.BranchProfileComponent), + data: { title: 'Branch Profile', breadcrumb: 'Branch Profile' } + }, + { + // Which GL account backs each clearing suspense/settlement role (CTS, NACH, RTGS, ...). + path: 'clearing-gl-config', + loadComponent: () => + import('./clearing-gl-config/clearing-gl-config.component').then((m) => m.ClearingGlConfigComponent), + data: { title: 'Clearing GL Configuration', breadcrumb: 'Clearing GL Configuration' } + }, { path: 'employees', data: { title: 'Manage Employees', breadcrumb: 'Manage Employees' }, diff --git a/src/app/organization/organization.component.html b/src/app/organization/organization.component.html index f54ef9507..4bf1aad35 100644 --- a/src/app/organization/organization.component.html +++ b/src/app/organization/organization.component.html @@ -34,6 +34,48 @@
+ + + + + + + + +
diff --git a/src/app/organization/organization.service.ts b/src/app/organization/organization.service.ts index a5f84ca38..f75312831 100644 --- a/src/app/organization/organization.service.ts +++ b/src/app/organization/organization.service.ts @@ -888,4 +888,51 @@ export class OrganizationService { deleteLoanOriginator(originatorId: number): Observable { return this.http.delete(`/loan-originators/${originatorId}`); } + + // ── Institution Profile ── + + /** @returns {Observable} The bank's own profile (name, legal name, code, contact...). */ + getInstitutionProfile(): Observable { + return this.http.get('/institutionprofile'); + } + + /** @returns {Observable} */ + updateInstitutionProfile(payload: any): Observable { + return this.http.put('/institutionprofile', payload); + } + + // ── Branch Profile ── + + /** @returns {Observable} Every office joined with its branch profile fields, if any. */ + getBranchProfiles(): Observable { + return this.http.get('/branchprofile'); + } + + /** @returns {Observable} One office's branch profile, joined with its office fields. */ + getBranchProfile(officeId: number): Observable { + const params = new HttpParams().set('officeId', String(officeId)); + return this.http.get('/branchprofile', { params }); + } + + /** @returns {Observable} */ + updateBranchProfile(officeId: number, payload: any): Observable { + return this.http.put(`/branchprofile/${officeId}`, payload); + } + + // ── Clearing GL Configuration ── + + /** @returns {Observable} Every clearing role (CTS/NACH/RTGS...) and its mapped GL account, if any. */ + getClearingGlConfig(): Observable { + return this.http.get('/clearingglconfig'); + } + + /** @returns {Observable} Selectable GL accounts for mapping a clearing role. */ + getClearingGlConfigTemplate(): Observable { + return this.http.get('/clearingglconfig/template'); + } + + /** @returns {Observable} */ + updateClearingGlConfig(configKey: string, glAccountId: number | null): Observable { + return this.http.put(`/clearingglconfig/${configKey}`, { glAccountId }); + } } diff --git a/src/app/shared/footer/footer.component.ts b/src/app/shared/footer/footer.component.ts index c895dc100..a6cd40fa4 100644 --- a/src/app/shared/footer/footer.component.ts +++ b/src/app/shared/footer/footer.component.ts @@ -182,12 +182,16 @@ export class FooterComponent implements OnInit, OnDestroy { * Get the Configuration for Business Date */ getConfigurations(): void { - if (this.authenticationService.isAuthenticated()) { - // Reading global configuration needs the READ_CONFIGURATION permission, which teller-style - // roles (cashier, clerk) do not carry. This runs from the footer on every authenticated - // page, so an unpermitted role was greeted by an "Unauthorized Request" modal at login for - // a check it never asked for. The footer only uses this to decide whether to show a - // business date, so a denied read is not an error here — it degrades to the system date. + // Reading global configuration needs the READ_CONFIGURATION permission, which teller-style + // roles (cashier, clerk) never carry by design — the read would always come back 403. The + // footer only uses this to decide whether to show a business date, so a role that can never + // read it just never asks, instead of round-tripping a request every page load (and every + // 60s poll while a day is open) purely to have it rejected. + if ( + this.authenticationService.isAuthenticated() && + !this.authenticationService.isCashierUser() && + !this.authenticationService.isClerkUser() + ) { this.systemService.getConfigurationByName(SettingsService.businessDateConfigName, true).subscribe({ next: (configurationData: any) => { this.isBusinessDateEnabled = configurationData.enabled; diff --git a/src/app/tasks/checker-inbox-and-tasks-tabs/disbursement-requests/disbursement-requests.component.html b/src/app/tasks/checker-inbox-and-tasks-tabs/disbursement-requests/disbursement-requests.component.html index 34feac5a2..ffc799586 100644 --- a/src/app/tasks/checker-inbox-and-tasks-tabs/disbursement-requests/disbursement-requests.component.html +++ b/src/app/tasks/checker-inbox-and-tasks-tabs/disbursement-requests/disbursement-requests.component.html @@ -124,23 +124,8 @@ {{ r.voucher_no || '—' }} - - {{ - r.request_type === 'BANK_TRANSFER' - ? 'Bank Transfer' - : r.request_type === 'COLLECTION' - ? 'Collection' - : 'Disbursement' - }} + + {{ typeLabel(r) }} diff --git a/src/app/tasks/checker-inbox-and-tasks-tabs/disbursement-requests/disbursement-requests.component.ts b/src/app/tasks/checker-inbox-and-tasks-tabs/disbursement-requests/disbursement-requests.component.ts index e9f382ffe..335c88610 100644 --- a/src/app/tasks/checker-inbox-and-tasks-tabs/disbursement-requests/disbursement-requests.component.ts +++ b/src/app/tasks/checker-inbox-and-tasks-tabs/disbursement-requests/disbursement-requests.component.ts @@ -9,10 +9,11 @@ import { Component, OnInit, inject } from '@angular/core'; import { CommonModule } from '@angular/common'; import { FormsModule } from '@angular/forms'; -import { Router, RouterModule } from '@angular/router'; +import { RouterModule } from '@angular/router'; import { MatIconModule } from '@angular/material/icon'; import { MatPaginatorModule, PageEvent } from '@angular/material/paginator'; import { MatSnackBar, MatSnackBarModule } from '@angular/material/snack-bar'; +import { MatDialog } from '@angular/material/dialog'; import { MatNativeDateModule } from '@angular/material/core'; import { MatFormFieldModule } from '@angular/material/form-field'; import { MatInputModule } from '@angular/material/input'; @@ -22,6 +23,7 @@ import { of } from 'rxjs'; import { ClerkDisbursementService } from '../../../cashier/clerk-disbursement.service'; import { SettingsService } from '../../../settings/settings.service'; +import { DisbursementReviewComponent } from '../../../clerk/disbursement-review/disbursement-review.component'; @Component({ selector: 'mifosx-disbursement-requests', @@ -43,9 +45,9 @@ import { SettingsService } from '../../../settings/settings.service'; }) export class DisbursementRequestsComponent implements OnInit { private service = inject(ClerkDisbursementService); - private router = inject(Router); private snackBar = inject(MatSnackBar); private settingsService = inject(SettingsService); + private dialog = inject(MatDialog); requests: any[] = []; totalRecords = 0; @@ -164,11 +166,13 @@ export class DisbursementRequestsComponent implements OnInit { } review(req: any): void { - this.router.navigate([ - '/checker-inbox-and-tasks/disbursement-requests', - req.id, - 'review' - ]); + this.dialog + .open(DisbursementReviewComponent, { + data: { requestId: req.id }, + autoFocus: false + }) + .afterClosed() + .subscribe(() => this.load()); } creditCount(req: any): number { @@ -180,6 +184,24 @@ export class DisbursementRequestsComponent implements OnInit { } } + typeLabel(req: any): string { + const map: Record = { + BANK_TRANSFER: 'Bank Transfer', + COLLECTION: 'Collection', + FUND_TRANSFER: 'Fund Transfer' + }; + return map[req.request_type] ?? 'Disbursement'; + } + + typeColor(req: any): string { + const map: Record = { + BANK_TRANSFER: '#e3f2fd', + COLLECTION: '#fce4ec', + FUND_TRANSFER: '#ede7f6' + }; + return map[req.request_type] ?? '#e8f5e9'; + } + statusClass(status: string): string { const map: Record = { PENDING: 'wri-status--pending', diff --git a/src/environments/.env.ts b/src/environments/.env.ts index ea6d58ac9..f0cbf38b0 100644 --- a/src/environments/.env.ts +++ b/src/environments/.env.ts @@ -9,8 +9,8 @@ /* tslint:disable */ export default { 'mifos_x': { - 'version': '260827', - 'hash': '79ffde51' + 'version': '260828', + 'hash': 'ce840d73' }, 'allow_switching_backend_instance': true }; From fe81d159f14e82e47f4aebf4a3e6d1a8e337c750 Mon Sep 17 00:00:00 2001 From: Nitesh Yadav Date: Sat, 29 Aug 2026 11:03:24 +0530 Subject: [PATCH 14/64] feat(cashier): carry forward yesterday's denomination count into Day Open, warn on a mismatch The Day Open form's note/coin table started at zero even though the branch's own closing count from the day before was already on record -- whoever opened the day had to re-key numbers that were already known. It now auto-fills from that closing count, flags any row a manual edit moves away from it, and shows a live Tallied/Short/Excess comparison against it -- so a short or excess opening count is visible before the day is opened, not discovered later. A reason is required once a mismatch exists, matching the new backend enforcement. Also fixes the register table's date chip wrapping onto two lines when the column narrows. Co-Authored-By: Claude Sonnet 5 --- .../branch-day/branch-day.component.html | 81 +++++++++-- .../branch-day/branch-day.component.scss | 15 ++ .../branch-day/branch-day.component.ts | 133 +++++++++++++++++- src/app/cashier/cashier-shared.scss | 1 + src/app/cashier/cashier.service.ts | 5 +- 5 files changed, 221 insertions(+), 14 deletions(-) diff --git a/src/app/cashier/branch-day/branch-day.component.html b/src/app/cashier/branch-day/branch-day.component.html index 9b97f565d..25bbb9933 100644 --- a/src/app/cashier/branch-day/branch-day.component.html +++ b/src/app/cashier/branch-day/branch-day.component.html @@ -135,11 +135,32 @@

- Left blank with no notes/coins counted below, yesterday's closing figure carries forward automatically — which - is what keeps the opening balance from drifting. Counting the drawer below is what actually records the - note/coin stock the branch can check a vault handout against later. + @if (hasAutoFilledOpenCounts) { + Auto-filled below from yesterday's own closing count (₹{{ carriedForwardTotal | number: '1.2-2' }}) — correct + only the rows where today's physical count differs before opening. + } @else { + Left blank with no notes/coins counted below, yesterday's closing figure carries forward automatically — which + is what keeps the opening balance from drifting. Counting the drawer below is what actually records the + note/coin stock the branch can check a vault handout against later. + }

+ @if (hasAutoFilledOpenCounts && openLiveDifference !== null) { + @if (openLiveDifference === 0) { +
+ vs. yesterday's close + Tallied +
+ } @else { +
+ vs. yesterday's close + + {{ openLiveDifference > 0 ? 'Excess' : 'Short' }} ₹{{ absValue(openLiveDifference) | number: '1.2-2' }} + +
+ } + } +
@@ -160,7 +181,7 @@ - @@ -191,7 +215,7 @@ - @@ -218,11 +245,27 @@ @if (openDenomTotal > 0) {

Denomination total: ₹{{ openDenomTotal | number: '1.2-2' }} + @if (hasAutoFilledOpenCounts) { + + }

} + @if (openLiveDifference !== null && openLiveDifference !== 0) { + + } +
- @@ -414,9 +457,14 @@

- Pre-filled with what the books expect — change it only if the drawer counted something different, and that - difference is then recorded against the day. Counting the denominations below overrides the typed figure with - what was actually counted, note by note. + @if (hasAutoFilledCounts) { + Auto-filled below from the vault and every cashier's position shown above — correct only the rows where the + physical count differs, note by note. + } @else { + Pre-filled with what the books expect — change it only if the drawer counted something different, and that + difference is then recorded against the day. Counting the denominations below overrides the typed figure with + what was actually counted, note by note. + }

@@ -439,7 +487,7 @@
- @@ -470,7 +521,7 @@ - @@ -497,6 +551,11 @@ @if (closeDenomTotal > 0) {

Denomination total: ₹{{ closeDenomTotal | number: '1.2-2' }} + @if (hasAutoFilledCounts) { + + }

} diff --git a/src/app/cashier/branch-day/branch-day.component.scss b/src/app/cashier/branch-day/branch-day.component.scss index 334ee94ba..a4b1ac062 100644 --- a/src/app/cashier/branch-day/branch-day.component.scss +++ b/src/app/cashier/branch-day/branch-day.component.scss @@ -184,6 +184,21 @@ .bd-denom { margin: 4px 0 0; max-width: 520px; + + /* Flags a row where the hand-count no longer matches what the vault/cashier positions reported. */ + &__auto { + display: block; + margin-top: 2px; + font-size: 10px; + white-space: nowrap; + } + + &__reset { + margin-left: 10px; + height: 24px; + padding: 0 10px; + font-size: 11px; + } } /* Auto-fetched EOD summary: what the vault and every cashier under it currently hold. */ diff --git a/src/app/cashier/branch-day/branch-day.component.ts b/src/app/cashier/branch-day/branch-day.component.ts index f6eb1cc20..9abba824f 100644 --- a/src/app/cashier/branch-day/branch-day.component.ts +++ b/src/app/cashier/branch-day/branch-day.component.ts @@ -90,6 +90,9 @@ export class BranchDayComponent implements OnInit { showOpenForm = false; openDate = ''; openingCash: number | null = null; + /** Required only when the counted/typed opening figure differs from yesterday's own closing + * figure — the backend rejects the open without it in that case. */ + openDifferenceReason = ''; opening = false; // Close-day form. The date is fixed by which day is being closed, never typed. @@ -106,6 +109,15 @@ export class BranchDayComponent implements OnInit { coins: Denom[] = []; openCounts: Record = {}; closeCounts: Record = {}; + /** What yesterday's own close counted, note by note — the open form starts from this instead of + * zero. The backend already carries this figure forward on its own when nothing is submitted for + * the day being opened; showing it here is only about letting whoever opens the day see and + * correct it, not a second place the carry-forward logic lives. */ + openCountsAuto: Record = {}; + /** What the vault + every cashier's live position added up to when the close form opened — the + * physical-count table starts from this instead of zero, since the EOD summary already fetched + * it. Kept separately from closeCounts so a manual recount can be told apart from it and undone. */ + closeCountsAuto: Record = {}; get allDenoms(): Denom[] { return [ @@ -212,21 +224,98 @@ export class BranchDayComponent implements OnInit { // over it is how a carried balance drifts. this.openingCash = null; this.openCounts = {}; + this.openCountsAuto = {}; + this.openDifferenceReason = ''; this.successMsg = ''; this.errorMsg = ''; + this.loadCarriedForwardDenominations(); } cancelOpen(): void { this.showOpenForm = false; + this.openCountsAuto = {}; + } + + /** + * Pulls yesterday's own closing note/coin count so the open form shows what is about to be + * carried forward instead of a blank table the caller has to trust blindly. Only the most + * recently closed day is used — the same day the backend itself falls back to when the caller + * submits no denominations and no lump sum. + */ + private loadCarriedForwardDenominations(): void { + const lastClosed = this.days.find((d) => d.status === 'CLOSED' && d.closingCash != null); + if (!lastClosed) return; + this.service + .branchDayDenominations(lastClosed.officeId, lastClosed.businessDate) + .pipe(catchError(() => of(null))) + .subscribe((res: any) => { + const closing = res?.closing || []; + if (!closing.length) return; + const totals: Record = {}; + for (const d of closing) { + totals[d.denominationId] = (totals[d.denominationId] || 0) + Number(d.count || 0); + } + this.openCountsAuto = totals; + // Only overwrite the table if nothing has been typed/counted since the form opened. + if (this.openDenomTotal === 0 && this.openingCash == null) { + this.openCounts = { ...totals }; + } + }); + } + + get hasAutoFilledOpenCounts(): boolean { + return Object.keys(this.openCountsAuto).length > 0; + } + + autoOpenCountFor(d: Denom): number { + return this.openCountsAuto[d.id] || 0; + } + + resetOpenToAutoFill(): void { + this.openCounts = { ...this.openCountsAuto }; + } + + get carriedForwardTotal(): number { + return this.totalOf(this.openCountsAuto); + } + + /** The figure the form would actually submit right now — the denomination total when any note/coin + * has been counted, otherwise the plain typed-in amount. Mirrors {@link effectiveClosingFigure}. */ + get effectiveOpeningFigure(): number | null { + return this.openDenomTotal > 0 ? this.openDenomTotal : this.openingCash; + } + + /** Difference between what is about to be opened with and what yesterday actually closed with — + * visible before saving, the same way the close form shows a live difference against the book + * figure, so a short/excess opening count isn't discovered only after the day is already running. */ + get openLiveDifference(): number | null { + if (!this.hasAutoFilledOpenCounts || this.effectiveOpeningFigure == null) return null; + return this.effectiveOpeningFigure - this.carriedForwardTotal; + } + + /** True once a reason is needed but hasn't been typed — the same "explain the gap before it's + * allowed" gate the close form already applies to a late close. */ + get needsOpenDifferenceReason(): boolean { + return this.openLiveDifference != null && this.openLiveDifference !== 0 && !this.openDifferenceReason.trim(); + } + + get canSubmitOpen(): boolean { + return !!this.openDate && !this.opening && !this.needsOpenDifferenceReason; } submitOpen(): void { - if (!this.openDate) return; + if (!this.openDate || this.needsOpenDifferenceReason) return; this.opening = true; this.errorMsg = ''; const denominations = this.denominationPayload(this.openCounts); this.service - .openBranchDay(this.openDate, this.openingCash ?? undefined, undefined, denominations) + .openBranchDay( + this.openDate, + this.openingCash ?? undefined, + undefined, + denominations, + this.openDifferenceReason.trim() || undefined + ) .pipe( catchError((err: any) => { this.errorMsg = @@ -252,6 +341,7 @@ export class BranchDayComponent implements OnInit { this.closeTarget = day; this.closingCash = null; this.closeCounts = {}; + this.closeCountsAuto = {}; this.closeRemarks = ''; this.forceReason = ''; this.successMsg = ''; @@ -327,9 +417,47 @@ export class BranchDayComponent implements OnInit { .subscribe((results: any[]) => { this.eodLoading = false; this.eodTellers = results; + this.autoFillCloseCounts(); }); } + /** + * Seeds the physical-count table from what the vault and every cashier are already shown to be + * holding, instead of leaving it at zero and making whoever closes the day re-key numbers that + * are already on screen above. It stays a starting point, not a lock — any row can still be + * corrected against what was actually counted, and {@link resetToAutoFill} undoes a correction. + */ + private autoFillCloseCounts(): void { + const totals: Record = {}; + for (const t of this.eodTellers) { + for (const d of t.vault?.denominations || []) { + totals[d.denominationId] = (totals[d.denominationId] || 0) + Number(d.count || 0); + } + for (const c of t.cashiers || []) { + for (const d of c.denominations || []) { + totals[d.denominationId] = (totals[d.denominationId] || 0) + Number(d.count || 0); + } + } + } + if (Object.keys(totals).length === 0) return; + this.closeCountsAuto = totals; + this.closeCounts = { ...totals }; + } + + get hasAutoFilledCounts(): boolean { + return Object.keys(this.closeCountsAuto).length > 0; + } + + /** The auto-fetched count for one denomination, so a row that was hand-edited away from it can + * still show what the vault + cashier positions said. */ + autoCountFor(d: Denom): number { + return this.closeCountsAuto[d.id] || 0; + } + + resetToAutoFill(): void { + this.closeCounts = { ...this.closeCountsAuto }; + } + private loadTellerSummary(teller: any, date: string) { return forkJoin({ vault: this.service.vaultPosition(teller.id, date).pipe(catchError(() => of(null))), @@ -403,6 +531,7 @@ export class BranchDayComponent implements OnInit { cancelClose(): void { this.closeTarget = null; this.eodTellers = []; + this.closeCountsAuto = {}; } /** A date earlier than today is a late close, and has to say why it was left open. */ diff --git a/src/app/cashier/cashier-shared.scss b/src/app/cashier/cashier-shared.scss index 6985025e4..90f9984cc 100644 --- a/src/app/cashier/cashier-shared.scss +++ b/src/app/cashier/cashier-shared.scss @@ -206,6 +206,7 @@ border-radius: 6px; font-size: 12px; font-weight: 700; + white-space: nowrap; background: linear-gradient(135deg, #ecfeff, #cffafe); color: #155e75; border: 1px solid #a5f3fc; diff --git a/src/app/cashier/cashier.service.ts b/src/app/cashier/cashier.service.ts index 7725e5e36..65e4cce0b 100644 --- a/src/app/cashier/cashier.service.ts +++ b/src/app/cashier/cashier.service.ts @@ -229,17 +229,20 @@ export class CashierService { return this.http.get('/branchday/close-preview', { params }); } + /** openingDifferenceReason is required when the opening figure differs from yesterday's closing. */ openBranchDay( businessDate: string, openingCash?: number, officeId?: number, - denominations?: { denominationId: number; denominationValue: number; count: number }[] + denominations?: { denominationId: number; denominationValue: number; count: number }[], + openingDifferenceReason?: string ): Observable { return this.http.post('/branchday/open', { businessDate, openingCash: openingCash != null ? String(openingCash) : null, officeId: officeId ?? null, denominations: denominations?.length ? denominations : null, + openingDifferenceReason: openingDifferenceReason ?? null, locale: 'en' }); } From 07a4c8b8e30f09aabd6ca5f4fe8a163225619992 Mon Sep 17 00:00:00 2001 From: Nitesh Yadav Date: Sun, 30 Aug 2026 10:38:39 +0530 Subject: [PATCH 15/64] fix(cashier,savings): real bank/branch letterhead on statements, print-preview flow, WITHDRAWN status fixes Account Statement is no longer a jsPDF file that downloads the moment the dialog opens. It now shows a live, on-screen preview styled the same way the working Passbook screen already is (bank header, branch line, account-details band, balance strip, transaction table), with its own date-range filter, and only prints via the browser's print dialog on an explicit click -- exactly the same clone-into-a-popup-window mechanism Passbook already uses, so "Save as PDF" is the reader's own choice rather than something forced on open. Both Passbook and the new Statement preview pull the bank name, license number, and branch name/address/IFSC/MICR from Institution Profile and Branch Profile instead of a hardcoded bank name -- a printed passbook or statement now actually identifies which bank and branch issued it. Cheque Book's own issuance detail screen gets a matching "Print" action, laying out a one-page issuance-slip PDF from data already on that screen (which book/series, leaf range, issued-to account, issue date). Cash Hold and Clearing Hold on the Transaction Entry balance strip were both hardcoded 0.00 placeholders; they now read the account's real hold-category breakdown, and a stray "Freeze Amount" placeholder a few rows up is wired to the working getter that was already sitting right next to it. A clerk withdrawal's own terminal status is WITHDRAWN, not APPROVED -- the Day Book screen had the same status-string gap the backend GL report did: neither its running CR/DR totals nor its status badge recognized a completed withdrawal, so real money that had gone out never moved the totals off zero. The Closing Cash Report's GL Entries panel also never refreshed for a newly picked date once it had already been opened once -- "Load" hid the panel instead of refreshing it, leaving a stale, wrong-date table under a header that had already relabelled itself correctly. Co-Authored-By: Claude Sonnet 5 --- .../closing-cash-report.component.ts | 14 +- .../cashier/day-book/day-book.component.ts | 8 +- .../transaction-entry.component.html | 21 +- .../transaction-entry.component.ts | 754 +++++++++++++++++- .../cheque-book-detail.component.html | 7 +- .../cheque-book-detail.component.ts | 6 + .../cheque-issuance-slip-pdf.ts | 120 +++ .../passbook-tab/passbook-tab.component.html | 23 +- .../passbook-tab/passbook-tab.component.scss | 12 + .../passbook-tab/passbook-tab.component.ts | 52 +- src/environments/.env.ts | 4 +- 11 files changed, 1006 insertions(+), 15 deletions(-) create mode 100644 src/app/chequebook/cheque-book-detail/cheque-issuance-slip-pdf.ts diff --git a/src/app/cashier/closing-cash-report/closing-cash-report.component.ts b/src/app/cashier/closing-cash-report/closing-cash-report.component.ts index 2b0e0a183..81d6c3634 100644 --- a/src/app/cashier/closing-cash-report/closing-cash-report.component.ts +++ b/src/app/cashier/closing-cash-report/closing-cash-report.component.ts @@ -84,7 +84,6 @@ export class ClosingCashReportComponent implements OnInit { } load(): void { - this.showGl = false; this.loading = true; this.service.closingCashReport(this.date).subscribe({ next: (res: any) => { @@ -106,6 +105,19 @@ export class ClosingCashReportComponent implements OnInit { this.loaded = true; } }); + + // GL Entries is scoped to `this.date` too, but was never re-fetched here -- changing the date and + // clicking Load left glLoaded=true from whatever date was open before, so a stale, wrong-date GL + // table stayed on screen under a header that had already relabelled itself to the new date. If + // the panel was open, refresh it for the date just loaded; otherwise just invalidate it so the + // next time it's opened it fetches fresh instead of showing leftover rows. + this.glOffset = 0; + this.glLoaded = false; + if (this.showGl) { + this.loadGl(); + } else { + this.glRows = []; + } } toggleGl(): void { diff --git a/src/app/cashier/day-book/day-book.component.ts b/src/app/cashier/day-book/day-book.component.ts index d6fe2dbd4..534f9d7ca 100644 --- a/src/app/cashier/day-book/day-book.component.ts +++ b/src/app/cashier/day-book/day-book.component.ts @@ -200,16 +200,20 @@ export class DayBookComponent implements OnInit { .reduce((s, r) => s + Number(r.amount ?? 0), 0); } - /** Only approved money is real money — a pending or rejected voucher must not move the totals. */ + /** Only approved money is real money — a pending or rejected voucher must not move the totals. + * The three source registers don't share one terminal status string: deposits and disbursements + * land on APPROVED, but a clerk withdrawal's own finished state is WITHDRAWN — missing it here + * meant a completed withdrawal moved real money out yet the DR total stayed at zero. */ private countsAsMoney(r: DayBookRow): boolean { const s = (r.status ?? '').toUpperCase(); - return s === 'APPROVED' || s === 'COMPLETED'; + return s === 'APPROVED' || s === 'COMPLETED' || s === 'WITHDRAWN'; } statusClass(status: string): string { switch ((status ?? '').toUpperCase()) { case 'APPROVED': case 'COMPLETED': + case 'WITHDRAWN': return 'db-badge db-badge--ok'; case 'PENDING': return 'db-badge db-badge--pending'; diff --git a/src/app/cashier/transaction-entry/transaction-entry.component.html b/src/app/cashier/transaction-entry/transaction-entry.component.html index 76de4e137..b260d1a8c 100644 --- a/src/app/cashier/transaction-entry/transaction-entry.component.html +++ b/src/app/cashier/transaction-entry/transaction-entry.component.html @@ -804,7 +804,7 @@
4.Freeze Amount - {{ currencySymbol }}0.00 + {{ currencySymbol }}{{ freezeAmount | number: '1.2-2' }}
5.Withdrawable Amt. @@ -865,7 +865,7 @@
Cash Hold - 0.00 + {{ cashHold | number: '1.2-2' }}
Transfer Hold @@ -873,7 +873,7 @@
Clearing Hold - 0.00 + {{ clearingHold | number: '1.2-2' }}
After Trans. @@ -889,6 +889,11 @@ Currency {{ accountDetails.currency?.code ?? 'INR' }}
+ @if (canPrintStatement) { + + } } @else { @@ -989,7 +994,7 @@
Cash Hold - 0.00 + {{ cashHold | number: '1.2-2' }}
Tr. Hold @@ -997,13 +1002,19 @@
Clr. Hold - 0.00 + {{ clearingHold | number: '1.2-2' }}
After Trans. {{ currencySymbol }}{{ clearBalance | number: '1.2-2' }}
+ @if (canPrintStatement) { +
+ + } @if (accountAlerts.length > 0) {
diff --git a/src/app/cashier/transaction-entry/transaction-entry.component.ts b/src/app/cashier/transaction-entry/transaction-entry.component.ts index e1bfb4d24..60afe5ff5 100644 --- a/src/app/cashier/transaction-entry/transaction-entry.component.ts +++ b/src/app/cashier/transaction-entry/transaction-entry.component.ts @@ -6,7 +6,7 @@ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ -import { Component, OnInit, OnDestroy, inject, Input } from '@angular/core'; +import { Component, OnInit, OnDestroy, inject, Input, ElementRef, ViewChild } from '@angular/core'; import { CommonModule } from '@angular/common'; import { DomSanitizer, SafeResourceUrl } from '@angular/platform-browser'; import { FormsModule } from '@angular/forms'; @@ -30,6 +30,7 @@ import { CustomerSearchResult } from '../../shared/customer-search-dialog/customer-search-dialog.component'; import { SponsorDDService } from '../../sponsor-dd/sponsor-dd.service'; +import { OrganizationService } from '../../organization/organization.service'; interface TxnRow { typ: string; @@ -62,6 +63,709 @@ interface Denomination { label: string; } +// ── Account Statement Dialog ─────────────────────────────────────────────────── +// +// Deliberately the same layout and print mechanism as +// savings/savings-account-view/passbook-tab.component.ts (bank-header/branch-line/ +// account-details-band/balance-summary-strip/statement-table/statement-footer, cloned into a popup +// window for window.print()) rather than a generated PDF: a UCB's own passbook screen is the +// reference for what a statement should look like, and jsPDF's own layout would inevitably drift +// from it. Nothing downloads on open — the statement is only ever shown as a preview here; printing +// (and "Save as PDF" from the browser's own print dialog, if a file is wanted) is a deliberate, +// separate click. + +@Component({ + selector: 'mifosx-account-statement-dialog', + template: ` +
+
+ + + + + +
+ +
+
+
+
+
+ account_balance +
+
+
{{ bankName }}
+
{{ bankSubtitle }}
+
+
+
+
STATEMENT OF ACCOUNT
+
{{ fromDate || '—' }} to {{ toDate || '—' }}
+
+
+ + @if (branchName || ifscCode || micrCode) { +
+ @if (branchName) { + {{ branchName }} Branch + } + @if (branchAddress) { + {{ branchAddress }} + } + @if (ifscCode) { + IFSC: {{ ifscCode }} + } + @if (micrCode) { + MICR: {{ micrCode }} + } +
+ } + + + +
+
+
Current Balance
+
{{ currencySym }}{{ currentBalance | number: '1.2-2' }}
+
+
+
+
Available Balance
+
{{ currencySym }}{{ availableBalance | number: '1.2-2' }}
+
+
+
+
Total Transactions
+
{{ filteredRows.length }}
+
+
+ + @if (filteredRows.length > 0) { +
{{ d.label }} +
+ @if (hasAutoFilledOpenCounts && countFor(openCounts, d) !== autoOpenCountFor(d)) { + yesterday closed {{ autoOpenCountFor(d) }} + }
₹{{ subtotalFor(openCounts, d) | number: '1.2-2' }}
{{ d.label }} +
+ @if (hasAutoFilledOpenCounts && countFor(openCounts, d) !== autoOpenCountFor(d)) { + yesterday closed {{ autoOpenCountFor(d) }} + }
₹{{ subtotalFor(openCounts, d) | number: '1.2-2' }}
{{ d.label }} +
+ @if (hasAutoFilledCounts && countFor(closeCounts, d) !== autoCountFor(d)) { + system counted {{ autoCountFor(d) }} + }
₹{{ subtotalFor(closeCounts, d) | number: '1.2-2' }}
{{ d.label }} +
+ @if (hasAutoFilledCounts && countFor(closeCounts, d) !== autoCountFor(d)) { + system counted {{ autoCountFor(d) }} + }
₹{{ subtotalFor(closeCounts, d) | number: '1.2-2' }}
+ + + + + + + + + + + + + @for (row of filteredRows; track row.id; let i = $index) { + + + + + + + + + + } + + + + + + + + + +
#DateDescriptionRef No.Debit (Dr)Credit (Cr)Balance
{{ i + 1 }}{{ row.dateLabel }} + {{ row.description }} + {{ row.id }} + @if (row.debit > 0) { + {{ currencySym }}{{ row.debit | number: '1.2-2' }} + } + + @if (row.credit > 0) { + {{ currencySym }}{{ row.credit | number: '1.2-2' }} + } + + {{ currencySym }}{{ row.runningBalance | number: '1.2-2' }} +
Closing Balance{{ currencySym }}{{ currentBalance | number: '1.2-2' }}
+ } @else { +
+ inbox +

No transactions in this period.

+
+ } + + +
+
+
+ `, + styles: [ + ` + .asd-shell { + display: flex; + flex-direction: column; + height: 82vh; + width: 780px; + max-width: 90vw; + } + .asd-filterbar { + display: flex; + align-items: flex-end; + gap: 12px; + padding: 12px 16px; + border-bottom: 1px solid #e2e8f0; + } + .asd-field { + display: flex; + flex-direction: column; + gap: 3px; + font-size: 11px; + color: #64748b; + + input { + padding: 6px 8px; + border: 1px solid #e2e8f0; + border-radius: 6px; + font-size: 12px; + } + } + .asd-spacer { + flex: 1; + } + .asd-btn { + padding: 7px 14px; + border-radius: 6px; + border: 1px solid #e2e8f0; + background: #fff; + cursor: pointer; + font-size: 13px; + + &--primary { + background: #1e40af; + border-color: #1e40af; + color: #fff; + } + } + .asd-scroll { + flex: 1; + overflow: auto; + padding: 16px; + background: #eef1f6; + } + + /* Statement look — mirrors passbook-tab.component's PRINT_STYLES exactly, so this preview and + a real passbook read the same way. */ + .statement-wrapper { + background: #fff; + border: 1px solid #d0d7e3; + border-radius: 6px; + overflow: hidden; + font-family: 'Segoe UI', Arial, sans-serif; + color: #333; + } + .bank-header { + background: #1a3a6b; + padding: 14px 20px; + display: flex; + justify-content: space-between; + align-items: center; + border-bottom: 4px solid #c8a951; + } + .bank-logo-area { + display: flex; + align-items: center; + gap: 12px; + } + .bank-logo-circle { + width: 44px; + height: 44px; + border-radius: 50%; + background: #c8a951; + display: flex; + align-items: center; + justify-content: center; + + mat-icon { + color: #1a3a6b; + } + } + .bank-name-block .bank-title { + color: #fff; + font-size: 16px; + font-weight: 700; + letter-spacing: 0.3px; + } + .bank-name-block .bank-subtitle { + color: #c8a951; + font-size: 10px; + font-weight: 500; + letter-spacing: 1.5px; + text-transform: uppercase; + margin-top: 2px; + } + .statement-label { + text-align: right; + } + .statement-label .stmt-label-text { + color: #c8a951; + font-size: 12px; + font-weight: 700; + letter-spacing: 2px; + text-transform: uppercase; + } + .statement-label .stmt-period { + color: rgb(255 255 255 / 75%); + font-size: 10px; + margin-top: 3px; + } + .branch-line { + background: #e8edf5; + border-bottom: 1px solid #d0d7e3; + padding: 5px 20px; + display: flex; + flex-wrap: wrap; + gap: 4px 16px; + font-size: 9.5px; + color: #4a5a7a; + } + .account-details-band { + background: #f0f3f9; + border-bottom: 1px solid #d0d7e3; + padding: 12px 20px; + } + .account-details-grid { + display: grid; + grid-template-columns: repeat(3, 1fr); + gap: 10px 24px; + } + .detail-block .detail-label { + font-size: 8.5px; + font-weight: 700; + color: #7a8aaa; + text-transform: uppercase; + letter-spacing: 0.8px; + margin-bottom: 2px; + } + .detail-block .detail-value { + font-size: 12px; + color: #222; + } + .detail-block .detail-value.bold { + font-weight: 600; + } + .detail-block .detail-value.mono { + font-family: 'Courier New', monospace; + letter-spacing: 1px; + } + .balance-summary-strip { + display: flex; + align-items: stretch; + background: #1a3a6b; + } + .balance-box { + flex: 1; + padding: 10px 18px; + text-align: center; + } + .balance-box .balance-box-label { + font-size: 9px; + font-weight: 600; + color: rgb(255 255 255 / 65%); + text-transform: uppercase; + letter-spacing: 1px; + margin-bottom: 3px; + } + .balance-box .balance-box-value { + font-size: 15px; + font-weight: 700; + color: #c8a951; + } + .balance-divider { + width: 1px; + background: rgb(255 255 255 / 15%); + margin: 6px 0; + } + .statement-table { + width: 100%; + border-collapse: collapse; + font-size: 11px; + } + .statement-table thead tr { + background: #e8edf5; + border-top: 1px solid #d0d7e3; + border-bottom: 2px solid #1a3a6b; + } + .statement-table thead th { + padding: 8px 9px; + font-size: 10px; + font-weight: 700; + color: #1a3a6b; + text-transform: uppercase; + letter-spacing: 0.4px; + white-space: nowrap; + text-align: left; + } + .statement-table tbody tr { + border-bottom: 1px solid #e8ecf3; + } + .statement-table tbody tr:nth-child(even) { + background: #f4f7fb; + } + .statement-table tbody td { + padding: 6px 9px; + vertical-align: middle; + color: #333; + } + .statement-table tfoot .total-row { + background: #e8edf5; + border-top: 2px solid #1a3a6b; + } + .statement-table tfoot .total-row td { + padding: 8px 9px; + } + .col-sr { + width: 34px; + } + .col-date { + width: 100px; + white-space: nowrap; + } + .col-desc { + min-width: 150px; + } + .col-ref { + width: 74px; + } + .col-amount { + width: 100px; + } + .col-balance { + width: 110px; + } + .debit-amount { + color: #c0392b; + font-weight: 500; + } + .credit-amount { + color: #1a7a3f; + font-weight: 500; + } + .balance-amount { + color: #1a3a6b; + font-weight: 600; + font-family: 'Courier New', monospace; + } + .total-label { + font-size: 11px; + font-weight: 700; + color: #1a3a6b; + text-transform: uppercase; + letter-spacing: 0.5px; + } + .closing-balance { + font-size: 13px; + font-weight: 700; + color: #1a3a6b; + font-family: 'Courier New', monospace; + } + .txn-desc { + font-weight: 500; + } + .text-right { + text-align: right; + } + .text-center { + text-align: center; + } + .text-muted { + color: #999; + } + .mono { + font-family: 'Courier New', monospace; + font-size: 10px; + } + .no-data-block { + padding: 40px; + text-align: center; + color: #aaa; + font-size: 13px; + } + .statement-footer { + background: #f0f3f9; + border-top: 1px solid #d0d7e3; + padding: 10px 20px; + text-align: center; + } + .statement-footer .footer-note { + font-size: 9px; + color: #888; + margin-bottom: 3px; + font-style: italic; + } + .statement-footer .footer-meta { + font-size: 9px; + color: #aaa; + letter-spacing: 0.3px; + } + ` + ], + standalone: true, + imports: [ + CommonModule, + FormsModule, + MatIconModule + ] +}) +export class AccountStatementDialogComponent implements OnInit { + dialogRef = inject>(MatDialogRef); + data = inject<{ account: any }>(MAT_DIALOG_DATA); + private organizationService = inject(OrganizationService); + + @ViewChild('statementContent') statementContent!: ElementRef; + + today = new Date().toISOString().slice(0, 10); + fromDate = ''; + toDate = this.today; + + bankName = 'Bank'; + bankSubtitle = 'Account Statement'; + branchName = ''; + branchAddress = ''; + ifscCode = ''; + micrCode = ''; + + accountNo = ''; + accountHolder = ''; + productName = ''; + currencySym = '₹'; + currentBalance = 0; + availableBalance = 0; + + private allRows: any[] = []; + filteredRows: any[] = []; + + ngOnInit(): void { + const acc = this.data.account ?? {}; + this.accountNo = acc.accountNo ?? ''; + this.accountHolder = acc.clientName ?? acc.groupName ?? ''; + this.productName = acc.savingsProductName ?? ''; + this.currencySym = acc.currency?.displaySymbol ?? acc.currency?.code ?? '₹'; + this.currentBalance = Number(acc.summary?.accountBalance ?? 0); + this.availableBalance = Number(acc.summary?.availableBalance ?? this.currentBalance); + + this.allRows = this.buildRows(acc.transactions ?? []); + this.filteredRows = [...this.allRows]; + + forkJoin({ + institution: this.organizationService.getInstitutionProfile().pipe(catchError(() => of(null))), + branches: this.organizationService.getBranchProfiles().pipe(catchError(() => of([]))) + }).subscribe(({ institution, branches }: any) => { + if (institution) { + this.bankName = institution.passbookHeaderName || institution.bankName || institution.legalName || 'Bank'; + this.bankSubtitle = institution.licenseNumber ? `Lic. No. ${institution.licenseNumber}` : 'Account Statement'; + } + const branch = Array.isArray(branches) ? branches[0] : null; + if (branch) { + this.branchName = branch.officeName ?? ''; + this.branchAddress = branch.address ?? ''; + this.ifscCode = branch.ifscCode ?? ''; + this.micrCode = branch.micrCode ?? ''; + } + }); + } + + private transactionDateAsTime(date: any): number { + if (Array.isArray(date) && date.length >= 3) { + return new Date(date[0], date[1] - 1, date[2]).getTime(); + } + return new Date(date).getTime(); + } + + private isDebit(transactionType: any): boolean { + return !!( + transactionType?.withdrawal || + transactionType?.feeDeduction || + transactionType?.overdraftInterest || + transactionType?.withholdTax + ); + } + + /** Mirrors passbook-tab.component's recalculateRunningBalances()/isDebit() -- same account, same + * ledger, so the two screens must never disagree on what a transaction is worth. */ + private buildRows(transactions: any[]): any[] { + const nonReversed = (transactions ?? []).filter((t: any) => !t.reversed); + const sorted = [...nonReversed].sort((a, b) => { + const da = this.transactionDateAsTime(a.date); + const db = this.transactionDateAsTime(b.date); + return da !== db ? da - db : (a.id ?? 0) - (b.id ?? 0); + }); + let balance = 0; + return sorted.map((txn) => { + const affectsBalance = + !txn.transactionType?.accrual && !txn.transactionType?.amountHold && !txn.transactionType?.amountRelease; + const debit = this.isDebit(txn.transactionType); + if (affectsBalance) { + balance += debit ? -txn.amount : txn.amount; + } + const time = this.transactionDateAsTime(txn.date); + return { + id: txn.id, + time, + dateLabel: isNaN(time) + ? '—' + : new Date(time).toLocaleDateString('en-IN', { day: '2-digit', month: 'short', year: 'numeric' }), + description: txn.transactionType?.value ?? '—', + debit: debit ? Number(txn.amount) : 0, + credit: !debit ? Number(txn.amount) : 0, + runningBalance: Math.round(balance * 100) / 100 + }; + }); + } + + applyFilter(): void { + if (!this.fromDate || !this.toDate) { + this.filteredRows = [...this.allRows]; + return; + } + const from = new Date(this.fromDate).getTime(); + const to = new Date(this.toDate).getTime(); + this.filteredRows = this.allRows.filter((r) => r.time >= from && r.time <= to); + } + + /** Same clone-into-popup-window approach as passbook-tab.component.printPassbook() -- the on-screen + * preview is never itself printed directly, since the dialog chrome around it must not appear. */ + printStatement(): void { + const source = this.statementContent?.nativeElement as HTMLElement; + if (!source) return; + + const clone = source.cloneNode(true) as HTMLElement; + clone.querySelectorAll('mat-icon, .mat-icon').forEach((el: Element) => { + const insideLogo = !!el.closest('.bank-logo-circle'); + if (insideLogo) { + el.outerHTML = + ''; + } else { + el.remove(); + } + }); + + const printWindow = window.open('', 'Statement', 'height=800,width=1100'); + if (!printWindow) return; + + printWindow.document.write(` + + + + Account Statement - ${this.accountNo} + + + + ${clone.outerHTML} + + + + `); + printWindow.document.close(); + printWindow.focus(); + } + + /** + * A popup window opened via window.open() doesn't inherit this component's own scoped Angular + * styles, so the exact same statement CSS has to be re-supplied here as plain text -- this is a + * duplicate of the `styles: [...]` block above by necessity, not an oversight; keep the two in sync + * if either changes. Mirrors passbook-tab.component.ts's own PRINT_STYLES constant for the same + * reason. + */ + private static readonly PRINT_STYLES = ` + @page { size: A4 portrait; margin: 10mm; } + * { margin: 0; padding: 0; box-sizing: border-box; -webkit-print-color-adjust: exact; print-color-adjust: exact; } + body { font-family: 'Segoe UI', Arial, sans-serif; color: #333; background: #fff; } + .statement-wrapper { background: #fff; border: 1px solid #d0d7e3; border-radius: 6px; overflow: hidden; } + .bank-header { background: #1a3a6b; padding: 14px 20px; display: flex; justify-content: space-between; align-items: center; border-bottom: 4px solid #c8a951; } + .bank-logo-area { display: flex; align-items: center; gap: 12px; } + .bank-logo-circle { width: 44px; height: 44px; border-radius: 50%; background: #c8a951; display: flex; align-items: center; justify-content: center; } + .bank-logo-svg { fill: #1a3a6b; display: block; } + .bank-name-block .bank-title { color: #fff; font-size: 16px; font-weight: 700; letter-spacing: 0.3px; } + .bank-name-block .bank-subtitle { color: #c8a951; font-size: 10px; font-weight: 500; letter-spacing: 1.5px; text-transform: uppercase; margin-top: 2px; } + .statement-label { text-align: right; } + .statement-label .stmt-label-text { color: #c8a951; font-size: 12px; font-weight: 700; letter-spacing: 2px; text-transform: uppercase; } + .statement-label .stmt-period { color: rgba(255, 255, 255, 0.75); font-size: 10px; margin-top: 3px; } + .branch-line { background: #e8edf5; border-bottom: 1px solid #d0d7e3; padding: 5px 20px; display: flex; flex-wrap: wrap; gap: 4px 16px; font-size: 9.5px; color: #4a5a7a; } + .account-details-band { background: #f0f3f9; border-bottom: 1px solid #d0d7e3; padding: 12px 20px; } + .account-details-grid { display: grid; grid-template-columns: repeat(3, 1fr); gap: 10px 24px; } + .detail-block .detail-label { font-size: 8.5px; font-weight: 700; color: #7a8aaa; text-transform: uppercase; letter-spacing: 0.8px; margin-bottom: 2px; } + .detail-block .detail-value { font-size: 12px; color: #222; } + .detail-block .detail-value.bold { font-weight: 600; } + .detail-block .detail-value.mono { font-family: 'Courier New', monospace; letter-spacing: 1px; } + .balance-summary-strip { display: flex; align-items: stretch; background: #1a3a6b; } + .balance-box { flex: 1; padding: 10px 18px; text-align: center; } + .balance-box .balance-box-label { font-size: 9px; font-weight: 600; color: rgba(255, 255, 255, 0.65); text-transform: uppercase; letter-spacing: 1px; margin-bottom: 3px; } + .balance-box .balance-box-value { font-size: 15px; font-weight: 700; color: #c8a951; } + .balance-divider { width: 1px; background: rgba(255, 255, 255, 0.15); margin: 6px 0; } + .statement-table { width: 100%; border-collapse: collapse; font-size: 11px; } + .statement-table thead { display: table-header-group; } + .statement-table tfoot { display: table-row-group; } + .statement-table tr { page-break-inside: avoid; break-inside: avoid; } + .statement-table thead tr { background: #e8edf5; border-top: 1px solid #d0d7e3; border-bottom: 2px solid #1a3a6b; } + .statement-table thead th { padding: 8px 9px; font-size: 10px; font-weight: 700; color: #1a3a6b; text-transform: uppercase; letter-spacing: 0.4px; white-space: nowrap; text-align: left; } + .statement-table tbody tr { border-bottom: 1px solid #e8ecf3; } + .statement-table tbody tr:nth-child(even) { background: #f4f7fb; } + .statement-table tbody td { padding: 6px 9px; vertical-align: middle; color: #333; } + .statement-table tfoot .total-row { background: #e8edf5; border-top: 2px solid #1a3a6b; } + .statement-table tfoot .total-row td { padding: 8px 9px; } + .col-sr { width: 34px; } + .col-date { width: 100px; white-space: nowrap; } + .col-desc { min-width: 150px; } + .col-ref { width: 74px; } + .col-amount { width: 100px; } + .col-balance { width: 110px; } + .debit-amount { color: #c0392b; font-weight: 500; } + .credit-amount { color: #1a7a3f; font-weight: 500; } + .balance-amount { color: #1a3a6b; font-weight: 600; font-family: 'Courier New', monospace; } + .total-label { font-size: 11px; font-weight: 700; color: #1a3a6b; text-transform: uppercase; letter-spacing: 0.5px; } + .closing-balance { font-size: 13px; font-weight: 700; color: #1a3a6b; font-family: 'Courier New', monospace; } + .txn-desc { font-weight: 500; } + .text-right { text-align: right; } + .text-center { text-align: center; } + .text-muted { color: #999; } + .mono { font-family: 'Courier New', monospace; font-size: 10px; } + .no-data-block { padding: 40px; text-align: center; color: #aaa; font-size: 13px; } + .statement-footer { background: #f0f3f9; border-top: 1px solid #d0d7e3; padding: 10px 20px; text-align: center; } + .statement-footer .footer-note { font-size: 9px; color: #888; margin-bottom: 3px; font-style: italic; } + .statement-footer .footer-meta { font-size: 9px; color: #aaa; letter-spacing: 0.3px; } + `; +} + // ── Joint Holder Detail Dialog ──────────────────────────────────────────────── @Component({ @@ -841,6 +1545,10 @@ export class TransactionEntryComponent implements OnInit, OnDestroy { // Extended cashier data clientPhotoUrl: string | null = null; signatureUrl: string | null = null; + /** Cash Hold (a manual/staff-placed lien) vs Clearing Hold (a cheque/DD deposit not yet realized, + * or a presented cheque not yet settled) — fetched separately from the account's own detail + * response, since only plain savings accounts (m_savings_account) carry this breakdown today. */ + holdBreakdown: { cashHold: number; clearingHold: number } | null = null; accountNotes: any[] = []; standingInstructions: any[] = []; todayTransactions: any[] = []; @@ -1986,6 +2694,7 @@ export class TransactionEntryComponent implements OnInit, OnDestroy { this.clientPhotoUrl = null; if (this.signatureUrl?.startsWith('blob:')) URL.revokeObjectURL(this.signatureUrl); this.signatureUrl = null; + this.holdBreakdown = null; this.accountNotes = []; this.standingInstructions = []; this.todayTransactions = []; @@ -2253,6 +2962,19 @@ export class TransactionEntryComponent implements OnInit, OnDestroy { } this.accountDetails = acc; this.jointHolders = acc.jointHolders ?? []; + // Cash Hold / Clearing Hold breakdown only exists for plain savings accounts (m_savings_account) + // — FD/RD/Current/Share/GL have no such row, so there's nothing to ask for. + if (acTyp?.toUpperCase() === 'SD' && acc.id) { + this.http + .get(`/savingsaccounts/${acc.id}/holdbreakdown`) + .pipe(catchError(() => of(null))) + .subscribe((hb: any) => { + if (token !== this.searchToken) return; + this.holdBreakdown = hb + ? { cashHold: Number(hb.cashHold) || 0, clearingHold: Number(hb.clearingHold) || 0 } + : null; + }); + } // Track per-row account id/name for multi-row disbursement if (this.selectedRow?.acNo) { this.rowAccountIds[this.selectedRow.acNo] = acc.id; @@ -2566,6 +3288,19 @@ export class TransactionEntryComponent implements OnInit, OnDestroy { return this.accountDetails?.savingsAmountOnHold ?? 0; } + /** A manual, staff-placed lien — the same underlying figure "Transfer Hold" already shows + * ({@link freezeAmount}); Fineract has one bucket for this, not two, so both fields legitimately + * read the same number rather than one of them being invented data. */ + get cashHold(): number { + return this.holdBreakdown?.cashHold ?? this.freezeAmount; + } + + /** Money still moving through the clearing process — a cheque/DD deposit not yet realized, or a + * presented cheque not yet settled. Only populated for a plain savings account; 0 elsewhere. */ + get clearingHold(): number { + return this.holdBreakdown?.clearingHold ?? 0; + } + get withdrawableAmount(): number { const avail = this.accountDetails?.summary?.availableBalance ?? this.currentBalance; const acTyp = this.selectedRow?.acTyp; @@ -2872,6 +3607,23 @@ export class TransactionEntryComponent implements OnInit, OnDestroy { }); } + /** Only meaningful for a plain savings account — the transactions array a Statement is built from + * is embedded on that account type's own detail response (?associations=all), never on + * FD/RD/Current/Share/GL. */ + get canPrintStatement(): boolean { + return this.selectedRow?.acTyp === 'SD' && !!this.accountDetails?.id; + } + + printStatement(): void { + if (!this.canPrintStatement) return; + this.dialog.open(AccountStatementDialogComponent, { + data: { account: this.accountDetails }, + width: '820px', + maxWidth: '95vw', + autoFocus: false + }); + } + // ── Same-day compliance indicators (copied from SavingsAccountTransactionsComponent) ── private computeSameDayNeftRtgs(transactions: any[] | undefined): void { diff --git a/src/app/chequebook/cheque-book-detail/cheque-book-detail.component.html b/src/app/chequebook/cheque-book-detail/cheque-book-detail.component.html index cf657e658..57a1fd6b3 100644 --- a/src/app/chequebook/cheque-book-detail/cheque-book-detail.component.html +++ b/src/app/chequebook/cheque-book-detail/cheque-book-detail.component.html @@ -9,7 +9,12 @@
diff --git a/src/app/chequebook/cheque-book-detail/cheque-book-detail.component.ts b/src/app/chequebook/cheque-book-detail/cheque-book-detail.component.ts index 2a95702db..684e01f2d 100644 --- a/src/app/chequebook/cheque-book-detail/cheque-book-detail.component.ts +++ b/src/app/chequebook/cheque-book-detail/cheque-book-detail.component.ts @@ -20,6 +20,7 @@ import { STANDALONE_SHARED_IMPORTS } from 'app/standalone-shared.module'; /** Custom Services */ import { ChequeBookService } from '../chequebook.service'; import { LeafDetailDialogComponent } from './leaf-detail-dialog/leaf-detail-dialog.component'; +import { buildChequeIssuanceSlipPdf } from './cheque-issuance-slip-pdf'; type LeafAction = 'stoppayment' | 'revokestoppayment' | 'reportlost' | 'cancelleaf' | 'markused' | 'return'; @@ -85,6 +86,11 @@ export class ChequeBookDetailComponent implements OnInit { this.dialog.open(LeafDetailDialogComponent, { data: leaf, width: '420px' }); } + printIssuanceSlip(): void { + const doc = buildChequeIssuanceSlipPdf(this.book); + doc.save(`cheque-book-issuance-${this.book.id}.pdf`); + } + startAction(action: LeafAction): void { this.activeAction = this.activeAction === action ? null : action; this.actionForm.reset(); diff --git a/src/app/chequebook/cheque-book-detail/cheque-issuance-slip-pdf.ts b/src/app/chequebook/cheque-book-detail/cheque-issuance-slip-pdf.ts new file mode 100644 index 000000000..ba1b5fc67 --- /dev/null +++ b/src/app/chequebook/cheque-book-detail/cheque-issuance-slip-pdf.ts @@ -0,0 +1,120 @@ +/** + * Copyright since 2025 Mifos Initiative + * + * This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. + */ + +/** + * A one-page issuance-slip PDF for a cheque book already issued to an account — the "Cheque Book" + * print action was previously a dead menu stub. This is a record of the issuance itself (which + * book/series, leaf range, issued-to account, issue date, branch), not a physical cheque leaf. + */ + +import { jsPDF } from 'jspdf'; + +const BANK_NAME = 'Nexora Co-operative Bank Ltd.'; +const C_TEXT: [number, number, number] = [ + 15, + 23, + 42 +]; +const C_MUTED: [number, number, number] = [ + 100, + 116, + 139 +]; +const C_BORDER: [number, number, number] = [ + 226, + 232, + 240 +]; +const ACCENT: [number, number, number] = [ + 30, + 64, + 175 +]; + +function formatDate(iso: string | null | undefined): string { + if (!iso) return '—'; + const d = new Date(iso); + if (isNaN(d.getTime())) return String(iso); + return d.toLocaleDateString('en-IN', { day: '2-digit', month: 'short', year: 'numeric' }); +} + +export interface ChequeIssuanceSlipBook { + id: number | string; + accountNo?: string; + clientName?: string; + accountType?: string; + officeName?: string; + seriesCode?: string; + rangeStart?: string | number; + rangeEnd?: string | number; + noOfBooks?: number; + status?: string; + issueDate?: string; +} + +export function buildChequeIssuanceSlipPdf(book: ChequeIssuanceSlipBook): jsPDF { + const doc = new jsPDF({ unit: 'mm', format: 'a5' }); + const pageWidth = doc.internal.pageSize.getWidth(); + const margin = 12; + let y = 16; + + doc.setFillColor(...ACCENT); + doc.rect(0, 0, pageWidth, 4, 'F'); + + doc.setFont('helvetica', 'bold'); + doc.setFontSize(14); + doc.setTextColor(...C_TEXT); + doc.text(BANK_NAME, margin, y); + y += 6; + + doc.setFont('helvetica', 'normal'); + doc.setFontSize(10); + doc.setTextColor(...C_MUTED); + doc.text('Cheque Book Issuance Slip', margin, y); + y += 8; + + doc.setDrawColor(...C_BORDER); + doc.line(margin, y, pageWidth - margin, y); + y += 8; + + const row = (label: string, value: string) => { + doc.setFont('helvetica', 'bold'); + doc.setFontSize(9); + doc.setTextColor(...C_MUTED); + doc.text(label, margin, y); + doc.setFont('helvetica', 'normal'); + doc.setFontSize(10.5); + doc.setTextColor(...C_TEXT); + doc.text(value, margin + 45, y); + y += 7; + }; + + row('Issuance ID', String(book.id ?? '—')); + row('Account No.', book.accountNo ?? '—'); + row('Account Holder', book.clientName ?? '—'); + row('Account Type', book.accountType ?? '—'); + row('Branch', book.officeName ?? '—'); + row('Series', book.seriesCode ?? '—'); + row('Leaf Range', book.rangeStart != null && book.rangeEnd != null ? `${book.rangeStart} – ${book.rangeEnd}` : '—'); + row('No. of Books', String(book.noOfBooks ?? '—')); + row('Status', book.status ?? '—'); + row('Issue Date', formatDate(book.issueDate)); + + y += 6; + doc.setDrawColor(...C_BORDER); + doc.line(margin, y, pageWidth - margin, y); + y += 14; + + doc.setFont('helvetica', 'normal'); + doc.setFontSize(9); + doc.setTextColor(...C_MUTED); + doc.text('Issued by: ______________________', margin, y); + doc.text('Received by: ______________________', margin, y + 16); + + return doc; +} diff --git a/src/app/savings/savings-account-view/passbook-tab/passbook-tab.component.html b/src/app/savings/savings-account-view/passbook-tab/passbook-tab.component.html index ee2fe2d98..0efb2fbcf 100644 --- a/src/app/savings/savings-account-view/passbook-tab/passbook-tab.component.html +++ b/src/app/savings/savings-account-view/passbook-tab/passbook-tab.component.html @@ -60,8 +60,8 @@ account_balance
-
LincPay Co-operative Bank Ltd.
-
Account Statement
+
{{ bankName }}
+
{{ bankSubtitle }}
@@ -70,6 +70,25 @@
+ + @if (branchName || ifscCode || micrCode) { +
+ @if (branchName) { + {{ branchName }} Branch + } + @if (branchAddress) { + {{ branchAddress }} + } + @if (ifscCode) { + IFSC: {{ ifscCode }} + } + @if (micrCode) { + MICR: {{ micrCode }} + } +
+ } +
@@ -597,15 +574,6 @@ ) }}
-
- Income from interest - {{ - formatGlAccountById( - savingProduct.incomeFromInterestId, - savingProductsTemplate?.accountingMappingOptions.incomeAccountOptions - ) - }} -
} diff --git a/src/app/products/saving-products/saving-product-stepper/saving-product-preview-step/saving-product-preview-step.component.ts b/src/app/products/saving-products/saving-product-stepper/saving-product-preview-step/saving-product-preview-step.component.ts index decfc645d..50194838b 100644 --- a/src/app/products/saving-products/saving-product-stepper/saving-product-preview-step/saving-product-preview-step.component.ts +++ b/src/app/products/saving-products/saving-product-stepper/saving-product-preview-step/saving-product-preview-step.component.ts @@ -94,7 +94,6 @@ export class SavingProductPreviewStepComponent implements OnInit, OnChanges { { key: 'neftFacility', label: 'NEFT Facility' }, { key: 'rtgsFacility', label: 'RTGS Facility' }, { key: 'interBankTotalCapFacility', label: 'Inter-Bank Total Cap Facility' }, - { key: 'withdrawalFacility', label: 'Withdrawal Facility' }, { key: 'depositFacilityKyc', label: 'Deposit Facility (KYC)' }, { key: 'virtualDebitCard', label: 'Virtual Debit Card' } ]; @@ -132,23 +131,14 @@ export class SavingProductPreviewStepComponent implements OnInit, OnChanges { this.savingProduct.savingsReferenceAccountId, assetAccountData ), - overdraftPortfolioControl: this.accounting.glAccountLookUp( - this.savingProduct.overdraftPortfolioControlId, - assetAccountData - ), savingsControlAccount: this.accounting.glAccountLookUp( this.savingProduct.savingsControlAccountId, liabilityAccountData ), - transfersInSuspenseAccount: this.accounting.glAccountLookUp( - this.savingProduct.transfersInSuspenseAccountId, - liabilityAccountData - ), interestOnSavingsAccount: this.accounting.glAccountLookUp( this.savingProduct.interestOnSavingsAccountId, expenseAccountData ), - writeOffAccount: this.accounting.glAccountLookUp(this.savingProduct.writeOffAccountId, expenseAccountData), incomeFromFeeAccount: this.accounting.glAccountLookUp( this.savingProduct.incomeFromFeeAccountId, incomeAccountData @@ -156,8 +146,7 @@ export class SavingProductPreviewStepComponent implements OnInit, OnChanges { incomeFromPenaltyAccount: this.accounting.glAccountLookUp( this.savingProduct.incomeFromPenaltyAccountId, incomeAccountData - ), - incomeFromInterest: this.accounting.glAccountLookUp(this.savingProduct.incomeFromInterestId, incomeAccountData) + ) }; if (this.isAccrualAccounting()) { diff --git a/src/app/products/saving-products/saving-product-stepper/saving-product-settings-step/saving-product-settings-step.component.ts b/src/app/products/saving-products/saving-product-stepper/saving-product-settings-step/saving-product-settings-step.component.ts index f62a8327f..2ce4edc8b 100644 --- a/src/app/products/saving-products/saving-product-stepper/saving-product-settings-step/saving-product-settings-step.component.ts +++ b/src/app/products/saving-products/saving-product-stepper/saving-product-settings-step/saving-product-settings-step.component.ts @@ -70,11 +70,7 @@ export class SavingProductSettingsStepComponent implements OnInit { : this.savingProductsTemplate.lockinPeriodFrequency && this.savingProductsTemplate.lockinPeriodFrequency > 0, accountClosedZeroBalanceInDayClosing: isEditing ? (this.savingProductsTemplate.accountClosedZeroBalanceInDayClosing ?? false) - : false, - isDormancyTrackingActive: isEditing ? (this.savingProductsTemplate.isDormancyTrackingActive ?? true) : true, - daysToInactive: isEditing ? this.savingProductsTemplate.daysToInactive || 30 : 30, - daysToDormancy: isEditing ? this.savingProductsTemplate.daysToDormancy || 60 : 60, - daysToEscheat: isEditing ? this.savingProductsTemplate.daysToEscheat || 90 : 90 + : false }); if ( @@ -115,12 +111,11 @@ export class SavingProductSettingsStepComponent implements OnInit { enableLockinPeriod: [false], lockinPeriodFrequency: [''], lockinPeriodFrequencyType: [''], - accountClosedZeroBalanceInDayClosing: [false], - // Dormancy tracking fields (required when isDormancyTrackingActive is true) - isDormancyTrackingActive: [true], - daysToInactive: [30], - daysToDormancy: [60], - daysToEscheat: [90] + accountClosedZeroBalanceInDayClosing: [false] + // Dormancy is deliberately absent: it belongs to the Currency step, which is where it is shown + // and edited. It was declared here as well, and because this step is spread into the payload + // after that one, the numbers an officer typed were silently replaced by these untouched + // defaults on the way to the server. }); } diff --git a/src/app/products/saving-products/view-saving-product/saving-product-general-tab/saving-product-general-tab.component.ts b/src/app/products/saving-products/view-saving-product/saving-product-general-tab/saving-product-general-tab.component.ts index f74717eb9..707b50301 100644 --- a/src/app/products/saving-products/view-saving-product/saving-product-general-tab/saving-product-general-tab.component.ts +++ b/src/app/products/saving-products/view-saving-product/saving-product-general-tab/saving-product-general-tab.component.ts @@ -86,7 +86,6 @@ export class SavingProductGeneralTabComponent { { key: 'neftFacility', label: 'NEFT Facility' }, { key: 'rtgsFacility', label: 'RTGS Facility' }, { key: 'interBankTotalCapFacility', label: 'Inter-Bank Total Cap Facility' }, - { key: 'withdrawalFacility', label: 'Withdrawal Facility' }, { key: 'depositFacilityKyc', label: 'Deposit Facility (KYC)' }, { key: 'virtualDebitCard', label: 'Virtual Debit Card' } ]; diff --git a/src/app/products/share-products/all-products-dividends/declare-dividend/declare-dividend.component.html b/src/app/products/share-products/all-products-dividends/declare-dividend/declare-dividend.component.html index c9cbf1d5e..daccfbafa 100644 --- a/src/app/products/share-products/all-products-dividends/declare-dividend/declare-dividend.component.html +++ b/src/app/products/share-products/all-products-dividends/declare-dividend/declare-dividend.component.html @@ -76,9 +76,7 @@  %  -

- Applied to each member's own share investment, pro-rated for how long they held it. -

+

Applied to each member's own share investment, pro-rated for how long they held it.

@if (loadingPreview) { diff --git a/src/app/products/share-products/view-dividend/view-dividend.component.ts b/src/app/products/share-products/view-dividend/view-dividend.component.ts index 82507da34..b39d7619c 100644 --- a/src/app/products/share-products/view-dividend/view-dividend.component.ts +++ b/src/app/products/share-products/view-dividend/view-dividend.component.ts @@ -248,27 +248,67 @@ export class ViewDividendComponent implements OnInit { ws.getCell('A1').alignment = { horizontal: 'center' }; const heading: [string, any][] = [ - ['Share Product', h?.productName ?? ''], - ['Financial Year', fy], - ['Dividend Period', h ? `${h.periodStartDate} to ${h.periodEndDate}` : ''], - ['Dividend Rate (% per annum)', h?.ratePercentage != null ? Number(h.ratePercentage) : 'Not recorded'], - ['Declared Amount', h?.declaredAmount != null ? Number(h.declaredAmount) : ''], - ['Distributed to Members', h?.allocatedAmount != null ? Number(h.allocatedAmount) : ''], - ['Payment Date', h?.paymentDate ? String(h.paymentDate).slice(0, 10) : 'Not yet approved'], - ['Status', h?.status === 'APPROVED' ? 'Approved' : 'Awaiting Approval'], - ['Members Paid', h?.paidMembers ?? ''], - ['Members Below Minimum', h?.unpaidMembers ?? ''] + [ + 'Share Product', + h?.productName ?? '' + ], + [ + 'Financial Year', + fy + ], + [ + 'Dividend Period', + h ? `${h.periodStartDate} to ${h.periodEndDate}` : '' + ], + [ + 'Dividend Rate (% per annum)', + h?.ratePercentage != null ? Number(h.ratePercentage) : 'Not recorded' + ], + [ + 'Declared Amount', + h?.declaredAmount != null ? Number(h.declaredAmount) : '' + ], + [ + 'Distributed to Members', + h?.allocatedAmount != null ? Number(h.allocatedAmount) : '' + ], + [ + 'Payment Date', + h?.paymentDate ? String(h.paymentDate).slice(0, 10) : 'Not yet approved' + ], + [ + 'Status', + h?.status === 'APPROVED' ? 'Approved' : 'Awaiting Approval' + ], + [ + 'Members Paid', + h?.paidMembers ?? '' + ], + [ + 'Members Below Minimum', + h?.unpaidMembers ?? '' + ] ]; - heading.forEach(([label, value], i) => { - const row = ws.getRow(i + 3); - row.getCell(1).value = label; - row.getCell(1).font = { bold: true }; - row.getCell(2).value = value; - }); + heading.forEach( + ([ + label, + value + ], i) => { + const row = ws.getRow(i + 3); + row.getCell(1).value = label; + row.getCell(1).font = { bold: true }; + row.getCell(2).value = value; + } + ); const tableStart = heading.length + 5; const header = ws.getRow(tableStart); - ['Client Name', 'Share Account', 'Dividend Amount', 'Status'].forEach((t, i) => { + [ + 'Client Name', + 'Share Account', + 'Dividend Amount', + 'Status' + ].forEach((t, i) => { header.getCell(i + 1).value = t; header.getCell(i + 1).font = { bold: true }; header.getCell(i + 1).fill = { type: 'pattern', pattern: 'solid', fgColor: { argb: 'FFE8ECF0' } }; @@ -286,7 +326,10 @@ export class ViewDividendComponent implements OnInit { const totalRow = ws.getRow(tableStart + 1 + (this.dataSource?.data?.length ?? 0)); totalRow.getCell(2).value = 'Total'; totalRow.getCell(2).font = { bold: true }; - totalRow.getCell(3).value = (this.dataSource?.data ?? []).reduce((s: number, d: any) => s + (Number(d?.amount) || 0), 0); + totalRow.getCell(3).value = (this.dataSource?.data ?? []).reduce( + (s: number, d: any) => s + (Number(d?.amount) || 0), + 0 + ); totalRow.getCell(3).numFmt = '#,##0.00'; totalRow.getCell(3).font = { bold: true }; diff --git a/src/app/rbi-circulars/share-capital-return/share-capital-return.component.html b/src/app/rbi-circulars/share-capital-return/share-capital-return.component.html index f3c3de609..43110052e 100644 --- a/src/app/rbi-circulars/share-capital-return/share-capital-return.component.html +++ b/src/app/rbi-circulars/share-capital-return/share-capital-return.component.html @@ -13,9 +13,9 @@

- Membership and share capital as reported to the RBI, per share type — how many members hold - each type, how much capital they have subscribed, and how much of the authorised issue - remains unsold, with the previous financial year alongside for comparison. + Membership and share capital as reported to the RBI, per share type — how many members hold each type, how much + capital they have subscribed, and how much of the authorised issue remains unsold, with the previous financial + year alongside for comparison.

@@ -31,7 +31,12 @@ Refresh - @@ -46,8 +51,8 @@
Loading the share register…
} @else if (report) {
- Reporting period {{ report.periodStartDate }} to - {{ report.periodEndDate }} · compared with FY {{ report.previousFinancialYear }} + Reporting period {{ report.periodStartDate }} to {{ report.periodEndDate }} · + compared with FY {{ report.previousFinancialYear }}
@@ -228,148 +216,173 @@ @if (view === 'customer') { -
- - - - - - - - - - - - - - - - - - @for (c of customerRows; track c.clientId) { - - - - - - - - - - - - - + + @for (g of customerGroups; track g.key) { +
+ - @if (expanded === c.clientId) { -
- + } - - - } - } @empty { - - - - } - -
Customer IDCustomer NameAccount NumberBranchCurrent RiskAlertsAML Rules TriggeredAmount under reviewLast AlertPosition
- - {{ c.clientId }} - {{ c.customerName }} - @if (c.openCount > 0) { - {{ c.openCount }} open - } - {{ c.accountNo || '—' }}{{ c.branchCode || '—' }} - {{ riskText(c.currentRisk) }} - {{ c.alertCount }} - @for (label of c.ruleLabels; track label) { - {{ label }} - } - {{ c.totalAmount | number: '1.0-0' }}{{ c.lastAlertDate }} - {{ verdictText(c) }} - @if (c.directionReason) { -
{{ c.directionReason }}
- } -
- - @for (a of c.alerts; track a.alertId) { -
-
- {{ a.rule.label }} - {{ a.rule.category }} - {{ a.alertId }} · raised {{ a.alertDate }} - - ₹{{ a.amount | number: '1.0-0' }} - {{ statusMeta[a.status].label }} -
-
- - Why it triggered: {{ a.rule.description }} — {{ txnDetail(a) }} -
+ @if (!collapsedGroups[g.key]) { +
+ + + + + + + + + + + + + + + + + + @for (c of g.rows; track c.clientId) { + + + + + + + + + + + + + + -
Customer IDCustomer NameAccount NumberBranchRating on fileAlertsAML Rules TriggeredAmount under reviewLast AlertAction
+ + {{ c.clientId }} + {{ c.customerName }} + @if (c.openCount > 0) { + {{ c.openCount }} open + } + {{ c.accountNo || '—' }}{{ c.branchCode || '—' }} + {{ riskText(c.currentRisk) }} + {{ c.alertCount }} + @for (label of c.ruleLabels; track label) { + {{ label }} + } + {{ c.totalAmount | number: '1.0-0' }}{{ c.lastAlertDate }} + + @if (c.directionReason) { +
{{ c.directionReason }}
+ } +
- - - - - - - - - - - - - - - @for (t of a.txns; track t.txnId) { - - - - - - + +
Txn DateTxn IDChannelDr/CrAmountBalance AfterNarrationCounterpartyAML Rule Triggered
{{ t.date }}{{ t.txnId }} - {{ channelLabel(t.channel) }} - {{ t.drCr }} - {{ t.amount | number: '1.0-0' }} - @if (t.ctrReportable) { - CTR + @if (expanded === c.clientId) { +
+ + @for (a of c.alerts; track a.alertId) { +
+
+ {{ a.rule.label }} + {{ a.rule.category }} + {{ a.alertId }} · raised {{ a.alertDate }} + + ₹{{ a.amount | number: '1.0-0' }} + {{ statusMeta[a.status].label }} +
+
+ + Why it triggered: {{ a.rule.description }} — + {{ txnDetail(a) }} +
+ + + + + + + + + + + + + + + + + @for (t of a.txns; track t.txnId) { + + + + + + + + + + + } - - - - - - - } - -
Txn DateTxn IDChannelDr/CrAmountBalance AfterNarrationCounterpartyAML Rule Triggered
{{ t.date }}{{ t.txnId }} + {{ channelLabel(t.channel) }} + {{ t.drCr }} + {{ t.amount | number: '1.0-0' }} + @if (t.ctrReportable) { + CTR + } + + {{ t.balanceAfter ? (t.balanceAfter | number: '1.0-0') : '—' }} + {{ t.narration || '—' }}{{ t.counterparty || '—' }} + {{ a.rule.label }} + @if (a.txns.length > 1) { + leg {{ $index + 1 }} of {{ a.txns.length }} + } +
{{ t.balanceAfter ? (t.balanceAfter | number: '1.0-0') : '—' }}{{ t.narration || '—' }}{{ t.counterparty || '—' }} - {{ a.rule.label }} - @if (a.txns.length > 1) { - leg {{ $index + 1 }} of {{ a.txns.length }} - } -
+
-
-
- {{ a.rule.suggestedAction }} -
-
- Reviewer: {{ a.reviewedBy || 'not yet assigned' }} — - {{ a.remarks || 'no remarks recorded' }} -
-
-
+
+
+ {{ a.rule.suggestedAction }} +
+
+ Reviewer: {{ a.reviewedBy || 'not yet assigned' }} — + {{ a.remarks || 'no remarks recorded' }} +
+
+
+ } +
- No customer tripped an AML rule for {{ rangeLabel }} at {{ branchLabel }}. -
-
+ } + + +
+ } +
+ } @empty { +
No customer tripped an AML rule for {{ rangeLabel }} at {{ branchLabel }}.
+ } } @@ -400,9 +413,6 @@ {{ a.clientId }} {{ a.customerName }} - @if (!a.live) { - Sample - } {{ a.accountNo || '—' }} {{ a.branchCode || '—' }} diff --git a/src/app/risk-review/risk-review-report.component.scss b/src/app/risk-review/risk-review-report.component.scss index aacb799ff..117eae800 100644 --- a/src/app/risk-review/risk-review-report.component.scss +++ b/src/app/risk-review/risk-review-report.component.scss @@ -139,12 +139,6 @@ span { flex: 1; } - - &--sample { - background: var(--r-amber-l); - border: 1px solid #fcd34d; - color: #78350f; - } } /* FILTERS */ @@ -403,6 +397,84 @@ font-weight: 600; } +/* VERDICT GROUPS — the section heading carries the verdict, so rows need not repeat it. */ +.rr-group { + margin-bottom: 14px; +} + +.rr-group-head { + display: flex; + align-items: center; + gap: 10px; + width: 100%; + padding: 10px 14px; + background: #fff; + border: 1px solid var(--r-g200); + border-left: 4px solid var(--r-g300); + border-radius: 10px; + cursor: pointer; + text-align: left; + font: inherit; +} + +.rr-group-t { + font-size: 14px; + font-weight: 700; +} + +/* The count sits next to the title so a reviewer sees the size of each pile at a glance. */ +.rr-group-n { + min-width: 24px; + padding: 1px 8px; + border-radius: 999px; + background: var(--r-g100, #f1f5f9); + font-size: 12px; + font-weight: 700; + text-align: center; +} + +.rr-group-h { + flex: 1; + font-size: 12px; + color: var(--r-g500); +} + +/* One colour per verdict, matched to the arrows already used in the rows. */ +.rr-group--str .rr-group-head { + border-left-color: #dc2626; +} + +.rr-group--str .rr-group-n { + background: #fee2e2; + color: #991b1b; +} + +.rr-group--up .rr-group-head { + border-left-color: #ea580c; +} + +.rr-group--up .rr-group-n { + background: #ffedd5; + color: #9a3412; +} + +.rr-group--down .rr-group-head { + border-left-color: #16a34a; +} + +.rr-group--down .rr-group-n { + background: #dcfce7; + color: #166534; +} + +.rr-group--hold .rr-group-head { + border-left-color: var(--r-g300); +} + +.rr-group .rr-table-wrap { + margin-top: 6px; +} + /* TABLE */ .rr-table-wrap { background: #fff; @@ -751,6 +823,75 @@ white-space: normal; } +/* Where the rating should land, read straight off the Current Risk column beside it. */ +.rr-move-cell { + min-width: 150px; + max-width: 190px; +} + +.rr-move { + display: inline-flex; + align-items: center; + gap: 4px; + padding: 2px 8px; + border-radius: 999px; + font-size: 11px; + font-weight: 700; + white-space: nowrap; + + &--up { + background: var(--r-red-l); + color: var(--r-red); + } + + /* Coming down is a relief, not an alarm — same green the verdict uses. */ + &--down { + background: var(--r-green-l); + color: var(--r-green); + } + + &--hold { + background: var(--r-g100); + color: var(--r-g500); + } +} + +.rr-move-why { + font-size: 10px; + color: var(--r-g500); + margin-top: 3px; + white-space: normal; +} + +/* Drill from the verdict into the KYC screen, where the rating is actually moved. */ +.rr-kyc-link { + display: inline-flex; + align-items: center; + gap: 5px; + margin-top: 6px; + padding: 3px 9px; + border: 1px solid var(--r-g300); + border-radius: 5px; + background: #fff; + font: inherit; + font-size: 10.5px; + font-weight: 600; + color: var(--r-g500); + white-space: nowrap; + cursor: pointer; + + &:hover { + border-color: var(--r-blue); + color: var(--r-blue); + } + + /* A rating that has to move gets the emphasis; a settled row stays quiet. */ + &--act { + border-color: var(--r-blue); + color: var(--r-blue); + } +} + .rr-tag { display: inline-block; margin-left: 6px; diff --git a/src/app/risk-review/risk-review-report.component.ts b/src/app/risk-review/risk-review-report.component.ts index 58ef27234..bd59f7bd6 100644 --- a/src/app/risk-review/risk-review-report.component.ts +++ b/src/app/risk-review/risk-review-report.component.ts @@ -9,6 +9,7 @@ /** Angular Imports */ import { Component, OnInit, inject } from '@angular/core'; import { FormsModule } from '@angular/forms'; +import { Router } from '@angular/router'; import { forkJoin, of } from 'rxjs'; import { catchError, map } from 'rxjs/operators'; @@ -37,8 +38,7 @@ import { RiskLevel, channelLabel, mapLiveAlert, - riskFromCategory, - sampleRiskAlerts + riskFromCategory } from './risk-review-data'; import { DateRange, @@ -57,6 +57,15 @@ import { type PeriodMode = 'day' | 'week' | 'month' | 'range'; type ViewMode = 'customer' | 'alert' | 'txn'; +/** One block of the customer view: the customers that share a single verdict. */ +interface RiskCustomerGroup { + key: string; + title: string; + hint: string; + tone: 'str' | 'up' | 'down' | 'hold'; + rows: RiskCustomerRow[]; +} + /** Starting value for the memo, used before the first filter pass runs. */ const EMPTY_SUMMARY: RiskReviewSummary = { customers: 0, @@ -82,9 +91,8 @@ const EMPTY_SUMMARY: RiskReviewSummary = { * rating, move it, refresh KYC, or escalate to the Principal Officer for an * STR — which is the vocabulary in `REVIEW_STATUS_META`. * - * Rows come from the live rule engine. If the engine has raised nothing (or is - * unreachable) the report falls back to a deterministic sample set and says so - * on screen and in the workbook, so sample rows are never taken for live data. + * Rows come from the live rule engine only. If the engine has raised nothing + * (or is unreachable) the report shows as empty rather than padding itself. */ @Component({ selector: 'mifosx-risk-review-report', @@ -100,6 +108,7 @@ export class RiskReviewReportComponent implements OnInit { private aml = inject(AmlService); private clients = inject(ClientsService); private scope = inject(BranchScopeService); + private router = inject(Router); readonly period = GL_PERIOD; readonly branches = GL_BRANCHES; @@ -122,22 +131,13 @@ export class RiskReviewReportComponent implements OnInit { private liveAlerts: RiskAlertRow[] = []; loading = true; - /** - * Live alerts and the demonstration set, in one worklist. - * - * Every row keeps its `live` flag and sample rows are tagged in the table, so - * a reviewer can still tell them apart at a glance. - */ + /** The worklist — live alerts from the rule engine. */ private alerts: RiskAlertRow[] = []; get hasLive(): boolean { return this.liveAlerts.length > 0; } - get sampleCount(): number { - return this.alerts.filter((a) => !a.live).length; - } - // ── Filter state ─────────────────────────────────────────────────────────── /** @@ -198,12 +198,16 @@ export class RiskReviewReportComponent implements OnInit { }); } - /** Live alerts first, then the demonstration set behind them. */ + /** + * Only what the AML engine actually raised. + * + * The demonstration set that used to be appended here is gone: a compliance report that mixes + * invented customers into live alerts is worse than an empty one, because every count, total and + * rule tally on the screen is then a number nobody can act on. An empty period now reads as an + * empty period. + */ private compose(): void { - this.alerts = [ - ...this.liveAlerts, - ...sampleRiskAlerts() - ]; + this.alerts = [...this.liveAlerts]; this.memoKey = ''; } @@ -334,6 +338,64 @@ export class RiskReviewReportComponent implements OnInit { return this.model.customers; } + /** + * The customers split by what the reviewer actually has to do about them. + * + * The report's whole question is "does the rating on file still hold?", and there are only four + * answers. Grouping by the answer puts that question in the section heading, where it is read + * once, instead of repeating it on every row across a Move To and a Position column that said the + * same thing in different words. + * + * Ordered by what a reviewer must deal with first: an STR sits with the Principal Officer against + * a regulatory clock, an under-rated customer is the real compliance gap, an over-rated one is + * only a correction, and matching ratings need nothing at all. + */ + get customerGroups(): RiskCustomerGroup[] { + const rows = this.customerRows; + const escalated = rows.filter((c) => c.escalated); + const rest = rows.filter((c) => !c.escalated); + + const groups: RiskCustomerGroup[] = [ + { + key: 'escalated', + title: 'With the Principal Officer', + hint: 'An STR has been filed. The rating is held where it is until the outcome — nothing to apply here.', + tone: 'str', + rows: escalated + }, + { + key: 'up', + title: 'Rating needs raising', + hint: 'The rules point higher than the rating on file. These are under-rated customers — the real gap.', + tone: 'up', + rows: rest.filter((c) => c.direction === 'up') + }, + { + key: 'down', + title: 'Rating can be lowered', + hint: 'Activity no longer supports the rating on file.', + tone: 'down', + rows: rest.filter((c) => c.direction === 'down') + }, + { + key: 'same', + title: 'No change needed', + hint: 'The rating on file already matches the behaviour. Listed for the record.', + tone: 'hold', + rows: rest.filter((c) => c.direction === 'same') + } + ]; + + return groups.filter((g) => g.rows.length > 0); + } + + /** Collapsed sections, by group key. 'No change needed' starts closed — nothing to act on. */ + collapsedGroups: Record = { same: true }; + + toggleGroup(key: string): void { + this.collapsedGroups[key] = !this.collapsedGroups[key]; + } + get txnRows(): RiskTxnView[] { return this.model.txns; } @@ -393,6 +455,74 @@ export class RiskReviewReportComponent implements OnInit { return 'rr-verdict'; } + /** Colours the target rating by which way it moves. */ + moveClass(c: RiskCustomerRow): string { + if (c.direction === 'up') { + return 'rr-move rr-move--up'; + } + if (c.direction === 'down') { + return 'rr-move rr-move--down'; + } + return 'rr-move rr-move--hold'; + } + + /** + * The short "from → to" line under the target rating. + * + * The reason column already carries the full argument; this only has to say what is + * happening to the rating, so a reviewer scanning the column never has to read a + * paragraph to find out whether a customer is moving. + */ + moveHint(c: RiskCustomerRow): string { + const from = this.riskText(c.currentRisk); + const to = this.riskText(c.recommendedRisk); + + if (c.escalated) { + return `${from} → hold at ${to} till STR outcome`; + } + if (c.direction === 'up') { + return `${from} → ${to} as per AML rules`; + } + if (c.direction === 'down') { + return `${from} → ${to}, activity does not support ${from}`; + } + // Nothing to move: say why it is staying rather than leaving the cell mute. + return c.currentRisk ? `Behaviour matches ${from} — no change` : 'No rating on record yet'; + } + + /** + * Opens the customer's KYC tab so the reviewer can act on the verdict. + * + * The report only ever recommends — nothing here writes the rating. Moving a customer + * is a KYC amendment and stays on the KYC screen, where the override checkbox, the + * remarks field and the review-date recalculation all live together. + */ + openKyc(c: RiskCustomerRow, event: Event): void { + // The row itself expands on click; the button must not do both. + event.stopPropagation(); + this.router.navigate([ + '/clients', + c.clientId, + 'kyc' + ]); + } + + /** + * What the reviewer is going to the KYC screen to do. + * + * An escalated case is worded as a review rather than a move: the rating is held + * pending the Principal Officer's conclusion, so there is nothing to apply yet. + */ + kycActionText(c: RiskCustomerRow): string { + if (c.escalated) { + return 'Open KYC'; + } + if (c.direction === 'up' || c.direction === 'down') { + return `Set ${RISK_LABEL[c.recommendedRisk]} in KYC`; + } + return 'Open KYC'; + } + /** Transaction detail as the reviewer would read it in one line. */ txnDetail(a: RiskAlertView): string { const amount = `₹${a.amount.toLocaleString('en-IN')}`; @@ -409,9 +539,7 @@ export class RiskReviewReportComponent implements OnInit { try { const s = this.summary; const subtitle = - `Period: ${this.rangeLabel} · Branch: ${this.branchLabel} · ` + - `Source: live AML monitoring engine` + - (this.sampleCount ? ` + ${this.sampleCount} DEMONSTRATION rows (not compliance data)` : ''); + `Period: ${this.rangeLabel} · Branch: ${this.branchLabel} · ` + `Source: live AML monitoring engine`; await exportWorkbook( [ @@ -533,6 +661,7 @@ export class RiskReviewReportComponent implements OnInit { 'Last Alert', 'Rules Point At', 'Recommended Risk', + 'Move To', 'Open Cases', 'Position', 'Basis' @@ -551,6 +680,7 @@ export class RiskReviewReportComponent implements OnInit { 12, 14, 16, + 38, 11, 24, 70 @@ -569,6 +699,7 @@ export class RiskReviewReportComponent implements OnInit { c.lastAlertDate, RISK_LABEL[c.proposedRisk], RISK_LABEL[c.recommendedRisk], + this.moveHint(c), c.openCount, this.verdictText(c), c.directionReason || '—' diff --git a/src/app/savings-daily-interest/savings-daily-interest.component.html b/src/app/savings-daily-interest/savings-daily-interest.component.html index d52213206..59c3d7334 100644 --- a/src/app/savings-daily-interest/savings-daily-interest.component.html +++ b/src/app/savings-daily-interest/savings-daily-interest.component.html @@ -24,6 +24,7 @@
+ @if (selectedProduct) {
-
-
-
- Withdrawal Facility - {{ facilityConfig.withdrawalFacility ? 'Enabled' : 'Disabled' }} -
- -
-
diff --git a/src/app/savings/savings-account-stepper/savings-account-digital-step/savings-account-digital-step.component.ts b/src/app/savings/savings-account-stepper/savings-account-digital-step/savings-account-digital-step.component.ts index 6add60514..b3aac19d9 100644 --- a/src/app/savings/savings-account-stepper/savings-account-digital-step/savings-account-digital-step.component.ts +++ b/src/app/savings/savings-account-stepper/savings-account-digital-step/savings-account-digital-step.component.ts @@ -24,7 +24,6 @@ const DEFAULT_FACILITY_CONFIG = { neftFacility: false, rtgsFacility: false, interBankTotalCapFacility: false, - withdrawalFacility: false, depositFacilityKyc: false, virtualDebitCard: false }; diff --git a/src/app/savings/savings-account-stepper/savings-account-preview-step/savings-account-preview-step.component.ts b/src/app/savings/savings-account-stepper/savings-account-preview-step/savings-account-preview-step.component.ts index 32b227bcd..880e51d90 100644 --- a/src/app/savings/savings-account-stepper/savings-account-preview-step/savings-account-preview-step.component.ts +++ b/src/app/savings/savings-account-stepper/savings-account-preview-step/savings-account-preview-step.component.ts @@ -73,7 +73,6 @@ export class SavingsAccountPreviewStepComponent implements OnChanges { { key: 'neftFacility', label: 'NEFT Facility' }, { key: 'rtgsFacility', label: 'RTGS Facility' }, { key: 'interBankTotalCapFacility', label: 'Inter-Bank Total Cap' }, - { key: 'withdrawalFacility', label: 'Withdrawal Facility' }, { key: 'depositFacilityKyc', label: 'Deposit Facility (KYC)' }, { key: 'virtualDebitCard', label: 'Virtual Debit Card' } ]; diff --git a/src/app/shared/charges/facility-charge-filter.service.ts b/src/app/shared/charges/facility-charge-filter.service.ts new file mode 100644 index 000000000..d9da72250 --- /dev/null +++ b/src/app/shared/charges/facility-charge-filter.service.ts @@ -0,0 +1,66 @@ +/** + * Copyright since 2025 Mifos Initiative + * + * This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. + */ + +/** Angular Imports */ +import { Injectable, inject } from '@angular/core'; +import { HttpClient } from '@angular/common/http'; + +/** rxjs Imports */ +import { Observable, of } from 'rxjs'; +import { catchError, map, shareReplay } from 'rxjs/operators'; + +/** + * Charges that are mapped to an account facility (ATM, UPI, Cheque Book, ...) are priced by the + * Facility Charge Mapping screen and levied automatically when that facility is enabled. Offering + * them again in a product/account charge picker would attach the same charge a second time and + * double-charge the customer, so they are filtered out of those pickers. + */ +@Injectable({ + providedIn: 'root' +}) +export class FacilityChargeFilterService { + private http = inject(HttpClient); + + /** Facility mappings rarely change within a session, so the lookup is fetched once and shared. */ + private mappedChargeIds$: Observable>; + + /** + * Ids of every charge currently mapped to a facility. + * Falls back to an empty set if the lookup fails, so a charge picker still renders. + */ + getFacilityMappedChargeIds(): Observable> { + if (!this.mappedChargeIds$) { + this.mappedChargeIds$ = this.http.get('/facilitychargemappings').pipe( + map( + (mappings: any) => + new Set( + ((mappings as any[]) || []) + .map((mapping) => Number(mapping?.chargeId)) + .filter((chargeId) => Number.isFinite(chargeId)) + ) + ), + catchError(() => of(new Set())), + shareReplay({ bufferSize: 1, refCount: false }) + ); + } + return this.mappedChargeIds$; + } + + /** + * Removes facility-mapped charges from a charge option list. + * @param charges Charge options straight off a product/account template. + * @returns The same list without any charge priced by a facility mapping. + */ + excludeFacilityCharges(charges: any[]): Observable { + return this.getFacilityMappedChargeIds().pipe( + map((mappedChargeIds) => + (charges || []).filter((charge) => !mappedChargeIds.has(Number(charge?.id ?? charge?.chargeId))) + ) + ); + } +} diff --git a/src/app/shared/dormancy-alert-panel/dormancy-alert-panel.component.html b/src/app/shared/dormancy-alert-panel/dormancy-alert-panel.component.html index c85e9182c..500edea7e 100644 --- a/src/app/shared/dormancy-alert-panel/dormancy-alert-panel.component.html +++ b/src/app/shared/dormancy-alert-panel/dormancy-alert-panel.component.html @@ -12,7 +12,9 @@
- + +
diff --git a/src/app/shared/dormancy-alert-panel/dormancy-alert-panel.component.ts b/src/app/shared/dormancy-alert-panel/dormancy-alert-panel.component.ts index 4a0e7f944..35663d419 100644 --- a/src/app/shared/dormancy-alert-panel/dormancy-alert-panel.component.ts +++ b/src/app/shared/dormancy-alert-panel/dormancy-alert-panel.component.ts @@ -6,7 +6,7 @@ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ -import { Component, Input, OnInit } from '@angular/core'; +import { Component, Input, OnChanges, OnInit, SimpleChanges } from '@angular/core'; import { STANDALONE_SHARED_IMPORTS } from 'app/standalone-shared.module'; export type DormancyRisk = 'safe' | 'low' | 'medium' | 'high' | 'critical' | 'escheat'; @@ -17,7 +17,7 @@ export type DormancyRisk = 'safe' | 'low' | 'medium' | 'high' | 'critical' | 'es styleUrls: ['./dormancy-alert-panel.component.scss'], imports: [...STANDALONE_SHARED_IMPORTS] }) -export class DormancyAlertPanelComponent implements OnInit { +export class DormancyAlertPanelComponent implements OnInit, OnChanges { /** Pass full account data — for ACCOUNT VIEW (general tab) */ @Input() accountData: any; /** Pass product template — for ACCOUNT CREATION (currency step) */ @@ -48,13 +48,40 @@ export class DormancyAlertPanelComponent implements OnInit { } ngOnInit(): void { + this.refresh(); + } + + /** + * The stepper reuses one panel instance while the user switches products, so the thresholds have + * to be recomputed on every input change — without this the panel keeps showing the previously + * selected product's days. + */ + ngOnChanges(changes: SimpleChanges): void { + if (changes['productTemplate'] || changes['accountData']) { + this.refresh(); + } + } + + private refresh(): void { if (this.productTemplate && !this.accountData) { this.initFromProductTemplate(); } else if (this.accountData) { this.initFromAccountData(); + } else { + this.resetThresholds(); } } + /** Clears stale thresholds when neither a product nor an account is bound. */ + private resetThresholds(): void { + this.daysToInactive = null; + this.daysToDormancy = null; + this.daysToEscheat = null; + this.isMonitoringActive = false; + this.isAlertEnabled = false; + this.shouldShow = false; + } + // ── Product Template Mode (Currency Step) ───────────────────── private initFromProductTemplate(): void { const pt = this.productTemplate; @@ -113,10 +140,15 @@ export class DormancyAlertPanelComponent implements OnInit { } this.computeRiskFromAccount(); - // Only surface this panel once the account has actually left "Active" — - // i.e. subStatus is Inactive/Dormant/Escheat. A healthy active account - // (statusId === 0) should never show a danger-colored dormancy alert. - this.shouldShow = this.statusId > 0; + // Shown wherever dormancy monitoring applies, so a branch can read the thresholds and how far + // the account is from each of them. The template drops the risk timeline while the account is + // still healthy, where there is no risk to show. + this.shouldShow = this.statusId > 0 || this.isMonitoringActive; + } + + /** True when the account has not left "Active", so there is no risk timeline worth showing. */ + get isHealthy(): boolean { + return this.statusId === 0; } private computeRiskFromAccount(): void { diff --git a/src/app/sponsor-dd/dd-list/dd-list.component.html b/src/app/sponsor-dd/dd-list/dd-list.component.html index 25bd2f873..a7b1fa4ff 100644 --- a/src/app/sponsor-dd/dd-list/dd-list.component.html +++ b/src/app/sponsor-dd/dd-list/dd-list.component.html @@ -122,18 +122,40 @@ Actions
+ Pending Approval (Req #{{ element.pendingIssueRequestId }}) + + + + Amendment Pending (Req #{{ element.pendingAmendmentRequestId }}) + `, @@ -101,25 +134,22 @@ import { SponsorDDService } from '../sponsor-dd.service'; .full-width { width: 100%; } + .row-two-fields { + display: flex; + gap: 16px; + } + .half-width { + flex: 1; + } .hint-text { color: #64748b; font-size: 13px; - margin: 0 0 12px 0; + margin: 0 0 16px 0; + line-height: 1.4; } - .readonly-accounts { - border: 1px solid #e2e8f0; - border-radius: 8px; - padding: 12px 16px; - background: #f8fafc; - } - .account-row { - display: flex; - justify-content: space-between; - font-size: 13px; - padding: 4px 0; - color: #334155; - } - .account-row span { + .loading-state { + padding: 24px; + text-align: center; color: #64748b; } ` @@ -127,6 +157,7 @@ import { SponsorDDService } from '../sponsor-dd.service'; }) export class GlSettingsDialogComponent implements OnInit { private fb = inject(FormBuilder); + private http = inject(HttpClient); private sponsorDDService = inject(SponsorDDService); private dialogRef = inject(MatDialogRef); private snackBar = inject(MatSnackBar); @@ -135,9 +166,27 @@ export class GlSettingsDialogComponent implements OnInit { loading = true; saving = false; mapping: any = null; + glAccounts: any[] = []; ngOnInit(): void { this.form = this.fb.group({ + ddPayableGlAccountId: [ + null, + [Validators.required] + ], + chargesIncomeGlAccountId: [ + null, + [Validators.required] + ], + gstPayableGlAccountId: [ + null, + [Validators.required] + ], + cashGlAccountId: [ + null, + [Validators.required] + ], + chequeClearingGlAccountId: [null], gstPercentage: [ 18, [ @@ -151,20 +200,42 @@ export class GlSettingsDialogComponent implements OnInit { [Validators.min(0)] ] }); - this.sponsorDDService.getGlMappings().subscribe({ - next: (mappings) => { - this.mapping = mappings && mappings.length ? mappings[0] : null; - if (this.mapping) { - this.form.patchValue({ - gstPercentage: this.mapping.gstPercentage ?? 18, - ddLimitAmount: this.mapping.ddLimitAmount ?? null - }); - } - this.loading = false; + + this.loadGlAccountsAndMapping(); + } + + loadGlAccountsAndMapping(): void { + this.loading = true; + this.http.get('/glaccounts?manualEntriesAllowed=true&disabled=false').subscribe({ + next: (accounts) => { + this.glAccounts = accounts || []; + this.sponsorDDService.getGlMappings().subscribe({ + next: (mappings) => { + this.mapping = mappings && mappings.length ? mappings[0] : null; + if (this.mapping) { + this.form.patchValue({ + ddPayableGlAccountId: this.mapping.ddPayableGlAccountId ?? null, + chargesIncomeGlAccountId: this.mapping.chargesIncomeGlAccountId ?? null, + gstPayableGlAccountId: this.mapping.gstPayableGlAccountId ?? null, + cashGlAccountId: this.mapping.cashGlAccountId ?? null, + chequeClearingGlAccountId: this.mapping.chequeClearingGlAccountId ?? null, + gstPercentage: this.mapping.gstPercentage ?? 18, + ddLimitAmount: this.mapping.ddLimitAmount ?? null + }); + } + this.loading = false; + }, + error: (err) => { + this.loading = false; + this.snackBar.open('Error loading GL mapping: ' + (err.error?.message || err.message), 'Close', { + duration: 5000 + }); + } + }); }, error: (err) => { this.loading = false; - this.snackBar.open('Error loading GL settings: ' + (err.error?.message || err.message), 'Close', { + this.snackBar.open('Error loading GL accounts: ' + (err.error?.message || err.message), 'Close', { duration: 5000 }); } @@ -172,29 +243,32 @@ export class GlSettingsDialogComponent implements OnInit { } onSave(): void { - if (this.form.invalid || !this.mapping) return; + if (this.form.invalid) return; this.saving = true; + const val = this.form.value; this.sponsorDDService .upsertGlMapping({ sponsorBankId: 0, - ddPayableGlAccountId: this.mapping.ddPayableGlAccountId, - chequeClearingGlAccountId: this.mapping.chequeClearingGlAccountId, - cashGlAccountId: this.mapping.cashGlAccountId, - chargesIncomeGlAccountId: this.mapping.chargesIncomeGlAccountId, - cashSuspenseGlAccountId: this.mapping.cashSuspenseGlAccountId, - gstPercentage: this.form.value.gstPercentage, - gstPayableGlAccountId: this.mapping.gstPayableGlAccountId, - ddLimitAmount: this.form.value.ddLimitAmount + ddPayableGlAccountId: val.ddPayableGlAccountId, + chequeClearingGlAccountId: val.chequeClearingGlAccountId, + cashGlAccountId: val.cashGlAccountId, + chargesIncomeGlAccountId: val.chargesIncomeGlAccountId, + cashSuspenseGlAccountId: this.mapping?.cashSuspenseGlAccountId || val.cashGlAccountId, + gstPercentage: val.gstPercentage, + gstPayableGlAccountId: val.gstPayableGlAccountId, + ddLimitAmount: val.ddLimitAmount }) .subscribe({ next: () => { - this.snackBar.open('GST percentage updated', 'Close', { duration: 3000 }); + this.snackBar.open('Sponsor DD GL Mapping & GST Settings saved successfully!', 'Close', { duration: 3000 }); this.dialogRef.close(true); }, error: (err) => { this.saving = false; - this.snackBar.open('Error: ' + (err.error?.message || err.message), 'Close', { duration: 5000 }); + this.snackBar.open('Error saving GL settings: ' + (err.error?.message || err.message), 'Close', { + duration: 5000 + }); } }); } diff --git a/src/app/sponsor-dd/sponsor-dd.service.ts b/src/app/sponsor-dd/sponsor-dd.service.ts index 463135776..7f6e34ff4 100644 --- a/src/app/sponsor-dd/sponsor-dd.service.ts +++ b/src/app/sponsor-dd/sponsor-dd.service.ts @@ -49,6 +49,18 @@ export class SponsorDDService { return this.http.get(`/sponsordd/leaflets/by-number/${ddNumber}`); } + getNextAvailableLeaflet(branchId?: number, sponsorBankId?: number, inventoryId?: number): Observable { + const params: any = {}; + if (branchId) params.branchId = branchId; + if (sponsorBankId) params.sponsorBankId = sponsorBankId; + if (inventoryId) params.inventoryId = inventoryId; + return this.http.get('/sponsordd/leaflets/next-available', { params }); + } + + getSponsorBanks(): Observable { + return this.http.get('/sponsorbanks'); + } + // ── Actions ── issueDemandDraft( leafletId: number, @@ -102,6 +114,7 @@ export class SponsorDDService { chequeNumber?: string; chequeBankName?: string; cashierDepositRequestId?: number; + denominationData?: string; } ): Observable { return this.http.post(`/sponsordd/leaflets/${leafletId}/issue-request`, data); @@ -205,6 +218,35 @@ export class SponsorDDService { return this.http.post(`/sponsordd/reprint-requests/${id}/reject`, { rejectionReason }); } + // ── DD amendment requests (maker-checker) ── + // Correcting an issued draft's beneficiary, or the branch and city it is payable at. Nothing else + // is amendable: the money behind the face value has already moved, so a draft wrong in that way + // is cancelled and re-issued rather than edited. + + /** + * Maker step. A field left blank keeps what the draft already carries, so correcting only the + * city does not mean retyping the beneficiary and risking a second mistake. + */ + requestDdAmendment( + leafletId: number, + changes: { beneficiaryName?: string; payableAtBranch?: string; payableAtCity?: string; reason: string } + ): Observable { + return this.http.post(`/sponsordd/leaflets/${leafletId}/amendment-request`, changes); + } + + getAllAmendmentRequests(status?: string): Observable { + const params = status ? { status } : {}; + return this.http.get('/sponsordd/amendment-requests', { params }); + } + + approveAmendmentRequest(id: number): Observable { + return this.http.post(`/sponsordd/amendment-requests/${id}/approve`, {}); + } + + rejectAmendmentRequest(id: number, rejectionReason: string): Observable { + return this.http.post(`/sponsordd/amendment-requests/${id}/reject`, { rejectionReason }); + } + // ── Approver signature (stamped onto the printed DD) ── // Reuses Fineract's generic document-attachment API (entityType is a free-form string with // no per-type backend wiring required) rather than building dedicated signature storage. diff --git a/src/app/tasks/checker-inbox-and-tasks-tabs/pending-client-edits/client-edit-email-dialog/client-edit-email-dialog.component.html b/src/app/tasks/checker-inbox-and-tasks-tabs/pending-client-edits/client-edit-email-dialog/client-edit-email-dialog.component.html index a4b311eee..3f107ddac 100644 --- a/src/app/tasks/checker-inbox-and-tasks-tabs/pending-client-edits/client-edit-email-dialog/client-edit-email-dialog.component.html +++ b/src/app/tasks/checker-inbox-and-tasks-tabs/pending-client-edits/client-edit-email-dialog/client-edit-email-dialog.component.html @@ -9,22 +9,16 @@

✉ Approval Email - +
@if (data.emailSent) { - - Sent - + Sent } @else { - - Not sent - + Not sent } @if (data.emailOpenedAt) { - - Seen - + Seen } @else { Not yet seen @@ -34,16 +28,15 @@

-

{{ data.emailSubject ?? '(No Subject)' }}

- +
C
- +
CBSNexora @@ -53,7 +46,7 @@
- to + to @if (data.emailTo) { {{ data.emailTo }} } @else { diff --git a/src/app/tasks/checker-inbox-and-tasks-tabs/pending-client-edits/client-edit-email-dialog/client-edit-email-dialog.component.ts b/src/app/tasks/checker-inbox-and-tasks-tabs/pending-client-edits/client-edit-email-dialog/client-edit-email-dialog.component.ts index e2a18cb2d..5954b11f9 100644 --- a/src/app/tasks/checker-inbox-and-tasks-tabs/pending-client-edits/client-edit-email-dialog/client-edit-email-dialog.component.ts +++ b/src/app/tasks/checker-inbox-and-tasks-tabs/pending-client-edits/client-edit-email-dialog/client-edit-email-dialog.component.ts @@ -58,8 +58,12 @@ export class ClientEditEmailDialogComponent { if (!isoDate) return '—'; const d = new Date(isoDate); return d.toLocaleString('en-IN', { - day: '2-digit', month: 'short', year: 'numeric', - hour: '2-digit', minute: '2-digit', hour12: true + day: '2-digit', + month: 'short', + year: 'numeric', + hour: '2-digit', + minute: '2-digit', + hour12: true }); } } diff --git a/src/app/tasks/checker-inbox-and-tasks-tabs/pending-savings-account-edits/savings-account-edit-compare-dialog/savings-account-edit-compare-dialog.component.ts b/src/app/tasks/checker-inbox-and-tasks-tabs/pending-savings-account-edits/savings-account-edit-compare-dialog/savings-account-edit-compare-dialog.component.ts index 0b3b1f84c..cfe3ebddf 100644 --- a/src/app/tasks/checker-inbox-and-tasks-tabs/pending-savings-account-edits/savings-account-edit-compare-dialog/savings-account-edit-compare-dialog.component.ts +++ b/src/app/tasks/checker-inbox-and-tasks-tabs/pending-savings-account-edits/savings-account-edit-compare-dialog/savings-account-edit-compare-dialog.component.ts @@ -40,7 +40,6 @@ const FACILITY_LABELS: Record = { neftFacility: 'NEFT Facility', rtgsFacility: 'RTGS Facility', interBankTotalCapFacility: 'Inter-Bank Total Cap', - withdrawalFacility: 'Withdrawal Facility', depositFacilityKyc: 'Deposit Facility (KYC)', virtualDebitCard: 'Virtual Debit Card' }; 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 faecfc283..e37709044 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 @@ -218,6 +218,14 @@ Closure Requests + + + DD Approvals + + + + DD Reprint Requests +
diff --git a/src/app/tasks/tasks-routing.module.ts b/src/app/tasks/tasks-routing.module.ts index 0b35387f7..636148c3d 100644 --- a/src/app/tasks/tasks-routing.module.ts +++ b/src/app/tasks/tasks-routing.module.ts @@ -213,6 +213,22 @@ const routes: Routes = [ path: 'closure-requests', component: ClosureRequestsComponent, data: { title: 'Closure Requests' } + }, + { + path: 'dd-issue-approvals', + loadComponent: () => + import('../sponsor-dd/dd-issue-approvals/dd-issue-approvals.component').then( + (m) => m.DdIssueApprovalsComponent + ), + data: { title: 'DD Issuance Approvals' } + }, + { + path: 'dd-reprint-approvals', + loadComponent: () => + import('../sponsor-dd/dd-reprint-approvals/dd-reprint-approvals.component').then( + (m) => m.DdReprintApprovalsComponent + ), + data: { title: 'DD Reprint Approvals' } } ] }, diff --git a/src/app/tds-reports/tds-statutory.compute.ts b/src/app/tds-reports/tds-statutory.compute.ts index decb83d85..bbb50e748 100644 --- a/src/app/tds-reports/tds-statutory.compute.ts +++ b/src/app/tds-reports/tds-statutory.compute.ts @@ -146,8 +146,7 @@ export function declarationStatement(fy: string, asAt: string = TDS_AS_AT): Decl for (const row of declared) { const acceptedOn = row.declarationAcceptedOn as string; - const key = - cfg.declarationStatementFrequency === 'MONTHLY' ? acceptedOn.slice(0, 7) : `Q${quarterOf(acceptedOn)}`; + const key = cfg.declarationStatementFrequency === 'MONTHLY' ? acceptedOn.slice(0, 7) : `Q${quarterOf(acceptedOn)}`; if (!row.declarationUin) { missingUinCount++; @@ -182,20 +181,22 @@ export function declarationStatement(fy: string, asAt: string = TDS_AS_AT): Decl const periods: DeclarationStatementPeriod[] = [...buckets.entries()] .sort((a, b) => a[0].localeCompare(b[0])) - .map(([ - key, - lines - ]) => { - const dueDate = declarationStatementDueDate(lines[0].acceptedOn, year); - return { + .map( + ([ key, - label: cfg.declarationStatementFrequency === 'MONTHLY' ? monthLabel(`${key}-01`) : key, - dueDate, - overdue: dueDate < asAt, - lines, - totalPaidWithoutDeduction: lines.reduce((s, l) => s + l.paidWithoutDeduction, 0) - }; - }); + lines + ]) => { + const dueDate = declarationStatementDueDate(lines[0].acceptedOn, year); + return { + key, + label: cfg.declarationStatementFrequency === 'MONTHLY' ? monthLabel(`${key}-01`) : key, + dueDate, + overdue: dueDate < asAt, + lines, + totalPaidWithoutDeduction: lines.reduce((s, l) => s + l.paidWithoutDeduction, 0) + }; + } + ); return { fy: year, From ab270e824dacfc9fac1cfcedb709ab59283001de Mon Sep 17 00:00:00 2001 From: Nitesh Yadav Date: Mon, 7 Sep 2026 18:38:39 +0530 Subject: [PATCH 31/64] feat(login): list all three registered tenants in the login screen selector The tenant selector (mifosx-tenant-selector, already wired into the login page) hides itself whenever fineractPlatformTenantIds resolves to a single entry -- and env.js only ever listed 'abc_bank', so the dropdown never had a reason to show even though Socity_bank and, as of this session, Default Bank are all live, reachable tenants. Lists all three identifiers exactly as registered in the tenants table (default, abc_bank, socity_bank) so a user can actually pick which bank to log into instead of being stuck on whichever one was hardcoded. --- src/assets/env.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/assets/env.js b/src/assets/env.js index f2ee5002a..db4201002 100644 --- a/src/assets/env.js +++ b/src/assets/env.js @@ -18,7 +18,7 @@ window["env"]["apiActuator"] = ''; window["env"]["fineractPlatformTenantId"] = 'abc_bank'; - window["env"]["fineractPlatformTenantIds"] = 'abc_bank'; + window["env"]["fineractPlatformTenantIds"] = 'default,abc_bank,socity_bank'; window['env']['tenantLogoUrl'] = ''; window['env']['tenantLogoUrlDark'] = ''; From 035b11fa96ebaf2aed3e354182c7b79d1fb1021a Mon Sep 17 00:00:00 2001 From: keshav Date: Tue, 8 Sep 2026 10:38:59 +0530 Subject: [PATCH 32/64] feat(cashier): add end-of-day report, my work, and cashier updates --- src/app/cashier/cashier-routing.module.ts | 97 ++- src/app/cashier/cashier-shared.scss | 12 + src/app/cashier/cashier.service.ts | 25 +- .../closing-cash-report.component.html | 170 +++-- .../closing-cash-report.component.ts | 73 ++- .../cashier/day-book/day-book.component.html | 88 ++- .../cashier/day-book/day-book.component.scss | 87 +++ .../cashier/day-book/day-book.component.ts | 108 +++- .../end-of-day-report.component.html | 606 ++++++++++++++++++ .../end-of-day-report.component.scss | 448 +++++++++++++ .../end-of-day-report.component.ts | 536 ++++++++++++++++ .../cashier/my-work/my-work.component.html | 203 ++++++ .../cashier/my-work/my-work.component.scss | 294 +++++++++ src/app/cashier/my-work/my-work.component.ts | 288 +++++++++ .../voucher-report.component.html | 17 + .../voucher-report.component.scss | 22 + .../voucher-report.component.ts | 49 +- src/app/core/shell/shell.component.html | 91 ++- .../shared/theme-toggle/theming.service.ts | 52 +- src/environments/.env.ts | 4 +- 20 files changed, 3146 insertions(+), 124 deletions(-) create mode 100644 src/app/cashier/end-of-day-report/end-of-day-report.component.html create mode 100644 src/app/cashier/end-of-day-report/end-of-day-report.component.scss create mode 100644 src/app/cashier/end-of-day-report/end-of-day-report.component.ts create mode 100644 src/app/cashier/my-work/my-work.component.html create mode 100644 src/app/cashier/my-work/my-work.component.scss create mode 100644 src/app/cashier/my-work/my-work.component.ts diff --git a/src/app/cashier/cashier-routing.module.ts b/src/app/cashier/cashier-routing.module.ts index 6ff299f77..8c9416ea2 100644 --- a/src/app/cashier/cashier-routing.module.ts +++ b/src/app/cashier/cashier-routing.module.ts @@ -117,6 +117,59 @@ const routes: Routes = [ import('../clerk/photo-signature/photo-signature.component').then((m) => m.PhotoSignatureComponent), data: { title: 'Photo & Signature', breadcrumb: 'Photo & Signature' } }, + // The same register the cashier screens use, without their cash-only filter: a clerk raises + // book entries — a closure settled to savings, a share redemption, an interest posting the + // system made on their approval — and cash-only would hide exactly those. + { + path: 'received-report', + component: VoucherReportComponent, + data: { + title: 'Received Vouchers', + breadcrumb: 'Received Vouchers', + side: 'CR', + cashOnly: false, + cashiersOnly: true + } + }, + { + path: 'payment-report', + component: VoucherReportComponent, + data: { + title: 'Payment Vouchers', + breadcrumb: 'Payment Vouchers', + side: 'DR', + cashOnly: false, + cashiersOnly: true + } + }, + // Both sides in one list, scoped to whoever is signed in — the "what did I do today" + // question the two registers above cannot answer, because an approval raises no voucher. + { + path: 'my-work', + loadComponent: () => import('../cashier/my-work/my-work.component').then((m) => m.MyWorkComponent), + data: { title: 'My Vouchers', breadcrumb: 'My Vouchers' } + }, + { + path: 'end-of-day', + loadComponent: () => + import('../cashier/end-of-day-report/end-of-day-report.component').then((m) => m.EndOfDayReportComponent), + data: { title: 'End of the Day Report', breadcrumb: 'End of the Day Report' } + }, + // The system's own postings — interest, charges, escheats, journals. Both sides in one + // list: a night's run credits and debits, and splitting it across two screens would hide + // half of it from whichever one you opened. + { + path: 'system-vouchers', + component: VoucherReportComponent, + data: { + title: 'System Vouchers', + breadcrumb: 'System Vouchers', + side: 'ALL', + cashOnly: false, + cashiersOnly: false, + systemOnly: true + } + }, { path: 'standing-instructions', loadComponent: () => @@ -172,6 +225,23 @@ const routes: Routes = [ path: 'vault-position', component: VaultPositionComponent, data: { title: 'Vault Position', breadcrumb: 'Vault 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 + // person's own credit, debit and net — which is the branch-level reading of the day, not + // a counter one. + path: 'day-book', + component: DayBookComponent, + data: { title: 'Day Book & Verification', breadcrumb: 'Day Book' } + }, + // The one sheet the day is signed off on: the transaction summary, the drawer counted + // against the books, and the notes that say why it cannot be closed yet. + { + path: 'end-of-day', + loadComponent: () => + import('./end-of-day-report/end-of-day-report.component').then((m) => m.EndOfDayReportComponent), + data: { title: 'End of the Day Report', breadcrumb: 'End of the Day Report' } } ] }, @@ -235,18 +305,41 @@ const routes: Routes = [ { path: 'received-report', component: VoucherReportComponent, - data: { title: 'Received Report', breadcrumb: 'Received Report', side: 'CR', cashOnly: true } + data: { + title: 'Received Report', + breadcrumb: 'Received Report', + side: 'CR', + cashOnly: true, + cashiersOnly: true + } }, { path: 'payment-report', component: VoucherReportComponent, - data: { title: 'Payment Report', breadcrumb: 'Payment Report', side: 'DR', cashOnly: true } + data: { + title: 'Payment Report', + breadcrumb: 'Payment Report', + side: 'DR', + cashOnly: true, + cashiersOnly: true + } + }, + { + path: 'my-work', + loadComponent: () => import('./my-work/my-work.component').then((m) => m.MyWorkComponent), + data: { title: 'My Vouchers', breadcrumb: 'My Vouchers' } }, { path: 'closing-report', component: ClosingCashReportComponent, data: { title: 'Closing Cash Report', breadcrumb: 'Closing Cash Report' } }, + { + path: 'end-of-day', + loadComponent: () => + import('./end-of-day-report/end-of-day-report.component').then((m) => m.EndOfDayReportComponent), + data: { title: 'End of the Day Report', breadcrumb: 'End of the Day Report' } + }, { path: 'excess-report', component: ExcessTransactionReportComponent, diff --git a/src/app/cashier/cashier-shared.scss b/src/app/cashier/cashier-shared.scss index 09ece3b90..58ac3ab4b 100644 --- a/src/app/cashier/cashier-shared.scss +++ b/src/app/cashier/cashier-shared.scss @@ -714,3 +714,15 @@ } } } + +/* Names a figure strip before the reader meets it. A run of tiles ending in a total reads as + arithmetic to somebody who already knows the screen and as five unrelated numbers to somebody + who does not. */ +.closing-lede { + margin: 0 0 -4px; + font-size: 12px; + font-weight: 700; + letter-spacing: 0.04em; + text-transform: uppercase; + color: #475569; +} diff --git a/src/app/cashier/cashier.service.ts b/src/app/cashier/cashier.service.ts index d9c9853a5..9845ce8a4 100644 --- a/src/app/cashier/cashier.service.ts +++ b/src/app/cashier/cashier.service.ts @@ -138,7 +138,9 @@ export class CashierService { to: string, username?: string, voucherType?: string, - cashOnly?: boolean + cashOnly?: boolean, + cashiersOnly?: boolean, + systemOnly?: boolean ): Observable { let params = new HttpParams().set('from', from).set('to', to); if (username) { @@ -152,9 +154,30 @@ export class CashierService { if (cashOnly) { params = params.set('cashOnly', 'true'); } + // Opt-in the same way: the counter registers ask for the drawer-holders only, the back-office + // ones keep seeing whoever raised a voucher. + if (cashiersOnly) { + params = params.set('cashiersOnly', 'true'); + } + // The postings nobody filed a request for: interest a nightly run credited, a charge taken from + // a balance, an escheat, a journal keyed to the ledger. Nowhere else shows them — My Vouchers is + // the reader's own work and a scheduled job is nobody's. + if (systemOnly) { + params = params.set('systemOnly', 'true'); + } return this.http.get('/vouchers/report', { params }); } + /** + * The signed-in user's own day: every voucher they raised and every one they verified, receipts + * and payments in one list. Always scoped to the caller by the server — there is no username + * parameter, because "my work" for somebody else is the register's job, not this screen's. + */ + myVoucherWork(from: string, to: string): Observable { + const params = new HttpParams().set('from', from).set('to', to); + return this.http.get('/vouchers/my-work', { params }); + } + /** * Account register for one date: what opened and closed that day, each account's balance as it * stood then, and what it holds now. diff --git a/src/app/cashier/closing-cash-report/closing-cash-report.component.html b/src/app/cashier/closing-cash-report/closing-cash-report.component.html index 0f9ad0019..b6faf9d5c 100644 --- a/src/app/cashier/closing-cash-report/closing-cash-report.component.html +++ b/src/app/cashier/closing-cash-report/closing-cash-report.component.html @@ -9,9 +9,8 @@
-
CASHIER · REPORT

Closing Cash Report

-

Denomination-wise deposit, withdrawal and net cash position for the selected day.

+

How much cash you started with, what came in and went out, and how much you must hand back.

- - @if (!showGl && (rows.length > 0 || ddCashTotal > 0)) { - -
+ + @if (!showGl && scopedToCashier && hasTodayLegs) { + +

Your cash for the day, step by step

+
- Total Deposits (IN) + Cash you got this morning + ₹{{ todayVaultIn | number: '1.2-2' }} +
+
+
+
+ Cash customers gave you ₹{{ depositTotalWithDd | number: '1.2-2' }} @if (ddCashTotal > 0) { - incl. ₹{{ ddCashTotal | number: '1.2-2' }} not itemised ({{ ddCashCount }} cash DD{{ + ₹{{ ddCashTotal | number: '1.2-2' }} of this was for {{ ddCashCount }} draft{{ ddCashCount === 1 ? '' : 's' - }}) + }}, notes not listed }
−
- Total Withdrawals (OUT) + Cash you gave customers ₹{{ withdrawalTotal | number: '1.2-2' }}
-
=
-
- - Net Counter Movement - ₹{{ netTotal | number: '1.2-2' }} -
-
- } - - - @if (!showGl && scopedToCashier && closingBalance !== null) { -
-
- Opening Balance - ₹{{ openingBalance | number: '1.2-2' }} -
-
+
-
- Cash Movement - ₹{{ cashMovement | number: '1.2-2' }} +
−
+
+ Cash already returned + ₹{{ todaySettledOut | number: '1.2-2' }}
=
+
- Closing Balance - ₹{{ closingBalance | number: '1.2-2' }} + Cash left with you now + ₹{{ expectedInDrawer | number: '1.2-2' }}
- @if (cashMovement !== null && netTotal !== cashMovement) { - +

+ Count your cash now — it should come to ₹{{ expectedInDrawer | number: '1.2-2' }}. Hand that amount back and + your day is done. +

+ + @if ((todayInterCashierIn ?? 0) > 0 || (todayInterCashierOut ?? 0) > 0) {

- Cash movement differs from counter movement by ₹{{ cashMovement - netTotal | number: '1.2-2' }} — vault, - inter-cashier and exchange legs that did not pass over the counter. + This already counts cash swapped with another cashier — ₹{{ todayInterCashierIn | number: '1.2-2' }} taken + from them, ₹{{ todayInterCashierOut | number: '1.2-2' }} given to them.

} } @@ -111,13 +102,16 @@ - - - - - - - + + + + + + @@ -126,15 +120,16 @@ - - - - + + + - - - + } @@ -170,13 +163,11 @@ - - - - - + + + @@ -190,13 +181,11 @@
DenominationDeposit PcsDeposit AmtWithdrawal PcsWithdrawal AmtNet PcsNet AmountNoteCame inWent outLeft with youWorth
₹{{ r.denominationValue }} {{ r.depositCount }}₹{{ r.depositAmount | number: '1.2-2' }}{{ r.withdrawalCount }}₹{{ r.withdrawalAmount | number: '1.2-2' }}{{ r.vaultInCount + r.depositCount || '—' }}{{ r.withdrawalCount + r.settledOutCount || '—' }} - {{ r.netCount > 0 ? '+' : '' }}{{ r.netCount }} + {{ r.netCount }} 0) {
- Not itemised + Notes not listed {{ ddCashCount }} cash DD{{ ddCashCount === 1 ? '' : 's' }} —₹{{ ddCashTotal | number: '1.2-2' }} —₹0.00 —+₹{{ ddCashTotal | number: '1.2-2' }}₹{{ ddCashTotal | number: '1.2-2' }}
Total{{ noteDepositCount }}₹{{ noteDepositAmount | number: '1.2-2' }}{{ noteWithdrawalCount }}₹{{ noteWithdrawalAmount | number: '1.2-2' }}{{ noteVaultInCount + noteDepositCount || '—' }}{{ noteWithdrawalCount + noteSettledOutCount || '—' }}{{ noteNetCount }} - {{ noteNetAmount >= 0 ? '+' : '−' }}₹{{ + {{ noteNetAmount >= 0 ? '' : '−' }}₹{{ (noteNetAmount < 0 ? -noteNetAmount : noteNetAmount) | number: '1.2-2' }}
- - - - - - - + + + + + @@ -205,15 +194,16 @@ - - - - + + + + + @if (systemOnly) { + + } @@ -209,6 +218,14 @@ } + @if (systemOnly) { + + } } diff --git a/src/app/cashier/voucher-report/voucher-report.component.scss b/src/app/cashier/voucher-report/voucher-report.component.scss index 690eb1893..1fe7a3b97 100644 --- a/src/app/cashier/voucher-report/voucher-report.component.scss +++ b/src/app/cashier/voucher-report/voucher-report.component.scss @@ -70,3 +70,25 @@ display: none !important; } } + +/* The ledger date on the system register. Marked when it differs from the day the voucher was + written: that is the whole reason the column exists, and a date the reader has to compare against + the Time column by eye is a date they will misread. */ +.vr-ledgerdate { + font-size: 12px; + font-weight: 600; + white-space: nowrap; +} + +.vr-ledgerdate--prior { + color: #5b21b6; +} + +.vr-priorflag { + display: block; + font-size: 9.5px; + font-weight: 700; + letter-spacing: 0.04em; + text-transform: uppercase; + color: #7c3aed; +} diff --git a/src/app/cashier/voucher-report/voucher-report.component.ts b/src/app/cashier/voucher-report/voucher-report.component.ts index 1d34966fa..8444b2813 100644 --- a/src/app/cashier/voucher-report/voucher-report.component.ts +++ b/src/app/cashier/voucher-report/voucher-report.component.ts @@ -20,7 +20,7 @@ import { CashierService } from '../cashier.service'; * 'CR' is the Received report (money in), 'DR' the Payment report (money out). Set from the route, * so the two screens are the same component pointed at opposite sides. */ -export type VoucherSide = 'CR' | 'DR'; +export type VoucherSide = 'CR' | 'DR' | 'ALL'; /** One voucher as the register shows it: the number, the two sides, and who raised it. */ interface VoucherRow { @@ -28,6 +28,10 @@ interface VoucherRow { voucherType: string | null; amount: number | null; createdAt: string | null; + /** The business date the posting belongs to, which is not always the day it was written. */ + ledgerDate?: string | null; + /** True when the voucher was written after the day it posts against had already closed. */ + postedAfterClose?: boolean; userId: number | null; username: string | null; branchName: string | null; @@ -105,6 +109,25 @@ export class VoucherReportComponent implements OnInit { */ cashOnly = false; + /** + * Whether this register is the counter's own — only the people who hold a drawer. + * + * These two screens reconcile the counter: what came in over it and what went out. Everyone + * else's work is their own to account for, and each person reads it on My Vouchers. Mixed in here + * it buried the cashier rows the registers exist for and left the totals matching no drawer. + */ + cashiersOnly = false; + + /** + * Whether this register is the system's own postings rather than anybody's work. + * + * Interest a nightly run credited, a charge taken from a balance, an escheat, a journal keyed + * straight to the ledger. These belong to no member of staff, so My Vouchers cannot show them — + * it is scoped to the reader — and the two counter registers deliberately exclude them. That left + * no screen answering "what did the system post last night". + */ + systemOnly = false; + report: VoucherReport | null = null; loading = false; error: string | null = null; @@ -112,6 +135,8 @@ export class VoucherReportComponent implements OnInit { ngOnInit(): void { this.side = (this.route.snapshot.data['side'] as VoucherSide) ?? 'CR'; this.cashOnly = this.route.snapshot.data['cashOnly'] === true; + this.cashiersOnly = this.route.snapshot.data['cashiersOnly'] === true; + this.systemOnly = this.route.snapshot.data['systemOnly'] === true; this.load(); } @@ -120,10 +145,23 @@ export class VoucherReportComponent implements OnInit { } get title(): string { + if (this.systemOnly) { + return 'System Vouchers'; + } return this.isReceived ? 'Received Report' : 'Payment Report'; } get subtitle(): string { + if (this.systemOnly) { + return 'What the system posted on its own — interest, charges, escheats and journals. Nobody raised these, so they appear on no one’s own list.'; + } + // Said plainly, because a register that silently omits the reader's own entries is worse than + // one that explains why: their work is on My Vouchers, and the line points them there. + if (this.cashiersOnly) { + return this.isReceived + ? 'Cash taken in at the counter by each cashier. Other staff read their own work on My Vouchers.' + : 'Cash paid out at the counter by each cashier. Other staff read their own work on My Vouchers.'; + } if (this.cashOnly) { return this.isReceived ? 'Cash taken in over a day or a month, and who took it — book entries left out.' @@ -136,6 +174,9 @@ export class VoucherReportComponent implements OnInit { /** The word for one row on this side, for counts and the empty state. */ get noun(): string { + if (this.systemOnly) { + return 'posting'; + } return this.isReceived ? 'receipt' : 'payment'; } @@ -150,7 +191,9 @@ export class VoucherReportComponent implements OnInit { to, this.usernameFilter.trim() || undefined, this.typeFilter.trim() || undefined, - this.cashOnly + this.cashOnly, + this.cashiersOnly, + this.systemOnly ) .pipe( catchError((err: any) => { @@ -199,7 +242,7 @@ export class VoucherReportComponent implements OnInit { */ get rows(): VoucherRow[] { const all = this.report?.vouchers ?? []; - return all.filter((row) => this.sideOf(row) === this.side); + return this.side === 'ALL' ? all : all.filter((row) => this.sideOf(row) === this.side); } /** diff --git a/src/app/core/shell/shell.component.html b/src/app/core/shell/shell.component.html index 7338b6eb3..ea35032d0 100644 --- a/src/app/core/shell/shell.component.html +++ b/src/app/core/shell/shell.component.html @@ -184,6 +184,44 @@ Today's Activity } + + + + + + + My Vouchers + + + + + + + + Received Vouchers + + + + + + + + + Payment Vouchers + ESC Show Header } @else { @@ -245,6 +283,9 @@ > Cash History + + My Vouchers + Accounting + + + @@ -1041,6 +1090,17 @@ + + + + + + + + + + + + + diff --git a/src/app/shared/theme-toggle/theming.service.ts b/src/app/shared/theme-toggle/theming.service.ts index 7684a3fdb..d9e3d7841 100644 --- a/src/app/shared/theme-toggle/theming.service.ts +++ b/src/app/shared/theme-toggle/theming.service.ts @@ -24,27 +24,51 @@ export class ThemingService { theme = new BehaviorSubject('light-theme'); // <- initial theme constructor() { - // Initially check if dark mode is enabled on system - this.darkModeOn = window.matchMedia && window.matchMedia('(prefers-color-scheme: dark)').matches; - - // If dark mode is enabled then directly switch to the dark-theme + // The user's own choice, kept in localStorage by the theme toggle, is the only thing that + // decides the theme. + // + // The OS preference used to decide it instead, and a media-query listener kept it in step with + // Windows. That is what turned a page black after printing: Chrome re-evaluates + // prefers-color-scheme around the print dialog, the listener fired on the way back, and the app + // flipped to dark against the user's saved choice — on every screen, and only after a print, + // which is exactly how it was reported. A member of staff who set the app light does not expect + // printing a report to change it, and the toggle they used had no way to win against the OS. + // + // Nothing here reads the OS any more, so there is no listener to fire and nothing to fight the + // toggle. isDarkMode() still reports the OS preference for the one caller that offers it as a + // default on first use. + this.darkModeOn = !!this.storedPreference(); this.setDarkMode(this.darkModeOn); - - // Watch for changes of the preference - window.matchMedia('(prefers-color-scheme: dark)').addListener((e) => { - const turnOn = e.matches; - this.theme.next(turnOn ? 'dark-theme' : 'light-theme'); - - // Trigger refresh of UI - this.ref.tick(); - }); } + /** The saved choice, or null when the user has never set one. */ + private storedPreference(): boolean | null { + try { + const raw = localStorage.getItem('mifosXThemeDarkEnabled'); + return raw === null ? null : JSON.parse(raw); + } catch { + // A blocked or corrupt localStorage must not stop the app from rendering; light is the + // default the rest of the stylesheet already assumes. + return null; + } + } + + /** + * Whether the app is currently in dark mode. + * + * Reports the app's own state, not the OS preference. It used to read the media query and assign + * the result to darkModeOn on the way past — so merely asking the question overwrote the user's + * saved choice, and setInitialDarkMode() then applied whatever the OS happened to say. + */ isDarkMode(): boolean { - this.darkModeOn = window.matchMedia && window.matchMedia('(prefers-color-scheme: dark)').matches; return this.darkModeOn; } + /** The OS preference, offered as a starting point when the user has never chosen for themselves. */ + isSystemDarkMode(): boolean { + return !!(window.matchMedia && window.matchMedia('(prefers-color-scheme: dark)').matches); + } + setDarkMode(isDarkMode: boolean) { this.darkModeOn = isDarkMode; if (isDarkMode) { diff --git a/src/environments/.env.ts b/src/environments/.env.ts index 819f5824c..4322db0df 100644 --- a/src/environments/.env.ts +++ b/src/environments/.env.ts @@ -9,8 +9,8 @@ /* tslint:disable */ export default { 'mifos_x': { - 'version': '260902', - 'hash': '63134f17' + 'version': '260908', + 'hash': 'fbce73fd' }, 'allow_switching_backend_instance': true }; From 811c535d998ad6a99aefe5436a8e16fe11f400f0 Mon Sep 17 00:00:00 2001 From: Nitesh Yadav Date: Tue, 8 Sep 2026 15:18:54 +0530 Subject: [PATCH 33/64] fix(shares): persist product-default charges on submit; show real charge state; office-delete UX Two share-account charge bugs found while chasing why a share account's entry-fee charge wasn't showing up in the "Apply Share Advance Collection" flow: - create-shares-account.component.ts / edit-shares-account.component.ts: the submit payload built charges as { chargeId: charge.id, amount }. A charge seeded from the product's own default template arrives as ShareAccountChargeData, where the real charge id lives in `chargeId` and `id` is null (no saved ShareAccountCharge row yet); only a charge picked from this step's own "Charge" dropdown (plain ChargeData) has it in `id`. So `charge.id` alone silently produced chargeId: undefined for the common case (accepting the product's own default charges), Angular's JSON.stringify dropped the undefined field, and the backend received a charge with no id to attach -- the account got created with zero persisted charges no matter what the product had configured. Verified via the actual submitted command JSON in the DB (charges:[{"amount":200}], chargeId missing entirely). Fixed by falling back to charge.chargeId || charge.id in both places. - shares-account-charges-step.component.ts: on the "edit an existing account" flow, the account's real saved charges (possibly a correctly empty array) get patched into chargesDataSource once the shared currencyCode control settles, but that patch never cleared the `pristine` guard. ngOnChanges fires again on the same input-update cycle (the sibling Terms step, declared earlier in the wizard, patches currencyCode from its own ngOnChanges, which synchronously triggers this component's currencyCode subscription, and Angular then runs this component's own ngOnChanges right after) -- with `pristine` still true, that re-seeded chargesDataSource from the product's *current* default template, silently overwriting the real (possibly empty) saved state. Set pristine = false once the real charges are patched in, so a later ngOnChanges can't clobber it. Also: offices.component.ts now distinguishes an office delete that actually completed from one that was queued for checker approval (response.rollbackTransaction, the same signal every other maker-checker-eligible screen in this app already keys off), instead of always claiming "Office Removed" regardless of which happened. Co-Authored-By: Claude Sonnet 5 --- src/app/organization/offices/offices.component.ts | 11 +++++++++-- .../create-shares-account.component.ts | 11 ++++++++++- .../edit-shares-account.component.ts | 8 +++++++- .../shares-account-charges-step.component.ts | 7 +++++++ 4 files changed, 33 insertions(+), 4 deletions(-) diff --git a/src/app/organization/offices/offices.component.ts b/src/app/organization/offices/offices.component.ts index 518b5bbd9..2f71d361f 100644 --- a/src/app/organization/offices/offices.component.ts +++ b/src/app/organization/offices/offices.component.ts @@ -262,8 +262,15 @@ export class OfficesComponent implements OnInit { ref.afterClosed().subscribe((response: any) => { if (!response?.delete) return; this.organizationService.deleteOffice(row.id).subscribe({ - next: () => { - this.showSuccess('Office Removed', `"${row.name}" has been deleted.`); + next: (response: any) => { + if (response?.rollbackTransaction) { + this.showSuccess( + 'Sent for Approval', + `Deleting "${row.name}" has been submitted and is awaiting checker approval.` + ); + } else { + this.showSuccess('Office Removed', `"${row.name}" has been deleted.`); + } this.refreshOffices(); }, error: (err: any) => { diff --git a/src/app/shares/create-shares-account/create-shares-account.component.ts b/src/app/shares/create-shares-account/create-shares-account.component.ts index 302754f98..694c36fbb 100644 --- a/src/app/shares/create-shares-account/create-shares-account.component.ts +++ b/src/app/shares/create-shares-account/create-shares-account.component.ts @@ -129,7 +129,16 @@ export class CreateSharesAccountComponent { const sharesAccount = { ...this.sharesAccount, clientId: this.sharesAccountTemplate.clientId, - charges: this.sharesAccount.charges.map((charge: any) => ({ chargeId: charge.id, amount: charge.amount })), + // A charge seeded from the product's own default template arrives as ShareAccountChargeData -- + // its `chargeId` field carries the real underlying charge id, `id` is null (it has no saved + // ShareAccountCharge row yet). A charge picked from this step's own "Charge" dropdown arrives as + // plain ChargeData instead, where `id` *is* the charge id. `charge.id` alone silently dropped the + // product-seeded case (chargeId ended up undefined and Angular's JSON.stringify just omits it), + // so an account created straight from the product defaults never actually got its charges saved. + charges: this.sharesAccount.charges.map((charge: any) => ({ + chargeId: charge.chargeId || charge.id, + amount: charge.amount + })), applicationDate: this.dateUtils.formatDate(this.sharesAccount.applicationDate, dateFormat), submittedDate: this.dateUtils.formatDate(this.sharesAccount.submittedDate, dateFormat), resolutionDate: this.dateUtils.formatDate(this.sharesAccount.resolutionDate, dateFormat), diff --git a/src/app/shares/edit-shares-account/edit-shares-account.component.ts b/src/app/shares/edit-shares-account/edit-shares-account.component.ts index 0c0d9cb2b..919e5a3ac 100644 --- a/src/app/shares/edit-shares-account/edit-shares-account.component.ts +++ b/src/app/shares/edit-shares-account/edit-shares-account.component.ts @@ -135,7 +135,13 @@ export class EditSharesAccountComponent { const sharesAccount = { ...this.sharesAccount, clientId: this.sharesAccountAndTemplate.clientId, - charges: this.sharesAccount.charges.map((charge: any) => ({ chargeId: charge.id, amount: charge.amount })), + // See create-shares-account.component.ts's submit() for why chargeId falls back to charge.id -- + // a product-default charge (ShareAccountChargeData) carries the real charge id in `chargeId`, + // not `id`; only a charge picked from this step's own dropdown (plain ChargeData) has it in `id`. + charges: this.sharesAccount.charges.map((charge: any) => ({ + chargeId: charge.chargeId || charge.id, + amount: charge.amount + })), applicationDate: this.dateUtils.formatDate(this.sharesAccount.applicationDate, dateFormat), submittedDate: this.dateUtils.formatDate(this.sharesAccount.submittedDate, dateFormat), resolutionDate: this.dateUtils.formatDate(this.sharesAccount.resolutionDate, dateFormat), diff --git a/src/app/shares/shares-account-stepper/shares-account-charges-step/shares-account-charges-step.component.ts b/src/app/shares/shares-account-stepper/shares-account-charges-step/shares-account-charges-step.component.ts index d5a89f5f5..a4af04a3a 100644 --- a/src/app/shares/shares-account-stepper/shares-account-charges-step/shares-account-charges-step.component.ts +++ b/src/app/shares/shares-account-stepper/shares-account-charges-step/shares-account-charges-step.component.ts @@ -104,6 +104,13 @@ export class SharesAccountChargesStepComponent implements OnInit, OnChanges { if (!this.isChargesPatched && this.sharesAccountTemplate.charges) { this.chargesDataSource = this.sharesAccountTemplate.charges; this.isChargesPatched = true; + // The account's real saved charges just landed (possibly an empty array, meaning the + // account genuinely has none). ngOnChanges fires again on the very same input-update cycle + // that triggered this subscription (currencyCode.patchValue is called from the terms step's + // own ngOnChanges, which runs before this component's) -- without this, its `pristine` guard + // would still be true and silently re-seed chargesDataSource from the product's *current* + // default template, overwriting the real state this just patched in. + this.pristine = false; } }); } From 227949f8b2577a493c7eb00cb6ca34e20df5ef0d Mon Sep 17 00:00:00 2001 From: anshul Date: Wed, 9 Sep 2026 11:32:37 +0530 Subject: [PATCH 34/64] feat: implement product approval workflow updates --- src/app/pipes/date-format.pipe.ts | 4 + src/app/products/products.service.ts | 10 +- ...vings-transaction-general-tab.component.ts | 1 - ...iew-journal-entry-transaction.component.ts | 1 - .../file-upload/file-upload.component.ts | 4 +- .../keyboard-shortcuts-dialog.component.ts | 2 - ...inline-create-shortcut-dialog.component.ts | 3 +- .../reco-batch-list.component.ts | 3 +- .../dd-inventory-list.component.ts | 3 +- .../sponsor-dd/dd-list/dd-list.component.ts | 3 +- .../checker-inbox.component.html | 5 +- .../checker-inbox.component.scss | 9 - .../checker-inbox/checker-inbox.component.ts | 148 ++++++--- .../pending-client-edits.component.ts | 3 +- .../pending-savings-products.component.html | 32 +- .../pending-savings-products.component.scss | 11 + .../pending-savings-products.component.ts | 120 +++++-- .../product-edit-diff-dialog.component.html | 57 ++++ .../product-edit-diff-dialog.component.scss | 194 +++++++++++ .../product-edit-diff-dialog.component.ts | 95 ++++++ .../rejected-account.component.ts | 1 - .../terminated-account.component.ts | 1 - .../checker-inbox-and-tasks.component.html | 4 +- .../getPendingSavingsProducts.resolver.ts | 88 ++++- src/app/tasks/product-entities.ts | 121 +++++++ src/app/tasks/tasks-routing.module.ts | 10 +- src/app/tasks/tasks.service.ts | 8 - .../view-checker-inbox.component.html | 242 ++++++++------ .../view-checker-inbox.component.scss | 312 +++++++++++++++++- .../view-checker-inbox.component.ts | 253 +++++++++++--- .../users/dsc/dsc-list/dsc-list.component.ts | 1 - 31 files changed, 1472 insertions(+), 277 deletions(-) create mode 100644 src/app/tasks/checker-inbox-and-tasks-tabs/pending-savings-products/product-edit-diff-dialog/product-edit-diff-dialog.component.html create mode 100644 src/app/tasks/checker-inbox-and-tasks-tabs/pending-savings-products/product-edit-diff-dialog/product-edit-diff-dialog.component.scss create mode 100644 src/app/tasks/checker-inbox-and-tasks-tabs/pending-savings-products/product-edit-diff-dialog/product-edit-diff-dialog.component.ts create mode 100644 src/app/tasks/product-entities.ts diff --git a/src/app/pipes/date-format.pipe.ts b/src/app/pipes/date-format.pipe.ts index 4582f2621..c12113dff 100644 --- a/src/app/pipes/date-format.pipe.ts +++ b/src/app/pipes/date-format.pipe.ts @@ -59,6 +59,10 @@ export class DateFormatPipe implements PipeTransform, OnDestroy { if (Array.isArray(value)) { // Backend sometimes returns [yyyy, MM, dd] dateVal = moment(value.join('-'), 'YYYY-MM-DD'); + } else if (typeof value === 'number' && value < 1e12) { + // Unix timestamp in seconds (e.g. /makercheckers madeOnDate). + // moment(seconds) would treat it as milliseconds → 1970; use moment.unix() instead. + dateVal = moment.unix(value); } else { dateVal = moment(value); } diff --git a/src/app/products/products.service.ts b/src/app/products/products.service.ts index c986955bc..de7aaaa7e 100644 --- a/src/app/products/products.service.ts +++ b/src/app/products/products.service.ts @@ -76,7 +76,7 @@ export class ProductsService { /** * Submitting a savings product update (above) puts it in the pending-approval temp table instead of - * applying it directly. These calls drive the Approvals -> Pending Savings Product Edits screen. + * applying it directly. These calls drive the Approvals -> Product Approval screen. */ getPendingSavingsProductEdits(): Observable { return this.http.get('/savingsproducts/pending'); @@ -124,7 +124,7 @@ export class ProductsService { /** * Submitting a current product update (above) puts it in the pending-approval temp table instead of - * applying it directly. These calls drive the Approvals -> Pending Product Edits screen. + * applying it directly. These calls drive the Approvals -> Product Approval screen. */ getPendingCurrentProductEdits(): Observable { return this.http.get('/currentproducts/pending'); @@ -168,7 +168,7 @@ export class ProductsService { /** * Submitting a share product update (above) puts it in the pending-approval temp table instead of - * applying it directly. These calls drive the Approvals -> Pending Product Edits screen. + * applying it directly. These calls drive the Approvals -> Product Approval screen. */ getPendingShareProductEdits(): Observable { return this.http.get('/products/share/pending'); @@ -313,7 +313,7 @@ export class ProductsService { /** * Submitting a fixed deposit product update (above) puts it in the pending-approval temp table instead of - * applying it directly. These calls drive the Approvals -> Pending Product Edits screen. + * applying it directly. These calls drive the Approvals -> Product Approval screen. */ getPendingFixedDepositProductEdits(): Observable { return this.http.get('/fixeddepositproducts/pending'); @@ -608,7 +608,7 @@ export class ProductsService { /** * Submitting a recurring deposit product update (above) puts it in the pending-approval temp table instead of - * applying it directly. These calls drive the Approvals -> Pending Product Edits screen. + * applying it directly. These calls drive the Approvals -> Product Approval screen. */ getPendingRecurringDepositProductEdits(): Observable { return this.http.get('/recurringdepositproducts/pending'); diff --git a/src/app/savings/savings-account-view/transactions/view-transaction/savings-transaction-general-tab/savings-transaction-general-tab.component.ts b/src/app/savings/savings-account-view/transactions/view-transaction/savings-transaction-general-tab/savings-transaction-general-tab.component.ts index 99b8e30d5..2bc3398bc 100644 --- a/src/app/savings/savings-account-view/transactions/view-transaction/savings-transaction-general-tab/savings-transaction-general-tab.component.ts +++ b/src/app/savings/savings-account-view/transactions/view-transaction/savings-transaction-general-tab/savings-transaction-general-tab.component.ts @@ -27,7 +27,6 @@ import { STANDALONE_SHARED_IMPORTS } from 'app/standalone-shared.module'; imports: [ ...STANDALONE_SHARED_IMPORTS, FaIconComponent, - NgClass, TransactionPaymentDetailComponent, CurrencyPipe, DateFormatPipe 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 01047441e..fa6a999a2 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 @@ -60,7 +60,6 @@ import { MatTooltipModule } from '@angular/material/tooltip'; DateFormatPipe, DatetimeFormatPipe, FormatNumberPipe, - YesnoPipe, MatCardModule, MatTooltipModule ] diff --git a/src/app/shared/file-upload/file-upload.component.ts b/src/app/shared/file-upload/file-upload.component.ts index 5ad7607ca..583ce5cb2 100644 --- a/src/app/shared/file-upload/file-upload.component.ts +++ b/src/app/shared/file-upload/file-upload.component.ts @@ -20,9 +20,7 @@ import { STANDALONE_SHARED_IMPORTS } from 'app/standalone-shared.module'; templateUrl: './file-upload.component.html', styleUrls: ['./file-upload.component.scss'], imports: [ - ...STANDALONE_SHARED_IMPORTS, - NgStyle, - FaIconComponent + ...STANDALONE_SHARED_IMPORTS ] }) export class FileUploadComponent implements OnInit { diff --git a/src/app/shared/keyboard-shortcuts-dialog/keyboard-shortcuts-dialog.component.ts b/src/app/shared/keyboard-shortcuts-dialog/keyboard-shortcuts-dialog.component.ts index d4c0ca1b5..685d2777b 100644 --- a/src/app/shared/keyboard-shortcuts-dialog/keyboard-shortcuts-dialog.component.ts +++ b/src/app/shared/keyboard-shortcuts-dialog/keyboard-shortcuts-dialog.component.ts @@ -42,10 +42,8 @@ import { MatOption } from '@angular/material/core'; MatDialogContent, MatDialogActions, MatDialogClose, - MatDivider, MatButton, FormsModule, - NgClass, MatIcon, MatFormField, MatLabel, diff --git a/src/app/shared/shortcut-usage-dialog/inline-create-shortcut-dialog.component.ts b/src/app/shared/shortcut-usage-dialog/inline-create-shortcut-dialog.component.ts index 741a0c065..aa08800eb 100644 --- a/src/app/shared/shortcut-usage-dialog/inline-create-shortcut-dialog.component.ts +++ b/src/app/shared/shortcut-usage-dialog/inline-create-shortcut-dialog.component.ts @@ -35,8 +35,7 @@ import { take } from 'rxjs/operators'; FormsModule, MatFormField, MatLabel, - MatInput, - MatError + MatInput ] }) export class InlineCreateShortcutDialogComponent { diff --git a/src/app/sponsor-dd-reconciliation/reco-batch-list/reco-batch-list.component.ts b/src/app/sponsor-dd-reconciliation/reco-batch-list/reco-batch-list.component.ts index 9bebd7216..a41050263 100644 --- a/src/app/sponsor-dd-reconciliation/reco-batch-list/reco-batch-list.component.ts +++ b/src/app/sponsor-dd-reconciliation/reco-batch-list/reco-batch-list.component.ts @@ -25,8 +25,7 @@ import { SponsorDDReconciliationService } from '../sponsor-dd-reconciliation.ser MatCardModule, MatTableModule, MatButtonModule, - MatSnackBarModule, - FaIconComponent + MatSnackBarModule ], templateUrl: './reco-batch-list.component.html', styles: [ diff --git a/src/app/sponsor-dd/dd-inventory-list/dd-inventory-list.component.ts b/src/app/sponsor-dd/dd-inventory-list/dd-inventory-list.component.ts index edd2f06f9..9a728cd77 100644 --- a/src/app/sponsor-dd/dd-inventory-list/dd-inventory-list.component.ts +++ b/src/app/sponsor-dd/dd-inventory-list/dd-inventory-list.component.ts @@ -34,8 +34,7 @@ import { SponsorDDService } from '../sponsor-dd.service'; MatTableModule, MatButtonModule, MatDialogModule, - MatSnackBarModule, - FaIconComponent + MatSnackBarModule ], templateUrl: './dd-inventory-list.component.html', styles: [ diff --git a/src/app/sponsor-dd/dd-list/dd-list.component.ts b/src/app/sponsor-dd/dd-list/dd-list.component.ts index 01bf708a3..f7a020b33 100644 --- a/src/app/sponsor-dd/dd-list/dd-list.component.ts +++ b/src/app/sponsor-dd/dd-list/dd-list.component.ts @@ -39,8 +39,7 @@ import { SponsorDDService } from '../sponsor-dd.service'; MatDialogModule, MatSnackBarModule, MatFormFieldModule, - MatInputModule, - FaIconComponent + MatInputModule ], templateUrl: './dd-list.component.html', styles: [ diff --git a/src/app/tasks/checker-inbox-and-tasks-tabs/checker-inbox/checker-inbox.component.html b/src/app/tasks/checker-inbox-and-tasks-tabs/checker-inbox/checker-inbox.component.html index 2a1f02ef2..0f7c556a9 100644 --- a/src/app/tasks/checker-inbox-and-tasks-tabs/checker-inbox/checker-inbox.component.html +++ b/src/app/tasks/checker-inbox-and-tasks-tabs/checker-inbox/checker-inbox.component.html @@ -7,7 +7,7 @@ -->
- @if (checkerData) { @@ -37,9 +37,6 @@ - diff --git a/src/app/tasks/checker-inbox-and-tasks-tabs/checker-inbox/checker-inbox.component.scss b/src/app/tasks/checker-inbox-and-tasks-tabs/checker-inbox/checker-inbox.component.scss index 4dea1d459..252e4ffc0 100644 --- a/src/app/tasks/checker-inbox-and-tasks-tabs/checker-inbox/checker-inbox.component.scss +++ b/src/app/tasks/checker-inbox-and-tasks-tabs/checker-inbox/checker-inbox.component.scss @@ -112,15 +112,6 @@ $text-muted: #64748b; } } -.cv-btn-delete { - border-color: #dc2626; - color: #dc2626; - - &:hover { - background: #fef2f2; - } -} - .cv-btn-reject { border-color: #64748b; color: #64748b; 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 6800f4e61..f16c22be9 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 @@ -33,12 +33,14 @@ import { SettingsService } from 'app/settings/settings.service'; /** Dialog Components */ import { ConfirmationDialogComponent } from 'app/shared/confirmation-dialog/confirmation-dialog.component'; +import { ViewCheckerInboxComponent } from '../../view-checker-inbox/view-checker-inbox.component'; import { Dates } from 'app/core/utils/dates'; import { TranslateService } from '@ngx-translate/core'; import { FaIconComponent } from '@fortawesome/angular-fontawesome'; import { MatCheckbox } from '@angular/material/checkbox'; import { DateFormatPipe } from '../../../pipes/date-format.pipe'; import { STANDALONE_SHARED_IMPORTS } from 'app/standalone-shared.module'; +import { isProductEntity, isTempTableAccountEdit } from '../../product-entities'; @Component({ selector: 'mifosx-checker-inbox', @@ -104,27 +106,19 @@ export class CheckerInboxComponent implements OnInit { ]; /** - * Entities whose pending edits are gated by our own custom staging tables (m_savings_product_temp - * for products, the savings account edit-request table for Nominee/Digital-Feature edits) rather - * than the native command-source pipeline. They're excluded from Checker Inbox entirely - - * approving/rejecting them here would call the native /makercheckers/{id}?command=approve endpoint, - * which replays the original command handler directly and never touches our staging table, leaving - * its lock/hold stuck forever. Action them from Approvals -> Pending Product Edits / Pending Account - * Edits instead, where that's handled correctly. + * The Checker Inbox holds non-product pending requests only. Two groups are routed elsewhere: + * + * - Product requests of every action (CREATE, UPDATE, DELETE) belong to Approvals -> Product + * Approval, which merges them with the product edits staged in m_saving_product_temp so a + * checker sees one queue per product rather than two. + * - Savings/current account edits are staged the same way as product edits and belong to + * Approvals -> Pending Account Edits. Approving them here would call + * /makercheckers/{id}?command=approve, which replays the original command handler and never + * touches the staging table, leaving its lock held forever. */ - private readonly productEditEntities = [ - 'SAVINGSPRODUCT', - 'CURRENTPRODUCT', - 'SHAREPRODUCT', - 'FIXEDDEPOSITPRODUCT', - 'RECURRINGDEPOSITPRODUCT', - 'SAVINGSACCOUNT', - 'CURRENTACCOUNT' - ]; - - private excludeProductEdits(items: any[]): any[] { + private excludeRoutedElsewhere(items: any[]): any[] { return (items || []).filter( - (item: any) => !(this.productEditEntities.includes(item?.entityName) && item?.actionName === 'UPDATE') + (item: any) => !isProductEntity(item?.entityName) && !isTempTableAccountEdit(item?.entityName, item?.actionName) ); } @@ -141,7 +135,7 @@ export class CheckerInboxComponent implements OnInit { constructor() { this.route.data.subscribe((data: { makerCheckerResource: any; makerCheckerTemplate: any }) => { const raw = data.makerCheckerResource; - this.searchData = this.excludeProductEdits(Array.isArray(raw) ? raw : (raw?.pageItems ?? [])); + this.searchData = this.excludeRoutedElsewhere(Array.isArray(raw) ? raw : (raw?.pageItems ?? [])); this.checkerData = this.searchData.length > 0; this.makerCheckerTemplate = data.makerCheckerTemplate ?? {}; this.dataSource = new MatTableDataSource(this.searchData); @@ -181,7 +175,7 @@ export class CheckerInboxComponent implements OnInit { makerDateTimeto: this.dateUtils.formatDate(this.makerCheckerSearchForm.value.makerDateTimeto, dateFormat) }; this.tasksService.getMakerCheckerData(makerCheckerSearchParams).subscribe((response: any) => { - this.searchData = this.excludeProductEdits(response); + this.searchData = this.excludeRoutedElsewhere(response); if (this.searchData.length === 0) { this.noSearchedData = true; } else { @@ -242,20 +236,6 @@ export class CheckerInboxComponent implements OnInit { }); } - deleteChecker() { - const deleteCheckerDialogRef = this.dialog.open(ConfirmationDialogComponent, { - data: { - heading: this.translateService.instant('labels.heading.Delete Checker'), - dialogContext: this.translateService.instant('labels.dialogContext.Are you sure you want to delete checker') - } - }); - deleteCheckerDialogRef.afterClosed().subscribe((response: { confirm: any }) => { - if (response.confirm) { - this.bulkDeleteChecker(); - } - }); - } - bulkCheckerApproveorReject(action: any) { const selectedAccounts = this.selection.selected.length; const listSelectedAccounts = this.selection.selected; @@ -270,22 +250,94 @@ export class CheckerInboxComponent implements OnInit { }); } - bulkDeleteChecker() { - const selectedAccounts = this.selection.selected.length; - const listSelectedAccounts = this.selection.selected; - let approvedAccounts = 0; - listSelectedAccounts.forEach((element: any) => { - this.tasksService.deleteMakerChecker(element.id).subscribe((response: any) => { - approvedAccounts++; - if (selectedAccounts === approvedAccounts) { + applyFilter(filterValue: string = '') { + this.dataSource.filter = filterValue.trim().toLowerCase(); + } + + /** + * Maps a maker-checker entityName to the Angular route path for that entity's existing view page. + * Returns null for entity types that have no dedicated view page in this application. + */ + private entityRoute(entityName: string, resourceId: string | number): string | null { + const id = resourceId; + switch ((entityName ?? '').toUpperCase()) { + case 'LOANPRODUCT': + return `/products/loan-products/${id}`; + case 'SAVINGSPRODUCT': + return `/products/saving-products/${id}`; + case 'CURRENTPRODUCT': + return `/products/current-products/${id}`; + case 'SHAREPRODUCT': + return `/products/share-products/${id}`; + case 'FIXEDDEPOSITPRODUCT': + return `/products/fixed-deposit-products/${id}`; + case 'RECURRINGDEPOSITPRODUCT': + return `/products/recurring-deposit-products/${id}`; + case 'CHARGE': + return `/products/charges/${id}`; + case 'GLACCOUNT': + return `/accounting/chart-of-accounts/gl-accounts/view/${id}`; + case 'CLIENT': + return `/clients/${id}/general`; + case 'LOAN': + return `/loans/${id}`; + case 'SAVINGSACCOUNT': + return `/savings-accounts/${id}`; + case 'CURRENTACCOUNT': + return `/current-accounts/${id}`; + default: + return null; + } + } + + /** + * Handles a row click in the Checker Inbox. + * + * Priority: + * 1. If the row has a known entityName and a resourceId → navigate to the entity's existing + * view page (read-only; no maker-checker action buttons are present on those pages). + * 2. If no resourceId is available (e.g. a DELETE action where the entity no longer exists) + * → show a brief informational snackbar. + * 3. If the entityName is unrecognised → fall back to the existing ViewCheckerInboxComponent + * dialog so the checker can still view the command details and action the entry. + */ + viewEntry(row: any): void { + const entityName: string = row.entityName ?? ''; + const resourceId = row.resourceId; + const route = this.entityRoute(entityName, resourceId); + + // DIAGNOSTIC — open browser DevTools console to confirm the row payload. + // Remove after confirming root cause. + console.log('[CheckerInbox] viewEntry row =', { + id: row.id, + entityName, + actionName: row.actionName, + resourceId, + resolvedRoute: route + }); + + if (route && resourceId != null) { + // Navigate to the existing entity view page (no maker-checker buttons there). + this.router.navigate([route]); + return; + } + + // 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. + this.dialog + .open(ViewCheckerInboxComponent, { + data: { checkerId: row.id }, + autoFocus: false + }) + .afterClosed() + .subscribe((result: any) => { + // Only reload when something was actually actioned — a plain close must not throw away the + // checker's current search, which reload() does by re-navigating the route. + if (result?.refreshed) { this.reload(); } }); - }); - } - - applyFilter(filterValue: string = '') { - this.dataSource.filter = filterValue.trim().toLowerCase(); } /** diff --git a/src/app/tasks/checker-inbox-and-tasks-tabs/pending-client-edits/pending-client-edits.component.ts b/src/app/tasks/checker-inbox-and-tasks-tabs/pending-client-edits/pending-client-edits.component.ts index 40ba63a9d..03f420097 100644 --- a/src/app/tasks/checker-inbox-and-tasks-tabs/pending-client-edits/pending-client-edits.component.ts +++ b/src/app/tasks/checker-inbox-and-tasks-tabs/pending-client-edits/pending-client-edits.component.ts @@ -98,8 +98,7 @@ const NATIONALITY_LABELS: Record = { MatRowDef, MatRow, MatIcon, - MatPaginatorModule, - ClientEditEmailDialogComponent + MatPaginatorModule ] }) export class PendingClientEditsComponent implements OnInit { diff --git a/src/app/tasks/checker-inbox-and-tasks-tabs/pending-savings-products/pending-savings-products.component.html b/src/app/tasks/checker-inbox-and-tasks-tabs/pending-savings-products/pending-savings-products.component.html index 5016189b8..cd3c453f3 100644 --- a/src/app/tasks/checker-inbox-and-tasks-tabs/pending-savings-products/pending-savings-products.component.html +++ b/src/app/tasks/checker-inbox-and-tasks-tabs/pending-savings-products/pending-savings-products.component.html @@ -11,7 +11,12 @@
search - +
@@ -20,7 +25,16 @@
+ + + + + @@ -49,8 +63,18 @@ - - + + - - + + @@ -326,8 +326,25 @@
{{ r.txnTime }}
} -
- + + + - + @@ -523,7 +544,7 @@ - + - + diff --git a/src/app/risk-review/risk-review-report.component.scss b/src/app/risk-review/risk-review-report.component.scss index c4f9b6066..db236d881 100644 --- a/src/app/risk-review/risk-review-report.component.scss +++ b/src/app/risk-review/risk-review-report.component.scss @@ -937,6 +937,13 @@ background: var(--r-g100); color: var(--r-g500); } + + /* An unrated customer reads neither as settled nor as a movement: there is no rating to move. Amber says the row + is outstanding without claiming the customer is a concern — the gap is the bank's, not theirs. */ + &--unrated { + background: var(--r-amber-l, #fef3c7); + color: var(--r-amber, #b45309); + } } .rr-move-why { diff --git a/src/app/risk-review/risk-review-report.component.ts b/src/app/risk-review/risk-review-report.component.ts index 8f0775fc3..3b4fd2fb4 100644 --- a/src/app/risk-review/risk-review-report.component.ts +++ b/src/app/risk-review/risk-review-report.component.ts @@ -10,6 +10,7 @@ import { Component, OnInit, inject } from '@angular/core'; import { FormsModule } from '@angular/forms'; import { Router } from '@angular/router'; +import { MatDialog } from '@angular/material/dialog'; import { forkJoin, of } from 'rxjs'; import { catchError, map } from 'rxjs/operators'; @@ -38,12 +39,14 @@ import { RiskLevel, channelLabel, mapLiveAlert, - riskFromCategory + riskFromCategory, + riskRank } from './risk-review-data'; import { DateRange, RatingDirection, RiskAlertView, + BehaviourWindowByClient, RiskCustomerRow, RiskReviewFilter, RiskReviewModel, @@ -53,6 +56,7 @@ import { monthRange, weekRange } from './risk-review.compute'; +import { RiskChangeDialogComponent, RiskChangeDialogData } from './risk-change-dialog.component'; type PeriodMode = 'day' | 'week' | 'month' | 'range'; type ViewMode = 'customer' | 'alert' | 'txn'; @@ -109,6 +113,7 @@ export class RiskReviewReportComponent implements OnInit { private clients = inject(ClientsService); private scope = inject(BranchScopeService); private router = inject(Router); + private dialog = inject(MatDialog); readonly period = GL_PERIOD; readonly branches = GL_BRANCHES; @@ -181,21 +186,37 @@ export class RiskReviewReportComponent implements OnInit { // ── Loading ──────────────────────────────────────────────────────────────── + /** + * The nightly job's verdict per customer, keyed on client id. + * + * Empty until the first load, and stays empty on a tenant where the job has not run - the report then falls back to + * its own per-alert reckoning rather than showing nothing. + */ + private behaviourWindows: BehaviourWindowByClient = {}; + load(): void { this.loading = true; - this.aml - .getAlerts() - .pipe(catchError(() => of([] as LiveAmlAlert[]))) - .subscribe((live) => { - const rows = (live || []).map((a) => mapLiveAlert(a as unknown as LiveAmlAlert)); - this.liveAlerts = rows; - this.compose(); - if (!rows.length) { - this.loading = false; - return; + // Both in one pass: the windows decide what each row recommends, so composing before they arrive would show a + // recommendation the screen then silently replaced. + forkJoin({ + alerts: this.aml.getAlerts().pipe(catchError(() => of([] as LiveAmlAlert[]))), + windows: this.aml.getBehaviourWindows().pipe(catchError(() => of([] as any[]))) + }).subscribe(({ alerts: live, windows }) => { + this.behaviourWindows = {}; + for (const w of windows || []) { + if (w?.clientId != null) { + this.behaviourWindows[w.clientId] = w; } - this.enrichCurrentRisk(rows); - }); + } + const rows = (live || []).map((a) => mapLiveAlert(a as unknown as LiveAmlAlert)); + this.liveAlerts = rows; + this.compose(); + if (!rows.length) { + this.loading = false; + return; + } + this.enrichCurrentRisk(rows); + }); } /** @@ -336,9 +357,11 @@ export class RiskReviewReportComponent implements OnInit { private memo: RiskReviewModel = { alerts: [], customers: [], txns: [], summary: EMPTY_SUMMARY }; private get model(): RiskReviewModel { - const key = JSON.stringify(this.filter) + `|${this.alerts.length}`; + // The window count belongs in the key: the windows decide what each row recommends, and they arrive in the same + // load as the alerts. Keying on the alerts alone would serve the memo computed before they landed. + const key = JSON.stringify(this.filter) + `|${this.alerts.length}|${Object.keys(this.behaviourWindows).length}`; if (key !== this.memoKey) { - this.memo = buildRiskReview(this.alerts, this.filter); + this.memo = buildRiskReview(this.alerts, this.filter, this.behaviourWindows); this.memoKey = key; } return this.memo; @@ -364,10 +387,63 @@ export class RiskReviewReportComponent implements OnInit { * a regulatory clock, an under-rated customer is the real compliance gap, an over-rated one is * only a correction, and matching ratings need nothing at all. */ + /** + * A report row for a customer the behaviour review named but no alert brought onto the report. + * + *

+ * The alert-derived fields are empty because there are none in the window, and that is the point being shown: the + * case rests on the period, not on anything that fired. The figures the verdict was drawn from travel on + * behaviourWindow, so the row can still explain itself. + */ + private rowFromWindow(w: any): RiskCustomerRow { + const RANK: Record = { 1: 'LOW', 2: 'MEDIUM', 3: 'HIGH' }; + const current = RANK[w.riskCategoryAtReview] ?? null; + const recommended = RANK[w.suggestedCategory] ?? current ?? 'LOW'; + return { + clientId: w.clientId, + customerName: w.clientName || `Client ${w.clientId}`, + accountNo: w.accountNo || '', + branchCode: w.officeName || '', + currentRisk: current, + alerts: [], + alertCount: 0, + totalAmount: Number(w.creditTotal || 0) + Number(w.debitTotal || 0), + largestAmount: Number(w.largestTxn || 0), + proposedRisk: recommended, + recommendedRisk: recommended, + direction: w.suggestedDirection === 'UP' ? 'up' : w.suggestedDirection === 'DOWN' ? 'down' : 'same', + directionReason: w.suggestionReason || '', + ruleLabels: [], + firstAlertDate: '', + lastAlertDate: '', + openCount: 0, + escalated: false, + needsUpgrade: w.suggestedDirection === 'UP', + needsDowngrade: w.suggestedDirection === 'DOWN', + behaviourWindow: w + }; + } + get customerGroups(): RiskCustomerGroup[] { const rows = this.customerRows; - const escalated = rows.filter((c) => c.escalated); - const rest = rows.filter((c) => !c.escalated); + // An escalated customer whose rating still has to move belongs with the work, not with the cases that are waiting + // on someone else. An open STR holds a rating at High, and if the customer is not yet at High that is a change + // somebody has to apply - filing the STR does not make it. Only a customer whose rating already matches sits under + // the Principal Officer with nothing to do. + const escalated = rows.filter((c) => c.escalated && c.direction === 'same'); + const rest = rows.filter((c) => !(c.escalated && c.direction === 'same')); + + // Customers the nightly review says to move who raised no alert inside the filter window. + // + // The report is built from alerts, so a customer appears only once a rule has fired on them recently. That is the + // wrong test for a downgrade: the whole case for lowering a rating is that nothing has fired for a year, so the + // customer with the strongest case is precisely the one the alert feed has stopped mentioning. Without this they + // would sit in the behaviour table with a verdict nobody ever sees. + const onReport = new Set(rows.map((r) => r.clientId)); + const offReport: RiskCustomerRow[] = Object.values(this.behaviourWindows) + .filter((w: any) => w?.clientId != null && !onReport.has(w.clientId)) + .filter((w: any) => w.suggestedDirection === 'UP' || w.suggestedDirection === 'DOWN') + .map((w: any) => this.rowFromWindow(w)); const groups: RiskCustomerGroup[] = [ { @@ -378,25 +454,42 @@ export class RiskReviewReportComponent implements OnInit { rows: escalated }, { - key: 'up', - title: 'Rating needs raising', - hint: 'The rules point higher than the rating on file. These are under-rated customers — the real gap.', + // Raising and lowering are one section, not two. The heading answers "is there anything to do about this + // customer", and which way the rating moves is already on every row - as a verdict, an arrow and a reason. + // Splitting them put the same answer in two places and left an officer reading two lists to find one day's + // work. Raises sort first within the section, because an under-rated customer is the more urgent gap. + key: 'change', + title: 'Change needed', + hint: 'Every condition for moving the rating is met. Apply the change; it goes to a checker for approval.', tone: 'up', - rows: rest.filter((c) => c.direction === 'up') + rows: [ + ...rest.filter((c) => c.currentRisk && c.direction === 'up'), + ...offReport.filter((c) => c.direction === 'up'), + ...rest.filter((c) => c.currentRisk && c.direction === 'down'), + ...offReport.filter((c) => c.direction === 'down') + ] }, { - key: 'down', - title: 'Rating can be lowered', - hint: 'Activity no longer supports the rating on file.', - tone: 'down', - rows: rest.filter((c) => c.direction === 'down') + // Its own section, and above "no change needed", because it is the opposite of that. An unrated customer + // whose money is moving has no rating for the behaviour to be compared against, so nothing here can say + // whether it is in keeping - the work is to rate them, and grouping them under "no change needed" buried the + // one action the report was asking for. + key: 'unrated', + title: 'Rating needed', + hint: 'These customers are transacting with no rating on file. Nothing can be judged against a rating that does not exist — rate them first.', + tone: 'up', + rows: rest.filter((c) => !c.currentRisk) }, { + // Everything an alert put on the report that the policy will not move. "No change needed" read as though + // these customers were fine, which is not what it means: a High-rated customer with four open alerts is on + // this list, and the reason is that the conditions for lowering the rating are not met - not that there is + // nothing to look at. The reason on each row says which condition fell short. key: 'same', - title: 'No change needed', - hint: 'The rating on file already matches the behaviour. Listed for the record.', + title: 'Rating holds — no change this period', + hint: 'An alert brought these customers onto the report, but the conditions for moving the rating are not met. The reason on each row says what is missing.', tone: 'hold', - rows: rest.filter((c) => c.direction === 'same') + rows: rest.filter((c) => c.currentRisk && c.direction === 'same') } ]; @@ -575,6 +668,67 @@ export class RiskReviewReportComponent implements OnInit { ]); } + /** + * Sends a case that is with the Principal Officer to the STR queue. + * + * The rating cannot be moved while an STR is open, so the useful thing to offer is the case + * itself. Sending the reviewer to the customer's KYC record instead made them land on a screen + * where the one action they might take is the one thing they must not do yet. + */ + openStrCase(c: RiskCustomerRow, event: Event): void { + event.stopPropagation(); + this.router.navigate(['/fiu'], { queryParams: { tab: 'str' } }); + } + + /** + * Whether this row's rating can be moved from here. + * + * A case with the Principal Officer is deliberately excluded: the rating is held where it is + * until the STR concludes, which is what the section heading on that group says. Offering the + * button there would invite a change nobody is in a position to make yet. + */ + canChangeRisk(c: RiskCustomerRow): boolean { + return !c.escalated; + } + + /** + * Raises a rating change for approval, rather than sending the reviewer to the KYC screen to make + * it themselves. + * + * The button used to navigate away: the reviewer left the report, found the customer, edited the + * record, and the change took effect on their say-so alone with no second pair of eyes and no + * record of why. The rating governs re-KYC frequency and AML thresholds, so it goes through a + * checker like every other decision of that weight. + */ + changeRisk(c: RiskCustomerRow, event: Event): void { + event.stopPropagation(); + if (!this.canChangeRisk(c)) { + return; + } + this.dialog + .open(RiskChangeDialogComponent, { + width: '520px', + maxWidth: '95vw', + disableClose: true, + data: { + clientId: c.clientId, + customerName: c.customerName, + currentCategory: riskRank(c.currentRisk) || null, + recommendedCategory: riskRank(c.recommendedRisk) || null, + recommendationReason: c.directionReason + } as RiskChangeDialogData + }) + .afterClosed() + .subscribe((sent) => { + if (sent) { + // The rating has not moved yet - only a request exists - so the figures on screen are + // still correct. Reloaded anyway so a second click cannot raise a duplicate request the + // server would refuse. + this.load(); + } + }); + } + /** * What the reviewer is going to the KYC screen to do. * @@ -589,25 +743,41 @@ export class RiskReviewReportComponent implements OnInit { * change nobody is authorised to make yet. */ directionText(c: RiskCustomerRow): string { - if (c.escalated) { + // Escalation is only the whole story when the rating already matches. An STR holds a customer at High, so one + // filed against a Medium-rated customer leaves a rating that still has to be moved - saying "held" there reports + // the case and hides the work, which is how this row came to sit under "Change needed" announcing that nothing + // needed doing. + if (c.escalated && c.direction === 'same') { return 'Held — with Principal Officer'; } - if (c.direction === 'up') { - return `Raise to ${RISK_LABEL[c.recommendedRisk]}`; + // Both ends of the move, not just the destination. "Lower to Medium" reads as a verdict on its own and leaves the + // officer joining it to the rating column to see what is actually changing; "High → Medium" is the whole fact in + // one glance, and on an exported sheet it still says what happened without the column beside it. + if (c.direction === 'up' || c.direction === 'down') { + const from = c.currentRisk ? RISK_LABEL[c.currentRisk] : 'Unrated'; + return `${from} → ${RISK_LABEL[c.recommendedRisk]}`; } - if (c.direction === 'down') { - return `Lower to ${RISK_LABEL[c.recommendedRisk]}`; - } - return c.currentRisk ? `Keep at ${RISK_LABEL[c.currentRisk]}` : 'No rating on record'; + // An unrated customer is told what to do, not what was concluded. "No rating on record" states the gap and stops + // there, which on a worklist reads as though the row needs nothing - and it is in fact the row that needs the most. + return c.currentRisk ? `Keep at ${RISK_LABEL[c.currentRisk]}` : 'Rate this customer'; } directionIcon(c: RiskCustomerRow): string { - if (c.escalated) { + // Same test as directionText: a padlock says the case is out of the officer's hands, which is untrue while the + // rating still has to be moved. + if (c.escalated && c.direction === 'same') { return 'lock'; } - // 'check' rather than an equals sign: faEquals is not in the shared library, and an - // unregistered icon throws at render and takes the whole row down with it. - return c.direction === 'up' ? 'arrow-up' : c.direction === 'down' ? 'arrow-down' : 'check'; + if (c.direction === 'up') { + return 'arrow-up'; + } + if (c.direction === 'down') { + return 'arrow-down'; + } + // A tick claims the row is settled. An unrated customer is not settled, so it gets the mark that asks a question + // rather than the one that closes it. 'exclamation-circle' rather than plain 'exclamation': only the registered + // names resolve, and an unregistered icon throws at render and takes the whole row down with it. + return c.currentRisk ? 'check' : 'exclamation-circle'; } /** diff --git a/src/app/risk-review/risk-review.compute.ts b/src/app/risk-review/risk-review.compute.ts index 67499a2bc..558b80ab5 100644 --- a/src/app/risk-review/risk-review.compute.ts +++ b/src/app/risk-review/risk-review.compute.ts @@ -10,7 +10,7 @@ import { AmlRuleMeta, CTR_THRESHOLD, MATERIALITY, - NON_MITIGABLE_RULES, + isNonMitigableRule, REVIEW_STATUS_META, RISK_LABEL, RiskAlertRow, @@ -109,6 +109,8 @@ export interface RiskCustomerRow { needsUpgrade: boolean; /** True when the rating on record is higher than the activity warrants. */ needsDowngrade: boolean; + /** The review period the recommendation was drawn from, where the nightly job has computed one. */ + behaviourWindow?: BehaviourWindowByClient[number] | null; } export interface RiskReviewSummary { @@ -158,7 +160,7 @@ function recommendRating( ruleCodes: string[] ): { risk: RiskLevel; reason: string } { const implied = RANK_TO_LEVEL[impliedRank] ?? 'LOW'; - const grave = ruleCodes.find((c) => NON_MITIGABLE_RULES.has(c)); + const grave = ruleCodes.find((c) => isNonMitigableRule(c)); const material = largestAmount >= CTR_THRESHOLD || totalAmount >= MATERIALITY.highTotal; const small = largestAmount < MATERIALITY.smallTxn && totalAmount < MATERIALITY.smallTotal; @@ -271,7 +273,36 @@ export function riskAlertViews(alerts: RiskAlertRow[], filter: RiskReviewFilter) * escalated, then rated below what the rules point at, then most alerts — * come to the top of the worklist. */ -export function riskCustomerRows(views: RiskAlertView[]): RiskCustomerRow[] { +/** + * The behaviour window the nightly job computed for one customer, where there is one. + * + * Keyed on client id. A customer with no window - one the job has not seen yet, or a tenant where the job has never + * run - falls back to the per-alert reckoning below, so the report renders either way. + */ +export interface BehaviourWindowByClient { + [clientId: number]: { + clientId: number; + clientName: string | null; + accountNo: string | null; + officeName: string | null; + riskCategoryAtReview: number | null; + txnCount: number | null; + distinctMonths: number | null; + windowFrom: any; + windowTo: any; + creditTotal: number | null; + debitTotal: number | null; + largestTxn: number | null; + cashTotal: number | null; + alertCount: number | null; + strCaseCount: number | null; + suggestedDirection: string | null; + suggestedCategory: number | null; + suggestionReason: string | null; + }; +} + +export function riskCustomerRows(views: RiskAlertView[], windows: BehaviourWindowByClient = {}): RiskCustomerRow[] { const byClient = new Map(); for (const v of views) { const list = byClient.get(v.clientId); @@ -297,19 +328,53 @@ export function riskCustomerRows(views: RiskAlertView[]): RiskCustomerRow[] { const largestAmount = Math.max(...alerts.map((a) => a.amount)); const escalated = alerts.some((a) => a.status === 'ESCALATED_STR' || a.status === 'STR_FILED'); - const recommendation = recommendRating( - proposedRank, - currentRisk, - largestAmount, - totalAmount, - escalated, - alerts.map((a) => a.ruleCode) - ); + // The server's own reckoning, where the nightly job has produced one. + // + // It is the better answer, and not because it is newer: it is drawn from a review period rather than from the + // alerts on this screen. A customer appears here only once a rule has fired, so judging them on those alerts + // alone describes the exceptions and never the ordinary months around them - which is exactly what a rating is + // supposed to reflect. The job also tests the things this screen cannot see at all: how many separate months the + // activity falls in, how long the customer has held the rating, whether an STR inside the block window forbids + // lowering it. + // + // Without a window - a tenant where the job has not run, or a customer it has not reached - the per-alert + // reckoning below still stands, so the report never renders blank while the two are in step. + // A window is only good for as long as the rating it was computed against. The job takes a snapshot each night + // and records the rating the customer held at the time; once an officer moves that rating the snapshot describes + // a customer who no longer exists, and the screen would go on asking for a change that has already been applied + // and approved. Dropping it here falls back to the per-alert reckoning until tonight's job catches up. + const raw = windows[clientId]; + const staleWindow = + raw?.riskCategoryAtReview != null && currentRisk != null && RANK_TO_LEVEL[raw.riskCategoryAtReview] !== currentRisk; + const window = staleWindow ? undefined : raw; + const recommendation = window?.suggestedDirection + ? { + risk: RANK_TO_LEVEL[window.suggestedCategory ?? 0] ?? currentRisk ?? 'LOW', + reason: window.suggestionReason ?? '' + } + : recommendRating( + proposedRank, + currentRisk, + largestAmount, + totalAmount, + escalated, + alerts.map((a) => a.ruleCode) + ); // Where the customer carries no rating there is nothing to move, so an // unrated customer is never counted as an upgrade or a downgrade. const delta = currentRisk ? riskRank(recommendation.risk) - riskRank(currentRisk) : 0; - const direction: RatingDirection = delta > 0 ? 'up' : delta < 0 ? 'down' : 'same'; + const direction: RatingDirection = window?.suggestedDirection + ? window.suggestedDirection === 'UP' + ? 'up' + : window.suggestedDirection === 'DOWN' + ? 'down' + : 'same' + : delta > 0 + ? 'up' + : delta < 0 + ? 'down' + : 'same'; rows.push({ clientId, @@ -332,7 +397,10 @@ export function riskCustomerRows(views: RiskAlertView[]): RiskCustomerRow[] { openCount: alerts.filter((a) => REVIEW_STATUS_META[a.status].open).length, escalated, needsUpgrade: direction === 'up', - needsDowngrade: direction === 'down' + needsDowngrade: direction === 'down', + // Carried through so the screen can show the period a recommendation was drawn from. A rating an officer is + // asked to approve has to show its working, and the alerts on this row are not it. + behaviourWindow: window ?? null }); } @@ -461,9 +529,13 @@ export interface RiskReviewModel { * the summary all run once per filter change rather than once per template * binding, which matters because every view reads from the same result. */ -export function buildRiskReview(alerts: RiskAlertRow[], filter: RiskReviewFilter): RiskReviewModel { +export function buildRiskReview( + alerts: RiskAlertRow[], + filter: RiskReviewFilter, + windows: BehaviourWindowByClient = {} +): RiskReviewModel { const base = riskAlertViews(alerts, filter); - const narrowed = applyDirectionFilter(base, riskCustomerRows(base), filter.direction); + const narrowed = applyDirectionFilter(base, riskCustomerRows(base, windows), filter.direction); return { alerts: narrowed.views, 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 9a5bb2c85..03f8fa26a 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 @@ -232,6 +232,12 @@ DD Revoke Requests + + + + Risk Category Changes +

diff --git a/src/app/tasks/tasks-routing.module.ts b/src/app/tasks/tasks-routing.module.ts index 03713aea3..e247d0f7a 100644 --- a/src/app/tasks/tasks-routing.module.ts +++ b/src/app/tasks/tasks-routing.module.ts @@ -243,6 +243,12 @@ const routes: Routes = [ (m) => m.DdRevokeApprovalsComponent ), data: { title: 'DD Revoke Approvals' } + }, + { + path: 'risk-change-approvals', + loadComponent: () => + import('../risk-review/risk-change-approvals.component').then((m) => m.RiskChangeApprovalsComponent), + data: { title: 'Risk Category Approvals' } } ] }, diff --git a/src/environments/.env.ts b/src/environments/.env.ts index c3226b95c..4509e26ef 100644 --- a/src/environments/.env.ts +++ b/src/environments/.env.ts @@ -9,8 +9,8 @@ /* tslint:disable */ export default { 'mifos_x': { - 'version': '260914', - 'hash': 'ee5a430b' + 'version': '260916', + 'hash': 'c01615ee' }, 'allow_switching_backend_instance': true }; From d18bd9cf37ffa02e95ea21ba848f636be42fdc0b Mon Sep 17 00:00:00 2001 From: Nitesh Yadav Date: Tue, 15 Sep 2026 11:13:20 +0530 Subject: [PATCH 56/64] fix(print): fix app-wide printing and remove a global print-CSS leak MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Root cause of blank/clipped print output across the app: the document root (html/body) is globally locked to one viewport height with overflow hidden, and Angular Material's drawer/tab-body containers each add their own on-screen scroll clip on top of that. main.scss now frees the full chain for print (document root, CDK drawer/sidenav containers, mat-tab-body, and the account-view's mat-tab-nav-bar which was rendering as a stray grey scrollable strip since it isn't covered by the mat-tab-group header rule already hidden). Also fixed both certificate print dialogs (share-certificate-dialog, deposit-certificate-dialog) reusing a `::ng-deep body > :not(.cdk-overlay- container)` rule to hide the shell during their own print: under Angular's style encapsulation this style is emitted fully unscoped into the global stylesheet and is never removed once the component has loaded once in a session, silently blanking every other page's print output for the rest of the session. Dropped `::ng-deep` from that one selector so it goes back to being properly scoped (and therefore inert, matching the other two dialogs that never had this bug) — the app shell is already hidden for every print via the global main.scss rules, so no dialog needs its own global override. --- .../certificate-preview-dialog.component.scss | 16 +- .../share-certificate-dialog.component.scss | 181 +++++++++++++++++- src/main.scss | 113 +++++++++-- 3 files changed, 283 insertions(+), 27 deletions(-) diff --git a/src/app/letter-mgmt/certificates/certificate-preview-dialog/certificate-preview-dialog.component.scss b/src/app/letter-mgmt/certificates/certificate-preview-dialog/certificate-preview-dialog.component.scss index 13b5f7636..dc84e930b 100644 --- a/src/app/letter-mgmt/certificates/certificate-preview-dialog/certificate-preview-dialog.component.scss +++ b/src/app/letter-mgmt/certificates/certificate-preview-dialog/certificate-preview-dialog.component.scss @@ -576,13 +576,11 @@ } /* ───────── Print — only the page, no toolbar / dialog chrome ───────── - This dialog is opened with `maxHeight: '92vh'`, which Angular Material carries onto the - overlay pane / .mat-mdc-dialog-container as an inline max-height + overflow:auto clip. - `position: absolute` still lives inside that clip and gets cut off — only the on-screen - scroll position prints. `position: fixed` escapes it: a fixed element's containing block is - the viewport itself, not the clipped ancestor (the classic "fixed child ignores an - overflow:hidden parent" behaviour), as long as nothing between here and the viewport sets a - transform/filter — the CDK overlay chrome doesn't. */ + The document-level clip (`html, body { overflow: hidden }`) is lifted globally in main.scss's + print block; what remains here is this dialog's own clip — it opens with `maxHeight: '92vh'`, + which Angular Material carries onto the overlay pane / .mat-mdc-dialog-container as an inline + max-height plus overflow:auto. Freeing those lets the whole certificate flow onto the page in + normal static order (a fixed/absolute hack would pin it to the first page instead). */ @media print { body > :not(.cdk-overlay-container) { display: none !important; @@ -611,9 +609,7 @@ } .cpd-root { - position: fixed; - left: 0; - top: 0; + position: static; width: 100% !important; height: auto !important; background: #fff; diff --git a/src/app/shares/shares-account-view/share-certificate-dialog/share-certificate-dialog.component.scss b/src/app/shares/shares-account-view/share-certificate-dialog/share-certificate-dialog.component.scss index 840774130..274b178df 100644 --- a/src/app/shares/shares-account-view/share-certificate-dialog/share-certificate-dialog.component.scss +++ b/src/app/shares/shares-account-view/share-certificate-dialog/share-certificate-dialog.component.scss @@ -384,18 +384,59 @@ /* Print CSS */ @media print { - /* Hide non-overlay contents during print */ + ::ng-deep html, + ::ng-deep body { + height: auto !important; + overflow: visible !important; + } + + /* Hide non-overlay contents during print. Deliberately NOT `::ng-deep` here: under Angular's + emulated encapsulation a bare `body` selector never carries this component's scoping + attribute, so a scoped rule can never match the real and is safely inert — whereas + `::ng-deep` emits it fully unscoped into the GLOBAL stylesheet for the rest of the session + (Angular never removes a component's injected styles when it's destroyed), silently hiding + every other page's print output app-wide the moment this dialog is opened once. The app + shell (nav, toolbar, sidebar) is already hidden for every print via the global rules in + main.scss, so this dialog doesn't need its own global override to get the same effect. */ body > :not(.cdk-overlay-container) { display: none !important; } + /* The dialog's own scroll region clips anything past 80vh -- printing only captures what a + scrollable/max-height box shows on screen, so both constraints must be lifted. */ + .dialog-content-container { + max-height: none !important; + overflow: visible !important; + } + + /* CDK's overlay wrapper/backdrop/pane are all position: fixed. Unset them so the full certificate prints statically. */ + ::ng-deep .cdk-overlay-backdrop { + display: none !important; + } + + ::ng-deep .cdk-overlay-container, + ::ng-deep .cdk-global-overlay-wrapper, + ::ng-deep .cdk-overlay-pane, + ::ng-deep .mat-mdc-dialog-container, + ::ng-deep .mdc-dialog__container, + ::ng-deep .mdc-dialog__surface { + position: static !important; + display: block !important; + max-height: none !important; + max-width: none !important; + height: auto !important; + width: auto !important; + overflow: visible !important; + transform: none !important; + box-shadow: none !important; + background: transparent !important; + } + /* Make certificate fill page */ #share-certificate-print { - position: absolute; - left: 0; - top: 0; + position: static !important; width: 100% !important; - height: 100% !important; + height: auto !important; padding: 0 !important; margin: 0 !important; background-color: #fff !important; @@ -411,7 +452,135 @@ display: none !important; } - .mat-mdc-dialog-container { + ::ng-deep .mat-mdc-dialog-container { padding: 0 !important; } + + /* Compact every spacing/font value so the whole certificate fits a single printed page */ + .certificate-container { + padding: 6px !important; + } + + .certificate-border-inner { + padding: 14px !important; + } + + .certificate-header { + margin-bottom: 10px !important; + padding-bottom: 10px !important; + + .bank-name { + font-size: 19px !important; + margin-bottom: 2px !important; + } + + .bank-sub { + font-size: 10px !important; + margin-bottom: 8px !important; + } + + .cert-title { + font-size: 19px !important; + letter-spacing: 2px !important; + margin-bottom: 2px !important; + } + + .cert-subtitle { + font-size: 9px !important; + } + } + + .cert-meta-row { + margin-bottom: 10px !important; + padding-bottom: 8px !important; + + .meta-label { + font-size: 9px !important; + margin-bottom: 1px !important; + } + + .meta-value { + font-size: 12px !important; + } + } + + .cert-body-section { + margin-bottom: 10px !important; + + .cert-main-text { + font-size: 11px !important; + line-height: 1.4 !important; + margin-bottom: 10px !important; + } + } + + .issued-to-details { + padding: 8px 14px !important; + margin: 8px auto !important; + + .client-name-details { + font-size: 14px !important; + } + + .client-ids, + .client-address { + font-size: 10px !important; + } + } + + .certificate-details-table { + margin-bottom: 12px !important; + + td { + padding: 5px 10px !important; + font-size: 10px !important; + } + + .lbl-col-highlight { + font-size: 12px !important; + } + + .val-col-highlight { + font-size: 13px !important; + } + } + + .allotment-table { + margin-bottom: 12px !important; + + th { + padding: 4px 8px !important; + font-size: 8px !important; + } + + td { + padding: 4px 8px !important; + font-size: 9px !important; + } + } + + .cert-footer { + margin-top: 8px !important; + + .seal-ring { + width: 60px !important; + height: 60px !important; + } + + .seal-text { + font-size: 8px !important; + } + + .closing-text { + font-size: 10px !important; + } + + .signature-line { + height: 22px !important; + } + + .signatory-title { + font-size: 10px !important; + } + } } diff --git a/src/main.scss b/src/main.scss index 387d35837..c6f7900ea 100644 --- a/src/main.scss +++ b/src/main.scss @@ -982,6 +982,16 @@ mat-card { .mat-mdc-tab-header, .mat-stepper-vertical-line, .mat-step-header, + /* Account-view pages (Shares, Savings, Current, Deposits…) use `
@for (r of g.rows; track r.voucherNo) { - - + is when the heading has scrolled away. Clicking anywhere on the row opens the full + slip -- every line, not just the first debit and first credit this table has room + to show. --> + + @@ -171,7 +183,7 @@ {{ r.creditAccount }} } @if (r.extraLines > 0) { - +{{ r.extraLines }} more + +{{ r.extraLines }} more } diff --git a/src/app/cashier/my-work/my-work.component.scss b/src/app/cashier/my-work/my-work.component.scss index 674708b5e..4e8d33e25 100644 --- a/src/app/cashier/my-work/my-work.component.scss +++ b/src/app/cashier/my-work/my-work.component.scss @@ -178,6 +178,21 @@ } } +.mw-row--clickable { + cursor: pointer; + + &:hover { + background: #f8fafc; + } +} + +.mw-vno__spinner { + margin-left: 4px; + font-size: 14px; + vertical-align: middle; + color: #94a3b8; +} + /* One section per part played. The heading carries the section's own count and total, so a maker's tally and a checker's are each readable without adding rows up. */ .mw-group { diff --git a/src/app/cashier/my-work/my-work.component.ts b/src/app/cashier/my-work/my-work.component.ts index 9173fc55f..4dbee818f 100644 --- a/src/app/cashier/my-work/my-work.component.ts +++ b/src/app/cashier/my-work/my-work.component.ts @@ -10,8 +10,10 @@ import { Component, OnInit, inject } from '@angular/core'; import { CommonModule } from '@angular/common'; import { FormsModule } from '@angular/forms'; import { MatIconModule } from '@angular/material/icon'; -import { catchError, of } from 'rxjs'; +import { MatDialog, MatDialogModule } from '@angular/material/dialog'; +import { catchError, finalize, of } from 'rxjs'; import { CashierService } from '../cashier.service'; +import { VoucherDetailDialogComponent } from './voucher-detail-dialog.component'; /** What the signed-in user's part in a voucher was. */ export type MyRole = 'RAISED' | 'VERIFIED' | 'RAISED_AND_VERIFIED'; @@ -72,11 +74,17 @@ interface MyWorkReport { imports: [ CommonModule, FormsModule, - MatIconModule + MatIconModule, + MatDialogModule ] }) export class MyWorkComponent implements OnInit { private service = inject(CashierService); + private dialog = inject(MatDialog); + + /** Which voucher's detail is currently being fetched, so the row that was clicked can show its + * own spinner instead of a page-wide one for what is a small, single-row lookup. */ + loadingDetailFor: string | null = null; /** Day is the working default; a month is what a close or an audit asks for. */ period: 'DAY' | 'MONTH' = 'DAY'; @@ -323,6 +331,31 @@ export class MyWorkComponent implements OnInit { return { from: first, to: lastIso }; } + /** Opens the full slip for one voucher — every debit and credit line, not just the first side of + * each a table row has room to show. */ + openDetail(voucherNo: string): void { + if (this.loadingDetailFor) { + return; + } + this.loadingDetailFor = voucherNo; + this.service + .voucherDetail(voucherNo) + .pipe( + catchError(() => of(null)), + finalize(() => (this.loadingDetailFor = null)) + ) + .subscribe((detail: any) => { + if (!detail) { + return; + } + this.dialog.open(VoucherDetailDialogComponent, { + data: { detail }, + width: '600px', + autoFocus: false + }); + }); + } + private todayIso(): string { const d = new Date(); return `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, '0')}-${String(d.getDate()).padStart(2, '0')}`; diff --git a/src/app/cashier/my-work/voucher-detail-dialog.component.ts b/src/app/cashier/my-work/voucher-detail-dialog.component.ts new file mode 100644 index 000000000..93137c3c7 --- /dev/null +++ b/src/app/cashier/my-work/voucher-detail-dialog.component.ts @@ -0,0 +1,320 @@ +/** + * Copyright since 2025 Mifos Initiative + * + * This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. + */ + +import { Component, inject } from '@angular/core'; +import { CommonModule } from '@angular/common'; +import { MatDialogModule, MatDialogRef, MAT_DIALOG_DATA } from '@angular/material/dialog'; +import { MatButtonModule } from '@angular/material/button'; +import { MatIconModule } from '@angular/material/icon'; + +export interface VoucherDetailDialogData { + /** Already-loaded detail from CashierService.voucherDetail() — the caller fetches, this dialog + * only renders, so one loading state lives in the row's own click handler rather than being + * duplicated inside every dialog that could ever show a voucher. */ + detail: { + voucherNo: string; + voucherType: string | null; + amount: number | null; + branchName: string | null; + createdBy: string | null; + createdAt: string | null; + totalDebit: number; + totalCredit: number; + balanced: boolean; + lines: { + lineNo: number; + entryType: 'DR' | 'CR'; + partyName: string | null; + accountNo: string | null; + amount: number; + description: string | null; + }[]; + /** GL entries a native platform command posted for the same underlying event, outside this + * voucher entirely — a share redemption's Dr Share Capital / Cr Share Sundry pair, posted at + * manager approval, before this voucher's own Dr Sundry / Cr Cash at Vault settlement leg even + * exists. Empty for every voucher that already is the whole transaction on its own. */ + relatedLines: { + stage: string; + entryType: 'DR' | 'CR'; + partyName: string | null; + accountNo: string | null; + amount: number; + description: string | null; + }[]; + }; +} + +/** The full slip behind a summarised register row — every debit and credit line a voucher is + * made of, not just the first side of each that a table row has room for. */ +@Component({ + selector: 'mifosx-voucher-detail-dialog', + standalone: true, + imports: [ + CommonModule, + MatDialogModule, + MatButtonModule, + MatIconModule + ], + template: ` +
+
+
+ receipt_long +
+

{{ detail.voucherNo }}

+

{{ detail.voucherType || 'Voucher' }} · {{ detail.branchName || 'Head Office' }}

+
+
+ +
+ +
+ Raised by {{ detail.createdBy || '—' }} + @if (detail.createdAt) { + {{ detail.createdAt | date: 'dd MMM yyyy, h:mm a' }} + } + @if (!detail.balanced) { + + warning + Debit and credit do not balance + + } +
+ +
DenominationDeposit PcsDeposit AmtWithdrawal PcsWithdrawal AmtNet PcsNet AmountCoinCame inWent outLeft with youWorth
₹{{ r.denominationValue }} {{ r.depositCount }}₹{{ r.depositAmount | number: '1.2-2' }}{{ r.withdrawalCount }}₹{{ r.withdrawalAmount | number: '1.2-2' }}{{ r.vaultInCount + r.depositCount || '—' }}{{ r.withdrawalCount + r.settledOutCount || '—' }} - {{ r.netCount > 0 ? '+' : '' }}{{ r.netCount }} + {{ r.netCount }}
- @if (scopedToCashier && closingBalance !== null) { - No cash crossed the counter on this date. The drawer still holds ₹{{ closingBalance | number: '1.2-2' }}, - carried forward. + @if (scopedToCashier && hasTodayLegs && expectedInDrawer !== 0) { + No cash crossed the counter on this date. The drawer should hold ₹{{ expectedInDrawer | number: '1.2-2' }} + from the day's vault and settlement legs. } @else { No cash denomination data for this date. } diff --git a/src/app/cashier/closing-cash-report/closing-cash-report.component.ts b/src/app/cashier/closing-cash-report/closing-cash-report.component.ts index a7d938a0a..b5876e77e 100644 --- a/src/app/cashier/closing-cash-report/closing-cash-report.component.ts +++ b/src/app/cashier/closing-cash-report/closing-cash-report.component.ts @@ -65,6 +65,19 @@ export class ClosingCashReportComponent implements OnInit { */ openingBalance: number | null = null; closingBalance: number | null = null; + + /** + * The day's own cash legs, which is what a cashier reconciles against the drawer. + * + * A cashier carries no balance: they draw from the vault in the morning and hand back at close. + * The cumulative opening/closing figures answer a question nobody at the counter asks, and one + * carrying years of history answers it with a number that cannot be counted against anything on + * the desk. + */ + todayVaultIn: number | null = null; + todaySettledOut: number | null = null; + todayInterCashierIn: number | null = null; + todayInterCashierOut: number | null = null; /** Every leg of the day's cash movement, which is wider than deposits less withdrawals. */ cashMovement: number | null = null; /** Whether the figures above are one cashier's drawer or the whole branch's counter work. */ @@ -95,6 +108,29 @@ export class ClosingCashReportComponent implements OnInit { selectedTellerId: number | null = null; selectedCashierId: number | null = null; + /** + * What should be in the drawer at the end of the day, from the day's own legs. + * + * Drawn from the vault, plus anything taken over the counter and from another cashier, less what + * went back out. This is the figure a cashier counts against — it does not carry a balance + * forward, because a cashier does not. + */ + get expectedInDrawer(): number { + return ( + Number(this.todayVaultIn ?? 0) + + Number(this.todayInterCashierIn ?? 0) + + Number(this.depositTotalWithDd ?? 0) - + Number(this.withdrawalTotal ?? 0) - + Number(this.todaySettledOut ?? 0) - + Number(this.todayInterCashierOut ?? 0) + ); + } + + /** True when any of the day's own legs carries a figure, so the panel is worth showing. */ + get hasTodayLegs(): boolean { + return this.todayVaultIn !== null; + } + get noteRows(): any[] { return this.rows.filter((r) => r.kind !== 'coin'); } @@ -122,8 +158,35 @@ export class ClosingCashReportComponent implements OnInit { return this.noteRows.reduce((s, r) => s + Number(r.withdrawalAmount ?? 0), 0); } + get noteVaultInCount(): number { + return this.noteRows.reduce((s, r) => s + Number(r.vaultInCount ?? 0), 0); + } + + get noteVaultInAmount(): number { + return this.noteRows.reduce((s, r) => s + Number(r.vaultInAmount ?? 0), 0); + } + + get noteSettledOutCount(): number { + return this.noteRows.reduce((s, r) => s + Number(r.settledOutCount ?? 0), 0); + } + + get noteSettledOutAmount(): number { + return this.noteRows.reduce((s, r) => s + Number(r.settledOutAmount ?? 0), 0); + } + + get noteNetCount(): number { + return this.noteVaultInCount + this.noteDepositCount - this.noteWithdrawalCount - this.noteSettledOutCount; + } + + /** + * What is left in the drawer in notes: everything that came in, less everything that went out. + * + * Was deposits minus withdrawals, which is the counter's movement rather than the drawer's + * contents — it left out the cash drawn from the vault, so a teller holding a ₹500 note saw a net + * of zero and had nothing to count against. + */ get noteNetAmount(): number { - return this.noteDepositAmount - this.noteWithdrawalAmount; + return this.noteVaultInAmount + this.noteDepositAmount - this.noteWithdrawalAmount - this.noteSettledOutAmount; } get coinRows(): any[] { return this.rows.filter((r) => r.kind === 'coin'); @@ -160,6 +223,10 @@ export class ClosingCashReportComponent implements OnInit { this.openingBalance = res?.openingBalance == null ? null : Number(res.openingBalance); this.closingBalance = res?.closingBalance == null ? null : Number(res.closingBalance); this.cashMovement = res?.cashMovement == null ? null : Number(res.cashMovement); + this.todayVaultIn = res?.todayVaultIn == null ? null : Number(res.todayVaultIn); + this.todaySettledOut = res?.todaySettledOut == null ? null : Number(res.todaySettledOut); + this.todayInterCashierIn = res?.todayInterCashierIn == null ? null : Number(res.todayInterCashierIn); + this.todayInterCashierOut = res?.todayInterCashierOut == null ? null : Number(res.todayInterCashierOut); this.loading = false; this.loaded = true; }, @@ -176,6 +243,10 @@ export class ClosingCashReportComponent implements OnInit { this.openingBalance = null; this.closingBalance = null; this.cashMovement = null; + this.todayVaultIn = null; + this.todaySettledOut = null; + this.todayInterCashierIn = null; + this.todayInterCashierOut = null; this.loading = false; this.loaded = true; } diff --git a/src/app/cashier/day-book/day-book.component.html b/src/app/cashier/day-book/day-book.component.html index 54d302add..5c5215145 100644 --- a/src/app/cashier/day-book/day-book.component.html +++ b/src/app/cashier/day-book/day-book.component.html @@ -9,11 +9,10 @@
-
CASHIER · DAY BOOK
-

Day Book

+

{{ title }}

- Every voucher raised on the selected date — cash deposits, withdrawals, disbursements, collections and bank - transfers, with credit and debit totals. + Every voucher raised on the selected date — cash deposits, withdrawals, disbursements, collections, interest and + journals, with each person's own credit, debit and net.

@@ -103,11 +102,90 @@ Totals count approved vouchers only — pending and rejected rows are listed but never move the money.

+ + @if (byUser.length > 0) { +
+
+ Officer Verification Sheet + Each person's own credit, debit and net for the day + {{ byUser.length }} {{ byUser.length === 1 ? 'person' : 'people' }} +
+
+ + + + + + + + + + + + + @for (u of byUser; track u.name) { + + + + + + + + + } + + + + + + + + + + + + +
Handled byItemsCredit (CR)Debit (DR)NetPending
{{ u.name }}{{ u.items }}₹{{ u.credit | number: '1.2-2' }}₹{{ u.debit | number: '1.2-2' }} + ₹{{ u.net | number: '1.2-2' }} + + @if (u.pending > 0) { + {{ u.pending }} + } @else { + — + } +
All staff{{ byUserTotals.items }}₹{{ byUserTotals.credit | number: '1.2-2' }}₹{{ byUserTotals.debit | number: '1.2-2' }} + ₹{{ byUserTotals.net | number: '1.2-2' }} + + @if (byUserTotals.pending > 0) { + {{ byUserTotals.pending }} + } @else { + — + } +
+
+
+ } + @for (s of sections; track s.key) { -
+ +
{{ s.label }} {{ s.hint }} diff --git a/src/app/cashier/day-book/day-book.component.scss b/src/app/cashier/day-book/day-book.component.scss index 32ee649b3..c9e21ddf1 100644 --- a/src/app/cashier/day-book/day-book.component.scss +++ b/src/app/cashier/day-book/day-book.component.scss @@ -36,6 +36,36 @@ from the others, so the three are not added up as though they were one list. */ .db-section { margin-bottom: 14px; + border-left: 3px solid #cbd5e1; + + /* Cash that actually crossed the counter — the section a drawer is checked against, so it + carries the darker green. */ + &--CASHIER { + border-left-color: #15803d; + + .db-section__head { + background: #f0fdf4; + } + } + + /* Raised but not actioned: no cash has moved yet, so the lighter shade of the same green. */ + &--CLERK { + border-left-color: #86efac; + + .db-section__head { + background: #f7fef9; + } + } + + /* Postings with no counter behind them — interest, charges, journals. Not the branch's cash at + all, so a different colour rather than a third green that would read as a third cash state. */ + &--SYSTEM { + border-left-color: #6366f1; + + .db-section__head { + background: #f5f5ff; + } + } &__head { display: flex; @@ -110,11 +140,19 @@ background: #f1f5f9; color: #475569; + /* Passed, but nothing handed over yet — the lighter green. */ &--ok { background: #dcfce7; color: #15803d; } + /* Cash has actually left the counter. Filled and dark, so the final state is the one the eye + lands on: on this page the difference between "approved" and "paid out" is the whole question. */ + &--done { + background: #15803d; + color: #fff; + } + &--pending { background: #fef3c7; color: #b45309; @@ -352,3 +390,52 @@ color: #334155; border-top: 1px solid #e2e8f0; } + +/* Officer Verification Sheet — the day totalled per person, for whoever signs the book off. + Numeric cells use the shared `.num` from cashier-shared.scss rather than a class of their own: + that one already right-aligns and tabular-figures both the heading and the cell, which is what + lines a money column up. A local class only styled the cells, leaving every heading left-aligned + over right-aligned figures. */ +.db-byuser { + /* Not one of the three sections below, so no section colour: this is the whole day summed by + person, and giving it one of their edges would read as a fourth section. */ + border-left-color: #94a3b8; + + &__name { + font-weight: 600; + color: #0f172a; + } + + /* Received green, paid-out red — the convention a passbook already uses, so the two columns are + told apart without reading the headings. */ + &__cr { + color: #15803d; + } + + &__dr { + color: #b91c1c; + } + + &__net { + font-weight: 600; + + &--pos { + color: #15803d; + } + + &--neg { + color: #b91c1c; + } + } + + /* The sheet's own total sits against a tinted band so the eye lands on the line that has to + agree with the day's figures above. */ + &__total { + background: #f8fafc; + font-weight: 600; + + td { + border-top: 2px solid #cbd5e1; + } + } +} diff --git a/src/app/cashier/day-book/day-book.component.ts b/src/app/cashier/day-book/day-book.component.ts index 418c340fa..8ce3c0436 100644 --- a/src/app/cashier/day-book/day-book.component.ts +++ b/src/app/cashier/day-book/day-book.component.ts @@ -10,6 +10,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 { Router } from '@angular/router'; import { catchError, of } from 'rxjs'; import { CashierService } from '../cashier.service'; @@ -20,7 +21,7 @@ import { CashierService } from '../cashier.service'; * the bank shifting its own money, not a customer transaction, and it is read on the Cashier * Position and Closing Cash Report where the drawer itself is the subject. */ -type DayBookSection = 'CASHIER' | 'CLERK'; +type DayBookSection = 'CASHIER' | 'CLERK' | 'SYSTEM'; interface DayBookRow { voucherNo: string | null; @@ -61,6 +62,18 @@ interface DayBookRow { }) export class DayBookComponent implements OnInit { private service = inject(CashierService); + private router = inject(Router); + + /** + * The heading, taken from the route the screen was opened by. + * + * One screen serves three menus, and only the Branch Ops reading is a different thing: there it + * is the branch's book with the verification sheet an officer signs off, rather than one desk's + * view of the day. + */ + get title(): string { + return this.router.url.includes('/branch-ops/') ? 'Day Book & Officer Verification' : 'Day Book'; + } date = ''; side: '' | 'CR' | 'DR' = ''; @@ -145,7 +158,15 @@ export class DayBookComponent implements OnInit { }[] { const order: { key: DayBookSection; label: string; hint: string }[] = [ { key: 'CASHIER', label: 'Cashier', hint: 'Cash actually taken in and paid out over the counter' }, - { key: 'CLERK', label: 'Clerk', hint: 'Requests raised but not yet actioned — no cash has moved' } + { key: 'CLERK', label: 'Clerk', hint: 'Requests raised but not yet actioned — no cash has moved' }, + // Postings that reached the ledger without a request behind them: interest the nightly run + // credited, a charge taken from a balance, a manual journal. These are the day's work too, + // and an officer accounting for the day cannot do it from the counter registers alone. + { + key: 'SYSTEM', + label: 'System & Journal', + hint: 'Interest, charges and journals posted without a counter request' + } ]; return order .map((s) => { @@ -206,11 +227,90 @@ export class DayBookComponent implements OnInit { return s === 'APPROVED' || s === 'COMPLETED' || s === 'WITHDRAWN'; } + /** + * The day totalled per person, for the officer who signs the book off. + * + * A branch's day book is read two ways. Entry by entry, to trace one voucher — which the sections + * below already give. And person by person, to ask what each member of staff handled and whether + * their column adds up: that is the reconciliation a principal officer actually performs, and + * without it they are adding a day of rows in their head. + * + * Built from whoever appears in the day's own rows rather than from a list of staff, so it needs + * no configuring: one person working alone gets one line, five people get five, and somebody who + * handled nothing today is simply absent instead of showing as a row of zeros. + * + * Only approved money counts, the same rule the section totals use — a pending request is not + * cash anybody is answerable for yet. Pending and rejected rows are still counted as ITEMS, so a + * person whose work is all awaiting approval does not vanish from the sheet. + * + * Ordered by the largest turnover first: the columns worth checking hardest are the ones that + * moved the most money. + */ + get byUser(): { + name: string; + credit: number; + debit: number; + net: number; + items: number; + pending: number; + }[] { + const map = new Map(); + + for (const r of this.filteredRows) { + // Rows the registers could not attribute are grouped rather than dropped: an unattributed + // amount is exactly what an officer needs to see, and hiding it would leave the sheet's own + // total disagreeing with the day's. + const name = (r.handledBy ?? '').trim() || 'Unattributed'; + const entry = map.get(name) ?? { name, credit: 0, debit: 0, items: 0, pending: 0 }; + + entry.items++; + if (this.countsAsMoney(r)) { + const amount = Number(r.amount ?? 0); + if (r.side === 'CR') { + entry.credit += amount; + } else { + entry.debit += amount; + } + } else { + entry.pending++; + } + map.set(name, entry); + } + + return Array.from(map.values()) + .map((e) => ({ ...e, net: e.credit - e.debit })) + .sort((a, b) => b.credit + b.debit - (a.credit + a.debit)); + } + + /** The sheet's own totals, which must equal the day's — that is what makes it verifiable. */ + get byUserTotals(): { credit: number; debit: number; net: number; items: number; pending: number } { + return this.byUser.reduce( + (t, u) => ({ + credit: t.credit + u.credit, + debit: t.debit + u.debit, + net: t.net + u.net, + items: t.items + u.items, + pending: t.pending + u.pending + }), + { credit: 0, debit: 0, net: 0, items: 0, pending: 0 } + ); + } + + /** + * Approved and paid out are not the same state, and the day book is where the difference matters: + * an approved withdrawal is a promise, a withdrawn one is money gone. Both used to render in the + * same green, so a reader could not tell from the badge whether the customer had actually been + * handed the cash — the one thing this page is read to find out. + */ statusClass(status: string): string { switch ((status ?? '').toUpperCase()) { - case 'APPROVED': - case 'COMPLETED': case 'WITHDRAWN': + case 'COMPLETED': + case 'CLOSED': + // Cash has left the counter. The darker, filled badge — the final state. + return 'db-badge db-badge--done'; + case 'APPROVED': + // Passed, but nobody has handed anything over yet. return 'db-badge db-badge--ok'; case 'PENDING': return 'db-badge db-badge--pending'; diff --git a/src/app/cashier/end-of-day-report/end-of-day-report.component.html b/src/app/cashier/end-of-day-report/end-of-day-report.component.html new file mode 100644 index 000000000..6a8d5eac7 --- /dev/null +++ b/src/app/cashier/end-of-day-report/end-of-day-report.component.html @@ -0,0 +1,606 @@ + + +
+ +
+
+

End of the Day Report

+

What moved today, whether the cash agrees with the books, and who signs off.

+
+
+ + + +
+
+ + @if (loading) { +
Loading the day’s figures…
+ } + + @if (loaded) { +
+ +
+

Daily EOD & Transaction Report

+
+
+ Date + {{ date | date: 'dd MMM yyyy' }} +
+
+ Branch + {{ branchName || '—' }} +
+
+ Prepared by + {{ preparedBy || '—' }} +
+
+ Scope + {{ scopedToCashier ? 'My counter' : 'Branch counter' }} +
+
+
+ + +
+

1 Transaction Summary

+ + @if (sectionLines.length) { +

Where the money came from

+ + + + + + + + + + + @for (l of sectionLines; track l.label) { + + + + + + + } + + + + + + + + + +
SourceEntriesMoney inMoney out
{{ l.label }}{{ l.count }}{{ l.credit ? (l.credit | currency: 'INR') : '—' }}{{ l.debit ? (l.debit | currency: 'INR') : '—' }}
Total + {{ txnCount }} + + {{ summaryCreditTotal | currency: 'INR' }} + + {{ summaryDebitTotal | currency: 'INR' }} +
+ +

What kind of entries

+ + + + + + + + + + + @for (l of kindLines; track l.label) { + + + + + + + } + +
KindEntriesMoney inMoney out
{{ l.label }}{{ l.count }}{{ l.credit ? (l.credit | currency: 'INR') : '—' }}{{ l.debit ? (l.debit | currency: 'INR') : '—' }}
+ +

+ Only entries that actually moved money. Anything still pending is listed at the bottom. These figures match + the Day Book for the same date. +

+ } @else { +
No transactions on this date.
+ } +
+ + +
+

2 Cash

+ + @if (scopedToCashier) { +

Your drawer today

+ + + + + + + + + + + @if (interCashierIn) { + + + + + } + + + + + @if (settledOut) { + + + + + } + @if (interCashierOut) { + + + + + } + + + + + + + + + @if (variance !== null) { + + + + + } + +
Cash you took from the vault this morning{{ openingFloat | currency: 'INR' }}
Cash you took from members deposits, including DD cash+{{ cashDeposits | currency: 'INR' }}
Cash another cashier gave you+{{ interCashierIn | currency: 'INR' }}
Cash you paid to members withdrawals−{{ cashWithdrawals | currency: 'INR' }}
Cash you handed back to the vault−{{ settledOut | currency: 'INR' }}
Cash you gave another cashier−{{ interCashierOut | currency: 'INR' }}
So your drawer should hold + @if (expectedCash !== null) { + {{ expectedCash | currency: 'INR' }} + } @else { + — + } +
System says you hold + @if (actualCash !== null) { + {{ actualCash | currency: 'INR' }} + } @else { + nothing recorded + } +
Difference + {{ variance | currency: 'INR' }} +
+ + @if (variance !== null) { + @if (varianceOk) { +

+ check_circle + Your cash agrees. Nothing short, nothing over. +

+ } @else { +

+ error + {{ variance! > 0 ? 'Extra' : 'Short' }} by + {{ (variance! > 0 ? variance : -variance!) | currency: 'INR' }}. Explain this before closing. +

+ } + } @else { +

Today’s cash legs were not sent for this login, so nothing is compared.

+ } + } @else { +

Cash that moved across the branch today

+ + + + + + + + + + + + + + + +
Cash taken from members deposits+{{ branchDeposits ?? 0 | currency: 'INR' }}
Cash paid to members withdrawals and closures−{{ branchWithdrawals ?? 0 | currency: 'INR' }}
Net change today + {{ branchNetMovement | currency: 'INR' }} +
+ +

Cash the branch is holding right now

+ + + + + + + + + + + + + + + +
In the vaults{{ vaultTotal ?? 0 | currency: 'INR' }}
With the cashiers{{ drawersTotal ?? 0 | currency: 'INR' }}
Total cash in the branch + @if (branchHeldTotal !== null) { + {{ branchHeldTotal | currency: 'INR' }} + } @else { + nothing recorded + } +
+ +

+ These are two different things and are not subtracted from each other. The first is today’s movement; + the second is everything the branch has accumulated to date. Each cashier’s own drawer is checked + against their own day on their login, and the branch figure is signed off on the Day Open / Day Close + screen. +

+ } +
+ + +
+

3 Handled By

+ + @if (byUser.length) { +

What each person handled

+ + + + + + + + + + + + @for (u of byUser; track u.label) { + + + + + + + + } + + + + + + + + + + +
Handled byEntriesMoney inMoney outNet
{{ u.label }}{{ u.count }}{{ u.credit ? (u.credit | currency: 'INR') : '—' }}{{ u.debit ? (u.debit | currency: 'INR') : '—' }}{{ u.credit - u.debit | currency: 'INR' }}
Total + {{ txnCount }} + + {{ summaryCreditTotal | currency: 'INR' }} + + {{ summaryDebitTotal | currency: 'INR' }} + + {{ summaryCreditTotal - summaryDebitTotal | currency: 'INR' }} +
+ } @else { +
Nobody moved money on this date.
+ } + + @if (cashierPositions.length) { +

What each cashier is holding

+ + + + + + + + + @for (p of cashierPositions; track p.cashierId) { + + + + + } + + @if (cashierPositions.length > 1) { + + + + + + + } +
CashierCash held
{{ p.cashierName }}{{ p.totalAmount | currency: 'INR' }}
Total + {{ cashierPositionTotal | currency: 'INR' }} +
+

+ This is what each drawer should contain right now — a running total, not just today. Count against it + before settling to the vault. +

+ } +
+ + +
+

4 Every Transaction

+ + @if (approvedRows.length) { + + + + + + + + + + + + + + @for (r of approvedRows; track r.voucherNo + '|' + r.txnId) { + + + + + + + + + + } + + + + + + + + +
VoucherKindAccountPartyHandled byMoney inMoney out
{{ r.voucherNo || '—' }}{{ kindLabel(r.kind) }}{{ r.accountNo || '—' }}{{ r.party || '—' }}{{ r.handledBy || '—' }}{{ r.side === 'CR' ? (r.amount | currency: 'INR') : '—' }}{{ r.side === 'DR' ? (r.amount | currency: 'INR') : '—' }}
+ Total — {{ txnCount }} entries + + {{ summaryCreditTotal | currency: 'INR' }} + + {{ summaryDebitTotal | currency: 'INR' }} +
+ } @else { +
No transactions on this date.
+ } + + @if (pendingRows.length) { +

Still pending — no money has moved on these

+ + + + + + + + + + + + + @for (r of pendingRows; track r.voucherNo + '|' + r.txnId) { + + + + + + + + + } + +
VoucherKindAccountPartyRaised byAmount
{{ r.voucherNo || '—' }}{{ kindLabel(r.kind) }}{{ r.accountNo || '—' }}{{ r.party || '—' }}{{ r.handledBy || '—' }}{{ r.amount | currency: 'INR' }}
+ } +
+ + + @if (priorDayRows.length) { +
+

+ nights_stay + Posted Overnight — work of + {{ priorDayDate ? (priorDayDate | date: 'dd MMM yyyy') : 'the previous day' }} +

+ + +
+
+ These belong to + {{ + priorDayDate ? (priorDayDate | date: 'dd MMM yyyy') : 'the previous day' + }} + the day the system posted them against — already closed +
+
→
+
+ Showing on + {{ date | date: 'dd MMM yyyy' }} + today’s report, because the run finished after midnight +
+
+ +

+ The nightly run posted these after + {{ priorDayDate ? (priorDayDate | date: 'dd MMM') : 'the previous day' }} had already closed and been signed + off, so they surface on today’s report instead. In the ledger they sit on + {{ priorDayDate ? (priorDayDate | date: 'dd MMM') : 'that date' }}, and they are + not counted in any figure above. +

+ + + + + + + + + + + + + + + @for (r of priorDayRows; track r.voucherNo + '|' + r.txnId) { + + + + + + + + + + + } + + + + + + + + +
VoucherKindAccountPartyLedger dateMoney inMoney out
{{ r.voucherNo || '—' }}{{ kindLabel(r.kind) }}{{ r.accountNo || '—' }}{{ r.party || '—' }}{{ r.effectiveDate ? (r.effectiveDate | date: 'dd MMM yyyy') : '—' }}{{ r.side === 'CR' ? (r.amount | currency: 'INR') : '—' }}{{ r.side === 'DR' ? (r.amount | currency: 'INR') : '—' }}
+ Total — {{ priorDayRows.length }} entries + + {{ priorDayCredit | currency: 'INR' }} + + {{ priorDayDebit | currency: 'INR' }} +
+
+ } + + +
+

5 Anything Outstanding

+ +
    +
  • + Entries handled + {{ txnCount }} entries · {{ kindLines.length }} kind(s) +
  • +
  • + Waiting for approval + + @if (pendingRows.length) { + {{ pendingRows.length }} entry(ies) — the day cannot close until these are done + } @else { + None + } + +
  • +
  • + Slips that did not match + + @if (mismatchRows.length) { + {{ mismatchRows.length }} slip(s) do not agree with the system + } @else { + None + } + +
  • +
  • + Slips not checked yet + + {{ + unverifiedRows.length + ? unverifiedRows.length + ' entry(ies) still to be checked against paper' + : 'All checked' + }} + +
  • + @if (priorDayRows.length) { +
  • + Posted overnight + + {{ priorDayRows.length }} entry(ies) for + {{ priorDayDate ? (priorDayDate | date: 'dd MMM') : 'the previous day' }} — listed in their own + section, not in today’s figures + +
  • + } +
  • + Status + + {{ + allClear + ? 'Everything is clear — the day is ready to close.' + : 'Sort out the items above before closing the day.' + }} + +
  • +
+
+ + +
+
+
+
{{ supervising ? 'Prepared by' : 'Cashier' }}
+
{{ preparedBy || '' }}
+
+
+
+
Manager / Principal Officer
+
 
+
+
+ +

+ Generated from the day book and the closing cash report for {{ date | date: 'dd MMM yyyy' }}. Figures move with + the underlying vouchers — reprint after any reversal. +

+
+ } +
diff --git a/src/app/cashier/end-of-day-report/end-of-day-report.component.scss b/src/app/cashier/end-of-day-report/end-of-day-report.component.scss new file mode 100644 index 000000000..15bd8bdab --- /dev/null +++ b/src/app/cashier/end-of-day-report/end-of-day-report.component.scss @@ -0,0 +1,448 @@ +/** + * 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 sheet itself, which is what gets signed. Bordered on screen so it reads as one document + rather than three panels, and the same borders carry into print. */ + +/* Centred in the page, because it is a document rather than a screen: a signed sheet pinned to the + left edge of a wide monitor reads as a panel that failed to fill its space. */ +.eod-sheet { + background: #fff; + border: 1px solid #cbd5e1; + border-radius: 8px; + padding: 28px 32px 24px; + max-width: 900px; + margin: 0 auto; + width: 100%; +} + +/* The toolbar and the loading line above it share the sheet's own edges, not the viewport's. */ +.eod .pg-head, +.eod > .pg-empty { + max-width: 900px; + margin: 0 auto; + width: 100%; +} + +.eod-sheet__head { + border-bottom: 2px solid #0f172a; + padding-bottom: 14px; + margin-bottom: 22px; +} + +.eod-sheet__title { + margin: 0 0 14px; + font-size: 19px; + font-weight: 700; + letter-spacing: 0.02em; + text-transform: uppercase; + color: #0f172a; + text-align: center; +} + +.eod-sheet__meta { + display: grid; + grid-template-columns: repeat(4, minmax(0, 1fr)); + gap: 10px 18px; +} + +.eod-meta { + display: flex; + flex-direction: column; + gap: 2px; +} + +.eod-meta__label { + font-size: 10px; + font-weight: 700; + letter-spacing: 0.06em; + text-transform: uppercase; + color: #64748b; +} + +.eod-meta__value { + font-size: 13px; + font-weight: 600; + color: #0f172a; +} + +/* ── Sections ── */ +.eod-section { + margin-bottom: 26px; + + /* A section must not be split across two printed pages: a reconciliation whose expected line is + on page one and whose variance is on page two is not a document anybody can sign. */ + break-inside: avoid; + page-break-inside: avoid; +} + +.eod-section__title { + display: flex; + align-items: center; + gap: 9px; + margin: 0 0 12px; + font-size: 13px; + font-weight: 700; + letter-spacing: 0.04em; + text-transform: uppercase; + color: #0f172a; + border-bottom: 1px solid #e2e8f0; + padding-bottom: 7px; +} + +.eod-section__no { + display: inline-flex; + align-items: center; + justify-content: center; + width: 20px; + height: 20px; + border-radius: 4px; + background: #0f172a; + color: #fff; + font-size: 11px; + font-weight: 700; + flex: 0 0 auto; +} + +.eod-note { + margin: 8px 0 0; + font-size: 11.5px; + color: #64748b; + line-height: 1.5; +} + +/* Names the table below it, so two tables in one section do not read as one long list. */ +.eod-caption { + margin: 0 0 6px; + font-size: 11px; + font-weight: 700; + letter-spacing: 0.05em; + text-transform: uppercase; + color: #475569; +} + +.eod-caption--spaced { + margin-top: 18px; +} + +/* The line-by-line list is wider than the rest of the sheet, so it scrolls in its own box rather + than making the whole page scroll sideways. */ +.eod-section--wide { + .pg-table { + min-width: 100%; + } +} + +.eod-txns { + font-size: 12px; + + td, + th { + white-space: nowrap; + } +} + +/* Voucher numbers and account numbers are read digit by digit, so they get a fixed pitch. */ +.eod-mono { + font-family: 'Roboto Mono', ui-monospace, 'Courier New', monospace; + font-size: 11.5px; +} + +/* Last night's batch. Set apart on purpose: these figures belong to a day that has already been + signed off, so the block has to read as an appendix rather than as part of the day being counted. + Indigo, because that is already the day book's colour for system postings. */ +.eod-section--prior { + background: #f5f3ff; + border: 1px solid #ddd6fe; + border-radius: 8px; + padding: 16px 18px 14px; + margin-top: 30px; + + .eod-section__title { + color: #5b21b6; + border-bottom-color: #ddd6fe; + } + + .pg-table th { + background: #ede9fe; + color: #5b21b6; + } +} + +/* Sits in the title row in place of the numbered chip: this block is outside the numbered sequence, + because it is not a step in counting today. */ +.eod-section__icon { + font-size: 18px; + width: 18px; + height: 18px; + color: #7c3aed; + flex: 0 0 auto; +} + +.eod-note--lead { + margin: 0 0 12px; + color: #5b21b6; +} + +/* The two dates, stated as a pair with an arrow between them. Spelling out "belongs to X, showing + on Y" is the only way a reader can be sure which of the two a figure is counted under — a single + date in a heading leaves exactly the ambiguity this block exists to remove. */ +.eod-datepair { + display: flex; + align-items: center; + gap: 14px; + margin: 0 0 14px; + padding: 12px 14px; + background: #fff; + border: 1px solid #ddd6fe; + border-radius: 6px; + flex-wrap: wrap; +} + +.eod-datepair__item { + display: flex; + flex-direction: column; + gap: 2px; + flex: 1 1 200px; +} + +.eod-datepair__label { + font-size: 10px; + font-weight: 700; + letter-spacing: 0.06em; + text-transform: uppercase; + color: #7c3aed; +} + +.eod-datepair__value { + font-size: 15px; + font-weight: 700; + color: #0f172a; +} + +.eod-datepair__hint { + font-size: 10.5px; + color: #64748b; + line-height: 1.4; +} + +.eod-datepair__arrow { + font-size: 20px; + color: #a78bfa; + flex: 0 0 auto; +} + +/* Dates in the row are read against the ledger, so they do not shrink into the grey the way a + secondary column normally would. */ +.eod-datecell { + font-size: 11.5px; + font-weight: 600; + color: #5b21b6; + white-space: nowrap; +} + +.eod-hint { + display: block; + font-size: 10.5px; + font-weight: 400; + color: #94a3b8; +} + +.eod-blank { + color: #94a3b8; + font-style: italic; +} + +/* ── Reconciliation table ── */ + +/* Two columns only, so the label takes the room and the figure sits in a fixed money column. */ +.eod-recon { + td:first-child { + width: auto; + } + + td:last-child { + width: 190px; + } +} + +.eod-recon__expected td { + border-top: 1px solid #94a3b8; + background: #f8fafc; +} + +.eod-recon__variance td { + border-top: 2px solid #0f172a; +} + +.eod-recon__variance.eod-ok td { + background: #f0fdf4; + color: #15803d; +} + +.eod-recon__variance.eod-bad td { + background: #fef2f2; + color: #b91c1c; +} + +/* ── Verdict line ── */ +.eod-verdict { + display: flex; + align-items: center; + gap: 7px; + margin: 12px 0 0; + padding: 9px 12px; + border-radius: 6px; + font-size: 12.5px; + font-weight: 600; + line-height: 1.45; + + mat-icon { + font-size: 17px; + width: 17px; + height: 17px; + flex: 0 0 auto; + } +} + +.eod-verdict--ok { + background: #f0fdf4; + border: 1px solid #86efac; + color: #15803d; +} + +.eod-verdict--bad { + background: #fef2f2; + border: 1px solid #fca5a5; + color: #b91c1c; +} + +/* ── Operational notes ── */ +.eod-notes { + list-style: none; + margin: 0; + padding: 0; + + li { + display: flex; + gap: 14px; + padding: 8px 10px; + border-bottom: 1px solid #f1f5f9; + font-size: 12.5px; + + &:last-child { + border-bottom: 0; + } + } +} + +.eod-notes__label { + flex: 0 0 190px; + font-weight: 600; + color: #475569; +} + +.eod-notes__value { + color: #0f172a; +} + +.eod-notes--warn { + background: #fffbeb; + + .eod-notes__value { + color: #b45309; + font-weight: 600; + } +} + +.eod-notes--ok { + background: #f0fdf4; + + .eod-notes__value { + color: #15803d; + font-weight: 600; + } +} + +/* ── Signatures ── */ +.eod-sign { + display: grid; + grid-template-columns: repeat(2, minmax(0, 1fr)); + gap: 40px; + margin-top: 46px; + break-inside: avoid; + page-break-inside: avoid; +} + +.eod-sign__block { + text-align: center; +} + +.eod-sign__line { + border-bottom: 1px solid #0f172a; + height: 34px; + margin-bottom: 7px; +} + +.eod-sign__role { + font-size: 11px; + font-weight: 700; + letter-spacing: 0.05em; + text-transform: uppercase; + color: #475569; +} + +.eod-sign__name { + font-size: 12px; + color: #64748b; + margin-top: 2px; +} + +.eod-foot { + margin: 26px 0 0; + padding-top: 12px; + border-top: 1px dashed #cbd5e1; + font-size: 10.5px; + color: #94a3b8; + text-align: center; + line-height: 1.5; +} + +/* ── Print ── + The sheet is the document; the shell, the toolbar and the app chrome are not. Colours are forced + so the variance verdict survives a printer that would otherwise drop backgrounds. */ +@media print { + .no-print, + .pg-head { + display: none !important; + } + + .eod-sheet { + border: 0; + border-radius: 0; + padding: 0; + max-width: none; + } + + .eod-sheet, + .eod-recon__variance td, + .eod-verdict, + .eod-notes--warn, + .eod-notes--ok { + -webkit-print-color-adjust: exact; + print-color-adjust: exact; + } + + .eod-sheet__title { + font-size: 16px; + } + + .pg-table { + font-size: 11px; + } +} diff --git a/src/app/cashier/end-of-day-report/end-of-day-report.component.ts b/src/app/cashier/end-of-day-report/end-of-day-report.component.ts new file mode 100644 index 000000000..88d8f9720 --- /dev/null +++ b/src/app/cashier/end-of-day-report/end-of-day-report.component.ts @@ -0,0 +1,536 @@ +/** + * 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 { CashierService } from '../cashier.service'; +import { SettingsService } from 'app/settings/settings.service'; +import { AuthenticationService } from 'app/core/authentication/authentication.service'; + +/** One part of the day's work — a section of the day book, or a kind of entry within it. */ +interface SummaryLine { + label: string; + credit: number; + debit: number; + count: number; +} + +/** + * The single sheet a cashier signs the day off with. + * + * Every other cashier screen answers one question — what is in the drawer, which vouchers are + * unverified, what did each person handle. At close of day somebody has to sign one piece of paper + * saying the money was counted and it agreed, and until now that meant reading four screens and + * copying figures onto a form by hand. + * + * Nothing here is computed on the server that was not already being computed: the closing report + * supplies the drawer legs, the day book supplies the transactions, and the cashier position + * supplies what the system believes is held. This screen assembles them in the order a person + * verifying the day reads them in, and prints. + */ +@Component({ + selector: 'mifosx-end-of-day-report', + templateUrl: './end-of-day-report.component.html', + styleUrls: [ + '../cashier-shared.scss', + './end-of-day-report.component.scss' + ], + standalone: true, + imports: [ + CommonModule, + FormsModule, + MatIconModule + ] +}) +export class EndOfDayReportComponent implements OnInit { + private service = inject(CashierService); + private settings = inject(SettingsService); + private auth = inject(AuthenticationService); + + date = ''; + loading = false; + loaded = false; + + /** Who is signing, and for which branch — read from the session, never typed in. */ + preparedBy = ''; + branchName = ''; + + // ── Section 1: transaction summary ── + rows: any[] = []; + creditTotal = 0; + debitTotal = 0; + + // ── Section 2: cash drawer ── + /** Drawn from the vault in the morning: a cashier's opening float. */ + openingFloat = 0; + cashDeposits = 0; + interCashierIn = 0; + cashWithdrawals = 0; + settledOut = 0; + interCashierOut = 0; + /** Whether the server sent this day's own cash legs, which only a cashier-scoped call gets. */ + hasDayLegs = false; + /** Whether the drawer figures are one cashier's or the whole branch's counter work. */ + scopedToCashier = false; + + /** What the drawers hold between them, per the system. */ + drawersTotal: number | null = null; + + /** + * Each cashier and what their drawer holds. + * + * A clerk holds no drawer of their own — they supervise the counter — so a single "actual cash" + * figure says nothing to them. The position endpoint already answers for whoever is asking: one + * drawer for a cashier, every drawer for a clerk or officer, so the same call serves both and the + * screen reads differently depending on who opened it. + */ + cashierPositions: { cashierId: number | null; cashierName: string; totalAmount: number }[] = []; + + /** + * The branch's own expected cash, from the day register. + * + * A branch-wide reading cannot be assembled from the per-cashier day legs: those are only + * returned when the call is scoped to one cashier. The branch day register already keeps this + * arithmetic for the close-day gate — opening cash, plus approved deposits, less what was + * withdrawn — so the sheet reads it rather than inventing a second version of the same sum. + * + * Null when no day has been opened for this date, which is a real state: there is nothing to + * reconcile against on a day the branch never opened. + */ + branchOpeningCash: number | null = null; + branchDeposits: number | null = null; + branchWithdrawals: number | null = null; + branchExpectedCash: number | null = null; + + /** + * What the vaults hold, which a branch-wide count must include. + * + * The branch register's expected cash covers the whole branch — vault and every drawer together — + * because cash moved from the vault to a cashier has not left the branch. Counting only the + * drawers against it would report the entire vault as a shortage. + */ + vaultTotal: number | null = null; + + ngOnInit(): void { + this.date = this.businessDate(); + this.preparedBy = this.currentUserName(); + this.branchName = this.currentOfficeName(); + this.load(); + } + + /** + * The date the books are open on, not the wall clock. + * + * An EOD sheet dated by the browser would be filed against the wrong day whenever the branch is + * still working yesterday's business date, which is exactly when a day is being closed. + */ + private businessDate(): string { + const d = this.settings.businessDate; + const safe = d instanceof Date && !isNaN(d.getTime()) ? d : new Date(); + return `${safe.getFullYear()}-${String(safe.getMonth() + 1).padStart(2, '0')}-${String(safe.getDate()).padStart(2, '0')}`; + } + + /** The member of staff behind the login, falling back to the username when no staff is linked. */ + private currentUserName(): string { + const c = this.auth.getCredentials(); + return c ? c.staffDisplayName || c.username || '' : ''; + } + + private currentOfficeName(): string { + return this.auth.getCredentials()?.officeName || ''; + } + + load(): void { + this.loading = true; + this.loaded = false; + + this.service.dayBook(this.date).subscribe({ + next: (res: any) => { + this.rows = res?.rows || []; + this.creditTotal = Number(res?.creditTotal || 0); + this.debitTotal = Number(res?.debitTotal || 0); + this.loading = false; + this.loaded = true; + }, + error: () => { + this.rows = []; + this.creditTotal = 0; + this.debitTotal = 0; + this.loading = false; + this.loaded = true; + } + }); + + this.service.closingCashReport(this.date).subscribe({ + next: (res: any) => { + // The four day legs are only sent for a cashier-scoped call. Read as absent rather than + // zero: a leg that is missing and a leg that is genuinely nil mean different things, and + // treating the first as the second is what let the sheet claim an expected balance of zero + // for a whole branch. + this.hasDayLegs = res?.todayVaultIn != null; + this.openingFloat = Number(res?.todayVaultIn || 0); + this.cashDeposits = Number(res?.depositTotalWithDd ?? res?.depositTotal ?? 0); + this.cashWithdrawals = Number(res?.withdrawalTotal || 0); + this.settledOut = Number(res?.todaySettledOut || 0); + this.interCashierIn = Number(res?.todayInterCashierIn || 0); + this.interCashierOut = Number(res?.todayInterCashierOut || 0); + this.scopedToCashier = res?.scopedToCashier === true; + }, + error: () => { + this.openingFloat = 0; + this.cashDeposits = 0; + this.cashWithdrawals = 0; + this.settledOut = 0; + this.interCashierIn = 0; + this.interCashierOut = 0; + this.scopedToCashier = false; + this.hasDayLegs = false; + } + }); + + // The branch's expected cash for the date, which is the only basis a branch-wide reading can + // reconcile on. Fails quietly for a plain cashier, who is not allowed to operate the day. + this.service.branchDayClosePreview(this.date).subscribe({ + next: (res: any) => { + this.branchOpeningCash = res?.openingCash == null ? null : Number(res.openingCash); + this.branchDeposits = res?.deposits == null ? null : Number(res.deposits); + this.branchWithdrawals = res?.withdrawals == null ? null : Number(res.withdrawals); + this.branchExpectedCash = res?.expectedCash == null ? null : Number(res.expectedCash); + }, + error: () => { + this.branchOpeningCash = null; + this.branchDeposits = null; + this.branchWithdrawals = null; + this.branchExpectedCash = null; + } + }); + + // One object for a cashier, an array of every cashier for a clerk or officer — the server + // decides from the caller's own roles, so this screen does not have to ask who is reading it. + this.service.cashierPositionReport(undefined, this.date).subscribe({ + next: (res: any) => { + const list = Array.isArray(res) ? res : res ? [res] : []; + this.cashierPositions = list + .filter((p: any) => p && p.cashierId != null) + .map((p: any) => ({ + cashierId: p.cashierId == null ? null : Number(p.cashierId), + cashierName: p.cashierName || `Cashier ${p.cashierId}`, + totalAmount: Number(p.totalAmount || 0) + })) + .sort((a: any, b: any) => b.totalAmount - a.totalAmount); + this.drawersTotal = this.cashierPositions.length + ? this.cashierPositions.reduce((s, p) => s + p.totalAmount, 0) + : null; + }, + error: () => { + // Nothing to count against, so the variance line stays blank rather than reading zero — a + // variance of zero is a claim that somebody counted and it agreed. + this.cashierPositions = []; + this.drawersTotal = null; + } + }); + + // Vault stock, so a branch-wide count is of the whole branch and not just the counters. + this.service.vaultPosition(undefined, this.date).subscribe({ + next: (res: any) => { + const list = Array.isArray(res) ? res : res ? [res] : []; + this.vaultTotal = list.reduce((s: number, v: any) => s + Number(v?.totalAmount || 0), 0); + }, + error: () => { + this.vaultTotal = null; + } + }); + } + + // ── Section 1 ── + + /** + * Only the entries that actually moved money. + * + * A pending request is listed on the day book so nobody loses sight of it, but it has taken no + * cash — counting it into an EOD summary would make the sheet disagree with the drawer. + */ + /** + * Last night's batch: posted against yesterday, surfacing on this report. + * + * A voucher's timestamp is the minute the job ran, while the transaction and the journal entry + * behind it carry the business date. So the interest run that fires just after midnight stamps + * itself with today although the ledger says yesterday — and yesterday was counted, reported and + * signed off hours before. Re-opening that day would invalidate a signed sheet, so the work is + * listed here instead, under its own heading, saying which day it belongs to. It is excluded from + * every total on this page: it is not today's money. + */ + get priorDayRows(): any[] { + return this.rows.filter((r) => r.section === 'SYSTEM_PRIOR'); + } + + get priorDayCredit(): number { + return this.priorDayRows.reduce((s, r) => s + (r.side === 'CR' ? Number(r.amount || 0) : 0), 0); + } + + get priorDayDebit(): number { + return this.priorDayRows.reduce((s, r) => s + (r.side === 'DR' ? Number(r.amount || 0) : 0), 0); + } + + /** The business date last night's batch worked on, for the heading. */ + get priorDayDate(): string | null { + const withDate = this.priorDayRows.find((r) => r.effectiveDate); + return withDate ? withDate.effectiveDate : null; + } + + get approvedRows(): any[] { + return this.rows.filter( + (r) => + r.section !== 'SYSTEM_PRIOR' && + (r.status === 'APPROVED' || r.status === 'COMPLETED' || r.status === 'WITHDRAWN') + ); + } + + /** + * The day split the way this system actually records it: where the entry came from. + * + * Not split by payment mode, which is the obvious shape for a retail till but the wrong one here. + * A payment type is only recorded against a denomination breakdown, and a breakdown is only + * written for physical cash — so a UPI or transfer deposit carries no payment type at all, and a + * mode-wise table would file every non-cash entry under "not specified" while claiming to be a + * breakdown by mode. The day book's own sections are recorded on every row, so this agrees with + * the day book for the same date instead of quietly disagreeing with it. + */ + get sectionLines(): SummaryLine[] { + const order = [ + 'CASHIER', + 'CLERK', + 'SYSTEM' + ]; + const labels: Record = { + CASHIER: 'Cash counter', + CLERK: 'Clerk desk', + SYSTEM: 'System — interest, charges, journals' + }; + const map = new Map(); + for (const r of this.approvedRows) { + const key = order.includes(r.section) ? r.section : 'SYSTEM'; + let line = map.get(key); + if (!line) { + line = { label: labels[key] || key, credit: 0, debit: 0, count: 0 }; + map.set(key, line); + } + this.addTo(line, r); + } + return order.filter((k) => map.has(k)).map((k) => map.get(k)!); + } + + /** + * What kind of entry it was — deposit, withdrawal, interest posting, charge. + * + * The sections above say where the day's work came from; this says what the work was, which is + * the question somebody checking a figure against a register actually asks. + */ + get kindLines(): SummaryLine[] { + const map = new Map(); + for (const r of this.approvedRows) { + const key = (r.kind || 'OTHER').toString(); + let line = map.get(key); + if (!line) { + line = { label: this.titleCase(key), credit: 0, debit: 0, count: 0 }; + map.set(key, line); + } + this.addTo(line, r); + } + return Array.from(map.values()).sort((a, b) => b.credit + b.debit - (a.credit + a.debit)); + } + + /** Adds one day-book row onto a summary line, on the side the row belongs to. */ + private addTo(line: SummaryLine, row: any): void { + const amt = Number(row.amount || 0); + if (row.side === 'CR') { + line.credit += amt; + } else { + line.debit += amt; + } + line.count += 1; + } + + /** DD CASH, INTEREST_POSTING and the like, as a person would read them. */ + kindLabel(raw: string | null | undefined): string { + if (!raw) { + return '—'; + } + return raw + .toString() + .replace(/_/g, ' ') + .toLowerCase() + .replace(/\b\w/g, (c) => c.toUpperCase()); + } + + private titleCase(raw: string): string { + return this.kindLabel(raw); + } + + get txnCount(): number { + return this.approvedRows.length; + } + + /** More than one drawer in view means a supervisor is reading, not a cashier. */ + get supervising(): boolean { + return this.cashierPositions.length > 1 || (!this.scopedToCashier && this.cashierPositions.length > 0); + } + + get cashierPositionTotal(): number { + return this.cashierPositions.reduce((s, p) => s + p.totalAmount, 0); + } + + /** + * Each person who handled the day's work, with their own credit, debit and net. + * + * The clerk signing the day off is verifying other people's work, so the sheet has to say whose. + * Read off the day book's own handledBy, which every register stamps. + */ + get byUser(): SummaryLine[] { + const map = new Map(); + for (const r of this.approvedRows) { + const key = (r.handledBy || 'Unattributed').toString(); + let line = map.get(key); + if (!line) { + line = { label: key, credit: 0, debit: 0, count: 0 }; + map.set(key, line); + } + this.addTo(line, r); + } + return Array.from(map.values()).sort((a, b) => b.credit + b.debit - (a.credit + a.debit)); + } + + get summaryCreditTotal(): number { + return this.sectionLines.reduce((s, l) => s + l.credit, 0); + } + + get summaryDebitTotal(): number { + return this.sectionLines.reduce((s, l) => s + l.debit, 0); + } + + // ── Section 2 ── + + /** Everything that came into the drawer today, over the counter and from another cashier. */ + get totalReceived(): number { + return this.cashDeposits + this.interCashierIn; + } + + /** Everything that left it: paid to members, handed to the vault, passed to another cashier. */ + get totalPaidOut(): number { + return this.cashWithdrawals + this.settledOut + this.interCashierOut; + } + + /** What should be on the desk: the float, plus what came in, less what went out. */ + get expectedClosing(): number { + return this.openingFloat + this.totalReceived - this.totalPaidOut; + } + + /** + * Whether an expected-against-held comparison can honestly be made. + * + * Only for a single cashier, and only when the server sent that cashier's day legs. Both halves + * then describe the same thing: today's legs, against a drawer that carries nothing forward + * because a cashier draws in the morning and hands back at close. + * + * Never branch-wide. The branch register's expected cash is *today's* arithmetic — yesterday's + * closing, plus today's deposits, less today's withdrawals — while the vault and drawer positions + * are running totals accumulated since the system went live. Subtracting one from the other + * compares an afternoon against a lifetime: it reported ₹2,11,932 extra on a day when ₹100 came + * in and ₹100 went out. There is no branch-wide running total to compare against, so the sheet + * shows the branch its movement and each drawer's position, and states no difference at all + * rather than a confident wrong one. + */ + get canReconcile(): boolean { + return this.scopedToCashier && this.hasDayLegs; + } + + /** The cash the system believes this cashier is holding. */ + get actualCash(): number | null { + return this.scopedToCashier ? this.drawersTotal : null; + } + + /** What today's legs say should be in the drawer. */ + get expectedCash(): number | null { + return this.canReconcile ? this.expectedClosing : null; + } + + /** Held less expected, for one cashier's own day. Null wherever that comparison is not honest. */ + get variance(): number | null { + const expected = this.expectedCash; + if (expected === null || this.actualCash === null) { + return null; + } + return this.actualCash - expected; + } + + /** Vaults and drawers together — what the branch is holding, per the system. */ + get branchHeldTotal(): number | null { + if (this.drawersTotal === null && this.vaultTotal === null) { + return null; + } + return Number(this.drawersTotal ?? 0) + Number(this.vaultTotal ?? 0); + } + + /** Today's net cash movement across the branch: what came in less what went out. */ + get branchNetMovement(): number { + return Number(this.branchDeposits ?? 0) - Number(this.branchWithdrawals ?? 0); + } + + /** True only when a count exists and it agrees to the paisa. */ + get varianceOk(): boolean { + return this.variance !== null && Math.abs(this.variance) < 0.005; + } + + get varianceClass(): string { + if (this.variance === null) { + return ''; + } + return this.varianceOk ? 'eod-ok' : 'eod-bad'; + } + + // ── Section 3 ── + + /** Entries still waiting on somebody, which is what a day cannot be closed over. */ + get pendingRows(): any[] { + return this.rows.filter((r) => r.status === 'PENDING' || r.status === 'SUBMITTED'); + } + + /** Cash entries whose paper slip nobody has checked against the screen. */ + get unverifiedRows(): any[] { + return this.approvedRows.filter((r) => !r.verifyStatus); + } + + /** A slip that was checked and did not agree with what was keyed in. */ + get mismatchRows(): any[] { + return this.rows.filter((r) => r.verifyStatus === 'MISMATCH' || r.verifyStatus === 'DISPUTED'); + } + + /** + * Whether the day is ready to close. + * + * Nothing pending, no slip disagreeing, and — where a cash comparison was actually made — the + * cash agreeing. A branch-wide reading makes no comparison, so it is not held to one: requiring + * `varianceOk` there meant the sheet could never say a day was clear however clean it was. + */ + get allClear(): boolean { + if (this.pendingRows.length > 0 || this.mismatchRows.length > 0) { + return false; + } + return this.variance === null || this.varianceOk; + } + + reload(): void { + this.load(); + } + + print(): void { + window.print(); + } +} diff --git a/src/app/cashier/my-work/my-work.component.html b/src/app/cashier/my-work/my-work.component.html new file mode 100644 index 000000000..3ff8b6343 --- /dev/null +++ b/src/app/cashier/my-work/my-work.component.html @@ -0,0 +1,203 @@ + + +
+
+
+
MY WORK
+

My Vouchers

+

+ Everything you entered and everything you approved — receipts, payments and journals in one list. +

+
+
+ + + +
+
+ + @if (error) { +
{{ error }}
+ } + + @if (loading) { +
Loading…
+ } + + @if (report && !loading) { +
+ {{ report.displayName || report.username }} + · + {{ report.from | date: 'dd MMM yyyy' }} + @if (report.from !== report.to) { + to {{ report.to | date: 'dd MMM yyyy' }} + } +
+ + +
+
+
Received
+
₹{{ report.receivedTotal | number: '1.2-2' }}
+
+
+
Payments
+
₹{{ report.paymentTotal | number: '1.2-2' }}
+
+
+
Journal
+
₹{{ report.journalTotal | number: '1.2-2' }}
+
+
+
Entered
+
{{ report.raisedCount }}
+
+
+
Approved
+
{{ report.verifiedCount }}
+
+
+ + @if (report.count > 0) { +
+ + + @if (isFiltered) { + + } +
+ } + + @if (rows.length === 0) { +
+ @if (report.count === 0) { + Nothing recorded against you for this period. + } @else { + No vouchers match these filters — {{ report.count }} hidden. + } +
+ } @else { +
+ Showing {{ rows.length }} + @if (isFiltered) { + of {{ report.count }} + } + {{ rows.length === 1 ? 'voucher' : 'vouchers' }} + Total ₹{{ filteredTotal | number: '1.2-2' }} +
+ + + @for (g of groups; track g.key) { +
+
+ {{ g.label }} + {{ g.hint }} + + {{ g.rows.length }} {{ g.rows.length === 1 ? 'voucher' : 'vouchers' }} + ₹{{ g.total | number: '1.2-2' }} + +
+
+ + + + + + + + + + + + + + + + @for (r of g.rows; track r.voucherNo) { + + + + + + + + + + + + + } + +
VoucherSideTypeDebitCreditAmountMy partStatusTime
{{ r.voucherNo }} + {{ sideLabel(r.side) }} + {{ r.voucherType }} + {{ r.debitParty || '—' }} + @if (r.debitAccount) { + {{ r.debitAccount }} + } + + {{ r.creditParty || '—' }} + @if (r.creditAccount) { + {{ r.creditAccount }} + } + @if (r.extraLines > 0) { + +{{ r.extraLines }} more + } + ₹{{ r.amount | number: '1.2-2' }} + + {{ roleIcon(r.myRole) }} + {{ roleLabel(r.myRole) }} + + + @if (r.myRole === 'VERIFIED' && r.raisedBy) { + by {{ r.raisedBy }} + } + + + {{ statusLabel(r.verificationStatus) }} + + {{ r.createdAt | date: 'HH:mm' }}
+
+
+ } + } + } +
diff --git a/src/app/cashier/my-work/my-work.component.scss b/src/app/cashier/my-work/my-work.component.scss new file mode 100644 index 000000000..674708b5e --- /dev/null +++ b/src/app/cashier/my-work/my-work.component.scss @@ -0,0 +1,294 @@ +/** + * 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/. + */ + +/* Who and when, above the day's figures. */ +.mw-who { + font-size: 13px; + color: #475569; + margin: 0 0 12px; + + strong { + color: #0f172a; + } + + &__sep { + margin: 0 6px; + color: #cbd5e1; + } +} + +/* The day at a glance: the two sides a branch balances, then what the user's part was. */ +.mw-tiles { + display: grid; + grid-template-columns: repeat(auto-fit, minmax(140px, 1fr)); + gap: 10px; + margin-bottom: 16px; +} + +.mw-tile { + background: #fff; + border: 1px solid #e2e8f0; + border-radius: 6px; + padding: 10px 12px; + + &__label { + font-size: 11px; + text-transform: uppercase; + letter-spacing: 0.04em; + color: #64748b; + margin-bottom: 4px; + } + + &__value { + font-size: 18px; + font-weight: 600; + color: #0f172a; + + /* Received green, paid-out red — the convention a passbook already uses, so the two totals + are told apart without reading the labels. */ + &--cr { + color: #15803d; + } + + &--dr { + color: #b91c1c; + } + } +} + +.mw-filters { + display: flex; + flex-wrap: wrap; + align-items: flex-end; + gap: 10px; + margin-bottom: 12px; +} + +.mw-count { + font-size: 12px; + color: #64748b; + margin-bottom: 8px; + + &__total { + float: right; + font-weight: 600; + color: #0f172a; + } +} + +.mw-table { + font-size: 13px; +} + +/* Money cells use the shared `.num` from cashier-shared.scss, which right-aligns and + tabular-figures the heading as well as the cell. A local class only reached the cells, leaving + every heading left-aligned over right-aligned figures. */ + +.mw-vno { + font-family: ui-monospace, SFMono-Regular, Menlo, monospace; + font-size: 12px; + white-space: nowrap; +} + +.mw-type { + font-size: 11px; + text-transform: uppercase; + letter-spacing: 0.03em; + color: #64748b; + white-space: nowrap; +} + +.mw-time { + white-space: nowrap; + color: #64748b; +} + +/* The account number or the maker's name, under the party it belongs to. */ +.mw-acct { + display: block; + font-size: 11px; + color: #94a3b8; + font-family: ui-monospace, SFMono-Regular, Menlo, monospace; +} + +.mw-more { + display: inline-block; + margin-left: 6px; + font-size: 10px; + padding: 1px 5px; + border-radius: 8px; + background: #f1f5f9; + color: #64748b; +} + +.mw-badge { + display: inline-block; + font-size: 10px; + font-weight: 600; + text-transform: uppercase; + letter-spacing: 0.04em; + padding: 2px 7px; + border-radius: 10px; + white-space: nowrap; + + &--CR { + background: #dcfce7; + color: #15803d; + } + + &--DR { + background: #fee2e2; + color: #b91c1c; + } + + &--JV { + background: #e0e7ff; + color: #4338ca; + } +} + +/* Whose work the row is, marked on the row itself. + A coloured left edge and a faint tint, rather than a different text colour: the eye finds the + edge while scanning a column of figures, which is how a day's list is actually read. Kept faint + so the amounts stay the thing being read. */ +.mw-row { + border-left: 3px solid transparent; + + &--RAISED { + border-left-color: #86efac; + } + + /* Somebody else's entry that this person passed. The row a reviewer came looking for, so it + carries the darker of the two greens. */ + &--VERIFIED { + border-left-color: #15803d; + background: #f0fdf4; + } + + /* Entered and approved by the same person. Legitimate in a small branch, but the one combination + an auditor stops on, so it is the only row given a warm tint. */ + &--RAISED_AND_VERIFIED { + border-left-color: #d97706; + background: #fffbf3; + } +} + +/* One section per part played. The heading carries the section's own count and total, so a maker's + tally and a checker's are each readable without adding rows up. */ +.mw-group { + margin-bottom: 16px; + + /* Two shades of one colour rather than two colours: entering and approving are stages of the + same piece of work, and the darker edge reads as the later one. */ + border-left: 3px solid #86efac; + + /* Same shades the row edges use, so a section and its rows read as one thing. */ + &--VERIFIED { + border-left-color: #15803d; + } + + &--RAISED_AND_VERIFIED { + border-left-color: #d97706; + } + + &__head { + display: flex; + align-items: baseline; + gap: 10px; + padding: 10px 14px; + border-bottom: 1px solid #e2e8f0; + background: #f8fafc; + + strong { + font-size: 13px; + color: #0f172a; + } + } + + &__hint { + font-size: 11px; + color: #64748b; + } + + &__count { + margin-left: auto; + font-size: 11px; + color: #64748b; + white-space: nowrap; + } + + &__total { + margin-left: 8px; + font-weight: 700; + color: #0f172a; + } +} + +/* Where the voucher stands. Three states, because there are three things a reader does: nothing + (done), chase it (pending), or look into it (bad). */ +.mw-status { + font-size: 11px; + font-weight: 600; + white-space: nowrap; + + /* Approved, posted — settled but nothing handed over. Lighter than 'paid' but still dark enough + to read as body text; a pale green on white fails at this size. */ + &--done { + color: #16a34a; + } + + /* Cash actually gone. Darker and filled, the same way the day book marks it. */ + &--paid { + background: #15803d; + color: #fff; + padding: 2px 8px; + border-radius: 999px; + } + + &--pending { + color: #b45309; + } + + &--bad { + color: #b91c1c; + } +} + +/* The part played, as a small pill with its own glyph. */ +.mw-part { + display: inline-flex; + align-items: center; + gap: 4px; + font-size: 11px; + font-weight: 600; + padding: 2px 8px 2px 6px; + border-radius: 10px; + white-space: nowrap; + + &__icon { + font-size: 13px; + width: 13px; + height: 13px; + line-height: 13px; + } + + &--RAISED { + background: #dcfce7; + color: #166534; + } + + &--VERIFIED { + background: #15803d; + color: #fff; + } + + &--RAISED_AND_VERIFIED { + background: #fef3c7; + color: #b45309; + } +} diff --git a/src/app/cashier/my-work/my-work.component.ts b/src/app/cashier/my-work/my-work.component.ts new file mode 100644 index 000000000..c4644ba0b --- /dev/null +++ b/src/app/cashier/my-work/my-work.component.ts @@ -0,0 +1,288 @@ +/** + * 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 { catchError, of } from 'rxjs'; +import { CashierService } from '../cashier.service'; + +/** What the signed-in user's part in a voucher was. */ +export type MyRole = 'RAISED' | 'VERIFIED' | 'RAISED_AND_VERIFIED'; + +/** Which register the voucher was numbered in: receipt, payment, or journal. */ +export type VoucherSide = 'CR' | 'DR' | 'JV'; + +interface MyWorkRow { + voucherNo: string; + side: VoucherSide | null; + voucherType: string | null; + amount: number | null; + createdAt: string | null; + raisedBy: string | null; + branchName: string | null; + verificationStatus: string | null; + verifiedByName: string | null; + verifiedAt: string | null; + debitParty: string | null; + debitAccount: string | null; + creditParty: string | null; + creditAccount: string | null; + /** Sides beyond the one debit and one credit shown — a deposit that also took a charge. */ + extraLines: number; + myRole: MyRole; +} + +interface MyWorkReport { + from: string; + to: string; + username: string; + displayName: string; + vouchers: MyWorkRow[]; + count: number; + raisedCount: number; + verifiedCount: number; + receivedTotal: number; + paymentTotal: number; + journalTotal: number; +} + +/** + * One person's day at the branch: what they entered and what they approved, in one list. + * + * The Received and Payment registers answer "what moved today" across everyone. This answers the + * question each member of staff asks at a close — "what did I do today" — which those two cannot, + * because a checker's approval raises no voucher of its own and leaves its only trace on the + * verification row. + */ +@Component({ + selector: 'mifosx-my-work', + templateUrl: './my-work.component.html', + styleUrls: [ + '../cashier-shared.scss', + './my-work.component.scss' + ], + standalone: true, + imports: [ + CommonModule, + FormsModule, + MatIconModule + ] +}) +export class MyWorkComponent implements OnInit { + private service = inject(CashierService); + + /** Day is the working default; a month is what a close or an audit asks for. */ + period: 'DAY' | 'MONTH' = 'DAY'; + anchor = this.todayIso(); + + /** Narrow to one side, or to the part the user played. Both applied in the browser. */ + sideFilter: '' | VoucherSide = ''; + roleFilter: '' | MyRole = ''; + + report: MyWorkReport | null = null; + loading = false; + error: string | null = null; + + ngOnInit(): void { + this.load(); + } + + load(): void { + this.loading = true; + this.error = null; + + const { from, to } = this.range(); + this.service + .myVoucherWork(from, to) + .pipe( + catchError((err: any) => { + this.error = + err?.error?.errors?.[0]?.defaultUserMessage ?? + err?.error?.developerMessage ?? + 'Your work for this period could not be loaded.'; + return of(null); + }) + ) + .subscribe((res: MyWorkReport | null) => { + this.loading = false; + this.report = res; + }); + } + + /** + * The rows after the two on-screen filters. + * + * Filtered here rather than at the server: the day's own work is a small list, and narrowing it + * without a round trip is what makes the filters feel like part of reading the page. + */ + get rows(): MyWorkRow[] { + const all = this.report?.vouchers ?? []; + return all.filter( + (r) => (!this.sideFilter || r.side === this.sideFilter) && (!this.roleFilter || r.myRole === this.roleFilter) + ); + } + + /** + * The rows split by the part the user played, each group with its own total. + * + * Entering a request and passing somebody else's are different jobs answerable to different + * people, and a branch reads them separately: "what did I put through" is a maker's own tally, + * "what did I let through" is a checker's. One list mixed them, so neither total could be read + * off the page — a manager who entered one voucher and approved twenty saw twenty-one rows and + * had to sort them by eye. + * + * An empty group is dropped rather than shown as a heading over nothing, so a clerk who never + * approves anything sees one section and not a permanently empty second one. + */ + get groups(): { key: MyRole; label: string; hint: string; rows: MyWorkRow[]; total: number }[] { + const order: { key: MyRole; label: string; hint: string }[] = [ + { key: 'RAISED', label: 'Entered by me', hint: 'Requests and postings you put through' }, + { key: 'VERIFIED', label: 'Approved by me', hint: "Other people's work you passed" }, + { + key: 'RAISED_AND_VERIFIED', + label: 'Entered & approved by me', + hint: 'Both roles on the same voucher — kept separate for the record' + } + ]; + return order + .map((g) => { + const rows = this.rows.filter((r) => r.myRole === g.key); + return { ...g, rows, total: rows.reduce((sum, r) => sum + Number(r.amount ?? 0), 0) }; + }) + .filter((g) => g.rows.length > 0); + } + + /** True when a filter is hiding part of what was loaded, so the count can say so. */ + get isFiltered(): boolean { + return !!this.sideFilter || !!this.roleFilter; + } + + clearFilters(): void { + this.sideFilter = ''; + this.roleFilter = ''; + } + + /** The total of the rows on screen, which is not the day's total once a filter is on. */ + get filteredTotal(): number { + return this.rows.reduce((sum, r) => sum + Number(r.amount ?? 0), 0); + } + + sideLabel(side: VoucherSide | null): string { + if (side === 'CR') { + return 'Received'; + } + if (side === 'DR') { + return 'Payment'; + } + return 'Journal'; + } + + roleLabel(role: MyRole): string { + if (role === 'RAISED') { + return 'Entered'; + } + if (role === 'VERIFIED') { + return 'Approved'; + } + return 'Entered & approved'; + } + + /** + * Where the voucher stands, in words a branch uses. + * + * A voucher with no status at all is not unknown — it is a posting that never went through a + * request: interest the nightly run credited, a charge taken from a balance. Those are done the + * moment they exist, so they read as Posted rather than as a blank a reader has to interpret. + */ + statusLabel(status: string | null): string { + const s = (status ?? '').toUpperCase(); + if (!s) { + return 'Posted'; + } + if (s === 'WITHDRAWN') { + return 'Paid out'; + } + if (s === 'APPROVED') { + return 'Approved'; + } + if (s === 'PENDING') { + return 'Pending'; + } + if (s === 'REJECTED') { + return 'Rejected'; + } + if (s === 'CLOSED') { + return 'Closed'; + } + // OK / MISMATCH come from a physical cash verification rather than a request. + return s.charAt(0) + s.slice(1).toLowerCase(); + } + + /** + * Groups the statuses by what a reader does about them. + * + * 'paid' is kept apart from 'done' because approved and paid out are different facts: one is a + * promise, the other is money gone. Sharing a colour hid exactly the thing somebody opens this + * page to check. + */ + statusClass(status: string | null): string { + const s = (status ?? '').toUpperCase(); + if (s === 'PENDING') { + return 'pending'; + } + if (s === 'REJECTED' || s === 'MISMATCH') { + return 'bad'; + } + if (s === 'WITHDRAWN' || s === 'COMPLETED' || s === 'CLOSED') { + return 'paid'; + } + return 'done'; + } + + /** + * A glyph per role, so the part played reads before the words do. + * + * A pen for an entry the user made, a tick for one they approved, and a warning for both on the + * same voucher — which a small branch does legitimately, and an auditor should still see marked. + */ + roleIcon(role: MyRole): string { + if (role === 'RAISED') { + return 'edit'; + } + if (role === 'VERIFIED') { + return 'check_circle'; + } + return 'warning'; + } + + /** The period the anchor date names: that day, or the whole calendar month around it. */ + private range(): { from: string; to: string } { + if (this.period === 'DAY') { + return { from: this.anchor, to: this.anchor }; + } + const [ + y, + m + ] = this.anchor.split('-').map(Number); + const first = `${y}-${String(m).padStart(2, '0')}-01`; + // Day 0 of the next month is the last day of this one, which avoids a table of month lengths + // and gets February right in a leap year. + const last = new Date(y, m, 0); + const lastIso = `${last.getFullYear()}-${String(last.getMonth() + 1).padStart(2, '0')}-${String( + last.getDate() + ).padStart(2, '0')}`; + return { from: first, to: lastIso }; + } + + private todayIso(): string { + const d = new Date(); + return `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, '0')}-${String(d.getDate()).padStart(2, '0')}`; + } +} diff --git a/src/app/cashier/voucher-report/voucher-report.component.html b/src/app/cashier/voucher-report/voucher-report.component.html index 0fac35882..73ac94478 100644 --- a/src/app/cashier/voucher-report/voucher-report.component.html +++ b/src/app/cashier/voucher-report/voucher-report.component.html @@ -170,6 +170,15 @@
Credit Amount UserLedger dateTime
{{ row.amount | number: '1.2-2' }} {{ row.username || '—' }} + {{ row.ledgerDate ? (row.ledgerDate | date: 'dd MMM yyyy') : '—' }} + @if (row.postedAfterClose) { + after close + } + {{ row.createdAt | date: 'dd MMM, HH:mm' }}
Type - {{ edit.productType }} + {{ productTypeLabel(edit.productType) }} + Request + + {{ edit.actionName }} + Actions - + + + + + + + + + + + + + + + + + + + + +
FieldCurrentProposed
{{ row.label }}{{ row.original }} + {{ row.proposed }} + {{ row.proposed }} +
+ +

No differences found.

+
+
+ + + + + + diff --git a/src/app/tasks/checker-inbox-and-tasks-tabs/pending-savings-products/product-edit-diff-dialog/product-edit-diff-dialog.component.scss b/src/app/tasks/checker-inbox-and-tasks-tabs/pending-savings-products/product-edit-diff-dialog/product-edit-diff-dialog.component.scss new file mode 100644 index 000000000..9e943b7fd --- /dev/null +++ b/src/app/tasks/checker-inbox-and-tasks-tabs/pending-savings-products/product-edit-diff-dialog/product-edit-diff-dialog.component.scss @@ -0,0 +1,194 @@ +/** + * 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/. + */ + +$border: #e2e8f0; +$bg-page: #f8fafc; +$text-dark: #1e293b; +$text-mid: #475569; +$text-soft: #94a3b8; +$changed: #fef9c3; +$changed-border: #fde047; +$new-text: #15803d; + +.diff-dialog { + display: flex; + flex-direction: column; + min-width: 560px; + max-width: 820px; +} + +// ── Header ─────────────────────────────────────────────────────────────────── +.diff-header { + display: flex; + align-items: flex-start; + justify-content: space-between; + flex-wrap: wrap; + gap: 8px; + padding: 16px 20px 12px; + border-bottom: 1px solid $border; + background: $bg-page; + + &__title { + display: flex; + align-items: center; + gap: 8px; + font-size: 15px; + font-weight: 700; + color: $text-dark; + } + + &__icon { + font-size: 18px; + color: #2563eb; + } + + &__name { + font-size: 13px; + font-weight: 500; + color: $text-mid; + border-left: 1px solid $border; + padding-left: 8px; + } + + &__meta { + display: flex; + align-items: center; + gap: 10px; + } +} + +.diff-badge { + display: inline-block; + padding: 2px 10px; + border-radius: 20px; + font-size: 12px; + font-weight: 600; + + &--changed { + background: #fef9c3; + color: #854d0e; + border: 1px solid #fde047; + } + + &--none { + background: #f0fdf4; + color: #166534; + border: 1px solid #86efac; + } +} + +.diff-toggle { + font-size: 12px; + font-weight: 600; + padding: 4px 12px; + border: 1px solid $border; + border-radius: 6px; + background: #fff; + color: $text-mid; + cursor: pointer; + transition: + background 0.15s, + color 0.15s; + + &:hover { + background: #f1f5f9; + color: $text-dark; + } +} + +// ── Body ───────────────────────────────────────────────────────────────────── +.diff-body { + max-height: 60vh; + overflow-y: auto; + padding: 0 !important; +} + +.diff-table { + width: 100%; + border-collapse: collapse; + font-size: 13px; + + th, + td { + padding: 9px 14px; + text-align: left; + border-bottom: 1px solid $border; + vertical-align: top; + } + + th { + font-size: 11px; + font-weight: 700; + text-transform: uppercase; + letter-spacing: 0.06em; + color: $text-soft; + background: $bg-page; + position: sticky; + top: 0; + z-index: 1; + } + + &__label { + width: 28%; + font-weight: 600; + color: $text-mid; + } + + &__val { + width: 36%; + color: $text-dark; + word-break: break-word; + + &--orig { + background: #fff; + } + + &--prop { + background: #fff; + } + } +} + +.diff-row--changed { + background: $changed !important; + + td { + border-bottom-color: $changed-border; + } + + .diff-table__val--orig { + background: #fef2f2; + color: #b91c1c; + text-decoration: line-through; + text-decoration-color: #fca5a5; + } + + .diff-table__val--prop { + background: #f0fdf4; + } +} + +.diff-new { + color: $new-text; + font-weight: 600; +} + +.diff-empty { + padding: 32px; + text-align: center; + color: $text-soft; + font-style: italic; + font-size: 14px; +} + +// ── Actions ─────────────────────────────────────────────────────────────────── +.diff-actions { + padding: 12px 20px; + border-top: 1px solid $border; + background: $bg-page; +} diff --git a/src/app/tasks/checker-inbox-and-tasks-tabs/pending-savings-products/product-edit-diff-dialog/product-edit-diff-dialog.component.ts b/src/app/tasks/checker-inbox-and-tasks-tabs/pending-savings-products/product-edit-diff-dialog/product-edit-diff-dialog.component.ts new file mode 100644 index 000000000..521a48bea --- /dev/null +++ b/src/app/tasks/checker-inbox-and-tasks-tabs/pending-savings-products/product-edit-diff-dialog/product-edit-diff-dialog.component.ts @@ -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/. + */ + +import { Component, inject } from '@angular/core'; +import { MAT_DIALOG_DATA, MatDialogRef } from '@angular/material/dialog'; +import { MatDialogModule } from '@angular/material/dialog'; +import { MatButtonModule } from '@angular/material/button'; +import { NgClass } from '@angular/common'; + +export interface ProductEditDiffDialogData { + productName: string; + original: Record; + proposed: Record; +} + +export interface DiffRow { + label: string; + original: string; + proposed: string; + changed: boolean; +} + +@Component({ + selector: 'mifosx-product-edit-diff-dialog', + templateUrl: './product-edit-diff-dialog.component.html', + styleUrls: ['./product-edit-diff-dialog.component.scss'], + imports: [ + MatDialogModule, + MatButtonModule, + NgClass + ] +}) +export class ProductEditDiffDialogComponent { + dialogRef = inject(MatDialogRef); + data: ProductEditDiffDialogData = inject(MAT_DIALOG_DATA); + + diffRows: DiffRow[] = []; + showChangedOnly = false; + + get visibleRows(): DiffRow[] { + return this.showChangedOnly ? this.diffRows.filter((r) => r.changed) : this.diffRows; + } + + get changedCount(): number { + return this.diffRows.filter((r) => r.changed).length; + } + + constructor() { + this.diffRows = this.buildDiff(this.data.original, this.data.proposed); + } + + private buildDiff(original: Record, proposed: Record): DiffRow[] { + const allKeys = new Set([ + ...Object.keys(original), + ...Object.keys(proposed) + ]); + const rows: DiffRow[] = []; + allKeys.forEach((key) => { + const orig = original[key]; + const prop = proposed[key]; + const origStr = this.format(orig); + const propStr = this.format(prop); + rows.push({ + label: this.labelFor(key), + original: origStr, + proposed: propStr, + changed: origStr !== propStr + }); + }); + return rows.sort((a, b) => (a.changed === b.changed ? 0 : a.changed ? -1 : 1)); + } + + private format(value: any): string { + if (value === null || value === undefined) return '—'; + if (typeof value === 'boolean') return value ? 'Yes' : 'No'; + if (typeof value === 'object') return JSON.stringify(value); + return String(value); + } + + private labelFor(key: string): string { + return key + .replace(/([a-z])([A-Z])/g, '$1 $2') + .replace(/[_\-.]+/g, ' ') + .replace(/\b\w/g, (c) => c.toUpperCase()); + } + + close(): void { + this.dialogRef.close(); + } +} diff --git a/src/app/tasks/checker-inbox-and-tasks-tabs/rejected-account/rejected-account.component.ts b/src/app/tasks/checker-inbox-and-tasks-tabs/rejected-account/rejected-account.component.ts index 36b86278b..10aba685e 100644 --- a/src/app/tasks/checker-inbox-and-tasks-tabs/rejected-account/rejected-account.component.ts +++ b/src/app/tasks/checker-inbox-and-tasks-tabs/rejected-account/rejected-account.component.ts @@ -31,7 +31,6 @@ import { STANDALONE_SHARED_IMPORTS } from 'app/standalone-shared.module'; styleUrls: ['./rejected-account.component.scss'], imports: [ ...STANDALONE_SHARED_IMPORTS, - FaIconComponent, MatTable, MatColumnDef, MatHeaderCellDef, diff --git a/src/app/tasks/checker-inbox-and-tasks-tabs/terminated-account/terminated-account.component.ts b/src/app/tasks/checker-inbox-and-tasks-tabs/terminated-account/terminated-account.component.ts index e74e61ec2..7e0d2d405 100644 --- a/src/app/tasks/checker-inbox-and-tasks-tabs/terminated-account/terminated-account.component.ts +++ b/src/app/tasks/checker-inbox-and-tasks-tabs/terminated-account/terminated-account.component.ts @@ -31,7 +31,6 @@ import { STANDALONE_SHARED_IMPORTS } from 'app/standalone-shared.module'; styleUrls: ['./terminated-account.component.scss'], imports: [ ...STANDALONE_SHARED_IMPORTS, - FaIconComponent, MatTable, MatColumnDef, MatHeaderCellDef, 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 433e1d7da..c6144a89a 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 @@ -74,12 +74,12 @@ - Pending Product Edits + Product Approval Product Approval list from the two pending-request sources the platform + * already has. No new workflow or table is involved; both feeds are read as-is. + * + * 1. Pending product edits (m_saving_product_temp), via /{producttype}/pending. UPDATE only, for the + * five product types whose PUT handler stages the change instead of logging a command. + * 2. Native maker-checker entries (m_portfolio_command_source), via /makercheckers. This is where + * product CREATE and DELETE land - and LOANPRODUCT/CHARGE requests of every action, since those + * were never diverted to the staging table. + * + * Rows staged in (1) also have an audit-only command-source row that surfaces in (2), so the + * maker-checker feed drops them - see isTempTableProductEdit. Each source keeps its own approve/reject + * path in the component; the merge here is presentational only. */ @Injectable() export class GetPendingSavingsProducts implements Resolve { private productsService = inject(ProductsService); + private tasksService = inject(TasksService); resolve(): Observable { return forkJoin({ @@ -27,15 +39,73 @@ export class GetPendingSavingsProducts implements Resolve { current: this.productsService.getPendingCurrentProductEdits().pipe(catchError(() => of([]))), share: this.productsService.getPendingShareProductEdits().pipe(catchError(() => of([]))), fd: this.productsService.getPendingFixedDepositProductEdits().pipe(catchError(() => of([]))), - rd: this.productsService.getPendingRecurringDepositProductEdits().pipe(catchError(() => of([]))) + rd: this.productsService.getPendingRecurringDepositProductEdits().pipe(catchError(() => of([]))), + // includeJson so a CREATE row - which has no resourceId, and therefore no product to look up - + // can still show the submitted product name. + makerChecker: this.tasksService.getMakerCheckerData({ includeJson: true }).pipe(catchError(() => of([]))) }).pipe( map((results) => [ - ...(results.savings || []).map((e: any) => ({ ...e, productType: 'SAVINGS' })), - ...(results.current || []).map((e: any) => ({ ...e, productType: 'CURRENT' })), - ...(results.share || []).map((e: any) => ({ ...e, productType: 'SHARE' })), - ...(results.fd || []).map((e: any) => ({ ...e, productType: 'FD' })), - ...(results.rd || []).map((e: any) => ({ ...e, productType: 'RD' })) + ...this.asEditRows(results.savings, 'SAVINGS'), + ...this.asEditRows(results.current, 'CURRENT'), + ...this.asEditRows(results.share, 'SHARE'), + ...this.asEditRows(results.fd, 'FD'), + ...this.asEditRows(results.rd, 'RD'), + ...this.asMakerCheckerRows(results.makerChecker) ]) ); } + + /** Staged product edits, unchanged apart from the source/action tags the merged table needs. */ + private asEditRows(edits: any[], productType: string): any[] { + return (edits || []).map((edit: any) => ({ + ...edit, + productType, + source: 'PENDING_EDIT', + actionName: 'UPDATE' + })); + } + + /** + * Normalises maker-checker entries onto the same field names the pending-edit table already renders, + * so the existing template keeps working for both sources. + */ + private asMakerCheckerRows(entries: any): any[] { + const items = Array.isArray(entries) ? entries : (entries?.pageItems ?? []); + return items + .filter((entry: any) => isProductEntity(entry?.entityName)) + .filter((entry: any) => !isTempTableProductEdit(entry?.entityName, entry?.actionName)) + .map((entry: any) => ({ + id: entry.id, + source: 'MAKER_CHECKER', + entityName: entry.entityName, + actionName: (entry.actionName ?? '').toUpperCase(), + productType: PRODUCT_ENTITY_TYPE_LABEL[(entry.entityName ?? '').toUpperCase()] ?? entry.entityName, + savingsProductId: entry.resourceId ?? null, + savingsProductName: this.resolveName(entry), + makerUsername: entry.maker, + // /makercheckers returns madeOnDate as a Unix timestamp in seconds. + // new Date(seconds) misreads it as milliseconds → January 1970. + // Normalise to milliseconds here, matching DatetimeFormatPipe's unix() branch. + createdOn: + typeof entry.madeOnDate === 'number' && entry.madeOnDate < 1e12 ? entry.madeOnDate * 1000 : entry.madeOnDate, + status: 'PENDING' + })); + } + + /** + * A CREATE has no resourceId - the product does not exist until approval - so the name comes from + * the submitted command payload. Falls back to the entity/id when the payload is absent (a DELETE + * carries none) or unparseable. + */ + private resolveName(entry: any): string { + try { + const payload = entry?.commandAsJson ? JSON.parse(entry.commandAsJson) : null; + if (payload?.name) { + return payload.name; + } + } catch { + /* fall through to the identifier below */ + } + return entry?.resourceId != null ? `${entry.entityName} #${entry.resourceId}` : entry.entityName; + } } diff --git a/src/app/tasks/product-entities.ts b/src/app/tasks/product-entities.ts new file mode 100644 index 000000000..90a5457f6 --- /dev/null +++ b/src/app/tasks/product-entities.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/. + */ + +/** + * Single source of truth for which maker-checker entities belong to Approvals -> Product Approval + * rather than the generic Checker Inbox. + * + * These are the entity names the application already treats as products: the five that own a + * /products/... view route and a pending-edit staging row, plus LOANPRODUCT and CHARGE, which are + * product-module entities whose maker-checker requests run entirely through the native pipeline. + * Deliberately excluded are the product-module *configuration* entities (tax groups, floating rates, + * delinquency buckets, product mixes, collaterals) - they stay in the Checker Inbox. + */ +export const PRODUCT_ENTITIES: readonly string[] = [ + 'LOANPRODUCT', + 'SAVINGSPRODUCT', + 'CURRENTPRODUCT', + 'SHAREPRODUCT', + 'FIXEDDEPOSITPRODUCT', + 'RECURRINGDEPOSITPRODUCT', + 'CHARGE' +]; + +/** + * The subset whose UPDATEs bypass the native command-source pipeline: the PUT handler calls + * submitEditForApproval, which stages the change in m_saving_product_temp and writes an *audit-only* + * command-source row alongside it. That row shows up in /makercheckers like any pending command, but + * approving it there replays the original handler and never clears the staging lock - so it must be + * actioned through /{producttype}/{id}/approve instead. + * + * Two consequences, both relied on below: + * - Product Approval must drop these rows from its maker-checker feed, since the pending-edit feed + * already supplies them (otherwise every product edit would be listed twice). + * - View Checker Inbox must not offer approve/reject on them. + */ +export const TEMP_TABLE_PRODUCT_ENTITIES: readonly string[] = [ + 'SAVINGSPRODUCT', + 'CURRENTPRODUCT', + 'SHAREPRODUCT', + 'FIXEDDEPOSITPRODUCT', + 'RECURRINGDEPOSITPRODUCT' +]; + +/** + * Account entities staged the same way (savings/current account Nominee and Digital-Feature edits). + * Not products - they belong to Approvals -> Pending Account Edits - but their UPDATE rows have to + * stay out of the Checker Inbox for exactly the same lock reason. + */ +export const TEMP_TABLE_ACCOUNT_ENTITIES: readonly string[] = [ + 'SAVINGSACCOUNT', + 'CURRENTACCOUNT' +]; + +/** Short type badge shown in the Product Approval table, matching the pending-edit productType tags. */ +export const PRODUCT_ENTITY_TYPE_LABEL: Record = { + LOANPRODUCT: 'LOAN', + SAVINGSPRODUCT: 'SAVINGS', + CURRENTPRODUCT: 'CURRENT', + SHAREPRODUCT: 'SHARE', + FIXEDDEPOSITPRODUCT: 'FD', + RECURRINGDEPOSITPRODUCT: 'RD', + CHARGE: 'CHARGE' +}; + +/** Whether a maker-checker entry is a product request, and so belongs to Product Approval. */ +export function isProductEntity(entityName: string | undefined | null): boolean { + return PRODUCT_ENTITIES.includes((entityName ?? '').toUpperCase()); +} + +/** Whether a maker-checker entry is a product edit already staged in m_saving_product_temp. */ +export function isTempTableProductEdit( + entityName: string | undefined | null, + actionName: string | undefined | null +): boolean { + return ( + TEMP_TABLE_PRODUCT_ENTITIES.includes((entityName ?? '').toUpperCase()) && + (actionName ?? '').toUpperCase() === 'UPDATE' + ); +} + +/** Whether a maker-checker entry is an account edit already staged for Pending Account Edits. */ +export function isTempTableAccountEdit( + entityName: string | undefined | null, + actionName: string | undefined | null +): boolean { + return ( + TEMP_TABLE_ACCOUNT_ENTITIES.includes((entityName ?? '').toUpperCase()) && + (actionName ?? '').toUpperCase() === 'UPDATE' + ); +} + +/** + * Display-only expansion of the short type badge above, for the Product Approval table. + * + * Presentation layer only: `productType` is still the value the component switches on to pick the + * right fetch/approve/reject endpoint, so the underlying row data, filtering and routing are + * untouched. Both the short code ('FD') and the spelled-out form ('FIXED DEPOSIT') are accepted so + * either feed maps correctly. + */ +export const PRODUCT_TYPE_DISPLAY_LABEL: Record = { + CURRENT: 'CURRENT PRODUCT', + SAVINGS: 'SAVINGS PRODUCT', + SHARE: 'SHARE PRODUCT', + CHARGE: 'CHARGE', + LOAN: 'LOAN PRODUCT', + FD: 'FIXED DEPOSIT PRODUCT', + 'FIXED DEPOSIT': 'FIXED DEPOSIT PRODUCT', + RD: 'RECURRING DEPOSIT PRODUCT', + 'RECURRING DEPOSIT': 'RECURRING DEPOSIT PRODUCT' +}; + +/** Label shown in the Product Approval Type column. Unmapped types render unchanged. */ +export function productTypeDisplayLabel(productType: string | undefined | null): string { + const raw = (productType ?? '').toString(); + return PRODUCT_TYPE_DISPLAY_LABEL[raw.trim().toUpperCase()] ?? raw; +} diff --git a/src/app/tasks/tasks-routing.module.ts b/src/app/tasks/tasks-routing.module.ts index 4dac6444d..7e18a29f3 100644 --- a/src/app/tasks/tasks-routing.module.ts +++ b/src/app/tasks/tasks-routing.module.ts @@ -124,13 +124,19 @@ const routes: Routes = [ } }, { - path: 'pending-savings-products', + path: 'product-approval', component: PendingSavingsProductsComponent, - data: { title: 'Pending Savings Product Edits' }, + data: { title: 'Product Approval' }, resolve: { pendingSavingsProductsData: GetPendingSavingsProducts } }, + { + // The tab was called "Pending Product Edits" until it grew to cover product CREATE and + // DELETE too; keep its old path working for bookmarks and saved links. + path: 'pending-savings-products', + redirectTo: 'product-approval' + }, { path: 'pending-savings-account-edits', component: PendingSavingsAccountEditsComponent, diff --git a/src/app/tasks/tasks.service.ts b/src/app/tasks/tasks.service.ts index 5f17d6809..f9f0e57f3 100644 --- a/src/app/tasks/tasks.service.ts +++ b/src/app/tasks/tasks.service.ts @@ -249,14 +249,6 @@ export class TasksService { return this.http.post(`/makercheckers/${makerCheckerId}`, {}, { params: httpParams }); } - /** - * Execute Maker Checker Delete Action - * @param {makerCheckerId} MakerCheckerId - */ - deleteMakerChecker(makerCheckerId: any): Observable { - return this.http.delete(`/makercheckers/${makerCheckerId}`); - } - /** * Get Maker Checker Details. * @param {makerCheckerId} MakerCheckerId 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 3ed785f19..bf2420d48 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,108 +6,166 @@ file, You can obtain one at http://mozilla.org/MPL/2.0/. --> -@if (isProductEditEntry) { -
-
- This is a product edit awaiting approval. Approve or reject it from - Approvals > Pending Product Edits instead — actioning it here would not release its lock. +
+ +
+ {{ headingTitle }} + @if (isDialog) { + + } +
+ + + @if (loading) { +
+ + {{ 'labels.heading.Loading' | translate }}…
-
-} @else { -
- - - -
-} + } -
- - -
-

{{ 'labels.heading.View Checker Inbox' | translate }}

- - - -

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

- - - -
- {{ 'labels.inputs.ID' | translate }}: - {{ checkerInboxDetail.id }} + @if (checkerInboxDetail) { +
+ @if (isProductEditEntry) { +
+ This is a product edit awaiting approval. Approve or reject it from + Approvals > Pending Product Edits instead — actioning it here would not release its lock.
- -
- {{ 'labels.inputs.Status' | translate }}: - {{ checkerInboxDetail.processingResult }} + } @else { +
+ +
+ } -
- {{ 'labels.inputs.User' | translate }}: - {{ checkerInboxDetail.maker }} -
- -
- {{ 'labels.inputs.Action' | translate }}: - {{ checkerInboxDetail.actionName }} -
- -
- {{ 'labels.inputs.Entity' | translate }}: - {{ checkerInboxDetail.entityName }} -
- - @if (checkerInboxDetail.resourceId) { -
- {{ 'labels.inputs.Resource ID' | translate }}: - {{ checkerInboxDetail.resourceId }} +
+
{{ 'labels.heading.Details' | translate }}
+ +
+
+
{{ 'labels.inputs.ID' | translate }}
+
{{ checkerInboxDetail.id }}
- } - -
- {{ 'labels.inputs.Date' | translate }}: - {{ checkerInboxDetail.madeOnDate | dateFormat }} -
- - @if (checkerInboxDetail.officeName) { -
- {{ 'labels.inputs.Office' | translate }}: - {{ checkerInboxDetail.officeName }} +
+
{{ 'labels.inputs.Status' | translate }}
+
{{ checkerInboxDetail.processingResult || '—' }}
- } - - @if (checkerInboxDetail.savingsAccountNo) { -
- {{ 'labels.inputs.Savings Account Number' | translate }}: - {{ checkerInboxDetail.savingsAccountNo }} +
+
{{ 'labels.inputs.User' | translate }}
+
{{ checkerInboxDetail.maker || '—' }}
- } - - @if (checkerInboxDetail.groupLevelName) { -
- {{ checkerInboxDetail.groupLevelName }} - {{ checkerInboxDetail.groupName }} +
+
{{ 'labels.inputs.Action' | translate }}
+
+ {{ + checkerInboxDetail.actionName + }} +
- } - - @if (displayJSONData) { -

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

- - @for (json of jsondata | keyvalue; track json) { -
- {{ json.key }}: - {{ json.value }} +
+
{{ 'labels.inputs.Entity' | translate }}
+
{{ checkerInboxDetail.entityName }}
+
+ @if (checkerInboxDetail.resourceId) { +
+
{{ 'labels.inputs.Resource ID' | translate }}
+
{{ checkerInboxDetail.resourceId }}
} - } +
+
{{ 'labels.inputs.Date' | translate }}
+
{{ checkerInboxDetail.madeOnDate | dateFormat }}
+
+ @if (checkerInboxDetail.officeName) { +
+
{{ 'labels.inputs.Office' | translate }}
+
{{ checkerInboxDetail.officeName }}
+
+ } + @if (checkerInboxDetail.savingsAccountNo) { +
+
{{ 'labels.inputs.Savings Account Number' | translate }}
+
{{ checkerInboxDetail.savingsAccountNo }}
+
+ } + @if (checkerInboxDetail.groupLevelName) { +
+
{{ checkerInboxDetail.groupLevelName }}
+
{{ checkerInboxDetail.groupName }}
+
+ } +
- - + + @if (displayJSONData) { +
+
{{ 'labels.heading.Command' | translate }}
+
+ @for (field of commandFields; track $index) { + + } +
+
+ } +
+ }
+ + + + @switch (field.kind) { + @case ('object') { +
+
{{ field.label }}
+
+
+ @for (child of field.fields; track $index) { + + } +
+
+
+ } + @case ('list') { +
+
+ {{ field.label }} {{ field.items.length }} +
+
+ @if (field.items.length === 0) { + None + } + @for (item of field.items; track $index) { + @if (item.kind === 'scalar') { + {{ item.text }} + } @else { +
+
{{ item.label }}
+
+ @for (child of item.kind === 'object' ? item.fields : item.items; track $index) { + + } +
+
+ } + } +
+
+ } + @default { +
+
{{ field.label }}
+
{{ field.text }}
+
+ } + } +
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 3d1ba1746..557951741 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 @@ -6,19 +6,321 @@ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ +// ── Design tokens ───────────────────────────────────────────────────────────── +$border: #e2e8f0; +$bg-page: #f8fafc; +$text-dark: #1e293b; +$text-mid: #475569; +$text-soft: #94a3b8; +$success: #16a34a; +$warning: #d97706; + .mat-elevation-z1 { margin: 1em 0 1.5em; } -h2, -h3, -h4 { +.vci { + background: #fff; + + /* Opened as a popup rather than a full page: sized to its own content, capped so a long command + payload scrolls inside the dialog instead of pushing it off screen. As a route it stays a + normal page-width card. */ + &--dialog { + width: min(680px, 92vw); + max-height: 85vh; + overflow-y: auto; + border-radius: 10px; + } + + &:not(&--dialog) { + max-width: 900px; + margin: 20px auto; + border: 1px solid $border; + border-radius: 12px; + overflow: hidden; + } +} + +// ── Header ─────────────────────────────────────────────────────────────────── +.vci-head { + display: flex; + align-items: center; + gap: 12px; + padding: 14px 20px; + border-bottom: 1px solid $border; + background: #fff; + position: sticky; + top: 0; + z-index: 1; + + &__title { + font-size: 15px; + font-weight: 700; + color: $text-dark; + } + + &__close { + margin-left: auto; + display: inline-flex; + align-items: center; + gap: 6px; + border: none; + background: none; + font-family: inherit; + font-size: 12.5px; + font-weight: 600; + color: $text-soft; + cursor: pointer; + padding: 6px 12px; + border-radius: 6px; + transition: all 0.15s; + + &:hover { + background: #f1f5f9; + color: $text-mid; + } + } +} + +.vci-loading { + display: flex; + align-items: center; + justify-content: center; + gap: 10px; + padding: 48px 24px; + font-size: 14px; + color: $text-soft; + + &__ic { + animation: vci-spin 1s linear infinite; + } +} + +@keyframes vci-spin { + to { + transform: rotate(360deg); + } +} + +.vci-body { + padding: 16px 20px 20px; + display: flex; + flex-direction: column; + gap: 16px; +} + +// ── Actions ────────────────────────────────────────────────────────────────── +.vci-actions { + display: flex; + gap: 10px; + flex-wrap: wrap; +} + +.vci-btn { + display: inline-flex; + align-items: center; + gap: 7px; + height: 36px; + padding: 0 18px; + border-radius: 8px; + font-family: inherit; + font-size: 13.5px; + font-weight: 600; + cursor: pointer; + border: 1.5px solid transparent; + transition: all 0.15s; + + &--approve { + background: #f0fdf4; + color: $success; + border-color: #86efac; + + &:hover { + background: $success; + color: #fff; + border-color: $success; + } + } + + &--delete { + background: #fff7ed; + color: #c2410c; + border-color: #fdba74; + + &:hover { + background: #ea580c; + color: #fff; + border-color: #ea580c; + } + } + + &--reject { + background: #fff; + color: $text-mid; + border-color: $border; + + &:hover { + background: #f1f5f9; + color: $text-dark; + } + } +} + +.vci-notice { + padding: 12px 16px; + border-left: 4px solid $warning; + background: #fffbeb; + border-radius: 6px; + font-size: 13px; + line-height: 1.5; + color: #92400e; +} + +// ── Detail sections ────────────────────────────────────────────────────────── +.vci-section { + border: 1px solid $border; + border-radius: 10px; + overflow: hidden; + + &__title { + padding: 10px 16px; + background: $bg-page; + border-bottom: 1px solid $border; + font-size: 11px; + font-weight: 800; + text-transform: uppercase; + letter-spacing: 0.07em; + color: $text-mid; + } +} + +/* One label above its value, two pairs per row where there is room. This replaces the global + flex-40/flex-60 utilities, which are sized for a full-width page and ran every field together + onto a single line inside the dialog. */ +.vci-grid { + display: grid; + grid-template-columns: repeat(auto-fit, minmax(220px, 1fr)); + gap: 14px 24px; margin: 0; font-weight: 500; } -span { - margin: 0.5em 0; +.vci-kv { + display: flex; + flex-direction: column; + gap: 3px; + min-width: 0; + + dt { + font-size: 10.5px; + font-weight: 700; + text-transform: uppercase; + letter-spacing: 0.06em; + color: $text-soft; + } + + dd { + margin: 0; + font-size: 14px; + font-weight: 500; + color: $text-dark; + line-height: 1.35; + overflow-wrap: anywhere; + } + + // A nested object/array needs the full grid width, not one of the two columns, or its own + // label/value pairs get squeezed into a sliver and wrap character by character. + &--wide { + grid-column: 1 / -1; + } +} + +/* Nested object/array rendering inside the Command section. Reuses .vci-kv for the leaves, so a + nested field looks like every other label/value pair, just indented under its parent. */ +.vci-subgrid { + display: grid; + grid-template-columns: repeat(auto-fit, minmax(200px, 1fr)); + gap: 10px 20px; + margin: 6px 0 0; + padding: 10px 12px; + border-left: 2px solid $border; + background: $bg-page; + border-radius: 0 6px 6px 0; + min-width: 0; +} + +/* One element of an array of objects. */ +.vci-entry { + margin-top: 8px; + border: 1px solid $border; + border-radius: 8px; + background: #fff; + overflow: hidden; + + &__head { + padding: 6px 12px; + background: $bg-page; + border-bottom: 1px solid $border; + font-size: 11px; + font-weight: 700; + text-transform: uppercase; + letter-spacing: 0.05em; + color: $text-mid; + } + + .vci-subgrid { + margin: 0; + border-left: 0; + background: transparent; + } +} + +/* Count badge next to an array's label. */ +.vci-count { + display: inline-block; + margin-left: 6px; + padding: 0 7px; + border-radius: 20px; + background: $border; + color: $text-mid; + font-size: 10.5px; + font-weight: 700; +} + +/* A scalar element of an array (e.g. a list of codes) - shown inline rather than as a card. */ +.vci-chip { + display: inline-block; + margin: 3px 6px 0 0; + padding: 3px 10px; + border-radius: 6px; + background: $bg-page; + border: 1px solid $border; + font-size: 12.5px; + color: $text-dark; + overflow-wrap: anywhere; +} + +.vci-none { + font-size: 13px; + font-style: italic; + color: $text-soft; +} + +.vci-tag { + display: inline-block; + padding: 2px 8px; + border-radius: 4px; + font-size: 12px; + font-weight: 600; + background: $bg-page; + color: $text-mid; + border: 1px solid $border; + + &--danger { + background: #fff1f2; + color: #be123c; + border-color: #fda4af; + } } .margin-t { 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 5bef1b3e1..f96bdd6e8 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,7 +10,7 @@ import { Component, inject } from '@angular/core'; import { ActivatedRoute, Router } from '@angular/router'; import * as _ from 'lodash'; -import { MatDialog } from '@angular/material/dialog'; +import { MatDialog, MatDialogRef, MAT_DIALOG_DATA } from '@angular/material/dialog'; /** Custom Services */ import { TasksService } from '../tasks.service'; @@ -19,10 +19,62 @@ import { TasksService } from '../tasks.service'; import { ConfirmationDialogComponent } from 'app/shared/confirmation-dialog/confirmation-dialog.component'; import { TranslateService } from '@ngx-translate/core'; import { FaIconComponent } from '@fortawesome/angular-fontawesome'; -import { MatDivider } from '@angular/material/divider'; -import { KeyValuePipe } from '@angular/common'; +import { NgTemplateOutlet } from '@angular/common'; import { DateFormatPipe } from '../../pipes/date-format.pipe'; import { STANDALONE_SHARED_IMPORTS } from 'app/standalone-shared.module'; +import { isTempTableProductEdit } from '../product-entities'; + +/** + * One row of the rendered Command payload. The dialog turns the parsed command JSON into a tree of + * these so the template can lay nested objects out as label/value pairs and arrays as repeated + * entries, rather than dumping raw JSON at the checker. Purely a view model — the command data + * itself (`jsondata`) is left exactly as the API returned it. + */ +export type CommandField = + | { label: string; kind: 'scalar'; text: string } + | { label: string; kind: 'object'; fields: CommandField[] } + | { label: string; kind: 'list'; items: CommandField[] }; + +/** + * Segments that read as initialisms in this domain, so `upiFacility` becomes "UPI Facility" rather + * than "Upi Facility". Anything not listed just gets title-cased, so an unknown key still renders + * sensibly - no per-product hard-coding. + */ +const LABEL_ACRONYMS = new Set([ + 'upi', + 'id', + 'ids', + 'url', + 'api', + 'ifsc', + 'micr', + 'kyc', + 'ckyc', + 'aml', + 'pan', + 'gst', + 'cgst', + 'sgst', + 'igst', + 'tds', + 'gl', + 'neft', + 'rtgs', + 'imps', + 'nach', + 'ecs', + 'atm', + 'nri', + 'cif', + 'dob', + 'otp', + 'sms', + 'emi', + 'fd', + 'rd', + 'mis', + 'nef' +]); @Component({ selector: 'mifosx-view-checker-inbox', @@ -31,8 +83,7 @@ import { STANDALONE_SHARED_IMPORTS } from 'app/standalone-shared.module'; imports: [ ...STANDALONE_SHARED_IMPORTS, FaIconComponent, - MatDivider, - KeyValuePipe, + NgTemplateOutlet, DateFormatPipe ] }) @@ -43,6 +94,98 @@ export class ViewCheckerInboxComponent { private translateService = inject(TranslateService); private tasksService = inject(TasksService); + /** Present only when opened as a popup from the Checker Inbox list; absent when reached by direct + * route (a bookmarked or shared link). Both entry points stay supported — this only changes where + * the detail comes from and how "done" is signalled: closing the dialog vs. navigating back. */ + private dialogRef = inject(MatDialogRef, { optional: true }); + private dialogData = inject<{ checkerId: number | string; title?: string } | null>(MAT_DIALOG_DATA, { + optional: true + }); + + loading = false; + + get isDialog(): boolean { + return !!this.dialogRef; + } + + /** + * Heading text. Defaults to the Checker Inbox heading — which is what the Checker Inbox itself and + * the direct route both get — but a caller opening this dialog from another tab can supply its own, + * so the checker sees which queue the request came from. Nothing else about the dialog changes. + */ + get headingTitle(): string { + return this.dialogData?.title ?? this.translateService.instant('labels.heading.View Checker Inbox'); + } + + /** + * The Command payload as a render tree. Built once per detail load rather than exposed as a getter, + * so change detection is not re-walking the payload on every cycle. + */ + commandFields: CommandField[] = []; + + /** camelCase / snake_case key -> readable label, e.g. `upiFacility` -> "UPI Facility". */ + private labelFor(key: string): string { + const spaced = String(key ?? '') + .replace(/[_\-.]+/g, ' ') + // splitAt lower|digit -> Upper, and at the tail of a run of capitals (`UPIFacility` -> `UPI Facility`) + .replace(/([a-z0-9])([A-Z])/g, '$1 $2') + .replace(/([A-Z]+)([A-Z][a-z])/g, '$1 $2') + .trim(); + if (!spaced) { + return ''; + } + return spaced + .split(/\s+/) + .map((word) => + LABEL_ACRONYMS.has(word.toLowerCase()) ? word.toUpperCase() : word.charAt(0).toUpperCase() + word.slice(1) + ) + .join(' '); + } + + /** Leaf rendering. Booleans read as Yes/No; everything else keeps the text it always had. */ + private scalarText(value: any): string { + if (value === null || value === undefined) { + return '—'; + } + if (typeof value === 'boolean') { + return value ? 'Yes' : 'No'; + } + return String(value); + } + + /** + * Recursively turns one key/value pair into a render node. Nothing is dropped or summarised - every + * key in the payload produces a node, so the checker still sees the complete request. + */ + private toField(label: string, value: any): CommandField { + if (Array.isArray(value)) { + return { + label, + kind: 'list', + items: value.map((item, index) => + item !== null && typeof item === 'object' + ? this.toField(`${label} ${index + 1}`, item) + : { label: '', kind: 'scalar' as const, text: this.scalarText(item) } + ) + }; + } + if (value !== null && typeof value === 'object') { + return { + label, + kind: 'object', + fields: Object.keys(value).map((key) => this.toField(this.labelFor(key), value[key])) + }; + } + return { label, kind: 'scalar', text: this.scalarText(value) }; + } + + private buildCommandFields(payload: any): CommandField[] { + if (!payload || typeof payload !== 'object' || Array.isArray(payload)) { + return []; + } + return Object.keys(payload).map((key) => this.toField(this.labelFor(key), payload[key])); + } + /** Checker Inbox Details Data */ checkerInboxDetail: any; /** JsonData */ @@ -51,24 +194,16 @@ export class ViewCheckerInboxComponent { displayJSONData = false; /** - * These entities' pending edits are gated by our own m_savings_product_temp lock, not the native - * command-source pipeline. Approving/rejecting them here would replay the original command handler - * directly via /makercheckers/{id}?command=approve and never clear our temp table's lock. Action them - * from Approvals -> Pending Product Edits instead. + * A product edit staged in m_saving_product_temp is not approvable here: /makercheckers/{id} replays + * the original command handler and never clears the staging lock. Product Approval actions those + * through the product's own approve/reject endpoint instead. + * + * Scoped to staged UPDATEs on purpose. Product CREATE and DELETE rows are ordinary command-source + * entries with no staging row, and Product Approval opens this dialog to action them - so they must + * keep their buttons. */ - private readonly productEditEntities = [ - 'SAVINGSPRODUCT', - 'CURRENTPRODUCT', - 'SHAREPRODUCT', - 'FIXEDDEPOSITPRODUCT', - 'RECURRINGDEPOSITPRODUCT' - ]; - get isProductEditEntry(): boolean { - return ( - this.productEditEntities.includes(this.checkerInboxDetail?.entityName) && - this.checkerInboxDetail?.actionName === 'UPDATE' - ); + return isTempTableProductEdit(this.checkerInboxDetail?.entityName, this.checkerInboxDetail?.actionName); } /** @@ -79,11 +214,58 @@ export class ViewCheckerInboxComponent { * @param {TasksService} tasksService Tasks Service. */ constructor() { + // Route entry: detail is pre-fetched by the resolver and injected via route.data. this.route.data.subscribe((data: { checkerInboxDetail: any }) => { - this.checkerInboxDetail = data.checkerInboxDetail; - this.jsondata = JSON.parse(this.checkerInboxDetail.commandAsJson); - this.displayJSONData = !_.isEmpty(this.jsondata); + if (data.checkerInboxDetail) { + this.applyDetail(data.checkerInboxDetail); + } }); + + // Dialog entry: no resolver fires, so fetch the detail imperatively using the + // checkerId the caller passed in MAT_DIALOG_DATA. + if (this.dialogData?.checkerId != null && !this.checkerInboxDetail) { + this.loading = true; + this.tasksService.getCheckerInboxDetail(this.dialogData.checkerId).subscribe({ + next: (detail: any) => { + this.loading = false; + this.applyDetail(detail); + }, + error: () => { + this.loading = false; + } + }); + } + } + + private applyDetail(detail: any): void { + this.checkerInboxDetail = detail; + // 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 { + this.jsondata = detail?.commandAsJson ? JSON.parse(detail.commandAsJson) : {}; + } catch { + this.jsondata = {}; + } + this.displayJSONData = !_.isEmpty(this.jsondata); + this.commandFields = this.buildCommandFields(this.jsondata); + } + + /** "Done" for this screen — closes the popup (telling the list to refresh) when opened as a + * dialog, or returns to the inbox route otherwise. */ + private finish(): void { + if (this.dialogRef) { + this.dialogRef.close({ refreshed: true }); + } else { + this.router.navigate(['../../'], { relativeTo: this.route }); + } + } + + close(): void { + if (this.dialogRef) { + this.dialogRef.close(); + } else { + this.router.navigate(['../../'], { relativeTo: this.route }); + } } /** @@ -99,7 +281,7 @@ export class ViewCheckerInboxComponent { approveCheckerDialogRef.afterClosed().subscribe((response: { confirm: any }) => { if (response.confirm) { this.tasksService.executeMakerCheckerAction(this.checkerInboxDetail.id, 'approve').subscribe((res: any) => { - this.router.navigate(['../../'], { relativeTo: this.route }); + this.finish(); }); } }); @@ -118,26 +300,7 @@ export class ViewCheckerInboxComponent { rejectCheckerDialogRef.afterClosed().subscribe((response: { confirm: any }) => { if (response.confirm) { this.tasksService.executeMakerCheckerAction(this.checkerInboxDetail.id, 'reject').subscribe((res: any) => { - this.router.navigate(['../../'], { relativeTo: this.route }); - }); - } - }); - } - - /** - * Delete Checker - */ - deleteChecker() { - const deleteCheckerDialogRef = this.dialog.open(ConfirmationDialogComponent, { - data: { - heading: this.translateService.instant('labels.heading.Delete Checker'), - dialogContext: this.translateService.instant('labels.dialogContext.Are you sure you want to delete checker') - } - }); - deleteCheckerDialogRef.afterClosed().subscribe((response: { confirm: any }) => { - if (response.confirm) { - this.tasksService.deleteMakerChecker(this.checkerInboxDetail.id).subscribe((res: any) => { - this.router.navigate(['../../'], { relativeTo: this.route }); + this.finish(); }); } }); diff --git a/src/app/users/dsc/dsc-list/dsc-list.component.ts b/src/app/users/dsc/dsc-list/dsc-list.component.ts index 429c65c72..064a1c890 100644 --- a/src/app/users/dsc/dsc-list/dsc-list.component.ts +++ b/src/app/users/dsc/dsc-list/dsc-list.component.ts @@ -52,7 +52,6 @@ import { RevokeDscDialogComponent } from '../revoke-dsc-dialog/revoke-dsc-dialog MatRow, MatButton, MatIconButton, - MatIcon, MatMenu, MatMenuItem, MatMenuTrigger, From 03bc2ff139467432114e318d680392954018f0db Mon Sep 17 00:00:00 2001 From: Nitesh Yadav Date: Wed, 9 Sep 2026 13:00:24 +0530 Subject: [PATCH 35/64] feat(branch-profile): add pincode field Co-Authored-By: Claude Opus 5 (1M context) --- .../organization/branch-profile/branch-profile.component.html | 4 ++++ .../organization/branch-profile/branch-profile.component.ts | 2 ++ 2 files changed, 6 insertions(+) diff --git a/src/app/organization/branch-profile/branch-profile.component.html b/src/app/organization/branch-profile/branch-profile.component.html index 154f8a246..0ad5d381b 100644 --- a/src/app/organization/branch-profile/branch-profile.component.html +++ b/src/app/organization/branch-profile/branch-profile.component.html @@ -71,6 +71,10 @@ Address + + Pincode + + Contact Phone diff --git a/src/app/organization/branch-profile/branch-profile.component.ts b/src/app/organization/branch-profile/branch-profile.component.ts index af14910ff..628d0cb6b 100644 --- a/src/app/organization/branch-profile/branch-profile.component.ts +++ b/src/app/organization/branch-profile/branch-profile.component.ts @@ -53,6 +53,7 @@ export class BranchProfileComponent implements OnInit { branchCode: [''], branchType: [null], address: [''], + pincode: [''], contactPhone: [''], contactEmail: [''], workingHoursStart: [''], @@ -102,6 +103,7 @@ export class BranchProfileComponent implements OnInit { branchCode: res.branchCode ?? '', branchType: res.branchType ?? null, address: res.address ?? '', + pincode: res.pincode ?? '', contactPhone: res.contactPhone ?? '', contactEmail: res.contactEmail ?? '', workingHoursStart: res.workingHoursStart ?? '', From 2fa6c2d8149df6f4a97a6fda43027ecba64260c6 Mon Sep 17 00:00:00 2001 From: anshul Date: Wed, 9 Sep 2026 14:43:11 +0530 Subject: [PATCH 36/64] fix: restore Product Approval action button colors --- .../pending-savings-products.component.scss | 36 +++++++++++++++---- 1 file changed, 30 insertions(+), 6 deletions(-) diff --git a/src/app/tasks/checker-inbox-and-tasks-tabs/pending-savings-products/pending-savings-products.component.scss b/src/app/tasks/checker-inbox-and-tasks-tabs/pending-savings-products/pending-savings-products.component.scss index 2038c7169..596f32709 100644 --- a/src/app/tasks/checker-inbox-and-tasks-tabs/pending-savings-products/pending-savings-products.component.scss +++ b/src/app/tasks/checker-inbox-and-tasks-tabs/pending-savings-products/pending-savings-products.component.scss @@ -45,8 +45,16 @@ $text-muted: #64748b; border-color: #2563eb; color: #2563eb; - &:hover { - background: #eff6ff; + &:not(:disabled) { + --mat-button-outlined-outline-color: #2563eb; + --mat-button-outlined-label-text-color: #2563eb; + + border-color: #2563eb !important; + color: #2563eb !important; + + &:hover { + background: #eff6ff !important; + } } } @@ -54,8 +62,16 @@ $text-muted: #64748b; border-color: #16a34a; color: #16a34a; - &:hover { - background: #f0fdf4; + &:not(:disabled) { + --mat-button-outlined-outline-color: #16a34a; + --mat-button-outlined-label-text-color: #16a34a; + + border-color: #16a34a !important; + color: #16a34a !important; + + &:hover { + background: #f0fdf4 !important; + } } } @@ -63,8 +79,16 @@ $text-muted: #64748b; border-color: #dc2626; color: #dc2626; - &:hover { - background: #fef2f2; + &:not(:disabled) { + --mat-button-outlined-outline-color: #dc2626; + --mat-button-outlined-label-text-color: #dc2626; + + border-color: #dc2626 !important; + color: #dc2626 !important; + + &:hover { + background: #fef2f2 !important; + } } } From f0c06988c01943935a387ec16cee974ef050654c Mon Sep 17 00:00:00 2001 From: Nitesh Yadav Date: Thu, 10 Sep 2026 12:40:32 +0530 Subject: [PATCH 37/64] fix(cashier): My Vouchers filters, Fund Transfer GL support, and disbursement-list rename MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit my-work.component (My Vouchers): - The five summary tiles (Received/Payments/Journal/Entered/Approved) were bound directly to the server's day-total fields, so they stayed fixed at the combined "entered + approved" figure no matter what the Side/My part dropdowns were set to. They're now pinned to this user's own entered rows only (enteredRows), independent of both filters, so "how much did I personally put through today" reads correctly regardless of what the table below happens to be filtered to. The table and its dropdowns are unchanged and still default to showing everything. transaction-entry.component (Fund Transfer): - onSave()'s plain bank-transfer branch (SD/CA/CB only) was catching every 'Fund Transfer' row before the GL-aware Disbursement/Collection branch below it could see them, so a Fund Transfer against a GL account was always rejected with "Invalid Account Type" even though that branch already supports it under the same label. Fund Transfer rows that include a GL leg now skip the plain-transfer branch and fall through correctly. my-disbursement-requests.component (Disbursements & Transfer list): - getCreditEntries() only read the stored credit_entries JSON, which holds the full picture for Collection (its GL leg is one of the entries) but not for Disbursement/Fund Transfer, whose GL is the request's own top-level source (gl_account_no/dr_amount) and was never in that array. The DR/CR breakdown for those two types showed only the credit side, looking like half the entry was missing. The GL leg is now synthesised back in as the first row for those two types, matching what actually posted. - Renamed the clerk's "My Disbursements" / "My Disbursement Requests" screen to "Disbursements & Transfer" throughout (nav tab, dropdown menu, route title/breadcrumb, cashier counter link, page header) — the register covers Disbursement, Collection, Fund Transfer and Bank Transfer requests, and the old name read as disbursement-only. "My Requests" (Withdrawal Slip requests, a separate register) is renamed to "Withdrawal Request" for the same reason: neither name said what was actually inside. --- .../branch-day/branch-day.component.html | 15 ++++++- .../branch-day/branch-day.component.ts | 26 ++++++++--- src/app/cashier/cashier-routing.module.ts | 2 +- .../cashier/my-work/my-work.component.html | 15 ++++--- src/app/cashier/my-work/my-work.component.ts | 44 ++++++++++++++++++- .../transaction-entry.component.html | 4 +- .../transaction-entry.component.ts | 14 +++++- .../my-disbursement-requests.component.html | 10 +++-- .../my-disbursement-requests.component.ts | 24 +++++++++- src/app/core/shell/shell.component.html | 6 +-- src/environments/.env.ts | 4 +- 11 files changed, 133 insertions(+), 31 deletions(-) diff --git a/src/app/cashier/branch-day/branch-day.component.html b/src/app/cashier/branch-day/branch-day.component.html index 13c4f28c2..a2fd93d0a 100644 --- a/src/app/cashier/branch-day/branch-day.component.html +++ b/src/app/cashier/branch-day/branch-day.component.html @@ -559,9 +559,20 @@

} - @if (isLateClose) { + + @if (isLateClose || hasUnreconciledOrUnsettledCashiers) {
- +
Received
-
₹{{ report.receivedTotal | number: '1.2-2' }}
+
₹{{ receivedTotal | number: '1.2-2' }}
Payments
-
₹{{ report.paymentTotal | number: '1.2-2' }}
+
₹{{ paymentTotal | number: '1.2-2' }}
Journal
-
₹{{ report.journalTotal | number: '1.2-2' }}
+
₹{{ journalTotal | number: '1.2-2' }}
Entered
-
{{ report.raisedCount }}
+
{{ raisedCount }}
Approved
-
{{ report.verifiedCount }}
+
{{ verifiedCount }}
diff --git a/src/app/cashier/my-work/my-work.component.ts b/src/app/cashier/my-work/my-work.component.ts index c4644ba0b..9173fc55f 100644 --- a/src/app/cashier/my-work/my-work.component.ts +++ b/src/app/cashier/my-work/my-work.component.ts @@ -82,7 +82,11 @@ export class MyWorkComponent implements OnInit { period: 'DAY' | 'MONTH' = 'DAY'; anchor = this.todayIso(); - /** Narrow to one side, or to the part the user played. Both applied in the browser. */ + /** + * Narrow to one side, or to the part the user played. Both applied in the browser, and only to + * the table below — the five summary tiles are pinned to Entered work regardless (see + * enteredRows), so this can default to showing everything without muddying them. + */ sideFilter: '' | VoucherSide = ''; roleFilter: '' | MyRole = ''; @@ -174,6 +178,44 @@ export class MyWorkComponent implements OnInit { return this.rows.reduce((sum, r) => sum + Number(r.amount ?? 0), 0); } + /** + * What this person themselves put through today — never narrowed by the Side or My part + * filters, which exist only to browse the table below. + * + * The five summary tiles are pinned to this set on purpose. They answer one question, "what did + * I personally do today", and that answer cannot depend on which slice of the list a person + * happens to have the table filtered to at the moment — a Received figure that changed whenever + * someone picked "Approved" to go look at what they'd passed would not be their own total any + * more, it would be whatever the table was showing. + */ + get enteredRows(): MyWorkRow[] { + const all = this.report?.vouchers ?? []; + return all.filter((r) => r.myRole === 'RAISED' || r.myRole === 'RAISED_AND_VERIFIED'); + } + + get receivedTotal(): number { + return this.enteredRows.filter((r) => r.side === 'CR').reduce((sum, r) => sum + Number(r.amount ?? 0), 0); + } + + get paymentTotal(): number { + return this.enteredRows.filter((r) => r.side === 'DR').reduce((sum, r) => sum + Number(r.amount ?? 0), 0); + } + + get journalTotal(): number { + return this.enteredRows + .filter((r) => r.side !== 'CR' && r.side !== 'DR') + .reduce((sum, r) => sum + Number(r.amount ?? 0), 0); + } + + get raisedCount(): number { + return this.enteredRows.length; + } + + get verifiedCount(): number { + const all = this.report?.vouchers ?? []; + return all.filter((r) => r.myRole === 'VERIFIED' || r.myRole === 'RAISED_AND_VERIFIED').length; + } + sideLabel(side: VoucherSide | null): string { if (side === 'CR') { return 'Received'; diff --git a/src/app/cashier/transaction-entry/transaction-entry.component.html b/src/app/cashier/transaction-entry/transaction-entry.component.html index 8496b5dc4..98ae9647e 100644 --- a/src/app/cashier/transaction-entry/transaction-entry.component.html +++ b/src/app/cashier/transaction-entry/transaction-entry.component.html @@ -1856,9 +1856,9 @@ class="ws-btn ws-btn--ghost" type="button" (click)="router.navigate(['/clerk/my-disbursement-requests'])" - title="View my disbursement requests" + title="View my fund requests — Disbursement, Collection, Fund Transfer and Bank Transfer" > - My Disbursements + Disbursements & Transfer } @if (canShowAccountActions) { diff --git a/src/app/cashier/transaction-entry/transaction-entry.component.ts b/src/app/cashier/transaction-entry/transaction-entry.component.ts index fbbd61837..5a49e956d 100644 --- a/src/app/cashier/transaction-entry/transaction-entry.component.ts +++ b/src/app/cashier/transaction-entry/transaction-entry.component.ts @@ -2763,7 +2763,7 @@ export class TransactionEntryComponent implements OnInit, OnDestroy { this.showAlert('success', 'Bank Transfer Submitted', [ `Voucher No: ${res.voucherNo}`, `Total: ₹${totalDr.toLocaleString('en-IN', { minimumFractionDigits: 2 })} · DR: ${drRows.length} account(s) → CR: ${crRows.length} account(s)`, - 'Pending Manager approval. Track status in "My Disbursement Requests".' + 'Pending Manager approval. Track status in "Disbursements & Transfer".' ]); this.rows = []; this.rowAccountIds = {}; @@ -4365,10 +4365,20 @@ export class TransactionEntryComponent implements OnInit, OnDestroy { // is the same flow under a second TYPE label — onTypChange() already defaults it identically to // 'Enter Bank Transfer', so submission has to recognise both or a share redemption or bank // transfer raised under this label silently falls through to the wrong branch below. + // + // But 'Fund Transfer' rows that include a GL leg are NOT this flow — they are the + // Disbursement/Collection double-entry shape (see the TYPE options comment), just under the + // friendlier 'Fund Transfer' label so a non-loan GL movement doesn't have to be called a + // "Disbursement". 'Enter Bank Transfer' never carries a GL leg, so it always stays in this + // branch; only skip out to the Disbursement/Collection branch below when every row is 'Fund + // Transfer' and at least one of them is GL. + const fundTransferRowsHaveGl = + this.rows.every((r) => r.typ === 'Fund Transfer') && this.rows.some((r) => r.acTyp === 'GL'); if ( this.isClerk && this.rows.length >= 2 && - this.rows.every((r) => r.typ === 'Enter Bank Transfer' || r.typ === 'Fund Transfer') + this.rows.every((r) => r.typ === 'Enter Bank Transfer' || r.typ === 'Fund Transfer') && + !fundTransferRowsHaveGl ) { const drRows = this.rows.filter((r) => r.dbCr === 'Dr'); const crRows = this.rows.filter((r) => r.dbCr === 'Cr'); diff --git a/src/app/clerk/my-disbursement-requests/my-disbursement-requests.component.html b/src/app/clerk/my-disbursement-requests/my-disbursement-requests.component.html index 6171928f5..634cdd3ba 100644 --- a/src/app/clerk/my-disbursement-requests/my-disbursement-requests.component.html +++ b/src/app/clerk/my-disbursement-requests/my-disbursement-requests.component.html @@ -12,13 +12,15 @@
swap_horiz
-
My Disbursement Requests
-
Track your submitted GL disbursement requests.
+
Disbursements & Transfer
+
+ Disbursement, Collection, Fund Transfer and Bank Transfer requests you submitted. +
@@ -99,7 +101,7 @@

You have not submitted any disbursement requests yet.

} @else { diff --git a/src/app/clerk/my-disbursement-requests/my-disbursement-requests.component.ts b/src/app/clerk/my-disbursement-requests/my-disbursement-requests.component.ts index ed333c1e3..9f57f9280 100644 --- a/src/app/clerk/my-disbursement-requests/my-disbursement-requests.component.ts +++ b/src/app/clerk/my-disbursement-requests/my-disbursement-requests.component.ts @@ -177,7 +177,29 @@ export class MyDisbursementRequestsComponent implements OnInit { getCreditEntries(req: any): any[] { try { const arr = typeof req.credit_entries === 'string' ? JSON.parse(req.credit_entries) : req.credit_entries; - return Array.isArray(arr) ? arr.map((e: any) => ({ ...e, amount: parseFloat(e.amount) || 0 })) : []; + const entries: any[] = Array.isArray(arr) + ? arr.map((e: any) => ({ ...e, amount: parseFloat(e.amount) || 0 })) + : []; + // Collection embeds its GL leg inside credit_entries (it can collect from several + // accounts into the one GL, so the GL has to be one row among them). Disbursement and + // Fund Transfer instead name their GL as the request's own top-level source — dr_amount + // is its amount — because there is exactly one, so it never needed a row of its own. That + // meant the DR/CR breakdown here only ever showed the credit side for those two types, + // looking like half the slip was missing. Adding it back as the first row here, rather + // than in credit_entries itself, keeps the stored JSON exactly what approve() posts from. + if ((req.request_type === 'DISBURSEMENT' || req.request_type === 'FUND_TRANSFER') && req.gl_account_no) { + return [ + { + dbCr: 'Dr', + accountNo: req.gl_account_no, + accountName: req.gl_account_name, + accountType: 'GL', + amount: parseFloat(req.dr_amount) || 0 + }, + ...entries + ]; + } + return entries; } catch { return []; } diff --git a/src/app/core/shell/shell.component.html b/src/app/core/shell/shell.component.html index ea35032d0..a2390e459 100644 --- a/src/app/core/shell/shell.component.html +++ b/src/app/core/shell/shell.component.html @@ -86,7 +86,7 @@ - My Requests + Withdrawal Request
- My Disbursements + Disbursements & Transfer My Withdrawal Requests
- @if (isLateClose) { + @if (needsForceReason) {

This day is being closed after its own date. It will be recorded as a force close, with the reason below kept permanently against it. @@ -592,10 +592,25 @@

@if (hasUnreconciledOrUnsettledCashiers) { -
- block + +
+ {{ blocksSameDayClose ? 'block' : 'warning' }}
- Cannot close the day — cashiers still holding cash have not been reconciled or settled. + @if (blocksSameDayClose) { + Cannot close today — cashiers still holding cash have not been reconciled or settled. + } @else { + This day's cash was never reconciled or settled. + }
    @for (detail of unreconciledOrUnsettledCashierDetails; track detail.name) {
  • @@ -610,17 +625,24 @@
  • }
- Each cashier must reconcile their cash and settle it back to the vault before the branch day can be - closed. + @if (blocksSameDayClose) { + Each cashier must count their drawer, reconcile it, and settle the cash back to the vault. Then this + day can be closed. + } @else { + The cash cannot be counted for a date that has passed. Record why above — their names are kept + against the record. + }
}
diff --git a/src/app/cashier/branch-day/branch-day.component.scss b/src/app/cashier/branch-day/branch-day.component.scss index 1db8d68d2..fa49ed69d 100644 --- a/src/app/cashier/branch-day/branch-day.component.scss +++ b/src/app/cashier/branch-day/branch-day.component.scss @@ -328,3 +328,17 @@ line-height: 1.6; } } + +/* Amber, not red: an unreconciled cashier is now a reason to record why the day is being closed + rather than a refusal to close it at all. Red said "you cannot", which was true when the button + was disabled and misleading once a force close became possible. */ +.bd-reconcile-block--warn { + background: #fffbeb; + border-color: #fde68a; + border-left-color: #d97706; + color: #92400e; + + mat-icon { + color: #d97706; + } +} diff --git a/src/app/cashier/branch-day/branch-day.component.ts b/src/app/cashier/branch-day/branch-day.component.ts index 7235a4ea4..579a3364a 100644 --- a/src/app/cashier/branch-day/branch-day.component.ts +++ b/src/app/cashier/branch-day/branch-day.component.ts @@ -571,6 +571,16 @@ export class BranchDayComponent implements OnInit { return result; } + /** + * Whether a reason has to be given before the day can close. + * + * Only for a late close. Cash still sitting with a cashier is not something a reason excuses — + * see canSubmitClose. + */ + get needsForceReason(): boolean { + return this.isLateClose; + } + get canSubmitClose(): boolean { const hasFigure = this.closingCash != null || this.closeDenomTotal > 0; if (!this.closeTarget || !hasFigure || this.closing) return false; diff --git a/src/app/clients/client-stepper/client-general-step/client-general-step.component.ts b/src/app/clients/client-stepper/client-general-step/client-general-step.component.ts index 7f1cab462..5fafd6717 100644 --- a/src/app/clients/client-stepper/client-general-step/client-general-step.component.ts +++ b/src/app/clients/client-stepper/client-general-step/client-general-step.component.ts @@ -2432,6 +2432,17 @@ export class ClientGeneralStepComponent implements OnInit, OnDestroy, OnChanges { upTo: null, category: 3, label: 'above ₹10 crore' } ]; + /** + * A numeric rule bound counts as configured only when it is present and above zero. + * + * Migration 0448 replaced every unset income/turnover NULL with 0.0, so "absent" now arrives as + * either. Every comparison in calculatedRisk already pairs its check with `> 0` for that reason; + * this keeps the criteria-less test reading the columns the same way. + */ + private static isConfigured(bound: number | string | null | undefined): boolean { + return bound != null && parseFloat(String(bound)) > 0; + } + /** "Low Risk" / "Medium Risk" / "High Risk" for a category id. */ riskCategoryLabel(category: number): string { if (category === 3) { @@ -2518,17 +2529,32 @@ export class ClientGeneralStepComponent implements OnInit, OnDestroy, OnChanges // Evaluate rules from DB (ordered by orderPosition ASC) if (this.riskRules && this.riskRules.length > 0) { for (const rule of this.riskRules) { - // Skip transaction/AML rules that have no KYC criteria + // Only KYC profile rules categorise a customer at onboarding. The same table also holds + // ~221 AML transaction-monitoring scenarios ("Large cash transaction", "Additional RFIs + // Rule - 91", ...) which describe behaviour and may only fire against posted transactions, + // as AML alerts — RBI Master Direction on KYC separates risk categorisation (Section 12) + // from transaction monitoring (Section 13). ruleKind, set on the row, is what tells them + // apart; it replaced an inference from "this rule has no criteria set", which migration + // 0448 silently broke by writing 0.0 over those NULLs. That let rule 91 match a salaried + // client on an income band it was never meant to carry and pre-flag them for AML. + if (rule.ruleKind === 'AML') { + continue; + } + + // Backstop for a rule row predating rule_kind: a rule naming no occupation, no legal form + // and no income or turnover band constrains nothing, so it would match every customer. + // Treat 0 as unset — that is how the comparisons below read it, and what 0448 left behind. if ( + rule.ruleKind == null && rule.legalFormId == null && rule.occupationCategoryCvId == null && rule.occupationCvId == null && - rule.minMonthlyIncome == null && - rule.maxMonthlyIncome == null && - rule.minAnnualIncome == null && - rule.maxAnnualIncome == null && - rule.minAnnualTurnover == null && - rule.maxAnnualTurnover == null + !ClientGeneralStepComponent.isConfigured(rule.minMonthlyIncome) && + !ClientGeneralStepComponent.isConfigured(rule.maxMonthlyIncome) && + !ClientGeneralStepComponent.isConfigured(rule.minAnnualIncome) && + !ClientGeneralStepComponent.isConfigured(rule.maxAnnualIncome) && + !ClientGeneralStepComponent.isConfigured(rule.minAnnualTurnover) && + !ClientGeneralStepComponent.isConfigured(rule.maxAnnualTurnover) ) { continue; } @@ -2576,7 +2602,12 @@ export class ClientGeneralStepComponent implements OnInit, OnDestroy, OnChanges return { category: rule.riskCategory, reason: rule.reason, - amlCheckStatus: rule.amlCheckStatus || (rule.riskCategory === 3 ? 'FLAGGED' : 'CLEAR'), + // A profile rule sets the risk category; it does not raise an AML flag. High Risk means + // enhanced due diligence and a 2-yearly KYC refresh (RBI MD Sections 12 and 38), not + // suspicion — an alert belongs to observed transaction behaviour (Section 13), so a + // customer who has transacted nothing yet stays CLEAR. A bank that genuinely wants a + // rule to flag on match can still say so explicitly via the rule's own amlCheckStatus. + amlCheckStatus: rule.amlCheckStatus || 'CLEAR', bannedEntityMatch: rule.bannedEntityMatch || 'NO', willfulDefaulter: rule.willfulDefaulter || 'NO', ecgcSalFlag: rule.ecgcSalFlag || 'NO', diff --git a/src/app/clients/clients-view/kyc-tab/kyc-tab.component.html b/src/app/clients/clients-view/kyc-tab/kyc-tab.component.html index c91dfc132..57311257a 100644 --- a/src/app/clients/clients-view/kyc-tab/kyc-tab.component.html +++ b/src/app/clients/clients-view/kyc-tab/kyc-tab.component.html @@ -280,6 +280,82 @@
+ + @if (showAadhaarReVerify && !aadhaarOtpSent) { +
+ + Enter 12-digit Aadhaar + + + + +
+ } + @if (aadhaarOtpSent) { +
+ sms OTP sent + + Enter OTP + + + + + +
+ } + - @if (showAadhaarReVerify && !aadhaarOtpSent) { -
- - Enter 12-digit Aadhaar - - - -
- } - @if (aadhaarOtpSent) { -
- sms OTP sent - - Enter OTP - - - - -
- } @if (verificationError.aadhaar) {
error_outline {{ verificationError.aadhaar }} @@ -1128,6 +1137,69 @@ + + @if (kycData?.kycDocumentName || kycDocumentNeedsUpdate) { + + } +
this.loadKycData(), + error: (err: any) => { + this.errorMessage = + err?.error?.developerMessage || + err?.error?.defaultUserMessage || + err?.message || + 'Verification succeeded but the KYC exception could not be updated.'; + this.loadKycData(); + } + }); + } + /** * Records the UIDAI enrolment receipt accepted in place of a verified Aadhaar. * @@ -786,6 +887,12 @@ export class KycTabComponent implements OnInit, OnDestroy { this.verificationError.aadhaar = null; } + /** Abandons the OTP exchange, so a mis-click can be backed out of without reloading the tab. */ + cancelAadhaarReVerify(): void { + this.resetOtpState(); + this.verificationError.aadhaar = null; + } + /** Step 1: Send OTP to Aadhaar-linked mobile */ sendAadhaarOtp(): void { const aadhaar = this.isEditMode ? this.kycForm?.get('aadhaarNumber')?.value : this.aadhaarFullNumber; @@ -855,7 +962,7 @@ export class KycTabComponent implements OnInit, OnDestroy { if (this.aadhaarReceiptTaken) { this.writeAadhaarReceipt(null); } else { - this.loadKycData(); + this.syncExceptionAfterVerify(); } } else { this.verificationStatus.aadhaar = 'FAILED'; diff --git a/src/app/clients/clients.service.ts b/src/app/clients/clients.service.ts index f6f8db064..7220fbe47 100644 --- a/src/app/clients/clients.service.ts +++ b/src/app/clients/clients.service.ts @@ -536,6 +536,17 @@ export class ClientsService { return this.http.post(`/clients/${clientId}/identifiers`, identifierData); } + /** + * Updates an identifier in place — the document number, its type, or the description. + * + * Without this the only way to record a re-collected document was to post a new identifier, so + * a customer whose Aadhaar was re-verified ended up with two rows carrying the same number and + * the same scans, and nothing saying which was current. + */ + updateClientIdentifier(clientId: string, identifierId: string, identifierData: any) { + return this.http.put(`/clients/${clientId}/identifiers/${identifierId}`, identifierData); + } + deleteClientIdentifier(clientId: string, identifierId: string) { return this.http.delete(`/clients/${clientId}/identifiers/${identifierId}`); } diff --git a/src/app/clients/edit-client/edit-client.component.ts b/src/app/clients/edit-client/edit-client.component.ts index c55beef28..0f300376a 100644 --- a/src/app/clients/edit-client/edit-client.component.ts +++ b/src/app/clients/edit-client/edit-client.component.ts @@ -1079,12 +1079,15 @@ export class EditClientComponent { */ private saveKycDocProof(clientId: string) { const payload = this.clientKycStep?.kycDocPayload; - if (!payload?.proofId || !payload?.files?.length) return; + // A document number corrected without re-scanning the document is still a change worth + // saving, so this no longer requires files — it used to return here and the new number was + // silently dropped. + if (!payload?.proofId) return; const proofName = payload.proof?.name || 'KYC Document'; const uploadFiles = (identifierId: string) => { - for (const entry of payload.files) { + for (const entry of payload.files || []) { const formData = new FormData(); formData.append('name', `${proofName} — ${entry.slot}`); formData.append('file', entry.file); @@ -1092,8 +1095,26 @@ export class EditClientComponent { } }; - if (this.clientKycStep?.existingKycIdentifierId) { - uploadFiles(this.clientKycStep.existingKycIdentifierId); + // A document already on file is updated in place, not posted again. + // + // Re-collecting a document used to create a second identifier carrying the same number and + // the same scans, with nothing to say which of the two was current — the client in front of + // this was left with two identical Aadhaar rows. The document the customer renewed is the + // one already recorded, so its number and type are written back to that row and any new + // scans are attached to it. + const existingId = this.clientKycStep?.existingKycIdentifierId; + if (existingId) { + this.resolveDocumentTypeId(payload.proofId, proofName).subscribe((documentTypeId: number | null) => { + const update: any = { documentKey: payload.documentKey || `${proofName} - ${clientId}`, status: 'Active' }; + if (documentTypeId != null) { + update.documentTypeId = documentTypeId; + } + this.clientsService.updateClientIdentifier(clientId, existingId, update).subscribe({ + // The scans go up either way: failing to rewrite the number must not cost the images. + next: () => uploadFiles(existingId), + error: () => uploadFiles(existingId) + }); + }); return; } diff --git a/src/app/clients/kyc-exception.util.spec.ts b/src/app/clients/kyc-exception.util.spec.ts index 3f77c8e59..c65736fe7 100644 --- a/src/app/clients/kyc-exception.util.spec.ts +++ b/src/app/clients/kyc-exception.util.spec.ts @@ -230,6 +230,23 @@ describe('deriveKycExceptionFields', () => { const both = `${KYC_EXCEPTION_PAN}, ${KYC_EXCEPTION_AADHAAR}`; expect(deriveKycExceptionFields(existingException(false, true, both)).exceptionReason).toBe(KYC_EXCEPTION_PAN); }); + + it('closes a record left open by a verification that passed after the exception was written', () => { + // A verification writes the exception before calling the vendor, so a check that then passes + // leaves `isException` set with the stale reason still loaded into the form's reason list. + // Saving such a record has to close it, not re-raise it off its own stale reason. + expect( + deriveKycExceptionFields({ + ...existingException(true, true, KYC_EXCEPTION_PAN), + manualReasons: [KYC_EXCEPTION_PAN] + }) + ).toEqual({ + isException: false, + exceptionReason: KYC_EXCEPTION_PAN, + exceptionResolved: true, + exceptionResolvedOn: TODAY + }); + }); }); describe('re-opening', () => { diff --git a/src/app/core/utils/api-date.ts b/src/app/core/utils/api-date.ts new file mode 100644 index 000000000..25c64dc54 --- /dev/null +++ b/src/app/core/utils/api-date.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/. + */ + +/** + * A date from the Fineract API as `yyyy-mm-dd`, or '' when there is none. + * + * Fineract serialises dates as `[year, month, day]` arrays, not strings, and reading one as a + * string gives `"2026,6,12"` — which renders literally on screen, and which `new Date()` turns + * into an Invalid Date, so every day-count off it comes out NaN. Two reports have already shipped + * that bug: the dormancy report sat on "Loading..." forever when `toISOString()` threw inside a + * subscribe callback, and the re-KYC report showed "2026,6,12 · Due in NaN days" with all three + * stage counters stuck on zero. + * + * Strings are passed through, since some endpoints are configured to return them. + */ +export function toIsoDate(value: any): string { + if (!value) { + return ''; + } + if (Array.isArray(value)) { + const [ + year, + month, + day + ] = value; + if (!year || !month || !day) { + return ''; + } + return `${year}-${String(month).padStart(2, '0')}-${String(day).padStart(2, '0')}`; + } + return String(value).slice(0, 10); +} diff --git a/src/app/dormancy-report/dormancy-report.component.ts b/src/app/dormancy-report/dormancy-report.component.ts index 7e6404121..39c794881 100644 --- a/src/app/dormancy-report/dormancy-report.component.ts +++ b/src/app/dormancy-report/dormancy-report.component.ts @@ -34,6 +34,7 @@ import { sortByIdle, stageFromSubStatus } from './dormancy-report.compute'; +import { toIsoDate } from 'app/core/utils/api-date'; /** * The three reports on accounts that have gone quiet. @@ -109,9 +110,20 @@ export class DormancyReportComponent implements OnInit { this.clientsService.getDormantAccounts().subscribe({ next: (accounts: any[]) => { - this.rows = sortByIdle( - (accounts || []).map((a) => this.mapAccount(a)).filter((r): r is DormancyRow => r !== null) - ); + // Mapping is wrapped because it did once throw on a date the API returned in a shape this + // screen did not expect. The throw escaped the callback, so `loading` was never cleared and + // the report sat on "Loading..." with every control dead and nothing in the network tab to + // explain it. A bad row should cost the row, not the screen. + try { + this.rows = sortByIdle( + (accounts || []).map((a) => this.mapAccount(a)).filter((r): r is DormancyRow => r !== null) + ); + } catch (e) { + this.rows = []; + this.errorMessage = 'Could not read the dormancy reports.'; + console.error('Dormancy report: could not map the accounts returned by the server', e); + } + this.openFirstStageWithRows(); this.loading = false; }, error: (err: any) => { @@ -300,7 +312,7 @@ export class DormancyReportComponent implements OnInit { return null; } - const lastTxn = (a?.lastCustomerTransactionDate || '').toString().slice(0, 10); + const lastTxn = toIsoDate(a?.lastCustomerTransactionDate); const next = lastTxn ? nextStage(lastTxn, stage) : { date: '', label: '' }; return { @@ -321,10 +333,10 @@ export class DormancyReportComponent implements OnInit { // against it. Absence of that row is the whole point of the report: the money has left the // customer's account but is not yet accounted for as having reached RBI. remittance: stage === 'ESCHEAT' ? (a?.deafRemittedOn ? 'REMITTED' : 'PENDING') : undefined, - escheatedOn: (a?.closedOnDate || '').toString().slice(0, 10) || undefined, - remittedOn: (a?.deafRemittedOn || '').toString().slice(0, 10) || undefined, + escheatedOn: toIsoDate(a?.closedOnDate) || undefined, + remittedOn: toIsoDate(a?.deafRemittedOn) || undefined, deafReference: a?.deafReference || undefined, - refundedOn: (a?.deafRefundedOn || '').toString().slice(0, 10) || undefined, + refundedOn: toIsoDate(a?.deafRefundedOn) || undefined, mobile: a?.mobileNo || '' }; } @@ -391,6 +403,30 @@ export class DormancyReportComponent implements OnInit { } } + /** + * Opens the first stage that actually has accounts on it. + * + * The tab that happens to be selected is not necessarily the one holding the data: the report + * opens on Dormant, and an account that has only reached Inactive — or that fell back to it + * because a customer transaction reset its clock — leaves the reader looking at "No accounts at + * this stage" beside a card counting 1. Only moves off a tab that is empty, so a reader who has + * deliberately opened a stage is not thrown off it when the data reloads. + */ + private openFirstStageWithRows(): void { + if (this.rowsForStage(this.tab).length > 0) { + return; + } + const ladder: DormancyStage[] = [ + 'INACTIVE', + 'DORMANT', + 'ESCHEAT' + ]; + const populated = ladder.find((stage) => this.rowsForStage(stage).length > 0); + if (populated) { + this.showTab(populated); + } + } + // ── Presentation ─────────────────────────────────────────────────────────── /** The server already returns the office by name, so there is nothing to look up. */ diff --git a/src/app/dormancy-report/dormancy-report.compute.ts b/src/app/dormancy-report/dormancy-report.compute.ts index 8b6f09100..bd491edb2 100644 --- a/src/app/dormancy-report/dormancy-report.compute.ts +++ b/src/app/dormancy-report/dormancy-report.compute.ts @@ -111,6 +111,10 @@ export interface DormancyRow { function addDays(date: string, days: number): string { const d = new Date(`${date}T00:00:00Z`); + if (Number.isNaN(d.getTime())) { + // Never let an unparseable date take the whole report down with it. + return ''; + } return new Date(d.getTime() + days * 86400000).toISOString().slice(0, 10); } diff --git a/src/app/kyc-expiry/kyc-expiry.component.ts b/src/app/kyc-expiry/kyc-expiry.component.ts index bc95be300..70054c079 100644 --- a/src/app/kyc-expiry/kyc-expiry.component.ts +++ b/src/app/kyc-expiry/kyc-expiry.component.ts @@ -19,6 +19,7 @@ import { FaIconComponent } from '@fortawesome/angular-fontawesome'; /** Custom Services */ import { ClientsService } from '../clients/clients.service'; +import { toIsoDate } from 'app/core/utils/api-date'; /** Report data + computation */ import { @@ -176,7 +177,7 @@ export class KycExpiryComponent implements OnInit { accountNo: r?.clientAccountNo || '', branchCode: r?.clientOfficeName || '', riskCategory: r?.riskCategory ?? 1, - expiryDate: (r?.expiryDate || '').toString().slice(0, 10), + expiryDate: toIsoDate(r?.expiryDate), // Only the masked forms come back from the server, which is what the dialog shows and all it // needs — the destination itself is resolved server-side when a notice is sent. mobile: r?.clientMobileMasked || (r?.hasMobile ? 'On file' : ''), @@ -187,7 +188,7 @@ export class KycExpiryComponent implements OnInit { private mapContact(log: any): KycContactRow { const status = log?.deliveryStatus || 'NOT_APPLICABLE'; return { - date: (log?.contactedOn || log?.createdOn || '').toString().slice(0, 10), + date: toIsoDate(log?.contactedOn || log?.createdOn), channel: log?.channel || '', channelLabel: channelLabel(log?.channel || ''), notes: log?.notes || '', diff --git a/src/assets/env.js b/src/assets/env.js index db4201002..3d4e606c0 100644 --- a/src/assets/env.js +++ b/src/assets/env.js @@ -17,8 +17,11 @@ window["env"]["apiVersion"] = ''; window["env"]["apiActuator"] = ''; - window["env"]["fineractPlatformTenantId"] = 'abc_bank'; - window["env"]["fineractPlatformTenantIds"] = 'default,abc_bank,socity_bank'; + // Tenants the login screen offers, and which one it starts on. Every identifier here has to exist + // in the tenants table of the tenant-store database, or the platform rejects the request as an + // unknown tenant — 'socity_bank' was listed with no such row behind it. + window["env"]["fineractPlatformTenantId"] = 'default'; + window["env"]["fineractPlatformTenantIds"] = 'default,abc_bank'; window['env']['tenantLogoUrl'] = ''; window['env']['tenantLogoUrlDark'] = ''; diff --git a/src/environments/.env.ts b/src/environments/.env.ts index db38cab6f..84cda5934 100644 --- a/src/environments/.env.ts +++ b/src/environments/.env.ts @@ -9,8 +9,8 @@ /* tslint:disable */ export default { 'mifos_x': { - 'version': '260909', - 'hash': '03bc2ff1' + 'version': '260910', + 'hash': 'f0c06988' }, 'allow_switching_backend_instance': true }; From d8a2deec3fe5fc9eb91dd1d8e12d51109d51aa32 Mon Sep 17 00:00:00 2001 From: keshav Date: Fri, 11 Sep 2026 10:48:32 +0530 Subject: [PATCH 39/64] feat(savings): add rounding adjustment account mapping in savings product accounting --- .../saving-product-accounting-step.component.ts | 16 +++++++++++++++- src/environments/.env.ts | 4 ++-- 2 files changed, 17 insertions(+), 3 deletions(-) diff --git a/src/app/products/saving-products/saving-product-stepper/saving-product-accounting-step/saving-product-accounting-step.component.ts b/src/app/products/saving-products/saving-product-stepper/saving-product-accounting-step/saving-product-accounting-step.component.ts index 29c600230..9f45429ff 100644 --- a/src/app/products/saving-products/saving-product-stepper/saving-product-accounting-step/saving-product-accounting-step.component.ts +++ b/src/app/products/saving-products/saving-product-stepper/saving-product-accounting-step/saving-product-accounting-step.component.ts @@ -193,7 +193,8 @@ export class SavingProductAccountingStepComponent implements OnInit { penaltiesReceivableAccountId: mappings.penaltyReceivableAccount?.id || '', interestPayableAccountId: mappings.interestPayableAccount?.id || '', interestReceivableAccountId: mappings.interestReceivableAccount?.id || '', - escheatLiabilityId: mappings.escheatLiabilityAccount?.id || '' + escheatLiabilityId: mappings.escheatLiabilityAccount?.id || '', + roundingAdjustmentAccountId: mappings.roundingAdjustmentAccount?.id || '' }); if (this.isCashOrAccrualAccounting()) { @@ -385,6 +386,9 @@ export class SavingProductAccountingStepComponent implements OnInit { // rather than as named accounting params, which would be silently dropped. Optional by design. addControl('savingsInterestPayableAccountId', false); addControl('savingsInterestPaidAccountId', false); + // A real accounting param, unlike the two above, so it rides out as itself rather than + // inside otherGlMappings. Optional: a product saved without one still closes. + addControl('roundingAdjustmentAccountId', false); // Transfers In Suspense / Income From Interest / Write Off / Overdraft Portfolio Control are // intentionally not offered on this screen, so their controls are never added — leaving them in // with required validators would keep the form invalid with no visible field to fix. @@ -427,6 +431,7 @@ export class SavingProductAccountingStepComponent implements OnInit { removeControl('escheatLiabilityId'); removeControl('savingsInterestPayableAccountId'); removeControl('savingsInterestPaidAccountId'); + removeControl('roundingAdjustmentAccountId'); this.clearOtherGlMappings(); } } @@ -563,6 +568,15 @@ export class SavingProductAccountingStepComponent implements OnInit { inputLabel: 'Savings interest paid', controlName: 'savingsInterestPaidAccountId', options: this.expenseAccountOptions + }, + { + // Cash has no paise, so a closing balance of 9,883.79 is handed over as 9,883 and the + // 79 paise land here rather than on the member's own ledger. + key: 'roundingAdjustmentAccountId', + label: 'Rounding Adjustment Account', + inputLabel: 'Rounding adjustment account', + controlName: 'roundingAdjustmentAccountId', + options: this.expenseAccountOptions } ], otherMappingsControlName: 'expensesOtherGlMappings', diff --git a/src/environments/.env.ts b/src/environments/.env.ts index 84cda5934..ea6c18341 100644 --- a/src/environments/.env.ts +++ b/src/environments/.env.ts @@ -9,8 +9,8 @@ /* tslint:disable */ export default { 'mifos_x': { - 'version': '260910', - 'hash': 'f0c06988' + 'version': '260911', + 'hash': 'fd5fe6e3' }, 'allow_switching_backend_instance': true }; From 42424dff8b2aaea861ad3372adda92496cec3c21 Mon Sep 17 00:00:00 2001 From: Nitesh Yadav Date: Fri, 11 Sep 2026 15:13:29 +0530 Subject: [PATCH 40/64] feat(security): tenant-level nav menu access control panel MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds the frontend half of the per-tenant menu-access ceiling (TenantMenuAccessApiResource on the backend): a bank cannot show a top-nav section the platform operator has capped, no matter what any role inside that tenant grants itself — closing the gap where a role carrying ALL_FUNCTIONS (as "Clerk" was found to, in two tenants this session) bypassed every ordinary MENU_.../SIDEBAR_... permission check. - tenant-menu-access.service.ts: loads the current tenant's list once, into a plain Set (not an Observable the directive would have to subscribe to, since mifosxHasPermission's setter runs synchronously). A failed load is treated as "nothing disabled" so a network hiccup on login never hides the whole nav. - has-permission.directive.ts: checks the tenant's ceiling first, ahead of the ALL_FUNCTIONS bypass — that ordering is the actual fix. - shell.component.ts/html: the nav now waits on the list being loaded before rendering at all, so a disabled menu is never shown and then hidden a moment later. - New standalone platform-admin/menu-access screen (#/platform-admin/ menu-access), reachable without logging in — its own X-Api-Key is the real boundary, checked on the backend, not this route. Lets the operator pick any tenant and toggle its menu list. Redesigned per request: full-width layout, icon-matched toggle tiles (same icon per item as the real nav), live enabled-count, connection status. - Loan Origination and Investment now use their own MENU_LOAN_ORIGINATION / MENU_INVESTMENT codes (see backend 0542) instead of sharing MENU_ADVANCES / MENU_RBI, so all 27 nav items are independently controllable from this screen. Also: src/assets/env.js — restored 'socity_bank' to fineractPlatformTenantIds. The tenant now exists in the tenant-store database; the comment explaining its earlier removal was stale. --- src/app/app-routing.module.ts | 7 + src/app/core/shell/shell.component.html | 11 +- src/app/core/shell/shell.component.ts | 11 + .../has-permission.directive.ts | 9 + .../tenant-menu-access.service.ts | 61 +++ .../menu-access/menu-access.component.html | 135 ++++++ .../menu-access/menu-access.component.scss | 431 ++++++++++++++++++ .../menu-access/menu-access.component.ts | 217 +++++++++ src/assets/env.js | 4 +- src/environments/.env.ts | 2 +- 10 files changed, 882 insertions(+), 6 deletions(-) create mode 100644 src/app/directives/has-permission/tenant-menu-access.service.ts create mode 100644 src/app/platform-admin/menu-access/menu-access.component.html create mode 100644 src/app/platform-admin/menu-access/menu-access.component.scss create mode 100644 src/app/platform-admin/menu-access/menu-access.component.ts diff --git a/src/app/app-routing.module.ts b/src/app/app-routing.module.ts index c2317b327..4cfde7def 100644 --- a/src/app/app-routing.module.ts +++ b/src/app/app-routing.module.ts @@ -13,6 +13,7 @@ import { Routes, RouterModule } from '@angular/router'; // Not Found Component import { NotFoundComponent } from './not-found/not-found.component'; import { CallbackComponent } from './zitadel/callback/callback.component'; +import { PlatformMenuAccessComponent } from './platform-admin/menu-access/menu-access.component'; /** * Fallback to this route when no prior route is matched. @@ -22,6 +23,12 @@ const routes: Routes = [ path: 'callback', component: CallbackComponent }, + { + // Platform-operator only — outside the normal login/tenant flow on purpose, see + // PlatformMenuAccessComponent's own doc comment for why that is safe. + path: 'platform-admin/menu-access', + component: PlatformMenuAccessComponent + }, { path: '**', component: NotFoundComponent diff --git a/src/app/core/shell/shell.component.html b/src/app/core/shell/shell.component.html index a2390e459..cf61082bd 100644 --- a/src/app/core/shell/shell.component.html +++ b/src/app/core/shell/shell.component.html @@ -473,7 +473,7 @@ ════════ END OLD ORDER ════════ --> - @if (!hideShellLayout) { + @if (!hideShellLayout && menuAccessReady) {
diff --git a/src/app/accounting/accounting.component.ts b/src/app/accounting/accounting.component.ts index cf9771660..c1318e154 100644 --- a/src/app/accounting/accounting.component.ts +++ b/src/app/accounting/accounting.component.ts @@ -18,6 +18,7 @@ import { MatIcon } from '@angular/material/icon'; import { FaIconComponent } from '@fortawesome/angular-fontawesome'; import { MatLine } from '@angular/material/grid-list'; import { STANDALONE_SHARED_IMPORTS } from 'app/standalone-shared.module'; +import { DISABLED_ACCOUNTING_FEATURES } from './disabled-features'; /** * Accounting component. @@ -63,6 +64,9 @@ export class AccountingComponent implements AfterViewInit { // Initialize an array of 10 boolean values, all set to false arrowBooleans: boolean[] = new Array(10).fill(false); + /** Which accounting tiles are switched off for this installation — see disabled-features.ts. */ + disabledFeatures = DISABLED_ACCOUNTING_FEATURES; + /** * Popover function * @param template TemplateRef. @@ -104,12 +108,15 @@ export class AccountingComponent implements AfterViewInit { ); }); } - if (this.configurationWizardService.showClosingEntries) { + // Closing Entries and Create Journal Entries can be switched off (see disabled-features.ts), in + // which case their tiles never render and these refs stay undefined — the wizard simply has no + // step to point at, rather than throwing on a missing element. + if (this.configurationWizardService.showClosingEntries && this.closingEntries) { setTimeout(() => { this.showPopover(this.templateClosingEntries, this.closingEntries.nativeElement, 'bottom', true); }); } - if (this.configurationWizardService.showCreateJournalEntries) { + if (this.configurationWizardService.showCreateJournalEntries && this.createJournalEntries) { setTimeout(() => { this.showPopover(this.templateCreateJournalEntries, this.createJournalEntries.nativeElement, 'bottom', true); }); diff --git a/src/app/accounting/accounting.service.ts b/src/app/accounting/accounting.service.ts index 69b896779..2143fbd22 100644 --- a/src/app/accounting/accounting.service.ts +++ b/src/app/accounting/accounting.service.ts @@ -116,6 +116,22 @@ export class AccountingService { return this.http.get(`/glaccounts`, { params: httpParams }); } + /** + * As-on-date balance sheet, grouped by Header/Detail hierarchy — see BalanceSheetApiResource. + * @param asOnDate yyyy-MM-dd. Defaults to today on the backend when omitted. + * @param officeId Optional — scopes every balance to one office instead of the whole organization. + */ + getBalanceSheet(asOnDate?: string, officeId?: number): Observable { + let httpParams = new HttpParams(); + if (asOnDate) { + httpParams = httpParams.set('asOnDate', asOnDate); + } + if (officeId != null) { + httpParams = httpParams.set('officeId', officeId.toString()); + } + return this.http.get('/accounting/balancesheet', { params: httpParams }); + } + /** * @param transactionId Transaction ID of journal entries. * @returns {Observable} Journal Entries. diff --git a/src/app/accounting/balance-sheet/balance-sheet-pdf.ts b/src/app/accounting/balance-sheet/balance-sheet-pdf.ts new file mode 100644 index 000000000..954ec2e52 --- /dev/null +++ b/src/app/accounting/balance-sheet/balance-sheet-pdf.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/. + */ + +/* + * Builds the Consolidated Balance Sheet PDF, laid out like the bank's printed report: liabilities + * on the left, assets on the right, each top-level group a named block with its accounts listed + * under it and a subtotal in the TOTAL column. + * + * Drawn directly with jsPDF rather than printed from the DOM: window.print() carries the whole app + * shell (sidenav, toolbar, ticker) into the output and gives no control over where a page breaks, + * which is what made the printed sheet unreadable. Here each column is paginated on its own, the + * way the printed report does it — the left side can run onto page 2 while the right side is still + * on page 1 — and every page repeats the heading. + */ + +import { jsPDF } from 'jspdf'; + +export interface BalanceSheetPdfNode { + name: string; + glCode: string; + usage: 'HEADER' | 'DETAIL'; + balance: number; + children: BalanceSheetPdfNode[]; +} + +export interface BalanceSheetPdfSide { + label: string; + total: number; + groups: { classification: string; total: number; nodes: BalanceSheetPdfNode[] }[]; +} + +export interface BalanceSheetPdfInput { + bankName: string; + officeLabel: string; + /** yyyy-MM-dd */ + asOnDate: string; + creditSide: BalanceSheetPdfSide; + debitSide: BalanceSheetPdfSide; +} + +// ── Page geometry, in mm on A4 portrait ───────────────────────────────────── +const PAGE_W = 210; +const PAGE_H = 297; +const MARGIN = 8; +const COL_W = (PAGE_W - MARGIN * 2) / 2; +const COL_X = [ + MARGIN, + MARGIN + COL_W +]; +/** Right edges of the BALANCE and TOTAL columns, relative to a column's left edge. */ +const BALANCE_R = COL_W - 26; +const TOTAL_R = COL_W - 2; +/** Name text wraps within this width so it never runs under the BALANCE column. */ +const NAME_W = COL_W - 30; + +const BODY_TOP = 36; +/** Leaves room under the body for the sign-off row and the print-date footer. */ +const BODY_BOTTOM = PAGE_H - 21; +const LINE_H = 3.1; + +const INK: [number, number, number] = [ + 20, + 20, + 20 +]; +const MUTED: [number, number, number] = [ + 90, + 90, + 90 +]; + +type Line = + | { kind: 'block'; text: string } + | { kind: 'subhead'; text: string; depth: number } + | { kind: 'detail'; lines: string[]; amount: string; depth: number } + | { kind: 'subsubtotal'; amount: string } + | { kind: 'subtotal'; amount: string } + | { kind: 'standalone'; lines: string[]; amount: string } + | { kind: 'grand'; amount: string } + | { kind: 'gap' }; + +function money(value: number): string { + return new Intl.NumberFormat('en-IN', { minimumFractionDigits: 2, maximumFractionDigits: 2 }).format(value ?? 0); +} + +function ddmmyyyy(iso: string): string { + if (!iso) { + return ''; + } + const [ + y, + m, + d + ] = iso.split('-'); + return `${d}/${m}/${y}`; +} + +function heightOf(line: Line): number { + switch (line.kind) { + case 'block': + // Heading plus the short dashed rule beneath it. + return LINE_H + 1.8; + case 'detail': + case 'standalone': + return LINE_H * line.lines.length; + case 'gap': + return 2.4; + case 'grand': + // Rule above, the figure, and the double rule beneath it. + return LINE_H + 6; + default: + return LINE_H; + } +} + +/** + * Flattens one side into the exact sequence of printed lines. Done up front so pagination is a + * matter of measuring heights, with no recursion happening mid-page. + */ +function flatten(doc: jsPDF, side: BalanceSheetPdfSide): Line[] { + const out: Line[] = []; + // splitTextToSize measures with whatever font is currently set, so it has to match the font the + // detail lines are actually drawn in — otherwise names wrap at the wrong width. + doc.setFont('helvetica', 'normal').setFontSize(6.8); + + const pushNode = (node: BalanceSheetPdfNode, depth: number): void => { + if (node.usage === 'DETAIL') { + const indent = depth * 2; + const wrapped: string[] = doc.splitTextToSize(node.name, NAME_W - indent); + out.push({ kind: 'detail', lines: wrapped, amount: money(node.balance), depth }); + return; + } + out.push({ kind: 'subhead', text: node.name, depth }); + node.children.forEach((child) => pushNode(child, depth + 1)); + out.push({ kind: 'subsubtotal', amount: money(node.balance) }); + }; + + for (const group of side.groups) { + for (const root of group.nodes) { + if (root.usage === 'DETAIL') { + // A Detail account sitting under no group at all: a group of one, printed on a single line + // carrying both its balance and its total, rather than a heading and a figure on separate + // rows with the BALANCE column left empty. + out.push({ + kind: 'standalone', + lines: doc.splitTextToSize(root.name.toUpperCase(), NAME_W), + amount: money(root.balance) + }); + out.push({ kind: 'gap' }); + continue; + } + out.push({ kind: 'block', text: root.name }); + root.children.forEach((child) => pushNode(child, 0)); + out.push({ kind: 'subtotal', amount: money(root.balance) }); + out.push({ kind: 'gap' }); + } + } + // Carried as a line of its own so pagination reserves room for it, rather than squeezing it + // against the bottom margin under the last block. + out.push({ kind: 'grand', amount: money(side.total) }); + return out; +} + +function dottedLine(doc: jsPDF, x1: number, y: number, x2: number): void { + doc.setLineDashPattern( + [ + 0.4, + 0.6 + ], + 0 + ); + doc.setLineWidth(0.15); + doc.line(x1, y, x2, y); + doc.setLineDashPattern([], 0); +} + +function drawPageFrame(doc: jsPDF, input: BalanceSheetPdfInput, page: number, pageCount: number): void { + doc.setTextColor(...INK); + + doc.setFont('helvetica', 'bold').setFontSize(9.5); + doc.text(`${input.bankName.toUpperCase()} , HEAD OFFICE`, PAGE_W / 2, 11, { align: 'center' }); + + doc.setFontSize(8); + doc.text(`CONSOLIDATED BALANCE SHEET (SUB GROUP WISE) AS ON : ${ddmmyyyy(input.asOnDate)}`, PAGE_W / 2, 16, { + align: 'center' + }); + doc.setFont('helvetica', 'normal').setFontSize(7); + doc.text(`Page ${page} of ${pageCount}`, PAGE_W - MARGIN, 16, { align: 'right' }); + doc.text(`FOR BRANCHES : ${input.officeLabel}`, PAGE_W / 2, 20, { align: 'center' }); + + dottedLine(doc, MARGIN, 23, PAGE_W - MARGIN); + + // Side titles and column headings, drawn per column. + const titles = [ + 'LIABILITIES ( CREDIT )', + 'ASSETS ( DEBIT )' + ]; + for (let c = 0; c < 2; c++) { + const x = COL_X[c]; + doc.setFont('helvetica', 'bold').setFontSize(7.5); + doc.text(titles[c], x + 1, 27.5); + doc.setFontSize(6.5); + doc.text('ACCOUNT NAME', x + 1, 32); + doc.text('BALANCE', x + BALANCE_R, 32, { align: 'right' }); + doc.text('TOTAL', x + TOTAL_R, 32, { align: 'right' }); + dottedLine(doc, x + 1, 33.4, x + COL_W - 2); + } + + // The divider between the two sides. + doc.setLineWidth(0.15); + doc.setDrawColor(150, 150, 150); + doc.line(COL_X[1], 24, COL_X[1], BODY_BOTTOM + 2); + doc.setDrawColor(0, 0, 0); + + // Footer. + dottedLine(doc, MARGIN, BODY_BOTTOM + 6, PAGE_W - MARGIN); + doc + .setFont('helvetica', 'normal') + .setFontSize(6.5) + .setTextColor(...MUTED); + const now = new Date(); + const stamp = + `${`${now.getDate()}`.padStart(2, '0')}/${`${now.getMonth() + 1}`.padStart(2, '0')}/${now.getFullYear()} ` + + `${`${now.getHours()}`.padStart(2, '0')}:${`${now.getMinutes()}`.padStart(2, '0')}:${`${now.getSeconds()}`.padStart(2, '0')}`; + doc.text(`Print Date : ${stamp}`, MARGIN, BODY_BOTTOM + 10); + doc.text('Consolidated Balance Sheet (Sub Group Wise)', PAGE_W - MARGIN, BODY_BOTTOM + 10, { align: 'right' }); + doc.setTextColor(...INK); +} + +/** Draws one line into a column at y, and returns the y the next line starts at. */ +function drawLine(doc: jsPDF, line: Line, x: number, y: number): number { + switch (line.kind) { + case 'block': { + doc.setFont('helvetica', 'bold').setFontSize(7); + doc.text(line.text.toUpperCase(), x + 1, y); + dottedLine(doc, x + 1, y + 1.1, x + 34); + return y + heightOf(line); + } + case 'subhead': { + doc.setFont('helvetica', 'bold').setFontSize(6.8); + doc.text(line.text, x + 1 + line.depth * 2, y); + return y + heightOf(line); + } + case 'detail': { + doc.setFont('helvetica', 'normal').setFontSize(6.8); + const indent = x + 2 + line.depth * 2; + line.lines.forEach((text, i) => doc.text(text, indent, y + i * LINE_H)); + // The amount sits on the first line of a wrapped name, as the printed report has it. + doc.text(line.amount, x + BALANCE_R, y, { align: 'right' }); + return y + heightOf(line); + } + case 'subsubtotal': { + doc.setFont('helvetica', 'bold').setFontSize(6.8); + dottedLine(doc, x + BALANCE_R - 22, y - 2.2, x + BALANCE_R); + doc.text(line.amount, x + BALANCE_R, y, { align: 'right' }); + return y + heightOf(line); + } + case 'subtotal': { + doc.setFont('helvetica', 'bold').setFontSize(7); + doc.text(line.amount, x + TOTAL_R, y, { align: 'right' }); + return y + heightOf(line); + } + case 'standalone': { + doc.setFont('helvetica', 'bold').setFontSize(7); + line.lines.forEach((text, i) => doc.text(text, x + 1, y + i * LINE_H)); + doc.text(line.amount, x + BALANCE_R, y, { align: 'right' }); + doc.text(line.amount, x + TOTAL_R, y, { align: 'right' }); + return y + heightOf(line); + } + case 'grand': { + const top = y + 1.5; + doc.setLineWidth(0.3); + doc.line(x + 1, top, x + COL_W - 2, top); + doc.setFont('helvetica', 'bold').setFontSize(7.5); + doc.text(line.amount, x + TOTAL_R, top + 3.6, { align: 'right' }); + doc.setLineWidth(0.2); + doc.line(x + 1, top + 5, x + COL_W - 2, top + 5); + doc.line(x + 1, top + 5.7, x + COL_W - 2, top + 5.7); + return y + heightOf(line); + } + default: + return y + heightOf(line); + } +} + +/** Splits one side's lines into per-page chunks that fit between BODY_TOP and BODY_BOTTOM. */ +function paginate(lines: Line[]): Line[][] { + const pages: Line[][] = []; + let current: Line[] = []; + let y = BODY_TOP; + + for (const line of lines) { + const h = heightOf(line); + if (y + h > BODY_BOTTOM) { + pages.push(current); + current = []; + y = BODY_TOP; + } + current.push(line); + y += h; + } + // Keep the trailing chunk only if it has something on it, but always return at least one page so + // a side with no balances still gets its heading and a zero total. + if (current.length > 0 || pages.length === 0) { + pages.push(current); + } + return pages; +} + +export function buildBalanceSheetPdf(input: BalanceSheetPdfInput): jsPDF { + const doc = new jsPDF({ orientation: 'p', unit: 'mm', format: 'a4', compress: true }); + + const creditPages = paginate(flatten(doc, input.creditSide)); + const debitPages = paginate(flatten(doc, input.debitSide)); + // Both sides print side by side, so the sheet runs as long as the longer of the two, plus a + // final page for the grand totals when they would not fit under the last block. + const pageCount = Math.max(creditPages.length, debitPages.length); + + for (let p = 0; p < pageCount; p++) { + if (p > 0) { + doc.addPage(); + } + drawPageFrame(doc, input, p + 1, pageCount); + + const sides = [ + creditPages[p] ?? [], + debitPages[p] ?? [] + ]; + + for (let c = 0; c < 2; c++) { + const x = COL_X[c]; + let y = BODY_TOP; + for (const line of sides[c]) { + y = drawLine(doc, line, x, y); + } + } + + // Sign-off row, on the closing page only. + if (p === pageCount - 1) { + doc + .setFont('helvetica', 'normal') + .setFontSize(6.5) + .setTextColor(...MUTED); + const roles = [ + 'CLERK', + 'ACCOUNTANT / OFFICER', + 'ASST. GEN MANAGER', + 'GENERAL MANAGER', + 'CHAIRMAN' + ]; + const step = (PAGE_W - MARGIN * 2) / roles.length; + roles.forEach((role, i) => doc.text(role, MARGIN + step * i + step / 2, BODY_BOTTOM + 2, { align: 'center' })); + doc.setTextColor(...INK); + } + } + + return doc; +} diff --git a/src/app/accounting/balance-sheet/balance-sheet.component.html b/src/app/accounting/balance-sheet/balance-sheet.component.html new file mode 100644 index 000000000..883d4bcb5 --- /dev/null +++ b/src/app/accounting/balance-sheet/balance-sheet.component.html @@ -0,0 +1,182 @@ + + +
+ +
+ + + + + @if (!loading && debitSide && creditSide) { + + {{ tallied ? 'Balanced' : 'Out of balance by ' + fmt(difference) }} + + } + + +
+ + @if (error) { +
{{ error }}
+ } + + @if (loading) { +
Loading balance sheet…
+ } + + @if (!loading && debitSide && creditSide) { + +
+
+

CONSOLIDATED BALANCE SHEET (SUB GROUP WISE)

+

AS ON : {{ displayDate(asOnDateLoaded) }}

+

FOR BRANCHES : {{ officeLabel }}

+
+ +
+ +
+
LIABILITIES ( CREDIT )
+
+ ACCOUNT NAME + BALANCE + TOTAL +
+ @for (group of creditSide.groups; track group.classification) { + @for (root of group.nodes; track root.id) { + + } + } + @if (creditSide.groups.length === 0) { +
No liability, equity or income balances as on this date.
+ } +
+ + + {{ fmt(creditSide.total) }} +
+
+ +
+
ASSETS ( DEBIT )
+
+ ACCOUNT NAME + BALANCE + TOTAL +
+ @for (group of debitSide.groups; track group.classification) { + @for (root of group.nodes; track root.id) { + + } + } + @if (debitSide.groups.length === 0) { +
No asset or expense balances as on this date.
+ } +
+ + + {{ fmt(debitSide.total) }} +
+
+
+ +
+ CLERK + ACCOUNTANT / OFFICER + ASST. GEN MANAGER + GENERAL MANAGER + CHAIRMAN +
+
+ } +
+ + + + @if (root.usage === 'DETAIL') { + +
+
+ {{ root.name }} + {{ fmt(root.balance) }} + {{ fmt(root.balance) }} +
+
+ } @else { +
+
{{ root.name }}
+
+ @for (child of root.children; track child.id) { + + } + @if (root.children.length === 0) { +
+ No accounts under this group + + +
+ } +
+ + + {{ fmt(root.balance) }} +
+
+ } +
+ + + + @if (node.usage === 'DETAIL') { +
+ {{ node.name }} + {{ fmt(node.balance) }} + +
+ } @else { +
+ {{ node.name }} + + +
+ @for (child of node.children; track child.id) { + + } +
+ + {{ fmt(node.balance) }} + +
+ } +
diff --git a/src/app/accounting/balance-sheet/balance-sheet.component.scss b/src/app/accounting/balance-sheet/balance-sheet.component.scss new file mode 100644 index 000000000..3bbfe893b --- /dev/null +++ b/src/app/accounting/balance-sheet/balance-sheet.component.scss @@ -0,0 +1,372 @@ +/** + * Copyright since 2025 Mifos Initiative + * + * This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. + */ + +$ink: #111827; +$ink-soft: #374151; +$ink-faint: #9ca3af; +$rule: #cbd5e1; +$border: #e2e8f0; +$accent: #1d4ed8; +$ok: #15803d; +$ok-soft: #f0fdf4; +$danger: #b91c1c; +$danger-soft: #fef2f2; + +// Amount columns share one width so BALANCE and TOTAL line up down the whole sheet. +$amt-col: 128px; + +.bs-page { + padding: 20px 24px 40px; + background: #f1f5f9; + min-height: 100%; +} + +// ── Controls (screen only) ──────────────────────────────────────────────── +.bs-controls { + display: flex; + align-items: flex-end; + gap: 14px; + flex-wrap: wrap; + margin-bottom: 18px; + + &__spacer { + flex: 1 1 auto; + } +} + +.bs-field { + display: flex; + flex-direction: column; + gap: 4px; + font-size: 10.5px; + font-weight: 700; + text-transform: uppercase; + letter-spacing: 0.06em; + color: $ink-soft; + + input, + select { + height: 36px; + padding: 0 10px; + border: 1px solid $border; + border-radius: 6px; + background: #fff; + font-size: 13px; + font-weight: 500; + text-transform: none; + letter-spacing: normal; + color: $ink; + } +} + +.bs-check { + display: flex; + align-items: center; + gap: 6px; + height: 36px; + font-size: 12.5px; + font-weight: 600; + color: $ink-soft; + cursor: pointer; + user-select: none; + + input { + width: 15px; + height: 15px; + cursor: pointer; + accent-color: $accent; + } +} + +.bs-btn { + height: 36px; + padding: 0 16px; + border: 1px solid $border; + border-radius: 6px; + background: #fff; + color: $ink-soft; + font-size: 12.5px; + font-weight: 700; + cursor: pointer; + + &--primary { + background: $ink; + border-color: $ink; + color: #fff; + } + + &:disabled { + opacity: 0.55; + cursor: not-allowed; + } +} + +.bs-tally { + padding: 6px 12px; + border-radius: 999px; + background: $ok-soft; + color: $ok; + font-size: 12px; + font-weight: 700; + + &--off { + background: $danger-soft; + color: $danger; + } +} + +.bs-alert { + padding: 10px 14px; + border-radius: 6px; + background: $danger-soft; + color: $danger; + border: 1px solid #fecaca; + font-size: 13px; + margin-bottom: 14px; +} + +.bs-loading { + padding: 48px 0; + text-align: center; + color: $ink-faint; + font-size: 13.5px; +} + +// ── The printed sheet ───────────────────────────────────────────────────── +.bs-sheet { + background: #fff; + border: 1px solid $border; + padding: 22px 26px 18px; + // The ledger reads as a printed report, so the whole sheet is set in a mono face with + // figures on tabular numerals — columns stay aligned however long the names run. + font-family: 'DejaVu Sans Mono', 'Liberation Mono', Consolas, 'Courier New', monospace; + font-variant-numeric: tabular-nums; + color: $ink; + + &__head { + text-align: center; + border-bottom: 1px dotted $rule; + padding-bottom: 10px; + margin-bottom: 12px; + + h2 { + margin: 0; + font-size: 13.5px; + font-weight: 700; + letter-spacing: 0.04em; + } + + p { + margin: 4px 0 0; + font-size: 11.5px; + color: $ink-soft; + } + } + + &__branches { + letter-spacing: 0.05em; + } + + &__body { + display: grid; + grid-template-columns: 1fr 1fr; + gap: 0; + } + + &__foot { + display: flex; + justify-content: space-between; + gap: 8px; + margin-top: 26px; + padding-top: 10px; + border-top: 1px dotted $rule; + font-size: 10px; + letter-spacing: 0.05em; + color: $ink-soft; + } +} + +.bs-col { + padding: 0 16px; + min-width: 0; + + &:first-child { + border-right: 1px dotted $rule; + padding-left: 0; + } + + &:last-child { + padding-right: 0; + } + + &__title { + font-size: 12px; + font-weight: 700; + letter-spacing: 0.05em; + padding-bottom: 6px; + } +} + +// ── Rows: one grid so name / BALANCE / TOTAL align across every block ────── +.bs-row { + display: grid; + grid-template-columns: minmax(0, 1fr) $amt-col $amt-col; + gap: 8px; + align-items: baseline; + font-size: 11.5px; + line-height: 1.55; + + > span:nth-child(2), + > span:nth-child(3) { + text-align: right; + white-space: nowrap; + } + + &--colhead { + font-weight: 700; + font-size: 10.5px; + letter-spacing: 0.05em; + border-bottom: 1px dotted $rule; + padding-bottom: 4px; + margin-bottom: 6px; + color: $ink-soft; + } + + &--line { + color: $ink-soft; + } + + // A nested Header inside a block: its own named sub-group. + &--subhead { + font-weight: 700; + color: $ink; + padding-top: 3px; + } + + // Sub-group subtotal sits in the BALANCE column, so it never collides with the + // block's own figure in TOTAL. + &--subsubtotal { + border-top: 1px dotted $rule; + font-weight: 700; + color: $accent; + } + + // Top-level block subtotal, in the TOTAL column. + &--subtotal { + font-weight: 700; + color: $ink; + padding-top: 2px; + } + + // A Header-less Detail account: its own one-line group. + &--standalone { + font-weight: 700; + color: $ink; + + .bs-name { + text-transform: uppercase; + letter-spacing: 0.03em; + } + } + + &--grand { + margin-top: 14px; + padding-top: 8px; + border-top: 1px solid $ink; + border-bottom: 3px double $ink; + font-weight: 700; + font-size: 12.5px; + } +} + +.bs-name { + overflow-wrap: anywhere; + + &--muted { + color: $ink-faint; + font-style: italic; + } +} + +.bs-amt { + font-variant-numeric: tabular-nums; +} + +.bs-block { + margin-bottom: 14px; + + &--single { + margin-bottom: 10px; + } + + &__name { + font-size: 11.5px; + font-weight: 700; + letter-spacing: 0.03em; + text-transform: uppercase; + } + + // The short dashed rule the printed sheet puts under every group heading. + &__rule { + width: 130px; + border-top: 1px dotted $ink-faint; + margin: 2px 0 4px; + } +} + +.bs-empty { + padding: 14px 0; + font-size: 11.5px; + color: $ink-faint; +} + +// ── Narrow screens: stack the two sides instead of squeezing the columns ─── +@media (width <= 1100px) { + .bs-sheet__body { + grid-template-columns: 1fr; + } + + .bs-col { + padding: 0; + + &:first-child { + border-right: none; + border-bottom: 1px dotted $rule; + padding-bottom: 16px; + margin-bottom: 16px; + } + } +} + +// ── Print: only the sheet, at report proportions ─────────────────────────── +@media print { + .bs-controls, + .bs-alert, + .bs-loading { + display: none !important; + } + + .bs-page { + padding: 0; + background: #fff; + } + + .bs-sheet { + border: none; + padding: 0; + font-size: 10px; + } + + .bs-sheet__body { + grid-template-columns: 1fr 1fr; + } + + .bs-block { + break-inside: avoid; + } +} diff --git a/src/app/accounting/balance-sheet/balance-sheet.component.ts b/src/app/accounting/balance-sheet/balance-sheet.component.ts new file mode 100644 index 000000000..fff932b9e --- /dev/null +++ b/src/app/accounting/balance-sheet/balance-sheet.component.ts @@ -0,0 +1,218 @@ +/** + * 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 { AccountingService } from '../accounting.service'; +import { OrganizationService } from '../../organization/organization.service'; +import { buildBalanceSheetPdf } from './balance-sheet-pdf'; + +interface BalanceSheetNode { + id: number; + name: string; + glCode: string; + usage: 'HEADER' | 'DETAIL'; + balance: number; + children: BalanceSheetNode[]; +} + +interface BalanceSheetGroup { + classification: string; + total: number; + nodes: BalanceSheetNode[]; +} + +interface BalanceSheetSide { + label: string; + total: number; + groups: BalanceSheetGroup[]; +} + +/** + * Consolidated Balance Sheet (sub-group wise), laid out the way the bank's own printed balance + * sheet reads: liabilities on the left, assets on the right, each top-level Header account a named + * block whose Detail accounts are listed under it and subtotalled in the TOTAL column. + * + * Balances come from BalanceSheetApiResource, which reads the live ledger - a Detail account's own + * postings, and a Header's as the recursive sum of its children (a Header never carries a posting + * of its own). A Detail account that sits under no Header at all is shown as its own one-line block + * rather than being dropped. + */ +@Component({ + selector: 'mifosx-balance-sheet', + standalone: true, + imports: [ + CommonModule, + FormsModule + ], + templateUrl: './balance-sheet.component.html', + styleUrl: './balance-sheet.component.scss' +}) +export class BalanceSheetComponent implements OnInit { + private accountingService = inject(AccountingService); + private organizationService = inject(OrganizationService); + + /** Printed at the head of the PDF. Falls back to a neutral title if the profile is not set up. */ + bankName = 'Bank'; + + asOnDate: string = new Date().toISOString().slice(0, 10); + officeId: number | null = null; + offices: { id: number; name: string }[] = []; + + loading = false; + error = ''; + asOnDateLoaded = ''; + + /** What the server returned, kept whole so the zero-balance toggle never needs a refetch. */ + private rawDebitSide: BalanceSheetSide | null = null; + private rawCreditSide: BalanceSheetSide | null = null; + + debitSide: BalanceSheetSide | null = null; + creditSide: BalanceSheetSide | null = null; + + /** + * A working chart of accounts carries far more accounts than a bank actually operates, and a + * printed balance sheet lists only the ones carrying a balance — leaving every nil line in makes + * the sheet unreadable. On by default for that reason; switch it off to audit the full chart. + */ + hideZeroBalances = true; + + /** Both sides are derived from the same double-entry ledger, so they can only disagree if a + * posting is missing a leg — worth surfacing rather than hiding. */ + get tallied(): boolean { + if (!this.debitSide || !this.creditSide) { + return true; + } + return Math.abs(this.debitSide.total - this.creditSide.total) < 0.01; + } + + get difference(): number { + if (!this.debitSide || !this.creditSide) { + return 0; + } + return Math.abs(this.debitSide.total - this.creditSide.total); + } + + get officeLabel(): string { + if (this.officeId == null) { + return 'ALL BRANCHES'; + } + return (this.offices.find((o) => o.id === this.officeId)?.name ?? '').toUpperCase(); + } + + ngOnInit(): void { + this.accountingService.getOffices().subscribe({ + next: (offices) => (this.offices = offices || []), + error: () => (this.offices = []) + }); + this.organizationService.getInstitutionProfile().subscribe({ + next: (profile: any) => { + this.bankName = profile?.statementHeaderName || profile?.bankName || this.bankName; + }, + // A missing institution profile must not stop the sheet from loading — the PDF just carries + // the fallback title. + error: () => undefined + }); + this.load(); + } + + load(): void { + this.loading = true; + this.error = ''; + this.accountingService.getBalanceSheet(this.asOnDate, this.officeId ?? undefined).subscribe({ + next: (res: any) => { + this.rawDebitSide = res.debitSide; + this.rawCreditSide = res.creditSide; + this.asOnDateLoaded = res.asOnDate; + this.applyZeroFilter(); + this.loading = false; + }, + error: () => { + this.error = 'Could not load the balance sheet for this date.'; + this.loading = false; + } + }); + } + + /** + * Rebuilds the displayed sides from what the server sent, dropping nil lines when asked. The + * totals are deliberately left as the server computed them — hiding a nil line must never change + * a figure, and a group total is unaffected by removing children that summed to nothing. + */ + applyZeroFilter(): void { + this.debitSide = this.filterSide(this.rawDebitSide); + this.creditSide = this.filterSide(this.rawCreditSide); + } + + private filterSide(side: BalanceSheetSide | null): BalanceSheetSide | null { + if (!side) { + return null; + } + if (!this.hideZeroBalances) { + return side; + } + const groups = side.groups + .map((group) => ({ + ...group, + nodes: group.nodes + .map((node) => this.pruneZeros(node)) + .filter((node): node is BalanceSheetNode => node !== null) + })) + .filter((group) => group.nodes.length > 0); + return { ...side, groups }; + } + + /** Drops a node whose balance is nil, and any Header left with nothing under it. */ + private pruneZeros(node: BalanceSheetNode): BalanceSheetNode | null { + if (Math.abs(node.balance) < 0.005) { + return null; + } + const children = node.children + .map((child) => this.pruneZeros(child)) + .filter((child): child is BalanceSheetNode => child !== null); + return { ...node, children }; + } + + /** Indian grouping with two decimals, matching the printed report (2,76,92,608.00). */ + fmt(value: number): string { + return new Intl.NumberFormat('en-IN', { minimumFractionDigits: 2, maximumFractionDigits: 2 }).format(value ?? 0); + } + + /** dd/MM/yyyy, as the printed report dates it. */ + displayDate(iso: string): string { + if (!iso) { + return ''; + } + const [ + y, + m, + d + ] = iso.split('-'); + return `${d}/${m}/${y}`; + } + + /** + * Builds the report as a real PDF rather than printing the page: window.print() takes the whole + * app shell with it and breaks pages wherever they happen to fall, which is what made the earlier + * output unusable. + */ + downloadPdf(): void { + if (!this.debitSide || !this.creditSide) { + return; + } + const doc = buildBalanceSheetPdf({ + bankName: this.bankName, + officeLabel: this.officeLabel, + asOnDate: this.asOnDateLoaded || this.asOnDate, + creditSide: this.creditSide, + debitSide: this.debitSide + }); + doc.save(`balance-sheet-${this.asOnDateLoaded || this.asOnDate}.pdf`); + } +} diff --git a/src/app/accounting/disabled-features.ts b/src/app/accounting/disabled-features.ts new file mode 100644 index 000000000..32c9c30eb --- /dev/null +++ b/src/app/accounting/disabled-features.ts @@ -0,0 +1,43 @@ +/** + * 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 } from '@angular/core'; +import { Router, CanActivateFn } from '@angular/router'; + +/** + * Accounting features switched off for this installation. + * + * Nothing is deleted — the components, routes and services all stay in place, and flipping a flag + * back to `false` brings the feature back with its tile and its route. The matching API block lives + * in DisabledFeatureApiFilter on the backend, so a hidden feature cannot be reached by calling the + * endpoint directly either. + */ +export const DISABLED_ACCOUNTING_FEATURES = { + createJournalEntries: true, + frequentPostings: true, + closingEntries: true, + accruals: true, + provisioningEntries: true, + accountingRules: true +}; + +/** + * Blocks a route belonging to a disabled feature, for anyone who reaches it by typing the URL + * rather than through a tile. `data.feature` names the flag to check. + */ +export const accountingFeatureEnabledGuard: CanActivateFn = (route) => { + const router = inject(Router); + const feature = route.data?.['feature'] as keyof typeof DISABLED_ACCOUNTING_FEATURES | undefined; + + if (!feature || !DISABLED_ACCOUNTING_FEATURES[feature]) { + return true; + } + + router.navigate(['/accounting'], { replaceUrl: true }); + return false; +}; diff --git a/src/app/accounting/migrate-cash-denomination/migrate-cash-denomination.component.html b/src/app/accounting/migrate-cash-denomination/migrate-cash-denomination.component.html index d6e56e66e..8c6cb227e 100644 --- a/src/app/accounting/migrate-cash-denomination/migrate-cash-denomination.component.html +++ b/src/app/accounting/migrate-cash-denomination/migrate-cash-denomination.component.html @@ -49,119 +49,144 @@ } @if (glStatus && !glError) { - -
- account_balance -
-
Cash GL Account
-
({{ glStatus.glCode }}) {{ glStatus.glAccountName }}
- @if (glHasBalance) { + @if (todayAlreadyOpen) { + +
+ lock +
+
Business Day Already Open
+
+ {{ todayOpenInfo.businessDate }} — opened by {{ todayOpenInfo.openedByName }}, opening cash ₹{{ + todayOpenInfo.openingCash | number: '1.2-2' + }} +
- Already holds - ₹{{ glTarget.toLocaleString('en-IN') }} - — count the drawer to exactly this amount. + This branch has already opened today's business day — counting the drawer again here would only be rejected + on submit. Deposits, withdrawals and closing today's day happen from Day Register instead.
- } @else { -
- No opening balance posted yet — whatever you count below becomes this account's opening balance. -
- } -
-
- - - - -
-
Denomination
-
Count
-
Amount
+
- @for (d of allDenoms; track d.id) { -
-
- {{ d.label }} {{ d.kind }} -
-
- - - -
-
₹{{ subtotalFor(d).toLocaleString('en-IN') }}
-
- } -
-
- - -
-
- Counted Total - ₹{{ countedTotal.toLocaleString('en-IN') }}
- @if (glHasBalance) { -
- GL Target - ₹{{ glTarget.toLocaleString('en-IN') }} + } @else { + +
+ account_balance +
+
Cash GL Account
+
({{ glStatus.glCode }}) {{ glStatus.glAccountName }}
+ @if (glHasBalance) { +
+ Already holds + ₹{{ glTarget.toLocaleString('en-IN') }} + — count the drawer to exactly this amount. +
+ } @else { +
+ No opening balance posted yet — whatever you count below becomes this account's opening balance. +
+ }
- } -
- @if (countedTotal <= 0) { - edit_note - Count each denomination above - } @else if (isReconciled) { - check_circle - {{ glHasBalance ? 'Matches the GL balance' : 'Will define the GL opening balance' }} - } @else { - error - Off by ₹{{ - (countedTotal - glTarget < 0 ? glTarget - countedTotal : countedTotal - glTarget).toLocaleString('en-IN') - }} - }
-
- @if (needsDifferenceReason) { - + + - - Why does the count differ from the GL balance? - - +
+
Denomination
+
Count
+
Amount
+
+ @for (d of allDenoms; track d.id) { +
+
+ {{ d.label }} {{ d.kind }} +
+
+ + + +
+
₹{{ subtotalFor(d).toLocaleString('en-IN') }}
+
+ }
- } - @if (errorMsg) { -
{{ errorMsg }}
- } - - @if (successMsg) { -
- check_circle -
- {{ successMsg }} -
- + +
+
+ Counted Total + ₹{{ countedTotal.toLocaleString('en-IN') }} +
+ @if (glHasBalance) { +
+ GL Target + ₹{{ glTarget.toLocaleString('en-IN') }}
+ } +
+ @if (countedTotal <= 0) { + edit_note + Count each denomination above + } @else if (isReconciled) { + check_circle + {{ glHasBalance ? 'Matches the GL balance' : 'Will define the GL opening balance' }} + } @else { + error + Off by ₹{{ + (countedTotal - glTarget < 0 ? glTarget - countedTotal : countedTotal - glTarget).toLocaleString( + 'en-IN' + ) + }} + }
- } - @if (!successMsg) { -
- -
+ @if (needsDifferenceReason) { + + + + Why does the count differ from the GL balance? + + + + + } + + @if (errorMsg) { +
{{ errorMsg }}
+ } + + @if (successMsg) { +
+ check_circle +
+ {{ successMsg }} +
+ +
+
+
+ } + + @if (!successMsg) { +
+ +
+ } } }
diff --git a/src/app/accounting/migrate-cash-denomination/migrate-cash-denomination.component.scss b/src/app/accounting/migrate-cash-denomination/migrate-cash-denomination.component.scss index 6dcf31aa0..c68d75abd 100644 --- a/src/app/accounting/migrate-cash-denomination/migrate-cash-denomination.component.scss +++ b/src/app/accounting/migrate-cash-denomination/migrate-cash-denomination.component.scss @@ -115,6 +115,10 @@ color: #64748b; line-height: 1.4; } + + button { + margin-top: 10px; + } } .mcd-grid { @@ -171,7 +175,11 @@ } &__input { - width: 48px; + /* Width is set inline per row (see countInputWidthCh()), growing with the digit count typed — + min-width here only covers the fallback before Angular has bound anything. */ + min-width: 48px; + max-width: 96px; + transition: width 0.1s ease-out; text-align: center; font-variant-numeric: tabular-nums; border: 1px solid #e2e8f0; diff --git a/src/app/accounting/migrate-cash-denomination/migrate-cash-denomination.component.ts b/src/app/accounting/migrate-cash-denomination/migrate-cash-denomination.component.ts index a66fb5651..2509a0fce 100644 --- a/src/app/accounting/migrate-cash-denomination/migrate-cash-denomination.component.ts +++ b/src/app/accounting/migrate-cash-denomination/migrate-cash-denomination.component.ts @@ -66,6 +66,14 @@ export class MigrateCashDenominationComponent implements OnInit { glStatus: any = null; glError = ''; + /** Set once per office change: whether TODAY's business day is already open for this office. This + * screen is a one-time "day 1" tool (see the class doc) - once a day has actually been opened, + * reopening the same one is exactly what BranchDayRegisterApiResource#migrateCashDenomination + * itself refuses (its own openDayInternal call rejects a day that is already open), so this is + * checked here too, before the user counts an entire drawer only to have the submit rejected. */ + todayAlreadyOpen = false; + todayOpenInfo: any = null; + notes: Denom[] = []; coins: Denom[] = []; counts: Record = {}; @@ -118,6 +126,8 @@ export class MigrateCashDenominationComponent implements OnInit { this.result = null; this.successMsg = ''; this.errorMsg = ''; + this.todayAlreadyOpen = false; + this.todayOpenInfo = null; if (!this.officeId) return; this.glLoading = true; @@ -131,6 +141,15 @@ export class MigrateCashDenominationComponent implements OnInit { this.glError = res.message || 'No cash GL account is configured for this tenant yet.'; } }); + + this.service + .branchDayRegister(this.officeId) + .pipe(catchError(() => of(null))) + .subscribe((res: any) => { + const openDay = res?.openDay; + this.todayAlreadyOpen = !!openDay && openDay.businessDate === res?.today; + this.todayOpenInfo = this.todayAlreadyOpen ? openDay : null; + }); } countFor(d: Denom): number { @@ -149,6 +168,12 @@ export class MigrateCashDenominationComponent implements OnInit { this.counts[d.id] = Math.max(0, (this.counts[d.id] || 0) + delta); } + /** Widens the count box as the typed count grows more digits, so a large count (hundreds, + * thousands) is never clipped or hard to read inside a field sized for one or two digits. */ + countInputWidthCh(d: Denom): number { + return Math.max(3, String(this.countFor(d)).length + 2); + } + get countedTotal(): number { return this.allDenoms.reduce((s, d) => s + this.subtotalFor(d), 0); } diff --git a/src/app/accounting/migrate-opening-balances/migrate-opening-balances.component.html b/src/app/accounting/migrate-opening-balances/migrate-opening-balances.component.html index 728b7cf77..ac06c467a 100644 --- a/src/app/accounting/migrate-opening-balances/migrate-opening-balances.component.html +++ b/src/app/accounting/migrate-opening-balances/migrate-opening-balances.component.html @@ -162,7 +162,18 @@
{{ openingBalancesData.glAccounts[i].glAccountCode }}
-
{{ openingBalancesData.glAccounts[i].glAccountName }}
+
+ {{ openingBalancesData.glAccounts[i].glAccountName }} + @if (isDenominationManaged(openingBalancesData.glAccounts[i].glAccountId)) { + + lock + Migrate Cash Denomination + + } +
diff --git a/src/app/accounting/migrate-opening-balances/migrate-opening-balances.component.scss b/src/app/accounting/migrate-opening-balances/migrate-opening-balances.component.scss index 14cf2ca5a..578f9a0ee 100644 --- a/src/app/accounting/migrate-opening-balances/migrate-opening-balances.component.scss +++ b/src/app/accounting/migrate-opening-balances/migrate-opening-balances.component.scss @@ -219,6 +219,33 @@ word-break: break-word; } + /* Cash at Vault / Cash at Teller — locked, see MigrateOpeningBalancesComponent.isDenominationManaged. */ + &__lock { + display: inline-flex; + align-items: center; + gap: 4px; + margin-left: 8px; + padding: 1px 8px 1px 6px; + border-radius: 999px; + background: #fff7ed; + color: #9a3412; + font-size: 11px; + font-weight: 600; + white-space: nowrap; + vertical-align: middle; + + mat-icon { + font-size: 13px; + width: 13px; + height: 13px; + } + + a { + color: inherit; + text-decoration: underline; + } + } + &__amt { text-align: right; } diff --git a/src/app/accounting/migrate-opening-balances/migrate-opening-balances.component.ts b/src/app/accounting/migrate-opening-balances/migrate-opening-balances.component.ts index b1636d9df..0a4bcf291 100644 --- a/src/app/accounting/migrate-opening-balances/migrate-opening-balances.component.ts +++ b/src/app/accounting/migrate-opening-balances/migrate-opening-balances.component.ts @@ -17,6 +17,7 @@ import { } from '@angular/forms'; import { ActivatedRoute, Router, RouterLink } from '@angular/router'; import { MatIconModule } from '@angular/material/icon'; +import { MatTooltipModule } from '@angular/material/tooltip'; /** Custom Services */ import { AccountingService } from '../accounting.service'; @@ -43,7 +44,8 @@ import { STANDALONE_SHARED_IMPORTS } from 'app/standalone-shared.module'; ...STANDALONE_SHARED_IMPORTS, GlAccountDisplayComponent, CurrencyPipe, - MatIconModule + MatIconModule, + MatTooltipModule ] }) export class MigrateOpeningBalancesComponent implements OnInit, AfterViewInit { @@ -74,6 +76,13 @@ export class MigrateOpeningBalancesComponent implements OnInit, AfterViewInit { debitsSum = 0; /** Sum total of credits. */ creditsSum = 0; + /** GL account ids mapped to Financial Activity 101 (Cash at Vault) / 102 (Cash at Teller) — the + * account(s) Migrate Cash Denomination owns. Their opening balance is shown here for visibility + * but locked against editing: redefining it as a bare number here, instead of through a counted + * denomination breakdown, would let the GL figure drift away from what is physically in the till. + * The backend enforces the same rule independently (see defineOpeningBalance's + * validateNoDenominationManagedCashAccounts) — this is a convenience, not the source of truth. */ + denominationManagedGlAccountIds: number[] = []; /* Reference of search form */ @ViewChild('searchFormRef') searchFormRef: ElementRef; @@ -95,6 +104,22 @@ export class MigrateOpeningBalancesComponent implements OnInit, AfterViewInit { this.officeData = data.offices; this.currencyData = data.currencies.selectedCurrencyOptions; }); + // Office-independent: which GL account(s) this tenant has mapped to Cash at Vault / Cash at + // Teller, so their row can be locked the moment the office's accounts are drawn. + this.accountingService.getFinancialActivityAccounts().subscribe((mappings: any[]) => { + this.denominationManagedGlAccountIds = (mappings || []) + .filter((mapping) => mapping.financialActivityData?.id === 101 || mapping.financialActivityData?.id === 102) + .map((mapping) => mapping.glAccountData?.id) + .filter((id) => id != null); + // Retrieve may already have run before this (fast) request came back — lock any row it + // missed rather than leaving it editable for the rest of this visit. + this.glAccountEntries?.controls.forEach((control) => { + const glAccountId = control.get('glAccountId')?.value; + if (this.isDenominationManaged(glAccountId) && control.enabled) { + control.disable(); + } + }); + }); } /** @@ -136,14 +161,31 @@ export class MigrateOpeningBalancesComponent implements OnInit, AfterViewInit { * @returns {FormGroup} GL Account entry form. */ createGLAccountEntryForm(glAccount: any): UntypedFormGroup { - return this.formBuilder.group( + // A never-touched account arrives as a placeholder row (amount/entryType both null); one that + // already carries an opening balance arrives with its real, committed figure — show that + // instead of leaving the field looking blank. + const existingDebit = glAccount.entryType?.value === 'DEBIT' ? glAccount.amount : null; + const existingCredit = glAccount.entryType?.value === 'CREDIT' ? glAccount.amount : null; + const group = this.formBuilder.group( { glAccountId: [glAccount.glAccountId], - debit: [null], - credit: [null] + debit: [existingDebit], + credit: [existingCredit] }, { validator: onlyOneOfTheFieldsIsRequiredValidator } ); + if (this.isDenominationManaged(glAccount.glAccountId)) { + // Disabling excludes this row from the form's own .value, so it is never resubmitted here — + // the account keeps whatever Migrate Cash Denomination last set it to. + group.disable(); + } + return group; + } + + /** Whether this GL account is Cash at Vault / Cash at Teller — locked here, see + * denominationManagedGlAccountIds. */ + isDenominationManaged(glAccountId: number): boolean { + return this.denominationManagedGlAccountIds.includes(glAccountId); } /** @@ -176,17 +218,30 @@ export class MigrateOpeningBalancesComponent implements OnInit, AfterViewInit { this.openingBalancesData = openingBalancesData; - entry.valueChanges.subscribe(() => { - this.debitsSum = 0; - this.creditsSum = 0; - entry.controls.forEach((value) => { - this.debitsSum += value.value.debit; - this.creditsSum += value.value.credit; - }); - }); + entry.valueChanges.subscribe(() => this.recomputeSums()); + // Runs once immediately too: valueChanges only fires on a later edit, and the locked cash + // row's own prefilled figure (set once, above, never through a further value change) would + // otherwise never be reflected in the banner at all. + this.recomputeSums(); }); } + /** Total Debit / Total Credit shown in the balance banner. A locked (denomination-managed) row is + * excluded here exactly as it is excluded from the form's own .value used at submit time — this + * screen only needs to balance the accounts it is itself about to (re)define, not the one + * Migrate Cash Denomination already settled and this form can never touch. */ + private recomputeSums(): void { + this.debitsSum = 0; + this.creditsSum = 0; + this.glAccountEntries.controls.forEach((control) => { + if (control.disabled) { + return; + } + this.debitsSum += control.value.debit || 0; + this.creditsSum += control.value.credit || 0; + }); + } + /** * Submits the opening balances form and defines opening balances, * if successful redirects to view created transaction. diff --git a/src/app/accounting/search-journal-entry/search-journal-entry.component.html b/src/app/accounting/search-journal-entry/search-journal-entry.component.html index f8dacebff..99e81b425 100644 --- a/src/app/accounting/search-journal-entry/search-journal-entry.component.html +++ b/src/app/accounting/search-journal-entry/search-journal-entry.component.html @@ -53,10 +53,13 @@ Refresh - + + @if (!disabledFeatures.createJournalEntries) { + + }
diff --git a/src/app/accounting/search-journal-entry/search-journal-entry.component.ts b/src/app/accounting/search-journal-entry/search-journal-entry.component.ts index 39c8e1694..cf8330511 100644 --- a/src/app/accounting/search-journal-entry/search-journal-entry.component.ts +++ b/src/app/accounting/search-journal-entry/search-journal-entry.component.ts @@ -14,6 +14,7 @@ import { ActivatedRoute, RouterLink } from '@angular/router'; import { merge } from 'rxjs'; import { tap, startWith, map, distinctUntilChanged, debounceTime } from 'rxjs/operators'; import { AccountingService } from '../accounting.service'; +import { DISABLED_ACCOUNTING_FEATURES } from '../disabled-features'; import { SettingsService } from 'app/settings/settings.service'; import { JournalEntriesDataSource } from './journal-entry.datasource'; import { Dates } from 'app/core/utils/dates'; @@ -90,6 +91,8 @@ export class SearchJournalEntryComponent implements OnInit, OnDestroy, AfterView // ── Misc ────────────────────────────────────────────────────────────────── minDate = new Date(2000, 0, 1); maxDate = new Date(); + /** Accounting features switched off for this installation — see disabled-features.ts. */ + disabledFeatures = DISABLED_ACCOUNTING_FEATURES; // ── Filter controls ─────────────────────────────────────────────────────── officeName = new UntypedFormControl(); diff --git a/src/app/cashier/branch-day/branch-day.component.html b/src/app/cashier/branch-day/branch-day.component.html index e5db06861..65d27eb6e 100644 --- a/src/app/cashier/branch-day/branch-day.component.html +++ b/src/app/cashier/branch-day/branch-day.component.html @@ -49,6 +49,18 @@
} + @if (firstOpenNeedsCashMigration && !missedOnly) { +
+ lock + + This branch has never opened a business day, and its Cash at Vault GL account has no opening balance yet. Count + the vault and open the first business day together from + Migrate Cash Denomination — Open Day here is + locked until that is done, so today's cash tracking never starts out of step with the ledger. + +
+ } + @if (missed.length > 0) {
@@ -703,10 +715,10 @@ · {{ timeOnly(d.closedAt) }} }
{{ d.closingCash != null ? '₹' + (d.closingCash | number: '1.2-2') : '—' }}{{ d.bookCash != null ? '₹' + (d.bookCash | number: '1.2-2') : '—' }}{{ hasValue(d.closingCash) ? '₹' + (d.closingCash | number: '1.2-2') : '—' }}{{ hasValue(d.bookCash) ? '₹' + (d.bookCash | number: '1.2-2') : '—' }} - @if (d.cashDifference == null) { + @if (!hasValue(d.cashDifference)) { — } @else if (d.cashDifference === 0) { Tallied diff --git a/src/app/cashier/branch-day/branch-day.component.scss b/src/app/cashier/branch-day/branch-day.component.scss index fa49ed69d..c97aebcc9 100644 --- a/src/app/cashier/branch-day/branch-day.component.scss +++ b/src/app/cashier/branch-day/branch-day.component.scss @@ -30,6 +30,36 @@ } } +/* Blocks Open Day until the branch's first-ever open has gone through Migrate Cash Denomination — + see BranchDayComponent.checkFirstOpenCashMigration(). Same shape as .ve-note, in the error palette + the rest of this screen already uses for a missed close (.bd-missed). */ +.bd-locked { + display: flex; + align-items: flex-start; + gap: 8px; + padding: 10px 14px; + margin-bottom: 14px; + background: #fef2f2; + border: 1px solid #fecaca; + border-radius: 8px; + color: #b91c1c; + font-size: 12px; + line-height: 1.5; + + mat-icon { + flex: none; + font-size: 18px; + width: 18px; + height: 18px; + } + + a { + color: inherit; + font-weight: 600; + text-decoration: underline; + } +} + .bd-success { padding: 10px 14px; margin-bottom: 14px; diff --git a/src/app/cashier/branch-day/branch-day.component.ts b/src/app/cashier/branch-day/branch-day.component.ts index 579a3364a..da476f658 100644 --- a/src/app/cashier/branch-day/branch-day.component.ts +++ b/src/app/cashier/branch-day/branch-day.component.ts @@ -10,7 +10,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 { ActivatedRoute } from '@angular/router'; +import { ActivatedRoute, RouterLink } from '@angular/router'; import { catchError, forkJoin, map, of, switchMap } from 'rxjs'; import { CashierService } from '../cashier.service'; @@ -61,7 +61,8 @@ interface BranchDay { imports: [ CommonModule, FormsModule, - MatIconModule + MatIconModule, + RouterLink ] }) export class BranchDayComponent implements OnInit { @@ -85,6 +86,12 @@ export class BranchDayComponent implements OnInit { loaded = false; errorMsg = ''; successMsg = ''; + /** True only for a branch that has never opened a business day and whose Cash at Vault / Cash at + * Teller GL account has never had anything posted to it — Open Day here would let day-to-day cash + * tracking begin while the ledger silently stays wherever it already was. The backend enforces + * this independently too (see BranchDayRegisterApiResource#requireCashMigrationBeforeFirstEverOpen); + * this flag only steers the user to the right screen before they hit that rejection. */ + firstOpenNeedsCashMigration = false; // Open-day form showOpenForm = false; @@ -202,6 +209,7 @@ export class BranchDayComponent implements OnInit { this.today = res?.today ?? ''; this.canOperate = !!res?.canOperate; this.openDate = this.today; + this.checkFirstOpenCashMigration(); }); this.service @@ -212,9 +220,42 @@ export class BranchDayComponent implements OnInit { }); } + /** + * Only relevant the moment this branch has never opened a business day before — every later + * open/close on this screen is untouched, so there is nothing to re-check once history exists. + */ + private checkFirstOpenCashMigration(): void { + if (this.days.length > 0 || this.openDay) { + this.firstOpenNeedsCashMigration = false; + return; + } + this.service.cashGlStatus().subscribe({ + next: (status: any) => { + // configured === false means this tenant has no Cash at Vault / Cash at Teller account + // mapped at all — nothing for this check to reconcile against, so it does not block. + this.firstOpenNeedsCashMigration = status?.configured !== false && !status?.hasBalance; + }, + error: () => { + this.firstOpenNeedsCashMigration = false; + } + }); + } + /** True when the branch has no open day at all, so today can be opened. */ get canOpenToday(): boolean { - return this.canOperate && !this.openDay; + return this.canOperate && !this.openDay && !this.firstOpenNeedsCashMigration; + } + + /** + * A still-open day's closing/book figures are dropped from the API response entirely (the + * backend's Gson instance omits a null map value rather than sending it as JSON null), so the + * field arrives as undefined, not null. Checking only `!== null` against that — which a stricter + * pass of this file made the mistake of doing — leaves undefined on the "has a value" branch, + * which then renders `'₹' + null` (the number pipe's own null output, string-concatenated) as the + * literal text "₹null". This checks for either, the way an open day actually shows up. + */ + hasValue(value: number | null | undefined): boolean { + return value !== null && value !== undefined; } startOpen(): void { diff --git a/src/app/cashier/transaction-entry/transaction-entry.component.ts b/src/app/cashier/transaction-entry/transaction-entry.component.ts index 5a49e956d..6f33ad242 100644 --- a/src/app/cashier/transaction-entry/transaction-entry.component.ts +++ b/src/app/cashier/transaction-entry/transaction-entry.component.ts @@ -3189,7 +3189,7 @@ export class TransactionEntryComponent implements OnInit, OnDestroy { // Step 1: search on the correct endpoint for this account type this.http .get( - `${cfg.list}?${cfg.listQueryParam}=${encodeURIComponent(acNo)}${cfg.supportsPagination ? `&offset=0&limit=${cfg.maxLimit}` : ''}` + `${cfg.list}?${cfg.listQueryParam}=${encodeURIComponent(acNo)}${cfg.supportsPagination ? `&offset=0&limit=${cfg.maxLimit}` : ''}${cfg.extraListQueryParams ? `&${cfg.extraListQueryParams}` : ''}` ) .pipe(catchError(() => of(null))) .subscribe((res: any) => { @@ -3278,6 +3278,10 @@ export class TransactionEntryComponent implements OnInit, OnDestroy { matchField: string; detailParams: string; supportsPagination: boolean; + /** Extra fixed query params (e.g. "usage=1") appended to every list-search request for this + * account type — used to keep Header GL accounts out of the results a clerk/cashier can pick + * for a transaction; a Header can never receive a posting, so it should never be offered. */ + extraListQueryParams?: string; } { const t = acTyp?.toUpperCase(); if (t === 'LA') { @@ -3331,7 +3335,10 @@ export class TransactionEntryComponent implements OnInit, OnDestroy { listQueryParam: 'searchParam', matchField: 'glCode', detailParams: '?fetchRunningBalance=true', - supportsPagination: false + supportsPagination: false, + // Detail-only: a Header account is a pure grouping node and can never receive a + // transaction, so it must never be a selectable match here in the first place. + extraListQueryParams: 'usage=1' }; } if (t === 'CA' || t === 'CB') { diff --git a/src/app/clients/edit-client/contact-otp-block/contact-otp-block.component.ts b/src/app/clients/edit-client/contact-otp-block/contact-otp-block.component.ts index 7d01f68d9..99cbd5959 100644 --- a/src/app/clients/edit-client/contact-otp-block/contact-otp-block.component.ts +++ b/src/app/clients/edit-client/contact-otp-block/contact-otp-block.component.ts @@ -164,6 +164,13 @@ export class ContactOtpBlockComponent implements OnChanges, OnDestroy { request$.subscribe({ next: (res: any) => { this.sending = false; + // The backend reports failure to actually dispatch the OTP (gateway error, flagged + // number, SMTP failure...) as a 200 response with success:false and a real reason in + // `message` — never a thrown error. Surface that reason instead of treating it as sent. + if (res?.success === false) { + this.errorMessage = res?.message || 'Could not send OTP.'; + return; + } this.otpId = res?.otpId ?? res?.resourceId ?? null; this.otpSent = true; this.clearAllDigits(); diff --git a/src/app/core/shell/sidenav/sidenav.component.html b/src/app/core/shell/sidenav/sidenav.component.html index 95778bb7c..a8ae77c84 100644 --- a/src/app/core/shell/sidenav/sidenav.component.html +++ b/src/app/core/shell/sidenav/sidenav.component.html @@ -299,32 +299,38 @@ {{ 'labels.menus.Notifications' | translate }} - - - - - {{ 'labels.menus.Frequent Postings' | translate }} - - - - - - {{ 'labels.menus.Create Journal Entry' | translate }} - + + @if (!disabledFeatures.frequentPostings) { + + + + + {{ 'labels.menus.Frequent Postings' | translate }} + + } + @if (!disabledFeatures.createJournalEntries) { + + + + + {{ 'labels.menus.Create Journal Entry' | translate }} + + } Holiday Calendar - Financial Year / Closing Entries + + @if (!disabledFeatures.closingEntries) { + Financial Year / Closing Entries + } Business Date (Day Register) diff --git a/src/app/organization/institution-profile/institution-profile.component.ts b/src/app/organization/institution-profile/institution-profile.component.ts index 41e8b41e5..2d04f9480 100644 --- a/src/app/organization/institution-profile/institution-profile.component.ts +++ b/src/app/organization/institution-profile/institution-profile.component.ts @@ -12,6 +12,7 @@ import { catchError, of } from 'rxjs'; import { STANDALONE_SHARED_IMPORTS } from 'app/standalone-shared.module'; import { OrganizationService } from '../organization.service'; import { SettingsService } from 'app/settings/settings.service'; +import { DISABLED_ACCOUNTING_FEATURES } from '../../accounting/disabled-features'; const BANK_TYPES = [ { value: 'COOPERATIVE', label: 'Co-operative Bank' }, @@ -63,6 +64,8 @@ export class InstitutionProfileComponent implements OnInit { private organizationService = inject(OrganizationService); private settingsService = inject(SettingsService); + /** Accounting features switched off for this installation — see accounting/disabled-features.ts. */ + disabledFeatures = DISABLED_ACCOUNTING_FEATURES; bankTypes = BANK_TYPES; ownershipTypes = OWNERSHIP_TYPES; licenseStatuses = LICENSE_STATUSES; diff --git a/src/app/platform-admin/menu-access/menu-access.component.ts b/src/app/platform-admin/menu-access/menu-access.component.ts index e98bcfebd..c5607a687 100644 --- a/src/app/platform-admin/menu-access/menu-access.component.ts +++ b/src/app/platform-admin/menu-access/menu-access.component.ts @@ -104,7 +104,8 @@ export class PlatformMenuAccessComponent { 'X-Api-Key': this.apiKey, // Any real tenant satisfies the platform's tenant-resolution filter; the target tenant for // read/write is this header's own value, so it is set to whichever tenant is actually in play. - 'Fineract-Platform-TenantId': tenantForFilter || this.selectedTenant || (this.tenants[0]?.identifier ?? 'default') + 'Fineract-Platform-TenantId': + tenantForFilter || this.selectedTenant || this.tenants[0]?.identifier || environment.fineractPlatformTenantId }; } @@ -126,7 +127,9 @@ export class PlatformMenuAccessComponent { this.rememberKey(); this.loadingTenants = true; try { - const res = await fetch(this.apiUrl('/tenant-menu-access/tenants'), { headers: this.headers('default') }); + const res = await fetch(this.apiUrl('/tenant-menu-access/tenants'), { + headers: this.headers(environment.fineractPlatformTenantId) + }); if (!res.ok) { throw new Error( res.status === 403 || res.status === 401 ? 'Invalid or inactive API key.' : `Server error (${res.status}).` diff --git a/src/app/products/share-products/create-share-product/create-share-product.component.html b/src/app/products/share-products/create-share-product/create-share-product.component.html index 89efc79c2..3d5ee4a18 100644 --- a/src/app/products/share-products/create-share-product/create-share-product.component.html +++ b/src/app/products/share-products/create-share-product/create-share-product.component.html @@ -110,8 +110,7 @@ [shareProductsTemplate]="shareProductsTemplate" [accountingRuleData]="accountingRuleData" [shareProduct]="shareProduct" - [taskPermission]="'CREATE_SHAREPRODUCT'" - (submitEvent)="submit()" + [showActions]="false" > diff --git a/src/app/products/share-products/share-product-stepper/share-product-preview-step/share-product-preview-step.component.html b/src/app/products/share-products/share-product-stepper/share-product-preview-step/share-product-preview-step.component.html index 91c9f9ec0..4bc274f94 100644 --- a/src/app/products/share-products/share-product-stepper/share-product-preview-step/share-product-preview-step.component.html +++ b/src/app/products/share-products/share-product-stepper/share-product-preview-step/share-product-preview-step.component.html @@ -250,11 +250,13 @@ -
- - -
+@if (showActions) { +
+ + +
+} diff --git a/src/app/products/share-products/share-product-stepper/share-product-preview-step/share-product-preview-step.component.ts b/src/app/products/share-products/share-product-stepper/share-product-preview-step/share-product-preview-step.component.ts index 1c1fc2c11..c725721bb 100644 --- a/src/app/products/share-products/share-product-stepper/share-product-preview-step/share-product-preview-step.component.ts +++ b/src/app/products/share-products/share-product-stepper/share-product-preview-step/share-product-preview-step.component.ts @@ -52,6 +52,10 @@ export class ShareProductPreviewStepComponent { @Input() accountingRuleData: any; @Input() shareProduct: any; @Input() taskPermission: string; + /** Set to false when the host page already renders its own Cancel/Submit bar for this step + * (the Create flow's shared bottom action bar) — Edit's stepper has no such bar of its own, so + * this defaults to true and keeps rendering the button there. */ + @Input() showActions = true; @Output() submitEvent = new EventEmitter(); marketPriceDisplayedColumns: string[] = [ diff --git a/src/app/savings/create-savings-account/create-savings-account.component.html b/src/app/savings/create-savings-account/create-savings-account.component.html index dfbf67e7c..f90406dab 100644 --- a/src/app/savings/create-savings-account/create-savings-account.component.html +++ b/src/app/savings/create-savings-account/create-savings-account.component.html @@ -91,16 +91,10 @@ [savingsAccountNominees]="savingsAccountCurrencyStep?.nominees ?? []" [submitting]="submitting" (submitEvent)="submit()" + (previousEvent)="prev()" > } - -
- -
diff --git a/src/app/savings/savings-account-stepper/savings-account-preview-step/savings-account-preview-step.component.html b/src/app/savings/savings-account-stepper/savings-account-preview-step/savings-account-preview-step.component.html index 44831e2d5..d67ed5cbb 100644 --- a/src/app/savings/savings-account-stepper/savings-account-preview-step/savings-account-preview-step.component.html +++ b/src/app/savings/savings-account-stepper/savings-account-preview-step/savings-account-preview-step.component.html @@ -585,25 +585,30 @@ @if (!hideButtons) {
- - +
+ + +
} diff --git a/src/app/savings/savings-account-stepper/savings-account-preview-step/savings-account-preview-step.component.scss b/src/app/savings/savings-account-stepper/savings-account-preview-step/savings-account-preview-step.component.scss index cffbf47eb..8c772a802 100644 --- a/src/app/savings/savings-account-stepper/savings-account-preview-step/savings-account-preview-step.component.scss +++ b/src/app/savings/savings-account-stepper/savings-account-preview-step/savings-account-preview-step.component.scss @@ -13,17 +13,21 @@ padding: 0.5rem 0; } -/* Submit + Cancel row at the bottom of the preview. Previous lives in the - parent wizard's tab action bar. */ +/* Previous, Cancel & Submit row at the bottom of the preview. */ .preview-submit-row { display: flex; - justify-content: flex-end; + justify-content: space-between; align-items: center; - gap: 12px; margin-top: 18px; padding-top: 16px; border-top: 1px solid #f1f5f9; + &__right { + display: flex; + align-items: center; + gap: 12px; + } + button { height: 42px !important; border-radius: 10px !important; diff --git a/src/app/savings/savings-account-stepper/savings-account-preview-step/savings-account-preview-step.component.ts b/src/app/savings/savings-account-stepper/savings-account-preview-step/savings-account-preview-step.component.ts index 880e51d90..c86dc9198 100644 --- a/src/app/savings/savings-account-stepper/savings-account-preview-step/savings-account-preview-step.component.ts +++ b/src/app/savings/savings-account-stepper/savings-account-preview-step/savings-account-preview-step.component.ts @@ -116,6 +116,9 @@ export class SavingsAccountPreviewStepComponent implements OnChanges { /** Form submission event */ @Output() submitEvent = new EventEmitter(); + /** Goes back a tab in the parent wizard — kept here so Previous renders in the same row as + * Cancel/Submit instead of a separate action bar underneath. */ + @Output() previousEvent = new EventEmitter(); ngOnChanges(): void { if (this.activeClientMembers?.length > 0) { diff --git a/src/app/shares/shares-account-view/shares-account-view.component.ts b/src/app/shares/shares-account-view/shares-account-view.component.ts index e8318cff0..84103260f 100644 --- a/src/app/shares/shares-account-view/shares-account-view.component.ts +++ b/src/app/shares/shares-account-view/shares-account-view.component.ts @@ -149,6 +149,13 @@ export class SharesAccountViewComponent implements OnInit { // capital that was never issued. if (!this.isSettled) { this.buttonConfig.removeOption('Share Certificate'); + } else { + // "Apply Collection" settles the account's ORIGINAL application funding -- a one-time + // step. Once collectionApplied is true there is nothing left to settle this way; any + // further share purchases on this account go through "Apply Additional Shares" instead, + // which raises and settles its own tranche. Leaving this button up after settlement invited + // reopening the dialog against an account with no unapplied receipt to find. + this.buttonConfig.removeButton('Apply Collection'); } } } diff --git a/src/app/sponsor-dd/gl-settings-dialog/gl-settings-dialog.component.ts b/src/app/sponsor-dd/gl-settings-dialog/gl-settings-dialog.component.ts index 3426b8bda..b4516b3c4 100644 --- a/src/app/sponsor-dd/gl-settings-dialog/gl-settings-dialog.component.ts +++ b/src/app/sponsor-dd/gl-settings-dialog/gl-settings-dialog.component.ts @@ -206,7 +206,9 @@ export class GlSettingsDialogComponent implements OnInit { loadGlAccountsAndMapping(): void { this.loading = true; - this.http.get('/glaccounts?manualEntriesAllowed=true&disabled=false').subscribe({ + // usage=1 (Detail only): these settings map real Sponsor DD transactions onto a GL account, and + // a Header account can never receive one, so it must never be offered here. + this.http.get('/glaccounts?manualEntriesAllowed=true&disabled=false&usage=1').subscribe({ next: (accounts) => { this.glAccounts = accounts || []; this.sponsorDDService.getGlMappings().subscribe({ diff --git a/src/assets/env.js b/src/assets/env.js index 3b7c7551c..e7ec0e8c1 100644 --- a/src/assets/env.js +++ b/src/assets/env.js @@ -20,8 +20,8 @@ // Tenants the login screen offers, and which one it starts on. Every identifier here has to exist // in the tenants table of the tenant-store database, or the platform rejects the request as an // unknown tenant. - window["env"]["fineractPlatformTenantId"] = 'default'; - window["env"]["fineractPlatformTenantIds"] = 'default,abc_bank,socity_bank'; + window["env"]["fineractPlatformTenantId"] = 'lincpay'; + window["env"]["fineractPlatformTenantIds"] = 'lincpay'; window['env']['tenantLogoUrl'] = ''; window['env']['tenantLogoUrlDark'] = ''; diff --git a/src/environments/environment.prod.ts b/src/environments/environment.prod.ts index cffbec166..d7885aac8 100644 --- a/src/environments/environment.prod.ts +++ b/src/environments/environment.prod.ts @@ -19,8 +19,8 @@ export const environment = { version: env.mifos_x.version, hash: env.mifos_x.hash, // For connecting to server running elsewhere update the tenant identifier - fineractPlatformTenantId: loadedEnv['fineractPlatformTenantId'] || 'default', - fineractPlatformTenantIds: loadedEnv['fineractPlatformTenantIds'] || 'default', + fineractPlatformTenantId: loadedEnv['fineractPlatformTenantId'] || 'lincpay', + fineractPlatformTenantIds: loadedEnv['fineractPlatformTenantIds'] || 'lincpay', // 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 f4cf45cb8..700ff10f9 100644 --- a/src/environments/environment.ts +++ b/src/environments/environment.ts @@ -22,8 +22,8 @@ export const environment = { version: env.mifos_x.version, hash: env.mifos_x.hash, // For connecting to server running elsewhere update the tenant identifier - fineractPlatformTenantId: loadedEnv.fineractPlatformTenantId || 'default', - fineractPlatformTenantIds: loadedEnv.fineractPlatformTenantIds || 'default', + fineractPlatformTenantId: loadedEnv.fineractPlatformTenantId || 'lincpay', + fineractPlatformTenantIds: loadedEnv.fineractPlatformTenantIds || 'lincpay', // 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, From ab58d7f74c57f19c1ddd61a58d4dabe5c2b95f5e Mon Sep 17 00:00:00 2001 From: Nitesh Yadav Date: Mon, 14 Sep 2026 07:19:50 +0530 Subject: [PATCH 42/64] fix(cashier): tag GL-entry denomination to the vault, show both voucher legs - Transaction Entry's manual GL-entry screen was recording its denomination breakdown against whichever GL account the teller selected, not the vault account whose physical notes actually moved -- resolve and use the Cash Vault GL's own code/name dynamically instead - Closing Cash Report's GL Entries table showed only the one account its denomination row knew about; now shows both the debit and credit account of each voucher (backend now returns debitParty/creditParty), matching what My Vouchers already shows for the same entries Co-Authored-By: Claude Sonnet 5 --- .../closing-cash-report.component.html | 25 ++++++++++++++++--- .../transaction-entry.component.ts | 17 +++++++++++-- 2 files changed, 36 insertions(+), 6 deletions(-) diff --git a/src/app/cashier/closing-cash-report/closing-cash-report.component.html b/src/app/cashier/closing-cash-report/closing-cash-report.component.html index b6faf9d5c..c964fd540 100644 --- a/src/app/cashier/closing-cash-report/closing-cash-report.component.html +++ b/src/app/cashier/closing-cash-report/closing-cash-report.component.html @@ -303,8 +303,8 @@
Voucher No DateA/C NoA/C NameDebitCredit Payment Type Teller DR (₹){{ r.acNo }}{{ r.acName }} +
{{ r.debitParty || r.acName }}
+ @if (r.debitAccount || r.acNo) { +
{{ r.debitAccount || r.acNo }}
+ } +
+ @if (r.creditParty) { +
{{ r.creditParty }}
+ @if (r.creditAccount) { +
{{ r.creditAccount }}
+ } + } @else { + — + } +
@if (r.paymentType) { {{ r.paymentType }} diff --git a/src/app/cashier/transaction-entry/transaction-entry.component.ts b/src/app/cashier/transaction-entry/transaction-entry.component.ts index 6f33ad242..50e1d398e 100644 --- a/src/app/cashier/transaction-entry/transaction-entry.component.ts +++ b/src/app/cashier/transaction-entry/transaction-entry.component.ts @@ -1617,6 +1617,13 @@ export class TransactionEntryComponent implements OnInit, OnDestroy { private lastLoadedAcNo = ''; private acNoInput$ = new Subject<{ row: TxnRow; index: number; value: string }>(); cashVaultGlId: number | null = null; + /** The Cash Vault GL account's own code and name — the denomination breakdown for a manual GL + * entry has to be filed against THIS account, not whichever account the teller typed in, since + * that account is never the one physical notes actually moved through (see submitGlJournalEntry). + * Both resolved dynamically off whatever this tenant's own financial-activity mapping names as + * its cash/vault account (see loadCashVaultGl) — never a literal account name or code. */ + cashVaultGlCode: string | null = null; + cashVaultGlName: string | null = null; // Joint holder expanded details (keyed by index) expandedHolder: number | null = null; @@ -3121,6 +3128,8 @@ export class TransactionEntryComponent implements OnInit, OnDestroy { byName(/vault/i) ?? byName(/cash/i) ?? items.find((a: any) => a.glAccountData?.type?.value === 'ASSET'); if (found?.glAccountData?.id) { this.cashVaultGlId = found.glAccountData.id; + this.cashVaultGlCode = found.glAccountData.glCode ?? null; + this.cashVaultGlName = found.glAccountData.name ?? found.financialActivityData?.name ?? null; } }); } @@ -5648,11 +5657,15 @@ export class TransactionEntryComponent implements OnInit, OnDestroy { count: this.countFor(d), subtotal: this.subtotalFor(d) })); + // Filed against the Cash Vault GL, not the account the teller selected on screen: that + // selected account (e.g. Sundry Creditors) is the other leg of the journal entry, and + // never the one whose physical notes actually changed hands. Tagging the breakdown to it + // instead of the vault is why this cash never reached the cashier/vault position. this.http .post('/cashier/gl-denomination', { txnId: res.transactionId ?? res.resourceId, - accountNo: this.accountDetails?.glCode ?? row.acNo, - accountName: this.accountDetails?.name ?? '', + accountNo: this.cashVaultGlCode ?? row.acNo, + accountName: this.cashVaultGlName ?? 'Cash at Vault', txnType: isDeposit ? 'DEPOSIT' : 'WITHDRAWAL', txnDate: txnDateIso, paymentTypeName: selectedPt?.name ?? 'Cash', From 31e99b13632e8985de784b346d5e12d809758296 Mon Sep 17 00:00:00 2001 From: Nitesh Yadav Date: Mon, 14 Sep 2026 11:39:14 +0530 Subject: [PATCH 43/64] fix(clients): rebuild CKYC document upload as a popup with full proof details MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The CKYC tab's document cards now open a dedicated dialog (with client-side preview) instead of expanding inline, collecting document type, number, and issue/expiry dates the same way client creation does. Fixes several bugs found along the way: - Uploading a document type that's valid for both identity and address (Aadhaar, Passport, etc.) no longer 404s or 500s — the "no such endpoint" and case-sensitive ClientIdentifierStatus.valueOf("Active") crashes are both resolved. - Edit Client's Identity-proof and Address-proof steps no longer show each other's document when both are dual-purpose OVD types, and no longer show stale/mismatched front-back scans after a document type change. - Address-proof's own document preview never showed a document at all when no m_client_kyc record existed yet. Document number and issue/expiry dates are persisted on the identifier (documentKey + a shared "Issue date: ..., Expiry date: ..." description convention) and read back by both the CKYC tab and Edit Client so the same details appear everywhere. Co-Authored-By: Claude Sonnet 5 --- .../client-india-address-step.component.html | 56 ++- .../client-india-address-step.component.ts | 114 ++++- .../client-kyc-step.component.ts | 48 +- .../ckyc-tab/ckyc-tab.component.html | 395 ++++++---------- .../ckyc-tab/ckyc-tab.component.scss | 65 +-- .../ckyc-tab/ckyc-tab.component.ts | 427 +++++++++++++++--- .../ckyc-upload-dialog.component.html | 167 +++++++ .../ckyc-upload-dialog.component.scss | 270 +++++++++++ .../ckyc-upload-dialog.component.ts | 155 +++++++ .../identities-tab.component.ts | 2 +- .../create-client/create-client.component.ts | 15 +- .../edit-client/edit-client.component.ts | 17 +- src/environments/.env.ts | 4 +- 13 files changed, 1364 insertions(+), 371 deletions(-) create mode 100644 src/app/clients/clients-view/ckyc-tab/ckyc-upload-dialog/ckyc-upload-dialog.component.html create mode 100644 src/app/clients/clients-view/ckyc-tab/ckyc-upload-dialog/ckyc-upload-dialog.component.scss create mode 100644 src/app/clients/clients-view/ckyc-tab/ckyc-upload-dialog/ckyc-upload-dialog.component.ts diff --git a/src/app/clients/client-stepper/client-india-address-step/client-india-address-step.component.html b/src/app/clients/client-stepper/client-india-address-step/client-india-address-step.component.html index c1922f22c..4daf63941 100644 --- a/src/app/clients/client-stepper/client-india-address-step/client-india-address-step.component.html +++ b/src/app/clients/client-stepper/client-india-address-step/client-india-address-step.component.html @@ -221,6 +221,22 @@ } + + Expiry date (optional) + event_busy + + + +
@@ -238,14 +254,18 @@ @if (existingAddressProofDocuments.length > 0 && !addrFiles.front && readOnly) {
- @if (addrMergedPreview) { - Saved address proof + @if (existingAddrFrontSlotPreview) { + Saved address proof } @else { description }
- {{ existingAddressProofDocuments[0].fileName || existingAddressProofDocuments[0].name }} + {{ existingAddrFrontSlotName }}
+ @if (existing.ADDRESS_PROOF.previewUrl || existing.ADDRESS_PROOF.previewUrlBack) { +
+ @if (existing.ADDRESS_PROOF.previewUrl) { + Address proof scan - front + } + @if (existing.ADDRESS_PROOF.previewUrlBack) { + Address proof scan - back + } +
+ }
{{ existing.ADDRESS_PROOF.name }} @@ -217,78 +235,51 @@ }
+ @if (cardDate('ADDRESS_PROOF', 'issue') || cardDate('ADDRESS_PROOF', 'expiry')) { +
+ @if (cardDate('ADDRESS_PROOF', 'issue')) { + Issued {{ cardDate('ADDRESS_PROOF', 'issue') }} + } + @if (cardDate('ADDRESS_PROOF', 'expiry')) { + Expires {{ cardDate('ADDRESS_PROOF', 'expiry') }} + } +
+ } +
+ } @else { +
+

Not uploaded yet. Accepted formats: PNG, JPEG, JPG, PDF

+
} - @if (!existing.ADDRESS_PROOF.present || existing.ADDRESS_PROOF.expanded) { -

{{ 'labels.inputs.Accepted formats' | translate }}: PNG, JPEG, JPG, PDF

-
- - - - - + @if (uploading['ADDRESS_PROOF']) { +
+ + Uploading… +
+ } + @if (uploadErrors['ADDRESS_PROOF']) { +
+ error_outline + {{ uploadErrors['ADDRESS_PROOF'] }}
- - @if (uploadErrors['ADDRESS_PROOF']) { -
- error_outline - {{ uploadErrors['ADDRESS_PROOF'] }} -
- } - @if (uploadResponses['ADDRESS_PROOF']) { -
-

- {{ 'labels.inputs.Status' | translate }}: {{ uploadResponses['ADDRESS_PROOF'].status }} -

-

- {{ 'labels.inputs.Request ID' | translate }}: {{ uploadResponses['ADDRESS_PROOF'].requestId }} -

-

- {{ 'labels.inputs.Acknowledgement Number' | translate }}: - {{ uploadResponses['ADDRESS_PROOF'].acknowledgementNumber }} -

-

- {{ 'labels.inputs.Processing Status' | translate }}: - {{ uploadResponses['ADDRESS_PROOF'].processingStatus }} -

-
- } } @@ -310,6 +301,24 @@ from {{ existing.PHOTO_PROOF.source }}
+ @if (existing.PHOTO_PROOF.previewUrl || existing.PHOTO_PROOF.previewUrlBack) { +
+ @if (existing.PHOTO_PROOF.previewUrl) { + ID proof scan - front + } + @if (existing.PHOTO_PROOF.previewUrlBack) { + ID proof scan - back + } +
+ }
{{ existing.PHOTO_PROOF.name }} @@ -322,78 +331,49 @@ }
+ @if (cardDate('PHOTO_PROOF', 'issue') || cardDate('PHOTO_PROOF', 'expiry')) { +
+ @if (cardDate('PHOTO_PROOF', 'issue')) { + Issued {{ cardDate('PHOTO_PROOF', 'issue') }} + } + @if (cardDate('PHOTO_PROOF', 'expiry')) { + Expires {{ cardDate('PHOTO_PROOF', 'expiry') }} + } +
+ } + + } @else { +
+

Not uploaded yet. Accepted formats: PNG, JPEG, JPG, PDF

+
} - @if (!existing.PHOTO_PROOF.present || existing.PHOTO_PROOF.expanded) { -

{{ 'labels.inputs.Accepted formats' | translate }}: PNG, JPEG, JPG, PDF

-
- - - - - + @if (uploading['PHOTO_PROOF']) { +
+ + Uploading… +
+ } + @if (uploadErrors['PHOTO_PROOF']) { +
+ error_outline + {{ uploadErrors['PHOTO_PROOF'] }}
- - @if (uploadErrors['PHOTO_PROOF']) { -
- error_outline - {{ uploadErrors['PHOTO_PROOF'] }} -
- } - @if (uploadResponses['PHOTO_PROOF']) { -
-

- {{ 'labels.inputs.Status' | translate }}: {{ uploadResponses['PHOTO_PROOF'].status }} -

-

- {{ 'labels.inputs.Request ID' | translate }}: {{ uploadResponses['PHOTO_PROOF'].requestId }} -

-

- {{ 'labels.inputs.Acknowledgement Number' | translate }}: - {{ uploadResponses['PHOTO_PROOF'].acknowledgementNumber }} -

-

- {{ 'labels.inputs.Processing Status' | translate }}: - {{ uploadResponses['PHOTO_PROOF'].processingStatus }} -

-
- } } @@ -422,74 +402,33 @@ mat-stroked-button type="button" class="ckyc-existing__replace" - (click)="toggleReplace('CLIENT_IMAGE')" + (click)="openUploadDialog('CLIENT_IMAGE')" > - - {{ existing.CLIENT_IMAGE.expanded ? 'Cancel replace' : 'Upload new version' }} + + Upload new version + +
+ } @else { +
+

Not uploaded yet. Accepted formats: PNG, JPEG, JPG

+
} - @if (!existing.CLIENT_IMAGE.present || existing.CLIENT_IMAGE.expanded) { -

{{ 'labels.inputs.Accepted formats' | translate }}: PNG, JPEG, JPG

-
- - - - - + @if (uploading['CLIENT_IMAGE']) { +
+ + Uploading… +
+ } + @if (uploadErrors['CLIENT_IMAGE']) { +
+ error_outline + {{ uploadErrors['CLIENT_IMAGE'] }}
- - @if (uploadErrors['CLIENT_IMAGE']) { -
- error_outline - {{ uploadErrors['CLIENT_IMAGE'] }} -
- } - @if (uploadResponses['CLIENT_IMAGE']) { -
-

- {{ 'labels.inputs.Status' | translate }}: {{ uploadResponses['CLIENT_IMAGE'].status }} -

-

- {{ 'labels.inputs.Request ID' | translate }}: {{ uploadResponses['CLIENT_IMAGE'].requestId }} -

-

- {{ 'labels.inputs.Acknowledgement Number' | translate }}: - {{ uploadResponses['CLIENT_IMAGE'].acknowledgementNumber }} -

-

- {{ 'labels.inputs.Processing Status' | translate }}: - {{ uploadResponses['CLIENT_IMAGE'].processingStatus }} -

-
- } } @@ -526,75 +465,33 @@ mat-stroked-button type="button" class="ckyc-existing__replace" - (click)="toggleReplace('CLIENT_SIGNATURE')" + (click)="openUploadDialog('CLIENT_SIGNATURE')" > - - {{ existing.CLIENT_SIGNATURE.expanded ? 'Cancel replace' : 'Upload new version' }} + + Upload new version + +
+ } @else { +
+

Not uploaded yet. Accepted formats: PNG, JPEG, JPG

+
} - @if (!existing.CLIENT_SIGNATURE.present || existing.CLIENT_SIGNATURE.expanded) { -

{{ 'labels.inputs.Accepted formats' | translate }}: PNG, JPEG, JPG

-
- - - - - + @if (uploading['CLIENT_SIGNATURE']) { +
+ + Uploading… +
+ } + @if (uploadErrors['CLIENT_SIGNATURE']) { +
+ error_outline + {{ uploadErrors['CLIENT_SIGNATURE'] }}
- - @if (uploadErrors['CLIENT_SIGNATURE']) { -
- error_outline - {{ uploadErrors['CLIENT_SIGNATURE'] }} -
- } - @if (uploadResponses['CLIENT_SIGNATURE']) { -
-

- {{ 'labels.inputs.Status' | translate }}: {{ uploadResponses['CLIENT_SIGNATURE'].status }} -

-

- {{ 'labels.inputs.Request ID' | translate }}: - {{ uploadResponses['CLIENT_SIGNATURE'].requestId }} -

-

- {{ 'labels.inputs.Acknowledgement Number' | translate }}: - {{ uploadResponses['CLIENT_SIGNATURE'].acknowledgementNumber }} -

-

- {{ 'labels.inputs.Processing Status' | translate }}: - {{ uploadResponses['CLIENT_SIGNATURE'].processingStatus }} -

-
- } } diff --git a/src/app/clients/clients-view/ckyc-tab/ckyc-tab.component.scss b/src/app/clients/clients-view/ckyc-tab/ckyc-tab.component.scss index 66d527c48..602d2dd3e 100644 --- a/src/app/clients/clients-view/ckyc-tab/ckyc-tab.component.scss +++ b/src/app/clients/clients-view/ckyc-tab/ckyc-tab.component.scss @@ -553,15 +553,21 @@ margin: 0 0 12px; } -.ckyc-file-row { +.ckyc-missing { + margin-top: 14px; display: flex; + flex-direction: column; + gap: 10px; align-items: flex-start; - gap: 8px; - margin-bottom: 12px; } -.ckyc-file-name-field { - flex: 1; +.ckyc-uploading { + display: flex; + align-items: center; + gap: 8px; + margin-top: 10px; + font-size: 12px; + color: #5f6368; } .ckyc-upload-error { @@ -583,19 +589,6 @@ } } -.ckyc-upload-response { - margin-top: 12px; - padding: 10px 14px; - background-color: #e8f5e9; - border: 1px solid #a5d6a7; - border-radius: 8px; - font-size: 12px; - - p { - margin: 4px 0; - } -} - @media (width <= 900px) { .ckyc-upload-grid { grid-template-columns: 1fr; @@ -610,14 +603,6 @@ .ckyc-upload-grid { grid-template-columns: 1fr; } - - .ckyc-file-row { - flex-direction: column; - } - - .ckyc-file-name-field { - width: 100%; - } } /* ===================== Already-available banners ===================== */ @@ -731,6 +716,34 @@ letter-spacing: 0.3px; } + &__thumbs { + display: flex; + align-items: center; + justify-content: center; + gap: 10px; + flex-wrap: wrap; + } + + &__dates { + display: flex; + align-items: center; + gap: 10px; + font-size: 11px; + color: #0f6e56; + flex-wrap: wrap; + + span { + display: inline-flex; + align-items: center; + gap: 5px; + } + + fa-icon { + color: #1d9e75; + font-size: 11px; + } + } + &__thumb { width: 100px; height: 100px; diff --git a/src/app/clients/clients-view/ckyc-tab/ckyc-tab.component.ts b/src/app/clients/clients-view/ckyc-tab/ckyc-tab.component.ts index adcd3d486..0acbbf2d8 100644 --- a/src/app/clients/clients-view/ckyc-tab/ckyc-tab.component.ts +++ b/src/app/clients/clients-view/ckyc-tab/ckyc-tab.component.ts @@ -9,27 +9,38 @@ /** Angular Imports */ import { Component, OnInit, OnDestroy, inject } from '@angular/core'; import { ActivatedRoute } from '@angular/router'; -import { Subject, takeUntil, catchError, of, forkJoin } from 'rxjs'; +import { Subject, Observable, takeUntil, catchError, of, forkJoin } from 'rxjs'; /** Custom Services */ import { ClientsService } from '../../clients.service'; import { AlertService } from 'app/core/alert/alert.service'; import { MatIcon } from '@angular/material/icon'; import { MatProgressSpinner } from '@angular/material/progress-spinner'; +import { MatDialog } from '@angular/material/dialog'; import { FaIconComponent } from '@fortawesome/angular-fontawesome'; import { STANDALONE_SHARED_IMPORTS } from 'app/standalone-shared.module'; +import { Dates } from 'app/core/utils/dates'; +import { ProofType, normalizeProofType } from '../../shared/kyc-proof-types'; +import { CkycUploadDialogComponent, CkycUploadDialogResult } from './ckyc-upload-dialog/ckyc-upload-dialog.component'; interface ExistingArtefact { present: boolean; name?: string; source?: string; previewUrl?: string; + previewUrlBack?: string; documentId?: string; + documentTypeId?: string; documentKey?: string; + /** "Issue date: yyyy-MM-dd, Expiry date: yyyy-MM-dd" — same convention client-india-address-step + * already writes/reads for address proof; now shared by the photo/identity proof too. */ + description?: string; fileCount?: number; - expanded?: boolean; } +type ProofKind = 'ADDRESS_PROOF' | 'PHOTO_PROOF'; +type CkycDocKind = ProofKind | 'CLIENT_IMAGE' | 'CLIENT_SIGNATURE'; + @Component({ selector: 'mifosx-ckyc-tab', templateUrl: './ckyc-tab.component.html', @@ -46,6 +57,8 @@ export class CkycTabComponent implements OnInit, OnDestroy { private route = inject(ActivatedRoute); private clientsService = inject(ClientsService); private alertService = inject(AlertService); + private dialog = inject(MatDialog); + private dates = inject(Dates); private destroy$ = new Subject(); @@ -80,16 +93,41 @@ export class CkycTabComponent implements OnInit, OnDestroy { } /** Pre-existing artefacts detected from client data */ - existing: Record<'ADDRESS_PROOF' | 'PHOTO_PROOF' | 'CLIENT_IMAGE' | 'CLIENT_SIGNATURE', ExistingArtefact> = { + existing: Record = { ADDRESS_PROOF: { present: false }, PHOTO_PROOF: { present: false }, CLIENT_IMAGE: { present: false }, CLIENT_SIGNATURE: { present: false } }; + /** Document-type options for the proof pickers, fetched once from /kyc/proof-types */ + addressProofOptions: ProofType[] = []; + idProofOptions: ProofType[] = []; + + /** Document type currently selected in each proof card's picker */ + selectedProofType: Record = { + ADDRESS_PROOF: null, + PHOTO_PROOF: null + }; + + /** Front/back files staged for the two proof cards; other cards keep a single file each */ + docFiles: Record = { + ADDRESS_PROOF: { front: null, back: null }, + PHOTO_PROOF: { front: null, back: null } + }; + + /** Document number + issue/expiry dates staged from the upload dialog, same fields client + * creation collects for a KYC/address proof — persisted onto the identifier's documentKey + * and description ("Issue date: yyyy-MM-dd, Expiry date: yyyy-MM-dd"). */ + private documentDetails: Record< + ProofKind, + { documentKey: string; issueDate: string | null; expiryDate: string | null } + > = { + ADDRESS_PROOF: { documentKey: '', issueDate: null, expiryDate: null }, + PHOTO_PROOF: { documentKey: '', issueDate: null, expiryDate: null } + }; + /** File references */ - addressProofFile: File | null = null; - photoProofFile: File | null = null; clientImageFile: File | null = null; clientSignatureFile: File | null = null; @@ -101,9 +139,6 @@ export class CkycTabComponent implements OnInit, OnDestroy { CLIENT_SIGNATURE: false }; - /** Per-document upload response */ - uploadResponses: { [key: string]: any } = {}; - /** Per-document error messages */ uploadErrors: { [key: string]: string | null } = { ADDRESS_PROOF: null, @@ -262,6 +297,8 @@ export class CkycTabComponent implements OnInit, OnDestroy { } // 3 & 4. ID proof + Address proof — client identifiers classified via proof-types + this.idProofOptions = (proofTypes?.idProofs || []).map((p: any) => normalizeProofType(p)); + this.addressProofOptions = (proofTypes?.addressProofs || []).map((p: any) => normalizeProofType(p)); const idProofIds = new Set((proofTypes?.idProofs || []).map((p: any) => String(p.id))); const addressProofIds = new Set((proofTypes?.addressProofs || []).map((p: any) => String(p.id))); @@ -287,21 +324,77 @@ export class CkycTabComponent implements OnInit, OnDestroy { name: proofName, documentKey: isSyntheticKey ? '' : rawKey, documentId: identity?.id, + documentTypeId: docTypeId, + description: identity?.description || '', fileCount: (identity?.documents || []).length }; if (idProofIds.has(docTypeId) && !this.existing.PHOTO_PROOF.present) { this.existing.PHOTO_PROOF = common; + this.selectedProofType.PHOTO_PROOF = this.idProofOptions.find((p) => String(p.id) === docTypeId) || null; + this.loadIdentifierDocPreview('PHOTO_PROOF', identity.id); } else if (addressProofIds.has(docTypeId) && !this.existing.ADDRESS_PROOF.present) { this.existing.ADDRESS_PROOF = common; + this.selectedProofType.ADDRESS_PROOF = + this.addressProofOptions.find((p) => String(p.id) === docTypeId) || null; + this.loadIdentifierDocPreview('ADDRESS_PROOF', identity.id); } } }); } - toggleReplace(documentType: string): void { - const entry = (this.existing as any)[documentType]; - if (entry) entry.expanded = !entry.expanded; + /** + * The scanned ID/address-proof card only showed its file name and key — never the scan + * itself, unlike the photo and signature cards right beside it. Mirrors those two: fetch + * the identifier's own attached document(s) (m_client_identifier -> m_document, the same + * chain the Identities tab and the create-client stepper read) and download the latest for + * an inline preview. Two-sided proof types (Aadhaar, Passport, Voter ID, Driving Licence) + * store front/back as two separate documents named " — Front"/"— Back" (the same + * convention the create-client stepper writes), so both are matched and previewed here; + * a legacy single upload with no such marker just shows as the one preview. + */ + private loadIdentifierDocPreview(kind: ProofKind, identifierId: string): void { + this.clientsService + .getClientIdentificationDocuments(identifierId) + .pipe( + takeUntil(this.destroy$), + catchError(() => of([])) + ) + .subscribe((docs: any) => { + const list = Array.isArray(docs) ? docs : []; + if (!list.length) return; + this.existing[kind].fileCount = list.length; + + const byNewest = (a: any, b: any) => b.id - a.id; + const label = (d: any) => (d?.name || d?.fileName || '').toString(); + const back = [...list].filter((d: any) => /back/i.test(label(d))).sort(byNewest)[0]; + const front = + [...list].filter((d: any) => /front/i.test(label(d))).sort(byNewest)[0] || + [...list].filter((d: any) => d.id !== back?.id).sort(byNewest)[0]; + + if (front) this.downloadIdentifierDocPreview(identifierId, front.id, kind, 'front'); + if (back && back.id !== front?.id) this.downloadIdentifierDocPreview(identifierId, back.id, kind, 'back'); + }); + } + + private downloadIdentifierDocPreview( + identifierId: string, + documentId: string, + kind: ProofKind, + slot: 'front' | 'back' + ): void { + this.clientsService + .downloadClientIdentificationDocument(identifierId, documentId) + .pipe(takeUntil(this.destroy$)) + .subscribe({ + next: (blob: any) => { + if (!blob) return; + const url = URL.createObjectURL(blob); + if (slot === 'front') this.existing[kind].previewUrl = url; + else this.existing[kind].previewUrlBack = url; + }, + error: () => undefined + }); } onAccountSelect(accountNo: string): void { @@ -309,33 +402,8 @@ export class CkycTabComponent implements OnInit, OnDestroy { this.selectedAccountType = this.selectedAccount?.accountType || ''; } - onFileSelect(event: any, documentType: string): void { - const file = event.target?.files?.[0]; - if (!file) return; - - switch (documentType) { - case 'ADDRESS_PROOF': - this.addressProofFile = file; - break; - case 'PHOTO_PROOF': - this.photoProofFile = file; - break; - case 'CLIENT_IMAGE': - this.clientImageFile = file; - break; - case 'CLIENT_SIGNATURE': - this.clientSignatureFile = file; - break; - } - this.uploadErrors[documentType] = null; - } - getFile(documentType: string): File | null { switch (documentType) { - case 'ADDRESS_PROOF': - return this.addressProofFile; - case 'PHOTO_PROOF': - return this.photoProofFile; case 'CLIENT_IMAGE': return this.clientImageFile; case 'CLIENT_SIGNATURE': @@ -345,38 +413,278 @@ export class CkycTabComponent implements OnInit, OnDestroy { } } + private static readonly CARD_TITLES: Record = { + ADDRESS_PROOF: 'Address Proof Document', + PHOTO_PROOF: 'Photo Proof Document', + CLIENT_IMAGE: 'Image of Client', + CLIENT_SIGNATURE: 'Signature of Client' + }; + + /** Opens the picker/preview popup for one card and, once the user confirms a file there, feeds + * the picked type/file(s) into the same upload path the card always used. Picking and preview + * now live entirely in the dialog — this screen only stages the result and re-runs upload. */ + openUploadDialog(kind: CkycDocKind): void { + const isProofKind = kind === 'ADDRESS_PROOF' || kind === 'PHOTO_PROOF'; + const action = this.existing[kind].present ? 'Replace' : 'Upload'; + const existingDates = isProofKind + ? this.parseDescriptionDates(this.existing[kind].description) + : { issueDate: null, expiryDate: null }; + + const ref = this.dialog.open(CkycUploadDialogComponent, { + width: '480px', + maxWidth: '95vw', + data: { + title: `${action} ${CkycTabComponent.CARD_TITLES[kind]}`, + isProofKind, + accept: isProofKind ? '.png,.jpeg,.jpg,.pdf' : '.png,.jpeg,.jpg', + proofOptions: + kind === 'PHOTO_PROOF' + ? this.idProofOptions + : kind === 'ADDRESS_PROOF' + ? this.addressProofOptions + : undefined, + initialProofType: isProofKind ? this.selectedProofType[kind as ProofKind] : undefined, + initialDocumentKey: isProofKind ? this.existing[kind].documentKey || '' : undefined, + initialIssueDate: existingDates.issueDate, + initialExpiryDate: existingDates.expiryDate + } + }); + + ref.afterClosed().subscribe((result: CkycUploadDialogResult | null) => { + if (!result) return; + if (isProofKind) { + const proofKind = kind as ProofKind; + this.selectedProofType[proofKind] = result.proofType || null; + this.docFiles[proofKind] = { front: result.front, back: result.back || null }; + this.documentDetails[proofKind] = { + documentKey: result.documentKey || '', + issueDate: result.issueDate || null, + expiryDate: result.expiryDate || null + }; + } else if (kind === 'CLIENT_IMAGE') { + this.clientImageFile = result.front; + } else { + this.clientSignatureFile = result.front; + } + this.uploadDocument(kind); + }); + } + + /** Reads back the "Issue date: yyyy-MM-dd, Expiry date: yyyy-MM-dd" convention this tab (and + * client-india-address-step, for address proof) writes into the identifier's description. */ + private parseDescriptionDates(description: string | undefined): { issueDate: Date | null; expiryDate: Date | null } { + const issueMatch = (description || '').match(/Issue date:\s*([^,|]+)/i); + const expiryMatch = (description || '').match(/Expiry date:\s*([^,|]+)/i); + return { + issueDate: issueMatch ? this.dates.parseDate(issueMatch[1].trim()) : null, + expiryDate: expiryMatch ? this.dates.parseDate(expiryMatch[1].trim()) : null + }; + } + + private buildDescription(issueDate: string | null, expiryDate: string | null): string { + const parts: string[] = []; + if (issueDate) parts.push(`Issue date: ${issueDate}`); + if (expiryDate) parts.push(`Expiry date: ${expiryDate}`); + return parts.join(', '); + } + + /** Issue/expiry date for display on the card, formatted dd-MM-yyyy — parsed from the same + * description text the upload dialog writes. */ + cardDate(kind: ProofKind, which: 'issue' | 'expiry'): string { + const parsed = this.parseDescriptionDates(this.existing[kind].description); + const value = which === 'issue' ? parsed.issueDate : parsed.expiryDate; + return value ? this.dates.formatDate(value, 'dd-MM-yyyy') : ''; + } + + /** + * Routes each card to whichever real endpoint that artefact is actually stored through — there + * is no single "/clients/{id}/ckyc/documents" resource on the backend, so posting every card to + * one made-up URL 404'd for all four. A photo goes to the image endpoint, a signature to the + * client's own documents, and an ID/address proof to the m_client_identifier it already belongs + * to (the same one the Identities tab and create-client stepper post to) — never a fresh, + * type-less upload, since which identifier a proof belongs to is exactly what makes it that + * proof rather than some other file on the client's record. + */ uploadDocument(documentType: string): void { + if (documentType === 'PHOTO_PROOF' || documentType === 'ADDRESS_PROOF') { + this.uploadProofDocument(documentType); + return; + } + const file = this.getFile(documentType); if (!file) return; this.uploading[documentType] = true; this.uploadErrors[documentType] = null; + const key = documentType as keyof typeof this.existing; - const formData = new FormData(); - formData.append('file', file); - formData.append('documentType', documentType); + let request$: Observable; + switch (documentType) { + case 'CLIENT_IMAGE': + request$ = this.clientsService.uploadClientProfileImage(this.clientId, file); + break; + case 'CLIENT_SIGNATURE': + request$ = this.clientsService.uploadClientSignatureImage(this.clientId, file); + break; + default: + return; + } - this.clientsService - .uploadCkycDocument(this.clientId, formData) - .pipe(takeUntil(this.destroy$)) - .subscribe({ - next: (response: any) => { - this.uploading[documentType] = false; - this.uploadResponses[documentType] = response; - this.alertService.alert({ - type: 'success', - message: response?.message || 'Document uploaded successfully.' - }); - }, - error: (err: any) => { - this.uploading[documentType] = false; - this.uploadErrors[documentType] = - err?.error?.developerMessage || - err?.error?.defaultUserMessage || - err?.message || - 'Failed to upload document.'; + request$.pipe(takeUntil(this.destroy$)).subscribe({ + next: () => { + this.uploading[documentType] = false; + this.alertService.alert({ type: 'success', message: 'Document uploaded successfully.' }); + this.refreshPreview(documentType); + }, + error: (err: any) => { + this.uploading[documentType] = false; + this.uploadErrors[documentType] = this.extractErrorMessage(err); + } + }); + } + + /** + * Proof cards work like the create-client KYC step: pick a document type first (its `sides` + * decides whether one image or a front+back pair is required), then upload. Reuses the same + * identifier the Identities tab manages — updates its documentTypeId if the picked type + * changed, or creates a fresh identifier when none exists yet, so this card never depends on + * a separate trip to the Identities tab. + */ + private uploadProofDocument(kind: ProofKind): void { + const proof = this.selectedProofType[kind]; + if (!proof) { + this.uploadErrors[kind] = 'Select a document type first.'; + return; + } + const files = this.docFiles[kind]; + if (!files.front || (proof.sides === 2 && !files.back)) { + this.uploadErrors[kind] = + proof.sides === 2 + ? 'Both front and back images are required for this document type.' + : 'Select a file to upload.'; + return; + } + + this.uploading[kind] = true; + this.uploadErrors[kind] = null; + + const existingId = this.existing[kind].documentId; + const details = this.documentDetails[kind]; + const documentKey = details.documentKey || `${proof.name} - ${this.clientId}`; + const description = this.buildDescription(details.issueDate, details.expiryDate); + + const identifier$: Observable = !existingId + ? this.clientsService.addClientIdentifier(this.clientId, { + documentTypeId: proof.id, + status: 'ACTIVE', + documentKey, + description: description || 'KYC document' + }) + : this.clientsService.updateClientIdentifier(this.clientId, existingId, { + documentTypeId: proof.id, + status: 'ACTIVE', + documentKey, + description + }); + + identifier$.pipe(takeUntil(this.destroy$)).subscribe({ + next: (res: any) => { + const identifierId = existingId || res?.resourceId; + const uploads: Observable[] = []; + + const front = new FormData(); + front.append('name', proof.sides === 2 ? `${proof.name} — Front` : proof.name); + front.append('file', files.front!); + uploads.push(this.clientsService.uploadClientIdentifierDocument(identifierId, front)); + + if (proof.sides === 2 && files.back) { + const back = new FormData(); + back.append('name', `${proof.name} — Back`); + back.append('file', files.back); + uploads.push(this.clientsService.uploadClientIdentifierDocument(identifierId, back)); } - }); + + forkJoin(uploads) + .pipe(takeUntil(this.destroy$)) + .subscribe({ + next: () => { + this.uploading[kind] = false; + this.existing[kind].present = true; + this.existing[kind].documentId = identifierId; + this.existing[kind].documentTypeId = String(proof.id); + this.existing[kind].documentKey = details.documentKey || ''; + this.existing[kind].description = description; + this.existing[kind].name = proof.name; + this.existing[kind].source = this.existing[kind].source || 'Identities section'; + this.docFiles[kind] = { front: null, back: null }; + this.alertService.alert({ type: 'success', message: 'Document uploaded successfully.' }); + this.refreshPreview(kind); + }, + error: (err: any) => { + this.uploading[kind] = false; + this.uploadErrors[kind] = this.extractErrorMessage(err); + } + }); + }, + error: (err: any) => { + this.uploading[kind] = false; + this.uploadErrors[kind] = this.extractErrorMessage(err); + } + }); + } + + private extractErrorMessage(err: any): string { + return ( + err?.error?.errors?.[0]?.defaultUserMessage || + err?.error?.developerMessage || + err?.error?.defaultUserMessage || + err?.message || + 'Failed to upload document.' + ); + } + + /** Re-fetches whichever artefact was just replaced, so its preview reflects the new file + * immediately instead of the one this screen loaded on entry. */ + private refreshPreview(documentType: string): void { + const staleKey = documentType as keyof typeof this.existing; + if (this.existing[staleKey].previewUrl?.startsWith('blob:')) { + URL.revokeObjectURL(this.existing[staleKey].previewUrl!); + } + if (this.existing[staleKey].previewUrlBack?.startsWith('blob:')) { + URL.revokeObjectURL(this.existing[staleKey].previewUrlBack!); + } + switch (documentType) { + case 'CLIENT_IMAGE': + this.clientsService + .getClientProfileImage(this.clientId) + .pipe(takeUntil(this.destroy$)) + .subscribe((photo: any) => { + if (photo) this.existing.CLIENT_IMAGE.previewUrl = photo; + }); + break; + case 'CLIENT_SIGNATURE': + this.clientsService + .getClientDocuments(this.clientId) + .pipe(takeUntil(this.destroy$)) + .subscribe((documents: any) => { + const doc = (documents as any[]) + ?.filter((d: any) => d?.name === 'clientSignature') + ?.sort((a: any, b: any) => b.id - a.id)?.[0]; + if (!doc) return; + this.existing.CLIENT_SIGNATURE.documentId = doc.id; + this.clientsService + .getClientSignatureImage(this.clientId, doc.id) + .pipe(takeUntil(this.destroy$)) + .subscribe((blob: any) => { + if (blob) this.existing.CLIENT_SIGNATURE.previewUrl = URL.createObjectURL(blob); + }); + }); + break; + case 'PHOTO_PROOF': + case 'ADDRESS_PROOF': + this.loadIdentifierDocPreview(documentType, this.existing[documentType].documentId!); + break; + } } ngOnDestroy(): void { @@ -385,6 +693,7 @@ export class CkycTabComponent implements OnInit, OnDestroy { // Release object URLs Object.values(this.existing).forEach((e) => { if (e.previewUrl?.startsWith('blob:')) URL.revokeObjectURL(e.previewUrl); + if (e.previewUrlBack?.startsWith('blob:')) URL.revokeObjectURL(e.previewUrlBack); }); } } diff --git a/src/app/clients/clients-view/ckyc-tab/ckyc-upload-dialog/ckyc-upload-dialog.component.html b/src/app/clients/clients-view/ckyc-tab/ckyc-upload-dialog/ckyc-upload-dialog.component.html new file mode 100644 index 000000000..cae9e00ff --- /dev/null +++ b/src/app/clients/clients-view/ckyc-tab/ckyc-upload-dialog/ckyc-upload-dialog.component.html @@ -0,0 +1,167 @@ + + +
+
+ +
+
+

{{ data.title }}

+

Fill in the document details, then attach the scan.

+
+ +
+ + + @if (data.isProofKind) { +
+ + + + @for (p of data.proofOptions; track p.id) { + {{ p.name }} + } + + +

Upload requirements will update automatically based on the type you pick.

+
+ + @if (selectedProofType) { + @if (selectedProofType.info) { +
+ + {{ selectedProofType.info }} +
+ } + +
+ + + + +
+ + + +
Scan
+ +
+
+ {{ sides === 2 ? 'Front side' : 'Document' }} + +
+ + @if (sides === 2) { +
+ Back side + +
+ } +
+ } + } @else { +
+
+ Document + +
+
+ } +
+ + + + + diff --git a/src/app/clients/clients-view/ckyc-tab/ckyc-upload-dialog/ckyc-upload-dialog.component.scss b/src/app/clients/clients-view/ckyc-tab/ckyc-upload-dialog/ckyc-upload-dialog.component.scss new file mode 100644 index 000000000..a6833e451 --- /dev/null +++ b/src/app/clients/clients-view/ckyc-tab/ckyc-upload-dialog/ckyc-upload-dialog.component.scss @@ -0,0 +1,270 @@ +/** + * 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; + max-width: 100%; +} + +/* ===================== Header ===================== */ +.cud-header { + display: flex; + align-items: flex-start; + gap: 12px; + padding: 20px 24px 16px; + border-bottom: 1px solid #eef1f4; + + &__badge { + flex-shrink: 0; + width: 36px; + height: 36px; + border-radius: 9px; + display: flex; + align-items: center; + justify-content: center; + font-size: 15px; + color: #fff; + background: linear-gradient(135deg, #1a73e8, #4285f4); + box-shadow: 0 3px 10px rgb(26 115 232 / 28%); + } + + &__text { + flex: 1; + min-width: 0; + } +} + +.cud-title { + margin: 0 !important; + padding: 0; + font-size: 16px; + font-weight: 700; + color: #1a1d21; + line-height: 1.3; +} + +.cud-subtitle { + margin: 2px 0 0; + font-size: 12px; + color: #6b7280; +} + +.cud-close { + flex-shrink: 0; + width: 32px; + height: 32px; + margin: -4px -4px 0 0; + color: #9aa0a6; + + &:hover { + color: #3c4043; + } +} + +/* ===================== Body / sections ===================== */ +.cud-body.mat-mdc-dialog-content { + padding: 18px 24px !important; + max-height: 70vh; +} + +.cud-section { + margin-bottom: 14px; + + &--dates { + display: flex; + gap: 14px; + + .cud-date-field { + flex: 1; + min-width: 0; + } + } + + &__label { + display: flex; + align-items: center; + gap: 7px; + font-size: 11px; + font-weight: 700; + color: #475569; + text-transform: uppercase; + letter-spacing: 0.05em; + margin-bottom: 6px; + + fa-icon { + color: #1a73e8; + font-size: 10.5px; + } + } +} + +.cud-field { + width: 100%; +} + +.cud-hint { + font-size: 12px; + color: #6b7280; + margin: 6px 0 0; + line-height: 1.4; +} + +.cud-banner { + display: flex; + align-items: flex-start; + gap: 9px; + padding: 10px 14px; + margin-bottom: 14px; + background: #f0fbf6; + border: 1px solid #cfecdd; + border-radius: 10px; + font-size: 12.5px; + color: #085041; + line-height: 1.45; + + fa-icon { + margin-top: 2px; + color: #1d9e75; + flex-shrink: 0; + } +} + +.cud-divider { + display: flex; + align-items: center; + gap: 10px; + margin: 16px 0 12px; + font-size: 10.5px; + font-weight: 700; + color: #9aa0a6; + text-transform: uppercase; + letter-spacing: 0.08em; + + &::before, + &::after { + content: ''; + flex: 1; + height: 1px; + background: #eef1f4; + } +} + +/* ===================== Front/back scan grid ===================== */ +.cud-grid { + display: grid; + grid-template-columns: 1fr; + gap: 12px; + + &--two { + grid-template-columns: repeat(2, 1fr); + } +} + +.cud-slot { + display: flex; + flex-direction: column; + gap: 6px; + + &__label { + font-size: 10.5px; + font-weight: 700; + color: #5f6368; + text-transform: uppercase; + letter-spacing: 0.05em; + } +} + +.cud-dropzone { + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + gap: 8px; + min-height: 130px; + padding: 16px; + border: 2px dashed #cbd5e1; + border-radius: 12px; + cursor: pointer; + text-align: center; + background: #fafbfc; + transition: + border-color 0.15s, + background 0.15s, + box-shadow 0.15s; + + &:hover { + border-color: #1a73e8; + background: #f5f9ff; + box-shadow: 0 2px 10px rgb(26 115 232 / 10%); + } + + &--has { + border-style: solid; + border-color: #1d9e75; + background: #f0fbf6; + } + + &__icon { + font-size: 22px; + color: #9aa5b1; + } + + &--has &__icon { + color: #1d9e75; + } + + &__text { + font-size: 12px; + color: #64748b; + margin: 0; + font-weight: 500; + } + + &__name { + font-size: 11.5px; + color: #085041; + font-weight: 600; + margin: 0; + word-break: break-all; + padding: 0 8px; + } + + &__img { + max-width: 100%; + max-height: 150px; + border-radius: 8px; + object-fit: contain; + box-shadow: 0 2px 8px rgb(0 0 0 / 8%); + } +} + +/* ===================== Actions ===================== */ +.cud-actions { + padding: 12px 24px !important; + margin: 0 !important; + border-top: 1px solid #eef1f4; + + fa-icon { + font-size: 12px; + } +} + +@media (width <= 560px) { + :host { + width: 100%; + } + + .cud-section--dates { + flex-direction: column; + gap: 14px; + } + + .cud-grid--two { + grid-template-columns: 1fr; + } +} diff --git a/src/app/clients/clients-view/ckyc-tab/ckyc-upload-dialog/ckyc-upload-dialog.component.ts b/src/app/clients/clients-view/ckyc-tab/ckyc-upload-dialog/ckyc-upload-dialog.component.ts new file mode 100644 index 000000000..3fe969bce --- /dev/null +++ b/src/app/clients/clients-view/ckyc-tab/ckyc-upload-dialog/ckyc-upload-dialog.component.ts @@ -0,0 +1,155 @@ +/** + * 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, inject } from '@angular/core'; +import { FormsModule } from '@angular/forms'; +import { + MatDialogRef, + MAT_DIALOG_DATA, + MatDialogTitle, + MatDialogContent, + MatDialogActions, + MatDialogClose +} from '@angular/material/dialog'; +import { MatIconButton } from '@angular/material/button'; +import { FaIconComponent } from '@fortawesome/angular-fontawesome'; +import { STANDALONE_SHARED_IMPORTS } from 'app/standalone-shared.module'; +import { Dates } from 'app/core/utils/dates'; +import { ProofType } from '../../../shared/kyc-proof-types'; + +export interface CkycUploadDialogData { + title: string; + isProofKind: boolean; + accept: string; + proofOptions?: ProofType[]; + initialProofType?: ProofType | null; + initialDocumentKey?: string; + initialIssueDate?: Date | null; + initialExpiryDate?: Date | null; +} + +export interface CkycUploadDialogResult { + proofType?: ProofType | null; + front: File; + back?: File; + /** Document number (Aadhaar/PAN/Passport number, etc.) — same field client creation collects. */ + documentKey?: string; + /** 'yyyy-MM-dd', same as the rest of this app's date fields. */ + issueDate?: string | null; + expiryDate?: string | null; +} + +/** + * A focused popup for replacing/uploading one CKYC artefact at a time — opened from each card on + * the CKYC tab instead of expanding a form inline underneath it. Collects the same details + * client creation does for an identity/address proof (document type, number, issue + expiry + * date) and shows a client-side preview of whatever image is picked before it's ever sent to the + * server; the actual upload still happens back in CkycTabComponent (this dialog only collects + * the type, number, dates and file(s), then hands them back). + */ +@Component({ + selector: 'mifosx-ckyc-upload-dialog', + templateUrl: './ckyc-upload-dialog.component.html', + styleUrls: ['./ckyc-upload-dialog.component.scss'], + imports: [ + ...STANDALONE_SHARED_IMPORTS, + FormsModule, + MatDialogTitle, + MatDialogContent, + MatDialogActions, + MatDialogClose, + MatIconButton, + FaIconComponent + ] +}) +export class CkycUploadDialogComponent implements OnDestroy { + private dates = inject(Dates); + dialogRef = inject>(MatDialogRef); + data = inject(MAT_DIALOG_DATA); + + selectedProofType: ProofType | null = this.data.initialProofType || null; + documentKey = this.data.initialDocumentKey || ''; + issueDate: Date | null = this.data.initialIssueDate || null; + expiryDate: Date | null = this.data.initialExpiryDate || null; + + front: File | null = null; + back: File | null = null; + frontPreview: string | null = null; + backPreview: string | null = null; + + get sides(): number { + return this.data.isProofKind ? (this.selectedProofType?.sides ?? 1) : 1; + } + + get canSubmit(): boolean { + if (this.data.isProofKind) { + if (!this.selectedProofType) return false; + if (!this.documentKey.trim()) return false; + } + if (!this.front) return false; + if (this.sides === 2 && !this.back) return false; + return true; + } + + compareProofType = (a: ProofType | null, b: ProofType | null): boolean => (a?.id ?? null) === (b?.id ?? null); + + onProofTypeChange(proof: ProofType | null): void { + this.selectedProofType = proof; + this.clearSlot('front'); + this.clearSlot('back'); + } + + onFilePick(slot: 'front' | 'back', event: any): void { + const file = event?.target?.files?.[0]; + if (event?.target) event.target.value = ''; + if (!file) return; + + this.clearSlot(slot); + const preview = file.type?.startsWith('image/') ? URL.createObjectURL(file) : null; + if (slot === 'front') { + this.front = file; + this.frontPreview = preview; + } else { + this.back = file; + this.backPreview = preview; + } + } + + private clearSlot(slot: 'front' | 'back'): void { + if (slot === 'front') { + if (this.frontPreview) URL.revokeObjectURL(this.frontPreview); + this.front = null; + this.frontPreview = null; + } else { + if (this.backPreview) URL.revokeObjectURL(this.backPreview); + this.back = null; + this.backPreview = null; + } + } + + submit(): void { + if (!this.canSubmit) return; + this.dialogRef.close({ + proofType: this.selectedProofType, + front: this.front!, + back: this.sides === 2 ? this.back! : undefined, + documentKey: this.data.isProofKind ? this.documentKey.trim() : undefined, + issueDate: this.issueDate ? this.dates.getDate(this.issueDate) : null, + expiryDate: this.expiryDate ? this.dates.getDate(this.expiryDate) : null + }); + } + + cancel(): void { + this.dialogRef.close(null); + } + + ngOnDestroy(): void { + if (this.frontPreview) URL.revokeObjectURL(this.frontPreview); + if (this.backPreview) URL.revokeObjectURL(this.backPreview); + } +} diff --git a/src/app/clients/clients-view/identities-tab/identities-tab.component.ts b/src/app/clients/clients-view/identities-tab/identities-tab.component.ts index fc9d25486..daaafe57c 100644 --- a/src/app/clients/clients-view/identities-tab/identities-tab.component.ts +++ b/src/app/clients/clients-view/identities-tab/identities-tab.component.ts @@ -229,7 +229,7 @@ export class IdentitiesTabComponent implements OnDestroy { } const identifierData = { documentTypeId, - status: 'Active', + status: 'ACTIVE', documentKey: kind === 'id' ? section.documentKey : `${proofName} - ${this.clientId}`, description: kind === 'address' && section.address ? section.address : description }; diff --git a/src/app/clients/create-client/create-client.component.ts b/src/app/clients/create-client/create-client.component.ts index 6218ea5ac..ccd05c057 100644 --- a/src/app/clients/create-client/create-client.component.ts +++ b/src/app/clients/create-client/create-client.component.ts @@ -840,6 +840,15 @@ export class CreateClientComponent { this.saveAddressProof(clientId); } + /** "Issue date: yyyy-MM-dd, Expiry date: yyyy-MM-dd" — same convention the CKYC tab and + * client-india-address-step's own "load existing" parsing both read back. */ + private buildProofDescription(issueDate: string | null | undefined, expiryDate: string | null | undefined): string { + const parts: string[] = []; + if (issueDate) parts.push(`Issue date: ${issueDate}`); + if (expiryDate) parts.push(`Expiry date: ${expiryDate}`); + return parts.join(', '); + } + /** * Save the address-proof picked on the permanent tab as a client identifier * + upload each selected file as a document attached to that identifier. @@ -855,9 +864,9 @@ export class CreateClientComponent { const proofName = payload.proof?.name || 'Address Proof'; const identifierData: any = { documentTypeId: payload.proofId, - status: 'Active', + status: 'ACTIVE', documentKey: payload.documentKey || `${proofName} - ${clientId}`, - description: payload.issueDate ? `Issue date: ${payload.issueDate}` : '' + description: this.buildProofDescription(payload.issueDate, payload.expiryDate) }; this.clientsService.addClientIdentifier(clientId, identifierData).subscribe({ @@ -904,7 +913,7 @@ export class CreateClientComponent { } const identifierData: any = { documentTypeId, - status: 'Active', + status: 'ACTIVE', documentKey: payload.documentKey || `${proofName} - ${clientId}`, description: 'KYC document' }; diff --git a/src/app/clients/edit-client/edit-client.component.ts b/src/app/clients/edit-client/edit-client.component.ts index 0f300376a..b2a07e089 100644 --- a/src/app/clients/edit-client/edit-client.component.ts +++ b/src/app/clients/edit-client/edit-client.component.ts @@ -1035,6 +1035,15 @@ export class EditClientComponent { this.saveAddressProof(clientId); } + /** "Issue date: yyyy-MM-dd, Expiry date: yyyy-MM-dd" — same convention the CKYC tab and + * client-india-address-step's own "load existing" parsing both read back. */ + private buildProofDescription(issueDate: string | null | undefined, expiryDate: string | null | undefined): string { + const parts: string[] = []; + if (issueDate) parts.push(`Issue date: ${issueDate}`); + if (expiryDate) parts.push(`Expiry date: ${expiryDate}`); + return parts.join(', '); + } + /** * Save the address-proof picked on the permanent tab as a client identifier * + upload each selected file as a document attached to that identifier. @@ -1050,9 +1059,9 @@ export class EditClientComponent { const proofName = payload.proof?.name || 'Address Proof'; const identifierData: any = { documentTypeId: payload.proofId, - status: 'Active', + status: 'ACTIVE', documentKey: payload.documentKey || `${proofName} - ${clientId}`, - description: payload.issueDate ? `Issue date: ${payload.issueDate}` : '' + description: this.buildProofDescription(payload.issueDate, payload.expiryDate) }; this.clientsService.addClientIdentifier(clientId, identifierData).subscribe({ @@ -1105,7 +1114,7 @@ export class EditClientComponent { const existingId = this.clientKycStep?.existingKycIdentifierId; if (existingId) { this.resolveDocumentTypeId(payload.proofId, proofName).subscribe((documentTypeId: number | null) => { - const update: any = { documentKey: payload.documentKey || `${proofName} - ${clientId}`, status: 'Active' }; + const update: any = { documentKey: payload.documentKey || `${proofName} - ${clientId}`, status: 'ACTIVE' }; if (documentTypeId != null) { update.documentTypeId = documentTypeId; } @@ -1128,7 +1137,7 @@ export class EditClientComponent { } const identifierData: any = { documentTypeId, - status: 'Active', + status: 'ACTIVE', documentKey: payload.documentKey || `${proofName} - ${clientId}`, description: 'KYC document' }; diff --git a/src/environments/.env.ts b/src/environments/.env.ts index 83bb12841..e4d9bc1c4 100644 --- a/src/environments/.env.ts +++ b/src/environments/.env.ts @@ -9,8 +9,8 @@ /* tslint:disable */ export default { 'mifos_x': { - 'version': '260911', - 'hash': '7afa9c7a' + 'version': '260914', + 'hash': 'ab58d7f7' }, 'allow_switching_backend_instance': true }; From 2124bb4b76aef9c9b2cf1a6ad7aa840d3a31110b Mon Sep 17 00:00:00 2001 From: keshav Date: Mon, 14 Sep 2026 11:43:26 +0530 Subject: [PATCH 44/64] feat: KYC document update, FIU & AML recurring alerts integration, Sponsor DD revoke workflow, and TDS reports --- .../search-journal-entry.component.html | 11 +- src/app/aml/aml.service.ts | 9 + .../cashier-issue-dd-dialog.component.ts | 7 +- .../transaction-entry.component.html | 16 +- .../transaction-entry.component.ts | 151 +++++- .../kyc-tab/kyc-tab.component.html | 95 +++- .../kyc-tab/kyc-tab.component.scss | 126 +++++ .../clients-view/kyc-tab/kyc-tab.component.ts | 345 ++++++++++++- .../kyc-document-update-dialog.component.html | 137 ++++++ .../kyc-document-update-dialog.component.scss | 172 +++++++ .../kyc-document-update-dialog.component.ts | 211 ++++++++ src/app/core/utils/api-date.ts | 29 ++ src/app/fiu/fiu-data.ts | 16 + src/app/fiu/fiu.component.html | 334 ++----------- src/app/fiu/fiu.component.scss | 11 + src/app/fiu/fiu.component.ts | 285 +++++++++-- .../aml-recurring-alerts.component.html | 9 - .../aml-recurring-alerts.component.scss | 21 - .../aml-recurring-alerts.component.ts | 45 +- .../aml-recurring-alerts.demo-data.ts | 248 ---------- src/app/risk-review/risk-review-data.ts | 17 +- .../risk-review-report.component.html | 74 ++- .../risk-review-report.component.scss | 88 +++- .../risk-review-report.component.ts | 139 +++++- src/app/risk-review/risk-review.compute.ts | 9 +- .../dd-issue-approvals.component.ts | 221 +++++++-- .../sponsor-dd/dd-list/dd-list.component.html | 25 + .../sponsor-dd/dd-list/dd-list.component.ts | 136 +++++- .../dd-revoke-approvals.component.html | 130 +++++ .../dd-revoke-approvals.component.ts | 460 ++++++++++++++++++ .../sponsor-dd/sponsor-dd-routing.module.ts | 6 + src/app/sponsor-dd/sponsor-dd.service.ts | 21 + .../checker-inbox-and-tasks.component.html | 6 + src/app/tasks/tasks-routing.module.ts | 8 + .../tds-interest-report.component.html | 2 +- .../tds-interest-report.component.ts | 46 +- src/app/tds-reports/tds-reports-data.ts | 187 +------ src/app/tds-reports/tds-reports.compute.ts | 9 +- src/app/tds-reports/tds-reports.service.ts | 91 ++++ src/environments/.env.ts | 4 +- 40 files changed, 3028 insertions(+), 929 deletions(-) create mode 100644 src/app/clients/shared/kyc-document-update-dialog/kyc-document-update-dialog.component.html create mode 100644 src/app/clients/shared/kyc-document-update-dialog/kyc-document-update-dialog.component.scss create mode 100644 src/app/clients/shared/kyc-document-update-dialog/kyc-document-update-dialog.component.ts delete mode 100644 src/app/rbi-circulars/aml-rules/aml-recurring-alerts/aml-recurring-alerts.demo-data.ts create mode 100644 src/app/sponsor-dd/dd-revoke-approvals/dd-revoke-approvals.component.html create mode 100644 src/app/sponsor-dd/dd-revoke-approvals/dd-revoke-approvals.component.ts create mode 100644 src/app/tds-reports/tds-reports.service.ts diff --git a/src/app/accounting/search-journal-entry/search-journal-entry.component.html b/src/app/accounting/search-journal-entry/search-journal-entry.component.html index f8dacebff..ade207e63 100644 --- a/src/app/accounting/search-journal-entry/search-journal-entry.component.html +++ b/src/app/accounting/search-journal-entry/search-journal-entry.component.html @@ -715,7 +715,10 @@ > - @if (totalEntries === 0) { + + @if (((dataSource?.entries$ | async) || []).length === 0) {
No entries found
@@ -724,7 +727,11 @@ }
- +
diff --git a/src/app/aml/aml.service.ts b/src/app/aml/aml.service.ts index 59385a350..76bbc1905 100644 --- a/src/app/aml/aml.service.ts +++ b/src/app/aml/aml.service.ts @@ -122,6 +122,15 @@ export class AmlService { return this.http.get('/aml/ctr-reports'); } + /** + * The reportable lines behind the generated CTRs, each resolved to the account, customer, branch + * and date a FIU-IND filing names. Omit `reportId` for every generated month. + */ + getCtrReportLines(reportId?: number): Observable { + const params = reportId ? new HttpParams().set('reportId', String(reportId)) : undefined; + return this.http.get('/aml/ctr-reports/lines', { params }); + } + /** month format: yyyy-MM */ generateCtrReport(month: string): Observable { return this.http.post('/aml/ctr-reports/generate', { month }); diff --git a/src/app/cashier/transaction-entry/cashier-issue-dd-dialog.component.ts b/src/app/cashier/transaction-entry/cashier-issue-dd-dialog.component.ts index 50e51c402..fff71aee1 100644 --- a/src/app/cashier/transaction-entry/cashier-issue-dd-dialog.component.ts +++ b/src/app/cashier/transaction-entry/cashier-issue-dd-dialog.component.ts @@ -71,8 +71,11 @@ export interface CashierIssueDdDialogData {
+
- Auto-Selected Leaflet + DD Number Being Issued {{ data.leaflet.ddNumber }} {{ data.sponsorBankName || 'Sponsor Bank' }} · {{ data.branchName }}
@@ -234,7 +237,7 @@ export interface CashierIssueDdDialogData {
diff --git a/src/app/cashier/transaction-entry/transaction-entry.component.html b/src/app/cashier/transaction-entry/transaction-entry.component.html index 98ae9647e..de55bc0f0 100644 --- a/src/app/cashier/transaction-entry/transaction-entry.component.html +++ b/src/app/cashier/transaction-entry/transaction-entry.component.html @@ -178,7 +178,7 @@ (row.dbCr = 'Cr') " > - @for (a of acTypOptions; track a) { + @for (a of acTypOptionsFor(row); track a) { } @@ -432,8 +432,14 @@ : 'Auto-selects next available leaflet and opens walk-in purchaser form' " > - post_add - Fill DD Details & Send for Approval + {{ + row.ddDetails ? 'edit_note' : 'post_add' + }} + + {{ + row.ddDetails ? 'DD ' + row.ddDetails.ddNumber + ' · Edit Details' : 'Fill DD Details' + }} @@ -1529,7 +1535,7 @@ (row.dbCr = 'Cr') " > - @for (a of acTypOptions; track a) { + @for (a of acTypOptionsFor(row); track a) { } @@ -1756,7 +1762,7 @@ " > post_add - Fill DD Details & Send for Approval + Fill DD Details diff --git a/src/app/cashier/transaction-entry/transaction-entry.component.ts b/src/app/cashier/transaction-entry/transaction-entry.component.ts index 5a49e956d..f787adb1c 100644 --- a/src/app/cashier/transaction-entry/transaction-entry.component.ts +++ b/src/app/cashier/transaction-entry/transaction-entry.component.ts @@ -57,6 +57,27 @@ interface TxnRow { * account — governs the share redemption (see submitShareRedemptionTransfer()). */ resolutionNumber?: string; meetingDate?: string; + /** + * Beneficiary and leaflet captured in the DD dialog, held on the row until Save. + * + * The dialog used to submit the request itself, which left the worksheet's own Save button doing + * nothing for a DD row — the clerk filled the dialog, the request was already gone, and pressing + * Save afterwards had no effect. Filling in details and committing them are two different + * actions, so the dialog now only records what was entered and Save is what sends it. + */ + ddDetails?: { + leafletId: number; + ddNumber: string; + beneficiaryName: string; + payableAtBranch: string; + payableAtCity: string; + /** Present only for a walk-in cash draft. */ + purchaser?: { name: string; address: string; aadhaar: string }; + /** Note-and-coin breakdown, cash drafts only. */ + denominations?: { [key: string]: number }; + fundingAccountId?: number; + fundingClientId?: number; + }; } interface Denomination { @@ -1671,6 +1692,33 @@ export class TransactionEntryComponent implements OnInit, OnDestroy { 'GL', 'LA' ]; + + /** + * Account types a clerk may fund a demand draft from. + * + * A clerk raises a draft against a member's own balance, so the only sensible sources are the + * accounts a member actually holds money in. GL was offered here too, and picking it quietly + * switched the row onto the cash path — the one that asks for a walk-in purchaser's name and a + * note-and-coin count, neither of which a clerk debiting an account has. The option is removed + * rather than validated afterwards, so the wrong path cannot be reached at all. + */ + private static readonly DD_CLERK_ACTYPS = [ + 'SD', + 'CA' + ]; + + /** + * The account types this particular row may use. + * + * Every other row keeps the full list; only a clerk's DD row is narrowed, because that is the + * only row where the account type silently decides which funding flow runs. + */ + acTypOptionsFor(row: TxnRow): string[] { + if (this.isClerk && row.typ === 'DD') { + return TransactionEntryComponent.DD_CLERK_ACTYPS; + } + return this.acTypOptions; + } dbCrOptions = [ 'Dr', 'Cr' @@ -2230,12 +2278,13 @@ export class TransactionEntryComponent implements OnInit, OnDestroy { // panel never appears and the row would post as an ordinary entry with the charges silently // dropped. // - // A clerk funds a draft through an account or an instrument instead, and their TXN TP. list no - // longer offers cash, so defaulting to it would open the field on a value it cannot show. - // isDdCashGlRow() still holds for them through AC.TP = GL. + // A clerk funds a draft by debiting a member's own account, so their row opens on savings and + // is debited, and GL is not on their AC.TP list at all (see acTypOptionsFor). Opening them on + // GL put the row on the cash path, which asks for a walk-in purchaser and a note-and-coin + // count that a clerk debiting an account does not have. case 'DD': - row.dbCr = 'Cr'; - row.acTyp = 'GL'; + row.dbCr = this.isClerk ? 'Dr' : 'Cr'; + row.acTyp = this.isClerk ? 'SD' : 'GL'; row.paymentTypeId = this.isClerk ? this.findTransferPaymentTypeId() : this.findCashPaymentTypeId(); row.chequeNo = ''; row.cheqDate = ''; @@ -2416,13 +2465,34 @@ export class TransactionEntryComponent implements OnInit, OnDestroy { } }); + // Recorded on the row, not sent. Save is what commits the worksheet, and a DD row that + // submitted itself here left that button with nothing to do — so the clerk had no way to + // review the entered details, and no way to abandon them either. dialogRef.afterClosed().subscribe((result) => { if (!result) return; - if (accountDebit) { - this.submitAccountDebitDd(row, leaflet, result, fundingAccountId!, fundingClientId!); - } else { - this.submitWalkinCashDd(row, leaflet, result); - } + row.ddDetails = { + leafletId: leaflet.id, + ddNumber: leaflet.ddNumber, + beneficiaryName: result.beneficiaryName, + payableAtBranch: result.payableAtBranch, + payableAtCity: result.payableAtCity, + purchaser: accountDebit + ? undefined + : { + name: result.walkinPurchaserName, + address: result.walkinPurchaserAddress, + aadhaar: result.walkinPurchaserAadhaar + }, + fundingAccountId: accountDebit ? fundingAccountId! : undefined, + fundingClientId: accountDebit ? fundingClientId! : undefined + }; + this.showAlert('success', 'DD Details Captured', [ + `DD No. ${leaflet.ddNumber} · in favour of ${result.beneficiaryName}.`, + accountDebit + ? `₹${this.ddTotalPayableFor(row)} will be debited from ${row.acNo} once a checker approves.` + : `₹${this.ddTotalPayableFor(row)} to be collected at the counter.`, + 'Press Save to send this draft for approval.' + ]); }); }, error: (err) => { @@ -2480,6 +2550,9 @@ export class TransactionEntryComponent implements OnInit, OnDestroy { row.amount = null; row.ddCharges = null; row.description = ''; + // Cleared with the row: leaving the captured details behind would let a second Save raise + // the same draft again against a leaflet that has already been used. + row.ddDetails = undefined; }, error: (err) => { this.saving = false; @@ -2488,6 +2561,53 @@ export class TransactionEntryComponent implements OnInit, OnDestroy { }); } + /** + * Sends the drafts on the worksheet for approval. + * + * Each row carries the beneficiary details the dialog captured; a row that was never filled in is + * refused by name rather than skipped, because a clerk who pressed Save expecting one draft and + * got none has no way of telling which row was missing. + */ + private saveDdRows(ddRows: TxnRow[]): void { + const unfilled = ddRows.filter((r) => !r.ddDetails); + if (unfilled.length > 0) { + this.showAlert('error', 'DD Details Not Entered', [ + unfilled.length === 1 + ? 'One DD row has no beneficiary details yet.' + : `${unfilled.length} DD rows have no beneficiary details yet.`, + 'Use "Fill DD Details & Send for Approval" on each DD row first, then press Save.' + ]); + return; + } + + for (const row of ddRows) { + const details = row.ddDetails!; + const leaflet = { id: details.leafletId, ddNumber: details.ddNumber }; + if (this.isDdAccountDebitRow(row)) { + this.submitAccountDebitDd( + row, + leaflet, + { + beneficiaryName: details.beneficiaryName, + payableAtBranch: details.payableAtBranch, + payableAtCity: details.payableAtCity + }, + details.fundingAccountId!, + details.fundingClientId! + ); + } else { + this.submitWalkinCashDd(row, leaflet, { + beneficiaryName: details.beneficiaryName, + payableAtBranch: details.payableAtBranch, + payableAtCity: details.payableAtCity, + walkinPurchaserName: details.purchaser?.name, + walkinPurchaserAddress: details.purchaser?.address, + walkinPurchaserAadhaar: details.purchaser?.aadhaar + }); + } + } + } + private submitWalkinCashDd(row: TxnRow, leaflet: any, purchaserData: any): void { // Read through countFor, which knows the map is keyed by kind:value. Indexing by d.id looked // right but matched nothing, so every cash DD went in with no breakdown at all and its money @@ -2557,6 +2677,8 @@ export class TransactionEntryComponent implements OnInit, OnDestroy { row.amount = null; row.ddCharges = null; row.description = ''; + // See the account-debit path: a second Save must not re-raise a draft already issued. + row.ddDetails = undefined; this.clearDenominations(); }, error: (err) => { @@ -4361,6 +4483,15 @@ export class TransactionEntryComponent implements OnInit, OnDestroy { } onSave(): void { + // Demand drafts commit here like everything else on the worksheet. Checked first because a DD + // row shares its shape with the ordinary entries below — without this it would fall through and + // post as a plain transaction, with the draft never raised and the charges silently dropped. + const ddRows = this.rows.filter((r) => this.isDdRow(r)); + if (ddRows.length > 0) { + this.saveDdRows(ddRows); + return; + } + // Clerk bank transfer — N DR savings/CA + N CR savings/CA, immediate execution. 'Fund Transfer' // is the same flow under a second TYPE label — onTypChange() already defaults it identically to // 'Enter Bank Transfer', so submission has to recognise both or a share redemption or bank diff --git a/src/app/clients/clients-view/kyc-tab/kyc-tab.component.html b/src/app/clients/clients-view/kyc-tab/kyc-tab.component.html index 57311257a..dd1e065c3 100644 --- a/src/app/clients/clients-view/kyc-tab/kyc-tab.component.html +++ b/src/app/clients/clients-view/kyc-tab/kyc-tab.component.html @@ -82,12 +82,13 @@ {{ kycDocumentAlertTitle }}

{{ kycDocumentAlertMessage }}

+ @@ -470,7 +471,11 @@ } - + + } @@ -1188,15 +1193,83 @@ - - - photo_library - Replace the scanned front / back of this document - chevron_right - + +
+
+ photo_library + Scanned document + @if (docScansLoading) { + + } +
+ + @if (!docScansLoading && !docScans.front && !docScans.back) { +
+ No scan is on file for this document. Upload one below. +
+ } + +
+ @for (slot of docScanSlots; track slot) { +
+
{{ slot === 'front' ? 'Front' : 'Back' }}
+ +
+ @if (docScanOf(slot)?.previewUrl) { + + } @else if (docScanOf(slot)) { + +
+ description + {{ docScanOf(slot)!.fileName }} +
+ } @else { +
Not on file
+ } +
+ + + +
+ } +
+ + @if (docScanError) { +
+ error_outline{{ docScanError }} +
+ } + @if (docScanSuccess) { +
+ check_circle{{ docScanSuccess }} +
+ } +
} diff --git a/src/app/clients/clients-view/kyc-tab/kyc-tab.component.scss b/src/app/clients/clients-view/kyc-tab/kyc-tab.component.scss index 101a3e073..0f4520920 100644 --- a/src/app/clients/clients-view/kyc-tab/kyc-tab.component.scss +++ b/src/app/clients/clients-view/kyc-tab/kyc-tab.component.scss @@ -1681,3 +1681,129 @@ .kyc-doc-scans-link__go { margin-left: auto; } + +/* The scan itself, beside the number and dates it belongs to. */ +.kyc-doc-scans { + margin-top: 10px; + padding: 12px; + border: 1px solid #e2e8f0; + border-radius: 8px; + background: #fff; +} + +.kyc-doc-scans__head { + display: flex; + align-items: center; + gap: 8px; + margin-bottom: 10px; + font-size: 12px; + font-weight: 700; + letter-spacing: 0.03em; + text-transform: uppercase; + color: #475569; + + mat-icon { + font-size: 18px; + width: 18px; + height: 18px; + color: #64748b; + } +} + +.kyc-doc-scans__empty { + margin-bottom: 10px; + font-size: 12.5px; + color: #64748b; +} + +.kyc-doc-scans__sides { + display: flex; + flex-wrap: wrap; + gap: 14px; +} + +.kyc-doc-side { + display: flex; + flex-direction: column; + gap: 6px; + width: 190px; +} + +.kyc-doc-side__label { + font-size: 11.5px; + font-weight: 700; + color: #334155; +} + +/* Fixed height so front and back line up whatever the scan's aspect ratio is. */ +.kyc-doc-side__frame { + display: flex; + align-items: center; + justify-content: center; + height: 118px; + padding: 6px; + border: 1px solid #e2e8f0; + border-radius: 6px; + background: #f8fafc; + overflow: hidden; + + img { + max-width: 100%; + max-height: 100%; + object-fit: contain; + border-radius: 3px; + } +} + +.kyc-doc-side__none { + font-size: 12px; + color: #94a3b8; +} + +.kyc-doc-side__file { + display: flex; + flex-direction: column; + align-items: center; + gap: 4px; + padding: 0 6px; + font-size: 11px; + color: #475569; + text-align: center; + word-break: break-word; + + mat-icon { + color: #64748b; + } +} + +.kyc-doc-side__btn { + font-size: 12px; + + mat-progress-spinner { + display: inline-block; + margin-right: 6px; + vertical-align: middle; + } +} + +.kyc-doc-scans__msg { + display: flex; + align-items: center; + gap: 6px; + margin-top: 10px; + font-size: 12.5px; + + mat-icon { + font-size: 17px; + width: 17px; + height: 17px; + } + + &--err { + color: #b91c1c; + } + + &--ok { + color: #15803d; + } +} diff --git a/src/app/clients/clients-view/kyc-tab/kyc-tab.component.ts b/src/app/clients/clients-view/kyc-tab/kyc-tab.component.ts index 365e9c0a7..ecfdd0210 100644 --- a/src/app/clients/clients-view/kyc-tab/kyc-tab.component.ts +++ b/src/app/clients/clients-view/kyc-tab/kyc-tab.component.ts @@ -11,7 +11,7 @@ import { Component, OnInit, OnDestroy, inject } from '@angular/core'; import { ActivatedRoute } from '@angular/router'; import { UntypedFormBuilder, UntypedFormGroup, Validators, FormsModule } from '@angular/forms'; import { MatDialog } from '@angular/material/dialog'; -import { catchError, of, Subject, takeUntil } from 'rxjs'; +import { catchError, forkJoin, map, of, Subject, takeUntil } from 'rxjs'; /** Custom Services */ import { ClientsService } from '../../clients.service'; @@ -30,6 +30,11 @@ import { AadhaarReceiptDialogComponent, AadhaarReceiptResult } from '../../shared/aadhaar-receipt-dialog/aadhaar-receipt-dialog.component'; +import { + KycDocumentUpdateDialogComponent, + KycDocumentUpdateData, + KycDocumentUpdateResult +} from '../../shared/kyc-document-update-dialog/kyc-document-update-dialog.component'; import { Dates } from 'app/core/utils/dates'; import { jsPDF } from 'jspdf'; import { MatIcon } from '@angular/material/icon'; @@ -38,6 +43,15 @@ import { MatProgressSpinner } from '@angular/material/progress-spinner'; import { FaIconComponent } from '@fortawesome/angular-fontawesome'; import { STANDALONE_SHARED_IMPORTS } from 'app/standalone-shared.module'; +/** One side of the scanned KYC document, with the preview once it has been fetched. */ +interface DocScanSide { + documentId: string; + /** The identifier row this scan hangs off; the two sides can sit on different rows. */ + identifierId: string; + fileName: string; + previewUrl: string | null; +} + /** * KYC Tab Component. * Displays and manages KYC (Know Your Customer) details for an existing client. @@ -149,9 +163,39 @@ export class KycTabComponent implements OnInit, OnDestroy { exceptionReason: [[]] }); + // ── Officially valid document scans ──────────────────────────────────────── + + /** + * The scans of the document named above, by side. + * + * The screen used to show the document's number and dates but not the image behind them, and + * sent the officer to the client edit screen to change it. Re-collecting an expiring document is + * the one thing this panel exists for, so the scan is shown and replaced here. + */ + docScans: { front: DocScanSide | null; back: DocScanSide | null } = { front: null, back: null }; + + /** The two sides, in display order, so the template loops rather than repeating itself. */ + readonly docScanSlots: ('front' | 'back')[] = ['front', 'back']; + + docScanOf(slot: 'front' | 'back') { + return this.docScans[slot]; + } + + /** Identifier row replacements are filed against. Null when the document has no identifier. */ + private docIdentifierId: string | null = null; + /** Every identifier row carrying this document, since the sides can be split across them. */ + private docIdentifierIds: string[] = []; + docScansLoading = false; + /** Which side is mid-upload, so only that button shows a spinner. */ + docScanUploading: 'front' | 'back' | null = null; + docScanError: string | null = null; + docScanSuccess: string | null = null; + ngOnInit(): void { this.route.parent.params.subscribe((params: any) => { this.clientId = params['clientId']; + // Scans are matched against the document number on the KYC record, so they are loaded once + // that record has arrived rather than alongside it. this.loadKycData(); this.loadClientDateOfBirth(); }); @@ -190,6 +234,8 @@ export class KycTabComponent implements OnInit, OnDestroy { isException: data.isException || false, exceptionReason: data.exceptionReason ? data.exceptionReason.split(', ') : [] }); + // Now that the document number is known, the scans filed under it can be found. + this.loadDocumentScans(); } }); } @@ -1307,6 +1353,297 @@ I further confirm that: doc.save(`Self_Declaration_${cifNumber}_${clientName.replace(/\s/g, '_')}.pdf`); } + // ── Document scans ───────────────────────────────────────────────────────── + + /** + * Finds the identifier the KYC document was filed under and loads its scans. + * + * The KYC record keeps the document's name and number but not the identifier id, so the two are + * matched on the number — that is what makes them the same document, and it survives the client + * holding several identifiers of the same type. + */ + private loadDocumentScans(): void { + if (!this.clientId) { + return; + } + this.docScansLoading = true; + this.clientsService + .getClientIdentifiers(this.clientId) + .pipe(catchError(() => of([] as any[]))) + .subscribe((identifiers: any[]) => { + const wantedKey = (this.kycData?.kycDocumentNumber || '').toString().trim(); + const wantedName = (this.kycData?.kycDocumentName || '').toString().trim().toLowerCase(); + const norm = (s: any) => (s || '').toString().trim().toLowerCase(); + + // Every identifier carrying this document number, not just the first. The onboarding step + // files each side against its own identifier row, so this client's Aadhaar front sits on + // one row and the back on another under the same number - taking only the first match + // showed the front and reported the back as missing. + let matches = (identifiers || []).filter((i) => wantedKey && norm(i.documentKey) === norm(wantedKey)); + if (!matches.length) { + matches = (identifiers || []).filter((i) => wantedName && norm(i.documentType?.name) === wantedName); + } + + if (!matches.length) { + this.docScansLoading = false; + this.docIdentifierId = null; + this.docIdentifierIds = []; + this.docScans = { front: null, back: null }; + return; + } + // Replacements are filed against the first row, so the sides stay together from here on. + this.docIdentifierId = String(matches[0].id); + this.docIdentifierIds = matches.map((m: any) => String(m.id)); + this.fetchScansFor(this.docIdentifierIds); + }); + } + + /** + * Sides are read off the document name, which is how they were written at upload + * (" — Front" / "— Back"). Where the name says nothing, upload order stands in, front + * first, rather than leaving a scan the officer cannot see. + */ + private fetchScansFor(identifierIds: string[]): void { + forkJoin( + identifierIds.map((id) => + this.clientsService.getClientIdentificationDocuments(id).pipe( + // Tagged with the row it came from, because the download call needs the parent id and + // the sides can be spread across more than one. + map((docs: any) => ((docs as any[]) || []).map((d) => ({ ...d, identifierId: id }))), + catchError(() => of([] as any[])) + ) + ) + ).subscribe((perIdentifier: any[][]) => { + this.docScansLoading = false; + // This runs again after every replace, so the previews being thrown away are revoked + // rather than left holding their blobs. + for (const old of [this.docScans.front, this.docScans.back]) { + if (old?.previewUrl) { + URL.revokeObjectURL(old.previewUrl); + } + } + + // Oldest first by document id, so the newest upload for a side is the last one written and + // therefore the one that stands. Identifier order cannot do this job: a replacement is filed + // against the first row while the original sits on the second, so reading the rows in order + // let a document uploaded months ago overwrite the scan collected a moment ago - the officer + // pressed Replace, saw "replaced", and the old image came straight back. + const docs = perIdentifier.flat().sort((a, b) => Number(a.id) - Number(b.id)); + const sides: typeof this.docScans = { front: null, back: null }; + const unlabelled: any[] = []; + + const entryOf = (d: any): DocScanSide => ({ + documentId: String(d.id), + identifierId: String(d.identifierId), + fileName: d.fileName || d.name, + previewUrl: null + }); + + for (const d of docs) { + const text = `${d.name || ''} ${d.fileName || ''}`.toLowerCase(); + // Later uploads win, so a replaced side shows the new scan rather than the original. + if (text.includes('back')) { + sides.back = entryOf(d); + } else if (text.includes('front')) { + sides.front = entryOf(d); + } else { + unlabelled.push(d); + } + } + for (const d of unlabelled) { + if (!sides.front) { + sides.front = entryOf(d); + } else if (!sides.back) { + sides.back = entryOf(d); + } + } + + this.docScans = sides; + for (const key of ['front', 'back'] as const) { + if (sides[key]) { + this.loadScanPreview(sides[key]!); + } + } + }); + } + + private loadScanPreview(side: DocScanSide): void { + this.clientsService + .downloadClientIdentificationDocument(side.identifierId, side.documentId) + .pipe(catchError(() => of(null))) + .subscribe((blob: Blob | null) => { + if (!blob || !blob.type?.startsWith('image/')) { + return; + } + // Revoked in ngOnDestroy; a preview left behind holds the blob in memory for the life of + // the tab. + side.previewUrl = URL.createObjectURL(blob); + }); + } + + /** + * Replaces one side with a freshly collected scan. + * + * The new file is uploaded under the same side name, which is what the readers here and on the + * client screen key on, so the latest upload becomes the scan on show. The old file is left in + * place deliberately: a KYC document that has been re-collected is a record of what was held + * before and when it changed, and destroying it would leave the audit trail with a gap. + */ + onDocScanSelected(event: Event, slot: 'front' | 'back'): void { + const input = event.target as HTMLInputElement; + const file = input.files?.[0]; + // Cleared so choosing the same filename twice still fires a change event. + input.value = ''; + if (!file || !this.docIdentifierId) { + return; + } + + this.docScanError = null; + this.docScanSuccess = null; + this.docScanUploading = slot; + + const label = slot === 'front' ? 'Front' : 'Back'; + const docName = this.kycForm.get('kycDocumentName')?.value || this.kycData?.kycDocumentName || 'KYC document'; + + const formData = new FormData(); + formData.append('file', file); + formData.append('name', `${docName} — ${label}`); + formData.append('description', `Re-collected on ${new Date().toLocaleDateString('en-IN')}`); + + this.clientsService.uploadClientIdentifierDocument(this.docIdentifierId, formData).subscribe({ + next: () => { + this.docScanUploading = null; + this.docScanSuccess = `${label} side replaced.`; + // Re-read every row, not just the one written to: the other side still lives on its own. + this.fetchScansFor(this.docIdentifierIds); + }, + error: (err: any) => { + this.docScanUploading = null; + this.docScanError = + err?.error?.defaultUserMessage || err?.error?.developerMessage || `Could not replace the ${label.toLowerCase()} side.`; + } + }); + } + + /** + * A verified record only accepts an amendment with a reason on it, so the dialog has to ask for + * one. 300 is VERIFIED; the code form is checked too, since either can arrive on the payload. + */ + private get isKycVerified(): boolean { + return this.kycData?.kycStatus === 300 || this.kycData?.kycStatusCode === 'clientKycStatus.verified'; + } + + /** + * Re-collects the officially valid document from its own row. + * + * PAN and Aadhaar are each handled on their row; this one sent the officer into edit mode on the + * whole KYC form to do the same job. The dialog asks for the number, the dates and each side of + * the scan together, which is what re-collecting a document actually is. + */ + updateKycDocument(): void { + if (!this.kycId) { + this.docScanError = 'KYC record not found. Save KYC details first.'; + return; + } + + this.dialog + .open(KycDocumentUpdateDialogComponent, { + width: '640px', + maxWidth: '95vw', + disableClose: true, + data: { + documentName: this.kycData?.kycDocumentName || 'KYC document', + documentNumber: this.kycData?.kycDocumentNumber || '', + issueDate: this.parseApiDate(this.kycData?.kycDocIssueDate), + expiryDate: this.parseApiDate(this.kycData?.kycDocExpiryDate), + reason: this.showKycDocumentAlert ? this.kycDocumentAlertMessage : '', + currentFront: this.docScans.front?.previewUrl || null, + currentBack: this.docScans.back?.previewUrl || null, + requiresRemarks: this.isKycVerified + } as KycDocumentUpdateData + }) + .afterClosed() + .subscribe((result: KycDocumentUpdateResult | undefined) => { + if (result) { + this.persistKycDocument(result); + } + }); + } + + /** + * Saves the corrected details, then uploads whichever sides were re-scanned. + * + * The details go first: they are the record, and a scan attached to a document whose number was + * never corrected would file the new image under the old identity. + */ + private persistKycDocument(result: KycDocumentUpdateResult): void { + this.saving = true; + this.docScanError = null; + this.docScanSuccess = null; + + const dateFormat = 'dd MMMM yyyy'; + const payload: any = { + locale: 'en', + dateFormat, + kycDocumentName: this.kycData?.kycDocumentName || null, + kycDocumentNumber: result.documentNumber || null, + kycDocIssueDate: result.issueDate ? this.dateUtils.formatDate(result.issueDate, dateFormat) : null, + kycDocExpiryDate: result.expiryDate ? this.dateUtils.formatDate(result.expiryDate, dateFormat) : null + }; + // The server refuses an amendment to a verified record without one, and records it as the + // reason the document changed. + if (result.remarks) { + payload.remarks = result.remarks; + } + + this.clientsService.updateClientKyc(this.clientId, this.kycId!, payload).subscribe({ + next: () => { + this.saving = false; + this.docScanSuccess = 'Document updated.'; + this.loadKycData(); + this.uploadReplacedSides(result); + }, + error: (err: any) => { + this.saving = false; + this.docScanError = + err?.error?.defaultUserMessage || err?.error?.developerMessage || 'Could not update the document.'; + } + }); + } + + /** Sends each re-scanned side through the same upload path the panel below uses. */ + private uploadReplacedSides(result: KycDocumentUpdateResult): void { + if (!this.docIdentifierId) { + return; + } + const sides: { slot: 'front' | 'back'; file: File }[] = []; + if (result.front) { + sides.push({ slot: 'front', file: result.front }); + } + if (result.back) { + sides.push({ slot: 'back', file: result.back }); + } + if (!sides.length) { + return; + } + + const docName = this.kycData?.kycDocumentName || 'KYC document'; + forkJoin( + sides.map(({ slot, file }) => { + const formData = new FormData(); + formData.append('file', file); + formData.append('name', `${docName} — ${slot === 'front' ? 'Front' : 'Back'}`); + formData.append('description', `Re-collected on ${new Date().toLocaleDateString('en-IN')}`); + return this.clientsService + .uploadClientIdentifierDocument(this.docIdentifierId!, formData) + .pipe(catchError(() => of(null))); + }) + ).subscribe(() => { + this.docScanSuccess = 'Document and scan updated.'; + this.fetchScansFor(this.docIdentifierIds); + }); + } + /** Handle file selection for signed declaration upload */ onDeclarationFileSelected(event: Event): void { const input = event.target as HTMLInputElement; @@ -1365,5 +1702,11 @@ I further confirm that: ngOnDestroy(): void { this.destroy$.next(); this.destroy$.complete(); + // Object URLs are held by the browser until revoked, so the scan blobs would outlive the tab. + for (const side of [this.docScans.front, this.docScans.back]) { + if (side?.previewUrl) { + URL.revokeObjectURL(side.previewUrl); + } + } } } diff --git a/src/app/clients/shared/kyc-document-update-dialog/kyc-document-update-dialog.component.html b/src/app/clients/shared/kyc-document-update-dialog/kyc-document-update-dialog.component.html new file mode 100644 index 000000000..6e646f72a --- /dev/null +++ b/src/app/clients/shared/kyc-document-update-dialog/kyc-document-update-dialog.component.html @@ -0,0 +1,137 @@ + + +

+ description + Update {{ data.documentName }} +

+ + + + @if (data.reason) { +
+ schedule + {{ data.reason }} +
+ } + +
+ + Document Number + + @if (form.get('documentNumber')?.hasError('required') && form.get('documentNumber')?.touched) { + Document number is required + } + + + + Issued On + + + + + + + Valid Till + + + + Blank if it does not expire + + + + @if (data.requiresRemarks) { + + Reason for update + + Recorded against the verified KYC record + @if (form.get('remarks')?.hasError('required') && form.get('remarks')?.touched) { + A reason is required to change a verified KYC record + } + + } +
+ +
+
Scanned copy
+

+ Leave a side untouched to keep the scan already on file. The previous copy is kept either way. +

+ +
+ @for (slot of slots; track slot) { +
+
+ {{ slot === 'front' ? 'Front' : 'Back' }} + @if (isReplaced(slot)) { + New + } +
+ +
+ @if (previewOf(slot)) { + + } @else if (isReplaced(slot)) { + +
+ insert_drive_file + {{ pickedName(slot) }} +
+ } @else { +
Not on file
+ } +
+ +
+ + @if (isReplaced(slot)) { + + } +
+ +
+ } +
+
+
+ + + + + diff --git a/src/app/clients/shared/kyc-document-update-dialog/kyc-document-update-dialog.component.scss b/src/app/clients/shared/kyc-document-update-dialog/kyc-document-update-dialog.component.scss new file mode 100644 index 000000000..1837b342c --- /dev/null +++ b/src/app/clients/shared/kyc-document-update-dialog/kyc-document-update-dialog.component.scss @@ -0,0 +1,172 @@ +/** + * Copyright since 2025 Mifos Initiative + * + * This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. + */ + +.kdu-title { + display: flex; + align-items: center; + gap: 8px; + + mat-icon { + color: #64748b; + } +} + +.kdu-body { + padding-top: 4px; +} + +.kdu-reason { + display: flex; + align-items: flex-start; + gap: 8px; + margin-bottom: 16px; + padding: 10px 12px; + border: 1px solid #fde68a; + border-radius: 6px; + background: #fffbeb; + color: #92400e; + font-size: 13px; + line-height: 1.5; + + mat-icon { + flex: 0 0 auto; + font-size: 18px; + width: 18px; + height: 18px; + color: #d97706; + } +} + +.kdu-form { + display: flex; + flex-wrap: wrap; + gap: 12px; +} + +.kdu-field { + flex: 1 1 180px; +} + +.kdu-field--wide { + flex: 1 1 100%; +} + +.kdu-scans { + margin-top: 8px; + padding: 12px; + border: 1px solid #e2e8f0; + border-radius: 8px; + background: #f8fafc; +} + +.kdu-scans__head { + font-size: 12px; + font-weight: 700; + letter-spacing: 0.03em; + text-transform: uppercase; + color: #475569; +} + +.kdu-scans__hint { + margin: 4px 0 12px; + font-size: 12px; + color: #64748b; +} + +.kdu-sides { + display: flex; + flex-wrap: wrap; + gap: 16px; +} + +.kdu-side { + display: flex; + flex-direction: column; + gap: 6px; + width: 210px; +} + +.kdu-side__label { + display: flex; + align-items: center; + gap: 6px; + font-size: 12px; + font-weight: 700; + color: #334155; +} + +/* Marks the side the officer has just re-scanned, so it is obvious what will change on save. */ +.kdu-side__new { + padding: 1px 7px; + border-radius: 999px; + background: #dcfce7; + color: #15803d; + font-size: 10.5px; + font-weight: 700; +} + +.kdu-side__frame { + display: flex; + align-items: center; + justify-content: center; + height: 132px; + padding: 6px; + border: 1px solid #e2e8f0; + border-radius: 6px; + background: #fff; + overflow: hidden; + + img { + max-width: 100%; + max-height: 100%; + object-fit: contain; + border-radius: 3px; + } + + &--new { + border-color: #86efac; + box-shadow: 0 0 0 1px #86efac; + } +} + +.kdu-side__none { + font-size: 12px; + color: #94a3b8; +} + +.kdu-side__file { + display: flex; + flex-direction: column; + align-items: center; + gap: 4px; + padding: 0 6px; + font-size: 11px; + color: #475569; + text-align: center; + word-break: break-word; + + mat-icon { + color: #64748b; + } +} + +.kdu-side__actions { + display: flex; + align-items: center; + gap: 4px; +} + +.kdu-side__btn { + flex: 1; + font-size: 12px; +} + +.kdu-actions { + padding: 8px 24px 20px; + gap: 8px; +} diff --git a/src/app/clients/shared/kyc-document-update-dialog/kyc-document-update-dialog.component.ts b/src/app/clients/shared/kyc-document-update-dialog/kyc-document-update-dialog.component.ts new file mode 100644 index 000000000..870bb38e1 --- /dev/null +++ b/src/app/clients/shared/kyc-document-update-dialog/kyc-document-update-dialog.component.ts @@ -0,0 +1,211 @@ +/** + * 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, OnDestroy, inject } from '@angular/core'; +import { CommonModule } from '@angular/common'; +import { UntypedFormBuilder, UntypedFormGroup, Validators, ReactiveFormsModule } from '@angular/forms'; +import { MAT_DIALOG_DATA, MatDialogModule, MatDialogRef } from '@angular/material/dialog'; +import { MatButtonModule } from '@angular/material/button'; +import { MatDatepickerModule } from '@angular/material/datepicker'; +import { MatFormFieldModule } from '@angular/material/form-field'; +import { MatIconModule } from '@angular/material/icon'; +import { MatInputModule } from '@angular/material/input'; +import { MatProgressSpinnerModule } from '@angular/material/progress-spinner'; +import { MatTooltipModule } from '@angular/material/tooltip'; +import { STANDALONE_SHARED_IMPORTS } from 'app/standalone-shared.module'; + +/** What the caller hands in: the document as it stands, and the scans already on file. */ +export interface KycDocumentUpdateData { + documentName: string; + documentNumber: string; + issueDate: Date | string | null; + expiryDate: Date | string | null; + /** Why the document is being asked for, shown at the top so the officer sees the reason. */ + reason: string; + /** Previews of what is currently held, so the officer can see what they are replacing. */ + currentFront: string | null; + currentBack: string | null; + /** + * True when the KYC record is already verified. Changing data on one of those needs a reason on + * the record, which the server enforces - so the dialog asks for it rather than letting the + * officer fill the whole form in and be refused on submit. + */ + requiresRemarks: boolean; +} + +/** What the caller gets back: the corrected details, plus whichever sides were re-scanned. */ +export interface KycDocumentUpdateResult { + documentNumber: string; + issueDate: Date | null; + expiryDate: Date | null; + front: File | null; + back: File | null; + /** Why the document was re-collected. Blank unless the record was already verified. */ + remarks: string; +} + +/** + * Re-collects an officially valid document without leaving the KYC record. + * + * PAN and Aadhaar are each dealt with on their own row of the verification table. The OVD was the + * exception: its Update button put the whole KYC form into edit mode, so re-collecting one expiring + * document meant scrolling a form of unrelated fields and finding the same document again. The work + * is the same either way - a new number, new dates, a fresh scan of each side - so it is asked for + * in one place. + */ +@Component({ + selector: 'mifosx-kyc-document-update-dialog', + templateUrl: './kyc-document-update-dialog.component.html', + styleUrls: ['./kyc-document-update-dialog.component.scss'], + standalone: true, + // The shared set carries the date adapter the two datepickers below need; the sibling dialogs + // pull it in the same way. + imports: [ + ...STANDALONE_SHARED_IMPORTS, + CommonModule, + ReactiveFormsModule, + MatDialogModule, + MatButtonModule, + MatDatepickerModule, + MatFormFieldModule, + MatIconModule, + MatInputModule, + MatProgressSpinnerModule, + MatTooltipModule + ] +}) +export class KycDocumentUpdateDialogComponent implements OnDestroy { + private formBuilder = inject(UntypedFormBuilder); + private dialogRef = inject>(MatDialogRef); + + form: UntypedFormGroup; + + /** Newly chosen files, with a local preview so the officer sees what they picked. */ + picked: { front: File | null; back: File | null } = { front: null, back: null }; + pickedPreview: { front: string | null; back: string | null } = { front: null, back: null }; + + readonly slots: ('front' | 'back')[] = ['front', 'back']; + + constructor(@Inject(MAT_DIALOG_DATA) public data: KycDocumentUpdateData) { + this.form = this.formBuilder.group({ + documentNumber: [ + data.documentNumber || '', + [Validators.required] + ], + issueDate: [this.toDate(data.issueDate)], + // Left free rather than required: a passport expires, a PAN card does not. + expiryDate: [this.toDate(data.expiryDate)], + // Required only where the server requires it, so completing a record that was never verified + // does not demand a justification for filling in blanks. + remarks: [ + '', + data.requiresRemarks ? [Validators.required] : [] + ] + }); + } + + private toDate(value: Date | string | null): Date | null { + if (!value) { + return null; + } + const d = value instanceof Date ? value : new Date(value); + return isNaN(d.getTime()) ? null : d; + } + + previewOf(slot: 'front' | 'back'): string | null { + return this.pickedPreview[slot] || (slot === 'front' ? this.data.currentFront : this.data.currentBack); + } + + /** True once this side has been re-scanned in this dialog, so the label can say so. */ + isReplaced(slot: 'front' | 'back'): boolean { + return !!this.picked[slot]; + } + + pickedName(slot: 'front' | 'back'): string { + return this.picked[slot]?.name || ''; + } + + onFilePicked(event: Event, slot: 'front' | 'back'): void { + const input = event.target as HTMLInputElement; + const file = input.files?.[0] || null; + // Cleared so re-picking the same filename still fires a change event. + input.value = ''; + if (!file) { + return; + } + if (this.pickedPreview[slot]) { + URL.revokeObjectURL(this.pickedPreview[slot]!); + } + this.picked[slot] = file; + this.pickedPreview[slot] = file.type.startsWith('image/') ? URL.createObjectURL(file) : null; + } + + clearPick(slot: 'front' | 'back'): void { + if (this.pickedPreview[slot]) { + URL.revokeObjectURL(this.pickedPreview[slot]!); + } + this.picked[slot] = null; + this.pickedPreview[slot] = null; + } + + /** + * Something has to change for the update to mean anything - either a corrected detail or a fresh + * scan. Submitting an untouched form would reopen a verified KYC record for no reason. + */ + get hasChanges(): boolean { + if (this.picked.front || this.picked.back) { + return true; + } + const v = this.form.value; + const sameNumber = (v.documentNumber || '').trim() === (this.data.documentNumber || '').trim(); + const sameIssue = this.sameDay(v.issueDate, this.toDate(this.data.issueDate)); + const sameExpiry = this.sameDay(v.expiryDate, this.toDate(this.data.expiryDate)); + return !(sameNumber && sameIssue && sameExpiry); + } + + private sameDay(a: Date | null, b: Date | null): boolean { + if (!a && !b) { + return true; + } + if (!a || !b) { + return false; + } + return a.getFullYear() === b.getFullYear() && a.getMonth() === b.getMonth() && a.getDate() === b.getDate(); + } + + submit(): void { + if (this.form.invalid || !this.hasChanges) { + this.form.markAllAsTouched(); + return; + } + const v = this.form.value; + const result: KycDocumentUpdateResult = { + documentNumber: (v.documentNumber || '').trim(), + issueDate: v.issueDate || null, + expiryDate: v.expiryDate || null, + front: this.picked.front, + back: this.picked.back, + remarks: (v.remarks || '').trim() + }; + this.dialogRef.close(result); + } + + cancel(): void { + this.dialogRef.close(); + } + + ngOnDestroy(): void { + // Only the previews this dialog created; the caller owns the ones it passed in. + for (const slot of this.slots) { + if (this.pickedPreview[slot]) { + URL.revokeObjectURL(this.pickedPreview[slot]!); + } + } + } +} diff --git a/src/app/core/utils/api-date.ts b/src/app/core/utils/api-date.ts index 25c64dc54..77a34947c 100644 --- a/src/app/core/utils/api-date.ts +++ b/src/app/core/utils/api-date.ts @@ -35,3 +35,32 @@ export function toIsoDate(value: any): string { } return String(value).slice(0, 10); } + +/** + * A Fineract timestamp as a real Date, so Angular's `date` pipe can format it. + * + * LocalDateTime arrives as [y, M, d, H, m, s] — an array the pipe renders as nothing at all, which + * is how a "Raised On" column came to sit empty next to an alert that plainly had been raised. + * Month is 1-based on the wire and 0-based in Date, hence the shift. + */ +export function toApiDate(value: any): Date | null { + if (!value) { + return null; + } + if (Array.isArray(value)) { + const [ + year, + month, + day, + hour = 0, + minute = 0, + second = 0 + ] = value; + if (!year || !month || !day) { + return null; + } + return new Date(year, month - 1, day, hour, minute, second); + } + const parsed = new Date(value); + return isNaN(parsed.getTime()) ? null : parsed; +} diff --git a/src/app/fiu/fiu-data.ts b/src/app/fiu/fiu-data.ts index b423d948e..f87077470 100644 --- a/src/app/fiu/fiu-data.ts +++ b/src/app/fiu/fiu-data.ts @@ -65,6 +65,22 @@ export interface CtrTransaction { evidenceDocType?: string; evidenceReference?: string; evidencedOn?: string; + + /** + * Account and customer facts carried alongside the transaction, read off the books when the CTR + * line is loaded. + * + * Account_Detail and Account_Person_Relation are derived from these rows, and they used to fill + * these columns with constants — every account opened on 20/05/2018, every account ACTIVE, every + * customer reference '0'. Those are statements about a customer's account in a filing, so they + * come from the record rather than from a placeholder. + */ + accountOpenedOn?: string; + accountClosedOn?: string; + accountStatus?: string; + ckycNumber?: string; + clientExternalId?: string; + individual?: boolean; } /** Outcome of the Principal Officer's investigation into an STR case */ diff --git a/src/app/fiu/fiu.component.html b/src/app/fiu/fiu.component.html index 499752e34..d2f31d2ec 100644 --- a/src/app/fiu/fiu.component.html +++ b/src/app/fiu/fiu.component.html @@ -14,8 +14,8 @@

Financial Intelligence Unit – India (FIU-IND)

- {{ bank.name }} · {{ scopeName }} · Reporting Month {{ bank.reportingMonth }} - · PMLA 2002 / FINnet 2.0 + {{ bank.name }} · {{ scopeName }} · Reporting Month + {{ reportingMonthLabel }} · PMLA 2002 / FINnet 2.0
@@ -33,12 +33,13 @@ {{ scopeName }} } -
- Principal Officer{{ bank.principalOfficer }} -
-
- REBANK BUID{{ bank.reBuid }} -
+ + @if (principalOfficerName) { +
+ Principal Officer{{ principalOfficerName }} +
+ } @@ -164,7 +165,9 @@
Threshold: ₹{{ bank.cashThreshold | number }} per single txn / aggregate per month
-
Due: 15 Feb 2026
+ +
Due: {{ ctrDueDateLabel }}
@@ -206,11 +209,29 @@ with the same bundle and are worked on the Customer Cards tab.

- + + @if (ctrLinesLoading) { +
Loading the generated CTR lines…
+ } @else if (ctrLinesError) { +
+ {{ ctrLinesError }} +
+ } @else if (!ctrTxns.length) { +
+ + No CTR has been generated yet. Pick a month under “Generate CTR” below and run it — every cash transaction + of ₹10,00,000 or more, and every customer whose smaller cash transactions add up to that within the month, + will be picked up from the books. +
+ } +
@@ -450,7 +471,7 @@
{{ alert.transactionType }} {{ alert.ruleCode }} — {{ alert.ruleDescription }} ₹{{ alert.amount | number: '1.0-0' }}{{ alert.raisedOn | date: 'medium' }}{{ asDate(alert.raisedOn) | date: 'medium' }} {{ alert.status }} {{ alert.transactionType }} {{ alert.ruleCode }} — {{ alert.ruleDescription }} ₹{{ alert.amount | number: '1.0-0' }}{{ alert.raisedOn | date: 'medium' }}{{ asDate(alert.raisedOn) | date: 'medium' }} {{ alert.reviewedByUsername || '—' }} {{ alert.reviewNotes || '—' }} @@ -744,295 +765,6 @@ Download STR XML - -
-
- - -
-
- - -
- -
- -
- @for (s of filteredStr; track s.reportRefNo) { -
-
-
- STR-{{ s.reportRefNo }} - {{ statusLabel(s.status) }} - {{ riskLabel(s.riskCategory) }} -
-
- {{ s.depositOrWithdrawal }} - ₹{{ s.txnAmount | number: '1.0-0' }} -
-
-
-
-
- {{ s.customerName }} -
-
- {{ s.accountNumber }} -
-
- {{ s.txnDate }} {{ s.txnTime }} -
-
- {{ s.detectedOn }} -
-
- {{ s.flaggedBy }} -
-
- {{ s.instrumentType }} {{ s.instrumentId }} -
-
- {{ s.branchCode }} {{ branchNameFor(s.branchCode) }} -
-
-
- -

{{ s.narrative }}

-
- @if (s.poRemarks) { -
- -

{{ s.poRemarks }}

-
- } - @if (s.poFinding) { -
- -

{{ poFindingLabel(s.poFinding) }}

-
- } - - - @if (s.status === 'awaiting-filing') { -
- -

- Generate the FINnet STR XML, upload it on the FIU-IND portal under - {{ bank.principalOfficer }}'s digital signature, then record the acknowledgement number here. -

-
- - - -
-
- } - - @if (s.status === 'filed' && s.fiuReferenceNo) { -
- - - Filed with FIU-IND on {{ s.filedOn }} · acknowledgement - {{ s.fiuReferenceNo }} - -
- } - - @if (!s.poFinding && investigatingStr?.reportRefNo !== s.reportRefNo) { -
- - -
- } - - - @if (investigatingStr?.reportRefNo === s.reportRefNo) { -
-
- Principal Officer investigation — STR-{{ s.reportRefNo }} - -
- - -
-
1 Documentary evidence called for
-
-
- - -
-
- - -
-
- - -
-
-
- - -
-
2 Customer’s explanation of the source
- -
- - -
-
- 3 Verification checklist - - {{ answeredCount }} / {{ strChecklist.length }} answered - -
- - - - - - - - - - @for (item of strChecklist; track item.key) { - - - - - - } - -
Verification pointGenuineNot satisfied
-
{{ item.label }}
-
{{ item.hint }}
-
- - - -
-
- - -
- @if (!canConcludeLocal) { - - - Complete all three steps to determine the outcome. - @if (!evidenceRecorded) { - Evidence not yet recorded. - } - @if (!allAnswered) { - {{ strChecklist.length - answeredCount }} verification point(s) pending. - } - - } @else if (derivedConclusion === 'NOT_SUSPICIOUS') { - - - Source of funds established. Case closes as clean — the transaction stays in - the CTR report and no STR is filed with FIU-IND. - - } @else { - - - Source of funds not established. {{ failedFindings.length }} point(s) - unsatisfied — an STR will be filed with FIU-IND in addition to the CTR. - - } -
- - -
- } -
-
- } @empty { -
No STR records match the filters.
- } -
} diff --git a/src/app/fiu/fiu.component.scss b/src/app/fiu/fiu.component.scss index dbac07b0c..f8f0390c8 100644 --- a/src/app/fiu/fiu.component.scss +++ b/src/app/fiu/fiu.component.scss @@ -1086,6 +1086,17 @@ flex-shrink: 0; margin-top: 2px; } + + /* Nothing to file yet, or the lines could not be read — not the neutral blue of an explanation. */ + &--warn { + background: #fffbeb; + border-color: #fde68a; + color: #92400e; + + fa-icon { + color: #d97706; + } + } } /* TOAST */ diff --git a/src/app/fiu/fiu.component.ts b/src/app/fiu/fiu.component.ts index c2dc8081b..304f3375a 100644 --- a/src/app/fiu/fiu.component.ts +++ b/src/app/fiu/fiu.component.ts @@ -13,17 +13,14 @@ import { FaIconComponent } from '@fortawesome/angular-fontawesome'; import { AmlService } from 'app/aml/aml.service'; import { SanctionsLogsService } from 'app/reports/sanctions-logs/sanctions-logs.service'; import { AuthenticationService } from 'app/core/authentication/authentication.service'; +import { toApiDate, toIsoDate } from 'app/core/utils/api-date'; import { BRANCHES, BranchMaster, CardTransaction, CtrTransaction, - CTR_TXNS, - TC1_TXNS, FIU_BANK, FIU_BRANCH_CODE_TO_CANONICAL, - KC1_INDIVIDUALS, - KC2_ENTITIES, KycEntity, KycIndividual, PoFinding, @@ -127,7 +124,7 @@ export const FIU_OBLIGATIONS: FiuObligation[] = [ ] }) export class FiuComponent implements OnInit { - /** Bump when CTR_TXNS / STR_TXNS change so cached rows are re-seeded. */ + /** Bump when the seeded STR set changes so cached rows are re-seeded. */ private static readonly SEED_VERSION = '2026-01-cash-threshold'; private amlService = inject(AmlService); @@ -140,11 +137,33 @@ export class FiuComponent implements OnInit { readonly obligations = FIU_OBLIGATIONS; bank = FIU_BANK; - ctrTxns: CtrTransaction[] = CTR_TXNS; - tc1Txns: CardTransaction[] = TC1_TXNS; + /** + * The CTR lines the bank has actually generated. Empty until the engine has produced a report — + * a filing screen that shows invented accounts is worse than one that shows none, because every + * count, total and CSV on it is then something nobody can submit. + */ + ctrTxns: CtrTransaction[] = []; + ctrLinesLoading = false; + ctrLinesError = ''; + /** + * ATM / card-rail cash, which feeds the same CTR as the counter transactions in TC2. + * + * Empty: the sample rows that used to sit here belonged to the sample accounts in TC2, and with + * those gone they referenced accounts this bank does not hold — twelve rows claiming to reconcile + * against a file that had none. Card transactions are not yet sourced from the books, so the file + * is honestly empty rather than populated with rows that cannot be filed. + */ + tc1Txns: CardTransaction[] = []; strTxns: StrTransaction[] = STR_TXNS; - kc1: KycIndividual[] = KC1_INDIVIDUALS; - kc2: KycEntity[] = KC2_ENTITIES; + /** + * KC1 / KC2 — the customer cards that travel with the CTR bundle. + * + * Empty for the same reason as TC1: these described sample customers of sample accounts. A card + * naming a person this bank has never onboarded cannot be filed, and leaving them in would put + * invented customers into a bundle an officer might submit. + */ + kc1: KycIndividual[] = []; + kc2: KycEntity[] = []; // -------------------- Head Office vs Branch scope -------------------- // CTR/STR filing itself is a bank-wide Principal-Officer function (one filing for the whole @@ -155,6 +174,12 @@ export class FiuComponent implements OnInit { readonly branches = BRANCHES; isHeadOffice = false; currentOfficeName = 'Branch Office'; + /** The month being filed, from the clock rather than a constant in the data file. */ + reportingMonthLabel = ''; + /** Blank unless the signed-in user actually holds the Principal Officer role. */ + principalOfficerName = ''; + /** CTR is due by the 15th of the month after the one being reported. */ + ctrDueDateLabel = ''; private ownBranchCode: string | null = null; /** '' = All Branches (Head Office consolidated view); otherwise a BranchMaster.code. */ branchFilter = ''; @@ -372,6 +397,21 @@ export class FiuComponent implements OnInit { const creds = this.authenticationService.getCredentials(); this.isHeadOffice = this.authenticationService.isHeadOfficeUser(); this.currentOfficeName = creds?.officeName || 'Branch Office'; + + // The header named a hard-coded officer and a hard-coded month — "Karthik Kamath" and + // "January 2026" — on a screen whose whole purpose is to say which month is being filed and + // by whom. Both now come from the session and the clock. + const today = new Date(); + this.reportingMonthLabel = today.toLocaleDateString('en-IN', { month: 'long', year: 'numeric' }); + // The 15th of the following month, which is the PMLA deadline for the month being reported. + this.ctrDueDateLabel = new Date(today.getFullYear(), today.getMonth() + 1, 15).toLocaleDateString('en-IN', { + day: 'numeric', + month: 'short', + year: 'numeric' + }); + this.principalOfficerName = this.authenticationService.isPrincipalOfficerUser() + ? creds?.staffDisplayName || creds?.username || 'Principal Officer' + : ''; if (this.isHeadOffice) { this.branchFilter = ''; } else { @@ -384,22 +424,17 @@ export class FiuComponent implements OnInit { this.loadAmlAlerts(); this.loadAmlCtrReports(); this.loadAmlStrCases(); + // The CTR grids are built from the reports the bank generated, not from a sample set. Loaded + // after the cached rows below so a fresh read always wins over whatever was stored last. + this.loadCtrLines(); // Bumping SEED_VERSION discards previously cached rows so a new data set takes effect. if (localStorage.getItem('fiu_seed_version') !== FiuComponent.SEED_VERSION) { localStorage.setItem('fiu_seed_version', FiuComponent.SEED_VERSION); - localStorage.setItem('fiu_ctr_txns', JSON.stringify(this.ctrTxns)); localStorage.setItem('fiu_str_cases', JSON.stringify(this.strTxns)); return; } - const storedCtr = localStorage.getItem('fiu_ctr_txns'); - if (storedCtr) { - this.ctrTxns = JSON.parse(storedCtr); - } else { - localStorage.setItem('fiu_ctr_txns', JSON.stringify(this.ctrTxns)); - } - const storedStr = localStorage.getItem('fiu_str_cases'); if (storedStr) { this.strTxns = JSON.parse(storedStr); @@ -442,7 +477,7 @@ export class FiuComponent implements OnInit { txn.evidenceDocType = this.ctrEvidenceDocType; txn.evidenceReference = this.ctrEvidenceRef.trim(); txn.evidencedOn = new Date().toLocaleDateString('en-GB').replace(/\//g, '-'); - localStorage.setItem('fiu_ctr_txns', JSON.stringify(this.ctrTxns)); + this.storeCtrCompliance(); this.evidencingTxn = null; this.toast(`${txn.customerName} — evidence recorded. Reported in CTR only, no STR referral.`); } @@ -453,7 +488,7 @@ export class FiuComponent implements OnInit { */ referCtrTxnToStr(txn: CtrTransaction): void { txn.complianceStatus = 'REFERRED'; - localStorage.setItem('fiu_ctr_txns', JSON.stringify(this.ctrTxns)); + this.storeCtrCompliance(); const newRef = Math.max(100, ...this.strTxns.map((s) => s.reportRefNo)) + 1; const newStrCase: StrTransaction = { @@ -521,7 +556,7 @@ export class FiuComponent implements OnInit { origin.evidenceDocType = this.evidenceDocType; origin.evidenceReference = this.evidenceReference.trim(); origin.evidencedOn = new Date().toLocaleDateString('en-GB').replace(/\//g, '-'); - localStorage.setItem('fiu_ctr_txns', JSON.stringify(this.ctrTxns)); + this.storeCtrCompliance(); } this.toast(`STR-${rec.reportRefNo} closed as clean — CTR only, no STR filed`); } else { @@ -598,6 +633,129 @@ export class FiuComponent implements OnInit { this.amlService.getStrCases().subscribe((data) => (this.amlStrCases = data || [])); } + /** + * Fineract sends timestamps as [y, M, d, H, m, s]. Angular's date pipe renders an array as + * nothing, which left "Raised On" blank beside alerts that had obviously been raised. + */ + asDate(value: any): Date | null { + return toApiDate(value); + } + + /** + * Loads the generated CTR lines and shapes them into the TC2 rows every grid on this tab derives + * from — Account_Detail, Account_Person_Relation and TC1 are all computed off `ctrTxns`, so the + * one conversion here puts real data behind all four files. + */ + loadCtrLines(): void { + this.ctrLinesLoading = true; + this.ctrLinesError = ''; + this.amlService.getCtrReportLines().subscribe({ + next: (lines) => { + this.ctrLinesLoading = false; + this.ctrTxns = (lines || []).map((l: any, i: number) => this.toCtrTransaction(l, i + 1)); + // Cached so the compliance flags an officer sets below survive a reload, keyed on the rows + // that actually came back rather than on a seeded set. + this.applyStoredCtrCompliance(); + }, + error: (err: any) => { + this.ctrLinesLoading = false; + this.ctrTxns = []; + this.ctrLinesError = + err?.error?.defaultUserMessage || 'Could not load the generated CTR lines. Generate a report for the month first.'; + } + }); + } + + /** + * One reportable line as a TC2 row. + * + * A connected-group line is a month of smaller cash movements by one customer that together cross + * the threshold, so it has no single transaction, account or date behind it. It is still + * reportable, and is shown as the aggregate it is rather than dropped. + */ + private toCtrTransaction(line: any, refNo: number): CtrTransaction { + const branchCode = this.branchCodeOfName(line.branchName) || ''; + return { + reportType: 'CTR', + reportRefNo: refNo, + branchCode, + accountNumber: line.accountNo || '', + txnBranchCode: branchCode, + depositOrWithdrawal: line.direction === 'Withdrawal' ? 'Withdrawal' : 'Deposit', + txnId: line.transactionId || line.id, + txnDate: toIsoDate(line.transactionDate), + txnTime: '', + txnAmount: Number(line.amount) || 0, + instrumentType: 'Cash', + instrumentId: 'CASH', + nonCustomerRef: '', + relationshipFlag: 'Customer', + thirdPartyPan: '', + declaration: line.connectedGroup ? 'Connected transactions aggregated for the month' : '', + customerName: line.clientName || '', + complianceStatus: 'PENDING', + accountOpenedOn: toIsoDate(line.accountOpenedOn), + accountClosedOn: toIsoDate(line.accountClosedOn), + accountStatus: line.accountStatus || '', + ckycNumber: line.ckycNumber || '', + clientExternalId: line.clientExternalId || '', + individual: line.individual !== false + }; + } + + /** Branch master code for an office name, so the branch filter on this tab still works. */ + private branchCodeOfName(name: string | null): string | null { + if (!name) { + return null; + } + const wanted = name.trim().toLowerCase(); + return this.branches.find((b: BranchMaster) => wanted.includes(b.name.toLowerCase()))?.code || null; + } + + /** + * Re-applies the evidence an officer recorded against these transactions. + * + * The rows themselves come from the server every load; only the counter-side compliance fields + * are held locally, so they are matched back on transaction id rather than by replacing the row. + */ + private applyStoredCtrCompliance(): void { + const stored = localStorage.getItem('fiu_ctr_compliance'); + if (!stored) { + return; + } + try { + const byTxnId: Record = JSON.parse(stored); + for (const t of this.ctrTxns) { + const saved = byTxnId[String(t.txnId)]; + if (saved) { + t.complianceStatus = saved.complianceStatus; + t.evidenceDocType = saved.evidenceDocType; + t.evidenceReference = saved.evidenceReference; + t.evidencedOn = saved.evidencedOn; + } + } + } catch { + // A corrupted cache must not take the filing screen down with it. + localStorage.removeItem('fiu_ctr_compliance'); + } + } + + /** Persists only the compliance fields, keyed on transaction id. */ + private storeCtrCompliance(): void { + const byTxnId: Record = {}; + for (const t of this.ctrTxns) { + if (t.complianceStatus && t.complianceStatus !== 'PENDING') { + byTxnId[String(t.txnId)] = { + complianceStatus: t.complianceStatus, + evidenceDocType: t.evidenceDocType, + evidenceReference: t.evidenceReference, + evidencedOn: t.evidencedOn + }; + } + } + localStorage.setItem('fiu_ctr_compliance', JSON.stringify(byTxnId)); + } + /** Surfaces a failed backend call — without this a rejected request looks like a dead button. */ private apiError(action: string) { return (err: any) => this.toast(err?.error?.defaultUserMessage || `${action} failed — please retry`); @@ -654,6 +812,9 @@ export class FiuComponent implements OnInit { next: () => { this.toast(`CTR report generated for ${this.ctrGenerateMonth}`); this.loadAmlCtrReports(); + // The grids above are built from these lines, so they are re-read rather than left showing + // the state before the run. + this.loadCtrLines(); }, error: this.apiError('CTR report generation') }); @@ -1016,7 +1177,9 @@ export class FiuComponent implements OnInit { t.txnBranchCode, t.depositOrWithdrawal, t.txnId.toString(), - t.txnDate.replace(/-/g, '/'), + // dd/MM/yyyy, as FINnet prints it. A straight slash-swap on an ISO date gave 2026/09/11, + // which reads as a different date to anyone expecting the prescribed format. + this.toFinnetDate(t.txnDate), t.txnTime, t.txnAmount.toString(), t.instrumentType, @@ -1254,6 +1417,19 @@ export class FiuComponent implements OnInit { return { headers, rows }; } + /** FINnet prints dates as dd/MM/yyyy; the API sends them as yyyy-MM-dd. Blank stays blank. */ + private toFinnetDate(iso: string | undefined): string { + if (!iso) { + return ''; + } + const [ + y, + m, + d + ] = iso.split('-'); + return y && m && d ? `${d}/${m}/${y}` : ''; + } + /** Account_Detail — per-report account aggregates (12-month) */ private buildAccountDetail(): FinnetTable { const headers = [ @@ -1290,12 +1466,15 @@ export class FiuComponent implements OnInit { rows.push([ t.reportType, t.reportRefNo.toString(), + // 212 is FINnet's savings-account code; every CTR line this module produces is a savings + // transaction, and a loan line would need its own code before it could be filed. '212', t.accountNumber, t.branchCode, - '20/05/2018', - '', - 'ACTIVE', + // Read off the account, not assumed. Every row used to carry 20/05/2018 and ACTIVE. + this.toFinnetDate(t.accountOpenedOn), + this.toFinnetDate(t.accountClosedOn), + t.accountStatus || '', '', debits.length.toString(), debits.reduce((s, x) => s + x.txnAmount, 0).toString(), @@ -1333,16 +1512,16 @@ export class FiuComponent implements OnInit { const key = `${t.reportRefNo}|${t.accountNumber}`; if (seen.has(key)) continue; seen.add(key); - // Find matching KC1 by reportRefNo (best-effort dummy mapping) - const k = this.kc1.find((x) => x.reportRefNo === t.reportRefNo); rows.push([ t.reportType, t.reportRefNo.toString(), t.branchCode, t.accountNumber, '1', - k ? k.ucic : '0', - 'Individual', + // The customer's own reference: their CKYC number where one is on file, else the external + // id the bank knows them by. This used to read '0' for every row, which identified nobody. + t.ckycNumber || t.clientExternalId || '', + t.individual === false ? 'Non-individual' : 'Individual', t.relationshipFlag === 'Non-Customer' ? t.customerName || '' : '' ]); } @@ -1488,14 +1667,56 @@ export class FiuComponent implements OnInit { } /** STR XML stub (FINnet 2.0 STR XML) */ + /** + * Writes the STR XML from the cases the Principal Officer has actually concluded as suspicious. + * + * It used to be built from the sample set, so the file named accounts and customers this bank + * does not hold — a return that could not be filed and should not have been offered. + */ exportStrXml(): void { - const suspects = this.strSuspects; + const suspects = (this.amlStrCases || []).filter( + (c: any) => c.conclusion === 'SUSPICIOUS' || c.status === 'CONCLUDED_SUSPICIOUS' || c.status === 'FILED' + ); if (!suspects.length) { - this.toast('No suspect cases to export — nothing matches the current filters'); + this.toast('No case has been concluded suspicious yet — nothing to file'); return; } - this.writeStrXml(suspects, `STR-${this.bank.reportingMonth.replace(/\s+/g, '-')}.xml`); - this.toast(`STR XML exported (${suspects.length} suspects)`); + this.writeLiveStrXml(suspects, `STR-${this.reportingMonthLabel.replace(/\s+/g, '-')}.xml`); + this.toast(`STR XML exported (${suspects.length} case${suspects.length === 1 ? '' : 's'})`); + } + + /** Serialises the concluded cases as FINnet 2.0 STR XML and downloads it. */ + private writeLiveStrXml(cases: any[], filename: string): void { + const xml: string[] = []; + xml.push(''); + xml.push(``); + xml.push(` `); + if (this.principalOfficerName) { + xml.push(` `); + } + for (const c of cases) { + xml.push(` `); + xml.push(` ${this.escapeXml(c.clientDisplayName || '')}`); + xml.push(` ${this.escapeXml(c.conclusion || '')}`); + if (c.investigationNotes) { + xml.push(` ${this.escapeXml(c.investigationNotes)}`); + } + if (c.sourceOfFundsNotes) { + xml.push(` ${this.escapeXml(c.sourceOfFundsNotes)}`); + } + if (c.fiuReferenceNo) { + xml.push(` `); + } + xml.push(' '); + } + xml.push(''); + const blob = new Blob([xml.join('\n')], { type: 'application/xml;charset=utf-8' }); + const url = URL.createObjectURL(blob); + const a = document.createElement('a'); + a.href = url; + a.download = filename; + a.click(); + URL.revokeObjectURL(url); } /** Serialises a set of suspect cases as FINnet 2.0 STR XML and downloads it. */ diff --git a/src/app/rbi-circulars/aml-rules/aml-recurring-alerts/aml-recurring-alerts.component.html b/src/app/rbi-circulars/aml-rules/aml-recurring-alerts/aml-recurring-alerts.component.html index f614c533c..8d8edac1e 100644 --- a/src/app/rbi-circulars/aml-rules/aml-recurring-alerts/aml-recurring-alerts.component.html +++ b/src/app/rbi-circulars/aml-rules/aml-recurring-alerts/aml-recurring-alerts.component.html @@ -51,15 +51,6 @@ Could not load the review queue. - -
- science - - Showing sample data — the AML engine returned no recurring alerts for this threshold and window. - These rows are for demonstration only and are not reportable. - -
- diff --git a/src/app/rbi-circulars/aml-rules/aml-recurring-alerts/aml-recurring-alerts.component.scss b/src/app/rbi-circulars/aml-rules/aml-recurring-alerts/aml-recurring-alerts.component.scss index 0f64353be..9d62ff9ed 100644 --- a/src/app/rbi-circulars/aml-rules/aml-recurring-alerts/aml-recurring-alerts.component.scss +++ b/src/app/rbi-circulars/aml-rules/aml-recurring-alerts/aml-recurring-alerts.component.scss @@ -79,27 +79,6 @@ font-size: 13px; } -.ra-demo { - display: flex; - align-items: flex-start; - gap: 8px; - padding: 10px 12px; - margin-bottom: 12px; - border: 1px solid #fde68a; - border-radius: 6px; - background: #fffbeb; - color: #92400e; - font-size: 12.5px; - line-height: 1.5; - - mat-icon { - flex: 0 0 auto; - font-size: 18px; - width: 18px; - height: 18px; - } -} - .ra-table { width: 100%; border-collapse: separate; diff --git a/src/app/rbi-circulars/aml-rules/aml-recurring-alerts/aml-recurring-alerts.component.ts b/src/app/rbi-circulars/aml-rules/aml-recurring-alerts/aml-recurring-alerts.component.ts index e69fcd8d5..bf2198e8c 100644 --- a/src/app/rbi-circulars/aml-rules/aml-recurring-alerts/aml-recurring-alerts.component.ts +++ b/src/app/rbi-circulars/aml-rules/aml-recurring-alerts/aml-recurring-alerts.component.ts @@ -25,7 +25,6 @@ import { AmlService } from '../../../aml/aml.service'; import { AlertService } from '../../../core/alert/alert.service'; import { HasPermissionDirective } from '../../../directives/has-permission/has-permission.directive'; import { AmlRecurringReviewDialogComponent } from './aml-recurring-review-dialog.component'; -import { amlRecurringAlertsDemoData } from './aml-recurring-alerts.demo-data'; /** * Customers whose transactions keep tripping the AML engine, queued for an employee to review why. @@ -95,8 +94,6 @@ export class AmlRecurringAlertsComponent implements OnInit { loading = false; loadFailed = false; - /** Set when the rows on screen are the sample set rather than engine output. */ - usingDemoData = false; /** Which customer's alert detail is expanded inline. */ expandedClientId: number | null = null; @@ -111,20 +108,20 @@ export class AmlRecurringAlertsComponent implements OnInit { this.amlService.getRecurringAlerts(this.minAlerts, this.windowDays, this.onlyNeedingReview).subscribe({ next: (data) => { this.loading = false; - const rows = data || []; - this.usingDemoData = rows.length === 0; - this.rows.data = this.mapRows(this.usingDemoData ? this.demoRows() : rows); + this.rows.data = this.mapRows(data || []); }, error: () => { this.loading = false; this.loadFailed = true; - this.usingDemoData = true; - this.rows.data = this.mapRows(this.demoRows()); + // An AML review queue shows what the engine found or nothing at all. Standing in sample + // customers when the call fails invents suspicious activity against names that are not + // the bank's, which is worse than an empty table saying the load failed. + this.rows.data = []; } }); } - /** Parses the date fields the template renders. Shared by engine rows and the sample set. */ + /** Parses the date fields the template renders. */ private mapRows(rows: any[]): any[] { return rows.map((r: any) => ({ ...r, @@ -135,36 +132,6 @@ export class AmlRecurringAlertsComponent implements OnInit { })); } - /** - * The sample set narrowed by the on-screen controls, so the threshold and window selectors behave - * the way they would against the engine instead of returning the same three rows regardless. - */ - private demoRows(): any[] { - const cutoff = Date.now() - this.windowDays * 24 * 60 * 60 * 1000; - return amlRecurringAlertsDemoData() - .map((row: any) => { - const alerts = (row.alerts || []).filter((a: any) => new Date(a.raisedOn).getTime() >= cutoff); - const ruleCodes = Array.from(new Set(alerts.map((a: any) => a.ruleCode))); - return { - ...row, - alerts, - alertCount: alerts.length, - openCount: alerts.filter((a: any) => a.status === 'OPEN').length, - escalatedCount: alerts.filter((a: any) => a.status === 'ESCALATED').length, - // Severity sits on the alert server-side; the sample rows carry a row total, so it is - // capped at what survived the window rather than recomputed. - highRiskCount: Math.min(row.highRiskCount, alerts.length), - distinctRules: ruleCodes.length, - ruleCodes, - totalAmount: alerts.reduce((sum: number, a: any) => sum + (Number(a.amount) || 0), 0), - firstAlertOn: alerts.length ? alerts[0].raisedOn : null, - lastAlertOn: alerts.length ? alerts[alerts.length - 1].raisedOn : null - }; - }) - .filter((row: any) => row.alertCount >= this.minAlerts) - .filter((row: any) => !this.onlyNeedingReview || row.needsReview); - } - toggleExpanded(row: any): void { this.expandedClientId = this.expandedClientId === row.clientId ? null : row.clientId; } diff --git a/src/app/rbi-circulars/aml-rules/aml-recurring-alerts/aml-recurring-alerts.demo-data.ts b/src/app/rbi-circulars/aml-rules/aml-recurring-alerts/aml-recurring-alerts.demo-data.ts deleted file mode 100644 index ffab67a8e..000000000 --- a/src/app/rbi-circulars/aml-rules/aml-recurring-alerts/aml-recurring-alerts.demo-data.ts +++ /dev/null @@ -1,248 +0,0 @@ -/** - * Copyright since 2025 Mifos Initiative - * - * This Source Code Form is subject to the terms of the Mozilla Public - * License, v. 2.0. If a copy of the MPL was not distributed with this - * file, You can obtain one at http://mozilla.org/MPL/2.0/. - */ - -/** - * Sample recurring-alert rows for the AML review queue, used only when the engine returns nothing - * for the selected window so the screen can be demonstrated and reviewed. - * - * Shaped exactly like the `/aml/recurring-alerts` response — dates as ISO strings, alerts nested — - * so the rows travel through the component's normal mapping rather than a second code path. - * - * Dates are generated relative to today rather than hard-coded, otherwise every row would fall - * outside the "last 90 days" window as soon as the file aged. - */ - -/** ISO timestamp `days` before now, at the given hour and minute. */ -function daysAgo(days: number, hour = 11, minute = 20): string { - const d = new Date(); - d.setDate(d.getDate() - days); - d.setHours(hour, minute, 0, 0); - return d.toISOString(); -} - -/** - * Three customers covering the three review states the table renders: never reviewed, reviewed but - * flagged again since, and reviewed with an outcome recorded. - */ -export function amlRecurringAlertsDemoData(): any[] { - return [ - // Never reviewed — the row the queue exists to surface. - { - clientId: 10078, - clientDisplayName: 'Ravi Kumar Sharma', - accountNo: '000000010078', - clientRiskCategory: 3, - alertCount: 5, - highRiskCount: 3, - openCount: 4, - escalatedCount: 1, - distinctRules: 3, - ruleCodes: [ - 'CASH_TXN_10L', - 'STRUCTURING', - 'RAPID_MOVEMENT' - ], - totalAmount: 4785000, - firstAlertOn: daysAgo(62, 10, 5), - lastAlertOn: daysAgo(3, 15, 42), - needsReview: true, - lastReviewedOn: null, - alertCountAtLastReview: null, - lastReviewOutcome: null, - lastReviewedByUsername: null, - lastReviewNotes: null, - alerts: [ - { - alertId: 90121, - raisedOn: daysAgo(62, 10, 5), - transactionType: 'Savings Deposit', - transactionId: 552301, - ruleCode: 'CASH_TXN_10L', - ruleDescription: 'Cash deposit at or above ₹10,00,000 in a single transaction', - amount: 1200000, - status: 'OPEN', - reviewNotes: null - }, - { - alertId: 90144, - raisedOn: daysAgo(48, 12, 18), - transactionType: 'Savings Deposit', - transactionId: 553980, - ruleCode: 'STRUCTURING', - ruleDescription: 'Multiple cash deposits just below the reporting threshold within 48 hours', - amount: 890000, - status: 'OPEN', - reviewNotes: null - }, - { - alertId: 90190, - raisedOn: daysAgo(29, 9, 55), - transactionType: 'Savings Withdrawal', - transactionId: 556114, - ruleCode: 'RAPID_MOVEMENT', - ruleDescription: 'Funds withdrawn within 24 hours of a large credit', - amount: 1150000, - status: 'ESCALATED', - reviewNotes: 'Escalated to Principal Officer — source of funds not established.' - }, - { - alertId: 90233, - raisedOn: daysAgo(14, 16, 30), - transactionType: 'Savings Deposit', - transactionId: 558702, - ruleCode: 'CASH_TXN_10L', - ruleDescription: 'Cash deposit at or above ₹10,00,000 in a single transaction', - amount: 1045000, - status: 'OPEN', - reviewNotes: null - }, - { - alertId: 90275, - raisedOn: daysAgo(3, 15, 42), - transactionType: 'Savings Withdrawal', - transactionId: 560488, - ruleCode: 'STRUCTURING', - ruleDescription: 'Multiple cash deposits just below the reporting threshold within 48 hours', - amount: 500000, - status: 'OPEN', - reviewNotes: null - } - ] - }, - - // Reviewed at 2 alerts, flagged twice since — returns to the queue as "New activity". - { - clientId: 10079, - clientDisplayName: 'Priya Patel', - accountNo: '000000010079', - clientRiskCategory: 2, - alertCount: 4, - highRiskCount: 1, - openCount: 2, - escalatedCount: 0, - distinctRules: 2, - ruleCodes: [ - 'HIGH_VALUE_TRANSFER', - 'ROUND_TRIPPING' - ], - totalAmount: 2130000, - firstAlertOn: daysAgo(71, 14, 10), - lastAlertOn: daysAgo(6, 11, 5), - needsReview: true, - lastReviewedOn: daysAgo(34, 17, 0), - alertCountAtLastReview: 2, - lastReviewOutcome: 'MONITOR', - lastReviewedByUsername: 'anjali.mehta', - lastReviewNotes: 'Textile trading receipts, invoices seen. Keep under watch for one more quarter.', - alerts: [ - { - alertId: 90102, - raisedOn: daysAgo(71, 14, 10), - transactionType: 'Account Transfer', - transactionId: 551002, - ruleCode: 'HIGH_VALUE_TRANSFER', - ruleDescription: 'Transfer above ₹5,00,000 to an unrelated account', - amount: 620000, - status: 'WHITELISTED', - reviewNotes: 'Supplier payment, invoice verified.' - }, - { - alertId: 90158, - raisedOn: daysAgo(41, 10, 45), - transactionType: 'Account Transfer', - transactionId: 554330, - ruleCode: 'ROUND_TRIPPING', - ruleDescription: 'Funds returned to the originating account within seven days', - amount: 450000, - status: 'WHITELISTED', - reviewNotes: 'Advance refunded after order cancellation.' - }, - { - alertId: 90241, - raisedOn: daysAgo(19, 13, 25), - transactionType: 'Account Transfer', - transactionId: 559011, - ruleCode: 'HIGH_VALUE_TRANSFER', - ruleDescription: 'Transfer above ₹5,00,000 to an unrelated account', - amount: 540000, - status: 'OPEN', - reviewNotes: null - }, - { - alertId: 90268, - raisedOn: daysAgo(6, 11, 5), - transactionType: 'Account Transfer', - transactionId: 560190, - ruleCode: 'ROUND_TRIPPING', - ruleDescription: 'Funds returned to the originating account within seven days', - amount: 520000, - status: 'OPEN', - reviewNotes: null - } - ] - }, - - // Reviewed with an outcome and no activity since — shows the settled state. - { - clientId: 10080, - clientDisplayName: 'Arjun Deshmukh', - accountNo: '000000010080', - clientRiskCategory: 1, - alertCount: 3, - highRiskCount: 0, - openCount: 0, - escalatedCount: 0, - distinctRules: 1, - ruleCodes: ['CASH_TXN_10L'], - totalAmount: 3420000, - firstAlertOn: daysAgo(80, 9, 30), - lastAlertOn: daysAgo(23, 12, 15), - needsReview: false, - lastReviewedOn: daysAgo(9, 15, 20), - alertCountAtLastReview: 3, - lastReviewOutcome: 'CLEARED', - lastReviewedByUsername: 'vikram.singh', - lastReviewNotes: 'Sugarcane crushing-season proceeds. Mill settlement statements filed with the branch.', - alerts: [ - { - alertId: 90088, - raisedOn: daysAgo(80, 9, 30), - transactionType: 'Savings Deposit', - transactionId: 550210, - ruleCode: 'CASH_TXN_10L', - ruleDescription: 'Cash deposit at or above ₹10,00,000 in a single transaction', - amount: 1180000, - status: 'WHITELISTED', - reviewNotes: 'Mill settlement for the 2025-26 crushing season.' - }, - { - alertId: 90165, - raisedOn: daysAgo(52, 11, 0), - transactionType: 'Savings Deposit', - transactionId: 554901, - ruleCode: 'CASH_TXN_10L', - ruleDescription: 'Cash deposit at or above ₹10,00,000 in a single transaction', - amount: 1090000, - status: 'WHITELISTED', - reviewNotes: 'Second instalment, statement on record.' - }, - { - alertId: 90212, - raisedOn: daysAgo(23, 12, 15), - transactionType: 'Savings Deposit', - transactionId: 557640, - ruleCode: 'CASH_TXN_10L', - ruleDescription: 'Cash deposit at or above ₹10,00,000 in a single transaction', - amount: 1150000, - status: 'WHITELISTED', - reviewNotes: 'Final instalment for the season.' - } - ] - } - ]; -} diff --git a/src/app/risk-review/risk-review-data.ts b/src/app/risk-review/risk-review-data.ts index 3e054ceda..633de824d 100644 --- a/src/app/risk-review/risk-review-data.ts +++ b/src/app/risk-review/risk-review-data.ts @@ -7,6 +7,7 @@ */ import { GL_BRANCHES, GL_PERIOD } from '../gl-reports/gl-reports-data'; +import { toIsoDate } from '../core/utils/api-date'; /** * Data layer for the Risk Review Report. @@ -390,17 +391,23 @@ export interface LiveAmlAlert { ruleDescription: string; amount: number; currencyCode: string; - raisedOn: string; + /** Fineract sends LocalDateTime as [y,m,d,h,mi,s], not a string. */ + raisedOn: number[] | string; status: string; reviewedByUsername: string | null; - reviewedOn: string | null; + reviewedOn: number[] | string | null; reviewNotes: string | null; disposition: string | null; } -/** Trims a LocalDateTime such as 2026-07-14T11:04:22 down to its date part. */ -function isoDayOf(value: string | null | undefined): string { - return (value || '').slice(0, 10); +/** + * Fineract serialises LocalDateTime as an array — raisedOn arrives as + * [2026,9,11,0,0,0], not a string. Slicing that gave back an array rather than + * a date, so every live row carried an unusable alertDate and the period filter + * discarded it: the report read empty while the engine held alerts. + */ +function isoDayOf(value: unknown): string { + return toIsoDate(value); } /** diff --git a/src/app/risk-review/risk-review-report.component.html b/src/app/risk-review/risk-review-report.component.html index 40d308e22..503cb0dad 100644 --- a/src/app/risk-review/risk-review-report.component.html +++ b/src/app/risk-review/risk-review-report.component.html @@ -150,33 +150,62 @@ } @else {
-
+ +
-
+ +
-
+
-
+ +
+
@@ -220,7 +249,7 @@ row does not have to repeat it in a Move To and a Position column. --> @for (g of customerGroups; track g.key) {
-
+ @@ -268,6 +298,20 @@ + + - - @if (expanded === c.clientId) { - diff --git a/src/app/sponsor-dd/dd-list/dd-list.component.ts b/src/app/sponsor-dd/dd-list/dd-list.component.ts index a8863798f..d9df2dc0d 100644 --- a/src/app/sponsor-dd/dd-list/dd-list.component.ts +++ b/src/app/sponsor-dd/dd-list/dd-list.component.ts @@ -142,6 +142,12 @@ import { DividendClaimRequestService } from '../../clerk/dividend-claim-request. background-color: #fef3c7; color: #b45309; } + /* Spoiled stock. Grey rather than red: nothing went wrong with the customer's draft, only + with the paper it was printed on, and the draft itself is alive on another leaflet. */ + .status-badge.revoked { + background-color: #e2e8f0; + color: #475569; + } .action-btn-group { display: flex; gap: 8px; @@ -212,7 +218,10 @@ export class DdListComponent implements OnInit, AfterViewInit { 'ISSUED', 'CANCELLED', 'STOPPED', - 'CLEARED' + 'CLEARED', + // Spoiled at the printer. Filterable on its own because it is the count that explains a pad + // whose issued drafts do not add up to the numbers consumed. + 'REVOKED' ]; selectedStatus = 'ALL'; searchText = ''; @@ -444,6 +453,23 @@ export class DdListComponent implements OnInit, AfterViewInit { } }); } + + /** The leaflet was ruined at the printer and its draft has to move onto a fresh blank. */ + openRevokeDialog(leaflet: any): void { + this.dialog + .open(RevokeDdDialogComponent, { + width: '480px', + maxWidth: '95vw', + data: { leaflet }, + disableClose: true + }) + .afterClosed() + .subscribe((result) => { + if (result) { + this.loadLeaflets(); + } + }); + } } // ── ISSUE DRAFT DIALOG COMPONENT ── @@ -1655,6 +1681,114 @@ export class RequestReprintDialogComponent implements OnInit { } } +/** + * Replacing a leaflet ruined at the printer. + * + * Not a reprint: a reprint puts the same draft back through the printer on the same paper, which is + * right when the print jammed but the leaflet survived. Here the leaflet itself is spoiled — its + * printed number can never be handed to anyone — so the draft moves onto the next blank and both + * leaflets stay on the register, or the pad comes up short against the sponsor bank's range. + */ +@Component({ + selector: 'mifosx-revoke-dd-dialog', + standalone: true, + imports: [ + CommonModule, + FormsModule, + ReactiveFormsModule, + MatDialogModule, + MatFormFieldModule, + MatInputModule, + MatButtonModule + ], + template: ` +

Revoke Spoiled DD (DD No: {{ data.leaflet.ddNumber }})

+ +

+ Use this when the leaflet itself is ruined — misprinted, torn or jammed — and cannot be given to the customer. + On approval this leaflet is marked REVOKED and the draft is re-issued on the next available + blank. +

+

+ No money moves. {{ data.leaflet.clientName || data.leaflet.walkinPurchaserName || 'The purchaser' }} has already + paid for this draft and is still owed the same one. +

+
+ + What went wrong with this leaflet? + + Reason is required + + +
+ + + + + `, + styles: [ + ` + .dialog-form { + display: flex; + flex-direction: column; + gap: 12px; + padding-top: 8px; + } + .full-width { + width: 100%; + } + .hint-text { + color: #64748b; + font-size: 13px; + margin: 0 0 10px 0; + line-height: 1.5; + } + ` + ] +}) +export class RevokeDdDialogComponent implements OnInit { + private fb = inject(FormBuilder); + private sponsorDDService = inject(SponsorDDService); + private dialogRef = inject(MatDialogRef); + private snackBar = inject(MatSnackBar); + protected data = inject(MAT_DIALOG_DATA); + + revokeForm!: FormGroup; + loading = false; + + ngOnInit(): void { + this.revokeForm = this.fb.group({ + reason: [ + '', + Validators.required + ] + }); + } + + onCancel(): void { + this.dialogRef.close(false); + } + + onSubmit(): void { + if (this.revokeForm.invalid) { + return; + } + this.loading = true; + this.sponsorDDService.requestRevoke(this.data.leaflet.id, this.revokeForm.value.reason).subscribe({ + next: () => { + this.snackBar.open('Revoke request submitted for approval', 'Close', { duration: 3000 }); + this.dialogRef.close(true); + }, + error: (err) => { + this.loading = false; + this.snackBar.open('Error: ' + (err.error?.message || err.message), 'Close', { duration: 6000 }); + } + }); + } +} + // ── PRINT DRAFT DIALOG COMPONENT ── @Component({ selector: 'mifosx-print-draft-dialog', diff --git a/src/app/sponsor-dd/dd-revoke-approvals/dd-revoke-approvals.component.html b/src/app/sponsor-dd/dd-revoke-approvals/dd-revoke-approvals.component.html new file mode 100644 index 000000000..7d6481477 --- /dev/null +++ b/src/app/sponsor-dd/dd-revoke-approvals/dd-revoke-approvals.component.html @@ -0,0 +1,130 @@ + +
+
+
+

DD Revoke Approvals

+

+ A leaflet ruined at the printer cannot be handed over, and its number can never be given to anyone else. + Approving moves the same draft onto the next available blank — this series first, then this sponsor bank, then + any other. No money moves: the customer was already debited. +

+
+
+ + + Search by DD No. or customer + + + +
+ +
+ + +
Customer Account Number Branch Rating on fileReview says Alerts AML Rules Triggered Amount under review {{ riskText(c.currentRisk) }} + + + {{ directionText(c) }} + + @if (c.directionReason) { +
{{ c.directionReason }}
+ } +
{{ c.alertCount }} @for (label of c.ruleLabels; track label) { @@ -276,10 +320,11 @@ {{ c.totalAmount | number: '1.0-0' }} {{ c.lastAlertDate }} + + - @if (c.directionReason) { -
{{ c.directionReason }}
- }
+ @for (a of c.alerts; track a.alertId) {
diff --git a/src/app/risk-review/risk-review-report.component.scss b/src/app/risk-review/risk-review-report.component.scss index 117eae800..7f6dfa5d5 100644 --- a/src/app/risk-review/risk-review-report.component.scss +++ b/src/app/risk-review/risk-review-report.component.scss @@ -286,12 +286,61 @@ margin-bottom: 14px; } +/* The verdict, on the row. Colour carries the same meaning as the section it sits under, so a + reviewer scanning the middle of a long table does not have to look back up to the heading. */ +.rr-move { + display: inline-flex; + align-items: center; + gap: 6px; + padding: 4px 10px; + border-radius: 999px; + font-size: 11.5px; + font-weight: 700; + white-space: nowrap; + + &--up { + background: #fef3c7; + color: #92400e; + } + + &--down { + background: #dbeafe; + color: #1e40af; + } + + &--same { + background: #f1f5f9; + color: var(--r-g500); + } +} + +/* A button, so the tile is reachable by keyboard and reads as the control it is. The type + defaults that come with that — centred text, the button font, the UA border — are undone + here so it still looks like the panel it replaced. */ .rr-kpi { + display: block; + width: 100%; background: #fff; border: 1px solid var(--r-g200); border-left: 4px solid var(--r-navy); border-radius: 11px; padding: 12px 16px; + text-align: left; + font: inherit; + color: inherit; + cursor: pointer; + transition: + box-shadow 0.15s ease, + border-color 0.15s ease; + + &:hover { + box-shadow: 0 1px 6px rgb(15 23 42 / 12%); + } + + &:focus-visible { + outline: 2px solid var(--r-blue); + outline-offset: 2px; + } &--blue { border-left-color: var(--r-blue); @@ -307,6 +356,18 @@ &--dim { border-left-color: var(--r-g300); + /* Nothing to narrow to, so the tile says so rather than accepting a click that does nothing. */ + cursor: default; + + &:hover { + box-shadow: none; + } + } + + /* The filter this tile applies is currently on. */ + &--on { + border-color: var(--r-navy); + box-shadow: inset 0 0 0 1px var(--r-navy); } } @@ -502,9 +563,11 @@ } td { - padding: 8px 10px; + padding: 11px 10px; border-bottom: 1px solid var(--r-g100); - vertical-align: top; + /* Middle rather than top: one cell in the row carries a two-line reason, and top alignment + left every other value floating against the row above it. */ + vertical-align: middle; } .num { @@ -538,6 +601,8 @@ td { padding: 12px 16px 16px; + /* The expanded block is a panel, not a value, so it starts at the top of its cell. */ + vertical-align: top; } } @@ -789,6 +854,19 @@ } /* What the report says should happen to the rating */ +/* The verdict and the sentence behind it. Wide enough that the reason wraps to two readable + lines instead of a narrow ragged column. */ +.rr-move-cell { + min-width: 250px; + max-width: 330px; +} + +/* Just the button, so the column is only as wide as the control it holds. */ +.rr-action-cell { + width: 1%; + white-space: nowrap; +} + .rr-verdict-cell { min-width: 210px; max-width: 300px; @@ -817,9 +895,11 @@ } .rr-verdict-why { - font-size: 10.5px; + font-size: 11px; + line-height: 1.45; color: var(--r-g500); - margin-top: 2px; + margin-top: 4px; + white-space: normal; white-space: normal; } diff --git a/src/app/risk-review/risk-review-report.component.ts b/src/app/risk-review/risk-review-report.component.ts index bd59f7bd6..8f0775fc3 100644 --- a/src/app/risk-review/risk-review-report.component.ts +++ b/src/app/risk-review/risk-review-report.component.ts @@ -224,21 +224,35 @@ export class RiskReviewReportComponent implements OnInit { return; } + // The alert carries the client but not their account number or branch, so both columns read + // as an em dash on every live row. Both sit on the client record the KYC call already needs, + // so they are fetched alongside it rather than left blank on a compliance report. forkJoin( clientIds.map((id) => - this.clients.getClientKyc(String(id), true).pipe( - map((kyc: any) => ({ id, risk: riskFromCategory(kyc?.riskCategory) })), - catchError(() => of({ id, risk: null as RiskLevel | null })) + forkJoin({ + kyc: this.clients.getClientKyc(String(id), true).pipe(catchError(() => of(null))), + client: this.clients.getClientData(String(id), true).pipe(catchError(() => of(null))) + }).pipe( + map((r: any) => ({ + id, + risk: riskFromCategory(r.kyc?.riskCategory), + accountNo: r.client?.accountNo || '', + branch: r.client?.officeName || '' + })), + catchError(() => of({ id, risk: null as RiskLevel | null, accountNo: '', branch: '' })) ) ) ).subscribe((results) => { - const byClient = new Map( - results.map((r) => [ - r.id, - r.risk - ]) - ); - this.liveAlerts = rows.map((r) => ({ ...r, currentRisk: byClient.get(r.clientId) ?? null })); + const byClient = new Map(results.map((r) => [r.id, r])); + this.liveAlerts = rows.map((r) => { + const info = byClient.get(r.clientId); + return { + ...r, + currentRisk: info?.risk ?? null, + accountNo: r.accountNo || info?.accountNo || '', + branchCode: r.branchCode || info?.branch || '' + }; + }); this.compose(); this.loading = false; }); @@ -389,8 +403,62 @@ export class RiskReviewReportComponent implements OnInit { return groups.filter((g) => g.rows.length > 0); } - /** Collapsed sections, by group key. 'No change needed' starts closed — nothing to act on. */ - collapsedGroups: Record = { same: true }; + /** + * Collapsed sections, by group key. Every group starts open. + * + * 'No change needed' used to start closed on the reasoning that there was nothing to act on. On a + * report that often holds one group, that meant opening the page to a heading and a count and no + * rows — the customer was there, but reading the report took a click to discover it. The toggle + * stays for folding a long section away. + */ + collapsedGroups: Record = {}; + + /** + * The summary tiles read as a question each — "which customers?", "which alerts still need a + * decision?" — so each one narrows the table to the rows it counted. They were plain text + * before, which invited a click that did nothing and left the reviewer to rebuild the same + * filter by hand from the dropdowns. + * + * Clicking an active tile clears it again, so a tile is never a one-way trip into a filter the + * reviewer then has to hunt for. + */ + focusAllCustomers(): void { + this.currentRisk = ''; + this.direction = ''; + this.openOnly = false; + this.expanded = null; + this.collapsedGroups = {}; + } + + focusOpenAlerts(): void { + this.openOnly = !this.openOnly; + this.direction = ''; + this.expanded = null; + this.collapsedGroups = {}; + } + + /** Only the customers whose rating the review says should move, either way. */ + focusRatingsToMove(): void { + // Nothing to narrow to when no rating needs moving; leaving the filter alone keeps the + // table showing what is actually there rather than emptying it. + if (this.summary.upgradeCandidates + this.summary.downgradeCandidates === 0) { + return; + } + this.direction = this.direction === 'up' ? 'down' : this.direction === 'down' ? '' : 'up'; + this.openOnly = false; + this.expanded = null; + this.collapsedGroups = {}; + } + + focusEscalated(): void { + if (this.summary.escalatedCustomers === 0) { + return; + } + this.status = this.status === 'ESCALATED_STR' ? '' : 'ESCALATED_STR'; + this.openOnly = false; + this.expanded = null; + this.collapsedGroups = {}; + } toggleGroup(key: string): void { this.collapsedGroups[key] = !this.collapsedGroups[key]; @@ -513,6 +581,47 @@ export class RiskReviewReportComponent implements OnInit { * An escalated case is worded as a review rather than a move: the rating is held * pending the Principal Officer's conclusion, so there is nothing to apply yet. */ + /** + * The verdict in the words a reviewer needs: which way the rating moves, and to what. + * + * An escalated case is deliberately not given a direction — the rating is held where it is + * until the Principal Officer reports back, so showing "raise to High" there would invite a + * change nobody is authorised to make yet. + */ + directionText(c: RiskCustomerRow): string { + if (c.escalated) { + return 'Held — with Principal Officer'; + } + if (c.direction === 'up') { + return `Raise to ${RISK_LABEL[c.recommendedRisk]}`; + } + if (c.direction === 'down') { + return `Lower to ${RISK_LABEL[c.recommendedRisk]}`; + } + return c.currentRisk ? `Keep at ${RISK_LABEL[c.currentRisk]}` : 'No rating on record'; + } + + directionIcon(c: RiskCustomerRow): string { + if (c.escalated) { + return 'lock'; + } + // 'check' rather than an equals sign: faEquals is not in the shared library, and an + // unregistered icon throws at render and takes the whole row down with it. + return c.direction === 'up' ? 'arrow-up' : c.direction === 'down' ? 'arrow-down' : 'check'; + } + + /** + * A live row carries the office name straight off the client record, not a GL branch code, so + * it is already the label to print. Only a coded row needs the lookup. + */ + branchLabelOf(branch: string): string { + if (!branch) { + return '—'; + } + const named = glBranchName(branch); + return named && named !== branch ? named : branch; + } + kycActionText(c: RiskCustomerRow): string { if (c.escalated) { return 'Open KYC'; @@ -689,7 +798,7 @@ export class RiskReviewReportComponent implements OnInit { String(c.clientId), c.customerName, c.accountNo || '—', - c.branchCode ? glBranchName(c.branchCode) : '—', + this.branchLabelOf(c.branchCode), this.riskText(c.currentRisk), c.alertCount, c.ruleLabels.join('; '), @@ -749,7 +858,7 @@ export class RiskReviewReportComponent implements OnInit { String(a.clientId), a.customerName, a.accountNo || '—', - a.branchCode ? glBranchName(a.branchCode) : '—', + this.branchLabelOf(a.branchCode), this.riskText(a.currentRisk), this.txnDetail(a), a.rule.label, @@ -815,7 +924,7 @@ export class RiskReviewReportComponent implements OnInit { String(t.clientId), t.customerName, t.accountNo || '—', - t.branchCode ? glBranchName(t.branchCode) : '—', + this.branchLabelOf(t.branchCode), this.riskText(t.currentRisk), channelLabel(t.channel), t.drCr, diff --git a/src/app/risk-review/risk-review.compute.ts b/src/app/risk-review/risk-review.compute.ts index ce0dd9000..67499a2bc 100644 --- a/src/app/risk-review/risk-review.compute.ts +++ b/src/app/risk-review/risk-review.compute.ts @@ -218,7 +218,14 @@ function recommendRating( }; } - return { risk, reason: '' }; + // The rating on file is where the activity puts it. Saying so is the verdict — leaving it blank + // read as "not assessed yet" on screen, which is the opposite of what the review concluded. + return { + risk, + reason: currentRisk + ? `${sizes}. Consistent with ${RISK_LABEL[risk]} — the rating on file stands.` + : `${sizes}. Activity points at ${RISK_LABEL[risk]}; no rating on record to compare against.` + }; } /** Applies every filter and joins each alert to its rule. */ diff --git a/src/app/sponsor-dd/dd-issue-approvals/dd-issue-approvals.component.ts b/src/app/sponsor-dd/dd-issue-approvals/dd-issue-approvals.component.ts index ddfc1d72a..f0f82b719 100644 --- a/src/app/sponsor-dd/dd-issue-approvals/dd-issue-approvals.component.ts +++ b/src/app/sponsor-dd/dd-issue-approvals/dd-issue-approvals.component.ts @@ -240,45 +240,83 @@ export class DdIssueApprovalsComponent implements OnInit, AfterViewInit { this.approveBatch(request); return; } - if ( - !confirm( - `Approve DD issuance for DD No. ${request.ddNumber} (₹${request.faceValue})? This will debit the account (or clear the cheque) and issue the draft.` - ) - ) { - return; + this.dialog + .open(ApproveIssueRequestDialogComponent, { + width: '440px', + maxWidth: '95vw', + data: { + isBatch: false, + ddNumber: request.ddNumber, + beneficiaryName: request.beneficiaryName, + clientName: request.clientName, + totalFaceValue: Number(request.faceValue) || 0, + // Named for how this particular draft is funded, rather than offering the approver every + // possibility and leaving them to work out which one applies. + effect: this.effectTextFor(request) + } + }) + .afterClosed() + .subscribe((confirmed) => { + if (!confirmed) { + return; + } + this.sponsorDDService.approveIssueRequest(request.id).subscribe({ + next: () => { + this.snackBar.open('DD issuance approved and posted successfully', 'Close', { duration: 3000 }); + this.loadRequests(); + }, + error: (err) => { + this.snackBar.open('Error: ' + (err.error?.message || err.message), 'Close', { duration: 6000 }); + } + }); + }); + } + + /** What approving this request actually does, in the words of its own payment method. */ + private effectTextFor(request: any, count = 1): string { + const drafts = count > 1 ? `${count} drafts` : 'the draft'; + const once = count > 1 ? ' once for the combined total' : ''; + switch ((request.paymentMethod || '').toUpperCase()) { + case 'ACCOUNT_DEBIT': + return `The customer's account is debited${once} and ${drafts} ${count > 1 ? 'are' : 'is'} issued. This cannot be undone — a draft issued in error has to be cancelled separately.`; + case 'CHEQUE': + return `The cheque is marked cleared${once} and ${drafts} ${count > 1 ? 'are' : 'is'} issued. This cannot be undone — a draft issued in error has to be cancelled separately.`; + default: + return `The cash collected is posted${once} and ${drafts} ${count > 1 ? 'are' : 'is'} issued. This cannot be undone — a draft issued in error has to be cancelled separately.`; } - this.sponsorDDService.approveIssueRequest(request.id).subscribe({ - next: () => { - this.snackBar.open('DD issuance approved and posted successfully', 'Close', { duration: 3000 }); - this.loadRequests(); - }, - error: (err) => { - this.snackBar.open('Error: ' + (err.error?.message || err.message), 'Close', { duration: 6000 }); - } - }); } private approveBatch(request: any): void { const siblings = this.batchSiblings(request); - const lines = siblings.map((r) => ` • ${r.beneficiaryName} — ₹${r.faceValue}`).join('\n'); const total = siblings.reduce((sum, r) => sum + (Number(r.faceValue) || 0), 0); - if ( - !confirm( - `Approve this DD slip - ${siblings.length} drafts, combined face value ₹${total}?\n${lines}\n\n` + - 'This debits the account (or clears the cheque) ONCE for the combined total and issues every draft in the slip.' - ) - ) { - return; - } - this.sponsorDDService.approveIssueRequestBatch(request.batchId).subscribe({ - next: () => { - this.snackBar.open('DD slip approved and posted successfully', 'Close', { duration: 3000 }); - this.loadRequests(); - }, - error: (err) => { - this.snackBar.open('Error: ' + (err.error?.message || err.message), 'Close', { duration: 6000 }); - } - }); + + this.dialog + .open(ApproveIssueRequestDialogComponent, { + width: '460px', + maxWidth: '95vw', + data: { + isBatch: true, + clientName: request.clientName, + totalFaceValue: total, + lines: siblings.map((r) => ({ beneficiaryName: r.beneficiaryName, faceValue: Number(r.faceValue) || 0 })), + effect: this.effectTextFor(request, siblings.length) + } + }) + .afterClosed() + .subscribe((confirmed) => { + if (!confirmed) { + return; + } + this.sponsorDDService.approveIssueRequestBatch(request.batchId).subscribe({ + next: () => { + this.snackBar.open('DD slip approved and posted successfully', 'Close', { duration: 3000 }); + this.loadRequests(); + }, + error: (err) => { + this.snackBar.open('Error: ' + (err.error?.message || err.message), 'Close', { duration: 6000 }); + } + }); + }); } openRejectDialog(request: any): void { @@ -302,6 +340,123 @@ export class DdIssueApprovalsComponent implements OnInit, AfterViewInit { } } +/** + * What the approver is about to commit, laid out rather than run together in a sentence. + * + * This was a browser `confirm()`, which the browser renders with its own "localhost:4200 says" + * chrome and no formatting — so the one figure that matters, the amount leaving the customer's + * account, sat mid-paragraph beside a parenthetical about cheques that does not apply to most + * drafts. An approval is the point of no return on someone else's money; it should read like one. + */ +@Component({ + selector: 'mifosx-approve-issue-request-dialog', + standalone: true, + imports: [ + CommonModule, + MatDialogModule, + MatButtonModule + ], + template: ` +

+ {{ data.isBatch ? 'Approve DD Slip' : 'Approve DD Issuance' }} +

+ +
+
+ {{ data.isBatch ? 'Drafts' : 'DD Number' }} + {{ data.isBatch ? data.lines.length + ' drafts' : data.ddNumber }} +
+
+ In favour of + {{ data.beneficiaryName || '—' }} +
+
+ Purchaser + {{ data.clientName }} +
+
+ {{ data.isBatch ? 'Combined face value' : 'Face value' }} + ₹{{ data.totalFaceValue | number: '1.2-2' }} +
+
+ + +
    +
  • {{ l.beneficiaryName }} — ₹{{ l.faceValue | number: '1.2-2' }}
  • +
+ +

+ {{ data.effect }} +

+
+ + + + + `, + styles: [ + ` + .approve-title { + margin-bottom: 4px; + } + .approve-summary { + border: 1px solid #e2e8f0; + border-radius: 8px; + padding: 4px 14px; + margin-bottom: 12px; + background: #f8fafc; + } + .approve-row { + display: flex; + justify-content: space-between; + align-items: baseline; + gap: 16px; + padding: 8px 0; + border-bottom: 1px solid #eef2f7; + font-size: 13px; + } + .approve-row:last-child { + border-bottom: none; + } + .approve-label { + color: #64748b; + } + .approve-value { + text-align: right; + } + .approve-amount { + font-size: 17px; + font-weight: 700; + } + .approve-lines { + margin: 0 0 12px; + padding-left: 20px; + font-size: 12.5px; + color: #334155; + max-height: 160px; + overflow-y: auto; + } + .approve-effect { + margin: 0; + padding: 10px 12px; + border-radius: 6px; + background: #fffbeb; + border: 1px solid #fde68a; + color: #92400e; + font-size: 12.5px; + line-height: 1.5; + } + ` + ] +}) +export class ApproveIssueRequestDialogComponent { + dialogRef = inject(MatDialogRef); + data = inject(MAT_DIALOG_DATA); +} + // ── REJECT DIALOG COMPONENT ── @Component({ selector: 'mifosx-reject-issue-request-dialog', diff --git a/src/app/sponsor-dd/dd-list/dd-list.component.html b/src/app/sponsor-dd/dd-list/dd-list.component.html index a7b1fa4ff..1a0b5be09 100644 --- a/src/app/sponsor-dd/dd-list/dd-list.component.html +++ b/src/app/sponsor-dd/dd-list/dd-list.component.html @@ -209,6 +209,31 @@ Reprint Pending Approval (Req #{{ element.pendingReprintRequestId }}) + + + + + Revoke Pending Approval (Req #{{ element.pendingRevokeRequestId }}) + + + + + Revoked → replaced by DD {{ element.revokedReplacedByDdNumber }} + + + Replaces spoiled DD {{ element.revokedReplacementOfDdNumber }} +
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
Req. ID{{ element.id }}Spoiled DD + {{ element.ddNumber }} + Purchaser{{ element.clientName || '—' }}Beneficiary{{ element.beneficiaryName || '—' }}Face Value{{ element.faceValue | number: '1.2-2' }}Reason{{ element.reason }}Replaced By + {{ element.replacementDdNumber }} + — + Status + + {{ element.status }} + + Requested By{{ element.makerUsername }}Requested On{{ element.createdDate | date: 'dd MMM yyyy, HH:mm' }}Actions +
+ + +
+ {{ + element.rejectionReason + }} +
+
by {{ element.checkerUsername }}, {{ element.checkerDate | date: 'dd MMM, HH:mm' }}
+
+
+ +
+ No DD revoke requests match the selected filter. +
+ + + + diff --git a/src/app/sponsor-dd/dd-revoke-approvals/dd-revoke-approvals.component.ts b/src/app/sponsor-dd/dd-revoke-approvals/dd-revoke-approvals.component.ts new file mode 100644 index 000000000..8d20db0e8 --- /dev/null +++ b/src/app/sponsor-dd/dd-revoke-approvals/dd-revoke-approvals.component.ts @@ -0,0 +1,460 @@ +/** + * 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, AfterViewInit, ViewChild, inject, Inject } from '@angular/core'; +import { CommonModule } from '@angular/common'; +import { RouterModule } from '@angular/router'; +import { FormsModule, ReactiveFormsModule, FormBuilder, FormGroup, Validators } from '@angular/forms'; +import { MatCardModule } from '@angular/material/card'; +import { MatTableModule, MatTableDataSource } from '@angular/material/table'; +import { MatPaginatorModule, MatPaginator } from '@angular/material/paginator'; +import { MatButtonModule } from '@angular/material/button'; +import { MatDialog, MatDialogModule, MatDialogRef, MAT_DIALOG_DATA } from '@angular/material/dialog'; +import { MatFormFieldModule } from '@angular/material/form-field'; +import { MatInputModule } from '@angular/material/input'; +import { MatSnackBar, MatSnackBarModule } from '@angular/material/snack-bar'; +import { SponsorDDService } from '../sponsor-dd.service'; + +/** + * The queue for drafts spoiled at the printer. + * + * Kept apart from reprint approvals because the two decisions are not alike. A reprint puts the + * same draft back through the printer on the same leaflet and consumes nothing. Approving a revoke + * spends a blank from a limited pad the sponsor bank sent — so the checker is told which customer's + * draft it is, for how much, and that a fresh leaflet will be taken. + */ +@Component({ + selector: 'mifosx-dd-revoke-approvals', + standalone: true, + imports: [ + CommonModule, + RouterModule, + MatCardModule, + MatTableModule, + MatPaginatorModule, + MatButtonModule, + MatDialogModule, + MatSnackBarModule, + MatFormFieldModule, + MatInputModule + ], + templateUrl: './dd-revoke-approvals.component.html', + styles: [ + ` + .approvals-container { + padding: 24px; + max-width: 1240px; + margin: 0 auto; + } + .header-row { + display: flex; + justify-content: space-between; + align-items: flex-start; + margin-bottom: 24px; + } + .title-area h2 { + margin: 0 0 4px 0; + font-size: 24px; + font-weight: 600; + color: #1e293b; + } + .title-area p { + margin: 0; + color: #64748b; + font-size: 14px; + } + .search-field { + width: 320px; + display: block; + margin-bottom: 8px; + } + .mat-mdc-card { + border-radius: 12px; + border: 1px solid #e2e8f0; + box-shadow: 0 1px 3px rgba(0, 0, 0, 0.05); + overflow: hidden; + } + table { + width: 100%; + } + th { + font-weight: 600; + color: #475569; + background-color: #f8fafc; + } + td { + color: #334155; + } + .status-badge { + display: inline-flex; + align-items: center; + padding: 4px 8px; + border-radius: 9999px; + font-size: 12px; + font-weight: 500; + } + .status-badge.pending { + background-color: #fef3c7; + color: #b45309; + } + .status-badge.approved { + background-color: #dcfce7; + color: #15803d; + } + .status-badge.rejected { + background-color: #fee2e2; + color: #b91c1c; + } + .action-btn-group { + display: flex; + gap: 8px; + } + .status-filter-row { + display: flex; + flex-wrap: wrap; + gap: 8px; + margin-bottom: 16px; + } + .status-filter-row button.filter-active { + background-color: #ede9fe; + } + .filter-count { + color: #64748b; + margin-left: 4px; + } + /* The replacement is the whole point of the decision, so it reads as a number rather than + sitting in the row as ordinary text. */ + .replacement-no { + font-weight: 600; + color: #15803d; + } + .muted { + color: #94a3b8; + } + ` + ] +}) +export class DdRevokeApprovalsComponent implements OnInit, AfterViewInit { + private sponsorDDService = inject(SponsorDDService); + private dialog = inject(MatDialog); + private snackBar = inject(MatSnackBar); + + @ViewChild(MatPaginator) paginator!: MatPaginator; + + displayedColumns: string[] = [ + 'id', + 'ddNumber', + 'clientName', + 'beneficiaryName', + 'faceValue', + 'reason', + 'replacementDdNumber', + 'status', + 'makerUsername', + 'createdDate', + 'actions' + ]; + dataSource = new MatTableDataSource([]); + + readonly statusOptions: string[] = [ + 'PENDING', + 'APPROVED', + 'REJECTED', + 'ALL' + ]; + selectedStatus = 'PENDING'; + searchText = ''; + + ngOnInit(): void { + this.dataSource.filterPredicate = (data) => { + const statusMatches = this.selectedStatus === 'ALL' || data.status === this.selectedStatus; + const needle = this.searchText.trim().toLowerCase(); + const searchMatches = + !needle || + (data.ddNumber || '').toLowerCase().includes(needle) || + (data.clientName || '').toLowerCase().includes(needle); + return statusMatches && searchMatches; + }; + this.loadRequests(); + } + + ngAfterViewInit(): void { + this.dataSource.paginator = this.paginator; + } + + loadRequests(): void { + this.sponsorDDService.getAllRevokeRequests().subscribe({ + next: (data) => { + this.dataSource.data = data || []; + this.applyFilter(); + }, + error: (err) => { + this.snackBar.open('Error loading revoke requests: ' + (err.error?.message || err.message), 'Close', { + duration: 5000 + }); + } + }); + } + + statusCount(status: string): number { + const all = this.dataSource.data; + return status === 'ALL' ? all.length : all.filter((r) => r.status === status).length; + } + + filterByStatus(status: string): void { + this.selectedStatus = status; + this.applyFilter(); + } + + onSearchChange(value: string): void { + this.searchText = value; + this.applyFilter(); + } + + private applyFilter(): void { + this.dataSource.filter = `${this.selectedStatus}|${this.searchText}`; + if (this.dataSource.paginator) { + this.dataSource.paginator.firstPage(); + } + } + + approve(request: any): void { + this.dialog + .open(ApproveRevokeRequestDialogComponent, { + width: '460px', + maxWidth: '95vw', + data: { request }, + disableClose: true + }) + .afterClosed() + .subscribe((confirmed) => { + if (!confirmed) { + return; + } + this.sponsorDDService.approveRevokeRequest(request.id).subscribe({ + next: (res: any) => { + // The replacement number is what the clerk has to write on and hand over, so it is said + // plainly rather than left for them to find by reopening the register. + const replacement = res?.subResourceId ? ` Replacement DD issued — leaflet #${res.subResourceId}.` : ''; + this.snackBar.open(`DD ${request.ddNumber} revoked.${replacement}`, 'Close', { duration: 6000 }); + this.loadRequests(); + }, + error: (err) => { + this.snackBar.open('Error: ' + (err.error?.message || err.message), 'Close', { duration: 7000 }); + } + }); + }); + } + + openRejectDialog(request: any): void { + this.dialog + .open(RejectRevokeRequestDialogComponent, { + width: '450px', + data: { request }, + disableClose: true + }) + .afterClosed() + .subscribe((result) => { + if (result) { + this.loadRequests(); + } + }); + } +} + +/** + * What approving actually costs, laid out before it is done. + * + * A browser `confirm()` would run the customer, the amount and the fact that a blank is consumed + * together in one sentence under the browser's own "localhost says" chrome. This is a decision + * about someone else's draft and about stock that cannot be replaced, so it reads like one. + */ +@Component({ + selector: 'mifosx-approve-revoke-request-dialog', + standalone: true, + imports: [ + CommonModule, + MatDialogModule, + MatButtonModule + ], + template: ` +

Approve DD Revoke

+ +
+
+ Spoiled DD + {{ data.request.ddNumber }} +
+
+ Purchaser + {{ data.request.clientName }} +
+
+ In favour of + {{ data.request.beneficiaryName }} +
+
+ Face value + ₹{{ data.request.faceValue | number: '1.2-2' }} +
+
+ +

Reason given
{{ data.request.reason }}

+ +

+ This marks {{ data.request.ddNumber }} as REVOKED and re-issues the same draft on the next available blank — + preferring this series, then this sponsor bank, then any other. No money moves: the customer was already + debited and is still owed this one draft. +

+
+ + + + + `, + styles: [ + ` + .rv-summary { + border: 1px solid #e2e8f0; + border-radius: 8px; + padding: 12px 14px; + margin-bottom: 14px; + background: #f8fafc; + } + .rv-row { + display: flex; + justify-content: space-between; + align-items: baseline; + gap: 16px; + padding: 4px 0; + } + .rv-row--total { + border-top: 1px solid #e2e8f0; + margin-top: 6px; + padding-top: 8px; + } + .rv-label { + color: #64748b; + font-size: 12.5px; + } + .rv-value { + color: #1e293b; + font-size: 13.5px; + text-align: right; + } + .rv-amount { + color: #1e293b; + font-size: 17px; + font-weight: 700; + } + .rv-reason { + font-size: 13px; + color: #334155; + margin: 0 0 12px; + } + .rv-effect { + font-size: 12.5px; + line-height: 1.55; + color: #92400e; + background: #fffbeb; + border: 1px solid #fde68a; + border-radius: 6px; + padding: 10px 12px; + margin: 0; + } + ` + ] +}) +export class ApproveRevokeRequestDialogComponent { + dialogRef = inject(MatDialogRef); + + constructor(@Inject(MAT_DIALOG_DATA) public data: { request: any }) {} +} + +// ── REJECT DIALOG ── +@Component({ + selector: 'mifosx-reject-revoke-request-dialog', + standalone: true, + imports: [ + CommonModule, + FormsModule, + ReactiveFormsModule, + MatDialogModule, + MatFormFieldModule, + MatInputModule, + MatButtonModule + ], + template: ` +

Reject DD Revoke (DD No: {{ data.request.ddNumber }})

+ +

+ The leaflet stays issued and no replacement is taken. The clerk keeps a draft they say they cannot hand over, + so the reason should tell them what to do instead. +

+
+ + Rejection Reason + + A reason is required + +
+
+ + + + + `, + styles: [ + ` + .full-width { + width: 100%; + } + .hint-text { + font-size: 13px; + color: #64748b; + margin: 0 0 12px; + line-height: 1.5; + } + ` + ] +}) +export class RejectRevokeRequestDialogComponent { + private fb = inject(FormBuilder); + private sponsorDDService = inject(SponsorDDService); + private snackBar = inject(MatSnackBar); + dialogRef = inject(MatDialogRef); + + rejectForm: FormGroup; + loading = false; + + constructor(@Inject(MAT_DIALOG_DATA) public data: { request: any }) { + this.rejectForm = this.fb.group({ + rejectionReason: ['', Validators.required] + }); + } + + onSubmit(): void { + if (this.rejectForm.invalid) { + return; + } + this.loading = true; + this.sponsorDDService.rejectRevokeRequest(this.data.request.id, this.rejectForm.value.rejectionReason).subscribe({ + next: () => { + this.loading = false; + this.snackBar.open('Revoke request rejected', 'Close', { duration: 3000 }); + this.dialogRef.close(true); + }, + error: (err) => { + this.loading = false; + this.snackBar.open('Error: ' + (err.error?.message || err.message), 'Close', { duration: 6000 }); + } + }); + } +} diff --git a/src/app/sponsor-dd/sponsor-dd-routing.module.ts b/src/app/sponsor-dd/sponsor-dd-routing.module.ts index d9d6c3500..4f5609233 100644 --- a/src/app/sponsor-dd/sponsor-dd-routing.module.ts +++ b/src/app/sponsor-dd/sponsor-dd-routing.module.ts @@ -13,6 +13,7 @@ import { DdInventoryListComponent } from './dd-inventory-list/dd-inventory-list. import { DdListComponent } from './dd-list/dd-list.component'; import { DdIssueApprovalsComponent } from './dd-issue-approvals/dd-issue-approvals.component'; import { DdReprintApprovalsComponent } from './dd-reprint-approvals/dd-reprint-approvals.component'; +import { DdRevokeApprovalsComponent } from './dd-revoke-approvals/dd-revoke-approvals.component'; const routes: Routes = [ Route.withShell([ @@ -44,6 +45,11 @@ const routes: Routes = [ path: 'reprint-approvals', component: DdReprintApprovalsComponent, data: { title: 'DD Reprint Approvals', breadcrumb: 'Reprint Approvals' } + }, + { + path: 'revoke-approvals', + component: DdRevokeApprovalsComponent, + data: { title: 'DD Revoke Approvals', breadcrumb: 'Revoke Approvals' } } ] } diff --git a/src/app/sponsor-dd/sponsor-dd.service.ts b/src/app/sponsor-dd/sponsor-dd.service.ts index 7f6e34ff4..707a5a9da 100644 --- a/src/app/sponsor-dd/sponsor-dd.service.ts +++ b/src/app/sponsor-dd/sponsor-dd.service.ts @@ -218,6 +218,27 @@ export class SponsorDDService { return this.http.post(`/sponsordd/reprint-requests/${id}/reject`, { rejectionReason }); } + // ── DD revoke requests (maker-checker) ── + // A leaflet ruined at the printer. Its number can never be handed to anyone, so the draft moves + // onto the next available blank and both leaflets stay on the register — one spoiled, one + // carrying the draft. Distinct from a reprint, which re-prints the same leaflet. + requestRevoke(leafletId: number, reason: string): Observable { + return this.http.post(`/sponsordd/leaflets/${leafletId}/revoke-request`, { reason }); + } + + getAllRevokeRequests(status?: string): Observable { + const params = status ? { status } : {}; + return this.http.get('/sponsordd/revoke-requests', { params }); + } + + approveRevokeRequest(id: number): Observable { + return this.http.post(`/sponsordd/revoke-requests/${id}/approve`, {}); + } + + rejectRevokeRequest(id: number, rejectionReason: string): Observable { + return this.http.post(`/sponsordd/revoke-requests/${id}/reject`, { rejectionReason }); + } + // ── DD amendment requests (maker-checker) ── // Correcting an issued draft's beneficiary, or the branch and city it is payable at. Nothing else // is amendable: the money behind the face value has already moved, so a draft wrong in that way 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 5fab7cf1b..9a5bb2c85 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 @@ -226,6 +226,12 @@ DD Reprint Requests + + + + DD Revoke Requests +
diff --git a/src/app/tasks/tasks-routing.module.ts b/src/app/tasks/tasks-routing.module.ts index f379b5c81..03713aea3 100644 --- a/src/app/tasks/tasks-routing.module.ts +++ b/src/app/tasks/tasks-routing.module.ts @@ -235,6 +235,14 @@ const routes: Routes = [ (m) => m.DdReprintApprovalsComponent ), data: { title: 'DD Reprint Approvals' } + }, + { + path: 'dd-revoke-approvals', + loadComponent: () => + import('../sponsor-dd/dd-revoke-approvals/dd-revoke-approvals.component').then( + (m) => m.DdRevokeApprovalsComponent + ), + data: { title: 'DD Revoke Approvals' } } ] }, diff --git a/src/app/tds-reports/tds-interest-report.component.html b/src/app/tds-reports/tds-interest-report.component.html index 45d79127d..27cd9f12e 100644 --- a/src/app/tds-reports/tds-interest-report.component.html +++ b/src/app/tds-reports/tds-interest-report.component.html @@ -80,7 +80,7 @@
-
Nexora Co-operative Bank Ltd.
-
Mumbai, Maharashtra, India
+
{{ bankName }}
+ @if (bankLocation) { +
{{ bankLocation }}
+ }
SHARE CERTIFICATE
Issued under the Common Seal of the Bank
@@ -51,8 +53,8 @@ {{ sharesAccountData.currentMarketPrice | currency: sharesAccountData.currency.code : 'symbol-narrow' }} - each, fully paid up, in Nexora Co-operative Bank Ltd., subject to - the memorandum, articles of association, and bye-laws of the bank. + each, fully paid up, in {{ bankName }}, subject to the memorandum, articles of association, and bye-laws of the bank.

@@ -75,7 +77,7 @@
Branch{{ clientData?.officeName || 'Mumbai Main Branch' }}{{ clientData?.officeName || 'Head Office' }} Member Since {{ diff --git a/src/app/shares/shares-account-view/share-certificate-dialog/share-certificate-dialog.component.scss b/src/app/shares/shares-account-view/share-certificate-dialog/share-certificate-dialog.component.scss index 97e7362ac..840774130 100644 --- a/src/app/shares/shares-account-view/share-certificate-dialog/share-certificate-dialog.component.scss +++ b/src/app/shares/shares-account-view/share-certificate-dialog/share-certificate-dialog.component.scss @@ -140,6 +140,8 @@ } .meta-value { + font-family: Arial, Helvetica, sans-serif; + font-variant-numeric: tabular-nums; font-size: 15px; font-weight: bold; color: #322f2b; @@ -226,12 +228,19 @@ .lbl-col { background-color: #f7f5ee; + font-family: Georgia, 'Times New Roman', Times, serif; font-weight: bold; color: #7b6f63; width: 25%; } + // Every figure a member actually needs to read at a glance — share counts, amounts, dates, the + // resolution number — sits in this column. Georgia's numerals kept blurring into each other at + // this size, so values get a plain sans-serif with tabular figures instead; the label column + // keeps the certificate's own serif since it's static text, never a number to misread. .val-col { + font-family: Arial, Helvetica, sans-serif; + font-variant-numeric: tabular-nums; color: #322f2b; width: 25%; } @@ -242,6 +251,7 @@ } .lbl-col-highlight { + font-family: Georgia, 'Times New Roman', Times, serif; font-weight: bold; color: #0b3c5d; font-size: 14px; @@ -251,6 +261,8 @@ } .val-col-highlight { + font-family: Arial, Helvetica, sans-serif; + font-variant-numeric: tabular-nums; font-size: 16px; font-weight: bold; color: #b8860b; /* Dark Goldenrod */ @@ -270,6 +282,7 @@ padding: 8px 12px; border: 1px solid #ede8db; background-color: #f7f5ee; + font-family: Georgia, 'Times New Roman', Times, serif; font-size: 11px; font-weight: bold; color: #7b6f63; @@ -277,9 +290,14 @@ letter-spacing: 0.6px; } + // Same reasoning as .val-col above: every cell here is a figure someone has to check against the + // register, so it reads in a plain sans-serif with tabular numerals rather than the certificate's + // decorative serif. td { padding: 9px 12px; border: 1px solid #ede8db; + font-family: Arial, Helvetica, sans-serif; + font-variant-numeric: tabular-nums; font-size: 13px; color: #322f2b; } diff --git a/src/app/shares/shares-account-view/share-certificate-dialog/share-certificate-dialog.component.ts b/src/app/shares/shares-account-view/share-certificate-dialog/share-certificate-dialog.component.ts index 2b4c517b9..61605cc0a 100644 --- a/src/app/shares/shares-account-view/share-certificate-dialog/share-certificate-dialog.component.ts +++ b/src/app/shares/shares-account-view/share-certificate-dialog/share-certificate-dialog.component.ts @@ -16,6 +16,7 @@ import { MatDialogClose } from '@angular/material/dialog'; import { ClientsService } from 'app/clients/clients.service'; +import { OrganizationService } from 'app/organization/organization.service'; import { STANDALONE_SHARED_IMPORTS } from 'app/standalone-shared.module'; import { DateFormatPipe } from '../../../pipes/date-format.pipe'; import { FormatNumberPipe } from '../../../pipes/format-number.pipe'; @@ -44,6 +45,7 @@ export class ShareCertificateDialogComponent implements OnInit { dialogRef = inject>(MatDialogRef); data = inject(MAT_DIALOG_DATA); clientsService = inject(ClientsService); + private organizationService = inject(OrganizationService); sharesAccountData: any; clientId: string; @@ -53,6 +55,11 @@ export class ShareCertificateDialogComponent implements OnInit { panNumber: string = 'Not Available'; memberId: string = ''; + /** The issuing bank's own name/location, as set on the Institution Profile — never a fixed name, + * since this certificate template is shared by every tenant this app runs for. */ + bankName: string = 'Bank'; + bankLocation: string = ''; + certificateNo: string = ''; folioNo: string = ''; shareNumbersRange: string = ''; @@ -155,6 +162,24 @@ export class ShareCertificateDialogComponent implements OnInit { }, error: () => {} }); + + // Fetch the issuing bank's own name/location from the Institution Profile, set once under + // Admin > Organization for whichever bank this instance runs for — never a fixed name baked + // into the certificate itself. + this.organizationService.getInstitutionProfile().subscribe({ + next: (profile: any) => { + this.bankName = profile?.statementHeaderName || profile?.bankName || this.bankName; + this.bankLocation = [ + profile?.city, + profile?.state, + profile?.country + ] + .filter((p) => !!p && String(p).trim()) + .join(', '); + }, + // A missing/incomplete profile must not stop the certificate from rendering. + error: () => {} + }); } /** diff --git a/src/environments/.env.ts b/src/environments/.env.ts index e4d9bc1c4..c3226b95c 100644 --- a/src/environments/.env.ts +++ b/src/environments/.env.ts @@ -10,7 +10,7 @@ export default { 'mifos_x': { 'version': '260914', - 'hash': 'ab58d7f7' + 'hash': 'ee5a430b' }, 'allow_switching_backend_instance': true }; diff --git a/src/main.scss b/src/main.scss index aa0387a44..387d35837 100644 --- a/src/main.scss +++ b/src/main.scss @@ -978,9 +978,18 @@ mat-card { mifosx-breadcrumb, mifosx-footer, .sidebar-panel, + .sidebar-handle, .mat-mdc-tab-header, .mat-stepper-vertical-line, - .mat-step-header { + .mat-step-header, + /* The real 2-row nav menu (Client Master / Approvals / Cashier … and the row below it) and the + live KPI ticker live directly in shell.component.html as .main-header / .kpi-ticker, not + inside — hiding only the toolbar left this menu printing on every page. */ + .main-header, + .kpi-ticker, + .cashier-nav, + .gl-backdrop, + .gl-mega-menu { display: none !important; } @@ -988,6 +997,32 @@ mat-card { margin-left: 0 !important; } + /* The whole app shell scrolls inside CDK's own clipped viewport — + .mat-drawer-container { overflow: hidden } and .mat-drawer-content { overflow: auto; height: 100% } + — so without freeing these too, print is still capped to one screen's worth of the page + regardless of the tab-body fix below. */ + #mifosx-shell-container, + .mat-drawer-container, + .mat-drawer-content, + mat-sidenav-content.sidenav { + position: static !important; + overflow: visible !important; + height: auto !important; + } + + /* Angular Material clips a tab's content to a JS-measured on-screen height via + `.mat-mdc-tab-body-wrapper { overflow: hidden }` (for the tab-switch height animation) and + `.mat-mdc-tab-body-content { height: 100%; overflow: auto }` — printing only what fit that + clip left every account/tab page (Shares Account General included) blank apart from a + stray border. Printing needs the full content, not the animated viewport. */ + .mat-mdc-tab-body-wrapper, + .mat-mdc-tab-body, + .mat-mdc-tab-body-content { + position: static !important; + overflow: visible !important; + height: auto !important; + } + body, html { background: #fff !important; From 0fbf6ec58c4e13bd6882dfbea4f3bffae9c14948 Mon Sep 17 00:00:00 2001 From: LincPay Admin Date: Mon, 14 Sep 2026 11:48:46 +0000 Subject: [PATCH 54/64] Delete .drone.yml --- .drone.yml | 288 ----------------------------------------------------- 1 file changed, 288 deletions(-) delete mode 100644 .drone.yml diff --git a/.drone.yml b/.drone.yml deleted file mode 100644 index 565892284..000000000 --- a/.drone.yml +++ /dev/null @@ -1,288 +0,0 @@ -kind: pipeline -type: docker -name: auto-deploy - -trigger: - branch: - - dev - event: - - push - -steps: - - # ============================================================ - # DOCKER BUILD & PUSH - # - # Angular build (npm ci + ng build) Dockerfile ke andar - # multi-stage me hota hai, isliye alag build step nahi chahiye. - # ============================================================ - - name: docker-build-push - image: docker - - volumes: - - name: dockersock - path: /var/run/docker.sock - - commands: - - | - set -e - - # -------------------------------------------------------- - # Repository name comes from Drone - # -------------------------------------------------------- - REPO="$${DRONE_REPO_NAME}" - REPO="$$(echo "$$REPO" | tr '[:upper:]' '[:lower:]')" - - IMAGE="harbor.lincpay:8082/lincpay/$$REPO:$${DRONE_BUILD_NUMBER}" - - echo "==========================================" - echo "DOCKER BUILD & PUSH" - echo "==========================================" - echo "Repository : $$REPO" - echo "Build : $${DRONE_BUILD_NUMBER}" - echo "Image : $$IMAGE" - echo "==========================================" - - # -------------------------------------------------------- - # Harbor login - # -------------------------------------------------------- - echo 'QVgW5QbqlKKtBU2dYApTY28Dd6J36GK3' | \ - docker login harbor.lincpay:8082 \ - -u 'robot$lincpay+drone' \ - --password-stdin - - # -------------------------------------------------------- - # Build - # -------------------------------------------------------- - echo "Building Docker image..." - - docker build \ - -t "$$IMAGE" \ - . - - # -------------------------------------------------------- - # Push - # -------------------------------------------------------- - echo "Pushing Docker image..." - - docker push "$$IMAGE" - - echo "Docker image pushed successfully." - - - # ============================================================ - # DEPLOY - # ============================================================ - - name: deploy - image: docker - - volumes: - - name: dockersock - path: /var/run/docker.sock - - - name: envfiles - path: /platform-env - - commands: - - | - set -e - - # ======================================================== - # REPOSITORY / SERVICE / IMAGE - # ======================================================== - - REPO="$${DRONE_REPO_NAME}" - REPO="$$(echo "$$REPO" | tr '[:upper:]' '[:lower:]')" - - SERVICE_NAME="$${REPO}_app" - - IMAGE="harbor.lincpay:8082/lincpay/$$REPO:$${DRONE_BUILD_NUMBER}" - - echo "==========================================" - echo "DEPLOYMENT" - echo "==========================================" - echo "Repository : $$REPO" - echo "Service : $$SERVICE_NAME" - echo "Image : $$IMAGE" - echo "Placement : node.labels.environment == dev" - echo "==========================================" - - - # ======================================================== - # VALIDATION - # ======================================================== - - if [ -z "$$REPO" ]; then - echo "ERROR: DRONE_REPO_NAME is empty." - exit 1 - fi - - if [ -z "$$SERVICE_NAME" ]; then - echo "ERROR: Service name is empty." - exit 1 - fi - - if [ -z "$$IMAGE" ]; then - echo "ERROR: Docker image is empty." - exit 1 - fi - - echo "Repository verified : $$REPO" - echo "Service verified : $$SERVICE_NAME" - echo "Image verified : $$IMAGE" - - - # ======================================================== - # EXISTING SERVICE - # ======================================================== - - if docker service ls --format '{{.Name}}' | grep -Fxq "$$SERVICE_NAME"; then - - echo "" - echo "==========================================" - echo "EXISTING SERVICE" - echo "==========================================" - echo "Service: $$SERVICE_NAME" - - - # ------------------------------------------------------ - # Check whether dev constraint already exists - # ------------------------------------------------------ - - CONSTRAINT_EXISTS=$$(docker service inspect "$$SERVICE_NAME" \ - --format '{{range .Spec.TaskTemplate.Placement.Constraints}}{{println .}}{{end}}' \ - | grep -Fx 'node.labels.environment == dev' || true) - - - # ------------------------------------------------------ - # Add constraint if missing - # ------------------------------------------------------ - - if [ -z "$$CONSTRAINT_EXISTS" ]; then - - echo "Constraint missing." - echo "Adding: node.labels.environment == dev" - - docker service update \ - --constraint-add 'node.labels.environment == dev' \ - "$$SERVICE_NAME" - - else - - echo "Constraint already exists." - - fi - - - # ------------------------------------------------------ - # Update image - # ------------------------------------------------------ - - echo "Updating service image..." - - docker service update \ - --image "$$IMAGE" \ - --replicas 1 \ - --with-registry-auth \ - "$$SERVICE_NAME" - - - # ======================================================== - # NEW SERVICE - # ======================================================== - - else - - echo "" - echo "==========================================" - echo "NEW SERVICE" - echo "==========================================" - echo "Creating: $$SERVICE_NAME" - - - # ------------------------------------------------------ - # Traefik rule - # - # Construct it without allowing Drone to consume - # the shell backticks. - # ------------------------------------------------------ - - TRAEFIK_RULE='Host(`'$$REPO'.lincpay`)' - - echo "Traefik rule: $$TRAEFIK_RULE" - - - # ------------------------------------------------------ - # Create service - # ------------------------------------------------------ - - docker service create \ - --name "$$SERVICE_NAME" \ - --network edge-network \ - --replicas 1 \ - --constraint 'node.labels.environment == dev' \ - --with-registry-auth \ - --env-file /platform-env/global.env \ - --env-file /platform-env/dev.env \ - --label traefik.enable=true \ - --label "traefik.docker.network=edge-network" \ - --label "traefik.http.routers.$$REPO.rule=$$TRAEFIK_RULE" \ - --label "traefik.http.services.$$REPO.loadbalancer.server.port=80" \ - "$$IMAGE" - - fi - - - # ======================================================== - # FINAL STATUS - # ======================================================== - - echo "" - echo "==========================================" - echo "FINAL SERVICE STATUS" - echo "==========================================" - - docker service ls \ - --filter "name=$$SERVICE_NAME" - - - echo "" - echo "==========================================" - echo "PLACEMENT CONSTRAINT" - echo "==========================================" - - docker service inspect "$$SERVICE_NAME" \ - --format '{{json .Spec.TaskTemplate.Placement}}' - - - echo "" - echo "==========================================" - echo "SERVICE TASKS" - echo "==========================================" - - docker service ps "$$SERVICE_NAME" --no-trunc - - - echo "" - echo "==========================================" - echo "DEPLOYMENT COMPLETE" - echo "==========================================" - - -volumes: - - # ============================================================ - # DOCKER SOCKET - # ============================================================ - - name: dockersock - host: - path: /var/run/docker.sock - - - # ============================================================ - # ENVIRONMENT FILES - # ============================================================ - - name: envfiles - host: - path: /opt/platform/env - From 0cef1b5a503f60bc142324463113daa23c418c53 Mon Sep 17 00:00:00 2001 From: keshav Date: Wed, 16 Sep 2026 12:51:24 +0530 Subject: [PATCH 55/64] feat: KYC risk profiling, AML flagged transactions bulk review, Risk Review report & Board Resolution register --- src/app/aml/aml.service.ts | 45 ++ .../bor-report.component.html | 147 ++++++ .../bor-report.component.scss | 313 +++++++++++++ .../board-resolution/bor-report.component.ts | 221 +++++++++ .../board-resolution/bor-report.service.ts | 94 ++++ .../kyc-tab/kyc-tab.component.html | 40 ++ .../kyc-tab/kyc-tab.component.scss | 25 + .../clients-view/kyc-tab/kyc-tab.component.ts | 44 +- src/app/core/shell/shell.component.html | 55 +-- src/app/fiu/fiu.component.ts | 24 +- .../aml-bulk-review-dialog.component.ts | 309 ++++++++++++ .../aml-flagged-transactions.component.html | 272 ++++++++++- .../aml-flagged-transactions.component.scss | 367 ++++++++++++++- .../aml-flagged-transactions.component.ts | 438 +++++++++++++++++- .../aml-review-alert-dialog.component.ts | 19 +- src/app/reports/reports-routing.module.ts | 5 + .../risk-change-approvals.component.ts | 390 ++++++++++++++++ .../risk-change-dialog.component.ts | 222 +++++++++ src/app/risk-review/risk-review-data.ts | 24 +- .../risk-review-report.component.html | 39 +- .../risk-review-report.component.scss | 7 + .../risk-review-report.component.ts | 250 ++++++++-- src/app/risk-review/risk-review.compute.ts | 102 +++- .../checker-inbox-and-tasks.component.html | 6 + src/app/tasks/tasks-routing.module.ts | 6 + src/environments/.env.ts | 4 +- 26 files changed, 3309 insertions(+), 159 deletions(-) create mode 100644 src/app/board-resolution/bor-report.component.html create mode 100644 src/app/board-resolution/bor-report.component.scss create mode 100644 src/app/board-resolution/bor-report.component.ts create mode 100644 src/app/board-resolution/bor-report.service.ts create mode 100644 src/app/rbi-circulars/aml-rules/aml-flagged-transactions/aml-bulk-review-dialog.component.ts create mode 100644 src/app/risk-review/risk-change-approvals.component.ts create mode 100644 src/app/risk-review/risk-change-dialog.component.ts diff --git a/src/app/aml/aml.service.ts b/src/app/aml/aml.service.ts index 76bbc1905..047fed18c 100644 --- a/src/app/aml/aml.service.ts +++ b/src/app/aml/aml.service.ts @@ -34,6 +34,17 @@ export class AmlService { return this.http.post(`/aml/alerts/${alertId}/review`, { disposition, notes }); } + /** + * Decides named rules within one alert, leaving its other rules open. + * + *

+ * The alert's own status follows from what is left undecided, so it is not sent: whitelisting five of six rules + * leaves the alert open on the sixth. + */ + reviewAlertRules(alertId: number, ruleCodes: string[], disposition: string, notes: string): Observable { + return this.http.post(`/aml/alerts/${alertId}/review`, { disposition, notes, ruleCodes }); + } + // ═══ Recurring-alert review queue ═══ /** @@ -141,4 +152,38 @@ export class AmlService { getRiskCategoryHistory(clientId: number): Observable { return this.http.get(`/clients/${clientId}/risk-category-history`); } + + // ═══ Risk-category changes (maker-checker) ═══ + // + // The rating decides how often a customer is re-KYC'd and what AML thresholds apply to them, so + // it moves only once a checker agrees rather than on one officer's say-so. + + /** @param requestedCategory 1 Low, 2 Medium, 3 High */ + requestRiskChange(clientId: number, requestedCategory: number, reason: string): Observable { + return this.http.post('/aml/risk-change-requests', { clientId, requestedCategory, reason }); + } + + getRiskChangeRequests(status?: string): Observable { + const params = status ? new HttpParams().set('status', status) : undefined; + return this.http.get('/aml/risk-change-requests', { params }); + } + + approveRiskChange(requestId: number): Observable { + return this.http.post(`/aml/risk-change-requests/${requestId}/approve`, {}); + } + + rejectRiskChange(requestId: number, rejectionReason: string): Observable { + return this.http.post(`/aml/risk-change-requests/${requestId}/reject`, { rejectionReason }); + } + + /** + * The latest behaviour window for each customer, as the nightly job computed it. + * + * One row per customer carrying the review period's figures and the rating they suggest. The Risk Review report + * prefers these over its own per-alert reckoning, because a rating describes a period rather than the handful of + * transactions that happened to trip a rule. + */ + getBehaviourWindows(): Observable { + return this.http.get('/risk-review/behaviour-windows'); + } } diff --git a/src/app/board-resolution/bor-report.component.html b/src/app/board-resolution/bor-report.component.html new file mode 100644 index 000000000..ec7b8bf32 --- /dev/null +++ b/src/app/board-resolution/bor-report.component.html @@ -0,0 +1,147 @@ + +

+
+
+ +
+

Board of Resolution Register — Share Capital

+

+ Every share account with the board resolution that authorised the allotment. Accounts with no resolution on + record sort to the top. +

+
+
+ +
+ + @if (loading) { +
Loading the register…
+ } @else if (loadError) { +
{{ loadError }}
+ } + + @if (!loading && !loadError) { + + @if (missingResolutionCount > 0) { +
+ + {{ missingResolutionCount }} share account{{ missingResolutionCount === 1 ? '' : 's' }} carr{{ + missingResolutionCount === 1 ? 'ies' : 'y' + }} + no resolution number. Share capital allotted without a board resolution cannot be evidenced to the Registrar. +
+ } + +
+
+
{{ filtered.length | number }}
+
Share Accounts
+
+
+
{{ distinctResolutions | number }}
+
Resolutions
+
+
+
{{ totalShares | number }}
+
Shares Allotted
+
+
+
₹{{ totalValue | number: '1.0-0' }}
+
Share Value
+
+
+ +
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+ + +
+ +
+ + + + + + + + + + + + + + + + + + + @for (r of filtered; track r.shareAccountId) { + + + + + + + + + + + + + + + } @empty { + + + + } + +
Resolution No.Resolution DateShare A/c No.CIF No.Member NameBranchProductSharesShare ValueTranchesStatusActivated On
+ @if (r.resolutionNumber) { + {{ r.resolutionNumber }} + } @else { + Not recorded + } + {{ r.resolutionDate || '—' }}{{ r.accountNo }}{{ r.cifNumber || '—' }}{{ r.clientName }}{{ r.officeName || '—' }}{{ r.productName || '—' }}{{ r.approvedShares | number }}{{ r.shareValue | number: '1.2-2' }}{{ r.trancheCount }}{{ statusText(r.statusEnum) }}{{ r.activatedDate || '—' }}
No share account matches the filters.
+
+ } +
diff --git a/src/app/board-resolution/bor-report.component.scss b/src/app/board-resolution/bor-report.component.scss new file mode 100644 index 000000000..a21ca4608 --- /dev/null +++ b/src/app/board-resolution/bor-report.component.scss @@ -0,0 +1,313 @@ +/** + * 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/. + */ + +.bor-root { + padding: 20px 24px 40px; + background: #f8fafc; + min-height: 100%; +} + +.bor-head { + display: flex; + align-items: flex-start; + justify-content: space-between; + gap: 16px; + margin-bottom: 18px; +} + +.bor-head-left { + display: flex; + align-items: flex-start; + gap: 12px; +} + +.bor-head-ic { + font-size: 26px; + color: #0f4f3c; + margin-top: 2px; +} + +.bor-head-t { + margin: 0 0 3px; + font-size: 20px; + font-weight: 700; + color: #1e293b; +} + +.bor-head-s { + margin: 0; + max-width: 780px; + font-size: 13px; + line-height: 1.5; + color: #64748b; +} + +.bor-note { + display: flex; + align-items: flex-start; + gap: 9px; + padding: 11px 14px; + margin-bottom: 14px; + border: 1px solid #bfdbfe; + border-radius: 9px; + background: #eff6ff; + color: #1e3a8a; + font-size: 12.5px; + line-height: 1.55; + + fa-icon { + flex: 0 0 auto; + margin-top: 1px; + } + + /* An allotment with no resolution behind it is the finding, not a detail. */ + &--warn { + border-color: #fde68a; + background: #fffbeb; + color: #92400e; + + fa-icon { + color: #d97706; + } + } +} + +.bor-kpis { + display: grid; + grid-template-columns: repeat(auto-fit, minmax(180px, 1fr)); + gap: 12px; + margin-bottom: 16px; +} + +.bor-kpi { + background: #fff; + border: 1px solid #e2e8f0; + border-left: 4px solid #0f4f3c; + border-radius: 11px; + padding: 12px 16px; + + &--blue { + border-left-color: #2563eb; + } + + &--green { + border-left-color: #15803d; + } + + &--amber { + border-left-color: #d97706; + } +} + +.bor-kpi-n { + font-size: 22px; + font-weight: 700; + color: #1e293b; + line-height: 1.2; +} + +.bor-kpi-l { + font-size: 12px; + color: #64748b; + margin-top: 2px; +} + +.bor-filters { + display: flex; + flex-wrap: wrap; + align-items: flex-end; + gap: 12px; + padding: 14px 16px; + margin-bottom: 14px; + background: #fff; + border: 1px solid #e2e8f0; + border-radius: 11px; +} + +.bor-filter { + display: flex; + flex-direction: column; + gap: 4px; + + label { + font-size: 10.5px; + font-weight: 700; + letter-spacing: 0.04em; + text-transform: uppercase; + color: #64748b; + } + + &--grow { + flex: 1 1 240px; + } +} + +.bor-inp { + height: 34px; + min-width: 170px; + padding: 0 10px; + border: 1px solid #cbd5e1; + border-radius: 7px; + font: inherit; + font-size: 13px; + color: #1e293b; + background: #fff; + + &:focus { + outline: 2px solid #2563eb; + outline-offset: -1px; + } +} + +.bor-check { + display: flex; + align-items: center; + gap: 6px; + height: 34px; + font-size: 12.5px; + color: #334155; + cursor: pointer; +} + +.bor-btn { + display: inline-flex; + align-items: center; + gap: 7px; + height: 34px; + padding: 0 14px; + border: 1px solid #cbd5e1; + border-radius: 7px; + background: #fff; + font: inherit; + font-size: 12.5px; + font-weight: 600; + color: #334155; + cursor: pointer; + + &:hover:not(:disabled) { + background: #f1f5f9; + } + + &:disabled { + opacity: 0.55; + cursor: default; + } + + &--xls { + border-color: #15803d; + background: #16a34a; + color: #fff; + + &:hover:not(:disabled) { + background: #15803d; + } + } +} + +.bor-table-wrap { + background: #fff; + border: 1px solid #e2e8f0; + border-radius: 11px; + overflow-x: auto; +} + +.bor-table { + width: 100%; + border-collapse: collapse; + font-size: 12.5px; + + th { + text-align: left; + padding: 10px; + background: #f1f5f9; + font-size: 10.5px; + font-weight: 700; + text-transform: uppercase; + letter-spacing: 0.04em; + color: #475569; + white-space: nowrap; + border-bottom: 1px solid #e2e8f0; + } + + td { + padding: 11px 10px; + border-bottom: 1px solid #f1f5f9; + vertical-align: middle; + color: #334155; + } + + .num { + text-align: right; + font-family: 'DM Mono', monospace; + } + + .mono { + font-family: 'DM Mono', monospace; + } + + .strong { + font-weight: 700; + } + + tbody tr:hover { + background: #f8fafc; + } +} + +/* The row an auditor is looking for, marked in the table as well as counted above it. */ +.bor-row--missing { + background: #fffbeb; +} + +.bor-res { + color: #0f4f3c; + font-family: 'DM Mono', monospace; +} + +.bor-missing { + display: inline-block; + padding: 2px 8px; + border-radius: 999px; + background: #fef3c7; + color: #92400e; + font-size: 11px; + font-weight: 700; +} + +.bor-status { + display: inline-block; + padding: 2px 9px; + border-radius: 999px; + font-size: 11px; + font-weight: 700; + background: #f1f5f9; + color: #475569; + + &--active { + background: #dcfce7; + color: #15803d; + } + + &--submitted, + &--approved { + background: #fef3c7; + color: #b45309; + } + + &--rejected, + &--closed { + background: #fee2e2; + color: #b91c1c; + } +} + +.bor-empty { + padding: 30px; + text-align: center; + color: #94a3b8; +} diff --git a/src/app/board-resolution/bor-report.component.ts b/src/app/board-resolution/bor-report.component.ts new file mode 100644 index 000000000..776a4ad40 --- /dev/null +++ b/src/app/board-resolution/bor-report.component.ts @@ -0,0 +1,221 @@ +/** + * 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 { exportWorkbook } from '../cashier/cashier-export'; +import { BorRegisterRow, BorReportService } from './bor-report.service'; + +/** m_share_account.status_enum, as the register labels it. */ +const STATUS_LABEL: Record = { + 100: 'Submitted', + 200: 'Approved', + 300: 'Active', + 400: 'Rejected', + 500: 'Closed', + 600: 'Inactive' +}; + +/** + * The Board of Resolution register for share capital. + * + * A co-operative allots shares only under a board resolution, and this is the list the Registrar or + * an auditor asks for: every member's holding against the resolution that authorised it. The + * account with no resolution recorded is the point of the register, so it sorts to the top and is + * counted on its own rather than being left to be noticed. + */ +@Component({ + selector: 'mifosx-bor-report', + templateUrl: './bor-report.component.html', + styleUrls: ['./bor-report.component.scss'], + imports: [ + ...STANDALONE_SHARED_IMPORTS, + FaIconComponent, + FormsModule + ] +}) +export class BorReportComponent implements OnInit { + private borService = inject(BorReportService); + + readonly statusLabel = STATUS_LABEL; + + rows: BorRegisterRow[] = []; + loading = false; + loadError = ''; + + /** '' = every status. */ + filterStatus: number | '' = ''; + filterBranch = ''; + filterResolution = ''; + search = ''; + /** Only the accounts with no resolution on file — the ones that need chasing. */ + missingOnly = false; + + exporting = false; + + ngOnInit(): void { + this.load(); + } + + load(): void { + this.loading = true; + this.loadError = ''; + this.borService.getRegister().subscribe({ + next: (rows) => { + this.loading = false; + this.rows = rows; + }, + error: (err: any) => { + this.loading = false; + this.rows = []; + this.loadError = err?.error?.defaultUserMessage || 'Could not load the BOR register.'; + } + }); + } + + /** Branches present in the data, so the filter offers only what can actually be selected. */ + get branches(): string[] { + return [...new Set(this.rows.map((r) => r.officeName).filter(Boolean))].sort(); + } + + get statuses(): number[] { + return [...new Set(this.rows.map((r) => r.statusEnum))].sort((a, b) => a - b); + } + + get filtered(): BorRegisterRow[] { + const needle = this.search.trim().toLowerCase(); + const resolutionNeedle = this.filterResolution.trim().toLowerCase(); + return this.rows.filter((r) => { + if (this.filterStatus !== '' && r.statusEnum !== this.filterStatus) { + return false; + } + if (this.filterBranch && r.officeName !== this.filterBranch) { + return false; + } + if (this.missingOnly && r.resolutionNumber) { + return false; + } + if (resolutionNeedle && !r.resolutionNumber.toLowerCase().includes(resolutionNeedle)) { + return false; + } + if ( + needle && + !( + r.clientName.toLowerCase().includes(needle) || + r.accountNo.toLowerCase().includes(needle) || + r.cifNumber.toLowerCase().includes(needle) + ) + ) { + return false; + } + return true; + }); + } + + get totalShares(): number { + return this.filtered.reduce((s, r) => s + r.approvedShares, 0); + } + + get totalValue(): number { + return this.filtered.reduce((s, r) => s + r.shareValue, 0); + } + + /** How many resolutions the filtered holdings were authorised under. */ + get distinctResolutions(): number { + return new Set(this.filtered.map((r) => r.resolutionNumber).filter(Boolean)).size; + } + + get missingResolutionCount(): number { + return this.rows.filter((r) => !r.resolutionNumber).length; + } + + statusText(statusEnum: number): string { + return STATUS_LABEL[statusEnum] || '—'; + } + + statusClass(statusEnum: number): string { + return 'bor-status bor-status--' + (STATUS_LABEL[statusEnum] || 'unknown').toLowerCase(); + } + + resetFilters(): void { + this.filterStatus = ''; + this.filterBranch = ''; + this.filterResolution = ''; + this.search = ''; + this.missingOnly = false; + } + + async exportExcel(): Promise { + this.exporting = true; + try { + const rows = this.filtered; + await exportWorkbook( + [ + { + sheetName: 'BOR Register', + title: 'Board of Resolution Register — Share Capital', + subtitle: `${rows.length} share account(s) · ${this.distinctResolutions} resolution(s) · ${this.totalShares} shares`, + headers: [ + 'Resolution No.', + 'Resolution Date', + 'Share A/c No.', + 'CIF No.', + 'Member Name', + 'Branch', + 'Product', + 'Shares', + 'Unit Price', + 'Share Value', + 'Tranches', + 'Status', + 'Activated On' + ], + widths: [ + 20, + 16, + 18, + 16, + 30, + 18, + 18, + 10, + 12, + 14, + 10, + 12, + 14 + ], + rows: rows.map((r) => [ + // Named rather than left blank, because an empty cell in a filing reads as an + // oversight in the export rather than a gap in the register. + r.resolutionNumber || 'NOT RECORDED', + r.resolutionDate || '—', + r.accountNo, + r.cifNumber, + r.clientName, + r.officeName, + r.productName, + r.approvedShares, + r.unitPrice, + r.shareValue, + r.trancheCount, + this.statusText(r.statusEnum), + r.activatedDate || '—' + ]) + } + ], + 'BOR-Register' + ); + } finally { + this.exporting = false; + } + } +} diff --git a/src/app/board-resolution/bor-report.service.ts b/src/app/board-resolution/bor-report.service.ts new file mode 100644 index 000000000..b75370e8c --- /dev/null +++ b/src/app/board-resolution/bor-report.service.ts @@ -0,0 +1,94 @@ +/** + * Copyright since 2025 Mifos Initiative + * + * This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. + */ + +/** Angular Imports */ +import { Injectable, inject } from '@angular/core'; +import { HttpClient, HttpParams } from '@angular/common/http'; +import { Observable, map } from 'rxjs'; + +/** Custom Helpers */ +import { toIsoDate } from '../core/utils/api-date'; + +/** One share account and the board resolution that authorised it. */ +export interface BorRegisterRow { + shareAccountId: number; + accountNo: string; + clientId: number; + cifNumber: string; + clientName: string; + officeId: number | null; + officeName: string; + productName: string; + currencyCode: string; + statusEnum: number; + approvedShares: number; + pendingShares: number; + unitPrice: number; + shareValue: number; + resolutionNumber: string; + /** ISO date, or '' where no meeting date was ever recorded against the allotment. */ + resolutionDate: string; + /** How many purchase tranches make up the holding. */ + trancheCount: number; + submittedDate: string; + approvedDate: string; + activatedDate: string; + closedDate: string; +} + +/** + * The BOR register. + * + * One call returns every account with its resolution. The share allotment report next door fetches + * each account separately and stops at two hundred, which is a limit on a register rather than on a + * page of one. + */ +@Injectable({ providedIn: 'root' }) +export class BorReportService { + private http = inject(HttpClient); + + getRegister(status?: number, officeId?: number): Observable { + let params = new HttpParams(); + if (status != null) { + params = params.set('status', String(status)); + } + if (officeId != null) { + params = params.set('officeId', String(officeId)); + } + return this.http + .get('/shareaccounts/bor-register', { params }) + .pipe(map((rows) => (rows || []).map((r) => this.toRow(r)))); + } + + /** Dates arrive as [y, M, d] arrays; flattened to ISO so they sort and filter as strings. */ + private toRow(r: any): BorRegisterRow { + return { + shareAccountId: r.shareAccountId, + accountNo: r.accountNo || '', + clientId: r.clientId, + cifNumber: r.cifNumber || '', + clientName: r.clientName || '', + officeId: r.officeId ?? null, + officeName: r.officeName || '', + productName: r.productName || '', + currencyCode: r.currencyCode || 'INR', + statusEnum: Number(r.statusEnum) || 0, + approvedShares: Number(r.approvedShares) || 0, + pendingShares: Number(r.pendingShares) || 0, + unitPrice: Number(r.unitPrice) || 0, + shareValue: Number(r.shareValue) || 0, + resolutionNumber: (r.resolutionNumber || '').trim(), + resolutionDate: toIsoDate(r.resolutionDate), + trancheCount: Number(r.trancheCount) || 0, + submittedDate: toIsoDate(r.submittedDate), + approvedDate: toIsoDate(r.approvedDate), + activatedDate: toIsoDate(r.activatedDate), + closedDate: toIsoDate(r.closedDate) + }; + } +} diff --git a/src/app/clients/clients-view/kyc-tab/kyc-tab.component.html b/src/app/clients/clients-view/kyc-tab/kyc-tab.component.html index dd1e065c3..44b97a309 100644 --- a/src/app/clients/clients-view/kyc-tab/kyc-tab.component.html +++ b/src/app/clients/clients-view/kyc-tab/kyc-tab.component.html @@ -1142,6 +1142,46 @@ + +
+
+

Risk Profile

+ Used by the periodic risk review +
+ +
+ + Politically Exposed Person + Holds the rating at High; never lowered on ordinary activity + + + Sanctions / watch-list match + UNSC, MHA or internal watch list + + + Adverse media / regulatory action + FIR, court proceedings or published action + +
+ +
+ + Declared annual income (₹) + + As stated by the customer + + + + Declared annual turnover (₹) + + For a business customer + +
+
+ diff --git a/src/app/clients/clients-view/kyc-tab/kyc-tab.component.scss b/src/app/clients/clients-view/kyc-tab/kyc-tab.component.scss index 0f4520920..032060e25 100644 --- a/src/app/clients/clients-view/kyc-tab/kyc-tab.component.scss +++ b/src/app/clients/clients-view/kyc-tab/kyc-tab.component.scss @@ -1807,3 +1807,28 @@ color: #15803d; } } + +/* ── Risk profile ──────────────────────────────────────────────────────────── + Determinations about the customer, which the periodic risk review reads directly. Grouped with + the document block so the form reads as "what we know about them" rather than scattering a PEP + flag among the identifier fields. */ + +.kyc-doc-fields__hint { + font-size: 12px; + color: #64748b; +} + +.kyc-risk-flags { + display: flex; + flex-direction: column; + gap: 10px; + padding: 4px 0 14px; +} + +/* Sits under its checkbox label: the consequence of ticking the box matters more than the box. */ +.kyc-risk-hint { + display: block; + font-size: 12px; + color: #64748b; + line-height: 1.4; +} diff --git a/src/app/clients/clients-view/kyc-tab/kyc-tab.component.ts b/src/app/clients/clients-view/kyc-tab/kyc-tab.component.ts index ecfdd0210..84743bb5b 100644 --- a/src/app/clients/clients-view/kyc-tab/kyc-tab.component.ts +++ b/src/app/clients/clients-view/kyc-tab/kyc-tab.component.ts @@ -140,6 +140,13 @@ export class KycTabComponent implements OnInit, OnDestroy { 1, [Validators.required] ], + // Risk profile. Determinations about the customer, which the periodic risk review reads directly - a PEP or a + // sanctions match holds the rating at High whatever the period's activity looks like. + isPep: [false], + sanctionsMatch: [false], + adverseMediaMatch: [false], + declaredAnnualIncome: [null], + declaredAnnualTurnover: [null], // The officially valid document, so a lapsed one can be re-collected on this form. The // backend has always accepted these four; only the fields were missing. kycDocumentName: [''], @@ -221,6 +228,11 @@ export class KycTabComponent implements OnInit, OnDestroy { this.kycForm.patchValue({ kycType: data.kycType || 1, riskCategory: data.riskCategory || 1, + isPep: !!data.isPep, + sanctionsMatch: !!data.sanctionsMatch, + adverseMediaMatch: !!data.adverseMediaMatch, + declaredAnnualIncome: data.declaredAnnualIncome ?? null, + declaredAnnualTurnover: data.declaredAnnualTurnover ?? null, panNumber: data.panNumber || '', // Aadhaar is stored masked on the server; user must re-enter // the full 12-digit number to re-verify, so don't pre-fill. @@ -446,6 +458,28 @@ export class KycTabComponent implements OnInit, OnDestroy { return new Date(); } + /** + * The customer's risk profile, as every save has to carry it. + * + *

+ * Sent from every path that writes the KYC record rather than only the main Save, because the form holds one set of + * values: a save triggered from the PAN declaration or the Aadhaar receipt would otherwise write the record without + * these and silently clear a PEP flag the officer had just ticked. + */ + private buildRiskProfileFields() { + const v = this.kycForm.value; + return { + isPep: !!v.isPep, + sanctionsMatch: !!v.sanctionsMatch, + adverseMediaMatch: !!v.adverseMediaMatch, + // Blank stays null rather than becoming zero: an income nobody asked for is unknown, and a zero would read as a + // customer who declared no income at all. + declaredAnnualIncome: v.declaredAnnualIncome === '' || v.declaredAnnualIncome == null ? null : v.declaredAnnualIncome, + declaredAnnualTurnover: + v.declaredAnnualTurnover === '' || v.declaredAnnualTurnover == null ? null : v.declaredAnnualTurnover + }; + } + private buildExceptionFields() { const panVerified = this.verificationStatus.pan === 'VERIFIED' || this.kycData?.panVerified === true; const aadhaarVerified = this.verificationStatus.aadhaar === 'VERIFIED' || this.kycData?.aadhaarVerified === true; @@ -484,7 +518,8 @@ export class KycTabComponent implements OnInit, OnDestroy { // dateFormat is required so the backend can parse exceptionResolvedOn when an exception closes. dateFormat: 'dd MMMM yyyy', ...this.buildDocumentFields(), - ...this.buildExceptionFields() + ...this.buildRiskProfileFields(), + ...this.buildExceptionFields() }; if (!this.kycId) { @@ -530,6 +565,7 @@ export class KycTabComponent implements OnInit, OnDestroy { remarks: this.kycForm.value.remarks || null, riskCategory: this.kycForm.value.riskCategory || 1, dateFormat: 'dd MMMM yyyy', + ...this.buildRiskProfileFields(), ...this.buildExceptionFields() }; @@ -729,7 +765,8 @@ export class KycTabComponent implements OnInit, OnDestroy { dateFormat, riskCategory: this.kycForm.value.riskCategory || 1, ...declarationFields, - ...this.buildExceptionFields() + ...this.buildRiskProfileFields(), + ...this.buildExceptionFields() }; this.clientsService @@ -889,7 +926,8 @@ export class KycTabComponent implements OnInit, OnDestroy { dateFormat, riskCategory: this.kycForm.value.riskCategory || 1, ...receiptFields, - ...this.buildExceptionFields() + ...this.buildRiskProfileFields(), + ...this.buildExceptionFields() }; this.clientsService diff --git a/src/app/core/shell/shell.component.html b/src/app/core/shell/shell.component.html index cf61082bd..7d8c63ad2 100644 --- a/src/app/core/shell/shell.component.html +++ b/src/app/core/shell/shell.component.html @@ -310,41 +310,6 @@ } } - - - - CASH₹14,82,500 - │ - - TXN TODAY - 1,247 - ↑ 8.2% - - │ - CLEARING IN42 - │ - CLEARING OUT38 - │ - - NPA·GROSS - 3.47% - ↑ 0.12 - - │ - CASA58.2% - │ - CD RATIO71.8% - │ - - RBI CIRC - DBR.AML.NO.4/14.01.001 - NEW - - │ - REPO6.50% - │ - - -

- - - LIVE - -
- - - -
-
+ } @@ -713,6 +671,9 @@ + +
+
Evidence relied on
+
+ {{ t }} +
+

Tick at least one piece of evidence

+ + + Reference (optional) + + +
+ + + {{ isWhitelist ? 'Evidence detail' : 'What makes these suspicious?' }} + + A reason is required + + + +

+ {{ + isWhitelist + ? 'Cleared as false positives — no report is filed, and the evidence is retained against each alert.' + : 'Each alert opens its own STR case for the Principal Officer.' + }} +

+ +

{{ errorMessage }}

+ + + + + + `, + styles: [ + ` + .bk-intro { + font-size: 13px; + color: #64748b; + margin: 0 0 12px; + line-height: 1.5; + } + .bk-list { + max-height: 190px; + overflow-y: auto; + border: 1px solid #e2e8f0; + border-radius: 8px; + margin-bottom: 16px; + background: #f8fafc; + } + .bk-row { + display: grid; + grid-template-columns: 1.1fr 1.4fr 1.6fr 0.9fr; + gap: 10px; + padding: 8px 12px; + font-size: 12px; + border-bottom: 1px solid #eef2f7; + } + .bk-row:last-child { + border-bottom: none; + } + .bk-txn { + font-family: 'DM Mono', monospace; + color: #334155; + } + .bk-client { + color: #1e293b; + } + .bk-rule { + color: #7c3aed; + font-size: 11px; + } + .bk-amt { + text-align: right; + font-family: 'DM Mono', monospace; + color: #1e293b; + } + .bk-label { + font-size: 11px; + font-weight: 700; + text-transform: uppercase; + letter-spacing: 0.04em; + color: #64748b; + margin-bottom: 6px; + } + .bk-checks { + display: flex; + flex-direction: column; + gap: 2px; + margin-bottom: 10px; + } + .bk-full { + width: 100%; + } + .bk-effect { + font-size: 12.5px; + line-height: 1.5; + color: #15803d; + background: #f0fdf4; + border: 1px solid #bbf7d0; + border-radius: 6px; + padding: 10px 12px; + margin: 4px 0 0; + } + .bk-effect--warn { + color: #b91c1c; + background: #fef2f2; + border-color: #fecaca; + } + .bk-err { + margin: 10px 0 0; + color: #b91c1c; + font-size: 12.5px; + } + ` + ] +}) +export class AmlBulkReviewDialogComponent { + private fb = inject(FormBuilder); + private amlService = inject(AmlService); + dialogRef = inject(MatDialogRef); + + readonly evidenceTypes = [ + 'Customer explanation obtained', + 'Source of funds documented', + 'KYC / identity re-verified', + 'Consistent with declared income or turnover', + 'Established business or salary pattern', + 'Duplicate or system-generated alert', + 'Other' + ]; + + form: FormGroup; + submitting = false; + errorMessage = ''; + + constructor(@Inject(MAT_DIALOG_DATA) public data: AmlBulkReviewData) { + this.form = this.fb.group({ + evidence: this.fb.array(this.evidenceTypes.map(() => false)), + evidenceRef: [ + '', + Validators.maxLength(100) + ], + notes: [ + '', + [ + Validators.required, + Validators.maxLength(800) + ] + ] + }); + } + + get isWhitelist(): boolean { + return this.data.disposition === 'WHITELIST'; + } + + get evidenceArray(): FormArray { + return this.form.get('evidence') as FormArray; + } + + get selectedEvidence(): string[] { + return this.evidenceTypes.filter((_, i) => this.evidenceArray.at(i)?.value === true); + } + + get evidenceMissing(): boolean { + return this.isWhitelist && this.evidenceArray.touched && this.selectedEvidence.length === 0; + } + + /** Exactly what is written to each alert, so the record reads the same as a single review. */ + private composedNotes(): string { + const v = this.form.value; + const detail = (v.notes || '').trim(); + if (!this.isWhitelist) { + return detail ? `Grounds: ${detail}` : ''; + } + const parts: string[] = []; + if (this.selectedEvidence.length) { + parts.push(`Evidence: ${this.selectedEvidence.join('; ')}`); + } + if (v.evidenceRef) { + parts.push(`Ref: ${(v.evidenceRef || '').trim()}`); + } + const head = parts.join(' | '); + const composed = head && detail ? `${head} — ${detail}` : head || detail; + // Batch note, so each alert says it was disposed of alongside others rather than on its own. + return `${composed} [batch of ${this.data.alerts.length}]`.slice(0, 1000); + } + + submit(): void { + this.evidenceArray.markAllAsTouched(); + if (this.form.invalid || (this.isWhitelist && this.selectedEvidence.length === 0)) { + this.form.markAllAsTouched(); + return; + } + + this.submitting = true; + this.errorMessage = ''; + const notes = this.composedNotes(); + + // Each alert is reviewed on its own call: one that fails - already disposed of by someone else, + // say - must not take the rest of the batch with it. Failures are counted and reported. + forkJoin( + this.data.alerts.map((a) => + this.amlService + .reviewAlert(a.id, this.data.disposition, notes) + .pipe(catchError(() => of({ __failed: true, id: a.id }))) + ) + ).subscribe((results: any[]) => { + this.submitting = false; + const failed = results.filter((r) => r?.__failed).length; + if (failed === results.length) { + this.errorMessage = 'None of the alerts could be updated. They may already have been reviewed.'; + return; + } + if (failed > 0) { + this.errorMessage = `${results.length - failed} updated, ${failed} could not be — those may already have been reviewed.`; + // Still closes as done: most of the batch landed, and the list behind must refresh. + setTimeout(() => this.dialogRef.close(true), 2200); + return; + } + this.dialogRef.close(true); + }); + } +} diff --git a/src/app/rbi-circulars/aml-rules/aml-flagged-transactions/aml-flagged-transactions.component.html b/src/app/rbi-circulars/aml-rules/aml-flagged-transactions/aml-flagged-transactions.component.html index 4470f0f0d..d566f2142 100644 --- a/src/app/rbi-circulars/aml-rules/aml-flagged-transactions/aml-flagged-transactions.component.html +++ b/src/app/rbi-circulars/aml-rules/aml-flagged-transactions/aml-flagged-transactions.component.html @@ -60,23 +60,113 @@ - +
-
+
-
+ +
-
+ +
+
- + +@if (selectedCount > 0) { +
+ {{ selectedCount }} selected + Low / medium only — a transaction that tripped a high-severity rule is decided on its own. + + + + +
+} + + +
+ + + + + + @@ -113,6 +203,142 @@
{{ alert.ruleCode }} {{ alert.ruleDescription }} + + @if (matchedRuleCount(alert) > 1) { + + } +
+ + + + + + + @@ -132,7 +358,7 @@ - - + + +
+ + @if (selectableRows.length) { + + } + + + @if (isSelectable(alert)) { + + } @else { + — + } + Raised On + +
+ @if (isExpanded(alert)) { +
+
+ Rules matched by {{ alert.transactionType | lowercase }} #{{ alert.transactionId }} — {{ amount(alert) }} + @if (selectableRulesOf(alert).length > 1) { + + } +
+ + @for (rule of matchedRules(alert); track rule.ruleCode) { +
+ + @if (isRuleSelectable(rule)) { + + } @else if (!isGradedRule(rule)) { + + + gavel + + } @else { + + {{ !rule.directlyDecided ? 'remove_circle_outline' : rule.disposition === 'ESCALATE' ? 'report' : 'verified' }} + + } + {{ severityLabel(rule.riskCategory) }} +
+ + {{ rule.ruleCode }} + @if (rule.governing) { + governing + } + + @if (!isGradedRule(rule)) { + + → CTR filing + + } + + @if (rule.disposition) { + + {{ rule.directlyDecided ? ruleDispositionLabel(rule) : 'Closed with case' }} + + } + + {{ rule.ruleDescription }} +
+
+ } + + + @if (selectedRuleCount > 0) { +
+ {{ selectedRuleCount }} rule{{ selectedRuleCount > 1 ? 's' : '' }} selected + + + + +
+ } +
+ @if (isHighSeverity(alert)) { + A high-severity rule fired, so this transaction is decided on its own rather than in a batch. + } @else if (!alert.highestSeverity) { + A statutory return rather than a graded suspicion — decided on its own. + } @else { + Every rule here is low or medium, so this transaction can be decided together with others. + } +
+
+ }
Action + @@ -180,15 +406,33 @@
- + +
- {{ activeStatus ? 'filter_alt_off' : 'verified_user' }} - {{ activeStatus || activeSeverity !== null ? 'filter_alt_off' : 'verified_user' }} + No transactions flagged. The feed updates automatically as the engine raises alerts. - No alerts in this status. + No alerts match the filters in force.
diff --git a/src/app/rbi-circulars/aml-rules/aml-flagged-transactions/aml-flagged-transactions.component.scss b/src/app/rbi-circulars/aml-rules/aml-flagged-transactions/aml-flagged-transactions.component.scss index 67a98fce5..2070f6a36 100644 --- a/src/app/rbi-circulars/aml-rules/aml-flagged-transactions/aml-flagged-transactions.component.scss +++ b/src/app/rbi-circulars/aml-rules/aml-flagged-transactions/aml-flagged-transactions.component.scss @@ -196,15 +196,43 @@ padding: 0 0 16px; } +/* A button, not a div: each tile narrows the table to its own severity band. */ .risk-tile { display: flex; flex-direction: column; + align-items: flex-start; gap: 2px; padding: 12px 14px; border: 1px solid #e2e8f0; border-left-width: 4px; border-radius: 8px; background: #fff; + font-family: inherit; + text-align: left; + cursor: pointer; + transition: + box-shadow 0.15s, + transform 0.1s; + + &:hover:not(:disabled) { + box-shadow: 0 2px 8px rgb(15 23 42 / 12%); + } + + &:active:not(:disabled) { + transform: translateY(1px); + } + + /* A band with nothing in it would filter to an empty table. */ + &:disabled { + opacity: 0.5; + cursor: default; + } +} + +/* The band currently narrowing the table. The ring reads on every tile colour, where a + background change would fight the severity tint each tile already carries. */ +.risk-tile.tile-active { + box-shadow: 0 0 0 2px #1a237e; } .risk-tile-n { @@ -293,8 +321,21 @@ } } -.alert-row:hover { +.alert-row { + cursor: pointer; + + &:hover { + background: #f8fafc; + } +} + +/* The open row and its pane read as one block. */ +.alert-row.row-expanded { background: #f8fafc; + + td { + border-bottom-color: transparent; + } } /* A newly arrived alert stays tinted until acknowledged. */ @@ -380,6 +421,285 @@ font-size: 12px; } +/* Quieter than the governing rule: it is context on one case, not a second case. */ +.rule-also { + margin-top: 3px; + color: #92400e; + background: #fffbeb; + border: 1px solid #fde68a; + border-radius: 4px; + padding: 1px 6px; + font-size: 11px; + align-self: flex-start; +} + +/* Says how many rules fired and opens the detail. Replaces the block of codes that used to sit in + this cell: the codes belong in the pane, where each can carry its own severity. */ +.rule-more { + display: inline-flex; + align-items: center; + gap: 3px; + margin-top: 4px; + padding: 2px 8px 2px 4px; + align-self: flex-start; + border: 1px solid #fde68a; + border-radius: 999px; + background: #fffbeb; + color: #92400e; + font-family: inherit; + font-size: 11px; + font-weight: 700; + cursor: pointer; + + mat-icon { + font-size: 15px; + width: 15px; + height: 15px; + } + + &:hover { + background: #fef3c7; + } +} + +/* ── Expanded detail ─────────────────────────────────────────────────────────────────────── */ + +.detail-row { + height: 0; +} + +.detail-cell { + padding: 0 !important; + border-bottom: none !important; +} + +/* Animating max-height rather than height: the pane's height depends on how many rules fired, and + a transition needs a figure to move between. */ +.detail-pane { + max-height: 0; + overflow: hidden; + transition: max-height 0.2s ease; + background: #f8fafc; + + /* Generous enough for a transaction that tripped every rule it could, plus the action bar. Too low + silently clips the last rule, which is the one an officer most needs to see. */ + &.detail-open { + max-height: 1200px; + } +} + +.detail-inner { + padding: 14px 16px 16px 60px; + border-bottom: 1px solid #e2e8f0; +} + +.detail-head { + display: flex; + align-items: center; + gap: 12px; + font-size: 11px; + font-weight: 700; + text-transform: uppercase; + letter-spacing: 0.05em; + color: #64748b; + margin-bottom: 10px; +} + +.rule-select-all { + border: none; + background: none; + padding: 0; + font-family: inherit; + font-size: 11px; + font-weight: 700; + letter-spacing: 0.04em; + text-transform: uppercase; + color: #1a237e; + cursor: pointer; + + &:hover { + text-decoration: underline; + } +} + +/* A rule that already carries a verdict is context, not a choice. */ +.detail-rule-decided { + opacity: 0.75; +} + +.rule-decided-icon { + flex: none; + font-size: 18px; + width: 18px; + height: 18px; + color: #15803d; + cursor: help; + + &.decided-escalated { + color: #b91c1c; + } + + /* Closed alongside another rule rather than judged on its own — stated, but not as a verdict. */ + &.decided-carried { + color: #94a3b8; + } + + /* A statutory return: not the officer's decision at all. */ + &.decided-statutory { + color: #475569; + } +} + +/* Where a statutory rule goes, said next to the rule rather than left to be inferred from a + missing checkbox. */ +.ctr-tag { + margin-left: 6px; + padding: 1px 6px; + border-radius: 4px; + background: #e2e8f0; + color: #334155; + font-size: 10px; + font-weight: 700; + letter-spacing: 0.03em; + text-transform: uppercase; + white-space: nowrap; +} + +.rule-verdict { + margin-left: 6px; + padding: 1px 6px; + border-radius: 4px; + background: #dcfce7; + color: #15803d; + font-size: 10px; + font-weight: 700; + letter-spacing: 0.03em; + text-transform: uppercase; + + &.verdict-escalated { + background: #fee2e2; + color: #b91c1c; + } + + /* Wins over the disposition colours above: this rule was not itself the finding, and colouring it + like one would claim the officer condemned it. */ + &.verdict-carried { + background: #f1f5f9; + color: #64748b; + } +} + +/* Acts on the ticked rules only, and sits with them rather than at the top of the table. */ +.rule-action-bar { + display: flex; + align-items: center; + gap: 10px; + margin-top: 12px; + padding: 10px 12px; + border: 1px solid #bfdbfe; + border-radius: 8px; + background: #eff6ff; +} + +.rule-action-count { + font-size: 12.5px; + font-weight: 700; + color: #1e3a8a; + white-space: nowrap; +} + +.detail-rule { + display: flex; + align-items: flex-start; + gap: 10px; + padding: 7px 10px; + border-radius: 6px; + background: #fff; + border: 1px solid #e2e8f0; + + & + & { + margin-top: 6px; + } +} + +/* The rule the alert was raised under, and the one the disposition is recorded against. */ +.detail-rule-governing { + border-color: #cbd5e1; + box-shadow: inset 3px 0 0 #1a237e; +} + +.detail-rule-text { + display: flex; + flex-direction: column; + gap: 1px; + min-width: 0; +} + +.detail-rule-code { + font-size: 12.5px; + font-weight: 700; + color: #1e293b; +} + +.detail-rule-desc { + font-size: 12px; + color: #64748b; +} + +.governing-tag { + margin-left: 6px; + padding: 1px 6px; + border-radius: 4px; + background: #e0e7ff; + color: #3730a3; + font-size: 10px; + font-weight: 700; + letter-spacing: 0.03em; + text-transform: uppercase; +} + +/* Severity of one rule inside the pane. Same colours as the summary tiles, so a band reads the + same wherever it appears. */ +.sev-chip { + flex: none; + min-width: 62px; + text-align: center; + padding: 2px 8px; + border-radius: 999px; + font-size: 10px; + font-weight: 700; + letter-spacing: 0.04em; + text-transform: uppercase; + + &.sev-high { + background: #fee2e2; + color: #b91c1c; + } + + &.sev-medium { + background: #fef3c7; + color: #b45309; + } + + &.sev-low { + background: #dcfce7; + color: #15803d; + } + + &.sev-none { + background: #e2e8f0; + color: #475569; + } +} + +/* Why this transaction can or cannot be decided in a batch, said where the officer is deciding. */ +.detail-foot { + margin-top: 10px; + font-size: 12px; + color: #475569; + font-style: italic; +} + .rule-code { font-weight: 600; } @@ -537,3 +857,48 @@ background: #fff; border-top: 1px solid #e2e8f0; } + +/* Batch disposition bar — only rendered while rows are ticked. */ +.bulk-bar { + display: flex; + align-items: center; + gap: 12px; + padding: 10px 16px; + margin-bottom: 10px; + border: 1px solid #bfdbfe; + border-radius: 10px; + background: #eff6ff; +} + +.bulk-count { + font-size: 13px; + font-weight: 700; + color: #1e3a8a; + white-space: nowrap; +} + +/* Says why some rows cannot be ticked, where the officer is looking rather than in a tooltip. */ +.bulk-note { + font-size: 12px; + color: #475569; +} + +.bulk-spacer { + flex: 1; +} + +.select-cell { + width: 44px; + padding-right: 0; +} + +/* Stands in for the checkbox on a row that cannot be batched, so the column still reads as a + column rather than leaving a hole the eye has to account for. */ +.select-na { + display: inline-block; + width: 18px; + text-align: center; + color: #cbd5e1; + cursor: help; + user-select: none; +} diff --git a/src/app/rbi-circulars/aml-rules/aml-flagged-transactions/aml-flagged-transactions.component.ts b/src/app/rbi-circulars/aml-rules/aml-flagged-transactions/aml-flagged-transactions.component.ts index 87cec7b0b..df87f805a 100644 --- a/src/app/rbi-circulars/aml-rules/aml-flagged-transactions/aml-flagged-transactions.component.ts +++ b/src/app/rbi-circulars/aml-rules/aml-flagged-transactions/aml-flagged-transactions.component.ts @@ -7,7 +7,7 @@ */ /** Angular Imports */ -import { Component, OnInit, OnDestroy, inject, ViewChild } from '@angular/core'; +import { Component, OnInit, AfterViewInit, OnDestroy, inject, ViewChild } from '@angular/core'; import { MatTableModule, MatTableDataSource } from '@angular/material/table'; import { MatPaginatorModule, MatPaginator } from '@angular/material/paginator'; import { MatIconModule } from '@angular/material/icon'; @@ -15,6 +15,7 @@ import { MatButtonModule } from '@angular/material/button'; import { MatTooltipModule } from '@angular/material/tooltip'; import { MatProgressSpinnerModule } from '@angular/material/progress-spinner'; import { MatDialog, MatDialogModule } from '@angular/material/dialog'; +import { MatCheckboxModule } from '@angular/material/checkbox'; import { CommonModule } from '@angular/common'; import { Subject, timer, of } from 'rxjs'; import { switchMap, takeUntil, catchError, filter } from 'rxjs/operators'; @@ -24,6 +25,7 @@ import { AmlService } from '../../../aml/aml.service'; import { AlertService } from '../../../core/alert/alert.service'; import { HasPermissionDirective } from '../../../directives/has-permission/has-permission.directive'; import { AmlReviewAlertDialogComponent } from './aml-review-alert-dialog.component'; +import { AmlBulkReviewDialogComponent } from './aml-bulk-review-dialog.component'; /** * Live feed of transactions the AML rule engine has flagged. @@ -48,10 +50,11 @@ import { AmlReviewAlertDialogComponent } from './aml-review-alert-dialog.compone MatTooltipModule, MatProgressSpinnerModule, MatDialogModule, + MatCheckboxModule, HasPermissionDirective ] }) -export class AmlFlaggedTransactionsComponent implements OnInit, OnDestroy { +export class AmlFlaggedTransactionsComponent implements OnInit, AfterViewInit, OnDestroy { private amlService = inject(AmlService); private dialog = inject(MatDialog); private alertService = inject(AlertService); @@ -62,6 +65,7 @@ export class AmlFlaggedTransactionsComponent implements OnInit, OnDestroy { alerts = new MatTableDataSource([]); displayedColumns: string[] = [ + 'select', 'raisedOn', 'transaction', 'client', @@ -71,6 +75,283 @@ export class AmlFlaggedTransactionsComponent implements OnInit, OnDestroy { 'actions' ]; + /** + * Alerts ticked for a single disposition across several transactions. + * + *

+ * Low and medium-severity alerts arrive in numbers and are mostly the same decision repeated - + * a run of ordinary cash deposits that each tripped a threshold. Dealing with them one dialog at + * a time is what stops an officer getting to the one alert that matters. High severity is + * deliberately excluded: an alert that reached the high band is a judgement about that customer's + * transaction, and it gets its own decision. + */ + selectedIds = new Set(); + + /** m_client_risk_rule.risk_category — 3 is the high band. */ + private static readonly HIGH_SEVERITY = 3; + + /** + * Whether any rule this transaction matched reached the high band. + * + *

+ * Read from highestSeverity, not from the governing rule's own riskCategory. One transaction + * raises one alert under whichever rule most warrants attention, and a statutory CTR outranks + * everything — so a high-severity rule can sit in "also matched" while the row itself reports the + * CTR, which carries no severity at all. Judging the row by the governing rule would offer such a + * transaction up for a batch decision. + */ + isHighSeverity(alert: any): boolean { + return Number(alert?.highestSeverity) === AmlFlaggedTransactionsComponent.HIGH_SEVERITY; + } + + /** + * The band this alert counts as, for the summary tiles. + * + *

+ * The same figure the checkbox is gated on, so the tiles and the table cannot disagree. Reading + * riskCategory here put a ₹12,00,000 cash deposit that had tripped a high-severity rule under + * "Unclassified", because the alert reports under the statutory CTR and a CTR carries no band of + * its own. Alerts raised before highest_severity existed fall back to the governing rule's band. + */ + private severityBand(alert: any): number { + const worst = Number(alert?.highestSeverity); + if (worst === 1 || worst === 2 || worst === 3) { + return worst; + } + const governing = Number(alert?.riskCategory); + if (governing === 1 || governing === 2 || governing === 3) { + return governing; + } + // 0, never NaN: the Unclassified tile filters on this value, and NaN === NaN is false, so a + // NaN band would leave that tile showing a count it could never select. + return 0; + } + + /** + * Only an open alert whose every matched rule sits in the low or medium band can be swept up with + * others. + * + *

+ * Two things are held back. A transaction that tripped any high-severity rule, because that is a + * judgement about the transaction itself. And one where no matched rule carried a severity at + * all, which is how the CTR rules arrive - they are not graded, being returns the bank owes + * rather than suspicions. Neither belongs in a batch disposed of on a single reason. + */ + isSelectable(alert: any): boolean { + const band = Number(alert?.highestSeverity); + const graded = band === 1 || band === 2; + return this.canReview(alert) && graded; + } + + /** The alert whose detail row is open. One at a time: this is a reading pane, not a comparison. */ + expandedId: number | null = null; + + + toggleExpanded(alert: any): void { + this.expandedId = this.expandedId === alert.id ? null : alert.id; + // Ticks belong to the alert they were made on, not to whichever pane is open next. + this.selectedRuleCodes.clear(); + } + + isExpanded(alert: any): boolean { + return this.expandedId === alert.id; + } + + /** + * Every rule the transaction matched, governing one first. + * + *

+ * Served by the API. The fallback rebuilds the list from the flat fields for an alert read before the API carried + * it, so an older row still expands to something rather than appearing to have matched nothing. + */ + matchedRules(alert: any): { ruleCode: string; ruleDescription: string; riskCategory: number | null; governing: boolean }[] { + if (Array.isArray(alert?.matchedRules) && alert.matchedRules.length) { + return alert.matchedRules; + } + const rules = [ + { + ruleCode: alert?.ruleCode, + ruleDescription: alert?.ruleDescription, + riskCategory: alert?.riskCategory ?? null, + governing: true + } + ]; + for (const code of String(alert?.alsoMatchedRules || '') + .split(',') + .map((c: string) => c.trim()) + .filter((c: string) => c)) { + rules.push({ ruleCode: code, ruleDescription: code, riskCategory: null, governing: false }); + } + return rules; + } + + /** How many rules the transaction matched, for the count shown on the collapsed row. */ + matchedRuleCount(alert: any): number { + return this.matchedRules(alert).length; + } + + severityLabel(band: number | null | undefined): string { + switch (Number(band)) { + case 1: + return 'Low'; + case 2: + return 'Medium'; + case 3: + return 'High'; + default: + return 'Statutory'; + } + } + + /** + * Rule codes ticked inside the open detail pane. + * + *

+ * Cleared whenever a different alert is opened: the codes are only meaningful against the alert they were ticked on, + * and carrying them across would apply one transaction's selection to another's rules. + */ + selectedRuleCodes = new Set(); + + isRuleSelected(rule: any): boolean { + return this.selectedRuleCodes.has(rule.ruleCode); + } + + /** + * Whether this rule is the officer's to decide. + * + *

+ * A rule already decided keeps its verdict. A rule with no severity band is not a graded suspicion at all - the CTR + * rules are statutory returns the bank owes FIU-IND on any cash transaction at or above ten lakh, filed on the + * amount whether or not the customer is above suspicion. There is no "whitelist" for one: clearing the transaction + * does not stop the CTR, and escalating it does not describe what the rule found. + */ + isRuleSelectable(rule: any): boolean { + return !rule?.disposition && this.isGradedRule(rule); + } + + /** A rule that carries a severity band, as against a statutory reporting rule that carries none. */ + isGradedRule(rule: any): boolean { + const band = Number(rule?.riskCategory); + return band === 1 || band === 2 || band === 3; + } + + toggleRuleSelected(rule: any): void { + if (!this.isRuleSelectable(rule)) { + return; + } + if (this.selectedRuleCodes.has(rule.ruleCode)) { + this.selectedRuleCodes.delete(rule.ruleCode); + } else { + this.selectedRuleCodes.add(rule.ruleCode); + } + } + + selectableRulesOf(alert: any): any[] { + return this.matchedRules(alert).filter((r) => this.isRuleSelectable(r)); + } + + get selectedRuleCount(): number { + return this.selectedRuleCodes.size; + } + + allRulesSelected(alert: any): boolean { + const open = this.selectableRulesOf(alert); + return open.length > 0 && open.every((r) => this.selectedRuleCodes.has(r.ruleCode)); + } + + toggleAllRules(alert: any): void { + if (this.allRulesSelected(alert)) { + this.selectedRuleCodes.clear(); + return; + } + for (const rule of this.selectableRulesOf(alert)) { + this.selectedRuleCodes.add(rule.ruleCode); + } + } + + ruleDispositionLabel(rule: any): string { + return rule?.disposition === 'ESCALATE' ? 'Escalated' : rule?.disposition === 'WHITELIST' ? 'Whitelisted' : ''; + } + + severityClass(band: number | null | undefined): string { + switch (Number(band)) { + case 1: + return 'sev-low'; + case 2: + return 'sev-medium'; + case 3: + return 'sev-high'; + default: + return 'sev-none'; + } + } + + isSelected(alert: any): boolean { + return this.selectedIds.has(alert.id); + } + + toggleSelected(alert: any): void { + if (!this.isSelectable(alert)) { + return; + } + if (this.selectedIds.has(alert.id)) { + this.selectedIds.delete(alert.id); + } else { + this.selectedIds.add(alert.id); + } + } + + /** The rows a "select all" would act on: what is on screen, open, and below the high band. */ + get selectableRows(): any[] { + return this.alerts.data.filter((a) => this.isSelectable(a)); + } + + get allSelectableSelected(): boolean { + const rows = this.selectableRows; + return rows.length > 0 && rows.every((a) => this.selectedIds.has(a.id)); + } + + toggleSelectAll(): void { + if (this.allSelectableSelected) { + this.selectedIds.clear(); + return; + } + for (const a of this.selectableRows) { + this.selectedIds.add(a.id); + } + } + + get selectedCount(): number { + return this.selectedIds.size; + } + + private selectedAlerts(): any[] { + return this.alerts.data.filter((a) => this.selectedIds.has(a.id)); + } + + /** + * Drops ticks that no longer stand, after every poll. + * + *

+ * The feed refreshes itself every few seconds while the officer works. Without this, an alert + * another officer disposed of in the meantime — or one the status filter has since hidden — stays + * in the set, and the bar goes on offering to decide it: the count reads higher than the ticks on + * screen, and the batch carries alerts nobody chose. + */ + private pruneSelection(): void { + const stillSelectable = new Set(this.selectableRows.map((a) => a.id)); + for (const id of Array.from(this.selectedIds)) { + if (!stillSelectable.has(id)) { + this.selectedIds.delete(id); + } + } + // Same for the open detail pane: an alert filtered away or disposed of elsewhere should not + // leave the table holding a pane for a row that is no longer on it. + if (this.expandedId !== null && !this.alerts.data.some((a) => a.id === this.expandedId)) { + this.expandedId = null; + } + } + /** Every alert as returned, before the status filter narrows the table. */ private allAlerts: any[] = []; @@ -84,12 +365,19 @@ export class AmlFlaggedTransactionsComponent implements OnInit, OnDestroy { activeStatus = ''; private statusCounts: Record = {}; + /** + * The severity tile currently narrowing the table: 1 Low, 2 Medium, 3 High, 0 unclassified, or + * null for all. Independent of {@link activeStatus} — the two compose, so an officer can ask for + * the high-severity alerts that are still open. + */ + activeSeverity: number | null = null; + /** * Severity mix of the flagged transactions currently in the table. riskCategory is the severity * of the rule that fired, resolved server-side from the originating catalog entry — the same * field the Rules Master tab shows in its Risk Category column. */ - riskSummary: { label: string; count: number; cls: string }[] = []; + riskSummary: { key: number; label: string; count: number; cls: string }[] = []; /** Alerts whose rule has no catalog ancestor (MANUAL_REFERRAL), so no severity to report. */ unclassifiedCount = 0; @@ -106,6 +394,18 @@ export class AmlFlaggedTransactionsComponent implements OnInit, OnDestroy { @ViewChild(MatPaginator) paginator!: MatPaginator; + /** + * Binds the paginator as soon as the view exists. + * + *

+ * The first poll can return before the view has initialised, and until this the data source was + * only ever wired up as a side effect of a later filter pass — so the table could render every + * alert unpaginated, then jump once the next poll arrived. + */ + ngAfterViewInit(): void { + this.alerts.paginator = this.paginator; + } + ngOnInit(): void { this.fetch(); timer(AmlFlaggedTransactionsComponent.POLL_INTERVAL_MS, AmlFlaggedTransactionsComponent.POLL_INTERVAL_MS) @@ -158,7 +458,22 @@ export class AmlFlaggedTransactionsComponent implements OnInit, OnDestroy { setStatus(key: string): void { this.activeStatus = key; - this.applyStatusFilter(); + this.applyStatusFilter(true); + } + + /** Clicking the tile already in force clears it, so the same click both narrows and undoes. */ + setSeverity(band: number): void { + this.activeSeverity = this.activeSeverity === band ? null : band; + this.applyStatusFilter(true); + } + + isSeverityActive(band: number): boolean { + return this.activeSeverity === band; + } + + clearSeverity(): void { + this.activeSeverity = null; + this.applyStatusFilter(true); } statusCount(key: string): number { @@ -203,47 +518,63 @@ export class AmlFlaggedTransactionsComponent implements OnInit, OnDestroy { this.allAlerts = list; this.recomputeStatusCounts(); this.applyStatusFilter(); + this.pruneSelection(); this.lastUpdated = new Date(); } - private applyStatusFilter(): void { - this.alerts.data = this.activeStatus + /** + * @param resetPage + * true only when the user changed a filter. A poll must not reset the page: the feed + * refreshes every few seconds, and an officer reading page 3 would be thrown back to + * page 1 each time. Nor may it touch the paginator before the data source has bound to + * it — doing that on the first pass left pageSize at 0 and emptied the table. + */ + private applyStatusFilter(resetPage = false): void { + const byStatus = this.activeStatus ? this.allAlerts.filter((a) => a.status === this.activeStatus) : this.allAlerts; + this.alerts.data = + this.activeSeverity === null ? byStatus : byStatus.filter((a) => this.severityBand(a) === this.activeSeverity); if (!this.alerts.paginator && this.paginator) { this.alerts.paginator = this.paginator; } - this.recomputeRiskSummary(); + if (resetPage && this.alerts.paginator) { + // Otherwise a filter applied while on page 2 leaves the table blank on a one-page result. + this.alerts.paginator.firstPage(); + } + // Counted before the severity filter, so every tile keeps its real figure and stays clickable. + // Counting the filtered rows would zero the other tiles the moment one was picked, leaving no + // way back to them. + this.recomputeRiskSummary(byStatus); } /** * Counted over the rows the status filter leaves visible, not the whole set — a summary that * disagreed with the table underneath it would be worse than no summary. */ - private recomputeRiskSummary(): void { - const pool = this.alerts.data; + private recomputeRiskSummary(pool: any[]): void { const tiers = [ { key: 3, label: 'High Risk', cls: 'risk-high' }, { key: 2, label: 'Medium Risk', cls: 'risk-medium' }, { key: 1, label: 'Low Risk', cls: 'risk-low' } ]; this.riskSummary = tiers.map((t) => ({ + key: t.key, label: t.label, cls: t.cls, - count: pool.filter((a) => Number(a.riskCategory) === t.key).length + count: pool.filter((a) => this.severityBand(a) === t.key).length })); - // Anything outside 1-3, including null, so the tiles always reconcile to the total. - this.unclassifiedCount = pool.filter( - (a) => ![ - 1, - 2, - 3 - ].includes(Number(a.riskCategory)) - ).length; + // Band 0 is everything ungraded, so the tiles always reconcile to the total. + this.unclassifiedCount = pool.filter((a) => this.severityBand(a) === 0).length; } + /** + * Everything the status filter leaves, whatever severity tile is in force — so the tiles still + * add up to the total while one of them is narrowing the table, and the total reads as the way + * back to all of them. + */ get summaryTotal(): number { - return this.alerts.data.length; + return this.activeStatus ? this.allAlerts.filter((a) => a.status === this.activeStatus).length : this.allAlerts.length; } private recomputeStatusCounts(): void { @@ -330,4 +661,73 @@ export class AmlFlaggedTransactionsComponent implements OnInit, OnDestroy { this.fetch(); }); } + + /** + * Applies one disposition to every ticked alert. + * + *

+ * Each still goes through the review dialog's own requirements - a reason, and for a whitelist + * the evidence behind it - asked once and recorded against every alert in the batch. That is the + * point of the batch: the officer is making one decision about one kind of activity, not the same + * decision twenty times. + */ + /** + * Applies one disposition to the rules ticked inside the open pane. + * + *

+ * The alert's own status is left to the server, which derives it from what remains: whitelisting five of six rules + * leaves the case open on the sixth rather than closing it. + */ + reviewSelectedRules(alert: any, disposition: 'WHITELIST' | 'ESCALATE'): void { + const codes = Array.from(this.selectedRuleCodes); + if (!codes.length) { + return; + } + this.dialog + .open(AmlReviewAlertDialogComponent, { + width: '560px', + maxWidth: '95vw', + autoFocus: false, + disableClose: true, + data: { alert, disposition, ruleCodes: codes } + }) + .afterClosed() + .subscribe((result: string | undefined) => { + if (!result) { + return; + } + this.alertService.alert({ + type: disposition === 'WHITELIST' ? 'Rules Whitelisted' : 'Rules Escalated', + message: `${codes.length} rule${codes.length > 1 ? 's' : ''} on ${alert.transactionType} #${alert.transactionId} ${ + disposition === 'WHITELIST' ? 'cleared' : 'escalated' + }.` + }); + this.selectedRuleCodes.clear(); + // The alert's status may have changed with the last rule decided, so re-read rather than patching locally. + this.fetch(); + }); + } + + reviewSelected(disposition: 'WHITELIST' | 'ESCALATE'): void { + const batch = this.selectedAlerts(); + if (!batch.length) { + return; + } + this.dialog + .open(AmlBulkReviewDialogComponent, { + width: '620px', + maxWidth: '95vw', + autoFocus: false, + disableClose: true, + data: { alerts: batch, disposition } + }) + .afterClosed() + .subscribe((done: boolean | undefined) => { + if (!done) { + return; + } + this.selectedIds.clear(); + this.fetch(); + }); + } } diff --git a/src/app/rbi-circulars/aml-rules/aml-flagged-transactions/aml-review-alert-dialog.component.ts b/src/app/rbi-circulars/aml-rules/aml-flagged-transactions/aml-review-alert-dialog.component.ts index c5bc66317..2d6825d64 100644 --- a/src/app/rbi-circulars/aml-rules/aml-flagged-transactions/aml-review-alert-dialog.component.ts +++ b/src/app/rbi-circulars/aml-rules/aml-flagged-transactions/aml-review-alert-dialog.component.ts @@ -31,6 +31,11 @@ import { AmlService } from '../../../aml/aml.service'; export interface AmlReviewDialogData { alert: any; disposition: 'WHITELIST' | 'ESCALATE'; + /** + * The rules being decided, when the officer picked some inside the expanded pane rather than acting on the whole + * transaction. Absent means the decision covers the alert and every rule under it. + */ + ruleCodes?: string[]; } /** @@ -162,6 +167,13 @@ export class AmlReviewAlertDialogComponent { } get title(): string { + // Named differently when only some of the transaction's rules are being decided, so the officer is not told they + // are clearing a transaction when five of its six rules stay open. + const n = this.data.ruleCodes?.length ?? 0; + if (n > 0) { + const subject = n === 1 ? 'Rule' : `${n} Rules`; + return this.isWhitelist ? `Whitelist ${subject}` : `Mark ${subject} as Suspicious`; + } return this.isWhitelist ? 'Whitelist Transaction' : 'Mark as Suspicious'; } @@ -181,7 +193,12 @@ export class AmlReviewAlertDialogComponent { this.submitting = true; this.errorMessage = ''; - this.amlService.reviewAlert(this.data.alert.id, this.data.disposition, this.composedNotes).subscribe({ + // Deciding named rules within the alert rather than the whole alert, when the caller passed some. The evidence + // requirements are the same either way, which is why this dialog serves both. + const request = this.data.ruleCodes?.length + ? this.amlService.reviewAlertRules(this.data.alert.id, this.data.ruleCodes, this.data.disposition, this.composedNotes) + : this.amlService.reviewAlert(this.data.alert.id, this.data.disposition, this.composedNotes); + request.subscribe({ next: () => { this.submitting = false; this.dialogRef.close(this.data.disposition); diff --git a/src/app/reports/reports-routing.module.ts b/src/app/reports/reports-routing.module.ts index 76f120463..4a6fe1a06 100644 --- a/src/app/reports/reports-routing.module.ts +++ b/src/app/reports/reports-routing.module.ts @@ -93,6 +93,11 @@ const routes: Routes = [ loadComponent: () => import('../tds-reports/tds-remittance-report.component').then((m) => m.TdsRemittanceReportComponent) }, + { + path: 'bor', + data: { title: 'BOR Register — Share Capital', breadcrumb: 'BOR Register' }, + loadComponent: () => import('../board-resolution/bor-report.component').then((m) => m.BorReportComponent) + }, { path: ':filter', data: { routeParamBreadcrumb: 'filter' }, diff --git a/src/app/risk-review/risk-change-approvals.component.ts b/src/app/risk-review/risk-change-approvals.component.ts new file mode 100644 index 000000000..51a3e1798 --- /dev/null +++ b/src/app/risk-review/risk-change-approvals.component.ts @@ -0,0 +1,390 @@ +/** + * 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, OnInit, inject } from '@angular/core'; +import { CommonModule } from '@angular/common'; +import { FormBuilder, FormGroup, ReactiveFormsModule, Validators } from '@angular/forms'; +import { MatButtonModule } from '@angular/material/button'; +import { MAT_DIALOG_DATA, MatDialog, MatDialogModule, MatDialogRef } from '@angular/material/dialog'; +import { MatFormFieldModule } from '@angular/material/form-field'; +import { MatInputModule } from '@angular/material/input'; +import { MatSnackBar, MatSnackBarModule } from '@angular/material/snack-bar'; + +import { AmlService } from 'app/aml/aml.service'; + +const CATEGORY_LABEL: Record = { 1: 'Low', 2: 'Medium', 3: 'High' }; + +/** + * The approval queue for risk-category changes raised from the Risk Review Report. + * + *

+ * Approving here moves the customer's KYC rating and their re-KYC due date together, so the row + * shows where the rating is going from and to, and why the reviewer asked - the checker is deciding + * how closely the bank watches that customer, not clearing a queue item. + */ +@Component({ + selector: 'mifosx-risk-change-approvals', + standalone: true, + imports: [ + CommonModule, + MatButtonModule, + MatDialogModule, + MatSnackBarModule + ], + template: ` +

+
+
+

Risk Category Approvals

+

+ Changes to a customer's KYC risk category, raised from the Risk Review Report. Approving moves the rating + and the re-KYC due date together, and writes the reason into the customer's risk history. +

+
+
+ +
+ +
+ +
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
Req. IDCustomerCIFBranchRating changeReasonStatusRequested ByRequested OnAction
{{ r.id }} + {{ r.clientName }} + {{ r.cifNumber || '—' }}{{ r.officeName || '—' }} + + {{ labelOf(r.currentCategory) }} → + {{ labelOf(r.requestedCategory) }} + + {{ r.reason }} + {{ r.status }} + {{ r.makerUsername }}{{ r.createdDate }} +
+ + +
+ + by {{ r.checkerUsername }}, {{ r.checkerDate }} + + {{ r.rejectionReason }} +
No risk category change matches this filter.
+
+
+ `, + styles: [ + ` + .rca-root { + padding: 20px 24px 40px; + background: #f8fafc; + min-height: 100%; + } + .rca-head { + margin-bottom: 16px; + } + .rca-title { + margin: 0 0 3px; + font-size: 20px; + font-weight: 700; + color: #1e293b; + } + .rca-sub { + margin: 0; + max-width: 800px; + font-size: 13px; + line-height: 1.5; + color: #64748b; + } + .rca-filters { + display: flex; + gap: 8px; + margin-bottom: 14px; + flex-wrap: wrap; + } + .rca-chip { + height: 32px; + padding: 0 14px; + border: 1px solid #cbd5e1; + border-radius: 999px; + background: #fff; + font: inherit; + font-size: 12.5px; + font-weight: 600; + color: #334155; + cursor: pointer; + } + .rca-chip--on { + border-color: #1e3a8a; + background: #eff6ff; + color: #1e3a8a; + } + .rca-count { + color: #94a3b8; + font-weight: 500; + } + .rca-table-wrap { + background: #fff; + border: 1px solid #e2e8f0; + border-radius: 11px; + overflow-x: auto; + } + .rca-table { + width: 100%; + border-collapse: collapse; + font-size: 12.5px; + } + .rca-table th { + text-align: left; + padding: 10px; + background: #f1f5f9; + font-size: 10.5px; + font-weight: 700; + text-transform: uppercase; + letter-spacing: 0.04em; + color: #475569; + white-space: nowrap; + border-bottom: 1px solid #e2e8f0; + } + .rca-table td { + padding: 11px 10px; + border-bottom: 1px solid #f1f5f9; + vertical-align: middle; + color: #334155; + } + .mono { + font-family: 'DM Mono', monospace; + } + .rca-move { + white-space: nowrap; + } + .rca-arrow { + color: #94a3b8; + margin: 0 4px; + } + .rca-reason { + max-width: 280px; + } + .rca-status { + display: inline-block; + padding: 2px 9px; + border-radius: 999px; + font-size: 11px; + font-weight: 700; + background: #f1f5f9; + color: #475569; + } + .rca-status--pending { + background: #fef3c7; + color: #b45309; + } + .rca-status--approved { + background: #dcfce7; + color: #15803d; + } + .rca-status--rejected { + background: #fee2e2; + color: #b91c1c; + } + .rca-actions { + display: flex; + gap: 8px; + } + .rca-done { + font-size: 11.5px; + color: #64748b; + } + .rca-empty { + padding: 30px; + text-align: center; + color: #94a3b8; + } + ` + ] +}) +export class RiskChangeApprovalsComponent implements OnInit { + private amlService = inject(AmlService); + private dialog = inject(MatDialog); + private snackBar = inject(MatSnackBar); + + rows: any[] = []; + readonly statuses = ['PENDING', 'APPROVED', 'REJECTED', '']; + selectedStatus = 'PENDING'; + + ngOnInit(): void { + this.load(); + } + + load(): void { + this.amlService.getRiskChangeRequests().subscribe({ + next: (rows) => (this.rows = rows || []), + error: (err: any) => + this.snackBar.open('Could not load requests: ' + (err?.error?.defaultUserMessage || err.message), 'Close', { + duration: 5000 + }) + }); + } + + get filtered(): any[] { + return this.selectedStatus ? this.rows.filter((r) => r.status === this.selectedStatus) : this.rows; + } + + countOf(status: string): number { + return status ? this.rows.filter((r) => r.status === status).length : this.rows.length; + } + + filterBy(status: string): void { + this.selectedStatus = status; + } + + labelOf(category: number | null): string { + return category == null ? 'Unrated' : CATEGORY_LABEL[category] || '—'; + } + + approve(r: any): void { + this.amlService.approveRiskChange(r.id).subscribe({ + next: () => { + this.snackBar.open( + `${r.clientName} moved to ${this.labelOf(r.requestedCategory)} — re-KYC due date updated.`, + 'Close', + { duration: 6000 } + ); + this.load(); + }, + error: (err: any) => + this.snackBar.open('Error: ' + (err?.error?.defaultUserMessage || err.message), 'Close', { duration: 7000 }) + }); + } + + reject(r: any): void { + this.dialog + .open(RejectRiskChangeDialogComponent, { width: '450px', data: { request: r }, disableClose: true }) + .afterClosed() + .subscribe((done) => { + if (done) { + this.load(); + } + }); + } +} + +// ── REJECT DIALOG ── +@Component({ + selector: 'mifosx-reject-risk-change-dialog', + standalone: true, + imports: [ + CommonModule, + ReactiveFormsModule, + MatDialogModule, + MatFormFieldModule, + MatInputModule, + MatButtonModule + ], + template: ` +

Reject Risk Category Change

+ +

+ {{ data.request.clientName }} stays on their current rating. The reason is shown to the reviewer who raised it. +

+
+ + Rejection reason + + A reason is required + +
+
+ + + + + `, + styles: [ + ` + .full { + width: 100%; + } + .hint { + font-size: 13px; + color: #64748b; + margin: 0 0 12px; + line-height: 1.5; + } + ` + ] +}) +export class RejectRiskChangeDialogComponent { + private fb = inject(FormBuilder); + private amlService = inject(AmlService); + private snackBar = inject(MatSnackBar); + dialogRef = inject(MatDialogRef); + + form: FormGroup; + busy = false; + + constructor(@Inject(MAT_DIALOG_DATA) public data: { request: any }) { + this.form = this.fb.group({ rejectionReason: ['', Validators.required] }); + } + + submit(): void { + if (this.form.invalid) { + return; + } + this.busy = true; + this.amlService.rejectRiskChange(this.data.request.id, this.form.value.rejectionReason).subscribe({ + next: () => { + this.busy = false; + this.snackBar.open('Risk category change rejected', 'Close', { duration: 3000 }); + this.dialogRef.close(true); + }, + error: (err: any) => { + this.busy = false; + this.snackBar.open('Error: ' + (err?.error?.defaultUserMessage || err.message), 'Close', { duration: 6000 }); + } + }); + } +} diff --git a/src/app/risk-review/risk-change-dialog.component.ts b/src/app/risk-review/risk-change-dialog.component.ts new file mode 100644 index 000000000..2cd8c7915 --- /dev/null +++ b/src/app/risk-review/risk-change-dialog.component.ts @@ -0,0 +1,222 @@ +/** + * 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, inject } from '@angular/core'; +import { CommonModule } from '@angular/common'; +import { FormBuilder, FormGroup, ReactiveFormsModule, Validators } from '@angular/forms'; +import { MAT_DIALOG_DATA, MatDialogModule, MatDialogRef } from '@angular/material/dialog'; +import { MatButtonModule } from '@angular/material/button'; +import { MatFormFieldModule } from '@angular/material/form-field'; +import { MatInputModule } from '@angular/material/input'; +import { MatSelectModule } from '@angular/material/select'; +import { MatSnackBar } from '@angular/material/snack-bar'; + +import { AmlService } from 'app/aml/aml.service'; + +export interface RiskChangeDialogData { + clientId: number; + customerName: string; + /** 1 Low, 2 Medium, 3 High; null where the customer carries no rating yet. */ + currentCategory: number | null; + /** What the report's own computation says the rating should be, used as the default. */ + recommendedCategory: number | null; + /** The one-line explanation the report already shows against the row. */ + recommendationReason: string; +} + +/** + * Raises a change to a customer's KYC risk category for approval. + * + *

+ * The rating governs how often the customer is re-KYC'd and what AML thresholds they are measured + * against, so it is not something one officer moves on their own. The dialog collects what it + * should become and why; a checker applies it. + */ +@Component({ + selector: 'mifosx-risk-change-dialog', + standalone: true, + imports: [ + CommonModule, + ReactiveFormsModule, + MatDialogModule, + MatButtonModule, + MatFormFieldModule, + MatInputModule, + MatSelectModule + ], + template: ` +

Change Risk Category

+ +
+
+ Customer + {{ data.customerName }} +
+
+ Rating on file + {{ labelOf(data.currentCategory) }} +
+
+ + +

+ Why this came up
{{ data.recommendationReason }} +

+ +
+ + New risk category + + Low + Medium + High + + Pick the new category + + + + Reason + + Recorded against the customer's risk history + A reason is required + +
+ +

+ The rating does not move now. It goes to a checker, and on approval the customer's KYC record and re-KYC due + date are updated together. +

+ +

{{ errorMessage }}

+
+ + + + + `, + styles: [ + ` + .rc-summary { + border: 1px solid #e2e8f0; + border-radius: 8px; + padding: 12px 14px; + margin-bottom: 14px; + background: #f8fafc; + } + .rc-row { + display: flex; + justify-content: space-between; + align-items: baseline; + gap: 16px; + padding: 3px 0; + } + .rc-label { + color: #64748b; + font-size: 12.5px; + } + .rc-value { + color: #1e293b; + font-size: 13.5px; + } + .rc-reason { + font-size: 12.5px; + line-height: 1.5; + color: #334155; + margin: 0 0 14px; + } + .rc-form { + display: flex; + flex-direction: column; + } + .rc-full { + width: 100%; + } + .rc-effect { + font-size: 12.5px; + line-height: 1.55; + color: #1e3a8a; + background: #eff6ff; + border: 1px solid #bfdbfe; + border-radius: 6px; + padding: 10px 12px; + margin: 4px 0 0; + } + .rc-error { + margin: 12px 0 0; + padding: 10px 12px; + border: 1px solid #fecaca; + border-radius: 6px; + background: #fef2f2; + color: #b91c1c; + font-size: 12.5px; + } + ` + ] +}) +export class RiskChangeDialogComponent { + private fb = inject(FormBuilder); + private amlService = inject(AmlService); + private snackBar = inject(MatSnackBar); + dialogRef = inject(MatDialogRef); + + form: FormGroup; + submitting = false; + errorMessage = ''; + + constructor(@Inject(MAT_DIALOG_DATA) public data: RiskChangeDialogData) { + this.form = this.fb.group({ + // Defaulted to what the report recommends, but left editable: the reviewer has seen the + // customer and the computation has not. + requestedCategory: [ + data.recommendedCategory ?? null, + Validators.required + ], + reason: [ + '', + Validators.required + ] + }); + } + + labelOf(category: number | null): string { + return category === 1 ? 'Low' : category === 2 ? 'Medium' : category === 3 ? 'High' : 'Unrated'; + } + + submit(): void { + if (this.form.invalid) { + this.form.markAllAsTouched(); + return; + } + this.submitting = true; + this.errorMessage = ''; + const v = this.form.value; + this.amlService.requestRiskChange(this.data.clientId, v.requestedCategory, (v.reason || '').trim()).subscribe({ + next: () => { + this.submitting = false; + this.snackBar.open( + `Risk change to ${this.labelOf(v.requestedCategory)} sent for approval — ${this.data.customerName}`, + 'Close', + { duration: 5000 } + ); + this.dialogRef.close(true); + }, + error: (err: any) => { + this.submitting = false; + this.errorMessage = err?.error?.defaultUserMessage || err?.error?.message || 'Could not send the request.'; + } + }); + } +} diff --git a/src/app/risk-review/risk-review-data.ts b/src/app/risk-review/risk-review-data.ts index 633de824d..fea25ea07 100644 --- a/src/app/risk-review/risk-review-data.ts +++ b/src/app/risk-review/risk-review-data.ts @@ -372,13 +372,29 @@ export const MATERIALITY = { * Rules whose seriousness does not depend on the amount. A sanctions match or a * deliberate structuring pattern is grave at any value, so activity being small * is not a reason to bring the rating down. + * + * Matched as prefixes, not as whole codes. The rule set names these by variant — + * STRUCTURING_BELOW_50K, STRUCTURING_BELOW_10L, PEP_ACCOUNT_MONITORING — so an + * exact-match Set never matched any of them and the protection was dead: a + * customer caught structuring could be recommended for downgrade purely because + * the amounts involved were small, which is the one case structuring exists to + * catch. */ -export const NON_MITIGABLE_RULES = new Set([ - 'SANCTIONS_HIT', +export const NON_MITIGABLE_RULE_PREFIXES = [ + 'SANCTIONS', 'PEP', 'STRUCTURING', - 'HIGH_RISK_COUNTRY' -]); + 'STRUCTURED', + 'HIGH_RISK_COUNTRY', + 'ADVERSE_MEDIA', + 'WILFUL_DEFAULTER' +]; + +/** Whether a rule code names activity that stays grave however small the amounts are. */ +export function isNonMitigableRule(ruleCode: string): boolean { + const code = (ruleCode || '').toUpperCase(); + return NON_MITIGABLE_RULE_PREFIXES.some((prefix) => code.startsWith(prefix)); +} /** Shape of one element of GET /aml/alerts. */ export interface LiveAmlAlert { diff --git a/src/app/risk-review/risk-review-report.component.html b/src/app/risk-review/risk-review-report.component.html index 503cb0dad..9bef269f2 100644 --- a/src/app/risk-review/risk-review-report.component.html +++ b/src/app/risk-review/risk-review-report.component.html @@ -304,7 +304,9 @@ reason sits under it rather than under the button, because it explains this conclusion and not the act of opening the KYC screen. -->
- + + {{ directionText(c) }} @@ -323,15 +325,32 @@ - + + @if (canChangeRisk(c)) { + + } @else { + + + }
{{ r.voucherNo }}
+ {{ r.voucherNo }} + @if (loadingDetailFor === r.voucherNo) { + hourglass_empty + } + {{ sideLabel(r.side) }} ₹{{ r.amount | number: '1.2-2' }}
+ + + + + + + + + + @for (line of detail.lines; track line.lineNo) { + + + + + + + @if (line.description) { + + + + + } + } @empty { + + + + } + + @if (detail.lines.length > 0) { + + + + + + + } +
SidePartyAccount No.Amount
+ + {{ line.entryType === 'DR' ? 'Debit' : 'Credit' }} + + {{ line.partyName || '—' }}{{ line.accountNo || '—' }}₹{{ line.amount | number: '1.2-2' }}
{{ line.description }}
This voucher's individual lines were not recorded.
Total + ₹{{ detail.totalDebit | number: '1.2-2' }} / ₹{{ detail.totalCredit | number: '1.2-2' }} +
+ + @if (detail.relatedLines.length > 0) { + + } + +
+ +
+
+ `, + styles: [ + ` + .vd-shell { + display: flex; + flex-direction: column; + gap: 16px; + padding: 20px 24px 16px; + min-width: 420px; + max-width: 560px; + } + .vd-header { + display: flex; + align-items: flex-start; + justify-content: space-between; + } + .vd-header__title { + display: flex; + align-items: center; + gap: 12px; + } + .vd-header__title h2 { + margin: 0; + font-size: 18px; + font-weight: 600; + } + .vd-header__title p { + margin: 2px 0 0; + font-size: 13px; + color: rgba(0, 0, 0, 0.6); + } + .vd-icon { + color: #1976d2; + } + .vd-meta { + display: flex; + flex-wrap: wrap; + gap: 6px 16px; + font-size: 13px; + color: rgba(0, 0, 0, 0.65); + } + .vd-unbalanced { + display: inline-flex; + align-items: center; + gap: 4px; + color: #b91c1c; + font-weight: 600; + } + .vd-table { + width: 100%; + border-collapse: collapse; + font-size: 13px; + } + .vd-table th { + text-align: left; + text-transform: uppercase; + font-size: 11px; + letter-spacing: 0.03em; + color: rgba(0, 0, 0, 0.5); + padding: 6px 8px; + border-bottom: 1px solid rgba(0, 0, 0, 0.1); + } + .vd-table td { + padding: 8px; + border-bottom: 1px solid rgba(0, 0, 0, 0.06); + } + .vd-table .num { + text-align: right; + font-variant-numeric: tabular-nums; + } + .vd-acct { + color: rgba(0, 0, 0, 0.55); + } + .vd-side { + display: inline-block; + padding: 2px 8px; + border-radius: 4px; + font-size: 11px; + font-weight: 600; + } + .vd-side--dr { + background: #fee2e2; + color: #b91c1c; + } + .vd-side--cr { + background: #dcfce7; + color: #15803d; + } + .vd-desc-row td { + padding-top: 0; + border-bottom: 1px solid rgba(0, 0, 0, 0.06); + } + .vd-desc { + font-size: 12px; + color: rgba(0, 0, 0, 0.5); + font-style: italic; + } + .vd-empty { + text-align: center; + color: rgba(0, 0, 0, 0.5); + padding: 16px 8px; + } + .vd-table tfoot td { + font-weight: 600; + border-bottom: none; + border-top: 1px solid rgba(0, 0, 0, 0.1); + } + .vd-related { + border-top: 1px dashed rgba(0, 0, 0, 0.15); + padding-top: 12px; + } + .vd-related__title { + display: flex; + align-items: center; + gap: 6px; + font-size: 12px; + font-weight: 600; + text-transform: uppercase; + letter-spacing: 0.03em; + color: #6d28d9; + margin-bottom: 8px; + } + .vd-table--related td { + background: #faf5ff; + } + .vd-related__hint { + margin: 6px 0 0; + font-size: 12px; + color: rgba(0, 0, 0, 0.5); + } + .vd-actions { + display: flex; + justify-content: flex-end; + } + ` + ] +}) +export class VoucherDetailDialogComponent { + dialogRef = inject(MatDialogRef); + private data = inject(MAT_DIALOG_DATA); + + detail = this.data.detail; +} diff --git a/src/app/cashier/transaction-entry/transaction-entry.component.html b/src/app/cashier/transaction-entry/transaction-entry.component.html index de55bc0f0..b6732deb6 100644 --- a/src/app/cashier/transaction-entry/transaction-entry.component.html +++ b/src/app/cashier/transaction-entry/transaction-entry.component.html @@ -166,22 +166,30 @@
- + @if (acTypLockedByToken && row === selectedRow) { + {{ acTypLockedLabel }} + } @else { + + }
@@ -206,14 +214,20 @@ ? ddGlMapping?.ddPayableGlCode || 'Auto GL' : 'Account number' " - [readonly]="row.typ === 'DD' && !isDdAccountDebitRow(row)" - [class.ws-f__ctl--readonly]="row.typ === 'DD' && !isDdAccountDebitRow(row)" + [readonly]=" + (row.typ === 'DD' && !isDdAccountDebitRow(row)) || (acTypLockedByToken && row === selectedRow) + " + [class.ws-f__ctl--readonly]=" + (row.typ === 'DD' && !isDdAccountDebitRow(row)) || (acTypLockedByToken && row === selectedRow) + " [title]=" - isDdAccountDebitRow(row) - ? 'Member account the draft is funded from' - : row.typ === 'DD' - ? 'Configured Sponsor DD Payable GL (Auto-selected)' - : 'Account number' + acTypLockedByToken && row === selectedRow + ? 'Locked — filled from a token-driven request, not a normal account entry' + : isDdAccountDebitRow(row) + ? 'Member account the draft is funded from' + : row.typ === 'DD' + ? 'Configured Sponsor DD Payable GL (Auto-selected)' + : 'Account number' " />
@@ -1523,22 +1537,30 @@
- + @if (acTypLockedByToken && row === selectedRow) { + {{ acTypLockedLabel }} + } @else { + + }
@@ -1556,10 +1578,16 @@ [placeholder]=" row.typ === 'DD' ? ddGlMapping?.ddPayableGlCode || 'Auto GL' : 'Account number' " - [readonly]="row.typ === 'DD'" - [class.ws-f__ctl--readonly]="row.typ === 'DD'" + [readonly]="row.typ === 'DD' || (acTypLockedByToken && row === selectedRow)" + [class.ws-f__ctl--readonly]=" + row.typ === 'DD' || (acTypLockedByToken && row === selectedRow) + " [title]=" - row.typ === 'DD' ? 'Configured Sponsor DD Payable GL (Auto-selected)' : 'Account number' + acTypLockedByToken && row === selectedRow + ? 'Locked — filled from a token-driven request, not a normal account entry' + : row.typ === 'DD' + ? 'Configured Sponsor DD Payable GL (Auto-selected)' + : 'Account number' " />
diff --git a/src/app/cashier/transaction-entry/transaction-entry.component.scss b/src/app/cashier/transaction-entry/transaction-entry.component.scss index 36cd56460..06858c598 100644 --- a/src/app/cashier/transaction-entry/transaction-entry.component.scss +++ b/src/app/cashier/transaction-entry/transaction-entry.component.scss @@ -916,6 +916,20 @@ input.ws-f__ctl--readonly { font-size: 11px; } +/* AC.TP shown as a locked label instead of the normal editable select, while the row is filled + from a token-driven request whose real account type row.acTyp cannot show (see + acTypLockedByToken in the component). */ +span.ws-f__ctl--locked { + display: inline-flex; + align-items: center; + background: #f1f5f9; + color: #475569; + cursor: not-allowed; + user-select: none; + font-size: 11px; + font-weight: 600; +} + input.ws-amt-input--locked { background: #fef9c3; color: #713f12; diff --git a/src/app/cashier/transaction-entry/transaction-entry.component.ts b/src/app/cashier/transaction-entry/transaction-entry.component.ts index 2ea622557..c7daa9584 100644 --- a/src/app/cashier/transaction-entry/transaction-entry.component.ts +++ b/src/app/cashier/transaction-entry/transaction-entry.component.ts @@ -1703,15 +1703,18 @@ export class TransactionEntryComponent implements OnInit, OnDestroy { /** * Account types a clerk may fund a demand draft from. * - * A clerk raises a draft against a member's own balance, so the only sensible sources are the - * accounts a member actually holds money in. GL was offered here too, and picking it quietly - * switched the row onto the cash path — the one that asks for a walk-in purchaser's name and a - * note-and-coin count, neither of which a clerk debiting an account has. The option is removed - * rather than validated afterwards, so the wrong path cannot be reached at all. + * A clerk raises a draft against a member's own balance (SD/CA) or, now, against a GL account + * directly for an institutional/holding-account draft (see isDdAccountDebitRow()) — both are + * governed, maker-checker submissions. GL used to be offered here too in a different sense: picking + * it quietly switched the row onto the CASHIER's walk-in cash path instead (the one that asks for a + * purchaser's name and a note-and-coin count), which isDdAccountDebitRow() now disambiguates by + * role — GL means "debit this GL account" for a clerk, and still means "cash/GL purchase" for a + * cashier, who never reaches this list at all (isDdAccountDebitRow() is clerk-only). */ private static readonly DD_CLERK_ACTYPS = [ 'SD', - 'CA' + 'CA', + 'GL' ]; /** @@ -1726,6 +1729,33 @@ export class TransactionEntryComponent implements OnInit, OnDestroy { } return this.acTypOptions; } + + /** True while the selected row is filled from a token-driven request whose row.acTyp is a + * technical stand-in rather than the account's real type — a dividend claim or an account closure + * both set row.acTyp = 'SD' purely to route past the SHR-specific guards elsewhere on this screen + * (see onTokNoBlur()), not because the counter is paying against a savings account. The AC.TP + * dropdown must not be left editable there: it already shows the wrong value, and a cashier + * changing it further would desync the row from the request it was auto-filled from. A plain + * withdrawal-slip token is deliberately excluded — its row.acTyp is resolved to the account's real + * type there, so the normal editable control already shows it correctly and there's nothing to + * lock. */ + get acTypLockedByToken(): boolean { + return !!(this.activeDividendClaimRequest || this.activeClosureRequest); + } + + /** What to show in place of the AC.TP dropdown while it's locked — the same label the account + * summary strip above already uses for each of these token pools, so the two never contradict + * each other on screen. */ + get acTypLockedLabel(): string { + if (this.activeClosureRequest) { + return 'Closure'; + } + if (this.activeDividendClaimRequest) { + return 'Dividend'; + } + return ''; + } + dbCrOptions = [ 'Dr', 'Cr' @@ -2321,14 +2351,21 @@ export class TransactionEntryComponent implements OnInit, OnDestroy { } /** - * TYPE=DD funded by debiting a member's savings/current account rather than by cash. + * TYPE=DD funded by debiting a member's savings/current account, or a GL account directly, rather + * than by cash. * - * Clerk-only. A cashier's counter takes cash and nothing else, so offering them an account debit - * would let a draft be raised against an account with no cash to reconcile it against — and the - * clerk flow is the one that carries a maker-checker step over the member's balance. + * Clerk-only. A cashier's counter takes cash and nothing else, so offering them an account/GL debit + * would let a draft be raised with no cash to reconcile it against — and the clerk flow is the one + * that carries a maker-checker step over the money moved. AC.TP = GL means something different for + * a cashier's own DD row (a walk-in cash/GL purchase, see isDdCashGlRow()) — this method is checked + * first there specifically so a clerk's GL row is claimed here instead. */ isDdAccountDebitRow(row: TxnRow): boolean { - return this.isClerk && row.typ === 'DD' && (row.acTyp === 'SD' || row.acTyp === 'CA' || row.acTyp === 'CB'); + return ( + this.isClerk && + row.typ === 'DD' && + (row.acTyp === 'SD' || row.acTyp === 'CA' || row.acTyp === 'CB' || row.acTyp === 'GL') + ); } /** @@ -2408,19 +2445,24 @@ export class TransactionEntryComponent implements OnInit, OnDestroy { // An account-funded draft needs the account resolved to a Fineract id before it can be sent: // the server takes a savings/current account id and a client id, not the typed number. Both are // filled in by the A/C NO. lookup, so an unresolved account means the number was never found. + // A GL-funded draft is the exception: a GL account is never tied to a client at all (see + // fetchAllDetails() — GL rows never populate rowClientIds), so only the account id is required. + const isGlDebit = accountDebit && row.acTyp === 'GL'; let fundingAccountId: number | null = null; let fundingClientId: number | null = null; if (accountDebit) { const acNo = (row.acNo || '').trim(); if (!acNo) { this.showAlert('error', 'Account Required', [ - 'Enter the account number the Demand Draft is funded from.' + isGlDebit + ? 'Enter the GL account the Demand Draft is funded from.' + : 'Enter the account number the Demand Draft is funded from.' ]); return; } fundingAccountId = this.rowAccountIds[acNo] ?? null; - fundingClientId = this.rowClientIds[acNo] ?? null; - if (!fundingAccountId || !fundingClientId) { + fundingClientId = isGlDebit ? null : (this.rowClientIds[acNo] ?? null); + if (!fundingAccountId || (!isGlDebit && !fundingClientId)) { this.showAlert('error', 'Account Not Found', [ `Account ${acNo} could not be resolved.`, 'Check the account number and wait for its details to load before raising the draft.' @@ -2510,29 +2552,32 @@ export class TransactionEntryComponent implements OnInit, OnDestroy { } /** - * Raises a Demand Draft funded by debiting a member's savings or current account. + * Raises a Demand Draft funded by debiting a member's savings/current account, or a GL account + * directly. * - * Clerk-only, and a maker-checker request like the cash flow: nothing is debited here. The member's - * account is only touched when a checker approves, which is also when the balance is re-checked — - * so a draft raised against a sufficient balance can still be refused if the balance has since - * gone. No denominations are sent, because no cash is taken. + * Clerk-only, and a maker-checker request like the cash flow: nothing is debited here. The + * account/GL is only touched when a checker approves, which for a member account is also when the + * balance is re-checked — so a draft raised against a sufficient balance can still be refused if + * the balance has since gone (a GL account carries no such balance check). No denominations are + * sent, because no cash is taken. * - * The account goes in savingsAccountId or currentAccountId, never both: the server insists on - * exactly one, since which table the account lives in decides which control GL is debited. + * The funding account goes in exactly one of savingsAccountId, currentAccountId or glAccountId: the + * server insists on exactly one, since which one decides what gets debited on approval. */ private submitAccountDebitDd( row: TxnRow, leaflet: any, beneficiaryData: any, fundingAccountId: number, - fundingClientId: number + fundingClientId: number | null ): void { const d = new Date(); const issuedDate = `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, '0')}-${String(d.getDate()).padStart(2, '0')}`; const isCurrent = row.acTyp === 'CA' || row.acTyp === 'CB'; + const isGl = row.acTyp === 'GL'; const payload = { - clientId: fundingClientId, + clientId: isGl ? undefined : (fundingClientId ?? undefined), beneficiaryName: beneficiaryData.beneficiaryName, payableAtBranch: beneficiaryData.payableAtBranch, payableAtCity: beneficiaryData.payableAtCity, @@ -2540,9 +2585,10 @@ export class TransactionEntryComponent implements OnInit, OnDestroy { chargesAmount: Number(row.ddCharges) || 0, issuingBranchId: row.br, issuedDate, - paymentMethod: 'ACCOUNT_DEBIT' as const, - savingsAccountId: isCurrent ? undefined : fundingAccountId, - currentAccountId: isCurrent ? fundingAccountId : undefined + paymentMethod: (isGl ? 'GL' : 'ACCOUNT_DEBIT') as 'GL' | 'ACCOUNT_DEBIT', + savingsAccountId: isGl || isCurrent ? undefined : fundingAccountId, + currentAccountId: isGl || !isCurrent ? undefined : fundingAccountId, + glAccountId: isGl ? fundingAccountId : undefined }; this.saving = true; @@ -3044,6 +3090,168 @@ export class TransactionEntryComponent implements OnInit, OnDestroy { ); } + /** + * Share redemption settled to a GL account the clerk picks on the CR row, instead of the + * member's linked savings account — mirrors submitShareRedemptionTransfer() above, but the + * credit side is any GL account (e.g. a "DD Payable" holding account), not a fixed linked + * account, so there is no "must match the linked account" check here. + */ + private submitShareGlSettlementTransfer(shareRow: TxnRow, glRow: TxnRow): void { + // Belt-and-braces: the TXN TP. dropdown already hides cash payment types for AC.TP = SHR (see + // paymentTypesForRow()), but re-check here too in case the payment type was set before that + // filter applied. + const settlementPaymentType = this.paymentTypes.find((p: any) => p.id === glRow.paymentTypeId); + if (!glRow.paymentTypeId) { + this.showAlert('error', 'Settlement Payment Type Required', [ + 'Select a Payment Type on the CR (GL account) row — it governs how the settlement credit posts.' + ]); + return; + } + if (settlementPaymentType?.isCashPayment) { + this.showAlert('error', 'Cash Not Allowed for Share Transactions', [ + 'Share redemption settlement is always a book-transfer, never cash.', + 'Select Transfer as the Payment Type on the CR row.' + ]); + return; + } + + const shareAccountId = this.rowAccountIds[shareRow.acNo]; + if (!shareAccountId) { + this.showAlert('error', 'Share Account Not Loaded', [`Please load account details for: ${shareRow.acNo}`]); + return; + } + const settlementGlAccountId = this.rowAccountIds[glRow.acNo]; + if (!settlementGlAccountId) { + this.showAlert('error', 'GL Account Not Loaded', [`Please load account details for: ${glRow.acNo}`]); + return; + } + + const shares = Number(shareRow.amount) || 0; + if (shares <= 0) { + this.showAlert('error', 'Share No. Required', ['Enter the number of shares to withdraw (greater than zero).']); + return; + } + if (!shareRow.resolutionNumber?.trim()) { + this.showAlert('error', 'Board Resolution Number Required', [ + 'A share withdrawal must be authorised by a Board Resolution.' + ]); + return; + } + if (!shareRow.meetingDate) { + this.showAlert('error', 'Date of Meeting Required', ['Enter the Board meeting date that passed the resolution.']); + return; + } + + const today = new Date(); + const requestedDate = `${today.getFullYear()}-${String(today.getMonth() + 1).padStart(2, '0')}-${String(today.getDate()).padStart(2, '0')}`; + + // Full snapshot of the CR (settlement) row exactly as the clerk filled it in — saved verbatim + // for audit purposes, same as the linked-savings path above. + const settlementRowSnapshot = { + branch: glRow.br, + accountType: glRow.acTyp, + accountNo: glRow.acNo, + ecsAccountNo: glRow.ecsAcNo || null, + dbCr: glRow.dbCr, + amount: glRow.amount ?? null, + chequeNo: glRow.chequeNo || null, + chequeDate: glRow.cheqDate || null, + tokenNo: glRow.tokNo || null, + description: glRow.description || null, + bankName: glRow.bankName || null, + routingCode: glRow.routingCode || null, + paymentTypeId: glRow.paymentTypeId, + paymentTypeName: settlementPaymentType?.name ?? null + }; + + const payload = { + shareAccountId, + accountNo: shareRow.acNo, + clientId: this.rowClientIds[shareRow.acNo] ?? null, + clientName: this.rowAccountNames[shareRow.acNo] ?? null, + requestedShares: shares, + requestedDate, + resolutionNumber: shareRow.resolutionNumber.trim(), + meetingDate: shareRow.meetingDate, + description: shareRow.description || null, + settlementPaymentTypeId: glRow.paymentTypeId, + settlementRowSnapshot, + settlementMode: 'GL', + settlementGlAccountId + }; + + const sharePrice = this.rowSharePrices[shareRow.acNo] ?? 0; + const currentHolding = this.rowShareHoldings[shareRow.acNo] ?? 0; + const remainingShares = currentHolding - shares; + const willCloseAccount = remainingShares <= 0; + const confirmLines = [ + `Share Account: ${shareRow.acNo} · Client: ${this.rowAccountNames[shareRow.acNo] ?? '—'}`, + `Shares to Withdraw: ${shares} @ ₹${sharePrice.toLocaleString('en-IN', { minimumFractionDigits: 2 })} = ₹${(shares * sharePrice).toLocaleString('en-IN', { minimumFractionDigits: 2 })}`, + `Remaining Shares After Withdrawal: ${Math.max(remainingShares, 0)}`, + ...(willCloseAccount + ? ['⚠ All shares withdrawn — this account will be CLOSED automatically when the Manager approves.'] + : []), + `Board Resolution No.: ${payload.resolutionNumber}`, + `Date of Meeting: ${payload.meetingDate}`, + `Settlement Credit To (GL): ${glRow.acNo}`, + `Settlement Payment Type: ${settlementPaymentType?.name ?? '—'}`, + ...(settlementRowSnapshot.description ? [`CR Row Remarks: ${settlementRowSnapshot.description}`] : []), + '', + willCloseAccount + ? 'Pending Manager approval — redemption, settlement and account closure all post together on approval.' + : 'Pending Manager approval — redemption and settlement both post together on approval.' + ]; + this.showConfirmAlert('info', 'Confirm Share Withdrawal Request', confirmLines, 'Submit for Approval', () => + this.doSubmitShareGlSettlementTransfer(payload, shareRow, shares, glRow.acNo) + ); + } + + private doSubmitShareGlSettlementTransfer( + payload: Record, + shareRow: TxnRow, + shares: number, + glAcNo: string + ): void { + this.saving = true; + this.clerkShareWithdrawalService + .submit(payload) + .pipe( + catchError((err: any) => { + const msg = + err?.error?.errors?.[0]?.defaultUserMessage ?? + err?.error?.developerMessage ?? + err?.error?.defaultUserMessage ?? + 'Share withdrawal submission failed. Please try again.'; + this.showAlert('error', 'Submission Failed', [msg]); + this.saving = false; + return of(null); + }) + ) + .subscribe((res: any) => { + this.saving = false; + if (res) { + const willCloseAccount = (this.rowShareHoldings[shareRow.acNo] ?? 0) - shares <= 0; + this.showAlert('success', 'Share Withdrawal Submitted', [ + `${shares} share(s) from ${shareRow.acNo} → settlement to GL ${glAcNo} on approval.`, + willCloseAccount + ? 'Pending Manager approval — this will close the share account, since all shares will be withdrawn. Track status in "My Share Requests".' + : 'Pending Manager approval. Track status in "My Share Requests".' + ]); + this.rows = []; + this.rowAccountIds = {}; + this.rowAccountNames = {}; + this.rowSharePrices = {}; + this.rowClientIds = {}; + this.rowLinkedSavingsAcNo = {}; + this.rowShareHoldings = {}; + this.addRow(); + this.selectedRow = this.rows[0]; + this.accountDetails = null; + this.clientDetails = null; + } + }); + } + private doSubmitShareRedemptionTransfer( payload: Record, shareRow: TxnRow, @@ -3103,37 +3311,23 @@ export class TransactionEntryComponent implements OnInit, OnDestroy { .pipe( catchError((err: any) => { // Not a withdrawal-request token at all (as opposed to PENDING/WITHDRAWN/REJECTED, - // which are legitimate matches with their own specific error) — try the dividend - // cash-claim pool, then the account-closure pool, before giving up: all three share - // the same TOK. NO. field at this counter. + // which are legitimate matches with their own specific error) — try the account-closure + // pool before giving up: both share the same TOK. NO. field at this counter. A dividend + // claim was once a third pool here, but a dividend never actually pays out in cash over + // this counter — it only ever settles through a Demand Draft, which is its own DD + // issuance request and its own token, so looking one up here could only ever 404. if (err?.status === 404) { - return this.dividendClaimRequestService.getByToken(token).pipe( - catchError((divErr: any) => { - if (divErr?.status === 404) { - return this.closureRequestService.getByToken(token).pipe( - catchError((closeErr: any) => { - this.loadingWithdrawal = false; - const msg: string = - closeErr?.error?.message ?? - closeErr?.error?.errors?.[0]?.defaultUserMessage ?? - `Token '${token}' could not be verified. Please try again.`; - this.showAlert('error', `Token '${token}'`, [msg]); - return of(null); - }), - map((closeReq: any) => (closeReq ? { __closureRequest: true, ...closeReq } : null)) - ); - } + return this.closureRequestService.getByToken(token).pipe( + catchError((closeErr: any) => { this.loadingWithdrawal = false; const msg: string = - divErr?.error?.message ?? - divErr?.error?.errors?.[0]?.defaultUserMessage ?? + closeErr?.error?.message ?? + closeErr?.error?.errors?.[0]?.defaultUserMessage ?? `Token '${token}' could not be verified. Please try again.`; this.showAlert('error', `Token '${token}'`, [msg]); return of(null); }), - // Only tag a result that actually came from the dividend pool — a closure hit - // recovered by the inner fallback above is already tagged and must stay that way. - map((divReq: any) => (divReq && !divReq.__closureRequest ? { __dividendClaim: true, ...divReq } : divReq)) + map((closeReq: any) => (closeReq ? { __closureRequest: true, ...closeReq } : null)) ); } this.loadingWithdrawal = false; @@ -3159,7 +3353,16 @@ export class TransactionEntryComponent implements OnInit, OnDestroy { this.clientDetails = null; this.accountError = ''; row.acNo = req.client_name ?? 'Dividend Claim #' + req.id; + // Marks this acNo as already resolved, the same way loadAccountDetails() itself would — + // without it, the next click into this row (onRowSelect) sees an acNo that was never + // "loaded" and re-searches for a real account matching this stub string, which can land + // on an unrelated real account whose number happens to share a suffix with it. + this.lastLoadedAcNo = row.acNo; row.acTyp = 'SD'; + // Every token-driven row is cash leaving the branch to a member, which is what the + // Payment register is for — not what this row's TYPE happened to default to before the + // token was entered. + row.typ = 'Payment'; row.amount = Number(req.amount) || null; row.dbCr = 'Dr'; this.selectedRow = row; @@ -3181,7 +3384,10 @@ export class TransactionEntryComponent implements OnInit, OnDestroy { this.clientDetails = null; this.accountError = ''; row.acNo = req.account_no ?? ''; + // See the same line in the dividend-claim branch above for why this is required. + this.lastLoadedAcNo = row.acNo; row.acTyp = 'SD'; + row.typ = 'Payment'; row.amount = Number(req.cashier_disbursed_amount) || null; row.dbCr = 'Dr'; // A closure request stores payment_type_id as NULL, meaning "Cash (Default)" — resolve the @@ -3194,15 +3400,23 @@ export class TransactionEntryComponent implements OnInit, OnDestroy { } this.activeWithdrawalRequest = req; + // A plain withdrawal-slip request carries its own real account_type (SAVINGS, CURRENT, + // FIXED_DEPOSIT or RECURRING_DEPOSIT — see ClerkWithdrawalRequestApiResource.submit()), unlike + // the closure/dividend pools above, which are never anything but a savings-shaped stub. + // Hardcoding 'SD' here searched every non-savings account on the wrong endpoint (Current + // accounts live under /currentaccounts, not /savingsaccounts) and showed it mislabelled even + // when the lookup happened to succeed. + const acTyp = TransactionEntryComponent.SEARCH_ACCOUNT_TYPE_TO_AC_TYP[req.account_type || ''] || 'SD'; row.acNo = req.account_no ?? ''; - row.acTyp = 'SD'; + row.acTyp = acTyp; + row.typ = 'Payment'; row.amount = Number(req.amount) || null; row.dbCr = 'Dr'; row.paymentTypeId = req.payment_type_id ?? null; row.description = req.description ?? ''; this.selectedRow = row; if (req.account_no) { - this.loadAccountDetails(req.account_no.trim(), 'SD'); + this.loadAccountDetails(req.account_no.trim(), acTyp); } }); } @@ -3229,10 +3443,24 @@ export class TransactionEntryComponent implements OnInit, OnDestroy { onAcNoBlur(row: TxnRow, index: number): void { if (!row.acNo.trim()) return; this.selectedRow = row; + // A token-driven row's account fields are already authoritative (see onTokNoBlur) -- merely + // clicking into and out of the A/C NO. field here must not re-search for them. Doing so used + // row.acTyp's internal stand-in value (e.g. 'SD' for a dividend claim, which is never the + // account's real type) against the account's real number, either finding nothing ("Account + // not found") or, worse, matching a completely unrelated real account that happened to share a + // number suffix. + if (this.activeWithdrawalRequest || this.activeDividendClaimRequest || this.activeClosureRequest) { + return; + } this.loadAccountDetails(row.acNo.trim(), row.acTyp); } onAcNoInput(row: TxnRow, index: number, value: string): void { + // Same reasoning as the guard at the top of onAcNoBlur() -- a token-driven row's account + // fields must not be edited or re-searched via this field at all. + if (this.activeWithdrawalRequest || this.activeDividendClaimRequest || this.activeClosureRequest) { + return; + } row.acNo = value; this.lastLoadedAcNo = ''; this.acNoInput$.next({ row, index, value }); @@ -3341,8 +3569,15 @@ export class TransactionEntryComponent implements OnInit, OnDestroy { const match = exactMatch ?? partialMatch ?? idMatch ?? null; if (match?.id) { const matchedGlCode = match[f] ?? acNo; - if (this.selectedRow && this.selectedRow.typ === 'DD' && match.glCode) { - this.selectedRow.acNo = match.glCode; + // Any row resolved via the partial/endsWith/id fallback above (not an exact code match) + // loads and displays the right account, but the row itself still carries whatever shorter + // string the cashier actually typed to search with -- and that's what gets submitted and + // shown everywhere downstream (vouchers, My Work, credit_entries), not the account's real + // full number. Correct the row to the account's actual code whenever the match wasn't + // already exact, for every account type -- not just the DD/GL cases this was previously + // limited to. + if (this.selectedRow && !exactMatch && matchedGlCode) { + this.selectedRow.acNo = matchedGlCode; } const clientIdFromSearch = acTyp !== 'GL' && match.clientId != null ? String(match.clientId) : null; this.fetchAllDetails(match.id, matchedGlCode, cfg, token, acTyp, clientIdFromSearch); @@ -4519,6 +4754,26 @@ export class TransactionEntryComponent implements OnInit, OnDestroy { // "Disbursement". 'Enter Bank Transfer' never carries a GL leg, so it always stays in this // branch; only skip out to the Disbursement/Collection branch below when every row is 'Fund // Transfer' and at least one of them is GL. + // Share redemption settled to a GL account instead of the linked savings account: DR=Share + // account, CR=any GL account the clerk picks (e.g. a "DD Payable" holding account, ahead of a + // second, independent manual entry that later moves the value out to an actual draft). Checked + // ahead of both branches below — the Enter Bank Transfer/Fund Transfer branch would otherwise + // treat a Fund Transfer carrying a GL leg as the Disbursement/Collection shape (see + // fundTransferRowsHaveGl below), and a Share account can never be debited by that path at all — + // no such Fineract command exists — so it would either be silently mis-posted or rejected late. + if ( + this.isClerk && + this.rows.length === 2 && + this.rows.every((r) => r.typ === 'Fund Transfer' || r.typ === 'Enter Bank Transfer') && + this.rows.some((r) => r.dbCr === 'Dr' && r.acTyp === 'SHR') && + this.rows.some((r) => r.dbCr === 'Cr' && r.acTyp === 'GL') + ) { + const shareRow = this.rows.find((r) => r.dbCr === 'Dr' && r.acTyp === 'SHR')!; + const glRow = this.rows.find((r) => r.dbCr === 'Cr' && r.acTyp === 'GL')!; + this.submitShareGlSettlementTransfer(shareRow, glRow); + return; + } + const fundTransferRowsHaveGl = this.rows.every((r) => r.typ === 'Fund Transfer') && this.rows.some((r) => r.acTyp === 'GL'); if ( diff --git a/src/app/shared/icons.module.ts b/src/app/shared/icons.module.ts index 07f2b7ee5..cce399f5a 100644 --- a/src/app/shared/icons.module.ts +++ b/src/app/shared/icons.module.ts @@ -56,6 +56,7 @@ import { faGavel, faGlobe, faHandHoldingUsd, + faHandPaper, faHome, faKey, faLink, @@ -303,6 +304,7 @@ export class IconsModule { faHandHoldingHand, faHandHoldingHeart, faHandHoldingUsd, + faHandPaper, faHeart, faHome, faKey, diff --git a/src/app/sponsor-dd/dd-inventory-list/dd-inventory-list.component.html b/src/app/sponsor-dd/dd-inventory-list/dd-inventory-list.component.html index e1a192400..b4b5384ed 100644 --- a/src/app/sponsor-dd/dd-inventory-list/dd-inventory-list.component.html +++ b/src/app/sponsor-dd/dd-inventory-list/dd-inventory-list.component.html @@ -5,124 +5,244 @@ 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/. --> -
-
-
-

Sponsor Bank DD Inventory

-

Manage allocated ranges of physical Demand Draft leaflets received from Sponsor Banks.

+ +
+ +
+
+
+ +
+
+

Sponsor Bank DD Inventory

+

+ Manage allocated ranges of physical Demand Draft leaflets received from Sponsor Banks. +

+
- +
- + @if (dividendContext) { -
-
- Issuing a dividend Demand Draft +
+
+ +
+
+ Issuing Dividend Demand Draft - {{ dividendContext.beneficiary }} · - {{ dividendContext.amount | number: '1.2-2' }} + Beneficiary: {{ dividendContext.beneficiary }} · Amount: + ₹{{ dividendContext.amount | number: '1.2-2' }} @if (dividendContext.financialYear) { · FY {{ dividendContext.financialYear }} } - Open a series below and choose an available leaflet. + Select a series below to pick an available leaflet.
- +
} - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - -
Batch ID{{ element.id }}Sponsor Bank{{ element.sponsorBankName }}Branch{{ element.branchName }}Start No.{{ element.seriesStart }}End No.{{ element.seriesEnd }}Total Leaves{{ element.totalLeaves }}Available Leaves - {{ element.availableLeaves }} - Received Date{{ element.receivedDate }}Status - - {{ element.status }} - - Actions - - -
- -
- No DD series registered yet. Click "Register DD Series" to add stock. + +
+
+
+ +
+
+ Total Series Batches + {{ totalBatches }} +
- + +
+
+ +
+
+ Active Stock Series + {{ activeBatchesCount }} +
+
+ +
+
+ +
+
+ Total Allocated Leaves + {{ totalLeavesCount | number }} +
+
+ +
+
+ +
+
+ Available Leaves + {{ totalAvailableLeaves | number }} +
+
+
+ + +
+ + +
+ + + +
+
+ + +
+
+
+ + Registered Series + {{ filteredData.length }} of {{ dataSource.length }} +
+
+ +
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
Batch ID#{{ element.id }}Sponsor Bank + + {{ element.sponsorBankName }} + Branch{{ element.branchName }}Start No.{{ element.seriesStart }}End No.{{ element.seriesEnd }}Total Leaves{{ element.totalLeaves }}Available Leaves + + {{ element.availableLeaves }} + + Received Date{{ element.receivedDate }}Status + + + {{ element.status }} + + Actions + +
+ + @if (filteredData.length === 0) { +
+ +

No DD Series Found

+

No registered DD stock series match your filter criteria.

+
+ } +
+
diff --git a/src/app/sponsor-dd/dd-inventory-list/dd-inventory-list.component.ts b/src/app/sponsor-dd/dd-inventory-list/dd-inventory-list.component.ts index 22da93780..9fe0013b0 100644 --- a/src/app/sponsor-dd/dd-inventory-list/dd-inventory-list.component.ts +++ b/src/app/sponsor-dd/dd-inventory-list/dd-inventory-list.component.ts @@ -22,6 +22,8 @@ import { MatNativeDateModule } from '@angular/material/core'; import { MatSnackBar, MatSnackBarModule } from '@angular/material/snack-bar'; import { FaIconComponent } from '@fortawesome/angular-fontawesome'; import { HttpClient } from '@angular/common/http'; +import { MatIconModule } from '@angular/material/icon'; +import { MatTooltipModule } from '@angular/material/tooltip'; import { SponsorDDService } from '../sponsor-dd.service'; @Component({ @@ -30,106 +32,482 @@ import { SponsorDDService } from '../sponsor-dd.service'; imports: [ CommonModule, RouterModule, + FormsModule, + ReactiveFormsModule, MatCardModule, MatTableModule, MatButtonModule, MatDialogModule, - MatSnackBarModule + MatSnackBarModule, + MatFormFieldModule, + MatInputModule, + MatIconModule, + MatTooltipModule, + FaIconComponent ], templateUrl: './dd-inventory-list.component.html', styles: [ ` - .inventory-container { + .inv-container { padding: 24px; - max-width: 1200px; + max-width: 1360px; margin: 0 auto; } - .header-row { + + /* Hero Header */ + .inv-hero { display: flex; justify-content: space-between; align-items: center; margin-bottom: 24px; + gap: 16px; + flex-wrap: wrap; } - .title-area h2 { - margin: 0 0 4px 0; - font-size: 24px; - font-weight: 600; - color: #1e293b; - } - .title-area p { - margin: 0; - color: #64748b; - font-size: 14px; - } - .mat-mdc-card { - border-radius: 12px; - border: 1px solid #e2e8f0; - box-shadow: 0 1px 3px rgba(0, 0, 0, 0.05); - overflow: hidden; - } - table { - width: 100%; - } - th { - font-weight: 600; - color: #475569; - background-color: #f8fafc; - } - td { - color: #334155; - } - .status-badge { - display: inline-flex; - align-items: center; - padding: 4px 8px; - border-radius: 9999px; - font-size: 12px; - font-weight: 500; - } - .status-badge.active { - background-color: #dcfce7; - color: #15803d; - } - .status-badge.exhausted { - background-color: #f1f5f9; - color: #475569; - } - .action-btn { - color: #7c3aed; - font-weight: 500; - } - .leaves-low { - color: #b91c1c; - font-weight: 600; - } - .dividend-banner { + .inv-hero__left { display: flex; align-items: center; gap: 16px; - margin-bottom: 20px; - padding: 12px 16px; - border: 1px solid #c7d2fe; - border-left: 4px solid #4f46e5; - border-radius: 8px; - background-color: #eef2ff; } - .dividend-banner__text { + .inv-hero__icon { + width: 52px; + height: 52px; + border-radius: 14px; + background: linear-gradient(135deg, #2563eb 0%, #4f46e5 100%); + color: #ffffff; + display: flex; + align-items: center; + justify-content: center; + font-size: 22px; + box-shadow: 0 4px 14px rgba(37, 99, 235, 0.25); + } + .inv-hero__title { + margin: 0; + font-size: 22px; + font-weight: 800; + color: #0f172a; + letter-spacing: -0.02em; + } + .inv-hero__sub { + margin: 2px 0 0 0; + font-size: 13.5px; + color: #64748b; + } + + /* Dividend Banner */ + .inv-dividend-banner { + display: flex; + align-items: center; + gap: 16px; + margin-bottom: 24px; + padding: 16px 20px; + border-radius: 12px; + border: 1px solid #c7d2fe; + background: linear-gradient(135deg, #eef2ff 0%, #f5f3ff 100%); + box-shadow: 0 2px 8px rgba(79, 70, 229, 0.08); + } + .inv-dividend-banner__ic { + width: 40px; + height: 40px; + border-radius: 10px; + background: #4f46e5; + color: #fff; + display: flex; + align-items: center; + justify-content: center; + font-size: 16px; + flex-shrink: 0; + } + .inv-dividend-banner__text { display: flex; flex-direction: column; gap: 2px; - font-size: 13px; + font-size: 13.5px; color: #312e81; } - .dividend-banner__text strong { - font-size: 13.5px; - } - .dividend-banner__hint { - color: #4338ca; + .inv-dividend-banner__hint { font-size: 12px; + color: #4338ca; } - .dividend-banner button { + .inv-dividend-banner button { margin-left: auto; } + + /* KPI Cards */ + .inv-kpi-grid { + display: grid; + grid-template-columns: repeat(auto-fit, minmax(240px, 1fr)); + gap: 16px; + margin-bottom: 24px; + } + .inv-kpi-card { + background: #ffffff; + border-radius: 12px; + padding: 18px 20px; + border: 1px solid #e2e8f0; + display: flex; + align-items: center; + gap: 16px; + box-shadow: 0 1px 3px rgba(15, 23, 42, 0.04); + transition: + transform 0.15s ease, + box-shadow 0.15s ease; + } + .inv-kpi-card:hover { + transform: translateY(-2px); + box-shadow: 0 6px 16px rgba(15, 23, 42, 0.08); + } + .inv-kpi-card__icon { + width: 46px; + height: 46px; + border-radius: 12px; + display: flex; + align-items: center; + justify-content: center; + font-size: 18px; + flex-shrink: 0; + } + .inv-kpi-card__icon.blue { + background: #eff6ff; + color: #2563eb; + } + .inv-kpi-card__icon.green { + background: #f0fdf4; + color: #16a34a; + } + .inv-kpi-card__icon.purple { + background: #faf5ff; + color: #9333ea; + } + .inv-kpi-card__icon.emerald { + background: #ecfdf5; + color: #059669; + } + .inv-kpi-card__info { + display: flex; + flex-direction: column; + } + .inv-kpi-card__label { + font-size: 12px; + font-weight: 600; + color: #64748b; + text-transform: uppercase; + letter-spacing: 0.04em; + } + .inv-kpi-card__val { + font-size: 24px; + font-weight: 800; + color: #0f172a; + margin-top: 2px; + line-height: 1; + } + + /* Toolbar */ + .inv-toolbar { + display: flex; + align-items: center; + justify-content: space-between; + gap: 16px; + margin-bottom: 16px; + flex-wrap: wrap; + } + .inv-search { + position: relative; + flex: 1; + min-width: 280px; + max-width: 460px; + } + .inv-search input { + width: 100%; + padding: 10px 14px 10px 38px; + border: 1px solid #cbd5e1; + border-radius: 10px; + font-size: 13.5px; + background: #ffffff; + color: #0f172a; + outline: none; + box-sizing: border-box; + transition: + border-color 0.15s, + box-shadow 0.15s; + } + .inv-search input:focus { + border-color: #2563eb; + box-shadow: 0 0 0 3px rgba(37, 99, 235, 0.12); + } + .inv-search__ic { + position: absolute; + left: 13px; + top: 50%; + transform: translateY(-50%); + color: #94a3b8; + font-size: 13px; + pointer-events: none; + } + .inv-search__clear { + position: absolute; + right: 10px; + top: 50%; + transform: translateY(-50%); + border: none; + background: transparent; + color: #94a3b8; + cursor: pointer; + padding: 4px; + } + .inv-search__clear:hover { + color: #ef4444; + } + + .inv-filter-chips { + display: flex; + align-items: center; + gap: 8px; + } + .inv-chip { + display: inline-flex; + align-items: center; + gap: 8px; + padding: 8px 14px; + border-radius: 999px; + border: 1px solid #e2e8f0; + background: #ffffff; + color: #475569; + font-size: 13px; + font-weight: 600; + cursor: pointer; + transition: all 0.15s ease; + } + .inv-chip:hover { + background: #f8fafc; + border-color: #cbd5e1; + } + .inv-chip.active { + background: #1e40af; + border-color: #1e40af; + color: #ffffff !important; + } + .inv-chip.active-chip.active { + background: #16a34a; + border-color: #16a34a; + color: #ffffff !important; + } + .inv-chip.exhausted-chip.active { + background: #475569; + border-color: #475569; + color: #ffffff !important; + } + .inv-chip__dot { + width: 7px; + height: 7px; + border-radius: 50%; + } + .inv-chip__dot.green { + background: #16a34a; + } + .inv-chip__dot.grey { + background: #64748b; + } + .inv-chip.active .inv-chip__dot { + background: #ffffff; + } + .inv-chip__count { + padding: 2px 7px; + border-radius: 999px; + background: rgba(0, 0, 0, 0.07); + font-size: 11px; + font-weight: 700; + } + .inv-chip.active .inv-chip__count { + background: rgba(255, 255, 255, 0.25); + color: #ffffff; + } + + /* Card & Table */ + .inv-card { + background: #ffffff; + border-radius: 14px; + border: 1px solid #e2e8f0; + box-shadow: 0 4px 12px rgba(15, 23, 42, 0.05); + overflow: hidden; + } + .inv-card__head { + padding: 16px 20px; + border-bottom: 1px solid #e2e8f0; + background: linear-gradient(180deg, #f8fafc 0%, #ffffff 100%); + } + .inv-card__title { + display: flex; + align-items: center; + gap: 8px; + font-size: 15px; + font-weight: 700; + color: #0f172a; + } + .inv-card__title-ic { + color: #2563eb; + } + .inv-card__count { + margin-left: 6px; + padding: 2px 10px; + border-radius: 999px; + background: #eff6ff; + color: #1e40af; + font-size: 11px; + font-weight: 700; + } + + .inv-table-wrap { + overflow-x: auto; + } + .inv-table { + width: 100%; + border-collapse: collapse; + } + .inv-table th { + background: #f1f5f9; + color: #475569; + font-size: 11px; + font-weight: 700; + text-transform: uppercase; + letter-spacing: 0.05em; + padding: 12px 16px; + border-bottom: 1px solid #e2e8f0; + white-space: nowrap; + } + .inv-table td { + padding: 12px 16px; + color: #334155; + border-bottom: 1px solid #f1f5f9; + font-size: 13.5px; + vertical-align: middle; + } + .inv-table tr:hover { + background: #f8fafc; + } + + .inv-cell-bank { + display: flex; + align-items: center; + gap: 8px; + } + .bank-icon { + color: #2563eb; + font-size: 14px; + } + + .inv-mono { + font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace; + } + .inv-strong { + font-weight: 700; + color: #0f172a; + } + .inv-muted { + color: #64748b; + } + .inv-num { + text-align: right; + } + + .inv-leaves-badge { + display: inline-block; + padding: 4px 10px; + border-radius: 6px; + background: #f0fdf4; + color: #16a34a; + font-weight: 700; + font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace; + } + .inv-leaves-badge.low { + background: #fef2f2; + color: #dc2626; + } + + .inv-badge { + display: inline-flex; + align-items: center; + gap: 6px; + padding: 4px 10px; + border-radius: 999px; + font-size: 12px; + font-weight: 600; + } + .inv-badge__dot { + width: 6px; + height: 6px; + border-radius: 50%; + background: currentColor; + } + .inv-badge.active { + background: #dcfce7; + color: #15803d; + } + .inv-badge.exhausted { + background: #f1f5f9; + color: #64748b; + } + + .inv-actions-col { + text-align: right; + } + + /* Buttons */ + .inv-btn { + display: inline-flex; + align-items: center; + gap: 8px; + padding: 9px 18px; + border-radius: 10px; + font-size: 13.5px; + font-weight: 600; + cursor: pointer; + border: 1px solid transparent; + transition: all 0.15s ease; + } + .inv-btn.primary { + background: linear-gradient(135deg, #2563eb 0%, #1d4ed8 100%); + color: #ffffff; + box-shadow: 0 2px 8px rgba(37, 99, 235, 0.3); + } + .inv-btn.primary:hover { + transform: translateY(-1px); + box-shadow: 0 4px 14px rgba(37, 99, 235, 0.4); + } + .inv-btn.ghost { + background: #ffffff; + border-color: #cbd5e1; + color: #475569; + } + .inv-btn.ghost:hover { + background: #f8fafc; + color: #0f172a; + } + .inv-btn.small { + padding: 6px 12px; + font-size: 12px; + border-radius: 8px; + } + + .inv-empty { + text-align: center; + padding: 48px 20px; + color: #64748b; + } + .inv-empty__ic { + font-size: 36px; + color: #cbd5e1; + margin-bottom: 8px; + } + .inv-empty__title { + font-size: 16px; + font-weight: 700; + color: #334155; + margin: 0 0 4px 0; + } + .inv-empty__sub { + font-size: 13px; + margin: 0; + } ` ] }) @@ -140,14 +518,9 @@ export class DdInventoryListComponent implements OnInit { private route = inject(ActivatedRoute); private router = inject(Router); - /** - * The dividend this visit is drawing a draft for, when the clerk arrived from a dividend claim - * rather than from the menu. - * - * Null on an ordinary visit, which is what keeps this page's normal behaviour untouched — the - * banner, the relabelled action and the forwarded query params all hang off this being set. - */ dividendContext: { lockId: number; amount: number; beneficiary: string; financialYear: string } | null = null; + searchText = ''; + selectedStatusFilter = 'ALL'; displayedColumns: string[] = [ 'id', @@ -168,13 +541,6 @@ export class DdInventoryListComponent implements OnInit { this.loadInventories(); } - /** - * Picks up the dividend a claim sent us here to pay out. - * - * Read from the URL rather than handed over in a service so a refreshed or bookmarked page still - * knows which lock it is drawing against — losing it silently would issue an ordinary counter - * draft against the member instead of settling their dividend. - */ private readDividendContext(): void { const params = this.route.snapshot.queryParams; const lockId = Number(params['dividendLockId']); @@ -189,7 +555,6 @@ export class DdInventoryListComponent implements OnInit { }; } - /** The dividend context to carry onto the leaflets page; empty on an ordinary visit. */ get dividendQueryParams(): Record { return this.dividendContext ? { @@ -201,7 +566,6 @@ export class DdInventoryListComponent implements OnInit { : {}; } - /** Abandons the claim and returns to the dividend dashboard, leaving the lock untouched. */ cancelDividendClaim(): void { this.router.navigate([ '/clerk', @@ -212,7 +576,7 @@ export class DdInventoryListComponent implements OnInit { loadInventories(): void { this.sponsorDDService.getInventories().subscribe({ next: (data) => { - this.dataSource = data; + this.dataSource = data || []; }, error: (err) => { this.snackBar.open('Error loading inventories: ' + (err.error?.message || err.message), 'Close', { @@ -222,9 +586,44 @@ export class DdInventoryListComponent implements OnInit { }); } + get totalBatches(): number { + return this.dataSource.length; + } + + get activeBatchesCount(): number { + return this.dataSource.filter((x) => (x.status || '').toUpperCase() === 'ACTIVE').length; + } + + get totalLeavesCount(): number { + return this.dataSource.reduce((acc, curr) => acc + (Number(curr.totalLeaves) || 0), 0); + } + + get totalAvailableLeaves(): number { + return this.dataSource.reduce((acc, curr) => acc + (Number(curr.availableLeaves) || 0), 0); + } + + get filteredData(): any[] { + let list = this.dataSource; + if (this.selectedStatusFilter !== 'ALL') { + list = list.filter((item) => (item.status || '').toUpperCase() === this.selectedStatusFilter); + } + if (this.searchText.trim()) { + const q = this.searchText.trim().toLowerCase(); + list = list.filter( + (item) => + String(item.id).toLowerCase().includes(q) || + (item.sponsorBankName || '').toLowerCase().includes(q) || + (item.branchName || '').toLowerCase().includes(q) || + String(item.seriesStart).includes(q) || + String(item.seriesEnd).includes(q) + ); + } + return list; + } + openAddInventoryDialog(): void { const dialogRef = this.dialog.open(AddInventoryDialogComponent, { - width: '500px', + width: '520px', disableClose: true }); diff --git a/src/app/sponsor-dd/dd-list/dd-list.component.html b/src/app/sponsor-dd/dd-list/dd-list.component.html index 1a0b5be09..4350c76c8 100644 --- a/src/app/sponsor-dd/dd-list/dd-list.component.html +++ b/src/app/sponsor-dd/dd-list/dd-list.component.html @@ -5,251 +5,326 @@ 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/. --> -
-
-
- -

DD Leaflet Registry

-

- Sponsor Bank: {{ inventoryDetails.sponsorBankName }} | Series Range: - {{ inventoryDetails.seriesStart }} - {{ inventoryDetails.seriesEnd }} -

+ +
+

DD Leaflet Registry

+
+ Sponsor Bank {{ inventoryDetails.sponsorBankName }} · Series + {{ inventoryDetails.seriesStart }}–{{ inventoryDetails.seriesEnd }} +
+
+
+
+ + +
+
+
+ +
+
+ Total Stock + {{ dataSource.data.length }} +
+
+ +
+
+ +
+
+ Available + {{ statusCount('AVAILABLE') }} +
+
+ +
+
+ +
+
+ Issued + {{ statusCount('ISSUED') }} +
+
+ +
+
+ +
+
+ Cancelled / Stopped + {{ statusCount('CANCELLED') + statusCount('STOPPED') }} +
+
+ +
+
+ +
+
+ Revoked / Spoiled + {{ statusCount('REVOKED') }} +
-
-
+
+ +
Issuing a dividend Demand Draft - {{ dividendContext.beneficiary }} · {{ dividendContext.amount | number: '1.2-2' }} + {{ dividendContext.beneficiary }} · ₹{{ dividendContext.amount | number: '1.2-2' }} · FY {{ dividendContext.financialYear }} - Pick an available leaflet below and press Issue. + Pick an available leaflet below and press "Draw for Dividend".
- +
- - Search by DD Leaflet No. - - - -
- +
+ +
+ + +
-

- Only DDs actually ISSUED today are shown - AVAILABLE, CANCELLED, STOPPED and CLEARED are hidden. Click "Showing All +

+ + Only DDs actually ISSUED today are shown — Available, Cancelled, Stopped and Cleared are hidden. Click "Showing All Dates" above to see everything.

- - - - - - - + +
+
+
+ + Leaflets + {{ dataSource.filteredData.length }} of {{ dataSource.data.length }} +
+
+
+
DD Leaflet No. - {{ element.ddNumber }} -
+ + + + + - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - + + - - - - Revoke Pending Approval (Req #{{ element.pendingRevokeRequestId }}) - + + + + + - - - Revoked → replaced by DD {{ element.revokedReplacedByDdNumber }} - - - Replaces spoiled DD {{ element.revokedReplacementOfDdNumber }} - - - - + + + + + + - - -
DD Leaflet No.{{ element.ddNumber }}Status - - {{ element.status }} - - Face Value - {{ element.faceValue > 0 ? (element.faceValue | number: '1.2-2') : '-' }} - Purchaser / Client - {{ element.issuedToClientName || element.walkinPurchaserName || '-' }} - Beneficiary{{ element.beneficiaryName || '-' }}Issued Date{{ element.issuedDate || '-' }}Advice Status - {{ element.status === 'ISSUED' ? element.adviceStatus : '-' }} - Actions -
- - - - Pending Approval (Req #{{ element.pendingIssueRequestId }}) - - - - - Amendment Pending (Req #{{ element.pendingAmendmentRequestId }}) - - - - - - - - Reprint Pending Approval (Req #{{ element.pendingReprintRequestId }}) + + +
Status + + + {{ element.status | titlecase }} + Face Value + {{ element.faceValue > 0 ? '₹' + (element.faceValue | number: '1.2-2') : '—' }} + Purchaser / Client + {{ element.issuedToClientName || element.walkinPurchaserName || '—' }} +
+ + + Beneficiary + {{ element.beneficiaryName || '—' }} + -
- {{ dataSource.data.length === 0 ? 'Loading leaflets...' : 'No leaflets match the selected filter.' }} + + + Issued Date + {{ element.issuedDate || '—' }} + + + + + Advice Status + + {{ element.status === 'ISSUED' ? (element.adviceStatus | titlecase) : '—' }} + + + + + + Actions + +
+ + + + + Pending Approval (Req #{{ element.pendingIssueRequestId }}) + + + + + + + + + + + + + + + + + + + + + + Amendment Pending (Req #{{ element.pendingAmendmentRequestId }}) + + + + Reprint Pending (Req #{{ element.pendingReprintRequestId }}) + + + + Revoke Pending (Req #{{ element.pendingRevokeRequestId }}) + + + + + + Replaced by DD {{ element.revokedReplacedByDdNumber }} + + + + Replaces spoiled DD {{ element.revokedReplacementOfDdNumber }} + +
+ +
+ + + + + +
+ + {{ dataSource.data.length === 0 ? 'Loading leaflets…' : 'No leaflets match the selected filter.' }} +
-
+
diff --git a/src/app/sponsor-dd/dd-list/dd-list.component.ts b/src/app/sponsor-dd/dd-list/dd-list.component.ts index d9df2dc0d..2a45d2e05 100644 --- a/src/app/sponsor-dd/dd-list/dd-list.component.ts +++ b/src/app/sponsor-dd/dd-list/dd-list.component.ts @@ -21,6 +21,9 @@ import { MatSelectModule } from '@angular/material/select'; import { MatDatepickerModule } from '@angular/material/datepicker'; import { MatNativeDateModule } from '@angular/material/core'; import { MatSnackBar, MatSnackBarModule } from '@angular/material/snack-bar'; +import { MatMenuModule } from '@angular/material/menu'; +import { MatIconModule } from '@angular/material/icon'; +import { MatTooltipModule } from '@angular/material/tooltip'; import { FaIconComponent } from '@fortawesome/angular-fontawesome'; import { HttpClient } from '@angular/common/http'; import { debounceTime } from 'rxjs/operators'; @@ -40,145 +43,624 @@ import { DividendClaimRequestService } from '../../clerk/dividend-claim-request. MatDialogModule, MatSnackBarModule, MatFormFieldModule, - MatInputModule + MatInputModule, + MatMenuModule, + MatIconModule, + MatTooltipModule, + FaIconComponent ], templateUrl: './dd-list.component.html', styles: [ ` - .leaflet-container { - padding: 24px; - max-width: 1200px; + /* Same visual language as users.component.scss (gradient header icon, KPI-style chips, card + table with sticky head + row-hover, icon-button/overflow-menu actions) so this registry + reads as part of the same app rather than a one-off style. */ + .ddreg-container { + padding: 20px 24px; + max-width: 1320px; margin: 0 auto; } - .dividend-banner { + + /* ─── Header ─── */ + .ddreg-header { display: flex; align-items: center; - gap: 16px; - margin-bottom: 20px; + margin-bottom: 16px; + } + .ddreg-header__left { + display: flex; + align-items: center; + gap: 12px; + } + .ddreg-back { + width: 36px; + height: 36px; + display: inline-flex; + align-items: center; + justify-content: center; + border-radius: 9px; + border: 1px solid #d0d5dd; + background: #fff; + color: #475569; + cursor: pointer; + transition: all 0.15s; + } + .ddreg-back:hover { + background: #f8fafc; + border-color: #94a3b8; + color: #1e293b; + } + .ddreg-header__icon { + width: 44px; + height: 44px; + display: inline-flex; + align-items: center; + justify-content: center; + border-radius: 12px; + background: linear-gradient(135deg, #1e88e5 0%, #7c3aed 100%); + color: #fff; + font-size: 18px; + box-shadow: 0 2px 8px rgb(29 78 216 / 25%); + } + .ddreg-header__title { + margin: 0; + font-size: 20px; + font-weight: 800; + color: #0d1b2a; + line-height: 1.1; + } + .ddreg-header__sub { + margin-top: 2px; + font-size: 12.5px; + color: #64748b; + } + + /* ─── Dividend context banner ─── */ + .ddreg-dividend { + display: flex; + align-items: center; + gap: 14px; + margin-bottom: 16px; padding: 12px 16px; border: 1px solid #c7d2fe; border-left: 4px solid #4f46e5; - border-radius: 8px; - background-color: #eef2ff; + border-radius: 10px; + background: linear-gradient(180deg, #eef2ff 0%, #f5f3ff 100%); } - .dividend-banner__text { + .ddreg-dividend__ic { + width: 34px; + height: 34px; + flex-shrink: 0; + display: inline-flex; + align-items: center; + justify-content: center; + border-radius: 9px; + background: #4f46e5; + color: #fff; + font-size: 14px; + } + .ddreg-dividend__text { display: flex; flex-direction: column; gap: 2px; font-size: 13px; color: #312e81; } - .dividend-banner__hint { + .ddreg-dividend__hint { color: #4338ca; font-size: 12px; } - .dividend-banner button { + .ddreg-dividend .ddreg-btn { margin-left: auto; } - .header-row { - display: flex; - justify-content: space-between; - align-items: center; - margin-bottom: 24px; - } - .title-area h2 { - margin: 0 0 4px 0; - font-size: 24px; - font-weight: 600; - color: #1e293b; - } - .title-area p { - margin: 0; - color: #64748b; - font-size: 14px; - } - .search-field { - width: 320px; - display: block; - margin-bottom: 8px; - } - .mat-mdc-card { - border-radius: 12px; - border: 1px solid #e2e8f0; - box-shadow: 0 1px 3px rgba(0, 0, 0, 0.05); - overflow: hidden; - } - table { - width: 100%; - } - th { - font-weight: 600; - color: #475569; - background-color: #f8fafc; - } - td { - color: #334155; - } - .status-badge { + + /* ─── Buttons ─── */ + .ddreg-btn { display: inline-flex; align-items: center; - padding: 4px 8px; - border-radius: 9999px; + gap: 8px; + padding: 8px 16px; + border-radius: 9px; + font-size: 13px; + font-weight: 600; + cursor: pointer; + transition: all 0.15s; + white-space: nowrap; + border: 1px solid transparent; + background: #fff; + } + .ddreg-btn fa-icon { font-size: 12px; - font-weight: 500; } - .status-badge.available { - background-color: #dcfce7; - color: #15803d; - } - .status-badge.issued { - background-color: #dbeafe; - color: #1d4ed8; - } - .status-badge.cleared { - background-color: #f1f5f9; + .ddreg-btn.ghost { + border-color: #d0d5dd; color: #475569; } - .status-badge.cancelled { - background-color: #fee2e2; + .ddreg-btn.ghost:hover { + background: #f8fafc; + border-color: #94a3b8; + color: #0d1b2a; + } + .ddreg-btn.ghost.active { + background: #ede9fe; + border-color: #c4b5fd; + color: #6d28d9; + } + .ddreg-btn.primary { + background: linear-gradient(135deg, #1e88e5 0%, #1e40af 100%); + border-color: #1e40af; + color: #fff; + box-shadow: 0 2px 6px -2px rgb(30 64 175 / 35%); + } + .ddreg-btn.primary:hover { + box-shadow: 0 4px 12px -3px rgb(30 64 175 / 45%); + transform: translateY(-1px); + } + .ddreg-btn.small { + padding: 6px 12px; + font-size: 12px; + } + + /* ─── Toolbar ─── */ + .ddreg-toolbar { + display: flex; + align-items: center; + gap: 12px; + flex-wrap: wrap; + margin-bottom: 12px; + } + .ddreg-toolbar__search { + position: relative; + flex: 1; + min-width: 260px; + max-width: 420px; + } + .ddreg-toolbar__search input { + width: 100%; + padding: 9px 34px; + border: 1px solid #d0d5dd; + border-radius: 9px; + font-size: 13px; + background: #fff; + color: #0d1b2a; + transition: all 0.15s; + box-sizing: border-box; + } + .ddreg-toolbar__search input:focus { + outline: none; + border-color: #1e88e5; + box-shadow: 0 0 0 3px rgb(30 136 229 / 12%); + } + .ddreg-toolbar__search-ic { + position: absolute; + left: 12px; + top: 50%; + transform: translateY(-50%); + color: #94a3b8; + font-size: 12px; + pointer-events: none; + } + .ddreg-toolbar__clear { + position: absolute; + right: 8px; + top: 50%; + transform: translateY(-50%); + width: 22px; + height: 22px; + border-radius: 50%; + border: 1px solid #e2e8f0; + background: #fff; + color: #94a3b8; + font-size: 10px; + cursor: pointer; + } + .ddreg-toolbar__clear:hover { + background: #fef2f2; + color: #ef4444; + border-color: #fecaca; + } + + /* ─── KPI Cards ─── */ + .ddreg-kpi-grid { + display: grid; + grid-template-columns: repeat(auto-fit, minmax(200px, 1fr)); + gap: 14px; + margin-bottom: 20px; + } + .ddreg-kpi-card { + background: #ffffff; + border-radius: 12px; + padding: 16px 18px; + border: 1px solid #e2e8f0; + display: flex; + align-items: center; + gap: 14px; + box-shadow: 0 1px 3px rgba(15, 23, 42, 0.04); + transition: + transform 0.15s ease, + box-shadow 0.15s ease; + } + .ddreg-kpi-card:hover { + transform: translateY(-2px); + box-shadow: 0 6px 16px rgba(15, 23, 42, 0.08); + } + .ddreg-kpi-card__icon { + width: 42px; + height: 42px; + border-radius: 10px; + display: flex; + align-items: center; + justify-content: center; + font-size: 16px; + flex-shrink: 0; + } + .ddreg-kpi-card__icon.blue { + background: #eff6ff; + color: #2563eb; + } + .ddreg-kpi-card__icon.green { + background: #f0fdf4; + color: #16a34a; + } + .ddreg-kpi-card__icon.indigo { + background: #eef2ff; + color: #4f46e5; + } + .ddreg-kpi-card__icon.amber { + background: #fffbeb; + color: #d97706; + } + .ddreg-kpi-card__icon.slate { + background: #f8fafc; + color: #64748b; + } + + .ddreg-kpi-card__info { + display: flex; + flex-direction: column; + } + .ddreg-kpi-card__label { + font-size: 11.5px; + font-weight: 600; + color: #64748b; + text-transform: uppercase; + letter-spacing: 0.04em; + } + .ddreg-kpi-card__val { + font-size: 22px; + font-weight: 800; + color: #0f172a; + margin-top: 1px; + line-height: 1; + } + + /* ─── Status filter chips (doubles as the count-at-a-glance) ─── */ + .ddreg-chips { + display: flex; + flex-wrap: wrap; + gap: 8px; + margin-bottom: 8px; + } + .ddreg-chip { + display: inline-flex; + align-items: center; + gap: 7px; + padding: 7px 12px; + border-radius: 999px; + border: 1px solid #e2e8f0; + background: #fff; + color: #475569; + font-size: 12.5px; + font-weight: 600; + cursor: pointer; + transition: all 0.15s; + } + .ddreg-chip:hover { + border-color: #cbd5e1; + background: #f8fafc; + } + .ddreg-chip__dot { + width: 7px; + height: 7px; + border-radius: 50%; + background: currentColor; + opacity: 0.6; + } + .ddreg-chip__count { + padding: 1px 7px; + border-radius: 999px; + background: rgb(0 0 0 / 6%); + font-size: 11px; + font-weight: 700; + } + .ddreg-chip.s-available { + color: #15803d; + } + .ddreg-chip.s-issued { + color: #1d4ed8; + } + .ddreg-chip.s-cancelled { color: #b91c1c; } - .status-badge.stopped { - background-color: #fef3c7; + .ddreg-chip.s-stopped { + color: #b45309; + } + .ddreg-chip.s-cleared, + .ddreg-chip.s-revoked { + color: #475569; + } + .ddreg-chip.s-all { + color: #4338ca; + } + .ddreg-chip.active { + background: #1e40af; + border-color: #1e40af; + color: #ffffff !important; + } + .ddreg-chip.active.s-available { + background: #16a34a; + border-color: #16a34a; + } + .ddreg-chip.active.s-issued { + background: #2563eb; + border-color: #2563eb; + } + .ddreg-chip.active.s-cancelled { + background: #dc2626; + border-color: #dc2626; + } + .ddreg-chip.active.s-stopped { + background: #d97706; + border-color: #d97706; + } + .ddreg-chip.active.s-cleared, + .ddreg-chip.active.s-revoked { + background: #475569; + border-color: #475569; + } + .ddreg-chip.active.s-all { + background: #4f46e5; + border-color: #4f46e5; + } + .ddreg-chip.active .ddreg-chip__dot { + background: #fff; + opacity: 0.9; + } + .ddreg-chip.active .ddreg-chip__count { + background: rgb(255 255 255 / 25%); + color: #fff; + } + + .ddreg-hint { + display: flex; + align-items: center; + gap: 6px; + color: #64748b; + font-size: 12.5px; + margin: 0 0 16px; + } + + /* ─── Card wrapper ─── */ + .ddreg-card { + background: #fff; + border: 1px solid #e2e8f0; + border-radius: 12px; + box-shadow: 0 1px 3px rgb(15 23 42 / 6%); + overflow: hidden; + } + .ddreg-card__head { + display: flex; + align-items: center; + padding: 14px 18px; + border-bottom: 1px solid #e2e8f0; + background: linear-gradient(180deg, #f8fafc 0%, #fff 100%); + } + .ddreg-card__title { + display: flex; + align-items: center; + gap: 8px; + font-size: 15px; + font-weight: 700; + color: #0d1b2a; + } + .ddreg-card__title-ic { + color: #1e88e5; + } + .ddreg-card__count { + margin-left: 8px; + padding: 2px 10px; + background: #eff6ff; + color: #1e40af; + border-radius: 999px; + font-size: 11px; + font-weight: 700; + } + + /* ─── Table ─── */ + .ddreg-table-wrap { + overflow: auto; + } + .ddreg-table { + width: 100%; + } + .ddreg-table th { + position: sticky; + top: 0; + z-index: 1; + background: #f1f5f9; + color: #475569; + text-align: left; + padding: 11px 14px !important; + font-size: 10.5px; + font-weight: 700; + text-transform: uppercase; + letter-spacing: 0.05em; + border-bottom: 1px solid #e2e8f0; + } + .ddreg-table td { + padding: 10px 14px !important; + color: #334155; + border-bottom: 1px solid #f1f5f9; + } + .ddreg-table tr.mat-mdc-row:hover { + background: #f8fafc; + } + .ddreg-num { + text-align: right; + } + .ddreg-mono { + font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace; + font-size: 12.5px; + } + .ddreg-strong { + font-weight: 700; + color: #0d1b2a; + } + .ddreg-muted { + color: #94a3b8; + } + + /* ─── Status badge (per-row, same palette as the filter chips) ─── */ + .ddreg-badge { + display: inline-flex; + align-items: center; + gap: 6px; + padding: 4px 10px; + border-radius: 9999px; + font-size: 11.5px; + font-weight: 600; + } + .ddreg-badge__dot { + width: 6px; + height: 6px; + border-radius: 50%; + background: currentColor; + } + .ddreg-badge.available { + background: #dcfce7; + color: #15803d; + } + .ddreg-badge.issued { + background: #dbeafe; + color: #1d4ed8; + } + .ddreg-badge.cleared { + background: #f1f5f9; + color: #475569; + } + .ddreg-badge.cancelled { + background: #fee2e2; + color: #b91c1c; + } + .ddreg-badge.stopped { + background: #fef3c7; color: #b45309; } /* Spoiled stock. Grey rather than red: nothing went wrong with the customer's draft, only with the paper it was printed on, and the draft itself is alive on another leaflet. */ - .status-badge.revoked { - background-color: #e2e8f0; + .ddreg-badge.revoked { + background: #e2e8f0; color: #475569; } - .action-btn-group { - display: flex; - gap: 8px; + + /* ─── Actions ─── */ + .ddreg-actions-col { + min-width: 190px; } - .pending-badge { + .ddreg-actions { + display: flex; + align-items: center; + flex-wrap: wrap; + gap: 6px; + } + .ddreg-act { + width: 30px; + height: 30px; + border: 1px solid #e2e8f0; + background: #fff; + border-radius: 7px; + cursor: pointer; display: inline-flex; align-items: center; - padding: 4px 10px; - border-radius: 9999px; - font-size: 12px; - font-weight: 500; - background-color: #fef3c7; - color: #b45309; - } - .status-filter-row { - display: flex; - flex-wrap: wrap; - gap: 8px; - margin-bottom: 16px; - } - .status-filter-row button.filter-active { - background-color: #ede9fe; - } - .filter-count { + justify-content: center; color: #64748b; - margin-left: 4px; + font-size: 12px; + transition: all 0.12s; } - .hint-text { + .ddreg-act:hover { + transform: translateY(-1px); + } + .ddreg-act.more:hover { + background: #f5f3ff; + border-color: #ddd6fe; + color: #7c3aed; + } + .ddreg-menu__ic { + width: 18px; + margin-right: 10px; color: #64748b; font-size: 13px; - margin: -8px 0 16px 0; + text-align: center; + } + .ddreg-menu__ic.warn { + color: #ef4444; + } + + .ddreg-pending { + display: inline-flex; + align-items: center; + gap: 5px; + padding: 4px 10px; + border-radius: 9999px; + font-size: 11px; + font-weight: 600; + background: #fef3c7; + color: #b45309; + } + .ddreg-pending fa-icon { + font-size: 10px; + } + .ddreg-info { + display: inline-flex; + align-items: center; + gap: 5px; + font-size: 11.5px; + color: #64748b; + } + .ddreg-info fa-icon { + font-size: 10px; + } + + .ddreg-empty { + display: flex; + flex-direction: column; + align-items: center; + gap: 8px; + padding: 48px 16px; + color: #94a3b8; + font-size: 13px; + } + .ddreg-empty fa-icon { + font-size: 22px; + opacity: 0.6; + } + + /* ─── Responsive ─── */ + @media (max-width: 900px) { + .ddreg-toolbar { + flex-direction: column; + align-items: stretch; + } + .ddreg-toolbar__search { + max-width: none; + } + } + @media (max-width: 600px) { + .ddreg-table th, + .ddreg-table td { + padding: 8px 10px !important; + font-size: 12px; + } } ` ] diff --git a/src/app/sponsor-dd/sponsor-dd.service.ts b/src/app/sponsor-dd/sponsor-dd.service.ts index 707a5a9da..9ee34c484 100644 --- a/src/app/sponsor-dd/sponsor-dd.service.ts +++ b/src/app/sponsor-dd/sponsor-dd.service.ts @@ -108,13 +108,14 @@ export class SponsorDDService { chargesAmount?: number; issuingBranchId: number; issuedDate: string; // yyyy-MM-dd - paymentMethod: 'ACCOUNT_DEBIT' | 'CHEQUE' | 'CASH'; + paymentMethod: 'ACCOUNT_DEBIT' | 'CHEQUE' | 'CASH' | 'GL'; savingsAccountId?: number; currentAccountId?: number; chequeNumber?: string; chequeBankName?: string; cashierDepositRequestId?: number; denominationData?: string; + glAccountId?: number; } ): Observable { return this.http.post(`/sponsordd/leaflets/${leafletId}/issue-request`, data); From 41540ad24ef4823b02bcc18997c9881f5eb83da3 Mon Sep 17 00:00:00 2001 From: Nitesh Yadav Date: Thu, 17 Sep 2026 13:04:09 +0530 Subject: [PATCH 58/64] feat(share-products): retire the Share Reference mapping, remove dead Cashier Share Collection screen Share Products: - "Share Reference (Cash Vault or Suspense)" removed from the accounting step (create, edit, preview and the read-only view tab) -- every register now books a share transaction against the account the money actually reached, so no product needs a placeholder asset mapping any more. The field is now optional server-side, not required. Cashier: - Removed the Share Collection screen and its three service methods. It read m_share_account.cashier_collected, a column that has never existed in this schema, and no other screen ever called it -- it could not have produced a working entry. Co-Authored-By: Claude Opus 5 (1M context) --- src/app/cashier/cashier-routing.module.ts | 6 - src/app/cashier/cashier.module.ts | 4 +- src/app/cashier/cashier.service.ts | 13 - .../share-collection.component.html | 375 ---------- .../share-collection.component.scss | 701 ------------------ .../share-collection.component.ts | 282 ------- ...share-product-accounting-step.component.ts | 35 +- .../share-product-preview-step.component.html | 8 - .../share-product-general-tab.component.html | 10 - 9 files changed, 2 insertions(+), 1432 deletions(-) delete mode 100644 src/app/cashier/share-collection/share-collection.component.html delete mode 100644 src/app/cashier/share-collection/share-collection.component.scss delete mode 100644 src/app/cashier/share-collection/share-collection.component.ts diff --git a/src/app/cashier/cashier-routing.module.ts b/src/app/cashier/cashier-routing.module.ts index 32f24de9b..045252339 100644 --- a/src/app/cashier/cashier-routing.module.ts +++ b/src/app/cashier/cashier-routing.module.ts @@ -26,7 +26,6 @@ import { VaultPositionComponent } from './vault-position/vault-position.componen 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'; -import { ShareCollectionComponent } from './share-collection/share-collection.component'; const routes: Routes = [ Route.withShell([ @@ -345,11 +344,6 @@ const routes: Routes = [ component: ExcessTransactionReportComponent, data: { title: 'Excess Transaction Report', breadcrumb: 'Excess Transaction Report' } }, - { - path: 'share-collection', - component: ShareCollectionComponent, - data: { title: 'Share Collection', breadcrumb: 'Share Collection' } - }, { path: 'transaction-entry', canActivate: [CashierOnlyGuard], diff --git a/src/app/cashier/cashier.module.ts b/src/app/cashier/cashier.module.ts index 2cf93e092..ec6dcfeeb 100644 --- a/src/app/cashier/cashier.module.ts +++ b/src/app/cashier/cashier.module.ts @@ -20,7 +20,6 @@ import { ReconciliationComponent } from './reconciliation/reconciliation.compone import { CashHistoryComponent } from './cash-history/cash-history.component'; import { ClosingCashReportComponent } from './closing-cash-report/closing-cash-report.component'; import { ExcessTransactionReportComponent } from './excess-transaction-report/excess-transaction-report.component'; -import { ShareCollectionComponent } from './share-collection/share-collection.component'; @NgModule({ imports: [ @@ -35,8 +34,7 @@ import { ShareCollectionComponent } from './share-collection/share-collection.co ReconciliationComponent, CashHistoryComponent, ClosingCashReportComponent, - ExcessTransactionReportComponent, - ShareCollectionComponent + ExcessTransactionReportComponent ] }) export class CashierModule {} diff --git a/src/app/cashier/cashier.service.ts b/src/app/cashier/cashier.service.ts index 4ef40be49..ad24b948b 100644 --- a/src/app/cashier/cashier.service.ts +++ b/src/app/cashier/cashier.service.ts @@ -364,19 +364,6 @@ export class CashierService { return this.http.get('/paymenttypes'); } - // ── Share Collection ── - getShareAccountForCollection(accountId: string): Observable { - return this.http.get(`/accounts/share/${accountId}`); - } - - collectSharePayment(data: any): Observable { - return this.http.post('/cashier/sharecollection', data); - } - - getShareCollectionStatus(shareAccountId: number): Observable { - return this.http.get(`/cashier/sharecollection/${shareAccountId}`); - } - /** One unapplied Share Sundry receipt, found by the voucher number the member quotes. */ getShareAdvanceByVoucher(voucherNo: string, glAccountNo: string, clientId?: number): Observable { // The voucher travels as a query parameter, not a path segment: numbers are formatted diff --git a/src/app/cashier/share-collection/share-collection.component.html b/src/app/cashier/share-collection/share-collection.component.html deleted file mode 100644 index a17ed89f7..000000000 --- a/src/app/cashier/share-collection/share-collection.component.html +++ /dev/null @@ -1,375 +0,0 @@ - - -
- -
-
-
CASHIER · SHARE COLLECTION
-

Share Collection

-

- Collect cash from member for share purchase — amounts are held in Sundry GL until the account is activated. -

-
-
- - - @if (pageState !== 'success') { - - } - - - @if (errorMsg) { -
- error_outline - {{ errorMsg }} -
- } - - - @if (pageState === 'searching') { -
- hourglass_empty -

Looking up account…

-
- } - - - @if (pageState === 'loaded') { - -
-
- person -
-
-
{{ account.clientName }}
-
- - {{ account.productName }} - {{ account.status?.value }} -
-
- - -
-
-
- - -
-
×
-
- - -
-
=
-
-
Share Amount
-
{{ currencySymbol }}{{ shareAmount | number: '1.2-2' }}
-
-
-
-
- - -
- -
-
- receipt_long - Amount Breakdown -
- - - - - - - - - - - - - @for (charge of charges; track charge.id) { - - - - - } - @if (charges.length === 0) { - - - - } - - - - - - - -
DescriptionAmount
- Share Amount - - ({{ requestedSharesInput }} shares × {{ currencySymbol }}{{ pricePerShare | number: '1.2-2' }}) - - {{ currencySymbol }}{{ shareAmount | number: '1.2-2' }}
{{ charge.name }} - -
- No charges applicable -
Total Payable - {{ currencySymbol }}{{ totalAmount | number: '1.2-2' }} -
-
- - -
-
- account_tree - GL Entries Preview -
- - - - - - - - - - - - - - - - - - - - @if (totalChargeAmount > 0) { - - - - - - } - -
AccountDr / CrAmount
Cash Vault GLDr{{ currencySymbol }}{{ totalAmount | number: '1.2-2' }}
Share Sundry GLCr{{ currencySymbol }}{{ shareAmount | number: '1.2-2' }}
Charge Receivable GLCr{{ currencySymbol }}{{ totalChargeAmount | number: '1.2-2' }}
-
- info_outline - Funds move to Share GL & Income GL when account is activated. -
-
-
- - - @if (isCashPayment && allDenominations.length > 0) { -
-
- payments - Denomination Entry - @if (denominationTotal > 0) { - - ₹{{ denominationTotal | number: '1.2-2' }} - {{ denomMatchesTotal ? '✓ Matches' : '≠ Mismatch' }} - - } - @if (denominationTotal === 0) { - ⚠ Required for Cash - } -
-
- - - - - - - - - - @for (d of allDenominations; track d.id) { - - - - - - } - - - - - - - - -
DenominationCountAmount
- {{ d.label }} - -
- - - -
-
{{ subtotalFor(d) > 0 ? (subtotalFor(d) | number: '1.2-2') : '—' }}
Total - ₹{{ denominationTotal | number: '1.2-2' }} -
- @if (!denomMatchesTotal && denominationTotal > 0) { -
- warning - Denomination ₹{{ denominationTotal | number: '1.2-2' }} must equal Total Payable ₹{{ - totalAmount | number: '1.2-2' - }}. -
- } - @if (denominationTotal > 0) { -
- -
- } -
-
- } - - -
- - Transaction Date * - - - - - Payment Mode - - — Cash (Default) — - @for (pt of paymentTypes; track pt.id) { - {{ pt.name }} - } - - - - - Note (Optional) - - -
- - - - } - - - @if (pageState === 'success' && collectionResult) { -
-
- check_circle -
-

Collection Posted Successfully

-

- Cash of - {{ collectionResult.currencyCode }} {{ collectionResult.totalAmount | number: '1.2-2' }} - collected from {{ collectionResult.clientName }} on - {{ collectionResult.collectionDate }} by {{ collectionResult.collectedBy }}. -

- - -
- Transaction ID - {{ collectionResult.transactionId }} -
- - -
- @for (entry of collectionResult.glEntries; track entry.account) { -
- {{ entry.type }}. {{ entry.account }} - {{ entry.currency }} {{ entry.amount | number: '1.2-2' }} -
- } -
- -

- Funds are held in Sundry GL. They will move to Share Equity GL when the account is activated. -

- - -
- } -
diff --git a/src/app/cashier/share-collection/share-collection.component.scss b/src/app/cashier/share-collection/share-collection.component.scss deleted file mode 100644 index c3a6606a2..000000000 --- a/src/app/cashier/share-collection/share-collection.component.scss +++ /dev/null @@ -1,701 +0,0 @@ -/** - * Copyright since 2025 Mifos Initiative - * - * This Source Code Form is subject to the terms of the Mozilla Public - * License, v. 2.0. If a copy of the MPL was not distributed with this - * file, You can obtain one at http://mozilla.org/MPL/2.0/. - */ - -// ── Search Bar ─────────────────────────────────────────────────────────────── - -.sc-search-bar { - display: flex; - align-items: center; - gap: 10px; - background: #fff; - border: 1px solid #e2e8f0; - border-radius: 12px; - padding: 10px 14px; - - &__input-wrap { - display: flex; - align-items: center; - gap: 8px; - flex: 1 1 auto; - } - - &__icon { - color: #94a3b8; - font-size: 20px; - width: 20px; - height: 20px; - flex-shrink: 0; - } - - &__input { - flex: 1 1 auto; - border: none; - outline: none; - font-size: 14px; - font-family: inherit; - color: #0d1b2a; - background: transparent; - - &::placeholder { - color: #94a3b8; - } - } -} - -// ── Alert ──────────────────────────────────────────────────────────────────── - -.sc-alert { - display: flex; - align-items: center; - gap: 10px; - padding: 12px 16px; - border-radius: 10px; - font-size: 13px; - font-weight: 500; - - mat-icon { - font-size: 20px; - width: 20px; - height: 20px; - flex-shrink: 0; - } - - &--error { - background: #fef2f2; - border: 1px solid #fecaca; - color: #dc2626; - } -} - -// ── Spinner ────────────────────────────────────────────────────────────────── - -.sc-spin { - animation: sc-spin 1.2s linear infinite; - display: block; - margin: 0 auto 8px; - font-size: 32px !important; - width: 32px !important; - height: 32px !important; - color: #94a3b8; -} - -@keyframes sc-spin { - from { - transform: rotate(0deg); - } - - to { - transform: rotate(360deg); - } -} - -// ── Member Card ────────────────────────────────────────────────────────────── - -.sc-member-card { - display: flex; - align-items: center; - gap: 16px; - background: #fff; - border: 1px solid #e2e8f0; - border-radius: 12px; - padding: 16px 20px; - - &__avatar { - display: flex; - align-items: center; - justify-content: center; - width: 48px; - height: 48px; - border-radius: 50%; - background: #eff6ff; - flex-shrink: 0; - - mat-icon { - color: #2563eb; - font-size: 24px; - width: 24px; - height: 24px; - } - } - - &__info { - flex: 1 1 auto; - min-width: 0; - } - - &__name { - font-size: 16px; - font-weight: 700; - color: #1e293b; - margin-bottom: 6px; - } - - &__meta { - display: flex; - flex-wrap: wrap; - gap: 6px; - } - - &__shares { - flex-shrink: 0; - display: flex; - align-items: center; - } -} - -// ── Badges ─────────────────────────────────────────────────────────────────── - -.sc-badge { - display: inline-flex; - align-items: center; - height: 22px; - padding: 0 8px; - border-radius: 5px; - font-size: 11px; - font-weight: 700; - background: #f1f5f9; - color: #475569; - border: 1px solid #e2e8f0; - - &--account { - background: #eff6ff; - color: #1d4ed8; - border-color: #bfdbfe; - font-family: 'Roboto Mono', monospace; - } - - &--product { - background: #f0fdf4; - color: #15803d; - border-color: #bbf7d0; - } - - &--pending { - background: #fffbeb; - color: #d97706; - border-color: #fde68a; - } - - &--active { - background: #f0fdf4; - color: #15803d; - border-color: #bbf7d0; - } -} - -// ── Editable Share Inputs ──────────────────────────────────────────────────── - -.sc-shares-inputs { - display: flex; - align-items: center; - gap: 10px; - flex-wrap: wrap; - justify-content: flex-end; -} - -.sc-shares-field { - display: flex; - flex-direction: column; - gap: 3px; -} - -.sc-shares-label { - font-size: 10px; - font-weight: 700; - text-transform: uppercase; - letter-spacing: 0.07em; - color: #94a3b8; -} - -.sc-shares-input { - width: 110px; - height: 38px; - border: 1.5px solid #e2e8f0; - border-radius: 8px; - padding: 0 10px; - font-size: 14px; - font-weight: 700; - font-family: inherit; - color: #1353a0; - text-align: right; - outline: none; - background: #f8fafc; - transition: border-color 0.15s; - - &:focus { - border-color: #1353a0; - background: #fff; - } - - &::-webkit-outer-spin-button, - &::-webkit-inner-spin-button { - appearance: none; - } -} - -.sc-shares-sep { - font-size: 20px; - font-weight: 300; - color: #94a3b8; - padding-top: 16px; -} - -.sc-shares-result { - text-align: right; -} - -.sc-shares-total { - font-size: 20px; - font-weight: 800; - color: #0f4f3c; - font-variant-numeric: tabular-nums; -} - -// ── Editable Charge Amount ─────────────────────────────────────────────────── - -.sc-charge-amount { - padding: 4px 14px !important; -} - -.sc-charge-input { - width: 100px; - height: 32px; - border: 1.5px solid #e2e8f0; - border-radius: 6px; - padding: 0 8px; - font-size: 13px; - font-weight: 700; - font-family: inherit; - color: #1353a0; - text-align: right; - outline: none; - background: #f8fafc; - transition: border-color 0.15s; - - &:focus { - border-color: #1353a0; - background: #fff; - } - - &::-webkit-outer-spin-button, - &::-webkit-inner-spin-button { - appearance: none; - } -} - -// ── Split Layout ───────────────────────────────────────────────────────────── - -.sc-split { - display: grid; - grid-template-columns: 1fr 1fr; - gap: 16px; - - @media (width <= 900px) { - grid-template-columns: 1fr; - } -} - -// ── Breakdown Card ─────────────────────────────────────────────────────────── - -.sc-breakdown { - &__header { - display: flex; - align-items: center; - gap: 8px; - padding: 12px 16px; - background: #f8fafc; - border-bottom: 1px solid #e2e8f0; - font-size: 13px; - font-weight: 700; - color: #334155; - - mat-icon { - font-size: 18px; - width: 18px; - height: 18px; - color: #0f4f3c; - } - } -} - -.sc-gl-preview { - .sc-breakdown__header { - mat-icon { - color: #1d4ed8; - } - } -} - -.sc-gl-note { - display: flex; - align-items: flex-start; - gap: 6px; - padding: 10px 14px; - font-size: 12px; - color: #64748b; - background: #f8fafc; - border-top: 1px solid #e2e8f0; - - mat-icon { - font-size: 16px; - width: 16px; - height: 16px; - flex-shrink: 0; - margin-top: 1px; - color: #94a3b8; - } -} - -// ── Table helpers ──────────────────────────────────────────────────────────── - -.sc-dim { - color: #94a3b8; - font-size: 12px; -} - -.sc-total-row td { - background: #f8fafc; - border-top: 2px solid #e2e8f0; - font-size: 14px; -} - -.sc-total-amount { - color: #0f4f3c; -} - -// ── Dr / Cr badges ─────────────────────────────────────────────────────────── - -.sc-dr-badge, -.sc-cr-badge { - display: inline-flex; - align-items: center; - justify-content: center; - width: 32px; - height: 20px; - border-radius: 4px; - font-size: 11px; - font-weight: 800; - letter-spacing: 0.04em; -} - -.sc-dr-badge { - background: #fef2f2; - color: #dc2626; - border: 1px solid #fecaca; -} - -.sc-cr-badge { - background: #f0fdf4; - color: #15803d; - border: 1px solid #bbf7d0; -} - -// ── Footer ─────────────────────────────────────────────────────────────────── - -.sc-footer { - justify-content: space-between !important; - - &__summary { - display: flex; - flex-direction: column; - gap: 2px; - } - - &__label { - font-size: 11px; - font-weight: 700; - text-transform: uppercase; - letter-spacing: 0.07em; - color: #64748b; - } - - &__amount { - font-size: 22px; - font-weight: 800; - color: #0f4f3c; - font-variant-numeric: tabular-nums; - } - - &__actions { - display: flex; - align-items: center; - gap: 10px; - } -} - -// ── Denomination Panel ──────────────────────────────────────────────────────── - -.sc-denom { - padding: 0; - overflow: hidden; -} - -.sc-denom-body { - padding: 14px 16px; -} - -.sc-denom-table { - width: 100%; - - th, - td { - padding: 7px 10px; - font-size: 13px; - } - - tfoot td { - border-top: 2px solid #e2e8f0; - background: #f8fafc; - font-size: 14px; - } -} - -.sc-denom-chip { - display: inline-flex; - align-items: center; - height: 24px; - padding: 0 10px; - border-radius: 5px; - font-size: 12px; - font-weight: 700; - background: #eff6ff; - color: #1d4ed8; - border: 1px solid #bfdbfe; - - &--coin { - background: #fffbeb; - color: #d97706; - border-color: #fde68a; - } -} - -.sc-denom-stepper { - display: flex; - align-items: center; - gap: 4px; - justify-content: flex-end; - - button { - width: 26px; - height: 26px; - border: 1.5px solid #e2e8f0; - border-radius: 5px; - background: #f8fafc; - color: #475569; - font-size: 16px; - line-height: 1; - cursor: pointer; - display: flex; - align-items: center; - justify-content: center; - padding: 0; - - &:hover:not(:disabled) { - background: #e0f2fe; - border-color: #7dd3fc; - color: #0369a1; - } - - &:disabled { - opacity: 0.35; - cursor: not-allowed; - } - } - - input { - width: 62px; - height: 28px; - border: 1.5px solid #e2e8f0; - border-radius: 6px; - padding: 0 6px; - font-size: 13px; - font-weight: 600; - font-family: inherit; - text-align: right; - outline: none; - background: #fff; - - &:focus { - border-color: #1353a0; - } - - &::-webkit-outer-spin-button, - &::-webkit-inner-spin-button { - appearance: none; - } - } -} - -.sc-denom-badge { - margin-left: auto; - display: inline-flex; - align-items: center; - height: 22px; - padding: 0 10px; - border-radius: 5px; - font-size: 12px; - font-weight: 700; - - &--match { - background: #f0fdf4; - color: #15803d; - border: 1px solid #bbf7d0; - } - - &--mismatch { - background: #fef2f2; - color: #dc2626; - border: 1px solid #fecaca; - } -} - -.sc-denom-required { - margin-left: auto; - font-size: 12px; - font-weight: 700; - color: #d97706; -} - -.sc-denom-mismatch { - display: flex; - align-items: center; - gap: 6px; - margin-top: 10px; - padding: 9px 12px; - background: #fef2f2; - border: 1px solid #fecaca; - border-radius: 8px; - font-size: 12px; - font-weight: 600; - color: #dc2626; - - mat-icon { - font-size: 16px; - width: 16px; - height: 16px; - flex-shrink: 0; - } -} - -.sc-denom-actions { - display: flex; - justify-content: flex-end; - margin-top: 10px; -} - -// ── Success ────────────────────────────────────────────────────────────────── - -.sc-success { - display: flex; - flex-direction: column; - align-items: center; - text-align: center; - padding: 40px 24px; - gap: 16px; - background: #fff; - border: 1px solid #e2e8f0; - border-radius: 16px; - - &__icon-wrap { - width: 72px; - height: 72px; - border-radius: 50%; - background: #f0fdf4; - display: flex; - align-items: center; - justify-content: center; - - mat-icon { - font-size: 40px; - width: 40px; - height: 40px; - color: #16a34a; - } - } - - &__title { - font-size: 22px; - font-weight: 700; - color: #1e293b; - margin: 0; - } - - &__sub { - font-size: 14px; - color: #64748b; - max-width: 480px; - margin: 0; - } - - &__ref { - display: flex; - align-items: center; - gap: 10px; - background: #f1f5f9; - border: 1px solid #e2e8f0; - border-radius: 8px; - padding: 8px 16px; - } - - &__ref-label { - font-size: 11px; - font-weight: 700; - text-transform: uppercase; - letter-spacing: 0.07em; - color: #94a3b8; - white-space: nowrap; - } - - &__ref-val { - font-family: 'Roboto Mono', 'Courier New', monospace; - font-size: 13px; - font-weight: 600; - color: #1353a0; - } - - &__note { - font-size: 12px; - color: #94a3b8; - max-width: 420px; - margin: 0; - text-align: center; - } - - &__gl { - display: flex; - flex-direction: column; - gap: 6px; - width: 100%; - max-width: 400px; - background: #f8fafc; - border: 1px solid #e2e8f0; - border-radius: 10px; - padding: 14px 16px; - } - - &__gl-row { - display: flex; - justify-content: space-between; - font-size: 13px; - font-weight: 600; - padding: 4px 0; - border-bottom: 1px dotted #e2e8f0; - - &:last-child { - border-bottom: none; - } - - &--dr { - color: #dc2626; - } - - &--cr { - color: #15803d; - } - } -} diff --git a/src/app/cashier/share-collection/share-collection.component.ts b/src/app/cashier/share-collection/share-collection.component.ts deleted file mode 100644 index e9d2119f3..000000000 --- a/src/app/cashier/share-collection/share-collection.component.ts +++ /dev/null @@ -1,282 +0,0 @@ -/** - * Copyright since 2025 Mifos Initiative - * - * This Source Code Form is subject to the terms of the Mozilla Public - * License, v. 2.0. If a copy of the MPL was not distributed with this - * file, You can obtain one at http://mozilla.org/MPL/2.0/. - */ - -import { Component, OnInit, inject } from '@angular/core'; -import { CommonModule } from '@angular/common'; -import { FormsModule } from '@angular/forms'; -import { RouterModule } from '@angular/router'; -import { MatIconModule } from '@angular/material/icon'; -import { MatSnackBar, MatSnackBarModule } from '@angular/material/snack-bar'; -import { MatFormFieldModule } from '@angular/material/form-field'; -import { MatSelectModule } from '@angular/material/select'; -import { MatInputModule } from '@angular/material/input'; -import { catchError } from 'rxjs/operators'; -import { of } from 'rxjs'; -import { CashierService } from '../cashier.service'; - -type PageState = 'idle' | 'searching' | 'loaded' | 'submitting' | 'success'; - -interface Denomination { - id: number; - value: number; - kind: 'note' | 'coin'; - label: string; -} - -interface ShareCharge { - id: number; - name: string; - amount: number; - currencySymbol: string; -} - -@Component({ - selector: 'mifosx-share-collection', - templateUrl: './share-collection.component.html', - styleUrls: [ - '../cashier-shared.scss', - './share-collection.component.scss' - ], - standalone: true, - imports: [ - CommonModule, - FormsModule, - RouterModule, - MatIconModule, - MatSnackBarModule, - MatFormFieldModule, - MatSelectModule, - MatInputModule - ] -}) -export class ShareCollectionComponent implements OnInit { - private service = inject(CashierService); - private snackBar = inject(MatSnackBar); - - pageState: PageState = 'idle'; - accountInput = ''; - transactionDate = ''; - paymentTypeId: number | null = null; - paymentTypes: any[] = []; - note = ''; - errorMsg = ''; - saving = false; - - account: any = null; - charges: ShareCharge[] = []; - collectionResult: any = null; // filled after successful POST - - // Editable fields — cashier can override if API returns 0 - requestedSharesInput = 0; - pricePerShare = 0; - - // ── Denomination ──────────────────────────────────────────────────────────── - notes: Denomination[] = []; - coins: Denomination[] = []; - denominationCounts: Record = {}; - - get shareAmount(): number { - return this.requestedSharesInput * this.pricePerShare; - } - - get totalChargeAmount(): number { - return this.charges.reduce((s, c) => s + c.amount, 0); - } - - get totalAmount(): number { - return this.shareAmount + this.totalChargeAmount; - } - - get currencySymbol(): string { - return this.account?.currency?.displaySymbol ?? '₹'; - } - - get isCashPayment(): boolean { - if (this.paymentTypeId === null) return true; - const pt = this.paymentTypes.find((p: any) => p.id === this.paymentTypeId); - return !!pt?.isCashPayment; - } - - get allDenominations(): Denomination[] { - const desc = (a: Denomination, b: Denomination) => b.value - a.value; - return [ - ...[...this.notes].sort(desc), - ...[...this.coins].sort(desc) - ]; - } - - get denominationTotal(): number { - return this.allDenominations.reduce((s, d) => s + this.subtotalFor(d), 0); - } - - get denomMatchesTotal(): boolean { - return this.denominationTotal === this.totalAmount; - } - - get isValid(): boolean { - if (!this.account || this.totalAmount <= 0 || !this.transactionDate) return false; - if (this.isCashPayment && this.allDenominations.length > 0) { - return this.denomMatchesTotal; - } - return true; - } - - ngOnInit(): void { - const d = new Date(); - this.transactionDate = `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, '0')}-${String(d.getDate()).padStart(2, '0')}`; - this.service - .getPaymentTypes() - .pipe(catchError(() => of([]))) - .subscribe((types: any[]) => (this.paymentTypes = types || [])); - this.loadDenominations(); - } - - private loadDenominations(): void { - this.service - .getActiveDenominations() - .pipe(catchError(() => of([]))) - .subscribe((list: any[]) => { - const rows: Denomination[] = (list || []).map((d: any) => ({ - id: d.id, - value: d.denomination ?? d.value, - kind: d.type === 'coin' ? 'coin' : 'note', - label: '₹' + (d.denomination ?? d.value) - })); - const order = (a: Denomination, b: Denomination) => b.value - a.value; - this.notes = rows.filter((r) => r.kind === 'note').sort(order); - this.coins = rows.filter((r) => r.kind === 'coin').sort(order); - }); - } - - // ── Denomination helpers ────────────────────────────────────────────────── - - denomKey(d: Denomination): string { - return `${d.kind}:${d.value}`; - } - - countFor(d: Denomination): number { - return this.denominationCounts[this.denomKey(d)] || 0; - } - - subtotalFor(d: Denomination): number { - return this.countFor(d) * d.value; - } - - onDenomChange(d: Denomination, raw: any): void { - this.denominationCounts[this.denomKey(d)] = Math.max(0, Math.floor(Number(raw) || 0)); - } - - bumpDenom(d: Denomination, delta: number): void { - this.denominationCounts[this.denomKey(d)] = Math.max(0, this.countFor(d) + delta); - } - - clearDenominations(): void { - this.denominationCounts = {}; - } - - searchAccount(): void { - const query = this.accountInput.trim(); - if (!query) return; - this.pageState = 'searching'; - this.account = null; - this.charges = []; - this.errorMsg = ''; - - this.service - .getShareAccountForCollection(query) - .pipe( - catchError((err) => { - this.errorMsg = - err?.error?.errors?.[0]?.defaultUserMessage || - err?.error?.developerMessage || - 'Account not found. Please check the account ID and try again.'; - this.pageState = 'idle'; - return of(null); - }) - ) - .subscribe((acc: any) => { - if (!acc) return; - this.account = acc; - - // Auto-detect shares from summary or first pending purchasedShares entry - const pendingTx = (acc.purchasedShares || []).find( - (t: any) => - t.status?.code === 'purchasedSharesStatusType.applied' && t.type?.code === 'purchasedSharesType.purchased' - ); - this.requestedSharesInput = Number( - acc.summary?.totalPendingForApprovalShares || pendingTx?.numberOfShares || acc.requestedShares || 0 - ); - this.pricePerShare = Number(pendingTx?.purchasedPrice || acc.currentMarketPrice || acc.unitPrice || 0); - - // Show ALL charges on the account — no type filtering - this.charges = (acc.charges || []).map((c: any) => ({ - id: c.chargeId ?? c.id, - name: c.name, - amount: Number(c.amount ?? c.amountOutstanding ?? 0), - currencySymbol: c.currency?.displaySymbol ?? '₹' - })); - - this.pageState = 'loaded'; - }); - } - - recalc(): void { - // triggered by input changes — nothing extra needed, getters auto-recalculate - } - - submit(): void { - if (!this.isValid || this.saving) return; - this.saving = true; - const payload = { - shareAccountId: this.account.id, - accountNo: this.account.accountNo, - clientName: this.account.clientName, - transactionDate: this.transactionDate, - requestedShares: this.requestedSharesInput, - pricePerShare: this.pricePerShare, - shareAmount: this.shareAmount, - chargeAmount: this.totalChargeAmount, - totalAmount: this.totalAmount, - paymentTypeId: this.paymentTypeId || null, - note: this.note || null - }; - - this.service.collectSharePayment(payload).subscribe({ - next: (result: any) => { - this.collectionResult = result; - this.snackBar.open('Share collection posted successfully.', 'Close', { duration: 3000 }); - this.pageState = 'success'; - this.saving = false; - }, - error: (err: any) => { - const msg = - err?.error?.errors?.[0]?.defaultUserMessage || - err?.error?.developerMessage || - err?.error?.defaultUserMessage || - 'Collection failed. Please try again.'; - this.snackBar.open(msg, 'Close', { duration: 8000 }); - this.saving = false; - } - }); - } - - reset(): void { - this.pageState = 'idle'; - this.accountInput = ''; - this.account = null; - this.charges = []; - this.errorMsg = ''; - this.note = ''; - this.paymentTypeId = null; - this.requestedSharesInput = 0; - this.pricePerShare = 0; - this.collectionResult = null; - this.saving = false; - this.clearDenominations(); - } -} diff --git a/src/app/products/share-products/share-product-stepper/share-product-accounting-step/share-product-accounting-step.component.ts b/src/app/products/share-products/share-product-stepper/share-product-accounting-step/share-product-accounting-step.component.ts index 14693833d..4cb5e33d2 100644 --- a/src/app/products/share-products/share-product-stepper/share-product-accounting-step/share-product-accounting-step.component.ts +++ b/src/app/products/share-products/share-product-stepper/share-product-accounting-step/share-product-accounting-step.component.ts @@ -70,16 +70,9 @@ export class ShareProductAccountingStepComponent implements OnInit { shareProductAccountingForm: UntypedFormGroup; accountingSectionConfigs: ShareAccountingSectionConfig[] = []; - assetAccountData: GLAccount[] = []; liabilityAccountData: GLAccount[] = []; incomeAccountData: GLAccount[] = []; equityAccountData: GLAccount[] = []; - /** "Share Reference" is where a redemption's credit side lands pending settlement — some banks - * model that as a receivable-like Asset (e.g. "Cash Vault", this field's original default), - * others as a payable-like Liability suspense account (e.g. an existing "Sundry" account - * already used elsewhere as Shares Suspense). Both are valid conventions, so this field accepts - * either instead of Asset-only. */ - referenceAccountData: GLAccount[] = []; constructor() { this.createShareProductAccountingForm(); @@ -87,18 +80,15 @@ export class ShareProductAccountingStepComponent implements OnInit { ngOnInit() { const opts = this.shareProductsTemplate.accountingMappingOptions || {}; - this.assetAccountData = opts.assetAccountOptions || []; this.liabilityAccountData = opts.liabilityAccountOptions || []; this.incomeAccountData = opts.incomeAccountOptions || []; this.equityAccountData = opts.equityAccountOptions || []; - this.referenceAccountData = this.assetAccountData.concat(this.liabilityAccountData); this.buildAccountingSectionConfigs(); if (this.shareProductsTemplate.accountingMappings) { const m = this.shareProductsTemplate.accountingMappings; this.shareProductAccountingForm.patchValue({ - shareReferenceId: m.shareReferenceId?.id || '', shareSuspenseId: m.shareSuspenseId?.id || '', incomeFromFeeAccountId: m.incomeFromFeeAccountId?.id || '', shareEquityId: m.shareEquityId?.id || '' @@ -109,10 +99,6 @@ export class ShareProductAccountingStepComponent implements OnInit { createShareProductAccountingForm() { this.shareProductAccountingForm = this.formBuilder.group({ accountingRule: [2], - shareReferenceId: [ - '', - Validators.required - ], shareSuspenseId: [ '', Validators.required @@ -125,7 +111,6 @@ export class ShareProductAccountingStepComponent implements OnInit { '', Validators.required ], - assetsOtherGlMappings: this.formBuilder.array([]), liabilitiesOtherGlMappings: this.formBuilder.array([]), incomeOtherGlMappings: this.formBuilder.array([]), equityOtherGlMappings: this.formBuilder.array([]) @@ -134,24 +119,6 @@ export class ShareProductAccountingStepComponent implements OnInit { buildAccountingSectionConfigs() { this.accountingSectionConfigs = [ - { - key: 'assets', - title: 'Assets', - pillLabel: 'Asset', - pillClass: 'green', - rows: [ - { - key: 'shareReferenceId', - label: 'Share Reference (Cash Vault or Suspense)', - inputLabel: 'Share reference', - controlName: 'shareReferenceId', - options: this.referenceAccountData, - required: true - } - ], - otherMappingsControlName: 'assetsOtherGlMappings', - otherAccountOptions: this.assetAccountData - }, { key: 'liabilities', title: 'Liabilities', @@ -239,7 +206,7 @@ export class ShareProductAccountingStepComponent implements OnInit { } get shareProductAccounting() { - const { assetsOtherGlMappings, liabilitiesOtherGlMappings, incomeOtherGlMappings, equityOtherGlMappings, ...rest } = + const { liabilitiesOtherGlMappings, incomeOtherGlMappings, equityOtherGlMappings, ...rest } = this.shareProductAccountingForm.value; return rest; } diff --git a/src/app/products/share-products/share-product-stepper/share-product-preview-step/share-product-preview-step.component.html b/src/app/products/share-products/share-product-stepper/share-product-preview-step/share-product-preview-step.component.html index 4bc274f94..4f1064ae8 100644 --- a/src/app/products/share-products/share-product-stepper/share-product-preview-step/share-product-preview-step.component.html +++ b/src/app/products/share-products/share-product-stepper/share-product-preview-step/share-product-preview-step.component.html @@ -213,14 +213,6 @@
@if (shareProduct.accountingRule === 2) { -
{{ 'labels.heading.Assets' | translate }}
-
- {{ 'labels.inputs.Share reference' | translate }}: - {{ - shareProduct.shareReferenceId - | find: shareProductsTemplate.accountingMappingOptions.assetAccountOptions : 'id' : 'name' - }} -
{{ 'labels.heading.Liabilities' | translate }}
{{ 'labels.inputs.Share suspense control' | translate }}: diff --git a/src/app/products/share-products/view-share-product/share-product-general-tab/share-product-general-tab.component.html b/src/app/products/share-products/view-share-product/share-product-general-tab/share-product-general-tab.component.html index 4538cc62a..2cd01ff86 100644 --- a/src/app/products/share-products/view-share-product/share-product-general-tab/share-product-general-tab.component.html +++ b/src/app/products/share-products/view-share-product/share-product-general-tab/share-product-general-tab.component.html @@ -208,16 +208,6 @@ @if (shareProduct.accountingRule.id === 2) {
-

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

-
- - -

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

Date: Thu, 17 Sep 2026 17:45:05 +0530 Subject: [PATCH 59/64] feat(shares, cashier): Entry Type tagging on Transaction Entry, real GL details on receipts Adds an Entry Type field to Transaction Entry's GL-crediting rows (Receipt / Collection), fetched from the backend's GLEntryType code, so the counter can flag what a GL credit is actually for (General / Share Collection / DD Issue) instead of leaving every GL account picker throughout Share Advance Collection guessing from the whole chart of accounts. Apply Share / Additional Share screens: - GL Account picker narrowed to accounts that actually have an eligible receipt, and only auto-selected when exactly one candidate exists -- no more defaulting to whichever account sorts first (Cash at Vault). - Voucher tables show Description and Collected By, and a Detail view exposes the receipt's real Dr/Cr GL legs instead of the GL Account dropdown's current unrelated selection. - Additional Share funds from a single voucher (radio-style selection); ticking a receipt syncs the GL Account picker to its own actual account. - Checker's Additional Share Request review dialog gets the same Description + real GL entry detail, expandable per voucher, so approval is never a guess about what will post. Co-Authored-By: Claude Sonnet 5 --- src/app/cashier/cashier.service.ts | 16 + .../transaction-entry.component.html | 38 + .../transaction-entry.component.ts | 45 +- .../apply-shares/apply-shares.component.html | 764 +++++++---- .../apply-shares/apply-shares.component.scss | 1137 +++++++++++++++-- .../apply-shares/apply-shares.component.ts | 74 +- .../apply-collection-dialog.component.html | 6 +- .../apply-collection-dialog.component.ts | 6 + ...ition-request-detail-dialog.component.html | 54 +- ...ition-request-detail-dialog.component.scss | 90 ++ ...ddition-request-detail-dialog.component.ts | 26 + 11 files changed, 1866 insertions(+), 390 deletions(-) diff --git a/src/app/cashier/cashier.service.ts b/src/app/cashier/cashier.service.ts index ad24b948b..83d3ba55b 100644 --- a/src/app/cashier/cashier.service.ts +++ b/src/app/cashier/cashier.service.ts @@ -401,6 +401,12 @@ export class CashierService { return this.http.post(`/shareadvancecollection/${collectionId}/apply`, data); } + /** The receipt's own real Dr/Cr GL legs — never the GL account currently picked in a search box, + * which is unrelated screen state that can name a different account than this receipt sits in. */ + getShareAdvanceGlEntries(collectionId: number): Observable { + return this.http.get(`/shareadvancecollection/${collectionId}/gl-entries`); + } + /** Every additional-share tranche awaiting approval, across every share account. */ getShareAdditionRequests(): Observable { return this.http.get('/shareadditionrequests'); @@ -413,4 +419,14 @@ export class CashierService { getShareAdditionRequest(transactionId: number): Observable { return this.http.get(`/shareadditionrequests/${transactionId}`); } + + /** + * Entry Type options for a GL credit (General / Share Collection / DD Issue / …) — the + * "GLEntryType" code under Admin > System > Manage Codes, so an admin can add another type later + * without a deployment. Empty on failure rather than erroring, since a row missing this dropdown's + * options should still be usable for every other field. + */ + getEntryTypeOptions(): Observable { + return this.http.get('/codes/name/GLEntryType/codevalues').pipe(catchError(() => of([]))); + } } diff --git a/src/app/cashier/transaction-entry/transaction-entry.component.html b/src/app/cashier/transaction-entry/transaction-entry.component.html index b6732deb6..ee14e4589 100644 --- a/src/app/cashier/transaction-entry/transaction-entry.component.html +++ b/src/app/cashier/transaction-entry/transaction-entry.component.html @@ -191,6 +191,25 @@ }
+ @if (showsEntryType(row)) { +
+ + +
+ }
@@ -1562,6 +1581,25 @@ }
+ @if (showsEntryType(row)) { +
+ + +
+ }
diff --git a/src/app/cashier/transaction-entry/transaction-entry.component.ts b/src/app/cashier/transaction-entry/transaction-entry.component.ts index c7daa9584..a96bf4f00 100644 --- a/src/app/cashier/transaction-entry/transaction-entry.component.ts +++ b/src/app/cashier/transaction-entry/transaction-entry.component.ts @@ -53,6 +53,19 @@ interface TxnRow { /** DD issuance charges/commission — only used when TYPE=DD, TXN TP=Cash, AC.TP=GL */ ddCharges: number | null; sponsorBankId?: number | null; + /** + * What a GL credit is actually for — the id of a code value under "GLEntryType" (General, Share + * Collection, DD Issue, or whatever an admin has added under Admin > System > Manage Codes). + * + * A cashier Receipt (or a clerk Collection) into a GL account is the counter picking, by hand, + * any account to post cash into — deliberately, so a broken product mapping can never block a + * genuine voucher. That freedom is also the hole: two unrelated flows can land in the same GL + * account with nothing on the row itself saying which one the money was collected for, and a + * manager applying a share request can pick up a voucher that was really meant to fund a DD. This + * tag is what a day-end reconciliation groups by, and what Share Advance Collection / Sponsor DD + * issuance check before adopting a row — only shown/sent for AC.TP=GL, DR./CR.=Cr rows. + */ + entryTypeCvId?: number | null; /** Only used when this row is AC.TP=SHR inside an Enter Bank Transfer to its linked savings * account — governs the share redemption (see submitShareRedemptionTransfer()). */ resolutionNumber?: string; @@ -1864,6 +1877,12 @@ export class TransactionEntryComponent implements OnInit, OnDestroy { this.loadCashVaultGl(); this.loadDdGstPercentage(); this.loadSponsorBanks(); + this.cashierService + .getEntryTypeOptions() + .pipe(catchError(() => of([]))) + .subscribe((options: any[]) => { + this.entryTypeOptions = options ?? []; + }); if (this.reviewMode) { this.loadRequestIntoForm(this.reviewRequestId!, 'review'); @@ -2379,6 +2398,23 @@ export class TransactionEntryComponent implements OnInit, OnDestroy { return this.isDdCashGlRow(row) || this.isDdAccountDebitRow(row); } + /** + * Whether this row needs an Entry Type tag — a credit into a GL account, which is exactly the + * case where the money's real purpose isn't otherwise recorded anywhere (see TxnRow.entryTypeCvId). + * A debit out of a GL account, or any entry against a savings/current/share account, carries no + * such ambiguity — the account itself says what it's for. + */ + showsEntryType(row: TxnRow): boolean { + return row.acTyp === 'GL' && row.dbCr === 'Cr'; + } + + /** + * Entry Type choices for a GL credit — the "GLEntryType" code (Admin > System > Manage Codes), + * fetched once in ngOnInit(). A code value, not a free-text field, so the day-end "group by type" + * this exists for stays reliable; an admin extends the list there, not in this file. + */ + entryTypeOptions: { id: number; name: string }[] = []; + /** GST on DD charges only (never on the DD face value), at the admin-configured rate. */ ddGstAmountFor(row: TxnRow): number { const charges = Number(row.ddCharges) || 0; @@ -2848,7 +2884,10 @@ export class TransactionEntryComponent implements OnInit, OnDestroy { // Without this the backend defaults every request from this screen to DISBURSEMENT — a // Collection or Fund Transfer submitted here was being saved (and shown in the checker's // inbox) as a plain Disbursement. - requestType: isCollectionMode ? 'COLLECTION' : isFundTransfer ? 'FUND_TRANSFER' : 'DISBURSEMENT' + requestType: isCollectionMode ? 'COLLECTION' : isFundTransfer ? 'FUND_TRANSFER' : 'DISBURSEMENT', + // Same ambiguity as a cashier Receipt — a Collection credits a GL account picked by hand. + // See TxnRow.entryTypeCvId. + ...(isCollectionMode ? { entryTypeCvId: crRows[0]?.entryTypeCvId || null } : {}) }; this.saving = true; @@ -5905,6 +5944,10 @@ export class TransactionEntryComponent implements OnInit, OnDestroy { chequeNo: row.chequeNo || null, bankName: row.bankName || null, denominationData: isCash && this.denominationTotal > 0 ? JSON.stringify(this.denominationCounts) : null, + // What this GL credit is actually for — see TxnRow.entryTypeCvId. Sent for every GL deposit + // (the backend defaults to "General" when the counter didn't pick one), so Share Advance + // Collection and Sponsor DD issuance can tell whose voucher this really is before adopting it. + ...(isGl ? { entryTypeCvId: row.entryTypeCvId || null } : {}), // DD + Cash + GL breakdown — persisted so the Sponsor DD issuance screen can read the face // value/charges/GST back once this request is approved, instead of retyping them. ...(this.isDdCashGlRow(row) diff --git a/src/app/shares/shares-account-actions/apply-shares/apply-shares.component.html b/src/app/shares/shares-account-actions/apply-shares/apply-shares.component.html index 4497dda2c..4e7601cb0 100644 --- a/src/app/shares/shares-account-actions/apply-shares/apply-shares.component.html +++ b/src/app/shares/shares-account-actions/apply-shares/apply-shares.component.html @@ -6,112 +6,189 @@ file, You can obtain one at http://mozilla.org/MPL/2.0/. --> -
- +
+ + +
+
+ add_card +
+
+

{{ 'Apply Additional Shares' | translate }}

+

Purchase additional share tranches via cash receipts or savings debit

+
+
+
- -
- - {{ 'labels.inputs.Request Date' | translate }} - - - - @if (applySharesForm.controls.requestedDate.hasError('required')) { - - {{ 'labels.inputs.Request Date' | translate }} {{ 'labels.commons.is' | translate }} - {{ 'labels.commons.required' | translate }} - - } - - - - - {{ 'labels.inputs.Resolution Number' | translate }} - - @if (applySharesForm.controls.resolutionNumber.hasError('required')) { - - {{ 'labels.inputs.Resolution Number' | translate }} {{ 'labels.commons.is' | translate }} - {{ 'labels.commons.required' | translate }} - - } - - - - {{ 'labels.inputs.Resolution Date' | translate }} - - - - @if (applySharesForm.controls.resolutionDate.hasError('required')) { - - {{ 'labels.inputs.Resolution Date' | translate }} {{ 'labels.commons.is' | translate }} - {{ 'labels.commons.required' | translate }} - - } - - - - {{ 'labels.inputs.Total No. of Shares' | translate }} - - @if (applySharesForm.controls.requestedShares.hasError('required')) { - - {{ 'labels.inputs.Requested Shares' | translate }} {{ 'labels.commons.is' | translate }} - {{ 'labels.commons.required' | translate }} - - } - - - - {{ 'labels.inputs.Current Price' | translate }} - - - - - - {{ 'labels.inputs.Amount' | translate }} - - {{ 'labels.inputs.Total No. of Shares' | translate }} × - {{ 'labels.inputs.Current Price' | translate }} - - - -
-
{{ 'labels.inputs.Payment Option' | translate }}
- - Apply Collection (Cash) - Debit from Savings - + + +
+
+ assignment + Application & Resolution Details
- @if (paymentMode === 'CASH') { -
-

- The member has already paid at the counter. Tick the receipts that make up this payment, or enter a - voucher number to find one — tick more than one where they paid in instalments. The cash is sitting in - Share Sundry against them, and approving this request moves it into share capital. -

+
+ + + {{ 'labels.inputs.Request Date' | translate }} + + + + @if (applySharesForm.controls.requestedDate.hasError('required')) { + + {{ 'labels.inputs.Request Date' | translate }} {{ 'labels.commons.is' | translate }} + {{ 'labels.commons.required' | translate }} + + } + -
+ + + {{ 'labels.inputs.Resolution Number' | translate }} + + @if (applySharesForm.controls.resolutionNumber.hasError('required')) { + + {{ 'labels.inputs.Resolution Number' | translate }} {{ 'labels.commons.is' | translate }} + {{ 'labels.commons.required' | translate }} + + } + + + + + {{ 'labels.inputs.Resolution Date' | translate }} + + + + @if (applySharesForm.controls.resolutionDate.hasError('required')) { + + {{ 'labels.inputs.Resolution Date' | translate }} {{ 'labels.commons.is' | translate }} + {{ 'labels.commons.required' | translate }} + + } + +
+
+ + +
+
+ calculate + Share Purchase Calculation +
+ +
+ + + {{ 'labels.inputs.Total No. of Shares' | translate }} + + @if (applySharesForm.controls.requestedShares.hasError('required')) { + + {{ 'labels.inputs.Requested Shares' | translate }} {{ 'labels.commons.is' | translate }} + {{ 'labels.commons.required' | translate }} + + } + + + + + {{ 'labels.inputs.Current Price' | translate }} + + per share + + + +
+ {{ 'labels.inputs.Amount' | translate }} + {{ amount | number: '1.2-2' }} + {{ applySharesForm.get('requestedShares').value || 0 }} shares × + {{ sharesAccountData?.currentMarketPrice || 0 }} +
+
+
+ + +
+
+ account_balance_wallet + {{ 'labels.inputs.Payment Option' | translate }} +
+ +
+
+
+ payments +
+
+ Apply Collection (Cash) + Link receipts paid at counter sitting in Share Sundry +
+ @if (paymentMode === 'CASH') { + check_circle + } +
+ +
+
+ account_balance +
+
+ Debit from Savings + Direct debit from member's linked savings account +
+ @if (paymentMode === 'SAVINGS') { + check_circle + } +
+
+
+ + + @if (paymentMode === 'CASH') { +
+
+ + + Select the one receipt that covers this payment, or enter a voucher number to search. Click + visibility on any row to view full details popup. + +
+ + +
+
+ - -
- - @if (voucherError) { -
- error_outline - {{ voucherError }} +
+ +
+ +
- } +
+
+ @if (voucherError) { +
+ error_outline + {{ voucherError }} +
+ } + + +
@if (loadingVouchers) {
- Loading the member's receipts… + Loading member receipts…
} @else if (vouchers.length === 0) {
- info_outline -
- No unapplied receipt for this member. -
- Collect the cash first from Cashier › Transaction Entry as a deposit to the Share Sundry - GL. Once it is approved, the voucher appears here. -
+ receipt_long +
+ No unapplied receipts found for this member. +

+ Collect cash at Cashier › Transaction Entry as a deposit to Share Sundry GL. Once + posted, it will appear here. +

} @else { - - - - - - - - - - - - @for (v of vouchers; track v.id) { - - - - - - - } - - @if (selectedVoucherIds.length > 0) { - - - - - - - - } -
Voucher / ReceiptDateAmount
- - {{ v.voucher_no || v.receipt_no }}{{ v.collection_date }} - {{ v.amount | number: '1.2-2' }} -
- {{ selectedVoucherIds.length }} selected, against {{ amount | number: '1.2-2' }} - - {{ selectedVoucherTotal | number: '1.2-2' }} -
+
+ +
+
+ receipt + Vouchers / Receipts + {{ vouchers.length }} +
+
+ + +
+ + + + + + + + + + + + + + + @for (v of vouchers; track v.id) { + + + + + + + + + + } + +
#Voucher / Receipt NoDateDescriptionCollected ByAmountDetail
+ + + {{ v.voucher_no || v.receipt_no }} + {{ v.collection_date }}{{ v.note || 'Share Collection' }} + + person_outline + {{ v.collected_by_name || 'System' }} + + + {{ v.amount | number: '1.2-2' }} + + +
+
+ + + +
- @if (selectedVoucherIds.length > 0 && voucherShortfall > 0) { -
+
error_outline - The selected receipts are {{ voucherShortfall | number: '1.2-2' }} short of - {{ amount | number: '1.2-2' }}. Tick another receipt, or collect the balance at the counter first. + Selected total {{ selectedVoucherTotal | number: '1.2-2' }} is + {{ voucherShortfall | number: '1.2-2' }} short of tranche target + {{ amount | number: '1.2-2' }}. Tick another receipt or collect balance.
} }
- } +
+ } - @if (paymentMode === 'SAVINGS') { -
-

- The amount is debited from the member's linked savings into Share Sundry when this is submitted, so - their balance drops now. Approving the request then moves it into share capital. -

+ + @if (paymentMode === 'SAVINGS') { +
+
+ + + The tranche amount will be automatically debited from the member's linked savings account into Share + Sundry upon submission. + +
- @if (checkingSavings) { -
- - Checking the linked savings account… -
- } @else if (savingsEligibility) { -
- @if (savingsEligibility.savingsAccountNo) { -
- Savings account - {{ savingsEligibility.savingsAccountNo }} -
-
- Available balance - {{ savingsEligibility.savingsBalance | number: '1.2-2' }} -
- } -
- To debit - {{ amount | number: '1.2-2' }} + @if (checkingSavings) { +
+ + Checking linked savings account eligibility… +
+ } @else if (savingsEligibility) { +
+ @if (savingsEligibility.savingsAccountNo) { +
+ Savings Account No. + {{ savingsEligibility.savingsAccountNo }}
-
- - @if (!savingsEligibility.eligible) { -
- error_outline - {{ savingsEligibility.reason }} +
+ Available Balance + {{ savingsEligibility.savingsBalance | number: '1.2-2' }}
} +
+ Amount to Debit + {{ amount | number: '1.2-2' }} +
+
+ + @if (!savingsEligibility.eligible) { +
+ error_outline + {{ savingsEligibility.reason }} +
} -
- } + } +
+ } - @if (errorMsg) { -
- error_outline - {{ errorMsg }} -
- } + @if (errorMsg) { +
+ error_outline + {{ errorMsg }} +
+ } - -

- Submitting records a request for approval. The shares are issued, and the money moved out of Share Sundry, - only once it is approved — until then it appears under pending, not in the account's transactions. -

+ +
+ schedule + + Submitting records a request for approval. Shares are issued and funds moved out of Share Sundry only upon + manager approval. +
- - -
+ + +@if (viewingVoucherDetail) { + +} diff --git a/src/app/shares/shares-account-actions/apply-shares/apply-shares.component.scss b/src/app/shares/shares-account-actions/apply-shares/apply-shares.component.scss index 9a9fa643c..80603efdc 100644 --- a/src/app/shares/shares-account-actions/apply-shares/apply-shares.component.scss +++ b/src/app/shares/shares-account-actions/apply-shares/apply-shares.component.scss @@ -6,201 +6,1027 @@ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ -$primary: #1a3a6b; -$border: #d8e0ec; -$muted: #6b7a90; -$danger: #b3261e; +// Modern Color Tokens +$primary: #1e3a8a; +$primary-light: #eff6ff; +$primary-border: #bfdbfe; +$accent-blue: #2563eb; +$text-main: #0f172a; +$text-muted: #64748b; +$border-color: #e2e8f0; +$border-light: #f1f5f9; +$bg-surface: #fff; +$bg-slate: #f8fafc; -.container { - max-width: 37rem; +$success: #059669; +$success-bg: #ecfdf5; +$success-border: #a7f3d0; + +$warning: #d97706; +$warning-bg: #fffbeb; +$warning-border: #fde68a; + +$danger: #dc2626; +$danger-bg: #fef2f2; +$danger-border: #fecaca; + +.action-container { + max-width: 72rem; + margin: 0.75rem auto; + padding: 0 0.5rem; } -.payment-mode { - margin: 4px 0 16px; +.action-card { + border-radius: 12px; + border: 1px solid $border-color; + background: $bg-surface; + box-shadow: 0 4px 16px -4px rgb(15 23 42 / 6%); + overflow: hidden; } -.payment-mode-label { - font-size: 12px; - text-transform: uppercase; - letter-spacing: 0.4px; - color: $muted; - margin-bottom: 8px; +/* Compact Header */ +.card-header-banner { + display: flex; + align-items: center; + gap: 0.75rem; + padding: 0.75rem 1rem; + background: linear-gradient(135deg, #1e3a8a 0%, #2563eb 100%); + color: #fff; + + .header-icon-box { + display: flex; + align-items: center; + justify-content: center; + width: 34px; + height: 34px; + background: rgb(255 255 255 / 20%); + border-radius: 8px; + + mat-icon { + font-size: 20px; + width: 20px; + height: 20px; + color: #fff; + } + } + + .header-text { + h2 { + margin: 0; + font-size: 1.05rem; + font-weight: 700; + color: #fff; + } + + p { + margin: 1px 0 0; + font-size: 0.78rem; + color: rgb(255 255 255 / 85%); + } + } } -.payment-mode-options { +.card-body { + padding: 0.85rem !important; +} + +/* Form Sections */ +.form-section { + background: $bg-surface; + border: 1px solid $border-light; + border-radius: 10px; + padding: 0.75rem 0.85rem; + margin-bottom: 0.65rem; + + &.financial-section { + background: $bg-slate; + border-color: $border-color; + } +} + +.section-title { + display: flex; + align-items: center; + gap: 0.4rem; + font-size: 0.85rem; + font-weight: 700; + color: $text-main; + margin-bottom: 0.65rem; + padding-bottom: 0.35rem; + border-bottom: 1px solid $border-light; + + .sec-icon { + font-size: 18px; + width: 18px; + height: 18px; + color: $accent-blue; + } +} + +/* Compact Form Grids */ +.form-grid { + display: grid; + gap: 0.65rem; + + &.form-grid-3 { + grid-template-columns: repeat(3, 1fr); + + @media (width <= 680px) { + grid-template-columns: 1fr; + } + } +} + +.align-items-center { + align-items: center; +} + +mat-form-field { + width: 100%; +} + +.shares-count-input { + text-align: left; +} + +// Disabled read-back field (auto-filled market price) — right-aligned so the figure sits right +// next to its "per share" suffix instead of stranded at the far left of an otherwise-empty field. +.price-input { + text-align: right; +} + +// Material fades a disabled outline field almost to invisibility by default, which next to the +// enabled "Total No. of Shares" field (a solid dark border) reads as a missing box rather than a +// deliberately read-only one. Matched back to the same border weight and label/value contrast. +.price-field ::ng-deep { + .mdc-notched-outline__leading, + .mdc-notched-outline__notch, + .mdc-notched-outline__trailing { + border-color: $border-color !important; + opacity: 1 !important; + } + + .mdc-floating-label { + color: $text-muted !important; + } + + input:disabled { + color: $text-main !important; + -webkit-text-fill-color: $text-main !important; + } +} + +.currency-label { + color: $text-muted; + font-size: 0.8rem; + white-space: nowrap; + margin-left: 0.35rem; +} + +/* Financial Stat Display Card */ +.amount-display-card { display: flex; flex-direction: column; - gap: 4px; + justify-content: center; + background: linear-gradient(135deg, #eff6ff 0%, #dbeafe 100%); + border: 1px solid $primary-border; + border-radius: 8px; + padding: 0.45rem 0.75rem; + height: 48px; + box-sizing: border-box; + + .amount-label { + font-size: 0.65rem; + text-transform: uppercase; + letter-spacing: 0.05em; + color: #1e40af; + font-weight: 700; + } + + .amount-value { + font-size: 1.1rem; + font-weight: 700; + color: #1e3a8a; + font-family: 'Courier New', monospace; + line-height: 1.1; + } + + .amount-subtext { + font-size: 0.65rem; + color: #3b82f6; + } } -/* The funding route's own detail, boxed off from the share fields above it: which voucher, or - which savings account, is what the clerk checks before submitting. */ +/* Payment Option Radio Cards */ +.payment-card-group { + display: grid; + grid-template-columns: repeat(2, 1fr); + gap: 0.65rem; + + @media (width <= 580px) { + grid-template-columns: 1fr; + } +} + +.payment-card-option { + display: flex; + align-items: center; + gap: 0.65rem; + padding: 0.65rem 0.75rem; + border: 1.5px solid $border-color; + border-radius: 10px; + background: $bg-surface; + cursor: pointer; + user-select: none; + transition: all 0.15s ease; + + .option-icon-box { + display: flex; + align-items: center; + justify-content: center; + width: 32px; + height: 32px; + border-radius: 8px; + background: $bg-slate; + color: $text-muted; + + mat-icon { + font-size: 18px; + width: 18px; + height: 18px; + } + } + + .option-content { + display: flex; + flex-direction: column; + + .option-title { + font-size: 0.82rem; + font-weight: 600; + color: $text-main; + } + + .option-desc { + font-size: 0.72rem; + color: $text-muted; + } + } + + .selected-check { + margin-left: auto; + font-size: 18px; + width: 18px; + height: 18px; + color: $accent-blue; + } + + &:hover { + border-color: #93c5fd; + background: #f8fafc; + } + + &.active { + border-color: $accent-blue; + background: $primary-light; + + .option-icon-box { + background: $accent-blue; + color: #fff; + } + } +} + +/* Funding Panel */ .funding-panel { - border: 1px solid $border; + border: 1px solid $border-color; + border-radius: 10px; + padding: 0.75rem; + background: #fcfdfe; + margin-top: 0.4rem; + margin-bottom: 0.65rem; +} + +.funding-intro-banner { + display: flex; + align-items: center; + gap: 0.5rem; + background: #f0f9ff; + border: 1px solid #bae6fd; border-radius: 6px; - padding: 14px; - margin-bottom: 16px; - background: #fbfcfe; + padding: 0.45rem 0.65rem; + font-size: 0.78rem; + color: #0369a1; + margin-bottom: 0.65rem; + + .banner-icon { + font-size: 16px; + width: 16px; + height: 16px; + color: #0284c7; + flex-shrink: 0; + } + + .inline-icon { + font-size: 14px; + width: 14px; + height: 14px; + vertical-align: middle; + } } -.funding-intro { - font-size: 13px; - color: $muted; - margin: 0 0 12px; +/* Lookup Toolbar */ +.lookup-toolbar { + display: grid; + grid-template-columns: 1fr 1fr; + gap: 0.65rem; + background: $bg-slate; + padding: 0.65rem; + border-radius: 8px; + border: 1px solid $border-color; + margin-bottom: 0.65rem; + + @media (width <= 600px) { + grid-template-columns: 1fr; + } } -.gl-select-field { - margin: 0 0 10px; +.toolbar-label { + display: flex; + align-items: center; + gap: 4px; + font-size: 0.72rem; + font-weight: 600; + text-transform: uppercase; + color: $text-muted; + margin-bottom: 4px; + + .mini-icon { + font-size: 14px; + width: 14px; + height: 14px; + } } -.gl-select { +.custom-select { width: 100%; - padding: 9px 11px; - border: 1px solid $border; + height: 34px; + padding: 0 0.65rem; + border: 1px solid #cbd5e1; border-radius: 6px; - font-size: 13px; - color: $primary; + font-size: 0.8rem; + color: $text-main; background: #fff; &:focus { outline: none; - border-color: $primary; - } - - &:disabled { - background: #f4f7fb; - color: $muted; + border-color: $accent-blue; } } -.voucher-lookup { +.lookup-input-group { display: flex; - gap: 8px; - align-items: center; - margin: 0 0 12px; -} + gap: 0.4rem; -.voucher-input { - flex: 1; - min-width: 0; - padding: 9px 11px; - border: 1px solid $border; - border-radius: 6px; - font-family: 'Roboto Mono', monospace; - font-size: 13px; - letter-spacing: 0.4px; - color: $primary; + .lookup-input { + flex: 1; + height: 34px; + padding: 0 0.65rem; + border: 1px solid #cbd5e1; + border-radius: 6px; + font-size: 0.8rem; + color: $text-main; + background: #fff; - &:focus { - outline: none; - border-color: $primary; + &:focus { + outline: none; + border-color: $accent-blue; + } } - &:disabled { - background: #f4f7fb; - color: $muted; + .find-btn { + height: 34px; + border-radius: 6px; + padding: 0 0.75rem; + display: inline-flex; + align-items: center; + gap: 4px; + font-size: 0.8rem; + + mat-icon { + font-size: 16px; + width: 16px; + height: 16px; + } } } -.funding-loading { - display: flex; - align-items: center; - gap: 10px; - padding: 8px 0; - font-size: 13px; - color: $muted; -} - -.funding-summary { - border: 1px solid $border; - border-radius: 6px; +/* Vouchers Array Card & Table */ +.vouchers-card { + border: 1px solid $border-color; + border-radius: 8px; background: #fff; - padding: 10px 12px; - font-size: 13px; + overflow: hidden; } -.funding-row { +.vouchers-header { display: flex; justify-content: space-between; - gap: 12px; - padding: 4px 0; - color: $primary; + align-items: center; + padding: 0.5rem 0.75rem; + background: #f1f5f9; + border-bottom: 1px solid $border-color; - > span:first-child { - color: $muted; + .vouchers-title { + display: flex; + align-items: center; + gap: 0.4rem; + font-size: 0.84rem; + font-weight: 700; + color: $text-main; + + .v-icon { + font-size: 16px; + width: 16px; + height: 16px; + color: $accent-blue; + } + + .badge-count { + background: $accent-blue; + color: #fff; + font-size: 0.7rem; + font-weight: 700; + padding: 0 6px; + border-radius: 999px; + } + } + + .select-all-btn { + font-size: 0.75rem; + color: $accent-blue; + font-weight: 600; + + mat-icon { + font-size: 16px; + width: 16px; + height: 16px; + margin-right: 2px; + } } } -.funding-row--total { - border-top: 1px solid #eceff4; - margin-top: 4px; - padding-top: 8px; - font-weight: 500; -} - -.mono { - font-family: 'Courier New', monospace; -} - -.funding-empty { - display: flex; - gap: 10px; - align-items: flex-start; - background: #f4f7fb; - border: 1px solid $border; - border-radius: 6px; - padding: 12px; - font-size: 13px; -} - -.funding-empty-hint { - color: $muted; - margin-top: 4px; +.table-responsive { + width: 100%; + overflow-x: auto; } .receipt-table { width: 100%; - border-collapse: collapse; - font-size: 13px; - background: #fff; - border: 1px solid $border; - border-radius: 6px; + border-collapse: separate; + border-spacing: 0; + font-size: 0.78rem; th { - background: #eef2f8; - color: $primary; - font-size: 11px; + background: $bg-slate; + color: $text-muted; + font-size: 0.7rem; + font-weight: 700; text-transform: uppercase; - letter-spacing: 0.4px; - padding: 8px; + padding: 0.5rem 0.65rem; + border-bottom: 1px solid $border-color; } td { - padding: 8px; - border-bottom: 1px solid #eceff4; + padding: 0.45rem 0.65rem; + border-bottom: 1px solid $border-light; + vertical-align: middle; } - tbody tr { + .receipt-row { cursor: pointer; + transition: background 0.12s ease; &:hover { - background: #f4f7fb; + background: #f8fafc; } &.selected { - background: #e7effb; + background: #eff6ff; + + .voucher-badge { + background: #dbeafe; + color: #1e40af; + border-color: #bfdbfe; + } } } - /* The running total against the tranche amount — what decides whether the request is accepted, - and not something to be added up by eye once a second receipt is in play. */ - tfoot .total-row { - border-top: 2px solid #cbd5e1; + .col-checkbox { + width: 36px; + } + + .col-actions { + width: 44px; + } + + .custom-checkbox { + width: 16px; + height: 16px; + accent-color: $accent-blue; + cursor: pointer; + } + + .voucher-badge { + display: inline-block; + background: #f1f5f9; + color: #334155; + padding: 2px 6px; + border-radius: 4px; + border: 1px solid #cbd5e1; + font-weight: 600; + font-size: 0.78rem; + } + + .desc-cell { + color: $text-muted; + max-width: 160px; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; + } + + .user-chip { + display: inline-flex; + align-items: center; + gap: 3px; + background: $bg-slate; + border: 1px solid $border-color; + padding: 1px 6px; + border-radius: 999px; + font-size: 0.72rem; + color: $text-muted; + + .chip-icon { + font-size: 12px; + width: 12px; + height: 12px; + } + } + + .amount-cell { + color: $text-main; + font-size: 0.84rem; + } + + .view-detail-btn { + width: 28px; + height: 28px; + line-height: 28px; + + .eye-icon { + font-size: 18px; + width: 18px; + height: 18px; + color: $accent-blue; + } + } +} + +/* Vouchers Footer Bar */ +.vouchers-footer { + display: flex; + justify-content: space-between; + align-items: center; + padding: 0.5rem 0.75rem; + background: $bg-slate; + border-top: 1.5px solid #cbd5e1; + flex-wrap: wrap; + gap: 0.5rem; + + .selected-summary { + display: flex; + align-items: center; + gap: 1rem; + flex-wrap: wrap; + + .summary-text { + font-size: 0.78rem; + color: $text-muted; + } + + .summary-amounts { + display: flex; + align-items: center; + gap: 0.3rem; + font-size: 0.8rem; + + .total-label { + color: $text-muted; + } + + .total-value { + color: $primary; + font-family: 'Courier New', monospace; + } + + .target-divider { + color: $text-muted; + } + + .target-value { + color: $text-muted; + } + } + } +} + +/* Coverage Status Badges */ +.coverage-status { + .status-badge { + display: inline-flex; + align-items: center; + gap: 4px; + padding: 3px 10px; + border-radius: 999px; + font-size: 0.72rem; + font-weight: 700; + + mat-icon { + font-size: 14px; + width: 14px; + height: 14px; + } + + &.badge-neutral { + background: #f1f5f9; + color: #64748b; + border: 1px solid #cbd5e1; + } + + &.badge-warning { + background: $warning-bg; + color: $warning; + border: 1px solid $warning-border; + } + + &.badge-success { + background: $success-bg; + color: $success; + border: 1px solid $success-border; + } + } +} + +/* Savings Panel Details */ +.savings-card { + background: #fff; + border: 1px solid $border-color; + border-radius: 8px; + padding: 0.65rem 0.85rem; +} + +.savings-row { + display: flex; + justify-content: space-between; + align-items: center; + padding: 0.35rem 0; + + .lbl { + font-size: 0.78rem; + color: $text-muted; + } + + .val { + font-size: 0.82rem; + color: $text-main; + } + + &.savings-row-total { + border-top: 1px dashed $border-color; + margin-top: 0.2rem; + padding-top: 0.5rem; + + .lbl { + font-weight: 600; + color: $text-main; + } + + .val { + font-size: 0.95rem; + font-weight: 700; + } + } +} + +/* Alert Boxes */ +.alert-box { + display: flex; + align-items: center; + gap: 0.5rem; + padding: 0.5rem 0.75rem; + border-radius: 8px; + font-size: 0.78rem; + + mat-icon { + font-size: 18px; + width: 18px; + height: 18px; + flex-shrink: 0; + } + + &.alert-error { + background: $danger-bg; + color: $danger; + border: 1px solid $danger-border; + } + + &.alert-warning { + background: $warning-bg; + color: #92400e; + border: 1px solid $warning-border; + } +} + +/* Pending Notice Banner */ +.pending-notice { + display: flex; + align-items: center; + gap: 0.5rem; + background: #f8fafc; + border: 1px solid $border-color; + border-radius: 8px; + padding: 0.5rem 0.75rem; + font-size: 0.76rem; + color: $text-muted; + + .notice-icon { + font-size: 16px; + width: 16px; + height: 16px; + color: $accent-blue; + flex-shrink: 0; + } +} + +/* Action Buttons Bar */ +.card-actions-bar { + display: flex; + justify-content: flex-end; + align-items: center; + gap: 0.65rem; + padding: 0.75rem 1rem !important; + background: $bg-slate; + border-top: 1px solid $border-color; + + .action-btn { + height: 36px; + padding: 0 1.25rem; + border-radius: 6px; + font-size: 0.82rem; + font-weight: 600; + + mat-icon { + font-size: 16px; + width: 16px; + height: 16px; + margin-right: 4px; + } + } +} + +/* POPUP MODAL DIALOG STYLES */ +.modal-backdrop { + position: fixed; + inset: 0; + background: rgb(15 23 42 / 50%); + backdrop-filter: blur(4px); + z-index: 1000; + display: flex; + align-items: center; + justify-content: center; + padding: 1rem; +} + +.voucher-modal-dialog { + background: #fff; + border-radius: 12px; + width: 100%; + max-width: 36rem; + box-shadow: 0 20px 40px -10px rgb(15 23 42 / 25%); + border: 1px solid $border-color; + overflow: hidden; + animation: modal-scale-in 0.2s ease-out; + + .modal-header { + display: flex; + justify-content: space-between; + align-items: center; + padding: 0.85rem 1.25rem; + background: linear-gradient(135deg, #1e3a8a 0%, #2563eb 100%); + color: #fff; + + .header-title { + display: flex; + align-items: center; + gap: 0.5rem; + font-size: 0.95rem; + font-weight: 700; + + .m-icon { + font-size: 20px; + width: 20px; + height: 20px; + } + + .modal-badge { + background: rgb(255 255 255 / 20%); + padding: 2px 8px; + border-radius: 6px; + font-size: 0.8rem; + } + } + + .close-btn { + color: #fff; + width: 32px; + height: 32px; + line-height: 32px; + } + } + + .modal-body { + padding: 1rem 1.25rem; + max-height: 70vh; + overflow-y: auto; + } + + .detail-grid { + display: grid; + grid-template-columns: repeat(2, 1fr); + gap: 0.75rem; + + @media (width <= 480px) { + grid-template-columns: 1fr; + } + } + + .detail-item { + display: flex; + flex-direction: column; + gap: 2px; + background: $bg-slate; + padding: 0.5rem 0.75rem; + border-radius: 8px; + border: 1px solid $border-light; + + .detail-label { + font-size: 0.7rem; + text-transform: uppercase; + letter-spacing: 0.04em; + color: $text-muted; + font-weight: 600; + } + + .detail-val { + font-size: 0.84rem; + color: $text-main; + } + + &.full-width { + grid-column: 1 / -1; + } + + &.highlight-amount-box { + background: $primary-light; + border-color: $primary-border; + + .detail-label { + color: #1e40af; + } + + .amount-val { + font-size: 1.25rem; + font-weight: 700; + color: #1e3a8a; + } + } + } + + .status-pill { + display: inline-block; + padding: 2px 8px; + border-radius: 999px; + font-size: 0.72rem; + font-weight: 700; + width: fit-content; + + &.status-unapplied { + background: $success-bg; + color: $success; + border: 1px solid $success-border; + } + } + + .note-box { + background: #fff; + border: 1px solid $border-color; + border-radius: 6px; + padding: 0.5rem 0.65rem; + font-size: 0.8rem; + color: $text-main; + line-height: 1.4; + margin-top: 4px; + white-space: pre-wrap; + } + + .gl-entries-loading, + .gl-entries-empty { + font-size: 0.8rem; + color: $text-muted; + padding: 0.5rem 0; + } + + .gl-entries-table { + width: 100%; + border-collapse: collapse; + margin-top: 4px; + font-size: 0.8rem; + background: #fff; + border: 1px solid $border-color; + border-radius: 6px; + overflow: hidden; + + th { + background: $bg-slate; + color: $text-muted; + font-size: 0.68rem; + text-transform: uppercase; + letter-spacing: 0.04em; + font-weight: 700; + padding: 0.4rem 0.6rem; + border-bottom: 1px solid $border-color; + } td { - padding-top: 10px; - color: #1f3d7a; + padding: 0.4rem 0.6rem; + border-bottom: 1px solid $border-light; + color: $text-main; } + + tr:last-child td { + border-bottom: none; + } + } + + .drcr-pill { + display: inline-block; + padding: 1px 8px; + border-radius: 999px; + font-size: 0.68rem; + font-weight: 700; + + &.drcr-pill--dr { + background: $danger-bg; + color: $danger; + border: 1px solid $danger-border; + } + + &.drcr-pill--cr { + background: $success-bg; + color: $success; + border: 1px solid $success-border; + } + } + + .modal-footer { + display: flex; + justify-content: flex-end; + gap: 0.65rem; + padding: 0.75rem 1.25rem; + background: $bg-slate; + border-top: 1px solid $border-color; + + .modal-action-btn { + mat-icon { + font-size: 18px; + width: 18px; + height: 18px; + margin-right: 4px; + } + } + } +} + +@keyframes modal-scale-in { + from { + opacity: 0; + transform: scale(0.95); + } + + to { + opacity: 1; + transform: scale(1); } } @@ -216,21 +1042,50 @@ $danger: #b3261e; text-align: center; } -.alert-error { - display: flex; - align-items: center; - gap: 8px; - background: #fdecea; - color: $danger; - border: 1px solid #f5c6c2; - border-radius: 6px; - padding: 10px 12px; - font-size: 13px; - margin-bottom: 12px; +.font-mono { + font-family: 'Courier New', monospace; } -.pending-hint { - font-size: 12px; - color: $muted; - margin: 0; +.font-semibold { + font-weight: 600; +} + +.font-accent { + font-family: + system-ui, + -apple-system, + BlinkMacSystemFont, + 'Segoe UI', + Roboto, + sans-serif; +} + +.font-success { + color: $success !important; +} + +.mt-8 { + margin-top: 8px; +} + +.mt-10 { + margin-top: 10px; +} + +.mt-12 { + margin-top: 12px; +} + +.animate-fade-in { + animation: fade-in 0.2s ease-in-out; +} + +@keyframes fade-in { + from { + opacity: 0; + } + + to { + opacity: 1; + } } diff --git a/src/app/shares/shares-account-actions/apply-shares/apply-shares.component.ts b/src/app/shares/shares-account-actions/apply-shares/apply-shares.component.ts index b071be51b..8922da149 100644 --- a/src/app/shares/shares-account-actions/apply-shares/apply-shares.component.ts +++ b/src/app/shares/shares-account-actions/apply-shares/apply-shares.component.ts @@ -107,6 +107,15 @@ export class ApplySharesComponent implements OnInit { glAccounts: any[] = []; loadingGlAccounts = true; + /** Currently selected voucher for viewing in the detail popup. */ + viewingVoucherDetail: any = null; + + /** The receipt's own real Dr/Cr GL legs, fetched fresh per voucher — never the GL Account + * dropdown above (that is just what the by-voucher search box is currently set to, which can + * name a completely different account than the one this specific receipt actually sits in). */ + voucherGlEntries: { glCode: string; accountName: string; drCr: string; amount: number }[] = []; + loadingGlEntries = false; + /** What the member's savings can cover, for the savings route. */ savingsEligibility: { eligible: boolean; @@ -134,10 +143,43 @@ export class ApplySharesComponent implements OnInit { .pipe(catchError(() => of(null))) .subscribe((res: any) => { this.glAccounts = res?.glAccounts || []; + // Auto-picked only when there is exactly one live candidate -- the backend already narrowed + // this list to accounts that actually hold an eligible receipt today, so a single option is + // unambiguous. Two or more still means the counter chooses by hand: picking "whichever + // sorts first" among several real candidates is the same blind guess that used to land on + // Cash at Vault, just with a shorter, filtered list to guess from. + if (this.glAccounts.length === 1 && !this.glAccountNo) { + this.glAccountNo = this.glAccounts[0].gl_code; + } this.loadingGlAccounts = false; }); } + setPaymentMode(mode: string): void { + this.applySharesForm.get('paymentMode')?.setValue(mode); + } + + openVoucherDetail(voucher: any, event?: Event): void { + if (event) { + event.stopPropagation(); + } + this.viewingVoucherDetail = voucher; + this.voucherGlEntries = []; + this.loadingGlEntries = true; + this.cashierService + .getShareAdvanceGlEntries(voucher.id) + .pipe(catchError(() => of(null))) + .subscribe((res: any) => { + this.voucherGlEntries = res?.entries || []; + this.loadingGlEntries = false; + }); + } + + closeVoucherDetail(): void { + this.viewingVoucherDetail = null; + this.voucherGlEntries = []; + } + /** * Creates the apply shares form. * @@ -254,12 +296,24 @@ export class ApplySharesComponent implements OnInit { return this.selectedVoucherIds.includes(id); } + /** + * One receipt funds one tranche — no combining instalments here (unlike the first-application + * Apply Collection dialog, which still allows several). Picking a different receipt replaces the + * current one rather than adding to it, so {@link selectedVoucherIds} never holds more than one id. + */ toggleVoucher(id: number): void { - this.selectedVoucherIds = this.isVoucherSelected(id) ? this.selectedVoucherIds.filter((x) => x !== id) : [ - ...this.selectedVoucherIds, - id - ]; + const wasSelected = this.isVoucherSelected(id); + this.selectedVoucherIds = wasSelected ? [] : [id]; this.voucherError = ''; + // The GL Account picker above follows whichever receipt is ticked — its own actual GL account + // (see gl_code on the row), not left showing an unrelated account or "Select…" once a receipt + // that plainly does have one is already chosen. + if (!wasSelected) { + const voucher = this.vouchers.find((v) => v.id === id); + if (voucher?.gl_code) { + this.glAccountNo = voucher.gl_code; + } + } } /** @@ -307,19 +361,13 @@ export class ApplySharesComponent implements OnInit { this.voucherError = `Voucher ${trimmed} belongs to ${res.client_name}, not the holder of this share account.`; return; } - // Added to the selection rather than replacing it — a looked-up voucher is usually the - // second half of a part-payment, not a change of mind about the first. No amount check - // here: one instalment is not meant to cover the tranche on its own. + // Replaces the selection rather than adding to it — one receipt funds one tranche here, so + // finding a voucher by number is a different way to pick THE receipt, not a second one. this.vouchers = [ res, ...this.vouchers.filter((v) => v.id !== res.id) ]; - if (!this.isVoucherSelected(res.id)) { - this.selectedVoucherIds = [ - ...this.selectedVoucherIds, - res.id - ]; - } + this.selectedVoucherIds = [res.id]; }); } diff --git a/src/app/shares/shares-account-view/apply-collection-dialog/apply-collection-dialog.component.html b/src/app/shares/shares-account-view/apply-collection-dialog/apply-collection-dialog.component.html index 4733ce6b2..756d2d39e 100644 --- a/src/app/shares/shares-account-view/apply-collection-dialog/apply-collection-dialog.component.html +++ b/src/app/shares/shares-account-view/apply-collection-dialog/apply-collection-dialog.component.html @@ -168,6 +168,8 @@ Voucher / Receipt Date + Description + Collected By Amount @@ -186,6 +188,8 @@ {{ r.voucher_no || r.receipt_no }} {{ r.collection_date }} + {{ r.note || '—' }} + {{ r.collected_by_name || '—' }} {{ r.amount | number: '1.2-2' }} } @@ -197,7 +201,7 @@ - + {{ selectedIds.length }} {{ selectedIds.length === 1 ? 'receipt' : 'receipts' }} selected {{ selectedTotal | number: '1.2-2' }} diff --git a/src/app/shares/shares-account-view/apply-collection-dialog/apply-collection-dialog.component.ts b/src/app/shares/shares-account-view/apply-collection-dialog/apply-collection-dialog.component.ts index 7ceb51747..b556591c7 100644 --- a/src/app/shares/shares-account-view/apply-collection-dialog/apply-collection-dialog.component.ts +++ b/src/app/shares/shares-account-view/apply-collection-dialog/apply-collection-dialog.component.ts @@ -135,6 +135,12 @@ export class ApplyCollectionDialogComponent implements OnInit { ) .subscribe((res: any) => { this.glAccounts = res?.glAccounts || []; + // Auto-picked only when there is exactly one live candidate -- the backend already narrowed + // this list to accounts that actually hold an eligible receipt today, so a single option is + // unambiguous. Two or more still means the counter chooses by hand. + if (this.glAccounts.length === 1 && !this.glAccountNo) { + this.glAccountNo = this.glAccounts[0].gl_code; + } this.loadingGlAccounts = false; }); diff --git a/src/app/tasks/checker-inbox-and-tasks-tabs/share-addition-requests/share-addition-request-detail-dialog/share-addition-request-detail-dialog.component.html b/src/app/tasks/checker-inbox-and-tasks-tabs/share-addition-requests/share-addition-request-detail-dialog/share-addition-request-detail-dialog.component.html index d7f2c7136..4e7fc8f42 100644 --- a/src/app/tasks/checker-inbox-and-tasks-tabs/share-addition-requests/share-addition-request-detail-dialog/share-addition-request-detail-dialog.component.html +++ b/src/app/tasks/checker-inbox-and-tasks-tabs/share-addition-requests/share-addition-request-detail-dialog/share-addition-request-detail-dialog.component.html @@ -68,18 +68,70 @@ Voucher / Receipt Date + Description Collected by Amount + Detail @for (v of detail.vouchers; track v.id) { - + {{ v.voucherNo || v.receiptNo }} {{ v.collectionDate }} + {{ v.note || '—' }} {{ v.collectedByName || '—' }} {{ v.amount | number: '1.2-2' }} + + + + @if (expandedVoucherId === v.id) { + + + @if (loadingGlEntriesId === v.id) { +
Loading GL entry…
+ } @else if (!glEntriesByVoucherId[v.id]?.length) { +
No GL posting recorded for this receipt.
+ } @else { + + + + + + + + + + @for (leg of glEntriesByVoucherId[v.id]; track leg.glCode + leg.drCr) { + + + + + + } + +
GL AccountDr / CrAmount
{{ leg.glCode }} — {{ leg.accountName }} + + {{ leg.drCr }} + + {{ leg.amount | number: '1.2-2' }}
+ } + + + } } diff --git a/src/app/tasks/checker-inbox-and-tasks-tabs/share-addition-requests/share-addition-request-detail-dialog/share-addition-request-detail-dialog.component.scss b/src/app/tasks/checker-inbox-and-tasks-tabs/share-addition-requests/share-addition-request-detail-dialog/share-addition-request-detail-dialog.component.scss index 4e8a7aff8..7b9c8d19f 100644 --- a/src/app/tasks/checker-inbox-and-tasks-tabs/share-addition-requests/share-addition-request-detail-dialog/share-addition-request-detail-dialog.component.scss +++ b/src/app/tasks/checker-inbox-and-tasks-tabs/share-addition-requests/share-addition-request-detail-dialog/share-addition-request-detail-dialog.component.scss @@ -121,3 +121,93 @@ $danger: #b3261e; font-size: 13px; margin-top: 12px; } + +.ta-center { + text-align: center; +} + +.sard-voucher-row { + cursor: pointer; + + &:hover { + background: #f4f7fb; + } +} + +.sard-desc-cell { + max-width: 160px; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.sard-detail-btn { + width: 30px; + height: 30px; + line-height: 30px; + + mat-icon { + font-size: 18px; + width: 18px; + height: 18px; + color: $primary; + } +} + +.sard-gl-row td { + background: #fbfcfe; + border-top: none; + padding: 4px 8px 10px; +} + +.sard-gl-loading, +.sard-gl-empty { + font-size: 12px; + color: $muted; + padding: 4px 0; +} + +.sard-gl-table { + width: 100%; + border-collapse: collapse; + font-size: 12px; + background: #fff; + border: 1px solid $border; + border-radius: 6px; + overflow: hidden; + + th { + color: $muted; + font-size: 10px; + text-transform: uppercase; + letter-spacing: 0.4px; + padding: 5px 8px; + background: #f4f7fb; + border-bottom: 1px solid $border; + } + + td { + padding: 5px 8px; + border-top: 1px solid #eceff4; + } +} + +.sard-drcr-pill { + display: inline-block; + padding: 1px 8px; + border-radius: 999px; + font-size: 10px; + font-weight: 700; + + &.sard-drcr-pill--dr { + background: #fef2f2; + color: #dc2626; + border: 1px solid #fecaca; + } + + &.sard-drcr-pill--cr { + background: #ecfdf5; + color: #059669; + border: 1px solid #a7f3d0; + } +} diff --git a/src/app/tasks/checker-inbox-and-tasks-tabs/share-addition-requests/share-addition-request-detail-dialog/share-addition-request-detail-dialog.component.ts b/src/app/tasks/checker-inbox-and-tasks-tabs/share-addition-requests/share-addition-request-detail-dialog/share-addition-request-detail-dialog.component.ts index 05a5267e8..2920bb3b7 100644 --- a/src/app/tasks/checker-inbox-and-tasks-tabs/share-addition-requests/share-addition-request-detail-dialog/share-addition-request-detail-dialog.component.ts +++ b/src/app/tasks/checker-inbox-and-tasks-tabs/share-addition-requests/share-addition-request-detail-dialog/share-addition-request-detail-dialog.component.ts @@ -50,6 +50,12 @@ export class ShareAdditionRequestDetailDialogComponent implements OnInit { actioning = false; detail: any = null; + /** Which voucher's row is currently expanded to show its real Dr/Cr GL legs, or null if none. */ + expandedVoucherId: number | null = null; + loadingGlEntriesId: number | null = null; + /** Fetched once per voucher and kept — a checker toggling the same row twice shouldn't re-fetch. */ + glEntriesByVoucherId: { [id: number]: { glCode: string; accountName: string; drCr: string; amount: number }[] } = {}; + public dialogRef = inject(MatDialogRef); public data = inject<{ transactionId: number }>(MAT_DIALOG_DATA); @@ -76,6 +82,26 @@ export class ShareAdditionRequestDetailDialogComponent implements OnInit { return this.detail?.vouchers?.length || 0; } + /** Shows or hides a voucher's real GL posting, fetching it the first time it is opened. */ + toggleVoucherGlEntries(voucherId: number): void { + if (this.expandedVoucherId === voucherId) { + this.expandedVoucherId = null; + return; + } + this.expandedVoucherId = voucherId; + if (this.glEntriesByVoucherId[voucherId]) { + return; + } + this.loadingGlEntriesId = voucherId; + this.cashierService + .getShareAdvanceGlEntries(voucherId) + .pipe(catchError(() => of(null))) + .subscribe((res: any) => { + this.glEntriesByVoucherId[voucherId] = res?.entries || []; + this.loadingGlEntriesId = null; + }); + } + approve(): void { this.act('approveadditionalshares'); } From f9b73a4930aa7effdeac88aa5a152df7b1df676e Mon Sep 17 00:00:00 2001 From: keshav Date: Fri, 18 Sep 2026 10:32:47 +0530 Subject: [PATCH 60/64] feat(aml,risk-review,accounting): update financial activity mapping options, AML flagged transactions filters, and risk review report --- ...te-financial-activity-mapping.component.ts | 21 +- ...it-financial-activity-mapping.component.ts | 21 +- .../financial-activity-account-options.ts | 38 ++ src/app/fiu/fiu.component.html | 139 +++---- src/app/fiu/fiu.component.ts | 82 ++-- .../aml-bulk-review-dialog.component.ts | 8 +- .../aml-flagged-transactions.component.html | 173 ++++++-- .../aml-flagged-transactions.component.scss | 109 +++++ .../aml-flagged-transactions.component.ts | 384 +++++++++++++++++- .../aml-review-alert-dialog.component.ts | 7 +- .../risk-change-approvals.component.ts | 19 +- .../risk-review-report.component.ts | 29 +- src/app/risk-review/risk-review.compute.ts | 63 ++- src/environments/.env.ts | 4 +- 14 files changed, 863 insertions(+), 234 deletions(-) create mode 100644 src/app/accounting/financial-activity-mappings/financial-activity-account-options.ts diff --git a/src/app/accounting/financial-activity-mappings/create-financial-activity-mapping/create-financial-activity-mapping.component.ts b/src/app/accounting/financial-activity-mappings/create-financial-activity-mapping/create-financial-activity-mapping.component.ts index 2aa1f0481..379940db2 100644 --- a/src/app/accounting/financial-activity-mappings/create-financial-activity-mapping/create-financial-activity-mapping.component.ts +++ b/src/app/accounting/financial-activity-mappings/create-financial-activity-mapping/create-financial-activity-mapping.component.ts @@ -16,6 +16,7 @@ import { AccountingService } from '../../accounting.service'; import { GLAccount } from 'app/shared/models/general.model'; import { GlAccountSelectorComponent } from '../../../shared/accounting/gl-account-selector/gl-account-selector.component'; import { STANDALONE_SHARED_IMPORTS } from 'app/standalone-shared.module'; +import { accountOptionsForActivity } from '../financial-activity-account-options'; /** * Create financial activity mapping component. @@ -87,21 +88,11 @@ export class CreateFinancialActivityMappingComponent implements OnInit { */ setGlAccountData() { this.financialActivityMappingForm.get('financialActivityId').valueChanges.subscribe((financialActivityId) => { - switch (financialActivityId) { - case 100: - case 101: - case 102: - case 103: - this.glAccountData = this.glAccountOptions.assetAccountOptions; - break; - case 200: - case 201: - this.glAccountData = this.glAccountOptions.liabilityAccountOptions; - break; - case 300: - this.glAccountData = this.glAccountOptions.equityAccountOptions; - break; - } + this.glAccountData = accountOptionsForActivity( + this.financialActivityData, + financialActivityId, + this.glAccountOptions + ); }); } diff --git a/src/app/accounting/financial-activity-mappings/edit-financial-activity-mapping/edit-financial-activity-mapping.component.ts b/src/app/accounting/financial-activity-mappings/edit-financial-activity-mapping/edit-financial-activity-mapping.component.ts index db1f71ca2..b8c6160b7 100644 --- a/src/app/accounting/financial-activity-mappings/edit-financial-activity-mapping/edit-financial-activity-mapping.component.ts +++ b/src/app/accounting/financial-activity-mappings/edit-financial-activity-mapping/edit-financial-activity-mapping.component.ts @@ -16,6 +16,7 @@ import { AccountingService } from '../../accounting.service'; import { GLAccount } from 'app/shared/models/general.model'; import { GlAccountSelectorComponent } from '../../../shared/accounting/gl-account-selector/gl-account-selector.component'; import { STANDALONE_SHARED_IMPORTS } from 'app/standalone-shared.module'; +import { accountOptionsForActivity } from '../financial-activity-account-options'; /** * Edit financial activity mapping component. @@ -98,21 +99,11 @@ export class EditFinancialActivityMappingComponent implements OnInit { */ setGLAccountData() { this.financialActivityMappingForm.get('financialActivityId').valueChanges.subscribe((financialActivityId) => { - switch (financialActivityId) { - case 100: - case 101: - case 102: - case 103: - this.glAccountData = this.glAccountOptions.assetAccountOptions; - break; - case 200: - case 201: - this.glAccountData = this.glAccountOptions.liabilityAccountOptions; - break; - case 300: - this.glAccountData = this.glAccountOptions.equityAccountOptions; - break; - } + this.glAccountData = accountOptionsForActivity( + this.financialActivityData, + financialActivityId, + this.glAccountOptions + ); }); } diff --git a/src/app/accounting/financial-activity-mappings/financial-activity-account-options.ts b/src/app/accounting/financial-activity-mappings/financial-activity-account-options.ts new file mode 100644 index 000000000..57a076f89 --- /dev/null +++ b/src/app/accounting/financial-activity-mappings/financial-activity-account-options.ts @@ -0,0 +1,38 @@ +/** + * Copyright since 2025 Mifos Initiative + * + * This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. + */ + +/** + * The GL accounts a financial activity may be mapped to. + * + * The activity itself says which kind of account it needs — the template returns `mappedGLAccountType` on every + * option — so the answer is read from the activity rather than worked out from its id. The screens used to switch on + * the id instead, listing the ones they knew: + * + * case 100, 101, 102, 103 → asset + * case 200, 201 → liability + * case 300 → equity + * + * Every activity added since fell through that switch and left the account dropdown empty, which is how dividendPaid + * (202), reserveForDividend (301), distributableProfit (302), retainedProfit (303) and dividendRoundOff (304) became + * unmappable — the screen offered no accounts and gave no reason. A list keyed on ids has to be extended by hand for + * each new activity, and is silently wrong until someone notices; asking the activity what it needs cannot fall out + * of step with the backend. + * + * @param activities the template's financialActivityOptions + * @param activityId the activity the user picked + * @param glAccountOptions the template's glAccountOptions, keyed assetAccountOptions / liabilityAccountOptions / … + */ +export function accountOptionsForActivity(activities: any[], activityId: number, glAccountOptions: any): any[] { + const activity = (activities || []).find((a) => a?.id === activityId); + const accountType = activity?.mappedGLAccountType; + if (!accountType || !glAccountOptions) { + return []; + } + // ASSET → assetAccountOptions, and so on for LIABILITY, EQUITY, INCOME and EXPENSE. + return glAccountOptions[`${accountType.toLowerCase()}AccountOptions`] ?? []; +} diff --git a/src/app/fiu/fiu.component.html b/src/app/fiu/fiu.component.html index d2f31d2ec..3c4946314 100644 --- a/src/app/fiu/fiu.component.html +++ b/src/app/fiu/fiu.component.html @@ -167,7 +167,9 @@
-
Due: {{ ctrDueDateLabel }}
+
+ Due: {{ ctrDueDateLabel }} +
@@ -220,9 +222,7 @@ @if (ctrLinesLoading) {
Loading the generated CTR lines…
} @else if (ctrLinesError) { -
- {{ ctrLinesError }} -
+
{{ ctrLinesError }}
} @else if (!ctrTxns.length) {
@@ -432,87 +432,17 @@ }
- -
-
-

Live AML Alerts (Awaiting Triage)

-

Flagged by the monitoring engine for suspicious behavioral patterns — triage and whitelist or escalate

-
- - + +
+ + + Alerts are triaged in RBI › AML Rules Master › Flagged Transactions. What an + officer escalates there arrives here as an STR case. +
-
- - - - - - - - - - - - - - - - - @for (alert of amlSuspiciousAlerts; track alert.id) { - - - - - - - - - - - - - } @empty { - - - - } - -
IDClientTypeRuleAmountRaised OnStatusRoutes ToNotesAction
{{ alert.id }}{{ alert.clientDisplayName }}{{ alert.transactionType }}{{ alert.ruleCode }} — {{ alert.ruleDescription }}₹{{ alert.amount | number: '1.0-0' }}{{ asDate(alert.raisedOn) | date: 'medium' }} - {{ alert.status }} - - {{ alertRouteLabel(alert.status, false) }} - - @if (alert.status === 'RAISED' || alert.status === 'UNDER_REVIEW') { - - } @else { - {{ alert.reviewNotes }} - } - - @if (alert.status === 'RAISED' || alert.status === 'UNDER_REVIEW') { -
- - -
- } -
No suspicious behavioral alerts awaiting triage.
-
-

- Pending awaiting Whitelist/Escalate decision · - → STR escalated to Principal Officer as an STR case -

- -
@@ -590,7 +520,7 @@ - @for (sc of amlStrCases; track sc.id) { + @for (sc of liveStrCases; track sc.id) { {{ sc.id }} {{ sc.clientDisplayName }} @@ -607,13 +537,50 @@ } @empty { - No STR cases yet. + No STR cases outstanding. }
+ + @if (filedStrCases.length) { +
+
+

Filed with FIU-IND

+

Reported and closed — kept for the record, with the reference the filing was made under.

+
+
+
+ + + + + + + + + + + + + @for (sc of filedStrCases; track sc.id) { + + + + + + + + + } + +
IDClientConclusionFiled OnFIU ReferenceAction
{{ sc.id }}{{ sc.clientDisplayName }}{{ sc.conclusion || '—' }}{{ asDate(sc.strFiledOn) | date: 'dd MMM yyyy' }}{{ sc.strReferenceNo || '—' }}
+
+ } + @if (selectedStrCase) {
diff --git a/src/app/fiu/fiu.component.ts b/src/app/fiu/fiu.component.ts index 7e45eb931..245162f51 100644 --- a/src/app/fiu/fiu.component.ts +++ b/src/app/fiu/fiu.component.ts @@ -220,13 +220,29 @@ export class FiuComponent implements OnInit { */ sanctionsStrCases: any[] = []; sanctionsStrFilter = ''; - amlAlertStatusFilter = ''; - amlReviewNotes: Record = {}; amlCtrReports: any[] = []; ctrGenerateMonth = ''; amlStrCases: any[] = []; + + /** + * The cases still carrying work, which is what "Live" means here. + * + *

+ * A case that has been filed with FIU-IND is finished: the report is gone, the reference number is on it, and there + * is nothing left for the Principal Officer to do. Leaving it in the live queue is how a working list stops being + * one — every filed case stays on it for good, and an officer opening the tab has to tell the outstanding work from + * the closed by reading a status column. Filed cases are listed below, where they can still be looked up. + */ + get liveStrCases(): any[] { + return this.amlStrCases.filter((c) => c?.status !== 'FILED'); + } + + /** Cases already filed with FIU-IND, kept for the record rather than as work. */ + get filedStrCases(): any[] { + return this.amlStrCases.filter((c) => c?.status === 'FILED'); + } selectedStrCase: any = null; concludeConclusion: 'NOT_SUSPICIOUS' | 'SUSPICIOUS' = 'NOT_SUSPICIOUS'; concludeInvestigationNotes = ''; @@ -633,10 +649,9 @@ export class FiuComponent implements OnInit { } /** - * Always fetches every status. The triage table and the cleared register are two views of the - * same set, so filtering server-side would empty one whenever the other was narrowed — and the - * CTR tab reads this list too, which a status filter chosen on the STR tab should not touch. - * amlAlertStatusFilter is applied client-side in {@link amlSuspiciousAlerts} instead. + * Always fetches every status. This tab shows what was cleared and what became an STR case, which are two views of + * the same set, and the CTR tab reads the list as well — so narrowing it server-side for one view would empty + * another. */ loadAmlAlerts(): void { this.amlService.getAlerts().subscribe((data) => (this.amlAlerts = data || [])); @@ -679,7 +694,8 @@ export class FiuComponent implements OnInit { this.ctrLinesLoading = false; this.ctrTxns = []; this.ctrLinesError = - err?.error?.defaultUserMessage || 'Could not load the generated CTR lines. Generate a report for the month first.'; + err?.error?.defaultUserMessage || + 'Could not load the generated CTR lines. Generate a report for the month first.'; } }); } @@ -779,48 +795,6 @@ export class FiuComponent implements OnInit { return (err: any) => this.toast(err?.error?.defaultUserMessage || `${action} failed — please retry`); } - /** - * Clearing or escalating an alert has to carry a reason: review_notes is the only audit record of - * why a flagged transaction was disposed of, and an empty string leaves no trace of the decision. - */ - private reviewNotesOf(alert: any): string | null { - const notes = (this.amlReviewNotes[alert.id] || '').trim(); - if (!notes) { - this.toast(`Enter review notes before disposing of alert #${alert.id}`); - return null; - } - return notes; - } - - whitelistAlert(alert: any): void { - const notes = this.reviewNotesOf(alert); - if (!notes) { - return; - } - this.amlService.reviewAlert(alert.id, 'WHITELIST', notes).subscribe({ - next: () => { - this.toast(`Alert #${alert.id} whitelisted — no STR raised`); - this.loadAmlAlerts(); - }, - error: this.apiError(`Whitelisting alert #${alert.id}`) - }); - } - - escalateAlert(alert: any): void { - const notes = this.reviewNotesOf(alert); - if (!notes) { - return; - } - this.amlService.reviewAlert(alert.id, 'ESCALATE', notes).subscribe({ - next: () => { - this.toast(`Alert #${alert.id} escalated — now an STR case for the Principal Officer`); - this.loadAmlAlerts(); - this.loadAmlStrCases(); - }, - error: this.apiError(`Escalating alert #${alert.id}`) - }); - } - generateCtrReport(): void { if (!this.ctrGenerateMonth) { this.toast('Pick a reporting month first'); @@ -1039,12 +1013,6 @@ export class FiuComponent implements OnInit { * The STR triage queue: still to decide, or already escalated. Whitelisted alerts are closed * with no report, so they are not STR business — they live in {@link amlWhitelistedAlerts}. */ - get amlSuspiciousAlerts(): any[] { - return this.behaviouralAlerts.filter( - (a) => a.status !== 'WHITELISTED' && (!this.amlAlertStatusFilter || a.status === this.amlAlertStatusFilter) - ); - } - /** Cleared register: alerts closed as false positives, kept for audit with their evidence. */ get amlWhitelistedAlerts(): any[] { return this.behaviouralAlerts.filter((a) => a.status === 'WHITELISTED'); @@ -1707,7 +1675,9 @@ export class FiuComponent implements OnInit { private writeLiveStrXml(cases: any[], filename: string): void { const xml: string[] = []; xml.push(''); - xml.push(``); + xml.push( + `` + ); xml.push(` `); if (this.principalOfficerName) { xml.push(` `); diff --git a/src/app/rbi-circulars/aml-rules/aml-flagged-transactions/aml-bulk-review-dialog.component.ts b/src/app/rbi-circulars/aml-rules/aml-flagged-transactions/aml-bulk-review-dialog.component.ts index 02a8097d4..e49fa73b5 100644 --- a/src/app/rbi-circulars/aml-rules/aml-flagged-transactions/aml-bulk-review-dialog.component.ts +++ b/src/app/rbi-circulars/aml-rules/aml-flagged-transactions/aml-bulk-review-dialog.component.ts @@ -57,8 +57,8 @@ export interface AmlBulkReviewData {

- The same decision, reason and evidence are recorded against each alert below. Every one stays - individually auditable. + The same decision, reason and evidence are recorded against each alert below. Every one stays individually + auditable.

@@ -218,7 +218,9 @@ export class AmlBulkReviewDialogComponent { submitting = false; errorMessage = ''; - constructor(@Inject(MAT_DIALOG_DATA) public data: AmlBulkReviewData) { + data: AmlBulkReviewData = inject(MAT_DIALOG_DATA); + + constructor() { this.form = this.fb.group({ evidence: this.fb.array(this.evidenceTypes.map(() => false)), evidenceRef: [ diff --git a/src/app/rbi-circulars/aml-rules/aml-flagged-transactions/aml-flagged-transactions.component.html b/src/app/rbi-circulars/aml-rules/aml-flagged-transactions/aml-flagged-transactions.component.html index d566f2142..ab2aaf069 100644 --- a/src/app/rbi-circulars/aml-rules/aml-flagged-transactions/aml-flagged-transactions.component.html +++ b/src/app/rbi-circulars/aml-rules/aml-flagged-transactions/aml-flagged-transactions.component.html @@ -36,6 +36,17 @@ + +
@@ -44,6 +55,52 @@ Could not reach the alert feed. Retrying automatically.
+ +
+ Period + @for (m of periodModes; track m.key) { + + } + + + @if (periodMode === 'day') { + + } + @if (periodMode === 'week') { + + } + @if (periodMode === 'month') { + + } + @if (periodMode === 'year') { + + } + @if (periodLabel) { + {{ periodLabel }} + } +
+
Status @@ -60,6 +117,34 @@
+ +
+ Top rules + @for (n of topRuleOptions; track n) { + + } + @if (topRules !== null) { + {{ distinctRuleCount }} rule{{ distinctRuleCount === 1 ? '' : 's' }} in view + } +
+ + +@if (ruleFrequency.length > 1) { +
+ @for (r of ruleFrequency; track r.code) { + + {{ r.code }} + {{ r.count }} + + } +
+} +
@@ -103,7 +188,9 @@ @if (selectedCount > 0) {
{{ selectedCount }} selected - Low / medium only — a transaction that tripped a high-severity rule is decided on its own. + Low / medium only — a transaction that tripped a high-severity rule is decided on its own. -
-
- - {{ alert.reviewedByUsername ? 'by ' + alert.reviewedByUsername : 'Reviewed' }} - - - - description - - - → STR - -
+ + @if (heldByAnother(alert)) { +
+ + lock + + with {{ heldBy(alert) }} +
+ } @else { +
+ + {{ alert.reviewedByUsername ? 'by ' + alert.reviewedByUsername : 'Reviewed' }} + + + + description + + + → STR + +
+ }
diff --git a/src/app/rbi-circulars/aml-rules/aml-flagged-transactions/aml-flagged-transactions.component.scss b/src/app/rbi-circulars/aml-rules/aml-flagged-transactions/aml-flagged-transactions.component.scss index 2070f6a36..a11f5bc7c 100644 --- a/src/app/rbi-circulars/aml-rules/aml-flagged-transactions/aml-flagged-transactions.component.scss +++ b/src/app/rbi-circulars/aml-rules/aml-flagged-transactions/aml-flagged-transactions.component.scss @@ -902,3 +902,112 @@ cursor: help; user-select: none; } + +/* Day picker. Sized and bordered to sit level with the status chips beside it, so the two rows read + as one control strip rather than a form field dropped above a set of buttons. */ +.day-input { + padding: 5px 11px; + border: 1px solid #cbd5e1; + border-radius: 999px; + background: #fff; + font-family: inherit; + font-size: 12.5px; + font-weight: 600; + color: #334155; + cursor: pointer; + + &:hover { + border-color: #1a237e; + } + + &:focus { + outline: none; + border-color: #1a237e; + } +} + +/* What the chosen mode resolved to. A week picked by naming one day inside it is otherwise invisible — the officer + has no way to tell which Monday-to-Sunday span they are looking at. */ +.period-resolved { + font-size: 12px; + color: #64748b; + font-variant-numeric: tabular-nums; +} + +/* Sits with the pause and refresh controls, green because it produces something the officer takes away rather + than changing what is on screen. */ +.export-btn { + display: inline-flex; + align-items: center; + gap: 6px; + padding: 6px 13px; + border: 1px solid #bbf7d0; + border-radius: 999px; + background: #f0fdf4; + color: #15803d; + font-family: inherit; + font-size: 12.5px; + font-weight: 700; + cursor: pointer; + + mat-icon { + font-size: 16px; + width: 16px; + height: 16px; + } + + &:hover:not(:disabled) { + background: #dcfce7; + border-color: #86efac; + } + + &:disabled { + opacity: 0.5; + cursor: not-allowed; + } +} + +/* Held by another officer. Grey, not red: nobody has done anything wrong, the case is simply somebody else's. */ +.held-icon { + font-size: 16px; + width: 16px; + height: 16px; + color: #94a3b8; + cursor: help; +} + +.held-by { + color: #64748b; + font-size: 12px; + font-style: italic; +} + +/* Rule frequency strip. Sits under the filters as a reading aid, not a control — clicking a rule would be a fourth + filter competing with the three above it. */ +.rule-freq { + display: flex; + flex-wrap: wrap; + gap: 6px; + padding: 0 0 14px; +} + +.rule-freq-item { + display: inline-flex; + align-items: center; + gap: 6px; + padding: 3px 9px; + border: 1px solid #e2e8f0; + border-radius: 999px; + background: #f8fafc; + font-size: 11px; + font-weight: 600; + color: #475569; +} + +.rule-freq-n { + padding: 0 6px; + border-radius: 999px; + background: #e2e8f0; + color: #1e293b; + font-variant-numeric: tabular-nums; +} diff --git a/src/app/rbi-circulars/aml-rules/aml-flagged-transactions/aml-flagged-transactions.component.ts b/src/app/rbi-circulars/aml-rules/aml-flagged-transactions/aml-flagged-transactions.component.ts index df87f805a..6f5b070ea 100644 --- a/src/app/rbi-circulars/aml-rules/aml-flagged-transactions/aml-flagged-transactions.component.ts +++ b/src/app/rbi-circulars/aml-rules/aml-flagged-transactions/aml-flagged-transactions.component.ts @@ -17,15 +17,21 @@ import { MatProgressSpinnerModule } from '@angular/material/progress-spinner'; import { MatDialog, MatDialogModule } from '@angular/material/dialog'; import { MatCheckboxModule } from '@angular/material/checkbox'; import { CommonModule } from '@angular/common'; +import { FormsModule } from '@angular/forms'; import { Subject, timer, of } from 'rxjs'; import { switchMap, takeUntil, catchError, filter } from 'rxjs/operators'; /** Custom Services */ import { AmlService } from '../../../aml/aml.service'; import { AlertService } from '../../../core/alert/alert.service'; +import { AuthenticationService } from '../../../core/authentication/authentication.service'; import { HasPermissionDirective } from '../../../directives/has-permission/has-permission.directive'; import { AmlReviewAlertDialogComponent } from './aml-review-alert-dialog.component'; import { AmlBulkReviewDialogComponent } from './aml-bulk-review-dialog.component'; +import { exportWorkbook } from '../../../cashier/cashier-export'; + +/** How the feed is narrowed by time. */ +type PeriodMode = 'all' | 'day' | 'week' | 'month' | 'year'; /** * Live feed of transactions the AML rule engine has flagged. @@ -43,6 +49,7 @@ import { AmlBulkReviewDialogComponent } from './aml-bulk-review-dialog.component standalone: true, imports: [ CommonModule, + FormsModule, MatTableModule, MatPaginatorModule, MatIconModule, @@ -58,6 +65,10 @@ export class AmlFlaggedTransactionsComponent implements OnInit, AfterViewInit, O private amlService = inject(AmlService); private dialog = inject(MatDialog); private alertService = inject(AlertService); + private auth = inject(AuthenticationService); + + /** Who is signed in, so the screen can tell an alert this officer holds from one somebody else does. */ + private readonly currentUsername = this.auth.getCredentials()?.username ?? ''; private destroy$ = new Subject(); /** Short enough to feel live to an officer watching the desk, long enough not to hammer the API. */ @@ -146,7 +157,15 @@ export class AmlFlaggedTransactionsComponent implements OnInit, AfterViewInit, O /** The alert whose detail row is open. One at a time: this is a reading pane, not a comparison. */ expandedId: number | null = null; - + /** + * Opens or closes the detail pane. Nothing is recorded by reading. + * + *

+ * Expanding a row used to mark the alert UNDER_REVIEW, on the reasoning that opening the pane is where the work + * starts. It is not: most expands are an officer glancing at what a transaction tripped, and a status that changes + * on a single click describes the click rather than any review. The queue filled with alerts nobody was working, + * and the one state the flag existed to make visible — this one is actually being worked — became meaningless. + */ toggleExpanded(alert: any): void { this.expandedId = this.expandedId === alert.id ? null : alert.id; // Ticks belong to the alert they were made on, not to whichever pane is open next. @@ -164,7 +183,9 @@ export class AmlFlaggedTransactionsComponent implements OnInit, AfterViewInit, O * Served by the API. The fallback rebuilds the list from the flat fields for an alert read before the API carried * it, so an older row still expands to something rather than appearing to have matched nothing. */ - matchedRules(alert: any): { ruleCode: string; ruleDescription: string; riskCategory: number | null; governing: boolean }[] { + matchedRules( + alert: any + ): { ruleCode: string; ruleDescription: string; riskCategory: number | null; governing: boolean }[] { if (Array.isArray(alert?.matchedRules) && alert.matchedRules.length) { return alert.matchedRules; } @@ -358,13 +379,226 @@ export class AmlFlaggedTransactionsComponent implements OnInit, AfterViewInit, O readonly statusFilters: { key: string; label: string }[] = [ { key: '', label: 'All alerts' }, { key: 'RAISED', label: 'Raised' }, - { key: 'UNDER_REVIEW', label: 'Under review' }, + // No "Under review" chip: nothing sets that status now that reading a row no longer records anything, so the + // chip would sit at zero for good. The status itself is kept — alerts predating this change still carry it, and + // canReview still treats them as open — but it is not offered as a filter nobody can populate. { key: 'ESCALATED', label: 'Escalated' }, { key: 'WHITELISTED', label: 'Whitelisted' } ]; activeStatus = ''; private statusCounts: Record = {}; + /** + * The period being worked. + * + * A monitoring feed accumulates: last week's alerts sit under today's, and an officer asked "what did we decide + * today" has to read past them. The period filter is what turns the feed into a day's, a week's or a month's work + * — and it composes with the status chips, so Today + Escalated answers "what did we mark suspicious today". + */ + readonly periodModes: { key: PeriodMode; label: string }[] = [ + { key: 'all', label: 'All time' }, + { key: 'day', label: 'Day wise' }, + { key: 'week', label: 'Week wise' }, + { key: 'month', label: 'Month wise' }, + { key: 'year', label: 'Year wise' } + ]; + periodMode: PeriodMode = 'all'; + + /** The chosen day / any day inside the chosen week / the chosen month / the chosen year. */ + filterDay = ''; + filterWeek = ''; + filterMonth = ''; + filterYear = ''; + + /** + * Narrows the table to the rules firing most often in the chosen period. + * + *

+ * A rule tripping repeatedly is a different question from one transaction tripping a rule: it is either a real + * pattern in the book, or a rule set too tight and filling the queue with noise. Either way the rules at the top of + * the count are the ones worth an officer's attention first, and the long tail below them can wait. + * + *

+ * Null shows every rule. The options are deliberately small — a "top 10" that lists 40 rules answers nothing. + */ + readonly topRuleOptions: (number | null)[] = [ + null, + 10, + 20, + 50 + ]; + topRules: number | null = null; + + setTopRules(n: number | null): void { + this.topRules = n; + this.applyStatusFilter(true); + } + + /** + * The rule codes to keep, most-triggered first, or null when every rule is in view. + * + *

+ * Counted over the period and status already chosen rather than over the whole feed, so "top 10 this month" means + * the ten busiest rules of that month. Ties are broken on the rule code so the same set comes back each time + * instead of shifting between polls. + */ + private topRuleCodes(pool: any[]): Set | null { + if (this.topRules === null) { + return null; + } + const counts = new Map(); + for (const a of pool) { + const code = a?.ruleCode; + if (code) { + counts.set(code, (counts.get(code) ?? 0) + 1); + } + } + if (counts.size <= this.topRules) { + return null; + } + const ranked = Array.from(counts.entries()) + .sort((a, b) => b[1] - a[1] || a[0].localeCompare(b[0])) + .slice(0, this.topRules) + .map(([code]) => code); + return new Set(ranked); + } + + /** How many distinct rules the current view holds, so the chip can say what it is narrowing. */ + get distinctRuleCount(): number { + return new Set(this.alerts.data.map((a) => a.ruleCode).filter((c) => c)).size; + } + + /** + * The rules in view with how often each fired, busiest first. + * + *

+ * The point of ranking rules is to see which ones are driving the queue, and a count per rule is that answer. + * Without it "Top 10" narrows the table and leaves the officer to work out what it kept. + */ + get ruleFrequency(): { code: string; count: number }[] { + const counts = new Map(); + for (const a of this.alerts.data) { + if (a?.ruleCode) { + counts.set(a.ruleCode, (counts.get(a.ruleCode) ?? 0) + 1); + } + } + return Array.from(counts.entries()) + .map( + ([ + code, + count + ]) => ({ code, count }) + ) + .sort((a, b) => b.count - a.count || a.code.localeCompare(b.code)); + } + + setPeriodMode(mode: PeriodMode): void { + this.periodMode = mode; + // Seeded with today the first time a mode is picked, so choosing "Month wise" shows this month rather than an + // empty field the officer has to fill before anything happens. + const now = new Date(); + const iso = this.isoOf(now); + if (mode === 'day' && !this.filterDay) { + this.filterDay = iso; + } + if (mode === 'week' && !this.filterWeek) { + this.filterWeek = iso; + } + if (mode === 'month' && !this.filterMonth) { + this.filterMonth = iso.slice(0, 7); + } + if (mode === 'year' && !this.filterYear) { + this.filterYear = String(now.getFullYear()); + } + this.applyStatusFilter(true); + } + + onPeriodChange(): void { + this.applyStatusFilter(true); + } + + /** The years the feed actually covers, newest first, so the picker offers nothing empty. */ + get availableYears(): string[] { + const years = new Set(); + for (const a of this.allAlerts) { + if (a.raisedOnDate) { + years.add(String(a.raisedOnDate.getFullYear())); + } + } + const list = Array.from(years).sort().reverse(); + return list.length ? list : [String(new Date().getFullYear())]; + } + + /** Reads back the resolved period, so the officer can see what "week wise" actually resolved to. */ + get periodLabel(): string { + const range = this.periodRange(); + if (!range) { + return ''; + } + return range.from === range.to ? range.from : `${range.from} to ${range.to}`; + } + + private isoOf(d: Date): string { + return `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, '0')}-${String(d.getDate()).padStart(2, '0')}`; + } + + /** + * The chosen period as an inclusive from/to, or null for all time. + * + * Week runs Monday to Sunday. Built from local date parts rather than by parsing to UTC: an alert raised late in + * the evening is on that evening's date for the officer looking at it, and a UTC round-trip would move it. + */ + private periodRange(): { from: string; to: string } | null { + switch (this.periodMode) { + case 'day': + return this.filterDay ? { from: this.filterDay, to: this.filterDay } : null; + case 'week': { + if (!this.filterWeek) { + return null; + } + const [ + y, + m, + d + ] = this.filterWeek.split('-').map(Number); + const picked = new Date(y, m - 1, d); + const backToMonday = picked.getDay() === 0 ? 6 : picked.getDay() - 1; + const monday = new Date(y, m - 1, d - backToMonday); + const sunday = new Date(y, m - 1, d - backToMonday + 6); + return { from: this.isoOf(monday), to: this.isoOf(sunday) }; + } + case 'month': { + if (!this.filterMonth) { + return null; + } + const [ + y, + m + ] = this.filterMonth.split('-').map(Number); + // Day 0 of the next month is the last day of this one, which handles February and leap years without a table. + return { from: `${this.filterMonth}-01`, to: this.isoOf(new Date(y, m, 0)) }; + } + case 'year': + return this.filterYear ? { from: `${this.filterYear}-01-01`, to: `${this.filterYear}-12-31` } : null; + default: + return null; + } + } + + /** Whether an alert was raised inside the chosen period. */ + private inActivePeriod(alert: any): boolean { + const range = this.periodRange(); + if (!range) { + return true; + } + const d: Date | null = alert?.raisedOnDate; + if (!d) { + return false; + } + const iso = this.isoOf(d); + return iso >= range.from && iso <= range.to; + } + /** * The severity tile currently narrowing the table: 1 Low, 2 Medium, 3 High, 0 unclassified, or * null for all. Independent of {@link activeStatus} — the two compose, so an officer can ask for @@ -530,11 +764,16 @@ export class AmlFlaggedTransactionsComponent implements OnInit, AfterViewInit, O * it — doing that on the first pass left pageSize at 0 and emptied the table. */ private applyStatusFilter(resetPage = false): void { - const byStatus = this.activeStatus - ? this.allAlerts.filter((a) => a.status === this.activeStatus) - : this.allAlerts; - this.alerts.data = + // The day narrows everything else: the status chips and the severity tiles are asking "of what is in view", and + // an officer who has picked a day expects their counts to describe that day rather than the whole feed. + const byDay = this.allAlerts.filter((a) => this.inActivePeriod(a)); + const byStatus = this.activeStatus ? byDay.filter((a) => a.status === this.activeStatus) : byDay; + const bySeverity = this.activeSeverity === null ? byStatus : byStatus.filter((a) => this.severityBand(a) === this.activeSeverity); + // Ranked last, over what the other filters left: "top 10 this month" is the ten busiest rules of that month, not + // the ten busiest overall then filtered down to a handful. + const keep = this.topRuleCodes(bySeverity); + this.alerts.data = keep ? bySeverity.filter((a) => keep.has(a.ruleCode)) : bySeverity; if (!this.alerts.paginator && this.paginator) { this.alerts.paginator = this.paginator; } @@ -573,15 +812,119 @@ export class AmlFlaggedTransactionsComponent implements OnInit, AfterViewInit, O * add up to the total while one of them is narrowing the table, and the total reads as the way * back to all of them. */ + exporting = false; + + /** + * Exports exactly what the table is showing. + * + *

+ * Built from `alerts.data` rather than from the full feed, so the period, status and severity an officer chose are + * the period, status and severity in the file. A workbook that quietly held more than the screen did would be + * worse than none: the figures in it would not reconcile with the report it came from, and an officer sending it + * on has no way to tell. + * + *

+ * The filters are written into the subtitle and the file name for the same reason — a sheet that says "10 alerts" + * with nothing about which month it covers cannot be checked later by anyone, including the person who made it. + */ + async exportExcel(): Promise { + const rows = this.alerts.data; + if (!rows.length || this.exporting) { + return; + } + this.exporting = true; + try { + const scope = [ + this.periodLabel ? `Period: ${this.periodLabel}` : 'Period: all time', + `Status: ${this.activeStatus ? this.statusLabel(this.activeStatus) : 'all'}`, + `Severity: ${this.activeSeverity === null ? 'all' : this.severityLabel(this.activeSeverity)}`, + `Rules: ${this.topRules === null ? 'all' : `top ${this.topRules} by frequency`}` + ].join(' · '); + + await exportWorkbook( + [ + { + sheetName: 'Flagged Transactions', + title: 'AML — Flagged Transactions', + subtitle: `${scope} · ${rows.length} alert${rows.length === 1 ? '' : 's'} · generated ${new Date().toLocaleString('en-IN')}`, + headers: [ + 'Alert ID', + 'Raised On', + 'Customer', + 'Customer ID', + 'Transaction', + 'Txn ID', + 'Governing Rule', + 'Rule Description', + 'All Rules Matched', + 'Severity', + 'Amount', + 'Status', + 'Reviewed By', + 'Review Notes' + ], + rows: rows.map((a) => [ + a.id ?? null, + a.raisedOnDate ? a.raisedOnDate.toLocaleString('en-IN') : '', + a.clientDisplayName || '', + a.clientId ?? null, + a.transactionType || '', + a.transactionId ?? null, + a.ruleCode || '', + a.ruleDescription || '', + // Every rule the transaction tripped, not only the one it reports under: the governing rule alone can + // hide a high-severity finding that a reader of the sheet would need. + this.matchedRules(a) + .map((r) => r.ruleCode) + .join(', '), + this.severityLabel(this.severityBand(a)), + Number(a.amount) || 0, + a.status || '', + a.reviewedByUsername || '', + a.reviewNotes || '' + ]), + widths: [ + 9, + 20, + 24, + 12, + 12, + 10, + 30, + 38, + 44, + 11, + 15, + 14, + 14, + 50 + ] + } + ], + `AML-Flagged-${this.periodLabel || 'all-time'}` + ); + } finally { + this.exporting = false; + } + } + + private statusLabel(key: string): string { + return this.statusFilters.find((s) => s.key === key)?.label ?? key; + } + get summaryTotal(): number { - return this.activeStatus ? this.allAlerts.filter((a) => a.status === this.activeStatus).length : this.allAlerts.length; + const pool = this.allAlerts.filter((a) => this.inActivePeriod(a)); + return this.activeStatus ? pool.filter((a) => a.status === this.activeStatus).length : pool.length; } private recomputeStatusCounts(): void { - this.statusCounts = { '': this.allAlerts.length }; + // Counted within the chosen day, so "Escalated 1" means one escalated that day rather than one in the whole feed. + // A chip whose figure describes a different set from the table under it is worse than no figure. + const pool = this.allAlerts.filter((a) => this.inActivePeriod(a)); + this.statusCounts = { '': pool.length }; for (const s of this.statusFilters) { if (s.key) { - this.statusCounts[s.key] = this.allAlerts.filter((a) => a.status === s.key).length; + this.statusCounts[s.key] = pool.filter((a) => a.status === s.key).length; } } } @@ -633,7 +976,26 @@ export class AmlFlaggedTransactionsComponent implements OnInit, AfterViewInit, O * opening a second STR case — this gate is what prevents it. */ canReview(alert: any): boolean { - return alert?.status === 'RAISED' || alert?.status === 'UNDER_REVIEW'; + if (alert?.status !== 'RAISED' && alert?.status !== 'UNDER_REVIEW') { + return false; + } + // An alert another officer has open is theirs until they decide it or hand it back. The endpoint refuses it + // anyway; hiding the buttons is what stops an officer working a case they will not be allowed to close. + return !this.heldByAnother(alert); + } + + /** True when this alert is under review by somebody other than the signed-in officer. */ + heldByAnother(alert: any): boolean { + return ( + alert?.status === 'UNDER_REVIEW' && + !!alert?.reviewedByUsername && + alert.reviewedByUsername !== this.currentUsername + ); + } + + /** Who has the alert, for the message shown in place of the action buttons. */ + heldBy(alert: any): string { + return alert?.reviewedByUsername || 'another officer'; } review(alert: any, disposition: 'WHITELIST' | 'ESCALATE'): void { diff --git a/src/app/rbi-circulars/aml-rules/aml-flagged-transactions/aml-review-alert-dialog.component.ts b/src/app/rbi-circulars/aml-rules/aml-flagged-transactions/aml-review-alert-dialog.component.ts index 2d6825d64..beddd4527 100644 --- a/src/app/rbi-circulars/aml-rules/aml-flagged-transactions/aml-review-alert-dialog.component.ts +++ b/src/app/rbi-circulars/aml-rules/aml-flagged-transactions/aml-review-alert-dialog.component.ts @@ -196,7 +196,12 @@ export class AmlReviewAlertDialogComponent { // Deciding named rules within the alert rather than the whole alert, when the caller passed some. The evidence // requirements are the same either way, which is why this dialog serves both. const request = this.data.ruleCodes?.length - ? this.amlService.reviewAlertRules(this.data.alert.id, this.data.ruleCodes, this.data.disposition, this.composedNotes) + ? this.amlService.reviewAlertRules( + this.data.alert.id, + this.data.ruleCodes, + this.data.disposition, + this.composedNotes + ) : this.amlService.reviewAlert(this.data.alert.id, this.data.disposition, this.composedNotes); request.subscribe({ next: () => { diff --git a/src/app/risk-review/risk-change-approvals.component.ts b/src/app/risk-review/risk-change-approvals.component.ts index 51a3e1798..677928e01 100644 --- a/src/app/risk-review/risk-change-approvals.component.ts +++ b/src/app/risk-review/risk-change-approvals.component.ts @@ -92,7 +92,9 @@ const CATEGORY_LABEL: Record = { 1: 'Low', 2: 'Medium', 3: 'High {{ r.reason }} - {{ r.status }} + {{ + r.status + }} {{ r.makerUsername }} {{ r.createdDate }} @@ -250,7 +252,12 @@ export class RiskChangeApprovalsComponent implements OnInit { private snackBar = inject(MatSnackBar); rows: any[] = []; - readonly statuses = ['PENDING', 'APPROVED', 'REJECTED', '']; + readonly statuses = [ + 'PENDING', + 'APPROVED', + 'REJECTED', + '' + ]; selectedStatus = 'PENDING'; ngOnInit(): void { @@ -363,11 +370,15 @@ export class RejectRiskChangeDialogComponent { private snackBar = inject(MatSnackBar); dialogRef = inject(MatDialogRef); + data: { request: any } = inject(MAT_DIALOG_DATA); form: FormGroup; busy = false; - constructor(@Inject(MAT_DIALOG_DATA) public data: { request: any }) { - this.form = this.fb.group({ rejectionReason: ['', Validators.required] }); + constructor() { + this.form = this.fb.group({ rejectionReason: [ + '', + Validators.required + ] }); } submit(): void { diff --git a/src/app/risk-review/risk-review-report.component.ts b/src/app/risk-review/risk-review-report.component.ts index 3b4fd2fb4..ee9d5f2b9 100644 --- a/src/app/risk-review/risk-review-report.component.ts +++ b/src/app/risk-review/risk-review-report.component.ts @@ -264,7 +264,12 @@ export class RiskReviewReportComponent implements OnInit { ) ) ).subscribe((results) => { - const byClient = new Map(results.map((r) => [r.id, r])); + const byClient = new Map( + results.map((r) => [ + r.id, + r + ]) + ); this.liveAlerts = rows.map((r) => { const info = byClient.get(r.clientId); return { @@ -470,24 +475,24 @@ export class RiskReviewReportComponent implements OnInit { ] }, { - // Its own section, and above "no change needed", because it is the opposite of that. An unrated customer - // whose money is moving has no rating for the behaviour to be compared against, so nothing here can say - // whether it is in keeping - the work is to rate them, and grouping them under "no change needed" buried the - // one action the report was asking for. + // Its own section, and above the watching list, because it is the opposite of that. An unrated customer whose + // money is moving has no rating for the behaviour to be compared against, so nothing here can say whether it + // is in keeping - the work is to give them one, and grouping them with the customers nothing need be done + // about buried the one action the report was asking for. key: 'unrated', - title: 'Rating needed', + title: 'Update risk', hint: 'These customers are transacting with no rating on file. Nothing can be judged against a rating that does not exist — rate them first.', tone: 'up', rows: rest.filter((c) => !c.currentRisk) }, { - // Everything an alert put on the report that the policy will not move. "No change needed" read as though - // these customers were fine, which is not what it means: a High-rated customer with four open alerts is on - // this list, and the reason is that the conditions for lowering the rating are not met - not that there is - // nothing to look at. The reason on each row says which condition fell short. + // Everything an alert put on the report that the policy will not move yet. "No change needed" read as though + // these customers were fine, which is not what it means: a customer with four open alerts sits here, and the + // reason is that the period does not yet carry enough to act on - not that there is nothing to look at. + // "Watching" says what the section is for, and the reason on each row says which condition fell short. key: 'same', - title: 'Rating holds — no change this period', - hint: 'An alert brought these customers onto the report, but the conditions for moving the rating are not met. The reason on each row says what is missing.', + title: 'Watching — not enough to act on', + hint: 'In view, but the period does not yet support moving the rating. The reason on each row says what is missing.', tone: 'hold', rows: rest.filter((c) => c.currentRisk && c.direction === 'same') } diff --git a/src/app/risk-review/risk-review.compute.ts b/src/app/risk-review/risk-review.compute.ts index 558b80ab5..215fcac21 100644 --- a/src/app/risk-review/risk-review.compute.ts +++ b/src/app/risk-review/risk-review.compute.ts @@ -26,6 +26,17 @@ export type RatingDirection = 'up' | 'down' | 'same'; const RANK_TO_LEVEL: Record = { 1: 'LOW', 2: 'MEDIUM', 3: 'HIGH' }; +/** + * What has to have been seen before this screen's own reckoning will argue for lowering a rating. + * + * The nightly behaviour review is the real test — it reads a 270-day window, counts transactions rather than alerts, + * and applies the blocks. These are the floor for the fallback used until that job has run: deliberately modest, + * because the fallback sees only the alerts inside the report's date filter, but not absent, because judging on + * amounts alone let three small deposits in one month argue for taking a customer off Medium. + */ +const MIN_ALERTS_TO_LOWER = 5; +const MIN_MONTHS_TO_LOWER = 3; + export interface DateRange { from: string; to: string; @@ -157,17 +168,26 @@ function recommendRating( largestAmount: number, totalAmount: number, escalated: boolean, - ruleCodes: string[] + ruleCodes: string[], + alertCount: number, + distinctMonths: number ): { risk: RiskLevel; reason: string } { const implied = RANK_TO_LEVEL[impliedRank] ?? 'LOW'; const grave = ruleCodes.find((c) => isNonMitigableRule(c)); const material = largestAmount >= CTR_THRESHOLD || totalAmount >= MATERIALITY.highTotal; - const small = largestAmount < MATERIALITY.smallTxn && totalAmount < MATERIALITY.smallTotal; + + // Small amounts are not on their own a reason to lower a rating. A rating describes a period of behaviour, so + // bringing one down needs a period to have been observed: enough transactions to be a pattern, and spread over + // enough months that they are not one busy week. Judging on amounts alone let three small deposits in a single + // month argue for taking a customer off Medium — which is the same mistake as re-rating on one transaction, only + // three times over. + const enoughActivity = alertCount >= MIN_ALERTS_TO_LOWER && distinctMonths >= MIN_MONTHS_TO_LOWER; + const small = enoughActivity && largestAmount < MATERIALITY.smallTxn && totalAmount < MATERIALITY.smallTotal; // What the rating should be. Amounts only ever pull the rules' bound down, and // only where nothing weightier holds it up. let risk: RiskLevel; - let basis: 'escalated' | 'grave' | 'material' | 'small' | 'moderate'; + let basis: 'escalated' | 'grave' | 'material' | 'small' | 'moderate' | 'thin'; if (escalated) { risk = 'HIGH'; basis = 'escalated'; @@ -177,6 +197,12 @@ function recommendRating( } else if (material) { risk = implied; basis = 'material'; + } else if (!enoughActivity) { + // Too little observed to say anything about the rating either way, so it stands. Stating this rather than + // quietly leaving the rating alone is the point: an officer reading the row learns the period was too thin, + // not that the customer was examined and found unremarkable. + risk = currentRisk ?? implied; + basis = 'thin'; } else { risk = RANK_TO_LEVEL[Math.min(impliedRank, small ? 1 : 2)] ?? 'LOW'; basis = small ? 'small' : 'moderate'; @@ -220,6 +246,27 @@ function recommendRating( }; } + if (basis === 'thin') { + // A customer with no rating is not waiting for more evidence — there is no rating for evidence to move. Telling + // them the period is too thin, under a verdict that says "Rate this customer", asks for the work and argues + // against it in the same row. The thin-period test is about lowering a rating that exists. + if (!currentRisk) { + return { + risk, + reason: `Transacting with no rating on file — ${inr(totalAmount)} across ${alertCount} alert${ + alertCount === 1 ? '' : 's' + }. Rate the customer; behaviour can only be judged against a rating.` + }; + } + // Says which of the two tests fell short, because "not enough activity" leaves an officer unable to tell whether + // the customer needs watching for another month or another five. + const shortOn = + alertCount < MIN_ALERTS_TO_LOWER + ? `only ${alertCount} alert${alertCount === 1 ? '' : 's'} in the period, ${MIN_ALERTS_TO_LOWER} needed` + : `activity falls in ${distinctMonths} month${distinctMonths === 1 ? '' : 's'}, ${MIN_MONTHS_TO_LOWER} separate months needed`; + return { risk, reason: `Too little to re-rate on — ${shortOn}. ${rated}; it stands until more is seen.` }; + } + // The rating on file is where the activity puts it. Saying so is the verdict — leaving it blank // read as "not assessed yet" on screen, which is the opposite of what the review concluded. return { @@ -345,7 +392,9 @@ export function riskCustomerRows(views: RiskAlertView[], windows: BehaviourWindo // and approved. Dropping it here falls back to the per-alert reckoning until tonight's job catches up. const raw = windows[clientId]; const staleWindow = - raw?.riskCategoryAtReview != null && currentRisk != null && RANK_TO_LEVEL[raw.riskCategoryAtReview] !== currentRisk; + raw?.riskCategoryAtReview != null && + currentRisk != null && + RANK_TO_LEVEL[raw.riskCategoryAtReview] !== currentRisk; const window = staleWindow ? undefined : raw; const recommendation = window?.suggestedDirection ? { @@ -358,7 +407,11 @@ export function riskCustomerRows(views: RiskAlertView[], windows: BehaviourWindo largestAmount, totalAmount, escalated, - alerts.map((a) => a.ruleCode) + alerts.map((a) => a.ruleCode), + alerts.length, + // Distinct calendar months the alerts fall in. Count alone does not establish a period: five alerts in one + // week is a busy fortnight, not five months of steady conduct. + new Set(alerts.map((a) => a.alertDate.slice(0, 7))).size ); // Where the customer carries no rating there is nothing to move, so an diff --git a/src/environments/.env.ts b/src/environments/.env.ts index 4509e26ef..6f0ee1ce1 100644 --- a/src/environments/.env.ts +++ b/src/environments/.env.ts @@ -9,8 +9,8 @@ /* tslint:disable */ export default { 'mifos_x': { - 'version': '260916', - 'hash': 'c01615ee' + 'version': '260918', + 'hash': '0cef1b5a' }, 'allow_switching_backend_instance': true }; From c7428d0cb26f9957678736545762decfa4210f28 Mon Sep 17 00:00:00 2001 From: Nitesh Yadav Date: Fri, 18 Sep 2026 13:06:02 +0530 Subject: [PATCH 61/64] feat(shares): dynamic Share Reports module, voucher attach/GL sync fixes - Add the full Share Reports surface (final balance, account statement, issued/status/nominal/share-wise/guarantor/member-interest reports, enquiry, payment certificate, share detail, dividend statement, folio statement, distinctive numbers, area-wise, mailing labels/letters, share transfer) as standalone routed components, wired into the Share menu - Wire the unattributed Share Collection receipt list into Apply Collection and Apply Additional Shares, with an Attach action independent of account/client - Sync the GL Account and Voucher No fields to whichever receipt is ticked or found, and surface the receipt's real Dr/Cr GL entries in its detail popup - Block Apply Additional Shares until the account's original application is settled Co-Authored-By: Claude Sonnet 5 --- src/app/cashier/cashier.service.ts | 11 + src/app/core/shell/shell.component.html | 42 +- .../share-account-statement.component.html | 209 ++++ .../share-account-statement.component.ts | 238 ++++ .../share-area-wise.component.html | 174 +++ .../share-area-wise.component.ts | 188 +++ .../share-detail-report.component.html | 192 +++ .../share-detail-report.component.ts | 293 +++++ .../share-distinctive-numbers.component.html | 152 +++ .../share-distinctive-numbers.component.ts | 206 +++ .../share-dividend-statement.component.html | 171 +++ .../share-dividend-statement.component.ts | 151 +++ .../share-enquiry.component.html | 291 +++++ .../share-enquiry/share-enquiry.component.ts | 246 ++++ .../share-final-balance.component.html | 208 ++++ .../share-final-balance.component.ts | 279 +++++ .../share-folio-statement.component.html | 250 ++++ .../share-folio-statement.component.ts | 217 ++++ .../share-guarantor-report.component.html | 162 +++ .../share-guarantor-report.component.ts | 208 ++++ .../share-issued-report.component.html | 204 +++ .../share-issued-report.component.ts | 264 ++++ .../share-label/share-label.component.html | 124 ++ .../share-label/share-label.component.ts | 197 +++ .../share-letter/share-letter.component.html | 171 +++ .../share-letter/share-letter.component.ts | 227 ++++ ...hare-member-interest-report.component.html | 166 +++ .../share-member-interest-report.component.ts | 197 +++ .../share-nominal-report.component.html | 244 ++++ .../share-nominal-report.component.ts | 337 +++++ .../share-payment-certificate.component.html | 160 +++ .../share-payment-certificate.component.ts | 236 ++++ .../shares/reports/share-report.shared.scss | 494 ++++++++ src/app/shares/reports/share-report.util.ts | 185 +++ .../share-status-report.component.html | 230 ++++ .../share-status-report.component.ts | 268 ++++ .../share-transfer.component.html | 282 +++++ .../share-transfer.component.ts | 339 +++++ .../share-wise-report.component.html | 165 +++ .../share-wise-report.component.ts | 167 +++ .../apply-shares/apply-shares.component.html | 64 +- .../apply-shares/apply-shares.component.scss | 63 + .../apply-shares/apply-shares.component.ts | 125 +- .../apply-collection-dialog.component.html | 748 ++++++++--- .../apply-collection-dialog.component.scss | 1099 ++++++++++++++--- .../apply-collection-dialog.component.ts | 178 ++- .../shares-account-view.component.ts | 8 +- src/app/shares/shares-routing.module.ts | 133 ++ src/app/shares/shares.service.ts | 172 +++ src/environments/.env.ts | 4 +- src/main.scss | 11 + 51 files changed, 10721 insertions(+), 429 deletions(-) create mode 100644 src/app/shares/reports/share-account-statement/share-account-statement.component.html create mode 100644 src/app/shares/reports/share-account-statement/share-account-statement.component.ts create mode 100644 src/app/shares/reports/share-area-wise/share-area-wise.component.html create mode 100644 src/app/shares/reports/share-area-wise/share-area-wise.component.ts create mode 100644 src/app/shares/reports/share-detail-report/share-detail-report.component.html create mode 100644 src/app/shares/reports/share-detail-report/share-detail-report.component.ts create mode 100644 src/app/shares/reports/share-distinctive-numbers/share-distinctive-numbers.component.html create mode 100644 src/app/shares/reports/share-distinctive-numbers/share-distinctive-numbers.component.ts create mode 100644 src/app/shares/reports/share-dividend-statement/share-dividend-statement.component.html create mode 100644 src/app/shares/reports/share-dividend-statement/share-dividend-statement.component.ts create mode 100644 src/app/shares/reports/share-enquiry/share-enquiry.component.html create mode 100644 src/app/shares/reports/share-enquiry/share-enquiry.component.ts create mode 100644 src/app/shares/reports/share-final-balance/share-final-balance.component.html create mode 100644 src/app/shares/reports/share-final-balance/share-final-balance.component.ts create mode 100644 src/app/shares/reports/share-folio-statement/share-folio-statement.component.html create mode 100644 src/app/shares/reports/share-folio-statement/share-folio-statement.component.ts create mode 100644 src/app/shares/reports/share-guarantor-report/share-guarantor-report.component.html create mode 100644 src/app/shares/reports/share-guarantor-report/share-guarantor-report.component.ts create mode 100644 src/app/shares/reports/share-issued-report/share-issued-report.component.html create mode 100644 src/app/shares/reports/share-issued-report/share-issued-report.component.ts create mode 100644 src/app/shares/reports/share-label/share-label.component.html create mode 100644 src/app/shares/reports/share-label/share-label.component.ts create mode 100644 src/app/shares/reports/share-letter/share-letter.component.html create mode 100644 src/app/shares/reports/share-letter/share-letter.component.ts create mode 100644 src/app/shares/reports/share-member-interest-report/share-member-interest-report.component.html create mode 100644 src/app/shares/reports/share-member-interest-report/share-member-interest-report.component.ts create mode 100644 src/app/shares/reports/share-nominal-report/share-nominal-report.component.html create mode 100644 src/app/shares/reports/share-nominal-report/share-nominal-report.component.ts create mode 100644 src/app/shares/reports/share-payment-certificate/share-payment-certificate.component.html create mode 100644 src/app/shares/reports/share-payment-certificate/share-payment-certificate.component.ts create mode 100644 src/app/shares/reports/share-report.shared.scss create mode 100644 src/app/shares/reports/share-report.util.ts create mode 100644 src/app/shares/reports/share-status-report/share-status-report.component.html create mode 100644 src/app/shares/reports/share-status-report/share-status-report.component.ts create mode 100644 src/app/shares/reports/share-transfer/share-transfer.component.html create mode 100644 src/app/shares/reports/share-transfer/share-transfer.component.ts create mode 100644 src/app/shares/reports/share-wise-report/share-wise-report.component.html create mode 100644 src/app/shares/reports/share-wise-report/share-wise-report.component.ts diff --git a/src/app/cashier/cashier.service.ts b/src/app/cashier/cashier.service.ts index 83d3ba55b..bab2c1ff7 100644 --- a/src/app/cashier/cashier.service.ts +++ b/src/app/cashier/cashier.service.ts @@ -390,6 +390,17 @@ export class CashierService { return this.http.get('/shareadvancecollection/requirement', { params: { shareAccountId: String(shareAccountId) } }); } + /** + * Approved share-collection receipts that name no member yet. + * + * A receipt taken straight into a GL account carries no member of its own, so it is never + * attributed automatically. This lists them so the counter can see the voucher they just raised + * and attach it deliberately, instead of being told there is nothing to apply. + */ + getUnattributedShareCollections(): Observable { + return this.http.get('/shareadvancecollection/unattributed'); + } + /** Receipts still sitting in Share Sundry, optionally for a single member. */ getUnappliedShareAdvances(clientId?: number): Observable { const suffix = clientId ? `?clientId=${clientId}` : ''; diff --git a/src/app/core/shell/shell.component.html b/src/app/core/shell/shell.component.html index 7d8c63ad2..aa9516dbe 100644 --- a/src/app/core/shell/shell.component.html +++ b/src/app/core/shell/shell.component.html @@ -1300,72 +1300,76 @@

Share Operations
- - - - -
Share Documents
- - - - -
Share Reports
- - - -
Share Admin
- - - - + + -
diff --git a/src/app/shares/reports/share-account-statement/share-account-statement.component.html b/src/app/shares/reports/share-account-statement/share-account-statement.component.html new file mode 100644 index 000000000..8e2f1d259 --- /dev/null +++ b/src/app/shares/reports/share-account-statement/share-account-statement.component.html @@ -0,0 +1,209 @@ + + +
+
+
+ +
+

Share Account Statement

+
+ One member's share account, movement by movement, with the holding each movement left behind. +
+
+
+
+ + +
+
+ + +
+
+ +
+ + +
+ @if (accountOptions.length && !selectedAccountId) { +
+ @for (opt of accountOptions; track opt.id) { + + } +
+ } +
+ +
+ + +
+
+ + +
+
+ + +
+
+ + @if (!selectedAccountId) { +
Search for a member or share account number above to open its statement.
+ } @else if (loading) { +
Loading statement…
+ } @else if (loadError) { +
{{ loadError }}
+ } @else if (account) { + +
+
+
+
Member
+
{{ account.clientName }}
+
+
+
Membership No
+
{{ account.cifNumber || '—' }}
+
+
+
Share A/c No
+
{{ account.accountNo }}
+
+
+
Product
+
{{ account.productName }}
+
+
+
Branch
+
{{ account.officeName || '—' }}
+
+
+
Status
+
+ {{ + statusLabel(account.statusEnum) + }} +
+
+
+
Linked Savings
+
{{ account.linkedSavingsAccountNo || 'Not linked' }}
+
+
+
Activated
+
{{ account.activatedDate || '—' }}
+
+
+ +
+ + @if (totals) { +
+
+ +
+
{{ totals.closingShares | number }}
+
Closing Shares
+
+
+
+ +
+
₹{{ totals.closingValue | number: '1.2-2' }}
+
Closing Value
+
+
+
+ +
+
{{ totals.sharesIn | number }}
+
Shares Purchased
+
+
+
+ +
+
{{ totals.sharesOut | number }}
+
Shares Redeemed
+
+
+
+ } + + @if (!lines.length) { +
+ No settled movement on this account{{ fromDate || toDate ? ' in the selected period' : '' }}. +
+ } @else { +
+ + + + + + + + + + + + + + + + + @for (line of lines; track line.transactionId) { + + + + + + + + + + + + + } + + + + + + + + + + + + +
DateParticularsBoard ResolutionFunded ByShares InShares OutUnit PriceAmountBalance SharesBalance Value
{{ line.transactionDate }} + {{ typeLabel(line.typeEnum) }} + {{ line.resolutionNumber || '—' }}{{ funding(line.fundingSource) }}{{ line.sharesIn ? (line.sharesIn | number) : '' }}{{ line.sharesOut ? (line.sharesOut | number) : '' }}{{ line.unitPrice | number: '1.2-2' }}{{ line.amount | number: '1.2-2' }}{{ line.balanceShares | number }}₹{{ line.balanceValue | number: '1.2-2' }}
Closing balance{{ totals?.sharesIn | number }}{{ totals?.sharesOut | number }}{{ totals?.closingShares | number }}₹{{ totals?.closingValue | number: '1.2-2' }}
+
+ } + } +
diff --git a/src/app/shares/reports/share-account-statement/share-account-statement.component.ts b/src/app/shares/reports/share-account-statement/share-account-statement.component.ts new file mode 100644 index 000000000..2272c7595 --- /dev/null +++ b/src/app/shares/reports/share-account-statement/share-account-statement.component.ts @@ -0,0 +1,238 @@ +/** + * 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 { ActivatedRoute, Router } from '@angular/router'; +import { FaIconComponent } from '@fortawesome/angular-fontawesome'; +import { Subject, of } from 'rxjs'; +import { catchError, debounceTime, distinctUntilChanged, switchMap } from 'rxjs/operators'; + +import { SharesService } from '../../shares.service'; +import { + SHARE_ACCOUNT_STATUS, + SHARE_TXN_TYPE, + fundingLabel, + shareReportCsv, + shareReportPrint +} from '../share-report.util'; + +interface StatementLine { + transactionId: number; + transactionDate: string; + typeEnum: number; + statusEnum: number; + sharesIn: number; + sharesOut: number; + unitPrice: number; + amount: number; + chargeAmount: number; + amountPaid: number; + resolutionNumber: string; + meetingDate: string; + fundingSource: string; + balanceShares: number; + balanceValue: number; +} + +/** + * Share Account Statement — one member's share account, movement by movement, with the holding each + * movement left behind. + * + *

+ * The account is chosen first because a statement is always about one account; the search box is + * server-side (the same summary endpoint the Share Holder report uses), so it finds a member across + * every branch rather than only among accounts already downloaded. + * + *

+ * A period narrows which lines are shown but never how the running balance was reached — the server + * walks the account's whole life and filters afterwards, so a mid-year statement opens at the + * holding the member actually had, not at zero. + */ +@Component({ + selector: 'mifosx-share-account-statement', + templateUrl: './share-account-statement.component.html', + styleUrls: [ + '../../../letter-mgmt/certificates/certificates.shared.scss', + '../share-report.shared.scss' + ], + imports: [ + CommonModule, + FormsModule, + FaIconComponent + ] +}) +export class ShareAccountStatementComponent implements OnInit { + private sharesService = inject(SharesService); + private route = inject(ActivatedRoute); + private router = inject(Router); + + /** Account picker. */ + accountSearch = ''; + accountOptions: any[] = []; + searchingAccounts = false; + selectedAccountId: number | null = null; + + /** Statement. */ + loading = false; + loadError = ''; + account: any = null; + lines: StatementLine[] = []; + totals: any = null; + + fromDate = ''; + toDate = ''; + + private searchTerm$ = new Subject(); + + ngOnInit(): void { + this.searchTerm$ + .pipe( + debounceTime(350), + distinctUntilChanged(), + switchMap((term) => { + this.searchingAccounts = true; + return this.sharesService.getShareAccountSummaries(term).pipe(catchError(() => of([]))); + }) + ) + .subscribe((res: any) => { + this.accountOptions = Array.isArray(res) ? res : res?.pageItems || []; + this.searchingAccounts = false; + }); + + // Deep-linkable: the account view's own statement button, and any report that drills in here, + // can hand over ?shareAccountId= and land on a loaded statement rather than an empty picker. + const preselected = this.route.snapshot.queryParamMap.get('shareAccountId'); + if (preselected) { + this.selectedAccountId = Number(preselected); + this.load(); + } + this.searchTerm$.next(''); + } + + onAccountSearch(term: string): void { + this.accountSearch = term; + this.searchTerm$.next(term.trim()); + } + + selectAccount(option: any): void { + this.selectedAccountId = option.id; + this.accountOptions = []; + this.accountSearch = `${option.clientName} — ${option.accountNo}`; + this.load(); + } + + load(): void { + if (!this.selectedAccountId) { + return; + } + this.loading = true; + this.loadError = ''; + this.sharesService + .getShareAccountStatement(this.selectedAccountId, { fromDate: this.fromDate, toDate: this.toDate }) + .pipe( + catchError((err) => { + this.loadError = this.readError(err, 'The share account statement could not be loaded.'); + return of(null); + }) + ) + .subscribe((res: any) => { + this.account = res?.account || null; + this.lines = res?.lines || []; + this.totals = res?.totals || null; + this.loading = false; + }); + } + + clearPeriod(): void { + this.fromDate = ''; + this.toDate = ''; + this.load(); + } + + typeLabel(typeEnum: number): string { + return SHARE_TXN_TYPE[typeEnum]?.label || String(typeEnum ?? ''); + } + + typeClass(typeEnum: number): string { + return SHARE_TXN_TYPE[typeEnum]?.cls || 'srep-pill--grey'; + } + + statusLabel(statusEnum: number): string { + return SHARE_ACCOUNT_STATUS[statusEnum]?.label || String(statusEnum ?? ''); + } + + statusClass(statusEnum: number): string { + return SHARE_ACCOUNT_STATUS[statusEnum]?.cls || 'srep-pill--grey'; + } + + funding(source: string): string { + return fundingLabel(source); + } + + openAccount(): void { + if (this.account) { + this.router.navigate([ + '/clients', + this.account.clientId, + 'shares-accounts', + this.account.shareAccountId, + 'general' + ]); + } + } + + exportCsv(): void { + if (!this.account) { + return; + } + shareReportCsv( + `share-statement-${this.account.accountNo}`, + [ + 'Date', + 'Particulars', + 'Board Resolution', + 'Funded By', + 'Shares In', + 'Shares Out', + 'Unit Price', + 'Amount', + 'Entry Fee', + 'Balance Shares', + 'Balance Value' + ], + this.lines.map((l) => [ + l.transactionDate, + this.typeLabel(l.typeEnum), + l.resolutionNumber, + this.funding(l.fundingSource), + l.sharesIn || '', + l.sharesOut || '', + l.unitPrice, + l.amount, + l.chargeAmount, + l.balanceShares, + l.balanceValue + ]) + ); + } + + print(): void { + shareReportPrint(); + } + + private readError(err: any, fallback: string): string { + return ( + err?.error?.errors?.[0]?.defaultUserMessage || + err?.error?.defaultUserMessage || + err?.error?.developerMessage || + fallback + ); + } +} diff --git a/src/app/shares/reports/share-area-wise/share-area-wise.component.html b/src/app/shares/reports/share-area-wise/share-area-wise.component.html new file mode 100644 index 000000000..58d02b8ce --- /dev/null +++ b/src/app/shares/reports/share-area-wise/share-area-wise.component.html @@ -0,0 +1,174 @@ + + +

+
+
+ +
+

Area Wise Shareholding

+
+ Where the society's members and its share capital sit — grouped by the member's own recorded address. +
+
+
+
+ + +
+
+ + @if (totals) { +
+
+ +
+
{{ totals.areaCount | number }}
+
Areas
+
+
+
+ +
+
{{ totals.memberCount | number }}
+
Members
+
+
+
+ +
+
₹{{ totals.shareValue | number: '1.2-2' }}
+
Share Capital
+
+
+
+ +
+
{{ totals.shares | number }}
+
Shares Held
+
+
+
+ } + + @if (loading) { +
Loading area-wise shareholding…
+ } @else if (loadError) { +
{{ loadError }}
+ } @else if (!members.length) { +
No member holds share capital yet.
+ } @else { + +
+ + + + + + + + + + + + + @for (a of areas; track a.area) { + + + + + + + + + } + + + + + + + + + + + +
AreaMembersCapitalSharesShare Value% of Capital
+ {{ a.area }} + @if (areaFilter === a.area) { + filtered + } + {{ a.memberCount | number }} +
+
{{ a.shares | number }}₹{{ a.shareValue | number: '1.2-2' }}{{ capitalShare(a) | number: '1.2-2' }}%
All areas{{ totals?.memberCount | number }}{{ totals?.shares | number }}₹{{ totals?.shareValue | number: '1.2-2' }}100.00%
+
+ + +
+
+ +
+ + +
+
+
+ + +
+
+ + +
+
+ + @if (!filteredMembers.length) { +
No member matches the filters applied.
+ } @else { +
+ + + + + + + + + + + + + + + @for (m of filteredMembers; track m.clientId) { + + + + + + + + + + + } + +
AreaMemberMembership NoMobileBranchPostal CodeShares HeldShare Value
{{ m.area }}{{ m.clientName }}{{ m.cifNumber || '—' }}{{ m.mobileNo || '—' }}{{ m.officeName || '—' }}{{ m.postalCode || '—' }}{{ m.sharesHeld | number }}₹{{ m.shareValue | number: '1.2-2' }}
+
+ } + } +
diff --git a/src/app/shares/reports/share-area-wise/share-area-wise.component.ts b/src/app/shares/reports/share-area-wise/share-area-wise.component.ts new file mode 100644 index 000000000..d2abd920e --- /dev/null +++ b/src/app/shares/reports/share-area-wise/share-area-wise.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, OnInit, inject } from '@angular/core'; +import { CommonModule } from '@angular/common'; +import { FormsModule } from '@angular/forms'; +import { Router } from '@angular/router'; +import { FaIconComponent } from '@fortawesome/angular-fontawesome'; +import { of } from 'rxjs'; +import { catchError } from 'rxjs/operators'; + +import { SharesService } from '../../shares.service'; +import { shareReportCsv, shareReportPrint } from '../share-report.util'; + +interface AreaRow { + area: string; + memberCount: number; + shares: number; + shareValue: number; +} + +interface AreaMemberRow { + clientId: number; + cifNumber: string; + clientName: string; + mobileNo: string; + officeName: string; + area: string; + postalCode: string; + sharesHeld: number; + shareValue: number; +} + +/** + * Area Wise — where the society's members and its share capital actually sit. + * + *

+ * A co-operative's area of operation is part of its licence, so "how much of our capital comes from + * which area" is a question both the board and the Registrar ask. Grouped on the member's own + * recorded address rather than the branch that serves them: the two answer different questions, and + * a licence is written against the address. + * + *

+ * Members with no address are their own group rather than dropped — an unaddressable member is a gap + * in the register worth seeing. + */ +@Component({ + selector: 'mifosx-share-area-wise', + templateUrl: './share-area-wise.component.html', + styleUrls: [ + '../../../letter-mgmt/certificates/certificates.shared.scss', + '../share-report.shared.scss' + ], + imports: [ + CommonModule, + FormsModule, + FaIconComponent + ] +}) +export class ShareAreaWiseComponent implements OnInit { + private sharesService = inject(SharesService); + private router = inject(Router); + + loading = true; + loadError = ''; + areas: AreaRow[] = []; + members: AreaMemberRow[] = []; + totals: any = null; + + search = ''; + /** Set by clicking an area row; '' means every area. */ + areaFilter = ''; + + ngOnInit(): void { + this.load(); + } + + load(): void { + this.loading = true; + this.loadError = ''; + this.sharesService + .getShareAreaWise({}) + .pipe( + catchError((err) => { + this.loadError = this.readError(err, 'The area-wise report could not be loaded.'); + return of(null); + }) + ) + .subscribe((res: any) => { + this.areas = res?.areas || []; + this.members = res?.members || []; + this.totals = res?.totals || null; + this.loading = false; + }); + } + + get filteredMembers(): AreaMemberRow[] { + const term = this.search.trim().toLowerCase(); + return this.members.filter((m) => { + if (this.areaFilter && m.area !== this.areaFilter) { + return false; + } + if (!term) { + return true; + } + return [ + m.clientName, + m.cifNumber, + m.area, + m.postalCode + ] + .filter(Boolean) + .some((field) => String(field).toLowerCase().includes(term)); + }); + } + + /** An area's share of the society's capital — the reading this report exists for. */ + capitalShare(row: AreaRow): number { + const total = Number(this.totals?.shareValue || 0); + return total ? (Number(row.shareValue || 0) * 100) / total : 0; + } + + barWidth(row: AreaRow): number { + const largest = Math.max(...this.areas.map((a) => Number(a.shareValue || 0)), 1); + return (Number(row.shareValue || 0) * 100) / largest; + } + + selectArea(area: string): void { + this.areaFilter = this.areaFilter === area ? '' : area; + } + + openMember(row: AreaMemberRow): void { + this.router.navigate([ + '/clients', + row.clientId, + 'general' + ]); + } + + clearFilters(): void { + this.search = ''; + this.areaFilter = ''; + } + + exportCsv(): void { + shareReportCsv( + 'share-area-wise', + [ + 'Area', + 'Member', + 'Membership No', + 'Mobile', + 'Branch', + 'Postal Code', + 'Shares Held', + 'Share Value' + ], + this.filteredMembers.map((m) => [ + m.area, + m.clientName, + m.cifNumber, + m.mobileNo, + m.officeName, + m.postalCode, + m.sharesHeld, + m.shareValue + ]) + ); + } + + print(): void { + shareReportPrint(); + } + + private readError(err: any, fallback: string): string { + return ( + err?.error?.errors?.[0]?.defaultUserMessage || + err?.error?.defaultUserMessage || + err?.error?.developerMessage || + fallback + ); + } +} diff --git a/src/app/shares/reports/share-detail-report/share-detail-report.component.html b/src/app/shares/reports/share-detail-report/share-detail-report.component.html new file mode 100644 index 000000000..6ca3fb45e --- /dev/null +++ b/src/app/shares/reports/share-detail-report/share-detail-report.component.html @@ -0,0 +1,192 @@ + + +

+
+
+ +
+

Share Detail

+
+ The complete share register — every account with its member, class and the terms it was opened under. +
+
+
+
+ + +
+
+ + @if (totals) { +
+
+ +
+
{{ totals.accountCount | number }}
+
Share Accounts
+
+
+
+ +
+
{{ totals.sharesHeld | number }}
+
Shares Held
+
+
+
+ +
+
₹{{ totals.shareValue | number: '1.2-2' }}
+
Share Capital
+
+
+
+ +
+
₹{{ totals.chargeOutstanding | number: '1.2-2' }}
+
Charges Outstanding
+
+
+
+ } + +
+
+ +
+ + +
+
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+ + @if (loading) { +
Loading the share register…
+ } @else if (loadError) { +
{{ loadError }}
+ } @else if (!rows.length) { +
No share account exists yet.
+ } @else if (!filteredRows.length) { +
No share account matches the filters applied.
+ } @else { +
+ + + + + + + + + + + + + + + + + + + + + + @for (row of filteredRows; track row.shareAccountId) { + + + + + + + + + + + + + + + + + + } + + + + + + + + + +
MemberMembership NoShare A/c NoBranchProductStateSharesValueTranchesBoard ResolutionLock-inMin ActiveLinked SavingsCharges O/SActivated
+ {{ row.clientName }} + @if (row.mobileNo) { +
{{ row.mobileNo }}
+ } +
{{ row.cifNumber || '—' }}{{ row.accountNo }}{{ row.officeName || '—' }}{{ row.productName }} + {{ stateLabel(row.holdingState) }} + + {{ row.sharesHeld | number }} + @if (row.holdingState === 'UNPAID') { +
{{ row.unpaidShares | number }} unpaid
+ } +
₹{{ row.shareValue | number: '1.2-2' }}{{ row.trancheCount | number }} + {{ row.resolutionNumber || '—' }} + @if (row.resolutionDate) { +
{{ row.resolutionDate }}
+ } +
{{ lockin(row) }}{{ minimumActive(row) }}{{ row.linkedSavingsAccountNo || '—' }} + {{ row.chargeOutstanding ? '₹' + (row.chargeOutstanding | number: '1.2-2') : '—' }} + {{ row.activatedDate || '—' }}
+ {{ isFiltered ? 'Total (filtered)' : 'Total' }} — {{ filteredRows.length | number }} accounts + {{ shownShares | number }}₹{{ shownValue | number: '1.2-2' }}
+
+ } +
diff --git a/src/app/shares/reports/share-detail-report/share-detail-report.component.ts b/src/app/shares/reports/share-detail-report/share-detail-report.component.ts new file mode 100644 index 000000000..7a9323403 --- /dev/null +++ b/src/app/shares/reports/share-detail-report/share-detail-report.component.ts @@ -0,0 +1,293 @@ +/** + * Copyright since 2025 Mifos Initiative + * + * This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. + */ + +import { Component, OnInit, inject } from '@angular/core'; +import { CommonModule } from '@angular/common'; +import { FormsModule } from '@angular/forms'; +import { Router } from '@angular/router'; +import { FaIconComponent } from '@fortawesome/angular-fontawesome'; +import { of } from 'rxjs'; +import { catchError } from 'rxjs/operators'; + +import { SharesService } from '../../shares.service'; +import { + SHARE_ACCOUNT_STATUS, + holdingStateClass, + holdingStateLabel, + holdingStateNote, + holdingStateRowClass, + periodLabel, + shareReportCsv, + shareReportPrint +} from '../share-report.util'; + +interface DetailRow { + shareAccountId: number; + accountNo: string; + externalId: string; + clientId: number; + cifNumber: string; + clientName: string; + mobileNo: string; + officeName: string; + productId: number; + productName: string; + productShortName: string; + currencyCode: string; + statusEnum: number; + holdingState: string; + unpaidShares: number; + unitPrice: number; + sharesHeld: number; + shareValue: number; + pendingShares: number; + trancheCount: number; + resolutionNumber: string; + resolutionDate: string; + lockinPeriod: number; + lockinPeriodEnum: number; + minimumActivePeriod: number; + minimumActivePeriodEnum: number; + allowDividendsInactive: boolean; + linkedSavingsAccountNo: string; + chargeTotal: number; + chargeOutstanding: number; + submittedDate: string; + approvedDate: string; + activatedDate: string; + closedDate: string; +} + +/** + * Share Detail — the complete register: every account with its member, share class, and the terms it + * was opened under. + * + *

+ * Where the Share Holder list answers "who holds shares", this answers "what exactly does this + * account say": lock-in, minimum active period, linked savings, charges, tranche count and governing + * resolution, which are otherwise spread one tab at a time across each account. + */ +@Component({ + selector: 'mifosx-share-detail-report', + templateUrl: './share-detail-report.component.html', + styleUrls: [ + '../../../letter-mgmt/certificates/certificates.shared.scss', + '../share-report.shared.scss' + ], + imports: [ + CommonModule, + FormsModule, + FaIconComponent + ] +}) +export class ShareDetailReportComponent implements OnInit { + private sharesService = inject(SharesService); + private router = inject(Router); + + loading = true; + loadError = ''; + rows: DetailRow[] = []; + totals: any = null; + + search = ''; + branchFilter = ''; + productFilter = ''; + statusFilter: number | '' = ''; + + ngOnInit(): void { + this.load(); + } + + load(): void { + this.loading = true; + this.loadError = ''; + this.sharesService + .getShareDetailRegister({}) + .pipe( + catchError((err) => { + this.loadError = this.readError(err, 'The share register could not be loaded.'); + return of(null); + }) + ) + .subscribe((res: any) => { + this.rows = res?.rows || []; + this.totals = res?.totals || null; + this.loading = false; + }); + } + + get branchOptions(): string[] { + return [...new Set(this.rows.map((r) => r.officeName).filter(Boolean))].sort(); + } + + get productOptions(): string[] { + return [...new Set(this.rows.map((r) => r.productName).filter(Boolean))].sort(); + } + + get statusOptions(): number[] { + return [...new Set(this.rows.map((r) => r.statusEnum))].sort((a, b) => a - b); + } + + get filteredRows(): DetailRow[] { + const term = this.search.trim().toLowerCase(); + return this.rows.filter((r) => { + if (this.branchFilter && r.officeName !== this.branchFilter) { + return false; + } + if (this.productFilter && r.productName !== this.productFilter) { + return false; + } + if (this.statusFilter !== '' && r.statusEnum !== this.statusFilter) { + return false; + } + if (!term) { + return true; + } + return [ + r.clientName, + r.accountNo, + r.cifNumber, + r.productName, + r.resolutionNumber, + r.externalId + ] + .filter(Boolean) + .some((field) => String(field).toLowerCase().includes(term)); + }); + } + + get shownShares(): number { + return this.filteredRows.reduce((sum, r) => sum + Number(r.sharesHeld || 0), 0); + } + + get shownValue(): number { + return this.filteredRows.reduce((sum, r) => sum + Number(r.shareValue || 0), 0); + } + + get isFiltered(): boolean { + return !!(this.search.trim() || this.branchFilter || this.productFilter || this.statusFilter !== ''); + } + + statusLabel(statusEnum: number): string { + return SHARE_ACCOUNT_STATUS[statusEnum]?.label || String(statusEnum ?? ''); + } + + lockin(row: DetailRow): string { + return periodLabel(row.lockinPeriod, row.lockinPeriodEnum); + } + + minimumActive(row: DetailRow): string { + return periodLabel(row.minimumActivePeriod, row.minimumActivePeriodEnum); + } + + stateLabel(state: string): string { + return holdingStateLabel(state); + } + + stateClass(state: string): string { + return holdingStateClass(state); + } + + stateRowClass(state: string): string { + return holdingStateRowClass(state); + } + + stateNote(state: string): string { + return holdingStateNote(state); + } + + clearFilters(): void { + this.search = ''; + this.branchFilter = ''; + this.productFilter = ''; + this.statusFilter = ''; + } + + openAccount(row: DetailRow): void { + this.router.navigate([ + '/clients', + row.clientId, + 'shares-accounts', + row.shareAccountId, + 'general' + ]); + } + + exportCsv(): void { + shareReportCsv( + 'share-detail-register', + [ + 'Member', + 'Membership No', + 'Mobile', + 'Share A/c No', + 'External Id', + 'Branch', + 'Product', + 'Status', + 'Holding State', + 'Allotted Unpaid', + 'Shares Held', + 'Unit Price', + 'Share Value', + 'Pending Shares', + 'Tranches', + 'Board Resolution', + 'Lock-in', + 'Min Active Period', + 'Dividends If Inactive', + 'Linked Savings', + 'Charges', + 'Charges Outstanding', + 'Submitted', + 'Activated', + 'Closed' + ], + this.filteredRows.map((r) => [ + r.clientName, + r.cifNumber, + r.mobileNo, + r.accountNo, + r.externalId, + r.officeName, + r.productName, + this.statusLabel(r.statusEnum), + this.stateLabel(r.holdingState), + r.unpaidShares, + r.sharesHeld, + r.unitPrice, + r.shareValue, + r.pendingShares, + r.trancheCount, + r.resolutionNumber, + this.lockin(r), + this.minimumActive(r), + r.allowDividendsInactive ? 'Yes' : 'No', + r.linkedSavingsAccountNo, + r.chargeTotal, + r.chargeOutstanding, + r.submittedDate, + r.activatedDate, + r.closedDate + ]) + ); + } + + print(): void { + shareReportPrint(); + } + + private readError(err: any, fallback: string): string { + return ( + err?.error?.errors?.[0]?.defaultUserMessage || + err?.error?.defaultUserMessage || + err?.error?.developerMessage || + fallback + ); + } +} diff --git a/src/app/shares/reports/share-distinctive-numbers/share-distinctive-numbers.component.html b/src/app/shares/reports/share-distinctive-numbers/share-distinctive-numbers.component.html new file mode 100644 index 000000000..7d552d1bd --- /dev/null +++ b/src/app/shares/reports/share-distinctive-numbers/share-distinctive-numbers.component.html @@ -0,0 +1,152 @@ + + +

+
+
+ +
+

Share Distinctive Number Detail

+
+ Which numbered shares each member holds — the range every allotment covers, per share class. +
+
+
+
+ + +
+
+ + @if (totals) { +
+
+ +
+
{{ totals.allotmentCount | number }}
+
Numbered Allotments
+
+
+
+ +
+
{{ totals.sharesNumbered | number }}
+
Shares Numbered
+
+
+
+ } + + +
+ + + Distinctive numbers are derived from issue order within each share class — no distinctive-number column + exists in the system, so each paid-up allotment takes the next block in sequence. Redeemed shares leave a gap + rather than renumbering everybody else, exactly as a physical register would. Only paid-up allotments are + numbered. + +
+ +
+
+ +
+ + +
+
+
+ +
+ + +
+
+
+ + +
+
+ + +
+
+ + @if (loading) { +
Loading the distinctive number register…
+ } @else if (loadError) { +
{{ loadError }}
+ } @else if (!rows.length) { +
No paid-up allotment has been made yet, so no share carries a number.
+ } @else if (!filteredRows.length) { +
+ @if (shareNumberLookup) { + Share number {{ shareNumberLookup | number }} has not been allotted in this share class. + } @else { + No allotment matches the filters applied. + } +
+ } @else { +
+ + + + + + + + + + + + + + + + + @for (row of filteredRows; track row.transactionId) { + + + + + + + + + + + + + } + + + + + + + + +
Share ClassDistinctive NumbersSharesMemberMembership NoShare A/c NoBranchAllotment DateFunded ByBoard Resolution
{{ row.productName }} + {{ range(row) }} + {{ row.shares | number }}{{ row.clientName }}{{ row.cifNumber || '—' }}{{ row.accountNo }}{{ row.officeName || '—' }}{{ row.allotmentDate }}{{ funding(row.fundingSource) }}{{ row.resolutionNumber || '—' }}
+ {{ isFiltered ? 'Total (filtered)' : 'Total' }} — {{ filteredRows.length | number }} allotments + {{ shownShares | number }}
+
+ } +
diff --git a/src/app/shares/reports/share-distinctive-numbers/share-distinctive-numbers.component.ts b/src/app/shares/reports/share-distinctive-numbers/share-distinctive-numbers.component.ts new file mode 100644 index 000000000..541745ac5 --- /dev/null +++ b/src/app/shares/reports/share-distinctive-numbers/share-distinctive-numbers.component.ts @@ -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/. + */ + +import { Component, OnInit, inject } from '@angular/core'; +import { CommonModule } from '@angular/common'; +import { FormsModule } from '@angular/forms'; +import { Router } from '@angular/router'; +import { FaIconComponent } from '@fortawesome/angular-fontawesome'; +import { of } from 'rxjs'; +import { catchError } from 'rxjs/operators'; + +import { SharesService } from '../../shares.service'; +import { fundingLabel, shareReportCsv, shareReportPrint } from '../share-report.util'; + +interface DistinctiveRow { + transactionId: number; + allotmentDate: string; + shareAccountId: number; + accountNo: string; + clientId: number; + cifNumber: string; + clientName: string; + officeName: string; + productId: number; + productName: string; + shares: number; + unitPrice: number; + amount: number; + resolutionNumber: string; + fundingSource: string; + distinctiveFrom: number; + distinctiveTo: number; +} + +/** + * Share Devede (Distinctive) Number Detail — which numbered shares each member actually holds. + * + *

+ * A share certificate names the distinctive numbers it covers, and the register has to be able to + * say who holds which. Nothing in the schema records those numbers, so they are derived the way the + * society would have allotted them: shares numbered in issue order within each share class, each + * allotment taking the next block. That is stated plainly on the screen rather than presented as an + * independently-kept number, because it is a derivation and a reader should know it. + */ +@Component({ + selector: 'mifosx-share-distinctive-numbers', + templateUrl: './share-distinctive-numbers.component.html', + styleUrls: [ + '../../../letter-mgmt/certificates/certificates.shared.scss', + '../share-report.shared.scss' + ], + imports: [ + CommonModule, + FormsModule, + FaIconComponent + ] +}) +export class ShareDistinctiveNumbersComponent implements OnInit { + private sharesService = inject(SharesService); + private router = inject(Router); + + loading = true; + loadError = ''; + rows: DistinctiveRow[] = []; + totals: any = null; + + search = ''; + productFilter = ''; + /** Find who holds a given share number — the question a certificate query actually arrives as. */ + shareNumberLookup: number | null = null; + + ngOnInit(): void { + this.load(); + } + + load(): void { + this.loading = true; + this.loadError = ''; + this.sharesService + .getShareDistinctiveNumbers({}) + .pipe( + catchError((err) => { + this.loadError = this.readError(err, 'The distinctive number register could not be loaded.'); + return of(null); + }) + ) + .subscribe((res: any) => { + this.rows = res?.rows || []; + this.totals = res?.totals || null; + this.loading = false; + }); + } + + get productOptions(): string[] { + return [...new Set(this.rows.map((r) => r.productName).filter(Boolean))].sort(); + } + + get filteredRows(): DistinctiveRow[] { + const term = this.search.trim().toLowerCase(); + const lookup = Number(this.shareNumberLookup); + return this.rows.filter((r) => { + if (this.productFilter && r.productName !== this.productFilter) { + return false; + } + // A share number belongs to exactly one block, so the lookup is a range test rather than a + // match — which is the whole point of holding the register this way. + if (lookup > 0 && !(lookup >= Number(r.distinctiveFrom) && lookup <= Number(r.distinctiveTo))) { + return false; + } + if (!term) { + return true; + } + return [ + r.clientName, + r.accountNo, + r.cifNumber, + r.productName, + r.resolutionNumber + ] + .filter(Boolean) + .some((field) => String(field).toLowerCase().includes(term)); + }); + } + + get shownShares(): number { + return this.filteredRows.reduce((sum, r) => sum + Number(r.shares || 0), 0); + } + + get isFiltered(): boolean { + return !!(this.search.trim() || this.productFilter || Number(this.shareNumberLookup) > 0); + } + + funding(source: string): string { + return fundingLabel(source); + } + + range(row: DistinctiveRow): string { + return `${Number(row.distinctiveFrom).toLocaleString('en-IN')} – ${Number(row.distinctiveTo).toLocaleString('en-IN')}`; + } + + clearFilters(): void { + this.search = ''; + this.productFilter = ''; + this.shareNumberLookup = null; + } + + openAccount(row: DistinctiveRow): void { + this.router.navigate([ + '/clients', + row.clientId, + 'shares-accounts', + row.shareAccountId, + 'general' + ]); + } + + exportCsv(): void { + shareReportCsv( + 'share-distinctive-numbers', + [ + 'Share Class', + 'Distinctive From', + 'Distinctive To', + 'Shares', + 'Member', + 'Membership No', + 'Share A/c No', + 'Branch', + 'Allotment Date', + 'Funded By', + 'Board Resolution' + ], + this.filteredRows.map((r) => [ + r.productName, + r.distinctiveFrom, + r.distinctiveTo, + r.shares, + r.clientName, + r.cifNumber, + r.accountNo, + r.officeName, + r.allotmentDate, + this.funding(r.fundingSource), + r.resolutionNumber + ]) + ); + } + + print(): void { + shareReportPrint(); + } + + private readError(err: any, fallback: string): string { + return ( + err?.error?.errors?.[0]?.defaultUserMessage || + err?.error?.defaultUserMessage || + err?.error?.developerMessage || + fallback + ); + } +} diff --git a/src/app/shares/reports/share-dividend-statement/share-dividend-statement.component.html b/src/app/shares/reports/share-dividend-statement/share-dividend-statement.component.html new file mode 100644 index 000000000..de23d6383 --- /dev/null +++ b/src/app/shares/reports/share-dividend-statement/share-dividend-statement.component.html @@ -0,0 +1,171 @@ + + +

+
+
+ +
+

Dividend Statement

+
+ Every dividend declared to a member, year by year, with what has been paid and what is still owed. +
+
+
+
+ + +
+
+ +
+
+ +
+ + +
+
+
+ + +
+
+ + @if (loadError) { +
{{ loadError }}
+ } @else if (searching) { +
Loading the statement…
+ } @else if (matches.length) { +
{{ matches.length }} members match. Choose one:
+
+ + + + + + + + + + @for (m of matches; track m.clientId) { + + + + + + } + +
MemberMembership No
{{ m.clientName }}{{ m.cifNumber || '—' }}Open
+
+ } @else if (searched && !member) { +
No member with a share account matches "{{ search }}".
+ } @else if (member) { +
+
+
+
Member
+
{{ member.clientName }}
+
+
+
Membership No
+
{{ member.cifNumber || '—' }}
+
+
+
Branch
+
{{ member.officeName || '—' }}
+
+
+
+ + @if (totals) { +
+
+ +
+
{{ totals.declarationCount | number }}
+
Declarations
+
+
+
+ +
+
₹{{ totals.declared | number: '1.2-2' }}
+
Total Declared
+
+
+
+ +
+
₹{{ totals.paid | number: '1.2-2' }}
+
Paid
+
+
+
+ +
+
₹{{ totals.outstanding | number: '1.2-2' }}
+
Still Owed
+
+
+
+ } + + @if (!rows.length) { +
No dividend has been declared to this member yet.
+ } @else { +
+ + + + + + + + + + + + + + @for (row of rows; track row.dividendDetailId) { + + + + + + + + + + } + + + + + + + + +
Dividend PeriodShare A/c NoProductRateShares Held NowAmountStatus
{{ row.periodStart }} — {{ row.periodEnd }}{{ row.accountNo }}{{ row.productName }}{{ row.ratePercentage ? (row.ratePercentage | number: '1.2-2') + '%' : '—' }}{{ row.sharesHeldNow | number }}₹{{ row.amount | number: '1.2-2' }} + {{ + statusLabel(row.statusEnum) + }} +
Total declared₹{{ totals?.declared | number: '1.2-2' }}
+
+ } + } @else { +
Search for a member above to see their dividend statement.
+ } +
diff --git a/src/app/shares/reports/share-dividend-statement/share-dividend-statement.component.ts b/src/app/shares/reports/share-dividend-statement/share-dividend-statement.component.ts new file mode 100644 index 000000000..811f790be --- /dev/null +++ b/src/app/shares/reports/share-dividend-statement/share-dividend-statement.component.ts @@ -0,0 +1,151 @@ +/** + * 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 { ActivatedRoute } from '@angular/router'; +import { FaIconComponent } from '@fortawesome/angular-fontawesome'; +import { of } from 'rxjs'; +import { catchError } from 'rxjs/operators'; + +import { SharesService } from '../../shares.service'; +import { SHARE_DIVIDEND_STATUS, shareReportCsv, shareReportPrint } from '../share-report.util'; + +/** + * Dividend Statement — every dividend declared to one member, with what has been paid and what is + * still owed. + * + *

+ * Handed to a member as their record, so declared and paid stay separate columns: "declared" and + * "paid" are different answers to "have I had my dividend", and a statement showing one amount + * cannot tell them apart. + */ +@Component({ + selector: 'mifosx-share-dividend-statement', + templateUrl: './share-dividend-statement.component.html', + styleUrls: [ + '../../../letter-mgmt/certificates/certificates.shared.scss', + '../share-report.shared.scss' + ], + imports: [ + CommonModule, + FormsModule, + FaIconComponent + ] +}) +export class ShareDividendStatementComponent implements OnInit { + private sharesService = inject(SharesService); + private route = inject(ActivatedRoute); + + search = ''; + searching = false; + searched = false; + loadError = ''; + + matches: any[] = []; + member: any = null; + rows: any[] = []; + totals: any = null; + + ngOnInit(): void { + const clientId = this.route.snapshot.queryParamMap.get('clientId'); + if (clientId) { + this.loadByClient(Number(clientId)); + } + } + + runSearch(): void { + const term = this.search.trim(); + if (!term) { + return; + } + this.fetch({ search: term }); + } + + loadByClient(clientId: number): void { + this.fetch({ clientId }); + } + + private fetch(params: { search?: string; clientId?: number }): void { + this.searching = true; + this.loadError = ''; + this.reset(); + this.sharesService + .getShareDividendStatement(params) + .pipe( + catchError((err) => { + this.loadError = this.readError(err, 'The dividend statement could not be loaded.'); + return of(null); + }) + ) + .subscribe((res: any) => { + this.matches = res?.matches || []; + this.member = res?.member || null; + this.rows = res?.rows || []; + this.totals = res?.totals || null; + this.searching = false; + this.searched = true; + }); + } + + private reset(): void { + this.matches = []; + this.member = null; + this.rows = []; + this.totals = null; + } + + statusLabel(status: number): string { + return SHARE_DIVIDEND_STATUS[status]?.label || String(status ?? ''); + } + + statusClass(status: number): string { + return SHARE_DIVIDEND_STATUS[status]?.cls || 'srep-pill--grey'; + } + + exportCsv(): void { + if (!this.member) { + return; + } + shareReportCsv( + `dividend-statement-${this.member.cifNumber || this.member.clientId}`, + [ + 'Period From', + 'Period To', + 'Share A/c No', + 'Product', + 'Rate %', + 'Amount', + 'Status' + ], + this.rows.map((r) => [ + r.periodStart, + r.periodEnd, + r.accountNo, + r.productName, + r.ratePercentage, + r.amount, + this.statusLabel(r.statusEnum) + ]) + ); + } + + print(): void { + shareReportPrint(); + } + + private readError(err: any, fallback: string): string { + return ( + err?.error?.errors?.[0]?.defaultUserMessage || + err?.error?.defaultUserMessage || + err?.error?.developerMessage || + fallback + ); + } +} diff --git a/src/app/shares/reports/share-enquiry/share-enquiry.component.html b/src/app/shares/reports/share-enquiry/share-enquiry.component.html new file mode 100644 index 000000000..deb67fa55 --- /dev/null +++ b/src/app/shares/reports/share-enquiry/share-enquiry.component.html @@ -0,0 +1,291 @@ + + +

+
+
+ +
+

Share Enquiry

+
+ One member's whole share position — accounts, movements, dividends and linked savings, in one answer. +
+
+
+
+ + +
+
+ +
+
+ +
+ + +
+
+
+ + +
+
+ + @if (loadError) { +
{{ loadError }}
+ } @else if (searching) { +
Looking up the member…
+ } @else if (matches.length) { + +
{{ matches.length }} members match. Choose one:
+
+ + + + + + + + + + @for (m of matches; track m.clientId) { + + + + + + } + +
MemberMembership No
{{ m.clientName }}{{ m.cifNumber || '—' }}Open
+
+ } @else if (searched && !member) { +
No member with a share account matches "{{ search }}".
+ } @else if (member) { + +
+
+
+
Member
+
{{ member.clientName }}
+
+
+
Membership No
+
{{ member.cifNumber || '—' }}
+
+
+
Branch
+
{{ member.officeName || '—' }}
+
+
+
Mobile
+
{{ member.mobileNo || '—' }}
+
+
+
Member Since
+
{{ member.memberSince || '—' }}
+
+
+
+ + @if (totals) { +
+
+ +
+
{{ totals.sharesHeld | number }}
+
Shares Held
+
+
+
+ +
+
₹{{ totals.shareValue | number: '1.2-2' }}
+
Share Value
+
+
+
+ +
+
{{ totals.accountCount | number }}
+
Share Accounts
+
+
+
+ +
+
₹{{ totals.dividendTotal | number: '1.2-2' }}
+
Dividend Declared
+
+
+
+ } + + +
+
+
+
+ + + + + + + + + + + + + + + + @for (a of accounts; track a.shareAccountId) { + + + + + + + + + + + + } + +
Share A/c NoProductStateShares HeldPendingShare ValueLinked SavingsActivated
{{ a.accountNo }}{{ a.productName }} + {{ + stateLabel(a.holdingState) + }} + + {{ a.sharesHeld | number }} + @if (a.holdingState === 'UNPAID') { +
{{ a.unpaidShares | number }} unpaid
+ } +
{{ a.pendingShares | number }}₹{{ a.shareValue | number: '1.2-2' }} + {{ a.linkedSavingsAccountNo || 'Not linked' }} + @if (a.linkedSavingsAccountNo) { +
+ Balance ₹{{ a.linkedSavingsBalance | number: '1.2-2' }} +
+ } +
{{ a.activatedDate || '—' }} + + +
+
+ + +
+
+
+ @if (!movements.length) { +
No share movement recorded for this member.
+ } @else { +
+ + + + + + + + + + + + + + + + @for (m of movements; track m.transactionId) { + + + + + + + + + + + + } + +
DateShare A/c NoParticularsSharesUnit PriceAmountFunded ByBoard ResolutionStatus
{{ m.transactionDate }}{{ m.accountNo }} + {{ typeLabel(m.typeEnum) }} + {{ m.shares | number }}{{ m.unitPrice | number: '1.2-2' }}₹{{ m.amount | number: '1.2-2' }}{{ funding(m.fundingSource) }}{{ m.resolutionNumber || '—' }} + {{ + txnStatusLabel(m.statusEnum) + }} +
+
+ } + + +
+
+
+ @if (!dividends.length) { +
No dividend has been declared to this member yet.
+ } @else { +
+ + + + + + + + + + + + + @for (d of dividends; track d.dividendDetailId) { + + + + + + + + + } + + + + + + + + +
PeriodShare A/c NoProductRateAmountStatus
{{ d.periodStart }} — {{ d.periodEnd }}{{ d.accountNo }}{{ d.productName }}{{ d.ratePercentage ? (d.ratePercentage | number: '1.2-2') + '%' : '—' }}₹{{ d.amount | number: '1.2-2' }} + {{ + dividendStatusLabel(d.statusEnum) + }} +
Total declared₹{{ totals?.dividendTotal | number: '1.2-2' }}
+
+ } + } @else { +
Search for a member above to see their full share position.
+ } +
diff --git a/src/app/shares/reports/share-enquiry/share-enquiry.component.ts b/src/app/shares/reports/share-enquiry/share-enquiry.component.ts new file mode 100644 index 000000000..34b306790 --- /dev/null +++ b/src/app/shares/reports/share-enquiry/share-enquiry.component.ts @@ -0,0 +1,246 @@ +/** + * 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 { ActivatedRoute, Router } from '@angular/router'; +import { FaIconComponent } from '@fortawesome/angular-fontawesome'; +import { of } from 'rxjs'; +import { catchError } from 'rxjs/operators'; + +import { SharesService } from '../../shares.service'; +import { + SHARE_TXN_STATUS, + SHARE_TXN_TYPE, + fundingLabel, + holdingStateClass, + holdingStateLabel, + holdingStateNote, + holdingStateRowClass, + shareReportCsv, + shareReportPrint +} from '../share-report.util'; + +/** m_share_account_dividend_details.status — where a member's dividend has got to. */ +const DIVIDEND_STATUS: { [key: number]: { label: string; cls: string } } = { + 100: { label: 'Declared', cls: 'srep-pill--amber' }, + 300: { label: 'Approved', cls: 'srep-pill--blue' }, + 600: { label: 'Paid', cls: 'srep-pill--green' } +}; + +/** + * Share Enquiry — everything about one member's share position in a single answer: their accounts, + * every movement, the dividends declared to them, and the savings account their money moves through. + * + *

+ * Built for the counter. Someone is standing in front of the clerk asking "how many shares do I + * hold, when did I buy them, and what dividend am I owed" — three screens is three chances to read + * the wrong member's row out loud, so this is one search and one page. + * + *

+ * A search matching several members returns the list rather than picking one: guessing between two + * members with similar names is precisely the mistake this screen must not make. + */ +@Component({ + selector: 'mifosx-share-enquiry', + templateUrl: './share-enquiry.component.html', + styleUrls: [ + '../../../letter-mgmt/certificates/certificates.shared.scss', + '../share-report.shared.scss' + ], + imports: [ + CommonModule, + FormsModule, + FaIconComponent + ] +}) +export class ShareEnquiryComponent implements OnInit { + private sharesService = inject(SharesService); + private route = inject(ActivatedRoute); + private router = inject(Router); + + search = ''; + searching = false; + searched = false; + loadError = ''; + + /** Populated when the search matched more than one member — the caller picks. */ + matches: any[] = []; + + member: any = null; + accounts: any[] = []; + movements: any[] = []; + dividends: any[] = []; + totals: any = null; + + ngOnInit(): void { + const clientId = this.route.snapshot.queryParamMap.get('clientId'); + if (clientId) { + this.loadByClient(Number(clientId)); + } + } + + runSearch(): void { + const term = this.search.trim(); + if (!term) { + return; + } + this.searching = true; + this.loadError = ''; + this.reset(); + this.sharesService + .getShareEnquiry({ search: term }) + .pipe( + catchError((err) => { + this.loadError = this.readError(err, 'The enquiry could not be run.'); + return of(null); + }) + ) + .subscribe((res: any) => this.apply(res)); + } + + loadByClient(clientId: number): void { + this.searching = true; + this.loadError = ''; + this.reset(); + this.sharesService + .getShareEnquiry({ clientId }) + .pipe( + catchError((err) => { + this.loadError = this.readError(err, 'The member could not be loaded.'); + return of(null); + }) + ) + .subscribe((res: any) => this.apply(res)); + } + + private apply(res: any): void { + this.matches = res?.matches || []; + this.member = res?.member || null; + this.accounts = res?.accounts || []; + this.movements = res?.movements || []; + this.dividends = res?.dividends || []; + this.totals = res?.totals || null; + this.searching = false; + this.searched = true; + } + + private reset(): void { + this.matches = []; + this.member = null; + this.accounts = []; + this.movements = []; + this.dividends = []; + this.totals = null; + } + + typeLabel(typeEnum: number): string { + return SHARE_TXN_TYPE[typeEnum]?.label || String(typeEnum ?? ''); + } + + typeClass(typeEnum: number): string { + return SHARE_TXN_TYPE[typeEnum]?.cls || 'srep-pill--grey'; + } + + txnStatusLabel(statusEnum: number): string { + return SHARE_TXN_STATUS[statusEnum]?.label || String(statusEnum ?? ''); + } + + txnStatusClass(statusEnum: number): string { + return SHARE_TXN_STATUS[statusEnum]?.cls || 'srep-pill--grey'; + } + + dividendStatusLabel(status: number): string { + return DIVIDEND_STATUS[status]?.label || String(status ?? ''); + } + + dividendStatusClass(status: number): string { + return DIVIDEND_STATUS[status]?.cls || 'srep-pill--grey'; + } + + funding(source: string): string { + return fundingLabel(source); + } + + stateLabel(state: string): string { + return holdingStateLabel(state); + } + + stateClass(state: string): string { + return holdingStateClass(state); + } + + stateRowClass(state: string): string { + return holdingStateRowClass(state); + } + + stateNote(state: string): string { + return holdingStateNote(state); + } + + openAccount(account: any): void { + this.router.navigate([ + '/clients', + this.member.clientId, + 'shares-accounts', + account.shareAccountId, + 'general' + ]); + } + + openStatement(account: any): void { + this.router.navigate(['/shares/reports/account-statement'], { + queryParams: { shareAccountId: account.shareAccountId } + }); + } + + exportCsv(): void { + if (!this.member) { + return; + } + shareReportCsv( + `share-enquiry-${this.member.cifNumber || this.member.clientId}`, + [ + 'Date', + 'Share A/c No', + 'Particulars', + 'Shares', + 'Unit Price', + 'Amount', + 'Funded By', + 'Board Resolution', + 'Status' + ], + this.movements.map((m) => [ + m.transactionDate, + m.accountNo, + this.typeLabel(m.typeEnum), + m.shares, + m.unitPrice, + m.amount, + this.funding(m.fundingSource), + m.resolutionNumber, + this.txnStatusLabel(m.statusEnum) + ]) + ); + } + + print(): void { + shareReportPrint(); + } + + private readError(err: any, fallback: string): string { + return ( + err?.error?.errors?.[0]?.defaultUserMessage || + err?.error?.defaultUserMessage || + err?.error?.developerMessage || + fallback + ); + } +} diff --git a/src/app/shares/reports/share-final-balance/share-final-balance.component.html b/src/app/shares/reports/share-final-balance/share-final-balance.component.html new file mode 100644 index 000000000..38b54fb05 --- /dev/null +++ b/src/app/shares/reports/share-final-balance/share-final-balance.component.html @@ -0,0 +1,208 @@ + + +

+
+
+ +
+

Share Final Balance

+
+ Share capital held by each member as on a date — purchases less redemptions, settled only. +
+
+
+
+ + +
+
+ + + @if (totals) { +
+
+ +
+
{{ totals.memberCount | number }}
+
Members Holding
+
+
+
+ +
+
{{ totals.sharesHeld | number }}
+
Shares Held
+
+
+
+ +
+
₹{{ totals.shareValue | number: '1.2-2' }}
+
Share Capital
+
+
+
+ +
+
₹{{ totals.amountRedeemed | number: '1.2-2' }}
+
Redeemed To Date
+
+
+
+ } + + + @if (hasUnpaidAllotments) { +
+ + + {{ unpaidAllotments.accountCount | number }} + {{ unpaidAllotments.accountCount === 1 ? 'account has' : 'accounts have' }} shares allotted but not yet paid for + — {{ unpaidAllotments.shares | number }} shares, ₹{{ unpaidAllotments.amount | number: '1.2-2' }}. These are excluded above: share capital is what the society has received, not what it has agreed to issue. + Apply the member's collection to bring them in. + +
+ } + + +
+
+ + +
+ +
+ +
+ + +
+
+ +
+ + +
+ +
+ + +
+ +
+ + +
+ +
+ + +
+
+ + @if (loading) { +
Loading share balances…
+ } @else if (loadError) { +
{{ loadError }}
+ } @else if (!rows.length) { +
No member held share capital as on {{ asOnDate }}.
+ } @else if (!filteredRows.length) { +
No member matches the filters applied.
+ } @else { +
+ + + + + + + + + + + + + + + + + + + @for (row of filteredRows; track row.shareAccountId) { + + + + + + + + + + + + + + + } + + + + + + + + + + + + +
MemberMembership NoShare A/c NoBranchProductStateShares HeldUnit PriceShare ValuePurchasedRedeemedLast Movement
{{ row.clientName }}{{ row.cifNumber || '—' }}{{ row.accountNo }}{{ row.officeName || '—' }}{{ row.productName }} + {{ stateLabel(row.holdingState) }} + + {{ row.sharesHeld | number }} + @if (row.holdingState === 'UNPAID') { +
{{ row.unpaidShares | number }} unpaid
+ } +
{{ row.unitPrice | number: '1.2-2' }}{{ row.shareValue | number: '1.2-2' }}{{ row.sharesPurchased | number }}{{ row.sharesRedeemed | number }}{{ row.lastMovementDate || '—' }}
+ {{ isFiltered ? 'Total (filtered)' : 'Total' }} — {{ filteredRows.length | number }} members + {{ shownShares | number }}₹{{ shownValue | number: '1.2-2' }}
+
+ + @if (isFiltered && totals) { +
+ Showing a filtered view. Society-wide as on {{ asOnDate }}: {{ totals.sharesHeld | number }} shares held by + {{ totals.memberCount | number }} members, ₹{{ totals.shareValue | number: '1.2-2' }} share capital. +
+ } + } +
diff --git a/src/app/shares/reports/share-final-balance/share-final-balance.component.ts b/src/app/shares/reports/share-final-balance/share-final-balance.component.ts new file mode 100644 index 000000000..63fb2500a --- /dev/null +++ b/src/app/shares/reports/share-final-balance/share-final-balance.component.ts @@ -0,0 +1,279 @@ +/** + * Copyright since 2025 Mifos Initiative + * + * This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. + */ + +import { Component, OnInit, inject } from '@angular/core'; +import { CommonModule } from '@angular/common'; +import { FormsModule } from '@angular/forms'; +import { Router } from '@angular/router'; +import { FaIconComponent } from '@fortawesome/angular-fontawesome'; +import { of } from 'rxjs'; +import { catchError } from 'rxjs/operators'; + +import { SharesService } from '../../shares.service'; +import { SettingsService } from 'app/settings/settings.service'; +import { + SHARE_ACCOUNT_STATUS, + holdingStateClass, + holdingStateLabel, + holdingStateNote, + holdingStateRowClass, + shareReportCsv, + shareReportPrint +} from '../share-report.util'; + +/** One member's closing shareholding. Shapes the row the server sends; nothing is derived here. */ +interface FinalBalanceRow { + shareAccountId: number; + accountNo: string; + clientId: number; + cifNumber: string; + clientName: string; + officeId: number; + officeName: string; + productId: number; + productName: string; + currencyCode: string; + statusEnum: number; + holdingState: string; + unpaidShares: number; + unitPrice: number; + sharesHeld: number; + shareValue: number; + sharesPurchased: number; + sharesRedeemed: number; + amountSubscribed: number; + amountRedeemed: number; + firstMovementDate: string; + lastMovementDate: string; + activatedDate: string; + closedDate: string; +} + +/** + * Final Balance — what each member's share capital stands at on a given date, and what the society's + * Share Capital account should therefore read. + * + *

+ * The date is the whole point of the report, so it is asked of the server rather than filtered in the + * browser: a holding "as on 31 March" means walking that member's purchases and redemptions up to + * that date, which the rows already on screen cannot answer. Changing the date re-queries. + * + *

+ * Branch, product and search are client-side, because they only ever narrow rows already fetched and + * a round trip for each keystroke would be worse than useless. + */ +@Component({ + selector: 'mifosx-share-final-balance', + templateUrl: './share-final-balance.component.html', + styleUrls: [ + '../../../letter-mgmt/certificates/certificates.shared.scss', + '../share-report.shared.scss' + ], + imports: [ + CommonModule, + FormsModule, + FaIconComponent + ] +}) +export class ShareFinalBalanceComponent implements OnInit { + private sharesService = inject(SharesService); + private settingsService = inject(SettingsService); + private router = inject(Router); + + loading = true; + loadError = ''; + rows: FinalBalanceRow[] = []; + totals: any = null; + + /** + * Shares allotted to a member whose money never arrived — the account was activated but its + * collection was never applied. + * + * Deliberately kept out of the balance (share capital is what the society received, not what it + * promised to issue) and deliberately shown anyway: a total that silently drops accounts leaves a + * reader wondering which, and an allotment sitting unpaid is the thing worth chasing. + */ + unpaidAllotments: { accountCount: number; shares: number; amount: number } | null = null; + + /** Server-side filter — changing it re-queries, since the balance itself depends on it. */ + asOnDate = ''; + /** Closed and fully-redeemed members, off by default: a closing-balance report lists holders. */ + includeZero = false; + + /** Client-side filters. */ + search = ''; + branchFilter = ''; + productFilter = ''; + + ngOnInit(): void { + const businessDate: Date = this.settingsService.businessDate || new Date(); + this.asOnDate = this.toIso(businessDate); + this.load(); + } + + load(): void { + this.loading = true; + this.loadError = ''; + this.sharesService + .getShareFinalBalance({ asOnDate: this.asOnDate, includeZero: this.includeZero }) + .pipe( + catchError((err) => { + this.loadError = this.readError(err, 'The share balance report could not be loaded.'); + return of(null); + }) + ) + .subscribe((res: any) => { + this.rows = res?.rows || []; + this.totals = res?.totals || null; + this.unpaidAllotments = res?.unpaidAllotments || null; + this.loading = false; + }); + } + + get hasUnpaidAllotments(): boolean { + return !!(this.unpaidAllotments && Number(this.unpaidAllotments.accountCount) > 0); + } + + stateLabel(state: string): string { + return holdingStateLabel(state); + } + + stateClass(state: string): string { + return holdingStateClass(state); + } + + stateRowClass(state: string): string { + return holdingStateRowClass(state); + } + + stateNote(state: string): string { + return holdingStateNote(state); + } + + get branchOptions(): string[] { + return [...new Set(this.rows.map((r) => r.officeName).filter(Boolean))].sort(); + } + + get productOptions(): string[] { + return [...new Set(this.rows.map((r) => r.productName).filter(Boolean))].sort(); + } + + get filteredRows(): FinalBalanceRow[] { + const term = this.search.trim().toLowerCase(); + return this.rows.filter((r) => { + if (this.branchFilter && r.officeName !== this.branchFilter) { + return false; + } + if (this.productFilter && r.productName !== this.productFilter) { + return false; + } + if (!term) { + return true; + } + return [ + r.clientName, + r.accountNo, + r.cifNumber, + r.productName + ] + .filter(Boolean) + .some((field) => String(field).toLowerCase().includes(term)); + }); + } + + /** + * Totals for what is on screen. The server's own totals cover the unfiltered report and are shown + * beside these, so a narrowed view can never be mistaken for the society's whole share capital. + */ + get shownShares(): number { + return this.filteredRows.reduce((sum, r) => sum + Number(r.sharesHeld || 0), 0); + } + + get shownValue(): number { + return this.filteredRows.reduce((sum, r) => sum + Number(r.shareValue || 0), 0); + } + + get isFiltered(): boolean { + return !!(this.search.trim() || this.branchFilter || this.productFilter); + } + + statusLabel(statusEnum: number): string { + return SHARE_ACCOUNT_STATUS[statusEnum]?.label || String(statusEnum ?? ''); + } + + clearFilters(): void { + this.search = ''; + this.branchFilter = ''; + this.productFilter = ''; + } + + openAccount(row: FinalBalanceRow): void { + this.router.navigate([ + '/clients', + row.clientId, + 'shares-accounts', + row.shareAccountId, + 'general' + ]); + } + + exportCsv(): void { + shareReportCsv( + `share-final-balance-${this.asOnDate || 'today'}`, + [ + 'Member', + 'Membership No', + 'Share A/c No', + 'Branch', + 'Product', + 'Status', + 'Holding State', + 'Allotted Unpaid', + 'Shares Held', + 'Unit Price', + 'Share Value', + 'Shares Purchased', + 'Shares Redeemed', + 'Last Movement' + ], + this.filteredRows.map((r) => [ + r.clientName, + r.cifNumber, + r.accountNo, + r.officeName, + r.productName, + this.statusLabel(r.statusEnum), + this.stateLabel(r.holdingState), + r.unpaidShares, + r.sharesHeld, + r.unitPrice, + r.shareValue, + r.sharesPurchased, + r.sharesRedeemed, + r.lastMovementDate + ]) + ); + } + + print(): void { + shareReportPrint(); + } + + private toIso(date: Date): string { + return `${date.getFullYear()}-${String(date.getMonth() + 1).padStart(2, '0')}-${String(date.getDate()).padStart(2, '0')}`; + } + + private readError(err: any, fallback: string): string { + return ( + err?.error?.errors?.[0]?.defaultUserMessage || + err?.error?.defaultUserMessage || + err?.error?.developerMessage || + fallback + ); + } +} diff --git a/src/app/shares/reports/share-folio-statement/share-folio-statement.component.html b/src/app/shares/reports/share-folio-statement/share-folio-statement.component.html new file mode 100644 index 000000000..55f5134b1 --- /dev/null +++ b/src/app/shares/reports/share-folio-statement/share-folio-statement.component.html @@ -0,0 +1,250 @@ + + +

+
+
+ +
+

Folio Statement

+
+ A member's folio in the share register — every account they hold, with the movements running across all of + them. +
+
+
+
+ + +
+
+ +
+
+ +
+ + +
+
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+ + @if (loadError) { +
{{ loadError }}
+ } @else if (searching) { +
Loading the folio…
+ } @else if (matches.length) { +
{{ matches.length }} members match. Choose one:
+
+ + + + + + + + + + @for (m of matches; track m.clientId) { + + + + + + } + +
MemberFolio No
{{ m.clientName }}{{ m.cifNumber || '—' }}Open
+
+ } @else if (searched && !folio) { +
No member with a share account matches "{{ search }}".
+ } @else if (folio) { +
+
+
+
Folio No
+
{{ folio.folioNumber || '—' }}
+
+
+
Member
+
{{ folio.clientName }}
+
+
+
Branch
+
{{ folio.officeName || '—' }}
+
+
+
Mobile
+
{{ folio.mobileNo || '—' }}
+
+
+
Member Since
+
{{ folio.memberSince || '—' }}
+
+
+
+ + @if (totals) { +
+
+ +
+
{{ totals.accountCount | number }}
+
Accounts In Folio
+
+
+
+ +
+
{{ totals.sharesHeld | number }}
+
Shares Held
+
+
+
+ +
+
₹{{ totals.shareValue | number: '1.2-2' }}
+
Folio Value
+
+
+
+ +
+
{{ totals.movementCount | number }}
+
Movements Shown
+
+
+
+ } + + +
+
+
+
+ + + + + + + + + + + + + + + @for (a of accounts; track a.shareAccountId) { + + + + + + + + + + + } + + + + + + + + + + +
Share A/c NoProductStateShares HeldUnit PriceValueActivatedClosed
{{ a.accountNo }}{{ a.productName }} + {{ + stateLabel(a.holdingState) + }} + + {{ a.sharesHeld | number }} + @if (a.holdingState === 'UNPAID') { +
{{ a.unpaidShares | number }} unpaid
+ } +
{{ a.unitPrice | number: '1.2-2' }}₹{{ a.shareValue | number: '1.2-2' }}{{ a.activatedDate || '—' }}{{ a.closedDate || '—' }}
Folio total{{ totals?.sharesHeld | number }}₹{{ totals?.shareValue | number: '1.2-2' }}
+
+ + +
+
+
+ @if (!lines.length) { +
+ No settled movement in this folio{{ fromDate || toDate ? ' in the selected period' : '' }}. +
+ } @else { +
+ + + + + + + + + + + + + + + + @for (line of lines; track line.transactionId) { + + + + + + + + + + + + } + + + + + + + +
DateShare A/c NoProductParticularsShares InShares OutAmountFunded ByFolio Balance
{{ line.transactionDate }}{{ line.accountNo }}{{ line.productName }} + {{ typeLabel(line.typeEnum) }} + {{ line.sharesIn ? (line.sharesIn | number) : '' }}{{ line.sharesOut ? (line.sharesOut | number) : '' }}₹{{ line.amount | number: '1.2-2' }}{{ funding(line.fundingSource) }}{{ line.folioBalance | number }}
Closing folio balance{{ totals?.closingFolioBalance | number }}
+
+ } + } @else { +
Search for a member above to open their folio.
+ } +
diff --git a/src/app/shares/reports/share-folio-statement/share-folio-statement.component.ts b/src/app/shares/reports/share-folio-statement/share-folio-statement.component.ts new file mode 100644 index 000000000..07539a841 --- /dev/null +++ b/src/app/shares/reports/share-folio-statement/share-folio-statement.component.ts @@ -0,0 +1,217 @@ +/** + * 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 { ActivatedRoute, Router } from '@angular/router'; +import { FaIconComponent } from '@fortawesome/angular-fontawesome'; +import { of } from 'rxjs'; +import { catchError } from 'rxjs/operators'; + +import { SharesService } from '../../shares.service'; +import { + SHARE_TXN_TYPE, + fundingLabel, + holdingStateClass, + holdingStateLabel, + holdingStateNote, + holdingStateRowClass, + shareReportCsv, + shareReportPrint +} from '../share-report.util'; + +/** + * Folio Statement — one member's folio in the share register: every account they hold and every + * movement across all of them, in one running sequence. + * + *

+ * Where the Account Statement is one account, a folio is the member. Someone holding two share + * classes has two accounts but one folio, and the register is kept by folio — so the running balance + * here is across accounts, which is the figure the register itself carries. + */ +@Component({ + selector: 'mifosx-share-folio-statement', + templateUrl: './share-folio-statement.component.html', + styleUrls: [ + '../../../letter-mgmt/certificates/certificates.shared.scss', + '../share-report.shared.scss' + ], + imports: [ + CommonModule, + FormsModule, + FaIconComponent + ] +}) +export class ShareFolioStatementComponent implements OnInit { + private sharesService = inject(SharesService); + private route = inject(ActivatedRoute); + private router = inject(Router); + + search = ''; + searching = false; + searched = false; + loadError = ''; + + matches: any[] = []; + folio: any = null; + accounts: any[] = []; + lines: any[] = []; + totals: any = null; + + fromDate = ''; + toDate = ''; + + /** Held once a folio is open, so changing the period re-queries the same member. */ + private resolvedClientId: number | null = null; + + ngOnInit(): void { + const clientId = this.route.snapshot.queryParamMap.get('clientId'); + if (clientId) { + this.resolvedClientId = Number(clientId); + this.fetch({ clientId: this.resolvedClientId }); + } + } + + runSearch(): void { + const term = this.search.trim(); + if (!term) { + return; + } + this.resolvedClientId = null; + this.fetch({ search: term }); + } + + loadByClient(clientId: number): void { + this.resolvedClientId = clientId; + this.fetch({ clientId }); + } + + reloadPeriod(): void { + if (this.resolvedClientId) { + this.fetch({ clientId: this.resolvedClientId }); + } + } + + clearPeriod(): void { + this.fromDate = ''; + this.toDate = ''; + this.reloadPeriod(); + } + + private fetch(params: { search?: string; clientId?: number }): void { + this.searching = true; + this.loadError = ''; + this.sharesService + .getShareFolioStatement({ ...params, fromDate: this.fromDate, toDate: this.toDate }) + .pipe( + catchError((err) => { + this.loadError = this.readError(err, 'The folio statement could not be loaded.'); + return of(null); + }) + ) + .subscribe((res: any) => { + this.matches = res?.matches || []; + this.folio = res?.folio || null; + this.accounts = res?.accounts || []; + this.lines = res?.lines || []; + this.totals = res?.totals || null; + if (this.folio) { + this.resolvedClientId = this.folio.clientId; + } + this.searching = false; + this.searched = true; + }); + } + + typeLabel(typeEnum: number): string { + return SHARE_TXN_TYPE[typeEnum]?.label || String(typeEnum ?? ''); + } + + typeClass(typeEnum: number): string { + return SHARE_TXN_TYPE[typeEnum]?.cls || 'srep-pill--grey'; + } + + funding(source: string): string { + return fundingLabel(source); + } + + stateLabel(state: string): string { + return holdingStateLabel(state); + } + + stateClass(state: string): string { + return holdingStateClass(state); + } + + stateRowClass(state: string): string { + return holdingStateRowClass(state); + } + + stateNote(state: string): string { + return holdingStateNote(state); + } + + openAccount(account: any): void { + this.router.navigate([ + '/clients', + this.folio.clientId, + 'shares-accounts', + account.shareAccountId, + 'general' + ]); + } + + exportCsv(): void { + if (!this.folio) { + return; + } + shareReportCsv( + `folio-statement-${this.folio.folioNumber || this.folio.clientId}`, + [ + 'Date', + 'Share A/c No', + 'Product', + 'Particulars', + 'Shares In', + 'Shares Out', + 'Unit Price', + 'Amount', + 'Funded By', + 'Board Resolution', + 'Folio Balance' + ], + this.lines.map((l) => [ + l.transactionDate, + l.accountNo, + l.productName, + this.typeLabel(l.typeEnum), + l.sharesIn || '', + l.sharesOut || '', + l.unitPrice, + l.amount, + this.funding(l.fundingSource), + l.resolutionNumber, + l.folioBalance + ]) + ); + } + + print(): void { + shareReportPrint(); + } + + private readError(err: any, fallback: string): string { + return ( + err?.error?.errors?.[0]?.defaultUserMessage || + err?.error?.defaultUserMessage || + err?.error?.developerMessage || + fallback + ); + } +} diff --git a/src/app/shares/reports/share-guarantor-report/share-guarantor-report.component.html b/src/app/shares/reports/share-guarantor-report/share-guarantor-report.component.html new file mode 100644 index 000000000..3c542cdb0 --- /dev/null +++ b/src/app/shares/reports/share-guarantor-report/share-guarantor-report.component.html @@ -0,0 +1,162 @@ + + +

+
+
+ +
+

Guarantor Detail

+
+ Everyone standing surety on a live loan, with the shareholding they hold in the society. +
+
+
+
+ + +
+
+ + @if (totals) { +
+
+ +
+
{{ totals.guarantorCount | number }}
+
Active Guarantors
+
+
+
+ +
+
₹{{ totals.guaranteedOutstanding | number: '1.2-2' }}
+
Guaranteed Outstanding
+
+
+
+ +
+
₹{{ totals.guarantorShareValue | number: '1.2-2' }}
+
Guarantors' Share Value
+
+
+
+ +
+
{{ totals.membersWithoutShares | number }}
+
Members Without Shares
+
+
+
+ } + +
+
+ +
+ + +
+
+
+ + +
+
+ + +
+
+ + +
+
+ + @if (loading) { +
Loading guarantors…
+ } @else if (loadError) { +
{{ loadError }}
+ } @else if (!rows.length) { +
No guarantor stands on a loan at present.
+ } @else if (!filteredRows.length) { +
No guarantor matches the filters applied.
+ } @else { +
+ + + + + + + + + + + + + + + + @for (row of filteredRows; track row.guarantorId) { + + + + + + + + + + + + } + + + + + + + + + + +
GuarantorTypeMembership NoBorrowerLoan A/c NoLoan OutstandingShare A/c NoShares HeldShare Value
+ {{ row.guarantorName || '—' }} + @if (row.guarantorMobile) { +
{{ row.guarantorMobile }}
+ } +
+ {{ row.guarantorType === 'MEMBER' ? 'Member' : 'External' }} + {{ row.guarantorCif || '—' }}{{ row.borrowerName }}{{ row.loanAccountNo }}₹{{ row.totalOutstanding | number: '1.2-2' }}{{ row.shareAccountNo || '—' }} + @if (isUncovered(row)) { + No shares + } @else { + {{ row.sharesHeld | number }} + } + ₹{{ row.shareValue | number: '1.2-2' }}
+ {{ isFiltered ? 'Total (filtered)' : 'Total' }} — {{ filteredRows.length | number }} guarantors + ₹{{ shownOutstanding | number: '1.2-2' }}₹{{ shownShareValue | number: '1.2-2' }}
+
+ } +
diff --git a/src/app/shares/reports/share-guarantor-report/share-guarantor-report.component.ts b/src/app/shares/reports/share-guarantor-report/share-guarantor-report.component.ts new file mode 100644 index 000000000..3f62e7025 --- /dev/null +++ b/src/app/shares/reports/share-guarantor-report/share-guarantor-report.component.ts @@ -0,0 +1,208 @@ +/** + * Copyright since 2025 Mifos Initiative + * + * This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. + */ + +import { Component, OnInit, inject } from '@angular/core'; +import { CommonModule } from '@angular/common'; +import { FormsModule } from '@angular/forms'; +import { Router } from '@angular/router'; +import { FaIconComponent } from '@fortawesome/angular-fontawesome'; +import { of } from 'rxjs'; +import { catchError } from 'rxjs/operators'; + +import { SharesService } from '../../shares.service'; +import { shareReportCsv, shareReportPrint } from '../share-report.util'; + +interface GuarantorRow { + guarantorId: number; + loanId: number; + loanAccountNo: string; + loanStatusId: number; + loanPrincipal: number; + principalOutstanding: number; + totalOutstanding: number; + borrowerName: string; + borrowerCif: string; + guarantorTypeEnum: number; + guarantorType: 'MEMBER' | 'EXTERNAL'; + guarantorName: string; + guarantorCif: string; + guarantorClientId: number; + guarantorOfficeName: string; + guarantorMobile: string; + shareAccountId: number; + shareAccountNo: string; + shareProductName: string; + sharesHeld: number; + shareValue: number; +} + +/** + * Guarantor Detail — everyone standing surety on a live loan, with the shareholding they hold in the + * society. + * + *

+ * A co-operative accepts a member as surety on the strength of their stake in it, so the exposure + * and the stake belong on one line. The case this report exists for is the member who stood surety + * and has since redeemed their shares: nothing on the loan screen or the share screen shows it, and + * it is flagged here rather than left to be noticed. + */ +@Component({ + selector: 'mifosx-share-guarantor-report', + templateUrl: './share-guarantor-report.component.html', + styleUrls: [ + '../../../letter-mgmt/certificates/certificates.shared.scss', + '../share-report.shared.scss' + ], + imports: [ + CommonModule, + FormsModule, + FaIconComponent + ] +}) +export class ShareGuarantorReportComponent implements OnInit { + private sharesService = inject(SharesService); + private router = inject(Router); + + loading = true; + loadError = ''; + rows: GuarantorRow[] = []; + totals: any = null; + + search = ''; + typeFilter = ''; + /** Members standing surety with no shareholding left — the exposure worth isolating. */ + onlyWithoutShares = false; + + ngOnInit(): void { + this.load(); + } + + load(): void { + this.loading = true; + this.loadError = ''; + this.sharesService + .getShareGuarantorDetail({}) + .pipe( + catchError((err) => { + this.loadError = this.readError(err, 'The guarantor report could not be loaded.'); + return of(null); + }) + ) + .subscribe((res: any) => { + this.rows = res?.rows || []; + this.totals = res?.totals || null; + this.loading = false; + }); + } + + get filteredRows(): GuarantorRow[] { + const term = this.search.trim().toLowerCase(); + return this.rows.filter((r) => { + if (this.typeFilter && r.guarantorType !== this.typeFilter) { + return false; + } + if (this.onlyWithoutShares && !(r.guarantorType === 'MEMBER' && !Number(r.sharesHeld))) { + return false; + } + if (!term) { + return true; + } + return [ + r.guarantorName, + r.guarantorCif, + r.borrowerName, + r.loanAccountNo, + r.shareAccountNo + ] + .filter(Boolean) + .some((field) => String(field).toLowerCase().includes(term)); + }); + } + + get shownOutstanding(): number { + return this.filteredRows.reduce((sum, r) => sum + Number(r.totalOutstanding || 0), 0); + } + + get shownShareValue(): number { + return this.filteredRows.reduce((sum, r) => sum + Number(r.shareValue || 0), 0); + } + + get isFiltered(): boolean { + return !!(this.search.trim() || this.typeFilter || this.onlyWithoutShares); + } + + /** A member guarantor holding nothing is the exposure; an external one never held any. */ + isUncovered(row: GuarantorRow): boolean { + return row.guarantorType === 'MEMBER' && !Number(row.sharesHeld); + } + + openGuarantorShares(row: GuarantorRow): void { + if (row.guarantorClientId && row.shareAccountId) { + this.router.navigate([ + '/clients', + row.guarantorClientId, + 'shares-accounts', + row.shareAccountId, + 'general' + ]); + } + } + + clearFilters(): void { + this.search = ''; + this.typeFilter = ''; + this.onlyWithoutShares = false; + } + + exportCsv(): void { + shareReportCsv( + 'share-guarantor-detail', + [ + 'Guarantor', + 'Type', + 'Membership No', + 'Branch', + 'Mobile', + 'Borrower', + 'Loan A/c No', + 'Loan Outstanding', + 'Share A/c No', + 'Share Product', + 'Shares Held', + 'Share Value' + ], + this.filteredRows.map((r) => [ + r.guarantorName, + r.guarantorType, + r.guarantorCif, + r.guarantorOfficeName, + r.guarantorMobile, + r.borrowerName, + r.loanAccountNo, + r.totalOutstanding, + r.shareAccountNo, + r.shareProductName, + r.sharesHeld, + r.shareValue + ]) + ); + } + + print(): void { + shareReportPrint(); + } + + private readError(err: any, fallback: string): string { + return ( + err?.error?.errors?.[0]?.defaultUserMessage || + err?.error?.defaultUserMessage || + err?.error?.developerMessage || + fallback + ); + } +} diff --git a/src/app/shares/reports/share-issued-report/share-issued-report.component.html b/src/app/shares/reports/share-issued-report/share-issued-report.component.html new file mode 100644 index 000000000..74801229d --- /dev/null +++ b/src/app/shares/reports/share-issued-report/share-issued-report.component.html @@ -0,0 +1,204 @@ + + +

+
+
+ +
+

Share Issued Register

+
+ Every tranche of shares allotted in the period, with the board resolution behind it and how it was paid for. +
+
+
+
+ + +
+
+ + @if (totals) { +
+
+ +
+
{{ totals.trancheCount | number }}
+
Allotments
+
+
+
+ +
+
{{ totals.shares | number }}
+
Shares Issued
+
+
+
+ +
+
₹{{ totals.amount | number: '1.2-2' }}
+
Capital Raised
+
+
+
+ +
+
{{ missingResolutionCount | number }}
+
Without Resolution
+
+
+
+ } + +
+
+ + +
+
+ + +
+
+ + +
+
+ +
+ + +
+
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+ + @if (loading) { +
Loading the issue register…
+ } @else if (loadError) { +
{{ loadError }}
+ } @else if (!rows.length) { +
No shares were allotted between {{ fromDate }} and {{ toDate }}.
+ } @else if (!filteredRows.length) { +
No allotment matches the filters applied.
+ } @else { +
+ + + + + + + + + + + + + + + + + + + + @for (row of filteredRows; track row.transactionId) { + + + + + + + + + + + + + + + + } + + + + + + + + + + + +
Issue DateMemberMembership NoShare A/c NoBranchProductSharesUnit PriceAmountEntry FeeFunded ByBoard ResolutionStatus
{{ row.issueDate }}{{ row.clientName }}{{ row.cifNumber || '—' }}{{ row.accountNo }}{{ row.officeName || '—' }}{{ row.productName }}{{ row.shares | number }}{{ row.unitPrice | number: '1.2-2' }}{{ row.amount | number: '1.2-2' }}{{ row.chargeAmount ? (row.chargeAmount | number: '1.2-2') : '—' }}{{ funding(row.fundingSource) }} + @if (row.resolutionNumber) { + {{ row.resolutionNumber }} + @if (row.meetingDate) { +
{{ row.meetingDate }}
+ } + } @else { + Not recorded + } +
+ {{ + txnStatusLabel(row.statusEnum) + }} +
+ {{ isFiltered ? 'Total (filtered)' : 'Total' }} — {{ filteredRows.length | number }} allotments + {{ shownShares | number }}₹{{ shownAmount | number: '1.2-2' }}
+
+ + @if (isFiltered && totals) { +
+ Showing a filtered view. Whole period {{ fromDate }} to {{ toDate }}: {{ totals.shares | number }} shares issued + to {{ totals.memberCount | number }} members across {{ totals.trancheCount | number }} allotments, ₹{{ + totals.amount | number: '1.2-2' + }}. +
+ } + } +
diff --git a/src/app/shares/reports/share-issued-report/share-issued-report.component.ts b/src/app/shares/reports/share-issued-report/share-issued-report.component.ts new file mode 100644 index 000000000..abefc22c0 --- /dev/null +++ b/src/app/shares/reports/share-issued-report/share-issued-report.component.ts @@ -0,0 +1,264 @@ +/** + * Copyright since 2025 Mifos Initiative + * + * This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. + */ + +import { Component, OnInit, inject } from '@angular/core'; +import { CommonModule } from '@angular/common'; +import { FormsModule } from '@angular/forms'; +import { Router } from '@angular/router'; +import { FaIconComponent } from '@fortawesome/angular-fontawesome'; +import { of } from 'rxjs'; +import { catchError } from 'rxjs/operators'; + +import { SharesService } from '../../shares.service'; +import { SettingsService } from 'app/settings/settings.service'; +import { SHARE_TXN_STATUS, fundingLabel, shareReportCsv, shareReportPrint } from '../share-report.util'; + +interface IssuedRow { + transactionId: number; + issueDate: string; + shareAccountId: number; + accountNo: string; + clientId: number; + cifNumber: string; + clientName: string; + officeId: number; + officeName: string; + productId: number; + productName: string; + currencyCode: string; + shares: number; + unitPrice: number; + amount: number; + chargeAmount: number; + statusEnum: number; + resolutionNumber: string; + meetingDate: string; + fundingSource: string; + accountStatusEnum: number; +} + +/** + * Share Issued — the allotment register: every tranche of shares issued in a period, with the board + * resolution that authorised it and how the member paid. + * + *

+ * One row per tranche, not per member: a member who bought three times was allotted three times, and + * the register records allotments. Rolling them up per member would lose the resolution each one was + * made under, which is the column an auditor actually reads. + * + *

+ * Period and status are server-side (the register is a period document, and "what is pending" is a + * different query, not a different view of the same rows); branch, product and search are local. + */ +@Component({ + selector: 'mifosx-share-issued-report', + templateUrl: './share-issued-report.component.html', + styleUrls: [ + '../../../letter-mgmt/certificates/certificates.shared.scss', + '../share-report.shared.scss' + ], + imports: [ + CommonModule, + FormsModule, + FaIconComponent + ] +}) +export class ShareIssuedReportComponent implements OnInit { + private sharesService = inject(SharesService); + private settingsService = inject(SettingsService); + private router = inject(Router); + + loading = true; + loadError = ''; + rows: IssuedRow[] = []; + totals: any = null; + + // ── Server-side ── + fromDate = ''; + toDate = ''; + /** '' = settled allotments only (the register's default); 100 = pending; 400 = rejected. */ + statusFilter: number | '' = ''; + + // ── Client-side ── + search = ''; + branchFilter = ''; + productFilter = ''; + fundingFilter = ''; + + ngOnInit(): void { + const businessDate: Date = this.settingsService.businessDate || new Date(); + // Opens on the current financial year, which is the period this register is almost always run + // for. April opens the FY in this system, matching the Share Capital Return. + const fyStart = new Date(businessDate.getFullYear(), businessDate.getMonth() >= 3 ? 3 : -9, 1); + this.fromDate = this.toIso(fyStart); + this.toDate = this.toIso(businessDate); + this.load(); + } + + load(): void { + this.loading = true; + this.loadError = ''; + this.sharesService + .getShareIssuedReport({ + fromDate: this.fromDate, + toDate: this.toDate, + status: this.statusFilter === '' ? undefined : this.statusFilter + }) + .pipe( + catchError((err) => { + this.loadError = this.readError(err, 'The share issue register could not be loaded.'); + return of(null); + }) + ) + .subscribe((res: any) => { + this.rows = res?.rows || []; + this.totals = res?.totals || null; + this.loading = false; + }); + } + + get branchOptions(): string[] { + return [...new Set(this.rows.map((r) => r.officeName).filter(Boolean))].sort(); + } + + get productOptions(): string[] { + return [...new Set(this.rows.map((r) => r.productName).filter(Boolean))].sort(); + } + + get fundingOptions(): string[] { + return [...new Set(this.rows.map((r) => fundingLabel(r.fundingSource)))].sort(); + } + + get filteredRows(): IssuedRow[] { + const term = this.search.trim().toLowerCase(); + return this.rows.filter((r) => { + if (this.branchFilter && r.officeName !== this.branchFilter) { + return false; + } + if (this.productFilter && r.productName !== this.productFilter) { + return false; + } + if (this.fundingFilter && fundingLabel(r.fundingSource) !== this.fundingFilter) { + return false; + } + if (!term) { + return true; + } + return [ + r.clientName, + r.accountNo, + r.cifNumber, + r.productName, + r.resolutionNumber + ] + .filter(Boolean) + .some((field) => String(field).toLowerCase().includes(term)); + }); + } + + get shownShares(): number { + return this.filteredRows.reduce((sum, r) => sum + Number(r.shares || 0), 0); + } + + get shownAmount(): number { + return this.filteredRows.reduce((sum, r) => sum + Number(r.amount || 0), 0); + } + + get isFiltered(): boolean { + return !!(this.search.trim() || this.branchFilter || this.productFilter || this.fundingFilter); + } + + /** Tranches with no resolution recorded — the one thing an auditor opens this register to find. */ + get missingResolutionCount(): number { + return this.filteredRows.filter((r) => !r.resolutionNumber).length; + } + + txnStatusLabel(statusEnum: number): string { + return SHARE_TXN_STATUS[statusEnum]?.label || String(statusEnum ?? ''); + } + + txnStatusClass(statusEnum: number): string { + return SHARE_TXN_STATUS[statusEnum]?.cls || 'srep-pill--grey'; + } + + funding(source: string): string { + return fundingLabel(source); + } + + clearFilters(): void { + this.search = ''; + this.branchFilter = ''; + this.productFilter = ''; + this.fundingFilter = ''; + } + + openAccount(row: IssuedRow): void { + this.router.navigate([ + '/clients', + row.clientId, + 'shares-accounts', + row.shareAccountId, + 'general' + ]); + } + + exportCsv(): void { + shareReportCsv( + `share-issued-${this.fromDate}-to-${this.toDate}`, + [ + 'Issue Date', + 'Member', + 'Membership No', + 'Share A/c No', + 'Branch', + 'Product', + 'Shares', + 'Unit Price', + 'Amount', + 'Entry Fee', + 'Funded By', + 'Board Resolution', + 'Meeting Date', + 'Status' + ], + this.filteredRows.map((r) => [ + r.issueDate, + r.clientName, + r.cifNumber, + r.accountNo, + r.officeName, + r.productName, + r.shares, + r.unitPrice, + r.amount, + r.chargeAmount, + this.funding(r.fundingSource), + r.resolutionNumber, + r.meetingDate, + this.txnStatusLabel(r.statusEnum) + ]) + ); + } + + print(): void { + shareReportPrint(); + } + + private toIso(date: Date): string { + return `${date.getFullYear()}-${String(date.getMonth() + 1).padStart(2, '0')}-${String(date.getDate()).padStart(2, '0')}`; + } + + private readError(err: any, fallback: string): string { + return ( + err?.error?.errors?.[0]?.defaultUserMessage || + err?.error?.defaultUserMessage || + err?.error?.developerMessage || + fallback + ); + } +} diff --git a/src/app/shares/reports/share-label/share-label.component.html b/src/app/shares/reports/share-label/share-label.component.html new file mode 100644 index 000000000..97f092a77 --- /dev/null +++ b/src/app/shares/reports/share-label/share-label.component.html @@ -0,0 +1,124 @@ + + +

+
+
+ +
+

Share Labels

+
+ Printable address labels for shareholding members — ready for an ordinary label sheet. +
+
+
+
+ + +
+
+ +
+
+ +
+ + +
+
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+ + @if (loading) { +
Loading the mailing list…
+ } @else if (loadError) { +
{{ loadError }}
+ } @else if (!filteredRows.length) { +
No shareholding member matches the filters applied.
+ } @else { +
+ {{ printableRows.length | number }} labels ready to print + @if (unaddressableRows.length) { + · {{ unaddressableRows.length | number }} member{{ unaddressableRows.length === 1 ? '' : 's' }} cannot be posted + to + } +
+ +
+ @for (row of printableRows; track row.clientId) { +
+
{{ row.clientName }}
+ @for (line of lines(row); track line) { +
{{ line }}
+ } + @if (showFolio && row.folioNumber) { +
Folio {{ row.folioNumber }}
+ } + @if (showHolding) { +
{{ row.sharesHeld | number }} shares · ₹{{ row.shareValue | number: '1.0-0' }}
+ } +
+ } +
+ + + @if (unaddressableRows.length) { +
+ + + {{ unaddressableRows.length | number }} shareholding member{{ + unaddressableRows.length === 1 ? ' has' : 's have' + }} + no address on file, so no label is printed for them: {{ unaddressableRows[0].clientName }} + @if (unaddressableRows.length > 1) { + and {{ unaddressableRows.length - 1 | number }} other{{ unaddressableRows.length > 2 ? 's' : '' }} + } + . Record an address against the member to include them in the next run. + +
+ } + } +
diff --git a/src/app/shares/reports/share-label/share-label.component.ts b/src/app/shares/reports/share-label/share-label.component.ts new file mode 100644 index 000000000..b9268f267 --- /dev/null +++ b/src/app/shares/reports/share-label/share-label.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 { CommonModule } from '@angular/common'; +import { FormsModule } from '@angular/forms'; +import { FaIconComponent } from '@fortawesome/angular-fontawesome'; +import { of } from 'rxjs'; +import { catchError } from 'rxjs/operators'; + +import { SharesService } from '../../shares.service'; +import { shareReportCsv, shareReportPrint } from '../share-report.util'; + +export interface MailingRow { + clientId: number; + folioNumber: string; + clientName: string; + mobileNo: string; + officeName: string; + addressLine1: string; + addressLine2: string; + street: string; + city: string; + district: string; + postalCode: string; + area: string; + sharesHeld: number; + shareValue: number; + shareAccountNos: string; + addressable: boolean; +} + +/** Assembles one member's address into the lines an envelope actually carries. */ +export function addressLines(row: MailingRow): string[] { + return [ + row.addressLine1, + row.addressLine2, + row.street, + [ + row.city, + row.district + ] + .filter(Boolean) + .join(', '), + row.postalCode ? `PIN ${row.postalCode}` : '' + ] + .map((line) => (line || '').trim()) + .filter(Boolean); +} + +/** + * Share Label — printable address labels for shareholding members. + * + *

+ * Laid out as a grid sized to ordinary label sheets and printed straight from the browser, so a + * mailing run needs no separate mail-merge tool. Members with no usable address are listed + * separately rather than printed as a blank label: a blank label is a wasted sheet and a member who + * silently never hears from the society. + */ +@Component({ + selector: 'mifosx-share-label', + templateUrl: './share-label.component.html', + styleUrls: [ + '../../../letter-mgmt/certificates/certificates.shared.scss', + '../share-report.shared.scss' + ], + imports: [ + CommonModule, + FormsModule, + FaIconComponent + ] +}) +export class ShareLabelComponent implements OnInit { + private sharesService = inject(SharesService); + + loading = true; + loadError = ''; + rows: MailingRow[] = []; + totals: any = null; + + search = ''; + areaFilter = ''; + /** Labels per row on the sheet — 2 and 3 cover the sheets a branch actually buys. */ + columns = 3; + showFolio = true; + showHolding = false; + + ngOnInit(): void { + this.load(); + } + + load(): void { + this.loading = true; + this.loadError = ''; + this.sharesService + .getShareMailingList({}) + .pipe( + catchError((err) => { + this.loadError = this.readError(err, 'The mailing list could not be loaded.'); + return of(null); + }) + ) + .subscribe((res: any) => { + this.rows = res?.rows || []; + this.totals = res?.totals || null; + this.loading = false; + }); + } + + get areaOptions(): string[] { + return [...new Set(this.rows.map((r) => r.area).filter(Boolean))].sort(); + } + + /** Only members who can actually be posted to — the rest are listed below, not printed. */ + get printableRows(): MailingRow[] { + return this.filteredRows.filter((r) => r.addressable); + } + + get unaddressableRows(): MailingRow[] { + return this.filteredRows.filter((r) => !r.addressable); + } + + get filteredRows(): MailingRow[] { + const term = this.search.trim().toLowerCase(); + return this.rows.filter((r) => { + if (this.areaFilter && r.area !== this.areaFilter) { + return false; + } + if (!term) { + return true; + } + return [ + r.clientName, + r.folioNumber, + r.area, + r.postalCode + ] + .filter(Boolean) + .some((field) => String(field).toLowerCase().includes(term)); + }); + } + + lines(row: MailingRow): string[] { + return addressLines(row); + } + + clearFilters(): void { + this.search = ''; + this.areaFilter = ''; + } + + exportCsv(): void { + shareReportCsv( + 'share-mailing-labels', + [ + 'Member', + 'Folio No', + 'Address', + 'City', + 'District', + 'Postal Code', + 'Mobile', + 'Shares Held', + 'Addressable' + ], + this.filteredRows.map((r) => [ + r.clientName, + r.folioNumber, + this.lines(r).join(' / '), + r.city, + r.district, + r.postalCode, + r.mobileNo, + r.sharesHeld, + r.addressable ? 'Yes' : 'No' + ]) + ); + } + + print(): void { + shareReportPrint(); + } + + private readError(err: any, fallback: string): string { + return ( + err?.error?.errors?.[0]?.defaultUserMessage || + err?.error?.defaultUserMessage || + err?.error?.developerMessage || + fallback + ); + } +} diff --git a/src/app/shares/reports/share-letter/share-letter.component.html b/src/app/shares/reports/share-letter/share-letter.component.html new file mode 100644 index 000000000..a30b55f7a --- /dev/null +++ b/src/app/shares/reports/share-letter/share-letter.component.html @@ -0,0 +1,171 @@ + + +

+
+
+ +
+

Share Letter

+
+ A letter to shareholding members, merged with their own details and printed — one page each. +
+
+
+
+ +
+
+ + +
+
+
+ + +
+
+ + +
+
+ +
+ + +
+ Merged per member: + @for (p of placeholders; track p) { + {{ p }}{{ $last ? '.' : ', ' }} + } + Anything else you write is sent as it stands. +
+
+
+ + +
+
+ +
+ + +
+
+
+ + +
+
+ + +
+
+ + @if (loading) { +
Loading the mailing list…
+ } @else if (loadError) { +
{{ loadError }}
+ } @else if (!filteredRows.length) { +
No shareholding member matches the filters applied.
+ } @else { +
+ + + + + + + + + + + + + @for (row of filteredRows; track row.clientId) { + + + + + + + + + } + +
MemberFolio NoAreaSharesPostable
+ + {{ row.clientName }}{{ row.folioNumber || '—' }}{{ row.area }}{{ row.sharesHeld | number }} + @if (row.addressable) { + Yes + } @else { + No address + } +
+
+ +
+ {{ recipients.length | number }} letter{{ recipients.length === 1 ? '' : 's' }} will print + @if (!selectedIds.length) { + (everyone shown — tick rows to narrow it) + } +
+ + + @for (row of recipients; track row.clientId) { +
+
+
{{ institution?.name || institution?.institutionName || 'Co-operative Bank' }}
+ @if (institution?.addressLine1 || institution?.address) { +
{{ institution?.addressLine1 || institution?.address }}
+ } +
+ +
+
+
{{ row.clientName }}
+ @for (line of lines(row); track line) { +
{{ line }}
+ } + @if (row.folioNumber) { +
Folio {{ row.folioNumber }}
+ } +
+
Date: {{ today }}
+
+ + @if (subject.trim()) { +
Subject: {{ subject }}
+ } + +
Dear {{ row.clientName }},
+
{{ merged(row) }}
+ +
+
Yours faithfully,
+
+
+
Authorised Signatory
+
+
+
+ } + } +
diff --git a/src/app/shares/reports/share-letter/share-letter.component.ts b/src/app/shares/reports/share-letter/share-letter.component.ts new file mode 100644 index 000000000..056cdb00a --- /dev/null +++ b/src/app/shares/reports/share-letter/share-letter.component.ts @@ -0,0 +1,227 @@ +/** + * 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 { FaIconComponent } from '@fortawesome/angular-fontawesome'; +import { of } from 'rxjs'; +import { catchError } from 'rxjs/operators'; + +import { SharesService } from '../../shares.service'; +import { OrganizationService } from 'app/organization/organization.service'; +import { MailingRow, addressLines } from '../share-label/share-label.component'; +import { shareReportPrint } from '../share-report.util'; + +/** The letters a society actually sends its shareholders, as starting text. */ +const TEMPLATES: { key: string; label: string; subject: string; body: string }[] = [ + { + key: 'AGM', + label: 'Notice of Annual General Meeting', + subject: 'Notice of the Annual General Meeting', + body: + 'You are hereby informed that the Annual General Meeting of the society will be held on {{MEETING_DATE}} at {{VENUE}}.\n\n' + + 'As a shareholding member holding {{SHARES}} shares (Folio {{FOLIO}}), you are entitled to attend and vote.\n\n' + + 'Your presence is requested.' + }, + { + key: 'DIVIDEND', + label: 'Dividend intimation', + subject: 'Dividend declared on your shareholding', + body: + 'We are pleased to inform you that the society has declared a dividend for the year {{YEAR}}.\n\n' + + 'Against your holding of {{SHARES}} shares (Folio {{FOLIO}}), the dividend due to you will be credited to your ' + + 'linked savings account. Please contact your branch if it is not received within fifteen days.' + }, + { + key: 'SHARE_CALL', + label: 'Call to complete shareholding', + subject: 'Completion of your share subscription', + body: + 'Our records show that your share account stands at {{SHARES}} shares (Folio {{FOLIO}}), which is below the ' + + 'minimum subscription your share class requires.\n\n' + + 'You are requested to complete your subscription at your branch at the earliest so that your membership rights ' + + 'remain unaffected.' + }, + { + key: 'BLANK', + label: 'Blank letter', + subject: '', + body: '' + } +]; + +/** + * Share Letter — a letter to shareholding members, merged with their own details and printed. + * + *

+ * Runs off the same mailing list the labels do, so the letter and the envelope always agree on who + * is being written to. Templates are starting text rather than fixed forms: the body is editable + * before printing, because the letter a society actually sends is never quite the one a system + * imagined. + * + *

+ * Placeholders are merged per member — {{NAME}}, {{FOLIO}}, {{SHARES}} and the rest — so one edit + * produces every letter, each addressed to the member it is going to. + */ +@Component({ + selector: 'mifosx-share-letter', + templateUrl: './share-letter.component.html', + styleUrls: [ + '../../../letter-mgmt/certificates/certificates.shared.scss', + '../share-report.shared.scss' + ], + imports: [ + CommonModule, + FormsModule, + FaIconComponent + ] +}) +export class ShareLetterComponent implements OnInit { + private sharesService = inject(SharesService); + private organizationService = inject(OrganizationService); + + loading = true; + loadError = ''; + rows: MailingRow[] = []; + institution: any = null; + + readonly templates = TEMPLATES; + templateKey = 'AGM'; + subject = TEMPLATES[0].subject; + body = TEMPLATES[0].body; + + search = ''; + areaFilter = ''; + /** Which members this run is for; empty means everyone shown. */ + selectedIds: number[] = []; + + ngOnInit(): void { + this.sharesService + .getShareMailingList({}) + .pipe( + catchError((err) => { + this.loadError = this.readError(err, 'The mailing list could not be loaded.'); + return of(null); + }) + ) + .subscribe((res: any) => { + this.rows = res?.rows || []; + this.loading = false; + }); + + this.organizationService + .getInstitutionProfile() + .pipe(catchError(() => of(null))) + .subscribe((res: any) => (this.institution = res)); + } + + applyTemplate(): void { + const template = TEMPLATES.find((t) => t.key === this.templateKey); + if (template) { + this.subject = template.subject; + this.body = template.body; + } + } + + get areaOptions(): string[] { + return [...new Set(this.rows.map((r) => r.area).filter(Boolean))].sort(); + } + + get filteredRows(): MailingRow[] { + const term = this.search.trim().toLowerCase(); + return this.rows.filter((r) => { + if (this.areaFilter && r.area !== this.areaFilter) { + return false; + } + if (!term) { + return true; + } + return [ + r.clientName, + r.folioNumber, + r.area + ] + .filter(Boolean) + .some((field) => String(field).toLowerCase().includes(term)); + }); + } + + /** Nothing ticked means the whole filtered list — the common case is "write to all of these". */ + get recipients(): MailingRow[] { + if (!this.selectedIds.length) { + return this.filteredRows; + } + return this.filteredRows.filter((r) => this.selectedIds.includes(r.clientId)); + } + + isSelected(clientId: number): boolean { + return this.selectedIds.includes(clientId); + } + + toggle(clientId: number): void { + this.selectedIds = this.isSelected(clientId) ? this.selectedIds.filter((id) => id !== clientId) : [ + ...this.selectedIds, + clientId + ]; + } + + selectAll(): void { + this.selectedIds = []; + } + + lines(row: MailingRow): string[] { + return addressLines(row); + } + + /** What one member's letter substitutes, keyed by the token that stands for it in the body. */ + private mergeFields(row: MailingRow): Record { + return { + NAME: row.clientName || '', + FOLIO: row.folioNumber || '', + SHARES: Number(row.sharesHeld || 0).toLocaleString('en-IN'), + SHARE_VALUE: Number(row.shareValue || 0).toLocaleString('en-IN'), + SHARE_ACCOUNTS: row.shareAccountNos || '', + BRANCH: row.officeName || '', + AREA: row.area || '' + }; + } + + /** + * The tokens the editor's help text offers, derived from the merge itself so the two can never + * drift apart — and assembled here rather than written in the template, because a literal + * `{{NAME}}` in an Angular template is an expression, not the text a letter-writer needs to see. + */ + readonly placeholders = Object.keys(this.mergeFields({} as MailingRow)).map((key) => `{{${key}}}`); + + /** The member's own details merged into the body, one letter at a time. */ + merged(row: MailingRow): string { + const fields = this.mergeFields(row); + // An unrecognised token is left standing rather than blanked: a letter that visibly still says + // {{VENUE}} tells the writer to fill it in; one silently emptied goes out with a hole in it. + return (this.body || '').replace(/\{\{([A-Z_]+)\}\}/g, (token, key: string) => fields[key] ?? token); + } + + get today(): string { + const now = new Date(); + return `${String(now.getDate()).padStart(2, '0')}-${String(now.getMonth() + 1).padStart(2, '0')}-${now.getFullYear()}`; + } + + print(): void { + shareReportPrint(); + } + + private readError(err: any, fallback: string): string { + return ( + err?.error?.errors?.[0]?.defaultUserMessage || + err?.error?.defaultUserMessage || + err?.error?.developerMessage || + fallback + ); + } +} diff --git a/src/app/shares/reports/share-member-interest-report/share-member-interest-report.component.html b/src/app/shares/reports/share-member-interest-report/share-member-interest-report.component.html new file mode 100644 index 000000000..166169c80 --- /dev/null +++ b/src/app/shares/reports/share-member-interest-report/share-member-interest-report.component.html @@ -0,0 +1,166 @@ + + +

+
+
+ +
+

Total Interest Received & Receivable

+
+ Interest earned from the borrowings of the members who hold the society's share capital. +
+
+
+
+ + +
+
+ + @if (totals) { +
+
+ +
+
₹{{ totals.interestReceived | number: '1.2-2' }}
+
Interest Received
+
+
+
+ +
+
₹{{ totals.interestReceivable | number: '1.2-2' }}
+
Interest Receivable
+
+
+
+ +
+
{{ totals.borrowingMembers | number }} / {{ totals.memberCount | number }}
+
Borrowing Members
+
+
+
+ +
+
₹{{ totals.shareValue | number: '1.2-2' }}
+
Their Share Capital
+
+
+
+ } + +
+
+ +
+ + +
+
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+ + @if (loading) { +
Loading interest figures…
+ } @else if (loadError) { +
{{ loadError }}
+ } @else if (!rows.length) { +
No member holds share capital yet.
+ } @else if (!filteredRows.length) { +
No member matches the filters applied.
+ } @else { +
+ + + + + + + + + + + + + + + + + + + @for (row of filteredRows; track row.clientId) { + + + + + + + + + + + + + + + } + + + + + + + + + + + + + + + +
MemberMembership NoBranchSharesShare ValueLoansPrincipal O/SInterest ChargedInterest ReceivedInterest ReceivableWaivedWritten Off
{{ row.clientName }}{{ row.cifNumber || '—' }}{{ row.officeName || '—' }}{{ row.sharesHeld | number }}₹{{ row.shareValue | number: '1.2-2' }}{{ row.loanCount | number }}₹{{ row.principalOutstanding | number: '1.2-2' }}₹{{ row.interestCharged | number: '1.2-2' }}₹{{ row.interestReceived | number: '1.2-2' }}₹{{ row.interestReceivable | number: '1.2-2' }}{{ row.interestWaived ? '₹' + (row.interestWaived | number: '1.2-2') : '—' }} + {{ row.interestWrittenOff ? '₹' + (row.interestWrittenOff | number: '1.2-2') : '—' }} +
+ {{ isFiltered ? 'Total (filtered)' : 'Total' }} — {{ filteredRows.length | number }} members + {{ sum('sharesHeld') | number }}₹{{ sum('shareValue') | number: '1.2-2' }}{{ sum('loanCount') | number }}₹{{ sum('principalOutstanding') | number: '1.2-2' }}₹{{ sum('interestCharged') | number: '1.2-2' }}₹{{ sum('interestReceived') | number: '1.2-2' }}₹{{ sum('interestReceivable') | number: '1.2-2' }}₹{{ sum('interestWaived') | number: '1.2-2' }}₹{{ sum('interestWrittenOff') | number: '1.2-2' }}
+
+ } +
diff --git a/src/app/shares/reports/share-member-interest-report/share-member-interest-report.component.ts b/src/app/shares/reports/share-member-interest-report/share-member-interest-report.component.ts new file mode 100644 index 000000000..e87fe1544 --- /dev/null +++ b/src/app/shares/reports/share-member-interest-report/share-member-interest-report.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 { CommonModule } from '@angular/common'; +import { FormsModule } from '@angular/forms'; +import { Router } from '@angular/router'; +import { FaIconComponent } from '@fortawesome/angular-fontawesome'; +import { of } from 'rxjs'; +import { catchError } from 'rxjs/operators'; + +import { SharesService } from '../../shares.service'; +import { shareReportCsv, shareReportPrint } from '../share-report.util'; + +interface MemberInterestRow { + clientId: number; + cifNumber: string; + clientName: string; + officeName: string; + sharesHeld: number; + shareValue: number; + loanCount: number; + principalLent: number; + principalOutstanding: number; + interestCharged: number; + interestReceived: number; + interestReceivable: number; + interestWaived: number; + interestWrittenOff: number; +} + +/** + * Total Interest Received & Receivable — what the society has earned in interest from the borrowings + * of the members who hold its share capital. + * + *

+ * Shares earn dividend, not interest; this is the other side of the member relationship. A member's + * stake and what their lending returns is the case for admitting and keeping them, and a + * co-operative reads the two together. + * + *

+ * Received and receivable stay in separate columns rather than being summed — interest charged but + * not yet collected is not income in hand, and a single "interest" figure hides exactly that. + */ +@Component({ + selector: 'mifosx-share-member-interest-report', + templateUrl: './share-member-interest-report.component.html', + styleUrls: [ + '../../../letter-mgmt/certificates/certificates.shared.scss', + '../share-report.shared.scss' + ], + imports: [ + CommonModule, + FormsModule, + FaIconComponent + ] +}) +export class ShareMemberInterestReportComponent implements OnInit { + private sharesService = inject(SharesService); + private router = inject(Router); + + loading = true; + loadError = ''; + rows: MemberInterestRow[] = []; + totals: any = null; + + /** Server-side: a closed loan's interest is history, not a position. */ + includeClosedLoans = false; + + search = ''; + branchFilter = ''; + /** Members who hold shares but have never borrowed are noise when the question is interest. */ + onlyBorrowers = false; + + ngOnInit(): void { + this.load(); + } + + load(): void { + this.loading = true; + this.loadError = ''; + this.sharesService + .getShareMemberLoanInterest({ includeClosedLoans: this.includeClosedLoans }) + .pipe( + catchError((err) => { + this.loadError = this.readError(err, 'The interest report could not be loaded.'); + return of(null); + }) + ) + .subscribe((res: any) => { + this.rows = res?.rows || []; + this.totals = res?.totals || null; + this.loading = false; + }); + } + + get branchOptions(): string[] { + return [...new Set(this.rows.map((r) => r.officeName).filter(Boolean))].sort(); + } + + get filteredRows(): MemberInterestRow[] { + const term = this.search.trim().toLowerCase(); + return this.rows.filter((r) => { + if (this.branchFilter && r.officeName !== this.branchFilter) { + return false; + } + if (this.onlyBorrowers && !Number(r.loanCount)) { + return false; + } + if (!term) { + return true; + } + return [ + r.clientName, + r.cifNumber + ] + .filter(Boolean) + .some((field) => String(field).toLowerCase().includes(term)); + }); + } + + sum(field: keyof MemberInterestRow): number { + return this.filteredRows.reduce((total, row) => total + Number(row[field] || 0), 0); + } + + get isFiltered(): boolean { + return !!(this.search.trim() || this.branchFilter || this.onlyBorrowers); + } + + openMember(row: MemberInterestRow): void { + this.router.navigate([ + '/clients', + row.clientId, + 'general' + ]); + } + + clearFilters(): void { + this.search = ''; + this.branchFilter = ''; + this.onlyBorrowers = false; + } + + exportCsv(): void { + shareReportCsv( + 'share-member-interest', + [ + 'Member', + 'Membership No', + 'Branch', + 'Shares Held', + 'Share Value', + 'Loans', + 'Principal Lent', + 'Principal Outstanding', + 'Interest Charged', + 'Interest Received', + 'Interest Receivable', + 'Interest Waived', + 'Interest Written Off' + ], + this.filteredRows.map((r) => [ + r.clientName, + r.cifNumber, + r.officeName, + r.sharesHeld, + r.shareValue, + r.loanCount, + r.principalLent, + r.principalOutstanding, + r.interestCharged, + r.interestReceived, + r.interestReceivable, + r.interestWaived, + r.interestWrittenOff + ]) + ); + } + + print(): void { + shareReportPrint(); + } + + private readError(err: any, fallback: string): string { + return ( + err?.error?.errors?.[0]?.defaultUserMessage || + err?.error?.defaultUserMessage || + err?.error?.developerMessage || + fallback + ); + } +} diff --git a/src/app/shares/reports/share-nominal-report/share-nominal-report.component.html b/src/app/shares/reports/share-nominal-report/share-nominal-report.component.html new file mode 100644 index 000000000..9891d0e39 --- /dev/null +++ b/src/app/shares/reports/share-nominal-report/share-nominal-report.component.html @@ -0,0 +1,244 @@ + + +

+
+
+ +
+

Nominal Share Report

+
+ Each member's holding at face value, measured against the share class's nominal, minimum and maximum. +
+
+
+
+ + +
+
+ + @if (totals) { +
+
+ +
+
₹{{ totals.nominalValue | number: '1.2-2' }}
+
Nominal Capital
+
+
+
+ +
+
{{ totals.regularMembers | number }}
+
Regular Members
+
+
+
+ +
+
{{ totals.nominalMembers | number }}
+
Nominal Members
+
+
+
+ +
+
{{ totals.belowMinimum + totals.aboveMaximum | number }}
+
Outside Limits
+
+
+
+ } + + + @if (unpaidRows.length) { +
+ + + {{ unpaidRows.length | number }} {{ unpaidRows.length === 1 ? 'account has' : 'accounts have' }} shares + allotted but never collected — {{ unpaidShares | number }} shares in all. They are active, so they are + listed below and flagged in amber, but they hold nothing: the shares exist on paper only until the member's + collection is applied. + +
+ } + +
+
+ + +
+
+ + +
+
+ +
+ + +
+
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+ + @if (loading) { +
Loading nominal share positions…
+ } @else if (loadError) { +
{{ loadError }}
+ } @else if (!rows.length) { +
No share account existed as on {{ asOnDate }}.
+ } @else if (!filteredRows.length) { +
No member matches the filters applied.
+ } @else { + @if (hiddenByStateCount) { +
+ {{ hiddenByStateCount | number }} closed, rejected or not-yet-active + {{ hiddenByStateCount === 1 ? 'account is' : 'accounts are' }} not shown. Switch Account state to see + them. +
+ } + +
+ + + + + + + + + + + + + + + + + + + + + @for (row of filteredRows; track row.shareAccountId) { + + + + + + + + + + + + + + + + + } + + + + + + + + +
MemberMembership NoShare A/c NoProductStateHeldNominalvs NominalMinMaxFace ValueNominal ValueClassCompliance
{{ row.clientName }}{{ row.cifNumber || '—' }}{{ row.accountNo }}{{ row.productName }} + {{ stateLabel(row.holdingState) }} + + {{ row.sharesHeld | number }} + @if (row.holdingState === 'UNPAID') { +
{{ row.unpaidShares | number }} unpaid
+ } +
{{ row.nominalShares | number }}{{ row.againstNominal > 0 ? '+' : '' }}{{ row.againstNominal | number }}{{ row.minimumShares | number }}{{ row.maximumShares | number }}{{ row.faceValue | number: '1.2-2' }}₹{{ row.nominalValue | number: '1.2-2' }} + {{ + membershipLabel(row.membershipClass) + }} + + {{ + complianceLabel(row.complianceStatus) + }} +
+ {{ isFiltered ? 'Total (filtered)' : 'Total' }} — {{ filteredRows.length | number }} accounts + ₹{{ shownNominalValue | number: '1.2-2' }}
+
+ + @if (isFiltered && totals) { +
+ Showing a filtered view. Society-wide: ₹{{ totals.nominalValue | number: '1.2-2' }} nominal capital, + {{ totals.regularMembers | number }} regular and {{ totals.nominalMembers | number }} nominal members, + {{ totals.belowMinimum + totals.aboveMaximum | number }} outside the class's limits. +
+ } + } +
diff --git a/src/app/shares/reports/share-nominal-report/share-nominal-report.component.ts b/src/app/shares/reports/share-nominal-report/share-nominal-report.component.ts new file mode 100644 index 000000000..78506b617 --- /dev/null +++ b/src/app/shares/reports/share-nominal-report/share-nominal-report.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/. + */ + +import { Component, OnInit, inject } from '@angular/core'; +import { CommonModule } from '@angular/common'; +import { FormsModule } from '@angular/forms'; +import { Router } from '@angular/router'; +import { FaIconComponent } from '@fortawesome/angular-fontawesome'; +import { of } from 'rxjs'; +import { catchError } from 'rxjs/operators'; + +import { SharesService } from '../../shares.service'; +import { SettingsService } from 'app/settings/settings.service'; +import { + SHARE_ACCOUNT_STATUS, + SHARE_HOLDING_STATE_ORDER, + holdingStateClass, + holdingStateLabel, + holdingStateNote, + holdingStateRowClass, + shareReportCsv, + shareReportPrint +} from '../share-report.util'; + +interface NominalRow { + shareAccountId: number; + accountNo: string; + clientId: number; + cifNumber: string; + clientName: string; + officeName: string; + productId: number; + productName: string; + statusEnum: number; + faceValue: number; + nominalShares: number; + minimumShares: number; + maximumShares: number; + sharesHeld: number; + nominalValue: number; + againstNominal: number; + complianceStatus: 'NO_HOLDING' | 'BELOW_MINIMUM' | 'ABOVE_MAXIMUM' | 'WITHIN_LIMITS' | 'NOT_APPLICABLE'; + membershipClass: 'NONE' | 'NOMINAL' | 'REGULAR' | 'NOT_APPLICABLE'; + holdingState: string; + unpaidShares: number; + activatedDate: string; +} + +/** What a compliance state is called and how it is flagged. */ +const COMPLIANCE: { [key: string]: { label: string; cls: string } } = { + WITHIN_LIMITS: { label: 'Within limits', cls: 'srep-pill--green' }, + BELOW_MINIMUM: { label: 'Below minimum', cls: 'srep-pill--red' }, + ABOVE_MAXIMUM: { label: 'Above maximum', cls: 'srep-pill--red' }, + NO_HOLDING: { label: 'No holding', cls: 'srep-pill--grey' }, + // A closed or rejected account is not subject to the class's minimum any more, so it is excused + // rather than reported as breaching it. + NOT_APPLICABLE: { label: 'Not applicable', cls: 'srep-pill--grey' } +}; + +const MEMBERSHIP: { [key: string]: { label: string; cls: string } } = { + REGULAR: { label: 'Regular', cls: 'srep-pill--green' }, + NOMINAL: { label: 'Nominal', cls: 'srep-pill--amber' }, + NONE: { label: '—', cls: 'srep-pill--grey' }, + NOT_APPLICABLE: { label: '—', cls: 'srep-pill--grey' } +}; + +/** + * Nominal Share Report — each member's holding read against the three numbers the share class + * itself sets: its nominal subscription, its minimum and its maximum. + * + *

+ * The report exists to find breaches, so the default view is every member and the breaches are + * flagged in place; the compliance filter is there for when the answer needed is only "who is + * short". + */ +@Component({ + selector: 'mifosx-share-nominal-report', + templateUrl: './share-nominal-report.component.html', + styleUrls: [ + '../../../letter-mgmt/certificates/certificates.shared.scss', + '../share-report.shared.scss' + ], + imports: [ + CommonModule, + FormsModule, + FaIconComponent + ] +}) +export class ShareNominalReportComponent implements OnInit { + private sharesService = inject(SharesService); + private settingsService = inject(SettingsService); + private router = inject(Router); + + loading = true; + loadError = ''; + rows: NominalRow[] = []; + totals: any = null; + stateCounts: any = null; + + asOnDate = ''; + search = ''; + branchFilter = ''; + productFilter = ''; + complianceFilter = ''; + membershipFilter = ''; + /** + * Which accounts to show. Defaults to the live ones — a compliance report is read as "who is + * short today", and a closed member cannot be short of anything. Closed and rejected accounts are + * one dropdown away rather than gone, and the count of what the default hides is shown above the + * table so the narrowing is never silent. + */ + stateFilter = 'LIVE'; + readonly stateOrder = SHARE_HOLDING_STATE_ORDER; + + ngOnInit(): void { + const businessDate: Date = this.settingsService.businessDate || new Date(); + this.asOnDate = this.toIso(businessDate); + this.load(); + } + + load(): void { + this.loading = true; + this.loadError = ''; + this.sharesService + .getShareNominalReport({ asOnDate: this.asOnDate }) + .pipe( + catchError((err) => { + this.loadError = this.readError(err, 'The nominal share report could not be loaded.'); + return of(null); + }) + ) + .subscribe((res: any) => { + this.rows = res?.rows || []; + this.totals = res?.totals || null; + this.stateCounts = res?.stateCounts || null; + this.loading = false; + }); + } + + /** Live means the account is still a going concern, whether or not it currently holds anything. */ + private isLive(row: NominalRow): boolean { + return row.holdingState === 'HOLDING' || row.holdingState === 'UNPAID' || row.holdingState === 'NIL'; + } + + /** How many accounts the default "live only" view is leaving out, so it can say so. */ + get hiddenByStateCount(): number { + return this.stateFilter === 'LIVE' ? this.rows.filter((r) => !this.isLive(r)).length : 0; + } + + get unpaidRows(): NominalRow[] { + return this.rows.filter((r) => r.holdingState === 'UNPAID'); + } + + get unpaidShares(): number { + return this.unpaidRows.reduce((sum, r) => sum + Number(r.unpaidShares || 0), 0); + } + + get branchOptions(): string[] { + return [...new Set(this.rows.map((r) => r.officeName).filter(Boolean))].sort(); + } + + get productOptions(): string[] { + return [...new Set(this.rows.map((r) => r.productName).filter(Boolean))].sort(); + } + + get filteredRows(): NominalRow[] { + const term = this.search.trim().toLowerCase(); + return this.rows.filter((r) => { + if (this.stateFilter === 'LIVE' ? !this.isLive(r) : this.stateFilter && r.holdingState !== this.stateFilter) { + return false; + } + if (this.branchFilter && r.officeName !== this.branchFilter) { + return false; + } + if (this.productFilter && r.productName !== this.productFilter) { + return false; + } + if (this.complianceFilter && r.complianceStatus !== this.complianceFilter) { + return false; + } + if (this.membershipFilter && r.membershipClass !== this.membershipFilter) { + return false; + } + if (!term) { + return true; + } + return [ + r.clientName, + r.accountNo, + r.cifNumber, + r.productName + ] + .filter(Boolean) + .some((field) => String(field).toLowerCase().includes(term)); + }); + } + + get shownNominalValue(): number { + return this.filteredRows.reduce((sum, r) => sum + Number(r.nominalValue || 0), 0); + } + + get isFiltered(): boolean { + return !!( + this.search.trim() || + this.branchFilter || + this.productFilter || + this.complianceFilter || + this.membershipFilter || + this.stateFilter !== 'LIVE' + ); + } + + stateLabel(state: string): string { + return holdingStateLabel(state); + } + + stateClass(state: string): string { + return holdingStateClass(state); + } + + stateRowClass(state: string): string { + return holdingStateRowClass(state); + } + + stateNote(state: string): string { + return holdingStateNote(state); + } + + complianceLabel(status: string): string { + return COMPLIANCE[status]?.label || status; + } + + complianceClass(status: string): string { + return COMPLIANCE[status]?.cls || 'srep-pill--grey'; + } + + membershipLabel(membership: string): string { + return MEMBERSHIP[membership]?.label || membership; + } + + membershipClass(membership: string): string { + return MEMBERSHIP[membership]?.cls || 'srep-pill--grey'; + } + + statusLabel(statusEnum: number): string { + return SHARE_ACCOUNT_STATUS[statusEnum]?.label || String(statusEnum ?? ''); + } + + statusClass(statusEnum: number): string { + return SHARE_ACCOUNT_STATUS[statusEnum]?.cls || 'srep-pill--grey'; + } + + clearFilters(): void { + this.search = ''; + this.branchFilter = ''; + this.productFilter = ''; + this.complianceFilter = ''; + this.membershipFilter = ''; + this.stateFilter = 'LIVE'; + } + + openAccount(row: NominalRow): void { + this.router.navigate([ + '/clients', + row.clientId, + 'shares-accounts', + row.shareAccountId, + 'general' + ]); + } + + exportCsv(): void { + shareReportCsv( + `share-nominal-${this.asOnDate || 'today'}`, + [ + 'Member', + 'Membership No', + 'Share A/c No', + 'Branch', + 'Product', + 'Account Status', + 'Holding State', + 'Shares Held', + 'Allotted Unpaid', + 'Nominal Required', + 'Against Nominal', + 'Minimum', + 'Maximum', + 'Face Value', + 'Nominal Value', + 'Membership Class', + 'Compliance' + ], + this.filteredRows.map((r) => [ + r.clientName, + r.cifNumber, + r.accountNo, + r.officeName, + r.productName, + this.statusLabel(r.statusEnum), + this.stateLabel(r.holdingState), + r.sharesHeld, + r.unpaidShares, + r.nominalShares, + r.againstNominal, + r.minimumShares, + r.maximumShares, + r.faceValue, + r.nominalValue, + this.membershipLabel(r.membershipClass), + this.complianceLabel(r.complianceStatus) + ]) + ); + } + + print(): void { + shareReportPrint(); + } + + private toIso(date: Date): string { + return `${date.getFullYear()}-${String(date.getMonth() + 1).padStart(2, '0')}-${String(date.getDate()).padStart(2, '0')}`; + } + + private readError(err: any, fallback: string): string { + return ( + err?.error?.errors?.[0]?.defaultUserMessage || + err?.error?.defaultUserMessage || + err?.error?.developerMessage || + fallback + ); + } +} diff --git a/src/app/shares/reports/share-payment-certificate/share-payment-certificate.component.html b/src/app/shares/reports/share-payment-certificate/share-payment-certificate.component.html new file mode 100644 index 000000000..caaa721b9 --- /dev/null +++ b/src/app/shares/reports/share-payment-certificate/share-payment-certificate.component.html @@ -0,0 +1,160 @@ + + +

+
+
+ +
+

Share Payment Certificate

+
Proof of what a member paid for their shares, and against which receipt.
+
+
+
+ +
+
+ +
+
+ +
+ + +
+ @if (accountOptions.length && !selectedAccountId) { +
+ @for (opt of accountOptions; track opt.id) { + + } +
+ } +
+
+ + @if (!selectedAccountId) { +
Search for a member or share account above to issue a payment certificate.
+ } @else if (loading) { +
Loading payment record…
+ } @else if (loadError) { +
{{ loadError }}
+ } @else if (account) { + +
+
+
{{ institution?.name || institution?.institutionName || 'Co-operative Bank' }}
+ @if (institution?.addressLine1 || institution?.address) { +
{{ institution?.addressLine1 || institution?.address }}
+ } +
Share Payment Certificate
+
+ +
+

+ This is to certify that {{ account.clientName }} + @if (account.cifNumber) { + (Membership No. {{ account.cifNumber }}) + } + @if (account.officeName) { + of our {{ account.officeName }} branch + } + has paid the amounts set out below towards + {{ totals?.sharesPaidFor | number }} shares of {{ account.productName }} at ₹{{ + account.unitPrice | number: '1.2-2' + }} + each, held under share account {{ account.accountNo }}. +

+
+ +
+ + + + + + + + + + + + + + @for (p of payments; track p.transactionId) { + + + + + + + + + + } + @for (c of charges; track c.chargeName) { + + + + + + + + } + + + + + + + + +
Payment DateSharesUnit PriceAmount PaidPaid ByReceipt / VoucherBoard Resolution
{{ p.paymentDate }}{{ p.shares | number }}{{ p.unitPrice | number: '1.2-2' }}₹{{ p.amount | number: '1.2-2' }}{{ funding(p.fundingSource) }} + {{ receiptRef(p) }} + @if (p.receiptDate) { +
{{ p.receiptDate }}
+ } +
{{ p.resolutionNumber || '—' }}
———₹{{ c.amountPaid | number: '1.2-2' }}{{ c.chargeName }} (entry fee)
Total paid₹{{ totals?.totalPaid | number: '1.2-2' }}
+
+ + @if (amountInWords) { +
+ Amount in words: {{ amountInWords }} +
+ } + +
+
+
+ Date of issue: {{ today }} +
+
+ Account status: {{ statusLabel(account.statusEnum) }}. Shares currently held: + {{ account.sharesHeld | number }}. +
+
+
+
+
Authorised Signatory
+
+
+
+ } +
diff --git a/src/app/shares/reports/share-payment-certificate/share-payment-certificate.component.ts b/src/app/shares/reports/share-payment-certificate/share-payment-certificate.component.ts new file mode 100644 index 000000000..0eba4b455 --- /dev/null +++ b/src/app/shares/reports/share-payment-certificate/share-payment-certificate.component.ts @@ -0,0 +1,236 @@ +/** + * Copyright since 2025 Mifos Initiative + * + * This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. + */ + +import { Component, OnInit, inject } from '@angular/core'; +import { CommonModule } from '@angular/common'; +import { FormsModule } from '@angular/forms'; +import { ActivatedRoute } from '@angular/router'; +import { FaIconComponent } from '@fortawesome/angular-fontawesome'; +import { Subject, of } from 'rxjs'; +import { catchError, debounceTime, distinctUntilChanged, switchMap } from 'rxjs/operators'; + +import { SharesService } from '../../shares.service'; +import { OrganizationService } from 'app/organization/organization.service'; +import { SHARE_ACCOUNT_STATUS, fundingLabel, shareReportPrint } from '../share-report.util'; + +/** + * Share Payment Certificate — proof of what a member paid for their shares, and against which + * receipt. + * + *

+ * Distinct from a share certificate, which certifies ownership: this certifies payment, so it leads + * with amounts, dates and the voucher each one was received on. It is the document a member is asked + * for when the money, not the holding, has to be proved. + * + *

+ * Every tranche is shown rather than one total — a member who paid in instalments paid on several + * dates against several receipts, and a certificate carrying a single figure reconciles to none of + * them. + */ +@Component({ + selector: 'mifosx-share-payment-certificate', + templateUrl: './share-payment-certificate.component.html', + styleUrls: [ + '../../../letter-mgmt/certificates/certificates.shared.scss', + '../share-report.shared.scss' + ], + imports: [ + CommonModule, + FormsModule, + FaIconComponent + ] +}) +export class SharePaymentCertificateComponent implements OnInit { + private sharesService = inject(SharesService); + private organizationService = inject(OrganizationService); + private route = inject(ActivatedRoute); + + accountSearch = ''; + accountOptions: any[] = []; + selectedAccountId: number | null = null; + + loading = false; + loadError = ''; + account: any = null; + payments: any[] = []; + charges: any[] = []; + totals: any = null; + + /** The society's own name and address, so the printed certificate is on its letterhead. */ + institution: any = null; + + private searchTerm$ = new Subject(); + + ngOnInit(): void { + this.searchTerm$ + .pipe( + debounceTime(350), + distinctUntilChanged(), + switchMap((term) => this.sharesService.getShareAccountSummaries(term).pipe(catchError(() => of([])))) + ) + .subscribe((res: any) => { + this.accountOptions = Array.isArray(res) ? res : res?.pageItems || []; + }); + + this.organizationService + .getInstitutionProfile() + .pipe(catchError(() => of(null))) + .subscribe((res: any) => (this.institution = res)); + + const preselected = this.route.snapshot.queryParamMap.get('shareAccountId'); + if (preselected) { + this.selectedAccountId = Number(preselected); + this.load(); + } + this.searchTerm$.next(''); + } + + onAccountSearch(term: string): void { + this.accountSearch = term; + this.searchTerm$.next(term.trim()); + } + + selectAccount(option: any): void { + this.selectedAccountId = option.id; + this.accountOptions = []; + this.accountSearch = `${option.clientName} — ${option.accountNo}`; + this.load(); + } + + load(): void { + if (!this.selectedAccountId) { + return; + } + this.loading = true; + this.loadError = ''; + this.sharesService + .getSharePaymentCertificate(this.selectedAccountId) + .pipe( + catchError((err) => { + this.loadError = this.readError(err, 'The payment certificate could not be loaded.'); + return of(null); + }) + ) + .subscribe((res: any) => { + this.account = res?.account || null; + this.payments = res?.payments || []; + this.charges = res?.charges || []; + this.totals = res?.totals || null; + this.loading = false; + }); + } + + statusLabel(statusEnum: number): string { + return SHARE_ACCOUNT_STATUS[statusEnum]?.label || String(statusEnum ?? ''); + } + + statusClass(statusEnum: number): string { + return SHARE_ACCOUNT_STATUS[statusEnum]?.cls || 'srep-pill--grey'; + } + + funding(source: string): string { + return fundingLabel(source); + } + + /** Whatever identifies the money that came in: a voucher, else the receipt, else the route. */ + receiptRef(payment: any): string { + return ( + payment.voucherNo || payment.receiptNo || (payment.fundingSource === 'SAVINGS' ? 'Debited from savings' : '—') + ); + } + + /** Amount in words, because a certificate carrying a figure alone is trivially altered. */ + get amountInWords(): string { + const amount = Math.round(Number(this.totals?.totalPaid || 0)); + return amount ? `${this.toWords(amount)} rupees only` : ''; + } + + private toWords(value: number): string { + const ones = [ + '', + 'one', + 'two', + 'three', + 'four', + 'five', + 'six', + 'seven', + 'eight', + 'nine', + 'ten', + 'eleven', + 'twelve', + 'thirteen', + 'fourteen', + 'fifteen', + 'sixteen', + 'seventeen', + 'eighteen', + 'nineteen' + ]; + const tens = [ + '', + '', + 'twenty', + 'thirty', + 'forty', + 'fifty', + 'sixty', + 'seventy', + 'eighty', + 'ninety' + ]; + + const twoDigits = (n: number): string => + n < 20 ? ones[n] : `${tens[Math.floor(n / 10)]}${n % 10 ? '-' + ones[n % 10] : ''}`; + + // Indian grouping — a co-operative's certificates read in lakh and crore, not million. + const parts: string[] = []; + const crore = Math.floor(value / 10000000); + const lakh = Math.floor((value % 10000000) / 100000); + const thousand = Math.floor((value % 100000) / 1000); + const hundred = Math.floor((value % 1000) / 100); + const rest = value % 100; + + if (crore) { + parts.push(`${twoDigits(crore)} crore`); + } + if (lakh) { + parts.push(`${twoDigits(lakh)} lakh`); + } + if (thousand) { + parts.push(`${twoDigits(thousand)} thousand`); + } + if (hundred) { + parts.push(`${ones[hundred]} hundred`); + } + if (rest) { + parts.push(twoDigits(rest)); + } + const words = parts.join(' ').trim(); + return words ? words.charAt(0).toUpperCase() + words.slice(1) : 'Zero'; + } + + get today(): string { + const now = new Date(); + return `${String(now.getDate()).padStart(2, '0')}-${String(now.getMonth() + 1).padStart(2, '0')}-${now.getFullYear()}`; + } + + print(): void { + shareReportPrint(); + } + + private readError(err: any, fallback: string): string { + return ( + err?.error?.errors?.[0]?.defaultUserMessage || + err?.error?.defaultUserMessage || + err?.error?.developerMessage || + fallback + ); + } +} diff --git a/src/app/shares/reports/share-report.shared.scss b/src/app/shares/reports/share-report.shared.scss new file mode 100644 index 000000000..26eabff50 --- /dev/null +++ b/src/app/shares/reports/share-report.shared.scss @@ -0,0 +1,494 @@ +/** + * 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 table, pill and print styling for the share reports. + * + * Layered on top of certificates.shared.scss, which already supplies the page frame + * (.cert-root / .cert-head / .cert-kpis / .cert-filters). Only what those do not cover lives here. + */ + +$srep-border: #e2e8f0; +$srep-border-soft: #f1f5f9; +$srep-head-bg: #f8fafc; +$srep-text: #0f172a; +$srep-muted: #64748b; + +.srep-table-wrap { + overflow-x: auto; + border: 1px solid $srep-border; + border-radius: 10px; + background: #fff; +} + +.srep-table { + width: 100%; + border-collapse: collapse; + font-size: 0.82rem; + + th { + position: sticky; + top: 0; + z-index: 1; + background: $srep-head-bg; + color: $srep-muted; + font-size: 0.68rem; + font-weight: 700; + text-transform: uppercase; + letter-spacing: 0.04em; + text-align: left; + padding: 0.55rem 0.7rem; + border-bottom: 1px solid $srep-border; + white-space: nowrap; + } + + td { + padding: 0.5rem 0.7rem; + border-bottom: 1px solid $srep-border-soft; + color: $srep-text; + vertical-align: middle; + } + + tbody tr:last-child td { + border-bottom: none; + } + + tbody tr.srep-row--click { + cursor: pointer; + + &:hover { + background: #f4f7fb; + } + } + + // A row that is over — closed, rejected, never activated — still belongs on the register, but it + // must not read like a live holding. Dimmed and marked at the edge rather than hidden, because a + // closed account that silently disappears is its own kind of confusion at a counter. + tbody tr.srep-row--muted { + background: #fafbfc; + color: $srep-muted; + + td { + color: $srep-muted; + } + + td:first-child { + box-shadow: inset 3px 0 0 #cbd5e1; + } + } + + // Allotted but never collected. Flagged, not dimmed: this one is live and wants chasing. + tbody tr.srep-row--flag { + background: #fffdf5; + + td:first-child { + box-shadow: inset 3px 0 0 #f59e0b; + } + } + + .srep-num { + text-align: right; + font-variant-numeric: tabular-nums; + white-space: nowrap; + } + + .srep-mono { + font-family: 'Courier New', monospace; + } + + // A second line under a figure, for the qualifier the figure alone cannot carry — "50 unpaid" + // beneath a held count of nothing. + .srep-sub { + margin-top: 1px; + font-size: 0.68rem; + font-weight: 600; + color: #b45309; + } + + tfoot td { + background: $srep-head-bg; + font-weight: 700; + border-top: 2px solid $srep-border; + border-bottom: none; + } +} + +.srep-pill { + display: inline-block; + padding: 1px 9px; + border-radius: 999px; + font-size: 0.68rem; + font-weight: 700; + white-space: nowrap; + + &.srep-pill--green { + background: #ecfdf5; + color: #059669; + border: 1px solid #a7f3d0; + } + + &.srep-pill--amber { + background: #fffbeb; + color: #d97706; + border: 1px solid #fde68a; + } + + &.srep-pill--red { + background: #fef2f2; + color: #dc2626; + border: 1px solid #fecaca; + } + + &.srep-pill--blue { + background: #eff6ff; + color: #2563eb; + border: 1px solid #bfdbfe; + } + + &.srep-pill--grey { + background: #f1f5f9; + color: #475569; + border: 1px solid #cbd5e1; + } +} + +.srep-state { + display: flex; + align-items: center; + justify-content: center; + gap: 0.5rem; + padding: 2rem 1rem; + color: $srep-muted; + font-size: 0.85rem; + text-align: center; +} + +.srep-state--error { + color: #dc2626; +} + +/* A narrowed view says so, right where the numbers are, so it is never read as the whole society. */ +.srep-scope-note { + margin-top: 0.5rem; + font-size: 0.75rem; + color: $srep-muted; +} + +/* Account picker — results drop under the search box rather than into a dialog, so choosing an + account is one click from typing rather than a modal that has to be dismissed. */ +.srep-picker { + position: absolute; + top: 100%; + left: 0; + right: 0; + z-index: 20; + margin-top: 2px; + max-height: 260px; + overflow-y: auto; + background: #fff; + border: 1px solid $srep-border; + border-radius: 8px; + box-shadow: 0 10px 24px -8px rgb(15 23 42 / 18%); +} + +.srep-picker-item { + display: flex; + flex-direction: column; + gap: 1px; + width: 100%; + padding: 0.45rem 0.7rem; + background: none; + border: none; + border-bottom: 1px solid $srep-border-soft; + text-align: left; + cursor: pointer; + + &:last-child { + border-bottom: none; + } + + &:hover { + background: #f4f7fb; + } +} + +.srep-picker-name { + font-size: 0.82rem; + font-weight: 600; + color: $srep-text; +} + +.srep-picker-meta { + font-size: 0.72rem; + color: $srep-muted; +} + +/* Inline distribution bar — the shareholding pattern reads as a shape before it reads as numbers, + and a chart library for one bar per row would be a dependency for nothing. */ +.srep-bar { + width: 100%; + height: 8px; + background: $srep-border-soft; + border-radius: 999px; + overflow: hidden; +} + +.srep-bar-fill { + height: 100%; + background: linear-gradient(90deg, #2563eb, #60a5fa); + border-radius: 999px; +} + +/* Statement header — the identity block a printed statement has to carry. */ +.srep-stmt-head { + display: flex; + align-items: flex-start; + justify-content: space-between; + gap: 1rem; + padding: 0.85rem 1rem; + margin-bottom: 0.85rem; + background: #f8fafc; + border: 1px solid $srep-border; + border-radius: 10px; +} + +.srep-stmt-grid { + display: grid; + grid-template-columns: repeat(auto-fit, minmax(150px, 1fr)); + gap: 0.6rem 1.2rem; + flex: 1; +} + +.srep-stmt-l { + font-size: 0.66rem; + text-transform: uppercase; + letter-spacing: 0.04em; + color: $srep-muted; + font-weight: 700; +} + +.srep-stmt-v { + font-size: 0.84rem; + color: $srep-text; + font-weight: 500; +} + +/* Something the reader has to know before trusting the number above it — an exclusion, a gap, a + figure that will not reconcile until someone acts. Loud enough to be read, not an error. */ +.srep-warn { + display: flex; + align-items: flex-start; + gap: 0.6rem; + margin: 0.6rem 0; + padding: 0.65rem 0.85rem; + background: #fffbeb; + border: 1px solid #fde68a; + border-left: 4px solid #d97706; + border-radius: 8px; + font-size: 0.82rem; + line-height: 1.55; + color: #92400e; +} + +/* The selected tab, in a screen that has two halves rather than two pages. */ +.srep-tab--active { + background: #eff6ff !important; + border-color: #2563eb !important; + color: #1e40af !important; + font-weight: 700; +} + +/* Printed certificate — a document, not a screen: it carries the society's letterhead, a body in + sentences and a signature block, because that is what a member is handed and files. */ +.spc-doc { + border: 1px solid $srep-border; + border-radius: 10px; + padding: 1.5rem 1.75rem; + background: #fff; +} + +.spc-letterhead { + text-align: center; + border-bottom: 2px solid $srep-text; + padding-bottom: 0.75rem; + margin-bottom: 1.1rem; +} + +.spc-org { + font-size: 1.15rem; + font-weight: 700; + color: $srep-text; + letter-spacing: 0.02em; +} + +.spc-org-sub { + font-size: 0.78rem; + color: $srep-muted; + margin-top: 2px; +} + +.spc-title { + margin-top: 0.7rem; + font-size: 0.95rem; + font-weight: 700; + text-transform: uppercase; + letter-spacing: 0.08em; + color: $srep-text; +} + +.spc-body { + font-size: 0.88rem; + line-height: 1.7; + color: $srep-text; + margin-bottom: 1rem; +} + +.spc-words { + margin-top: 0.75rem; + font-size: 0.85rem; + color: $srep-text; +} + +.spc-foot { + display: flex; + align-items: flex-end; + justify-content: space-between; + gap: 2rem; + margin-top: 2.5rem; + font-size: 0.82rem; +} + +.spc-sign { + text-align: center; + font-size: 0.8rem; + color: $srep-text; +} + +.spc-sign-line { + width: 170px; + border-top: 1px solid $srep-text; + margin-bottom: 4px; +} + +/* Label sheet — a grid sized to the label stationery a branch buys, printed straight from here. */ +.slab-sheet { + display: grid; + gap: 0.5rem; + margin-top: 0.6rem; +} + +.slab { + border: 1px dashed $srep-border; + border-radius: 6px; + padding: 0.65rem 0.8rem; + min-height: 92px; + font-size: 0.82rem; + line-height: 1.45; + color: $srep-text; + break-inside: avoid; +} + +.slab-name { + font-weight: 700; +} + +.slab-line { + color: $srep-text; +} + +.slab-meta { + margin-top: 3px; + font-size: 0.7rem; + color: $srep-muted; +} + +/* One letter, one page. */ +.slet-page { + border: 1px solid $srep-border; + border-radius: 10px; + padding: 1.5rem 1.75rem; + margin-top: 0.85rem; + background: #fff; + break-after: page; +} + +.slet-meta { + display: flex; + justify-content: space-between; + gap: 1.5rem; + font-size: 0.84rem; + line-height: 1.5; + margin-bottom: 1.1rem; +} + +.slet-date { + white-space: nowrap; + color: $srep-muted; +} + +.slet-subject { + font-weight: 700; + font-size: 0.86rem; + margin-bottom: 0.9rem; +} + +.slet-salutation { + font-size: 0.86rem; + margin-bottom: 0.6rem; +} + +.slet-body { + font-size: 0.86rem; + line-height: 1.75; + white-space: pre-wrap; + color: $srep-text; +} + +.slet-body-input { + width: 100%; + padding: 0.55rem 0.7rem; + border: 1px solid $srep-border; + border-radius: 8px; + font-family: inherit; + font-size: 0.84rem; + line-height: 1.6; + color: $srep-text; + resize: vertical; +} + +@media print { + .cert-head-r, + .cert-filters, + .srep-no-print { + display: none !important; + } + + .slab { + border: 1px solid #999; + } + + .slet-page { + border: none; + padding: 0; + margin: 0; + } + + .spc-doc { + border: none; + padding: 0; + } + + .srep-table-wrap { + overflow: visible; + border: none; + } + + .srep-table th { + position: static; + } +} diff --git a/src/app/shares/reports/share-report.util.ts b/src/app/shares/reports/share-report.util.ts new file mode 100644 index 000000000..4d609ef39 --- /dev/null +++ b/src/app/shares/reports/share-report.util.ts @@ -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/. + */ + +/** + * Shared vocabulary and plumbing for the share reports. + * + * Kept in one place because the reports are read side by side — a member shown as "Active" on the + * balance report and "Approved" on the status report would read as two different things when it is + * one status_enum, and a CSV that quotes its fields on one screen but not the next breaks whichever + * spreadsheet opens both. + */ + +/** m_share_account.status_enum → what a reader calls it, and the pill it wears. */ +export const SHARE_ACCOUNT_STATUS: { [key: number]: { label: string; cls: string } } = { + 100: { label: 'Pending Approval', cls: 'srep-pill--amber' }, + 200: { label: 'Approved', cls: 'srep-pill--blue' }, + 300: { label: 'Active', cls: 'srep-pill--green' }, + 500: { label: 'Rejected', cls: 'srep-pill--red' }, + 600: { label: 'Closed', cls: 'srep-pill--grey' } +}; + +/** + * Why a share account's holding reads the way it does — the server's own derivation, so every + * report tells the member the same story about the same account. + * + *

+ * A nil holding is the most over-loaded figure on a share register. A closed account, an active + * account whose money was never collected, and an account redeemed down to nothing all print the + * same "0", and a reader cannot tell them apart. Each state therefore carries its own pill, its own + * row treatment, and a sentence saying what it means — closed and finished rows are dimmed, and the + * one state that still wants chasing is flagged rather than greyed out. + */ +export const SHARE_HOLDING_STATE: { + [key: string]: { label: string; cls: string; row: string; note: string }; +} = { + HOLDING: { + label: 'Holding', + cls: 'srep-pill--green', + row: '', + note: 'Paid-up shares in hand.' + }, + UNPAID: { + label: 'Allotted · unpaid', + cls: 'srep-pill--amber', + row: 'srep-row--flag', + note: 'Shares allotted, but the collection was never applied — so they are in no capital figure.' + }, + NIL: { + label: 'Nil holding', + cls: 'srep-pill--grey', + row: 'srep-row--muted', + note: 'Active and paid for, but redeemed back down to nothing.' + }, + CLOSED: { + label: 'Closed', + cls: 'srep-pill--grey', + row: 'srep-row--muted', + note: 'The account is closed — the nil holding is final and correct.' + }, + NOT_ACTIVE: { + label: 'Not active', + cls: 'srep-pill--blue', + row: 'srep-row--muted', + note: 'Submitted or approved, not yet activated.' + }, + REJECTED: { + label: 'Rejected', + cls: 'srep-pill--red', + row: 'srep-row--muted', + note: 'The application never became an account.' + } +}; + +/** The states in the order a reader thinks about them — live first, finished last. */ +export const SHARE_HOLDING_STATE_ORDER = [ + 'HOLDING', + 'UNPAID', + 'NIL', + 'CLOSED', + 'NOT_ACTIVE', + 'REJECTED' +]; + +export function holdingStateLabel(state: string | null | undefined): string { + return SHARE_HOLDING_STATE[state || '']?.label || state || '—'; +} + +export function holdingStateClass(state: string | null | undefined): string { + return SHARE_HOLDING_STATE[state || '']?.cls || 'srep-pill--grey'; +} + +/** The row treatment: dimmed when the account is over, flagged when it still wants chasing. */ +export function holdingStateRowClass(state: string | null | undefined): string { + return SHARE_HOLDING_STATE[state || '']?.row || ''; +} + +export function holdingStateNote(state: string | null | undefined): string { + return SHARE_HOLDING_STATE[state || '']?.note || ''; +} + +/** m_share_account_transactions.type_enum — only the two that move shares. */ +export const SHARE_TXN_TYPE: { [key: number]: { label: string; cls: string } } = { + 500: { label: 'Purchase', cls: 'srep-pill--green' }, + 600: { label: 'Redemption', cls: 'srep-pill--red' }, + 700: { label: 'Charge Payment', cls: 'srep-pill--grey' } +}; + +/** m_share_account_transactions.status_enum — a tranche's own approval state. */ +export const SHARE_TXN_STATUS: { [key: number]: { label: string; cls: string } } = { + 100: { label: 'Pending', cls: 'srep-pill--amber' }, + 300: { label: 'Approved', cls: 'srep-pill--green' }, + 400: { label: 'Rejected', cls: 'srep-pill--red' } +}; + +/** How a tranche was paid for; blank on the opening tranche, which the account itself carries. */ +export function fundingLabel(source: string | null | undefined): string { + if (!source) { + return 'Opening'; + } + return source === 'CASH' ? 'Cash Receipt' : source === 'SAVINGS' ? 'Savings Debit' : source; +} + +/** m_share_account_dividend_details.status — where a member's dividend has got to. */ +export const SHARE_DIVIDEND_STATUS: { [key: number]: { label: string; cls: string } } = { + 100: { label: 'Declared', cls: 'srep-pill--amber' }, + 300: { label: 'Approved', cls: 'srep-pill--blue' }, + 600: { label: 'Paid', cls: 'srep-pill--green' } +}; + +/** Where a transfer stands in maker-checker. */ +export const SHARE_TRANSFER_STATUS: { [key: string]: { label: string; cls: string } } = { + PENDING: { label: 'Pending approval', cls: 'srep-pill--amber' }, + APPROVED: { label: 'Approved', cls: 'srep-pill--green' }, + REJECTED: { label: 'Rejected', cls: 'srep-pill--red' } +}; + +/** + * Fineract's period frequency enum, as a phrase. + * + * Reads "18 months" rather than "18 / 2", which is what the raw pair means to nobody outside the + * schema. An unset period says "None" rather than showing a bare zero that looks like a real term. + */ +export function periodLabel(value: number | null | undefined, frequencyEnum: number | null | undefined): string { + if (!value) { + return 'None'; + } + const unit = { 0: 'day', 1: 'week', 2: 'month', 3: 'year' }[frequencyEnum ?? -1] || 'period'; + return `${value} ${unit}${Number(value) === 1 ? '' : 's'}`; +} + +/** + * Downloads rows as CSV. + * + * Every field is quoted and its own quotes doubled, so a member name carrying a comma — which is + * most of them, once an address or a title creeps in — cannot shift every later column by one. + */ +export function shareReportCsv( + filename: string, + headers: string[], + rows: (string | number | null | undefined)[][] +): void { + const escape = (value: string | number | null | undefined): string => `"${String(value ?? '').replace(/"/g, '""')}"`; + const csv = [ + headers.map(escape).join(','), + ...rows.map((row) => row.map(escape).join(',')) + ].join('\n'); + + const blob = new Blob(['' + csv], { type: 'text/csv;charset=utf-8;' }); + const url = URL.createObjectURL(blob); + const link = document.createElement('a'); + link.href = url; + link.download = `${filename}.csv`; + link.click(); + URL.revokeObjectURL(url); +} + +/** The browser's own print, against the component's print stylesheet. */ +export function shareReportPrint(): void { + window.print(); +} diff --git a/src/app/shares/reports/share-status-report/share-status-report.component.html b/src/app/shares/reports/share-status-report/share-status-report.component.html new file mode 100644 index 000000000..85fd0ee19 --- /dev/null +++ b/src/app/shares/reports/share-status-report/share-status-report.component.html @@ -0,0 +1,230 @@ + + +

+
+
+ +
+

Share Status Report

+
+ Where every share account stands — by status and share class, with the accounts behind each figure. +
+
+
+
+ + +
+
+ + @if (totals) { +
+
+ +
+
{{ totals.accountCount | number }}
+
Share Accounts
+
+
+
+ +
+
{{ totals.memberCount | number }}
+
Members
+
+
+
+ +
+
{{ totals.approvedShares | number }}
+
Shares Allotted
+
+
+
+ +
+
₹{{ totals.shareValue | number: '1.2-2' }}
+
Share Capital
+
+
+
+ } + + @if (loading) { +
Loading share status…
+ } @else if (loadError) { +
{{ loadError }}
+ } @else { + +
+ + + + + + + + + + + + @for (s of statusTotals; track s.statusEnum) { + + + + + + + + } + + + + + + + + + + +
StatusAccountsMembersShares AllottedShare Value
+ {{ statusLabel(s.statusEnum) }} + @if (statusFilter === s.statusEnum) { +  filtered  + } + {{ s.accountCount | number }}{{ s.memberCount | number }}{{ s.shares | number }}₹{{ s.value | number: '1.2-2' }}
All statuses{{ totals?.accountCount | number }}{{ totals?.memberCount | number }}{{ totals?.approvedShares | number }}₹{{ totals?.shareValue | number: '1.2-2' }}
+
+ + + @if (groups.length > 1) { +
+
+ +
+
+
+ + + + + + + + + + + + + + @for (g of groups; track g.productId + '-' + g.statusEnum) { + + + + + + + + + + } + +
Share ProductStatusAccountsMembersSharesPending SharesShare Value
{{ g.productName }} + {{ statusLabel(g.statusEnum) }} + {{ g.accountCount | number }}{{ g.memberCount | number }}{{ g.approvedShares | number }}{{ g.pendingShares | number }}₹{{ g.shareValue | number: '1.2-2' }}
+
+ } + + +
+
+ +
+ + +
+
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+ + @if (!filteredAccounts.length) { +
No share account matches the filters applied.
+ } @else { +
+ + + + + + + + + + + + + + + + + @for (a of filteredAccounts; track a.shareAccountId) { + + + + + + + + + + + + + } + +
MemberMembership NoShare A/c NoBranchProductStatusStatus DateSharesPendingShare Value
{{ a.clientName }}{{ a.cifNumber || '—' }}{{ a.accountNo }}{{ a.officeName || '—' }}{{ a.productName }} + {{ statusLabel(a.statusEnum) }} + {{ effectiveDate(a) || '—' }}{{ a.approvedShares | number }}{{ a.pendingShares | number }}₹{{ a.shareValue | number: '1.2-2' }}
+
+ } + } +
diff --git a/src/app/shares/reports/share-status-report/share-status-report.component.ts b/src/app/shares/reports/share-status-report/share-status-report.component.ts new file mode 100644 index 000000000..81baf66c9 --- /dev/null +++ b/src/app/shares/reports/share-status-report/share-status-report.component.ts @@ -0,0 +1,268 @@ +/** + * Copyright since 2025 Mifos Initiative + * + * This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. + */ + +import { Component, OnInit, inject } from '@angular/core'; +import { CommonModule } from '@angular/common'; +import { FormsModule } from '@angular/forms'; +import { Router } from '@angular/router'; +import { FaIconComponent } from '@fortawesome/angular-fontawesome'; +import { of } from 'rxjs'; +import { catchError } from 'rxjs/operators'; + +import { SharesService } from '../../shares.service'; +import { SHARE_ACCOUNT_STATUS, shareReportCsv, shareReportPrint } from '../share-report.util'; + +interface StatusGroup { + statusEnum: number; + productId: number; + productName: string; + accountCount: number; + memberCount: number; + approvedShares: number; + pendingShares: number; + shareValue: number; +} + +interface StatusAccount { + shareAccountId: number; + accountNo: string; + clientId: number; + cifNumber: string; + clientName: string; + officeName: string; + productId: number; + productName: string; + currencyCode: string; + statusEnum: number; + approvedShares: number; + pendingShares: number; + shareValue: number; + submittedDate: string; + approvedDate: string; + activatedDate: string; + rejectedDate: string; + closedDate: string; +} + +/** + * Share Status — where every share account stands, summarised by status and share class, with the + * accounts behind each figure on the same screen. + * + *

+ * Summary and detail together on purpose: the question this report is opened with is almost always + * "how many are pending, and which ones", and splitting that across two screens turns one answer + * into two navigations. Clicking a summary row filters the detail below it rather than leaving. + */ +@Component({ + selector: 'mifosx-share-status-report', + templateUrl: './share-status-report.component.html', + styleUrls: [ + '../../../letter-mgmt/certificates/certificates.shared.scss', + '../share-report.shared.scss' + ], + imports: [ + CommonModule, + FormsModule, + FaIconComponent + ] +}) +export class ShareStatusReportComponent implements OnInit { + private sharesService = inject(SharesService); + private router = inject(Router); + + loading = true; + loadError = ''; + groups: StatusGroup[] = []; + accounts: StatusAccount[] = []; + totals: any = null; + + search = ''; + branchFilter = ''; + productFilter = ''; + /** Set by clicking a summary row; '' means every status. */ + statusFilter: number | '' = ''; + + ngOnInit(): void { + this.load(); + } + + load(): void { + this.loading = true; + this.loadError = ''; + this.sharesService + .getShareStatusSummary({}) + .pipe( + catchError((err) => { + this.loadError = this.readError(err, 'The share status report could not be loaded.'); + return of(null); + }) + ) + .subscribe((res: any) => { + this.groups = res?.groups || []; + this.accounts = res?.accounts || []; + this.totals = res?.totals || null; + this.loading = false; + }); + } + + get branchOptions(): string[] { + return [...new Set(this.accounts.map((a) => a.officeName).filter(Boolean))].sort(); + } + + get productOptions(): string[] { + return [...new Set(this.accounts.map((a) => a.productName).filter(Boolean))].sort(); + } + + /** Distinct statuses present, in the lifecycle order a reader expects rather than by count. */ + get statusOptions(): number[] { + return [...new Set(this.groups.map((g) => g.statusEnum))].sort((a, b) => a - b); + } + + /** One line per status, products folded together — the shape a board paper wants. */ + get statusTotals(): { + statusEnum: number; + accountCount: number; + memberCount: number; + shares: number; + value: number; + }[] { + const byStatus = new Map< + number, + { statusEnum: number; accountCount: number; memberCount: number; shares: number; value: number } + >(); + this.groups.forEach((g) => { + const entry = byStatus.get(g.statusEnum) || { + statusEnum: g.statusEnum, + accountCount: 0, + memberCount: 0, + shares: 0, + value: 0 + }; + entry.accountCount += Number(g.accountCount || 0); + entry.memberCount += Number(g.memberCount || 0); + entry.shares += Number(g.approvedShares || 0); + entry.value += Number(g.shareValue || 0); + byStatus.set(g.statusEnum, entry); + }); + return [...byStatus.values()].sort((a, b) => a.statusEnum - b.statusEnum); + } + + get filteredAccounts(): StatusAccount[] { + const term = this.search.trim().toLowerCase(); + return this.accounts.filter((a) => { + if (this.statusFilter !== '' && a.statusEnum !== this.statusFilter) { + return false; + } + if (this.branchFilter && a.officeName !== this.branchFilter) { + return false; + } + if (this.productFilter && a.productName !== this.productFilter) { + return false; + } + if (!term) { + return true; + } + return [ + a.clientName, + a.accountNo, + a.cifNumber, + a.productName + ] + .filter(Boolean) + .some((field) => String(field).toLowerCase().includes(term)); + }); + } + + selectStatus(statusEnum: number): void { + this.statusFilter = this.statusFilter === statusEnum ? '' : statusEnum; + } + + statusLabel(statusEnum: number): string { + return SHARE_ACCOUNT_STATUS[statusEnum]?.label || String(statusEnum ?? ''); + } + + statusClass(statusEnum: number): string { + return SHARE_ACCOUNT_STATUS[statusEnum]?.cls || 'srep-pill--grey'; + } + + /** The date that put an account into the status it is in — otherwise every row shows a blank. */ + effectiveDate(account: StatusAccount): string { + switch (account.statusEnum) { + case 600: + return account.closedDate; + case 500: + return account.rejectedDate; + case 300: + return account.activatedDate; + case 200: + return account.approvedDate; + default: + return account.submittedDate; + } + } + + clearFilters(): void { + this.search = ''; + this.branchFilter = ''; + this.productFilter = ''; + this.statusFilter = ''; + } + + openAccount(account: StatusAccount): void { + this.router.navigate([ + '/clients', + account.clientId, + 'shares-accounts', + account.shareAccountId, + 'general' + ]); + } + + exportCsv(): void { + shareReportCsv( + 'share-status-report', + [ + 'Member', + 'Membership No', + 'Share A/c No', + 'Branch', + 'Product', + 'Status', + 'Status Date', + 'Approved Shares', + 'Pending Shares', + 'Share Value' + ], + this.filteredAccounts.map((a) => [ + a.clientName, + a.cifNumber, + a.accountNo, + a.officeName, + a.productName, + this.statusLabel(a.statusEnum), + this.effectiveDate(a), + a.approvedShares, + a.pendingShares, + a.shareValue + ]) + ); + } + + print(): void { + shareReportPrint(); + } + + private readError(err: any, fallback: string): string { + return ( + err?.error?.errors?.[0]?.defaultUserMessage || + err?.error?.defaultUserMessage || + err?.error?.developerMessage || + fallback + ); + } +} diff --git a/src/app/shares/reports/share-transfer/share-transfer.component.html b/src/app/shares/reports/share-transfer/share-transfer.component.html new file mode 100644 index 000000000..b73109ca1 --- /dev/null +++ b/src/app/shares/reports/share-transfer/share-transfer.component.html @@ -0,0 +1,282 @@ + + +

+
+
+ +
+

Share Transfer

+
+ Moving shares from one member to another, under board resolution — and the register of every transfer made. +
+
+
+
+ + +
+
+ + @if (totals) { +
+
+ +
+
{{ totals.transferCount | number }}
+
Transfers
+
+
+
+ +
+
{{ totals.pendingCount | number }}
+
Awaiting Approval
+
+
+
+ +
+
{{ totals.sharesTransferred | number }}
+
Shares Transferred
+
+
+
+ +
+
₹{{ totals.valueTransferred | number: '1.2-2' }}
+
Value Transferred
+
+
+
+ } + + +
+
+ + +
+
+ + +
+
+ + @if (tab === 'new') { + +
+
+
+ +
+ + +
+ @if (fromOptions.length && !fromAccount) { +
+ @for (opt of fromOptions; track opt.id) { + + } +
+ } +
+ +
+ +
+ + +
+ @if (toOptions.length && !toAccount) { +
+ @for (opt of toOptions; track opt.id) { + + } +
+ } +
+ +
+ + +
+ +
+ + +
+ +
+ + +
+ +
+ + +
+ +
+ + +
+ +
+ + +
+
+ + +
+
+ + @if (fromAccount && toAccount && shares) { +
+ {{ shares | number }} shares of {{ fromAccount.productName }} move from + {{ fromAccount.clientName }} ({{ fromAccount.accountNo }}) to {{ toAccount.clientName }} ({{ + toAccount.accountNo + }}). Share capital does not change — only the register does. Nothing moves until a checker approves it. +
+ } +
+ + @if (submitError) { +
{{ submitError }}
+ } + @if (submitSuccess) { +
{{ submitSuccess }}
+ } + } + + +
+
+ +
+ + +
+
+
+ + +
+
+ + @if (loading) { +
Loading the transfer register…
+ } @else if (loadError) { +
{{ loadError }}
+ } @else if (!rows.length) { +
No share transfer has been recorded yet.
+ } @else if (!filteredRows.length) { +
No transfer matches the filters applied.
+ } @else { +
+ + + + + + + + + + + + + + + + + @for (row of filteredRows; track row.transferId) { + + + + + + + + + + + + + } + +
ReferenceDateFromToProductSharesValueBoard ResolutionStatusAction
{{ row.transferReference }}{{ row.transferDate }} + {{ row.fromClientName }} +
{{ row.fromAccountNo }}
+
+ {{ row.toClientName }} +
{{ row.toAccountNo }}
+
{{ row.productName }}{{ row.shares | number }}₹{{ row.transferAmount | number: '1.2-2' }} + {{ row.resolutionNumber || '—' }} + @if (row.meetingDate) { +
{{ row.meetingDate }}
+ } +
+ {{ statusLabel(row.status) }} + @if (row.status === 'REJECTED' && row.rejectionReason) { +
{{ row.rejectionReason }}
+ } + @if (row.status !== 'PENDING' && row.checkerName) { +
by {{ row.checkerName }}
+ } +
+ @if (row.status === 'PENDING') { + + + } @else { + {{ row.actionedOn || '—' }} + } +
+
+ } +
diff --git a/src/app/shares/reports/share-transfer/share-transfer.component.ts b/src/app/shares/reports/share-transfer/share-transfer.component.ts new file mode 100644 index 000000000..154d8aab2 --- /dev/null +++ b/src/app/shares/reports/share-transfer/share-transfer.component.ts @@ -0,0 +1,339 @@ +/** + * Copyright since 2025 Mifos Initiative + * + * This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. + */ + +import { Component, OnInit, inject } from '@angular/core'; +import { CommonModule } from '@angular/common'; +import { FormsModule } from '@angular/forms'; +import { FaIconComponent } from '@fortawesome/angular-fontawesome'; +import { Subject, of } from 'rxjs'; +import { catchError, debounceTime, distinctUntilChanged, switchMap } from 'rxjs/operators'; + +import { SharesService } from '../../shares.service'; +import { SettingsService } from 'app/settings/settings.service'; +import { SHARE_TRANSFER_STATUS, shareReportCsv, shareReportPrint } from '../share-report.util'; + +/** + * Share Transfer — moving shares from one member to another, and the register of every transfer + * made. + * + *

+ * No money reaches the society and its share capital does not change: what changes is the register, + * one member's holding down and another's up. The society's part is to approve it, so the screen is + * a request form and a queue rather than an instant action — a transfer carries voting rights with + * it and is not one clerk's decision. + * + *

+ * Every rule is enforced by the server at submission and again at approval. Nothing is validated + * only here: holdings move while a request waits, and a browser that checked once at 10am would + * happily submit something impossible by noon. + */ +@Component({ + selector: 'mifosx-share-transfer', + templateUrl: './share-transfer.component.html', + styleUrls: [ + '../../../letter-mgmt/certificates/certificates.shared.scss', + '../share-report.shared.scss' + ], + imports: [ + CommonModule, + FormsModule, + FaIconComponent + ] +}) +export class ShareTransferComponent implements OnInit { + private sharesService = inject(SharesService); + private settingsService = inject(SettingsService); + + /** Which half of the screen is showing: raise a transfer, or work the register. */ + tab: 'new' | 'register' = 'register'; + + // ── Register ── + loading = true; + loadError = ''; + rows: any[] = []; + totals: any = null; + statusFilter = 'ALL'; + search = ''; + actioningId: number | null = null; + + // ── New transfer ── + fromSearch = ''; + toSearch = ''; + fromOptions: any[] = []; + toOptions: any[] = []; + fromAccount: any = null; + toAccount: any = null; + shares: number | null = null; + transferDate = ''; + resolutionNumber = ''; + meetingDate = ''; + reason = ''; + submitting = false; + submitError = ''; + submitSuccess = ''; + + private fromTerm$ = new Subject(); + private toTerm$ = new Subject(); + + ngOnInit(): void { + const businessDate: Date = this.settingsService.businessDate || new Date(); + this.transferDate = this.toIso(businessDate); + + this.fromTerm$ + .pipe( + debounceTime(350), + distinctUntilChanged(), + switchMap((term) => this.sharesService.getShareAccountSummaries(term).pipe(catchError(() => of([])))) + ) + .subscribe((res: any) => (this.fromOptions = Array.isArray(res) ? res : res?.pageItems || [])); + + this.toTerm$ + .pipe( + debounceTime(350), + distinctUntilChanged(), + switchMap((term) => this.sharesService.getShareAccountSummaries(term).pipe(catchError(() => of([])))) + ) + .subscribe((res: any) => (this.toOptions = Array.isArray(res) ? res : res?.pageItems || [])); + + this.loadRegister(); + } + + // ── Register ────────────────────────────────────────────────────────── + + loadRegister(): void { + this.loading = true; + this.loadError = ''; + this.sharesService + .getShareTransfers(this.statusFilter === 'ALL' ? undefined : this.statusFilter) + .pipe( + catchError((err) => { + this.loadError = this.readError(err, 'The transfer register could not be loaded.'); + return of(null); + }) + ) + .subscribe((res: any) => { + this.rows = res?.rows || []; + this.totals = res?.totals || null; + this.loading = false; + }); + } + + get filteredRows(): any[] { + const term = this.search.trim().toLowerCase(); + if (!term) { + return this.rows; + } + return this.rows.filter((r) => + [ + r.fromClientName, + r.toClientName, + r.fromAccountNo, + r.toAccountNo, + r.transferReference, + r.resolutionNumber + ] + .filter(Boolean) + .some((field: any) => String(field).toLowerCase().includes(term)) + ); + } + + approve(row: any): void { + this.actioningId = row.transferId; + this.loadError = ''; + this.sharesService + .approveShareTransfer(row.transferId) + .pipe( + catchError((err) => { + this.loadError = this.readError(err, 'The transfer could not be approved.'); + this.actioningId = null; + return of(null); + }) + ) + .subscribe((res: any) => { + this.actioningId = null; + if (res) { + this.loadRegister(); + } + }); + } + + reject(row: any): void { + this.actioningId = row.transferId; + this.loadError = ''; + this.sharesService + .rejectShareTransfer(row.transferId) + .pipe( + catchError((err) => { + this.loadError = this.readError(err, 'The transfer could not be rejected.'); + this.actioningId = null; + return of(null); + }) + ) + .subscribe((res: any) => { + this.actioningId = null; + if (res) { + this.loadRegister(); + } + }); + } + + statusLabel(status: string): string { + return SHARE_TRANSFER_STATUS[status]?.label || status; + } + + statusClass(status: string): string { + return SHARE_TRANSFER_STATUS[status]?.cls || 'srep-pill--grey'; + } + + // ── New transfer ────────────────────────────────────────────────────── + + onFromSearch(term: string): void { + this.fromSearch = term; + this.fromAccount = null; + this.fromTerm$.next(term.trim()); + } + + onToSearch(term: string): void { + this.toSearch = term; + this.toAccount = null; + this.toTerm$.next(term.trim()); + } + + pickFrom(option: any): void { + this.fromAccount = option; + this.fromOptions = []; + this.fromSearch = `${option.clientName} — ${option.accountNo}`; + } + + pickTo(option: any): void { + this.toAccount = option; + this.toOptions = []; + this.toSearch = `${option.clientName} — ${option.accountNo}`; + } + + /** + * Only what the browser can honestly know: both sides chosen, a positive number of shares, and the + * resolution captured. Everything about holdings and class limits is the server's to judge, since + * only it knows what the register says right now. + */ + get canSubmit(): boolean { + return !!( + this.fromAccount && + this.toAccount && + this.shares && + this.shares > 0 && + this.transferDate && + this.resolutionNumber.trim() && + this.meetingDate && + !this.submitting + ); + } + + submit(): void { + if (!this.canSubmit) { + return; + } + this.submitting = true; + this.submitError = ''; + this.submitSuccess = ''; + this.sharesService + .submitShareTransfer({ + fromShareAccountId: this.fromAccount.id, + toShareAccountId: this.toAccount.id, + shares: this.shares, + transferDate: this.transferDate, + resolutionNumber: this.resolutionNumber.trim(), + meetingDate: this.meetingDate, + reason: this.reason.trim() || null + }) + .pipe( + catchError((err) => { + this.submitError = this.readError(err, 'The transfer could not be submitted.'); + this.submitting = false; + return of(null); + }) + ) + .subscribe((res: any) => { + this.submitting = false; + if (res) { + this.submitSuccess = res.message || `Transfer ${res.transferReference} sent for approval.`; + this.resetForm(); + this.loadRegister(); + } + }); + } + + resetForm(): void { + this.fromSearch = ''; + this.toSearch = ''; + this.fromAccount = null; + this.toAccount = null; + this.shares = null; + this.resolutionNumber = ''; + this.meetingDate = ''; + this.reason = ''; + this.submitError = ''; + } + + exportCsv(): void { + shareReportCsv( + 'share-transfer-register', + [ + 'Reference', + 'Date', + 'From Member', + 'From A/c No', + 'To Member', + 'To A/c No', + 'Product', + 'Shares', + 'Unit Price', + 'Value', + 'Board Resolution', + 'Meeting Date', + 'Status', + 'Raised By', + 'Actioned By' + ], + this.filteredRows.map((r) => [ + r.transferReference, + r.transferDate, + r.fromClientName, + r.fromAccountNo, + r.toClientName, + r.toAccountNo, + r.productName, + r.shares, + r.unitPrice, + r.transferAmount, + r.resolutionNumber, + r.meetingDate, + this.statusLabel(r.status), + r.makerName, + r.checkerName + ]) + ); + } + + print(): void { + shareReportPrint(); + } + + private toIso(date: Date): string { + return `${date.getFullYear()}-${String(date.getMonth() + 1).padStart(2, '0')}-${String(date.getDate()).padStart(2, '0')}`; + } + + private readError(err: any, fallback: string): string { + return ( + err?.error?.errors?.[0]?.defaultUserMessage || + err?.error?.defaultUserMessage || + err?.error?.developerMessage || + fallback + ); + } +} diff --git a/src/app/shares/reports/share-wise-report/share-wise-report.component.html b/src/app/shares/reports/share-wise-report/share-wise-report.component.html new file mode 100644 index 000000000..4f8db52f4 --- /dev/null +++ b/src/app/shares/reports/share-wise-report/share-wise-report.component.html @@ -0,0 +1,165 @@ + + +

+
+
+ +
+

Share Wise Report

+
+ How the society's share capital is spread — members per holding band, and the split by share class. +
+
+
+
+ + +
+
+ + @if (totals) { +
+
+ +
+
{{ totalMembers | number }}
+
Shareholding Members
+
+
+
+ +
+
{{ totals.shares | number }}
+
Shares Held
+
+
+
+ +
+
₹{{ totals.shareValue | number: '1.2-2' }}
+
Share Capital
+
+
+
+ +
+
{{ byProduct.length | number }}
+
Share Classes
+
+
+
+ } + +
+
+ + +
+
+ + @if (loading) { +
Loading the shareholding pattern…
+ } @else if (loadError) { +
{{ loadError }}
+ } @else if (!totals?.shares) { +
No member held share capital as on {{ asOnDate }}.
+ } @else { + +
+ + + + + + + + + + + + + + @for (band of bands; track band.band) { + + + + + + + + + + } + + + + + + + + + + + + +
Holding Band (shares)MembersDistributionAccountsSharesShare Value% of Capital
{{ band.band }}{{ band.memberCount | number }} +
+
+
+
{{ band.accountCount | number }}{{ band.shares | number }}₹{{ band.shareValue | number: '1.2-2' }}{{ band.capitalShare | number: '1.2-2' }}%
All bands{{ totalMembers | number }}{{ totals.shares | number }}₹{{ totals.shareValue | number: '1.2-2' }}100.00%
+
+ + +
+
+ +
+
+
+ + + + + + + + + + + + + @for (row of byProduct; track row.productId) { + + + + + + + + + } + + + + + + + + + + + +
Share ProductMembersAccountsSharesShare Value% of Capital
{{ row.productName }}{{ row.memberCount | number }}{{ row.accountCount | number }}{{ row.shares | number }}₹{{ row.shareValue | number: '1.2-2' }}{{ productShare(row) | number: '1.2-2' }}%
All classes{{ totals.shares | number }}₹{{ totals.shareValue | number: '1.2-2' }}100.00%
+
+ } +
diff --git a/src/app/shares/reports/share-wise-report/share-wise-report.component.ts b/src/app/shares/reports/share-wise-report/share-wise-report.component.ts new file mode 100644 index 000000000..3620b50bd --- /dev/null +++ b/src/app/shares/reports/share-wise-report/share-wise-report.component.ts @@ -0,0 +1,167 @@ +/** + * Copyright since 2025 Mifos Initiative + * + * This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. + */ + +import { Component, OnInit, inject } from '@angular/core'; +import { CommonModule } from '@angular/common'; +import { FormsModule } from '@angular/forms'; +import { FaIconComponent } from '@fortawesome/angular-fontawesome'; +import { of } from 'rxjs'; +import { catchError } from 'rxjs/operators'; + +import { SharesService } from '../../shares.service'; +import { SettingsService } from 'app/settings/settings.service'; +import { shareReportCsv, shareReportPrint } from '../share-report.util'; + +interface BandRow { + band: string; + bandOrder: number; + accountCount: number; + memberCount: number; + shares: number; + shareValue: number; + /** This band's percentage of total share capital, computed server-side against the true total. */ + capitalShare: number; +} + +interface ProductRow { + productId: number; + productName: string; + accountCount: number; + memberCount: number; + shares: number; + shareValue: number; +} + +/** + * Share Wise Report — the shareholding pattern: how the society's capital is spread across its + * members, and how it splits by share class. + * + *

+ * Concentration is what this answers. A total and a member count cannot show whether three members + * hold half the capital, and that is the number a board and the Registrar both ask for. + * + *

+ * No client-side filters beyond the date and scope the server already applied: a distribution that + * can be narrowed in the browser stops being a distribution — the percentages would still be of the + * whole while the bands shown were of a part. + */ +@Component({ + selector: 'mifosx-share-wise-report', + templateUrl: './share-wise-report.component.html', + styleUrls: [ + '../../../letter-mgmt/certificates/certificates.shared.scss', + '../share-report.shared.scss' + ], + imports: [ + CommonModule, + FormsModule, + FaIconComponent + ] +}) +export class ShareWiseReportComponent implements OnInit { + private sharesService = inject(SharesService); + private settingsService = inject(SettingsService); + + loading = true; + loadError = ''; + bands: BandRow[] = []; + byProduct: ProductRow[] = []; + totals: any = null; + + asOnDate = ''; + + ngOnInit(): void { + const businessDate: Date = this.settingsService.businessDate || new Date(); + this.asOnDate = this.toIso(businessDate); + this.load(); + } + + load(): void { + this.loading = true; + this.loadError = ''; + this.sharesService + .getShareWiseReport({ asOnDate: this.asOnDate }) + .pipe( + catchError((err) => { + this.loadError = this.readError(err, 'The shareholding pattern could not be loaded.'); + return of(null); + }) + ) + .subscribe((res: any) => { + this.bands = res?.bands || []; + this.byProduct = res?.byProduct || []; + this.totals = res?.totals || null; + this.loading = false; + }); + } + + get totalMembers(): number { + return this.bands.reduce((sum, b) => sum + Number(b.memberCount || 0), 0); + } + + /** A product's share of total capital — the same reading the bands give, by class instead. */ + productShare(row: ProductRow): number { + const total = Number(this.totals?.shareValue || 0); + return total ? (Number(row.shareValue || 0) * 100) / total : 0; + } + + /** Bar width for the inline distribution graphic; the largest band fills the cell. */ + barWidth(row: BandRow): number { + const largest = Math.max(...this.bands.map((b) => Number(b.memberCount || 0)), 1); + return (Number(row.memberCount || 0) * 100) / largest; + } + + exportCsv(): void { + shareReportCsv( + `share-wise-${this.asOnDate || 'today'}`, + [ + 'Holding Band', + 'Accounts', + 'Members', + 'Shares', + 'Share Value', + '% of Capital' + ], + [ + ...this.bands.map((b) => [ + b.band, + b.accountCount, + b.memberCount, + b.shares, + b.shareValue, + b.capitalShare + ]), + [ + 'TOTAL', + '', + this.totalMembers, + this.totals?.shares ?? '', + this.totals?.shareValue ?? '', + 100 + ] + ] + ); + } + + print(): void { + shareReportPrint(); + } + + private toIso(date: Date): string { + return `${date.getFullYear()}-${String(date.getMonth() + 1).padStart(2, '0')}-${String(date.getDate()).padStart(2, '0')}`; + } + + private readError(err: any, fallback: string): string { + return ( + err?.error?.errors?.[0]?.defaultUserMessage || + err?.error?.defaultUserMessage || + err?.error?.developerMessage || + fallback + ); + } +} diff --git a/src/app/shares/shares-account-actions/apply-shares/apply-shares.component.html b/src/app/shares/shares-account-actions/apply-shares/apply-shares.component.html index 4e7601cb0..e4b31cf58 100644 --- a/src/app/shares/shares-account-actions/apply-shares/apply-shares.component.html +++ b/src/app/shares/shares-account-actions/apply-shares/apply-shares.component.html @@ -208,20 +208,20 @@

+ + @if (unattributed.length) { +
+
+ person_add + Unattached Share Collection Receipts — Attach to This Tranche +
+
+ + + + + + + + + + + + + @for (u of unattributed; track u.id) { + + + + + + + + + } + +
Voucher NoDateDeposited ToDescriptionAmountAction
+ {{ u.voucherNo }} + @if (isOwnClientVoucher(u)) { + This member's receipt + } + {{ u.txnDate }}{{ u.glAccountNo }} — {{ u.glAccountName }}{{ u.description || '—' }}{{ u.amount | number: '1.2-2' }} + +
+
+
+ } + @if (voucherError) {
error_outline diff --git a/src/app/shares/shares-account-actions/apply-shares/apply-shares.component.scss b/src/app/shares/shares-account-actions/apply-shares/apply-shares.component.scss index 80603efdc..fdb52947f 100644 --- a/src/app/shares/shares-account-actions/apply-shares/apply-shares.component.scss +++ b/src/app/shares/shares-account-actions/apply-shares/apply-shares.component.scss @@ -481,6 +481,69 @@ mat-form-field { overflow-x: auto; } +/* Unattached share-collection receipts. Set apart in amber so it never reads as this member's own + settled list above: these belong to nobody until somebody attaches one. */ +.unattributed-section { + margin: 0 0 0.75rem; + padding: 0.65rem; + border: 1px solid $warning-border; + border-radius: 8px; + background: $warning-bg; + + .section-title { + border-bottom: none; + margin-bottom: 0.5rem; + padding-bottom: 0; + + &.text-amber { + color: $warning; + + .sec-icon { + color: $warning; + } + } + } + + .unattributed-table { + background: $bg-surface; + + .col-attach { + width: 96px; + } + } + + .attach-btn { + height: 28px; + line-height: 28px; + font-size: 0.75rem; + padding: 0 0.65rem; + } + + // A row already linked to the member whose screen this is -- the same voucher is also sitting, + // pre-loaded and tickable, in the Vouchers/Receipts table below. This is not a duplicate to + // clear up; it is the same receipt shown two ways for two different purposes (find it here, + // select it there), so the row is called out instead of removed. + .own-client-row { + background: $success-bg; + + &:hover { + background: $success-bg; + } + + .voucher-badge { + border-color: $success-border; + } + } + + .own-client-badge { + display: block; + margin-top: 2px; + font-size: 0.65rem; + font-weight: 700; + color: $success; + } +} + .receipt-table { width: 100%; border-collapse: separate; diff --git a/src/app/shares/shares-account-actions/apply-shares/apply-shares.component.ts b/src/app/shares/shares-account-actions/apply-shares/apply-shares.component.ts index 8922da149..98e09d124 100644 --- a/src/app/shares/shares-account-actions/apply-shares/apply-shares.component.ts +++ b/src/app/shares/shares-account-actions/apply-shares/apply-shares.component.ts @@ -100,6 +100,24 @@ export class ApplySharesComponent implements OnInit { lookingUpVoucher = false; voucherError = ''; + /** Voucher / Receipt No shown in the "Find Specific Voucher" box. Mirrors whichever receipt is + * ticked below (see {@link toggleVoucher}) so a tick is visibly confirmed up there too, and + * carries whatever the counter has typed in when they search by hand instead. */ + voucherNo = ''; + + /** + * Share-collection receipts that are approved but belong to nobody yet. + * + * A receipt paid straight into a GL account names no member of its own — a GL account is nobody's + * account — so it is never attributed automatically, and never filtered by this member's client id + * (the cashier's typed description is free text and can easily be wrong; nothing here is trusted + * to match a member by parsing it). Listing it is not attribution: it only makes the voucher + * findable, so a counter that tagged a receipt "Share Collection" moments ago is not told there is + * nothing to fund this tranche with. Attaching it below is still a deliberate click against the + * member on screen. + */ + unattributed: any[] = []; + /** GL account the deposit was posted to — required, and always picked by hand. Never resolved * from a share product's accounting setup, so a broken or missing product mapping can never * make a genuine voucher unreachable. */ @@ -242,6 +260,7 @@ export class ApplySharesComponent implements OnInit { this.vouchers = []; this.selectedVoucherIds = []; this.voucherError = ''; + this.voucherNo = ''; this.errorMsg = ''; this.savingsEligibility = null; if (mode === 'SAVINGS') { @@ -266,8 +285,9 @@ export class ApplySharesComponent implements OnInit { /** * Loads the member's unapplied Share Sundry receipts so they can be ticked off. * - * A single receipt is ticked for them: with only one candidate there is nothing to choose, and - * making the clerk tick it adds a step that can only be got wrong by forgetting it. + * Deliberately nothing ticked, not even when a single receipt is listed — which receipt funds the + * tranche is a choice the person applying it makes, not one they inherit from a pre-ticked radio + * they never looked at. */ private loadVouchers(): void { this.loadingVouchers = true; @@ -286,16 +306,49 @@ export class ApplySharesComponent implements OnInit { return; } this.vouchers = res.unappliedCollections || []; - if (this.vouchers.length === 1) { - this.selectedVoucherIds = [this.vouchers[0].id]; - } }); + + this.cashierService + .getUnattributedShareCollections() + .pipe(catchError(() => of(null))) + .subscribe((res: any) => (this.unattributed = res?.rows || [])); + } + + /** + * Attaches one of the unattributed share-collection receipts to this member's tranche. + * + * Goes straight to {@link resolveVoucher} rather than through {@link lookupVoucher}: a row already + * sitting in this list is either a fresh cashier deposit (which does carry its own GL account) or + * an existing, already-adopted {@code m_share_advance_collection} row (the "linked to …" ones), + * whose GL account can genuinely come back blank — its subquery reads the posting the ORIGINAL + * adoption made, which a clerk-disbursement-funded receipt does not always leave behind cleanly. + * Either way the backend needs no GL account to resolve a voucher it can already find by number, so + * a blank one here must never block the click the way it rightly blocks a hand-typed search with + * nothing chosen yet. + */ + attachUnattributedVoucher(row: any): void { + if (this.lookingUpVoucher || this.submitting) { + return; + } + this.resolveVoucher(row.voucherNo, row.glAccountNo || ''); } isVoucherSelected(id: number): boolean { return this.selectedVoucherIds.includes(id); } + /** + * Whether an unattributed-list row is already linked to the member this screen belongs to. + * + * Such a row also appears, pre-loaded and ready to tick, in the Vouchers/Receipts table below — + * that is not a bug to hide, it is the same receipt reachable two ways. This only flags it so the + * counter understands why it shows up here too, rather than reading it as an unrelated stray + * voucher that happens to look identical. + */ + isOwnClientVoucher(u: any): boolean { + return u?.linkedClientId != null && Number(u.linkedClientId) === Number(this.sharesAccountData.clientId); + } + /** * One receipt funds one tranche — no combining instalments here (unlike the first-application * Apply Collection dialog, which still allows several). Picking a different receipt replaces the @@ -305,15 +358,18 @@ export class ApplySharesComponent implements OnInit { const wasSelected = this.isVoucherSelected(id); this.selectedVoucherIds = wasSelected ? [] : [id]; this.voucherError = ''; - // The GL Account picker above follows whichever receipt is ticked — its own actual GL account - // (see gl_code on the row), not left showing an unrelated account or "Select…" once a receipt - // that plainly does have one is already chosen. - if (!wasSelected) { - const voucher = this.vouchers.find((v) => v.id === id); - if (voucher?.gl_code) { - this.glAccountNo = voucher.gl_code; - } + if (wasSelected) { + this.voucherNo = ''; + return; } + // The GL Account and Find Specific Voucher fields above follow whichever receipt is ticked -- + // their own actual GL account and voucher number (see gl_code / voucher_no on the row), not left + // showing an unrelated value or blank once a receipt that plainly has them is already chosen. + const voucher = this.vouchers.find((v) => v.id === id); + if (voucher?.gl_code) { + this.glAccountNo = voucher.gl_code; + } + this.voucherNo = voucher?.voucher_no || voucher?.receipt_no || ''; } /** @@ -330,24 +386,40 @@ export class ApplySharesComponent implements OnInit { * Resolves a voucher number the member quotes and adds it to the list. * * Kept alongside the tick list rather than replacing it: the member usually quotes the number on - * the slip in their hand, which is faster than finding it among their receipts. The lookup is - * deliberately not restricted to this member, so a voucher belonging to somebody else is - * surfaced here as a plain mismatch rather than silently ignored. + * the slip in their hand, which is faster than finding it among their receipts. The lookup is not + * restricted to this member — any receipt found this way can be attached here, whichever member it + * was originally recorded against. */ - lookupVoucher(voucherNo: string): void { - const trimmed = (voucherNo || '').trim(); + lookupVoucher(): void { + const trimmed = this.voucherNo.trim(); const glAccountNo = this.glAccountNo.trim(); + // A hand-typed search genuinely needs both fields: an empty GL account here means nothing has + // been chosen yet, not that one isn't needed — unlike {@link attachUnattributedVoucher}, which + // already knows exactly which receipt it means and never has this ambiguity. if (!trimmed || !glAccountNo || this.lookingUpVoucher) { return; } + this.resolveVoucher(trimmed, glAccountNo); + } + + /** + * Resolves a voucher number (with whatever GL account, if any, is known for it) and adds the + * receipt it finds to the list, selected. + * + * Shared by {@link lookupVoucher} (a hand-typed search, where both fields are guaranteed non-blank + * by the Find button's own disabled state) and {@link attachUnattributedVoucher} (a click on a row + * already on screen, whose GL account can legitimately be blank) — the actual lookup and its + * bookkeeping afterwards are identical either way. + */ + private resolveVoucher(voucherNo: string, glAccountNo: string): void { this.lookingUpVoucher = true; this.voucherError = ''; this.cashierService - .getShareAdvanceByVoucher(trimmed, glAccountNo, this.sharesAccountData.clientId) + .getShareAdvanceByVoucher(voucherNo, glAccountNo, this.sharesAccountData.clientId) .pipe( catchError((err: any) => { - this.voucherError = this.readError(err, `Voucher ${trimmed} could not be found.`); + this.voucherError = this.readError(err, `Voucher ${voucherNo} could not be found.`); this.lookingUpVoucher = false; return of(null); }) @@ -357,10 +429,8 @@ export class ApplySharesComponent implements OnInit { if (!res) { return; } - if (Number(res.client_id) !== Number(this.sharesAccountData.clientId)) { - this.voucherError = `Voucher ${trimmed} belongs to ${res.client_name}, not the holder of this share account.`; - return; - } + // No client-match check here by deliberate choice: any receipt can be attached to any + // share account, regardless of which member it was originally recorded against. // Replaces the selection rather than adding to it — one receipt funds one tranche here, so // finding a voucher by number is a different way to pick THE receipt, not a second one. this.vouchers = [ @@ -368,6 +438,13 @@ export class ApplySharesComponent implements OnInit { ...this.vouchers.filter((v) => v.id !== res.id) ]; this.selectedVoucherIds = [res.id]; + this.voucherNo = res.voucher_no || res.receipt_no || voucherNo; + if (res.gl_code) { + this.glAccountNo = res.gl_code; + } + // Once attached it belongs to this member, so it leaves the unattributed list rather than + // sitting there inviting a second, conflicting attachment. + this.unattributed = this.unattributed.filter((u) => u.voucherNo !== voucherNo); }); } diff --git a/src/app/shares/shares-account-view/apply-collection-dialog/apply-collection-dialog.component.html b/src/app/shares/shares-account-view/apply-collection-dialog/apply-collection-dialog.component.html index 756d2d39e..12a30dadf 100644 --- a/src/app/shares/shares-account-view/apply-collection-dialog/apply-collection-dialog.component.html +++ b/src/app/shares/shares-account-view/apply-collection-dialog/apply-collection-dialog.component.html @@ -6,103 +6,94 @@ file, You can obtain one at http://mozilla.org/MPL/2.0/. --> -

Apply Share Advance Collection

- - -

- Tick the receipts that make up this payment to settle share account {{ data.accountNo }}, then apply. Today's unapplied receipts for this member are listed below — tick more than one where they paid in - instalments. The amount moves out of Share Sundry into share capital, and any entry fee is posted to fee income. -

- - - @if (requirement) { -
- - Required: {{ requirement.shareAmount | number: '1.2-2' }} (Shares) - @if (requirement.chargeAmount > 0) { - + {{ requirement.chargeAmount | number: '1.2-2' }} (Entry Fee) = - {{ requirement.required | number: '1.2-2' }} Total - } - - @if (selectedIds.length > 0) { - - @if (shortfall === 0) { - Share receipts covered - } @else { - {{ selectedTotal | number: '1.2-2' }} of {{ shareRequired | number: '1.2-2' }} collected — - {{ shortfall | number: '1.2-2' }} short - } - - } +
+ +
+
+ account_balance_wallet
- } - - -
- -
- -
- -
- - +
+

Apply Share Advance Collection

+

+ Settling share account {{ data.accountNo }} +

+
- - @if (requirement && requirement.chargeAmount > 0) { -
- + + +
+ + + Tick the receipts that make up this payment to settle share account {{ data.accountNo }}. Multiple receipts can be selected for instalment payments. Click + visibility to view full receipt popup details. + +
- @if (chargeReceipt) { -
- {{ chargeShortfall > 0 ? 'error_outline' : 'check_circle_outline' }} - - {{ chargeReceipt.voucher_no || chargeReceipt.receipt_no }} - — {{ chargeReceipt.amount | number: '1.2-2' }} - @if (chargeShortfall > 0) { - (short by {{ chargeShortfall | number: '1.2-2' }}) - } @else { - — entry fee verified - } - - + + @if (requirement) { +
+
+ calculate + Tranche Requirement
- } @else { -
+ +
+
+ Shares Value + {{ requirement.shareAmount | number: '1.2-2' }} +
+ + @if (requirement.chargeAmount > 0) { + + +
+ Entry Fee + {{ requirement.chargeAmount | number: '1.2-2' }} +
+ } + + = +
+ Total Required + {{ requirement.required | number: '1.2-2' }} +
+
+ + @if (selectedIds.length > 0) { +
+ @if (shortfall === 0) { + + check_circle Share receipts fully covered + + } @else { + + warning {{ selectedTotal | number: '1.2-2' }} of + {{ shareRequired | number: '1.2-2' }} collected — {{ shortfall | number: '1.2-2' }} short + + } +
+ } +
+ } + + +
+ + +
+
+
-
- - -
- -
- Leave this blank if the fee was already paid as part of the share receipt above. -
- } -
- } - - @if (errorMsg) { -
- error_outline - {{ errorMsg }} -
- } - - @if (loading) { -
- - Loading receipts… -
- } @else if (receipts.length === 0) { -
- info_outline -
- No unapplied receipt for this member today. -
- If the cash was taken on an earlier day, enter that voucher number above. Otherwise collect it first from - Cashier › Transaction Entry as a deposit to the Share Sundry GL — once a manager approves it, the - voucher appears here. +
+ +
+ + +
- } @else { - - - - - - - - - - - - - @for (r of receipts; track r.id) { - - - - - - - - + + + @if (requirement && requirement.chargeAmount > 0) { +
+ + + @if (chargeReceipt) { +
+ {{ chargeShortfall > 0 ? 'error_outline' : 'check_circle' }} + + {{ chargeReceipt.voucher_no || chargeReceipt.receipt_no }} + — {{ chargeReceipt.amount | number: '1.2-2' }} + @if (chargeShortfall > 0) { + (short by {{ chargeShortfall | number: '1.2-2' }}) + } @else { + — Entry fee verified + } + + +
+ } @else { +
+
+ + +
+ +
+ +
+ + +
+
+
+
+ Leave this blank if the entry fee was already paid as part of the main share receipt above. +
} -
- - @if (selectedIds.length > 0) { - - - - - - - + + } + + + @if (unattributed.length) { +
+ +
+
Voucher / ReceiptDateDescriptionCollected ByAmount
- - - {{ r.voucher_no || r.receipt_no }}{{ r.collection_date }}{{ r.note || '—' }}{{ r.collected_by_name || '—' }}{{ r.amount | number: '1.2-2' }}
- {{ selectedIds.length }} {{ selectedIds.length === 1 ? 'receipt' : 'receipts' }} selected - {{ selectedTotal | number: '1.2-2' }}
+ + + + + + + + + + + + @for (u of unattributed; track u.id) { + + + + + + + + + } + +
Voucher NoDateDeposited ToDescriptionAmountAction
+ {{ u.voucherNo }} + @if (isOwnClientVoucher(u)) { + This member's receipt + } + {{ u.txnDate }}{{ u.glAccountNo }} — {{ u.glAccountName }}{{ u.description || '—' }}{{ u.amount | number: '1.2-2' }} + +
+
+
+ } + + @if (errorMsg) { +
+ error_outline + {{ errorMsg }} +
+ } + + +
+ @if (loading) { +
+ + Loading member receipts… +
+ } @else if (receipts.length === 0) { +
+ receipt_long +
+ No unapplied receipt found for this member today. +

+ If cash was taken on an earlier day, enter that voucher number above. Otherwise collect cash first at + Cashier › Transaction Entry into Share Sundry GL. +

+
+
+ } @else { +
+ +
+
+ receipt + Member Unapplied Receipts + {{ receipts.length }} +
+ + @if (receipts.length > 1) { +
+ +
+ } +
+ + +
+ + + + + + + + + + + + + + @for (r of receipts; track r.id) { + + + + + + + + + + } + +
+ @if (receipts.length > 1) { + + } @else { + # + } + Voucher / Receipt NoDateDescriptionCollected ByAmountDetail
+ + + {{ r.voucher_no || r.receipt_no }} + {{ r.collection_date }}{{ r.note || 'Share Collection' }} + + person_outline + {{ r.collected_by_name || 'System' }} + + + {{ r.amount | number: '1.2-2' }} + + +
+
+ + + +
} - - -
- Their total must cover the account's share value — plus any entry fee, unless it was verified as its own receipt - above. Anything paid over that is credited to the member's savings account.
- } - + - - - - + + + + + +
+ + +@if (viewingReceiptDetail) { + +} diff --git a/src/app/shares/shares-account-view/apply-collection-dialog/apply-collection-dialog.component.scss b/src/app/shares/shares-account-view/apply-collection-dialog/apply-collection-dialog.component.scss index b7546ba4a..361dd3acc 100644 --- a/src/app/shares/shares-account-view/apply-collection-dialog/apply-collection-dialog.component.scss +++ b/src/app/shares/shares-account-view/apply-collection-dialog/apply-collection-dialog.component.scss @@ -6,232 +6,976 @@ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ -$primary: #1a3a6b; -$border: #d8e0ec; -$muted: #6b7a90; -$danger: #b3261e; -$success: #1e7d3c; +// Modern Color Tokens +$primary: #1e3a8a; +$primary-light: #eff6ff; +$primary-border: #bfdbfe; +$accent-blue: #2563eb; +$text-main: #0f172a; +$text-muted: #64748b; +$border-color: #e2e8f0; +$border-light: #f1f5f9; +$bg-surface: #fff; +$bg-slate: #f8fafc; -.intro { - font-size: 13px; - color: $muted; - margin: 0 0 12px; +$success: #059669; +$success-bg: #ecfdf5; +$success-border: #a7f3d0; + +$warning: #d97706; +$warning-bg: #fffbeb; +$warning-border: #fde68a; + +$danger: #dc2626; +$danger-bg: #fef2f2; +$danger-border: #fecaca; + +:host { + display: block; + width: 100%; } -.requirement-summary { +.dialog-wrapper { + margin: -24px; display: flex; flex-direction: column; - gap: 4px; - background: #f4f7fb; - border: 1px solid $border; - border-radius: 6px; - padding: 10px 12px; - font-size: 13px; - color: $primary; - margin: 0 0 14px; + background: #fff; + border-radius: 16px; + overflow: hidden; } -.requirement-progress { - font-size: 12px; - font-weight: 500; - color: $danger; +/* Header Banner - Spacious & Premium */ +.dialog-header-banner { + display: flex; + align-items: center; + gap: 1rem; + padding: 1.1rem 1.5rem; + background: linear-gradient(135deg, #1e3a8a 0%, #2563eb 100%); + color: #fff; - &.covered { - color: $success; + .header-icon-box { + display: flex; + align-items: center; + justify-content: center; + width: 42px; + height: 42px; + background: rgb(255 255 255 / 20%); + border-radius: 10px; + backdrop-filter: blur(4px); + + mat-icon { + font-size: 24px; + width: 24px; + height: 24px; + color: #fff; + } + } + + .header-text { + flex: 1; + + h2 { + margin: 0; + font-size: 1.25rem; + font-weight: 700; + letter-spacing: -0.01em; + color: #fff; + } + + p { + margin: 2px 0 0; + font-size: 0.85rem; + color: rgb(255 255 255 / 88%); + } + } + + .close-header-btn { + color: #fff; + width: 36px; + height: 36px; + line-height: 36px; + border-radius: 8px; + + &:hover { + background: rgb(255 255 255 / 15%); + } } } -.voucher-section { - margin: 0 0 14px; +.dialog-body { + padding: 1.25rem 1.5rem !important; + max-height: 76vh; + overflow-y: auto; + box-sizing: border-box; + + /* Custom Scrollbar */ + &::-webkit-scrollbar { + width: 6px; + } + + &::-webkit-scrollbar-thumb { + background: #cbd5e1; + border-radius: 999px; + } } -.voucher-section-label { - font-size: 11px; +/* Intro Callout */ +.dialog-intro-banner { + display: flex; + align-items: flex-start; + gap: 0.65rem; + background: #f0f9ff; + border: 1px solid #bae6fd; + border-radius: 10px; + padding: 0.75rem 1rem; + font-size: 0.84rem; + color: #0369a1; + margin-bottom: 1rem; + + .banner-icon { + font-size: 20px; + width: 20px; + height: 20px; + color: #0284c7; + margin-top: 1px; + flex-shrink: 0; + } + + .inline-icon { + font-size: 15px; + width: 15px; + height: 15px; + vertical-align: middle; + } +} + +/* Requirement Summary Stat Card */ +.requirement-summary-card { + background: $bg-slate; + border: 1px solid $border-color; + border-radius: 12px; + padding: 1rem 1.25rem; + margin-bottom: 1rem; + + .req-header { + display: flex; + align-items: center; + gap: 0.4rem; + font-size: 0.88rem; + font-weight: 700; + color: $text-main; + margin-bottom: 0.65rem; + padding-bottom: 0.35rem; + border-bottom: 1px solid $border-light; + + .req-icon { + font-size: 18px; + width: 18px; + height: 18px; + color: $accent-blue; + } + } + + .req-metrics { + display: flex; + align-items: center; + gap: 1rem; + flex-wrap: wrap; + + .metric-item { + display: flex; + flex-direction: column; + + .m-label { + font-size: 0.72rem; + text-transform: uppercase; + letter-spacing: 0.04em; + color: $text-muted; + font-weight: 600; + } + + .m-val { + font-size: 0.98rem; + color: $text-main; + } + + &.highlight-total { + background: linear-gradient(135deg, #eff6ff 0%, #dbeafe 100%); + border: 1px solid $primary-border; + border-radius: 8px; + padding: 0.35rem 0.85rem; + + .m-label { + color: #1e40af; + } + + .m-val { + color: #1e3a8a; + font-size: 1.1rem; + } + } + } + + .metric-plus, + .metric-equals { + font-size: 1.1rem; + font-weight: 700; + color: $text-muted; + } + } + + .req-status-bar { + border-top: 1px solid $border-light; + padding-top: 0.5rem; + margin-top: 0.5rem; + } +} + +/* Search Sections */ +.search-section { + margin-bottom: 1rem; + + .section-label { + display: flex; + align-items: center; + gap: 0.4rem; + font-size: 0.88rem; + font-weight: 700; + color: $text-main; + margin-bottom: 0.5rem; + + .sec-icon { + font-size: 18px; + width: 18px; + height: 18px; + color: $accent-blue; + } + } +} + +/* Lookup Toolbar - Wide Layout */ +.lookup-toolbar { + display: grid; + grid-template-columns: 1.2fr 1fr; + gap: 1rem; + background: $bg-slate; + padding: 0.85rem 1rem; + border-radius: 10px; + border: 1px solid $border-color; + + @media (width <= 680px) { + grid-template-columns: 1fr; + } +} + +.toolbar-label { + display: flex; + align-items: center; + gap: 4px; + font-size: 0.75rem; font-weight: 600; text-transform: uppercase; - letter-spacing: 0.4px; - color: $muted; - margin: 0 0 6px; + letter-spacing: 0.04em; + color: $text-muted; + margin-bottom: 5px; + + .mini-icon { + font-size: 15px; + width: 15px; + height: 15px; + } +} + +.custom-select { + width: 100%; + height: 38px; + padding: 0 0.75rem; + border: 1px solid #cbd5e1; + border-radius: 8px; + font-size: 0.88rem; + color: $text-main; + background: #fff; + box-sizing: border-box; + + &:focus { + outline: none; + border-color: $accent-blue; + box-shadow: 0 0 0 3px rgb(37 99 235 / 10%); + } +} + +.lookup-input-group { + display: flex; + gap: 0.5rem; + + .lookup-input { + flex: 1; + height: 38px; + padding: 0 0.75rem; + border: 1px solid #cbd5e1; + border-radius: 8px; + font-size: 0.88rem; + color: $text-main; + background: #fff; + box-sizing: border-box; + + &:focus { + outline: none; + border-color: $accent-blue; + box-shadow: 0 0 0 3px rgb(37 99 235 / 10%); + } + } + + .find-btn { + height: 38px; + border-radius: 8px; + padding: 0 1rem; + display: inline-flex; + align-items: center; + gap: 4px; + font-size: 0.85rem; + font-weight: 600; + + mat-icon { + font-size: 18px; + width: 18px; + height: 18px; + } + } } .charge-receipt-found { display: flex; align-items: center; - gap: 8px; - background: #eefaf1; - border: 1px solid #bfe6cb; - border-radius: 6px; - padding: 8px 10px; - font-size: 13px; + gap: 0.75rem; + background: $success-bg; + border: 1px solid $success-border; + border-radius: 10px; + padding: 0.65rem 1rem; + font-size: 0.85rem; color: $success; &.short { - background: #fdecea; - border-color: #f5c6c2; + background: $danger-bg; + border-color: $danger-border; color: $danger; } mat-icon { + font-size: 20px; + width: 20px; + height: 20px; flex-shrink: 0; } -} -.charge-receipt-detail { - flex: 1; - min-width: 0; + .change-btn { + margin-left: auto; + font-size: 0.8rem; + height: 32px; + line-height: 32px; + padding: 0 0.85rem; + } } .charge-receipt-hint { - font-size: 11px; - color: $muted; + font-size: 0.76rem; + color: $text-muted; margin-top: 4px; } -.voucher-lookup { - display: flex; - gap: 8px; - align-items: center; - margin: 0 0 14px; -} +/* Unattributed Section */ +.unattributed-section { + background: #fffdf5; + border: 1px solid $warning-border; + border-radius: 10px; + padding: 0.85rem; + margin-bottom: 1rem; -.voucher-input { - flex: 1; - min-width: 0; - padding: 9px 11px; - border: 1px solid $border; - border-radius: 6px; - font-family: 'Roboto Mono', monospace; - font-size: 13px; - letter-spacing: 0.4px; - color: $primary; - - &:focus { - outline: none; - border-color: $primary; + .attach-btn { + height: 30px; + line-height: 30px; + font-size: 0.78rem; + padding: 0 0.75rem; } - &:disabled { - background: #f4f7fb; - color: $muted; + // A row already linked to the member this dialog is settling -- the same voucher also sits, + // pre-loaded and tickable, in the Member Unapplied Receipts table below. Called out rather than + // removed, since it is the same receipt reachable two ways for two different purposes. + .own-client-row { + background: $success-bg; + + &:hover { + background: $success-bg; + } + + .voucher-badge { + border-color: $success-border; + } + } + + .own-client-badge { + display: block; + margin-top: 2px; + font-size: 0.65rem; + font-weight: 700; + color: $success; } } -.gl-select-field { - margin: 0 0 10px; -} - -.gl-select { - width: 100%; - padding: 9px 11px; - border: 1px solid $border; - border-radius: 6px; - font-size: 13px; - color: $primary; +/* Vouchers Array Card & Table */ +.vouchers-card { + border: 1px solid $border-color; + border-radius: 10px; background: #fff; - - &:focus { - outline: none; - border-color: $primary; - } - - &:disabled { - background: #f4f7fb; - color: $muted; - } + overflow: hidden; + box-shadow: 0 1px 3px rgb(0 0 0 / 5%); } -.loading { +.vouchers-header { display: flex; + justify-content: space-between; align-items: center; - gap: 10px; - padding: 16px 0; - font-size: 13px; - color: $muted; + padding: 0.65rem 1rem; + background: #f1f5f9; + border-bottom: 1px solid $border-color; + + .vouchers-title { + display: flex; + align-items: center; + gap: 0.5rem; + font-size: 0.9rem; + font-weight: 700; + color: $text-main; + + .v-icon { + font-size: 18px; + width: 18px; + height: 18px; + color: $accent-blue; + } + + .badge-count { + background: $accent-blue; + color: #fff; + font-size: 0.75rem; + font-weight: 700; + padding: 1px 8px; + border-radius: 999px; + } + } + + .select-all-btn { + font-size: 0.8rem; + color: $accent-blue; + font-weight: 600; + + mat-icon { + font-size: 18px; + width: 18px; + height: 18px; + margin-right: 4px; + } + } } -.empty { - display: flex; - gap: 10px; - align-items: flex-start; - background: #f4f7fb; - border: 1px solid $border; - border-radius: 6px; - padding: 12px; - font-size: 13px; -} - -.empty-hint { - color: $muted; - margin-top: 4px; +.table-responsive { + width: 100%; + overflow-x: auto; } .receipt-table { width: 100%; - border-collapse: collapse; - font-size: 13px; - border: 1px solid $border; - border-radius: 6px; - overflow: hidden; + border-collapse: separate; + border-spacing: 0; + font-size: 0.84rem; th { - background: #eef2f8; - color: $primary; - font-size: 11px; - font-weight: 600; + background: $bg-slate; + color: $text-muted; + font-size: 0.72rem; + font-weight: 700; text-transform: uppercase; - letter-spacing: 0.4px; - padding: 9px 12px; - white-space: nowrap; + letter-spacing: 0.04em; + padding: 0.65rem 0.85rem; + border-bottom: 1px solid $border-color; } td { - padding: 10px 12px; - border-bottom: 1px solid #eceff4; - color: #1f2937; + padding: 0.6rem 0.85rem; + border-bottom: 1px solid $border-light; + vertical-align: middle; } - tbody tr:last-child td { - border-bottom: none; - } - - /* Just wide enough for the box, so the voucher number starts near the left edge instead of - after a column of empty space. */ - .col-tick { - width: 36px; - } - - tbody tr { + .receipt-row { cursor: pointer; + transition: background 0.12s ease; &:hover { - background: #f4f7fb; + background: #f8fafc; } &.selected { - background: #e7effb; + background: #eff6ff; + + .voucher-badge { + background: #dbeafe; + color: #1e40af; + border-color: #bfdbfe; + } } } - /* Tabular figures so the amounts line up digit for digit down the column and against the total - below them — the whole point of the column is that it can be read as a sum. */ - .amount-cell { - font-variant-numeric: tabular-nums; - font-weight: 500; - white-space: nowrap; + .col-checkbox { + width: 40px; } - tfoot .total-row { - background: #f4f7fb; - border-top: 2px solid #cbd5e1; + .col-actions { + width: 48px; + } + + .custom-checkbox { + width: 17px; + height: 17px; + accent-color: $accent-blue; + cursor: pointer; + } + + .voucher-badge { + display: inline-block; + background: #f1f5f9; + color: #334155; + padding: 3px 8px; + border-radius: 6px; + border: 1px solid #cbd5e1; + font-weight: 600; + font-size: 0.82rem; + } + + .desc-cell { + color: $text-muted; + max-width: 200px; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; + } + + .user-chip { + display: inline-flex; + align-items: center; + gap: 4px; + background: $bg-slate; + border: 1px solid $border-color; + padding: 2px 8px; + border-radius: 999px; + font-size: 0.78rem; + color: $text-muted; + + .chip-icon { + font-size: 13px; + width: 13px; + height: 13px; + } + } + + .amount-cell { + color: $text-main; + font-size: 0.9rem; + } + + .view-detail-btn { + width: 32px; + height: 32px; + line-height: 32px; + + .eye-icon { + font-size: 20px; + width: 20px; + height: 20px; + color: $accent-blue; + } + } +} + +/* Footer & Status Badges */ +.vouchers-footer { + display: flex; + justify-content: space-between; + align-items: center; + padding: 0.65rem 1rem; + background: $bg-slate; + border-top: 1.5px solid #cbd5e1; + flex-wrap: wrap; + gap: 0.75rem; + + .selected-summary { + display: flex; + align-items: center; + gap: 1.25rem; + flex-wrap: wrap; + + .summary-text { + font-size: 0.82rem; + color: $text-muted; + } + + .summary-amounts { + display: flex; + align-items: center; + gap: 0.4rem; + font-size: 0.85rem; + + .total-label { + color: $text-muted; + } + + .total-value { + color: $primary; + font-family: 'Courier New', monospace; + } + + .target-divider { + color: $text-muted; + } + + .target-value { + color: $text-muted; + } + } + } +} + +.coverage-status { + .status-badge { + display: inline-flex; + align-items: center; + gap: 4px; + padding: 4px 12px; + border-radius: 999px; + font-size: 0.78rem; + font-weight: 700; + + mat-icon { + font-size: 16px; + width: 16px; + height: 16px; + } + + &.badge-neutral { + background: #f1f5f9; + color: #64748b; + border: 1px solid #cbd5e1; + } + + &.badge-warning { + background: $warning-bg; + color: $warning; + border: 1px solid $warning-border; + } + + &.badge-success { + background: $success-bg; + color: $success; + border: 1px solid $success-border; + } + } +} + +.funding-loading, +.funding-empty { + display: flex; + align-items: center; + gap: 0.75rem; + padding: 1rem; + font-size: 0.85rem; + background: $bg-slate; + border: 1px solid $border-color; + border-radius: 10px; + color: $text-muted; +} + +/* Alert Boxes */ +.alert-box { + display: flex; + align-items: center; + gap: 0.65rem; + padding: 0.65rem 1rem; + border-radius: 10px; + font-size: 0.84rem; + + mat-icon { + font-size: 20px; + width: 20px; + height: 20px; + flex-shrink: 0; + } + + &.alert-error { + background: $danger-bg; + color: $danger; + border: 1px solid $danger-border; + } +} + +/* Dialog Actions Bar */ +.dialog-actions-bar { + display: flex; + justify-content: flex-end; + align-items: center; + gap: 0.75rem; + padding: 0.85rem 1.5rem !important; + background: $bg-slate; + border-top: 1px solid $border-color; + + .action-btn { + height: 40px; + padding: 0 1.5rem; + border-radius: 8px; + font-size: 0.88rem; + font-weight: 600; + + mat-icon { + font-size: 18px; + width: 18px; + height: 18px; + margin-right: 4px; + } + } +} + +/* POPUP MODAL DIALOG STYLES */ +.modal-backdrop { + position: fixed; + inset: 0; + background: rgb(15 23 42 / 55%); + backdrop-filter: blur(4px); + z-index: 2000; + display: flex; + align-items: center; + justify-content: center; + padding: 1rem; +} + +.voucher-modal-dialog { + background: #fff; + border-radius: 14px; + width: 100%; + max-width: 36rem; + box-shadow: 0 25px 50px -12px rgb(15 23 42 / 30%); + border: 1px solid $border-color; + overflow: hidden; + animation: modal-scale-in 0.2s ease-out; + + .modal-header { + display: flex; + justify-content: space-between; + align-items: center; + padding: 1rem 1.25rem; + background: linear-gradient(135deg, #1e3a8a 0%, #2563eb 100%); + color: #fff; + + .header-title { + display: flex; + align-items: center; + gap: 0.5rem; + font-size: 1rem; + font-weight: 700; + + .m-icon { + font-size: 22px; + width: 22px; + height: 22px; + } + + .modal-badge { + background: rgb(255 255 255 / 20%); + padding: 2px 8px; + border-radius: 6px; + font-size: 0.82rem; + } + } + + .close-btn { + color: #fff; + width: 32px; + height: 32px; + line-height: 32px; + } + } + + .modal-body { + padding: 1.25rem; + max-height: 70vh; + overflow-y: auto; + } + + .detail-grid { + display: grid; + grid-template-columns: repeat(2, 1fr); + gap: 0.85rem; + + @media (width <= 480px) { + grid-template-columns: 1fr; + } + } + + .detail-item { + display: flex; + flex-direction: column; + gap: 2px; + background: $bg-slate; + padding: 0.6rem 0.85rem; + border-radius: 8px; + border: 1px solid $border-light; + + .detail-label { + font-size: 0.72rem; + text-transform: uppercase; + letter-spacing: 0.04em; + color: $text-muted; + font-weight: 600; + } + + .detail-val { + font-size: 0.88rem; + color: $text-main; + } + + &.full-width { + grid-column: 1 / -1; + } + + &.highlight-amount-box { + background: $primary-light; + border-color: $primary-border; + + .detail-label { + color: #1e40af; + } + + .amount-val { + font-size: 1.35rem; + font-weight: 700; + color: #1e3a8a; + } + } + } + + /* The receipt's own real Dr/Cr GL legs -- what this deposit actually posted, never the GL account + currently picked in the by-voucher search box, which is unrelated screen state. */ + .gl-entries-loading, + .gl-entries-empty { + display: flex; + align-items: center; + gap: 0.4rem; + font-size: 0.8rem; + color: $text-muted; + margin-top: 4px; + } + + .gl-entries-table { + width: 100%; + border-collapse: collapse; + margin-top: 6px; + font-size: 0.82rem; + + th { + text-align: left; + font-size: 0.68rem; + text-transform: uppercase; + letter-spacing: 0.04em; + color: $text-muted; + font-weight: 700; + padding: 4px 8px; + border-bottom: 1px solid $border-color; + } td { - padding-top: 10px; - font-size: 13px; - font-weight: 600; - color: #1f3d7a; + padding: 6px 8px; + color: $text-main; + border-bottom: 1px solid $border-light; + } + + tbody tr:last-child td { border-bottom: none; } } + + .drcr-badge { + display: inline-block; + padding: 1px 8px; + border-radius: 999px; + font-size: 0.7rem; + font-weight: 700; + + &.dr { + background: $danger-bg; + color: $danger; + border: 1px solid $danger-border; + } + + &.cr { + background: $success-bg; + color: $success; + border: 1px solid $success-border; + } + } + + .status-pill { + display: inline-block; + padding: 2px 8px; + border-radius: 999px; + font-size: 0.75rem; + font-weight: 700; + width: fit-content; + + &.status-unapplied { + background: $success-bg; + color: $success; + border: 1px solid $success-border; + } + } + + .note-box { + background: #fff; + border: 1px solid $border-color; + border-radius: 8px; + padding: 0.65rem; + font-size: 0.84rem; + color: $text-main; + line-height: 1.45; + margin-top: 4px; + white-space: pre-wrap; + } + + .modal-footer { + display: flex; + justify-content: flex-end; + gap: 0.75rem; + padding: 0.85rem 1.25rem; + background: $bg-slate; + border-top: 1px solid $border-color; + + .modal-action-btn { + mat-icon { + font-size: 18px; + width: 18px; + height: 18px; + margin-right: 4px; + } + } + } +} + +@keyframes modal-scale-in { + from { + opacity: 0; + transform: scale(0.95); + } + + to { + opacity: 1; + transform: scale(1); + } } .ta-left { @@ -246,25 +990,50 @@ $success: #1e7d3c; text-align: center; } -.mono { +.font-mono { font-family: 'Courier New', monospace; } -.note { - margin-top: 10px; - font-size: 12px; - color: $muted; +.font-semibold { + font-weight: 600; } -.alert-error { - display: flex; - align-items: center; - gap: 8px; - background: #fdecea; - color: $danger; - border: 1px solid #f5c6c2; - border-radius: 6px; - padding: 10px 12px; - font-size: 13px; - margin-bottom: 12px; +.font-accent { + font-family: + system-ui, + -apple-system, + BlinkMacSystemFont, + 'Segoe UI', + Roboto, + sans-serif; +} + +.font-warning { + color: $warning !important; +} + +.mt-6 { + margin-top: 6px; +} + +.mt-8 { + margin-top: 8px; +} + +.text-amber { + color: #b45309; +} + +.animate-fade-in { + animation: fade-in 0.2s ease-in-out; +} + +@keyframes fade-in { + from { + opacity: 0; + } + + to { + opacity: 1; + } } diff --git a/src/app/shares/shares-account-view/apply-collection-dialog/apply-collection-dialog.component.ts b/src/app/shares/shares-account-view/apply-collection-dialog/apply-collection-dialog.component.ts index b556591c7..370bc5708 100644 --- a/src/app/shares/shares-account-view/apply-collection-dialog/apply-collection-dialog.component.ts +++ b/src/app/shares/shares-account-view/apply-collection-dialog/apply-collection-dialog.component.ts @@ -13,7 +13,7 @@ import { MAT_DIALOG_DATA, MatDialogRef, MatDialogModule } from '@angular/materia import { MatButtonModule } from '@angular/material/button'; import { MatIconModule } from '@angular/material/icon'; import { MatProgressSpinnerModule } from '@angular/material/progress-spinner'; -import { catchError, map } from 'rxjs/operators'; +import { catchError } from 'rxjs/operators'; import { of } from 'rxjs'; import { CashierService } from 'app/cashier/cashier.service'; import { SettingsService } from 'app/settings/settings.service'; @@ -97,6 +97,18 @@ export class ApplyCollectionDialogComponent implements OnInit { requirement: { shareAmount: number; chargeAmount: number; required: number } | null = null; loadingRequirement = true; + /** + * Share-collection receipts that are approved but belong to nobody yet. + * + *

+ * A receipt paid straight into a GL account names no member — a GL account is nobody's account — + * so it is never attributed on its own. Listing it here is not attribution: it only makes the + * voucher findable, so a counter that tagged a receipt "Share Collection" ten minutes ago is not + * told there is nothing to apply. Attaching it is still a click a person makes against a member + * they can see. + */ + unattributed: any[] = []; + public dialogRef = inject(MatDialogRef); public data = inject<{ shareAccountId: number; clientId: number; accountNo: string }>(MAT_DIALOG_DATA); @@ -124,6 +136,11 @@ export class ApplyCollectionDialogComponent implements OnInit { this.loading = false; }); + this.cashierService + .getUnattributedShareCollections() + .pipe(catchError(() => of(null))) + .subscribe((res: any) => (this.unattributed = res?.rows || [])); + this.cashierService .getShareAdvanceGlAccounts() .pipe( @@ -135,12 +152,17 @@ export class ApplyCollectionDialogComponent implements OnInit { ) .subscribe((res: any) => { this.glAccounts = res?.glAccounts || []; - // Auto-picked only when there is exactly one live candidate -- the backend already narrowed - // this list to accounts that actually hold an eligible receipt today, so a single option is - // unambiguous. Two or more still means the counter chooses by hand. + // Auto-picked only when it is genuinely the only option. The list now carries every + // postable GL account again (see the backend's own comment), so picking index 0 whenever + // one loads would silently select whichever account sorts first by gl_code -- Cash at + // Vault, almost always -- the exact bug this was fixed for once already. Two or more + // candidates always means the counter chooses by hand. if (this.glAccounts.length === 1 && !this.glAccountNo) { this.glAccountNo = this.glAccounts[0].gl_code; } + if (this.glAccounts.length === 1 && !this.chargeGlAccountNo) { + this.chargeGlAccountNo = this.glAccounts[0].gl_code; + } this.loadingGlAccounts = false; }); @@ -159,6 +181,92 @@ export class ApplyCollectionDialogComponent implements OnInit { }); } + /** Receipt object currently being viewed in detail popup. */ + viewingReceiptDetail: any = null; + + /** + * The receipt's own real Dr/Cr GL legs, under its own {@code gl_transaction_id} — never the GL + * account currently picked in the by-voucher search box above, which is unrelated screen state + * that can name a completely different account than the one this receipt actually sits in. + */ + viewingReceiptGlEntries: any[] | null = null; + loadingReceiptGlEntries = false; + + openReceiptDetail(receipt: any, event?: Event): void { + if (event) { + event.stopPropagation(); + } + this.viewingReceiptDetail = receipt; + this.viewingReceiptGlEntries = null; + this.loadingReceiptGlEntries = true; + this.cashierService + .getShareAdvanceGlEntries(receipt.id) + .pipe(catchError(() => of(null))) + .subscribe((res: any) => { + this.viewingReceiptGlEntries = res?.entries || []; + this.loadingReceiptGlEntries = false; + }); + } + + closeReceiptDetail(): void { + this.viewingReceiptDetail = null; + this.viewingReceiptGlEntries = null; + } + + toggleAll(): void { + if (this.isAllSelected()) { + this.selectedIds = []; + } else { + this.selectedIds = this.receipts.map((r) => r.id); + } + this.syncLookupFieldsToSelection(); + } + + /** + * Mirrors a ticked receipt's own GL account and voucher number into the Share Receipt Lookup + * fields above, so a tick is visibly confirmed rather than leaving those fields blank while the + * table below shows the real selection. Particularly worth seeing across instalments, where two + * receipts making up one settlement can sit in two different GL accounts, exactly like this + * member's own 200902 and 400105 receipts do. + * + * Shows the most recently ticked receipt when several are selected — there is no single GL + * account to show once more than one is chosen, and the most recent tick is the one the counter + * just made. Clears both fields once nothing is ticked. + */ + private syncLookupFieldsToSelection(): void { + if (!this.selectedIds.length) { + this.glAccountNo = ''; + this.voucherNo = ''; + return; + } + const lastId = this.selectedIds[this.selectedIds.length - 1]; + const receipt = this.receipts.find((r) => r.id === lastId); + if (receipt) { + this.glAccountNo = receipt.gl_code || this.glAccountNo; + this.voucherNo = receipt.voucher_no || receipt.receipt_no || ''; + } + } + + /** + * Whether an unattributed-list row is already linked to the member this dialog is settling. + * + * Such a row also appears, pre-loaded, in the Member Unapplied Receipts table below — that is not + * a duplicate to clear up, it is the same receipt reachable two ways. This only flags it so the + * counter understands why it shows up here too, rather than reading it as an unrelated stray + * voucher that happens to look identical. + */ + isOwnClientVoucher(u: any): boolean { + return u?.linkedClientId != null && Number(u.linkedClientId) === Number(this.data.clientId); + } + + isAllSelected(): boolean { + return this.receipts.length > 0 && this.selectedIds.length === this.receipts.length; + } + + isSomeSelected(): boolean { + return this.selectedIds.length > 0 && this.selectedIds.length < this.receipts.length; + } + /** * What the share receipts still need to reach — the share value alone once the entry fee has its * own verified receipt, otherwise the combined share-plus-fee total, matching exactly what the @@ -221,6 +329,7 @@ export class ApplyCollectionDialogComponent implements OnInit { ...this.selectedIds, id ]; + this.syncLookupFieldsToSelection(); } /** @@ -243,12 +352,48 @@ export class ApplyCollectionDialogComponent implements OnInit { * voucher belonging to somebody else is surfaced here as a clear mismatch rather than failing * later inside apply(). */ + /** + * Attaches one of the unattributed share-collection receipts to this member. + * + *

+ * Goes straight to {@link resolveVoucher} rather than through {@link lookupVoucher}: a row already + * on this list is either a fresh cashier deposit (which does carry its own GL account) or an + * already-adopted {@code m_share_advance_collection} row (the "linked to …" ones), whose GL account + * can legitimately come back blank — its subquery reads the posting the original adoption made, + * which a clerk-disbursement-funded receipt does not always leave behind cleanly. Either way the + * backend needs no GL account to resolve a voucher it can already find by number, so a blank one + * here must never block the click the way it rightly blocks a hand-typed search with nothing chosen + * yet. The list never decides whose money it is; the click does. + */ + attachUnattributed(row: any): void { + if (this.lookingUp || this.submitting) { + return; + } + this.resolveVoucher(row.voucherNo, row.glAccountNo || ''); + } + lookupVoucher(): void { const voucher = this.voucherNo.trim(); const glAccountNo = this.glAccountNo.trim(); + // A hand-typed search genuinely needs both fields: an empty GL account here means nothing has + // been chosen yet, not that one isn't needed — unlike {@link attachUnattributed}, which already + // knows exactly which receipt it means and never has this ambiguity. if (!voucher || !glAccountNo || this.lookingUp) { return; } + this.resolveVoucher(voucher, glAccountNo); + } + + /** + * Resolves a voucher number (with whatever GL account, if any, is known for it) and adds the + * receipt it finds to the table, ticked. + * + * Shared by {@link lookupVoucher} (a hand-typed search, where both fields are guaranteed non-blank + * by the Find button's own disabled state) and {@link attachUnattributed} (a click on a row already + * on screen, whose GL account can legitimately be blank) — the actual lookup and its bookkeeping + * afterwards are identical either way. + */ + private resolveVoucher(voucher: string, glAccountNo: string): void { this.lookingUp = true; this.errorMsg = ''; this.findVoucher(voucher, glAccountNo).subscribe((res) => { @@ -257,8 +402,8 @@ export class ApplyCollectionDialogComponent implements OnInit { return; } // Added to the table (replacing any stale copy) and ticked, unlike the listed receipts which - // are left for the clerk: typing a voucher number IS the deliberate choice of that receipt, - // so making them tick it again would be asking the same question twice. + // are left for the clerk: attaching a specific voucher IS the deliberate choice of that + // receipt, so making them tick it again would be asking the same question twice. this.receipts = [ res, ...this.receipts.filter((r) => r.id !== res.id) @@ -269,7 +414,19 @@ export class ApplyCollectionDialogComponent implements OnInit { res.id ]; } + // Once attached it belongs to this member, so it leaves the unattributed list rather than + // sitting there inviting a second, conflicting attachment. + this.unattributed = this.unattributed.filter((u) => u.voucherNo !== voucher); + // Cleared, not left showing this voucher: an instalment payment often means searching for a + // second receipt right after this one, and the box should be ready for that number, not still + // holding the one just added. this.voucherNo = ''; + // The GL Account picker still follows this receipt, exactly as it does when an already-listed + // receipt is ticked — res.gl_code is this receipt's own real GL account (see findByVoucher()'s + // subquery on the server), not whatever the search box happened to hold before this search. + if (res.gl_code) { + this.glAccountNo = res.gl_code; + } }); } @@ -311,14 +468,9 @@ export class ApplyCollectionDialogComponent implements OnInit { * own for the entry fee — since the two are never allowed to mix into the same sum. */ private findVoucher(voucher: string, glAccountNo: string) { + // No client-match check here by deliberate choice: any receipt can be attached to any share + // account, regardless of which member it was originally recorded against. return this.cashierService.getShareAdvanceByVoucher(voucher, glAccountNo, this.data.clientId).pipe( - map((res: any) => { - if (Number(res.client_id) !== Number(this.data.clientId)) { - this.errorMsg = `Voucher ${voucher} belongs to ${res.client_name}, not the holder of this share account.`; - return null; - } - return res; - }), catchError((err) => { this.errorMsg = this.readError(err, `Voucher ${voucher} could not be found.`); return of(null); diff --git a/src/app/shares/shares-account-view/shares-account-view.component.ts b/src/app/shares/shares-account-view/shares-account-view.component.ts index 84103260f..d97a3125c 100644 --- a/src/app/shares/shares-account-view/shares-account-view.component.ts +++ b/src/app/shares/shares-account-view/shares-account-view.component.ts @@ -149,6 +149,10 @@ export class SharesAccountViewComponent implements OnInit { // capital that was never issued. if (!this.isSettled) { this.buttonConfig.removeOption('Share Certificate'); + // A further tranche on top of an application the member never paid for makes no sense: the + // first allotment is still sitting unissued, and a second one would only grow a holding that + // does not exist yet. Settle the original collection first, then add to it. + this.buttonConfig.removeButton('Apply Additional Shares'); } else { // "Apply Collection" settles the account's ORIGINAL application funding -- a one-time // step. Once collectionApplied is true there is nothing left to settle this way; any @@ -202,7 +206,9 @@ export class SharesAccountViewComponent implements OnInit { */ private applyCollection() { const dialogRef = this.dialog.open(ApplyCollectionDialogComponent, { - width: '560px', + width: '820px', + maxWidth: '92vw', + panelClass: 'apply-collection-dialog-panel', data: { shareAccountId: this.sharesAccountData.id, clientId: this.sharesAccountData.clientId, diff --git a/src/app/shares/shares-routing.module.ts b/src/app/shares/shares-routing.module.ts index a2f7f614f..d2c17ecd1 100644 --- a/src/app/shares/shares-routing.module.ts +++ b/src/app/shares/shares-routing.module.ts @@ -41,6 +41,139 @@ const routes: Routes = [ data: { title: 'Share Holders', breadcrumb: 'Share Holders' }, component: ShareHoldersComponent }, + // Share reports. Lazily loaded one component at a time: a clerk opening the Share menu for a + // withdrawal should not pay for four report screens they did not ask for, and each is + // self-contained enough that there is nothing to share by bundling them together. + { + path: 'reports/final-balance', + data: { title: 'Share Final Balance', breadcrumb: 'Final Balance' }, + loadComponent: () => + import('./reports/share-final-balance/share-final-balance.component').then( + (m) => m.ShareFinalBalanceComponent + ) + }, + { + path: 'reports/account-statement', + data: { title: 'Share Account Statement', breadcrumb: 'Account Statement' }, + loadComponent: () => + import('./reports/share-account-statement/share-account-statement.component').then( + (m) => m.ShareAccountStatementComponent + ) + }, + { + path: 'reports/share-issued', + data: { title: 'Share Issued Register', breadcrumb: 'Share Issued' }, + loadComponent: () => + import('./reports/share-issued-report/share-issued-report.component').then( + (m) => m.ShareIssuedReportComponent + ) + }, + { + path: 'reports/share-status', + data: { title: 'Share Status Report', breadcrumb: 'Share Status' }, + loadComponent: () => + import('./reports/share-status-report/share-status-report.component').then( + (m) => m.ShareStatusReportComponent + ) + }, + { + path: 'reports/nominal-shares', + data: { title: 'Nominal Share Report', breadcrumb: 'Nominal Share Report' }, + loadComponent: () => + import('./reports/share-nominal-report/share-nominal-report.component').then( + (m) => m.ShareNominalReportComponent + ) + }, + { + path: 'reports/share-wise', + data: { title: 'Share Wise Report', breadcrumb: 'Share Wise Report' }, + loadComponent: () => + import('./reports/share-wise-report/share-wise-report.component').then((m) => m.ShareWiseReportComponent) + }, + { + path: 'reports/guarantor-detail', + data: { title: 'Guarantor Detail', breadcrumb: 'Guarantor Detail' }, + loadComponent: () => + import('./reports/share-guarantor-report/share-guarantor-report.component').then( + (m) => m.ShareGuarantorReportComponent + ) + }, + { + path: 'reports/member-interest', + data: { title: 'Total Interest Received & Receivable', breadcrumb: 'Total Int Rec' }, + loadComponent: () => + import('./reports/share-member-interest-report/share-member-interest-report.component').then( + (m) => m.ShareMemberInterestReportComponent + ) + }, + { + path: 'reports/enquiry', + data: { title: 'Share Enquiry', breadcrumb: 'Share Enquiry' }, + loadComponent: () => + import('./reports/share-enquiry/share-enquiry.component').then((m) => m.ShareEnquiryComponent) + }, + { + path: 'reports/payment-certificate', + data: { title: 'Share Payment Certificate', breadcrumb: 'Payment Certificate' }, + loadComponent: () => + import('./reports/share-payment-certificate/share-payment-certificate.component').then( + (m) => m.SharePaymentCertificateComponent + ) + }, + { + path: 'reports/share-detail', + data: { title: 'Share Detail', breadcrumb: 'Share Detail' }, + loadComponent: () => + import('./reports/share-detail-report/share-detail-report.component').then( + (m) => m.ShareDetailReportComponent + ) + }, + { + path: 'reports/dividend-statement', + data: { title: 'Dividend Statement', breadcrumb: 'Dividend Statement' }, + loadComponent: () => + import('./reports/share-dividend-statement/share-dividend-statement.component').then( + (m) => m.ShareDividendStatementComponent + ) + }, + { + path: 'reports/folio-statement', + data: { title: 'Folio Statement', breadcrumb: 'Folio Statement' }, + loadComponent: () => + import('./reports/share-folio-statement/share-folio-statement.component').then( + (m) => m.ShareFolioStatementComponent + ) + }, + { + path: 'transfers', + data: { title: 'Share Transfer', breadcrumb: 'Share Transfer' }, + loadComponent: () => + import('./reports/share-transfer/share-transfer.component').then((m) => m.ShareTransferComponent) + }, + { + path: 'reports/distinctive-numbers', + data: { title: 'Share Distinctive Number Detail', breadcrumb: 'Distinctive Numbers' }, + loadComponent: () => + import('./reports/share-distinctive-numbers/share-distinctive-numbers.component').then( + (m) => m.ShareDistinctiveNumbersComponent + ) + }, + { + path: 'reports/area-wise', + data: { title: 'Area Wise Shareholding', breadcrumb: 'Area Wise' }, + loadComponent: () => + import('./reports/share-area-wise/share-area-wise.component').then((m) => m.ShareAreaWiseComponent) + }, + { + path: 'reports/labels', + data: { title: 'Share Labels', breadcrumb: 'Share Label' }, + loadComponent: () => import('./reports/share-label/share-label.component').then((m) => m.ShareLabelComponent) + }, + { + path: 'reports/letters', + data: { title: 'Share Letter', breadcrumb: 'Share Letter' }, + loadComponent: () => import('./reports/share-letter/share-letter.component').then((m) => m.ShareLetterComponent) + }, { path: 'create', data: { title: 'Create Shares Account', breadcrumb: 'Create Shares Account' }, diff --git a/src/app/shares/shares.service.ts b/src/app/shares/shares.service.ts index 0bf0f7d71..a7d277584 100644 --- a/src/app/shares/shares.service.ts +++ b/src/app/shares/shares.service.ts @@ -190,4 +190,176 @@ export class SharesService { rejectShareAccountEdit(accountId: string, rejectReason?: string): Observable { return this.http.post(`/shareaccounts/${accountId}/edit/reject`, { rejectReason: rejectReason || '' }); } + + // ── Share reports ────────────────────────────────────────────────────── + // + // Each of these is a single server-side query returning finished rows plus its own totals, rather + // than the fetch-all-accounts-then-fan-out pattern the older letter-mgmt reports use. That pattern + // stops at the 200-account page it asks for and recomputes totals from whatever arrived, which a + // report a balance sheet has to reconcile to cannot do. + + /** + * Share capital held per member as on a date, derived from settled purchases less redemptions. + * + * @param params asOnDate (yyyy-MM-dd), officeId, productId, includeZero — all optional. + */ + getShareFinalBalance(params: { + asOnDate?: string; + officeId?: number | string; + productId?: number | string; + includeZero?: boolean; + }): Observable { + return this.http.get('/sharereports/final-balance', { params: this.cleanParams(params) }); + } + + /** One share account's movements in date order, with the running holding after each. */ + getShareAccountStatement( + shareAccountId: number | string, + params: { fromDate?: string; toDate?: string } = {} + ): Observable { + return this.http.get(`/sharereports/account-statement/${shareAccountId}`, { params: this.cleanParams(params) }); + } + + /** Allotment register — one row per tranche of shares issued in the period. */ + getShareIssuedReport(params: { + fromDate?: string; + toDate?: string; + officeId?: number | string; + productId?: number | string; + status?: number | string; + }): Observable { + return this.http.get('/sharereports/share-issued', { params: this.cleanParams(params) }); + } + + /** Share accounts grouped by status and product, with the accounts behind each group. */ + getShareStatusSummary(params: { officeId?: number | string; productId?: number | string } = {}): Observable { + return this.http.get('/sharereports/status-summary', { params: this.cleanParams(params) }); + } + + /** Holdings measured against the share class's own nominal, minimum and maximum, at face value. */ + getShareNominalReport( + params: { asOnDate?: string; officeId?: number | string; productId?: number | string } = {} + ): Observable { + return this.http.get('/sharereports/nominal-shares', { params: this.cleanParams(params) }); + } + + /** Shareholding pattern — members and capital per holding band, plus the per-product split. */ + getShareWiseReport( + params: { asOnDate?: string; officeId?: number | string; productId?: number | string } = {} + ): Observable { + return this.http.get('/sharereports/share-wise', { params: this.cleanParams(params) }); + } + + /** Active loan guarantors with the shareholding each one holds in the society. */ + getShareGuarantorDetail(params: { officeId?: number | string; onlyMembers?: boolean } = {}): Observable { + return this.http.get('/sharereports/guarantor-detail', { params: this.cleanParams(params) }); + } + + /** Interest received and receivable from the members who hold share capital. */ + getShareMemberLoanInterest( + params: { officeId?: number | string; includeClosedLoans?: boolean } = {} + ): Observable { + return this.http.get('/sharereports/member-loan-interest', { params: this.cleanParams(params) }); + } + + /** + * One member's whole share position — accounts, movements, dividends and linked savings. + * + * Sends `search` when the member is being looked for and `clientId` once one is chosen: a search + * matching several members comes back as a list to pick from rather than guessing between them. + */ + getShareEnquiry(params: { search?: string; clientId?: number | string }): Observable { + return this.http.get('/sharereports/enquiry', { params: this.cleanParams(params) }); + } + + /** What a member actually paid for their shares, and against which receipt — for the certificate. */ + getSharePaymentCertificate(shareAccountId: number | string): Observable { + return this.http.get(`/sharereports/payment-certificate/${shareAccountId}`); + } + + /** The full share register: every account with its class, terms, lock-in, charges and resolution. */ + getShareDetailRegister( + params: { officeId?: number | string; productId?: number | string; status?: number | string } = {} + ): Observable { + return this.http.get('/sharereports/share-detail', { params: this.cleanParams(params) }); + } + + /** One member's dividends across every year, declared against paid. */ + getShareDividendStatement(params: { search?: string; clientId?: number | string }): Observable { + return this.http.get('/sharereports/dividend-statement', { params: this.cleanParams(params) }); + } + + /** One member's folio: every share account they hold, with the movements running across all of them. */ + getShareFolioStatement(params: { + search?: string; + clientId?: number | string; + fromDate?: string; + toDate?: string; + }): Observable { + return this.http.get('/sharereports/folio-statement', { params: this.cleanParams(params) }); + } + + /** The numbered share range each allotment covers, derived from issue order per share class. */ + getShareDistinctiveNumbers( + params: { productId?: number | string; clientId?: number | string } = {} + ): Observable { + return this.http.get('/sharereports/distinctive-numbers', { params: this.cleanParams(params) }); + } + + /** Members and share capital grouped by the area their address puts them in. */ + getShareAreaWise(params: { productId?: number | string } = {}): Observable { + return this.http.get('/sharereports/area-wise', { params: this.cleanParams(params) }); + } + + /** + * Name, address, folio and holding for every shareholding member. + * + * One call behind both Share Label and Share Letter — they want the same rows and differ only in + * what is printed around them. + */ + getShareMailingList( + params: { productId?: number | string; officeId?: number | string; area?: string } = {} + ): Observable { + return this.http.get('/sharereports/mailing-list', { params: this.cleanParams(params) }); + } + + // ── Share transfers (maker-checker) ──────────────────────────────────── + + /** Records a transfer between two members. Nothing moves until a checker approves it. */ + submitShareTransfer(data: any): Observable { + return this.http.post('/sharetransfers', data); + } + + /** The transfer register — every transfer with both members and the resolution behind it. */ + getShareTransfers(status?: string): Observable { + return this.http.get('/sharetransfers', { params: this.cleanParams({ status }) }); + } + + /** Approves a transfer: re-checks every rule, then moves the shares. */ + approveShareTransfer(transferId: number): Observable { + return this.http.post(`/sharetransfers/${transferId}/approve`, {}); + } + + rejectShareTransfer(transferId: number, rejectionReason?: string): Observable { + return this.http.post(`/sharetransfers/${transferId}/reject`, { rejectionReason: rejectionReason || '' }); + } + + /** + * Drops empty filters rather than sending them blank — the report endpoints treat an absent + * parameter as "no filter", but a present-and-empty one would be read as a real value. + */ + private cleanParams(params: Record): HttpParams { + let httpParams = new HttpParams(); + Object.entries(params).forEach( + ([ + key, + value + ]) => { + if (value !== null && value !== undefined && value !== '') { + httpParams = httpParams.set(key, String(value)); + } + } + ); + return httpParams; + } } diff --git a/src/environments/.env.ts b/src/environments/.env.ts index 4509e26ef..858410ec4 100644 --- a/src/environments/.env.ts +++ b/src/environments/.env.ts @@ -9,8 +9,8 @@ /* tslint:disable */ export default { 'mifos_x': { - 'version': '260916', - 'hash': 'c01615ee' + 'version': '260918', + 'hash': 'e53dfb5a' }, 'allow_switching_backend_instance': true }; diff --git a/src/main.scss b/src/main.scss index c6f7900ea..7b96bfb34 100644 --- a/src/main.scss +++ b/src/main.scss @@ -1960,3 +1960,14 @@ mifosx-client-datatable-step * { display: none !important; } } + +/* Apply Collection Dialog Panel Styling */ +.apply-collection-dialog-panel { + .mat-mdc-dialog-container, + .mdc-dialog__surface { + border-radius: 16px !important; + padding: 0 !important; + overflow: hidden !important; + box-shadow: 0 25px 60px -15px rgb(15 23 42 / 30%) !important; + } +} From dc7f66805683c60161164b363a491839b0983207 Mon Sep 17 00:00:00 2001 From: Nitesh Yadav Date: Fri, 18 Sep 2026 13:57:14 +0530 Subject: [PATCH 62/64] feat(shares): move voucher search behind a popup, not an always-visible toolbar The GL Account / Voucher No lookup is for the exception -- a receipt the unattributed list and the member's own list do not already carry -- so it now sits behind a "Search a Voucher Not Listed Below" button in both Apply Collection and Apply Additional Shares, opening the same lookup in a popup (matching the existing receipt-detail popup) instead of occupying screen space whether or not anyone needs it. A successful find closes the popup and the result appears ticked in the list behind it, same as before. --- .../apply-shares/apply-shares.component.html | 148 ++++++++++++------ .../apply-shares/apply-shares.component.scss | 30 ++++ .../apply-shares/apply-shares.component.ts | 22 +++ .../apply-collection-dialog.component.html | 143 +++++++++++------ .../apply-collection-dialog.component.scss | 30 ++++ .../apply-collection-dialog.component.ts | 22 +++ 6 files changed, 294 insertions(+), 101 deletions(-) diff --git a/src/app/shares/shares-account-actions/apply-shares/apply-shares.component.html b/src/app/shares/shares-account-actions/apply-shares/apply-shares.component.html index e4b31cf58..45cc66466 100644 --- a/src/app/shares/shares-account-actions/apply-shares/apply-shares.component.html +++ b/src/app/shares/shares-account-actions/apply-shares/apply-shares.component.html @@ -178,61 +178,25 @@

- Select the one receipt that covers this payment, or enter a voucher number to search. Click + Select the one receipt that covers this payment. Click visibility on any row to view full details popup.
- -
-
- - -
- -
- -
- - -
-
-
+ + +@if (voucherSearchOpen) { + +} diff --git a/src/app/shares/shares-account-actions/apply-shares/apply-shares.component.scss b/src/app/shares/shares-account-actions/apply-shares/apply-shares.component.scss index fdb52947f..7e7360995 100644 --- a/src/app/shares/shares-account-actions/apply-shares/apply-shares.component.scss +++ b/src/app/shares/shares-account-actions/apply-shares/apply-shares.component.scss @@ -335,6 +335,36 @@ mat-form-field { } } +.voucher-search-toggle-btn { + display: inline-flex; + align-items: center; + gap: 6px; + font-size: 0.82rem; + font-weight: 600; + color: $accent-blue; + border-color: $primary-border; + margin-bottom: 1rem; + + mat-icon { + font-size: 17px; + width: 17px; + height: 17px; + } +} + +.voucher-search-hint { + font-size: 0.8rem; + color: $text-muted; + margin: 0 0 0.85rem; +} + +// The shared .lookup-toolbar normally goes two-column above its own breakpoint of BROWSER width, +// which says nothing about how wide this popup itself is -- at the popup's width, two columns crowd +// the GL Account select against the voucher input. Single column reads better at this popup's width. +.voucher-search-dialog .lookup-toolbar { + grid-template-columns: 1fr; +} + /* Lookup Toolbar */ .lookup-toolbar { display: grid; diff --git a/src/app/shares/shares-account-actions/apply-shares/apply-shares.component.ts b/src/app/shares/shares-account-actions/apply-shares/apply-shares.component.ts index 98e09d124..099c1031f 100644 --- a/src/app/shares/shares-account-actions/apply-shares/apply-shares.component.ts +++ b/src/app/shares/shares-account-actions/apply-shares/apply-shares.component.ts @@ -100,6 +100,25 @@ export class ApplySharesComponent implements OnInit { lookingUpVoucher = false; voucherError = ''; + /** + * Whether the voucher-search popup is open. + * + * The GL Account / Voucher No lookup is for the exception — a receipt the unattributed list and + * this member's own list do not carry — so it sits behind a button rather than on screen whether + * or not anyone needs it, exactly like the receipt-detail popup already does for the same reason. + */ + voucherSearchOpen = false; + + openVoucherSearch(): void { + this.voucherError = ''; + this.voucherSearchOpen = true; + } + + closeVoucherSearch(): void { + this.voucherSearchOpen = false; + this.voucherError = ''; + } + /** Voucher / Receipt No shown in the "Find Specific Voucher" box. Mirrors whichever receipt is * ticked below (see {@link toggleVoucher}) so a tick is visibly confirmed up there too, and * carries whatever the counter has typed in when they search by hand instead. */ @@ -445,6 +464,9 @@ export class ApplySharesComponent implements OnInit { // Once attached it belongs to this member, so it leaves the unattributed list rather than // sitting there inviting a second, conflicting attachment. this.unattributed = this.unattributed.filter((u) => u.voucherNo !== voucherNo); + // Found it -- the popup has done its job, so it closes rather than sitting open over a + // receipt that now already shows, ticked, in the table behind it. + this.voucherSearchOpen = false; }); } diff --git a/src/app/shares/shares-account-view/apply-collection-dialog/apply-collection-dialog.component.html b/src/app/shares/shares-account-view/apply-collection-dialog/apply-collection-dialog.component.html index 12a30dadf..9e35e3841 100644 --- a/src/app/shares/shares-account-view/apply-collection-dialog/apply-collection-dialog.component.html +++ b/src/app/shares/shares-account-view/apply-collection-dialog/apply-collection-dialog.component.html @@ -80,57 +80,21 @@
} - +
- - -
-
- - -
- -
- -
- - -
-
-
+
@@ -575,3 +539,82 @@
} + + +@if (voucherSearchOpen) { + +} diff --git a/src/app/shares/shares-account-view/apply-collection-dialog/apply-collection-dialog.component.scss b/src/app/shares/shares-account-view/apply-collection-dialog/apply-collection-dialog.component.scss index 361dd3acc..e0ff13114 100644 --- a/src/app/shares/shares-account-view/apply-collection-dialog/apply-collection-dialog.component.scss +++ b/src/app/shares/shares-account-view/apply-collection-dialog/apply-collection-dialog.component.scss @@ -253,6 +253,36 @@ $danger-border: #fecaca; } } +.voucher-search-toggle-btn { + display: inline-flex; + align-items: center; + gap: 6px; + font-size: 0.82rem; + font-weight: 600; + color: $accent-blue; + border-color: $primary-border; + + mat-icon { + font-size: 17px; + width: 17px; + height: 17px; + } +} + +.voucher-search-hint { + font-size: 0.8rem; + color: $text-muted; + margin: 0 0 0.85rem; +} + +// The shared .lookup-toolbar normally goes two-column above 680px of BROWSER width, which says +// nothing about how wide this popup itself is -- at the popup's own ~36rem, two columns crowd the +// GL Account select against the voucher input. Single column reads better at this popup's width, +// full stop, rather than reintroducing a container query for one dialog. +.voucher-search-dialog .lookup-toolbar { + grid-template-columns: 1fr; +} + /* Lookup Toolbar - Wide Layout */ .lookup-toolbar { display: grid; diff --git a/src/app/shares/shares-account-view/apply-collection-dialog/apply-collection-dialog.component.ts b/src/app/shares/shares-account-view/apply-collection-dialog/apply-collection-dialog.component.ts index 370bc5708..51298aee5 100644 --- a/src/app/shares/shares-account-view/apply-collection-dialog/apply-collection-dialog.component.ts +++ b/src/app/shares/shares-account-view/apply-collection-dialog/apply-collection-dialog.component.ts @@ -72,6 +72,25 @@ export class ApplyCollectionDialogComponent implements OnInit { voucherNo = ''; lookingUp = false; + /** + * Whether the voucher-search popup is open. + * + * The GL Account / Voucher No lookup is for the exception — a receipt the unattributed list and + * the member's own list do not carry — so it sits behind a button rather than on screen whether or + * not anyone needs it, exactly like the receipt-detail popup already does for the same reason. + */ + voucherSearchOpen = false; + + openVoucherSearch(): void { + this.errorMsg = ''; + this.voucherSearchOpen = true; + } + + closeVoucherSearch(): void { + this.voucherSearchOpen = false; + this.errorMsg = ''; + } + /** GL account the deposit was posted to — required, and always picked by hand. Never resolved * from a share product's accounting setup, so a broken or missing product mapping can never * make a genuine voucher unreachable. */ @@ -427,6 +446,9 @@ export class ApplyCollectionDialogComponent implements OnInit { if (res.gl_code) { this.glAccountNo = res.gl_code; } + // Found it -- the popup has done its job, so it closes rather than sitting open over a + // receipt that now already shows, ticked, in the table behind it. + this.voucherSearchOpen = false; }); } From 3c363d037226424f8b0b02c985048841c093e7c0 Mon Sep 17 00:00:00 2001 From: Nitesh Yadav Date: Fri, 18 Sep 2026 18:56:34 +0530 Subject: [PATCH 63/64] feat(shares): one-voucher-per-slot receipt dialogs, Apply Additional Shares as a popup Apply Collection and Apply Additional Shares settle the same Share Collection receipts for the same member, so they are now deliberately the same screen. Apply Collection takes exactly two receipts and no more: one for the share value, one for the entry fee, in two separate slots. They are different things -- the share receipt is money moving into share capital, while the fee receipt only proves the fee was already collected and is never posted a second time -- and picking both out of one ticked list made that distinction invisible. Apply Additional Shares moves from its own route to a dialog. It was a full page a clerk had to leave the account for and come back from, to fill in a short form with the member standing at the counter. Its old route, resolver case and action- host branch are removed rather than left behind as dead paths. Both screens now draw their receipts from a single pool, whether a receipt already names the member or is a voucher paid straight into a GL account; an unattributed one is attached at the moment it is picked, so what used to be an Attach in one table and a tick in another is one deliberate click. The by-voucher search and the GL-entry detail both sit behind buttons, as exceptions rather than permanent furniture. Dialog chrome and the receipt shape are shared (share-dialog.shared.scss, share-receipt-option.ts) so the two cannot drift apart again. Also fixes content being clipped at the dialog edges: the wrapper cancelled a 24px surface padding that the panel class had already zeroed, pushing every edge outside the surface where overflow:hidden cut it off, and the height cap sat on the scrolling body rather than the wrapper, letting the footer stack past the bottom of the viewport. Adds the product-wise share summary report (authorised, issued, available and unpaid shares, distinctive range and account count per product). Co-Authored-By: Claude Opus 5 (1M context) --- src/app/cashier/cashier.service.ts | 21 +- src/app/core/shell/shell.component.html | 3 + .../share-account-actions.resolver.ts | 1 - .../share-product-summary.component.html | 168 +++ .../share-product-summary.component.ts | 168 +++ .../shares/reports/share-report.shared.scss | 17 + src/app/shares/share-dialog.shared.scss | 996 +++++++++++++ src/app/shares/share-receipt-option.ts | 85 ++ .../apply-shares/apply-shares.component.html | 953 ++++-------- .../apply-shares/apply-shares.component.scss | 1274 ++--------------- .../apply-shares/apply-shares.component.ts | 768 +++++----- .../shares-account-actions.component.html | 3 - .../shares-account-actions.component.ts | 4 - .../apply-collection-dialog.component.html | 921 +++++------- .../apply-collection-dialog.component.scss | 1060 +------------- .../apply-collection-dialog.component.ts | 578 +++----- .../shares-account-view.component.ts | 63 +- src/app/shares/shares-routing.module.ts | 8 + src/app/shares/shares.module.ts | 2 - src/app/shares/shares.service.ts | 8 + src/environments/.env.ts | 2 +- src/main.scss | 32 +- 22 files changed, 2906 insertions(+), 4229 deletions(-) create mode 100644 src/app/shares/reports/share-product-summary/share-product-summary.component.html create mode 100644 src/app/shares/reports/share-product-summary/share-product-summary.component.ts create mode 100644 src/app/shares/share-dialog.shared.scss create mode 100644 src/app/shares/share-receipt-option.ts diff --git a/src/app/cashier/cashier.service.ts b/src/app/cashier/cashier.service.ts index bab2c1ff7..3078701f8 100644 --- a/src/app/cashier/cashier.service.ts +++ b/src/app/cashier/cashier.service.ts @@ -391,20 +391,17 @@ export class CashierService { } /** - * Approved share-collection receipts that name no member yet. + * Everything an Apply screen needs, in one call: `own` is this member's own unapplied receipts, + * ready to tick; `rows` is every other Share Collection receipt still available — unclaimed GL + * deposits and receipts linked to another member — which attach by voucher instead. * - * A receipt taken straight into a GL account carries no member of its own, so it is never - * attributed automatically. This lists them so the counter can see the voucher they just raised - * and attach it deliberately, instead of being told there is nothing to apply. + * One request rather than two: both lists come from the same table under the same filters, and + * splitting them across endpoints only meant the same rows were fetched twice and, where they + * overlapped, drawn twice on the same screen. */ - getUnattributedShareCollections(): Observable { - return this.http.get('/shareadvancecollection/unattributed'); - } - - /** Receipts still sitting in Share Sundry, optionally for a single member. */ - getUnappliedShareAdvances(clientId?: number): Observable { - const suffix = clientId ? `?clientId=${clientId}` : ''; - return this.http.get(`/shareadvancecollection${suffix}`); + getShareCollectionReceipts(clientId?: number): Observable { + const params = clientId ? new HttpParams().set('clientId', String(clientId)) : undefined; + return this.http.get('/shareadvancecollection/unattributed', { params }); } /** Moves a receipt out of Sundry into share capital for an activated account. */ diff --git a/src/app/core/shell/shell.component.html b/src/app/core/shell/shell.component.html index aa9516dbe..3351e3022 100644 --- a/src/app/core/shell/shell.component.html +++ b/src/app/core/shell/shell.component.html @@ -1353,6 +1353,9 @@ +
diff --git a/src/app/shares/common-resolvers/share-account-actions.resolver.ts b/src/app/shares/common-resolvers/share-account-actions.resolver.ts index 21749db82..24679e699 100644 --- a/src/app/shares/common-resolvers/share-account-actions.resolver.ts +++ b/src/app/shares/common-resolvers/share-account-actions.resolver.ts @@ -32,7 +32,6 @@ export class ShareAccountActionsResolver { const actionName = route.paramMap.get('name'); const shareAccountId = route.paramMap.get('shareAccountId') || route.parent.parent.paramMap.get('shareAccountId'); switch (actionName) { - case 'Apply Additional Shares': case 'Redeem Shares': case 'Approve Additional Shares': case 'Reject Additional Shares': diff --git a/src/app/shares/reports/share-product-summary/share-product-summary.component.html b/src/app/shares/reports/share-product-summary/share-product-summary.component.html new file mode 100644 index 000000000..5ef7dcd6b --- /dev/null +++ b/src/app/shares/reports/share-product-summary/share-product-summary.component.html @@ -0,0 +1,168 @@ + + +
+
+
+ +
+

Share Product Summary

+
+ Every share class against its own authorised capital — shares issued, shares remaining, and the + distinctive-number range issued so far. "Issued" means paid up, not merely allotted. +
+
+
+
+ + +
+
+ + @if (rows.length) { +
+
+ +
+
{{ totals.authorisedShares | number }}
+
Authorised Shares (all classes)
+
+
+
+ +
+
{{ totals.issuedShares | number }}
+
Shares Issued (paid up)
+
+
+
+ +
+
₹{{ totals.issuedCapital | number: '1.2-2' }}
+
Share Capital Issued
+
+
+
+ +
+
{{ totals.unpaidShares | number }}
+
Allotted, Unpaid
+
+
+
+ } + + @if (loading) { +
Loading product summary…
+ } @else if (loadError) { +
{{ loadError }}
+ } @else if (!rows.length) { +
No share product is configured.
+ } @else { +
+ + + + + + + + + + + + + + + + + @for (row of rows; track row.productId) { + + + + + + + + + + + + + } + + + + + + + + + + + + +
Share ClassUnit PriceAuthorisedIssued (Paid Up)AvailableUtilisationAllotted, UnpaidDistinctive RangeAccountsMin / Nominal / Max
+ {{ row.productName }} + @if (row.productShortName) { +
{{ row.productShortName }}
+ } +
{{ row.unitPrice | number: '1.2-2' }} + {{ row.authorisedShares | number }} +
+ ₹{{ row.authorisedCapital | number: '1.0-0' }} +
+
+ {{ row.issuedShares | number }} +
+ ₹{{ row.issuedCapital | number: '1.0-0' }} +
+
{{ row.availableShares | number }} +
+
+
+
+ {{ utilisationPercent(row) | number: '1.0-1' }}% +
+
+ @if (row.unpaidShares) { + {{ row.unpaidShares | number }} +
+ ₹{{ row.unpaidAmount | number: '1.0-0' }} · {{ row.unpaidAccounts }} + {{ row.unpaidAccounts === 1 ? 'account' : 'accounts' }} +
+ } @else { + 0 + } +
{{ distinctiveRange(row) }} + {{ row.accountCount | number }} +
+ {{ row.memberCount | number }} {{ row.memberCount === 1 ? 'member' : 'members' }} +
+
+ {{ row.minimumClientShares ?? '—' }} / {{ row.nominalClientShares ?? '—' }} / + {{ row.maximumClientShares ?? '—' }} +
Total — {{ rows.length | number }} share classes{{ totals.authorisedShares | number }}{{ totals.issuedShares | number }}{{ totals.unpaidShares | number }}
+
+ + @if (totals.unpaidShares) { +
+ + + {{ totals.unpaidShares | number }} shares across every class are allotted but not yet paid for — + ₹{{ totals.unpaidAmount | number: '1.2-2' }} in all. They are excluded from "Issued" above; see Final + Balance for which members they belong to. + +
+ } + } +
diff --git a/src/app/shares/reports/share-product-summary/share-product-summary.component.ts b/src/app/shares/reports/share-product-summary/share-product-summary.component.ts new file mode 100644 index 000000000..c2ac6ba91 --- /dev/null +++ b/src/app/shares/reports/share-product-summary/share-product-summary.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, inject } from '@angular/core'; +import { CommonModule } from '@angular/common'; +import { FaIconComponent } from '@fortawesome/angular-fontawesome'; +import { of } from 'rxjs'; +import { catchError } from 'rxjs/operators'; + +import { SharesService } from '../../shares.service'; +import { shareReportCsv, shareReportPrint } from '../share-report.util'; + +interface ProductSummaryRow { + productId: number; + productName: string; + productShortName: string; + unitPrice: number; + minimumClientShares: number; + nominalClientShares: number; + maximumClientShares: number; + authorisedShares: number; + authorisedCapital: number; + issuedShares: number; + issuedCapital: number; + availableShares: number; + unpaidAccounts: number; + unpaidShares: number; + unpaidAmount: number; + distinctiveFrom: number | null; + distinctiveTo: number | null; + accountCount: number; + memberCount: number; +} + +/** + * Share Product Summary — every share class measured against its own authorised capital: how many + * shares are issued, how many remain, and the distinctive-number range issued so far. + * + *

+ * "Issued" is paid-up shares — settled purchases less redemptions, computed the same way every other + * report in this suite computes them — not {@code m_share_product.totalsubscribed_shares}. That + * platform column counts an allotment the moment it is submitted, before any money has necessarily + * arrived, so it runs ahead of what the society has actually received. A board reading this page is + * reading the same figure the GL Share Capital account would show, not a subscription count. + */ +@Component({ + selector: 'mifosx-share-product-summary', + templateUrl: './share-product-summary.component.html', + styleUrls: [ + '../../../letter-mgmt/certificates/certificates.shared.scss', + '../share-report.shared.scss' + ], + imports: [ + CommonModule, + FaIconComponent + ] +}) +export class ShareProductSummaryComponent implements OnInit { + private sharesService = inject(SharesService); + + loading = true; + loadError = ''; + rows: ProductSummaryRow[] = []; + + ngOnInit(): void { + this.load(); + } + + load(): void { + this.loading = true; + this.loadError = ''; + this.sharesService + .getShareProductSummary() + .pipe( + catchError((err: any) => { + this.loadError = this.readError(err, 'The product summary could not be loaded.'); + return of(null); + }) + ) + .subscribe((res: any) => { + this.rows = res?.rows || []; + this.loading = false; + }); + } + + /** How much of a product's authorised capital is actually issued, for the progress bar. */ + utilisationPercent(row: ProductSummaryRow): number { + if (!row.authorisedShares) { + return 0; + } + const pct = (row.issuedShares / row.authorisedShares) * 100; + return Math.max(0, Math.min(100, pct)); + } + + distinctiveRange(row: ProductSummaryRow): string { + if (!row.distinctiveFrom || !row.distinctiveTo) { + return 'None allotted yet'; + } + return `${row.distinctiveFrom} – ${row.distinctiveTo}`; + } + + get totals() { + return { + authorisedShares: this.rows.reduce((sum, r) => sum + Number(r.authorisedShares || 0), 0), + authorisedCapital: this.rows.reduce((sum, r) => sum + Number(r.authorisedCapital || 0), 0), + issuedShares: this.rows.reduce((sum, r) => sum + Number(r.issuedShares || 0), 0), + issuedCapital: this.rows.reduce((sum, r) => sum + Number(r.issuedCapital || 0), 0), + unpaidShares: this.rows.reduce((sum, r) => sum + Number(r.unpaidShares || 0), 0), + unpaidAmount: this.rows.reduce((sum, r) => sum + Number(r.unpaidAmount || 0), 0) + }; + } + + exportCsv(): void { + shareReportCsv( + 'share-product-summary', + [ + 'Product', + 'Unit Price', + 'Authorised Shares', + 'Authorised Capital', + 'Issued Shares', + 'Issued Capital', + 'Available Shares', + 'Unpaid Accounts', + 'Unpaid Shares', + 'Unpaid Amount', + 'Distinctive Range', + 'Accounts', + 'Members', + 'Min / Nominal / Max Per Member' + ], + this.rows.map((r) => [ + r.productName, + r.unitPrice, + r.authorisedShares, + r.authorisedCapital, + r.issuedShares, + r.issuedCapital, + r.availableShares, + r.unpaidAccounts, + r.unpaidShares, + r.unpaidAmount, + this.distinctiveRange(r), + r.accountCount, + r.memberCount, + `${r.minimumClientShares ?? '—'} / ${r.nominalClientShares ?? '—'} / ${r.maximumClientShares ?? '—'}` + ]) + ); + } + + print(): void { + shareReportPrint(); + } + + private readError(err: any, fallback: string): string { + return ( + err?.error?.errors?.[0]?.defaultUserMessage || + err?.error?.defaultUserMessage || + err?.error?.developerMessage || + fallback + ); + } +} diff --git a/src/app/shares/reports/share-report.shared.scss b/src/app/shares/reports/share-report.shared.scss index 26eabff50..b837ce60f 100644 --- a/src/app/shares/reports/share-report.shared.scss +++ b/src/app/shares/reports/share-report.shared.scss @@ -278,6 +278,23 @@ $srep-muted: #64748b; font-weight: 500; } +/* A share class's own authorised-vs-issued fraction, read at a glance rather than only in the two + numbers beside it. */ +.product-utilisation-bar { + width: 100%; + min-width: 90px; + height: 6px; + border-radius: 999px; + background: $srep-border-soft; + overflow: hidden; +} + +.product-utilisation-fill { + height: 100%; + border-radius: 999px; + background: linear-gradient(90deg, #2563eb, #059669); +} + /* Something the reader has to know before trusting the number above it — an exclusion, a gap, a figure that will not reconcile until someone acts. Loud enough to be read, not an error. */ .srep-warn { diff --git a/src/app/shares/share-dialog.shared.scss b/src/app/shares/share-dialog.shared.scss new file mode 100644 index 000000000..710f215b4 --- /dev/null +++ b/src/app/shares/share-dialog.shared.scss @@ -0,0 +1,996 @@ +/** + * 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 chrome for Apply Collection and Apply Additional Shares dialogs. +// Each component @uses this, so every rule is emitted once per component +// and stays scoped to it -- nothing here leaks into the global stylesheet. + +/* ── Design tokens ─────────────────────────────────────────────────────── */ + +$primary: #1e3a8a; +$primary-mid: #2448b0; +$accent: #2563eb; +$accent-soft: #eff6ff; +$accent-border: #bfdbfe; + +$text-main: #0f172a; +$text-sub: #334155; +$text-muted: #64748b; +$border-color: #e2e8f0; +$bg-page: #f8fafc; +$bg-white: #fff; + +$success: #059669; +$success-bg: #ecfdf5; +$success-border: #6ee7b7; + +$danger: #dc2626; +$danger-bg: #fef2f2; +$danger-border: #fca5a5; + +/* ── Utilities ─────────────────────────────────────────────────────────── */ + +.mono { + font-family: 'Roboto Mono', 'Courier New', monospace; + font-variant-numeric: tabular-nums; +} + +/* ── Dialog wrapper ────────────────────────────────────────────────────── */ + +.dialog-wrapper { + display: flex; + flex-direction: column; + max-height: 88vh; + background: $bg-white; + border-radius: 14px; + overflow: hidden; +} + +/* ── Header ────────────────────────────────────────────────────────────── */ + +.dialog-header { + display: flex; + flex: none; + align-items: center; + gap: 0.85rem; + padding: 1.1rem 1.5rem; + background: linear-gradient(135deg, $primary 0%, $primary-mid 60%, #3b5fc0 100%); + color: #fff; + + .header-icon { + display: flex; + align-items: center; + justify-content: center; + flex: none; + width: 38px; + height: 38px; + border-radius: 9px; + background: rgb(255 255 255 / 15%); + border: 1px solid rgb(255 255 255 / 20%); + + mat-icon { + font-size: 20px; + width: 20px; + height: 20px; + color: #fff; + } + } + + .header-text { + flex: 1; + min-width: 0; + + h2 { + margin: 0; + font-size: 1.08rem; + font-weight: 700; + letter-spacing: 0.01em; + } + + p { + margin: 0.18rem 0 0; + font-size: 0.78rem; + opacity: 0.78; + } + } + + .header-close { + color: rgb(255 255 255 / 80%); + flex: none; + + &:hover { + color: #fff; + background: rgb(255 255 255 / 12%) !important; + } + } +} + +/* ── Body ──────────────────────────────────────────────────────────────── */ + +.dialog-body { + display: flex; + flex: 1 1 auto; + flex-direction: column; + gap: 1.1rem; + min-height: 0; + max-height: none !important; + padding: 1.4rem 1.5rem !important; + overflow-y: auto; + background: $bg-page; + + &::-webkit-scrollbar { + width: 5px; + } + + &::-webkit-scrollbar-track { + background: transparent; + } + + &::-webkit-scrollbar-thumb { + background: #cbd5e1; + border-radius: 99px; + } +} + +.state-block { + display: flex; + align-items: center; + justify-content: center; + gap: 0.75rem; + padding: 2rem 0; + color: $text-muted; + font-size: 0.85rem; +} + +/* ═══════════════════════════════════════════════════════════════════════ + Collection Summary Card (sc-* prefix) + ═══════════════════════════════════════════════════════════════════════ */ + +.sc-card { + border: 1px solid $accent-border; + border-radius: 10px; + background: $bg-white; + // No overflow:hidden — it clips row content in certain flex contexts + box-shadow: 0 2px 8px rgb(37 99 235 / 8%); +} + +/* ── Each item row (Share / Entry Fee) ─────────────────────────────────── */ + +.sc-row { + display: flex; + flex-direction: row; + align-items: center; + justify-content: space-between; + gap: 1rem; + padding: 0.85rem 1.15rem; + background: $bg-white; + border-left: 4px solid transparent; + transition: + background 0.15s, + border-color 0.2s; + + &.sc-sep { + border-top: 1px solid $border-color; + } + + &.sc-ok { + background: $success-bg; + border-left-color: $success; + } + + &.sc-bad { + background: $danger-bg; + border-left-color: $danger; + } +} + +/* Left side: label + required amount */ +.sc-left { + flex: 1; + min-width: 0; + display: flex; + flex-direction: column; + gap: 0.18rem; +} + +.sc-label { + display: block; + font-size: 0.86rem; + font-weight: 700; + color: $text-main; + line-height: 1.3; + min-height: 1em; // guarantees height even if content is empty +} + +.sc-sub { + display: block; + font-size: 0.72rem; + color: $text-muted; + line-height: 1.35; + min-height: 1em; + + b { + font-weight: 600; + color: $text-sub; + } +} + +/* Right side: collected amount + badge */ +.sc-right { + display: flex; + align-items: center; + gap: 0.5rem; + flex: none; +} + +.sc-val { + font-family: 'Roboto Mono', 'Courier New', monospace; + font-variant-numeric: tabular-nums; + font-size: 0.96rem; + font-weight: 700; + color: $text-muted; + letter-spacing: -0.01em; + transition: color 0.2s; + + &.sc-val-ok { + color: $success; + } + + &.sc-val-bad { + color: $danger; + } +} + +.sc-tag { + display: inline-flex; + align-items: center; + padding: 0.15rem 0.5rem; + border-radius: 99px; + font-size: 0.68rem; + font-weight: 700; + white-space: nowrap; + + &.sc-tag-ok { + background: $success-bg; + color: $success; + border: 1px solid $success-border; + } + + &.sc-tag-bad { + background: $danger-bg; + color: $danger; + border: 1px solid $danger-border; + } +} + +/* ── Total row ─────────────────────────────────────────────────────────── */ + +.sc-total-row { + display: flex; + flex-direction: row; + align-items: center; + justify-content: space-between; + gap: 1rem; + padding: 0.8rem 1.15rem; + border-top: 1px solid $border-color; + background: $bg-page; + transition: background 0.2s; + + &.sc-total-ok { + background: $success-bg; + } +} + +.sc-total-lbl { + font-size: 0.82rem; + font-weight: 800; + color: $text-main; + letter-spacing: 0.01em; +} + +.sc-total-nums { + display: flex; + align-items: center; + gap: 0.45rem; + flex: none; +} + +.sc-total-req { + font-family: 'Roboto Mono', 'Courier New', monospace; + font-size: 0.78rem; + font-weight: 500; + color: $text-muted; +} + +.sc-arrow { + font-size: 0.8rem; + color: #94a3b8; + font-weight: 300; +} + +.sc-total-got { + font-family: 'Roboto Mono', 'Courier New', monospace; + font-variant-numeric: tabular-nums; + font-size: 1rem; + font-weight: 800; + color: $text-muted; + letter-spacing: -0.02em; + transition: color 0.2s; + + &.sc-total-got-ok { + color: $success; + } +} + +.sc-status-pill { + display: inline-flex; + align-items: center; + padding: 0.2rem 0.65rem; + border-radius: 99px; + font-size: 0.68rem; + font-weight: 800; + letter-spacing: 0.04em; + white-space: nowrap; + transition: all 0.2s; + + &.sc-pill-ok { + background: $success; + color: #fff; + box-shadow: 0 2px 6px rgb(5 150 105 / 30%); + } + + &.sc-pill-pending { + background: $border-color; + color: $text-muted; + } +} + +/* ═══════════════════════════════════════════════════════════════════════ */ + +/* ── Receipt step slots ────────────────────────────────────────────────── */ + +.slot { + display: flex; + flex-direction: column; + gap: 0.75rem; + padding: 1.1rem 1.25rem; + border: 1px solid $border-color; + border-radius: 10px; + background: $bg-white; + box-shadow: 0 1px 4px rgb(15 23 42 / 4%); + transition: box-shadow 0.2s; + + &:hover { + box-shadow: 0 3px 12px rgb(15 23 42 / 7%); + } +} + +.slot-head { + display: flex; + align-items: center; + gap: 0.75rem; +} + +.slot-step { + display: flex; + align-items: center; + justify-content: center; + flex: none; + width: 26px; + height: 26px; + border-radius: 50%; + background: linear-gradient(135deg, $primary 0%, $accent 100%); + color: #fff; + font-size: 0.72rem; + font-weight: 800; + box-shadow: 0 2px 6px rgb(37 99 235 / 30%); +} + +.slot-title { + flex: 1; + min-width: 0; + + h3 { + margin: 0; + font-size: 0.9rem; + font-weight: 700; + color: $text-main; + } + + p { + margin: 0.12rem 0 0; + font-size: 0.72rem; + color: $text-muted; + } +} + +.slot-search { + display: inline-flex; + align-items: center; + gap: 0.3rem; + flex: none; + padding: 0.38rem 0.72rem; + border: 1.5px solid $accent-border; + border-radius: 7px; + background: $accent-soft; + color: $accent; + font-size: 0.74rem; + font-weight: 700; + cursor: pointer; + transition: all 0.15s; + + mat-icon { + width: 15px; + height: 15px; + font-size: 15px; + } + + &:hover { + background: $accent; + border-color: $accent; + color: #fff; + box-shadow: 0 2px 8px rgb(37 99 235 / 25%); + } +} + +/* ── Pickable option list ───────────────────────────────────────────────── */ + +.options { + list-style: none; + margin: 0; + padding: 0; + display: flex; + flex-direction: column; + gap: 0.35rem; + max-height: 210px; + overflow-y: auto; + + &::-webkit-scrollbar { + width: 4px; + } + + &::-webkit-scrollbar-thumb { + background: #cbd5e1; + border-radius: 99px; + } +} + +.option { + display: flex; + align-items: center; + gap: 0.7rem; + padding: 0.72rem 0.9rem; + border: 1.5px solid $border-color; + border-radius: 7px; + cursor: pointer; + background: $bg-white; + transition: all 0.14s; + + &:hover { + border-color: $accent; + background: $accent-soft; + box-shadow: 0 2px 6px rgb(37 99 235 / 10%); + } + + &.option-busy { + opacity: 0.6; + pointer-events: none; + } +} + +.option-radio { + flex: none; + width: 14px; + height: 14px; + border: 2px solid #cbd5e1; + border-radius: 50%; + transition: border-color 0.14s; + + .option:hover & { + border-color: $accent; + } +} + +.option-main { + flex: 1; + min-width: 0; + display: flex; + flex-direction: column; + gap: 0.1rem; +} + +.option-voucher { + font-size: 0.81rem; + font-weight: 700; + color: $text-main; +} + +.option-meta { + font-size: 0.7rem; + color: $text-muted; +} + +.option-desc { + font-size: 0.7rem; + color: $text-muted; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.option-amount { + flex: none; + font-size: 0.85rem; + font-weight: 700; + color: $text-main; +} + +.option-info { + display: flex; + align-items: center; + justify-content: center; + flex: none; + width: 26px; + height: 26px; + padding: 0; + border: 1px solid $border-color; + border-radius: 5px; + background: transparent; + color: $text-muted; + cursor: pointer; + transition: all 0.14s; + + mat-icon { + width: 16px; + height: 16px; + font-size: 16px; + } + + &:hover { + color: $accent; + border-color: $accent; + background: $accent-soft; + } +} + +/* ── Chosen receipt ─────────────────────────────────────────────────────── */ + +.chosen { + display: flex; + align-items: center; + gap: 0.8rem; + padding: 0.75rem 1rem; + border: 2px solid $success-border; + border-radius: 9px; + background: $success-bg; + box-shadow: 0 2px 8px rgb(5 150 105 / 10%); + + &.chosen-short { + border-color: $danger-border; + background: $danger-bg; + box-shadow: 0 2px 8px rgb(220 38 38 / 8%); + } +} + +.chosen-badge { + display: flex; + align-items: center; + justify-content: center; + flex: none; + width: 30px; + height: 30px; + border-radius: 7px; + background: $success; + + .chosen-short & { + background: $danger; + } + + mat-icon { + font-size: 17px; + width: 17px; + height: 17px; + color: #fff; + } +} + +.chosen-main { + flex: 1; + min-width: 0; + display: flex; + flex-direction: column; + gap: 0.1rem; +} + +.chosen-voucher { + font-size: 0.84rem; + font-weight: 700; + color: $text-main; +} + +.chosen-gl { + font-size: 0.7rem; + color: $text-muted; +} + +.chosen-amount { + flex: none; + font-size: 0.95rem; + font-weight: 800; + color: $text-main; + font-family: 'Roboto Mono', 'Courier New', monospace; +} + +.chosen-clear { + display: flex; + align-items: center; + justify-content: center; + flex: none; + width: 26px; + height: 26px; + padding: 0; + border: 1px solid $danger-border; + border-radius: 5px; + background: transparent; + color: #f87171; + cursor: pointer; + transition: all 0.14s; + + mat-icon { + width: 15px; + height: 15px; + font-size: 15px; + } + + &:hover { + color: $danger; + background: $danger-bg; + border-color: $danger; + } +} + +/* ── Slot notes ─────────────────────────────────────────────────────────── */ + +.slot-note { + margin: 0; + font-size: 0.74rem; + font-weight: 500; + + &.note-good { + color: $success; + } + + &.note-bad { + color: $danger; + font-weight: 700; + } + + &.note-muted { + color: $text-muted; + } +} + +.slot-empty { + margin: 0; + padding: 0.8rem 1rem; + border: 1.5px dashed $border-color; + border-radius: 7px; + background: $bg-page; + font-size: 0.75rem; + line-height: 1.55; + color: $text-muted; +} + +/* ── Error alert ─────────────────────────────────────────────────────────── */ + +.alert { + display: flex; + align-items: flex-start; + gap: 0.5rem; + padding: 0.7rem 0.9rem; + border: 1.5px solid $danger-border; + border-radius: 7px; + background: $danger-bg; + color: $danger; + font-size: 0.76rem; + line-height: 1.45; + + mat-icon { + flex: none; + width: 17px; + height: 17px; + font-size: 17px; + margin-top: 1px; + } +} + +/* ── Footer ─────────────────────────────────────────────────────────────── */ + +.dialog-actions { + display: flex; + flex: none; + justify-content: flex-end; + align-items: center; + gap: 0.75rem; + min-height: 0; + padding: 1rem 1.5rem !important; + margin: 0 !important; + border-top: 1px solid $border-color; + background: $bg-white; + box-shadow: 0 -2px 8px rgb(15 23 42 / 5%); + + // Both buttons: same height, same border-radius, same font + button { + display: inline-flex !important; + align-items: center !important; + justify-content: center !important; + gap: 0.3rem !important; + height: 40px !important; + min-width: 110px !important; + padding: 0 1.1rem !important; + border-radius: 8px !important; + font-weight: 600 !important; + font-size: 0.85rem !important; + line-height: 1 !important; + letter-spacing: 0.01em !important; + + // Icon inside button — vertically center it and size it correctly + mat-icon { + display: inline-flex !important; + align-items: center !important; + justify-content: center !important; + width: 18px !important; + height: 18px !important; + font-size: 18px !important; + line-height: 1 !important; + vertical-align: middle !important; + margin: 0 !important; + flex: none; + } + + mat-spinner { + flex: none; + } + } + + // Stroked cancel button + [mat-stroked-button] { + border-color: $border-color !important; + color: $text-sub !important; + + &:hover { + border-color: #94a3b8 !important; + background: $bg-page !important; + } + } + + // Primary action button (enabled) + [mat-flat-button][color='primary']:not([disabled]) { + background: linear-gradient(135deg, $accent 0%, #1d4ed8 100%) !important; + box-shadow: 0 2px 8px rgb(37 99 235 / 28%) !important; + color: #fff !important; + + &:hover { + box-shadow: 0 4px 14px rgb(37 99 235 / 40%) !important; + transform: translateY(-1px) !important; + } + + &:active { + transform: translateY(0) !important; + } + } + + // Primary action button (disabled) + [mat-flat-button][color='primary'][disabled] { + background: #e2e8f0 !important; + color: #94a3b8 !important; + box-shadow: none !important; + } +} + +/* ── Form fields (in search popup) ─────────────────────────────────────── */ + +.field { + display: flex; + flex-direction: column; + gap: 0.35rem; + + > span { + font-size: 0.68rem; + font-weight: 700; + text-transform: uppercase; + letter-spacing: 0.06em; + color: $text-muted; + } + + select, + input { + padding: 0.55rem 0.7rem; + border: 1.5px solid $border-color; + border-radius: 7px; + font-size: 0.82rem; + color: $text-main; + background: #fff; + outline: none; + transition: + border-color 0.14s, + box-shadow 0.14s; + + &:hover { + border-color: #94a3b8; + } + + &:focus { + border-color: $accent; + box-shadow: 0 0 0 3px rgb(37 99 235 / 12%); + } + } +} + +/* ── Sub-popups ─────────────────────────────────────────────────────────── */ + +.overlay { + position: fixed; + inset: 0; + z-index: 2000; + display: flex; + align-items: center; + justify-content: center; + padding: 1.5rem; + background: rgb(15 23 42 / 55%); + backdrop-filter: blur(2px); +} + +.popup { + display: flex; + flex-direction: column; + width: 100%; + max-width: 460px; + max-height: 80vh; + border-radius: 12px; + background: #fff; + overflow: hidden; + box-shadow: 0 20px 50px -8px rgb(15 23 42 / 30%); + animation: popup-in 0.15s cubic-bezier(0.22, 1, 0.36, 1); +} + +@keyframes popup-in { + from { + opacity: 0; + transform: scale(0.96) translateY(8px); + } + + to { + opacity: 1; + transform: none; + } +} + +.popup-head { + display: flex; + align-items: center; + gap: 0.5rem; + padding: 0.85rem 0.85rem 0.85rem 1.2rem; + border-bottom: 1px solid $border-color; + background: $bg-page; + + h3 { + flex: 1; + margin: 0; + font-size: 0.92rem; + font-weight: 700; + color: $text-main; + } +} + +.popup-body { + display: flex; + flex-direction: column; + gap: 0.85rem; + padding: 1.2rem; + overflow-y: auto; +} + +.popup-foot { + display: flex; + justify-content: flex-end; + gap: 0.6rem; + padding: 0.8rem 1.2rem; + border-top: 1px solid $border-color; + background: $bg-page; +} + +/* ── Receipt detail popup ───────────────────────────────────────────────── */ + +.detail-list { + display: flex; + flex-direction: column; + gap: 0; + margin: 0; + border: 1px solid $border-color; + border-radius: 8px; + overflow: hidden; + + > div { + display: flex; + border-bottom: 1px solid $border-color; + + &:last-child { + border-bottom: none; + } + } + + dt { + flex: none; + width: 110px; + padding: 0.6rem 0.9rem; + font-size: 0.68rem; + font-weight: 700; + text-transform: uppercase; + letter-spacing: 0.05em; + color: $text-muted; + background: $bg-page; + border-right: 1px solid $border-color; + } + + dd { + flex: 1; + min-width: 0; + margin: 0; + padding: 0.6rem 0.9rem; + font-size: 0.8rem; + color: $text-main; + word-break: break-word; + } +} + +.detail-heading { + margin: 0.4rem 0 0.1rem; + font-size: 0.68rem; + font-weight: 700; + text-transform: uppercase; + letter-spacing: 0.06em; + color: $text-muted; +} + +.gl-table { + width: 100%; + border-collapse: collapse; + font-size: 0.76rem; + border: 1px solid $border-color; + border-radius: 7px; + overflow: hidden; + + th, + td { + padding: 0.45rem 0.6rem; + border-bottom: 1px solid $border-color; + text-align: left; + } + + tbody tr:last-child td { + border-bottom: none; + } + + th { + font-size: 0.64rem; + font-weight: 700; + text-transform: uppercase; + letter-spacing: 0.05em; + color: $text-muted; + background: $bg-page; + } + + tbody tr:hover { + background: $accent-soft; + } + + .num { + text-align: right; + } +} diff --git a/src/app/shares/share-receipt-option.ts b/src/app/shares/share-receipt-option.ts new file mode 100644 index 000000000..3218b0efa --- /dev/null +++ b/src/app/shares/share-receipt-option.ts @@ -0,0 +1,85 @@ +/** + * 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 Share Collection receipt the counter can choose, in a single shape whatever it came from. + * + * {@code GET /shareadvancecollection/unattributed} answers with two differently-shaped lists — the + * member's own receipts under {@code own}, and every other receipt still going spare under + * {@code rows} — and normalising both to this on arrival is what lets a screen show one plain list + * of vouchers instead of two tables whose difference a counter has to understand before they can + * pick anything. + * + * Shared by the two screens that settle a share purchase (the first application's Apply Collection + * dialog and the Apply Additional Shares dialog) so a receipt reads identically on both. + */ +export interface ReceiptOption { + /** Stable identity for tracking and comparison; unique across both source lists. */ + key: string; + /** + * The {@code m_share_advance_collection} row behind this receipt, or null while it is still only + * a voucher number. A null one is attached to the member on first pick. + */ + collectionId: number | null; + voucherNo: string; + glAccountNo: string; + glAccountName: string; + date: string; + amount: number; + description: string; + cashierName: string; +} + +/** + * A receipt that already has an {@code m_share_advance_collection} row — ready to settle as-is. + * + * Also the shape {@code GET /shareadvancecollection/by-voucher} answers with, so a receipt found by + * number arrives here too, already attached. + */ +export function receiptFromOwn(row: any): ReceiptOption { + return { + key: `c${row.id}`, + collectionId: Number(row.id), + voucherNo: row.voucher_no || row.receipt_no || '', + glAccountNo: row.gl_code || '', + glAccountName: '', + date: String(row.collection_date || '').slice(0, 10), + amount: Number(row.amount || 0), + description: row.note || '', + cashierName: row.collected_by_name || '' + }; +} + +/** A receipt that is still only a voucher, attached to the member when it is picked. */ +export function receiptFromUnattached(row: any): ReceiptOption { + return { + key: `v${row.id}`, + collectionId: null, + voucherNo: row.voucherNo || '', + glAccountNo: row.glAccountNo || '', + glAccountName: row.glAccountName || '', + date: String(row.txnDate || '').slice(0, 10), + amount: Number(row.amount || 0), + description: row.description || '', + cashierName: row.cashierName || '' + }; +} + +/** + * The GL account a receipt sits in, named where the name is known and coded where it is not. + * + * An attached receipt carries only its {@code gl_code}, so the name is looked up in whatever GL + * account list the screen already loaded for its voucher search rather than fetched again. + */ +export function receiptGlLabel(option: ReceiptOption, glAccounts: any[]): string { + const name = option.glAccountName || glAccounts.find((gl) => gl.gl_code === option.glAccountNo)?.name || ''; + if (!option.glAccountNo) { + return name || '—'; + } + return name ? `${option.glAccountNo} — ${name}` : option.glAccountNo; +} diff --git a/src/app/shares/shares-account-actions/apply-shares/apply-shares.component.html b/src/app/shares/shares-account-actions/apply-shares/apply-shares.component.html index 45cc66466..0299ae92f 100644 --- a/src/app/shares/shares-account-actions/apply-shares/apply-shares.component.html +++ b/src/app/shares/shares-account-actions/apply-shares/apply-shares.component.html @@ -6,692 +6,367 @@ file, You can obtain one at http://mozilla.org/MPL/2.0/. --> -

- - -
-
- add_card +
+
+
+ trending_up +
+
+

Apply Additional Shares

+

+ Share account {{ data.accountNo }} +

+
+ +
+ + +
+
+
+ Shares +
-
-

{{ 'Apply Additional Shares' | translate }}

-

Purchase additional share tranches via cash receipts or savings debit

+
+ Unit Price + {{ unitPrice | number: '1.2-2' }} +
+
+ Total Amount + {{ amount | number: '1.2-2' }}
+
-
- - -
-
- assignment - Application & Resolution Details -
- -
- - - {{ 'labels.inputs.Request Date' | translate }} - - - - @if (applySharesForm.controls.requestedDate.hasError('required')) { - - {{ 'labels.inputs.Request Date' | translate }} {{ 'labels.commons.is' | translate }} - {{ 'labels.commons.required' | translate }} - - } - - - - - {{ 'labels.inputs.Resolution Number' | translate }} - - @if (applySharesForm.controls.resolutionNumber.hasError('required')) { - - {{ 'labels.inputs.Resolution Number' | translate }} {{ 'labels.commons.is' | translate }} - {{ 'labels.commons.required' | translate }} - - } - - - - - {{ 'labels.inputs.Resolution Date' | translate }} - - - - @if (applySharesForm.controls.resolutionDate.hasError('required')) { - - {{ 'labels.inputs.Resolution Date' | translate }} {{ 'labels.commons.is' | translate }} - {{ 'labels.commons.required' | translate }} - - } - -
+ + +
+
+ 1 +
+

Board Resolution

+

The meeting that authorised this tranche, not the one the account was opened under.

+
- -
-
- calculate - Share Purchase Calculation -
+
+ + + +
+
-
- - - {{ 'labels.inputs.Total No. of Shares' | translate }} - - @if (applySharesForm.controls.requestedShares.hasError('required')) { - - {{ 'labels.inputs.Requested Shares' | translate }} {{ 'labels.commons.is' | translate }} - {{ 'labels.commons.required' | translate }} - - } - - - - - {{ 'labels.inputs.Current Price' | translate }} - - per share - - - -
- {{ 'labels.inputs.Amount' | translate }} - {{ amount | number: '1.2-2' }} - {{ applySharesForm.get('requestedShares').value || 0 }} shares × - {{ sharesAccountData?.currentMarketPrice || 0 }} -
-
+ +
+
+ 2 +
+

Funding

+

Approval posts Dr Sundry / Cr Share Capital either way.

+ @if (funding === 'CASH') { + + } +
- -
-
- account_balance_wallet - {{ 'labels.inputs.Payment Option' | translate }} +
+ + +
+ + @if (funding === 'CASH') { + @if (loadingReceipts) { +
+ + Loading receipts…
- -
-
-
- payments -
-
- Apply Collection (Cash) - Link receipts paid at counter sitting in Share Sundry -
- @if (paymentMode === 'CASH') { - check_circle - } + } @else if (receipt) { +
+
+ {{ shortfall > 0 ? 'warning' : 'check_circle' }}
- -
-
- account_balance -
-
- Debit from Savings - Direct debit from member's linked savings account -
- @if (paymentMode === 'SAVINGS') { - check_circle - } +
+ {{ receipt.voucherNo }} + {{ glLabel(receipt) }}
-
-
- - - @if (paymentMode === 'CASH') { -
-
- - - Select the one receipt that covers this payment. Click - visibility on any row to view full details popup. - -
- - - - - - @if (unattributed.length) { -
-
- person_add - Unattached Share Collection Receipts — Attach to This Tranche -
-
- - - - - - - - - - - - - @for (u of unattributed; track u.id) { - - - - - - - - - } - -
Voucher NoDateDeposited ToDescriptionAmountAction
- {{ u.voucherNo }} - @if (isOwnClientVoucher(u)) { - This member's receipt - } - {{ u.txnDate }}{{ u.glAccountNo }} — {{ u.glAccountName }}{{ u.description || '—' }}{{ u.amount | number: '1.2-2' }} - -
-
-
- } - - @if (voucherError) { -
- error_outline - {{ voucherError }} -
- } - - -
- @if (loadingVouchers) { -
- - Loading member receipts… -
- } @else if (vouchers.length === 0) { -
- receipt_long -
- No unapplied receipts found for this member. -

- Collect cash at Cashier › Transaction Entry as a deposit to Share Sundry GL. Once - posted, it will appear here. -

-
-
- } @else { -
- -
-
- receipt - Vouchers / Receipts - {{ vouchers.length }} -
-
- - -
- - - - - - - - - - - - - - - @for (v of vouchers; track v.id) { - - - - - - - - - - } - -
#Voucher / Receipt NoDateDescriptionCollected ByAmountDetail
- - - {{ v.voucher_no || v.receipt_no }} - {{ v.collection_date }}{{ v.note || 'Share Collection' }} - - person_outline - {{ v.collected_by_name || 'System' }} - - - {{ v.amount | number: '1.2-2' }} - - -
-
- - - -
- - @if (selectedVoucherIds.length > 0 && voucherShortfall > 0) { -
- error_outline - - Selected total {{ selectedVoucherTotal | number: '1.2-2' }} is - {{ voucherShortfall | number: '1.2-2' }} short of tranche target - {{ amount | number: '1.2-2' }}. Tick another receipt or collect balance. - -
- } - } -
+ @if (shortfall > 0) { +

+ Short by {{ shortfall | number: '1.2-2' }} — this receipt does not cover {{ amount | number: '1.2-2' }}. +

+ } @else { +

Covers the {{ amount | number: '1.2-2' }} tranche.

+ } + } @else if (available.length) { +
    + @for (option of available; track option.key) { +
  • + +
    + {{ option.voucherNo }} + {{ option.date }} · {{ glLabel(option) }} + @if (option.description) { + {{ option.description }} + } +
    + {{ option.amount | number: '1.2-2' }} + @if (attaching === option.key) { + + } @else if (option.collectionId !== null) { + + } +
  • + } +
+ } @else { +

+ No Share Collection receipt is waiting. Collect the cash at Cashier › Transaction Entry with entry + type Share Collection, or find an earlier voucher by number. +

} + } - - @if (paymentMode === 'SAVINGS') { -
-
- - - The tranche amount will be automatically debited from the member's linked savings account into Share - Sundry upon submission. + @if (funding === 'SAVINGS') { + @if (checkingSavings) { +
+ + Checking the linked savings account… +
+ } @else if (savings) { +
+
+ {{ savings.eligible ? 'check_circle' : 'warning' }} +
+
+ + @if (savings.savingsAccountNo) { + Savings {{ savings.savingsAccountNo }} + } @else { + Linked savings account + } + + + {{ savings.eligible ? 'Debited into Share Sundry on submit.' : savings.reason }}
- - @if (checkingSavings) { -
- - Checking linked savings account eligibility… -
- } @else if (savingsEligibility) { -
- @if (savingsEligibility.savingsAccountNo) { -
- Savings Account No. - {{ savingsEligibility.savingsAccountNo }} -
-
- Available Balance - {{ savingsEligibility.savingsBalance | number: '1.2-2' }} -
- } -
- Amount to Debit - {{ amount | number: '1.2-2' }} -
-
- - @if (!savingsEligibility.eligible) { -
- error_outline - {{ savingsEligibility.reason }} -
- } + @if (savings.savingsBalance !== null) { + {{ savings.savingsBalance | number: '1.2-2' }} }
+ } @else { +

Enter the number of shares to check the member's balance.

} + } +
+ + @if (errorMsg) { +
+ error_outline + {{ errorMsg }} +
+ } +
+ + + + + +
+ + +@if (searchOpen) { +
+