Compare commits

...
14 Commits
Author SHA1 Message Date
anshul 6f259ad06e Merge branch 'dev-latest' into feature/bugfix 2026-09-24 18:09:54 +05:30
anshul 7b9a6394c8 Implement organization maker-checker workflows 2026-09-24 17:22:50 +05:30
niteshandClaude Sonnet 5 1eaa8cc8cf feat(fd): make the Payment dialog the FD Dashboard's Renew action too, redesign it
The Payment dialog (Transfer to Savings / Reinvest Full / Reinvest Principal Only / Partial
Reinvest, works whether an FD is Active or Matured) already had a complete, correct backend
implementation for every closure type -- including one this session found and fixed a real gap
in on the backend side (Reinvest Principal Only silently stranded its leftover interest). Rather
than duplicating that logic in a second, narrower dialog, the FD Dashboard's Renew action now
opens this same dialog in a `dashboardRenewal` mode: every closure type is still available, but
the Closed On Date is computed from the product's grace window instead of picked freely
(backdated to the original maturity date within grace, today once it's passed, picker hidden so
it can't be moved off that value), and submit() calls the new gap-interest pre-step endpoint
first when past grace, before the same generic `payment` command every other caller already used.

Also: full visual redesign (bigger dialog, gradient header, per-option accent colors, refined
spacing) applied to both entry points since they're the same component now; removes the native
"Close" action from a Matured FD's Account Actions menu, since what happens at maturity now
goes through the nightly job or this dialog, not a separate close command that bypassed both.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-24 16:12:45 +05:30
niteshandClaude Sonnet 5 a652ce1093 feat(fd): make Maturity Instructions optional at account-opening, pre-fill it on edit
Maturity Instructions can now be left unselected when opening a SINGLE/DOUBLE/SPECIAL FD --
a new "-- Not selected (decide later via FD Dashboard) --" option, plus a clear button next to
the field, since a maker had no way to go back to blank once something was picked. An account
left this way is never touched by the nightly maturity job; what happens to it at maturity is
decided later, manually, from the FD Dashboard's Renew action (see the backend/dashboard
commits) -- an explicit choice here still auto-processes on its own exactly as before.

Also fixes a real, separate pre-existing bug found in the same step: editing an existing
account never read back its own already-saved Maturity Instructions/transfer-target choice --
the Edit screen always opened onto a blank picker even for an account that genuinely had one
set, because it was being read from the wrong template object (the blank per-product default
template refetched from the Product dropdown, not the actual account+template response that
carries the account's own saved values).

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-24 16:08:51 +05:30
niteshandClaude Sonnet 5 50b3542dfe fix(products): show only genuinely-changed fields in the product-edit diff
Three separate bugs in the checker's "Proposed changes" dialog, found while adding the
Renewal Grace Period field above:

- The "current" side resolved an enum field's value against the wrong payload key
  (preClosurePenalInterestOnTypeId, minDepositTermTypeId, etc. are exposed on the stored
  product as an object under a differently-named field, e.g. preClosurePenalInterestOnType.id)
  so it always read as blank -- these fields showed as changed on every single edit, never
  just the ones a maker actually touched.
- Enum/option fields resolved their label against a naive `<field>Options` key that doesn't
  match Fineract's actual (inconsistent) naming, so a genuinely changed field showed its raw
  numeric id instead of the option a maker picked.
- Renewal Grace Period Days and other newer fields never appeared at all, because the
  maker-checker pending-edit mirror table didn't carry them (see the backend commit for that).

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-24 16:03:15 +05:30
niteshandClaude Sonnet 5 89c091671f feat(fd): add Renewal Grace Period (Days) field to FD product Terms step
Frontend half of the new per-product grace-period field: number input alongside the existing
term fields, and stops forcing real default values (1 / the first period-frequency option) onto
minDepositTerm/minDepositTermTypeId, two hidden fields nobody actually edits through this form
-- left blank like their max-/in-multiples-of- siblings already correctly were, so a governed
product edit's "Proposed changes" diff no longer showed these as changed on every unrelated edit.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-24 16:02:35 +05:30
niteshandClaude Sonnet 5 865ad8ba88 feat(fd): add FD Dashboard -- maturity ladder + manual Renew action
New screen (Deposits menu, repurposing the dead "FD Maturity Report" shell button): every
active Fixed Deposit positioned against its own maturity date (Matured / Due Soon / Upcoming /
On Track), computed client-side from maturityDate so it can never drift out of step with the
account. Reads the new lean maturity-dashboard backend endpoint.

A matured, renewable row (no maturity instruction chosen at account-opening, or an explicit
Reinvest instruction still waiting on its product's grace period) gets a Renew action wired to
the account's own Payment dialog -- see the FD account-view commit for that side of it.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-24 16:01:43 +05:30
anshul 08a427467a A 2026-09-23 15:51:10 +05:30
niteshandClaude Sonnet 5 3f59cc6b91 fix(cashier): route FD/RD cashier deposits through maker-checker approval
SYMPTOM
A cashier could credit an FD or RD account with cash from Transaction
Entry and the money moved in instantly, with no Manager approval -
unlike a Savings/Current deposit from the same screen, which correctly
creates a PENDING request under Approvals.

ROOT CAUSE
confirmSubmit()'s maker-checker branch for a cashier deposit only
matched row.acTyp in ('SD','CA','CB','GL'):
  if (!this.isClerk && !this.reviewMode && isDeposit &&
      (row.acTyp === 'SD' || row.acTyp === 'CA' || row.acTyp === 'CB' || row.acTyp === 'GL')) {
    this.submitCashierDepositRequest(row, ...);
    return;
  }
'FD' and 'RD' are their own distinct account-type codes in this
component (see creditEntryAccountType(): 'FD' -> 'FIXED_DEPOSIT', 'RD'
-> 'RECURRING_DEPOSIT', separate from 'SD'/'CA'/'CB') and were simply
never in this list, so a deposit into either fell through every later
branch (all of which are withdrawal/disbursement-only) straight to the
generic, unguarded direct POST at the bottom of confirmSubmit().

Found by tracing why a real FD funding deposit never showed up under
Branch Ops -> Approvals: it had posted directly, no request record
ever existed for it, and it later showed up as an unexplained cash
"Excess" in the Branch Day Register close (the money was physically
counted by the cashier via denominationBreakdown in the same direct
POST, but the approved-requests-only "book" total never counted it -
see the paired backend commit for the matching approve()-side fix and
how that stale data artifact was cleaned up).

FIX
Added 'FD' and 'RD' to the account-type list in that gate, so a
cashier's FD/RD deposit now creates a PENDING
m_cashier_deposit_request via submitCashierDepositRequest() exactly
like SD/CA/CB/GL already did. buildDepositPayload() needed no changes
- it already resolves accountType via creditEntryAccountType() and
sets savingsAccountId to the FD/RD account's own id correctly; only
this one gate condition excluded them.

SCOPE
This one condition, in this one method. Every other account type's
existing deposit/withdrawal behavior on this screen is unchanged.

IF THIS BREAKS SOMETHING LATER
- A cashier FD/RD deposit posting instantly again (no PENDING request
  created) -> check this gate's account-type list still includes
  'FD'/'RD', and that isClerk/reviewMode aren't being set unexpectedly
  for a cashier session.
- The backend rejecting or misrouting the resulting APPROVED request
  -> see the paired fix in CashierDepositRequestApiResource
  (cbsnexora-backend), resolveDepositAccountCategory() /
  executeFixedDepositDeposit() / executeRecurringDepositDeposit().
- A "Vault + Cashiers" vs "book" Excess reappearing in Branch Day
  Register close for a specific FD/RD deposit -> that combination
  (physically counted denomination with no approved request) should be
  impossible with this fix in place; if it recurs, this fix (or its
  backend half) has regressed.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-23 14:27:16 +05:30
niteshandClaude Sonnet 5 b0777e9845 fix(deposits): FD Single Rate Calculator sent tenureInMonths=0 for tenures under 30 days
SYMPTOM
FD Single account create screen, Rate Calculator: entering a tenure
under 30 days (e.g. 27) and clicking "Find Rate" produced a Bad
Request - "The parameter `tenureInMonths` must be greater than 0."

ROOT CAUSE
calculateRate() (fixed-deposit-account-interest-rate-chart-step.
component.ts) always converted the Days-unit tenure to months before
calling the backend calculator:
  tenureInMonths = Math.floor(enteredDays / 30)
For any tenure under 30 days this floors to 0, which the backend
correctly rejects (paired backend commit: "FD Single Rate Calculator
crashed for tenures under 30 days").

FIX
- fixed-deposits.service.ts: calculateFixedDepositInterest() -
  tenureInMonths is now optional, added optional tenureInDays.
- fixed-deposit-account-interest-rate-chart-step.component.ts -
  calculateRate(): for a Days-unit tenure >= 30 days, unchanged
  (tenureInMonths = floor(days/30)); for < 30 days, sends tenureInDays
  instead and leaves tenureInMonths undefined. Also fixed the
  API-failure fallback's periodic-compounding math, which divided by
  tenureInMonths directly and would have produced NaN once
  tenureInMonths could legitimately be undefined - it now falls back
  to (enteredDays / 30) when tenureInMonths is absent.

SCOPE
Only the Rate Calculator preview inside the FD Single/Double/Special
account create screen. Does not touch how depositPeriod /
depositPeriodFrequencyId get sent on actual account submission.

IF THIS BREAKS SOMETHING LATER
- The same 400 reappearing for a short FD Single tenure -> confirm the
  Find Rate network call carries tenureInDays and NOT a zero
  tenureInMonths; check calculateRate()'s branch choosing between the
  two.
- A wrong maturity preview number specifically when the backend API
  call fails and the frontend falls back to its own formula -> check
  the `effectiveMonths = tenureInMonths ?? enteredDays / 30` fallback
  line in calculateRate().

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-23 14:21:57 +05:30
anshulandClaude Sonnet 5 cda7c52d11 Merge branch 'dev' into feature/bugfix
Conflicts resolved in:
- src/app/clients/clients-view/ckyc-tab/ckyc-tab.component.html
- src/app/clients/clients-view/ckyc-tab/ckyc-tab.component.ts

Kept dev's CKYC architecture (upload dialog, real per-artefact endpoints)
and layered feature/bugfix's fixes on top: upload file validation,
missing-file badge, silent preview download, and image/PDF view button.
Also includes the CKYC "Image of Client" upload-state fix and the
client-change state-leak fix in the same component.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-16 16:55:32 +05:30
anshul d132bbe716 feat: add CKYC management tab, checker inbox views, and maker-checker diff utilities 2026-09-16 15:17:51 +05:30
satyamsingh_8888 6eaf9b959f Add Harbor login to deploy step so cross-node pull auth propagates to dev2
continuous-integration/drone/push Build is passing
build-run / Run Lint, Build and Deploy (push) Canceled after 0s
Publish Image in Docker Hub / build (push) Canceled after 0s
Tests / testRigor (push) Canceled after 0s
Playwright E2E Tests / Run Playwright Tests (push) Canceled after 0s
2026-09-14 18:41:34 +05:30
satyamsingh_8888 b184a0909c Deploy cbsnexora UI to dev2 node (environment==dev2 constraint)
continuous-integration/drone/push Build was killed
build-run / Run Lint, Build and Deploy (push) Canceled after 0s
Publish Image in Docker Hub / build (push) Canceled after 0s
Playwright E2E Tests / Run Playwright Tests (push) Canceled after 0s
Tests / testRigor (push) Canceled after 0s
2026-09-14 18:22:00 +05:30
66 changed files with 4200 additions and 498 deletions
+1 -1
View File
@@ -15,7 +15,7 @@
"ng": "ng",
"build": "node version.js && npm run env -s && ng build --configuration production --output-hashing=none",
"build:prod": "node version.js && node --max-old-space-size=16384 ./node_modules/@angular/cli/bin/ng build --configuration production --output-hashing=none --base-href=/web-app/",
"start": "npm run env -s && cross-env NODE_OPTIONS=\"--max_old_space_size=16384\" ng serve --proxy-config proxy.localhost.conf.js",
"start": "npm run env -s && cross-env NODE_OPTIONS=\"--max_old_space_size=4096\" ng serve --proxy-config proxy.localhost.conf.js",
"start:share": "npm run env -s && cross-env NODE_OPTIONS=\"--max_old_space_size=16384\" ng serve --host 0.0.0.0 --proxy-config proxy.localhost.conf.js",
"start:demo": "npm run env -s && cross-env NODE_OPTIONS=\"--max_old_space_size=16384\" ng serve --proxy-config proxy.conf.js",
"serve:dev": "npm run env -s && cross-env NODE_OPTIONS=\"--max_old_space_size=16384\" ng serve --source-map --proxy-config proxy.localhost.conf.js",
+11
View File
@@ -74,6 +74,17 @@ export class AccountsService {
return this.http.get('/fixeddepositaccounts', { params: httpParams });
}
/**
* FD Dashboard's data source: every ACTIVE FD, id/account/client/product identity, maturity date
* and deposit/maturity amounts only. Deliberately a separate, narrow endpoint rather than
* getFixedDepositAccounts() above -- that one's backend query joins in six more tables (audit
* who-did-what-when, currency, tax group) an unbounded dashboard fetch does not need, over columns
* that had no index until this feature added one (see backend migration 0571).
*/
getFixedDepositMaturityDashboard(): Observable<any> {
return this.http.get('/fixeddepositaccounts/maturity-dashboard');
}
getRecurringDepositAccounts(params: AccountListParams = {}): Observable<any> {
let httpParams = new HttpParams()
.set('paged', 'true')
+2
View File
@@ -63,6 +63,7 @@ import { CfcfrmsModule } from './cfcfrms/cfcfrms.module';
import { BankCalendarModule } from './bank-calendar/bank-calendar.module';
import { DeafModule } from './deaf/deaf.module';
import { SavingsDailyInterestModule } from './savings-daily-interest/savings-daily-interest.module';
import { FdDashboardModule } from './fd-dashboard/fd-dashboard.module';
import { DicgcModule } from './dicgc/dicgc.module';
import { FiuModule } from './fiu/fiu.module';
import { GlReportsModule } from './gl-reports/gl-reports.module';
@@ -176,6 +177,7 @@ export function HttpLoaderFactory(http: HttpClient) {
BankCalendarModule,
DeafModule,
SavingsDailyInterestModule,
FdDashboardModule,
DicgcModule,
FiuModule,
GlReportsModule,
@@ -5804,13 +5804,21 @@ export class TransactionEntryComponent implements OnInit, OnDestroy {
return;
}
// Cashier deposit (savings/current/GL) → route through maker-checker (create PENDING request, no direct credit)
// Cashier deposit (savings/current/FD/RD/GL) → route through maker-checker (create PENDING request,
// no direct credit). FD and RD are their own account-type codes (see creditEntryAccountType()) and
// belong here just as much as a plain savings deposit -- a cashier crediting an FD/RD must not be
// able to move money in without a manager's approval either.
// Use the dialog-confirmed amount — for cash that is the denomination total
if (
!this.isClerk &&
!this.reviewMode &&
isDeposit &&
(row.acTyp === 'SD' || row.acTyp === 'CA' || row.acTyp === 'CB' || row.acTyp === 'GL')
(row.acTyp === 'SD' ||
row.acTyp === 'CA' ||
row.acTyp === 'CB' ||
row.acTyp === 'FD' ||
row.acTyp === 'RD' ||
row.acTyp === 'GL')
) {
this.submitCashierDepositRequest(row, this.confirmData?.amount ?? amount);
return;
@@ -202,9 +202,14 @@
<div>
<strong>Already available</strong>
<span class="ckyc-existing__source">from {{ existing.ADDRESS_PROOF.source }}</span>
@if (existing.ADDRESS_PROOF.previewUnavailable) {
<span class="ckyc-existing__warn">stored file unavailable</span>
}
</div>
</div>
@if (existing.ADDRESS_PROOF.previewUrl || existing.ADDRESS_PROOF.previewUrlBack) {
@if (
(existing.ADDRESS_PROOF.previewUrl || existing.ADDRESS_PROOF.previewUrlBack) && isImage('ADDRESS_PROOF')
) {
<div class="ckyc-existing__thumbs">
@if (existing.ADDRESS_PROOF.previewUrl) {
<img
@@ -222,6 +227,17 @@
}
</div>
}
@if (existing.ADDRESS_PROOF.previewUrl) {
<button
mat-stroked-button
type="button"
class="ckyc-existing__view"
(click)="openDocument('ADDRESS_PROOF')"
>
<fa-icon icon="eye"></fa-icon>
&nbsp;View document
</button>
}
<div class="ckyc-existing__meta">
<fa-icon icon="file-alt"></fa-icon>
<span>{{ existing.ADDRESS_PROOF.name }}</span>
@@ -299,9 +315,12 @@
<div>
<strong>Already available</strong>
<span class="ckyc-existing__source">from {{ existing.PHOTO_PROOF.source }}</span>
@if (existing.PHOTO_PROOF.previewUnavailable) {
<span class="ckyc-existing__warn">stored file unavailable</span>
}
</div>
</div>
@if (existing.PHOTO_PROOF.previewUrl || existing.PHOTO_PROOF.previewUrlBack) {
@if ((existing.PHOTO_PROOF.previewUrl || existing.PHOTO_PROOF.previewUrlBack) && isImage('PHOTO_PROOF')) {
<div class="ckyc-existing__thumbs">
@if (existing.PHOTO_PROOF.previewUrl) {
<img
@@ -319,6 +338,17 @@
}
</div>
}
@if (existing.PHOTO_PROOF.previewUrl) {
<button
mat-stroked-button
type="button"
class="ckyc-existing__view"
(click)="openDocument('PHOTO_PROOF')"
>
<fa-icon icon="eye"></fa-icon>
&nbsp;View document
</button>
}
<div class="ckyc-existing__meta">
<fa-icon icon="id-card"></fa-icon>
<span>{{ existing.PHOTO_PROOF.name }}</span>
@@ -393,11 +423,25 @@
<div>
<strong>Already available</strong>
<span class="ckyc-existing__source">from {{ existing.CLIENT_IMAGE.source }}</span>
@if (existing.CLIENT_IMAGE.previewUnavailable) {
<span class="ckyc-existing__warn">stored file unavailable</span>
}
</div>
</div>
@if (existing.CLIENT_IMAGE.previewUrl) {
@if (existing.CLIENT_IMAGE.previewUrl && isImage('CLIENT_IMAGE')) {
<img [src]="existing.CLIENT_IMAGE.previewUrl" alt="Client photo" class="ckyc-existing__thumb" />
}
@if (existing.CLIENT_IMAGE.previewUrl) {
<button
mat-stroked-button
type="button"
class="ckyc-existing__view"
(click)="openDocument('CLIENT_IMAGE')"
>
<fa-icon icon="eye"></fa-icon>
&nbsp;View document
</button>
}
<button
mat-stroked-button
type="button"
@@ -448,15 +492,29 @@
<div>
<strong>Already available</strong>
<span class="ckyc-existing__source">from {{ existing.CLIENT_SIGNATURE.source }}</span>
@if (existing.CLIENT_SIGNATURE.previewUnavailable) {
<span class="ckyc-existing__warn">stored file unavailable</span>
}
</div>
</div>
@if (existing.CLIENT_SIGNATURE.previewUrl) {
@if (existing.CLIENT_SIGNATURE.previewUrl && isImage('CLIENT_SIGNATURE')) {
<img
[src]="existing.CLIENT_SIGNATURE.previewUrl"
alt="Client signature"
class="ckyc-existing__thumb ckyc-existing__thumb--wide"
/>
}
@if (existing.CLIENT_SIGNATURE.previewUrl) {
<button
mat-stroked-button
type="button"
class="ckyc-existing__view"
(click)="openDocument('CLIENT_SIGNATURE')"
>
<fa-icon icon="eye"></fa-icon>
&nbsp;View document
</button>
}
<div class="ckyc-existing__meta">
<fa-icon icon="file-signature"></fa-icon>
<span>{{ existing.CLIENT_SIGNATURE.name }}</span>
@@ -623,6 +623,35 @@
}
}
/* Opens the uploaded artefact full size in a new tab. Sits directly under the thumbnail, and is
the only way to inspect a PDF - which cannot render as an inline <img>. */
.ckyc-existing__view {
align-self: flex-start;
color: #1d6fd0;
border-color: #bcd8f5;
background: #fff;
font-size: 13px;
font-weight: 600;
&:hover {
background: #eef6ff;
}
}
/* Shown when a document row exists but its file could not be fetched from the content store,
so "Already available" is not silently misleading. */
.ckyc-existing__warn {
display: inline-block;
margin-left: 8px;
padding: 1px 8px;
border-radius: 10px;
background: #fdecec;
border: 1px solid #f3c2c2;
color: #a12b2b;
font-size: 11px;
font-weight: 600;
}
.ckyc-existing {
display: flex;
flex-direction: column;
@@ -36,11 +36,41 @@ interface ExistingArtefact {
* already writes/reads for address proof; now shared by the photo/identity proof too. */
description?: string;
fileCount?: number;
/** Set when the stored file could not be fetched - the row exists but its content is gone. */
previewUnavailable?: boolean;
/** Stored MIME type, so the card knows whether it can render an inline thumbnail. */
mimeType?: string;
}
type ProofKind = 'ADDRESS_PROOF' | 'PHOTO_PROOF';
type CkycDocKind = ProofKind | 'CLIENT_IMAGE' | 'CLIENT_SIGNATURE';
/**
* Client-side upload guards, kept deliberately in step with the backend so a file that passes here
* is not rejected server-side:
* - 5 MB matches `spring.servlet.multipart.max-file-size`
* - the extension list is the intersection of the backend `fineract.content.regex-whitelist`
* with what these cards can actually render.
* The dialog's `accept=` is only a file-picker hint and is trivially bypassed, so the real check
* runs here, on the file the dialog hands back, before anything is sent.
*/
const MAX_UPLOAD_BYTES = 5 * 1024 * 1024;
const IMAGE_EXTENSIONS = [
'png',
'jpeg',
'jpg'
];
const DOCUMENT_EXTENSIONS = [
...IMAGE_EXTENSIONS,
'pdf'
];
/** Cards that render an inline <img>, so a PDF would not display in them. */
const IMAGE_ONLY_KINDS: CkycDocKind[] = [
'CLIENT_IMAGE',
'CLIENT_SIGNATURE'
];
@Component({
selector: 'mifosx-ckyc-tab',
templateUrl: './ckyc-tab.component.html',
@@ -150,6 +180,11 @@ export class CkycTabComponent implements OnInit, OnDestroy {
ngOnInit(): void {
this.route.parent.params.subscribe((params: any) => {
this.clientId = params['clientId'];
// Angular reuses this component when only the clientId in the route changes, and
// loadExistingArtefacts() only ever *assigns* what it finds - it has no "not found" branch.
// Without clearing first, a client with no photo (or no signature, or no proof) keeps showing
// the previous client's card.
this.resetArtefactState();
this.loadClientInfo();
this.loadAccounts();
this.loadCkycData();
@@ -157,6 +192,46 @@ export class CkycTabComponent implements OnInit, OnDestroy {
});
}
/**
* Returns every per-client card to its empty state, so nothing from the client just viewed can
* survive into the next one. Covers all four cards plus the details staged for an upload - a file
* or document number left over from another client must never be submitted against this one.
*/
private resetArtefactState(): void {
this.revokePreviewUrls();
this.existing = {
ADDRESS_PROOF: { present: false },
PHOTO_PROOF: { present: false },
CLIENT_IMAGE: { present: false },
CLIENT_SIGNATURE: { present: false }
};
this.selectedProofType = { ADDRESS_PROOF: null, PHOTO_PROOF: null };
this.docFiles = {
ADDRESS_PROOF: { front: null, back: null },
PHOTO_PROOF: { front: null, back: null }
};
this.documentDetails = {
ADDRESS_PROOF: { documentKey: '', issueDate: null, expiryDate: null },
PHOTO_PROOF: { documentKey: '', issueDate: null, expiryDate: null }
};
this.clientImageFile = null;
this.clientSignatureFile = null;
this.uploadErrors = {
ADDRESS_PROOF: null,
PHOTO_PROOF: null,
CLIENT_IMAGE: null,
CLIENT_SIGNATURE: null
};
}
/** Frees the blob URLs held by the current cards before their references are dropped. */
private revokePreviewUrls(): void {
Object.values(this.existing).forEach((e) => {
if (e.previewUrl?.startsWith('blob:')) URL.revokeObjectURL(e.previewUrl);
if (e.previewUrlBack?.startsWith('blob:')) URL.revokeObjectURL(e.previewUrlBack);
});
}
private loadClientInfo(): void {
this.route.parent.data.subscribe((data: any) => {
const cv = data?.clientViewData;
@@ -280,19 +355,29 @@ export class CkycTabComponent implements OnInit, OnDestroy {
present: true,
source: 'Client registration',
name: signatureDoc.fileName || 'Client signature',
documentId: signatureDoc.id
documentId: signatureDoc.id,
mimeType: signatureDoc.type
};
// Fetch blob for preview
// Same endpoint getClientSignatureImage() uses, but opted out of the global error alert:
// a m_document row whose file is no longer in the content store returns 500, and the card
// below reports that itself. Without `silent` the interceptor pops an "Internal Server
// Error" modal on every page load, for a failure the user cannot act on.
this.clientsService
.getClientSignatureImage(this.clientId, signatureDoc.id)
.downloadClientDocument(this.clientId, String(signatureDoc.id), true)
.pipe(takeUntil(this.destroy$))
.subscribe({
next: (blob: any) => {
if (blob) {
if (blob && blob.size > 0) {
this.existing.CLIENT_SIGNATURE.previewUrl = URL.createObjectURL(blob);
} else {
// A 200 with an empty body still means there is nothing to show. Without this the
// card would claim "Already available" with no thumbnail and no explanation.
this.existing.CLIENT_SIGNATURE.previewUnavailable = true;
}
},
error: () => undefined
error: () => {
this.existing.CLIENT_SIGNATURE.previewUnavailable = true;
}
});
}
@@ -364,6 +449,7 @@ export class CkycTabComponent implements OnInit, OnDestroy {
const list = Array.isArray(docs) ? docs : [];
if (!list.length) return;
this.existing[kind].fileCount = list.length;
this.existing[kind].previewUnavailable = false;
const byNewest = (a: any, b: any) => b.id - a.id;
const label = (d: any) => (d?.name || d?.fileName || '').toString();
@@ -372,6 +458,9 @@ export class CkycTabComponent implements OnInit, OnDestroy {
[...list].filter((d: any) => /front/i.test(label(d))).sort(byNewest)[0] ||
[...list].filter((d: any) => d.id !== back?.id).sort(byNewest)[0];
// A proof can be scanned to PDF, which no <img> can render - remember what was stored so the
// card offers "View document" instead of a broken thumbnail.
if (front) this.existing[kind].mimeType = front.type;
if (front) this.downloadIdentifierDocPreview(identifierId, front.id, kind, 'front');
if (back && back.id !== front?.id) this.downloadIdentifierDocPreview(identifierId, back.id, kind, 'back');
});
@@ -388,12 +477,19 @@ export class CkycTabComponent implements OnInit, OnDestroy {
.pipe(takeUntil(this.destroy$))
.subscribe({
next: (blob: any) => {
if (!blob) return;
if (!blob || blob.size === 0) {
this.existing[kind].previewUnavailable = true;
return;
}
const url = URL.createObjectURL(blob);
if (slot === 'front') this.existing[kind].previewUrl = url;
else this.existing[kind].previewUrlBack = url;
},
error: () => undefined
// The row exists but its file is gone from the content store. Saying so on the card beats
// showing "Already available" next to a blank space.
error: () => {
this.existing[kind].previewUnavailable = true;
}
});
}
@@ -452,6 +548,18 @@ export class CkycTabComponent implements OnInit, OnDestroy {
ref.afterClosed().subscribe((result: CkycUploadDialogResult | null) => {
if (!result) return;
// The dialog's `accept=` filter is only a hint to the file picker and is trivially bypassed,
// so the size/type guard runs here on what actually came back. Catching it now turns a raw
// server rejection into a message on the card.
const rejection =
this.validationError(result.front, kind) ?? (result.back ? this.validationError(result.back, kind) : null);
if (rejection) {
this.uploadErrors[kind] = rejection;
return;
}
this.uploadErrors[kind] = null;
if (isProofKind) {
const proofKind = kind as ProofKind;
this.selectedProofType[proofKind] = result.proofType || null;
@@ -470,6 +578,66 @@ export class CkycTabComponent implements OnInit, OnDestroy {
});
}
/** Extensions a given card can both store and render. */
private allowedExtensions(kind: CkycDocKind): string[] {
return IMAGE_ONLY_KINDS.includes(kind) ? IMAGE_EXTENSIONS : DOCUMENT_EXTENSIONS;
}
/**
* Rejects a file the backend would refuse (or the card could not render) before it is uploaded,
* so the user gets a clear message instead of a raw server error. Returns null when the file is
* acceptable.
*/
private validationError(file: File | null | undefined, kind: CkycDocKind): string | null {
if (!file) {
return null;
}
const allowed = this.allowedExtensions(kind);
const extension = (file.name.split('.').pop() || '').toLowerCase();
if (!allowed.includes(extension)) {
return `Unsupported file type ".${extension}". Allowed: ${allowed.map((e) => e.toUpperCase()).join(', ')}.`;
}
if (file.size > MAX_UPLOAD_BYTES) {
const mb = (file.size / (1024 * 1024)).toFixed(1);
return `File is ${mb} MB. The maximum allowed size is 5 MB.`;
}
if (file.size === 0) {
return 'The selected file is empty.';
}
return null;
}
/**
* True when the stored file can be shown as an inline thumbnail. A PDF cannot, so its card falls
* back to the "View document" button alone rather than rendering a broken image.
*/
isImage(kind: CkycDocKind): boolean {
const entry = this.existing[kind];
// The profile image arrives as a `data:image/...;base64,` URL, which declares its own type -
// it has neither a stored MIME type nor a file extension for the checks below to read.
if (entry?.previewUrl?.startsWith('data:')) {
return entry.previewUrl.startsWith('data:image/');
}
if (entry?.mimeType) {
return entry.mimeType.startsWith('image/');
}
// Older rows can have no stored MIME type - fall back to the file extension.
const extension = (entry?.name?.split('.').pop() || '').toLowerCase();
return IMAGE_EXTENSIONS.includes(extension);
}
/**
* Opens the uploaded document full size in a new tab. Uses the blob already fetched for the
* thumbnail, so it works for PDFs too and costs no extra request.
*/
openDocument(kind: CkycDocKind): void {
const url = this.existing[kind]?.previewUrl;
if (url) {
window.open(url, '_blank', 'noopener');
}
}
/** 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 } {
@@ -533,6 +701,10 @@ export class CkycTabComponent implements OnInit, OnDestroy {
request$.pipe(takeUntil(this.destroy$)).subscribe({
next: () => {
this.uploading[documentType] = false;
// The card is gated on `present`; without this a successful upload left it reading
// "Not uploaded yet". uploadProofDocument() sets the same flag for the proof cards.
this.existing[key].present = true;
this.existing[key].source = this.existing[key].source || 'CKYC upload';
this.alertService.alert({ type: 'success', message: 'Document uploaded successfully.' });
this.refreshPreview(documentType);
},
@@ -653,13 +825,22 @@ export class CkycTabComponent implements OnInit, OnDestroy {
if (this.existing[staleKey].previewUrlBack?.startsWith('blob:')) {
URL.revokeObjectURL(this.existing[staleKey].previewUrlBack!);
}
// The upload that triggered this refresh is what repairs a previously missing file, so the
// warning has to be cleared before the re-read - otherwise a fixed card keeps the old badge.
this.existing[staleKey].previewUrl = undefined;
this.existing[staleKey].previewUrlBack = undefined;
this.existing[staleKey].previewUnavailable = false;
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;
if (photo) {
this.existing.CLIENT_IMAGE.previewUrl = photo;
} else {
this.existing.CLIENT_IMAGE.previewUnavailable = true;
}
});
break;
case 'CLIENT_SIGNATURE':
@@ -672,11 +853,21 @@ export class CkycTabComponent implements OnInit, OnDestroy {
?.sort((a: any, b: any) => b.id - a.id)?.[0];
if (!doc) return;
this.existing.CLIENT_SIGNATURE.documentId = doc.id;
this.existing.CLIENT_SIGNATURE.mimeType = doc.type;
this.clientsService
.getClientSignatureImage(this.clientId, doc.id)
.downloadClientDocument(this.clientId, String(doc.id), true)
.pipe(takeUntil(this.destroy$))
.subscribe((blob: any) => {
if (blob) this.existing.CLIENT_SIGNATURE.previewUrl = URL.createObjectURL(blob);
.subscribe({
next: (blob: any) => {
if (blob && blob.size > 0) {
this.existing.CLIENT_SIGNATURE.previewUrl = URL.createObjectURL(blob);
} else {
this.existing.CLIENT_SIGNATURE.previewUnavailable = true;
}
},
error: () => {
this.existing.CLIENT_SIGNATURE.previewUnavailable = true;
}
});
});
break;
@@ -690,10 +881,6 @@ export class CkycTabComponent implements OnInit, OnDestroy {
ngOnDestroy(): void {
this.destroy$.next();
this.destroy$.complete();
// 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);
});
this.revokePreviewUrls();
}
}
@@ -1247,9 +1247,7 @@
</div>
@if (!docScansLoading && !docScans.front && !docScans.back) {
<div class="kyc-doc-scans__empty">
No scan is on file for this document. Upload one below.
</div>
<div class="kyc-doc-scans__empty">No scan is on file for this document. Upload one below.</div>
}
<div class="kyc-doc-scans__sides">
@@ -182,7 +182,10 @@ export class KycTabComponent implements OnInit, OnDestroy {
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'];
readonly docScanSlots: ('front' | 'back')[] = [
'front',
'back'
];
docScanOf(slot: 'front' | 'back') {
return this.docScans[slot];
@@ -1455,7 +1458,10 @@ I further confirm that:
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]) {
for (const old of [
this.docScans.front,
this.docScans.back
]) {
if (old?.previewUrl) {
URL.revokeObjectURL(old.previewUrl);
}
@@ -1497,7 +1503,10 @@ I further confirm that:
}
this.docScans = sides;
for (const key of ['front', 'back'] as const) {
for (const key of [
'front',
'back'
] as const) {
if (sides[key]) {
this.loadScanPreview(sides[key]!);
}
@@ -1558,7 +1567,9 @@ I further confirm that:
error: (err: any) => {
this.docScanUploading = null;
this.docScanError =
err?.error?.defaultUserMessage || err?.error?.developerMessage || `Could not replace the ${label.toLowerCase()} side.`;
err?.error?.defaultUserMessage ||
err?.error?.developerMessage ||
`Could not replace the ${label.toLowerCase()} side.`;
}
});
}
@@ -1741,7 +1752,10 @@ I further confirm that:
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]) {
for (const side of [
this.docScans.front,
this.docScans.back
]) {
if (side?.previewUrl) {
URL.revokeObjectURL(side.previewUrl);
}
+33 -6
View File
@@ -569,8 +569,19 @@ export class ClientsService {
return this.http.get(`/clients/${clientId}/documents`);
}
downloadClientDocument(parentEntityId: string, documentId: string) {
return this.http.get(`/clients/${parentEntityId}/documents/${documentId}/attachment`, { responseType: 'blob' });
/**
* Fetches a client document's stored file.
*
* `silent` opts the request out of ErrorHandlerInterceptor's alert. Pass it when the caller
* renders its own failure state - notably thumbnail fetches, where a `m_document` row whose file
* is no longer in the content store returns 500 and would otherwise pop a modal the user can do
* nothing about. The interceptor still rethrows, so the caller's error callback continues to run.
*/
downloadClientDocument(parentEntityId: string, documentId: string, silent = false) {
return this.http.get(`/clients/${parentEntityId}/documents/${documentId}/attachment`, {
responseType: 'blob',
...(silent ? { context: new HttpContext().set(SILENT_HTTP_ERROR, true) } : {})
});
}
uploadClientDocument(clientId: string, documentData: any) {
@@ -992,12 +1003,28 @@ export class ClientsService {
return this.http.post(`/clients/${clientId}/entityclassification/${category}`, data);
}
getClientCkyc(clientId: string): Observable<any> {
return this.http.get(`/clients/${clientId}/ckyc`);
/**
* Uploads a single CKYC artefact against the client, using the platform's generic document
* endpoint (DocumentApiResource, POST /v1/{entityType}/{entityId}/documents). There is no
* CKYC-specific document API on the backend - the earlier `/clients/{id}/ckyc/documents` path
* was never implemented and returned 404.
*
* The multipart body must carry `file`, `name` and `description`; the caller builds those so the
* stored document stays identifiable by CKYC artefact type. See ckyc-tab.component.ts.
*/
uploadCkycDocument(clientId: string, formData: FormData): Observable<any> {
return this.http.post(`/clients/${clientId}/documents`, formData);
}
uploadCkycDocument(clientId: string, formData: FormData): Observable<any> {
return this.http.post(`/clients/${clientId}/ckyc/documents`, formData);
/**
* Replaces the content of an existing client document in place (DocumentApiResource PUT
* /v1/{entityType}/{entityId}/documents/{documentId}), keeping the same document id.
*
* Used by the CKYC screen's "Upload new version" so a re-upload updates the existing artefact
* rather than adding another row. Takes the same multipart body as the create call.
*/
replaceClientDocument(clientId: string, documentId: string, formData: FormData): Observable<any> {
return this.http.put(`/clients/${clientId}/documents/${documentId}`, formData);
}
/**
@@ -109,13 +109,7 @@
</button>
}
</div>
<input
#sideInput
type="file"
hidden
accept="image/*,application/pdf"
(change)="onFilePicked($event, slot)"
/>
<input #sideInput type="file" hidden accept="image/*,application/pdf" (change)="onFilePicked($event, slot)" />
</div>
}
</div>
+10 -2
View File
@@ -65,6 +65,14 @@
<span class="cashier-nav__esc-hint" (click)="onEscapeKey()"> <kbd>ESC</kbd> Show Header </span>
</nav>
}
<!-- FD Dashboard fullscreen — ESC hint bar -->
@else if (currentUrl.includes('/fd-dashboard')) {
<nav class="cashier-nav cashier-nav--election">
<span class="cashier-nav__esc-hint" (click)="onEscapeKey()">
<kbd>ESC</kbd> Exit Fullscreen (Show Header &amp; Footer)
</span>
</nav>
}
<!-- Clerk Dashboard fullscreen nav — only visible at /clerk/* -->
@else if (currentUrl.includes('/clerk/')) {
<nav class="cashier-nav cashier-nav--clerk">
@@ -1203,8 +1211,8 @@
<button mat-menu-item tabindex="0">
<fa-icon icon="clock" class="menu-item-icon"></fa-icon> Account Expiry
</button>
<button mat-menu-item tabindex="0">
<fa-icon icon="file-alt" class="menu-item-icon"></fa-icon> FD Maturity Report
<button mat-menu-item tabindex="0" [routerLink]="['/fd-dashboard']">
<fa-icon icon="calendar-check" class="menu-item-icon"></fa-icon> FD Dashboard
</button>
<button mat-menu-item tabindex="0">
<fa-icon icon="chart-line" class="menu-item-icon"></fa-icon> FD Interest Report
+9 -7
View File
@@ -167,7 +167,9 @@ export class ShellComponent implements OnInit, OnDestroy {
url.includes('/clerk/withdrawal-request') ||
url.includes('/clerk/my-disbursement-requests');
const isElectionRoute = url.includes('/election');
this.hideShellLayout = isCashierOnly || isClerkRoute || isElectionRoute || this.isWithdrawalReviewUrl;
const isFdDashboardRoute = url.includes('/fd-dashboard');
this.hideShellLayout =
isCashierOnly || isClerkRoute || isElectionRoute || isFdDashboardRoute || this.isWithdrawalReviewUrl;
this.cdr.detectChanges();
};
this.routeSub = this.router.events.pipe(filter((e) => e instanceof NavigationEnd)).subscribe(() => checkLayout());
@@ -180,10 +182,7 @@ export class ShellComponent implements OnInit, OnDestroy {
});
}
/**
* Toggles the current collapsed state of sidenav according to the emitted event.
* @param {boolean} event denotes state of sidenav
*/
/** Toggles the current collapsed state of sidenav according to the emitted event. */
toggleCollapse($event: boolean) {
this.sidenavCollapsed = $event;
this.cdr.detectChanges();
@@ -204,12 +203,15 @@ export class ShellComponent implements OnInit, OnDestroy {
return /\/checker-inbox-and-tasks\/(share-)?withdrawal-requests\/\d+\/review/.test(this.router.url);
}
/** ESC key — exit fullscreen at Clerk Dashboard / Election / review screen, restoring full shell header. */
/** ESC key — exit fullscreen at Clerk Dashboard / Election / FD Dashboard / review screen, restoring full shell header. */
@HostListener('document:keydown.escape')
onEscapeKey(): void {
if (
this.hideShellLayout &&
(this.router.url.includes('/clerk/') || this.router.url.includes('/election') || this.isWithdrawalReviewUrl)
(this.router.url.includes('/clerk/') ||
this.router.url.includes('/election') ||
this.router.url.includes('/fd-dashboard') ||
this.isWithdrawalReviewUrl)
) {
this.hideShellLayout = false;
this.cdr.detectChanges();
@@ -334,14 +334,18 @@ export class FixedDepositAccountInterestRateChartStepComponent implements OnChan
const frequencyId = this.resolveFrequencyId(this.tenurePeriodUnit);
if (this.principalAmount && this.principalAmount > 0) {
// Convert tenure to months for backend API
let tenureInMonths = 0;
// Convert tenure to months for backend API. A Days tenure under 30 days rounds down to 0
// months, which the backend rejects -- send it as tenureInDays instead in that case.
let tenureInMonths: number | undefined;
let tenureInDays: number | undefined;
if (this.tenurePeriodUnit === 'Years') {
tenureInMonths = this.tenureValue * 12;
} else if (this.tenurePeriodUnit === 'Months') {
tenureInMonths = this.tenureValue;
} else {
} else if (enteredDays >= 30) {
tenureInMonths = Math.floor(enteredDays / 30);
} else {
tenureInDays = enteredDays;
}
// Map compounding period type id → months (0=Daily, 1=Daily, 4=Monthly, 5=Quarterly, 6=SemiAnnual, 7=Annual)
@@ -395,6 +399,7 @@ export class FixedDepositAccountInterestRateChartStepComponent implements OnChan
principalAmount: this.principalAmount,
annualInterestRate: rate,
tenureInMonths,
tenureInDays,
interestCompoundingPeriodInMonths: compMonths, // 0 = daily, now supported by backend
interestPostingPeriodInMonths: postMonths,
submittedOnDate: submittedOnDateStr,
@@ -417,7 +422,8 @@ export class FixedDepositAccountInterestRateChartStepComponent implements OnChan
Math.round(this.principalAmount * Math.pow(1 + r / daysInYearVal, enteredDays) * 100) / 100;
} else {
const n = 12 / compMonths;
const totalCompoundings = Math.floor(tenureInMonths / compMonths);
const effectiveMonths = tenureInMonths ?? enteredDays / 30;
const totalCompoundings = Math.floor(effectiveMonths / compMonths);
const rPerCompounding = rate / (n * 100);
result.maturityAmount =
Math.round(this.principalAmount * Math.pow(1 + rPerCompounding, totalCompoundings) * 100) / 100;
@@ -7,17 +7,29 @@
-->
<form [formGroup]="closureSettingsForm" class="layout-row-wrap gap-2px responsive-column">
<h4 class="mat-h4 flex-98">{{ 'labels.heading.For Pre-mature closure' | translate }}</h4>
<h4 class="mat-h4 flex-98">{{ 'labels.heading.Maturity Instructions' | translate }}</h4>
<mat-form-field class="flex-48">
<mat-label>{{ 'labels.inputs.Maturity Instructions' | translate }}</mat-label>
<mat-select formControlName="maturityInstructionId">
<mat-option [value]="''">-- Not selected (decide later via FD Dashboard) --</mat-option>
@for (maturityInstruction of maturityInstructionOptions; track maturityInstruction) {
<mat-option [value]="maturityInstruction.id">
{{ maturityInstruction.value | translateKey: 'catalogs' }}
</mat-option>
}
</mat-select>
@if (closureSettingsForm.value.maturityInstructionId) {
<button
mat-icon-button
matSuffix
type="button"
title="Clear -- decide later via FD Dashboard"
(click)="$event.stopPropagation(); closureSettingsForm.get('maturityInstructionId')?.setValue('')"
>
<mat-icon>close</mat-icon>
</button>
}
</mat-form-field>
@if (closureSettingsForm.contains('transferToSavingsId')) {
@@ -28,8 +40,8 @@
<mat-icon>savings</mat-icon>
</div>
<div>
<h3 class="dal-card__title">{{ 'labels.inputs.Transfer to savings account' | translate }}</h3>
<span class="dal-card__sub">Maturity proceeds will be credited to this account</span>
<h3 class="dal-card__title">{{ transferAccountCardTitle }}</h3>
<span class="dal-card__sub">{{ transferAccountCardSubtitle }}</span>
</div>
</div>
</div>
@@ -69,7 +81,7 @@
</div>
} @else {
<mat-form-field appearance="outline" class="flex-98">
<mat-label>{{ 'labels.inputs.Transfer to savings account' | translate }}</mat-label>
<mat-label>{{ transferAccountCardTitle }}</mat-label>
<mat-select formControlName="transferToSavingsId" required>
@for (account of savingsAccountsData; track account) {
<mat-option [value]="account.id">
@@ -77,13 +89,15 @@
</mat-option>
}
</mat-select>
<mat-error>Transfer to savings account is required</mat-error>
<mat-error>{{ transferAccountCardTitle }} is required</mat-error>
</mat-form-field>
}
</div>
</div>
}
<h4 class="mat-h4 flex-98">{{ 'labels.heading.For Pre-mature closure' | translate }}</h4>
<mat-checkbox class="flex-98 margin-v" labelPosition="before" formControlName="preClosurePenalApplicable">
{{ 'labels.inputs.Apply Penal Interest (less)' | translate }}
</mat-checkbox>
@@ -11,6 +11,7 @@ import { Component, Input, OnChanges, SimpleChanges, inject } from '@angular/cor
import { UntypedFormGroup, UntypedFormBuilder, UntypedFormControl, Validators } from '@angular/forms';
import { MatCheckbox } from '@angular/material/checkbox';
import { MatIcon } from '@angular/material/icon';
import { MatIconButton } from '@angular/material/button';
import { STANDALONE_SHARED_IMPORTS } from 'app/standalone-shared.module';
/**
@@ -25,7 +26,8 @@ import { STANDALONE_SHARED_IMPORTS } from 'app/standalone-shared.module';
imports: [
...STANDALONE_SHARED_IMPORTS,
MatCheckbox,
MatIcon
MatIcon,
MatIconButton
]
})
export class FixedDepositAccountMaturityTermsStepComponent implements OnChanges {
@@ -53,9 +55,12 @@ export class FixedDepositAccountMaturityTermsStepComponent implements OnChanges
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) {
// 200 = Transfer to Savings -- the whole matured balance goes here. 400 = Reinvest Principal
// Only -- the principal rolls into a new FD, but the leftover interest still needs the same
// kind of target so it isn't left stranded on the closed account (a real gap the account used
// to silently hit before this field also asked for it here). Reinvest Principal + Interest
// (300) rolls the whole balance into the same account, so it never needs a target at all.
if (value === 200 || value === 400) {
this.closureSettingsForm.addControl(
'transferToSavingsId',
new UntypedFormControl(this.linkedSavingsAccountId ?? '', Validators.required)
@@ -91,15 +96,48 @@ export class FixedDepositAccountMaturityTermsStepComponent implements OnChanges
});
}
}
// Editing an existing account: its own already-saved Maturity Instructions choice lives on
// `fixedDepositsAccountTemplate` (the real account+template GET, FixedDepositAccountData's
// `onAccountClosure`) -- NOT on `fixedDepositsAccountProductTemplate`, which is only the blank
// per-product default template refetched from the Product dropdown's own productId (via
// getFixedDepositsAccountTemplate(clientId, productId)) and carries no account-specific data at
// all. This was never read from the right place before, so Edit always opened onto a blank
// picker even for an account that really does have an instruction saved. `.dirty` guards it the
// same way the penal-interest fields above do, so a maker's own in-progress edit here is never
// silently overwritten if this input fires again mid-edit.
if (this.fixedDepositsAccountTemplate && !this.closureSettingsForm.get('maturityInstructionId').dirty) {
this.closureSettingsForm
.get('maturityInstructionId')
?.setValue(this.fixedDepositsAccountTemplate.onAccountClosure?.id ?? '');
const transferControl = this.closureSettingsForm.get('transferToSavingsId');
if (transferControl) {
transferControl.setValue(
this.fixedDepositsAccountTemplate.transferToSavingsId ?? this.linkedSavingsAccountId ?? ''
);
}
}
}
/** The linked-savings account for the transfer-to-savings maturity instruction, once picked. */
/** The linked-savings account for the transfer-to-savings / reinvest-principal-only-interest
* 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;
}
get transferAccountCardTitle(): string {
return this.closureSettingsForm.value.maturityInstructionId === 400
? 'Interest payout account'
: 'Transfer to savings account';
}
get transferAccountCardSubtitle(): string {
return this.closureSettingsForm.value.maturityInstructionId === 400
? 'The principal renews into a new FD; interest earned is paid out to this account.'
: 'Maturity proceeds will be credited to this account.';
}
clearTransferAccount(): void {
this.closureSettingsForm.get('transferToSavingsId')?.setValue('');
}
@@ -8,11 +8,24 @@
<div class="pmt">
<div class="pmt__header">
<span class="pmt__header-ico"><fa-icon icon="sync-alt"></fa-icon></span>
<div class="pmt__header-text">
<h2 class="pmt__title">{{ 'labels.heading.Payment' | translate }}</h2>
<p class="pmt__subtitle">Process this Fixed Deposit now -- works whether it's Active or Matured.</p>
<div class="pmt__header-left">
<span class="pmt__header-ico"><fa-icon icon="sync-alt"></fa-icon></span>
<div class="pmt__header-text">
<h2 class="pmt__title">
{{ isDashboardRenewalMode ? 'Renew Fixed Deposit' : ('labels.heading.Payment' | translate) }}
</h2>
<p class="pmt__subtitle">
{{
isDashboardRenewalMode
? 'Renew ' + (dashboardRenewalRow?.accountNo ?? 'this matured FD') + '.'
: "Process this Fixed Deposit now -- works whether it's Active or Matured."
}}
</p>
</div>
</div>
<button type="button" class="pmt__header-close" mat-dialog-close [disabled]="submitting" title="Close">
<fa-icon icon="times"></fa-icon>
</button>
</div>
<div mat-dialog-content class="pmt__content">
@@ -27,37 +40,46 @@
</div>
} @else {
<form [formGroup]="paymentForm">
@if (isDashboardRenewalMode) {
<p class="pmt__hint">
<fa-icon icon="info-circle" class="m-r-5"></fa-icon>
{{ dashboardRenewalTimingText }}
</p>
}
@if (reinvestCap !== null && reinvestCap !== undefined) {
<div class="pmt__stat">
<span class="pmt__stat-label">
{{
remainderIsApproximate
? 'Amount available on the selected date'
: ('labels.inputs.Maturity Amount' | translate)
isDashboardRenewalMode || !remainderIsApproximate
? ('labels.inputs.Maturity Amount' | translate)
: 'Amount available on the selected date'
}}
</span>
<span class="pmt__stat-value">{{ reinvestCap | formatNumber }}</span>
</div>
}
<mat-form-field appearance="outline" class="pmt__date" (click)="closedOnDatePicker.open()">
<mat-label>{{ 'labels.inputs.Closed On Date' | translate }}</mat-label>
<input
matInput
[max]="maxDate"
[matDatepicker]="closedOnDatePicker"
required
formControlName="closedOnDate"
/>
<mat-datepicker-toggle matSuffix [for]="closedOnDatePicker"></mat-datepicker-toggle>
<mat-datepicker #closedOnDatePicker></mat-datepicker>
@if (paymentForm.controls.closedOnDate.hasError('required')) {
<mat-error>
{{ 'labels.inputs.Closed On Date' | translate }} {{ 'labels.commons.is' | translate }}
<strong>{{ 'labels.commons.required' | translate }}</strong>
</mat-error>
}
</mat-form-field>
@if (!isDashboardRenewalMode) {
<mat-form-field appearance="outline" class="pmt__date" (click)="closedOnDatePicker.open()">
<mat-label>{{ 'labels.inputs.Closed On Date' | translate }}</mat-label>
<input
matInput
[max]="maxDate"
[matDatepicker]="closedOnDatePicker"
required
formControlName="closedOnDate"
/>
<mat-datepicker-toggle matSuffix [for]="closedOnDatePicker"></mat-datepicker-toggle>
<mat-datepicker #closedOnDatePicker></mat-datepicker>
@if (paymentForm.controls.closedOnDate.hasError('required')) {
<mat-error>
{{ 'labels.inputs.Closed On Date' | translate }} {{ 'labels.commons.is' | translate }}
<strong>{{ 'labels.commons.required' | translate }}</strong>
</mat-error>
}
</mat-form-field>
}
<div class="pmt__section-label" id="pmt-action-label">{{ 'labels.inputs.Action' | translate }}</div>
<div class="pmt__options" role="radiogroup" aria-labelledby="pmt-action-label">
@@ -162,10 +184,12 @@
</mat-form-field>
}
<mat-form-field appearance="outline">
<mat-label>{{ 'labels.inputs.Note' | translate }}</mat-label>
<textarea matInput formControlName="note" cdkTextareaAutosize cdkAutosizeMinRows="2"></textarea>
</mat-form-field>
@if (!isDashboardRenewalMode) {
<mat-form-field appearance="outline">
<mat-label>{{ 'labels.inputs.Note' | translate }}</mat-label>
<textarea matInput formControlName="note" cdkTextareaAutosize cdkAutosizeMinRows="2"></textarea>
</mat-form-field>
}
@if (submitError) {
<p class="pmt__error" role="alert">
@@ -178,14 +202,25 @@
</div>
<div mat-dialog-actions align="end" class="pmt__actions">
<button mat-button mat-dialog-close [disabled]="submitting">{{ 'labels.buttons.Cancel' | translate }}</button>
<button mat-button mat-dialog-close [disabled]="submitting" class="pmt__btn-cancel">
{{ 'labels.buttons.Cancel' | translate }}
</button>
<button
mat-raised-button
color="primary"
class="pmt__btn-submit"
[disabled]="!paymentForm?.valid || submitting || loadFailed"
(click)="submit()"
>
{{ submitting ? ('labels.buttons.Processing' | translate) : ('labels.buttons.Confirm' | translate) }}
{{
submitting
? isDashboardRenewalMode
? 'Renewing...'
: ('labels.buttons.Processing' | translate)
: isDashboardRenewalMode
? 'Confirm & Renew'
: ('labels.buttons.Confirm' | translate)
}}
</button>
</div>
</div>
@@ -6,291 +6,404 @@
* 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;
/**
* Payment / Renewal Dialog Styles
* CBSNexora Core Banking System
*/
.pmt {
display: flex;
flex-direction: column;
min-height: 320px;
max-height: 85vh;
height: 100%;
max-height: 92vh;
box-sizing: border-box;
font-family:
'DM Sans',
system-ui,
-apple-system,
sans-serif;
color: #0f172a;
border-radius: 16px;
overflow: hidden;
margin: -24px;
box-shadow: 0 24px 48px -12px rgb(15 23 42 / 25%);
}
.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;
justify-content: space-between;
gap: 16px;
padding: 26px 30px;
background: linear-gradient(135deg, #1e3a8a 0%, #1d4ed8 55%, #2563eb 100%);
color: #fff;
flex-shrink: 0;
position: relative;
overflow: hidden;
&::after {
content: '';
position: absolute;
inset: 0;
background: radial-gradient(circle at 88% -20%, rgb(255 255 255 / 22%), transparent 55%);
pointer-events: none;
}
&-left {
display: flex;
align-items: center;
gap: 16px;
position: relative;
z-index: 1;
}
&-ico {
width: 48px;
height: 48px;
border-radius: 14px;
background: rgb(255 255 255 / 16%);
border: 1px solid rgb(255 255 255 / 25%);
display: flex;
align-items: center;
justify-content: center;
font-size: 20px;
color: #fff;
flex-shrink: 0;
}
&-text {
display: flex;
flex-direction: column;
}
&-close {
position: relative;
z-index: 1;
background: rgb(255 255 255 / 14%);
border: none;
color: #fff;
width: 34px;
height: 34px;
border-radius: 50%;
display: flex;
align-items: center;
justify-content: center;
cursor: pointer;
transition: all 0.2s ease;
flex-shrink: 0;
&:hover:not(:disabled) {
background: rgb(255 255 255 / 28%);
transform: rotate(90deg);
}
&:disabled {
opacity: 0.5;
cursor: not-allowed;
}
}
}
.pmt__title {
margin: 0;
font-size: 19px;
font-weight: 700;
font-size: 20px;
font-weight: 800;
color: #fff;
line-height: 1.25;
letter-spacing: -0.01em;
}
.pmt__subtitle {
margin: 4px 0 0;
font-size: 12.5px;
color: rgb(255 255 255 / 82%);
margin: 5px 0 0;
font-size: 13px;
color: rgb(255 255 255 / 88%);
line-height: 1.4;
}
.pmt__content {
padding: 22px 28px 8px !important;
padding: 26px 30px !important;
overflow-y: auto;
flex: 1 1 auto;
background: #fbfcfe;
mat-form-field {
width: 100%;
}
}
.pmt__loading {
display: flex;
justify-content: center;
align-items: center;
min-height: 260px;
min-height: 240px;
}
.pmt__stat {
display: flex;
flex-direction: column;
gap: 2px;
background: $blue-soft;
border: 1px solid #dbeafe;
border-radius: 10px;
padding: 12px 16px;
gap: 4px;
background: linear-gradient(135deg, #eff6ff 0%, #e0e9ff 100%);
border: 1px solid #bfdbfe;
border-radius: 14px;
padding: 18px 22px;
margin-bottom: 18px;
}
.pmt__stat-label {
font-size: 11px;
font-weight: 700;
text-transform: uppercase;
letter-spacing: 0.05em;
color: $blue;
}
&-label {
font-size: 11.5px;
font-weight: 700;
text-transform: uppercase;
letter-spacing: 0.07em;
color: #1d4ed8;
}
.pmt__stat-value {
font-size: 22px;
font-weight: 800;
color: $navy;
font-variant-numeric: tabular-nums;
}
.pmt__date {
width: 100%;
&-value {
font-family: 'JetBrains Mono', 'DM Sans', monospace;
font-size: 28px;
font-weight: 800;
color: #0f172a;
letter-spacing: -0.01em;
}
}
.pmt__section-label {
font-size: 11.5px;
font-size: 12px;
font-weight: 700;
text-transform: uppercase;
letter-spacing: 0.05em;
color: $slate;
margin: 4px 0 10px;
letter-spacing: 0.06em;
color: #475569;
margin: 10px 0 12px;
}
/* Action Options Grid - 2 Equal Columns, comfortably wide now that the dialog itself is wider */
.pmt__options {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(190px, 1fr));
gap: 10px;
margin-bottom: 6px;
grid-template-columns: repeat(2, 1fr);
gap: 14px;
margin-bottom: 18px;
@media (width <= 560px) {
grid-template-columns: 1fr;
}
}
.pmt__option {
display: flex;
align-items: flex-start;
gap: 10px;
gap: 14px;
text-align: left;
background: #fff;
border: 1.5px solid $border;
border-radius: 10px;
padding: 12px 14px;
border: 1.5px solid #e2e8f0;
border-radius: 14px;
padding: 16px;
cursor: pointer;
font: inherit;
transition:
border-color 0.15s,
background 0.15s,
box-shadow 0.15s;
}
transition: all 0.2s cubic-bezier(0.4, 0, 0.2, 1);
position: relative;
.pmt__option:hover {
border-color: #93c5fd;
background: $blue-soft;
}
&:hover {
border-color: #93c5fd;
background: #f8fafc;
box-shadow: 0 6px 16px rgb(15 23 42 / 6%);
transform: translateY(-1px);
}
.pmt__option--active {
border-color: $blue;
background: $blue-soft;
box-shadow: 0 0 0 1px $blue;
}
&--active {
border-color: #2563eb;
background: #eff6ff;
box-shadow: 0 0 0 1.5px #2563eb;
.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-ico {
background: #2563eb;
color: #fff;
border-color: #2563eb;
}
}
.pmt__option--active .pmt__option-ico {
background: $blue;
color: #fff;
}
&-ico {
width: 38px;
height: 38px;
min-width: 38px;
border-radius: 10px;
background: #f1f5f9;
border: 1px solid #e2e8f0;
color: #2563eb;
display: flex;
align-items: center;
justify-content: center;
font-size: 15px;
flex-shrink: 0;
transition: all 0.2s ease;
}
.pmt__option-text {
display: flex;
flex-direction: column;
gap: 2px;
}
// Distinct accent per option while unselected, purely presentational -- helps the four cards read
// apart at a glance. Selecting a card always fills its icon blue (see &--active above), matching the
// blue selection border/check regardless of which option it is, rather than mixing selection-blue
// with the option's own accent.
&:not(.pmt__option--active):nth-child(2) .pmt__option-ico {
color: #7c3aed;
}
.pmt__option-title {
font-size: 13px;
font-weight: 700;
color: $navy;
}
&:not(.pmt__option--active):nth-child(3) .pmt__option-ico {
color: #0d9488;
}
.pmt__option-desc {
font-size: 11px;
color: $muted;
line-height: 1.35;
}
&:not(.pmt__option--active):nth-child(4) .pmt__option-ico {
color: #d97706;
}
.pmt__option:focus-visible {
outline: 2px solid $blue;
outline-offset: 2px;
}
&-text {
display: flex;
flex-direction: column;
gap: 4px;
flex: 1;
}
.pmt__option-check {
margin-left: auto;
color: $blue;
font-size: 13px;
&-title {
font-size: 14px;
font-weight: 700;
color: #0f172a;
line-height: 1.3;
}
&-desc {
font-size: 12px;
color: #64748b;
line-height: 1.4;
}
&-check {
color: #2563eb;
font-size: 17px;
position: absolute;
top: 14px;
right: 14px;
}
}
.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;
font-weight: 600;
margin: -10px 0 14px;
}
.pmt__split {
display: flex;
align-items: center;
gap: 12px;
background: $soft;
gap: 16px;
background: #f8fafc;
border: 1px dashed #cbd5e1;
border-radius: 10px;
padding: 14px 16px 0;
margin: 4px 0 8px;
border-radius: 14px;
padding: 18px;
margin: 10px 0 18px;
flex-wrap: wrap;
&-input {
flex: 1 1 220px;
}
&-arrow {
color: #94a3b8;
font-size: 17px;
}
&-remainder {
display: flex;
flex-direction: column;
gap: 3px;
background: #f0fdf4;
border: 1px solid #bbf7d0;
border-radius: 10px;
padding: 12px 16px;
flex: 1 1 220px;
&-label {
font-size: 11px;
font-weight: 700;
text-transform: uppercase;
letter-spacing: 0.04em;
color: #166534;
}
&-value {
font-family: 'JetBrains Mono', 'DM Sans', monospace;
font-size: 19px;
font-weight: 800;
color: #166534;
}
}
}
.pmt__split-input {
flex: 1 1 180px;
min-width: 160px;
}
.pmt__split-arrow {
color: $muted;
font-size: 14px;
margin-bottom: 20px;
}
.pmt__split-remainder {
.pmt__hint {
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;
align-items: flex-start;
gap: 10px;
background: #f0f9ff;
border: 1px solid #bae6fd;
border-left: 4px solid #0284c7;
border-radius: 10px;
color: #0369a1;
font-size: 13px;
line-height: 1.5;
padding: 12px 16px;
margin: 0 0 18px;
&--warn {
background: #fffbeb;
border-color: #fde68a;
border-left-color: #d97706;
color: #b45309;
}
}
.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__error {
display: flex;
align-items: flex-start;
gap: 10px;
background: #fef2f2;
border: 1px solid #fecaca;
border-left: 4px solid #ef4444;
border-radius: 10px;
color: #b91c1c;
font-size: 13px;
padding: 12px 16px;
margin: 10px 0;
}
.pmt__actions {
padding: 14px 28px 20px !important;
border-top: 1px solid $border;
flex: 0 0 auto;
padding: 18px 30px !important;
border-top: 1px solid #e2e8f0;
background: #fff;
display: flex;
align-items: center;
justify-content: flex-end;
gap: 14px;
flex-shrink: 0;
.pmt__btn-cancel {
border-radius: 10px;
font-weight: 600;
color: #475569;
}
.pmt__btn-submit {
border-radius: 10px;
font-weight: 700;
padding: 0 26px;
height: 42px;
background: linear-gradient(135deg, #1d4ed8 0%, #2563eb 100%);
color: #fff;
box-shadow: 0 8px 20px rgb(37 99 235 / 30%);
transition: all 0.2s ease;
&:hover:not(:disabled) {
box-shadow: 0 10px 24px rgb(37 99 235 / 40%);
transform: translateY(-1px);
}
&:disabled {
background: #cbd5e1;
box-shadow: none;
color: #64748b;
}
}
}
@@ -27,12 +27,22 @@ 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';
import { FdMaturityRow } from 'app/fd-dashboard/fd-dashboard.compute';
/**
* "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.
*
* Doubles as the FD Dashboard's Renew action when opened with `data.dashboardRenewal` set: same form,
* same full set of closure types (nothing removed -- Transfer to Savings and Partial Reinvest work from
* the Dashboard too, not just the two Reinvest options). The only two differences in that mode: the
* Closed On Date is computed from the FD's grace window instead of picked freely (backdated to the
* original maturity date within grace, today once it's passed -- the picker is hidden, not just
* defaulted, so it can't accidentally be moved off that date), and submit() first calls
* `creditGapInterestIfPastGrace` when past grace, crediting the gap-period interest (at the linked
* account's rate) onto the FD before the normal `payment` command runs and reads its balance.
*/
@Component({
selector: 'mifosx-payment-dialog',
@@ -76,7 +86,13 @@ export class PaymentDialogComponent implements OnInit {
};
dialogRef = inject<MatDialogRef<PaymentDialogComponent>>(MatDialogRef);
data: { accountId: number | string; accountBalance?: number; maturityDate?: any } = inject(MAT_DIALOG_DATA);
data: {
accountId: number | string;
accountBalance?: number;
maturityDate?: any;
/** Present only when opened from the FD Dashboard's Renew action. */
dashboardRenewal?: { row: FdMaturityRow };
} = inject(MAT_DIALOG_DATA);
private formBuilder = inject(UntypedFormBuilder);
private fixedDepositsService = inject(FixedDepositsService);
private settingsService = inject(SettingsService);
@@ -93,6 +109,10 @@ export class PaymentDialogComponent implements OnInit {
submitError: string = null;
maxDate: Date;
/** Whether gap-interest still needs crediting before this submits -- only ever true once, right
* after open; cleared as soon as `submit()` has credited it, so a retried submit doesn't double-post. */
private gapInterestPending = false;
ngOnInit(): void {
this.maxDate = this.settingsService.businessDate;
this.paymentForm = this.formBuilder.group({
@@ -106,6 +126,18 @@ export class PaymentDialogComponent implements OnInit {
],
note: ['']
});
const dashboardRow = this.dashboardRenewalRow;
if (dashboardRow) {
// Computed from the grace window, never picked -- the date field stays in the form (the generic
// submit path and reinvestCap/processingOnOrAfterMaturity both key off it) but its picker is
// hidden in this mode (see the template), so it can't be moved off this value.
this.paymentForm
.get('closedOnDate')
.setValue(dashboardRow.withinGraceWindow ? new Date(dashboardRow.maturityDate) : this.maxDate);
this.gapInterestPending = !dashboardRow.withinGraceWindow;
}
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.
@@ -136,6 +168,25 @@ export class PaymentDialogComponent implements OnInit {
});
}
get isDashboardRenewalMode(): boolean {
return !!this.data.dashboardRenewal;
}
get dashboardRenewalRow(): FdMaturityRow | null {
return this.data.dashboardRenewal?.row ?? null;
}
/** What clicking Confirm is actually about to do -- shown above the (hidden-picker) date in this
* mode, since the date itself is no longer visible to explain it. */
get dashboardRenewalTimingText(): string {
const row = this.dashboardRenewalRow;
if (!row) return '';
return row.withinGraceWindow
? `Backdated to its original maturity date (${row.maturityDate}), at the currently active interest rate.`
: `This FD's grace period has passed -- gap-period interest (for the days since maturity) will be ` +
`credited at the linked account's rate first, then this renews from today.`;
}
/**
* The amount being split, and the bound the entered partial amount is checked against.
* <p>
@@ -259,6 +310,31 @@ export class PaymentDialogComponent implements OnInit {
}
submit(): void {
if (this.gapInterestPending) {
this.submitting = true;
this.submitError = null;
this.fixedDepositsService.creditGapInterestIfPastGrace(this.data.accountId).subscribe({
next: () => {
this.gapInterestPending = false;
this.submitGenericPayment();
},
error: (error: any) => {
this.submitting = false;
this.submitError =
error?.error?.defaultUserMessage ??
error?.error?.errors?.[0]?.defaultUserMessage ??
'Could not credit gap-period interest. Please review the details and try again.';
}
});
return;
}
this.submitGenericPayment();
}
/** The same `command=payment` submission every non-Dashboard call to this dialog already used --
* Dashboard-renewal mode reaches it too, once any gap-interest pre-step above has cleared, so every
* closure type behaves identically regardless of where the dialog was opened from. */
private submitGenericPayment(): void {
const formValue = { ...this.paymentForm.value };
const locale = this.settingsService.language.code;
const dateFormat = this.settingsService.dateFormat;
@@ -276,7 +276,7 @@ export class FixedDepositAccountViewComponent implements OnInit {
*/
private openPaymentDialog(): void {
const paymentDialogRef = this.dialog.open(PaymentDialogComponent, {
width: '640px',
width: '720px',
maxWidth: '95vw',
data: {
accountId: this.fixedDepositsAccountData.id,
@@ -57,11 +57,12 @@ export class FixedDepositsButtonsConfiguration {
];
break;
case 'Matured':
// 'Close' (the generic Fineract apply-maturity-instructions action) is deliberately left out
// here -- what happens to a matured FD is now handled either automatically by the nightly job
// (an account opened with an explicit instruction) or manually via the FD Dashboard's Renew
// action (an account left without one, backdated within grace / gap-interest after). Closing
// from here would go around both of those paths.
this.buttonsArray = [
{
name: 'Close',
icon: 'fa fa-arrow-right'
},
{
name: 'Calculate Interest',
icon: 'fa fa-table'
@@ -133,7 +133,8 @@ export class FixedDepositsService {
calculateFixedDepositInterest(params: {
principalAmount: number;
annualInterestRate: number;
tenureInMonths: number;
tenureInMonths?: number;
tenureInDays?: number; // alternative to tenureInMonths, for sub-month FD Single tenures
interestCompoundingPeriodInMonths: number;
interestPostingPeriodInMonths: number;
submittedOnDate?: string; // yyyy-MM-dd — enables exact calendar calculation
@@ -153,6 +154,26 @@ export class FixedDepositsService {
return this.http.get(`/fixeddepositaccounts/calculate-fd-interest`, { params: httpParams });
}
/**
* FD Dashboard's "Renew" action on a matured FD whose product has a renewal grace period
* configured: backdated to the original maturity date within the grace window, or from today
* with a gap-period interest credit (at the linked Savings account's rate) once it has passed.
* @param accountId Fixed Deposit Account Id
* @returns {Observable<any>} { renewedAccountId, maturityDate, maturityAmount, backdated, gapInterestAmount? }
*/
/** `reinvestType` is the staff's choice made at renewal time: 300 = Reinvest Principal + Interest,
* 400 = Reinvest Principal Only (rest of the balance goes to the FD's linked Savings account). */
renewFixedDepositAccount(accountId: string | number, reinvestType: 300 | 400): Observable<any> {
return this.http.post(`/fixeddepositaccounts/${accountId}/renew`, { reinvestType });
}
/** Pre-step the Payment dialog calls, from the FD Dashboard, before submitting the generic `payment`
* command -- credits gap-period interest (at the linked account's rate) if the product's renewal
* grace window has passed; a no-op (`gapInterestAmount: 0`) if still within it. */
creditGapInterestIfPastGrace(accountId: string | number): Observable<any> {
return this.http.post(`/fixeddepositaccounts/${accountId}/credit-gap-interest`, {});
}
/**
* @param fixedDepositAccount Fixed Deposit Account
* @returns {Observable<any>}
@@ -0,0 +1,29 @@
/**
* Copyright since 2025 Mifos Initiative
*
* This Source Code Form is subject to the terms of the Mozilla Public
* License, v. 2.0. If a copy of the MPL was not distributed with this
* file, You can obtain one at http://mozilla.org/MPL/2.0/.
*/
import { NgModule } from '@angular/core';
import { Routes, RouterModule } from '@angular/router';
import { Route } from '../core/route/route.service';
import { FdDashboardComponent } from './fd-dashboard.component';
const routes: Routes = [
Route.withShell([
{
path: 'fd-dashboard',
component: FdDashboardComponent,
data: { title: 'FD Dashboard', breadcrumb: 'FD Dashboard' }
}
])
];
@NgModule({
imports: [RouterModule.forChild(routes)],
exports: [RouterModule]
})
export class FdDashboardRoutingModule {}
@@ -0,0 +1,240 @@
<!--
Copyright since 2025 Mifos Initiative
This Source Code Form is subject to the terms of the Mozilla Public
License, v. 2.0. If a copy of the MPL was not distributed with this
file, You can obtain one at http://mozilla.org/MPL/2.0/.
-->
<div class="fdd-container">
<!-- Compact Light Header -->
<div class="fdd-header">
<div class="fdd-header__left">
<div class="fdd-header__icon">
<fa-icon icon="piggy-bank"></fa-icon>
</div>
<div>
<div class="fdd-header__title-row">
<h2 class="fdd-header__title">FD Dashboard & Maturity Ladder</h2>
<span class="fdd-header__badge">Core Banking</span>
</div>
<p class="fdd-header__sub">
Active Fixed Deposits positioned against maturity dates. Press <kbd>ESC</kbd> to toggle header & footer.
</p>
</div>
</div>
<div class="fdd-header__right">
<button class="fdd-btn fdd-btn--slim" (click)="load()" [disabled]="loading" title="Refresh maturity ladder data">
<fa-icon icon="sync-alt" [class.fa-spin]="loading"></fa-icon>
<span>{{ loading ? 'Syncing...' : 'Reload' }}</span>
</button>
</div>
</div>
<!-- Alerts -->
@if (errorMessage) {
<div class="fdd-alert fdd-alert--danger">
<fa-icon icon="exclamation-triangle"></fa-icon>
<span>{{ errorMessage }}</span>
</div>
}
<!-- Compact Stat Cards Grid -->
<div class="fdd-stats-grid">
@for (key of stageKeys; track key) {
<button
[class]="'fdd-stat-card fdd-stat-card--' + stageMeta[key].tone"
[class.fdd-stat-card--active]="stageFilter === key"
(click)="toggleStage(key)"
>
<div class="fdd-stat-card__header">
<span class="fdd-stat-card__label">{{ stageMeta[key].label }}</span>
<div class="fdd-stat-card__icon">
@switch (key) {
@case ('MATURED') {
<fa-icon icon="exclamation-circle"></fa-icon>
}
@case ('DUE_SOON') {
<fa-icon icon="clock"></fa-icon>
}
@case ('UPCOMING') {
<fa-icon icon="calendar-alt"></fa-icon>
}
@default {
<fa-icon icon="check-circle"></fa-icon>
}
}
</div>
</div>
<div class="fdd-stat-card__val">
@switch (key) {
@case ('MATURED') {
{{ summary.matured }}
}
@case ('DUE_SOON') {
{{ summary.dueSoon }}
}
@case ('UPCOMING') {
{{ summary.upcoming }}
}
@default {
{{ summary.onTrack }}
}
}
</div>
<div class="fdd-stat-card__sub">{{ stageMeta[key].summary }}</div>
</button>
}
</div>
<!-- Compact Filter & Search Toolbar -->
<div class="fdd-toolbar">
<div class="fdd-search-box">
<fa-icon icon="search" class="fdd-search-box__icon"></fa-icon>
<input
class="fdd-search-box__input"
type="text"
placeholder="Search account no. or client name..."
[(ngModel)]="search"
/>
@if (search) {
<button class="fdd-search-box__clear" (click)="search = ''" title="Clear search">
<fa-icon icon="times"></fa-icon>
</button>
}
</div>
@if (stageFilter || search) {
<button class="fdd-btn fdd-btn--reset fdd-btn--slim" (click)="resetFilters()">
<fa-icon icon="undo"></fa-icon> Reset
</button>
}
<div class="fdd-toolbar__spacer"></div>
<div class="fdd-count-chip">
<fa-icon icon="layer-group"></fa-icon>
<span
><strong>{{ visibleRows.length }}</strong> of {{ summary.total }} active FDs</span
>
</div>
</div>
<!-- Worklist Table / Empty State -->
@if (loading) {
<div class="fdd-card fdd-state-card">
<fa-icon icon="spinner" [class.fa-spin]="true" class="fdd-spinner"></fa-icon>
<p>Loading FD maturity list...</p>
</div>
} @else if (visibleRows.length === 0) {
<div class="fdd-card fdd-state-card">
<fa-icon icon="inbox" class="fdd-state-icon"></fa-icon>
<p class="fdd-state-msg">
@if (!stageFilter && summary.total > 0) {
No FDs are matured or maturing within {{ policy.upcomingDays }} days. Click "On Track" to see all active FDs.
} @else {
No FDs match these filters.
}
</p>
@if (stageFilter || search) {
<button class="fdd-btn fdd-btn--primary fdd-btn--slim" (click)="resetFilters()">
<fa-icon icon="undo"></fa-icon> Clear Filters
</button>
}
</div>
} @else {
<div class="fdd-table-card">
<div class="fdd-table-wrapper">
<table class="fdd-table">
<thead>
<tr>
<th>Account</th>
<th>Client</th>
<th>Product</th>
<th>Maturity Date</th>
<th class="text-right">Deposit Amount</th>
<th class="text-right">Maturity Amount</th>
<th>Status</th>
<th class="text-center">Action</th>
</tr>
</thead>
<tbody>
@for (r of visibleRows; track r.id) {
<tr class="fdd-row" (click)="openAccount(r)">
<td>
<span class="fdd-acc-num">{{ r.accountNo || '—' }}</span>
</td>
<td>
<div class="fdd-client-cell">
<span class="fdd-client-name">{{ r.clientName }}</span>
</div>
</td>
<td>
<span class="fdd-product-tag">{{ r.productName }}</span>
</td>
<td>
<div class="fdd-date-cell">
<span class="fdd-date-val">{{ r.maturityDate }}</span>
<span class="fdd-date-sub" [class.fdd-date-sub--overdue]="r.daysOverdue > 0">
{{ maturityText(r) }}
</span>
</div>
</td>
<td class="text-right">
<span class="fdd-amount fdd-amount--deposit">
{{ r.depositAmount != null ? '₹' + (r.depositAmount | number: '1.2-2') : '—' }}
</span>
</td>
<td class="text-right">
<span class="fdd-amount fdd-amount--maturity">
{{ r.maturityAmount != null ? '₹' + (r.maturityAmount | number: '1.2-2') : '—' }}
</span>
</td>
<td>
<div class="fdd-status-cell">
<span [class]="stageClass(r.stage)">
{{ r.meta.label }}
</span>
@if (r.renewable) {
<div
class="fdd-grace-sub"
[class.fdd-grace-sub--warning]="r.renewalGracePeriodDays > 0 && !r.withinGraceWindow"
>
{{ graceStatusText(r) }}
</div>
} @else if (r.configuredInstructionLabel) {
<div class="fdd-grace-sub">Configured: {{ r.configuredInstructionLabel }} (auto-processes)</div>
}
</div>
</td>
<td class="fdd-act-cell text-center" (click)="$event.stopPropagation()">
@if (r.renewable) {
<button class="fdd-btn fdd-btn--action" [disabled]="renewing === r.id" (click)="openRenewDialog(r)">
@if (renewing === r.id) {
<fa-icon icon="spinner" [class.fa-spin]="true"></fa-icon> Renewing...
} @else {
<fa-icon icon="sync-alt"></fa-icon> Renew
}
</button>
} @else {
<button class="fdd-btn-icon" (click)="openAccount(r)" title="View Account Details">
<fa-icon icon="chevron-right"></fa-icon>
</button>
}
</td>
</tr>
}
</tbody>
</table>
</div>
</div>
}
</div>
@@ -0,0 +1,578 @@
/**
* Copyright since 2025 Mifos Initiative
*
* This Source Code Form is subject to the terms of the Mozilla Public
* License, v. 2.0. If a copy of the MPL was not distributed with this
* file, You can obtain one at http://mozilla.org/MPL/2.0/.
*/
/**
* Ultra-Compact Light FD Dashboard & Maturity Ladder Styles
* CBSNexora Core Banking Design System
*/
:host {
display: block;
font-family:
'DM Sans',
system-ui,
-apple-system,
sans-serif;
color: #0f172a;
background: #f8fafc;
min-height: 100vh;
padding: 12px 16px;
box-sizing: border-box;
}
.fdd-container {
max-width: 1400px;
margin: 0 auto;
display: flex;
flex-direction: column;
gap: 12px;
}
/* ── Light Compact Header ────────────────────────────────────────────────── */
.fdd-header {
background: #fff;
border: 1px solid #e2e8f0;
border-radius: 12px;
padding: 10px 16px;
display: flex;
align-items: center;
justify-content: space-between;
gap: 16px;
box-shadow: 0 1px 3px rgb(0 0 0 / 4%);
&__left {
display: flex;
align-items: center;
gap: 12px;
}
&__icon {
width: 36px;
height: 36px;
border-radius: 8px;
background: #eff6ff;
color: #2563eb;
display: flex;
align-items: center;
justify-content: center;
font-size: 18px;
flex-shrink: 0;
}
&__title-row {
display: flex;
align-items: center;
gap: 8px;
}
&__title {
margin: 0;
font-size: 16px;
font-weight: 700;
color: #0f172a;
}
&__badge {
font-size: 10px;
font-weight: 700;
text-transform: uppercase;
letter-spacing: 0.05em;
padding: 2px 6px;
border-radius: 4px;
background: #e0f2fe;
color: #0369a1;
}
&__sub {
margin: 2px 0 0;
font-size: 11.5px;
color: #64748b;
kbd {
background: #f1f5f9;
border: 1px solid #cbd5e1;
border-radius: 3px;
padding: 1px 4px;
font-family: inherit;
font-size: 10.5px;
font-weight: 700;
color: #334155;
}
}
}
/* ── Alerts ─────────────────────────────────────────────────────────────── */
.fdd-alert {
display: flex;
align-items: center;
gap: 8px;
padding: 8px 12px;
border-radius: 8px;
font-size: 12px;
font-weight: 600;
&--danger {
background: #fef2f2;
border: 1px solid #fca5a5;
color: #dc2626;
}
&--warning {
background: #fffbeb;
border: 1px solid #fcd34d;
color: #d97706;
}
}
/* ── Compact Stat Cards Grid ────────────────────────────────────────────── */
.fdd-stats-grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(200px, 1fr));
gap: 10px;
}
.fdd-stat-card {
background: #fff;
border: 1px solid #e2e8f0;
border-left: 4px solid #cbd5e1;
border-radius: 10px;
padding: 10px 14px;
text-align: left;
cursor: pointer;
transition: all 0.15s ease;
&:hover {
border-color: #cbd5e1;
box-shadow: 0 4px 12px rgb(15 23 42 / 6%);
}
&__header {
display: flex;
align-items: center;
justify-content: space-between;
margin-bottom: 4px;
}
&__label {
font-size: 12px;
font-weight: 700;
color: #334155;
}
&__icon {
font-size: 14px;
}
&__val {
font-size: 20px;
font-weight: 800;
line-height: 1.1;
color: #0f172a;
margin-bottom: 2px;
}
&__sub {
font-size: 10.5px;
color: #64748b;
line-height: 1.3;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
/* Tone styling */
&--danger {
border-left-color: #ef4444;
.fdd-stat-card__icon {
color: #ef4444;
}
&.fdd-stat-card--active {
background: #fef2f2;
border-color: #fca5a5;
}
}
&--warn {
border-left-color: #f59e0b;
.fdd-stat-card__icon {
color: #f59e0b;
}
&.fdd-stat-card--active {
background: #fffbeb;
border-color: #fcd34d;
}
}
&--info {
border-left-color: #3b82f6;
.fdd-stat-card__icon {
color: #3b82f6;
}
&.fdd-stat-card--active {
background: #eff6ff;
border-color: #93c5fd;
}
}
&--neutral {
border-left-color: #64748b;
.fdd-stat-card__icon {
color: #64748b;
}
&.fdd-stat-card--active {
background: #f1f5f9;
border-color: #cbd5e1;
}
}
}
/* ── Compact Filter Toolbar ─────────────────────────────────────────────── */
.fdd-toolbar {
background: #fff;
border: 1px solid #e2e8f0;
border-radius: 10px;
padding: 6px 10px;
display: flex;
align-items: center;
gap: 10px;
box-shadow: 0 1px 2px rgb(0 0 0 / 2%);
&__spacer {
flex: 1;
}
}
.fdd-search-box {
position: relative;
display: flex;
align-items: center;
width: 300px;
&__icon {
position: absolute;
left: 10px;
color: #94a3b8;
font-size: 12px;
pointer-events: none;
}
&__input {
width: 100%;
padding: 5px 30px 5px 28px;
border: 1px solid #cbd5e1;
border-radius: 6px;
font-size: 12px;
font-family: inherit;
color: #0f172a;
background: #fff;
&:focus {
outline: none;
border-color: #2563eb;
box-shadow: 0 0 0 2px rgb(37 99 235 / 15%);
}
}
&__clear {
position: absolute;
right: 6px;
border: none;
background: transparent;
color: #94a3b8;
cursor: pointer;
padding: 2px 4px;
&:hover {
color: #334155;
}
}
}
.fdd-count-chip {
display: inline-flex;
align-items: center;
gap: 6px;
font-size: 11.5px;
color: #64748b;
}
/* ── Buttons ────────────────────────────────────────────────────────────── */
.fdd-btn {
display: inline-flex;
align-items: center;
justify-content: center;
gap: 6px;
padding: 6px 12px;
border-radius: 6px;
font-family: inherit;
font-size: 12px;
font-weight: 600;
cursor: pointer;
border: 1px solid #cbd5e1;
background: #fff;
color: #334155;
transition: all 0.15s ease;
&:hover:not(:disabled) {
border-color: #2563eb;
color: #2563eb;
background: #f8fafc;
}
&--slim {
padding: 4px 10px;
font-size: 11.5px;
}
&--primary {
background: #2563eb;
color: #fff;
border-color: #2563eb;
&:hover:not(:disabled) {
background: #1d4ed8;
color: #fff;
}
}
&--action {
background: #2563eb;
color: #fff;
border-color: #2563eb;
padding: 4px 10px;
font-size: 11px;
border-radius: 5px;
&:hover:not(:disabled) {
background: #1d4ed8;
color: #fff;
}
}
&:disabled {
opacity: 0.55;
cursor: not-allowed;
}
}
.fdd-btn-icon {
width: 26px;
height: 26px;
border-radius: 5px;
border: 1px solid #e2e8f0;
background: #fff;
color: #64748b;
display: inline-flex;
align-items: center;
justify-content: center;
cursor: pointer;
transition: all 0.15s ease;
&:hover {
background: #eff6ff;
color: #2563eb;
border-color: #93c5fd;
}
}
/* ── Compact Table ──────────────────────────────────────────────────────── */
.fdd-table-card {
background: #fff;
border: 1px solid #e2e8f0;
border-radius: 10px;
overflow: hidden;
box-shadow: 0 1px 3px rgb(0 0 0 / 3%);
}
.fdd-table-wrapper {
overflow-x: auto;
}
.fdd-table {
width: 100%;
border-collapse: collapse;
text-align: left;
font-size: 12px;
th {
padding: 8px 10px;
background: #f1f5f9;
color: #475569;
font-size: 10.5px;
font-weight: 700;
text-transform: uppercase;
letter-spacing: 0.04em;
border-bottom: 1px solid #e2e8f0;
white-space: nowrap;
}
td {
padding: 7px 10px;
border-bottom: 1px solid #f1f5f9;
vertical-align: middle;
}
.text-right {
text-align: right;
}
.text-center {
text-align: center;
}
}
.fdd-row {
cursor: pointer;
&:hover {
background: #f8fafc;
}
&:last-child td {
border-bottom: none;
}
}
.fdd-acc-num {
font-family: 'JetBrains Mono', monospace;
font-size: 11.5px;
font-weight: 600;
color: #0f172a;
}
.fdd-client-name {
font-weight: 600;
color: #0f172a;
}
.fdd-product-tag {
font-size: 11px;
color: #475569;
}
.fdd-date-cell {
display: flex;
flex-direction: column;
gap: 1px;
.fdd-date-val {
font-family: 'JetBrains Mono', monospace;
font-size: 11.5px;
color: #0f172a;
}
.fdd-date-sub {
font-size: 10.5px;
color: #64748b;
&--overdue {
color: #dc2626;
font-weight: 700;
}
}
}
.fdd-amount {
font-family: 'JetBrains Mono', monospace;
font-size: 12px;
font-weight: 600;
&--deposit {
color: #334155;
}
&--maturity {
color: #2563eb;
font-weight: 700;
}
}
.fdd-status-cell {
display: flex;
flex-direction: column;
gap: 2px;
}
.fdd-stage {
display: inline-block;
padding: 2px 8px;
border-radius: 12px;
font-size: 10.5px;
font-weight: 700;
width: fit-content;
&--info {
background: #eff6ff;
color: #2563eb;
}
&--warn {
background: #fffbeb;
color: #d97706;
}
&--danger {
background: #fef2f2;
color: #dc2626;
}
&--neutral {
background: #f1f5f9;
color: #475569;
}
}
.fdd-grace-sub {
font-size: 10px;
color: #64748b;
&--warning {
color: #dc2626;
font-weight: 600;
}
}
.fdd-card.fdd-state-card {
background: #fff;
border: 1px solid #e2e8f0;
border-radius: 10px;
padding: 24px;
text-align: center;
font-size: 12px;
color: #64748b;
.fdd-state-icon {
font-size: 24px;
color: #94a3b8;
margin-bottom: 8px;
}
.fdd-spinner {
font-size: 20px;
color: #2563eb;
margin-bottom: 8px;
}
.fdd-state-msg {
margin: 0 0 12px;
}
}
@@ -0,0 +1,273 @@
/**
* Copyright since 2025 Mifos Initiative
*
* This Source Code Form is subject to the terms of the Mozilla Public
* License, v. 2.0. If a copy of the MPL was not distributed with this
* file, You can obtain one at http://mozilla.org/MPL/2.0/.
*/
/** Angular Imports */
import { Component, OnInit, inject } from '@angular/core';
import { FormsModule } from '@angular/forms';
import { Router } from '@angular/router';
import { MatDialog } from '@angular/material/dialog';
/** Custom Modules */
import { STANDALONE_SHARED_IMPORTS } from 'app/standalone-shared.module';
import { FaIconComponent } from '@fortawesome/angular-fontawesome';
/** Custom Services */
import { AccountsService } from '../accounts/accounts.service';
import { FixedDepositsService } from '../deposits/fixed-deposits/fixed-deposits.service';
import { toIsoDate } from 'app/core/utils/api-date';
/** Dashboard data + computation */
import {
FD_MATURITY_POLICY,
FD_MATURITY_STAGES,
FdMaturityRow,
FdMaturityStage,
FdMaturitySummary,
buildMaturityRow,
maturitySummary,
sortMaturityRows
} from './fd-dashboard.compute';
import { PaymentDialogComponent } from '../deposits/fixed-deposits/fixed-deposit-account-view/custom-dialogs/payment-dialog/payment-dialog.component';
/**
* FD Dashboard: every active Fixed Deposit positioned against its own maturity date.
*
* Read-only and computed, the same way the re-KYC expiry worklist is: the ladder (Matured / Due
* Soon / Upcoming / On Track) is worked out here from `maturityDate`, not stored anywhere, so this
* screen can never drift out of step with the account -- it always says what today's date implies.
* Data comes from the same `/fixeddepositaccounts` list Accounts > Fixed Deposit already uses;
* nothing new was added on the backend for this.
*/
@Component({
selector: 'mifosx-fd-dashboard',
templateUrl: './fd-dashboard.component.html',
styleUrls: ['./fd-dashboard.component.scss'],
imports: [
...STANDALONE_SHARED_IMPORTS,
FormsModule,
FaIconComponent
]
})
export class FdDashboardComponent implements OnInit {
private accountsService = inject(AccountsService);
private fixedDepositsService = inject(FixedDepositsService);
private router = inject(Router);
private dialog = inject(MatDialog);
loading = false;
errorMessage = '';
rows: FdMaturityRow[] = [];
/** Renew action state -- the dialog itself owns submit-in-progress/error display; this just guards
* against opening a second dialog for the same row while one is already up. */
renewing: number | null = null;
/** Filters */
stageFilter: FdMaturityStage | '' = '';
search = '';
readonly policy = FD_MATURITY_POLICY;
readonly stageMeta = FD_MATURITY_STAGES;
/** ON_TRACK is reachable only by clicking its own tile -- the default view stays on what needs
* attention (matured or maturing soon), matching why this screen exists. */
readonly stageKeys: FdMaturityStage[] = [
'MATURED',
'DUE_SOON',
'UPCOMING',
'ON_TRACK'
];
ngOnInit(): void {
this.load();
}
/**
* Loads every active FD and re-derives the maturity ladder for each.
*
* Calls the dedicated maturity-dashboard endpoint (backend: FixedDepositMaturityDashboardApiResource),
* not the general Accounts > Fixed Deposit list endpoint -- that one's query is wide (six extra
* table joins for audit/currency/tax fields this screen never shows) and, until this feature
* added them, ran over unindexed columns. The dashboard endpoint already filters to ACTIVE FDs
* (deposit_type_enum/status_enum are fixed in its SQL, not a request parameter), so there's
* nothing to re-check client-side here.
*/
load(): void {
this.loading = true;
this.errorMessage = '';
const today = toIsoDate(new Date().toISOString());
this.accountsService.getFixedDepositMaturityDashboard().subscribe({
next: (res: any) => {
const items: any[] = Array.isArray(res) ? res : [];
const mapped = items
.map((a: any) => this.mapAccount(a))
.map((a) => buildMaturityRow(a, today))
.filter((r): r is FdMaturityRow => !!r);
this.rows = sortMaturityRows(mapped);
this.loading = false;
},
error: (err: any) => {
this.rows = [];
this.loading = false;
this.errorMessage =
err?.error?.defaultUserMessage || err?.error?.developerMessage || 'Could not load the FD maturity list.';
}
});
}
/** Maps one raw `/fixeddepositaccounts/maturity-dashboard` list item onto what the ladder needs. */
private mapAccount(a: any): {
id: number;
accountNo: string;
clientId?: number;
clientName?: string;
productName?: string;
savingsProductName?: string;
fdType?: string;
maturityDate: string;
depositAmount?: number;
maturityAmount?: number;
onClosureType?: number;
linkedAccountId?: number;
renewalGracePeriodDays?: number;
} {
return {
id: a?.id,
accountNo: a?.accountNo || '',
clientId: a?.clientId,
clientName: a?.clientName,
productName: a?.productName,
savingsProductName: a?.savingsProductName,
fdType: a?.fdType,
maturityDate: toIsoDate(a?.maturityDate),
depositAmount: a?.depositAmount,
maturityAmount: a?.maturityAmount,
onClosureType: a?.onClosureType,
linkedAccountId: a?.linkedAccountId,
renewalGracePeriodDays: a?.renewalGracePeriodDays
};
}
// ── Filtering ──────────────────────────────────────────────────────────────
get visibleRows(): FdMaturityRow[] {
const needle = this.search.trim().toLowerCase();
const stage = this.stageFilter;
return this.rows.filter((r) => {
if (stage) {
if (r.stage !== stage) {
return false;
}
} else if (r.stage === 'ON_TRACK') {
// No tile selected -- the default view is the worklist, not every active FD.
return false;
}
if (needle) {
const hay = `${r.accountNo} ${r.clientName}`.toLowerCase();
if (!hay.includes(needle)) {
return false;
}
}
return true;
});
}
get summary(): FdMaturitySummary {
return maturitySummary(this.rows);
}
resetFilters(): void {
this.stageFilter = '';
this.search = '';
}
toggleStage(stage: FdMaturityStage): void {
this.stageFilter = this.stageFilter === stage ? '' : stage;
}
// ── Navigation ─────────────────────────────────────────────────────────────
openAccount(row: FdMaturityRow): void {
if (!row.clientId) {
return;
}
this.router.navigate([
'/clients',
row.clientId,
'fixed-deposits-accounts',
row.id,
'general'
]);
}
// ── Renewal ────────────────────────────────────────────────────────────────
/** Renew is a single action on a matured, renewable row -- opens the same Payment dialog an account
* page's own Account Actions menu uses, scoped to just the Reinvest choices, with the closure date
* and gap-interest crediting decided from the grace window rather than picked here (see
* PaymentDialogComponent's `dashboardRenewal` mode). */
openRenewDialog(row: FdMaturityRow): void {
if (!row.renewable || this.renewing != null) {
return;
}
this.renewing = row.id;
this.dialog
.open(PaymentDialogComponent, {
width: '720px',
maxWidth: '95vw',
data: {
accountId: row.id,
accountBalance: row.maturityAmount,
maturityDate: row.maturityDate,
dashboardRenewal: { row }
}
})
.afterClosed()
.subscribe((result: { confirm: boolean } | undefined) => {
this.renewing = null;
if (result?.confirm) {
this.load();
}
});
}
// ── Presentation ───────────────────────────────────────────────────────────
stageClass(stage: FdMaturityStage): string {
return `fdd-stage fdd-stage--${FD_MATURITY_STAGES[stage].tone}`;
}
/** "12 days overdue" / "in 5 days" / "Matures today", as a branch officer would say it. */
maturityText(row: FdMaturityRow): string {
if (row.daysOverdue > 0) {
return `${row.daysOverdue} day${row.daysOverdue === 1 ? '' : 's'} overdue`;
}
if (row.daysRemaining === 0) {
return 'Matures today';
}
return `In ${row.daysRemaining} day${row.daysRemaining === 1 ? '' : 's'}`;
}
/** "Renewable — 6 of 9 grace days left" / "Grace period (9d) passed 3 days ago" / "Renewable" (no
* grace configured) -- the sub-label under a matured, renewable row's stage badge. */
graceStatusText(row: FdMaturityRow): string {
if (!row.renewalGracePeriodDays) {
return 'Renewable';
}
const grace = row.renewalGracePeriodDays;
if (row.withinGraceWindow) {
const daysLeft = grace - row.daysOverdue;
return `Renewable — ${daysLeft} of ${grace} grace day${grace === 1 ? '' : 's'} left`;
}
const daysPastGrace = row.daysOverdue - grace;
return `Grace period (${grace}d) passed ${daysPastGrace} day${daysPastGrace === 1 ? '' : 's'} ago`;
}
}
@@ -0,0 +1,240 @@
/**
* Copyright since 2025 Mifos Initiative
*
* This Source Code Form is subject to the terms of the Mozilla Public
* License, v. 2.0. If a copy of the MPL was not distributed with this
* file, You can obtain one at http://mozilla.org/MPL/2.0/.
*/
/**
* Where an active Fixed Deposit sits relative to its own maturity date.
*
* MATURED accounts are the ones staff actually need to act on -- the FD has run its course but is
* still ACTIVE in the core, meaning nobody has applied the maturity instruction yet (or the
* scheduled job hasn't reached it). DUE_SOON and UPCOMING exist so a branch can see what's coming
* before it lands, not just after.
*/
export type FdMaturityStage = 'MATURED' | 'DUE_SOON' | 'UPCOMING' | 'ON_TRACK';
/** Day-count thresholds, measured forward from today toward the maturity date. */
export const FD_MATURITY_POLICY = {
/** Maturing within this many days counts as DUE_SOON. */
dueSoonDays: 7,
/** Maturing within this many days (and not already DUE_SOON) counts as UPCOMING. */
upcomingDays: 30
};
export interface FdMaturityStageMeta {
stage: FdMaturityStage;
label: string;
/** Colour key the template maps to a badge/tile class. */
tone: 'info' | 'warn' | 'danger' | 'neutral';
summary: string;
}
export const FD_MATURITY_STAGES: Record<FdMaturityStage, FdMaturityStageMeta> = {
MATURED: {
stage: 'MATURED',
label: 'Matured',
tone: 'danger',
summary: 'Past maturity date and still active -- the maturity instruction has not been applied yet.'
},
DUE_SOON: {
stage: 'DUE_SOON',
label: `Maturing ≤ ${FD_MATURITY_POLICY.dueSoonDays} Days`,
tone: 'warn',
summary: 'Matures within the week.'
},
UPCOMING: {
stage: 'UPCOMING',
label: `Maturing ≤ ${FD_MATURITY_POLICY.upcomingDays} Days`,
tone: 'info',
summary: 'Matures within the month.'
},
ON_TRACK: {
stage: 'ON_TRACK',
label: 'On Track',
tone: 'neutral',
summary: 'Maturity date is further out.'
}
};
/** FD types the manual-renewal action applies to. SPECIAL is architecturally forced to Transfer to
* Savings server-side and can never be Reinvest-configured, so it's never renewable here. */
const RENEWABLE_FD_TYPES = [
'SINGLE',
'DOUBLE'
];
/** DepositAccountOnClosureType labels, for the "what will happen automatically" sub-label shown on a
* matured row that already carries an explicit instruction from account-opening. */
const ON_CLOSURE_TYPE_LABELS: Record<number, string> = {
200: 'Transfer to Savings',
300: 'Reinvest Principal + Interest',
400: 'Reinvest Principal Only'
};
/** One active Fixed Deposit account, positioned on the maturity ladder. */
export interface FdMaturityRow {
id: number;
accountNo: string;
clientId: number | null;
clientName: string;
productName: string;
maturityDate: string;
depositAmount: number | null;
maturityAmount: number | null;
/** Negative once the maturity date has passed. */
daysRemaining: number;
/** 0 while not yet matured; how many days past maturity otherwise. */
daysOverdue: number;
stage: FdMaturityStage;
meta: FdMaturityStageMeta;
/** Null/0 -- no buffer configured; renewing after the maturity date itself pays gap interest. */
renewalGracePeriodDays: number | null;
linkedAccountId: number | null;
/** The account's own stored DepositAccountOnClosureType, raw -- null when none was chosen at
* account-opening. On a renewable row that DOES carry one (grace-period case: the account picked
* Reinvest, and is only waiting because the product also has a grace window), the Renew dialog
* pre-selects this rather than presenting a neutral, undecided choice -- the maker already decided
* what this FD should do, grace only changed when that gets carried out. */
onClosureType: number | null;
/** True only when the account was opened with NO maturity instruction at all (the officer left
* Maturity Instructions blank at account-opening) -- that's the one case nothing else will ever
* act on this FD, so the Dashboard's manual Renew action is meaningful. An account that WAS given
* an explicit instruction (Transfer to Savings or either Reinvest option) auto-processes on its
* own via the nightly job, exactly as it always has -- the Dashboard only reports on those, it
* doesn't also offer to renew them. */
isReinvestEligible: boolean;
/** True once matured AND eligible -- the only case where nothing has auto-processed it, so a
* manual action from the Dashboard is meaningful. */
renewable: boolean;
/** Only meaningful when renewable -- true if still inside the grace window (backdated renewal,
* no gap interest), false if the window has passed (renews from today, gap interest applies). */
withinGraceWindow: boolean;
/** Set on a matured row that is NOT renewable because it already carries an explicit instruction --
* what it was configured to do at account-opening, shown so staff can see at a glance what tonight's
* job will do without opening the account. Null for a renewable row (nothing decided yet) or a row
* that isn't matured. */
configuredInstructionLabel: string | null;
}
/** Days between two ISO dates, positive when `later` is after `earlier`. */
function daysBetween(earlier: string, later: string): number {
const a = new Date(`${earlier}T00:00:00Z`).getTime();
const b = new Date(`${later}T00:00:00Z`).getTime();
return Math.round((b - a) / 86400000);
}
/** Which rung of the ladder an account is on, from how many days remain until maturity. */
export function maturityStage(daysRemaining: number): FdMaturityStage {
if (daysRemaining <= 0) {
return 'MATURED';
}
if (daysRemaining <= FD_MATURITY_POLICY.dueSoonDays) {
return 'DUE_SOON';
}
if (daysRemaining <= FD_MATURITY_POLICY.upcomingDays) {
return 'UPCOMING';
}
return 'ON_TRACK';
}
/** Builds one dashboard row from a raw `/fixeddepositaccounts` list item and today's ISO date. */
export function buildMaturityRow(
fd: {
id: number;
accountNo: string;
clientId?: number;
clientName?: string;
savingsProductName?: string;
productName?: string;
fdType?: string;
maturityDate: string;
depositAmount?: number;
maturityAmount?: number;
onClosureType?: number;
linkedAccountId?: number;
renewalGracePeriodDays?: number;
},
today: string
): FdMaturityRow | null {
if (!fd.maturityDate) {
// No maturity date means the account isn't fully assembled yet (e.g. a chart not applied) --
// nothing sound to bucket it by, so it's left off the worklist rather than shown wrong.
return null;
}
const daysRemaining = daysBetween(today, fd.maturityDate);
const stage = maturityStage(daysRemaining);
// No grace configured reads as a 0-day buffer, not "not renewable" -- it only shrinks the backdate
// window, matching the backend's default (renewMaturedFixedDeposit treats a missing value as 0).
const gracePeriodDays = fd.renewalGracePeriodDays ?? 0;
// Mirrors updateMaturityDetails()'s own "will I auto-process this?" check exactly, inverted: the
// nightly job leaves an account alone (never touches it) only when it was opened with NO maturity
// instruction at all -- that's the one case the Dashboard's Renew action exists for. An account
// opened WITH an explicit instruction auto-processes tonight regardless of the product's renewal
// grace period (the grace window never delays it), so it's never renewable here either way.
const isReinvestEligible =
!!fd.fdType && RENEWABLE_FD_TYPES.includes(fd.fdType as string) && fd.onClosureType == null;
const renewable = stage === 'MATURED' && isReinvestEligible;
const withinGraceWindow = renewable && daysOverdueFor(daysRemaining) <= gracePeriodDays;
const configuredInstructionLabel =
stage === 'MATURED' && !renewable && fd.onClosureType != null
? (ON_CLOSURE_TYPE_LABELS[fd.onClosureType] ?? null)
: null;
return {
id: fd.id,
accountNo: fd.accountNo || '',
clientId: fd.clientId ?? null,
clientName: fd.clientName || '—',
productName: fd.productName || fd.savingsProductName || '—',
maturityDate: fd.maturityDate,
depositAmount: fd.depositAmount ?? null,
maturityAmount: fd.maturityAmount ?? null,
daysRemaining,
daysOverdue: daysOverdueFor(daysRemaining),
stage,
meta: FD_MATURITY_STAGES[stage],
renewalGracePeriodDays: gracePeriodDays,
linkedAccountId: fd.linkedAccountId ?? null,
onClosureType: fd.onClosureType ?? null,
isReinvestEligible,
renewable,
withinGraceWindow,
configuredInstructionLabel
};
}
function daysOverdueFor(daysRemaining: number): number {
return daysRemaining < 0 ? -daysRemaining : 0;
}
export interface FdMaturitySummary {
total: number;
matured: number;
dueSoon: number;
upcoming: number;
onTrack: number;
}
export function maturitySummary(rows: FdMaturityRow[]): FdMaturitySummary {
return {
total: rows.length,
matured: rows.filter((r) => r.stage === 'MATURED').length,
dueSoon: rows.filter((r) => r.stage === 'DUE_SOON').length,
upcoming: rows.filter((r) => r.stage === 'UPCOMING').length,
onTrack: rows.filter((r) => r.stage === 'ON_TRACK').length
};
}
/** Sorts the worklist so the most overdue, then the soonest-to-mature, rows come first. */
export function sortMaturityRows(rows: FdMaturityRow[]): FdMaturityRow[] {
const rank: Record<FdMaturityStage, number> = {
MATURED: 0,
DUE_SOON: 1,
UPCOMING: 2,
ON_TRACK: 3
};
return [...rows].sort((a, b) => rank[a.stage] - rank[b.stage] || a.daysRemaining - b.daysRemaining);
}
@@ -0,0 +1,22 @@
/**
* Copyright since 2025 Mifos Initiative
*
* This Source Code Form is subject to the terms of the Mozilla Public
* License, v. 2.0. If a copy of the MPL was not distributed with this
* file, You can obtain one at http://mozilla.org/MPL/2.0/.
*/
import { NgModule } from '@angular/core';
import { SharedModule } from '../shared/shared.module';
import { FdDashboardRoutingModule } from './fd-dashboard-routing.module';
import { FdDashboardComponent } from './fd-dashboard.component';
@NgModule({
imports: [
SharedModule,
FdDashboardRoutingModule,
FdDashboardComponent
]
})
export class FdDashboardModule {}
+1 -1
View File
@@ -438,7 +438,7 @@ export class LoginComponent implements OnInit, OnDestroy {
.split(',')
.map((id) => id.trim())
.filter((id) => id.length > 0);
if (tenantIds.length === 0 || (tenantIds.length === 1 && tenantIds[0] === 'default')) {
if (tenantIds.length === 0) {
return false;
}
return true;
@@ -143,11 +143,25 @@ export class BranchProfileComponent implements OnInit {
.subscribe((res: any) => {
this.saving = false;
if (!res) return;
if (res.rollbackTransaction) {
// Parked for checker approval - the branch profile has not actually changed yet.
this.successMsg = 'Branch profile update has been submitted for approval.';
this.form.markAsPristine();
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;
// The response is now a CommandProcessingResult, not the updated row (the write goes through
// the command pipeline) - reload the saved row from the server instead of patching state from
// this response.
this.organizationService.getBranchProfile(this.selectedOfficeId!).subscribe((updated: any) => {
if (!updated) return;
this.lastUpdated = updated.updatedByUsername
? { by: updated.updatedByUsername, at: updated.updatedAt }
: null;
const idx = this.offices.findIndex((o) => o.officeId === this.selectedOfficeId);
if (idx >= 0) this.offices[idx] = updated;
});
});
}
}
@@ -182,9 +182,18 @@ export class InstitutionProfileComponent implements OnInit {
.subscribe((res: any) => {
this.saving = false;
if (!res) return;
if (res.rollbackTransaction) {
// Parked for checker approval - the profile has not actually changed yet, so the form must
// not be patched with the submitted (not-yet-applied) values as if it had.
this.successMsg = 'Bank profile update has been submitted for approval.';
this.form.markAsPristine();
return;
}
this.successMsg = 'Bank profile saved.';
this.applyResult(res);
this.form.markAsPristine();
// The response is now a CommandProcessingResult, not the updated row (the write goes through
// the command pipeline) - reload the saved row from the server instead of patching the form
// from this response.
this.load();
});
}
@@ -16,6 +16,7 @@ import { OrganizationService } from '../../organization.service';
import { CdkTextareaAutosize } from '@angular/cdk/text-field';
import { MatCheckbox } from '@angular/material/checkbox';
import { STANDALONE_SHARED_IMPORTS } from 'app/standalone-shared.module';
import { AlertService } from 'app/core/alert/alert.service';
/**
* Create Payment Type Component.
@@ -35,6 +36,7 @@ export class CreatePaymentTypeComponent implements OnInit {
private organizationService = inject(OrganizationService);
private router = inject(Router);
private route = inject(ActivatedRoute);
private alertService = inject(AlertService);
/** Payment Type form. */
paymentTypeForm: UntypedFormGroup;
@@ -73,7 +75,13 @@ export class CreatePaymentTypeComponent implements OnInit {
*/
submit() {
const paymentType = this.paymentTypeForm.value;
this.organizationService.createPaymentType(paymentType).subscribe((response) => {
this.organizationService.createPaymentType(paymentType).subscribe((response: any) => {
if (response?.rollbackTransaction) {
this.alertService.alert({
type: 'Success',
message: 'Payment Type creation has been submitted for approval.'
});
}
this.router.navigate(['../'], { relativeTo: this.route });
});
}
@@ -16,6 +16,7 @@ import { OrganizationService } from 'app/organization/organization.service';
import { CdkTextareaAutosize } from '@angular/cdk/text-field';
import { MatCheckbox } from '@angular/material/checkbox';
import { STANDALONE_SHARED_IMPORTS } from 'app/standalone-shared.module';
import { AlertService } from 'app/core/alert/alert.service';
/**
* Edit Payment Type component.
@@ -35,6 +36,7 @@ export class EditPaymentTypeComponent implements OnInit {
private organizationService = inject(OrganizationService);
private router = inject(Router);
private route = inject(ActivatedRoute);
private alertService = inject(AlertService);
/** Payment Type form. */
paymentTypeForm: UntypedFormGroup;
@@ -88,7 +90,13 @@ export class EditPaymentTypeComponent implements OnInit {
*/
submit() {
const paymentType = this.paymentTypeForm.value;
this.organizationService.updatePaymentType(this.paymentTypeData.id, paymentType).subscribe((response) => {
this.organizationService.updatePaymentType(this.paymentTypeData.id, paymentType).subscribe((response: any) => {
if (response?.rollbackTransaction) {
this.alertService.alert({
type: 'Success',
message: 'Payment Type update has been submitted for approval.'
});
}
this.router.navigate(['../../'], { relativeTo: this.route });
});
}
@@ -34,6 +34,7 @@ import { DeleteDialogComponent } from '../../shared/delete-dialog/delete-dialog.
import { FaIconComponent } from '@fortawesome/angular-fontawesome';
import { MatTooltip } from '@angular/material/tooltip';
import { STANDALONE_SHARED_IMPORTS } from 'app/standalone-shared.module';
import { AlertService } from 'app/core/alert/alert.service';
/**
* Payment Types component.
@@ -65,6 +66,7 @@ export class PaymentTypesComponent implements OnInit {
private organizationService = inject(OrganizationService);
private route = inject(ActivatedRoute);
private dialog = inject(MatDialog);
private alertService = inject(AlertService);
/** Payment Types data. */
paymentTypesData: any;
@@ -132,9 +134,20 @@ export class PaymentTypesComponent implements OnInit {
});
deletePaymentTypeDialogRef.afterClosed().subscribe((response: any) => {
if (response.delete) {
this.organizationService.deletePaymentType(paymentTypeId).subscribe(() => {
this.paymentTypesData = this.paymentTypesData.filter((paymentType: any) => paymentType.id !== paymentTypeId);
this.setPaymentTypes();
this.organizationService.deletePaymentType(paymentTypeId).subscribe((deleteResponse: any) => {
if (deleteResponse?.rollbackTransaction) {
// Parked for checker approval - the record has not actually been removed yet, so the
// list must not drop it (that would falsely show the deletion as already applied).
this.alertService.alert({
type: 'Success',
message: 'Payment Type deletion has been submitted for approval.'
});
} else {
this.paymentTypesData = this.paymentTypesData.filter(
(paymentType: any) => paymentType.id !== paymentTypeId
);
this.setPaymentTypes();
}
});
}
});
@@ -23,6 +23,7 @@ import { OrganizationService } from '../organization.service';
import { SettingsService } from 'app/settings/settings.service';
import { PopoverService } from '../../configuration-wizard/popover/popover.service';
import { ConfigurationWizardService } from '../../configuration-wizard/configuration-wizard.service';
import { AlertService } from 'app/core/alert/alert.service';
/** Custom Dialog Components */
import { NextStepDialogComponent } from '../../configuration-wizard/next-step-dialog/next-step-dialog.component';
@@ -53,6 +54,7 @@ export class WorkingDaysComponent implements OnInit, AfterViewInit {
private dialog = inject(MatDialog);
private configurationWizardService = inject(ConfigurationWizardService);
private popoverService = inject(PopoverService);
private alertService = inject(AlertService);
/** Working days form. */
workingDaysForm: UntypedFormGroup;
@@ -152,7 +154,13 @@ export class WorkingDaysComponent implements OnInit, AfterViewInit {
}
}
workingDays.recurrence = recurrence;
this.organizationService.updateWorkingDays(workingDays).subscribe((response) => {
this.organizationService.updateWorkingDays(workingDays).subscribe((response: any) => {
if (response?.rollbackTransaction) {
this.alertService.alert({
type: 'Success',
message: 'Working Days update has been submitted for approval.'
});
}
if (this.configurationWizardService.showDefineWorkingDays) {
this.configurationWizardService.showDefineWorkingDays = false;
this.openNextStepDialog();
@@ -34,6 +34,35 @@
</div>
</div>
<!-- Renewal -->
<div class="fdpt-section">
<div class="fdpt-section__header">
<span class="fdpt-section__badge"><i class="fas fa-sync-alt"></i></span>
<span>Renewal</span>
</div>
<div class="fdpt-body">
<div class="fdpt-banner">
<span class="fdpt-banner__icon">ℹ️</span>
<span>
Leave blank for an FD on this product to keep renewing instantly, the same night it matures, as before. Set a
number of days and a matured "auto renew" FD instead waits on the FD Dashboard for that many days for staff to
renew it manually -- backdated to the original maturity date within that window, or from the day it is
actually renewed (with gap-period interest at the linked Savings account's rate) once the window has passed.
</span>
</div>
<div class="fdpt-grid fdpt-grid--three">
<mat-form-field appearance="outline" class="fdpt-field">
<mat-label>Renewal Grace Period</mat-label>
<input type="number" matInput formControlName="renewalGracePeriodDays" min="0" />
<mat-hint>Days</mat-hint>
@if (fixedDepositProductTermsForm.get('renewalGracePeriodDays').hasError('min')) {
<mat-error>Renewal Grace Period must be zero or greater</mat-error>
}
</mat-form-field>
</div>
</div>
</div>
<!-- Interest Rate & Posting Terms -->
<div class="fdpt-section">
<div class="fdpt-section__header">
@@ -74,7 +74,6 @@ export class FixedDepositProductTermsStepComponent implements OnInit, OnChanges
this.periodFrequencyData = this.fixedDepositProductsTemplate.periodFrequencyTypeOptions || [];
const isEditing = !!this.fixedDepositProductsTemplate.id;
const defaultPeriodTypeId = this.periodFrequencyData[0]?.id ?? null;
const defaultAveragePeriod = this.averagePeriodData[0]?.value || 'Monthly';
const savedEnumTypeId =
this.fixedDepositProductsTemplate.interestCalculationType?.id || this.interestCalculationTypeData?.[0]?.id || 1;
@@ -85,12 +84,17 @@ export class FixedDepositProductTermsStepComponent implements OnInit, OnChanges
: (this.interestCalculationTypeOptionData?.[0]?.id ?? null);
this.fixedDepositProductTermsForm.patchValue({
minDepositTerm: this.fixedDepositProductsTemplate.minDepositTerm ?? 1,
minDepositTermTypeId: this.fixedDepositProductsTemplate.minDepositTermType?.id ?? defaultPeriodTypeId,
// Hidden, unedited-by-anyone fields -- left blank (like their max-/in-multiples-of- siblings
// already correctly are) rather than forced to a real default, or every single product edit
// submitted a "real" value against a product that had none, and a checker's "Proposed changes"
// review showed these two as "changed" on every unrelated edit, never anything a maker touched.
minDepositTerm: this.fixedDepositProductsTemplate.minDepositTerm ?? '',
minDepositTermTypeId: this.fixedDepositProductsTemplate.minDepositTermType?.id ?? '',
maxDepositTerm: this.fixedDepositProductsTemplate.maxDepositTerm ?? '',
maxDepositTermTypeId: this.fixedDepositProductsTemplate.maxDepositTermType?.id ?? '',
inMultiplesOfDepositTerm: this.fixedDepositProductsTemplate.inMultiplesOfDepositTerm ?? '',
inMultiplesOfDepositTermTypeId: this.fixedDepositProductsTemplate.inMultiplesOfDepositTermType?.id ?? '',
renewalGracePeriodDays: this.fixedDepositProductsTemplate.renewalGracePeriodDays ?? '',
minDepositAmount: this.fixedDepositProductsTemplate.minDepositAmount,
maxDepositAmount: this.fixedDepositProductsTemplate.maxDepositAmount,
nominalAnnualInterestRate: this.fixedDepositProductsTemplate.nominalAnnualInterestRate ?? 0,
@@ -171,6 +175,13 @@ export class FixedDepositProductTermsStepComponent implements OnInit, OnChanges
maxDepositTermTypeId: [''],
inMultiplesOfDepositTerm: [''],
inMultiplesOfDepositTermTypeId: [''],
// Days after maturity a depositor can still walk in and have their FD renewed backdated to the
// original maturity date, at the currently active rate -- blank/0 keeps today's instant,
// same-night auto-renewal (see FD Dashboard for the manual renewal this enables).
renewalGracePeriodDays: [
'',
Validators.min(0)
],
minDepositAmount: [
'',
Validators.min(0)
@@ -226,8 +226,10 @@
<mat-spinner diameter="16"></mat-spinner>
<span>Submitting…</span>
} @else {
<mat-icon>check</mat-icon>
<span>Submit Request</span>
<ng-container>
<mat-icon>check</mat-icon>
<span>Submit Request</span>
</ng-container>
}
</button>
</mat-dialog-actions>
@@ -275,8 +275,10 @@
<mat-spinner diameter="16"></mat-spinner>
<span>Applying…</span>
} @else {
<mat-icon>check</mat-icon>
<span>Apply Collection</span>
<ng-container>
<mat-icon>check</mat-icon>
<span>Apply Collection</span>
</ng-container>
}
</button>
</mat-dialog-actions>
@@ -297,8 +297,14 @@ export class GlSettingsDialogComponent implements OnInit {
ddLimitAmount: val.ddLimitAmount
})
.subscribe({
next: () => {
this.snackBar.open('Sponsor DD GL Mapping & GST Settings saved successfully!', 'Close', { duration: 3000 });
next: (res: any) => {
// A save submitted for approval comes back HTTP 200 with rollbackTransaction: true - the
// mapping has not actually been applied yet, so this must not claim success the same way an
// immediate save does.
const message = res?.rollbackTransaction
? 'Sponsor DD GL Mapping & GST Settings submitted for approval'
: 'Sponsor DD GL Mapping & GST Settings saved successfully!';
this.snackBar.open(message, 'Close', { duration: res?.rollbackTransaction ? 4000 : 3000 });
this.dialogRef.close(true);
},
error: (err) => {
@@ -20,6 +20,7 @@ import { MatInputModule } from '@angular/material/input';
import { MatSelectModule } from '@angular/material/select';
import { MatIconModule } from '@angular/material/icon';
import { MatSnackBar, MatSnackBarModule } from '@angular/material/snack-bar';
import { AlertService } from 'app/core/alert/alert.service';
@Component({
selector: 'mifosx-sponsor-bank-list',
@@ -356,6 +357,7 @@ export class AddSponsorBankDialogComponent implements OnInit {
private http = inject(HttpClient);
private dialogRef = inject(MatDialogRef<AddSponsorBankDialogComponent>);
private snackBar = inject(MatSnackBar);
private alertService = inject(AlertService);
/** The bank being edited, or null when adding a new one. */
data = inject(MAT_DIALOG_DATA, { optional: true }) as any;
@@ -419,10 +421,24 @@ export class AddSponsorBankDialogComponent implements OnInit {
? this.http.put<any>(`/sponsorbanks/${this.data.bank.id}`, this.form.value)
: this.http.post<any>('/sponsorbanks', this.form.value);
request$.subscribe({
next: () => {
this.snackBar.open(this.isEdit ? 'Sponsor Bank updated' : 'Sponsor Bank added successfully', 'Close', {
duration: 3000
});
next: (res: any) => {
// A create/update submitted for approval comes back HTTP 200 with rollbackTransaction: true -
// the bank has not actually been created/changed yet, so this must not claim success the same
// way an immediate create/update does. Routed through AlertService (not the local snackBar)
// so it uses the same top-right notification as the Payment Type/Working Days Maker-Checker
// flows, instead of the bottom-positioned snackbar used for this dialog's other messages.
if (res?.rollbackTransaction) {
this.alertService.alert({
type: 'Success',
message: this.isEdit
? 'Sponsor Bank update has been submitted for approval'
: 'Sponsor Bank creation has been submitted for approval'
});
} else {
this.snackBar.open(this.isEdit ? 'Sponsor Bank updated' : 'Sponsor Bank added successfully', 'Close', {
duration: 3000
});
}
this.dialogRef.close(true);
},
error: (err) => {
@@ -7,6 +7,26 @@
-->
<div class="layout-row align-end gap-2px responsive-column container m-b-20">
<mat-form-field class="mc-search-field">
<input
matInput
type="text"
placeholder="Search permissions..."
[value]="searchTerm"
(input)="onSearchTermChange($any($event.target).value)"
/>
@if (isSearchActive) {
<button
class="mc-clear-search"
matSuffix
type="button"
(click)="clearSearch()"
[attr.aria-label]="'Clear search'"
>
<fa-icon icon="times"></fa-icon>
</button>
}
</mat-form-field>
<div #buttonEdit class="in-block">
<button
mat-raised-button
@@ -41,14 +61,20 @@
<mat-divider [vertical]="true"></mat-divider>
<div class="list-permission layout-column flex-70">
@if (isSearchActive && displayedPermissions.length === 0) {
<p class="no-results">No permissions found.</p>
}
<form [formGroup]="formGroup" (submit)="submit()">
<div formArrayName="roster">
@for (permission of permissions.permissions; track permission; let i = $index) {
@for (permission of displayedPermissions; track permission.code) {
<div class="display-permissions">
<div [formGroupName]="i">
<div [formGroupName]="permission.globalIndex">
<mat-checkbox name="cp" id="{{ permission.code }}" formControlName="selected">
{{ permissionName(permission.code) }}
</mat-checkbox>
@if (isSearchActive) {
<span class="search-result-grouping">({{ formatName(permission.grouping || '') }})</span>
}
</div>
</div>
}
@@ -46,3 +46,32 @@ mat-list-item {
.inactive:hover {
transform: scale(1.1);
}
.mc-search-field {
width: 260px;
margin-right: 12px;
}
.mc-clear-search {
border: none;
background: transparent;
cursor: pointer;
color: inherit;
opacity: 0.6;
}
.mc-clear-search:hover {
opacity: 1;
}
.no-results {
padding-top: 15px;
font-style: italic;
opacity: 0.7;
}
.search-result-grouping {
padding-left: 6px;
font-size: 0.85rem;
opacity: 0.6;
}
@@ -27,6 +27,11 @@ interface Permission {
id: number;
selected: boolean;
grouping?: string;
/** Index of this permission's control within the `roster` FormArray (which is built from the
* full, globally-ordered permission list). Needed because the checkbox list on screen is
* rendered from a per-grouping filtered subset, whose own local index does not match the
* roster's global index. */
globalIndex?: number;
}
interface PermissionGroup {
@@ -70,6 +75,12 @@ export class ConfigureMakerCheckerTasksComponent implements OnInit, AfterViewIni
currentGrouping = '';
tempPermissionUIData: PermissionData = {};
permissions: PermissionGroup = { permissions: [] };
/** Flat list of every permission across all groupings, built once alongside
* tempPermissionUIData. Used only for global search - it is a read-only view over the same
* Permission objects (each still carrying its correct globalIndex), never a separate copy
* that could drift out of sync with the roster FormArray. */
allPermissions: Permission[] = [];
searchTerm = '';
formData = {};
isDisabled = true;
@@ -137,22 +148,30 @@ export class ConfigureMakerCheckerTasksComponent implements OnInit, AfterViewIni
setMakerCheckerTask(): void {
this.tempPermissionUIData = {};
this.allPermissions = [];
for (const permission of this.permissionsData) {
this.permissionsData.forEach((permission: any, globalIndex: number) => {
if (permission.grouping !== this.currentGrouping) {
this.currentGrouping = permission.grouping || '';
this.groupings.push(this.currentGrouping);
this.tempPermissionUIData[this.currentGrouping] = { permissions: [] };
}
// globalIndex matches this permission's position in `this.formGroup.controls.roster`,
// since both this list and `roster` are built from the same `this.permissionsData` array
// in the same order (see createForm()). The checkbox must address the roster control by
// this global index, never by its local position within the current grouping's filtered view.
const temp: Permission = {
code: permission.code,
id: permission.id,
selected: permission.selected
selected: permission.selected,
grouping: this.currentGrouping,
globalIndex
};
this.tempPermissionUIData[this.currentGrouping].permissions.push(temp);
}
this.allPermissions.push(temp);
});
}
showPermissions(grouping: string): void {
@@ -164,6 +183,38 @@ export class ConfigureMakerCheckerTasksComponent implements OnInit, AfterViewIni
}
}
/**
* The permissions currently rendered as checkboxes. While a search term is active this is a
* global, cross-grouping match over `allPermissions`; otherwise it is the normal per-grouping
* view. Either way each item still carries its original globalIndex, so the checkbox template
* always resolves the same roster control regardless of which list produced it.
*/
get displayedPermissions(): Permission[] {
if (!this.isSearchActive) {
return this.permissions.permissions;
}
const term = this.searchTerm.trim().toLowerCase();
return this.allPermissions.filter((permission) => this.matchesSearch(permission, term));
}
get isSearchActive(): boolean {
return this.searchTerm.trim().length > 0;
}
onSearchTermChange(value: string): void {
this.searchTerm = value;
}
clearSearch(): void {
this.searchTerm = '';
}
private matchesSearch(permission: Permission, term: string): boolean {
const readableName = permission.code.replace(/_/g, ' ').toLowerCase();
const haystack = `${permission.code.toLowerCase()} ${readableName}`;
return haystack.includes(term);
}
permissionName = function (name: any) {
name = name || '';
// replace '_' with ' '
@@ -127,7 +127,106 @@
<div class="empty-state">
<i class="fas fa-clock empty-icon"></i>
<p class="empty-title">No pending accounts</p>
<p class="empty-text">There are no savings accounts awaiting approval at this time.</p>
<p class="empty-text">There are no accounts awaiting approval at this time.</p>
</div>
}
<!-- Share accounts awaiting approval render here too, rather than on their own tab. Own table,
own per-row approve/reject/view actions - unchanged from their previous standalone screen. -->
<h3 class="pa-section-heading">Pending Share Accounts</h3>
@if (showShareData) {
<div class="psa-action-bar">
<mat-form-field appearance="outline" class="psa-search-field">
<mat-icon matPrefix>search</mat-icon>
<input
#shareFilterInput
matInput
placeholder="Search by account number, member or product"
(keyup)="applyShareFilter(shareFilterInput.value)"
/>
</mat-form-field>
</div>
<div class="table-container">
<table mat-table [dataSource]="shareDataSource" class="modern-table">
<ng-container matColumnDef="accountNo">
<th mat-header-cell *matHeaderCellDef>Account No.</th>
<td mat-cell *matCellDef="let account" class="psa-mono">{{ account.accountNo }}</td>
</ng-container>
<ng-container matColumnDef="clientName">
<th mat-header-cell *matHeaderCellDef>Member</th>
<td mat-cell *matCellDef="let account">
{{ account.clientName }}
@if (account.officeName) {
<div class="psa-sub">{{ account.officeName }}</div>
}
</td>
</ng-container>
<ng-container matColumnDef="productName">
<th mat-header-cell *matHeaderCellDef>Product</th>
<td mat-cell *matCellDef="let account">{{ account.productName }}</td>
</ng-container>
<ng-container matColumnDef="requestedShares">
<th mat-header-cell *matHeaderCellDef class="ta-right">Shares</th>
<td mat-cell *matCellDef="let account" class="ta-right psa-num">{{ account.requestedShares }}</td>
</ng-container>
<ng-container matColumnDef="requestedAmount">
<th mat-header-cell *matHeaderCellDef class="ta-right">Amount</th>
<td mat-cell *matCellDef="let account" class="ta-right psa-num">
{{ account.requestedAmount | number: '1.2-2' }}
</td>
</ng-container>
<ng-container matColumnDef="resolution">
<th mat-header-cell *matHeaderCellDef>Resolution</th>
<td mat-cell *matCellDef="let account">
{{ account.resolutionNumber || '—' }}
@if (account.resolutionDate) {
<div class="psa-sub">{{ formatShareDate(account.resolutionDate) }}</div>
}
</td>
</ng-container>
<ng-container matColumnDef="submittedDate">
<th mat-header-cell *matHeaderCellDef>Submitted</th>
<td mat-cell *matCellDef="let account">
{{ formatShareDate(account.submittedDate) }}
@if (account.submittedByUsername) {
<div class="psa-sub">by {{ account.submittedByUsername }}</div>
}
</td>
</ng-container>
<ng-container matColumnDef="actions">
<th mat-header-cell *matHeaderCellDef>Actions</th>
<td mat-cell *matCellDef="let account">
<button mat-stroked-button class="psa-btn psa-btn-view" (click)="viewShareAccount(account)">
<fa-icon icon="eye" class="m-r-10"></fa-icon>View
</button>
<button mat-stroked-button class="psa-btn psa-btn-approve" (click)="approveShareAccount(account)">
<fa-icon icon="check" class="m-r-10"></fa-icon>Approve
</button>
<button mat-stroked-button class="psa-btn psa-btn-reject" (click)="rejectShareAccount(account)">
<fa-icon icon="ban" class="m-r-10"></fa-icon>Reject
</button>
</td>
</ng-container>
<tr mat-header-row *matHeaderRowDef="shareDisplayedColumns"></tr>
<tr mat-row *matRowDef="let row; columns: shareDisplayedColumns" class="table-row"></tr>
</table>
</div>
}
@if (!showShareData) {
<div class="empty-state">
<fa-icon icon="hourglass-half" class="empty-icon"></fa-icon>
<p class="empty-title">No pending share accounts</p>
<p class="empty-text">There are no share accounts awaiting approval at this time.</p>
</div>
}
</div>
@@ -14,6 +14,16 @@ $text-muted: #64748b;
padding: 16px 0;
}
/* Marks where the Share Account section begins below the normal-account table on this same screen. */
.pa-section-heading {
margin: 28px 0 12px;
font-size: 15px;
font-weight: 700;
color: #0d1b2a;
padding-top: 16px;
border-top: 1px solid $border;
}
.cv-action-bar {
display: flex;
align-items: center;
@@ -29,17 +29,24 @@ import { MatCheckbox } from '@angular/material/checkbox';
import { FaIconComponent } from '@fortawesome/angular-fontawesome';
import { TasksService } from '../../tasks.service';
import { SharesService } from 'app/shares/shares.service';
import { SettingsService } from 'app/settings/settings.service';
import { Dates } from 'app/core/utils/dates';
import { DecimalPipe } from '@angular/common';
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 { DatepickerBase } from 'app/shared/form-dialog/formfield/model/datepicker-base';
import { InputBase } from 'app/shared/form-dialog/formfield/model/input-base';
import { STANDALONE_SHARED_IMPORTS } from 'app/standalone-shared.module';
@Component({
selector: 'mifosx-pending-account',
templateUrl: './pending-account.component.html',
styleUrls: ['./pending-account.component.scss'],
styleUrls: [
'./pending-account.component.scss',
'../pending-share-accounts/pending-share-accounts.component.scss'
],
imports: [
...STANDALONE_SHARED_IMPORTS,
FaIconComponent,
@@ -54,7 +61,8 @@ import { STANDALONE_SHARED_IMPORTS } from 'app/standalone-shared.module';
MatHeaderRow,
MatRowDef,
MatRow,
MatIcon
MatIcon,
DecimalPipe
]
})
export class PendingAccountComponent {
@@ -64,6 +72,7 @@ export class PendingAccountComponent {
private dateUtils = inject(Dates);
private settingsService = inject(SettingsService);
private tasksService = inject(TasksService);
private sharesService = inject(SharesService);
dataSource: MatTableDataSource<any>;
selection: SelectionModel<any>;
@@ -81,8 +90,26 @@ export class PendingAccountComponent {
'status'
];
/** Share accounts awaiting approval - rendered as their own section on this same screen rather
* than a separate tab. Kept out of `dataSource`/`selection` (bulk-select + date-picker approve)
* since a share subscription is approved/rejected one at a time against today's business date,
* exactly as it always was on its own page - only where it's shown has changed. */
shareDataSource: MatTableDataSource<any>;
showShareData = false;
shareDisplayedColumns: string[] = [
'accountNo',
'clientName',
'productName',
'requestedShares',
'requestedAmount',
'resolution',
'submittedDate',
'actions'
];
constructor() {
this.route.data.subscribe((data: { pendingAccountsData: any }) => {
this.route.data.subscribe((data: { pendingAccountsData: any; pendingShareAccountsData: any }) => {
// status 100 = submitted and pending approval only; exclude terminated accounts
const items = (data.pendingAccountsData?.pageItems || []).filter(
(a: any) => a.status?.id === 100 && !a.terminateTrue
@@ -90,6 +117,10 @@ export class PendingAccountComponent {
this.showData = items.length > 0;
this.dataSource = new MatTableDataSource(items);
this.selection = new SelectionModel(true, []);
const shareItems = data.pendingShareAccountsData || [];
this.showShareData = shareItems.length > 0;
this.shareDataSource = new MatTableDataSource(shareItems);
});
}
@@ -283,6 +314,92 @@ export class PendingAccountComponent {
return date;
}
applyShareFilter(filterValue: string = '') {
this.shareDataSource.filter = filterValue.trim().toLowerCase();
}
/** A date from the share account API rendered readably, whether it arrives as a string or as
* Fineract's {@code [yyyy, m, d]} array. Kept separate from `formatDate()` above, which the
* normal-account table already relies on with its own date-array handling. */
formatShareDate(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 share account itself, for a checker who wants more than the row shows. */
viewShareAccount(account: any): void {
this.router.navigate([
'/clients',
account.clientId,
'shares-accounts',
account.shareAccountId
]);
}
approveShareAccount(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, same as this
// queue's original standalone screen: clearing a submission is a decision, not a backdated
// transaction, so a date field per row would be extra clicks for no benefit.
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());
}
});
}
rejectShareAccount(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) {
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() {
const url = this.router.url;
this.router
@@ -117,6 +117,7 @@
[class.status-badge--current]="edit.accountType === 'Current'"
[class.status-badge--fd]="edit.accountType === 'Fixed Deposit'"
[class.status-badge--rd]="edit.accountType === 'Recurring Deposit'"
[class.status-badge--share]="edit.accountType === 'Share'"
>
{{ edit.accountType }}
</span>
@@ -412,6 +412,11 @@ $text-muted: #64748b;
background: #ecfdf5;
color: #065f46;
}
&--share {
background: #eef2ff;
color: #3730a3;
}
}
.empty-state {
@@ -29,16 +29,20 @@ import { FaIconComponent } from '@fortawesome/angular-fontawesome';
import { SavingsService } from 'app/savings/savings.service';
import { CurrentAccountsService } from 'app/current-accounts/current-accounts.service';
import { SharesService } from 'app/shares/shares.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 { SavingsAccountEditCompareDialogComponent } from './savings-account-edit-compare-dialog/savings-account-edit-compare-dialog.component';
import { ShareAccountEditCompareDialogComponent } from '../pending-share-account-edits/share-account-edit-compare-dialog/share-account-edit-compare-dialog.component';
import { STANDALONE_SHARED_IMPORTS } from 'app/standalone-shared.module';
/**
* Checker-facing list of savings account Nominee / Digital-Feature edits awaiting approval.
* Mirrors PendingSavingsProductsComponent's list + compare + approve/reject pattern.
* Checker-facing list of savings/current/share account Nominee, Digital-Feature and details edits
* awaiting approval. Mirrors PendingSavingsProductsComponent's list + compare + approve/reject
* pattern; Share Account edits are folded in here (rather than kept on their own tab) the same way
* Current Account edits already are, via `accountType` as the discriminator.
*/
@Component({
selector: 'mifosx-pending-savings-account-edits',
@@ -67,6 +71,7 @@ export class PendingSavingsAccountEditsComponent {
private dialog = inject(MatDialog);
private savingsService = inject(SavingsService);
private currentAccountsService = inject(CurrentAccountsService);
private sharesService = inject(SharesService);
dataSource: MatTableDataSource<any>;
showData = false;
@@ -104,13 +109,23 @@ export class PendingSavingsAccountEditsComponent {
constructor() {
this.maxDate = new Date();
this.route.data.subscribe((data: { pendingSavingsAccountEditsData: any; pendingCurrentAccountEditsData: any }) => {
this.allItems = this.mergeItems(data.pendingSavingsAccountEditsData, data.pendingCurrentAccountEditsData);
this.applyFilters();
});
this.route.data.subscribe(
(data: {
pendingSavingsAccountEditsData: any;
pendingCurrentAccountEditsData: any;
pendingShareAccountEditsData: any;
}) => {
this.allItems = this.mergeItems(
data.pendingSavingsAccountEditsData,
data.pendingCurrentAccountEditsData,
data.pendingShareAccountEditsData
);
this.applyFilters();
}
);
}
private mergeItems(savingsData: any, currentData: any): any[] {
private mergeItems(savingsData: any, currentData: any, shareData?: any): any[] {
const savingsItems = (savingsData || []).map((item: any) => {
let type = 'Savings';
if (item.depositAccountType === 'FIXED_DEPOSIT') {
@@ -127,9 +142,14 @@ export class PendingSavingsAccountEditsComponent {
...item,
accountType: 'Current'
}));
const shareItems = (shareData || []).map((item: any) => ({
...item,
accountType: 'Share'
}));
return [
...savingsItems,
...currentItems
...currentItems,
...shareItems
];
}
@@ -211,10 +231,11 @@ export class PendingSavingsAccountEditsComponent {
this.loading = true;
forkJoin({
savings: this.savingsService.getAllPendingSavingsAccountEdits(),
current: this.currentAccountsService.getAllPendingCurrentAccountEdits()
current: this.currentAccountsService.getAllPendingCurrentAccountEdits(),
share: this.sharesService.getAllPendingShareAccountEdits()
}).subscribe({
next: ({ savings, current }) => {
this.allItems = this.mergeItems(savings, current);
next: ({ savings, current, share }) => {
this.allItems = this.mergeItems(savings, current, share);
this.applyFilters();
this.loading = false;
},
@@ -234,13 +255,35 @@ export class PendingSavingsAccountEditsComponent {
/**
* 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.
* what's proposed, then opens the compare dialog. The maker's edit form (AccountNomineesTabComponent)
* already submits only the fields it actually touched, so proposedFacilityConfig/proposedNominee only
* ever carry changed keys -- the dialog still diffs facility values against this current config
* defensively, so an unchanged value can never appear in the popup. `clientId`/`nomineeAccountType`
* are also passed through so the dialog can look up the nominee's actual "before" state for an
* UPDATE/DELETE (the pending-edit record itself only ever stores the proposed side).
*/
viewCompare(edit: any): void {
this.loadingCompareFor = edit;
if (edit.accountType === 'Share') {
this.sharesService.getSharesAccountData(String(edit.shareAccountId), false).subscribe({
next: (account: any) => {
this.loadingCompareFor = null;
this.dialog.open(ShareAccountEditCompareDialogComponent, {
width: '640px',
data: {
accountNo: edit.accountNo,
currentAccount: account,
edit,
rejectReason: edit.rejectReason
}
});
},
error: () => {
this.loadingCompareFor = null;
}
});
return;
}
const isCurrent = edit.accountType === 'Current';
const account$ = isCurrent
? this.currentAccountsService.getCurrentAccountAndTemplate(edit.currentAccountId, false)
@@ -255,6 +298,8 @@ export class PendingSavingsAccountEditsComponent {
currentFacilityConfig: account?.accountFacilityConfig ?? {},
proposedFacilityConfig: edit.proposedFacilityConfig ?? {},
proposedNominee: edit.proposedNominee ?? {},
clientId: account?.clientId,
nomineeAccountType: this.nomineeAccountTypeFor(edit.accountType),
rejectReason: edit.rejectReason
}
});
@@ -265,7 +310,38 @@ export class PendingSavingsAccountEditsComponent {
});
}
/** `m_client_nominee.account_type` code for this row's deposit type - matches exactly what
* AccountNomineesTabComponent/the write services use when a nominee is added, updated or deleted
* against this account, so the compare dialog's live nominee lookup queries the same rows. */
private nomineeAccountTypeFor(accountType: string): string {
switch (accountType) {
case 'Current':
return 'CB';
case 'Fixed Deposit':
return 'FD';
case 'Recurring Deposit':
return 'RD';
default:
return 'SB';
}
}
approve(edit: any): void {
if (edit.accountType === 'Share') {
const dialogRef = this.dialog.open(ConfirmationDialogComponent, {
data: {
heading: 'Approve Share Account Edit',
dialogContext: `Apply the pending details edit for account "${edit.accountNo}"? This cannot be undone.`,
type: 'basic'
}
});
dialogRef.afterClosed().subscribe((response: any) => {
if (response?.confirm) {
this.sharesService.approveShareAccountEdit(edit.shareAccountId).subscribe(() => this.reload());
}
});
return;
}
const isCurrent = edit.accountType === 'Current';
const dialogRef = this.dialog.open(ConfirmationDialogComponent, {
data: {
@@ -286,7 +362,6 @@ export class PendingSavingsAccountEditsComponent {
}
reject(edit: any): void {
const isCurrent = edit.accountType === 'Current';
const formfields: FormfieldBase[] = [
new InputBase({
controlName: 'rejectReason',
@@ -306,6 +381,11 @@ export class PendingSavingsAccountEditsComponent {
dialogRef.afterClosed().subscribe((response: any) => {
if (response?.data) {
const rejectReason = response.data.value.rejectReason || '';
if (edit.accountType === 'Share') {
this.sharesService.rejectShareAccountEdit(edit.shareAccountId, rejectReason).subscribe(() => this.reload());
return;
}
const isCurrent = edit.accountType === 'Current';
if (isCurrent) {
this.currentAccountsService
.rejectCurrentAccountEdit(edit.currentAccountId, rejectReason)
@@ -49,6 +49,7 @@
<thead>
<tr>
<th>Field</th>
<th>Current</th>
<th>Proposed</th>
</tr>
</thead>
@@ -56,7 +57,8 @@
@for (row of nomineeRows; track row.label) {
<tr>
<td class="saecd-table__label">{{ row.label }}</td>
<td class="saecd-table__new" colspan="2">{{ row.proposed }}</td>
<td class="saecd-table__old">{{ row.current }}</td>
<td class="saecd-table__new">{{ row.proposed }}</td>
</tr>
}
</tbody>
@@ -18,6 +18,7 @@ import {
import { MatButton } from '@angular/material/button';
import { MatIcon } from '@angular/material/icon';
import { STANDALONE_SHARED_IMPORTS } from 'app/standalone-shared.module';
import { ClientsService } from 'app/clients/clients.service';
interface DiffRow {
label: string;
@@ -60,10 +61,18 @@ const NOMINEE_LABELS: Record<string, string> = {
/**
* 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,
* popup instead of an inline panel. The maker's edit form (AccountNomineesTabComponent for nominees;
* the account's own Digital & Account Features screen for facility toggles) already submits only the
* fields that were actually touched, so `proposedFacilityConfig`/`proposedNominee` only ever carry
* changed keys -- this still diffs facility values against the account's current config defensively,
* so an unchanged value can never appear here even if that ever stops being true upstream.
*
* The pending-edit record itself never stores what the nominee looked like *before* an UPDATE/DELETE
* (only the proposed new state) -- `SavingsAccountPendingEditData`/`CurrentAccountPendingEditData` have
* no such field. For those two operations this dialog fetches the account's nominees live (the same
* `ClientsService.getClientNominees` call `AccountNomineesTabComponent` itself uses) and matches on
* `nomineeId` to recover a real "before" value; an ADD has no "before" by definition and keeps showing
* '—'. If the lookup fails or finds no match, the proposed-only view already rendered is left as is.
*/
@Component({
selector: 'mifosx-savings-account-edit-compare-dialog',
@@ -82,6 +91,7 @@ const NOMINEE_LABELS: Record<string, string> = {
export class SavingsAccountEditCompareDialogComponent implements OnInit {
dialogRef = inject<MatDialogRef<SavingsAccountEditCompareDialogComponent>>(MatDialogRef);
data = inject(MAT_DIALOG_DATA);
private clientsService = inject(ClientsService);
accountNo = '';
facilityRows: DiffRow[] = [];
@@ -103,14 +113,89 @@ export class SavingsAccountEditCompareDialogComponent implements OnInit {
}))
.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])
}));
this.buildNomineeRows(this.data.proposedNominee ?? {});
}
/**
* Builds the nominee rows from the proposed side alone first (matches what was always shown), then
* - for UPDATE/DELETE only - looks up the account's actual nominees to fill in a real "before" value
* and re-filters to genuine changes. ADD has nothing to look up and keeps '—' on the current side.
*/
private buildNomineeRows(proposedNominee: Record<string, any>): void {
this.nomineeRows = this.nomineeRowsFrom(proposedNominee, null);
const operation = proposedNominee.operation;
const nomineeId = proposedNominee.nomineeId;
const canLookUp =
(operation === 'UPDATE' || operation === 'DELETE') &&
nomineeId !== null &&
nomineeId !== undefined &&
this.data.clientId !== null &&
this.data.clientId !== undefined &&
!!this.data.nomineeAccountType;
if (!canLookUp) {
return;
}
this.clientsService
.getClientNominees(String(this.data.clientId), this.accountNo, this.data.nomineeAccountType)
.subscribe({
next: (nominees: any[]) => {
const before = (nominees || []).find((n: any) => n?.id === nomineeId);
if (!before) {
// No match found (e.g. already actioned elsewhere) - the proposed-only rows already
// rendered stay as they are rather than showing a misleading blank lookup.
return;
}
if (operation === 'DELETE') {
this.nomineeRows = [
{
label: 'Nominee',
current: this.displayValue(before.nomineeDisplayName),
proposed: 'Removed'
}
];
return;
}
this.nomineeRows = this.nomineeRowsFrom(proposedNominee, before);
},
// Lookup failed - the proposed-only rows already on screen stay as they are.
error: () => undefined
});
}
/** A proposed-nominee key mapped to the matching field on the account's live `ClientNomineeData`,
* preferring an already-resolved display name (`relationshipName`/`guardianRelationshipName`) over
* the raw code-value id so the "before" side reads the same way `AccountNomineesTabComponent` does. */
private beforeValueFor(before: Record<string, any> | null, key: string): any {
if (!before) {
return undefined;
}
if (key === 'relationshipId') {
return before.relationshipName ?? before.relationshipId;
}
if (key === 'guardianRelationshipId') {
return before.guardianRelationshipName ?? before.guardianRelationshipId;
}
return before[key];
}
private nomineeRowsFrom(proposed: Record<string, any>, before: Record<string, any> | null): DiffRow[] {
return Object.keys(proposed)
.filter((key) => proposed[key] !== null && proposed[key] !== undefined)
.map((key) => {
// "Operation"/"Nominee Id" describe the request, not a value that was itself changed - always
// shown, never filtered by the current-vs-proposed comparison below.
const isMeta = key === 'operation' || key === 'nomineeId';
return {
label: NOMINEE_LABELS[key] ?? this.humanizeKey(key),
current: isMeta ? '—' : this.displayValue(this.beforeValueFor(before, key)),
proposed: this.displayValue(proposed[key]),
isMeta
};
})
.filter((row) => row.isMeta || row.current !== row.proposed)
.map(({ label, current, proposed: proposedValue }) => ({ label, current, proposed: proposedValue }));
}
private sameValue(a: any, b: any): boolean {
@@ -130,6 +215,13 @@ export class SavingsAccountEditCompareDialogComponent implements OnInit {
if (typeof value === 'boolean') {
return value ? 'Yes' : 'No';
}
// effectiveDate (the one date field in this dialog, on both the current and proposed nominee
// side) arrives as Fineract's [yyyy, M, d] array, not a string - read it the same way every other
// date in this app is, so identical dates compare equal and different ones read as dates.
if (Array.isArray(value) && value.length >= 3 && value.every((part) => typeof part === 'number')) {
const d = new Date(value[0], value[1] - 1, value[2]);
return isNaN(d.getTime()) ? String(value) : d.toLocaleDateString('en-IN');
}
return String(value);
}
@@ -18,6 +18,7 @@ import {
import { MatButton } from '@angular/material/button';
import { MatIcon } from '@angular/material/icon';
import { STANDALONE_SHARED_IMPORTS } from 'app/standalone-shared.module';
import { humanizeKey, sameValue, toIsoDate } from 'app/tasks/command-diff.util';
interface DiffRow {
label: string;
@@ -69,7 +70,14 @@ export class ProductEditDiffDialogComponent implements OnInit {
currencyCode: 'currency.code',
digitsAfterDecimal: 'currency.decimalPlaces',
inMultiplesOf: 'currency.inMultiplesOf',
taxGroupId: 'taxGroup.id'
taxGroupId: 'taxGroup.id',
// Enum fields the stored product exposes as "<name>Type": {id, value}", not "<name>TypeId" --
// without these the "current" side always read as blank, so these showed as changed on every
// edit even when the maker never touched them.
preClosurePenalInterestOnTypeId: 'preClosurePenalInterestOnType.id',
minDepositTermTypeId: 'minDepositTermType.id',
maxDepositTermTypeId: 'maxDepositTermType.id',
inMultiplesOfDepositTermTypeId: 'inMultiplesOfDepositTermType.id'
};
/**
@@ -78,6 +86,17 @@ export class ProductEditDiffDialogComponent implements OnInit {
* `accountingMappingOptions` rather than a per-field `<field>Options` key, so both sides of the
* diff need this table to turn an account id into the account's name.
*/
/**
* Fields whose option list isn't named after the field at all -- min/max/in-multiples-of deposit
* term type all resolve against one shared frequency list (Days/Weeks/Months/Years), not a list of
* their own, so the `<field>Options` convention below never finds them.
*/
private static readonly OPTIONS_KEY_ALIASES: Record<string, string> = {
minDepositTermTypeId: 'periodFrequencyTypeOptions',
maxDepositTermTypeId: 'periodFrequencyTypeOptions',
inMultiplesOfDepositTermTypeId: 'periodFrequencyTypeOptions'
};
private static readonly GL_FIELD_TO_MAPPING: Record<string, string> = {
savingsReferenceAccountId: 'savingsReferenceAccount',
savingsControlAccountId: 'savingsControlAccount',
@@ -106,9 +125,9 @@ export class ProductEditDiffDialogComponent implements OnInit {
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]))
.filter(({ key, originalValue }) => !sameValue(originalValue, proposed[key]))
.map(({ key, originalValue }) => ({
label: this.humanizeKey(key),
label: humanizeKey(key),
original: this.displayValue(originalValue, key, original),
proposed: this.displayValue(proposed[key], key, original)
}))
@@ -149,23 +168,37 @@ export class ProductEditDiffDialogComponent implements OnInit {
/**
* 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
* `<field>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.
* table. The product fetched with template=true usually carries the matching option list under a
* predictable `<field>Options` key (accountingRule -> accountingRuleOptions), so the id is resolved
* back to the label the maker actually picked. Two other shapes this also has to cover:
* - a field ending in "...TypeId" whose list drops the "Id" (preClosurePenalInterestOnTypeId ->
* preClosurePenalInterestOnTypeOptions), and
* - a field resolving against a shared list that isn't named after the field at all
* (OPTIONS_KEY_ALIASES).
* All three are tried in turn; returns null only when none of them find a match, so the id is
* shown as-is rather than silently wrong.
*/
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 candidateKeys = [
ProductEditDiffDialogComponent.OPTIONS_KEY_ALIASES[fieldKey],
`${fieldKey}Options`,
fieldKey.endsWith('Id') ? `${fieldKey.slice(0, -2)}Options` : null
].filter((key): key is string => !!key);
for (const optionsKey of candidateKeys) {
const options = template?.[optionsKey];
if (!Array.isArray(options)) {
continue;
}
const match = options.find((option: any) => String(option?.id) === String(id));
if (match) {
return match.value ?? match.name ?? match.code ?? null;
}
}
const match = options.find((option: any) => String(option?.id) === String(id));
if (!match) {
return null;
}
return match.value ?? match.name ?? match.code ?? null;
return null;
}
/**
@@ -173,58 +206,11 @@ export class ProductEditDiffDialogComponent implements OnInit {
* 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);
const asDate = toIsoDate(value);
if (asDate !== null) {
return asDate;
}
@@ -248,14 +234,4 @@ export class ProductEditDiffDialogComponent implements OnInit {
// 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);
}
}
@@ -48,7 +48,6 @@ const USER_FIELD_LABELS: Record<string, string> = {
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',
@@ -59,10 +59,10 @@
<tr>
<td class="col-field">{{ r.label }}</td>
@if (data.requestType === 'UPDATE_USER') {
<td class="col-curr">{{ r.current != null && r.current !== '' ? r.current : '—' }}</td>
<td class="col-curr">{{ displayValue(r.current) }}</td>
}
<td class="col-prop" [class.highlight-change]="data.requestType === 'UPDATE_USER' && r.current !== r.value">
{{ r.value != null && r.value !== '' ? r.value : '—' }}
{{ displayValue(r.value) }}
</td>
</tr>
}
@@ -17,6 +17,7 @@ import {
} from '@angular/material/dialog';
import { CdkScrollable } from '@angular/cdk/scrolling';
import { STANDALONE_SHARED_IMPORTS } from 'app/standalone-shared.module';
import { displayScalar } from 'app/tasks/command-diff.util';
export interface UserEditCompareRow {
key: string;
@@ -54,4 +55,7 @@ export interface UserEditCompareDialogData {
export class UserEditCompareDialogComponent {
dialogRef = inject<MatDialogRef<UserEditCompareDialogComponent>>(MatDialogRef);
data = inject<UserEditCompareDialogData>(MAT_DIALOG_DATA);
/** Shared with the generic maker-checker diff screens so booleans/nulls render the same way everywhere. */
displayValue = displayScalar;
}
@@ -90,26 +90,6 @@
<fa-icon icon="edit" class="ac-tab__ic"></fa-icon>
<span>Pending Account Edits</span>
</a>
<!-- New share accounts awaiting approval. Next to Pending Share Account Edits, since the two are
the share equivalents of Pending Account and Pending Account Edits. -->
<a
class="ac-tab"
routerLink="./pending-share-accounts"
routerLinkActive="ac-tab--active"
*mifosxHasPermission="'ALL_FUNCTIONS_READ'"
>
<fa-icon icon="hourglass-half" class="ac-tab__ic"></fa-icon>
<span>Pending Share Accounts</span>
</a>
<a
class="ac-tab"
routerLink="./pending-share-account-edits"
routerLinkActive="ac-tab--active"
*mifosxHasPermission="'ALL_FUNCTIONS_READ'"
>
<fa-icon icon="edit" class="ac-tab__ic"></fa-icon>
<span>Pending Share Account Edits</span>
</a>
<a
class="ac-tab"
routerLink="./pending-client-edits"
+332
View File
@@ -0,0 +1,332 @@
/**
* Copyright since 2025 Mifos Initiative
*
* This Source Code Form is subject to the terms of the Mozilla Public
* License, v. 2.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 comparison helpers for maker-checker review screens.
*
* Extracted verbatim from ProductEditDiffDialogComponent, which had already solved the awkward part:
* a naive comparison of a submitted command against the stored entity reports almost every field as
* changed, because the two sides encode the same value differently. The normalisation below is what
* keeps a diff down to the fields that genuinely moved:
*
* - dates arrive as `[yyyy, m, d]` from the API but as `"yyyy-MM-dd"` in the payload
* - option/enum fields come back as `{ id, value }` but are submitted as a bare id
* - `null`, `undefined`, `''`, `[]` and `{}` all mean "not set"
* - numeric strings and numbers are the same value
*
* Product-specific concerns (GL account mappings, `<field>Options` lookups, currency aliases) stay in
* the product dialog - they have no meaning for a role or a client.
*/
/**
* Keys that are never a setting a checker reviews, in two groups.
*
* The first is request plumbing - how the command was encoded, plus the identifiers naming the record
* it applies to, both of which the screen already shows.
*
* The second is values derived from another field rather than entered. An age is computed from
* `dateOfBirth` at the moment it is read, so a command submitted last month always disagrees with the
* entity read today - a difference caused by the calendar, not by the maker. The field the maker can
* actually change (`dateOfBirth`) is still compared, so a real edit is never hidden by this.
*/
export const COMMAND_NOISE_KEYS: ReadonlySet<string> = new Set([
// request plumbing
'locale',
'dateFormat',
'monthDayFormat',
'id',
'productId',
'savingsProductId',
// derived from dateOfBirth
'ageYears',
'ageMonths',
'ageDays',
'isMinor',
'isSeniorCitizen'
]);
const MONTH_NAMES = [
'january',
'february',
'march',
'april',
'may',
'june',
'july',
'august',
'september',
'october',
'november',
'december'
];
/**
* Recognises every date shape these two sides use and returns "yyyy-MM-dd", or null if not a date.
*
* Three encodings are in play for the same day:
* - `[yyyy, m, d]` what the read APIs return
* - `"yyyy-MM-dd"` what some forms submit
* - `"29 June 2026"` what Fineract commands carry, per the payload's own `dateFormat`
*
* Missing the third one makes every dated field read as changed on every request, which is exactly
* the noise this whole comparison exists to remove.
*/
export function 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') {
return null;
}
if (/^\d{4}-\d{2}-\d{2}$/.test(value)) {
return value;
}
const spelled = /^(\d{1,2})\s+([A-Za-z]+)\s+(\d{4})$/.exec(value.trim());
if (spelled) {
const day = Number(spelled[1]);
const monthName = spelled[2].toLowerCase();
const monthIndex = MONTH_NAMES.findIndex((name) => name === monthName || name.slice(0, 3) === monthName);
if (monthIndex !== -1) {
return `${spelled[3]}-${String(monthIndex + 1).padStart(2, '0')}-${String(day).padStart(2, '0')}`;
}
}
return null;
}
/**
* Folds a value into the one form both sides of a comparison can be measured in. See the class
* comment above for why each case exists.
*/
export function normalizeValue(value: any): any {
if (value === null || value === undefined || value === '') {
return null;
}
const asDate = toIsoDate(value);
if (asDate !== null) {
return asDate;
}
if (Array.isArray(value)) {
return value.length === 0 ? null : value.map((item) => normalizeValue(item));
}
if (typeof value === 'object') {
if (value.id !== undefined) {
return normalizeValue(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;
}
/**
* True when two values mean the same thing.
*
* The structural comparison handles most cases, but the two sides of a maker-checker review come
* from different APIs that expand the same option differently: `/audits/{id}` returns an enum as its
* label ("Daily") while the entity's own read API returns `{ id, code, value }`. Those normalise to
* different things yet are the same setting, so a second pass asks the question that actually
* matters to a checker - would these two render identically on screen? If they would, there is
* nothing to report.
*/
export function sameValue(a: any, b: any): boolean {
if (JSON.stringify(normalizeValue(a)) === JSON.stringify(normalizeValue(b))) {
return true;
}
return displayScalar(a) === displayScalar(b);
}
/** camelCase / PascalCase / snake_case field name to a spaced, capitalised label. */
export function humanizeKey(key: string): string {
const spaced = String(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);
}
/**
* Renders one value for display, without any entity-specific option lookup. Arrays and objects are
* summarised rather than dumped - a review row states *that* a collection changed; the detail belongs
* in the entity's own screen.
*/
export function displayScalar(value: any): string {
if (value === null || value === undefined || value === '') {
return '—';
}
const asDate = 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 '—';
}
if (value.name && value.glCode) {
return `${value.name} (${value.glCode})`;
}
return value.value ?? value.name ?? value.code ?? (value.id !== undefined ? String(value.id) : '—');
}
if (typeof value === 'boolean') {
return value ? 'Yes' : 'No';
}
return String(value);
}
/**
* Sections a CREATE payload is bucketed into, so a checker reads a submitted product the way it was
* entered rather than as one flat run of fields. Matching is by key name, tried in order, so the
* grouping works for any entity - a role or a GL account simply lands everything in "Details".
*/
const CREATE_SECTIONS: { title: string; matches: RegExp }[] = [
{ title: 'Accounting', matches: /account(ing)?|glaccount|writeOff|receivable|payable|suspense|escheat|fund/i },
{ title: 'Charges & Fees', matches: /charge|fee|penalt|tax/i },
{
title: 'Interest & Terms',
matches: /interest|term|period|lockin|compound|annualis|maturity|deposit(Amount|Min|Max)/i
},
{ title: 'Balances & Limits', matches: /balance|overdraft|limit|amount|minimum|maximum|multiplesOf|decimal/i },
{ title: 'Facilities & Features', matches: /facility|facilities|nominee|nomination|alert|banking|card|withdrawal/i }
];
const CREATE_DEFAULT_SECTION = 'General';
/** One named group of payload keys for a CREATE review. */
export interface CreateSection {
title: string;
keys: string[];
}
/**
* Buckets a CREATE payload's keys into readable sections. Keys the sections do not recognise fall
* into "Details", which is also where everything lands for entities that have no product-like shape.
* Empty sections are dropped, and section order is stable so the same entity always reads the same.
*/
export function groupCreateFields(payload: Record<string, any>, ignoredKeys?: ReadonlySet<string>): CreateSection[] {
const ignored = ignoredKeys ?? COMMAND_NOISE_KEYS;
const buckets = new Map<string, string[]>();
buckets.set(CREATE_DEFAULT_SECTION, []);
CREATE_SECTIONS.forEach((section) => buckets.set(section.title, []));
Object.keys(payload ?? {})
.filter((key) => !ignored.has(key))
.forEach((key) => {
const section = CREATE_SECTIONS.find((candidate) => candidate.matches.test(key));
buckets.get(section ? section.title : CREATE_DEFAULT_SECTION).push(key);
});
const ordered = [
CREATE_DEFAULT_SECTION,
...CREATE_SECTIONS.map((section) => section.title)
];
return ordered
.map((title) => ({ title, keys: buckets.get(title) ?? [] }))
.filter((section) => section.keys.length > 0);
}
/**
* Reads the current value for a payload key, allowing for the two naming conventions the read APIs
* use that the command payload does not:
*
* - lifecycle dates live under `timeline` (`timeline.submittedOnDate`)
* - a payload `<field>Id` is usually the expanded `<field>` object on the entity
*
* Returns `undefined` when the key cannot be located at all, which the caller reports honestly
* rather than claiming the field was newly added.
*/
export function currentValueFor(current: any, key: string): any {
if (current && key in current) {
return current[key];
}
if (current?.timeline && key in current.timeline) {
return current.timeline[key];
}
if (key.endsWith('Id')) {
const base = key.slice(0, -2);
if (current && base in current) {
return current[base];
}
}
return undefined;
}
/**
* How a single field moved between the stored entity and the submitted command.
*
* `SUBMITTED` is not a change: it marks a field the command carries but which could not be located
* on the entity, so no before-value exists to compare against. Calling those ADDED would tell the
* checker something untrue.
*/
export type ChangeKind = 'ADDED' | 'REMOVED' | 'MODIFIED' | 'SUBMITTED';
export interface CommandChange {
label: string;
kind: ChangeKind;
oldValue: string;
newValue: string;
}
/**
* Compares a submitted command payload against the entity as it stands today, returning only the
* fields that actually moved.
*
* Only keys present in the payload are considered: a command says what the maker submitted, so a
* field absent from it was not part of the request and must not be reported as removed.
*/
export function diffCommandAgainstEntity(
submitted: Record<string, any>,
current: Record<string, any>,
options?: { ignoredKeys?: ReadonlySet<string>; currentValueFor?: (key: string) => any }
): CommandChange[] {
const ignored = options?.ignoredKeys ?? COMMAND_NOISE_KEYS;
const readCurrent = options?.currentValueFor ?? ((key: string) => currentValueFor(current, key));
const knownOnEntity = (key: string) =>
!!current && (key in current || (!!current.timeline && key in current.timeline) || `${key}Id` in current);
const rank: Record<ChangeKind, number> = { MODIFIED: 0, ADDED: 1, REMOVED: 2, SUBMITTED: 3 };
return (
Object.keys(submitted ?? {})
.filter((key) => !ignored.has(key))
.map((key) => ({ key, before: readCurrent(key), after: submitted[key] }))
.filter(({ before, after }) => !sameValue(before, after))
.map(({ key, before, after }) => {
const hadBefore = normalizeValue(before) !== null;
const hasAfter = normalizeValue(after) !== null;
// No before-value *and* no such field on the entity means there is nothing to compare, which is
// different from a field that exists and was empty.
const kind: ChangeKind = !hadBefore
? knownOnEntity(key) || !!options?.currentValueFor
? 'ADDED'
: 'SUBMITTED'
: !hasAfter
? 'REMOVED'
: 'MODIFIED';
return {
label: humanizeKey(key),
kind,
oldValue: displayScalar(before),
newValue: displayScalar(after)
};
})
// Real changes first, then the fields that could only be reported as submitted.
.sort((a, b) => rank[a.kind] - rank[b.kind] || a.label.localeCompare(b.label))
);
}
+224
View File
@@ -0,0 +1,224 @@
/**
* Copyright since 2025 Mifos Initiative
*
* This Source Code Form is subject to the terms of the Mozilla Public
* License, v. 2.0. If a copy of the MPL was not distributed with this
* file, You can obtain one at http://mozilla.org/MPL/2.0/.
*/
import { Observable } from 'rxjs';
import { ProductsService } from 'app/products/products.service';
import { ClientsService } from 'app/clients/clients.service';
import { SavingsService } from 'app/savings/savings.service';
import { FixedDepositsService } from 'app/deposits/fixed-deposits/fixed-deposits.service';
import { RecurringDepositsService } from 'app/deposits/recurring-deposits/recurring-deposits.service';
import { SystemService } from 'app/system/system.service';
import { AccountingService } from 'app/accounting/accounting.service';
import { UsersService } from 'app/users/users.service';
import { OrganizationService } from 'app/organization/organization.service';
/**
* The services a maker-checker review screen needs in order to read an entity's current state.
* Passed in rather than injected here so this stays a plain lookup table with no Angular wiring.
*/
export interface EntityFetchServices {
products: ProductsService;
clients: ClientsService;
savings: SavingsService;
fixedDeposits: FixedDepositsService;
recurringDeposits: RecurringDepositsService;
system: SystemService;
accounting: AccountingService;
users: UsersService;
organization: OrganizationService;
}
/**
* Entities with exactly one row in the whole tenant (a global settings screen, not a record with an
* id) - Working Days being the first. These commands carry no resourceId at all, so the normal
* "no id, no lookup" guard below is relaxed for exactly this set; every other entity's behavior is
* unchanged.
*/
const SINGLETON_ENTITIES: ReadonlySet<string> = new Set(['WORKINGDAYS']);
/**
* Maps a maker-checker `entityName` to the existing API that returns that entity as it stands today,
* so an UPDATE can be shown as OLD -> NEW and a DELETE can show what is about to be removed.
*
* Every entry reuses an API the application already calls elsewhere - nothing new was added on the
* backend. Products are fetched with `template=true` because that response also carries the option
* lists (`accountingRuleOptions`, `interestCompoundingPeriodTypeOptions`, ...) the payload's bare ids
* refer to.
*
* An entity absent from this table simply has no current-state lookup; callers fall back to rendering
* the submitted payload, which is what every entity did before this table existed.
*/
const ENTITY_FETCHERS: Record<string, (id: string, s: EntityFetchServices) => Observable<any>> = {
// ---- Products ----------------------------------------------------------------
SAVINGSPRODUCT: (id, s) => s.products.getSavingProduct(id, true),
CURRENTPRODUCT: (id, s) => s.products.getCurrentProduct(id, true),
SHAREPRODUCT: (id, s) => s.products.getShareProduct(id, true),
LOANPRODUCT: (id, s) => s.products.getLoanProduct(id, true),
FIXEDDEPOSITPRODUCT: (id, s) => s.products.getFixedDepositProductAndTemplate(id),
RECURRINGDEPOSITPRODUCT: (id, s) => s.products.getRecurringDepositProduct(id, true),
CHARGE: (id, s) => s.products.getCharge(id, true),
// ---- Accounts ----------------------------------------------------------------
SAVINGSACCOUNT: (id, s) => s.savings.getSavingsAccountData(id),
// Deposit accounts must be read through their own endpoints: /savingsaccounts omits the deposit
// terms (depositAmount, depositPeriod, charts, ...), so comparing against it reports every one of
// those as newly added.
FIXEDDEPOSITACCOUNT: (id, s) => s.fixedDeposits.getFixedDepositsAccountData(id),
RECURRINGDEPOSITACCOUNT: (id, s) => s.recurringDeposits.getRecurringDepositsAccountAndTemplate(id),
// ---- People and administration ----------------------------------------------
CLIENT: (id, s) => s.clients.getClientData(id, true),
ROLE: (id, s) => s.system.getRole(id),
USER: (id, s) => s.users.getUser(id),
GLACCOUNT: (id, s) => s.accounting.getGlAccount(id, true),
// ---- Singleton settings screens ----------------------------------------------
// No resourceId - the command applies to the one Working Days row the tenant has.
WORKINGDAYS: (id, s) => s.organization.getWorkingDays()
};
/** Whether this screen can read the entity's current state, and therefore produce a real diff. */
export function canFetchEntity(entityName: string | undefined | null): boolean {
return !!ENTITY_FETCHERS[(entityName ?? '').toUpperCase()];
}
/**
* Returns the entity as it stands today, or null when there is no mapping for it or no resource to
* read - in which case the caller keeps showing the submitted payload rather than failing.
*/
export function fetchCurrentEntity(
entityName: string | undefined | null,
resourceId: string | number | undefined | null,
services: EntityFetchServices
): Observable<any> | null {
const key = (entityName ?? '').toUpperCase();
const fetcher = ENTITY_FETCHERS[key];
if (!fetcher) {
return null;
}
const hasResourceId = resourceId !== null && resourceId !== undefined && resourceId !== '';
if (!hasResourceId && !SINGLETON_ENTITIES.has(key)) {
return null;
}
return fetcher(String(resourceId ?? ''), services);
}
/**
* The handful of fields worth showing for a DELETE, whose payload is empty - the checker needs to
* recognise *which* record is about to go, not read its full configuration. Keys are tried in order
* and the first present one wins; anything not listed falls back to a generic set.
*/
const DELETE_SUMMARY_KEYS: Record<string, string[]> = {
SAVINGSPRODUCT: [
'name',
'shortName',
'description',
'currency'
],
CURRENTPRODUCT: [
'name',
'shortName',
'description',
'currency'
],
SHAREPRODUCT: [
'name',
'shortName',
'description',
'currency'
],
LOANPRODUCT: [
'name',
'shortName',
'description',
'currency'
],
FIXEDDEPOSITPRODUCT: [
'name',
'shortName',
'description',
'currency'
],
RECURRINGDEPOSITPRODUCT: [
'name',
'shortName',
'description',
'currency'
],
CHARGE: [
'name',
'currency',
'amount',
'chargeAppliesTo',
'chargeTimeType'
],
CLIENT: [
'displayName',
'accountNo',
'officeName',
'status',
'mobileNo',
'emailAddress'
],
SAVINGSACCOUNT: [
'accountNo',
'clientName',
'savingsProductName',
'status',
'currency'
],
FIXEDDEPOSITACCOUNT: [
'accountNo',
'clientName',
'savingsProductName',
'status',
'currency'
],
RECURRINGDEPOSITACCOUNT: [
'accountNo',
'clientName',
'savingsProductName',
'status',
'currency'
],
ROLE: [
'name',
'description',
'disabled'
],
USER: [
'username',
'firstname',
'lastname',
'email',
'officeName'
],
GLACCOUNT: [
'name',
'glCode',
'type',
'usage',
'description'
]
};
const GENERIC_SUMMARY_KEYS = [
'name',
'displayName',
'accountNo',
'shortName',
'username',
'description',
'status'
];
/** Field names worth surfacing for the entity about to be deleted. */
export function deleteSummaryKeys(entityName: string | undefined | null): string[] {
return DELETE_SUMMARY_KEYS[(entityName ?? '').toUpperCase()] ?? GENERIC_SUMMARY_KEYS;
}
+8 -2
View File
@@ -108,7 +108,10 @@ const routes: Routes = [
component: PendingAccountComponent,
data: { title: 'Pending Account' },
resolve: {
pendingAccountsData: GetPendingAccounts
pendingAccountsData: GetPendingAccounts,
// Share accounts awaiting approval now render inside this same screen instead of their
// own tab - reuses the same resolver the standalone route below still uses.
pendingShareAccountsData: GetPendingShareAccounts
}
},
{
@@ -147,7 +150,10 @@ const routes: Routes = [
data: { title: 'Pending Account Edits' },
resolve: {
pendingSavingsAccountEditsData: GetPendingSavingsAccountEdits,
pendingCurrentAccountEditsData: GetPendingCurrentAccountEdits
pendingCurrentAccountEditsData: GetPendingCurrentAccountEdits,
// Share account edits awaiting approval now render inside this same screen instead of
// their own tab - reuses the same resolver the standalone route below still uses.
pendingShareAccountEditsData: GetPendingShareAccountEdits
}
},
{
@@ -39,9 +39,6 @@
<button type="button" class="vci-btn vci-btn--approve" (click)="approveChecker()">
<fa-icon icon="check"></fa-icon><span>{{ 'labels.buttons.Approve' | translate }}</span>
</button>
<button type="button" class="vci-btn vci-btn--delete" (click)="deleteChecker()">
<fa-icon icon="trash"></fa-icon><span>{{ 'labels.buttons.Delete' | translate }}</span>
</button>
<button type="button" class="vci-btn vci-btn--reject" (click)="rejectChecker()">
<fa-icon icon="times"></fa-icon><span>{{ 'labels.buttons.Reject' | translate }}</span>
</button>
@@ -74,7 +71,9 @@
</dd>
</div>
<div class="vci-kv">
<dt>{{ 'labels.inputs.Entity' | translate }}</dt>
<!-- Not `labels.inputs.Entity`: this project maps that key to "Non-Individual" for the client
legal-form screens, which reads as nonsense against a value like GLACCOUNT. -->
<dt>{{ 'labels.inputs.Entity Type' | translate }}</dt>
<dd>{{ checkerInboxDetail.entityName }}</dd>
</div>
@if (checkerInboxDetail.resourceId) {
@@ -108,9 +107,61 @@
</dl>
</div>
@if (displayJSONData) {
@if (comparing) {
<div class="vci-loading">
<fa-icon icon="spinner" class="vci-loading__ic"></fa-icon>
<span>Comparing against current values…</span>
</div>
}
<!-- UPDATE: only the fields this command actually moves, as OLD -> NEW. -->
@if (showChanges) {
<div class="vci-section">
<div class="vci-section__title">{{ 'labels.heading.Command' | translate }}</div>
<div class="vci-section__title">Changes Requested ({{ changes.length }})</div>
@if (changes.length === 0) {
<p class="vci-nochange">This request does not change any value.</p>
} @else {
<div class="vci-changes">
@for (change of changes; track $index) {
<div class="vci-change">
<div class="vci-change__head">
<span class="vci-change__label">{{ change.label }}</span>
<span class="vci-change__kind vci-change__kind--{{ change.kind.toLowerCase() }}">{{
change.kind
}}</span>
</div>
<div class="vci-change__values">
<span class="vci-change__old">{{ change.oldValue }}</span>
<fa-icon icon="arrow-right" class="vci-change__arrow"></fa-icon>
<span class="vci-change__new">{{ change.newValue }}</span>
</div>
</div>
}
</div>
}
</div>
}
<!-- CREATE: submitted data grouped into readable sections. -->
@if (!showChanges && createSections.length > 0) {
@for (section of createSections; track section.title) {
<div class="vci-section">
<div class="vci-section__title">{{ section.title }}</div>
<dl class="vci-grid">
@for (field of section.fields; track $index) {
<ng-container *ngTemplateOutlet="commandField; context: { $implicit: field }"></ng-container>
}
</dl>
</div>
}
}
<!-- DELETE summary, role-permission changes, and the fallback payload all render here. -->
@if (!showChanges && createSections.length === 0 && displayJSONData) {
<div class="vci-section">
<div class="vci-section__title">
{{ deletingEntity ? 'Record To Be Deleted' : ('labels.heading.Command' | translate) }}
</div>
<dl class="vci-grid">
@for (field of commandFields; track $index) {
<ng-container *ngTemplateOutlet="commandField; context: { $implicit: field }"></ng-container>
@@ -304,6 +304,97 @@ $bg-page: #f8fafc;
color: $text-soft;
}
/* ── UPDATE review: one row per field that actually moved ──────────────────── */
.vci-changes {
display: flex;
flex-direction: column;
gap: 1px;
background: $border;
}
.vci-change {
background: #fff;
padding: 11px 16px;
&__head {
display: flex;
align-items: center;
gap: 10px;
margin-bottom: 5px;
}
&__label {
font-size: 11px;
font-weight: 700;
text-transform: uppercase;
letter-spacing: 0.06em;
color: $text-soft;
}
&__kind {
font-size: 9.5px;
font-weight: 700;
letter-spacing: 0.07em;
padding: 1px 7px;
border-radius: 2px;
&--modified {
background: #e0f2fe;
color: #0369a1;
}
&--added {
background: #dcfce7;
color: #15803d;
}
&--removed {
background: #fee2e2;
color: #991b1b;
}
/* Not a change: the entity has no such field, so there was no before-value to compare. */
&--submitted {
background: #eef1f4;
color: #5b6673;
}
}
&__values {
display: flex;
align-items: center;
gap: 10px;
flex-wrap: wrap;
font-size: 14px;
min-width: 0;
}
&__old {
color: $text-soft;
text-decoration: line-through;
overflow-wrap: anywhere;
}
&__arrow {
color: $text-soft;
font-size: 11px;
flex: none;
}
&__new {
color: $text-dark;
font-weight: 600;
overflow-wrap: anywhere;
}
}
.vci-nochange {
margin: 0;
padding: 16px;
font-size: 13.5px;
color: $text-soft;
}
.vci-tag {
display: inline-flex;
align-items: center;
@@ -14,6 +14,7 @@ import { MatDialog, MatDialogRef, MAT_DIALOG_DATA } from '@angular/material/dial
/** Custom Services */
import { TasksService } from '../tasks.service';
import { SystemService } from 'app/system/system.service';
/** Dialog Components */
import { ConfirmationDialogComponent } from 'app/shared/confirmation-dialog/confirmation-dialog.component';
@@ -23,6 +24,24 @@ 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';
import {
CommandChange,
diffCommandAgainstEntity,
displayScalar,
groupCreateFields,
humanizeKey
} from '../command-diff.util';
import { deleteSummaryKeys, EntityFetchServices, fetchCurrentEntity } from '../maker-checker-entity-fetch';
/** Services the entity lookup table needs; assembled once and handed to the shared fetcher. */
import { ProductsService } from 'app/products/products.service';
import { ClientsService } from 'app/clients/clients.service';
import { SavingsService } from 'app/savings/savings.service';
import { AccountingService } from 'app/accounting/accounting.service';
import { UsersService } from 'app/users/users.service';
import { FixedDepositsService } from 'app/deposits/fixed-deposits/fixed-deposits.service';
import { RecurringDepositsService } from 'app/deposits/recurring-deposits/recurring-deposits.service';
import { OrganizationService } from 'app/organization/organization.service';
/**
* One row of the rendered Command payload. The dialog turns the parsed command JSON into a tree of
@@ -35,6 +54,17 @@ export type CommandField =
| { label: string; kind: 'object'; fields: CommandField[] }
| { label: string; kind: 'list'; items: CommandField[] };
/**
* Transport-level fields every Fineract command carries. They describe how the request was encoded,
* not what the maker actually changed, so they are dropped before the payload is shown - present in
* roughly three quarters of all stored commands, and pure noise to a checker.
*/
const NOISE_KEYS = new Set([
'locale',
'dateFormat',
'monthDayFormat'
]);
/**
* 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
@@ -76,6 +106,24 @@ const LABEL_ACRONYMS = new Set([
'nef'
]);
/**
* Fields that identify one element of an array, tried in order. Keeping this as a list of names rather
* than per-entity rules means an unfamiliar collection still gets a readable heading if it happens to
* carry any of them.
*/
const ENTRY_LABEL_KEYS = [
'limitType',
'name',
'displayName',
'chargeName',
'title',
'label',
'type',
'description',
'code',
'value'
];
@Component({
selector: 'mifosx-view-checker-inbox',
templateUrl: './view-checker-inbox.component.html',
@@ -93,6 +141,30 @@ export class ViewCheckerInboxComponent {
private router = inject(Router);
private translateService = inject(TranslateService);
private tasksService = inject(TasksService);
private systemService = inject(SystemService);
private productsService = inject(ProductsService);
private clientsService = inject(ClientsService);
private savingsService = inject(SavingsService);
private accountingService = inject(AccountingService);
private usersService = inject(UsersService);
private fixedDepositsService = inject(FixedDepositsService);
private recurringDepositsService = inject(RecurringDepositsService);
private organizationService = inject(OrganizationService);
/** Bundle handed to the shared entity lookup, so that table stays free of Angular injection. */
private get entityFetchServices(): EntityFetchServices {
return {
products: this.productsService,
clients: this.clientsService,
savings: this.savingsService,
system: this.systemService,
accounting: this.accountingService,
users: this.usersService,
fixedDeposits: this.fixedDepositsService,
recurringDeposits: this.recurringDepositsService,
organization: this.organizationService
};
}
/** 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. */
@@ -122,6 +194,17 @@ export class ViewCheckerInboxComponent {
*/
commandFields: CommandField[] = [];
/** UPDATE review: the fields this command actually moves, as OLD -> NEW. */
changes: CommandChange[] = [];
/** True once the current entity was read and a real comparison is on screen. */
showChanges = false;
/** True while the current entity is being fetched for comparison. */
comparing = false;
/** True when the screen is describing a record that is about to be deleted. */
deletingEntity = false;
/** CREATE review: submitted data bucketed into readable sections. */
createSections: { title: string; fields: CommandField[] }[] = [];
/** camelCase / snake_case key -> readable label, e.g. `upiFacility` -> "UPI Facility". */
private labelFor(key: string): string {
const spaced = String(key ?? '')
@@ -156,6 +239,27 @@ export class ViewCheckerInboxComponent {
* 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.
*/
/**
* Names one element of an array of objects.
*
* Numbering them "Account Facility Limits 1 ... 10" tells a checker nothing - the position in a JSON
* array is not a fact about the record. Most entries carry a field that identifies them (`limitType`,
* a charge's `name`, a chart's `description`), so that value is used as the heading and the ordinal
* is only a fallback for entries that have nothing identifying at all.
*/
private entryLabel(item: Record<string, any>, listLabel: string, index: number): string {
for (const key of ENTRY_LABEL_KEYS) {
const candidate = item?.[key];
if (typeof candidate === 'string' && candidate.trim() !== '') {
return candidate.trim();
}
if (typeof candidate === 'number') {
return String(candidate);
}
}
return `${listLabel} ${index + 1}`;
}
private toField(label: string, value: any): CommandField {
if (Array.isArray(value)) {
return {
@@ -163,7 +267,7 @@ export class ViewCheckerInboxComponent {
kind: 'list',
items: value.map((item, index) =>
item !== null && typeof item === 'object'
? this.toField(`${label} ${index + 1}`, item)
? this.toField(this.entryLabel(item, label, index), item)
: { label: '', kind: 'scalar' as const, text: this.scalarText(item) }
)
};
@@ -172,7 +276,9 @@ export class ViewCheckerInboxComponent {
return {
label,
kind: 'object',
fields: Object.keys(value).map((key) => this.toField(this.labelFor(key), value[key]))
fields: Object.keys(value)
.filter((key) => !NOISE_KEYS.has(key))
.map((key) => this.toField(this.labelFor(key), value[key]))
};
}
return { label, kind: 'scalar', text: this.scalarText(value) };
@@ -182,7 +288,9 @@ export class ViewCheckerInboxComponent {
if (!payload || typeof payload !== 'object' || Array.isArray(payload)) {
return [];
}
return Object.keys(payload).map((key) => this.toField(this.labelFor(key), payload[key]));
return Object.keys(payload)
.filter((key) => !NOISE_KEYS.has(key))
.map((key) => this.toField(this.labelFor(key), payload[key]));
}
/** Checker Inbox Details Data */
@@ -238,6 +346,14 @@ export class ViewCheckerInboxComponent {
private applyDetail(detail: any): void {
this.checkerInboxDetail = detail;
// Angular reuses this component when only the route's id changes, so the previous request's review
// state has to be cleared explicitly — otherwise a DELETE opened after an UPDATE keeps showing the
// UPDATE's changes.
this.changes = [];
this.showChanges = false;
this.comparing = false;
this.deletingEntity = false;
this.createSections = [];
// 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 {
@@ -247,6 +363,274 @@ export class ViewCheckerInboxComponent {
}
this.displayJSONData = !_.isEmpty(this.jsondata);
this.commandFields = this.buildCommandFields(this.jsondata);
// Role permissions get their own treatment: the payload is the role's whole permission map, so a
// field-by-field diff would be 1,100 rows rather than the handful that moved.
if (this.isRolePermissionsCommand(detail)) {
this.loadRolePermissionChanges(detail.resourceId, this.jsondata.permissions);
return;
}
const action = (detail?.actionName ?? '').toUpperCase();
if (action === 'UPDATE') {
this.loadUpdateChanges(detail);
} else if (action === 'DELETE') {
this.loadDeleteSummary(detail);
} else if (action === 'CREATE') {
this.createSections = this.buildCreateSections(this.jsondata);
if ((detail?.entityName ?? '').toUpperCase() === 'GLACCOUNT') {
this.resolveGlAccountCreateLabels(detail, this.jsondata);
}
}
}
/**
* UPDATE: read the entity as it stands today and show only what the command actually moves.
*
* Several screens submit their entire form regardless of what the maker touched - a fixed deposit
* account update posts 28 fields every time - so without this the checker cannot tell which one
* changed. If the entity cannot be read the submitted payload stays on screen, which is what every
* entity showed before.
*/
private loadUpdateChanges(detail: any): void {
const request$ = fetchCurrentEntity(detail?.entityName, detail?.resourceId, this.entityFetchServices);
if (!request$) {
return;
}
this.comparing = true;
request$.subscribe({
next: (current: any) => {
// A slow response for a record the user has already navigated away from must not overwrite the
// record now on screen.
if (!this.isCurrent(detail)) {
return;
}
this.comparing = false;
this.changes = diffCommandAgainstEntity(this.jsondata ?? {}, current ?? {});
this.showChanges = true;
},
error: () => {
this.comparing = false;
}
});
}
/**
* DELETE: the payload is empty for every entity, so the command alone tells the checker nothing.
* Reads the record instead and shows the few fields that identify it.
*/
private loadDeleteSummary(detail: any): void {
const request$ = fetchCurrentEntity(detail?.entityName, detail?.resourceId, this.entityFetchServices);
if (!request$) {
return;
}
this.comparing = true;
request$.subscribe({
next: (current: any) => {
if (!this.isCurrent(detail)) {
return;
}
this.comparing = false;
const keys = deleteSummaryKeys(detail?.entityName);
const fields = keys
.filter((key) => current?.[key] !== undefined && current?.[key] !== null && current?.[key] !== '')
.map((key) => ({
label: humanizeKey(key),
kind: 'scalar' as const,
text: displayScalar(current[key])
}));
if (fields.length > 0) {
this.commandFields = fields;
this.displayJSONData = true;
this.deletingEntity = true;
}
},
error: () => {
this.comparing = false;
}
});
}
/** Whether `detail` is still the request on screen, i.e. an in-flight lookup is not stale. */
private isCurrent(detail: any): boolean {
return this.checkerInboxDetail?.id === detail?.id;
}
/** CREATE: nothing to compare against, so the submitted data is grouped rather than diffed. */
private buildCreateSections(payload: any): { title: string; fields: CommandField[] }[] {
if (!payload || typeof payload !== 'object' || Array.isArray(payload)) {
return [];
}
return groupCreateFields(payload).map((section) => ({
title: section.title,
fields: section.keys.map((key) => this.toField(this.labelFor(key), payload[key]))
}));
}
/**
* GL Account CREATE: `type`, `usage`, `parentId` and `tagId` arrive as raw numeric ids - meaningless
* to a checker without the chart-of-accounts template, which is the same lookup data the create-GL
* form itself already uses (`create-gl-account.component.ts`). Fetched once, purely for display: the
* stored command payload (`jsondata`) and the already-rendered raw sections are left as they are
* unless the lookup succeeds, so a slow or failed template call never blocks or breaks this screen.
*/
private resolveGlAccountCreateLabels(detail: any, payload: Record<string, any>): void {
this.accountingService.getChartOfAccountsTemplate().subscribe({
next: (template: any) => {
// A slow response for a request the checker has already navigated away from must not overwrite
// whatever is now on screen.
if (!this.isCurrent(detail)) {
return;
}
const resolved = { ...payload };
const typeId = payload.type;
const typeOption = (template?.accountTypeOptions ?? []).find((option: any) => option.id === typeId);
if (typeOption) {
resolved.type = this.translateService.instant(`labels.inputs.accounting.${typeOption.value}`);
}
const usageOption = (template?.usageOptions ?? []).find((option: any) => option.id === payload.usage);
if (usageOption) {
resolved.usage = usageOption.value;
}
const parentAccount = this.glHeaderAccountOptions(template, typeId).find(
(account: any) => account.id === payload.parentId
);
if (parentAccount) {
resolved.parentId = parentAccount.glCode
? `${parentAccount.name} (${parentAccount.glCode})`
: parentAccount.name;
}
const tagOption = this.glTagOptions(template, typeId).find((tag: any) => tag.id === payload.tagId);
if (tagOption) {
resolved.tagId = tagOption.name;
}
this.createSections = this.buildCreateSections(resolved);
},
// Lookup failed - the raw ids already on screen from buildCreateSections() above stay as they are.
error: () => undefined
});
}
/** Header/parent account options are partitioned by GL account type, same as the create-GL form. */
private glHeaderAccountOptions(template: any, typeId: number): any[] {
switch (typeId) {
case 1:
return template?.assetHeaderAccountOptions ?? [];
case 2:
return template?.liabilityHeaderAccountOptions ?? [];
case 3:
return template?.equityHeaderAccountOptions ?? [];
case 4:
return template?.incomeHeaderAccountOptions ?? [];
case 5:
return template?.expenseHeaderAccountOptions ?? [];
default:
return [];
}
}
/** Allowed tag options are partitioned by GL account type, same as the create-GL form. */
private glTagOptions(template: any, typeId: number): any[] {
switch (typeId) {
case 1:
return template?.allowedAssetsTagOptions ?? [];
case 2:
return template?.allowedLiabilitiesTagOptions ?? [];
case 3:
return template?.allowedEquityTagOptions ?? [];
case 4:
return template?.allowedIncomeTagOptions ?? [];
case 5:
return template?.allowedExpensesTagOptions ?? [];
default:
return [];
}
}
/**
* A role permission update posts the role's *entire* permission map - over 1,100 entries, of which
* a handful are actually being changed. Rendering that as-is buries the change completely, so this
* screen fetches the role's current permissions and shows only the difference.
*/
private isRolePermissionsCommand(detail: any): boolean {
return (
(detail?.entityName ?? '').toUpperCase() === 'ROLE' &&
(detail?.actionName ?? '').toUpperCase() === 'PERMISSIONS' &&
detail?.resourceId != null &&
!!this.jsondata?.permissions
);
}
/**
* Replaces the flat permission dump with Granted / Revoked lists, by comparing the submitted map
* against what the role holds today. Anything the maker left untouched is summarised as a count
* rather than listed.
*
* If the role lookup fails the original payload stays on screen - a degraded but complete view is
* better than showing the checker nothing.
*/
private loadRolePermissionChanges(roleId: string | number, submitted: Record<string, boolean>): void {
this.systemService.getRole(roleId).subscribe({
next: (role: any) => {
const current = new Map<string, boolean>(
(role?.permissionUsageData ?? []).map((p: any): [string, boolean] => [
p.code,
!!p.selected
])
);
const granted: string[] = [];
const revoked: string[] = [];
let unchanged = 0;
Object.keys(submitted).forEach((code) => {
const now = !!submitted[code];
const before = current.get(code);
// A code missing from the role lookup has no "before" to compare against, so treat it as
// changed only when it is being switched on.
if (before === undefined) {
if (now) {
granted.push(code);
}
return;
}
if (now === before) {
unchanged++;
} else if (now) {
granted.push(code);
} else {
revoked.push(code);
}
});
this.commandFields = this.buildPermissionChangeFields(granted, revoked, unchanged);
},
error: () => undefined
});
}
private buildPermissionChangeFields(granted: string[], revoked: string[], unchanged: number): CommandField[] {
const fields: CommandField[] = [];
const asList = (label: string, codes: string[]): CommandField => ({
label,
kind: 'list',
items: codes.sort().map((code) => ({ label: '', kind: 'scalar' as const, text: this.labelFor(code) }))
});
fields.push(asList(`Permissions Granted (${granted.length})`, granted));
fields.push(asList(`Permissions Revoked (${revoked.length})`, revoked));
if (granted.length === 0 && revoked.length === 0) {
fields.push({ label: 'Result', kind: 'scalar', text: 'No permission changes in this request.' });
}
fields.push({ label: 'Unchanged Permissions', kind: 'scalar', text: `${unchanged} left as they were` });
return fields;
}
/** "Done" for this screen — closes the popup (telling the list to refresh) when opened as a
@@ -304,23 +688,4 @@ export class ViewCheckerInboxComponent {
}
});
}
/**
* 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.finish();
});
}
});
}
}
-1
View File
@@ -171,7 +171,6 @@ export function tdsDeductees(): TdsDeductee[] {
return liveDeductees;
}
/**
* Statutory date by which tax deducted in a given month must be deposited.
*
+2 -2
View File
@@ -9,8 +9,8 @@
/* tslint:disable */
export default {
'mifos_x': {
'version': '260921',
'hash': '3c363d03'
'version': '260923',
'hash': '3f59cc6b'
},
'allow_switching_backend_instance': true
};
+13
View File
@@ -55,6 +55,19 @@
box-shadow: none;
}
// ===== Payment / Renewal Dialog Panel =====
.pmt-dialog-panel .mat-mdc-dialog-container .mdc-dialog__surface {
padding: 0 !important;
border-radius: 16px !important;
overflow: hidden !important;
box-shadow: 0 20px 40px rgb(15 23 42 / 25%) !important;
}
.pmt-dialog-panel .mat-mdc-dialog-content {
padding: 0 !important;
max-height: none !important;
}
.ipd-dialog .mdc-dialog__surface {
border-radius: 20px !important;
background: transparent !important;