{{ existing.CLIENT_SIGNATURE.name }}
diff --git a/src/app/clients/clients-view/ckyc-tab/ckyc-tab.component.scss b/src/app/clients/clients-view/ckyc-tab/ckyc-tab.component.scss
index 602d2dd3e..94b6f1336 100644
--- a/src/app/clients/clients-view/ckyc-tab/ckyc-tab.component.scss
+++ b/src/app/clients/clients-view/ckyc-tab/ckyc-tab.component.scss
@@ -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 . */
+.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;
diff --git a/src/app/clients/clients-view/ckyc-tab/ckyc-tab.component.ts b/src/app/clients/clients-view/ckyc-tab/ckyc-tab.component.ts
index 0acbbf2d8..b90ec1d8c 100644
--- a/src/app/clients/clients-view/ckyc-tab/ckyc-tab.component.ts
+++ b/src/app/clients/clients-view/ckyc-tab/ckyc-tab.component.ts
@@ -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 , 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 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();
}
}
diff --git a/src/app/clients/clients-view/kyc-tab/kyc-tab.component.html b/src/app/clients/clients-view/kyc-tab/kyc-tab.component.html
index 44b97a309..98a1c34c5 100644
--- a/src/app/clients/clients-view/kyc-tab/kyc-tab.component.html
+++ b/src/app/clients/clients-view/kyc-tab/kyc-tab.component.html
@@ -1247,9 +1247,7 @@
- No scan is on file for this document. Upload one below.
-
+
No scan is on file for this document. Upload one below.
}
diff --git a/src/app/clients/clients-view/kyc-tab/kyc-tab.component.ts b/src/app/clients/clients-view/kyc-tab/kyc-tab.component.ts
index 84743bb5b..c95341dee 100644
--- a/src/app/clients/clients-view/kyc-tab/kyc-tab.component.ts
+++ b/src/app/clients/clients-view/kyc-tab/kyc-tab.component.ts
@@ -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);
}
diff --git a/src/app/clients/clients.service.ts b/src/app/clients/clients.service.ts
index 7220fbe47..cd08181ce 100644
--- a/src/app/clients/clients.service.ts
+++ b/src/app/clients/clients.service.ts
@@ -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 {
- 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 {
+ return this.http.post(`/clients/${clientId}/documents`, formData);
}
- uploadCkycDocument(clientId: string, formData: FormData): Observable {
- 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 {
+ return this.http.put(`/clients/${clientId}/documents/${documentId}`, formData);
}
/**
diff --git a/src/app/clients/shared/kyc-document-update-dialog/kyc-document-update-dialog.component.html b/src/app/clients/shared/kyc-document-update-dialog/kyc-document-update-dialog.component.html
index 6e646f72a..bf720407f 100644
--- a/src/app/clients/shared/kyc-document-update-dialog/kyc-document-update-dialog.component.html
+++ b/src/app/clients/shared/kyc-document-update-dialog/kyc-document-update-dialog.component.html
@@ -109,13 +109,7 @@
}
-
+
}
diff --git a/src/app/login/login.component.ts b/src/app/login/login.component.ts
index 4bf3298d2..e69f7b89b 100644
--- a/src/app/login/login.component.ts
+++ b/src/app/login/login.component.ts
@@ -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;
diff --git a/src/app/organization/branch-profile/branch-profile.component.ts b/src/app/organization/branch-profile/branch-profile.component.ts
index 628d0cb6b..564d8edd1 100644
--- a/src/app/organization/branch-profile/branch-profile.component.ts
+++ b/src/app/organization/branch-profile/branch-profile.component.ts
@@ -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;
+ });
});
}
}
diff --git a/src/app/organization/currencies/manage-currencies/manage-currencies.component.ts b/src/app/organization/currencies/manage-currencies/manage-currencies.component.ts
index a1564f3b9..f34083525 100644
--- a/src/app/organization/currencies/manage-currencies/manage-currencies.component.ts
+++ b/src/app/organization/currencies/manage-currencies/manage-currencies.component.ts
@@ -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);
});
}
});
diff --git a/src/app/organization/institution-profile/institution-profile.component.ts b/src/app/organization/institution-profile/institution-profile.component.ts
index 2d04f9480..db05e8a75 100644
--- a/src/app/organization/institution-profile/institution-profile.component.ts
+++ b/src/app/organization/institution-profile/institution-profile.component.ts
@@ -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();
});
}
diff --git a/src/app/organization/manage-funds/create-fund/create-fund.component.ts b/src/app/organization/manage-funds/create-fund/create-fund.component.ts
index 4d2c0de59..292d6c132 100644
--- a/src/app/organization/manage-funds/create-fund/create-fund.component.ts
+++ b/src/app/organization/manage-funds/create-fund/create-fund.component.ts
@@ -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 });
});
}
diff --git a/src/app/organization/payment-types/create-payment-type/create-payment-type.component.ts b/src/app/organization/payment-types/create-payment-type/create-payment-type.component.ts
index d16092a72..c3b0d26b6 100644
--- a/src/app/organization/payment-types/create-payment-type/create-payment-type.component.ts
+++ b/src/app/organization/payment-types/create-payment-type/create-payment-type.component.ts
@@ -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 });
});
}
diff --git a/src/app/organization/payment-types/edit-payment-type/edit-payment-type.component.ts b/src/app/organization/payment-types/edit-payment-type/edit-payment-type.component.ts
index 7ec31a1c2..81eda5756 100644
--- a/src/app/organization/payment-types/edit-payment-type/edit-payment-type.component.ts
+++ b/src/app/organization/payment-types/edit-payment-type/edit-payment-type.component.ts
@@ -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 });
});
}
diff --git a/src/app/organization/payment-types/payment-types.component.ts b/src/app/organization/payment-types/payment-types.component.ts
index d883877e1..189f7efa5 100644
--- a/src/app/organization/payment-types/payment-types.component.ts
+++ b/src/app/organization/payment-types/payment-types.component.ts
@@ -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();
+ }
});
}
});
diff --git a/src/app/organization/working-days/working-days.component.ts b/src/app/organization/working-days/working-days.component.ts
index b175dafc1..dc88ae7ab 100644
--- a/src/app/organization/working-days/working-days.component.ts
+++ b/src/app/organization/working-days/working-days.component.ts
@@ -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();
diff --git a/src/app/shares/shares-account-actions/apply-shares/apply-shares.component.html b/src/app/shares/shares-account-actions/apply-shares/apply-shares.component.html
index 0299ae92f..ee9cf450c 100644
--- a/src/app/shares/shares-account-actions/apply-shares/apply-shares.component.html
+++ b/src/app/shares/shares-account-actions/apply-shares/apply-shares.component.html
@@ -226,8 +226,10 @@
Submitting…
} @else {
- check
- Submit Request
+
+ check
+ Submit Request
+
}
diff --git a/src/app/shares/shares-account-view/apply-collection-dialog/apply-collection-dialog.component.html b/src/app/shares/shares-account-view/apply-collection-dialog/apply-collection-dialog.component.html
index 4a91e4673..9657f6992 100644
--- a/src/app/shares/shares-account-view/apply-collection-dialog/apply-collection-dialog.component.html
+++ b/src/app/shares/shares-account-view/apply-collection-dialog/apply-collection-dialog.component.html
@@ -275,8 +275,10 @@
Applying…
} @else {
- check
- Apply Collection
+
+ check
+ Apply Collection
+
}
diff --git a/src/app/sponsor-dd/gl-settings-dialog/gl-settings-dialog.component.ts b/src/app/sponsor-dd/gl-settings-dialog/gl-settings-dialog.component.ts
index 979ab26b0..debedb2fe 100644
--- a/src/app/sponsor-dd/gl-settings-dialog/gl-settings-dialog.component.ts
+++ b/src/app/sponsor-dd/gl-settings-dialog/gl-settings-dialog.component.ts
@@ -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) => {
diff --git a/src/app/sponsor-dd/sponsor-bank-list/sponsor-bank-list.component.ts b/src/app/sponsor-dd/sponsor-bank-list/sponsor-bank-list.component.ts
index 5a55ebe6c..1cf87d78a 100644
--- a/src/app/sponsor-dd/sponsor-bank-list/sponsor-bank-list.component.ts
+++ b/src/app/sponsor-dd/sponsor-bank-list/sponsor-bank-list.component.ts
@@ -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);
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(`/sponsorbanks/${this.data.bank.id}`, this.form.value)
: this.http.post('/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) => {
diff --git a/src/app/system/account-number-preferences/account-number-preferences.component.html b/src/app/system/account-number-preferences/account-number-preferences.component.html
index d16691645..4a234addc 100644
--- a/src/app/system/account-number-preferences/account-number-preferences.component.html
+++ b/src/app/system/account-number-preferences/account-number-preferences.component.html
@@ -7,7 +7,12 @@
-->