diff --git a/package.json b/package.json index 4e0057794..aad5a8105 100644 --- a/package.json +++ b/package.json @@ -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", diff --git a/src/app/clients/clients-view/ckyc-tab/ckyc-tab.component.html b/src/app/clients/clients-view/ckyc-tab/ckyc-tab.component.html index 4f71a5b6d..4beac80ce 100644 --- a/src/app/clients/clients-view/ckyc-tab/ckyc-tab.component.html +++ b/src/app/clients/clients-view/ckyc-tab/ckyc-tab.component.html @@ -202,9 +202,14 @@
Already available from {{ existing.ADDRESS_PROOF.source }} + @if (existing.ADDRESS_PROOF.previewUnavailable) { + stored file unavailable + }
- @if (existing.ADDRESS_PROOF.previewUrl || existing.ADDRESS_PROOF.previewUrlBack) { + @if ( + (existing.ADDRESS_PROOF.previewUrl || existing.ADDRESS_PROOF.previewUrlBack) && isImage('ADDRESS_PROOF') + ) {
@if (existing.ADDRESS_PROOF.previewUrl) { } + @if (existing.ADDRESS_PROOF.previewUrl) { + + }
{{ existing.ADDRESS_PROOF.name }} @@ -299,9 +315,12 @@
Already available from {{ existing.PHOTO_PROOF.source }} + @if (existing.PHOTO_PROOF.previewUnavailable) { + stored file unavailable + }
- @if (existing.PHOTO_PROOF.previewUrl || existing.PHOTO_PROOF.previewUrlBack) { + @if ((existing.PHOTO_PROOF.previewUrl || existing.PHOTO_PROOF.previewUrlBack) && isImage('PHOTO_PROOF')) {
@if (existing.PHOTO_PROOF.previewUrl) { } + @if (existing.PHOTO_PROOF.previewUrl) { + + }
{{ existing.PHOTO_PROOF.name }} @@ -393,11 +423,25 @@
Already available from {{ existing.CLIENT_IMAGE.source }} + @if (existing.CLIENT_IMAGE.previewUnavailable) { + stored file unavailable + }
- @if (existing.CLIENT_IMAGE.previewUrl) { + @if (existing.CLIENT_IMAGE.previewUrl && isImage('CLIENT_IMAGE')) { Client photo } + @if (existing.CLIENT_IMAGE.previewUrl) { + + }
- @if (existing.CLIENT_SIGNATURE.previewUrl) { + @if (existing.CLIENT_SIGNATURE.previewUrl && isImage('CLIENT_SIGNATURE')) { Client signature } + @if (existing.CLIENT_SIGNATURE.previewUrl) { + + }
{{ 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 @@
@if (!docScansLoading && !docScans.front && !docScans.back) { -
- 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 @@ -->
- diff --git a/src/app/system/account-number-preferences/create-account-number-preference/create-account-number-preference.component.ts b/src/app/system/account-number-preferences/create-account-number-preference/create-account-number-preference.component.ts index 31276169b..f217a7e3b 100644 --- a/src/app/system/account-number-preferences/create-account-number-preference/create-account-number-preference.component.ts +++ b/src/app/system/account-number-preferences/create-account-number-preference/create-account-number-preference.component.ts @@ -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( [ '../', diff --git a/src/app/system/account-number-preferences/edit-account-number-preference/edit-account-number-preference.component.ts b/src/app/system/account-number-preferences/edit-account-number-preference/edit-account-number-preference.component.ts index 134eb1134..d29c50971 100644 --- a/src/app/system/account-number-preferences/edit-account-number-preference/edit-account-number-preference.component.ts +++ b/src/app/system/account-number-preferences/edit-account-number-preference/edit-account-number-preference.component.ts @@ -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 }); }); } diff --git a/src/app/system/account-number-preferences/view-account-number-preference/view-account-number-preference.component.ts b/src/app/system/account-number-preferences/view-account-number-preference/view-account-number-preference.component.ts index e67996982..78e7e5743 100644 --- a/src/app/system/account-number-preferences/view-account-number-preference/view-account-number-preference.component.ts +++ b/src/app/system/account-number-preferences/view-account-number-preference/view-account-number-preference.component.ts @@ -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']); + }); } }); } diff --git a/src/app/system/codes/create-code/create-code.component.ts b/src/app/system/codes/create-code/create-code.component.ts index e933d4f34..9ade49035 100644 --- a/src/app/system/codes/create-code/create-code.component.ts +++ b/src/app/system/codes/create-code/create-code.component.ts @@ -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; diff --git a/src/app/system/codes/edit-code/edit-code.component.ts b/src/app/system/codes/edit-code/edit-code.component.ts index 2ec83282a..69c9e94d6 100644 --- a/src/app/system/codes/edit-code/edit-code.component.ts +++ b/src/app/system/codes/edit-code/edit-code.component.ts @@ -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( [ '../../', diff --git a/src/app/system/codes/view-code/view-code.component.html b/src/app/system/codes/view-code/view-code.component.html index 80160fe68..dd029c014 100644 --- a/src/app/system/codes/view-code/view-code.component.html +++ b/src/app/system/codes/view-code/view-code.component.html @@ -33,7 +33,7 @@ mat-stroked-button color="warn" (click)="delete()" - *mifosxHasPermission="'DELETE_CODEVALUE'" + *mifosxHasPermission="'DELETE_CODE'" class="vc-header__btn-delete" > diff --git a/src/app/system/codes/view-code/view-code.component.ts b/src/app/system/codes/view-code/view-code.component.ts index ef05aef74..52b87adf1 100644 --- a/src/app/system/codes/view-code/view-code.component.ts +++ b/src/app/system/codes/view-code/view-code.component.ts @@ -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({ diff --git a/src/app/system/configurations/global-configurations-tab/edit-configuration/edit-configuration.component.ts b/src/app/system/configurations/global-configurations-tab/edit-configuration/edit-configuration.component.ts index 35bb17470..c2b331773 100644 --- a/src/app/system/configurations/global-configurations-tab/edit-configuration/edit-configuration.component.ts +++ b/src/app/system/configurations/global-configurations-tab/edit-configuration/edit-configuration.component.ts @@ -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 }); }); } diff --git a/src/app/system/configurations/global-configurations-tab/global-configurations-tab.component.ts b/src/app/system/configurations/global-configurations-tab/global-configurations-tab.component.ts index 80a29ad45..4af539e36 100644 --- a/src/app/system/configurations/global-configurations-tab/global-configurations-tab.component.ts +++ b/src/app/system/configurations/global-configurations-tab/global-configurations-tab.component.ts @@ -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 }); + } + }); } /** diff --git a/src/app/system/configure-maker-checker-tasks/configure-maker-checker-tasks.component.html b/src/app/system/configure-maker-checker-tasks/configure-maker-checker-tasks.component.html index 66fb9bf28..c2325820b 100644 --- a/src/app/system/configure-maker-checker-tasks/configure-maker-checker-tasks.component.html +++ b/src/app/system/configure-maker-checker-tasks/configure-maker-checker-tasks.component.html @@ -7,6 +7,26 @@ -->
+ + + @if (isSearchActive) { + + } +
- @@ -74,7 +71,9 @@
-
{{ 'labels.inputs.Entity' | translate }}
+ +
{{ 'labels.inputs.Entity Type' | translate }}
{{ checkerInboxDetail.entityName }}
@if (checkerInboxDetail.resourceId) { @@ -108,9 +107,61 @@
- @if (displayJSONData) { + @if (comparing) { +
+ + Comparing against current values… +
+ } + + + @if (showChanges) {
-
{{ 'labels.heading.Command' | translate }}
+
Changes Requested ({{ changes.length }})
+ @if (changes.length === 0) { +

This request does not change any value.

+ } @else { +
+ @for (change of changes; track $index) { +
+
+ {{ change.label }} + {{ + change.kind + }} +
+
+ {{ change.oldValue }} + + {{ change.newValue }} +
+
+ } +
+ } +
+ } + + + @if (!showChanges && createSections.length > 0) { + @for (section of createSections; track section.title) { +
+
{{ section.title }}
+
+ @for (field of section.fields; track $index) { + + } +
+
+ } + } + + + @if (!showChanges && createSections.length === 0 && displayJSONData) { +
+
+ {{ deletingEntity ? 'Record To Be Deleted' : ('labels.heading.Command' | translate) }} +
@for (field of commandFields; track $index) { diff --git a/src/app/tasks/view-checker-inbox/view-checker-inbox.component.scss b/src/app/tasks/view-checker-inbox/view-checker-inbox.component.scss index e1d13e084..9bb961c1c 100644 --- a/src/app/tasks/view-checker-inbox/view-checker-inbox.component.scss +++ b/src/app/tasks/view-checker-inbox/view-checker-inbox.component.scss @@ -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; diff --git a/src/app/tasks/view-checker-inbox/view-checker-inbox.component.ts b/src/app/tasks/view-checker-inbox/view-checker-inbox.component.ts index cfc3cb336..ade448188 100644 --- a/src/app/tasks/view-checker-inbox/view-checker-inbox.component.ts +++ b/src/app/tasks/view-checker-inbox/view-checker-inbox.component.ts @@ -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, 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): 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): void { + this.systemService.getRole(roleId).subscribe({ + next: (role: any) => { + const current = new Map( + (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(); - }); - } - }); - } } diff --git a/src/app/tds-reports/tds-reports-data.ts b/src/app/tds-reports/tds-reports-data.ts index a92ba644a..f64f23644 100644 --- a/src/app/tds-reports/tds-reports-data.ts +++ b/src/app/tds-reports/tds-reports-data.ts @@ -171,7 +171,6 @@ export function tdsDeductees(): TdsDeductee[] { return liveDeductees; } - /** * Statutory date by which tax deducted in a given month must be deposited. *