Compare commits

..
Author SHA1 Message Date
anshul 73871a0993 Enforce same-user maker-checker restriction 2026-10-01 12:04:01 +05:30
anshul 7b0cb5bb63 Merge branch 'dev-latest' into feature/bugfix 2026-09-30 16:32:46 +05:30
anshul 646e73d08a Merge branch 'dev-latest' into feature/bugfix 2026-09-29 10:16:58 +05:30
niteshandClaude Sonnet 5 a7ed3883df Merge branch 'feature/bugfix' into dev-latest
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
build-run / Run Lint, Build and Deploy (push) Canceled after 0s
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-28 18:36:31 +05:30
niteshandClaude Sonnet 5 18c3bb6356 fix(current-accounts): show bank/branch letterhead on passbook, matching savings
Current account passbook hardcoded "LincPay Co-operative Bank Ltd." and had
no branch identification line at all, unlike the savings passbook which
pulls the bank name and branch/IFSC/MICR details from the Institution and
Branch Profile. Wire up the same OrganizationService lookup so the current
account statement shows real bank/branch details too.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-28 18:18:35 +05:30
niteshandClaude Sonnet 5 6afa79bd37 fix: correct withdrawal-review account lookup, switch tenant to utkarshbank
- Manager withdrawal-review screen always searched the Savings account
  type regardless of the request's actual account_type, so reviewing a
  Current-account withdrawal failed with "Account not found". Reuse the
  existing SEARCH_ACCOUNT_TYPE_TO_AC_TYP mapping (already used by the
  cashier lookup path) instead of a hardcoded 'SD'.
- Point the UI tenant id at utkarshbank (was lincpay) to match the new
  backend tenant.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-28 17:53:50 +05:30
anshul ec23d57734 Merge branch 'dev-latest' into feature/bugfix 2026-09-28 17:23:53 +05:30
nitesh 192bc7725e Merge branch 'dev-latest' of https://git.lincpay.tech/CBSNexora/cbsnexora into dev-latest 2026-09-28 13:43:14 +05:30
niteshandClaude Sonnet 5 3f6485f652 feat(fd): restrict Payment to Matured accounts, unify its behavior with Dashboard Renew
Payment showed for both Active and Matured FDs, even though an Active FD
already has Premature Close for closing early. Now Matured-only.

That also meant a genuinely matured, past-grace FD closed via the account's
own Payment button (rather than the FD Dashboard's Renew action) silently
skipped crediting its gap-period interest -- the date-locking and
gap-interest preview/crediting were gated behind dashboard-only data, not
the account's actual matured state. Both now key off the same
renewal-preview response regardless of entry point, so Payment and Renew
are functionally identical once an FD has matured, as they should be since
they're already the same dialog component.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-28 13:03:47 +05:30
niteshandClaude Sonnet 5 8d0d8822f0 feat(fd): convert Premature Close to a dialog, with its full calculation shown
Was a full-page form at a dedicated route, reached by navigating away from
the account -- now a popup dialog in the same visual language as the
Payment/Renew dialog, just in this Danger Zone action's own red accent.
Also fixes a real gap in the form it replaces: the premature amount/action
options never loaded until a maker happened to touch the date picker, since
the calculation was only ever triggered on value change, never on open.

Added a breakdown panel showing exactly how the shown Maturity Amount was
reached -- Deposit Amount, Contracted Interest Rate, Pre-Closure Penalty
(when applicable), and the resulting Interest Earned -- using data the
account-view page already had, since the calculation endpoint itself only
ever returns the final number.

Removed the now-dead full-page component/route target it replaces.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-28 13:03:01 +05:30
niteshandClaude Sonnet 5 59e1eeacc3 fix(accounting): show latest journal entries first by default
The table fell back to the backend's own default order (entry_date, id
ascending) until a maker clicked a column header, so the oldest entries
sat at the top on every fresh load. Defaults the table's sort to id
descending instead.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-28 13:02:38 +05:30
anshul 4ca08f6ee9 feat: add new standalone components for system and organization management modules 2026-09-28 12:55:42 +05:30
anshul f451672ce7 Merge branch 'dev-latest' into feature/bugfix 2026-09-28 12:51:26 +05:30
anshul a5ed3fde21 Merge branch 'dev-latest' into feature/bugfix 2026-09-25 11:06:16 +05:30
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
anshul 08a427467a A 2026-09-23 15:51:10 +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
97 changed files with 3503 additions and 602 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",
@@ -539,7 +539,7 @@
</div>
<div class="gl-table-shell">
<mat-table [dataSource]="dataSource" matSort class="gl-mat-table">
<mat-table [dataSource]="dataSource" matSort matSortActive="id" matSortDirection="desc" class="gl-mat-table">
<!-- #ID -->
<ng-container matColumnDef="id">
<mat-header-cell *matHeaderCellDef mat-sort-header class="gl-th">#</mat-header-cell>
@@ -6041,7 +6041,12 @@ export class TransactionEntryComponent implements OnInit, OnDestroy {
this.editRequest = req;
}
const acTyp = 'SD';
// Same bug, same fix as the token-driven withdrawal-slip lookup above (see its comment): the
// request carries its own real account_type (SAVINGS, CURRENT, FIXED_DEPOSIT or
// RECURRING_DEPOSIT), and hardcoding 'SD' here searched every non-savings account on the
// wrong endpoint, so reviewing a Current-account withdrawal always failed with "Account not
// found".
const acTyp = TransactionEntryComponent.SEARCH_ACCOUNT_TYPE_TO_AC_TYP[req.account_type || ''] || 'SD';
const row = this.rows[0];
row.acNo = req.account_no ?? '';
row.acTyp = acTyp;
@@ -1321,6 +1321,11 @@
kycForm.get('kycDocumentNumber')?.hasError('pattern') && kycForm.get('kycDocumentNumber')?.touched
) {
<mat-error>Only letters, numbers, hyphens and slashes allowed</mat-error>
} @else if (
kycForm.get('kycDocumentNumber')?.hasError('aadhaarLast4Mismatch') &&
kycForm.get('kycDocumentNumber')?.touched
) {
<mat-error>Last 4 digits do not match the Aadhaar number entered above</mat-error>
}
</mat-form-field>
@@ -8,7 +8,14 @@
import { Component, Input, OnInit, OnChanges, OnDestroy, SimpleChanges, inject } from '@angular/core';
import { forkJoin, catchError, of } from 'rxjs';
import { UntypedFormBuilder, UntypedFormGroup, Validators, FormsModule } from '@angular/forms';
import {
UntypedFormBuilder,
UntypedFormGroup,
Validators,
FormsModule,
AbstractControl,
ValidationErrors
} from '@angular/forms';
import {
MatDialog,
MAT_DIALOG_DATA,
@@ -567,6 +574,28 @@ export class ClientKycStepComponent implements OnInit, OnChanges, OnDestroy {
/** Existing signature document ID (for re-upload/delete) */
existingSignatureDocId: string = '';
/**
* Cross-field check for the OVD "Document Number" field (kycDocumentNumber): when Aadhaar Card
* is the selected KYC document, its value must identify the same Aadhaar as the aadhaarNumber
* field captured earlier in this step - the last 4 digits are what's compared, since
* aadhaarNumber may hold either the raw 12-digit number or a masked value (masking only ever
* obscures the first 8 digits - see aadhaarMasked in the kycData getter below - so the last 4
* characters are always the real digits either way). Not applicable to any other document type,
* where kycDocumentNumber is unrelated to Aadhaar entirely.
*/
private aadhaarLast4MatchValidator = (control: AbstractControl): ValidationErrors | null => {
if (this.selectedKycDoc?.code !== 'AADHAAR') {
return null;
}
const docNumber = (control.value || '').toString().trim();
const aadhaarValue = (control.parent?.get('aadhaarNumber')?.value || '').toString().trim();
if (docNumber.length < 4 || aadhaarValue.length < 4) {
// Required/pattern validators already flag an empty or too-short value on their own.
return null;
}
return docNumber.slice(-4) === aadhaarValue.slice(-4) ? null : { aadhaarLast4Mismatch: true };
};
/** KYC Form */
kycForm: UntypedFormGroup = this.formBuilder.group({
hasPan: [true],
@@ -616,7 +645,8 @@ export class ClientKycStepComponent implements OnInit, OnChanges, OnDestroy {
[
Validators.required,
Validators.pattern(/^[A-Za-z0-9\-\/\s]+$/),
Validators.maxLength(30)
Validators.maxLength(30),
this.aadhaarLast4MatchValidator
]
],
kycDocIssueDate: [''],
@@ -739,6 +769,14 @@ export class ClientKycStepComponent implements OnInit, OnChanges, OnDestroy {
this.kycForm.get('ecgcSalFlag')?.disable();
this.kycForm.get('dormantFlag')?.disable();
}
// aadhaarLast4MatchValidator (on kycDocumentNumber) reads this control's value directly rather
// than through its own valueChanges, so a re-check has to be triggered explicitly whenever the
// Aadhaar field itself changes - otherwise fixing aadhaarNumber after kycDocumentNumber was
// already entered would leave a stale mismatch error in place.
this.kycForm.get('aadhaarNumber')?.valueChanges.subscribe(() => {
this.kycForm.get('kycDocumentNumber')?.updateValueAndValidity({ onlySelf: true });
});
}
/** Seed the document-type picker from defaults (entity RBI docs vs. individual OVDs,
@@ -1009,6 +1047,9 @@ export class ClientKycStepComponent implements OnInit, OnChanges, OnDestroy {
// Reset uploads + merged preview when document type changes
this.kycDocFiles = { front: null, back: null };
this.kycDocMergedPreview = null;
// aadhaarLast4MatchValidator only applies while Aadhaar Card is selected - re-check so switching
// to/away from it immediately picks up (or clears) the mismatch error.
this.kycForm.get('kycDocumentNumber')?.updateValueAndValidity({ onlySelf: true });
}
isKycDocSelected(proof: ProofType): boolean {
@@ -1189,6 +1230,9 @@ export class ClientKycStepComponent implements OnInit, OnChanges, OnDestroy {
// Sync chip-picker selection with the prefilled document name
if (data.kycDocumentName) {
this.selectedKycDoc = this.findKycDocProofByName(data.kycDocumentName);
// patchValue above ran kycDocumentNumber's validators before selectedKycDoc was known,
// so aadhaarLast4MatchValidator needs a fresh check now that it is.
this.kycForm.get('kycDocumentNumber')?.updateValueAndValidity({ onlySelf: true });
}
// Set verification statuses from existing data
if (data.panVerified) {
@@ -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>
@@ -54,8 +54,8 @@
<mat-icon>account_balance</mat-icon>
</div>
<div class="bank-name-block">
<div class="bank-title">LincPay Co-operative Bank Ltd.</div>
<div class="bank-subtitle">Current Account Statement</div>
<div class="bank-title">{{ bankName }}</div>
<div class="bank-subtitle">{{ bankSubtitle }}</div>
</div>
</div>
<div class="statement-label">
@@ -64,6 +64,25 @@
</div>
</div>
<!-- Branch line — IFSC/MICR are how a UCB customer identifies which branch issued this, and are
needed for NEFT/RTGS/cheque printing, so a real passbook always carries them. -->
@if (branchName || ifscCode || micrCode) {
<div class="branch-line">
@if (branchName) {
<span>{{ branchName }} Branch</span>
}
@if (branchAddress) {
<span>{{ branchAddress }}</span>
}
@if (ifscCode) {
<span>IFSC: {{ ifscCode }}</span>
}
@if (micrCode) {
<span>MICR: {{ micrCode }}</span>
}
</div>
}
<!-- Account Details -->
<div class="account-details-band">
<div class="account-details-grid">
@@ -127,6 +127,18 @@ $header-text: #fff;
}
}
// ─── Branch Identification Line ──────────────────────────────────────────────
.branch-line {
background: #e8edf5;
border-bottom: 1px solid $border-color;
padding: 6px 24px;
display: flex;
flex-wrap: wrap;
gap: 4px 18px;
font-size: 11px;
color: #4a5a7a;
}
// ─── Account Details Band ────────────────────────────────────────────────────
.account-details-band {
background: #f0f3f9;
@@ -11,11 +11,12 @@ import { Component, OnInit, OnDestroy, inject, ElementRef, ViewChild } from '@an
import { ActivatedRoute } from '@angular/router';
import { UntypedFormBuilder } from '@angular/forms';
import { CurrencyPipe } from '@angular/common';
import { Subject, takeUntil } from 'rxjs';
import { Subject, takeUntil, catchError, forkJoin, of } from 'rxjs';
/** Custom Services */
import { SettingsService } from 'app/settings/settings.service';
import { Dates } from 'app/core/utils/dates';
import { OrganizationService } from '../../../organization/organization.service';
import { Currency } from 'app/shared/models/general.model';
import {
CurrentAccountTransaction,
@@ -44,6 +45,7 @@ export class PassbookTabComponent implements OnInit, OnDestroy {
private formBuilder = inject(UntypedFormBuilder);
private settingsService = inject(SettingsService);
private dateUtils = inject(Dates);
private organizationService = inject(OrganizationService);
private destroy$ = new Subject<void>();
@@ -60,6 +62,17 @@ export class PassbookTabComponent implements OnInit, OnDestroy {
availableBalance: number = 0;
activatedOnDate: any = null;
/** Bank/branch letterhead — real values from Institution Profile + Branch Profile (Organization
* module) rather than a hardcoded name, so a printed passbook actually identifies which bank and
* branch issued it, the way a real UCB's core banking system does. Falls back to a generic label
* if the profile was never filled in, rather than showing a fabricated bank name. */
bankName = 'Bank';
bankSubtitle = 'Current Account Statement';
branchName = '';
branchAddress = '';
ifscCode = '';
micrCode = '';
/** Transactions */
allTransactions: CurrentAccountTransaction[] = [];
filteredTransactions: CurrentAccountTransaction[] = [];
@@ -92,6 +105,33 @@ export class PassbookTabComponent implements OnInit, OnDestroy {
fromDate: [''],
toDate: ['']
});
this.loadLetterhead();
}
/** Institution Profile is the bank's own single record; Branch Profile is per-office. There is no
* office id on the current account data reaching this tab, and this deployment runs a single
* branch, so the first branch profile on record is the correct one here — a genuinely
* multi-branch deployment would need the account's own office id threaded through instead. */
private loadLetterhead(): void {
forkJoin({
institution: this.organizationService.getInstitutionProfile().pipe(catchError(() => of(null))),
branches: this.organizationService.getBranchProfiles().pipe(catchError(() => of([])))
}).subscribe(({ institution, branches }: any) => {
if (institution) {
this.bankName = institution.passbookHeaderName || institution.bankName || institution.legalName || 'Bank';
this.bankSubtitle = institution.licenseNumber
? `Lic. No. ${institution.licenseNumber}`
: 'Current Account Statement';
}
const branch = Array.isArray(branches) ? branches[0] : null;
if (branch) {
this.branchName = branch.officeName || '';
this.branchAddress = branch.address || '';
this.ifscCode = branch.ifscCode || '';
this.micrCode = branch.micrCode || '';
}
});
}
private computeRunningBalances(transactions: CurrentAccountTransaction[]): CurrentAccountTransaction[] {
@@ -279,6 +319,18 @@ export class PassbookTabComponent implements OnInit, OnDestroy {
margin-top: 3px;
}
/* Branch identification line */
.branch-line {
background: #e8edf5;
border-bottom: 1px solid #d0d7e3;
padding: 5px 20px;
display: flex;
flex-wrap: wrap;
gap: 4px 16px;
font-size: 9.5px;
color: #4a5a7a;
}
/* Account details band */
.account-details-band {
background: #f0f3f9;
@@ -18,7 +18,7 @@
{{
isDashboardRenewalMode
? 'Renew ' + (dashboardRenewalRow?.accountNo ?? 'this matured FD') + '.'
: "Process this Fixed Deposit now -- works whether it's Active or Matured."
: 'Process this matured Fixed Deposit now.'
}}
</p>
</div>
@@ -40,10 +40,10 @@
</div>
} @else {
<form [formGroup]="paymentForm">
@if (isDashboardRenewalMode) {
@if (isMaturedRenewal) {
<p class="pmt__hint">
<fa-icon icon="info-circle" class="m-r-5"></fa-icon>
{{ dashboardRenewalTimingText }}
{{ maturedRenewalTimingText }}
</p>
@if (renewalPreviewLoading) {
@@ -97,7 +97,7 @@
<div class="pmt__stat">
<span class="pmt__stat-label">
{{
isDashboardRenewalMode || !remainderIsApproximate
isMaturedRenewal || !remainderIsApproximate
? ('labels.inputs.Maturity Amount' | translate)
: 'Amount available on the selected date'
}}
@@ -106,7 +106,7 @@
</div>
}
@if (!isDashboardRenewalMode) {
@if (!isMaturedRenewal) {
<mat-form-field appearance="outline" class="pmt__date" (click)="closedOnDatePicker.open()">
<mat-label>{{ 'labels.inputs.Closed On Date' | translate }}</mat-label>
<input
@@ -230,7 +230,7 @@
</mat-form-field>
}
@if (!isDashboardRenewalMode) {
@if (!isMaturedRenewal) {
<mat-form-field appearance="outline">
<mat-label>{{ 'labels.inputs.Note' | translate }}</mat-label>
<textarea matInput formControlName="note" cdkTextareaAutosize cdkAutosizeMinRows="2"></textarea>
@@ -133,29 +133,32 @@ 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.renewalPreviewLoading = true;
this.fixedDepositsService.getFdRenewalPreview(this.data.accountId).subscribe({
next: (preview: any) => {
this.renewalPreview = preview;
this.renewalPreviewLoading = false;
},
// Non-fatal -- the plain timing text above still explains what will happen even if the
// numeric breakdown couldn't be fetched; only the numbers are missing, not the action itself.
error: () => {
this.renewalPreviewLoading = false;
// Fetched every time this dialog opens, not just from the FD Dashboard -- Payment is now only ever
// offered once an FD has Matured (see FixedDepositsButtonsConfiguration), so a plain Payment on it
// is exactly the same "late renewal" scenario the Dashboard's Renew action handles, and needs the
// exact same date-locking/gap-interest treatment, or an FD closed this way instead of from the
// Dashboard would silently skip crediting interest it genuinely earned.
this.renewalPreviewLoading = true;
this.fixedDepositsService.getFdRenewalPreview(this.data.accountId).subscribe({
next: (preview: any) => {
this.renewalPreview = preview;
this.renewalPreviewLoading = false;
if (preview?.isMatured) {
// 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 once matured (see the template), so it can't be moved off this value.
this.paymentForm
.get('closedOnDate')
.setValue(preview.withinGrace ? new Date(preview.maturityDate) : this.maxDate);
this.gapInterestPending = !preview.withinGrace;
}
});
}
},
// Non-fatal -- Confirm still works from here (backend re-derives the same numbers on submit),
// only the numeric preview and its date-locking are unavailable, not the action itself.
error: () => {
this.renewalPreviewLoading = false;
}
});
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
@@ -195,13 +198,19 @@ export class PaymentDialogComponent implements OnInit {
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.`
/** True once the loaded preview confirms this FD has actually Matured -- which, since Payment is
* only ever offered on a Matured account, is every real open of this dialog now, from either entry
* point. Drives the date-locking and gap-interest preview/crediting identically either way. */
get isMaturedRenewal(): boolean {
return !!this.renewalPreview?.isMatured;
}
/** What clicking Confirm is actually about to do -- shown above the (hidden-picker) date once
* matured, since the date itself is no longer visible to explain it. */
get maturedRenewalTimingText(): string {
if (!this.renewalPreview) return '';
return this.renewalPreview.withinGrace
? `Backdated to its original maturity date (${this.renewalPreview.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.`;
}
@@ -0,0 +1,194 @@
<!--
Copyright since 2025 Mifos Initiative
This Source Code Form is subject to the terms of the Mozilla Public
License, v. 2.0. If a copy of the MPL was not distributed with this
file, You can obtain one at http://mozilla.org/MPL/2.0/.
-->
<div class="pcd">
<div class="pcd__header">
<div class="pcd__header-left">
<span class="pcd__header-ico"><fa-icon icon="times-circle"></fa-icon></span>
<div class="pcd__header-text">
<h2 class="pcd__title">{{ 'labels.heading.Premature Close' | translate }}</h2>
<p class="pcd__subtitle">
Close {{ data.accountNo ?? 'this account' }} before maturity -- interest is recalculated as of the date below,
usually at a lower, penalty rate.
</p>
</div>
</div>
<button type="button" class="pcd__header-close" mat-dialog-close [disabled]="submitting" title="Close">
<fa-icon icon="times"></fa-icon>
</button>
</div>
<div mat-dialog-content class="pcd__content">
@if (loadFailed) {
<div class="pcd__error" role="alert">
<fa-icon icon="times-circle" class="m-r-10"></fa-icon>
Could not calculate the premature-closure amount for this account. Close this dialog and try again.
</div>
} @else {
<form [formGroup]="prematureCloseForm">
<mat-form-field appearance="outline" class="pcd__date" (click)="closedOnDatePicker.open()">
<mat-label>{{ 'labels.inputs.Premature Close Date' | translate }}</mat-label>
<input
matInput
[min]="minDate"
[max]="maxDate"
[matDatepicker]="closedOnDatePicker"
required
formControlName="closedOnDate"
/>
<mat-datepicker-toggle matSuffix [for]="closedOnDatePicker"></mat-datepicker-toggle>
<mat-datepicker #closedOnDatePicker></mat-datepicker>
@if (prematureCloseForm.controls.closedOnDate.hasError('required')) {
<mat-error>
{{ 'labels.inputs.Premature Close Date' | translate }} {{ 'labels.commons.is' | translate }}
<strong>{{ 'labels.commons.required' | translate }}</strong>
</mat-error>
}
</mat-form-field>
@if (loadingAmount) {
<div class="pcd__loading">
<mat-spinner diameter="28"></mat-spinner>
<span>Calculating premature-closure amount...</span>
</div>
} @else {
@if (maturityAmount !== null && maturityAmount !== undefined) {
<div class="pcd__preview">
@if (data.depositAmount) {
<div class="pcd__preview-row">
<span class="pcd__preview-label">Deposit Amount</span>
<span class="pcd__preview-value">{{ data.depositAmount | formatNumber }}</span>
</div>
}
@if (data.nominalAnnualInterestRate) {
<div class="pcd__preview-row">
<span class="pcd__preview-label">Contracted Interest Rate</span>
<span class="pcd__preview-value">{{ data.nominalAnnualInterestRate }}%</span>
</div>
}
@if (data.preClosurePenalApplicable) {
<div class="pcd__preview-row">
<span class="pcd__preview-label"
>Pre-Closure Penalty (on {{ data.preClosurePenalInterestOnType?.value }})</span
>
<span class="pcd__preview-value">&minus; {{ data.preClosurePenalInterest }}%</span>
</div>
}
@if (interestEarned !== null) {
<div class="pcd__preview-row pcd__preview-row--highlight">
<span class="pcd__preview-label"
>Interest Earned (to {{ 'labels.inputs.Premature Close Date' | translate }})</span
>
<span class="pcd__preview-value">+ {{ interestEarned | formatNumber }}</span>
</div>
}
<div class="pcd__preview-row pcd__preview-row--total">
<span class="pcd__preview-label">{{ 'labels.inputs.Maturity Amount' | translate }}</span>
<span class="pcd__preview-value">{{ maturityAmount | formatNumber }}</span>
</div>
</div>
}
<div class="pcd__section-label" id="pcd-action-label">{{ 'labels.inputs.Action' | translate }}</div>
<div class="pcd__options" role="radiogroup" aria-labelledby="pcd-action-label">
@for (closureOption of onAccountClosureOptions; track closureOption) {
<button
type="button"
class="pcd__option"
role="radio"
[attr.aria-checked]="prematureCloseForm.get('onAccountClosureId').value === closureOption.id"
[class.pcd__option--active]="prematureCloseForm.get('onAccountClosureId').value === closureOption.id"
(click)="selectClosureType(closureOption.id)"
>
<span class="pcd__option-ico"><fa-icon [icon]="iconFor(closureOption.id)"></fa-icon></span>
<span class="pcd__option-text">
<span class="pcd__option-title">{{ closureOption.value }}</span>
<span class="pcd__option-desc">{{ descriptionFor(closureOption.id) }}</span>
</span>
@if (prematureCloseForm.get('onAccountClosureId').value === closureOption.id) {
<fa-icon icon="check-circle" class="pcd__option-check"></fa-icon>
}
</button>
}
</div>
@if (
prematureCloseForm.controls.onAccountClosureId?.hasError('required') &&
prematureCloseForm.controls.onAccountClosureId?.touched
) {
<div class="pcd__option-error">
{{ 'labels.inputs.Account action' | translate }} {{ 'labels.commons.is' | translate }}
{{ 'labels.commons.required' | translate }}
</div>
}
@if (noDestinationAccounts) {
<p class="pcd__hint pcd__hint--warn" role="alert">
<fa-icon icon="info-circle" class="m-r-5"></fa-icon>
This customer has no active savings account to transfer into. Open one first, or choose an action that
pays out in cash instead.
</p>
}
@if (prematureCloseForm.contains('toSavingsAccountId')) {
<mat-form-field appearance="outline">
<mat-label>{{ 'labels.inputs.Transfer to Savings' | translate }}</mat-label>
<mat-select required formControlName="toSavingsAccountId">
@for (account of savingsAccountsData; track account) {
<mat-option [value]="account.id">
{{ account.accountNo }}
</mat-option>
}
</mat-select>
@if (prematureCloseForm.controls.toSavingsAccountId?.hasError('required')) {
<mat-error>
{{ 'labels.inputs.Transfer to savings account' | translate }} {{ 'labels.commons.is' | translate }}
<strong>{{ 'labels.commons.required' | translate }}</strong>
</mat-error>
}
</mat-form-field>
}
@if (prematureCloseForm.contains('transferDescription')) {
<mat-form-field appearance="outline">
<mat-label>{{ 'labels.inputs.Transfer Description' | translate }}</mat-label>
<input matInput formControlName="transferDescription" />
</mat-form-field>
}
@if (prematureCloseForm.contains('note')) {
<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="pcd__error" role="alert">
<fa-icon icon="times-circle" class="m-r-5"></fa-icon>
{{ submitError }}
</p>
}
</form>
}
</div>
<div mat-dialog-actions align="end" class="pcd__actions">
<button mat-button mat-dialog-close [disabled]="submitting" class="pcd__btn-cancel">
{{ 'labels.buttons.Cancel' | translate }}
</button>
<button
mat-raised-button
class="pcd__btn-submit"
[disabled]="!prematureCloseForm?.valid || submitting || loadFailed || loadingAmount"
(click)="submit()"
>
{{ submitting ? ('labels.buttons.Processing' | translate) : ('labels.buttons.Confirm' | translate) }}
</button>
</div>
</div>
@@ -0,0 +1,388 @@
/**
* Copyright since 2025 Mifos Initiative
*
* This Source Code Form is subject to the terms of the Mozilla Public
* License, v. 2.0. If a copy of the MPL was not distributed with this
* file, You can obtain one at http://mozilla.org/MPL/2.0/.
*/
/**
* Premature Close Dialog Styles
* CBSNexora Core Banking System
*
* Same shape/spacing/typography as the Payment dialog (.pmt__*), in this Danger Zone action's own red
* accent on the header and Confirm button, rather than Payment's blue -- an irreversible, usually
* penalty-bearing action reads differently from a routine one, everywhere else stays identical so the
* two dialogs feel like the same product.
*/
.pcd {
display: flex;
flex-direction: column;
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;
box-shadow: 0 24px 48px -12px rgb(15 23 42 / 25%);
}
.pcd__header {
display: flex;
align-items: center;
justify-content: space-between;
gap: 16px;
padding: 26px 30px;
background: linear-gradient(135deg, #7f1d1d 0%, #b91c1c 55%, #dc2626 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;
}
}
}
.pcd__title {
margin: 0;
font-size: 20px;
font-weight: 800;
color: #fff;
line-height: 1.25;
letter-spacing: -0.01em;
}
.pcd__subtitle {
margin: 5px 0 0;
font-size: 13px;
color: rgb(255 255 255 / 88%);
line-height: 1.4;
}
.pcd__content {
padding: 26px 30px !important;
overflow-y: auto;
flex: 1 1 auto;
background: #fbfcfe;
mat-form-field {
width: 100%;
}
}
.pcd__loading {
display: flex;
align-items: center;
gap: 12px;
color: #64748b;
font-size: 13px;
padding: 14px 0 18px;
}
.pcd__preview {
display: flex;
flex-direction: column;
background: #fff;
border: 1px solid #e2e8f0;
border-radius: 14px;
padding: 4px 18px;
margin: 0 0 18px;
&-row {
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
padding: 11px 0;
border-bottom: 1px solid #f1f5f9;
&:last-child {
border-bottom: none;
}
&--highlight {
.pcd__preview-value {
color: #b91c1c;
font-weight: 800;
}
}
&--total {
margin: 0 -18px;
padding: 14px 18px;
background: linear-gradient(135deg, #fef2f2 0%, #fee2e2 100%);
border-bottom: none;
border-radius: 0 0 12px 12px;
.pcd__preview-label {
color: #b91c1c;
font-weight: 700;
}
.pcd__preview-value {
color: #0f172a;
font-size: 18px;
font-weight: 800;
}
}
}
&-label {
font-size: 12.5px;
color: #64748b;
font-weight: 600;
}
&-value {
font-family: 'JetBrains Mono', 'DM Sans', monospace;
font-size: 14px;
font-weight: 700;
color: #0f172a;
}
}
.pcd__section-label {
font-size: 12px;
font-weight: 700;
text-transform: uppercase;
letter-spacing: 0.06em;
color: #475569;
margin: 10px 0 12px;
}
/* Action Options Grid - 2 Equal Columns */
.pcd__options {
display: grid;
grid-template-columns: repeat(2, 1fr);
gap: 14px;
margin-bottom: 18px;
@media (width <= 560px) {
grid-template-columns: 1fr;
}
}
.pcd__option {
display: flex;
align-items: flex-start;
gap: 14px;
text-align: left;
background: #fff;
border: 1.5px solid #e2e8f0;
border-radius: 14px;
padding: 16px;
cursor: pointer;
font: inherit;
transition: all 0.2s cubic-bezier(0.4, 0, 0.2, 1);
position: relative;
&:hover {
border-color: #fca5a5;
background: #f8fafc;
box-shadow: 0 6px 16px rgb(15 23 42 / 6%);
transform: translateY(-1px);
}
&--active {
border-color: #dc2626;
background: #fef2f2;
box-shadow: 0 0 0 1.5px #dc2626;
.pcd__option-ico {
background: #dc2626;
color: #fff;
border-color: #dc2626;
}
}
&-ico {
width: 38px;
height: 38px;
min-width: 38px;
border-radius: 10px;
background: #f1f5f9;
border: 1px solid #e2e8f0;
color: #dc2626;
display: flex;
align-items: center;
justify-content: center;
font-size: 15px;
flex-shrink: 0;
transition: all 0.2s ease;
}
&-text {
display: flex;
flex-direction: column;
gap: 4px;
flex: 1;
}
&-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: #dc2626;
font-size: 17px;
position: absolute;
top: 14px;
right: 14px;
}
}
.pcd__option-error {
color: #b91c1c;
font-size: 12px;
font-weight: 600;
margin: -10px 0 14px;
}
.pcd__hint {
display: flex;
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;
}
}
.pcd__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;
}
.pcd__actions {
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;
.pcd__btn-cancel {
border-radius: 10px;
font-weight: 600;
color: #475569;
}
.pcd__btn-submit {
border-radius: 10px;
font-weight: 700;
padding: 0 26px;
height: 42px;
background: linear-gradient(135deg, #b91c1c 0%, #dc2626 100%);
color: #fff;
box-shadow: 0 8px 20px rgb(220 38 38 / 30%);
transition: all 0.2s ease;
&:hover:not(:disabled) {
box-shadow: 0 10px 24px rgb(220 38 38 / 40%);
transform: translateY(-1px);
}
&:disabled {
background: #cbd5e1;
box-shadow: none;
color: #64748b;
}
}
}
@@ -0,0 +1,218 @@
/**
* Copyright since 2025 Mifos Initiative
*
* This Source Code Form is subject to the terms of the Mozilla Public
* License, v. 2.0. If a copy of the MPL was not distributed with this
* file, You can obtain one at http://mozilla.org/MPL/2.0/.
*/
/** Angular Imports */
import { Component, OnInit, inject } from '@angular/core';
import {
MAT_DIALOG_DATA,
MatDialogRef,
MatDialogContent,
MatDialogActions,
MatDialogClose
} from '@angular/material/dialog';
import { CdkScrollable } from '@angular/cdk/scrolling';
import { CdkTextareaAutosize } from '@angular/cdk/text-field';
import { FaIconComponent } from '@fortawesome/angular-fontawesome';
import { UntypedFormBuilder, UntypedFormGroup, UntypedFormControl, Validators } from '@angular/forms';
/** Custom Services */
import { FixedDepositsService } from '../../../fixed-deposits.service';
import { SettingsService } from 'app/settings/settings.service';
import { Dates } from 'app/core/utils/dates';
import { FormatNumberPipe } from '../../../../../pipes/format-number.pipe';
import { STANDALONE_SHARED_IMPORTS } from 'app/standalone-shared.module';
/**
* "Premature Close" dialog -- Active-only, no reinvest option (the deposit hasn't matured, so there's
* nothing to renew): pay out in cash, or transfer the pre-maturity balance to a savings account. Same
* `.pcd__*` visual language as the Payment dialog ({@link PaymentDialogComponent}), in this Danger Zone
* action's own red accent rather than Payment's blue, since it's an irreversible, usually
* penalty-bearing action rather than a routine one.
* <p>
* The premature amount is date-dependent (unlike Payment's one-shot template load): every change to
* Closed On Date re-calls `calculatePrematureAmount`, same as the full-page form this replaces did.
*/
@Component({
selector: 'mifosx-premature-close-dialog',
templateUrl: './premature-close-dialog.component.html',
styleUrls: ['./premature-close-dialog.component.scss'],
imports: [
...STANDALONE_SHARED_IMPORTS,
CdkScrollable,
MatDialogContent,
MatDialogActions,
MatDialogClose,
CdkTextareaAutosize,
FaIconComponent,
FormatNumberPipe
]
})
export class PrematureCloseDialogComponent implements OnInit {
/** The `onAccountClosureId` codes Premature Close can offer -- same codes Payment/Close use. */
private static readonly WITHDRAW_DEPOSIT_ID = 100;
private static readonly TRANSFER_TO_SAVINGS_ID = 200;
private static readonly ACTION_ICONS: Record<number, string> = {
[PrematureCloseDialogComponent.WITHDRAW_DEPOSIT_ID]: 'money-bill-wave',
[PrematureCloseDialogComponent.TRANSFER_TO_SAVINGS_ID]: 'wallet'
};
private static readonly ACTION_DESCRIPTIONS: Record<number, string> = {
[PrematureCloseDialogComponent.WITHDRAW_DEPOSIT_ID]: 'Pay out the full balance in cash',
[PrematureCloseDialogComponent.TRANSFER_TO_SAVINGS_ID]: 'Move the full balance to another savings account'
};
dialogRef = inject<MatDialogRef<PrematureCloseDialogComponent>>(MatDialogRef);
data: {
accountId: number | string;
accountNo?: string;
depositAmount?: number;
nominalAnnualInterestRate?: number;
preClosurePenalApplicable?: boolean;
preClosurePenalInterest?: number;
preClosurePenalInterestOnType?: { value: string };
} = inject(MAT_DIALOG_DATA);
private formBuilder = inject(UntypedFormBuilder);
private fixedDepositsService = inject(FixedDepositsService);
private settingsService = inject(SettingsService);
private dateUtils = inject(Dates);
prematureCloseForm: UntypedFormGroup;
onAccountClosureOptions: any[] = [];
savingsAccountsData: any[] = [];
maturityAmount: number = null;
loadingAmount = true;
loadFailed = false;
submitting = false;
submitError: string = null;
minDate = new Date(2000, 0, 1);
maxDate: Date;
ngOnInit(): void {
this.maxDate = this.settingsService.businessDate;
this.prematureCloseForm = this.formBuilder.group({
closedOnDate: [
this.maxDate,
Validators.required
]
});
this.prematureCloseForm
.get('closedOnDate')
.valueChanges.subscribe((date: Date) => this.calculatePrematureAmount(date));
// Same date the form already starts with -- without this the amount/options never load unless the
// maker happens to touch the date picker, which the full-page form this replaces required too.
this.calculatePrematureAmount(this.maxDate);
}
private calculatePrematureAmount(date: Date): void {
this.loadingAmount = true;
this.loadFailed = false;
const locale = this.settingsService.language.code;
const dateFormat = this.settingsService.dateFormat;
const data = {
closedOnDate: this.dateUtils.formatDate(date, dateFormat),
dateFormat,
locale
};
this.fixedDepositsService
.executeFixedDepositsAccountCommand(this.data.accountId as string, 'calculatePrematureAmount', data)
.subscribe({
next: (response: any) => {
this.savingsAccountsData = response.savingsAccounts ?? [];
this.onAccountClosureOptions = response.onAccountClosureOptions ?? [];
this.maturityAmount = response.maturityAmount;
this.loadingAmount = false;
if (!this.prematureCloseForm.contains('onAccountClosureId')) {
this.prematureCloseForm.addControl('onAccountClosureId', new UntypedFormControl('', Validators.required));
this.prematureCloseForm.addControl('note', new UntypedFormControl(''));
this.prematureCloseForm
.get('onAccountClosureId')
.valueChanges.subscribe((id: number) => this.onClosureTypeChange(id));
}
},
// Without this the spinner would run forever on a failed load, leaving Cancel as the only way out.
error: () => {
this.loadingAmount = false;
this.loadFailed = true;
}
});
}
/** Adds/removes the destination-account control to match what the chosen type needs. */
private onClosureTypeChange(id: number): void {
if (id === PrematureCloseDialogComponent.TRANSFER_TO_SAVINGS_ID) {
if (!this.prematureCloseForm.contains('toSavingsAccountId')) {
this.prematureCloseForm.addControl('toSavingsAccountId', new UntypedFormControl('', Validators.required));
this.prematureCloseForm.addControl('transferDescription', new UntypedFormControl(''));
}
} else {
this.prematureCloseForm.removeControl('toSavingsAccountId');
this.prematureCloseForm.removeControl('transferDescription');
}
}
/** The gap between the deposit and the computed maturity amount -- the actual interest this premature
* closure earns, at whatever rate (contracted, or the lower pre-closure-penalty one below) applied. */
get interestEarned(): number | null {
if (this.maturityAmount === null || this.maturityAmount === undefined || !this.data.depositAmount) {
return null;
}
return Number(this.maturityAmount) - Number(this.data.depositAmount);
}
/** True when the chosen action needs a destination account but the customer has none to pick. */
get noDestinationAccounts(): boolean {
return (
!this.loadingAmount &&
this.prematureCloseForm?.contains('toSavingsAccountId') &&
this.savingsAccountsData.length === 0
);
}
iconFor(id: number): string {
return PrematureCloseDialogComponent.ACTION_ICONS[id] ?? 'circle';
}
descriptionFor(id: number): string {
return PrematureCloseDialogComponent.ACTION_DESCRIPTIONS[id] ?? '';
}
selectClosureType(id: number): void {
this.prematureCloseForm.get('onAccountClosureId').setValue(id);
this.prematureCloseForm.get('onAccountClosureId').markAsTouched();
}
submit(): void {
const formValue = { ...this.prematureCloseForm.value };
const locale = this.settingsService.language.code;
const dateFormat = this.settingsService.dateFormat;
if (formValue.closedOnDate instanceof Date) {
formValue.closedOnDate = this.dateUtils.formatDate(formValue.closedOnDate, dateFormat);
}
const data = { ...formValue, dateFormat, locale };
this.submitting = true;
this.submitError = null;
this.fixedDepositsService
.executeFixedDepositsAccountCommand(this.data.accountId as string, 'prematureClose', data)
.subscribe({
next: () => {
this.dialogRef.close({ confirm: true });
},
// Keep the dialog open with the entered values and say what went wrong, rather than leaving a
// permanently disabled Confirm behind whatever modal the global interceptor threw up.
error: (error: any) => {
this.submitting = false;
this.submitError =
error?.error?.defaultUserMessage ??
error?.error?.errors?.[0]?.defaultUserMessage ??
'This account could not be prematurely closed. Please review the details and try again.';
}
});
}
}
@@ -17,6 +17,7 @@ import { CalculateInterestDialogComponent } from './custom-dialogs/calculate-int
import { PostInterestDialogComponent } from './custom-dialogs/post-interest-dialog/post-interest-dialog.component';
import { ToggleWithholdTaxDialogComponent } from './custom-dialogs/toggle-withhold-tax-dialog/toggle-withhold-tax-dialog.component';
import { PaymentDialogComponent } from './custom-dialogs/payment-dialog/payment-dialog.component';
import { PrematureCloseDialogComponent } from './custom-dialogs/premature-close-dialog/premature-close-dialog.component';
import { DepositCertificateDialogComponent } from 'app/shared/deposit-certificate-dialog/deposit-certificate-dialog.component';
/** Custom Button Config. */
@@ -238,7 +239,6 @@ export class FixedDepositAccountViewComponent implements OnInit {
case 'Undo Activation':
case 'Add Charge':
case 'Withdrawn by Client':
case 'Premature Close':
case 'Deposit':
case 'Withdrawal':
this.router.navigate([`actions/${name}`], { relativeTo: this.route });
@@ -267,12 +267,17 @@ export class FixedDepositAccountViewComponent implements OnInit {
case 'Payment':
this.openPaymentDialog();
break;
case 'Premature Close':
this.openPrematureCloseDialog();
break;
}
}
/**
* Opens the Payment action -- unlike Premature Close/Close, works whether the account is Active or
* Matured, and is the only place a Partial Reinvest can be done.
* Opens the Payment action -- only offered once the FD has actually Matured (see
* FixedDepositsButtonsConfiguration), and the only place a Partial Reinvest can be done. Also the
* exact same dialog the FD Dashboard's Renew action opens (see fd-dashboard.component.ts's
* openRenewDialog), just without dashboardRenewal data, so its date picker stays visible.
*/
private openPaymentDialog(): void {
const paymentDialogRef = this.dialog.open(PaymentDialogComponent, {
@@ -293,6 +298,34 @@ export class FixedDepositAccountViewComponent implements OnInit {
});
}
/**
* Opens the Premature Close action -- Active-only, no reinvest option, since the deposit hasn't
* matured yet.
*/
private openPrematureCloseDialog(): void {
const prematureCloseDialogRef = this.dialog.open(PrematureCloseDialogComponent, {
width: '720px',
maxWidth: '95vw',
data: {
accountId: this.fixedDepositsAccountData.id,
accountNo: this.fixedDepositsAccountData.fdAccountNumber || this.fixedDepositsAccountData.accountNo,
// calculatePrematureAmount only ever returns the final maturityAmount, not how it was reached --
// these come from the account's own data (already loaded for this page) so the dialog can show
// the breakdown (deposit, contracted rate, penalty, interest earned) behind that one number.
depositAmount: this.fixedDepositsAccountData.depositAmount,
nominalAnnualInterestRate: this.fixedDepositsAccountData.nominalAnnualInterestRate,
preClosurePenalApplicable: this.fixedDepositsAccountData.preClosurePenalApplicable,
preClosurePenalInterest: this.fixedDepositsAccountData.preClosurePenalInterest,
preClosurePenalInterestOnType: this.fixedDepositsAccountData.preClosurePenalInterestOnType
}
});
prematureCloseDialogRef.afterClosed().subscribe((response: any) => {
if (response?.confirm) {
this.reload();
}
});
}
/**
* Deletes Fixed Deposits Account.
*/
@@ -110,7 +110,18 @@ export class FixedDepositsButtonsConfiguration {
setOptions(status: string) {
switch (status) {
case 'Active':
this.optionArray = [
{
name: 'Post Interest'
},
{
name: 'Add Charge'
}
];
break;
case 'Matured':
// Payment only makes sense once the deposit has actually matured -- an Active FD that needs
// closing early already has Premature Close for that.
this.optionArray = [
{
name: 'Post Interest'
@@ -24,9 +24,6 @@
@if (actions['Add Charge']) {
<mifosx-add-charge-fixed-deposits-account></mifosx-add-charge-fixed-deposits-account>
}
@if (actions['Premature Close']) {
<mifosx-premature-close-fixed-deposits-account></mifosx-premature-close-fixed-deposits-account>
}
@if (actions['Close']) {
<mifosx-close-fixed-deposits-account></mifosx-close-fixed-deposits-account>
}
@@ -15,7 +15,6 @@ import { ActivateFixedDepositsAccountComponent } from './activate-fixed-deposits
import { UndoApprovalFixedDepositsAccountComponent } from './undo-approval-fixed-deposits-account/undo-approval-fixed-deposits-account.component';
import { WithdrawByClientFixedDepositsAccountComponent } from './withdraw-by-client-fixed-deposits-account/withdraw-by-client-fixed-deposits-account.component';
import { AddChargeFixedDepositsAccountComponent } from './add-charge-fixed-deposits-account/add-charge-fixed-deposits-account.component';
import { PrematureCloseFixedDepositsAccountComponent } from './premature-close-fixed-deposits-account/premature-close-fixed-deposits-account.component';
import { CloseFixedDepositsAccountComponent } from './close-fixed-deposits-account/close-fixed-deposits-account.component';
import { FixedDepositsCashTransactionComponent } from './fixed-deposits-cash-transaction/fixed-deposits-cash-transaction.component';
import { STANDALONE_SHARED_IMPORTS } from 'app/standalone-shared.module';
@@ -35,7 +34,6 @@ import { STANDALONE_SHARED_IMPORTS } from 'app/standalone-shared.module';
UndoApprovalFixedDepositsAccountComponent,
WithdrawByClientFixedDepositsAccountComponent,
AddChargeFixedDepositsAccountComponent,
PrematureCloseFixedDepositsAccountComponent,
CloseFixedDepositsAccountComponent,
FixedDepositsCashTransactionComponent
]
@@ -52,7 +50,6 @@ export class FixedDepositsAccountActionsComponent {
'Undo Approval': boolean;
'Undo Activation': boolean;
'Add Charge': boolean;
'Premature Close': boolean;
'Withdrawn by Client': boolean;
Withdrawal: boolean;
Deposit: boolean;
@@ -64,7 +61,6 @@ export class FixedDepositsAccountActionsComponent {
'Undo Approval': false,
'Undo Activation': false,
'Add Charge': false,
'Premature Close': false,
'Withdrawn by Client': false,
Withdrawal: false,
Deposit: false
@@ -1,105 +0,0 @@
<!--
Copyright since 2025 Mifos Initiative
This Source Code Form is subject to the terms of the Mozilla Public
License, v. 2.0. If a copy of the MPL was not distributed with this
file, You can obtain one at http://mozilla.org/MPL/2.0/.
-->
<div class="container">
<mat-card>
<form [formGroup]="prematureCloseAccountForm" (ngSubmit)="submit()">
<mat-card-content>
<div class="layout-column">
<mat-form-field (click)="closedOnDatePicker.open()">
<mat-label>{{ 'labels.inputs.Premature Close Date' | translate }}</mat-label>
<input
matInput
[min]="minDate"
[max]="maxDate"
[matDatepicker]="closedOnDatePicker"
required
formControlName="closedOnDate"
/>
<mat-datepicker-toggle matSuffix [for]="closedOnDatePicker"></mat-datepicker-toggle>
<mat-datepicker #closedOnDatePicker></mat-datepicker>
@if (prematureCloseAccountForm.controls.closedOnDate.hasError('required')) {
<mat-error>
{{ 'labels.inputs.Premature Close Date' | translate }} {{ 'labels.commons.is' | translate }}
<strong>{{ 'labels.commons.required' | translate }}</strong>
</mat-error>
}
</mat-form-field>
@if (prematureCloseAccountForm.controls.maturityAmount) {
<mat-form-field>
<mat-label>{{ 'labels.inputs.Maturity Amount' | translate }}</mat-label>
<input type="number" matInput formControlName="maturityAmount" />
</mat-form-field>
}
@if (prematureCloseAccountForm.contains('onAccountClosureId')) {
<mat-form-field>
<mat-label>{{ 'labels.inputs.Action' | translate }}</mat-label>
<mat-select required formControlName="onAccountClosureId">
@for (closureOption of onAccountClosureOptions; track closureOption) {
<mat-option [value]="closureOption.id">
{{ closureOption.value }}
</mat-option>
}
</mat-select>
@if (prematureCloseAccountForm.controls.onAccountClosureId.hasError('required')) {
<mat-error>
{{ 'labels.inputs.Account action' | translate }} {{ 'labels.commons.is' | translate }}
<strong>{{ 'labels.commons.required' | translate }}</strong>
</mat-error>
}
</mat-form-field>
}
@if (prematureCloseAccountForm.contains('toSavingsAccountId')) {
<mat-form-field>
<mat-label>{{ 'labels.inputs.Transfer to Savings' | translate }}</mat-label>
<mat-select required formControlName="toSavingsAccountId">
@for (account of savingsAccountsData; track account) {
<mat-option [value]="account.id">
{{ account.accountNo }}
</mat-option>
}
</mat-select>
@if (prematureCloseAccountForm.controls.toSavingsAccountId.hasError('required')) {
<mat-error>
{{ 'labels.inputs.Transfer to savings account' | translate }} {{ 'labels.commons.is' | translate }}
<strong>{{ 'labels.commons.required' | translate }}</strong>
</mat-error>
}
</mat-form-field>
}
@if (prematureCloseAccountForm.contains('transferDescription')) {
<mat-form-field>
<mat-label>{{ 'labels.inputs.Transfer Description' | translate }}</mat-label>
<input matInput formControlName="transferDescription" />
</mat-form-field>
}
@if (prematureCloseAccountForm.contains('note')) {
<mat-form-field>
<mat-label>{{ 'labels.inputs.Note' | translate }}</mat-label>
<textarea matInput formControlName="note" cdkTextareaAutosize cdkAutosizeMinRows="2"></textarea>
</mat-form-field>
}
</div>
</mat-card-content>
<mat-card-actions class="layout-row align-center gap-5px responsive-column">
<button type="button" mat-raised-button [routerLink]="['../../']">
{{ 'labels.buttons.Cancel' | translate }}
</button>
<button mat-raised-button color="warn" [disabled]="!prematureCloseAccountForm.valid">
{{ 'labels.buttons.Confirm' | translate }}
</button>
</mat-card-actions>
</form>
</mat-card>
</div>
@@ -1,11 +0,0 @@
/**
* Copyright since 2025 Mifos Initiative
*
* This Source Code Form is subject to the terms of the Mozilla Public
* License, v. 2.0. If a copy of the MPL was not distributed with this
* file, You can obtain one at http://mozilla.org/MPL/2.0/.
*/
.container {
max-width: 37rem;
}
@@ -1,179 +0,0 @@
/**
* Copyright since 2025 Mifos Initiative
*
* This Source Code Form is subject to the terms of the Mozilla Public
* License, v. 2.0. If a copy of the MPL was not distributed with this
* file, You can obtain one at http://mozilla.org/MPL/2.0/.
*/
/** Angular Imports */
import { Component, OnInit, inject } from '@angular/core';
import {
UntypedFormGroup,
UntypedFormBuilder,
Validators,
UntypedFormControl,
ReactiveFormsModule
} from '@angular/forms';
import { ActivatedRoute, Router, RouterLink } from '@angular/router';
/** Custom Services */
import { FixedDepositsService } from '../../fixed-deposits.service';
import { SettingsService } from 'app/settings/settings.service';
import { Dates } from 'app/core/utils/dates';
import { CdkTextareaAutosize } from '@angular/cdk/text-field';
import { STANDALONE_SHARED_IMPORTS } from 'app/standalone-shared.module';
/**
* Premature Close Fixed Deposits Account Component
*/
@Component({
selector: 'mifosx-premature-close-fixed-deposits-account',
templateUrl: './premature-close-fixed-deposits-account.component.html',
styleUrls: ['./premature-close-fixed-deposits-account.component.scss'],
imports: [
...STANDALONE_SHARED_IMPORTS,
CdkTextareaAutosize
]
})
export class PrematureCloseFixedDepositsAccountComponent implements OnInit {
private formBuilder = inject(UntypedFormBuilder);
private fixedDepositsService = inject(FixedDepositsService);
private dateUtils = inject(Dates);
private route = inject(ActivatedRoute);
private router = inject(Router);
private settingsService = inject(SettingsService);
/** Minimum date allowed. */
minDate = new Date(2000, 0, 1);
/** Maximum date allowed. */
maxDate = new Date();
/** Premature Close FD Account form. */
prematureCloseAccountForm: UntypedFormGroup;
/** Savings Account Data */
savingsAccountsData: any;
/** On account Closure Options */
onAccountClosureOptions: any;
/** Fixed Deposits Account Id */
accountId: any;
/** Form submission event */
isSubmitted = false;
/**
* @param {FormBuilder} formBuilder Form Builder
* @param {FixedDepositsService} fixedDepositsService Fixed Deposits Service
* @param {Dates} dateUtils Date Utils
* @param {ActivatedRoute} route Activated Route
* @param {Router} router Router
* @param {SettingsService} settingsService Settings Service
*/
constructor() {
this.accountId = this.route.parent.snapshot.params['fixedDepositAccountId'];
}
/**
* Creates the premature close fd account form.
*/
ngOnInit() {
this.maxDate = this.settingsService.businessDate;
this.createPrematureCloseAccountForm();
this.buildDependencies();
}
/**
* Creates the premature close fd account form.
*/
createPrematureCloseAccountForm() {
this.prematureCloseAccountForm = this.formBuilder.group({
closedOnDate: [
'',
Validators.required
]
});
}
/**
* Subscribes to value changes of parent control.
*/
buildDependencies() {
this.prematureCloseAccountForm.get('closedOnDate').valueChanges.subscribe((value: Date) => {
if (!this.isSubmitted) {
this.calculatePrematureAmount(value);
}
});
}
/**
* Calculates prematue amount based on closed on date.
* @param {Date} date Premature Close Date
*/
calculatePrematureAmount(date: Date) {
const locale = this.settingsService.language.code;
const dateFormat = this.settingsService.dateFormat;
const data = {
closedOnDate: this.dateUtils.formatDate(date, dateFormat),
dateFormat,
locale
};
this.fixedDepositsService
.executeFixedDepositsAccountCommand(this.accountId, 'calculatePrematureAmount', data)
.subscribe((response: any) => {
this.savingsAccountsData = response.savingsAccounts;
this.onAccountClosureOptions = response.onAccountClosureOptions;
this.prematureCloseAccountForm.addControl(
'maturityAmount',
new UntypedFormControl({ value: '', disabled: true })
);
this.prematureCloseAccountForm.addControl(
'onAccountClosureId',
new UntypedFormControl('', Validators.required)
);
this.prematureCloseAccountForm.addControl('note', new UntypedFormControl(''));
this.prematureCloseAccountForm.get('maturityAmount').patchValue(response.maturityAmount);
this.addTransferDetails();
});
}
/**
* Subscribes to value changes of `onAccountClosureId` adds and removes transfer details accordingly.
*/
addTransferDetails() {
this.prematureCloseAccountForm.get('onAccountClosureId').valueChanges.subscribe((id: any) => {
if (id === 200) {
this.prematureCloseAccountForm.addControl(
'toSavingsAccountId',
new UntypedFormControl('', Validators.required)
);
this.prematureCloseAccountForm.addControl('transferDescription', new UntypedFormControl(''));
} else {
this.prematureCloseAccountForm.removeControl('toSavingsAccountId');
this.prematureCloseAccountForm.removeControl('transferDescription');
}
});
}
/**
* Submits the form and premature closes the fd account,
* if successful redirects to the fd account.
*/
submit() {
const prematureCloseAccountFormData = this.prematureCloseAccountForm.value;
this.isSubmitted = true;
const locale = this.settingsService.language.code;
const dateFormat = this.settingsService.dateFormat;
const prevClosedDate: Date = this.prematureCloseAccountForm.value.closedOnDate;
if (prematureCloseAccountFormData.closedOnDate instanceof Date) {
prematureCloseAccountFormData.closedOnDate = this.dateUtils.formatDate(prevClosedDate, dateFormat);
}
const data = {
...prematureCloseAccountFormData,
dateFormat,
locale
};
this.fixedDepositsService
.executeFixedDepositsAccountCommand(this.accountId, 'prematureClose', data)
.subscribe(() => {
this.router.navigate(['../../'], { relativeTo: this.route });
});
}
}
@@ -35,7 +35,6 @@ import { ViewTransactionComponent } from './fixed-deposit-account-view/view-tran
import { WaiveChargeDialogComponent } from './fixed-deposit-account-view/custom-dialogs/waive-charge-dialog/waive-charge-dialog.component';
import { InactivateChargeDialogComponent } from './fixed-deposit-account-view/custom-dialogs/inactivate-charge-dialog/inactivate-charge-dialog.component';
import { AddChargeFixedDepositsAccountComponent } from './fixed-deposits-account-actions/add-charge-fixed-deposits-account/add-charge-fixed-deposits-account.component';
import { PrematureCloseFixedDepositsAccountComponent } from './fixed-deposits-account-actions/premature-close-fixed-deposits-account/premature-close-fixed-deposits-account.component';
import { CloseFixedDepositsAccountComponent } from './fixed-deposits-account-actions/close-fixed-deposits-account/close-fixed-deposits-account.component';
import { CreateFixedDepositAccountComponent } from './create-fixed-deposit-account/create-fixed-deposit-account.component';
import { FixedDepositAccountDetailsStepComponent } from './fixed-deposit-account-stepper/fixed-deposit-account-details-step/fixed-deposit-account-details-step.component';
@@ -78,7 +77,6 @@ import { FixedDepositsCashTransactionComponent } from './fixed-deposits-account-
WaiveChargeDialogComponent,
InactivateChargeDialogComponent,
AddChargeFixedDepositsAccountComponent,
PrematureCloseFixedDepositsAccountComponent,
CloseFixedDepositsAccountComponent,
CreateFixedDepositAccountComponent,
FixedDepositAccountDetailsStepComponent,
+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;
});
});
}
}
@@ -42,6 +42,7 @@ import { AsyncPipe } from '@angular/common';
import { FaIconComponent } from '@fortawesome/angular-fontawesome';
import { MatGridList, MatGridTile } from '@angular/material/grid-list';
import { STANDALONE_SHARED_IMPORTS } from 'app/standalone-shared.module';
import { AlertService } from 'app/core/alert/alert.service';
/**
* Manage Currencies component.
@@ -68,6 +69,7 @@ export class ManageCurrenciesComponent implements OnInit, AfterViewInit, OnDestr
private translateService = inject(TranslateService);
private configurationWizardService = inject(ConfigurationWizardService);
private popoverService = inject(PopoverService);
private alertService = inject(AlertService);
//** Defining PlaceHolders for the search bar */
placeHolderLabel = '';
@@ -164,8 +166,17 @@ export class ManageCurrenciesComponent implements OnInit, AfterViewInit, OnDestr
if (!selectedCurrencyCodes.includes(newCurrency.code)) {
selectedCurrencyCodes.push(newCurrency.code);
this.organizationservice.updateCurrencies(selectedCurrencyCodes).subscribe((response: any) => {
this.selectedCurrencies.push(newCurrency);
this.formRef.resetForm();
if (response?.rollbackTransaction) {
// Parked for checker approval - the currency has not actually been added yet, so the
// local list must not be mutated as if it had.
this.alertService.alert({
type: 'Success',
message: 'Currency configuration update has been submitted for approval.'
});
return;
}
this.selectedCurrencies.push(newCurrency);
if (this.configurationWizardService.showCurrencyForm) {
this.configurationWizardService.showCurrencyForm = false;
this.openDialog();
@@ -187,9 +198,18 @@ export class ManageCurrenciesComponent implements OnInit, AfterViewInit, OnDestr
});
deleteCurrencyDialogRef.afterClosed().subscribe((response: any) => {
if (response.delete) {
this.organizationservice.updateCurrencies(selectedCurrencyCodes).subscribe(() => {
this.selectedCurrencies.splice(index, 1);
this.organizationservice.updateCurrencies(selectedCurrencyCodes).subscribe((updateResponse: any) => {
this.formRef.resetForm();
if (updateResponse?.rollbackTransaction) {
// Parked for checker approval - the currency has not actually been removed yet, so the
// local list must not be mutated as if it had.
this.alertService.alert({
type: 'Success',
message: 'Currency configuration update has been submitted for approval.'
});
return;
}
this.selectedCurrencies.splice(index, 1);
});
}
});
@@ -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();
});
}
@@ -11,6 +11,7 @@ import { UntypedFormBuilder, UntypedFormGroup, Validators, ReactiveFormsModule }
import { ActivatedRoute, Router, RouterLink } from '@angular/router';
import { OrganizationService } from 'app/organization/organization.service';
import { STANDALONE_SHARED_IMPORTS } from 'app/standalone-shared.module';
import { AlertService } from 'app/core/alert/alert.service';
@Component({
selector: 'mifosx-create-fund',
@@ -25,6 +26,7 @@ export class CreateFundComponent implements OnInit {
private formBuilder = inject(UntypedFormBuilder);
private router = inject(Router);
private route = inject(ActivatedRoute);
private alertService = inject(AlertService);
/** Charge form. */
fundForm: UntypedFormGroup;
@@ -49,6 +51,12 @@ export class CreateFundComponent implements OnInit {
submit() {
const payload = this.fundForm.getRawValue();
this.organizationService.createFund(payload).subscribe((response: any) => {
if (response?.rollbackTransaction) {
this.alertService.alert({
type: 'Success',
message: 'Fund creation has been submitted for approval.'
});
}
this.router.navigate(['../'], { relativeTo: this.route });
});
}
@@ -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();
@@ -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>
@@ -301,8 +301,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,7 +7,12 @@
-->
<div class="container m-b-20 layout-row align-end gap-20px">
<button mat-raised-button color="primary" [routerLink]="['create']" *mifosxHasPermission="'ADD_PREFERENCES'">
<button
mat-raised-button
color="primary"
[routerLink]="['create']"
*mifosxHasPermission="'CREATE_ACCOUNTNUMBERFORMAT'"
>
<fa-icon icon="plus" class="m-r-10"></fa-icon>
{{ 'labels.buttons.Create Preference' | translate }}
</button>
@@ -14,6 +14,7 @@ import { Router, ActivatedRoute, RouterLink } from '@angular/router';
/** Custom Services */
import { SystemService } from '../../system.service';
import { STANDALONE_SHARED_IMPORTS } from 'app/standalone-shared.module';
import { AlertService } from 'app/core/alert/alert.service';
/**
* Create Account Number Preference Component.
@@ -31,6 +32,7 @@ export class CreateAccountNumberPreferenceComponent implements OnInit {
private systemService = inject(SystemService);
private route = inject(ActivatedRoute);
private router = inject(Router);
private alertService = inject(AlertService);
/** Account Number Preferences Form */
accountNumberPreferenceForm: UntypedFormGroup;
@@ -96,6 +98,14 @@ export class CreateAccountNumberPreferenceComponent implements OnInit {
accountNumberPreference.prefixType = undefined;
}
this.systemService.createAccountNumberPreference(accountNumberPreference).subscribe((response: any) => {
if (response?.rollbackTransaction) {
this.alertService.alert({
type: 'Success',
message: 'Account number preference creation has been submitted for approval.'
});
this.router.navigate(['../'], { relativeTo: this.route });
return;
}
this.router.navigate(
[
'../',
@@ -14,6 +14,7 @@ import { ActivatedRoute, Router, RouterLink } from '@angular/router';
/** Custom Services */
import { SystemService } from 'app/system/system.service';
import { STANDALONE_SHARED_IMPORTS } from 'app/standalone-shared.module';
import { AlertService } from 'app/core/alert/alert.service';
/**
* Edit Account Number Preference Component.
@@ -31,6 +32,7 @@ export class EditAccountNumberPreferenceComponent implements OnInit {
private formBuilder = inject(UntypedFormBuilder);
private systemService = inject(SystemService);
private router = inject(Router);
private alertService = inject(AlertService);
/** Account Number Preference Form */
accountNumberPreferenceForm: UntypedFormGroup;
@@ -90,6 +92,12 @@ export class EditAccountNumberPreferenceComponent implements OnInit {
this.systemService
.updateAccountNumberPreference(this.accountNumberPreferenceData.id, accountNumberPreferenceValue)
.subscribe((response: any) => {
if (response?.rollbackTransaction) {
this.alertService.alert({
type: 'Success',
message: 'Account number preference update has been submitted for approval.'
});
}
this.router.navigate(['../'], { relativeTo: this.route });
});
}
@@ -19,6 +19,7 @@ import { TranslateService } from '@ngx-translate/core';
import { DeleteDialogComponent } from 'app/shared/delete-dialog/delete-dialog.component';
import { FaIconComponent } from '@fortawesome/angular-fontawesome';
import { STANDALONE_SHARED_IMPORTS } from 'app/standalone-shared.module';
import { AlertService } from 'app/core/alert/alert.service';
/**
* View Account Number Preference Component.
@@ -38,6 +39,7 @@ export class ViewAccountNumberPreferenceComponent {
private router = inject(Router);
private dialog = inject(MatDialog);
private translateService = inject(TranslateService);
private alertService = inject(AlertService);
/** Account Number Preference Data */
accountNumberPreferenceData: any;
@@ -70,9 +72,17 @@ export class ViewAccountNumberPreferenceComponent {
});
deleteAccountNumberPreferenceDialogRef.afterClosed().subscribe((response: any) => {
if (response.delete) {
this.systemService.deleteAccountNumberPreference(this.accountNumberPreferenceData.id).subscribe(() => {
this.router.navigate(['/system/account-number-preferences']);
});
this.systemService
.deleteAccountNumberPreference(this.accountNumberPreferenceData.id)
.subscribe((deleteResponse: any) => {
if (deleteResponse?.rollbackTransaction) {
this.alertService.alert({
type: 'Success',
message: 'Account number preference deletion has been submitted for approval.'
});
}
this.router.navigate(['/system/account-number-preferences']);
});
}
});
}
@@ -17,6 +17,7 @@ import { PopoverService } from '../../../configuration-wizard/popover/popover.se
import { PopoverRef } from '../../../configuration-wizard/popover/popover-ref';
import { ConfigurationWizardService } from '../../../configuration-wizard/configuration-wizard.service';
import { STANDALONE_SHARED_IMPORTS } from 'app/standalone-shared.module';
import { AlertService } from 'app/core/alert/alert.service';
@Component({
selector: 'mifosx-create-code',
@@ -33,6 +34,7 @@ export class CreateCodeComponent implements OnInit, AfterViewInit {
private router = inject(Router);
private configurationWizardService = inject(ConfigurationWizardService);
private popoverService = inject(PopoverService);
private alertService = inject(AlertService);
/** Code form. */
codeForm: UntypedFormGroup;
@@ -67,6 +69,14 @@ export class CreateCodeComponent implements OnInit, AfterViewInit {
*/
submit() {
this.systemService.createCode(this.codeForm.value).subscribe((response: any) => {
if (response?.rollbackTransaction) {
this.alertService.alert({
type: 'Success',
message: 'Code creation has been submitted for approval.'
});
this.router.navigate(['../'], { relativeTo: this.route });
return;
}
if (this.configurationWizardService.showSystemCodesForm) {
this.configurationWizardService.showSystemCodesForm = false;
this.configurationWizardService.showRolesandPermission = true;
@@ -14,6 +14,7 @@ import { ActivatedRoute, Router, RouterLink } from '@angular/router';
/** Custom Services */
import { SystemService } from '../../system.service';
import { STANDALONE_SHARED_IMPORTS } from 'app/standalone-shared.module';
import { AlertService } from 'app/core/alert/alert.service';
/**
* Edit Code Component.
@@ -31,6 +32,7 @@ export class EditCodeComponent implements OnInit {
private systemService = inject(SystemService);
private route = inject(ActivatedRoute);
private router = inject(Router);
private alertService = inject(AlertService);
/** Code Form */
codeForm: UntypedFormGroup;
@@ -75,6 +77,20 @@ export class EditCodeComponent implements OnInit {
*/
submit() {
this.systemService.updateCode(this.codeForm.value, this.codeData.id).subscribe((response: any) => {
if (response?.rollbackTransaction) {
this.alertService.alert({
type: 'Success',
message: 'Code update has been submitted for approval.'
});
this.router.navigate(
[
'../../',
this.codeData.id
],
{ relativeTo: this.route }
);
return;
}
this.router.navigate(
[
'../../',
@@ -33,7 +33,7 @@
mat-stroked-button
color="warn"
(click)="delete()"
*mifosxHasPermission="'DELETE_CODEVALUE'"
*mifosxHasPermission="'DELETE_CODE'"
class="vc-header__btn-delete"
>
<fa-icon icon="trash" class="m-r-10"></fa-icon>
@@ -30,6 +30,7 @@ import { MatCard, MatCardContent } from '@angular/material/card';
import { MatCheckbox } from '@angular/material/checkbox';
import { MatTooltip } from '@angular/material/tooltip';
import { STANDALONE_SHARED_IMPORTS } from 'app/standalone-shared.module';
import { AlertService } from 'app/core/alert/alert.service';
/**
* View Code Component.
@@ -53,6 +54,7 @@ export class ViewCodeComponent implements OnInit {
private formBuilder = inject(UntypedFormBuilder);
private dialog = inject(MatDialog);
private translateService = inject(TranslateService);
private alertService = inject(AlertService);
/** Code Data */
codeData: any;
@@ -152,6 +154,13 @@ export class ViewCodeComponent implements OnInit {
deleteCodeValue(index: number) {
const codeValueId = this.codeValuesData[index].id;
this.systemService.deleteCodeValue(this.codeData.id, codeValueId).subscribe((response: any) => {
if (response?.rollbackTransaction) {
this.alertService.alert({
type: 'Success',
message: 'Code value deletion has been submitted for approval.'
});
return;
}
this.codeValuesData.splice(index, 1);
this.codeValues.removeAt(index);
this.codeValueRowStatus.splice(index, 1);
@@ -177,6 +186,13 @@ export class ViewCodeComponent implements OnInit {
this.systemService
.updateCodeValue(this.codeData.id, this.codeValuesData[index].id, updatedCodeValue)
.subscribe((response: any) => {
if (response?.rollbackTransaction) {
this.alertService.alert({
type: 'Success',
message: 'Code value update has been submitted for approval.'
});
return;
}
this.codeValues.at(index).disable();
this.codeValueRowStatus[index] = 'disabled';
this.codeValues.at(index).markAsPristine();
@@ -192,7 +208,13 @@ export class ViewCodeComponent implements OnInit {
});
deleteCodeDialogRef.afterClosed().subscribe((response: any) => {
if (response.delete) {
this.systemService.deleteCode(this.codeData.id).subscribe(() => {
this.systemService.deleteCode(this.codeData.id).subscribe((deleteResponse: any) => {
if (deleteResponse?.rollbackTransaction) {
this.alertService.alert({
type: 'Success',
message: 'Code deletion has been submitted for approval.'
});
}
this.router.navigate(['/system/codes']);
});
}
@@ -221,6 +243,13 @@ export class ViewCodeComponent implements OnInit {
const newCodeValue: { name: string; description: string; position: string; isActive: boolean } =
this.codeValues.at(index).value;
this.systemService.createCodeValue(this.codeData.id, newCodeValue).subscribe((response: any) => {
if (response?.rollbackTransaction) {
this.alertService.alert({
type: 'Success',
message: 'Code value creation has been submitted for approval.'
});
return;
}
this.codeValues.at(index).disable();
this.codeValueRowStatus[index] = 'disabled';
this.codeValuesData.push({
@@ -12,6 +12,7 @@ import { UntypedFormBuilder, UntypedFormGroup, Validators, ReactiveFormsModule }
import { ActivatedRoute, Router, RouterLink } from '@angular/router';
import { SettingsService } from 'app/settings/settings.service';
import { Dates } from 'app/core/utils/dates';
import { AlertService } from 'app/core/alert/alert.service';
/** Custom Services */
import { SystemService } from '../../../system.service';
@@ -37,6 +38,7 @@ export class EditConfigurationComponent implements OnInit {
private route = inject(ActivatedRoute);
private router = inject(Router);
private dateUtils = inject(Dates);
private alertService = inject(AlertService);
/** Minimum transaction date allowed. */
minDate = new Date(2000, 0, 1);
@@ -122,7 +124,15 @@ export class EditConfigurationComponent implements OnInit {
delete payload.dateValue;
}
this.systemService.updateConfiguration(this.configuration.id, payload).subscribe(() => {
this.systemService.updateConfiguration(this.configuration.id, payload).subscribe((response: any) => {
if (response?.rollbackTransaction) {
this.alertService.alert({
type: 'Success',
message: 'Configuration update has been submitted for approval.'
});
this.router.navigate(['../../'], { relativeTo: this.route });
return;
}
this.router.navigate(['../../'], { relativeTo: this.route });
});
}
@@ -145,15 +145,25 @@ export class GlobalConfigurationsTabComponent implements OnInit, AfterViewInit {
* Enables/Disables respective configuration
*/
toggleStatus(configuration: any) {
this.systemService
.updateConfiguration(configuration.id, { enabled: configuration.enabled })
.subscribe((response: any) => {
configuration.enabled = response.changes.enabled;
if (configuration.name === SettingsService.businessDateConfigName) {
const msg = configuration.enabled ? 'enabled' : 'disabled';
this.alertService.alert({ type: SettingsService.businessDateType + ' Set Config', message: msg });
}
});
const desiredEnabled = configuration.enabled;
this.systemService.updateConfiguration(configuration.id, { enabled: desiredEnabled }).subscribe((response: any) => {
if (response?.rollbackTransaction) {
// Parked for checker approval - the configuration has not actually changed yet, so the
// toggle (already flipped optimistically by ngModel on click) must be reverted, and
// response.changes must not be read since a parked response never contains it.
configuration.enabled = !desiredEnabled;
this.alertService.alert({
type: 'Success',
message: 'Configuration update has been submitted for approval.'
});
return;
}
configuration.enabled = response.changes.enabled;
if (configuration.name === SettingsService.businessDateConfigName) {
const msg = configuration.enabled ? 'enabled' : 'disabled';
this.alertService.alert({ type: SettingsService.businessDateType + ' Set Config', message: msg });
}
});
}
/**
@@ -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 ' '
@@ -45,6 +45,7 @@ import { MatTooltip } from '@angular/material/tooltip';
import { FaIconComponent } from '@fortawesome/angular-fontawesome';
import { DateFormatPipe } from '../../pipes/date-format.pipe';
import { STANDALONE_SHARED_IMPORTS } from 'app/standalone-shared.module';
import { AlertService } from 'app/core/alert/alert.service';
/**
* Entity to Entity Mapping Component
@@ -81,6 +82,7 @@ export class EntityToEntityMappingComponent implements OnInit {
private dialog = inject(MatDialog);
private settingsService = inject(SettingsService);
private translateService = inject(TranslateService);
private alertService = inject(AlertService);
/** Stores entity to entity mapping data */
entityMappings: string[] = [];
@@ -392,6 +394,12 @@ export class EntityToEntityMappingComponent implements OnInit {
newMappingData.dateFormat = dateFormat;
newMappingData.locale = this.settingsService.language.code;
this.systemService.createMapping(this.relId, newMappingData).subscribe((response: any) => {
if (response?.rollbackTransaction) {
this.alertService.alert({
type: 'Success',
message: 'Entity to entity mapping creation has been submitted for approval.'
});
}
this.showFilteredData();
});
}
@@ -413,6 +421,12 @@ export class EntityToEntityMappingComponent implements OnInit {
newMappingData.dateFormat = dateFormat;
newMappingData.locale = this.settingsService.language.code;
this.systemService.editMapping(this.mapIdToEdit, newMappingData).subscribe((response: any) => {
if (response?.rollbackTransaction) {
this.alertService.alert({
type: 'Success',
message: 'Entity to entity mapping update has been submitted for approval.'
});
}
this.showFilteredData();
});
}
@@ -427,7 +441,13 @@ export class EntityToEntityMappingComponent implements OnInit {
});
deleteNoteDialogRef.afterClosed().subscribe((response: any) => {
if (response.delete) {
this.systemService.deleteMapping(id).subscribe(() => {
this.systemService.deleteMapping(id).subscribe((deleteResponse: any) => {
if (deleteResponse?.rollbackTransaction) {
this.alertService.alert({
type: 'Success',
message: 'Entity to entity mapping deletion has been submitted for approval.'
});
}
this.showFilteredData();
});
}
@@ -16,6 +16,7 @@ import { SystemService } from 'app/system/system.service';
import { MatButton, MatIconButton } from '@angular/material/button';
import { FaIconComponent } from '@fortawesome/angular-fontawesome';
import { STANDALONE_SHARED_IMPORTS } from 'app/standalone-shared.module';
import { AlertService } from 'app/core/alert/alert.service';
/**
* Edit Amazon S3 Component.
@@ -35,6 +36,7 @@ export class EditAmazonS3Component implements OnInit {
private systemService = inject(SystemService);
private route = inject(ActivatedRoute);
private router = inject(Router);
private alertService = inject(AlertService);
/** Amazon S3 Configuration data */
amazonS3ConfigurationData: any;
@@ -95,6 +97,12 @@ export class EditAmazonS3Component implements OnInit {
this.systemService
.updateExternalConfiguration('S3', this.amazonS3ConfigurationForm.value)
.subscribe((response: any) => {
if (response?.rollbackTransaction) {
this.alertService.alert({
type: 'Success',
message: 'Amazon S3 configuration update has been submitted for approval.'
});
}
this.router.navigate(['../'], { relativeTo: this.route });
});
}
@@ -17,6 +17,7 @@ import { MatButton, MatIconButton } from '@angular/material/button';
import { FaIconComponent } from '@fortawesome/angular-fontawesome';
import { MatCheckbox } from '@angular/material/checkbox';
import { STANDALONE_SHARED_IMPORTS } from 'app/standalone-shared.module';
import { AlertService } from 'app/core/alert/alert.service';
/**
* Edit Email Configuration Component.
@@ -37,6 +38,7 @@ export class EditEmailComponent implements OnInit {
private systemService = inject(SystemService);
private route = inject(ActivatedRoute);
private router = inject(Router);
private alertService = inject(AlertService);
/** Password input field type. */
passwordInputType: string;
@@ -110,6 +112,12 @@ export class EditEmailComponent implements OnInit {
this.systemService
.updateExternalConfiguration('SMTP', this.emailConfigurationForm.value)
.subscribe((response: any) => {
if (response?.rollbackTransaction) {
this.alertService.alert({
type: 'Success',
message: 'Email configuration update has been submitted for approval.'
});
}
this.router.navigate(['../'], { relativeTo: this.route });
});
}
@@ -14,6 +14,7 @@ import { UntypedFormGroup, UntypedFormBuilder, Validators, ReactiveFormsModule }
/** Custom Services */
import { SystemService } from 'app/system/system.service';
import { STANDALONE_SHARED_IMPORTS } from 'app/standalone-shared.module';
import { AlertService } from 'app/core/alert/alert.service';
/**
* Edit Notification Configuration Component.
@@ -31,6 +32,7 @@ export class EditNotificationComponent implements OnInit {
private systemService = inject(SystemService);
private route = inject(ActivatedRoute);
private router = inject(Router);
private alertService = inject(AlertService);
/** Notification Configuration data */
notificationConfigurationData: any;
@@ -85,6 +87,12 @@ export class EditNotificationComponent implements OnInit {
this.systemService
.updateExternalConfiguration('NOTIFICATION', this.notificationConfigurationForm.value)
.subscribe((response: any) => {
if (response?.rollbackTransaction) {
this.alertService.alert({
type: 'Success',
message: 'Notification configuration update has been submitted for approval.'
});
}
this.router.navigate(['../'], { relativeTo: this.route });
});
}
@@ -14,6 +14,7 @@ import { UntypedFormGroup, UntypedFormBuilder, Validators, ReactiveFormsModule }
/** Custom Services */
import { SystemService } from 'app/system/system.service';
import { STANDALONE_SHARED_IMPORTS } from 'app/standalone-shared.module';
import { AlertService } from 'app/core/alert/alert.service';
/**
* Edit SMS Configuration Component.
@@ -31,6 +32,7 @@ export class EditSMSComponent implements OnInit {
private systemService = inject(SystemService);
private route = inject(ActivatedRoute);
private router = inject(Router);
private alertService = inject(AlertService);
/** SMS Configuration data */
smsConfigurationData: any;
@@ -89,6 +91,12 @@ export class EditSMSComponent implements OnInit {
this.systemService
.updateExternalConfiguration('SMS', this.smsConfigurationForm.value)
.subscribe((response: any) => {
if (response?.rollbackTransaction) {
this.alertService.alert({
type: 'Success',
message: 'SMS configuration update has been submitted for approval.'
});
}
this.router.navigate(['../'], { relativeTo: this.route });
});
}
@@ -48,6 +48,7 @@ import { MatButton, MatIconButton } from '@angular/material/button';
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';
/**
* Create Data Table Component.
@@ -86,6 +87,7 @@ export class CreateDataTableComponent implements OnInit, AfterViewInit {
private configurationWizardService = inject(ConfigurationWizardService);
private popoverService = inject(PopoverService);
private translateService = inject(TranslateService);
private alertService = inject(AlertService);
/** Data Table Form */
dataTableForm: UntypedFormGroup;
@@ -296,6 +298,14 @@ export class CreateDataTableComponent implements OnInit, AfterViewInit {
delete payload.entitySubType;
}
this.systemService.createDataTable(payload).subscribe((response: any) => {
if (response?.rollbackTransaction) {
this.alertService.alert({
type: 'Success',
message: 'Data table creation has been submitted for approval.'
});
this.router.navigate(['../'], { relativeTo: this.route });
return;
}
if (this.configurationWizardService.showDatatablesForm) {
this.configurationWizardService.showDatatablesForm = false;
this.openDialog();
@@ -42,6 +42,7 @@ import { MatButton, MatIconButton } from '@angular/material/button';
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';
/**
* Edit Data Table Component.
@@ -77,6 +78,7 @@ export class EditDataTableComponent implements OnInit {
private router = inject(Router);
private dialog = inject(MatDialog);
private translateService = inject(TranslateService);
private alertService = inject(AlertService);
/** Data Table Form. */
dataTableForm: UntypedFormGroup;
@@ -471,6 +473,12 @@ export class EditDataTableComponent implements OnInit {
this.systemService
.updateDataTable(this.dataTableChangesData, this.dataTableData.registeredTableName)
.subscribe((response: any) => {
if (response?.rollbackTransaction) {
this.alertService.alert({
type: 'Success',
message: 'Data table update has been submitted for approval.'
});
}
this.router.navigate(['../'], { relativeTo: this.route });
});
}
@@ -36,6 +36,7 @@ import { DeleteDialogComponent } from 'app/shared/delete-dialog/delete-dialog.co
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';
/**
* View Data Table Component
@@ -69,6 +70,7 @@ export class ViewDataTableComponent implements OnInit {
private router = inject(Router);
private dialog = inject(MatDialog);
private translateService = inject(TranslateService);
private alertService = inject(AlertService);
public datatables = inject(Datatables);
/** Data Table Data */
@@ -145,7 +147,13 @@ export class ViewDataTableComponent implements OnInit {
});
deleteDataTableDialogRef.afterClosed().subscribe((response: any) => {
if (response.delete) {
this.systemService.deleteDataTable(this.dataTableData.registeredTableName).subscribe(() => {
this.systemService.deleteDataTable(this.dataTableData.registeredTableName).subscribe((deleteResponse: any) => {
if (deleteResponse?.rollbackTransaction) {
this.alertService.alert({
type: 'Success',
message: 'Data table deletion has been submitted for approval.'
});
}
this.router.navigate(['/system/data-tables'], { relativeTo: this.route });
});
}
@@ -38,6 +38,7 @@ import { MatCheckbox } from '@angular/material/checkbox';
import { MatButton, MatIconButton } from '@angular/material/button';
import { FaIconComponent } from '@fortawesome/angular-fontawesome';
import { STANDALONE_SHARED_IMPORTS } from 'app/standalone-shared.module';
import { AlertService } from 'app/core/alert/alert.service';
/**
* Create Hook Component.
@@ -73,6 +74,7 @@ export class CreateHookComponent implements OnInit {
private formBuilder = inject(UntypedFormBuilder);
private dialog = inject(MatDialog);
private translateService = inject(TranslateService);
private alertService = inject(AlertService);
/** Hooks Template Data. */
hooksTemplateData: any;
@@ -253,6 +255,14 @@ export class CreateHookComponent implements OnInit {
}
};
this.systemService.createHook(hook).subscribe((response: any) => {
if (response?.rollbackTransaction) {
this.alertService.alert({
type: 'Success',
message: 'Hook creation has been submitted for approval.'
});
this.router.navigate(['../'], { relativeTo: this.route });
return;
}
this.router.navigate(
[
'../',
@@ -44,6 +44,7 @@ import { MatDivider } from '@angular/material/divider';
import { MatButton, MatIconButton } from '@angular/material/button';
import { FaIconComponent } from '@fortawesome/angular-fontawesome';
import { STANDALONE_SHARED_IMPORTS } from 'app/standalone-shared.module';
import { AlertService } from 'app/core/alert/alert.service';
/**
* Edit Hook Component.
@@ -83,6 +84,7 @@ export class EditHookComponent implements OnInit {
private translateService = inject(TranslateService);
private snackBar = inject(MatSnackBar);
private cdr = inject(ChangeDetectorRef);
private alertService = inject(AlertService);
@NgModule({
imports: [
@@ -286,6 +288,12 @@ export class EditHookComponent implements OnInit {
}
};
this.systemService.updateHook(this.hookData.id, hook).subscribe((response: any) => {
if (response?.rollbackTransaction) {
this.alertService.alert({
type: 'Success',
message: 'Hook update has been submitted for approval.'
});
}
this.router.navigate(['../'], { relativeTo: this.route });
});
}
@@ -20,6 +20,7 @@ import { SystemService } from '../../system.service';
import { FaIconComponent } from '@fortawesome/angular-fontawesome';
import { DateFormatPipe } from '../../../pipes/date-format.pipe';
import { STANDALONE_SHARED_IMPORTS } from 'app/standalone-shared.module';
import { AlertService } from 'app/core/alert/alert.service';
/**
* View Hook Component.
@@ -40,6 +41,7 @@ export class ViewHookComponent {
private systemService = inject(SystemService);
private router = inject(Router);
private translateService = inject(TranslateService);
private alertService = inject(AlertService);
/** Hook Data. */
hookData: any;
@@ -67,7 +69,13 @@ export class ViewHookComponent {
});
deleteHookDialogRef.afterClosed().subscribe((response: any) => {
if (response.delete) {
this.systemService.deleteHook(this.hookData.id).subscribe(() => {
this.systemService.deleteHook(this.hookData.id).subscribe((deleteResponse: any) => {
if (deleteResponse?.rollbackTransaction) {
this.alertService.alert({
type: 'Success',
message: 'Hook deletion has been submitted for approval.'
});
}
this.router.navigate(['/system/hooks']);
});
}
@@ -33,6 +33,7 @@ import { FaIconComponent } from '@fortawesome/angular-fontawesome';
import { MatTooltip } from '@angular/material/tooltip';
import { WorkflowDiagramComponent } from './workflow-diagram/workflow-diagram.component';
import { STANDALONE_SHARED_IMPORTS } from 'app/standalone-shared.module';
import { AlertService } from 'app/core/alert/alert.service';
export interface JobStep {
stepName: string;
@@ -68,6 +69,7 @@ export class WorkflowJobsComponent implements OnInit {
private systemService = inject(SystemService);
dialog = inject(MatDialog);
private translateService = inject(TranslateService);
private alertService = inject(AlertService);
stepOrderHasChanged = false;
@@ -209,8 +211,14 @@ export class WorkflowJobsComponent implements OnInit {
this.systemService
.putWorkflowJobSteps(this.jobStepName, payload)
.toPromise()
.then((data) => {
.then((data: any) => {
this.stepOrderHasChanged = false;
if (data?.rollbackTransaction) {
this.alertService.alert({
type: 'Success',
message: 'Workflow step order update has been submitted for approval.'
});
}
});
}
}
@@ -39,6 +39,7 @@ import { CdkTextareaAutosize } from '@angular/cdk/text-field';
import { MatButton, MatIconButton } from '@angular/material/button';
import { FaIconComponent } from '@fortawesome/angular-fontawesome';
import { STANDALONE_SHARED_IMPORTS } from 'app/standalone-shared.module';
import { AlertService } from 'app/core/alert/alert.service';
/**
* Create Report Component.
@@ -75,6 +76,7 @@ export class CreateReportComponent implements OnInit {
private router = inject(Router);
private dialog = inject(MatDialog);
private translateServices = inject(TranslateService);
private alertService = inject(AlertService);
/** Report Form. */
reportForm: UntypedFormGroup;
@@ -267,7 +269,14 @@ export class CreateReportComponent implements OnInit {
return reportParameter;
});
this.systemService.createReport(this.reportForm.value).subscribe((response: any) => {
// TODO: Implement Maker Checker Component.
if (response?.rollbackTransaction) {
this.alertService.alert({
type: 'Success',
message: 'Report creation has been submitted for approval.'
});
this.router.navigate(['../'], { relativeTo: this.route });
return;
}
this.router.navigate(
[
'../',
@@ -38,6 +38,7 @@ import { CdkTextareaAutosize } from '@angular/cdk/text-field';
import { MatButton, MatIconButton } from '@angular/material/button';
import { FaIconComponent } from '@fortawesome/angular-fontawesome';
import { STANDALONE_SHARED_IMPORTS } from 'app/standalone-shared.module';
import { AlertService } from 'app/core/alert/alert.service';
/**
* Edit Report Component.
@@ -73,6 +74,7 @@ export class EditReportComponent implements OnInit {
private router = inject(Router);
private systemService = inject(SystemService);
private dialog = inject(MatDialog);
private alertService = inject(AlertService);
/** Report Data. */
reportData: any;
@@ -292,7 +294,13 @@ export class EditReportComponent implements OnInit {
return reportParameter;
});
}
this.systemService.updateReport(this.reportData.id, this.reportForm.value).subscribe(() => {
this.systemService.updateReport(this.reportData.id, this.reportForm.value).subscribe((response: any) => {
if (response?.rollbackTransaction) {
this.alertService.alert({
type: 'Success',
message: 'Report update has been submitted for approval.'
});
}
this.router.navigate(['../'], { relativeTo: this.route });
});
}
@@ -20,6 +20,7 @@ import { FaIconComponent } from '@fortawesome/angular-fontawesome';
import { MatCard, MatCardTitle, MatCardContent } from '@angular/material/card';
import { YesnoPipe } from '../../../pipes/yesno.pipe';
import { STANDALONE_SHARED_IMPORTS } from 'app/standalone-shared.module';
import { AlertService } from 'app/core/alert/alert.service';
/**
* View Report Component.
@@ -40,6 +41,7 @@ export class ViewReportComponent {
private systemService = inject(SystemService);
private dialog = inject(MatDialog);
private router = inject(Router);
private alertService = inject(AlertService);
/** Report Data. */
reportData: any;
@@ -66,7 +68,13 @@ export class ViewReportComponent {
});
deleteReportDialogRef.afterClosed().subscribe((response: any) => {
if (response.delete) {
this.systemService.deleteReport(this.reportData.id).subscribe(() => {
this.systemService.deleteReport(this.reportData.id).subscribe((deleteResponse: any) => {
if (deleteResponse?.rollbackTransaction) {
this.alertService.alert({
type: 'Success',
message: 'Report deletion has been submitted for approval.'
});
}
this.router.navigate(['/system/reports']);
});
}
@@ -30,6 +30,7 @@ import { FaIconComponent } from '@fortawesome/angular-fontawesome';
import { MatDivider } from '@angular/material/divider';
import { MatTooltip } from '@angular/material/tooltip';
import { STANDALONE_SHARED_IMPORTS } from 'app/standalone-shared.module';
import { AlertService } from 'app/core/alert/alert.service';
/**
* Create survey component.
@@ -55,6 +56,7 @@ export class CreateSurveyComponent implements OnInit {
private route = inject(ActivatedRoute);
private router = inject(Router);
dialog = inject(MatDialog);
private alertService = inject(AlertService);
/** Survey form. */
surveyForm: UntypedFormGroup;
@@ -244,6 +246,12 @@ export class CreateSurveyComponent implements OnInit {
countryCode: this.surveyForm.value.countryCode.toUpperCase()
});
this.systemService.createSurvey(this.surveyForm.value).subscribe((response: any) => {
if (response?.rollbackTransaction) {
this.alertService.alert({
type: 'Success',
message: 'Survey creation has been submitted for approval.'
});
}
this.router.navigate(['../'], { relativeTo: this.route });
});
}
@@ -34,6 +34,7 @@ import { FaIconComponent } from '@fortawesome/angular-fontawesome';
import { MatDivider } from '@angular/material/divider';
import { MatTooltip } from '@angular/material/tooltip';
import { STANDALONE_SHARED_IMPORTS } from 'app/standalone-shared.module';
import { AlertService } from 'app/core/alert/alert.service';
/**
* Edit survey component.
@@ -59,6 +60,7 @@ export class EditSurveyComponent {
private route = inject(ActivatedRoute);
private router = inject(Router);
dialog = inject(MatDialog);
private alertService = inject(AlertService);
/** Survey form. */
surveyForm: UntypedFormGroup;
@@ -299,6 +301,12 @@ export class EditSurveyComponent {
this.systemService
.editSurvey(this.route.snapshot.paramMap.get('id'), this.surveyForm.value)
.subscribe((response: any) => {
if (response?.rollbackTransaction) {
this.alertService.alert({
type: 'Success',
message: 'Survey update has been submitted for approval.'
});
}
this.router.navigate(['../'], { relativeTo: this.route });
});
}
@@ -30,6 +30,7 @@ import { SystemService } from '../system.service';
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';
/**
* Manage Surveys component.
@@ -60,6 +61,7 @@ import { STANDALONE_SHARED_IMPORTS } from 'app/standalone-shared.module';
export class ManageSurveysComponent implements OnInit {
private route = inject(ActivatedRoute);
private systemService = inject(SystemService);
private alertService = inject(AlertService);
/* Surveys data */
surveysData: any;
@@ -139,7 +141,14 @@ export class ManageSurveysComponent implements OnInit {
* @param {any} survey Survey to activate.
*/
activate(survey: any) {
this.systemService.activateSurvey(survey.id).subscribe(() => {
this.systemService.activateSurvey(survey.id).subscribe((response: any) => {
if (response?.rollbackTransaction) {
this.alertService.alert({
type: 'Success',
message: 'Survey activation has been submitted for approval.'
});
return;
}
const today = new Date().toISOString().split('T')[0];
// This mimics the server-side logic
survey.validFrom = today;
@@ -152,7 +161,14 @@ export class ManageSurveysComponent implements OnInit {
* @param {any} survey Survey to deactivate.
*/
deactivate(survey: any) {
this.systemService.deactivateSurvey(survey.id).subscribe(() => {
this.systemService.deactivateSurvey(survey.id).subscribe((response: any) => {
if (response?.rollbackTransaction) {
this.alertService.alert({
type: 'Success',
message: 'Survey deactivation has been submitted for approval.'
});
return;
}
const date = new Date();
date.setDate(date.getDate() - 1); // Set to yesterday
const yesterday = date.toISOString().split('T')[0];
@@ -15,6 +15,7 @@ import { forkJoin } from 'rxjs';
import { UserAgreementService, UserAgreement } from './user-agreement.service';
import { ConfirmationDialogComponent } from 'app/shared/confirmation-dialog/confirmation-dialog.component';
import { HasPermissionDirective } from 'app/directives/has-permission/has-permission.directive';
import { AlertService } from 'app/core/alert/alert.service';
@Component({
selector: 'mifosx-manage-user-agreement',
@@ -34,6 +35,7 @@ export class ManageUserAgreementComponent implements OnInit {
private service = inject(UserAgreementService);
private dialog = inject(MatDialog);
private snackBar = inject(MatSnackBar);
private alertService = inject(AlertService);
agreements: UserAgreement[] = [];
activeId: number | null = null;
@@ -118,10 +120,18 @@ export class ManageUserAgreementComponent implements OnInit {
body: this.model.body
})
.subscribe({
next: () => {
this.snackBar.open('Agreement published', 'Close', { duration: 3000 });
next: (response: any) => {
this.saving = false;
this.drawerOpen = false;
if (response?.rollbackTransaction) {
this.alertService.alert({
type: 'Success',
message: 'Agreement publication has been submitted for approval.'
});
this.load();
return;
}
this.snackBar.open('Agreement published', 'Close', { duration: 3000 });
this.load();
},
error: (err: any) => {
@@ -35,7 +35,7 @@ export class UserAgreementService {
}
/** Publish a new version. The backend deactivates the previous active row automatically. */
create(data: { title: string; body: string }): Observable<{ resourceId: number }> {
return this.http.post<{ resourceId: number }>('/useragreements', data);
create(data: { title: string; body: string }): Observable<{ resourceId: number; rollbackTransaction?: boolean }> {
return this.http.post<{ resourceId: number; rollbackTransaction?: boolean }>('/useragreements', data);
}
}
@@ -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;
@@ -124,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)
}))
@@ -205,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;
}
@@ -280,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
@@ -20,8 +20,8 @@
// Tenants the login screen offers, and which one it starts on. Every identifier here has to exist
// in the tenants table of the tenant-store database, or the platform rejects the request as an
// unknown tenant.
window["env"]["fineractPlatformTenantId"] = 'lincpay';
window["env"]["fineractPlatformTenantIds"] = 'lincpay';
window["env"]["fineractPlatformTenantId"] = 'utkarshbank';
window["env"]["fineractPlatformTenantIds"] = 'utkarshbank';
window['env']['tenantLogoUrl'] = '';
window['env']['tenantLogoUrlDark'] = '';
+2 -2
View File
@@ -19,8 +19,8 @@ export const environment = {
version: env.mifos_x.version,
hash: env.mifos_x.hash,
// For connecting to server running elsewhere update the tenant identifier
fineractPlatformTenantId: loadedEnv['fineractPlatformTenantId'] || 'lincpay',
fineractPlatformTenantIds: loadedEnv['fineractPlatformTenantIds'] || 'lincpay',
fineractPlatformTenantId: loadedEnv['fineractPlatformTenantId'] || 'utkarshbank',
fineractPlatformTenantIds: loadedEnv['fineractPlatformTenantIds'] || 'utkarshbank',
// For connecting to others servers running elsewhere update the base API URL
baseApiUrls:
loadedEnv['fineractApiUrls'] || 'https://demo.mifos.community,https://localhost:8443,' + window.location.origin,
+2 -2
View File
@@ -22,8 +22,8 @@ export const environment = {
version: env.mifos_x.version,
hash: env.mifos_x.hash,
// For connecting to server running elsewhere update the tenant identifier
fineractPlatformTenantId: loadedEnv.fineractPlatformTenantId || 'lincpay',
fineractPlatformTenantIds: loadedEnv.fineractPlatformTenantIds || 'lincpay',
fineractPlatformTenantId: loadedEnv.fineractPlatformTenantId || 'utkarshbank',
fineractPlatformTenantIds: loadedEnv.fineractPlatformTenantIds || 'utkarshbank',
// For connecting to others servers running elsewhere update the base API URL
baseApiUrls:
loadedEnv.fineractApiUrls || 'https://demo.mifos.community,https://localhost:8443,' + window.location.origin,